detached 78bf949

fix(release): harden v2.31 readiness

commit
78bf9496a89ca6ca2e70ab62b7666629f478e07f
author
brice · · 2026-05-23 09:57 UTC
parent
35467d7
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
44 files · +1414 -114
M README.md +2 -2
@@ -137,8 +137,8 @@ See the [Deploy Action README](.github/actions/deploy/README.md) for multi-platf
137137 - Network isolation per application
138138 - Single binary, ~15MB RAM
139139
140-> [!WARNING]
141-> Gordon does not handle TLS termination. Place it behind Cloudflare Proxy or any upstream reverse proxy that manages HTTPS certificates.
140+> [!NOTE]
141+> Gordon can terminate TLS directly via its internal CA (default `server.tls_port = 8443`), static certificates, or public ACME certificates. Cloudflare and upstream reverse proxies are optional deployment choices, not requirements.
142142
143143 ## Documentation
144144
@@ -137,8 +137,8 @@ See the [Deploy Action README](.github/actions/deploy/README.md) for multi-platf
137 - Network isolation per application137 - Network isolation per application
138 - Single binary, ~15MB RAM138 - Single binary, ~15MB RAM
139 139
140-> [!WARNING]140+> [!NOTE]
141-> Gordon does not handle TLS termination. Place it behind Cloudflare Proxy or any upstream reverse proxy that manages HTTPS certificates.141+> Gordon can terminate TLS directly via its internal CA (default `server.tls_port = 8443`), static certificates, or public ACME certificates. Cloudflare and upstream reverse proxies are optional deployment choices, not requirements.
142 142
143 ## Documentation143 ## Documentation
144 144
M docs/cli/auth.md +1 -5
@@ -90,13 +90,9 @@ gordon auth show-token | pbcopy # copy to clipboard
9090 Remove the stored token for a remote.
9191
9292 ```bash
93-gordon auth logout [--remote <name>] [--revoke]
93+gordon auth logout [--remote <name>]
9494 ```
9595
96-| Option | Description |
97-|--------|-------------|
98-| `--revoke` | Also revoke the token server-side (not yet implemented) |
99-
10096 Use the global `--remote, -r` flag to target a specific remote. See [CLI Overview](./index.md).
10197
10298 Deletes the token from pass and clears token fields in `remotes.toml`.
@@ -90,13 +90,9 @@ gordon auth show-token | pbcopy # copy to clipboard
90 Remove the stored token for a remote.90 Remove the stored token for a remote.
91 91
92 ```bash92 ```bash
93-gordon auth logout [--remote <name>] [--revoke]93+gordon auth logout [--remote <name>]
94 ```94 ```
95 95
96-| Option | Description |
97-|--------|-------------|
98-| `--revoke` | Also revoke the token server-side (not yet implemented) |
99-
100 Use the global `--remote, -r` flag to target a specific remote. See [CLI Overview](./index.md).96 Use the global `--remote, -r` flag to target a specific remote. See [CLI Overview](./index.md).
101 97
102 Deletes the token from pass and clears token fields in `remotes.toml`.98 Deletes the token from pass and clears token fields in `remotes.toml`.
M docs/cli/index.md +1 -1
@@ -241,7 +241,7 @@ gordon routes list
241241 Gordon reads configuration from environment variables:
242242
243243 ```bash
244-GORDON_SERVER_PORT=8080 gordon serve
244+GORDON_SERVER_PORT=8088 gordon serve
245245 GORDON_LOGGING_LEVEL=debug gordon serve
246246 ```
247247
@@ -241,7 +241,7 @@ gordon routes list
241 Gordon reads configuration from environment variables:241 Gordon reads configuration from environment variables:
242 242
243 ```bash243 ```bash
244-GORDON_SERVER_PORT=8080 gordon serve244+GORDON_SERVER_PORT=8088 gordon serve
245 GORDON_LOGGING_LEVEL=debug gordon serve245 GORDON_LOGGING_LEVEL=debug gordon serve
246 ```246 ```
247 247
M docs/cli/serve.md +1 -1
@@ -57,7 +57,7 @@ gordon serve --config /path/to/gordon.toml
5757 gordon serve -c ./my-config.toml
5858
5959 # With environment override
60-GORDON_SERVER_PORT=8080 gordon serve
60+GORDON_SERVER_PORT=8088 gordon serve
6161 GORDON_LOGGING_LEVEL=debug gordon serve
6262 ```
6363
@@ -57,7 +57,7 @@ gordon serve --config /path/to/gordon.toml
57 gordon serve -c ./my-config.toml57 gordon serve -c ./my-config.toml
58 58
59 # With environment override59 # With environment override
60-GORDON_SERVER_PORT=8080 gordon serve60+GORDON_SERVER_PORT=8088 gordon serve
61 GORDON_LOGGING_LEVEL=debug gordon serve61 GORDON_LOGGING_LEVEL=debug gordon serve
62 ```62 ```
63 63
M docs/cli/status.md +5 -4
@@ -21,9 +21,9 @@ gordon status --remote https://gordon.mydomain.com --token $TOKEN
2121 ```
2222 Gordon Status
2323
24-Domain: registry.example.com
24+Gordon Domain: gordon.example.com
2525 Registry Port: 5000
26-Server Port: 8080
26+Server Port: 8088
2727 Routes: 3
2828 Auto-Route: true
2929 Network Isolation: false
@@ -38,9 +38,9 @@ Container Status:
3838
3939 | Field | Description |
4040 |-------|-------------|
41-| Domain | Registry domain from configuration |
41+| Gordon Domain | Public Gordon domain from configuration |
4242 | Registry Port | Docker registry port |
43-| Server Port | Gordon admin API port |
43+| Server Port | Gordon HTTP proxy port |
4444 | Routes | Total configured routes |
4545 | Auto-Route | Whether auto-routing is enabled |
4646 | Network Isolation | Whether network isolation is enabled |
@@ -51,6 +51,7 @@ Container Status:
5151 | State | Description |
5252 |-------|-------------|
5353 | running | Container is running and healthy |
54+| restarting | Container is in runtime restart backoff or restart cycle |
5455 | stopped | Container was stopped |
5556 | exited | Container exited (check logs for errors) |
5657 | paused | Container is paused |
@@ -21,9 +21,9 @@ gordon status --remote https://gordon.mydomain.com --token $TOKEN
21 ```21 ```
22 Gordon Status22 Gordon Status
23 23
24-Domain: registry.example.com24+Gordon Domain: gordon.example.com
25 Registry Port: 500025 Registry Port: 5000
26-Server Port: 808026+Server Port: 8088
27 Routes: 327 Routes: 3
28 Auto-Route: true28 Auto-Route: true
29 Network Isolation: false29 Network Isolation: false
@@ -38,9 +38,9 @@ Container Status:
38 38
39 | Field | Description |39 | Field | Description |
40 |-------|-------------|40 |-------|-------------|
41-| Domain | Registry domain from configuration |41+| Gordon Domain | Public Gordon domain from configuration |
42 | Registry Port | Docker registry port |42 | Registry Port | Docker registry port |
43-| Server Port | Gordon admin API port |43+| Server Port | Gordon HTTP proxy port |
44 | Routes | Total configured routes |44 | Routes | Total configured routes |
45 | Auto-Route | Whether auto-routing is enabled |45 | Auto-Route | Whether auto-routing is enabled |
46 | Network Isolation | Whether network isolation is enabled |46 | Network Isolation | Whether network isolation is enabled |
@@ -51,6 +51,7 @@ Container Status:
51 | State | Description |51 | State | Description |
52 |-------|-------------|52 |-------|-------------|
53 | running | Container is running and healthy |53 | running | Container is running and healthy |
54+| restarting | Container is in runtime restart backoff or restart cycle |
54 | stopped | Container was stopped |55 | stopped | Container was stopped |
55 | exited | Container exited (check logs for errors) |56 | exited | Container exited (check logs for errors) |
56 | paused | Container is paused |57 | paused | Container is paused |
M docs/config/index.md +3 -2
@@ -13,7 +13,8 @@ Gordon uses a single TOML configuration file located at `~/.config/gordon/gordon
1313
1414 ```toml
1515 [server]
16-port = 8080
16+port = 8088
17+tls_port = 8443
1718 registry_port = 5000
1819 gordon_domain = "gordon.mydomain.com"
1920
@@ -272,7 +273,7 @@ gordon reload
272273 Configuration values can be overridden with environment variables:
273274
274275 ```bash
275-GORDON_SERVER_PORT=8080 gordon serve
276+GORDON_SERVER_PORT=8088 gordon serve
276277 GORDON_LOGGING_LEVEL=debug gordon serve
277278 ```
278279
@@ -13,7 +13,8 @@ Gordon uses a single TOML configuration file located at `~/.config/gordon/gordon
13 13
14 ```toml14 ```toml
15 [server]15 [server]
16-port = 808016+port = 8088
17+tls_port = 8443
17 registry_port = 500018 registry_port = 5000
18 gordon_domain = "gordon.mydomain.com"19 gordon_domain = "gordon.mydomain.com"
19 20
@@ -272,7 +273,7 @@ gordon reload
272 Configuration values can be overridden with environment variables:273 Configuration values can be overridden with environment variables:
273 274
274 ```bash275 ```bash
275-GORDON_SERVER_PORT=8080 gordon serve276+GORDON_SERVER_PORT=8088 gordon serve
276 GORDON_LOGGING_LEVEL=debug gordon serve277 GORDON_LOGGING_LEVEL=debug gordon serve
277 ```278 ```
278 279
M docs/config/logging.md +30 -0
@@ -22,6 +22,13 @@ dir = "~/.gordon/logs/containers"
2222 max_size = 100
2323 max_backups = 3
2424 max_age = 28
25+
26+[logging.access_log]
27+enabled = false
28+format = "json"
29+output = "stdout"
30+exclude_health_checks = true
31+syslog_identifier = "gordon-access"
2532 ```
2633
2734 ## Options
@@ -56,6 +63,22 @@ The Admin API and `gordon logs` read from the process log file. Keep
5663 | `container_logs.max_backups` | int | `3` | Old files to keep |
5764 | `container_logs.max_age` | int | `28` | Days to keep files |
5865
66+### Access Log
67+
68+| Option | Type | Default | Description |
69+|--------|------|---------|-------------|
70+| `access_log.enabled` | bool | `false` | Enable a dedicated HTTP access log |
71+| `access_log.format` | string | `"json"` | Access log format: `json`, `clf`, `combined` |
72+| `access_log.output` | string | `"stdout"` | Output sink: `stdout`, `file`, `journald` |
73+| `access_log.file_path` | string | - | File path when `output = "file"` |
74+| `access_log.max_size` | int | `100` | Max file size in MB for file output |
75+| `access_log.max_backups` | int | `3` | Old files to keep for file output |
76+| `access_log.max_age` | int | `28` | Days to keep file output |
77+| `access_log.exclude_health_checks` | bool | `true` | Skip health and readiness checks |
78+| `access_log.syslog_identifier` | string | `"gordon-access"` | Journald identifier |
79+
80+Use the access log for reverse-proxy traffic analysis, CrowdSec/fail2ban ingestion, or request auditing without mixing entries into the main process log.
81+
5982 ## Log Levels
6083
6184 | Level | Description |
@@ -114,6 +137,13 @@ dir = "./logs/containers"
114137 max_size = 10
115138 max_backups = 2
116139 max_age = 7
140+
141+[logging.access_log]
142+enabled = true
143+format = "json"
144+output = "file"
145+file_path = "./logs/access.log"
146+exclude_health_checks = true
117147 ```
118148
119149 ### Production
@@ -22,6 +22,13 @@ dir = "~/.gordon/logs/containers"
22 max_size = 10022 max_size = 100
23 max_backups = 323 max_backups = 3
24 max_age = 2824 max_age = 28
25+
26+[logging.access_log]
27+enabled = false
28+format = "json"
29+output = "stdout"
30+exclude_health_checks = true
31+syslog_identifier = "gordon-access"
25 ```32 ```
26 33
27 ## Options34 ## Options
@@ -56,6 +63,22 @@ The Admin API and `gordon logs` read from the process log file. Keep
56 | `container_logs.max_backups` | int | `3` | Old files to keep |63 | `container_logs.max_backups` | int | `3` | Old files to keep |
57 | `container_logs.max_age` | int | `28` | Days to keep files |64 | `container_logs.max_age` | int | `28` | Days to keep files |
58 65
66+### Access Log
67+
68+| Option | Type | Default | Description |
69+|--------|------|---------|-------------|
70+| `access_log.enabled` | bool | `false` | Enable a dedicated HTTP access log |
71+| `access_log.format` | string | `"json"` | Access log format: `json`, `clf`, `combined` |
72+| `access_log.output` | string | `"stdout"` | Output sink: `stdout`, `file`, `journald` |
73+| `access_log.file_path` | string | - | File path when `output = "file"` |
74+| `access_log.max_size` | int | `100` | Max file size in MB for file output |
75+| `access_log.max_backups` | int | `3` | Old files to keep for file output |
76+| `access_log.max_age` | int | `28` | Days to keep file output |
77+| `access_log.exclude_health_checks` | bool | `true` | Skip health and readiness checks |
78+| `access_log.syslog_identifier` | string | `"gordon-access"` | Journald identifier |
79+
80+Use the access log for reverse-proxy traffic analysis, CrowdSec/fail2ban ingestion, or request auditing without mixing entries into the main process log.
81+
59 ## Log Levels82 ## Log Levels
60 83
61 | Level | Description |84 | Level | Description |
@@ -114,6 +137,13 @@ dir = "./logs/containers"
114 max_size = 10137 max_size = 10
115 max_backups = 2138 max_backups = 2
116 max_age = 7139 max_age = 7
140+
141+[logging.access_log]
142+enabled = true
143+format = "json"
144+output = "file"
145+file_path = "./logs/access.log"
146+exclude_health_checks = true
117 ```147 ```
118 148
119 ### Production149 ### Production
M docs/config/reference.md +21 -2
@@ -38,7 +38,7 @@ polling_interval = "5s" # Interval between DNS-01 propagation
3838 enabled = true # Enable registry authentication (default: true)
3939 secrets_backend = "unsafe" # "pass", "sops", or "unsafe"
4040 token_secret = "" # Path in secrets backend to JWT signing key (REQUIRED)
41-token_expiry = "720h" # Token expiry duration (720h = 30 days)
41+token_expiry = "30d" # Token expiry duration
4242 access_token_ttl = "15m" # Ephemeral access token lifetime (default: 15m)
4343
4444 # =============================================================================
@@ -81,6 +81,17 @@ max_size = 100 # Max size in MB before rotation
8181 max_backups = 3 # Number of old files to keep
8282 max_age = 28 # Days to keep old files
8383
84+[logging.access_log]
85+enabled = false # Dedicated HTTP access log for reverse-proxy traffic
86+format = "json" # "json", "clf", or "combined"
87+output = "stdout" # "stdout", "file", or "journald"
88+file_path = "" # Required when output = "file"
89+max_size = 100 # Max size in MB before rotation (file output)
90+max_backups = 3 # Number of old files to keep (file output)
91+max_age = 28 # Days to keep old files (file output)
92+exclude_health_checks = true # Skip noisy readiness/liveness requests
93+syslog_identifier = "gordon-access" # Journald identifier when output = "journald"
94+
8495 # =============================================================================
8596 # TELEMETRY (OpenTelemetry)
8697 # =============================================================================
@@ -238,6 +249,14 @@ keep_last = 3 # Keep N newest tags per repository
238249 | `logging.container_logs.max_size` | `100` | 100 MB |
239250 | `logging.container_logs.max_backups` | `3` | Keep 3 old files |
240251 | `logging.container_logs.max_age` | `28` | 28 days |
252+| `logging.access_log.enabled` | `false` | Dedicated HTTP access log disabled |
253+| `logging.access_log.format` | `"json"` | Access log format (`json`, `clf`, `combined`) |
254+| `logging.access_log.output` | `"stdout"` | Access log sink (`stdout`, `file`, `journald`) |
255+| `logging.access_log.max_size` | `100` | 100 MB for file output |
256+| `logging.access_log.max_backups` | `3` | Keep 3 old files for file output |
257+| `logging.access_log.max_age` | `28` | 28 days for file output |
258+| `logging.access_log.exclude_health_checks` | `true` | Skip health-check requests |
259+| `logging.access_log.syslog_identifier` | `"gordon-access"` | Journald identifier |
241260 | `telemetry.enabled` | `false` | Enable OTLP telemetry export |
242261 | `telemetry.endpoint` | `""` | OTLP HTTP endpoint URL |
243262 | `telemetry.auth_token` | `""` | Base64 `user:password` for Basic auth |
@@ -285,7 +304,7 @@ GORDON_<SECTION>_<KEY>=value
285304
286305 Examples:
287306 ```bash
288-GORDON_SERVER_PORT=8080
307+GORDON_SERVER_PORT=8088
289308 GORDON_SERVER_GORDON_DOMAIN=gordon.example.com
290309 GORDON_AUTH_ENABLED=true
291310 GORDON_LOGGING_LEVEL=debug
@@ -38,7 +38,7 @@ polling_interval = "5s" # Interval between DNS-01 propagation
38 enabled = true # Enable registry authentication (default: true)38 enabled = true # Enable registry authentication (default: true)
39 secrets_backend = "unsafe" # "pass", "sops", or "unsafe"39 secrets_backend = "unsafe" # "pass", "sops", or "unsafe"
40 token_secret = "" # Path in secrets backend to JWT signing key (REQUIRED)40 token_secret = "" # Path in secrets backend to JWT signing key (REQUIRED)
41-token_expiry = "720h" # Token expiry duration (720h = 30 days)41+token_expiry = "30d" # Token expiry duration
42 access_token_ttl = "15m" # Ephemeral access token lifetime (default: 15m)42 access_token_ttl = "15m" # Ephemeral access token lifetime (default: 15m)
43 43
44 # =============================================================================44 # =============================================================================
@@ -81,6 +81,17 @@ max_size = 100 # Max size in MB before rotation
81 max_backups = 3 # Number of old files to keep81 max_backups = 3 # Number of old files to keep
82 max_age = 28 # Days to keep old files82 max_age = 28 # Days to keep old files
83 83
84+[logging.access_log]
85+enabled = false # Dedicated HTTP access log for reverse-proxy traffic
86+format = "json" # "json", "clf", or "combined"
87+output = "stdout" # "stdout", "file", or "journald"
88+file_path = "" # Required when output = "file"
89+max_size = 100 # Max size in MB before rotation (file output)
90+max_backups = 3 # Number of old files to keep (file output)
91+max_age = 28 # Days to keep old files (file output)
92+exclude_health_checks = true # Skip noisy readiness/liveness requests
93+syslog_identifier = "gordon-access" # Journald identifier when output = "journald"
94+
84 # =============================================================================95 # =============================================================================
85 # TELEMETRY (OpenTelemetry)96 # TELEMETRY (OpenTelemetry)
86 # =============================================================================97 # =============================================================================
@@ -238,6 +249,14 @@ keep_last = 3 # Keep N newest tags per repository
238 | `logging.container_logs.max_size` | `100` | 100 MB |249 | `logging.container_logs.max_size` | `100` | 100 MB |
239 | `logging.container_logs.max_backups` | `3` | Keep 3 old files |250 | `logging.container_logs.max_backups` | `3` | Keep 3 old files |
240 | `logging.container_logs.max_age` | `28` | 28 days |251 | `logging.container_logs.max_age` | `28` | 28 days |
252+| `logging.access_log.enabled` | `false` | Dedicated HTTP access log disabled |
253+| `logging.access_log.format` | `"json"` | Access log format (`json`, `clf`, `combined`) |
254+| `logging.access_log.output` | `"stdout"` | Access log sink (`stdout`, `file`, `journald`) |
255+| `logging.access_log.max_size` | `100` | 100 MB for file output |
256+| `logging.access_log.max_backups` | `3` | Keep 3 old files for file output |
257+| `logging.access_log.max_age` | `28` | 28 days for file output |
258+| `logging.access_log.exclude_health_checks` | `true` | Skip health-check requests |
259+| `logging.access_log.syslog_identifier` | `"gordon-access"` | Journald identifier |
241 | `telemetry.enabled` | `false` | Enable OTLP telemetry export |260 | `telemetry.enabled` | `false` | Enable OTLP telemetry export |
242 | `telemetry.endpoint` | `""` | OTLP HTTP endpoint URL |261 | `telemetry.endpoint` | `""` | OTLP HTTP endpoint URL |
243 | `telemetry.auth_token` | `""` | Base64 `user:password` for Basic auth |262 | `telemetry.auth_token` | `""` | Base64 `user:password` for Basic auth |
@@ -285,7 +304,7 @@ GORDON_<SECTION>_<KEY>=value
285 304
286 Examples:305 Examples:
287 ```bash306 ```bash
288-GORDON_SERVER_PORT=8080307+GORDON_SERVER_PORT=8088
289 GORDON_SERVER_GORDON_DOMAIN=gordon.example.com308 GORDON_SERVER_GORDON_DOMAIN=gordon.example.com
290 GORDON_AUTH_ENABLED=true309 GORDON_AUTH_ENABLED=true
291 GORDON_LOGGING_LEVEL=debug310 GORDON_LOGGING_LEVEL=debug
M docs/config/server.md +8 -8
@@ -48,14 +48,14 @@ The `port` setting controls where Gordon listens for HTTP traffic:
4848
4949 ```toml
5050 [server]
51-port = 8080 # Use 8080 for rootless containers with port forwarding
51+port = 8088 # Default high port for rootless or forwarded setups
5252 ```
5353
5454 For rootless containers, you'll typically use a high port and configure firewall port forwarding:
5555
5656 ```bash
57-# Forward port 80 to 8080
58-sudo firewall-cmd --permanent --add-forward-port=port=80:proto=tcp:toport=8080
57+# Forward port 80 to 8088
58+sudo firewall-cmd --permanent --add-forward-port=port=80:proto=tcp:toport=8088
5959 ```
6060
6161 ### Internal CA and TLS
@@ -115,7 +115,7 @@ HTTP-01 requires public access to port 80 for each hostname being validated. If
115115
116116 Gordon limits new ACME certificate orders to `obtain_batch_size` per reconcile run (default `1`) so enabling ACME on an existing multi-route server does not burst through every route and hit Let's Encrypt rate limits. Later reloads, restarts, or other explicit reconcile runs continue issuing remaining certificates.
117117
118-If the initial ACME reconcile fails (e.g. due to a transient network error or misconfiguration), Gordon logs the failure but still starts the renewal loop for already cached certificates. Operators should fix the underlying error and restart/reload Gordon to trigger a new obtain attempt for missing certificates.
118+If the initial ACME reconcile fails (e.g. due to a transient network error or misconfiguration), Gordon logs the failure and keeps serving any existing certificates. The renewal loop retries reconcile work periodically, so missing certificates self-heal after the underlying issue is fixed without requiring a restart.
119119
120120 For Cloudflare Full/Strict, Cloudflare terminates browser TLS at the edge and connects to Gordon over HTTPS. A public ACME certificate served by Gordon is the preferred origin certificate because Cloudflare Strict can validate it without custom origin trust. Cloudflare Flexible mode (HTTPS at the edge, HTTP to Gordon) is not end-to-end HTTPS.
121121
@@ -412,7 +412,7 @@ HSTS headers are sent automatically on the HTTPS listener (when `r.TLS != nil`).
412412
413413 ```toml
414414 [server]
415-port = 8080
415+port = 8088
416416 registry_port = 5000
417417 gordon_domain = "gordon.local"
418418 data_dir = "./dev-data"
@@ -422,7 +422,7 @@ data_dir = "./dev-data"
422422
423423 ```toml
424424 [server]
425-port = 8080
425+port = 8088
426426 registry_port = 5000
427427 gordon_domain = "gordon.company.com"
428428 data_dir = "/var/lib/gordon"
@@ -432,14 +432,14 @@ data_dir = "/var/lib/gordon"
432432
433433 ```toml
434434 [server]
435-port = 8080 # High port for rootless
435+port = 8088 # High port for rootless
436436 registry_port = 5000
437437 gordon_domain = "gordon.mydomain.com"
438438 ```
439439
440440 With firewall port forwarding:
441441 ```bash
442-sudo firewall-cmd --permanent --add-forward-port=port=80:proto=tcp:toport=8080
442+sudo firewall-cmd --permanent --add-forward-port=port=80:proto=tcp:toport=8088
443443 sudo firewall-cmd --reload
444444 ```
445445
@@ -48,14 +48,14 @@ The `port` setting controls where Gordon listens for HTTP traffic:
48 48
49 ```toml49 ```toml
50 [server]50 [server]
51-port = 8080 # Use 8080 for rootless containers with port forwarding51+port = 8088 # Default high port for rootless or forwarded setups
52 ```52 ```
53 53
54 For rootless containers, you'll typically use a high port and configure firewall port forwarding:54 For rootless containers, you'll typically use a high port and configure firewall port forwarding:
55 55
56 ```bash56 ```bash
57-# Forward port 80 to 808057+# Forward port 80 to 8088
58-sudo firewall-cmd --permanent --add-forward-port=port=80:proto=tcp:toport=808058+sudo firewall-cmd --permanent --add-forward-port=port=80:proto=tcp:toport=8088
59 ```59 ```
60 60
61 ### Internal CA and TLS61 ### Internal CA and TLS
@@ -115,7 +115,7 @@ HTTP-01 requires public access to port 80 for each hostname being validated. If
115 115
116 Gordon limits new ACME certificate orders to `obtain_batch_size` per reconcile run (default `1`) so enabling ACME on an existing multi-route server does not burst through every route and hit Let's Encrypt rate limits. Later reloads, restarts, or other explicit reconcile runs continue issuing remaining certificates.116 Gordon limits new ACME certificate orders to `obtain_batch_size` per reconcile run (default `1`) so enabling ACME on an existing multi-route server does not burst through every route and hit Let's Encrypt rate limits. Later reloads, restarts, or other explicit reconcile runs continue issuing remaining certificates.
117 117
118-If the initial ACME reconcile fails (e.g. due to a transient network error or misconfiguration), Gordon logs the failure but still starts the renewal loop for already cached certificates. Operators should fix the underlying error and restart/reload Gordon to trigger a new obtain attempt for missing certificates.118+If the initial ACME reconcile fails (e.g. due to a transient network error or misconfiguration), Gordon logs the failure and keeps serving any existing certificates. The renewal loop retries reconcile work periodically, so missing certificates self-heal after the underlying issue is fixed without requiring a restart.
119 119
120 For Cloudflare Full/Strict, Cloudflare terminates browser TLS at the edge and connects to Gordon over HTTPS. A public ACME certificate served by Gordon is the preferred origin certificate because Cloudflare Strict can validate it without custom origin trust. Cloudflare Flexible mode (HTTPS at the edge, HTTP to Gordon) is not end-to-end HTTPS.120 For Cloudflare Full/Strict, Cloudflare terminates browser TLS at the edge and connects to Gordon over HTTPS. A public ACME certificate served by Gordon is the preferred origin certificate because Cloudflare Strict can validate it without custom origin trust. Cloudflare Flexible mode (HTTPS at the edge, HTTP to Gordon) is not end-to-end HTTPS.
121 121
@@ -412,7 +412,7 @@ HSTS headers are sent automatically on the HTTPS listener (when `r.TLS != nil`).
412 412
413 ```toml413 ```toml
414 [server]414 [server]
415-port = 8080415+port = 8088
416 registry_port = 5000416 registry_port = 5000
417 gordon_domain = "gordon.local"417 gordon_domain = "gordon.local"
418 data_dir = "./dev-data"418 data_dir = "./dev-data"
@@ -422,7 +422,7 @@ data_dir = "./dev-data"
422 422
423 ```toml423 ```toml
424 [server]424 [server]
425-port = 8080425+port = 8088
426 registry_port = 5000426 registry_port = 5000
427 gordon_domain = "gordon.company.com"427 gordon_domain = "gordon.company.com"
428 data_dir = "/var/lib/gordon"428 data_dir = "/var/lib/gordon"
@@ -432,14 +432,14 @@ data_dir = "/var/lib/gordon"
432 432
433 ```toml433 ```toml
434 [server]434 [server]
435-port = 8080 # High port for rootless435+port = 8088 # High port for rootless
436 registry_port = 5000436 registry_port = 5000
437 gordon_domain = "gordon.mydomain.com"437 gordon_domain = "gordon.mydomain.com"
438 ```438 ```
439 439
440 With firewall port forwarding:440 With firewall port forwarding:
441 ```bash441 ```bash
442-sudo firewall-cmd --permanent --add-forward-port=port=80:proto=tcp:toport=8080442+sudo firewall-cmd --permanent --add-forward-port=port=80:proto=tcp:toport=8088
443 sudo firewall-cmd --reload443 sudo firewall-cmd --reload
444 ```444 ```
445 445
M docs/deployment/rollback.md +12 -0
@@ -2,6 +2,18 @@
22
33 Roll back to previous versions when deployments fail.
44
5+## Recommended: Use `gordon pin`
6+
7+`gordon pin` is the built-in rollback and roll-forward workflow for route images:
8+
9+```bash
10+gordon pin app.example.com
11+gordon pin app.example.com --tag v2.30.1
12+gordon pin list app.example.com
13+```
14+
15+Use it when the target image already exists in the Gordon registry and you want Gordon to update the route and redeploy it for you.
16+
517 ## Rollback Strategies
618
719 ### 1. Config-Based Rollback
@@ -2,6 +2,18 @@
2 2
3 Roll back to previous versions when deployments fail.3 Roll back to previous versions when deployments fail.
4 4
5+## Recommended: Use `gordon pin`
6+
7+`gordon pin` is the built-in rollback and roll-forward workflow for route images:
8+
9+```bash
10+gordon pin app.example.com
11+gordon pin app.example.com --tag v2.30.1
12+gordon pin list app.example.com
13+```
14+
15+Use it when the target image already exists in the Gordon registry and you want Gordon to update the route and redeploy it for you.
16+
5 ## Rollback Strategies17 ## Rollback Strategies
6 18
7 ### 1. Config-Based Rollback19 ### 1. Config-Based Rollback
M docs/getting-started.md +4 -1
@@ -73,8 +73,9 @@ Edit `~/.config/gordon/gordon.toml`:
7373
7474 ```toml
7575 [server]
76-port = 8080 # Proxy port (use with Cloudflare/rootless)
76+port = 8088 # HTTP proxy port (default)
7777 registry_port = 5000 # Registry port
78+tls_port = 8443 # HTTPS listener (default)
7879 gordon_domain = "gordon.mydomain.com" # Registry + Admin API domain
7980
8081 [routes]
@@ -98,6 +99,8 @@ Why wildcard (`*`)?
9899
99100 If your DNS provider supports wildcard CNAME flattening (Cloudflare does), `* -> gordon.mydomain.com` is usually the cleanest option.
100101
102+> **Important for Cloudflare or other proxied setups:** when Gordon's internal CA is enabled (default `tls_port = 8443`), add your proxy edge IPs to `server.proxy_allowed_ips` or proxied HTTP traffic will get `403 Forbidden`. See [Installation](./installation.md#proxy-origin-allowlist). If you do not want Gordon to manage TLS at all, set `tls_port = 0`.
103+
101104 ## 6. Start Gordon as a Service
102105
103106 ```bash
@@ -73,8 +73,9 @@ Edit `~/.config/gordon/gordon.toml`:
73 73
74 ```toml74 ```toml
75 [server]75 [server]
76-port = 8080 # Proxy port (use with Cloudflare/rootless)76+port = 8088 # HTTP proxy port (default)
77 registry_port = 5000 # Registry port77 registry_port = 5000 # Registry port
78+tls_port = 8443 # HTTPS listener (default)
78 gordon_domain = "gordon.mydomain.com" # Registry + Admin API domain79 gordon_domain = "gordon.mydomain.com" # Registry + Admin API domain
79 80
80 [routes]81 [routes]
@@ -98,6 +99,8 @@ Why wildcard (`*`)?
98 99
99 If your DNS provider supports wildcard CNAME flattening (Cloudflare does), `* -> gordon.mydomain.com` is usually the cleanest option.100 If your DNS provider supports wildcard CNAME flattening (Cloudflare does), `* -> gordon.mydomain.com` is usually the cleanest option.
100 101
102+> **Important for Cloudflare or other proxied setups:** when Gordon's internal CA is enabled (default `tls_port = 8443`), add your proxy edge IPs to `server.proxy_allowed_ips` or proxied HTTP traffic will get `403 Forbidden`. See [Installation](./installation.md#proxy-origin-allowlist). If you do not want Gordon to manage TLS at all, set `tls_port = 0`.
103+
101 ## 6. Start Gordon as a Service104 ## 6. Start Gordon as a Service
102 105
103 ```bash106 ```bash
M docs/index.md +2 -1
@@ -78,7 +78,8 @@ Gordon runs on your VPS and provides:
7878 - Linux VPS (Ubuntu/Debian recommended)
7979 - Docker or Podman runtime
8080 - Domain pointing to your server
81-- Cloudflare account for HTTPS (free tier works)
81+- Domain pointing to your server
82+- Optional Cloudflare account or other reverse proxy for edge TLS
8283
8384 > **Note:** Gordon can run behind Cloudflare/nginx, or terminate TLS directly via its internal CA (enabled by default on `server.tls_port`). Set `tls_port = 0` to disable.
8485
@@ -78,7 +78,8 @@ Gordon runs on your VPS and provides:
78 - Linux VPS (Ubuntu/Debian recommended)78 - Linux VPS (Ubuntu/Debian recommended)
79 - Docker or Podman runtime79 - Docker or Podman runtime
80 - Domain pointing to your server80 - Domain pointing to your server
81-- Cloudflare account for HTTPS (free tier works)81+- Domain pointing to your server
82+- Optional Cloudflare account or other reverse proxy for edge TLS
82 83
83 > **Note:** Gordon can run behind Cloudflare/nginx, or terminate TLS directly via its internal CA (enabled by default on `server.tls_port`). Set `tls_port = 0` to disable.84 > **Note:** Gordon can run behind Cloudflare/nginx, or terminate TLS directly via its internal CA (enabled by default on `server.tls_port`). Set `tls_port = 0` to disable.
84 85
M docs/installation.md +16 -4
@@ -249,16 +249,16 @@ Point your domains to your server:
249249
250250 ## Cloudflare SSL Mode
251251
252-Gordon serves HTTP by default. Cloudflare must connect to your origin over HTTP unless you explicitly enable TLS.
252+Gordon serves HTTP on `server.port` and HTTPS on `server.tls_port` by default. Choose the Cloudflare mode that matches how you want Cloudflare to reach your origin.
253253
254254 | Mode | How it works | Use when |
255255 |------|-------------|----------|
256-| **Flexible** | Cloudflare terminates TLS; connects to Gordon on HTTP | Gordon has no TLS cert (default setup) |
257-| **Full (Strict)** | Cloudflare connects to Gordon over HTTPS with a valid cert | Set `server.tls_port` (default 8443) and provide a Cloudflare Origin CA cert via `server.tls_cert_file` and `server.tls_key_file` |
256+| **Flexible** | Cloudflare terminates TLS; connects to Gordon on HTTP | You want edge HTTPS only, or you set `tls_port = 0` |
257+| **Full (Strict)** | Cloudflare connects to Gordon over HTTPS with a valid cert | You want end-to-end HTTPS using Gordon's public ACME certs or a static cert (`tls_cert_file` / `tls_key_file`) |
258258
259259 Wrong mode causes: **521** (Cloudflare can't connect) or **525** (TLS handshake failed).
260260
261-> **Important:** You must also set `proxy_allowed_ips` with Cloudflare edge IPs — see [Proxy Origin Allowlist](#proxy-origin-allowlist) below.
261+> **Important:** When `tls_port != 0`, you must also set `proxy_allowed_ips` with Cloudflare edge IPs — see [Proxy Origin Allowlist](#proxy-origin-allowlist) below.
262262
263263 > **Rootless note:** Unprivileged users can't bind port 80. Forward port 80→8088 (or your configured port) via firewall — see the firewall section above.
264264
@@ -283,6 +283,18 @@ Without this setting, Cloudflare traffic receives `403 Forbidden: Only certifica
283283
284284 > **Note:** This is separate from `[api.rate_limit] trusted_proxies`, which controls IP extraction from `X-Forwarded-For`. Both should list your proxy IPs. See [Proxy Origin IP Allowlist](./config/server.md#proxy-origin-ip-allowlist) for details.
285285
286+## Installing a Specific Version or Pre-Release
287+
288+```bash
289+# Install an exact version
290+curl -fsSL https://gordon.bnema.dev/install | GORDON_VERSION=v2.30.1 bash
291+
292+# Install the latest pre-release instead of the latest stable release
293+curl -fsSL https://gordon.bnema.dev/install | GORDON_PRERELEASE=1 bash
294+```
295+
296+By default, the installer resolves `latest` to the newest stable release and verifies the downloaded checksum before installing.
297+
286298 ## Verify Installation
287299
288300 ```bash
@@ -249,16 +249,16 @@ Point your domains to your server:
249 249
250 ## Cloudflare SSL Mode250 ## Cloudflare SSL Mode
251 251
252-Gordon serves HTTP by default. Cloudflare must connect to your origin over HTTP unless you explicitly enable TLS.252+Gordon serves HTTP on `server.port` and HTTPS on `server.tls_port` by default. Choose the Cloudflare mode that matches how you want Cloudflare to reach your origin.
253 253
254 | Mode | How it works | Use when |254 | Mode | How it works | Use when |
255 |------|-------------|----------|255 |------|-------------|----------|
256-| **Flexible** | Cloudflare terminates TLS; connects to Gordon on HTTP | Gordon has no TLS cert (default setup) |256+| **Flexible** | Cloudflare terminates TLS; connects to Gordon on HTTP | You want edge HTTPS only, or you set `tls_port = 0` |
257-| **Full (Strict)** | Cloudflare connects to Gordon over HTTPS with a valid cert | Set `server.tls_port` (default 8443) and provide a Cloudflare Origin CA cert via `server.tls_cert_file` and `server.tls_key_file` |257+| **Full (Strict)** | Cloudflare connects to Gordon over HTTPS with a valid cert | You want end-to-end HTTPS using Gordon's public ACME certs or a static cert (`tls_cert_file` / `tls_key_file`) |
258 258
259 Wrong mode causes: **521** (Cloudflare can't connect) or **525** (TLS handshake failed).259 Wrong mode causes: **521** (Cloudflare can't connect) or **525** (TLS handshake failed).
260 260
261-> **Important:** You must also set `proxy_allowed_ips` with Cloudflare edge IPs — see [Proxy Origin Allowlist](#proxy-origin-allowlist) below.261+> **Important:** When `tls_port != 0`, you must also set `proxy_allowed_ips` with Cloudflare edge IPs — see [Proxy Origin Allowlist](#proxy-origin-allowlist) below.
262 262
263 > **Rootless note:** Unprivileged users can't bind port 80. Forward port 80→8088 (or your configured port) via firewall — see the firewall section above.263 > **Rootless note:** Unprivileged users can't bind port 80. Forward port 80→8088 (or your configured port) via firewall — see the firewall section above.
264 264
@@ -283,6 +283,18 @@ Without this setting, Cloudflare traffic receives `403 Forbidden: Only certifica
283 283
284 > **Note:** This is separate from `[api.rate_limit] trusted_proxies`, which controls IP extraction from `X-Forwarded-For`. Both should list your proxy IPs. See [Proxy Origin IP Allowlist](./config/server.md#proxy-origin-ip-allowlist) for details.284 > **Note:** This is separate from `[api.rate_limit] trusted_proxies`, which controls IP extraction from `X-Forwarded-For`. Both should list your proxy IPs. See [Proxy Origin IP Allowlist](./config/server.md#proxy-origin-ip-allowlist) for details.
285 285
286+## Installing a Specific Version or Pre-Release
287+
288+```bash
289+# Install an exact version
290+curl -fsSL https://gordon.bnema.dev/install | GORDON_VERSION=v2.30.1 bash
291+
292+# Install the latest pre-release instead of the latest stable release
293+curl -fsSL https://gordon.bnema.dev/install | GORDON_PRERELEASE=1 bash
294+```
295+
296+By default, the installer resolves `latest` to the newest stable release and verifies the downloaded checksum before installing.
297+
286 ## Verify Installation298 ## Verify Installation
287 299
288 ```bash300 ```bash
M docs/reference/troubleshooting.md +1 -1
@@ -406,7 +406,7 @@ docker inspect gordon-app-mydomain-com
406406
407407 # Check connectivity
408408 curl -v http://localhost:5000/v2/
409-curl -v http://localhost:8080/
409+curl -v http://localhost:8088/
410410 ```
411411
412412 ## Getting Help
@@ -406,7 +406,7 @@ docker inspect gordon-app-mydomain-com
406 406
407 # Check connectivity407 # Check connectivity
408 curl -v http://localhost:5000/v2/408 curl -v http://localhost:5000/v2/
409-curl -v http://localhost:8080/409+curl -v http://localhost:8088/
410 ```410 ```
411 411
412 ## Getting Help412 ## Getting Help
M docs/upgrading.md +55 -1
@@ -97,6 +97,60 @@ Recommended rollout:
9797
9898 During the transition, Gordon treats both the current and legacy hosts as its own registry for image matching and internal pulls, then saves canonical refs back to `gordon_domain`. Remote CLI and admin API traffic should use the new `gordon_domain`.
9999
100+### Breaking: `gordon rollback` Renamed to `gordon pin`
101+
102+If you have scripts or runbooks using `gordon rollback`, switch them to `gordon pin`:
103+
104+```bash
105+gordon pin app.example.com
106+gordon pin app.example.com --tag v2.30.1
107+gordon pin list app.example.com
108+```
109+
110+### Breaking-ish: `routes list` Is Inventory-Only
111+
112+`gordon routes list` now shows domain/image inventory only. Detailed runtime state, HTTP probe status, and attachment status moved to `gordon routes status`.
113+
114+If you have automation parsing status-like fields from `routes list`, migrate it to `routes status --json`.
115+
116+### New: Public ACME TLS
117+
118+Gordon can now obtain public certificates with `[tls.acme]` using either `http-01` or `cloudflare-dns-01`.
119+
120+- `server.tls_port` must stay enabled (`> 0`)
121+- `http-01` needs public HTTP reachability for each hostname being validated
122+- `cloudflare-dns-01` needs a Cloudflare token with zone-read and DNS-edit access for every zone used by HTTPS routes
123+- `obtain_batch_size` limits new ACME orders per reconcile pass to reduce rate-limit spikes
124+
125+### New: DNS Resolver Settings for ACME DNS-01
126+
127+`[dns]` controls the recursive resolvers Gordon uses to verify public DNS propagation for ACME DNS-01.
128+
129+Add this if you need non-default resolvers, split-horizon-aware testing, or a slower propagation window:
130+
131+```toml
132+[dns]
133+resolvers = ["1.1.1.1:53", "8.8.8.8:53"]
134+propagation_timeout = "5m"
135+polling_interval = "5s"
136+```
137+
138+### Runtime Change: Managed Containers Use Restart Policy `always`
139+
140+Newly created managed route containers now use the runtime restart policy `always`. Existing containers pick this up on redeploy/recreate.
141+
142+This improves crash recovery and keeps Docker/Podman behavior aligned with Gordon's startup reconciliation.
143+
144+### New: Saved Remote Inference for Targeted Commands
145+
146+When you do not pass `--remote`, Gordon can now infer a saved remote for some target-based commands when exactly one configured remote matches the route, image, attachment target, or repository.
147+
148+If multiple remotes match or probing fails, Gordon now stops and asks you to pass `--remote` explicitly instead of guessing.
149+
150+### New: Dedicated Access Log
151+
152+You can now enable `logging.access_log.*` for a dedicated HTTP access log separate from the main process log. This is useful for request auditing, GoAccess, CrowdSec, or fail2ban-style tooling.
153+
100154 ## v2.16.0 to v2.30.0
101155
102156 ### Breaking: Password Authentication Removed
@@ -116,7 +170,7 @@ Gordon v2.30.0 removes password-based authentication entirely. Only token-based
116170
117171 - `auth.access_token_ttl` configures the lifetime of ephemeral access tokens issued by `/auth/token` (default: `"15m"`)
118172 - `gordon auth show-token` prints the stored token for a remote
119-- `gordon auth logout` removes the stored token (with optional `--revoke`)
173+- `gordon auth logout` removes the stored token locally
120174 - Automatic token exchange: the CLI transparently exchanges long-lived tokens for ephemeral ones before API calls
121175 - Admin scopes (`admin:*:*`, `admin:routes:read`, etc.) allow fine-grained access control for remote CLI operations
122176
@@ -97,6 +97,60 @@ Recommended rollout:
97 97
98 During the transition, Gordon treats both the current and legacy hosts as its own registry for image matching and internal pulls, then saves canonical refs back to `gordon_domain`. Remote CLI and admin API traffic should use the new `gordon_domain`.98 During the transition, Gordon treats both the current and legacy hosts as its own registry for image matching and internal pulls, then saves canonical refs back to `gordon_domain`. Remote CLI and admin API traffic should use the new `gordon_domain`.
99 99
100+### Breaking: `gordon rollback` Renamed to `gordon pin`
101+
102+If you have scripts or runbooks using `gordon rollback`, switch them to `gordon pin`:
103+
104+```bash
105+gordon pin app.example.com
106+gordon pin app.example.com --tag v2.30.1
107+gordon pin list app.example.com
108+```
109+
110+### Breaking-ish: `routes list` Is Inventory-Only
111+
112+`gordon routes list` now shows domain/image inventory only. Detailed runtime state, HTTP probe status, and attachment status moved to `gordon routes status`.
113+
114+If you have automation parsing status-like fields from `routes list`, migrate it to `routes status --json`.
115+
116+### New: Public ACME TLS
117+
118+Gordon can now obtain public certificates with `[tls.acme]` using either `http-01` or `cloudflare-dns-01`.
119+
120+- `server.tls_port` must stay enabled (`> 0`)
121+- `http-01` needs public HTTP reachability for each hostname being validated
122+- `cloudflare-dns-01` needs a Cloudflare token with zone-read and DNS-edit access for every zone used by HTTPS routes
123+- `obtain_batch_size` limits new ACME orders per reconcile pass to reduce rate-limit spikes
124+
125+### New: DNS Resolver Settings for ACME DNS-01
126+
127+`[dns]` controls the recursive resolvers Gordon uses to verify public DNS propagation for ACME DNS-01.
128+
129+Add this if you need non-default resolvers, split-horizon-aware testing, or a slower propagation window:
130+
131+```toml
132+[dns]
133+resolvers = ["1.1.1.1:53", "8.8.8.8:53"]
134+propagation_timeout = "5m"
135+polling_interval = "5s"
136+```
137+
138+### Runtime Change: Managed Containers Use Restart Policy `always`
139+
140+Newly created managed route containers now use the runtime restart policy `always`. Existing containers pick this up on redeploy/recreate.
141+
142+This improves crash recovery and keeps Docker/Podman behavior aligned with Gordon's startup reconciliation.
143+
144+### New: Saved Remote Inference for Targeted Commands
145+
146+When you do not pass `--remote`, Gordon can now infer a saved remote for some target-based commands when exactly one configured remote matches the route, image, attachment target, or repository.
147+
148+If multiple remotes match or probing fails, Gordon now stops and asks you to pass `--remote` explicitly instead of guessing.
149+
150+### New: Dedicated Access Log
151+
152+You can now enable `logging.access_log.*` for a dedicated HTTP access log separate from the main process log. This is useful for request auditing, GoAccess, CrowdSec, or fail2ban-style tooling.
153+
100 ## v2.16.0 to v2.30.0154 ## v2.16.0 to v2.30.0
101 155
102 ### Breaking: Password Authentication Removed156 ### Breaking: Password Authentication Removed
@@ -116,7 +170,7 @@ Gordon v2.30.0 removes password-based authentication entirely. Only token-based
116 170
117 - `auth.access_token_ttl` configures the lifetime of ephemeral access tokens issued by `/auth/token` (default: `"15m"`)171 - `auth.access_token_ttl` configures the lifetime of ephemeral access tokens issued by `/auth/token` (default: `"15m"`)
118 - `gordon auth show-token` prints the stored token for a remote172 - `gordon auth show-token` prints the stored token for a remote
119-- `gordon auth logout` removes the stored token (with optional `--revoke`)173+- `gordon auth logout` removes the stored token locally
120 - Automatic token exchange: the CLI transparently exchanges long-lived tokens for ephemeral ones before API calls174 - Automatic token exchange: the CLI transparently exchanges long-lived tokens for ephemeral ones before API calls
121 - Admin scopes (`admin:*:*`, `admin:routes:read`, etc.) allow fine-grained access control for remote CLI operations175 - Admin scopes (`admin:*:*`, `admin:routes:read`, etc.) allow fine-grained access control for remote CLI operations
122 176
M gordon.toml.example +57 -0
@@ -3,10 +3,33 @@
33
44 [server]
55 port = 8088
6+tls_port = 8443
67 registry_port = 5000
78 gordon_domain = "gordon.example.com"
89 # legacy_registry_domains = ["registry.example.com:5000"]
10+# proxy_allowed_ips = ["173.245.48.0/20"]
11+# registry_allowed_ips = []
12+# registry_listen_address = ""
13+# tls_cert_file = ""
14+# tls_key_file = ""
15+# force_https_redirect = false
916 # data_dir = "~/.gordon"
17+# max_blob_chunk_size = "95MB"
18+# max_blob_size = "1GB"
19+# max_proxy_body_size = "512MB"
20+# max_proxy_response_size = "1GB"
21+# max_concurrent_connections = 10000
22+
23+[dns]
24+resolvers = ["1.1.1.1:53", "8.8.8.8:53"]
25+propagation_timeout = "5m"
26+polling_interval = "5s"
27+
28+[tls.acme]
29+enabled = false
30+email = ""
31+challenge = "auto"
32+obtain_batch_size = 1
1033
1134 [auth]
1235 enabled = true
@@ -68,8 +91,42 @@ enabled = false
6891 level = "info"
6992 format = "console"
7093
94+[logging.file]
95+enabled = false
96+# path = "~/.gordon/logs/gordon.log"
97+# max_size = 100
98+# max_backups = 3
99+# max_age = 28
100+
71101 [logging.container_logs]
72102 enabled = true
103+# dir = "~/.gordon/logs/containers"
104+# max_size = 100
105+# max_backups = 3
106+# max_age = 28
107+
108+[logging.access_log]
109+enabled = false
110+format = "json"
111+output = "stdout"
112+# file_path = "~/.gordon/logs/access.log"
113+max_size = 100
114+max_backups = 3
115+max_age = 28
116+exclude_health_checks = true
117+syslog_identifier = "gordon-access"
118+
119+[telemetry]
120+enabled = false
121+# endpoint = "http://localhost:4318"
122+# auth_token = ""
123+traces = true
124+metrics = true
125+logs = true
126+trace_sample_rate = 1.0
127+
128+[env]
129+# dir = "~/.gordon/env"
73130
74131 [backups]
75132 enabled = false
@@ -3,10 +3,33 @@
3 3
4 [server]4 [server]
5 port = 80885 port = 8088
6+tls_port = 8443
6 registry_port = 50007 registry_port = 5000
7 gordon_domain = "gordon.example.com"8 gordon_domain = "gordon.example.com"
8 # legacy_registry_domains = ["registry.example.com:5000"]9 # legacy_registry_domains = ["registry.example.com:5000"]
10+# proxy_allowed_ips = ["173.245.48.0/20"]
11+# registry_allowed_ips = []
12+# registry_listen_address = ""
13+# tls_cert_file = ""
14+# tls_key_file = ""
15+# force_https_redirect = false
9 # data_dir = "~/.gordon"16 # data_dir = "~/.gordon"
17+# max_blob_chunk_size = "95MB"
18+# max_blob_size = "1GB"
19+# max_proxy_body_size = "512MB"
20+# max_proxy_response_size = "1GB"
21+# max_concurrent_connections = 10000
22+
23+[dns]
24+resolvers = ["1.1.1.1:53", "8.8.8.8:53"]
25+propagation_timeout = "5m"
26+polling_interval = "5s"
27+
28+[tls.acme]
29+enabled = false
30+email = ""
31+challenge = "auto"
32+obtain_batch_size = 1
10 33
11 [auth]34 [auth]
12 enabled = true35 enabled = true
@@ -68,8 +91,42 @@ enabled = false
68 level = "info"91 level = "info"
69 format = "console"92 format = "console"
70 93
94+[logging.file]
95+enabled = false
96+# path = "~/.gordon/logs/gordon.log"
97+# max_size = 100
98+# max_backups = 3
99+# max_age = 28
100+
71 [logging.container_logs]101 [logging.container_logs]
72 enabled = true102 enabled = true
103+# dir = "~/.gordon/logs/containers"
104+# max_size = 100
105+# max_backups = 3
106+# max_age = 28
107+
108+[logging.access_log]
109+enabled = false
110+format = "json"
111+output = "stdout"
112+# file_path = "~/.gordon/logs/access.log"
113+max_size = 100
114+max_backups = 3
115+max_age = 28
116+exclude_health_checks = true
117+syslog_identifier = "gordon-access"
118+
119+[telemetry]
120+enabled = false
121+# endpoint = "http://localhost:4318"
122+# auth_token = ""
123+traces = true
124+metrics = true
125+logs = true
126+trace_sample_rate = 1.0
127+
128+[env]
129+# dir = "~/.gordon/env"
73 130
74 [backups]131 [backups]
75 enabled = false132 enabled = false
M internal/adapters/in/cli/attachments.go +4 -1
@@ -70,7 +70,10 @@ Examples:
7070 return runAttachmentsList(ctx, handle.plane, target, jsonOut, cmd.OutOrStdout())
7171 }
7272
73- client, isRemote := GetRemoteClient()
73+ client, isRemote, err := GetRemoteClient()
74+ if err != nil {
75+ return err
76+ }
7477 if isRemote {
7578 return runAttachmentsListRemote(ctx, client, target, jsonOut, cmd.OutOrStdout())
7679 }
@@ -70,7 +70,10 @@ Examples:
70 return runAttachmentsList(ctx, handle.plane, target, jsonOut, cmd.OutOrStdout())70 return runAttachmentsList(ctx, handle.plane, target, jsonOut, cmd.OutOrStdout())
71 }71 }
72 72
73- client, isRemote := GetRemoteClient()73+ client, isRemote, err := GetRemoteClient()
74+ if err != nil {
75+ return err
76+ }
74 if isRemote {77 if isRemote {
75 return runAttachmentsListRemote(ctx, client, target, jsonOut, cmd.OutOrStdout())78 return runAttachmentsListRemote(ctx, client, target, jsonOut, cmd.OutOrStdout())
76 }79 }
M internal/adapters/in/cli/auth.go +4 -1
@@ -236,7 +236,10 @@ Examples:
236236 }
237237
238238 func resolveRequiredRemote(flagToken string) (*remote.ResolvedRemote, error) {
239- resolved, isRemote := remote.Resolve(remoteFlag, flagToken, insecureTLSFlag)
239+ resolved, isRemote, err := remote.ResolveStrict(remoteFlag, flagToken, insecureTLSFlag)
240+ if err != nil {
241+ return nil, err
242+ }
240243 if !isRemote {
241244 return nil, fmt.Errorf("no remote specified. Use --remote or 'gordon remotes use <name>'")
242245 }
@@ -236,7 +236,10 @@ Examples:
236 }236 }
237 237
238 func resolveRequiredRemote(flagToken string) (*remote.ResolvedRemote, error) {238 func resolveRequiredRemote(flagToken string) (*remote.ResolvedRemote, error) {
239- resolved, isRemote := remote.Resolve(remoteFlag, flagToken, insecureTLSFlag)239+ resolved, isRemote, err := remote.ResolveStrict(remoteFlag, flagToken, insecureTLSFlag)
240+ if err != nil {
241+ return nil, err
242+ }
240 if !isRemote {243 if !isRemote {
241 return nil, fmt.Errorf("no remote specified. Use --remote or 'gordon remotes use <name>'")244 return nil, fmt.Errorf("no remote specified. Use --remote or 'gordon remotes use <name>'")
242 }245 }
M internal/adapters/in/cli/controlplane_local.go +48 -0
@@ -153,6 +153,54 @@ func (l *localControlPlane) RemoveRouteWithCleanup(ctx context.Context, routeDom
153153 return resp, nil
154154 }
155155
156+func (l *localControlPlane) GetRouteCleanupPreview(ctx context.Context, routeDomain string) (*domain.CleanupReport, error) {
157+ previewer, ok := any(l.containerSvc).(interface {
158+ PreviewRemovedRouteCleanup(context.Context, string) (*domain.CleanupReport, error)
159+ })
160+ if !ok {
161+ return nil, fmt.Errorf("route cleanup preview unavailable")
162+ }
163+
164+ report, err := previewer.PreviewRemovedRouteCleanup(ctx, routeDomain)
165+ if err != nil {
166+ return nil, err
167+ }
168+ if l.volumeSvc == nil {
169+ return report, nil
170+ }
171+
172+ volumes, err := l.ListVolumes(ctx)
173+ if err != nil {
174+ return nil, fmt.Errorf("list volumes: %w", err)
175+ }
176+ if len(volumes) == 0 {
177+ return report, nil
178+ }
179+
180+ scope := newRouteResourceScope(routeDomain, l.routeCleanupVolumePrefix(), nil, report.PreservedAttachments)
181+ for _, volume := range volumesForRouteDiagnosis(volumes, scope) {
182+ report.PreservedVolumes = append(report.PreservedVolumes, domain.CleanupVolume{
183+ Name: volume.Name,
184+ Reason: "volume preserved for explicit cleanup review",
185+ })
186+ }
187+ return report, nil
188+}
189+
190+func (l *localControlPlane) routeCleanupVolumePrefix() string {
191+ const defaultPrefix = "gordon"
192+ if l.configSvc == nil {
193+ return defaultPrefix
194+ }
195+ if volumeCfg, ok := any(l.configSvc).(interface{ GetVolumeConfig() (bool, string, bool) }); ok {
196+ _, prefix, _ := volumeCfg.GetVolumeConfig()
197+ if prefix != "" {
198+ return prefix
199+ }
200+ }
201+ return defaultPrefix
202+}
203+
156204 func (l *localControlPlane) Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) {
157205 registryDomain := ""
158206 if l.configSvc != nil {
@@ -153,6 +153,54 @@ func (l *localControlPlane) RemoveRouteWithCleanup(ctx context.Context, routeDom
153 return resp, nil153 return resp, nil
154 }154 }
155 155
156+func (l *localControlPlane) GetRouteCleanupPreview(ctx context.Context, routeDomain string) (*domain.CleanupReport, error) {
157+ previewer, ok := any(l.containerSvc).(interface {
158+ PreviewRemovedRouteCleanup(context.Context, string) (*domain.CleanupReport, error)
159+ })
160+ if !ok {
161+ return nil, fmt.Errorf("route cleanup preview unavailable")
162+ }
163+
164+ report, err := previewer.PreviewRemovedRouteCleanup(ctx, routeDomain)
165+ if err != nil {
166+ return nil, err
167+ }
168+ if l.volumeSvc == nil {
169+ return report, nil
170+ }
171+
172+ volumes, err := l.ListVolumes(ctx)
173+ if err != nil {
174+ return nil, fmt.Errorf("list volumes: %w", err)
175+ }
176+ if len(volumes) == 0 {
177+ return report, nil
178+ }
179+
180+ scope := newRouteResourceScope(routeDomain, l.routeCleanupVolumePrefix(), nil, report.PreservedAttachments)
181+ for _, volume := range volumesForRouteDiagnosis(volumes, scope) {
182+ report.PreservedVolumes = append(report.PreservedVolumes, domain.CleanupVolume{
183+ Name: volume.Name,
184+ Reason: "volume preserved for explicit cleanup review",
185+ })
186+ }
187+ return report, nil
188+}
189+
190+func (l *localControlPlane) routeCleanupVolumePrefix() string {
191+ const defaultPrefix = "gordon"
192+ if l.configSvc == nil {
193+ return defaultPrefix
194+ }
195+ if volumeCfg, ok := any(l.configSvc).(interface{ GetVolumeConfig() (bool, string, bool) }); ok {
196+ _, prefix, _ := volumeCfg.GetVolumeConfig()
197+ if prefix != "" {
198+ return prefix
199+ }
200+ }
201+ return defaultPrefix
202+}
203+
156 func (l *localControlPlane) Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) {204 func (l *localControlPlane) Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) {
157 registryDomain := ""205 registryDomain := ""
158 if l.configSvc != nil {206 if l.configSvc != nil {
M internal/adapters/in/cli/controlplane_remote.go +4 -0
@@ -49,6 +49,10 @@ func (r *remoteControlPlane) RemoveRouteWithCleanup(ctx context.Context, routeDo
4949 return r.client.RemoveRouteWithCleanup(ctx, routeDomain)
5050 }
5151
52+func (r *remoteControlPlane) GetRouteCleanupPreview(ctx context.Context, routeDomain string) (*domain.CleanupReport, error) {
53+ return r.client.GetRouteCleanupPreview(ctx, routeDomain)
54+}
55+
5256 func (r *remoteControlPlane) Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) {
5357 return r.client.Bootstrap(ctx, req)
5458 }
@@ -49,6 +49,10 @@ func (r *remoteControlPlane) RemoveRouteWithCleanup(ctx context.Context, routeDo
49 return r.client.RemoveRouteWithCleanup(ctx, routeDomain)49 return r.client.RemoveRouteWithCleanup(ctx, routeDomain)
50 }50 }
51 51
52+func (r *remoteControlPlane) GetRouteCleanupPreview(ctx context.Context, routeDomain string) (*domain.CleanupReport, error) {
53+ return r.client.GetRouteCleanupPreview(ctx, routeDomain)
54+}
55+
52 func (r *remoteControlPlane) Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) {56 func (r *remoteControlPlane) Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) {
53 return r.client.Bootstrap(ctx, req)57 return r.client.Bootstrap(ctx, req)
54 }58 }
M internal/adapters/in/cli/controlplane_resolver.go +10 -1
@@ -25,7 +25,10 @@ func (h *controlPlaneHandle) close() {
2525 }
2626
2727 func resolveControlPlane(configPath string) (*controlPlaneHandle, error) {
28- client, isRemote := GetRemoteClient()
28+ client, isRemote, err := GetRemoteClient()
29+ if err != nil {
30+ return nil, err
31+ }
2932 if isRemote {
3033 return &controlPlaneHandle{plane: NewRemoteControlPlane(client), isRemote: true}, nil
3134 }
@@ -44,6 +47,12 @@ func resolveControlPlaneForRouteDomain(ctx context.Context, routeDomain string)
4447 })
4548 }
4649
50+func resolveControlPlaneForRouteCleanupDomain(ctx context.Context, routeDomain string) (*controlPlaneHandle, error) {
51+ return resolveControlPlaneWithInference(ctx, func(ctx context.Context) (*remote.ResolvedRemote, error) {
52+ return inferRemoteForRouteCleanupDomain(ctx, routeDomain)
53+ })
54+}
55+
4756 func resolveControlPlaneForAttachmentTarget(ctx context.Context, target string) (*controlPlaneHandle, error) {
4857 return resolveControlPlaneWithInference(ctx, func(ctx context.Context) (*remote.ResolvedRemote, error) {
4958 return inferRemoteForAttachmentTarget(ctx, target)
@@ -25,7 +25,10 @@ func (h *controlPlaneHandle) close() {
25 }25 }
26 26
27 func resolveControlPlane(configPath string) (*controlPlaneHandle, error) {27 func resolveControlPlane(configPath string) (*controlPlaneHandle, error) {
28- client, isRemote := GetRemoteClient()28+ client, isRemote, err := GetRemoteClient()
29+ if err != nil {
30+ return nil, err
31+ }
29 if isRemote {32 if isRemote {
30 return &controlPlaneHandle{plane: NewRemoteControlPlane(client), isRemote: true}, nil33 return &controlPlaneHandle{plane: NewRemoteControlPlane(client), isRemote: true}, nil
31 }34 }
@@ -44,6 +47,12 @@ func resolveControlPlaneForRouteDomain(ctx context.Context, routeDomain string)
44 })47 })
45 }48 }
46 49
50+func resolveControlPlaneForRouteCleanupDomain(ctx context.Context, routeDomain string) (*controlPlaneHandle, error) {
51+ return resolveControlPlaneWithInference(ctx, func(ctx context.Context) (*remote.ResolvedRemote, error) {
52+ return inferRemoteForRouteCleanupDomain(ctx, routeDomain)
53+ })
54+}
55+
47 func resolveControlPlaneForAttachmentTarget(ctx context.Context, target string) (*controlPlaneHandle, error) {56 func resolveControlPlaneForAttachmentTarget(ctx context.Context, target string) (*controlPlaneHandle, error) {
48 return resolveControlPlaneWithInference(ctx, func(ctx context.Context) (*remote.ResolvedRemote, error) {57 return resolveControlPlaneWithInference(ctx, func(ctx context.Context) (*remote.ResolvedRemote, error) {
49 return inferRemoteForAttachmentTarget(ctx, target)58 return inferRemoteForAttachmentTarget(ctx, target)
M internal/adapters/in/cli/controlplane_resolver_test.go +33 -0
@@ -75,3 +75,36 @@ secrets_backend = "unsafe"
7575 require.NotNil(t, handle.plane)
7676 defer handle.close()
7777 }
78+
79+func TestResolveControlPlane_ExplicitUnknownRemoteReturnsError(t *testing.T) {
80+ tmpDir := t.TempDir()
81+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, "xdg"))
82+ t.Setenv("GORDON_REMOTE", "")
83+ t.Setenv("GORDON_TOKEN", "")
84+ originalRemoteFlag, originalTokenFlag, originalInsecureTLSFlag := remoteFlag, tokenFlag, insecureTLSFlag
85+ t.Cleanup(func() {
86+ remoteFlag = originalRemoteFlag
87+ tokenFlag = originalTokenFlag
88+ insecureTLSFlag = originalInsecureTLSFlag
89+ })
90+
91+ remoteFlag = "does-not-exist"
92+ tokenFlag = ""
93+ insecureTLSFlag = false
94+
95+ configPath := filepath.Join(tmpDir, "gordon.toml")
96+ err := os.WriteFile(configPath, []byte(`[server]
97+gordon_domain = "gordon.local"
98+data_dir = "`+filepath.Join(tmpDir, "data")+`"
99+
100+[auth]
101+enabled = false
102+secrets_backend = "unsafe"
103+`), 0o600)
104+ require.NoError(t, err)
105+
106+ handle, err := resolveControlPlane(configPath)
107+ require.Error(t, err)
108+ require.Nil(t, handle)
109+ require.Contains(t, err.Error(), "does-not-exist")
110+}
@@ -75,3 +75,36 @@ secrets_backend = "unsafe"
75 require.NotNil(t, handle.plane)75 require.NotNil(t, handle.plane)
76 defer handle.close()76 defer handle.close()
77 }77 }
78+
79+func TestResolveControlPlane_ExplicitUnknownRemoteReturnsError(t *testing.T) {
80+ tmpDir := t.TempDir()
81+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, "xdg"))
82+ t.Setenv("GORDON_REMOTE", "")
83+ t.Setenv("GORDON_TOKEN", "")
84+ originalRemoteFlag, originalTokenFlag, originalInsecureTLSFlag := remoteFlag, tokenFlag, insecureTLSFlag
85+ t.Cleanup(func() {
86+ remoteFlag = originalRemoteFlag
87+ tokenFlag = originalTokenFlag
88+ insecureTLSFlag = originalInsecureTLSFlag
89+ })
90+
91+ remoteFlag = "does-not-exist"
92+ tokenFlag = ""
93+ insecureTLSFlag = false
94+
95+ configPath := filepath.Join(tmpDir, "gordon.toml")
96+ err := os.WriteFile(configPath, []byte(`[server]
97+gordon_domain = "gordon.local"
98+data_dir = "`+filepath.Join(tmpDir, "data")+`"
99+
100+[auth]
101+enabled = false
102+secrets_backend = "unsafe"
103+`), 0o600)
104+ require.NoError(t, err)
105+
106+ handle, err := resolveControlPlane(configPath)
107+ require.Error(t, err)
108+ require.Nil(t, handle)
109+ require.Contains(t, err.Error(), "does-not-exist")
110+}
M internal/adapters/in/cli/images.go +8 -2
@@ -89,7 +89,10 @@ func newImagesListCmd() *cobra.Command {
8989 Use: "list",
9090 Short: "List runtime images and registry tags",
9191 RunE: func(cmd *cobra.Command, _ []string) error {
92- client, isRemote := GetRemoteClient()
92+ client, isRemote, err := GetRemoteClient()
93+ if err != nil {
94+ return err
95+ }
9396 if !isRemote {
9497 return fmt.Errorf("images commands require a configured remote target")
9598 }
@@ -114,7 +117,10 @@ func newImagesPruneCmd() *cobra.Command {
114117 By default both runtime and registry cleanup run, keeping latest + 3 previous
115118 release tags per repository. Use --dangling or --registry to restrict scope.`,
116119 RunE: func(cmd *cobra.Command, _ []string) error {
117- client, isRemote := GetRemoteClient()
120+ client, isRemote, err := GetRemoteClient()
121+ if err != nil {
122+ return err
123+ }
118124 if !isRemote {
119125 return fmt.Errorf("images commands require a configured remote target")
120126 }
@@ -89,7 +89,10 @@ func newImagesListCmd() *cobra.Command {
89 Use: "list",89 Use: "list",
90 Short: "List runtime images and registry tags",90 Short: "List runtime images and registry tags",
91 RunE: func(cmd *cobra.Command, _ []string) error {91 RunE: func(cmd *cobra.Command, _ []string) error {
92- client, isRemote := GetRemoteClient()92+ client, isRemote, err := GetRemoteClient()
93+ if err != nil {
94+ return err
95+ }
93 if !isRemote {96 if !isRemote {
94 return fmt.Errorf("images commands require a configured remote target")97 return fmt.Errorf("images commands require a configured remote target")
95 }98 }
@@ -114,7 +117,10 @@ func newImagesPruneCmd() *cobra.Command {
114 By default both runtime and registry cleanup run, keeping latest + 3 previous117 By default both runtime and registry cleanup run, keeping latest + 3 previous
115 release tags per repository. Use --dangling or --registry to restrict scope.`,118 release tags per repository. Use --dangling or --registry to restrict scope.`,
116 RunE: func(cmd *cobra.Command, _ []string) error {119 RunE: func(cmd *cobra.Command, _ []string) error {
117- client, isRemote := GetRemoteClient()120+ client, isRemote, err := GetRemoteClient()
121+ if err != nil {
122+ return err
123+ }
118 if !isRemote {124 if !isRemote {
119 return fmt.Errorf("images commands require a configured remote target")125 return fmt.Errorf("images commands require a configured remote target")
120 }126 }
M internal/adapters/in/cli/pin.go +3 -2
@@ -235,13 +235,14 @@ func deploySelectedTag(ctx context.Context, cp ControlPlane, route *domain.Route
235235
236236 result, err := cp.Deploy(ctx, pinDomain)
237237 if err != nil {
238+ formattedErr := formatDeployFailure(err)
238239 // Attempt to revert route to previous image
239240 route.Image = oldImage
240241 if revertErr := cp.UpdateRoute(ctx, *route); revertErr != nil {
241242 _ = cliWritef(ew, "WARNING: deploy failed and could not revert route: %v\n", revertErr)
242- return fmt.Errorf("failed to deploy; revert failed: %v; deploy error: %w", revertErr, err)
243+ return fmt.Errorf("failed to deploy; revert failed: %v; deploy error: %w", revertErr, formattedErr)
243244 }
244- return fmt.Errorf("failed to deploy (route reverted): %w", err)
245+ return fmt.Errorf("%w\nroute reverted to previous image", formattedErr)
245246 }
246247 containerID := shortContainerID(result.ContainerID)
247248 return cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Pinned %s to %s (container: %s)", pinDomain, selectedTag, containerID)))
@@ -235,13 +235,14 @@ func deploySelectedTag(ctx context.Context, cp ControlPlane, route *domain.Route
235 235
236 result, err := cp.Deploy(ctx, pinDomain)236 result, err := cp.Deploy(ctx, pinDomain)
237 if err != nil {237 if err != nil {
238+ formattedErr := formatDeployFailure(err)
238 // Attempt to revert route to previous image239 // Attempt to revert route to previous image
239 route.Image = oldImage240 route.Image = oldImage
240 if revertErr := cp.UpdateRoute(ctx, *route); revertErr != nil {241 if revertErr := cp.UpdateRoute(ctx, *route); revertErr != nil {
241 _ = cliWritef(ew, "WARNING: deploy failed and could not revert route: %v\n", revertErr)242 _ = cliWritef(ew, "WARNING: deploy failed and could not revert route: %v\n", revertErr)
242- return fmt.Errorf("failed to deploy; revert failed: %v; deploy error: %w", revertErr, err)243+ return fmt.Errorf("failed to deploy; revert failed: %v; deploy error: %w", revertErr, formattedErr)
243 }244 }
244- return fmt.Errorf("failed to deploy (route reverted): %w", err)245+ return fmt.Errorf("%w\nroute reverted to previous image", formattedErr)
245 }246 }
246 containerID := shortContainerID(result.ContainerID)247 containerID := shortContainerID(result.ContainerID)
247 return cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Pinned %s to %s (container: %s)", pinDomain, selectedTag, containerID)))248 return cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Pinned %s to %s (container: %s)", pinDomain, selectedTag, containerID)))
M internal/adapters/in/cli/pin_test.go +24 -0
@@ -2,9 +2,12 @@ package cli
22
33 import (
44 "bytes"
5+ "context"
56 "errors"
67 "testing"
78
9+ climocks "github.com/bnema/gordon/internal/adapters/in/cli/mocks"
10+ "github.com/bnema/gordon/internal/domain"
811 "github.com/stretchr/testify/assert"
912 )
1013
@@ -78,3 +81,24 @@ func TestPrintPinTags_ReturnsWriteError(t *testing.T) {
7881 assert.Error(t, err)
7982 assert.Contains(t, err.Error(), "write failed")
8083 }
84+
85+func TestDeploySelectedTag_FormatsDeployFailureAfterRevert(t *testing.T) {
86+ cp := climocks.NewMockControlPlane(t)
87+ route := &domain.Route{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}
88+ deployErr := &domain.DeployFailureError{
89+ Summary: "failed to deploy",
90+ Cause: "health check failed",
91+ Hint: "check DATABASE_URL",
92+ }
93+
94+ cp.EXPECT().UpdateRoute(context.Background(), domain.Route{Domain: "app.example.com", Image: "registry.example.com/myapp:v2"}).Return(nil).Once()
95+ cp.EXPECT().Deploy(context.Background(), "app.example.com").Return(nil, deployErr).Once()
96+ cp.EXPECT().UpdateRoute(context.Background(), domain.Route{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}).Return(nil).Once()
97+
98+ err := deploySelectedTag(context.Background(), cp, route, &bytes.Buffer{}, &bytes.Buffer{}, "app.example.com", "myapp", "v2")
99+ assert.Error(t, err)
100+ assert.Contains(t, err.Error(), "failed to deploy")
101+ assert.Contains(t, err.Error(), "Cause: health check failed")
102+ assert.Contains(t, err.Error(), "Hint: check DATABASE_URL")
103+ assert.Contains(t, err.Error(), "route reverted")
104+}
@@ -2,9 +2,12 @@ package cli
2 2
3 import (3 import (
4 "bytes"4 "bytes"
5+ "context"
5 "errors"6 "errors"
6 "testing"7 "testing"
7 8
9+ climocks "github.com/bnema/gordon/internal/adapters/in/cli/mocks"
10+ "github.com/bnema/gordon/internal/domain"
8 "github.com/stretchr/testify/assert"11 "github.com/stretchr/testify/assert"
9 )12 )
10 13
@@ -78,3 +81,24 @@ func TestPrintPinTags_ReturnsWriteError(t *testing.T) {
78 assert.Error(t, err)81 assert.Error(t, err)
79 assert.Contains(t, err.Error(), "write failed")82 assert.Contains(t, err.Error(), "write failed")
80 }83 }
84+
85+func TestDeploySelectedTag_FormatsDeployFailureAfterRevert(t *testing.T) {
86+ cp := climocks.NewMockControlPlane(t)
87+ route := &domain.Route{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}
88+ deployErr := &domain.DeployFailureError{
89+ Summary: "failed to deploy",
90+ Cause: "health check failed",
91+ Hint: "check DATABASE_URL",
92+ }
93+
94+ cp.EXPECT().UpdateRoute(context.Background(), domain.Route{Domain: "app.example.com", Image: "registry.example.com/myapp:v2"}).Return(nil).Once()
95+ cp.EXPECT().Deploy(context.Background(), "app.example.com").Return(nil, deployErr).Once()
96+ cp.EXPECT().UpdateRoute(context.Background(), domain.Route{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}).Return(nil).Once()
97+
98+ err := deploySelectedTag(context.Background(), cp, route, &bytes.Buffer{}, &bytes.Buffer{}, "app.example.com", "myapp", "v2")
99+ assert.Error(t, err)
100+ assert.Contains(t, err.Error(), "failed to deploy")
101+ assert.Contains(t, err.Error(), "Cause: health check failed")
102+ assert.Contains(t, err.Error(), "Hint: check DATABASE_URL")
103+ assert.Contains(t, err.Error(), "route reverted")
104+}
M internal/adapters/in/cli/preview.go +16 -4
@@ -102,7 +102,10 @@ func newPreviewListCmd() *cobra.Command {
102102 ctx := cmd.Context()
103103 out := cmd.OutOrStdout()
104104
105- client, isRemote := GetRemoteClient()
105+ client, isRemote, err := GetRemoteClient()
106+ if err != nil {
107+ return err
108+ }
106109 if !isRemote {
107110 return fmt.Errorf("preview list requires --remote (local preview listing is not yet supported)")
108111 }
@@ -137,7 +140,10 @@ func newPreviewDeleteCmd() *cobra.Command {
137140 out := cmd.OutOrStdout()
138141 name := args[0]
139142
140- client, isRemote := GetRemoteClient()
143+ client, isRemote, err := GetRemoteClient()
144+ if err != nil {
145+ return err
146+ }
141147 if !isRemote {
142148 return fmt.Errorf("preview delete requires --remote (local preview deletion is not yet supported)")
143149 }
@@ -162,7 +168,10 @@ func newPreviewExtendCmd() *cobra.Command {
162168 out := cmd.OutOrStdout()
163169 name := args[0]
164170
165- client, isRemote := GetRemoteClient()
171+ client, isRemote, err := GetRemoteClient()
172+ if err != nil {
173+ return err
174+ }
166175 if !isRemote {
167176 return fmt.Errorf("preview extend requires --remote (local preview extend is not yet supported)")
168177 }
@@ -315,7 +324,10 @@ func waitForPreview(ctx context.Context, out io.Writer, name, ttl string, noData
315324 return err
316325 }
317326
318- client, isRemote := GetRemoteClient()
327+ client, isRemote, err := GetRemoteClient()
328+ if err != nil {
329+ return err
330+ }
319331 if !isRemote {
320332 return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Push complete. Preview %q will be created by the server (use --remote to poll status).", name)))
321333 }
@@ -102,7 +102,10 @@ func newPreviewListCmd() *cobra.Command {
102 ctx := cmd.Context()102 ctx := cmd.Context()
103 out := cmd.OutOrStdout()103 out := cmd.OutOrStdout()
104 104
105- client, isRemote := GetRemoteClient()105+ client, isRemote, err := GetRemoteClient()
106+ if err != nil {
107+ return err
108+ }
106 if !isRemote {109 if !isRemote {
107 return fmt.Errorf("preview list requires --remote (local preview listing is not yet supported)")110 return fmt.Errorf("preview list requires --remote (local preview listing is not yet supported)")
108 }111 }
@@ -137,7 +140,10 @@ func newPreviewDeleteCmd() *cobra.Command {
137 out := cmd.OutOrStdout()140 out := cmd.OutOrStdout()
138 name := args[0]141 name := args[0]
139 142
140- client, isRemote := GetRemoteClient()143+ client, isRemote, err := GetRemoteClient()
144+ if err != nil {
145+ return err
146+ }
141 if !isRemote {147 if !isRemote {
142 return fmt.Errorf("preview delete requires --remote (local preview deletion is not yet supported)")148 return fmt.Errorf("preview delete requires --remote (local preview deletion is not yet supported)")
143 }149 }
@@ -162,7 +168,10 @@ func newPreviewExtendCmd() *cobra.Command {
162 out := cmd.OutOrStdout()168 out := cmd.OutOrStdout()
163 name := args[0]169 name := args[0]
164 170
165- client, isRemote := GetRemoteClient()171+ client, isRemote, err := GetRemoteClient()
172+ if err != nil {
173+ return err
174+ }
166 if !isRemote {175 if !isRemote {
167 return fmt.Errorf("preview extend requires --remote (local preview extend is not yet supported)")176 return fmt.Errorf("preview extend requires --remote (local preview extend is not yet supported)")
168 }177 }
@@ -315,7 +324,10 @@ func waitForPreview(ctx context.Context, out io.Writer, name, ttl string, noData
315 return err324 return err
316 }325 }
317 326
318- client, isRemote := GetRemoteClient()327+ client, isRemote, err := GetRemoteClient()
328+ if err != nil {
329+ return err
330+ }
319 if !isRemote {331 if !isRemote {
320 return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Push complete. Preview %q will be created by the server (use --remote to poll status).", name)))332 return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Push complete. Preview %q will be created by the server (use --remote to poll status).", name)))
321 }333 }
M internal/adapters/in/cli/push_image_ops.go +4 -1
@@ -54,7 +54,10 @@ func newDockerImageOps(insecureTLS bool, progress io.Writer) (*dockerImageOps, e
5454
5555 // newImageOpsFromFlags resolves TLS settings from CLI flags/config and creates ops.
5656 func newImageOpsFromFlags() (pushImageOps, error) {
57- resolved, _ := remote.Resolve(remoteFlag, tokenFlag, insecureTLSFlag)
57+ resolved, _, err := remote.ResolveStrict(remoteFlag, tokenFlag, insecureTLSFlag)
58+ if err != nil {
59+ return nil, err
60+ }
5861 return newImageOpsForResolvedRemote(resolved)
5962 }
6063
@@ -54,7 +54,10 @@ func newDockerImageOps(insecureTLS bool, progress io.Writer) (*dockerImageOps, e
54 54
55 // newImageOpsFromFlags resolves TLS settings from CLI flags/config and creates ops.55 // newImageOpsFromFlags resolves TLS settings from CLI flags/config and creates ops.
56 func newImageOpsFromFlags() (pushImageOps, error) {56 func newImageOpsFromFlags() (pushImageOps, error) {
57- resolved, _ := remote.Resolve(remoteFlag, tokenFlag, insecureTLSFlag)57+ resolved, _, err := remote.ResolveStrict(remoteFlag, tokenFlag, insecureTLSFlag)
58+ if err != nil {
59+ return nil, err
60+ }
58 return newImageOpsForResolvedRemote(resolved)61 return newImageOpsForResolvedRemote(resolved)
59 }62 }
60 63
M internal/adapters/in/cli/push_remote_inference_test.go +123 -0
@@ -193,6 +193,101 @@ url = "`+staging.URL+`"
193193 assert.Contains(t, err.Error(), `multiple saved remotes match route "app.example.com"`)
194194 }
195195
196+func TestInferRemoteForRouteCleanupDomain_UsesCleanupPreviewWhenRouteConfigMissing(t *testing.T) {
197+ prod := newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{
198+ getRoute: func(domainName string) (*domain.Route, int) {
199+ return nil, http.StatusNotFound
200+ },
201+ getRouteCleanup: func(domainName string) (*domain.CleanupReport, int) {
202+ return &domain.CleanupReport{
203+ Domain: domainName,
204+ OrphanedEntities: []domain.CleanupOrphanedEntity{{
205+ Kind: "route_container",
206+ ID: "abc123",
207+ Status: "running",
208+ }},
209+ }, http.StatusOK
210+ },
211+ })
212+ staging := newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{
213+ getRoute: func(domainName string) (*domain.Route, int) {
214+ return nil, http.StatusNotFound
215+ },
216+ getRouteCleanup: func(domainName string) (*domain.CleanupReport, int) {
217+ return &domain.CleanupReport{Domain: domainName}, http.StatusOK
218+ },
219+ })
220+
221+ configurePushRemoteInferenceTestEnv(t, `
222+[remotes.prod]
223+url = "`+prod.URL+`"
224+
225+[remotes.staging]
226+url = "`+staging.URL+`"
227+`)
228+
229+ resolved, err := inferRemoteForRouteCleanupDomain(context.Background(), "app.example.com")
230+ require.NoError(t, err)
231+ require.NotNil(t, resolved)
232+ assert.Equal(t, "prod", resolved.Name)
233+}
234+
235+func TestInferRemoteForRouteCleanupDomain_UsesCleanupPreviewVolumesWhenRouteConfigMissing(t *testing.T) {
236+ prod := newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{
237+ getRoute: func(domainName string) (*domain.Route, int) {
238+ return nil, http.StatusNotFound
239+ },
240+ getRouteCleanup: func(domainName string) (*domain.CleanupReport, int) {
241+ return &domain.CleanupReport{
242+ Domain: domainName,
243+ PreservedVolumes: []domain.CleanupVolume{{Name: "gordon-app-example-com-data"}},
244+ }, http.StatusOK
245+ },
246+ })
247+ staging := newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{
248+ getRoute: func(domainName string) (*domain.Route, int) {
249+ return nil, http.StatusNotFound
250+ },
251+ getRouteCleanup: func(domainName string) (*domain.CleanupReport, int) {
252+ return &domain.CleanupReport{Domain: domainName}, http.StatusOK
253+ },
254+ })
255+
256+ configurePushRemoteInferenceTestEnv(t, `
257+[remotes.prod]
258+url = "`+prod.URL+`"
259+
260+[remotes.staging]
261+url = "`+staging.URL+`"
262+`)
263+
264+ resolved, err := inferRemoteForRouteCleanupDomain(context.Background(), "app.example.com")
265+ require.NoError(t, err)
266+ require.NotNil(t, resolved)
267+ assert.Equal(t, "prod", resolved.Name)
268+}
269+
270+func TestInferRemoteForRouteCleanupDomain_FailsSafeWhenCleanupPreviewProbeFails(t *testing.T) {
271+ prod := newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{
272+ getRoute: func(domainName string) (*domain.Route, int) {
273+ return nil, http.StatusNotFound
274+ },
275+ getRouteCleanup: func(domainName string) (*domain.CleanupReport, int) {
276+ return nil, http.StatusForbidden
277+ },
278+ })
279+
280+ configurePushRemoteInferenceTestEnv(t, `
281+[remotes.prod]
282+url = "`+prod.URL+`"
283+`)
284+
285+ resolved, err := inferRemoteForRouteCleanupDomain(context.Background(), "app.example.com")
286+ require.Error(t, err)
287+ assert.Nil(t, resolved)
288+ assert.Contains(t, err.Error(), `could not safely infer remote for route cleanup "app.example.com"`)
289+}
290+
196291 func TestInferRemoteForAttachmentImage_UsesUniqueMatchingSavedRemote(t *testing.T) {
197292 prod := newAttachmentTargetsByImageTestServer(t, func(image string) ([]string, int) {
198293 return []string{"app.example.com"}, http.StatusOK
@@ -339,6 +434,7 @@ func newGetRouteTestServer(t *testing.T, handler func(domainName string) (*domai
339434
340435 type multiAdminProbeHandlers struct {
341436 getRoute func(domainName string) (*domain.Route, int)
437+ getRouteCleanup func(domainName string) (*domain.CleanupReport, int)
342438 getAttachmentsConfig func(target string) ([]string, int)
343439 listTags func(repository string) ([]string, int)
344440 findAttachmentTarget func(image string) ([]string, int)
@@ -349,6 +445,8 @@ func newMultiAdminProbeTestServer(t *testing.T, handlers multiAdminProbeHandlers
349445
350446 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
351447 switch {
448+ case strings.HasSuffix(r.URL.Path, "/cleanup") && strings.HasPrefix(r.URL.Path, "/admin/routes/"):
449+ handleGetRouteCleanupProbe(t, w, r, handlers.getRouteCleanup)
352450 case strings.HasPrefix(r.URL.Path, "/admin/routes/"):
353451 handleGetRouteProbe(t, w, r, handlers.getRoute)
354452 case strings.HasPrefix(r.URL.Path, "/admin/attachments/by-image/"):
@@ -406,6 +504,31 @@ func handleGetRouteProbe(t *testing.T, w http.ResponseWriter, r *http.Request, h
406504 }
407505 }
408506
507+func handleGetRouteCleanupProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(domainName string) (*domain.CleanupReport, int)) {
508+ t.Helper()
509+ if handler == nil {
510+ http.NotFound(w, r)
511+ return
512+ }
513+ domainName, err := url.PathUnescape(strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/admin/routes/"), "/cleanup"))
514+ if !assert.NoError(t, err) {
515+ return
516+ }
517+ report, status := handler(domainName)
518+ if status >= http.StatusBadRequest {
519+ w.Header().Set("Content-Type", "application/json")
520+ w.WriteHeader(status)
521+ if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"})) {
522+ return
523+ }
524+ return
525+ }
526+ w.Header().Set("Content-Type", "application/json")
527+ if !assert.NoError(t, json.NewEncoder(w).Encode(report)) {
528+ return
529+ }
530+}
531+
409532 func handleAttachmentTargetsByImageProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(image string) ([]string, int)) {
410533 t.Helper()
411534 if handler == nil {
@@ -193,6 +193,101 @@ url = "`+staging.URL+`"
193 assert.Contains(t, err.Error(), `multiple saved remotes match route "app.example.com"`)193 assert.Contains(t, err.Error(), `multiple saved remotes match route "app.example.com"`)
194 }194 }
195 195
196+func TestInferRemoteForRouteCleanupDomain_UsesCleanupPreviewWhenRouteConfigMissing(t *testing.T) {
197+ prod := newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{
198+ getRoute: func(domainName string) (*domain.Route, int) {
199+ return nil, http.StatusNotFound
200+ },
201+ getRouteCleanup: func(domainName string) (*domain.CleanupReport, int) {
202+ return &domain.CleanupReport{
203+ Domain: domainName,
204+ OrphanedEntities: []domain.CleanupOrphanedEntity{{
205+ Kind: "route_container",
206+ ID: "abc123",
207+ Status: "running",
208+ }},
209+ }, http.StatusOK
210+ },
211+ })
212+ staging := newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{
213+ getRoute: func(domainName string) (*domain.Route, int) {
214+ return nil, http.StatusNotFound
215+ },
216+ getRouteCleanup: func(domainName string) (*domain.CleanupReport, int) {
217+ return &domain.CleanupReport{Domain: domainName}, http.StatusOK
218+ },
219+ })
220+
221+ configurePushRemoteInferenceTestEnv(t, `
222+[remotes.prod]
223+url = "`+prod.URL+`"
224+
225+[remotes.staging]
226+url = "`+staging.URL+`"
227+`)
228+
229+ resolved, err := inferRemoteForRouteCleanupDomain(context.Background(), "app.example.com")
230+ require.NoError(t, err)
231+ require.NotNil(t, resolved)
232+ assert.Equal(t, "prod", resolved.Name)
233+}
234+
235+func TestInferRemoteForRouteCleanupDomain_UsesCleanupPreviewVolumesWhenRouteConfigMissing(t *testing.T) {
236+ prod := newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{
237+ getRoute: func(domainName string) (*domain.Route, int) {
238+ return nil, http.StatusNotFound
239+ },
240+ getRouteCleanup: func(domainName string) (*domain.CleanupReport, int) {
241+ return &domain.CleanupReport{
242+ Domain: domainName,
243+ PreservedVolumes: []domain.CleanupVolume{{Name: "gordon-app-example-com-data"}},
244+ }, http.StatusOK
245+ },
246+ })
247+ staging := newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{
248+ getRoute: func(domainName string) (*domain.Route, int) {
249+ return nil, http.StatusNotFound
250+ },
251+ getRouteCleanup: func(domainName string) (*domain.CleanupReport, int) {
252+ return &domain.CleanupReport{Domain: domainName}, http.StatusOK
253+ },
254+ })
255+
256+ configurePushRemoteInferenceTestEnv(t, `
257+[remotes.prod]
258+url = "`+prod.URL+`"
259+
260+[remotes.staging]
261+url = "`+staging.URL+`"
262+`)
263+
264+ resolved, err := inferRemoteForRouteCleanupDomain(context.Background(), "app.example.com")
265+ require.NoError(t, err)
266+ require.NotNil(t, resolved)
267+ assert.Equal(t, "prod", resolved.Name)
268+}
269+
270+func TestInferRemoteForRouteCleanupDomain_FailsSafeWhenCleanupPreviewProbeFails(t *testing.T) {
271+ prod := newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{
272+ getRoute: func(domainName string) (*domain.Route, int) {
273+ return nil, http.StatusNotFound
274+ },
275+ getRouteCleanup: func(domainName string) (*domain.CleanupReport, int) {
276+ return nil, http.StatusForbidden
277+ },
278+ })
279+
280+ configurePushRemoteInferenceTestEnv(t, `
281+[remotes.prod]
282+url = "`+prod.URL+`"
283+`)
284+
285+ resolved, err := inferRemoteForRouteCleanupDomain(context.Background(), "app.example.com")
286+ require.Error(t, err)
287+ assert.Nil(t, resolved)
288+ assert.Contains(t, err.Error(), `could not safely infer remote for route cleanup "app.example.com"`)
289+}
290+
196 func TestInferRemoteForAttachmentImage_UsesUniqueMatchingSavedRemote(t *testing.T) {291 func TestInferRemoteForAttachmentImage_UsesUniqueMatchingSavedRemote(t *testing.T) {
197 prod := newAttachmentTargetsByImageTestServer(t, func(image string) ([]string, int) {292 prod := newAttachmentTargetsByImageTestServer(t, func(image string) ([]string, int) {
198 return []string{"app.example.com"}, http.StatusOK293 return []string{"app.example.com"}, http.StatusOK
@@ -339,6 +434,7 @@ func newGetRouteTestServer(t *testing.T, handler func(domainName string) (*domai
339 434
340 type multiAdminProbeHandlers struct {435 type multiAdminProbeHandlers struct {
341 getRoute func(domainName string) (*domain.Route, int)436 getRoute func(domainName string) (*domain.Route, int)
437+ getRouteCleanup func(domainName string) (*domain.CleanupReport, int)
342 getAttachmentsConfig func(target string) ([]string, int)438 getAttachmentsConfig func(target string) ([]string, int)
343 listTags func(repository string) ([]string, int)439 listTags func(repository string) ([]string, int)
344 findAttachmentTarget func(image string) ([]string, int)440 findAttachmentTarget func(image string) ([]string, int)
@@ -349,6 +445,8 @@ func newMultiAdminProbeTestServer(t *testing.T, handlers multiAdminProbeHandlers
349 445
350 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {446 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
351 switch {447 switch {
448+ case strings.HasSuffix(r.URL.Path, "/cleanup") && strings.HasPrefix(r.URL.Path, "/admin/routes/"):
449+ handleGetRouteCleanupProbe(t, w, r, handlers.getRouteCleanup)
352 case strings.HasPrefix(r.URL.Path, "/admin/routes/"):450 case strings.HasPrefix(r.URL.Path, "/admin/routes/"):
353 handleGetRouteProbe(t, w, r, handlers.getRoute)451 handleGetRouteProbe(t, w, r, handlers.getRoute)
354 case strings.HasPrefix(r.URL.Path, "/admin/attachments/by-image/"):452 case strings.HasPrefix(r.URL.Path, "/admin/attachments/by-image/"):
@@ -406,6 +504,31 @@ func handleGetRouteProbe(t *testing.T, w http.ResponseWriter, r *http.Request, h
406 }504 }
407 }505 }
408 506
507+func handleGetRouteCleanupProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(domainName string) (*domain.CleanupReport, int)) {
508+ t.Helper()
509+ if handler == nil {
510+ http.NotFound(w, r)
511+ return
512+ }
513+ domainName, err := url.PathUnescape(strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/admin/routes/"), "/cleanup"))
514+ if !assert.NoError(t, err) {
515+ return
516+ }
517+ report, status := handler(domainName)
518+ if status >= http.StatusBadRequest {
519+ w.Header().Set("Content-Type", "application/json")
520+ w.WriteHeader(status)
521+ if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"})) {
522+ return
523+ }
524+ return
525+ }
526+ w.Header().Set("Content-Type", "application/json")
527+ if !assert.NoError(t, json.NewEncoder(w).Encode(report)) {
528+ return
529+ }
530+}
531+
409 func handleAttachmentTargetsByImageProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(image string) ([]string, int)) {532 func handleAttachmentTargetsByImageProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(image string) ([]string, int)) {
410 t.Helper()533 t.Helper()
411 if handler == nil {534 if handler == nil {
M internal/adapters/in/cli/remote/client.go +15 -0
@@ -514,6 +514,21 @@ func (c *Client) GetRoute(ctx context.Context, routeDomain string) (*domain.Rout
514514 return &route, nil
515515 }
516516
517+// GetRouteCleanupPreview returns retained cleanup state for a route that may no longer be configured.
518+func (c *Client) GetRouteCleanupPreview(ctx context.Context, routeDomain string) (*domain.CleanupReport, error) {
519+ resp, err := c.request(ctx, http.MethodGet, "/routes/"+url.PathEscape(routeDomain)+"/cleanup", nil)
520+ if err != nil {
521+ return nil, err
522+ }
523+
524+ var report domain.CleanupReport
525+ if err := parseResponse(resp, &report); err != nil {
526+ return nil, err
527+ }
528+
529+ return &report, nil
530+}
531+
517532 // FindRoutesByImage returns all routes associated with the given image name.
518533 func (c *Client) FindRoutesByImage(ctx context.Context, imageName string) ([]domain.Route, error) {
519534 resp, err := c.request(ctx, http.MethodGet, "/routes/by-image/"+url.PathEscape(imageName), nil)
@@ -514,6 +514,21 @@ func (c *Client) GetRoute(ctx context.Context, routeDomain string) (*domain.Rout
514 return &route, nil514 return &route, nil
515 }515 }
516 516
517+// GetRouteCleanupPreview returns retained cleanup state for a route that may no longer be configured.
518+func (c *Client) GetRouteCleanupPreview(ctx context.Context, routeDomain string) (*domain.CleanupReport, error) {
519+ resp, err := c.request(ctx, http.MethodGet, "/routes/"+url.PathEscape(routeDomain)+"/cleanup", nil)
520+ if err != nil {
521+ return nil, err
522+ }
523+
524+ var report domain.CleanupReport
525+ if err := parseResponse(resp, &report); err != nil {
526+ return nil, err
527+ }
528+
529+ return &report, nil
530+}
531+
517 // FindRoutesByImage returns all routes associated with the given image name.532 // FindRoutesByImage returns all routes associated with the given image name.
518 func (c *Client) FindRoutesByImage(ctx context.Context, imageName string) ([]domain.Route, error) {533 func (c *Client) FindRoutesByImage(ctx context.Context, imageName string) ([]domain.Route, error) {
519 resp, err := c.request(ctx, http.MethodGet, "/routes/by-image/"+url.PathEscape(imageName), nil)534 resp, err := c.request(ctx, http.MethodGet, "/routes/by-image/"+url.PathEscape(imageName), nil)
M internal/adapters/in/cli/remote/config.go +15 -4
@@ -241,15 +241,26 @@ func resolveInsecureForTarget(flagInsecure bool, name string, remotes *ClientCon
241241 // Token precedence: flag > GORDON_TOKEN env > named remote (pass > TOML token > token_env).
242242 // InsecureTLS precedence: flag > GORDON_INSECURE env > remote entry field.
243243 func Resolve(flagRemote, flagToken string, flagInsecure bool) (*ResolvedRemote, bool) {
244+ resolved, found, _ := ResolveStrict(flagRemote, flagToken, flagInsecure)
245+ return resolved, found
246+}
247+
248+// ResolveStrict resolves the remote target like Resolve, but returns an error
249+// when an explicit remote selector is invalid instead of silently falling back
250+// to local mode.
251+func ResolveStrict(flagRemote, flagToken string, flagInsecure bool) (*ResolvedRemote, bool, error) {
244252 remotes, _ := LoadRemotes("")
245253
246254 name, url, found := resolveTarget(flagRemote, remotes)
247- if !found && flagRemote != "" && !strings.HasPrefix(flagRemote, "http://") && !strings.HasPrefix(flagRemote, "https://") {
248- return nil, false
255+ if !found && flagRemote != "" {
256+ return nil, false, fmt.Errorf("remote %q not found", flagRemote)
249257 }
250258 if !found {
251259 if envRemote := os.Getenv("GORDON_REMOTE"); envRemote != "" {
252260 name, url, found = resolveTarget(envRemote, remotes)
261+ if !found {
262+ return nil, false, fmt.Errorf("remote %q from GORDON_REMOTE not found", envRemote)
263+ }
253264 }
254265 }
255266 if !found && remotes != nil && remotes.Active != "" {
@@ -260,7 +271,7 @@ func Resolve(flagRemote, flagToken string, flagInsecure bool) (*ResolvedRemote,
260271 }
261272 }
262273 if !found {
263- return nil, false
274+ return nil, false, nil
264275 }
265276
266277 return &ResolvedRemote{
@@ -268,7 +279,7 @@ func Resolve(flagRemote, flagToken string, flagInsecure bool) (*ResolvedRemote,
268279 URL: url,
269280 Token: resolveTokenForTarget(flagToken, name, remotes),
270281 InsecureTLS: resolveInsecureForTarget(flagInsecure, name, remotes),
271- }, true
282+ }, true, nil
272283 }
273284
274285 // resolveTarget checks if value is a saved remote name or an ad-hoc URL.
@@ -241,15 +241,26 @@ func resolveInsecureForTarget(flagInsecure bool, name string, remotes *ClientCon
241 // Token precedence: flag > GORDON_TOKEN env > named remote (pass > TOML token > token_env).241 // Token precedence: flag > GORDON_TOKEN env > named remote (pass > TOML token > token_env).
242 // InsecureTLS precedence: flag > GORDON_INSECURE env > remote entry field.242 // InsecureTLS precedence: flag > GORDON_INSECURE env > remote entry field.
243 func Resolve(flagRemote, flagToken string, flagInsecure bool) (*ResolvedRemote, bool) {243 func Resolve(flagRemote, flagToken string, flagInsecure bool) (*ResolvedRemote, bool) {
244+ resolved, found, _ := ResolveStrict(flagRemote, flagToken, flagInsecure)
245+ return resolved, found
246+}
247+
248+// ResolveStrict resolves the remote target like Resolve, but returns an error
249+// when an explicit remote selector is invalid instead of silently falling back
250+// to local mode.
251+func ResolveStrict(flagRemote, flagToken string, flagInsecure bool) (*ResolvedRemote, bool, error) {
244 remotes, _ := LoadRemotes("")252 remotes, _ := LoadRemotes("")
245 253
246 name, url, found := resolveTarget(flagRemote, remotes)254 name, url, found := resolveTarget(flagRemote, remotes)
247- if !found && flagRemote != "" && !strings.HasPrefix(flagRemote, "http://") && !strings.HasPrefix(flagRemote, "https://") {255+ if !found && flagRemote != "" {
248- return nil, false256+ return nil, false, fmt.Errorf("remote %q not found", flagRemote)
249 }257 }
250 if !found {258 if !found {
251 if envRemote := os.Getenv("GORDON_REMOTE"); envRemote != "" {259 if envRemote := os.Getenv("GORDON_REMOTE"); envRemote != "" {
252 name, url, found = resolveTarget(envRemote, remotes)260 name, url, found = resolveTarget(envRemote, remotes)
261+ if !found {
262+ return nil, false, fmt.Errorf("remote %q from GORDON_REMOTE not found", envRemote)
263+ }
253 }264 }
254 }265 }
255 if !found && remotes != nil && remotes.Active != "" {266 if !found && remotes != nil && remotes.Active != "" {
@@ -260,7 +271,7 @@ func Resolve(flagRemote, flagToken string, flagInsecure bool) (*ResolvedRemote,
260 }271 }
261 }272 }
262 if !found {273 if !found {
263- return nil, false274+ return nil, false, nil
264 }275 }
265 276
266 return &ResolvedRemote{277 return &ResolvedRemote{
@@ -268,7 +279,7 @@ func Resolve(flagRemote, flagToken string, flagInsecure bool) (*ResolvedRemote,
268 URL: url,279 URL: url,
269 Token: resolveTokenForTarget(flagToken, name, remotes),280 Token: resolveTokenForTarget(flagToken, name, remotes),
270 InsecureTLS: resolveInsecureForTarget(flagInsecure, name, remotes),281 InsecureTLS: resolveInsecureForTarget(flagInsecure, name, remotes),
271- }, true282+ }, true, nil
272 }283 }
273 284
274 // resolveTarget checks if value is a saved remote name or an ad-hoc URL.285 // resolveTarget checks if value is a saved remote name or an ad-hoc URL.
M internal/adapters/in/cli/remote_inference.go +25 -0
@@ -72,6 +72,31 @@ func inferRemoteForRouteDomain(ctx context.Context, routeDomain string) (*remote
7272 })
7373 }
7474
75+func inferRemoteForRouteCleanupDomain(ctx context.Context, routeDomain string) (*remote.ResolvedRemote, error) {
76+ return inferSavedRemote(ctx, "route cleanup", routeDomain, func(ctx context.Context, client *remote.Client) (bool, error) {
77+ _, err := client.GetRoute(ctx, routeDomain)
78+ if err == nil {
79+ return true, nil
80+ }
81+ if !isRemoteNotFoundError(err) {
82+ return false, err
83+ }
84+
85+ preview, err := client.GetRouteCleanupPreview(ctx, routeDomain)
86+ if err != nil {
87+ return false, err
88+ }
89+ return routeCleanupReportHasEvidence(preview), nil
90+ })
91+}
92+
93+func routeCleanupReportHasEvidence(report *domain.CleanupReport) bool {
94+ if report == nil {
95+ return false
96+ }
97+ return len(report.PreservedVolumes) > 0 || len(report.PreservedAttachments) > 0 || len(report.OrphanedEntities) > 0
98+}
99+
75100 func inferRemoteForAttachmentImage(ctx context.Context, imageName string) (*remote.ResolvedRemote, error) {
76101 return inferSavedRemote(ctx, "attachment image", imageName, func(ctx context.Context, client *remote.Client) (bool, error) {
77102 targets, err := client.FindAttachmentTargetsByImage(ctx, imageName)
@@ -72,6 +72,31 @@ func inferRemoteForRouteDomain(ctx context.Context, routeDomain string) (*remote
72 })72 })
73 }73 }
74 74
75+func inferRemoteForRouteCleanupDomain(ctx context.Context, routeDomain string) (*remote.ResolvedRemote, error) {
76+ return inferSavedRemote(ctx, "route cleanup", routeDomain, func(ctx context.Context, client *remote.Client) (bool, error) {
77+ _, err := client.GetRoute(ctx, routeDomain)
78+ if err == nil {
79+ return true, nil
80+ }
81+ if !isRemoteNotFoundError(err) {
82+ return false, err
83+ }
84+
85+ preview, err := client.GetRouteCleanupPreview(ctx, routeDomain)
86+ if err != nil {
87+ return false, err
88+ }
89+ return routeCleanupReportHasEvidence(preview), nil
90+ })
91+}
92+
93+func routeCleanupReportHasEvidence(report *domain.CleanupReport) bool {
94+ if report == nil {
95+ return false
96+ }
97+ return len(report.PreservedVolumes) > 0 || len(report.PreservedAttachments) > 0 || len(report.OrphanedEntities) > 0
98+}
99+
75 func inferRemoteForAttachmentImage(ctx context.Context, imageName string) (*remote.ResolvedRemote, error) {100 func inferRemoteForAttachmentImage(ctx context.Context, imageName string) (*remote.ResolvedRemote, error) {
76 return inferSavedRemote(ctx, "attachment image", imageName, func(ctx context.Context, client *remote.Client) (bool, error) {101 return inferSavedRemote(ctx, "attachment image", imageName, func(ctx context.Context, client *remote.Client) (bool, error) {
77 targets, err := client.FindAttachmentTargetsByImage(ctx, imageName)102 targets, err := client.FindAttachmentTargetsByImage(ctx, imageName)
M internal/adapters/in/cli/root.go +17 -8
@@ -172,15 +172,18 @@ Commands are organized by where they run:
172172
173173 // GetRemoteClient returns a remote client if targeting a remote instance,
174174 // or nil if running locally.
175-func GetRemoteClient() (*remote.Client, bool) {
176- resolved, isRemote := remote.Resolve(remoteFlag, tokenFlag, insecureTLSFlag)
175+func GetRemoteClient() (*remote.Client, bool, error) {
176+ resolved, isRemote, err := remote.ResolveStrict(remoteFlag, tokenFlag, insecureTLSFlag)
177+ if err != nil {
178+ return nil, false, err
179+ }
177180 if !isRemote {
178- return nil, false
181+ return nil, false, nil
179182 }
180183
181184 opts := remoteClientOptions(resolved.Token, resolved.InsecureTLS)
182185 client := remote.NewClient(resolved.URL, opts...)
183- return client, true
186+ return client, true, nil
184187 }
185188
186189 func remoteClientOptions(token string, insecureTLS bool) []remote.ClientOption {
@@ -196,8 +199,8 @@ func remoteClientOptions(token string, insecureTLS bool) []remote.ClientOption {
196199
197200 // IsRemoteMode returns true if CLI is targeting a remote Gordon instance.
198201 func IsRemoteMode() bool {
199- _, isRemote := remote.Resolve(remoteFlag, tokenFlag, insecureTLSFlag)
200- return isRemote
202+ _, isRemote, err := remote.ResolveStrict(remoteFlag, tokenFlag, insecureTLSFlag)
203+ return err == nil && isRemote
201204 }
202205
203206 // newReloadCmd creates the reload command.
@@ -211,7 +214,10 @@ a running container. Running containers are never restarted to ensure 100% uptim
211214 Use this command after editing config.toml to add new routes, or after pushing
212215 images to the registry when the route was not yet configured.`,
213216 RunE: func(cmd *cobra.Command, args []string) error {
214- client, isRemote := GetRemoteClient()
217+ client, isRemote, err := GetRemoteClient()
218+ if err != nil {
219+ return err
220+ }
215221 if isRemote {
216222 return runReloadRemote(cmd.Context(), client)
217223 }
@@ -321,7 +327,10 @@ func runLogs(ctx context.Context, logsConfigPath, logDomain string, follow bool,
321327 return runContainerLogs(ctx, handle.plane, logDomain, follow, lines, out)
322328 }
323329
324- client, isRemote := GetRemoteClient()
330+ client, isRemote, err := GetRemoteClient()
331+ if err != nil {
332+ return err
333+ }
325334 if isRemote {
326335 return runLogsRemote(ctx, client, logDomain, follow, lines, out)
327336 }
@@ -172,15 +172,18 @@ Commands are organized by where they run:
172 172
173 // GetRemoteClient returns a remote client if targeting a remote instance,173 // GetRemoteClient returns a remote client if targeting a remote instance,
174 // or nil if running locally.174 // or nil if running locally.
175-func GetRemoteClient() (*remote.Client, bool) {175+func GetRemoteClient() (*remote.Client, bool, error) {
176- resolved, isRemote := remote.Resolve(remoteFlag, tokenFlag, insecureTLSFlag)176+ resolved, isRemote, err := remote.ResolveStrict(remoteFlag, tokenFlag, insecureTLSFlag)
177+ if err != nil {
178+ return nil, false, err
179+ }
177 if !isRemote {180 if !isRemote {
178- return nil, false181+ return nil, false, nil
179 }182 }
180 183
181 opts := remoteClientOptions(resolved.Token, resolved.InsecureTLS)184 opts := remoteClientOptions(resolved.Token, resolved.InsecureTLS)
182 client := remote.NewClient(resolved.URL, opts...)185 client := remote.NewClient(resolved.URL, opts...)
183- return client, true186+ return client, true, nil
184 }187 }
185 188
186 func remoteClientOptions(token string, insecureTLS bool) []remote.ClientOption {189 func remoteClientOptions(token string, insecureTLS bool) []remote.ClientOption {
@@ -196,8 +199,8 @@ func remoteClientOptions(token string, insecureTLS bool) []remote.ClientOption {
196 199
197 // IsRemoteMode returns true if CLI is targeting a remote Gordon instance.200 // IsRemoteMode returns true if CLI is targeting a remote Gordon instance.
198 func IsRemoteMode() bool {201 func IsRemoteMode() bool {
199- _, isRemote := remote.Resolve(remoteFlag, tokenFlag, insecureTLSFlag)202+ _, isRemote, err := remote.ResolveStrict(remoteFlag, tokenFlag, insecureTLSFlag)
200- return isRemote203+ return err == nil && isRemote
201 }204 }
202 205
203 // newReloadCmd creates the reload command.206 // newReloadCmd creates the reload command.
@@ -211,7 +214,10 @@ a running container. Running containers are never restarted to ensure 100% uptim
211 Use this command after editing config.toml to add new routes, or after pushing214 Use this command after editing config.toml to add new routes, or after pushing
212 images to the registry when the route was not yet configured.`,215 images to the registry when the route was not yet configured.`,
213 RunE: func(cmd *cobra.Command, args []string) error {216 RunE: func(cmd *cobra.Command, args []string) error {
214- client, isRemote := GetRemoteClient()217+ client, isRemote, err := GetRemoteClient()
218+ if err != nil {
219+ return err
220+ }
215 if isRemote {221 if isRemote {
216 return runReloadRemote(cmd.Context(), client)222 return runReloadRemote(cmd.Context(), client)
217 }223 }
@@ -321,7 +327,10 @@ func runLogs(ctx context.Context, logsConfigPath, logDomain string, follow bool,
321 return runContainerLogs(ctx, handle.plane, logDomain, follow, lines, out)327 return runContainerLogs(ctx, handle.plane, logDomain, follow, lines, out)
322 }328 }
323 329
324- client, isRemote := GetRemoteClient()330+ client, isRemote, err := GetRemoteClient()
331+ if err != nil {
332+ return err
333+ }
325 if isRemote {334 if isRemote {
326 return runLogsRemote(ctx, client, logDomain, follow, lines, out)335 return runLogsRemote(ctx, client, logDomain, follow, lines, out)
327 }336 }
M internal/adapters/in/cli/routes.go +94 -17
@@ -127,15 +127,16 @@ type routeStatusSection struct {
127127 }
128128
129129 type routeDiagnosis struct {
130- Domain string `json:"domain"`
131- Configured bool `json:"configured"`
132- Route *domain.Route `json:"route,omitempty"`
133- Runtime *remote.RouteInfo `json:"runtime,omitempty"`
134- Health *remote.RouteHealth `json:"health,omitempty"`
135- Volumes []dto.Volume `json:"volumes,omitempty"`
136- OrphanedAttachments []domain.CleanupAttachment `json:"orphaned_attachments,omitempty"`
137- Warnings []string `json:"warnings,omitempty"`
138- Hints []string `json:"hints,omitempty"`
130+ Domain string `json:"domain"`
131+ Configured bool `json:"configured"`
132+ Route *domain.Route `json:"route,omitempty"`
133+ Runtime *remote.RouteInfo `json:"runtime,omitempty"`
134+ Health *remote.RouteHealth `json:"health,omitempty"`
135+ Volumes []dto.Volume `json:"volumes,omitempty"`
136+ OrphanedAttachments []domain.CleanupAttachment `json:"orphaned_attachments,omitempty"`
137+ OrphanedEntities []domain.CleanupOrphanedEntity `json:"orphaned_entities,omitempty"`
138+ Warnings []string `json:"warnings,omitempty"`
139+ Hints []string `json:"hints,omitempty"`
139140 }
140141
141142 type routesListDeps struct {
@@ -787,13 +788,13 @@ func loadRoutesListLocalSection(ctx context.Context, cfgPath string) (routeListS
787788 func loadRoutesListRemoteSection(ctx context.Context, name string, entry remote.RemoteEntry) (routeListSection, error) {
788789 section := routeListSection{Kind: "remote", Name: name, URL: entry.URL}
789790 client := newRoutesTargetClient(name, entry)
790- routes, err := client.ListRoutesWithDetails(ctx)
791+ routes, err := client.ListRoutes(ctx)
791792 if err != nil {
792793 section.Error = err.Error()
793794 return section, nil
794795 }
795796
796- section.Routes = routeListItemsFromInfos(routes)
797+ section.Routes = routeListItemsFromRoutes(routes)
797798 return section, nil
798799 }
799800
@@ -885,7 +886,7 @@ func routeStatusItemFromInfo(route remote.RouteInfo, routeHealth *remote.RouteHe
885886 return item
886887 }
887888
888-func routeListItemsFromInfos(routes []remote.RouteInfo) []routeListItem {
889+func routeListItemsFromRoutes(routes []domain.Route) []routeListItem {
889890 items := make([]routeListItem, 0, len(routes))
890891 for _, route := range routes {
891892 items = append(items, routeListItem{Domain: route.Domain, Image: route.Image})
@@ -1105,7 +1106,7 @@ func newRoutesDiagnoseCmd() *cobra.Command {
11051106 }
11061107
11071108 func runRouteDiagnosis(ctx context.Context, domainName string, out io.Writer, jsonOut bool) error {
1108- handle, err := resolveControlPlaneForRouteDomain(ctx, domainName)
1109+ handle, err := resolveControlPlaneForRouteCleanupDomain(ctx, domainName)
11091110 if err != nil {
11101111 return err
11111112 }
@@ -1123,6 +1124,7 @@ func runRouteDiagnosis(ctx context.Context, domainName string, out io.Writer, js
11231124 func buildRouteDiagnosis(ctx context.Context, cp ControlPlane, domainName string) (*routeDiagnosis, error) {
11241125 diag := &routeDiagnosis{Domain: domainName}
11251126 loadRouteDiagnosisConfig(ctx, cp, domainName, diag)
1127+ loadRouteDiagnosisCleanupPreview(ctx, cp, domainName, diag)
11261128 loadRouteDiagnosisRuntime(ctx, cp, domainName, diag)
11271129 loadRouteDiagnosisHealth(ctx, cp, domainName, diag)
11281130 loadRouteDiagnosisOrphanedAttachments(ctx, cp, domainName, diag)
@@ -1143,6 +1145,39 @@ func loadRouteDiagnosisConfig(ctx context.Context, cp ControlPlane, domainName s
11431145 }
11441146 }
11451147
1148+func loadRouteDiagnosisCleanupPreview(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) {
1149+ if diag.Configured {
1150+ return
1151+ }
1152+ previewer, ok := cp.(interface {
1153+ GetRouteCleanupPreview(context.Context, string) (*domain.CleanupReport, error)
1154+ })
1155+ if !ok {
1156+ return
1157+ }
1158+
1159+ report, err := previewer.GetRouteCleanupPreview(ctx, domainName)
1160+ if err != nil {
1161+ if isRemoteNotFoundError(err) {
1162+ return
1163+ }
1164+ diag.Warnings = append(diag.Warnings, "failed to load route cleanup preview: "+err.Error())
1165+ return
1166+ }
1167+ if report == nil {
1168+ return
1169+ }
1170+ if len(diag.OrphanedAttachments) == 0 {
1171+ diag.OrphanedAttachments = append(diag.OrphanedAttachments, report.PreservedAttachments...)
1172+ }
1173+ if len(diag.Volumes) == 0 {
1174+ diag.Volumes = cleanupVolumesForDiagnosis(report.PreservedVolumes)
1175+ }
1176+ diag.OrphanedEntities = append(diag.OrphanedEntities, report.OrphanedEntities...)
1177+ diag.Warnings = append(diag.Warnings, report.Warnings...)
1178+ diag.Hints = append(diag.Hints, report.Hints...)
1179+}
1180+
11461181 func loadRouteDiagnosisRuntime(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) {
11471182 routes, err := cp.ListRoutesWithDetails(ctx)
11481183 if err != nil {
@@ -1165,6 +1200,9 @@ func loadRouteDiagnosisHealth(ctx context.Context, cp ControlPlane, domainName s
11651200 }
11661201
11671202 func loadRouteDiagnosisVolumes(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) {
1203+ if len(diag.Volumes) > 0 {
1204+ return
1205+ }
11681206 if volumes, err := cp.ListVolumes(ctx); err == nil {
11691207 if len(volumes) == 0 {
11701208 return
@@ -1184,6 +1222,9 @@ func routeDiagnosisVolumePrefix(ctx context.Context, cp ControlPlane) string {
11841222 }
11851223
11861224 func loadRouteDiagnosisOrphanedAttachments(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) {
1225+ if len(diag.OrphanedAttachments) > 0 {
1226+ return
1227+ }
11871228 lister, ok := cp.(interface {
11881229 ListOrphanedAttachments(context.Context) ([]domain.CleanupAttachment, error)
11891230 })
@@ -1195,8 +1236,16 @@ func loadRouteDiagnosisOrphanedAttachments(ctx context.Context, cp ControlPlane,
11951236 }
11961237 }
11971238
1239+func cleanupVolumesForDiagnosis(volumes []domain.CleanupVolume) []dto.Volume {
1240+ result := make([]dto.Volume, 0, len(volumes))
1241+ for _, volume := range volumes {
1242+ result = append(result, dto.Volume{Name: volume.Name})
1243+ }
1244+ return result
1245+}
1246+
11981247 func finalizeRouteDiagnosis(diag *routeDiagnosis) {
1199- if !diag.Configured && diag.Runtime != nil {
1248+ if !diag.Configured && (diag.Runtime != nil || hasRouteCleanupEntity(diag.OrphanedEntities, "route_container")) {
12001249 diag.Warnings = append(diag.Warnings, "route is not configured but runtime state still exists")
12011250 }
12021251 if len(diag.Volumes) > 0 {
@@ -1207,6 +1256,15 @@ func finalizeRouteDiagnosis(diag *routeDiagnosis) {
12071256 }
12081257 }
12091258
1259+func hasRouteCleanupEntity(entities []domain.CleanupOrphanedEntity, kind string) bool {
1260+ for _, entity := range entities {
1261+ if entity.Kind == kind {
1262+ return true
1263+ }
1264+ }
1265+ return false
1266+}
1267+
12101268 type routeResourceScope struct {
12111269 containerNames map[string]struct{}
12121270 volumePrefixes []string
@@ -1338,6 +1396,21 @@ func writeRouteDiagnosisSummary(out io.Writer, diag *routeDiagnosis) error {
13381396 }
13391397
13401398 func writeRouteDiagnosisLeftovers(out io.Writer, diag *routeDiagnosis) error {
1399+ for _, entity := range diag.OrphanedEntities {
1400+ label := entity.Name
1401+ if label == "" {
1402+ label = entity.ID
1403+ }
1404+ if label == "" {
1405+ label = entity.Kind
1406+ }
1407+ if entity.Status != "" {
1408+ label = fmt.Sprintf("%s (%s)", label, entity.Status)
1409+ }
1410+ if err := cliWriteLine(out, cliRenderWarning("Orphaned "+strings.ReplaceAll(entity.Kind, "_", " ")+": "+label)); err != nil {
1411+ return err
1412+ }
1413+ }
13411414 volumeLabel := "Volume:"
13421415 if !diag.Configured {
13431416 volumeLabel = "Preserved volume:"
@@ -1382,7 +1455,7 @@ type routePurgeOptions struct {
13821455 }
13831456
13841457 func runRoutePurgeCommand(ctx context.Context, domainName string, out io.Writer, opts routePurgeOptions, jsonOut bool) error {
1385- handle, err := resolveControlPlaneForRouteDomain(ctx, domainName)
1458+ handle, err := resolveControlPlaneForRouteCleanupDomain(ctx, domainName)
13861459 if err != nil {
13871460 return err
13881461 }
@@ -1406,6 +1479,7 @@ func runRoutePurge(ctx context.Context, cp ControlPlane, domainName string, opts
14061479 if diag.Runtime != nil && diag.Runtime.ContainerID != "" {
14071480 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"})
14081481 }
1482+ report.OrphanedEntities = append(report.OrphanedEntities, diag.OrphanedEntities...)
14091483 if opts.Volumes {
14101484 for _, volume := range diag.Volumes {
14111485 report.PreservedVolumes = append(report.PreservedVolumes, domain.CleanupVolume{Name: volume.Name, Reason: "volume purge requires explicit --volumes and runtime support"})
@@ -1511,7 +1585,10 @@ Examples:
15111585 Image: image,
15121586 }
15131587
1514- client, isRemote := GetRemoteClient()
1588+ client, isRemote, err := GetRemoteClient()
1589+ if err != nil {
1590+ return err
1591+ }
15151592 if isRemote {
15161593 if err := client.AddRoute(ctx, route); err != nil {
15171594 return fmt.Errorf("failed to add route: %w", err)
@@ -1568,7 +1645,7 @@ Examples:
15681645 }
15691646 }
15701647
1571- handle, err := resolveControlPlaneForRouteDomain(ctx, routeDomain)
1648+ handle, err := resolveControlPlaneForRouteCleanupDomain(ctx, routeDomain)
15721649 if err != nil {
15731650 return err
15741651 }
@@ -127,15 +127,16 @@ type routeStatusSection struct {
127 }127 }
128 128
129 type routeDiagnosis struct {129 type routeDiagnosis struct {
130- Domain string `json:"domain"`130+ Domain string `json:"domain"`
131- Configured bool `json:"configured"`131+ Configured bool `json:"configured"`
132- Route *domain.Route `json:"route,omitempty"`132+ Route *domain.Route `json:"route,omitempty"`
133- Runtime *remote.RouteInfo `json:"runtime,omitempty"`133+ Runtime *remote.RouteInfo `json:"runtime,omitempty"`
134- Health *remote.RouteHealth `json:"health,omitempty"`134+ Health *remote.RouteHealth `json:"health,omitempty"`
135- Volumes []dto.Volume `json:"volumes,omitempty"`135+ Volumes []dto.Volume `json:"volumes,omitempty"`
136- OrphanedAttachments []domain.CleanupAttachment `json:"orphaned_attachments,omitempty"`136+ OrphanedAttachments []domain.CleanupAttachment `json:"orphaned_attachments,omitempty"`
137- Warnings []string `json:"warnings,omitempty"`137+ OrphanedEntities []domain.CleanupOrphanedEntity `json:"orphaned_entities,omitempty"`
138- Hints []string `json:"hints,omitempty"`138+ Warnings []string `json:"warnings,omitempty"`
139+ Hints []string `json:"hints,omitempty"`
139 }140 }
140 141
141 type routesListDeps struct {142 type routesListDeps struct {
@@ -787,13 +788,13 @@ func loadRoutesListLocalSection(ctx context.Context, cfgPath string) (routeListS
787 func loadRoutesListRemoteSection(ctx context.Context, name string, entry remote.RemoteEntry) (routeListSection, error) {788 func loadRoutesListRemoteSection(ctx context.Context, name string, entry remote.RemoteEntry) (routeListSection, error) {
788 section := routeListSection{Kind: "remote", Name: name, URL: entry.URL}789 section := routeListSection{Kind: "remote", Name: name, URL: entry.URL}
789 client := newRoutesTargetClient(name, entry)790 client := newRoutesTargetClient(name, entry)
790- routes, err := client.ListRoutesWithDetails(ctx)791+ routes, err := client.ListRoutes(ctx)
791 if err != nil {792 if err != nil {
792 section.Error = err.Error()793 section.Error = err.Error()
793 return section, nil794 return section, nil
794 }795 }
795 796
796- section.Routes = routeListItemsFromInfos(routes)797+ section.Routes = routeListItemsFromRoutes(routes)
797 return section, nil798 return section, nil
798 }799 }
799 800
@@ -885,7 +886,7 @@ func routeStatusItemFromInfo(route remote.RouteInfo, routeHealth *remote.RouteHe
885 return item886 return item
886 }887 }
887 888
888-func routeListItemsFromInfos(routes []remote.RouteInfo) []routeListItem {889+func routeListItemsFromRoutes(routes []domain.Route) []routeListItem {
889 items := make([]routeListItem, 0, len(routes))890 items := make([]routeListItem, 0, len(routes))
890 for _, route := range routes {891 for _, route := range routes {
891 items = append(items, routeListItem{Domain: route.Domain, Image: route.Image})892 items = append(items, routeListItem{Domain: route.Domain, Image: route.Image})
@@ -1105,7 +1106,7 @@ func newRoutesDiagnoseCmd() *cobra.Command {
1105 }1106 }
1106 1107
1107 func runRouteDiagnosis(ctx context.Context, domainName string, out io.Writer, jsonOut bool) error {1108 func runRouteDiagnosis(ctx context.Context, domainName string, out io.Writer, jsonOut bool) error {
1108- handle, err := resolveControlPlaneForRouteDomain(ctx, domainName)1109+ handle, err := resolveControlPlaneForRouteCleanupDomain(ctx, domainName)
1109 if err != nil {1110 if err != nil {
1110 return err1111 return err
1111 }1112 }
@@ -1123,6 +1124,7 @@ func runRouteDiagnosis(ctx context.Context, domainName string, out io.Writer, js
1123 func buildRouteDiagnosis(ctx context.Context, cp ControlPlane, domainName string) (*routeDiagnosis, error) {1124 func buildRouteDiagnosis(ctx context.Context, cp ControlPlane, domainName string) (*routeDiagnosis, error) {
1124 diag := &routeDiagnosis{Domain: domainName}1125 diag := &routeDiagnosis{Domain: domainName}
1125 loadRouteDiagnosisConfig(ctx, cp, domainName, diag)1126 loadRouteDiagnosisConfig(ctx, cp, domainName, diag)
1127+ loadRouteDiagnosisCleanupPreview(ctx, cp, domainName, diag)
1126 loadRouteDiagnosisRuntime(ctx, cp, domainName, diag)1128 loadRouteDiagnosisRuntime(ctx, cp, domainName, diag)
1127 loadRouteDiagnosisHealth(ctx, cp, domainName, diag)1129 loadRouteDiagnosisHealth(ctx, cp, domainName, diag)
1128 loadRouteDiagnosisOrphanedAttachments(ctx, cp, domainName, diag)1130 loadRouteDiagnosisOrphanedAttachments(ctx, cp, domainName, diag)
@@ -1143,6 +1145,39 @@ func loadRouteDiagnosisConfig(ctx context.Context, cp ControlPlane, domainName s
1143 }1145 }
1144 }1146 }
1145 1147
1148+func loadRouteDiagnosisCleanupPreview(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) {
1149+ if diag.Configured {
1150+ return
1151+ }
1152+ previewer, ok := cp.(interface {
1153+ GetRouteCleanupPreview(context.Context, string) (*domain.CleanupReport, error)
1154+ })
1155+ if !ok {
1156+ return
1157+ }
1158+
1159+ report, err := previewer.GetRouteCleanupPreview(ctx, domainName)
1160+ if err != nil {
1161+ if isRemoteNotFoundError(err) {
1162+ return
1163+ }
1164+ diag.Warnings = append(diag.Warnings, "failed to load route cleanup preview: "+err.Error())
1165+ return
1166+ }
1167+ if report == nil {
1168+ return
1169+ }
1170+ if len(diag.OrphanedAttachments) == 0 {
1171+ diag.OrphanedAttachments = append(diag.OrphanedAttachments, report.PreservedAttachments...)
1172+ }
1173+ if len(diag.Volumes) == 0 {
1174+ diag.Volumes = cleanupVolumesForDiagnosis(report.PreservedVolumes)
1175+ }
1176+ diag.OrphanedEntities = append(diag.OrphanedEntities, report.OrphanedEntities...)
1177+ diag.Warnings = append(diag.Warnings, report.Warnings...)
1178+ diag.Hints = append(diag.Hints, report.Hints...)
1179+}
1180+
1146 func loadRouteDiagnosisRuntime(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) {1181 func loadRouteDiagnosisRuntime(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) {
1147 routes, err := cp.ListRoutesWithDetails(ctx)1182 routes, err := cp.ListRoutesWithDetails(ctx)
1148 if err != nil {1183 if err != nil {
@@ -1165,6 +1200,9 @@ func loadRouteDiagnosisHealth(ctx context.Context, cp ControlPlane, domainName s
1165 }1200 }
1166 1201
1167 func loadRouteDiagnosisVolumes(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) {1202 func loadRouteDiagnosisVolumes(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) {
1203+ if len(diag.Volumes) > 0 {
1204+ return
1205+ }
1168 if volumes, err := cp.ListVolumes(ctx); err == nil {1206 if volumes, err := cp.ListVolumes(ctx); err == nil {
1169 if len(volumes) == 0 {1207 if len(volumes) == 0 {
1170 return1208 return
@@ -1184,6 +1222,9 @@ func routeDiagnosisVolumePrefix(ctx context.Context, cp ControlPlane) string {
1184 }1222 }
1185 1223
1186 func loadRouteDiagnosisOrphanedAttachments(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) {1224 func loadRouteDiagnosisOrphanedAttachments(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) {
1225+ if len(diag.OrphanedAttachments) > 0 {
1226+ return
1227+ }
1187 lister, ok := cp.(interface {1228 lister, ok := cp.(interface {
1188 ListOrphanedAttachments(context.Context) ([]domain.CleanupAttachment, error)1229 ListOrphanedAttachments(context.Context) ([]domain.CleanupAttachment, error)
1189 })1230 })
@@ -1195,8 +1236,16 @@ func loadRouteDiagnosisOrphanedAttachments(ctx context.Context, cp ControlPlane,
1195 }1236 }
1196 }1237 }
1197 1238
1239+func cleanupVolumesForDiagnosis(volumes []domain.CleanupVolume) []dto.Volume {
1240+ result := make([]dto.Volume, 0, len(volumes))
1241+ for _, volume := range volumes {
1242+ result = append(result, dto.Volume{Name: volume.Name})
1243+ }
1244+ return result
1245+}
1246+
1198 func finalizeRouteDiagnosis(diag *routeDiagnosis) {1247 func finalizeRouteDiagnosis(diag *routeDiagnosis) {
1199- if !diag.Configured && diag.Runtime != nil {1248+ if !diag.Configured && (diag.Runtime != nil || hasRouteCleanupEntity(diag.OrphanedEntities, "route_container")) {
1200 diag.Warnings = append(diag.Warnings, "route is not configured but runtime state still exists")1249 diag.Warnings = append(diag.Warnings, "route is not configured but runtime state still exists")
1201 }1250 }
1202 if len(diag.Volumes) > 0 {1251 if len(diag.Volumes) > 0 {
@@ -1207,6 +1256,15 @@ func finalizeRouteDiagnosis(diag *routeDiagnosis) {
1207 }1256 }
1208 }1257 }
1209 1258
1259+func hasRouteCleanupEntity(entities []domain.CleanupOrphanedEntity, kind string) bool {
1260+ for _, entity := range entities {
1261+ if entity.Kind == kind {
1262+ return true
1263+ }
1264+ }
1265+ return false
1266+}
1267+
1210 type routeResourceScope struct {1268 type routeResourceScope struct {
1211 containerNames map[string]struct{}1269 containerNames map[string]struct{}
1212 volumePrefixes []string1270 volumePrefixes []string
@@ -1338,6 +1396,21 @@ func writeRouteDiagnosisSummary(out io.Writer, diag *routeDiagnosis) error {
1338 }1396 }
1339 1397
1340 func writeRouteDiagnosisLeftovers(out io.Writer, diag *routeDiagnosis) error {1398 func writeRouteDiagnosisLeftovers(out io.Writer, diag *routeDiagnosis) error {
1399+ for _, entity := range diag.OrphanedEntities {
1400+ label := entity.Name
1401+ if label == "" {
1402+ label = entity.ID
1403+ }
1404+ if label == "" {
1405+ label = entity.Kind
1406+ }
1407+ if entity.Status != "" {
1408+ label = fmt.Sprintf("%s (%s)", label, entity.Status)
1409+ }
1410+ if err := cliWriteLine(out, cliRenderWarning("Orphaned "+strings.ReplaceAll(entity.Kind, "_", " ")+": "+label)); err != nil {
1411+ return err
1412+ }
1413+ }
1341 volumeLabel := "Volume:"1414 volumeLabel := "Volume:"
1342 if !diag.Configured {1415 if !diag.Configured {
1343 volumeLabel = "Preserved volume:"1416 volumeLabel = "Preserved volume:"
@@ -1382,7 +1455,7 @@ type routePurgeOptions struct {
1382 }1455 }
1383 1456
1384 func runRoutePurgeCommand(ctx context.Context, domainName string, out io.Writer, opts routePurgeOptions, jsonOut bool) error {1457 func runRoutePurgeCommand(ctx context.Context, domainName string, out io.Writer, opts routePurgeOptions, jsonOut bool) error {
1385- handle, err := resolveControlPlaneForRouteDomain(ctx, domainName)1458+ handle, err := resolveControlPlaneForRouteCleanupDomain(ctx, domainName)
1386 if err != nil {1459 if err != nil {
1387 return err1460 return err
1388 }1461 }
@@ -1406,6 +1479,7 @@ func runRoutePurge(ctx context.Context, cp ControlPlane, domainName string, opts
1406 if diag.Runtime != nil && diag.Runtime.ContainerID != "" {1479 if diag.Runtime != nil && diag.Runtime.ContainerID != "" {
1407 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"})1480 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"})
1408 }1481 }
1482+ report.OrphanedEntities = append(report.OrphanedEntities, diag.OrphanedEntities...)
1409 if opts.Volumes {1483 if opts.Volumes {
1410 for _, volume := range diag.Volumes {1484 for _, volume := range diag.Volumes {
1411 report.PreservedVolumes = append(report.PreservedVolumes, domain.CleanupVolume{Name: volume.Name, Reason: "volume purge requires explicit --volumes and runtime support"})1485 report.PreservedVolumes = append(report.PreservedVolumes, domain.CleanupVolume{Name: volume.Name, Reason: "volume purge requires explicit --volumes and runtime support"})
@@ -1511,7 +1585,10 @@ Examples:
1511 Image: image,1585 Image: image,
1512 }1586 }
1513 1587
1514- client, isRemote := GetRemoteClient()1588+ client, isRemote, err := GetRemoteClient()
1589+ if err != nil {
1590+ return err
1591+ }
1515 if isRemote {1592 if isRemote {
1516 if err := client.AddRoute(ctx, route); err != nil {1593 if err := client.AddRoute(ctx, route); err != nil {
1517 return fmt.Errorf("failed to add route: %w", err)1594 return fmt.Errorf("failed to add route: %w", err)
@@ -1568,7 +1645,7 @@ Examples:
1568 }1645 }
1569 }1646 }
1570 1647
1571- handle, err := resolveControlPlaneForRouteDomain(ctx, routeDomain)1648+ handle, err := resolveControlPlaneForRouteCleanupDomain(ctx, routeDomain)
1572 if err != nil {1649 if err != nil {
1573 return err1650 return err
1574 }1651 }
M internal/adapters/in/cli/routes_test.go +64 -0
@@ -809,6 +809,30 @@ func TestRenderRoutesListSections_HidesLocalErrorWhenRemoteIsUsable(t *testing.T
809809 assert.Contains(t, rendered, "No routes configured")
810810 }
811811
812+func TestLoadRoutesListRemoteSection_UsesInventoryEndpoint(t *testing.T) {
813+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
814+ w.Header().Set("Content-Type", "application/json")
815+ switch {
816+ case r.URL.Path == "/admin/routes" && r.URL.RawQuery == "":
817+ _, _ = w.Write([]byte(`{"routes":[{"domain":"app.example.com","image":"app:latest"}]}`))
818+ case r.URL.Path == "/admin/routes" && r.URL.RawQuery == "detailed=true":
819+ w.WriteHeader(http.StatusInternalServerError)
820+ _, _ = w.Write([]byte(`{"error":"detailed route status unavailable"}`))
821+ default:
822+ t.Fatalf("unexpected request: %s?%s", r.URL.Path, r.URL.RawQuery)
823+ }
824+ }))
825+ defer srv.Close()
826+
827+ section, err := loadRoutesListRemoteSection(context.Background(), "prod", remote.RemoteEntry{URL: srv.URL})
828+
829+ require.NoError(t, err)
830+ require.Empty(t, section.Error)
831+ require.Len(t, section.Routes, 1)
832+ assert.Equal(t, "app.example.com", section.Routes[0].Domain)
833+ assert.Equal(t, "app:latest", section.Routes[0].Image)
834+}
835+
812836 func TestRenderRoutesStatusSections_HidesLocalErrorWhenRemoteIsUsable(t *testing.T) {
813837 sections := []routeStatusSection{
814838 {Kind: "local", Name: "local", Error: "failed to initialize local control plane"},
@@ -931,6 +955,8 @@ func TestBuildRouteDiagnosis_UsesConfiguredVolumePrefixForPreservedVolumes(t *te
931955 case "/admin/routes/example.test":
932956 w.WriteHeader(http.StatusNotFound)
933957 _, _ = w.Write([]byte(`{"error":"route not found"}`))
958+ case "/admin/routes/example.test/cleanup":
959+ _, _ = w.Write([]byte(`{"domain":"example.test","preserved_volumes":[{"name":"custom-example-test-data-docs"}]}`))
934960 case "/admin/routes":
935961 require.Equal(t, "detailed=true", r.URL.RawQuery)
936962 _, _ = w.Write([]byte(`{"routes":[]}`))
@@ -984,6 +1010,40 @@ func TestBuildRouteDiagnosis_IncludesAttachmentVolumesWithDoubleUnderscoreOwnerE
9841010 assert.Equal(t, "gordon-gordon-app__example__test-postgres-var-lib-postgresql", diag.Volumes[0].Name)
9851011 }
9861012
1013+func TestBuildRouteDiagnosis_UsesCleanupPreviewForRemovedRouteRuntimeState(t *testing.T) {
1014+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1015+ w.Header().Set("Content-Type", "application/json")
1016+ switch r.URL.Path {
1017+ case "/admin/routes/example.test":
1018+ w.WriteHeader(http.StatusNotFound)
1019+ _, _ = w.Write([]byte(`{"error":"route not found"}`))
1020+ case "/admin/routes/example.test/cleanup":
1021+ _, _ = w.Write([]byte(`{"domain":"example.test","orphaned_entities":[{"kind":"route_container","id":"ctr-1","name":"gordon-example.test","status":"running"}],"preserved_attachments":[{"name":"old-postgres","container_id":"att-1","owner":"example.test"}],"preserved_volumes":[{"name":"gordon-example-test-data"}]}`))
1022+ case "/admin/routes":
1023+ require.Equal(t, "detailed=true", r.URL.RawQuery)
1024+ _, _ = w.Write([]byte(`{"routes":[]}`))
1025+ case "/admin/health":
1026+ _, _ = w.Write([]byte(`{"health":{}}`))
1027+ default:
1028+ t.Fatalf("unexpected path: %s", r.URL.Path)
1029+ }
1030+ }))
1031+ defer srv.Close()
1032+
1033+ cp := NewRemoteControlPlane(remote.NewClient(srv.URL))
1034+ diag, err := buildRouteDiagnosis(context.Background(), cp, "example.test")
1035+
1036+ require.NoError(t, err)
1037+ assert.False(t, diag.Configured)
1038+ require.Len(t, diag.OrphanedEntities, 1)
1039+ assert.Equal(t, "route_container", diag.OrphanedEntities[0].Kind)
1040+ require.Len(t, diag.OrphanedAttachments, 1)
1041+ assert.Equal(t, "old-postgres", diag.OrphanedAttachments[0].Name)
1042+ require.Len(t, diag.Volumes, 1)
1043+ assert.Equal(t, "gordon-example-test-data", diag.Volumes[0].Name)
1044+ assert.Contains(t, diag.Warnings, "route is not configured but runtime state still exists")
1045+}
1046+
9871047 func TestBuildRouteDiagnosis_RemoteMissingRouteDoesNotWarnOnExpectedNotFound(t *testing.T) {
9881048 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
9891049 w.Header().Set("Content-Type", "application/json")
@@ -991,6 +1051,8 @@ func TestBuildRouteDiagnosis_RemoteMissingRouteDoesNotWarnOnExpectedNotFound(t *
9911051 case "/admin/routes/missing.example.test":
9921052 w.WriteHeader(http.StatusNotFound)
9931053 _, _ = w.Write([]byte(`{"error":"route not found"}`))
1054+ case "/admin/routes/missing.example.test/cleanup":
1055+ _, _ = w.Write([]byte(`{"domain":"missing.example.test"}`))
9941056 case "/admin/routes":
9951057 require.Equal(t, "detailed=true", r.URL.RawQuery)
9961058 _, _ = w.Write([]byte(`{"routes":[]}`))
@@ -1021,6 +1083,8 @@ func TestRunRoutePurge_VolumesUsesConfiguredVolumePrefix(t *testing.T) {
10211083 case "/admin/routes/example.test":
10221084 w.WriteHeader(http.StatusNotFound)
10231085 _, _ = w.Write([]byte(`{"error":"route not found"}`))
1086+ case "/admin/routes/example.test/cleanup":
1087+ _, _ = w.Write([]byte(`{"domain":"example.test","preserved_volumes":[{"name":"custom-example-test-data-docs"}]}`))
10241088 case "/admin/routes":
10251089 require.Equal(t, "detailed=true", r.URL.RawQuery)
10261090 _, _ = w.Write([]byte(`{"routes":[]}`))
@@ -809,6 +809,30 @@ func TestRenderRoutesListSections_HidesLocalErrorWhenRemoteIsUsable(t *testing.T
809 assert.Contains(t, rendered, "No routes configured")809 assert.Contains(t, rendered, "No routes configured")
810 }810 }
811 811
812+func TestLoadRoutesListRemoteSection_UsesInventoryEndpoint(t *testing.T) {
813+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
814+ w.Header().Set("Content-Type", "application/json")
815+ switch {
816+ case r.URL.Path == "/admin/routes" && r.URL.RawQuery == "":
817+ _, _ = w.Write([]byte(`{"routes":[{"domain":"app.example.com","image":"app:latest"}]}`))
818+ case r.URL.Path == "/admin/routes" && r.URL.RawQuery == "detailed=true":
819+ w.WriteHeader(http.StatusInternalServerError)
820+ _, _ = w.Write([]byte(`{"error":"detailed route status unavailable"}`))
821+ default:
822+ t.Fatalf("unexpected request: %s?%s", r.URL.Path, r.URL.RawQuery)
823+ }
824+ }))
825+ defer srv.Close()
826+
827+ section, err := loadRoutesListRemoteSection(context.Background(), "prod", remote.RemoteEntry{URL: srv.URL})
828+
829+ require.NoError(t, err)
830+ require.Empty(t, section.Error)
831+ require.Len(t, section.Routes, 1)
832+ assert.Equal(t, "app.example.com", section.Routes[0].Domain)
833+ assert.Equal(t, "app:latest", section.Routes[0].Image)
834+}
835+
812 func TestRenderRoutesStatusSections_HidesLocalErrorWhenRemoteIsUsable(t *testing.T) {836 func TestRenderRoutesStatusSections_HidesLocalErrorWhenRemoteIsUsable(t *testing.T) {
813 sections := []routeStatusSection{837 sections := []routeStatusSection{
814 {Kind: "local", Name: "local", Error: "failed to initialize local control plane"},838 {Kind: "local", Name: "local", Error: "failed to initialize local control plane"},
@@ -931,6 +955,8 @@ func TestBuildRouteDiagnosis_UsesConfiguredVolumePrefixForPreservedVolumes(t *te
931 case "/admin/routes/example.test":955 case "/admin/routes/example.test":
932 w.WriteHeader(http.StatusNotFound)956 w.WriteHeader(http.StatusNotFound)
933 _, _ = w.Write([]byte(`{"error":"route not found"}`))957 _, _ = w.Write([]byte(`{"error":"route not found"}`))
958+ case "/admin/routes/example.test/cleanup":
959+ _, _ = w.Write([]byte(`{"domain":"example.test","preserved_volumes":[{"name":"custom-example-test-data-docs"}]}`))
934 case "/admin/routes":960 case "/admin/routes":
935 require.Equal(t, "detailed=true", r.URL.RawQuery)961 require.Equal(t, "detailed=true", r.URL.RawQuery)
936 _, _ = w.Write([]byte(`{"routes":[]}`))962 _, _ = w.Write([]byte(`{"routes":[]}`))
@@ -984,6 +1010,40 @@ func TestBuildRouteDiagnosis_IncludesAttachmentVolumesWithDoubleUnderscoreOwnerE
984 assert.Equal(t, "gordon-gordon-app__example__test-postgres-var-lib-postgresql", diag.Volumes[0].Name)1010 assert.Equal(t, "gordon-gordon-app__example__test-postgres-var-lib-postgresql", diag.Volumes[0].Name)
985 }1011 }
986 1012
1013+func TestBuildRouteDiagnosis_UsesCleanupPreviewForRemovedRouteRuntimeState(t *testing.T) {
1014+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1015+ w.Header().Set("Content-Type", "application/json")
1016+ switch r.URL.Path {
1017+ case "/admin/routes/example.test":
1018+ w.WriteHeader(http.StatusNotFound)
1019+ _, _ = w.Write([]byte(`{"error":"route not found"}`))
1020+ case "/admin/routes/example.test/cleanup":
1021+ _, _ = w.Write([]byte(`{"domain":"example.test","orphaned_entities":[{"kind":"route_container","id":"ctr-1","name":"gordon-example.test","status":"running"}],"preserved_attachments":[{"name":"old-postgres","container_id":"att-1","owner":"example.test"}],"preserved_volumes":[{"name":"gordon-example-test-data"}]}`))
1022+ case "/admin/routes":
1023+ require.Equal(t, "detailed=true", r.URL.RawQuery)
1024+ _, _ = w.Write([]byte(`{"routes":[]}`))
1025+ case "/admin/health":
1026+ _, _ = w.Write([]byte(`{"health":{}}`))
1027+ default:
1028+ t.Fatalf("unexpected path: %s", r.URL.Path)
1029+ }
1030+ }))
1031+ defer srv.Close()
1032+
1033+ cp := NewRemoteControlPlane(remote.NewClient(srv.URL))
1034+ diag, err := buildRouteDiagnosis(context.Background(), cp, "example.test")
1035+
1036+ require.NoError(t, err)
1037+ assert.False(t, diag.Configured)
1038+ require.Len(t, diag.OrphanedEntities, 1)
1039+ assert.Equal(t, "route_container", diag.OrphanedEntities[0].Kind)
1040+ require.Len(t, diag.OrphanedAttachments, 1)
1041+ assert.Equal(t, "old-postgres", diag.OrphanedAttachments[0].Name)
1042+ require.Len(t, diag.Volumes, 1)
1043+ assert.Equal(t, "gordon-example-test-data", diag.Volumes[0].Name)
1044+ assert.Contains(t, diag.Warnings, "route is not configured but runtime state still exists")
1045+}
1046+
987 func TestBuildRouteDiagnosis_RemoteMissingRouteDoesNotWarnOnExpectedNotFound(t *testing.T) {1047 func TestBuildRouteDiagnosis_RemoteMissingRouteDoesNotWarnOnExpectedNotFound(t *testing.T) {
988 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {1048 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
989 w.Header().Set("Content-Type", "application/json")1049 w.Header().Set("Content-Type", "application/json")
@@ -991,6 +1051,8 @@ func TestBuildRouteDiagnosis_RemoteMissingRouteDoesNotWarnOnExpectedNotFound(t *
991 case "/admin/routes/missing.example.test":1051 case "/admin/routes/missing.example.test":
992 w.WriteHeader(http.StatusNotFound)1052 w.WriteHeader(http.StatusNotFound)
993 _, _ = w.Write([]byte(`{"error":"route not found"}`))1053 _, _ = w.Write([]byte(`{"error":"route not found"}`))
1054+ case "/admin/routes/missing.example.test/cleanup":
1055+ _, _ = w.Write([]byte(`{"domain":"missing.example.test"}`))
994 case "/admin/routes":1056 case "/admin/routes":
995 require.Equal(t, "detailed=true", r.URL.RawQuery)1057 require.Equal(t, "detailed=true", r.URL.RawQuery)
996 _, _ = w.Write([]byte(`{"routes":[]}`))1058 _, _ = w.Write([]byte(`{"routes":[]}`))
@@ -1021,6 +1083,8 @@ func TestRunRoutePurge_VolumesUsesConfiguredVolumePrefix(t *testing.T) {
1021 case "/admin/routes/example.test":1083 case "/admin/routes/example.test":
1022 w.WriteHeader(http.StatusNotFound)1084 w.WriteHeader(http.StatusNotFound)
1023 _, _ = w.Write([]byte(`{"error":"route not found"}`))1085 _, _ = w.Write([]byte(`{"error":"route not found"}`))
1086+ case "/admin/routes/example.test/cleanup":
1087+ _, _ = w.Write([]byte(`{"domain":"example.test","preserved_volumes":[{"name":"custom-example-test-data-docs"}]}`))
1024 case "/admin/routes":1088 case "/admin/routes":
1025 require.Equal(t, "detailed=true", r.URL.RawQuery)1089 require.Equal(t, "detailed=true", r.URL.RawQuery)
1026 _, _ = w.Write([]byte(`{"routes":[]}`))1090 _, _ = w.Write([]byte(`{"routes":[]}`))
M internal/adapters/in/http/admin/handler.go +169 -4
@@ -628,6 +628,15 @@ func (h *Handler) handleRoutesGet(w http.ResponseWriter, r *http.Request, routeD
628628 return
629629 }
630630
631+ if parentDomain, ok := strings.CutSuffix(routeDomain, "/cleanup"); ok {
632+ if parentDomain == "" {
633+ h.sendError(w, http.StatusBadRequest, "domain required in path")
634+ return
635+ }
636+ h.handleRouteCleanupPreview(w, r, parentDomain)
637+ return
638+ }
639+
631640 if parentDomain, ok := strings.CutSuffix(routeDomain, "/attachments"); ok {
632641 if parentDomain == "" {
633642 h.sendError(w, http.StatusBadRequest, "domain required in path")
@@ -650,6 +659,151 @@ func (h *Handler) handleRoutesGet(w http.ResponseWriter, r *http.Request, routeD
650659 h.sendJSON(w, http.StatusOK, toRouteResponse(*route))
651660 }
652661
662+func (h *Handler) handleRouteCleanupPreview(w http.ResponseWriter, r *http.Request, routeDomain string) {
663+ ctx := r.Context()
664+ log := zerowrap.FromCtx(ctx)
665+
666+ if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionRead) {
667+ h.sendError(w, http.StatusForbidden, "insufficient permissions for config:read")
668+ return
669+ }
670+ if !HasAccess(ctx, domain.AdminResourceVolumes, domain.AdminActionRead) {
671+ h.sendError(w, http.StatusForbidden, "insufficient permissions for volumes:read")
672+ return
673+ }
674+
675+ previewer, ok := any(h.containerSvc).(interface {
676+ PreviewRemovedRouteCleanup(context.Context, string) (*domain.CleanupReport, error)
677+ })
678+ if !ok {
679+ log.Error().Str("domain", routeDomain).Msg("route cleanup preview unavailable")
680+ h.sendError(w, http.StatusInternalServerError, "route cleanup preview unavailable")
681+ return
682+ }
683+
684+ report, err := previewer.PreviewRemovedRouteCleanup(ctx, routeDomain)
685+ if err != nil {
686+ switch {
687+ case errors.Is(err, domain.ErrRouteDomainInvalid), errors.Is(err, domain.ErrRouteDomainEmpty):
688+ h.sendError(w, http.StatusBadRequest, "invalid route domain")
689+ default:
690+ log.Error().Err(err).Str("domain", routeDomain).Msg("failed to inspect route cleanup state")
691+ h.sendError(w, http.StatusInternalServerError, "failed to inspect route cleanup state")
692+ }
693+ return
694+ }
695+
696+ if h.volumeSvc == nil {
697+ log.Error().Str("domain", routeDomain).Msg("volume service not available for route cleanup preview")
698+ h.sendError(w, http.StatusServiceUnavailable, "volume service not available")
699+ return
700+ }
701+
702+ volumes, err := h.volumeSvc.ListVolumes(ctx)
703+ if err != nil {
704+ log.Error().Err(err).Str("domain", routeDomain).Msg("failed to list volumes for route cleanup preview")
705+ h.sendError(w, http.StatusInternalServerError, "failed to inspect route cleanup state")
706+ return
707+ }
708+ report.PreservedVolumes = append(
709+ report.PreservedVolumes,
710+ matchingRouteCleanupVolumes(routeDomain, h.routeCleanupVolumePrefix(), volumes, report.PreservedAttachments)...,
711+ )
712+
713+ h.sendJSON(w, http.StatusOK, dto.CleanupReportFromDomain(report))
714+}
715+
716+func (h *Handler) routeCleanupVolumePrefix() string {
717+ const defaultPrefix = "gordon"
718+ if h.configSvc == nil {
719+ return defaultPrefix
720+ }
721+ if volumeCfg, ok := any(h.configSvc).(interface{ GetVolumeConfig() (bool, string, bool) }); ok {
722+ _, prefix, _ := volumeCfg.GetVolumeConfig()
723+ if prefix != "" {
724+ return prefix
725+ }
726+ }
727+ return defaultPrefix
728+}
729+
730+type routeCleanupVolumeScope struct {
731+ containerNames map[string]struct{}
732+ volumePrefixes []string
733+}
734+
735+func newRouteCleanupVolumeScope(routeDomain, volumePrefix string, attachments []domain.CleanupAttachment) routeCleanupVolumeScope {
736+ if volumePrefix == "" {
737+ volumePrefix = "gordon"
738+ }
739+ scope := routeCleanupVolumeScope{
740+ containerNames: make(map[string]struct{}),
741+ volumePrefixes: []string{volumePrefix + "-" + strings.ReplaceAll(routeDomain, ".", "-") + "-"},
742+ }
743+ for _, name := range []string{
744+ fmt.Sprintf("gordon-%s", routeDomain),
745+ fmt.Sprintf("gordon-%s-new", routeDomain),
746+ fmt.Sprintf("gordon-%s-next", routeDomain),
747+ } {
748+ scope.containerNames[name] = struct{}{}
749+ }
750+ for _, attachment := range attachments {
751+ if attachment.Name == "" {
752+ continue
753+ }
754+ scope.containerNames[attachment.Name] = struct{}{}
755+ scope.volumePrefixes = append(scope.volumePrefixes, volumePrefix+"-"+attachment.Name+"-")
756+ for _, name := range routeAttachmentContainerNamesForCleanup(routeDomain, attachment.Name) {
757+ scope.containerNames[name] = struct{}{}
758+ scope.volumePrefixes = append(scope.volumePrefixes, volumePrefix+"-"+name+"-")
759+ }
760+ }
761+ return scope
762+}
763+
764+func routeAttachmentContainerNamesForCleanup(routeDomain, serviceName string) []string {
765+ if serviceName == "" {
766+ return nil
767+ }
768+ return []string{
769+ fmt.Sprintf("gordon-%s-%s", domain.SanitizeDomainForContainer(routeDomain), serviceName),
770+ fmt.Sprintf("gordon-%s-%s", domain.SanitizeDomainForContainerLegacy(routeDomain), serviceName),
771+ }
772+}
773+
774+func matchingRouteCleanupVolumes(routeDomain, volumePrefix string, volumes []*domain.VolumeInfo, attachments []domain.CleanupAttachment) []domain.CleanupVolume {
775+ scope := newRouteCleanupVolumeScope(routeDomain, volumePrefix, attachments)
776+ matched := make([]domain.CleanupVolume, 0)
777+ for _, volume := range volumes {
778+ if volume == nil || !scope.matches(volume) {
779+ continue
780+ }
781+ matched = append(matched, domain.CleanupVolume{
782+ Name: volume.Name,
783+ ContainerPath: "",
784+ Reason: "volume preserved for explicit cleanup review",
785+ })
786+ }
787+ return matched
788+}
789+
790+func (s routeCleanupVolumeScope) matches(volume *domain.VolumeInfo) bool {
791+ if volume == nil {
792+ return false
793+ }
794+ for _, containerName := range volume.Containers {
795+ if _, ok := s.containerNames[containerName]; ok {
796+ return true
797+ }
798+ }
799+ for _, prefix := range s.volumePrefixes {
800+ if strings.HasPrefix(volume.Name, prefix) {
801+ return true
802+ }
803+ }
804+ return false
805+}
806+
653807 func (h *Handler) handleRoutesPost(w http.ResponseWriter, r *http.Request) {
654808 ctx := r.Context()
655809 log := zerowrap.FromCtx(ctx)
@@ -823,12 +977,18 @@ func (h *Handler) handleRoutesByImage(w http.ResponseWriter, r *http.Request, pa
823977 return
824978 }
825979
826- imageName := strings.TrimPrefix(path, "/routes/by-image/")
827- if imageName == "" || imageName == "/routes/by-image" {
980+ rawImageName := strings.TrimPrefix(path, "/routes/by-image/")
981+ if rawImageName == "" || rawImageName == "/routes/by-image" {
828982 h.sendError(w, http.StatusBadRequest, "image name required in path")
829983 return
830984 }
831985
986+ imageName, err := url.PathUnescape(rawImageName)
987+ if err != nil {
988+ h.sendError(w, http.StatusBadRequest, "invalid image name encoding")
989+ return
990+ }
991+
832992 routes := h.configSvc.FindRoutesByImage(ctx, imageName)
833993
834994 response := make([]routeResponse, 0, len(routes))
@@ -1519,11 +1679,16 @@ func (h *Handler) handleTags(w http.ResponseWriter, r *http.Request, path string
15191679 return
15201680 }
15211681
1522- repository := strings.TrimPrefix(path, "/tags/")
1523- if repository == "" || repository == "/tags" {
1682+ rawRepository := strings.TrimPrefix(path, "/tags/")
1683+ if rawRepository == "" || rawRepository == "/tags" {
15241684 h.sendError(w, http.StatusBadRequest, "repository name required in path")
15251685 return
15261686 }
1687+ repository, err := url.PathUnescape(rawRepository)
1688+ if err != nil {
1689+ h.sendError(w, http.StatusBadRequest, "invalid repository name encoding")
1690+ return
1691+ }
15271692 if err := validation.ValidateRepositoryName(repository); err != nil {
15281693 h.sendError(w, http.StatusBadRequest, "invalid repository name")
15291694 return
@@ -628,6 +628,15 @@ func (h *Handler) handleRoutesGet(w http.ResponseWriter, r *http.Request, routeD
628 return628 return
629 }629 }
630 630
631+ if parentDomain, ok := strings.CutSuffix(routeDomain, "/cleanup"); ok {
632+ if parentDomain == "" {
633+ h.sendError(w, http.StatusBadRequest, "domain required in path")
634+ return
635+ }
636+ h.handleRouteCleanupPreview(w, r, parentDomain)
637+ return
638+ }
639+
631 if parentDomain, ok := strings.CutSuffix(routeDomain, "/attachments"); ok {640 if parentDomain, ok := strings.CutSuffix(routeDomain, "/attachments"); ok {
632 if parentDomain == "" {641 if parentDomain == "" {
633 h.sendError(w, http.StatusBadRequest, "domain required in path")642 h.sendError(w, http.StatusBadRequest, "domain required in path")
@@ -650,6 +659,151 @@ func (h *Handler) handleRoutesGet(w http.ResponseWriter, r *http.Request, routeD
650 h.sendJSON(w, http.StatusOK, toRouteResponse(*route))659 h.sendJSON(w, http.StatusOK, toRouteResponse(*route))
651 }660 }
652 661
662+func (h *Handler) handleRouteCleanupPreview(w http.ResponseWriter, r *http.Request, routeDomain string) {
663+ ctx := r.Context()
664+ log := zerowrap.FromCtx(ctx)
665+
666+ if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionRead) {
667+ h.sendError(w, http.StatusForbidden, "insufficient permissions for config:read")
668+ return
669+ }
670+ if !HasAccess(ctx, domain.AdminResourceVolumes, domain.AdminActionRead) {
671+ h.sendError(w, http.StatusForbidden, "insufficient permissions for volumes:read")
672+ return
673+ }
674+
675+ previewer, ok := any(h.containerSvc).(interface {
676+ PreviewRemovedRouteCleanup(context.Context, string) (*domain.CleanupReport, error)
677+ })
678+ if !ok {
679+ log.Error().Str("domain", routeDomain).Msg("route cleanup preview unavailable")
680+ h.sendError(w, http.StatusInternalServerError, "route cleanup preview unavailable")
681+ return
682+ }
683+
684+ report, err := previewer.PreviewRemovedRouteCleanup(ctx, routeDomain)
685+ if err != nil {
686+ switch {
687+ case errors.Is(err, domain.ErrRouteDomainInvalid), errors.Is(err, domain.ErrRouteDomainEmpty):
688+ h.sendError(w, http.StatusBadRequest, "invalid route domain")
689+ default:
690+ log.Error().Err(err).Str("domain", routeDomain).Msg("failed to inspect route cleanup state")
691+ h.sendError(w, http.StatusInternalServerError, "failed to inspect route cleanup state")
692+ }
693+ return
694+ }
695+
696+ if h.volumeSvc == nil {
697+ log.Error().Str("domain", routeDomain).Msg("volume service not available for route cleanup preview")
698+ h.sendError(w, http.StatusServiceUnavailable, "volume service not available")
699+ return
700+ }
701+
702+ volumes, err := h.volumeSvc.ListVolumes(ctx)
703+ if err != nil {
704+ log.Error().Err(err).Str("domain", routeDomain).Msg("failed to list volumes for route cleanup preview")
705+ h.sendError(w, http.StatusInternalServerError, "failed to inspect route cleanup state")
706+ return
707+ }
708+ report.PreservedVolumes = append(
709+ report.PreservedVolumes,
710+ matchingRouteCleanupVolumes(routeDomain, h.routeCleanupVolumePrefix(), volumes, report.PreservedAttachments)...,
711+ )
712+
713+ h.sendJSON(w, http.StatusOK, dto.CleanupReportFromDomain(report))
714+}
715+
716+func (h *Handler) routeCleanupVolumePrefix() string {
717+ const defaultPrefix = "gordon"
718+ if h.configSvc == nil {
719+ return defaultPrefix
720+ }
721+ if volumeCfg, ok := any(h.configSvc).(interface{ GetVolumeConfig() (bool, string, bool) }); ok {
722+ _, prefix, _ := volumeCfg.GetVolumeConfig()
723+ if prefix != "" {
724+ return prefix
725+ }
726+ }
727+ return defaultPrefix
728+}
729+
730+type routeCleanupVolumeScope struct {
731+ containerNames map[string]struct{}
732+ volumePrefixes []string
733+}
734+
735+func newRouteCleanupVolumeScope(routeDomain, volumePrefix string, attachments []domain.CleanupAttachment) routeCleanupVolumeScope {
736+ if volumePrefix == "" {
737+ volumePrefix = "gordon"
738+ }
739+ scope := routeCleanupVolumeScope{
740+ containerNames: make(map[string]struct{}),
741+ volumePrefixes: []string{volumePrefix + "-" + strings.ReplaceAll(routeDomain, ".", "-") + "-"},
742+ }
743+ for _, name := range []string{
744+ fmt.Sprintf("gordon-%s", routeDomain),
745+ fmt.Sprintf("gordon-%s-new", routeDomain),
746+ fmt.Sprintf("gordon-%s-next", routeDomain),
747+ } {
748+ scope.containerNames[name] = struct{}{}
749+ }
750+ for _, attachment := range attachments {
751+ if attachment.Name == "" {
752+ continue
753+ }
754+ scope.containerNames[attachment.Name] = struct{}{}
755+ scope.volumePrefixes = append(scope.volumePrefixes, volumePrefix+"-"+attachment.Name+"-")
756+ for _, name := range routeAttachmentContainerNamesForCleanup(routeDomain, attachment.Name) {
757+ scope.containerNames[name] = struct{}{}
758+ scope.volumePrefixes = append(scope.volumePrefixes, volumePrefix+"-"+name+"-")
759+ }
760+ }
761+ return scope
762+}
763+
764+func routeAttachmentContainerNamesForCleanup(routeDomain, serviceName string) []string {
765+ if serviceName == "" {
766+ return nil
767+ }
768+ return []string{
769+ fmt.Sprintf("gordon-%s-%s", domain.SanitizeDomainForContainer(routeDomain), serviceName),
770+ fmt.Sprintf("gordon-%s-%s", domain.SanitizeDomainForContainerLegacy(routeDomain), serviceName),
771+ }
772+}
773+
774+func matchingRouteCleanupVolumes(routeDomain, volumePrefix string, volumes []*domain.VolumeInfo, attachments []domain.CleanupAttachment) []domain.CleanupVolume {
775+ scope := newRouteCleanupVolumeScope(routeDomain, volumePrefix, attachments)
776+ matched := make([]domain.CleanupVolume, 0)
777+ for _, volume := range volumes {
778+ if volume == nil || !scope.matches(volume) {
779+ continue
780+ }
781+ matched = append(matched, domain.CleanupVolume{
782+ Name: volume.Name,
783+ ContainerPath: "",
784+ Reason: "volume preserved for explicit cleanup review",
785+ })
786+ }
787+ return matched
788+}
789+
790+func (s routeCleanupVolumeScope) matches(volume *domain.VolumeInfo) bool {
791+ if volume == nil {
792+ return false
793+ }
794+ for _, containerName := range volume.Containers {
795+ if _, ok := s.containerNames[containerName]; ok {
796+ return true
797+ }
798+ }
799+ for _, prefix := range s.volumePrefixes {
800+ if strings.HasPrefix(volume.Name, prefix) {
801+ return true
802+ }
803+ }
804+ return false
805+}
806+
653 func (h *Handler) handleRoutesPost(w http.ResponseWriter, r *http.Request) {807 func (h *Handler) handleRoutesPost(w http.ResponseWriter, r *http.Request) {
654 ctx := r.Context()808 ctx := r.Context()
655 log := zerowrap.FromCtx(ctx)809 log := zerowrap.FromCtx(ctx)
@@ -823,12 +977,18 @@ func (h *Handler) handleRoutesByImage(w http.ResponseWriter, r *http.Request, pa
823 return977 return
824 }978 }
825 979
826- imageName := strings.TrimPrefix(path, "/routes/by-image/")980+ rawImageName := strings.TrimPrefix(path, "/routes/by-image/")
827- if imageName == "" || imageName == "/routes/by-image" {981+ if rawImageName == "" || rawImageName == "/routes/by-image" {
828 h.sendError(w, http.StatusBadRequest, "image name required in path")982 h.sendError(w, http.StatusBadRequest, "image name required in path")
829 return983 return
830 }984 }
831 985
986+ imageName, err := url.PathUnescape(rawImageName)
987+ if err != nil {
988+ h.sendError(w, http.StatusBadRequest, "invalid image name encoding")
989+ return
990+ }
991+
832 routes := h.configSvc.FindRoutesByImage(ctx, imageName)992 routes := h.configSvc.FindRoutesByImage(ctx, imageName)
833 993
834 response := make([]routeResponse, 0, len(routes))994 response := make([]routeResponse, 0, len(routes))
@@ -1519,11 +1679,16 @@ func (h *Handler) handleTags(w http.ResponseWriter, r *http.Request, path string
1519 return1679 return
1520 }1680 }
1521 1681
1522- repository := strings.TrimPrefix(path, "/tags/")1682+ rawRepository := strings.TrimPrefix(path, "/tags/")
1523- if repository == "" || repository == "/tags" {1683+ if rawRepository == "" || rawRepository == "/tags" {
1524 h.sendError(w, http.StatusBadRequest, "repository name required in path")1684 h.sendError(w, http.StatusBadRequest, "repository name required in path")
1525 return1685 return
1526 }1686 }
1687+ repository, err := url.PathUnescape(rawRepository)
1688+ if err != nil {
1689+ h.sendError(w, http.StatusBadRequest, "invalid repository name encoding")
1690+ return
1691+ }
1527 if err := validation.ValidateRepositoryName(repository); err != nil {1692 if err := validation.ValidateRepositoryName(repository); err != nil {
1528 h.sendError(w, http.StatusBadRequest, "invalid repository name")1693 h.sendError(w, http.StatusBadRequest, "invalid repository name")
1529 return1694 return
M internal/adapters/in/http/admin/handler_test.go +119 -0
@@ -29,6 +29,18 @@ type stubImageService struct {
2929 pruneFunc func(context.Context, domain.ImagePruneOptions) (domain.ImagePruneReport, error)
3030 }
3131
32+type previewContainerService struct {
33+ *inmocks.MockContainerService
34+ preview func(context.Context, string) (*domain.CleanupReport, error)
35+}
36+
37+func (s *previewContainerService) PreviewRemovedRouteCleanup(ctx context.Context, routeDomain string) (*domain.CleanupReport, error) {
38+ if s.preview == nil {
39+ return nil, nil
40+ }
41+ return s.preview(ctx, routeDomain)
42+}
43+
3244 type noopReloadTrigger struct{}
3345
3446 func (noopReloadTrigger) Trigger(context.Context) error { return nil }
@@ -687,6 +699,34 @@ func TestHandler_AttachmentsByImageGet_RequiresReadScope(t *testing.T) {
687699 assert.JSONEq(t, `{"error":"insufficient permissions for config:read"}`, rec.Body.String())
688700 }
689701
702+func TestHandler_RoutesByImageGet_URLDecodedImage(t *testing.T) {
703+ configSvc := inmocks.NewMockConfigService(t)
704+ authSvc := inmocks.NewMockAuthService(t)
705+ containerSvc := inmocks.NewMockContainerService(t)
706+ secretSvc := inmocks.NewMockSecretService(t)
707+
708+ handler := newTestHandler(t, func(d *HandlerDeps) {
709+ d.ConfigSvc = configSvc
710+ d.AuthSvc = authSvc
711+ d.ContainerSvc = containerSvc
712+ d.SecretSvc = secretSvc
713+ })
714+
715+ configSvc.EXPECT().FindRoutesByImage(mock.Anything, "registry/org/image:tag").Return([]domain.Route{{
716+ Domain: "app.example.com",
717+ Image: "registry/org/image:tag",
718+ }}).Once()
719+
720+ req := httptest.NewRequest("GET", "/admin/routes/by-image/registry%2Forg%2Fimage:tag", nil)
721+ req = req.WithContext(ctxWithScopes("admin:routes:read"))
722+ rec := httptest.NewRecorder()
723+
724+ handler.ServeHTTP(rec, req)
725+
726+ assert.Equal(t, http.StatusOK, rec.Code)
727+ assert.JSONEq(t, `{"image":"registry/org/image:tag","routes":[{"domain":"app.example.com","image":"registry/org/image:tag","https":false}]}`, rec.Body.String())
728+}
729+
690730 func TestHandler_RoutesDelete_RequiresWriteScope(t *testing.T) {
691731 configSvc := inmocks.NewMockConfigService(t)
692732 authSvc := inmocks.NewMockAuthService(t)
@@ -1566,6 +1606,58 @@ func TestHandler_RoutesGet_NotFound(t *testing.T) {
15661606 assert.Equal(t, http.StatusNotFound, rec.Code)
15671607 }
15681608
1609+func TestHandler_RouteCleanupPreview_RequiresRoutesConfigAndVolumesReadScopes(t *testing.T) {
1610+ configSvc := inmocks.NewMockConfigService(t)
1611+ authSvc := inmocks.NewMockAuthService(t)
1612+ volumeSvc := inmocks.NewMockVolumeService(t)
1613+ secretSvc := inmocks.NewMockSecretService(t)
1614+ containerMock := inmocks.NewMockContainerService(t)
1615+ containerSvc := &previewContainerService{
1616+ MockContainerService: containerMock,
1617+ preview: func(_ context.Context, routeDomain string) (*domain.CleanupReport, error) {
1618+ return &domain.CleanupReport{
1619+ Domain: routeDomain,
1620+ OrphanedEntities: []domain.CleanupOrphanedEntity{{Kind: "route_container", ID: "ctr-1", Status: "running"}},
1621+ }, nil
1622+ },
1623+ }
1624+
1625+ handler := newTestHandler(t, func(d *HandlerDeps) {
1626+ d.ConfigSvc = configSvc
1627+ d.AuthSvc = authSvc
1628+ d.ContainerSvc = containerSvc
1629+ d.VolumeSvc = volumeSvc
1630+ d.SecretSvc = secretSvc
1631+ })
1632+
1633+ volumeSvc.EXPECT().ListVolumes(mock.Anything).Return([]*domain.VolumeInfo{{Name: "gordon-app-example-com-data"}}, nil).Once()
1634+
1635+ req := httptest.NewRequest("GET", "/admin/routes/app.example.com/cleanup", nil)
1636+ req = req.WithContext(ctxWithScopes("admin:routes:read", "admin:config:read", "admin:volumes:read"))
1637+ rec := httptest.NewRecorder()
1638+
1639+ handler.ServeHTTP(rec, req)
1640+
1641+ assert.Equal(t, http.StatusOK, rec.Code)
1642+ var report dto.CleanupReport
1643+ require.NoError(t, json.NewDecoder(rec.Body).Decode(&report))
1644+ require.Len(t, report.OrphanedEntities, 1)
1645+ assert.Equal(t, "route_container", report.OrphanedEntities[0].Kind)
1646+ require.Len(t, report.PreservedVolumes, 1)
1647+ assert.Equal(t, "gordon-app-example-com-data", report.PreservedVolumes[0].Name)
1648+
1649+ for _, scopes := range [][]string{
1650+ {"admin:routes:read", "admin:volumes:read"},
1651+ {"admin:routes:read", "admin:config:read"},
1652+ } {
1653+ req := httptest.NewRequest("GET", "/admin/routes/app.example.com/cleanup", nil)
1654+ req = req.WithContext(ctxWithScopes(scopes...))
1655+ rec := httptest.NewRecorder()
1656+ handler.ServeHTTP(rec, req)
1657+ assert.Equal(t, http.StatusForbidden, rec.Code)
1658+ }
1659+}
1660+
15691661 func TestHandler_RoutesPost_MissingFields(t *testing.T) {
15701662 tests := []struct {
15711663 name string
@@ -2093,6 +2185,33 @@ func TestHandler_Tags_Error(t *testing.T) {
20932185 assert.NotContains(t, rec.Body.String(), "registry error")
20942186 }
20952187
2188+func TestHandler_Tags_URLDecodedRepository(t *testing.T) {
2189+ configSvc := inmocks.NewMockConfigService(t)
2190+ authSvc := inmocks.NewMockAuthService(t)
2191+ containerSvc := inmocks.NewMockContainerService(t)
2192+ secretSvc := inmocks.NewMockSecretService(t)
2193+ registrySvc := inmocks.NewMockRegistryService(t)
2194+
2195+ handler := newTestHandler(t, func(d *HandlerDeps) {
2196+ d.ConfigSvc = configSvc
2197+ d.AuthSvc = authSvc
2198+ d.ContainerSvc = containerSvc
2199+ d.SecretSvc = secretSvc
2200+ d.RegistrySvc = registrySvc
2201+ })
2202+
2203+ registrySvc.EXPECT().ListTags(mock.Anything, "repo/app").Return([]string{"latest"}, nil)
2204+
2205+ req := httptest.NewRequest("GET", "/admin/tags/repo%2Fapp", nil)
2206+ req = req.WithContext(ctxWithScopes("admin:status:read"))
2207+ rec := httptest.NewRecorder()
2208+
2209+ handler.ServeHTTP(rec, req)
2210+
2211+ assert.Equal(t, http.StatusOK, rec.Code)
2212+ assert.JSONEq(t, `{"repository":"repo/app","tags":["latest"]}`, rec.Body.String())
2213+}
2214+
20962215 func TestHandler_AttachmentSecretsPost_RequiresWriteScope(t *testing.T) {
20972216 configSvc := inmocks.NewMockConfigService(t)
20982217 authSvc := inmocks.NewMockAuthService(t)
@@ -29,6 +29,18 @@ type stubImageService struct {
29 pruneFunc func(context.Context, domain.ImagePruneOptions) (domain.ImagePruneReport, error)29 pruneFunc func(context.Context, domain.ImagePruneOptions) (domain.ImagePruneReport, error)
30 }30 }
31 31
32+type previewContainerService struct {
33+ *inmocks.MockContainerService
34+ preview func(context.Context, string) (*domain.CleanupReport, error)
35+}
36+
37+func (s *previewContainerService) PreviewRemovedRouteCleanup(ctx context.Context, routeDomain string) (*domain.CleanupReport, error) {
38+ if s.preview == nil {
39+ return nil, nil
40+ }
41+ return s.preview(ctx, routeDomain)
42+}
43+
32 type noopReloadTrigger struct{}44 type noopReloadTrigger struct{}
33 45
34 func (noopReloadTrigger) Trigger(context.Context) error { return nil }46 func (noopReloadTrigger) Trigger(context.Context) error { return nil }
@@ -687,6 +699,34 @@ func TestHandler_AttachmentsByImageGet_RequiresReadScope(t *testing.T) {
687 assert.JSONEq(t, `{"error":"insufficient permissions for config:read"}`, rec.Body.String())699 assert.JSONEq(t, `{"error":"insufficient permissions for config:read"}`, rec.Body.String())
688 }700 }
689 701
702+func TestHandler_RoutesByImageGet_URLDecodedImage(t *testing.T) {
703+ configSvc := inmocks.NewMockConfigService(t)
704+ authSvc := inmocks.NewMockAuthService(t)
705+ containerSvc := inmocks.NewMockContainerService(t)
706+ secretSvc := inmocks.NewMockSecretService(t)
707+
708+ handler := newTestHandler(t, func(d *HandlerDeps) {
709+ d.ConfigSvc = configSvc
710+ d.AuthSvc = authSvc
711+ d.ContainerSvc = containerSvc
712+ d.SecretSvc = secretSvc
713+ })
714+
715+ configSvc.EXPECT().FindRoutesByImage(mock.Anything, "registry/org/image:tag").Return([]domain.Route{{
716+ Domain: "app.example.com",
717+ Image: "registry/org/image:tag",
718+ }}).Once()
719+
720+ req := httptest.NewRequest("GET", "/admin/routes/by-image/registry%2Forg%2Fimage:tag", nil)
721+ req = req.WithContext(ctxWithScopes("admin:routes:read"))
722+ rec := httptest.NewRecorder()
723+
724+ handler.ServeHTTP(rec, req)
725+
726+ assert.Equal(t, http.StatusOK, rec.Code)
727+ assert.JSONEq(t, `{"image":"registry/org/image:tag","routes":[{"domain":"app.example.com","image":"registry/org/image:tag","https":false}]}`, rec.Body.String())
728+}
729+
690 func TestHandler_RoutesDelete_RequiresWriteScope(t *testing.T) {730 func TestHandler_RoutesDelete_RequiresWriteScope(t *testing.T) {
691 configSvc := inmocks.NewMockConfigService(t)731 configSvc := inmocks.NewMockConfigService(t)
692 authSvc := inmocks.NewMockAuthService(t)732 authSvc := inmocks.NewMockAuthService(t)
@@ -1566,6 +1606,58 @@ func TestHandler_RoutesGet_NotFound(t *testing.T) {
1566 assert.Equal(t, http.StatusNotFound, rec.Code)1606 assert.Equal(t, http.StatusNotFound, rec.Code)
1567 }1607 }
1568 1608
1609+func TestHandler_RouteCleanupPreview_RequiresRoutesConfigAndVolumesReadScopes(t *testing.T) {
1610+ configSvc := inmocks.NewMockConfigService(t)
1611+ authSvc := inmocks.NewMockAuthService(t)
1612+ volumeSvc := inmocks.NewMockVolumeService(t)
1613+ secretSvc := inmocks.NewMockSecretService(t)
1614+ containerMock := inmocks.NewMockContainerService(t)
1615+ containerSvc := &previewContainerService{
1616+ MockContainerService: containerMock,
1617+ preview: func(_ context.Context, routeDomain string) (*domain.CleanupReport, error) {
1618+ return &domain.CleanupReport{
1619+ Domain: routeDomain,
1620+ OrphanedEntities: []domain.CleanupOrphanedEntity{{Kind: "route_container", ID: "ctr-1", Status: "running"}},
1621+ }, nil
1622+ },
1623+ }
1624+
1625+ handler := newTestHandler(t, func(d *HandlerDeps) {
1626+ d.ConfigSvc = configSvc
1627+ d.AuthSvc = authSvc
1628+ d.ContainerSvc = containerSvc
1629+ d.VolumeSvc = volumeSvc
1630+ d.SecretSvc = secretSvc
1631+ })
1632+
1633+ volumeSvc.EXPECT().ListVolumes(mock.Anything).Return([]*domain.VolumeInfo{{Name: "gordon-app-example-com-data"}}, nil).Once()
1634+
1635+ req := httptest.NewRequest("GET", "/admin/routes/app.example.com/cleanup", nil)
1636+ req = req.WithContext(ctxWithScopes("admin:routes:read", "admin:config:read", "admin:volumes:read"))
1637+ rec := httptest.NewRecorder()
1638+
1639+ handler.ServeHTTP(rec, req)
1640+
1641+ assert.Equal(t, http.StatusOK, rec.Code)
1642+ var report dto.CleanupReport
1643+ require.NoError(t, json.NewDecoder(rec.Body).Decode(&report))
1644+ require.Len(t, report.OrphanedEntities, 1)
1645+ assert.Equal(t, "route_container", report.OrphanedEntities[0].Kind)
1646+ require.Len(t, report.PreservedVolumes, 1)
1647+ assert.Equal(t, "gordon-app-example-com-data", report.PreservedVolumes[0].Name)
1648+
1649+ for _, scopes := range [][]string{
1650+ {"admin:routes:read", "admin:volumes:read"},
1651+ {"admin:routes:read", "admin:config:read"},
1652+ } {
1653+ req := httptest.NewRequest("GET", "/admin/routes/app.example.com/cleanup", nil)
1654+ req = req.WithContext(ctxWithScopes(scopes...))
1655+ rec := httptest.NewRecorder()
1656+ handler.ServeHTTP(rec, req)
1657+ assert.Equal(t, http.StatusForbidden, rec.Code)
1658+ }
1659+}
1660+
1569 func TestHandler_RoutesPost_MissingFields(t *testing.T) {1661 func TestHandler_RoutesPost_MissingFields(t *testing.T) {
1570 tests := []struct {1662 tests := []struct {
1571 name string1663 name string
@@ -2093,6 +2185,33 @@ func TestHandler_Tags_Error(t *testing.T) {
2093 assert.NotContains(t, rec.Body.String(), "registry error")2185 assert.NotContains(t, rec.Body.String(), "registry error")
2094 }2186 }
2095 2187
2188+func TestHandler_Tags_URLDecodedRepository(t *testing.T) {
2189+ configSvc := inmocks.NewMockConfigService(t)
2190+ authSvc := inmocks.NewMockAuthService(t)
2191+ containerSvc := inmocks.NewMockContainerService(t)
2192+ secretSvc := inmocks.NewMockSecretService(t)
2193+ registrySvc := inmocks.NewMockRegistryService(t)
2194+
2195+ handler := newTestHandler(t, func(d *HandlerDeps) {
2196+ d.ConfigSvc = configSvc
2197+ d.AuthSvc = authSvc
2198+ d.ContainerSvc = containerSvc
2199+ d.SecretSvc = secretSvc
2200+ d.RegistrySvc = registrySvc
2201+ })
2202+
2203+ registrySvc.EXPECT().ListTags(mock.Anything, "repo/app").Return([]string{"latest"}, nil)
2204+
2205+ req := httptest.NewRequest("GET", "/admin/tags/repo%2Fapp", nil)
2206+ req = req.WithContext(ctxWithScopes("admin:status:read"))
2207+ rec := httptest.NewRecorder()
2208+
2209+ handler.ServeHTTP(rec, req)
2210+
2211+ assert.Equal(t, http.StatusOK, rec.Code)
2212+ assert.JSONEq(t, `{"repository":"repo/app","tags":["latest"]}`, rec.Body.String())
2213+}
2214+
2096 func TestHandler_AttachmentSecretsPost_RequiresWriteScope(t *testing.T) {2215 func TestHandler_AttachmentSecretsPost_RequiresWriteScope(t *testing.T) {
2097 configSvc := inmocks.NewMockConfigService(t)2216 configSvc := inmocks.NewMockConfigService(t)
2098 authSvc := inmocks.NewMockAuthService(t)2217 authSvc := inmocks.NewMockAuthService(t)
M internal/app/run.go +50 -16
@@ -516,6 +516,7 @@ func createServicesWithOptions(ctx context.Context, v *viper.Viper, cfg Config,
516516 }
517517
518518 si.svc.reloadCoordinator = newReloadCoordinator(v, si.svc.configSvc, si.svc.proxySvc, nil, si.svc.eventBus, si.svc.publicTLSSvc, log)
519+ si.registerReloadCoordinatorHooks()
519520
520521 si.initHandlers()
521522
@@ -695,6 +696,21 @@ func (si *serviceInit) initRuntimeAndProxy() error {
695696 }
696697
697698 // initHandlers creates the auth, health, log, preview, and admin handlers.
699+func (si *serviceInit) registerReloadCoordinatorHooks() {
700+ if si.svc.reloadCoordinator == nil || si.svc.containerSvc == nil {
701+ return
702+ }
703+
704+ si.svc.reloadCoordinator.SetContainerConfigApplier(func(reloadCfg Config) error {
705+ containerCfg, err := buildContainerServiceConfig(si.ctx, si.v, reloadCfg, si.svc, si.log)
706+ if err != nil {
707+ return err
708+ }
709+ si.svc.containerSvc.UpdateConfig(containerCfg)
710+ return nil
711+ })
712+}
713+
698714 func (si *serviceInit) initHandlers() {
699715 if si.svc.authSvc != nil {
700716 internalAuth := authhandler.InternalAuth{
@@ -1608,10 +1624,11 @@ type reloadCoordinator struct {
16081624 lastRun time.Time
16091625 debounce time.Duration
16101626
1611- configSvc configReloader
1612- v *viper.Viper
1613- proxySvc proxyConfigUpdater
1614- registryLimits interface {
1627+ configSvc configReloader
1628+ v *viper.Viper
1629+ proxySvc proxyConfigUpdater
1630+ applyContainerConfig func(Config) error
1631+ registryLimits interface {
16151632 UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64)
16161633 }
16171634 eventBus out.EventPublisher
@@ -1642,6 +1659,12 @@ func (c *reloadCoordinator) SetRegistryLimits(limits interface {
16421659 c.registryLimits = limits
16431660 }
16441661
1662+func (c *reloadCoordinator) SetContainerConfigApplier(apply func(Config) error) {
1663+ c.mu.Lock()
1664+ defer c.mu.Unlock()
1665+ c.applyContainerConfig = apply
1666+}
1667+
16451668 func (c *reloadCoordinator) Trigger(ctx context.Context) error {
16461669 c.mu.Lock()
16471670 defer c.mu.Unlock()
@@ -1694,6 +1717,12 @@ func (c *reloadCoordinator) applyLoadedConfig(ctx context.Context, now time.Time
16941717 if c.registryLimits != nil {
16951718 c.registryLimits.UpdateBlobLimits(reloadedProxy.maxBlobChunkSize, reloadedProxy.maxBlobSize)
16961719 }
1720+ if c.applyContainerConfig != nil {
1721+ if err := c.applyContainerConfig(reloadCfg); err != nil {
1722+ c.log.Error().Err(err).Msg("failed to apply container config on reload")
1723+ return fmt.Errorf("failed to apply container config on reload: %w", err)
1724+ }
1725+ }
16971726
16981727 if c.eventBus != nil {
16991728 if err := c.eventBus.Publish(domain.EventConfigReload, nil); err != nil {
@@ -1813,23 +1842,21 @@ func buildDNSConfig(cfg Config) (publictls.DNSConfig, error) {
18131842 return dnsCfg, nil
18141843 }
18151844
1816-// createContainerService creates the container service with configuration.
1817-func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc *services, log zerowrap.Logger) (*container.Service, error) {
1818- // Parse and validate container resource limits from config
1845+func buildContainerServiceConfig(ctx context.Context, v *viper.Viper, cfg Config, svc *services, log zerowrap.Logger) (container.Config, error) {
18191846 if cfg.Containers.CPULimit < 0 {
1820- return nil, fmt.Errorf("containers.cpu_limit must be >= 0 (got %f)", cfg.Containers.CPULimit)
1847+ return container.Config{}, fmt.Errorf("containers.cpu_limit must be >= 0 (got %f)", cfg.Containers.CPULimit)
18211848 }
18221849 if cfg.Containers.PidsLimit < 0 {
1823- return nil, fmt.Errorf("containers.pids_limit must be >= 0 (got %d)", cfg.Containers.PidsLimit)
1850+ return container.Config{}, fmt.Errorf("containers.pids_limit must be >= 0 (got %d)", cfg.Containers.PidsLimit)
18241851 }
18251852 var defaultMemoryLimit int64
18261853 if cfg.Containers.MemoryLimit != "" {
18271854 parsed, err := bytesize.Parse(cfg.Containers.MemoryLimit)
18281855 if err != nil {
1829- return nil, fmt.Errorf("invalid containers.memory_limit %q: %w", cfg.Containers.MemoryLimit, err)
1856+ return container.Config{}, fmt.Errorf("invalid containers.memory_limit %q: %w", cfg.Containers.MemoryLimit, err)
18301857 }
18311858 if parsed <= 0 {
1832- return nil, fmt.Errorf("containers.memory_limit must be positive (got %q)", cfg.Containers.MemoryLimit)
1859+ return container.Config{}, fmt.Errorf("containers.memory_limit must be positive (got %q)", cfg.Containers.MemoryLimit)
18331860 }
18341861 defaultMemoryLimit = parsed
18351862 }
@@ -1881,17 +1908,15 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc
18811908
18821909 if containerConfig.RegistryAuthEnabled {
18831910 if svc.authSvc == nil {
1884- return nil, fmt.Errorf("authentication service unavailable: cannot generate registry service token")
1911+ return container.Config{}, fmt.Errorf("authentication service unavailable: cannot generate registry service token")
18851912 }
18861913 expiry, err := resolveServiceTokenExpiry(cfg)
18871914 if err != nil {
1888- return nil, log.WrapErr(err, "failed to resolve service token expiry")
1915+ return container.Config{}, log.WrapErr(err, "failed to resolve service token expiry")
18891916 }
1890- // Note: Service tokens are not auto-refreshed. If the token expires during
1891- // container runtime, the container will need to be recreated to get a new token.
18921917 serviceToken, err := svc.authSvc.GenerateToken(ctx, serviceTokenSubject, []string{"pull"}, expiry)
18931918 if err != nil {
1894- return nil, log.WrapErr(err, "failed to generate registry service token")
1919+ return container.Config{}, log.WrapErr(err, "failed to generate registry service token")
18951920 }
18961921 log.Info().
18971922 Str("subject", serviceTokenSubject).
@@ -1903,6 +1928,15 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc
19031928 log.Warn().Msg("registry auth disabled; container image pulls will use unauthenticated mode")
19041929 }
19051930
1931+ return containerConfig, nil
1932+}
1933+
1934+// createContainerService creates the container service with configuration.
1935+func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc *services, log zerowrap.Logger) (*container.Service, error) {
1936+ containerConfig, err := buildContainerServiceConfig(ctx, v, cfg, svc, log)
1937+ if err != nil {
1938+ return nil, err
1939+ }
19061940 return container.NewService(svc.runtime, svc.envLoader, svc.eventBus, svc.logWriter, containerConfig, svc.configSvc), nil
19071941 }
19081942
@@ -516,6 +516,7 @@ func createServicesWithOptions(ctx context.Context, v *viper.Viper, cfg Config,
516 }516 }
517 517
518 si.svc.reloadCoordinator = newReloadCoordinator(v, si.svc.configSvc, si.svc.proxySvc, nil, si.svc.eventBus, si.svc.publicTLSSvc, log)518 si.svc.reloadCoordinator = newReloadCoordinator(v, si.svc.configSvc, si.svc.proxySvc, nil, si.svc.eventBus, si.svc.publicTLSSvc, log)
519+ si.registerReloadCoordinatorHooks()
519 520
520 si.initHandlers()521 si.initHandlers()
521 522
@@ -695,6 +696,21 @@ func (si *serviceInit) initRuntimeAndProxy() error {
695 }696 }
696 697
697 // initHandlers creates the auth, health, log, preview, and admin handlers.698 // initHandlers creates the auth, health, log, preview, and admin handlers.
699+func (si *serviceInit) registerReloadCoordinatorHooks() {
700+ if si.svc.reloadCoordinator == nil || si.svc.containerSvc == nil {
701+ return
702+ }
703+
704+ si.svc.reloadCoordinator.SetContainerConfigApplier(func(reloadCfg Config) error {
705+ containerCfg, err := buildContainerServiceConfig(si.ctx, si.v, reloadCfg, si.svc, si.log)
706+ if err != nil {
707+ return err
708+ }
709+ si.svc.containerSvc.UpdateConfig(containerCfg)
710+ return nil
711+ })
712+}
713+
698 func (si *serviceInit) initHandlers() {714 func (si *serviceInit) initHandlers() {
699 if si.svc.authSvc != nil {715 if si.svc.authSvc != nil {
700 internalAuth := authhandler.InternalAuth{716 internalAuth := authhandler.InternalAuth{
@@ -1608,10 +1624,11 @@ type reloadCoordinator struct {
1608 lastRun time.Time1624 lastRun time.Time
1609 debounce time.Duration1625 debounce time.Duration
1610 1626
1611- configSvc configReloader1627+ configSvc configReloader
1612- v *viper.Viper1628+ v *viper.Viper
1613- proxySvc proxyConfigUpdater1629+ proxySvc proxyConfigUpdater
1614- registryLimits interface {1630+ applyContainerConfig func(Config) error
1631+ registryLimits interface {
1615 UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64)1632 UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64)
1616 }1633 }
1617 eventBus out.EventPublisher1634 eventBus out.EventPublisher
@@ -1642,6 +1659,12 @@ func (c *reloadCoordinator) SetRegistryLimits(limits interface {
1642 c.registryLimits = limits1659 c.registryLimits = limits
1643 }1660 }
1644 1661
1662+func (c *reloadCoordinator) SetContainerConfigApplier(apply func(Config) error) {
1663+ c.mu.Lock()
1664+ defer c.mu.Unlock()
1665+ c.applyContainerConfig = apply
1666+}
1667+
1645 func (c *reloadCoordinator) Trigger(ctx context.Context) error {1668 func (c *reloadCoordinator) Trigger(ctx context.Context) error {
1646 c.mu.Lock()1669 c.mu.Lock()
1647 defer c.mu.Unlock()1670 defer c.mu.Unlock()
@@ -1694,6 +1717,12 @@ func (c *reloadCoordinator) applyLoadedConfig(ctx context.Context, now time.Time
1694 if c.registryLimits != nil {1717 if c.registryLimits != nil {
1695 c.registryLimits.UpdateBlobLimits(reloadedProxy.maxBlobChunkSize, reloadedProxy.maxBlobSize)1718 c.registryLimits.UpdateBlobLimits(reloadedProxy.maxBlobChunkSize, reloadedProxy.maxBlobSize)
1696 }1719 }
1720+ if c.applyContainerConfig != nil {
1721+ if err := c.applyContainerConfig(reloadCfg); err != nil {
1722+ c.log.Error().Err(err).Msg("failed to apply container config on reload")
1723+ return fmt.Errorf("failed to apply container config on reload: %w", err)
1724+ }
1725+ }
1697 1726
1698 if c.eventBus != nil {1727 if c.eventBus != nil {
1699 if err := c.eventBus.Publish(domain.EventConfigReload, nil); err != nil {1728 if err := c.eventBus.Publish(domain.EventConfigReload, nil); err != nil {
@@ -1813,23 +1842,21 @@ func buildDNSConfig(cfg Config) (publictls.DNSConfig, error) {
1813 return dnsCfg, nil1842 return dnsCfg, nil
1814 }1843 }
1815 1844
1816-// createContainerService creates the container service with configuration.1845+func buildContainerServiceConfig(ctx context.Context, v *viper.Viper, cfg Config, svc *services, log zerowrap.Logger) (container.Config, error) {
1817-func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc *services, log zerowrap.Logger) (*container.Service, error) {
1818- // Parse and validate container resource limits from config
1819 if cfg.Containers.CPULimit < 0 {1846 if cfg.Containers.CPULimit < 0 {
1820- return nil, fmt.Errorf("containers.cpu_limit must be >= 0 (got %f)", cfg.Containers.CPULimit)1847+ return container.Config{}, fmt.Errorf("containers.cpu_limit must be >= 0 (got %f)", cfg.Containers.CPULimit)
1821 }1848 }
1822 if cfg.Containers.PidsLimit < 0 {1849 if cfg.Containers.PidsLimit < 0 {
1823- return nil, fmt.Errorf("containers.pids_limit must be >= 0 (got %d)", cfg.Containers.PidsLimit)1850+ return container.Config{}, fmt.Errorf("containers.pids_limit must be >= 0 (got %d)", cfg.Containers.PidsLimit)
1824 }1851 }
1825 var defaultMemoryLimit int641852 var defaultMemoryLimit int64
1826 if cfg.Containers.MemoryLimit != "" {1853 if cfg.Containers.MemoryLimit != "" {
1827 parsed, err := bytesize.Parse(cfg.Containers.MemoryLimit)1854 parsed, err := bytesize.Parse(cfg.Containers.MemoryLimit)
1828 if err != nil {1855 if err != nil {
1829- return nil, fmt.Errorf("invalid containers.memory_limit %q: %w", cfg.Containers.MemoryLimit, err)1856+ return container.Config{}, fmt.Errorf("invalid containers.memory_limit %q: %w", cfg.Containers.MemoryLimit, err)
1830 }1857 }
1831 if parsed <= 0 {1858 if parsed <= 0 {
1832- return nil, fmt.Errorf("containers.memory_limit must be positive (got %q)", cfg.Containers.MemoryLimit)1859+ return container.Config{}, fmt.Errorf("containers.memory_limit must be positive (got %q)", cfg.Containers.MemoryLimit)
1833 }1860 }
1834 defaultMemoryLimit = parsed1861 defaultMemoryLimit = parsed
1835 }1862 }
@@ -1881,17 +1908,15 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc
1881 1908
1882 if containerConfig.RegistryAuthEnabled {1909 if containerConfig.RegistryAuthEnabled {
1883 if svc.authSvc == nil {1910 if svc.authSvc == nil {
1884- return nil, fmt.Errorf("authentication service unavailable: cannot generate registry service token")1911+ return container.Config{}, fmt.Errorf("authentication service unavailable: cannot generate registry service token")
1885 }1912 }
1886 expiry, err := resolveServiceTokenExpiry(cfg)1913 expiry, err := resolveServiceTokenExpiry(cfg)
1887 if err != nil {1914 if err != nil {
1888- return nil, log.WrapErr(err, "failed to resolve service token expiry")1915+ return container.Config{}, log.WrapErr(err, "failed to resolve service token expiry")
1889 }1916 }
1890- // Note: Service tokens are not auto-refreshed. If the token expires during
1891- // container runtime, the container will need to be recreated to get a new token.
1892 serviceToken, err := svc.authSvc.GenerateToken(ctx, serviceTokenSubject, []string{"pull"}, expiry)1917 serviceToken, err := svc.authSvc.GenerateToken(ctx, serviceTokenSubject, []string{"pull"}, expiry)
1893 if err != nil {1918 if err != nil {
1894- return nil, log.WrapErr(err, "failed to generate registry service token")1919+ return container.Config{}, log.WrapErr(err, "failed to generate registry service token")
1895 }1920 }
1896 log.Info().1921 log.Info().
1897 Str("subject", serviceTokenSubject).1922 Str("subject", serviceTokenSubject).
@@ -1903,6 +1928,15 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc
1903 log.Warn().Msg("registry auth disabled; container image pulls will use unauthenticated mode")1928 log.Warn().Msg("registry auth disabled; container image pulls will use unauthenticated mode")
1904 }1929 }
1905 1930
1931+ return containerConfig, nil
1932+}
1933+
1934+// createContainerService creates the container service with configuration.
1935+func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc *services, log zerowrap.Logger) (*container.Service, error) {
1936+ containerConfig, err := buildContainerServiceConfig(ctx, v, cfg, svc, log)
1937+ if err != nil {
1938+ return nil, err
1939+ }
1906 return container.NewService(svc.runtime, svc.envLoader, svc.eventBus, svc.logWriter, containerConfig, svc.configSvc), nil1940 return container.NewService(svc.runtime, svc.envLoader, svc.eventBus, svc.logWriter, containerConfig, svc.configSvc), nil
1907 }1941 }
1908 1942
M internal/app/run_reload_test.go +54 -0
@@ -14,6 +14,8 @@ import (
1414
1515 "github.com/bnema/gordon/internal/adapters/in/http/registry"
1616 "github.com/bnema/gordon/internal/domain"
17+ cfgusecase "github.com/bnema/gordon/internal/usecase/config"
18+ "github.com/bnema/gordon/internal/usecase/container"
1719 "github.com/bnema/gordon/internal/usecase/proxy"
1820 )
1921
@@ -112,6 +114,17 @@ func (r *registryLimitsRecorder) UpdateBlobLimits(maxBlobChunkSize, maxBlobSize
112114 r.maxBlobSize = maxBlobSize
113115 }
114116
117+type containerConfigApplyRecorder struct {
118+ calls int
119+ cfg Config
120+}
121+
122+func (r *containerConfigApplyRecorder) Apply(cfg Config) error {
123+ r.calls++
124+ r.cfg = cfg
125+ return nil
126+}
127+
115128 type eventBusRecorder struct {
116129 mu sync.Mutex
117130 calls int
@@ -179,22 +192,30 @@ func TestReloadCoordinator_ApplyLoadedConfig_RebuildsProxyConfigAndPublishesEven
179192 v.Set("server.max_blob_size", "8MB")
180193 v.Set("server.max_proxy_response_size", "7MB")
181194 v.Set("server.max_concurrent_connections", 99)
195+ v.Set("server.legacy_registry_domains", []string{"old.example.com"})
196+ v.Set("images.allowed_registries", []string{"docker.io"})
182197
183198 reloadSvc := &reloadRecorder{}
184199 proxySvc := &proxyRecorder{}
185200 events := &eventBusRecorder{}
201+ containerCfg := &containerConfigApplyRecorder{}
186202
187203 registryLimits := &registryLimitsRecorder{}
188204 coord := newReloadCoordinator(v, reloadSvc, proxySvc, registryLimits, events, nil, zerowrap.Default())
205+ coord.SetContainerConfigApplier(containerCfg.Apply)
189206 require.NoError(t, coord.ApplyLoadedConfig(ctx))
190207
191208 require.Equal(t, 0, reloadSvc.Calls())
192209 require.Equal(t, 1, proxySvc.calls)
193210 require.Equal(t, 1, events.Calls())
194211 require.Equal(t, 1, registryLimits.calls)
212+ require.Equal(t, 1, containerCfg.calls)
195213 require.Equal(t, int64(6<<20), registryLimits.maxBlobChunkSize)
196214 require.Equal(t, int64(8<<20), registryLimits.maxBlobSize)
197215 require.Equal(t, domain.EventConfigReload, events.eventType)
216+ require.Equal(t, "reload.example.com", containerCfg.cfg.Server.GordonDomain)
217+ require.Equal(t, []string{"old.example.com"}, containerCfg.cfg.Server.LegacyRegistryDomains)
218+ require.Equal(t, []string{"docker.io"}, containerCfg.cfg.Images.AllowedRegistries)
198219 require.Equal(t, proxy.Config{
199220 RegistryDomain: "reload.example.com",
200221 RegistryPort: 5000,
@@ -204,6 +225,39 @@ func TestReloadCoordinator_ApplyLoadedConfig_RebuildsProxyConfigAndPublishesEven
204225 }, proxySvc.config)
205226 }
206227
228+func TestServiceInit_RegisterReloadCoordinatorHooks_WiresContainerConfigApplier(t *testing.T) {
229+ ctx := context.Background()
230+ v := viper.New()
231+ v.Set("server.gordon_domain", "reload.example.com")
232+ v.Set("server.registry_port", 5000)
233+
234+ configSvc := cfgusecase.NewService(v, nil)
235+ require.NoError(t, configSvc.Load(ctx))
236+
237+ si := &serviceInit{
238+ ctx: ctx,
239+ v: v,
240+ cfg: Config{},
241+ log: zerowrap.Default(),
242+ svc: &services{
243+ configSvc: configSvc,
244+ containerSvc: container.NewService(nil, nil, nil, nil, container.Config{}, configSvc),
245+ internalRegUser: "gordon",
246+ internalRegPass: "secret",
247+ reloadCoordinator: newReloadCoordinator(v, &reloadRecorder{}, &proxyRecorder{}, nil, nil, nil, zerowrap.Default()),
248+ },
249+ }
250+
251+ si.registerReloadCoordinatorHooks()
252+ require.NotNil(t, si.svc.reloadCoordinator.applyContainerConfig)
253+
254+ reloadCfg := Config{}
255+ reloadCfg.Server.GordonDomain = "reload.example.com"
256+ reloadCfg.Server.RegistryPort = 5000
257+ reloadCfg.Images.AllowedRegistries = []string{"docker.io"}
258+
259+ require.NoError(t, si.svc.reloadCoordinator.applyContainerConfig(reloadCfg))
260+}
207261 func TestReloadCoordinator_DebouncesRepeatedWatchCallbacks(t *testing.T) {
208262 ctx := context.Background()
209263 v := viper.New()
@@ -14,6 +14,8 @@ import (
14 14
15 "github.com/bnema/gordon/internal/adapters/in/http/registry"15 "github.com/bnema/gordon/internal/adapters/in/http/registry"
16 "github.com/bnema/gordon/internal/domain"16 "github.com/bnema/gordon/internal/domain"
17+ cfgusecase "github.com/bnema/gordon/internal/usecase/config"
18+ "github.com/bnema/gordon/internal/usecase/container"
17 "github.com/bnema/gordon/internal/usecase/proxy"19 "github.com/bnema/gordon/internal/usecase/proxy"
18 )20 )
19 21
@@ -112,6 +114,17 @@ func (r *registryLimitsRecorder) UpdateBlobLimits(maxBlobChunkSize, maxBlobSize
112 r.maxBlobSize = maxBlobSize114 r.maxBlobSize = maxBlobSize
113 }115 }
114 116
117+type containerConfigApplyRecorder struct {
118+ calls int
119+ cfg Config
120+}
121+
122+func (r *containerConfigApplyRecorder) Apply(cfg Config) error {
123+ r.calls++
124+ r.cfg = cfg
125+ return nil
126+}
127+
115 type eventBusRecorder struct {128 type eventBusRecorder struct {
116 mu sync.Mutex129 mu sync.Mutex
117 calls int130 calls int
@@ -179,22 +192,30 @@ func TestReloadCoordinator_ApplyLoadedConfig_RebuildsProxyConfigAndPublishesEven
179 v.Set("server.max_blob_size", "8MB")192 v.Set("server.max_blob_size", "8MB")
180 v.Set("server.max_proxy_response_size", "7MB")193 v.Set("server.max_proxy_response_size", "7MB")
181 v.Set("server.max_concurrent_connections", 99)194 v.Set("server.max_concurrent_connections", 99)
195+ v.Set("server.legacy_registry_domains", []string{"old.example.com"})
196+ v.Set("images.allowed_registries", []string{"docker.io"})
182 197
183 reloadSvc := &reloadRecorder{}198 reloadSvc := &reloadRecorder{}
184 proxySvc := &proxyRecorder{}199 proxySvc := &proxyRecorder{}
185 events := &eventBusRecorder{}200 events := &eventBusRecorder{}
201+ containerCfg := &containerConfigApplyRecorder{}
186 202
187 registryLimits := &registryLimitsRecorder{}203 registryLimits := &registryLimitsRecorder{}
188 coord := newReloadCoordinator(v, reloadSvc, proxySvc, registryLimits, events, nil, zerowrap.Default())204 coord := newReloadCoordinator(v, reloadSvc, proxySvc, registryLimits, events, nil, zerowrap.Default())
205+ coord.SetContainerConfigApplier(containerCfg.Apply)
189 require.NoError(t, coord.ApplyLoadedConfig(ctx))206 require.NoError(t, coord.ApplyLoadedConfig(ctx))
190 207
191 require.Equal(t, 0, reloadSvc.Calls())208 require.Equal(t, 0, reloadSvc.Calls())
192 require.Equal(t, 1, proxySvc.calls)209 require.Equal(t, 1, proxySvc.calls)
193 require.Equal(t, 1, events.Calls())210 require.Equal(t, 1, events.Calls())
194 require.Equal(t, 1, registryLimits.calls)211 require.Equal(t, 1, registryLimits.calls)
212+ require.Equal(t, 1, containerCfg.calls)
195 require.Equal(t, int64(6<<20), registryLimits.maxBlobChunkSize)213 require.Equal(t, int64(6<<20), registryLimits.maxBlobChunkSize)
196 require.Equal(t, int64(8<<20), registryLimits.maxBlobSize)214 require.Equal(t, int64(8<<20), registryLimits.maxBlobSize)
197 require.Equal(t, domain.EventConfigReload, events.eventType)215 require.Equal(t, domain.EventConfigReload, events.eventType)
216+ require.Equal(t, "reload.example.com", containerCfg.cfg.Server.GordonDomain)
217+ require.Equal(t, []string{"old.example.com"}, containerCfg.cfg.Server.LegacyRegistryDomains)
218+ require.Equal(t, []string{"docker.io"}, containerCfg.cfg.Images.AllowedRegistries)
198 require.Equal(t, proxy.Config{219 require.Equal(t, proxy.Config{
199 RegistryDomain: "reload.example.com",220 RegistryDomain: "reload.example.com",
200 RegistryPort: 5000,221 RegistryPort: 5000,
@@ -204,6 +225,39 @@ func TestReloadCoordinator_ApplyLoadedConfig_RebuildsProxyConfigAndPublishesEven
204 }, proxySvc.config)225 }, proxySvc.config)
205 }226 }
206 227
228+func TestServiceInit_RegisterReloadCoordinatorHooks_WiresContainerConfigApplier(t *testing.T) {
229+ ctx := context.Background()
230+ v := viper.New()
231+ v.Set("server.gordon_domain", "reload.example.com")
232+ v.Set("server.registry_port", 5000)
233+
234+ configSvc := cfgusecase.NewService(v, nil)
235+ require.NoError(t, configSvc.Load(ctx))
236+
237+ si := &serviceInit{
238+ ctx: ctx,
239+ v: v,
240+ cfg: Config{},
241+ log: zerowrap.Default(),
242+ svc: &services{
243+ configSvc: configSvc,
244+ containerSvc: container.NewService(nil, nil, nil, nil, container.Config{}, configSvc),
245+ internalRegUser: "gordon",
246+ internalRegPass: "secret",
247+ reloadCoordinator: newReloadCoordinator(v, &reloadRecorder{}, &proxyRecorder{}, nil, nil, nil, zerowrap.Default()),
248+ },
249+ }
250+
251+ si.registerReloadCoordinatorHooks()
252+ require.NotNil(t, si.svc.reloadCoordinator.applyContainerConfig)
253+
254+ reloadCfg := Config{}
255+ reloadCfg.Server.GordonDomain = "reload.example.com"
256+ reloadCfg.Server.RegistryPort = 5000
257+ reloadCfg.Images.AllowedRegistries = []string{"docker.io"}
258+
259+ require.NoError(t, si.svc.reloadCoordinator.applyContainerConfig(reloadCfg))
260+}
207 func TestReloadCoordinator_DebouncesRepeatedWatchCallbacks(t *testing.T) {261 func TestReloadCoordinator_DebouncesRepeatedWatchCallbacks(t *testing.T) {
208 ctx := context.Background()262 ctx := context.Background()
209 v := viper.New()263 v := viper.New()
M internal/usecase/container/service.go +60 -8
@@ -1159,6 +1159,53 @@ func (s *Service) Stop(ctx context.Context, containerID string) error {
11591159 return nil
11601160 }
11611161
1162+// PreviewRemovedRouteCleanup reports retained runtime state for a route that is
1163+// no longer configured, without mutating containers or in-memory tracking.
1164+func (s *Service) PreviewRemovedRouteCleanup(ctx context.Context, domainName string) (*domain.CleanupReport, error) {
1165+ canonicalDomain, ok := domain.CanonicalRouteDomain(domainName)
1166+ if !ok {
1167+ return &domain.CleanupReport{Domain: domainName}, domain.ErrRouteDomainInvalid
1168+ }
1169+ domainName = canonicalDomain
1170+
1171+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1172+ zerowrap.FieldLayer: "usecase",
1173+ zerowrap.FieldUseCase: "PreviewRemovedRouteCleanup",
1174+ "domain": domainName,
1175+ })
1176+ log := zerowrap.FromCtx(ctx)
1177+
1178+ report := &domain.CleanupReport{Domain: domainName}
1179+
1180+ unlock, err := s.acquireDomainDeployLock(ctx, domainName)
1181+ if err != nil {
1182+ return report, err
1183+ }
1184+ defer unlock()
1185+
1186+ allContainers, err := s.runtime.ListContainers(ctx, true)
1187+ if err != nil {
1188+ return report, log.WrapErr(err, "failed to list containers for removed route cleanup preview")
1189+ }
1190+
1191+ routeContainers := routeContainersForDomain(allContainers, domainName)
1192+ report.PreservedAttachments = preservedAttachmentsForDomain(allContainers, domainName)
1193+ if len(report.PreservedAttachments) > 0 {
1194+ report.Hints = append(report.Hints, "attachments preserved; remove or purge attachments explicitly if they are no longer needed")
1195+ }
1196+ for _, c := range routeContainers {
1197+ report.OrphanedEntities = append(report.OrphanedEntities, domain.CleanupOrphanedEntity{
1198+ Kind: "route_container",
1199+ ID: c.ID,
1200+ Name: c.Name,
1201+ Status: c.Status,
1202+ Reason: "route runtime state still exists after route removal",
1203+ })
1204+ }
1205+
1206+ return report, nil
1207+}
1208+
11621209 // ReconcileRemovedRoute removes active runtime containers for a route that was
11631210 // removed from configuration while preserving stateful resources for explicit
11641211 // follow-up cleanup. It intentionally removes only main route containers, never
@@ -1510,7 +1557,7 @@ func (s *Service) ListAttachments(ctx context.Context, domainName string) []doma
15101557 return s.getAttachmentsForDomain(ctx, domainName)
15111558 }
15121559
1513-// ListOrphanedAttachments returns running attachment containers no longer configured.
1560+// ListOrphanedAttachments returns managed attachment containers no longer configured.
15141561 func (s *Service) ListOrphanedAttachments(ctx context.Context) ([]domain.CleanupAttachment, error) {
15151562 containers, err := s.runtime.ListContainers(ctx, true)
15161563 if err != nil {
@@ -1611,7 +1658,7 @@ func cleanupAttachmentsFromContainers(containers []*domain.Container) []domain.C
16111658 }
16121659
16131660 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"
1661+ return c != nil && c.Labels != nil && c.Labels[domain.LabelManaged] == "true" && c.Labels[domain.LabelAttachment] == "true"
16151662 }
16161663
16171664 func attachmentImage(c *domain.Container) string {
@@ -1694,6 +1741,10 @@ func (s *Service) HealthCheck(ctx context.Context) map[string]bool {
16941741 }
16951742
16961743 // SyncContainers synchronizes containers with runtime state.
1744+func isTrackedManagedContainerStatus(status string) bool {
1745+ return status == "" || status == string(domain.ContainerStatusRunning) || status == "restarting"
1746+}
1747+
16971748 func (s *Service) SyncContainers(ctx context.Context) error {
16981749 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
16991750 zerowrap.FieldLayer: "usecase",
@@ -1712,7 +1763,7 @@ func (s *Service) SyncContainers(ctx context.Context) error {
17121763 managed := make(map[string]*domain.Container)
17131764 attachments := make(map[string][]string)
17141765 for _, c := range allContainers {
1715- if c.Status != "" && c.Status != string(domain.ContainerStatusRunning) {
1766+ if !isTrackedManagedContainerStatus(c.Status) {
17161767 continue
17171768 }
17181769 if c.Labels == nil || c.Labels[domain.LabelManaged] != "true" {
@@ -2580,15 +2631,16 @@ func (s *Service) cleanupOrphanedContainers(ctx context.Context, domainName stri
25802631
25812632 for _, c := range allContainers {
25822633 if (c.Name == expectedName || c.Name == expectedNewName || c.Name == expectedNextName) && c.ID != skipContainerID {
2583- // Skip any running container regardless of name — a running
2584- // temp container may be actively serving traffic while the
2585- // new container stabilizes, or may be from a concurrent deploy.
2586- if c.Status == "running" {
2634+ // Skip any container that is still active in the runtime, including
2635+ // restart backoff, regardless of name — a temp container may be
2636+ // serving traffic while the new container stabilizes, or may be from
2637+ // a concurrent deploy.
2638+ if c.Status == "running" || c.Status == "restarting" {
25872639 log.Debug().
25882640 Str(zerowrap.FieldEntityID, c.ID).
25892641 Str("container_name", c.Name).
25902642 Str(zerowrap.FieldStatus, c.Status).
2591- Msg("skipping running container during orphan cleanup")
2643+ Msg("skipping active container during orphan cleanup")
25922644 continue
25932645 }
25942646
@@ -1159,6 +1159,53 @@ func (s *Service) Stop(ctx context.Context, containerID string) error {
1159 return nil1159 return nil
1160 }1160 }
1161 1161
1162+// PreviewRemovedRouteCleanup reports retained runtime state for a route that is
1163+// no longer configured, without mutating containers or in-memory tracking.
1164+func (s *Service) PreviewRemovedRouteCleanup(ctx context.Context, domainName string) (*domain.CleanupReport, error) {
1165+ canonicalDomain, ok := domain.CanonicalRouteDomain(domainName)
1166+ if !ok {
1167+ return &domain.CleanupReport{Domain: domainName}, domain.ErrRouteDomainInvalid
1168+ }
1169+ domainName = canonicalDomain
1170+
1171+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1172+ zerowrap.FieldLayer: "usecase",
1173+ zerowrap.FieldUseCase: "PreviewRemovedRouteCleanup",
1174+ "domain": domainName,
1175+ })
1176+ log := zerowrap.FromCtx(ctx)
1177+
1178+ report := &domain.CleanupReport{Domain: domainName}
1179+
1180+ unlock, err := s.acquireDomainDeployLock(ctx, domainName)
1181+ if err != nil {
1182+ return report, err
1183+ }
1184+ defer unlock()
1185+
1186+ allContainers, err := s.runtime.ListContainers(ctx, true)
1187+ if err != nil {
1188+ return report, log.WrapErr(err, "failed to list containers for removed route cleanup preview")
1189+ }
1190+
1191+ routeContainers := routeContainersForDomain(allContainers, domainName)
1192+ report.PreservedAttachments = preservedAttachmentsForDomain(allContainers, domainName)
1193+ if len(report.PreservedAttachments) > 0 {
1194+ report.Hints = append(report.Hints, "attachments preserved; remove or purge attachments explicitly if they are no longer needed")
1195+ }
1196+ for _, c := range routeContainers {
1197+ report.OrphanedEntities = append(report.OrphanedEntities, domain.CleanupOrphanedEntity{
1198+ Kind: "route_container",
1199+ ID: c.ID,
1200+ Name: c.Name,
1201+ Status: c.Status,
1202+ Reason: "route runtime state still exists after route removal",
1203+ })
1204+ }
1205+
1206+ return report, nil
1207+}
1208+
1162 // ReconcileRemovedRoute removes active runtime containers for a route that was1209 // ReconcileRemovedRoute removes active runtime containers for a route that was
1163 // removed from configuration while preserving stateful resources for explicit1210 // removed from configuration while preserving stateful resources for explicit
1164 // follow-up cleanup. It intentionally removes only main route containers, never1211 // follow-up cleanup. It intentionally removes only main route containers, never
@@ -1510,7 +1557,7 @@ func (s *Service) ListAttachments(ctx context.Context, domainName string) []doma
1510 return s.getAttachmentsForDomain(ctx, domainName)1557 return s.getAttachmentsForDomain(ctx, domainName)
1511 }1558 }
1512 1559
1513-// ListOrphanedAttachments returns running attachment containers no longer configured.1560+// ListOrphanedAttachments returns managed attachment containers no longer configured.
1514 func (s *Service) ListOrphanedAttachments(ctx context.Context) ([]domain.CleanupAttachment, error) {1561 func (s *Service) ListOrphanedAttachments(ctx context.Context) ([]domain.CleanupAttachment, error) {
1515 containers, err := s.runtime.ListContainers(ctx, true)1562 containers, err := s.runtime.ListContainers(ctx, true)
1516 if err != nil {1563 if err != nil {
@@ -1611,7 +1658,7 @@ func cleanupAttachmentsFromContainers(containers []*domain.Container) []domain.C
1611 }1658 }
1612 1659
1613 func isManagedAttachment(c *domain.Container) bool {1660 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"1661+ return c != nil && c.Labels != nil && c.Labels[domain.LabelManaged] == "true" && c.Labels[domain.LabelAttachment] == "true"
1615 }1662 }
1616 1663
1617 func attachmentImage(c *domain.Container) string {1664 func attachmentImage(c *domain.Container) string {
@@ -1694,6 +1741,10 @@ func (s *Service) HealthCheck(ctx context.Context) map[string]bool {
1694 }1741 }
1695 1742
1696 // SyncContainers synchronizes containers with runtime state.1743 // SyncContainers synchronizes containers with runtime state.
1744+func isTrackedManagedContainerStatus(status string) bool {
1745+ return status == "" || status == string(domain.ContainerStatusRunning) || status == "restarting"
1746+}
1747+
1697 func (s *Service) SyncContainers(ctx context.Context) error {1748 func (s *Service) SyncContainers(ctx context.Context) error {
1698 ctx = zerowrap.CtxWithFields(ctx, map[string]any{1749 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1699 zerowrap.FieldLayer: "usecase",1750 zerowrap.FieldLayer: "usecase",
@@ -1712,7 +1763,7 @@ func (s *Service) SyncContainers(ctx context.Context) error {
1712 managed := make(map[string]*domain.Container)1763 managed := make(map[string]*domain.Container)
1713 attachments := make(map[string][]string)1764 attachments := make(map[string][]string)
1714 for _, c := range allContainers {1765 for _, c := range allContainers {
1715- if c.Status != "" && c.Status != string(domain.ContainerStatusRunning) {1766+ if !isTrackedManagedContainerStatus(c.Status) {
1716 continue1767 continue
1717 }1768 }
1718 if c.Labels == nil || c.Labels[domain.LabelManaged] != "true" {1769 if c.Labels == nil || c.Labels[domain.LabelManaged] != "true" {
@@ -2580,15 +2631,16 @@ func (s *Service) cleanupOrphanedContainers(ctx context.Context, domainName stri
2580 2631
2581 for _, c := range allContainers {2632 for _, c := range allContainers {
2582 if (c.Name == expectedName || c.Name == expectedNewName || c.Name == expectedNextName) && c.ID != skipContainerID {2633 if (c.Name == expectedName || c.Name == expectedNewName || c.Name == expectedNextName) && c.ID != skipContainerID {
2583- // Skip any running container regardless of name — a running2634+ // Skip any container that is still active in the runtime, including
2584- // temp container may be actively serving traffic while the2635+ // restart backoff, regardless of name — a temp container may be
2585- // new container stabilizes, or may be from a concurrent deploy.2636+ // serving traffic while the new container stabilizes, or may be from
2586- if c.Status == "running" {2637+ // a concurrent deploy.
2638+ if c.Status == "running" || c.Status == "restarting" {
2587 log.Debug().2639 log.Debug().
2588 Str(zerowrap.FieldEntityID, c.ID).2640 Str(zerowrap.FieldEntityID, c.ID).
2589 Str("container_name", c.Name).2641 Str("container_name", c.Name).
2590 Str(zerowrap.FieldStatus, c.Status).2642 Str(zerowrap.FieldStatus, c.Status).
2591- Msg("skipping running container during orphan cleanup")2643+ Msg("skipping active container during orphan cleanup")
2592 continue2644 continue
2593 }2645 }
2594 2646
M internal/usecase/container/service_test.go +124 -10
@@ -1277,6 +1277,33 @@ func TestService_SyncContainers(t *testing.T) {
12771277 assert.Contains(t, svc.containers, "app2.example.com")
12781278 }
12791279
1280+func TestService_SyncContainers_KeepsRestartingManagedContainers(t *testing.T) {
1281+ runtime := mocks.NewMockContainerRuntime(t)
1282+ envLoader := mocks.NewMockEnvLoader(t)
1283+ eventBus := mocks.NewMockEventPublisher(t)
1284+
1285+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
1286+ ctx := testContext()
1287+
1288+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
1289+ {
1290+ ID: "container-1",
1291+ Name: "gordon-app1.example.com",
1292+ Status: "restarting",
1293+ Labels: map[string]string{
1294+ "gordon.domain": "app1.example.com",
1295+ "gordon.managed": "true",
1296+ },
1297+ },
1298+ }, nil)
1299+
1300+ err := svc.SyncContainers(ctx)
1301+
1302+ assert.NoError(t, err)
1303+ assert.Len(t, svc.containers, 1)
1304+ assert.Contains(t, svc.containers, "app1.example.com")
1305+}
1306+
12801307 func TestService_Shutdown(t *testing.T) {
12811308 runtime := mocks.NewMockContainerRuntime(t)
12821309 envLoader := mocks.NewMockEnvLoader(t)
@@ -3566,6 +3593,27 @@ func TestService_Deploy_OrphanCleanup_NeverKillsRunningCanonical(t *testing.T) {
35663593 assert.Equal(t, "new-container", tracked.ID)
35673594 }
35683595
3596+func TestService_CleanupOrphanedContainers_SkipsRestartingContainer(t *testing.T) {
3597+ runtime := mocks.NewMockContainerRuntime(t)
3598+ envLoader := mocks.NewMockEnvLoader(t)
3599+ eventBus := mocks.NewMockEventPublisher(t)
3600+
3601+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
3602+ ctx := testContext()
3603+
3604+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
3605+ {
3606+ ID: "restarting-container",
3607+ Name: "gordon-test.example.com",
3608+ Status: "restarting",
3609+ },
3610+ }, nil)
3611+
3612+ err := svc.cleanupOrphanedContainers(ctx, "test.example.com", "")
3613+
3614+ assert.NoError(t, err)
3615+}
3616+
35693617 // TestService_Deploy_RollbackOnPostSwitchCrash verifies that if the new container crashes
35703618 // during the post-switch stabilization window, traffic is automatically rolled back to the
35713619 // old container: the old container is restored as tracked, the proxy cache is re-invalidated,
@@ -4491,6 +4539,55 @@ func TestService_ReconcileRemovedRoute_IsIdempotentWhenNoContainerExists(t *test
44914539 assert.Contains(t, report.Warnings, "no active route container found for removed route")
44924540 }
44934541
4542+func TestService_PreviewRemovedRouteCleanup_ReportsRuntimeStateWithoutMutation(t *testing.T) {
4543+ ctx := testContext()
4544+ runtime := mocks.NewMockContainerRuntime(t)
4545+ svc := NewService(runtime, nil, mocks.NewMockEventPublisher(t), nil, Config{}, nil)
4546+
4547+ mainContainer := &domain.Container{
4548+ ID: "route-container",
4549+ Name: "gordon-app.example.com",
4550+ Status: string(domain.ContainerStatusRunning),
4551+ Labels: map[string]string{
4552+ domain.LabelManaged: "true",
4553+ domain.LabelRoute: "app.example.com",
4554+ domain.LabelDomain: "app.example.com",
4555+ },
4556+ }
4557+ attachment := &domain.Container{
4558+ ID: "attachment-container",
4559+ Name: "gordon-app-example-com-postgres",
4560+ Status: string(domain.ContainerStatusRunning),
4561+ Labels: map[string]string{
4562+ domain.LabelManaged: "true",
4563+ domain.LabelAttachment: "true",
4564+ domain.LabelAttachedTo: "app.example.com",
4565+ },
4566+ }
4567+
4568+ svc.mu.Lock()
4569+ svc.containers["app.example.com"] = mainContainer
4570+ svc.attachments["app.example.com"] = []string{attachment.ID}
4571+ svc.managedCount = 1
4572+ svc.mu.Unlock()
4573+
4574+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{mainContainer, attachment}, nil).Once()
4575+
4576+ report, err := svc.PreviewRemovedRouteCleanup(ctx, "app.example.com")
4577+ require.NoError(t, err)
4578+ require.NotNil(t, report)
4579+
4580+ require.Len(t, report.OrphanedEntities, 1)
4581+ assert.Equal(t, "route_container", report.OrphanedEntities[0].Kind)
4582+ assert.Equal(t, mainContainer.ID, report.OrphanedEntities[0].ID)
4583+ require.Len(t, report.PreservedAttachments, 1)
4584+ assert.Equal(t, attachment.ID, report.PreservedAttachments[0].ContainerID)
4585+ assert.Contains(t, report.Hints, "attachments preserved; remove or purge attachments explicitly if they are no longer needed")
4586+
4587+ _, exists := svc.Get(ctx, "app.example.com")
4588+ assert.True(t, exists, "preview should not clear route tracking")
4589+}
4590+
44944591 func TestService_ReconcileRemovedRoute_CanonicalizesDomainBeforeCleanup(t *testing.T) {
44954592 ctx := testContext()
44964593 runtime := mocks.NewMockContainerRuntime(t)
@@ -4659,7 +4756,7 @@ func TestService_CleanupOrphanedAttachments_StopRemovesContainerButPreservesData
46594756 runtime := mocks.NewMockContainerRuntime(t)
46604757 logWriter := mocks.NewMockContainerLogWriter(t)
46614758 svc := NewService(runtime, nil, mocks.NewMockEventPublisher(t), logWriter, Config{}, nil)
4662- orphan := &domain.Container{
4759+ running := &domain.Container{
46634760 ID: "postgres-1",
46644761 Name: "gordon-app-example-com-postgres",
46654762 Image: "postgres:16",
@@ -4671,20 +4768,35 @@ func TestService_CleanupOrphanedAttachments_StopRemovesContainerButPreservesData
46714768 domain.LabelImage: "postgres:16",
46724769 },
46734770 }
4771+ stopped := &domain.Container{
4772+ ID: "redis-1",
4773+ Name: "gordon-app-example-com-redis",
4774+ Image: "redis:7",
4775+ Status: string(domain.ContainerStatusExited),
4776+ Labels: map[string]string{
4777+ domain.LabelManaged: "true",
4778+ domain.LabelAttachment: "true",
4779+ domain.LabelAttachedTo: "app.example.com",
4780+ domain.LabelImage: "redis:7",
4781+ },
4782+ }
46744783 svc.mu.Lock()
4675- svc.attachments["app.example.com"] = []string{orphan.ID}
4784+ svc.attachments["app.example.com"] = []string{running.ID, stopped.ID}
46764785 svc.mu.Unlock()
46774786
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()
4787+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{running, stopped}, nil).Once()
4788+ logWriter.EXPECT().StopLogging(running.ID).Return(nil).Once()
4789+ runtime.EXPECT().StopContainer(mock.Anything, running.ID).Return(nil).Once()
4790+ runtime.EXPECT().RemoveContainer(mock.Anything, running.ID, true).Return(nil).Once()
4791+ logWriter.EXPECT().StopLogging(stopped.ID).Return(nil).Once()
4792+ runtime.EXPECT().RemoveContainer(mock.Anything, stopped.ID, true).Return(nil).Once()
46824793
46834794 report, err := svc.CleanupOrphanedAttachments(ctx, "", true)
46844795 require.NoError(t, err)
46854796 require.NotNil(t, report)
4686- require.Len(t, report.RemovedContainers, 1)
4687- assert.Equal(t, orphan.ID, report.RemovedContainers[0].ID)
4797+ require.Len(t, report.RemovedContainers, 2)
4798+ assert.Equal(t, running.ID, report.RemovedContainers[0].ID)
4799+ assert.Equal(t, stopped.ID, report.RemovedContainers[1].ID)
46884800 assert.Contains(t, report.Hints, "attachment volumes and data were preserved")
46894801
46904802 svc.mu.RLock()
@@ -4693,7 +4805,7 @@ func TestService_CleanupOrphanedAttachments_StopRemovesContainerButPreservesData
46934805 assert.Empty(t, ids)
46944806 }
46954807
4696-func TestService_ListOrphanedAttachments_IgnoresStoppedAttachments(t *testing.T) {
4808+func TestService_ListOrphanedAttachments_IncludesStoppedAttachments(t *testing.T) {
46974809 ctx := testContext()
46984810 runtime := mocks.NewMockContainerRuntime(t)
46994811 svc := NewService(runtime, nil, mocks.NewMockEventPublisher(t), nil, Config{}, nil)
@@ -4713,7 +4825,9 @@ func TestService_ListOrphanedAttachments_IgnoresStoppedAttachments(t *testing.T)
47134825
47144826 orphans, err := svc.ListOrphanedAttachments(ctx)
47154827 require.NoError(t, err)
4716- assert.Empty(t, orphans)
4828+ require.Len(t, orphans, 1)
4829+ assert.Equal(t, stopped.ID, orphans[0].ContainerID)
4830+ assert.Equal(t, string(domain.ContainerStatusExited), orphans[0].Status)
47174831 }
47184832
47194833 func TestService_ListOrphanedAttachments_IncludesOwnerAndHonorsGroupConfig(t *testing.T) {
@@ -1277,6 +1277,33 @@ func TestService_SyncContainers(t *testing.T) {
1277 assert.Contains(t, svc.containers, "app2.example.com")1277 assert.Contains(t, svc.containers, "app2.example.com")
1278 }1278 }
1279 1279
1280+func TestService_SyncContainers_KeepsRestartingManagedContainers(t *testing.T) {
1281+ runtime := mocks.NewMockContainerRuntime(t)
1282+ envLoader := mocks.NewMockEnvLoader(t)
1283+ eventBus := mocks.NewMockEventPublisher(t)
1284+
1285+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
1286+ ctx := testContext()
1287+
1288+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
1289+ {
1290+ ID: "container-1",
1291+ Name: "gordon-app1.example.com",
1292+ Status: "restarting",
1293+ Labels: map[string]string{
1294+ "gordon.domain": "app1.example.com",
1295+ "gordon.managed": "true",
1296+ },
1297+ },
1298+ }, nil)
1299+
1300+ err := svc.SyncContainers(ctx)
1301+
1302+ assert.NoError(t, err)
1303+ assert.Len(t, svc.containers, 1)
1304+ assert.Contains(t, svc.containers, "app1.example.com")
1305+}
1306+
1280 func TestService_Shutdown(t *testing.T) {1307 func TestService_Shutdown(t *testing.T) {
1281 runtime := mocks.NewMockContainerRuntime(t)1308 runtime := mocks.NewMockContainerRuntime(t)
1282 envLoader := mocks.NewMockEnvLoader(t)1309 envLoader := mocks.NewMockEnvLoader(t)
@@ -3566,6 +3593,27 @@ func TestService_Deploy_OrphanCleanup_NeverKillsRunningCanonical(t *testing.T) {
3566 assert.Equal(t, "new-container", tracked.ID)3593 assert.Equal(t, "new-container", tracked.ID)
3567 }3594 }
3568 3595
3596+func TestService_CleanupOrphanedContainers_SkipsRestartingContainer(t *testing.T) {
3597+ runtime := mocks.NewMockContainerRuntime(t)
3598+ envLoader := mocks.NewMockEnvLoader(t)
3599+ eventBus := mocks.NewMockEventPublisher(t)
3600+
3601+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
3602+ ctx := testContext()
3603+
3604+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
3605+ {
3606+ ID: "restarting-container",
3607+ Name: "gordon-test.example.com",
3608+ Status: "restarting",
3609+ },
3610+ }, nil)
3611+
3612+ err := svc.cleanupOrphanedContainers(ctx, "test.example.com", "")
3613+
3614+ assert.NoError(t, err)
3615+}
3616+
3569 // TestService_Deploy_RollbackOnPostSwitchCrash verifies that if the new container crashes3617 // TestService_Deploy_RollbackOnPostSwitchCrash verifies that if the new container crashes
3570 // during the post-switch stabilization window, traffic is automatically rolled back to the3618 // during the post-switch stabilization window, traffic is automatically rolled back to the
3571 // old container: the old container is restored as tracked, the proxy cache is re-invalidated,3619 // old container: the old container is restored as tracked, the proxy cache is re-invalidated,
@@ -4491,6 +4539,55 @@ func TestService_ReconcileRemovedRoute_IsIdempotentWhenNoContainerExists(t *test
4491 assert.Contains(t, report.Warnings, "no active route container found for removed route")4539 assert.Contains(t, report.Warnings, "no active route container found for removed route")
4492 }4540 }
4493 4541
4542+func TestService_PreviewRemovedRouteCleanup_ReportsRuntimeStateWithoutMutation(t *testing.T) {
4543+ ctx := testContext()
4544+ runtime := mocks.NewMockContainerRuntime(t)
4545+ svc := NewService(runtime, nil, mocks.NewMockEventPublisher(t), nil, Config{}, nil)
4546+
4547+ mainContainer := &domain.Container{
4548+ ID: "route-container",
4549+ Name: "gordon-app.example.com",
4550+ Status: string(domain.ContainerStatusRunning),
4551+ Labels: map[string]string{
4552+ domain.LabelManaged: "true",
4553+ domain.LabelRoute: "app.example.com",
4554+ domain.LabelDomain: "app.example.com",
4555+ },
4556+ }
4557+ attachment := &domain.Container{
4558+ ID: "attachment-container",
4559+ Name: "gordon-app-example-com-postgres",
4560+ Status: string(domain.ContainerStatusRunning),
4561+ Labels: map[string]string{
4562+ domain.LabelManaged: "true",
4563+ domain.LabelAttachment: "true",
4564+ domain.LabelAttachedTo: "app.example.com",
4565+ },
4566+ }
4567+
4568+ svc.mu.Lock()
4569+ svc.containers["app.example.com"] = mainContainer
4570+ svc.attachments["app.example.com"] = []string{attachment.ID}
4571+ svc.managedCount = 1
4572+ svc.mu.Unlock()
4573+
4574+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{mainContainer, attachment}, nil).Once()
4575+
4576+ report, err := svc.PreviewRemovedRouteCleanup(ctx, "app.example.com")
4577+ require.NoError(t, err)
4578+ require.NotNil(t, report)
4579+
4580+ require.Len(t, report.OrphanedEntities, 1)
4581+ assert.Equal(t, "route_container", report.OrphanedEntities[0].Kind)
4582+ assert.Equal(t, mainContainer.ID, report.OrphanedEntities[0].ID)
4583+ require.Len(t, report.PreservedAttachments, 1)
4584+ assert.Equal(t, attachment.ID, report.PreservedAttachments[0].ContainerID)
4585+ assert.Contains(t, report.Hints, "attachments preserved; remove or purge attachments explicitly if they are no longer needed")
4586+
4587+ _, exists := svc.Get(ctx, "app.example.com")
4588+ assert.True(t, exists, "preview should not clear route tracking")
4589+}
4590+
4494 func TestService_ReconcileRemovedRoute_CanonicalizesDomainBeforeCleanup(t *testing.T) {4591 func TestService_ReconcileRemovedRoute_CanonicalizesDomainBeforeCleanup(t *testing.T) {
4495 ctx := testContext()4592 ctx := testContext()
4496 runtime := mocks.NewMockContainerRuntime(t)4593 runtime := mocks.NewMockContainerRuntime(t)
@@ -4659,7 +4756,7 @@ func TestService_CleanupOrphanedAttachments_StopRemovesContainerButPreservesData
4659 runtime := mocks.NewMockContainerRuntime(t)4756 runtime := mocks.NewMockContainerRuntime(t)
4660 logWriter := mocks.NewMockContainerLogWriter(t)4757 logWriter := mocks.NewMockContainerLogWriter(t)
4661 svc := NewService(runtime, nil, mocks.NewMockEventPublisher(t), logWriter, Config{}, nil)4758 svc := NewService(runtime, nil, mocks.NewMockEventPublisher(t), logWriter, Config{}, nil)
4662- orphan := &domain.Container{4759+ running := &domain.Container{
4663 ID: "postgres-1",4760 ID: "postgres-1",
4664 Name: "gordon-app-example-com-postgres",4761 Name: "gordon-app-example-com-postgres",
4665 Image: "postgres:16",4762 Image: "postgres:16",
@@ -4671,20 +4768,35 @@ func TestService_CleanupOrphanedAttachments_StopRemovesContainerButPreservesData
4671 domain.LabelImage: "postgres:16",4768 domain.LabelImage: "postgres:16",
4672 },4769 },
4673 }4770 }
4771+ stopped := &domain.Container{
4772+ ID: "redis-1",
4773+ Name: "gordon-app-example-com-redis",
4774+ Image: "redis:7",
4775+ Status: string(domain.ContainerStatusExited),
4776+ Labels: map[string]string{
4777+ domain.LabelManaged: "true",
4778+ domain.LabelAttachment: "true",
4779+ domain.LabelAttachedTo: "app.example.com",
4780+ domain.LabelImage: "redis:7",
4781+ },
4782+ }
4674 svc.mu.Lock()4783 svc.mu.Lock()
4675- svc.attachments["app.example.com"] = []string{orphan.ID}4784+ svc.attachments["app.example.com"] = []string{running.ID, stopped.ID}
4676 svc.mu.Unlock()4785 svc.mu.Unlock()
4677 4786
4678- runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{orphan}, nil).Once()4787+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{running, stopped}, nil).Once()
4679- logWriter.EXPECT().StopLogging(orphan.ID).Return(nil).Once()4788+ logWriter.EXPECT().StopLogging(running.ID).Return(nil).Once()
4680- runtime.EXPECT().StopContainer(mock.Anything, orphan.ID).Return(nil).Once()4789+ runtime.EXPECT().StopContainer(mock.Anything, running.ID).Return(nil).Once()
4681- runtime.EXPECT().RemoveContainer(mock.Anything, orphan.ID, true).Return(nil).Once()4790+ runtime.EXPECT().RemoveContainer(mock.Anything, running.ID, true).Return(nil).Once()
4791+ logWriter.EXPECT().StopLogging(stopped.ID).Return(nil).Once()
4792+ runtime.EXPECT().RemoveContainer(mock.Anything, stopped.ID, true).Return(nil).Once()
4682 4793
4683 report, err := svc.CleanupOrphanedAttachments(ctx, "", true)4794 report, err := svc.CleanupOrphanedAttachments(ctx, "", true)
4684 require.NoError(t, err)4795 require.NoError(t, err)
4685 require.NotNil(t, report)4796 require.NotNil(t, report)
4686- require.Len(t, report.RemovedContainers, 1)4797+ require.Len(t, report.RemovedContainers, 2)
4687- assert.Equal(t, orphan.ID, report.RemovedContainers[0].ID)4798+ assert.Equal(t, running.ID, report.RemovedContainers[0].ID)
4799+ assert.Equal(t, stopped.ID, report.RemovedContainers[1].ID)
4688 assert.Contains(t, report.Hints, "attachment volumes and data were preserved")4800 assert.Contains(t, report.Hints, "attachment volumes and data were preserved")
4689 4801
4690 svc.mu.RLock()4802 svc.mu.RLock()
@@ -4693,7 +4805,7 @@ func TestService_CleanupOrphanedAttachments_StopRemovesContainerButPreservesData
4693 assert.Empty(t, ids)4805 assert.Empty(t, ids)
4694 }4806 }
4695 4807
4696-func TestService_ListOrphanedAttachments_IgnoresStoppedAttachments(t *testing.T) {4808+func TestService_ListOrphanedAttachments_IncludesStoppedAttachments(t *testing.T) {
4697 ctx := testContext()4809 ctx := testContext()
4698 runtime := mocks.NewMockContainerRuntime(t)4810 runtime := mocks.NewMockContainerRuntime(t)
4699 svc := NewService(runtime, nil, mocks.NewMockEventPublisher(t), nil, Config{}, nil)4811 svc := NewService(runtime, nil, mocks.NewMockEventPublisher(t), nil, Config{}, nil)
@@ -4713,7 +4825,9 @@ func TestService_ListOrphanedAttachments_IgnoresStoppedAttachments(t *testing.T)
4713 4825
4714 orphans, err := svc.ListOrphanedAttachments(ctx)4826 orphans, err := svc.ListOrphanedAttachments(ctx)
4715 require.NoError(t, err)4827 require.NoError(t, err)
4716- assert.Empty(t, orphans)4828+ require.Len(t, orphans, 1)
4829+ assert.Equal(t, stopped.ID, orphans[0].ContainerID)
4830+ assert.Equal(t, string(domain.ContainerStatusExited), orphans[0].Status)
4717 }4831 }
4718 4832
4719 func TestService_ListOrphanedAttachments_IncludesOwnerAndHonorsGroupConfig(t *testing.T) {4833 func TestService_ListOrphanedAttachments_IncludesOwnerAndHonorsGroupConfig(t *testing.T) {
M internal/usecase/publictls/renewal.go +6 -2
@@ -68,8 +68,9 @@ func (s *Service) StartRenewalLoop(ctx context.Context, interval time.Duration)
6868
6969 log := zerowrap.FromCtx(loopCtx)
7070
71- // Startup and reload paths reconcile routes; the periodic loop only renews
72- // due certificates to avoid repeated zone lookups and ACME ordering work.
71+ // Startup and reload paths perform an immediate reconcile; the loop still
72+ // renews due certificates right away so already-managed certs do not wait
73+ // an extra interval before renewal.
7374 if err := s.renewDueCertificates(loopCtx, time.Now()); err != nil {
7475 log.Warn().Err(err).Msg("failed to renew due public TLS certificates")
7576 }
@@ -79,6 +80,9 @@ func (s *Service) StartRenewalLoop(ctx context.Context, interval time.Duration)
7980 case <-loopCtx.Done():
8081 return
8182 case <-ticker.C:
83+ if err := s.Reconcile(loopCtx); err != nil {
84+ log.Warn().Err(err).Msg("failed to reconcile public TLS certificates")
85+ }
8286 if err := s.renewDueCertificates(loopCtx, time.Now()); err != nil {
8387 log.Warn().Err(err).Msg("failed to renew due public TLS certificates")
8488 }
@@ -68,8 +68,9 @@ func (s *Service) StartRenewalLoop(ctx context.Context, interval time.Duration)
68 68
69 log := zerowrap.FromCtx(loopCtx)69 log := zerowrap.FromCtx(loopCtx)
70 70
71- // Startup and reload paths reconcile routes; the periodic loop only renews71+ // Startup and reload paths perform an immediate reconcile; the loop still
72- // due certificates to avoid repeated zone lookups and ACME ordering work.72+ // renews due certificates right away so already-managed certs do not wait
73+ // an extra interval before renewal.
73 if err := s.renewDueCertificates(loopCtx, time.Now()); err != nil {74 if err := s.renewDueCertificates(loopCtx, time.Now()); err != nil {
74 log.Warn().Err(err).Msg("failed to renew due public TLS certificates")75 log.Warn().Err(err).Msg("failed to renew due public TLS certificates")
75 }76 }
@@ -79,6 +80,9 @@ func (s *Service) StartRenewalLoop(ctx context.Context, interval time.Duration)
79 case <-loopCtx.Done():80 case <-loopCtx.Done():
80 return81 return
81 case <-ticker.C:82 case <-ticker.C:
83+ if err := s.Reconcile(loopCtx); err != nil {
84+ log.Warn().Err(err).Msg("failed to reconcile public TLS certificates")
85+ }
82 if err := s.renewDueCertificates(loopCtx, time.Now()); err != nil {86 if err := s.renewDueCertificates(loopCtx, time.Now()); err != nil {
83 log.Warn().Err(err).Msg("failed to renew due public TLS certificates")87 log.Warn().Err(err).Msg("failed to renew due public TLS certificates")
84 }88 }
M internal/usecase/publictls/renewal_test.go +42 -0
@@ -115,6 +115,48 @@ func TestRenewalLoopStops(t *testing.T) {
115115 }, 5*time.Second, 10*time.Millisecond, "renewal loop should stop after context cancel")
116116 }
117117
118+func TestRenewalLoop_ReconcilesMissingCertificatesOnTick(t *testing.T) {
119+ ctx, cancel := context.WithCancel(context.Background())
120+ defer cancel()
121+
122+ cfg := Config{
123+ Enabled: true,
124+ Email: "admin@example.com",
125+ Challenge: "http-01",
126+ HTTPPort: 8088,
127+ TLSPort: 8443,
128+ }
129+
130+ routes := &fakeRoutes{routes: []domain.Route{{Domain: "app.example.com"}}}
131+ issuer, recorder := newMockPublicCertificateIssuer(t, nil, nil)
132+ store, _ := newMockCertificateStore(t)
133+
134+ svc := NewService(cfg, ServiceDeps{
135+ Config: cfg,
136+ Routes: routes,
137+ Issuer: issuer,
138+ Store: store,
139+ Challenges: NewHTTP01Challenges(),
140+ Effective: EffectiveChallenge{
141+ ConfiguredMode: domain.ACMEChallengeHTTP01,
142+ Mode: domain.ACMEChallengeHTTP01,
143+ TokenSource: domain.ACMETokenSourceNone,
144+ Reason: "http-01 challenge selected",
145+ },
146+ })
147+
148+ require.NoError(t, svc.Load(ctx))
149+ done := svc.StartRenewalLoop(ctx, 10*time.Millisecond)
150+ defer func() {
151+ cancel()
152+ <-done
153+ }()
154+
155+ require.Eventually(t, func() bool {
156+ return len(recorder.Orders()) > 0
157+ }, 5*time.Second, 10*time.Millisecond, "renewal loop should retry missing certificate obtains")
158+}
159+
118160 // ---------------------------------------------------------------------------
119161 // RenewDueCertificates
120162 // ---------------------------------------------------------------------------
@@ -115,6 +115,48 @@ func TestRenewalLoopStops(t *testing.T) {
115 }, 5*time.Second, 10*time.Millisecond, "renewal loop should stop after context cancel")115 }, 5*time.Second, 10*time.Millisecond, "renewal loop should stop after context cancel")
116 }116 }
117 117
118+func TestRenewalLoop_ReconcilesMissingCertificatesOnTick(t *testing.T) {
119+ ctx, cancel := context.WithCancel(context.Background())
120+ defer cancel()
121+
122+ cfg := Config{
123+ Enabled: true,
124+ Email: "admin@example.com",
125+ Challenge: "http-01",
126+ HTTPPort: 8088,
127+ TLSPort: 8443,
128+ }
129+
130+ routes := &fakeRoutes{routes: []domain.Route{{Domain: "app.example.com"}}}
131+ issuer, recorder := newMockPublicCertificateIssuer(t, nil, nil)
132+ store, _ := newMockCertificateStore(t)
133+
134+ svc := NewService(cfg, ServiceDeps{
135+ Config: cfg,
136+ Routes: routes,
137+ Issuer: issuer,
138+ Store: store,
139+ Challenges: NewHTTP01Challenges(),
140+ Effective: EffectiveChallenge{
141+ ConfiguredMode: domain.ACMEChallengeHTTP01,
142+ Mode: domain.ACMEChallengeHTTP01,
143+ TokenSource: domain.ACMETokenSourceNone,
144+ Reason: "http-01 challenge selected",
145+ },
146+ })
147+
148+ require.NoError(t, svc.Load(ctx))
149+ done := svc.StartRenewalLoop(ctx, 10*time.Millisecond)
150+ defer func() {
151+ cancel()
152+ <-done
153+ }()
154+
155+ require.Eventually(t, func() bool {
156+ return len(recorder.Orders()) > 0
157+ }, 5*time.Second, 10*time.Millisecond, "renewal loop should retry missing certificate obtains")
158+}
159+
118 // ---------------------------------------------------------------------------160 // ---------------------------------------------------------------------------
119 // RenewDueCertificates161 // RenewDueCertificates
120 // ---------------------------------------------------------------------------162 // ---------------------------------------------------------------------------
M internal/usecase/publictls/service.go +5 -0
@@ -38,6 +38,7 @@ const defaultObtainBatchSize = 1
3838 // Service manages public TLS certificates via ACME.
3939 type Service struct {
4040 mu sync.RWMutex
41+ reconcileMu sync.Mutex
4142 cfg Config
4243 deps ServiceDeps
4344 log zerowrap.Logger
@@ -131,6 +132,10 @@ func (s *Service) Reconcile(ctx context.Context) error {
131132 log.Debug().Msg("acme disabled, skipping reconcile")
132133 return nil
133134 }
135+
136+ s.reconcileMu.Lock()
137+ defer s.reconcileMu.Unlock()
138+
134139 log.Debug().Msg("starting certificate reconciliation")
135140
136141 if s.deps.Store == nil {
@@ -38,6 +38,7 @@ const defaultObtainBatchSize = 1
38 // Service manages public TLS certificates via ACME.38 // Service manages public TLS certificates via ACME.
39 type Service struct {39 type Service struct {
40 mu sync.RWMutex40 mu sync.RWMutex
41+ reconcileMu sync.Mutex
41 cfg Config42 cfg Config
42 deps ServiceDeps43 deps ServiceDeps
43 log zerowrap.Logger44 log zerowrap.Logger
@@ -131,6 +132,10 @@ func (s *Service) Reconcile(ctx context.Context) error {
131 log.Debug().Msg("acme disabled, skipping reconcile")132 log.Debug().Msg("acme disabled, skipping reconcile")
132 return nil133 return nil
133 }134 }
135+
136+ s.reconcileMu.Lock()
137+ defer s.reconcileMu.Unlock()
138+
134 log.Debug().Msg("starting certificate reconciliation")139 log.Debug().Msg("starting certificate reconciliation")
135 140
136 if s.deps.Store == nil {141 if s.deps.Store == nil {
M internal/usecase/publictls/service_test.go +55 -0
@@ -617,6 +617,61 @@ func TestServiceReconcileObtainsMissingHTTP01Cert(t *testing.T) {
617617 assert.NotEmpty(t, stored[0].Certificate.Certificate)
618618 }
619619
620+func TestServiceReconcile_SerializesConcurrentRuns(t *testing.T) {
621+ ctx := context.Background()
622+ routes := &fakeRoutes{routes: []domain.Route{{Domain: "app.example.com"}}}
623+ obtainStarted := make(chan struct{}, 2)
624+ releaseObtain := make(chan struct{})
625+
626+ issuer, recorder := newMockPublicCertificateIssuer(t, func(_ context.Context, order out.CertificateOrder) (*out.StoredCertificate, error) {
627+ obtainStarted <- struct{}{}
628+ <-releaseObtain
629+ return defaultStoredCert(order)
630+ }, nil)
631+ store, _ := newMockCertificateStore(t)
632+
633+ cfg := Config{
634+ Enabled: true,
635+ Email: "admin@example.com",
636+ Challenge: "http-01",
637+ HTTPPort: 8088,
638+ TLSPort: 8443,
639+ }
640+
641+ svc := NewService(cfg, ServiceDeps{
642+ Config: cfg,
643+ Routes: routes,
644+ Issuer: issuer,
645+ Store: store,
646+ Challenges: NewHTTP01Challenges(),
647+ Effective: EffectiveChallenge{
648+ ConfiguredMode: domain.ACMEChallengeHTTP01,
649+ Mode: domain.ACMEChallengeHTTP01,
650+ TokenSource: domain.ACMETokenSourceNone,
651+ Reason: "http-01 challenge selected",
652+ },
653+ })
654+
655+ require.NoError(t, svc.Load(ctx))
656+
657+ errCh := make(chan error, 2)
658+ go func() { errCh <- svc.Reconcile(ctx) }()
659+
660+ select {
661+ case <-obtainStarted:
662+ case <-time.After(2 * time.Second):
663+ t.Fatal("first reconcile did not start certificate obtain")
664+ }
665+
666+ go func() { errCh <- svc.Reconcile(ctx) }()
667+ time.Sleep(50 * time.Millisecond)
668+ close(releaseObtain)
669+
670+ require.NoError(t, <-errCh)
671+ require.NoError(t, <-errCh)
672+ assert.Len(t, recorder.Orders(), 1)
673+}
674+
620675 func TestServiceReconcileResolvesMissingEffectiveChallenge(t *testing.T) {
621676 ctx := context.Background()
622677 routes := &fakeRoutes{routes: []domain.Route{{Domain: "app.example.com"}}}
@@ -617,6 +617,61 @@ func TestServiceReconcileObtainsMissingHTTP01Cert(t *testing.T) {
617 assert.NotEmpty(t, stored[0].Certificate.Certificate)617 assert.NotEmpty(t, stored[0].Certificate.Certificate)
618 }618 }
619 619
620+func TestServiceReconcile_SerializesConcurrentRuns(t *testing.T) {
621+ ctx := context.Background()
622+ routes := &fakeRoutes{routes: []domain.Route{{Domain: "app.example.com"}}}
623+ obtainStarted := make(chan struct{}, 2)
624+ releaseObtain := make(chan struct{})
625+
626+ issuer, recorder := newMockPublicCertificateIssuer(t, func(_ context.Context, order out.CertificateOrder) (*out.StoredCertificate, error) {
627+ obtainStarted <- struct{}{}
628+ <-releaseObtain
629+ return defaultStoredCert(order)
630+ }, nil)
631+ store, _ := newMockCertificateStore(t)
632+
633+ cfg := Config{
634+ Enabled: true,
635+ Email: "admin@example.com",
636+ Challenge: "http-01",
637+ HTTPPort: 8088,
638+ TLSPort: 8443,
639+ }
640+
641+ svc := NewService(cfg, ServiceDeps{
642+ Config: cfg,
643+ Routes: routes,
644+ Issuer: issuer,
645+ Store: store,
646+ Challenges: NewHTTP01Challenges(),
647+ Effective: EffectiveChallenge{
648+ ConfiguredMode: domain.ACMEChallengeHTTP01,
649+ Mode: domain.ACMEChallengeHTTP01,
650+ TokenSource: domain.ACMETokenSourceNone,
651+ Reason: "http-01 challenge selected",
652+ },
653+ })
654+
655+ require.NoError(t, svc.Load(ctx))
656+
657+ errCh := make(chan error, 2)
658+ go func() { errCh <- svc.Reconcile(ctx) }()
659+
660+ select {
661+ case <-obtainStarted:
662+ case <-time.After(2 * time.Second):
663+ t.Fatal("first reconcile did not start certificate obtain")
664+ }
665+
666+ go func() { errCh <- svc.Reconcile(ctx) }()
667+ time.Sleep(50 * time.Millisecond)
668+ close(releaseObtain)
669+
670+ require.NoError(t, <-errCh)
671+ require.NoError(t, <-errCh)
672+ assert.Len(t, recorder.Orders(), 1)
673+}
674+
620 func TestServiceReconcileResolvesMissingEffectiveChallenge(t *testing.T) {675 func TestServiceReconcileResolvesMissingEffectiveChallenge(t *testing.T) {
621 ctx := context.Background()676 ctx := context.Background()
622 routes := &fakeRoutes{routes: []domain.Route{{Domain: "app.example.com"}}}677 routes := &fakeRoutes{routes: []domain.Route{{Domain: "app.example.com"}}}

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