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
87 gordon logs # Gordon process logs87 gordon logs # Gordon process logs
88 gordon logs -f # Follow process logs88 gordon logs -f # Follow process logs
89 gordon logs -n 100 # Last 100 lines89 gordon logs -n 100 # Last 100 lines
90-gordon logs myapp.local # Container logs for myapp.local90+gordon logs myapp.example.com # Container logs for myapp.example.com
91-gordon logs myapp.local -f # Follow container logs91+gordon logs myapp.example.com -f # Follow container logs
92 92
93 # Check version93 # Check version
94 gordon version94 gordon version
@@ -126,9 +126,9 @@ gordon attachments add app.example.com postgres:18
126 gordon attachments remove app.example.com postgres:18126 gordon attachments remove app.example.com postgres:18
127 127
128 # Secrets128 # Secrets
129-gordon secrets list myapp.local129+gordon secrets list myapp.example.com
130-gordon secrets set myapp.local DATABASE_URL "postgres://..."130+gordon secrets set myapp.example.com DATABASE_URL "postgres://..."
131-gordon secrets remove myapp.local DATABASE_URL131+gordon secrets remove myapp.example.com DATABASE_URL
132 132
133 # Remotes133 # Remotes
134 gordon remotes add prod https://gordon.mydomain.com --token $TOKEN134 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
168 when you want to bypass your saved configuration.168 when you want to bypass your saved configuration.
169 169
170 **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.170 **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.
171 172
172 Use `--insecure` when the remote endpoint uses a self-signed or otherwise untrusted TLS certificate.173 Use `--insecure` when the remote endpoint uses a self-signed or otherwise untrusted TLS certificate.
173 You can make this persistent with `insecure_tls = true` in `[client]` of `~/.config/gordon/gordon.toml`174 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]
53 | `<name>` | Name for the remote (e.g., `prod`, `staging`) |53 | `<name>` | Name for the remote (e.g., `prod`, `staging`) |
54 | `<url>` | Gordon URL (the `gordon_domain` from remote config) |54 | `<url>` | Gordon URL (the `gordon_domain` from remote config) |
55 55
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+
56 ### Options58 ### Options
57 59
58 | Option | Description |60 | Option | Description |
M docs/cli/secrets.md +21 -20
@@ -7,7 +7,8 @@ Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
7 7
8 Storage depends on the secrets backend:8 Storage depends on the secrets backend:
9 - `pass`: secrets are stored in pass under `gordon/env/<domain>/<KEY>`9 - `pass`: secrets are stored in pass under `gordon/env/<domain>/<KEY>`
10-- `sops` or `unsafe`: secrets are stored in domain `.env` files10+- `sops`: secrets live in domain `.env` files and can reference SOPS-encrypted values
11+- `unsafe`: secrets are stored in plain-text domain `.env` files
11 12
12 ## gordon secrets13 ## gordon secrets
13 14
@@ -47,10 +48,10 @@ gordon secrets list <domain>
47 48
48 ```bash49 ```bash
49 # Local50 # Local
50-gordon secrets list myapp.local51+gordon secrets list myapp.example.com
51 52
52 # Remote (override)53 # Remote (override)
53-gordon secrets list myapp.local --remote https://gordon.mydomain.com --token $TOKEN54+gordon secrets list myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN
54 ```55 ```
55 56
56 ### Output57 ### Output
@@ -101,7 +102,7 @@ Set a secret value for a domain.
101 102
102 ```bash103 ```bash
103 gordon secrets set <domain> <KEY=value>...104 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://..."
105 ```106 ```
106 107
107 ### Arguments108 ### Arguments
@@ -109,8 +110,7 @@ gordon secrets set myapp.local DATABASE_URL "postgres://..."
109 | Argument | Description |110 | Argument | Description |
110 |----------|-------------|111 |----------|-------------|
111 | `<domain>` | The domain name to set the secret for |112 | `<domain>` | The domain name to set the secret for |
112-| `<key>` | The secret key (environment variable name) |113+| `<KEY=value>...` | One or more secret key/value pairs |
113-| `<value>` | The secret value |
114 114
115 ### Options115 ### Options
116 116
@@ -124,11 +124,11 @@ gordon secrets set myapp.local DATABASE_URL "postgres://..."
124 124
125 ```bash125 ```bash
126 # Local126 # Local
127-gordon secrets set myapp.local DATABASE_URL "postgres://user:pass@postgres:5432/db"127+gordon secrets set myapp.example.com DATABASE_URL="postgres://user:pass@postgres:5432/db"
128-gordon secrets set myapp.local API_KEY "your-api-key"128+gordon secrets set myapp.example.com API_KEY="your-api-key"
129 129
130 # Remote (override)130 # Remote (override)
131-gordon secrets set myapp.local DATABASE_URL "postgres://..." --remote https://gordon.mydomain.com --token $TOKEN131+gordon secrets set myapp.example.com DATABASE_URL="$DATABASE_URL" --remote https://gordon.mydomain.com --token $TOKEN
132 132
133 # Set attachment secrets133 # Set attachment secrets
134 gordon secrets set app.mydomain.com --attachment postgres POSTGRES_PASSWORD=secret134 gordon secrets set app.mydomain.com --attachment postgres POSTGRES_PASSWORD=secret
@@ -160,6 +160,7 @@ gordon secrets remove <domain> <key>
160 | Option | Description |160 | Option | Description |
161 |--------|-------------|161 |--------|-------------|
162 | `--attachment` / `-a` | Target an attachment service (e.g., postgres, redis) |162 | `--attachment` / `-a` | Target an attachment service (e.g., postgres, redis) |
163+| `--force` | Remove without confirmation |
163 | `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |164 | `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
164 | `--token` | Authentication token for remote |165 | `--token` | Authentication token for remote |
165 166
@@ -167,10 +168,10 @@ gordon secrets remove <domain> <key>
167 168
168 ```bash169 ```bash
169 # Local170 # Local
170-gordon secrets remove myapp.local DATABASE_URL171+gordon secrets remove myapp.example.com DATABASE_URL
171 172
172 # Remote (override)173 # Remote (override)
173-gordon secrets remove myapp.local DATABASE_URL --remote https://gordon.mydomain.com --token $TOKEN174+gordon secrets remove myapp.example.com DATABASE_URL --remote https://gordon.mydomain.com --token $TOKEN
174 175
175 # Remove attachment secret176 # Remove attachment secret
176 gordon secrets remove app.mydomain.com --attachment postgres POSTGRES_PASSWORD177 gordon secrets remove app.mydomain.com --attachment postgres POSTGRES_PASSWORD
@@ -184,17 +185,17 @@ gordon secrets remove app.mydomain.com --attachment postgres POSTGRES_PASSWORD
184 185
185 ```bash186 ```bash
186 # Database connection187 # 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"
188 189
189 # API keys190 # API keys
190-gordon secrets set myapp.local STRIPE_KEY "sk_live_..."191+gordon secrets set myapp.example.com STRIPE_KEY="sk_live_..."
191-gordon secrets set myapp.local SENDGRID_KEY "SG..."192+gordon secrets set myapp.example.com SENDGRID_KEY="SG..."
192 193
193 # JWT secret194 # 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"
195 196
196 # Verify197 # Verify
197-gordon secrets list myapp.local198+gordon secrets list myapp.example.com
198 ```199 ```
199 200
200 ### CI/CD Secret Management201 ### CI/CD Secret Management
@@ -205,8 +206,8 @@ export GORDON_REMOTE=https://gordon.mydomain.com
205 export GORDON_TOKEN=$GORDON_TOKEN206 export GORDON_TOKEN=$GORDON_TOKEN
206 207
207 # Update secrets before deploy208 # Update secrets before deploy
208-gordon secrets set myapp.example.com DATABASE_URL "$DATABASE_URL"209+gordon secrets set myapp.example.com DATABASE_URL="$DATABASE_URL"
209-gordon secrets set myapp.example.com API_KEY "$API_KEY"210+gordon secrets set myapp.example.com API_KEY="$API_KEY"
210 211
211 # Deploy212 # Deploy
212 gordon deploy myapp.example.com213 gordon deploy myapp.example.com
@@ -219,10 +220,10 @@ gordon deploy myapp.example.com
219 NEW_JWT_SECRET=$(openssl rand -base64 32)220 NEW_JWT_SECRET=$(openssl rand -base64 32)
220 221
221 # Update the secret222 # 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"
223 224
224 # Redeploy to pick up new secret225 # Redeploy to pick up new secret
225-gordon deploy myapp.local226+gordon deploy myapp.example.com
226 ```227 ```
227 228
228 ### Attachment Secrets229 ### Attachment Secrets
M docs/cli/serve.md +6 -6
@@ -245,13 +245,13 @@ gordon logs -n 100 # Last 100 lines
245 gordon logs -f -n 200 # Follow, starting from last 200 lines245 gordon logs -f -n 200 # Follow, starting from last 200 lines
246 246
247 # Container logs247 # Container logs
248-gordon logs myapp.local # Last 50 lines from container248+gordon logs myapp.example.com # Last 50 lines from container
249-gordon logs myapp.local -f # Follow container logs249+gordon logs myapp.example.com -f # Follow container logs
250-gordon logs myapp.local -n 100 # Last 100 lines from container250+gordon logs myapp.example.com -n 100 # Last 100 lines from container
251 251
252 # Remote mode (override)252 # Remote mode (override)
253 gordon logs --remote https://gordon.mydomain.com --token $TOKEN253 gordon logs --remote https://gordon.mydomain.com --token $TOKEN
254-gordon logs myapp.local --remote https://gordon.mydomain.com --token $TOKEN254+gordon logs myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN
255 ```255 ```
256 256
257 ### Log Locations257 ### Log Locations
@@ -267,8 +267,8 @@ tail -f ~/.gordon/logs/gordon.log
267 journalctl --user -u gordon -f267 journalctl --user -u gordon -f
268 268
269 # Container logs via docker (local alternative)269 # Container logs via docker (local alternative)
270-docker logs --tail 50 myapp.local270+docker logs --tail 50 myapp.example.com
271-docker logs -f myapp.local271+docker logs -f myapp.example.com
272 ```272 ```
273 273
274 ---274 ---
M docs/concepts.md +15 -12
@@ -65,23 +65,25 @@ Routes map domains to container images:
65 65
66 ```toml66 ```toml
67 [routes]67 [routes]
68-"app.mydomain.com" = "myapp:latest"68+"app.example.com" = { image = "myapp:latest" }
69-"api.mydomain.com" = "myapi:v2.1.0"69+"api.example.com" = { image = "myapi:v2.1.0" }
70 ```70 ```
71 71
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:
73 75
74 1. Looks up the route configuration76 1. Looks up the route configuration
75 2. Finds the running container for `myapp:latest`77 2. Finds the running container for `myapp:latest`
76 3. Proxies the request to that container78 3. Proxies the request to that container
77 79
78-### HTTP vs HTTPS Routes80+### Route Domains
79 81
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.
81 83
82 ```toml84 ```toml
83 [routes]85 [routes]
84-"http://internal.local" = "internal-app:latest"86+"dev-app.example.com" = { image = "internal-app:latest", https = false }
85 ```87 ```
86 88
87 ## Network Isolation89 ## Network Isolation
@@ -188,16 +190,18 @@ Gordon watches its config file and reloads automatically:
188 190
189 1. Edit `~/.config/gordon/gordon.toml`191 1. Edit `~/.config/gordon/gordon.toml`
190 2. Save the file192 2. Save the file
191-3. Gordon reloads routes, attachments, and network groups193+3. Gordon reloads hot-reloaded settings such as attachments, network groups, and routes
192-4. Containers sync to match new configuration194+4. Containers and proxy config sync to match the new configuration
195+
196+Route file edits now reload automatically again.
193 197
194-You can also trigger a manual reload:198+You can also trigger a reload:
195 199
196 ```bash200 ```bash
197 gordon reload201 gordon reload
198 ```202 ```
199 203
200-This sends `SIGUSR1` to the running Gordon process.204+`gordon reload` sends `SIGUSR1` to the running Gordon process.
201 205
202 ## Event System206 ## Event System
203 207
@@ -206,8 +210,7 @@ Gordon uses an internal event system for coordination:
206 | Event | Trigger | Action |210 | Event | Trigger | Action |
207 |-------|---------|--------|211 |-------|---------|--------|
208 | `image.pushed` | Image pushed to registry | Deploy container |212 | `image.pushed` | Image pushed to registry | Deploy container |
209-| `config.reload` | Config file changed | Sync containers |213+| `config.reload` | Config file changed or `gordon reload` sends `SIGUSR1` | Reload config, sync containers, and refresh proxy state |
210-| `manual.reload` | `gordon reload` command | Sync containers |
211 | `manual.deploy` | `gordon deploy <domain>` command | Deploy specific route |214 | `manual.deploy` | `gordon deploy <domain>` command | Deploy specific route |
212 | `container.deployed` | Container started | Update proxy cache |215 | `container.deployed` | Container started | Update proxy cache |
213 216
M docs/config/index.md +3 -1
@@ -21,6 +21,8 @@ gordon_domain = "gordon.mydomain.com"
21 "app.mydomain.com" = "myapp:latest"21 "app.mydomain.com" = "myapp:latest"
22 ```22 ```
23 23
24+> **Note:** `gordon_domain` is the canonical key. Migrate older `registry_domain` values before restarting.
25+
24 ## Full Configuration Reference26 ## Full Configuration Reference
25 27
26 For a complete list of all configuration options with their default values, see the [Configuration Reference](./reference.md).28 For a complete list of all configuration options with their default values, see the [Configuration Reference](./reference.md).
@@ -231,7 +233,7 @@ gordon reload
231 | `server.max_proxy_response_size` |233 | `server.max_proxy_response_size` |
232 | `server.max_concurrent_conns` |234 | `server.max_concurrent_conns` |
233 235
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.
235 237
236 ### Requires restart238 ### Requires restart
237 239
M docs/config/reference.md +3 -2
@@ -118,8 +118,9 @@ preserve = true # Keep volumes when containers are
118 # ROUTES118 # ROUTES
119 # =============================================================================119 # =============================================================================
120 [routes]120 [routes]
121-# "domain.com" = "image:tag"121+# "domain.com" = { image = "image:tag" }
122-# "http://insecure.domain.com" = "image:tag" # HTTP-only (no HTTPS redirect)122+# "insecure.domain.com" = { image = "image:tag", https = false }
123+# Legacy "http://domain.com" keys are read for compatibility and rewritten on save.
123 124
124 # =============================================================================125 # =============================================================================
125 # EXTERNAL ROUTES126 # EXTERNAL ROUTES
M docs/config/routes.md +53 -53
@@ -1,28 +1,30 @@
1 # Routes Configuration1 # Routes Configuration
2 2
3-Routes map domains to container images.3+Routes map hostnames to container images.
4 4
5 ## Configuration5 ## Configuration
6 6
7 ```toml7 ```toml
8 [routes]8 [routes]
9-"app.mydomain.com" = "myapp:latest"9+"app.mydomain.com" = { image = "myapp:latest" }
10-"api.mydomain.com" = "myapi:v2.1.0"10+"api.mydomain.com" = { image = "myapi:v2.1.0" }
11-"admin.mydomain.com" = "admin-panel:v1.0.0"11+"admin.mydomain.com" = { image = "admin-panel:v1.0.0" }
12 ```12 ```
13 13
14 ## Syntax14 ## Syntax
15 15
16 ```toml16 ```toml
17 [routes]17 [routes]
18-"<domain>" = "<image>:<tag>"18+"<domain>" = { image = "<image>:<tag>" }
19 ```19 ```
20 20
21 | Component | Description |21 | Component | Description |
22 |-----------|-------------|22 |-----------|-------------|
23-| `domain` | Fully qualified domain name |23+| `domain` | Public, fully qualified domain name |
24-| `image` | Container image name (as pushed to Gordon's registry) |24+| `image` | Full container image reference, including tag |
25-| `tag` | Image tag (version, `latest`, etc.) |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.
26 28
27 ## Route Types29 ## Route Types
28 30
@@ -32,20 +34,20 @@ Standard routes expect HTTPS traffic (terminated by Cloudflare or similar):
32 34
33 ```toml35 ```toml
34 [routes]36 [routes]
35-"app.mydomain.com" = "myapp:latest"37+"app.mydomain.com" = { image = "myapp:latest" }
36 ```38 ```
37 39
38-### HTTP-Only Routes40+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
39 43
40-For internal or development routes without HTTPS:44+For local testing, use a hostname you can resolve yourself:
41 45
42 ```toml46 ```toml
43 [routes]47 [routes]
44-"http://internal.local" = "internal-app:latest"48+"dev-app.example.com" = { image = "internal-app:latest", https = false }
45 ```49 ```
46 50
47-The `http://` prefix tells Gordon not to expect HTTPS.
48-
49 ## Version Strategies51 ## Version Strategies
50 52
51 ### Latest Tag53 ### Latest Tag
@@ -54,7 +56,7 @@ Always deploy the most recent push:
54 56
55 ```toml57 ```toml
56 [routes]58 [routes]
57-"app.mydomain.com" = "myapp:latest"59+"app.mydomain.com" = { image = "myapp:latest" }
58 ```60 ```
59 61
60 ### Pinned Version62 ### Pinned Version
@@ -63,14 +65,14 @@ Deploy a specific version:
63 65
64 ```toml66 ```toml
65 [routes]67 [routes]
66-"app.mydomain.com" = "myapp:v2.1.0"68+"app.mydomain.com" = { image = "myapp:v2.1.0" }
67 ```69 ```
68 70
69 Update the config to deploy a new version:71 Update the config to deploy a new version:
70 72
71 ```toml73 ```toml
72 [routes]74 [routes]
73-"app.mydomain.com" = "myapp:v2.2.0" # Changed75+"app.mydomain.com" = { image = "myapp:v2.2.0" } # Changed
74 ```76 ```
75 77
76 ### Semantic Versioning78 ### Semantic Versioning
@@ -79,9 +81,9 @@ Use different routes for different versions:
79 81
80 ```toml82 ```toml
81 [routes]83 [routes]
82-"app.mydomain.com" = "myapp:v2.1.0" # Production84+"app.mydomain.com" = { image = "myapp:v2.1.0" } # Production
83-"staging.mydomain.com" = "myapp:staging" # Staging85+"staging.mydomain.com" = { image = "myapp:staging" } # Staging
84-"canary.mydomain.com" = "myapp:canary" # Canary86+"canary.mydomain.com" = { image = "myapp:canary" } # Canary
85 ```87 ```
86 88
87 ## Multiple Routes89 ## Multiple Routes
@@ -90,19 +92,19 @@ Use different routes for different versions:
90 92
91 ```toml93 ```toml
92 [routes]94 [routes]
93-"app.mydomain.com" = "myapp:latest"95+"app.mydomain.com" = { image = "myapp:latest" }
94-"www.mydomain.com" = "myapp:latest"96+"www.mydomain.com" = { image = "myapp:latest" }
95-"mydomain.com" = "myapp:latest"97+"mydomain.com" = { image = "myapp:latest" }
96 ```98 ```
97 99
98 ### Multiple Services100 ### Multiple Services
99 101
100 ```toml102 ```toml
101 [routes]103 [routes]
102-"app.mydomain.com" = "frontend:latest"104+"app.mydomain.com" = { image = "frontend:latest" }
103-"api.mydomain.com" = "backend:v2.1.0"105+"api.mydomain.com" = { image = "backend:v2.1.0" }
104-"docs.mydomain.com" = "documentation:latest"106+"docs.mydomain.com" = { image = "documentation:latest" }
105-"status.mydomain.com" = "status-page:v1.0.0"107+"status.mydomain.com" = { image = "status-page:v1.0.0" }
106 ```108 ```
107 109
108 ## How Routing Works110 ## How Routing Works
@@ -132,25 +134,22 @@ docker push registry.mydomain.com/myapp:latest
132 4. Updates proxy to route traffic to new container134 4. Updates proxy to route traffic to new container
133 5. Stops old container135 5. Stops old container
134 136
135-## Hot Reload137+## Route Changes
136 138
137-Routes reload automatically when the config file changes:139+Route changes in the config file are hot-reloaded automatically:
138 140
139-```bash141+1. Edit `[routes]` in `gordon.toml`
140-# Edit config142+2. Save the file
141-vim ~/.config/gordon/gordon.toml143+3. Gordon reloads the updated routes and proxy config
142 144
143-# Add new route145+Legacy `http://...` route keys are still read here for backward compatibility and rewritten the next time Gordon saves the config.
144-[routes]
145-"newapp.mydomain.com" = "newapp:latest"
146 146
147-# Save - Gordon reloads automatically147+You can still manage routes with the API or CLI if you prefer live mutations:
148-```
149-
150-Or trigger manually:
151 148
152 ```bash149 ```bash
153-gordon reload150+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
154 ```153 ```
155 154
156 ## Examples155 ## Examples
@@ -159,23 +158,24 @@ gordon reload
159 158
160 ```toml159 ```toml
161 [routes]160 [routes]
162-"app.local" = "myapp:latest"161+"dev-app.example.com" = { image = "myapp:latest" }
163-"api.local" = "myapi:latest"162+"dev-api.example.com" = { image = "myapi:latest" }
164 ```163 ```
165 164
166 Add to `/etc/hosts`:165 Add to `/etc/hosts`:
167-```166+
168-127.0.0.1 app.local api.local registry.local167+```text
168+127.0.0.1 dev-app.example.com dev-api.example.com registry.example.com
169 ```169 ```
170 170
171 ### Production Setup171 ### Production Setup
172 172
173 ```toml173 ```toml
174 [routes]174 [routes]
175-"app.company.com" = "company-app:v2.1.0"175+"app.company.com" = { image = "company-app:v2.1.0" }
176-"api.company.com" = "company-api:v1.5.2"176+"api.company.com" = { image = "company-api:v1.5.2" }
177-"admin.company.com" = "admin-panel:v1.0.1"177+"admin.company.com" = { image = "admin-panel:v1.0.1" }
178-"docs.company.com" = "company-docs:latest"178+"docs.company.com" = { image = "company-docs:latest" }
179 ```179 ```
180 180
181 ### Multi-Tenant SaaS181 ### Multi-Tenant SaaS
@@ -183,15 +183,15 @@ Add to `/etc/hosts`:
183 ```toml183 ```toml
184 [routes]184 [routes]
185 # Platform services185 # Platform services
186-"app.saas-platform.com" = "saas-frontend:v2.1.0"186+"app.saas-platform.com" = { image = "saas-frontend:v2.1.0" }
187-"api.saas-platform.com" = "saas-api:v3.2.1"187+"api.saas-platform.com" = { image = "saas-api:v3.2.1" }
188 188
189 # Customer subdomains189 # Customer subdomains
190-"acme.saas-platform.com" = "saas-app:v2.1.0"190+"acme.saas-platform.com" = { image = "saas-app:v2.1.0" }
191-"beta.saas-platform.com" = "saas-app:v2.1.0"191+"beta.saas-platform.com" = { image = "saas-app:v2.1.0" }
192 192
193 # Customer custom domains193 # Customer custom domains
194-"portal.acme-corp.com" = "saas-app:v2.1.0"194+"portal.acme-corp.com" = { image = "saas-app:v2.1.0" }
195 ```195 ```
196 196
197 ## External Services197 ## External Services
M docs/config/server.md +6 -2
@@ -27,7 +27,7 @@ gordon_domain = "gordon.mydomain.com" # Gordon domain (required)
27 | `tls_key_file` | string | `""` | Path to PEM private key file. Must be set together with `tls_cert_file` |27 | `tls_key_file` | string | `""` | Path to PEM private key file. Must be set together with `tls_cert_file` |
28 | `force_https_redirect` | bool | `false` | Redirect all HTTP requests to the HTTPS port. For direct-access setups without a TLS-terminating proxy |28 | `force_https_redirect` | bool | `false` | Redirect all HTTP requests to the HTTPS port. For direct-access setups without a TLS-terminating proxy |
29 | `gordon_domain` | string | **required** | Domain for Gordon (registry + admin API) |29 | `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. |
31 | `data_dir` | string | `~/.gordon` | Directory for registry data, logs, and env files |31 | `data_dir` | string | `~/.gordon` | Directory for registry data, logs, and env files |
32 | `max_proxy_body_size` | string | `"512MB"` | Maximum request body size for proxied requests |32 | `max_proxy_body_size` | string | `"512MB"` | Maximum request body size for proxied requests |
33 | `max_blob_chunk_size` | string | `"95MB"` | Maximum request body size for a single registry blob upload chunk |33 | `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:
171 - CLI remote targeting (`gordon routes --remote https://gordon.mydomain.com`)171 - CLI remote targeting (`gordon routes --remote https://gordon.mydomain.com`)
172 - Authentication endpoints (`/auth/*`)172 - Authentication endpoints (`/auth/*`)
173 173
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+
174 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).178 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).
175 179
176 Security note:180 Security note:
177 - Direct access to `/admin/*` on `registry_port` is blocked for non-loopback clients.181 - Direct access to `/admin/*` on `registry_port` is blocked for non-loopback clients.
178 - Admin API traffic should go through the main proxy listener (`server.port`/`server.tls_port`).182 - Admin API traffic should go through the main proxy listener (`server.port`/`server.tls_port`).
179 183
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.
181 185
182 ## Data Directory186 ## Data Directory
183 187
M docs/upgrading.md +24 -0
@@ -2,6 +2,10 @@
2 2
3 This guide covers breaking changes and migration steps between major versions.3 This guide covers breaking changes and migration steps between major versions.
4 4
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+
5 ## v2.30.0 to v2.31.09 ## v2.30.0 to v2.31.0
6 10
7 ### Breaking: Default Port Changes11 ### Breaking: Default Port Changes
@@ -50,6 +54,26 @@ proxy_allowed_ips = [
50 54
51 Without this, all proxied HTTP traffic returns `403 Forbidden`. Set `tls_port = 0` to disable the internal CA and skip this requirement.55 Without this, all proxied HTTP traffic returns `403 Forbidden`. Set `tls_port = 0` to disable the internal CA and skip this requirement.
52 56
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+
53 ## v2.16.0 to v2.30.077 ## v2.16.0 to v2.30.0
54 78
55 ### Breaking: Password Authentication Removed79 ### Breaking: Password Authentication Removed
M internal/adapters/in/http/admin/bootstrap_test.go +140 -49
@@ -4,8 +4,8 @@ import (
4 "bytes"4 "bytes"
5 "encoding/json"5 "encoding/json"
6 "errors"6 "errors"
7+ "io"
7 "net/http"8 "net/http"
8- "net/http/httptest"
9 "testing"9 "testing"
10 10
11 "github.com/stretchr/testify/assert"11 "github.com/stretchr/testify/assert"
@@ -31,6 +31,7 @@ func TestBootstrap_FullWorkflow_HappyPath(t *testing.T) {
31 d.SecretSvc = secretSvc31 d.SecretSvc = secretSvc
32 d.RegistrySvc = registrySvc32 d.RegistrySvc = registrySvc
33 })33 })
34+ server := newScopedTestServer(t, handler, "admin:routes:write", "admin:secrets:write", "admin:config:write")
34 35
35 payload := dto.BootstrapRequest{36 payload := dto.BootstrapRequest{
36 Domain: "app.example.com",37 Domain: "app.example.com",
@@ -43,7 +44,7 @@ func TestBootstrap_FullWorkflow_HappyPath(t *testing.T) {
43 }44 }
44 45
45 configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Once()46 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()
47 configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(nil).Once()48 configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(nil).Once()
48 secretSvc.EXPECT().Set(mock.Anything, payload.Domain, map[string]string{"APP_ENV": "prod"}).Return(nil).Once()49 secretSvc.EXPECT().Set(mock.Anything, payload.Domain, map[string]string{"APP_ENV": "prod"}).Return(nil).Once()
49 secretSvc.EXPECT().SetAttachment(mock.Anything, payload.Domain, "postgres", map[string]string{"POSTGRES_PASSWORD": "secret"}).Return(nil).Once()50 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) {
51 body, err := json.Marshal(payload)52 body, err := json.Marshal(payload)
52 require.NoError(t, err)53 require.NoError(t, err)
53 54
54- req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body))55+ req, err := http.NewRequest(http.MethodPost, server.URL+"/admin/bootstrap", bytes.NewReader(body))
55- req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:secrets:write", "admin:config:write"))56+ require.NoError(t, err)
56- rec := httptest.NewRecorder()
57 57
58- handler.ServeHTTP(rec, req)58+ resp, err := server.Client().Do(req)
59+ require.NoError(t, err)
60+ defer resp.Body.Close()
59 61
60- assert.Equal(t, http.StatusOK, rec.Code)62+ assert.Equal(t, http.StatusOK, resp.StatusCode)
61 registrySvc.AssertNotCalled(t, "GetManifest", mock.Anything, mock.Anything, mock.Anything)63 registrySvc.AssertNotCalled(t, "GetManifest", mock.Anything, mock.Anything, mock.Anything)
62 64
63 var response dto.BootstrapResponse65 var response dto.BootstrapResponse
64- require.NoError(t, json.NewDecoder(rec.Body).Decode(&response))66+ require.NoError(t, json.NewDecoder(resp.Body).Decode(&response))
65 assert.Equal(t, payload.Domain, response.Domain)67 assert.Equal(t, payload.Domain, response.Domain)
66 assert.Equal(t, "reg.bnema.dev/myapp", response.Image)68 assert.Equal(t, "reg.bnema.dev/myapp", response.Image)
67 assert.Equal(t, "push reg.bnema.dev/myapp to trigger deployment", response.Next)69 assert.Equal(t, "push reg.bnema.dev/myapp to trigger deployment", response.Next)
@@ -73,6 +75,43 @@ func TestBootstrap_FullWorkflow_HappyPath(t *testing.T) {
73 }, response.Steps)75 }, response.Steps)
74 }76 }
75 77
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+
76 func TestBootstrap_Idempotent_Rerun(t *testing.T) {115 func TestBootstrap_Idempotent_Rerun(t *testing.T) {
77 configSvc := inmocks.NewMockConfigService(t)116 configSvc := inmocks.NewMockConfigService(t)
78 authSvc := inmocks.NewMockAuthService(t)117 authSvc := inmocks.NewMockAuthService(t)
@@ -85,6 +124,7 @@ func TestBootstrap_Idempotent_Rerun(t *testing.T) {
85 d.ContainerSvc = containerSvc124 d.ContainerSvc = containerSvc
86 d.SecretSvc = secretSvc125 d.SecretSvc = secretSvc
87 })126 })
127+ server := newScopedTestServer(t, handler, "admin:routes:write", "admin:secrets:write", "admin:config:write")
88 128
89 payload := dto.BootstrapRequest{129 payload := dto.BootstrapRequest{
90 Domain: "app.example.com",130 Domain: "app.example.com",
@@ -97,12 +137,12 @@ func TestBootstrap_Idempotent_Rerun(t *testing.T) {
97 }137 }
98 138
99 configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Times(2)139 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()
101 configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(nil).Once()141 configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(nil).Once()
102 secretSvc.EXPECT().Set(mock.Anything, payload.Domain, map[string]string{"APP_ENV": "prod"}).Return(nil).Once()142 secretSvc.EXPECT().Set(mock.Anything, payload.Domain, map[string]string{"APP_ENV": "prod"}).Return(nil).Once()
103 secretSvc.EXPECT().SetAttachment(mock.Anything, payload.Domain, "postgres", map[string]string{"POSTGRES_PASSWORD": "secret"}).Return(nil).Once()143 secretSvc.EXPECT().SetAttachment(mock.Anything, payload.Domain, "postgres", map[string]string{"POSTGRES_PASSWORD": "secret"}).Return(nil).Once()
104 144
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()
106 configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(domain.ErrAttachmentExists).Once()146 configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(domain.ErrAttachmentExists).Once()
107 secretSvc.EXPECT().Set(mock.Anything, payload.Domain, map[string]string{"APP_ENV": "prod"}).Return(nil).Once()147 secretSvc.EXPECT().Set(mock.Anything, payload.Domain, map[string]string{"APP_ENV": "prod"}).Return(nil).Once()
108 secretSvc.EXPECT().SetAttachment(mock.Anything, payload.Domain, "postgres", map[string]string{"POSTGRES_PASSWORD": "secret"}).Return(nil).Once()148 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) {
111 require.NoError(t, err)151 require.NoError(t, err)
112 152
113 for i := range 2 {153 for i := range 2 {
114- req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body))154+ req, err := http.NewRequest(http.MethodPost, server.URL+"/admin/bootstrap", bytes.NewReader(body))
115- req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:secrets:write", "admin:config:write"))155+ require.NoError(t, err)
116- rec := httptest.NewRecorder()
117 156
118- handler.ServeHTTP(rec, req)157+ resp, err := server.Client().Do(req)
158+ require.NoError(t, err)
159+ defer resp.Body.Close()
119 160
120- assert.Equal(t, http.StatusOK, rec.Code)161+ assert.Equal(t, http.StatusOK, resp.StatusCode)
121 162
122 var response dto.BootstrapResponse163 var response dto.BootstrapResponse
123- require.NoError(t, json.NewDecoder(rec.Body).Decode(&response))164+ require.NoError(t, json.NewDecoder(resp.Body).Decode(&response))
124 assert.Equal(t, payload.Domain, response.Domain)165 assert.Equal(t, payload.Domain, response.Domain)
125 assert.Equal(t, "reg.bnema.dev/myapp", response.Image)166 assert.Equal(t, "reg.bnema.dev/myapp", response.Image)
126 require.Len(t, response.Steps, 4)167 require.Len(t, response.Steps, 4)
@@ -154,18 +195,22 @@ func TestBootstrap_MissingDomain(t *testing.T) {
154 d.ContainerSvc = containerSvc195 d.ContainerSvc = containerSvc
155 d.SecretSvc = secretSvc196 d.SecretSvc = secretSvc
156 })197 })
198+ server := newScopedTestServer(t, handler, "admin:routes:write", "admin:config:write")
157 199
158 body, err := json.Marshal(dto.BootstrapRequest{Image: "myapp:latest"})200 body, err := json.Marshal(dto.BootstrapRequest{Image: "myapp:latest"})
159 require.NoError(t, err)201 require.NoError(t, err)
160 202
161- req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body))203+ req, err := http.NewRequest(http.MethodPost, server.URL+"/admin/bootstrap", bytes.NewReader(body))
162- req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:config:write"))204+ require.NoError(t, err)
163- rec := httptest.NewRecorder()
164 205
165- handler.ServeHTTP(rec, req)206+ resp, err := server.Client().Do(req)
207+ require.NoError(t, err)
208+ defer resp.Body.Close()
166 209
167- assert.Equal(t, http.StatusBadRequest, rec.Code)210+ assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
168- assert.JSONEq(t, `{"error":"domain is required"}`+"\n", rec.Body.String())211+ bodyBytes, err := io.ReadAll(resp.Body)
212+ require.NoError(t, err)
213+ assert.JSONEq(t, `{"error":"domain is required"}`+"\n", string(bodyBytes))
169 }214 }
170 215
171 func TestBootstrap_MissingImage(t *testing.T) {216 func TestBootstrap_MissingImage(t *testing.T) {
@@ -180,18 +225,22 @@ func TestBootstrap_MissingImage(t *testing.T) {
180 d.ContainerSvc = containerSvc225 d.ContainerSvc = containerSvc
181 d.SecretSvc = secretSvc226 d.SecretSvc = secretSvc
182 })227 })
228+ server := newScopedTestServer(t, handler, "admin:routes:write", "admin:config:write")
183 229
184 body, err := json.Marshal(dto.BootstrapRequest{Domain: "app.example.com"})230 body, err := json.Marshal(dto.BootstrapRequest{Domain: "app.example.com"})
185 require.NoError(t, err)231 require.NoError(t, err)
186 232
187- req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body))233+ req, err := http.NewRequest(http.MethodPost, server.URL+"/admin/bootstrap", bytes.NewReader(body))
188- req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:config:write"))234+ require.NoError(t, err)
189- rec := httptest.NewRecorder()
190 235
191- handler.ServeHTTP(rec, req)236+ resp, err := server.Client().Do(req)
237+ require.NoError(t, err)
238+ defer resp.Body.Close()
192 239
193- assert.Equal(t, http.StatusBadRequest, rec.Code)240+ assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
194- assert.JSONEq(t, `{"error":"image is required"}`+"\n", rec.Body.String())241+ bodyBytes, err := io.ReadAll(resp.Body)
242+ require.NoError(t, err)
243+ assert.JSONEq(t, `{"error":"image is required"}`+"\n", string(bodyBytes))
195 }244 }
196 245
197 func TestBootstrap_NormalizesImageBeforeStoring(t *testing.T) {246 func TestBootstrap_NormalizesImageBeforeStoring(t *testing.T) {
@@ -206,24 +255,26 @@ func TestBootstrap_NormalizesImageBeforeStoring(t *testing.T) {
206 d.ContainerSvc = containerSvc255 d.ContainerSvc = containerSvc
207 d.SecretSvc = secretSvc256 d.SecretSvc = secretSvc
208 })257 })
258+ server := newScopedTestServer(t, handler, "admin:routes:write", "admin:config:write")
209 259
210 payload := dto.BootstrapRequest{Domain: "app.example.com", Image: "pitlane:v1"}260 payload := dto.BootstrapRequest{Domain: "app.example.com", Image: "pitlane:v1"}
211 configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Once()261 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()
213 263
214 body, err := json.Marshal(payload)264 body, err := json.Marshal(payload)
215 require.NoError(t, err)265 require.NoError(t, err)
216 266
217- req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body))267+ req, err := http.NewRequest(http.MethodPost, server.URL+"/admin/bootstrap", bytes.NewReader(body))
218- req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:config:write"))268+ require.NoError(t, err)
219- rec := httptest.NewRecorder()
220 269
221- handler.ServeHTTP(rec, req)270+ resp, err := server.Client().Do(req)
271+ require.NoError(t, err)
272+ defer resp.Body.Close()
222 273
223- assert.Equal(t, http.StatusOK, rec.Code)274+ assert.Equal(t, http.StatusOK, resp.StatusCode)
224 275
225 var response dto.BootstrapResponse276 var response dto.BootstrapResponse
226- require.NoError(t, json.NewDecoder(rec.Body).Decode(&response))277+ require.NoError(t, json.NewDecoder(resp.Body).Decode(&response))
227 assert.Equal(t, payload.Domain, response.Domain)278 assert.Equal(t, payload.Domain, response.Domain)
228 assert.Equal(t, "reg.bnema.dev/pitlane", response.Image)279 assert.Equal(t, "reg.bnema.dev/pitlane", response.Image)
229 assert.Equal(t, "push reg.bnema.dev/pitlane to trigger deployment", response.Next)280 assert.Equal(t, "push reg.bnema.dev/pitlane to trigger deployment", response.Next)
@@ -257,26 +308,64 @@ func TestBootstrap_AddRouteValidationErrors(t *testing.T) {
257 d.ContainerSvc = containerSvc308 d.ContainerSvc = containerSvc
258 d.SecretSvc = secretSvc309 d.SecretSvc = secretSvc
259 })310 })
311+ server := newScopedTestServer(t, handler, "admin:routes:write", "admin:config:write")
260 312
261 payload := dto.BootstrapRequest{Domain: "app.example.com", Image: "myapp:latest"}313 payload := dto.BootstrapRequest{Domain: "app.example.com", Image: "myapp:latest"}
262 configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Once()314 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()
264 316
265 body, err := json.Marshal(payload)317 body, err := json.Marshal(payload)
266 require.NoError(t, err)318 require.NoError(t, err)
267 319
268- req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body))320+ req, err := http.NewRequest(http.MethodPost, server.URL+"/admin/bootstrap", bytes.NewReader(body))
269- req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:config:write"))321+ require.NoError(t, err)
270- rec := httptest.NewRecorder()
271 322
272- handler.ServeHTTP(rec, req)323+ resp, err := server.Client().Do(req)
324+ require.NoError(t, err)
325+ defer resp.Body.Close()
273 326
274- assert.Equal(t, http.StatusBadRequest, rec.Code)327+ assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
275- assert.JSONEq(t, `{"error":"`+tt.mockErr.Error()+`"}`+"\n", rec.Body.String())328+ bodyBytes, err := io.ReadAll(resp.Body)
329+ require.NoError(t, err)
330+ assert.JSONEq(t, `{"error":"`+tt.mockErr.Error()+`"}`+"\n", string(bodyBytes))
276 })331 })
277 }332 }
278 }333 }
279 334
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+
280 func TestBootstrap_PartialFailure_AttachmentError(t *testing.T) {369 func TestBootstrap_PartialFailure_AttachmentError(t *testing.T) {
281 configSvc := inmocks.NewMockConfigService(t)370 configSvc := inmocks.NewMockConfigService(t)
282 authSvc := inmocks.NewMockAuthService(t)371 authSvc := inmocks.NewMockAuthService(t)
@@ -289,6 +378,7 @@ func TestBootstrap_PartialFailure_AttachmentError(t *testing.T) {
289 d.ContainerSvc = containerSvc378 d.ContainerSvc = containerSvc
290 d.SecretSvc = secretSvc379 d.SecretSvc = secretSvc
291 })380 })
381+ server := newScopedTestServer(t, handler, "admin:routes:write", "admin:config:write")
292 382
293 payload := dto.BootstrapRequest{383 payload := dto.BootstrapRequest{
294 Domain: "app.example.com",384 Domain: "app.example.com",
@@ -297,22 +387,23 @@ func TestBootstrap_PartialFailure_AttachmentError(t *testing.T) {
297 }387 }
298 388
299 configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Once()389 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()
301 configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(errors.New("attachment failed")).Once()391 configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(errors.New("attachment failed")).Once()
302 392
303 body, err := json.Marshal(payload)393 body, err := json.Marshal(payload)
304 require.NoError(t, err)394 require.NoError(t, err)
305 395
306- req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body))396+ req, err := http.NewRequest(http.MethodPost, server.URL+"/admin/bootstrap", bytes.NewReader(body))
307- req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:config:write"))397+ require.NoError(t, err)
308- rec := httptest.NewRecorder()
309 398
310- handler.ServeHTTP(rec, req)399+ resp, err := server.Client().Do(req)
400+ require.NoError(t, err)
401+ defer resp.Body.Close()
311 402
312- assert.Equal(t, http.StatusInternalServerError, rec.Code)403+ assert.Equal(t, http.StatusInternalServerError, resp.StatusCode)
313 404
314 var response dto.BootstrapResponse405 var response dto.BootstrapResponse
315- require.NoError(t, json.NewDecoder(rec.Body).Decode(&response))406+ require.NoError(t, json.NewDecoder(resp.Body).Decode(&response))
316 assert.Equal(t, []dto.BootstrapStep{407 assert.Equal(t, []dto.BootstrapStep{
317 {Name: "route", Status: "configured"},408 {Name: "route", Status: "configured"},
318 {Name: "attachment:postgres:16", Status: "failed"},409 {Name: "attachment:postgres:16", Status: "failed"},
M internal/adapters/in/http/admin/handler.go +98 -61
@@ -17,7 +17,6 @@ import (
17 17
18 "github.com/bnema/gordon/internal/adapters/dto"18 "github.com/bnema/gordon/internal/adapters/dto"
19 "github.com/bnema/gordon/internal/boundaries/in"19 "github.com/bnema/gordon/internal/boundaries/in"
20- "github.com/bnema/gordon/internal/boundaries/out"
21 "github.com/bnema/gordon/internal/domain"20 "github.com/bnema/gordon/internal/domain"
22 "github.com/bnema/gordon/internal/usecase/config"21 "github.com/bnema/gordon/internal/usecase/config"
23 "github.com/bnema/gordon/internal/usecase/registry"22 "github.com/bnema/gordon/internal/usecase/registry"
@@ -35,21 +34,25 @@ type registryDeployService interface {
35 in.DeployCoordinator34 in.DeployCoordinator
36 }35 }
37 36
37+type reloadTrigger interface {
38+ Trigger(ctx context.Context) error
39+}
40+
38 // Handler implements the HTTP handler for the admin API.41 // Handler implements the HTTP handler for the admin API.
39 type Handler struct {42 type Handler struct {
40- configSvc in.ConfigService43+ configSvc in.ConfigService
41- authSvc in.AuthService44+ authSvc in.AuthService
42- containerSvc in.ContainerService45+ containerSvc in.ContainerService
43- backupSvc in.BackupService46+ backupSvc in.BackupService
44- imageSvc in.ImageService47+ imageSvc in.ImageService
45- healthSvc in.HealthService48+ healthSvc in.HealthService
46- secretSvc in.SecretService49+ secretSvc in.SecretService
47- logSvc in.LogService50+ logSvc in.LogService
48- volumeSvc in.VolumeService51+ volumeSvc in.VolumeService
49- registrySvc registryDeployService52+ registrySvc registryDeployService
50- previewSvc previewService53+ previewSvc previewService
51- eventBus out.EventPublisher54+ reloadTrigger reloadTrigger
52- log zerowrap.Logger55+ log zerowrap.Logger
53 }56 }
54 57
55 // Type aliases for API responses using shared DTO types.58 // Type aliases for API responses using shared DTO types.
@@ -133,37 +136,37 @@ func toDatabaseInfoResponse(db domain.DBInfo) dto.DatabaseInfo {
133 136
134 // HandlerDeps contains all dependencies for the admin HTTP handler.137 // HandlerDeps contains all dependencies for the admin HTTP handler.
135 type HandlerDeps struct {138 type HandlerDeps struct {
136- ConfigSvc in.ConfigService139+ ConfigSvc in.ConfigService
137- AuthSvc in.AuthService140+ AuthSvc in.AuthService
138- ContainerSvc in.ContainerService141+ ContainerSvc in.ContainerService
139- HealthSvc in.HealthService142+ HealthSvc in.HealthService
140- SecretSvc in.SecretService143+ SecretSvc in.SecretService
141- LogSvc in.LogService144+ LogSvc in.LogService
142- RegistrySvc registryDeployService145+ RegistrySvc registryDeployService
143- EventBus out.EventPublisher146+ Log zerowrap.Logger
144- Log zerowrap.Logger147+ BackupSvc in.BackupService
145- BackupSvc in.BackupService148+ PreviewSvc previewService
146- PreviewSvc previewService149+ ImageSvc in.ImageService
147- ImageSvc in.ImageService150+ VolumeSvc in.VolumeService
148- VolumeSvc in.VolumeService151+ ReloadTrigger reloadTrigger
149 }152 }
150 153
151 // NewHandler creates a new admin HTTP handler.154 // NewHandler creates a new admin HTTP handler.
152 func NewHandler(deps HandlerDeps) *Handler {155 func NewHandler(deps HandlerDeps) *Handler {
153 return &Handler{156 return &Handler{
154- configSvc: deps.ConfigSvc,157+ configSvc: deps.ConfigSvc,
155- authSvc: deps.AuthSvc,158+ authSvc: deps.AuthSvc,
156- containerSvc: deps.ContainerSvc,159+ containerSvc: deps.ContainerSvc,
157- backupSvc: deps.BackupSvc,160+ backupSvc: deps.BackupSvc,
158- imageSvc: deps.ImageSvc,161+ imageSvc: deps.ImageSvc,
159- healthSvc: deps.HealthSvc,162+ healthSvc: deps.HealthSvc,
160- secretSvc: deps.SecretSvc,163+ secretSvc: deps.SecretSvc,
161- logSvc: deps.LogSvc,164+ logSvc: deps.LogSvc,
162- volumeSvc: deps.VolumeSvc,165+ volumeSvc: deps.VolumeSvc,
163- registrySvc: deps.RegistrySvc,166+ registrySvc: deps.RegistrySvc,
164- previewSvc: deps.PreviewSvc,167+ previewSvc: deps.PreviewSvc,
165- eventBus: deps.EventBus,168+ reloadTrigger: deps.ReloadTrigger,
166- log: deps.Log,169+ log: deps.Log,
167 }170 }
168 }171 }
169 172
@@ -378,11 +381,11 @@ func (h *Handler) handleBootstrap(w http.ResponseWriter, r *http.Request) {
378 resp.Steps = append(resp.Steps, dto.BootstrapStep{Name: name, Status: status})381 resp.Steps = append(resp.Steps, dto.BootstrapStep{Name: name, Status: status})
379 }382 }
380 383
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})
382 switch {385 switch {
383 case err == nil:386 case err == nil:
384 addStep("route", "configured")387 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):
386 addStep("route", "failed")389 addStep("route", "failed")
387 h.sendError(w, http.StatusBadRequest, err.Error())390 h.sendError(w, http.StatusBadRequest, err.Error())
388 return391 return
@@ -570,17 +573,26 @@ func (h *Handler) handleRoutesPost(w http.ResponseWriter, r *http.Request) {
570 // Limit request body size573 // Limit request body size
571 r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)574 r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
572 575
573- var route domain.Route576+ var req struct {
574- if err := json.NewDecoder(r.Body).Decode(&route); err != nil {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 {
575 log.Warn().Err(err).Msg("invalid route JSON")582 log.Warn().Err(err).Msg("invalid route JSON")
576 h.sendError(w, http.StatusBadRequest, "invalid JSON")583 h.sendError(w, http.StatusBadRequest, "invalid JSON")
577 return584 return
578 }585 }
579 586
587+ route := domain.Route{Domain: req.Domain, Image: req.Image, HTTPS: true}
588+ if req.HTTPS != nil {
589+ route.HTTPS = *req.HTTPS
590+ }
591+
580 if err := h.configSvc.AddRoute(ctx, route); err != nil {592 if err := h.configSvc.AddRoute(ctx, route); err != nil {
581 log.Error().Err(err).Str("domain", route.Domain).Msg("failed to add route")593 log.Error().Err(err).Str("domain", route.Domain).Msg("failed to add route")
582 switch {594 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):
584 h.sendError(w, http.StatusBadRequest, err.Error())596 h.sendError(w, http.StatusBadRequest, err.Error())
585 default:597 default:
586 h.sendError(w, http.StatusInternalServerError, "failed to add route")598 h.sendError(w, http.StatusInternalServerError, "failed to add route")
@@ -610,21 +622,44 @@ func (h *Handler) handleRoutesPut(w http.ResponseWriter, r *http.Request, routeD
610 // Limit request body size622 // Limit request body size
611 r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)623 r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
612 624
613- var route domain.Route625+ var req struct {
614- if err := json.NewDecoder(r.Body).Decode(&route); err != nil {626+ Image string `json:"image"`
627+ HTTPS *bool `json:"https"`
628+ }
629+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
615 log.Warn().Err(err).Msg("invalid route JSON")630 log.Warn().Err(err).Msg("invalid route JSON")
616 h.sendError(w, http.StatusBadRequest, "invalid JSON")631 h.sendError(w, http.StatusBadRequest, "invalid JSON")
617 return632 return
618 }633 }
619 634
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
620 route.Domain = routeDomain651 route.Domain = routeDomain
652+ route.Image = req.Image
653+ if req.HTTPS != nil {
654+ route.HTTPS = *req.HTTPS
655+ }
621 656
622 if err := h.configSvc.UpdateRoute(ctx, route); err != nil {657 if err := h.configSvc.UpdateRoute(ctx, route); err != nil {
623 log.Error().Err(err).Str("domain", routeDomain).Msg("failed to update route")658 log.Error().Err(err).Str("domain", routeDomain).Msg("failed to update route")
624 switch {659 switch {
625 case errors.Is(err, domain.ErrRouteNotFound):660 case errors.Is(err, domain.ErrRouteNotFound):
626 h.sendError(w, http.StatusNotFound, "route not found")661 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):
628 h.sendError(w, http.StatusBadRequest, err.Error())663 h.sendError(w, http.StatusBadRequest, err.Error())
629 default:664 default:
630 h.sendError(w, http.StatusInternalServerError, "failed to update route")665 h.sendError(w, http.StatusInternalServerError, "failed to update route")
@@ -653,9 +688,12 @@ func (h *Handler) handleRoutesDelete(w http.ResponseWriter, r *http.Request, rou
653 688
654 if err := h.configSvc.RemoveRoute(ctx, routeDomain); err != nil {689 if err := h.configSvc.RemoveRoute(ctx, routeDomain); err != nil {
655 log.Error().Err(err).Str("domain", routeDomain).Msg("failed to remove route")690 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):
657 h.sendError(w, http.StatusNotFound, "route not found")693 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:
659 h.sendError(w, http.StatusInternalServerError, "failed to remove route")697 h.sendError(w, http.StatusInternalServerError, "failed to remove route")
660 }698 }
661 return699 return
@@ -1170,20 +1208,19 @@ func (h *Handler) handleReload(w http.ResponseWriter, r *http.Request) {
1170 return1208 return
1171 }1209 }
1172 1210
1173- // Reload configuration from file into memory1211+ if h.reloadTrigger == nil {
1174- // Using Reload() instead of Load() to ensure we re-read the file from disk1212+ log.Error().Msg("reload trigger unavailable")
1175- if err := h.configSvc.Reload(ctx); err != nil {1213+ h.sendError(w, http.StatusInternalServerError, "reload trigger unavailable")
1176- log.Error().Err(err).Msg("failed to reload config")
1177- h.sendError(w, http.StatusInternalServerError, "failed to reload config")
1178 return1214 return
1179 }1215 }
1180 1216
1181- // Publish manual reload event to sync containers1217+ reloadCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
1182- // ManualReloadHandler starts missing containers without restarting running ones1218+ defer cancel()
1183- if h.eventBus != nil {1219+
1184- if err := h.eventBus.Publish(domain.EventManualReload, nil); err != nil {1220+ if err := h.reloadTrigger.Trigger(reloadCtx); err != nil {
1185- log.Warn().Err(err).Msg("failed to publish manual reload event")1221+ log.Error().Err(err).Msg("failed to reload config")
1186- }1222+ h.sendError(w, http.StatusInternalServerError, "failed to reload config")
1223+ return
1187 }1224 }
1188 1225
1189 log.Info().Msg("config reloaded via admin API")1226 log.Info().Msg("config reloaded via admin API")
M internal/adapters/in/http/admin/handler_test.go +215 -20
@@ -17,6 +17,7 @@ import (
17 "github.com/bnema/zerowrap"17 "github.com/bnema/zerowrap"
18 "github.com/stretchr/testify/assert"18 "github.com/stretchr/testify/assert"
19 "github.com/stretchr/testify/mock"19 "github.com/stretchr/testify/mock"
20+ "github.com/stretchr/testify/require"
20 21
21 "github.com/bnema/gordon/internal/adapters/dto"22 "github.com/bnema/gordon/internal/adapters/dto"
22 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"23 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
@@ -28,6 +29,24 @@ type stubImageService struct {
28 pruneFunc func(context.Context, domain.ImagePruneOptions) (domain.ImagePruneReport, error)29 pruneFunc func(context.Context, domain.ImagePruneOptions) (domain.ImagePruneReport, error)
29 }30 }
30 31
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+
31 func (s *stubImageService) ListImages(ctx context.Context) ([]domain.ImageInfo, error) {50 func (s *stubImageService) ListImages(ctx context.Context) ([]domain.ImageInfo, error) {
32 if s.listImagesFunc == nil {51 if s.listImagesFunc == nil {
33 return nil, nil52 return nil, nil
@@ -51,15 +70,27 @@ func ctxWithScopes(scopes ...string) context.Context {
51 return context.WithValue(ctx, domain.ContextKeyScopes, scopes)70 return context.WithValue(ctx, domain.ContextKeyScopes, scopes)
52 }71 }
53 72
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+
54 func newTestHandler(t *testing.T, opts ...func(*HandlerDeps)) *Handler {84 func newTestHandler(t *testing.T, opts ...func(*HandlerDeps)) *Handler {
55 t.Helper()85 t.Helper()
56 deps := HandlerDeps{86 deps := HandlerDeps{
57- ConfigSvc: inmocks.NewMockConfigService(t),87+ ConfigSvc: inmocks.NewMockConfigService(t),
58- AuthSvc: inmocks.NewMockAuthService(t),88+ AuthSvc: inmocks.NewMockAuthService(t),
59- ContainerSvc: inmocks.NewMockContainerService(t),89+ ContainerSvc: inmocks.NewMockContainerService(t),
60- HealthSvc: inmocks.NewMockHealthService(t),90+ HealthSvc: inmocks.NewMockHealthService(t),
61- SecretSvc: inmocks.NewMockSecretService(t),91+ SecretSvc: inmocks.NewMockSecretService(t),
62- Log: testLogger(),92+ Log: testLogger(),
93+ ReloadTrigger: noopReloadTrigger{},
63 }94 }
64 for _, opt := range opts {95 for _, opt := range opts {
65 opt(&deps)96 opt(&deps)
@@ -201,6 +232,35 @@ func TestHandler_RoutesPost_RequiresWriteScope(t *testing.T) {
201 }232 }
202 }233 }
203 234
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+
204 func TestHandler_RoutesPut_RequiresWriteScope(t *testing.T) {264 func TestHandler_RoutesPut_RequiresWriteScope(t *testing.T) {
205 configSvc := inmocks.NewMockConfigService(t)265 configSvc := inmocks.NewMockConfigService(t)
206 authSvc := inmocks.NewMockAuthService(t)266 authSvc := inmocks.NewMockAuthService(t)
@@ -236,6 +296,7 @@ func TestHandler_RoutesPut_RequiresWriteScope(t *testing.T) {
236 for _, tt := range tests {296 for _, tt := range tests {
237 t.Run(tt.name, func(t *testing.T) {297 t.Run(tt.name, func(t *testing.T) {
238 if tt.wantStatus == http.StatusOK {298 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()
239 configSvc.EXPECT().UpdateRoute(mock.Anything, mock.AnythingOfType("domain.Route")).Return(nil).Maybe()300 configSvc.EXPECT().UpdateRoute(mock.Anything, mock.AnythingOfType("domain.Route")).Return(nil).Maybe()
240 }301 }
241 302
@@ -250,13 +311,43 @@ func TestHandler_RoutesPut_RequiresWriteScope(t *testing.T) {
250 }311 }
251 }312 }
252 313
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+
253 func TestHandler_RoutesPost_AllowsRouteCreationWithoutRegistryManifest(t *testing.T) {344 func TestHandler_RoutesPost_AllowsRouteCreationWithoutRegistryManifest(t *testing.T) {
254 configSvc := inmocks.NewMockConfigService(t)345 configSvc := inmocks.NewMockConfigService(t)
255 authSvc := inmocks.NewMockAuthService(t)346 authSvc := inmocks.NewMockAuthService(t)
256 containerSvc := inmocks.NewMockContainerService(t)347 containerSvc := inmocks.NewMockContainerService(t)
257 secretSvc := inmocks.NewMockSecretService(t)348 secretSvc := inmocks.NewMockSecretService(t)
258 registrySvc := inmocks.NewMockRegistryService(t)349 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}
260 351
261 handler := newTestHandler(t, func(d *HandlerDeps) {352 handler := newTestHandler(t, func(d *HandlerDeps) {
262 d.ConfigSvc = configSvc353 d.ConfigSvc = configSvc
@@ -268,24 +359,57 @@ func TestHandler_RoutesPost_AllowsRouteCreationWithoutRegistryManifest(t *testin
268 359
269 configSvc.EXPECT().AddRoute(mock.Anything, route).Return(nil).Once()360 configSvc.EXPECT().AddRoute(mock.Anything, route).Return(nil).Once()
270 361
271- req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(`{"domain":"app.example.com","image":"myapp:latest"}`))362+ server := newScopedTestServer(t, handler, "admin:routes:write")
272- req = req.WithContext(ctxWithScopes("admin:routes:write"))363+ req, err := http.NewRequest(http.MethodPost, server.URL+"/admin/routes", bytes.NewBufferString(`{"domain":"app.example.com","image":"myapp:latest"}`))
273- rec := httptest.NewRecorder()364+ require.NoError(t, err)
274 365
275- handler.ServeHTTP(rec, req)366+ resp, err := server.Client().Do(req)
367+ require.NoError(t, err)
368+ defer resp.Body.Close()
276 369
277- assert.Equal(t, http.StatusCreated, rec.Code)370+ body, err := io.ReadAll(resp.Body)
278- assert.JSONEq(t, `{"domain":"app.example.com","image":"myapp:latest","https":false}`, rec.Body.String())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))
279 registrySvc.AssertNotCalled(t, "GetManifest", mock.Anything, mock.Anything, mock.Anything)374 registrySvc.AssertNotCalled(t, "GetManifest", mock.Anything, mock.Anything, mock.Anything)
280 }375 }
281 376
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) {
283 configSvc := inmocks.NewMockConfigService(t)407 configSvc := inmocks.NewMockConfigService(t)
284 authSvc := inmocks.NewMockAuthService(t)408 authSvc := inmocks.NewMockAuthService(t)
285 containerSvc := inmocks.NewMockContainerService(t)409 containerSvc := inmocks.NewMockContainerService(t)
286 secretSvc := inmocks.NewMockSecretService(t)410 secretSvc := inmocks.NewMockSecretService(t)
287 registrySvc := inmocks.NewMockRegistryService(t)411 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}
289 413
290 handler := newTestHandler(t, func(d *HandlerDeps) {414 handler := newTestHandler(t, func(d *HandlerDeps) {
291 d.ConfigSvc = configSvc415 d.ConfigSvc = configSvc
@@ -295,6 +419,7 @@ func TestHandler_RoutesPut_AllowsRouteUpdateWithoutRegistryManifest(t *testing.T
295 d.RegistrySvc = registrySvc419 d.RegistrySvc = registrySvc
296 })420 })
297 421
422+ configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(&domain.Route{Domain: "app.example.com", Image: "myapp:latest", HTTPS: false}, nil).Once()
298 configSvc.EXPECT().UpdateRoute(mock.Anything, route).Return(nil).Once()423 configSvc.EXPECT().UpdateRoute(mock.Anything, route).Return(nil).Once()
299 424
300 req := httptest.NewRequest("PUT", "/admin/routes/app.example.com", bytes.NewBufferString(`{"image":"myapp:v2"}`))425 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
308 registrySvc.AssertNotCalled(t, "GetManifest", mock.Anything, mock.Anything, mock.Anything)433 registrySvc.AssertNotCalled(t, "GetManifest", mock.Anything, mock.Anything, mock.Anything)
309 }434 }
310 435
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+
311 func TestHandler_AttachmentsByImageGet_OneTarget(t *testing.T) {462 func TestHandler_AttachmentsByImageGet_OneTarget(t *testing.T) {
312 configSvc := inmocks.NewMockConfigService(t)463 configSvc := inmocks.NewMockConfigService(t)
313 authSvc := inmocks.NewMockAuthService(t)464 authSvc := inmocks.NewMockAuthService(t)
@@ -769,7 +920,7 @@ func TestHandler_Bootstrap_HappyPath(t *testing.T) {
769 })920 })
770 921
771 configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Once()922 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()
773 configSvc.EXPECT().AddAttachment(mock.Anything, "app.example.com", "postgres:16").Return(nil).Once()924 configSvc.EXPECT().AddAttachment(mock.Anything, "app.example.com", "postgres:16").Return(nil).Once()
774 secretSvc.EXPECT().Set(mock.Anything, "app.example.com", map[string]string{"APP_ENV": "prod"}).Return(nil).Once()925 secretSvc.EXPECT().Set(mock.Anything, "app.example.com", map[string]string{"APP_ENV": "prod"}).Return(nil).Once()
775 secretSvc.EXPECT().SetAttachment(mock.Anything, "app.example.com", "postgres", map[string]string{"POSTGRES_PASSWORD": "secret"}).Return(nil).Once()926 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) {
838 configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Once()989 configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Once()
839 }990 }
840 if tt.wantStatus == http.StatusOK {991 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()
842 }993 }
843 994
844 req := httptest.NewRequest("POST", "/admin/bootstrap", bytes.NewBufferString(tt.body))995 req := httptest.NewRequest("POST", "/admin/bootstrap", bytes.NewBufferString(tt.body))
@@ -980,12 +1131,14 @@ func TestHandler_Reload_RequiresWriteScope(t *testing.T) {
980 authSvc := inmocks.NewMockAuthService(t)1131 authSvc := inmocks.NewMockAuthService(t)
981 containerSvc := inmocks.NewMockContainerService(t)1132 containerSvc := inmocks.NewMockContainerService(t)
982 secretSvc := inmocks.NewMockSecretService(t)1133 secretSvc := inmocks.NewMockSecretService(t)
1134+ reloadTrigger := &reloadTriggerRecorder{}
983 1135
984 handler := newTestHandler(t, func(d *HandlerDeps) {1136 handler := newTestHandler(t, func(d *HandlerDeps) {
985 d.ConfigSvc = configSvc1137 d.ConfigSvc = configSvc
986 d.AuthSvc = authSvc1138 d.AuthSvc = authSvc
987 d.ContainerSvc = containerSvc1139 d.ContainerSvc = containerSvc
988 d.SecretSvc = secretSvc1140 d.SecretSvc = secretSvc
1141+ d.ReloadTrigger = reloadTrigger
989 })1142 })
990 1143
991 tests := []struct {1144 tests := []struct {
@@ -1017,9 +1170,7 @@ func TestHandler_Reload_RequiresWriteScope(t *testing.T) {
1017 1170
1018 for _, tt := range tests {1171 for _, tt := range tests {
1019 t.Run(tt.name, func(t *testing.T) {1172 t.Run(tt.name, func(t *testing.T) {
1020- if tt.wantStatus == http.StatusOK {1173+ reloadTrigger.calls = 0
1021- configSvc.EXPECT().Reload(mock.Anything).Return(nil).Maybe()
1022- }
1023 1174
1024 req := httptest.NewRequest("POST", "/admin/reload", nil)1175 req := httptest.NewRequest("POST", "/admin/reload", nil)
1025 req = req.WithContext(ctxWithScopes(tt.scopes...))1176 req = req.WithContext(ctxWithScopes(tt.scopes...))
@@ -1028,10 +1179,54 @@ func TestHandler_Reload_RequiresWriteScope(t *testing.T) {
1028 handler.ServeHTTP(rec, req)1179 handler.ServeHTTP(rec, req)
1029 1180
1030 assert.Equal(t, tt.wantStatus, rec.Code)1181 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+ }
1031 })1187 })
1032 }1188 }
1033 }1189 }
1034 1190
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+
1035 // Functional tests1230 // Functional tests
1036 1231
1037 func TestHandler_RoutesGet_ReturnsRoutes(t *testing.T) {1232 func TestHandler_RoutesGet_ReturnsRoutes(t *testing.T) {
M internal/app/run.go +177 -97
@@ -16,12 +16,12 @@ import (
16 "path/filepath"16 "path/filepath"
17 "strconv"17 "strconv"
18 "strings"18 "strings"
19+ "sync"
19 "syscall"20 "syscall"
20 "time"21 "time"
21 22
22 "github.com/bnema/zerowrap"23 "github.com/bnema/zerowrap"
23 zerowrapotel "github.com/bnema/zerowrap/otel"24 zerowrapotel "github.com/bnema/zerowrap/otel"
24- "github.com/fsnotify/fsnotify"
25 "github.com/spf13/viper"25 "github.com/spf13/viper"
26 26
27 // Adapters - Output27 // Adapters - Output
@@ -188,35 +188,36 @@ type Config struct {
188 188
189 // services holds all the services used by the application.189 // services holds all the services used by the application.
190 type services struct {190 type services struct {
191- runtime *docker.Runtime191+ runtime *docker.Runtime
192- eventBus *eventbus.InMemory192+ eventBus *eventbus.InMemory
193- blobStorage *filesystem.BlobStorage193+ blobStorage *filesystem.BlobStorage
194- manifestStorage *filesystem.ManifestStorage194+ manifestStorage *filesystem.ManifestStorage
195- backupStorage *filesystem.BackupStorage195+ backupStorage *filesystem.BackupStorage
196- envLoader out.EnvLoader196+ envLoader out.EnvLoader
197- logWriter *logwriter.LogWriter197+ logWriter *logwriter.LogWriter
198- tokenStore out.TokenStore198+ tokenStore out.TokenStore
199- configSvc *config.Service199+ configSvc *config.Service
200- secretSvc *secretsSvc.Service200+ secretSvc *secretsSvc.Service
201- containerSvc *container.Service201+ containerSvc *container.Service
202- backupSvc *backup.Service202+ backupSvc *backup.Service
203- registrySvc *registrySvc.Service203+ registrySvc *registrySvc.Service
204- healthSvc *health.Service204+ healthSvc *health.Service
205- logSvc *logs.Service205+ logSvc *logs.Service
206- imageSvc *images.Service206+ imageSvc *images.Service
207- volumeSvc *volumesSvc.Service207+ volumeSvc *volumesSvc.Service
208- proxySvc *proxy.Service208+ proxySvc *proxy.Service
209- authSvc *auth.Service209+ authSvc *auth.Service
210- authHandler *authhandler.Handler210+ authHandler *authhandler.Handler
211- adminHandler *admin.Handler211+ adminHandler *admin.Handler
212- internalRegUser string212+ internalRegUser string
213- internalRegPass string213+ internalRegPass string
214- previewStore *filesystem.PreviewStore214+ previewStore *filesystem.PreviewStore
215- previewService *preview.Service215+ previewService *preview.Service
216- envDir string216+ envDir string
217- maxBlobChunkSize int64217+ maxBlobChunkSize int64
218- caAdapter *pkiadapter.CA218+ caAdapter *pkiadapter.CA
219- pkiSvc *pkiusecase.Service219+ pkiSvc *pkiusecase.Service
220+ reloadCoordinator *reloadCoordinator
220 }221 }
221 222
222 // Run initializes and starts the Gordon application.223 // Run initializes and starts the Gordon application.
@@ -262,12 +263,7 @@ func Run(ctx context.Context, configPath string) error {
262 263
263 log.Info().Msg("Gordon starting")264 log.Info().Msg("Gordon starting")
264 265
265- // Deprecation warnings for removed TLS config fields266+ warnDeprecatedConfigKeys(v, log)
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- }
271 267
272 // Create PID file268 // Create PID file
273 pidFile := createPidFile(log)269 pidFile := createPidFile(log)
@@ -290,7 +286,9 @@ func Run(ctx context.Context, configPath string) error {
290 // timers before graceful shutdown, preventing deploys during drain.286 // timers before graceful shutdown, preventing deploys during drain.
291 287
292 // Set up config hot reload288 // 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+ }
294 292
295 // Start event bus293 // Start event bus
296 if err := svc.eventBus.Start(); err != nil {294 if err := svc.eventBus.Start(); err != nil {
@@ -299,7 +297,15 @@ func Run(ctx context.Context, configPath string) error {
299 defer svc.eventBus.Stop()297 defer svc.eventBus.Stop()
300 298
301 // Start servers, wait for listeners to bind, then sync/auto-start containers.299 // 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+ }
303 }309 }
304 310
305 // initConfig loads configuration from file.311 // initConfig loads configuration from file.
@@ -454,6 +460,8 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
454 return nil, err460 return nil, err
455 }461 }
456 462
463+ si.svc.reloadCoordinator = newReloadCoordinator(v, si.svc.configSvc, si.svc.proxySvc, si.svc.eventBus, log)
464+
457 si.initHandlers()465 si.initHandlers()
458 466
459 return si.svc, nil467 return si.svc, nil
@@ -545,19 +553,19 @@ func (si *serviceInit) initHandlers() {
545 initPreviewService(si.ctx, si.cfg, si.svc, si.log)553 initPreviewService(si.ctx, si.cfg, si.svc, si.log)
546 554
547 si.svc.adminHandler = admin.NewHandler(admin.HandlerDeps{555 si.svc.adminHandler = admin.NewHandler(admin.HandlerDeps{
548- ConfigSvc: si.svc.configSvc,556+ ConfigSvc: si.svc.configSvc,
549- AuthSvc: si.svc.authSvc,557+ AuthSvc: si.svc.authSvc,
550- ContainerSvc: si.svc.containerSvc,558+ ContainerSvc: si.svc.containerSvc,
551- HealthSvc: si.svc.healthSvc,559+ HealthSvc: si.svc.healthSvc,
552- SecretSvc: si.svc.secretSvc,560+ SecretSvc: si.svc.secretSvc,
553- LogSvc: si.svc.logSvc,561+ LogSvc: si.svc.logSvc,
554- RegistrySvc: si.svc.registrySvc,562+ RegistrySvc: si.svc.registrySvc,
555- EventBus: si.svc.eventBus,563+ ReloadTrigger: si.svc.reloadCoordinator,
556- Log: si.log,564+ Log: si.log,
557- BackupSvc: si.svc.backupSvc,565+ BackupSvc: si.svc.backupSvc,
558- PreviewSvc: si.svc.previewService,566+ PreviewSvc: si.svc.previewService,
559- ImageSvc: si.svc.imageSvc,567+ ImageSvc: si.svc.imageSvc,
560- VolumeSvc: si.svc.volumeSvc,568+ VolumeSvc: si.svc.volumeSvc,
561 })569 })
562 }570 }
563 571
@@ -1343,6 +1351,112 @@ type proxyConfigResult struct {
1343 maxBlobChunkSize int641351 maxBlobChunkSize int64
1344 }1352 }
1345 1353
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+
1346 // buildProxyConfig parses size-related config fields and builds the proxy config.1460 // buildProxyConfig parses size-related config fields and builds the proxy config.
1347 func buildProxyConfig(cfg Config, log zerowrap.Logger) (*proxyConfigResult, error) {1461 func buildProxyConfig(cfg Config, log zerowrap.Logger) (*proxyConfigResult, error) {
1348 maxProxyBodySize := int64(512 << 20) // 512MB default1462 maxProxyBodySize := int64(512 << 20) // 512MB default
@@ -1550,7 +1664,6 @@ func registerEventHandlers(ctx context.Context, svc *services, cfg Config) (func
1550 autoPreviewHandler := preview.NewAutoPreviewHandler(1664 autoPreviewHandler := preview.NewAutoPreviewHandler(
1551 ctx,1665 ctx,
1552 svc.configSvc,1666 svc.configSvc,
1553- svc.blobStorage,
1554 svc.previewService,1667 svc.previewService,
1555 )1668 )
1556 1669
@@ -1565,11 +1678,6 @@ func registerEventHandlers(ctx context.Context, svc *services, cfg Config) (func
1565 return nil, fmt.Errorf("failed to subscribe config reload handler: %w", err)1678 return nil, fmt.Errorf("failed to subscribe config reload handler: %w", err)
1566 }1679 }
1567 1680
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-
1573 manualDeployHandler := container.NewManualDeployHandler(ctx, svc.containerSvc, svc.configSvc)1681 manualDeployHandler := container.NewManualDeployHandler(ctx, svc.containerSvc, svc.configSvc)
1574 if err := svc.eventBus.Subscribe(manualDeployHandler); err != nil {1682 if err := svc.eventBus.Subscribe(manualDeployHandler); err != nil {
1575 return nil, fmt.Errorf("failed to subscribe manual deploy handler: %w", err)1683 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
1593 return cleanup, nil1701 return cleanup, nil
1594 }1702 }
1595 1703
1596-// setupConfigHotReload sets up Viper config hot reload.1704+// setupConfigHotReload sets up config hot reload.
1597-// NOTE: This does NOT reload routes into memory. Routes are managed via API1705+func setupConfigHotReload(ctx context.Context, configSvc configWatcher, coordinator loadedConfigApplier) error {
1598-// (AddRoute/UpdateRoute/RemoveRoute) and memory is the source of truth.1706+ if err := configSvc.Watch(ctx, func() {
1599-// The file watcher only updates proxy config and refreshes targets.1707+ _ = coordinator.ApplyLoadedConfig(ctx)
1600-// Manual config file edits to routes require a server restart.1708+ }); err != nil {
1601-func setupConfigHotReload(ctx context.Context, v *viper.Viper, svc *services, log zerowrap.Logger) {1709+ return fmt.Errorf("failed to watch config: %w", err)
1602- v.OnConfigChange(func(e fsnotify.Event) {1710+ }
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- }
1627 1711
1628- log.Debug().Msg("config hot reload complete (routes unchanged, memory is source of truth)")1712+ return nil
1629- })
1630- v.WatchConfig()
1631 }1713 }
1632 1714
1633 // syncAndAutoStart syncs existing containers and auto-starts if configured.1715 // syncAndAutoStart syncs existing containers and auto-starts if configured.
@@ -2054,7 +2136,7 @@ func registerAdminRoutes(registryMux *http.ServeMux, svc *services, cfg Config,
2054 // - SIGUSR2: Triggers manual deploy for a specific route2136 // - SIGUSR2: Triggers manual deploy for a specific route
2055 // The deferred signal.Stop calls ensure signal handlers are properly2137 // The deferred signal.Stop calls ensure signal handlers are properly
2056 // cleaned up before program exit, preventing signal handler leaks.2138 // 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 {
2058 // Initialize access log writer. Kept here (not in Run) to keep Run's cyclomatic2140 // Initialize access log writer. Kept here (not in Run) to keep Run's cyclomatic
2059 // complexity within the project limit of 15.2141 // complexity within the project limit of 15.
2060 accessWriterConcrete, err := initAccessLog(cfg, log)2142 accessWriterConcrete, err := initAccessLog(cfg, log)
@@ -2148,7 +2230,7 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services,
2148 // Auto-start after servers are listening (registry port is now bound).2230 // Auto-start after servers are listening (registry port is now bound).
2149 syncAndAutoStart(ctx, svc, log)2231 syncAndAutoStart(ctx, svc, log)
2150 2232
2151- waitForShutdown(ctx, errChan, reloadChan, deployChan, svc.eventBus, log)2233+ waitForShutdown(ctx, errChan, reloadChan, deployChan, reload, svc.eventBus, log)
2152 cleanupHandlers() // Stop debounce timers before draining containers2234 cleanupHandlers() // Stop debounce timers before draining containers
2153 gracefulShutdown(registrySrv, proxySrv, tlsSrv, svc.containerSvc, svc.proxySvc, svc.pkiSvc, log)2235 gracefulShutdown(registrySrv, proxySrv, tlsSrv, svc.containerSvc, svc.proxySvc, svc.pkiSvc, log)
2154 return nil2236 return nil
@@ -2321,7 +2403,7 @@ func resolveSchedulePreset(raw, name string, defaultVal domain.BackupSchedule) (
2321 2403
2322 // waitForShutdown blocks on the event loop, handling server errors and2404 // waitForShutdown blocks on the event loop, handling server errors and
2323 // Unix signals (reload, deploy, shutdown) until the context is cancelled.2405 // 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) {
2325 for {2407 for {
2326 select {2408 select {
2327 case err := <-errChan:2409 case err := <-errChan:
@@ -2329,9 +2411,7 @@ func waitForShutdown(ctx context.Context, errChan <-chan error, reloadChan, depl
2329 return2411 return
2330 case <-reloadChan:2412 case <-reloadChan:
2331 log.Info().Msg("reload signal received (SIGUSR1)")2413 log.Info().Msg("reload signal received (SIGUSR1)")
2332- if err := eventBus.Publish(domain.EventManualReload, nil); err != nil {2414+ _ = reload.Trigger(ctx)
2333- log.Error().Err(err).Msg("failed to publish manual reload event")
2334- }
2335 case <-deployChan:2415 case <-deployChan:
2336 log.Info().Msg("deploy signal received (SIGUSR2)")2416 log.Info().Msg("deploy signal received (SIGUSR2)")
2337 domainName, err := readDeployRequest()2417 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 (
7 "strings"7 "strings"
8 )8 )
9 9
10-var envKeyRegex = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)10+var (
11-var containerNameRegex = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]*$`)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+)
12 15
13 // SanitizeDomainForEnvFile validates and sanitizes a domain name for env file storage.16 // SanitizeDomainForEnvFile validates and sanitizes a domain name for env file storage.
14 // Returns the collision-resistant storage key used for filenames.17 // Returns the collision-resistant storage key used for filenames.
@@ -77,6 +80,14 @@ func ValidateEnvKey(key string) error {
77 return nil80 return nil
78 }81 }
79 82
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+
80 // ValidateContainerName validates a container name for attachment storage.91 // ValidateContainerName validates a container name for attachment storage.
81 // Container names can contain alphanumeric characters, hyphens, and underscores,92 // Container names can contain alphanumeric characters, hyphens, and underscores,
82 // but must start with a letter.93 // but must start with a letter.
M internal/domain/env_test.go +31 -0
@@ -179,6 +179,37 @@ func TestValidateEnvKey(t *testing.T) {
179 }179 }
180 }180 }
181 181
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+
182 func TestValidateContainerName(t *testing.T) {213 func TestValidateContainerName(t *testing.T) {
183 tests := []struct {214 tests := []struct {
184 name string215 name string
M internal/domain/errors.go +15 -13
@@ -18,12 +18,13 @@ var (
18 ErrInvalidImageFormat = errors.New("invalid image format")18 ErrInvalidImageFormat = errors.New("invalid image format")
19 19
20 // Route errors20 // Route errors
21- ErrRouteNotFound = errors.New("route not found")21+ ErrRouteNotFound = errors.New("route not found")
22- ErrRouteExists = errors.New("route already exists")22+ ErrRouteExists = errors.New("route already exists")
23- ErrInvalidRoute = errors.New("invalid route configuration")23+ ErrInvalidRoute = errors.New("invalid route configuration")
24- ErrRouteDomainEmpty = errors.New("route domain cannot be empty")24+ ErrRouteDomainEmpty = errors.New("route domain cannot be empty")
25- ErrRouteImageEmpty = errors.New("route image cannot be empty")25+ ErrRouteImageEmpty = errors.New("route image cannot be empty")
26- ErrNoTargetAvailable = errors.New("no target available for route")26+ ErrRouteDomainInvalid = errors.New("route domain is not a valid public hostname")
27+ ErrNoTargetAvailable = errors.New("no target available for route")
27 28
28 // Registry errors29 // Registry errors
29 ErrManifestNotFound = errors.New("manifest not found")30 ErrManifestNotFound = errors.New("manifest not found")
@@ -64,13 +65,14 @@ var (
64 ErrTokenNotFound = errors.New("token not found")65 ErrTokenNotFound = errors.New("token not found")
65 66
66 // Security errors67 // Security errors
67- ErrPathTraversal = errors.New("path traversal not allowed")68+ ErrPathTraversal = errors.New("path traversal not allowed")
68- ErrInvalidPath = errors.New("invalid path format")69+ ErrInvalidPath = errors.New("invalid path format")
69- ErrInsufficientScope = errors.New("insufficient scope for operation")70+ ErrInsufficientScope = errors.New("insufficient scope for operation")
70- ErrInvalidScope = errors.New("invalid scope format")71+ ErrInvalidScope = errors.New("invalid scope format")
71- ErrSSRFBlocked = errors.New("request to internal/blocked network not allowed")72+ ErrSSRFBlocked = errors.New("request to internal/blocked network not allowed")
72- ErrInvalidEnvKey = errors.New("invalid environment variable key")73+ ErrInvalidEnvKey = errors.New("invalid environment variable key")
73- ErrLongLivedToken = errors.New("long-lived tokens must be exchanged via /auth/token")74+ ErrEnvContainsSecretRef = errors.New("environment file contains secret reference")
75+ ErrLongLivedToken = errors.New("long-lived tokens must be exchanged via /auth/token")
74 76
75 // Remote errors77 // Remote errors
76 ErrEmptyRemoteName = errors.New("remote name must not be empty")78 ErrEmptyRemoteName = errors.New("remote name must not be empty")
M internal/domain/event.go +0 -1
@@ -12,7 +12,6 @@ const (
12 EventImagePushed EventType = "image.pushed"12 EventImagePushed EventType = "image.pushed"
13 EventImageDeleted EventType = "image.deleted"13 EventImageDeleted EventType = "image.deleted"
14 EventConfigReload EventType = "config.reload"14 EventConfigReload EventType = "config.reload"
15- EventManualReload EventType = "manual.reload"
16 EventManualDeploy EventType = "manual.deploy"15 EventManualDeploy EventType = "manual.deploy"
17 EventContainerStop EventType = "container.stop"16 EventContainerStop EventType = "container.stop"
18 EventContainerStart EventType = "container.start"17 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 (
7 7
8 "github.com/bnema/zerowrap"8 "github.com/bnema/zerowrap"
9 9
10- "github.com/bnema/gordon/internal/boundaries/out"
11 "github.com/bnema/gordon/internal/domain"10 "github.com/bnema/gordon/internal/domain"
12 "github.com/bnema/gordon/internal/usecase/auto"11 "github.com/bnema/gordon/internal/usecase/auto"
13 )12 )
@@ -16,20 +15,17 @@ import (
16 type AutoPreviewHandler struct {15 type AutoPreviewHandler struct {
17 serviceCtx context.Context16 serviceCtx context.Context
18 config auto.AutoConfigProvider17 config auto.AutoConfigProvider
19- blobStorage out.BlobStorage
20 previewService *Service18 previewService *Service
21 }19 }
22 20
23 func NewAutoPreviewHandler(21 func NewAutoPreviewHandler(
24 serviceCtx context.Context,22 serviceCtx context.Context,
25 config auto.AutoConfigProvider,23 config auto.AutoConfigProvider,
26- blobStorage out.BlobStorage,
27 previewService *Service,24 previewService *Service,
28 ) *AutoPreviewHandler {25 ) *AutoPreviewHandler {
29 return &AutoPreviewHandler{26 return &AutoPreviewHandler{
30 serviceCtx: serviceCtx,27 serviceCtx: serviceCtx,
31 config: config,28 config: config,
32- blobStorage: blobStorage,
33 previewService: previewService,29 previewService: previewService,
34 }30 }
35 }31 }
@@ -53,70 +49,98 @@ func (h *AutoPreviewHandler) Handle(ctx context.Context, event domain.Event) err
53 return nil49 return nil
54 }50 }
55 51
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-
63 previewConfig := h.config.GetPreviewConfig()52 previewConfig := h.config.GetPreviewConfig()
64 previewName := ExtractPreviewName(payload.Reference, previewConfig.TagPatterns)53 previewName := ExtractPreviewName(payload.Reference, previewConfig.TagPatterns)
65 54
66- // Resolve base route: labels first, then route config fallback.55+ routes := h.config.FindRoutesByImage(ctx, payload.Name)
67- // When falling back to routes, skip preview domains (they contain the separator)56+ baseRoutes := resolveBaseRoutes(routes, previewConfig)
68- // to avoid using a preview as the base for another preview.57+ if len(baseRoutes) == 0 {
69- domains := collectDomains(labels)58+ log.Debug().Str("image", payload.Name).Msg("no trusted route found for preview base, skipping")
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")
80 return nil59 return nil
81 }60 }
82- baseRouteDomain := domains[0]
83 61
84- // Validate allowlist
85 allowedDomains := h.config.GetAllowedDomains()62 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- }
95 63
96 imageName := payload.Name + ":" + payload.Reference64 imageName := payload.Name + ":" + payload.Reference
97-65+ for _, baseRoute := range baseRoutes {
98- // Launch async — volume cloning may exceed event bus timeout66+ previewDomain, err := GeneratePreviewDomain(baseRoute.Domain, previewName, previewConfig.Separator)
99- go func() {67+ if err != nil {
100- if err := h.previewService.CreatePreview(h.serviceCtx, CreatePreviewRequest{68+ log.Debug().Err(err).Str("base_domain", baseRoute.Domain).Msg("failed to generate preview domain")
101- Name: previewName,69+ continue
102- Domain: previewDomain,70+ }
103- BaseRoute: baseRouteDomain,71+ if !auto.MatchesDomainAllowlist(previewDomain, allowedDomains) {
104- Image: imageName,72+ log.Debug().Str("domain", previewDomain).Strs("patterns", allowedDomains).Msg("preview domain not in allowlist, skipping")
105- HTTPS: true,73+ continue
106- PreviewConfig: previewConfig,
107- }); err != nil {
108- log.Error().Err(err).Str("preview", previewName).Msg("failed to create preview")
109 }74 }
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+ }
111 90
112 return nil91 return nil
113 }92 }
114 93
115-func collectDomains(labels *domain.ImageLabels) []string {94+// resolveBaseRoutes determines the eligible base routes for preview env/data inheritance.
116- var domains []string95+type baseRouteInfo struct {
117- if labels.Domain != "" {96+ Domain string
118- domains = append(domains, labels.Domain)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
119 }141 }
120- domains = append(domains, labels.Domains...)142+ baseDomain := baseLabel + rest
121- return domains143+ // Only treat as preview if the base domain actually exists in the route set
144+ _, exists := routeSet[baseDomain]
145+ return exists
122 }146 }
M internal/usecase/auto/preview/handler_test.go +130 -15
@@ -1,32 +1,147 @@
1 package preview1 package preview
2 2
3 import (3 import (
4+ "context"
5+ "sync"
4 "testing"6 "testing"
7+ "time"
5 8
6- "github.com/bnema/gordon/internal/domain"
7 "github.com/stretchr/testify/assert"9 "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/gordon/internal/domain"
8 )13 )
9 14
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+
10 func TestAutoPreviewHandler_CanHandle(t *testing.T) {66 func TestAutoPreviewHandler_CanHandle(t *testing.T) {
11 h := &AutoPreviewHandler{}67 h := &AutoPreviewHandler{}
12 assert.True(t, h.CanHandle(domain.EventImagePushed))68 assert.True(t, h.CanHandle(domain.EventImagePushed))
13 assert.False(t, h.CanHandle(domain.EventConfigReload))69 assert.False(t, h.CanHandle(domain.EventConfigReload))
14 }70 }
15 71
16-func TestCollectDomains(t *testing.T) {72+func TestResolveBaseRoutes_ReturnsAllEligibleBases(t *testing.T) {
17- tests := []struct {73+ routes := []domain.Route{
18- name string74+ {Domain: "app-preview-old.example.com", Image: "myapp", HTTPS: true},
19- labels *domain.ImageLabels75+ {Domain: "app.example.com", Image: "myapp", HTTPS: true},
20- want []string76+ {Domain: "alias.example.com", Image: "myapp", HTTPS: false},
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},
26 }77 }
27- for _, tt := range tests {78+
28- t.Run(tt.name, func(t *testing.T) {79+ baseRoutes := resolveBaseRoutes(routes, domain.PreviewConfig{Separator: "-preview-"})
29- assert.Equal(t, tt.want, collectDomains(tt.labels))80+ assert.ElementsMatch(t, []baseRouteInfo{
30- })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},
31 }90 }
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)
32 }147 }
M internal/usecase/config/service.go +323 -121
@@ -41,7 +41,7 @@ type Config struct {
41 PreviewEnvCopy bool41 PreviewEnvCopy bool
42 NetworkIsolation bool42 NetworkIsolation bool
43 NetworkPrefix string43 NetworkPrefix string
44- Routes map[string]string44+ Routes map[string]routeConfig
45 ExternalRoutes map[string]string // domain -> "host:port"45 ExternalRoutes map[string]string // domain -> "host:port"
46 RegistryAuthEnabled bool46 RegistryAuthEnabled bool
47 RegistryAuthUsername string47 RegistryAuthUsername string
@@ -53,6 +53,11 @@ type Config struct {
53 Attachments map[string][]string53 Attachments map[string][]string
54 }54 }
55 55
56+type routeConfig struct {
57+ Image string `toml:"image"`
58+ HTTPS bool `toml:"https"`
59+}
60+
56 // Service implements the ConfigService interface.61 // Service implements the ConfigService interface.
57 type Service struct {62 type Service struct {
58 viper *viper.Viper63 viper *viper.Viper
@@ -83,14 +88,18 @@ func (s *Service) Load(ctx context.Context) error {
83 s.mu.Lock()88 s.mu.Lock()
84 defer s.mu.Unlock()89 defer s.mu.Unlock()
85 90
86- // Load configuration from viper91+ newConfig := s.loadConfigValues()
87- s.config = 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"))
88 101
89- // Load complex nested structures102+ s.config = newConfig
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"))
94 103
95 log.Info().104 log.Info().
96 Int("server_port", s.config.ServerPort).105 Int("server_port", s.config.ServerPort).
@@ -174,7 +183,7 @@ func (s *Service) loadConfigValues() Config {
174 VolumeAutoCreate: s.viper.GetBool("volumes.auto_create"),183 VolumeAutoCreate: s.viper.GetBool("volumes.auto_create"),
175 VolumePrefix: s.viper.GetString("volumes.prefix"),184 VolumePrefix: s.viper.GetString("volumes.prefix"),
176 VolumePreserve: s.viper.GetBool("volumes.preserve"),185 VolumePreserve: s.viper.GetBool("volumes.preserve"),
177- Routes: make(map[string]string),186+ Routes: make(map[string]routeConfig),
178 ExternalRoutes: make(map[string]string),187 ExternalRoutes: make(map[string]string),
179 NetworkGroups: make(map[string][]string),188 NetworkGroups: make(map[string][]string),
180 Attachments: make(map[string][]string),189 Attachments: make(map[string][]string),
@@ -223,27 +232,112 @@ func loadStringArrayMap(raw any) map[string][]string {
223 return result232 return result
224 }233 }
225 234
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+
226 // GetRoutes returns all configured routes.330 // GetRoutes returns all configured routes.
227 func (s *Service) GetRoutes(_ context.Context) []domain.Route {331 func (s *Service) GetRoutes(_ context.Context) []domain.Route {
228 s.mu.RLock()332 s.mu.RLock()
229 defer s.mu.RUnlock()333 defer s.mu.RUnlock()
230 334
231 var routes []domain.Route335 var routes []domain.Route
232- for domainName, image := range s.config.Routes {336+ for domainName, route := range s.config.Routes {
233- route := domain.Route{
234- Image: image,
235- HTTPS: true,
236- }
237-
238- // Check if domain has http:// prefix
239 if strings.HasPrefix(domainName, "http://") {337 if strings.HasPrefix(domainName, "http://") {
240- route.Domain = strings.TrimPrefix(domainName, "http://")338+ continue
241- route.HTTPS = false
242- } else {
243- route.Domain = domainName
244 }339 }
245-340+ routes = append(routes, domain.Route{Domain: domainName, Image: route.Image, HTTPS: route.HTTPS})
246- routes = append(routes, route)
247 }341 }
248 342
249 return routes343 return routes
@@ -254,22 +348,13 @@ func (s *Service) GetRoute(_ context.Context, domainName string) (*domain.Route,
254 s.mu.RLock()348 s.mu.RLock()
255 defer s.mu.RUnlock()349 defer s.mu.RUnlock()
256 350
257- image, exists := s.config.Routes[domainName]351+ routeCfg, ok := s.config.Routes[domainName]
258- if !exists {352+ if !ok {
259 return nil, domain.ErrRouteNotFound353 return nil, domain.ErrRouteNotFound
260 }354 }
261 355
262- route := &domain.Route{356+ routeValue := domain.Route{Domain: domainName, Image: routeCfg.Image, HTTPS: routeCfg.HTTPS}
263- Domain: domainName,357+ route := &routeValue
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- }
273 358
274 return route, nil359 return route, nil
275 }360 }
@@ -282,19 +367,12 @@ func (s *Service) FindRoutesByImage(_ context.Context, imageName string) []domai
282 defer s.mu.RUnlock()367 defer s.mu.RUnlock()
283 368
284 var routes []domain.Route369 var routes []domain.Route
285- for domainName, image := range s.config.Routes {370+ for domainName, route := range s.config.Routes {
286- if matchesImageName(imageName, image, s.config.RegistryDomain) {371+ if strings.HasPrefix(domainName, "http://") {
287- route := domain.Route{372+ continue
288- Image: image,373+ }
289- HTTPS: true,374+ if matchesImageName(imageName, route.Image, s.config.RegistryDomain) {
290- }375+ routes = append(routes, domain.Route{Domain: domainName, Image: route.Image, HTTPS: route.HTTPS})
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)
298 }376 }
299 }377 }
300 378
@@ -386,6 +464,23 @@ func NormalizeBootstrapImage(image, registryDomain string) (string, error) {
386 return image, nil464 return image, nil
387 }465 }
388 466
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+
389 // AddRoute adds a new route to the configuration and persists it.484 // AddRoute adds a new route to the configuration and persists it.
390 func (s *Service) AddRoute(ctx context.Context, route domain.Route) error {485 func (s *Service) AddRoute(ctx context.Context, route domain.Route) error {
391 ctx = zerowrap.CtxWithFields(ctx, map[string]any{486 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
@@ -399,36 +494,45 @@ func (s *Service) AddRoute(ctx context.Context, route domain.Route) error {
399 if route.Domain == "" {494 if route.Domain == "" {
400 return domain.ErrRouteDomainEmpty495 return domain.ErrRouteDomainEmpty
401 }496 }
497+ if !domain.IsValidRouteDomain(route.Domain) {
498+ return domain.ErrRouteDomainInvalid
499+ }
402 if route.Image == "" {500 if route.Image == "" {
403 return domain.ErrRouteImageEmpty501 return domain.ErrRouteImageEmpty
404 }502 }
405 503
406 // Store previous value for rollback504 // Store previous value for rollback
407 s.mu.Lock()505 s.mu.Lock()
408- previousImage, existed := s.config.Routes[route.Domain]506+ currentConfig := s.config
409- if s.config.Routes == nil {507+ if currentConfig.Routes == nil {
410- s.config.Routes = make(map[string]string)508+ currentConfig.Routes = make(map[string]routeConfig)
411- }509+ }
412- if existed && previousImage == route.Image {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 {
413 s.mu.Unlock()515 s.mu.Unlock()
414 return nil516 return nil
415 }517 }
416- s.config.Routes[route.Domain] = route.Image518+ nextRoutes := cloneRouteConfigs(currentConfig.Routes)
417- s.mu.Unlock()519+ nextRoutes[route.Domain] = newRoute
520+ if legacyExisted {
521+ delete(nextRoutes, legacyKey)
522+ }
418 523
419- // Persist to disk - rollback on failure524+ nextConfig := currentConfig
420- if err := s.Save(ctx); err != nil {525+ nextConfig.Routes = nextRoutes
421- log.Warn().Err(err).Msg("failed to persist route to disk, rolling back")526+
422- s.mu.Lock()527+ if err := s.persistConfig(ctx, nextConfig); err != nil {
423- if existed {528+ log.Warn().Err(err).Msg("failed to persist route to disk")
424- s.config.Routes[route.Domain] = previousImage
425- } else {
426- delete(s.config.Routes, route.Domain)
427- }
428 s.mu.Unlock()529 s.mu.Unlock()
429 return err530 return err
430 }531 }
431 532
533+ s.config.Routes = nextRoutes
534+ s.mu.Unlock()
535+
432 log.Info().Str("image", route.Image).Msg("route added to configuration")536 log.Info().Str("image", route.Image).Msg("route added to configuration")
433 return nil537 return nil
434 }538 }
@@ -446,29 +550,41 @@ func (s *Service) UpdateRoute(ctx context.Context, route domain.Route) error {
446 if route.Domain == "" {550 if route.Domain == "" {
447 return domain.ErrRouteDomainEmpty551 return domain.ErrRouteDomainEmpty
448 }552 }
553+ if !domain.IsValidRouteDomain(route.Domain) {
554+ return domain.ErrRouteDomainInvalid
555+ }
449 if route.Image == "" {556 if route.Image == "" {
450 return domain.ErrRouteImageEmpty557 return domain.ErrRouteImageEmpty
451 }558 }
452 559
453 // Store previous value for rollback560 // Store previous value for rollback
454 s.mu.Lock()561 s.mu.Lock()
455- previousImage, exists := s.config.Routes[route.Domain]562+ currentConfig := s.config
456- if !exists {563+ previousRoute, ok := currentConfig.Routes[route.Domain]
564+ if !ok {
457 s.mu.Unlock()565 s.mu.Unlock()
458 return domain.ErrRouteNotFound566 return domain.ErrRouteNotFound
459 }567 }
460- s.config.Routes[route.Domain] = route.Image568+ updatedRoute := previousRoute
461- s.mu.Unlock()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
462 575
463- // Persist to disk - rollback on failure576+ nextConfig := currentConfig
464- if err := s.Save(ctx); err != nil {577+ nextConfig.Routes = nextRoutes
465- log.Warn().Err(err).Msg("failed to persist route update to disk, rolling back")578+
466- s.mu.Lock()579+ if err := s.persistConfig(ctx, nextConfig); err != nil {
467- s.config.Routes[route.Domain] = previousImage580+ log.Warn().Err(err).Msg("failed to persist route update to disk")
468 s.mu.Unlock()581 s.mu.Unlock()
469 return err582 return err
470 }583 }
471 584
585+ s.config.Routes = nextRoutes
586+ s.mu.Unlock()
587+
472 log.Info().Str("image", route.Image).Msg("route updated")588 log.Info().Str("image", route.Image).Msg("route updated")
473 return nil589 return nil
474 }590 }
@@ -482,25 +598,38 @@ func (s *Service) RemoveRoute(ctx context.Context, domainName string) error {
482 })598 })
483 log := zerowrap.FromCtx(ctx)599 log := zerowrap.FromCtx(ctx)
484 600
601+ if domainName == "" {
602+ return domain.ErrRouteDomainEmpty
603+ }
604+ if !domain.IsValidRouteDomain(domainName) {
605+ return domain.ErrRouteDomainInvalid
606+ }
607+
485 // Store previous value for rollback608 // Store previous value for rollback
486 s.mu.Lock()609 s.mu.Lock()
487- previousImage, exists := s.config.Routes[domainName]610+ currentConfig := s.config
488- if !exists {611+ _, ok := currentConfig.Routes[domainName]
612+ if !ok {
489 s.mu.Unlock()613 s.mu.Unlock()
490 return domain.ErrRouteNotFound614 return domain.ErrRouteNotFound
491 }615 }
492- delete(s.config.Routes, domainName)616+ legacyKey := legacyRouteStorageKey(domainName)
493- s.mu.Unlock()617+ nextRoutes := cloneRouteConfigs(currentConfig.Routes)
618+ delete(nextRoutes, legacyKey)
619+ delete(nextRoutes, domainName)
494 620
495- // Persist to disk - rollback on failure621+ nextConfig := currentConfig
496- if err := s.Save(ctx); err != nil {622+ nextConfig.Routes = nextRoutes
497- log.Warn().Err(err).Msg("failed to persist route removal to disk, rolling back")623+
498- s.mu.Lock()624+ if err := s.persistConfig(ctx, nextConfig); err != nil {
499- s.config.Routes[domainName] = previousImage625+ log.Warn().Err(err).Msg("failed to persist route removal to disk")
500 s.mu.Unlock()626 s.mu.Unlock()
501 return err627 return err
502 }628 }
503 629
630+ s.config.Routes = nextRoutes
631+ s.mu.Unlock()
632+
504 log.Info().Msg("route removed")633 log.Info().Msg("route removed")
505 return nil634 return nil
506 }635 }
@@ -511,11 +640,16 @@ func (s *Service) Save(ctx context.Context) error {
511 zerowrap.FieldLayer: "usecase",640 zerowrap.FieldLayer: "usecase",
512 zerowrap.FieldUseCase: "SaveConfig",641 zerowrap.FieldUseCase: "SaveConfig",
513 })642 })
514- log := zerowrap.FromCtx(ctx)
515 643
516 s.mu.Lock()644 s.mu.Lock()
517 defer s.mu.Unlock()645 defer s.mu.Unlock()
518 646
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+
519 configFile := s.viper.ConfigFileUsed()653 configFile := s.viper.ConfigFileUsed()
520 if configFile == "" {654 if configFile == "" {
521 return log.WrapErr(fmt.Errorf("no config file path"), "cannot save config")655 return log.WrapErr(fmt.Errorf("no config file path"), "cannot save config")
@@ -527,7 +661,7 @@ func (s *Service) Save(ctx context.Context) error {
527 661
528 snapshot := s.snapshotCriticalFields()662 snapshot := s.snapshotCriticalFields()
529 663
530- if err := s.writeConfigSurgical(configFile); err != nil {664+ if err := s.writeConfigSurgical(configFile, config); err != nil {
531 return log.WrapErr(err, "failed to write config")665 return log.WrapErr(err, "failed to write config")
532 }666 }
533 667
@@ -598,50 +732,49 @@ func (s *Service) verifyCriticalFields(snap configSnapshot) error {
598 return nil732 return nil
599 }733 }
600 734
601-func (s *Service) writeConfigSurgical(configFile string) error {735+func (s *Service) writeConfigSurgical(configFile string, snapshotConfig Config) error {
602 data, err := os.ReadFile(configFile)736 data, err := os.ReadFile(configFile)
737+ var configMap map[string]any
603 if err != nil {738 if err != nil {
604 if os.IsNotExist(err) {739 if os.IsNotExist(err) {
605- s.viper.Set("routes", s.config.Routes)740+ configMap = s.viper.AllSettings()
606- s.viper.Set("external_routes", s.config.ExternalRoutes)741+ } else {
607- s.viper.Set("attachments", s.config.Attachments)742+ return fmt.Errorf("failed to read config file: %w", err)
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)
612 }743 }
613- return fmt.Errorf("failed to read config file: %w", err)
614 }744 }
615 745
616- var config map[string]any746+ if configMap == nil {
747+ configMap = make(map[string]any)
748+ }
617 if len(data) > 0 {749 if len(data) > 0 {
618- if err := toml.Unmarshal(data, &config); err != nil {750+ if err := toml.Unmarshal(data, &configMap); err != nil {
619 return fmt.Errorf("failed to parse config file: %w", err)751 return fmt.Errorf("failed to parse config file: %w", err)
620 }752 }
621 }753 }
622- if config == nil {
623- config = make(map[string]any)
624- }
625 754
626- config["routes"] = s.config.Routes755+ delete(configMap, "routes")
627- config["external_routes"] = s.config.ExternalRoutes756+ configMap["external_routes"] = snapshotConfig.ExternalRoutes
628- config["attachments"] = s.config.Attachments757+ configMap["attachments"] = snapshotConfig.Attachments
629- config["network_groups"] = s.config.NetworkGroups758+ configMap["network_groups"] = snapshotConfig.NetworkGroups
630- // Write to both old and new keys for backward compat759+ applyAutoAllowedDomains(configMap, snapshotConfig.AutoRouteAllowedDomains)
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- }
639 760
640- out, err := toml.Marshal(config)761+ out, err := toml.Marshal(configMap)
641 if err != nil {762 if err != nil {
642 return fmt.Errorf("failed to marshal config: %w", err)763 return fmt.Errorf("failed to marshal config: %w", err)
643 }764 }
644 765
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+
645 configDir := filepath.Dir(configFile)778 configDir := filepath.Dir(configFile)
646 tmpFile, err := os.CreateTemp(configDir, ".gordon-config-*.tmp")779 tmpFile, err := os.CreateTemp(configDir, ".gordon-config-*.tmp")
647 if err != nil {780 if err != nil {
@@ -671,6 +804,84 @@ func (s *Service) writeConfigSurgical(configFile string) error {
671 return nil804 return nil
672 }805 }
673 806
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+
674 func backupConfigFile(configFile string) error {885 func backupConfigFile(configFile string) error {
675 src, err := os.ReadFile(configFile)886 src, err := os.ReadFile(configFile)
676 if err != nil {887 if err != nil {
@@ -816,15 +1027,6 @@ func (s *Service) Watch(ctx context.Context, onChange func()) error {
816 return1027 return
817 }1028 }
818 1029
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-
828 if onChange != nil {1030 if onChange != nil {
829 onChange()1031 onChange()
830 }1032 }
M internal/usecase/config/service_test.go +630 -9
@@ -20,6 +20,44 @@ func testContext() context.Context {
20 return zerowrap.WithCtx(context.Background(), zerowrap.Default())20 return zerowrap.WithCtx(context.Background(), zerowrap.Default())
21 }21 }
22 22
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+
23 func TestService_Load(t *testing.T) {61 func TestService_Load(t *testing.T) {
24 v := viper.New()62 v := viper.New()
25 v.Set("server.port", 8080)63 v.Set("server.port", 8080)
@@ -60,6 +98,181 @@ func TestService_Load(t *testing.T) {
60 assert.False(t, preserve)98 assert.False(t, preserve)
61 }99 }
62 100
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+
63 func TestService_Reload(t *testing.T) {276 func TestService_Reload(t *testing.T) {
64 t.Run("success - picks up config file changes", func(t *testing.T) {277 t.Run("success - picks up config file changes", func(t *testing.T) {
65 // Create temp config file278 // Create temp config file
@@ -87,7 +300,9 @@ port = 8080
87 err = svc.Load(ctx)300 err = svc.Load(ctx)
88 require.NoError(t, err)301 require.NoError(t, err)
89 assert.Equal(t, 8080, svc.GetServerPort())302 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)
91 306
92 // Modify config file on disk307 // Modify config file on disk
93 updatedConfig := `[server]308 updatedConfig := `[server]
@@ -105,8 +320,12 @@ port = 9090
105 require.NoError(t, err)320 require.NoError(t, err)
106 321
107 assert.Equal(t, 9090, svc.GetServerPort())322 assert.Equal(t, 9090, svc.GetServerPort())
108- assert.Equal(t, "myapp:v2", svc.GetConfig().Routes["app.example.com"])323+ route = requireRoute(t, svc.GetConfig().Routes, "app.example.com")
109- assert.Equal(t, "newapp:latest", svc.GetConfig().Routes["new.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)
110 })329 })
111 330
112 t.Run("error - config file not found", func(t *testing.T) {331 t.Run("error - config file not found", func(t *testing.T) {
@@ -224,6 +443,14 @@ func TestService_GetRoute(t *testing.T) {
224 assert.True(t, route.HTTPS)443 assert.True(t, route.HTTPS)
225 })444 })
226 445
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+
227 t.Run("non-existent route", func(t *testing.T) {454 t.Run("non-existent route", func(t *testing.T) {
228 route, err := svc.GetRoute(ctx, "notfound.example.com")455 route, err := svc.GetRoute(ctx, "notfound.example.com")
229 assert.ErrorIs(t, err, domain.ErrRouteNotFound)456 assert.ErrorIs(t, err, domain.ErrRouteNotFound)
@@ -250,6 +477,7 @@ func TestService_AddRoute(t *testing.T) {
250 route := domain.Route{477 route := domain.Route{
251 Domain: "new.example.com",478 Domain: "new.example.com",
252 Image: "newapp:latest",479 Image: "newapp:latest",
480+ HTTPS: true,
253 }481 }
254 482
255 err = svc.AddRoute(ctx, route)483 err = svc.AddRoute(ctx, route)
@@ -258,7 +486,73 @@ func TestService_AddRoute(t *testing.T) {
258 486
259 // Verify route was added487 // Verify route was added
260 config := svc.GetConfig()488 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)
262 })556 })
263 557
264 t.Run("idempotent same domain and image saves once", func(t *testing.T) {558 t.Run("idempotent same domain and image saves once", func(t *testing.T) {
@@ -278,6 +572,7 @@ func TestService_AddRoute(t *testing.T) {
278 route := domain.Route{572 route := domain.Route{
279 Domain: "new.example.com",573 Domain: "new.example.com",
280 Image: "newapp:latest",574 Image: "newapp:latest",
575+ HTTPS: true,
281 }576 }
282 577
283 err = svc.AddRoute(ctx, route)578 err = svc.AddRoute(ctx, route)
@@ -293,7 +588,9 @@ func TestService_AddRoute(t *testing.T) {
293 require.NoError(t, err)588 require.NoError(t, err)
294 589
295 assert.Equal(t, before.ModTime(), after.ModTime())590 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)
297 })594 })
298 595
299 t.Run("empty domain", func(t *testing.T) {596 t.Run("empty domain", func(t *testing.T) {
@@ -325,6 +622,90 @@ func TestService_AddRoute(t *testing.T) {
325 err := svc.AddRoute(ctx, route)622 err := svc.AddRoute(ctx, route)
326 assert.ErrorIs(t, err, domain.ErrRouteImageEmpty)623 assert.ErrorIs(t, err, domain.ErrRouteImageEmpty)
327 })624 })
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+ })
328 }709 }
329 710
330 func TestService_UpdateRoute(t *testing.T) {711 func TestService_UpdateRoute(t *testing.T) {
@@ -350,6 +731,7 @@ func TestService_UpdateRoute(t *testing.T) {
350 route := domain.Route{731 route := domain.Route{
351 Domain: "app.example.com",732 Domain: "app.example.com",
352 Image: "myapp:v2",733 Image: "myapp:v2",
734+ HTTPS: true,
353 }735 }
354 736
355 err = svc.UpdateRoute(ctx, route)737 err = svc.UpdateRoute(ctx, route)
@@ -357,7 +739,45 @@ func TestService_UpdateRoute(t *testing.T) {
357 assert.NoError(t, err)739 assert.NoError(t, err)
358 740
359 config := svc.GetConfig()741 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 }`)
361 })781 })
362 782
363 t.Run("not found", func(t *testing.T) {783 t.Run("not found", func(t *testing.T) {
@@ -406,6 +826,103 @@ func TestService_UpdateRoute(t *testing.T) {
406 err := svc.UpdateRoute(ctx, route)826 err := svc.UpdateRoute(ctx, route)
407 assert.ErrorIs(t, err, domain.ErrRouteImageEmpty)827 assert.ErrorIs(t, err, domain.ErrRouteImageEmpty)
408 })828 })
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+ })
409 }926 }
410 927
411 func TestService_RemoveRoute(t *testing.T) {928 func TestService_RemoveRoute(t *testing.T) {
@@ -436,6 +953,16 @@ func TestService_RemoveRoute(t *testing.T) {
436 assert.False(t, exists)953 assert.False(t, exists)
437 }954 }
438 955
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+
439 func TestService_RemoveRoute_NotFound(t *testing.T) {966 func TestService_RemoveRoute_NotFound(t *testing.T) {
440 v := viper.New()967 v := viper.New()
441 eventBus := mocks.NewMockEventPublisher(t)968 eventBus := mocks.NewMockEventPublisher(t)
@@ -449,6 +976,95 @@ func TestService_RemoveRoute_NotFound(t *testing.T) {
449 assert.ErrorIs(t, err, domain.ErrRouteNotFound)976 assert.ErrorIs(t, err, domain.ErrRouteNotFound)
450 }977 }
451 978
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+
452 func TestService_GetNetworkGroups(t *testing.T) {1068 func TestService_GetNetworkGroups(t *testing.T) {
453 v := viper.New()1069 v := viper.New()
454 v.Set("network_groups", map[string]interface{}{1070 v.Set("network_groups", map[string]interface{}{
@@ -1434,9 +2050,14 @@ backend = ["app.example.com"]
1434 assert.True(t, v2.GetBool("backups.enabled"))2050 assert.True(t, v2.GetBool("backups.enabled"))
1435 assert.Equal(t, "~/.gordon/backups", v2.GetString("backups.storage_dir"))2051 assert.Equal(t, "~/.gordon/backups", v2.GetString("backups.storage_dir"))
1436 2052
1437- savedRoutes := loadStringMap(v2.Get("routes"))2053+ savedRoutes, err := loadCanonicalRoutes(v2.Get("routes"))
1438- assert.Equal(t, "newapp:latest", savedRoutes["new.example.com"])2054+ require.NoError(t, err)
1439- assert.Equal(t, "reg.example.com/myapp:latest", savedRoutes["app.example.com"])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)
1440 2061
1441 matches, err := filepath.Glob(configFile + ".bak.*")2062 matches, err := filepath.Glob(configFile + ".bak.*")
1442 require.NoError(t, err)2063 require.NoError(t, err)
M internal/usecase/container/autoroute.go +19 -45
@@ -1,7 +1,6 @@
1 package container1 package container
2 2
3 import (3 import (
4- "bufio"
5 "bytes"4 "bytes"
6 "context"5 "context"
7 "fmt"6 "fmt"
@@ -199,11 +198,13 @@ func (h *AutoRouteHandler) createOrUpdateRoute(ctx context.Context, routeDomain,
199 route := domain.Route{198 route := domain.Route{
200 Domain: routeDomain,199 Domain: routeDomain,
201 Image: imageName,200 Image: imageName,
201+ HTTPS: true,
202 }202 }
203 203
204 existingRoutes := h.configSvc.GetRoutes(ctx)204 existingRoutes := h.configSvc.GetRoutes(ctx)
205 for _, existing := range existingRoutes {205 for _, existing := range existingRoutes {
206 if existing.Domain == routeDomain {206 if existing.Domain == routeDomain {
207+ route.HTTPS = existing.HTTPS
207 if auto.ExtractRepoName(existing.Image, h.registryDomain) != auto.ExtractRepoName(imageName, h.registryDomain) {208 if auto.ExtractRepoName(existing.Image, h.registryDomain) != auto.ExtractRepoName(imageName, h.registryDomain) {
208 log.Warn().Str("domain", routeDomain).Str("existing_image", existing.Image).Str("image", imageName).Msg("auto-route update rejected due to repository ownership mismatch")209 log.Warn().Str("domain", routeDomain).Str("existing_image", existing.Image).Str("image", imageName).Msg("auto-route update rejected due to repository ownership mismatch")
209 return false210 return false
@@ -280,7 +281,7 @@ func (h *AutoRouteHandler) extractAndMergeEnvFile(ctx context.Context, imageRef,
280 }281 }
281 282
282 // Parse the extracted env file283 // Parse the extracted env file
283- imageEnv, err := parseEnvFile(envData)284+ imageEnv, err := domain.ParseEnvData(envData)
284 if err != nil {285 if err != nil {
285 return fmt.Errorf("failed to parse env file: %w", err)286 return fmt.Errorf("failed to parse env file: %w", err)
286 }287 }
@@ -290,6 +291,15 @@ func (h *AutoRouteHandler) extractAndMergeEnvFile(ctx context.Context, imageRef,
290 return nil291 return nil
291 }292 }
292 293
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+
293 // Load existing env file for this domain303 // Load existing env file for this domain
294 envFileName, err := domainToEnvFileName(routeDomain)304 envFileName, err := domainToEnvFileName(routeDomain)
295 if err != nil {305 if err != nil {
@@ -299,7 +309,13 @@ func (h *AutoRouteHandler) extractAndMergeEnvFile(ctx context.Context, imageRef,
299 309
300 existingEnv := make(map[string]string)310 existingEnv := make(map[string]string)
301 if data, err := os.ReadFile(envFileDst); err == nil {311 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)
303 }319 }
304 320
305 // Merge: image values are defaults, existing values win321 // Merge: image values are defaults, existing values win
@@ -352,48 +368,6 @@ func (h *AutoRouteHandler) extractLabels(ctx context.Context, manifestData []byt
352 return labels, nil368 return labels, nil
353 }369 }
354 370
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-
397 // domainToEnvFileName converts a domain to an env file name.371 // domainToEnvFileName converts a domain to an env file name.
398 // Must match the naming convention in envloader.FileLoader.getEnvFilePath.372 // Must match the naming convention in envloader.FileLoader.getEnvFilePath.
399 func domainToEnvFileName(domainName string) (string, error) {373 func domainToEnvFileName(domainName string) (string, error) {
M internal/usecase/container/autoroute_test.go +162 -11
@@ -4,6 +4,8 @@ import (
4 "context"4 "context"
5 "encoding/json"5 "encoding/json"
6 "io"6 "io"
7+ "os"
8+ "path/filepath"
7 "strings"9 "strings"
8 "testing"10 "testing"
9 11
@@ -62,7 +64,7 @@ func TestDomainToEnvFileName(t *testing.T) {
62 }64 }
63 }65 }
64 66
65-// parseEnvFile tests67+// ParseEnvData tests
66 68
67 func TestParseEnvFile(t *testing.T) {69 func TestParseEnvFile(t *testing.T) {
68 tests := []struct {70 tests := []struct {
@@ -142,7 +144,7 @@ func TestParseEnvFile(t *testing.T) {
142 144
143 for _, tt := range tests {145 for _, tt := range tests {
144 t.Run(tt.name, func(t *testing.T) {146 t.Run(tt.name, func(t *testing.T) {
145- result, err := parseEnvFile([]byte(tt.input))147+ result, err := domain.ParseEnvData([]byte(tt.input))
146 148
147 if tt.wantErr {149 if tt.wantErr {
148 assert.Error(t, err)150 assert.Error(t, err)
@@ -395,7 +397,6 @@ func TestAutoRouteHandler_CanHandle(t *testing.T) {
395 397
396 assert.True(t, handler.CanHandle(domain.EventImagePushed))398 assert.True(t, handler.CanHandle(domain.EventImagePushed))
397 assert.False(t, handler.CanHandle(domain.EventConfigReload))399 assert.False(t, handler.CanHandle(domain.EventConfigReload))
398- assert.False(t, handler.CanHandle(domain.EventManualReload))
399 }400 }
400 401
401 func TestAutoRouteHandler_Handle_DisabledAutoRoute(t *testing.T) {402 func TestAutoRouteHandler_Handle_DisabledAutoRoute(t *testing.T) {
@@ -508,12 +509,14 @@ func TestAutoRouteHandler_Handle_CreatesNewRoute(t *testing.T) {
508 configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{509 configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{
509 Domain: "app.example.com",510 Domain: "app.example.com",
510 Image: "myapp:latest",511 Image: "myapp:latest",
512+ HTTPS: true,
511 }).Return(nil)513 }).Return(nil)
512 514
513 // Expect deploy to be triggered515 // Expect deploy to be triggered
514 containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{516 containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
515 Domain: "app.example.com",517 Domain: "app.example.com",
516 Image: "myapp:latest",518 Image: "myapp:latest",
519+ HTTPS: true,
517 }).Return(&domain.Container{ID: "container-1"}, nil)520 }).Return(&domain.Container{ID: "container-1"}, nil)
518 521
519 event := domain.Event{522 event := domain.Event{
@@ -564,19 +567,21 @@ func TestAutoRouteHandler_Handle_UpdatesExistingRoute(t *testing.T) {
564 567
565 // Existing route with different image568 // Existing route with different image
566 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{569 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},
568 })571 })
569 572
570 // Expect route to be updated573 // Expect route to be updated
571 configSvc.EXPECT().UpdateRoute(mock.Anything, domain.Route{574 configSvc.EXPECT().UpdateRoute(mock.Anything, domain.Route{
572 Domain: "app.example.com",575 Domain: "app.example.com",
573 Image: "myapp:v2",576 Image: "myapp:v2",
577+ HTTPS: true,
574 }).Return(nil)578 }).Return(nil)
575 579
576 // Expect deploy to be triggered580 // Expect deploy to be triggered
577 containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{581 containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
578 Domain: "app.example.com",582 Domain: "app.example.com",
579 Image: "myapp:v2",583 Image: "myapp:v2",
584+ HTTPS: true,
580 }).Return(&domain.Container{ID: "container-1"}, nil)585 }).Return(&domain.Container{ID: "container-1"}, nil)
581 586
582 event := domain.Event{587 event := domain.Event{
@@ -733,14 +738,17 @@ func TestAutoRouteHandler_Handle_MultipleDomains(t *testing.T) {
733 configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{738 configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{
734 Domain: "app.example.com",739 Domain: "app.example.com",
735 Image: "myapp:latest",740 Image: "myapp:latest",
741+ HTTPS: true,
736 }).Return(nil)742 }).Return(nil)
737 configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{743 configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{
738 Domain: "api.example.com",744 Domain: "api.example.com",
739 Image: "myapp:latest",745 Image: "myapp:latest",
746+ HTTPS: true,
740 }).Return(nil)747 }).Return(nil)
741 configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{748 configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{
742 Domain: "www.example.com",749 Domain: "www.example.com",
743 Image: "myapp:latest",750 Image: "myapp:latest",
751+ HTTPS: true,
744 }).Return(nil)752 }).Return(nil)
745 753
746 // Expect deploy for all three754 // Expect deploy for all three
@@ -786,8 +794,8 @@ func TestAutoRouteHandler_NewDomain_Allowed(t *testing.T) {
786 handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")794 handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
787 795
788 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{})796 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)797+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:latest", HTTPS: true}).Return(nil)
790- containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:latest"}).Return(&domain.Container{ID: "c1"}, nil)798+ containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:latest", HTTPS: true}).Return(&domain.Container{ID: "c1"}, nil)
791 799
792 created := handler.createOrUpdateRoute(context.Background(), "app.example.com", "myapp:latest", []string{"*.example.com"})800 created := handler.createOrUpdateRoute(context.Background(), "app.example.com", "myapp:latest", []string{"*.example.com"})
793 assert.True(t, created)801 assert.True(t, created)
@@ -813,9 +821,9 @@ func TestAutoRouteHandler_ExistingDomain_SameRepo(t *testing.T) {
813 blobStorage := mocks.NewMockBlobStorage(t)821 blobStorage := mocks.NewMockBlobStorage(t)
814 handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")822 handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
815 823
816- configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{Domain: "app.example.com", Image: "myapp:v1"}})824+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{Domain: "app.example.com", Image: "myapp:v1", HTTPS: true}})
817- configSvc.EXPECT().UpdateRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:v2"}).Return(nil)825+ configSvc.EXPECT().UpdateRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:v2", HTTPS: true}).Return(nil)
818- containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:v2"}).Return(&domain.Container{ID: "c1"}, nil)826+ containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:v2", HTTPS: true}).Return(&domain.Container{ID: "c1"}, nil)
819 827
820 created := handler.createOrUpdateRoute(context.Background(), "app.example.com", "myapp:v2", []string{"*.example.com"})828 created := handler.createOrUpdateRoute(context.Background(), "app.example.com", "myapp:v2", []string{"*.example.com"})
821 assert.False(t, created)829 assert.False(t, created)
@@ -845,8 +853,8 @@ func TestAutoRouteHandler_EnvExtractionOnlyOnCreate(t *testing.T) {
845 853
846 configSvc.EXPECT().GetAutoRouteAllowedDomains(mock.Anything).Return([]string{"*.example.com"}, nil).Twice()854 configSvc.EXPECT().GetAutoRouteAllowedDomains(mock.Anything).Return([]string{"*.example.com"}, nil).Twice()
847 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}).Once()855 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)856+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:latest", HTTPS: true}).Return(nil)
849- containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:latest"}).Return(&domain.Container{ID: "c1"}, nil)857+ containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:latest", HTTPS: true}).Return(&domain.Container{ID: "c1"}, nil)
850 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{Domain: "app.example.com", Image: "myapp:latest"}}).Once()858 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{Domain: "app.example.com", Image: "myapp:latest"}}).Once()
851 859
852 handler.processRoutes(context.Background(), []string{"app.example.com"}, "myapp:latest", &domain.ImageLabels{EnvFile: ".env"})860 handler.processRoutes(context.Background(), []string{"app.example.com"}, "myapp:latest", &domain.ImageLabels{EnvFile: ".env"})
@@ -857,21 +865,164 @@ func TestAutoRouteHandler_EnvExtractionOnlyOnCreate(t *testing.T) {
857 assert.Equal(t, ".env", extractor.lastPath)865 assert.Equal(t, ".env", extractor.lastPath)
858 }866 }
859 867
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+
860 type testEnvExtractor struct {890 type testEnvExtractor struct {
861 calls int891 calls int
862 lastImage string892 lastImage string
863 lastPath string893 lastPath string
894+ data []byte
895+ err error
864 }896 }
865 897
866 func (e *testEnvExtractor) ExtractEnvFileFromImage(_ context.Context, imageRef, envFilePath string) ([]byte, error) {898 func (e *testEnvExtractor) ExtractEnvFileFromImage(_ context.Context, imageRef, envFilePath string) ([]byte, error) {
867 e.calls++899 e.calls++
868 e.lastImage = imageRef900 e.lastImage = imageRef
869 e.lastPath = envFilePath901 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+ }
870 return []byte("FOO=bar\n"), nil908 return []byte("FOO=bar\n"), nil
871 }909 }
872 910
873 // collectDomains tests911 // collectDomains tests
874 912
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+
875 func TestAutoRouteHandler_CollectDomains(t *testing.T) {1026 func TestAutoRouteHandler_CollectDomains(t *testing.T) {
876 ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())1027 ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
877 configSvc := inmocks.NewMockConfigService(t)1028 configSvc := inmocks.NewMockConfigService(t)
M internal/usecase/container/events.go +0 -85
@@ -175,91 +175,6 @@ func (h *ConfigReloadHandler) CanHandle(eventType domain.EventType) bool {
175 return eventType == domain.EventConfigReload175 return eventType == domain.EventConfigReload
176 }176 }
177 177
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-
263 // ManualDeployHandler handles manual.deploy events for specific routes.178 // ManualDeployHandler handles manual.deploy events for specific routes.
264 type ManualDeployHandler struct {179 type ManualDeployHandler struct {
265 containerSvc in.ContainerService180 containerSvc in.ContainerService
M internal/usecase/container/events_test.go +0 -151
@@ -27,7 +27,6 @@ func TestImagePushedHandler_CanHandle(t *testing.T) {
27 27
28 assert.True(t, handler.CanHandle(domain.EventImagePushed))28 assert.True(t, handler.CanHandle(domain.EventImagePushed))
29 assert.False(t, handler.CanHandle(domain.EventConfigReload))29 assert.False(t, handler.CanHandle(domain.EventConfigReload))
30- assert.False(t, handler.CanHandle(domain.EventManualReload))
31 }30 }
32 31
33 func TestImagePushedHandler_Handle_DeploysMatchingRoutes(t *testing.T) {32 func TestImagePushedHandler_Handle_DeploysMatchingRoutes(t *testing.T) {
@@ -197,7 +196,6 @@ func TestConfigReloadHandler_CanHandle(t *testing.T) {
197 196
198 assert.True(t, handler.CanHandle(domain.EventConfigReload))197 assert.True(t, handler.CanHandle(domain.EventConfigReload))
199 assert.False(t, handler.CanHandle(domain.EventImagePushed))198 assert.False(t, handler.CanHandle(domain.EventImagePushed))
200- assert.False(t, handler.CanHandle(domain.EventManualReload))
201 }199 }
202 200
203 func TestConfigReloadHandler_Handle_DeploysNewRoutes(t *testing.T) {201 func TestConfigReloadHandler_Handle_DeploysNewRoutes(t *testing.T) {
@@ -360,154 +358,6 @@ func TestConfigReloadHandler_Handle_NoChanges(t *testing.T) {
360 assert.NoError(t, err)358 assert.NoError(t, err)
361 }359 }
362 360
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-
511 // ManualDeployHandler tests361 // ManualDeployHandler tests
512 362
513 func TestConfigReloadHandlerUpdatesContainerConfig(t *testing.T) {363 func TestConfigReloadHandlerUpdatesContainerConfig(t *testing.T) {
@@ -552,7 +402,6 @@ func TestManualDeployHandler_CanHandle(t *testing.T) {
552 402
553 assert.True(t, handler.CanHandle(domain.EventManualDeploy))403 assert.True(t, handler.CanHandle(domain.EventManualDeploy))
554 assert.False(t, handler.CanHandle(domain.EventImagePushed))404 assert.False(t, handler.CanHandle(domain.EventImagePushed))
555- assert.False(t, handler.CanHandle(domain.EventManualReload))
556 }405 }
557 406
558 func TestManualDeployHandler_Handle_DeploysSpecificRoute(t *testing.T) {407 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
52 ContainerStatus: "unknown",52 ContainerStatus: "unknown",
53 }53 }
54 54
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+
55 // Check container status62 // Check container status
56 container, exists := s.containerSvc.Get(ctx, route.Domain)63 container, exists := s.containerSvc.Get(ctx, route.Domain)
57 if !exists || container == nil {64 if !exists || container == nil {
@@ -71,7 +78,11 @@ func (s *Service) CheckRoute(ctx context.Context, route domain.Route) *domain.Ro
71 }78 }
72 79
73 // Probe HTTP endpoint80 // 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)
75 statusCode, responseTime, err := s.prober.Probe(ctx, url)86 statusCode, responseTime, err := s.prober.Probe(ctx, url)
76 if err != nil {87 if err != nil {
77 health.Error = err.Error()88 health.Error = err.Error()
M internal/usecase/health/service_test.go +66 -7
@@ -25,7 +25,7 @@ func TestService_CheckRoute_ContainerNotFound(t *testing.T) {
25 containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(nil, false)25 containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(nil, false)
26 26
27 svc := NewService(configSvc, containerSvc, prober, testLogger())27 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}
29 29
30 health := svc.CheckRoute(context.Background(), route)30 health := svc.CheckRoute(context.Background(), route)
31 31
@@ -48,7 +48,7 @@ func TestService_CheckRoute_ContainerNotRunning(t *testing.T) {
48 containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(container, true)48 containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(container, true)
49 49
50 svc := NewService(configSvc, containerSvc, prober, testLogger())50 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}
52 52
53 health := svc.CheckRoute(context.Background(), route)53 health := svc.CheckRoute(context.Background(), route)
54 54
@@ -72,7 +72,32 @@ func TestService_CheckRoute_HTTPProbeSuccess(t *testing.T) {
72 prober.EXPECT().Probe(mock.Anything, "https://app.example.com/").Return(200, int64(45), nil)72 prober.EXPECT().Probe(mock.Anything, "https://app.example.com/").Return(200, int64(45), nil)
73 73
74 svc := NewService(configSvc, containerSvc, prober, testLogger())74 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}
76 101
77 health := svc.CheckRoute(context.Background(), route)102 health := svc.CheckRoute(context.Background(), route)
78 103
@@ -97,7 +122,7 @@ func TestService_CheckRoute_HTTPProbeFailure(t *testing.T) {
97 prober.EXPECT().Probe(mock.Anything, "https://app.example.com/").Return(0, int64(0), errors.New("connection refused"))122 prober.EXPECT().Probe(mock.Anything, "https://app.example.com/").Return(0, int64(0), errors.New("connection refused"))
98 123
99 svc := NewService(configSvc, containerSvc, prober, testLogger())124 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}
101 126
102 health := svc.CheckRoute(context.Background(), route)127 health := svc.CheckRoute(context.Background(), route)
103 128
@@ -121,7 +146,7 @@ func TestService_CheckRoute_HTTPStatus5xx(t *testing.T) {
121 prober.EXPECT().Probe(mock.Anything, "https://app.example.com/").Return(502, int64(100), nil)146 prober.EXPECT().Probe(mock.Anything, "https://app.example.com/").Return(502, int64(100), nil)
122 147
123 svc := NewService(configSvc, containerSvc, prober, testLogger())148 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}
125 150
126 health := svc.CheckRoute(context.Background(), route)151 health := svc.CheckRoute(context.Background(), route)
127 152
@@ -139,8 +164,8 @@ func TestService_CheckAllRoutes(t *testing.T) {
139 prober := mocks.NewMockHTTPProber(t)164 prober := mocks.NewMockHTTPProber(t)
140 165
141 routes := []domain.Route{166 routes := []domain.Route{
142- {Domain: "app1.example.com", Image: "app1:latest"},167+ {Domain: "app1.example.com", Image: "app1:latest", HTTPS: true},
143- {Domain: "app2.example.com", Image: "app2:latest"},168+ {Domain: "app2.example.com", Image: "app2:latest", HTTPS: true},
144 }169 }
145 configSvc.EXPECT().GetRoutes(mock.Anything).Return(routes)170 configSvc.EXPECT().GetRoutes(mock.Anything).Return(routes)
146 171
@@ -182,3 +207,37 @@ func TestService_CheckAllRoutes_NoRoutes(t *testing.T) {
182 207
183 assert.Empty(t, results)208 assert.Empty(t, results)
184 }209 }
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