detached 1290c78

Merge pull request #200 from bnema/feat/deletion-cleanup-lifecycle

feat(cleanup): reconcile removed runtime entities
commit
1290c78385689a2a48bc154a35eab9e21db38bd1
author
bnema · · 2026-05-22 04:15 UTC
committer
GitHub · · 2026-05-22 04:15 UTC
merge of
4106bb1 + 33a66ec · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
29 files · +5128 -41
M .mockery.yaml +1 -0
@@ -52,3 +52,4 @@ packages:
5252 github.com/bnema/gordon/internal/adapters/in/cli:
5353 interfaces:
5454 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
2424 | `add` | Add an attachment to a domain or network group |
2525 | `push` | Build or push attachment images to the Gordon registry |
2626 | `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 |
2729
2830 ### Alias
2931
@@ -93,6 +95,71 @@ gordon attachments list --json
9395
9496 ---
9597
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+
96163 ## gordon attachments add
97164
98165 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 ### Alias30 ### 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 add163 ## 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
275275
276276 ---
277277
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+
278358 ## gordon routes remove
279359
280360 Remove a route.
281361
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+
282364 ```bash
283365 gordon routes remove <domain>
284366 gordon routes remove myapp.example.com
@@ -305,6 +387,15 @@ gordon routes remove myapp.example.com
305387
306388 # Remote (override)
307389 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)
308399 ```
309400
310401 ---
@@ -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 remove358 ## 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 ```bash364 ```bash
283 gordon routes remove <domain>365 gordon routes remove <domain>
284 gordon routes remove myapp.example.com366 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 $TOKEN389 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: [══════════════════
5959 ↑ start ↑ traffic routed
6060 ```
6161
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+
6284 ## Routes
6385
6486 Routes map domains to container images:
@@ -59,6 +59,28 @@ New Container: [══════════════════
59 ↑ start ↑ traffic routed59 ↑ 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 ## Routes84 ## 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 {
5050
5151 // RouteDeleteResponse represents a route removal response.
5252 type RouteDeleteResponse struct {
53- Status string `json:"status"`
53+ Status string `json:"status"`
54+ Cleanup *CleanupReport `json:"cleanup,omitempty"`
5455 }
@@ -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 (
99 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
1010 "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
1111 "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
12+ "github.com/bnema/gordon/internal/domain"
1213
1314 "github.com/spf13/cobra"
1415 )
@@ -32,6 +33,8 @@ the local Gordon configuration.`,
3233 cmd.AddCommand(newAttachmentsListCmd())
3334 cmd.AddCommand(newAttachmentsAddCmd())
3435 cmd.AddCommand(newAttachmentsRemoveCmd())
36+ cmd.AddCommand(newAttachmentsOrphansCmd())
37+ cmd.AddCommand(newAttachmentsPruneCmd())
3538 cmd.AddCommand(newAttachmentsPushCmd())
3639
3740 return cmd
@@ -277,6 +280,130 @@ func runAttachmentsListLocal(ctx context.Context, cfgPath string, target string,
277280 return nil
278281 }
279282
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+
280407 // newAttachmentsAddCmd creates the attachments add command.
281408 func newAttachmentsAddCmd() *cobra.Command {
282409 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 cmd40 return cmd
@@ -277,6 +280,130 @@ func runAttachmentsListLocal(ctx context.Context, cfgPath string, target string,
277 return nil280 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
8181 panic("unexpected call")
8282 }
8383
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+
8492 func (c *attachmentPushTestControlPlane) FindAttachmentTargetsByImage(ctx context.Context, imageName string) ([]string, error) {
8593 if c.findAttachmentTargets != nil {
8694 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 {
3030
3131 GetAllAttachmentsConfig(ctx context.Context) (map[string][]string, error)
3232 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)
3335 FindAttachmentTargetsByImage(ctx context.Context, imageName string) ([]string, error)
3436 AddAttachment(ctx context.Context, domainOrGroup, image string) error
3537 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) error36 AddAttachment(ctx context.Context, domainOrGroup, image string) error
35 RemoveAttachment(ctx context.Context, domainOrGroup, image string) error37 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)
130130 }
131131
132132 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) {
133138 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)
135151 }
136- return l.configSvc.RemoveRoute(ctx, routeDomain)
152+ resp.Cleanup = dto.CleanupReportFromDomain(report)
153+ return resp, nil
137154 }
138155
139156 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
239256
240257 keys, attachments, err := l.secretSvc.ListKeysWithAttachments(ctx, secretDomain)
241258 if err != nil {
242- return nil, err
259+ return nil, fmt.Errorf("list secret keys with attachments: %w", err)
243260 }
244261
245262 result := &remote.SecretsListResult{
@@ -284,6 +301,28 @@ func (l *localControlPlane) DeleteAttachmentSecret(ctx context.Context, domainNa
284301 return l.secretSvc.DeleteAttachment(ctx, domainName, service, key)
285302 }
286303
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+
287326 func (l *localControlPlane) GetAllAttachmentsConfig(ctx context.Context) (map[string][]string, error) {
288327 if l.configSvc == nil {
289328 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, err259+ 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 (
88 "github.com/bnema/gordon/internal/adapters/dto"
99 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
1010 "github.com/bnema/gordon/internal/domain"
11+ "github.com/stretchr/testify/assert"
1112 "github.com/stretchr/testify/mock"
1213 "github.com/stretchr/testify/require"
1314 )
@@ -110,6 +111,28 @@ func TestLocalControlPlane_ListTags(t *testing.T) {
110111 require.Equal(t, []string{"v1.0.0", "latest"}, tags)
111112 }
112113
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+
113136 func TestLocalControlPlane_RestartWithAttachments(t *testing.T) {
114137 t.Parallel()
115138
@@ -192,3 +215,19 @@ func TestLocalControlPlane_GetContainerLogs(t *testing.T) {
192215 require.NoError(t, err)
193216 require.Equal(t, []string{"line1", "line2"}, lines)
194217 }
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
4141 }
4242
4343 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)
4550 }
4651
4752 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
6873 return r.client.DeleteAttachmentSecret(ctx, domainName, service, key)
6974 }
7075
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+
7184 func (r *remoteControlPlane) GetAllAttachmentsConfig(ctx context.Context) (map[string][]string, error) {
7285 return r.client.GetAllAttachmentsConfig(ctx)
7386 }
@@ -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,
9090 panic("unexpected call")
9191 }
9292
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+
93101 func (c *resolveFromImageTestControlPlane) FindAttachmentTargetsByImage(context.Context, string) ([]string, error) {
94102 panic("unexpected call")
95103 }
@@ -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 {
548548
549549 // RemoveRoute removes a route by domain.
550550 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) {
551557 resp, err := c.request(ctx, http.MethodDelete, "/routes/"+url.PathEscape(routeDomain), nil)
552558 if err != nil {
553- return err
559+ return nil, fmt.Errorf("delete route %s failed: %w", routeDomain, err)
554560 }
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
556566 }
557567
558568 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
10251035
10261036 // Attachments Config API
10271037
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+
10281077 // GetAllAttachmentsConfig returns all configured attachments.
10291078 func (c *Client) GetAllAttachmentsConfig(ctx context.Context) (map[string][]string, error) {
10301079 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 err559+ 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 API1036 // 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 (
1313
1414 "github.com/spf13/cobra"
1515
16+ "github.com/bnema/gordon/internal/adapters/dto"
1617 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
1718 "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
1819 "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
@@ -127,6 +128,18 @@ type routeStatusSection struct {
127128 Routes []routeStatusItem `json:"routes,omitempty"`
128129 }
129130
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+
130143 type routesListDeps struct {
131144 explicitRemote func() (*remote.ResolvedRemote, bool, error)
132145 loadLocal func(context.Context, string) (routeListSection, error)
@@ -194,6 +207,8 @@ the local Gordon configuration.`,
194207 cmd.AddCommand(newRoutesStatusCmd())
195208
196209 cmd.AddCommand(newRoutesShowCmd())
210+ cmd.AddCommand(newRoutesDiagnoseCmd())
211+ cmd.AddCommand(newRoutesPurgeCmd())
197212 cmd.AddCommand(newRoutesAddCmd())
198213 cmd.AddCommand(newRoutesRemoveCmd())
199214
@@ -1078,7 +1093,287 @@ func writeRouteShowText(out io.Writer, domainName, image, containerStatus string
10781093 return nil
10791094 }
10801095
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 = &copy
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+
10821377 func newRoutesAddCmd() *cobra.Command {
10831378 var image string
10841379
@@ -1183,12 +1478,22 @@ Examples:
11831478 return err
11841479 }
11851480 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 {
11871493 return fmt.Errorf("failed to remove route: %w", err)
11881494 }
11891495
1190- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Route removed: %s", routeDomain)))
1191- return nil
1496+ return writeRouteRemoveText(cmd.OutOrStdout(), routeDomain, cleanup)
11921497 },
11931498 }
11941499
@@ -1197,6 +1502,105 @@ Examples:
11971502 return cmd
11981503 }
11991504
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+
12001604 // newStatusCmd creates the status command.
12011605 func newStatusCmd() *cobra.Command {
12021606 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 nil1093 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 = &copy
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 string1378 var image string
1084 1379
@@ -1183,12 +1478,22 @@ Examples:
1183 return err1478 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 cmd1502 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 (
1313 "sync/atomic"
1414 "testing"
1515
16+ "github.com/bnema/gordon/internal/adapters/dto"
17+ climocks "github.com/bnema/gordon/internal/adapters/in/cli/mocks"
1618 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
1719 "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
1820 "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
@@ -39,6 +41,14 @@ func (c *routesShowTestControlPlane) GetHealth(ctx context.Context) (map[string]
3941 panic("unexpected call")
4042 }
4143
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+
4252 func TestTruncateImage(t *testing.T) {
4353 tests := []struct {
4454 name string
@@ -885,6 +895,35 @@ func TestRenderRoutesStatusSections_KeepsLocalErrorWhenAllRemotesFail(t *testing
885895 assert.Contains(t, rendered, "dial tcp timeout")
886896 }
887897
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+
888927 func TestBuildRouteStatusTree_DeterministicAcrossInputOrder(t *testing.T) {
889928 routes := []routeStatusItem{
890929 {Domain: "zulu.example", Image: "zulu:v1", Network: "gordon-zulu", ContainerStatus: "running"},
@@ -901,3 +940,38 @@ func TestBuildRouteStatusTree_DeterministicAcrossInputOrder(t *testing.T) {
901940
902941 assert.Equal(t, outA.String(), outB.String())
903942 }
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 string54 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
8282 panic("unexpected call")
8383 }
8484
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+
8593 func (c *tlsTestControlPlane) FindAttachmentTargetsByImage(context.Context, string) ([]string, error) {
8694 panic("unexpected call")
8795 }
@@ -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)
229229 func (h *Handler) matchRoute(path string) (routeHandler, bool) {
230230 // Exact match routes
231231 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) },
242244 }
243245 if handler, ok := exactRoutes[path]; ok {
244246 return handler, true
@@ -274,6 +276,62 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) {
274276 return nil, false
275277 }
276278
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+
277335 // handleAttachmentsByImage handles GET /admin/attachments/by-image/{image} endpoint.
278336 // Returns all attachment targets associated with the given image name.
279337 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
721779 }
722780
723781 if err := h.configSvc.RemoveRoute(ctx, routeDomain); err != nil {
724- log.Error().Err(err).Str("domain", routeDomain).Msg("failed to remove route")
725782 switch {
726783 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")
728785 case errors.Is(err, domain.ErrRouteDomainEmpty), errors.Is(err, domain.ErrRouteDomainInvalid):
786+ log.Error().Err(err).Str("domain", routeDomain).Msg("failed to remove route")
729787 h.sendError(w, http.StatusBadRequest, "invalid route domain")
788+ return
730789 default:
790+ log.Error().Err(err).Str("domain", routeDomain).Msg("failed to remove route")
731791 h.sendError(w, http.StatusInternalServerError, "failed to remove route")
792+ return
732793 }
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)
734805 }
735806
736807 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})
738809 }
739810
740811 // 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 routes230 // 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, true246 return handler, true
@@ -274,6 +276,62 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) {
274 return nil, false276 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- return794+ }
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) {
720720 for _, tt := range tests {
721721 t.Run(tt.name, func(t *testing.T) {
722722 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()
724725 }
725726
726727 req := httptest.NewRequest("DELETE", "/admin/routes/app.example.com", nil)
@@ -734,6 +735,115 @@ func TestHandler_RoutesDelete_RequiresWriteScope(t *testing.T) {
734735 }
735736 }
736737
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+
737847 // Secrets endpoint tests
738848
739849 func TestHandler_SecretsGet_RequiresReadScope(t *testing.T) {
@@ -2712,3 +2822,133 @@ func TestHandler_TLSStatus_POSTReturns405(t *testing.T) {
27122822
27132823 assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode)
27142824 }
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 tests847 // 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 {
2020 // Remove removes a container, optionally forcing removal.
2121 Remove(ctx context.Context, containerID string, force bool) error
2222
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+
2327 // Get retrieves a container by domain name.
2428 Get(ctx context.Context, domain string) (*domain.Container, bool)
2529
@@ -36,6 +40,13 @@ type ContainerService interface {
3640 // ListAttachments returns attachments for a domain.
3741 ListAttachments(ctx context.Context, domain string) []domain.Attachment
3842
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+
3950 // ListNetworks returns Gordon-managed networks.
4051 ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error)
4152
@@ -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) error21 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.Attachment41 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
9595 return _c
9696 }
9797
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+
98172 // Deploy provides a mock function for the type MockContainerService
99173 func (_mock *MockContainerService) Deploy(ctx context.Context, route domain.Route) (*domain.Container, error) {
100174 ret := _mock.Called(ctx, route)
@@ -458,6 +532,68 @@ func (_c *MockContainerService_ListNetworks_Call) RunAndReturn(run func(ctx cont
458532 return _c
459533 }
460534
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+
461597 // ListRoutesWithDetails provides a mock function for the type MockContainerService
462598 func (_mock *MockContainerService) ListRoutesWithDetails(ctx context.Context) []domain.RouteInfo {
463599 ret := _mock.Called(ctx)
@@ -511,6 +647,74 @@ func (_c *MockContainerService_ListRoutesWithDetails_Call) RunAndReturn(run func
511647 return _c
512648 }
513649
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+
514718 // Remove provides a mock function for the type MockContainerService
515719 func (_mock *MockContainerService) Remove(ctx context.Context, containerID string, force bool) error {
516720 ret := _mock.Called(ctx, containerID, force)
@@ -95,6 +95,80 @@ func (_c *MockContainerService_AutoStart_Call) RunAndReturn(run func(ctx context
95 return _c95 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 MockContainerService172 // 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 _c532 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 MockContainerService597 // 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 _c647 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 MockContainerService718 // 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
117117 currentContainers := h.containerSvc.List(ctx)
118118
119119 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 != "" {
122122 activeRoutes[route] = container
123123 }
124124 }
@@ -156,14 +156,10 @@ func (h *ConfigReloadHandler) Handle(ctx context.Context, event domain.Event) er
156156 log.Info().
157157 Str("domain", route).
158158 Str(zerowrap.FieldEntityID, container.ID).
159- Msg("route no longer configured, stopping container")
159+ Msg("route no longer configured, reconciling removed route runtime state")
160160
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})
167163 }
168164 }
169165
@@ -175,6 +171,21 @@ func (h *ConfigReloadHandler) CanHandle(eventType domain.EventType) bool {
175171 return eventType == domain.EventConfigReload
176172 }
177173
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+
178189 // ManualDeployHandler handles manual.deploy events for specific routes.
179190 type ManualDeployHandler struct {
180191 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] = container122 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.EventConfigReload171 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.ContainerService191 containerSvc in.ContainerService
M internal/usecase/container/events_test.go +29 -2
@@ -261,8 +261,7 @@ func TestConfigReloadHandler_Handle_StopsRemovedRoutes(t *testing.T) {
261261 // Empty routes - the route was removed
262262 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{})
263263
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)
266265
267266 event := domain.Event{
268267 ID: "event-123",
@@ -274,6 +273,34 @@ func TestConfigReloadHandler_Handle_StopsRemovedRoutes(t *testing.T) {
274273 assert.NoError(t, err)
275274 }
276275
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+
277304 func TestConfigReloadHandler_Handle_RedeploysChangedImage(t *testing.T) {
278305 containerSvc := inmocks.NewMockContainerService(t)
279306 configSvc := inmocks.NewMockConfigService(t)
@@ -261,8 +261,7 @@ func TestConfigReloadHandler_Handle_StopsRemovedRoutes(t *testing.T) {
261 // Empty routes - the route was removed261 // 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
134134 }
135135
136136 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+ }
137141 log.Warn().Str("domain", domainName).Msg("monitor: container is unhealthy, restarting")
138142 if err := m.service.runtime.RestartContainer(ctx, containerID); err != nil {
139143 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
159163 m.mu.Unlock()
160164 }
161165
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+
162173 func (m *Monitor) handleCrash(ctx context.Context, log zerowrap.Logger, domainName, containerID string, exitCode int, now time.Time) {
163174 m.mu.Lock()
164175 rec := m.history[domainName]
@@ -212,10 +223,7 @@ func (m *Monitor) handleCrash(ctx context.Context, log zerowrap.Logger, domainNa
212223 m.mu.Unlock()
213224
214225 // 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) {
219227 log.Debug().Str("domain", domainName).Msg("monitor: container replaced by deploy, skipping restart")
220228 return
221229 }
@@ -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 return228 return
221 }229 }
M internal/usecase/container/monitor_test.go +23 -0
@@ -127,6 +127,29 @@ func TestMonitor_RestartsUnhealthyContainer(t *testing.T) {
127127 runtime.AssertExpectations(t)
128128 }
129129
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+
130153 func TestMonitor_CrashLoopBackoff(t *testing.T) {
131154 runtime := mocks.NewMockContainerRuntime(t)
132155 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 {
11591159 return nil
11601160 }
11611161
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+
11621321 // Remove removes a container.
11631322 func (s *Service) Remove(ctx context.Context, containerID string, force bool) error {
11641323 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
@@ -1351,6 +1510,139 @@ func (s *Service) ListAttachments(ctx context.Context, domainName string) []doma
13511510 return s.getAttachmentsForDomain(ctx, domainName)
13521511 }
13531512
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+
13541646 // ListNetworks returns Gordon-managed networks.
13551647 func (s *Service) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) {
13561648 s.mu.RLock()
@@ -1159,6 +1159,165 @@ func (s *Service) Stop(ctx context.Context, containerID string) error {
1159 return nil1159 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) {
44184418
44194419 assert.Equal(t, domain.RestartPolicyAlways, cfg.RestartPolicy)
44204420 }
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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close