detached 0c06e0c

feat(cleanup): reconcile removed runtime entities

commit
0c06e0cebdb3adf22f5e59367609697f61cfed35
author
brice · · 2026-05-22 03:15 UTC
committer
brice · · 2026-05-22 03:39 UTC
parent
4106bb1
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
22 files · +2254 -40
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 +4 -1
@@ -1,6 +1,8 @@
11 // Package dto provides shared data transfer objects for API responses.
22 package dto
33
4+import "github.com/bnema/gordon/internal/domain"
5+
46 // Route represents route configuration in API responses.
57 type Route struct {
68 Domain string `json:"domain"`
@@ -50,5 +52,6 @@ type RoutesByImageResponse struct {
5052
5153 // RouteDeleteResponse represents a route removal response.
5254 type RouteDeleteResponse struct {
53- Status string `json:"status"`
55+ Status string `json:"status"`
56+ Cleanup *domain.CleanupReport `json:"cleanup,omitempty"`
5457 }
@@ -1,6 +1,8 @@
1 // Package dto provides shared data transfer objects for API responses.1 // Package dto provides shared data transfer objects for API responses.
2 package dto2 package dto
3 3
4+import "github.com/bnema/gordon/internal/domain"
5+
4 // Route represents route configuration in API responses.6 // Route represents route configuration in API responses.
5 type Route struct {7 type Route struct {
6 Domain string `json:"domain"`8 Domain string `json:"domain"`
@@ -50,5 +52,6 @@ type RoutesByImageResponse struct {
50 52
51 // RouteDeleteResponse represents a route removal response.53 // RouteDeleteResponse represents a route removal response.
52 type RouteDeleteResponse struct {54 type RouteDeleteResponse struct {
53- Status string `json:"status"`55+ Status string `json:"status"`
56+ Cleanup *domain.CleanupReport `json:"cleanup,omitempty"`
54 }57 }
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/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 = 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 = 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, 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, 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 }
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, err
554560 }
555- return parseResponse(resp, nil)
561+ var result dto.RouteDeleteResponse
562+ if err := parseResponse(resp, &result); err != nil {
563+ return nil, 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, err
1069+ }
1070+ var result domain.CleanupReport
1071+ if err := parseResponse(resp, &result); err != nil {
1072+ return nil, 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, err
554 }560 }
555- return parseResponse(resp, nil)561+ var result dto.RouteDeleteResponse
562+ if err := parseResponse(resp, &result); err != nil {
563+ return nil, 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, err
1069+ }
1070+ var result domain.CleanupReport
1071+ if err := parseResponse(resp, &result); err != nil {
1072+ return nil, 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 +379 -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,277 @@ 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+ handle, err := resolveControlPlaneForRouteDomain(cmd.Context(), args[0])
1105+ if err != nil {
1106+ return err
1107+ }
1108+ defer handle.close()
1109+ diag, err := buildRouteDiagnosis(cmd.Context(), handle.plane, args[0])
1110+ if err != nil {
1111+ return err
1112+ }
1113+ if jsonOut {
1114+ return writeJSON(cmd.OutOrStdout(), diag)
1115+ }
1116+ return writeRouteDiagnosisText(cmd.OutOrStdout(), diag)
1117+ },
1118+ }
1119+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
1120+ return cmd
1121+}
1122+
1123+func buildRouteDiagnosis(ctx context.Context, cp ControlPlane, domainName string) (*routeDiagnosis, error) {
1124+ diag := &routeDiagnosis{Domain: domainName}
1125+ loadRouteDiagnosisConfig(ctx, cp, domainName, diag)
1126+ loadRouteDiagnosisRuntime(ctx, cp, domainName, diag)
1127+ loadRouteDiagnosisHealth(ctx, cp, domainName, diag)
1128+ loadRouteDiagnosisVolumes(ctx, cp, domainName, diag)
1129+ loadRouteDiagnosisOrphanedAttachments(ctx, cp, domainName, diag)
1130+ finalizeRouteDiagnosis(diag)
1131+ return diag, nil
1132+}
1133+
1134+func loadRouteDiagnosisConfig(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) {
1135+ route, err := cp.GetRoute(ctx, domainName)
1136+ if err == nil && route != nil {
1137+ diag.Configured = true
1138+ diag.Route = route
1139+ return
1140+ }
1141+ if err != nil && !errors.Is(err, domain.ErrRouteNotFound) {
1142+ diag.Warnings = append(diag.Warnings, "failed to load route config: "+err.Error())
1143+ }
1144+}
1145+
1146+func loadRouteDiagnosisRuntime(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) {
1147+ routes, err := cp.ListRoutesWithDetails(ctx)
1148+ if err != nil {
1149+ diag.Warnings = append(diag.Warnings, "failed to load route runtime details: "+err.Error())
1150+ return
1151+ }
1152+ for _, info := range routes {
1153+ if strings.EqualFold(info.Domain, domainName) {
1154+ copy := info
1155+ diag.Runtime = &copy
1156+ return
1157+ }
1158+ }
1159+}
1160+
1161+func loadRouteDiagnosisHealth(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) {
1162+ if health, err := cp.GetHealth(ctx); err == nil && health != nil {
1163+ diag.Health = health[domainName]
1164+ }
1165+}
1166+
1167+func loadRouteDiagnosisVolumes(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) {
1168+ if volumes, err := cp.ListVolumes(ctx); err == nil {
1169+ diag.Volumes = volumesForRouteDiagnosis(volumes, domainName)
1170+ }
1171+}
1172+
1173+func loadRouteDiagnosisOrphanedAttachments(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) {
1174+ lister, ok := cp.(interface {
1175+ ListOrphanedAttachments(context.Context) ([]domain.CleanupAttachment, error)
1176+ })
1177+ if !ok {
1178+ return
1179+ }
1180+ if attachments, err := lister.ListOrphanedAttachments(ctx); err == nil {
1181+ diag.OrphanedAttachments = attachmentsForRouteDiagnosis(attachments, domainName)
1182+ }
1183+}
1184+
1185+func finalizeRouteDiagnosis(diag *routeDiagnosis) {
1186+ if !diag.Configured && diag.Runtime != nil {
1187+ diag.Warnings = append(diag.Warnings, "route is not configured but runtime state still exists")
1188+ }
1189+ if len(diag.Volumes) > 0 {
1190+ diag.Hints = append(diag.Hints, "persistent volumes are preserved by default")
1191+ }
1192+ if len(diag.OrphanedAttachments) > 0 {
1193+ diag.Hints = append(diag.Hints, "run 'gordon attachments prune --stop' to stop orphaned attachment containers while preserving volumes")
1194+ }
1195+}
1196+
1197+func volumesForRouteDiagnosis(volumes []dto.Volume, domainName string) []dto.Volume {
1198+ needle := strings.ReplaceAll(strings.ToLower(domainName), ".", "-")
1199+ var out []dto.Volume
1200+ for _, volume := range volumes {
1201+ if strings.Contains(strings.ToLower(volume.Name), needle) {
1202+ out = append(out, volume)
1203+ }
1204+ }
1205+ return out
1206+}
1207+
1208+func attachmentsForRouteDiagnosis(attachments []domain.CleanupAttachment, domainName string) []domain.CleanupAttachment {
1209+ var out []domain.CleanupAttachment
1210+ for _, attachment := range attachments {
1211+ if strings.EqualFold(attachment.Owner, domainName) {
1212+ out = append(out, attachment)
1213+ }
1214+ }
1215+ return out
1216+}
1217+
1218+func writeRouteDiagnosisText(out io.Writer, diag *routeDiagnosis) error {
1219+ if err := cliWriteLine(out, cliRenderTitle("Route diagnosis: "+diag.Domain)); err != nil {
1220+ return err
1221+ }
1222+ if err := writeRouteDiagnosisSummary(out, diag); err != nil {
1223+ return err
1224+ }
1225+ if err := writeRouteDiagnosisLeftovers(out, diag); err != nil {
1226+ return err
1227+ }
1228+ return writeCleanupMessages(out, diag.Warnings, diag.Hints)
1229+}
1230+
1231+func writeRouteDiagnosisSummary(out io.Writer, diag *routeDiagnosis) error {
1232+ if diag.Configured && diag.Route != nil {
1233+ if err := cliWriteLine(out, cliRenderMeta("Image:", diag.Route.Image)); err != nil {
1234+ return err
1235+ }
1236+ } else if err := cliWriteLine(out, cliRenderWarning("Route is not configured")); err != nil {
1237+ return err
1238+ }
1239+ if diag.Runtime == nil {
1240+ return nil
1241+ }
1242+ return cliWriteLine(out, cliRenderMeta("Container:", diag.Runtime.ContainerStatus+" "+shortContainerID(diag.Runtime.ContainerID)))
1243+}
1244+
1245+func writeRouteDiagnosisLeftovers(out io.Writer, diag *routeDiagnosis) error {
1246+ for _, volume := range diag.Volumes {
1247+ if err := cliWriteLine(out, cliRenderMeta("Preserved volume:", volume.Name)); err != nil {
1248+ return err
1249+ }
1250+ }
1251+ for _, attachment := range diag.OrphanedAttachments {
1252+ if err := cliWriteLine(out, cliRenderWarning("Orphaned attachment: "+attachment.Name)); err != nil {
1253+ return err
1254+ }
1255+ }
1256+ return nil
1257+}
1258+
1259+func newRoutesPurgeCmd() *cobra.Command {
1260+ var jsonOut bool
1261+ var force bool
1262+ var includeAttachments bool
1263+ var includeVolumes bool
1264+ cmd := &cobra.Command{
1265+ Use: "purge <domain>",
1266+ Short: "Review or explicitly purge retained route resources",
1267+ Args: cobra.ExactArgs(1),
1268+ RunE: func(cmd *cobra.Command, args []string) error {
1269+ handle, err := resolveControlPlaneForRouteDomain(cmd.Context(), args[0])
1270+ if err != nil {
1271+ return err
1272+ }
1273+ defer handle.close()
1274+ report, err := runRoutePurge(cmd.Context(), handle.plane, args[0], routePurgeOptions{Force: force, Attachments: includeAttachments, Volumes: includeVolumes})
1275+ if err != nil {
1276+ return err
1277+ }
1278+ if jsonOut {
1279+ return writeJSON(cmd.OutOrStdout(), report)
1280+ }
1281+ return writeRoutePurgeText(cmd.OutOrStdout(), report, force)
1282+ },
1283+ }
1284+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
1285+ cmd.Flags().BoolVar(&force, "force", false, "Execute purge actions; default is dry-run")
1286+ cmd.Flags().BoolVar(&includeAttachments, "attachments", false, "Include orphaned attachment containers")
1287+ cmd.Flags().BoolVar(&includeVolumes, "volumes", false, "Include preserved volumes in the purge plan")
1288+ return cmd
1289+}
1290+
1291+type routePurgeOptions struct {
1292+ Force bool
1293+ Attachments bool
1294+ Volumes bool
1295+}
1296+
1297+func runRoutePurge(ctx context.Context, cp ControlPlane, domainName string, opts routePurgeOptions) (*domain.CleanupReport, error) {
1298+ diag, err := buildRouteDiagnosis(ctx, cp, domainName)
1299+ if err != nil {
1300+ return nil, err
1301+ }
1302+ report := &domain.CleanupReport{Domain: domainName}
1303+ if diag.Runtime != nil && diag.Runtime.ContainerID != "" {
1304+ 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"})
1305+ }
1306+ for _, volume := range diag.Volumes {
1307+ report.PreservedVolumes = append(report.PreservedVolumes, domain.CleanupVolume{Name: volume.Name, Reason: "volume purge requires explicit --volumes and runtime support"})
1308+ }
1309+ if !opts.Attachments || !opts.Force {
1310+ report.PreservedAttachments = append(report.PreservedAttachments, diag.OrphanedAttachments...)
1311+ }
1312+ if opts.Attachments && opts.Force {
1313+ cleaner, ok := cp.(interface {
1314+ CleanupOrphanedAttachments(context.Context, string, bool) (*domain.CleanupReport, error)
1315+ })
1316+ if !ok {
1317+ return nil, fmt.Errorf("attachment purge is unavailable")
1318+ }
1319+ attachmentReport, err := cleaner.CleanupOrphanedAttachments(ctx, domainName, true)
1320+ if err != nil {
1321+ return nil, err
1322+ }
1323+ report.RemovedContainers = append(report.RemovedContainers, attachmentReport.RemovedContainers...)
1324+ report.PreservedVolumes = append(report.PreservedVolumes, attachmentReport.PreservedVolumes...)
1325+ report.PreservedAttachments = append(report.PreservedAttachments, attachmentReport.PreservedAttachments...)
1326+ report.OrphanedEntities = append(report.OrphanedEntities, attachmentReport.OrphanedEntities...)
1327+ report.Warnings = append(report.Warnings, attachmentReport.Warnings...)
1328+ report.Hints = append(report.Hints, attachmentReport.Hints...)
1329+ report.PartialFailures = append(report.PartialFailures, attachmentReport.PartialFailures...)
1330+ }
1331+ if opts.Volumes {
1332+ report.Warnings = append(report.Warnings, "volume purge is not executed yet; preserved volumes are reported for manual review")
1333+ }
1334+ if !opts.Force {
1335+ report.Hints = append(report.Hints, "dry-run only; add --force with explicit category flags to execute supported purge actions")
1336+ }
1337+ return report, nil
1338+}
1339+
1340+func writeRoutePurgeText(out io.Writer, report *domain.CleanupReport, force bool) error {
1341+ mode := "Purge dry-run"
1342+ if force {
1343+ mode = "Purge completed"
1344+ }
1345+ if err := cliWriteLine(out, cliRenderTitle(mode+": "+report.Domain)); err != nil {
1346+ return err
1347+ }
1348+ if err := writePreservedAttachments(out, report.PreservedAttachments); err != nil {
1349+ return err
1350+ }
1351+ if err := writeRemovedContainers(out, report.RemovedContainers); err != nil {
1352+ return err
1353+ }
1354+ for _, entity := range report.OrphanedEntities {
1355+ if err := cliWriteLine(out, cliRenderWarning("Purge candidate: "+entity.Kind+" "+entity.ID)); err != nil {
1356+ return err
1357+ }
1358+ }
1359+ for _, volume := range report.PreservedVolumes {
1360+ if err := cliWriteLine(out, cliRenderMeta("Preserved volume:", volume.Name)); err != nil {
1361+ return err
1362+ }
1363+ }
1364+ return writeCleanupMessages(out, report.Warnings, report.Hints)
1365+}
1366+
10821367 func newRoutesAddCmd() *cobra.Command {
10831368 var image string
10841369
@@ -1183,12 +1468,22 @@ Examples:
11831468 return err
11841469 }
11851470 defer handle.close()
1186- if err := handle.plane.RemoveRoute(ctx, routeDomain); err != nil {
1471+ var cleanup *domain.CleanupReport
1472+ if remover, ok := handle.plane.(interface {
1473+ RemoveRouteWithCleanup(context.Context, string) (*dto.RouteDeleteResponse, error)
1474+ }); ok {
1475+ resp, err := remover.RemoveRouteWithCleanup(ctx, routeDomain)
1476+ if err != nil {
1477+ return fmt.Errorf("failed to remove route: %w", err)
1478+ }
1479+ if resp != nil {
1480+ cleanup = resp.Cleanup
1481+ }
1482+ } else if err := handle.plane.RemoveRoute(ctx, routeDomain); err != nil {
11871483 return fmt.Errorf("failed to remove route: %w", err)
11881484 }
11891485
1190- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Route removed: %s", routeDomain)))
1191- return nil
1486+ return writeRouteRemoveText(cmd.OutOrStdout(), routeDomain, cleanup)
11921487 },
11931488 }
11941489
@@ -1197,6 +1492,86 @@ Examples:
11971492 return cmd
11981493 }
11991494
1495+func writeRouteRemoveText(out io.Writer, routeDomain string, cleanup *domain.CleanupReport) error {
1496+ if err := cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Route removed: %s", routeDomain))); err != nil {
1497+ return err
1498+ }
1499+ if cleanup == nil {
1500+ return nil
1501+ }
1502+ if err := writeRemovedContainers(out, cleanup.RemovedContainers); err != nil {
1503+ return err
1504+ }
1505+ if err := writePreservedAttachments(out, cleanup.PreservedAttachments); err != nil {
1506+ return err
1507+ }
1508+ return writeCleanupMessages(out, cleanup.Warnings, cleanup.Hints)
1509+}
1510+
1511+func writeRemovedContainers(out io.Writer, containers []domain.CleanupContainer) error {
1512+ if len(containers) == 0 {
1513+ return nil
1514+ }
1515+ if err := cliWriteLine(out, ""); err != nil {
1516+ return err
1517+ }
1518+ if err := cliWriteLine(out, styles.Theme.Bold.Render("Removed containers:")); err != nil {
1519+ return err
1520+ }
1521+ for _, c := range containers {
1522+ label := c.Name
1523+ if label == "" {
1524+ label = c.ID
1525+ }
1526+ if id := shortContainerID(c.ID); id != "" && id != label {
1527+ label = fmt.Sprintf("%s (%s)", label, id)
1528+ }
1529+ if err := cliWriteLine(out, " "+label); err != nil {
1530+ return err
1531+ }
1532+ }
1533+ return nil
1534+}
1535+
1536+func writePreservedAttachments(out io.Writer, attachments []domain.CleanupAttachment) error {
1537+ if len(attachments) == 0 {
1538+ return nil
1539+ }
1540+ if err := cliWriteLine(out, ""); err != nil {
1541+ return err
1542+ }
1543+ if err := cliWriteLine(out, styles.Theme.Bold.Render("Preserved attachments:")); err != nil {
1544+ return err
1545+ }
1546+ for _, a := range attachments {
1547+ label := a.Name
1548+ if label == "" {
1549+ label = a.ContainerID
1550+ }
1551+ if a.Status != "" {
1552+ label = fmt.Sprintf("%s (%s)", label, a.Status)
1553+ }
1554+ if err := cliWriteLine(out, " "+label); err != nil {
1555+ return err
1556+ }
1557+ }
1558+ return nil
1559+}
1560+
1561+func writeCleanupMessages(out io.Writer, warnings, hints []string) error {
1562+ for _, warning := range warnings {
1563+ if err := cliWriteLine(out, cliRenderWarning(warning)); err != nil {
1564+ return err
1565+ }
1566+ }
1567+ for _, hint := range hints {
1568+ if err := cliWriteLine(out, cliRenderMuted("Hint: "+hint)); err != nil {
1569+ return err
1570+ }
1571+ }
1572+ return nil
1573+}
1574+
12001575 // newStatusCmd creates the status command.
12011576 func newStatusCmd() *cobra.Command {
12021577 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,277 @@ 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+ handle, err := resolveControlPlaneForRouteDomain(cmd.Context(), args[0])
1105+ if err != nil {
1106+ return err
1107+ }
1108+ defer handle.close()
1109+ diag, err := buildRouteDiagnosis(cmd.Context(), handle.plane, args[0])
1110+ if err != nil {
1111+ return err
1112+ }
1113+ if jsonOut {
1114+ return writeJSON(cmd.OutOrStdout(), diag)
1115+ }
1116+ return writeRouteDiagnosisText(cmd.OutOrStdout(), diag)
1117+ },
1118+ }
1119+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
1120+ return cmd
1121+}
1122+
1123+func buildRouteDiagnosis(ctx context.Context, cp ControlPlane, domainName string) (*routeDiagnosis, error) {
1124+ diag := &routeDiagnosis{Domain: domainName}
1125+ loadRouteDiagnosisConfig(ctx, cp, domainName, diag)
1126+ loadRouteDiagnosisRuntime(ctx, cp, domainName, diag)
1127+ loadRouteDiagnosisHealth(ctx, cp, domainName, diag)
1128+ loadRouteDiagnosisVolumes(ctx, cp, domainName, diag)
1129+ loadRouteDiagnosisOrphanedAttachments(ctx, cp, domainName, diag)
1130+ finalizeRouteDiagnosis(diag)
1131+ return diag, nil
1132+}
1133+
1134+func loadRouteDiagnosisConfig(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) {
1135+ route, err := cp.GetRoute(ctx, domainName)
1136+ if err == nil && route != nil {
1137+ diag.Configured = true
1138+ diag.Route = route
1139+ return
1140+ }
1141+ if err != nil && !errors.Is(err, domain.ErrRouteNotFound) {
1142+ diag.Warnings = append(diag.Warnings, "failed to load route config: "+err.Error())
1143+ }
1144+}
1145+
1146+func loadRouteDiagnosisRuntime(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) {
1147+ routes, err := cp.ListRoutesWithDetails(ctx)
1148+ if err != nil {
1149+ diag.Warnings = append(diag.Warnings, "failed to load route runtime details: "+err.Error())
1150+ return
1151+ }
1152+ for _, info := range routes {
1153+ if strings.EqualFold(info.Domain, domainName) {
1154+ copy := info
1155+ diag.Runtime = &copy
1156+ return
1157+ }
1158+ }
1159+}
1160+
1161+func loadRouteDiagnosisHealth(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) {
1162+ if health, err := cp.GetHealth(ctx); err == nil && health != nil {
1163+ diag.Health = health[domainName]
1164+ }
1165+}
1166+
1167+func loadRouteDiagnosisVolumes(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) {
1168+ if volumes, err := cp.ListVolumes(ctx); err == nil {
1169+ diag.Volumes = volumesForRouteDiagnosis(volumes, domainName)
1170+ }
1171+}
1172+
1173+func loadRouteDiagnosisOrphanedAttachments(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) {
1174+ lister, ok := cp.(interface {
1175+ ListOrphanedAttachments(context.Context) ([]domain.CleanupAttachment, error)
1176+ })
1177+ if !ok {
1178+ return
1179+ }
1180+ if attachments, err := lister.ListOrphanedAttachments(ctx); err == nil {
1181+ diag.OrphanedAttachments = attachmentsForRouteDiagnosis(attachments, domainName)
1182+ }
1183+}
1184+
1185+func finalizeRouteDiagnosis(diag *routeDiagnosis) {
1186+ if !diag.Configured && diag.Runtime != nil {
1187+ diag.Warnings = append(diag.Warnings, "route is not configured but runtime state still exists")
1188+ }
1189+ if len(diag.Volumes) > 0 {
1190+ diag.Hints = append(diag.Hints, "persistent volumes are preserved by default")
1191+ }
1192+ if len(diag.OrphanedAttachments) > 0 {
1193+ diag.Hints = append(diag.Hints, "run 'gordon attachments prune --stop' to stop orphaned attachment containers while preserving volumes")
1194+ }
1195+}
1196+
1197+func volumesForRouteDiagnosis(volumes []dto.Volume, domainName string) []dto.Volume {
1198+ needle := strings.ReplaceAll(strings.ToLower(domainName), ".", "-")
1199+ var out []dto.Volume
1200+ for _, volume := range volumes {
1201+ if strings.Contains(strings.ToLower(volume.Name), needle) {
1202+ out = append(out, volume)
1203+ }
1204+ }
1205+ return out
1206+}
1207+
1208+func attachmentsForRouteDiagnosis(attachments []domain.CleanupAttachment, domainName string) []domain.CleanupAttachment {
1209+ var out []domain.CleanupAttachment
1210+ for _, attachment := range attachments {
1211+ if strings.EqualFold(attachment.Owner, domainName) {
1212+ out = append(out, attachment)
1213+ }
1214+ }
1215+ return out
1216+}
1217+
1218+func writeRouteDiagnosisText(out io.Writer, diag *routeDiagnosis) error {
1219+ if err := cliWriteLine(out, cliRenderTitle("Route diagnosis: "+diag.Domain)); err != nil {
1220+ return err
1221+ }
1222+ if err := writeRouteDiagnosisSummary(out, diag); err != nil {
1223+ return err
1224+ }
1225+ if err := writeRouteDiagnosisLeftovers(out, diag); err != nil {
1226+ return err
1227+ }
1228+ return writeCleanupMessages(out, diag.Warnings, diag.Hints)
1229+}
1230+
1231+func writeRouteDiagnosisSummary(out io.Writer, diag *routeDiagnosis) error {
1232+ if diag.Configured && diag.Route != nil {
1233+ if err := cliWriteLine(out, cliRenderMeta("Image:", diag.Route.Image)); err != nil {
1234+ return err
1235+ }
1236+ } else if err := cliWriteLine(out, cliRenderWarning("Route is not configured")); err != nil {
1237+ return err
1238+ }
1239+ if diag.Runtime == nil {
1240+ return nil
1241+ }
1242+ return cliWriteLine(out, cliRenderMeta("Container:", diag.Runtime.ContainerStatus+" "+shortContainerID(diag.Runtime.ContainerID)))
1243+}
1244+
1245+func writeRouteDiagnosisLeftovers(out io.Writer, diag *routeDiagnosis) error {
1246+ for _, volume := range diag.Volumes {
1247+ if err := cliWriteLine(out, cliRenderMeta("Preserved volume:", volume.Name)); err != nil {
1248+ return err
1249+ }
1250+ }
1251+ for _, attachment := range diag.OrphanedAttachments {
1252+ if err := cliWriteLine(out, cliRenderWarning("Orphaned attachment: "+attachment.Name)); err != nil {
1253+ return err
1254+ }
1255+ }
1256+ return nil
1257+}
1258+
1259+func newRoutesPurgeCmd() *cobra.Command {
1260+ var jsonOut bool
1261+ var force bool
1262+ var includeAttachments bool
1263+ var includeVolumes bool
1264+ cmd := &cobra.Command{
1265+ Use: "purge <domain>",
1266+ Short: "Review or explicitly purge retained route resources",
1267+ Args: cobra.ExactArgs(1),
1268+ RunE: func(cmd *cobra.Command, args []string) error {
1269+ handle, err := resolveControlPlaneForRouteDomain(cmd.Context(), args[0])
1270+ if err != nil {
1271+ return err
1272+ }
1273+ defer handle.close()
1274+ report, err := runRoutePurge(cmd.Context(), handle.plane, args[0], routePurgeOptions{Force: force, Attachments: includeAttachments, Volumes: includeVolumes})
1275+ if err != nil {
1276+ return err
1277+ }
1278+ if jsonOut {
1279+ return writeJSON(cmd.OutOrStdout(), report)
1280+ }
1281+ return writeRoutePurgeText(cmd.OutOrStdout(), report, force)
1282+ },
1283+ }
1284+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
1285+ cmd.Flags().BoolVar(&force, "force", false, "Execute purge actions; default is dry-run")
1286+ cmd.Flags().BoolVar(&includeAttachments, "attachments", false, "Include orphaned attachment containers")
1287+ cmd.Flags().BoolVar(&includeVolumes, "volumes", false, "Include preserved volumes in the purge plan")
1288+ return cmd
1289+}
1290+
1291+type routePurgeOptions struct {
1292+ Force bool
1293+ Attachments bool
1294+ Volumes bool
1295+}
1296+
1297+func runRoutePurge(ctx context.Context, cp ControlPlane, domainName string, opts routePurgeOptions) (*domain.CleanupReport, error) {
1298+ diag, err := buildRouteDiagnosis(ctx, cp, domainName)
1299+ if err != nil {
1300+ return nil, err
1301+ }
1302+ report := &domain.CleanupReport{Domain: domainName}
1303+ if diag.Runtime != nil && diag.Runtime.ContainerID != "" {
1304+ 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"})
1305+ }
1306+ for _, volume := range diag.Volumes {
1307+ report.PreservedVolumes = append(report.PreservedVolumes, domain.CleanupVolume{Name: volume.Name, Reason: "volume purge requires explicit --volumes and runtime support"})
1308+ }
1309+ if !opts.Attachments || !opts.Force {
1310+ report.PreservedAttachments = append(report.PreservedAttachments, diag.OrphanedAttachments...)
1311+ }
1312+ if opts.Attachments && opts.Force {
1313+ cleaner, ok := cp.(interface {
1314+ CleanupOrphanedAttachments(context.Context, string, bool) (*domain.CleanupReport, error)
1315+ })
1316+ if !ok {
1317+ return nil, fmt.Errorf("attachment purge is unavailable")
1318+ }
1319+ attachmentReport, err := cleaner.CleanupOrphanedAttachments(ctx, domainName, true)
1320+ if err != nil {
1321+ return nil, err
1322+ }
1323+ report.RemovedContainers = append(report.RemovedContainers, attachmentReport.RemovedContainers...)
1324+ report.PreservedVolumes = append(report.PreservedVolumes, attachmentReport.PreservedVolumes...)
1325+ report.PreservedAttachments = append(report.PreservedAttachments, attachmentReport.PreservedAttachments...)
1326+ report.OrphanedEntities = append(report.OrphanedEntities, attachmentReport.OrphanedEntities...)
1327+ report.Warnings = append(report.Warnings, attachmentReport.Warnings...)
1328+ report.Hints = append(report.Hints, attachmentReport.Hints...)
1329+ report.PartialFailures = append(report.PartialFailures, attachmentReport.PartialFailures...)
1330+ }
1331+ if opts.Volumes {
1332+ report.Warnings = append(report.Warnings, "volume purge is not executed yet; preserved volumes are reported for manual review")
1333+ }
1334+ if !opts.Force {
1335+ report.Hints = append(report.Hints, "dry-run only; add --force with explicit category flags to execute supported purge actions")
1336+ }
1337+ return report, nil
1338+}
1339+
1340+func writeRoutePurgeText(out io.Writer, report *domain.CleanupReport, force bool) error {
1341+ mode := "Purge dry-run"
1342+ if force {
1343+ mode = "Purge completed"
1344+ }
1345+ if err := cliWriteLine(out, cliRenderTitle(mode+": "+report.Domain)); err != nil {
1346+ return err
1347+ }
1348+ if err := writePreservedAttachments(out, report.PreservedAttachments); err != nil {
1349+ return err
1350+ }
1351+ if err := writeRemovedContainers(out, report.RemovedContainers); err != nil {
1352+ return err
1353+ }
1354+ for _, entity := range report.OrphanedEntities {
1355+ if err := cliWriteLine(out, cliRenderWarning("Purge candidate: "+entity.Kind+" "+entity.ID)); err != nil {
1356+ return err
1357+ }
1358+ }
1359+ for _, volume := range report.PreservedVolumes {
1360+ if err := cliWriteLine(out, cliRenderMeta("Preserved volume:", volume.Name)); err != nil {
1361+ return err
1362+ }
1363+ }
1364+ return writeCleanupMessages(out, report.Warnings, report.Hints)
1365+}
1366+
1082 func newRoutesAddCmd() *cobra.Command {1367 func newRoutesAddCmd() *cobra.Command {
1083 var image string1368 var image string
1084 1369
@@ -1183,12 +1468,22 @@ Examples:
1183 return err1468 return err
1184 }1469 }
1185 defer handle.close()1470 defer handle.close()
1186- if err := handle.plane.RemoveRoute(ctx, routeDomain); err != nil {1471+ var cleanup *domain.CleanupReport
1472+ if remover, ok := handle.plane.(interface {
1473+ RemoveRouteWithCleanup(context.Context, string) (*dto.RouteDeleteResponse, error)
1474+ }); ok {
1475+ resp, err := remover.RemoveRouteWithCleanup(ctx, routeDomain)
1476+ if err != nil {
1477+ return fmt.Errorf("failed to remove route: %w", err)
1478+ }
1479+ if resp != nil {
1480+ cleanup = resp.Cleanup
1481+ }
1482+ } else if err := handle.plane.RemoveRoute(ctx, routeDomain); err != nil {
1187 return fmt.Errorf("failed to remove route: %w", err)1483 return fmt.Errorf("failed to remove route: %w", err)
1188 }1484 }
1189 1485
1190- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Route removed: %s", routeDomain)))1486+ return writeRouteRemoveText(cmd.OutOrStdout(), routeDomain, cleanup)
1191- return nil
1192 },1487 },
1193 }1488 }
1194 1489
@@ -1197,6 +1492,86 @@ Examples:
1197 return cmd1492 return cmd
1198 }1493 }
1199 1494
1495+func writeRouteRemoveText(out io.Writer, routeDomain string, cleanup *domain.CleanupReport) error {
1496+ if err := cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Route removed: %s", routeDomain))); err != nil {
1497+ return err
1498+ }
1499+ if cleanup == nil {
1500+ return nil
1501+ }
1502+ if err := writeRemovedContainers(out, cleanup.RemovedContainers); err != nil {
1503+ return err
1504+ }
1505+ if err := writePreservedAttachments(out, cleanup.PreservedAttachments); err != nil {
1506+ return err
1507+ }
1508+ return writeCleanupMessages(out, cleanup.Warnings, cleanup.Hints)
1509+}
1510+
1511+func writeRemovedContainers(out io.Writer, containers []domain.CleanupContainer) error {
1512+ if len(containers) == 0 {
1513+ return nil
1514+ }
1515+ if err := cliWriteLine(out, ""); err != nil {
1516+ return err
1517+ }
1518+ if err := cliWriteLine(out, styles.Theme.Bold.Render("Removed containers:")); err != nil {
1519+ return err
1520+ }
1521+ for _, c := range containers {
1522+ label := c.Name
1523+ if label == "" {
1524+ label = c.ID
1525+ }
1526+ if id := shortContainerID(c.ID); id != "" && id != label {
1527+ label = fmt.Sprintf("%s (%s)", label, id)
1528+ }
1529+ if err := cliWriteLine(out, " "+label); err != nil {
1530+ return err
1531+ }
1532+ }
1533+ return nil
1534+}
1535+
1536+func writePreservedAttachments(out io.Writer, attachments []domain.CleanupAttachment) error {
1537+ if len(attachments) == 0 {
1538+ return nil
1539+ }
1540+ if err := cliWriteLine(out, ""); err != nil {
1541+ return err
1542+ }
1543+ if err := cliWriteLine(out, styles.Theme.Bold.Render("Preserved attachments:")); err != nil {
1544+ return err
1545+ }
1546+ for _, a := range attachments {
1547+ label := a.Name
1548+ if label == "" {
1549+ label = a.ContainerID
1550+ }
1551+ if a.Status != "" {
1552+ label = fmt.Sprintf("%s (%s)", label, a.Status)
1553+ }
1554+ if err := cliWriteLine(out, " "+label); err != nil {
1555+ return err
1556+ }
1557+ }
1558+ return nil
1559+}
1560+
1561+func writeCleanupMessages(out io.Writer, warnings, hints []string) error {
1562+ for _, warning := range warnings {
1563+ if err := cliWriteLine(out, cliRenderWarning(warning)); err != nil {
1564+ return err
1565+ }
1566+ }
1567+ for _, hint := range hints {
1568+ if err := cliWriteLine(out, cliRenderMuted("Hint: "+hint)); err != nil {
1569+ return err
1570+ }
1571+ }
1572+ return nil
1573+}
1574+
1200 // newStatusCmd creates the status command.1575 // newStatusCmd creates the status command.
1201 func newStatusCmd() *cobra.Command {1576 func newStatusCmd() *cobra.Command {
1202 return &cobra.Command{1577 return &cobra.Command{
M internal/adapters/in/cli/routes_test.go +106 -0
@@ -13,6 +13,7 @@ import (
1313 "sync/atomic"
1414 "testing"
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"
@@ -885,6 +886,35 @@ func TestRenderRoutesStatusSections_KeepsLocalErrorWhenAllRemotesFail(t *testing
885886 assert.Contains(t, rendered, "dial tcp timeout")
886887 }
887888
889+func TestWriteRouteRemoveTextShowsCleanupReport(t *testing.T) {
890+ var out bytes.Buffer
891+ report := &domain.CleanupReport{
892+ Domain: "app.example.com",
893+ RemovedContainers: []domain.CleanupContainer{{
894+ ID: "1234567890abcdef",
895+ Name: "gordon-app.example.com",
896+ }},
897+ PreservedAttachments: []domain.CleanupAttachment{{
898+ Name: "postgres",
899+ ContainerID: "attachment-1",
900+ Status: "running",
901+ }},
902+ Warnings: []string{"volumes preserved"},
903+ Hints: []string{"run diagnose"},
904+ }
905+
906+ err := writeRouteRemoveText(&out, "app.example.com", report)
907+ require.NoError(t, err)
908+ rendered := stripANSI(out.String())
909+ assert.Contains(t, rendered, "Route removed: app.example.com")
910+ assert.Contains(t, rendered, "Removed containers:")
911+ assert.Contains(t, rendered, "gordon-app.example.com (1234567890ab)")
912+ assert.Contains(t, rendered, "Preserved attachments:")
913+ assert.Contains(t, rendered, "postgres (running)")
914+ assert.Contains(t, rendered, "volumes preserved")
915+ assert.Contains(t, rendered, "run diagnose")
916+}
917+
888918 func TestBuildRouteStatusTree_DeterministicAcrossInputOrder(t *testing.T) {
889919 routes := []routeStatusItem{
890920 {Domain: "zulu.example", Image: "zulu:v1", Network: "gordon-zulu", ContainerStatus: "running"},
@@ -901,3 +931,79 @@ func TestBuildRouteStatusTree_DeterministicAcrossInputOrder(t *testing.T) {
901931
902932 assert.Equal(t, outA.String(), outB.String())
903933 }
934+
935+func TestAttachmentsForRouteDiagnosisUsesOwner(t *testing.T) {
936+ attachments := []domain.CleanupAttachment{
937+ {Name: "unexpected-name", Owner: "app.example.com", ContainerID: "match"},
938+ {Name: "gordon-other-example-com-postgres", Owner: "other.example.com", ContainerID: "miss"},
939+ }
940+
941+ matched := attachmentsForRouteDiagnosis(attachments, "app.example.com")
942+
943+ require.Len(t, matched, 1)
944+ assert.Equal(t, "match", matched[0].ContainerID)
945+}
946+
947+func TestRunRoutePurge_AttachmentsForceIsScopedToRoute(t *testing.T) {
948+ cp := &routePurgeTestControlPlane{
949+ route: &domain.Route{Domain: "app.example.com", Image: "app:latest"},
950+ orphanedAttachments: []domain.CleanupAttachment{
951+ {ContainerID: "app-attachment", Owner: "app.example.com", Name: "postgres"},
952+ {ContainerID: "other-attachment", Owner: "other.example.com", Name: "redis"},
953+ },
954+ }
955+
956+ report, err := runRoutePurge(context.Background(), cp, "app.example.com", routePurgeOptions{Force: true, Attachments: true})
957+
958+ require.NoError(t, err)
959+ require.NotNil(t, report)
960+ require.Len(t, cp.cleanedAttachments, 1)
961+ assert.Equal(t, "app-attachment", cp.cleanedAttachments[0].ContainerID)
962+ require.Len(t, report.RemovedContainers, 1)
963+ assert.Equal(t, "app-attachment", report.RemovedContainers[0].ID)
964+}
965+
966+type routePurgeTestControlPlane struct {
967+ resolveFromImageTestControlPlane
968+ route *domain.Route
969+ orphanedAttachments []domain.CleanupAttachment
970+ cleanedAttachments []domain.CleanupAttachment
971+}
972+
973+func (c *routePurgeTestControlPlane) GetRoute(context.Context, string) (*domain.Route, error) {
974+ if c.route == nil {
975+ return nil, domain.ErrRouteNotFound
976+ }
977+ return c.route, nil
978+}
979+
980+func (c *routePurgeTestControlPlane) ListRoutesWithDetails(context.Context) ([]remote.RouteInfo, error) {
981+ return nil, nil
982+}
983+
984+func (c *routePurgeTestControlPlane) GetHealth(context.Context) (map[string]*remote.RouteHealth, error) {
985+ return nil, nil
986+}
987+
988+func (c *routePurgeTestControlPlane) ListVolumes(context.Context) ([]dto.Volume, error) {
989+ return nil, nil
990+}
991+
992+func (c *routePurgeTestControlPlane) ListOrphanedAttachments(context.Context) ([]domain.CleanupAttachment, error) {
993+ return c.orphanedAttachments, nil
994+}
995+
996+func (c *routePurgeTestControlPlane) CleanupOrphanedAttachments(_ context.Context, owner string, stop bool) (*domain.CleanupReport, error) {
997+ if !stop {
998+ return &domain.CleanupReport{}, nil
999+ }
1000+ var report domain.CleanupReport
1001+ for _, attachment := range c.orphanedAttachments {
1002+ if attachment.Owner != owner {
1003+ continue
1004+ }
1005+ c.cleanedAttachments = append(c.cleanedAttachments, attachment)
1006+ report.RemovedContainers = append(report.RemovedContainers, domain.CleanupContainer{ID: attachment.ContainerID, Name: attachment.Name})
1007+ }
1008+ return &report, nil
1009+}
@@ -13,6 +13,7 @@ import (
13 "sync/atomic"13 "sync/atomic"
14 "testing"14 "testing"
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"
@@ -885,6 +886,35 @@ func TestRenderRoutesStatusSections_KeepsLocalErrorWhenAllRemotesFail(t *testing
885 assert.Contains(t, rendered, "dial tcp timeout")886 assert.Contains(t, rendered, "dial tcp timeout")
886 }887 }
887 888
889+func TestWriteRouteRemoveTextShowsCleanupReport(t *testing.T) {
890+ var out bytes.Buffer
891+ report := &domain.CleanupReport{
892+ Domain: "app.example.com",
893+ RemovedContainers: []domain.CleanupContainer{{
894+ ID: "1234567890abcdef",
895+ Name: "gordon-app.example.com",
896+ }},
897+ PreservedAttachments: []domain.CleanupAttachment{{
898+ Name: "postgres",
899+ ContainerID: "attachment-1",
900+ Status: "running",
901+ }},
902+ Warnings: []string{"volumes preserved"},
903+ Hints: []string{"run diagnose"},
904+ }
905+
906+ err := writeRouteRemoveText(&out, "app.example.com", report)
907+ require.NoError(t, err)
908+ rendered := stripANSI(out.String())
909+ assert.Contains(t, rendered, "Route removed: app.example.com")
910+ assert.Contains(t, rendered, "Removed containers:")
911+ assert.Contains(t, rendered, "gordon-app.example.com (1234567890ab)")
912+ assert.Contains(t, rendered, "Preserved attachments:")
913+ assert.Contains(t, rendered, "postgres (running)")
914+ assert.Contains(t, rendered, "volumes preserved")
915+ assert.Contains(t, rendered, "run diagnose")
916+}
917+
888 func TestBuildRouteStatusTree_DeterministicAcrossInputOrder(t *testing.T) {918 func TestBuildRouteStatusTree_DeterministicAcrossInputOrder(t *testing.T) {
889 routes := []routeStatusItem{919 routes := []routeStatusItem{
890 {Domain: "zulu.example", Image: "zulu:v1", Network: "gordon-zulu", ContainerStatus: "running"},920 {Domain: "zulu.example", Image: "zulu:v1", Network: "gordon-zulu", ContainerStatus: "running"},
@@ -901,3 +931,79 @@ func TestBuildRouteStatusTree_DeterministicAcrossInputOrder(t *testing.T) {
901 931
902 assert.Equal(t, outA.String(), outB.String())932 assert.Equal(t, outA.String(), outB.String())
903 }933 }
934+
935+func TestAttachmentsForRouteDiagnosisUsesOwner(t *testing.T) {
936+ attachments := []domain.CleanupAttachment{
937+ {Name: "unexpected-name", Owner: "app.example.com", ContainerID: "match"},
938+ {Name: "gordon-other-example-com-postgres", Owner: "other.example.com", ContainerID: "miss"},
939+ }
940+
941+ matched := attachmentsForRouteDiagnosis(attachments, "app.example.com")
942+
943+ require.Len(t, matched, 1)
944+ assert.Equal(t, "match", matched[0].ContainerID)
945+}
946+
947+func TestRunRoutePurge_AttachmentsForceIsScopedToRoute(t *testing.T) {
948+ cp := &routePurgeTestControlPlane{
949+ route: &domain.Route{Domain: "app.example.com", Image: "app:latest"},
950+ orphanedAttachments: []domain.CleanupAttachment{
951+ {ContainerID: "app-attachment", Owner: "app.example.com", Name: "postgres"},
952+ {ContainerID: "other-attachment", Owner: "other.example.com", Name: "redis"},
953+ },
954+ }
955+
956+ report, err := runRoutePurge(context.Background(), cp, "app.example.com", routePurgeOptions{Force: true, Attachments: true})
957+
958+ require.NoError(t, err)
959+ require.NotNil(t, report)
960+ require.Len(t, cp.cleanedAttachments, 1)
961+ assert.Equal(t, "app-attachment", cp.cleanedAttachments[0].ContainerID)
962+ require.Len(t, report.RemovedContainers, 1)
963+ assert.Equal(t, "app-attachment", report.RemovedContainers[0].ID)
964+}
965+
966+type routePurgeTestControlPlane struct {
967+ resolveFromImageTestControlPlane
968+ route *domain.Route
969+ orphanedAttachments []domain.CleanupAttachment
970+ cleanedAttachments []domain.CleanupAttachment
971+}
972+
973+func (c *routePurgeTestControlPlane) GetRoute(context.Context, string) (*domain.Route, error) {
974+ if c.route == nil {
975+ return nil, domain.ErrRouteNotFound
976+ }
977+ return c.route, nil
978+}
979+
980+func (c *routePurgeTestControlPlane) ListRoutesWithDetails(context.Context) ([]remote.RouteInfo, error) {
981+ return nil, nil
982+}
983+
984+func (c *routePurgeTestControlPlane) GetHealth(context.Context) (map[string]*remote.RouteHealth, error) {
985+ return nil, nil
986+}
987+
988+func (c *routePurgeTestControlPlane) ListVolumes(context.Context) ([]dto.Volume, error) {
989+ return nil, nil
990+}
991+
992+func (c *routePurgeTestControlPlane) ListOrphanedAttachments(context.Context) ([]domain.CleanupAttachment, error) {
993+ return c.orphanedAttachments, nil
994+}
995+
996+func (c *routePurgeTestControlPlane) CleanupOrphanedAttachments(_ context.Context, owner string, stop bool) (*domain.CleanupReport, error) {
997+ if !stop {
998+ return &domain.CleanupReport{}, nil
999+ }
1000+ var report domain.CleanupReport
1001+ for _, attachment := range c.orphanedAttachments {
1002+ if attachment.Owner != owner {
1003+ continue
1004+ }
1005+ c.cleanedAttachments = append(c.cleanedAttachments, attachment)
1006+ report.RemovedContainers = append(report.RemovedContainers, domain.CleanupContainer{ID: attachment.ContainerID, Name: attachment.Name})
1007+ }
1008+ return &report, nil
1009+}
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 *domain.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 = 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 *domain.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 = 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 +235 -0
@@ -721,6 +721,7 @@ func TestHandler_RoutesDelete_RequiresWriteScope(t *testing.T) {
721721 t.Run(tt.name, func(t *testing.T) {
722722 if tt.wantStatus == http.StatusOK {
723723 configSvc.EXPECT().RemoveRoute(mock.Anything, "app.example.com").Return(nil).Maybe()
724+ containerSvc.EXPECT().ReconcileRemovedRoute(mock.Anything, "app.example.com").Return(&domain.CleanupReport{Domain: "app.example.com"}, nil).Maybe()
724725 }
725726
726727 req := httptest.NewRequest("DELETE", "/admin/routes/app.example.com", nil)
@@ -734,6 +735,110 @@ 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+
755+ req := httptest.NewRequest("DELETE", "/admin/routes/app.example.com", nil)
756+ req = req.WithContext(ctxWithScopes("admin:routes:write"))
757+ rec := httptest.NewRecorder()
758+
759+ handler.ServeHTTP(rec, req)
760+
761+ require.Equal(t, http.StatusOK, rec.Code)
762+ var resp dto.RouteDeleteResponse
763+ require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp))
764+ require.NotNil(t, resp.Cleanup)
765+ assert.Equal(t, "app.example.com", resp.Cleanup.Domain)
766+}
767+
768+func TestHandler_RoutesDelete_ReturnsServerErrorWhenRuntimeCleanupFails(t *testing.T) {
769+ configSvc := inmocks.NewMockConfigService(t)
770+ authSvc := inmocks.NewMockAuthService(t)
771+ containerSvc := inmocks.NewMockContainerService(t)
772+ secretSvc := inmocks.NewMockSecretService(t)
773+
774+ configSvc.EXPECT().RemoveRoute(mock.Anything, "app.example.com").Return(nil).Once()
775+ containerSvc.EXPECT().ReconcileRemovedRoute(mock.Anything, "app.example.com").Return(nil, errors.New("boom")).Once()
776+
777+ handler := newTestHandler(t, func(d *HandlerDeps) {
778+ d.ConfigSvc = configSvc
779+ d.AuthSvc = authSvc
780+ d.ContainerSvc = containerSvc
781+ d.SecretSvc = secretSvc
782+ })
783+
784+ req := httptest.NewRequest("DELETE", "/admin/routes/app.example.com", nil)
785+ req = req.WithContext(ctxWithScopes("admin:routes:write"))
786+ rec := httptest.NewRecorder()
787+
788+ handler.ServeHTTP(rec, req)
789+
790+ assert.Equal(t, http.StatusInternalServerError, rec.Code)
791+ assert.Contains(t, rec.Body.String(), "route removed but runtime cleanup failed")
792+}
793+
794+func TestHandler_RoutesDelete_ReconcilesRuntimeAndReturnsCleanupReport(t *testing.T) {
795+ configSvc := inmocks.NewMockConfigService(t)
796+ authSvc := inmocks.NewMockAuthService(t)
797+ containerSvc := inmocks.NewMockContainerService(t)
798+ secretSvc := inmocks.NewMockSecretService(t)
799+
800+ report := &domain.CleanupReport{
801+ Domain: "app.example.com",
802+ RemovedContainers: []domain.CleanupContainer{{
803+ ID: "container-1",
804+ Name: "gordon-app.example.com",
805+ }},
806+ PreservedAttachments: []domain.CleanupAttachment{{
807+ Name: "postgres",
808+ ContainerID: "attachment-1",
809+ Status: "running",
810+ }},
811+ }
812+
813+ removeCall := configSvc.EXPECT().RemoveRoute(mock.Anything, "app.example.com").Return(nil).Once()
814+ cleanupCall := containerSvc.EXPECT().ReconcileRemovedRoute(mock.Anything, "app.example.com").Return(report, nil).Once()
815+ mock.InOrder(removeCall, cleanupCall)
816+
817+ handler := newTestHandler(t, func(d *HandlerDeps) {
818+ d.ConfigSvc = configSvc
819+ d.AuthSvc = authSvc
820+ d.ContainerSvc = containerSvc
821+ d.SecretSvc = secretSvc
822+ })
823+
824+ req := httptest.NewRequest("DELETE", "/admin/routes/app.example.com", nil)
825+ req = req.WithContext(ctxWithScopes("admin:routes:write"))
826+ rec := httptest.NewRecorder()
827+
828+ handler.ServeHTTP(rec, req)
829+
830+ require.Equal(t, http.StatusOK, rec.Code)
831+ var resp dto.RouteDeleteResponse
832+ require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp))
833+ assert.Equal(t, "removed", resp.Status)
834+ require.NotNil(t, resp.Cleanup)
835+ assert.Equal(t, "app.example.com", resp.Cleanup.Domain)
836+ require.Len(t, resp.Cleanup.RemovedContainers, 1)
837+ assert.Equal(t, "container-1", resp.Cleanup.RemovedContainers[0].ID)
838+ require.Len(t, resp.Cleanup.PreservedAttachments, 1)
839+ assert.Equal(t, "attachment-1", resp.Cleanup.PreservedAttachments[0].ContainerID)
840+}
841+
737842 // Secrets endpoint tests
738843
739844 func TestHandler_SecretsGet_RequiresReadScope(t *testing.T) {
@@ -2712,3 +2817,133 @@ func TestHandler_TLSStatus_POSTReturns405(t *testing.T) {
27122817
27132818 assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode)
27142819 }
2820+
2821+func TestHandler_AttachmentOrphans_ReturnsOrphanedAttachments(t *testing.T) {
2822+ configSvc := inmocks.NewMockConfigService(t)
2823+ authSvc := inmocks.NewMockAuthService(t)
2824+ containerSvc := inmocks.NewMockContainerService(t)
2825+ secretSvc := inmocks.NewMockSecretService(t)
2826+ attachments := []domain.CleanupAttachment{{ContainerID: "attachment-1", Name: "postgres", Owner: "app.example.com"}}
2827+ containerSvc.EXPECT().ListOrphanedAttachments(mock.Anything).Return(attachments, nil).Once()
2828+
2829+ handler := newTestHandler(t, func(d *HandlerDeps) {
2830+ d.ConfigSvc = configSvc
2831+ d.AuthSvc = authSvc
2832+ d.ContainerSvc = containerSvc
2833+ d.SecretSvc = secretSvc
2834+ })
2835+
2836+ req := httptest.NewRequest("GET", "/admin/attachments/orphans", nil)
2837+ req = req.WithContext(ctxWithScopes("admin:config:read"))
2838+ rec := httptest.NewRecorder()
2839+
2840+ handler.ServeHTTP(rec, req)
2841+
2842+ require.Equal(t, http.StatusOK, rec.Code)
2843+ var resp struct {
2844+ Attachments []domain.CleanupAttachment `json:"attachments"`
2845+ }
2846+ require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp))
2847+ require.Len(t, resp.Attachments, 1)
2848+ assert.Equal(t, "app.example.com", resp.Attachments[0].Owner)
2849+}
2850+
2851+func TestHandler_AttachmentPrune_StopsOnlyWhenRequested(t *testing.T) {
2852+ configSvc := inmocks.NewMockConfigService(t)
2853+ authSvc := inmocks.NewMockAuthService(t)
2854+ containerSvc := inmocks.NewMockContainerService(t)
2855+ secretSvc := inmocks.NewMockSecretService(t)
2856+ report := &domain.CleanupReport{RemovedContainers: []domain.CleanupContainer{{ID: "attachment-1"}}}
2857+ containerSvc.EXPECT().CleanupOrphanedAttachments(mock.Anything, "", true).Return(report, nil).Once()
2858+
2859+ handler := newTestHandler(t, func(d *HandlerDeps) {
2860+ d.ConfigSvc = configSvc
2861+ d.AuthSvc = authSvc
2862+ d.ContainerSvc = containerSvc
2863+ d.SecretSvc = secretSvc
2864+ })
2865+
2866+ req := httptest.NewRequest("POST", "/admin/attachments/prune?stop=true", nil)
2867+ req = req.WithContext(ctxWithScopes("admin:config:write"))
2868+ rec := httptest.NewRecorder()
2869+
2870+ handler.ServeHTTP(rec, req)
2871+
2872+ require.Equal(t, http.StatusOK, rec.Code)
2873+ var got domain.CleanupReport
2874+ require.NoError(t, json.NewDecoder(rec.Body).Decode(&got))
2875+ require.Len(t, got.RemovedContainers, 1)
2876+ assert.Equal(t, "attachment-1", got.RemovedContainers[0].ID)
2877+}
2878+
2879+func TestHandler_AttachmentOrphans_ReturnsServerErrorOnListFailure(t *testing.T) {
2880+ configSvc := inmocks.NewMockConfigService(t)
2881+ authSvc := inmocks.NewMockAuthService(t)
2882+ containerSvc := inmocks.NewMockContainerService(t)
2883+ secretSvc := inmocks.NewMockSecretService(t)
2884+ containerSvc.EXPECT().ListOrphanedAttachments(mock.Anything).Return(nil, errors.New("boom")).Once()
2885+
2886+ handler := newTestHandler(t, func(d *HandlerDeps) {
2887+ d.ConfigSvc = configSvc
2888+ d.AuthSvc = authSvc
2889+ d.ContainerSvc = containerSvc
2890+ d.SecretSvc = secretSvc
2891+ })
2892+
2893+ req := httptest.NewRequest("GET", "/admin/attachments/orphans", nil)
2894+ req = req.WithContext(ctxWithScopes("admin:config:read"))
2895+ rec := httptest.NewRecorder()
2896+
2897+ handler.ServeHTTP(rec, req)
2898+
2899+ assert.Equal(t, http.StatusInternalServerError, rec.Code)
2900+}
2901+
2902+func TestHandler_AttachmentOrphans_PostReturns405(t *testing.T) {
2903+ handler := newTestHandler(t, func(d *HandlerDeps) {
2904+ d.ContainerSvc = inmocks.NewMockContainerService(t)
2905+ })
2906+ req := httptest.NewRequest("POST", "/admin/attachments/orphans", nil)
2907+ req = req.WithContext(ctxWithScopes("admin:config:read"))
2908+ rec := httptest.NewRecorder()
2909+
2910+ handler.ServeHTTP(rec, req)
2911+
2912+ assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
2913+}
2914+
2915+func TestHandler_AttachmentPrune_ReturnsServerErrorOnCleanupFailure(t *testing.T) {
2916+ configSvc := inmocks.NewMockConfigService(t)
2917+ authSvc := inmocks.NewMockAuthService(t)
2918+ containerSvc := inmocks.NewMockContainerService(t)
2919+ secretSvc := inmocks.NewMockSecretService(t)
2920+ containerSvc.EXPECT().CleanupOrphanedAttachments(mock.Anything, "", true).Return(nil, errors.New("boom")).Once()
2921+
2922+ handler := newTestHandler(t, func(d *HandlerDeps) {
2923+ d.ConfigSvc = configSvc
2924+ d.AuthSvc = authSvc
2925+ d.ContainerSvc = containerSvc
2926+ d.SecretSvc = secretSvc
2927+ })
2928+
2929+ req := httptest.NewRequest("POST", "/admin/attachments/prune?stop=true", nil)
2930+ req = req.WithContext(ctxWithScopes("admin:config:write"))
2931+ rec := httptest.NewRecorder()
2932+
2933+ handler.ServeHTTP(rec, req)
2934+
2935+ assert.Equal(t, http.StatusInternalServerError, rec.Code)
2936+}
2937+
2938+func TestHandler_AttachmentPrune_GetReturns405(t *testing.T) {
2939+ handler := newTestHandler(t, func(d *HandlerDeps) {
2940+ d.ContainerSvc = inmocks.NewMockContainerService(t)
2941+ })
2942+ req := httptest.NewRequest("GET", "/admin/attachments/prune", nil)
2943+ req = req.WithContext(ctxWithScopes("admin:config:write"))
2944+ rec := httptest.NewRecorder()
2945+
2946+ handler.ServeHTTP(rec, req)
2947+
2948+ assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
2949+}
@@ -721,6 +721,7 @@ func TestHandler_RoutesDelete_RequiresWriteScope(t *testing.T) {
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).Maybe()
724+ containerSvc.EXPECT().ReconcileRemovedRoute(mock.Anything, "app.example.com").Return(&domain.CleanupReport{Domain: "app.example.com"}, nil).Maybe()
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,110 @@ 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+
755+ req := httptest.NewRequest("DELETE", "/admin/routes/app.example.com", nil)
756+ req = req.WithContext(ctxWithScopes("admin:routes:write"))
757+ rec := httptest.NewRecorder()
758+
759+ handler.ServeHTTP(rec, req)
760+
761+ require.Equal(t, http.StatusOK, rec.Code)
762+ var resp dto.RouteDeleteResponse
763+ require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp))
764+ require.NotNil(t, resp.Cleanup)
765+ assert.Equal(t, "app.example.com", resp.Cleanup.Domain)
766+}
767+
768+func TestHandler_RoutesDelete_ReturnsServerErrorWhenRuntimeCleanupFails(t *testing.T) {
769+ configSvc := inmocks.NewMockConfigService(t)
770+ authSvc := inmocks.NewMockAuthService(t)
771+ containerSvc := inmocks.NewMockContainerService(t)
772+ secretSvc := inmocks.NewMockSecretService(t)
773+
774+ configSvc.EXPECT().RemoveRoute(mock.Anything, "app.example.com").Return(nil).Once()
775+ containerSvc.EXPECT().ReconcileRemovedRoute(mock.Anything, "app.example.com").Return(nil, errors.New("boom")).Once()
776+
777+ handler := newTestHandler(t, func(d *HandlerDeps) {
778+ d.ConfigSvc = configSvc
779+ d.AuthSvc = authSvc
780+ d.ContainerSvc = containerSvc
781+ d.SecretSvc = secretSvc
782+ })
783+
784+ req := httptest.NewRequest("DELETE", "/admin/routes/app.example.com", nil)
785+ req = req.WithContext(ctxWithScopes("admin:routes:write"))
786+ rec := httptest.NewRecorder()
787+
788+ handler.ServeHTTP(rec, req)
789+
790+ assert.Equal(t, http.StatusInternalServerError, rec.Code)
791+ assert.Contains(t, rec.Body.String(), "route removed but runtime cleanup failed")
792+}
793+
794+func TestHandler_RoutesDelete_ReconcilesRuntimeAndReturnsCleanupReport(t *testing.T) {
795+ configSvc := inmocks.NewMockConfigService(t)
796+ authSvc := inmocks.NewMockAuthService(t)
797+ containerSvc := inmocks.NewMockContainerService(t)
798+ secretSvc := inmocks.NewMockSecretService(t)
799+
800+ report := &domain.CleanupReport{
801+ Domain: "app.example.com",
802+ RemovedContainers: []domain.CleanupContainer{{
803+ ID: "container-1",
804+ Name: "gordon-app.example.com",
805+ }},
806+ PreservedAttachments: []domain.CleanupAttachment{{
807+ Name: "postgres",
808+ ContainerID: "attachment-1",
809+ Status: "running",
810+ }},
811+ }
812+
813+ removeCall := configSvc.EXPECT().RemoveRoute(mock.Anything, "app.example.com").Return(nil).Once()
814+ cleanupCall := containerSvc.EXPECT().ReconcileRemovedRoute(mock.Anything, "app.example.com").Return(report, nil).Once()
815+ mock.InOrder(removeCall, cleanupCall)
816+
817+ handler := newTestHandler(t, func(d *HandlerDeps) {
818+ d.ConfigSvc = configSvc
819+ d.AuthSvc = authSvc
820+ d.ContainerSvc = containerSvc
821+ d.SecretSvc = secretSvc
822+ })
823+
824+ req := httptest.NewRequest("DELETE", "/admin/routes/app.example.com", nil)
825+ req = req.WithContext(ctxWithScopes("admin:routes:write"))
826+ rec := httptest.NewRecorder()
827+
828+ handler.ServeHTTP(rec, req)
829+
830+ require.Equal(t, http.StatusOK, rec.Code)
831+ var resp dto.RouteDeleteResponse
832+ require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp))
833+ assert.Equal(t, "removed", resp.Status)
834+ require.NotNil(t, resp.Cleanup)
835+ assert.Equal(t, "app.example.com", resp.Cleanup.Domain)
836+ require.Len(t, resp.Cleanup.RemovedContainers, 1)
837+ assert.Equal(t, "container-1", resp.Cleanup.RemovedContainers[0].ID)
838+ require.Len(t, resp.Cleanup.PreservedAttachments, 1)
839+ assert.Equal(t, "attachment-1", resp.Cleanup.PreservedAttachments[0].ContainerID)
840+}
841+
737 // Secrets endpoint tests842 // Secrets endpoint tests
738 843
739 func TestHandler_SecretsGet_RequiresReadScope(t *testing.T) {844 func TestHandler_SecretsGet_RequiresReadScope(t *testing.T) {
@@ -2712,3 +2817,133 @@ func TestHandler_TLSStatus_POSTReturns405(t *testing.T) {
2712 2817
2713 assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode)2818 assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode)
2714 }2819 }
2820+
2821+func TestHandler_AttachmentOrphans_ReturnsOrphanedAttachments(t *testing.T) {
2822+ configSvc := inmocks.NewMockConfigService(t)
2823+ authSvc := inmocks.NewMockAuthService(t)
2824+ containerSvc := inmocks.NewMockContainerService(t)
2825+ secretSvc := inmocks.NewMockSecretService(t)
2826+ attachments := []domain.CleanupAttachment{{ContainerID: "attachment-1", Name: "postgres", Owner: "app.example.com"}}
2827+ containerSvc.EXPECT().ListOrphanedAttachments(mock.Anything).Return(attachments, nil).Once()
2828+
2829+ handler := newTestHandler(t, func(d *HandlerDeps) {
2830+ d.ConfigSvc = configSvc
2831+ d.AuthSvc = authSvc
2832+ d.ContainerSvc = containerSvc
2833+ d.SecretSvc = secretSvc
2834+ })
2835+
2836+ req := httptest.NewRequest("GET", "/admin/attachments/orphans", nil)
2837+ req = req.WithContext(ctxWithScopes("admin:config:read"))
2838+ rec := httptest.NewRecorder()
2839+
2840+ handler.ServeHTTP(rec, req)
2841+
2842+ require.Equal(t, http.StatusOK, rec.Code)
2843+ var resp struct {
2844+ Attachments []domain.CleanupAttachment `json:"attachments"`
2845+ }
2846+ require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp))
2847+ require.Len(t, resp.Attachments, 1)
2848+ assert.Equal(t, "app.example.com", resp.Attachments[0].Owner)
2849+}
2850+
2851+func TestHandler_AttachmentPrune_StopsOnlyWhenRequested(t *testing.T) {
2852+ configSvc := inmocks.NewMockConfigService(t)
2853+ authSvc := inmocks.NewMockAuthService(t)
2854+ containerSvc := inmocks.NewMockContainerService(t)
2855+ secretSvc := inmocks.NewMockSecretService(t)
2856+ report := &domain.CleanupReport{RemovedContainers: []domain.CleanupContainer{{ID: "attachment-1"}}}
2857+ containerSvc.EXPECT().CleanupOrphanedAttachments(mock.Anything, "", true).Return(report, nil).Once()
2858+
2859+ handler := newTestHandler(t, func(d *HandlerDeps) {
2860+ d.ConfigSvc = configSvc
2861+ d.AuthSvc = authSvc
2862+ d.ContainerSvc = containerSvc
2863+ d.SecretSvc = secretSvc
2864+ })
2865+
2866+ req := httptest.NewRequest("POST", "/admin/attachments/prune?stop=true", nil)
2867+ req = req.WithContext(ctxWithScopes("admin:config:write"))
2868+ rec := httptest.NewRecorder()
2869+
2870+ handler.ServeHTTP(rec, req)
2871+
2872+ require.Equal(t, http.StatusOK, rec.Code)
2873+ var got domain.CleanupReport
2874+ require.NoError(t, json.NewDecoder(rec.Body).Decode(&got))
2875+ require.Len(t, got.RemovedContainers, 1)
2876+ assert.Equal(t, "attachment-1", got.RemovedContainers[0].ID)
2877+}
2878+
2879+func TestHandler_AttachmentOrphans_ReturnsServerErrorOnListFailure(t *testing.T) {
2880+ configSvc := inmocks.NewMockConfigService(t)
2881+ authSvc := inmocks.NewMockAuthService(t)
2882+ containerSvc := inmocks.NewMockContainerService(t)
2883+ secretSvc := inmocks.NewMockSecretService(t)
2884+ containerSvc.EXPECT().ListOrphanedAttachments(mock.Anything).Return(nil, errors.New("boom")).Once()
2885+
2886+ handler := newTestHandler(t, func(d *HandlerDeps) {
2887+ d.ConfigSvc = configSvc
2888+ d.AuthSvc = authSvc
2889+ d.ContainerSvc = containerSvc
2890+ d.SecretSvc = secretSvc
2891+ })
2892+
2893+ req := httptest.NewRequest("GET", "/admin/attachments/orphans", nil)
2894+ req = req.WithContext(ctxWithScopes("admin:config:read"))
2895+ rec := httptest.NewRecorder()
2896+
2897+ handler.ServeHTTP(rec, req)
2898+
2899+ assert.Equal(t, http.StatusInternalServerError, rec.Code)
2900+}
2901+
2902+func TestHandler_AttachmentOrphans_PostReturns405(t *testing.T) {
2903+ handler := newTestHandler(t, func(d *HandlerDeps) {
2904+ d.ContainerSvc = inmocks.NewMockContainerService(t)
2905+ })
2906+ req := httptest.NewRequest("POST", "/admin/attachments/orphans", nil)
2907+ req = req.WithContext(ctxWithScopes("admin:config:read"))
2908+ rec := httptest.NewRecorder()
2909+
2910+ handler.ServeHTTP(rec, req)
2911+
2912+ assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
2913+}
2914+
2915+func TestHandler_AttachmentPrune_ReturnsServerErrorOnCleanupFailure(t *testing.T) {
2916+ configSvc := inmocks.NewMockConfigService(t)
2917+ authSvc := inmocks.NewMockAuthService(t)
2918+ containerSvc := inmocks.NewMockContainerService(t)
2919+ secretSvc := inmocks.NewMockSecretService(t)
2920+ containerSvc.EXPECT().CleanupOrphanedAttachments(mock.Anything, "", true).Return(nil, errors.New("boom")).Once()
2921+
2922+ handler := newTestHandler(t, func(d *HandlerDeps) {
2923+ d.ConfigSvc = configSvc
2924+ d.AuthSvc = authSvc
2925+ d.ContainerSvc = containerSvc
2926+ d.SecretSvc = secretSvc
2927+ })
2928+
2929+ req := httptest.NewRequest("POST", "/admin/attachments/prune?stop=true", nil)
2930+ req = req.WithContext(ctxWithScopes("admin:config:write"))
2931+ rec := httptest.NewRecorder()
2932+
2933+ handler.ServeHTTP(rec, req)
2934+
2935+ assert.Equal(t, http.StatusInternalServerError, rec.Code)
2936+}
2937+
2938+func TestHandler_AttachmentPrune_GetReturns405(t *testing.T) {
2939+ handler := newTestHandler(t, func(d *HandlerDeps) {
2940+ d.ContainerSvc = inmocks.NewMockContainerService(t)
2941+ })
2942+ req := httptest.NewRequest("GET", "/admin/attachments/prune", nil)
2943+ req = req.WithContext(ctxWithScopes("admin:config:write"))
2944+ rec := httptest.NewRecorder()
2945+
2946+ handler.ServeHTTP(rec, req)
2947+
2948+ assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
2949+}
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