Merge pull request #200 from bnema/feat/deletion-cleanup-lifecycle
feat(cleanup): reconcile removed runtime entities
29 files · +5128 -41
M .mockery.yaml +1 -0
| @@ -52,3 +52,4 @@ packages: | |||
| 52 | 52 | github.com/bnema/gordon/internal/adapters/in/cli: | |
| 53 | 53 | interfaces: | |
| 54 | 54 | pushImageOps: | |
| 55 | + | ControlPlane: | |
| @@ -52,3 +52,4 @@ packages: | |||||
| 52 | github.com/bnema/gordon/internal/adapters/in/cli: | 52 | github.com/bnema/gordon/internal/adapters/in/cli: | ||
| 53 | interfaces: | 53 | interfaces: | ||
| 54 | pushImageOps: | 54 | pushImageOps: | ||
| 55 | + | ControlPlane: | |||
M docs/cli/attachments.md +67 -0
| @@ -24,6 +24,8 @@ The CLI will warn you if you try to add an attachment without network isolation | |||
| 24 | 24 | | `add` | Add an attachment to a domain or network group | | |
| 25 | 25 | | `push` | Build or push attachment images to the Gordon registry | | |
| 26 | 26 | | `remove` | Remove an attachment from a domain or network group | | |
| 27 | + | | `orphans` | List running attachment containers no longer configured | | |
| 28 | + | | `prune` | Dry-run or stop orphaned attachment containers while preserving volumes | | |
| 27 | 29 | ||
| 28 | 30 | ### Alias | |
| 29 | 31 | ||
| @@ -93,6 +95,71 @@ gordon attachments list --json | |||
| 93 | 95 | ||
| 94 | 96 | --- | |
| 95 | 97 | ||
| 98 | + | ## gordon attachments orphans | |
| 99 | + | ||
| 100 | + | List running attachment containers that are no longer configured. | |
| 101 | + | ||
| 102 | + | ```bash | |
| 103 | + | gordon attachments orphans | |
| 104 | + | gordon attachments orphans --json | |
| 105 | + | ``` | |
| 106 | + | ||
| 107 | + | ### Output | |
| 108 | + | ||
| 109 | + | | Container ID | Name | Image | Status | Owner | | |
| 110 | + | |--------------|------|-------|--------|-------| | |
| 111 | + | | `pg123` | `postgres` | `postgres:16` | `running` | `myapp.example.com` | | |
| 112 | + | ||
| 113 | + | ### JSON Output | |
| 114 | + | ||
| 115 | + | ```json | |
| 116 | + | { | |
| 117 | + | "attachments": [ | |
| 118 | + | { | |
| 119 | + | "container_id": "pg123", | |
| 120 | + | "name": "postgres", | |
| 121 | + | "image": "postgres:16", | |
| 122 | + | "status": "running", | |
| 123 | + | "owner": "myapp.example.com" | |
| 124 | + | } | |
| 125 | + | ] | |
| 126 | + | } | |
| 127 | + | ``` | |
| 128 | + | ||
| 129 | + | ## gordon attachments prune | |
| 130 | + | ||
| 131 | + | Lists orphaned attachments by default (dry-run). Use `--stop` to stop and remove the orphaned containers while preserving attachment volumes/data. | |
| 132 | + | ||
| 133 | + | ```bash | |
| 134 | + | gordon attachments prune # dry-run/report only | |
| 135 | + | gordon attachments prune --stop # stop/remove orphaned containers, preserve volumes | |
| 136 | + | ``` | |
| 137 | + | ||
| 138 | + | ### Output | |
| 139 | + | ||
| 140 | + | ```text | |
| 141 | + | Orphaned attachments | |
| 142 | + | postgres -> postgres:16 | |
| 143 | + | Hint: orphaned attachments preserved; rerun with --stop to stop/remove containers while preserving volumes | |
| 144 | + | ``` | |
| 145 | + | ||
| 146 | + | ### Output with `--stop` | |
| 147 | + | ||
| 148 | + | ```text | |
| 149 | + | Removed orphaned attachment containers: | |
| 150 | + | postgres | |
| 151 | + | Hint: attachment volumes and data were preserved | |
| 152 | + | ``` | |
| 153 | + | ||
| 154 | + | ### Options | |
| 155 | + | ||
| 156 | + | | Option | Description | | |
| 157 | + | |--------|-------------| | |
| 158 | + | | `--stop` | Stop and remove orphaned attachment containers while preserving volumes/data | | |
| 159 | + | | `--json` | Output the cleanup report as JSON | | |
| 160 | + | | `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) | | |
| 161 | + | | `--token` | Authentication token for remote | | |
| 162 | + | ||
| 96 | 163 | ## gordon attachments add | |
| 97 | 164 | ||
| 98 | 165 | Add an attachment to a domain or network group. | |
| @@ -24,6 +24,8 @@ The CLI will warn you if you try to add an attachment without network isolation | |||||
| 24 | | `add` | Add an attachment to a domain or network group | | 24 | | `add` | Add an attachment to a domain or network group | | ||
| 25 | | `push` | Build or push attachment images to the Gordon registry | | 25 | | `push` | Build or push attachment images to the Gordon registry | | ||
| 26 | | `remove` | Remove an attachment from a domain or network group | | 26 | | `remove` | Remove an attachment from a domain or network group | | ||
| 27 | + | | `orphans` | List running attachment containers no longer configured | | |||
| 28 | + | | `prune` | Dry-run or stop orphaned attachment containers while preserving volumes | | |||
| 27 | 29 | ||||
| 28 | ### Alias | 30 | ### Alias | ||
| 29 | 31 | ||||
| @@ -93,6 +95,71 @@ gordon attachments list --json | |||||
| 93 | 95 | ||||
| 94 | --- | 96 | --- | ||
| 95 | 97 | ||||
| 98 | + | ## gordon attachments orphans | |||
| 99 | + | ||||
| 100 | + | List running attachment containers that are no longer configured. | |||
| 101 | + | ||||
| 102 | + | ```bash | |||
| 103 | + | gordon attachments orphans | |||
| 104 | + | gordon attachments orphans --json | |||
| 105 | + | ``` | |||
| 106 | + | ||||
| 107 | + | ### Output | |||
| 108 | + | ||||
| 109 | + | | Container ID | Name | Image | Status | Owner | | |||
| 110 | + | |--------------|------|-------|--------|-------| | |||
| 111 | + | | `pg123` | `postgres` | `postgres:16` | `running` | `myapp.example.com` | | |||
| 112 | + | ||||
| 113 | + | ### JSON Output | |||
| 114 | + | ||||
| 115 | + | ```json | |||
| 116 | + | { | |||
| 117 | + | "attachments": [ | |||
| 118 | + | { | |||
| 119 | + | "container_id": "pg123", | |||
| 120 | + | "name": "postgres", | |||
| 121 | + | "image": "postgres:16", | |||
| 122 | + | "status": "running", | |||
| 123 | + | "owner": "myapp.example.com" | |||
| 124 | + | } | |||
| 125 | + | ] | |||
| 126 | + | } | |||
| 127 | + | ``` | |||
| 128 | + | ||||
| 129 | + | ## gordon attachments prune | |||
| 130 | + | ||||
| 131 | + | Lists orphaned attachments by default (dry-run). Use `--stop` to stop and remove the orphaned containers while preserving attachment volumes/data. | |||
| 132 | + | ||||
| 133 | + | ```bash | |||
| 134 | + | gordon attachments prune # dry-run/report only | |||
| 135 | + | gordon attachments prune --stop # stop/remove orphaned containers, preserve volumes | |||
| 136 | + | ``` | |||
| 137 | + | ||||
| 138 | + | ### Output | |||
| 139 | + | ||||
| 140 | + | ```text | |||
| 141 | + | Orphaned attachments | |||
| 142 | + | postgres -> postgres:16 | |||
| 143 | + | Hint: orphaned attachments preserved; rerun with --stop to stop/remove containers while preserving volumes | |||
| 144 | + | ``` | |||
| 145 | + | ||||
| 146 | + | ### Output with `--stop` | |||
| 147 | + | ||||
| 148 | + | ```text | |||
| 149 | + | Removed orphaned attachment containers: | |||
| 150 | + | postgres | |||
| 151 | + | Hint: attachment volumes and data were preserved | |||
| 152 | + | ``` | |||
| 153 | + | ||||
| 154 | + | ### Options | |||
| 155 | + | ||||
| 156 | + | | Option | Description | | |||
| 157 | + | |--------|-------------| | |||
| 158 | + | | `--stop` | Stop and remove orphaned attachment containers while preserving volumes/data | | |||
| 159 | + | | `--json` | Output the cleanup report as JSON | | |||
| 160 | + | | `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) | | |||
| 161 | + | | `--token` | Authentication token for remote | | |||
| 162 | + | ||||
| 96 | ## gordon attachments add | 163 | ## gordon attachments add | ||
| 97 | 164 | ||||
| 98 | Add an attachment to a domain or network group. | 165 | Add an attachment to a domain or network group. | ||
M docs/cli/routes.md +91 -0
| @@ -275,10 +275,92 @@ gordon routes show myapp.example.com --remote https://gordon.mydomain.com --toke | |||
| 275 | 275 | ||
| 276 | 276 | --- | |
| 277 | 277 | ||
| 278 | + | ## gordon routes diagnose | |
| 279 | + | ||
| 280 | + | Diagnose route configuration, runtime state, preserved volumes, and orphaned attachment containers. | |
| 281 | + | ||
| 282 | + | ```bash | |
| 283 | + | gordon routes diagnose <domain> | |
| 284 | + | gordon routes diagnose myapp.example.com --json | |
| 285 | + | ``` | |
| 286 | + | ||
| 287 | + | Use this after route deletion or failed deployment to see whether runtime state still exists and which safe cleanup commands to run next. | |
| 288 | + | ||
| 289 | + | ### Output | |
| 290 | + | ||
| 291 | + | ```text | |
| 292 | + | Route diagnosis: myapp.example.com | |
| 293 | + | Image: myapp:latest | |
| 294 | + | Container: running abc123def456 | |
| 295 | + | Preserved volume: gordon-myapp-example-com-data | |
| 296 | + | Orphaned attachment: postgres | |
| 297 | + | Hint: persistent volumes are preserved by default | |
| 298 | + | Hint: run 'gordon attachments prune --stop' to stop orphaned attachment containers while preserving volumes | |
| 299 | + | ``` | |
| 300 | + | ||
| 301 | + | ### JSON Output | |
| 302 | + | ||
| 303 | + | ```json | |
| 304 | + | { | |
| 305 | + | "domain": "myapp.example.com", | |
| 306 | + | "configured": true, | |
| 307 | + | "route": {"domain": "myapp.example.com", "image": "myapp:latest", "https": true}, | |
| 308 | + | "runtime": {"domain": "myapp.example.com", "container_id": "abc123def456", "container_status": "running"}, | |
| 309 | + | "volumes": [{"name": "gordon-myapp-example-com-data", "in_use": false}], | |
| 310 | + | "orphaned_attachments": [{"container_id": "pg123", "name": "postgres", "image": "postgres:16", "status": "running", "owner": "myapp.example.com"}], | |
| 311 | + | "hints": ["persistent volumes are preserved by default"] | |
| 312 | + | } | |
| 313 | + | ``` | |
| 314 | + | ||
| 315 | + | ### Next Steps | |
| 316 | + | ||
| 317 | + | ```bash | |
| 318 | + | gordon routes purge myapp.example.com # dry-run retained resources | |
| 319 | + | gordon routes purge myapp.example.com --attachments --force | |
| 320 | + | ``` | |
| 321 | + | ||
| 322 | + | --- | |
| 323 | + | ||
| 324 | + | ## gordon routes purge | |
| 325 | + | ||
| 326 | + | Review retained resources for a route and execute supported explicit cleanup actions only when forced. | |
| 327 | + | ||
| 328 | + | ```bash | |
| 329 | + | gordon routes purge myapp.example.com # dry-run | |
| 330 | + | gordon routes purge myapp.example.com --attachments --force | |
| 331 | + | gordon routes purge myapp.example.com --volumes # report preserved volumes for manual review | |
| 332 | + | ``` | |
| 333 | + | ||
| 334 | + | ### Dry-run Output | |
| 335 | + | ||
| 336 | + | ```text | |
| 337 | + | Purge dry-run: myapp.example.com | |
| 338 | + | Preserved attachments: | |
| 339 | + | postgres (running) | |
| 340 | + | Purge candidate: route_container abc123def456 | |
| 341 | + | Preserved volume: gordon-myapp-example-com-data | |
| 342 | + | Hint: dry-run only; add --force with explicit category flags to execute supported purge actions | |
| 343 | + | ``` | |
| 344 | + | ||
| 345 | + | ### Forced Attachment Cleanup Output | |
| 346 | + | ||
| 347 | + | ```text | |
| 348 | + | Purge completed: myapp.example.com | |
| 349 | + | Removed containers: | |
| 350 | + | postgres | |
| 351 | + | Hint: attachment volumes and data were preserved | |
| 352 | + | ``` | |
| 353 | + | ||
| 354 | + | Purge is intentionally conservative. Volumes are reported and preserved unless a dedicated volume deletion flow supports safe targeted deletion. | |
| 355 | + | ||
| 356 | + | --- | |
| 357 | + | ||
| 278 | 358 | ## gordon routes remove | |
| 279 | 359 | ||
| 280 | 360 | Remove a route. | |
| 281 | 361 | ||
| 362 | + | Route removal is safe by default. Gordon removes the route from configuration and reconciles the active route container so it is no longer served or restarted by Gordon's monitor. Stateful data is preserved. Volumes and attachment data are not deleted unless a separate explicit purge flow requests destructive cleanup. | |
| 363 | + | ||
| 282 | 364 | ```bash | |
| 283 | 365 | gordon routes remove <domain> | |
| 284 | 366 | gordon routes remove myapp.example.com | |
| @@ -305,6 +387,15 @@ gordon routes remove myapp.example.com | |||
| 305 | 387 | ||
| 306 | 388 | # Remote (override) | |
| 307 | 389 | gordon routes remove myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN | |
| 390 | + | ||
| 391 | + | # Typical output | |
| 392 | + | Route removed: myapp.example.com | |
| 393 | + | ||
| 394 | + | Removed containers: | |
| 395 | + | gordon-myapp.example.com (abc123def456) | |
| 396 | + | ||
| 397 | + | Preserved attachments: | |
| 398 | + | postgres (running) | |
| 308 | 399 | ``` | |
| 309 | 400 | ||
| 310 | 401 | --- | |
| @@ -275,10 +275,92 @@ gordon routes show myapp.example.com --remote https://gordon.mydomain.com --toke | |||||
| 275 | 275 | ||||
| 276 | --- | 276 | --- | ||
| 277 | 277 | ||||
| 278 | + | ## gordon routes diagnose | |||
| 279 | + | ||||
| 280 | + | Diagnose route configuration, runtime state, preserved volumes, and orphaned attachment containers. | |||
| 281 | + | ||||
| 282 | + | ```bash | |||
| 283 | + | gordon routes diagnose <domain> | |||
| 284 | + | gordon routes diagnose myapp.example.com --json | |||
| 285 | + | ``` | |||
| 286 | + | ||||
| 287 | + | Use this after route deletion or failed deployment to see whether runtime state still exists and which safe cleanup commands to run next. | |||
| 288 | + | ||||
| 289 | + | ### Output | |||
| 290 | + | ||||
| 291 | + | ```text | |||
| 292 | + | Route diagnosis: myapp.example.com | |||
| 293 | + | Image: myapp:latest | |||
| 294 | + | Container: running abc123def456 | |||
| 295 | + | Preserved volume: gordon-myapp-example-com-data | |||
| 296 | + | Orphaned attachment: postgres | |||
| 297 | + | Hint: persistent volumes are preserved by default | |||
| 298 | + | Hint: run 'gordon attachments prune --stop' to stop orphaned attachment containers while preserving volumes | |||
| 299 | + | ``` | |||
| 300 | + | ||||
| 301 | + | ### JSON Output | |||
| 302 | + | ||||
| 303 | + | ```json | |||
| 304 | + | { | |||
| 305 | + | "domain": "myapp.example.com", | |||
| 306 | + | "configured": true, | |||
| 307 | + | "route": {"domain": "myapp.example.com", "image": "myapp:latest", "https": true}, | |||
| 308 | + | "runtime": {"domain": "myapp.example.com", "container_id": "abc123def456", "container_status": "running"}, | |||
| 309 | + | "volumes": [{"name": "gordon-myapp-example-com-data", "in_use": false}], | |||
| 310 | + | "orphaned_attachments": [{"container_id": "pg123", "name": "postgres", "image": "postgres:16", "status": "running", "owner": "myapp.example.com"}], | |||
| 311 | + | "hints": ["persistent volumes are preserved by default"] | |||
| 312 | + | } | |||
| 313 | + | ``` | |||
| 314 | + | ||||
| 315 | + | ### Next Steps | |||
| 316 | + | ||||
| 317 | + | ```bash | |||
| 318 | + | gordon routes purge myapp.example.com # dry-run retained resources | |||
| 319 | + | gordon routes purge myapp.example.com --attachments --force | |||
| 320 | + | ``` | |||
| 321 | + | ||||
| 322 | + | --- | |||
| 323 | + | ||||
| 324 | + | ## gordon routes purge | |||
| 325 | + | ||||
| 326 | + | Review retained resources for a route and execute supported explicit cleanup actions only when forced. | |||
| 327 | + | ||||
| 328 | + | ```bash | |||
| 329 | + | gordon routes purge myapp.example.com # dry-run | |||
| 330 | + | gordon routes purge myapp.example.com --attachments --force | |||
| 331 | + | gordon routes purge myapp.example.com --volumes # report preserved volumes for manual review | |||
| 332 | + | ``` | |||
| 333 | + | ||||
| 334 | + | ### Dry-run Output | |||
| 335 | + | ||||
| 336 | + | ```text | |||
| 337 | + | Purge dry-run: myapp.example.com | |||
| 338 | + | Preserved attachments: | |||
| 339 | + | postgres (running) | |||
| 340 | + | Purge candidate: route_container abc123def456 | |||
| 341 | + | Preserved volume: gordon-myapp-example-com-data | |||
| 342 | + | Hint: dry-run only; add --force with explicit category flags to execute supported purge actions | |||
| 343 | + | ``` | |||
| 344 | + | ||||
| 345 | + | ### Forced Attachment Cleanup Output | |||
| 346 | + | ||||
| 347 | + | ```text | |||
| 348 | + | Purge completed: myapp.example.com | |||
| 349 | + | Removed containers: | |||
| 350 | + | postgres | |||
| 351 | + | Hint: attachment volumes and data were preserved | |||
| 352 | + | ``` | |||
| 353 | + | ||||
| 354 | + | Purge is intentionally conservative. Volumes are reported and preserved unless a dedicated volume deletion flow supports safe targeted deletion. | |||
| 355 | + | ||||
| 356 | + | --- | |||
| 357 | + | ||||
| 278 | ## gordon routes remove | 358 | ## gordon routes remove | ||
| 279 | 359 | ||||
| 280 | Remove a route. | 360 | Remove a route. | ||
| 281 | 361 | ||||
| 362 | + | Route removal is safe by default. Gordon removes the route from configuration and reconciles the active route container so it is no longer served or restarted by Gordon's monitor. Stateful data is preserved. Volumes and attachment data are not deleted unless a separate explicit purge flow requests destructive cleanup. | |||
| 363 | + | ||||
| 282 | ```bash | 364 | ```bash | ||
| 283 | gordon routes remove <domain> | 365 | gordon routes remove <domain> | ||
| 284 | gordon routes remove myapp.example.com | 366 | gordon routes remove myapp.example.com | ||
| @@ -305,6 +387,15 @@ gordon routes remove myapp.example.com | |||||
| 305 | 387 | ||||
| 306 | # Remote (override) | 388 | # Remote (override) | ||
| 307 | gordon routes remove myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN | 389 | gordon routes remove myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN | ||
| 390 | + | ||||
| 391 | + | # Typical output | |||
| 392 | + | Route removed: myapp.example.com | |||
| 393 | + | ||||
| 394 | + | Removed containers: | |||
| 395 | + | gordon-myapp.example.com (abc123def456) | |||
| 396 | + | ||||
| 397 | + | Preserved attachments: | |||
| 398 | + | postgres (running) | |||
| 308 | ``` | 399 | ``` | ||
| 309 | 400 | ||||
| 310 | --- | 401 | --- | ||
M docs/concepts.md +22 -0
| @@ -59,6 +59,28 @@ New Container: [══════════════════ | |||
| 59 | 59 | ↑ start ↑ traffic routed | |
| 60 | 60 | ``` | |
| 61 | 61 | ||
| 62 | + | ## Deletion and Cleanup Lifecycle | |
| 63 | + | ||
| 64 | + | Gordon separates configuration removal from destructive data cleanup: | |
| 65 | + | ||
| 66 | + | - **configured** — an entity exists in Gordon configuration, such as a route in `gordon.toml`. | |
| 67 | + | - **active** — an entity has runtime state, such as a running Gordon-managed container. | |
| 68 | + | - **preserved** — state intentionally kept after configuration removal, such as volumes or attachment data. | |
| 69 | + | - **orphaned** — runtime state no longer referenced by configuration and requiring follow-up cleanup or diagnosis. | |
| 70 | + | - **purged** — state explicitly deleted by a destructive cleanup command. | |
| 71 | + | - **retained** — preserved state that Gordon reports so operators can decide whether to keep or purge it later. | |
| 72 | + | ||
| 73 | + | Safe deletion is the default. Removing a route reconciles active route containers so the app is no longer served or restarted by Gordon's monitor. Stateful data such as volumes and attachment data is preserved unless a later purge command explicitly requests deletion. | |
| 74 | + | ||
| 75 | + | Cleanup reports use additive-only JSON fields so humans and automation can rely on stable keys while Gordon adds more details over time. | |
| 76 | + | ||
| 77 | + | Current runtime cleanup capabilities are intentionally conservative: | |
| 78 | + | ||
| 79 | + | - Gordon can identify managed route and attachment containers by labels such as `gordon.managed`, `gordon.route`, and `gordon.attachment`. | |
| 80 | + | - Gordon can stop and remove containers. | |
| 81 | + | - Existing volume attribution is limited for older volumes; Gordon may need naming heuristics when labels do not identify owner, mount path, or category. | |
| 82 | + | - Updating runtime restart policy and relabeling existing volumes are runtime-dependent capabilities and should be treated as best-effort when added. | |
| 83 | + | ||
| 62 | 84 | ## Routes | |
| 63 | 85 | ||
| 64 | 86 | Routes map domains to container images: | |
| @@ -59,6 +59,28 @@ New Container: [══════════════════ | |||||
| 59 | ↑ start ↑ traffic routed | 59 | ↑ start ↑ traffic routed | ||
| 60 | ``` | 60 | ``` | ||
| 61 | 61 | ||||
| 62 | + | ## Deletion and Cleanup Lifecycle | |||
| 63 | + | ||||
| 64 | + | Gordon separates configuration removal from destructive data cleanup: | |||
| 65 | + | ||||
| 66 | + | - **configured** — an entity exists in Gordon configuration, such as a route in `gordon.toml`. | |||
| 67 | + | - **active** — an entity has runtime state, such as a running Gordon-managed container. | |||
| 68 | + | - **preserved** — state intentionally kept after configuration removal, such as volumes or attachment data. | |||
| 69 | + | - **orphaned** — runtime state no longer referenced by configuration and requiring follow-up cleanup or diagnosis. | |||
| 70 | + | - **purged** — state explicitly deleted by a destructive cleanup command. | |||
| 71 | + | - **retained** — preserved state that Gordon reports so operators can decide whether to keep or purge it later. | |||
| 72 | + | ||||
| 73 | + | Safe deletion is the default. Removing a route reconciles active route containers so the app is no longer served or restarted by Gordon's monitor. Stateful data such as volumes and attachment data is preserved unless a later purge command explicitly requests deletion. | |||
| 74 | + | ||||
| 75 | + | Cleanup reports use additive-only JSON fields so humans and automation can rely on stable keys while Gordon adds more details over time. | |||
| 76 | + | ||||
| 77 | + | Current runtime cleanup capabilities are intentionally conservative: | |||
| 78 | + | ||||
| 79 | + | - Gordon can identify managed route and attachment containers by labels such as `gordon.managed`, `gordon.route`, and `gordon.attachment`. | |||
| 80 | + | - Gordon can stop and remove containers. | |||
| 81 | + | - Existing volume attribution is limited for older volumes; Gordon may need naming heuristics when labels do not identify owner, mount path, or category. | |||
| 82 | + | - Updating runtime restart policy and relabeling existing volumes are runtime-dependent capabilities and should be treated as best-effort when added. | |||
| 83 | + | ||||
| 62 | ## Routes | 84 | ## Routes | ||
| 63 | 85 | ||||
| 64 | Routes map domains to container images: | 86 | Routes map domains to container images: | ||
M internal/adapters/dto/admin_route.go +2 -1
| @@ -50,5 +50,6 @@ type RoutesByImageResponse struct { | |||
| 50 | 50 | ||
| 51 | 51 | // RouteDeleteResponse represents a route removal response. | |
| 52 | 52 | type RouteDeleteResponse struct { | |
| 53 | - | Status string `json:"status"` | |
| 53 | + | Status string `json:"status"` | |
| 54 | + | Cleanup *CleanupReport `json:"cleanup,omitempty"` | |
| 54 | 55 | } | |
| @@ -50,5 +50,6 @@ type RoutesByImageResponse struct { | |||||
| 50 | 50 | ||||
| 51 | // RouteDeleteResponse represents a route removal response. | 51 | // RouteDeleteResponse represents a route removal response. | ||
| 52 | type RouteDeleteResponse struct { | 52 | type RouteDeleteResponse struct { | ||
| 53 | - | Status string `json:"status"` | 53 | + | Status string `json:"status"` |
| 54 | + | Cleanup *CleanupReport `json:"cleanup,omitempty"` | |||
| 54 | } | 55 | } | ||
A internal/adapters/dto/cleanup.go +86 -0
| @@ -0,0 +1,86 @@ | |||
| 1 | + | package dto | |
| 2 | + | ||
| 3 | + | import "github.com/bnema/gordon/internal/domain" | |
| 4 | + | ||
| 5 | + | // CleanupReport describes cleanup results in API responses. | |
| 6 | + | type CleanupReport struct { | |
| 7 | + | Domain string `json:"domain"` | |
| 8 | + | RemovedContainers []CleanupContainer `json:"removed_containers"` | |
| 9 | + | PreservedVolumes []CleanupVolume `json:"preserved_volumes"` | |
| 10 | + | PreservedAttachments []CleanupAttachment `json:"preserved_attachments"` | |
| 11 | + | OrphanedEntities []CleanupOrphaned `json:"orphaned_entities"` | |
| 12 | + | Warnings []string `json:"warnings"` | |
| 13 | + | Hints []string `json:"hints"` | |
| 14 | + | PartialFailures []CleanupFailure `json:"partial_failures"` | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | // CleanupContainer identifies a container affected by cleanup. | |
| 18 | + | type CleanupContainer struct { | |
| 19 | + | ID string `json:"id"` | |
| 20 | + | Name string `json:"name"` | |
| 21 | + | Image string `json:"image"` | |
| 22 | + | Status string `json:"status"` | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // CleanupVolume identifies preserved stateful data. | |
| 26 | + | type CleanupVolume struct { | |
| 27 | + | Name string `json:"name"` | |
| 28 | + | ContainerPath string `json:"container_path,omitempty"` | |
| 29 | + | Reason string `json:"reason,omitempty"` | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // CleanupAttachment identifies an attachment retained for follow-up cleanup. | |
| 33 | + | type CleanupAttachment struct { | |
| 34 | + | Name string `json:"name"` | |
| 35 | + | Image string `json:"image"` | |
| 36 | + | ContainerID string `json:"container_id"` | |
| 37 | + | Status string `json:"status"` | |
| 38 | + | Owner string `json:"owner,omitempty"` | |
| 39 | + | Reason string `json:"reason,omitempty"` | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // CleanupOrphaned identifies runtime state no longer configured. | |
| 43 | + | type CleanupOrphaned struct { | |
| 44 | + | Kind string `json:"kind"` | |
| 45 | + | ID string `json:"id,omitempty"` | |
| 46 | + | Name string `json:"name,omitempty"` | |
| 47 | + | Status string `json:"status,omitempty"` | |
| 48 | + | Reason string `json:"reason,omitempty"` | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // CleanupFailure records non-fatal cleanup failures. | |
| 52 | + | type CleanupFailure struct { | |
| 53 | + | Action string `json:"action"` | |
| 54 | + | Kind string `json:"kind"` | |
| 55 | + | ID string `json:"id,omitempty"` | |
| 56 | + | Name string `json:"name,omitempty"` | |
| 57 | + | Error string `json:"error"` | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // CleanupReportFromDomain maps a domain cleanup report to an API DTO. | |
| 61 | + | func CleanupReportFromDomain(report *domain.CleanupReport) *CleanupReport { | |
| 62 | + | if report == nil { | |
| 63 | + | return nil | |
| 64 | + | } | |
| 65 | + | out := &CleanupReport{ | |
| 66 | + | Domain: report.Domain, | |
| 67 | + | Warnings: append([]string(nil), report.Warnings...), | |
| 68 | + | Hints: append([]string(nil), report.Hints...), | |
| 69 | + | } | |
| 70 | + | for _, item := range report.RemovedContainers { | |
| 71 | + | out.RemovedContainers = append(out.RemovedContainers, CleanupContainer(item)) | |
| 72 | + | } | |
| 73 | + | for _, item := range report.PreservedVolumes { | |
| 74 | + | out.PreservedVolumes = append(out.PreservedVolumes, CleanupVolume(item)) | |
| 75 | + | } | |
| 76 | + | for _, item := range report.PreservedAttachments { | |
| 77 | + | out.PreservedAttachments = append(out.PreservedAttachments, CleanupAttachment(item)) | |
| 78 | + | } | |
| 79 | + | for _, item := range report.OrphanedEntities { | |
| 80 | + | out.OrphanedEntities = append(out.OrphanedEntities, CleanupOrphaned(item)) | |
| 81 | + | } | |
| 82 | + | for _, item := range report.PartialFailures { | |
| 83 | + | out.PartialFailures = append(out.PartialFailures, CleanupFailure(item)) | |
| 84 | + | } | |
| 85 | + | return out | |
| 86 | + | } | |
| @@ -0,0 +1,86 @@ | |||||
| 1 | + | package dto | |||
| 2 | + | ||||
| 3 | + | import "github.com/bnema/gordon/internal/domain" | |||
| 4 | + | ||||
| 5 | + | // CleanupReport describes cleanup results in API responses. | |||
| 6 | + | type CleanupReport struct { | |||
| 7 | + | Domain string `json:"domain"` | |||
| 8 | + | RemovedContainers []CleanupContainer `json:"removed_containers"` | |||
| 9 | + | PreservedVolumes []CleanupVolume `json:"preserved_volumes"` | |||
| 10 | + | PreservedAttachments []CleanupAttachment `json:"preserved_attachments"` | |||
| 11 | + | OrphanedEntities []CleanupOrphaned `json:"orphaned_entities"` | |||
| 12 | + | Warnings []string `json:"warnings"` | |||
| 13 | + | Hints []string `json:"hints"` | |||
| 14 | + | PartialFailures []CleanupFailure `json:"partial_failures"` | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | // CleanupContainer identifies a container affected by cleanup. | |||
| 18 | + | type CleanupContainer struct { | |||
| 19 | + | ID string `json:"id"` | |||
| 20 | + | Name string `json:"name"` | |||
| 21 | + | Image string `json:"image"` | |||
| 22 | + | Status string `json:"status"` | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // CleanupVolume identifies preserved stateful data. | |||
| 26 | + | type CleanupVolume struct { | |||
| 27 | + | Name string `json:"name"` | |||
| 28 | + | ContainerPath string `json:"container_path,omitempty"` | |||
| 29 | + | Reason string `json:"reason,omitempty"` | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | // CleanupAttachment identifies an attachment retained for follow-up cleanup. | |||
| 33 | + | type CleanupAttachment struct { | |||
| 34 | + | Name string `json:"name"` | |||
| 35 | + | Image string `json:"image"` | |||
| 36 | + | ContainerID string `json:"container_id"` | |||
| 37 | + | Status string `json:"status"` | |||
| 38 | + | Owner string `json:"owner,omitempty"` | |||
| 39 | + | Reason string `json:"reason,omitempty"` | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // CleanupOrphaned identifies runtime state no longer configured. | |||
| 43 | + | type CleanupOrphaned struct { | |||
| 44 | + | Kind string `json:"kind"` | |||
| 45 | + | ID string `json:"id,omitempty"` | |||
| 46 | + | Name string `json:"name,omitempty"` | |||
| 47 | + | Status string `json:"status,omitempty"` | |||
| 48 | + | Reason string `json:"reason,omitempty"` | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // CleanupFailure records non-fatal cleanup failures. | |||
| 52 | + | type CleanupFailure struct { | |||
| 53 | + | Action string `json:"action"` | |||
| 54 | + | Kind string `json:"kind"` | |||
| 55 | + | ID string `json:"id,omitempty"` | |||
| 56 | + | Name string `json:"name,omitempty"` | |||
| 57 | + | Error string `json:"error"` | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | // CleanupReportFromDomain maps a domain cleanup report to an API DTO. | |||
| 61 | + | func CleanupReportFromDomain(report *domain.CleanupReport) *CleanupReport { | |||
| 62 | + | if report == nil { | |||
| 63 | + | return nil | |||
| 64 | + | } | |||
| 65 | + | out := &CleanupReport{ | |||
| 66 | + | Domain: report.Domain, | |||
| 67 | + | Warnings: append([]string(nil), report.Warnings...), | |||
| 68 | + | Hints: append([]string(nil), report.Hints...), | |||
| 69 | + | } | |||
| 70 | + | for _, item := range report.RemovedContainers { | |||
| 71 | + | out.RemovedContainers = append(out.RemovedContainers, CleanupContainer(item)) | |||
| 72 | + | } | |||
| 73 | + | for _, item := range report.PreservedVolumes { | |||
| 74 | + | out.PreservedVolumes = append(out.PreservedVolumes, CleanupVolume(item)) | |||
| 75 | + | } | |||
| 76 | + | for _, item := range report.PreservedAttachments { | |||
| 77 | + | out.PreservedAttachments = append(out.PreservedAttachments, CleanupAttachment(item)) | |||
| 78 | + | } | |||
| 79 | + | for _, item := range report.OrphanedEntities { | |||
| 80 | + | out.OrphanedEntities = append(out.OrphanedEntities, CleanupOrphaned(item)) | |||
| 81 | + | } | |||
| 82 | + | for _, item := range report.PartialFailures { | |||
| 83 | + | out.PartialFailures = append(out.PartialFailures, CleanupFailure(item)) | |||
| 84 | + | } | |||
| 85 | + | return out | |||
| 86 | + | } | |||
M internal/adapters/in/cli/attachments.go +127 -0
| @@ -9,6 +9,7 @@ import ( | |||
| 9 | 9 | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |
| 10 | 10 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/components" | |
| 11 | 11 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" | |
| 12 | + | "github.com/bnema/gordon/internal/domain" | |
| 12 | 13 | ||
| 13 | 14 | "github.com/spf13/cobra" | |
| 14 | 15 | ) | |
| @@ -32,6 +33,8 @@ the local Gordon configuration.`, | |||
| 32 | 33 | cmd.AddCommand(newAttachmentsListCmd()) | |
| 33 | 34 | cmd.AddCommand(newAttachmentsAddCmd()) | |
| 34 | 35 | cmd.AddCommand(newAttachmentsRemoveCmd()) | |
| 36 | + | cmd.AddCommand(newAttachmentsOrphansCmd()) | |
| 37 | + | cmd.AddCommand(newAttachmentsPruneCmd()) | |
| 35 | 38 | cmd.AddCommand(newAttachmentsPushCmd()) | |
| 36 | 39 | ||
| 37 | 40 | return cmd | |
| @@ -277,6 +280,130 @@ func runAttachmentsListLocal(ctx context.Context, cfgPath string, target string, | |||
| 277 | 280 | return nil | |
| 278 | 281 | } | |
| 279 | 282 | ||
| 283 | + | func newAttachmentsOrphansCmd() *cobra.Command { | |
| 284 | + | var jsonOut bool | |
| 285 | + | cmd := &cobra.Command{ | |
| 286 | + | Use: "orphans", | |
| 287 | + | Short: "List orphaned attachment containers", | |
| 288 | + | Args: cobra.NoArgs, | |
| 289 | + | RunE: func(cmd *cobra.Command, args []string) error { | |
| 290 | + | handle, err := resolveControlPlane(configPath) | |
| 291 | + | if err != nil { | |
| 292 | + | return err | |
| 293 | + | } | |
| 294 | + | defer handle.close() | |
| 295 | + | lister, ok := handle.plane.(interface { | |
| 296 | + | ListOrphanedAttachments(context.Context) ([]domain.CleanupAttachment, error) | |
| 297 | + | }) | |
| 298 | + | if !ok { | |
| 299 | + | return fmt.Errorf("attachment orphan listing is unavailable") | |
| 300 | + | } | |
| 301 | + | attachments, err := lister.ListOrphanedAttachments(cmd.Context()) | |
| 302 | + | if err != nil { | |
| 303 | + | return fmt.Errorf("failed to list orphaned attachments: %w", err) | |
| 304 | + | } | |
| 305 | + | if jsonOut { | |
| 306 | + | return writeJSON(cmd.OutOrStdout(), map[string]any{"attachments": attachments}) | |
| 307 | + | } | |
| 308 | + | return writeAttachmentOrphansText(cmd.OutOrStdout(), attachments) | |
| 309 | + | }, | |
| 310 | + | } | |
| 311 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |
| 312 | + | return cmd | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | func newAttachmentsPruneCmd() *cobra.Command { | |
| 316 | + | var jsonOut bool | |
| 317 | + | var stop bool | |
| 318 | + | cmd := &cobra.Command{ | |
| 319 | + | Use: "prune", | |
| 320 | + | Short: "Review or stop orphaned attachment containers", | |
| 321 | + | Args: cobra.NoArgs, | |
| 322 | + | RunE: func(cmd *cobra.Command, args []string) error { | |
| 323 | + | handle, err := resolveControlPlane(configPath) | |
| 324 | + | if err != nil { | |
| 325 | + | return err | |
| 326 | + | } | |
| 327 | + | defer handle.close() | |
| 328 | + | cleaner, ok := handle.plane.(interface { | |
| 329 | + | CleanupOrphanedAttachments(context.Context, string, bool) (*domain.CleanupReport, error) | |
| 330 | + | }) | |
| 331 | + | if !ok { | |
| 332 | + | return fmt.Errorf("attachment orphan cleanup is unavailable") | |
| 333 | + | } | |
| 334 | + | report, err := cleaner.CleanupOrphanedAttachments(cmd.Context(), "", stop) | |
| 335 | + | if err != nil { | |
| 336 | + | return fmt.Errorf("failed to cleanup orphaned attachments: %w", err) | |
| 337 | + | } | |
| 338 | + | if jsonOut { | |
| 339 | + | return writeJSON(cmd.OutOrStdout(), report) | |
| 340 | + | } | |
| 341 | + | return writeAttachmentPruneText(cmd.OutOrStdout(), report, stop) | |
| 342 | + | }, | |
| 343 | + | } | |
| 344 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |
| 345 | + | cmd.Flags().BoolVar(&stop, "stop", false, "Stop and remove orphaned attachment containers while preserving volumes") | |
| 346 | + | return cmd | |
| 347 | + | } | |
| 348 | + | ||
| 349 | + | func writeAttachmentOrphansText(out io.Writer, attachments []domain.CleanupAttachment) error { | |
| 350 | + | if len(attachments) == 0 { | |
| 351 | + | return cliWriteLine(out, styles.Theme.Muted.Render("No orphaned attachments found")) | |
| 352 | + | } | |
| 353 | + | if err := cliWriteLine(out, cliRenderTitle("Orphaned attachments")); err != nil { | |
| 354 | + | return err | |
| 355 | + | } | |
| 356 | + | for _, a := range attachments { | |
| 357 | + | label := a.Name | |
| 358 | + | if label == "" { | |
| 359 | + | label = a.ContainerID | |
| 360 | + | } | |
| 361 | + | if a.Image != "" { | |
| 362 | + | label += " -> " + a.Image | |
| 363 | + | } | |
| 364 | + | if err := cliWriteLine(out, " "+label); err != nil { | |
| 365 | + | return err | |
| 366 | + | } | |
| 367 | + | } | |
| 368 | + | return nil | |
| 369 | + | } | |
| 370 | + | ||
| 371 | + | func writeAttachmentPruneText(out io.Writer, report *domain.CleanupReport, stop bool) error { | |
| 372 | + | if report == nil { | |
| 373 | + | return nil | |
| 374 | + | } | |
| 375 | + | if !stop { | |
| 376 | + | if err := writeAttachmentOrphansText(out, report.PreservedAttachments); err != nil { | |
| 377 | + | return err | |
| 378 | + | } | |
| 379 | + | return writeCleanupMessages(out, report.Warnings, report.Hints) | |
| 380 | + | } | |
| 381 | + | if len(report.RemovedContainers) == 0 { | |
| 382 | + | if err := cliWriteLine(out, styles.Theme.Muted.Render("No orphaned attachments removed")); err != nil { | |
| 383 | + | return err | |
| 384 | + | } | |
| 385 | + | } else { | |
| 386 | + | if err := cliWriteLine(out, cliRenderSuccess("Removed orphaned attachment containers:")); err != nil { | |
| 387 | + | return err | |
| 388 | + | } | |
| 389 | + | for _, c := range report.RemovedContainers { | |
| 390 | + | label := c.Name | |
| 391 | + | if label == "" { | |
| 392 | + | label = c.ID | |
| 393 | + | } | |
| 394 | + | if err := cliWriteLine(out, " "+label); err != nil { | |
| 395 | + | return err | |
| 396 | + | } | |
| 397 | + | } | |
| 398 | + | } | |
| 399 | + | for _, failure := range report.PartialFailures { | |
| 400 | + | if err := cliWriteLine(out, cliRenderWarning(fmt.Sprintf("%s %s failed: %s", failure.Action, failure.Name, failure.Error))); err != nil { | |
| 401 | + | return err | |
| 402 | + | } | |
| 403 | + | } | |
| 404 | + | return writeCleanupMessages(out, report.Warnings, report.Hints) | |
| 405 | + | } | |
| 406 | + | ||
| 280 | 407 | // newAttachmentsAddCmd creates the attachments add command. | |
| 281 | 408 | func newAttachmentsAddCmd() *cobra.Command { | |
| 282 | 409 | cmd := &cobra.Command{ | |
| @@ -9,6 +9,7 @@ import ( | |||||
| 9 | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | 9 | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | ||
| 10 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/components" | 10 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/components" | ||
| 11 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" | 11 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" | ||
| 12 | + | "github.com/bnema/gordon/internal/domain" | |||
| 12 | 13 | ||||
| 13 | "github.com/spf13/cobra" | 14 | "github.com/spf13/cobra" | ||
| 14 | ) | 15 | ) | ||
| @@ -32,6 +33,8 @@ the local Gordon configuration.`, | |||||
| 32 | cmd.AddCommand(newAttachmentsListCmd()) | 33 | cmd.AddCommand(newAttachmentsListCmd()) | ||
| 33 | cmd.AddCommand(newAttachmentsAddCmd()) | 34 | cmd.AddCommand(newAttachmentsAddCmd()) | ||
| 34 | cmd.AddCommand(newAttachmentsRemoveCmd()) | 35 | cmd.AddCommand(newAttachmentsRemoveCmd()) | ||
| 36 | + | cmd.AddCommand(newAttachmentsOrphansCmd()) | |||
| 37 | + | cmd.AddCommand(newAttachmentsPruneCmd()) | |||
| 35 | cmd.AddCommand(newAttachmentsPushCmd()) | 38 | cmd.AddCommand(newAttachmentsPushCmd()) | ||
| 36 | 39 | ||||
| 37 | return cmd | 40 | return cmd | ||
| @@ -277,6 +280,130 @@ func runAttachmentsListLocal(ctx context.Context, cfgPath string, target string, | |||||
| 277 | return nil | 280 | return nil | ||
| 278 | } | 281 | } | ||
| 279 | 282 | ||||
| 283 | + | func newAttachmentsOrphansCmd() *cobra.Command { | |||
| 284 | + | var jsonOut bool | |||
| 285 | + | cmd := &cobra.Command{ | |||
| 286 | + | Use: "orphans", | |||
| 287 | + | Short: "List orphaned attachment containers", | |||
| 288 | + | Args: cobra.NoArgs, | |||
| 289 | + | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 290 | + | handle, err := resolveControlPlane(configPath) | |||
| 291 | + | if err != nil { | |||
| 292 | + | return err | |||
| 293 | + | } | |||
| 294 | + | defer handle.close() | |||
| 295 | + | lister, ok := handle.plane.(interface { | |||
| 296 | + | ListOrphanedAttachments(context.Context) ([]domain.CleanupAttachment, error) | |||
| 297 | + | }) | |||
| 298 | + | if !ok { | |||
| 299 | + | return fmt.Errorf("attachment orphan listing is unavailable") | |||
| 300 | + | } | |||
| 301 | + | attachments, err := lister.ListOrphanedAttachments(cmd.Context()) | |||
| 302 | + | if err != nil { | |||
| 303 | + | return fmt.Errorf("failed to list orphaned attachments: %w", err) | |||
| 304 | + | } | |||
| 305 | + | if jsonOut { | |||
| 306 | + | return writeJSON(cmd.OutOrStdout(), map[string]any{"attachments": attachments}) | |||
| 307 | + | } | |||
| 308 | + | return writeAttachmentOrphansText(cmd.OutOrStdout(), attachments) | |||
| 309 | + | }, | |||
| 310 | + | } | |||
| 311 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |||
| 312 | + | return cmd | |||
| 313 | + | } | |||
| 314 | + | ||||
| 315 | + | func newAttachmentsPruneCmd() *cobra.Command { | |||
| 316 | + | var jsonOut bool | |||
| 317 | + | var stop bool | |||
| 318 | + | cmd := &cobra.Command{ | |||
| 319 | + | Use: "prune", | |||
| 320 | + | Short: "Review or stop orphaned attachment containers", | |||
| 321 | + | Args: cobra.NoArgs, | |||
| 322 | + | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 323 | + | handle, err := resolveControlPlane(configPath) | |||
| 324 | + | if err != nil { | |||
| 325 | + | return err | |||
| 326 | + | } | |||
| 327 | + | defer handle.close() | |||
| 328 | + | cleaner, ok := handle.plane.(interface { | |||
| 329 | + | CleanupOrphanedAttachments(context.Context, string, bool) (*domain.CleanupReport, error) | |||
| 330 | + | }) | |||
| 331 | + | if !ok { | |||
| 332 | + | return fmt.Errorf("attachment orphan cleanup is unavailable") | |||
| 333 | + | } | |||
| 334 | + | report, err := cleaner.CleanupOrphanedAttachments(cmd.Context(), "", stop) | |||
| 335 | + | if err != nil { | |||
| 336 | + | return fmt.Errorf("failed to cleanup orphaned attachments: %w", err) | |||
| 337 | + | } | |||
| 338 | + | if jsonOut { | |||
| 339 | + | return writeJSON(cmd.OutOrStdout(), report) | |||
| 340 | + | } | |||
| 341 | + | return writeAttachmentPruneText(cmd.OutOrStdout(), report, stop) | |||
| 342 | + | }, | |||
| 343 | + | } | |||
| 344 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |||
| 345 | + | cmd.Flags().BoolVar(&stop, "stop", false, "Stop and remove orphaned attachment containers while preserving volumes") | |||
| 346 | + | return cmd | |||
| 347 | + | } | |||
| 348 | + | ||||
| 349 | + | func writeAttachmentOrphansText(out io.Writer, attachments []domain.CleanupAttachment) error { | |||
| 350 | + | if len(attachments) == 0 { | |||
| 351 | + | return cliWriteLine(out, styles.Theme.Muted.Render("No orphaned attachments found")) | |||
| 352 | + | } | |||
| 353 | + | if err := cliWriteLine(out, cliRenderTitle("Orphaned attachments")); err != nil { | |||
| 354 | + | return err | |||
| 355 | + | } | |||
| 356 | + | for _, a := range attachments { | |||
| 357 | + | label := a.Name | |||
| 358 | + | if label == "" { | |||
| 359 | + | label = a.ContainerID | |||
| 360 | + | } | |||
| 361 | + | if a.Image != "" { | |||
| 362 | + | label += " -> " + a.Image | |||
| 363 | + | } | |||
| 364 | + | if err := cliWriteLine(out, " "+label); err != nil { | |||
| 365 | + | return err | |||
| 366 | + | } | |||
| 367 | + | } | |||
| 368 | + | return nil | |||
| 369 | + | } | |||
| 370 | + | ||||
| 371 | + | func writeAttachmentPruneText(out io.Writer, report *domain.CleanupReport, stop bool) error { | |||
| 372 | + | if report == nil { | |||
| 373 | + | return nil | |||
| 374 | + | } | |||
| 375 | + | if !stop { | |||
| 376 | + | if err := writeAttachmentOrphansText(out, report.PreservedAttachments); err != nil { | |||
| 377 | + | return err | |||
| 378 | + | } | |||
| 379 | + | return writeCleanupMessages(out, report.Warnings, report.Hints) | |||
| 380 | + | } | |||
| 381 | + | if len(report.RemovedContainers) == 0 { | |||
| 382 | + | if err := cliWriteLine(out, styles.Theme.Muted.Render("No orphaned attachments removed")); err != nil { | |||
| 383 | + | return err | |||
| 384 | + | } | |||
| 385 | + | } else { | |||
| 386 | + | if err := cliWriteLine(out, cliRenderSuccess("Removed orphaned attachment containers:")); err != nil { | |||
| 387 | + | return err | |||
| 388 | + | } | |||
| 389 | + | for _, c := range report.RemovedContainers { | |||
| 390 | + | label := c.Name | |||
| 391 | + | if label == "" { | |||
| 392 | + | label = c.ID | |||
| 393 | + | } | |||
| 394 | + | if err := cliWriteLine(out, " "+label); err != nil { | |||
| 395 | + | return err | |||
| 396 | + | } | |||
| 397 | + | } | |||
| 398 | + | } | |||
| 399 | + | for _, failure := range report.PartialFailures { | |||
| 400 | + | if err := cliWriteLine(out, cliRenderWarning(fmt.Sprintf("%s %s failed: %s", failure.Action, failure.Name, failure.Error))); err != nil { | |||
| 401 | + | return err | |||
| 402 | + | } | |||
| 403 | + | } | |||
| 404 | + | return writeCleanupMessages(out, report.Warnings, report.Hints) | |||
| 405 | + | } | |||
| 406 | + | ||||
| 280 | // newAttachmentsAddCmd creates the attachments add command. | 407 | // newAttachmentsAddCmd creates the attachments add command. | ||
| 281 | func newAttachmentsAddCmd() *cobra.Command { | 408 | func newAttachmentsAddCmd() *cobra.Command { | ||
| 282 | cmd := &cobra.Command{ | 409 | cmd := &cobra.Command{ | ||
M internal/adapters/in/cli/attachments_push_test.go +8 -0
| @@ -81,6 +81,14 @@ func (c *attachmentPushTestControlPlane) GetAttachmentsConfig(context.Context, s | |||
| 81 | 81 | panic("unexpected call") | |
| 82 | 82 | } | |
| 83 | 83 | ||
| 84 | + | func (c *attachmentPushTestControlPlane) ListOrphanedAttachments(context.Context) ([]domain.CleanupAttachment, error) { | |
| 85 | + | panic("unexpected call") | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func (c *attachmentPushTestControlPlane) CleanupOrphanedAttachments(context.Context, string, bool) (*domain.CleanupReport, error) { | |
| 89 | + | panic("unexpected call") | |
| 90 | + | } | |
| 91 | + | ||
| 84 | 92 | func (c *attachmentPushTestControlPlane) FindAttachmentTargetsByImage(ctx context.Context, imageName string) ([]string, error) { | |
| 85 | 93 | if c.findAttachmentTargets != nil { | |
| 86 | 94 | return c.findAttachmentTargets(ctx, imageName) | |
| @@ -81,6 +81,14 @@ func (c *attachmentPushTestControlPlane) GetAttachmentsConfig(context.Context, s | |||||
| 81 | panic("unexpected call") | 81 | panic("unexpected call") | ||
| 82 | } | 82 | } | ||
| 83 | 83 | ||||
| 84 | + | func (c *attachmentPushTestControlPlane) ListOrphanedAttachments(context.Context) ([]domain.CleanupAttachment, error) { | |||
| 85 | + | panic("unexpected call") | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | func (c *attachmentPushTestControlPlane) CleanupOrphanedAttachments(context.Context, string, bool) (*domain.CleanupReport, error) { | |||
| 89 | + | panic("unexpected call") | |||
| 90 | + | } | |||
| 91 | + | ||||
| 84 | func (c *attachmentPushTestControlPlane) FindAttachmentTargetsByImage(ctx context.Context, imageName string) ([]string, error) { | 92 | func (c *attachmentPushTestControlPlane) FindAttachmentTargetsByImage(ctx context.Context, imageName string) ([]string, error) { | ||
| 85 | if c.findAttachmentTargets != nil { | 93 | if c.findAttachmentTargets != nil { | ||
| 86 | return c.findAttachmentTargets(ctx, imageName) | 94 | return c.findAttachmentTargets(ctx, imageName) | ||
M internal/adapters/in/cli/controlplane.go +2 -0
| @@ -30,6 +30,8 @@ type ControlPlane interface { | |||
| 30 | 30 | ||
| 31 | 31 | GetAllAttachmentsConfig(ctx context.Context) (map[string][]string, error) | |
| 32 | 32 | GetAttachmentsConfig(ctx context.Context, domainOrGroup string) ([]string, error) | |
| 33 | + | ListOrphanedAttachments(ctx context.Context) ([]domain.CleanupAttachment, error) | |
| 34 | + | CleanupOrphanedAttachments(ctx context.Context, owner string, stop bool) (*domain.CleanupReport, error) | |
| 33 | 35 | FindAttachmentTargetsByImage(ctx context.Context, imageName string) ([]string, error) | |
| 34 | 36 | AddAttachment(ctx context.Context, domainOrGroup, image string) error | |
| 35 | 37 | RemoveAttachment(ctx context.Context, domainOrGroup, image string) error | |
| @@ -30,6 +30,8 @@ type ControlPlane interface { | |||||
| 30 | 30 | ||||
| 31 | GetAllAttachmentsConfig(ctx context.Context) (map[string][]string, error) | 31 | GetAllAttachmentsConfig(ctx context.Context) (map[string][]string, error) | ||
| 32 | GetAttachmentsConfig(ctx context.Context, domainOrGroup string) ([]string, error) | 32 | GetAttachmentsConfig(ctx context.Context, domainOrGroup string) ([]string, error) | ||
| 33 | + | ListOrphanedAttachments(ctx context.Context) ([]domain.CleanupAttachment, error) | |||
| 34 | + | CleanupOrphanedAttachments(ctx context.Context, owner string, stop bool) (*domain.CleanupReport, error) | |||
| 33 | FindAttachmentTargetsByImage(ctx context.Context, imageName string) ([]string, error) | 35 | FindAttachmentTargetsByImage(ctx context.Context, imageName string) ([]string, error) | ||
| 34 | AddAttachment(ctx context.Context, domainOrGroup, image string) error | 36 | AddAttachment(ctx context.Context, domainOrGroup, image string) error | ||
| 35 | RemoveAttachment(ctx context.Context, domainOrGroup, image string) error | 37 | RemoveAttachment(ctx context.Context, domainOrGroup, image string) error | ||
M internal/adapters/in/cli/controlplane_local.go +42 -3
| @@ -130,10 +130,27 @@ func (l *localControlPlane) UpdateRoute(ctx context.Context, route domain.Route) | |||
| 130 | 130 | } | |
| 131 | 131 | ||
| 132 | 132 | func (l *localControlPlane) RemoveRoute(ctx context.Context, routeDomain string) error { | |
| 133 | + | _, err := l.RemoveRouteWithCleanup(ctx, routeDomain) | |
| 134 | + | return err | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | func (l *localControlPlane) RemoveRouteWithCleanup(ctx context.Context, routeDomain string) (*dto.RouteDeleteResponse, error) { | |
| 133 | 138 | if l.configSvc == nil { | |
| 134 | - | return fmt.Errorf("local config service unavailable") | |
| 139 | + | return nil, fmt.Errorf("local config service unavailable") | |
| 140 | + | } | |
| 141 | + | if err := l.configSvc.RemoveRoute(ctx, routeDomain); err != nil && !errors.Is(err, domain.ErrRouteNotFound) { | |
| 142 | + | return nil, fmt.Errorf("remove route: %w", err) | |
| 143 | + | } | |
| 144 | + | resp := &dto.RouteDeleteResponse{Status: "removed"} | |
| 145 | + | if l.containerSvc == nil { | |
| 146 | + | return resp, nil | |
| 147 | + | } | |
| 148 | + | report, err := l.containerSvc.ReconcileRemovedRoute(ctx, routeDomain) | |
| 149 | + | if err != nil { | |
| 150 | + | return nil, fmt.Errorf("failed to cleanup removed route runtime state: %w", err) | |
| 135 | 151 | } | |
| 136 | - | return l.configSvc.RemoveRoute(ctx, routeDomain) | |
| 152 | + | resp.Cleanup = dto.CleanupReportFromDomain(report) | |
| 153 | + | return resp, nil | |
| 137 | 154 | } | |
| 138 | 155 | ||
| 139 | 156 | func (l *localControlPlane) Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) { | |
| @@ -239,7 +256,7 @@ func (l *localControlPlane) ListSecretsWithAttachments(ctx context.Context, secr | |||
| 239 | 256 | ||
| 240 | 257 | keys, attachments, err := l.secretSvc.ListKeysWithAttachments(ctx, secretDomain) | |
| 241 | 258 | if err != nil { | |
| 242 | - | return nil, err | |
| 259 | + | return nil, fmt.Errorf("list secret keys with attachments: %w", err) | |
| 243 | 260 | } | |
| 244 | 261 | ||
| 245 | 262 | result := &remote.SecretsListResult{ | |
| @@ -284,6 +301,28 @@ func (l *localControlPlane) DeleteAttachmentSecret(ctx context.Context, domainNa | |||
| 284 | 301 | return l.secretSvc.DeleteAttachment(ctx, domainName, service, key) | |
| 285 | 302 | } | |
| 286 | 303 | ||
| 304 | + | func (l *localControlPlane) ListOrphanedAttachments(ctx context.Context) ([]domain.CleanupAttachment, error) { | |
| 305 | + | if l.containerSvc == nil { | |
| 306 | + | return nil, fmt.Errorf("local container service unavailable") | |
| 307 | + | } | |
| 308 | + | attachments, err := l.containerSvc.ListOrphanedAttachments(ctx) | |
| 309 | + | if err != nil { | |
| 310 | + | return nil, fmt.Errorf("list orphaned attachments: %w", err) | |
| 311 | + | } | |
| 312 | + | return attachments, nil | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | func (l *localControlPlane) CleanupOrphanedAttachments(ctx context.Context, owner string, stop bool) (*domain.CleanupReport, error) { | |
| 316 | + | if l.containerSvc == nil { | |
| 317 | + | return nil, fmt.Errorf("local container service unavailable") | |
| 318 | + | } | |
| 319 | + | report, err := l.containerSvc.CleanupOrphanedAttachments(ctx, owner, stop) | |
| 320 | + | if err != nil { | |
| 321 | + | return nil, fmt.Errorf("cleanup orphaned attachments: %w", err) | |
| 322 | + | } | |
| 323 | + | return report, nil | |
| 324 | + | } | |
| 325 | + | ||
| 287 | 326 | func (l *localControlPlane) GetAllAttachmentsConfig(ctx context.Context) (map[string][]string, error) { | |
| 288 | 327 | if l.configSvc == nil { | |
| 289 | 328 | return nil, fmt.Errorf("local config service unavailable") | |
| @@ -130,10 +130,27 @@ func (l *localControlPlane) UpdateRoute(ctx context.Context, route domain.Route) | |||||
| 130 | } | 130 | } | ||
| 131 | 131 | ||||
| 132 | func (l *localControlPlane) RemoveRoute(ctx context.Context, routeDomain string) error { | 132 | func (l *localControlPlane) RemoveRoute(ctx context.Context, routeDomain string) error { | ||
| 133 | + | _, err := l.RemoveRouteWithCleanup(ctx, routeDomain) | |||
| 134 | + | return err | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | func (l *localControlPlane) RemoveRouteWithCleanup(ctx context.Context, routeDomain string) (*dto.RouteDeleteResponse, error) { | |||
| 133 | if l.configSvc == nil { | 138 | if l.configSvc == nil { | ||
| 134 | - | return fmt.Errorf("local config service unavailable") | 139 | + | return nil, fmt.Errorf("local config service unavailable") |
| 140 | + | } | |||
| 141 | + | if err := l.configSvc.RemoveRoute(ctx, routeDomain); err != nil && !errors.Is(err, domain.ErrRouteNotFound) { | |||
| 142 | + | return nil, fmt.Errorf("remove route: %w", err) | |||
| 143 | + | } | |||
| 144 | + | resp := &dto.RouteDeleteResponse{Status: "removed"} | |||
| 145 | + | if l.containerSvc == nil { | |||
| 146 | + | return resp, nil | |||
| 147 | + | } | |||
| 148 | + | report, err := l.containerSvc.ReconcileRemovedRoute(ctx, routeDomain) | |||
| 149 | + | if err != nil { | |||
| 150 | + | return nil, fmt.Errorf("failed to cleanup removed route runtime state: %w", err) | |||
| 135 | } | 151 | } | ||
| 136 | - | return l.configSvc.RemoveRoute(ctx, routeDomain) | 152 | + | resp.Cleanup = dto.CleanupReportFromDomain(report) |
| 153 | + | return resp, nil | |||
| 137 | } | 154 | } | ||
| 138 | 155 | ||||
| 139 | func (l *localControlPlane) Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) { | 156 | func (l *localControlPlane) Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) { | ||
| @@ -239,7 +256,7 @@ func (l *localControlPlane) ListSecretsWithAttachments(ctx context.Context, secr | |||||
| 239 | 256 | ||||
| 240 | keys, attachments, err := l.secretSvc.ListKeysWithAttachments(ctx, secretDomain) | 257 | keys, attachments, err := l.secretSvc.ListKeysWithAttachments(ctx, secretDomain) | ||
| 241 | if err != nil { | 258 | if err != nil { | ||
| 242 | - | return nil, err | 259 | + | return nil, fmt.Errorf("list secret keys with attachments: %w", err) |
| 243 | } | 260 | } | ||
| 244 | 261 | ||||
| 245 | result := &remote.SecretsListResult{ | 262 | result := &remote.SecretsListResult{ | ||
| @@ -284,6 +301,28 @@ func (l *localControlPlane) DeleteAttachmentSecret(ctx context.Context, domainNa | |||||
| 284 | return l.secretSvc.DeleteAttachment(ctx, domainName, service, key) | 301 | return l.secretSvc.DeleteAttachment(ctx, domainName, service, key) | ||
| 285 | } | 302 | } | ||
| 286 | 303 | ||||
| 304 | + | func (l *localControlPlane) ListOrphanedAttachments(ctx context.Context) ([]domain.CleanupAttachment, error) { | |||
| 305 | + | if l.containerSvc == nil { | |||
| 306 | + | return nil, fmt.Errorf("local container service unavailable") | |||
| 307 | + | } | |||
| 308 | + | attachments, err := l.containerSvc.ListOrphanedAttachments(ctx) | |||
| 309 | + | if err != nil { | |||
| 310 | + | return nil, fmt.Errorf("list orphaned attachments: %w", err) | |||
| 311 | + | } | |||
| 312 | + | return attachments, nil | |||
| 313 | + | } | |||
| 314 | + | ||||
| 315 | + | func (l *localControlPlane) CleanupOrphanedAttachments(ctx context.Context, owner string, stop bool) (*domain.CleanupReport, error) { | |||
| 316 | + | if l.containerSvc == nil { | |||
| 317 | + | return nil, fmt.Errorf("local container service unavailable") | |||
| 318 | + | } | |||
| 319 | + | report, err := l.containerSvc.CleanupOrphanedAttachments(ctx, owner, stop) | |||
| 320 | + | if err != nil { | |||
| 321 | + | return nil, fmt.Errorf("cleanup orphaned attachments: %w", err) | |||
| 322 | + | } | |||
| 323 | + | return report, nil | |||
| 324 | + | } | |||
| 325 | + | ||||
| 287 | func (l *localControlPlane) GetAllAttachmentsConfig(ctx context.Context) (map[string][]string, error) { | 326 | func (l *localControlPlane) GetAllAttachmentsConfig(ctx context.Context) (map[string][]string, error) { | ||
| 288 | if l.configSvc == nil { | 327 | if l.configSvc == nil { | ||
| 289 | return nil, fmt.Errorf("local config service unavailable") | 328 | return nil, fmt.Errorf("local config service unavailable") | ||
M internal/adapters/in/cli/controlplane_local_test.go +39 -0
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "github.com/bnema/gordon/internal/adapters/dto" | |
| 9 | 9 | inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks" | |
| 10 | 10 | "github.com/bnema/gordon/internal/domain" | |
| 11 | + | "github.com/stretchr/testify/assert" | |
| 11 | 12 | "github.com/stretchr/testify/mock" | |
| 12 | 13 | "github.com/stretchr/testify/require" | |
| 13 | 14 | ) | |
| @@ -110,6 +111,28 @@ func TestLocalControlPlane_ListTags(t *testing.T) { | |||
| 110 | 111 | require.Equal(t, []string{"v1.0.0", "latest"}, tags) | |
| 111 | 112 | } | |
| 112 | 113 | ||
| 114 | + | func TestLocalControlPlane_RemoveRoutePersistsConfigThenReconcilesRuntime(t *testing.T) { | |
| 115 | + | t.Parallel() | |
| 116 | + | ||
| 117 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 118 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 119 | + | report := &domain.CleanupReport{ | |
| 120 | + | Domain: "app.local", | |
| 121 | + | RemovedContainers: []domain.CleanupContainer{{ | |
| 122 | + | ID: "container-1", | |
| 123 | + | Name: "gordon-app.local", | |
| 124 | + | }}, | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | removeCall := configSvc.EXPECT().RemoveRoute(mock.Anything, "app.local").Return(nil).Once() | |
| 128 | + | cleanupCall := containerSvc.EXPECT().ReconcileRemovedRoute(mock.Anything, "app.local").Return(report, nil).Once() | |
| 129 | + | mock.InOrder(removeCall, cleanupCall) | |
| 130 | + | ||
| 131 | + | cp := &localControlPlane{configSvc: configSvc, containerSvc: containerSvc} | |
| 132 | + | err := cp.RemoveRoute(context.Background(), "app.local") | |
| 133 | + | require.NoError(t, err) | |
| 134 | + | } | |
| 135 | + | ||
| 113 | 136 | func TestLocalControlPlane_RestartWithAttachments(t *testing.T) { | |
| 114 | 137 | t.Parallel() | |
| 115 | 138 | ||
| @@ -192,3 +215,19 @@ func TestLocalControlPlane_GetContainerLogs(t *testing.T) { | |||
| 192 | 215 | require.NoError(t, err) | |
| 193 | 216 | require.Equal(t, []string{"line1", "line2"}, lines) | |
| 194 | 217 | } | |
| 218 | + | ||
| 219 | + | func TestLocalControlPlane_RemoveRouteReconcilesRuntimeWhenRouteAlreadyMissing(t *testing.T) { | |
| 220 | + | ctx := context.Background() | |
| 221 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 222 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 223 | + | report := &domain.CleanupReport{Domain: "app.local"} | |
| 224 | + | ||
| 225 | + | configSvc.EXPECT().RemoveRoute(mock.Anything, "app.local").Return(domain.ErrRouteNotFound).Once() | |
| 226 | + | containerSvc.EXPECT().ReconcileRemovedRoute(mock.Anything, "app.local").Return(report, nil).Once() | |
| 227 | + | ||
| 228 | + | cp := &localControlPlane{configSvc: configSvc, containerSvc: containerSvc} | |
| 229 | + | resp, err := cp.RemoveRouteWithCleanup(ctx, "app.local") | |
| 230 | + | require.NoError(t, err) | |
| 231 | + | require.NotNil(t, resp) | |
| 232 | + | assert.Equal(t, dto.CleanupReportFromDomain(report), resp.Cleanup) | |
| 233 | + | } | |
| @@ -8,6 +8,7 @@ import ( | |||||
| 8 | "github.com/bnema/gordon/internal/adapters/dto" | 8 | "github.com/bnema/gordon/internal/adapters/dto" | ||
| 9 | inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks" | 9 | inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks" | ||
| 10 | "github.com/bnema/gordon/internal/domain" | 10 | "github.com/bnema/gordon/internal/domain" | ||
| 11 | + | "github.com/stretchr/testify/assert" | |||
| 11 | "github.com/stretchr/testify/mock" | 12 | "github.com/stretchr/testify/mock" | ||
| 12 | "github.com/stretchr/testify/require" | 13 | "github.com/stretchr/testify/require" | ||
| 13 | ) | 14 | ) | ||
| @@ -110,6 +111,28 @@ func TestLocalControlPlane_ListTags(t *testing.T) { | |||||
| 110 | require.Equal(t, []string{"v1.0.0", "latest"}, tags) | 111 | require.Equal(t, []string{"v1.0.0", "latest"}, tags) | ||
| 111 | } | 112 | } | ||
| 112 | 113 | ||||
| 114 | + | func TestLocalControlPlane_RemoveRoutePersistsConfigThenReconcilesRuntime(t *testing.T) { | |||
| 115 | + | t.Parallel() | |||
| 116 | + | ||||
| 117 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 118 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 119 | + | report := &domain.CleanupReport{ | |||
| 120 | + | Domain: "app.local", | |||
| 121 | + | RemovedContainers: []domain.CleanupContainer{{ | |||
| 122 | + | ID: "container-1", | |||
| 123 | + | Name: "gordon-app.local", | |||
| 124 | + | }}, | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | removeCall := configSvc.EXPECT().RemoveRoute(mock.Anything, "app.local").Return(nil).Once() | |||
| 128 | + | cleanupCall := containerSvc.EXPECT().ReconcileRemovedRoute(mock.Anything, "app.local").Return(report, nil).Once() | |||
| 129 | + | mock.InOrder(removeCall, cleanupCall) | |||
| 130 | + | ||||
| 131 | + | cp := &localControlPlane{configSvc: configSvc, containerSvc: containerSvc} | |||
| 132 | + | err := cp.RemoveRoute(context.Background(), "app.local") | |||
| 133 | + | require.NoError(t, err) | |||
| 134 | + | } | |||
| 135 | + | ||||
| 113 | func TestLocalControlPlane_RestartWithAttachments(t *testing.T) { | 136 | func TestLocalControlPlane_RestartWithAttachments(t *testing.T) { | ||
| 114 | t.Parallel() | 137 | t.Parallel() | ||
| 115 | 138 | ||||
| @@ -192,3 +215,19 @@ func TestLocalControlPlane_GetContainerLogs(t *testing.T) { | |||||
| 192 | require.NoError(t, err) | 215 | require.NoError(t, err) | ||
| 193 | require.Equal(t, []string{"line1", "line2"}, lines) | 216 | require.Equal(t, []string{"line1", "line2"}, lines) | ||
| 194 | } | 217 | } | ||
| 218 | + | ||||
| 219 | + | func TestLocalControlPlane_RemoveRouteReconcilesRuntimeWhenRouteAlreadyMissing(t *testing.T) { | |||
| 220 | + | ctx := context.Background() | |||
| 221 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 222 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 223 | + | report := &domain.CleanupReport{Domain: "app.local"} | |||
| 224 | + | ||||
| 225 | + | configSvc.EXPECT().RemoveRoute(mock.Anything, "app.local").Return(domain.ErrRouteNotFound).Once() | |||
| 226 | + | containerSvc.EXPECT().ReconcileRemovedRoute(mock.Anything, "app.local").Return(report, nil).Once() | |||
| 227 | + | ||||
| 228 | + | cp := &localControlPlane{configSvc: configSvc, containerSvc: containerSvc} | |||
| 229 | + | resp, err := cp.RemoveRouteWithCleanup(ctx, "app.local") | |||
| 230 | + | require.NoError(t, err) | |||
| 231 | + | require.NotNil(t, resp) | |||
| 232 | + | assert.Equal(t, dto.CleanupReportFromDomain(report), resp.Cleanup) | |||
| 233 | + | } | |||
M internal/adapters/in/cli/controlplane_remote.go +14 -1
| @@ -41,7 +41,12 @@ func (r *remoteControlPlane) UpdateRoute(ctx context.Context, route domain.Route | |||
| 41 | 41 | } | |
| 42 | 42 | ||
| 43 | 43 | func (r *remoteControlPlane) RemoveRoute(ctx context.Context, routeDomain string) error { | |
| 44 | - | return r.client.RemoveRoute(ctx, routeDomain) | |
| 44 | + | _, err := r.RemoveRouteWithCleanup(ctx, routeDomain) | |
| 45 | + | return err | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func (r *remoteControlPlane) RemoveRouteWithCleanup(ctx context.Context, routeDomain string) (*dto.RouteDeleteResponse, error) { | |
| 49 | + | return r.client.RemoveRouteWithCleanup(ctx, routeDomain) | |
| 45 | 50 | } | |
| 46 | 51 | ||
| 47 | 52 | func (r *remoteControlPlane) Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) { | |
| @@ -68,6 +73,14 @@ func (r *remoteControlPlane) DeleteAttachmentSecret(ctx context.Context, domainN | |||
| 68 | 73 | return r.client.DeleteAttachmentSecret(ctx, domainName, service, key) | |
| 69 | 74 | } | |
| 70 | 75 | ||
| 76 | + | func (r *remoteControlPlane) ListOrphanedAttachments(ctx context.Context) ([]domain.CleanupAttachment, error) { | |
| 77 | + | return r.client.ListOrphanedAttachments(ctx) | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func (r *remoteControlPlane) CleanupOrphanedAttachments(ctx context.Context, owner string, stop bool) (*domain.CleanupReport, error) { | |
| 81 | + | return r.client.CleanupOrphanedAttachments(ctx, owner, stop) | |
| 82 | + | } | |
| 83 | + | ||
| 71 | 84 | func (r *remoteControlPlane) GetAllAttachmentsConfig(ctx context.Context) (map[string][]string, error) { | |
| 72 | 85 | return r.client.GetAllAttachmentsConfig(ctx) | |
| 73 | 86 | } | |
| @@ -41,7 +41,12 @@ func (r *remoteControlPlane) UpdateRoute(ctx context.Context, route domain.Route | |||||
| 41 | } | 41 | } | ||
| 42 | 42 | ||||
| 43 | func (r *remoteControlPlane) RemoveRoute(ctx context.Context, routeDomain string) error { | 43 | func (r *remoteControlPlane) RemoveRoute(ctx context.Context, routeDomain string) error { | ||
| 44 | - | return r.client.RemoveRoute(ctx, routeDomain) | 44 | + | _, err := r.RemoveRouteWithCleanup(ctx, routeDomain) |
| 45 | + | return err | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func (r *remoteControlPlane) RemoveRouteWithCleanup(ctx context.Context, routeDomain string) (*dto.RouteDeleteResponse, error) { | |||
| 49 | + | return r.client.RemoveRouteWithCleanup(ctx, routeDomain) | |||
| 45 | } | 50 | } | ||
| 46 | 51 | ||||
| 47 | func (r *remoteControlPlane) Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) { | 52 | func (r *remoteControlPlane) Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) { | ||
| @@ -68,6 +73,14 @@ func (r *remoteControlPlane) DeleteAttachmentSecret(ctx context.Context, domainN | |||||
| 68 | return r.client.DeleteAttachmentSecret(ctx, domainName, service, key) | 73 | return r.client.DeleteAttachmentSecret(ctx, domainName, service, key) | ||
| 69 | } | 74 | } | ||
| 70 | 75 | ||||
| 76 | + | func (r *remoteControlPlane) ListOrphanedAttachments(ctx context.Context) ([]domain.CleanupAttachment, error) { | |||
| 77 | + | return r.client.ListOrphanedAttachments(ctx) | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func (r *remoteControlPlane) CleanupOrphanedAttachments(ctx context.Context, owner string, stop bool) (*domain.CleanupReport, error) { | |||
| 81 | + | return r.client.CleanupOrphanedAttachments(ctx, owner, stop) | |||
| 82 | + | } | |||
| 83 | + | ||||
| 71 | func (r *remoteControlPlane) GetAllAttachmentsConfig(ctx context.Context) (map[string][]string, error) { | 84 | func (r *remoteControlPlane) GetAllAttachmentsConfig(ctx context.Context) (map[string][]string, error) { | ||
| 72 | return r.client.GetAllAttachmentsConfig(ctx) | 85 | return r.client.GetAllAttachmentsConfig(ctx) | ||
| 73 | } | 86 | } | ||
A internal/adapters/in/cli/mocks/mock_control_plane.go +2760 -0 large diff (2760 lines changed): collapsed
M internal/adapters/in/cli/push_test.go +8 -0
| @@ -90,6 +90,14 @@ func (c *resolveFromImageTestControlPlane) GetAttachmentsConfig(context.Context, | |||
| 90 | 90 | panic("unexpected call") | |
| 91 | 91 | } | |
| 92 | 92 | ||
| 93 | + | func (c *resolveFromImageTestControlPlane) ListOrphanedAttachments(context.Context) ([]domain.CleanupAttachment, error) { | |
| 94 | + | panic("unexpected call") | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func (c *resolveFromImageTestControlPlane) CleanupOrphanedAttachments(context.Context, string, bool) (*domain.CleanupReport, error) { | |
| 98 | + | panic("unexpected call") | |
| 99 | + | } | |
| 100 | + | ||
| 93 | 101 | func (c *resolveFromImageTestControlPlane) FindAttachmentTargetsByImage(context.Context, string) ([]string, error) { | |
| 94 | 102 | panic("unexpected call") | |
| 95 | 103 | } | |
| @@ -90,6 +90,14 @@ func (c *resolveFromImageTestControlPlane) GetAttachmentsConfig(context.Context, | |||||
| 90 | panic("unexpected call") | 90 | panic("unexpected call") | ||
| 91 | } | 91 | } | ||
| 92 | 92 | ||||
| 93 | + | func (c *resolveFromImageTestControlPlane) ListOrphanedAttachments(context.Context) ([]domain.CleanupAttachment, error) { | |||
| 94 | + | panic("unexpected call") | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func (c *resolveFromImageTestControlPlane) CleanupOrphanedAttachments(context.Context, string, bool) (*domain.CleanupReport, error) { | |||
| 98 | + | panic("unexpected call") | |||
| 99 | + | } | |||
| 100 | + | ||||
| 93 | func (c *resolveFromImageTestControlPlane) FindAttachmentTargetsByImage(context.Context, string) ([]string, error) { | 101 | func (c *resolveFromImageTestControlPlane) FindAttachmentTargetsByImage(context.Context, string) ([]string, error) { | ||
| 94 | panic("unexpected call") | 102 | panic("unexpected call") | ||
| 95 | } | 103 | } | ||
M internal/adapters/in/cli/remote/client.go +51 -2
| @@ -548,11 +548,21 @@ func (c *Client) UpdateRoute(ctx context.Context, route domain.Route) error { | |||
| 548 | 548 | ||
| 549 | 549 | // RemoveRoute removes a route by domain. | |
| 550 | 550 | func (c *Client) RemoveRoute(ctx context.Context, routeDomain string) error { | |
| 551 | + | _, err := c.RemoveRouteWithCleanup(ctx, routeDomain) | |
| 552 | + | return err | |
| 553 | + | } | |
| 554 | + | ||
| 555 | + | // RemoveRouteWithCleanup removes a route and returns the server cleanup report. | |
| 556 | + | func (c *Client) RemoveRouteWithCleanup(ctx context.Context, routeDomain string) (*dto.RouteDeleteResponse, error) { | |
| 551 | 557 | resp, err := c.request(ctx, http.MethodDelete, "/routes/"+url.PathEscape(routeDomain), nil) | |
| 552 | 558 | if err != nil { | |
| 553 | - | return err | |
| 559 | + | return nil, fmt.Errorf("delete route %s failed: %w", routeDomain, err) | |
| 554 | 560 | } | |
| 555 | - | return parseResponse(resp, nil) | |
| 561 | + | var result dto.RouteDeleteResponse | |
| 562 | + | if err := parseResponse(resp, &result); err != nil { | |
| 563 | + | return nil, fmt.Errorf("parse delete route %s response: %w", routeDomain, err) | |
| 564 | + | } | |
| 565 | + | return &result, nil | |
| 556 | 566 | } | |
| 557 | 567 | ||
| 558 | 568 | func (c *Client) Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) { | |
| @@ -1025,6 +1035,45 @@ func (c *Client) StreamContainerLogs(ctx context.Context, logDomain string, line | |||
| 1025 | 1035 | ||
| 1026 | 1036 | // Attachments Config API | |
| 1027 | 1037 | ||
| 1038 | + | // ListOrphanedAttachments returns running attachment containers no longer configured. | |
| 1039 | + | func (c *Client) ListOrphanedAttachments(ctx context.Context) ([]domain.CleanupAttachment, error) { | |
| 1040 | + | resp, err := c.request(ctx, http.MethodGet, "/attachments/orphans", nil) | |
| 1041 | + | if err != nil { | |
| 1042 | + | return nil, err | |
| 1043 | + | } | |
| 1044 | + | var result struct { | |
| 1045 | + | Attachments []domain.CleanupAttachment `json:"attachments"` | |
| 1046 | + | } | |
| 1047 | + | if err := parseResponse(resp, &result); err != nil { | |
| 1048 | + | return nil, err | |
| 1049 | + | } | |
| 1050 | + | return result.Attachments, nil | |
| 1051 | + | } | |
| 1052 | + | ||
| 1053 | + | // CleanupOrphanedAttachments optionally stops/removes orphaned attachment containers. | |
| 1054 | + | func (c *Client) CleanupOrphanedAttachments(ctx context.Context, owner string, stop bool) (*domain.CleanupReport, error) { | |
| 1055 | + | path := "/attachments/prune" | |
| 1056 | + | params := url.Values{} | |
| 1057 | + | if stop { | |
| 1058 | + | params.Set("stop", "true") | |
| 1059 | + | } | |
| 1060 | + | if owner != "" { | |
| 1061 | + | params.Set("owner", owner) | |
| 1062 | + | } | |
| 1063 | + | if encoded := params.Encode(); encoded != "" { | |
| 1064 | + | path += "?" + encoded | |
| 1065 | + | } | |
| 1066 | + | resp, err := c.request(ctx, http.MethodPost, path, nil) | |
| 1067 | + | if err != nil { | |
| 1068 | + | return nil, fmt.Errorf("cleanup orphaned attachments failed: %w", err) | |
| 1069 | + | } | |
| 1070 | + | var result domain.CleanupReport | |
| 1071 | + | if err := parseResponse(resp, &result); err != nil { | |
| 1072 | + | return nil, fmt.Errorf("parse orphaned attachment cleanup response: %w", err) | |
| 1073 | + | } | |
| 1074 | + | return &result, nil | |
| 1075 | + | } | |
| 1076 | + | ||
| 1028 | 1077 | // GetAllAttachmentsConfig returns all configured attachments. | |
| 1029 | 1078 | func (c *Client) GetAllAttachmentsConfig(ctx context.Context) (map[string][]string, error) { | |
| 1030 | 1079 | resp, err := c.request(ctx, http.MethodGet, "/attachments", nil) | |
| @@ -548,11 +548,21 @@ func (c *Client) UpdateRoute(ctx context.Context, route domain.Route) error { | |||||
| 548 | 548 | ||||
| 549 | // RemoveRoute removes a route by domain. | 549 | // RemoveRoute removes a route by domain. | ||
| 550 | func (c *Client) RemoveRoute(ctx context.Context, routeDomain string) error { | 550 | func (c *Client) RemoveRoute(ctx context.Context, routeDomain string) error { | ||
| 551 | + | _, err := c.RemoveRouteWithCleanup(ctx, routeDomain) | |||
| 552 | + | return err | |||
| 553 | + | } | |||
| 554 | + | ||||
| 555 | + | // RemoveRouteWithCleanup removes a route and returns the server cleanup report. | |||
| 556 | + | func (c *Client) RemoveRouteWithCleanup(ctx context.Context, routeDomain string) (*dto.RouteDeleteResponse, error) { | |||
| 551 | resp, err := c.request(ctx, http.MethodDelete, "/routes/"+url.PathEscape(routeDomain), nil) | 557 | resp, err := c.request(ctx, http.MethodDelete, "/routes/"+url.PathEscape(routeDomain), nil) | ||
| 552 | if err != nil { | 558 | if err != nil { | ||
| 553 | - | return err | 559 | + | return nil, fmt.Errorf("delete route %s failed: %w", routeDomain, err) |
| 554 | } | 560 | } | ||
| 555 | - | return parseResponse(resp, nil) | 561 | + | var result dto.RouteDeleteResponse |
| 562 | + | if err := parseResponse(resp, &result); err != nil { | |||
| 563 | + | return nil, fmt.Errorf("parse delete route %s response: %w", routeDomain, err) | |||
| 564 | + | } | |||
| 565 | + | return &result, nil | |||
| 556 | } | 566 | } | ||
| 557 | 567 | ||||
| 558 | func (c *Client) Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) { | 568 | func (c *Client) Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) { | ||
| @@ -1025,6 +1035,45 @@ func (c *Client) StreamContainerLogs(ctx context.Context, logDomain string, line | |||||
| 1025 | 1035 | ||||
| 1026 | // Attachments Config API | 1036 | // Attachments Config API | ||
| 1027 | 1037 | ||||
| 1038 | + | // ListOrphanedAttachments returns running attachment containers no longer configured. | |||
| 1039 | + | func (c *Client) ListOrphanedAttachments(ctx context.Context) ([]domain.CleanupAttachment, error) { | |||
| 1040 | + | resp, err := c.request(ctx, http.MethodGet, "/attachments/orphans", nil) | |||
| 1041 | + | if err != nil { | |||
| 1042 | + | return nil, err | |||
| 1043 | + | } | |||
| 1044 | + | var result struct { | |||
| 1045 | + | Attachments []domain.CleanupAttachment `json:"attachments"` | |||
| 1046 | + | } | |||
| 1047 | + | if err := parseResponse(resp, &result); err != nil { | |||
| 1048 | + | return nil, err | |||
| 1049 | + | } | |||
| 1050 | + | return result.Attachments, nil | |||
| 1051 | + | } | |||
| 1052 | + | ||||
| 1053 | + | // CleanupOrphanedAttachments optionally stops/removes orphaned attachment containers. | |||
| 1054 | + | func (c *Client) CleanupOrphanedAttachments(ctx context.Context, owner string, stop bool) (*domain.CleanupReport, error) { | |||
| 1055 | + | path := "/attachments/prune" | |||
| 1056 | + | params := url.Values{} | |||
| 1057 | + | if stop { | |||
| 1058 | + | params.Set("stop", "true") | |||
| 1059 | + | } | |||
| 1060 | + | if owner != "" { | |||
| 1061 | + | params.Set("owner", owner) | |||
| 1062 | + | } | |||
| 1063 | + | if encoded := params.Encode(); encoded != "" { | |||
| 1064 | + | path += "?" + encoded | |||
| 1065 | + | } | |||
| 1066 | + | resp, err := c.request(ctx, http.MethodPost, path, nil) | |||
| 1067 | + | if err != nil { | |||
| 1068 | + | return nil, fmt.Errorf("cleanup orphaned attachments failed: %w", err) | |||
| 1069 | + | } | |||
| 1070 | + | var result domain.CleanupReport | |||
| 1071 | + | if err := parseResponse(resp, &result); err != nil { | |||
| 1072 | + | return nil, fmt.Errorf("parse orphaned attachment cleanup response: %w", err) | |||
| 1073 | + | } | |||
| 1074 | + | return &result, nil | |||
| 1075 | + | } | |||
| 1076 | + | ||||
| 1028 | // GetAllAttachmentsConfig returns all configured attachments. | 1077 | // GetAllAttachmentsConfig returns all configured attachments. | ||
| 1029 | func (c *Client) GetAllAttachmentsConfig(ctx context.Context) (map[string][]string, error) { | 1078 | func (c *Client) GetAllAttachmentsConfig(ctx context.Context) (map[string][]string, error) { | ||
| 1030 | resp, err := c.request(ctx, http.MethodGet, "/attachments", nil) | 1079 | resp, err := c.request(ctx, http.MethodGet, "/attachments", nil) | ||
M internal/adapters/in/cli/routes.go +408 -4
| @@ -13,6 +13,7 @@ import ( | |||
| 13 | 13 | ||
| 14 | 14 | "github.com/spf13/cobra" | |
| 15 | 15 | ||
| 16 | + | "github.com/bnema/gordon/internal/adapters/dto" | |
| 16 | 17 | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |
| 17 | 18 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/components" | |
| 18 | 19 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" | |
| @@ -127,6 +128,18 @@ type routeStatusSection struct { | |||
| 127 | 128 | Routes []routeStatusItem `json:"routes,omitempty"` | |
| 128 | 129 | } | |
| 129 | 130 | ||
| 131 | + | type routeDiagnosis struct { | |
| 132 | + | Domain string `json:"domain"` | |
| 133 | + | Configured bool `json:"configured"` | |
| 134 | + | Route *domain.Route `json:"route,omitempty"` | |
| 135 | + | Runtime *remote.RouteInfo `json:"runtime,omitempty"` | |
| 136 | + | Health *remote.RouteHealth `json:"health,omitempty"` | |
| 137 | + | Volumes []dto.Volume `json:"volumes,omitempty"` | |
| 138 | + | OrphanedAttachments []domain.CleanupAttachment `json:"orphaned_attachments,omitempty"` | |
| 139 | + | Warnings []string `json:"warnings,omitempty"` | |
| 140 | + | Hints []string `json:"hints,omitempty"` | |
| 141 | + | } | |
| 142 | + | ||
| 130 | 143 | type routesListDeps struct { | |
| 131 | 144 | explicitRemote func() (*remote.ResolvedRemote, bool, error) | |
| 132 | 145 | loadLocal func(context.Context, string) (routeListSection, error) | |
| @@ -194,6 +207,8 @@ the local Gordon configuration.`, | |||
| 194 | 207 | cmd.AddCommand(newRoutesStatusCmd()) | |
| 195 | 208 | ||
| 196 | 209 | cmd.AddCommand(newRoutesShowCmd()) | |
| 210 | + | cmd.AddCommand(newRoutesDiagnoseCmd()) | |
| 211 | + | cmd.AddCommand(newRoutesPurgeCmd()) | |
| 197 | 212 | cmd.AddCommand(newRoutesAddCmd()) | |
| 198 | 213 | cmd.AddCommand(newRoutesRemoveCmd()) | |
| 199 | 214 | ||
| @@ -1078,7 +1093,287 @@ func writeRouteShowText(out io.Writer, domainName, image, containerStatus string | |||
| 1078 | 1093 | return nil | |
| 1079 | 1094 | } | |
| 1080 | 1095 | ||
| 1081 | - | // newRoutesAddCmd creates the routes add command. | |
| 1096 | + | // newRoutesDiagnoseCmd creates the routes diagnose command. | |
| 1097 | + | func newRoutesDiagnoseCmd() *cobra.Command { | |
| 1098 | + | var jsonOut bool | |
| 1099 | + | cmd := &cobra.Command{ | |
| 1100 | + | Use: "diagnose <domain>", | |
| 1101 | + | Short: "Diagnose route configuration, runtime state, and cleanup leftovers", | |
| 1102 | + | Args: cobra.ExactArgs(1), | |
| 1103 | + | RunE: func(cmd *cobra.Command, args []string) error { | |
| 1104 | + | return runRouteDiagnosis(cmd.Context(), args[0], cmd.OutOrStdout(), jsonOut) | |
| 1105 | + | }, | |
| 1106 | + | } | |
| 1107 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |
| 1108 | + | return cmd | |
| 1109 | + | } | |
| 1110 | + | ||
| 1111 | + | func runRouteDiagnosis(ctx context.Context, domainName string, out io.Writer, jsonOut bool) error { | |
| 1112 | + | handle, err := resolveControlPlaneForRouteDomain(ctx, domainName) | |
| 1113 | + | if err != nil { | |
| 1114 | + | return err | |
| 1115 | + | } | |
| 1116 | + | defer handle.close() | |
| 1117 | + | diag, err := buildRouteDiagnosis(ctx, handle.plane, domainName) | |
| 1118 | + | if err != nil { | |
| 1119 | + | return err | |
| 1120 | + | } | |
| 1121 | + | if jsonOut { | |
| 1122 | + | return writeJSON(out, diag) | |
| 1123 | + | } | |
| 1124 | + | return writeRouteDiagnosisText(out, diag) | |
| 1125 | + | } | |
| 1126 | + | ||
| 1127 | + | func buildRouteDiagnosis(ctx context.Context, cp ControlPlane, domainName string) (*routeDiagnosis, error) { | |
| 1128 | + | diag := &routeDiagnosis{Domain: domainName} | |
| 1129 | + | loadRouteDiagnosisConfig(ctx, cp, domainName, diag) | |
| 1130 | + | loadRouteDiagnosisRuntime(ctx, cp, domainName, diag) | |
| 1131 | + | loadRouteDiagnosisHealth(ctx, cp, domainName, diag) | |
| 1132 | + | loadRouteDiagnosisVolumes(ctx, cp, domainName, diag) | |
| 1133 | + | loadRouteDiagnosisOrphanedAttachments(ctx, cp, domainName, diag) | |
| 1134 | + | finalizeRouteDiagnosis(diag) | |
| 1135 | + | return diag, nil | |
| 1136 | + | } | |
| 1137 | + | ||
| 1138 | + | func loadRouteDiagnosisConfig(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) { | |
| 1139 | + | route, err := cp.GetRoute(ctx, domainName) | |
| 1140 | + | if err == nil && route != nil { | |
| 1141 | + | diag.Configured = true | |
| 1142 | + | diag.Route = route | |
| 1143 | + | return | |
| 1144 | + | } | |
| 1145 | + | if err != nil && !errors.Is(err, domain.ErrRouteNotFound) { | |
| 1146 | + | diag.Warnings = append(diag.Warnings, "failed to load route config: "+err.Error()) | |
| 1147 | + | } | |
| 1148 | + | } | |
| 1149 | + | ||
| 1150 | + | func loadRouteDiagnosisRuntime(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) { | |
| 1151 | + | routes, err := cp.ListRoutesWithDetails(ctx) | |
| 1152 | + | if err != nil { | |
| 1153 | + | diag.Warnings = append(diag.Warnings, "failed to load route runtime details: "+err.Error()) | |
| 1154 | + | return | |
| 1155 | + | } | |
| 1156 | + | for _, info := range routes { | |
| 1157 | + | if strings.EqualFold(info.Domain, domainName) { | |
| 1158 | + | copy := info | |
| 1159 | + | diag.Runtime = © | |
| 1160 | + | return | |
| 1161 | + | } | |
| 1162 | + | } | |
| 1163 | + | } | |
| 1164 | + | ||
| 1165 | + | func loadRouteDiagnosisHealth(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) { | |
| 1166 | + | if health, err := cp.GetHealth(ctx); err == nil && health != nil { | |
| 1167 | + | diag.Health = health[domainName] | |
| 1168 | + | } | |
| 1169 | + | } | |
| 1170 | + | ||
| 1171 | + | func loadRouteDiagnosisVolumes(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) { | |
| 1172 | + | if volumes, err := cp.ListVolumes(ctx); err == nil { | |
| 1173 | + | diag.Volumes = volumesForRouteDiagnosis(volumes, domainName) | |
| 1174 | + | } | |
| 1175 | + | } | |
| 1176 | + | ||
| 1177 | + | func loadRouteDiagnosisOrphanedAttachments(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) { | |
| 1178 | + | lister, ok := cp.(interface { | |
| 1179 | + | ListOrphanedAttachments(context.Context) ([]domain.CleanupAttachment, error) | |
| 1180 | + | }) | |
| 1181 | + | if !ok { | |
| 1182 | + | return | |
| 1183 | + | } | |
| 1184 | + | if attachments, err := lister.ListOrphanedAttachments(ctx); err == nil { | |
| 1185 | + | diag.OrphanedAttachments = attachmentsForRouteDiagnosis(attachments, domainName) | |
| 1186 | + | } | |
| 1187 | + | } | |
| 1188 | + | ||
| 1189 | + | func finalizeRouteDiagnosis(diag *routeDiagnosis) { | |
| 1190 | + | if !diag.Configured && diag.Runtime != nil { | |
| 1191 | + | diag.Warnings = append(diag.Warnings, "route is not configured but runtime state still exists") | |
| 1192 | + | } | |
| 1193 | + | if len(diag.Volumes) > 0 { | |
| 1194 | + | diag.Hints = append(diag.Hints, "persistent volumes are preserved by default") | |
| 1195 | + | } | |
| 1196 | + | if len(diag.OrphanedAttachments) > 0 { | |
| 1197 | + | diag.Hints = append(diag.Hints, "run 'gordon attachments prune --stop' to stop orphaned attachment containers while preserving volumes") | |
| 1198 | + | } | |
| 1199 | + | } | |
| 1200 | + | ||
| 1201 | + | func volumesForRouteDiagnosis(volumes []dto.Volume, domainName string) []dto.Volume { | |
| 1202 | + | needle := strings.ReplaceAll(strings.ToLower(domainName), ".", "-") | |
| 1203 | + | var out []dto.Volume | |
| 1204 | + | for _, volume := range volumes { | |
| 1205 | + | if strings.Contains(strings.ToLower(volume.Name), needle) { | |
| 1206 | + | out = append(out, volume) | |
| 1207 | + | } | |
| 1208 | + | } | |
| 1209 | + | return out | |
| 1210 | + | } | |
| 1211 | + | ||
| 1212 | + | func attachmentsForRouteDiagnosis(attachments []domain.CleanupAttachment, domainName string) []domain.CleanupAttachment { | |
| 1213 | + | var out []domain.CleanupAttachment | |
| 1214 | + | for _, attachment := range attachments { | |
| 1215 | + | if strings.EqualFold(attachment.Owner, domainName) { | |
| 1216 | + | out = append(out, attachment) | |
| 1217 | + | } | |
| 1218 | + | } | |
| 1219 | + | return out | |
| 1220 | + | } | |
| 1221 | + | ||
| 1222 | + | func writeRouteDiagnosisText(out io.Writer, diag *routeDiagnosis) error { | |
| 1223 | + | if err := cliWriteLine(out, cliRenderTitle("Route diagnosis: "+diag.Domain)); err != nil { | |
| 1224 | + | return err | |
| 1225 | + | } | |
| 1226 | + | if err := writeRouteDiagnosisSummary(out, diag); err != nil { | |
| 1227 | + | return err | |
| 1228 | + | } | |
| 1229 | + | if err := writeRouteDiagnosisLeftovers(out, diag); err != nil { | |
| 1230 | + | return err | |
| 1231 | + | } | |
| 1232 | + | return writeCleanupMessages(out, diag.Warnings, diag.Hints) | |
| 1233 | + | } | |
| 1234 | + | ||
| 1235 | + | func writeRouteDiagnosisSummary(out io.Writer, diag *routeDiagnosis) error { | |
| 1236 | + | if diag.Configured && diag.Route != nil { | |
| 1237 | + | if err := cliWriteLine(out, cliRenderMeta("Image:", diag.Route.Image)); err != nil { | |
| 1238 | + | return err | |
| 1239 | + | } | |
| 1240 | + | } else if err := cliWriteLine(out, cliRenderWarning("Route is not configured")); err != nil { | |
| 1241 | + | return err | |
| 1242 | + | } | |
| 1243 | + | if diag.Runtime == nil { | |
| 1244 | + | return nil | |
| 1245 | + | } | |
| 1246 | + | return cliWriteLine(out, cliRenderMeta("Container:", diag.Runtime.ContainerStatus+" "+shortContainerID(diag.Runtime.ContainerID))) | |
| 1247 | + | } | |
| 1248 | + | ||
| 1249 | + | func writeRouteDiagnosisLeftovers(out io.Writer, diag *routeDiagnosis) error { | |
| 1250 | + | for _, volume := range diag.Volumes { | |
| 1251 | + | if err := cliWriteLine(out, cliRenderMeta("Preserved volume:", volume.Name)); err != nil { | |
| 1252 | + | return err | |
| 1253 | + | } | |
| 1254 | + | } | |
| 1255 | + | for _, attachment := range diag.OrphanedAttachments { | |
| 1256 | + | if err := cliWriteLine(out, cliRenderWarning("Orphaned attachment: "+attachment.Name)); err != nil { | |
| 1257 | + | return err | |
| 1258 | + | } | |
| 1259 | + | } | |
| 1260 | + | return nil | |
| 1261 | + | } | |
| 1262 | + | ||
| 1263 | + | func newRoutesPurgeCmd() *cobra.Command { | |
| 1264 | + | var jsonOut bool | |
| 1265 | + | var force bool | |
| 1266 | + | var includeAttachments bool | |
| 1267 | + | var includeVolumes bool | |
| 1268 | + | cmd := &cobra.Command{ | |
| 1269 | + | Use: "purge <domain>", | |
| 1270 | + | Short: "Review or explicitly purge retained route resources", | |
| 1271 | + | Args: cobra.ExactArgs(1), | |
| 1272 | + | RunE: func(cmd *cobra.Command, args []string) error { | |
| 1273 | + | return runRoutePurgeCommand(cmd.Context(), args[0], cmd.OutOrStdout(), routePurgeOptions{Force: force, Attachments: includeAttachments, Volumes: includeVolumes}, jsonOut) | |
| 1274 | + | }, | |
| 1275 | + | } | |
| 1276 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |
| 1277 | + | cmd.Flags().BoolVar(&force, "force", false, "Execute purge actions; default is dry-run") | |
| 1278 | + | cmd.Flags().BoolVar(&includeAttachments, "attachments", false, "Include orphaned attachment containers") | |
| 1279 | + | cmd.Flags().BoolVar(&includeVolumes, "volumes", false, "Include preserved volumes in the purge plan") | |
| 1280 | + | return cmd | |
| 1281 | + | } | |
| 1282 | + | ||
| 1283 | + | type routePurgeOptions struct { | |
| 1284 | + | Force bool | |
| 1285 | + | Attachments bool | |
| 1286 | + | Volumes bool | |
| 1287 | + | } | |
| 1288 | + | ||
| 1289 | + | func runRoutePurgeCommand(ctx context.Context, domainName string, out io.Writer, opts routePurgeOptions, jsonOut bool) error { | |
| 1290 | + | handle, err := resolveControlPlaneForRouteDomain(ctx, domainName) | |
| 1291 | + | if err != nil { | |
| 1292 | + | return err | |
| 1293 | + | } | |
| 1294 | + | defer handle.close() | |
| 1295 | + | report, err := runRoutePurge(ctx, handle.plane, domainName, opts) | |
| 1296 | + | if err != nil { | |
| 1297 | + | return err | |
| 1298 | + | } | |
| 1299 | + | if jsonOut { | |
| 1300 | + | return writeJSON(out, report) | |
| 1301 | + | } | |
| 1302 | + | return writeRoutePurgeText(out, report, opts.Force) | |
| 1303 | + | } | |
| 1304 | + | ||
| 1305 | + | func runRoutePurge(ctx context.Context, cp ControlPlane, domainName string, opts routePurgeOptions) (*domain.CleanupReport, error) { | |
| 1306 | + | diag, err := buildRouteDiagnosis(ctx, cp, domainName) | |
| 1307 | + | if err != nil { | |
| 1308 | + | return nil, err | |
| 1309 | + | } | |
| 1310 | + | report := &domain.CleanupReport{Domain: domainName} | |
| 1311 | + | if diag.Runtime != nil && diag.Runtime.ContainerID != "" { | |
| 1312 | + | report.OrphanedEntities = append(report.OrphanedEntities, domain.CleanupOrphanedEntity{Kind: "route_container", ID: diag.Runtime.ContainerID, Status: diag.Runtime.ContainerStatus, Reason: "route runtime state matched purge target"}) | |
| 1313 | + | } | |
| 1314 | + | if opts.Volumes { | |
| 1315 | + | for _, volume := range diag.Volumes { | |
| 1316 | + | report.PreservedVolumes = append(report.PreservedVolumes, domain.CleanupVolume{Name: volume.Name, Reason: "volume purge requires explicit --volumes and runtime support"}) | |
| 1317 | + | } | |
| 1318 | + | } | |
| 1319 | + | if !opts.Attachments || !opts.Force { | |
| 1320 | + | report.PreservedAttachments = append(report.PreservedAttachments, diag.OrphanedAttachments...) | |
| 1321 | + | } | |
| 1322 | + | if opts.Attachments && opts.Force { | |
| 1323 | + | cleaner, ok := cp.(interface { | |
| 1324 | + | CleanupOrphanedAttachments(context.Context, string, bool) (*domain.CleanupReport, error) | |
| 1325 | + | }) | |
| 1326 | + | if !ok { | |
| 1327 | + | return nil, fmt.Errorf("attachment purge is unavailable") | |
| 1328 | + | } | |
| 1329 | + | attachmentReport, err := cleaner.CleanupOrphanedAttachments(ctx, domainName, true) | |
| 1330 | + | if err != nil { | |
| 1331 | + | return nil, err | |
| 1332 | + | } | |
| 1333 | + | report.RemovedContainers = append(report.RemovedContainers, attachmentReport.RemovedContainers...) | |
| 1334 | + | report.PreservedVolumes = append(report.PreservedVolumes, attachmentReport.PreservedVolumes...) | |
| 1335 | + | report.PreservedAttachments = append(report.PreservedAttachments, attachmentReport.PreservedAttachments...) | |
| 1336 | + | report.OrphanedEntities = append(report.OrphanedEntities, attachmentReport.OrphanedEntities...) | |
| 1337 | + | report.Warnings = append(report.Warnings, attachmentReport.Warnings...) | |
| 1338 | + | report.Hints = append(report.Hints, attachmentReport.Hints...) | |
| 1339 | + | report.PartialFailures = append(report.PartialFailures, attachmentReport.PartialFailures...) | |
| 1340 | + | } | |
| 1341 | + | if opts.Volumes { | |
| 1342 | + | report.Warnings = append(report.Warnings, "volume purge is not executed yet; preserved volumes are reported for manual review") | |
| 1343 | + | } | |
| 1344 | + | if !opts.Force { | |
| 1345 | + | report.Hints = append(report.Hints, "dry-run only; add --force with explicit category flags to execute supported purge actions") | |
| 1346 | + | } | |
| 1347 | + | return report, nil | |
| 1348 | + | } | |
| 1349 | + | ||
| 1350 | + | func writeRoutePurgeText(out io.Writer, report *domain.CleanupReport, force bool) error { | |
| 1351 | + | mode := "Purge dry-run" | |
| 1352 | + | if force { | |
| 1353 | + | mode = "Purge completed" | |
| 1354 | + | } | |
| 1355 | + | if err := cliWriteLine(out, cliRenderTitle(mode+": "+report.Domain)); err != nil { | |
| 1356 | + | return err | |
| 1357 | + | } | |
| 1358 | + | if err := writePreservedAttachments(out, report.PreservedAttachments); err != nil { | |
| 1359 | + | return err | |
| 1360 | + | } | |
| 1361 | + | if err := writeRemovedContainers(out, report.RemovedContainers); err != nil { | |
| 1362 | + | return err | |
| 1363 | + | } | |
| 1364 | + | for _, entity := range report.OrphanedEntities { | |
| 1365 | + | if err := cliWriteLine(out, cliRenderWarning("Purge candidate: "+entity.Kind+" "+entity.ID)); err != nil { | |
| 1366 | + | return err | |
| 1367 | + | } | |
| 1368 | + | } | |
| 1369 | + | for _, volume := range report.PreservedVolumes { | |
| 1370 | + | if err := cliWriteLine(out, cliRenderMeta("Preserved volume:", volume.Name)); err != nil { | |
| 1371 | + | return err | |
| 1372 | + | } | |
| 1373 | + | } | |
| 1374 | + | return writeCleanupMessages(out, report.Warnings, report.Hints) | |
| 1375 | + | } | |
| 1376 | + | ||
| 1082 | 1377 | func newRoutesAddCmd() *cobra.Command { | |
| 1083 | 1378 | var image string | |
| 1084 | 1379 | ||
| @@ -1183,12 +1478,22 @@ Examples: | |||
| 1183 | 1478 | return err | |
| 1184 | 1479 | } | |
| 1185 | 1480 | defer handle.close() | |
| 1186 | - | if err := handle.plane.RemoveRoute(ctx, routeDomain); err != nil { | |
| 1481 | + | var cleanup *dto.CleanupReport | |
| 1482 | + | if remover, ok := handle.plane.(interface { | |
| 1483 | + | RemoveRouteWithCleanup(context.Context, string) (*dto.RouteDeleteResponse, error) | |
| 1484 | + | }); ok { | |
| 1485 | + | resp, err := remover.RemoveRouteWithCleanup(ctx, routeDomain) | |
| 1486 | + | if err != nil { | |
| 1487 | + | return fmt.Errorf("failed to remove route: %w", err) | |
| 1488 | + | } | |
| 1489 | + | if resp != nil { | |
| 1490 | + | cleanup = resp.Cleanup | |
| 1491 | + | } | |
| 1492 | + | } else if err := handle.plane.RemoveRoute(ctx, routeDomain); err != nil { | |
| 1187 | 1493 | return fmt.Errorf("failed to remove route: %w", err) | |
| 1188 | 1494 | } | |
| 1189 | 1495 | ||
| 1190 | - | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Route removed: %s", routeDomain))) | |
| 1191 | - | return nil | |
| 1496 | + | return writeRouteRemoveText(cmd.OutOrStdout(), routeDomain, cleanup) | |
| 1192 | 1497 | }, | |
| 1193 | 1498 | } | |
| 1194 | 1499 | ||
| @@ -1197,6 +1502,105 @@ Examples: | |||
| 1197 | 1502 | return cmd | |
| 1198 | 1503 | } | |
| 1199 | 1504 | ||
| 1505 | + | func writeRouteRemoveText(out io.Writer, routeDomain string, cleanup *dto.CleanupReport) error { | |
| 1506 | + | if err := cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Route removed: %s", routeDomain))); err != nil { | |
| 1507 | + | return err | |
| 1508 | + | } | |
| 1509 | + | if cleanup == nil { | |
| 1510 | + | return nil | |
| 1511 | + | } | |
| 1512 | + | cleanupReport := cleanupReportDTOToDomain(cleanup) | |
| 1513 | + | if err := writeRemovedContainers(out, cleanupReport.RemovedContainers); err != nil { | |
| 1514 | + | return err | |
| 1515 | + | } | |
| 1516 | + | if err := writePreservedAttachments(out, cleanupReport.PreservedAttachments); err != nil { | |
| 1517 | + | return err | |
| 1518 | + | } | |
| 1519 | + | return writeCleanupMessages(out, cleanupReport.Warnings, cleanupReport.Hints) | |
| 1520 | + | } | |
| 1521 | + | ||
| 1522 | + | func cleanupReportDTOToDomain(cleanup *dto.CleanupReport) *domain.CleanupReport { | |
| 1523 | + | if cleanup == nil { | |
| 1524 | + | return nil | |
| 1525 | + | } | |
| 1526 | + | out := &domain.CleanupReport{ | |
| 1527 | + | Domain: cleanup.Domain, | |
| 1528 | + | Warnings: append([]string(nil), cleanup.Warnings...), | |
| 1529 | + | Hints: append([]string(nil), cleanup.Hints...), | |
| 1530 | + | } | |
| 1531 | + | for _, item := range cleanup.RemovedContainers { | |
| 1532 | + | out.RemovedContainers = append(out.RemovedContainers, domain.CleanupContainer(item)) | |
| 1533 | + | } | |
| 1534 | + | for _, item := range cleanup.PreservedAttachments { | |
| 1535 | + | out.PreservedAttachments = append(out.PreservedAttachments, domain.CleanupAttachment(item)) | |
| 1536 | + | } | |
| 1537 | + | return out | |
| 1538 | + | } | |
| 1539 | + | ||
| 1540 | + | func writeRemovedContainers(out io.Writer, containers []domain.CleanupContainer) error { | |
| 1541 | + | if len(containers) == 0 { | |
| 1542 | + | return nil | |
| 1543 | + | } | |
| 1544 | + | if err := cliWriteLine(out, ""); err != nil { | |
| 1545 | + | return err | |
| 1546 | + | } | |
| 1547 | + | if err := cliWriteLine(out, styles.Theme.Bold.Render("Removed containers:")); err != nil { | |
| 1548 | + | return err | |
| 1549 | + | } | |
| 1550 | + | for _, c := range containers { | |
| 1551 | + | label := c.Name | |
| 1552 | + | if label == "" { | |
| 1553 | + | label = c.ID | |
| 1554 | + | } | |
| 1555 | + | if id := shortContainerID(c.ID); id != "" && id != label { | |
| 1556 | + | label = fmt.Sprintf("%s (%s)", label, id) | |
| 1557 | + | } | |
| 1558 | + | if err := cliWriteLine(out, " "+label); err != nil { | |
| 1559 | + | return err | |
| 1560 | + | } | |
| 1561 | + | } | |
| 1562 | + | return nil | |
| 1563 | + | } | |
| 1564 | + | ||
| 1565 | + | func writePreservedAttachments(out io.Writer, attachments []domain.CleanupAttachment) error { | |
| 1566 | + | if len(attachments) == 0 { | |
| 1567 | + | return nil | |
| 1568 | + | } | |
| 1569 | + | if err := cliWriteLine(out, ""); err != nil { | |
| 1570 | + | return err | |
| 1571 | + | } | |
| 1572 | + | if err := cliWriteLine(out, styles.Theme.Bold.Render("Preserved attachments:")); err != nil { | |
| 1573 | + | return err | |
| 1574 | + | } | |
| 1575 | + | for _, a := range attachments { | |
| 1576 | + | label := a.Name | |
| 1577 | + | if label == "" { | |
| 1578 | + | label = a.ContainerID | |
| 1579 | + | } | |
| 1580 | + | if a.Status != "" { | |
| 1581 | + | label = fmt.Sprintf("%s (%s)", label, a.Status) | |
| 1582 | + | } | |
| 1583 | + | if err := cliWriteLine(out, " "+label); err != nil { | |
| 1584 | + | return err | |
| 1585 | + | } | |
| 1586 | + | } | |
| 1587 | + | return nil | |
| 1588 | + | } | |
| 1589 | + | ||
| 1590 | + | func writeCleanupMessages(out io.Writer, warnings, hints []string) error { | |
| 1591 | + | for _, warning := range warnings { | |
| 1592 | + | if err := cliWriteLine(out, cliRenderWarning(warning)); err != nil { | |
| 1593 | + | return err | |
| 1594 | + | } | |
| 1595 | + | } | |
| 1596 | + | for _, hint := range hints { | |
| 1597 | + | if err := cliWriteLine(out, cliRenderMuted("Hint: "+hint)); err != nil { | |
| 1598 | + | return err | |
| 1599 | + | } | |
| 1600 | + | } | |
| 1601 | + | return nil | |
| 1602 | + | } | |
| 1603 | + | ||
| 1200 | 1604 | // newStatusCmd creates the status command. | |
| 1201 | 1605 | func newStatusCmd() *cobra.Command { | |
| 1202 | 1606 | return &cobra.Command{ | |
| @@ -13,6 +13,7 @@ import ( | |||||
| 13 | 13 | ||||
| 14 | "github.com/spf13/cobra" | 14 | "github.com/spf13/cobra" | ||
| 15 | 15 | ||||
| 16 | + | "github.com/bnema/gordon/internal/adapters/dto" | |||
| 16 | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | 17 | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | ||
| 17 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/components" | 18 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/components" | ||
| 18 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" | 19 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" | ||
| @@ -127,6 +128,18 @@ type routeStatusSection struct { | |||||
| 127 | Routes []routeStatusItem `json:"routes,omitempty"` | 128 | Routes []routeStatusItem `json:"routes,omitempty"` | ||
| 128 | } | 129 | } | ||
| 129 | 130 | ||||
| 131 | + | type routeDiagnosis struct { | |||
| 132 | + | Domain string `json:"domain"` | |||
| 133 | + | Configured bool `json:"configured"` | |||
| 134 | + | Route *domain.Route `json:"route,omitempty"` | |||
| 135 | + | Runtime *remote.RouteInfo `json:"runtime,omitempty"` | |||
| 136 | + | Health *remote.RouteHealth `json:"health,omitempty"` | |||
| 137 | + | Volumes []dto.Volume `json:"volumes,omitempty"` | |||
| 138 | + | OrphanedAttachments []domain.CleanupAttachment `json:"orphaned_attachments,omitempty"` | |||
| 139 | + | Warnings []string `json:"warnings,omitempty"` | |||
| 140 | + | Hints []string `json:"hints,omitempty"` | |||
| 141 | + | } | |||
| 142 | + | ||||
| 130 | type routesListDeps struct { | 143 | type routesListDeps struct { | ||
| 131 | explicitRemote func() (*remote.ResolvedRemote, bool, error) | 144 | explicitRemote func() (*remote.ResolvedRemote, bool, error) | ||
| 132 | loadLocal func(context.Context, string) (routeListSection, error) | 145 | loadLocal func(context.Context, string) (routeListSection, error) | ||
| @@ -194,6 +207,8 @@ the local Gordon configuration.`, | |||||
| 194 | cmd.AddCommand(newRoutesStatusCmd()) | 207 | cmd.AddCommand(newRoutesStatusCmd()) | ||
| 195 | 208 | ||||
| 196 | cmd.AddCommand(newRoutesShowCmd()) | 209 | cmd.AddCommand(newRoutesShowCmd()) | ||
| 210 | + | cmd.AddCommand(newRoutesDiagnoseCmd()) | |||
| 211 | + | cmd.AddCommand(newRoutesPurgeCmd()) | |||
| 197 | cmd.AddCommand(newRoutesAddCmd()) | 212 | cmd.AddCommand(newRoutesAddCmd()) | ||
| 198 | cmd.AddCommand(newRoutesRemoveCmd()) | 213 | cmd.AddCommand(newRoutesRemoveCmd()) | ||
| 199 | 214 | ||||
| @@ -1078,7 +1093,287 @@ func writeRouteShowText(out io.Writer, domainName, image, containerStatus string | |||||
| 1078 | return nil | 1093 | return nil | ||
| 1079 | } | 1094 | } | ||
| 1080 | 1095 | ||||
| 1081 | - | // newRoutesAddCmd creates the routes add command. | 1096 | + | // newRoutesDiagnoseCmd creates the routes diagnose command. |
| 1097 | + | func newRoutesDiagnoseCmd() *cobra.Command { | |||
| 1098 | + | var jsonOut bool | |||
| 1099 | + | cmd := &cobra.Command{ | |||
| 1100 | + | Use: "diagnose <domain>", | |||
| 1101 | + | Short: "Diagnose route configuration, runtime state, and cleanup leftovers", | |||
| 1102 | + | Args: cobra.ExactArgs(1), | |||
| 1103 | + | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 1104 | + | return runRouteDiagnosis(cmd.Context(), args[0], cmd.OutOrStdout(), jsonOut) | |||
| 1105 | + | }, | |||
| 1106 | + | } | |||
| 1107 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |||
| 1108 | + | return cmd | |||
| 1109 | + | } | |||
| 1110 | + | ||||
| 1111 | + | func runRouteDiagnosis(ctx context.Context, domainName string, out io.Writer, jsonOut bool) error { | |||
| 1112 | + | handle, err := resolveControlPlaneForRouteDomain(ctx, domainName) | |||
| 1113 | + | if err != nil { | |||
| 1114 | + | return err | |||
| 1115 | + | } | |||
| 1116 | + | defer handle.close() | |||
| 1117 | + | diag, err := buildRouteDiagnosis(ctx, handle.plane, domainName) | |||
| 1118 | + | if err != nil { | |||
| 1119 | + | return err | |||
| 1120 | + | } | |||
| 1121 | + | if jsonOut { | |||
| 1122 | + | return writeJSON(out, diag) | |||
| 1123 | + | } | |||
| 1124 | + | return writeRouteDiagnosisText(out, diag) | |||
| 1125 | + | } | |||
| 1126 | + | ||||
| 1127 | + | func buildRouteDiagnosis(ctx context.Context, cp ControlPlane, domainName string) (*routeDiagnosis, error) { | |||
| 1128 | + | diag := &routeDiagnosis{Domain: domainName} | |||
| 1129 | + | loadRouteDiagnosisConfig(ctx, cp, domainName, diag) | |||
| 1130 | + | loadRouteDiagnosisRuntime(ctx, cp, domainName, diag) | |||
| 1131 | + | loadRouteDiagnosisHealth(ctx, cp, domainName, diag) | |||
| 1132 | + | loadRouteDiagnosisVolumes(ctx, cp, domainName, diag) | |||
| 1133 | + | loadRouteDiagnosisOrphanedAttachments(ctx, cp, domainName, diag) | |||
| 1134 | + | finalizeRouteDiagnosis(diag) | |||
| 1135 | + | return diag, nil | |||
| 1136 | + | } | |||
| 1137 | + | ||||
| 1138 | + | func loadRouteDiagnosisConfig(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) { | |||
| 1139 | + | route, err := cp.GetRoute(ctx, domainName) | |||
| 1140 | + | if err == nil && route != nil { | |||
| 1141 | + | diag.Configured = true | |||
| 1142 | + | diag.Route = route | |||
| 1143 | + | return | |||
| 1144 | + | } | |||
| 1145 | + | if err != nil && !errors.Is(err, domain.ErrRouteNotFound) { | |||
| 1146 | + | diag.Warnings = append(diag.Warnings, "failed to load route config: "+err.Error()) | |||
| 1147 | + | } | |||
| 1148 | + | } | |||
| 1149 | + | ||||
| 1150 | + | func loadRouteDiagnosisRuntime(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) { | |||
| 1151 | + | routes, err := cp.ListRoutesWithDetails(ctx) | |||
| 1152 | + | if err != nil { | |||
| 1153 | + | diag.Warnings = append(diag.Warnings, "failed to load route runtime details: "+err.Error()) | |||
| 1154 | + | return | |||
| 1155 | + | } | |||
| 1156 | + | for _, info := range routes { | |||
| 1157 | + | if strings.EqualFold(info.Domain, domainName) { | |||
| 1158 | + | copy := info | |||
| 1159 | + | diag.Runtime = © | |||
| 1160 | + | return | |||
| 1161 | + | } | |||
| 1162 | + | } | |||
| 1163 | + | } | |||
| 1164 | + | ||||
| 1165 | + | func loadRouteDiagnosisHealth(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) { | |||
| 1166 | + | if health, err := cp.GetHealth(ctx); err == nil && health != nil { | |||
| 1167 | + | diag.Health = health[domainName] | |||
| 1168 | + | } | |||
| 1169 | + | } | |||
| 1170 | + | ||||
| 1171 | + | func loadRouteDiagnosisVolumes(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) { | |||
| 1172 | + | if volumes, err := cp.ListVolumes(ctx); err == nil { | |||
| 1173 | + | diag.Volumes = volumesForRouteDiagnosis(volumes, domainName) | |||
| 1174 | + | } | |||
| 1175 | + | } | |||
| 1176 | + | ||||
| 1177 | + | func loadRouteDiagnosisOrphanedAttachments(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) { | |||
| 1178 | + | lister, ok := cp.(interface { | |||
| 1179 | + | ListOrphanedAttachments(context.Context) ([]domain.CleanupAttachment, error) | |||
| 1180 | + | }) | |||
| 1181 | + | if !ok { | |||
| 1182 | + | return | |||
| 1183 | + | } | |||
| 1184 | + | if attachments, err := lister.ListOrphanedAttachments(ctx); err == nil { | |||
| 1185 | + | diag.OrphanedAttachments = attachmentsForRouteDiagnosis(attachments, domainName) | |||
| 1186 | + | } | |||
| 1187 | + | } | |||
| 1188 | + | ||||
| 1189 | + | func finalizeRouteDiagnosis(diag *routeDiagnosis) { | |||
| 1190 | + | if !diag.Configured && diag.Runtime != nil { | |||
| 1191 | + | diag.Warnings = append(diag.Warnings, "route is not configured but runtime state still exists") | |||
| 1192 | + | } | |||
| 1193 | + | if len(diag.Volumes) > 0 { | |||
| 1194 | + | diag.Hints = append(diag.Hints, "persistent volumes are preserved by default") | |||
| 1195 | + | } | |||
| 1196 | + | if len(diag.OrphanedAttachments) > 0 { | |||
| 1197 | + | diag.Hints = append(diag.Hints, "run 'gordon attachments prune --stop' to stop orphaned attachment containers while preserving volumes") | |||
| 1198 | + | } | |||
| 1199 | + | } | |||
| 1200 | + | ||||
| 1201 | + | func volumesForRouteDiagnosis(volumes []dto.Volume, domainName string) []dto.Volume { | |||
| 1202 | + | needle := strings.ReplaceAll(strings.ToLower(domainName), ".", "-") | |||
| 1203 | + | var out []dto.Volume | |||
| 1204 | + | for _, volume := range volumes { | |||
| 1205 | + | if strings.Contains(strings.ToLower(volume.Name), needle) { | |||
| 1206 | + | out = append(out, volume) | |||
| 1207 | + | } | |||
| 1208 | + | } | |||
| 1209 | + | return out | |||
| 1210 | + | } | |||
| 1211 | + | ||||
| 1212 | + | func attachmentsForRouteDiagnosis(attachments []domain.CleanupAttachment, domainName string) []domain.CleanupAttachment { | |||
| 1213 | + | var out []domain.CleanupAttachment | |||
| 1214 | + | for _, attachment := range attachments { | |||
| 1215 | + | if strings.EqualFold(attachment.Owner, domainName) { | |||
| 1216 | + | out = append(out, attachment) | |||
| 1217 | + | } | |||
| 1218 | + | } | |||
| 1219 | + | return out | |||
| 1220 | + | } | |||
| 1221 | + | ||||
| 1222 | + | func writeRouteDiagnosisText(out io.Writer, diag *routeDiagnosis) error { | |||
| 1223 | + | if err := cliWriteLine(out, cliRenderTitle("Route diagnosis: "+diag.Domain)); err != nil { | |||
| 1224 | + | return err | |||
| 1225 | + | } | |||
| 1226 | + | if err := writeRouteDiagnosisSummary(out, diag); err != nil { | |||
| 1227 | + | return err | |||
| 1228 | + | } | |||
| 1229 | + | if err := writeRouteDiagnosisLeftovers(out, diag); err != nil { | |||
| 1230 | + | return err | |||
| 1231 | + | } | |||
| 1232 | + | return writeCleanupMessages(out, diag.Warnings, diag.Hints) | |||
| 1233 | + | } | |||
| 1234 | + | ||||
| 1235 | + | func writeRouteDiagnosisSummary(out io.Writer, diag *routeDiagnosis) error { | |||
| 1236 | + | if diag.Configured && diag.Route != nil { | |||
| 1237 | + | if err := cliWriteLine(out, cliRenderMeta("Image:", diag.Route.Image)); err != nil { | |||
| 1238 | + | return err | |||
| 1239 | + | } | |||
| 1240 | + | } else if err := cliWriteLine(out, cliRenderWarning("Route is not configured")); err != nil { | |||
| 1241 | + | return err | |||
| 1242 | + | } | |||
| 1243 | + | if diag.Runtime == nil { | |||
| 1244 | + | return nil | |||
| 1245 | + | } | |||
| 1246 | + | return cliWriteLine(out, cliRenderMeta("Container:", diag.Runtime.ContainerStatus+" "+shortContainerID(diag.Runtime.ContainerID))) | |||
| 1247 | + | } | |||
| 1248 | + | ||||
| 1249 | + | func writeRouteDiagnosisLeftovers(out io.Writer, diag *routeDiagnosis) error { | |||
| 1250 | + | for _, volume := range diag.Volumes { | |||
| 1251 | + | if err := cliWriteLine(out, cliRenderMeta("Preserved volume:", volume.Name)); err != nil { | |||
| 1252 | + | return err | |||
| 1253 | + | } | |||
| 1254 | + | } | |||
| 1255 | + | for _, attachment := range diag.OrphanedAttachments { | |||
| 1256 | + | if err := cliWriteLine(out, cliRenderWarning("Orphaned attachment: "+attachment.Name)); err != nil { | |||
| 1257 | + | return err | |||
| 1258 | + | } | |||
| 1259 | + | } | |||
| 1260 | + | return nil | |||
| 1261 | + | } | |||
| 1262 | + | ||||
| 1263 | + | func newRoutesPurgeCmd() *cobra.Command { | |||
| 1264 | + | var jsonOut bool | |||
| 1265 | + | var force bool | |||
| 1266 | + | var includeAttachments bool | |||
| 1267 | + | var includeVolumes bool | |||
| 1268 | + | cmd := &cobra.Command{ | |||
| 1269 | + | Use: "purge <domain>", | |||
| 1270 | + | Short: "Review or explicitly purge retained route resources", | |||
| 1271 | + | Args: cobra.ExactArgs(1), | |||
| 1272 | + | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 1273 | + | return runRoutePurgeCommand(cmd.Context(), args[0], cmd.OutOrStdout(), routePurgeOptions{Force: force, Attachments: includeAttachments, Volumes: includeVolumes}, jsonOut) | |||
| 1274 | + | }, | |||
| 1275 | + | } | |||
| 1276 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |||
| 1277 | + | cmd.Flags().BoolVar(&force, "force", false, "Execute purge actions; default is dry-run") | |||
| 1278 | + | cmd.Flags().BoolVar(&includeAttachments, "attachments", false, "Include orphaned attachment containers") | |||
| 1279 | + | cmd.Flags().BoolVar(&includeVolumes, "volumes", false, "Include preserved volumes in the purge plan") | |||
| 1280 | + | return cmd | |||
| 1281 | + | } | |||
| 1282 | + | ||||
| 1283 | + | type routePurgeOptions struct { | |||
| 1284 | + | Force bool | |||
| 1285 | + | Attachments bool | |||
| 1286 | + | Volumes bool | |||
| 1287 | + | } | |||
| 1288 | + | ||||
| 1289 | + | func runRoutePurgeCommand(ctx context.Context, domainName string, out io.Writer, opts routePurgeOptions, jsonOut bool) error { | |||
| 1290 | + | handle, err := resolveControlPlaneForRouteDomain(ctx, domainName) | |||
| 1291 | + | if err != nil { | |||
| 1292 | + | return err | |||
| 1293 | + | } | |||
| 1294 | + | defer handle.close() | |||
| 1295 | + | report, err := runRoutePurge(ctx, handle.plane, domainName, opts) | |||
| 1296 | + | if err != nil { | |||
| 1297 | + | return err | |||
| 1298 | + | } | |||
| 1299 | + | if jsonOut { | |||
| 1300 | + | return writeJSON(out, report) | |||
| 1301 | + | } | |||
| 1302 | + | return writeRoutePurgeText(out, report, opts.Force) | |||
| 1303 | + | } | |||
| 1304 | + | ||||
| 1305 | + | func runRoutePurge(ctx context.Context, cp ControlPlane, domainName string, opts routePurgeOptions) (*domain.CleanupReport, error) { | |||
| 1306 | + | diag, err := buildRouteDiagnosis(ctx, cp, domainName) | |||
| 1307 | + | if err != nil { | |||
| 1308 | + | return nil, err | |||
| 1309 | + | } | |||
| 1310 | + | report := &domain.CleanupReport{Domain: domainName} | |||
| 1311 | + | if diag.Runtime != nil && diag.Runtime.ContainerID != "" { | |||
| 1312 | + | report.OrphanedEntities = append(report.OrphanedEntities, domain.CleanupOrphanedEntity{Kind: "route_container", ID: diag.Runtime.ContainerID, Status: diag.Runtime.ContainerStatus, Reason: "route runtime state matched purge target"}) | |||
| 1313 | + | } | |||
| 1314 | + | if opts.Volumes { | |||
| 1315 | + | for _, volume := range diag.Volumes { | |||
| 1316 | + | report.PreservedVolumes = append(report.PreservedVolumes, domain.CleanupVolume{Name: volume.Name, Reason: "volume purge requires explicit --volumes and runtime support"}) | |||
| 1317 | + | } | |||
| 1318 | + | } | |||
| 1319 | + | if !opts.Attachments || !opts.Force { | |||
| 1320 | + | report.PreservedAttachments = append(report.PreservedAttachments, diag.OrphanedAttachments...) | |||
| 1321 | + | } | |||
| 1322 | + | if opts.Attachments && opts.Force { | |||
| 1323 | + | cleaner, ok := cp.(interface { | |||
| 1324 | + | CleanupOrphanedAttachments(context.Context, string, bool) (*domain.CleanupReport, error) | |||
| 1325 | + | }) | |||
| 1326 | + | if !ok { | |||
| 1327 | + | return nil, fmt.Errorf("attachment purge is unavailable") | |||
| 1328 | + | } | |||
| 1329 | + | attachmentReport, err := cleaner.CleanupOrphanedAttachments(ctx, domainName, true) | |||
| 1330 | + | if err != nil { | |||
| 1331 | + | return nil, err | |||
| 1332 | + | } | |||
| 1333 | + | report.RemovedContainers = append(report.RemovedContainers, attachmentReport.RemovedContainers...) | |||
| 1334 | + | report.PreservedVolumes = append(report.PreservedVolumes, attachmentReport.PreservedVolumes...) | |||
| 1335 | + | report.PreservedAttachments = append(report.PreservedAttachments, attachmentReport.PreservedAttachments...) | |||
| 1336 | + | report.OrphanedEntities = append(report.OrphanedEntities, attachmentReport.OrphanedEntities...) | |||
| 1337 | + | report.Warnings = append(report.Warnings, attachmentReport.Warnings...) | |||
| 1338 | + | report.Hints = append(report.Hints, attachmentReport.Hints...) | |||
| 1339 | + | report.PartialFailures = append(report.PartialFailures, attachmentReport.PartialFailures...) | |||
| 1340 | + | } | |||
| 1341 | + | if opts.Volumes { | |||
| 1342 | + | report.Warnings = append(report.Warnings, "volume purge is not executed yet; preserved volumes are reported for manual review") | |||
| 1343 | + | } | |||
| 1344 | + | if !opts.Force { | |||
| 1345 | + | report.Hints = append(report.Hints, "dry-run only; add --force with explicit category flags to execute supported purge actions") | |||
| 1346 | + | } | |||
| 1347 | + | return report, nil | |||
| 1348 | + | } | |||
| 1349 | + | ||||
| 1350 | + | func writeRoutePurgeText(out io.Writer, report *domain.CleanupReport, force bool) error { | |||
| 1351 | + | mode := "Purge dry-run" | |||
| 1352 | + | if force { | |||
| 1353 | + | mode = "Purge completed" | |||
| 1354 | + | } | |||
| 1355 | + | if err := cliWriteLine(out, cliRenderTitle(mode+": "+report.Domain)); err != nil { | |||
| 1356 | + | return err | |||
| 1357 | + | } | |||
| 1358 | + | if err := writePreservedAttachments(out, report.PreservedAttachments); err != nil { | |||
| 1359 | + | return err | |||
| 1360 | + | } | |||
| 1361 | + | if err := writeRemovedContainers(out, report.RemovedContainers); err != nil { | |||
| 1362 | + | return err | |||
| 1363 | + | } | |||
| 1364 | + | for _, entity := range report.OrphanedEntities { | |||
| 1365 | + | if err := cliWriteLine(out, cliRenderWarning("Purge candidate: "+entity.Kind+" "+entity.ID)); err != nil { | |||
| 1366 | + | return err | |||
| 1367 | + | } | |||
| 1368 | + | } | |||
| 1369 | + | for _, volume := range report.PreservedVolumes { | |||
| 1370 | + | if err := cliWriteLine(out, cliRenderMeta("Preserved volume:", volume.Name)); err != nil { | |||
| 1371 | + | return err | |||
| 1372 | + | } | |||
| 1373 | + | } | |||
| 1374 | + | return writeCleanupMessages(out, report.Warnings, report.Hints) | |||
| 1375 | + | } | |||
| 1376 | + | ||||
| 1082 | func newRoutesAddCmd() *cobra.Command { | 1377 | func newRoutesAddCmd() *cobra.Command { | ||
| 1083 | var image string | 1378 | var image string | ||
| 1084 | 1379 | ||||
| @@ -1183,12 +1478,22 @@ Examples: | |||||
| 1183 | return err | 1478 | return err | ||
| 1184 | } | 1479 | } | ||
| 1185 | defer handle.close() | 1480 | defer handle.close() | ||
| 1186 | - | if err := handle.plane.RemoveRoute(ctx, routeDomain); err != nil { | 1481 | + | var cleanup *dto.CleanupReport |
| 1482 | + | if remover, ok := handle.plane.(interface { | |||
| 1483 | + | RemoveRouteWithCleanup(context.Context, string) (*dto.RouteDeleteResponse, error) | |||
| 1484 | + | }); ok { | |||
| 1485 | + | resp, err := remover.RemoveRouteWithCleanup(ctx, routeDomain) | |||
| 1486 | + | if err != nil { | |||
| 1487 | + | return fmt.Errorf("failed to remove route: %w", err) | |||
| 1488 | + | } | |||
| 1489 | + | if resp != nil { | |||
| 1490 | + | cleanup = resp.Cleanup | |||
| 1491 | + | } | |||
| 1492 | + | } else if err := handle.plane.RemoveRoute(ctx, routeDomain); err != nil { | |||
| 1187 | return fmt.Errorf("failed to remove route: %w", err) | 1493 | return fmt.Errorf("failed to remove route: %w", err) | ||
| 1188 | } | 1494 | } | ||
| 1189 | 1495 | ||||
| 1190 | - | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Route removed: %s", routeDomain))) | 1496 | + | return writeRouteRemoveText(cmd.OutOrStdout(), routeDomain, cleanup) |
| 1191 | - | return nil | |||
| 1192 | }, | 1497 | }, | ||
| 1193 | } | 1498 | } | ||
| 1194 | 1499 | ||||
| @@ -1197,6 +1502,105 @@ Examples: | |||||
| 1197 | return cmd | 1502 | return cmd | ||
| 1198 | } | 1503 | } | ||
| 1199 | 1504 | ||||
| 1505 | + | func writeRouteRemoveText(out io.Writer, routeDomain string, cleanup *dto.CleanupReport) error { | |||
| 1506 | + | if err := cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Route removed: %s", routeDomain))); err != nil { | |||
| 1507 | + | return err | |||
| 1508 | + | } | |||
| 1509 | + | if cleanup == nil { | |||
| 1510 | + | return nil | |||
| 1511 | + | } | |||
| 1512 | + | cleanupReport := cleanupReportDTOToDomain(cleanup) | |||
| 1513 | + | if err := writeRemovedContainers(out, cleanupReport.RemovedContainers); err != nil { | |||
| 1514 | + | return err | |||
| 1515 | + | } | |||
| 1516 | + | if err := writePreservedAttachments(out, cleanupReport.PreservedAttachments); err != nil { | |||
| 1517 | + | return err | |||
| 1518 | + | } | |||
| 1519 | + | return writeCleanupMessages(out, cleanupReport.Warnings, cleanupReport.Hints) | |||
| 1520 | + | } | |||
| 1521 | + | ||||
| 1522 | + | func cleanupReportDTOToDomain(cleanup *dto.CleanupReport) *domain.CleanupReport { | |||
| 1523 | + | if cleanup == nil { | |||
| 1524 | + | return nil | |||
| 1525 | + | } | |||
| 1526 | + | out := &domain.CleanupReport{ | |||
| 1527 | + | Domain: cleanup.Domain, | |||
| 1528 | + | Warnings: append([]string(nil), cleanup.Warnings...), | |||
| 1529 | + | Hints: append([]string(nil), cleanup.Hints...), | |||
| 1530 | + | } | |||
| 1531 | + | for _, item := range cleanup.RemovedContainers { | |||
| 1532 | + | out.RemovedContainers = append(out.RemovedContainers, domain.CleanupContainer(item)) | |||
| 1533 | + | } | |||
| 1534 | + | for _, item := range cleanup.PreservedAttachments { | |||
| 1535 | + | out.PreservedAttachments = append(out.PreservedAttachments, domain.CleanupAttachment(item)) | |||
| 1536 | + | } | |||
| 1537 | + | return out | |||
| 1538 | + | } | |||
| 1539 | + | ||||
| 1540 | + | func writeRemovedContainers(out io.Writer, containers []domain.CleanupContainer) error { | |||
| 1541 | + | if len(containers) == 0 { | |||
| 1542 | + | return nil | |||
| 1543 | + | } | |||
| 1544 | + | if err := cliWriteLine(out, ""); err != nil { | |||
| 1545 | + | return err | |||
| 1546 | + | } | |||
| 1547 | + | if err := cliWriteLine(out, styles.Theme.Bold.Render("Removed containers:")); err != nil { | |||
| 1548 | + | return err | |||
| 1549 | + | } | |||
| 1550 | + | for _, c := range containers { | |||
| 1551 | + | label := c.Name | |||
| 1552 | + | if label == "" { | |||
| 1553 | + | label = c.ID | |||
| 1554 | + | } | |||
| 1555 | + | if id := shortContainerID(c.ID); id != "" && id != label { | |||
| 1556 | + | label = fmt.Sprintf("%s (%s)", label, id) | |||
| 1557 | + | } | |||
| 1558 | + | if err := cliWriteLine(out, " "+label); err != nil { | |||
| 1559 | + | return err | |||
| 1560 | + | } | |||
| 1561 | + | } | |||
| 1562 | + | return nil | |||
| 1563 | + | } | |||
| 1564 | + | ||||
| 1565 | + | func writePreservedAttachments(out io.Writer, attachments []domain.CleanupAttachment) error { | |||
| 1566 | + | if len(attachments) == 0 { | |||
| 1567 | + | return nil | |||
| 1568 | + | } | |||
| 1569 | + | if err := cliWriteLine(out, ""); err != nil { | |||
| 1570 | + | return err | |||
| 1571 | + | } | |||
| 1572 | + | if err := cliWriteLine(out, styles.Theme.Bold.Render("Preserved attachments:")); err != nil { | |||
| 1573 | + | return err | |||
| 1574 | + | } | |||
| 1575 | + | for _, a := range attachments { | |||
| 1576 | + | label := a.Name | |||
| 1577 | + | if label == "" { | |||
| 1578 | + | label = a.ContainerID | |||
| 1579 | + | } | |||
| 1580 | + | if a.Status != "" { | |||
| 1581 | + | label = fmt.Sprintf("%s (%s)", label, a.Status) | |||
| 1582 | + | } | |||
| 1583 | + | if err := cliWriteLine(out, " "+label); err != nil { | |||
| 1584 | + | return err | |||
| 1585 | + | } | |||
| 1586 | + | } | |||
| 1587 | + | return nil | |||
| 1588 | + | } | |||
| 1589 | + | ||||
| 1590 | + | func writeCleanupMessages(out io.Writer, warnings, hints []string) error { | |||
| 1591 | + | for _, warning := range warnings { | |||
| 1592 | + | if err := cliWriteLine(out, cliRenderWarning(warning)); err != nil { | |||
| 1593 | + | return err | |||
| 1594 | + | } | |||
| 1595 | + | } | |||
| 1596 | + | for _, hint := range hints { | |||
| 1597 | + | if err := cliWriteLine(out, cliRenderMuted("Hint: "+hint)); err != nil { | |||
| 1598 | + | return err | |||
| 1599 | + | } | |||
| 1600 | + | } | |||
| 1601 | + | return nil | |||
| 1602 | + | } | |||
| 1603 | + | ||||
| 1200 | // newStatusCmd creates the status command. | 1604 | // newStatusCmd creates the status command. | ||
| 1201 | func newStatusCmd() *cobra.Command { | 1605 | func newStatusCmd() *cobra.Command { | ||
| 1202 | return &cobra.Command{ | 1606 | return &cobra.Command{ | ||
M internal/adapters/in/cli/routes_test.go +74 -0
| @@ -13,6 +13,8 @@ import ( | |||
| 13 | 13 | "sync/atomic" | |
| 14 | 14 | "testing" | |
| 15 | 15 | ||
| 16 | + | "github.com/bnema/gordon/internal/adapters/dto" | |
| 17 | + | climocks "github.com/bnema/gordon/internal/adapters/in/cli/mocks" | |
| 16 | 18 | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |
| 17 | 19 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/components" | |
| 18 | 20 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" | |
| @@ -39,6 +41,14 @@ func (c *routesShowTestControlPlane) GetHealth(ctx context.Context) (map[string] | |||
| 39 | 41 | panic("unexpected call") | |
| 40 | 42 | } | |
| 41 | 43 | ||
| 44 | + | func (c *routesShowTestControlPlane) ListOrphanedAttachments(context.Context) ([]domain.CleanupAttachment, error) { | |
| 45 | + | panic("unexpected call") | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func (c *routesShowTestControlPlane) CleanupOrphanedAttachments(context.Context, string, bool) (*domain.CleanupReport, error) { | |
| 49 | + | panic("unexpected call") | |
| 50 | + | } | |
| 51 | + | ||
| 42 | 52 | func TestTruncateImage(t *testing.T) { | |
| 43 | 53 | tests := []struct { | |
| 44 | 54 | name string | |
| @@ -885,6 +895,35 @@ func TestRenderRoutesStatusSections_KeepsLocalErrorWhenAllRemotesFail(t *testing | |||
| 885 | 895 | assert.Contains(t, rendered, "dial tcp timeout") | |
| 886 | 896 | } | |
| 887 | 897 | ||
| 898 | + | func TestWriteRouteRemoveTextShowsCleanupReport(t *testing.T) { | |
| 899 | + | var out bytes.Buffer | |
| 900 | + | report := &domain.CleanupReport{ | |
| 901 | + | Domain: "app.example.com", | |
| 902 | + | RemovedContainers: []domain.CleanupContainer{{ | |
| 903 | + | ID: "1234567890abcdef", | |
| 904 | + | Name: "gordon-app.example.com", | |
| 905 | + | }}, | |
| 906 | + | PreservedAttachments: []domain.CleanupAttachment{{ | |
| 907 | + | Name: "postgres", | |
| 908 | + | ContainerID: "attachment-1", | |
| 909 | + | Status: "running", | |
| 910 | + | }}, | |
| 911 | + | Warnings: []string{"volumes preserved"}, | |
| 912 | + | Hints: []string{"run diagnose"}, | |
| 913 | + | } | |
| 914 | + | ||
| 915 | + | err := writeRouteRemoveText(&out, "app.example.com", dto.CleanupReportFromDomain(report)) | |
| 916 | + | require.NoError(t, err) | |
| 917 | + | rendered := stripANSI(out.String()) | |
| 918 | + | assert.Contains(t, rendered, "Route removed: app.example.com") | |
| 919 | + | assert.Contains(t, rendered, "Removed containers:") | |
| 920 | + | assert.Contains(t, rendered, "gordon-app.example.com (1234567890ab)") | |
| 921 | + | assert.Contains(t, rendered, "Preserved attachments:") | |
| 922 | + | assert.Contains(t, rendered, "postgres (running)") | |
| 923 | + | assert.Contains(t, rendered, "volumes preserved") | |
| 924 | + | assert.Contains(t, rendered, "run diagnose") | |
| 925 | + | } | |
| 926 | + | ||
| 888 | 927 | func TestBuildRouteStatusTree_DeterministicAcrossInputOrder(t *testing.T) { | |
| 889 | 928 | routes := []routeStatusItem{ | |
| 890 | 929 | {Domain: "zulu.example", Image: "zulu:v1", Network: "gordon-zulu", ContainerStatus: "running"}, | |
| @@ -901,3 +940,38 @@ func TestBuildRouteStatusTree_DeterministicAcrossInputOrder(t *testing.T) { | |||
| 901 | 940 | ||
| 902 | 941 | assert.Equal(t, outA.String(), outB.String()) | |
| 903 | 942 | } | |
| 943 | + | ||
| 944 | + | func TestAttachmentsForRouteDiagnosisUsesOwner(t *testing.T) { | |
| 945 | + | attachments := []domain.CleanupAttachment{ | |
| 946 | + | {Name: "unexpected-name", Owner: "app.example.com", ContainerID: "match"}, | |
| 947 | + | {Name: "gordon-other-example-com-postgres", Owner: "other.example.com", ContainerID: "miss"}, | |
| 948 | + | } | |
| 949 | + | ||
| 950 | + | matched := attachmentsForRouteDiagnosis(attachments, "app.example.com") | |
| 951 | + | ||
| 952 | + | require.Len(t, matched, 1) | |
| 953 | + | assert.Equal(t, "match", matched[0].ContainerID) | |
| 954 | + | } | |
| 955 | + | ||
| 956 | + | func TestRunRoutePurge_AttachmentsForceIsScopedToRoute(t *testing.T) { | |
| 957 | + | cp := climocks.NewMockControlPlane(t) | |
| 958 | + | orphanedAttachments := []domain.CleanupAttachment{ | |
| 959 | + | {ContainerID: "app-attachment", Owner: "app.example.com", Name: "postgres"}, | |
| 960 | + | {ContainerID: "other-attachment", Owner: "other.example.com", Name: "redis"}, | |
| 961 | + | } | |
| 962 | + | cp.EXPECT().GetRoute(context.Background(), "app.example.com").Return(&domain.Route{Domain: "app.example.com", Image: "app:latest"}, nil).Once() | |
| 963 | + | cp.EXPECT().ListRoutesWithDetails(context.Background()).Return(nil, nil).Once() | |
| 964 | + | cp.EXPECT().GetHealth(context.Background()).Return(nil, nil).Once() | |
| 965 | + | cp.EXPECT().ListVolumes(context.Background()).Return(nil, nil).Once() | |
| 966 | + | cp.EXPECT().ListOrphanedAttachments(context.Background()).Return(orphanedAttachments, nil).Once() | |
| 967 | + | cp.EXPECT().CleanupOrphanedAttachments(context.Background(), "app.example.com", true).Return(&domain.CleanupReport{ | |
| 968 | + | RemovedContainers: []domain.CleanupContainer{{ID: "app-attachment", Name: "postgres"}}, | |
| 969 | + | }, nil).Once() | |
| 970 | + | ||
| 971 | + | report, err := runRoutePurge(context.Background(), cp, "app.example.com", routePurgeOptions{Force: true, Attachments: true}) | |
| 972 | + | ||
| 973 | + | require.NoError(t, err) | |
| 974 | + | require.NotNil(t, report) | |
| 975 | + | require.Len(t, report.RemovedContainers, 1) | |
| 976 | + | assert.Equal(t, "app-attachment", report.RemovedContainers[0].ID) | |
| 977 | + | } | |
| @@ -13,6 +13,8 @@ import ( | |||||
| 13 | "sync/atomic" | 13 | "sync/atomic" | ||
| 14 | "testing" | 14 | "testing" | ||
| 15 | 15 | ||||
| 16 | + | "github.com/bnema/gordon/internal/adapters/dto" | |||
| 17 | + | climocks "github.com/bnema/gordon/internal/adapters/in/cli/mocks" | |||
| 16 | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | 18 | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | ||
| 17 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/components" | 19 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/components" | ||
| 18 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" | 20 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" | ||
| @@ -39,6 +41,14 @@ func (c *routesShowTestControlPlane) GetHealth(ctx context.Context) (map[string] | |||||
| 39 | panic("unexpected call") | 41 | panic("unexpected call") | ||
| 40 | } | 42 | } | ||
| 41 | 43 | ||||
| 44 | + | func (c *routesShowTestControlPlane) ListOrphanedAttachments(context.Context) ([]domain.CleanupAttachment, error) { | |||
| 45 | + | panic("unexpected call") | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func (c *routesShowTestControlPlane) CleanupOrphanedAttachments(context.Context, string, bool) (*domain.CleanupReport, error) { | |||
| 49 | + | panic("unexpected call") | |||
| 50 | + | } | |||
| 51 | + | ||||
| 42 | func TestTruncateImage(t *testing.T) { | 52 | func TestTruncateImage(t *testing.T) { | ||
| 43 | tests := []struct { | 53 | tests := []struct { | ||
| 44 | name string | 54 | name string | ||
| @@ -885,6 +895,35 @@ func TestRenderRoutesStatusSections_KeepsLocalErrorWhenAllRemotesFail(t *testing | |||||
| 885 | assert.Contains(t, rendered, "dial tcp timeout") | 895 | assert.Contains(t, rendered, "dial tcp timeout") | ||
| 886 | } | 896 | } | ||
| 887 | 897 | ||||
| 898 | + | func TestWriteRouteRemoveTextShowsCleanupReport(t *testing.T) { | |||
| 899 | + | var out bytes.Buffer | |||
| 900 | + | report := &domain.CleanupReport{ | |||
| 901 | + | Domain: "app.example.com", | |||
| 902 | + | RemovedContainers: []domain.CleanupContainer{{ | |||
| 903 | + | ID: "1234567890abcdef", | |||
| 904 | + | Name: "gordon-app.example.com", | |||
| 905 | + | }}, | |||
| 906 | + | PreservedAttachments: []domain.CleanupAttachment{{ | |||
| 907 | + | Name: "postgres", | |||
| 908 | + | ContainerID: "attachment-1", | |||
| 909 | + | Status: "running", | |||
| 910 | + | }}, | |||
| 911 | + | Warnings: []string{"volumes preserved"}, | |||
| 912 | + | Hints: []string{"run diagnose"}, | |||
| 913 | + | } | |||
| 914 | + | ||||
| 915 | + | err := writeRouteRemoveText(&out, "app.example.com", dto.CleanupReportFromDomain(report)) | |||
| 916 | + | require.NoError(t, err) | |||
| 917 | + | rendered := stripANSI(out.String()) | |||
| 918 | + | assert.Contains(t, rendered, "Route removed: app.example.com") | |||
| 919 | + | assert.Contains(t, rendered, "Removed containers:") | |||
| 920 | + | assert.Contains(t, rendered, "gordon-app.example.com (1234567890ab)") | |||
| 921 | + | assert.Contains(t, rendered, "Preserved attachments:") | |||
| 922 | + | assert.Contains(t, rendered, "postgres (running)") | |||
| 923 | + | assert.Contains(t, rendered, "volumes preserved") | |||
| 924 | + | assert.Contains(t, rendered, "run diagnose") | |||
| 925 | + | } | |||
| 926 | + | ||||
| 888 | func TestBuildRouteStatusTree_DeterministicAcrossInputOrder(t *testing.T) { | 927 | func TestBuildRouteStatusTree_DeterministicAcrossInputOrder(t *testing.T) { | ||
| 889 | routes := []routeStatusItem{ | 928 | routes := []routeStatusItem{ | ||
| 890 | {Domain: "zulu.example", Image: "zulu:v1", Network: "gordon-zulu", ContainerStatus: "running"}, | 929 | {Domain: "zulu.example", Image: "zulu:v1", Network: "gordon-zulu", ContainerStatus: "running"}, | ||
| @@ -901,3 +940,38 @@ func TestBuildRouteStatusTree_DeterministicAcrossInputOrder(t *testing.T) { | |||||
| 901 | 940 | ||||
| 902 | assert.Equal(t, outA.String(), outB.String()) | 941 | assert.Equal(t, outA.String(), outB.String()) | ||
| 903 | } | 942 | } | ||
| 943 | + | ||||
| 944 | + | func TestAttachmentsForRouteDiagnosisUsesOwner(t *testing.T) { | |||
| 945 | + | attachments := []domain.CleanupAttachment{ | |||
| 946 | + | {Name: "unexpected-name", Owner: "app.example.com", ContainerID: "match"}, | |||
| 947 | + | {Name: "gordon-other-example-com-postgres", Owner: "other.example.com", ContainerID: "miss"}, | |||
| 948 | + | } | |||
| 949 | + | ||||
| 950 | + | matched := attachmentsForRouteDiagnosis(attachments, "app.example.com") | |||
| 951 | + | ||||
| 952 | + | require.Len(t, matched, 1) | |||
| 953 | + | assert.Equal(t, "match", matched[0].ContainerID) | |||
| 954 | + | } | |||
| 955 | + | ||||
| 956 | + | func TestRunRoutePurge_AttachmentsForceIsScopedToRoute(t *testing.T) { | |||
| 957 | + | cp := climocks.NewMockControlPlane(t) | |||
| 958 | + | orphanedAttachments := []domain.CleanupAttachment{ | |||
| 959 | + | {ContainerID: "app-attachment", Owner: "app.example.com", Name: "postgres"}, | |||
| 960 | + | {ContainerID: "other-attachment", Owner: "other.example.com", Name: "redis"}, | |||
| 961 | + | } | |||
| 962 | + | cp.EXPECT().GetRoute(context.Background(), "app.example.com").Return(&domain.Route{Domain: "app.example.com", Image: "app:latest"}, nil).Once() | |||
| 963 | + | cp.EXPECT().ListRoutesWithDetails(context.Background()).Return(nil, nil).Once() | |||
| 964 | + | cp.EXPECT().GetHealth(context.Background()).Return(nil, nil).Once() | |||
| 965 | + | cp.EXPECT().ListVolumes(context.Background()).Return(nil, nil).Once() | |||
| 966 | + | cp.EXPECT().ListOrphanedAttachments(context.Background()).Return(orphanedAttachments, nil).Once() | |||
| 967 | + | cp.EXPECT().CleanupOrphanedAttachments(context.Background(), "app.example.com", true).Return(&domain.CleanupReport{ | |||
| 968 | + | RemovedContainers: []domain.CleanupContainer{{ID: "app-attachment", Name: "postgres"}}, | |||
| 969 | + | }, nil).Once() | |||
| 970 | + | ||||
| 971 | + | report, err := runRoutePurge(context.Background(), cp, "app.example.com", routePurgeOptions{Force: true, Attachments: true}) | |||
| 972 | + | ||||
| 973 | + | require.NoError(t, err) | |||
| 974 | + | require.NotNil(t, report) | |||
| 975 | + | require.Len(t, report.RemovedContainers, 1) | |||
| 976 | + | assert.Equal(t, "app-attachment", report.RemovedContainers[0].ID) | |||
| 977 | + | } | |||
M internal/adapters/in/cli/tls_test.go +8 -0
| @@ -82,6 +82,14 @@ func (c *tlsTestControlPlane) GetAttachmentsConfig(context.Context, string) ([]s | |||
| 82 | 82 | panic("unexpected call") | |
| 83 | 83 | } | |
| 84 | 84 | ||
| 85 | + | func (c *tlsTestControlPlane) ListOrphanedAttachments(context.Context) ([]domain.CleanupAttachment, error) { | |
| 86 | + | panic("unexpected call") | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func (c *tlsTestControlPlane) CleanupOrphanedAttachments(context.Context, string, bool) (*domain.CleanupReport, error) { | |
| 90 | + | panic("unexpected call") | |
| 91 | + | } | |
| 92 | + | ||
| 85 | 93 | func (c *tlsTestControlPlane) FindAttachmentTargetsByImage(context.Context, string) ([]string, error) { | |
| 86 | 94 | panic("unexpected call") | |
| 87 | 95 | } | |
| @@ -82,6 +82,14 @@ func (c *tlsTestControlPlane) GetAttachmentsConfig(context.Context, string) ([]s | |||||
| 82 | panic("unexpected call") | 82 | panic("unexpected call") | ||
| 83 | } | 83 | } | ||
| 84 | 84 | ||||
| 85 | + | func (c *tlsTestControlPlane) ListOrphanedAttachments(context.Context) ([]domain.CleanupAttachment, error) { | |||
| 86 | + | panic("unexpected call") | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func (c *tlsTestControlPlane) CleanupOrphanedAttachments(context.Context, string, bool) (*domain.CleanupReport, error) { | |||
| 90 | + | panic("unexpected call") | |||
| 91 | + | } | |||
| 92 | + | ||||
| 85 | func (c *tlsTestControlPlane) FindAttachmentTargetsByImage(context.Context, string) ([]string, error) { | 93 | func (c *tlsTestControlPlane) FindAttachmentTargetsByImage(context.Context, string) ([]string, error) { | ||
| 86 | panic("unexpected call") | 94 | panic("unexpected call") | ||
| 87 | } | 95 | } | ||
M internal/adapters/in/http/admin/handler.go +85 -14
| @@ -229,16 +229,18 @@ type routeHandler func(w http.ResponseWriter, r *http.Request, path string) | |||
| 229 | 229 | func (h *Handler) matchRoute(path string) (routeHandler, bool) { | |
| 230 | 230 | // Exact match routes | |
| 231 | 231 | exactRoutes := map[string]routeHandler{ | |
| 232 | - | "/networks": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleNetworks(w, r) }, | |
| 233 | - | "/status": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleStatus(w, r) }, | |
| 234 | - | "/health": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleHealth(w, r) }, | |
| 235 | - | "/bootstrap": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleBootstrap(w, r) }, | |
| 236 | - | "/reload": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleReload(w, r) }, | |
| 237 | - | "/config": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleConfig(w, r) }, | |
| 238 | - | "/auth/verify": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleAuthVerify(w, r) }, | |
| 239 | - | "/volumes": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleListVolumes(w, r) }, | |
| 240 | - | "/volumes/prune": func(w http.ResponseWriter, r *http.Request, _ string) { h.handlePruneVolumes(w, r) }, | |
| 241 | - | "/tls/status": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleTLSStatus(w, r) }, | |
| 232 | + | "/networks": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleNetworks(w, r) }, | |
| 233 | + | "/status": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleStatus(w, r) }, | |
| 234 | + | "/health": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleHealth(w, r) }, | |
| 235 | + | "/bootstrap": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleBootstrap(w, r) }, | |
| 236 | + | "/reload": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleReload(w, r) }, | |
| 237 | + | "/config": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleConfig(w, r) }, | |
| 238 | + | "/auth/verify": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleAuthVerify(w, r) }, | |
| 239 | + | "/volumes": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleListVolumes(w, r) }, | |
| 240 | + | "/volumes/prune": func(w http.ResponseWriter, r *http.Request, _ string) { h.handlePruneVolumes(w, r) }, | |
| 241 | + | "/attachments/orphans": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleAttachmentOrphans(w, r) }, | |
| 242 | + | "/attachments/prune": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleAttachmentPrune(w, r) }, | |
| 243 | + | "/tls/status": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleTLSStatus(w, r) }, | |
| 242 | 244 | } | |
| 243 | 245 | if handler, ok := exactRoutes[path]; ok { | |
| 244 | 246 | return handler, true | |
| @@ -274,6 +276,62 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) { | |||
| 274 | 276 | return nil, false | |
| 275 | 277 | } | |
| 276 | 278 | ||
| 279 | + | func (h *Handler) handleAttachmentOrphans(w http.ResponseWriter, r *http.Request) { | |
| 280 | + | ctx := r.Context() | |
| 281 | + | if r.Method != http.MethodGet { | |
| 282 | + | h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") | |
| 283 | + | return | |
| 284 | + | } | |
| 285 | + | if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionRead) { | |
| 286 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for config:read") | |
| 287 | + | return | |
| 288 | + | } | |
| 289 | + | if h.containerSvc == nil { | |
| 290 | + | log := zerowrap.FromCtx(ctx) | |
| 291 | + | log.Error().Msg("container service not available for orphaned attachment listing") | |
| 292 | + | h.sendError(w, http.StatusInternalServerError, "container service not available") | |
| 293 | + | return | |
| 294 | + | } | |
| 295 | + | attachments, err := h.containerSvc.ListOrphanedAttachments(ctx) | |
| 296 | + | if err != nil { | |
| 297 | + | log := zerowrap.FromCtx(ctx) | |
| 298 | + | log.Error().Err(err).Msg("failed to list orphaned attachments") | |
| 299 | + | h.sendError(w, http.StatusInternalServerError, "failed to list orphaned attachments") | |
| 300 | + | return | |
| 301 | + | } | |
| 302 | + | h.sendJSON(w, http.StatusOK, map[string]any{"attachments": attachments}) | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | func (h *Handler) handleAttachmentPrune(w http.ResponseWriter, r *http.Request) { | |
| 306 | + | ctx := r.Context() | |
| 307 | + | if r.Method != http.MethodPost { | |
| 308 | + | h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") | |
| 309 | + | return | |
| 310 | + | } | |
| 311 | + | if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) { | |
| 312 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write") | |
| 313 | + | return | |
| 314 | + | } | |
| 315 | + | if h.containerSvc == nil { | |
| 316 | + | log := zerowrap.FromCtx(ctx) | |
| 317 | + | log.Error().Msg("container service not available for orphaned attachment cleanup") | |
| 318 | + | h.sendError(w, http.StatusInternalServerError, "container service not available") | |
| 319 | + | return | |
| 320 | + | } | |
| 321 | + | stop := r.URL.Query().Get("stop") == "true" | |
| 322 | + | owner := r.URL.Query().Get("owner") | |
| 323 | + | var report *domain.CleanupReport | |
| 324 | + | var err error | |
| 325 | + | report, err = h.containerSvc.CleanupOrphanedAttachments(ctx, owner, stop) | |
| 326 | + | if err != nil { | |
| 327 | + | log := zerowrap.FromCtx(ctx) | |
| 328 | + | log.Error().Err(err).Str("owner", owner).Bool("stop", stop).Msg("failed to cleanup orphaned attachments") | |
| 329 | + | h.sendError(w, http.StatusInternalServerError, "failed to cleanup orphaned attachments") | |
| 330 | + | return | |
| 331 | + | } | |
| 332 | + | h.sendJSON(w, http.StatusOK, report) | |
| 333 | + | } | |
| 334 | + | ||
| 277 | 335 | // handleAttachmentsByImage handles GET /admin/attachments/by-image/{image} endpoint. | |
| 278 | 336 | // Returns all attachment targets associated with the given image name. | |
| 279 | 337 | func (h *Handler) handleAttachmentsByImage(w http.ResponseWriter, r *http.Request, path string) { | |
| @@ -721,20 +779,33 @@ func (h *Handler) handleRoutesDelete(w http.ResponseWriter, r *http.Request, rou | |||
| 721 | 779 | } | |
| 722 | 780 | ||
| 723 | 781 | if err := h.configSvc.RemoveRoute(ctx, routeDomain); err != nil { | |
| 724 | - | log.Error().Err(err).Str("domain", routeDomain).Msg("failed to remove route") | |
| 725 | 782 | switch { | |
| 726 | 783 | case errors.Is(err, domain.ErrRouteNotFound): | |
| 727 | - | h.sendError(w, http.StatusNotFound, "route not found") | |
| 784 | + | log.Debug().Str("domain", routeDomain).Msg("route already absent, reconciling runtime state") | |
| 728 | 785 | case errors.Is(err, domain.ErrRouteDomainEmpty), errors.Is(err, domain.ErrRouteDomainInvalid): | |
| 786 | + | log.Error().Err(err).Str("domain", routeDomain).Msg("failed to remove route") | |
| 729 | 787 | h.sendError(w, http.StatusBadRequest, "invalid route domain") | |
| 788 | + | return | |
| 730 | 789 | default: | |
| 790 | + | log.Error().Err(err).Str("domain", routeDomain).Msg("failed to remove route") | |
| 731 | 791 | h.sendError(w, http.StatusInternalServerError, "failed to remove route") | |
| 792 | + | return | |
| 732 | 793 | } | |
| 733 | - | return | |
| 794 | + | } | |
| 795 | + | ||
| 796 | + | var cleanup *dto.CleanupReport | |
| 797 | + | if h.containerSvc != nil { | |
| 798 | + | report, err := h.containerSvc.ReconcileRemovedRoute(ctx, routeDomain) | |
| 799 | + | if err != nil { | |
| 800 | + | log.Error().Err(err).Str("domain", routeDomain).Msg("failed to cleanup removed route runtime state") | |
| 801 | + | h.sendError(w, http.StatusInternalServerError, "route removed but runtime cleanup failed") | |
| 802 | + | return | |
| 803 | + | } | |
| 804 | + | cleanup = dto.CleanupReportFromDomain(report) | |
| 734 | 805 | } | |
| 735 | 806 | ||
| 736 | 807 | log.Info().Str("domain", routeDomain).Msg("route removed") | |
| 737 | - | h.sendJSON(w, http.StatusOK, dto.RouteDeleteResponse{Status: "removed"}) | |
| 808 | + | h.sendJSON(w, http.StatusOK, dto.RouteDeleteResponse{Status: "removed", Cleanup: cleanup}) | |
| 738 | 809 | } | |
| 739 | 810 | ||
| 740 | 811 | // handleRoutesByImage handles GET /admin/routes/by-image/{image} endpoint. | |
| @@ -229,16 +229,18 @@ type routeHandler func(w http.ResponseWriter, r *http.Request, path string) | |||||
| 229 | func (h *Handler) matchRoute(path string) (routeHandler, bool) { | 229 | func (h *Handler) matchRoute(path string) (routeHandler, bool) { | ||
| 230 | // Exact match routes | 230 | // Exact match routes | ||
| 231 | exactRoutes := map[string]routeHandler{ | 231 | exactRoutes := map[string]routeHandler{ | ||
| 232 | - | "/networks": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleNetworks(w, r) }, | 232 | + | "/networks": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleNetworks(w, r) }, |
| 233 | - | "/status": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleStatus(w, r) }, | 233 | + | "/status": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleStatus(w, r) }, |
| 234 | - | "/health": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleHealth(w, r) }, | 234 | + | "/health": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleHealth(w, r) }, |
| 235 | - | "/bootstrap": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleBootstrap(w, r) }, | 235 | + | "/bootstrap": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleBootstrap(w, r) }, |
| 236 | - | "/reload": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleReload(w, r) }, | 236 | + | "/reload": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleReload(w, r) }, |
| 237 | - | "/config": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleConfig(w, r) }, | 237 | + | "/config": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleConfig(w, r) }, |
| 238 | - | "/auth/verify": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleAuthVerify(w, r) }, | 238 | + | "/auth/verify": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleAuthVerify(w, r) }, |
| 239 | - | "/volumes": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleListVolumes(w, r) }, | 239 | + | "/volumes": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleListVolumes(w, r) }, |
| 240 | - | "/volumes/prune": func(w http.ResponseWriter, r *http.Request, _ string) { h.handlePruneVolumes(w, r) }, | 240 | + | "/volumes/prune": func(w http.ResponseWriter, r *http.Request, _ string) { h.handlePruneVolumes(w, r) }, |
| 241 | - | "/tls/status": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleTLSStatus(w, r) }, | 241 | + | "/attachments/orphans": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleAttachmentOrphans(w, r) }, |
| 242 | + | "/attachments/prune": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleAttachmentPrune(w, r) }, | |||
| 243 | + | "/tls/status": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleTLSStatus(w, r) }, | |||
| 242 | } | 244 | } | ||
| 243 | if handler, ok := exactRoutes[path]; ok { | 245 | if handler, ok := exactRoutes[path]; ok { | ||
| 244 | return handler, true | 246 | return handler, true | ||
| @@ -274,6 +276,62 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) { | |||||
| 274 | return nil, false | 276 | return nil, false | ||
| 275 | } | 277 | } | ||
| 276 | 278 | ||||
| 279 | + | func (h *Handler) handleAttachmentOrphans(w http.ResponseWriter, r *http.Request) { | |||
| 280 | + | ctx := r.Context() | |||
| 281 | + | if r.Method != http.MethodGet { | |||
| 282 | + | h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") | |||
| 283 | + | return | |||
| 284 | + | } | |||
| 285 | + | if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionRead) { | |||
| 286 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for config:read") | |||
| 287 | + | return | |||
| 288 | + | } | |||
| 289 | + | if h.containerSvc == nil { | |||
| 290 | + | log := zerowrap.FromCtx(ctx) | |||
| 291 | + | log.Error().Msg("container service not available for orphaned attachment listing") | |||
| 292 | + | h.sendError(w, http.StatusInternalServerError, "container service not available") | |||
| 293 | + | return | |||
| 294 | + | } | |||
| 295 | + | attachments, err := h.containerSvc.ListOrphanedAttachments(ctx) | |||
| 296 | + | if err != nil { | |||
| 297 | + | log := zerowrap.FromCtx(ctx) | |||
| 298 | + | log.Error().Err(err).Msg("failed to list orphaned attachments") | |||
| 299 | + | h.sendError(w, http.StatusInternalServerError, "failed to list orphaned attachments") | |||
| 300 | + | return | |||
| 301 | + | } | |||
| 302 | + | h.sendJSON(w, http.StatusOK, map[string]any{"attachments": attachments}) | |||
| 303 | + | } | |||
| 304 | + | ||||
| 305 | + | func (h *Handler) handleAttachmentPrune(w http.ResponseWriter, r *http.Request) { | |||
| 306 | + | ctx := r.Context() | |||
| 307 | + | if r.Method != http.MethodPost { | |||
| 308 | + | h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") | |||
| 309 | + | return | |||
| 310 | + | } | |||
| 311 | + | if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) { | |||
| 312 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write") | |||
| 313 | + | return | |||
| 314 | + | } | |||
| 315 | + | if h.containerSvc == nil { | |||
| 316 | + | log := zerowrap.FromCtx(ctx) | |||
| 317 | + | log.Error().Msg("container service not available for orphaned attachment cleanup") | |||
| 318 | + | h.sendError(w, http.StatusInternalServerError, "container service not available") | |||
| 319 | + | return | |||
| 320 | + | } | |||
| 321 | + | stop := r.URL.Query().Get("stop") == "true" | |||
| 322 | + | owner := r.URL.Query().Get("owner") | |||
| 323 | + | var report *domain.CleanupReport | |||
| 324 | + | var err error | |||
| 325 | + | report, err = h.containerSvc.CleanupOrphanedAttachments(ctx, owner, stop) | |||
| 326 | + | if err != nil { | |||
| 327 | + | log := zerowrap.FromCtx(ctx) | |||
| 328 | + | log.Error().Err(err).Str("owner", owner).Bool("stop", stop).Msg("failed to cleanup orphaned attachments") | |||
| 329 | + | h.sendError(w, http.StatusInternalServerError, "failed to cleanup orphaned attachments") | |||
| 330 | + | return | |||
| 331 | + | } | |||
| 332 | + | h.sendJSON(w, http.StatusOK, report) | |||
| 333 | + | } | |||
| 334 | + | ||||
| 277 | // handleAttachmentsByImage handles GET /admin/attachments/by-image/{image} endpoint. | 335 | // handleAttachmentsByImage handles GET /admin/attachments/by-image/{image} endpoint. | ||
| 278 | // Returns all attachment targets associated with the given image name. | 336 | // Returns all attachment targets associated with the given image name. | ||
| 279 | func (h *Handler) handleAttachmentsByImage(w http.ResponseWriter, r *http.Request, path string) { | 337 | func (h *Handler) handleAttachmentsByImage(w http.ResponseWriter, r *http.Request, path string) { | ||
| @@ -721,20 +779,33 @@ func (h *Handler) handleRoutesDelete(w http.ResponseWriter, r *http.Request, rou | |||||
| 721 | } | 779 | } | ||
| 722 | 780 | ||||
| 723 | if err := h.configSvc.RemoveRoute(ctx, routeDomain); err != nil { | 781 | if err := h.configSvc.RemoveRoute(ctx, routeDomain); err != nil { | ||
| 724 | - | log.Error().Err(err).Str("domain", routeDomain).Msg("failed to remove route") | |||
| 725 | switch { | 782 | switch { | ||
| 726 | case errors.Is(err, domain.ErrRouteNotFound): | 783 | case errors.Is(err, domain.ErrRouteNotFound): | ||
| 727 | - | h.sendError(w, http.StatusNotFound, "route not found") | 784 | + | log.Debug().Str("domain", routeDomain).Msg("route already absent, reconciling runtime state") |
| 728 | case errors.Is(err, domain.ErrRouteDomainEmpty), errors.Is(err, domain.ErrRouteDomainInvalid): | 785 | case errors.Is(err, domain.ErrRouteDomainEmpty), errors.Is(err, domain.ErrRouteDomainInvalid): | ||
| 786 | + | log.Error().Err(err).Str("domain", routeDomain).Msg("failed to remove route") | |||
| 729 | h.sendError(w, http.StatusBadRequest, "invalid route domain") | 787 | h.sendError(w, http.StatusBadRequest, "invalid route domain") | ||
| 788 | + | return | |||
| 730 | default: | 789 | default: | ||
| 790 | + | log.Error().Err(err).Str("domain", routeDomain).Msg("failed to remove route") | |||
| 731 | h.sendError(w, http.StatusInternalServerError, "failed to remove route") | 791 | h.sendError(w, http.StatusInternalServerError, "failed to remove route") | ||
| 792 | + | return | |||
| 732 | } | 793 | } | ||
| 733 | - | return | 794 | + | } |
| 795 | + | ||||
| 796 | + | var cleanup *dto.CleanupReport | |||
| 797 | + | if h.containerSvc != nil { | |||
| 798 | + | report, err := h.containerSvc.ReconcileRemovedRoute(ctx, routeDomain) | |||
| 799 | + | if err != nil { | |||
| 800 | + | log.Error().Err(err).Str("domain", routeDomain).Msg("failed to cleanup removed route runtime state") | |||
| 801 | + | h.sendError(w, http.StatusInternalServerError, "route removed but runtime cleanup failed") | |||
| 802 | + | return | |||
| 803 | + | } | |||
| 804 | + | cleanup = dto.CleanupReportFromDomain(report) | |||
| 734 | } | 805 | } | ||
| 735 | 806 | ||||
| 736 | log.Info().Str("domain", routeDomain).Msg("route removed") | 807 | log.Info().Str("domain", routeDomain).Msg("route removed") | ||
| 737 | - | h.sendJSON(w, http.StatusOK, dto.RouteDeleteResponse{Status: "removed"}) | 808 | + | h.sendJSON(w, http.StatusOK, dto.RouteDeleteResponse{Status: "removed", Cleanup: cleanup}) |
| 738 | } | 809 | } | ||
| 739 | 810 | ||||
| 740 | // handleRoutesByImage handles GET /admin/routes/by-image/{image} endpoint. | 811 | // handleRoutesByImage handles GET /admin/routes/by-image/{image} endpoint. | ||
M internal/adapters/in/http/admin/handler_test.go +241 -1
| @@ -720,7 +720,8 @@ func TestHandler_RoutesDelete_RequiresWriteScope(t *testing.T) { | |||
| 720 | 720 | for _, tt := range tests { | |
| 721 | 721 | t.Run(tt.name, func(t *testing.T) { | |
| 722 | 722 | if tt.wantStatus == http.StatusOK { | |
| 723 | - | configSvc.EXPECT().RemoveRoute(mock.Anything, "app.example.com").Return(nil).Maybe() | |
| 723 | + | configSvc.EXPECT().RemoveRoute(mock.Anything, "app.example.com").Return(nil).Once() | |
| 724 | + | containerSvc.EXPECT().ReconcileRemovedRoute(mock.Anything, "app.example.com").Return(&domain.CleanupReport{Domain: "app.example.com"}, nil).Once() | |
| 724 | 725 | } | |
| 725 | 726 | ||
| 726 | 727 | req := httptest.NewRequest("DELETE", "/admin/routes/app.example.com", nil) | |
| @@ -734,6 +735,115 @@ func TestHandler_RoutesDelete_RequiresWriteScope(t *testing.T) { | |||
| 734 | 735 | } | |
| 735 | 736 | } | |
| 736 | 737 | ||
| 738 | + | func TestHandler_RoutesDelete_ReconcilesRuntimeWhenRouteAlreadyMissing(t *testing.T) { | |
| 739 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 740 | + | authSvc := inmocks.NewMockAuthService(t) | |
| 741 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 742 | + | secretSvc := inmocks.NewMockSecretService(t) | |
| 743 | + | report := &domain.CleanupReport{Domain: "app.example.com"} | |
| 744 | + | ||
| 745 | + | configSvc.EXPECT().RemoveRoute(mock.Anything, "app.example.com").Return(domain.ErrRouteNotFound).Once() | |
| 746 | + | containerSvc.EXPECT().ReconcileRemovedRoute(mock.Anything, "app.example.com").Return(report, nil).Once() | |
| 747 | + | ||
| 748 | + | handler := newTestHandler(t, func(d *HandlerDeps) { | |
| 749 | + | d.ConfigSvc = configSvc | |
| 750 | + | d.AuthSvc = authSvc | |
| 751 | + | d.ContainerSvc = containerSvc | |
| 752 | + | d.SecretSvc = secretSvc | |
| 753 | + | }) | |
| 754 | + | server := newScopedTestServer(t, handler, "admin:routes:write") | |
| 755 | + | ||
| 756 | + | req, err := http.NewRequest(http.MethodDelete, server.URL+"/admin/routes/app.example.com", nil) | |
| 757 | + | require.NoError(t, err) | |
| 758 | + | respHTTP, err := http.DefaultClient.Do(req) | |
| 759 | + | require.NoError(t, err) | |
| 760 | + | defer respHTTP.Body.Close() | |
| 761 | + | ||
| 762 | + | require.Equal(t, http.StatusOK, respHTTP.StatusCode) | |
| 763 | + | var resp dto.RouteDeleteResponse | |
| 764 | + | require.NoError(t, json.NewDecoder(respHTTP.Body).Decode(&resp)) | |
| 765 | + | require.NotNil(t, resp.Cleanup) | |
| 766 | + | assert.Equal(t, "app.example.com", resp.Cleanup.Domain) | |
| 767 | + | } | |
| 768 | + | ||
| 769 | + | func TestHandler_RoutesDelete_ReturnsServerErrorWhenRuntimeCleanupFails(t *testing.T) { | |
| 770 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 771 | + | authSvc := inmocks.NewMockAuthService(t) | |
| 772 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 773 | + | secretSvc := inmocks.NewMockSecretService(t) | |
| 774 | + | ||
| 775 | + | configSvc.EXPECT().RemoveRoute(mock.Anything, "app.example.com").Return(nil).Once() | |
| 776 | + | containerSvc.EXPECT().ReconcileRemovedRoute(mock.Anything, "app.example.com").Return(nil, errors.New("boom")).Once() | |
| 777 | + | ||
| 778 | + | handler := newTestHandler(t, func(d *HandlerDeps) { | |
| 779 | + | d.ConfigSvc = configSvc | |
| 780 | + | d.AuthSvc = authSvc | |
| 781 | + | d.ContainerSvc = containerSvc | |
| 782 | + | d.SecretSvc = secretSvc | |
| 783 | + | }) | |
| 784 | + | server := newScopedTestServer(t, handler, "admin:routes:write") | |
| 785 | + | ||
| 786 | + | req, err := http.NewRequest(http.MethodDelete, server.URL+"/admin/routes/app.example.com", nil) | |
| 787 | + | require.NoError(t, err) | |
| 788 | + | resp, err := http.DefaultClient.Do(req) | |
| 789 | + | require.NoError(t, err) | |
| 790 | + | defer resp.Body.Close() | |
| 791 | + | body, err := io.ReadAll(resp.Body) | |
| 792 | + | require.NoError(t, err) | |
| 793 | + | ||
| 794 | + | assert.Equal(t, http.StatusInternalServerError, resp.StatusCode) | |
| 795 | + | assert.Contains(t, string(body), "route removed but runtime cleanup failed") | |
| 796 | + | } | |
| 797 | + | ||
| 798 | + | func TestHandler_RoutesDelete_ReconcilesRuntimeAndReturnsCleanupReport(t *testing.T) { | |
| 799 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 800 | + | authSvc := inmocks.NewMockAuthService(t) | |
| 801 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 802 | + | secretSvc := inmocks.NewMockSecretService(t) | |
| 803 | + | ||
| 804 | + | report := &domain.CleanupReport{ | |
| 805 | + | Domain: "app.example.com", | |
| 806 | + | RemovedContainers: []domain.CleanupContainer{{ | |
| 807 | + | ID: "container-1", | |
| 808 | + | Name: "gordon-app.example.com", | |
| 809 | + | }}, | |
| 810 | + | PreservedAttachments: []domain.CleanupAttachment{{ | |
| 811 | + | Name: "postgres", | |
| 812 | + | ContainerID: "attachment-1", | |
| 813 | + | Status: "running", | |
| 814 | + | }}, | |
| 815 | + | } | |
| 816 | + | ||
| 817 | + | removeCall := configSvc.EXPECT().RemoveRoute(mock.Anything, "app.example.com").Return(nil).Once() | |
| 818 | + | cleanupCall := containerSvc.EXPECT().ReconcileRemovedRoute(mock.Anything, "app.example.com").Return(report, nil).Once() | |
| 819 | + | mock.InOrder(removeCall, cleanupCall) | |
| 820 | + | ||
| 821 | + | handler := newTestHandler(t, func(d *HandlerDeps) { | |
| 822 | + | d.ConfigSvc = configSvc | |
| 823 | + | d.AuthSvc = authSvc | |
| 824 | + | d.ContainerSvc = containerSvc | |
| 825 | + | d.SecretSvc = secretSvc | |
| 826 | + | }) | |
| 827 | + | server := newScopedTestServer(t, handler, "admin:routes:write") | |
| 828 | + | ||
| 829 | + | req, err := http.NewRequest(http.MethodDelete, server.URL+"/admin/routes/app.example.com", nil) | |
| 830 | + | require.NoError(t, err) | |
| 831 | + | respHTTP, err := http.DefaultClient.Do(req) | |
| 832 | + | require.NoError(t, err) | |
| 833 | + | defer respHTTP.Body.Close() | |
| 834 | + | ||
| 835 | + | require.Equal(t, http.StatusOK, respHTTP.StatusCode) | |
| 836 | + | var resp dto.RouteDeleteResponse | |
| 837 | + | require.NoError(t, json.NewDecoder(respHTTP.Body).Decode(&resp)) | |
| 838 | + | assert.Equal(t, "removed", resp.Status) | |
| 839 | + | require.NotNil(t, resp.Cleanup) | |
| 840 | + | assert.Equal(t, "app.example.com", resp.Cleanup.Domain) | |
| 841 | + | require.Len(t, resp.Cleanup.RemovedContainers, 1) | |
| 842 | + | assert.Equal(t, "container-1", resp.Cleanup.RemovedContainers[0].ID) | |
| 843 | + | require.Len(t, resp.Cleanup.PreservedAttachments, 1) | |
| 844 | + | assert.Equal(t, "attachment-1", resp.Cleanup.PreservedAttachments[0].ContainerID) | |
| 845 | + | } | |
| 846 | + | ||
| 737 | 847 | // Secrets endpoint tests | |
| 738 | 848 | ||
| 739 | 849 | func TestHandler_SecretsGet_RequiresReadScope(t *testing.T) { | |
| @@ -2712,3 +2822,133 @@ func TestHandler_TLSStatus_POSTReturns405(t *testing.T) { | |||
| 2712 | 2822 | ||
| 2713 | 2823 | assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode) | |
| 2714 | 2824 | } | |
| 2825 | + | ||
| 2826 | + | func TestHandler_AttachmentOrphans_ReturnsOrphanedAttachments(t *testing.T) { | |
| 2827 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 2828 | + | authSvc := inmocks.NewMockAuthService(t) | |
| 2829 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 2830 | + | secretSvc := inmocks.NewMockSecretService(t) | |
| 2831 | + | attachments := []domain.CleanupAttachment{{ContainerID: "attachment-1", Name: "postgres", Owner: "app.example.com"}} | |
| 2832 | + | containerSvc.EXPECT().ListOrphanedAttachments(mock.Anything).Return(attachments, nil).Once() | |
| 2833 | + | ||
| 2834 | + | handler := newTestHandler(t, func(d *HandlerDeps) { | |
| 2835 | + | d.ConfigSvc = configSvc | |
| 2836 | + | d.AuthSvc = authSvc | |
| 2837 | + | d.ContainerSvc = containerSvc | |
| 2838 | + | d.SecretSvc = secretSvc | |
| 2839 | + | }) | |
| 2840 | + | ||
| 2841 | + | req := httptest.NewRequest("GET", "/admin/attachments/orphans", nil) | |
| 2842 | + | req = req.WithContext(ctxWithScopes("admin:config:read")) | |
| 2843 | + | rec := httptest.NewRecorder() | |
| 2844 | + | ||
| 2845 | + | handler.ServeHTTP(rec, req) | |
| 2846 | + | ||
| 2847 | + | require.Equal(t, http.StatusOK, rec.Code) | |
| 2848 | + | var resp struct { | |
| 2849 | + | Attachments []domain.CleanupAttachment `json:"attachments"` | |
| 2850 | + | } | |
| 2851 | + | require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) | |
| 2852 | + | require.Len(t, resp.Attachments, 1) | |
| 2853 | + | assert.Equal(t, "app.example.com", resp.Attachments[0].Owner) | |
| 2854 | + | } | |
| 2855 | + | ||
| 2856 | + | func TestHandler_AttachmentPrune_StopsOnlyWhenRequested(t *testing.T) { | |
| 2857 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 2858 | + | authSvc := inmocks.NewMockAuthService(t) | |
| 2859 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 2860 | + | secretSvc := inmocks.NewMockSecretService(t) | |
| 2861 | + | report := &domain.CleanupReport{RemovedContainers: []domain.CleanupContainer{{ID: "attachment-1"}}} | |
| 2862 | + | containerSvc.EXPECT().CleanupOrphanedAttachments(mock.Anything, "", true).Return(report, nil).Once() | |
| 2863 | + | ||
| 2864 | + | handler := newTestHandler(t, func(d *HandlerDeps) { | |
| 2865 | + | d.ConfigSvc = configSvc | |
| 2866 | + | d.AuthSvc = authSvc | |
| 2867 | + | d.ContainerSvc = containerSvc | |
| 2868 | + | d.SecretSvc = secretSvc | |
| 2869 | + | }) | |
| 2870 | + | ||
| 2871 | + | req := httptest.NewRequest("POST", "/admin/attachments/prune?stop=true", nil) | |
| 2872 | + | req = req.WithContext(ctxWithScopes("admin:config:write")) | |
| 2873 | + | rec := httptest.NewRecorder() | |
| 2874 | + | ||
| 2875 | + | handler.ServeHTTP(rec, req) | |
| 2876 | + | ||
| 2877 | + | require.Equal(t, http.StatusOK, rec.Code) | |
| 2878 | + | var got domain.CleanupReport | |
| 2879 | + | require.NoError(t, json.NewDecoder(rec.Body).Decode(&got)) | |
| 2880 | + | require.Len(t, got.RemovedContainers, 1) | |
| 2881 | + | assert.Equal(t, "attachment-1", got.RemovedContainers[0].ID) | |
| 2882 | + | } | |
| 2883 | + | ||
| 2884 | + | func TestHandler_AttachmentOrphans_ReturnsServerErrorOnListFailure(t *testing.T) { | |
| 2885 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 2886 | + | authSvc := inmocks.NewMockAuthService(t) | |
| 2887 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 2888 | + | secretSvc := inmocks.NewMockSecretService(t) | |
| 2889 | + | containerSvc.EXPECT().ListOrphanedAttachments(mock.Anything).Return(nil, errors.New("boom")).Once() | |
| 2890 | + | ||
| 2891 | + | handler := newTestHandler(t, func(d *HandlerDeps) { | |
| 2892 | + | d.ConfigSvc = configSvc | |
| 2893 | + | d.AuthSvc = authSvc | |
| 2894 | + | d.ContainerSvc = containerSvc | |
| 2895 | + | d.SecretSvc = secretSvc | |
| 2896 | + | }) | |
| 2897 | + | ||
| 2898 | + | req := httptest.NewRequest("GET", "/admin/attachments/orphans", nil) | |
| 2899 | + | req = req.WithContext(ctxWithScopes("admin:config:read")) | |
| 2900 | + | rec := httptest.NewRecorder() | |
| 2901 | + | ||
| 2902 | + | handler.ServeHTTP(rec, req) | |
| 2903 | + | ||
| 2904 | + | assert.Equal(t, http.StatusInternalServerError, rec.Code) | |
| 2905 | + | } | |
| 2906 | + | ||
| 2907 | + | func TestHandler_AttachmentOrphans_PostReturns405(t *testing.T) { | |
| 2908 | + | handler := newTestHandler(t, func(d *HandlerDeps) { | |
| 2909 | + | d.ContainerSvc = inmocks.NewMockContainerService(t) | |
| 2910 | + | }) | |
| 2911 | + | req := httptest.NewRequest("POST", "/admin/attachments/orphans", nil) | |
| 2912 | + | req = req.WithContext(ctxWithScopes("admin:config:read")) | |
| 2913 | + | rec := httptest.NewRecorder() | |
| 2914 | + | ||
| 2915 | + | handler.ServeHTTP(rec, req) | |
| 2916 | + | ||
| 2917 | + | assert.Equal(t, http.StatusMethodNotAllowed, rec.Code) | |
| 2918 | + | } | |
| 2919 | + | ||
| 2920 | + | func TestHandler_AttachmentPrune_ReturnsServerErrorOnCleanupFailure(t *testing.T) { | |
| 2921 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 2922 | + | authSvc := inmocks.NewMockAuthService(t) | |
| 2923 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 2924 | + | secretSvc := inmocks.NewMockSecretService(t) | |
| 2925 | + | containerSvc.EXPECT().CleanupOrphanedAttachments(mock.Anything, "", true).Return(nil, errors.New("boom")).Once() | |
| 2926 | + | ||
| 2927 | + | handler := newTestHandler(t, func(d *HandlerDeps) { | |
| 2928 | + | d.ConfigSvc = configSvc | |
| 2929 | + | d.AuthSvc = authSvc | |
| 2930 | + | d.ContainerSvc = containerSvc | |
| 2931 | + | d.SecretSvc = secretSvc | |
| 2932 | + | }) | |
| 2933 | + | ||
| 2934 | + | req := httptest.NewRequest("POST", "/admin/attachments/prune?stop=true", nil) | |
| 2935 | + | req = req.WithContext(ctxWithScopes("admin:config:write")) | |
| 2936 | + | rec := httptest.NewRecorder() | |
| 2937 | + | ||
| 2938 | + | handler.ServeHTTP(rec, req) | |
| 2939 | + | ||
| 2940 | + | assert.Equal(t, http.StatusInternalServerError, rec.Code) | |
| 2941 | + | } | |
| 2942 | + | ||
| 2943 | + | func TestHandler_AttachmentPrune_GetReturns405(t *testing.T) { | |
| 2944 | + | handler := newTestHandler(t, func(d *HandlerDeps) { | |
| 2945 | + | d.ContainerSvc = inmocks.NewMockContainerService(t) | |
| 2946 | + | }) | |
| 2947 | + | req := httptest.NewRequest("GET", "/admin/attachments/prune", nil) | |
| 2948 | + | req = req.WithContext(ctxWithScopes("admin:config:write")) | |
| 2949 | + | rec := httptest.NewRecorder() | |
| 2950 | + | ||
| 2951 | + | handler.ServeHTTP(rec, req) | |
| 2952 | + | ||
| 2953 | + | assert.Equal(t, http.StatusMethodNotAllowed, rec.Code) | |
| 2954 | + | } | |
| @@ -720,7 +720,8 @@ func TestHandler_RoutesDelete_RequiresWriteScope(t *testing.T) { | |||||
| 720 | for _, tt := range tests { | 720 | for _, tt := range tests { | ||
| 721 | t.Run(tt.name, func(t *testing.T) { | 721 | t.Run(tt.name, func(t *testing.T) { | ||
| 722 | if tt.wantStatus == http.StatusOK { | 722 | if tt.wantStatus == http.StatusOK { | ||
| 723 | - | configSvc.EXPECT().RemoveRoute(mock.Anything, "app.example.com").Return(nil).Maybe() | 723 | + | configSvc.EXPECT().RemoveRoute(mock.Anything, "app.example.com").Return(nil).Once() |
| 724 | + | containerSvc.EXPECT().ReconcileRemovedRoute(mock.Anything, "app.example.com").Return(&domain.CleanupReport{Domain: "app.example.com"}, nil).Once() | |||
| 724 | } | 725 | } | ||
| 725 | 726 | ||||
| 726 | req := httptest.NewRequest("DELETE", "/admin/routes/app.example.com", nil) | 727 | req := httptest.NewRequest("DELETE", "/admin/routes/app.example.com", nil) | ||
| @@ -734,6 +735,115 @@ func TestHandler_RoutesDelete_RequiresWriteScope(t *testing.T) { | |||||
| 734 | } | 735 | } | ||
| 735 | } | 736 | } | ||
| 736 | 737 | ||||
| 738 | + | func TestHandler_RoutesDelete_ReconcilesRuntimeWhenRouteAlreadyMissing(t *testing.T) { | |||
| 739 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 740 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 741 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 742 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 743 | + | report := &domain.CleanupReport{Domain: "app.example.com"} | |||
| 744 | + | ||||
| 745 | + | configSvc.EXPECT().RemoveRoute(mock.Anything, "app.example.com").Return(domain.ErrRouteNotFound).Once() | |||
| 746 | + | containerSvc.EXPECT().ReconcileRemovedRoute(mock.Anything, "app.example.com").Return(report, nil).Once() | |||
| 747 | + | ||||
| 748 | + | handler := newTestHandler(t, func(d *HandlerDeps) { | |||
| 749 | + | d.ConfigSvc = configSvc | |||
| 750 | + | d.AuthSvc = authSvc | |||
| 751 | + | d.ContainerSvc = containerSvc | |||
| 752 | + | d.SecretSvc = secretSvc | |||
| 753 | + | }) | |||
| 754 | + | server := newScopedTestServer(t, handler, "admin:routes:write") | |||
| 755 | + | ||||
| 756 | + | req, err := http.NewRequest(http.MethodDelete, server.URL+"/admin/routes/app.example.com", nil) | |||
| 757 | + | require.NoError(t, err) | |||
| 758 | + | respHTTP, err := http.DefaultClient.Do(req) | |||
| 759 | + | require.NoError(t, err) | |||
| 760 | + | defer respHTTP.Body.Close() | |||
| 761 | + | ||||
| 762 | + | require.Equal(t, http.StatusOK, respHTTP.StatusCode) | |||
| 763 | + | var resp dto.RouteDeleteResponse | |||
| 764 | + | require.NoError(t, json.NewDecoder(respHTTP.Body).Decode(&resp)) | |||
| 765 | + | require.NotNil(t, resp.Cleanup) | |||
| 766 | + | assert.Equal(t, "app.example.com", resp.Cleanup.Domain) | |||
| 767 | + | } | |||
| 768 | + | ||||
| 769 | + | func TestHandler_RoutesDelete_ReturnsServerErrorWhenRuntimeCleanupFails(t *testing.T) { | |||
| 770 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 771 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 772 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 773 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 774 | + | ||||
| 775 | + | configSvc.EXPECT().RemoveRoute(mock.Anything, "app.example.com").Return(nil).Once() | |||
| 776 | + | containerSvc.EXPECT().ReconcileRemovedRoute(mock.Anything, "app.example.com").Return(nil, errors.New("boom")).Once() | |||
| 777 | + | ||||
| 778 | + | handler := newTestHandler(t, func(d *HandlerDeps) { | |||
| 779 | + | d.ConfigSvc = configSvc | |||
| 780 | + | d.AuthSvc = authSvc | |||
| 781 | + | d.ContainerSvc = containerSvc | |||
| 782 | + | d.SecretSvc = secretSvc | |||
| 783 | + | }) | |||
| 784 | + | server := newScopedTestServer(t, handler, "admin:routes:write") | |||
| 785 | + | ||||
| 786 | + | req, err := http.NewRequest(http.MethodDelete, server.URL+"/admin/routes/app.example.com", nil) | |||
| 787 | + | require.NoError(t, err) | |||
| 788 | + | resp, err := http.DefaultClient.Do(req) | |||
| 789 | + | require.NoError(t, err) | |||
| 790 | + | defer resp.Body.Close() | |||
| 791 | + | body, err := io.ReadAll(resp.Body) | |||
| 792 | + | require.NoError(t, err) | |||
| 793 | + | ||||
| 794 | + | assert.Equal(t, http.StatusInternalServerError, resp.StatusCode) | |||
| 795 | + | assert.Contains(t, string(body), "route removed but runtime cleanup failed") | |||
| 796 | + | } | |||
| 797 | + | ||||
| 798 | + | func TestHandler_RoutesDelete_ReconcilesRuntimeAndReturnsCleanupReport(t *testing.T) { | |||
| 799 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 800 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 801 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 802 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 803 | + | ||||
| 804 | + | report := &domain.CleanupReport{ | |||
| 805 | + | Domain: "app.example.com", | |||
| 806 | + | RemovedContainers: []domain.CleanupContainer{{ | |||
| 807 | + | ID: "container-1", | |||
| 808 | + | Name: "gordon-app.example.com", | |||
| 809 | + | }}, | |||
| 810 | + | PreservedAttachments: []domain.CleanupAttachment{{ | |||
| 811 | + | Name: "postgres", | |||
| 812 | + | ContainerID: "attachment-1", | |||
| 813 | + | Status: "running", | |||
| 814 | + | }}, | |||
| 815 | + | } | |||
| 816 | + | ||||
| 817 | + | removeCall := configSvc.EXPECT().RemoveRoute(mock.Anything, "app.example.com").Return(nil).Once() | |||
| 818 | + | cleanupCall := containerSvc.EXPECT().ReconcileRemovedRoute(mock.Anything, "app.example.com").Return(report, nil).Once() | |||
| 819 | + | mock.InOrder(removeCall, cleanupCall) | |||
| 820 | + | ||||
| 821 | + | handler := newTestHandler(t, func(d *HandlerDeps) { | |||
| 822 | + | d.ConfigSvc = configSvc | |||
| 823 | + | d.AuthSvc = authSvc | |||
| 824 | + | d.ContainerSvc = containerSvc | |||
| 825 | + | d.SecretSvc = secretSvc | |||
| 826 | + | }) | |||
| 827 | + | server := newScopedTestServer(t, handler, "admin:routes:write") | |||
| 828 | + | ||||
| 829 | + | req, err := http.NewRequest(http.MethodDelete, server.URL+"/admin/routes/app.example.com", nil) | |||
| 830 | + | require.NoError(t, err) | |||
| 831 | + | respHTTP, err := http.DefaultClient.Do(req) | |||
| 832 | + | require.NoError(t, err) | |||
| 833 | + | defer respHTTP.Body.Close() | |||
| 834 | + | ||||
| 835 | + | require.Equal(t, http.StatusOK, respHTTP.StatusCode) | |||
| 836 | + | var resp dto.RouteDeleteResponse | |||
| 837 | + | require.NoError(t, json.NewDecoder(respHTTP.Body).Decode(&resp)) | |||
| 838 | + | assert.Equal(t, "removed", resp.Status) | |||
| 839 | + | require.NotNil(t, resp.Cleanup) | |||
| 840 | + | assert.Equal(t, "app.example.com", resp.Cleanup.Domain) | |||
| 841 | + | require.Len(t, resp.Cleanup.RemovedContainers, 1) | |||
| 842 | + | assert.Equal(t, "container-1", resp.Cleanup.RemovedContainers[0].ID) | |||
| 843 | + | require.Len(t, resp.Cleanup.PreservedAttachments, 1) | |||
| 844 | + | assert.Equal(t, "attachment-1", resp.Cleanup.PreservedAttachments[0].ContainerID) | |||
| 845 | + | } | |||
| 846 | + | ||||
| 737 | // Secrets endpoint tests | 847 | // Secrets endpoint tests | ||
| 738 | 848 | ||||
| 739 | func TestHandler_SecretsGet_RequiresReadScope(t *testing.T) { | 849 | func TestHandler_SecretsGet_RequiresReadScope(t *testing.T) { | ||
| @@ -2712,3 +2822,133 @@ func TestHandler_TLSStatus_POSTReturns405(t *testing.T) { | |||||
| 2712 | 2822 | ||||
| 2713 | assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode) | 2823 | assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode) | ||
| 2714 | } | 2824 | } | ||
| 2825 | + | ||||
| 2826 | + | func TestHandler_AttachmentOrphans_ReturnsOrphanedAttachments(t *testing.T) { | |||
| 2827 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 2828 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 2829 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 2830 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 2831 | + | attachments := []domain.CleanupAttachment{{ContainerID: "attachment-1", Name: "postgres", Owner: "app.example.com"}} | |||
| 2832 | + | containerSvc.EXPECT().ListOrphanedAttachments(mock.Anything).Return(attachments, nil).Once() | |||
| 2833 | + | ||||
| 2834 | + | handler := newTestHandler(t, func(d *HandlerDeps) { | |||
| 2835 | + | d.ConfigSvc = configSvc | |||
| 2836 | + | d.AuthSvc = authSvc | |||
| 2837 | + | d.ContainerSvc = containerSvc | |||
| 2838 | + | d.SecretSvc = secretSvc | |||
| 2839 | + | }) | |||
| 2840 | + | ||||
| 2841 | + | req := httptest.NewRequest("GET", "/admin/attachments/orphans", nil) | |||
| 2842 | + | req = req.WithContext(ctxWithScopes("admin:config:read")) | |||
| 2843 | + | rec := httptest.NewRecorder() | |||
| 2844 | + | ||||
| 2845 | + | handler.ServeHTTP(rec, req) | |||
| 2846 | + | ||||
| 2847 | + | require.Equal(t, http.StatusOK, rec.Code) | |||
| 2848 | + | var resp struct { | |||
| 2849 | + | Attachments []domain.CleanupAttachment `json:"attachments"` | |||
| 2850 | + | } | |||
| 2851 | + | require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) | |||
| 2852 | + | require.Len(t, resp.Attachments, 1) | |||
| 2853 | + | assert.Equal(t, "app.example.com", resp.Attachments[0].Owner) | |||
| 2854 | + | } | |||
| 2855 | + | ||||
| 2856 | + | func TestHandler_AttachmentPrune_StopsOnlyWhenRequested(t *testing.T) { | |||
| 2857 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 2858 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 2859 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 2860 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 2861 | + | report := &domain.CleanupReport{RemovedContainers: []domain.CleanupContainer{{ID: "attachment-1"}}} | |||
| 2862 | + | containerSvc.EXPECT().CleanupOrphanedAttachments(mock.Anything, "", true).Return(report, nil).Once() | |||
| 2863 | + | ||||
| 2864 | + | handler := newTestHandler(t, func(d *HandlerDeps) { | |||
| 2865 | + | d.ConfigSvc = configSvc | |||
| 2866 | + | d.AuthSvc = authSvc | |||
| 2867 | + | d.ContainerSvc = containerSvc | |||
| 2868 | + | d.SecretSvc = secretSvc | |||
| 2869 | + | }) | |||
| 2870 | + | ||||
| 2871 | + | req := httptest.NewRequest("POST", "/admin/attachments/prune?stop=true", nil) | |||
| 2872 | + | req = req.WithContext(ctxWithScopes("admin:config:write")) | |||
| 2873 | + | rec := httptest.NewRecorder() | |||
| 2874 | + | ||||
| 2875 | + | handler.ServeHTTP(rec, req) | |||
| 2876 | + | ||||
| 2877 | + | require.Equal(t, http.StatusOK, rec.Code) | |||
| 2878 | + | var got domain.CleanupReport | |||
| 2879 | + | require.NoError(t, json.NewDecoder(rec.Body).Decode(&got)) | |||
| 2880 | + | require.Len(t, got.RemovedContainers, 1) | |||
| 2881 | + | assert.Equal(t, "attachment-1", got.RemovedContainers[0].ID) | |||
| 2882 | + | } | |||
| 2883 | + | ||||
| 2884 | + | func TestHandler_AttachmentOrphans_ReturnsServerErrorOnListFailure(t *testing.T) { | |||
| 2885 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 2886 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 2887 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 2888 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 2889 | + | containerSvc.EXPECT().ListOrphanedAttachments(mock.Anything).Return(nil, errors.New("boom")).Once() | |||
| 2890 | + | ||||
| 2891 | + | handler := newTestHandler(t, func(d *HandlerDeps) { | |||
| 2892 | + | d.ConfigSvc = configSvc | |||
| 2893 | + | d.AuthSvc = authSvc | |||
| 2894 | + | d.ContainerSvc = containerSvc | |||
| 2895 | + | d.SecretSvc = secretSvc | |||
| 2896 | + | }) | |||
| 2897 | + | ||||
| 2898 | + | req := httptest.NewRequest("GET", "/admin/attachments/orphans", nil) | |||
| 2899 | + | req = req.WithContext(ctxWithScopes("admin:config:read")) | |||
| 2900 | + | rec := httptest.NewRecorder() | |||
| 2901 | + | ||||
| 2902 | + | handler.ServeHTTP(rec, req) | |||
| 2903 | + | ||||
| 2904 | + | assert.Equal(t, http.StatusInternalServerError, rec.Code) | |||
| 2905 | + | } | |||
| 2906 | + | ||||
| 2907 | + | func TestHandler_AttachmentOrphans_PostReturns405(t *testing.T) { | |||
| 2908 | + | handler := newTestHandler(t, func(d *HandlerDeps) { | |||
| 2909 | + | d.ContainerSvc = inmocks.NewMockContainerService(t) | |||
| 2910 | + | }) | |||
| 2911 | + | req := httptest.NewRequest("POST", "/admin/attachments/orphans", nil) | |||
| 2912 | + | req = req.WithContext(ctxWithScopes("admin:config:read")) | |||
| 2913 | + | rec := httptest.NewRecorder() | |||
| 2914 | + | ||||
| 2915 | + | handler.ServeHTTP(rec, req) | |||
| 2916 | + | ||||
| 2917 | + | assert.Equal(t, http.StatusMethodNotAllowed, rec.Code) | |||
| 2918 | + | } | |||
| 2919 | + | ||||
| 2920 | + | func TestHandler_AttachmentPrune_ReturnsServerErrorOnCleanupFailure(t *testing.T) { | |||
| 2921 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 2922 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 2923 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 2924 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 2925 | + | containerSvc.EXPECT().CleanupOrphanedAttachments(mock.Anything, "", true).Return(nil, errors.New("boom")).Once() | |||
| 2926 | + | ||||
| 2927 | + | handler := newTestHandler(t, func(d *HandlerDeps) { | |||
| 2928 | + | d.ConfigSvc = configSvc | |||
| 2929 | + | d.AuthSvc = authSvc | |||
| 2930 | + | d.ContainerSvc = containerSvc | |||
| 2931 | + | d.SecretSvc = secretSvc | |||
| 2932 | + | }) | |||
| 2933 | + | ||||
| 2934 | + | req := httptest.NewRequest("POST", "/admin/attachments/prune?stop=true", nil) | |||
| 2935 | + | req = req.WithContext(ctxWithScopes("admin:config:write")) | |||
| 2936 | + | rec := httptest.NewRecorder() | |||
| 2937 | + | ||||
| 2938 | + | handler.ServeHTTP(rec, req) | |||
| 2939 | + | ||||
| 2940 | + | assert.Equal(t, http.StatusInternalServerError, rec.Code) | |||
| 2941 | + | } | |||
| 2942 | + | ||||
| 2943 | + | func TestHandler_AttachmentPrune_GetReturns405(t *testing.T) { | |||
| 2944 | + | handler := newTestHandler(t, func(d *HandlerDeps) { | |||
| 2945 | + | d.ContainerSvc = inmocks.NewMockContainerService(t) | |||
| 2946 | + | }) | |||
| 2947 | + | req := httptest.NewRequest("GET", "/admin/attachments/prune", nil) | |||
| 2948 | + | req = req.WithContext(ctxWithScopes("admin:config:write")) | |||
| 2949 | + | rec := httptest.NewRecorder() | |||
| 2950 | + | ||||
| 2951 | + | handler.ServeHTTP(rec, req) | |||
| 2952 | + | ||||
| 2953 | + | assert.Equal(t, http.StatusMethodNotAllowed, rec.Code) | |||
| 2954 | + | } | |||
M internal/boundaries/in/container.go +11 -0
| @@ -20,6 +20,10 @@ type ContainerService interface { | |||
| 20 | 20 | // Remove removes a container, optionally forcing removal. | |
| 21 | 21 | Remove(ctx context.Context, containerID string, force bool) error | |
| 22 | 22 | ||
| 23 | + | // ReconcileRemovedRoute removes active route runtime containers after the | |
| 24 | + | // route has been removed from configuration, preserving stateful resources. | |
| 25 | + | ReconcileRemovedRoute(ctx context.Context, domain string) (*domain.CleanupReport, error) | |
| 26 | + | ||
| 23 | 27 | // Get retrieves a container by domain name. | |
| 24 | 28 | Get(ctx context.Context, domain string) (*domain.Container, bool) | |
| 25 | 29 | ||
| @@ -36,6 +40,13 @@ type ContainerService interface { | |||
| 36 | 40 | // ListAttachments returns attachments for a domain. | |
| 37 | 41 | ListAttachments(ctx context.Context, domain string) []domain.Attachment | |
| 38 | 42 | ||
| 43 | + | // ListOrphanedAttachments returns running attachment containers no longer configured. | |
| 44 | + | ListOrphanedAttachments(ctx context.Context) ([]domain.CleanupAttachment, error) | |
| 45 | + | ||
| 46 | + | // CleanupOrphanedAttachments optionally stops/removes orphaned attachment containers. | |
| 47 | + | // When owner is non-empty, cleanup is scoped to that attachment owner. | |
| 48 | + | CleanupOrphanedAttachments(ctx context.Context, owner string, stop bool) (*domain.CleanupReport, error) | |
| 49 | + | ||
| 39 | 50 | // ListNetworks returns Gordon-managed networks. | |
| 40 | 51 | ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) | |
| 41 | 52 | ||
| @@ -20,6 +20,10 @@ type ContainerService interface { | |||||
| 20 | // Remove removes a container, optionally forcing removal. | 20 | // Remove removes a container, optionally forcing removal. | ||
| 21 | Remove(ctx context.Context, containerID string, force bool) error | 21 | Remove(ctx context.Context, containerID string, force bool) error | ||
| 22 | 22 | ||||
| 23 | + | // ReconcileRemovedRoute removes active route runtime containers after the | |||
| 24 | + | // route has been removed from configuration, preserving stateful resources. | |||
| 25 | + | ReconcileRemovedRoute(ctx context.Context, domain string) (*domain.CleanupReport, error) | |||
| 26 | + | ||||
| 23 | // Get retrieves a container by domain name. | 27 | // Get retrieves a container by domain name. | ||
| 24 | Get(ctx context.Context, domain string) (*domain.Container, bool) | 28 | Get(ctx context.Context, domain string) (*domain.Container, bool) | ||
| 25 | 29 | ||||
| @@ -36,6 +40,13 @@ type ContainerService interface { | |||||
| 36 | // ListAttachments returns attachments for a domain. | 40 | // ListAttachments returns attachments for a domain. | ||
| 37 | ListAttachments(ctx context.Context, domain string) []domain.Attachment | 41 | ListAttachments(ctx context.Context, domain string) []domain.Attachment | ||
| 38 | 42 | ||||
| 43 | + | // ListOrphanedAttachments returns running attachment containers no longer configured. | |||
| 44 | + | ListOrphanedAttachments(ctx context.Context) ([]domain.CleanupAttachment, error) | |||
| 45 | + | ||||
| 46 | + | // CleanupOrphanedAttachments optionally stops/removes orphaned attachment containers. | |||
| 47 | + | // When owner is non-empty, cleanup is scoped to that attachment owner. | |||
| 48 | + | CleanupOrphanedAttachments(ctx context.Context, owner string, stop bool) (*domain.CleanupReport, error) | |||
| 49 | + | ||||
| 39 | // ListNetworks returns Gordon-managed networks. | 50 | // ListNetworks returns Gordon-managed networks. | ||
| 40 | ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) | 51 | ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) | ||
| 41 | 52 | ||||
M internal/boundaries/in/mocks/mock_container_service.go +204 -0
| @@ -95,6 +95,80 @@ func (_c *MockContainerService_AutoStart_Call) RunAndReturn(run func(ctx context | |||
| 95 | 95 | return _c | |
| 96 | 96 | } | |
| 97 | 97 | ||
| 98 | + | // CleanupOrphanedAttachments provides a mock function for the type MockContainerService | |
| 99 | + | func (_mock *MockContainerService) CleanupOrphanedAttachments(ctx context.Context, owner string, stop bool) (*domain.CleanupReport, error) { | |
| 100 | + | ret := _mock.Called(ctx, owner, stop) | |
| 101 | + | ||
| 102 | + | if len(ret) == 0 { | |
| 103 | + | panic("no return value specified for CleanupOrphanedAttachments") | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | var r0 *domain.CleanupReport | |
| 107 | + | var r1 error | |
| 108 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, bool) (*domain.CleanupReport, error)); ok { | |
| 109 | + | return returnFunc(ctx, owner, stop) | |
| 110 | + | } | |
| 111 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, bool) *domain.CleanupReport); ok { | |
| 112 | + | r0 = returnFunc(ctx, owner, stop) | |
| 113 | + | } else { | |
| 114 | + | if ret.Get(0) != nil { | |
| 115 | + | r0 = ret.Get(0).(*domain.CleanupReport) | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string, bool) error); ok { | |
| 119 | + | r1 = returnFunc(ctx, owner, stop) | |
| 120 | + | } else { | |
| 121 | + | r1 = ret.Error(1) | |
| 122 | + | } | |
| 123 | + | return r0, r1 | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | // MockContainerService_CleanupOrphanedAttachments_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CleanupOrphanedAttachments' | |
| 127 | + | type MockContainerService_CleanupOrphanedAttachments_Call struct { | |
| 128 | + | *mock.Call | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | // CleanupOrphanedAttachments is a helper method to define mock.On call | |
| 132 | + | // - ctx context.Context | |
| 133 | + | // - owner string | |
| 134 | + | // - stop bool | |
| 135 | + | func (_e *MockContainerService_Expecter) CleanupOrphanedAttachments(ctx interface{}, owner interface{}, stop interface{}) *MockContainerService_CleanupOrphanedAttachments_Call { | |
| 136 | + | return &MockContainerService_CleanupOrphanedAttachments_Call{Call: _e.mock.On("CleanupOrphanedAttachments", ctx, owner, stop)} | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func (_c *MockContainerService_CleanupOrphanedAttachments_Call) Run(run func(ctx context.Context, owner string, stop bool)) *MockContainerService_CleanupOrphanedAttachments_Call { | |
| 140 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 141 | + | var arg0 context.Context | |
| 142 | + | if args[0] != nil { | |
| 143 | + | arg0 = args[0].(context.Context) | |
| 144 | + | } | |
| 145 | + | var arg1 string | |
| 146 | + | if args[1] != nil { | |
| 147 | + | arg1 = args[1].(string) | |
| 148 | + | } | |
| 149 | + | var arg2 bool | |
| 150 | + | if args[2] != nil { | |
| 151 | + | arg2 = args[2].(bool) | |
| 152 | + | } | |
| 153 | + | run( | |
| 154 | + | arg0, | |
| 155 | + | arg1, | |
| 156 | + | arg2, | |
| 157 | + | ) | |
| 158 | + | }) | |
| 159 | + | return _c | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | func (_c *MockContainerService_CleanupOrphanedAttachments_Call) Return(cleanupReport *domain.CleanupReport, err error) *MockContainerService_CleanupOrphanedAttachments_Call { | |
| 163 | + | _c.Call.Return(cleanupReport, err) | |
| 164 | + | return _c | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | func (_c *MockContainerService_CleanupOrphanedAttachments_Call) RunAndReturn(run func(ctx context.Context, owner string, stop bool) (*domain.CleanupReport, error)) *MockContainerService_CleanupOrphanedAttachments_Call { | |
| 168 | + | _c.Call.Return(run) | |
| 169 | + | return _c | |
| 170 | + | } | |
| 171 | + | ||
| 98 | 172 | // Deploy provides a mock function for the type MockContainerService | |
| 99 | 173 | func (_mock *MockContainerService) Deploy(ctx context.Context, route domain.Route) (*domain.Container, error) { | |
| 100 | 174 | ret := _mock.Called(ctx, route) | |
| @@ -458,6 +532,68 @@ func (_c *MockContainerService_ListNetworks_Call) RunAndReturn(run func(ctx cont | |||
| 458 | 532 | return _c | |
| 459 | 533 | } | |
| 460 | 534 | ||
| 535 | + | // ListOrphanedAttachments provides a mock function for the type MockContainerService | |
| 536 | + | func (_mock *MockContainerService) ListOrphanedAttachments(ctx context.Context) ([]domain.CleanupAttachment, error) { | |
| 537 | + | ret := _mock.Called(ctx) | |
| 538 | + | ||
| 539 | + | if len(ret) == 0 { | |
| 540 | + | panic("no return value specified for ListOrphanedAttachments") | |
| 541 | + | } | |
| 542 | + | ||
| 543 | + | var r0 []domain.CleanupAttachment | |
| 544 | + | var r1 error | |
| 545 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) ([]domain.CleanupAttachment, error)); ok { | |
| 546 | + | return returnFunc(ctx) | |
| 547 | + | } | |
| 548 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) []domain.CleanupAttachment); ok { | |
| 549 | + | r0 = returnFunc(ctx) | |
| 550 | + | } else { | |
| 551 | + | if ret.Get(0) != nil { | |
| 552 | + | r0 = ret.Get(0).([]domain.CleanupAttachment) | |
| 553 | + | } | |
| 554 | + | } | |
| 555 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |
| 556 | + | r1 = returnFunc(ctx) | |
| 557 | + | } else { | |
| 558 | + | r1 = ret.Error(1) | |
| 559 | + | } | |
| 560 | + | return r0, r1 | |
| 561 | + | } | |
| 562 | + | ||
| 563 | + | // MockContainerService_ListOrphanedAttachments_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListOrphanedAttachments' | |
| 564 | + | type MockContainerService_ListOrphanedAttachments_Call struct { | |
| 565 | + | *mock.Call | |
| 566 | + | } | |
| 567 | + | ||
| 568 | + | // ListOrphanedAttachments is a helper method to define mock.On call | |
| 569 | + | // - ctx context.Context | |
| 570 | + | func (_e *MockContainerService_Expecter) ListOrphanedAttachments(ctx interface{}) *MockContainerService_ListOrphanedAttachments_Call { | |
| 571 | + | return &MockContainerService_ListOrphanedAttachments_Call{Call: _e.mock.On("ListOrphanedAttachments", ctx)} | |
| 572 | + | } | |
| 573 | + | ||
| 574 | + | func (_c *MockContainerService_ListOrphanedAttachments_Call) Run(run func(ctx context.Context)) *MockContainerService_ListOrphanedAttachments_Call { | |
| 575 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 576 | + | var arg0 context.Context | |
| 577 | + | if args[0] != nil { | |
| 578 | + | arg0 = args[0].(context.Context) | |
| 579 | + | } | |
| 580 | + | run( | |
| 581 | + | arg0, | |
| 582 | + | ) | |
| 583 | + | }) | |
| 584 | + | return _c | |
| 585 | + | } | |
| 586 | + | ||
| 587 | + | func (_c *MockContainerService_ListOrphanedAttachments_Call) Return(cleanupAttachments []domain.CleanupAttachment, err error) *MockContainerService_ListOrphanedAttachments_Call { | |
| 588 | + | _c.Call.Return(cleanupAttachments, err) | |
| 589 | + | return _c | |
| 590 | + | } | |
| 591 | + | ||
| 592 | + | func (_c *MockContainerService_ListOrphanedAttachments_Call) RunAndReturn(run func(ctx context.Context) ([]domain.CleanupAttachment, error)) *MockContainerService_ListOrphanedAttachments_Call { | |
| 593 | + | _c.Call.Return(run) | |
| 594 | + | return _c | |
| 595 | + | } | |
| 596 | + | ||
| 461 | 597 | // ListRoutesWithDetails provides a mock function for the type MockContainerService | |
| 462 | 598 | func (_mock *MockContainerService) ListRoutesWithDetails(ctx context.Context) []domain.RouteInfo { | |
| 463 | 599 | ret := _mock.Called(ctx) | |
| @@ -511,6 +647,74 @@ func (_c *MockContainerService_ListRoutesWithDetails_Call) RunAndReturn(run func | |||
| 511 | 647 | return _c | |
| 512 | 648 | } | |
| 513 | 649 | ||
| 650 | + | // ReconcileRemovedRoute provides a mock function for the type MockContainerService | |
| 651 | + | func (_mock *MockContainerService) ReconcileRemovedRoute(ctx context.Context, domain1 string) (*domain.CleanupReport, error) { | |
| 652 | + | ret := _mock.Called(ctx, domain1) | |
| 653 | + | ||
| 654 | + | if len(ret) == 0 { | |
| 655 | + | panic("no return value specified for ReconcileRemovedRoute") | |
| 656 | + | } | |
| 657 | + | ||
| 658 | + | var r0 *domain.CleanupReport | |
| 659 | + | var r1 error | |
| 660 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (*domain.CleanupReport, error)); ok { | |
| 661 | + | return returnFunc(ctx, domain1) | |
| 662 | + | } | |
| 663 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) *domain.CleanupReport); ok { | |
| 664 | + | r0 = returnFunc(ctx, domain1) | |
| 665 | + | } else { | |
| 666 | + | if ret.Get(0) != nil { | |
| 667 | + | r0 = ret.Get(0).(*domain.CleanupReport) | |
| 668 | + | } | |
| 669 | + | } | |
| 670 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | |
| 671 | + | r1 = returnFunc(ctx, domain1) | |
| 672 | + | } else { | |
| 673 | + | r1 = ret.Error(1) | |
| 674 | + | } | |
| 675 | + | return r0, r1 | |
| 676 | + | } | |
| 677 | + | ||
| 678 | + | // MockContainerService_ReconcileRemovedRoute_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReconcileRemovedRoute' | |
| 679 | + | type MockContainerService_ReconcileRemovedRoute_Call struct { | |
| 680 | + | *mock.Call | |
| 681 | + | } | |
| 682 | + | ||
| 683 | + | // ReconcileRemovedRoute is a helper method to define mock.On call | |
| 684 | + | // - ctx context.Context | |
| 685 | + | // - domain1 string | |
| 686 | + | func (_e *MockContainerService_Expecter) ReconcileRemovedRoute(ctx interface{}, domain1 interface{}) *MockContainerService_ReconcileRemovedRoute_Call { | |
| 687 | + | return &MockContainerService_ReconcileRemovedRoute_Call{Call: _e.mock.On("ReconcileRemovedRoute", ctx, domain1)} | |
| 688 | + | } | |
| 689 | + | ||
| 690 | + | func (_c *MockContainerService_ReconcileRemovedRoute_Call) Run(run func(ctx context.Context, domain1 string)) *MockContainerService_ReconcileRemovedRoute_Call { | |
| 691 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 692 | + | var arg0 context.Context | |
| 693 | + | if args[0] != nil { | |
| 694 | + | arg0 = args[0].(context.Context) | |
| 695 | + | } | |
| 696 | + | var arg1 string | |
| 697 | + | if args[1] != nil { | |
| 698 | + | arg1 = args[1].(string) | |
| 699 | + | } | |
| 700 | + | run( | |
| 701 | + | arg0, | |
| 702 | + | arg1, | |
| 703 | + | ) | |
| 704 | + | }) | |
| 705 | + | return _c | |
| 706 | + | } | |
| 707 | + | ||
| 708 | + | func (_c *MockContainerService_ReconcileRemovedRoute_Call) Return(cleanupReport *domain.CleanupReport, err error) *MockContainerService_ReconcileRemovedRoute_Call { | |
| 709 | + | _c.Call.Return(cleanupReport, err) | |
| 710 | + | return _c | |
| 711 | + | } | |
| 712 | + | ||
| 713 | + | func (_c *MockContainerService_ReconcileRemovedRoute_Call) RunAndReturn(run func(ctx context.Context, domain1 string) (*domain.CleanupReport, error)) *MockContainerService_ReconcileRemovedRoute_Call { | |
| 714 | + | _c.Call.Return(run) | |
| 715 | + | return _c | |
| 716 | + | } | |
| 717 | + | ||
| 514 | 718 | // Remove provides a mock function for the type MockContainerService | |
| 515 | 719 | func (_mock *MockContainerService) Remove(ctx context.Context, containerID string, force bool) error { | |
| 516 | 720 | ret := _mock.Called(ctx, containerID, force) | |
| @@ -95,6 +95,80 @@ func (_c *MockContainerService_AutoStart_Call) RunAndReturn(run func(ctx context | |||||
| 95 | return _c | 95 | return _c | ||
| 96 | } | 96 | } | ||
| 97 | 97 | ||||
| 98 | + | // CleanupOrphanedAttachments provides a mock function for the type MockContainerService | |||
| 99 | + | func (_mock *MockContainerService) CleanupOrphanedAttachments(ctx context.Context, owner string, stop bool) (*domain.CleanupReport, error) { | |||
| 100 | + | ret := _mock.Called(ctx, owner, stop) | |||
| 101 | + | ||||
| 102 | + | if len(ret) == 0 { | |||
| 103 | + | panic("no return value specified for CleanupOrphanedAttachments") | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | var r0 *domain.CleanupReport | |||
| 107 | + | var r1 error | |||
| 108 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, bool) (*domain.CleanupReport, error)); ok { | |||
| 109 | + | return returnFunc(ctx, owner, stop) | |||
| 110 | + | } | |||
| 111 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, bool) *domain.CleanupReport); ok { | |||
| 112 | + | r0 = returnFunc(ctx, owner, stop) | |||
| 113 | + | } else { | |||
| 114 | + | if ret.Get(0) != nil { | |||
| 115 | + | r0 = ret.Get(0).(*domain.CleanupReport) | |||
| 116 | + | } | |||
| 117 | + | } | |||
| 118 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string, bool) error); ok { | |||
| 119 | + | r1 = returnFunc(ctx, owner, stop) | |||
| 120 | + | } else { | |||
| 121 | + | r1 = ret.Error(1) | |||
| 122 | + | } | |||
| 123 | + | return r0, r1 | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | // MockContainerService_CleanupOrphanedAttachments_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CleanupOrphanedAttachments' | |||
| 127 | + | type MockContainerService_CleanupOrphanedAttachments_Call struct { | |||
| 128 | + | *mock.Call | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | // CleanupOrphanedAttachments is a helper method to define mock.On call | |||
| 132 | + | // - ctx context.Context | |||
| 133 | + | // - owner string | |||
| 134 | + | // - stop bool | |||
| 135 | + | func (_e *MockContainerService_Expecter) CleanupOrphanedAttachments(ctx interface{}, owner interface{}, stop interface{}) *MockContainerService_CleanupOrphanedAttachments_Call { | |||
| 136 | + | return &MockContainerService_CleanupOrphanedAttachments_Call{Call: _e.mock.On("CleanupOrphanedAttachments", ctx, owner, stop)} | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | func (_c *MockContainerService_CleanupOrphanedAttachments_Call) Run(run func(ctx context.Context, owner string, stop bool)) *MockContainerService_CleanupOrphanedAttachments_Call { | |||
| 140 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 141 | + | var arg0 context.Context | |||
| 142 | + | if args[0] != nil { | |||
| 143 | + | arg0 = args[0].(context.Context) | |||
| 144 | + | } | |||
| 145 | + | var arg1 string | |||
| 146 | + | if args[1] != nil { | |||
| 147 | + | arg1 = args[1].(string) | |||
| 148 | + | } | |||
| 149 | + | var arg2 bool | |||
| 150 | + | if args[2] != nil { | |||
| 151 | + | arg2 = args[2].(bool) | |||
| 152 | + | } | |||
| 153 | + | run( | |||
| 154 | + | arg0, | |||
| 155 | + | arg1, | |||
| 156 | + | arg2, | |||
| 157 | + | ) | |||
| 158 | + | }) | |||
| 159 | + | return _c | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | func (_c *MockContainerService_CleanupOrphanedAttachments_Call) Return(cleanupReport *domain.CleanupReport, err error) *MockContainerService_CleanupOrphanedAttachments_Call { | |||
| 163 | + | _c.Call.Return(cleanupReport, err) | |||
| 164 | + | return _c | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | func (_c *MockContainerService_CleanupOrphanedAttachments_Call) RunAndReturn(run func(ctx context.Context, owner string, stop bool) (*domain.CleanupReport, error)) *MockContainerService_CleanupOrphanedAttachments_Call { | |||
| 168 | + | _c.Call.Return(run) | |||
| 169 | + | return _c | |||
| 170 | + | } | |||
| 171 | + | ||||
| 98 | // Deploy provides a mock function for the type MockContainerService | 172 | // Deploy provides a mock function for the type MockContainerService | ||
| 99 | func (_mock *MockContainerService) Deploy(ctx context.Context, route domain.Route) (*domain.Container, error) { | 173 | func (_mock *MockContainerService) Deploy(ctx context.Context, route domain.Route) (*domain.Container, error) { | ||
| 100 | ret := _mock.Called(ctx, route) | 174 | ret := _mock.Called(ctx, route) | ||
| @@ -458,6 +532,68 @@ func (_c *MockContainerService_ListNetworks_Call) RunAndReturn(run func(ctx cont | |||||
| 458 | return _c | 532 | return _c | ||
| 459 | } | 533 | } | ||
| 460 | 534 | ||||
| 535 | + | // ListOrphanedAttachments provides a mock function for the type MockContainerService | |||
| 536 | + | func (_mock *MockContainerService) ListOrphanedAttachments(ctx context.Context) ([]domain.CleanupAttachment, error) { | |||
| 537 | + | ret := _mock.Called(ctx) | |||
| 538 | + | ||||
| 539 | + | if len(ret) == 0 { | |||
| 540 | + | panic("no return value specified for ListOrphanedAttachments") | |||
| 541 | + | } | |||
| 542 | + | ||||
| 543 | + | var r0 []domain.CleanupAttachment | |||
| 544 | + | var r1 error | |||
| 545 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) ([]domain.CleanupAttachment, error)); ok { | |||
| 546 | + | return returnFunc(ctx) | |||
| 547 | + | } | |||
| 548 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) []domain.CleanupAttachment); ok { | |||
| 549 | + | r0 = returnFunc(ctx) | |||
| 550 | + | } else { | |||
| 551 | + | if ret.Get(0) != nil { | |||
| 552 | + | r0 = ret.Get(0).([]domain.CleanupAttachment) | |||
| 553 | + | } | |||
| 554 | + | } | |||
| 555 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |||
| 556 | + | r1 = returnFunc(ctx) | |||
| 557 | + | } else { | |||
| 558 | + | r1 = ret.Error(1) | |||
| 559 | + | } | |||
| 560 | + | return r0, r1 | |||
| 561 | + | } | |||
| 562 | + | ||||
| 563 | + | // MockContainerService_ListOrphanedAttachments_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListOrphanedAttachments' | |||
| 564 | + | type MockContainerService_ListOrphanedAttachments_Call struct { | |||
| 565 | + | *mock.Call | |||
| 566 | + | } | |||
| 567 | + | ||||
| 568 | + | // ListOrphanedAttachments is a helper method to define mock.On call | |||
| 569 | + | // - ctx context.Context | |||
| 570 | + | func (_e *MockContainerService_Expecter) ListOrphanedAttachments(ctx interface{}) *MockContainerService_ListOrphanedAttachments_Call { | |||
| 571 | + | return &MockContainerService_ListOrphanedAttachments_Call{Call: _e.mock.On("ListOrphanedAttachments", ctx)} | |||
| 572 | + | } | |||
| 573 | + | ||||
| 574 | + | func (_c *MockContainerService_ListOrphanedAttachments_Call) Run(run func(ctx context.Context)) *MockContainerService_ListOrphanedAttachments_Call { | |||
| 575 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 576 | + | var arg0 context.Context | |||
| 577 | + | if args[0] != nil { | |||
| 578 | + | arg0 = args[0].(context.Context) | |||
| 579 | + | } | |||
| 580 | + | run( | |||
| 581 | + | arg0, | |||
| 582 | + | ) | |||
| 583 | + | }) | |||
| 584 | + | return _c | |||
| 585 | + | } | |||
| 586 | + | ||||
| 587 | + | func (_c *MockContainerService_ListOrphanedAttachments_Call) Return(cleanupAttachments []domain.CleanupAttachment, err error) *MockContainerService_ListOrphanedAttachments_Call { | |||
| 588 | + | _c.Call.Return(cleanupAttachments, err) | |||
| 589 | + | return _c | |||
| 590 | + | } | |||
| 591 | + | ||||
| 592 | + | func (_c *MockContainerService_ListOrphanedAttachments_Call) RunAndReturn(run func(ctx context.Context) ([]domain.CleanupAttachment, error)) *MockContainerService_ListOrphanedAttachments_Call { | |||
| 593 | + | _c.Call.Return(run) | |||
| 594 | + | return _c | |||
| 595 | + | } | |||
| 596 | + | ||||
| 461 | // ListRoutesWithDetails provides a mock function for the type MockContainerService | 597 | // ListRoutesWithDetails provides a mock function for the type MockContainerService | ||
| 462 | func (_mock *MockContainerService) ListRoutesWithDetails(ctx context.Context) []domain.RouteInfo { | 598 | func (_mock *MockContainerService) ListRoutesWithDetails(ctx context.Context) []domain.RouteInfo { | ||
| 463 | ret := _mock.Called(ctx) | 599 | ret := _mock.Called(ctx) | ||
| @@ -511,6 +647,74 @@ func (_c *MockContainerService_ListRoutesWithDetails_Call) RunAndReturn(run func | |||||
| 511 | return _c | 647 | return _c | ||
| 512 | } | 648 | } | ||
| 513 | 649 | ||||
| 650 | + | // ReconcileRemovedRoute provides a mock function for the type MockContainerService | |||
| 651 | + | func (_mock *MockContainerService) ReconcileRemovedRoute(ctx context.Context, domain1 string) (*domain.CleanupReport, error) { | |||
| 652 | + | ret := _mock.Called(ctx, domain1) | |||
| 653 | + | ||||
| 654 | + | if len(ret) == 0 { | |||
| 655 | + | panic("no return value specified for ReconcileRemovedRoute") | |||
| 656 | + | } | |||
| 657 | + | ||||
| 658 | + | var r0 *domain.CleanupReport | |||
| 659 | + | var r1 error | |||
| 660 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (*domain.CleanupReport, error)); ok { | |||
| 661 | + | return returnFunc(ctx, domain1) | |||
| 662 | + | } | |||
| 663 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) *domain.CleanupReport); ok { | |||
| 664 | + | r0 = returnFunc(ctx, domain1) | |||
| 665 | + | } else { | |||
| 666 | + | if ret.Get(0) != nil { | |||
| 667 | + | r0 = ret.Get(0).(*domain.CleanupReport) | |||
| 668 | + | } | |||
| 669 | + | } | |||
| 670 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | |||
| 671 | + | r1 = returnFunc(ctx, domain1) | |||
| 672 | + | } else { | |||
| 673 | + | r1 = ret.Error(1) | |||
| 674 | + | } | |||
| 675 | + | return r0, r1 | |||
| 676 | + | } | |||
| 677 | + | ||||
| 678 | + | // MockContainerService_ReconcileRemovedRoute_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReconcileRemovedRoute' | |||
| 679 | + | type MockContainerService_ReconcileRemovedRoute_Call struct { | |||
| 680 | + | *mock.Call | |||
| 681 | + | } | |||
| 682 | + | ||||
| 683 | + | // ReconcileRemovedRoute is a helper method to define mock.On call | |||
| 684 | + | // - ctx context.Context | |||
| 685 | + | // - domain1 string | |||
| 686 | + | func (_e *MockContainerService_Expecter) ReconcileRemovedRoute(ctx interface{}, domain1 interface{}) *MockContainerService_ReconcileRemovedRoute_Call { | |||
| 687 | + | return &MockContainerService_ReconcileRemovedRoute_Call{Call: _e.mock.On("ReconcileRemovedRoute", ctx, domain1)} | |||
| 688 | + | } | |||
| 689 | + | ||||
| 690 | + | func (_c *MockContainerService_ReconcileRemovedRoute_Call) Run(run func(ctx context.Context, domain1 string)) *MockContainerService_ReconcileRemovedRoute_Call { | |||
| 691 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 692 | + | var arg0 context.Context | |||
| 693 | + | if args[0] != nil { | |||
| 694 | + | arg0 = args[0].(context.Context) | |||
| 695 | + | } | |||
| 696 | + | var arg1 string | |||
| 697 | + | if args[1] != nil { | |||
| 698 | + | arg1 = args[1].(string) | |||
| 699 | + | } | |||
| 700 | + | run( | |||
| 701 | + | arg0, | |||
| 702 | + | arg1, | |||
| 703 | + | ) | |||
| 704 | + | }) | |||
| 705 | + | return _c | |||
| 706 | + | } | |||
| 707 | + | ||||
| 708 | + | func (_c *MockContainerService_ReconcileRemovedRoute_Call) Return(cleanupReport *domain.CleanupReport, err error) *MockContainerService_ReconcileRemovedRoute_Call { | |||
| 709 | + | _c.Call.Return(cleanupReport, err) | |||
| 710 | + | return _c | |||
| 711 | + | } | |||
| 712 | + | ||||
| 713 | + | func (_c *MockContainerService_ReconcileRemovedRoute_Call) RunAndReturn(run func(ctx context.Context, domain1 string) (*domain.CleanupReport, error)) *MockContainerService_ReconcileRemovedRoute_Call { | |||
| 714 | + | _c.Call.Return(run) | |||
| 715 | + | return _c | |||
| 716 | + | } | |||
| 717 | + | ||||
| 514 | // Remove provides a mock function for the type MockContainerService | 718 | // Remove provides a mock function for the type MockContainerService | ||
| 515 | func (_mock *MockContainerService) Remove(ctx context.Context, containerID string, force bool) error { | 719 | func (_mock *MockContainerService) Remove(ctx context.Context, containerID string, force bool) error { | ||
| 516 | ret := _mock.Called(ctx, containerID, force) | 720 | ret := _mock.Called(ctx, containerID, force) | ||
A internal/domain/cleanup.go +59 -0
| @@ -0,0 +1,59 @@ | |||
| 1 | + | package domain | |
| 2 | + | ||
| 3 | + | // CleanupReport describes the result of reconciling runtime state after a | |
| 4 | + | // configuration entity was removed. Fields are additive-only for CLI/API JSON | |
| 5 | + | // consumers; callers should tolerate unknown future fields. | |
| 6 | + | type CleanupReport struct { | |
| 7 | + | Domain string `json:"domain"` | |
| 8 | + | RemovedContainers []CleanupContainer `json:"removed_containers"` | |
| 9 | + | PreservedVolumes []CleanupVolume `json:"preserved_volumes"` | |
| 10 | + | PreservedAttachments []CleanupAttachment `json:"preserved_attachments"` | |
| 11 | + | OrphanedEntities []CleanupOrphanedEntity `json:"orphaned_entities"` | |
| 12 | + | Warnings []string `json:"warnings"` | |
| 13 | + | Hints []string `json:"hints"` | |
| 14 | + | PartialFailures []CleanupFailure `json:"partial_failures"` | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | // CleanupContainer identifies a container affected by cleanup. | |
| 18 | + | type CleanupContainer struct { | |
| 19 | + | ID string `json:"id"` | |
| 20 | + | Name string `json:"name"` | |
| 21 | + | Image string `json:"image"` | |
| 22 | + | Status string `json:"status"` | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // CleanupVolume identifies stateful data intentionally preserved by cleanup. | |
| 26 | + | type CleanupVolume struct { | |
| 27 | + | Name string `json:"name"` | |
| 28 | + | ContainerPath string `json:"container_path,omitempty"` | |
| 29 | + | Reason string `json:"reason,omitempty"` | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // CleanupAttachment identifies an attachment intentionally preserved or kept for | |
| 33 | + | // explicit follow-up cleanup. | |
| 34 | + | type CleanupAttachment struct { | |
| 35 | + | Name string `json:"name"` | |
| 36 | + | Image string `json:"image"` | |
| 37 | + | ContainerID string `json:"container_id"` | |
| 38 | + | Status string `json:"status"` | |
| 39 | + | Owner string `json:"owner,omitempty"` | |
| 40 | + | Reason string `json:"reason,omitempty"` | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // CleanupOrphanedEntity identifies runtime state not currently configured. | |
| 44 | + | type CleanupOrphanedEntity struct { | |
| 45 | + | Kind string `json:"kind"` | |
| 46 | + | ID string `json:"id,omitempty"` | |
| 47 | + | Name string `json:"name,omitempty"` | |
| 48 | + | Status string `json:"status,omitempty"` | |
| 49 | + | Reason string `json:"reason,omitempty"` | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // CleanupFailure records non-fatal cleanup failures that require follow-up. | |
| 53 | + | type CleanupFailure struct { | |
| 54 | + | Action string `json:"action"` | |
| 55 | + | Kind string `json:"kind"` | |
| 56 | + | ID string `json:"id,omitempty"` | |
| 57 | + | Name string `json:"name,omitempty"` | |
| 58 | + | Error string `json:"error"` | |
| 59 | + | } | |
| @@ -0,0 +1,59 @@ | |||||
| 1 | + | package domain | |||
| 2 | + | ||||
| 3 | + | // CleanupReport describes the result of reconciling runtime state after a | |||
| 4 | + | // configuration entity was removed. Fields are additive-only for CLI/API JSON | |||
| 5 | + | // consumers; callers should tolerate unknown future fields. | |||
| 6 | + | type CleanupReport struct { | |||
| 7 | + | Domain string `json:"domain"` | |||
| 8 | + | RemovedContainers []CleanupContainer `json:"removed_containers"` | |||
| 9 | + | PreservedVolumes []CleanupVolume `json:"preserved_volumes"` | |||
| 10 | + | PreservedAttachments []CleanupAttachment `json:"preserved_attachments"` | |||
| 11 | + | OrphanedEntities []CleanupOrphanedEntity `json:"orphaned_entities"` | |||
| 12 | + | Warnings []string `json:"warnings"` | |||
| 13 | + | Hints []string `json:"hints"` | |||
| 14 | + | PartialFailures []CleanupFailure `json:"partial_failures"` | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | // CleanupContainer identifies a container affected by cleanup. | |||
| 18 | + | type CleanupContainer struct { | |||
| 19 | + | ID string `json:"id"` | |||
| 20 | + | Name string `json:"name"` | |||
| 21 | + | Image string `json:"image"` | |||
| 22 | + | Status string `json:"status"` | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // CleanupVolume identifies stateful data intentionally preserved by cleanup. | |||
| 26 | + | type CleanupVolume struct { | |||
| 27 | + | Name string `json:"name"` | |||
| 28 | + | ContainerPath string `json:"container_path,omitempty"` | |||
| 29 | + | Reason string `json:"reason,omitempty"` | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | // CleanupAttachment identifies an attachment intentionally preserved or kept for | |||
| 33 | + | // explicit follow-up cleanup. | |||
| 34 | + | type CleanupAttachment struct { | |||
| 35 | + | Name string `json:"name"` | |||
| 36 | + | Image string `json:"image"` | |||
| 37 | + | ContainerID string `json:"container_id"` | |||
| 38 | + | Status string `json:"status"` | |||
| 39 | + | Owner string `json:"owner,omitempty"` | |||
| 40 | + | Reason string `json:"reason,omitempty"` | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | // CleanupOrphanedEntity identifies runtime state not currently configured. | |||
| 44 | + | type CleanupOrphanedEntity struct { | |||
| 45 | + | Kind string `json:"kind"` | |||
| 46 | + | ID string `json:"id,omitempty"` | |||
| 47 | + | Name string `json:"name,omitempty"` | |||
| 48 | + | Status string `json:"status,omitempty"` | |||
| 49 | + | Reason string `json:"reason,omitempty"` | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | // CleanupFailure records non-fatal cleanup failures that require follow-up. | |||
| 53 | + | type CleanupFailure struct { | |||
| 54 | + | Action string `json:"action"` | |||
| 55 | + | Kind string `json:"kind"` | |||
| 56 | + | ID string `json:"id,omitempty"` | |||
| 57 | + | Name string `json:"name,omitempty"` | |||
| 58 | + | Error string `json:"error"` | |||
| 59 | + | } | |||
M internal/usecase/container/events.go +20 -9
| @@ -117,8 +117,8 @@ func (h *ConfigReloadHandler) Handle(ctx context.Context, event domain.Event) er | |||
| 117 | 117 | currentContainers := h.containerSvc.List(ctx) | |
| 118 | 118 | ||
| 119 | 119 | activeRoutes := make(map[string]*domain.Container) | |
| 120 | - | for _, container := range currentContainers { | |
| 121 | - | if route, exists := container.Labels[domain.LabelRoute]; exists { | |
| 120 | + | for domainName, container := range currentContainers { | |
| 121 | + | if route := routeDomainForContainer(domainName, container); route != "" { | |
| 122 | 122 | activeRoutes[route] = container | |
| 123 | 123 | } | |
| 124 | 124 | } | |
| @@ -156,14 +156,10 @@ func (h *ConfigReloadHandler) Handle(ctx context.Context, event domain.Event) er | |||
| 156 | 156 | log.Info(). | |
| 157 | 157 | Str("domain", route). | |
| 158 | 158 | Str(zerowrap.FieldEntityID, container.ID). | |
| 159 | - | Msg("route no longer configured, stopping container") | |
| 159 | + | Msg("route no longer configured, reconciling removed route runtime state") | |
| 160 | 160 | ||
| 161 | - | if err := h.containerSvc.Stop(ctx, container.ID); err != nil { | |
| 162 | - | log.WrapErrWithFields(err, "failed to stop container", map[string]any{zerowrap.FieldEntityID: container.ID}) | |
| 163 | - | } | |
| 164 | - | ||
| 165 | - | if err := h.containerSvc.Remove(ctx, container.ID, true); err != nil { | |
| 166 | - | log.WrapErrWithFields(err, "failed to remove container", map[string]any{zerowrap.FieldEntityID: container.ID}) | |
| 161 | + | if _, err := h.containerSvc.ReconcileRemovedRoute(ctx, route); err != nil { | |
| 162 | + | log.WrapErrWithFields(err, "failed to reconcile removed route runtime state", map[string]any{"domain": route, zerowrap.FieldEntityID: container.ID}) | |
| 167 | 163 | } | |
| 168 | 164 | } | |
| 169 | 165 | ||
| @@ -175,6 +171,21 @@ func (h *ConfigReloadHandler) CanHandle(eventType domain.EventType) bool { | |||
| 175 | 171 | return eventType == domain.EventConfigReload | |
| 176 | 172 | } | |
| 177 | 173 | ||
| 174 | + | func routeDomainForContainer(trackedDomain string, container *domain.Container) string { | |
| 175 | + | if container == nil { | |
| 176 | + | return "" | |
| 177 | + | } | |
| 178 | + | if container.Labels != nil { | |
| 179 | + | if route := container.Labels[domain.LabelRoute]; route != "" { | |
| 180 | + | return route | |
| 181 | + | } | |
| 182 | + | if route := container.Labels[domain.LabelDomain]; route != "" { | |
| 183 | + | return route | |
| 184 | + | } | |
| 185 | + | } | |
| 186 | + | return trackedDomain | |
| 187 | + | } | |
| 188 | + | ||
| 178 | 189 | // ManualDeployHandler handles manual.deploy events for specific routes. | |
| 179 | 190 | type ManualDeployHandler struct { | |
| 180 | 191 | containerSvc in.ContainerService | |
| @@ -117,8 +117,8 @@ func (h *ConfigReloadHandler) Handle(ctx context.Context, event domain.Event) er | |||||
| 117 | currentContainers := h.containerSvc.List(ctx) | 117 | currentContainers := h.containerSvc.List(ctx) | ||
| 118 | 118 | ||||
| 119 | activeRoutes := make(map[string]*domain.Container) | 119 | activeRoutes := make(map[string]*domain.Container) | ||
| 120 | - | for _, container := range currentContainers { | 120 | + | for domainName, container := range currentContainers { |
| 121 | - | if route, exists := container.Labels[domain.LabelRoute]; exists { | 121 | + | if route := routeDomainForContainer(domainName, container); route != "" { |
| 122 | activeRoutes[route] = container | 122 | activeRoutes[route] = container | ||
| 123 | } | 123 | } | ||
| 124 | } | 124 | } | ||
| @@ -156,14 +156,10 @@ func (h *ConfigReloadHandler) Handle(ctx context.Context, event domain.Event) er | |||||
| 156 | log.Info(). | 156 | log.Info(). | ||
| 157 | Str("domain", route). | 157 | Str("domain", route). | ||
| 158 | Str(zerowrap.FieldEntityID, container.ID). | 158 | Str(zerowrap.FieldEntityID, container.ID). | ||
| 159 | - | Msg("route no longer configured, stopping container") | 159 | + | Msg("route no longer configured, reconciling removed route runtime state") |
| 160 | 160 | ||||
| 161 | - | if err := h.containerSvc.Stop(ctx, container.ID); err != nil { | 161 | + | if _, err := h.containerSvc.ReconcileRemovedRoute(ctx, route); err != nil { |
| 162 | - | log.WrapErrWithFields(err, "failed to stop container", map[string]any{zerowrap.FieldEntityID: container.ID}) | 162 | + | log.WrapErrWithFields(err, "failed to reconcile removed route runtime state", map[string]any{"domain": route, zerowrap.FieldEntityID: container.ID}) |
| 163 | - | } | |||
| 164 | - | ||||
| 165 | - | if err := h.containerSvc.Remove(ctx, container.ID, true); err != nil { | |||
| 166 | - | log.WrapErrWithFields(err, "failed to remove container", map[string]any{zerowrap.FieldEntityID: container.ID}) | |||
| 167 | } | 163 | } | ||
| 168 | } | 164 | } | ||
| 169 | 165 | ||||
| @@ -175,6 +171,21 @@ func (h *ConfigReloadHandler) CanHandle(eventType domain.EventType) bool { | |||||
| 175 | return eventType == domain.EventConfigReload | 171 | return eventType == domain.EventConfigReload | ||
| 176 | } | 172 | } | ||
| 177 | 173 | ||||
| 174 | + | func routeDomainForContainer(trackedDomain string, container *domain.Container) string { | |||
| 175 | + | if container == nil { | |||
| 176 | + | return "" | |||
| 177 | + | } | |||
| 178 | + | if container.Labels != nil { | |||
| 179 | + | if route := container.Labels[domain.LabelRoute]; route != "" { | |||
| 180 | + | return route | |||
| 181 | + | } | |||
| 182 | + | if route := container.Labels[domain.LabelDomain]; route != "" { | |||
| 183 | + | return route | |||
| 184 | + | } | |||
| 185 | + | } | |||
| 186 | + | return trackedDomain | |||
| 187 | + | } | |||
| 188 | + | ||||
| 178 | // ManualDeployHandler handles manual.deploy events for specific routes. | 189 | // ManualDeployHandler handles manual.deploy events for specific routes. | ||
| 179 | type ManualDeployHandler struct { | 190 | type ManualDeployHandler struct { | ||
| 180 | containerSvc in.ContainerService | 191 | containerSvc in.ContainerService | ||
M internal/usecase/container/events_test.go +29 -2
| @@ -261,8 +261,7 @@ func TestConfigReloadHandler_Handle_StopsRemovedRoutes(t *testing.T) { | |||
| 261 | 261 | // Empty routes - the route was removed | |
| 262 | 262 | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}) | |
| 263 | 263 | ||
| 264 | - | containerSvc.EXPECT().Stop(mock.Anything, "container-old").Return(nil) | |
| 265 | - | containerSvc.EXPECT().Remove(mock.Anything, "container-old", true).Return(nil) | |
| 264 | + | containerSvc.EXPECT().ReconcileRemovedRoute(mock.Anything, "removed.example.com").Return(&domain.CleanupReport{Domain: "removed.example.com"}, nil) | |
| 266 | 265 | ||
| 267 | 266 | event := domain.Event{ | |
| 268 | 267 | ID: "event-123", | |
| @@ -274,6 +273,34 @@ func TestConfigReloadHandler_Handle_StopsRemovedRoutes(t *testing.T) { | |||
| 274 | 273 | assert.NoError(t, err) | |
| 275 | 274 | } | |
| 276 | 275 | ||
| 276 | + | func TestConfigReloadHandler_Handle_ReconcilesRemovedRouteWithLegacyDomainLabel(t *testing.T) { | |
| 277 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 278 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 279 | + | ||
| 280 | + | handler := NewConfigReloadHandler(testCtx(), containerSvc, configSvc) | |
| 281 | + | ||
| 282 | + | containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil) | |
| 283 | + | configSvc.EXPECT().GetAllAttachments(mock.Anything).Return(map[string][]string{}) | |
| 284 | + | containerSvc.EXPECT().UpdateAttachments(map[string][]string{}).Return() | |
| 285 | + | containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{ | |
| 286 | + | "legacy.example.com": { | |
| 287 | + | ID: "container-legacy", | |
| 288 | + | Labels: map[string]string{ | |
| 289 | + | domain.LabelDomain: "legacy.example.com", | |
| 290 | + | domain.LabelImage: "legacy:latest", | |
| 291 | + | }, | |
| 292 | + | }, | |
| 293 | + | }) | |
| 294 | + | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}) | |
| 295 | + | containerSvc.EXPECT().ReconcileRemovedRoute(mock.Anything, "legacy.example.com").Return(&domain.CleanupReport{Domain: "legacy.example.com"}, nil) | |
| 296 | + | ||
| 297 | + | event := domain.Event{ID: "event-legacy", Type: domain.EventConfigReload} | |
| 298 | + | ||
| 299 | + | err := handler.Handle(context.Background(), event) | |
| 300 | + | ||
| 301 | + | assert.NoError(t, err) | |
| 302 | + | } | |
| 303 | + | ||
| 277 | 304 | func TestConfigReloadHandler_Handle_RedeploysChangedImage(t *testing.T) { | |
| 278 | 305 | containerSvc := inmocks.NewMockContainerService(t) | |
| 279 | 306 | configSvc := inmocks.NewMockConfigService(t) | |
| @@ -261,8 +261,7 @@ func TestConfigReloadHandler_Handle_StopsRemovedRoutes(t *testing.T) { | |||||
| 261 | // Empty routes - the route was removed | 261 | // Empty routes - the route was removed | ||
| 262 | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}) | 262 | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}) | ||
| 263 | 263 | ||||
| 264 | - | containerSvc.EXPECT().Stop(mock.Anything, "container-old").Return(nil) | 264 | + | containerSvc.EXPECT().ReconcileRemovedRoute(mock.Anything, "removed.example.com").Return(&domain.CleanupReport{Domain: "removed.example.com"}, nil) |
| 265 | - | containerSvc.EXPECT().Remove(mock.Anything, "container-old", true).Return(nil) | |||
| 266 | 265 | ||||
| 267 | event := domain.Event{ | 266 | event := domain.Event{ | ||
| 268 | ID: "event-123", | 267 | ID: "event-123", | ||
| @@ -274,6 +273,34 @@ func TestConfigReloadHandler_Handle_StopsRemovedRoutes(t *testing.T) { | |||||
| 274 | assert.NoError(t, err) | 273 | assert.NoError(t, err) | ||
| 275 | } | 274 | } | ||
| 276 | 275 | ||||
| 276 | + | func TestConfigReloadHandler_Handle_ReconcilesRemovedRouteWithLegacyDomainLabel(t *testing.T) { | |||
| 277 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 278 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 279 | + | ||||
| 280 | + | handler := NewConfigReloadHandler(testCtx(), containerSvc, configSvc) | |||
| 281 | + | ||||
| 282 | + | containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil) | |||
| 283 | + | configSvc.EXPECT().GetAllAttachments(mock.Anything).Return(map[string][]string{}) | |||
| 284 | + | containerSvc.EXPECT().UpdateAttachments(map[string][]string{}).Return() | |||
| 285 | + | containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{ | |||
| 286 | + | "legacy.example.com": { | |||
| 287 | + | ID: "container-legacy", | |||
| 288 | + | Labels: map[string]string{ | |||
| 289 | + | domain.LabelDomain: "legacy.example.com", | |||
| 290 | + | domain.LabelImage: "legacy:latest", | |||
| 291 | + | }, | |||
| 292 | + | }, | |||
| 293 | + | }) | |||
| 294 | + | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}) | |||
| 295 | + | containerSvc.EXPECT().ReconcileRemovedRoute(mock.Anything, "legacy.example.com").Return(&domain.CleanupReport{Domain: "legacy.example.com"}, nil) | |||
| 296 | + | ||||
| 297 | + | event := domain.Event{ID: "event-legacy", Type: domain.EventConfigReload} | |||
| 298 | + | ||||
| 299 | + | err := handler.Handle(context.Background(), event) | |||
| 300 | + | ||||
| 301 | + | assert.NoError(t, err) | |||
| 302 | + | } | |||
| 303 | + | ||||
| 277 | func TestConfigReloadHandler_Handle_RedeploysChangedImage(t *testing.T) { | 304 | func TestConfigReloadHandler_Handle_RedeploysChangedImage(t *testing.T) { | ||
| 278 | containerSvc := inmocks.NewMockContainerService(t) | 305 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 279 | configSvc := inmocks.NewMockConfigService(t) | 306 | configSvc := inmocks.NewMockConfigService(t) | ||
M internal/usecase/container/monitor.go +12 -4
| @@ -134,6 +134,10 @@ func (m *Monitor) handleRunning(ctx context.Context, log zerowrap.Logger, domain | |||
| 134 | 134 | } | |
| 135 | 135 | ||
| 136 | 136 | if hasHealthcheck && healthStatus == "unhealthy" { | |
| 137 | + | if !m.isContainerStillTracked(domainName, containerID) { | |
| 138 | + | log.Debug().Str("domain", domainName).Msg("monitor: container no longer tracked, skipping unhealthy restart") | |
| 139 | + | return | |
| 140 | + | } | |
| 137 | 141 | log.Warn().Str("domain", domainName).Msg("monitor: container is unhealthy, restarting") | |
| 138 | 142 | if err := m.service.runtime.RestartContainer(ctx, containerID); err != nil { | |
| 139 | 143 | log.Warn().Err(err).Str("domain", domainName).Msg("monitor: failed to restart unhealthy container") | |
| @@ -159,6 +163,13 @@ func (m *Monitor) handleRunning(ctx context.Context, log zerowrap.Logger, domain | |||
| 159 | 163 | m.mu.Unlock() | |
| 160 | 164 | } | |
| 161 | 165 | ||
| 166 | + | func (m *Monitor) isContainerStillTracked(domainName, containerID string) bool { | |
| 167 | + | m.service.mu.RLock() | |
| 168 | + | current := m.service.containers[domainName] | |
| 169 | + | m.service.mu.RUnlock() | |
| 170 | + | return current != nil && current.ID == containerID | |
| 171 | + | } | |
| 172 | + | ||
| 162 | 173 | func (m *Monitor) handleCrash(ctx context.Context, log zerowrap.Logger, domainName, containerID string, exitCode int, now time.Time) { | |
| 163 | 174 | m.mu.Lock() | |
| 164 | 175 | rec := m.history[domainName] | |
| @@ -212,10 +223,7 @@ func (m *Monitor) handleCrash(ctx context.Context, log zerowrap.Logger, domainNa | |||
| 212 | 223 | m.mu.Unlock() | |
| 213 | 224 | ||
| 214 | 225 | // Verify container ID still matches tracked state (deploy may have replaced it). | |
| 215 | - | m.service.mu.RLock() | |
| 216 | - | current := m.service.containers[domainName] | |
| 217 | - | m.service.mu.RUnlock() | |
| 218 | - | if current == nil || current.ID != containerID { | |
| 226 | + | if !m.isContainerStillTracked(domainName, containerID) { | |
| 219 | 227 | log.Debug().Str("domain", domainName).Msg("monitor: container replaced by deploy, skipping restart") | |
| 220 | 228 | return | |
| 221 | 229 | } | |
| @@ -134,6 +134,10 @@ func (m *Monitor) handleRunning(ctx context.Context, log zerowrap.Logger, domain | |||||
| 134 | } | 134 | } | ||
| 135 | 135 | ||||
| 136 | if hasHealthcheck && healthStatus == "unhealthy" { | 136 | if hasHealthcheck && healthStatus == "unhealthy" { | ||
| 137 | + | if !m.isContainerStillTracked(domainName, containerID) { | |||
| 138 | + | log.Debug().Str("domain", domainName).Msg("monitor: container no longer tracked, skipping unhealthy restart") | |||
| 139 | + | return | |||
| 140 | + | } | |||
| 137 | log.Warn().Str("domain", domainName).Msg("monitor: container is unhealthy, restarting") | 141 | log.Warn().Str("domain", domainName).Msg("monitor: container is unhealthy, restarting") | ||
| 138 | if err := m.service.runtime.RestartContainer(ctx, containerID); err != nil { | 142 | if err := m.service.runtime.RestartContainer(ctx, containerID); err != nil { | ||
| 139 | log.Warn().Err(err).Str("domain", domainName).Msg("monitor: failed to restart unhealthy container") | 143 | log.Warn().Err(err).Str("domain", domainName).Msg("monitor: failed to restart unhealthy container") | ||
| @@ -159,6 +163,13 @@ func (m *Monitor) handleRunning(ctx context.Context, log zerowrap.Logger, domain | |||||
| 159 | m.mu.Unlock() | 163 | m.mu.Unlock() | ||
| 160 | } | 164 | } | ||
| 161 | 165 | ||||
| 166 | + | func (m *Monitor) isContainerStillTracked(domainName, containerID string) bool { | |||
| 167 | + | m.service.mu.RLock() | |||
| 168 | + | current := m.service.containers[domainName] | |||
| 169 | + | m.service.mu.RUnlock() | |||
| 170 | + | return current != nil && current.ID == containerID | |||
| 171 | + | } | |||
| 172 | + | ||||
| 162 | func (m *Monitor) handleCrash(ctx context.Context, log zerowrap.Logger, domainName, containerID string, exitCode int, now time.Time) { | 173 | func (m *Monitor) handleCrash(ctx context.Context, log zerowrap.Logger, domainName, containerID string, exitCode int, now time.Time) { | ||
| 163 | m.mu.Lock() | 174 | m.mu.Lock() | ||
| 164 | rec := m.history[domainName] | 175 | rec := m.history[domainName] | ||
| @@ -212,10 +223,7 @@ func (m *Monitor) handleCrash(ctx context.Context, log zerowrap.Logger, domainNa | |||||
| 212 | m.mu.Unlock() | 223 | m.mu.Unlock() | ||
| 213 | 224 | ||||
| 214 | // Verify container ID still matches tracked state (deploy may have replaced it). | 225 | // Verify container ID still matches tracked state (deploy may have replaced it). | ||
| 215 | - | m.service.mu.RLock() | 226 | + | if !m.isContainerStillTracked(domainName, containerID) { |
| 216 | - | current := m.service.containers[domainName] | |||
| 217 | - | m.service.mu.RUnlock() | |||
| 218 | - | if current == nil || current.ID != containerID { | |||
| 219 | log.Debug().Str("domain", domainName).Msg("monitor: container replaced by deploy, skipping restart") | 227 | log.Debug().Str("domain", domainName).Msg("monitor: container replaced by deploy, skipping restart") | ||
| 220 | return | 228 | return | ||
| 221 | } | 229 | } | ||
M internal/usecase/container/monitor_test.go +23 -0
| @@ -127,6 +127,29 @@ func TestMonitor_RestartsUnhealthyContainer(t *testing.T) { | |||
| 127 | 127 | runtime.AssertExpectations(t) | |
| 128 | 128 | } | |
| 129 | 129 | ||
| 130 | + | func TestMonitor_SkipsUnhealthyRestartWhenContainerWasUntracked(t *testing.T) { | |
| 131 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 132 | + | svc := newTestService(runtime) | |
| 133 | + | ||
| 134 | + | tracked := &domain.Container{ID: "ctr-1", Image: "myapp:latest"} | |
| 135 | + | svc.containers["app.example.com"] = tracked | |
| 136 | + | ||
| 137 | + | runtime.EXPECT().InspectContainer(mock.Anything, "ctr-1").Run(func(context.Context, string) { | |
| 138 | + | svc.mu.Lock() | |
| 139 | + | delete(svc.containers, "app.example.com") | |
| 140 | + | svc.mu.Unlock() | |
| 141 | + | }).Return(&domain.Container{ | |
| 142 | + | ID: "ctr-1", | |
| 143 | + | Status: string(domain.ContainerStatusRunning), | |
| 144 | + | }, nil) | |
| 145 | + | runtime.EXPECT().GetContainerHealthStatus(mock.Anything, "ctr-1").Return("unhealthy", true, nil) | |
| 146 | + | ||
| 147 | + | m := newMonitor(svc) | |
| 148 | + | m.check(monitorTestContext()) | |
| 149 | + | ||
| 150 | + | runtime.AssertNotCalled(t, "RestartContainer", mock.Anything, "ctr-1") | |
| 151 | + | } | |
| 152 | + | ||
| 130 | 153 | func TestMonitor_CrashLoopBackoff(t *testing.T) { | |
| 131 | 154 | runtime := mocks.NewMockContainerRuntime(t) | |
| 132 | 155 | svc := newTestService(runtime) | |
| @@ -127,6 +127,29 @@ func TestMonitor_RestartsUnhealthyContainer(t *testing.T) { | |||||
| 127 | runtime.AssertExpectations(t) | 127 | runtime.AssertExpectations(t) | ||
| 128 | } | 128 | } | ||
| 129 | 129 | ||||
| 130 | + | func TestMonitor_SkipsUnhealthyRestartWhenContainerWasUntracked(t *testing.T) { | |||
| 131 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 132 | + | svc := newTestService(runtime) | |||
| 133 | + | ||||
| 134 | + | tracked := &domain.Container{ID: "ctr-1", Image: "myapp:latest"} | |||
| 135 | + | svc.containers["app.example.com"] = tracked | |||
| 136 | + | ||||
| 137 | + | runtime.EXPECT().InspectContainer(mock.Anything, "ctr-1").Run(func(context.Context, string) { | |||
| 138 | + | svc.mu.Lock() | |||
| 139 | + | delete(svc.containers, "app.example.com") | |||
| 140 | + | svc.mu.Unlock() | |||
| 141 | + | }).Return(&domain.Container{ | |||
| 142 | + | ID: "ctr-1", | |||
| 143 | + | Status: string(domain.ContainerStatusRunning), | |||
| 144 | + | }, nil) | |||
| 145 | + | runtime.EXPECT().GetContainerHealthStatus(mock.Anything, "ctr-1").Return("unhealthy", true, nil) | |||
| 146 | + | ||||
| 147 | + | m := newMonitor(svc) | |||
| 148 | + | m.check(monitorTestContext()) | |||
| 149 | + | ||||
| 150 | + | runtime.AssertNotCalled(t, "RestartContainer", mock.Anything, "ctr-1") | |||
| 151 | + | } | |||
| 152 | + | ||||
| 130 | func TestMonitor_CrashLoopBackoff(t *testing.T) { | 153 | func TestMonitor_CrashLoopBackoff(t *testing.T) { | ||
| 131 | runtime := mocks.NewMockContainerRuntime(t) | 154 | runtime := mocks.NewMockContainerRuntime(t) | ||
| 132 | svc := newTestService(runtime) | 155 | svc := newTestService(runtime) | ||
M internal/usecase/container/service.go +292 -0
| @@ -1159,6 +1159,165 @@ func (s *Service) Stop(ctx context.Context, containerID string) error { | |||
| 1159 | 1159 | return nil | |
| 1160 | 1160 | } | |
| 1161 | 1161 | ||
| 1162 | + | // ReconcileRemovedRoute removes active runtime containers for a route that was | |
| 1163 | + | // removed from configuration while preserving stateful resources for explicit | |
| 1164 | + | // follow-up cleanup. It intentionally removes only main route containers, never | |
| 1165 | + | // attachment containers or volumes. | |
| 1166 | + | func (s *Service) ReconcileRemovedRoute(ctx context.Context, domainName string) (*domain.CleanupReport, error) { | |
| 1167 | + | canonicalDomain, ok := domain.CanonicalRouteDomain(domainName) | |
| 1168 | + | if !ok { | |
| 1169 | + | return &domain.CleanupReport{Domain: domainName}, domain.ErrRouteDomainInvalid | |
| 1170 | + | } | |
| 1171 | + | domainName = canonicalDomain | |
| 1172 | + | ||
| 1173 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 1174 | + | zerowrap.FieldLayer: "usecase", | |
| 1175 | + | zerowrap.FieldUseCase: "ReconcileRemovedRoute", | |
| 1176 | + | "domain": domainName, | |
| 1177 | + | }) | |
| 1178 | + | log := zerowrap.FromCtx(ctx) | |
| 1179 | + | ||
| 1180 | + | report := &domain.CleanupReport{Domain: domainName} | |
| 1181 | + | ||
| 1182 | + | unlock, err := s.acquireDomainDeployLock(ctx, domainName) | |
| 1183 | + | if err != nil { | |
| 1184 | + | return report, err | |
| 1185 | + | } | |
| 1186 | + | defer unlock() | |
| 1187 | + | ||
| 1188 | + | allContainers, err := s.runtime.ListContainers(ctx, true) | |
| 1189 | + | if err != nil { | |
| 1190 | + | return report, log.WrapErr(err, "failed to list containers for removed route cleanup") | |
| 1191 | + | } | |
| 1192 | + | ||
| 1193 | + | routeContainers := routeContainersForDomain(allContainers, domainName) | |
| 1194 | + | ||
| 1195 | + | // Clear in-memory state before stopping containers so the monitor cannot | |
| 1196 | + | // interpret the deliberate removal as a crash that should be restarted. | |
| 1197 | + | s.clearRouteContainerTracking(ctx, domainName) | |
| 1198 | + | if inv := s.proxyCacheInvalidator(); inv != nil { | |
| 1199 | + | inv.InvalidateTarget(ctx, domainName) | |
| 1200 | + | } | |
| 1201 | + | ||
| 1202 | + | report.PreservedAttachments = preservedAttachmentsForDomain(allContainers, domainName) | |
| 1203 | + | if len(report.PreservedAttachments) > 0 { | |
| 1204 | + | report.Hints = append(report.Hints, "attachments preserved; remove or purge attachments explicitly if they are no longer needed") | |
| 1205 | + | } | |
| 1206 | + | ||
| 1207 | + | if len(routeContainers) == 0 { | |
| 1208 | + | report.Warnings = append(report.Warnings, "no active route container found for removed route") | |
| 1209 | + | return report, nil | |
| 1210 | + | } | |
| 1211 | + | ||
| 1212 | + | s.removeRouteContainersForCleanup(ctx, log, routeContainers, report) | |
| 1213 | + | ||
| 1214 | + | if len(report.PartialFailures) > 0 { | |
| 1215 | + | report.Warnings = append(report.Warnings, "route cleanup completed with partial failures; manual follow-up may be required") | |
| 1216 | + | } | |
| 1217 | + | ||
| 1218 | + | return report, nil | |
| 1219 | + | } | |
| 1220 | + | ||
| 1221 | + | func routeContainersForDomain(containers []*domain.Container, domainName string) []*domain.Container { | |
| 1222 | + | var routeContainers []*domain.Container | |
| 1223 | + | for _, c := range containers { | |
| 1224 | + | if isManagedRouteContainerForDomain(c, domainName) { | |
| 1225 | + | routeContainers = append(routeContainers, c) | |
| 1226 | + | } | |
| 1227 | + | } | |
| 1228 | + | return routeContainers | |
| 1229 | + | } | |
| 1230 | + | ||
| 1231 | + | func preservedAttachmentsForDomain(containers []*domain.Container, domainName string) []domain.CleanupAttachment { | |
| 1232 | + | var attachments []domain.CleanupAttachment | |
| 1233 | + | for _, c := range containers { | |
| 1234 | + | if isPreservedAttachmentForDomain(c, domainName) { | |
| 1235 | + | attachments = append(attachments, domain.CleanupAttachment{ | |
| 1236 | + | Name: c.Name, | |
| 1237 | + | Image: c.Image, | |
| 1238 | + | ContainerID: c.ID, | |
| 1239 | + | Status: c.Status, | |
| 1240 | + | Owner: c.Labels[domain.LabelAttachedTo], | |
| 1241 | + | Reason: "attachments are preserved by default; remove or purge them explicitly", | |
| 1242 | + | }) | |
| 1243 | + | } | |
| 1244 | + | } | |
| 1245 | + | return attachments | |
| 1246 | + | } | |
| 1247 | + | ||
| 1248 | + | func (s *Service) removeRouteContainersForCleanup(ctx context.Context, log zerowrap.Logger, containers []*domain.Container, report *domain.CleanupReport) { | |
| 1249 | + | for _, c := range containers { | |
| 1250 | + | s.stopRouteLogCollectionForCleanup(log, c, report) | |
| 1251 | + | s.stopRouteContainerForCleanup(ctx, log, c, report) | |
| 1252 | + | if err := s.runtime.RemoveContainer(ctx, c.ID, true); err != nil { | |
| 1253 | + | report.PartialFailures = append(report.PartialFailures, domain.CleanupFailure{Action: "remove", Kind: "container", ID: c.ID, Name: c.Name, Error: err.Error()}) | |
| 1254 | + | log.Warn().Err(err).Str(zerowrap.FieldEntityID, c.ID).Msg("failed to remove removed route container") | |
| 1255 | + | continue | |
| 1256 | + | } | |
| 1257 | + | report.RemovedContainers = append(report.RemovedContainers, domain.CleanupContainer{ID: c.ID, Name: c.Name, Image: c.Image, Status: c.Status}) | |
| 1258 | + | } | |
| 1259 | + | } | |
| 1260 | + | ||
| 1261 | + | func (s *Service) stopRouteLogCollectionForCleanup(log zerowrap.Logger, c *domain.Container, report *domain.CleanupReport) { | |
| 1262 | + | if s.logWriter == nil { | |
| 1263 | + | return | |
| 1264 | + | } | |
| 1265 | + | if err := s.logWriter.StopLogging(c.ID); err != nil { | |
| 1266 | + | report.PartialFailures = append(report.PartialFailures, domain.CleanupFailure{Action: "stop_logging", Kind: "container", ID: c.ID, Name: c.Name, Error: err.Error()}) | |
| 1267 | + | log.Warn().Err(err).Str(zerowrap.FieldEntityID, c.ID).Msg("failed to stop removed route log collection") | |
| 1268 | + | } | |
| 1269 | + | } | |
| 1270 | + | ||
| 1271 | + | func (s *Service) stopRouteContainerForCleanup(ctx context.Context, log zerowrap.Logger, c *domain.Container, report *domain.CleanupReport) { | |
| 1272 | + | if c.Status != string(domain.ContainerStatusRunning) { | |
| 1273 | + | return | |
| 1274 | + | } | |
| 1275 | + | if err := s.runtime.StopContainer(ctx, c.ID); err != nil { | |
| 1276 | + | report.PartialFailures = append(report.PartialFailures, domain.CleanupFailure{Action: "stop", Kind: "container", ID: c.ID, Name: c.Name, Error: err.Error()}) | |
| 1277 | + | log.Warn().Err(err).Str(zerowrap.FieldEntityID, c.ID).Msg("failed to stop removed route container") | |
| 1278 | + | } | |
| 1279 | + | } | |
| 1280 | + | ||
| 1281 | + | func isManagedRouteContainerForDomain(c *domain.Container, domainName string) bool { | |
| 1282 | + | if c == nil || c.Labels == nil || c.Labels[domain.LabelManaged] != "true" { | |
| 1283 | + | return false | |
| 1284 | + | } | |
| 1285 | + | if c.Labels[domain.LabelAttachment] == "true" { | |
| 1286 | + | return false | |
| 1287 | + | } | |
| 1288 | + | return c.Labels[domain.LabelRoute] == domainName || c.Labels[domain.LabelDomain] == domainName || c.Name == fmt.Sprintf("gordon-%s", domainName) | |
| 1289 | + | } | |
| 1290 | + | ||
| 1291 | + | func isPreservedAttachmentForDomain(c *domain.Container, domainName string) bool { | |
| 1292 | + | if c == nil || c.Labels == nil || c.Labels[domain.LabelManaged] != "true" { | |
| 1293 | + | return false | |
| 1294 | + | } | |
| 1295 | + | return c.Labels[domain.LabelAttachment] == "true" && c.Labels[domain.LabelAttachedTo] == domainName | |
| 1296 | + | } | |
| 1297 | + | ||
| 1298 | + | func (s *Service) proxyCacheInvalidator() out.ProxyCacheInvalidator { | |
| 1299 | + | s.mu.RLock() | |
| 1300 | + | defer s.mu.RUnlock() | |
| 1301 | + | return s.cacheInvalidator | |
| 1302 | + | } | |
| 1303 | + | ||
| 1304 | + | func (s *Service) clearRouteContainerTracking(ctx context.Context, domainName string) { | |
| 1305 | + | s.mu.Lock() | |
| 1306 | + | removed := false | |
| 1307 | + | if _, exists := s.containers[domainName]; exists { | |
| 1308 | + | delete(s.containers, domainName) | |
| 1309 | + | if s.managedCount > 0 { | |
| 1310 | + | s.managedCount-- | |
| 1311 | + | } | |
| 1312 | + | removed = true | |
| 1313 | + | } | |
| 1314 | + | s.mu.Unlock() | |
| 1315 | + | ||
| 1316 | + | if removed && s.metrics != nil { | |
| 1317 | + | s.metrics.ManagedContainers.Add(ctx, -1) | |
| 1318 | + | } | |
| 1319 | + | } | |
| 1320 | + | ||
| 1162 | 1321 | // Remove removes a container. | |
| 1163 | 1322 | func (s *Service) Remove(ctx context.Context, containerID string, force bool) error { | |
| 1164 | 1323 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| @@ -1351,6 +1510,139 @@ func (s *Service) ListAttachments(ctx context.Context, domainName string) []doma | |||
| 1351 | 1510 | return s.getAttachmentsForDomain(ctx, domainName) | |
| 1352 | 1511 | } | |
| 1353 | 1512 | ||
| 1513 | + | // ListOrphanedAttachments returns running attachment containers no longer configured. | |
| 1514 | + | func (s *Service) ListOrphanedAttachments(ctx context.Context) ([]domain.CleanupAttachment, error) { | |
| 1515 | + | containers, err := s.runtime.ListContainers(ctx, true) | |
| 1516 | + | if err != nil { | |
| 1517 | + | return nil, fmt.Errorf("failed to list containers with runtime for orphaned attachments: %w", err) | |
| 1518 | + | } | |
| 1519 | + | return s.orphanedAttachmentsFromContainers(containers), nil | |
| 1520 | + | } | |
| 1521 | + | ||
| 1522 | + | // CleanupOrphanedAttachments optionally stops/removes orphaned attachment containers. | |
| 1523 | + | // When owner is non-empty, cleanup is scoped to that attachment owner. | |
| 1524 | + | func (s *Service) CleanupOrphanedAttachments(ctx context.Context, owner string, stop bool) (*domain.CleanupReport, error) { | |
| 1525 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 1526 | + | zerowrap.FieldLayer: "usecase", | |
| 1527 | + | zerowrap.FieldUseCase: "CleanupOrphanedAttachments", | |
| 1528 | + | "owner": owner, | |
| 1529 | + | }) | |
| 1530 | + | log := zerowrap.FromCtx(ctx) | |
| 1531 | + | report := &domain.CleanupReport{Domain: owner} | |
| 1532 | + | ||
| 1533 | + | containers, err := s.runtime.ListContainers(ctx, true) | |
| 1534 | + | if err != nil { | |
| 1535 | + | return report, fmt.Errorf("failed to list containers with runtime for orphaned attachment cleanup: %w", err) | |
| 1536 | + | } | |
| 1537 | + | orphans := filterAttachmentContainersByOwner(s.orphanedAttachmentContainers(containers), owner) | |
| 1538 | + | if !stop { | |
| 1539 | + | report.PreservedAttachments = cleanupAttachmentsFromContainers(orphans) | |
| 1540 | + | if len(report.PreservedAttachments) > 0 { | |
| 1541 | + | report.Hints = append(report.Hints, "orphaned attachments preserved; rerun with --stop to stop/remove containers while preserving volumes") | |
| 1542 | + | } | |
| 1543 | + | return report, nil | |
| 1544 | + | } | |
| 1545 | + | ||
| 1546 | + | for _, c := range orphans { | |
| 1547 | + | s.stopRouteLogCollectionForCleanup(log, c, report) | |
| 1548 | + | s.stopRouteContainerForCleanup(ctx, log, c, report) | |
| 1549 | + | if err := s.runtime.RemoveContainer(ctx, c.ID, true); err != nil { | |
| 1550 | + | report.PartialFailures = append(report.PartialFailures, domain.CleanupFailure{Action: "remove", Kind: "attachment", ID: c.ID, Name: c.Name, Error: err.Error()}) | |
| 1551 | + | log.Warn().Err(err).Str(zerowrap.FieldEntityID, c.ID).Msg("failed to remove orphaned attachment") | |
| 1552 | + | continue | |
| 1553 | + | } | |
| 1554 | + | report.RemovedContainers = append(report.RemovedContainers, domain.CleanupContainer{ID: c.ID, Name: c.Name, Image: attachmentImage(c), Status: c.Status}) | |
| 1555 | + | s.untrackAttachmentContainer(c.Labels[domain.LabelAttachedTo], c.ID) | |
| 1556 | + | } | |
| 1557 | + | if len(report.RemovedContainers) > 0 { | |
| 1558 | + | report.Hints = append(report.Hints, "attachment volumes and data were preserved") | |
| 1559 | + | } | |
| 1560 | + | if len(report.PartialFailures) > 0 { | |
| 1561 | + | report.Warnings = append(report.Warnings, "attachment cleanup completed with partial failures; manual follow-up may be required") | |
| 1562 | + | } | |
| 1563 | + | return report, nil | |
| 1564 | + | } | |
| 1565 | + | ||
| 1566 | + | func (s *Service) orphanedAttachmentsFromContainers(containers []*domain.Container) []domain.CleanupAttachment { | |
| 1567 | + | return cleanupAttachmentsFromContainers(s.orphanedAttachmentContainers(containers)) | |
| 1568 | + | } | |
| 1569 | + | ||
| 1570 | + | func (s *Service) orphanedAttachmentContainers(containers []*domain.Container) []*domain.Container { | |
| 1571 | + | var out []*domain.Container | |
| 1572 | + | for _, c := range containers { | |
| 1573 | + | if !isManagedAttachment(c) { | |
| 1574 | + | continue | |
| 1575 | + | } | |
| 1576 | + | owner := c.Labels[domain.LabelAttachedTo] | |
| 1577 | + | image := attachmentImage(c) | |
| 1578 | + | if owner == "" || image == "" || !s.isAttachmentConfiguredForOwner(owner, image) { | |
| 1579 | + | out = append(out, c) | |
| 1580 | + | } | |
| 1581 | + | } | |
| 1582 | + | return out | |
| 1583 | + | } | |
| 1584 | + | ||
| 1585 | + | func filterAttachmentContainersByOwner(containers []*domain.Container, owner string) []*domain.Container { | |
| 1586 | + | if owner == "" { | |
| 1587 | + | return containers | |
| 1588 | + | } | |
| 1589 | + | var out []*domain.Container | |
| 1590 | + | for _, c := range containers { | |
| 1591 | + | if c != nil && c.Labels != nil && strings.EqualFold(c.Labels[domain.LabelAttachedTo], owner) { | |
| 1592 | + | out = append(out, c) | |
| 1593 | + | } | |
| 1594 | + | } | |
| 1595 | + | return out | |
| 1596 | + | } | |
| 1597 | + | ||
| 1598 | + | func cleanupAttachmentsFromContainers(containers []*domain.Container) []domain.CleanupAttachment { | |
| 1599 | + | attachments := make([]domain.CleanupAttachment, 0, len(containers)) | |
| 1600 | + | for _, c := range containers { | |
| 1601 | + | attachments = append(attachments, domain.CleanupAttachment{ | |
| 1602 | + | Name: c.Name, | |
| 1603 | + | Image: attachmentImage(c), | |
| 1604 | + | ContainerID: c.ID, | |
| 1605 | + | Status: c.Status, | |
| 1606 | + | Owner: c.Labels[domain.LabelAttachedTo], | |
| 1607 | + | Reason: "attachment container is no longer configured", | |
| 1608 | + | }) | |
| 1609 | + | } | |
| 1610 | + | return attachments | |
| 1611 | + | } | |
| 1612 | + | ||
| 1613 | + | func isManagedAttachment(c *domain.Container) bool { | |
| 1614 | + | return c != nil && c.Status == string(domain.ContainerStatusRunning) && c.Labels != nil && c.Labels[domain.LabelManaged] == "true" && c.Labels[domain.LabelAttachment] == "true" | |
| 1615 | + | } | |
| 1616 | + | ||
| 1617 | + | func attachmentImage(c *domain.Container) string { | |
| 1618 | + | if c == nil { | |
| 1619 | + | return "" | |
| 1620 | + | } | |
| 1621 | + | if c.Labels != nil && c.Labels[domain.LabelImage] != "" { | |
| 1622 | + | return c.Labels[domain.LabelImage] | |
| 1623 | + | } | |
| 1624 | + | return c.Image | |
| 1625 | + | } | |
| 1626 | + | ||
| 1627 | + | func (s *Service) isAttachmentConfiguredForOwner(owner, image string) bool { | |
| 1628 | + | return slices.Contains(s.resolveAttachmentsForDomain(owner), image) | |
| 1629 | + | } | |
| 1630 | + | ||
| 1631 | + | func (s *Service) untrackAttachmentContainer(owner, containerID string) { | |
| 1632 | + | s.mu.Lock() | |
| 1633 | + | defer s.mu.Unlock() | |
| 1634 | + | ids := s.attachments[owner] | |
| 1635 | + | for i, id := range ids { | |
| 1636 | + | if id == containerID { | |
| 1637 | + | s.attachments[owner] = slices.Delete(ids, i, i+1) | |
| 1638 | + | break | |
| 1639 | + | } | |
| 1640 | + | } | |
| 1641 | + | if len(s.attachments[owner]) == 0 { | |
| 1642 | + | delete(s.attachments, owner) | |
| 1643 | + | } | |
| 1644 | + | } | |
| 1645 | + | ||
| 1354 | 1646 | // ListNetworks returns Gordon-managed networks. | |
| 1355 | 1647 | func (s *Service) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) { | |
| 1356 | 1648 | s.mu.RLock() | |
| @@ -1159,6 +1159,165 @@ func (s *Service) Stop(ctx context.Context, containerID string) error { | |||||
| 1159 | return nil | 1159 | return nil | ||
| 1160 | } | 1160 | } | ||
| 1161 | 1161 | ||||
| 1162 | + | // ReconcileRemovedRoute removes active runtime containers for a route that was | |||
| 1163 | + | // removed from configuration while preserving stateful resources for explicit | |||
| 1164 | + | // follow-up cleanup. It intentionally removes only main route containers, never | |||
| 1165 | + | // attachment containers or volumes. | |||
| 1166 | + | func (s *Service) ReconcileRemovedRoute(ctx context.Context, domainName string) (*domain.CleanupReport, error) { | |||
| 1167 | + | canonicalDomain, ok := domain.CanonicalRouteDomain(domainName) | |||
| 1168 | + | if !ok { | |||
| 1169 | + | return &domain.CleanupReport{Domain: domainName}, domain.ErrRouteDomainInvalid | |||
| 1170 | + | } | |||
| 1171 | + | domainName = canonicalDomain | |||
| 1172 | + | ||||
| 1173 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |||
| 1174 | + | zerowrap.FieldLayer: "usecase", | |||
| 1175 | + | zerowrap.FieldUseCase: "ReconcileRemovedRoute", | |||
| 1176 | + | "domain": domainName, | |||
| 1177 | + | }) | |||
| 1178 | + | log := zerowrap.FromCtx(ctx) | |||
| 1179 | + | ||||
| 1180 | + | report := &domain.CleanupReport{Domain: domainName} | |||
| 1181 | + | ||||
| 1182 | + | unlock, err := s.acquireDomainDeployLock(ctx, domainName) | |||
| 1183 | + | if err != nil { | |||
| 1184 | + | return report, err | |||
| 1185 | + | } | |||
| 1186 | + | defer unlock() | |||
| 1187 | + | ||||
| 1188 | + | allContainers, err := s.runtime.ListContainers(ctx, true) | |||
| 1189 | + | if err != nil { | |||
| 1190 | + | return report, log.WrapErr(err, "failed to list containers for removed route cleanup") | |||
| 1191 | + | } | |||
| 1192 | + | ||||
| 1193 | + | routeContainers := routeContainersForDomain(allContainers, domainName) | |||
| 1194 | + | ||||
| 1195 | + | // Clear in-memory state before stopping containers so the monitor cannot | |||
| 1196 | + | // interpret the deliberate removal as a crash that should be restarted. | |||
| 1197 | + | s.clearRouteContainerTracking(ctx, domainName) | |||
| 1198 | + | if inv := s.proxyCacheInvalidator(); inv != nil { | |||
| 1199 | + | inv.InvalidateTarget(ctx, domainName) | |||
| 1200 | + | } | |||
| 1201 | + | ||||
| 1202 | + | report.PreservedAttachments = preservedAttachmentsForDomain(allContainers, domainName) | |||
| 1203 | + | if len(report.PreservedAttachments) > 0 { | |||
| 1204 | + | report.Hints = append(report.Hints, "attachments preserved; remove or purge attachments explicitly if they are no longer needed") | |||
| 1205 | + | } | |||
| 1206 | + | ||||
| 1207 | + | if len(routeContainers) == 0 { | |||
| 1208 | + | report.Warnings = append(report.Warnings, "no active route container found for removed route") | |||
| 1209 | + | return report, nil | |||
| 1210 | + | } | |||
| 1211 | + | ||||
| 1212 | + | s.removeRouteContainersForCleanup(ctx, log, routeContainers, report) | |||
| 1213 | + | ||||
| 1214 | + | if len(report.PartialFailures) > 0 { | |||
| 1215 | + | report.Warnings = append(report.Warnings, "route cleanup completed with partial failures; manual follow-up may be required") | |||
| 1216 | + | } | |||
| 1217 | + | ||||
| 1218 | + | return report, nil | |||
| 1219 | + | } | |||
| 1220 | + | ||||
| 1221 | + | func routeContainersForDomain(containers []*domain.Container, domainName string) []*domain.Container { | |||
| 1222 | + | var routeContainers []*domain.Container | |||
| 1223 | + | for _, c := range containers { | |||
| 1224 | + | if isManagedRouteContainerForDomain(c, domainName) { | |||
| 1225 | + | routeContainers = append(routeContainers, c) | |||
| 1226 | + | } | |||
| 1227 | + | } | |||
| 1228 | + | return routeContainers | |||
| 1229 | + | } | |||
| 1230 | + | ||||
| 1231 | + | func preservedAttachmentsForDomain(containers []*domain.Container, domainName string) []domain.CleanupAttachment { | |||
| 1232 | + | var attachments []domain.CleanupAttachment | |||
| 1233 | + | for _, c := range containers { | |||
| 1234 | + | if isPreservedAttachmentForDomain(c, domainName) { | |||
| 1235 | + | attachments = append(attachments, domain.CleanupAttachment{ | |||
| 1236 | + | Name: c.Name, | |||
| 1237 | + | Image: c.Image, | |||
| 1238 | + | ContainerID: c.ID, | |||
| 1239 | + | Status: c.Status, | |||
| 1240 | + | Owner: c.Labels[domain.LabelAttachedTo], | |||
| 1241 | + | Reason: "attachments are preserved by default; remove or purge them explicitly", | |||
| 1242 | + | }) | |||
| 1243 | + | } | |||
| 1244 | + | } | |||
| 1245 | + | return attachments | |||
| 1246 | + | } | |||
| 1247 | + | ||||
| 1248 | + | func (s *Service) removeRouteContainersForCleanup(ctx context.Context, log zerowrap.Logger, containers []*domain.Container, report *domain.CleanupReport) { | |||
| 1249 | + | for _, c := range containers { | |||
| 1250 | + | s.stopRouteLogCollectionForCleanup(log, c, report) | |||
| 1251 | + | s.stopRouteContainerForCleanup(ctx, log, c, report) | |||
| 1252 | + | if err := s.runtime.RemoveContainer(ctx, c.ID, true); err != nil { | |||
| 1253 | + | report.PartialFailures = append(report.PartialFailures, domain.CleanupFailure{Action: "remove", Kind: "container", ID: c.ID, Name: c.Name, Error: err.Error()}) | |||
| 1254 | + | log.Warn().Err(err).Str(zerowrap.FieldEntityID, c.ID).Msg("failed to remove removed route container") | |||
| 1255 | + | continue | |||
| 1256 | + | } | |||
| 1257 | + | report.RemovedContainers = append(report.RemovedContainers, domain.CleanupContainer{ID: c.ID, Name: c.Name, Image: c.Image, Status: c.Status}) | |||
| 1258 | + | } | |||
| 1259 | + | } | |||
| 1260 | + | ||||
| 1261 | + | func (s *Service) stopRouteLogCollectionForCleanup(log zerowrap.Logger, c *domain.Container, report *domain.CleanupReport) { | |||
| 1262 | + | if s.logWriter == nil { | |||
| 1263 | + | return | |||
| 1264 | + | } | |||
| 1265 | + | if err := s.logWriter.StopLogging(c.ID); err != nil { | |||
| 1266 | + | report.PartialFailures = append(report.PartialFailures, domain.CleanupFailure{Action: "stop_logging", Kind: "container", ID: c.ID, Name: c.Name, Error: err.Error()}) | |||
| 1267 | + | log.Warn().Err(err).Str(zerowrap.FieldEntityID, c.ID).Msg("failed to stop removed route log collection") | |||
| 1268 | + | } | |||
| 1269 | + | } | |||
| 1270 | + | ||||
| 1271 | + | func (s *Service) stopRouteContainerForCleanup(ctx context.Context, log zerowrap.Logger, c *domain.Container, report *domain.CleanupReport) { | |||
| 1272 | + | if c.Status != string(domain.ContainerStatusRunning) { | |||
| 1273 | + | return | |||
| 1274 | + | } | |||
| 1275 | + | if err := s.runtime.StopContainer(ctx, c.ID); err != nil { | |||
| 1276 | + | report.PartialFailures = append(report.PartialFailures, domain.CleanupFailure{Action: "stop", Kind: "container", ID: c.ID, Name: c.Name, Error: err.Error()}) | |||
| 1277 | + | log.Warn().Err(err).Str(zerowrap.FieldEntityID, c.ID).Msg("failed to stop removed route container") | |||
| 1278 | + | } | |||
| 1279 | + | } | |||
| 1280 | + | ||||
| 1281 | + | func isManagedRouteContainerForDomain(c *domain.Container, domainName string) bool { | |||
| 1282 | + | if c == nil || c.Labels == nil || c.Labels[domain.LabelManaged] != "true" { | |||
| 1283 | + | return false | |||
| 1284 | + | } | |||
| 1285 | + | if c.Labels[domain.LabelAttachment] == "true" { | |||
| 1286 | + | return false | |||
| 1287 | + | } | |||
| 1288 | + | return c.Labels[domain.LabelRoute] == domainName || c.Labels[domain.LabelDomain] == domainName || c.Name == fmt.Sprintf("gordon-%s", domainName) | |||
| 1289 | + | } | |||
| 1290 | + | ||||
| 1291 | + | func isPreservedAttachmentForDomain(c *domain.Container, domainName string) bool { | |||
| 1292 | + | if c == nil || c.Labels == nil || c.Labels[domain.LabelManaged] != "true" { | |||
| 1293 | + | return false | |||
| 1294 | + | } | |||
| 1295 | + | return c.Labels[domain.LabelAttachment] == "true" && c.Labels[domain.LabelAttachedTo] == domainName | |||
| 1296 | + | } | |||
| 1297 | + | ||||
| 1298 | + | func (s *Service) proxyCacheInvalidator() out.ProxyCacheInvalidator { | |||
| 1299 | + | s.mu.RLock() | |||
| 1300 | + | defer s.mu.RUnlock() | |||
| 1301 | + | return s.cacheInvalidator | |||
| 1302 | + | } | |||
| 1303 | + | ||||
| 1304 | + | func (s *Service) clearRouteContainerTracking(ctx context.Context, domainName string) { | |||
| 1305 | + | s.mu.Lock() | |||
| 1306 | + | removed := false | |||
| 1307 | + | if _, exists := s.containers[domainName]; exists { | |||
| 1308 | + | delete(s.containers, domainName) | |||
| 1309 | + | if s.managedCount > 0 { | |||
| 1310 | + | s.managedCount-- | |||
| 1311 | + | } | |||
| 1312 | + | removed = true | |||
| 1313 | + | } | |||
| 1314 | + | s.mu.Unlock() | |||
| 1315 | + | ||||
| 1316 | + | if removed && s.metrics != nil { | |||
| 1317 | + | s.metrics.ManagedContainers.Add(ctx, -1) | |||
| 1318 | + | } | |||
| 1319 | + | } | |||
| 1320 | + | ||||
| 1162 | // Remove removes a container. | 1321 | // Remove removes a container. | ||
| 1163 | func (s *Service) Remove(ctx context.Context, containerID string, force bool) error { | 1322 | func (s *Service) Remove(ctx context.Context, containerID string, force bool) error { | ||
| 1164 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | 1323 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | ||
| @@ -1351,6 +1510,139 @@ func (s *Service) ListAttachments(ctx context.Context, domainName string) []doma | |||||
| 1351 | return s.getAttachmentsForDomain(ctx, domainName) | 1510 | return s.getAttachmentsForDomain(ctx, domainName) | ||
| 1352 | } | 1511 | } | ||
| 1353 | 1512 | ||||
| 1513 | + | // ListOrphanedAttachments returns running attachment containers no longer configured. | |||
| 1514 | + | func (s *Service) ListOrphanedAttachments(ctx context.Context) ([]domain.CleanupAttachment, error) { | |||
| 1515 | + | containers, err := s.runtime.ListContainers(ctx, true) | |||
| 1516 | + | if err != nil { | |||
| 1517 | + | return nil, fmt.Errorf("failed to list containers with runtime for orphaned attachments: %w", err) | |||
| 1518 | + | } | |||
| 1519 | + | return s.orphanedAttachmentsFromContainers(containers), nil | |||
| 1520 | + | } | |||
| 1521 | + | ||||
| 1522 | + | // CleanupOrphanedAttachments optionally stops/removes orphaned attachment containers. | |||
| 1523 | + | // When owner is non-empty, cleanup is scoped to that attachment owner. | |||
| 1524 | + | func (s *Service) CleanupOrphanedAttachments(ctx context.Context, owner string, stop bool) (*domain.CleanupReport, error) { | |||
| 1525 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |||
| 1526 | + | zerowrap.FieldLayer: "usecase", | |||
| 1527 | + | zerowrap.FieldUseCase: "CleanupOrphanedAttachments", | |||
| 1528 | + | "owner": owner, | |||
| 1529 | + | }) | |||
| 1530 | + | log := zerowrap.FromCtx(ctx) | |||
| 1531 | + | report := &domain.CleanupReport{Domain: owner} | |||
| 1532 | + | ||||
| 1533 | + | containers, err := s.runtime.ListContainers(ctx, true) | |||
| 1534 | + | if err != nil { | |||
| 1535 | + | return report, fmt.Errorf("failed to list containers with runtime for orphaned attachment cleanup: %w", err) | |||
| 1536 | + | } | |||
| 1537 | + | orphans := filterAttachmentContainersByOwner(s.orphanedAttachmentContainers(containers), owner) | |||
| 1538 | + | if !stop { | |||
| 1539 | + | report.PreservedAttachments = cleanupAttachmentsFromContainers(orphans) | |||
| 1540 | + | if len(report.PreservedAttachments) > 0 { | |||
| 1541 | + | report.Hints = append(report.Hints, "orphaned attachments preserved; rerun with --stop to stop/remove containers while preserving volumes") | |||
| 1542 | + | } | |||
| 1543 | + | return report, nil | |||
| 1544 | + | } | |||
| 1545 | + | ||||
| 1546 | + | for _, c := range orphans { | |||
| 1547 | + | s.stopRouteLogCollectionForCleanup(log, c, report) | |||
| 1548 | + | s.stopRouteContainerForCleanup(ctx, log, c, report) | |||
| 1549 | + | if err := s.runtime.RemoveContainer(ctx, c.ID, true); err != nil { | |||
| 1550 | + | report.PartialFailures = append(report.PartialFailures, domain.CleanupFailure{Action: "remove", Kind: "attachment", ID: c.ID, Name: c.Name, Error: err.Error()}) | |||
| 1551 | + | log.Warn().Err(err).Str(zerowrap.FieldEntityID, c.ID).Msg("failed to remove orphaned attachment") | |||
| 1552 | + | continue | |||
| 1553 | + | } | |||
| 1554 | + | report.RemovedContainers = append(report.RemovedContainers, domain.CleanupContainer{ID: c.ID, Name: c.Name, Image: attachmentImage(c), Status: c.Status}) | |||
| 1555 | + | s.untrackAttachmentContainer(c.Labels[domain.LabelAttachedTo], c.ID) | |||
| 1556 | + | } | |||
| 1557 | + | if len(report.RemovedContainers) > 0 { | |||
| 1558 | + | report.Hints = append(report.Hints, "attachment volumes and data were preserved") | |||
| 1559 | + | } | |||
| 1560 | + | if len(report.PartialFailures) > 0 { | |||
| 1561 | + | report.Warnings = append(report.Warnings, "attachment cleanup completed with partial failures; manual follow-up may be required") | |||
| 1562 | + | } | |||
| 1563 | + | return report, nil | |||
| 1564 | + | } | |||
| 1565 | + | ||||
| 1566 | + | func (s *Service) orphanedAttachmentsFromContainers(containers []*domain.Container) []domain.CleanupAttachment { | |||
| 1567 | + | return cleanupAttachmentsFromContainers(s.orphanedAttachmentContainers(containers)) | |||
| 1568 | + | } | |||
| 1569 | + | ||||
| 1570 | + | func (s *Service) orphanedAttachmentContainers(containers []*domain.Container) []*domain.Container { | |||
| 1571 | + | var out []*domain.Container | |||
| 1572 | + | for _, c := range containers { | |||
| 1573 | + | if !isManagedAttachment(c) { | |||
| 1574 | + | continue | |||
| 1575 | + | } | |||
| 1576 | + | owner := c.Labels[domain.LabelAttachedTo] | |||
| 1577 | + | image := attachmentImage(c) | |||
| 1578 | + | if owner == "" || image == "" || !s.isAttachmentConfiguredForOwner(owner, image) { | |||
| 1579 | + | out = append(out, c) | |||
| 1580 | + | } | |||
| 1581 | + | } | |||
| 1582 | + | return out | |||
| 1583 | + | } | |||
| 1584 | + | ||||
| 1585 | + | func filterAttachmentContainersByOwner(containers []*domain.Container, owner string) []*domain.Container { | |||
| 1586 | + | if owner == "" { | |||
| 1587 | + | return containers | |||
| 1588 | + | } | |||
| 1589 | + | var out []*domain.Container | |||
| 1590 | + | for _, c := range containers { | |||
| 1591 | + | if c != nil && c.Labels != nil && strings.EqualFold(c.Labels[domain.LabelAttachedTo], owner) { | |||
| 1592 | + | out = append(out, c) | |||
| 1593 | + | } | |||
| 1594 | + | } | |||
| 1595 | + | return out | |||
| 1596 | + | } | |||
| 1597 | + | ||||
| 1598 | + | func cleanupAttachmentsFromContainers(containers []*domain.Container) []domain.CleanupAttachment { | |||
| 1599 | + | attachments := make([]domain.CleanupAttachment, 0, len(containers)) | |||
| 1600 | + | for _, c := range containers { | |||
| 1601 | + | attachments = append(attachments, domain.CleanupAttachment{ | |||
| 1602 | + | Name: c.Name, | |||
| 1603 | + | Image: attachmentImage(c), | |||
| 1604 | + | ContainerID: c.ID, | |||
| 1605 | + | Status: c.Status, | |||
| 1606 | + | Owner: c.Labels[domain.LabelAttachedTo], | |||
| 1607 | + | Reason: "attachment container is no longer configured", | |||
| 1608 | + | }) | |||
| 1609 | + | } | |||
| 1610 | + | return attachments | |||
| 1611 | + | } | |||
| 1612 | + | ||||
| 1613 | + | func isManagedAttachment(c *domain.Container) bool { | |||
| 1614 | + | return c != nil && c.Status == string(domain.ContainerStatusRunning) && c.Labels != nil && c.Labels[domain.LabelManaged] == "true" && c.Labels[domain.LabelAttachment] == "true" | |||
| 1615 | + | } | |||
| 1616 | + | ||||
| 1617 | + | func attachmentImage(c *domain.Container) string { | |||
| 1618 | + | if c == nil { | |||
| 1619 | + | return "" | |||
| 1620 | + | } | |||
| 1621 | + | if c.Labels != nil && c.Labels[domain.LabelImage] != "" { | |||
| 1622 | + | return c.Labels[domain.LabelImage] | |||
| 1623 | + | } | |||
| 1624 | + | return c.Image | |||
| 1625 | + | } | |||
| 1626 | + | ||||
| 1627 | + | func (s *Service) isAttachmentConfiguredForOwner(owner, image string) bool { | |||
| 1628 | + | return slices.Contains(s.resolveAttachmentsForDomain(owner), image) | |||
| 1629 | + | } | |||
| 1630 | + | ||||
| 1631 | + | func (s *Service) untrackAttachmentContainer(owner, containerID string) { | |||
| 1632 | + | s.mu.Lock() | |||
| 1633 | + | defer s.mu.Unlock() | |||
| 1634 | + | ids := s.attachments[owner] | |||
| 1635 | + | for i, id := range ids { | |||
| 1636 | + | if id == containerID { | |||
| 1637 | + | s.attachments[owner] = slices.Delete(ids, i, i+1) | |||
| 1638 | + | break | |||
| 1639 | + | } | |||
| 1640 | + | } | |||
| 1641 | + | if len(s.attachments[owner]) == 0 { | |||
| 1642 | + | delete(s.attachments, owner) | |||
| 1643 | + | } | |||
| 1644 | + | } | |||
| 1645 | + | ||||
| 1354 | // ListNetworks returns Gordon-managed networks. | 1646 | // ListNetworks returns Gordon-managed networks. | ||
| 1355 | func (s *Service) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) { | 1647 | func (s *Service) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) { | ||
| 1356 | s.mu.RLock() | 1648 | s.mu.RLock() | ||
M internal/usecase/container/service_test.go +342 -0
| @@ -4418,3 +4418,345 @@ func TestService_BuildContainerConfig_SetsRestartPolicyAlways(t *testing.T) { | |||
| 4418 | 4418 | ||
| 4419 | 4419 | assert.Equal(t, domain.RestartPolicyAlways, cfg.RestartPolicy) | |
| 4420 | 4420 | } | |
| 4421 | + | ||
| 4422 | + | func TestService_ReconcileRemovedRoute_RemovesOnlyMainRouteContainers(t *testing.T) { | |
| 4423 | + | ctx := testContext() | |
| 4424 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 4425 | + | svc := NewService(runtime, nil, mocks.NewMockEventPublisher(t), nil, Config{}, nil) | |
| 4426 | + | ||
| 4427 | + | mainContainer := &domain.Container{ | |
| 4428 | + | ID: "route-container", | |
| 4429 | + | Name: "gordon-app.example.com", | |
| 4430 | + | Status: string(domain.ContainerStatusRunning), | |
| 4431 | + | Labels: map[string]string{ | |
| 4432 | + | domain.LabelManaged: "true", | |
| 4433 | + | domain.LabelRoute: "app.example.com", | |
| 4434 | + | domain.LabelDomain: "app.example.com", | |
| 4435 | + | }, | |
| 4436 | + | } | |
| 4437 | + | attachment := &domain.Container{ | |
| 4438 | + | ID: "attachment-container", | |
| 4439 | + | Name: "gordon-app-example-com-postgres", | |
| 4440 | + | Status: string(domain.ContainerStatusRunning), | |
| 4441 | + | Labels: map[string]string{ | |
| 4442 | + | domain.LabelManaged: "true", | |
| 4443 | + | domain.LabelAttachment: "true", | |
| 4444 | + | domain.LabelAttachedTo: "app.example.com", | |
| 4445 | + | }, | |
| 4446 | + | } | |
| 4447 | + | ||
| 4448 | + | svc.mu.Lock() | |
| 4449 | + | svc.containers["app.example.com"] = mainContainer | |
| 4450 | + | svc.attachments["app.example.com"] = []string{attachment.ID} | |
| 4451 | + | svc.managedCount = 1 | |
| 4452 | + | svc.mu.Unlock() | |
| 4453 | + | ||
| 4454 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{mainContainer, attachment}, nil).Once() | |
| 4455 | + | runtime.EXPECT().StopContainer(mock.Anything, mainContainer.ID).Return(nil).Once() | |
| 4456 | + | runtime.EXPECT().RemoveContainer(mock.Anything, mainContainer.ID, true).Return(nil).Once() | |
| 4457 | + | ||
| 4458 | + | report, err := svc.ReconcileRemovedRoute(ctx, "app.example.com") | |
| 4459 | + | require.NoError(t, err) | |
| 4460 | + | require.NotNil(t, report) | |
| 4461 | + | ||
| 4462 | + | assert.Equal(t, "app.example.com", report.Domain) | |
| 4463 | + | require.Len(t, report.RemovedContainers, 1) | |
| 4464 | + | assert.Equal(t, mainContainer.ID, report.RemovedContainers[0].ID) | |
| 4465 | + | require.Len(t, report.PreservedAttachments, 1) | |
| 4466 | + | assert.Equal(t, attachment.ID, report.PreservedAttachments[0].ContainerID) | |
| 4467 | + | assert.Contains(t, report.Hints, "attachments preserved; remove or purge attachments explicitly if they are no longer needed") | |
| 4468 | + | ||
| 4469 | + | _, exists := svc.Get(ctx, "app.example.com") | |
| 4470 | + | assert.False(t, exists, "removed route must be cleared from in-memory tracking so monitor cannot restart it") | |
| 4471 | + | ||
| 4472 | + | svc.mu.RLock() | |
| 4473 | + | _, attachmentsTracked := svc.attachments["app.example.com"] | |
| 4474 | + | svc.mu.RUnlock() | |
| 4475 | + | assert.True(t, attachmentsTracked, "route cleanup preserves attachment tracking for follow-up cleanup") | |
| 4476 | + | } | |
| 4477 | + | ||
| 4478 | + | func TestService_ReconcileRemovedRoute_IsIdempotentWhenNoContainerExists(t *testing.T) { | |
| 4479 | + | ctx := testContext() | |
| 4480 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 4481 | + | svc := NewService(runtime, nil, mocks.NewMockEventPublisher(t), nil, Config{}, nil) | |
| 4482 | + | ||
| 4483 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Once() | |
| 4484 | + | ||
| 4485 | + | report, err := svc.ReconcileRemovedRoute(ctx, "missing.example.com") | |
| 4486 | + | require.NoError(t, err) | |
| 4487 | + | require.NotNil(t, report) | |
| 4488 | + | ||
| 4489 | + | assert.Equal(t, "missing.example.com", report.Domain) | |
| 4490 | + | assert.Empty(t, report.RemovedContainers) | |
| 4491 | + | assert.Contains(t, report.Warnings, "no active route container found for removed route") | |
| 4492 | + | } | |
| 4493 | + | ||
| 4494 | + | func TestService_ReconcileRemovedRoute_CanonicalizesDomainBeforeCleanup(t *testing.T) { | |
| 4495 | + | ctx := testContext() | |
| 4496 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 4497 | + | svc := NewService(runtime, nil, mocks.NewMockEventPublisher(t), nil, Config{}, nil) | |
| 4498 | + | ||
| 4499 | + | mainContainer := &domain.Container{ | |
| 4500 | + | ID: "route-container", | |
| 4501 | + | Name: "gordon-app.example.com", | |
| 4502 | + | Status: string(domain.ContainerStatusRunning), | |
| 4503 | + | Labels: map[string]string{ | |
| 4504 | + | domain.LabelManaged: "true", | |
| 4505 | + | domain.LabelRoute: "app.example.com", | |
| 4506 | + | domain.LabelDomain: "app.example.com", | |
| 4507 | + | }, | |
| 4508 | + | } | |
| 4509 | + | svc.mu.Lock() | |
| 4510 | + | svc.containers["app.example.com"] = mainContainer | |
| 4511 | + | svc.managedCount = 1 | |
| 4512 | + | svc.mu.Unlock() | |
| 4513 | + | ||
| 4514 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{mainContainer}, nil).Once() | |
| 4515 | + | runtime.EXPECT().StopContainer(mock.Anything, mainContainer.ID).Return(nil).Once() | |
| 4516 | + | runtime.EXPECT().RemoveContainer(mock.Anything, mainContainer.ID, true).Return(nil).Once() | |
| 4517 | + | ||
| 4518 | + | report, err := svc.ReconcileRemovedRoute(ctx, "App.EXAMPLE.com") | |
| 4519 | + | require.NoError(t, err) | |
| 4520 | + | require.NotNil(t, report) | |
| 4521 | + | assert.Equal(t, "app.example.com", report.Domain) | |
| 4522 | + | require.Len(t, report.RemovedContainers, 1) | |
| 4523 | + | ||
| 4524 | + | _, exists := svc.Get(ctx, "app.example.com") | |
| 4525 | + | assert.False(t, exists) | |
| 4526 | + | } | |
| 4527 | + | ||
| 4528 | + | func TestService_ReconcileRemovedRoute_StopsLogCollectionForRemovedContainer(t *testing.T) { | |
| 4529 | + | ctx := testContext() | |
| 4530 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 4531 | + | logWriter := mocks.NewMockContainerLogWriter(t) | |
| 4532 | + | svc := NewService(runtime, nil, mocks.NewMockEventPublisher(t), logWriter, Config{}, nil) | |
| 4533 | + | ||
| 4534 | + | mainContainer := &domain.Container{ | |
| 4535 | + | ID: "route-container", | |
| 4536 | + | Name: "gordon-app.example.com", | |
| 4537 | + | Status: string(domain.ContainerStatusRunning), | |
| 4538 | + | Labels: map[string]string{ | |
| 4539 | + | domain.LabelManaged: "true", | |
| 4540 | + | domain.LabelRoute: "app.example.com", | |
| 4541 | + | }, | |
| 4542 | + | } | |
| 4543 | + | ||
| 4544 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{mainContainer}, nil).Once() | |
| 4545 | + | logWriter.EXPECT().StopLogging(mainContainer.ID).Return(nil).Once() | |
| 4546 | + | runtime.EXPECT().StopContainer(mock.Anything, mainContainer.ID).Return(nil).Once() | |
| 4547 | + | runtime.EXPECT().RemoveContainer(mock.Anything, mainContainer.ID, true).Return(nil).Once() | |
| 4548 | + | ||
| 4549 | + | _, err := svc.ReconcileRemovedRoute(ctx, "app.example.com") | |
| 4550 | + | require.NoError(t, err) | |
| 4551 | + | } | |
| 4552 | + | ||
| 4553 | + | func TestService_ReconcileRemovedRoute_InvalidatesProxyCacheAndMetric(t *testing.T) { | |
| 4554 | + | ctx := testContext() | |
| 4555 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 4556 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 4557 | + | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | |
| 4558 | + | svc := NewService(runtime, nil, eventBus, nil, Config{}, nil) | |
| 4559 | + | ||
| 4560 | + | metrics, reader := setupMetricsTest(t) | |
| 4561 | + | svc.SetMetrics(metrics) | |
| 4562 | + | ||
| 4563 | + | mainContainer := &domain.Container{ | |
| 4564 | + | ID: "route-container", | |
| 4565 | + | Name: "gordon-app.example.com", | |
| 4566 | + | Status: string(domain.ContainerStatusRunning), | |
| 4567 | + | Labels: map[string]string{ | |
| 4568 | + | domain.LabelManaged: "true", | |
| 4569 | + | domain.LabelRoute: "app.example.com", | |
| 4570 | + | }, | |
| 4571 | + | } | |
| 4572 | + | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil).Once() | |
| 4573 | + | svc.activateDeployedContainer(ctx, "app.example.com", mainContainer) | |
| 4574 | + | svc.SetProxyCacheInvalidator(cacheInvalidator) | |
| 4575 | + | ||
| 4576 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{mainContainer}, nil).Once() | |
| 4577 | + | cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "app.example.com").Return().Once() | |
| 4578 | + | runtime.EXPECT().StopContainer(mock.Anything, mainContainer.ID).Return(nil).Once() | |
| 4579 | + | runtime.EXPECT().RemoveContainer(mock.Anything, mainContainer.ID, true).Return(nil).Once() | |
| 4580 | + | ||
| 4581 | + | _, err := svc.ReconcileRemovedRoute(ctx, "app.example.com") | |
| 4582 | + | require.NoError(t, err) | |
| 4583 | + | ||
| 4584 | + | value, _, _ := managedMetricState(t, reader) | |
| 4585 | + | assert.Equal(t, int64(0), value) | |
| 4586 | + | } | |
| 4587 | + | ||
| 4588 | + | func TestService_ListOrphanedAttachments_DetectsRunningAttachmentNoLongerConfigured(t *testing.T) { | |
| 4589 | + | ctx := testContext() | |
| 4590 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 4591 | + | svc := NewService(runtime, nil, mocks.NewMockEventPublisher(t), nil, Config{Attachments: map[string][]string{ | |
| 4592 | + | "app.example.com": {"redis:7"}, | |
| 4593 | + | }}, nil) | |
| 4594 | + | ||
| 4595 | + | orphan := &domain.Container{ | |
| 4596 | + | ID: "postgres-1", | |
| 4597 | + | Name: "gordon-app-example-com-postgres", | |
| 4598 | + | Image: "postgres:16", | |
| 4599 | + | Status: string(domain.ContainerStatusRunning), | |
| 4600 | + | Labels: map[string]string{ | |
| 4601 | + | domain.LabelManaged: "true", | |
| 4602 | + | domain.LabelAttachment: "true", | |
| 4603 | + | domain.LabelAttachedTo: "app.example.com", | |
| 4604 | + | domain.LabelImage: "postgres:16", | |
| 4605 | + | }, | |
| 4606 | + | } | |
| 4607 | + | configured := &domain.Container{ | |
| 4608 | + | ID: "redis-1", | |
| 4609 | + | Name: "gordon-app-example-com-redis", | |
| 4610 | + | Image: "redis:7", | |
| 4611 | + | Status: string(domain.ContainerStatusRunning), | |
| 4612 | + | Labels: map[string]string{ | |
| 4613 | + | domain.LabelManaged: "true", | |
| 4614 | + | domain.LabelAttachment: "true", | |
| 4615 | + | domain.LabelAttachedTo: "app.example.com", | |
| 4616 | + | domain.LabelImage: "redis:7", | |
| 4617 | + | }, | |
| 4618 | + | } | |
| 4619 | + | ||
| 4620 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{orphan, configured}, nil).Once() | |
| 4621 | + | ||
| 4622 | + | orphans, err := svc.ListOrphanedAttachments(ctx) | |
| 4623 | + | require.NoError(t, err) | |
| 4624 | + | require.Len(t, orphans, 1) | |
| 4625 | + | assert.Equal(t, orphan.ID, orphans[0].ContainerID) | |
| 4626 | + | assert.Equal(t, "postgres:16", orphans[0].Image) | |
| 4627 | + | assert.Contains(t, orphans[0].Reason, "no longer configured") | |
| 4628 | + | } | |
| 4629 | + | ||
| 4630 | + | func TestService_CleanupOrphanedAttachments_DryRunPreservesRunningContainer(t *testing.T) { | |
| 4631 | + | ctx := testContext() | |
| 4632 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 4633 | + | svc := NewService(runtime, nil, mocks.NewMockEventPublisher(t), nil, Config{}, nil) | |
| 4634 | + | orphan := &domain.Container{ | |
| 4635 | + | ID: "postgres-1", | |
| 4636 | + | Name: "gordon-app-example-com-postgres", | |
| 4637 | + | Image: "postgres:16", | |
| 4638 | + | Status: string(domain.ContainerStatusRunning), | |
| 4639 | + | Labels: map[string]string{ | |
| 4640 | + | domain.LabelManaged: "true", | |
| 4641 | + | domain.LabelAttachment: "true", | |
| 4642 | + | domain.LabelAttachedTo: "app.example.com", | |
| 4643 | + | domain.LabelImage: "postgres:16", | |
| 4644 | + | }, | |
| 4645 | + | } | |
| 4646 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{orphan}, nil).Once() | |
| 4647 | + | ||
| 4648 | + | report, err := svc.CleanupOrphanedAttachments(ctx, "", false) | |
| 4649 | + | require.NoError(t, err) | |
| 4650 | + | require.NotNil(t, report) | |
| 4651 | + | require.Len(t, report.PreservedAttachments, 1) | |
| 4652 | + | assert.Equal(t, orphan.ID, report.PreservedAttachments[0].ContainerID) | |
| 4653 | + | runtime.AssertNotCalled(t, "StopContainer", mock.Anything, orphan.ID) | |
| 4654 | + | runtime.AssertNotCalled(t, "RemoveContainer", mock.Anything, orphan.ID, mock.Anything) | |
| 4655 | + | } | |
| 4656 | + | ||
| 4657 | + | func TestService_CleanupOrphanedAttachments_StopRemovesContainerButPreservesData(t *testing.T) { | |
| 4658 | + | ctx := testContext() | |
| 4659 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 4660 | + | logWriter := mocks.NewMockContainerLogWriter(t) | |
| 4661 | + | svc := NewService(runtime, nil, mocks.NewMockEventPublisher(t), logWriter, Config{}, nil) | |
| 4662 | + | orphan := &domain.Container{ | |
| 4663 | + | ID: "postgres-1", | |
| 4664 | + | Name: "gordon-app-example-com-postgres", | |
| 4665 | + | Image: "postgres:16", | |
| 4666 | + | Status: string(domain.ContainerStatusRunning), | |
| 4667 | + | Labels: map[string]string{ | |
| 4668 | + | domain.LabelManaged: "true", | |
| 4669 | + | domain.LabelAttachment: "true", | |
| 4670 | + | domain.LabelAttachedTo: "app.example.com", | |
| 4671 | + | domain.LabelImage: "postgres:16", | |
| 4672 | + | }, | |
| 4673 | + | } | |
| 4674 | + | svc.mu.Lock() | |
| 4675 | + | svc.attachments["app.example.com"] = []string{orphan.ID} | |
| 4676 | + | svc.mu.Unlock() | |
| 4677 | + | ||
| 4678 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{orphan}, nil).Once() | |
| 4679 | + | logWriter.EXPECT().StopLogging(orphan.ID).Return(nil).Once() | |
| 4680 | + | runtime.EXPECT().StopContainer(mock.Anything, orphan.ID).Return(nil).Once() | |
| 4681 | + | runtime.EXPECT().RemoveContainer(mock.Anything, orphan.ID, true).Return(nil).Once() | |
| 4682 | + | ||
| 4683 | + | report, err := svc.CleanupOrphanedAttachments(ctx, "", true) | |
| 4684 | + | require.NoError(t, err) | |
| 4685 | + | require.NotNil(t, report) | |
| 4686 | + | require.Len(t, report.RemovedContainers, 1) | |
| 4687 | + | assert.Equal(t, orphan.ID, report.RemovedContainers[0].ID) | |
| 4688 | + | assert.Contains(t, report.Hints, "attachment volumes and data were preserved") | |
| 4689 | + | ||
| 4690 | + | svc.mu.RLock() | |
| 4691 | + | ids := svc.attachments["app.example.com"] | |
| 4692 | + | svc.mu.RUnlock() | |
| 4693 | + | assert.Empty(t, ids) | |
| 4694 | + | } | |
| 4695 | + | ||
| 4696 | + | func TestService_ListOrphanedAttachments_IgnoresStoppedAttachments(t *testing.T) { | |
| 4697 | + | ctx := testContext() | |
| 4698 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 4699 | + | svc := NewService(runtime, nil, mocks.NewMockEventPublisher(t), nil, Config{}, nil) | |
| 4700 | + | stopped := &domain.Container{ | |
| 4701 | + | ID: "postgres-1", | |
| 4702 | + | Name: "gordon-app-example-com-postgres", | |
| 4703 | + | Image: "postgres:16", | |
| 4704 | + | Status: string(domain.ContainerStatusExited), | |
| 4705 | + | Labels: map[string]string{ | |
| 4706 | + | domain.LabelManaged: "true", | |
| 4707 | + | domain.LabelAttachment: "true", | |
| 4708 | + | domain.LabelAttachedTo: "app.example.com", | |
| 4709 | + | domain.LabelImage: "postgres:16", | |
| 4710 | + | }, | |
| 4711 | + | } | |
| 4712 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{stopped}, nil).Once() | |
| 4713 | + | ||
| 4714 | + | orphans, err := svc.ListOrphanedAttachments(ctx) | |
| 4715 | + | require.NoError(t, err) | |
| 4716 | + | assert.Empty(t, orphans) | |
| 4717 | + | } | |
| 4718 | + | ||
| 4719 | + | func TestService_ListOrphanedAttachments_IncludesOwnerAndHonorsGroupConfig(t *testing.T) { | |
| 4720 | + | ctx := testContext() | |
| 4721 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 4722 | + | svc := NewService(runtime, nil, mocks.NewMockEventPublisher(t), nil, Config{ | |
| 4723 | + | NetworkIsolation: true, | |
| 4724 | + | NetworkGroups: map[string][]string{ | |
| 4725 | + | "backend": {"app.example.com"}, | |
| 4726 | + | }, | |
| 4727 | + | Attachments: map[string][]string{ | |
| 4728 | + | "backend": {"postgres:16"}, | |
| 4729 | + | }, | |
| 4730 | + | }, nil) | |
| 4731 | + | configuredByGroup := &domain.Container{ | |
| 4732 | + | ID: "postgres-1", | |
| 4733 | + | Name: "gordon-app-example-com-postgres", | |
| 4734 | + | Image: "postgres:16", | |
| 4735 | + | Status: string(domain.ContainerStatusRunning), | |
| 4736 | + | Labels: map[string]string{ | |
| 4737 | + | domain.LabelManaged: "true", | |
| 4738 | + | domain.LabelAttachment: "true", | |
| 4739 | + | domain.LabelAttachedTo: "app.example.com", | |
| 4740 | + | domain.LabelImage: "postgres:16", | |
| 4741 | + | }, | |
| 4742 | + | } | |
| 4743 | + | orphan := &domain.Container{ | |
| 4744 | + | ID: "redis-1", | |
| 4745 | + | Name: "gordon-app-example-com-redis", | |
| 4746 | + | Image: "redis:7", | |
| 4747 | + | Status: string(domain.ContainerStatusRunning), | |
| 4748 | + | Labels: map[string]string{ | |
| 4749 | + | domain.LabelManaged: "true", | |
| 4750 | + | domain.LabelAttachment: "true", | |
| 4751 | + | domain.LabelAttachedTo: "app.example.com", | |
| 4752 | + | domain.LabelImage: "redis:7", | |
| 4753 | + | }, | |
| 4754 | + | } | |
| 4755 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{configuredByGroup, orphan}, nil).Once() | |
| 4756 | + | ||
| 4757 | + | orphans, err := svc.ListOrphanedAttachments(ctx) | |
| 4758 | + | require.NoError(t, err) | |
| 4759 | + | require.Len(t, orphans, 1) | |
| 4760 | + | assert.Equal(t, "redis-1", orphans[0].ContainerID) | |
| 4761 | + | assert.Equal(t, "app.example.com", orphans[0].Owner) | |
| 4762 | + | } | |
| @@ -4418,3 +4418,345 @@ func TestService_BuildContainerConfig_SetsRestartPolicyAlways(t *testing.T) { | |||||
| 4418 | 4418 | ||||
| 4419 | assert.Equal(t, domain.RestartPolicyAlways, cfg.RestartPolicy) | 4419 | assert.Equal(t, domain.RestartPolicyAlways, cfg.RestartPolicy) | ||
| 4420 | } | 4420 | } | ||
| 4421 | + | ||||
| 4422 | + | func TestService_ReconcileRemovedRoute_RemovesOnlyMainRouteContainers(t *testing.T) { | |||
| 4423 | + | ctx := testContext() | |||
| 4424 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 4425 | + | svc := NewService(runtime, nil, mocks.NewMockEventPublisher(t), nil, Config{}, nil) | |||
| 4426 | + | ||||
| 4427 | + | mainContainer := &domain.Container{ | |||
| 4428 | + | ID: "route-container", | |||
| 4429 | + | Name: "gordon-app.example.com", | |||
| 4430 | + | Status: string(domain.ContainerStatusRunning), | |||
| 4431 | + | Labels: map[string]string{ | |||
| 4432 | + | domain.LabelManaged: "true", | |||
| 4433 | + | domain.LabelRoute: "app.example.com", | |||
| 4434 | + | domain.LabelDomain: "app.example.com", | |||
| 4435 | + | }, | |||
| 4436 | + | } | |||
| 4437 | + | attachment := &domain.Container{ | |||
| 4438 | + | ID: "attachment-container", | |||
| 4439 | + | Name: "gordon-app-example-com-postgres", | |||
| 4440 | + | Status: string(domain.ContainerStatusRunning), | |||
| 4441 | + | Labels: map[string]string{ | |||
| 4442 | + | domain.LabelManaged: "true", | |||
| 4443 | + | domain.LabelAttachment: "true", | |||
| 4444 | + | domain.LabelAttachedTo: "app.example.com", | |||
| 4445 | + | }, | |||
| 4446 | + | } | |||
| 4447 | + | ||||
| 4448 | + | svc.mu.Lock() | |||
| 4449 | + | svc.containers["app.example.com"] = mainContainer | |||
| 4450 | + | svc.attachments["app.example.com"] = []string{attachment.ID} | |||
| 4451 | + | svc.managedCount = 1 | |||
| 4452 | + | svc.mu.Unlock() | |||
| 4453 | + | ||||
| 4454 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{mainContainer, attachment}, nil).Once() | |||
| 4455 | + | runtime.EXPECT().StopContainer(mock.Anything, mainContainer.ID).Return(nil).Once() | |||
| 4456 | + | runtime.EXPECT().RemoveContainer(mock.Anything, mainContainer.ID, true).Return(nil).Once() | |||
| 4457 | + | ||||
| 4458 | + | report, err := svc.ReconcileRemovedRoute(ctx, "app.example.com") | |||
| 4459 | + | require.NoError(t, err) | |||
| 4460 | + | require.NotNil(t, report) | |||
| 4461 | + | ||||
| 4462 | + | assert.Equal(t, "app.example.com", report.Domain) | |||
| 4463 | + | require.Len(t, report.RemovedContainers, 1) | |||
| 4464 | + | assert.Equal(t, mainContainer.ID, report.RemovedContainers[0].ID) | |||
| 4465 | + | require.Len(t, report.PreservedAttachments, 1) | |||
| 4466 | + | assert.Equal(t, attachment.ID, report.PreservedAttachments[0].ContainerID) | |||
| 4467 | + | assert.Contains(t, report.Hints, "attachments preserved; remove or purge attachments explicitly if they are no longer needed") | |||
| 4468 | + | ||||
| 4469 | + | _, exists := svc.Get(ctx, "app.example.com") | |||
| 4470 | + | assert.False(t, exists, "removed route must be cleared from in-memory tracking so monitor cannot restart it") | |||
| 4471 | + | ||||
| 4472 | + | svc.mu.RLock() | |||
| 4473 | + | _, attachmentsTracked := svc.attachments["app.example.com"] | |||
| 4474 | + | svc.mu.RUnlock() | |||
| 4475 | + | assert.True(t, attachmentsTracked, "route cleanup preserves attachment tracking for follow-up cleanup") | |||
| 4476 | + | } | |||
| 4477 | + | ||||
| 4478 | + | func TestService_ReconcileRemovedRoute_IsIdempotentWhenNoContainerExists(t *testing.T) { | |||
| 4479 | + | ctx := testContext() | |||
| 4480 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 4481 | + | svc := NewService(runtime, nil, mocks.NewMockEventPublisher(t), nil, Config{}, nil) | |||
| 4482 | + | ||||
| 4483 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Once() | |||
| 4484 | + | ||||
| 4485 | + | report, err := svc.ReconcileRemovedRoute(ctx, "missing.example.com") | |||
| 4486 | + | require.NoError(t, err) | |||
| 4487 | + | require.NotNil(t, report) | |||
| 4488 | + | ||||
| 4489 | + | assert.Equal(t, "missing.example.com", report.Domain) | |||
| 4490 | + | assert.Empty(t, report.RemovedContainers) | |||
| 4491 | + | assert.Contains(t, report.Warnings, "no active route container found for removed route") | |||
| 4492 | + | } | |||
| 4493 | + | ||||
| 4494 | + | func TestService_ReconcileRemovedRoute_CanonicalizesDomainBeforeCleanup(t *testing.T) { | |||
| 4495 | + | ctx := testContext() | |||
| 4496 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 4497 | + | svc := NewService(runtime, nil, mocks.NewMockEventPublisher(t), nil, Config{}, nil) | |||
| 4498 | + | ||||
| 4499 | + | mainContainer := &domain.Container{ | |||
| 4500 | + | ID: "route-container", | |||
| 4501 | + | Name: "gordon-app.example.com", | |||
| 4502 | + | Status: string(domain.ContainerStatusRunning), | |||
| 4503 | + | Labels: map[string]string{ | |||
| 4504 | + | domain.LabelManaged: "true", | |||
| 4505 | + | domain.LabelRoute: "app.example.com", | |||
| 4506 | + | domain.LabelDomain: "app.example.com", | |||
| 4507 | + | }, | |||
| 4508 | + | } | |||
| 4509 | + | svc.mu.Lock() | |||
| 4510 | + | svc.containers["app.example.com"] = mainContainer | |||
| 4511 | + | svc.managedCount = 1 | |||
| 4512 | + | svc.mu.Unlock() | |||
| 4513 | + | ||||
| 4514 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{mainContainer}, nil).Once() | |||
| 4515 | + | runtime.EXPECT().StopContainer(mock.Anything, mainContainer.ID).Return(nil).Once() | |||
| 4516 | + | runtime.EXPECT().RemoveContainer(mock.Anything, mainContainer.ID, true).Return(nil).Once() | |||
| 4517 | + | ||||
| 4518 | + | report, err := svc.ReconcileRemovedRoute(ctx, "App.EXAMPLE.com") | |||
| 4519 | + | require.NoError(t, err) | |||
| 4520 | + | require.NotNil(t, report) | |||
| 4521 | + | assert.Equal(t, "app.example.com", report.Domain) | |||
| 4522 | + | require.Len(t, report.RemovedContainers, 1) | |||
| 4523 | + | ||||
| 4524 | + | _, exists := svc.Get(ctx, "app.example.com") | |||
| 4525 | + | assert.False(t, exists) | |||
| 4526 | + | } | |||
| 4527 | + | ||||
| 4528 | + | func TestService_ReconcileRemovedRoute_StopsLogCollectionForRemovedContainer(t *testing.T) { | |||
| 4529 | + | ctx := testContext() | |||
| 4530 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 4531 | + | logWriter := mocks.NewMockContainerLogWriter(t) | |||
| 4532 | + | svc := NewService(runtime, nil, mocks.NewMockEventPublisher(t), logWriter, Config{}, nil) | |||
| 4533 | + | ||||
| 4534 | + | mainContainer := &domain.Container{ | |||
| 4535 | + | ID: "route-container", | |||
| 4536 | + | Name: "gordon-app.example.com", | |||
| 4537 | + | Status: string(domain.ContainerStatusRunning), | |||
| 4538 | + | Labels: map[string]string{ | |||
| 4539 | + | domain.LabelManaged: "true", | |||
| 4540 | + | domain.LabelRoute: "app.example.com", | |||
| 4541 | + | }, | |||
| 4542 | + | } | |||
| 4543 | + | ||||
| 4544 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{mainContainer}, nil).Once() | |||
| 4545 | + | logWriter.EXPECT().StopLogging(mainContainer.ID).Return(nil).Once() | |||
| 4546 | + | runtime.EXPECT().StopContainer(mock.Anything, mainContainer.ID).Return(nil).Once() | |||
| 4547 | + | runtime.EXPECT().RemoveContainer(mock.Anything, mainContainer.ID, true).Return(nil).Once() | |||
| 4548 | + | ||||
| 4549 | + | _, err := svc.ReconcileRemovedRoute(ctx, "app.example.com") | |||
| 4550 | + | require.NoError(t, err) | |||
| 4551 | + | } | |||
| 4552 | + | ||||
| 4553 | + | func TestService_ReconcileRemovedRoute_InvalidatesProxyCacheAndMetric(t *testing.T) { | |||
| 4554 | + | ctx := testContext() | |||
| 4555 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 4556 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 4557 | + | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | |||
| 4558 | + | svc := NewService(runtime, nil, eventBus, nil, Config{}, nil) | |||
| 4559 | + | ||||
| 4560 | + | metrics, reader := setupMetricsTest(t) | |||
| 4561 | + | svc.SetMetrics(metrics) | |||
| 4562 | + | ||||
| 4563 | + | mainContainer := &domain.Container{ | |||
| 4564 | + | ID: "route-container", | |||
| 4565 | + | Name: "gordon-app.example.com", | |||
| 4566 | + | Status: string(domain.ContainerStatusRunning), | |||
| 4567 | + | Labels: map[string]string{ | |||
| 4568 | + | domain.LabelManaged: "true", | |||
| 4569 | + | domain.LabelRoute: "app.example.com", | |||
| 4570 | + | }, | |||
| 4571 | + | } | |||
| 4572 | + | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil).Once() | |||
| 4573 | + | svc.activateDeployedContainer(ctx, "app.example.com", mainContainer) | |||
| 4574 | + | svc.SetProxyCacheInvalidator(cacheInvalidator) | |||
| 4575 | + | ||||
| 4576 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{mainContainer}, nil).Once() | |||
| 4577 | + | cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "app.example.com").Return().Once() | |||
| 4578 | + | runtime.EXPECT().StopContainer(mock.Anything, mainContainer.ID).Return(nil).Once() | |||
| 4579 | + | runtime.EXPECT().RemoveContainer(mock.Anything, mainContainer.ID, true).Return(nil).Once() | |||
| 4580 | + | ||||
| 4581 | + | _, err := svc.ReconcileRemovedRoute(ctx, "app.example.com") | |||
| 4582 | + | require.NoError(t, err) | |||
| 4583 | + | ||||
| 4584 | + | value, _, _ := managedMetricState(t, reader) | |||
| 4585 | + | assert.Equal(t, int64(0), value) | |||
| 4586 | + | } | |||
| 4587 | + | ||||
| 4588 | + | func TestService_ListOrphanedAttachments_DetectsRunningAttachmentNoLongerConfigured(t *testing.T) { | |||
| 4589 | + | ctx := testContext() | |||
| 4590 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 4591 | + | svc := NewService(runtime, nil, mocks.NewMockEventPublisher(t), nil, Config{Attachments: map[string][]string{ | |||
| 4592 | + | "app.example.com": {"redis:7"}, | |||
| 4593 | + | }}, nil) | |||
| 4594 | + | ||||
| 4595 | + | orphan := &domain.Container{ | |||
| 4596 | + | ID: "postgres-1", | |||
| 4597 | + | Name: "gordon-app-example-com-postgres", | |||
| 4598 | + | Image: "postgres:16", | |||
| 4599 | + | Status: string(domain.ContainerStatusRunning), | |||
| 4600 | + | Labels: map[string]string{ | |||
| 4601 | + | domain.LabelManaged: "true", | |||
| 4602 | + | domain.LabelAttachment: "true", | |||
| 4603 | + | domain.LabelAttachedTo: "app.example.com", | |||
| 4604 | + | domain.LabelImage: "postgres:16", | |||
| 4605 | + | }, | |||
| 4606 | + | } | |||
| 4607 | + | configured := &domain.Container{ | |||
| 4608 | + | ID: "redis-1", | |||
| 4609 | + | Name: "gordon-app-example-com-redis", | |||
| 4610 | + | Image: "redis:7", | |||
| 4611 | + | Status: string(domain.ContainerStatusRunning), | |||
| 4612 | + | Labels: map[string]string{ | |||
| 4613 | + | domain.LabelManaged: "true", | |||
| 4614 | + | domain.LabelAttachment: "true", | |||
| 4615 | + | domain.LabelAttachedTo: "app.example.com", | |||
| 4616 | + | domain.LabelImage: "redis:7", | |||
| 4617 | + | }, | |||
| 4618 | + | } | |||
| 4619 | + | ||||
| 4620 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{orphan, configured}, nil).Once() | |||
| 4621 | + | ||||
| 4622 | + | orphans, err := svc.ListOrphanedAttachments(ctx) | |||
| 4623 | + | require.NoError(t, err) | |||
| 4624 | + | require.Len(t, orphans, 1) | |||
| 4625 | + | assert.Equal(t, orphan.ID, orphans[0].ContainerID) | |||
| 4626 | + | assert.Equal(t, "postgres:16", orphans[0].Image) | |||
| 4627 | + | assert.Contains(t, orphans[0].Reason, "no longer configured") | |||
| 4628 | + | } | |||
| 4629 | + | ||||
| 4630 | + | func TestService_CleanupOrphanedAttachments_DryRunPreservesRunningContainer(t *testing.T) { | |||
| 4631 | + | ctx := testContext() | |||
| 4632 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 4633 | + | svc := NewService(runtime, nil, mocks.NewMockEventPublisher(t), nil, Config{}, nil) | |||
| 4634 | + | orphan := &domain.Container{ | |||
| 4635 | + | ID: "postgres-1", | |||
| 4636 | + | Name: "gordon-app-example-com-postgres", | |||
| 4637 | + | Image: "postgres:16", | |||
| 4638 | + | Status: string(domain.ContainerStatusRunning), | |||
| 4639 | + | Labels: map[string]string{ | |||
| 4640 | + | domain.LabelManaged: "true", | |||
| 4641 | + | domain.LabelAttachment: "true", | |||
| 4642 | + | domain.LabelAttachedTo: "app.example.com", | |||
| 4643 | + | domain.LabelImage: "postgres:16", | |||
| 4644 | + | }, | |||
| 4645 | + | } | |||
| 4646 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{orphan}, nil).Once() | |||
| 4647 | + | ||||
| 4648 | + | report, err := svc.CleanupOrphanedAttachments(ctx, "", false) | |||
| 4649 | + | require.NoError(t, err) | |||
| 4650 | + | require.NotNil(t, report) | |||
| 4651 | + | require.Len(t, report.PreservedAttachments, 1) | |||
| 4652 | + | assert.Equal(t, orphan.ID, report.PreservedAttachments[0].ContainerID) | |||
| 4653 | + | runtime.AssertNotCalled(t, "StopContainer", mock.Anything, orphan.ID) | |||
| 4654 | + | runtime.AssertNotCalled(t, "RemoveContainer", mock.Anything, orphan.ID, mock.Anything) | |||
| 4655 | + | } | |||
| 4656 | + | ||||
| 4657 | + | func TestService_CleanupOrphanedAttachments_StopRemovesContainerButPreservesData(t *testing.T) { | |||
| 4658 | + | ctx := testContext() | |||
| 4659 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 4660 | + | logWriter := mocks.NewMockContainerLogWriter(t) | |||
| 4661 | + | svc := NewService(runtime, nil, mocks.NewMockEventPublisher(t), logWriter, Config{}, nil) | |||
| 4662 | + | orphan := &domain.Container{ | |||
| 4663 | + | ID: "postgres-1", | |||
| 4664 | + | Name: "gordon-app-example-com-postgres", | |||
| 4665 | + | Image: "postgres:16", | |||
| 4666 | + | Status: string(domain.ContainerStatusRunning), | |||
| 4667 | + | Labels: map[string]string{ | |||
| 4668 | + | domain.LabelManaged: "true", | |||
| 4669 | + | domain.LabelAttachment: "true", | |||
| 4670 | + | domain.LabelAttachedTo: "app.example.com", | |||
| 4671 | + | domain.LabelImage: "postgres:16", | |||
| 4672 | + | }, | |||
| 4673 | + | } | |||
| 4674 | + | svc.mu.Lock() | |||
| 4675 | + | svc.attachments["app.example.com"] = []string{orphan.ID} | |||
| 4676 | + | svc.mu.Unlock() | |||
| 4677 | + | ||||
| 4678 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{orphan}, nil).Once() | |||
| 4679 | + | logWriter.EXPECT().StopLogging(orphan.ID).Return(nil).Once() | |||
| 4680 | + | runtime.EXPECT().StopContainer(mock.Anything, orphan.ID).Return(nil).Once() | |||
| 4681 | + | runtime.EXPECT().RemoveContainer(mock.Anything, orphan.ID, true).Return(nil).Once() | |||
| 4682 | + | ||||
| 4683 | + | report, err := svc.CleanupOrphanedAttachments(ctx, "", true) | |||
| 4684 | + | require.NoError(t, err) | |||
| 4685 | + | require.NotNil(t, report) | |||
| 4686 | + | require.Len(t, report.RemovedContainers, 1) | |||
| 4687 | + | assert.Equal(t, orphan.ID, report.RemovedContainers[0].ID) | |||
| 4688 | + | assert.Contains(t, report.Hints, "attachment volumes and data were preserved") | |||
| 4689 | + | ||||
| 4690 | + | svc.mu.RLock() | |||
| 4691 | + | ids := svc.attachments["app.example.com"] | |||
| 4692 | + | svc.mu.RUnlock() | |||
| 4693 | + | assert.Empty(t, ids) | |||
| 4694 | + | } | |||
| 4695 | + | ||||
| 4696 | + | func TestService_ListOrphanedAttachments_IgnoresStoppedAttachments(t *testing.T) { | |||
| 4697 | + | ctx := testContext() | |||
| 4698 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 4699 | + | svc := NewService(runtime, nil, mocks.NewMockEventPublisher(t), nil, Config{}, nil) | |||
| 4700 | + | stopped := &domain.Container{ | |||
| 4701 | + | ID: "postgres-1", | |||
| 4702 | + | Name: "gordon-app-example-com-postgres", | |||
| 4703 | + | Image: "postgres:16", | |||
| 4704 | + | Status: string(domain.ContainerStatusExited), | |||
| 4705 | + | Labels: map[string]string{ | |||
| 4706 | + | domain.LabelManaged: "true", | |||
| 4707 | + | domain.LabelAttachment: "true", | |||
| 4708 | + | domain.LabelAttachedTo: "app.example.com", | |||
| 4709 | + | domain.LabelImage: "postgres:16", | |||
| 4710 | + | }, | |||
| 4711 | + | } | |||
| 4712 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{stopped}, nil).Once() | |||
| 4713 | + | ||||
| 4714 | + | orphans, err := svc.ListOrphanedAttachments(ctx) | |||
| 4715 | + | require.NoError(t, err) | |||
| 4716 | + | assert.Empty(t, orphans) | |||
| 4717 | + | } | |||
| 4718 | + | ||||
| 4719 | + | func TestService_ListOrphanedAttachments_IncludesOwnerAndHonorsGroupConfig(t *testing.T) { | |||
| 4720 | + | ctx := testContext() | |||
| 4721 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 4722 | + | svc := NewService(runtime, nil, mocks.NewMockEventPublisher(t), nil, Config{ | |||
| 4723 | + | NetworkIsolation: true, | |||
| 4724 | + | NetworkGroups: map[string][]string{ | |||
| 4725 | + | "backend": {"app.example.com"}, | |||
| 4726 | + | }, | |||
| 4727 | + | Attachments: map[string][]string{ | |||
| 4728 | + | "backend": {"postgres:16"}, | |||
| 4729 | + | }, | |||
| 4730 | + | }, nil) | |||
| 4731 | + | configuredByGroup := &domain.Container{ | |||
| 4732 | + | ID: "postgres-1", | |||
| 4733 | + | Name: "gordon-app-example-com-postgres", | |||
| 4734 | + | Image: "postgres:16", | |||
| 4735 | + | Status: string(domain.ContainerStatusRunning), | |||
| 4736 | + | Labels: map[string]string{ | |||
| 4737 | + | domain.LabelManaged: "true", | |||
| 4738 | + | domain.LabelAttachment: "true", | |||
| 4739 | + | domain.LabelAttachedTo: "app.example.com", | |||
| 4740 | + | domain.LabelImage: "postgres:16", | |||
| 4741 | + | }, | |||
| 4742 | + | } | |||
| 4743 | + | orphan := &domain.Container{ | |||
| 4744 | + | ID: "redis-1", | |||
| 4745 | + | Name: "gordon-app-example-com-redis", | |||
| 4746 | + | Image: "redis:7", | |||
| 4747 | + | Status: string(domain.ContainerStatusRunning), | |||
| 4748 | + | Labels: map[string]string{ | |||
| 4749 | + | domain.LabelManaged: "true", | |||
| 4750 | + | domain.LabelAttachment: "true", | |||
| 4751 | + | domain.LabelAttachedTo: "app.example.com", | |||
| 4752 | + | domain.LabelImage: "redis:7", | |||
| 4753 | + | }, | |||
| 4754 | + | } | |||
| 4755 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{configuredByGroup, orphan}, nil).Once() | |||
| 4756 | + | ||||
| 4757 | + | orphans, err := svc.ListOrphanedAttachments(ctx) | |||
| 4758 | + | require.NoError(t, err) | |||
| 4759 | + | require.Len(t, orphans, 1) | |||
| 4760 | + | assert.Equal(t, "redis-1", orphans[0].ContainerID) | |||
| 4761 | + | assert.Equal(t, "app.example.com", orphans[0].Owner) | |||
| 4762 | + | } | |||