detached 5569831

fix(routes): tighten route handling and unify reload behavior (#167)

* fix(auto): harden preview and route trust boundaries

* test: cover preview and route edge cases

* fix(config): preserve route defaults in review follow-up

* refactor(preview): remove dead handler dependency

* fix(config): reconcile legacy route storage keys

* fix(config): simplify legacy route key rollback

* docs: clarify gordon domain migration

* fix(routes): address merge blockers in reload and validation

* refactor(reload): unify config reload entrypoints

* fix(admin): route reload through coordinator

* fix(admin): surface reload failures

* fix(health): probe routes with the correct scheme

* fix(preview): create previews per trusted route

* fix(admin): align route defaults with canonical schema

* test(admin): cover stored route lookup after update

* test(preview): remove unused capture store helper

* fix(routes): finish canonical route migration

* fix: tighten route reload and preview handling

* fix: preserve route state across reload updates
commit
55698310504f0ca9ffc24d8571fe61240239d43b
author
bnema · · 2026-04-12 13:41 UTC
committer
GitHub · · 2026-04-12 13:41 UTC
parent
f40d719
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
31 files · +2751 -844
M docs/cli/index.md +6 -5
@@ -87,8 +87,8 @@ gordon rollback myapp.example.com
8787 gordon logs # Gordon process logs
8888 gordon logs -f # Follow process logs
8989 gordon logs -n 100 # Last 100 lines
90-gordon logs myapp.local # Container logs for myapp.local
91-gordon logs myapp.local -f # Follow container logs
90+gordon logs myapp.example.com # Container logs for myapp.example.com
91+gordon logs myapp.example.com -f # Follow container logs
9292
9393 # Check version
9494 gordon version
@@ -126,9 +126,9 @@ gordon attachments add app.example.com postgres:18
126126 gordon attachments remove app.example.com postgres:18
127127
128128 # Secrets
129-gordon secrets list myapp.local
130-gordon secrets set myapp.local DATABASE_URL "postgres://..."
131-gordon secrets remove myapp.local DATABASE_URL
129+gordon secrets list myapp.example.com
130+gordon secrets set myapp.example.com DATABASE_URL "postgres://..."
131+gordon secrets remove myapp.example.com DATABASE_URL
132132
133133 # Remotes
134134 gordon remotes add prod https://gordon.mydomain.com --token $TOKEN
@@ -168,6 +168,7 @@ or `GORDON_REMOTE` environment variable. Use `--remote` and `--token` as global
168168 when you want to bypass your saved configuration.
169169
170170 **Important:** The remote URL must be the `gordon_domain` configured on the remote Gordon instance. This is the domain that serves both the container registry and the Admin API.
171+If remote CLI gets a `404` during `/auth/token` exchange, the server likely still sets only `server.registry_domain` and needs `server.gordon_domain` configured.
171172
172173 Use `--insecure` when the remote endpoint uses a self-signed or otherwise untrusted TLS certificate.
173174 You can make this persistent with `insecure_tls = true` in `[client]` of `~/.config/gordon/gordon.toml`
M docs/cli/remotes.md +2 -0
@@ -53,6 +53,8 @@ gordon remotes add <name> <url> [options]
5353 | `<name>` | Name for the remote (e.g., `prod`, `staging`) |
5454 | `<url>` | Gordon URL (the `gordon_domain` from remote config) |
5555
56+> **Note:** `<url>` must match the configured `gordon_domain`. Older servers that only set `registry_domain` may need a config migration before remote CLI works.
57+
5658 ### Options
5759
5860 | Option | Description |
M docs/cli/secrets.md +21 -20
@@ -7,7 +7,8 @@ Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
77
88 Storage depends on the secrets backend:
99 - `pass`: secrets are stored in pass under `gordon/env/<domain>/<KEY>`
10-- `sops` or `unsafe`: secrets are stored in domain `.env` files
10+- `sops`: secrets live in domain `.env` files and can reference SOPS-encrypted values
11+- `unsafe`: secrets are stored in plain-text domain `.env` files
1112
1213 ## gordon secrets
1314
@@ -47,10 +48,10 @@ gordon secrets list <domain>
4748
4849 ```bash
4950 # Local
50-gordon secrets list myapp.local
51+gordon secrets list myapp.example.com
5152
5253 # Remote (override)
53-gordon secrets list myapp.local --remote https://gordon.mydomain.com --token $TOKEN
54+gordon secrets list myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN
5455 ```
5556
5657 ### Output
@@ -101,7 +102,7 @@ Set a secret value for a domain.
101102
102103 ```bash
103104 gordon secrets set <domain> <KEY=value>...
104-gordon secrets set myapp.local DATABASE_URL "postgres://..."
105+gordon secrets set myapp.example.com DATABASE_URL="postgres://..."
105106 ```
106107
107108 ### Arguments
@@ -109,8 +110,7 @@ gordon secrets set myapp.local DATABASE_URL "postgres://..."
109110 | Argument | Description |
110111 |----------|-------------|
111112 | `<domain>` | The domain name to set the secret for |
112-| `<key>` | The secret key (environment variable name) |
113-| `<value>` | The secret value |
113+| `<KEY=value>...` | One or more secret key/value pairs |
114114
115115 ### Options
116116
@@ -124,11 +124,11 @@ gordon secrets set myapp.local DATABASE_URL "postgres://..."
124124
125125 ```bash
126126 # Local
127-gordon secrets set myapp.local DATABASE_URL "postgres://user:pass@postgres:5432/db"
128-gordon secrets set myapp.local API_KEY "your-api-key"
127+gordon secrets set myapp.example.com DATABASE_URL="postgres://user:pass@postgres:5432/db"
128+gordon secrets set myapp.example.com API_KEY="your-api-key"
129129
130130 # Remote (override)
131-gordon secrets set myapp.local DATABASE_URL "postgres://..." --remote https://gordon.mydomain.com --token $TOKEN
131+gordon secrets set myapp.example.com DATABASE_URL="$DATABASE_URL" --remote https://gordon.mydomain.com --token $TOKEN
132132
133133 # Set attachment secrets
134134 gordon secrets set app.mydomain.com --attachment postgres POSTGRES_PASSWORD=secret
@@ -160,6 +160,7 @@ gordon secrets remove <domain> <key>
160160 | Option | Description |
161161 |--------|-------------|
162162 | `--attachment` / `-a` | Target an attachment service (e.g., postgres, redis) |
163+| `--force` | Remove without confirmation |
163164 | `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
164165 | `--token` | Authentication token for remote |
165166
@@ -167,10 +168,10 @@ gordon secrets remove <domain> <key>
167168
168169 ```bash
169170 # Local
170-gordon secrets remove myapp.local DATABASE_URL
171+gordon secrets remove myapp.example.com DATABASE_URL
171172
172173 # Remote (override)
173-gordon secrets remove myapp.local DATABASE_URL --remote https://gordon.mydomain.com --token $TOKEN
174+gordon secrets remove myapp.example.com DATABASE_URL --remote https://gordon.mydomain.com --token $TOKEN
174175
175176 # Remove attachment secret
176177 gordon secrets remove app.mydomain.com --attachment postgres POSTGRES_PASSWORD
@@ -184,17 +185,17 @@ gordon secrets remove app.mydomain.com --attachment postgres POSTGRES_PASSWORD
184185
185186 ```bash
186187 # Database connection
187-gordon secrets set myapp.local DATABASE_URL "postgres://user:pass@postgres:5432/mydb"
188+gordon secrets set myapp.example.com DATABASE_URL="postgres://user:pass@postgres:5432/mydb"
188189
189190 # API keys
190-gordon secrets set myapp.local STRIPE_KEY "sk_live_..."
191-gordon secrets set myapp.local SENDGRID_KEY "SG..."
191+gordon secrets set myapp.example.com STRIPE_KEY="sk_live_..."
192+gordon secrets set myapp.example.com SENDGRID_KEY="SG..."
192193
193194 # JWT secret
194-gordon secrets set myapp.local JWT_SECRET "your-jwt-secret-here"
195+gordon secrets set myapp.example.com JWT_SECRET="your-jwt-secret-here"
195196
196197 # Verify
197-gordon secrets list myapp.local
198+gordon secrets list myapp.example.com
198199 ```
199200
200201 ### CI/CD Secret Management
@@ -205,8 +206,8 @@ export GORDON_REMOTE=https://gordon.mydomain.com
205206 export GORDON_TOKEN=$GORDON_TOKEN
206207
207208 # Update secrets before deploy
208-gordon secrets set myapp.example.com DATABASE_URL "$DATABASE_URL"
209-gordon secrets set myapp.example.com API_KEY "$API_KEY"
209+gordon secrets set myapp.example.com DATABASE_URL="$DATABASE_URL"
210+gordon secrets set myapp.example.com API_KEY="$API_KEY"
210211
211212 # Deploy
212213 gordon deploy myapp.example.com
@@ -219,10 +220,10 @@ gordon deploy myapp.example.com
219220 NEW_JWT_SECRET=$(openssl rand -base64 32)
220221
221222 # Update the secret
222-gordon secrets set myapp.local JWT_SECRET "$NEW_JWT_SECRET"
223+gordon secrets set myapp.example.com JWT_SECRET="$NEW_JWT_SECRET"
223224
224225 # Redeploy to pick up new secret
225-gordon deploy myapp.local
226+gordon deploy myapp.example.com
226227 ```
227228
228229 ### Attachment Secrets
M docs/cli/serve.md +6 -6
@@ -245,13 +245,13 @@ gordon logs -n 100 # Last 100 lines
245245 gordon logs -f -n 200 # Follow, starting from last 200 lines
246246
247247 # Container logs
248-gordon logs myapp.local # Last 50 lines from container
249-gordon logs myapp.local -f # Follow container logs
250-gordon logs myapp.local -n 100 # Last 100 lines from container
248+gordon logs myapp.example.com # Last 50 lines from container
249+gordon logs myapp.example.com -f # Follow container logs
250+gordon logs myapp.example.com -n 100 # Last 100 lines from container
251251
252252 # Remote mode (override)
253253 gordon logs --remote https://gordon.mydomain.com --token $TOKEN
254-gordon logs myapp.local --remote https://gordon.mydomain.com --token $TOKEN
254+gordon logs myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN
255255 ```
256256
257257 ### Log Locations
@@ -267,8 +267,8 @@ tail -f ~/.gordon/logs/gordon.log
267267 journalctl --user -u gordon -f
268268
269269 # Container logs via docker (local alternative)
270-docker logs --tail 50 myapp.local
271-docker logs -f myapp.local
270+docker logs --tail 50 myapp.example.com
271+docker logs -f myapp.example.com
272272 ```
273273
274274 ---
M docs/concepts.md +15 -12
@@ -65,23 +65,25 @@ Routes map domains to container images:
6565
6666 ```toml
6767 [routes]
68-"app.mydomain.com" = "myapp:latest"
69-"api.mydomain.com" = "myapi:v2.1.0"
68+"app.example.com" = { image = "myapp:latest" }
69+"api.example.com" = { image = "myapi:v2.1.0" }
7070 ```
7171
72-When a request comes in for `app.mydomain.com`, Gordon:
72+Route domains must be plain hostnames such as `app.example.com`. Gordon rejects `http://` and `https://` prefixes, `.local` and `.internal` suffixes, localhost names, and IP literals. Legacy `http://...` keys are still read for backward compatibility and rewritten on the next save.
73+
74+When a request comes in for `app.example.com`, Gordon:
7375
7476 1. Looks up the route configuration
7577 2. Finds the running container for `myapp:latest`
7678 3. Proxies the request to that container
7779
78-### HTTP vs HTTPS Routes
80+### Route Domains
7981
80-By default, routes expect HTTPS (terminated by Cloudflare). For HTTP-only routes:
82+Routes use plain hostnames. HTTPS is enabled by default for routes; see the [routes example](./config/routes.md#development-setup) with `dev-app.example.com`. Cloudflare is one deployment option, but Gordon can also terminate TLS with its built-in listener and certificates issued by Gordon's internal CA, or with static certificates. Gordon does not provide ACME or Let's Encrypt challenge automation.
8183
8284 ```toml
8385 [routes]
84-"http://internal.local" = "internal-app:latest"
86+"dev-app.example.com" = { image = "internal-app:latest", https = false }
8587 ```
8688
8789 ## Network Isolation
@@ -188,16 +190,18 @@ Gordon watches its config file and reloads automatically:
188190
189191 1. Edit `~/.config/gordon/gordon.toml`
190192 2. Save the file
191-3. Gordon reloads routes, attachments, and network groups
192-4. Containers sync to match new configuration
193+3. Gordon reloads hot-reloaded settings such as attachments, network groups, and routes
194+4. Containers and proxy config sync to match the new configuration
195+
196+Route file edits now reload automatically again.
193197
194-You can also trigger a manual reload:
198+You can also trigger a reload:
195199
196200 ```bash
197201 gordon reload
198202 ```
199203
200-This sends `SIGUSR1` to the running Gordon process.
204+`gordon reload` sends `SIGUSR1` to the running Gordon process.
201205
202206 ## Event System
203207
@@ -206,8 +210,7 @@ Gordon uses an internal event system for coordination:
206210 | Event | Trigger | Action |
207211 |-------|---------|--------|
208212 | `image.pushed` | Image pushed to registry | Deploy container |
209-| `config.reload` | Config file changed | Sync containers |
210-| `manual.reload` | `gordon reload` command | Sync containers |
213+| `config.reload` | Config file changed or `gordon reload` sends `SIGUSR1` | Reload config, sync containers, and refresh proxy state |
211214 | `manual.deploy` | `gordon deploy <domain>` command | Deploy specific route |
212215 | `container.deployed` | Container started | Update proxy cache |
213216
M docs/config/index.md +3 -1
@@ -21,6 +21,8 @@ gordon_domain = "gordon.mydomain.com"
2121 "app.mydomain.com" = "myapp:latest"
2222 ```
2323
24+> **Note:** `gordon_domain` is the canonical key. Migrate older `registry_domain` values before restarting.
25+
2426 ## Full Configuration Reference
2527
2628 For a complete list of all configuration options with their default values, see the [Configuration Reference](./reference.md).
@@ -231,7 +233,7 @@ gordon reload
231233 | `server.max_proxy_response_size` |
232234 | `server.max_concurrent_conns` |
233235
234-> **Note:** Routes are not hot-reloaded from the config file. Use the API or CLI (`gordon routes add/update/remove`) to manage routes at runtime.
236+> **Note:** Routes are hot-reloaded from the config file. You can still use the API or CLI (`gordon routes add/update/remove`) for live route changes.
235237
236238 ### Requires restart
237239
M docs/config/reference.md +3 -2
@@ -118,8 +118,9 @@ preserve = true # Keep volumes when containers are
118118 # ROUTES
119119 # =============================================================================
120120 [routes]
121-# "domain.com" = "image:tag"
122-# "http://insecure.domain.com" = "image:tag" # HTTP-only (no HTTPS redirect)
121+# "domain.com" = { image = "image:tag" }
122+# "insecure.domain.com" = { image = "image:tag", https = false }
123+# Legacy "http://domain.com" keys are read for compatibility and rewritten on save.
123124
124125 # =============================================================================
125126 # EXTERNAL ROUTES
M docs/config/routes.md +53 -53
@@ -1,28 +1,30 @@
11 # Routes Configuration
22
3-Routes map domains to container images.
3+Routes map hostnames to container images.
44
55 ## Configuration
66
77 ```toml
88 [routes]
9-"app.mydomain.com" = "myapp:latest"
10-"api.mydomain.com" = "myapi:v2.1.0"
11-"admin.mydomain.com" = "admin-panel:v1.0.0"
9+"app.mydomain.com" = { image = "myapp:latest" }
10+"api.mydomain.com" = { image = "myapi:v2.1.0" }
11+"admin.mydomain.com" = { image = "admin-panel:v1.0.0" }
1212 ```
1313
1414 ## Syntax
1515
1616 ```toml
1717 [routes]
18-"<domain>" = "<image>:<tag>"
18+"<domain>" = { image = "<image>:<tag>" }
1919 ```
2020
2121 | Component | Description |
2222 |-----------|-------------|
23-| `domain` | Fully qualified domain name |
24-| `image` | Container image name (as pushed to Gordon's registry) |
25-| `tag` | Image tag (version, `latest`, etc.) |
23+| `domain` | Public, fully qualified domain name |
24+| `image` | Full container image reference, including tag |
25+| `https` | Optional; add `false` for HTTP-only routes |
26+
27+Legacy `http://...` route keys are still read for backward compatibility and rewritten on the next save.
2628
2729 ## Route Types
2830
@@ -32,20 +34,20 @@ Standard routes expect HTTPS traffic (terminated by Cloudflare or similar):
3234
3335 ```toml
3436 [routes]
35-"app.mydomain.com" = "myapp:latest"
37+"app.mydomain.com" = { image = "myapp:latest" }
3638 ```
3739
38-### HTTP-Only Routes
40+Route domains must be plain hostnames. Gordon rejects `http://` and `https://` prefixes, `.local` and `.internal` suffixes, localhost names, and IP literals.
41+
42+### Development Routes
3943
40-For internal or development routes without HTTPS:
44+For local testing, use a hostname you can resolve yourself:
4145
4246 ```toml
4347 [routes]
44-"http://internal.local" = "internal-app:latest"
48+"dev-app.example.com" = { image = "internal-app:latest", https = false }
4549 ```
4650
47-The `http://` prefix tells Gordon not to expect HTTPS.
48-
4951 ## Version Strategies
5052
5153 ### Latest Tag
@@ -54,7 +56,7 @@ Always deploy the most recent push:
5456
5557 ```toml
5658 [routes]
57-"app.mydomain.com" = "myapp:latest"
59+"app.mydomain.com" = { image = "myapp:latest" }
5860 ```
5961
6062 ### Pinned Version
@@ -63,14 +65,14 @@ Deploy a specific version:
6365
6466 ```toml
6567 [routes]
66-"app.mydomain.com" = "myapp:v2.1.0"
68+"app.mydomain.com" = { image = "myapp:v2.1.0" }
6769 ```
6870
6971 Update the config to deploy a new version:
7072
7173 ```toml
7274 [routes]
73-"app.mydomain.com" = "myapp:v2.2.0" # Changed
75+"app.mydomain.com" = { image = "myapp:v2.2.0" } # Changed
7476 ```
7577
7678 ### Semantic Versioning
@@ -79,9 +81,9 @@ Use different routes for different versions:
7981
8082 ```toml
8183 [routes]
82-"app.mydomain.com" = "myapp:v2.1.0" # Production
83-"staging.mydomain.com" = "myapp:staging" # Staging
84-"canary.mydomain.com" = "myapp:canary" # Canary
84+"app.mydomain.com" = { image = "myapp:v2.1.0" } # Production
85+"staging.mydomain.com" = { image = "myapp:staging" } # Staging
86+"canary.mydomain.com" = { image = "myapp:canary" } # Canary
8587 ```
8688
8789 ## Multiple Routes
@@ -90,19 +92,19 @@ Use different routes for different versions:
9092
9193 ```toml
9294 [routes]
93-"app.mydomain.com" = "myapp:latest"
94-"www.mydomain.com" = "myapp:latest"
95-"mydomain.com" = "myapp:latest"
95+"app.mydomain.com" = { image = "myapp:latest" }
96+"www.mydomain.com" = { image = "myapp:latest" }
97+"mydomain.com" = { image = "myapp:latest" }
9698 ```
9799
98100 ### Multiple Services
99101
100102 ```toml
101103 [routes]
102-"app.mydomain.com" = "frontend:latest"
103-"api.mydomain.com" = "backend:v2.1.0"
104-"docs.mydomain.com" = "documentation:latest"
105-"status.mydomain.com" = "status-page:v1.0.0"
104+"app.mydomain.com" = { image = "frontend:latest" }
105+"api.mydomain.com" = { image = "backend:v2.1.0" }
106+"docs.mydomain.com" = { image = "documentation:latest" }
107+"status.mydomain.com" = { image = "status-page:v1.0.0" }
106108 ```
107109
108110 ## How Routing Works
@@ -132,25 +134,22 @@ docker push registry.mydomain.com/myapp:latest
132134 4. Updates proxy to route traffic to new container
133135 5. Stops old container
134136
135-## Hot Reload
137+## Route Changes
136138
137-Routes reload automatically when the config file changes:
139+Route changes in the config file are hot-reloaded automatically:
138140
139-```bash
140-# Edit config
141-vim ~/.config/gordon/gordon.toml
141+1. Edit `[routes]` in `gordon.toml`
142+2. Save the file
143+3. Gordon reloads the updated routes and proxy config
142144
143-# Add new route
144-[routes]
145-"newapp.mydomain.com" = "newapp:latest"
145+Legacy `http://...` route keys are still read here for backward compatibility and rewritten the next time Gordon saves the config.
146146
147-# Save - Gordon reloads automatically
148-```
149-
150-Or trigger manually:
147+You can still manage routes with the API or CLI if you prefer live mutations:
151148
152149 ```bash
153-gordon reload
150+gordon routes add newapp.mydomain.com newapp:latest
151+gordon routes add app.mydomain.com myapp:v2.2.0
152+gordon routes remove oldapp.mydomain.com
154153 ```
155154
156155 ## Examples
@@ -159,23 +158,24 @@ gordon reload
159158
160159 ```toml
161160 [routes]
162-"app.local" = "myapp:latest"
163-"api.local" = "myapi:latest"
161+"dev-app.example.com" = { image = "myapp:latest" }
162+"dev-api.example.com" = { image = "myapi:latest" }
164163 ```
165164
166165 Add to `/etc/hosts`:
167-```
168-127.0.0.1 app.local api.local registry.local
166+
167+```text
168+127.0.0.1 dev-app.example.com dev-api.example.com registry.example.com
169169 ```
170170
171171 ### Production Setup
172172
173173 ```toml
174174 [routes]
175-"app.company.com" = "company-app:v2.1.0"
176-"api.company.com" = "company-api:v1.5.2"
177-"admin.company.com" = "admin-panel:v1.0.1"
178-"docs.company.com" = "company-docs:latest"
175+"app.company.com" = { image = "company-app:v2.1.0" }
176+"api.company.com" = { image = "company-api:v1.5.2" }
177+"admin.company.com" = { image = "admin-panel:v1.0.1" }
178+"docs.company.com" = { image = "company-docs:latest" }
179179 ```
180180
181181 ### Multi-Tenant SaaS
@@ -183,15 +183,15 @@ Add to `/etc/hosts`:
183183 ```toml
184184 [routes]
185185 # Platform services
186-"app.saas-platform.com" = "saas-frontend:v2.1.0"
187-"api.saas-platform.com" = "saas-api:v3.2.1"
186+"app.saas-platform.com" = { image = "saas-frontend:v2.1.0" }
187+"api.saas-platform.com" = { image = "saas-api:v3.2.1" }
188188
189189 # Customer subdomains
190-"acme.saas-platform.com" = "saas-app:v2.1.0"
191-"beta.saas-platform.com" = "saas-app:v2.1.0"
190+"acme.saas-platform.com" = { image = "saas-app:v2.1.0" }
191+"beta.saas-platform.com" = { image = "saas-app:v2.1.0" }
192192
193193 # Customer custom domains
194-"portal.acme-corp.com" = "saas-app:v2.1.0"
194+"portal.acme-corp.com" = { image = "saas-app:v2.1.0" }
195195 ```
196196
197197 ## External Services
M docs/config/server.md +6 -2
@@ -27,7 +27,7 @@ gordon_domain = "gordon.mydomain.com" # Gordon domain (required)
2727 | `tls_key_file` | string | `""` | Path to PEM private key file. Must be set together with `tls_cert_file` |
2828 | `force_https_redirect` | bool | `false` | Redirect all HTTP requests to the HTTPS port. For direct-access setups without a TLS-terminating proxy |
2929 | `gordon_domain` | string | **required** | Domain for Gordon (registry + admin API) |
30-| `registry_domain` | string | - | Deprecated alias for `gordon_domain` |
30+| `registry_domain` | string | - | Deprecated migration key. Set `gordon_domain` instead. |
3131 | `data_dir` | string | `~/.gordon` | Directory for registry data, logs, and env files |
3232 | `max_proxy_body_size` | string | `"512MB"` | Maximum request body size for proxied requests |
3333 | `max_blob_chunk_size` | string | `"95MB"` | Maximum request body size for a single registry blob upload chunk |
@@ -171,13 +171,17 @@ This domain is used for:
171171 - CLI remote targeting (`gordon routes --remote https://gordon.mydomain.com`)
172172 - Authentication endpoints (`/auth/*`)
173173
174+> **Warning:** If you are upgrading an older config, copy `server.registry_domain` to `server.gordon_domain` before restarting.
175+
176+Without this migration, a Host/remote-target mismatch can break routing or remote CLI token exchange.
177+
174178 When requests arrive on the proxy port with this domain as the Host header, Gordon routes them to the backend services (registry and admin API).
175179
176180 Security note:
177181 - Direct access to `/admin/*` on `registry_port` is blocked for non-loopback clients.
178182 - Admin API traffic should go through the main proxy listener (`server.port`/`server.tls_port`).
179183
180-> **Note:** `registry_domain` is supported as a deprecated alias for backwards compatibility.
184+> **Note:** `registry_domain` is a deprecated migration key; use `gordon_domain` for new and upgraded configs.
181185
182186 ## Data Directory
183187
M docs/upgrading.md +24 -0
@@ -2,6 +2,10 @@
22
33 This guide covers breaking changes and migration steps between major versions.
44
5+## Route-Domain Validation
6+
7+Route keys must be plain hostnames. Use inline tables like `"app.example.com" = { image = "myapp:latest" }`. Gordon still reads legacy `http://...` route entries for backward compatibility and rewrites them on the next save. Update `[routes]`, CLI commands, and automation that reference the old values.
8+
59 ## v2.30.0 to v2.31.0
610
711 ### Breaking: Default Port Changes
@@ -50,6 +54,26 @@ proxy_allowed_ips = [
5054
5155 Without this, all proxied HTTP traffic returns `403 Forbidden`. Set `tls_port = 0` to disable the internal CA and skip this requirement.
5256
57+### Breaking: `server.gordon_domain` Replaces `server.registry_domain`
58+
59+Gordon now uses `server.gordon_domain` as the public registry and admin host. Migrate older configs that still set only `server.registry_domain` before restarting:
60+
61+**Before:**
62+
63+```toml
64+[server]
65+registry_domain = "gordon.example.com"
66+```
67+
68+**After:**
69+
70+```toml
71+[server]
72+gordon_domain = "gordon.example.com"
73+```
74+
75+If you do not migrate, `gordon status --remote ...` and `gordon routes list --remote ...` can fail with `/auth/token` `404`, and `reg-domain/v2/` or `/admin/status` can return `404`.
76+
5377 ## v2.16.0 to v2.30.0
5478
5579 ### Breaking: Password Authentication Removed
M internal/adapters/in/http/admin/bootstrap_test.go +140 -49
@@ -4,8 +4,8 @@ import (
44 "bytes"
55 "encoding/json"
66 "errors"
7+ "io"
78 "net/http"
8- "net/http/httptest"
99 "testing"
1010
1111 "github.com/stretchr/testify/assert"
@@ -31,6 +31,7 @@ func TestBootstrap_FullWorkflow_HappyPath(t *testing.T) {
3131 d.SecretSvc = secretSvc
3232 d.RegistrySvc = registrySvc
3333 })
34+ server := newScopedTestServer(t, handler, "admin:routes:write", "admin:secrets:write", "admin:config:write")
3435
3536 payload := dto.BootstrapRequest{
3637 Domain: "app.example.com",
@@ -43,7 +44,7 @@ func TestBootstrap_FullWorkflow_HappyPath(t *testing.T) {
4344 }
4445
4546 configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Once()
46- configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: "reg.bnema.dev/myapp"}).Return(nil).Once()
47+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: "reg.bnema.dev/myapp", HTTPS: true}).Return(nil).Once()
4748 configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(nil).Once()
4849 secretSvc.EXPECT().Set(mock.Anything, payload.Domain, map[string]string{"APP_ENV": "prod"}).Return(nil).Once()
4950 secretSvc.EXPECT().SetAttachment(mock.Anything, payload.Domain, "postgres", map[string]string{"POSTGRES_PASSWORD": "secret"}).Return(nil).Once()
@@ -51,17 +52,18 @@ func TestBootstrap_FullWorkflow_HappyPath(t *testing.T) {
5152 body, err := json.Marshal(payload)
5253 require.NoError(t, err)
5354
54- req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body))
55- req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:secrets:write", "admin:config:write"))
56- rec := httptest.NewRecorder()
55+ req, err := http.NewRequest(http.MethodPost, server.URL+"/admin/bootstrap", bytes.NewReader(body))
56+ require.NoError(t, err)
5757
58- handler.ServeHTTP(rec, req)
58+ resp, err := server.Client().Do(req)
59+ require.NoError(t, err)
60+ defer resp.Body.Close()
5961
60- assert.Equal(t, http.StatusOK, rec.Code)
62+ assert.Equal(t, http.StatusOK, resp.StatusCode)
6163 registrySvc.AssertNotCalled(t, "GetManifest", mock.Anything, mock.Anything, mock.Anything)
6264
6365 var response dto.BootstrapResponse
64- require.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
66+ require.NoError(t, json.NewDecoder(resp.Body).Decode(&response))
6567 assert.Equal(t, payload.Domain, response.Domain)
6668 assert.Equal(t, "reg.bnema.dev/myapp", response.Image)
6769 assert.Equal(t, "push reg.bnema.dev/myapp to trigger deployment", response.Next)
@@ -73,6 +75,43 @@ func TestBootstrap_FullWorkflow_HappyPath(t *testing.T) {
7375 }, response.Steps)
7476 }
7577
78+func TestHandleBootstrap_DefaultsHTTPSWhenOmitted(t *testing.T) {
79+ configSvc := inmocks.NewMockConfigService(t)
80+ authSvc := inmocks.NewMockAuthService(t)
81+ containerSvc := inmocks.NewMockContainerService(t)
82+ secretSvc := inmocks.NewMockSecretService(t)
83+
84+ handler := newTestHandler(t, func(d *HandlerDeps) {
85+ d.ConfigSvc = configSvc
86+ d.AuthSvc = authSvc
87+ d.ContainerSvc = containerSvc
88+ d.SecretSvc = secretSvc
89+ })
90+ server := newScopedTestServer(t, handler, "admin:routes:write", "admin:config:write")
91+
92+ payload := dto.BootstrapRequest{Domain: "app.example.com", Image: "myapp:latest"}
93+ configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Once()
94+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: "reg.bnema.dev/myapp", HTTPS: true}).Return(nil).Once()
95+
96+ body, err := json.Marshal(payload)
97+ require.NoError(t, err)
98+
99+ req, err := http.NewRequest(http.MethodPost, server.URL+"/admin/bootstrap", bytes.NewReader(body))
100+ require.NoError(t, err)
101+
102+ resp, err := server.Client().Do(req)
103+ require.NoError(t, err)
104+ defer resp.Body.Close()
105+
106+ assert.Equal(t, http.StatusOK, resp.StatusCode)
107+
108+ var response dto.BootstrapResponse
109+ require.NoError(t, json.NewDecoder(resp.Body).Decode(&response))
110+ assert.Equal(t, payload.Domain, response.Domain)
111+ assert.Equal(t, "reg.bnema.dev/myapp", response.Image)
112+ assert.Equal(t, "push reg.bnema.dev/myapp to trigger deployment", response.Next)
113+}
114+
76115 func TestBootstrap_Idempotent_Rerun(t *testing.T) {
77116 configSvc := inmocks.NewMockConfigService(t)
78117 authSvc := inmocks.NewMockAuthService(t)
@@ -85,6 +124,7 @@ func TestBootstrap_Idempotent_Rerun(t *testing.T) {
85124 d.ContainerSvc = containerSvc
86125 d.SecretSvc = secretSvc
87126 })
127+ server := newScopedTestServer(t, handler, "admin:routes:write", "admin:secrets:write", "admin:config:write")
88128
89129 payload := dto.BootstrapRequest{
90130 Domain: "app.example.com",
@@ -97,12 +137,12 @@ func TestBootstrap_Idempotent_Rerun(t *testing.T) {
97137 }
98138
99139 configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Times(2)
100- configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: "reg.bnema.dev/myapp"}).Return(nil).Once()
140+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: "reg.bnema.dev/myapp", HTTPS: true}).Return(nil).Once()
101141 configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(nil).Once()
102142 secretSvc.EXPECT().Set(mock.Anything, payload.Domain, map[string]string{"APP_ENV": "prod"}).Return(nil).Once()
103143 secretSvc.EXPECT().SetAttachment(mock.Anything, payload.Domain, "postgres", map[string]string{"POSTGRES_PASSWORD": "secret"}).Return(nil).Once()
104144
105- configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: "reg.bnema.dev/myapp"}).Return(nil).Once()
145+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: "reg.bnema.dev/myapp", HTTPS: true}).Return(nil).Once()
106146 configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(domain.ErrAttachmentExists).Once()
107147 secretSvc.EXPECT().Set(mock.Anything, payload.Domain, map[string]string{"APP_ENV": "prod"}).Return(nil).Once()
108148 secretSvc.EXPECT().SetAttachment(mock.Anything, payload.Domain, "postgres", map[string]string{"POSTGRES_PASSWORD": "secret"}).Return(nil).Once()
@@ -111,16 +151,17 @@ func TestBootstrap_Idempotent_Rerun(t *testing.T) {
111151 require.NoError(t, err)
112152
113153 for i := range 2 {
114- req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body))
115- req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:secrets:write", "admin:config:write"))
116- rec := httptest.NewRecorder()
154+ req, err := http.NewRequest(http.MethodPost, server.URL+"/admin/bootstrap", bytes.NewReader(body))
155+ require.NoError(t, err)
117156
118- handler.ServeHTTP(rec, req)
157+ resp, err := server.Client().Do(req)
158+ require.NoError(t, err)
159+ defer resp.Body.Close()
119160
120- assert.Equal(t, http.StatusOK, rec.Code)
161+ assert.Equal(t, http.StatusOK, resp.StatusCode)
121162
122163 var response dto.BootstrapResponse
123- require.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
164+ require.NoError(t, json.NewDecoder(resp.Body).Decode(&response))
124165 assert.Equal(t, payload.Domain, response.Domain)
125166 assert.Equal(t, "reg.bnema.dev/myapp", response.Image)
126167 require.Len(t, response.Steps, 4)
@@ -154,18 +195,22 @@ func TestBootstrap_MissingDomain(t *testing.T) {
154195 d.ContainerSvc = containerSvc
155196 d.SecretSvc = secretSvc
156197 })
198+ server := newScopedTestServer(t, handler, "admin:routes:write", "admin:config:write")
157199
158200 body, err := json.Marshal(dto.BootstrapRequest{Image: "myapp:latest"})
159201 require.NoError(t, err)
160202
161- req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body))
162- req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:config:write"))
163- rec := httptest.NewRecorder()
203+ req, err := http.NewRequest(http.MethodPost, server.URL+"/admin/bootstrap", bytes.NewReader(body))
204+ require.NoError(t, err)
164205
165- handler.ServeHTTP(rec, req)
206+ resp, err := server.Client().Do(req)
207+ require.NoError(t, err)
208+ defer resp.Body.Close()
166209
167- assert.Equal(t, http.StatusBadRequest, rec.Code)
168- assert.JSONEq(t, `{"error":"domain is required"}`+"\n", rec.Body.String())
210+ assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
211+ bodyBytes, err := io.ReadAll(resp.Body)
212+ require.NoError(t, err)
213+ assert.JSONEq(t, `{"error":"domain is required"}`+"\n", string(bodyBytes))
169214 }
170215
171216 func TestBootstrap_MissingImage(t *testing.T) {
@@ -180,18 +225,22 @@ func TestBootstrap_MissingImage(t *testing.T) {
180225 d.ContainerSvc = containerSvc
181226 d.SecretSvc = secretSvc
182227 })
228+ server := newScopedTestServer(t, handler, "admin:routes:write", "admin:config:write")
183229
184230 body, err := json.Marshal(dto.BootstrapRequest{Domain: "app.example.com"})
185231 require.NoError(t, err)
186232
187- req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body))
188- req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:config:write"))
189- rec := httptest.NewRecorder()
233+ req, err := http.NewRequest(http.MethodPost, server.URL+"/admin/bootstrap", bytes.NewReader(body))
234+ require.NoError(t, err)
190235
191- handler.ServeHTTP(rec, req)
236+ resp, err := server.Client().Do(req)
237+ require.NoError(t, err)
238+ defer resp.Body.Close()
192239
193- assert.Equal(t, http.StatusBadRequest, rec.Code)
194- assert.JSONEq(t, `{"error":"image is required"}`+"\n", rec.Body.String())
240+ assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
241+ bodyBytes, err := io.ReadAll(resp.Body)
242+ require.NoError(t, err)
243+ assert.JSONEq(t, `{"error":"image is required"}`+"\n", string(bodyBytes))
195244 }
196245
197246 func TestBootstrap_NormalizesImageBeforeStoring(t *testing.T) {
@@ -206,24 +255,26 @@ func TestBootstrap_NormalizesImageBeforeStoring(t *testing.T) {
206255 d.ContainerSvc = containerSvc
207256 d.SecretSvc = secretSvc
208257 })
258+ server := newScopedTestServer(t, handler, "admin:routes:write", "admin:config:write")
209259
210260 payload := dto.BootstrapRequest{Domain: "app.example.com", Image: "pitlane:v1"}
211261 configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Once()
212- configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: "reg.bnema.dev/pitlane"}).Return(nil).Once()
262+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: "reg.bnema.dev/pitlane", HTTPS: true}).Return(nil).Once()
213263
214264 body, err := json.Marshal(payload)
215265 require.NoError(t, err)
216266
217- req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body))
218- req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:config:write"))
219- rec := httptest.NewRecorder()
267+ req, err := http.NewRequest(http.MethodPost, server.URL+"/admin/bootstrap", bytes.NewReader(body))
268+ require.NoError(t, err)
220269
221- handler.ServeHTTP(rec, req)
270+ resp, err := server.Client().Do(req)
271+ require.NoError(t, err)
272+ defer resp.Body.Close()
222273
223- assert.Equal(t, http.StatusOK, rec.Code)
274+ assert.Equal(t, http.StatusOK, resp.StatusCode)
224275
225276 var response dto.BootstrapResponse
226- require.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
277+ require.NoError(t, json.NewDecoder(resp.Body).Decode(&response))
227278 assert.Equal(t, payload.Domain, response.Domain)
228279 assert.Equal(t, "reg.bnema.dev/pitlane", response.Image)
229280 assert.Equal(t, "push reg.bnema.dev/pitlane to trigger deployment", response.Next)
@@ -257,26 +308,64 @@ func TestBootstrap_AddRouteValidationErrors(t *testing.T) {
257308 d.ContainerSvc = containerSvc
258309 d.SecretSvc = secretSvc
259310 })
311+ server := newScopedTestServer(t, handler, "admin:routes:write", "admin:config:write")
260312
261313 payload := dto.BootstrapRequest{Domain: "app.example.com", Image: "myapp:latest"}
262314 configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Once()
263- configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: "reg.bnema.dev/myapp"}).Return(tt.mockErr).Once()
315+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: "reg.bnema.dev/myapp", HTTPS: true}).Return(tt.mockErr).Once()
264316
265317 body, err := json.Marshal(payload)
266318 require.NoError(t, err)
267319
268- req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body))
269- req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:config:write"))
270- rec := httptest.NewRecorder()
320+ req, err := http.NewRequest(http.MethodPost, server.URL+"/admin/bootstrap", bytes.NewReader(body))
321+ require.NoError(t, err)
271322
272- handler.ServeHTTP(rec, req)
323+ resp, err := server.Client().Do(req)
324+ require.NoError(t, err)
325+ defer resp.Body.Close()
273326
274- assert.Equal(t, http.StatusBadRequest, rec.Code)
275- assert.JSONEq(t, `{"error":"`+tt.mockErr.Error()+`"}`+"\n", rec.Body.String())
327+ assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
328+ bodyBytes, err := io.ReadAll(resp.Body)
329+ require.NoError(t, err)
330+ assert.JSONEq(t, `{"error":"`+tt.mockErr.Error()+`"}`+"\n", string(bodyBytes))
276331 })
277332 }
278333 }
279334
335+func TestBootstrap_InvalidRouteDomain(t *testing.T) {
336+ configSvc := inmocks.NewMockConfigService(t)
337+ authSvc := inmocks.NewMockAuthService(t)
338+ containerSvc := inmocks.NewMockContainerService(t)
339+ secretSvc := inmocks.NewMockSecretService(t)
340+
341+ handler := newTestHandler(t, func(d *HandlerDeps) {
342+ d.ConfigSvc = configSvc
343+ d.AuthSvc = authSvc
344+ d.ContainerSvc = containerSvc
345+ d.SecretSvc = secretSvc
346+ })
347+ server := newScopedTestServer(t, handler, "admin:routes:write", "admin:config:write")
348+
349+ payload := dto.BootstrapRequest{Domain: "invalid domain", Image: "myapp:latest"}
350+ configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Once()
351+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: "reg.bnema.dev/myapp", HTTPS: true}).Return(domain.ErrRouteDomainInvalid).Once()
352+
353+ body, err := json.Marshal(payload)
354+ require.NoError(t, err)
355+
356+ req, err := http.NewRequest(http.MethodPost, server.URL+"/admin/bootstrap", bytes.NewReader(body))
357+ require.NoError(t, err)
358+
359+ resp, err := server.Client().Do(req)
360+ require.NoError(t, err)
361+ defer resp.Body.Close()
362+
363+ assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
364+ bodyBytes, err := io.ReadAll(resp.Body)
365+ require.NoError(t, err)
366+ assert.JSONEq(t, `{"error":"`+domain.ErrRouteDomainInvalid.Error()+`"}`+"\n", string(bodyBytes))
367+}
368+
280369 func TestBootstrap_PartialFailure_AttachmentError(t *testing.T) {
281370 configSvc := inmocks.NewMockConfigService(t)
282371 authSvc := inmocks.NewMockAuthService(t)
@@ -289,6 +378,7 @@ func TestBootstrap_PartialFailure_AttachmentError(t *testing.T) {
289378 d.ContainerSvc = containerSvc
290379 d.SecretSvc = secretSvc
291380 })
381+ server := newScopedTestServer(t, handler, "admin:routes:write", "admin:config:write")
292382
293383 payload := dto.BootstrapRequest{
294384 Domain: "app.example.com",
@@ -297,22 +387,23 @@ func TestBootstrap_PartialFailure_AttachmentError(t *testing.T) {
297387 }
298388
299389 configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Once()
300- configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: "reg.bnema.dev/myapp"}).Return(nil).Once()
390+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: "reg.bnema.dev/myapp", HTTPS: true}).Return(nil).Once()
301391 configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(errors.New("attachment failed")).Once()
302392
303393 body, err := json.Marshal(payload)
304394 require.NoError(t, err)
305395
306- req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body))
307- req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:config:write"))
308- rec := httptest.NewRecorder()
396+ req, err := http.NewRequest(http.MethodPost, server.URL+"/admin/bootstrap", bytes.NewReader(body))
397+ require.NoError(t, err)
309398
310- handler.ServeHTTP(rec, req)
399+ resp, err := server.Client().Do(req)
400+ require.NoError(t, err)
401+ defer resp.Body.Close()
311402
312- assert.Equal(t, http.StatusInternalServerError, rec.Code)
403+ assert.Equal(t, http.StatusInternalServerError, resp.StatusCode)
313404
314405 var response dto.BootstrapResponse
315- require.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
406+ require.NoError(t, json.NewDecoder(resp.Body).Decode(&response))
316407 assert.Equal(t, []dto.BootstrapStep{
317408 {Name: "route", Status: "configured"},
318409 {Name: "attachment:postgres:16", Status: "failed"},
M internal/adapters/in/http/admin/handler.go +98 -61
@@ -17,7 +17,6 @@ import (
1717
1818 "github.com/bnema/gordon/internal/adapters/dto"
1919 "github.com/bnema/gordon/internal/boundaries/in"
20- "github.com/bnema/gordon/internal/boundaries/out"
2120 "github.com/bnema/gordon/internal/domain"
2221 "github.com/bnema/gordon/internal/usecase/config"
2322 "github.com/bnema/gordon/internal/usecase/registry"
@@ -35,21 +34,25 @@ type registryDeployService interface {
3534 in.DeployCoordinator
3635 }
3736
37+type reloadTrigger interface {
38+ Trigger(ctx context.Context) error
39+}
40+
3841 // Handler implements the HTTP handler for the admin API.
3942 type Handler struct {
40- configSvc in.ConfigService
41- authSvc in.AuthService
42- containerSvc in.ContainerService
43- backupSvc in.BackupService
44- imageSvc in.ImageService
45- healthSvc in.HealthService
46- secretSvc in.SecretService
47- logSvc in.LogService
48- volumeSvc in.VolumeService
49- registrySvc registryDeployService
50- previewSvc previewService
51- eventBus out.EventPublisher
52- log zerowrap.Logger
43+ configSvc in.ConfigService
44+ authSvc in.AuthService
45+ containerSvc in.ContainerService
46+ backupSvc in.BackupService
47+ imageSvc in.ImageService
48+ healthSvc in.HealthService
49+ secretSvc in.SecretService
50+ logSvc in.LogService
51+ volumeSvc in.VolumeService
52+ registrySvc registryDeployService
53+ previewSvc previewService
54+ reloadTrigger reloadTrigger
55+ log zerowrap.Logger
5356 }
5457
5558 // Type aliases for API responses using shared DTO types.
@@ -133,37 +136,37 @@ func toDatabaseInfoResponse(db domain.DBInfo) dto.DatabaseInfo {
133136
134137 // HandlerDeps contains all dependencies for the admin HTTP handler.
135138 type HandlerDeps struct {
136- ConfigSvc in.ConfigService
137- AuthSvc in.AuthService
138- ContainerSvc in.ContainerService
139- HealthSvc in.HealthService
140- SecretSvc in.SecretService
141- LogSvc in.LogService
142- RegistrySvc registryDeployService
143- EventBus out.EventPublisher
144- Log zerowrap.Logger
145- BackupSvc in.BackupService
146- PreviewSvc previewService
147- ImageSvc in.ImageService
148- VolumeSvc in.VolumeService
139+ ConfigSvc in.ConfigService
140+ AuthSvc in.AuthService
141+ ContainerSvc in.ContainerService
142+ HealthSvc in.HealthService
143+ SecretSvc in.SecretService
144+ LogSvc in.LogService
145+ RegistrySvc registryDeployService
146+ Log zerowrap.Logger
147+ BackupSvc in.BackupService
148+ PreviewSvc previewService
149+ ImageSvc in.ImageService
150+ VolumeSvc in.VolumeService
151+ ReloadTrigger reloadTrigger
149152 }
150153
151154 // NewHandler creates a new admin HTTP handler.
152155 func NewHandler(deps HandlerDeps) *Handler {
153156 return &Handler{
154- configSvc: deps.ConfigSvc,
155- authSvc: deps.AuthSvc,
156- containerSvc: deps.ContainerSvc,
157- backupSvc: deps.BackupSvc,
158- imageSvc: deps.ImageSvc,
159- healthSvc: deps.HealthSvc,
160- secretSvc: deps.SecretSvc,
161- logSvc: deps.LogSvc,
162- volumeSvc: deps.VolumeSvc,
163- registrySvc: deps.RegistrySvc,
164- previewSvc: deps.PreviewSvc,
165- eventBus: deps.EventBus,
166- log: deps.Log,
157+ configSvc: deps.ConfigSvc,
158+ authSvc: deps.AuthSvc,
159+ containerSvc: deps.ContainerSvc,
160+ backupSvc: deps.BackupSvc,
161+ imageSvc: deps.ImageSvc,
162+ healthSvc: deps.HealthSvc,
163+ secretSvc: deps.SecretSvc,
164+ logSvc: deps.LogSvc,
165+ volumeSvc: deps.VolumeSvc,
166+ registrySvc: deps.RegistrySvc,
167+ previewSvc: deps.PreviewSvc,
168+ reloadTrigger: deps.ReloadTrigger,
169+ log: deps.Log,
167170 }
168171 }
169172
@@ -378,11 +381,11 @@ func (h *Handler) handleBootstrap(w http.ResponseWriter, r *http.Request) {
378381 resp.Steps = append(resp.Steps, dto.BootstrapStep{Name: name, Status: status})
379382 }
380383
381- err = h.configSvc.AddRoute(ctx, domain.Route{Domain: req.Domain, Image: normalizedImage})
384+ err = h.configSvc.AddRoute(ctx, domain.Route{Domain: req.Domain, Image: normalizedImage, HTTPS: true})
382385 switch {
383386 case err == nil:
384387 addStep("route", "configured")
385- case errors.Is(err, domain.ErrRouteDomainEmpty), errors.Is(err, domain.ErrRouteImageEmpty):
388+ case errors.Is(err, domain.ErrRouteDomainEmpty), errors.Is(err, domain.ErrRouteDomainInvalid), errors.Is(err, domain.ErrRouteImageEmpty):
386389 addStep("route", "failed")
387390 h.sendError(w, http.StatusBadRequest, err.Error())
388391 return
@@ -570,17 +573,26 @@ func (h *Handler) handleRoutesPost(w http.ResponseWriter, r *http.Request) {
570573 // Limit request body size
571574 r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
572575
573- var route domain.Route
574- if err := json.NewDecoder(r.Body).Decode(&route); err != nil {
576+ var req struct {
577+ Domain string `json:"domain"`
578+ Image string `json:"image"`
579+ HTTPS *bool `json:"https"`
580+ }
581+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
575582 log.Warn().Err(err).Msg("invalid route JSON")
576583 h.sendError(w, http.StatusBadRequest, "invalid JSON")
577584 return
578585 }
579586
587+ route := domain.Route{Domain: req.Domain, Image: req.Image, HTTPS: true}
588+ if req.HTTPS != nil {
589+ route.HTTPS = *req.HTTPS
590+ }
591+
580592 if err := h.configSvc.AddRoute(ctx, route); err != nil {
581593 log.Error().Err(err).Str("domain", route.Domain).Msg("failed to add route")
582594 switch {
583- case errors.Is(err, domain.ErrRouteDomainEmpty), errors.Is(err, domain.ErrRouteImageEmpty):
595+ case errors.Is(err, domain.ErrRouteDomainEmpty), errors.Is(err, domain.ErrRouteDomainInvalid), errors.Is(err, domain.ErrRouteImageEmpty):
584596 h.sendError(w, http.StatusBadRequest, err.Error())
585597 default:
586598 h.sendError(w, http.StatusInternalServerError, "failed to add route")
@@ -610,21 +622,44 @@ func (h *Handler) handleRoutesPut(w http.ResponseWriter, r *http.Request, routeD
610622 // Limit request body size
611623 r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
612624
613- var route domain.Route
614- if err := json.NewDecoder(r.Body).Decode(&route); err != nil {
625+ var req struct {
626+ Image string `json:"image"`
627+ HTTPS *bool `json:"https"`
628+ }
629+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
615630 log.Warn().Err(err).Msg("invalid route JSON")
616631 h.sendError(w, http.StatusBadRequest, "invalid JSON")
617632 return
618633 }
619634
635+ storedRoute, err := h.configSvc.GetRoute(ctx, routeDomain)
636+ if err != nil {
637+ if errors.Is(err, domain.ErrRouteNotFound) {
638+ h.sendError(w, http.StatusNotFound, "route not found")
639+ return
640+ }
641+ log.Error().Err(err).Str("domain", routeDomain).Msg("failed to load route")
642+ h.sendError(w, http.StatusInternalServerError, "failed to update route")
643+ return
644+ }
645+ if storedRoute == nil {
646+ h.sendError(w, http.StatusNotFound, "route not found")
647+ return
648+ }
649+
650+ route := *storedRoute
620651 route.Domain = routeDomain
652+ route.Image = req.Image
653+ if req.HTTPS != nil {
654+ route.HTTPS = *req.HTTPS
655+ }
621656
622657 if err := h.configSvc.UpdateRoute(ctx, route); err != nil {
623658 log.Error().Err(err).Str("domain", routeDomain).Msg("failed to update route")
624659 switch {
625660 case errors.Is(err, domain.ErrRouteNotFound):
626661 h.sendError(w, http.StatusNotFound, "route not found")
627- case errors.Is(err, domain.ErrRouteDomainEmpty), errors.Is(err, domain.ErrRouteImageEmpty):
662+ case errors.Is(err, domain.ErrRouteDomainEmpty), errors.Is(err, domain.ErrRouteDomainInvalid), errors.Is(err, domain.ErrRouteImageEmpty):
628663 h.sendError(w, http.StatusBadRequest, err.Error())
629664 default:
630665 h.sendError(w, http.StatusInternalServerError, "failed to update route")
@@ -653,9 +688,12 @@ func (h *Handler) handleRoutesDelete(w http.ResponseWriter, r *http.Request, rou
653688
654689 if err := h.configSvc.RemoveRoute(ctx, routeDomain); err != nil {
655690 log.Error().Err(err).Str("domain", routeDomain).Msg("failed to remove route")
656- if errors.Is(err, domain.ErrRouteNotFound) {
691+ switch {
692+ case errors.Is(err, domain.ErrRouteNotFound):
657693 h.sendError(w, http.StatusNotFound, "route not found")
658- } else {
694+ case errors.Is(err, domain.ErrRouteDomainEmpty), errors.Is(err, domain.ErrRouteDomainInvalid):
695+ h.sendError(w, http.StatusBadRequest, "invalid route domain")
696+ default:
659697 h.sendError(w, http.StatusInternalServerError, "failed to remove route")
660698 }
661699 return
@@ -1170,20 +1208,19 @@ func (h *Handler) handleReload(w http.ResponseWriter, r *http.Request) {
11701208 return
11711209 }
11721210
1173- // Reload configuration from file into memory
1174- // Using Reload() instead of Load() to ensure we re-read the file from disk
1175- if err := h.configSvc.Reload(ctx); err != nil {
1176- log.Error().Err(err).Msg("failed to reload config")
1177- h.sendError(w, http.StatusInternalServerError, "failed to reload config")
1211+ if h.reloadTrigger == nil {
1212+ log.Error().Msg("reload trigger unavailable")
1213+ h.sendError(w, http.StatusInternalServerError, "reload trigger unavailable")
11781214 return
11791215 }
11801216
1181- // Publish manual reload event to sync containers
1182- // ManualReloadHandler starts missing containers without restarting running ones
1183- if h.eventBus != nil {
1184- if err := h.eventBus.Publish(domain.EventManualReload, nil); err != nil {
1185- log.Warn().Err(err).Msg("failed to publish manual reload event")
1186- }
1217+ reloadCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
1218+ defer cancel()
1219+
1220+ if err := h.reloadTrigger.Trigger(reloadCtx); err != nil {
1221+ log.Error().Err(err).Msg("failed to reload config")
1222+ h.sendError(w, http.StatusInternalServerError, "failed to reload config")
1223+ return
11871224 }
11881225
11891226 log.Info().Msg("config reloaded via admin API")
M internal/adapters/in/http/admin/handler_test.go +215 -20
@@ -17,6 +17,7 @@ import (
1717 "github.com/bnema/zerowrap"
1818 "github.com/stretchr/testify/assert"
1919 "github.com/stretchr/testify/mock"
20+ "github.com/stretchr/testify/require"
2021
2122 "github.com/bnema/gordon/internal/adapters/dto"
2223 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
@@ -28,6 +29,24 @@ type stubImageService struct {
2829 pruneFunc func(context.Context, domain.ImagePruneOptions) (domain.ImagePruneReport, error)
2930 }
3031
32+type noopReloadTrigger struct{}
33+
34+func (noopReloadTrigger) Trigger(context.Context) error { return nil }
35+
36+type reloadTriggerRecorder struct {
37+ calls int
38+ err error
39+ ctxErrAtCall error
40+ hasDeadline bool
41+}
42+
43+func (r *reloadTriggerRecorder) Trigger(ctx context.Context) error {
44+ r.calls++
45+ r.ctxErrAtCall = ctx.Err()
46+ _, r.hasDeadline = ctx.Deadline()
47+ return r.err
48+}
49+
3150 func (s *stubImageService) ListImages(ctx context.Context) ([]domain.ImageInfo, error) {
3251 if s.listImagesFunc == nil {
3352 return nil, nil
@@ -51,15 +70,27 @@ func ctxWithScopes(scopes ...string) context.Context {
5170 return context.WithValue(ctx, domain.ContextKeyScopes, scopes)
5271 }
5372
73+func newScopedTestServer(t *testing.T, handler http.Handler, scopes ...string) *httptest.Server {
74+ t.Helper()
75+
76+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
77+ handler.ServeHTTP(w, r.WithContext(ctxWithScopes(scopes...)))
78+ }))
79+ t.Cleanup(server.Close)
80+
81+ return server
82+}
83+
5484 func newTestHandler(t *testing.T, opts ...func(*HandlerDeps)) *Handler {
5585 t.Helper()
5686 deps := HandlerDeps{
57- ConfigSvc: inmocks.NewMockConfigService(t),
58- AuthSvc: inmocks.NewMockAuthService(t),
59- ContainerSvc: inmocks.NewMockContainerService(t),
60- HealthSvc: inmocks.NewMockHealthService(t),
61- SecretSvc: inmocks.NewMockSecretService(t),
62- Log: testLogger(),
87+ ConfigSvc: inmocks.NewMockConfigService(t),
88+ AuthSvc: inmocks.NewMockAuthService(t),
89+ ContainerSvc: inmocks.NewMockContainerService(t),
90+ HealthSvc: inmocks.NewMockHealthService(t),
91+ SecretSvc: inmocks.NewMockSecretService(t),
92+ Log: testLogger(),
93+ ReloadTrigger: noopReloadTrigger{},
6394 }
6495 for _, opt := range opts {
6596 opt(&deps)
@@ -201,6 +232,35 @@ func TestHandler_RoutesPost_RequiresWriteScope(t *testing.T) {
201232 }
202233 }
203234
235+func TestHandler_RoutesPost_InvalidRouteDomain(t *testing.T) {
236+ configSvc := inmocks.NewMockConfigService(t)
237+ authSvc := inmocks.NewMockAuthService(t)
238+ containerSvc := inmocks.NewMockContainerService(t)
239+ secretSvc := inmocks.NewMockSecretService(t)
240+
241+ handler := newTestHandler(t, func(d *HandlerDeps) {
242+ d.ConfigSvc = configSvc
243+ d.AuthSvc = authSvc
244+ d.ContainerSvc = containerSvc
245+ d.SecretSvc = secretSvc
246+ })
247+ server := newScopedTestServer(t, handler, "admin:routes:write")
248+
249+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: "localhost", Image: "myapp:latest", HTTPS: true}).Return(domain.ErrRouteDomainInvalid).Once()
250+
251+ req, err := http.NewRequest(http.MethodPost, server.URL+"/admin/routes", bytes.NewBufferString(`{"domain":"localhost","image":"myapp:latest"}`))
252+ require.NoError(t, err)
253+
254+ resp, err := server.Client().Do(req)
255+ require.NoError(t, err)
256+ defer resp.Body.Close()
257+
258+ assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
259+ body, err := io.ReadAll(resp.Body)
260+ require.NoError(t, err)
261+ assert.JSONEq(t, `{"error":"`+domain.ErrRouteDomainInvalid.Error()+`"}`, string(body))
262+}
263+
204264 func TestHandler_RoutesPut_RequiresWriteScope(t *testing.T) {
205265 configSvc := inmocks.NewMockConfigService(t)
206266 authSvc := inmocks.NewMockAuthService(t)
@@ -236,6 +296,7 @@ func TestHandler_RoutesPut_RequiresWriteScope(t *testing.T) {
236296 for _, tt := range tests {
237297 t.Run(tt.name, func(t *testing.T) {
238298 if tt.wantStatus == http.StatusOK {
299+ configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(&domain.Route{Domain: "app.example.com", Image: "myapp:v1", HTTPS: true}, nil).Maybe()
239300 configSvc.EXPECT().UpdateRoute(mock.Anything, mock.AnythingOfType("domain.Route")).Return(nil).Maybe()
240301 }
241302
@@ -250,13 +311,43 @@ func TestHandler_RoutesPut_RequiresWriteScope(t *testing.T) {
250311 }
251312 }
252313
314+func TestHandler_RoutesPut_InvalidRouteDomain(t *testing.T) {
315+ configSvc := inmocks.NewMockConfigService(t)
316+ authSvc := inmocks.NewMockAuthService(t)
317+ containerSvc := inmocks.NewMockContainerService(t)
318+ secretSvc := inmocks.NewMockSecretService(t)
319+
320+ handler := newTestHandler(t, func(d *HandlerDeps) {
321+ d.ConfigSvc = configSvc
322+ d.AuthSvc = authSvc
323+ d.ContainerSvc = containerSvc
324+ d.SecretSvc = secretSvc
325+ })
326+ server := newScopedTestServer(t, handler, "admin:routes:write")
327+
328+ configSvc.EXPECT().GetRoute(mock.Anything, "localhost").Return(&domain.Route{Domain: "localhost", Image: "myapp:v1", HTTPS: true}, nil).Once()
329+ configSvc.EXPECT().UpdateRoute(mock.Anything, domain.Route{Domain: "localhost", Image: "myapp:v2", HTTPS: true}).Return(domain.ErrRouteDomainInvalid).Once()
330+
331+ req, err := http.NewRequest(http.MethodPut, server.URL+"/admin/routes/localhost", bytes.NewBufferString(`{"image":"myapp:v2"}`))
332+ require.NoError(t, err)
333+
334+ resp, err := server.Client().Do(req)
335+ require.NoError(t, err)
336+ defer resp.Body.Close()
337+
338+ assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
339+ body, err := io.ReadAll(resp.Body)
340+ require.NoError(t, err)
341+ assert.JSONEq(t, `{"error":"`+domain.ErrRouteDomainInvalid.Error()+`"}`, string(body))
342+}
343+
253344 func TestHandler_RoutesPost_AllowsRouteCreationWithoutRegistryManifest(t *testing.T) {
254345 configSvc := inmocks.NewMockConfigService(t)
255346 authSvc := inmocks.NewMockAuthService(t)
256347 containerSvc := inmocks.NewMockContainerService(t)
257348 secretSvc := inmocks.NewMockSecretService(t)
258349 registrySvc := inmocks.NewMockRegistryService(t)
259- route := domain.Route{Domain: "app.example.com", Image: "myapp:latest"}
350+ route := domain.Route{Domain: "app.example.com", Image: "myapp:latest", HTTPS: true}
260351
261352 handler := newTestHandler(t, func(d *HandlerDeps) {
262353 d.ConfigSvc = configSvc
@@ -268,24 +359,57 @@ func TestHandler_RoutesPost_AllowsRouteCreationWithoutRegistryManifest(t *testin
268359
269360 configSvc.EXPECT().AddRoute(mock.Anything, route).Return(nil).Once()
270361
271- req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(`{"domain":"app.example.com","image":"myapp:latest"}`))
272- req = req.WithContext(ctxWithScopes("admin:routes:write"))
273- rec := httptest.NewRecorder()
362+ server := newScopedTestServer(t, handler, "admin:routes:write")
363+ req, err := http.NewRequest(http.MethodPost, server.URL+"/admin/routes", bytes.NewBufferString(`{"domain":"app.example.com","image":"myapp:latest"}`))
364+ require.NoError(t, err)
274365
275- handler.ServeHTTP(rec, req)
366+ resp, err := server.Client().Do(req)
367+ require.NoError(t, err)
368+ defer resp.Body.Close()
276369
277- assert.Equal(t, http.StatusCreated, rec.Code)
278- assert.JSONEq(t, `{"domain":"app.example.com","image":"myapp:latest","https":false}`, rec.Body.String())
370+ body, err := io.ReadAll(resp.Body)
371+ require.NoError(t, err)
372+ assert.Equal(t, http.StatusCreated, resp.StatusCode)
373+ assert.JSONEq(t, `{"domain":"app.example.com","image":"myapp:latest","https":true}`, string(body))
279374 registrySvc.AssertNotCalled(t, "GetManifest", mock.Anything, mock.Anything, mock.Anything)
280375 }
281376
282-func TestHandler_RoutesPut_AllowsRouteUpdateWithoutRegistryManifest(t *testing.T) {
377+func TestHandleRoutesPost_DefaultsHTTPSWhenOmitted(t *testing.T) {
378+ configSvc := inmocks.NewMockConfigService(t)
379+ authSvc := inmocks.NewMockAuthService(t)
380+ containerSvc := inmocks.NewMockContainerService(t)
381+ secretSvc := inmocks.NewMockSecretService(t)
382+
383+ handler := newTestHandler(t, func(d *HandlerDeps) {
384+ d.ConfigSvc = configSvc
385+ d.AuthSvc = authSvc
386+ d.ContainerSvc = containerSvc
387+ d.SecretSvc = secretSvc
388+ })
389+
390+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:latest", HTTPS: true}).Return(nil).Once()
391+
392+ server := newScopedTestServer(t, handler, "admin:routes:write")
393+ req, err := http.NewRequest(http.MethodPost, server.URL+"/admin/routes", bytes.NewBufferString(`{"domain":"app.example.com","image":"myapp:latest"}`))
394+ require.NoError(t, err)
395+
396+ resp, err := server.Client().Do(req)
397+ require.NoError(t, err)
398+ defer resp.Body.Close()
399+
400+ body, err := io.ReadAll(resp.Body)
401+ require.NoError(t, err)
402+ assert.Equal(t, http.StatusCreated, resp.StatusCode)
403+ assert.JSONEq(t, `{"domain":"app.example.com","image":"myapp:latest","https":true}`, string(body))
404+}
405+
406+func TestHandleRoutesPut_PreservesStoredHTTPSWhenOmitted(t *testing.T) {
283407 configSvc := inmocks.NewMockConfigService(t)
284408 authSvc := inmocks.NewMockAuthService(t)
285409 containerSvc := inmocks.NewMockContainerService(t)
286410 secretSvc := inmocks.NewMockSecretService(t)
287411 registrySvc := inmocks.NewMockRegistryService(t)
288- route := domain.Route{Domain: "app.example.com", Image: "myapp:v2"}
412+ route := domain.Route{Domain: "app.example.com", Image: "myapp:v2", HTTPS: false}
289413
290414 handler := newTestHandler(t, func(d *HandlerDeps) {
291415 d.ConfigSvc = configSvc
@@ -295,6 +419,7 @@ func TestHandler_RoutesPut_AllowsRouteUpdateWithoutRegistryManifest(t *testing.T
295419 d.RegistrySvc = registrySvc
296420 })
297421
422+ configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(&domain.Route{Domain: "app.example.com", Image: "myapp:latest", HTTPS: false}, nil).Once()
298423 configSvc.EXPECT().UpdateRoute(mock.Anything, route).Return(nil).Once()
299424
300425 req := httptest.NewRequest("PUT", "/admin/routes/app.example.com", bytes.NewBufferString(`{"image":"myapp:v2"}`))
@@ -308,6 +433,32 @@ func TestHandler_RoutesPut_AllowsRouteUpdateWithoutRegistryManifest(t *testing.T
308433 registrySvc.AssertNotCalled(t, "GetManifest", mock.Anything, mock.Anything, mock.Anything)
309434 }
310435
436+func TestHandleRoutesPut_OverridesStoredHTTPSWhenProvided(t *testing.T) {
437+ configSvc := inmocks.NewMockConfigService(t)
438+ authSvc := inmocks.NewMockAuthService(t)
439+ containerSvc := inmocks.NewMockContainerService(t)
440+ secretSvc := inmocks.NewMockSecretService(t)
441+
442+ handler := newTestHandler(t, func(d *HandlerDeps) {
443+ d.ConfigSvc = configSvc
444+ d.AuthSvc = authSvc
445+ d.ContainerSvc = containerSvc
446+ d.SecretSvc = secretSvc
447+ })
448+
449+ configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(&domain.Route{Domain: "app.example.com", Image: "myapp:latest", HTTPS: false}, nil).Once()
450+ configSvc.EXPECT().UpdateRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:v2", HTTPS: true}).Return(nil).Once()
451+
452+ req := httptest.NewRequest("PUT", "/admin/routes/app.example.com", bytes.NewBufferString(`{"image":"myapp:v2","https":true}`))
453+ req = req.WithContext(ctxWithScopes("admin:routes:write"))
454+ rec := httptest.NewRecorder()
455+
456+ handler.ServeHTTP(rec, req)
457+
458+ assert.Equal(t, http.StatusOK, rec.Code)
459+ assert.JSONEq(t, `{"domain":"app.example.com","image":"myapp:v2","https":true}`, rec.Body.String())
460+}
461+
311462 func TestHandler_AttachmentsByImageGet_OneTarget(t *testing.T) {
312463 configSvc := inmocks.NewMockConfigService(t)
313464 authSvc := inmocks.NewMockAuthService(t)
@@ -769,7 +920,7 @@ func TestHandler_Bootstrap_HappyPath(t *testing.T) {
769920 })
770921
771922 configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Once()
772- configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "reg.bnema.dev/myapp"}).Return(nil).Once()
923+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "reg.bnema.dev/myapp", HTTPS: true}).Return(nil).Once()
773924 configSvc.EXPECT().AddAttachment(mock.Anything, "app.example.com", "postgres:16").Return(nil).Once()
774925 secretSvc.EXPECT().Set(mock.Anything, "app.example.com", map[string]string{"APP_ENV": "prod"}).Return(nil).Once()
775926 secretSvc.EXPECT().SetAttachment(mock.Anything, "app.example.com", "postgres", map[string]string{"POSTGRES_PASSWORD": "secret"}).Return(nil).Once()
@@ -838,7 +989,7 @@ func TestHandler_Bootstrap_RequiresPermissions(t *testing.T) {
838989 configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Once()
839990 }
840991 if tt.wantStatus == http.StatusOK {
841- configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "reg.bnema.dev/myapp"}).Return(nil).Once()
992+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "reg.bnema.dev/myapp", HTTPS: true}).Return(nil).Once()
842993 }
843994
844995 req := httptest.NewRequest("POST", "/admin/bootstrap", bytes.NewBufferString(tt.body))
@@ -980,12 +1131,14 @@ func TestHandler_Reload_RequiresWriteScope(t *testing.T) {
9801131 authSvc := inmocks.NewMockAuthService(t)
9811132 containerSvc := inmocks.NewMockContainerService(t)
9821133 secretSvc := inmocks.NewMockSecretService(t)
1134+ reloadTrigger := &reloadTriggerRecorder{}
9831135
9841136 handler := newTestHandler(t, func(d *HandlerDeps) {
9851137 d.ConfigSvc = configSvc
9861138 d.AuthSvc = authSvc
9871139 d.ContainerSvc = containerSvc
9881140 d.SecretSvc = secretSvc
1141+ d.ReloadTrigger = reloadTrigger
9891142 })
9901143
9911144 tests := []struct {
@@ -1017,9 +1170,7 @@ func TestHandler_Reload_RequiresWriteScope(t *testing.T) {
10171170
10181171 for _, tt := range tests {
10191172 t.Run(tt.name, func(t *testing.T) {
1020- if tt.wantStatus == http.StatusOK {
1021- configSvc.EXPECT().Reload(mock.Anything).Return(nil).Maybe()
1022- }
1173+ reloadTrigger.calls = 0
10231174
10241175 req := httptest.NewRequest("POST", "/admin/reload", nil)
10251176 req = req.WithContext(ctxWithScopes(tt.scopes...))
@@ -1028,10 +1179,54 @@ func TestHandler_Reload_RequiresWriteScope(t *testing.T) {
10281179 handler.ServeHTTP(rec, req)
10291180
10301181 assert.Equal(t, tt.wantStatus, rec.Code)
1182+ if tt.wantStatus == http.StatusOK {
1183+ assert.Equal(t, 1, reloadTrigger.calls)
1184+ } else {
1185+ assert.Zero(t, reloadTrigger.calls)
1186+ }
10311187 })
10321188 }
10331189 }
10341190
1191+func TestHandler_Reload_ReturnsServerErrorOnTriggerFailure(t *testing.T) {
1192+ reloadTrigger := &reloadTriggerRecorder{err: errors.New("reload failed")}
1193+
1194+ handler := newTestHandler(t, func(d *HandlerDeps) {
1195+ d.ReloadTrigger = reloadTrigger
1196+ })
1197+
1198+ req := httptest.NewRequest("POST", "/admin/reload", nil)
1199+ req = req.WithContext(ctxWithScopes("admin:config:write"))
1200+ rec := httptest.NewRecorder()
1201+
1202+ handler.ServeHTTP(rec, req)
1203+
1204+ assert.Equal(t, http.StatusInternalServerError, rec.Code)
1205+ assert.Equal(t, 1, reloadTrigger.calls)
1206+}
1207+
1208+func TestHandler_Reload_UsesDetachedContextWithDeadline(t *testing.T) {
1209+ reloadTrigger := &reloadTriggerRecorder{}
1210+
1211+ handler := newTestHandler(t, func(d *HandlerDeps) {
1212+ d.ReloadTrigger = reloadTrigger
1213+ })
1214+
1215+ reqCtx, cancel := context.WithCancel(ctxWithScopes("admin:config:write"))
1216+ cancel()
1217+
1218+ req := httptest.NewRequest("POST", "/admin/reload", nil)
1219+ req = req.WithContext(reqCtx)
1220+ rec := httptest.NewRecorder()
1221+
1222+ handler.ServeHTTP(rec, req)
1223+
1224+ assert.Equal(t, http.StatusOK, rec.Code)
1225+ assert.Equal(t, 1, reloadTrigger.calls)
1226+ require.NoError(t, reloadTrigger.ctxErrAtCall)
1227+ assert.True(t, reloadTrigger.hasDeadline)
1228+}
1229+
10351230 // Functional tests
10361231
10371232 func TestHandler_RoutesGet_ReturnsRoutes(t *testing.T) {
M internal/app/run.go +177 -97
@@ -16,12 +16,12 @@ import (
1616 "path/filepath"
1717 "strconv"
1818 "strings"
19+ "sync"
1920 "syscall"
2021 "time"
2122
2223 "github.com/bnema/zerowrap"
2324 zerowrapotel "github.com/bnema/zerowrap/otel"
24- "github.com/fsnotify/fsnotify"
2525 "github.com/spf13/viper"
2626
2727 // Adapters - Output
@@ -188,35 +188,36 @@ type Config struct {
188188
189189 // services holds all the services used by the application.
190190 type services struct {
191- runtime *docker.Runtime
192- eventBus *eventbus.InMemory
193- blobStorage *filesystem.BlobStorage
194- manifestStorage *filesystem.ManifestStorage
195- backupStorage *filesystem.BackupStorage
196- envLoader out.EnvLoader
197- logWriter *logwriter.LogWriter
198- tokenStore out.TokenStore
199- configSvc *config.Service
200- secretSvc *secretsSvc.Service
201- containerSvc *container.Service
202- backupSvc *backup.Service
203- registrySvc *registrySvc.Service
204- healthSvc *health.Service
205- logSvc *logs.Service
206- imageSvc *images.Service
207- volumeSvc *volumesSvc.Service
208- proxySvc *proxy.Service
209- authSvc *auth.Service
210- authHandler *authhandler.Handler
211- adminHandler *admin.Handler
212- internalRegUser string
213- internalRegPass string
214- previewStore *filesystem.PreviewStore
215- previewService *preview.Service
216- envDir string
217- maxBlobChunkSize int64
218- caAdapter *pkiadapter.CA
219- pkiSvc *pkiusecase.Service
191+ runtime *docker.Runtime
192+ eventBus *eventbus.InMemory
193+ blobStorage *filesystem.BlobStorage
194+ manifestStorage *filesystem.ManifestStorage
195+ backupStorage *filesystem.BackupStorage
196+ envLoader out.EnvLoader
197+ logWriter *logwriter.LogWriter
198+ tokenStore out.TokenStore
199+ configSvc *config.Service
200+ secretSvc *secretsSvc.Service
201+ containerSvc *container.Service
202+ backupSvc *backup.Service
203+ registrySvc *registrySvc.Service
204+ healthSvc *health.Service
205+ logSvc *logs.Service
206+ imageSvc *images.Service
207+ volumeSvc *volumesSvc.Service
208+ proxySvc *proxy.Service
209+ authSvc *auth.Service
210+ authHandler *authhandler.Handler
211+ adminHandler *admin.Handler
212+ internalRegUser string
213+ internalRegPass string
214+ previewStore *filesystem.PreviewStore
215+ previewService *preview.Service
216+ envDir string
217+ maxBlobChunkSize int64
218+ caAdapter *pkiadapter.CA
219+ pkiSvc *pkiusecase.Service
220+ reloadCoordinator *reloadCoordinator
220221 }
221222
222223 // Run initializes and starts the Gordon application.
@@ -262,12 +263,7 @@ func Run(ctx context.Context, configPath string) error {
262263
263264 log.Info().Msg("Gordon starting")
264265
265- // Deprecation warnings for removed TLS config fields
266- for _, key := range []string{"server.tls_enabled", "server.force_hsts"} {
267- if v.IsSet(key) {
268- log.Warn().Str("key", key).Msg("deprecated config key — Gordon now uses an internal CA with automatic TLS; remove this from your config")
269- }
270- }
266+ warnDeprecatedConfigKeys(v, log)
271267
272268 // Create PID file
273269 pidFile := createPidFile(log)
@@ -290,7 +286,9 @@ func Run(ctx context.Context, configPath string) error {
290286 // timers before graceful shutdown, preventing deploys during drain.
291287
292288 // Set up config hot reload
293- setupConfigHotReload(ctx, v, svc, log)
289+ if err := setupConfigHotReload(ctx, svc.configSvc, svc.reloadCoordinator); err != nil {
290+ return err
291+ }
294292
295293 // Start event bus
296294 if err := svc.eventBus.Start(); err != nil {
@@ -299,7 +297,15 @@ func Run(ctx context.Context, configPath string) error {
299297 defer svc.eventBus.Stop()
300298
301299 // Start servers, wait for listeners to bind, then sync/auto-start containers.
302- return runServers(ctx, v, cfg, svc, cleanupHandlers, log)
300+ return runServers(ctx, v, cfg, svc, svc.reloadCoordinator, cleanupHandlers, log)
301+}
302+
303+func warnDeprecatedConfigKeys(v *viper.Viper, log zerowrap.Logger) {
304+ for _, key := range []string{"server.tls_enabled", "server.force_hsts"} {
305+ if v.IsSet(key) {
306+ log.Warn().Str("key", key).Msg("deprecated config key — Gordon now uses an internal CA with automatic TLS; remove this from your config")
307+ }
308+ }
303309 }
304310
305311 // initConfig loads configuration from file.
@@ -454,6 +460,8 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
454460 return nil, err
455461 }
456462
463+ si.svc.reloadCoordinator = newReloadCoordinator(v, si.svc.configSvc, si.svc.proxySvc, si.svc.eventBus, log)
464+
457465 si.initHandlers()
458466
459467 return si.svc, nil
@@ -545,19 +553,19 @@ func (si *serviceInit) initHandlers() {
545553 initPreviewService(si.ctx, si.cfg, si.svc, si.log)
546554
547555 si.svc.adminHandler = admin.NewHandler(admin.HandlerDeps{
548- ConfigSvc: si.svc.configSvc,
549- AuthSvc: si.svc.authSvc,
550- ContainerSvc: si.svc.containerSvc,
551- HealthSvc: si.svc.healthSvc,
552- SecretSvc: si.svc.secretSvc,
553- LogSvc: si.svc.logSvc,
554- RegistrySvc: si.svc.registrySvc,
555- EventBus: si.svc.eventBus,
556- Log: si.log,
557- BackupSvc: si.svc.backupSvc,
558- PreviewSvc: si.svc.previewService,
559- ImageSvc: si.svc.imageSvc,
560- VolumeSvc: si.svc.volumeSvc,
556+ ConfigSvc: si.svc.configSvc,
557+ AuthSvc: si.svc.authSvc,
558+ ContainerSvc: si.svc.containerSvc,
559+ HealthSvc: si.svc.healthSvc,
560+ SecretSvc: si.svc.secretSvc,
561+ LogSvc: si.svc.logSvc,
562+ RegistrySvc: si.svc.registrySvc,
563+ ReloadTrigger: si.svc.reloadCoordinator,
564+ Log: si.log,
565+ BackupSvc: si.svc.backupSvc,
566+ PreviewSvc: si.svc.previewService,
567+ ImageSvc: si.svc.imageSvc,
568+ VolumeSvc: si.svc.volumeSvc,
561569 })
562570 }
563571
@@ -1343,6 +1351,112 @@ type proxyConfigResult struct {
13431351 maxBlobChunkSize int64
13441352 }
13451353
1354+type configWatcher interface {
1355+ Watch(ctx context.Context, onChange func()) error
1356+}
1357+
1358+type configReloader interface {
1359+ Reload(ctx context.Context) error
1360+}
1361+
1362+type proxyConfigUpdater interface {
1363+ UpdateConfig(config proxy.Config)
1364+}
1365+
1366+type reloadTrigger interface {
1367+ Trigger(ctx context.Context) error
1368+}
1369+
1370+type loadedConfigApplier interface {
1371+ ApplyLoadedConfig(ctx context.Context) error
1372+}
1373+
1374+type reloadCoordinator struct {
1375+ mu sync.Mutex
1376+ lastRun time.Time
1377+ debounce time.Duration
1378+
1379+ configSvc configReloader
1380+ v *viper.Viper
1381+ proxySvc proxyConfigUpdater
1382+ eventBus out.EventPublisher
1383+ log zerowrap.Logger
1384+}
1385+
1386+func newReloadCoordinator(v *viper.Viper, configSvc configReloader, proxySvc proxyConfigUpdater, eventBus out.EventPublisher, log zerowrap.Logger) *reloadCoordinator {
1387+ return &reloadCoordinator{
1388+ debounce: 500 * time.Millisecond,
1389+ configSvc: configSvc,
1390+ v: v,
1391+ proxySvc: proxySvc,
1392+ eventBus: eventBus,
1393+ log: log,
1394+ }
1395+}
1396+
1397+func (c *reloadCoordinator) Trigger(ctx context.Context) error {
1398+ c.mu.Lock()
1399+ defer c.mu.Unlock()
1400+
1401+ return c.reloadLocked(ctx, true)
1402+}
1403+
1404+func (c *reloadCoordinator) ApplyLoadedConfig(ctx context.Context) error {
1405+ c.mu.Lock()
1406+ defer c.mu.Unlock()
1407+
1408+ return c.reloadLocked(ctx, false)
1409+}
1410+
1411+func (c *reloadCoordinator) reloadLocked(ctx context.Context, loadConfig bool) error {
1412+ now := time.Now()
1413+ if !c.lastRun.IsZero() && now.Sub(c.lastRun) < c.debounce {
1414+ c.log.Debug().Dur("since_last_reload", now.Sub(c.lastRun)).Msg("skipping config reload trigger due to debounce")
1415+ return nil
1416+ }
1417+
1418+ if loadConfig {
1419+ if err := c.configSvc.Reload(ctx); err != nil {
1420+ c.log.Error().Err(err).Msg("failed to reload config")
1421+ return fmt.Errorf("failed to reload config: %w", err)
1422+ }
1423+ }
1424+
1425+ if err := c.applyLoadedConfig(now); err != nil {
1426+ return err
1427+ }
1428+
1429+ return nil
1430+}
1431+
1432+func (c *reloadCoordinator) applyLoadedConfig(now time.Time) error {
1433+ var reloadCfg Config
1434+ if err := c.v.Unmarshal(&reloadCfg); err != nil {
1435+ c.log.Error().Err(err).Msg("failed to unmarshal config on reload")
1436+ return fmt.Errorf("failed to unmarshal config on reload: %w", err)
1437+ }
1438+
1439+ reloadedProxy, err := buildProxyConfig(reloadCfg, c.log)
1440+ if err != nil {
1441+ c.log.Error().Err(err).Msg("failed to parse proxy config on reload")
1442+ return fmt.Errorf("failed to parse proxy config on reload: %w", err)
1443+ }
1444+
1445+ c.proxySvc.UpdateConfig(reloadedProxy.proxyConfig)
1446+
1447+ if c.eventBus != nil {
1448+ if err := c.eventBus.Publish(domain.EventConfigReload, nil); err != nil {
1449+ c.log.Error().Err(err).Msg("failed to publish config reload event")
1450+ return fmt.Errorf("failed to publish config reload event: %w", err)
1451+ }
1452+ }
1453+
1454+ c.lastRun = now
1455+
1456+ c.log.Debug().Msg("config hot reload complete")
1457+ return nil
1458+}
1459+
13461460 // buildProxyConfig parses size-related config fields and builds the proxy config.
13471461 func buildProxyConfig(cfg Config, log zerowrap.Logger) (*proxyConfigResult, error) {
13481462 maxProxyBodySize := int64(512 << 20) // 512MB default
@@ -1550,7 +1664,6 @@ func registerEventHandlers(ctx context.Context, svc *services, cfg Config) (func
15501664 autoPreviewHandler := preview.NewAutoPreviewHandler(
15511665 ctx,
15521666 svc.configSvc,
1553- svc.blobStorage,
15541667 svc.previewService,
15551668 )
15561669
@@ -1565,11 +1678,6 @@ func registerEventHandlers(ctx context.Context, svc *services, cfg Config) (func
15651678 return nil, fmt.Errorf("failed to subscribe config reload handler: %w", err)
15661679 }
15671680
1568- manualReloadHandler := container.NewManualReloadHandler(ctx, svc.containerSvc, svc.configSvc)
1569- if err := svc.eventBus.Subscribe(manualReloadHandler); err != nil {
1570- return nil, fmt.Errorf("failed to subscribe manual reload handler: %w", err)
1571- }
1572-
15731681 manualDeployHandler := container.NewManualDeployHandler(ctx, svc.containerSvc, svc.configSvc)
15741682 if err := svc.eventBus.Subscribe(manualDeployHandler); err != nil {
15751683 return nil, fmt.Errorf("failed to subscribe manual deploy handler: %w", err)
@@ -1593,41 +1701,15 @@ func registerEventHandlers(ctx context.Context, svc *services, cfg Config) (func
15931701 return cleanup, nil
15941702 }
15951703
1596-// setupConfigHotReload sets up Viper config hot reload.
1597-// NOTE: This does NOT reload routes into memory. Routes are managed via API
1598-// (AddRoute/UpdateRoute/RemoveRoute) and memory is the source of truth.
1599-// The file watcher only updates proxy config and refreshes targets.
1600-// Manual config file edits to routes require a server restart.
1601-func setupConfigHotReload(ctx context.Context, v *viper.Viper, svc *services, log zerowrap.Logger) {
1602- v.OnConfigChange(func(e fsnotify.Event) {
1603- log.Info().Str("file", e.Name).Msg("config file changed")
1604-
1605- if err := v.ReadInConfig(); err != nil {
1606- log.Error().Err(err).Msg("failed to reload config")
1607- return
1608- }
1609-
1610- // Re-unmarshal config and rebuild proxy config to pick up all changes
1611- var reloadCfg Config
1612- if err := v.Unmarshal(&reloadCfg); err != nil {
1613- log.Error().Err(err).Msg("failed to unmarshal config on reload")
1614- return
1615- }
1616- reloadedProxy, err := buildProxyConfig(reloadCfg, log)
1617- if err != nil {
1618- log.Error().Err(err).Msg("failed to parse proxy config on reload")
1619- return
1620- }
1621- svc.proxySvc.UpdateConfig(reloadedProxy.proxyConfig)
1622-
1623- // Clear proxy target cache to pick up external route changes
1624- if err := svc.proxySvc.RefreshTargets(ctx); err != nil {
1625- log.Warn().Err(err).Msg("failed to refresh proxy targets")
1626- }
1704+// setupConfigHotReload sets up config hot reload.
1705+func setupConfigHotReload(ctx context.Context, configSvc configWatcher, coordinator loadedConfigApplier) error {
1706+ if err := configSvc.Watch(ctx, func() {
1707+ _ = coordinator.ApplyLoadedConfig(ctx)
1708+ }); err != nil {
1709+ return fmt.Errorf("failed to watch config: %w", err)
1710+ }
16271711
1628- log.Debug().Msg("config hot reload complete (routes unchanged, memory is source of truth)")
1629- })
1630- v.WatchConfig()
1712+ return nil
16311713 }
16321714
16331715 // syncAndAutoStart syncs existing containers and auto-starts if configured.
@@ -2054,7 +2136,7 @@ func registerAdminRoutes(registryMux *http.ServeMux, svc *services, cfg Config,
20542136 // - SIGUSR2: Triggers manual deploy for a specific route
20552137 // The deferred signal.Stop calls ensure signal handlers are properly
20562138 // cleaned up before program exit, preventing signal handler leaks.
2057-func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services, cleanupHandlers func(), log zerowrap.Logger) error {
2139+func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services, reload reloadTrigger, cleanupHandlers func(), log zerowrap.Logger) error {
20582140 // Initialize access log writer. Kept here (not in Run) to keep Run's cyclomatic
20592141 // complexity within the project limit of 15.
20602142 accessWriterConcrete, err := initAccessLog(cfg, log)
@@ -2148,7 +2230,7 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services,
21482230 // Auto-start after servers are listening (registry port is now bound).
21492231 syncAndAutoStart(ctx, svc, log)
21502232
2151- waitForShutdown(ctx, errChan, reloadChan, deployChan, svc.eventBus, log)
2233+ waitForShutdown(ctx, errChan, reloadChan, deployChan, reload, svc.eventBus, log)
21522234 cleanupHandlers() // Stop debounce timers before draining containers
21532235 gracefulShutdown(registrySrv, proxySrv, tlsSrv, svc.containerSvc, svc.proxySvc, svc.pkiSvc, log)
21542236 return nil
@@ -2321,7 +2403,7 @@ func resolveSchedulePreset(raw, name string, defaultVal domain.BackupSchedule) (
23212403
23222404 // waitForShutdown blocks on the event loop, handling server errors and
23232405 // Unix signals (reload, deploy, shutdown) until the context is cancelled.
2324-func waitForShutdown(ctx context.Context, errChan <-chan error, reloadChan, deployChan <-chan os.Signal, eventBus out.EventBus, log zerowrap.Logger) {
2406+func waitForShutdown(ctx context.Context, errChan <-chan error, reloadChan, deployChan <-chan os.Signal, reload reloadTrigger, eventBus out.EventBus, log zerowrap.Logger) {
23252407 for {
23262408 select {
23272409 case err := <-errChan:
@@ -2329,9 +2411,7 @@ func waitForShutdown(ctx context.Context, errChan <-chan error, reloadChan, depl
23292411 return
23302412 case <-reloadChan:
23312413 log.Info().Msg("reload signal received (SIGUSR1)")
2332- if err := eventBus.Publish(domain.EventManualReload, nil); err != nil {
2333- log.Error().Err(err).Msg("failed to publish manual reload event")
2334- }
2414+ _ = reload.Trigger(ctx)
23352415 case <-deployChan:
23362416 log.Info().Msg("deploy signal received (SIGUSR2)")
23372417 domainName, err := readDeployRequest()
A internal/app/run_reload_test.go +319 -0
@@ -0,0 +1,319 @@
1+package app
2+
3+import (
4+ "context"
5+ "errors"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/zerowrap"
11+ "github.com/spf13/viper"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+
15+ "github.com/bnema/gordon/internal/domain"
16+ "github.com/bnema/gordon/internal/usecase/proxy"
17+)
18+
19+type watchRecorder struct {
20+ called bool
21+ onChange func()
22+}
23+
24+func (w *watchRecorder) Watch(ctx context.Context, onChange func()) error {
25+ w.called = true
26+ w.onChange = onChange
27+ return nil
28+}
29+
30+type coordinatorRecorder struct {
31+ mu sync.Mutex
32+ triggerCalls int
33+ applyCalls int
34+ err error
35+}
36+
37+func (c *coordinatorRecorder) Trigger(context.Context) error {
38+ c.mu.Lock()
39+ c.triggerCalls++
40+ err := c.err
41+ c.mu.Unlock()
42+ return err
43+}
44+
45+func (c *coordinatorRecorder) ApplyLoadedConfig(context.Context) error {
46+ c.mu.Lock()
47+ c.applyCalls++
48+ err := c.err
49+ c.mu.Unlock()
50+ return err
51+}
52+
53+func (c *coordinatorRecorder) TriggerCalls() int {
54+ c.mu.Lock()
55+ defer c.mu.Unlock()
56+ return c.triggerCalls
57+}
58+
59+func (c *coordinatorRecorder) ApplyCalls() int {
60+ c.mu.Lock()
61+ defer c.mu.Unlock()
62+ return c.applyCalls
63+}
64+
65+type reloadRecorder struct {
66+ mu sync.Mutex
67+ calls int
68+ err error
69+ onReload func()
70+}
71+
72+func (r *reloadRecorder) Reload(context.Context) error {
73+ r.mu.Lock()
74+ r.calls++
75+ err := r.err
76+ onReload := r.onReload
77+ r.mu.Unlock()
78+
79+ if onReload != nil {
80+ onReload()
81+ }
82+
83+ return err
84+}
85+
86+func (r *reloadRecorder) Calls() int {
87+ r.mu.Lock()
88+ defer r.mu.Unlock()
89+ return r.calls
90+}
91+
92+type proxyRecorder struct {
93+ calls int
94+ config proxy.Config
95+}
96+
97+func (p *proxyRecorder) UpdateConfig(config proxy.Config) {
98+ p.calls++
99+ p.config = config
100+}
101+
102+type eventBusRecorder struct {
103+ mu sync.Mutex
104+ calls int
105+ eventType domain.EventType
106+ payload any
107+ err error
108+}
109+
110+func (e *eventBusRecorder) Publish(eventType domain.EventType, payload any) error {
111+ e.mu.Lock()
112+ e.calls++
113+ e.eventType = eventType
114+ e.payload = payload
115+ err := e.err
116+ e.mu.Unlock()
117+ return err
118+}
119+
120+func (e *eventBusRecorder) Calls() int {
121+ e.mu.Lock()
122+ defer e.mu.Unlock()
123+ return e.calls
124+}
125+
126+func TestSetupConfigHotReload_WatchCallbackInvokesCoordinator(t *testing.T) {
127+ ctx := context.Background()
128+ configSvc := &watchRecorder{}
129+ coordinator := &coordinatorRecorder{}
130+
131+ err := setupConfigHotReload(ctx, configSvc, coordinator)
132+ require.NoError(t, err)
133+ require.True(t, configSvc.called)
134+ require.NotNil(t, configSvc.onChange)
135+
136+ configSvc.onChange()
137+ require.Equal(t, 1, coordinator.ApplyCalls())
138+ require.Equal(t, 0, coordinator.TriggerCalls())
139+}
140+
141+func TestReloadCoordinator_ApplyLoadedConfig_RebuildsProxyConfigAndPublishesEvent(t *testing.T) {
142+ ctx := context.Background()
143+ v := viper.New()
144+ v.Set("server.gordon_domain", "reload.example.com")
145+ v.Set("server.registry_port", 5000)
146+ v.Set("server.max_proxy_body_size", "5MB")
147+ v.Set("server.max_blob_chunk_size", "6MB")
148+ v.Set("server.max_proxy_response_size", "7MB")
149+ v.Set("server.max_concurrent_connections", 99)
150+
151+ reloadSvc := &reloadRecorder{}
152+ proxySvc := &proxyRecorder{}
153+ events := &eventBusRecorder{}
154+
155+ coord := newReloadCoordinator(v, reloadSvc, proxySvc, events, zerowrap.Default())
156+ require.NoError(t, coord.ApplyLoadedConfig(ctx))
157+
158+ require.Equal(t, 0, reloadSvc.Calls())
159+ require.Equal(t, 1, proxySvc.calls)
160+ require.Equal(t, 1, events.Calls())
161+ require.Equal(t, domain.EventConfigReload, events.eventType)
162+ require.Equal(t, proxy.Config{
163+ RegistryDomain: "reload.example.com",
164+ RegistryPort: 5000,
165+ MaxBodySize: 5 << 20,
166+ MaxResponseSize: 7 << 20,
167+ MaxConcurrentConns: 99,
168+ }, proxySvc.config)
169+}
170+
171+func TestReloadCoordinator_DebouncesRepeatedWatchCallbacks(t *testing.T) {
172+ ctx := context.Background()
173+ v := viper.New()
174+ v.Set("server.gordon_domain", "new.example.com")
175+ v.Set("server.registry_port", 5000)
176+ v.Set("server.max_proxy_body_size", "5MB")
177+ v.Set("server.max_blob_chunk_size", "6MB")
178+ v.Set("server.max_proxy_response_size", "7MB")
179+ v.Set("server.max_concurrent_connections", 99)
180+
181+ reloadSvc := &reloadRecorder{}
182+ proxySvc := &proxyRecorder{}
183+ events := &eventBusRecorder{}
184+
185+ coord := newReloadCoordinator(v, reloadSvc, proxySvc, events, zerowrap.Default())
186+ require.NoError(t, coord.Trigger(ctx))
187+ require.NoError(t, coord.Trigger(ctx))
188+
189+ require.Equal(t, 1, reloadSvc.Calls())
190+ require.Equal(t, 1, proxySvc.calls)
191+ require.Equal(t, 1, events.Calls())
192+ require.Equal(t, domain.EventConfigReload, events.eventType)
193+ require.Equal(t, proxy.Config{
194+ RegistryDomain: "new.example.com",
195+ RegistryPort: 5000,
196+ MaxBodySize: 5 << 20,
197+ MaxResponseSize: 7 << 20,
198+ MaxConcurrentConns: 99,
199+ }, proxySvc.config)
200+}
201+
202+func TestReloadCoordinator_RetriesImmediatelyAfterFailedReload(t *testing.T) {
203+ ctx := context.Background()
204+ v := viper.New()
205+ v.Set("server.gordon_domain", "retry.example.com")
206+ v.Set("server.registry_port", 5000)
207+ v.Set("server.max_proxy_body_size", "2MB")
208+ v.Set("server.max_blob_chunk_size", "3MB")
209+ v.Set("server.max_proxy_response_size", "4MB")
210+ v.Set("server.max_concurrent_connections", 11)
211+
212+ reloadErr := errors.New("reload failed")
213+ reloadSvc := &reloadRecorder{err: reloadErr}
214+ proxySvc := &proxyRecorder{}
215+ coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, zerowrap.Default())
216+
217+ err := coord.Trigger(ctx)
218+ require.ErrorIs(t, err, reloadErr)
219+
220+ reloadSvc.mu.Lock()
221+ reloadSvc.err = nil
222+ reloadSvc.mu.Unlock()
223+
224+ require.NoError(t, coord.Trigger(ctx))
225+ require.Equal(t, 2, reloadSvc.Calls())
226+ assert.Equal(t, 1, proxySvc.calls)
227+}
228+
229+func TestReloadCoordinator_PublishErrorDoesNotAdvanceDebounceState(t *testing.T) {
230+ ctx := context.Background()
231+ v := viper.New()
232+ v.Set("server.gordon_domain", "publish.example.com")
233+ v.Set("server.registry_port", 5000)
234+ v.Set("server.max_proxy_body_size", "5MB")
235+ v.Set("server.max_blob_chunk_size", "6MB")
236+ v.Set("server.max_proxy_response_size", "7MB")
237+ v.Set("server.max_concurrent_connections", 99)
238+
239+ publishErr := errors.New("publish failed")
240+ reloadSvc := &reloadRecorder{}
241+ proxySvc := &proxyRecorder{}
242+ events := &eventBusRecorder{err: publishErr}
243+
244+ coord := newReloadCoordinator(v, reloadSvc, proxySvc, events, zerowrap.Default())
245+
246+ err := coord.Trigger(ctx)
247+ require.ErrorIs(t, err, publishErr)
248+
249+ err = coord.Trigger(ctx)
250+ require.ErrorIs(t, err, publishErr)
251+
252+ require.Equal(t, 2, reloadSvc.Calls())
253+ require.Equal(t, 2, proxySvc.calls)
254+ require.Equal(t, 2, events.Calls())
255+ require.Equal(t, domain.EventConfigReload, events.eventType)
256+}
257+
258+func TestReloadCoordinator_SerializesOverlappingReloadRequests(t *testing.T) {
259+ ctx := context.Background()
260+ v := viper.New()
261+ v.Set("server.gordon_domain", "app.example.com")
262+ v.Set("server.registry_port", 5000)
263+ v.Set("server.max_proxy_body_size", "2MB")
264+ v.Set("server.max_blob_chunk_size", "3MB")
265+ v.Set("server.max_proxy_response_size", "4MB")
266+ v.Set("server.max_concurrent_connections", 11)
267+
268+ started := make(chan struct{})
269+ release := make(chan struct{})
270+ var once sync.Once
271+ reloadSvc := &reloadRecorder{onReload: func() {
272+ once.Do(func() { close(started) })
273+ <-release
274+ }}
275+ proxySvc := &proxyRecorder{}
276+ coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, zerowrap.Default())
277+
278+ firstDone := make(chan struct{})
279+ go func() {
280+ _ = coord.Trigger(ctx)
281+ close(firstDone)
282+ }()
283+
284+ select {
285+ case <-started:
286+ case <-time.After(2 * time.Second):
287+ t.Fatal("first reload did not start")
288+ }
289+
290+ secondDone := make(chan struct{})
291+ go func() {
292+ _ = coord.Trigger(ctx)
293+ close(secondDone)
294+ }()
295+
296+ select {
297+ case <-secondDone:
298+ t.Fatal("second reload finished before the first was released")
299+ case <-time.After(75 * time.Millisecond):
300+ }
301+
302+ require.Equal(t, 1, reloadSvc.Calls())
303+ close(release)
304+
305+ select {
306+ case <-firstDone:
307+ case <-time.After(2 * time.Second):
308+ t.Fatal("first reload did not finish")
309+ }
310+
311+ select {
312+ case <-secondDone:
313+ case <-time.After(2 * time.Second):
314+ t.Fatal("second reload did not finish")
315+ }
316+
317+ require.Equal(t, 1, reloadSvc.Calls())
318+ assert.Equal(t, 1, proxySvc.calls)
319+}
M internal/domain/env.go +13 -2
@@ -7,8 +7,11 @@ import (
77 "strings"
88 )
99
10-var envKeyRegex = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
11-var containerNameRegex = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]*$`)
10+var (
11+ envKeyRegex = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
12+ containerNameRegex = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]*$`)
13+ secretRefRegex = regexp.MustCompile(`\$\{(pass|sops):[^}]+\}`)
14+)
1215
1316 // SanitizeDomainForEnvFile validates and sanitizes a domain name for env file storage.
1417 // Returns the collision-resistant storage key used for filenames.
@@ -77,6 +80,14 @@ func ValidateEnvKey(key string) error {
7780 return nil
7881 }
7982
83+// ContainsSecretReference detects if a string contains a secret provider reference
84+// like ${pass:path} or ${sops:path}. This is used to prevent attacker-controlled
85+// env files from persisting secret references that would later resolve against
86+// host secret providers.
87+func ContainsSecretReference(value string) bool {
88+ return secretRefRegex.MatchString(value)
89+}
90+
8091 // ValidateContainerName validates a container name for attachment storage.
8192 // Container names can contain alphanumeric characters, hyphens, and underscores,
8293 // but must start with a letter.
M internal/domain/env_test.go +31 -0
@@ -179,6 +179,37 @@ func TestValidateEnvKey(t *testing.T) {
179179 }
180180 }
181181
182+func TestContainsSecretReference(t *testing.T) {
183+ tests := []struct {
184+ name string
185+ value string
186+ expected bool
187+ }{
188+ {name: "simple pass reference", value: "${pass:myapp/db}", expected: true},
189+ {name: "simple sops reference", value: "${sops:secrets.yaml#/key}", expected: true},
190+ {name: "pass reference in text", value: "prefix-${pass:path}-suffix", expected: true},
191+ {name: "sops reference in text", value: "${sops:file.yaml}", expected: true},
192+ {name: "no reference - plain text", value: "hello world", expected: false},
193+ {name: "no reference - dollar sign only", value: "$FOO", expected: false},
194+ {name: "no reference - empty braces", value: "${}", expected: false},
195+ {name: "no reference - missing colon", value: "${pass}", expected: false},
196+ {name: "no reference - extra braces", value: "${{pass:path}}", expected: false},
197+ {name: "normal env var", value: "${FOO}", expected: false},
198+ {name: "value with equals and reference", value: "key=${pass:secret}", expected: true},
199+ {name: "quoted reference", value: `"${pass:secret}"`, expected: true},
200+ {name: "multiple references", value: "${pass:a}${sops:b}", expected: true},
201+ {name: "case sensitive - uppercase", value: "${PASS:secret}", expected: false},
202+ {name: "case sensitive - mixed", value: "${Pass:secret}", expected: false},
203+ }
204+
205+ for _, tt := range tests {
206+ t.Run(tt.name, func(t *testing.T) {
207+ result := ContainsSecretReference(tt.value)
208+ assert.Equal(t, tt.expected, result)
209+ })
210+ }
211+}
212+
182213 func TestValidateContainerName(t *testing.T) {
183214 tests := []struct {
184215 name string
M internal/domain/errors.go +15 -13
@@ -18,12 +18,13 @@ var (
1818 ErrInvalidImageFormat = errors.New("invalid image format")
1919
2020 // Route errors
21- ErrRouteNotFound = errors.New("route not found")
22- ErrRouteExists = errors.New("route already exists")
23- ErrInvalidRoute = errors.New("invalid route configuration")
24- ErrRouteDomainEmpty = errors.New("route domain cannot be empty")
25- ErrRouteImageEmpty = errors.New("route image cannot be empty")
26- ErrNoTargetAvailable = errors.New("no target available for route")
21+ ErrRouteNotFound = errors.New("route not found")
22+ ErrRouteExists = errors.New("route already exists")
23+ ErrInvalidRoute = errors.New("invalid route configuration")
24+ ErrRouteDomainEmpty = errors.New("route domain cannot be empty")
25+ ErrRouteImageEmpty = errors.New("route image cannot be empty")
26+ ErrRouteDomainInvalid = errors.New("route domain is not a valid public hostname")
27+ ErrNoTargetAvailable = errors.New("no target available for route")
2728
2829 // Registry errors
2930 ErrManifestNotFound = errors.New("manifest not found")
@@ -64,13 +65,14 @@ var (
6465 ErrTokenNotFound = errors.New("token not found")
6566
6667 // Security errors
67- ErrPathTraversal = errors.New("path traversal not allowed")
68- ErrInvalidPath = errors.New("invalid path format")
69- ErrInsufficientScope = errors.New("insufficient scope for operation")
70- ErrInvalidScope = errors.New("invalid scope format")
71- ErrSSRFBlocked = errors.New("request to internal/blocked network not allowed")
72- ErrInvalidEnvKey = errors.New("invalid environment variable key")
73- ErrLongLivedToken = errors.New("long-lived tokens must be exchanged via /auth/token")
68+ ErrPathTraversal = errors.New("path traversal not allowed")
69+ ErrInvalidPath = errors.New("invalid path format")
70+ ErrInsufficientScope = errors.New("insufficient scope for operation")
71+ ErrInvalidScope = errors.New("invalid scope format")
72+ ErrSSRFBlocked = errors.New("request to internal/blocked network not allowed")
73+ ErrInvalidEnvKey = errors.New("invalid environment variable key")
74+ ErrEnvContainsSecretRef = errors.New("environment file contains secret reference")
75+ ErrLongLivedToken = errors.New("long-lived tokens must be exchanged via /auth/token")
7476
7577 // Remote errors
7678 ErrEmptyRemoteName = errors.New("remote name must not be empty")
M internal/domain/event.go +0 -1
@@ -12,7 +12,6 @@ const (
1212 EventImagePushed EventType = "image.pushed"
1313 EventImageDeleted EventType = "image.deleted"
1414 EventConfigReload EventType = "config.reload"
15- EventManualReload EventType = "manual.reload"
1615 EventManualDeploy EventType = "manual.deploy"
1716 EventContainerStop EventType = "container.stop"
1817 EventContainerStart EventType = "container.start"
A internal/domain/validation.go +94 -0
@@ -0,0 +1,94 @@
1+package domain
2+
3+import (
4+ "net"
5+ "regexp"
6+ "strings"
7+)
8+
9+// hostnamePattern matches valid hostname characters (alphanumeric, hyphen, dot).
10+// Used as a basic sanity check before detailed validation.
11+// The actual validation uses net.ParseIP and explicit checks.
12+var hostnamePattern = regexp.MustCompile(`^[a-zA-Z0-9.-]+$`)
13+
14+// IsValidRouteDomain validates that a domain is a public hostname suitable
15+// for use as an application route. It rejects IP literals, localhost,
16+// internal-only names, and authority strings containing ports.
17+//
18+// Valid: app.example.com, api.site.org, my-app.co.uk
19+// Invalid: 192.168.1.1, localhost, myapp.local, example.com:8080
20+func IsValidRouteDomain(domain string) bool {
21+ // Reject empty
22+ if domain == "" {
23+ return false
24+ }
25+
26+ // Basic character sanity check - only allow alphanumeric, hyphen, dot
27+ // This also rejects paths, query strings, schemes (which contain :/ etc.)
28+ if !hostnamePattern.MatchString(domain) {
29+ return false
30+ }
31+
32+ // Reject leading/trailing dots and consecutive dots
33+ if strings.HasPrefix(domain, ".") || strings.HasSuffix(domain, ".") ||
34+ strings.Contains(domain, "..") {
35+ return false
36+ }
37+
38+ // Reject total hostname length > 253 (RFC 1035)
39+ if len(domain) > 253 {
40+ return false
41+ }
42+
43+ // Must have at least one dot (domain + TLD minimum)
44+ // This also rejects single-label names like "localhost", "myapp"
45+ if !strings.Contains(domain, ".") {
46+ return false
47+ }
48+
49+ // Check if it looks like an IP address (IPv4 or IPv6)
50+ // net.ParseIP accepts various formats including IPv6 brackets
51+ if net.ParseIP(domain) != nil {
52+ return false
53+ }
54+
55+ // Check for internal-only TLDs
56+ lowerDomain := strings.ToLower(domain)
57+ if strings.HasSuffix(lowerDomain, ".local") ||
58+ strings.HasSuffix(lowerDomain, ".internal") {
59+ return false
60+ }
61+
62+ // Check for localhost variants
63+ if strings.HasPrefix(lowerDomain, "localhost.") {
64+ return false
65+ }
66+ if strings.HasSuffix(lowerDomain, ".localhost") {
67+ return false
68+ }
69+
70+ // Validate each label conforms to RFC 1123
71+ labels := strings.Split(domain, ".")
72+ for _, label := range labels {
73+ if !isValidLabel(label) {
74+ return false
75+ }
76+ }
77+
78+ return true
79+}
80+
81+// isValidLabel checks the per-label RFC 1123 length and hyphen rules.
82+// Character allowlisting is enforced earlier by hostnamePattern.
83+func isValidLabel(label string) bool {
84+ if label == "" {
85+ return false
86+ }
87+ if len(label) > 63 {
88+ return false
89+ }
90+ if strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") {
91+ return false
92+ }
93+ return true
94+}
A internal/domain/validation_test.go +89 -0
@@ -0,0 +1,89 @@
1+package domain
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+func TestIsValidRouteDomain(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ domain string
14+ want bool
15+ }{
16+ // Valid public hostnames
17+ {"valid domain", "app.example.com", true},
18+ {"valid subdomain", "api.app.example.com", true},
19+ {"valid single subdomain", "example.com", true},
20+ {"valid with hyphens", "my-app.example-site.com", true},
21+
22+ // Invalid: empty
23+ {"empty domain", "", false},
24+
25+ // Invalid: IP literals (SSRF risk)
26+ {"IPv4 address", "192.168.1.1", false},
27+ {"IPv4 with path", "10.0.0.1/path", false},
28+ {"IPv4 localhost", "127.0.0.1", false},
29+ {"IPv6 address", "::1", false},
30+ {"IPv6 full", "2001:db8::1", false},
31+ {"IPv6 bracketed", "[::1]", false},
32+ {"IPv6 with zone", "fe80::1%eth0", false},
33+
34+ // Invalid: localhost variants
35+ {"localhost", "localhost", false},
36+ {"localhost.localdomain", "localhost.localdomain", false},
37+
38+ // Invalid: internal-only TLDs
39+ {"dot local", "myapp.local", false},
40+ {"dot internal", "service.internal", false},
41+ {"subdomain local", "api.myapp.local", false},
42+
43+ // Invalid: port in authority
44+ {"with port", "example.com:8080", false},
45+ {"localhost with port", "localhost:3000", false},
46+ {"IP with port", "192.168.1.1:8080", false},
47+
48+ // Invalid: scheme or path components
49+ {"with scheme", "https://example.com", false},
50+ {"with path", "example.com/path", false},
51+ {"with query", "example.com?query=1", false},
52+
53+ // Invalid: wildcards or patterns
54+ {"wildcard", "*.example.com", false},
55+ {"star anywhere", "exa*mple.com", false},
56+
57+ // Edge cases
58+ {"single label", "myapp", false},
59+ {"single label numeric", "api", false},
60+ {"trailing dot", "example.com.", false},
61+ {"leading dot", ".example.com", false},
62+ {"double dots", "exa..mple.com", false},
63+
64+ // Invalid: label starts or ends with hyphen
65+ {"label starts with hyphen", "-app.example.com", false},
66+ {"label ends with hyphen", "app-.example.com", false},
67+ {"label starts and ends with hyphen", "-app-.example.com", false},
68+ {"root label starts with hyphen", "-example.com", false},
69+ {"root label ends with hyphen", "example-.com", false},
70+
71+ // Invalid: label too long (>63 chars)
72+ {"label too long", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.example.com", false},
73+
74+ // Invalid: total hostname too long (>253 chars)
75+ {"hostname too long", strings.Repeat("a", 55) + "." + strings.Repeat("b", 55) + "." + strings.Repeat("c", 55) + "." + strings.Repeat("d", 55) + "." + strings.Repeat("e", 30) + ".com", false},
76+
77+ // Invalid: .localhost suffix (DNS rebinding risk)
78+ {"dot localhost suffix", "app.localhost", false},
79+ {"deep localhost suffix", "sub.app.localhost", false},
80+ {"mixed case localhost suffix", "app.Localhost", false},
81+ }
82+
83+ for _, tt := range tests {
84+ t.Run(tt.name, func(t *testing.T) {
85+ got := IsValidRouteDomain(tt.domain)
86+ assert.Equal(t, tt.want, got, "IsValidRouteDomain(%q)", tt.domain)
87+ })
88+ }
89+}
M internal/usecase/auto/preview/handler.go +79 -55
@@ -7,7 +7,6 @@ import (
77
88 "github.com/bnema/zerowrap"
99
10- "github.com/bnema/gordon/internal/boundaries/out"
1110 "github.com/bnema/gordon/internal/domain"
1211 "github.com/bnema/gordon/internal/usecase/auto"
1312 )
@@ -16,20 +15,17 @@ import (
1615 type AutoPreviewHandler struct {
1716 serviceCtx context.Context
1817 config auto.AutoConfigProvider
19- blobStorage out.BlobStorage
2018 previewService *Service
2119 }
2220
2321 func NewAutoPreviewHandler(
2422 serviceCtx context.Context,
2523 config auto.AutoConfigProvider,
26- blobStorage out.BlobStorage,
2724 previewService *Service,
2825 ) *AutoPreviewHandler {
2926 return &AutoPreviewHandler{
3027 serviceCtx: serviceCtx,
3128 config: config,
32- blobStorage: blobStorage,
3329 previewService: previewService,
3430 }
3531 }
@@ -53,70 +49,98 @@ func (h *AutoPreviewHandler) Handle(ctx context.Context, event domain.Event) err
5349 return nil
5450 }
5551
56- // Extract labels from manifest
57- labels, err := auto.ExtractLabels(ctx, payload.Manifest, h.blobStorage)
58- if err != nil {
59- log.Debug().Err(err).Msg("failed to extract labels, skipping preview handler")
60- return nil
61- }
62-
6352 previewConfig := h.config.GetPreviewConfig()
6453 previewName := ExtractPreviewName(payload.Reference, previewConfig.TagPatterns)
6554
66- // Resolve base route: labels first, then route config fallback.
67- // When falling back to routes, skip preview domains (they contain the separator)
68- // to avoid using a preview as the base for another preview.
69- domains := collectDomains(labels)
70- if len(domains) == 0 {
71- routes := h.config.FindRoutesByImage(ctx, payload.Name)
72- for _, r := range routes {
73- if !strings.Contains(r.Domain, previewConfig.Separator) {
74- domains = append(domains, r.Domain)
75- }
76- }
77- }
78- if len(domains) == 0 {
79- log.Debug().Str("image", payload.Name).Msg("no domain found from labels or routes, skipping preview")
55+ routes := h.config.FindRoutesByImage(ctx, payload.Name)
56+ baseRoutes := resolveBaseRoutes(routes, previewConfig)
57+ if len(baseRoutes) == 0 {
58+ log.Debug().Str("image", payload.Name).Msg("no trusted route found for preview base, skipping")
8059 return nil
8160 }
82- baseRouteDomain := domains[0]
8361
84- // Validate allowlist
8562 allowedDomains := h.config.GetAllowedDomains()
86- previewDomain, err := GeneratePreviewDomain(baseRouteDomain, previewName, previewConfig.Separator)
87- if err != nil {
88- log.Debug().Err(err).Str("base_domain", baseRouteDomain).Msg("failed to generate preview domain")
89- return nil
90- }
91- if !auto.MatchesDomainAllowlist(previewDomain, allowedDomains) {
92- log.Debug().Str("domain", previewDomain).Strs("patterns", allowedDomains).Msg("preview domain not in allowlist, skipping")
93- return nil
94- }
9563
9664 imageName := payload.Name + ":" + payload.Reference
97-
98- // Launch async — volume cloning may exceed event bus timeout
99- go func() {
100- if err := h.previewService.CreatePreview(h.serviceCtx, CreatePreviewRequest{
101- Name: previewName,
102- Domain: previewDomain,
103- BaseRoute: baseRouteDomain,
104- Image: imageName,
105- HTTPS: true,
106- PreviewConfig: previewConfig,
107- }); err != nil {
108- log.Error().Err(err).Str("preview", previewName).Msg("failed to create preview")
65+ for _, baseRoute := range baseRoutes {
66+ previewDomain, err := GeneratePreviewDomain(baseRoute.Domain, previewName, previewConfig.Separator)
67+ if err != nil {
68+ log.Debug().Err(err).Str("base_domain", baseRoute.Domain).Msg("failed to generate preview domain")
69+ continue
70+ }
71+ if !auto.MatchesDomainAllowlist(previewDomain, allowedDomains) {
72+ log.Debug().Str("domain", previewDomain).Strs("patterns", allowedDomains).Msg("preview domain not in allowlist, skipping")
73+ continue
10974 }
110- }()
75+
76+ // Launch async — volume cloning may exceed event bus timeout
77+ go func(baseRoute baseRouteInfo, previewDomain string) {
78+ if err := h.previewService.CreatePreview(h.serviceCtx, CreatePreviewRequest{
79+ Name: previewName,
80+ Domain: previewDomain,
81+ BaseRoute: baseRoute.Domain,
82+ Image: imageName,
83+ HTTPS: baseRoute.HTTPS,
84+ PreviewConfig: previewConfig,
85+ }); err != nil {
86+ log.Error().Err(err).Str("preview", previewName).Str("base_route", baseRoute.Domain).Msg("failed to create preview")
87+ }
88+ }(baseRoute, previewDomain)
89+ }
11190
11291 return nil
11392 }
11493
115-func collectDomains(labels *domain.ImageLabels) []string {
116- var domains []string
117- if labels.Domain != "" {
118- domains = append(domains, labels.Domain)
94+// resolveBaseRoutes determines the eligible base routes for preview env/data inheritance.
95+type baseRouteInfo struct {
96+ Domain string
97+ HTTPS bool
98+}
99+
100+func resolveBaseRoutes(routes []domain.Route, previewConfig domain.PreviewConfig) []baseRouteInfo {
101+ domainSet := make(map[string]struct{}, len(routes))
102+ for _, r := range routes {
103+ domainSet[r.Domain] = struct{}{}
104+ }
105+
106+ baseRoutes := make([]baseRouteInfo, 0, len(routes))
107+ for _, r := range routes {
108+ if !isPreviewDomain(r.Domain, previewConfig.Separator, domainSet) {
109+ baseRoutes = append(baseRoutes, baseRouteInfo{Domain: r.Domain, HTTPS: r.HTTPS})
110+ }
111+ }
112+ return baseRoutes
113+}
114+
115+// isPreviewDomain checks if a domain is a preview domain by examining the first
116+// DNS label for the separator AND verifying that the implied base domain exists
117+// in the route set. This avoids false positives like "my--app.example.com" when
118+// no route "my.example.com" exists — that domain just happens to contain the
119+// separator in its name.
120+// If separator is empty, no domain is treated as a preview domain.
121+func isPreviewDomain(d, separator string, routeSet map[string]struct{}) bool {
122+ if separator == "" {
123+ return false
124+ }
125+ // Extract the first label (subdomain) before the first dot
126+ firstLabel := d
127+ rest := ""
128+ if idx := strings.Index(d, "."); idx != -1 {
129+ firstLabel = d[:idx]
130+ rest = d[idx:] // includes leading dot
131+ }
132+ // Check if the first label contains the separator
133+ sepIdx := strings.Index(firstLabel, separator)
134+ if sepIdx < 0 {
135+ return false
136+ }
137+ // Reconstruct the potential base domain: everything before the separator + rest
138+ baseLabel := firstLabel[:sepIdx]
139+ if baseLabel == "" {
140+ return false
119141 }
120- domains = append(domains, labels.Domains...)
121- return domains
142+ baseDomain := baseLabel + rest
143+ // Only treat as preview if the base domain actually exists in the route set
144+ _, exists := routeSet[baseDomain]
145+ return exists
122146 }
M internal/usecase/auto/preview/handler_test.go +130 -15
@@ -1,32 +1,147 @@
11 package preview
22
33 import (
4+ "context"
5+ "sync"
46 "testing"
7+ "time"
58
6- "github.com/bnema/gordon/internal/domain"
79 "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/gordon/internal/domain"
813 )
914
15+type captureStore struct {
16+ mu sync.Mutex
17+ previews []domain.PreviewRoute
18+ history [][]domain.PreviewRoute
19+}
20+
21+func (s *captureStore) Load(_ context.Context) ([]domain.PreviewRoute, error) {
22+ s.mu.Lock()
23+ defer s.mu.Unlock()
24+ cp := make([]domain.PreviewRoute, len(s.previews))
25+ copy(cp, s.previews)
26+ return cp, nil
27+}
28+
29+func (s *captureStore) Save(_ context.Context, previews []domain.PreviewRoute) error {
30+ s.mu.Lock()
31+ defer s.mu.Unlock()
32+ s.previews = make([]domain.PreviewRoute, len(previews))
33+ copy(s.previews, previews)
34+ snapshot := make([]domain.PreviewRoute, len(previews))
35+ copy(snapshot, previews)
36+ s.history = append(s.history, snapshot)
37+ return nil
38+}
39+
40+func (s *captureStore) saves() [][]domain.PreviewRoute {
41+ s.mu.Lock()
42+ defer s.mu.Unlock()
43+ cp := make([][]domain.PreviewRoute, len(s.history))
44+ for i := range s.history {
45+ cp[i] = make([]domain.PreviewRoute, len(s.history[i]))
46+ copy(cp[i], s.history[i])
47+ }
48+ return cp
49+}
50+
51+type fakeAutoConfigProvider struct {
52+ previewConfig domain.PreviewConfig
53+ routesByImage map[string][]domain.Route
54+ allowedDomains []string
55+}
56+
57+func (f *fakeAutoConfigProvider) IsAutoEnabled() bool { return false }
58+func (f *fakeAutoConfigProvider) IsPreviewEnabled() bool { return true }
59+func (f *fakeAutoConfigProvider) GetPreviewTagPatterns() []string { return f.previewConfig.TagPatterns }
60+func (f *fakeAutoConfigProvider) GetPreviewConfig() domain.PreviewConfig { return f.previewConfig }
61+func (f *fakeAutoConfigProvider) GetAllowedDomains() []string { return f.allowedDomains }
62+func (f *fakeAutoConfigProvider) FindRoutesByImage(_ context.Context, imageName string) []domain.Route {
63+ return f.routesByImage[imageName]
64+}
65+
1066 func TestAutoPreviewHandler_CanHandle(t *testing.T) {
1167 h := &AutoPreviewHandler{}
1268 assert.True(t, h.CanHandle(domain.EventImagePushed))
1369 assert.False(t, h.CanHandle(domain.EventConfigReload))
1470 }
1571
16-func TestCollectDomains(t *testing.T) {
17- tests := []struct {
18- name string
19- labels *domain.ImageLabels
20- want []string
21- }{
22- {"single domain", &domain.ImageLabels{Domain: "app.example.com"}, []string{"app.example.com"}},
23- {"multiple domains", &domain.ImageLabels{Domains: []string{"a.com", "b.com"}}, []string{"a.com", "b.com"}},
24- {"both", &domain.ImageLabels{Domain: "main.com", Domains: []string{"alt.com"}}, []string{"main.com", "alt.com"}},
25- {"empty", &domain.ImageLabels{}, nil},
72+func TestResolveBaseRoutes_ReturnsAllEligibleBases(t *testing.T) {
73+ routes := []domain.Route{
74+ {Domain: "app-preview-old.example.com", Image: "myapp", HTTPS: true},
75+ {Domain: "app.example.com", Image: "myapp", HTTPS: true},
76+ {Domain: "alias.example.com", Image: "myapp", HTTPS: false},
2677 }
27- for _, tt := range tests {
28- t.Run(tt.name, func(t *testing.T) {
29- assert.Equal(t, tt.want, collectDomains(tt.labels))
30- })
78+
79+ baseRoutes := resolveBaseRoutes(routes, domain.PreviewConfig{Separator: "-preview-"})
80+ assert.ElementsMatch(t, []baseRouteInfo{
81+ {Domain: "app.example.com", HTTPS: true},
82+ {Domain: "alias.example.com", HTTPS: false},
83+ }, baseRoutes)
84+}
85+
86+func TestResolveBaseRoutes_TreatsSeparatorWithoutBaseAsEligible(t *testing.T) {
87+ routes := []domain.Route{
88+ {Domain: "my--app.example.com", Image: "myapp", HTTPS: false},
89+ {Domain: "other.example.com", Image: "myapp", HTTPS: true},
3190 }
91+
92+ baseRoutes := resolveBaseRoutes(routes, domain.PreviewConfig{Separator: "--"})
93+ assert.ElementsMatch(t, []baseRouteInfo{
94+ {Domain: "my--app.example.com", HTTPS: false},
95+ {Domain: "other.example.com", HTTPS: true},
96+ }, baseRoutes)
97+}
98+
99+func TestAutoPreviewHandler_Handle_CreatesPreviewPerBaseRoute(t *testing.T) {
100+ store := &captureStore{}
101+ svc := NewService(store, time.Hour)
102+ h := NewAutoPreviewHandler(t.Context(), &fakeAutoConfigProvider{
103+ previewConfig: domain.PreviewConfig{
104+ Separator: "--",
105+ TagPatterns: []string{"preview-*"},
106+ TTL: time.Hour,
107+ },
108+ allowedDomains: []string{"*"},
109+ routesByImage: map[string][]domain.Route{
110+ "myapp": {
111+ {Domain: "app.example.com", Image: "myapp", HTTPS: true},
112+ {Domain: "alias.example.com", Image: "myapp", HTTPS: false},
113+ },
114+ },
115+ }, svc)
116+
117+ err := h.Handle(t.Context(), domain.Event{
118+ ID: "evt-1",
119+ Type: domain.EventImagePushed,
120+ Data: domain.ImagePushedPayload{Name: "myapp", Reference: "preview-login"},
121+ })
122+ require.NoError(t, err)
123+
124+ require.Eventually(t, func() bool {
125+ for _, save := range store.saves() {
126+ if len(save) != 2 {
127+ continue
128+ }
129+
130+ pairs := map[string]domain.PreviewRoute{}
131+ for _, preview := range save {
132+ pairs[preview.Domain] = preview
133+ }
134+
135+ app, ok := pairs["app--login.example.com"]
136+ if !ok || app.BaseRoute != "app.example.com" || !app.HTTPS {
137+ continue
138+ }
139+
140+ alias, ok := pairs["alias--login.example.com"]
141+ if ok && alias.BaseRoute == "alias.example.com" && !alias.HTTPS {
142+ return true
143+ }
144+ }
145+ return false
146+ }, time.Second, 10*time.Millisecond)
32147 }
M internal/usecase/config/service.go +323 -121
@@ -41,7 +41,7 @@ type Config struct {
4141 PreviewEnvCopy bool
4242 NetworkIsolation bool
4343 NetworkPrefix string
44- Routes map[string]string
44+ Routes map[string]routeConfig
4545 ExternalRoutes map[string]string // domain -> "host:port"
4646 RegistryAuthEnabled bool
4747 RegistryAuthUsername string
@@ -53,6 +53,11 @@ type Config struct {
5353 Attachments map[string][]string
5454 }
5555
56+type routeConfig struct {
57+ Image string `toml:"image"`
58+ HTTPS bool `toml:"https"`
59+}
60+
5661 // Service implements the ConfigService interface.
5762 type Service struct {
5863 viper *viper.Viper
@@ -83,14 +88,18 @@ func (s *Service) Load(ctx context.Context) error {
8388 s.mu.Lock()
8489 defer s.mu.Unlock()
8590
86- // Load configuration from viper
87- s.config = s.loadConfigValues()
91+ newConfig := s.loadConfigValues()
92+
93+ routes, err := loadCanonicalRoutes(s.viper.Get("routes"))
94+ if err != nil {
95+ return log.WrapErr(err, "failed to load routes")
96+ }
97+ newConfig.Routes = routes
98+ newConfig.ExternalRoutes = loadStringMap(s.viper.Get("external_routes"))
99+ newConfig.NetworkGroups = loadStringArrayMap(s.viper.Get("network_groups"))
100+ newConfig.Attachments = loadStringArrayMap(s.viper.Get("attachments"))
88101
89- // Load complex nested structures
90- s.config.Routes = loadStringMap(s.viper.Get("routes"))
91- s.config.ExternalRoutes = loadStringMap(s.viper.Get("external_routes"))
92- s.config.NetworkGroups = loadStringArrayMap(s.viper.Get("network_groups"))
93- s.config.Attachments = loadStringArrayMap(s.viper.Get("attachments"))
102+ s.config = newConfig
94103
95104 log.Info().
96105 Int("server_port", s.config.ServerPort).
@@ -174,7 +183,7 @@ func (s *Service) loadConfigValues() Config {
174183 VolumeAutoCreate: s.viper.GetBool("volumes.auto_create"),
175184 VolumePrefix: s.viper.GetString("volumes.prefix"),
176185 VolumePreserve: s.viper.GetBool("volumes.preserve"),
177- Routes: make(map[string]string),
186+ Routes: make(map[string]routeConfig),
178187 ExternalRoutes: make(map[string]string),
179188 NetworkGroups: make(map[string][]string),
180189 Attachments: make(map[string][]string),
@@ -223,27 +232,112 @@ func loadStringArrayMap(raw any) map[string][]string {
223232 return result
224233 }
225234
235+func loadCanonicalRoutes(raw any) (map[string]routeConfig, error) {
236+ result := make(map[string]routeConfig)
237+ if raw == nil {
238+ return result, nil
239+ }
240+
241+ routes, ok := raw.(map[string]any)
242+ if !ok {
243+ return nil, fmt.Errorf("routes section must be a table, got %T", raw)
244+ }
245+
246+ for key, value := range routes {
247+ if strings.HasPrefix(key, "http://") {
248+ continue
249+ }
250+
251+ route, err := parseCanonicalRouteEntry(key, value)
252+ if err != nil {
253+ return nil, err
254+ }
255+ result[key] = route
256+ }
257+
258+ for key, value := range routes {
259+ if !strings.HasPrefix(key, "http://") {
260+ continue
261+ }
262+
263+ domainName := strings.TrimPrefix(key, "http://")
264+ if _, exists := result[domainName]; exists {
265+ continue
266+ }
267+
268+ route, err := parseLegacyRouteEntry(domainName, value)
269+ if err != nil {
270+ return nil, err
271+ }
272+ result[domainName] = route
273+ }
274+
275+ return result, nil
276+}
277+
278+func parseCanonicalRouteEntry(domainName string, raw any) (routeConfig, error) {
279+ if !domain.IsValidRouteDomain(domainName) {
280+ return routeConfig{}, fmt.Errorf("invalid route key %q: %w", domainName, domain.ErrRouteDomainInvalid)
281+ }
282+
283+ switch value := raw.(type) {
284+ case string:
285+ if value == "" {
286+ return routeConfig{}, fmt.Errorf("route %q has empty image", domainName)
287+ }
288+ return routeConfig{Image: value, HTTPS: true}, nil
289+ case map[string]any:
290+ return parseRouteTable(domainName, value)
291+ default:
292+ return routeConfig{}, fmt.Errorf("route %q has unsupported value type %T", domainName, raw)
293+ }
294+}
295+
296+func parseLegacyRouteEntry(domainName string, raw any) (routeConfig, error) {
297+ if !domain.IsValidRouteDomain(domainName) {
298+ return routeConfig{}, fmt.Errorf("invalid route key %q: %w", legacyRouteStorageKey(domainName), domain.ErrRouteDomainInvalid)
299+ }
300+
301+ value, ok := raw.(string)
302+ if !ok {
303+ return routeConfig{}, fmt.Errorf("legacy route %q must be a string", legacyRouteStorageKey(domainName))
304+ }
305+ if value == "" {
306+ return routeConfig{}, fmt.Errorf("legacy route %q has empty image", legacyRouteStorageKey(domainName))
307+ }
308+
309+ return routeConfig{Image: value, HTTPS: false}, nil
310+}
311+
312+func parseRouteTable(domainName string, raw map[string]any) (routeConfig, error) {
313+ imageValue, ok := raw["image"].(string)
314+ if !ok || imageValue == "" {
315+ return routeConfig{}, fmt.Errorf("route %q has invalid image field", domainName)
316+ }
317+
318+ httpsValue, ok := raw["https"]
319+ if !ok {
320+ return routeConfig{Image: imageValue, HTTPS: true}, nil
321+ }
322+ https, ok := httpsValue.(bool)
323+ if !ok {
324+ return routeConfig{}, fmt.Errorf("route %q has invalid https field", domainName)
325+ }
326+
327+ return routeConfig{Image: imageValue, HTTPS: https}, nil
328+}
329+
226330 // GetRoutes returns all configured routes.
227331 func (s *Service) GetRoutes(_ context.Context) []domain.Route {
228332 s.mu.RLock()
229333 defer s.mu.RUnlock()
230334
231335 var routes []domain.Route
232- for domainName, image := range s.config.Routes {
233- route := domain.Route{
234- Image: image,
235- HTTPS: true,
236- }
237-
238- // Check if domain has http:// prefix
336+ for domainName, route := range s.config.Routes {
239337 if strings.HasPrefix(domainName, "http://") {
240- route.Domain = strings.TrimPrefix(domainName, "http://")
241- route.HTTPS = false
242- } else {
243- route.Domain = domainName
338+ continue
244339 }
245-
246- routes = append(routes, route)
340+ routes = append(routes, domain.Route{Domain: domainName, Image: route.Image, HTTPS: route.HTTPS})
247341 }
248342
249343 return routes
@@ -254,22 +348,13 @@ func (s *Service) GetRoute(_ context.Context, domainName string) (*domain.Route,
254348 s.mu.RLock()
255349 defer s.mu.RUnlock()
256350
257- image, exists := s.config.Routes[domainName]
258- if !exists {
351+ routeCfg, ok := s.config.Routes[domainName]
352+ if !ok {
259353 return nil, domain.ErrRouteNotFound
260354 }
261355
262- route := &domain.Route{
263- Domain: domainName,
264- Image: image,
265- HTTPS: true,
266- }
267-
268- // Check if domain has http:// prefix
269- if strings.HasPrefix(domainName, "http://") {
270- route.Domain = strings.TrimPrefix(domainName, "http://")
271- route.HTTPS = false
272- }
356+ routeValue := domain.Route{Domain: domainName, Image: routeCfg.Image, HTTPS: routeCfg.HTTPS}
357+ route := &routeValue
273358
274359 return route, nil
275360 }
@@ -282,19 +367,12 @@ func (s *Service) FindRoutesByImage(_ context.Context, imageName string) []domai
282367 defer s.mu.RUnlock()
283368
284369 var routes []domain.Route
285- for domainName, image := range s.config.Routes {
286- if matchesImageName(imageName, image, s.config.RegistryDomain) {
287- route := domain.Route{
288- Image: image,
289- HTTPS: true,
290- }
291- if strings.HasPrefix(domainName, "http://") {
292- route.Domain = strings.TrimPrefix(domainName, "http://")
293- route.HTTPS = false
294- } else {
295- route.Domain = domainName
296- }
297- routes = append(routes, route)
370+ for domainName, route := range s.config.Routes {
371+ if strings.HasPrefix(domainName, "http://") {
372+ continue
373+ }
374+ if matchesImageName(imageName, route.Image, s.config.RegistryDomain) {
375+ routes = append(routes, domain.Route{Domain: domainName, Image: route.Image, HTTPS: route.HTTPS})
298376 }
299377 }
300378
@@ -386,6 +464,23 @@ func NormalizeBootstrapImage(image, registryDomain string) (string, error) {
386464 return image, nil
387465 }
388466
467+func legacyRouteStorageKey(domainName string) string {
468+ return "http://" + domainName
469+}
470+
471+func cloneRouteConfigs(routes map[string]routeConfig) map[string]routeConfig {
472+ if routes == nil {
473+ return make(map[string]routeConfig)
474+ }
475+
476+ cloned := make(map[string]routeConfig, len(routes))
477+ for key, route := range routes {
478+ cloned[key] = route
479+ }
480+
481+ return cloned
482+}
483+
389484 // AddRoute adds a new route to the configuration and persists it.
390485 func (s *Service) AddRoute(ctx context.Context, route domain.Route) error {
391486 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
@@ -399,36 +494,45 @@ func (s *Service) AddRoute(ctx context.Context, route domain.Route) error {
399494 if route.Domain == "" {
400495 return domain.ErrRouteDomainEmpty
401496 }
497+ if !domain.IsValidRouteDomain(route.Domain) {
498+ return domain.ErrRouteDomainInvalid
499+ }
402500 if route.Image == "" {
403501 return domain.ErrRouteImageEmpty
404502 }
405503
406504 // Store previous value for rollback
407505 s.mu.Lock()
408- previousImage, existed := s.config.Routes[route.Domain]
409- if s.config.Routes == nil {
410- s.config.Routes = make(map[string]string)
411- }
412- if existed && previousImage == route.Image {
506+ currentConfig := s.config
507+ if currentConfig.Routes == nil {
508+ currentConfig.Routes = make(map[string]routeConfig)
509+ }
510+ previousCanonicalRoute, canonicalExisted := currentConfig.Routes[route.Domain]
511+ legacyKey := legacyRouteStorageKey(route.Domain)
512+ _, legacyExisted := currentConfig.Routes[legacyKey]
513+ newRoute := routeConfig{Image: route.Image, HTTPS: route.HTTPS}
514+ if canonicalExisted && previousCanonicalRoute == newRoute && !legacyExisted {
413515 s.mu.Unlock()
414516 return nil
415517 }
416- s.config.Routes[route.Domain] = route.Image
417- s.mu.Unlock()
518+ nextRoutes := cloneRouteConfigs(currentConfig.Routes)
519+ nextRoutes[route.Domain] = newRoute
520+ if legacyExisted {
521+ delete(nextRoutes, legacyKey)
522+ }
418523
419- // Persist to disk - rollback on failure
420- if err := s.Save(ctx); err != nil {
421- log.Warn().Err(err).Msg("failed to persist route to disk, rolling back")
422- s.mu.Lock()
423- if existed {
424- s.config.Routes[route.Domain] = previousImage
425- } else {
426- delete(s.config.Routes, route.Domain)
427- }
524+ nextConfig := currentConfig
525+ nextConfig.Routes = nextRoutes
526+
527+ if err := s.persistConfig(ctx, nextConfig); err != nil {
528+ log.Warn().Err(err).Msg("failed to persist route to disk")
428529 s.mu.Unlock()
429530 return err
430531 }
431532
533+ s.config.Routes = nextRoutes
534+ s.mu.Unlock()
535+
432536 log.Info().Str("image", route.Image).Msg("route added to configuration")
433537 return nil
434538 }
@@ -446,29 +550,41 @@ func (s *Service) UpdateRoute(ctx context.Context, route domain.Route) error {
446550 if route.Domain == "" {
447551 return domain.ErrRouteDomainEmpty
448552 }
553+ if !domain.IsValidRouteDomain(route.Domain) {
554+ return domain.ErrRouteDomainInvalid
555+ }
449556 if route.Image == "" {
450557 return domain.ErrRouteImageEmpty
451558 }
452559
453560 // Store previous value for rollback
454561 s.mu.Lock()
455- previousImage, exists := s.config.Routes[route.Domain]
456- if !exists {
562+ currentConfig := s.config
563+ previousRoute, ok := currentConfig.Routes[route.Domain]
564+ if !ok {
457565 s.mu.Unlock()
458566 return domain.ErrRouteNotFound
459567 }
460- s.config.Routes[route.Domain] = route.Image
461- s.mu.Unlock()
568+ updatedRoute := previousRoute
569+ legacyKey := legacyRouteStorageKey(route.Domain)
570+ nextRoutes := cloneRouteConfigs(currentConfig.Routes)
571+ delete(nextRoutes, legacyKey)
572+ updatedRoute.Image = route.Image
573+ updatedRoute.HTTPS = route.HTTPS
574+ nextRoutes[route.Domain] = updatedRoute
462575
463- // Persist to disk - rollback on failure
464- if err := s.Save(ctx); err != nil {
465- log.Warn().Err(err).Msg("failed to persist route update to disk, rolling back")
466- s.mu.Lock()
467- s.config.Routes[route.Domain] = previousImage
576+ nextConfig := currentConfig
577+ nextConfig.Routes = nextRoutes
578+
579+ if err := s.persistConfig(ctx, nextConfig); err != nil {
580+ log.Warn().Err(err).Msg("failed to persist route update to disk")
468581 s.mu.Unlock()
469582 return err
470583 }
471584
585+ s.config.Routes = nextRoutes
586+ s.mu.Unlock()
587+
472588 log.Info().Str("image", route.Image).Msg("route updated")
473589 return nil
474590 }
@@ -482,25 +598,38 @@ func (s *Service) RemoveRoute(ctx context.Context, domainName string) error {
482598 })
483599 log := zerowrap.FromCtx(ctx)
484600
601+ if domainName == "" {
602+ return domain.ErrRouteDomainEmpty
603+ }
604+ if !domain.IsValidRouteDomain(domainName) {
605+ return domain.ErrRouteDomainInvalid
606+ }
607+
485608 // Store previous value for rollback
486609 s.mu.Lock()
487- previousImage, exists := s.config.Routes[domainName]
488- if !exists {
610+ currentConfig := s.config
611+ _, ok := currentConfig.Routes[domainName]
612+ if !ok {
489613 s.mu.Unlock()
490614 return domain.ErrRouteNotFound
491615 }
492- delete(s.config.Routes, domainName)
493- s.mu.Unlock()
616+ legacyKey := legacyRouteStorageKey(domainName)
617+ nextRoutes := cloneRouteConfigs(currentConfig.Routes)
618+ delete(nextRoutes, legacyKey)
619+ delete(nextRoutes, domainName)
494620
495- // Persist to disk - rollback on failure
496- if err := s.Save(ctx); err != nil {
497- log.Warn().Err(err).Msg("failed to persist route removal to disk, rolling back")
498- s.mu.Lock()
499- s.config.Routes[domainName] = previousImage
621+ nextConfig := currentConfig
622+ nextConfig.Routes = nextRoutes
623+
624+ if err := s.persistConfig(ctx, nextConfig); err != nil {
625+ log.Warn().Err(err).Msg("failed to persist route removal to disk")
500626 s.mu.Unlock()
501627 return err
502628 }
503629
630+ s.config.Routes = nextRoutes
631+ s.mu.Unlock()
632+
504633 log.Info().Msg("route removed")
505634 return nil
506635 }
@@ -511,11 +640,16 @@ func (s *Service) Save(ctx context.Context) error {
511640 zerowrap.FieldLayer: "usecase",
512641 zerowrap.FieldUseCase: "SaveConfig",
513642 })
514- log := zerowrap.FromCtx(ctx)
515643
516644 s.mu.Lock()
517645 defer s.mu.Unlock()
518646
647+ return s.persistConfig(ctx, s.config)
648+}
649+
650+func (s *Service) persistConfig(ctx context.Context, config Config) error {
651+ log := zerowrap.FromCtx(ctx)
652+
519653 configFile := s.viper.ConfigFileUsed()
520654 if configFile == "" {
521655 return log.WrapErr(fmt.Errorf("no config file path"), "cannot save config")
@@ -527,7 +661,7 @@ func (s *Service) Save(ctx context.Context) error {
527661
528662 snapshot := s.snapshotCriticalFields()
529663
530- if err := s.writeConfigSurgical(configFile); err != nil {
664+ if err := s.writeConfigSurgical(configFile, config); err != nil {
531665 return log.WrapErr(err, "failed to write config")
532666 }
533667
@@ -598,50 +732,49 @@ func (s *Service) verifyCriticalFields(snap configSnapshot) error {
598732 return nil
599733 }
600734
601-func (s *Service) writeConfigSurgical(configFile string) error {
735+func (s *Service) writeConfigSurgical(configFile string, snapshotConfig Config) error {
602736 data, err := os.ReadFile(configFile)
737+ var configMap map[string]any
603738 if err != nil {
604739 if os.IsNotExist(err) {
605- s.viper.Set("routes", s.config.Routes)
606- s.viper.Set("external_routes", s.config.ExternalRoutes)
607- s.viper.Set("attachments", s.config.Attachments)
608- s.viper.Set("network_groups", s.config.NetworkGroups)
609- s.viper.Set("auto_route_allowed_domains", s.config.AutoRouteAllowedDomains)
610- s.viper.Set("auto.allowed_domains", s.config.AutoRouteAllowedDomains)
611- return s.viper.WriteConfigAs(configFile)
740+ configMap = s.viper.AllSettings()
741+ } else {
742+ return fmt.Errorf("failed to read config file: %w", err)
612743 }
613- return fmt.Errorf("failed to read config file: %w", err)
614744 }
615745
616- var config map[string]any
746+ if configMap == nil {
747+ configMap = make(map[string]any)
748+ }
617749 if len(data) > 0 {
618- if err := toml.Unmarshal(data, &config); err != nil {
750+ if err := toml.Unmarshal(data, &configMap); err != nil {
619751 return fmt.Errorf("failed to parse config file: %w", err)
620752 }
621753 }
622- if config == nil {
623- config = make(map[string]any)
624- }
625754
626- config["routes"] = s.config.Routes
627- config["external_routes"] = s.config.ExternalRoutes
628- config["attachments"] = s.config.Attachments
629- config["network_groups"] = s.config.NetworkGroups
630- // Write to both old and new keys for backward compat
631- config["auto_route_allowed_domains"] = s.config.AutoRouteAllowedDomains
632- if autoSection, ok := config["auto"].(map[string]any); ok {
633- autoSection["allowed_domains"] = s.config.AutoRouteAllowedDomains
634- } else {
635- config["auto"] = map[string]any{
636- "allowed_domains": s.config.AutoRouteAllowedDomains,
637- }
638- }
755+ delete(configMap, "routes")
756+ configMap["external_routes"] = snapshotConfig.ExternalRoutes
757+ configMap["attachments"] = snapshotConfig.Attachments
758+ configMap["network_groups"] = snapshotConfig.NetworkGroups
759+ applyAutoAllowedDomains(configMap, snapshotConfig.AutoRouteAllowedDomains)
639760
640- out, err := toml.Marshal(config)
761+ out, err := toml.Marshal(configMap)
641762 if err != nil {
642763 return fmt.Errorf("failed to marshal config: %w", err)
643764 }
644765
766+ routes, err := canonicalRoutesForSave(snapshotConfig.Routes)
767+ if err != nil {
768+ return err
769+ }
770+ routesOut := renderCanonicalRoutesSection(routes)
771+ if routesOut != "" {
772+ if len(out) > 0 {
773+ out = append(out, '\n')
774+ }
775+ out = append(out, []byte(routesOut)...)
776+ }
777+
645778 configDir := filepath.Dir(configFile)
646779 tmpFile, err := os.CreateTemp(configDir, ".gordon-config-*.tmp")
647780 if err != nil {
@@ -671,6 +804,84 @@ func (s *Service) writeConfigSurgical(configFile string) error {
671804 return nil
672805 }
673806
807+func applyAutoAllowedDomains(config map[string]any, allowedDomains []string) {
808+ // Write to both old and new keys for backward compat
809+ config["auto_route_allowed_domains"] = allowedDomains
810+ if autoSection, ok := config["auto"].(map[string]any); ok {
811+ autoSection["allowed_domains"] = allowedDomains
812+ return
813+ }
814+
815+ config["auto"] = map[string]any{
816+ "allowed_domains": allowedDomains,
817+ }
818+}
819+
820+func canonicalRoutesForSave(routes map[string]routeConfig) (map[string]routeConfig, error) {
821+ result := make(map[string]routeConfig)
822+ if routes == nil {
823+ return result, nil
824+ }
825+
826+ for key, route := range routes {
827+ if strings.HasPrefix(key, "http://") {
828+ continue
829+ }
830+ if !domain.IsValidRouteDomain(key) {
831+ return nil, fmt.Errorf("invalid route key %q: %w", key, domain.ErrRouteDomainInvalid)
832+ }
833+ result[key] = route
834+ }
835+
836+ for key, route := range routes {
837+ if !strings.HasPrefix(key, "http://") {
838+ continue
839+ }
840+
841+ domainName := strings.TrimPrefix(key, "http://")
842+ if !domain.IsValidRouteDomain(domainName) {
843+ return nil, fmt.Errorf("invalid route key %q: %w", key, domain.ErrRouteDomainInvalid)
844+ }
845+ if _, exists := result[domainName]; exists {
846+ continue
847+ }
848+ route.HTTPS = false
849+ result[domainName] = route
850+ }
851+
852+ return result, nil
853+}
854+
855+func renderCanonicalRoutesSection(routes map[string]routeConfig) string {
856+ if len(routes) == 0 {
857+ return ""
858+ }
859+
860+ keys := make([]string, 0, len(routes))
861+ for key := range routes {
862+ keys = append(keys, key)
863+ }
864+ slices.Sort(keys)
865+
866+ var b strings.Builder
867+ b.WriteString("[routes]\n")
868+ for _, key := range keys {
869+ route := routes[key]
870+ b.WriteString(strconv.Quote(key))
871+ b.WriteString(" = { image = ")
872+ b.WriteString(strconv.Quote(route.Image))
873+ b.WriteString(", https = ")
874+ if route.HTTPS {
875+ b.WriteString("true")
876+ } else {
877+ b.WriteString("false")
878+ }
879+ b.WriteString(" }\n")
880+ }
881+
882+ return b.String()
883+}
884+
674885 func backupConfigFile(configFile string) error {
675886 src, err := os.ReadFile(configFile)
676887 if err != nil {
@@ -816,15 +1027,6 @@ func (s *Service) Watch(ctx context.Context, onChange func()) error {
8161027 return
8171028 }
8181029
819- // Publish config reload event
820- if s.eventBus != nil {
821- if err := s.eventBus.Publish(domain.EventConfigReload, domain.ConfigReloadPayload{
822- Source: "file",
823- }); err != nil {
824- log.WrapErr(err, "failed to publish config reload event")
825- }
826- }
827-
8281030 if onChange != nil {
8291031 onChange()
8301032 }
M internal/usecase/config/service_test.go +630 -9
@@ -20,6 +20,44 @@ func testContext() context.Context {
2020 return zerowrap.WithCtx(context.Background(), zerowrap.Default())
2121 }
2222
23+func requireRoute(t *testing.T, routes map[string]routeConfig, domainName string) routeConfig {
24+ t.Helper()
25+ route, ok := routes[domainName]
26+ require.True(t, ok, "route %q not found", domainName)
27+ return route
28+}
29+
30+func setupServiceWithCoexistingLegacyRoute(t *testing.T, canonicalImage, legacyImage string) (*Service, context.Context) {
31+ t.Helper()
32+
33+ tmpDir := t.TempDir()
34+ configFile := filepath.Join(tmpDir, "gordon.toml")
35+ err := os.WriteFile(configFile, []byte(fmt.Sprintf("[routes]\n\"app.example.com\" = %q\n\"http://app.example.com\" = %q\n", canonicalImage, legacyImage)), 0600)
36+ require.NoError(t, err)
37+
38+ v := viper.New()
39+ v.SetConfigFile(configFile)
40+ v.Set("routes", map[string]any{
41+ "app.example.com": canonicalImage,
42+ "http://app.example.com": legacyImage,
43+ })
44+
45+ eventBus := mocks.NewMockEventPublisher(t)
46+ svc := NewService(v, eventBus)
47+ ctx := testContext()
48+ require.NoError(t, svc.Load(ctx))
49+
50+ svc.mu.Lock()
51+ if svc.config.Routes == nil {
52+ svc.config.Routes = make(map[string]routeConfig)
53+ }
54+ svc.config.Routes["app.example.com"] = routeConfig{Image: canonicalImage, HTTPS: true}
55+ svc.config.Routes["http://app.example.com"] = routeConfig{Image: legacyImage, HTTPS: false}
56+ svc.mu.Unlock()
57+
58+ return svc, ctx
59+}
60+
2361 func TestService_Load(t *testing.T) {
2462 v := viper.New()
2563 v.Set("server.port", 8080)
@@ -60,6 +98,181 @@ func TestService_Load(t *testing.T) {
6098 assert.False(t, preserve)
6199 }
62100
101+func TestService_Load_CanonicalInlineRoute(t *testing.T) {
102+ tmpDir := t.TempDir()
103+ configFile := filepath.Join(tmpDir, "gordon.toml")
104+ err := os.WriteFile(configFile, []byte(`[routes]
105+"insecure.example.com" = { image = "myapp:latest", https = false }
106+`), 0600)
107+ require.NoError(t, err)
108+
109+ v := viper.New()
110+ v.SetConfigFile(configFile)
111+ require.NoError(t, v.ReadInConfig())
112+
113+ eventBus := mocks.NewMockEventPublisher(t)
114+ svc := NewService(v, eventBus)
115+ ctx := testContext()
116+
117+ err = svc.Load(ctx)
118+ require.NoError(t, err)
119+
120+ route, err := svc.GetRoute(ctx, "insecure.example.com")
121+ require.NoError(t, err)
122+ assert.Equal(t, "myapp:latest", route.Image)
123+ assert.False(t, route.HTTPS)
124+}
125+
126+func TestService_Load_CanonicalInlineRouteDefaultsHTTPS(t *testing.T) {
127+ tmpDir := t.TempDir()
128+ configFile := filepath.Join(tmpDir, "gordon.toml")
129+ err := os.WriteFile(configFile, []byte(`[routes]
130+"secure.example.com" = { image = "myapp:latest" }
131+`), 0600)
132+ require.NoError(t, err)
133+
134+ v := viper.New()
135+ v.SetConfigFile(configFile)
136+ require.NoError(t, v.ReadInConfig())
137+
138+ eventBus := mocks.NewMockEventPublisher(t)
139+ svc := NewService(v, eventBus)
140+ ctx := testContext()
141+
142+ err = svc.Load(ctx)
143+ require.NoError(t, err)
144+
145+ route, err := svc.GetRoute(ctx, "secure.example.com")
146+ require.NoError(t, err)
147+ assert.Equal(t, "myapp:latest", route.Image)
148+ assert.True(t, route.HTTPS)
149+}
150+
151+func TestService_Reload_InvalidRouteKeyPreservesPreviousState(t *testing.T) {
152+ tmpDir := t.TempDir()
153+ configFile := filepath.Join(tmpDir, "gordon.toml")
154+ initialConfig := `[routes]
155+"app.example.com" = "myapp:v1"
156+`
157+ err := os.WriteFile(configFile, []byte(initialConfig), 0600)
158+ require.NoError(t, err)
159+
160+ v := viper.New()
161+ v.SetConfigFile(configFile)
162+ require.NoError(t, v.ReadInConfig())
163+
164+ eventBus := mocks.NewMockEventPublisher(t)
165+ svc := NewService(v, eventBus)
166+ ctx := testContext()
167+
168+ err = svc.Load(ctx)
169+ require.NoError(t, err)
170+
171+ updatedConfig := `[routes]
172+"localhost" = "myapp:v2"
173+`
174+ err = os.WriteFile(configFile, []byte(updatedConfig), 0600)
175+ require.NoError(t, err)
176+
177+ err = svc.Reload(ctx)
178+ require.Error(t, err)
179+
180+ route, getErr := svc.GetRoute(ctx, "app.example.com")
181+ require.NoError(t, getErr)
182+ assert.Equal(t, "myapp:v1", route.Image)
183+ assert.True(t, route.HTTPS)
184+}
185+
186+func TestService_GetRoutes_DeduplicatesCanonicalAndLegacyEntries(t *testing.T) {
187+ svc, ctx := setupServiceWithCoexistingLegacyRoute(t, "canonical:v2", "legacy:v1")
188+
189+ routes := svc.GetRoutes(ctx)
190+
191+ require.Len(t, routes, 1)
192+ assert.Equal(t, "app.example.com", routes[0].Domain)
193+ assert.Equal(t, "canonical:v2", routes[0].Image)
194+ assert.True(t, routes[0].HTTPS)
195+}
196+
197+func TestService_FindRoutesByImage_DeduplicatesCanonicalAndLegacyEntries(t *testing.T) {
198+ v := viper.New()
199+ v.Set("server.registry_domain", "registry.example.com")
200+ v.Set("routes", map[string]any{})
201+ eventBus := mocks.NewMockEventPublisher(t)
202+ svc := NewService(v, eventBus)
203+ ctx := testContext()
204+
205+ _ = svc.Load(ctx)
206+ svc.mu.Lock()
207+ svc.config.Routes = make(map[string]routeConfig)
208+ svc.config.Routes["app.example.com"] = routeConfig{Image: "registry.example.com/myapp:v2", HTTPS: true}
209+ svc.config.Routes["http://app.example.com"] = routeConfig{Image: "myapp:v1", HTTPS: false}
210+ svc.mu.Unlock()
211+
212+ routes := svc.FindRoutesByImage(ctx, "myapp")
213+
214+ require.Len(t, routes, 1)
215+ assert.Equal(t, "app.example.com", routes[0].Domain)
216+ assert.Equal(t, "registry.example.com/myapp:v2", routes[0].Image)
217+ assert.True(t, routes[0].HTTPS)
218+}
219+
220+func TestService_AddRoute_StoresHTTPSFalseInCanonicalForm(t *testing.T) {
221+ tmpDir := t.TempDir()
222+ configFile := filepath.Join(tmpDir, "gordon.toml")
223+ err := os.WriteFile(configFile, []byte("[routes]\n"), 0600)
224+ require.NoError(t, err)
225+
226+ v := viper.New()
227+ v.SetConfigFile(configFile)
228+ eventBus := mocks.NewMockEventPublisher(t)
229+ svc := NewService(v, eventBus)
230+ ctx := testContext()
231+
232+ _ = svc.Load(ctx)
233+
234+ err = svc.AddRoute(ctx, domain.Route{Domain: "new.example.com", Image: "newapp:latest", HTTPS: false})
235+ require.NoError(t, err)
236+
237+ routeCfg := requireRoute(t, svc.GetConfig().Routes, "new.example.com")
238+ assert.Equal(t, "newapp:latest", routeCfg.Image)
239+ assert.False(t, routeCfg.HTTPS)
240+}
241+
242+func TestService_AddRoute_RewritesRoutesToCanonicalInlineTables(t *testing.T) {
243+ tmpDir := t.TempDir()
244+ configFile := filepath.Join(tmpDir, "gordon.toml")
245+ initialConfig := `[routes]
246+"secure.example.com" = "secure:v1"
247+"http://insecure.example.com" = "insecure:v1"
248+`
249+ err := os.WriteFile(configFile, []byte(initialConfig), 0600)
250+ require.NoError(t, err)
251+
252+ v := viper.New()
253+ v.SetConfigFile(configFile)
254+ require.NoError(t, v.ReadInConfig())
255+
256+ eventBus := mocks.NewMockEventPublisher(t)
257+ svc := NewService(v, eventBus)
258+ ctx := testContext()
259+
260+ err = svc.Load(ctx)
261+ require.NoError(t, err)
262+
263+ err = svc.AddRoute(ctx, domain.Route{Domain: "new.example.com", Image: "new:v1"})
264+ require.NoError(t, err)
265+
266+ content, err := os.ReadFile(configFile)
267+ require.NoError(t, err)
268+ text := string(content)
269+ assert.Contains(t, text, `"secure.example.com" = { image = "secure:v1", https = true }`)
270+ assert.Contains(t, text, `"insecure.example.com" = { image = "insecure:v1", https = false }`)
271+ assert.Contains(t, text, `"new.example.com" = { image = "new:v1", https = false }`)
272+ assert.NotContains(t, text, "http://insecure.example.com")
273+ assert.Contains(t, text, "[routes]")
274+}
275+
63276 func TestService_Reload(t *testing.T) {
64277 t.Run("success - picks up config file changes", func(t *testing.T) {
65278 // Create temp config file
@@ -87,7 +300,9 @@ port = 8080
87300 err = svc.Load(ctx)
88301 require.NoError(t, err)
89302 assert.Equal(t, 8080, svc.GetServerPort())
90- assert.Equal(t, "myapp:v1", svc.GetConfig().Routes["app.example.com"])
303+ route := requireRoute(t, svc.GetConfig().Routes, "app.example.com")
304+ assert.Equal(t, "myapp:v1", route.Image)
305+ assert.True(t, route.HTTPS)
91306
92307 // Modify config file on disk
93308 updatedConfig := `[server]
@@ -105,8 +320,12 @@ port = 9090
105320 require.NoError(t, err)
106321
107322 assert.Equal(t, 9090, svc.GetServerPort())
108- assert.Equal(t, "myapp:v2", svc.GetConfig().Routes["app.example.com"])
109- assert.Equal(t, "newapp:latest", svc.GetConfig().Routes["new.example.com"])
323+ route = requireRoute(t, svc.GetConfig().Routes, "app.example.com")
324+ assert.Equal(t, "myapp:v2", route.Image)
325+ assert.True(t, route.HTTPS)
326+ route = requireRoute(t, svc.GetConfig().Routes, "new.example.com")
327+ assert.Equal(t, "newapp:latest", route.Image)
328+ assert.True(t, route.HTTPS)
110329 })
111330
112331 t.Run("error - config file not found", func(t *testing.T) {
@@ -224,6 +443,14 @@ func TestService_GetRoute(t *testing.T) {
224443 assert.True(t, route.HTTPS)
225444 })
226445
446+ t.Run("legacy http route found via fallback", func(t *testing.T) {
447+ route, err := svc.GetRoute(ctx, "insecure.example")
448+ require.NoError(t, err)
449+ assert.Equal(t, "insecure.example", route.Domain)
450+ assert.Equal(t, "insecureapp:latest", route.Image)
451+ assert.False(t, route.HTTPS)
452+ })
453+
227454 t.Run("non-existent route", func(t *testing.T) {
228455 route, err := svc.GetRoute(ctx, "notfound.example.com")
229456 assert.ErrorIs(t, err, domain.ErrRouteNotFound)
@@ -250,6 +477,7 @@ func TestService_AddRoute(t *testing.T) {
250477 route := domain.Route{
251478 Domain: "new.example.com",
252479 Image: "newapp:latest",
480+ HTTPS: true,
253481 }
254482
255483 err = svc.AddRoute(ctx, route)
@@ -258,7 +486,73 @@ func TestService_AddRoute(t *testing.T) {
258486
259487 // Verify route was added
260488 config := svc.GetConfig()
261- assert.Equal(t, "newapp:latest", config.Routes["new.example.com"])
489+ routeCfg := requireRoute(t, config.Routes, "new.example.com")
490+ assert.Equal(t, "newapp:latest", routeCfg.Image)
491+ assert.True(t, routeCfg.HTTPS)
492+ })
493+
494+ t.Run("zero-value route stores HTTPS false in canonical form", func(t *testing.T) {
495+ tmpDir := t.TempDir()
496+ configFile := filepath.Join(tmpDir, "gordon.toml")
497+ err := os.WriteFile(configFile, []byte("[routes]\n"), 0600)
498+ require.NoError(t, err)
499+
500+ v := viper.New()
501+ v.SetConfigFile(configFile)
502+ eventBus := mocks.NewMockEventPublisher(t)
503+ svc := NewService(v, eventBus)
504+ ctx := testContext()
505+ _ = svc.Load(ctx)
506+
507+ // Zero-value HTTPS field (false) should be preserved
508+ route := domain.Route{
509+ Domain: "app.example.com",
510+ Image: "myapp:latest",
511+ }
512+ err = svc.AddRoute(ctx, route)
513+ require.NoError(t, err)
514+
515+ config := svc.GetConfig()
516+ routeCfg := requireRoute(t, config.Routes, "app.example.com")
517+ assert.Equal(t, "myapp:latest", routeCfg.Image,
518+ "zero-value route should store under plain domain key")
519+ assert.False(t, routeCfg.HTTPS)
520+ _, hasHTTPKey := config.Routes["http://app.example.com"]
521+ assert.False(t, hasHTTPKey, "zero-value route should NOT get http:// prefix")
522+
523+ // Verify it round-trips as HTTPS=false via GetRoutes
524+ routes := svc.GetRoutes(ctx)
525+ require.Len(t, routes, 1)
526+ assert.False(t, routes[0].HTTPS, "zero-value route should be treated as HTTP")
527+ })
528+
529+ t.Run("HTTPS explicitly true stores under plain domain key", func(t *testing.T) {
530+ tmpDir := t.TempDir()
531+ configFile := filepath.Join(tmpDir, "gordon.toml")
532+ err := os.WriteFile(configFile, []byte("[routes]\n"), 0600)
533+ require.NoError(t, err)
534+
535+ v := viper.New()
536+ v.SetConfigFile(configFile)
537+ eventBus := mocks.NewMockEventPublisher(t)
538+ svc := NewService(v, eventBus)
539+ ctx := testContext()
540+ _ = svc.Load(ctx)
541+
542+ // Explicitly set HTTPS: true — should store under plain domain key
543+ route := domain.Route{
544+ Domain: "secure.example.com",
545+ Image: "myapp:latest",
546+ HTTPS: true,
547+ }
548+ err = svc.AddRoute(ctx, route)
549+ require.NoError(t, err)
550+
551+ config := svc.GetConfig()
552+ routeCfg := requireRoute(t, config.Routes, "secure.example.com")
553+ assert.Equal(t, "myapp:latest", routeCfg.Image,
554+ "HTTPS=true route should store under plain domain key")
555+ assert.True(t, routeCfg.HTTPS)
262556 })
263557
264558 t.Run("idempotent same domain and image saves once", func(t *testing.T) {
@@ -278,6 +572,7 @@ func TestService_AddRoute(t *testing.T) {
278572 route := domain.Route{
279573 Domain: "new.example.com",
280574 Image: "newapp:latest",
575+ HTTPS: true,
281576 }
282577
283578 err = svc.AddRoute(ctx, route)
@@ -293,7 +588,9 @@ func TestService_AddRoute(t *testing.T) {
293588 require.NoError(t, err)
294589
295590 assert.Equal(t, before.ModTime(), after.ModTime())
296- assert.Equal(t, "newapp:latest", svc.GetConfig().Routes["new.example.com"])
591+ routeCfg := requireRoute(t, svc.GetConfig().Routes, "new.example.com")
592+ assert.Equal(t, "newapp:latest", routeCfg.Image)
593+ assert.True(t, routeCfg.HTTPS)
297594 })
298595
299596 t.Run("empty domain", func(t *testing.T) {
@@ -325,6 +622,90 @@ func TestService_AddRoute(t *testing.T) {
325622 err := svc.AddRoute(ctx, route)
326623 assert.ErrorIs(t, err, domain.ErrRouteImageEmpty)
327624 })
625+
626+ t.Run("invalid domain - IP address", func(t *testing.T) {
627+ v := viper.New()
628+ eventBus := mocks.NewMockEventPublisher(t)
629+ svc := NewService(v, eventBus)
630+ ctx := testContext()
631+
632+ route := domain.Route{
633+ Domain: "192.168.1.1",
634+ Image: "myapp:latest",
635+ }
636+
637+ err := svc.AddRoute(ctx, route)
638+ assert.ErrorIs(t, err, domain.ErrRouteDomainInvalid)
639+ })
640+
641+ t.Run("invalid domain - localhost", func(t *testing.T) {
642+ v := viper.New()
643+ eventBus := mocks.NewMockEventPublisher(t)
644+ svc := NewService(v, eventBus)
645+ ctx := testContext()
646+
647+ route := domain.Route{
648+ Domain: "localhost",
649+ Image: "myapp:latest",
650+ }
651+
652+ err := svc.AddRoute(ctx, route)
653+ assert.ErrorIs(t, err, domain.ErrRouteDomainInvalid)
654+ })
655+
656+ t.Run("invalid domain - internal TLD", func(t *testing.T) {
657+ v := viper.New()
658+ eventBus := mocks.NewMockEventPublisher(t)
659+ svc := NewService(v, eventBus)
660+ ctx := testContext()
661+
662+ route := domain.Route{
663+ Domain: "myapp.local",
664+ Image: "myapp:latest",
665+ }
666+
667+ err := svc.AddRoute(ctx, route)
668+ assert.ErrorIs(t, err, domain.ErrRouteDomainInvalid)
669+ })
670+
671+ t.Run("invalid domain - with port", func(t *testing.T) {
672+ v := viper.New()
673+ eventBus := mocks.NewMockEventPublisher(t)
674+ svc := NewService(v, eventBus)
675+ ctx := testContext()
676+
677+ route := domain.Route{
678+ Domain: "example.com:8080",
679+ Image: "myapp:latest",
680+ }
681+
682+ err := svc.AddRoute(ctx, route)
683+ assert.ErrorIs(t, err, domain.ErrRouteDomainInvalid)
684+ })
685+
686+ t.Run("reconciles coexisting legacy http key", func(t *testing.T) {
687+ svc, ctx := setupServiceWithCoexistingLegacyRoute(t, "myapp:v1", "myapp:old")
688+ preConfig := svc.GetConfig()
689+ requireRoute(t, preConfig.Routes, "app.example.com")
690+ requireRoute(t, preConfig.Routes, "http://app.example.com")
691+
692+ route := domain.Route{
693+ Domain: "app.example.com",
694+ Image: "myapp:v2",
695+ HTTPS: true,
696+ }
697+
698+ err := svc.AddRoute(ctx, route)
699+ assert.NoError(t, err)
700+
701+ config := svc.GetConfig()
702+ routeCfg := requireRoute(t, config.Routes, "app.example.com")
703+ assert.Equal(t, "myapp:v2", routeCfg.Image,
704+ "canonical route should be updated")
705+ assert.True(t, routeCfg.HTTPS)
706+ _, exists := config.Routes["http://app.example.com"]
707+ assert.False(t, exists, "legacy http route key should be removed during reconciliation")
708+ })
328709 }
329710
330711 func TestService_UpdateRoute(t *testing.T) {
@@ -350,6 +731,7 @@ func TestService_UpdateRoute(t *testing.T) {
350731 route := domain.Route{
351732 Domain: "app.example.com",
352733 Image: "myapp:v2",
734+ HTTPS: true,
353735 }
354736
355737 err = svc.UpdateRoute(ctx, route)
@@ -357,7 +739,45 @@ func TestService_UpdateRoute(t *testing.T) {
357739 assert.NoError(t, err)
358740
359741 config := svc.GetConfig()
360- assert.Equal(t, "myapp:v2", config.Routes["app.example.com"])
742+ routeCfg := requireRoute(t, config.Routes, "app.example.com")
743+ assert.Equal(t, "myapp:v2", routeCfg.Image)
744+ assert.True(t, routeCfg.HTTPS)
745+ })
746+
747+ t.Run("updates https setting", func(t *testing.T) {
748+ tmpDir := t.TempDir()
749+ configFile := filepath.Join(tmpDir, "gordon.toml")
750+ err := os.WriteFile(configFile, []byte("[routes]\n\"app.example.com\" = \"myapp:v1\"\n"), 0600)
751+ require.NoError(t, err)
752+
753+ v := viper.New()
754+ v.SetConfigFile(configFile)
755+ v.Set("routes", map[string]any{
756+ "app.example.com": "myapp:v1",
757+ })
758+
759+ eventBus := mocks.NewMockEventPublisher(t)
760+ svc := NewService(v, eventBus)
761+ ctx := testContext()
762+
763+ _ = svc.Load(ctx)
764+
765+ route := domain.Route{
766+ Domain: "app.example.com",
767+ Image: "myapp:v2",
768+ HTTPS: false,
769+ }
770+
771+ err = svc.UpdateRoute(ctx, route)
772+ require.NoError(t, err)
773+
774+ routeCfg := requireRoute(t, svc.GetConfig().Routes, "app.example.com")
775+ assert.Equal(t, "myapp:v2", routeCfg.Image)
776+ assert.False(t, routeCfg.HTTPS)
777+
778+ content, err := os.ReadFile(configFile)
779+ require.NoError(t, err)
780+ assert.Contains(t, string(content), `"app.example.com" = { image = "myapp:v2", https = false }`)
361781 })
362782
363783 t.Run("not found", func(t *testing.T) {
@@ -406,6 +826,103 @@ func TestService_UpdateRoute(t *testing.T) {
406826 err := svc.UpdateRoute(ctx, route)
407827 assert.ErrorIs(t, err, domain.ErrRouteImageEmpty)
408828 })
829+
830+ t.Run("invalid domain - IP address", func(t *testing.T) {
831+ v := viper.New()
832+ v.Set("routes", map[string]any{
833+ "192.168.1.1": "myapp:v1",
834+ })
835+ eventBus := mocks.NewMockEventPublisher(t)
836+ svc := NewService(v, eventBus)
837+ ctx := testContext()
838+
839+ _ = svc.Load(ctx)
840+
841+ route := domain.Route{
842+ Domain: "192.168.1.1",
843+ Image: "myapp:v2",
844+ }
845+
846+ err := svc.UpdateRoute(ctx, route)
847+ assert.ErrorIs(t, err, domain.ErrRouteDomainInvalid)
848+ })
849+
850+ t.Run("invalid domain - localhost", func(t *testing.T) {
851+ v := viper.New()
852+ v.Set("routes", map[string]any{
853+ "localhost": "myapp:v1",
854+ })
855+ eventBus := mocks.NewMockEventPublisher(t)
856+ svc := NewService(v, eventBus)
857+ ctx := testContext()
858+
859+ _ = svc.Load(ctx)
860+
861+ route := domain.Route{
862+ Domain: "localhost",
863+ Image: "myapp:v2",
864+ }
865+
866+ err := svc.UpdateRoute(ctx, route)
867+ assert.ErrorIs(t, err, domain.ErrRouteDomainInvalid)
868+ })
869+
870+ t.Run("insecure route found via http prefix fallback", func(t *testing.T) {
871+ tmpDir := t.TempDir()
872+ configFile := filepath.Join(tmpDir, "gordon.toml")
873+ err := os.WriteFile(configFile, []byte("[routes]\n\"http://insecure.example.com\" = \"myapp:v1\"\n"), 0600)
874+ require.NoError(t, err)
875+
876+ v := viper.New()
877+ v.SetConfigFile(configFile)
878+ v.Set("routes", map[string]any{
879+ "http://insecure.example.com": "myapp:v1",
880+ })
881+ eventBus := mocks.NewMockEventPublisher(t)
882+ svc := NewService(v, eventBus)
883+ ctx := testContext()
884+ _ = svc.Load(ctx)
885+
886+ // Caller uses clean domain for update
887+ route := domain.Route{
888+ Domain: "insecure.example.com",
889+ Image: "myapp:v2",
890+ HTTPS: false,
891+ }
892+
893+ err = svc.UpdateRoute(ctx, route)
894+ assert.NoError(t, err, "UpdateRoute should find insecure route via http:// fallback")
895+
896+ config := svc.GetConfig()
897+ routeCfg := requireRoute(t, config.Routes, "insecure.example.com")
898+ assert.Equal(t, "myapp:v2", routeCfg.Image,
899+ "insecure route update should preserve the plain hostname key")
900+ assert.False(t, routeCfg.HTTPS)
901+ })
902+
903+ t.Run("reconciles coexisting legacy http key", func(t *testing.T) {
904+ svc, ctx := setupServiceWithCoexistingLegacyRoute(t, "myapp:v1", "myapp:old")
905+ preConfig := svc.GetConfig()
906+ requireRoute(t, preConfig.Routes, "app.example.com")
907+ requireRoute(t, preConfig.Routes, "http://app.example.com")
908+
909+ route := domain.Route{
910+ Domain: "app.example.com",
911+ Image: "myapp:v2",
912+ HTTPS: true,
913+ }
914+
915+ err := svc.UpdateRoute(ctx, route)
916+ assert.NoError(t, err)
917+
918+ config := svc.GetConfig()
919+ routeCfg := requireRoute(t, config.Routes, "app.example.com")
920+ assert.Equal(t, "myapp:v2", routeCfg.Image,
921+ "canonical route should be updated")
922+ assert.True(t, routeCfg.HTTPS)
923+ _, exists := config.Routes["http://app.example.com"]
924+ assert.False(t, exists, "legacy http route key should be removed during reconciliation")
925+ })
409926 }
410927
411928 func TestService_RemoveRoute(t *testing.T) {
@@ -436,6 +953,16 @@ func TestService_RemoveRoute(t *testing.T) {
436953 assert.False(t, exists)
437954 }
438955
956+func TestService_RemoveRoute_InvalidDomain(t *testing.T) {
957+ v := viper.New()
958+ eventBus := mocks.NewMockEventPublisher(t)
959+ svc := NewService(v, eventBus)
960+ ctx := testContext()
961+
962+ err := svc.RemoveRoute(ctx, "localhost")
963+ assert.ErrorIs(t, err, domain.ErrRouteDomainInvalid)
964+}
965+
439966 func TestService_RemoveRoute_NotFound(t *testing.T) {
440967 v := viper.New()
441968 eventBus := mocks.NewMockEventPublisher(t)
@@ -449,6 +976,95 @@ func TestService_RemoveRoute_NotFound(t *testing.T) {
449976 assert.ErrorIs(t, err, domain.ErrRouteNotFound)
450977 }
451978
979+func TestService_RemoveRoute_InsecureFallback(t *testing.T) {
980+ // Setup: insecure route stored with http:// prefix
981+ tmpDir := t.TempDir()
982+ configFile := filepath.Join(tmpDir, "gordon.toml")
983+ err := os.WriteFile(configFile, []byte("[routes]\n\"http://insecure.example.com\" = \"myapp:latest\"\n"), 0600)
984+ require.NoError(t, err)
985+
986+ v := viper.New()
987+ v.SetConfigFile(configFile)
988+ v.Set("routes", map[string]interface{}{
989+ "http://insecure.example.com": "myapp:latest",
990+ })
991+
992+ eventBus := mocks.NewMockEventPublisher(t)
993+ svc := NewService(v, eventBus)
994+ ctx := testContext()
995+
996+ _ = svc.Load(ctx)
997+
998+ // Caller passes plain domain (not the storage key)
999+ err = svc.RemoveRoute(ctx, "insecure.example.com")
1000+ assert.NoError(t, err, "RemoveRoute should find insecure route via http:// fallback")
1001+
1002+ config := svc.GetConfig()
1003+ _, exists := config.Routes["insecure.example.com"]
1004+ assert.False(t, exists, "insecure route should be removed")
1005+}
1006+
1007+func TestService_RemoveRoute_ReconcilesCoexistingLegacyKey(t *testing.T) {
1008+ svc, ctx := setupServiceWithCoexistingLegacyRoute(t, "myapp:latest", "myapp:old")
1009+
1010+ err := svc.RemoveRoute(ctx, "app.example.com")
1011+ assert.NoError(t, err)
1012+
1013+ config := svc.GetConfig()
1014+ _, exists := config.Routes["app.example.com"]
1015+ assert.False(t, exists, "canonical route should be removed")
1016+ _, exists = config.Routes["http://app.example.com"]
1017+ assert.False(t, exists, "legacy http route key should be removed during reconciliation")
1018+}
1019+
1020+func TestService_Save_WrapsInvalidRouteDomainError(t *testing.T) {
1021+ t.Run("canonical key", func(t *testing.T) {
1022+ tmpDir := t.TempDir()
1023+ configFile := filepath.Join(tmpDir, "gordon.toml")
1024+ err := os.WriteFile(configFile, []byte("[routes]\n\"app.example.com\" = \"myapp:latest\"\n"), 0600)
1025+ require.NoError(t, err)
1026+
1027+ v := viper.New()
1028+ v.SetConfigFile(configFile)
1029+ require.NoError(t, v.ReadInConfig())
1030+
1031+ eventBus := mocks.NewMockEventPublisher(t)
1032+ svc := NewService(v, eventBus)
1033+ ctx := testContext()
1034+ require.NoError(t, svc.Load(ctx))
1035+
1036+ svc.mu.Lock()
1037+ svc.config.Routes["localhost"] = routeConfig{Image: "myapp:latest", HTTPS: true}
1038+ svc.mu.Unlock()
1039+
1040+ err = svc.Save(ctx)
1041+ assert.ErrorIs(t, err, domain.ErrRouteDomainInvalid)
1042+ })
1043+
1044+ t.Run("legacy key", func(t *testing.T) {
1045+ tmpDir := t.TempDir()
1046+ configFile := filepath.Join(tmpDir, "gordon.toml")
1047+ err := os.WriteFile(configFile, []byte("[routes]\n\"app.example.com\" = \"myapp:latest\"\n"), 0600)
1048+ require.NoError(t, err)
1049+
1050+ v := viper.New()
1051+ v.SetConfigFile(configFile)
1052+ require.NoError(t, v.ReadInConfig())
1053+
1054+ eventBus := mocks.NewMockEventPublisher(t)
1055+ svc := NewService(v, eventBus)
1056+ ctx := testContext()
1057+ require.NoError(t, svc.Load(ctx))
1058+
1059+ svc.mu.Lock()
1060+ svc.config.Routes["http://localhost"] = routeConfig{Image: "myapp:latest", HTTPS: false}
1061+ svc.mu.Unlock()
1062+
1063+ err = svc.Save(ctx)
1064+ assert.ErrorIs(t, err, domain.ErrRouteDomainInvalid)
1065+ })
1066+}
1067+
4521068 func TestService_GetNetworkGroups(t *testing.T) {
4531069 v := viper.New()
4541070 v.Set("network_groups", map[string]interface{}{
@@ -1434,9 +2050,14 @@ backend = ["app.example.com"]
14342050 assert.True(t, v2.GetBool("backups.enabled"))
14352051 assert.Equal(t, "~/.gordon/backups", v2.GetString("backups.storage_dir"))
14362052
1437- savedRoutes := loadStringMap(v2.Get("routes"))
1438- assert.Equal(t, "newapp:latest", savedRoutes["new.example.com"])
1439- assert.Equal(t, "reg.example.com/myapp:latest", savedRoutes["app.example.com"])
2053+ savedRoutes, err := loadCanonicalRoutes(v2.Get("routes"))
2054+ require.NoError(t, err)
2055+ routeCfg := requireRoute(t, savedRoutes, "new.example.com")
2056+ assert.Equal(t, "newapp:latest", routeCfg.Image)
2057+ assert.False(t, routeCfg.HTTPS)
2058+ routeCfg = requireRoute(t, savedRoutes, "app.example.com")
2059+ assert.Equal(t, "reg.example.com/myapp:latest", routeCfg.Image)
2060+ assert.True(t, routeCfg.HTTPS)
14402061
14412062 matches, err := filepath.Glob(configFile + ".bak.*")
14422063 require.NoError(t, err)
M internal/usecase/container/autoroute.go +19 -45
@@ -1,7 +1,6 @@
11 package container
22
33 import (
4- "bufio"
54 "bytes"
65 "context"
76 "fmt"
@@ -199,11 +198,13 @@ func (h *AutoRouteHandler) createOrUpdateRoute(ctx context.Context, routeDomain,
199198 route := domain.Route{
200199 Domain: routeDomain,
201200 Image: imageName,
201+ HTTPS: true,
202202 }
203203
204204 existingRoutes := h.configSvc.GetRoutes(ctx)
205205 for _, existing := range existingRoutes {
206206 if existing.Domain == routeDomain {
207+ route.HTTPS = existing.HTTPS
207208 if auto.ExtractRepoName(existing.Image, h.registryDomain) != auto.ExtractRepoName(imageName, h.registryDomain) {
208209 log.Warn().Str("domain", routeDomain).Str("existing_image", existing.Image).Str("image", imageName).Msg("auto-route update rejected due to repository ownership mismatch")
209210 return false
@@ -280,7 +281,7 @@ func (h *AutoRouteHandler) extractAndMergeEnvFile(ctx context.Context, imageRef,
280281 }
281282
282283 // Parse the extracted env file
283- imageEnv, err := parseEnvFile(envData)
284+ imageEnv, err := domain.ParseEnvData(envData)
284285 if err != nil {
285286 return fmt.Errorf("failed to parse env file: %w", err)
286287 }
@@ -290,6 +291,15 @@ func (h *AutoRouteHandler) extractAndMergeEnvFile(ctx context.Context, imageRef,
290291 return nil
291292 }
292293
294+ // Security check: reject imported env files containing secret references
295+ // to prevent attacker-controlled images from persisting ${provider:path}
296+ // syntax that would later resolve against host secret providers.
297+ for key, value := range imageEnv {
298+ if domain.ContainsSecretReference(value) {
299+ return fmt.Errorf("env key %q contains secret reference: %w", key, domain.ErrEnvContainsSecretRef)
300+ }
301+ }
302+
293303 // Load existing env file for this domain
294304 envFileName, err := domainToEnvFileName(routeDomain)
295305 if err != nil {
@@ -299,7 +309,13 @@ func (h *AutoRouteHandler) extractAndMergeEnvFile(ctx context.Context, imageRef,
299309
300310 existingEnv := make(map[string]string)
301311 if data, err := os.ReadFile(envFileDst); err == nil {
302- existingEnv, _ = parseEnvFile(data)
312+ var parseErr error
313+ existingEnv, parseErr = domain.ParseEnvData(data)
314+ if parseErr != nil {
315+ return fmt.Errorf("failed to parse existing env file %q: %w", envFileDst, parseErr)
316+ }
317+ } else if !os.IsNotExist(err) {
318+ return fmt.Errorf("failed to read existing env file %q: %w", envFileDst, err)
303319 }
304320
305321 // Merge: image values are defaults, existing values win
@@ -352,48 +368,6 @@ func (h *AutoRouteHandler) extractLabels(ctx context.Context, manifestData []byt
352368 return labels, nil
353369 }
354370
355-// parseEnvFile parses an env file into a map of key-value pairs.
356-func parseEnvFile(data []byte) (map[string]string, error) {
357- env := make(map[string]string)
358-
359- scanner := bufio.NewScanner(bytes.NewReader(data))
360- for scanner.Scan() {
361- line := strings.TrimSpace(scanner.Text())
362-
363- // Skip empty lines and comments
364- if line == "" || strings.HasPrefix(line, "#") {
365- continue
366- }
367-
368- // Parse KEY=value
369- parts := strings.SplitN(line, "=", 2)
370- if len(parts) != 2 {
371- continue
372- }
373-
374- key := strings.TrimSpace(parts[0])
375- value := strings.TrimSpace(parts[1])
376-
377- // Remove surrounding quotes if present
378- if len(value) >= 2 {
379- if (value[0] == '"' && value[len(value)-1] == '"') ||
380- (value[0] == '\'' && value[len(value)-1] == '\'') {
381- value = value[1 : len(value)-1]
382- }
383- }
384-
385- if key != "" {
386- env[key] = value
387- }
388- }
389-
390- if err := scanner.Err(); err != nil {
391- return nil, err
392- }
393-
394- return env, nil
395-}
396-
397371 // domainToEnvFileName converts a domain to an env file name.
398372 // Must match the naming convention in envloader.FileLoader.getEnvFilePath.
399373 func domainToEnvFileName(domainName string) (string, error) {
M internal/usecase/container/autoroute_test.go +162 -11
@@ -4,6 +4,8 @@ import (
44 "context"
55 "encoding/json"
66 "io"
7+ "os"
8+ "path/filepath"
79 "strings"
810 "testing"
911
@@ -62,7 +64,7 @@ func TestDomainToEnvFileName(t *testing.T) {
6264 }
6365 }
6466
65-// parseEnvFile tests
67+// ParseEnvData tests
6668
6769 func TestParseEnvFile(t *testing.T) {
6870 tests := []struct {
@@ -142,7 +144,7 @@ func TestParseEnvFile(t *testing.T) {
142144
143145 for _, tt := range tests {
144146 t.Run(tt.name, func(t *testing.T) {
145- result, err := parseEnvFile([]byte(tt.input))
147+ result, err := domain.ParseEnvData([]byte(tt.input))
146148
147149 if tt.wantErr {
148150 assert.Error(t, err)
@@ -395,7 +397,6 @@ func TestAutoRouteHandler_CanHandle(t *testing.T) {
395397
396398 assert.True(t, handler.CanHandle(domain.EventImagePushed))
397399 assert.False(t, handler.CanHandle(domain.EventConfigReload))
398- assert.False(t, handler.CanHandle(domain.EventManualReload))
399400 }
400401
401402 func TestAutoRouteHandler_Handle_DisabledAutoRoute(t *testing.T) {
@@ -508,12 +509,14 @@ func TestAutoRouteHandler_Handle_CreatesNewRoute(t *testing.T) {
508509 configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{
509510 Domain: "app.example.com",
510511 Image: "myapp:latest",
512+ HTTPS: true,
511513 }).Return(nil)
512514
513515 // Expect deploy to be triggered
514516 containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
515517 Domain: "app.example.com",
516518 Image: "myapp:latest",
519+ HTTPS: true,
517520 }).Return(&domain.Container{ID: "container-1"}, nil)
518521
519522 event := domain.Event{
@@ -564,19 +567,21 @@ func TestAutoRouteHandler_Handle_UpdatesExistingRoute(t *testing.T) {
564567
565568 // Existing route with different image
566569 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
567- {Domain: "app.example.com", Image: "myapp:v1"},
570+ {Domain: "app.example.com", Image: "myapp:v1", HTTPS: true},
568571 })
569572
570573 // Expect route to be updated
571574 configSvc.EXPECT().UpdateRoute(mock.Anything, domain.Route{
572575 Domain: "app.example.com",
573576 Image: "myapp:v2",
577+ HTTPS: true,
574578 }).Return(nil)
575579
576580 // Expect deploy to be triggered
577581 containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
578582 Domain: "app.example.com",
579583 Image: "myapp:v2",
584+ HTTPS: true,
580585 }).Return(&domain.Container{ID: "container-1"}, nil)
581586
582587 event := domain.Event{
@@ -733,14 +738,17 @@ func TestAutoRouteHandler_Handle_MultipleDomains(t *testing.T) {
733738 configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{
734739 Domain: "app.example.com",
735740 Image: "myapp:latest",
741+ HTTPS: true,
736742 }).Return(nil)
737743 configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{
738744 Domain: "api.example.com",
739745 Image: "myapp:latest",
746+ HTTPS: true,
740747 }).Return(nil)
741748 configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{
742749 Domain: "www.example.com",
743750 Image: "myapp:latest",
751+ HTTPS: true,
744752 }).Return(nil)
745753
746754 // Expect deploy for all three
@@ -786,8 +794,8 @@ func TestAutoRouteHandler_NewDomain_Allowed(t *testing.T) {
786794 handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
787795
788796 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{})
789- configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:latest"}).Return(nil)
790- containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:latest"}).Return(&domain.Container{ID: "c1"}, nil)
797+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:latest", HTTPS: true}).Return(nil)
798+ containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:latest", HTTPS: true}).Return(&domain.Container{ID: "c1"}, nil)
791799
792800 created := handler.createOrUpdateRoute(context.Background(), "app.example.com", "myapp:latest", []string{"*.example.com"})
793801 assert.True(t, created)
@@ -813,9 +821,9 @@ func TestAutoRouteHandler_ExistingDomain_SameRepo(t *testing.T) {
813821 blobStorage := mocks.NewMockBlobStorage(t)
814822 handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
815823
816- configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{Domain: "app.example.com", Image: "myapp:v1"}})
817- configSvc.EXPECT().UpdateRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:v2"}).Return(nil)
818- containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:v2"}).Return(&domain.Container{ID: "c1"}, nil)
824+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{Domain: "app.example.com", Image: "myapp:v1", HTTPS: true}})
825+ configSvc.EXPECT().UpdateRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:v2", HTTPS: true}).Return(nil)
826+ containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:v2", HTTPS: true}).Return(&domain.Container{ID: "c1"}, nil)
819827
820828 created := handler.createOrUpdateRoute(context.Background(), "app.example.com", "myapp:v2", []string{"*.example.com"})
821829 assert.False(t, created)
@@ -845,8 +853,8 @@ func TestAutoRouteHandler_EnvExtractionOnlyOnCreate(t *testing.T) {
845853
846854 configSvc.EXPECT().GetAutoRouteAllowedDomains(mock.Anything).Return([]string{"*.example.com"}, nil).Twice()
847855 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}).Once()
848- configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:latest"}).Return(nil)
849- containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:latest"}).Return(&domain.Container{ID: "c1"}, nil)
856+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:latest", HTTPS: true}).Return(nil)
857+ containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:latest", HTTPS: true}).Return(&domain.Container{ID: "c1"}, nil)
850858 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{Domain: "app.example.com", Image: "myapp:latest"}}).Once()
851859
852860 handler.processRoutes(context.Background(), []string{"app.example.com"}, "myapp:latest", &domain.ImageLabels{EnvFile: ".env"})
@@ -857,21 +865,164 @@ func TestAutoRouteHandler_EnvExtractionOnlyOnCreate(t *testing.T) {
857865 assert.Equal(t, ".env", extractor.lastPath)
858866 }
859867
868+func TestAutoRouteHandler_ExtractAndMergeEnvFile_ReturnsExistingParseError(t *testing.T) {
869+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
870+ configSvc := inmocks.NewMockConfigService(t)
871+ containerSvc := inmocks.NewMockContainerService(t)
872+ blobStorage := mocks.NewMockBlobStorage(t)
873+ extractor := &testEnvExtractor{data: []byte("FOO=bar\n")}
874+
875+ envDir := t.TempDir()
876+ handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com").WithEnvExtractor(extractor, envDir)
877+
878+ envFileName, err := domainToEnvFileName("app.example.com")
879+ require.NoError(t, err)
880+ envFileDst := filepath.Join(envDir, envFileName)
881+ err = os.WriteFile(envFileDst, []byte("BIG="+strings.Repeat("a", (1<<20)+1)), 0600)
882+ require.NoError(t, err)
883+
884+ err = handler.extractAndMergeEnvFile(context.Background(), "myapp:latest", "app.example.com", ".env")
885+ require.Error(t, err)
886+ assert.Contains(t, err.Error(), envFileDst)
887+ assert.Contains(t, err.Error(), "failed to parse existing env file")
888+}
889+
860890 type testEnvExtractor struct {
861891 calls int
862892 lastImage string
863893 lastPath string
894+ data []byte
895+ err error
864896 }
865897
866898 func (e *testEnvExtractor) ExtractEnvFileFromImage(_ context.Context, imageRef, envFilePath string) ([]byte, error) {
867899 e.calls++
868900 e.lastImage = imageRef
869901 e.lastPath = envFilePath
902+ if e.err != nil {
903+ return nil, e.err
904+ }
905+ if e.data != nil {
906+ return e.data, nil
907+ }
870908 return []byte("FOO=bar\n"), nil
871909 }
872910
873911 // collectDomains tests
874912
913+func TestAutoRouteHandler_ExtractAndMergeEnvFile_RejectsSecretReferences(t *testing.T) {
914+ tests := []struct {
915+ name string
916+ envData string
917+ wantErr bool
918+ errContains string
919+ }{
920+ {
921+ name: "pass reference in value",
922+ envData: "DB_PASSWORD=${pass:myapp/db}\n",
923+ wantErr: true,
924+ errContains: "secret reference",
925+ },
926+ {
927+ name: "sops reference in value",
928+ envData: "API_KEY=${sops:secrets.yaml#/key}\n",
929+ wantErr: true,
930+ errContains: "secret reference",
931+ },
932+ {
933+ name: "pass reference embedded in text",
934+ envData: "CONNECTION=prefix-${pass:secret}-suffix\n",
935+ wantErr: true,
936+ errContains: "secret reference",
937+ },
938+ {
939+ name: "multiple secret references",
940+ envData: "A=${pass:a}\nB=${sops:b}\n",
941+ wantErr: true,
942+ errContains: "secret reference",
943+ },
944+ {
945+ name: "normal env values are allowed",
946+ envData: "FOO=bar\nBAZ=qux\n",
947+ wantErr: false,
948+ },
949+ {
950+ name: "dollar sign without reference is allowed",
951+ envData: "PRICE=$100\n",
952+ wantErr: false,
953+ },
954+ {
955+ name: "empty braces are allowed",
956+ envData: "EMPTY=${}\n",
957+ wantErr: false,
958+ },
959+ }
960+
961+ for _, tt := range tests {
962+ t.Run(tt.name, func(t *testing.T) {
963+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
964+ configSvc := inmocks.NewMockConfigService(t)
965+ containerSvc := inmocks.NewMockContainerService(t)
966+ blobStorage := mocks.NewMockBlobStorage(t)
967+ extractor := &testEnvExtractor{data: []byte(tt.envData)}
968+
969+ envDir := t.TempDir()
970+ handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com").
971+ WithEnvExtractor(extractor, envDir)
972+
973+ err := handler.extractAndMergeEnvFile(context.Background(), "myapp:latest", "app.example.com", ".env")
974+
975+ if tt.wantErr {
976+ assert.Error(t, err)
977+ assert.Contains(t, err.Error(), tt.errContains)
978+ return
979+ }
980+
981+ assert.NoError(t, err)
982+ })
983+ }
984+}
985+
986+func TestAutoRouteHandler_ExtractAndMergeEnvFile_DoesNotPersistSecretReferences(t *testing.T) {
987+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
988+ configSvc := inmocks.NewMockConfigService(t)
989+ containerSvc := inmocks.NewMockContainerService(t)
990+ blobStorage := mocks.NewMockBlobStorage(t)
991+ extractor := &testEnvExtractor{data: []byte("DB_PASSWORD=${pass:myapp/db}\n")}
992+
993+ envDir := t.TempDir()
994+ handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com").WithEnvExtractor(extractor, envDir)
995+
996+ envFileName, err := domainToEnvFileName("app.example.com")
997+ require.NoError(t, err)
998+ envFileDst := filepath.Join(envDir, envFileName)
999+
1000+ err = handler.extractAndMergeEnvFile(context.Background(), "myapp:latest", "app.example.com", ".env")
1001+ require.ErrorIs(t, err, domain.ErrEnvContainsSecretRef)
1002+ assert.NoFileExists(t, envFileDst)
1003+}
1004+
1005+func TestAutoRouteHandler_ExtractAndMergeEnvFile_ReturnsReadErrorForExistingFileIssues(t *testing.T) {
1006+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
1007+ configSvc := inmocks.NewMockConfigService(t)
1008+ containerSvc := inmocks.NewMockContainerService(t)
1009+ blobStorage := mocks.NewMockBlobStorage(t)
1010+ extractor := &testEnvExtractor{data: []byte("FOO=bar\n")}
1011+
1012+ envDir := t.TempDir()
1013+ handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com").WithEnvExtractor(extractor, envDir)
1014+
1015+ envFileName, err := domainToEnvFileName("app.example.com")
1016+ require.NoError(t, err)
1017+ envFileDst := filepath.Join(envDir, envFileName)
1018+ require.NoError(t, os.Mkdir(envFileDst, 0700))
1019+
1020+ err = handler.extractAndMergeEnvFile(context.Background(), "myapp:latest", "app.example.com", ".env")
1021+ require.Error(t, err)
1022+ assert.Contains(t, err.Error(), envFileDst)
1023+ assert.Contains(t, err.Error(), "failed to read existing env file")
1024+}
1025+
8751026 func TestAutoRouteHandler_CollectDomains(t *testing.T) {
8761027 ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
8771028 configSvc := inmocks.NewMockConfigService(t)
M internal/usecase/container/events.go +0 -85
@@ -175,91 +175,6 @@ func (h *ConfigReloadHandler) CanHandle(eventType domain.EventType) bool {
175175 return eventType == domain.EventConfigReload
176176 }
177177
178-// ManualReloadHandler handles manual.reload events.
179-type ManualReloadHandler struct {
180- containerSvc in.ContainerService
181- configSvc in.ConfigService
182- ctx context.Context
183-}
184-
185-// NewManualReloadHandler creates a new ManualReloadHandler.
186-func NewManualReloadHandler(ctx context.Context, containerSvc in.ContainerService, configSvc in.ConfigService) *ManualReloadHandler {
187- return &ManualReloadHandler{
188- containerSvc: containerSvc,
189- configSvc: configSvc,
190- ctx: ctx,
191- }
192-}
193-
194-// Handle handles an event.
195-// It starts containers for configured routes that don't have a running container.
196-// Running containers are NEVER restarted to ensure 100% uptime.
197-func (h *ManualReloadHandler) Handle(ctx context.Context, event domain.Event) error {
198- ctx = zerowrap.CtxWithFields(ctx, map[string]any{
199- zerowrap.FieldLayer: "usecase",
200- zerowrap.FieldHandler: "ManualReloadHandler",
201- zerowrap.FieldEvent: string(event.Type),
202- "event_id": event.ID,
203- })
204- log := zerowrap.FromCtx(ctx)
205-
206- log.Info().Msg("processing manual reload event - starting missing containers")
207-
208- // Sync containers first to ensure we have accurate state
209- // This removes tracking for containers that were stopped externally
210- if err := h.containerSvc.SyncContainers(ctx); err != nil {
211- log.Warn().Err(err).Msg("failed to sync containers before reload, proceeding with current state")
212- }
213-
214- routes := h.configSvc.GetRoutes(ctx)
215- startedCount := 0
216- skippedCount := 0
217- errorCount := 0
218-
219- for _, route := range routes {
220- if _, exists := h.containerSvc.Get(ctx, route.Domain); exists {
221- log.Debug().Str("domain", route.Domain).Msg("container already running, skipping")
222- skippedCount++
223- continue
224- }
225-
226- log.Info().
227- Str("domain", route.Domain).
228- Str("image", route.Image).
229- Msg("starting container for route")
230-
231- if _, err := h.containerSvc.Deploy(domain.WithInternalDeploy(ctx), route); err != nil {
232- log.WrapErrWithFields(err, "failed to start container", map[string]any{"domain": route.Domain})
233- errorCount++
234- continue
235- }
236-
237- startedCount++
238- log.Info().Str("domain", route.Domain).Msg("container started successfully")
239- }
240-
241- if errorCount > 0 {
242- log.Warn().
243- Int("started", startedCount).
244- Int("skipped", skippedCount).
245- Int("errors", errorCount).
246- Msg("manual reload completed with some errors")
247- return fmt.Errorf("manual reload completed with %d errors", errorCount)
248- }
249-
250- log.Info().
251- Int("started", startedCount).
252- Int("skipped", skippedCount).
253- Int("total", len(routes)).
254- Msg("manual reload completed successfully")
255- return nil
256-}
257-
258-// CanHandle returns whether this handler can handle the given event type.
259-func (h *ManualReloadHandler) CanHandle(eventType domain.EventType) bool {
260- return eventType == domain.EventManualReload
261-}
262-
263178 // ManualDeployHandler handles manual.deploy events for specific routes.
264179 type ManualDeployHandler struct {
265180 containerSvc in.ContainerService
M internal/usecase/container/events_test.go +0 -151
@@ -27,7 +27,6 @@ func TestImagePushedHandler_CanHandle(t *testing.T) {
2727
2828 assert.True(t, handler.CanHandle(domain.EventImagePushed))
2929 assert.False(t, handler.CanHandle(domain.EventConfigReload))
30- assert.False(t, handler.CanHandle(domain.EventManualReload))
3130 }
3231
3332 func TestImagePushedHandler_Handle_DeploysMatchingRoutes(t *testing.T) {
@@ -197,7 +196,6 @@ func TestConfigReloadHandler_CanHandle(t *testing.T) {
197196
198197 assert.True(t, handler.CanHandle(domain.EventConfigReload))
199198 assert.False(t, handler.CanHandle(domain.EventImagePushed))
200- assert.False(t, handler.CanHandle(domain.EventManualReload))
201199 }
202200
203201 func TestConfigReloadHandler_Handle_DeploysNewRoutes(t *testing.T) {
@@ -360,154 +358,6 @@ func TestConfigReloadHandler_Handle_NoChanges(t *testing.T) {
360358 assert.NoError(t, err)
361359 }
362360
363-// ManualReloadHandler tests
364-
365-func TestManualReloadHandler_CanHandle(t *testing.T) {
366- containerSvc := inmocks.NewMockContainerService(t)
367- configSvc := inmocks.NewMockConfigService(t)
368-
369- handler := NewManualReloadHandler(testCtx(), containerSvc, configSvc)
370-
371- assert.True(t, handler.CanHandle(domain.EventManualReload))
372- assert.False(t, handler.CanHandle(domain.EventImagePushed))
373- assert.False(t, handler.CanHandle(domain.EventConfigReload))
374-}
375-
376-func TestManualReloadHandler_Handle_StartsOnlyMissingContainers(t *testing.T) {
377- containerSvc := inmocks.NewMockContainerService(t)
378- configSvc := inmocks.NewMockConfigService(t)
379-
380- handler := NewManualReloadHandler(testCtx(), containerSvc, configSvc)
381-
382- // SyncContainers is called first
383- containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
384-
385- // Routes in config
386- configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
387- {Domain: "app1.example.com", Image: "app1:latest"},
388- {Domain: "app2.example.com", Image: "app2:latest"},
389- })
390-
391- // app1 has a running container, app2 does not
392- containerSvc.EXPECT().Get(mock.Anything, "app1.example.com").Return(&domain.Container{ID: "container-1"}, true)
393- containerSvc.EXPECT().Get(mock.Anything, "app2.example.com").Return(nil, false)
394-
395- // Only app2 should be deployed (app1 is already running - never restart healthy containers)
396- containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
397- Domain: "app2.example.com",
398- Image: "app2:latest",
399- }).Return(&domain.Container{ID: "container-2"}, nil)
400-
401- event := domain.Event{
402- ID: "event-123",
403- Type: domain.EventManualReload,
404- }
405-
406- err := handler.Handle(context.Background(), event)
407-
408- assert.NoError(t, err)
409-}
410-
411-func TestManualReloadHandler_Handle_StartsMissingContainer(t *testing.T) {
412- containerSvc := inmocks.NewMockContainerService(t)
413- configSvc := inmocks.NewMockConfigService(t)
414-
415- handler := NewManualReloadHandler(testCtx(), containerSvc, configSvc)
416-
417- // SyncContainers is called first
418- containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
419-
420- configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
421- {Domain: "app.example.com", Image: "app:latest"},
422- })
423-
424- containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(nil, false)
425-
426- // Deploy should be called to start the missing container
427- containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
428- Domain: "app.example.com",
429- Image: "app:latest",
430- }).Return(&domain.Container{ID: "container-1"}, nil)
431-
432- event := domain.Event{
433- ID: "event-123",
434- Type: domain.EventManualReload,
435- }
436-
437- err := handler.Handle(context.Background(), event)
438-
439- assert.NoError(t, err)
440-}
441-
442-func TestManualReloadHandler_Handle_DeployErrors(t *testing.T) {
443- containerSvc := inmocks.NewMockContainerService(t)
444- configSvc := inmocks.NewMockConfigService(t)
445-
446- handler := NewManualReloadHandler(testCtx(), containerSvc, configSvc)
447-
448- // SyncContainers is called first
449- containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
450-
451- configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
452- {Domain: "app1.example.com", Image: "app1:latest"},
453- {Domain: "app2.example.com", Image: "app2:latest"},
454- })
455-
456- // Both containers are missing
457- containerSvc.EXPECT().Get(mock.Anything, "app1.example.com").Return(nil, false)
458- containerSvc.EXPECT().Get(mock.Anything, "app2.example.com").Return(nil, false)
459-
460- // app1 fails to deploy, app2 succeeds
461- containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
462- Domain: "app1.example.com",
463- Image: "app1:latest",
464- }).Return(nil, errors.New("deploy failed"))
465-
466- containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
467- Domain: "app2.example.com",
468- Image: "app2:latest",
469- }).Return(&domain.Container{ID: "container-2"}, nil)
470-
471- event := domain.Event{
472- ID: "event-123",
473- Type: domain.EventManualReload,
474- }
475-
476- err := handler.Handle(context.Background(), event)
477-
478- // Should return error indicating some failures
479- assert.Error(t, err)
480- assert.Contains(t, err.Error(), "1 errors")
481-}
482-
483-func TestManualReloadHandler_Handle_DoesNotRestartRunningContainers(t *testing.T) {
484- containerSvc := inmocks.NewMockContainerService(t)
485- configSvc := inmocks.NewMockConfigService(t)
486-
487- handler := NewManualReloadHandler(testCtx(), containerSvc, configSvc)
488-
489- // SyncContainers is called first
490- containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
491-
492- // Route with running container
493- configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
494- {Domain: "app.example.com", Image: "app:latest"},
495- })
496-
497- containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(&domain.Container{ID: "container-1"}, true)
498-
499- // NO Deploy call expected - container is already running, never restart healthy containers
500-
501- event := domain.Event{
502- ID: "event-123",
503- Type: domain.EventManualReload,
504- }
505-
506- err := handler.Handle(context.Background(), event)
507-
508- assert.NoError(t, err)
509-}
510-
511361 // ManualDeployHandler tests
512362
513363 func TestConfigReloadHandlerUpdatesContainerConfig(t *testing.T) {
@@ -552,7 +402,6 @@ func TestManualDeployHandler_CanHandle(t *testing.T) {
552402
553403 assert.True(t, handler.CanHandle(domain.EventManualDeploy))
554404 assert.False(t, handler.CanHandle(domain.EventImagePushed))
555- assert.False(t, handler.CanHandle(domain.EventManualReload))
556405 }
557406
558407 func TestManualDeployHandler_Handle_DeploysSpecificRoute(t *testing.T) {
M internal/usecase/health/service.go +12 -1
@@ -52,6 +52,13 @@ func (s *Service) CheckRoute(ctx context.Context, route domain.Route) *domain.Ro
5252 ContainerStatus: "unknown",
5353 }
5454
55+ // Validate domain before probing - prevents SSRF via invalid route domains
56+ if !domain.IsValidRouteDomain(route.Domain) {
57+ health.Error = "invalid route domain for health probe"
58+ log.Debug().Msg("health check blocked for invalid domain")
59+ return health
60+ }
61+
5562 // Check container status
5663 container, exists := s.containerSvc.Get(ctx, route.Domain)
5764 if !exists || container == nil {
@@ -71,7 +78,11 @@ func (s *Service) CheckRoute(ctx context.Context, route domain.Route) *domain.Ro
7178 }
7279
7380 // Probe HTTP endpoint
74- url := fmt.Sprintf("https://%s/", route.Domain)
81+ scheme := "http"
82+ if route.HTTPS {
83+ scheme = "https"
84+ }
85+ url := fmt.Sprintf("%s://%s/", scheme, route.Domain)
7586 statusCode, responseTime, err := s.prober.Probe(ctx, url)
7687 if err != nil {
7788 health.Error = err.Error()
M internal/usecase/health/service_test.go +66 -7
@@ -25,7 +25,7 @@ func TestService_CheckRoute_ContainerNotFound(t *testing.T) {
2525 containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(nil, false)
2626
2727 svc := NewService(configSvc, containerSvc, prober, testLogger())
28- route := domain.Route{Domain: "app.example.com", Image: "myapp:latest"}
28+ route := domain.Route{Domain: "app.example.com", Image: "myapp:latest", HTTPS: true}
2929
3030 health := svc.CheckRoute(context.Background(), route)
3131
@@ -48,7 +48,7 @@ func TestService_CheckRoute_ContainerNotRunning(t *testing.T) {
4848 containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(container, true)
4949
5050 svc := NewService(configSvc, containerSvc, prober, testLogger())
51- route := domain.Route{Domain: "app.example.com", Image: "myapp:latest"}
51+ route := domain.Route{Domain: "app.example.com", Image: "myapp:latest", HTTPS: true}
5252
5353 health := svc.CheckRoute(context.Background(), route)
5454
@@ -72,7 +72,32 @@ func TestService_CheckRoute_HTTPProbeSuccess(t *testing.T) {
7272 prober.EXPECT().Probe(mock.Anything, "https://app.example.com/").Return(200, int64(45), nil)
7373
7474 svc := NewService(configSvc, containerSvc, prober, testLogger())
75- route := domain.Route{Domain: "app.example.com", Image: "myapp:latest"}
75+ route := domain.Route{Domain: "app.example.com", Image: "myapp:latest", HTTPS: true}
76+
77+ health := svc.CheckRoute(context.Background(), route)
78+
79+ assert.Equal(t, "app.example.com", health.Domain)
80+ assert.Equal(t, "running", health.ContainerStatus)
81+ assert.Equal(t, 200, health.HTTPStatus)
82+ assert.Equal(t, int64(45), health.ResponseTimeMs)
83+ assert.True(t, health.Healthy)
84+ assert.Empty(t, health.Error)
85+}
86+
87+func TestService_CheckRoute_HTTPOnlyRouteUsesHTTP(t *testing.T) {
88+ configSvc := mocks.NewMockConfigService(t)
89+ containerSvc := mocks.NewMockContainerService(t)
90+ prober := mocks.NewMockHTTPProber(t)
91+
92+ container := &domain.Container{
93+ ID: "abc123",
94+ Status: "running",
95+ }
96+ containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(container, true)
97+ prober.EXPECT().Probe(mock.Anything, "http://app.example.com/").Return(200, int64(45), nil)
98+
99+ svc := NewService(configSvc, containerSvc, prober, testLogger())
100+ route := domain.Route{Domain: "app.example.com", Image: "myapp:latest", HTTPS: false}
76101
77102 health := svc.CheckRoute(context.Background(), route)
78103
@@ -97,7 +122,7 @@ func TestService_CheckRoute_HTTPProbeFailure(t *testing.T) {
97122 prober.EXPECT().Probe(mock.Anything, "https://app.example.com/").Return(0, int64(0), errors.New("connection refused"))
98123
99124 svc := NewService(configSvc, containerSvc, prober, testLogger())
100- route := domain.Route{Domain: "app.example.com", Image: "myapp:latest"}
125+ route := domain.Route{Domain: "app.example.com", Image: "myapp:latest", HTTPS: true}
101126
102127 health := svc.CheckRoute(context.Background(), route)
103128
@@ -121,7 +146,7 @@ func TestService_CheckRoute_HTTPStatus5xx(t *testing.T) {
121146 prober.EXPECT().Probe(mock.Anything, "https://app.example.com/").Return(502, int64(100), nil)
122147
123148 svc := NewService(configSvc, containerSvc, prober, testLogger())
124- route := domain.Route{Domain: "app.example.com", Image: "myapp:latest"}
149+ route := domain.Route{Domain: "app.example.com", Image: "myapp:latest", HTTPS: true}
125150
126151 health := svc.CheckRoute(context.Background(), route)
127152
@@ -139,8 +164,8 @@ func TestService_CheckAllRoutes(t *testing.T) {
139164 prober := mocks.NewMockHTTPProber(t)
140165
141166 routes := []domain.Route{
142- {Domain: "app1.example.com", Image: "app1:latest"},
143- {Domain: "app2.example.com", Image: "app2:latest"},
167+ {Domain: "app1.example.com", Image: "app1:latest", HTTPS: true},
168+ {Domain: "app2.example.com", Image: "app2:latest", HTTPS: true},
144169 }
145170 configSvc.EXPECT().GetRoutes(mock.Anything).Return(routes)
146171
@@ -182,3 +207,37 @@ func TestService_CheckAllRoutes_NoRoutes(t *testing.T) {
182207
183208 assert.Empty(t, results)
184209 }
210+
211+func TestService_CheckRoute_InvalidDomain_Blocked(t *testing.T) {
212+ configSvc := mocks.NewMockConfigService(t)
213+ containerSvc := mocks.NewMockContainerService(t)
214+ prober := mocks.NewMockHTTPProber(t)
215+
216+ // No container lookup should happen for invalid domains
217+ // No probe should happen either
218+
219+ svc := NewService(configSvc, containerSvc, prober, testLogger())
220+
221+ tests := []struct {
222+ name string
223+ domain string
224+ }{
225+ {"IP address", "192.168.1.1"},
226+ {"localhost", "localhost"},
227+ {"local TLD", "myapp.local"},
228+ {"internal TLD", "service.internal"},
229+ {"with port", "example.com:8080"},
230+ {"IPv6", "::1"},
231+ }
232+
233+ for _, tt := range tests {
234+ t.Run(tt.name, func(t *testing.T) {
235+ route := domain.Route{Domain: tt.domain, Image: "myapp:latest"}
236+ health := svc.CheckRoute(context.Background(), route)
237+
238+ assert.Equal(t, tt.domain, health.Domain)
239+ assert.False(t, health.Healthy)
240+ assert.Equal(t, "invalid route domain for health probe", health.Error)
241+ })
242+ }
243+}

Keyboard shortcuts

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

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

Branches 7

Tags 106

↑↓ moveenter openesc close