Merge pull request #203 from bnema/fix/v2-31-release-readiness
fix: harden Gordon v2.31 release readiness
46 files · +1512 -119
M README.md +2 -2
| @@ -137,8 +137,8 @@ See the [Deploy Action README](.github/actions/deploy/README.md) for multi-platf | |||
| 137 | 137 | - Network isolation per application | |
| 138 | 138 | - Single binary, ~15MB RAM | |
| 139 | 139 | ||
| 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. | |
| 142 | 142 | ||
| 143 | 143 | ## Documentation | |
| 144 | 144 | ||
| @@ -137,8 +137,8 @@ See the [Deploy Action README](.github/actions/deploy/README.md) for multi-platf | |||||
| 137 | - Network isolation per application | 137 | - Network isolation per application | ||
| 138 | - Single binary, ~15MB RAM | 138 | - 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 | ## Documentation | 143 | ## Documentation | ||
| 144 | 144 | ||||
M docs/cli/auth.md +1 -5
| @@ -90,13 +90,9 @@ gordon auth show-token | pbcopy # copy to clipboard | |||
| 90 | 90 | Remove the stored token for a remote. | |
| 91 | 91 | ||
| 92 | 92 | ```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 | 96 | Use the global `--remote, -r` flag to target a specific remote. See [CLI Overview](./index.md). | |
| 101 | 97 | ||
| 102 | 98 | 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 | ```bash | 92 | ```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 | |||
| 241 | 241 | Gordon reads configuration from environment variables: | |
| 242 | 242 | ||
| 243 | 243 | ```bash | |
| 244 | - | GORDON_SERVER_PORT=8080 gordon serve | |
| 244 | + | GORDON_SERVER_PORT=8088 gordon serve | |
| 245 | 245 | GORDON_LOGGING_LEVEL=debug gordon serve | |
| 246 | 246 | ``` | |
| 247 | 247 | ||
| @@ -241,7 +241,7 @@ gordon routes list | |||||
| 241 | Gordon reads configuration from environment variables: | 241 | Gordon reads configuration from environment variables: | ||
| 242 | 242 | ||||
| 243 | ```bash | 243 | ```bash | ||
| 244 | - | GORDON_SERVER_PORT=8080 gordon serve | 244 | + | GORDON_SERVER_PORT=8088 gordon serve |
| 245 | GORDON_LOGGING_LEVEL=debug gordon serve | 245 | 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 | |||
| 57 | 57 | gordon serve -c ./my-config.toml | |
| 58 | 58 | ||
| 59 | 59 | # With environment override | |
| 60 | - | GORDON_SERVER_PORT=8080 gordon serve | |
| 60 | + | GORDON_SERVER_PORT=8088 gordon serve | |
| 61 | 61 | GORDON_LOGGING_LEVEL=debug gordon serve | |
| 62 | 62 | ``` | |
| 63 | 63 | ||
| @@ -57,7 +57,7 @@ gordon serve --config /path/to/gordon.toml | |||||
| 57 | gordon serve -c ./my-config.toml | 57 | gordon serve -c ./my-config.toml | ||
| 58 | 58 | ||||
| 59 | # With environment override | 59 | # With environment override | ||
| 60 | - | GORDON_SERVER_PORT=8080 gordon serve | 60 | + | GORDON_SERVER_PORT=8088 gordon serve |
| 61 | GORDON_LOGGING_LEVEL=debug gordon serve | 61 | 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 | |||
| 21 | 21 | ``` | |
| 22 | 22 | Gordon Status | |
| 23 | 23 | ||
| 24 | - | Domain: registry.example.com | |
| 24 | + | Gordon Domain: gordon.example.com | |
| 25 | 25 | Registry Port: 5000 | |
| 26 | - | Server Port: 8080 | |
| 26 | + | Server Port: 8088 | |
| 27 | 27 | Routes: 3 | |
| 28 | 28 | Auto-Route: true | |
| 29 | 29 | Network Isolation: false | |
| @@ -38,9 +38,9 @@ Container Status: | |||
| 38 | 38 | ||
| 39 | 39 | | Field | Description | | |
| 40 | 40 | |-------|-------------| | |
| 41 | - | | Domain | Registry domain from configuration | | |
| 41 | + | | Gordon Domain | Public Gordon domain from configuration | | |
| 42 | 42 | | Registry Port | Docker registry port | | |
| 43 | - | | Server Port | Gordon admin API port | | |
| 43 | + | | Server Port | Gordon HTTP proxy port | | |
| 44 | 44 | | Routes | Total configured routes | | |
| 45 | 45 | | Auto-Route | Whether auto-routing is enabled | | |
| 46 | 46 | | Network Isolation | Whether network isolation is enabled | | |
| @@ -51,6 +51,7 @@ Container Status: | |||
| 51 | 51 | | State | Description | | |
| 52 | 52 | |-------|-------------| | |
| 53 | 53 | | running | Container is running and healthy | | |
| 54 | + | | restarting | Container is in runtime restart backoff or restart cycle | | |
| 54 | 55 | | stopped | Container was stopped | | |
| 55 | 56 | | exited | Container exited (check logs for errors) | | |
| 56 | 57 | | paused | Container is paused | | |
| @@ -21,9 +21,9 @@ gordon status --remote https://gordon.mydomain.com --token $TOKEN | |||||
| 21 | ``` | 21 | ``` | ||
| 22 | Gordon Status | 22 | Gordon Status | ||
| 23 | 23 | ||||
| 24 | - | Domain: registry.example.com | 24 | + | Gordon Domain: gordon.example.com |
| 25 | Registry Port: 5000 | 25 | Registry Port: 5000 | ||
| 26 | - | Server Port: 8080 | 26 | + | Server Port: 8088 |
| 27 | Routes: 3 | 27 | Routes: 3 | ||
| 28 | Auto-Route: true | 28 | Auto-Route: true | ||
| 29 | Network Isolation: false | 29 | 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 | |||
| 13 | 13 | ||
| 14 | 14 | ```toml | |
| 15 | 15 | [server] | |
| 16 | - | port = 8080 | |
| 16 | + | port = 8088 | |
| 17 | + | tls_port = 8443 | |
| 17 | 18 | registry_port = 5000 | |
| 18 | 19 | gordon_domain = "gordon.mydomain.com" | |
| 19 | 20 | ||
| @@ -272,7 +273,7 @@ gordon reload | |||
| 272 | 273 | Configuration values can be overridden with environment variables: | |
| 273 | 274 | ||
| 274 | 275 | ```bash | |
| 275 | - | GORDON_SERVER_PORT=8080 gordon serve | |
| 276 | + | GORDON_SERVER_PORT=8088 gordon serve | |
| 276 | 277 | GORDON_LOGGING_LEVEL=debug gordon serve | |
| 277 | 278 | ``` | |
| 278 | 279 | ||
| @@ -13,7 +13,8 @@ Gordon uses a single TOML configuration file located at `~/.config/gordon/gordon | |||||
| 13 | 13 | ||||
| 14 | ```toml | 14 | ```toml | ||
| 15 | [server] | 15 | [server] | ||
| 16 | - | port = 8080 | 16 | + | port = 8088 |
| 17 | + | tls_port = 8443 | |||
| 17 | registry_port = 5000 | 18 | 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 | ```bash | 275 | ```bash | ||
| 275 | - | GORDON_SERVER_PORT=8080 gordon serve | 276 | + | GORDON_SERVER_PORT=8088 gordon serve |
| 276 | GORDON_LOGGING_LEVEL=debug gordon serve | 277 | 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" | |||
| 22 | 22 | max_size = 100 | |
| 23 | 23 | max_backups = 3 | |
| 24 | 24 | 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 | 34 | ## Options | |
| @@ -56,6 +63,22 @@ The Admin API and `gordon logs` read from the process log file. Keep | |||
| 56 | 63 | | `container_logs.max_backups` | int | `3` | Old files to keep | | |
| 57 | 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 | 82 | ## Log Levels | |
| 60 | 83 | ||
| 61 | 84 | | Level | Description | | |
| @@ -114,6 +137,13 @@ dir = "./logs/containers" | |||
| 114 | 137 | max_size = 10 | |
| 115 | 138 | max_backups = 2 | |
| 116 | 139 | 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 | 149 | ### Production | |
| @@ -22,6 +22,13 @@ dir = "~/.gordon/logs/containers" | |||||
| 22 | max_size = 100 | 22 | max_size = 100 | ||
| 23 | max_backups = 3 | 23 | max_backups = 3 | ||
| 24 | max_age = 28 | 24 | 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 | ## Options | 34 | ## 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 Levels | 82 | ## Log Levels | ||
| 60 | 83 | ||||
| 61 | | Level | Description | | 84 | | Level | Description | | ||
| @@ -114,6 +137,13 @@ dir = "./logs/containers" | |||||
| 114 | max_size = 10 | 137 | max_size = 10 | ||
| 115 | max_backups = 2 | 138 | max_backups = 2 | ||
| 116 | max_age = 7 | 139 | 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 | ### Production | 149 | ### Production | ||
M docs/config/reference.md +22 -3
| @@ -38,7 +38,7 @@ polling_interval = "5s" # Interval between DNS-01 propagation | |||
| 38 | 38 | enabled = true # Enable registry authentication (default: true) | |
| 39 | 39 | secrets_backend = "unsafe" # "pass", "sops", or "unsafe" | |
| 40 | 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 | 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 | 81 | max_backups = 3 # Number of old files to keep | |
| 82 | 82 | 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 | 96 | # TELEMETRY (OpenTelemetry) | |
| 86 | 97 | # ============================================================================= | |
| @@ -221,7 +232,7 @@ keep_last = 3 # Keep N newest tags per repository | |||
| 221 | 232 | | `tls.acme.obtain_batch_size` | `1` | Maximum new ACME certificate orders per reconcile run | | |
| 222 | 233 | | `auth.enabled` | `true` | Enable authentication; when `false`, run local-only mode (loopback-only `/v2/*`, `/admin/*` disabled) | | |
| 223 | 234 | | `auth.secrets_backend` | `"unsafe"` | Secrets storage | | |
| 224 | - | | `auth.token_expiry` | `"720h"` | 30 days | | |
| 235 | + | | `auth.token_expiry` | `"30d"` | 30 days | | |
| 225 | 236 | | `auth.access_token_ttl` | `"15m"` | Ephemeral access token lifetime | | |
| 226 | 237 | | `api.rate_limit.enabled` | `true` | Enable rate limiting | | |
| 227 | 238 | | `api.rate_limit.global_rps` | `500` | Global requests/second | | |
| @@ -238,6 +249,14 @@ keep_last = 3 # Keep N newest tags per repository | |||
| 238 | 249 | | `logging.container_logs.max_size` | `100` | 100 MB | | |
| 239 | 250 | | `logging.container_logs.max_backups` | `3` | Keep 3 old files | | |
| 240 | 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 | 260 | | `telemetry.enabled` | `false` | Enable OTLP telemetry export | | |
| 242 | 261 | | `telemetry.endpoint` | `""` | OTLP HTTP endpoint URL | | |
| 243 | 262 | | `telemetry.auth_token` | `""` | Base64 `user:password` for Basic auth | | |
| @@ -285,7 +304,7 @@ GORDON_<SECTION>_<KEY>=value | |||
| 285 | 304 | ||
| 286 | 305 | Examples: | |
| 287 | 306 | ```bash | |
| 288 | - | GORDON_SERVER_PORT=8080 | |
| 307 | + | GORDON_SERVER_PORT=8088 | |
| 289 | 308 | GORDON_SERVER_GORDON_DOMAIN=gordon.example.com | |
| 290 | 309 | GORDON_AUTH_ENABLED=true | |
| 291 | 310 | 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 keep | 81 | max_backups = 3 # Number of old files to keep | ||
| 82 | max_age = 28 # Days to keep old files | 82 | 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 | # ============================================================================= | ||
| @@ -221,7 +232,7 @@ keep_last = 3 # Keep N newest tags per repository | |||||
| 221 | | `tls.acme.obtain_batch_size` | `1` | Maximum new ACME certificate orders per reconcile run | | 232 | | `tls.acme.obtain_batch_size` | `1` | Maximum new ACME certificate orders per reconcile run | | ||
| 222 | | `auth.enabled` | `true` | Enable authentication; when `false`, run local-only mode (loopback-only `/v2/*`, `/admin/*` disabled) | | 233 | | `auth.enabled` | `true` | Enable authentication; when `false`, run local-only mode (loopback-only `/v2/*`, `/admin/*` disabled) | | ||
| 223 | | `auth.secrets_backend` | `"unsafe"` | Secrets storage | | 234 | | `auth.secrets_backend` | `"unsafe"` | Secrets storage | | ||
| 224 | - | | `auth.token_expiry` | `"720h"` | 30 days | | 235 | + | | `auth.token_expiry` | `"30d"` | 30 days | |
| 225 | | `auth.access_token_ttl` | `"15m"` | Ephemeral access token lifetime | | 236 | | `auth.access_token_ttl` | `"15m"` | Ephemeral access token lifetime | | ||
| 226 | | `api.rate_limit.enabled` | `true` | Enable rate limiting | | 237 | | `api.rate_limit.enabled` | `true` | Enable rate limiting | | ||
| 227 | | `api.rate_limit.global_rps` | `500` | Global requests/second | | 238 | | `api.rate_limit.global_rps` | `500` | Global requests/second | | ||
| @@ -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 | ```bash | 306 | ```bash | ||
| 288 | - | GORDON_SERVER_PORT=8080 | 307 | + | GORDON_SERVER_PORT=8088 |
| 289 | GORDON_SERVER_GORDON_DOMAIN=gordon.example.com | 308 | GORDON_SERVER_GORDON_DOMAIN=gordon.example.com | ||
| 290 | GORDON_AUTH_ENABLED=true | 309 | GORDON_AUTH_ENABLED=true | ||
| 291 | GORDON_LOGGING_LEVEL=debug | 310 | GORDON_LOGGING_LEVEL=debug | ||
M docs/config/server.md +9 -8
| @@ -48,14 +48,14 @@ The `port` setting controls where Gordon listens for HTTP traffic: | |||
| 48 | 48 | ||
| 49 | 49 | ```toml | |
| 50 | 50 | [server] | |
| 51 | - | port = 8080 # Use 8080 for rootless containers with port forwarding | |
| 51 | + | port = 8088 # Default high port for rootless or forwarded setups | |
| 52 | 52 | ``` | |
| 53 | 53 | ||
| 54 | 54 | For rootless containers, you'll typically use a high port and configure firewall port forwarding: | |
| 55 | 55 | ||
| 56 | 56 | ```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 | |
| 59 | 59 | ``` | |
| 60 | 60 | ||
| 61 | 61 | ### 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 | 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 | 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 | 413 | ```toml | |
| 414 | 414 | [server] | |
| 415 | - | port = 8080 | |
| 415 | + | port = 8088 | |
| 416 | 416 | registry_port = 5000 | |
| 417 | 417 | gordon_domain = "gordon.local" | |
| 418 | 418 | data_dir = "./dev-data" | |
| @@ -422,7 +422,7 @@ data_dir = "./dev-data" | |||
| 422 | 422 | ||
| 423 | 423 | ```toml | |
| 424 | 424 | [server] | |
| 425 | - | port = 8080 | |
| 425 | + | port = 8088 | |
| 426 | 426 | registry_port = 5000 | |
| 427 | 427 | gordon_domain = "gordon.company.com" | |
| 428 | 428 | data_dir = "/var/lib/gordon" | |
| @@ -432,14 +432,15 @@ data_dir = "/var/lib/gordon" | |||
| 432 | 432 | ||
| 433 | 433 | ```toml | |
| 434 | 434 | [server] | |
| 435 | - | port = 8080 # High port for rootless | |
| 435 | + | port = 8088 # High port for rootless | |
| 436 | 436 | registry_port = 5000 | |
| 437 | 437 | gordon_domain = "gordon.mydomain.com" | |
| 438 | 438 | ``` | |
| 439 | 439 | ||
| 440 | 440 | With firewall port forwarding: | |
| 441 | + | ||
| 441 | 442 | ```bash | |
| 442 | - | sudo firewall-cmd --permanent --add-forward-port=port=80:proto=tcp:toport=8080 | |
| 443 | + | sudo firewall-cmd --permanent --add-forward-port=port=80:proto=tcp:toport=8088 | |
| 443 | 444 | sudo firewall-cmd --reload | |
| 444 | 445 | ``` | |
| 445 | 446 | ||
| @@ -48,14 +48,14 @@ The `port` setting controls where Gordon listens for HTTP traffic: | |||||
| 48 | 48 | ||||
| 49 | ```toml | 49 | ```toml | ||
| 50 | [server] | 50 | [server] | ||
| 51 | - | port = 8080 # Use 8080 for rootless containers with port forwarding | 51 | + | 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 | ```bash | 56 | ```bash | ||
| 57 | - | # Forward port 80 to 8080 | 57 | + | # Forward port 80 to 8088 |
| 58 | - | sudo firewall-cmd --permanent --add-forward-port=port=80:proto=tcp:toport=8080 | 58 | + | sudo firewall-cmd --permanent --add-forward-port=port=80:proto=tcp:toport=8088 |
| 59 | ``` | 59 | ``` | ||
| 60 | 60 | ||||
| 61 | ### Internal CA and TLS | 61 | ### 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 | ```toml | 413 | ```toml | ||
| 414 | [server] | 414 | [server] | ||
| 415 | - | port = 8080 | 415 | + | port = 8088 |
| 416 | registry_port = 5000 | 416 | 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 | ```toml | 423 | ```toml | ||
| 424 | [server] | 424 | [server] | ||
| 425 | - | port = 8080 | 425 | + | port = 8088 |
| 426 | registry_port = 5000 | 426 | 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,15 @@ data_dir = "/var/lib/gordon" | |||||
| 432 | 432 | ||||
| 433 | ```toml | 433 | ```toml | ||
| 434 | [server] | 434 | [server] | ||
| 435 | - | port = 8080 # High port for rootless | 435 | + | port = 8088 # High port for rootless |
| 436 | registry_port = 5000 | 436 | 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 | + | ||||
| 441 | ```bash | 442 | ```bash | ||
| 442 | - | sudo firewall-cmd --permanent --add-forward-port=port=80:proto=tcp:toport=8080 | 443 | + | sudo firewall-cmd --permanent --add-forward-port=port=80:proto=tcp:toport=8088 |
| 443 | sudo firewall-cmd --reload | 444 | sudo firewall-cmd --reload | ||
| 444 | ``` | 445 | ``` | ||
| 445 | 446 | ||||
M docs/deployment/rollback.md +12 -0
| @@ -2,6 +2,18 @@ | |||
| 2 | 2 | ||
| 3 | 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 | 17 | ## Rollback Strategies | |
| 6 | 18 | ||
| 7 | 19 | ### 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 Strategies | 17 | ## Rollback Strategies | ||
| 6 | 18 | ||||
| 7 | ### 1. Config-Based Rollback | 19 | ### 1. Config-Based Rollback | ||
M docs/getting-started.md +4 -1
| @@ -73,8 +73,9 @@ Edit `~/.config/gordon/gordon.toml`: | |||
| 73 | 73 | ||
| 74 | 74 | ```toml | |
| 75 | 75 | [server] | |
| 76 | - | port = 8080 # Proxy port (use with Cloudflare/rootless) | |
| 76 | + | port = 8088 # HTTP proxy port (default) | |
| 77 | 77 | registry_port = 5000 # Registry port | |
| 78 | + | tls_port = 8443 # HTTPS listener (default) | |
| 78 | 79 | gordon_domain = "gordon.mydomain.com" # Registry + Admin API domain | |
| 79 | 80 | ||
| 80 | 81 | [routes] | |
| @@ -98,6 +99,8 @@ Why wildcard (`*`)? | |||
| 98 | 99 | ||
| 99 | 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 | 104 | ## 6. Start Gordon as a Service | |
| 102 | 105 | ||
| 103 | 106 | ```bash | |
| @@ -73,8 +73,9 @@ Edit `~/.config/gordon/gordon.toml`: | |||||
| 73 | 73 | ||||
| 74 | ```toml | 74 | ```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 port | 77 | registry_port = 5000 # Registry port | ||
| 78 | + | tls_port = 8443 # HTTPS listener (default) | |||
| 78 | gordon_domain = "gordon.mydomain.com" # Registry + Admin API domain | 79 | 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 Service | 104 | ## 6. Start Gordon as a Service | ||
| 102 | 105 | ||||
| 103 | ```bash | 106 | ```bash | ||
M docs/index.md +1 -1
| @@ -78,7 +78,7 @@ Gordon runs on your VPS and provides: | |||
| 78 | 78 | - Linux VPS (Ubuntu/Debian recommended) | |
| 79 | 79 | - Docker or Podman runtime | |
| 80 | 80 | - Domain pointing to your server | |
| 81 | - | - Cloudflare account for HTTPS (free tier works) | |
| 81 | + | - Optional Cloudflare account or other reverse proxy for edge TLS | |
| 82 | 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 | 84 | ||
| @@ -78,7 +78,7 @@ Gordon runs on your VPS and provides: | |||||
| 78 | - Linux VPS (Ubuntu/Debian recommended) | 78 | - Linux VPS (Ubuntu/Debian recommended) | ||
| 79 | - Docker or Podman runtime | 79 | - Docker or Podman runtime | ||
| 80 | - Domain pointing to your server | 80 | - Domain pointing to your server | ||
| 81 | - | - Cloudflare account for HTTPS (free tier works) | 81 | + | - Optional Cloudflare account or other reverse proxy for edge TLS |
| 82 | 82 | ||||
| 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. | 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 | 84 | ||||
M docs/installation.md +16 -4
| @@ -249,16 +249,16 @@ Point your domains to your server: | |||
| 249 | 249 | ||
| 250 | 250 | ## 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 | 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) | | |
| 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`) | | |
| 258 | 258 | ||
| 259 | 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 | 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 | 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 | 298 | ## Verify Installation | |
| 287 | 299 | ||
| 288 | 300 | ```bash | |
| @@ -249,16 +249,16 @@ Point your domains to your server: | |||||
| 249 | 249 | ||||
| 250 | ## Cloudflare SSL Mode | 250 | ## 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 Installation | 298 | ## Verify Installation | ||
| 287 | 299 | ||||
| 288 | ```bash | 300 | ```bash | ||
M docs/reference/troubleshooting.md +1 -1
| @@ -406,7 +406,7 @@ docker inspect gordon-app-mydomain-com | |||
| 406 | 406 | ||
| 407 | 407 | # Check connectivity | |
| 408 | 408 | curl -v http://localhost:5000/v2/ | |
| 409 | - | curl -v http://localhost:8080/ | |
| 409 | + | curl -v http://localhost:8088/ | |
| 410 | 410 | ``` | |
| 411 | 411 | ||
| 412 | 412 | ## Getting Help | |
| @@ -406,7 +406,7 @@ docker inspect gordon-app-mydomain-com | |||||
| 406 | 406 | ||||
| 407 | # Check connectivity | 407 | # 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 Help | 412 | ## Getting Help | ||
M docs/upgrading.md +55 -1
| @@ -97,6 +97,60 @@ Recommended rollout: | |||
| 97 | 97 | ||
| 98 | 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 | 154 | ## v2.16.0 to v2.30.0 | |
| 101 | 155 | ||
| 102 | 156 | ### Breaking: Password Authentication Removed | |
| @@ -116,7 +170,7 @@ Gordon v2.30.0 removes password-based authentication entirely. Only token-based | |||
| 116 | 170 | ||
| 117 | 171 | - `auth.access_token_ttl` configures the lifetime of ephemeral access tokens issued by `/auth/token` (default: `"15m"`) | |
| 118 | 172 | - `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 | 174 | - Automatic token exchange: the CLI transparently exchanges long-lived tokens for ephemeral ones before API calls | |
| 121 | 175 | - Admin scopes (`admin:*:*`, `admin:routes:read`, etc.) allow fine-grained access control for remote CLI operations | |
| 122 | 176 | ||
| @@ -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.0 | 154 | ## v2.16.0 to v2.30.0 | ||
| 101 | 155 | ||||
| 102 | ### Breaking: Password Authentication Removed | 156 | ### 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 remote | 172 | - `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 calls | 174 | - 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 operations | 175 | - 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 @@ | |||
| 3 | 3 | ||
| 4 | 4 | [server] | |
| 5 | 5 | port = 8088 | |
| 6 | + | tls_port = 8443 | |
| 6 | 7 | registry_port = 5000 | |
| 7 | 8 | gordon_domain = "gordon.example.com" | |
| 8 | 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 | 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 | 34 | [auth] | |
| 12 | 35 | enabled = true | |
| @@ -68,8 +91,42 @@ enabled = false | |||
| 68 | 91 | level = "info" | |
| 69 | 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 | 101 | [logging.container_logs] | |
| 72 | 102 | 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 | 131 | [backups] | |
| 75 | 132 | enabled = false | |
| @@ -3,10 +3,33 @@ | |||||
| 3 | 3 | ||||
| 4 | [server] | 4 | [server] | ||
| 5 | port = 8088 | 5 | port = 8088 | ||
| 6 | + | tls_port = 8443 | |||
| 6 | registry_port = 5000 | 7 | 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 = true | 35 | 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 = true | 102 | 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 = false | 132 | enabled = false | ||
M internal/adapters/in/cli/attachments.go +4 -1
| @@ -70,7 +70,10 @@ Examples: | |||
| 70 | 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 | 77 | if isRemote { | |
| 75 | 78 | return runAttachmentsListRemote(ctx, client, target, jsonOut, cmd.OutOrStdout()) | |
| 76 | 79 | } | |
| @@ -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: | |||
| 236 | 236 | } | |
| 237 | 237 | ||
| 238 | 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, fmt.Errorf("failed to resolve remote %q: %w", remoteFlag, err) | |
| 242 | + | } | |
| 240 | 243 | if !isRemote { | |
| 241 | 244 | return nil, fmt.Errorf("no remote specified. Use --remote or 'gordon remotes use <name>'") | |
| 242 | 245 | } | |
| @@ -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, fmt.Errorf("failed to resolve remote %q: %w", remoteFlag, 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 +51 -0
| @@ -153,6 +153,57 @@ func (l *localControlPlane) RemoveRouteWithCleanup(ctx context.Context, routeDom | |||
| 153 | 153 | 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 report == nil { | |
| 169 | + | return nil, nil | |
| 170 | + | } | |
| 171 | + | if l.volumeSvc == nil { | |
| 172 | + | return report, nil | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | volumes, err := l.ListVolumes(ctx) | |
| 176 | + | if err != nil { | |
| 177 | + | return nil, fmt.Errorf("list volumes: %w", err) | |
| 178 | + | } | |
| 179 | + | if len(volumes) == 0 { | |
| 180 | + | return report, nil | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | scope := newRouteResourceScope(routeDomain, l.routeCleanupVolumePrefix(), nil, report.PreservedAttachments) | |
| 184 | + | for _, volume := range volumesForRouteDiagnosis(volumes, scope) { | |
| 185 | + | report.PreservedVolumes = append(report.PreservedVolumes, domain.CleanupVolume{ | |
| 186 | + | Name: volume.Name, | |
| 187 | + | Reason: "volume preserved for explicit cleanup review", | |
| 188 | + | }) | |
| 189 | + | } | |
| 190 | + | return report, nil | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | func (l *localControlPlane) routeCleanupVolumePrefix() string { | |
| 194 | + | const defaultPrefix = "gordon" | |
| 195 | + | if l.configSvc == nil { | |
| 196 | + | return defaultPrefix | |
| 197 | + | } | |
| 198 | + | if volumeCfg, ok := any(l.configSvc).(interface{ GetVolumeConfig() (bool, string, bool) }); ok { | |
| 199 | + | _, prefix, _ := volumeCfg.GetVolumeConfig() | |
| 200 | + | if prefix != "" { | |
| 201 | + | return prefix | |
| 202 | + | } | |
| 203 | + | } | |
| 204 | + | return defaultPrefix | |
| 205 | + | } | |
| 206 | + | ||
| 156 | 207 | func (l *localControlPlane) Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) { | |
| 157 | 208 | registryDomain := "" | |
| 158 | 209 | if l.configSvc != nil { | |
| @@ -153,6 +153,57 @@ func (l *localControlPlane) RemoveRouteWithCleanup(ctx context.Context, routeDom | |||||
| 153 | return resp, nil | 153 | 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 report == nil { | |||
| 169 | + | return nil, nil | |||
| 170 | + | } | |||
| 171 | + | if l.volumeSvc == nil { | |||
| 172 | + | return report, nil | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | volumes, err := l.ListVolumes(ctx) | |||
| 176 | + | if err != nil { | |||
| 177 | + | return nil, fmt.Errorf("list volumes: %w", err) | |||
| 178 | + | } | |||
| 179 | + | if len(volumes) == 0 { | |||
| 180 | + | return report, nil | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | scope := newRouteResourceScope(routeDomain, l.routeCleanupVolumePrefix(), nil, report.PreservedAttachments) | |||
| 184 | + | for _, volume := range volumesForRouteDiagnosis(volumes, scope) { | |||
| 185 | + | report.PreservedVolumes = append(report.PreservedVolumes, domain.CleanupVolume{ | |||
| 186 | + | Name: volume.Name, | |||
| 187 | + | Reason: "volume preserved for explicit cleanup review", | |||
| 188 | + | }) | |||
| 189 | + | } | |||
| 190 | + | return report, nil | |||
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | func (l *localControlPlane) routeCleanupVolumePrefix() string { | |||
| 194 | + | const defaultPrefix = "gordon" | |||
| 195 | + | if l.configSvc == nil { | |||
| 196 | + | return defaultPrefix | |||
| 197 | + | } | |||
| 198 | + | if volumeCfg, ok := any(l.configSvc).(interface{ GetVolumeConfig() (bool, string, bool) }); ok { | |||
| 199 | + | _, prefix, _ := volumeCfg.GetVolumeConfig() | |||
| 200 | + | if prefix != "" { | |||
| 201 | + | return prefix | |||
| 202 | + | } | |||
| 203 | + | } | |||
| 204 | + | return defaultPrefix | |||
| 205 | + | } | |||
| 206 | + | ||||
| 156 | func (l *localControlPlane) Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) { | 207 | func (l *localControlPlane) Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) { | ||
| 157 | registryDomain := "" | 208 | registryDomain := "" | ||
| 158 | if l.configSvc != nil { | 209 | if l.configSvc != nil { | ||
M internal/adapters/in/cli/controlplane_local_test.go +28 -0
| @@ -13,6 +13,18 @@ import ( | |||
| 13 | 13 | "github.com/stretchr/testify/require" | |
| 14 | 14 | ) | |
| 15 | 15 | ||
| 16 | + | type previewContainerService struct { | |
| 17 | + | *inmocks.MockContainerService | |
| 18 | + | preview func(context.Context, string) (*domain.CleanupReport, error) | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func (s *previewContainerService) PreviewRemovedRouteCleanup(ctx context.Context, routeDomain string) (*domain.CleanupReport, error) { | |
| 22 | + | if s.preview == nil { | |
| 23 | + | return nil, nil | |
| 24 | + | } | |
| 25 | + | return s.preview(ctx, routeDomain) | |
| 26 | + | } | |
| 27 | + | ||
| 16 | 28 | func TestLocalControlPlane_GetStatus(t *testing.T) { | |
| 17 | 29 | t.Parallel() | |
| 18 | 30 | ||
| @@ -216,6 +228,22 @@ func TestLocalControlPlane_GetContainerLogs(t *testing.T) { | |||
| 216 | 228 | require.Equal(t, []string{"line1", "line2"}, lines) | |
| 217 | 229 | } | |
| 218 | 230 | ||
| 231 | + | func TestLocalControlPlane_GetRouteCleanupPreview_ReturnsNilReportWithoutPanic(t *testing.T) { | |
| 232 | + | ctx := context.Background() | |
| 233 | + | volumeSvc := inmocks.NewMockVolumeService(t) | |
| 234 | + | containerSvc := &previewContainerService{ | |
| 235 | + | MockContainerService: inmocks.NewMockContainerService(t), | |
| 236 | + | preview: func(context.Context, string) (*domain.CleanupReport, error) { | |
| 237 | + | return nil, nil | |
| 238 | + | }, | |
| 239 | + | } | |
| 240 | + | cp := &localControlPlane{containerSvc: containerSvc, volumeSvc: volumeSvc} | |
| 241 | + | ||
| 242 | + | report, err := cp.GetRouteCleanupPreview(ctx, "app.local") | |
| 243 | + | require.NoError(t, err) | |
| 244 | + | assert.Nil(t, report) | |
| 245 | + | } | |
| 246 | + | ||
| 219 | 247 | func TestLocalControlPlane_RemoveRouteReconcilesRuntimeWhenRouteAlreadyMissing(t *testing.T) { | |
| 220 | 248 | ctx := context.Background() | |
| 221 | 249 | configSvc := inmocks.NewMockConfigService(t) | |
| @@ -13,6 +13,18 @@ import ( | |||||
| 13 | "github.com/stretchr/testify/require" | 13 | "github.com/stretchr/testify/require" | ||
| 14 | ) | 14 | ) | ||
| 15 | 15 | ||||
| 16 | + | type previewContainerService struct { | |||
| 17 | + | *inmocks.MockContainerService | |||
| 18 | + | preview func(context.Context, string) (*domain.CleanupReport, error) | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | func (s *previewContainerService) PreviewRemovedRouteCleanup(ctx context.Context, routeDomain string) (*domain.CleanupReport, error) { | |||
| 22 | + | if s.preview == nil { | |||
| 23 | + | return nil, nil | |||
| 24 | + | } | |||
| 25 | + | return s.preview(ctx, routeDomain) | |||
| 26 | + | } | |||
| 27 | + | ||||
| 16 | func TestLocalControlPlane_GetStatus(t *testing.T) { | 28 | func TestLocalControlPlane_GetStatus(t *testing.T) { | ||
| 17 | t.Parallel() | 29 | t.Parallel() | ||
| 18 | 30 | ||||
| @@ -216,6 +228,22 @@ func TestLocalControlPlane_GetContainerLogs(t *testing.T) { | |||||
| 216 | require.Equal(t, []string{"line1", "line2"}, lines) | 228 | require.Equal(t, []string{"line1", "line2"}, lines) | ||
| 217 | } | 229 | } | ||
| 218 | 230 | ||||
| 231 | + | func TestLocalControlPlane_GetRouteCleanupPreview_ReturnsNilReportWithoutPanic(t *testing.T) { | |||
| 232 | + | ctx := context.Background() | |||
| 233 | + | volumeSvc := inmocks.NewMockVolumeService(t) | |||
| 234 | + | containerSvc := &previewContainerService{ | |||
| 235 | + | MockContainerService: inmocks.NewMockContainerService(t), | |||
| 236 | + | preview: func(context.Context, string) (*domain.CleanupReport, error) { | |||
| 237 | + | return nil, nil | |||
| 238 | + | }, | |||
| 239 | + | } | |||
| 240 | + | cp := &localControlPlane{containerSvc: containerSvc, volumeSvc: volumeSvc} | |||
| 241 | + | ||||
| 242 | + | report, err := cp.GetRouteCleanupPreview(ctx, "app.local") | |||
| 243 | + | require.NoError(t, err) | |||
| 244 | + | assert.Nil(t, report) | |||
| 245 | + | } | |||
| 246 | + | ||||
| 219 | func TestLocalControlPlane_RemoveRouteReconcilesRuntimeWhenRouteAlreadyMissing(t *testing.T) { | 247 | func TestLocalControlPlane_RemoveRouteReconcilesRuntimeWhenRouteAlreadyMissing(t *testing.T) { | ||
| 220 | ctx := context.Background() | 248 | ctx := context.Background() | ||
| 221 | configSvc := inmocks.NewMockConfigService(t) | 249 | configSvc := inmocks.NewMockConfigService(t) | ||
M internal/adapters/in/cli/controlplane_remote.go +4 -0
| @@ -49,6 +49,10 @@ func (r *remoteControlPlane) RemoveRouteWithCleanup(ctx context.Context, routeDo | |||
| 49 | 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 | 56 | func (r *remoteControlPlane) Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) { | |
| 53 | 57 | return r.client.Bootstrap(ctx, req) | |
| 54 | 58 | } | |
| @@ -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() { | |||
| 25 | 25 | } | |
| 26 | 26 | ||
| 27 | 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 | 32 | if isRemote { | |
| 30 | 33 | 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 | 56 | func resolveControlPlaneForAttachmentTarget(ctx context.Context, target string) (*controlPlaneHandle, error) { | |
| 48 | 57 | return resolveControlPlaneWithInference(ctx, func(ctx context.Context) (*remote.ResolvedRemote, error) { | |
| 49 | 58 | 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}, nil | 33 | 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" | |||
| 75 | 75 | require.NotNil(t, handle.plane) | |
| 76 | 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 | + | } | |
| @@ -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 { | |||
| 89 | 89 | Use: "list", | |
| 90 | 90 | Short: "List runtime images and registry tags", | |
| 91 | 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 | 96 | if !isRemote { | |
| 94 | 97 | return fmt.Errorf("images commands require a configured remote target") | |
| 95 | 98 | } | |
| @@ -114,7 +117,10 @@ func newImagesPruneCmd() *cobra.Command { | |||
| 114 | 117 | By default both runtime and registry cleanup run, keeping latest + 3 previous | |
| 115 | 118 | release tags per repository. Use --dangling or --registry to restrict scope.`, | |
| 116 | 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 | 124 | if !isRemote { | |
| 119 | 125 | return fmt.Errorf("images commands require a configured remote target") | |
| 120 | 126 | } | |
| @@ -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 previous | 117 | 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 | |||
| 235 | 235 | ||
| 236 | 236 | result, err := cp.Deploy(ctx, pinDomain) | |
| 237 | 237 | if err != nil { | |
| 238 | + | formattedErr := formatDeployFailure(err) | |
| 238 | 239 | // Attempt to revert route to previous image | |
| 239 | 240 | route.Image = oldImage | |
| 240 | 241 | if revertErr := cp.UpdateRoute(ctx, *route); revertErr != nil { | |
| 241 | 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: %w; deploy error: %v", revertErr, formattedErr) | |
| 243 | 244 | } | |
| 244 | - | return fmt.Errorf("failed to deploy (route reverted): %w", err) | |
| 245 | + | return fmt.Errorf("deploy failed; route reverted to previous image: %w", formattedErr) | |
| 245 | 246 | } | |
| 246 | 247 | containerID := shortContainerID(result.ContainerID) | |
| 247 | 248 | 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 image | 239 | // Attempt to revert route to previous image | ||
| 239 | route.Image = oldImage | 240 | 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: %w; deploy error: %v", revertErr, formattedErr) |
| 243 | } | 244 | } | ||
| 244 | - | return fmt.Errorf("failed to deploy (route reverted): %w", err) | 245 | + | return fmt.Errorf("deploy failed; route reverted to previous image: %w", 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| 5 | + | "context" | |
| 5 | 6 | "errors" | |
| 6 | 7 | "testing" | |
| 7 | 8 | ||
| 9 | + | climocks "github.com/bnema/gordon/internal/adapters/in/cli/mocks" | |
| 10 | + | "github.com/bnema/gordon/internal/domain" | |
| 8 | 11 | "github.com/stretchr/testify/assert" | |
| 9 | 12 | ) | |
| 10 | 13 | ||
| @@ -78,3 +81,24 @@ func TestPrintPinTags_ReturnsWriteError(t *testing.T) { | |||
| 78 | 81 | assert.Error(t, err) | |
| 79 | 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 to previous image") | |
| 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 to previous image") | |||
| 104 | + | } | |||
M internal/adapters/in/cli/preview.go +16 -4
| @@ -102,7 +102,10 @@ func newPreviewListCmd() *cobra.Command { | |||
| 102 | 102 | ctx := cmd.Context() | |
| 103 | 103 | out := cmd.OutOrStdout() | |
| 104 | 104 | ||
| 105 | - | client, isRemote := GetRemoteClient() | |
| 105 | + | client, isRemote, err := GetRemoteClient() | |
| 106 | + | if err != nil { | |
| 107 | + | return err | |
| 108 | + | } | |
| 106 | 109 | if !isRemote { | |
| 107 | 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 | 140 | out := cmd.OutOrStdout() | |
| 138 | 141 | name := args[0] | |
| 139 | 142 | ||
| 140 | - | client, isRemote := GetRemoteClient() | |
| 143 | + | client, isRemote, err := GetRemoteClient() | |
| 144 | + | if err != nil { | |
| 145 | + | return err | |
| 146 | + | } | |
| 141 | 147 | if !isRemote { | |
| 142 | 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 | 168 | out := cmd.OutOrStdout() | |
| 163 | 169 | name := args[0] | |
| 164 | 170 | ||
| 165 | - | client, isRemote := GetRemoteClient() | |
| 171 | + | client, isRemote, err := GetRemoteClient() | |
| 172 | + | if err != nil { | |
| 173 | + | return err | |
| 174 | + | } | |
| 166 | 175 | if !isRemote { | |
| 167 | 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 | 324 | return err | |
| 316 | 325 | } | |
| 317 | 326 | ||
| 318 | - | client, isRemote := GetRemoteClient() | |
| 327 | + | client, isRemote, err := GetRemoteClient() | |
| 328 | + | if err != nil { | |
| 329 | + | return err | |
| 330 | + | } | |
| 319 | 331 | if !isRemote { | |
| 320 | 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 | } | |
| @@ -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 err | 324 | 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 | |||
| 54 | 54 | ||
| 55 | 55 | // newImageOpsFromFlags resolves TLS settings from CLI flags/config and creates ops. | |
| 56 | 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 | 61 | return newImageOpsForResolvedRemote(resolved) | |
| 59 | 62 | } | |
| 60 | 63 | ||
| @@ -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+`" | |||
| 193 | 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 | 291 | func TestInferRemoteForAttachmentImage_UsesUniqueMatchingSavedRemote(t *testing.T) { | |
| 197 | 292 | prod := newAttachmentTargetsByImageTestServer(t, func(image string) ([]string, int) { | |
| 198 | 293 | return []string{"app.example.com"}, http.StatusOK | |
| @@ -339,6 +434,7 @@ func newGetRouteTestServer(t *testing.T, handler func(domainName string) (*domai | |||
| 339 | 434 | ||
| 340 | 435 | type multiAdminProbeHandlers struct { | |
| 341 | 436 | getRoute func(domainName string) (*domain.Route, int) | |
| 437 | + | getRouteCleanup func(domainName string) (*domain.CleanupReport, int) | |
| 342 | 438 | getAttachmentsConfig func(target string) ([]string, int) | |
| 343 | 439 | listTags func(repository string) ([]string, int) | |
| 344 | 440 | findAttachmentTarget func(image string) ([]string, int) | |
| @@ -349,6 +445,8 @@ func newMultiAdminProbeTestServer(t *testing.T, handlers multiAdminProbeHandlers | |||
| 349 | 445 | ||
| 350 | 446 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 351 | 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 | 450 | case strings.HasPrefix(r.URL.Path, "/admin/routes/"): | |
| 353 | 451 | handleGetRouteProbe(t, w, r, handlers.getRoute) | |
| 354 | 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 | 532 | func handleAttachmentTargetsByImageProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(image string) ([]string, int)) { | |
| 410 | 533 | t.Helper() | |
| 411 | 534 | 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.StatusOK | 293 | 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 | |||
| 514 | 514 | 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 | 532 | // FindRoutesByImage returns all routes associated with the given image name. | |
| 518 | 533 | func (c *Client) FindRoutesByImage(ctx context.Context, imageName string) ([]domain.Route, error) { | |
| 519 | 534 | 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, nil | 514 | 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 +19 -5
| @@ -241,15 +241,29 @@ func resolveInsecureForTarget(flagInsecure bool, name string, remotes *ClientCon | |||
| 241 | 241 | // Token precedence: flag > GORDON_TOKEN env > named remote (pass > TOML token > token_env). | |
| 242 | 242 | // InsecureTLS precedence: flag > GORDON_INSECURE env > remote entry field. | |
| 243 | 243 | func Resolve(flagRemote, flagToken string, flagInsecure bool) (*ResolvedRemote, bool) { | |
| 244 | - | remotes, _ := LoadRemotes("") | |
| 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) { | |
| 252 | + | remotes, err := LoadRemotes("") | |
| 253 | + | if err != nil { | |
| 254 | + | return nil, false, fmt.Errorf("loading remotes: %w", err) | |
| 255 | + | } | |
| 245 | 256 | ||
| 246 | 257 | name, url, found := resolveTarget(flagRemote, remotes) | |
| 247 | - | if !found && flagRemote != "" && !strings.HasPrefix(flagRemote, "http://") && !strings.HasPrefix(flagRemote, "https://") { | |
| 248 | - | return nil, false | |
| 258 | + | if !found && flagRemote != "" { | |
| 259 | + | return nil, false, fmt.Errorf("remote %q: %w", flagRemote, domain.ErrRemoteNotFound) | |
| 249 | 260 | } | |
| 250 | 261 | if !found { | |
| 251 | 262 | if envRemote := os.Getenv("GORDON_REMOTE"); envRemote != "" { | |
| 252 | 263 | name, url, found = resolveTarget(envRemote, remotes) | |
| 264 | + | if !found { | |
| 265 | + | return nil, false, fmt.Errorf("remote %q from GORDON_REMOTE: %w", envRemote, domain.ErrRemoteNotFound) | |
| 266 | + | } | |
| 253 | 267 | } | |
| 254 | 268 | } | |
| 255 | 269 | if !found && remotes != nil && remotes.Active != "" { | |
| @@ -260,7 +274,7 @@ func Resolve(flagRemote, flagToken string, flagInsecure bool) (*ResolvedRemote, | |||
| 260 | 274 | } | |
| 261 | 275 | } | |
| 262 | 276 | if !found { | |
| 263 | - | return nil, false | |
| 277 | + | return nil, false, nil | |
| 264 | 278 | } | |
| 265 | 279 | ||
| 266 | 280 | return &ResolvedRemote{ | |
| @@ -268,7 +282,7 @@ func Resolve(flagRemote, flagToken string, flagInsecure bool) (*ResolvedRemote, | |||
| 268 | 282 | URL: url, | |
| 269 | 283 | Token: resolveTokenForTarget(flagToken, name, remotes), | |
| 270 | 284 | InsecureTLS: resolveInsecureForTarget(flagInsecure, name, remotes), | |
| 271 | - | }, true | |
| 285 | + | }, true, nil | |
| 272 | 286 | } | |
| 273 | 287 | ||
| 274 | 288 | // resolveTarget checks if value is a saved remote name or an ad-hoc URL. | |
| @@ -241,15 +241,29 @@ 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 | - | remotes, _ := LoadRemotes("") | 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) { | |||
| 252 | + | remotes, err := LoadRemotes("") | |||
| 253 | + | if err != nil { | |||
| 254 | + | return nil, false, fmt.Errorf("loading remotes: %w", err) | |||
| 255 | + | } | |||
| 245 | 256 | ||||
| 246 | name, url, found := resolveTarget(flagRemote, remotes) | 257 | name, url, found := resolveTarget(flagRemote, remotes) | ||
| 247 | - | if !found && flagRemote != "" && !strings.HasPrefix(flagRemote, "http://") && !strings.HasPrefix(flagRemote, "https://") { | 258 | + | if !found && flagRemote != "" { |
| 248 | - | return nil, false | 259 | + | return nil, false, fmt.Errorf("remote %q: %w", flagRemote, domain.ErrRemoteNotFound) |
| 249 | } | 260 | } | ||
| 250 | if !found { | 261 | if !found { | ||
| 251 | if envRemote := os.Getenv("GORDON_REMOTE"); envRemote != "" { | 262 | if envRemote := os.Getenv("GORDON_REMOTE"); envRemote != "" { | ||
| 252 | name, url, found = resolveTarget(envRemote, remotes) | 263 | name, url, found = resolveTarget(envRemote, remotes) | ||
| 264 | + | if !found { | |||
| 265 | + | return nil, false, fmt.Errorf("remote %q from GORDON_REMOTE: %w", envRemote, domain.ErrRemoteNotFound) | |||
| 266 | + | } | |||
| 253 | } | 267 | } | ||
| 254 | } | 268 | } | ||
| 255 | if !found && remotes != nil && remotes.Active != "" { | 269 | if !found && remotes != nil && remotes.Active != "" { | ||
| @@ -260,7 +274,7 @@ func Resolve(flagRemote, flagToken string, flagInsecure bool) (*ResolvedRemote, | |||||
| 260 | } | 274 | } | ||
| 261 | } | 275 | } | ||
| 262 | if !found { | 276 | if !found { | ||
| 263 | - | return nil, false | 277 | + | return nil, false, nil |
| 264 | } | 278 | } | ||
| 265 | 279 | ||||
| 266 | return &ResolvedRemote{ | 280 | return &ResolvedRemote{ | ||
| @@ -268,7 +282,7 @@ func Resolve(flagRemote, flagToken string, flagInsecure bool) (*ResolvedRemote, | |||||
| 268 | URL: url, | 282 | URL: url, | ||
| 269 | Token: resolveTokenForTarget(flagToken, name, remotes), | 283 | Token: resolveTokenForTarget(flagToken, name, remotes), | ||
| 270 | InsecureTLS: resolveInsecureForTarget(flagInsecure, name, remotes), | 284 | InsecureTLS: resolveInsecureForTarget(flagInsecure, name, remotes), | ||
| 271 | - | }, true | 285 | + | }, true, nil |
| 272 | } | 286 | } | ||
| 273 | 287 | ||||
| 274 | // resolveTarget checks if value is a saved remote name or an ad-hoc URL. | 288 | // resolveTarget checks if value is a saved remote name or an ad-hoc URL. | ||
M internal/adapters/in/cli/remote/resolve_test.go +34 -0
| @@ -1,10 +1,15 @@ | |||
| 1 | 1 | package remote | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "errors" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 4 | 7 | "testing" | |
| 5 | 8 | ||
| 6 | 9 | "github.com/stretchr/testify/assert" | |
| 7 | 10 | "github.com/stretchr/testify/require" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/gordon/internal/domain" | |
| 8 | 13 | ) | |
| 9 | 14 | ||
| 10 | 15 | func TestResolve_FlagNameLookup(t *testing.T) { | |
| @@ -202,3 +207,32 @@ func TestResolve_UnknownNameReturnsNotFound(t *testing.T) { | |||
| 202 | 207 | assert.False(t, ok) | |
| 203 | 208 | assert.Nil(t, resolved) | |
| 204 | 209 | } | |
| 210 | + | ||
| 211 | + | func TestResolveStrict_UnknownFlagRemoteWrapsErrRemoteNotFound(t *testing.T) { | |
| 212 | + | t.Setenv("XDG_CONFIG_HOME", t.TempDir()) | |
| 213 | + | t.Setenv("GORDON_REMOTE", "") | |
| 214 | + | t.Setenv("GORDON_TOKEN", "") | |
| 215 | + | ||
| 216 | + | resolved, ok, err := ResolveStrict("nonexistent", "", false) | |
| 217 | + | require.Error(t, err) | |
| 218 | + | assert.False(t, ok) | |
| 219 | + | assert.Nil(t, resolved) | |
| 220 | + | assert.ErrorIs(t, err, domain.ErrRemoteNotFound) | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | func TestResolveStrict_LoadRemotesErrorIsReturned(t *testing.T) { | |
| 224 | + | configHome := t.TempDir() | |
| 225 | + | t.Setenv("XDG_CONFIG_HOME", configHome) | |
| 226 | + | t.Setenv("GORDON_REMOTE", "") | |
| 227 | + | t.Setenv("GORDON_TOKEN", "") | |
| 228 | + | ||
| 229 | + | remotesPath := filepath.Join(configHome, "gordon", "remotes.toml") | |
| 230 | + | require.NoError(t, os.MkdirAll(remotesPath, 0o755)) | |
| 231 | + | ||
| 232 | + | resolved, ok, err := ResolveStrict("", "", false) | |
| 233 | + | require.Error(t, err) | |
| 234 | + | assert.False(t, ok) | |
| 235 | + | assert.Nil(t, resolved) | |
| 236 | + | assert.Contains(t, err.Error(), "loading remotes") | |
| 237 | + | assert.False(t, errors.Is(err, domain.ErrRemoteNotFound)) | |
| 238 | + | } | |
| @@ -1,10 +1,15 @@ | |||||
| 1 | package remote | 1 | package remote | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "errors" | |||
| 5 | + | "os" | |||
| 6 | + | "path/filepath" | |||
| 4 | "testing" | 7 | "testing" | ||
| 5 | 8 | ||||
| 6 | "github.com/stretchr/testify/assert" | 9 | "github.com/stretchr/testify/assert" | ||
| 7 | "github.com/stretchr/testify/require" | 10 | "github.com/stretchr/testify/require" | ||
| 11 | + | ||||
| 12 | + | "github.com/bnema/gordon/internal/domain" | |||
| 8 | ) | 13 | ) | ||
| 9 | 14 | ||||
| 10 | func TestResolve_FlagNameLookup(t *testing.T) { | 15 | func TestResolve_FlagNameLookup(t *testing.T) { | ||
| @@ -202,3 +207,32 @@ func TestResolve_UnknownNameReturnsNotFound(t *testing.T) { | |||||
| 202 | assert.False(t, ok) | 207 | assert.False(t, ok) | ||
| 203 | assert.Nil(t, resolved) | 208 | assert.Nil(t, resolved) | ||
| 204 | } | 209 | } | ||
| 210 | + | ||||
| 211 | + | func TestResolveStrict_UnknownFlagRemoteWrapsErrRemoteNotFound(t *testing.T) { | |||
| 212 | + | t.Setenv("XDG_CONFIG_HOME", t.TempDir()) | |||
| 213 | + | t.Setenv("GORDON_REMOTE", "") | |||
| 214 | + | t.Setenv("GORDON_TOKEN", "") | |||
| 215 | + | ||||
| 216 | + | resolved, ok, err := ResolveStrict("nonexistent", "", false) | |||
| 217 | + | require.Error(t, err) | |||
| 218 | + | assert.False(t, ok) | |||
| 219 | + | assert.Nil(t, resolved) | |||
| 220 | + | assert.ErrorIs(t, err, domain.ErrRemoteNotFound) | |||
| 221 | + | } | |||
| 222 | + | ||||
| 223 | + | func TestResolveStrict_LoadRemotesErrorIsReturned(t *testing.T) { | |||
| 224 | + | configHome := t.TempDir() | |||
| 225 | + | t.Setenv("XDG_CONFIG_HOME", configHome) | |||
| 226 | + | t.Setenv("GORDON_REMOTE", "") | |||
| 227 | + | t.Setenv("GORDON_TOKEN", "") | |||
| 228 | + | ||||
| 229 | + | remotesPath := filepath.Join(configHome, "gordon", "remotes.toml") | |||
| 230 | + | require.NoError(t, os.MkdirAll(remotesPath, 0o755)) | |||
| 231 | + | ||||
| 232 | + | resolved, ok, err := ResolveStrict("", "", false) | |||
| 233 | + | require.Error(t, err) | |||
| 234 | + | assert.False(t, ok) | |||
| 235 | + | assert.Nil(t, resolved) | |||
| 236 | + | assert.Contains(t, err.Error(), "loading remotes") | |||
| 237 | + | assert.False(t, errors.Is(err, domain.ErrRemoteNotFound)) | |||
| 238 | + | } | |||
M internal/adapters/in/cli/remote_inference.go +25 -0
| @@ -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 | 100 | func inferRemoteForAttachmentImage(ctx context.Context, imageName string) (*remote.ResolvedRemote, error) { | |
| 76 | 101 | return inferSavedRemote(ctx, "attachment image", imageName, func(ctx context.Context, client *remote.Client) (bool, error) { | |
| 77 | 102 | 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: | |||
| 172 | 172 | ||
| 173 | 173 | // GetRemoteClient returns a remote client if targeting a remote instance, | |
| 174 | 174 | // 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 | + | } | |
| 177 | 180 | if !isRemote { | |
| 178 | - | return nil, false | |
| 181 | + | return nil, false, nil | |
| 179 | 182 | } | |
| 180 | 183 | ||
| 181 | 184 | opts := remoteClientOptions(resolved.Token, resolved.InsecureTLS) | |
| 182 | 185 | client := remote.NewClient(resolved.URL, opts...) | |
| 183 | - | return client, true | |
| 186 | + | return client, true, nil | |
| 184 | 187 | } | |
| 185 | 188 | ||
| 186 | 189 | func remoteClientOptions(token string, insecureTLS bool) []remote.ClientOption { | |
| @@ -196,8 +199,8 @@ func remoteClientOptions(token string, insecureTLS bool) []remote.ClientOption { | |||
| 196 | 199 | ||
| 197 | 200 | // IsRemoteMode returns true if CLI is targeting a remote Gordon instance. | |
| 198 | 201 | 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 | |
| 201 | 204 | } | |
| 202 | 205 | ||
| 203 | 206 | // newReloadCmd creates the reload command. | |
| @@ -211,7 +214,10 @@ a running container. Running containers are never restarted to ensure 100% uptim | |||
| 211 | 214 | Use this command after editing config.toml to add new routes, or after pushing | |
| 212 | 215 | images to the registry when the route was not yet configured.`, | |
| 213 | 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 | 221 | if isRemote { | |
| 216 | 222 | return runReloadRemote(cmd.Context(), client) | |
| 217 | 223 | } | |
| @@ -321,7 +327,10 @@ func runLogs(ctx context.Context, logsConfigPath, logDomain string, follow bool, | |||
| 321 | 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 | 334 | if isRemote { | |
| 326 | 335 | return runLogsRemote(ctx, client, logDomain, follow, lines, out) | |
| 327 | 336 | } | |
| @@ -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, false | 181 | + | 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, true | 186 | + | 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 isRemote | 203 | + | 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 pushing | 214 | 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 +116 -20
| @@ -127,15 +127,16 @@ type routeStatusSection struct { | |||
| 127 | 127 | } | |
| 128 | 128 | ||
| 129 | 129 | 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"` | |
| 139 | 140 | } | |
| 140 | 141 | ||
| 141 | 142 | type routesListDeps struct { | |
| @@ -787,13 +788,13 @@ func loadRoutesListLocalSection(ctx context.Context, cfgPath string) (routeListS | |||
| 787 | 788 | func loadRoutesListRemoteSection(ctx context.Context, name string, entry remote.RemoteEntry) (routeListSection, error) { | |
| 788 | 789 | section := routeListSection{Kind: "remote", Name: name, URL: entry.URL} | |
| 789 | 790 | client := newRoutesTargetClient(name, entry) | |
| 790 | - | routes, err := client.ListRoutesWithDetails(ctx) | |
| 791 | + | routes, err := client.ListRoutes(ctx) | |
| 791 | 792 | if err != nil { | |
| 792 | 793 | section.Error = err.Error() | |
| 793 | 794 | return section, nil | |
| 794 | 795 | } | |
| 795 | 796 | ||
| 796 | - | section.Routes = routeListItemsFromInfos(routes) | |
| 797 | + | section.Routes = routeListItemsFromRoutes(routes) | |
| 797 | 798 | return section, nil | |
| 798 | 799 | } | |
| 799 | 800 | ||
| @@ -885,7 +886,7 @@ func routeStatusItemFromInfo(route remote.RouteInfo, routeHealth *remote.RouteHe | |||
| 885 | 886 | return item | |
| 886 | 887 | } | |
| 887 | 888 | ||
| 888 | - | func routeListItemsFromInfos(routes []remote.RouteInfo) []routeListItem { | |
| 889 | + | func routeListItemsFromRoutes(routes []domain.Route) []routeListItem { | |
| 889 | 890 | items := make([]routeListItem, 0, len(routes)) | |
| 890 | 891 | for _, route := range routes { | |
| 891 | 892 | items = append(items, routeListItem{Domain: route.Domain, Image: route.Image}) | |
| @@ -1105,7 +1106,7 @@ func newRoutesDiagnoseCmd() *cobra.Command { | |||
| 1105 | 1106 | } | |
| 1106 | 1107 | ||
| 1107 | 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 | 1110 | if err != nil { | |
| 1110 | 1111 | return err | |
| 1111 | 1112 | } | |
| @@ -1123,6 +1124,7 @@ func runRouteDiagnosis(ctx context.Context, domainName string, out io.Writer, js | |||
| 1123 | 1124 | func buildRouteDiagnosis(ctx context.Context, cp ControlPlane, domainName string) (*routeDiagnosis, error) { | |
| 1124 | 1125 | diag := &routeDiagnosis{Domain: domainName} | |
| 1125 | 1126 | loadRouteDiagnosisConfig(ctx, cp, domainName, diag) | |
| 1127 | + | loadRouteDiagnosisCleanupPreview(ctx, cp, domainName, diag) | |
| 1126 | 1128 | loadRouteDiagnosisRuntime(ctx, cp, domainName, diag) | |
| 1127 | 1129 | loadRouteDiagnosisHealth(ctx, cp, domainName, diag) | |
| 1128 | 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 | 1181 | func loadRouteDiagnosisRuntime(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) { | |
| 1147 | 1182 | routes, err := cp.ListRoutesWithDetails(ctx) | |
| 1148 | 1183 | if err != nil { | |
| @@ -1165,6 +1200,9 @@ func loadRouteDiagnosisHealth(ctx context.Context, cp ControlPlane, domainName s | |||
| 1165 | 1200 | } | |
| 1166 | 1201 | ||
| 1167 | 1202 | func loadRouteDiagnosisVolumes(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) { | |
| 1203 | + | if len(diag.Volumes) > 0 { | |
| 1204 | + | return | |
| 1205 | + | } | |
| 1168 | 1206 | if volumes, err := cp.ListVolumes(ctx); err == nil { | |
| 1169 | 1207 | if len(volumes) == 0 { | |
| 1170 | 1208 | return | |
| @@ -1184,6 +1222,9 @@ func routeDiagnosisVolumePrefix(ctx context.Context, cp ControlPlane) string { | |||
| 1184 | 1222 | } | |
| 1185 | 1223 | ||
| 1186 | 1224 | func loadRouteDiagnosisOrphanedAttachments(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) { | |
| 1225 | + | if len(diag.OrphanedAttachments) > 0 { | |
| 1226 | + | return | |
| 1227 | + | } | |
| 1187 | 1228 | lister, ok := cp.(interface { | |
| 1188 | 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 | 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 | 1249 | diag.Warnings = append(diag.Warnings, "route is not configured but runtime state still exists") | |
| 1201 | 1250 | } | |
| 1202 | 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 | 1268 | type routeResourceScope struct { | |
| 1211 | 1269 | containerNames map[string]struct{} | |
| 1212 | 1270 | volumePrefixes []string | |
| @@ -1338,6 +1396,21 @@ func writeRouteDiagnosisSummary(out io.Writer, diag *routeDiagnosis) error { | |||
| 1338 | 1396 | } | |
| 1339 | 1397 | ||
| 1340 | 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 | 1414 | volumeLabel := "Volume:" | |
| 1342 | 1415 | if !diag.Configured { | |
| 1343 | 1416 | volumeLabel = "Preserved volume:" | |
| @@ -1382,7 +1455,7 @@ type routePurgeOptions struct { | |||
| 1382 | 1455 | } | |
| 1383 | 1456 | ||
| 1384 | 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 | 1459 | if err != nil { | |
| 1387 | 1460 | return err | |
| 1388 | 1461 | } | |
| @@ -1406,6 +1479,7 @@ func runRoutePurge(ctx context.Context, cp ControlPlane, domainName string, opts | |||
| 1406 | 1479 | if diag.Runtime != nil && diag.Runtime.ContainerID != "" { | |
| 1407 | 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 = appendUniqueCleanupEntities(report.OrphanedEntities, diag.OrphanedEntities...) | |
| 1409 | 1483 | if opts.Volumes { | |
| 1410 | 1484 | for _, volume := range diag.Volumes { | |
| 1411 | 1485 | report.PreservedVolumes = append(report.PreservedVolumes, domain.CleanupVolume{Name: volume.Name, Reason: "volume purge requires explicit --volumes and runtime support"}) | |
| @@ -1442,6 +1516,26 @@ func runRoutePurge(ctx context.Context, cp ControlPlane, domainName string, opts | |||
| 1442 | 1516 | return report, nil | |
| 1443 | 1517 | } | |
| 1444 | 1518 | ||
| 1519 | + | func appendUniqueCleanupEntities(existing []domain.CleanupOrphanedEntity, candidates ...domain.CleanupOrphanedEntity) []domain.CleanupOrphanedEntity { | |
| 1520 | + | seen := make(map[string]struct{}, len(existing)) | |
| 1521 | + | for _, entity := range existing { | |
| 1522 | + | seen[cleanupEntityKey(entity)] = struct{}{} | |
| 1523 | + | } | |
| 1524 | + | for _, entity := range candidates { | |
| 1525 | + | key := cleanupEntityKey(entity) | |
| 1526 | + | if _, ok := seen[key]; ok { | |
| 1527 | + | continue | |
| 1528 | + | } | |
| 1529 | + | existing = append(existing, entity) | |
| 1530 | + | seen[key] = struct{}{} | |
| 1531 | + | } | |
| 1532 | + | return existing | |
| 1533 | + | } | |
| 1534 | + | ||
| 1535 | + | func cleanupEntityKey(entity domain.CleanupOrphanedEntity) string { | |
| 1536 | + | return entity.Kind + "|" + entity.ID + "|" + entity.Name | |
| 1537 | + | } | |
| 1538 | + | ||
| 1445 | 1539 | func writeRoutePurgeText(out io.Writer, report *domain.CleanupReport, force bool) error { | |
| 1446 | 1540 | mode := "Purge dry-run" | |
| 1447 | 1541 | if force { | |
| @@ -1485,7 +1579,7 @@ Examples: | |||
| 1485 | 1579 | gordon --remote https://gordon.mydomain.com routes add api.mydomain.com api:v2`, | |
| 1486 | 1580 | Args: cobra.RangeArgs(1, 2), | |
| 1487 | 1581 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 1488 | - | ctx := context.Background() | |
| 1582 | + | ctx := cmd.Context() | |
| 1489 | 1583 | routeDomain := args[0] | |
| 1490 | 1584 | ||
| 1491 | 1585 | // Resolve image from positional argument or flag | |
| @@ -1511,7 +1605,10 @@ Examples: | |||
| 1511 | 1605 | Image: image, | |
| 1512 | 1606 | } | |
| 1513 | 1607 | ||
| 1514 | - | client, isRemote := GetRemoteClient() | |
| 1608 | + | client, isRemote, err := GetRemoteClient() | |
| 1609 | + | if err != nil { | |
| 1610 | + | return err | |
| 1611 | + | } | |
| 1515 | 1612 | if isRemote { | |
| 1516 | 1613 | if err := client.AddRoute(ctx, route); err != nil { | |
| 1517 | 1614 | return fmt.Errorf("failed to add route: %w", err) | |
| @@ -1526,8 +1623,7 @@ Examples: | |||
| 1526 | 1623 | } | |
| 1527 | 1624 | } | |
| 1528 | 1625 | ||
| 1529 | - | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Route configured: %s -> %s", routeDomain, image))) | |
| 1530 | - | return nil | |
| 1626 | + | return cliWriteLine(cmd.OutOrStdout(), styles.RenderSuccess(fmt.Sprintf("Route configured: %s -> %s", routeDomain, image))) | |
| 1531 | 1627 | }, | |
| 1532 | 1628 | } | |
| 1533 | 1629 | ||
| @@ -1568,7 +1664,7 @@ Examples: | |||
| 1568 | 1664 | } | |
| 1569 | 1665 | } | |
| 1570 | 1666 | ||
| 1571 | - | handle, err := resolveControlPlaneForRouteDomain(ctx, routeDomain) | |
| 1667 | + | handle, err := resolveControlPlaneForRouteCleanupDomain(ctx, routeDomain) | |
| 1572 | 1668 | if err != nil { | |
| 1573 | 1669 | return err | |
| 1574 | 1670 | } | |
| @@ -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, nil | 794 | return section, nil | ||
| 794 | } | 795 | } | ||
| 795 | 796 | ||||
| 796 | - | section.Routes = routeListItemsFromInfos(routes) | 797 | + | section.Routes = routeListItemsFromRoutes(routes) |
| 797 | return section, nil | 798 | return section, nil | ||
| 798 | } | 799 | } | ||
| 799 | 800 | ||||
| @@ -885,7 +886,7 @@ func routeStatusItemFromInfo(route remote.RouteInfo, routeHealth *remote.RouteHe | |||||
| 885 | return item | 886 | 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 err | 1111 | 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 | return | 1208 | 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 []string | 1270 | 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 err | 1460 | 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 = appendUniqueCleanupEntities(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"}) | ||
| @@ -1442,6 +1516,26 @@ func runRoutePurge(ctx context.Context, cp ControlPlane, domainName string, opts | |||||
| 1442 | return report, nil | 1516 | return report, nil | ||
| 1443 | } | 1517 | } | ||
| 1444 | 1518 | ||||
| 1519 | + | func appendUniqueCleanupEntities(existing []domain.CleanupOrphanedEntity, candidates ...domain.CleanupOrphanedEntity) []domain.CleanupOrphanedEntity { | |||
| 1520 | + | seen := make(map[string]struct{}, len(existing)) | |||
| 1521 | + | for _, entity := range existing { | |||
| 1522 | + | seen[cleanupEntityKey(entity)] = struct{}{} | |||
| 1523 | + | } | |||
| 1524 | + | for _, entity := range candidates { | |||
| 1525 | + | key := cleanupEntityKey(entity) | |||
| 1526 | + | if _, ok := seen[key]; ok { | |||
| 1527 | + | continue | |||
| 1528 | + | } | |||
| 1529 | + | existing = append(existing, entity) | |||
| 1530 | + | seen[key] = struct{}{} | |||
| 1531 | + | } | |||
| 1532 | + | return existing | |||
| 1533 | + | } | |||
| 1534 | + | ||||
| 1535 | + | func cleanupEntityKey(entity domain.CleanupOrphanedEntity) string { | |||
| 1536 | + | return entity.Kind + "|" + entity.ID + "|" + entity.Name | |||
| 1537 | + | } | |||
| 1538 | + | ||||
| 1445 | func writeRoutePurgeText(out io.Writer, report *domain.CleanupReport, force bool) error { | 1539 | func writeRoutePurgeText(out io.Writer, report *domain.CleanupReport, force bool) error { | ||
| 1446 | mode := "Purge dry-run" | 1540 | mode := "Purge dry-run" | ||
| 1447 | if force { | 1541 | if force { | ||
| @@ -1485,7 +1579,7 @@ Examples: | |||||
| 1485 | gordon --remote https://gordon.mydomain.com routes add api.mydomain.com api:v2`, | 1579 | gordon --remote https://gordon.mydomain.com routes add api.mydomain.com api:v2`, | ||
| 1486 | Args: cobra.RangeArgs(1, 2), | 1580 | Args: cobra.RangeArgs(1, 2), | ||
| 1487 | RunE: func(cmd *cobra.Command, args []string) error { | 1581 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 1488 | - | ctx := context.Background() | 1582 | + | ctx := cmd.Context() |
| 1489 | routeDomain := args[0] | 1583 | routeDomain := args[0] | ||
| 1490 | 1584 | ||||
| 1491 | // Resolve image from positional argument or flag | 1585 | // Resolve image from positional argument or flag | ||
| @@ -1511,7 +1605,10 @@ Examples: | |||||
| 1511 | Image: image, | 1605 | Image: image, | ||
| 1512 | } | 1606 | } | ||
| 1513 | 1607 | ||||
| 1514 | - | client, isRemote := GetRemoteClient() | 1608 | + | client, isRemote, err := GetRemoteClient() |
| 1609 | + | if err != nil { | |||
| 1610 | + | return err | |||
| 1611 | + | } | |||
| 1515 | if isRemote { | 1612 | if isRemote { | ||
| 1516 | if err := client.AddRoute(ctx, route); err != nil { | 1613 | if err := client.AddRoute(ctx, route); err != nil { | ||
| 1517 | return fmt.Errorf("failed to add route: %w", err) | 1614 | return fmt.Errorf("failed to add route: %w", err) | ||
| @@ -1526,8 +1623,7 @@ Examples: | |||||
| 1526 | } | 1623 | } | ||
| 1527 | } | 1624 | } | ||
| 1528 | 1625 | ||||
| 1529 | - | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Route configured: %s -> %s", routeDomain, image))) | 1626 | + | return cliWriteLine(cmd.OutOrStdout(), styles.RenderSuccess(fmt.Sprintf("Route configured: %s -> %s", routeDomain, image))) |
| 1530 | - | return nil | |||
| 1531 | }, | 1627 | }, | ||
| 1532 | } | 1628 | } | ||
| 1533 | 1629 | ||||
| @@ -1568,7 +1664,7 @@ Examples: | |||||
| 1568 | } | 1664 | } | ||
| 1569 | } | 1665 | } | ||
| 1570 | 1666 | ||||
| 1571 | - | handle, err := resolveControlPlaneForRouteDomain(ctx, routeDomain) | 1667 | + | handle, err := resolveControlPlaneForRouteCleanupDomain(ctx, routeDomain) |
| 1572 | if err != nil { | 1668 | if err != nil { | ||
| 1573 | return err | 1669 | return err | ||
| 1574 | } | 1670 | } | ||
M internal/adapters/in/cli/routes_test.go +64 -0
| @@ -809,6 +809,30 @@ func TestRenderRoutesListSections_HidesLocalErrorWhenRemoteIsUsable(t *testing.T | |||
| 809 | 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 | 836 | func TestRenderRoutesStatusSections_HidesLocalErrorWhenRemoteIsUsable(t *testing.T) { | |
| 813 | 837 | sections := []routeStatusSection{ | |
| 814 | 838 | {Kind: "local", Name: "local", Error: "failed to initialize local control plane"}, | |
| @@ -931,6 +955,8 @@ func TestBuildRouteDiagnosis_UsesConfiguredVolumePrefixForPreservedVolumes(t *te | |||
| 931 | 955 | case "/admin/routes/example.test": | |
| 932 | 956 | w.WriteHeader(http.StatusNotFound) | |
| 933 | 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 | 960 | case "/admin/routes": | |
| 935 | 961 | require.Equal(t, "detailed=true", r.URL.RawQuery) | |
| 936 | 962 | _, _ = w.Write([]byte(`{"routes":[]}`)) | |
| @@ -984,6 +1010,40 @@ func TestBuildRouteDiagnosis_IncludesAttachmentVolumesWithDoubleUnderscoreOwnerE | |||
| 984 | 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 | 1047 | func TestBuildRouteDiagnosis_RemoteMissingRouteDoesNotWarnOnExpectedNotFound(t *testing.T) { | |
| 988 | 1048 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 989 | 1049 | w.Header().Set("Content-Type", "application/json") | |
| @@ -991,6 +1051,8 @@ func TestBuildRouteDiagnosis_RemoteMissingRouteDoesNotWarnOnExpectedNotFound(t * | |||
| 991 | 1051 | case "/admin/routes/missing.example.test": | |
| 992 | 1052 | w.WriteHeader(http.StatusNotFound) | |
| 993 | 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 | 1056 | case "/admin/routes": | |
| 995 | 1057 | require.Equal(t, "detailed=true", r.URL.RawQuery) | |
| 996 | 1058 | _, _ = w.Write([]byte(`{"routes":[]}`)) | |
| @@ -1021,6 +1083,8 @@ func TestRunRoutePurge_VolumesUsesConfiguredVolumePrefix(t *testing.T) { | |||
| 1021 | 1083 | case "/admin/routes/example.test": | |
| 1022 | 1084 | w.WriteHeader(http.StatusNotFound) | |
| 1023 | 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 | 1088 | case "/admin/routes": | |
| 1025 | 1089 | require.Equal(t, "detailed=true", r.URL.RawQuery) | |
| 1026 | 1090 | _, _ = 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 | |||
| 628 | 628 | 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 | 640 | if parentDomain, ok := strings.CutSuffix(routeDomain, "/attachments"); ok { | |
| 632 | 641 | if parentDomain == "" { | |
| 633 | 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 | 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 | 807 | func (h *Handler) handleRoutesPost(w http.ResponseWriter, r *http.Request) { | |
| 654 | 808 | ctx := r.Context() | |
| 655 | 809 | log := zerowrap.FromCtx(ctx) | |
| @@ -823,12 +977,18 @@ func (h *Handler) handleRoutesByImage(w http.ResponseWriter, r *http.Request, pa | |||
| 823 | 977 | return | |
| 824 | 978 | } | |
| 825 | 979 | ||
| 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" { | |
| 828 | 982 | h.sendError(w, http.StatusBadRequest, "image name required in path") | |
| 829 | 983 | 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 | 992 | routes := h.configSvc.FindRoutesByImage(ctx, imageName) | |
| 833 | 993 | ||
| 834 | 994 | response := make([]routeResponse, 0, len(routes)) | |
| @@ -1519,11 +1679,16 @@ func (h *Handler) handleTags(w http.ResponseWriter, r *http.Request, path string | |||
| 1519 | 1679 | return | |
| 1520 | 1680 | } | |
| 1521 | 1681 | ||
| 1522 | - | repository := strings.TrimPrefix(path, "/tags/") | |
| 1523 | - | if repository == "" || repository == "/tags" { | |
| 1682 | + | rawRepository := strings.TrimPrefix(path, "/tags/") | |
| 1683 | + | if rawRepository == "" || rawRepository == "/tags" { | |
| 1524 | 1684 | h.sendError(w, http.StatusBadRequest, "repository name required in path") | |
| 1525 | 1685 | 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 | 1692 | if err := validation.ValidateRepositoryName(repository); err != nil { | |
| 1528 | 1693 | h.sendError(w, http.StatusBadRequest, "invalid repository name") | |
| 1529 | 1694 | return | |
| @@ -628,6 +628,15 @@ func (h *Handler) handleRoutesGet(w http.ResponseWriter, r *http.Request, routeD | |||||
| 628 | return | 628 | 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 | return | 977 | 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 | return | 983 | 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 | return | 1679 | 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 | return | 1685 | 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 | return | 1694 | return | ||
M internal/adapters/in/http/admin/handler_test.go +119 -0
| @@ -29,6 +29,18 @@ type stubImageService struct { | |||
| 29 | 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 | 44 | type noopReloadTrigger struct{} | |
| 33 | 45 | ||
| 34 | 46 | func (noopReloadTrigger) Trigger(context.Context) error { return nil } | |
| @@ -687,6 +699,34 @@ func TestHandler_AttachmentsByImageGet_RequiresReadScope(t *testing.T) { | |||
| 687 | 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 | 730 | func TestHandler_RoutesDelete_RequiresWriteScope(t *testing.T) { | |
| 691 | 731 | configSvc := inmocks.NewMockConfigService(t) | |
| 692 | 732 | authSvc := inmocks.NewMockAuthService(t) | |
| @@ -1566,6 +1606,58 @@ func TestHandler_RoutesGet_NotFound(t *testing.T) { | |||
| 1566 | 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 | 1661 | func TestHandler_RoutesPost_MissingFields(t *testing.T) { | |
| 1570 | 1662 | tests := []struct { | |
| 1571 | 1663 | name string | |
| @@ -2093,6 +2185,33 @@ func TestHandler_Tags_Error(t *testing.T) { | |||
| 2093 | 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 | 2215 | func TestHandler_AttachmentSecretsPost_RequiresWriteScope(t *testing.T) { | |
| 2097 | 2216 | configSvc := inmocks.NewMockConfigService(t) | |
| 2098 | 2217 | 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 string | 1663 | 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, | |||
| 516 | 516 | } | |
| 517 | 517 | ||
| 518 | 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 | 521 | si.initHandlers() | |
| 521 | 522 | ||
| @@ -695,6 +696,21 @@ func (si *serviceInit) initRuntimeAndProxy() error { | |||
| 695 | 696 | } | |
| 696 | 697 | ||
| 697 | 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(reloadCtx context.Context, reloadCfg Config) error { | |
| 705 | + | containerCfg, err := buildContainerServiceConfig(reloadCtx, 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 | 714 | func (si *serviceInit) initHandlers() { | |
| 699 | 715 | if si.svc.authSvc != nil { | |
| 700 | 716 | internalAuth := authhandler.InternalAuth{ | |
| @@ -1608,10 +1624,11 @@ type reloadCoordinator struct { | |||
| 1608 | 1624 | lastRun time.Time | |
| 1609 | 1625 | debounce time.Duration | |
| 1610 | 1626 | ||
| 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(context.Context, Config) error | |
| 1631 | + | registryLimits interface { | |
| 1615 | 1632 | UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64) | |
| 1616 | 1633 | } | |
| 1617 | 1634 | eventBus out.EventPublisher | |
| @@ -1642,6 +1659,12 @@ func (c *reloadCoordinator) SetRegistryLimits(limits interface { | |||
| 1642 | 1659 | c.registryLimits = limits | |
| 1643 | 1660 | } | |
| 1644 | 1661 | ||
| 1662 | + | func (c *reloadCoordinator) SetContainerConfigApplier(apply func(context.Context, Config) error) { | |
| 1663 | + | c.mu.Lock() | |
| 1664 | + | defer c.mu.Unlock() | |
| 1665 | + | c.applyContainerConfig = apply | |
| 1666 | + | } | |
| 1667 | + | ||
| 1645 | 1668 | func (c *reloadCoordinator) Trigger(ctx context.Context) error { | |
| 1646 | 1669 | c.mu.Lock() | |
| 1647 | 1670 | defer c.mu.Unlock() | |
| @@ -1690,6 +1713,12 @@ func (c *reloadCoordinator) applyLoadedConfig(ctx context.Context, now time.Time | |||
| 1690 | 1713 | return fmt.Errorf("failed to parse proxy config on reload: %w", err) | |
| 1691 | 1714 | } | |
| 1692 | 1715 | ||
| 1716 | + | if c.applyContainerConfig != nil { | |
| 1717 | + | if err := c.applyContainerConfig(ctx, reloadCfg); err != nil { | |
| 1718 | + | c.log.Error().Err(err).Msg("failed to apply container config on reload") | |
| 1719 | + | return fmt.Errorf("failed to apply container config on reload: %w", err) | |
| 1720 | + | } | |
| 1721 | + | } | |
| 1693 | 1722 | c.proxySvc.UpdateConfig(reloadedProxy.proxyConfig) | |
| 1694 | 1723 | if c.registryLimits != nil { | |
| 1695 | 1724 | c.registryLimits.UpdateBlobLimits(reloadedProxy.maxBlobChunkSize, reloadedProxy.maxBlobSize) | |
| @@ -1813,23 +1842,21 @@ func buildDNSConfig(cfg Config) (publictls.DNSConfig, error) { | |||
| 1813 | 1842 | return dnsCfg, nil | |
| 1814 | 1843 | } | |
| 1815 | 1844 | ||
| 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) { | |
| 1819 | 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 | 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 | 1852 | var defaultMemoryLimit int64 | |
| 1826 | 1853 | if cfg.Containers.MemoryLimit != "" { | |
| 1827 | 1854 | parsed, err := bytesize.Parse(cfg.Containers.MemoryLimit) | |
| 1828 | 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 | 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 | 1861 | defaultMemoryLimit = parsed | |
| 1835 | 1862 | } | |
| @@ -1881,17 +1908,15 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc | |||
| 1881 | 1908 | ||
| 1882 | 1909 | if containerConfig.RegistryAuthEnabled { | |
| 1883 | 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 | 1913 | expiry, err := resolveServiceTokenExpiry(cfg) | |
| 1887 | 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 | 1917 | serviceToken, err := svc.authSvc.GenerateToken(ctx, serviceTokenSubject, []string{"pull"}, expiry) | |
| 1893 | 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 | 1921 | log.Info(). | |
| 1897 | 1922 | Str("subject", serviceTokenSubject). | |
| @@ -1903,6 +1928,15 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc | |||
| 1903 | 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 | 1940 | return container.NewService(svc.runtime, svc.envLoader, svc.eventBus, svc.logWriter, containerConfig, svc.configSvc), nil | |
| 1907 | 1941 | } | |
| 1908 | 1942 | ||
| @@ -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(reloadCtx context.Context, reloadCfg Config) error { | |||
| 705 | + | containerCfg, err := buildContainerServiceConfig(reloadCtx, 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.Time | 1624 | lastRun time.Time | ||
| 1609 | debounce time.Duration | 1625 | debounce time.Duration | ||
| 1610 | 1626 | ||||
| 1611 | - | configSvc configReloader | 1627 | + | configSvc configReloader |
| 1612 | - | v *viper.Viper | 1628 | + | v *viper.Viper |
| 1613 | - | proxySvc proxyConfigUpdater | 1629 | + | proxySvc proxyConfigUpdater |
| 1614 | - | registryLimits interface { | 1630 | + | applyContainerConfig func(context.Context, Config) error |
| 1631 | + | registryLimits interface { | |||
| 1615 | UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64) | 1632 | UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64) | ||
| 1616 | } | 1633 | } | ||
| 1617 | eventBus out.EventPublisher | 1634 | eventBus out.EventPublisher | ||
| @@ -1642,6 +1659,12 @@ func (c *reloadCoordinator) SetRegistryLimits(limits interface { | |||||
| 1642 | c.registryLimits = limits | 1659 | c.registryLimits = limits | ||
| 1643 | } | 1660 | } | ||
| 1644 | 1661 | ||||
| 1662 | + | func (c *reloadCoordinator) SetContainerConfigApplier(apply func(context.Context, 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() | ||
| @@ -1690,6 +1713,12 @@ func (c *reloadCoordinator) applyLoadedConfig(ctx context.Context, now time.Time | |||||
| 1690 | return fmt.Errorf("failed to parse proxy config on reload: %w", err) | 1713 | return fmt.Errorf("failed to parse proxy config on reload: %w", err) | ||
| 1691 | } | 1714 | } | ||
| 1692 | 1715 | ||||
| 1716 | + | if c.applyContainerConfig != nil { | |||
| 1717 | + | if err := c.applyContainerConfig(ctx, reloadCfg); err != nil { | |||
| 1718 | + | c.log.Error().Err(err).Msg("failed to apply container config on reload") | |||
| 1719 | + | return fmt.Errorf("failed to apply container config on reload: %w", err) | |||
| 1720 | + | } | |||
| 1721 | + | } | |||
| 1693 | c.proxySvc.UpdateConfig(reloadedProxy.proxyConfig) | 1722 | c.proxySvc.UpdateConfig(reloadedProxy.proxyConfig) | ||
| 1694 | if c.registryLimits != nil { | 1723 | if c.registryLimits != nil { | ||
| 1695 | c.registryLimits.UpdateBlobLimits(reloadedProxy.maxBlobChunkSize, reloadedProxy.maxBlobSize) | 1724 | c.registryLimits.UpdateBlobLimits(reloadedProxy.maxBlobChunkSize, reloadedProxy.maxBlobSize) | ||
| @@ -1813,23 +1842,21 @@ func buildDNSConfig(cfg Config) (publictls.DNSConfig, error) { | |||||
| 1813 | return dnsCfg, nil | 1842 | 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 int64 | 1852 | 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 = parsed | 1861 | 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), nil | 1940 | 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 +56 -0
| @@ -14,6 +14,8 @@ import ( | |||
| 14 | 14 | ||
| 15 | 15 | "github.com/bnema/gordon/internal/adapters/in/http/registry" | |
| 16 | 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 | 19 | "github.com/bnema/gordon/internal/usecase/proxy" | |
| 18 | 20 | ) | |
| 19 | 21 | ||
| @@ -112,6 +114,19 @@ func (r *registryLimitsRecorder) UpdateBlobLimits(maxBlobChunkSize, maxBlobSize | |||
| 112 | 114 | r.maxBlobSize = maxBlobSize | |
| 113 | 115 | } | |
| 114 | 116 | ||
| 117 | + | type containerConfigApplyRecorder struct { | |
| 118 | + | calls int | |
| 119 | + | ctx context.Context | |
| 120 | + | cfg Config | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func (r *containerConfigApplyRecorder) Apply(ctx context.Context, cfg Config) error { | |
| 124 | + | r.calls++ | |
| 125 | + | r.ctx = ctx | |
| 126 | + | r.cfg = cfg | |
| 127 | + | return nil | |
| 128 | + | } | |
| 129 | + | ||
| 115 | 130 | type eventBusRecorder struct { | |
| 116 | 131 | mu sync.Mutex | |
| 117 | 132 | calls int | |
| @@ -179,22 +194,30 @@ func TestReloadCoordinator_ApplyLoadedConfig_RebuildsProxyConfigAndPublishesEven | |||
| 179 | 194 | v.Set("server.max_blob_size", "8MB") | |
| 180 | 195 | v.Set("server.max_proxy_response_size", "7MB") | |
| 181 | 196 | v.Set("server.max_concurrent_connections", 99) | |
| 197 | + | v.Set("server.legacy_registry_domains", []string{"old.example.com"}) | |
| 198 | + | v.Set("images.allowed_registries", []string{"docker.io"}) | |
| 182 | 199 | ||
| 183 | 200 | reloadSvc := &reloadRecorder{} | |
| 184 | 201 | proxySvc := &proxyRecorder{} | |
| 185 | 202 | events := &eventBusRecorder{} | |
| 203 | + | containerCfg := &containerConfigApplyRecorder{} | |
| 186 | 204 | ||
| 187 | 205 | registryLimits := ®istryLimitsRecorder{} | |
| 188 | 206 | coord := newReloadCoordinator(v, reloadSvc, proxySvc, registryLimits, events, nil, zerowrap.Default()) | |
| 207 | + | coord.SetContainerConfigApplier(containerCfg.Apply) | |
| 189 | 208 | require.NoError(t, coord.ApplyLoadedConfig(ctx)) | |
| 190 | 209 | ||
| 191 | 210 | require.Equal(t, 0, reloadSvc.Calls()) | |
| 192 | 211 | require.Equal(t, 1, proxySvc.calls) | |
| 193 | 212 | require.Equal(t, 1, events.Calls()) | |
| 194 | 213 | require.Equal(t, 1, registryLimits.calls) | |
| 214 | + | require.Equal(t, 1, containerCfg.calls) | |
| 195 | 215 | require.Equal(t, int64(6<<20), registryLimits.maxBlobChunkSize) | |
| 196 | 216 | require.Equal(t, int64(8<<20), registryLimits.maxBlobSize) | |
| 197 | 217 | require.Equal(t, domain.EventConfigReload, events.eventType) | |
| 218 | + | require.Equal(t, "reload.example.com", containerCfg.cfg.Server.GordonDomain) | |
| 219 | + | require.Equal(t, []string{"old.example.com"}, containerCfg.cfg.Server.LegacyRegistryDomains) | |
| 220 | + | require.Equal(t, []string{"docker.io"}, containerCfg.cfg.Images.AllowedRegistries) | |
| 198 | 221 | require.Equal(t, proxy.Config{ | |
| 199 | 222 | RegistryDomain: "reload.example.com", | |
| 200 | 223 | RegistryPort: 5000, | |
| @@ -204,6 +227,39 @@ func TestReloadCoordinator_ApplyLoadedConfig_RebuildsProxyConfigAndPublishesEven | |||
| 204 | 227 | }, proxySvc.config) | |
| 205 | 228 | } | |
| 206 | 229 | ||
| 230 | + | func TestServiceInit_RegisterReloadCoordinatorHooks_WiresContainerConfigApplier(t *testing.T) { | |
| 231 | + | ctx := context.Background() | |
| 232 | + | v := viper.New() | |
| 233 | + | v.Set("server.gordon_domain", "reload.example.com") | |
| 234 | + | v.Set("server.registry_port", 5000) | |
| 235 | + | ||
| 236 | + | configSvc := cfgusecase.NewService(v, nil) | |
| 237 | + | require.NoError(t, configSvc.Load(ctx)) | |
| 238 | + | ||
| 239 | + | si := &serviceInit{ | |
| 240 | + | ctx: ctx, | |
| 241 | + | v: v, | |
| 242 | + | cfg: Config{}, | |
| 243 | + | log: zerowrap.Default(), | |
| 244 | + | svc: &services{ | |
| 245 | + | configSvc: configSvc, | |
| 246 | + | containerSvc: container.NewService(nil, nil, nil, nil, container.Config{}, configSvc), | |
| 247 | + | internalRegUser: "gordon", | |
| 248 | + | internalRegPass: "secret", | |
| 249 | + | reloadCoordinator: newReloadCoordinator(v, &reloadRecorder{}, &proxyRecorder{}, nil, nil, nil, zerowrap.Default()), | |
| 250 | + | }, | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | si.registerReloadCoordinatorHooks() | |
| 254 | + | require.NotNil(t, si.svc.reloadCoordinator.applyContainerConfig) | |
| 255 | + | ||
| 256 | + | reloadCfg := Config{} | |
| 257 | + | reloadCfg.Server.GordonDomain = "reload.example.com" | |
| 258 | + | reloadCfg.Server.RegistryPort = 5000 | |
| 259 | + | reloadCfg.Images.AllowedRegistries = []string{"docker.io"} | |
| 260 | + | ||
| 261 | + | require.NoError(t, si.svc.reloadCoordinator.applyContainerConfig(ctx, reloadCfg)) | |
| 262 | + | } | |
| 207 | 263 | func TestReloadCoordinator_DebouncesRepeatedWatchCallbacks(t *testing.T) { | |
| 208 | 264 | ctx := context.Background() | |
| 209 | 265 | 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,19 @@ func (r *registryLimitsRecorder) UpdateBlobLimits(maxBlobChunkSize, maxBlobSize | |||||
| 112 | r.maxBlobSize = maxBlobSize | 114 | r.maxBlobSize = maxBlobSize | ||
| 113 | } | 115 | } | ||
| 114 | 116 | ||||
| 117 | + | type containerConfigApplyRecorder struct { | |||
| 118 | + | calls int | |||
| 119 | + | ctx context.Context | |||
| 120 | + | cfg Config | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | func (r *containerConfigApplyRecorder) Apply(ctx context.Context, cfg Config) error { | |||
| 124 | + | r.calls++ | |||
| 125 | + | r.ctx = ctx | |||
| 126 | + | r.cfg = cfg | |||
| 127 | + | return nil | |||
| 128 | + | } | |||
| 129 | + | ||||
| 115 | type eventBusRecorder struct { | 130 | type eventBusRecorder struct { | ||
| 116 | mu sync.Mutex | 131 | mu sync.Mutex | ||
| 117 | calls int | 132 | calls int | ||
| @@ -179,22 +194,30 @@ func TestReloadCoordinator_ApplyLoadedConfig_RebuildsProxyConfigAndPublishesEven | |||||
| 179 | v.Set("server.max_blob_size", "8MB") | 194 | v.Set("server.max_blob_size", "8MB") | ||
| 180 | v.Set("server.max_proxy_response_size", "7MB") | 195 | v.Set("server.max_proxy_response_size", "7MB") | ||
| 181 | v.Set("server.max_concurrent_connections", 99) | 196 | v.Set("server.max_concurrent_connections", 99) | ||
| 197 | + | v.Set("server.legacy_registry_domains", []string{"old.example.com"}) | |||
| 198 | + | v.Set("images.allowed_registries", []string{"docker.io"}) | |||
| 182 | 199 | ||||
| 183 | reloadSvc := &reloadRecorder{} | 200 | reloadSvc := &reloadRecorder{} | ||
| 184 | proxySvc := &proxyRecorder{} | 201 | proxySvc := &proxyRecorder{} | ||
| 185 | events := &eventBusRecorder{} | 202 | events := &eventBusRecorder{} | ||
| 203 | + | containerCfg := &containerConfigApplyRecorder{} | |||
| 186 | 204 | ||||
| 187 | registryLimits := ®istryLimitsRecorder{} | 205 | registryLimits := ®istryLimitsRecorder{} | ||
| 188 | coord := newReloadCoordinator(v, reloadSvc, proxySvc, registryLimits, events, nil, zerowrap.Default()) | 206 | coord := newReloadCoordinator(v, reloadSvc, proxySvc, registryLimits, events, nil, zerowrap.Default()) | ||
| 207 | + | coord.SetContainerConfigApplier(containerCfg.Apply) | |||
| 189 | require.NoError(t, coord.ApplyLoadedConfig(ctx)) | 208 | require.NoError(t, coord.ApplyLoadedConfig(ctx)) | ||
| 190 | 209 | ||||
| 191 | require.Equal(t, 0, reloadSvc.Calls()) | 210 | require.Equal(t, 0, reloadSvc.Calls()) | ||
| 192 | require.Equal(t, 1, proxySvc.calls) | 211 | require.Equal(t, 1, proxySvc.calls) | ||
| 193 | require.Equal(t, 1, events.Calls()) | 212 | require.Equal(t, 1, events.Calls()) | ||
| 194 | require.Equal(t, 1, registryLimits.calls) | 213 | require.Equal(t, 1, registryLimits.calls) | ||
| 214 | + | require.Equal(t, 1, containerCfg.calls) | |||
| 195 | require.Equal(t, int64(6<<20), registryLimits.maxBlobChunkSize) | 215 | require.Equal(t, int64(6<<20), registryLimits.maxBlobChunkSize) | ||
| 196 | require.Equal(t, int64(8<<20), registryLimits.maxBlobSize) | 216 | require.Equal(t, int64(8<<20), registryLimits.maxBlobSize) | ||
| 197 | require.Equal(t, domain.EventConfigReload, events.eventType) | 217 | require.Equal(t, domain.EventConfigReload, events.eventType) | ||
| 218 | + | require.Equal(t, "reload.example.com", containerCfg.cfg.Server.GordonDomain) | |||
| 219 | + | require.Equal(t, []string{"old.example.com"}, containerCfg.cfg.Server.LegacyRegistryDomains) | |||
| 220 | + | require.Equal(t, []string{"docker.io"}, containerCfg.cfg.Images.AllowedRegistries) | |||
| 198 | require.Equal(t, proxy.Config{ | 221 | require.Equal(t, proxy.Config{ | ||
| 199 | RegistryDomain: "reload.example.com", | 222 | RegistryDomain: "reload.example.com", | ||
| 200 | RegistryPort: 5000, | 223 | RegistryPort: 5000, | ||
| @@ -204,6 +227,39 @@ func TestReloadCoordinator_ApplyLoadedConfig_RebuildsProxyConfigAndPublishesEven | |||||
| 204 | }, proxySvc.config) | 227 | }, proxySvc.config) | ||
| 205 | } | 228 | } | ||
| 206 | 229 | ||||
| 230 | + | func TestServiceInit_RegisterReloadCoordinatorHooks_WiresContainerConfigApplier(t *testing.T) { | |||
| 231 | + | ctx := context.Background() | |||
| 232 | + | v := viper.New() | |||
| 233 | + | v.Set("server.gordon_domain", "reload.example.com") | |||
| 234 | + | v.Set("server.registry_port", 5000) | |||
| 235 | + | ||||
| 236 | + | configSvc := cfgusecase.NewService(v, nil) | |||
| 237 | + | require.NoError(t, configSvc.Load(ctx)) | |||
| 238 | + | ||||
| 239 | + | si := &serviceInit{ | |||
| 240 | + | ctx: ctx, | |||
| 241 | + | v: v, | |||
| 242 | + | cfg: Config{}, | |||
| 243 | + | log: zerowrap.Default(), | |||
| 244 | + | svc: &services{ | |||
| 245 | + | configSvc: configSvc, | |||
| 246 | + | containerSvc: container.NewService(nil, nil, nil, nil, container.Config{}, configSvc), | |||
| 247 | + | internalRegUser: "gordon", | |||
| 248 | + | internalRegPass: "secret", | |||
| 249 | + | reloadCoordinator: newReloadCoordinator(v, &reloadRecorder{}, &proxyRecorder{}, nil, nil, nil, zerowrap.Default()), | |||
| 250 | + | }, | |||
| 251 | + | } | |||
| 252 | + | ||||
| 253 | + | si.registerReloadCoordinatorHooks() | |||
| 254 | + | require.NotNil(t, si.svc.reloadCoordinator.applyContainerConfig) | |||
| 255 | + | ||||
| 256 | + | reloadCfg := Config{} | |||
| 257 | + | reloadCfg.Server.GordonDomain = "reload.example.com" | |||
| 258 | + | reloadCfg.Server.RegistryPort = 5000 | |||
| 259 | + | reloadCfg.Images.AllowedRegistries = []string{"docker.io"} | |||
| 260 | + | ||||
| 261 | + | require.NoError(t, si.svc.reloadCoordinator.applyContainerConfig(ctx, reloadCfg)) | |||
| 262 | + | } | |||
| 207 | func TestReloadCoordinator_DebouncesRepeatedWatchCallbacks(t *testing.T) { | 263 | func TestReloadCoordinator_DebouncesRepeatedWatchCallbacks(t *testing.T) { | ||
| 208 | ctx := context.Background() | 264 | ctx := context.Background() | ||
| 209 | v := viper.New() | 265 | 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 { | |||
| 1159 | 1159 | 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 | 1209 | // ReconcileRemovedRoute removes active runtime containers for a route that was | |
| 1163 | 1210 | // removed from configuration while preserving stateful resources for explicit | |
| 1164 | 1211 | // 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 | 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 | 1561 | func (s *Service) ListOrphanedAttachments(ctx context.Context) ([]domain.CleanupAttachment, error) { | |
| 1515 | 1562 | containers, err := s.runtime.ListContainers(ctx, true) | |
| 1516 | 1563 | if err != nil { | |
| @@ -1611,7 +1658,7 @@ func cleanupAttachmentsFromContainers(containers []*domain.Container) []domain.C | |||
| 1611 | 1658 | } | |
| 1612 | 1659 | ||
| 1613 | 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 | 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 | 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 | 1748 | func (s *Service) SyncContainers(ctx context.Context) error { | |
| 1698 | 1749 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 1699 | 1750 | zerowrap.FieldLayer: "usecase", | |
| @@ -1712,7 +1763,7 @@ func (s *Service) SyncContainers(ctx context.Context) error { | |||
| 1712 | 1763 | managed := make(map[string]*domain.Container) | |
| 1713 | 1764 | attachments := make(map[string][]string) | |
| 1714 | 1765 | for _, c := range allContainers { | |
| 1715 | - | if c.Status != "" && c.Status != string(domain.ContainerStatusRunning) { | |
| 1766 | + | if !isTrackedManagedContainerStatus(c.Status) { | |
| 1716 | 1767 | continue | |
| 1717 | 1768 | } | |
| 1718 | 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 | 2632 | for _, c := range allContainers { | |
| 2582 | 2633 | 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" { | |
| 2587 | 2639 | log.Debug(). | |
| 2588 | 2640 | Str(zerowrap.FieldEntityID, c.ID). | |
| 2589 | 2641 | Str("container_name", c.Name). | |
| 2590 | 2642 | Str(zerowrap.FieldStatus, c.Status). | |
| 2591 | - | Msg("skipping running container during orphan cleanup") | |
| 2643 | + | Msg("skipping active container during orphan cleanup") | |
| 2592 | 2644 | continue | |
| 2593 | 2645 | } | |
| 2594 | 2646 | ||
| @@ -1159,6 +1159,53 @@ func (s *Service) Stop(ctx context.Context, containerID string) error { | |||||
| 1159 | return nil | 1159 | 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 was | 1209 | // ReconcileRemovedRoute removes active runtime containers for a route that was | ||
| 1163 | // removed from configuration while preserving stateful resources for explicit | 1210 | // removed from configuration while preserving stateful resources for explicit | ||
| 1164 | // follow-up cleanup. It intentionally removes only main route containers, never | 1211 | // 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 | continue | 1767 | 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 running | 2634 | + | // Skip any container that is still active in the runtime, including |
| 2584 | - | // temp container may be actively serving traffic while the | 2635 | + | // 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 | continue | 2644 | continue | ||
| 2593 | } | 2645 | } | ||
| 2594 | 2646 | ||||
M internal/usecase/container/service_test.go +124 -10
| @@ -1277,6 +1277,33 @@ func TestService_SyncContainers(t *testing.T) { | |||
| 1277 | 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 | 1307 | func TestService_Shutdown(t *testing.T) { | |
| 1281 | 1308 | runtime := mocks.NewMockContainerRuntime(t) | |
| 1282 | 1309 | envLoader := mocks.NewMockEnvLoader(t) | |
| @@ -3566,6 +3593,27 @@ func TestService_Deploy_OrphanCleanup_NeverKillsRunningCanonical(t *testing.T) { | |||
| 3566 | 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 | 3617 | // TestService_Deploy_RollbackOnPostSwitchCrash verifies that if the new container crashes | |
| 3570 | 3618 | // during the post-switch stabilization window, traffic is automatically rolled back to the | |
| 3571 | 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 | 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 | 4591 | func TestService_ReconcileRemovedRoute_CanonicalizesDomainBeforeCleanup(t *testing.T) { | |
| 4495 | 4592 | ctx := testContext() | |
| 4496 | 4593 | runtime := mocks.NewMockContainerRuntime(t) | |
| @@ -4659,7 +4756,7 @@ func TestService_CleanupOrphanedAttachments_StopRemovesContainerButPreservesData | |||
| 4659 | 4756 | runtime := mocks.NewMockContainerRuntime(t) | |
| 4660 | 4757 | logWriter := mocks.NewMockContainerLogWriter(t) | |
| 4661 | 4758 | svc := NewService(runtime, nil, mocks.NewMockEventPublisher(t), logWriter, Config{}, nil) | |
| 4662 | - | orphan := &domain.Container{ | |
| 4759 | + | running := &domain.Container{ | |
| 4663 | 4760 | ID: "postgres-1", | |
| 4664 | 4761 | Name: "gordon-app-example-com-postgres", | |
| 4665 | 4762 | Image: "postgres:16", | |
| @@ -4671,20 +4768,35 @@ func TestService_CleanupOrphanedAttachments_StopRemovesContainerButPreservesData | |||
| 4671 | 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 | 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 | 4785 | svc.mu.Unlock() | |
| 4677 | 4786 | ||
| 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() | |
| 4682 | 4793 | ||
| 4683 | 4794 | report, err := svc.CleanupOrphanedAttachments(ctx, "", true) | |
| 4684 | 4795 | require.NoError(t, err) | |
| 4685 | 4796 | 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) | |
| 4688 | 4800 | assert.Contains(t, report.Hints, "attachment volumes and data were preserved") | |
| 4689 | 4801 | ||
| 4690 | 4802 | svc.mu.RLock() | |
| @@ -4693,7 +4805,7 @@ func TestService_CleanupOrphanedAttachments_StopRemovesContainerButPreservesData | |||
| 4693 | 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 | 4809 | ctx := testContext() | |
| 4698 | 4810 | runtime := mocks.NewMockContainerRuntime(t) | |
| 4699 | 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 | 4826 | orphans, err := svc.ListOrphanedAttachments(ctx) | |
| 4715 | 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 | 4833 | 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 crashes | 3617 | // TestService_Deploy_RollbackOnPostSwitchCrash verifies that if the new container crashes | ||
| 3570 | // during the post-switch stabilization window, traffic is automatically rolled back to the | 3618 | // 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) | |||
| 68 | 68 | ||
| 69 | 69 | log := zerowrap.FromCtx(loopCtx) | |
| 70 | 70 | ||
| 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. | |
| 73 | 74 | if err := s.renewDueCertificates(loopCtx, time.Now()); err != nil { | |
| 74 | 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 | 80 | case <-loopCtx.Done(): | |
| 80 | 81 | return | |
| 81 | 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 | 86 | if err := s.renewDueCertificates(loopCtx, time.Now()); err != nil { | |
| 83 | 87 | log.Warn().Err(err).Msg("failed to renew due public TLS certificates") | |
| 84 | 88 | } | |
| @@ -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 renews | 71 | + | // 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 | return | 81 | 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) { | |||
| 115 | 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 | 161 | // RenewDueCertificates | |
| 120 | 162 | // --------------------------------------------------------------------------- | |
| @@ -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 | // RenewDueCertificates | 161 | // RenewDueCertificates | ||
| 120 | // --------------------------------------------------------------------------- | 162 | // --------------------------------------------------------------------------- | ||
M internal/usecase/publictls/service.go +5 -0
| @@ -38,6 +38,7 @@ const defaultObtainBatchSize = 1 | |||
| 38 | 38 | // Service manages public TLS certificates via ACME. | |
| 39 | 39 | type Service struct { | |
| 40 | 40 | mu sync.RWMutex | |
| 41 | + | reconcileMu sync.Mutex | |
| 41 | 42 | cfg Config | |
| 42 | 43 | deps ServiceDeps | |
| 43 | 44 | log zerowrap.Logger | |
| @@ -131,6 +132,10 @@ func (s *Service) Reconcile(ctx context.Context) error { | |||
| 131 | 132 | log.Debug().Msg("acme disabled, skipping reconcile") | |
| 132 | 133 | return nil | |
| 133 | 134 | } | |
| 135 | + | ||
| 136 | + | s.reconcileMu.Lock() | |
| 137 | + | defer s.reconcileMu.Unlock() | |
| 138 | + | ||
| 134 | 139 | log.Debug().Msg("starting certificate reconciliation") | |
| 135 | 140 | ||
| 136 | 141 | 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.RWMutex | 40 | mu sync.RWMutex | ||
| 41 | + | reconcileMu sync.Mutex | |||
| 41 | cfg Config | 42 | cfg Config | ||
| 42 | deps ServiceDeps | 43 | deps ServiceDeps | ||
| 43 | log zerowrap.Logger | 44 | 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 nil | 133 | 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 +59 -0
| @@ -617,6 +617,65 @@ func TestServiceReconcileObtainsMissingHTTP01Cert(t *testing.T) { | |||
| 617 | 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 | + | secondStarted := make(chan struct{}) | |
| 667 | + | go func() { | |
| 668 | + | close(secondStarted) | |
| 669 | + | errCh <- svc.Reconcile(ctx) | |
| 670 | + | }() | |
| 671 | + | <-secondStarted | |
| 672 | + | close(releaseObtain) | |
| 673 | + | ||
| 674 | + | require.NoError(t, <-errCh) | |
| 675 | + | require.NoError(t, <-errCh) | |
| 676 | + | assert.Len(t, recorder.Orders(), 1) | |
| 677 | + | } | |
| 678 | + | ||
| 620 | 679 | func TestServiceReconcileResolvesMissingEffectiveChallenge(t *testing.T) { | |
| 621 | 680 | ctx := context.Background() | |
| 622 | 681 | routes := &fakeRoutes{routes: []domain.Route{{Domain: "app.example.com"}}} | |
| @@ -617,6 +617,65 @@ 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 | + | secondStarted := make(chan struct{}) | |||
| 667 | + | go func() { | |||
| 668 | + | close(secondStarted) | |||
| 669 | + | errCh <- svc.Reconcile(ctx) | |||
| 670 | + | }() | |||
| 671 | + | <-secondStarted | |||
| 672 | + | close(releaseObtain) | |||
| 673 | + | ||||
| 674 | + | require.NoError(t, <-errCh) | |||
| 675 | + | require.NoError(t, <-errCh) | |||
| 676 | + | assert.Len(t, recorder.Orders(), 1) | |||
| 677 | + | } | |||
| 678 | + | ||||
| 620 | func TestServiceReconcileResolvesMissingEffectiveChallenge(t *testing.T) { | 679 | func TestServiceReconcileResolvesMissingEffectiveChallenge(t *testing.T) { | ||
| 621 | ctx := context.Background() | 680 | ctx := context.Background() | ||
| 622 | routes := &fakeRoutes{routes: []domain.Route{{Domain: "app.example.com"}}} | 681 | routes := &fakeRoutes{routes: []domain.Route{{Domain: "app.example.com"}}} | ||