detached 7289826

refactor(cli)!: v3 command layout and remove domain secrets (#296)

* refactor(cli)!: remove domain secrets, move push under images, add daemon group

- Remove the v2 per-domain installation secrets stack (CLI, admin
  /secrets API, SecretService, domain secret store, env loader, env file
  migration, [env] config). No v3 code path read these values.
- /admin/secrets now answers 410 endpoint-retired.
- gordon push -> gordon images push.
- gordon status/logs/reload/config/tls/traffic/networks -> gordon daemon <cmd>.
- tls, traffic and networks are single commands under daemon.

* docs: document daemon and images push commands, drop domain secrets

- Add docs/cli/daemon.md covering status, logs, reload, config, tls,
  traffic, and networks; remove the per-command pages it replaces.
- Fold push into docs/cli/images.md.
- Remove gordon secrets, [env], env-variables, and admin:secrets docs;
  point users to gordon apps secrets.
- Reject a leftover [env] config section with a config-retired
  diagnostic; drop dead env/container-name domain helpers.
- Update README, guides, wiki, CI examples, and upgrade notes.
commit
728982699cf79c19209dccec752b5b5f69d50844
author
bnema · · 2026-09-26 02:13 UTC
committer
GitHub · · 2026-09-26 02:13 UTC
parent
7bcdeff
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v3.0.0-alpha.2
116 files · +876 -8390
M .mockery.yaml +0 -3
@@ -17,7 +17,6 @@ packages:
17 EventSubscriber:17 EventSubscriber:
18 EventHandler:18 EventHandler:
19 SecretProvider:19 SecretProvider:
20- EnvLoader:
21 ContainerLogWriter:20 ContainerLogWriter:
22 Metrics:21 Metrics:
23 LogExporter:22 LogExporter:
@@ -27,7 +26,6 @@ packages:
27 AppStateReader:26 AppStateReader:
28 ImageResolver:27 ImageResolver:
29 SecretWriter:28 SecretWriter:
30- DomainSecretStore:
31 RateLimiter:29 RateLimiter:
32 BackupStorage:30 BackupStorage:
33 HTTPChallengeSink:31 HTTPChallengeSink:
@@ -52,7 +50,6 @@ packages:
52 AuthService:50 AuthService:
53 HealthService:51 HealthService:
54 HTTPProber:52 HTTPProber:
55- SecretService:
56 LogService:53 LogService:
57 VolumeService:54 VolumeService:
58 PublicTLSService:55 PublicTLSService:
M README.md +9 -8
@@ -38,7 +38,7 @@ Build locally, push to your Gordon server, declare the app, deploy:
38 38
39 ```bash39 ```bash
40 # Push the image (OCI transfer only, never deploys)40 # Push the image (OCI transfer only, never deploys)
41-gordon push myapp --build --remote prod41+gordon images push myapp --build --remote prod
42 42
43 # Declare the app (blog.toml references the pushed tag)43 # Declare the app (blog.toml references the pushed tag)
44 gordon apps apply --file blog.toml --remote prod44 gordon apps apply --file blog.toml --remote prod
@@ -47,7 +47,7 @@ gordon apps apply --file blog.toml --remote prod
47 gordon apps deploy blog --remote prod47 gordon apps deploy blog --remote prod
48 48
49 # Check status49 # Check status
50-gordon status50+gordon daemon status
51 gordon apps status blog --remote prod51 gordon apps status blog --remote prod
52 ```52 ```
53 53
@@ -106,8 +106,10 @@ gordon apps deploy blog --remote prod
106 | Command | Description |106 | Command | Description |
107 |---------|-------------|107 |---------|-------------|
108 | `gordon serve` | Start the Gordon server |108 | `gordon serve` | Start the Gordon server |
109-| `gordon status` | Show server and app fleet status |109+| `gordon daemon status` | Show server and app fleet status |
110-| `gordon config show` | Display installation configuration |110+| `gordon daemon logs` | Show Gordon process logs |
111+| `gordon daemon reload` | Reload installation configuration |
112+| `gordon daemon config show` | Display installation configuration |
111 113
112 ### Applications114 ### Applications
113 115
@@ -123,23 +125,22 @@ gordon apps deploy blog --remote prod
123 | `gordon apps stop APP` | Stop, preserve all data |125 | `gordon apps stop APP` | Stop, preserve all data |
124 | `gordon apps start APP` | Start a stopped app |126 | `gordon apps start APP` | Start a stopped app |
125 | `gordon apps remove APP` | Remove workloads (volumes/secrets retained) |127 | `gordon apps remove APP` | Remove workloads (volumes/secrets retained) |
126-| `gordon push [image]` | Tag and push an image (never deploys) |
127 128
128 ### Images & Registry129 ### Images & Registry
129 130
130 | Command | Description |131 | Command | Description |
131 |---------|-------------|132 |---------|-------------|
133+| `gordon images push [image]` | Tag and push an image (never deploys) |
132 | `gordon images list` | List runtime and registry images |134 | `gordon images list` | List runtime and registry images |
133 | `gordon images prune` | Clean up dangling images and old tags |135 | `gordon images prune` | Clean up dangling images and old tags |
134 | `gordon images tags <repo>` | List registry tags for a repository |136 | `gordon images tags <repo>` | List registry tags for a repository |
135 137
136-### Secrets & Config138+### Secrets
137 139
138 | Command | Description |140 | Command | Description |
139 |---------|-------------|141 |---------|-------------|
142+| `gordon apps secrets list APP` | List app secret names |
140 | `gordon apps secrets set APP --service SVC KEY=VAL` | Set app secret values |143 | `gordon apps secrets set APP --service SVC KEY=VAL` | Set app secret values |
141-| `gordon secrets list <domain>` | List installation secret keys |
142-| `gordon secrets set <domain> --from-file PATH` | Set installation secrets |
143 144
144 ### Remotes & Auth145 ### Remotes & Auth
145 146
M docs/cli/apps.md +2 -2
@@ -364,7 +364,7 @@ never accepted. `--service` is required when the app has several services.
364 364
365 ```bash365 ```bash
366 # Push the image first (OCI transfer only)366 # Push the image first (OCI transfer only)
367-gordon push myapp --build --remote prod367+gordon images push myapp --build --remote prod
368 368
369 # Apply the manifest that references the pushed tag369 # Apply the manifest that references the pushed tag
370 gordon apps apply --file blog.toml --remote prod370 gordon apps apply --file blog.toml --remote prod
@@ -386,5 +386,5 @@ gordon apps status blog --remote prod
386 ## Related386 ## Related
387 387
388 - [CLI Overview](./index.md)388 - [CLI Overview](./index.md)
389-- [Push Command](./push.md)389+- [Images Commands](./images.md#gordon-images-push)
390 - [Deployment](../deployment/index.md)390 - [Deployment](../deployment/index.md)
M docs/cli/auth.md +1 -3
@@ -135,7 +135,7 @@ Standard Go durations also work: `24h`, `30m`, `1h30m`
135 135
136 Registry scopes: `push`, `pull`, `push,pull`136 Registry scopes: `push`, `pull`, `push,pull`
137 137
138-Admin scopes: `admin:*:*`, `admin:apps:read`, `admin:apps:write`, `admin:config:read`, `admin:config:write`, `admin:status:read`, `admin:logs:read`, `admin:volumes:read`, `admin:volumes:write`, `admin:secrets:read`, `admin:secrets:write`138+Admin scopes: `admin:*:*`, `admin:apps:read`, `admin:apps:write`, `admin:config:read`, `admin:config:write`, `admin:status:read`, `admin:logs:read`, `admin:volumes:read`, `admin:volumes:write`
139 139
140 Combine scopes with commas:140 Combine scopes with commas:
141 141
@@ -277,8 +277,6 @@ Usage:
277 | `admin:config:write` | Config write access |277 | `admin:config:write` | Config write access |
278 | `admin:status:read` | Read-only status/health |278 | `admin:status:read` | Read-only status/health |
279 | `admin:logs:read` | Read-only logs access |279 | `admin:logs:read` | Read-only logs access |
280-| `admin:secrets:read` | List secret keys |
281-| `admin:secrets:write` | Set/delete secrets |
282 280
283 ## Token Expiry Formats281 ## Token Expiry Formats
284 282
D docs/cli/config.md +0 -112
@@ -1,112 +0,0 @@
1-# Config Commands
2-
3-Inspect Gordon server configuration.
4-
5-Remote targeting uses client config or an active remote by default.
6-Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
7-
8-## gordon config
9-
10-### Subcommands
11-
12-| Subcommand | Description |
13-|------------|-------------|
14-| `show` | Show server configuration |
15-| `validate` | Statically validate a local configuration file |
16-
17----
18-
19-## gordon config show
20-
21-Display the Gordon installation configuration including server settings,
22-network isolation, volumes, and external route domains. App routes live
23-under `gordon apps show`, never here. Sensitive filesystem paths and upstream external route targets are redacted by default.
24-
25-```bash
26-gordon config show
27-gordon config show --json
28-gordon config show --remote https://gordon.mydomain.com --token $TOKEN
29-```
30-
31-### Flags
32-
33-| Flag | Description |
34-|------|-------------|
35-| `--json` | Output as JSON |
36-
37-### JSON Output
38-
39-```json
40-{
41- "server": {
42- "port": 1111,
43- "registry_port": 5000,
44- "registry_domain": "reg.example.com"
45- },
46- "network_isolation": {
47- "enabled": true,
48- "prefix": "gordon"
49- },
50- "volumes": {
51- "auto_create": true,
52- "prefix": "gordon",
53- "preserve": true
54- },
55- "external_routes": [
56- {"domain": "reg.example.com"}
57- ]
58-}
59-```
60-
61-External route targets and `server.data_dir` are intentionally omitted from the default admin config response because they reveal internal network and filesystem layout.
62-
63----
64-
65-## gordon config validate
66-
67-Statically validates a candidate configuration file before it is installed.
68-This command is local-only: `--remote` is rejected. Validation is static —
69-runtime, ACTIVE-state, secret, pull, and listener checks are not performed.
70-A file that fails validation exits non-zero; `--json` is still written first.
71-
72-```bash
73-gordon config validate --file ./gordon.toml
74-gordon config validate --file ./gordon.toml --json
75-```
76-
77-### Flags
78-
79-| Flag | Description |
80-|------|-------------|
81-| `--file` | Local candidate configuration file (required) |
82-| `--json` | Output as JSON |
83-
84-### JSON Output
85-
86-On success:
87-
88-```json
89-{
90- "valid": true,
91- "diagnostics": [],
92- "scope": "static"
93-}
94-```
95-
96-On failure, `valid` is `false` and `diagnostics` carries the failure:
97-
98-```json
99-{
100- "valid": false,
101- "diagnostics": [
102- {"code": "config-invalid", "key": "", "message": "configuration failed static validation"}
103- ],
104- "scope": "static"
105-}
106-```
107-
108-## Related
109-
110-- [CLI Overview](./index.md)
111-- [Status Command](./status.md)
112-- [Apps Commands](./apps.md)
A docs/cli/daemon.md +518 -0
@@ -0,0 +1,518 @@
1+# Daemon Commands
2+
3+Inspect and operate the Gordon daemon: status, process logs, reload, configuration, TLS, traffic, and networks.
4+
5+These commands target the local daemon through its owner-only socket, or the daemon selected with `--remote`, `GORDON_REMOTE`, or the active remote. See [CLI Overview](./index.md).
6+
7+## gordon daemon
8+
9+| Subcommand | Description |
10+|------------|-------------|
11+| `status` | Show daemon status and the app fleet summary |
12+| `logs` | Show daemon process logs |
13+| `reload` | Reload installation configuration |
14+| `config show` | Show installation configuration |
15+| `config validate` | Statically validate a local configuration file |
16+| `tls` | Show public TLS certificate status |
17+| `traffic` | Show traffic entrypoint, router, and counter status |
18+| `networks` | List Gordon-managed networks |
19+
20+---
21+
22+## gordon daemon status
23+
24+Display installation identity plus one status line per app (from desired/active state, no container inspection). Per-service detail lives under `gordon apps show APP` and `gordon apps status APP`.
25+
26+```bash
27+gordon daemon status
28+gordon daemon status --remote prod
29+```
30+
31+`gordon daemon status` works in local mode and remote mode.
32+
33+- Local mode reads status from in-process services.
34+- Remote mode reads status from the target admin API.
35+
36+### Output
37+
38+```
39+Gordon Status
40+
41+Gordon Domain: gordon.example.com
42+Registry Port: 5000
43+Server Port: 8088
44+Apps: 3
45+Network Isolation: false
46+
47+Container Status:
48+ blog: active
49+ shop: deploying
50+ old-site: stopped
51+```
52+
53+### Information Displayed
54+
55+| Field | Description |
56+|-------|-------------|
57+| Gordon Domain | Public Gordon domain from configuration |
58+| Registry Port | Docker registry port |
59+| Server Port | Gordon admin port |
60+| Apps | Total apps in desired/active state |
61+| Network Isolation | Whether installation network policy is enabled |
62+| Container Status | Fleet status per app (see states below) |
63+
64+### App States
65+
66+| State | Description |
67+|-------|-------------|
68+| active | App deployed and converged on desired state |
69+| deploying | Desired state diverges from effective state |
70+| pending | App applied but never deployed |
71+| stopped | Durable stopped intent (stays stopped across reboot) |
72+
73+### Flags
74+
75+Uses the global remote flags:
76+
77+| Flag | Description |
78+|------|-------------|
79+| `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
80+| `--token-file` | Read the remote token from a mode 0600 file |
81+
82+### Environment Variables
83+
84+| Variable | Description |
85+|----------|-------------|
86+| `GORDON_REMOTE` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
87+| `GORDON_TOKEN` | Authentication token |
88+
89+### Examples
90+
91+### Check Local or Remote Status
92+
93+```bash
94+# Local
95+gordon daemon status
96+
97+# Using a saved remote
98+gordon daemon status --remote prod
99+
100+# Using environment variables
101+export GORDON_REMOTE=https://gordon.mydomain.com
102+export GORDON_TOKEN=your-token
103+gordon daemon status
104+```
105+
106+### Quick Fleet Check
107+
108+```bash
109+# Check for non-converged apps
110+gordon daemon status --remote prod | grep -E "(deploying|pending|stopped)"
111+```
112+
113+### Required Permissions (Remote Only)
114+
115+Remote status calls require `admin:status:read` scope in the authentication token.
116+
117+```bash
118+# Generate token with required scope
119+gordon auth token generate --subject admin --scopes admin:status:read
120+```
121+
122+---
123+
124+## gordon daemon logs
125+
126+Display Gordon daemon process logs. Application workload output is read with
127+`gordon apps logs APP --service SVC`, which resolves the app's active container
128+through the daemon.
129+
130+### Synopsis
131+
132+```bash
133+gordon daemon logs [options]
134+```
135+
136+### Options
137+
138+| Option | Short | Default | Description |
139+|--------|-------|---------|-------------|
140+| `--config` | `-c` | Auto | Path to config file |
141+| `--follow` | `-f` | false | Follow log output (like `tail -f`) |
142+| `--lines` | `-n` | 50 | Number of lines to show |
143+| `--remote, -r` | | | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
144+| `--token-file` | | | Read the remote token from a mode 0600 file |
145+
146+Remote targeting uses client config or an active remote by default. Use
147+`--remote` and `--token-file` to override. See [CLI Overview](./index.md).
148+
149+Remote log access requires an admin token with `admin:logs:read` (or `admin:*:*`). `admin:status:read` is not sufficient for logs.
150+
151+### Examples
152+
153+```bash
154+# Gordon process logs
155+gordon daemon logs # Last 50 lines
156+gordon daemon logs -f # Follow logs
157+gordon daemon logs -n 100 # Last 100 lines
158+gordon daemon logs -f -n 200 # Follow, starting from last 200 lines
159+
160+# App service logs
161+gordon apps logs blog --service web
162+gordon apps logs blog --service web --follow --remote prod
163+
164+# Remote process logs (override)
165+gordon daemon logs --remote prod
166+```
167+
168+### Log Locations
169+
170+```bash
171+# Using gordon daemon logs
172+gordon daemon logs -f
173+
174+# Direct file access
175+tail -f ~/.gordon/logs/gordon.log
176+
177+# With systemd
178+journalctl --user -u gordon -f
179+
180+# App service logs through Gordon
181+gordon apps logs blog --service web --tail 50
182+gordon apps logs blog --service web --follow
183+```
184+
185+---
186+
187+## gordon daemon reload
188+
189+Reload installation configuration.
190+
191+### Synopsis
192+
193+```bash
194+gordon daemon reload
195+```
196+
197+### Description
198+
199+Sends `SIGUSR1` to the running Gordon process, triggering:
200+
201+- Installation settings reload (live keys apply, restart-required keys are reported)
202+- Traffic/proxy state refresh from ACTIVE app projection
203+
204+Reload never activates pending desired app state, re-resolves images, or flips intent. Obsolete application keys (`routes`, `attachments`, `services`, `auto`, previews, …) are rejected with a `config-retired` diagnostic before any mutation.
205+
206+### Example
207+
208+```bash
209+# After editing gordon.toml, apply changes without restart
210+vim ~/.config/gordon/gordon.toml
211+gordon daemon reload
212+```
213+
214+---
215+
216+## gordon daemon config show
217+
218+Display the Gordon installation configuration including server settings,
219+network isolation, volumes, and external route domains. App routes live
220+under `gordon apps show`, never here. Sensitive filesystem paths and upstream external route targets are redacted by default.
221+
222+```bash
223+gordon daemon config show
224+gordon daemon config show --json
225+gordon daemon config show --remote prod
226+```
227+
228+### Flags
229+
230+| Flag | Description |
231+|------|-------------|
232+| `--json` | Output as JSON |
233+
234+### JSON Output
235+
236+```json
237+{
238+ "server": {
239+ "port": 1111,
240+ "registry_port": 5000,
241+ "registry_domain": "reg.example.com"
242+ },
243+ "network_isolation": {
244+ "enabled": true,
245+ "prefix": "gordon"
246+ },
247+ "volumes": {
248+ "auto_create": true,
249+ "prefix": "gordon",
250+ "preserve": true
251+ },
252+ "external_routes": [
253+ {"domain": "reg.example.com"}
254+ ]
255+}
256+```
257+
258+External route targets and `server.data_dir` are intentionally omitted from the default admin config response because they reveal internal network and filesystem layout.
259+
260+---
261+
262+## gordon daemon config validate
263+
264+Statically validates a candidate configuration file before it is installed.
265+This command is local-only: `--remote` is rejected. Validation is static —
266+runtime, ACTIVE-state, secret, pull, and listener checks are not performed.
267+A file that fails validation exits non-zero; `--json` is still written first.
268+
269+```bash
270+gordon daemon config validate --file ./gordon.toml
271+gordon daemon config validate --file ./gordon.toml --json
272+```
273+
274+### Flags
275+
276+| Flag | Description |
277+|------|-------------|
278+| `--file` | Local candidate configuration file (required) |
279+| `--json` | Output as JSON |
280+
281+### JSON Output
282+
283+On success:
284+
285+```json
286+{
287+ "valid": true,
288+ "diagnostics": [],
289+ "scope": "static"
290+}
291+```
292+
293+On failure, `valid` is `false` and `diagnostics` carries the failure:
294+
295+```json
296+{
297+ "valid": false,
298+ "diagnostics": [
299+ {"code": "config-invalid", "key": "", "message": "configuration failed static validation"}
300+ ],
301+ "scope": "static"
302+}
303+```
304+
305+---
306+
307+## gordon daemon tls
308+
309+Inspect public TLS/ACME certificate status.
310+
311+Gordon serves normal HTTPS fallback on TLS-capable entrypoints such as `entrypoints.edge` with `protocol = "smart_tcp"`. Certificate priority is static certificates first, then public ACME certificates, then Gordon's internal CA.
312+
313+ACME challenge notes:
314+
315+- DNS-01 (`cloudflare-dns-01`) does not require a special external port 80 edge.
316+- HTTP-01 requires an HTTP-capable smart TCP entrypoint reachable on external port 80 for every hostname being validated.
317+- TLS-ALPN-01 is not supported.
318+
319+Display the current public TLS/ACME certificate status, including ACME mode,
320+certificate details, route coverage, and any errors.
321+
322+```bash
323+gordon daemon tls
324+gordon daemon tls --json
325+gordon daemon tls --remote prod
326+```
327+
328+### Flags
329+
330+| Flag | Description |
331+|------|-------------|
332+| `--json` | Output as JSON |
333+
334+### Human Output
335+
336+```text
337+Public TLS / ACME Status
338+
339+ACME: enabled
340+Configured Mode: auto
341+Effective Mode: http-01
342+Reason: configured
343+Token Source: env
344+
345+Certificates
346+ ID: cert-abc123
347+ Names: example.com, www.example.com
348+ Status: valid
349+ Not After: 2026-05-29 12:00:00
350+
351+Route Coverage
352+ example.com covered=yes covered_by=cert-abc123
353+ internal.local covered=no error=self-signed cert
354+
355+Errors
356+ route internal.local has no ACME cert
357+```
358+
359+### JSON Output
360+
361+```json
362+{
363+ "acme_enabled": true,
364+ "configured_mode": "auto",
365+ "effective_mode": "http-01",
366+ "selection_reason": "configured",
367+ "token_source": "env",
368+ "certificates": [
369+ {
370+ "id": "cert-abc123",
371+ "names": ["example.com", "www.example.com"],
372+ "challenge": "http-01",
373+ "status": "valid",
374+ "not_after": "2026-05-29T12:00:00Z",
375+ "renewal_pending": false
376+ }
377+ ],
378+ "routes": [
379+ {
380+ "domain": "example.com",
381+ "covered": true,
382+ "covered_by": "cert-abc123",
383+ "required_acme": true
384+ },
385+ {
386+ "domain": "internal.local",
387+ "covered": false,
388+ "required_acme": false,
389+ "error": "self-signed cert"
390+ }
391+ ],
392+ "errors": ["route internal.local has no ACME cert"]
393+}
394+```
395+
396+### Token Source
397+
398+The `token_source` field indicates where the ACME token was sourced from
399+(e.g., `env`, `file`, `config`). The token value is never displayed.
400+
401+---
402+
403+## gordon daemon traffic
404+
405+```bash
406+gordon daemon traffic --remote prod
407+gordon daemon traffic --remote https://gordon.example.com --json
408+```
409+
410+Remote mode queries the running Gordon admin API. Local mode does not synthesize runtime traffic state from a fresh config load; use a configured remote target for authoritative counters and reload status.
411+
412+### Flags
413+
414+| Flag | Description |
415+|------|-------------|
416+| `--json` | Output machine-readable JSON |
417+| `--remote`, `-r` | Remote Gordon instance or saved remote name |
418+
419+### JSON Output
420+
421+```json
422+{
423+ "last_reload_status": "ok",
424+ "entrypoints": [
425+ {
426+ "name": "postgres",
427+ "address": "0.0.0.0:5432",
428+ "protocol": "tcp",
429+ "active": true,
430+ "active_tcp_connections": 1,
431+ "active_udp_sessions": 0,
432+ "total_accepted": 12,
433+ "total_refused": 0,
434+ "total_errors": 0,
435+ "bytes_in": 4096,
436+ "bytes_out": 8192,
437+ "smart_tcp": {
438+ "http_accepted": 0,
439+ "h2c_accepted": 0,
440+ "https_fallback_accepted": 0,
441+ "tls_passthrough_accepted": 0,
442+ "raw_fallback_accepted": 0,
443+ "entrypoint_cidr_refused": 0,
444+ "raw_fallback_cidr_refused": 0,
445+ "proxy_refused": 0,
446+ "unknown_no_fallback_refused": 0,
447+ "malformed_rejected": 0,
448+ "sniff_timeout": 0,
449+ "client_hello_too_large": 0
450+ }
451+ }
452+ ],
453+ "routers": [],
454+ "services": [],
455+ "counters": {
456+ "active_tcp_connections": 1,
457+ "active_udp_sessions": 0,
458+ "total_accepted": 12,
459+ "total_refused": 0,
460+ "total_errors": 0,
461+ "bytes_in": 4096,
462+ "bytes_out": 8192,
463+ "smart_tcp": {
464+ "http_accepted": 0,
465+ "h2c_accepted": 0,
466+ "https_fallback_accepted": 0,
467+ "tls_passthrough_accepted": 0,
468+ "raw_fallback_accepted": 0,
469+ "entrypoint_cidr_refused": 0,
470+ "raw_fallback_cidr_refused": 0,
471+ "proxy_refused": 0,
472+ "unknown_no_fallback_refused": 0,
473+ "malformed_rejected": 0,
474+ "sniff_timeout": 0,
475+ "client_hello_too_large": 0
476+ }
477+ }
478+}
479+```
480+
481+---
482+
483+## gordon daemon networks
484+
485+Display Docker networks managed by Gordon, including which containers
486+are connected to each network.
487+
488+```bash
489+gordon daemon networks
490+gordon daemon networks --json
491+gordon daemon networks --remote prod
492+```
493+
494+### Flags
495+
496+| Flag | Description |
497+|------|-------------|
498+| `--json` | Output as JSON |
499+
500+### JSON Output
501+
502+```json
503+[
504+ {
505+ "name": "gordon_myapp",
506+ "driver": "bridge",
507+ "containers": ["container1", "container2"]
508+ }
509+]
510+```
511+
512+## Related
513+
514+- [CLI Overview](./index.md)
515+- [Apps Commands](./apps.md)
516+- [Serve Command](./serve.md)
517+- [Traffic configuration](../config/traffic.md)
518+- [Remote CLI Management](/wiki/guides/remote-cli.md)
M docs/cli/images.md +115 -1
@@ -1,6 +1,6 @@
1 # Images Command1 # Images Command
2 2
3-List and prune runtime/registry images.3+Push, list, and prune runtime/registry images.
4 4
5 ## gordon images5 ## gordon images
6 6
@@ -10,12 +10,126 @@ gordon images <subcommand>
10 10
11 Subcommands:11 Subcommands:
12 12
13+- `push` - Tag and push an image to the Gordon registry (never deploys).
13 - `list` - List runtime images and registry tags.14 - `list` - List runtime images and registry tags.
14 - `prune` - Prune dangling runtime images and old registry tags.15 - `prune` - Prune dangling runtime images and old registry tags.
15 - `tags` - List registry tags for a specific repository.16 - `tags` - List registry tags for a specific repository.
16 17
17 > **Note:** Images commands require remote mode (`--remote` + `--token`, or configured remotes).18 > **Note:** Images commands require remote mode (`--remote` + `--token`, or configured remotes).
18 19
20+## gordon images push
21+
22+Tag and push an image to the Gordon registry. Push transfers OCI content
23+only: it never deploys. Deploy separately with `gordon apps deploy` after
24+applying the manifest that references the pushed tag.
25+
26+### Synopsis
27+
28+```bash
29+gordon images push [image] [options]
30+```
31+
32+### Arguments
33+
34+| Argument | Description |
35+|----------|-------------|
36+| `[image]` | Image name to push (optional). If omitted, auto-detected from Dockerfile labels or current directory name |
37+
38+### Options
39+
40+| Option | Description |
41+|--------|-------------|
42+| `--build` | Build the image first using `docker buildx` |
43+| `-f, --file` | Path to Dockerfile (default: `./Dockerfile`, used with `--build`) |
44+| `--platform` | Target platform for buildx (default: `linux/amd64`) |
45+| `--build-arg` | Additional build args (repeatable, `KEY=VALUE`) |
46+| `--tag` | Override pushed version tag (default: tag ref from CI, then `git describe --tags --dirty`) |
47+| `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
48+| `--token` | Authentication token for remote |
49+
50+### Description
51+
52+`gordon images push` tags the selected image for the Gordon registry and pushes it.
53+
54+If you do not pass `--remote` and no active remote is configured, Gordon tries to
55+infer the correct saved remote by probing your saved remotes for the image.
56+It auto-selects only when exactly one remote matches. If multiple remotes match,
57+or a saved remote cannot be probed safely, the command stops and asks you to use
58+`--remote` explicitly.
59+
60+Domain-style push targets are retired: push an image name, then
61+`gordon apps apply` the manifest that references the pushed tag.
62+
63+- The version tag defaults to a CI tag ref (like `refs/tags/v1.2.3`) when available,
64+ then falls back to `git describe --tags --dirty` (for example
65+ `v1.2.3-4-gabc1234` or `v1.2.3-dirty`). If no tag is found, `latest` is used.
66+- When `--build` is set, the command builds with `docker buildx build --load`
67+ and injects `VERSION`, `GIT_TAG`, `GIT_SHA`, and `BUILD_TIME` into the build
68+ environment plus any `--build-arg` values. To use these in your Dockerfile,
69+ declare them with `ARG` (e.g., `ARG VERSION`) then reference via `ENV` or
70+ in build steps.
71+- Use `-f/--file` to build from a Dockerfile outside the current directory root.
72+- The version tag and `latest` are both pushed (unless the version is `latest`).
73+
74+### Authentication
75+
76+When used with `--remote`, gordon images push authenticates in two ways:
77+
78+- **Admin API** (tag listing for inference): uses `--token` or `$GORDON_TOKEN` as Bearer token
79+- **Registry push**: automatically exchanges the token for a short-lived (5 min) registry access token via `/auth/token` -- no `docker login` required
80+
81+This means CI/CD pipelines only need a single secret (`GORDON_TOKEN`).
82+
83+### Version Auto-Detection
84+
85+Gordon reads version tags from CI environment variables (in priority order):
86+
87+| CI System | Variable | Example |
88+|-----------|----------|---------|
89+| GitHub Actions | `$GITHUB_REF` | `refs/tags/v1.2.0` |
90+| GitHub Actions | `$GITHUB_REF_TYPE` + `$GITHUB_REF_NAME` | `tag` + `v1.2.0` |
91+| GitLab CI | `$CI_COMMIT_TAG` | `v1.2.0` |
92+| Azure DevOps | `$BUILD_SOURCEBRANCH` | `refs/tags/v1.2.0` |
93+| Any | `git describe --tags` | `v1.2.3-4-gabc1234` |
94+| Fallback | - | `latest` |
95+
96+### Examples
97+
98+```bash
99+# Build, push (auto-detect image name)
100+gordon images push --build --remote https://gordon.example.com
101+
102+# Push an image
103+gordon images push myapp --remote https://gordon.example.com
104+
105+# Push a fully qualified ref
106+gordon images push registry.example.com/myapp:v1.2.3 --build --remote https://gordon.example.com
107+
108+# Push existing local image with an explicit tag
109+gordon images push myapp --tag v1.2.0 --remote https://gordon.example.com
110+
111+# Build for ARM and pass build args
112+gordon images push myapp --build --platform linux/arm64 --build-arg CGO_ENABLED=0 --remote https://gordon.example.com
113+
114+# Build from a custom Dockerfile path
115+gordon images push myapp --build -f docker/app/Dockerfile --remote https://gordon.example.com
116+
117+# CI/CD usage (single env var, no docker login needed)
118+export GORDON_TOKEN="your-token"
119+gordon images push myapp --build --remote https://gordon.example.com
120+```
121+
122+### Notes
123+
124+- Remote mode required. See [CLI Overview](./index.md) for targeting options.
125+- `--build` requires Docker with Buildx. Docker Desktop includes it; on Linux,
126+ install the `docker-buildx-plugin` package.
127+- Gordon uses native registry uploads instead of shelling out to `docker push`.
128+ Image layers are sent in 50MB chunks, which stays under Cloudflare's 100MB
129+ per-request limit so proxied pushes keep working. Keep the server's
130+ `max_blob_chunk_size` larger than the client chunk size; the default `95MB`
131+ works out of the box.
132+
19 ## gordon images list133 ## gordon images list
20 134
21 ```bash135 ```bash
M docs/cli/index.md +22 -28
@@ -26,16 +26,8 @@ Management commands use the authenticated daemon API. By default they connect th
26 |---------|-------------|---------------|26 |---------|-------------|---------------|
27 | `gordon apps` | Manage applications, operations, and app secrets | [apps](./apps.md) |27 | `gordon apps` | Manage applications, operations, and app secrets | [apps](./apps.md) |
28 | `gordon backups` | Manage declared app database and volume backups | [backup](./backup.md) |28 | `gordon backups` | Manage declared app database and volume backups | [backup](./backup.md) |
29-| `gordon config` | Show and validate server configuration | [config](./config.md) |29+| `gordon daemon` | Daemon status, logs, reload, config, TLS, traffic, and networks | [daemon](./daemon.md) |
30-| `gordon images` | List and prune images | [images](./images.md) |30+| `gordon images` | Push, list, and prune images | [images](./images.md) |
31-| `gordon logs` | Display Gordon process logs | [serve](./serve.md#gordon-logs) |
32-| `gordon networks list` | List Gordon-managed Docker networks | [networks](./networks.md) |
33-| `gordon push` | Tag and push an image (never deploys) | [push](./push.md) |
34-| `gordon reload` | Reload installation configuration | [serve](./serve.md#gordon-reload) |
35-| `gordon secrets` | Manage installation secrets | [secrets](./secrets.md) |
36-| `gordon status` | Show Gordon server status | [status](./status.md) |
37-| `gordon tls` | Inspect TLS status | [tls](./tls.md) |
38-| `gordon traffic` | Inspect traffic plane status | [traffic](./traffic.md) |
39 | `gordon volumes` | Manage volumes | [volumes](./volumes.md) |31 | `gordon volumes` | Manage volumes | [volumes](./volumes.md) |
40 32
41 ## Client Commands33 ## Client Commands
@@ -54,7 +46,7 @@ gordon serve
54 gordon serve --config /path/to/config.toml46 gordon serve --config /path/to/config.toml
55 47
56 # Reload installation configuration (never activates app state)48 # Reload installation configuration (never activates app state)
57-gordon reload49+gordon daemon reload
58 50
59 # Applications (daemon-owned; fail without a reachable daemon)51 # Applications (daemon-owned; fail without a reachable daemon)
60 gordon apps apply --file blog.toml52 gordon apps apply --file blog.toml
@@ -70,15 +62,21 @@ gordon apps remove blog
70 gordon apps operations show blog --key <operation-key>62 gordon apps operations show blog --key <operation-key>
71 gordon apps operations watch blog --key <operation-key>63 gordon apps operations watch blog --key <operation-key>
72 gordon apps secrets list blog64 gordon apps secrets list blog
73-gordon config validate --file /path/to/gordon.toml65+
66+# Daemon
67+gordon daemon status
68+gordon daemon config show
69+gordon daemon config validate --file /path/to/gordon.toml
70+gordon daemon tls
71+gordon daemon networks
74 72
75 # Push an image (OCI transfer only; deploy separately)73 # Push an image (OCI transfer only; deploy separately)
76-gordon push myapp --build --remote prod74+gordon images push myapp --build --remote prod
77 75
78 # View logs76 # View logs
79-gordon logs # Gordon process logs77+gordon daemon logs # Gordon process logs
80-gordon logs -f # Follow process logs78+gordon daemon logs -f # Follow process logs
81-gordon logs -n 100 # Last 100 process-log lines79+gordon daemon logs -n 100 # Last 100 process-log lines
82 gordon apps logs blog --service web # App service logs80 gordon apps logs blog --service web # App service logs
83 gordon apps logs blog --service web --follow # Follow app service logs81 gordon apps logs blog --service web --follow # Follow app service logs
84 82
@@ -86,8 +84,8 @@ gordon apps logs blog --service web --follow # Follow app service logs
86 gordon version84 gordon version
87 85
88 # Traffic plane86 # Traffic plane
89-gordon traffic status --remote prod87+gordon daemon traffic --remote prod
90-gordon traffic status --remote prod --json88+gordon daemon traffic --remote prod --json
91 89
92 # Backups90 # Backups
93 gordon backups list91 gordon backups list
@@ -96,6 +94,7 @@ gordon backups volume run shop --service api --volume data
96 gordon backups status94 gordon backups status
97 95
98 # Images96 # Images
97+gordon images push myapp --build --remote prod
99 gordon images list98 gordon images list
100 gordon images prune --dry-run99 gordon images prune --dry-run
101 gordon images prune --keep-releases 3100 gordon images prune --keep-releases 3
@@ -110,11 +109,6 @@ gordon auth token list
110 gordon auth token revoke <token-id>109 gordon auth token revoke <token-id>
111 gordon auth internal110 gordon auth internal
112 111
113-# Secrets
114-gordon secrets list myapp.example.com
115-gordon secrets set myapp.example.com --from-file ./app.env
116-gordon secrets remove myapp.example.com DATABASE_URL
117-
118 # Remotes112 # Remotes
119 gordon remotes add prod https://gordon.mydomain.com --token $TOKEN113 gordon remotes add prod https://gordon.mydomain.com --token $TOKEN
120 gordon remotes list114 gordon remotes list
@@ -141,14 +135,14 @@ or `GORDON_REMOTE` environment variable. Use `--remote` and `--token` as global
141 when you want to bypass your saved configuration.135 when you want to bypass your saved configuration.
142 136
143 When no explicit remote is selected and no active remote is configured, Gordon can137 When no explicit remote is selected and no active remote is configured, Gordon can
144-**auto-infer a saved remote** for `gordon push`. It probes your saved remotes and uses the138+**auto-infer a saved remote** for `gordon images push`. It probes your saved remotes and uses the
145 remote automatically when exactly one matches. If multiple remotes match, Gordon stops with139 remote automatically when exactly one matches. If multiple remotes match, Gordon stops with
146 an ambiguity error and asks you to use `--remote`. If any remote probe fails, Gordon also140 an ambiguity error and asks you to use `--remote`. If any remote probe fails, Gordon also
147 stops rather than guessing.141 stops rather than guessing.
148 142
149 ```bash143 ```bash
150 # Auto-inferred single match from saved remotes144 # Auto-inferred single match from saved remotes
151-gordon push myapp --build145+gordon images push myapp --build
152 ```146 ```
153 147
154 **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.148 **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.
@@ -159,16 +153,16 @@ or in a specific entry in `~/.config/gordon/remotes.toml`.
159 153
160 ```bash154 ```bash
161 # Using flags (use the gordon_domain from remote Gordon config)155 # Using flags (use the gordon_domain from remote Gordon config)
162-gordon status --remote https://gordon.example.com --token $TOKEN156+gordon daemon status --remote https://gordon.example.com --token $TOKEN
163 157
164 # Against self-signed/private CA endpoint158 # Against self-signed/private CA endpoint
165-gordon --remote https://gordon.example.com --token $TOKEN --insecure status159+gordon --remote https://gordon.example.com --token $TOKEN --insecure daemon status
166 160
167 # Using environment variables161 # Using environment variables
168 export GORDON_REMOTE=https://gordon.example.com162 export GORDON_REMOTE=https://gordon.example.com
169 export GORDON_TOKEN=$TOKEN163 export GORDON_TOKEN=$TOKEN
170 export GORDON_INSECURE=true164 export GORDON_INSECURE=true
171-gordon status165+gordon daemon status
172 ```166 ```
173 167
174 ## Exit Codes168 ## Exit Codes
D docs/cli/networks.md +0 -49
@@ -1,49 +0,0 @@
1-# Networks Commands
2-
3-Inspect Gordon-managed Docker networks.
4-
5-Remote targeting uses client config or an active remote by default.
6-Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
7-
8-## gordon networks
9-
10-### Subcommands
11-
12-| Subcommand | Description |
13-|------------|-------------|
14-| `list` | List Gordon-managed Docker networks |
15-
16----
17-
18-## gordon networks list
19-
20-Display Docker networks managed by Gordon, including which containers
21-are connected to each network.
22-
23-```bash
24-gordon networks list
25-gordon networks list --json
26-gordon networks list --remote https://gordon.mydomain.com --token $TOKEN
27-```
28-
29-### Flags
30-
31-| Flag | Description |
32-|------|-------------|
33-| `--json` | Output as JSON |
34-
35-### JSON Output
36-
37-```json
38-[
39- {
40- "name": "gordon_myapp",
41- "driver": "bridge",
42- "containers": ["container1", "container2"]
43- }
44-]
45-```
46-
47-## Related
48-
49-- [CLI Overview](./index.md)
D docs/cli/push.md +0 -119
@@ -1,119 +0,0 @@
1-# Push Command
2-
3-Tag and push an image to the Gordon registry. Push transfers OCI content
4-only: it never deploys. Deploy separately with `gordon apps deploy` after
5-applying the manifest that references the pushed tag.
6-
7-## gordon push
8-
9-### Synopsis
10-
11-```bash
12-gordon push [image] [options]
13-```
14-
15-### Arguments
16-
17-| Argument | Description |
18-|----------|-------------|
19-| `[image]` | Image name to push (optional). If omitted, auto-detected from Dockerfile labels or current directory name |
20-
21-### Options
22-
23-| Option | Description |
24-|--------|-------------|
25-| `--build` | Build the image first using `docker buildx` |
26-| `-f, --file` | Path to Dockerfile (default: `./Dockerfile`, used with `--build`) |
27-| `--platform` | Target platform for buildx (default: `linux/amd64`) |
28-| `--build-arg` | Additional build args (repeatable, `KEY=VALUE`) |
29-| `--tag` | Override pushed version tag (default: tag ref from CI, then `git describe --tags --dirty`) |
30-| `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
31-| `--token` | Authentication token for remote |
32-
33-### Description
34-
35-`gordon push` tags the selected image for the Gordon registry and pushes it.
36-
37-If you do not pass `--remote` and no active remote is configured, Gordon tries to
38-infer the correct saved remote by probing your saved remotes for the image.
39-It auto-selects only when exactly one remote matches. If multiple remotes match,
40-or a saved remote cannot be probed safely, the command stops and asks you to use
41-`--remote` explicitly.
42-
43-Domain-style push targets are retired: push an image name, then
44-`gordon apps apply` the manifest that references the pushed tag.
45-
46-- The version tag defaults to a CI tag ref (like `refs/tags/v1.2.3`) when available,
47- then falls back to `git describe --tags --dirty` (for example
48- `v1.2.3-4-gabc1234` or `v1.2.3-dirty`). If no tag is found, `latest` is used.
49-- When `--build` is set, the command builds with `docker buildx build --load`
50- and injects `VERSION`, `GIT_TAG`, `GIT_SHA`, and `BUILD_TIME` into the build
51- environment plus any `--build-arg` values. To use these in your Dockerfile,
52- declare them with `ARG` (e.g., `ARG VERSION`) then reference via `ENV` or
53- in build steps.
54-- Use `-f/--file` to build from a Dockerfile outside the current directory root.
55-- The version tag and `latest` are both pushed (unless the version is `latest`).
56-
57-### Authentication
58-
59-When used with `--remote`, gordon push authenticates in two ways:
60-
61-- **Admin API** (tag listing for inference): uses `--token` or `$GORDON_TOKEN` as Bearer token
62-- **Registry push**: automatically exchanges the token for a short-lived (5 min) registry access token via `/auth/token` -- no `docker login` required
63-
64-This means CI/CD pipelines only need a single secret (`GORDON_TOKEN`).
65-
66-### Version Auto-Detection
67-
68-Gordon reads version tags from CI environment variables (in priority order):
69-
70-| CI System | Variable | Example |
71-|-----------|----------|---------|
72-| GitHub Actions | `$GITHUB_REF` | `refs/tags/v1.2.0` |
73-| GitHub Actions | `$GITHUB_REF_TYPE` + `$GITHUB_REF_NAME` | `tag` + `v1.2.0` |
74-| GitLab CI | `$CI_COMMIT_TAG` | `v1.2.0` |
75-| Azure DevOps | `$BUILD_SOURCEBRANCH` | `refs/tags/v1.2.0` |
76-| Any | `git describe --tags` | `v1.2.3-4-gabc1234` |
77-| Fallback | - | `latest` |
78-
79-### Examples
80-
81-```bash
82-# Build, push (auto-detect image name)
83-gordon push --build --remote https://gordon.example.com
84-
85-# Push an image
86-gordon push myapp --remote https://gordon.example.com
87-
88-# Push a fully qualified ref
89-gordon push registry.example.com/myapp:v1.2.3 --build --remote https://gordon.example.com
90-
91-# Push existing local image with an explicit tag
92-gordon push myapp --tag v1.2.0 --remote https://gordon.example.com
93-
94-# Build for ARM and pass build args
95-gordon push myapp --build --platform linux/arm64 --build-arg CGO_ENABLED=0 --remote https://gordon.example.com
96-
97-# Build from a custom Dockerfile path
98-gordon push myapp --build -f docker/app/Dockerfile --remote https://gordon.example.com
99-
100-# CI/CD usage (single env var, no docker login needed)
101-export GORDON_TOKEN="your-token"
102-gordon push myapp --build --remote https://gordon.example.com
103-```
104-
105-### Notes
106-
107-- Remote mode required. See [CLI Overview](./index.md) for targeting options.
108-- `--build` requires Docker with Buildx. Docker Desktop includes it; on Linux,
109- install the `docker-buildx-plugin` package.
110-- Gordon uses native registry uploads instead of shelling out to `docker push`.
111- Image layers are sent in 50MB chunks, which stays under Cloudflare's 100MB
112- per-request limit so proxied pushes keep working. Keep the server's
113- `max_blob_chunk_size` larger than the client chunk size; the default `95MB`
114- works out of the box.
115-
116-## Related
117-
118-- [CLI Overview](./index.md)
119-- [Authentication](../config/auth.md)
M docs/cli/remotes.md +12 -12
@@ -164,12 +164,12 @@ gordon remotes use <name>
164 164
165 When a remote is active, it's used automatically for most remote-capable commands without needing to specify `--remote` and `--token`.165 When a remote is active, it's used automatically for most remote-capable commands without needing to specify `--remote` and `--token`.
166 166
167-If no remote is active and you do not pass `--remote`, Gordon can also auto-infer a saved remote for `gordon push` and `gordon images tags <repository>`. It only auto-selects when exactly one saved remote matches. Ambiguous matches and probe failures require an explicit `--remote`.167+If no remote is active and you do not pass `--remote`, Gordon can also auto-infer a saved remote for `gordon images push` and `gordon images tags <repository>`. It only auto-selects when exactly one saved remote matches. Ambiguous matches and probe failures require an explicit `--remote`.
168 168
169 ```bash169 ```bash
170 gordon remotes use prod170 gordon remotes use prod
171-gordon secrets list app.com # Uses prod remote automatically171+gordon apps list # Uses prod remote automatically
172-gordon status # Uses prod remote automatically172+gordon daemon status # Uses prod remote automatically
173 ```173 ```
174 174
175 ### Example175 ### Example
@@ -270,7 +270,7 @@ This allows overriding specific values while keeping defaults:
270 270
271 ```bash271 ```bash
272 # Force one target272 # Force one target
273-GORDON_REMOTE=prod gordon status273+GORDON_REMOTE=prod gordon daemon status
274 ```274 ```
275 275
276 ---276 ---
@@ -287,12 +287,12 @@ gordon remotes add dev https://gordon.dev.example.com --token-env DEV_TOKEN
287 287
288 # Work with prod288 # Work with prod
289 gordon remotes use prod289 gordon remotes use prod
290-gordon secrets list myapp.example.com290+gordon apps list
291-gordon status --remote prod291+gordon daemon status
292 292
293 # Switch to staging293 # Switch to staging
294 gordon remotes use staging294 gordon remotes use staging
295-GORDON_REMOTE=staging gordon status295+GORDON_REMOTE=staging gordon daemon status
296 ```296 ```
297 297
298 ### CI/CD Pipeline298 ### CI/CD Pipeline
@@ -306,19 +306,19 @@ env:
306 steps:306 steps:
307 - name: Push to Gordon307 - name: Push to Gordon
308 run: |308 run: |
309- gordon push myapp --build --remote ${{ secrets.GORDON_URL }}309+ gordon images push myapp --build --remote ${{ secrets.GORDON_URL }}
310 ```310 ```
311 311
312 ### Compare Environments312 ### Compare Environments
313 313
314 ```bash314 ```bash
315 # Compare one target at a time315 # Compare one target at a time
316-gordon status --remote https://gordon.example.com --token $PROD_TOKEN316+gordon daemon status --remote https://gordon.example.com --token $PROD_TOKEN
317-GORDON_REMOTE=staging gordon status317+GORDON_REMOTE=staging gordon daemon status
318 318
319 # Or switch between active remotes for other commands319 # Or switch between active remotes for other commands
320-gordon remotes use prod && gordon secrets list myapp.example.com320+gordon remotes use prod && gordon apps list
321-gordon remotes use staging && gordon secrets list myapp.example.com321+gordon remotes use staging && gordon apps list
322 ```322 ```
323 323
324 ### Private Admin + Wildcard App Domains324 ### Private Admin + Wildcard App Domains
D docs/cli/secrets.md +0 -199
@@ -1,199 +0,0 @@
1-# Secrets Commands
2-
3-Manage installation secrets on local or remote Gordon instances.
4-
5-Storage depends on the secrets backend:
6-- `pass`: secrets are stored in pass under `gordon/env/<domain>/<KEY>`
7-- `sops`: secrets live in domain `.env` files and can reference SOPS-encrypted values
8-- `unsafe`: secrets are stored in plain-text domain `.env` files
9-
10-App secret VALUES stay in pass under
11-`gordon/apps/<uuid>/<service>/<name>` and are managed with
12-`gordon apps secrets`. The commands below manage installation secrets only.
13-
14-## gordon secrets
15-
16-### Subcommands
17-
18-| Subcommand | Description |
19-|------------|-------------|
20-| `list` | List all secret keys for a domain |
21-| `set` | Set secrets for a domain from a file |
22-| `remove` | Remove a secret |
23-
24----
25-
26-## gordon secrets list
27-
28-List all secret key names for a specific domain. Only key names are shown, never values.
29-
30-```bash
31-gordon secrets list <domain>
32-```
33-
34-### Arguments
35-
36-| Argument | Description |
37-|----------|-------------|
38-| `<domain>` | The domain name to list secrets for |
39-
40-### Options
41-
42-| Option | Description |
43-|--------|-------------|
44-| `--json` | Output secrets as JSON |
45-| `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
46-| `--token` | Authentication token for remote |
47-
48-### Examples
49-
50-```bash
51-# Local
52-gordon secrets list myapp.example.com
53-
54-# Remote (override)
55-gordon secrets list myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN
56-```
57-
58-### Output
59-
60-```
61-Secrets for app.mydomain.com
62-
63-Key Value
64-DATABASE_URL (hidden)
65-API_KEY (hidden)
66-```
67-
68-### JSON Output
69-
70-```bash
71-gordon secrets list app.mydomain.com --json
72-```
73-
74-```json
75-{
76- "domain": "app.mydomain.com",
77- "keys": ["API_KEY", "DATABASE_URL"]
78-}
79-```
80-
81----
82-
83-## gordon secrets set
84-
85-Set secrets for a domain from a mode 0600 file containing one `KEY=value` pair per line.
86-
87-```bash
88-gordon secrets set <domain> --from-file <path>
89-```
90-
91-### Arguments
92-
93-| Argument | Description |
94-|----------|-------------|
95-| `<domain>` | The domain name to set secrets for |
96-
97-### Options
98-
99-| Option | Description |
100-|--------|-------------|
101-| `--from-file` | Read KEY=value lines from a mode 0600 file (required) |
102-| `--json` | Output as JSON |
103-| `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
104-| `--token` | Authentication token for remote |
105-
106-### Examples
107-
108-```bash
109-# Local
110-gordon secrets set myapp.example.com --from-file ./app.env
111-
112-# Remote (override)
113-gordon secrets set myapp.example.com --from-file ./app.env --remote https://gordon.mydomain.com --token $TOKEN
114-```
115-
116----
117-
118-## gordon secrets remove
119-
120-Remove a secret from a domain.
121-
122-```bash
123-gordon secrets remove <domain> <key>
124-```
125-
126-### Arguments
127-
128-| Argument | Description |
129-|----------|-------------|
130-| `<domain>` | The domain name |
131-| `<key>` | The secret key to remove |
132-
133-### Options
134-
135-| Option | Description |
136-|--------|-------------|
137-| `--force`, `-f` | Remove without confirmation |
138-| `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
139-| `--token` | Authentication token for remote |
140-
141-### Examples
142-
143-```bash
144-# Local
145-gordon secrets remove myapp.example.com DATABASE_URL
146-
147-# Remote (override)
148-gordon secrets remove myapp.example.com DATABASE_URL --remote https://gordon.mydomain.com --token $TOKEN
149-```
150-
151----
152-
153-## Workflow Examples
154-
155-### Setting Up Application Secrets
156-
157-```bash
158-# Write secrets to a protected file
159-cat > app.env <<'EOF'
160-DATABASE_URL=postgres://user:pass@postgres:5432/mydb
161-API_KEY=your-api-key
162-EOF
163-chmod 600 app.env
164-
165-gordon secrets set myapp.example.com --from-file ./app.env
166-
167-# Verify
168-gordon secrets list myapp.example.com
169-```
170-
171-### CI/CD Secret Management
172-
173-```bash
174-# In your CI/CD pipeline
175-export GORDON_REMOTE=https://gordon.mydomain.com
176-export GORDON_TOKEN=$GORDON_TOKEN
177-
178-# Update secrets
179-gordon secrets set myapp.example.com --from-file ./app.env
180-```
181-
182-### Rotating Secrets
183-
184-```bash
185-# Generate new secret
186-NEW_JWT_SECRET=$(openssl rand -base64 32)
187-printf 'JWT_SECRET=%s\n' "$NEW_JWT_SECRET" > rotate.env
188-chmod 600 rotate.env
189-
190-# Update the secret
191-gordon secrets set myapp.example.com --from-file ./rotate.env
192-```
193-
194-## Related
195-
196-- [CLI Overview](./index.md)
197-- [Secrets Configuration](../config/secrets.md)
198-- [Environment Variables](../config/env.md)
199-- [Apps Commands](./apps.md)
M docs/cli/serve.md +0 -92
@@ -128,98 +128,6 @@ Reload and startup recovery never activate pending desired app state, re-resolve
128 128
129 ---129 ---
130 130
131-## gordon reload
132-
133-Reload installation configuration.
134-
135-### Synopsis
136-
137-```bash
138-gordon reload
139-```
140-
141-### Description
142-
143-Sends `SIGUSR1` to the running Gordon process, triggering:
144-
145-- Installation settings reload (live keys apply, restart-required keys are reported)
146-- Traffic/proxy state refresh from ACTIVE app projection
147-
148-Reload never activates pending desired app state, re-resolves images, or flips intent. Obsolete application keys (`routes`, `attachments`, `services`, `auto`, previews, …) are rejected with a `config-retired` diagnostic before any mutation.
149-
150-### Example
151-
152-```bash
153-# After editing gordon.toml, apply changes without restart
154-vim ~/.config/gordon/gordon.toml
155-gordon reload
156-```
157-
158----
159-
160-## gordon logs
161-
162-Display Gordon daemon process logs. Application workload output is read with
163-`gordon apps logs APP --service SVC`, which resolves the app's active container
164-through the daemon.
165-
166-### Synopsis
167-
168-```bash
169-gordon logs [options]
170-```
171-
172-### Options
173-
174-| Option | Short | Default | Description |
175-|--------|-------|---------|-------------|
176-| `--config` | `-c` | Auto | Path to config file |
177-| `--follow` | `-f` | false | Follow log output (like `tail -f`) |
178-| `--lines` | `-n` | 50 | Number of lines to show |
179-| `--remote, -r` | | | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
180-| `--token` | | | Authentication token for remote |
181-
182-Remote targeting uses client config or an active remote by default. Use
183-`--remote` and `--token` to override. See [CLI Overview](./index.md).
184-
185-Remote log access requires an admin token with `admin:logs:read` (or `admin:*:*`). `admin:status:read` is not sufficient for logs.
186-
187-### Examples
188-
189-```bash
190-# Gordon process logs
191-gordon logs # Last 50 lines
192-gordon logs -f # Follow logs
193-gordon logs -n 100 # Last 100 lines
194-gordon logs -f -n 200 # Follow, starting from last 200 lines
195-
196-# App service logs
197-gordon apps logs blog --service web
198-gordon apps logs blog --service web --follow --remote prod
199-
200-# Remote process logs (override)
201-gordon logs --remote https://gordon.mydomain.com --token $TOKEN
202-```
203-
204-### Log Locations
205-
206-```bash
207-# Using gordon logs
208-gordon logs -f
209-
210-# Direct file access
211-tail -f ~/.gordon/logs/gordon.log
212-
213-# With systemd
214-journalctl --user -u gordon -f
215-
216-# App service logs through Gordon
217-gordon apps logs blog --service web --tail 50
218-gordon apps logs blog --service web --follow
219-```
220-
221----
222-
223 ## gordon version131 ## gordon version
224 132
225 Print version information.133 Print version information.
D docs/cli/status.md +0 -110
@@ -1,110 +0,0 @@
1-# Status Command
2-
3-Show Gordon server status and the app fleet summary.
4-
5-## gordon status
6-
7-Display installation identity plus one status line per app (from desired/active state, no container inspection). Per-service detail lives under `gordon apps show APP` and `gordon apps status APP`.
8-
9-```bash
10-gordon status
11-gordon status --remote https://gordon.mydomain.com --token $TOKEN
12-```
13-
14-`gordon status` works in local mode and remote mode.
15-
16-- Local mode reads status from in-process services.
17-- Remote mode reads status from the target admin API.
18-
19-### Output
20-
21-```
22-Gordon Status
23-
24-Gordon Domain: gordon.example.com
25-Registry Port: 5000
26-Server Port: 8088
27-Apps: 3
28-Network Isolation: false
29-
30-Container Status:
31- blog: active
32- shop: deploying
33- old-site: stopped
34-```
35-
36-### Information Displayed
37-
38-| Field | Description |
39-|-------|-------------|
40-| Gordon Domain | Public Gordon domain from configuration |
41-| Registry Port | Docker registry port |
42-| Server Port | Gordon admin port |
43-| Routes | Total apps in desired/active state |
44-| Network Isolation | Whether installation network policy is enabled |
45-| Container Status | Fleet status per app (see states below) |
46-
47-### App States
48-
49-| State | Description |
50-|-------|-------------|
51-| active | App deployed and converged on desired state |
52-| deploying | Desired state diverges from effective state |
53-| pending | App applied but never deployed |
54-| stopped | Durable stopped intent (stays stopped across reboot) |
55-
56-## Flags
57-
58-The status command uses global flags for remote access:
59-
60-| Flag | Description |
61-|------|-------------|
62-| `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
63-| `--token` | Authentication token |
64-
65-## Environment Variables
66-
67-| Variable | Description |
68-|----------|-------------|
69-| `GORDON_REMOTE` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
70-| `GORDON_TOKEN` | Authentication token |
71-
72-## Examples
73-
74-### Check Local or Remote Status
75-
76-```bash
77-# Local
78-gordon status
79-
80-# Using flags
81-gordon status --remote https://gordon.mydomain.com --token $TOKEN
82-
83-# Using environment variables
84-export GORDON_REMOTE=https://gordon.mydomain.com
85-export GORDON_TOKEN=your-token
86-gordon status
87-```
88-
89-### Quick Fleet Check
90-
91-```bash
92-# Check for non-converged apps
93-gordon status --remote https://gordon.mydomain.com --token $TOKEN | grep -E "(deploying|pending|stopped)"
94-```
95-
96-## Required Permissions (Remote Only)
97-
98-Remote status calls require `admin:status:read` scope in the authentication token.
99-
100-```bash
101-# Generate token with required scope
102-gordon auth token generate --subject admin --scopes admin:status:read
103-```
104-
105-## Related
106-
107-- [Serve Command](./serve.md)
108-- [Apps Commands](./apps.md)
109-- [CLI Overview](./index.md)
110-- [Remote CLI Management](/wiki/guides/remote-cli.md)
D docs/cli/tls.md +0 -113
@@ -1,113 +0,0 @@
1-# TLS Commands
2-
3-Inspect public TLS/ACME certificate status.
4-
5-Gordon serves normal HTTPS fallback on TLS-capable entrypoints such as `entrypoints.edge` with `protocol = "smart_tcp"`. Certificate priority is static certificates first, then public ACME certificates, then Gordon's internal CA.
6-
7-ACME challenge notes:
8-
9-- DNS-01 (`cloudflare-dns-01`) does not require a special external port 80 edge.
10-- HTTP-01 requires an HTTP-capable smart TCP entrypoint reachable on external port 80 for every hostname being validated.
11-- TLS-ALPN-01 is not supported.
12-
13-Remote targeting uses client config or an active remote by default.
14-Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
15-
16-## gordon tls
17-
18-### Subcommands
19-
20-| Subcommand | Description |
21-|------------|-------------|
22-| `status` | Show public TLS certificate status |
23-
24----
25-
26-## gordon tls status
27-
28-Display the current public TLS/ACME certificate status, including ACME mode,
29-certificate details, route coverage, and any errors.
30-
31-```bash
32-gordon tls status
33-gordon tls status --json
34-gordon tls status --remote https://gordon.mydomain.com --token $TOKEN
35-```
36-
37-### Flags
38-
39-| Flag | Description |
40-|------|-------------|
41-| `--json` | Output as JSON |
42-
43-### Human Output
44-
45-```text
46-Public TLS / ACME Status
47-
48-ACME: enabled
49-Configured Mode: auto
50-Effective Mode: http-01
51-Reason: configured
52-Token Source: env
53-
54-Certificates
55- ID: cert-abc123
56- Names: example.com, www.example.com
57- Status: valid
58- Not After: 2026-05-29 12:00:00
59-
60-Route Coverage
61- example.com covered=yes covered_by=cert-abc123
62- internal.local covered=no error=self-signed cert
63-
64-Errors
65- route internal.local has no ACME cert
66-```
67-
68-### JSON Output
69-
70-```json
71-{
72- "acme_enabled": true,
73- "configured_mode": "auto",
74- "effective_mode": "http-01",
75- "selection_reason": "configured",
76- "token_source": "env",
77- "certificates": [
78- {
79- "id": "cert-abc123",
80- "names": ["example.com", "www.example.com"],
81- "challenge": "http-01",
82- "status": "valid",
83- "not_after": "2026-05-29T12:00:00Z",
84- "renewal_pending": false
85- }
86- ],
87- "routes": [
88- {
89- "domain": "example.com",
90- "covered": true,
91- "covered_by": "cert-abc123",
92- "required_acme": true
93- },
94- {
95- "domain": "internal.local",
96- "covered": false,
97- "required_acme": false,
98- "error": "self-signed cert"
99- }
100- ],
101- "errors": ["route internal.local has no ACME cert"]
102-}
103-```
104-
105-### Token Source
106-
107-The `token_source` field indicates where the ACME token was sourced from
108-(e.g., `env`, `file`, `config`). The token value is never displayed.
109-
110-## Related
111-
112-- [CLI Overview](./index.md)
113-- [Status Command](./status.md)
D docs/cli/traffic.md +0 -93
@@ -1,93 +0,0 @@
1-# gordon traffic
2-
3-Inspect Gordon traffic plane status.
4-
5-## Subcommands
6-
7-| Command | Description |
8-|---------|-------------|
9-| `gordon traffic status` | Show traffic entrypoints, counters, and reload status |
10-
11-## Usage
12-
13-```bash
14-gordon traffic status --remote prod
15-gordon traffic status --remote https://gordon.example.com --json
16-```
17-
18-Remote mode queries the running Gordon admin API. Local mode does not synthesize runtime traffic state from a fresh config load; use a configured remote target for authoritative counters and reload status.
19-
20-## Flags
21-
22-| Flag | Description |
23-|------|-------------|
24-| `--json` | Output machine-readable JSON |
25-| `--remote`, `-r` | Remote Gordon instance or saved remote name |
26-
27-## JSON Output
28-
29-```json
30-{
31- "last_reload_status": "ok",
32- "entrypoints": [
33- {
34- "name": "postgres",
35- "address": "0.0.0.0:5432",
36- "protocol": "tcp",
37- "active": true,
38- "active_tcp_connections": 1,
39- "active_udp_sessions": 0,
40- "total_accepted": 12,
41- "total_refused": 0,
42- "total_errors": 0,
43- "bytes_in": 4096,
44- "bytes_out": 8192,
45- "smart_tcp": {
46- "http_accepted": 0,
47- "h2c_accepted": 0,
48- "https_fallback_accepted": 0,
49- "tls_passthrough_accepted": 0,
50- "raw_fallback_accepted": 0,
51- "entrypoint_cidr_refused": 0,
52- "raw_fallback_cidr_refused": 0,
53- "proxy_refused": 0,
54- "unknown_no_fallback_refused": 0,
55- "malformed_rejected": 0,
56- "sniff_timeout": 0,
57- "client_hello_too_large": 0
58- }
59- }
60- ],
61- "routers": [],
62- "services": [],
63- "counters": {
64- "active_tcp_connections": 1,
65- "active_udp_sessions": 0,
66- "total_accepted": 12,
67- "total_refused": 0,
68- "total_errors": 0,
69- "bytes_in": 4096,
70- "bytes_out": 8192,
71- "smart_tcp": {
72- "http_accepted": 0,
73- "h2c_accepted": 0,
74- "https_fallback_accepted": 0,
75- "tls_passthrough_accepted": 0,
76- "raw_fallback_accepted": 0,
77- "entrypoint_cidr_refused": 0,
78- "raw_fallback_cidr_refused": 0,
79- "proxy_refused": 0,
80- "unknown_no_fallback_refused": 0,
81- "malformed_rejected": 0,
82- "sniff_timeout": 0,
83- "client_hello_too_large": 0
84- }
85- }
86-}
87-```
88-
89-## Related
90-
91-- [Traffic configuration](../config/traffic.md)
92-- [Standalone services](../config/services.md)
93-- [Server status](./status.md)
M docs/concepts.md +2 -2
@@ -164,10 +164,10 @@ Secret values stay in pass under `gordon/apps/<uuid>/<service>/<name>`, keyed by
164 Gordon watches `gordon.toml` and reloads installation-only settings (entrypoints, TLS, limits, external routes). Reload never activates pending app desired state, never re-resolves image tags, and never starts app workloads.164 Gordon watches `gordon.toml` and reloads installation-only settings (entrypoints, TLS, limits, external routes). Reload never activates pending app desired state, never re-resolves image tags, and never starts app workloads.
165 165
166 ```bash166 ```bash
167-gordon reload167+gordon daemon reload
168 ```168 ```
169 169
170-`gordon reload` sends `SIGUSR1` to the running Gordon process. `gordon.toml` holds installation settings only — app workloads live in app files.170+`gordon daemon reload` sends `SIGUSR1` to the running Gordon process. `gordon.toml` holds installation settings only — app workloads live in app files.
171 171
172 ## Event System172 ## Event System
173 173
M docs/config/auth.md +0 -2
@@ -95,8 +95,6 @@ Admin scopes (for remote CLI):
95 | `admin:logs:read` | Read-only logs access |95 | `admin:logs:read` | Read-only logs access |
96 | `admin:volumes:read` | List Docker volumes |96 | `admin:volumes:read` | List Docker volumes |
97 | `admin:volumes:write` | Prune eligible Gordon-managed volumes |97 | `admin:volumes:write` | Prune eligible Gordon-managed volumes |
98-| `admin:secrets:read` | List secret keys |
99-| `admin:secrets:write` | Set/delete secrets |
100 98
101 Examples:99 Examples:
102 100
D docs/config/env.md +0 -64
@@ -1,64 +0,0 @@
1-# Environment Variables
2-
3-App environment comes from two places: public `[env]` in the app file (injected into all services at deploy) and per-service `[services.<name>.secrets]` (values in pass, resolved at deploy). This page covers the installation `[env]` store location and the provider syntax used in env files.
4-
5-## Configuration
6-
7-```toml
8-[env]
9-dir = "~/.gordon/env" # Default location
10-```
11-
12-## Options
13-
14-| Option | Type | Default | Description |
15-|--------|------|---------|-------------|
16-| `dir` | string | `~/.gordon/env` | Directory for the installation secret store |
17-
18-## How It Works
19-
20-The `[env]` directory backs the installation secret store:
21-
22-- With the `pass` backend, `gordon secrets set <domain> --from-file` stores per-domain secrets in pass under `gordon/env/<sanitized-domain>/<KEY>` (dots/colons/slashes → underscores). Env files are not used for secrets.
23-- With the `pass` backend, Gordon imports eligible plaintext `.env` files into pass at startup and removes each source file only after every entry is stored successfully. If a destination entry already exists or an import fails, Gordon leaves the source file in place for operator review.
24-- With the `sops` or `unsafe` backend, env files remain the source of truth; use `${sops:...}` syntax for encrypted values when `secrets_backend = "sops"`.
25-
26-App containers do NOT read these files: services receive the app file's `[env]` plus their resolved `[services.<name>.secrets]` at deploy time (see [App Manifest](./apps.md)).
27-
28-## Secret Provider Syntax
29-
30-Reference secrets from configured backends in env files:
31-
32-### Pass (Unix Password Manager)
33-
34-```bash
35-DATABASE_PASSWORD=${pass:myapp/database/password}
36-API_SECRET=${pass:company/api-secret}
37-JWT_KEY=${pass:production/jwt-signing-key}
38-```
39-
40-### SOPS (Encrypted Files)
41-
42-```bash
43-DATABASE_PASSWORD=${sops:secrets.yaml:database.password}
44-API_SECRET=${sops:production.yaml:api.secret}
45-STRIPE_KEY=${sops:secrets.yaml:stripe.api_key}
46-```
47-
48-### Syntax Reference
49-
50-| Provider | Syntax | Example |
51-|----------|--------|---------|
52-| pass | `${pass:<path>}` | `${pass:myapp/db-password}` |
53-| sops | `${sops:<file>:<key.path>}` | `${sops:secrets.yaml:db.password}` |
54-
55-## File Permissions
56-
57-The env directory uses secure permissions:
58-- Directory: `0700` (owner only)
59-- Files: `0600` (owner only)
60-
61-## Related
62-
63-- [Secrets Configuration](./secrets.md)
64-- [App Manifest](./apps.md)
M docs/config/index.md +2 -7
@@ -89,17 +89,13 @@ metrics = true # Export metrics
89 logs = true # Export Gordon, access, and app logs89 logs = true # Export Gordon, access, and app logs
90 trace_sample_rate = 1.0 # 0.0 = none, 1.0 = all90 trace_sample_rate = 1.0 # 0.0 = none, 1.0 = all
91 91
92-# Installation secret store location
93-[env]
94-dir = "~/.gordon/env" # Default location
95-
96 # Volume settings92 # Volume settings
97 [volumes]93 [volumes]
98 auto_create = true # Auto-create from Dockerfile VOLUME94 auto_create = true # Auto-create from Dockerfile VOLUME
99 prefix = "gordon" # Volume name prefix95 prefix = "gordon" # Volume name prefix
100 preserve = true # Keep volumes on container removal96 preserve = true # Keep volumes on container removal
101 97
102-# Installation network policy (prefix filter for `gordon networks list`)98+# Installation network policy (prefix filter for `gordon daemon networks`)
103 [network_isolation]99 [network_isolation]
104 enabled = true # Gordon-managed network policy100 enabled = true # Gordon-managed network policy
105 network_prefix = "gordon" # Network name prefix101 network_prefix = "gordon" # Network name prefix
@@ -143,7 +139,6 @@ Docker Hub (`docker.io` and `registry-1.docker.io`), `ghcr.io`, `quay.io`, and G
143 | `[api.rate_limit]` | Rate limiting configuration | [Rate Limiting](./rate-limiting.md) |139 | `[api.rate_limit]` | Rate limiting configuration | [Rate Limiting](./rate-limiting.md) |
144 | `[logging]` | Logging configuration | [Logging](./logging.md) |140 | `[logging]` | Logging configuration | [Logging](./logging.md) |
145 | `[telemetry]` | OpenTelemetry observability export | [Telemetry](./telemetry.md) |141 | `[telemetry]` | OpenTelemetry observability export | [Telemetry](./telemetry.md) |
146-| `[env]` | Installation secret store location | [Environment](./env.md) |
147 | `[volumes]` | Volume management | [Volumes](./volumes.md) |142 | `[volumes]` | Volume management | [Volumes](./volumes.md) |
148 | `[network_isolation]` | Installation network policy | [Network Isolation](./network-isolation.md) |143 | `[network_isolation]` | Installation network policy | [Network Isolation](./network-isolation.md) |
149 | `[external_routes]` | Non-containerized service proxying | [External Routes](./external-routes.md) |144 | `[external_routes]` | Non-containerized service proxying | [External Routes](./external-routes.md) |
@@ -203,7 +198,7 @@ When `auth.enabled=false`, Gordon runs in local-only mode: `/admin/*` is not reg
203 Gordon watches the configuration file and reloads automatically when changes are detected. You can also trigger a manual reload:198 Gordon watches the configuration file and reloads automatically when changes are detected. You can also trigger a manual reload:
204 199
205 ```bash200 ```bash
206-gordon reload201+gordon daemon reload
207 ```202 ```
208 203
209 ### Hot-reloaded (no restart needed)204 ### Hot-reloaded (no restart needed)
M docs/config/logging.md +3 -3
@@ -43,7 +43,7 @@ syslog_identifier = "gordon-access"
43 | `file.max_backups` | int | `3` | Number of old files to keep |43 | `file.max_backups` | int | `3` | Number of old files to keep |
44 | `file.max_age` | int | `28` | Days to keep old files |44 | `file.max_age` | int | `28` | Days to keep old files |
45 45
46-The Admin API and `gordon logs` read from the process log file. Keep `logging.file.enabled` set to `true` if you need process log streaming.46+The Admin API and `gordon daemon logs` read from the process log file. Keep `logging.file.enabled` set to `true` if you need process log streaming.
47 47
48 ### Workload Logs48 ### Workload Logs
49 49
@@ -142,8 +142,8 @@ enabled = false
142 ### Gordon Process Logs142 ### Gordon Process Logs
143 143
144 ```bash144 ```bash
145-gordon logs -f145+gordon daemon logs -f
146-gordon logs -n 100146+gordon daemon logs -n 100
147 tail -f ~/.gordon/logs/gordon.log147 tail -f ~/.gordon/logs/gordon.log
148 journalctl --user -u gordon -f148 journalctl --user -u gordon -f
149 ```149 ```
M docs/config/network-isolation.md +2 -2
@@ -16,7 +16,7 @@ internal = false
16 | Option | Type | Default | Description |16 | Option | Type | Default | Description |
17 |--------|------|---------|-------------|17 |--------|------|---------|-------------|
18 | `enabled` | bool | `true` | Enable Gordon-managed network policy |18 | `enabled` | bool | `true` | Enable Gordon-managed network policy |
19-| `network_prefix` | string | `"gordon"` | Prefix filter for `gordon networks list` |19+| `network_prefix` | string | `"gordon"` | Prefix filter for `gordon daemon networks` |
20 | `internal` | bool | `false` | Create isolated Docker networks with Docker's `Internal` flag, blocking direct external egress from containers on those networks. |20 | `internal` | bool | `false` | Create isolated Docker networks with Docker's `Internal` flag, blocking direct external egress from containers on those networks. |
21 21
22 Per-app isolation is declared in app files, not here: each app gets a private network automatically, and services can join named shared networks with `[[network.shared]]` (see [App Manifest](./apps.md)). Deploy adds AND removes memberships without disconnecting unrelated services. Shared networks are created/reused only within verified Gordon ownership.22 Per-app isolation is declared in app files, not here: each app gets a private network automatically, and services can join named shared networks with `[[network.shared]]` (see [App Manifest](./apps.md)). Deploy adds AND removes memberships without disconnecting unrelated services. Shared networks are created/reused only within verified Gordon ownership.
@@ -34,7 +34,7 @@ Readiness helpers never join shared networks and exist only on the target app's
34 View Gordon-managed networks:34 View Gordon-managed networks:
35 35
36 ```bash36 ```bash
37-gordon networks list37+gordon daemon networks
38 docker network ls | grep gordon38 docker network ls | grep gordon
39 ```39 ```
40 40
M docs/config/reference.md +1 -7
@@ -110,12 +110,6 @@ metrics = true # Export metrics
110 logs = true # Export Gordon, access, and app logs110 logs = true # Export Gordon, access, and app logs
111 trace_sample_rate = 1.0 # Fraction of traces to sample (0.0–1.0)111 trace_sample_rate = 1.0 # Fraction of traces to sample (0.0–1.0)
112 112
113-# =============================================================================
114-# ENVIRONMENT
115-# =============================================================================
116-[env]
117-dir = "" # Env files directory (default: {data_dir}/env)
118-
119 # =============================================================================113 # =============================================================================
120 # CONTAINERS114 # CONTAINERS
121 # =============================================================================115 # =============================================================================
@@ -127,7 +121,7 @@ security_profile = "compat" # "compat" or "strict"
127 # =============================================================================121 # =============================================================================
128 [network_isolation]122 [network_isolation]
129 enabled = true # Installation network policy for Gordon-managed networks123 enabled = true # Installation network policy for Gordon-managed networks
130-network_prefix = "gordon" # Prefix filter for `gordon networks list`124+network_prefix = "gordon" # Prefix filter for `gordon daemon networks`
131 internal = false # Create Docker internal networks (blocks direct egress)125 internal = false # Create Docker internal networks (blocks direct egress)
132 126
133 # =============================================================================127 # =============================================================================
M docs/config/secrets.md +1 -31
@@ -1,6 +1,6 @@
1 # Secrets Configuration1 # Secrets Configuration
2 2
3-Configure how Gordon stores and retrieves sensitive data. Two separate stores exist: installation secrets (this page) and app secret values (managed with `gordon apps secrets`, values in pass under `gordon/apps/<uuid>/<service>/<name>`).3+Configure how Gordon stores and retrieves sensitive data: auth secrets such as `token_secret` (this page) and app secret values (managed with `gordon apps secrets`, values in pass under `gordon/apps/<uuid>/<service>/<name>`).
4 4
5 ## Configuration5 ## Configuration
6 6
@@ -52,10 +52,6 @@ token_secret = "gordon/auth/token_secret" # Path in pass store
52 - Standard Unix tooling52 - Standard Unix tooling
53 - Works with team GPG keys53 - Works with team GPG keys
54 54
55-**Installation secrets storage:**
56-- `gordon secrets set <domain> --from-file` stores per-domain secrets in pass under `gordon/env/<sanitized-domain>/<KEY>` (dots/colons/slashes → underscores)
57-- Eligible plaintext `.env` files are imported into pass at startup and removed only after all entries are stored successfully; conflicts or failures leave the source file for operator review
58-
59 ### SOPS55 ### SOPS
60 56
61 Uses Mozilla SOPS for encrypted file-based secrets:57 Uses Mozilla SOPS for encrypted file-based secrets:
@@ -75,12 +71,6 @@ brew install sops # macOS
75 sops secrets.yaml71 sops secrets.yaml
76 ```72 ```
77 73
78-**Usage in env files:**
79-```bash
80-API_SECRET=${sops:secrets.yaml:api.secret}
81-DB_PASSWORD=${sops:secrets.yaml:database.password}
82-```
83-
84 **Benefits:**74 **Benefits:**
85 - Multiple encryption backends (AWS KMS, GCP KMS, Azure Key Vault, PGP)75 - Multiple encryption backends (AWS KMS, GCP KMS, Azure Key Vault, PGP)
86 - YAML/JSON file encryption76 - YAML/JSON file encryption
@@ -117,26 +107,6 @@ echo "your-token-secret" > ~/.gordon/secrets/gordon/auth/token_secret
117 107
118 > **Warning:** Only use for local development. Secrets are stored in plain text.108 > **Warning:** Only use for local development. Secrets are stored in plain text.
119 109
120-## Secret Provider Syntax
121-
122-In environment files, reference secrets using provider syntax:
123-
124-### Pass Provider
125-
126-```bash
127-# ${pass:<path>}
128-DATABASE_PASSWORD=${pass:myapp/database/password}
129-API_KEY=${pass:myapp/api-key}
130-```
131-
132-### SOPS Provider
133-
134-```bash
135-# ${sops:<file>:<key.path>}
136-DATABASE_PASSWORD=${sops:secrets.yaml:database.password}
137-API_SECRET=${sops:production.yaml:api.key}
138-```
139-
140 ## App Secret Values110 ## App Secret Values
141 111
142 App secret names are registered in the app manifest (`[services.<name>.secrets]` maps ENV name to secret name); values are written separately and stay in pass:112 App secret names are registered in the app manifest (`[services.<name>.secrets]` maps ENV name to secret name); values are written separately and stay in pass:
M docs/config/security-hardening.md +0 -4
@@ -83,10 +83,6 @@ The manifest references only the logical name: `devices = ["transcode-gpu"]`.
83 - Device-bearing creates require Podman 5.4+ or Docker 28.3+ with native CDI configured. Older or unrecognized engines fail with a structured `runtime-unsupported` error and never run without devices.83 - Device-bearing creates require Podman 5.4+ or Docker 28.3+ with native CDI configured. Older or unrecognized engines fail with a structured `runtime-unsupported` error and never run without devices.
84 - Gordon installs no drivers, manages no quotas, and injects no device environment: images carry their own runtime expectations.84 - Gordon installs no drivers, manages no quotas, and injects no device environment: images carry their own runtime expectations.
85 85
86-## Pass import plaintext handling
87-
88-With the `pass` backend, Gordon imports eligible plaintext `.env` files at startup. It removes a source file only after every entry is stored successfully. If a destination entry already exists or an import fails, Gordon fails closed and leaves the plaintext source in place for operator review.
89-
90 ## External image registries86 ## External image registries
91 87
92 Docker Hub, `ghcr.io`, `quay.io`, and Gordon's configured registry are always allowed. Add every other registry hostname and non-default port explicitly:88 Docker Hub, `ghcr.io`, `quay.io`, and Gordon's configured registry are always allowed. Add every other registry hostname and non-default port explicitly:
M docs/config/server.md +1 -2
@@ -32,7 +32,7 @@ protocol = "smart_tcp"
32 | `gordon_domain` | string | **required** | Domain for Gordon (registry + admin API) |32 | `gordon_domain` | string | **required** | Domain for Gordon (registry + admin API) |
33 | `registry_domain` | string | - | Deprecated migration key. Set `gordon_domain` instead. |33 | `registry_domain` | string | - | Deprecated migration key. Set `gordon_domain` instead. |
34 | `legacy_registry_domains` | []string | `[]` | Additional Gordon registry hosts treated as aliases during staged migration. See [Upgrading: Staged Registry Host Rename](../upgrading.md#staged-registry-host-rename). |34 | `legacy_registry_domains` | []string | `[]` | Additional Gordon registry hosts treated as aliases during staged migration. See [Upgrading: Staged Registry Host Rename](../upgrading.md#staged-registry-host-rename). |
35-| `data_dir` | string | `~/.gordon` | Directory for registry data, logs, and env files |35+| `data_dir` | string | `~/.gordon` | Directory for registry data, logs, and state |
36 | `max_proxy_body_size` | string | `"512MB"` | Maximum request body size for proxied requests |36 | `max_proxy_body_size` | string | `"512MB"` | Maximum request body size for proxied requests |
37 | `max_blob_chunk_size` | string | `"95MB"` | Maximum request body size for a single registry blob upload chunk |37 | `max_blob_chunk_size` | string | `"95MB"` | Maximum request body size for a single registry blob upload chunk |
38 | `max_blob_size` | string | `"1GB"` | Maximum cumulative size for one registry blob/layer upload |38 | `max_blob_size` | string | `"1GB"` | Maximum cumulative size for one registry blob/layer upload |
@@ -220,7 +220,6 @@ data_dir = "~/.gordon" # Default for user installations
220 ├── registry/ # Container images and manifests220 ├── registry/ # Container images and manifests
221 │ ├── blobs/221 │ ├── blobs/
222 │ └── manifests/222 │ └── manifests/
223-├── env/ # Installation secret-store data
224 ├── logs/ # Gordon process and file-based access logs223 ├── logs/ # Gordon process and file-based access logs
225 │ ├── gordon.log224 │ ├── gordon.log
226 │ └── access.log225 │ └── access.log
M docs/config/services.md +1 -1
@@ -91,4 +91,4 @@ By default Gordon removes old or disabled service containers while preserving vo
91 91
92 - [Traffic Plane Configuration](./traffic.md)92 - [Traffic Plane Configuration](./traffic.md)
93 - [Configuration Reference](./reference.md)93 - [Configuration Reference](./reference.md)
94-- [CLI traffic status](../cli/traffic.md)94+- [CLI traffic status](../cli/daemon.md#gordon-daemon-traffic)
M docs/config/telemetry.md +1 -1
@@ -110,7 +110,7 @@ Set `auth_token` to the Base64-encoded `user:password` string. Gordon sends it a
110 110
111 For OpenObserve, copy the token from **Ingestion > OTLP** in the web UI.111 For OpenObserve, copy the token from **Ingestion > OTLP** in the web UI.
112 112
113-Since Gordon itself is the platform (not a managed container), it does not use `gordon secrets set`. Store the token with one of these methods:113+Gordon itself is the platform, not an app, so the token does not go through `gordon apps secrets`. Store it with one of these methods:
114 114
115 | Method | How |115 | Method | How |
116 |--------|-----|116 |--------|-----|
M docs/config/traffic.md +1 -1
@@ -178,4 +178,4 @@ UDP sessions are keyed by client address and expire after `idle_timeout`. If `ma
178 - [Server Settings](./server.md)178 - [Server Settings](./server.md)
179 - [App Manifest](./apps.md)179 - [App Manifest](./apps.md)
180 - [Standalone Services](./services.md)180 - [Standalone Services](./services.md)
181-- [CLI traffic status](../cli/traffic.md)181+- [CLI traffic status](../cli/daemon.md#gordon-daemon-traffic)
M docs/deployment/generic-ci.md +8 -8
@@ -8,7 +8,7 @@ Deploy with Gordon from any CI/CD system.
8 - Network access to the public Gordon HTTPS domain; do not expose or target the loopback `server.registry_port`8 - Network access to the public Gordon HTTPS domain; do not expose or target the loopback `server.registry_port`
9 - Gordon binary (optional but recommended)9 - Gordon binary (optional but recommended)
10 10
11-## Recommended: gordon push11+## Recommended: gordon images push
12 12
13 ### 1. Generate Deployment Token13 ### 1. Generate Deployment Token
14 14
@@ -40,7 +40,7 @@ chmod +x /usr/local/bin/gordon
40 40
41 # 2. Build, push, and deploy41 # 2. Build, push, and deploy
42 export GORDON_TOKEN="$GORDON_TOKEN"42 export GORDON_TOKEN="$GORDON_TOKEN"
43-gordon push --build \43+gordon images push --build \
44 --remote "$GORDON_REMOTE" \44 --remote "$GORDON_REMOTE" \
45 45
46 ```46 ```
@@ -52,7 +52,7 @@ Gordon auto-detects the version from CI environment variables:
52 | GitHub Actions | `$GITHUB_REF` | `refs/tags/v1.2.0` |52 | GitHub Actions | `$GITHUB_REF` | `refs/tags/v1.2.0` |
53 | GitLab CI | `$CI_COMMIT_TAG` | `v1.2.0` |53 | GitLab CI | `$CI_COMMIT_TAG` | `v1.2.0` |
54 | Azure DevOps | `$BUILD_SOURCEBRANCH` | `refs/tags/v1.2.0` |54 | Azure DevOps | `$BUILD_SOURCEBRANCH` | `refs/tags/v1.2.0` |
55-| Other | `--tag` flag | `gordon push --tag v1.2.0` |55+| Other | `--tag` flag | `gordon images push --tag v1.2.0` |
56 56
57 ## Alternative: docker push57 ## Alternative: docker push
58 58
@@ -89,7 +89,7 @@ pipeline {
89 curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 \89 curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 \
90 -o /usr/local/bin/gordon90 -o /usr/local/bin/gordon
91 chmod +x /usr/local/bin/gordon91 chmod +x /usr/local/bin/gordon
92- gordon push --build --remote "$GORDON_REMOTE"92+ gordon images push --build --remote "$GORDON_REMOTE"
93 '''93 '''
94 }94 }
95 }95 }
@@ -119,7 +119,7 @@ jobs:
119 - run:119 - run:
120 name: Deploy120 name: Deploy
121 command: |121 command: |
122- gordon push --build \122+ gordon images push --build \
123 --remote "$GORDON_REMOTE"123 --remote "$GORDON_REMOTE"
124 124
125 workflows:125 workflows:
@@ -152,7 +152,7 @@ steps:
152 - apk add --no-cache curl152 - apk add --no-cache curl
153 - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon153 - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon
154 - chmod +x /usr/local/bin/gordon154 - chmod +x /usr/local/bin/gordon
155- - gordon push --build --remote "$GORDON_REMOTE"155+ - gordon images push --build --remote "$GORDON_REMOTE"
156 156
157 trigger:157 trigger:
158 event:158 event:
@@ -188,7 +188,7 @@ gordon apps deploy myapp --remote "$GORDON_REMOTE"
188 188
189 Gordon could not detect a version tag. Either:189 Gordon could not detect a version tag. Either:
190 - Tag your git repo: `git tag v1.0.0 && git push --tags`190 - Tag your git repo: `git tag v1.0.0 && git push --tags`
191-- Pass explicitly: `gordon push --tag v1.0.0`191+- Pass explicitly: `gordon images push --tag v1.0.0`
192 192
193 ### Large images time out193 ### Large images time out
194 194
@@ -200,4 +200,4 @@ Gordon uploads in 50MB chunks. For very large images (> 1GB), the push may take
200 - [GitLab CI](./gitlab-ci.md)200 - [GitLab CI](./gitlab-ci.md)
201 - [Deployment Overview](./index.md)201 - [Deployment Overview](./index.md)
202 - [Authentication](../config/auth.md)202 - [Authentication](../config/auth.md)
203-- [Push Command](../cli/push.md)203+- [Images Commands](../cli/images.md#gordon-images-push)
M docs/deployment/github-actions.md +10 -10
@@ -1,6 +1,6 @@
1 # GitHub Actions Deployment1 # GitHub Actions Deployment
2 2
3-Two approaches for deploying with GitHub Actions: the `gordon push` CLI (recommended) or a Docker-based workflow using Gordon's official action.3+Two approaches for deploying with GitHub Actions: the `gordon images push` CLI (recommended) or a Docker-based workflow using Gordon's official action.
4 4
5 ## Prerequisites5 ## Prerequisites
6 6
@@ -8,9 +8,9 @@ Two approaches for deploying with GitHub Actions: the `gordon push` CLI (recomme
8 2. Deployment token generated with the minimum required scopes: `push,pull` for image transfer, plus `admin:apps:read,admin:apps:write` only when the workflow applies or deploys apps8 2. Deployment token generated with the minimum required scopes: `push,pull` for image transfer, plus `admin:apps:read,admin:apps:write` only when the workflow applies or deploys apps
9 3. GitHub repository secrets configured9 3. GitHub repository secrets configured
10 10
11-## Recommended: gordon push11+## Recommended: gordon images push
12 12
13-Use the `gordon push` CLI for a lightweight, single-step build and deploy.13+Use the `gordon images push` CLI for a lightweight, single-step build and deploy.
14 14
15 ### 1. Generate Token15 ### 1. Generate Token
16 16
@@ -71,7 +71,7 @@ jobs:
71 env:71 env:
72 GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }}72 GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }}
73 run: |73 run: |
74- gordon push --build \74+ gordon images push --build \
75 --remote ${{ secrets.GORDON_REMOTE }}75 --remote ${{ secrets.GORDON_REMOTE }}
76 ```76 ```
77 77
@@ -102,7 +102,7 @@ jobs:
102 env:102 env:
103 GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }}103 GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }}
104 run: |104 run: |
105- gordon push --build \105+ gordon images push --build \
106 --remote ${{ secrets.GORDON_REMOTE }} \106 --remote ${{ secrets.GORDON_REMOTE }} \
107 --tag latest107 --tag latest
108 ```108 ```
@@ -149,7 +149,7 @@ jobs:
149 149
150 #### Monorepo150 #### Monorepo
151 151
152-Deploy multiple services with separate `gordon push` calls:152+Deploy multiple services with separate `gordon images push` calls:
153 153
154 ```yaml154 ```yaml
155 name: Deploy Services155 name: Deploy Services
@@ -171,7 +171,7 @@ jobs:
171 env:171 env:
172 GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }}172 GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }}
173 run: |173 run: |
174- gordon push --build \174+ gordon images push --build \
175 --remote ${{ secrets.GORDON_REMOTE }} \175 --remote ${{ secrets.GORDON_REMOTE }} \
176 --file ./services/api/Dockerfile \176 --file ./services/api/Dockerfile \
177 myapp-api177 myapp-api
@@ -180,7 +180,7 @@ jobs:
180 env:180 env:
181 GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }}181 GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }}
182 run: |182 run: |
183- gordon push --build \183+ gordon images push --build \
184 --remote ${{ secrets.GORDON_REMOTE }} \184 --remote ${{ secrets.GORDON_REMOTE }} \
185 --file ./services/web/Dockerfile \185 --file ./services/web/Dockerfile \
186 myapp-web186 myapp-web
@@ -195,7 +195,7 @@ Pass build arguments to the Docker build:
195 env:195 env:
196 GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }}196 GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }}
197 run: |197 run: |
198- gordon push --build \198+ gordon images push --build \
199 --remote ${{ secrets.GORDON_REMOTE }} \199 --remote ${{ secrets.GORDON_REMOTE }} \
200 --build-arg NODE_ENV=production \200 --build-arg NODE_ENV=production \
201 --build-arg API_URL=https://api.example.com \201 --build-arg API_URL=https://api.example.com \
@@ -478,5 +478,5 @@ Error: unauthorized: authentication required
478 - [Generic CI](./generic-ci.md)478 - [Generic CI](./generic-ci.md)
479 - [Deployment Overview](./index.md)479 - [Deployment Overview](./index.md)
480 - [Authentication](../config/auth.md)480 - [Authentication](../config/auth.md)
481-- [Push Command](../cli/push.md)481+- [Images Commands](../cli/images.md#gordon-images-push)
482 - [Rollback](./rollback.md)482 - [Rollback](./rollback.md)
M docs/deployment/gitlab-ci.md +6 -6
@@ -45,7 +45,7 @@ deploy:
45 - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon45 - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon
46 - chmod +x /usr/local/bin/gordon46 - chmod +x /usr/local/bin/gordon
47 script:47 script:
48- - gordon push --build48+ - gordon images push --build
49 --remote "$GORDON_REMOTE"49 --remote "$GORDON_REMOTE"
50 --token "$GORDON_TOKEN"50 --token "$GORDON_TOKEN"
51 51
@@ -73,7 +73,7 @@ deploy:
73 - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon73 - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon
74 - chmod +x /usr/local/bin/gordon74 - chmod +x /usr/local/bin/gordon
75 script:75 script:
76- - gordon push --build76+ - gordon images push --build
77 --remote "$GORDON_REMOTE"77 --remote "$GORDON_REMOTE"
78 --token "$GORDON_TOKEN"78 --token "$GORDON_TOKEN"
79 79
@@ -96,7 +96,7 @@ deploy:
96 - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon96 - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon
97 - chmod +x /usr/local/bin/gordon97 - chmod +x /usr/local/bin/gordon
98 script:98 script:
99- - gordon push --build99+ - gordon images push --build
100 --remote "$GORDON_REMOTE"100 --remote "$GORDON_REMOTE"
101 --token "$GORDON_TOKEN"101 --token "$GORDON_TOKEN"
102 102
@@ -117,7 +117,7 @@ deploy:
117 - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon117 - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon
118 - chmod +x /usr/local/bin/gordon118 - chmod +x /usr/local/bin/gordon
119 script:119 script:
120- - gordon push --build120+ - gordon images push --build
121 --remote "$GORDON_REMOTE"121 --remote "$GORDON_REMOTE"
122 --token "$GORDON_TOKEN"122 --token "$GORDON_TOKEN"
123 123
@@ -137,7 +137,7 @@ deploy:
137 - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon137 - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon
138 - chmod +x /usr/local/bin/gordon138 - chmod +x /usr/local/bin/gordon
139 script:139 script:
140- - gordon push --build140+ - gordon images push --build
141 --remote "$GORDON_REMOTE"141 --remote "$GORDON_REMOTE"
142 --token "$GORDON_TOKEN"142 --token "$GORDON_TOKEN"
143 --build-arg NODE_ENV=production143 --build-arg NODE_ENV=production
@@ -208,4 +208,4 @@ GitLab CI clones your repository automatically. The build context defaults to th
208 - [Generic CI](./generic-ci.md)208 - [Generic CI](./generic-ci.md)
209 - [Deployment Overview](./index.md)209 - [Deployment Overview](./index.md)
210 - [Authentication](../config/auth.md)210 - [Authentication](../config/auth.md)
211-- [Push Command](../cli/push.md)211+- [Images Commands](../cli/images.md#gordon-images-push)
M docs/deployment/index.md +6 -6
@@ -2,16 +2,16 @@
2 2
3 Gordon deploys apps in two explicit steps: push the image to its built-in registry, then apply the app manifest and deploy. Push transfers OCI content only — it never deploys, creates routes, or modifies manifests.3 Gordon deploys apps in two explicit steps: push the image to its built-in registry, then apply the app manifest and deploy. Push transfers OCI content only — it never deploys, creates routes, or modifies manifests.
4 4
5-## Recommended: gordon push + apps deploy5+## Recommended: gordon images push + apps deploy
6 6
7-`gordon push --build --remote` builds, pushes, and stores the image from CI/CD pipelines. Activation is a separate explicit step with the Gordon CLI.7+`gordon images push --build --remote` builds, pushes, and stores the image from CI/CD pipelines. Activation is a separate explicit step with the Gordon CLI.
8 8
9 - Single secret (`GORDON_TOKEN`): auto-exchanges for a short-lived registry token9 - Single secret (`GORDON_TOKEN`): auto-exchanges for a short-lived registry token
10 - Auto-detects version from CI environment (`$GITHUB_REF`, `$CI_COMMIT_TAG`, `$BUILD_SOURCEBRANCH`, or `git describe`)10 - Auto-detects version from CI environment (`$GITHUB_REF`, `$CI_COMMIT_TAG`, `$BUILD_SOURCEBRANCH`, or `git describe`)
11 - Chunked uploads (50MB chunks) — works behind Cloudflare and restrictive proxies11 - Chunked uploads (50MB chunks) — works behind Cloudflare and restrictive proxies
12 12
13 ```bash13 ```bash
14-gordon push --build --remote https://gordon.example.com14+gordon images push --build --remote https://gordon.example.com
15 ```15 ```
16 16
17 Then activate (from CI with the Gordon binary, or from your machine):17 Then activate (from CI with the Gordon binary, or from your machine):
@@ -25,10 +25,10 @@ gordon apps deploy blog --remote https://gordon.example.com
25 25
26 | Method | Best For | Secrets Needed | Registry Access | Deploy Control |26 | Method | Best For | Secrets Needed | Registry Access | Deploy Control |
27 |--------|----------|----------------|-----------------|----------------|27 |--------|----------|----------------|-----------------|----------------|
28-| `gordon push` + `apps deploy` (Recommended) | CI/CD pipelines | 1 (`GORDON_TOKEN`) | Via gordon domain (HTTPS) | Explicit (CLI-triggered) |28+| `gordon images push` + `apps deploy` (Recommended) | CI/CD pipelines | 1 (`GORDON_TOKEN`) | Via gordon domain (HTTPS) | Explicit (CLI-triggered) |
29 | `docker push` + `apps deploy` | Simple setups, existing Docker workflows | 2 (`username` + `token`) | Via gordon domain (HTTPS) | Explicit (CLI-triggered) |29 | `docker push` + `apps deploy` | Simple setups, existing Docker workflows | 2 (`username` + `token`) | Via gordon domain (HTTPS) | Explicit (CLI-triggered) |
30 30
31-### Method 1: gordon push + apps deploy (Recommended)31+### Method 1: gordon images push + apps deploy (Recommended)
32 32
33 The Gordon CLI handles authentication, image building, and registry upload in a single step. Deploy stays explicit.33 The Gordon CLI handles authentication, image building, and registry upload in a single step. Deploy stays explicit.
34 34
@@ -38,7 +38,7 @@ The Gordon CLI handles authentication, image building, and registry upload in a
38 38
39 ```bash39 ```bash
40 # Build and push (OCI transfer only)40 # Build and push (OCI transfer only)
41-gordon push --build --remote https://gordon.example.com41+gordon images push --build --remote https://gordon.example.com
42 42
43 # Apply the manifest that references the pushed tag, then deploy43 # Apply the manifest that references the pushed tag, then deploy
44 gordon apps apply --file blog.toml --remote https://gordon.example.com44 gordon apps apply --file blog.toml --remote https://gordon.example.com
M docs/getting-started.md +3 -3
@@ -148,7 +148,7 @@ gordon remotes add prod https://gordon.mydomain.com --token <your-token>
148 gordon remotes use prod148 gordon remotes use prod
149 149
150 # Build and push the image (OCI transfer only, never deploys)150 # Build and push the image (OCI transfer only, never deploys)
151-gordon push myapp --build --remote prod151+gordon images push myapp --build --remote prod
152 ```152 ```
153 153
154 Write the app file (`blog.toml`). The file is intended for Git: it must never contain secret values.154 Write the app file (`blog.toml`). The file is intended for Git: it must never contain secret values.
@@ -173,7 +173,7 @@ gordon apps deploy blog --remote prod
173 173
174 What these commands do:174 What these commands do:
175 175
176-- `gordon push` builds, uploads, and stores the image. It never deploys.176+- `gordon images push` builds, uploads, and stores the image. It never deploys.
177 - `gordon apps apply` validates the manifest and persists it as desired state.177 - `gordon apps apply` validates the manifest and persists it as desired state.
178 - `gordon apps deploy` activates the accepted revision: runs preflight, withdraws the service from traffic, stops and removes the old container, starts the new container, waits for readiness, publishes the new `ACTIVE` state, and routes traffic to it.178 - `gordon apps deploy` activates the accepted revision: runs preflight, withdraws the service from traffic, stops and removes the old container, starts the new container, waits for readiness, publishes the new `ACTIVE` state, and routes traffic to it.
179 179
@@ -191,7 +191,7 @@ Push a new image, update the manifest tag if needed, apply, deploy:
191 191
192 ```bash192 ```bash
193 # Make changes, then build + push193 # Make changes, then build + push
194-gordon push myapp --build --remote prod194+gordon images push myapp --build --remote prod
195 195
196 # Deploy the new tag (re-resolves mutable tags)196 # Deploy the new tag (re-resolves mutable tags)
197 gordon apps deploy blog --remote prod197 gordon apps deploy blog --remote prod
M docs/index.md +0 -1
@@ -70,7 +70,6 @@ Gordon runs on your VPS and provides:
70 ### Reference70 ### Reference
71 71
72 - [Docker Labels](./reference/docker-labels.md) - Container and image labels72 - [Docker Labels](./reference/docker-labels.md) - Container and image labels
73-- [Environment Variables](./reference/env-variables.md) - Environment variable syntax
74 - [Troubleshooting](./reference/troubleshooting.md) - Common issues and solutions73 - [Troubleshooting](./reference/troubleshooting.md) - Common issues and solutions
75 74
76 ## Requirements75 ## Requirements
M docs/installation.md +0 -1
@@ -377,7 +377,6 @@ Gordon stores data in the following locations:
377 | `~/.config/gordon/gordon.toml` | Configuration file |377 | `~/.config/gordon/gordon.toml` | Configuration file |
378 | `~/.gordon/` | Default data directory |378 | `~/.gordon/` | Default data directory |
379 | `~/.gordon/registry/` | Container images |379 | `~/.gordon/registry/` | Container images |
380-| `~/.gordon/env/` | Environment files |
381 | `~/.gordon/logs/` | Application logs |380 | `~/.gordon/logs/` | Application logs |
382 | `~/.gordon/secrets/` | Secrets (unsafe backend only) |381 | `~/.gordon/secrets/` | Secrets (unsafe backend only) |
383 382
M docs/migrate-to-v3.md +1 -1
@@ -57,7 +57,7 @@ Manifests written for an early v3 alpha used `[[service]]` with a `name` field;
57 57
58 ## 2. Migrate domain secrets58 ## 2. Migrate domain secrets
59 59
60-Gordon does not convert domain-scoped secrets into app secrets. Inventory the old key names, declare them under `[services.<name>.secrets]`, then apply the manifest before setting values:60+Gordon does not convert domain-scoped secrets into app secrets, and v3 has no command to list them. With the `pass` backend, inventory the old key names with `pass ls gordon/env`, declare them under `[services.<name>.secrets]`, then apply the manifest before setting values:
61 61
62 ```bash62 ```bash
63 gordon apps apply --file ./example-app.toml --remote production63 gordon apps apply --file ./example-app.toml --remote production
D docs/reference/env-variables.md +0 -222
@@ -1,222 +0,0 @@
1-# Environment Variables Reference
2-
3-How Gordon handles environment variables for containers.
4-
5-## Variable Sources
6-
7-Environment variables are loaded from multiple sources and merged:
8-
9-| Source | Priority | Description |
10-|--------|----------|-------------|
11-| Dockerfile `ENV` | Lowest | Default values from image |
12-| `.env` file | Highest | Per-route overrides |
13-
14-Higher priority sources override lower priority values.
15-
16-## Environment File Location
17-
18-Files are stored in the env directory (default: `~/.gordon/env/`):
19-
20-```
21-~/.gordon/env/
22-├── app_mydomain_com.env
23-├── api_mydomain_com.env
24-└── admin_mydomain_com.env
25-```
26-
27-**Naming:** Replace dots with underscores, add `.env` suffix.
28-
29-| Domain | File Name |
30-|--------|-----------|
31-| `app.mydomain.com` | `app_mydomain_com.env` |
32-| `api.company.io` | `api_company_io.env` |
33-| `staging.app.dev` | `staging_app_dev.env` |
34-
35-## File Format
36-
37-Standard `.env` format:
38-
39-```bash
40-# Comments start with #
41-KEY=value
42-ANOTHER_KEY=another value
43-
44-# Quotes are optional but recommended for values with spaces
45-MESSAGE="Hello World"
46-
47-# No spaces around =
48-DATABASE_URL=postgresql://localhost:5432/mydb
49-```
50-
51-## Secret Provider Syntax
52-
53-Reference secrets from configured backends:
54-
55-### Pass Provider
56-
57-```bash
58-# Syntax: ${pass:<path>}
59-DATABASE_PASSWORD=${pass:myapp/database/password}
60-API_KEY=${pass:company/api-key}
61-JWT_SECRET=${pass:production/jwt-secret}
62-```
63-
64-### SOPS Provider
65-
66-```bash
67-# Syntax: ${sops:<file>:<key.path>}
68-DATABASE_PASSWORD=${sops:secrets.yaml:database.password}
69-API_SECRET=${sops:production.yaml:api.secret.key}
70-STRIPE_KEY=${sops:secrets.yaml:stripe.api_key}
71-```
72-
73-## Variable Expansion
74-
75-### From Secrets
76-
77-```bash
78-# Password from pass
79-DB_PASSWORD=${pass:myapp/db-password}
80-
81-# Then use in connection string
82-DATABASE_URL=postgresql://user:${DB_PASSWORD}@db:5432/mydb
83-```
84-
85-### Static Values
86-
87-```bash
88-# Simple values
89-NODE_ENV=production
90-PORT=3000
91-
92-# Values with special characters (use quotes)
93-MESSAGE="Hello, World!"
94-JSON_CONFIG='{"key": "value"}'
95-```
96-
97-## Dockerfile ENV
98-
99-Variables from Dockerfile serve as defaults:
100-
101-```dockerfile
102-FROM node:18
103-ENV NODE_ENV=development
104-ENV PORT=3000
105-ENV LOG_LEVEL=info
106-```
107-
108-Override in `.env` file:
109-
110-```bash
111-# Override NODE_ENV, keep PORT and LOG_LEVEL defaults
112-NODE_ENV=production
113-```
114-
115-Result:
116-- `NODE_ENV=production` (from .env)
117-- `PORT=3000` (from Dockerfile)
118-- `LOG_LEVEL=info` (from Dockerfile)
119-
120-## Common Variables
121-
122-### Node.js
123-
124-```bash
125-NODE_ENV=production
126-PORT=3000
127-LOG_LEVEL=warn
128-```
129-
130-### Python
131-
132-```bash
133-FLASK_ENV=production
134-DJANGO_SETTINGS_MODULE=myapp.settings.production
135-PYTHONUNBUFFERED=1
136-```
137-
138-### Database Connections
139-
140-```bash
141-# PostgreSQL
142-DATABASE_URL=postgresql://user:pass@postgres:5432/mydb
143-
144-# MySQL
145-DATABASE_URL=mysql://user:pass@mysql:3306/mydb
146-
147-# Redis
148-REDIS_URL=redis://redis:6379/0
149-```
150-
151-### External Services
152-
153-```bash
154-# AWS
155-AWS_ACCESS_KEY_ID=${pass:aws/access-key}
156-AWS_SECRET_ACCESS_KEY=${pass:aws/secret-key}
157-AWS_REGION=us-east-1
158-
159-# Stripe
160-STRIPE_SECRET_KEY=${pass:stripe/secret-key}
161-STRIPE_PUBLISHABLE_KEY=pk_live_...
162-
163-# SendGrid
164-SENDGRID_API_KEY=${pass:sendgrid/api-key}
165-```
166-
167-## Examples
168-
169-### Minimal
170-
171-```bash
172-NODE_ENV=production
173-PORT=3000
174-```
175-
176-### With Database
177-
178-```bash
179-NODE_ENV=production
180-PORT=3000
181-DATABASE_URL=postgresql://postgres:5432/myapp
182-DATABASE_PASSWORD=${pass:myapp/db-password}
183-```
184-
185-### Full Production
186-
187-```bash
188-# Application
189-NODE_ENV=production
190-PORT=3000
191-LOG_LEVEL=warn
192-
193-# Database
194-DATABASE_URL=postgresql://postgres:5432/production
195-DATABASE_PASSWORD=${pass:company/db-password}
196-
197-# Cache
198-REDIS_URL=redis://redis:6379/0
199-
200-# External APIs
201-STRIPE_SECRET_KEY=${pass:company/stripe-secret}
202-SENDGRID_API_KEY=${pass:company/sendgrid-key}
203-
204-# Auth
205-JWT_SECRET=${pass:company/jwt-secret}
206-SESSION_SECRET=${pass:company/session-secret}
207-
208-# Feature flags
209-ENABLE_ANALYTICS=true
210-MAINTENANCE_MODE=false
211-```
212-
213-## Viewing Container Environment
214-
215-```bash
216-docker inspect gordon-app-mydomain-com --format '{{json .Config.Env}}' | jq
217-```
218-
219-## Related
220-
221-- [Environment Configuration](../config/env.md)
222-- [Secrets Configuration](../config/secrets.md)
M docs/reference/index.md +0 -1
@@ -5,7 +5,6 @@ Technical reference documentation for Gordon.
5 ## Contents5 ## Contents
6 6
7 - [Docker Labels](./docker-labels.md) - Container and image labels used by Gordon7 - [Docker Labels](./docker-labels.md) - Container and image labels used by Gordon
8-- [Environment Variables](./env-variables.md) - Environment variable syntax and interpolation
9 - [Telemetry & Metrics](../config/telemetry.md) - OpenTelemetry configuration, custom metrics, and trace spans8 - [Telemetry & Metrics](../config/telemetry.md) - OpenTelemetry configuration, custom metrics, and trace spans
10 - [Troubleshooting](./troubleshooting.md) - Common issues and solutions9 - [Troubleshooting](./troubleshooting.md) - Common issues and solutions
11 10
M docs/reference/troubleshooting.md +11 -16
@@ -91,7 +91,7 @@ No restart needed — Podman reads this on each pull/push.
91 1. Per-command flag:91 1. Per-command flag:
92 92
93 ```bash93 ```bash
94- gordon push myapp:latest --insecure94+ gordon images push myapp:latest --insecure
95 ```95 ```
96 96
97 2. Environment variable:97 2. Environment variable:
@@ -164,7 +164,7 @@ gordon apps deploy app
164 164
165 2. Check Gordon logs:165 2. Check Gordon logs:
166 ```bash166 ```bash
167- gordon logs -f167+ gordon daemon logs -f
168 ```168 ```
169 169
170 3. Run container manually to debug:170 3. Run container manually to debug:
@@ -174,28 +174,23 @@ gordon apps deploy app
174 174
175 ### Environment variables not loaded175 ### Environment variables not loaded
176 176
177-**Cause:** Env file not found or wrong format.177+**Cause:** The variable is not declared in the app file, or its secret value was never set.
178 178
179 **Solutions:**179 **Solutions:**
180 180
181-1. Check file exists with correct name:181+1. Declare public values under `[env]` and service values under `[services.<name>.secrets]` in the app file, then apply it:
182 ```bash182 ```bash
183- ls ~/.gordon/env/183+ gordon apps apply --file ./blog.toml
184- # Should show: app_mydomain_com.env (dots → underscores)
185 ```184 ```
186 185
187-2. Check file permissions:186+2. Check which secret values are set (names only):
188 ```bash187 ```bash
189- chmod 600 ~/.gordon/env/app_mydomain_com.env188+ gordon apps secrets list blog
190 ```189 ```
191 190
192-3. Check file format (no spaces around `=`):191+3. Deploy or restart: values apply on the next deploy/restart, never to running containers.
193 ```bash192 ```bash
194- # Correct193+ gordon apps restart blog
195- KEY=value
196-
197- # Wrong
198- KEY = value
199 ```194 ```
200 195
201 ### Secrets not resolved196 ### Secrets not resolved
@@ -318,7 +313,7 @@ gordon apps deploy app
318 313
319 **Solution:** Manual reload:314 **Solution:** Manual reload:
320 ```bash315 ```bash
321-gordon reload316+gordon daemon reload
322 ```317 ```
323 318
324 ### Stale `targets.toml` in config directory319 ### Stale `targets.toml` in config directory
@@ -372,7 +367,7 @@ path = "~/.gordon/logs/gordon.log"
372 systemctl --user status gordon367 systemctl --user status gordon
373 368
374 # View Gordon logs369 # View Gordon logs
375-gordon logs -f370+gordon daemon logs -f
376 journalctl --user -u gordon -f371 journalctl --user -u gordon -f
377 372
378 # List containers373 # List containers
M docs/upgrading.md +6 -4
@@ -13,7 +13,9 @@ Follow [Migrate to Gordon v3](./migrate-to-v3.md) for the complete cutover proce
13 - App-workload keys in `gordon.toml`: `[routes]`, `[attachments]`, `[network_groups]`, `[service_routes]`, `[auto_route]` (+ `_allowed_domains`), and `[previews]`. Gordon fails boot/reload closed with a `config-retired` diagnostic naming the fix when any of them is present — never a silent migration.13 - App-workload keys in `gordon.toml`: `[routes]`, `[attachments]`, `[network_groups]`, `[service_routes]`, `[auto_route]` (+ `_allowed_domains`), and `[previews]`. Gordon fails boot/reload closed with a `config-retired` diagnostic naming the fix when any of them is present — never a silent migration.
14 - Installation-level `[[services]]` (standalone L4 workloads) and `[[network_services]]` (L4 traffic plane) stay valid. Only their old app-workload semantics were removed; declare application workloads in standalone files instead. See [Standalone Services](./config/services.md).14 - Installation-level `[[services]]` (standalone L4 workloads) and `[[network_services]]` (L4 traffic plane) stay valid. Only their old app-workload semantics were removed; declare application workloads in standalone files instead. See [Standalone Services](./config/services.md).
15 - CLI: `pin`, `preview`, `attachments`, `bootstrap`, `autoroute allow`, `routes add/remove/purge`, push deploy/route inference, implicit deploys on push/reload, label/env-file inference. Removed HTTP mutation endpoints answer `410 Gone`.15 - CLI: `pin`, `preview`, `attachments`, `bootstrap`, `autoroute allow`, `routes add/remove/purge`, push deploy/route inference, implicit deploys on push/reload, label/env-file inference. Removed HTTP mutation endpoints answer `410 Gone`.
16-- Scopes `admin:routes:*` are replaced by `admin:apps:read` (list, show, diff, status) and `admin:apps:write` (apply, deploy, lifecycle, secrets). Regenerate CI tokens, e.g. `--scopes "push,pull,admin:apps:read,admin:apps:write"`.16+- Domain secrets: `gordon secrets`, `/admin/secrets` (now `410 Gone`), the `admin:secrets:*` scopes, the `[env]` config section, and startup `.env` import into pass. Use `gordon apps secrets`. Existing `gordon/env/...` pass entries are left untouched.
17+- CLI layout: `gordon push` is now `gordon images push`. `status`, `logs`, `reload`, `config`, `tls status`, `traffic status`, and `networks list` moved under `gordon daemon` (`gordon daemon status`, `gordon daemon tls`, `gordon daemon traffic`, `gordon daemon networks`, …). There are no aliases.
18+- Scopes `admin:routes:*` and `admin:secrets:*` are replaced by `admin:apps:read` (list, show, diff, status) and `admin:apps:write` (apply, deploy, lifecycle, secrets). Regenerate CI tokens, e.g. `--scopes "push,pull,admin:apps:read,admin:apps:write"`.
17 - No historical rollback command: roll back by applying a manifest that references the previous tag and deploying again.19 - No historical rollback command: roll back by applying a manifest that references the previous tag and deploying again.
18 20
19 ### Migrating a v3 alpha app manifest21 ### Migrating a v3 alpha app manifest
@@ -52,7 +54,7 @@ Names containing dots must be quoted: `[services."web.api"]` and `[[services."we
52 ### Manual migration54 ### Manual migration
53 55
54 1. Back up databases/volumes and pass entries with the existing procedures. Record original ownership and image versions. No update hook deletes volumes: unknown resources are preserved, never adopted.56 1. Back up databases/volumes and pass entries with the existing procedures. Record original ownership and image versions. No update hook deletes volumes: unknown resources are preserved, never adopted.
55-2. Delete the removed keys from `gordon.toml` (installation settings only: entrypoints, TLS, limits, external routes, images policy, backups destinations stay).57+2. Delete the removed keys, including `[env]`, from `gordon.toml` (installation settings only: entrypoints, TLS, limits, external routes, images policy, backups destinations stay).
56 3. Write one `<app>.toml` per app (see [App Manifest](./config/apps.md)): services, `[[services.<name>.http]]` hosts, `[services.<name>.secrets]` names, volumes, `[[network.shared]]`, backup declarations.58 3. Write one `<app>.toml` per app (see [App Manifest](./config/apps.md)): services, `[[services.<name>.http]]` hosts, `[services.<name>.secrets]` names, volumes, `[[network.shared]]`, backup declarations.
57 4. Migrate secret values explicitly with `gordon apps secrets set` after applying each manifest. Gordon does not copy `gordon/env/<domain>/...` entries into `gordon/apps/<uuid>/<service>/...`; follow the [v3 secrets migration procedure](./migrate-to-v3.md#2-migrate-domain-secrets).59 4. Migrate secret values explicitly with `gordon apps secrets set` after applying each manifest. Gordon does not copy `gordon/env/<domain>/...` entries into `gordon/apps/<uuid>/<service>/...`; follow the [v3 secrets migration procedure](./migrate-to-v3.md#2-migrate-domain-secrets).
58 5. `gordon apps apply --file <app>.toml`, then `gordon apps deploy <app>`.60 5. `gordon apps apply --file <app>.toml`, then `gordon apps deploy <app>`.
@@ -150,7 +152,7 @@ registry_domain = "gordon.example.com"
150 gordon_domain = "gordon.example.com"152 gordon_domain = "gordon.example.com"
151 ```153 ```
152 154
153-If you do not migrate, `gordon status --remote ...` and `gordon apps list --remote ...` can fail with `/auth/token` `404`, and `reg-domain/v2/` or `/admin/status` can return `404`.155+If you do not migrate, `gordon daemon status --remote ...` and `gordon apps list --remote ...` can fail with `/auth/token` `404`, and `reg-domain/v2/` or `/admin/status` can return `404`.
154 156
155 ### Staged Registry Host Rename157 ### Staged Registry Host Rename
156 158
@@ -477,7 +479,7 @@ If using pass or sops, update your secret paths:
477 ```479 ```
478 6. **Check logs** for any errors:480 6. **Check logs** for any errors:
479 ```bash481 ```bash
480- gordon logs482+ gordon daemon logs
481 ```483 ```
482 484
483 ## Getting Help485 ## Getting Help
M gordon.toml.example +1 -4
@@ -62,7 +62,7 @@ security_profile = "compat"
62 62
63 [network_isolation]63 [network_isolation]
64 # Installation network policy for Gordon-managed networks (prefix filter64 # Installation network policy for Gordon-managed networks (prefix filter
65-# for `gordon networks list`). Per-app isolation is removed: apps declare65+# for `gordon daemon networks`). Per-app isolation is removed: apps declare
66 # `[[network.shared]]` membership in their app file instead.66 # `[[network.shared]]` membership in their app file instead.
67 enabled = true67 enabled = true
68 network_prefix = "gordon"68 network_prefix = "gordon"
@@ -151,9 +151,6 @@ metrics = true
151 logs = true151 logs = true
152 trace_sample_rate = 1.0152 trace_sample_rate = 1.0
153 153
154-[env]
155-# dir = "~/.gordon/env"
156-
157 [backups.databases]154 [backups.databases]
158 enabled = false155 enabled = false
159 schedule = "daily"156 schedule = "daily"
D internal/adapters/dto/admin_secrets.go +0 -12
@@ -1,12 +0,0 @@
1-package dto
2-
3-// SecretsListResponse represents a list of secret keys for a domain.
4-type SecretsListResponse struct {
5- Domain string `json:"domain"`
6- Keys []string `json:"keys"`
7-}
8-
9-// SecretsStatusResponse represents a secrets write response.
10-type SecretsStatusResponse struct {
11- Status string `json:"status"`
12-}
M internal/adapters/in/cli/config.go +3 -3
@@ -32,9 +32,9 @@ func newConfigShowCmd() *cobra.Command {
32 auto-route, network isolation, routes, and external routes.32 auto-route, network isolation, routes, and external routes.
33 33
34 Examples:34 Examples:
35- gordon config show35+ gordon daemon config show
36- gordon config show --json36+ gordon daemon config show --json
37- gordon config show --remote https://gordon.mydomain.com --token $TOKEN`,37+ gordon daemon config show --remote prod`,
38 RunE: func(cmd *cobra.Command, _ []string) error {38 RunE: func(cmd *cobra.Command, _ []string) error {
39 ctx := cmd.Context()39 ctx := cmd.Context()
40 handle, err := resolveControlPlane(cliConfigPath)40 handle, err := resolveControlPlane(cliConfigPath)
M internal/adapters/in/cli/config_validate.go +1 -1
@@ -30,7 +30,7 @@ func newConfigValidateCmd() *cobra.Command {
30 Args: cobra.NoArgs,30 Args: cobra.NoArgs,
31 RunE: func(cmd *cobra.Command, _ []string) error {31 RunE: func(cmd *cobra.Command, _ []string) error {
32 if remoteFlag != "" {32 if remoteFlag != "" {
33- return fmt.Errorf("config validate is local-only; --remote is not supported")33+ return fmt.Errorf("daemon config validate is local-only; --remote is not supported")
34 }34 }
35 return runConfigValidate(cmd.OutOrStdout(), file, jsonOut)35 return runConfigValidate(cmd.OutOrStdout(), file, jsonOut)
36 },36 },
M internal/adapters/in/cli/controlplane.go +0 -4
@@ -27,10 +27,6 @@ type ControlPlane interface {
27 SetAppSecrets(ctx context.Context, app string, req dto.AppSecretSetRequest) error27 SetAppSecrets(ctx context.Context, app string, req dto.AppSecretSetRequest) error
28 DeleteAppSecret(ctx context.Context, app string, req dto.AppSecretDeleteRequest) error28 DeleteAppSecret(ctx context.Context, app string, req dto.AppSecretDeleteRequest) error
29 29
30- ListSecrets(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error)
31- SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error
32- DeleteSecret(ctx context.Context, secretDomain, key string) error
33-
34 GetStatus(ctx context.Context) (*remote.Status, error)30 GetStatus(ctx context.Context) (*remote.Status, error)
35 GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error)31 GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error)
36 GetTrafficStatus(ctx context.Context) (*dto.TrafficStatusResponse, error)32 GetTrafficStatus(ctx context.Context) (*dto.TrafficStatusResponse, error)
M internal/adapters/in/cli/controlplane_remote.go +0 -12
@@ -72,18 +72,6 @@ func (r *remoteControlPlane) DeleteAppSecret(ctx context.Context, app string, re
72 return r.client.DeleteAppSecret(ctx, app, req)72 return r.client.DeleteAppSecret(ctx, app, req)
73 }73 }
74 74
75-func (r *remoteControlPlane) ListSecrets(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) {
76- return r.client.ListSecretsWithAttachments(ctx, secretDomain)
77-}
78-
79-func (r *remoteControlPlane) SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error {
80- return r.client.SetSecrets(ctx, secretDomain, secrets)
81-}
82-
83-func (r *remoteControlPlane) DeleteSecret(ctx context.Context, secretDomain, key string) error {
84- return r.client.DeleteSecret(ctx, secretDomain, key)
85-}
86-
87 func (r *remoteControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) {75 func (r *remoteControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) {
88 return r.client.GetStatus(ctx)76 return r.client.GetStatus(ctx)
89 }77 }
M internal/adapters/in/cli/controlplane_resolver.go +0 -4
@@ -51,10 +51,6 @@ func newRemoteControlPlaneHandle(target *remote.ResolvedRemote) *controlPlaneHan
51 return &controlPlaneHandle{plane: NewRemoteControlPlane(client), isRemote: true}51 return &controlPlaneHandle{plane: NewRemoteControlPlane(client), isRemote: true}
52 }52 }
53 53
54-func resolveControlPlaneForDomain(ctx context.Context, _ string) (*controlPlaneHandle, error) {
55- return resolveControlPlaneWithInference(ctx, inferExplicitTarget)
56-}
57-
58 func resolveControlPlaneForRepository(ctx context.Context, repository string) (*controlPlaneHandle, error) {54 func resolveControlPlaneForRepository(ctx context.Context, repository string) (*controlPlaneHandle, error) {
59 return resolveControlPlaneWithInference(ctx, func(ctx context.Context) (*remote.ResolvedRemote, error) {55 return resolveControlPlaneWithInference(ctx, func(ctx context.Context) (*remote.ResolvedRemote, error) {
60 return inferRemoteForRepository(ctx, repository)56 return inferRemoteForRepository(ctx, repository)
A internal/adapters/in/cli/daemon.go +29 -0
@@ -0,0 +1,29 @@
1+package cli
2+
3+import "github.com/spf13/cobra"
4+
5+// newDaemonCmd groups commands that inspect or operate the Gordon daemon
6+// itself rather than the apps it runs. They target the local daemon or the
7+// one selected with --remote.
8+func newDaemonCmd() *cobra.Command {
9+ cmd := &cobra.Command{
10+ Use: "daemon",
11+ Short: "Inspect and operate the Gordon daemon",
12+ Long: `Inspect and operate the Gordon daemon: status, process logs,
13+configuration, TLS, traffic, and networks.
14+
15+Targets the local daemon, or the one selected with --remote.`,
16+ }
17+
18+ cmd.AddCommand(
19+ newStatusCmd(),
20+ newLogsCmd(),
21+ newReloadCmd(),
22+ newConfigCmd(),
23+ newTLSCmd(),
24+ newTrafficCmd(),
25+ newNetworksCmd(),
26+ )
27+
28+ return cmd
29+}
M internal/adapters/in/cli/images.go +4 -3
@@ -69,10 +69,11 @@ var imagesListTableColumns = []components.TableColumn{
69 func newImagesCmd() *cobra.Command {69 func newImagesCmd() *cobra.Command {
70 cmd := &cobra.Command{70 cmd := &cobra.Command{
71 Use: "images",71 Use: "images",
72- Short: "List and prune images",72+ Short: "Push, list, and prune images",
73- Long: `Inspect and clean up runtime and registry images through the selected daemon.`,73+ Long: `Push, inspect, and clean up runtime and registry images through the selected daemon.`,
74 }74 }
75 75
76+ cmd.AddCommand(newPushCmd())
76 cmd.AddCommand(newImagesListCmd())77 cmd.AddCommand(newImagesListCmd())
77 cmd.AddCommand(newImagesPruneCmd())78 cmd.AddCommand(newImagesPruneCmd())
78 cmd.AddCommand(newImagesTagsCmd())79 cmd.AddCommand(newImagesTagsCmd())
@@ -144,7 +145,7 @@ func newImagesTagsCmd() *cobra.Command {
144 Examples:145 Examples:
145 gordon images tags myapp146 gordon images tags myapp
146 gordon images tags myapp --json147 gordon images tags myapp --json
147- gordon images tags myapp --remote https://gordon.mydomain.com --token $TOKEN`,148+ gordon images tags myapp --remote prod`,
148 Args: cobra.ExactArgs(1),149 Args: cobra.ExactArgs(1),
149 RunE: func(cmd *cobra.Command, args []string) error {150 RunE: func(cmd *cobra.Command, args []string) error {
150 ctx := cmd.Context()151 ctx := cmd.Context()
M internal/adapters/in/cli/json_parity_test.go +3 -4
@@ -12,7 +12,6 @@ func TestAllListCommands_AcceptJSONFlag(t *testing.T) {
12 name string12 name string
13 builder func() *cobra.Command13 builder func() *cobra.Command
14 }{14 }{
15- {"secrets list", newSecretsListCmd},
16 {"images list", newImagesListCmd},15 {"images list", newImagesListCmd},
17 {"backup list", newBackupListCmd},16 {"backup list", newBackupListCmd},
18 {"backup volumes list", newVolumeBackupListCmd},17 {"backup volumes list", newVolumeBackupListCmd},
@@ -20,10 +19,10 @@ func TestAllListCommands_AcceptJSONFlag(t *testing.T) {
20 {"backup volumes status", newVolumeBackupStatusCmd},19 {"backup volumes status", newVolumeBackupStatusCmd},
21 {"auth token list", newTokenListCmd},20 {"auth token list", newTokenListCmd},
22 {"remotes list", newRemotesListCmd},21 {"remotes list", newRemotesListCmd},
23- {"config show", newConfigShowCmd},22+ {"daemon config show", newConfigShowCmd},
24- {"networks list", newNetworksListCmd},23+ {"daemon networks", newNetworksCmd},
25 {"images tags", newImagesTagsCmd},24 {"images tags", newImagesTagsCmd},
26- {"tls status", newTLSStatusCmd},25+ {"daemon tls", newTLSCmd},
27 }26 }
28 27
29 for _, tc := range commands {28 for _, tc := range commands {
M internal/adapters/in/cli/json_test.go +0 -25
@@ -10,19 +10,9 @@ import (
10 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
11 11
12 "github.com/bnema/gordon/internal/adapters/dto"12 "github.com/bnema/gordon/internal/adapters/dto"
13- "github.com/bnema/gordon/internal/adapters/in/cli/remote"
14 "github.com/bnema/gordon/internal/domain"13 "github.com/bnema/gordon/internal/domain"
15 )14 )
16 15
17-func TestSecretsList_JSONFlag_Accepted(t *testing.T) {
18- cmd := newSecretsListCmd()
19- f := cmd.Flags().Lookup("json")
20- assert.NotNil(t, f)
21- if f != nil {
22- assert.Equal(t, "false", f.DefValue)
23- }
24-}
25-
26 func TestImagesList_JSONFlag_Accepted(t *testing.T) {16 func TestImagesList_JSONFlag_Accepted(t *testing.T) {
27 cmd := newImagesListCmd()17 cmd := newImagesListCmd()
28 f := cmd.Flags().Lookup("json")18 f := cmd.Flags().Lookup("json")
@@ -147,21 +137,6 @@ func TestRemotesList_JSONShape_RoundTripsRemoteObjects(t *testing.T) {
147 assert.True(t, got[0].InsecureTLS)137 assert.True(t, got[0].InsecureTLS)
148 }138 }
149 139
150-func TestSecretsList_JSONShape_RoundTripsPayload(t *testing.T) {
151- payload := remote.SecretsListResult{
152- Domain: "app.example.com",
153- Keys: []string{"API_KEY"},
154- }
155-
156- encoded, err := json.Marshal(payload)
157- require.NoError(t, err)
158-
159- var got remote.SecretsListResult
160- require.NoError(t, json.Unmarshal(encoded, &got))
161- assert.Equal(t, payload.Domain, got.Domain)
162- assert.Equal(t, payload.Keys, got.Keys)
163-}
164-
165 func TestWriteJSON_ProducesValidIndentedJSON(t *testing.T) {140 func TestWriteJSON_ProducesValidIndentedJSON(t *testing.T) {
166 var out bytes.Buffer141 var out bytes.Buffer
167 err := writeJSON(&out, map[string]any{"ok": true})142 err := writeJSON(&out, map[string]any{"ok": true})
M internal/adapters/in/cli/local.go +0 -61
@@ -4,25 +4,18 @@ package cli
4 import (4 import (
5 "context"5 "context"
6 "fmt"6 "fmt"
7- "path/filepath"
8 "strings"7 "strings"
9 8
10- "github.com/bnema/zerowrap"
11 "github.com/spf13/viper"9 "github.com/spf13/viper"
12 10
13- "github.com/bnema/gordon/internal/adapters/out/domainsecrets"
14 "github.com/bnema/gordon/internal/app"11 "github.com/bnema/gordon/internal/app"
15 "github.com/bnema/gordon/internal/boundaries/in"12 "github.com/bnema/gordon/internal/boundaries/in"
16- "github.com/bnema/gordon/internal/boundaries/out"
17- "github.com/bnema/gordon/internal/domain"
18 "github.com/bnema/gordon/internal/usecase/config"13 "github.com/bnema/gordon/internal/usecase/config"
19- secretsSvc "github.com/bnema/gordon/internal/usecase/secrets"
20 )14 )
21 15
22 // LocalServices provides direct access to local services for CLI operations.16 // LocalServices provides direct access to local services for CLI operations.
23 type LocalServices struct {17 type LocalServices struct {
24 configSvc in.ConfigService18 configSvc in.ConfigService
25- secretSvc in.SecretService
26 dataDir string19 dataDir string
27 tlsEnabled bool20 tlsEnabled bool
28 }21 }
@@ -32,11 +25,6 @@ func (l *LocalServices) GetConfigService() in.ConfigService {
32 return l.configSvc25 return l.configSvc
33 }26 }
34 27
35-// GetSecretService returns the secret service.
36-func (l *LocalServices) GetSecretService() in.SecretService {
37- return l.secretSvc
38-}
39-
40 // GetDataDir returns the data directory.28 // GetDataDir returns the data directory.
41 func (l *LocalServices) GetDataDir() string {29 func (l *LocalServices) GetDataDir() string {
42 return l.dataDir30 return l.dataDir
@@ -67,8 +55,6 @@ func GetLocalServices(cliConfigPath string) (*LocalServices, error) {
67 // Config file not found is OK for some operations55 // Config file not found is OK for some operations
68 }56 }
69 57
70- log := zerowrap.New(cliLogConfig)
71-
72 // Create config service (without event bus for CLI operations)58 // Create config service (without event bus for CLI operations)
73 configSvc := config.NewService(v, nil)59 configSvc := config.NewService(v, nil)
74 ctx := context.Background()60 ctx := context.Background()
@@ -76,27 +62,13 @@ func GetLocalServices(cliConfigPath string) (*LocalServices, error) {
76 return nil, fmt.Errorf("failed to load configuration: %w", err)62 return nil, fmt.Errorf("failed to load configuration: %w", err)
77 }63 }
78 64
79- // Determine data directory and env directory
80 dataDir := v.GetString("server.data_dir")65 dataDir := v.GetString("server.data_dir")
81 if dataDir == "" {66 if dataDir == "" {
82 dataDir = app.DefaultDataDir()67 dataDir = app.DefaultDataDir()
83 }68 }
84 69
85- envDir := v.GetString("env.dir")
86- if envDir == "" {
87- envDir = filepath.Join(dataDir, "env")
88- }
89-
90- // Create domain secret store using configured backend (pass or file-based)
91- domainSecretStore, err := createLocalDomainSecretStore(v, envDir, log)
92- if err != nil {
93- return nil, fmt.Errorf("failed to create domain secret store: %w", err)
94- }
95- secretSvc := secretsSvc.NewService(domainSecretStore, log, nil)
96-
97 return &LocalServices{70 return &LocalServices{
98 configSvc: configSvc,71 configSvc: configSvc,
99- secretSvc: secretSvc,
100 dataDir: dataDir,72 dataDir: dataDir,
101 tlsEnabled: hasLocalTLSCapableEntrypoint(v),73 tlsEnabled: hasLocalTLSCapableEntrypoint(v),
102 }, nil74 }, nil
@@ -115,36 +87,3 @@ func hasLocalTLSCapableEntrypoint(v *viper.Viper) bool {
115 }87 }
116 return false88 return false
117 }89 }
118-
119-func createLocalDomainSecretStore(v *viper.Viper, envDir string, log zerowrap.Logger) (out.DomainSecretStore, error) {
120- backend := resolveLocalSecretsBackend(v)
121- switch backend {
122- case domain.SecretsBackendPass:
123- return domainsecrets.NewPassStore(log)
124- case domain.SecretsBackendSops:
125- return nil, fmt.Errorf("sops backend not yet supported for domain secrets")
126- default:
127- return domainsecrets.NewFileStore(envDir, log)
128- }
129-}
130-
131-func resolveLocalSecretsBackend(v *viper.Viper) domain.SecretsBackend {
132- backend := strings.TrimSpace(v.GetString("auth.secrets_backend"))
133- if backend == "" {
134- // Legacy key support for older configs.
135- backend = strings.TrimSpace(v.GetString("secrets.backend"))
136- }
137-
138- switch backend {
139- case "pass":
140- return domain.SecretsBackendPass
141- case "sops":
142- return domain.SecretsBackendSops
143- case "unsafe", "":
144- return domain.SecretsBackendUnsafe
145- default:
146- log := zerowrap.Default()
147- log.Warn().Str("backend", backend).Msg("unrecognized secrets backend, falling back to unsafe")
148- return domain.SecretsBackendUnsafe
149- }
150-}
M internal/adapters/in/cli/local_test.go +0 -66
@@ -3,41 +3,10 @@ package cli
3 import (3 import (
4 "testing"4 "testing"
5 5
6- "github.com/bnema/zerowrap"
7 "github.com/spf13/viper"6 "github.com/spf13/viper"
8 "github.com/stretchr/testify/require"7 "github.com/stretchr/testify/require"
9-
10- "github.com/bnema/gordon/internal/adapters/out/domainsecrets"
11- "github.com/bnema/gordon/internal/domain"
12 )8 )
13 9
14-func TestResolveLocalSecretsBackend(t *testing.T) {
15- t.Run("auth backend takes precedence", func(t *testing.T) {
16- v := viper.New()
17- v.Set("auth.secrets_backend", "pass")
18- v.Set("secrets.backend", "unsafe")
19-
20- got := resolveLocalSecretsBackend(v)
21- require.Equal(t, domain.SecretsBackendPass, got)
22- })
23-
24- t.Run("falls back to legacy secrets backend key", func(t *testing.T) {
25- v := viper.New()
26- v.Set("secrets.backend", "pass")
27-
28- got := resolveLocalSecretsBackend(v)
29- require.Equal(t, domain.SecretsBackendPass, got)
30- })
31-
32- t.Run("defaults to unsafe for unknown backend", func(t *testing.T) {
33- v := viper.New()
34- v.Set("auth.secrets_backend", "wat")
35-
36- got := resolveLocalSecretsBackend(v)
37- require.Equal(t, domain.SecretsBackendUnsafe, got)
38- })
39-}
40-
41 func TestHasLocalTLSCapableEntrypoint(t *testing.T) {10 func TestHasLocalTLSCapableEntrypoint(t *testing.T) {
42 for _, tt := range []struct {11 for _, tt := range []struct {
43 name string12 name string
@@ -56,38 +25,3 @@ func TestHasLocalTLSCapableEntrypoint(t *testing.T) {
56 })25 })
57 }26 }
58 }27 }
59-
60-func TestCreateLocalDomainSecretStore_UsesPassStoreForPass(t *testing.T) {
61- // pass(1) must be available on the system for this test
62- if _, err := domainsecrets.NewPassStore(zerowrap.New(zerowrap.Config{Level: "error", Format: "console"})); err != nil {
63- t.Skipf("pass store not available: %v", err)
64- }
65-
66- v := viper.New()
67- v.Set("auth.secrets_backend", "pass")
68-
69- log := zerowrap.New(zerowrap.Config{Level: "error", Format: "console"})
70- store, err := createLocalDomainSecretStore(v, t.TempDir(), log)
71- require.NoError(t, err)
72- require.IsType(t, &domainsecrets.PassStore{}, store)
73-}
74-
75-func TestCreateLocalDomainSecretStore_RejectsSopsBackend(t *testing.T) {
76- v := viper.New()
77- v.Set("auth.secrets_backend", "sops")
78-
79- log := zerowrap.New(zerowrap.Config{Level: "error", Format: "console"})
80- _, err := createLocalDomainSecretStore(v, t.TempDir(), log)
81- require.Error(t, err)
82- require.Contains(t, err.Error(), "sops backend not yet supported")
83-}
84-
85-func TestCreateLocalDomainSecretStore_UsesFileStoreForUnsafe(t *testing.T) {
86- v := viper.New()
87- v.Set("auth.secrets_backend", "unsafe")
88-
89- log := zerowrap.New(zerowrap.Config{Level: "error", Format: "console"})
90- store, err := createLocalDomainSecretStore(v, t.TempDir(), log)
91- require.NoError(t, err)
92- require.IsType(t, &domainsecrets.FileStore{}, store)
93-}
M internal/adapters/in/cli/mocks/mock_control_plane.go +0 -194
@@ -242,69 +242,6 @@ func (_c *MockControlPlane_DeleteAppSecret_Call) RunAndReturn(run func(ctx conte
242 return _c242 return _c
243 }243 }
244 244
245-// DeleteSecret provides a mock function for the type MockControlPlane
246-func (_mock *MockControlPlane) DeleteSecret(ctx context.Context, secretDomain string, key string) error {
247- ret := _mock.Called(ctx, secretDomain, key)
248-
249- if len(ret) == 0 {
250- panic("no return value specified for DeleteSecret")
251- }
252-
253- var r0 error
254- if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok {
255- r0 = returnFunc(ctx, secretDomain, key)
256- } else {
257- r0 = ret.Error(0)
258- }
259- return r0
260-}
261-
262-// MockControlPlane_DeleteSecret_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteSecret'
263-type MockControlPlane_DeleteSecret_Call struct {
264- *mock.Call
265-}
266-
267-// DeleteSecret is a helper method to define mock.On call
268-// - ctx context.Context
269-// - secretDomain string
270-// - key string
271-func (_e *MockControlPlane_Expecter) DeleteSecret(ctx any, secretDomain any, key any) *MockControlPlane_DeleteSecret_Call {
272- return &MockControlPlane_DeleteSecret_Call{Call: _e.mock.On("DeleteSecret", ctx, secretDomain, key)}
273-}
274-
275-func (_c *MockControlPlane_DeleteSecret_Call) Run(run func(ctx context.Context, secretDomain string, key string)) *MockControlPlane_DeleteSecret_Call {
276- _c.Call.Run(func(args mock.Arguments) {
277- var arg0 context.Context
278- if args[0] != nil {
279- arg0 = args[0].(context.Context)
280- }
281- var arg1 string
282- if args[1] != nil {
283- arg1 = args[1].(string)
284- }
285- var arg2 string
286- if args[2] != nil {
287- arg2 = args[2].(string)
288- }
289- run(
290- arg0,
291- arg1,
292- arg2,
293- )
294- })
295- return _c
296-}
297-
298-func (_c *MockControlPlane_DeleteSecret_Call) Return(err error) *MockControlPlane_DeleteSecret_Call {
299- _c.Call.Return(err)
300- return _c
301-}
302-
303-func (_c *MockControlPlane_DeleteSecret_Call) RunAndReturn(run func(ctx context.Context, secretDomain string, key string) error) *MockControlPlane_DeleteSecret_Call {
304- _c.Call.Return(run)
305- return _c
306-}
307-
308 // DeployApp provides a mock function for the type MockControlPlane245 // DeployApp provides a mock function for the type MockControlPlane
309 func (_mock *MockControlPlane) DeployApp(ctx context.Context, app string, req dto.AppDeployRequest) (*dto.AppDeployResponse, string, error) {246 func (_mock *MockControlPlane) DeployApp(ctx context.Context, app string, req dto.AppDeployRequest) (*dto.AppDeployResponse, string, error) {
310 ret := _mock.Called(ctx, app, req)247 ret := _mock.Called(ctx, app, req)
@@ -1035,74 +972,6 @@ func (_c *MockControlPlane_ListNetworks_Call) RunAndReturn(run func(ctx context.
1035 return _c972 return _c
1036 }973 }
1037 974
1038-// ListSecrets provides a mock function for the type MockControlPlane
1039-func (_mock *MockControlPlane) ListSecrets(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) {
1040- ret := _mock.Called(ctx, secretDomain)
1041-
1042- if len(ret) == 0 {
1043- panic("no return value specified for ListSecrets")
1044- }
1045-
1046- var r0 *remote.SecretsListResult
1047- var r1 error
1048- if returnFunc, ok := ret.Get(0).(func(context.Context, string) (*remote.SecretsListResult, error)); ok {
1049- return returnFunc(ctx, secretDomain)
1050- }
1051- if returnFunc, ok := ret.Get(0).(func(context.Context, string) *remote.SecretsListResult); ok {
1052- r0 = returnFunc(ctx, secretDomain)
1053- } else {
1054- if ret.Get(0) != nil {
1055- r0 = ret.Get(0).(*remote.SecretsListResult)
1056- }
1057- }
1058- if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
1059- r1 = returnFunc(ctx, secretDomain)
1060- } else {
1061- r1 = ret.Error(1)
1062- }
1063- return r0, r1
1064-}
1065-
1066-// MockControlPlane_ListSecrets_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListSecrets'
1067-type MockControlPlane_ListSecrets_Call struct {
1068- *mock.Call
1069-}
1070-
1071-// ListSecrets is a helper method to define mock.On call
1072-// - ctx context.Context
1073-// - secretDomain string
1074-func (_e *MockControlPlane_Expecter) ListSecrets(ctx any, secretDomain any) *MockControlPlane_ListSecrets_Call {
1075- return &MockControlPlane_ListSecrets_Call{Call: _e.mock.On("ListSecrets", ctx, secretDomain)}
1076-}
1077-
1078-func (_c *MockControlPlane_ListSecrets_Call) Run(run func(ctx context.Context, secretDomain string)) *MockControlPlane_ListSecrets_Call {
1079- _c.Call.Run(func(args mock.Arguments) {
1080- var arg0 context.Context
1081- if args[0] != nil {
1082- arg0 = args[0].(context.Context)
1083- }
1084- var arg1 string
1085- if args[1] != nil {
1086- arg1 = args[1].(string)
1087- }
1088- run(
1089- arg0,
1090- arg1,
1091- )
1092- })
1093- return _c
1094-}
1095-
1096-func (_c *MockControlPlane_ListSecrets_Call) Return(secretsListResult *remote.SecretsListResult, err error) *MockControlPlane_ListSecrets_Call {
1097- _c.Call.Return(secretsListResult, err)
1098- return _c
1099-}
1100-
1101-func (_c *MockControlPlane_ListSecrets_Call) RunAndReturn(run func(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error)) *MockControlPlane_ListSecrets_Call {
1102- _c.Call.Return(run)
1103- return _c
1104-}
1105-
1106 // ListTags provides a mock function for the type MockControlPlane975 // ListTags provides a mock function for the type MockControlPlane
1107 func (_mock *MockControlPlane) ListTags(ctx context.Context, repository string) ([]string, error) {976 func (_mock *MockControlPlane) ListTags(ctx context.Context, repository string) ([]string, error) {
1108 ret := _mock.Called(ctx, repository)977 ret := _mock.Called(ctx, repository)
@@ -1877,69 +1746,6 @@ func (_c *MockControlPlane_SetAppSecrets_Call) RunAndReturn(run func(ctx context
1877 return _c1746 return _c
1878 }1747 }
1879 1748
1880-// SetSecrets provides a mock function for the type MockControlPlane
1881-func (_mock *MockControlPlane) SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error {
1882- ret := _mock.Called(ctx, secretDomain, secrets)
1883-
1884- if len(ret) == 0 {
1885- panic("no return value specified for SetSecrets")
1886- }
1887-
1888- var r0 error
1889- if returnFunc, ok := ret.Get(0).(func(context.Context, string, map[string]string) error); ok {
1890- r0 = returnFunc(ctx, secretDomain, secrets)
1891- } else {
1892- r0 = ret.Error(0)
1893- }
1894- return r0
1895-}
1896-
1897-// MockControlPlane_SetSecrets_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetSecrets'
1898-type MockControlPlane_SetSecrets_Call struct {
1899- *mock.Call
1900-}
1901-
1902-// SetSecrets is a helper method to define mock.On call
1903-// - ctx context.Context
1904-// - secretDomain string
1905-// - secrets map[string]string
1906-func (_e *MockControlPlane_Expecter) SetSecrets(ctx any, secretDomain any, secrets any) *MockControlPlane_SetSecrets_Call {
1907- return &MockControlPlane_SetSecrets_Call{Call: _e.mock.On("SetSecrets", ctx, secretDomain, secrets)}
1908-}
1909-
1910-func (_c *MockControlPlane_SetSecrets_Call) Run(run func(ctx context.Context, secretDomain string, secrets map[string]string)) *MockControlPlane_SetSecrets_Call {
1911- _c.Call.Run(func(args mock.Arguments) {
1912- var arg0 context.Context
1913- if args[0] != nil {
1914- arg0 = args[0].(context.Context)
1915- }
1916- var arg1 string
1917- if args[1] != nil {
1918- arg1 = args[1].(string)
1919- }
1920- var arg2 map[string]string
1921- if args[2] != nil {
1922- arg2 = args[2].(map[string]string)
1923- }
1924- run(
1925- arg0,
1926- arg1,
1927- arg2,
1928- )
1929- })
1930- return _c
1931-}
1932-
1933-func (_c *MockControlPlane_SetSecrets_Call) Return(err error) *MockControlPlane_SetSecrets_Call {
1934- _c.Call.Return(err)
1935- return _c
1936-}
1937-
1938-func (_c *MockControlPlane_SetSecrets_Call) RunAndReturn(run func(ctx context.Context, secretDomain string, secrets map[string]string) error) *MockControlPlane_SetSecrets_Call {
1939- _c.Call.Return(run)
1940- return _c
1941-}
1942-
1943 // ShowApp provides a mock function for the type MockControlPlane1749 // ShowApp provides a mock function for the type MockControlPlane
1944 func (_mock *MockControlPlane) ShowApp(ctx context.Context, app string) (*dto.AppShowResponse, error) {1750 func (_mock *MockControlPlane) ShowApp(ctx context.Context, app string) (*dto.AppShowResponse, error) {
1945 ret := _mock.Called(ctx, app)1751 ret := _mock.Called(ctx, app)
M internal/adapters/in/cli/networks.go +5 -16
@@ -12,29 +12,18 @@ import (
12 )12 )
13 13
14 func newNetworksCmd() *cobra.Command {14 func newNetworksCmd() *cobra.Command {
15- cmd := &cobra.Command{
16- Use: "networks",
17- Short: "Inspect Gordon-managed networks",
18- }
19-
20- cmd.AddCommand(newNetworksListCmd())
21-
22- return cmd
23-}
24-
25-func newNetworksListCmd() *cobra.Command {
26 var jsonOut bool15 var jsonOut bool
27 16
28 cmd := &cobra.Command{17 cmd := &cobra.Command{
29- Use: "list",18+ Use: "networks",
30- Short: "List Gordon-managed Docker networks",19+ Short: "List Gordon-managed networks",
31 Long: `Display Docker networks managed by Gordon, including which containers20 Long: `Display Docker networks managed by Gordon, including which containers
32 are connected to each network.21 are connected to each network.
33 22
34 Examples:23 Examples:
35- gordon networks list24+ gordon daemon networks
36- gordon networks list --json25+ gordon daemon networks --json
37- gordon networks list --remote https://gordon.mydomain.com --token $TOKEN`,26+ gordon daemon networks --remote prod`,
38 RunE: func(cmd *cobra.Command, _ []string) error {27 RunE: func(cmd *cobra.Command, _ []string) error {
39 ctx := cmd.Context()28 ctx := cmd.Context()
40 handle, err := resolveControlPlane(cliConfigPath)29 handle, err := resolveControlPlane(cliConfigPath)
M internal/adapters/in/cli/push.go +4 -4
@@ -66,10 +66,10 @@ deploys. Deploy separately with ` + "`gordon apps deploy`" + ` after applying
66 the manifest that references the pushed tag.66 the manifest that references the pushed tag.
67 67
68 Examples:68 Examples:
69- gordon push myapp --build --remote ...69+ gordon images push myapp --build --remote ...
70- gordon push myapp:v1.2.3 --tag v1.2.3 --remote ...70+ gordon images push myapp:v1.2.3 --tag v1.2.3 --remote ...
71- gordon push registry.example.com/myapp:v1.2.3 --build --remote ...71+ gordon images push registry.example.com/myapp:v1.2.3 --build --remote ...
72- gordon push --build --build-arg CGO_ENABLED=0 --remote ...`,72+ gordon images push --build --build-arg CGO_ENABLED=0 --remote ...`,
73 Args: cobra.MaximumNArgs(1),73 Args: cobra.MaximumNArgs(1),
74 RunE: func(cmd *cobra.Command, args []string) error {74 RunE: func(cmd *cobra.Command, args []string) error {
75 var imageArg string75 var imageArg string
M internal/adapters/in/cli/push_image_ops.go +1 -1
@@ -181,7 +181,7 @@ func (d *dockerImageOps) Build(ctx context.Context, args []string) error {
181 "Podman and other runtimes are not supported for --build.\n" +181 "Podman and other runtimes are not supported for --build.\n" +
182 "Build the image manually and push with:\n" +182 "Build the image manually and push with:\n" +
183 " podman build -t <image> .\n" +183 " podman build -t <image> .\n" +
184- " gordon push <image>",184+ " gordon images push <image>",
185 )185 )
186 }186 }
187 cmd := exec.CommandContext(ctx, "docker", args...) // #nosec G204187 cmd := exec.CommandContext(ctx, "docker", args...) // #nosec G204
M internal/adapters/in/cli/remote/client.go +0 -42
@@ -481,48 +481,6 @@ func (c *Client) requestWithRetry(ctx context.Context, method, path string, body
481 return nil, fmt.Errorf("request failed after retries")481 return nil, fmt.Errorf("request failed after retries")
482 }482 }
483 483
484-// Secrets API
485-
486-// SecretsListResult contains domain secret keys.
487-type SecretsListResult struct {
488- Domain string `json:"domain"`
489- Keys []string `json:"keys"`
490-}
491-
492-// ListSecretsWithAttachments returns domain secret keys. Attachment
493-// containers are retired: the daemon answers without attachment keys.
494-func (c *Client) ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*SecretsListResult, error) {
495- resp, err := c.request(ctx, http.MethodGet, "/secrets/"+url.PathEscape(secretDomain), nil)
496- if err != nil {
497- return nil, err
498- }
499-
500- var result SecretsListResult
501- if err := parseResponse(resp, &result); err != nil {
502- return nil, err
503- }
504-
505- return &result, nil
506-}
507-
508-// SetSecrets sets secrets for a domain.
509-func (c *Client) SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error {
510- resp, err := c.request(ctx, http.MethodPost, "/secrets/"+url.PathEscape(secretDomain), secrets)
511- if err != nil {
512- return err
513- }
514- return parseResponse(resp, nil)
515-}
516-
517-// DeleteSecret removes a secret from a domain.
518-func (c *Client) DeleteSecret(ctx context.Context, secretDomain, key string) error {
519- resp, err := c.request(ctx, http.MethodDelete, "/secrets/"+url.PathEscape(secretDomain)+"/"+url.PathEscape(key), nil)
520- if err != nil {
521- return err
522- }
523- return parseResponse(resp, nil)
524-}
525-
526 // Status API484 // Status API
527 485
528 // Status represents the Gordon server status.486 // Status represents the Gordon server status.
M internal/adapters/in/cli/remote_inference.go +0 -42
@@ -129,39 +129,6 @@ func isRemoteNotFoundError(err error) bool {
129 return errors.Is(err, domain.ErrRouteNotFound)129 return errors.Is(err, domain.ErrRouteNotFound)
130 }130 }
131 131
132-func resolveExplicitRemote() (*remote.ResolvedRemote, bool, error) {
133- target, ok := resolveExplicitTargetName()
134- if !ok {
135- return nil, false, nil
136- }
137-
138- if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") {
139- return &remote.ResolvedRemote{
140- URL: target,
141- Token: resolveTokenForTarget("", remote.RemoteEntry{}),
142- InsecureTLS: resolveInsecureForTarget("", remote.RemoteEntry{}),
143- }, true, nil
144- }
145-
146- remotes, err := remote.LoadRemotes("")
147- if err != nil {
148- return nil, false, err
149- }
150-
151- if remotes != nil {
152- if entry, found := remotes.Remotes[target]; found {
153- return &remote.ResolvedRemote{
154- Name: target,
155- URL: entry.URL,
156- Token: resolveTokenForTarget(target, entry),
157- InsecureTLS: resolveInsecureForTarget(target, entry),
158- }, true, nil
159- }
160- }
161-
162- return nil, false, nil
163-}
164-
165 func resolveExplicitTargetName() (string, bool) {132 func resolveExplicitTargetName() (string, bool) {
166 if target := strings.TrimSpace(remoteFlag); target != "" {133 if target := strings.TrimSpace(remoteFlag); target != "" {
167 return target, true134 return target, true
@@ -203,12 +170,3 @@ func resolveInsecureForTarget(name string, entry remote.RemoteEntry) bool {
203 func newInferenceTargetClient(name string, entry remote.RemoteEntry) *remote.Client {170 func newInferenceTargetClient(name string, entry remote.RemoteEntry) *remote.Client {
204 return remote.NewClient(entry.URL, remoteClientOptions(resolveTokenForTarget(name, entry), resolveInsecureForTarget(name, entry))...)171 return remote.NewClient(entry.URL, remoteClientOptions(resolveTokenForTarget(name, entry), resolveInsecureForTarget(name, entry))...)
205 }172 }
206-
207-// inferExplicitTarget resolves --remote/GORDON_REMOTE without probing saved remotes.
208-func inferExplicitTarget(_ context.Context) (*remote.ResolvedRemote, error) {
209- resolved, ok, err := resolveExplicitRemote()
210- if err != nil || !ok {
211- return nil, err
212- }
213- return resolved, nil
214-}
M internal/adapters/in/cli/remotes.go +1 -1
@@ -296,7 +296,7 @@ without needing to specify --remote.
296 296
297 Examples:297 Examples:
298 gordon remotes use prod298 gordon remotes use prod
299- gordon status # Uses prod remote automatically`,299+ gordon daemon status # Uses prod remote automatically`,
300 Args: cobra.ExactArgs(1),300 Args: cobra.ExactArgs(1),
301 RunE: func(cmd *cobra.Command, args []string) error {301 RunE: func(cmd *cobra.Command, args []string) error {
302 name := args[0]302 name := args[0]
M internal/adapters/in/cli/root.go +16 -57
@@ -55,11 +55,11 @@ func NewRootCmd() *cobra.Command {
55 container registry, an app runtime, and a reverse proxy.55 container registry, an app runtime, and a reverse proxy.
56 56
57 Applications are declared in standalone TOML files and managed with57 Applications are declared in standalone TOML files and managed with
58-gordon apps; push transfers OCI content only and never deploys.58+gordon apps; gordon images push transfers OCI content only and never deploys.
59 59
60 Commands are organized by where they run:60 Commands are organized by where they run:
61 Server-only: Run on the machine hosting Gordon (serve, auth, ca)61 Server-only: Run on the machine hosting Gordon (serve, auth, ca)
62- Management: Work locally or remotely via --remote flag (apps, secrets, etc.)62+ Management: Work locally or remotely via --remote flag (apps, daemon, images, etc.)
63 Client-only: CLI utilities that don't require a running Gordon server`,63 Client-only: CLI utilities that don't require a running Gordon server`,
64 }64 }
65 65
@@ -88,57 +88,16 @@ Commands are organized by where they run:
88 caCmd.GroupID = groupServer88 caCmd.GroupID = groupServer
89 rootCmd.AddCommand(caCmd)89 rootCmd.AddCommand(caCmd)
90 90
91- secretsCmd := newSecretsCmd()91+ for _, cmd := range []*cobra.Command{
92- secretsCmd.GroupID = groupManage92+ newAppsCmd(),
93- rootCmd.AddCommand(secretsCmd)93+ newBackupCmd(),
94-94+ newDaemonCmd(),
95- pushCmd := newPushCmd()95+ newImagesCmd(),
96- pushCmd.GroupID = groupManage96+ newVolumesCmd(),
97- rootCmd.AddCommand(pushCmd)97+ } {
98-98+ cmd.GroupID = groupManage
99- reloadCmd := newReloadCmd()99+ rootCmd.AddCommand(cmd)
100- reloadCmd.GroupID = groupManage100+ }
101- rootCmd.AddCommand(reloadCmd)
102-
103- logsCmd := newLogsCmd()
104- logsCmd.GroupID = groupManage
105- rootCmd.AddCommand(logsCmd)
106-
107- statusCmd := newStatusCmd()
108- statusCmd.GroupID = groupManage
109- rootCmd.AddCommand(statusCmd)
110-
111- backupCmd := newBackupCmd()
112- backupCmd.GroupID = groupManage
113- rootCmd.AddCommand(backupCmd)
114-
115- imagesCmd := newImagesCmd()
116- imagesCmd.GroupID = groupManage
117- rootCmd.AddCommand(imagesCmd)
118-
119- configCmd := newConfigCmd()
120- configCmd.GroupID = groupManage
121- rootCmd.AddCommand(configCmd)
122-
123- networksCmd := newNetworksCmd()
124- networksCmd.GroupID = groupManage
125- rootCmd.AddCommand(networksCmd)
126-
127- volumesCmd := newVolumesCmd()
128- volumesCmd.GroupID = groupManage
129- rootCmd.AddCommand(volumesCmd)
130-
131- tlsCmd := newTLSCmd()
132- tlsCmd.GroupID = groupManage
133- rootCmd.AddCommand(tlsCmd)
134-
135- trafficCmd := newTrafficCmd()
136- trafficCmd.GroupID = groupManage
137- rootCmd.AddCommand(trafficCmd)
138-
139- appsCmd := newAppsCmd()
140- appsCmd.GroupID = groupManage
141- rootCmd.AddCommand(appsCmd)
142 101
143 // Client-only commands (no server needed)102 // Client-only commands (no server needed)
144 remotesCmd := newRemotesCmd()103 remotesCmd := newRemotesCmd()
@@ -257,12 +216,12 @@ Application workload output is read with gordon apps logs APP --service SVC,
257 which resolves the app's active container through the daemon.216 which resolves the app's active container through the daemon.
258 217
259 Examples:218 Examples:
260- gordon logs # Gordon process logs219+ gordon daemon logs # Gordon process logs
261- gordon logs -f # Follow process logs220+ gordon daemon logs -f # Follow process logs
262- gordon logs -n 100 # Last 100 lines221+ gordon daemon logs -n 100 # Last 100 lines
263 222
264 Remote mode:223 Remote mode:
265- gordon logs --remote https://gordon.mydomain.com --token $TOKEN`,224+ gordon daemon logs --remote prod`,
266 Args: cobra.NoArgs,225 Args: cobra.NoArgs,
267 RunE: func(cmd *cobra.Command, args []string) error {226 RunE: func(cmd *cobra.Command, args []string) error {
268 return runLogs(cmd.Context(), logsConfigPath, follow, lines, cmd.OutOrStdout())227 return runLogs(cmd.Context(), logsConfigPath, follow, lines, cmd.OutOrStdout())
D internal/adapters/in/cli/secrets.go +0 -252
@@ -1,252 +0,0 @@
1-package cli
2-
3-import (
4- "context"
5- "fmt"
6- "strings"
7-
8- "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
9- "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
10-
11- "github.com/spf13/cobra"
12-)
13-
14-// newSecretsCmd creates the secrets command group.
15-func newSecretsCmd() *cobra.Command {
16- cmd := &cobra.Command{
17- Use: "secrets",
18- Short: "Manage installation secrets",
19- Long: `Manage installation secrets (per-host entries such as service auth).
20-
21-Application secret VALUES are managed with gordon apps secrets and stay
22-in pass under gordon/apps/<uuid>/<service>/<name>. The commands below
23-manage the installation secret store only.
24-
25-When targeting a remote Gordon instance (via --remote flag or GORDON_REMOTE env var),
26-these commands operate on the remote server.`,
27- }
28-
29- cmd.AddCommand(newSecretsListCmd())
30- cmd.AddCommand(newSecretsSetCmd())
31- cmd.AddCommand(newSecretsRemoveCmd())
32-
33- return cmd
34-}
35-
36-// newSecretsListCmd creates the secrets list command.
37-func newSecretsListCmd() *cobra.Command {
38- var jsonOut bool
39-
40- cmd := &cobra.Command{
41- Use: "list <domain>",
42- Short: "List secrets for a domain",
43- Long: `List all secret keys configured for a domain.
44-
45-Note: Only secret keys are shown, not values (for security).
46-
47-Examples:
48- gordon secrets list app.mydomain.com
49- gordon --remote https://gordon.mydomain.com secrets list api.mydomain.com`,
50- Args: cobra.ExactArgs(1),
51- RunE: func(cmd *cobra.Command, args []string) error {
52- return runSecretsListCmd(cmd, args, jsonOut)
53- },
54- }
55-
56- cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
57-
58- return cmd
59-}
60-
61-// runSecretsListCmd executes the secrets list command.
62-func runSecretsListCmd(cmd *cobra.Command, args []string, jsonOut bool) error {
63- ctx := cmd.Context()
64- secretDomain := args[0]
65-
66- handle, err := resolveControlPlaneForDomain(ctx, secretDomain)
67- if err != nil {
68- return err
69- }
70- defer handle.close()
71-
72- keys, err := fetchSecrets(ctx, handle.plane, secretDomain)
73- if err != nil {
74- return err
75- }
76-
77- if len(keys) == 0 {
78- if jsonOut {
79- return writeJSON(cmd.OutOrStdout(), map[string]any{
80- "domain": secretDomain,
81- "keys": []string{},
82- })
83- }
84- fmt.Println(styles.Theme.Muted.Render(fmt.Sprintf("No secrets configured for %s", secretDomain)))
85- return nil
86- }
87-
88- if jsonOut {
89- return writeJSON(cmd.OutOrStdout(), map[string]any{
90- "domain": secretDomain,
91- "keys": keys,
92- })
93- }
94-
95- title := fmt.Sprintf("Secrets for %s", secretDomain)
96- if !handle.isRemote {
97- title = fmt.Sprintf("Secrets for %s (local)", secretDomain)
98- }
99- fmt.Println(styles.Theme.Title.Render(title))
100- fmt.Println()
101-
102- rows := buildSecretsTableRows(keys)
103-
104- table := components.NewTable(
105- components.WithColumns([]components.TableColumn{
106- {Title: "Key", Width: 45},
107- {Title: "Value", Width: 10},
108- }),
109- components.WithRows(rows),
110- )
111-
112- fmt.Println(table.View())
113- return nil
114-}
115-
116-// fetchSecrets retrieves secret keys from the selected control plane.
117-func fetchSecrets(ctx context.Context, cp ControlPlane, secretDomain string) ([]string, error) {
118- result, err := cp.ListSecrets(ctx, secretDomain)
119- if err != nil {
120- return nil, fmt.Errorf("failed to list secrets: %w", err)
121- }
122- return result.Keys, nil
123-}
124-
125-// buildSecretsTableRows builds table rows for secret keys.
126-func buildSecretsTableRows(keys []string) [][]string {
127- var rows [][]string
128- for _, key := range keys {
129- rows = append(rows, []string{key, styles.Theme.Muted.Render("(hidden)")})
130- }
131- return rows
132-}
133-
134-// newSecretsSetCmd creates the secrets set command.
135-func newSecretsSetCmd() *cobra.Command {
136- var fromFile string
137- var jsonOut bool
138-
139- cmd := &cobra.Command{
140- Use: "set <domain> --from-file <path>",
141- Short: "Set installation secrets for a domain",
142- Long: `Set one or more installation secrets for a domain.
143-
144-Secrets are read from a mode 0600 file containing one KEY=value pair per line.
145-For app secret values, use gordon apps secrets instead.
146-
147-Examples:
148- gordon secrets set app.mydomain.com --from-file ./app.env`,
149- Args: cobra.ExactArgs(1),
150- RunE: func(cmd *cobra.Command, args []string) error {
151- ctx := cmd.Context()
152- secretDomain := args[0]
153- content, err := readProtectedSecretFile(fromFile)
154- if err != nil {
155- return fmt.Errorf("read secrets: %w", err)
156- }
157-
158- secrets := make(map[string]string)
159- for _, pair := range strings.Split(content, "\n") {
160- parts := strings.SplitN(pair, "=", 2)
161- if len(parts) != 2 {
162- return fmt.Errorf("invalid format: %s (expected KEY=value)", pair)
163- }
164- secrets[parts[0]] = parts[1]
165- }
166-
167- handle, err := resolveControlPlaneForDomain(ctx, secretDomain)
168- if err != nil {
169- return err
170- }
171- defer handle.close()
172- if err := handle.plane.SetSecrets(ctx, secretDomain, secrets); err != nil {
173- return fmt.Errorf("failed to set secrets: %w", err)
174- }
175-
176- target := secretDomain
177-
178- if jsonOut {
179- return writeJSON(cmd.OutOrStdout(), map[string]any{
180- "domain": secretDomain, "count": len(secrets), "updated": true,
181- })
182- }
183- if len(secrets) == 1 {
184- for key := range secrets {
185- return cliWriteLine(cmd.OutOrStdout(), styles.RenderSuccess(fmt.Sprintf("Secret set: %s on %s", key, target)))
186- }
187- }
188- return cliWriteLine(cmd.OutOrStdout(), styles.RenderSuccess(fmt.Sprintf("Set %d secrets for %s", len(secrets), target)))
189- },
190- }
191-
192- cmd.Flags().StringVar(&fromFile, "from-file", "", "Read KEY=value lines from a mode 0600 file")
193- cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
194- _ = cmd.MarkFlagRequired("from-file")
195-
196- return cmd
197-}
198-
199-// newSecretsRemoveCmd creates the secrets remove command.
200-func newSecretsRemoveCmd() *cobra.Command {
201- var force bool
202-
203- cmd := &cobra.Command{
204- Use: "remove <domain> <key>",
205- Short: "Remove an installation secret",
206- Long: `Remove an installation secret from a domain.
207-
208-For app secret values, use gordon apps secrets delete instead.
209-
210-Examples:
211- gordon secrets remove app.mydomain.com OLD_API_KEY
212- gordon secrets remove app.mydomain.com OLD_API_KEY --force`,
213- Args: cobra.ExactArgs(2),
214- RunE: func(cmd *cobra.Command, args []string) error {
215- ctx := cmd.Context()
216- secretDomain := args[0]
217- key := args[1]
218-
219- target := secretDomain
220-
221- // Confirm unless --force
222- if !force {
223- confirmed, err := components.RunConfirm(
224- fmt.Sprintf("Remove secret '%s' from %s?", key, target),
225- )
226- if err != nil {
227- return err
228- }
229- if !confirmed {
230- fmt.Println(styles.Theme.Muted.Render("Cancelled"))
231- return nil
232- }
233- }
234-
235- handle, err := resolveControlPlaneForDomain(ctx, secretDomain)
236- if err != nil {
237- return err
238- }
239- defer handle.close()
240- if err := handle.plane.DeleteSecret(ctx, secretDomain, key); err != nil {
241- return fmt.Errorf("failed to remove secret: %w", err)
242- }
243-
244- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret removed from %s: %s", target, key)))
245- return nil
246- },
247- }
248-
249- cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation")
250-
251- return cmd
252-}
M internal/adapters/in/cli/tls.go +1 -12
@@ -16,21 +16,10 @@ import (
16 var tlsResolveControlPlane = resolveControlPlane16 var tlsResolveControlPlane = resolveControlPlane
17 17
18 func newTLSCmd() *cobra.Command {18 func newTLSCmd() *cobra.Command {
19- cmd := &cobra.Command{
20- Use: "tls",
21- Short: "Inspect TLS certificate status",
22- }
23-
24- cmd.AddCommand(newTLSStatusCmd())
25-
26- return cmd
27-}
28-
29-func newTLSStatusCmd() *cobra.Command {
30 var jsonOut bool19 var jsonOut bool
31 20
32 cmd := &cobra.Command{21 cmd := &cobra.Command{
33- Use: "status",22+ Use: "tls",
34 Short: "Show public TLS certificate status",23 Short: "Show public TLS certificate status",
35 RunE: func(cmd *cobra.Command, _ []string) error {24 RunE: func(cmd *cobra.Command, _ []string) error {
36 handle, err := tlsResolveControlPlane(cliConfigPath)25 handle, err := tlsResolveControlPlane(cliConfigPath)
D internal/adapters/in/cli/traffic.go +0 -12
@@ -1,12 +0,0 @@
1-package cli
2-
3-import "github.com/spf13/cobra"
4-
5-func newTrafficCmd() *cobra.Command {
6- cmd := &cobra.Command{
7- Use: "traffic",
8- Short: "Inspect remote traffic plane status",
9- }
10- cmd.AddCommand(newTrafficStatusCmd())
11- return cmd
12-}
M internal/adapters/in/cli/traffic_status.go +2 -2
@@ -13,10 +13,10 @@ import (
13 13
14 var trafficResolveControlPlane = resolveControlPlane14 var trafficResolveControlPlane = resolveControlPlane
15 15
16-func newTrafficStatusCmd() *cobra.Command {16+func newTrafficCmd() *cobra.Command {
17 var jsonOut bool17 var jsonOut bool
18 cmd := &cobra.Command{18 cmd := &cobra.Command{
19- Use: "status",19+ Use: "traffic",
20 Short: "Show traffic entrypoint, router, and counter status",20 Short: "Show traffic entrypoint, router, and counter status",
21 Args: cobra.NoArgs,21 Args: cobra.NoArgs,
22 RunE: func(cmd *cobra.Command, args []string) error {22 RunE: func(cmd *cobra.Command, args []string) error {
M internal/adapters/in/cli/traffic_test.go +2 -7
@@ -19,19 +19,14 @@ import (
19 19
20 func TestTrafficCommandExists(t *testing.T) {20 func TestTrafficCommandExists(t *testing.T) {
21 cmd := NewRootCmd()21 cmd := NewRootCmd()
22- traffic, _, err := cmd.Find([]string{"traffic"})22+ traffic, _, err := cmd.Find([]string{"daemon", "traffic"})
23 require.NoError(t, err)23 require.NoError(t, err)
24 require.NotNil(t, traffic)24 require.NotNil(t, traffic)
25 assert.Equal(t, "traffic", traffic.Name())25 assert.Equal(t, "traffic", traffic.Name())
26-
27- status, _, err := cmd.Find([]string{"traffic", "status"})
28- require.NoError(t, err)
29- require.NotNil(t, status)
30- assert.Equal(t, "status", status.Name())
31 }26 }
32 27
33 func TestTrafficStatusRejectsArgs(t *testing.T) {28 func TestTrafficStatusRejectsArgs(t *testing.T) {
34- cmd := newTrafficStatusCmd()29+ cmd := newTrafficCmd()
35 require.Error(t, cmd.Args(cmd, []string{"extra"}))30 require.Error(t, cmd.Args(cmd, []string{"extra"}))
36 }31 }
37 32
M internal/adapters/in/http/admin/handler.go +0 -110
@@ -41,7 +41,6 @@ type Handler struct {
41 volumeBackupSvc in.VolumeBackupService41 volumeBackupSvc in.VolumeBackupService
42 imageSvc in.ImageService42 imageSvc in.ImageService
43 healthSvc in.HealthService43 healthSvc in.HealthService
44- secretSvc in.SecretService
45 logSvc in.LogService44 logSvc in.LogService
46 volumeSvc in.VolumeService45 volumeSvc in.VolumeService
47 registrySvc in.RegistryService46 registrySvc in.RegistryService
@@ -115,7 +114,6 @@ type HandlerDeps struct {
115 AuthSvc in.AuthService114 AuthSvc in.AuthService
116 ContainerSvc in.ContainerService115 ContainerSvc in.ContainerService
117 HealthSvc in.HealthService116 HealthSvc in.HealthService
118- SecretSvc in.SecretService
119 LogSvc in.LogService117 LogSvc in.LogService
120 RegistrySvc in.RegistryService118 RegistrySvc in.RegistryService
121 Log zerowrap.Logger119 Log zerowrap.Logger
@@ -139,7 +137,6 @@ func NewHandler(deps HandlerDeps) *Handler {
139 volumeBackupSvc: deps.VolumeBackupSvc,137 volumeBackupSvc: deps.VolumeBackupSvc,
140 imageSvc: deps.ImageSvc,138 imageSvc: deps.ImageSvc,
141 healthSvc: deps.HealthSvc,139 healthSvc: deps.HealthSvc,
142- secretSvc: deps.SecretSvc,
143 logSvc: deps.LogSvc,140 logSvc: deps.LogSvc,
144 volumeSvc: deps.VolumeSvc,141 volumeSvc: deps.VolumeSvc,
145 registrySvc: deps.RegistrySvc,142 registrySvc: deps.RegistrySvc,
@@ -214,7 +211,6 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) {
214 }{211 }{
215 {"/apps", h.handleApps},212 {"/apps", h.handleApps},
216 {"/backups", h.handleBackups},213 {"/backups", h.handleBackups},
217- {"/secrets", h.handleSecrets},
218 {"/tags", h.handleTags},214 {"/tags", h.handleTags},
219 {"/images", h.handleImages},215 {"/images", h.handleImages},
220 {"/logs", h.handleLogs},216 {"/logs", h.handleLogs},
@@ -276,112 +272,6 @@ func (h *Handler) handleNetworks(w http.ResponseWriter, r *http.Request) {
276 h.sendJSON(w, http.StatusOK, dto.NetworksResponse{Networks: response})272 h.sendJSON(w, http.StatusOK, dto.NetworksResponse{Networks: response})
277 }273 }
278 274
279-// handleSecrets handles /admin/secrets endpoints.
280-func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path string) {
281- // Parse path: /secrets/{domain} or /secrets/{domain}/{key}
282- parts := strings.Split(strings.TrimPrefix(path, "/secrets/"), "/")
283- if len(parts) == 0 || parts[0] == "" {
284- h.sendError(w, http.StatusBadRequest, "domain required")
285- return
286- }
287-
288- secretDomain := parts[0]
289-
290- secretKey := ""
291- if len(parts) > 1 {
292- secretKey = parts[1]
293- }
294-
295- switch r.Method {
296- case http.MethodGet:
297- h.handleSecretsGet(w, r, secretDomain)
298- case http.MethodPost:
299- h.handleSecretsPost(w, r, secretDomain)
300- case http.MethodDelete:
301- h.handleSecretsDelete(w, r, secretDomain, secretKey)
302- default:
303- h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
304- }
305-}
306-
307-// handleSecretsPost handles POST /admin/secrets/{domain} - set secrets.
308-func (h *Handler) handleSecretsPost(w http.ResponseWriter, r *http.Request, secretDomain string) {
309- ctx := r.Context()
310- log := zerowrap.FromCtx(ctx)
311-
312- if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) {
313- h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write")
314- return
315- }
316-
317- r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
318-
319- var data map[string]string
320- if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
321- log.Warn().Err(err).Msg("invalid secrets JSON")
322- h.sendError(w, http.StatusBadRequest, "invalid JSON")
323- return
324- }
325-
326- if err := h.secretSvc.Set(ctx, secretDomain, data); err != nil {
327- log.Error().Err(err).Str("domain", secretDomain).Msg("failed to set secrets")
328- h.sendError(w, http.StatusBadRequest, "invalid domain")
329- return
330- }
331-
332- log.Info().Str("domain", secretDomain).Int("count", len(data)).Msg("secrets set")
333- h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "updated"})
334-}
335-
336-// handleSecretsDelete handles DELETE /admin/secrets/{domain}/{key} - delete a secret.
337-func (h *Handler) handleSecretsDelete(w http.ResponseWriter, r *http.Request, secretDomain, secretKey string) {
338- ctx := r.Context()
339- log := zerowrap.FromCtx(ctx)
340-
341- if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) {
342- h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write")
343- return
344- }
345- if secretKey == "" {
346- h.sendError(w, http.StatusBadRequest, "key required in path")
347- return
348- }
349-
350- if err := h.secretSvc.Delete(ctx, secretDomain, secretKey); err != nil {
351- log.Error().Err(err).Str("domain", secretDomain).Str("key", secretKey).Msg("failed to delete secret")
352- h.sendError(w, http.StatusBadRequest, "invalid domain")
353- return
354- }
355-
356- log.Info().Str("domain", secretDomain).Str("key", secretKey).Msg("secret deleted")
357- h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "deleted"})
358-}
359-
360-// handleSecretsGet handles GET /admin/secrets/{domain} - list secrets.
361-func (h *Handler) handleSecretsGet(w http.ResponseWriter, r *http.Request, secretDomain string) {
362- ctx := r.Context()
363- log := zerowrap.FromCtx(ctx)
364-
365- // Check read permission
366- if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionRead) {
367- h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:read")
368- return
369- }
370-
371- // List secrets for domain (names only, not values).
372- keys, err := h.secretSvc.ListKeys(ctx, secretDomain)
373- if err != nil {
374- log.Error().Err(err).Str("domain", secretDomain).Msg("failed to list secrets")
375- h.sendError(w, http.StatusBadRequest, "invalid domain")
376- return
377- }
378-
379- h.sendJSON(w, http.StatusOK, dto.SecretsListResponse{
380- Domain: secretDomain,
381- Keys: keys,
382- })
383-}
384-
385 // handleHealth handles /admin/health endpoint.275 // handleHealth handles /admin/health endpoint.
386 // Returns detailed health status for all routes including HTTP probe results.276 // Returns detailed health status for all routes including HTTP probe results.
387 func (h *Handler) handleHealth(w http.ResponseWriter, r *http.Request) {277 func (h *Handler) handleHealth(w http.ResponseWriter, r *http.Request) {
M internal/adapters/in/http/admin/handler_apps_test.go +0 -1
@@ -28,7 +28,6 @@ func appsTestHandler(t *testing.T, appSvc *inmocks.MockAppService) *Handler {
28 AuthSvc: inmocks.NewMockAuthService(t),28 AuthSvc: inmocks.NewMockAuthService(t),
29 ContainerSvc: inmocks.NewMockContainerService(t),29 ContainerSvc: inmocks.NewMockContainerService(t),
30 HealthSvc: inmocks.NewMockHealthService(t),30 HealthSvc: inmocks.NewMockHealthService(t),
31- SecretSvc: inmocks.NewMockSecretService(t),
32 Log: testLogger(),31 Log: testLogger(),
33 ReloadTrigger: noopReloadTrigger{},32 ReloadTrigger: noopReloadTrigger{},
34 AppSvc: appSvc,33 AppSvc: appSvc,
M internal/adapters/in/http/admin/handler_retired.go +2 -1
@@ -6,7 +6,7 @@ import (
6 "github.com/bnema/gordon/internal/adapters/dto"6 "github.com/bnema/gordon/internal/adapters/dto"
7 )7 )
8 8
9-// retiredMutationPaths are legacy mutation endpoints removed by the v39+// retiredMutationPaths are legacy endpoints removed by the v3
10 // declarative-apps cutover. Only the 410 Gone rejection dispatcher below10 // declarative-apps cutover. Only the 410 Gone rejection dispatcher below
11 // remains; no old business handlers or compatibility behavior is kept.11 // remains; no old business handlers or compatibility behavior is kept.
12 var retiredMutationPaths = []string{12 var retiredMutationPaths = []string{
@@ -23,6 +23,7 @@ var retiredMutationPaths = []string{
23 "/preview",23 "/preview",
24 "/previews",24 "/previews",
25 "/autoroute/allowed-domains",25 "/autoroute/allowed-domains",
26+ "/secrets",
26 }27 }
27 28
28 // isRetiredMutation reports whether a request path targets a removed29 // isRetiredMutation reports whether a request path targets a removed
M internal/adapters/in/http/admin/handler_retired_test.go +3 -1
@@ -20,7 +20,6 @@ func retiredTestHandler(t *testing.T) *Handler {
20 AuthSvc: inmocks.NewMockAuthService(t),20 AuthSvc: inmocks.NewMockAuthService(t),
21 ContainerSvc: inmocks.NewMockContainerService(t),21 ContainerSvc: inmocks.NewMockContainerService(t),
22 HealthSvc: inmocks.NewMockHealthService(t),22 HealthSvc: inmocks.NewMockHealthService(t),
23- SecretSvc: inmocks.NewMockSecretService(t),
24 Log: testLogger(),23 Log: testLogger(),
25 ReloadTrigger: noopReloadTrigger{},24 ReloadTrigger: noopReloadTrigger{},
26 })25 })
@@ -46,6 +45,9 @@ func TestHandler_RetiredMutations_Return410(t *testing.T) {
46 {http.MethodPost, "/admin/preview/blog"},45 {http.MethodPost, "/admin/preview/blog"},
47 {http.MethodGet, "/admin/previews"},46 {http.MethodGet, "/admin/previews"},
48 {http.MethodPost, "/admin/autoroute/allowed-domains"},47 {http.MethodPost, "/admin/autoroute/allowed-domains"},
48+ {http.MethodGet, "/admin/secrets/example.com"},
49+ {http.MethodPost, "/admin/secrets/example.com"},
50+ {http.MethodDelete, "/admin/secrets/example.com/KEY"},
49 }51 }
50 for _, target := range targets {52 for _, target := range targets {
51 req := httptest.NewRequest(target.method, target.path, nil)53 req := httptest.NewRequest(target.method, target.path, nil)
M internal/adapters/in/http/admin/handler_test.go +0 -250
@@ -86,7 +86,6 @@ func newTestHandler(t *testing.T, opts ...func(*HandlerDeps)) *Handler {
86 AuthSvc: inmocks.NewMockAuthService(t),86 AuthSvc: inmocks.NewMockAuthService(t),
87 ContainerSvc: inmocks.NewMockContainerService(t),87 ContainerSvc: inmocks.NewMockContainerService(t),
88 HealthSvc: inmocks.NewMockHealthService(t),88 HealthSvc: inmocks.NewMockHealthService(t),
89- SecretSvc: inmocks.NewMockSecretService(t),
90 Log: testLogger(),89 Log: testLogger(),
91 ReloadTrigger: noopReloadTrigger{},90 ReloadTrigger: noopReloadTrigger{},
92 }91 }
@@ -156,180 +155,15 @@ func TestHandler_VolumesPrune_RequiresVolumesWriteScope(t *testing.T) {
156 155
157 // Routes endpoint tests156 // Routes endpoint tests
158 157
159-func TestHandler_SecretsGet_RequiresReadScope(t *testing.T) {
160- configSvc := inmocks.NewMockConfigService(t)
161- authSvc := inmocks.NewMockAuthService(t)
162- containerSvc := inmocks.NewMockContainerService(t)
163- secretSvc := inmocks.NewMockSecretService(t)
164-
165- handler := newTestHandler(t, func(d *HandlerDeps) {
166- d.ConfigSvc = configSvc
167- d.AuthSvc = authSvc
168- d.ContainerSvc = containerSvc
169- d.SecretSvc = secretSvc
170- })
171-
172- tests := []struct {
173- name string
174- scopes []string
175- wantStatus int
176- }{
177- {
178- name: "secrets read access granted",
179- scopes: []string{"admin:secrets:read"},
180- wantStatus: http.StatusOK,
181- },
182- {
183- name: "secrets wildcard access granted",
184- scopes: []string{"admin:secrets:*"},
185- wantStatus: http.StatusOK,
186- },
187- {
188- name: "all admin access granted",
189- scopes: []string{"admin:*:*"},
190- wantStatus: http.StatusOK,
191- },
192- {
193- name: "only write access denied",
194- scopes: []string{"admin:secrets:write"},
195- wantStatus: http.StatusForbidden,
196- },
197- {
198- name: "wrong resource denied",
199- scopes: []string{"admin:routes:read"},
200- wantStatus: http.StatusForbidden,
201- },
202- }
203-
204- for _, tt := range tests {
205- t.Run(tt.name, func(t *testing.T) {
206- if tt.wantStatus == http.StatusOK {
207- secretSvc.EXPECT().ListKeys(mock.Anything, "app.example.com").Return([]string{}, nil).Maybe()
208- }
209-
210- req := httptest.NewRequest("GET", "/admin/secrets/app.example.com", nil)
211- req = req.WithContext(ctxWithScopes(tt.scopes...))
212- rec := httptest.NewRecorder()
213-
214- handler.ServeHTTP(rec, req)
215-
216- assert.Equal(t, tt.wantStatus, rec.Code)
217- })
218- }
219-}
220-
221-func TestHandler_SecretsPost_RequiresWriteScope(t *testing.T) {
222- configSvc := inmocks.NewMockConfigService(t)
223- authSvc := inmocks.NewMockAuthService(t)
224- containerSvc := inmocks.NewMockContainerService(t)
225- secretSvc := inmocks.NewMockSecretService(t)
226-
227- handler := newTestHandler(t, func(d *HandlerDeps) {
228- d.ConfigSvc = configSvc
229- d.AuthSvc = authSvc
230- d.ContainerSvc = containerSvc
231- d.SecretSvc = secretSvc
232- })
233-
234- secretsJSON := `{"API_KEY": "secret123"}`
235-
236- tests := []struct {
237- name string
238- scopes []string
239- wantStatus int
240- }{
241- {
242- name: "secrets write access granted",
243- scopes: []string{"admin:secrets:write"},
244- wantStatus: http.StatusOK,
245- },
246- {
247- name: "only read access denied",
248- scopes: []string{"admin:secrets:read"},
249- wantStatus: http.StatusForbidden,
250- },
251- {
252- name: "wrong resource denied",
253- scopes: []string{"admin:routes:write"},
254- wantStatus: http.StatusForbidden,
255- },
256- }
257-
258- for _, tt := range tests {
259- t.Run(tt.name, func(t *testing.T) {
260- if tt.wantStatus == http.StatusOK {
261- secretSvc.EXPECT().Set(mock.Anything, "app.example.com", mock.AnythingOfType("map[string]string")).Return(nil).Maybe()
262- }
263-
264- req := httptest.NewRequest("POST", "/admin/secrets/app.example.com", bytes.NewBufferString(secretsJSON))
265- req = req.WithContext(ctxWithScopes(tt.scopes...))
266- rec := httptest.NewRecorder()
267-
268- handler.ServeHTTP(rec, req)
269-
270- assert.Equal(t, tt.wantStatus, rec.Code)
271- })
272- }
273-}
274-
275-func TestHandler_SecretsDelete_RequiresWriteScope(t *testing.T) {
276- configSvc := inmocks.NewMockConfigService(t)
277- authSvc := inmocks.NewMockAuthService(t)
278- containerSvc := inmocks.NewMockContainerService(t)
279- secretSvc := inmocks.NewMockSecretService(t)
280-
281- handler := newTestHandler(t, func(d *HandlerDeps) {
282- d.ConfigSvc = configSvc
283- d.AuthSvc = authSvc
284- d.ContainerSvc = containerSvc
285- d.SecretSvc = secretSvc
286- })
287-
288- tests := []struct {
289- name string
290- scopes []string
291- wantStatus int
292- }{
293- {
294- name: "secrets write access granted",
295- scopes: []string{"admin:secrets:write"},
296- wantStatus: http.StatusOK,
297- },
298- {
299- name: "only read access denied",
300- scopes: []string{"admin:secrets:read"},
301- wantStatus: http.StatusForbidden,
302- },
303- }
304-
305- for _, tt := range tests {
306- t.Run(tt.name, func(t *testing.T) {
307- if tt.wantStatus == http.StatusOK {
308- secretSvc.EXPECT().Delete(mock.Anything, "app.example.com", "API_KEY").Return(nil).Maybe()
309- }
310-
311- req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/API_KEY", nil)
312- req = req.WithContext(ctxWithScopes(tt.scopes...))
313- rec := httptest.NewRecorder()
314-
315- handler.ServeHTTP(rec, req)
316-
317- assert.Equal(t, tt.wantStatus, rec.Code)
318- })
319- }
320-}
321-
322 func TestHandler_Status_RequiresReadScope(t *testing.T) {158 func TestHandler_Status_RequiresReadScope(t *testing.T) {
323 configSvc := inmocks.NewMockConfigService(t)159 configSvc := inmocks.NewMockConfigService(t)
324 authSvc := inmocks.NewMockAuthService(t)160 authSvc := inmocks.NewMockAuthService(t)
325 containerSvc := inmocks.NewMockContainerService(t)161 containerSvc := inmocks.NewMockContainerService(t)
326- secretSvc := inmocks.NewMockSecretService(t)
327 162
328 handler := newTestHandler(t, func(d *HandlerDeps) {163 handler := newTestHandler(t, func(d *HandlerDeps) {
329 d.ConfigSvc = configSvc164 d.ConfigSvc = configSvc
330 d.AuthSvc = authSvc165 d.AuthSvc = authSvc
331 d.ContainerSvc = containerSvc166 d.ContainerSvc = containerSvc
332- d.SecretSvc = secretSvc
333 })167 })
334 168
335 tests := []struct {169 tests := []struct {
@@ -408,13 +242,11 @@ func TestHandler_Config_RequiresReadScope(t *testing.T) {
408 configSvc := inmocks.NewMockConfigService(t)242 configSvc := inmocks.NewMockConfigService(t)
409 authSvc := inmocks.NewMockAuthService(t)243 authSvc := inmocks.NewMockAuthService(t)
410 containerSvc := inmocks.NewMockContainerService(t)244 containerSvc := inmocks.NewMockContainerService(t)
411- secretSvc := inmocks.NewMockSecretService(t)
412 245
413 handler := newTestHandler(t, func(d *HandlerDeps) {246 handler := newTestHandler(t, func(d *HandlerDeps) {
414 d.ConfigSvc = configSvc247 d.ConfigSvc = configSvc
415 d.AuthSvc = authSvc248 d.AuthSvc = authSvc
416 d.ContainerSvc = containerSvc249 d.ContainerSvc = containerSvc
417- d.SecretSvc = secretSvc
418 })250 })
419 251
420 tests := []struct {252 tests := []struct {
@@ -468,14 +300,12 @@ func TestHandler_Reload_RequiresWriteScope(t *testing.T) {
468 configSvc := inmocks.NewMockConfigService(t)300 configSvc := inmocks.NewMockConfigService(t)
469 authSvc := inmocks.NewMockAuthService(t)301 authSvc := inmocks.NewMockAuthService(t)
470 containerSvc := inmocks.NewMockContainerService(t)302 containerSvc := inmocks.NewMockContainerService(t)
471- secretSvc := inmocks.NewMockSecretService(t)
472 reloadTrigger := &reloadTriggerRecorder{}303 reloadTrigger := &reloadTriggerRecorder{}
473 304
474 handler := newTestHandler(t, func(d *HandlerDeps) {305 handler := newTestHandler(t, func(d *HandlerDeps) {
475 d.ConfigSvc = configSvc306 d.ConfigSvc = configSvc
476 d.AuthSvc = authSvc307 d.AuthSvc = authSvc
477 d.ContainerSvc = containerSvc308 d.ContainerSvc = containerSvc
478- d.SecretSvc = secretSvc
479 d.ReloadTrigger = reloadTrigger309 d.ReloadTrigger = reloadTrigger
480 })310 })
481 311
@@ -567,61 +397,15 @@ func TestHandler_Reload_UsesDetachedContextWithDeadline(t *testing.T) {
567 397
568 // Functional tests398 // Functional tests
569 399
570-func TestHandler_Secrets_MissingDomain(t *testing.T) {
571- configSvc := inmocks.NewMockConfigService(t)
572- authSvc := inmocks.NewMockAuthService(t)
573- containerSvc := inmocks.NewMockContainerService(t)
574- secretSvc := inmocks.NewMockSecretService(t)
575-
576- handler := newTestHandler(t, func(d *HandlerDeps) {
577- d.ConfigSvc = configSvc
578- d.AuthSvc = authSvc
579- d.ContainerSvc = containerSvc
580- d.SecretSvc = secretSvc
581- })
582-
583- req := httptest.NewRequest("GET", "/admin/secrets/", nil)
584- req = req.WithContext(ctxWithScopes("admin:secrets:read"))
585- rec := httptest.NewRecorder()
586-
587- handler.ServeHTTP(rec, req)
588-
589- assert.Equal(t, http.StatusBadRequest, rec.Code)
590-}
591-
592-func TestHandler_SecretsDelete_MissingKey(t *testing.T) {
593- configSvc := inmocks.NewMockConfigService(t)
594- authSvc := inmocks.NewMockAuthService(t)
595- containerSvc := inmocks.NewMockContainerService(t)
596- secretSvc := inmocks.NewMockSecretService(t)
597-
598- handler := newTestHandler(t, func(d *HandlerDeps) {
599- d.ConfigSvc = configSvc
600- d.AuthSvc = authSvc
601- d.ContainerSvc = containerSvc
602- d.SecretSvc = secretSvc
603- })
604-
605- req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com", nil)
606- req = req.WithContext(ctxWithScopes("admin:secrets:write"))
607- rec := httptest.NewRecorder()
608-
609- handler.ServeHTTP(rec, req)
610-
611- assert.Equal(t, http.StatusBadRequest, rec.Code)
612-}
613-
614 func TestHandler_MethodNotAllowed(t *testing.T) {400 func TestHandler_MethodNotAllowed(t *testing.T) {
615 configSvc := inmocks.NewMockConfigService(t)401 configSvc := inmocks.NewMockConfigService(t)
616 authSvc := inmocks.NewMockAuthService(t)402 authSvc := inmocks.NewMockAuthService(t)
617 containerSvc := inmocks.NewMockContainerService(t)403 containerSvc := inmocks.NewMockContainerService(t)
618- secretSvc := inmocks.NewMockSecretService(t)
619 404
620 handler := newTestHandler(t, func(d *HandlerDeps) {405 handler := newTestHandler(t, func(d *HandlerDeps) {
621 d.ConfigSvc = configSvc406 d.ConfigSvc = configSvc
622 d.AuthSvc = authSvc407 d.AuthSvc = authSvc
623 d.ContainerSvc = containerSvc408 d.ContainerSvc = containerSvc
624- d.SecretSvc = secretSvc
625 })409 })
626 410
627 tests := []struct {411 tests := []struct {
@@ -653,13 +437,11 @@ func TestHandler_Tags_InvalidRepository(t *testing.T) {
653 configSvc := inmocks.NewMockConfigService(t)437 configSvc := inmocks.NewMockConfigService(t)
654 authSvc := inmocks.NewMockAuthService(t)438 authSvc := inmocks.NewMockAuthService(t)
655 containerSvc := inmocks.NewMockContainerService(t)439 containerSvc := inmocks.NewMockContainerService(t)
656- secretSvc := inmocks.NewMockSecretService(t)
657 440
658 handler := newTestHandler(t, func(d *HandlerDeps) {441 handler := newTestHandler(t, func(d *HandlerDeps) {
659 d.ConfigSvc = configSvc442 d.ConfigSvc = configSvc
660 d.AuthSvc = authSvc443 d.AuthSvc = authSvc
661 d.ContainerSvc = containerSvc444 d.ContainerSvc = containerSvc
662- d.SecretSvc = secretSvc
663 })445 })
664 446
665 tests := []struct {447 tests := []struct {
@@ -688,14 +470,12 @@ func TestHandler_Tags_Success(t *testing.T) {
688 configSvc := inmocks.NewMockConfigService(t)470 configSvc := inmocks.NewMockConfigService(t)
689 authSvc := inmocks.NewMockAuthService(t)471 authSvc := inmocks.NewMockAuthService(t)
690 containerSvc := inmocks.NewMockContainerService(t)472 containerSvc := inmocks.NewMockContainerService(t)
691- secretSvc := inmocks.NewMockSecretService(t)
692 registrySvc := inmocks.NewMockRegistryService(t)473 registrySvc := inmocks.NewMockRegistryService(t)
693 474
694 handler := newTestHandler(t, func(d *HandlerDeps) {475 handler := newTestHandler(t, func(d *HandlerDeps) {
695 d.ConfigSvc = configSvc476 d.ConfigSvc = configSvc
696 d.AuthSvc = authSvc477 d.AuthSvc = authSvc
697 d.ContainerSvc = containerSvc478 d.ContainerSvc = containerSvc
698- d.SecretSvc = secretSvc
699 d.RegistrySvc = registrySvc479 d.RegistrySvc = registrySvc
700 })480 })
701 481
@@ -721,14 +501,12 @@ func TestHandler_Tags_Error(t *testing.T) {
721 configSvc := inmocks.NewMockConfigService(t)501 configSvc := inmocks.NewMockConfigService(t)
722 authSvc := inmocks.NewMockAuthService(t)502 authSvc := inmocks.NewMockAuthService(t)
723 containerSvc := inmocks.NewMockContainerService(t)503 containerSvc := inmocks.NewMockContainerService(t)
724- secretSvc := inmocks.NewMockSecretService(t)
725 registrySvc := inmocks.NewMockRegistryService(t)504 registrySvc := inmocks.NewMockRegistryService(t)
726 505
727 handler := newTestHandler(t, func(d *HandlerDeps) {506 handler := newTestHandler(t, func(d *HandlerDeps) {
728 d.ConfigSvc = configSvc507 d.ConfigSvc = configSvc
729 d.AuthSvc = authSvc508 d.AuthSvc = authSvc
730 d.ContainerSvc = containerSvc509 d.ContainerSvc = containerSvc
731- d.SecretSvc = secretSvc
732 d.RegistrySvc = registrySvc510 d.RegistrySvc = registrySvc
733 })511 })
734 512
@@ -748,14 +526,12 @@ func TestHandler_Tags_URLDecodedRepository(t *testing.T) {
748 configSvc := inmocks.NewMockConfigService(t)526 configSvc := inmocks.NewMockConfigService(t)
749 authSvc := inmocks.NewMockAuthService(t)527 authSvc := inmocks.NewMockAuthService(t)
750 containerSvc := inmocks.NewMockContainerService(t)528 containerSvc := inmocks.NewMockContainerService(t)
751- secretSvc := inmocks.NewMockSecretService(t)
752 registrySvc := inmocks.NewMockRegistryService(t)529 registrySvc := inmocks.NewMockRegistryService(t)
753 530
754 handler := newTestHandler(t, func(d *HandlerDeps) {531 handler := newTestHandler(t, func(d *HandlerDeps) {
755 d.ConfigSvc = configSvc532 d.ConfigSvc = configSvc
756 d.AuthSvc = authSvc533 d.AuthSvc = authSvc
757 d.ContainerSvc = containerSvc534 d.ContainerSvc = containerSvc
758- d.SecretSvc = secretSvc
759 d.RegistrySvc = registrySvc535 d.RegistrySvc = registrySvc
760 })536 })
761 537
@@ -775,13 +551,11 @@ func TestHandler_NotFound(t *testing.T) {
775 configSvc := inmocks.NewMockConfigService(t)551 configSvc := inmocks.NewMockConfigService(t)
776 authSvc := inmocks.NewMockAuthService(t)552 authSvc := inmocks.NewMockAuthService(t)
777 containerSvc := inmocks.NewMockContainerService(t)553 containerSvc := inmocks.NewMockContainerService(t)
778- secretSvc := inmocks.NewMockSecretService(t)
779 554
780 handler := newTestHandler(t, func(d *HandlerDeps) {555 handler := newTestHandler(t, func(d *HandlerDeps) {
781 d.ConfigSvc = configSvc556 d.ConfigSvc = configSvc
782 d.AuthSvc = authSvc557 d.AuthSvc = authSvc
783 d.ContainerSvc = containerSvc558 d.ContainerSvc = containerSvc
784- d.SecretSvc = secretSvc
785 })559 })
786 560
787 req := httptest.NewRequest("GET", "/admin/unknown", nil)561 req := httptest.NewRequest("GET", "/admin/unknown", nil)
@@ -797,7 +571,6 @@ func TestHandler_BackupsStatus(t *testing.T) {
797 configSvc := inmocks.NewMockConfigService(t)571 configSvc := inmocks.NewMockConfigService(t)
798 authSvc := inmocks.NewMockAuthService(t)572 authSvc := inmocks.NewMockAuthService(t)
799 containerSvc := inmocks.NewMockContainerService(t)573 containerSvc := inmocks.NewMockContainerService(t)
800- secretSvc := inmocks.NewMockSecretService(t)
801 backupSvc := inmocks.NewMockBackupService(t)574 backupSvc := inmocks.NewMockBackupService(t)
802 575
803 backupSvc.EXPECT().Status(mock.Anything).Return([]domain.BackupJob{{App: "shop", Service: "api", DBName: "orders", Status: domain.BackupStatusCompleted, FilePath: "/var/lib/gordon/backups/private.bak"}}, nil)576 backupSvc.EXPECT().Status(mock.Anything).Return([]domain.BackupJob{{App: "shop", Service: "api", DBName: "orders", Status: domain.BackupStatusCompleted, FilePath: "/var/lib/gordon/backups/private.bak"}}, nil)
@@ -806,7 +579,6 @@ func TestHandler_BackupsStatus(t *testing.T) {
806 d.ConfigSvc = configSvc579 d.ConfigSvc = configSvc
807 d.AuthSvc = authSvc580 d.AuthSvc = authSvc
808 d.ContainerSvc = containerSvc581 d.ContainerSvc = containerSvc
809- d.SecretSvc = secretSvc
810 d.BackupSvc = backupSvc582 d.BackupSvc = backupSvc
811 })583 })
812 584
@@ -826,7 +598,6 @@ func TestHandler_BackupsListApp(t *testing.T) {
826 configSvc := inmocks.NewMockConfigService(t)598 configSvc := inmocks.NewMockConfigService(t)
827 authSvc := inmocks.NewMockAuthService(t)599 authSvc := inmocks.NewMockAuthService(t)
828 containerSvc := inmocks.NewMockContainerService(t)600 containerSvc := inmocks.NewMockContainerService(t)
829- secretSvc := inmocks.NewMockSecretService(t)
830 backupSvc := inmocks.NewMockBackupService(t)601 backupSvc := inmocks.NewMockBackupService(t)
831 602
832 backupSvc.EXPECT().ListBackups(mock.Anything, "shop").Return([]domain.BackupJob{{App: "shop", Service: "api", DBName: "orders", Status: domain.BackupStatusCompleted}}, nil)603 backupSvc.EXPECT().ListBackups(mock.Anything, "shop").Return([]domain.BackupJob{{App: "shop", Service: "api", DBName: "orders", Status: domain.BackupStatusCompleted}}, nil)
@@ -835,7 +606,6 @@ func TestHandler_BackupsListApp(t *testing.T) {
835 d.ConfigSvc = configSvc606 d.ConfigSvc = configSvc
836 d.AuthSvc = authSvc607 d.AuthSvc = authSvc
837 d.ContainerSvc = containerSvc608 d.ContainerSvc = containerSvc
838- d.SecretSvc = secretSvc
839 d.BackupSvc = backupSvc609 d.BackupSvc = backupSvc
840 })610 })
841 611
@@ -853,7 +623,6 @@ func TestHandler_BackupsRunApp(t *testing.T) {
853 configSvc := inmocks.NewMockConfigService(t)623 configSvc := inmocks.NewMockConfigService(t)
854 authSvc := inmocks.NewMockAuthService(t)624 authSvc := inmocks.NewMockAuthService(t)
855 containerSvc := inmocks.NewMockContainerService(t)625 containerSvc := inmocks.NewMockContainerService(t)
856- secretSvc := inmocks.NewMockSecretService(t)
857 backupSvc := inmocks.NewMockBackupService(t)626 backupSvc := inmocks.NewMockBackupService(t)
858 627
859 backupSvc.EXPECT().RunBackup(mock.Anything, "shop", "api", "orders").Return(&domain.BackupResult{Job: domain.BackupJob{App: "shop", Service: "api", DBName: "orders", Status: domain.BackupStatusCompleted}}, nil)628 backupSvc.EXPECT().RunBackup(mock.Anything, "shop", "api", "orders").Return(&domain.BackupResult{Job: domain.BackupJob{App: "shop", Service: "api", DBName: "orders", Status: domain.BackupStatusCompleted}}, nil)
@@ -862,7 +631,6 @@ func TestHandler_BackupsRunApp(t *testing.T) {
862 d.ConfigSvc = configSvc631 d.ConfigSvc = configSvc
863 d.AuthSvc = authSvc632 d.AuthSvc = authSvc
864 d.ContainerSvc = containerSvc633 d.ContainerSvc = containerSvc
865- d.SecretSvc = secretSvc
866 d.BackupSvc = backupSvc634 d.BackupSvc = backupSvc
867 })635 })
868 636
@@ -925,7 +693,6 @@ func TestHandler_BackupsRunApp_ChunkedBodyIsDecoded(t *testing.T) {
925 configSvc := inmocks.NewMockConfigService(t)693 configSvc := inmocks.NewMockConfigService(t)
926 authSvc := inmocks.NewMockAuthService(t)694 authSvc := inmocks.NewMockAuthService(t)
927 containerSvc := inmocks.NewMockContainerService(t)695 containerSvc := inmocks.NewMockContainerService(t)
928- secretSvc := inmocks.NewMockSecretService(t)
929 backupSvc := inmocks.NewMockBackupService(t)696 backupSvc := inmocks.NewMockBackupService(t)
930 697
931 backupSvc.EXPECT().RunBackup(mock.Anything, "shop", "api", "orders").Return(&domain.BackupResult{Job: domain.BackupJob{App: "shop", Service: "api", DBName: "orders", Status: domain.BackupStatusCompleted}}, nil)698 backupSvc.EXPECT().RunBackup(mock.Anything, "shop", "api", "orders").Return(&domain.BackupResult{Job: domain.BackupJob{App: "shop", Service: "api", DBName: "orders", Status: domain.BackupStatusCompleted}}, nil)
@@ -934,7 +701,6 @@ func TestHandler_BackupsRunApp_ChunkedBodyIsDecoded(t *testing.T) {
934 d.ConfigSvc = configSvc701 d.ConfigSvc = configSvc
935 d.AuthSvc = authSvc702 d.AuthSvc = authSvc
936 d.ContainerSvc = containerSvc703 d.ContainerSvc = containerSvc
937- d.SecretSvc = secretSvc
938 d.BackupSvc = backupSvc704 d.BackupSvc = backupSvc
939 })705 })
940 706
@@ -991,7 +757,6 @@ func TestHandler_ImagesGet_ReturnsMappedList(t *testing.T) {
991 configSvc := inmocks.NewMockConfigService(t)757 configSvc := inmocks.NewMockConfigService(t)
992 authSvc := inmocks.NewMockAuthService(t)758 authSvc := inmocks.NewMockAuthService(t)
993 containerSvc := inmocks.NewMockContainerService(t)759 containerSvc := inmocks.NewMockContainerService(t)
994- secretSvc := inmocks.NewMockSecretService(t)
995 760
996 createdAt := time.Date(2026, time.February, 8, 14, 30, 0, 0, time.UTC)761 createdAt := time.Date(2026, time.February, 8, 14, 30, 0, 0, time.UTC)
997 imageSvc := &stubImageService{762 imageSvc := &stubImageService{
@@ -1021,7 +786,6 @@ func TestHandler_ImagesGet_ReturnsMappedList(t *testing.T) {
1021 d.ConfigSvc = configSvc786 d.ConfigSvc = configSvc
1022 d.AuthSvc = authSvc787 d.AuthSvc = authSvc
1023 d.ContainerSvc = containerSvc788 d.ContainerSvc = containerSvc
1024- d.SecretSvc = secretSvc
1025 d.ImageSvc = imageSvc789 d.ImageSvc = imageSvc
1026 })790 })
1027 791
@@ -1059,7 +823,6 @@ func TestHandler_ImagesPrune_AcceptsOptionalKeepLast(t *testing.T) {
1059 configSvc := inmocks.NewMockConfigService(t)823 configSvc := inmocks.NewMockConfigService(t)
1060 authSvc := inmocks.NewMockAuthService(t)824 authSvc := inmocks.NewMockAuthService(t)
1061 containerSvc := inmocks.NewMockContainerService(t)825 containerSvc := inmocks.NewMockContainerService(t)
1062- secretSvc := inmocks.NewMockSecretService(t)
1063 826
1064 tests := []struct {827 tests := []struct {
1065 name string828 name string
@@ -1088,7 +851,6 @@ func TestHandler_ImagesPrune_AcceptsOptionalKeepLast(t *testing.T) {
1088 d.ConfigSvc = configSvc851 d.ConfigSvc = configSvc
1089 d.AuthSvc = authSvc852 d.AuthSvc = authSvc
1090 d.ContainerSvc = containerSvc853 d.ContainerSvc = containerSvc
1091- d.SecretSvc = secretSvc
1092 d.ImageSvc = imageSvc854 d.ImageSvc = imageSvc
1093 })855 })
1094 856
@@ -1111,7 +873,6 @@ func TestHandler_Images_ErrorMappingForServiceFailures(t *testing.T) {
1111 configSvc := inmocks.NewMockConfigService(t)873 configSvc := inmocks.NewMockConfigService(t)
1112 authSvc := inmocks.NewMockAuthService(t)874 authSvc := inmocks.NewMockAuthService(t)
1113 containerSvc := inmocks.NewMockContainerService(t)875 containerSvc := inmocks.NewMockContainerService(t)
1114- secretSvc := inmocks.NewMockSecretService(t)
1115 876
1116 imageSvc := &stubImageService{877 imageSvc := &stubImageService{
1117 listImagesFunc: func(context.Context) ([]domain.ImageInfo, error) {878 listImagesFunc: func(context.Context) ([]domain.ImageInfo, error) {
@@ -1123,7 +884,6 @@ func TestHandler_Images_ErrorMappingForServiceFailures(t *testing.T) {
1123 d.ConfigSvc = configSvc884 d.ConfigSvc = configSvc
1124 d.AuthSvc = authSvc885 d.AuthSvc = authSvc
1125 d.ContainerSvc = containerSvc886 d.ContainerSvc = containerSvc
1126- d.SecretSvc = secretSvc
1127 d.ImageSvc = imageSvc887 d.ImageSvc = imageSvc
1128 })888 })
1129 889
@@ -1142,7 +902,6 @@ func TestHandler_Images_ErrorMappingForServiceFailures(t *testing.T) {
1142 configSvc := inmocks.NewMockConfigService(t)902 configSvc := inmocks.NewMockConfigService(t)
1143 authSvc := inmocks.NewMockAuthService(t)903 authSvc := inmocks.NewMockAuthService(t)
1144 containerSvc := inmocks.NewMockContainerService(t)904 containerSvc := inmocks.NewMockContainerService(t)
1145- secretSvc := inmocks.NewMockSecretService(t)
1146 905
1147 imageSvc := &stubImageService{906 imageSvc := &stubImageService{
1148 pruneFunc: func(context.Context, domain.ImagePruneOptions) (domain.ImagePruneReport, error) {907 pruneFunc: func(context.Context, domain.ImagePruneOptions) (domain.ImagePruneReport, error) {
@@ -1154,7 +913,6 @@ func TestHandler_Images_ErrorMappingForServiceFailures(t *testing.T) {
1154 d.ConfigSvc = configSvc913 d.ConfigSvc = configSvc
1155 d.AuthSvc = authSvc914 d.AuthSvc = authSvc
1156 d.ContainerSvc = containerSvc915 d.ContainerSvc = containerSvc
1157- d.SecretSvc = secretSvc
1158 d.ImageSvc = imageSvc916 d.ImageSvc = imageSvc
1159 })917 })
1160 918
@@ -1175,13 +933,11 @@ func TestHandler_ImagesPrune_ValidationAndAvailability(t *testing.T) {
1175 configSvc := inmocks.NewMockConfigService(t)933 configSvc := inmocks.NewMockConfigService(t)
1176 authSvc := inmocks.NewMockAuthService(t)934 authSvc := inmocks.NewMockAuthService(t)
1177 containerSvc := inmocks.NewMockContainerService(t)935 containerSvc := inmocks.NewMockContainerService(t)
1178- secretSvc := inmocks.NewMockSecretService(t)
1179 936
1180 handler := newTestHandler(t, func(d *HandlerDeps) {937 handler := newTestHandler(t, func(d *HandlerDeps) {
1181 d.ConfigSvc = configSvc938 d.ConfigSvc = configSvc
1182 d.AuthSvc = authSvc939 d.AuthSvc = authSvc
1183 d.ContainerSvc = containerSvc940 d.ContainerSvc = containerSvc
1184- d.SecretSvc = secretSvc
1185 d.ImageSvc = &stubImageService{}941 d.ImageSvc = &stubImageService{}
1186 })942 })
1187 943
@@ -1199,13 +955,11 @@ func TestHandler_ImagesPrune_ValidationAndAvailability(t *testing.T) {
1199 configSvc := inmocks.NewMockConfigService(t)955 configSvc := inmocks.NewMockConfigService(t)
1200 authSvc := inmocks.NewMockAuthService(t)956 authSvc := inmocks.NewMockAuthService(t)
1201 containerSvc := inmocks.NewMockContainerService(t)957 containerSvc := inmocks.NewMockContainerService(t)
1202- secretSvc := inmocks.NewMockSecretService(t)
1203 958
1204 handler := newTestHandler(t, func(d *HandlerDeps) {959 handler := newTestHandler(t, func(d *HandlerDeps) {
1205 d.ConfigSvc = configSvc960 d.ConfigSvc = configSvc
1206 d.AuthSvc = authSvc961 d.AuthSvc = authSvc
1207 d.ContainerSvc = containerSvc962 d.ContainerSvc = containerSvc
1208- d.SecretSvc = secretSvc
1209 })963 })
1210 964
1211 req := httptest.NewRequest("GET", "/admin/images", nil)965 req := httptest.NewRequest("GET", "/admin/images", nil)
@@ -1223,7 +977,6 @@ func TestHandler_ImagesPrune_ScopeDefaults(t *testing.T) {
1223 configSvc := inmocks.NewMockConfigService(t)977 configSvc := inmocks.NewMockConfigService(t)
1224 authSvc := inmocks.NewMockAuthService(t)978 authSvc := inmocks.NewMockAuthService(t)
1225 containerSvc := inmocks.NewMockContainerService(t)979 containerSvc := inmocks.NewMockContainerService(t)
1226- secretSvc := inmocks.NewMockSecretService(t)
1227 980
1228 tests := []struct {981 tests := []struct {
1229 name string982 name string
@@ -1281,7 +1034,6 @@ func TestHandler_ImagesPrune_ScopeDefaults(t *testing.T) {
1281 d.ConfigSvc = configSvc1034 d.ConfigSvc = configSvc
1282 d.AuthSvc = authSvc1035 d.AuthSvc = authSvc
1283 d.ContainerSvc = containerSvc1036 d.ContainerSvc = containerSvc
1284- d.SecretSvc = secretSvc
1285 d.ImageSvc = imageSvc1037 d.ImageSvc = imageSvc
1286 })1038 })
1287 1039
@@ -1306,13 +1058,11 @@ func TestHandler_Images_Authorization(t *testing.T) {
1306 configSvc := inmocks.NewMockConfigService(t)1058 configSvc := inmocks.NewMockConfigService(t)
1307 authSvc := inmocks.NewMockAuthService(t)1059 authSvc := inmocks.NewMockAuthService(t)
1308 containerSvc := inmocks.NewMockContainerService(t)1060 containerSvc := inmocks.NewMockContainerService(t)
1309- secretSvc := inmocks.NewMockSecretService(t)
1310 1061
1311 handler := newTestHandler(t, func(d *HandlerDeps) {1062 handler := newTestHandler(t, func(d *HandlerDeps) {
1312 d.ConfigSvc = configSvc1063 d.ConfigSvc = configSvc
1313 d.AuthSvc = authSvc1064 d.AuthSvc = authSvc
1314 d.ContainerSvc = containerSvc1065 d.ContainerSvc = containerSvc
1315- d.SecretSvc = secretSvc
1316 d.ImageSvc = &stubImageService{}1066 d.ImageSvc = &stubImageService{}
1317 })1067 })
1318 1068
M internal/adapters/in/http/admin/local_authority.go +0 -7
@@ -22,7 +22,6 @@ var localAuthorityScopes = []string{
22 domain.AdminScopeApps(domain.AdminActionRead, domain.AdminActionWrite),22 domain.AdminScopeApps(domain.AdminActionRead, domain.AdminActionWrite),
23 domain.AdminScopeStatus(domain.AdminActionRead),23 domain.AdminScopeStatus(domain.AdminActionRead),
24 domain.AdminScopeConfig(domain.AdminActionRead, domain.AdminActionWrite),24 domain.AdminScopeConfig(domain.AdminActionRead, domain.AdminActionWrite),
25- domain.AdminScopeSecrets(domain.AdminActionRead, domain.AdminActionWrite),
26 domain.AdminScopeLogs(domain.AdminActionRead),25 domain.AdminScopeLogs(domain.AdminActionRead),
27 domain.AdminScopeVolumes(domain.AdminActionRead, domain.AdminActionWrite),26 domain.AdminScopeVolumes(domain.AdminActionRead, domain.AdminActionWrite),
28 }27 }
@@ -64,7 +63,6 @@ func localPathAllowed(method, cleanedPath string) bool {
64 prefixRules := map[string]func(string, []string) bool{63 prefixRules := map[string]func(string, []string) bool{
65 "apps": localAppPathAllowed,64 "apps": localAppPathAllowed,
66 "backups": localBackupPathAllowed,65 "backups": localBackupPathAllowed,
67- "secrets": localSecretPathAllowed,
68 "logs": localLogPathAllowed,66 "logs": localLogPathAllowed,
69 "tags": localTagPathAllowed,67 "tags": localTagPathAllowed,
70 }68 }
@@ -72,11 +70,6 @@ func localPathAllowed(method, cleanedPath string) bool {
72 return ok && rule(method, parts)70 return ok && rule(method, parts)
73 }71 }
74 72
75-func localSecretPathAllowed(method string, parts []string) bool {
76- return len(parts) == 2 && parts[1] != "" && (method == http.MethodGet || method == http.MethodPost) ||
77- len(parts) == 3 && parts[1] != "" && parts[2] != "" && method == http.MethodDelete
78-}
79-
80 func localLogPathAllowed(method string, parts []string) bool {73 func localLogPathAllowed(method string, parts []string) bool {
81 return (len(parts) == 1 || len(parts) == 3 && parts[1] != "" && parts[2] != "") && method == http.MethodGet74 return (len(parts) == 1 || len(parts) == 3 && parts[1] != "" && parts[2] != "") && method == http.MethodGet
82 }75 }
M internal/adapters/in/http/admin/local_authority_test.go +3 -5
@@ -35,8 +35,6 @@ func TestLocalAuthorityInjectsLeastPrivilegeScopes(t *testing.T) {
35 {domain.AdminResourceStatus, domain.AdminActionRead},35 {domain.AdminResourceStatus, domain.AdminActionRead},
36 {domain.AdminResourceConfig, domain.AdminActionRead},36 {domain.AdminResourceConfig, domain.AdminActionRead},
37 {domain.AdminResourceConfig, domain.AdminActionWrite},37 {domain.AdminResourceConfig, domain.AdminActionWrite},
38- {domain.AdminResourceSecrets, domain.AdminActionRead},
39- {domain.AdminResourceSecrets, domain.AdminActionWrite},
40 {domain.AdminResourceLogs, domain.AdminActionRead},38 {domain.AdminResourceLogs, domain.AdminActionRead},
41 {domain.AdminResourceVolumes, domain.AdminActionRead},39 {domain.AdminResourceVolumes, domain.AdminActionRead},
42 {domain.AdminResourceVolumes, domain.AdminActionWrite},40 {domain.AdminResourceVolumes, domain.AdminActionWrite},
@@ -44,6 +42,7 @@ func TestLocalAuthorityInjectsLeastPrivilegeScopes(t *testing.T) {
44 assert.True(t, HasAccess(ctx, access.resource, access.action), "%s:%s", access.resource, access.action)42 assert.True(t, HasAccess(ctx, access.resource, access.action), "%s:%s", access.resource, access.action)
45 }43 }
46 assert.False(t, HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionRead))44 assert.False(t, HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionRead))
45+ assert.False(t, HasAccess(ctx, "secrets", domain.AdminActionRead))
47 assert.False(t, HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionWrite))46 assert.False(t, HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionWrite))
48 assert.False(t, HasAccess(ctx, domain.AdminResourceLogs, domain.AdminActionWrite))47 assert.False(t, HasAccess(ctx, domain.AdminResourceLogs, domain.AdminActionWrite))
49 assert.False(t, HasAccess(ctx, domain.AdminResourceAll, domain.AdminActionAll))48 assert.False(t, HasAccess(ctx, domain.AdminResourceAll, domain.AdminActionAll))
@@ -70,9 +69,6 @@ func TestLocalAuthorityCanonicalAllowlist(t *testing.T) {
70 {http.MethodGet, "/tags/repository"},69 {http.MethodGet, "/tags/repository"},
71 {http.MethodGet, "/logs"},70 {http.MethodGet, "/logs"},
72 {http.MethodGet, "/logs/blog/web"},71 {http.MethodGet, "/logs/blog/web"},
73- {http.MethodGet, "/secrets/blog.example.com"},
74- {http.MethodPost, "/secrets/blog.example.com"},
75- {http.MethodDelete, "/secrets/blog.example.com/API_KEY"},
76 {http.MethodGet, "/backups"},72 {http.MethodGet, "/backups"},
77 {http.MethodGet, "/backups/status"},73 {http.MethodGet, "/backups/status"},
78 {http.MethodGet, "/backups/shop"},74 {http.MethodGet, "/backups/shop"},
@@ -116,6 +112,8 @@ func TestLocalAuthorityDeniesNearMissesAndNonCanonicalPaths(t *testing.T) {
116 {http.MethodGet, "/health"},112 {http.MethodGet, "/health"},
117 {http.MethodGet, "/appsX"},113 {http.MethodGet, "/appsX"},
118 {http.MethodGet, "/secrets"},114 {http.MethodGet, "/secrets"},
115+ {http.MethodGet, "/secrets/blog.example.com"},
116+ {http.MethodPost, "/secrets/blog.example.com"},
119 {http.MethodDelete, "/secrets/blog"},117 {http.MethodDelete, "/secrets/blog"},
120 {http.MethodGet, "/images/anything"},118 {http.MethodGet, "/images/anything"},
121 {http.MethodGet, "/tags/repository/extra"},119 {http.MethodGet, "/tags/repository/extra"},
D internal/adapters/out/domainsecrets/helpers_test.go +0 -25
@@ -1,25 +0,0 @@
1-package domainsecrets
2-
3-import (
4- "fmt"
5- "testing"
6-
7- "github.com/bnema/zerowrap"
8-)
9-
10-func testLogger() zerowrap.Logger {
11- return zerowrap.Default()
12-}
13-
14-// CleanupPassAttachment removes attachment secrets from pass for testing.
15-// It's a test helper that should be called with defer to ensure cleanup.
16-// This function is exported to be shared across test packages.
17-func CleanupPassAttachment(_ *testing.T, containerName string, keys []string) {
18- for _, key := range keys {
19- path := fmt.Sprintf("%s/%s/%s", PassAttachmentPath, containerName, key)
20- _ = passCmd("rm", "-f", path)
21- }
22-
23- manifestPath := fmt.Sprintf("%s/%s/.keys", PassAttachmentPath, containerName)
24- _ = passCmd("rm", "-f", manifestPath)
25-}
D internal/adapters/out/domainsecrets/pass_store.go +0 -934
@@ -1,934 +0,0 @@
1-// Package domainsecrets implements the DomainSecretStore adapter using pass.
2-package domainsecrets
3-
4-import (
5- "context"
6- "fmt"
7- "os/exec"
8- "regexp"
9- "sort"
10- "strings"
11- "sync"
12- "time"
13-
14- "github.com/bnema/zerowrap"
15-
16- "github.com/bnema/gordon/internal/adapters/out/secrets"
17- "github.com/bnema/gordon/internal/boundaries/out"
18- "github.com/bnema/gordon/internal/domain"
19-)
20-
21-// ansiRegex matches ANSI escape sequences for stripping from pass output.
22-var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*m`)
23-
24-const (
25- PassDomainSecretsPath = "gordon/env" //nolint:gosec // Not a credential, this is a pass store path.
26- PassAttachmentPath = "gordon/env/attachments" //nolint:gosec // Not a credential, this is a pass store path.
27-)
28-
29-// PassStore implements the DomainSecretStore interface using the pass password manager.
30-type PassStore struct {
31- mu sync.Mutex
32- timeout time.Duration
33- log zerowrap.Logger
34-}
35-
36-// NewPassStore creates a new pass-based domain secret store.
37-func NewPassStore(log zerowrap.Logger) (*PassStore, error) {
38- // Use a timeout so a stalled GPG agent or keyring does not hang startup.
39- probeCtx, probeCancel := context.WithTimeout(context.Background(), 10*time.Second)
40- defer probeCancel()
41- if err := exec.CommandContext(probeCtx, "pass", "version").Run(); err != nil { //nolint:gosec // binary is a constant ("pass"), no user input
42- return nil, fmt.Errorf("pass is not available: %w", err)
43- }
44-
45- log.Debug().
46- Str(zerowrap.FieldLayer, "adapter").
47- Str(zerowrap.FieldAdapter, "domainsecrets").
48- Str("provider", "pass").
49- Msg("domain secret store initialized")
50-
51- return &PassStore{
52- timeout: 10 * time.Second,
53- log: log,
54- }, nil
55-}
56-
57-// ListKeys returns the list of secret keys for a domain (not values).
58-func (s *PassStore) ListKeys(domainName string) ([]string, error) {
59- manifestPath, err := s.manifestPath(domainName)
60- if err != nil {
61- return nil, err
62- }
63-
64- ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
65- defer cancel()
66-
67- content, exists, err := s.passShow(ctx, manifestPath)
68- if err != nil {
69- return nil, err
70- }
71- keys := []string{}
72- if exists {
73- for _, line := range strings.Split(content, "\n") {
74- key := strings.TrimSpace(line)
75- if key == "" {
76- continue
77- }
78- keys = append(keys, key)
79- }
80- }
81- sort.Strings(keys)
82-
83- // Recover keys that may exist in pass but are missing from the manifest.
84- discovered, err := s.listDomainKeys(domainName)
85- if err != nil {
86- return nil, err
87- }
88-
89- merged, changed := mergeUniqueKeys(keys, discovered)
90- if changed {
91- // Best-effort self-heal of stale manifest.
92- writeCtx, writeCancel := context.WithTimeout(context.Background(), s.timeout)
93- writeErr := s.passInsert(writeCtx, manifestPath, strings.Join(merged, "\n"))
94- writeCancel()
95- if writeErr != nil {
96- s.log.Warn().
97- Str(zerowrap.FieldLayer, "adapter").
98- Str(zerowrap.FieldAdapter, "domainsecrets").
99- Str("domain", domainName).
100- Err(writeErr).
101- Msg("failed to self-heal pass manifest, continuing with recovered keys")
102- }
103- }
104-
105- return merged, nil
106-}
107-
108-// GetAll returns all secrets for a domain as a key-value map.
109-func (s *PassStore) GetAll(domainName string) (map[string]string, error) {
110- keys, err := s.ListKeys(domainName)
111- if err != nil {
112- return nil, err
113- }
114-
115- secretsMap := make(map[string]string)
116- for _, key := range keys {
117- keyPath, err := s.keyPath(domainName, key)
118- if err != nil {
119- return nil, err
120- }
121-
122- ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
123- value, exists, err := s.passShow(ctx, keyPath)
124- cancel()
125- if err != nil {
126- return nil, err
127- }
128- if !exists {
129- s.log.Warn().
130- Str(zerowrap.FieldLayer, "adapter").
131- Str(zerowrap.FieldAdapter, "domainsecrets").
132- Str("domain", domainName).
133- Str("key", key).
134- Msg("secret listed in manifest but missing in pass")
135- continue
136- }
137- secretsMap[key] = value
138- }
139-
140- return secretsMap, nil
141-}
142-
143-// SetIfEmpty sets multiple secrets for a domain only when no keys already exist.
144-// It performs the precondition check inside the store adapter so migration
145-// callers cannot accidentally split the check and write into separate steps.
146-func (s *PassStore) SetIfEmpty(domainName string, secretsMap map[string]string) ([]string, error) {
147- s.mu.Lock()
148- defer s.mu.Unlock()
149-
150- existingKeys, err := s.ListKeys(domainName)
151- if err != nil {
152- return nil, err
153- }
154- if len(existingKeys) > 0 {
155- return nil, fmt.Errorf("%w: pass secrets already exist for %s (found %d keys)", domain.ErrSecretsAlreadyExist, domainName, len(existingKeys))
156- }
157- if err := s.Set(domainName, secretsMap); err != nil {
158- return nil, err
159- }
160- return sortedMapKeys(secretsMap), nil
161-}
162-
163-// Set sets or updates multiple secrets for a domain.
164-func (s *PassStore) Set(domainName string, secretsMap map[string]string) error {
165- if _, err := s.domainPath(domainName); err != nil {
166- return err
167- }
168-
169- existingKeys, err := s.ListKeys(domainName)
170- if err != nil {
171- return err
172- }
173-
174- existingSet := make(map[string]struct{}, len(existingKeys))
175- for _, key := range existingKeys {
176- existingSet[key] = struct{}{}
177- }
178-
179- insertedNewPaths := make([]string, 0, len(secretsMap))
180- for key, value := range secretsMap {
181- if err := domain.ValidateEnvKey(key); err != nil {
182- return err
183- }
184-
185- keyPath, err := s.keyPath(domainName, key)
186- if err != nil {
187- return err
188- }
189-
190- ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
191- err = s.passInsert(ctx, keyPath, value)
192- cancel()
193- if err != nil {
194- s.cleanupInsertedPaths(insertedNewPaths)
195- return fmt.Errorf("failed to store secret %s: %w", key, err)
196- }
197-
198- if _, exists := existingSet[key]; !exists {
199- insertedNewPaths = append(insertedNewPaths, keyPath)
200- }
201- }
202-
203- keySet := make(map[string]struct{}, len(existingKeys)+len(secretsMap))
204- for _, key := range existingKeys {
205- keySet[key] = struct{}{}
206- }
207- for key := range secretsMap {
208- keySet[key] = struct{}{}
209- }
210-
211- keys := make([]string, 0, len(keySet))
212- for key := range keySet {
213- keys = append(keys, key)
214- }
215- sort.Strings(keys)
216-
217- manifestPath, err := s.manifestPath(domainName)
218- if err != nil {
219- return err
220- }
221-
222- ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
223- if err := s.passInsert(ctx, manifestPath, strings.Join(keys, "\n")); err != nil {
224- cancel()
225- s.cleanupInsertedPaths(insertedNewPaths)
226- return fmt.Errorf("failed to update manifest: %w", err)
227- }
228- cancel()
229-
230- return nil
231-}
232-
233-// Delete removes a specific secret key from a domain.
234-func (s *PassStore) Delete(domainName, key string) error {
235- if _, err := s.domainPath(domainName); err != nil {
236- return err
237- }
238-
239- keyPath, err := s.keyPath(domainName, key)
240- if err != nil {
241- return err
242- }
243-
244- rmCtx, rmCancel := context.WithTimeout(context.Background(), s.timeout)
245- defer rmCancel()
246- if err := s.passRemove(rmCtx, keyPath); err != nil {
247- return err
248- }
249-
250- keys, err := s.ListKeys(domainName)
251- if err != nil {
252- return err
253- }
254-
255- updated := make([]string, 0, len(keys))
256- for _, existingKey := range keys {
257- if existingKey == key {
258- continue
259- }
260- updated = append(updated, existingKey)
261- }
262- sort.Strings(updated)
263-
264- manifestPath, err := s.manifestPath(domainName)
265- if err != nil {
266- return err
267- }
268-
269- insertCtx, insertCancel := context.WithTimeout(context.Background(), s.timeout)
270- defer insertCancel()
271- if err := s.passInsert(insertCtx, manifestPath, strings.Join(updated, "\n")); err != nil {
272- return fmt.Errorf("failed to update manifest: %w", err)
273- }
274-
275- return nil
276-}
277-
278-// SetAttachmentIfEmpty sets attachment secrets only when no keys already exist.
279-// It performs the precondition check inside the store adapter so migration
280-// callers cannot accidentally split the check and write into separate steps.
281-func (s *PassStore) SetAttachmentIfEmpty(containerName string, secretsMap map[string]string) ([]string, error) {
282- s.mu.Lock()
283- defer s.mu.Unlock()
284-
285- existingKeys, err := s.listAttachmentKeysRecover(containerName)
286- if err != nil {
287- return nil, err
288- }
289- if len(existingKeys) > 0 {
290- return nil, fmt.Errorf("%w: pass secrets already exist for attachment %s (found %d keys)", domain.ErrSecretsAlreadyExist, containerName, len(existingKeys))
291- }
292- if err := s.SetAttachment(containerName, secretsMap); err != nil {
293- return nil, err
294- }
295- return sortedMapKeys(secretsMap), nil
296-}
297-
298-// SetAttachment sets or updates multiple secrets for an attachment container.
299-func (s *PassStore) SetAttachment(containerName string, secretsMap map[string]string) error {
300- if _, err := s.attachmentPath(containerName); err != nil {
301- return err
302- }
303-
304- existingKeys, err := s.listAttachmentKeys(containerName)
305- if err != nil {
306- return err
307- }
308-
309- existingSet := make(map[string]struct{}, len(existingKeys))
310- for _, key := range existingKeys {
311- existingSet[key] = struct{}{}
312- }
313-
314- insertedNewPaths := make([]string, 0, len(secretsMap))
315- for key, value := range secretsMap {
316- if err := domain.ValidateEnvKey(key); err != nil {
317- return err
318- }
319-
320- keyPath, err := s.attachmentKeyPath(containerName, key)
321- if err != nil {
322- return err
323- }
324-
325- ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
326- err = s.passInsert(ctx, keyPath, value)
327- cancel()
328- if err != nil {
329- s.cleanupInsertedPaths(insertedNewPaths)
330- return fmt.Errorf("failed to store attachment secret %s: %w", key, err)
331- }
332-
333- if _, exists := existingSet[key]; !exists {
334- insertedNewPaths = append(insertedNewPaths, keyPath)
335- }
336- }
337-
338- keySet := make(map[string]struct{}, len(existingKeys)+len(secretsMap))
339- for _, key := range existingKeys {
340- keySet[key] = struct{}{}
341- }
342- for key := range secretsMap {
343- keySet[key] = struct{}{}
344- }
345-
346- keys := make([]string, 0, len(keySet))
347- for key := range keySet {
348- keys = append(keys, key)
349- }
350- sort.Strings(keys)
351-
352- manifestPath, err := s.attachmentManifestPath(containerName)
353- if err != nil {
354- return err
355- }
356-
357- ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
358- if err := s.passInsert(ctx, manifestPath, strings.Join(keys, "\n")); err != nil {
359- cancel()
360- s.cleanupInsertedPaths(insertedNewPaths)
361- return fmt.Errorf("failed to update attachment manifest: %w", err)
362- }
363- cancel()
364-
365- return nil
366-}
367-
368-// DeleteAttachment removes a specific secret key from an attachment container.
369-func (s *PassStore) DeleteAttachment(containerName, key string) error {
370- if _, err := s.attachmentPath(containerName); err != nil {
371- return err
372- }
373-
374- keyPath, err := s.attachmentKeyPath(containerName, key)
375- if err != nil {
376- return err
377- }
378-
379- rmCtx, rmCancel := context.WithTimeout(context.Background(), s.timeout)
380- defer rmCancel()
381- if err := s.passRemove(rmCtx, keyPath); err != nil {
382- return err
383- }
384-
385- keys, err := s.listAttachmentKeys(containerName)
386- if err != nil {
387- return err
388- }
389-
390- updated := make([]string, 0, len(keys))
391- for _, existingKey := range keys {
392- if existingKey == key {
393- continue
394- }
395- updated = append(updated, existingKey)
396- }
397- sort.Strings(updated)
398-
399- manifestPath, err := s.attachmentManifestPath(containerName)
400- if err != nil {
401- return err
402- }
403-
404- insertCtx, insertCancel := context.WithTimeout(context.Background(), s.timeout)
405- defer insertCancel()
406- if err := s.passInsert(insertCtx, manifestPath, strings.Join(updated, "\n")); err != nil {
407- return fmt.Errorf("failed to update attachment manifest: %w", err)
408- }
409-
410- return nil
411-}
412-
413-// GetAllAttachment returns all secrets for an attachment container as a key-value map.
414-func (s *PassStore) GetAllAttachment(containerName string) (map[string]string, error) {
415- keys, err := s.listAttachmentKeys(containerName)
416- if err != nil {
417- return nil, err
418- }
419-
420- secretsMap := make(map[string]string)
421- for _, key := range keys {
422- keyPath, err := s.attachmentKeyPath(containerName, key)
423- if err != nil {
424- return nil, err
425- }
426-
427- ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
428- value, exists, err := s.passShow(ctx, keyPath)
429- cancel()
430- if err != nil {
431- return nil, err
432- }
433- if !exists {
434- s.log.Warn().
435- Str(zerowrap.FieldLayer, "adapter").
436- Str(zerowrap.FieldAdapter, "domainsecrets").
437- Str("container", containerName).
438- Str("key", key).
439- Msg("attachment secret listed in manifest but missing in pass")
440- continue
441- }
442- secretsMap[key] = value
443- }
444-
445- return secretsMap, nil
446-}
447-
448-// ListAttachmentKeys finds attachment secrets for a domain from pass.
449-// Supports both new (collision-resistant) and legacy container naming for backwards compatibility.
450-func (s *PassStore) ListAttachmentKeys(domainName string) ([]out.AttachmentSecrets, error) {
451- if _, err := s.domainPath(domainName); err != nil {
452- return nil, err
453- }
454-
455- ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
456- defer cancel()
457-
458- containers, err := s.listTopLevelEntries(ctx, PassAttachmentPath)
459- if err != nil {
460- return nil, err
461- }
462-
463- // Try both new and legacy sanitization for backwards compatibility
464- sanitizedDomain := domain.SanitizeDomainForContainer(domainName)
465- sanitizedDomainLegacy := domain.SanitizeDomainForContainerLegacy(domainName)
466- prefixes := []string{
467- "gordon-" + sanitizedDomain + "-", // New format (collision-resistant)
468- "gordon-" + sanitizedDomainLegacy + "-", // Old format (buggy but backwards compatible)
469- }
470-
471- seen := make(map[string]bool)
472- var results []out.AttachmentSecrets
473- for _, containerName := range containers {
474- matches := false
475- for _, prefix := range prefixes {
476- if strings.HasPrefix(containerName, prefix) {
477- matches = true
478- break
479- }
480- }
481- if !matches {
482- continue
483- }
484-
485- // Deduplicate results
486- if seen[containerName] {
487- continue
488- }
489- seen[containerName] = true
490-
491- manifestPath := fmt.Sprintf("%s/%s/.keys", PassAttachmentPath, containerName)
492- if err := secrets.ValidatePath(manifestPath); err != nil {
493- return nil, err
494- }
495-
496- showCtx, showCancel := context.WithTimeout(context.Background(), s.timeout)
497- content, exists, err := s.passShow(showCtx, manifestPath)
498- showCancel()
499- if err != nil {
500- return nil, err
501- }
502- if !exists {
503- continue
504- }
505-
506- keys := []string{}
507- for _, line := range strings.Split(content, "\n") {
508- key := strings.TrimSpace(line)
509- if key == "" {
510- continue
511- }
512- keys = append(keys, key)
513- }
514-
515- if len(keys) > 0 {
516- results = append(results, out.AttachmentSecrets{
517- Service: containerName,
518- Keys: keys,
519- })
520- }
521- }
522-
523- return results, nil
524-}
525-
526-func (s *PassStore) domainPath(domainName string) (string, error) {
527- safeDomain, err := s.sanitizeDomain(domainName)
528- if err != nil {
529- return "", err
530- }
531- path := fmt.Sprintf("%s/%s", PassDomainSecretsPath, safeDomain)
532- if err := secrets.ValidatePath(path); err != nil {
533- return "", err
534- }
535- return path, nil
536-}
537-
538-func (s *PassStore) keyPath(domainName, key string) (string, error) {
539- domainPath, err := s.domainPath(domainName)
540- if err != nil {
541- return "", err
542- }
543- if err := domain.ValidateEnvKey(key); err != nil {
544- return "", err
545- }
546- path := fmt.Sprintf("%s/%s", domainPath, key)
547- if err := secrets.ValidatePath(path); err != nil {
548- return "", err
549- }
550- return path, nil
551-}
552-
553-func (s *PassStore) manifestPath(domainName string) (string, error) {
554- domainPath, err := s.domainPath(domainName)
555- if err != nil {
556- return "", err
557- }
558- path := fmt.Sprintf("%s/.keys", domainPath)
559- if err := secrets.ValidatePath(path); err != nil {
560- return "", err
561- }
562- return path, nil
563-}
564-
565-func (s *PassStore) listDomainKeys(domainName string) ([]string, error) {
566- basePath, err := s.domainPath(domainName)
567- if err != nil {
568- return nil, err
569- }
570-
571- ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
572- defer cancel()
573-
574- entries, err := s.listTopLevelEntries(ctx, basePath)
575- if err != nil {
576- return nil, err
577- }
578-
579- keys := make([]string, 0, len(entries))
580- for _, entry := range entries {
581- if entry == ".keys" {
582- continue
583- }
584- if err := domain.ValidateEnvKey(entry); err != nil {
585- continue
586- }
587- keys = append(keys, entry)
588- }
589-
590- sort.Strings(keys)
591- return keys, nil
592-}
593-
594-func (s *PassStore) attachmentPath(containerName string) (string, error) {
595- if err := domain.ValidateContainerName(containerName); err != nil {
596- return "", err
597- }
598- path := fmt.Sprintf("%s/%s", PassAttachmentPath, containerName)
599- if err := secrets.ValidatePath(path); err != nil {
600- return "", err
601- }
602- return path, nil
603-}
604-
605-func (s *PassStore) attachmentKeyPath(containerName, key string) (string, error) {
606- attachmentPath, err := s.attachmentPath(containerName)
607- if err != nil {
608- return "", err
609- }
610- if err := domain.ValidateEnvKey(key); err != nil {
611- return "", err
612- }
613- path := fmt.Sprintf("%s/%s", attachmentPath, key)
614- if err := secrets.ValidatePath(path); err != nil {
615- return "", err
616- }
617- return path, nil
618-}
619-
620-func (s *PassStore) attachmentManifestPath(containerName string) (string, error) {
621- attachmentPath, err := s.attachmentPath(containerName)
622- if err != nil {
623- return "", err
624- }
625- path := fmt.Sprintf("%s/.keys", attachmentPath)
626- if err := secrets.ValidatePath(path); err != nil {
627- return "", err
628- }
629- return path, nil
630-}
631-
632-func (s *PassStore) listAttachmentKeys(containerName string) ([]string, error) {
633- manifestPath, err := s.attachmentManifestPath(containerName)
634- if err != nil {
635- return nil, err
636- }
637-
638- ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
639- defer cancel()
640-
641- content, exists, err := s.passShow(ctx, manifestPath)
642- if err != nil {
643- return nil, err
644- }
645- if !exists {
646- return []string{}, nil
647- }
648-
649- keys := []string{}
650- for _, line := range strings.Split(content, "\n") {
651- key := strings.TrimSpace(line)
652- if key == "" {
653- continue
654- }
655- keys = append(keys, key)
656- }
657-
658- sort.Strings(keys)
659- return keys, nil
660-}
661-
662-func (s *PassStore) listAttachmentKeysRecover(containerName string) ([]string, error) {
663- keys, err := s.listAttachmentKeys(containerName)
664- if err != nil {
665- return nil, err
666- }
667- discovered, err := s.discoverAttachmentKeys(containerName)
668- if err != nil {
669- return nil, err
670- }
671- merged, changed := mergeUniqueKeys(keys, discovered)
672- if changed {
673- manifestPath, err := s.attachmentManifestPath(containerName)
674- if err != nil {
675- return nil, err
676- }
677- writeCtx, writeCancel := context.WithTimeout(context.Background(), s.timeout)
678- writeErr := s.passInsert(writeCtx, manifestPath, strings.Join(merged, "\n"))
679- writeCancel()
680- if writeErr != nil {
681- s.log.Warn().
682- Str(zerowrap.FieldLayer, "adapter").
683- Str(zerowrap.FieldAdapter, "domainsecrets").
684- Str("container", containerName).
685- Err(writeErr).
686- Msg("failed to self-heal attachment pass manifest, continuing with recovered keys")
687- }
688- }
689- return merged, nil
690-}
691-
692-func (s *PassStore) discoverAttachmentKeys(containerName string) ([]string, error) {
693- basePath, err := s.attachmentPath(containerName)
694- if err != nil {
695- return nil, err
696- }
697-
698- ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
699- defer cancel()
700-
701- entries, err := s.listTopLevelEntries(ctx, basePath)
702- if err != nil {
703- return nil, err
704- }
705-
706- keys := make([]string, 0, len(entries))
707- for _, entry := range entries {
708- if entry == ".keys" {
709- continue
710- }
711- if err := domain.ValidateEnvKey(entry); err != nil {
712- continue
713- }
714- keys = append(keys, entry)
715- }
716-
717- sort.Strings(keys)
718- return keys, nil
719-}
720-
721-func (s *PassStore) sanitizeDomain(domainName string) (string, error) {
722- safeDomain, err := domain.SanitizeDomainForEnvFile(domainName)
723- if err != nil {
724- s.log.Warn().
725- Str(zerowrap.FieldLayer, "adapter").
726- Str(zerowrap.FieldAdapter, "domainsecrets").
727- Str("domain", domainName).
728- Err(err).
729- Msg("rejected invalid domain")
730- return "", domain.ErrPathTraversal
731- }
732- return safeDomain, nil
733-}
734-
735-// ManifestExists checks if the manifest exists for a domain.
736-func (s *PassStore) ManifestExists(domainName string) (bool, error) {
737- manifestPath, err := s.manifestPath(domainName)
738- if err != nil {
739- return false, err
740- }
741-
742- ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
743- defer cancel()
744-
745- _, exists, err := s.passShow(ctx, manifestPath)
746- if err != nil {
747- return false, err
748- }
749-
750- return exists, nil
751-}
752-
753-func (s *PassStore) passInsert(ctx context.Context, path, value string) error {
754- cmd := exec.CommandContext(ctx, "pass", "insert", "-m", "-f", path) //nolint:gosec // binary is constant ("pass"); path arguments validated by secrets path validator
755- cmd.Stdin = strings.NewReader(value)
756- output, err := cmd.CombinedOutput()
757- if err != nil {
758- return fmt.Errorf("pass insert failed: %s: %w", strings.TrimSpace(string(output)), err)
759- }
760- return nil
761-}
762-
763-func (s *PassStore) passRemove(ctx context.Context, path string) error {
764- cmd := exec.CommandContext(ctx, "pass", "rm", "-f", path) //nolint:gosec // binary is constant ("pass"); path arguments validated by secrets path validator
765- output, err := cmd.CombinedOutput()
766- if err != nil {
767- if passEntryMissing(string(output)) {
768- return nil
769- }
770- return fmt.Errorf("pass rm failed: %s: %w", strings.TrimSpace(string(output)), err)
771- }
772- return nil
773-}
774-
775-func (s *PassStore) passShow(ctx context.Context, path string) (string, bool, error) {
776- cmd := exec.CommandContext(ctx, "pass", "show", path) //nolint:gosec // binary is constant ("pass"); path arguments validated by secrets path validator
777- output, err := cmd.CombinedOutput()
778- if err != nil {
779- if passEntryMissing(string(output)) {
780- return "", false, nil
781- }
782- return "", false, fmt.Errorf("pass show failed: %s: %w", strings.TrimSpace(string(output)), err)
783- }
784-
785- clean := ansiRegex.ReplaceAllString(string(output), "")
786- clean = strings.TrimRight(clean, "\r\n")
787- return clean, true, nil
788-}
789-
790-func (s *PassStore) listTopLevelEntries(ctx context.Context, basePath string) ([]string, error) {
791- if err := secrets.ValidatePath(basePath); err != nil {
792- return nil, err
793- }
794-
795- cmd := exec.CommandContext(ctx, "pass", "ls", basePath) //nolint:gosec // binary is constant ("pass"); path arguments validated by secrets path validator
796- output, err := cmd.CombinedOutput()
797- if err != nil {
798- if passEntryMissing(string(output)) {
799- return []string{}, nil
800- }
801- return nil, fmt.Errorf("pass ls failed: %s: %w", strings.TrimSpace(string(output)), err)
802- }
803-
804- entries := []string{}
805- for _, entry := range parsePassListOutput(basePath, string(output)) {
806- if entry.depth == 1 {
807- entries = append(entries, entry.name)
808- }
809- }
810-
811- return entries, nil
812-}
813-
814-type passListEntry struct {
815- name string
816- depth int
817-}
818-
819-func parsePassListOutput(basePath, output string) []passListEntry {
820- lines := strings.Split(output, "\n")
821- entries := []passListEntry{}
822-
823- for _, line := range lines {
824- line = ansiRegex.ReplaceAllString(line, "")
825- if strings.TrimSpace(line) == "" {
826- continue
827- }
828-
829- depth := 0
830- prefixLoop:
831- for {
832- switch {
833- case strings.HasPrefix(line, "│ "):
834- line = strings.TrimPrefix(line, "│ ")
835- depth++
836- case strings.HasPrefix(line, "| "):
837- line = strings.TrimPrefix(line, "| ")
838- depth++
839- case strings.HasPrefix(line, " "):
840- line = strings.TrimPrefix(line, " ")
841- depth++
842- default:
843- break prefixLoop
844- }
845- }
846-
847- switch {
848- case strings.HasPrefix(line, "├── "):
849- line = strings.TrimPrefix(line, "├── ")
850- depth++
851- case strings.HasPrefix(line, "└── "):
852- line = strings.TrimPrefix(line, "└── ")
853- depth++
854- case strings.HasPrefix(line, "|-- "):
855- line = strings.TrimPrefix(line, "|-- ")
856- depth++
857- case strings.HasPrefix(line, "`-- "):
858- line = strings.TrimPrefix(line, "`-- ")
859- depth++
860- }
861-
862- name := strings.TrimSpace(line)
863- if name == "" || name == basePath {
864- continue
865- }
866- if depth == 0 {
867- continue
868- }
869-
870- entries = append(entries, passListEntry{name: name, depth: depth})
871- }
872-
873- return entries
874-}
875-
876-func passEntryMissing(output string) bool {
877- clean := ansiRegex.ReplaceAllString(output, "")
878- lower := strings.ToLower(clean)
879- return strings.Contains(lower, "not in the password store") ||
880- strings.Contains(lower, "password store is empty")
881-}
882-
883-func sortedMapKeys(values map[string]string) []string {
884- keys := make([]string, 0, len(values))
885- for key := range values {
886- keys = append(keys, key)
887- }
888- sort.Strings(keys)
889- return keys
890-}
891-
892-func mergeUniqueKeys(primary, secondary []string) ([]string, bool) {
893- mergedSet := make(map[string]struct{}, len(primary)+len(secondary))
894- for _, key := range primary {
895- mergedSet[key] = struct{}{}
896- }
897- for _, key := range secondary {
898- mergedSet[key] = struct{}{}
899- }
900-
901- merged := make([]string, 0, len(mergedSet))
902- for key := range mergedSet {
903- merged = append(merged, key)
904- }
905- sort.Strings(merged)
906-
907- if len(merged) != len(primary) {
908- return merged, true
909- }
910-
911- for i := range primary {
912- if primary[i] != merged[i] {
913- return merged, true
914- }
915- }
916-
917- return merged, false
918-}
919-
920-func (s *PassStore) cleanupInsertedPaths(paths []string) {
921- if len(paths) == 0 {
922- return
923- }
924-
925- for _, path := range paths {
926- // Wrap in a closure so defer cancel() runs at the end of each iteration,
927- // not at the end of cleanupInsertedPaths (which would delay all cancels).
928- func(p string) {
929- ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
930- defer cancel()
931- _ = s.passRemove(ctx, p)
932- }(path)
933- }
934-}
D internal/adapters/out/domainsecrets/pass_store_test.go +0 -330
@@ -1,330 +0,0 @@
1-package domainsecrets
2-
3-import (
4- "context"
5- "fmt"
6- "os/exec"
7- "strings"
8- "testing"
9- "time"
10-
11- "github.com/stretchr/testify/assert"
12- "github.com/stretchr/testify/require"
13-
14- "github.com/bnema/gordon/internal/domain"
15-)
16-
17-func passCmd(args ...string) error {
18- ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
19- defer cancel()
20- return exec.CommandContext(ctx, "pass", args...).Run()
21-}
22-
23-func passInsertValue(path, value string) error {
24- ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
25- defer cancel()
26- cmd := exec.CommandContext(ctx, "pass", "insert", "-m", "-f", path)
27- cmd.Stdin = strings.NewReader(value)
28- _, err := cmd.CombinedOutput()
29- return err
30-}
31-
32-func passShow(path string) (string, error) {
33- ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
34- defer cancel()
35- out, err := exec.CommandContext(ctx, "pass", "show", path).CombinedOutput()
36- if err != nil {
37- return "", err
38- }
39- return strings.TrimSpace(string(out)), nil
40-}
41-
42-func requirePass(t *testing.T) {
43- if err := passCmd("version"); err != nil {
44- t.Skip("pass not available")
45- }
46- if err := passCmd("ls"); err != nil {
47- t.Skip("pass store not initialized")
48- }
49-}
50-
51-func cleanupPassDomain(_ *testing.T, domainName string, keys []string) {
52- safeDomain, err := domain.SanitizeDomainForEnvFile(domainName)
53- if err != nil {
54- return
55- }
56-
57- for _, key := range keys {
58- path := fmt.Sprintf("%s/%s/%s", PassDomainSecretsPath, safeDomain, key)
59- _ = passCmd("rm", "-f", path)
60- }
61-
62- manifestPath := fmt.Sprintf("%s/%s/.keys", PassDomainSecretsPath, safeDomain)
63- _ = passCmd("rm", "-f", manifestPath)
64-}
65-
66-func TestPassStore_SetGetDelete(t *testing.T) {
67- requirePass(t)
68-
69- store, err := NewPassStore(testLogger())
70- require.NoError(t, err)
71-
72- domainName := fmt.Sprintf("test.%d.example.com", time.Now().UnixNano())
73- keys := []string{"API_KEY", "DB_PASSWORD"}
74- defer cleanupPassDomain(t, domainName, keys)
75-
76- secretsMap := map[string]string{
77- "API_KEY": "alpha",
78- "DB_PASSWORD": "bravo",
79- }
80-
81- err = store.Set(domainName, secretsMap)
82- require.NoError(t, err)
83-
84- keysList, err := store.ListKeys(domainName)
85- require.NoError(t, err)
86- assert.Len(t, keysList, 2)
87- assert.ElementsMatch(t, keys, keysList)
88-
89- values, err := store.GetAll(domainName)
90- require.NoError(t, err)
91- assert.Equal(t, "alpha", values["API_KEY"])
92- assert.Equal(t, "bravo", values["DB_PASSWORD"])
93-
94- err = store.Delete(domainName, "DB_PASSWORD")
95- require.NoError(t, err)
96-
97- keysList, err = store.ListKeys(domainName)
98- require.NoError(t, err)
99- assert.ElementsMatch(t, []string{"API_KEY"}, keysList)
100-}
101-
102-func TestPassStore_SetGetAttachment(t *testing.T) {
103- requirePass(t)
104-
105- store, err := NewPassStore(testLogger())
106- require.NoError(t, err)
107-
108- containerName := fmt.Sprintf("gitea-postgres-%d", time.Now().UnixNano())
109- keys := []string{"POSTGRES_USER", "POSTGRES_PASSWORD"}
110- defer CleanupPassAttachment(t, containerName, keys)
111-
112- secretsMap := map[string]string{
113- "POSTGRES_USER": "gitea",
114- "POSTGRES_PASSWORD": "secret123",
115- }
116-
117- err = store.SetAttachment(containerName, secretsMap)
118- require.NoError(t, err)
119-
120- values, err := store.GetAllAttachment(containerName)
121- require.NoError(t, err)
122- assert.Equal(t, "gitea", values["POSTGRES_USER"])
123- assert.Equal(t, "secret123", values["POSTGRES_PASSWORD"])
124-}
125-
126-func TestPassStore_ListAttachmentKeys_AfterSet(t *testing.T) {
127- requirePass(t)
128-
129- store, err := NewPassStore(testLogger())
130- require.NoError(t, err)
131-
132- containerName := fmt.Sprintf("redis-cache-%d", time.Now().UnixNano())
133- keys := []string{"REDIS_PASSWORD"}
134- defer CleanupPassAttachment(t, containerName, keys)
135-
136- secretsMap := map[string]string{
137- "REDIS_PASSWORD": "redis123",
138- }
139-
140- err = store.SetAttachment(containerName, secretsMap)
141- require.NoError(t, err)
142-
143- values, err := store.GetAllAttachment(containerName)
144- require.NoError(t, err)
145- assert.Len(t, values, 1)
146- assert.Equal(t, "redis123", values["REDIS_PASSWORD"])
147-}
148-
149-func TestPassStore_DeleteAttachment(t *testing.T) {
150- requirePass(t)
151-
152- store, err := NewPassStore(testLogger())
153- require.NoError(t, err)
154-
155- containerName := fmt.Sprintf("gitea-postgres-%d", time.Now().UnixNano())
156- keys := []string{"POSTGRES_USER", "POSTGRES_PASSWORD"}
157- defer CleanupPassAttachment(t, containerName, keys)
158-
159- // Set 2 secrets
160- secretsMap := map[string]string{
161- "POSTGRES_USER": "gitea",
162- "POSTGRES_PASSWORD": "secret123",
163- }
164- err = store.SetAttachment(containerName, secretsMap)
165- require.NoError(t, err)
166-
167- // Delete 1
168- err = store.DeleteAttachment(containerName, "POSTGRES_PASSWORD")
169- require.NoError(t, err)
170-
171- // Verify remaining via GetAllAttachment
172- values, err := store.GetAllAttachment(containerName)
173- require.NoError(t, err)
174- assert.Len(t, values, 1)
175- assert.Equal(t, "gitea", values["POSTGRES_USER"])
176- _, exists := values["POSTGRES_PASSWORD"]
177- assert.False(t, exists)
178-}
179-
180-func TestPassStore_Delete_Idempotent(t *testing.T) {
181- requirePass(t)
182-
183- store, err := NewPassStore(testLogger())
184- require.NoError(t, err)
185-
186- domainName := fmt.Sprintf("idempotent.%d.example.com", time.Now().UnixNano())
187- keys := []string{"API_KEY"}
188- defer cleanupPassDomain(t, domainName, keys)
189-
190- err = store.Set(domainName, map[string]string{"API_KEY": "alpha"})
191- require.NoError(t, err)
192-
193- err = store.Delete(domainName, "API_KEY")
194- require.NoError(t, err)
195-
196- // Second delete of an already-removed key must be a no-op.
197- err = store.Delete(domainName, "API_KEY")
198- require.NoError(t, err)
199-
200- values, err := store.GetAll(domainName)
201- require.NoError(t, err)
202- assert.NotContains(t, values, "API_KEY")
203-}
204-
205-func TestPassStore_DeleteAttachment_Idempotent(t *testing.T) {
206- requirePass(t)
207-
208- store, err := NewPassStore(testLogger())
209- require.NoError(t, err)
210-
211- containerName := fmt.Sprintf("idempotent-attachment-%d", time.Now().UnixNano())
212- keys := []string{"POSTGRES_PASSWORD"}
213- defer CleanupPassAttachment(t, containerName, keys)
214-
215- err = store.SetAttachment(containerName, map[string]string{"POSTGRES_PASSWORD": "secret123"})
216- require.NoError(t, err)
217-
218- err = store.DeleteAttachment(containerName, "POSTGRES_PASSWORD")
219- require.NoError(t, err)
220-
221- // Second delete of an already-removed key must be a no-op.
222- err = store.DeleteAttachment(containerName, "POSTGRES_PASSWORD")
223- require.NoError(t, err)
224-
225- values, err := store.GetAllAttachment(containerName)
226- require.NoError(t, err)
227- assert.NotContains(t, values, "POSTGRES_PASSWORD")
228-}
229-
230-func TestPassStore_SetAttachment_OverwriteValue(t *testing.T) {
231- requirePass(t)
232-
233- store, err := NewPassStore(testLogger())
234- require.NoError(t, err)
235-
236- containerName := fmt.Sprintf("attachment-overwrite-%d", time.Now().UnixNano())
237- keys := []string{"REDIS_PASSWORD"}
238- defer CleanupPassAttachment(t, containerName, keys)
239-
240- err = store.SetAttachment(containerName, map[string]string{"REDIS_PASSWORD": "first"})
241- require.NoError(t, err)
242-
243- // Re-setting same key with a different value should deterministically overwrite.
244- err = store.SetAttachment(containerName, map[string]string{"REDIS_PASSWORD": "second"})
245- require.NoError(t, err)
246-
247- values, err := store.GetAllAttachment(containerName)
248- require.NoError(t, err)
249- assert.Equal(t, "second", values["REDIS_PASSWORD"])
250-}
251-
252-func TestPassStore_ListKeys_RecoversOrphanedEntries(t *testing.T) {
253- requirePass(t)
254-
255- store, err := NewPassStore(testLogger())
256- require.NoError(t, err)
257-
258- domainName := fmt.Sprintf("orphan.%d.example.com", time.Now().UnixNano())
259- keys := []string{"EXISTING", "ORIGIN"}
260- defer cleanupPassDomain(t, domainName, keys)
261-
262- err = store.Set(domainName, map[string]string{"EXISTING": "present"})
263- require.NoError(t, err)
264-
265- safeDomain, err := domain.SanitizeDomainForEnvFile(domainName)
266- require.NoError(t, err)
267-
268- orphanPath := fmt.Sprintf("%s/%s/ORIGIN", PassDomainSecretsPath, safeDomain)
269- err = passInsertValue(orphanPath, "https://example.com")
270- require.NoError(t, err)
271-
272- manifestPath := fmt.Sprintf("%s/%s/.keys", PassDomainSecretsPath, safeDomain)
273- err = passInsertValue(manifestPath, "EXISTING\n")
274- require.NoError(t, err)
275-
276- listed, err := store.ListKeys(domainName)
277- require.NoError(t, err)
278- assert.ElementsMatch(t, []string{"EXISTING", "ORIGIN"}, listed)
279-
280- values, err := store.GetAll(domainName)
281- require.NoError(t, err)
282- assert.Equal(t, "present", values["EXISTING"])
283- assert.Equal(t, "https://example.com", values["ORIGIN"])
284-
285- manifest, err := passShow(manifestPath)
286- require.NoError(t, err)
287- assert.Contains(t, manifest, "EXISTING")
288- assert.Contains(t, manifest, "ORIGIN")
289-}
290-
291-func TestParsePassListOutput(t *testing.T) {
292- tests := []struct {
293- name string
294- basePath string
295- output string
296- want []passListEntry
297- }{
298- {
299- name: "simple tree",
300- basePath: "domain",
301- output: "domain\n├── key1\n└── key2",
302- want: []passListEntry{{name: "key1", depth: 1}, {name: "key2", depth: 1}},
303- },
304- {
305- name: "nested tree",
306- basePath: "domain",
307- output: "domain\n│ ├── subkey1\n│ └── subkey2\n└── key1",
308- want: []passListEntry{{name: "subkey1", depth: 2}, {name: "subkey2", depth: 2}, {name: "key1", depth: 1}},
309- },
310- {
311- name: "empty output",
312- basePath: "domain",
313- output: "",
314- want: []passListEntry{},
315- },
316- {
317- name: "ASCII fallback chars",
318- basePath: "domain",
319- output: "domain\n| |-- subkey1\n`-- key1",
320- want: []passListEntry{{name: "subkey1", depth: 2}, {name: "key1", depth: 1}},
321- },
322- }
323-
324- for _, tt := range tests {
325- t.Run(tt.name, func(t *testing.T) {
326- got := parsePassListOutput(tt.basePath, tt.output)
327- assert.Equal(t, tt.want, got)
328- })
329- }
330-}
D internal/adapters/out/domainsecrets/store.go +0 -554
@@ -1,554 +0,0 @@
1-// Package domainsecrets implements the DomainSecretStore adapter using filesystem-based env files.
2-package domainsecrets
3-
4-import (
5- "bufio"
6- "fmt"
7- "os"
8- "path/filepath"
9- "sort"
10- "strings"
11-
12- "github.com/bnema/zerowrap"
13-
14- "github.com/bnema/gordon/internal/boundaries/out"
15- "github.com/bnema/gordon/internal/domain"
16-)
17-
18-// FileStore implements the DomainSecretStore interface using filesystem-based env files.
19-// This adapter is responsible only for file I/O operations; domain validation
20-// should be performed by the use case layer before calling these methods.
21-type FileStore struct {
22- envDir string
23- log zerowrap.Logger
24-}
25-
26-// NewFileStore creates a new file-based domain secret store.
27-func NewFileStore(envDir string, log zerowrap.Logger) (*FileStore, error) {
28- // Ensure env directory exists
29- if err := os.MkdirAll(envDir, 0700); err != nil {
30- return nil, fmt.Errorf("failed to create env directory %s: %w", envDir, err)
31- }
32-
33- log.Debug().
34- Str(zerowrap.FieldLayer, "adapter").
35- Str(zerowrap.FieldAdapter, "domainsecrets").
36- Str("env_dir", envDir).
37- Msg("domain secret store initialized")
38-
39- return &FileStore{
40- envDir: envDir,
41- log: log,
42- }, nil
43-}
44-
45-// ListKeys returns the list of secret keys for a domain (not values).
46-func (s *FileStore) ListKeys(domainName string) ([]string, error) {
47- envFile, err := s.validateEnvFilePath(domainName)
48- if err != nil {
49- return nil, err
50- }
51-
52- file, err := os.Open(envFile)
53- if err != nil {
54- if os.IsNotExist(err) {
55- return []string{}, nil
56- }
57- return nil, fmt.Errorf("failed to open env file: %w", err)
58- }
59- defer file.Close()
60-
61- var keys []string
62- scanner := bufio.NewScanner(file)
63- for scanner.Scan() {
64- line := strings.TrimSpace(scanner.Text())
65- // Skip empty lines and comments
66- if line == "" || strings.HasPrefix(line, "#") {
67- continue
68- }
69- // Extract key from KEY=value
70- if idx := strings.Index(line, "="); idx > 0 {
71- keys = append(keys, line[:idx])
72- }
73- }
74-
75- if err := scanner.Err(); err != nil {
76- return nil, fmt.Errorf("failed to read env file: %w", err)
77- }
78-
79- return keys, nil
80-}
81-
82-// GetAll returns all secrets for a domain as a key-value map.
83-func (s *FileStore) GetAll(domainName string) (map[string]string, error) {
84- envFile, err := s.validateEnvFilePath(domainName)
85- if err != nil {
86- return nil, err
87- }
88-
89- data, err := os.ReadFile(envFile)
90- if err != nil {
91- if os.IsNotExist(err) {
92- return map[string]string{}, nil
93- }
94- return nil, fmt.Errorf("failed to read env file: %w", err)
95- }
96-
97- secrets, err := domain.ParseEnvData(data)
98- if err != nil {
99- return nil, fmt.Errorf("failed to parse env file: %w", err)
100- }
101-
102- return secrets, nil
103-}
104-
105-// Set sets or updates multiple secrets for a domain, merging with existing.
106-func (s *FileStore) Set(domainName string, secrets map[string]string) error {
107- // Validate domain first
108- if _, err := s.validateEnvFilePath(domainName); err != nil {
109- return err
110- }
111-
112- // Ensure env directory exists
113- if err := os.MkdirAll(s.envDir, 0700); err != nil {
114- return fmt.Errorf("failed to create env directory: %w", err)
115- }
116-
117- // Read existing secrets
118- existing, err := s.GetAll(domainName)
119- if err != nil {
120- return err
121- }
122-
123- // Merge new secrets with existing
124- for key, value := range secrets {
125- existing[key] = value
126- }
127-
128- // Write back atomically
129- return s.writeSecretsAtomic(domainName, existing)
130-}
131-
132-// Delete removes a specific secret key from a domain.
133-func (s *FileStore) Delete(domainName, key string) error {
134- // Validate domain first
135- if _, err := s.validateEnvFilePath(domainName); err != nil {
136- return err
137- }
138-
139- // Read existing secrets
140- existing, err := s.GetAll(domainName)
141- if err != nil {
142- return err
143- }
144-
145- // Remove the key
146- delete(existing, key)
147-
148- // Write back atomically
149- return s.writeSecretsAtomic(domainName, existing)
150-}
151-
152-// SetAttachment sets or updates multiple secrets for an attachment container.
153-func (s *FileStore) SetAttachment(containerName string, secrets map[string]string) error {
154- // Validate container name first
155- if _, err := s.validateAttachmentEnvFilePath(containerName); err != nil {
156- return err
157- }
158-
159- // Ensure env directory exists
160- if err := os.MkdirAll(s.envDir, 0700); err != nil {
161- return fmt.Errorf("failed to create env directory: %w", err)
162- }
163-
164- // Read existing secrets
165- existing, err := s.GetAllAttachment(containerName)
166- if err != nil {
167- return err
168- }
169-
170- // Merge new secrets with existing
171- for key, value := range secrets {
172- existing[key] = value
173- }
174-
175- // Write back atomically
176- return s.writeAttachmentSecretsAtomic(containerName, existing)
177-}
178-
179-// DeleteAttachment removes a specific secret key from an attachment container.
180-func (s *FileStore) DeleteAttachment(containerName, key string) error {
181- // Validate container name first
182- if _, err := s.validateAttachmentEnvFilePath(containerName); err != nil {
183- return err
184- }
185-
186- // Read existing secrets
187- existing, err := s.GetAllAttachment(containerName)
188- if err != nil {
189- return err
190- }
191-
192- // Remove the key (no-op if not present)
193- delete(existing, key)
194-
195- // Write back atomically
196- return s.writeAttachmentSecretsAtomic(containerName, existing)
197-}
198-
199-// GetAllAttachment returns all secrets for an attachment container as a key-value map.
200-func (s *FileStore) GetAllAttachment(containerName string) (map[string]string, error) {
201- envFile, err := s.validateAttachmentEnvFilePath(containerName)
202- if err != nil {
203- return nil, err
204- }
205-
206- data, err := os.ReadFile(envFile)
207- if err != nil {
208- if os.IsNotExist(err) {
209- return map[string]string{}, nil
210- }
211- return nil, fmt.Errorf("failed to read attachment env file: %w", err)
212- }
213-
214- secrets, err := domain.ParseEnvData(data)
215- if err != nil {
216- return nil, fmt.Errorf("failed to parse attachment env file: %w", err)
217- }
218-
219- return secrets, nil
220-}
221-
222-// validateSecretValues checks that no secret value contains newline characters.
223-func validateSecretValues(secrets map[string]string) error {
224- for key, value := range secrets {
225- if strings.ContainsAny(value, "\n\r") {
226- return fmt.Errorf("secret value for %q contains newline characters", key)
227- }
228- }
229- return nil
230-}
231-
232-// writeSecretsAtomic writes all secrets to the domain's env file atomically.
233-// It writes to a temporary file first, syncs it, then renames to the final path.
234-func (s *FileStore) writeSecretsAtomic(domainName string, secrets map[string]string) error {
235- if err := validateSecretValues(secrets); err != nil {
236- return fmt.Errorf("invalid domain secret values for %q: %w", domainName, err)
237- }
238-
239- envFile, err := s.validateEnvFilePath(domainName)
240- if err != nil {
241- return err
242- }
243- tmpFile := envFile + ".tmp"
244-
245- file, err := os.OpenFile(tmpFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
246- if err != nil {
247- return fmt.Errorf("failed to create temp env file: %w", err)
248- }
249-
250- // Write header comment
251- if _, err := fmt.Fprintf(file, "# Environment variables for %s\n", domainName); err != nil {
252- file.Close()
253- os.Remove(tmpFile)
254- return fmt.Errorf("failed to write header: %w", err)
255- }
256- if _, err := fmt.Fprintf(file, "# Managed by Gordon admin API\n\n"); err != nil {
257- file.Close()
258- os.Remove(tmpFile)
259- return fmt.Errorf("failed to write header: %w", err)
260- }
261-
262- // Write each secret in sorted order for deterministic output
263- keys := make([]string, 0, len(secrets))
264- for key := range secrets {
265- keys = append(keys, key)
266- }
267- sort.Strings(keys)
268-
269- for _, key := range keys {
270- if _, err := fmt.Fprintf(file, "%s=%s\n", key, secrets[key]); err != nil {
271- file.Close()
272- os.Remove(tmpFile)
273- return fmt.Errorf("failed to write secret %s: %w", key, err)
274- }
275- }
276-
277- // Sync to ensure data is on disk before rename
278- if err := file.Sync(); err != nil {
279- file.Close()
280- os.Remove(tmpFile)
281- return fmt.Errorf("failed to sync env file: %w", err)
282- }
283-
284- if err := file.Close(); err != nil {
285- os.Remove(tmpFile)
286- return fmt.Errorf("failed to close env file: %w", err)
287- }
288-
289- // Atomic rename
290- if err := os.Rename(tmpFile, envFile); err != nil {
291- os.Remove(tmpFile)
292- return fmt.Errorf("failed to rename env file: %w", err)
293- }
294-
295- return nil
296-}
297-
298-// getEnvFilePath converts a domain to its env file path.
299-// This must match the naming convention in envloader.FileLoader.getEnvFilePath.
300-//
301-// SECURITY: Validates domain and ensures the resulting path stays within envDir.
302-func (s *FileStore) getEnvFilePath(domainName string) (string, error) {
303- storageKey, err := domain.NewEnvStorageKey(domainName)
304- if err != nil {
305- s.log.Warn().
306- Str(zerowrap.FieldLayer, "adapter").
307- Str(zerowrap.FieldAdapter, "domainsecrets").
308- Str("domain", domainName).
309- Err(err).
310- Msg("rejected invalid domain")
311- return "", err
312- }
313-
314- fullPath := filepath.Join(s.envDir, storageKey.FileName())
315-
316- // SECURITY: Final validation - ensure path stays within envDir
317- cleanPath := filepath.Clean(fullPath)
318- cleanEnvDir := filepath.Clean(s.envDir)
319- if !strings.HasPrefix(cleanPath, cleanEnvDir+string(filepath.Separator)) {
320- s.log.Error().
321- Str(zerowrap.FieldLayer, "adapter").
322- Str(zerowrap.FieldAdapter, "domainsecrets").
323- Str("domain", domainName).
324- Str("attempted_path", fullPath).
325- Msg("path traversal attempt blocked - path escapes env directory")
326- return "", domain.ErrPathTraversal
327- }
328-
329- return fullPath, nil
330-}
331-
332-// validateEnvFilePath validates that a domain produces a valid env file path.
333-// Returns an error if the domain is invalid or would result in path traversal.
334-func (s *FileStore) validateEnvFilePath(domainName string) (string, error) {
335- return s.getEnvFilePath(domainName)
336-}
337-
338-// getAttachmentEnvFilePath converts a container name to its attachment env file path.
339-// Attachment env files follow the pattern: gordon-<containerName>.env
340-//
341-// SECURITY: Validates container name and ensures the resulting path stays within envDir.
342-func (s *FileStore) getAttachmentEnvFilePath(containerName string) (string, error) {
343- if err := domain.ValidateContainerName(containerName); err != nil {
344- s.log.Warn().
345- Str(zerowrap.FieldLayer, "adapter").
346- Str(zerowrap.FieldAdapter, "domainsecrets").
347- Str("container", containerName).
348- Err(err).
349- Msg("rejected invalid container name")
350- return "", err
351- }
352-
353- fullPath := filepath.Join(s.envDir, "gordon-"+containerName+".env")
354-
355- // SECURITY: Final validation - ensure path stays within envDir
356- cleanPath := filepath.Clean(fullPath)
357- cleanEnvDir := filepath.Clean(s.envDir)
358- if !strings.HasPrefix(cleanPath, cleanEnvDir+string(filepath.Separator)) {
359- s.log.Error().
360- Str(zerowrap.FieldLayer, "adapter").
361- Str(zerowrap.FieldAdapter, "domainsecrets").
362- Str("container", containerName).
363- Str("attempted_path", fullPath).
364- Msg("path traversal attempt blocked - path escapes env directory")
365- return "", domain.ErrPathTraversal
366- }
367-
368- return fullPath, nil
369-}
370-
371-// validateAttachmentEnvFilePath validates that a container name produces a valid attachment env file path.
372-// Returns an error if the container name is invalid or would result in path traversal.
373-func (s *FileStore) validateAttachmentEnvFilePath(containerName string) (string, error) {
374- return s.getAttachmentEnvFilePath(containerName)
375-}
376-
377-// writeAttachmentSecretsAtomic writes all secrets to the attachment's env file atomically.
378-// It writes to a temporary file first, syncs it, then renames to the final path.
379-func (s *FileStore) writeAttachmentSecretsAtomic(containerName string, secrets map[string]string) error {
380- if err := validateSecretValues(secrets); err != nil {
381- return fmt.Errorf("invalid attachment secret values for %q: %w", containerName, err)
382- }
383-
384- envFile, err := s.validateAttachmentEnvFilePath(containerName)
385- if err != nil {
386- return err
387- }
388- tmpFile := envFile + ".tmp"
389-
390- file, err := os.OpenFile(tmpFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
391- if err != nil {
392- return fmt.Errorf("failed to create temp attachment env file: %w", err)
393- }
394-
395- // Write header comment
396- if _, err := fmt.Fprintf(file, "# Environment variables for attachment %s\n", containerName); err != nil {
397- file.Close()
398- os.Remove(tmpFile)
399- return fmt.Errorf("failed to write header: %w", err)
400- }
401- if _, err := fmt.Fprintf(file, "# Managed by Gordon admin API\n\n"); err != nil {
402- file.Close()
403- os.Remove(tmpFile)
404- return fmt.Errorf("failed to write header: %w", err)
405- }
406-
407- // Write each secret in sorted order for deterministic output
408- keys := make([]string, 0, len(secrets))
409- for key := range secrets {
410- keys = append(keys, key)
411- }
412- sort.Strings(keys)
413-
414- for _, key := range keys {
415- if _, err := fmt.Fprintf(file, "%s=%s\n", key, secrets[key]); err != nil {
416- file.Close()
417- os.Remove(tmpFile)
418- return fmt.Errorf("failed to write secret %s: %w", key, err)
419- }
420- }
421-
422- // Sync to ensure data is on disk before rename
423- if err := file.Sync(); err != nil {
424- file.Close()
425- os.Remove(tmpFile)
426- return fmt.Errorf("failed to sync attachment env file: %w", err)
427- }
428-
429- if err := file.Close(); err != nil {
430- os.Remove(tmpFile)
431- return fmt.Errorf("failed to close attachment env file: %w", err)
432- }
433-
434- // Atomic rename
435- if err := os.Rename(tmpFile, envFile); err != nil {
436- os.Remove(tmpFile)
437- return fmt.Errorf("failed to rename attachment env file: %w", err)
438- }
439-
440- return nil
441-}
442-
443-// ListAttachmentKeys finds attachment env files for a domain and returns their keys.
444-// Supports both new (collision-resistant) and legacy container naming for backwards compatibility.
445-// Attachment env files follow the naming pattern: gordon-{sanitized-domain}-{service}.env
446-func (s *FileStore) ListAttachmentKeys(domainName string) ([]out.AttachmentSecrets, error) {
447- // Try both new and legacy sanitization for backwards compatibility
448- sanitized := domain.SanitizeDomainForContainer(domainName)
449- sanitizedLegacy := domain.SanitizeDomainForContainerLegacy(domainName)
450- prefixes := []string{
451- "gordon-" + sanitized + "-", // New format (collision-resistant)
452- "gordon-" + sanitizedLegacy + "-", // Old format (buggy but backwards compatible)
453- }
454-
455- // List all files in env directory
456- entries, err := os.ReadDir(s.envDir)
457- if err != nil {
458- if os.IsNotExist(err) {
459- return nil, nil
460- }
461- return nil, fmt.Errorf("failed to read env directory: %w", err)
462- }
463-
464- seen := make(map[string]bool)
465- var results []out.AttachmentSecrets
466- for _, entry := range entries {
467- if entry.IsDir() {
468- continue
469- }
470- name := entry.Name()
471-
472- // Check if this matches any of our prefixes
473- var matchedPrefix string
474- for _, prefix := range prefixes {
475- if strings.HasPrefix(name, prefix) && strings.HasSuffix(name, ".env") {
476- matchedPrefix = prefix
477- break
478- }
479- }
480- if matchedPrefix == "" {
481- continue
482- }
483-
484- // Extract container name from filename
485- containerName := strings.TrimSuffix(name, ".env")
486-
487- // Deduplicate results
488- if seen[containerName] {
489- continue
490- }
491- seen[containerName] = true
492-
493- // Extract service name from filename
494- // e.g., "gordon-git-bnema-dev-gitea-postgres.env" → "gitea-postgres"
495- serviceName := strings.TrimPrefix(name, matchedPrefix)
496- serviceName = strings.TrimSuffix(serviceName, ".env")
497- if serviceName == "" {
498- continue
499- }
500-
501- // Read keys from this file using existing method
502- // Note: We use the container name directly since it matches the env file naming
503- keys, err := s.listKeysFromFile(filepath.Join(s.envDir, name))
504- if err != nil {
505- s.log.Warn().
506- Err(err).
507- Str("file", name).
508- Str("domain", domainName).
509- Msg("failed to read attachment secrets file")
510- continue
511- }
512-
513- if len(keys) > 0 {
514- results = append(results, out.AttachmentSecrets{
515- Service: containerName,
516- Keys: keys,
517- })
518- }
519- }
520-
521- return results, nil
522-}
523-
524-// listKeysFromFile reads secret keys from a specific env file path.
525-func (s *FileStore) listKeysFromFile(filePath string) ([]string, error) {
526- file, err := os.Open(filePath)
527- if err != nil {
528- if os.IsNotExist(err) {
529- return []string{}, nil
530- }
531- return nil, fmt.Errorf("failed to open env file: %w", err)
532- }
533- defer file.Close()
534-
535- var keys []string
536- scanner := bufio.NewScanner(file)
537- for scanner.Scan() {
538- line := strings.TrimSpace(scanner.Text())
539- // Skip empty lines and comments
540- if line == "" || strings.HasPrefix(line, "#") {
541- continue
542- }
543- // Extract key from KEY=value
544- if idx := strings.Index(line, "="); idx > 0 {
545- keys = append(keys, line[:idx])
546- }
547- }
548-
549- if err := scanner.Err(); err != nil {
550- return nil, fmt.Errorf("failed to read env file: %w", err)
551- }
552-
553- return keys, nil
554-}
D internal/adapters/out/domainsecrets/store_test.go +0 -432
@@ -1,432 +0,0 @@
1-package domainsecrets
2-
3-import (
4- "errors"
5- "os"
6- "path/filepath"
7- "testing"
8-
9- "github.com/stretchr/testify/assert"
10- "github.com/stretchr/testify/require"
11-
12- "github.com/bnema/gordon/internal/domain"
13-)
14-
15-func TestFileStore_PathTraversal(t *testing.T) {
16- tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*")
17- require.NoError(t, err)
18- defer os.RemoveAll(tmpDir)
19-
20- store, err := NewFileStore(tmpDir, testLogger())
21- require.NoError(t, err)
22-
23- tests := []struct {
24- name string
25- domain string
26- wantErr bool
27- }{
28- // Valid domains
29- {"simple domain", "example.com", false},
30- {"subdomain", "sub.example.com", false},
31- {"domain with port", "example.com:8080", false},
32- {"domain with path", "example.com/path", false},
33- {"hyphenated domain", "my-app.example.com", false},
34- {"single char", "a", false},
35-
36- // Path traversal attempts - should fail
37- {"path traversal with dots", "../../../etc/passwd", true},
38- {"path traversal in middle", "example.com/../../../etc/passwd", true},
39- {"double dots only", "..", true},
40- {"path traversal at end", "example.com/..", true},
41-
42- // Invalid format - should fail
43- {"empty domain", "", true},
44- {"starts with dot", ".example.com", true},
45- {"ends with dot", "example.com.", true},
46- {"special chars semicolon", "example;rm -rf /", true},
47- {"null byte", "example\x00.com", true},
48- {"newline", "example\n.com", true},
49- }
50-
51- for _, tt := range tests {
52- t.Run(tt.name, func(t *testing.T) {
53- // Test ListKeys
54- _, err := store.ListKeys(tt.domain)
55- if tt.wantErr {
56- require.Error(t, err)
57- assert.True(t, errors.Is(err, domain.ErrPathTraversal), "expected ErrPathTraversal, got: %v", err)
58- } else {
59- // No error, or file not found (which is fine)
60- if err != nil {
61- assert.False(t, errors.Is(err, domain.ErrPathTraversal), "unexpected ErrPathTraversal")
62- }
63- }
64-
65- // Test Set
66- err = store.Set(tt.domain, map[string]string{"KEY": "value"})
67- if tt.wantErr {
68- require.Error(t, err)
69- assert.True(t, errors.Is(err, domain.ErrPathTraversal), "expected ErrPathTraversal, got: %v", err)
70- } else {
71- assert.NoError(t, err)
72- }
73-
74- // Test GetAll
75- _, err = store.GetAll(tt.domain)
76- if tt.wantErr {
77- require.Error(t, err)
78- assert.True(t, errors.Is(err, domain.ErrPathTraversal), "expected ErrPathTraversal, got: %v", err)
79- }
80-
81- // Test Delete
82- err = store.Delete(tt.domain, "KEY")
83- if tt.wantErr {
84- require.Error(t, err)
85- assert.True(t, errors.Is(err, domain.ErrPathTraversal), "expected ErrPathTraversal, got: %v", err)
86- }
87- })
88- }
89-}
90-
91-func TestFileStore_PathContainment(t *testing.T) {
92- tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*")
93- require.NoError(t, err)
94- defer os.RemoveAll(tmpDir)
95-
96- store, err := NewFileStore(tmpDir, testLogger())
97- require.NoError(t, err)
98-
99- // Valid domain - file should be created inside tmpDir
100- err = store.Set("example.com", map[string]string{"KEY": "value"})
101- require.NoError(t, err)
102-
103- // Verify file exists inside tmpDir
104- expectedFile := filepath.Join(tmpDir, "ZXhhbXBsZS5jb20.env")
105- _, err = os.Stat(expectedFile)
106- assert.NoError(t, err, "expected env file to exist at %s", expectedFile)
107-
108- // Verify no file was created outside tmpDir
109- entries, err := filepath.Glob(tmpDir + "/../*")
110- require.NoError(t, err)
111- for _, entry := range entries {
112- if filepath.Base(entry) != filepath.Base(tmpDir) {
113- // Check it's not our test file escaped
114- assert.NotContains(t, entry, ".env", "unexpected .env file outside tmpDir: %s", entry)
115- }
116- }
117-}
118-
119-func TestFileStore_DistinctDomainsUseDistinctFiles(t *testing.T) {
120- store, err := NewFileStore(t.TempDir(), testLogger())
121- require.NoError(t, err)
122-
123- dotPath, err := store.validateEnvFilePath("app.example.com")
124- require.NoError(t, err)
125-
126- portPath, err := store.validateEnvFilePath("app:example:com")
127- require.NoError(t, err)
128-
129- pathPath, err := store.validateEnvFilePath("app/example/com")
130- require.NoError(t, err)
131-
132- assert.NotEqual(t, dotPath, portPath)
133- assert.NotEqual(t, dotPath, pathPath)
134- assert.NotEqual(t, portPath, pathPath)
135-}
136-
137-func TestSanitizeDomainForContainer(t *testing.T) {
138- tests := []struct {
139- domain string
140- expected string
141- description string
142- }{
143- {
144- domain: "git.example.com",
145- expected: "git__example__com",
146- description: "Dots become double underscores",
147- },
148- {
149- domain: "git-example.com",
150- expected: "git-example__com",
151- description: "Hyphens preserved, dots become underscores",
152- },
153- {
154- domain: "app:8080.example.com",
155- expected: "app-_8080__example__com",
156- description: "Colons become hyphen-underscore",
157- },
158- {
159- domain: "git.example.com:3000",
160- expected: "git__example__com-_3000",
161- description: "Multiple separators handled distinctly",
162- },
163- {
164- domain: "simple.com",
165- expected: "simple__com",
166- description: "Simple domain",
167- },
168- }
169-
170- for _, tt := range tests {
171- t.Run(tt.domain, func(t *testing.T) {
172- result := domain.SanitizeDomainForContainer(tt.domain)
173- assert.Equal(t, tt.expected, result, "sanitization should match expected")
174- })
175- }
176-
177- // Verify no collisions between potentially conflicting domains
178- t.Run("NoCollisions", func(t *testing.T) {
179- domains := []string{
180- "git.example.com",
181- "git-example.com",
182- "app:8080.example.com",
183- "app-8080-example.com",
184- }
185-
186- results := make(map[string]string)
187- for _, d := range domains {
188- result := domain.SanitizeDomainForContainer(d)
189- if original, exists := results[result]; exists {
190- t.Errorf("COLLISION: %q and %q both sanitize to %q", original, d, result)
191- }
192- results[result] = d
193- }
194- })
195-}
196-
197-func TestFileStore_AttachmentOperations(t *testing.T) {
198- tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*")
199- require.NoError(t, err)
200- defer os.RemoveAll(tmpDir)
201-
202- store, err := NewFileStore(tmpDir, testLogger())
203- require.NoError(t, err)
204-
205- containerName := "gitea-postgres"
206-
207- t.Run("SetAttachment creates new file", func(t *testing.T) {
208- err := store.SetAttachment(containerName, map[string]string{
209- "POSTGRES_DB": "gitea",
210- "POSTGRES_PASSWORD": "secret123",
211- })
212- require.NoError(t, err)
213-
214- // Verify file exists
215- expectedFile := filepath.Join(tmpDir, "gordon-"+containerName+".env")
216- _, err = os.Stat(expectedFile)
217- assert.NoError(t, err, "expected attachment env file to exist at %s", expectedFile)
218- })
219-
220- t.Run("GetAllAttachment retrieves secrets", func(t *testing.T) {
221- secrets, err := store.GetAllAttachment(containerName)
222- require.NoError(t, err)
223- assert.Equal(t, "gitea", secrets["POSTGRES_DB"])
224- assert.Equal(t, "secret123", secrets["POSTGRES_PASSWORD"])
225- })
226-
227- t.Run("SetAttachment merges with existing", func(t *testing.T) {
228- err := store.SetAttachment(containerName, map[string]string{
229- "NEW_VAR": "new_value",
230- })
231- require.NoError(t, err)
232-
233- // Should have both old and new secrets
234- secrets, err := store.GetAllAttachment(containerName)
235- require.NoError(t, err)
236- assert.Equal(t, "gitea", secrets["POSTGRES_DB"])
237- assert.Equal(t, "secret123", secrets["POSTGRES_PASSWORD"])
238- assert.Equal(t, "new_value", secrets["NEW_VAR"])
239- })
240-
241- t.Run("GetAllAttachment returns empty map for non-existent container", func(t *testing.T) {
242- secrets, err := store.GetAllAttachment("non-existent")
243- require.NoError(t, err)
244- assert.Empty(t, secrets)
245- })
246-
247- t.Run("SetAttachment with hyphenated container name", func(t *testing.T) {
248- hyphenatedContainer := "gordon-git-example-com-gitea-postgres"
249- err := store.SetAttachment(hyphenatedContainer, map[string]string{
250- "KEY": "value",
251- })
252- require.NoError(t, err)
253-
254- secrets, err := store.GetAllAttachment(hyphenatedContainer)
255- require.NoError(t, err)
256- assert.Equal(t, "value", secrets["KEY"])
257- })
258-}
259-
260-func TestFileStore_AttachmentPathTraversal(t *testing.T) {
261- tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*")
262- require.NoError(t, err)
263- defer os.RemoveAll(tmpDir)
264-
265- store, err := NewFileStore(tmpDir, testLogger())
266- require.NoError(t, err)
267-
268- tests := []struct {
269- name string
270- container string
271- wantSetErr bool
272- wantGetErr bool
273- }{
274- // Valid container names
275- {"simple", "postgres", false, false},
276- {"with hyphen", "gitea-postgres", false, false},
277- {"with underscore", "gitea_postgres", false, false},
278- {"complex real container", "gordon-git-example-com-gitea-postgres", false, false},
279-
280- // Invalid container names - should fail
281- {"empty", "", true, true},
282- {"starts with number", "1container", true, true},
283- {"starts with hyphen", "-container", true, true},
284- {"starts with underscore", "_container", true, true},
285- {"path traversal", "../etc", true, true},
286- {"contains slash", "container/name", true, true},
287- {"contains backslash", "container\\name", true, true},
288- }
289-
290- for _, tt := range tests {
291- t.Run(tt.name, func(t *testing.T) {
292- // Test SetAttachment
293- err := store.SetAttachment(tt.container, map[string]string{"KEY": "value"})
294- if tt.wantSetErr {
295- require.Error(t, err)
296- } else {
297- assert.NoError(t, err)
298- }
299-
300- // Test GetAllAttachment
301- _, err = store.GetAllAttachment(tt.container)
302- if tt.wantGetErr {
303- require.Error(t, err)
304- } else {
305- if !tt.wantSetErr {
306- // Only check no error if we successfully set it
307- assert.NoError(t, err)
308- }
309- }
310- })
311- }
312-}
313-
314-func TestFileStore_ValidDomainOperations(t *testing.T) {
315- tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*")
316- require.NoError(t, err)
317- defer os.RemoveAll(tmpDir)
318-
319- store, err := NewFileStore(tmpDir, testLogger())
320- require.NoError(t, err)
321-
322- domain := "test.example.com"
323-
324- // Set secrets
325- err = store.Set(domain, map[string]string{
326- "DB_HOST": "localhost",
327- "DB_PASSWORD": "secret123",
328- })
329- require.NoError(t, err)
330-
331- // Get all secrets
332- secrets, err := store.GetAll(domain)
333- require.NoError(t, err)
334- assert.Equal(t, "localhost", secrets["DB_HOST"])
335- assert.Equal(t, "secret123", secrets["DB_PASSWORD"])
336-
337- // List keys
338- keys, err := store.ListKeys(domain)
339- require.NoError(t, err)
340- assert.Len(t, keys, 2)
341- assert.Contains(t, keys, "DB_HOST")
342- assert.Contains(t, keys, "DB_PASSWORD")
343-
344- // Delete a key
345- err = store.Delete(domain, "DB_PASSWORD")
346- require.NoError(t, err)
347-
348- // Verify deletion
349- secrets, err = store.GetAll(domain)
350- require.NoError(t, err)
351- assert.Len(t, secrets, 1)
352- assert.Equal(t, "localhost", secrets["DB_HOST"])
353- _, exists := secrets["DB_PASSWORD"]
354- assert.False(t, exists)
355-}
356-
357-func TestFileStore_DeleteAttachment(t *testing.T) {
358- tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*")
359- require.NoError(t, err)
360- defer os.RemoveAll(tmpDir)
361-
362- store, err := NewFileStore(tmpDir, testLogger())
363- require.NoError(t, err)
364-
365- containerName := "gitea-postgres"
366-
367- // Seed with two secrets
368- err = store.SetAttachment(containerName, map[string]string{
369- "POSTGRES_USER": "gitea",
370- "POSTGRES_PASSWORD": "secret123",
371- })
372- require.NoError(t, err)
373-
374- // Delete one key
375- err = store.DeleteAttachment(containerName, "POSTGRES_PASSWORD")
376- require.NoError(t, err)
377-
378- // Verify remaining
379- secrets, err := store.GetAllAttachment(containerName)
380- require.NoError(t, err)
381- assert.Len(t, secrets, 1)
382- assert.Equal(t, "gitea", secrets["POSTGRES_USER"])
383- _, exists := secrets["POSTGRES_PASSWORD"]
384- assert.False(t, exists)
385-
386- // Delete nonexistent key - should be no-op
387- err = store.DeleteAttachment(containerName, "NONEXISTENT_KEY")
388- assert.NoError(t, err)
389-
390- // Verify secrets unchanged after no-op delete
391- secrets, err = store.GetAllAttachment(containerName)
392- require.NoError(t, err)
393- assert.Len(t, secrets, 1)
394- assert.Equal(t, "gitea", secrets["POSTGRES_USER"])
395-}
396-
397-func TestGetEnvFilePath(t *testing.T) {
398- tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*")
399- require.NoError(t, err)
400- defer os.RemoveAll(tmpDir)
401-
402- store, err := NewFileStore(tmpDir, testLogger())
403- require.NoError(t, err)
404-
405- tests := []struct {
406- domain string
407- wantEmpty bool
408- wantFilename string
409- }{
410- {"example.com", false, "ZXhhbXBsZS5jb20.env"},
411- {"sub.example.com", false, "c3ViLmV4YW1wbGUuY29t.env"},
412- {"example.com:8080", false, "ZXhhbXBsZS5jb206ODA4MA.env"},
413- {"example.com/path", false, "ZXhhbXBsZS5jb20vcGF0aA.env"},
414- {"../evil", true, ""},
415- {"", true, ""},
416- }
417-
418- for _, tt := range tests {
419- t.Run(tt.domain, func(t *testing.T) {
420- path, err := store.getEnvFilePath(tt.domain)
421-
422- if tt.wantEmpty {
423- assert.Error(t, err)
424- assert.Empty(t, path)
425- } else {
426- require.NoError(t, err)
427- assert.NotEmpty(t, path)
428- assert.Equal(t, filepath.Join(tmpDir, tt.wantFilename), path)
429- }
430- })
431- }
432-}
D internal/adapters/out/envloader/file.go +0 -253
@@ -1,253 +0,0 @@
1-// Package envloader implements the environment variable loader adapter.
2-package envloader
3-
4-import (
5- "bufio"
6- "context"
7- "fmt"
8- "os"
9- "path/filepath"
10- "strings"
11-
12- "github.com/bnema/zerowrap"
13-
14- "github.com/bnema/gordon/internal/boundaries/out"
15- "github.com/bnema/gordon/internal/domain"
16-)
17-
18-// FileLoader implements the EnvLoader interface using filesystem-based env files.
19-type FileLoader struct {
20- envDir string
21- secretProviders map[string]out.SecretProvider
22- log zerowrap.Logger
23-}
24-
25-// NewFileLoader creates a new file-based environment loader.
26-func NewFileLoader(envDir string, log zerowrap.Logger) (*FileLoader, error) {
27- // Ensure env directory exists
28- if err := os.MkdirAll(envDir, 0700); err != nil {
29- return nil, fmt.Errorf("failed to create env directory %s: %w", envDir, err)
30- }
31-
32- log.Debug().
33- Str(zerowrap.FieldLayer, "adapter").
34- Str(zerowrap.FieldAdapter, "envloader").
35- Str("env_dir", envDir).
36- Msg("env loader initialized")
37-
38- return &FileLoader{
39- envDir: envDir,
40- secretProviders: make(map[string]out.SecretProvider),
41- log: log,
42- }, nil
43-}
44-
45-// RegisterSecretProvider registers a secret provider for resolving secrets.
46-func (l *FileLoader) RegisterSecretProvider(provider out.SecretProvider) {
47- l.secretProviders[provider.Name()] = provider
48- l.log.Debug().
49- Str(zerowrap.FieldLayer, "adapter").
50- Str(zerowrap.FieldAdapter, "envloader").
51- Str("provider", provider.Name()).
52- Msg("secret provider registered")
53-}
54-
55-// LoadEnv loads environment variables for a given domain.
56-func (l *FileLoader) LoadEnv(ctx context.Context, domain string) ([]string, error) {
57- ctx = zerowrap.CtxWithFields(ctx, map[string]any{
58- zerowrap.FieldLayer: "adapter",
59- zerowrap.FieldAdapter: "envloader",
60- zerowrap.FieldAction: "LoadEnv",
61- "domain": domain,
62- })
63- log := zerowrap.FromCtx(ctx)
64-
65- envVars := []string{}
66- envFile, err := l.getEnvFilePath(domain)
67- if err != nil {
68- return nil, log.WrapErr(err, "invalid env file domain")
69- }
70-
71- // Check if env file exists
72- if _, err := os.Stat(envFile); os.IsNotExist(err) {
73- log.Debug().Str("env_file", envFile).Msg("no env file found for route")
74- return envVars, nil
75- }
76-
77- log.Debug().Str("env_file", envFile).Msg("loading env file for route")
78-
79- file, err := os.Open(envFile)
80- if err != nil {
81- return nil, log.WrapErr(err, "failed to open env file")
82- }
83- defer file.Close()
84-
85- scanner := bufio.NewScanner(file)
86- lineNum := 0
87-
88- for scanner.Scan() {
89- lineNum++
90- line := strings.TrimSpace(scanner.Text())
91-
92- // Skip empty lines and comments
93- if line == "" || strings.HasPrefix(line, "#") {
94- continue
95- }
96-
97- // Parse KEY=VALUE format
98- parts := strings.SplitN(line, "=", 2)
99- if len(parts) != 2 {
100- log.Warn().
101- Int("line", lineNum).
102- Msg("invalid env file line format, skipping")
103- continue
104- }
105-
106- key := strings.TrimSpace(parts[0])
107- value := strings.TrimSpace(parts[1])
108-
109- // Remove quotes if present
110- if len(value) >= 2 {
111- if (strings.HasPrefix(value, "\"") && strings.HasSuffix(value, "\"")) ||
112- (strings.HasPrefix(value, "'") && strings.HasSuffix(value, "'")) {
113- value = value[1 : len(value)-1]
114- }
115- }
116-
117- // Resolve secrets if value contains secret syntax
118- resolvedValue, err := l.resolveSecrets(ctx, value)
119- if err != nil {
120- return nil, log.WrapErrWithFields(
121- fmt.Errorf("key %s on line %d: %w", key, lineNum, err),
122- "failed to resolve secret",
123- map[string]any{
124- "key": key,
125- "line": lineNum,
126- },
127- )
128- }
129-
130- envVars = append(envVars, fmt.Sprintf("%s=%s", key, resolvedValue))
131- }
132-
133- if err := scanner.Err(); err != nil {
134- return nil, log.WrapErr(err, "error reading env file")
135- }
136-
137- log.Info().Int(zerowrap.FieldCount, len(envVars)).Msg("loaded environment variables for route")
138-
139- return envVars, nil
140-}
141-
142-// CreateEnvFile creates an empty environment file for a new domain.
143-func (l *FileLoader) CreateEnvFile(ctx context.Context, domain string) error {
144- ctx = zerowrap.CtxWithFields(ctx, map[string]any{
145- zerowrap.FieldLayer: "adapter",
146- zerowrap.FieldAdapter: "envloader",
147- zerowrap.FieldAction: "CreateEnvFile",
148- "domain": domain,
149- })
150- log := zerowrap.FromCtx(ctx)
151-
152- envFile, err := l.getEnvFilePath(domain)
153- if err != nil {
154- return log.WrapErr(err, "invalid env file domain")
155- }
156-
157- // Check if file already exists
158- if _, err := os.Stat(envFile); err == nil {
159- log.Debug().Str("env_file", envFile).Msg("env file already exists, skipping creation")
160- return nil
161- } else if !os.IsNotExist(err) {
162- return log.WrapErr(err, "error checking env file")
163- }
164-
165- // Create empty env file with helpful comments
166- content := fmt.Sprintf(`# Environment variables for route: %s
167-# Add KEY=VALUE pairs, one per line
168-# Secrets can be referenced with ${provider:path} syntax
169-
170-`, domain)
171-
172- if err := os.WriteFile(envFile, []byte(content), 0600); err != nil {
173- return log.WrapErr(err, "failed to create env file")
174- }
175-
176- log.Info().Str("env_file", envFile).Msg("created empty env file for route")
177-
178- return nil
179-}
180-
181-// EnvFileExists checks if an environment file exists for a domain.
182-func (l *FileLoader) EnvFileExists(domain string) (bool, error) {
183- envFile, err := l.getEnvFilePath(domain)
184- if err != nil {
185- return false, err
186- }
187-
188- _, err = os.Stat(envFile)
189- if os.IsNotExist(err) {
190- return false, nil
191- }
192- if err != nil {
193- return false, err
194- }
195-
196- return true, nil
197-}
198-
199-// resolveSecrets resolves any secret references in the value.
200-// Secret syntax: ${provider:path}
201-func (l *FileLoader) resolveSecrets(ctx context.Context, value string) (string, error) {
202- // Check if value contains secret syntax: ${provider:path}
203- if !strings.Contains(value, "${") {
204- return value, nil
205- }
206-
207- result := value
208- for {
209- start := strings.Index(result, "${")
210- if start == -1 {
211- break
212- }
213-
214- end := strings.Index(result[start:], "}")
215- if end == -1 {
216- return "", fmt.Errorf("unclosed secret syntax")
217- }
218- end += start
219-
220- secretRef := result[start+2 : end]
221- parts := strings.SplitN(secretRef, ":", 2)
222- if len(parts) != 2 {
223- return "", fmt.Errorf("invalid secret syntax: expected 'provider:path'")
224- }
225-
226- providerName := parts[0]
227- secretPath := parts[1]
228-
229- provider, exists := l.secretProviders[providerName]
230- if !exists {
231- return "", fmt.Errorf("unknown secret provider: %s", providerName)
232- }
233-
234- secretValue, err := provider.GetSecret(ctx, secretPath)
235- if err != nil {
236- return "", fmt.Errorf("failed to get secret from provider %s", providerName)
237- }
238-
239- // Replace the secret reference with the actual value
240- result = result[:start] + secretValue + result[end+1:]
241- }
242-
243- return result, nil
244-}
245-
246-func (l *FileLoader) getEnvFilePath(domainName string) (string, error) {
247- storageKey, err := domain.NewEnvStorageKey(domainName)
248- if err != nil {
249- return "", err
250- }
251-
252- return filepath.Join(l.envDir, storageKey.FileName()), nil
253-}
D internal/adapters/out/envloader/file_test.go +0 -368
@@ -1,368 +0,0 @@
1-package envloader
2-
3-import (
4- "bytes"
5- "context"
6- "errors"
7- "os"
8- "path/filepath"
9- "testing"
10-
11- "github.com/bnema/zerowrap"
12- "github.com/stretchr/testify/assert"
13- "github.com/stretchr/testify/require"
14-
15- "github.com/bnema/gordon/internal/boundaries/out/mocks"
16- "github.com/bnema/gordon/internal/domain"
17-)
18-
19-func TestFileLoader_ResolveSecrets(t *testing.T) {
20- // Create a temp directory for tests
21- tmpDir := t.TempDir()
22- log := zerowrap.New(zerowrap.Config{Level: "fatal"})
23-
24- loader, err := NewFileLoader(tmpDir, log)
25- require.NoError(t, err)
26-
27- ctx := context.Background()
28-
29- t.Run("no secret syntax returns value unchanged", func(t *testing.T) {
30- result, err := loader.resolveSecrets(ctx, "plain-value")
31- assert.NoError(t, err)
32- assert.Equal(t, "plain-value", result)
33- })
34-
35- t.Run("unclosed secret syntax returns error", func(t *testing.T) {
36- _, err := loader.resolveSecrets(ctx, "${pass:secret")
37- assert.Error(t, err)
38- assert.Contains(t, err.Error(), "unclosed secret syntax")
39- })
40-
41- t.Run("invalid secret syntax without colon returns error", func(t *testing.T) {
42- _, err := loader.resolveSecrets(ctx, "${invalid}")
43- assert.Error(t, err)
44- assert.Contains(t, err.Error(), "invalid secret syntax")
45- })
46-
47- t.Run("unknown provider returns error", func(t *testing.T) {
48- _, err := loader.resolveSecrets(ctx, "${unknown:path}")
49- assert.Error(t, err)
50- assert.Contains(t, err.Error(), "unknown secret provider")
51- })
52-
53- t.Run("pass provider resolves secret", func(t *testing.T) {
54- mockPass := mocks.NewMockSecretProvider(t)
55- mockPass.EXPECT().Name().Return("pass")
56- mockPass.EXPECT().GetSecret(ctx, "company/api-key").Return("secret-value-123", nil)
57-
58- loader.RegisterSecretProvider(mockPass)
59-
60- result, err := loader.resolveSecrets(ctx, "${pass:company/api-key}")
61- assert.NoError(t, err)
62- assert.Equal(t, "secret-value-123", result)
63- })
64-
65- t.Run("sops provider resolves secret", func(t *testing.T) {
66- mockSops := mocks.NewMockSecretProvider(t)
67- mockSops.EXPECT().Name().Return("sops")
68- mockSops.EXPECT().GetSecret(ctx, "secrets.yaml:app.password").Return("sops-secret", nil)
69-
70- loader.RegisterSecretProvider(mockSops)
71-
72- result, err := loader.resolveSecrets(ctx, "${sops:secrets.yaml:app.password}")
73- assert.NoError(t, err)
74- assert.Equal(t, "sops-secret", result)
75- })
76-
77- t.Run("multiple secrets in one value", func(t *testing.T) {
78- mockPass := mocks.NewMockSecretProvider(t)
79- mockPass.EXPECT().Name().Return("pass")
80- mockPass.EXPECT().GetSecret(ctx, "db/user").Return("admin", nil)
81- mockPass.EXPECT().GetSecret(ctx, "db/pass").Return("secret123", nil)
82-
83- loader.RegisterSecretProvider(mockPass)
84-
85- result, err := loader.resolveSecrets(ctx, "postgresql://${pass:db/user}:${pass:db/pass}@localhost:5432/db")
86- assert.NoError(t, err)
87- assert.Equal(t, "postgresql://admin:secret123@localhost:5432/db", result)
88- })
89-
90- t.Run("provider error is propagated", func(t *testing.T) {
91- mockPass := mocks.NewMockSecretProvider(t)
92- mockPass.EXPECT().Name().Return("pass")
93- mockPass.EXPECT().GetSecret(ctx, "missing/secret").Return("", assert.AnError)
94-
95- loader.RegisterSecretProvider(mockPass)
96-
97- _, err := loader.resolveSecrets(ctx, "${pass:missing/secret}")
98- assert.Error(t, err)
99- assert.Contains(t, err.Error(), "failed to get secret from provider")
100- })
101-}
102-
103-func TestFileLoader_LoadEnv(t *testing.T) {
104- log := zerowrap.New(zerowrap.Config{Level: "fatal"})
105- ctx := context.Background()
106-
107- t.Run("missing env file returns empty slice", func(t *testing.T) {
108- tmpDir := t.TempDir()
109- loader, err := NewFileLoader(tmpDir, log)
110- require.NoError(t, err)
111-
112- envVars, err := loader.LoadEnv(ctx, "nonexistent.example.com")
113- assert.NoError(t, err)
114- assert.Empty(t, envVars)
115- })
116-
117- t.Run("loads simple env file", func(t *testing.T) {
118- tmpDir := t.TempDir()
119- loader, err := NewFileLoader(tmpDir, log)
120- require.NoError(t, err)
121-
122- // Create env file
123- envContent := `# Comment line
124-NODE_ENV=production
125-API_URL=https://api.example.com
126-`
127- storageKey, err := domain.NewEnvStorageKey("app.example.com")
128- require.NoError(t, err)
129- envFile := filepath.Join(tmpDir, storageKey.FileName())
130- require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600))
131-
132- envVars, err := loader.LoadEnv(ctx, "app.example.com")
133- assert.NoError(t, err)
134- assert.Len(t, envVars, 2)
135- assert.Contains(t, envVars, "NODE_ENV=production")
136- assert.Contains(t, envVars, "API_URL=https://api.example.com")
137- })
138-
139- t.Run("loads env file with secrets", func(t *testing.T) {
140- tmpDir := t.TempDir()
141- loader, err := NewFileLoader(tmpDir, log)
142- require.NoError(t, err)
143-
144- // Register mock provider
145- mockPass := mocks.NewMockSecretProvider(t)
146- mockPass.EXPECT().Name().Return("pass")
147- mockPass.EXPECT().GetSecret(ctx, "app/api-key").Return("super-secret-key", nil)
148- loader.RegisterSecretProvider(mockPass)
149-
150- // Create env file with secret reference
151- envContent := `NODE_ENV=production
152-API_KEY=${pass:app/api-key}
153-`
154- storageKey, err := domain.NewEnvStorageKey("app.example.com")
155- require.NoError(t, err)
156- envFile := filepath.Join(tmpDir, storageKey.FileName())
157- require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600))
158-
159- envVars, err := loader.LoadEnv(ctx, "app.example.com")
160- assert.NoError(t, err)
161- assert.Len(t, envVars, 2)
162- assert.Contains(t, envVars, "NODE_ENV=production")
163- assert.Contains(t, envVars, "API_KEY=super-secret-key")
164- })
165-
166- t.Run("handles quoted values", func(t *testing.T) {
167- tmpDir := t.TempDir()
168- loader, err := NewFileLoader(tmpDir, log)
169- require.NoError(t, err)
170-
171- envContent := `DOUBLE_QUOTED="hello world"
172-SINGLE_QUOTED='hello world'
173-`
174- storageKey, err := domain.NewEnvStorageKey("app.example.com")
175- require.NoError(t, err)
176- envFile := filepath.Join(tmpDir, storageKey.FileName())
177- require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600))
178-
179- envVars, err := loader.LoadEnv(ctx, "app.example.com")
180- assert.NoError(t, err)
181- assert.Len(t, envVars, 2)
182- assert.Contains(t, envVars, "DOUBLE_QUOTED=hello world")
183- assert.Contains(t, envVars, "SINGLE_QUOTED=hello world")
184- })
185-
186- t.Run("skips invalid lines", func(t *testing.T) {
187- tmpDir := t.TempDir()
188- loader, err := NewFileLoader(tmpDir, log)
189- require.NoError(t, err)
190-
191- envContent := `VALID=value
192-invalid line without equals
193-ALSO_VALID=another
194-`
195- storageKey, err := domain.NewEnvStorageKey("app.example.com")
196- require.NoError(t, err)
197- envFile := filepath.Join(tmpDir, storageKey.FileName())
198- require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600))
199-
200- envVars, err := loader.LoadEnv(ctx, "app.example.com")
201- assert.NoError(t, err)
202- assert.Len(t, envVars, 2)
203- assert.Contains(t, envVars, "VALID=value")
204- assert.Contains(t, envVars, "ALSO_VALID=another")
205- })
206-}
207-
208-func TestFileLoader_LoadEnv_RedactsSecretBearingDiagnostics(t *testing.T) {
209- ctx := context.Background()
210-
211- t.Run("invalid line warning omits raw line content", func(t *testing.T) {
212- var logs bytes.Buffer
213- log := zerowrap.New(zerowrap.Config{Level: "warn", Format: "json", Output: &logs})
214- loader, err := NewFileLoader(t.TempDir(), log)
215- require.NoError(t, err)
216- ctx := zerowrap.WithCtx(ctx, log)
217-
218- envFile, err := loader.getEnvFilePath("app.example.com")
219- require.NoError(t, err)
220- require.NoError(t, os.WriteFile(envFile, []byte("valid line leaks super-secret-token\nVALID=value\n"), 0600))
221-
222- envVars, err := loader.LoadEnv(ctx, "app.example.com")
223- require.NoError(t, err)
224- assert.Equal(t, []string{"VALID=value"}, envVars)
225- assert.NotContains(t, logs.String(), "super-secret-token")
226- assert.NotContains(t, logs.String(), "valid line leaks")
227- assert.Contains(t, logs.String(), "line")
228- })
229-
230- t.Run("secret resolution error omits unclosed raw value", func(t *testing.T) {
231- log := zerowrap.New(zerowrap.Config{Level: "fatal"})
232- loader, err := NewFileLoader(t.TempDir(), log)
233- require.NoError(t, err)
234-
235- envFile, err := loader.getEnvFilePath("app.example.com")
236- require.NoError(t, err)
237- require.NoError(t, os.WriteFile(envFile, []byte("DATABASE_URL=postgres://user:${pass:db-password-super-secret\n"), 0600))
238-
239- _, err = loader.LoadEnv(ctx, "app.example.com")
240- require.Error(t, err)
241- assert.Contains(t, err.Error(), "failed to resolve secret")
242- assert.Contains(t, err.Error(), "DATABASE_URL")
243- assert.NotContains(t, err.Error(), "postgres://user")
244- assert.NotContains(t, err.Error(), "db-password-super-secret")
245- })
246-
247- t.Run("secret resolution error omits malformed closed secret ref", func(t *testing.T) {
248- log := zerowrap.New(zerowrap.Config{Level: "fatal"})
249- loader, err := NewFileLoader(t.TempDir(), log)
250- require.NoError(t, err)
251-
252- envFile, err := loader.getEnvFilePath("app.example.com")
253- require.NoError(t, err)
254- require.NoError(t, os.WriteFile(envFile, []byte("TOKEN=${db-password-super-secret}\n"), 0600))
255-
256- _, err = loader.LoadEnv(ctx, "app.example.com")
257- require.Error(t, err)
258- assert.Contains(t, err.Error(), "failed to resolve secret")
259- assert.Contains(t, err.Error(), "TOKEN")
260- assert.NotContains(t, err.Error(), "db-password-super-secret")
261- })
262-
263- t.Run("secret resolution error omits provider error details", func(t *testing.T) {
264- log := zerowrap.New(zerowrap.Config{Level: "fatal"})
265- loader, err := NewFileLoader(t.TempDir(), log)
266- require.NoError(t, err)
267-
268- mockPass := mocks.NewMockSecretProvider(t)
269- mockPass.EXPECT().Name().Return("pass")
270- mockPass.EXPECT().GetSecret(ctx, "db-password-super-secret").Return("", errors.New("backend leaked db-password-super-secret"))
271- loader.RegisterSecretProvider(mockPass)
272-
273- envFile, err := loader.getEnvFilePath("app.example.com")
274- require.NoError(t, err)
275- require.NoError(t, os.WriteFile(envFile, []byte("TOKEN=${pass:db-password-super-secret}\n"), 0600))
276-
277- _, err = loader.LoadEnv(ctx, "app.example.com")
278- require.Error(t, err)
279- assert.Contains(t, err.Error(), "failed to resolve secret")
280- assert.Contains(t, err.Error(), "TOKEN")
281- assert.Contains(t, err.Error(), "provider pass")
282- assert.NotContains(t, err.Error(), "db-password-super-secret")
283- })
284-}
285-
286-func TestFileLoader_GetEnvFilePath(t *testing.T) {
287- tmpDir := t.TempDir()
288- log := zerowrap.New(zerowrap.Config{Level: "fatal"})
289-
290- loader, err := NewFileLoader(tmpDir, log)
291- require.NoError(t, err)
292-
293- tests := []struct {
294- domain string
295- expected string
296- }{
297- {"app.example.com", "YXBwLmV4YW1wbGUuY29t.env"},
298- {"api.sub.example.com", "YXBpLnN1Yi5leGFtcGxlLmNvbQ.env"},
299- {"localhost:8080", "bG9jYWxob3N0OjgwODA.env"},
300- }
301-
302- for _, tt := range tests {
303- t.Run(tt.domain, func(t *testing.T) {
304- result, err := loader.getEnvFilePath(tt.domain)
305- require.NoError(t, err)
306- assert.Equal(t, filepath.Join(tmpDir, tt.expected), result)
307- })
308- }
309-}
310-
311-func TestFileLoader_GetEnvFilePath_DistinguishesSeparators(t *testing.T) {
312- tmpDir := t.TempDir()
313- log := zerowrap.New(zerowrap.Config{Level: "fatal"})
314-
315- loader, err := NewFileLoader(tmpDir, log)
316- require.NoError(t, err)
317-
318- dotPath, err := loader.getEnvFilePath("app.example.com")
319- require.NoError(t, err)
320- portPath, err := loader.getEnvFilePath("app:example:com")
321- require.NoError(t, err)
322- slashPath, err := loader.getEnvFilePath("app/example/com")
323- require.NoError(t, err)
324-
325- assert.NotEqual(t, dotPath, portPath)
326- assert.NotEqual(t, dotPath, slashPath)
327- assert.NotEqual(t, portPath, slashPath)
328-}
329-
330-func TestFileLoader_GetEnvFilePath_RejectsInvalidDomain(t *testing.T) {
331- tmpDir := t.TempDir()
332- log := zerowrap.New(zerowrap.Config{Level: "fatal"})
333-
334- loader, err := NewFileLoader(tmpDir, log)
335- require.NoError(t, err)
336-
337- _, err = loader.getEnvFilePath("../etc/passwd")
338- require.ErrorIs(t, err, domain.ErrPathTraversal)
339-}
340-
341-func TestFileLoader_EnvFileExists(t *testing.T) {
342- log := zerowrap.New(zerowrap.Config{Level: "fatal"})
343-
344- t.Run("returns false nil when file is missing", func(t *testing.T) {
345- tmpDir := t.TempDir()
346- loader, err := NewFileLoader(tmpDir, log)
347- require.NoError(t, err)
348-
349- exists, err := loader.EnvFileExists("app.example.com")
350- require.NoError(t, err)
351- assert.False(t, exists)
352- })
353-
354- t.Run("returns true nil when file exists", func(t *testing.T) {
355- tmpDir := t.TempDir()
356- loader, err := NewFileLoader(tmpDir, log)
357- require.NoError(t, err)
358-
359- storageKey, err := domain.NewEnvStorageKey("app.example.com")
360- require.NoError(t, err)
361- envFile := filepath.Join(tmpDir, storageKey.FileName())
362- require.NoError(t, os.WriteFile(envFile, []byte("KEY=value\n"), 0600))
363-
364- exists, err := loader.EnvFileExists("app.example.com")
365- require.NoError(t, err)
366- assert.True(t, exists)
367- })
368-}
D internal/adapters/out/envloader/pass_loader.go +0 -117
@@ -1,117 +0,0 @@
1-// Package envloader implements the environment variable loader adapter.
2-package envloader
3-
4-import (
5- "context"
6- "fmt"
7- "sort"
8- "strings"
9-
10- "github.com/bnema/zerowrap"
11-
12- "github.com/bnema/gordon/internal/adapters/out/domainsecrets"
13-)
14-
15-// PassLoader implements the EnvLoader interface using pass-backed secrets.
16-type PassLoader struct {
17- store *domainsecrets.PassStore
18- log zerowrap.Logger
19-}
20-
21-// NewPassLoader creates a new pass-based environment loader.
22-func NewPassLoader(store *domainsecrets.PassStore, log zerowrap.Logger) (*PassLoader, error) {
23- if store == nil {
24- return nil, fmt.Errorf("pass store is required")
25- }
26-
27- log.Debug().
28- Str(zerowrap.FieldLayer, "adapter").
29- Str(zerowrap.FieldAdapter, "envloader").
30- Str("provider", "pass").
31- Msg("env loader initialized")
32-
33- return &PassLoader{
34- store: store,
35- log: log,
36- }, nil
37-}
38-
39-// LoadEnv loads environment variables for a given domain.
40-func (l *PassLoader) LoadEnv(ctx context.Context, domain string) ([]string, error) {
41- ctx = zerowrap.CtxWithFields(ctx, map[string]any{
42- zerowrap.FieldLayer: "adapter",
43- zerowrap.FieldAdapter: "envloader",
44- zerowrap.FieldAction: "LoadEnv",
45- "domain": domain,
46- })
47- log := zerowrap.FromCtx(ctx)
48-
49- var secretsMap map[string]string
50- var err error
51-
52- if isAttachmentContainer(domain) {
53- secretsMap, err = l.store.GetAllAttachment(domain)
54- if err != nil {
55- return nil, log.WrapErr(err, "failed to load attachment env from pass")
56- }
57- } else {
58- secretsMap, err = l.store.GetAll(domain)
59- if err != nil {
60- return nil, log.WrapErr(err, "failed to load env from pass")
61- }
62- }
63-
64- keys := make([]string, 0, len(secretsMap))
65- for key := range secretsMap {
66- keys = append(keys, key)
67- }
68- sort.Strings(keys)
69-
70- envVars := make([]string, 0, len(keys))
71- for _, key := range keys {
72- envVars = append(envVars, fmt.Sprintf("%s=%s", key, secretsMap[key]))
73- }
74-
75- log.Info().Int(zerowrap.FieldCount, len(envVars)).Msg("loaded environment variables for route")
76-
77- return envVars, nil
78-}
79-
80-// isAttachmentContainer determines whether a given name refers to an attachment container
81-// or a domain. It uses a simple heuristic: attachment container names always start with
82-// the "gordon-" prefix (e.g., "gordon-git-example-com-gitea-postgres").
83-//
84-// LIMITATION: This heuristic assumes domains never start with "gordon-". A domain like
85-// "gordon-app.example.com" would be incorrectly classified as an attachment container.
86-// This is an acceptable trade-off for simplicity, but if such domains become common,
87-// this should be replaced with a more robust detection mechanism (e.g., checking both
88-// GetAll() and GetAllAttachment() and seeing which returns results).
89-func isAttachmentContainer(name string) bool {
90- // Attachment container names start with "gordon-" and never contain dots.
91- // Domain names (including preview domains like "gordon--test.bnema.dev")
92- // always contain dots, so checking for dots distinguishes them.
93- return strings.HasPrefix(name, "gordon-") && !strings.Contains(name, ".")
94-}
95-
96-// CreateEnvFile is a no-op for pass-backed loader.
97-func (l *PassLoader) CreateEnvFile(ctx context.Context, domain string) error {
98- ctx = zerowrap.CtxWithFields(ctx, map[string]any{
99- zerowrap.FieldLayer: "adapter",
100- zerowrap.FieldAdapter: "envloader",
101- zerowrap.FieldAction: "CreateEnvFile",
102- "domain": domain,
103- })
104- log := zerowrap.FromCtx(ctx)
105- log.Debug().Msg("pass loader does not create env files")
106- return nil
107-}
108-
109-// EnvFileExists checks if a manifest exists for a domain in pass.
110-func (l *PassLoader) EnvFileExists(domain string) (bool, error) {
111- exists, err := l.store.ManifestExists(domain)
112- if err != nil {
113- l.log.Warn().Err(err).Str("domain", domain).Msg("failed to check pass manifest")
114- return false, err
115- }
116- return exists, nil
117-}
D internal/adapters/out/envloader/pass_loader_test.go +0 -108
@@ -1,108 +0,0 @@
1-package envloader
2-
3-import (
4- "context"
5- "fmt"
6- "os/exec"
7- "testing"
8- "time"
9-
10- "github.com/stretchr/testify/assert"
11- "github.com/stretchr/testify/require"
12-
13- "github.com/bnema/gordon/internal/adapters/out/domainsecrets"
14- "github.com/bnema/zerowrap"
15-)
16-
17-func passCmd(args ...string) error {
18- ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
19- defer cancel()
20- return exec.CommandContext(ctx, "pass", args...).Run()
21-}
22-
23-func requirePass(t *testing.T) {
24- if err := passCmd("version"); err != nil {
25- t.Skip("pass not available")
26- }
27- if err := passCmd("ls"); err != nil {
28- t.Skip("pass store not initialized")
29- }
30-}
31-
32-func cleanupPassEnv(t *testing.T, path string) {
33- _ = passCmd("rm", "-rf", path)
34-}
35-
36-func TestPassLoader_LoadEnv_Domain(t *testing.T) {
37- requirePass(t)
38-
39- log := zerowrap.New(zerowrap.Config{Level: "fatal"})
40- store, err := domainsecrets.NewPassStore(log)
41- require.NoError(t, err)
42-
43- loader, err := NewPassLoader(store, log)
44- require.NoError(t, err)
45-
46- domainName := fmt.Sprintf("test.%d.example.com", time.Now().UnixNano())
47- defer cleanupPassEnv(t, fmt.Sprintf("%s/%s", domainsecrets.PassDomainSecretsPath, domainName))
48-
49- secretsMap := map[string]string{
50- "API_KEY": "test123",
51- "DB_HOST": "localhost",
52- }
53- err = store.Set(domainName, secretsMap)
54- require.NoError(t, err)
55-
56- ctx := context.Background()
57- envVars, err := loader.LoadEnv(ctx, domainName)
58- require.NoError(t, err)
59- assert.Len(t, envVars, 2)
60- assert.Contains(t, envVars, "API_KEY=test123")
61- assert.Contains(t, envVars, "DB_HOST=localhost")
62-}
63-
64-func TestPassLoader_LoadEnv_Attachment(t *testing.T) {
65- requirePass(t)
66-
67- log := zerowrap.New(zerowrap.Config{Level: "fatal"})
68- store, err := domainsecrets.NewPassStore(log)
69- require.NoError(t, err)
70-
71- loader, err := NewPassLoader(store, log)
72- require.NoError(t, err)
73-
74- containerName := fmt.Sprintf("gordon-gitea-postgres-%d", time.Now().UnixNano())
75- defer cleanupPassEnv(t, fmt.Sprintf("%s/%s", domainsecrets.PassAttachmentPath, containerName))
76-
77- secretsMap := map[string]string{
78- "POSTGRES_USER": "gitea",
79- "POSTGRES_PASSWORD": "secret123",
80- }
81- err = store.SetAttachment(containerName, secretsMap)
82- require.NoError(t, err)
83-
84- ctx := context.Background()
85- envVars, err := loader.LoadEnv(ctx, containerName)
86- require.NoError(t, err)
87- assert.Len(t, envVars, 2)
88- assert.Contains(t, envVars, "POSTGRES_USER=gitea")
89- assert.Contains(t, envVars, "POSTGRES_PASSWORD=secret123")
90-}
91-
92-func TestPassLoader_LoadEnv_EmptyDomain(t *testing.T) {
93- requirePass(t)
94-
95- log := zerowrap.New(zerowrap.Config{Level: "fatal"})
96- store, err := domainsecrets.NewPassStore(log)
97- require.NoError(t, err)
98-
99- loader, err := NewPassLoader(store, log)
100- require.NoError(t, err)
101-
102- domainName := fmt.Sprintf("empty.%d.example.com", time.Now().UnixNano())
103-
104- ctx := context.Background()
105- envVars, err := loader.LoadEnv(ctx, domainName)
106- require.NoError(t, err)
107- assert.Len(t, envVars, 0)
108-}
M internal/app/kernel.go +0 -14
@@ -10,7 +10,6 @@ import (
10 10
11 "github.com/bnema/gordon/internal/boundaries/in"11 "github.com/bnema/gordon/internal/boundaries/in"
12 configusecase "github.com/bnema/gordon/internal/usecase/config"12 configusecase "github.com/bnema/gordon/internal/usecase/config"
13- secretsusecase "github.com/bnema/gordon/internal/usecase/secrets"
14 )13 )
15 14
16 // Kernel provides in-process service access for local CLI execution.15 // Kernel provides in-process service access for local CLI execution.
@@ -19,7 +18,6 @@ import (
19 type Kernel struct {18 type Kernel struct {
20 authEnabled bool19 authEnabled bool
21 configSvc in.ConfigService20 configSvc in.ConfigService
22- secretSvc in.SecretService
23 containerSvc in.ContainerService21 containerSvc in.ContainerService
24 backupSvc in.BackupService22 backupSvc in.BackupService
25 volumeBackupSvc in.VolumeBackupService23 volumeBackupSvc in.VolumeBackupService
@@ -87,7 +85,6 @@ func newKernel(configPath string, initLog kernelLoggerInit) (*Kernel, error) {
87 kernel := &Kernel{85 kernel := &Kernel{
88 authEnabled: cfg.Auth.Enabled,86 authEnabled: cfg.Auth.Enabled,
89 configSvc: svc.configSvc,87 configSvc: svc.configSvc,
90- secretSvc: svc.secretSvc,
91 containerSvc: svc.containerSvc,88 containerSvc: svc.containerSvc,
92 backupSvc: svc.backupSvc,89 backupSvc: svc.backupSvc,
93 volumeBackupSvc: svc.volumeBackupSvc,90 volumeBackupSvc: svc.volumeBackupSvc,
@@ -116,18 +113,9 @@ func newKernel(configPath string, initLog kernelLoggerInit) (*Kernel, error) {
116 return nil, fmt.Errorf("failed to load configuration: %w", err)113 return nil, fmt.Errorf("failed to load configuration: %w", err)
117 }114 }
118 115
119- _, _, _, domainSecretStore, err := createDomainSecretStore(cfg, log)
120- if err != nil {
121- cleanup()
122- return nil, fmt.Errorf("failed to create local secret store: %w", err)
123- }
124-
125- secretSvc := secretsusecase.NewService(domainSecretStore, log, nil)
126-
127 return &Kernel{116 return &Kernel{
128 authEnabled: cfg.Auth.Enabled,117 authEnabled: cfg.Auth.Enabled,
129 configSvc: configSvc,118 configSvc: configSvc,
130- secretSvc: secretSvc,
131 log: log,119 log: log,
132 cleanup: cleanup,120 cleanup: cleanup,
133 }, nil121 }, nil
@@ -162,8 +150,6 @@ func (k *Kernel) Close() error {
162 150
163 func (k *Kernel) Config() in.ConfigService { return k.configSvc }151 func (k *Kernel) Config() in.ConfigService { return k.configSvc }
164 152
165-func (k *Kernel) Secrets() in.SecretService { return k.secretSvc }
166-
167 func (k *Kernel) Container() in.ContainerService { return k.containerSvc }153 func (k *Kernel) Container() in.ContainerService { return k.containerSvc }
168 154
169 func (k *Kernel) Backup() in.BackupService { return k.backupSvc }155 func (k *Kernel) Backup() in.BackupService { return k.backupSvc }
M internal/app/kernel_test.go +0 -1
@@ -37,7 +37,6 @@ secrets_backend = "unsafe"
37 t.Cleanup(func() { require.NoError(t, kernel.Close()) })37 t.Cleanup(func() { require.NoError(t, kernel.Close()) })
38 38
39 require.NotNil(t, kernel.Config())39 require.NotNil(t, kernel.Config())
40- require.NotNil(t, kernel.Secrets())
41 }40 }
42 41
43 // TestKernelClose_QuiescenceTimeoutSkipsCleanup proves the fail-closed kernel42 // TestKernelClose_QuiescenceTimeoutSkipsCleanup proves the fail-closed kernel
D internal/app/migrate_env.go +0 -228
@@ -1,228 +0,0 @@
1-package app
2-
3-import (
4- "errors"
5- "fmt"
6- "os"
7- "path/filepath"
8- "strings"
9-
10- "github.com/bnema/zerowrap"
11-
12- "github.com/bnema/gordon/internal/adapters/out/domainsecrets"
13- "github.com/bnema/gordon/internal/domain"
14-)
15-
16-func migrateEnvFilesToPass(envDir string, passStore *domainsecrets.PassStore, log zerowrap.Logger) error {
17- entries, err := readEnvDir(envDir)
18- if err != nil {
19- return err
20- }
21-
22- for _, entry := range entries {
23- if entry.IsDir() {
24- continue
25- }
26- name := entry.Name()
27- if !isPlainEnvFile(name) {
28- continue
29- }
30- if err := migrateEnvFile(envDir, name, passStore, log); err != nil {
31- return err
32- }
33- }
34-
35- if err := migrateAttachmentEnvFilesToPass(envDir, passStore, log); err != nil {
36- return err
37- }
38-
39- return nil
40-}
41-
42-func readEnvDir(envDir string) ([]os.DirEntry, error) {
43- entries, err := os.ReadDir(envDir)
44- if err != nil {
45- if os.IsNotExist(err) {
46- return nil, nil
47- }
48- return nil, fmt.Errorf("failed to read env directory: %w", err)
49- }
50- return entries, nil
51-}
52-
53-func isPlainEnvFile(name string) bool {
54- if !strings.HasSuffix(name, ".env") || strings.HasSuffix(name, ".env.migrated") {
55- return false
56- }
57- // Skip attachment env files: gordon-<sanitized-domain>-<service>.env
58- if strings.HasPrefix(name, "gordon-") {
59- return false
60- }
61- return true
62-}
63-
64-func migrateEnvFile(envDir, name string, passStore *domainsecrets.PassStore, log zerowrap.Logger) error {
65- filePath := filepath.Join(envDir, name)
66- data, err := os.ReadFile(filePath)
67- if err != nil {
68- return fmt.Errorf("failed to read env file %s: %w", filePath, err)
69- }
70-
71- domainName := strings.TrimSuffix(name, ".env")
72- secrets, err := domain.ParseEnvData(data)
73- if err != nil {
74- return fmt.Errorf("failed to parse env file %s: %w", filePath, err)
75- }
76- if len(secrets) == 0 {
77- log.Info().
78- Str("file", filePath).
79- Msg("no secrets found in env file; skipping migration")
80- return nil
81- }
82-
83- writtenKeys, err := passStore.SetIfEmpty(domainName, secrets)
84- if err != nil {
85- if errors.Is(err, domain.ErrSecretsAlreadyExist) {
86- return fmt.Errorf("%w; refusing to delete plaintext env file automatically", err)
87- }
88- return fmt.Errorf("failed to migrate secrets for %s: %w", domainName, err)
89- }
90-
91- if err := os.Remove(filePath); err != nil {
92- // Check if the stored secrets match what we just wrote
93- storedSecrets, getErr := passStore.GetAll(domainName)
94- if getErr == nil && secretsMatch(storedSecrets, secrets) {
95- // Migration was successful, removal failed but secrets are stored
96- log.Warn().
97- Str("file", filePath).
98- Str("domain", domainName).
99- Msg("secrets migrated successfully but failed to remove original file; treating as success")
100- return nil
101- }
102- // Attempt rollback: remove only the pass entries this migration wrote.
103- for _, key := range writtenKeys {
104- if delErr := passStore.Delete(domainName, key); delErr != nil {
105- log.Error().
106- Err(delErr).
107- Str("domain", domainName).
108- Str("key", key).
109- Msg("failed to rollback pass entry after file removal failure")
110- }
111- }
112- return fmt.Errorf("failed to remove migrated env file %s: %w", filePath, err)
113- }
114-
115- log.Info().
116- Int(zerowrap.FieldCount, len(secrets)).
117- Str("domain", domainName).
118- Msg("migrated secrets for domain from plain text to pass and removed original env file")
119-
120- return nil
121-}
122-
123-func migrateAttachmentEnvFilesToPass(envDir string, passStore *domainsecrets.PassStore, log zerowrap.Logger) error {
124- entries, err := readEnvDir(envDir)
125- if err != nil {
126- return err
127- }
128-
129- for _, entry := range entries {
130- if entry.IsDir() {
131- continue
132- }
133- name := entry.Name()
134- if !isAttachmentEnvFile(name) {
135- continue
136- }
137- if err := migrateAttachmentEnvFile(envDir, name, passStore, log); err != nil {
138- return err
139- }
140- }
141-
142- return nil
143-}
144-
145-func isAttachmentEnvFile(name string) bool {
146- if !strings.HasSuffix(name, ".env") || strings.HasSuffix(name, ".env.migrated") {
147- return false
148- }
149- return strings.HasPrefix(name, "gordon-")
150-}
151-
152-func migrateAttachmentEnvFile(envDir, name string, passStore *domainsecrets.PassStore, log zerowrap.Logger) error {
153- filePath := filepath.Join(envDir, name)
154- data, err := os.ReadFile(filePath)
155- if err != nil {
156- return fmt.Errorf("failed to read attachment env file %s: %w", filePath, err)
157- }
158-
159- containerName := extractContainerNameFromAttachmentFile(name)
160- secrets, err := domain.ParseEnvData(data)
161- if err != nil {
162- return fmt.Errorf("failed to parse attachment env file %s: %w", filePath, err)
163- }
164- if len(secrets) == 0 {
165- log.Info().
166- Str("file", filePath).
167- Msg("no secrets found in attachment env file; skipping migration")
168- return nil
169- }
170-
171- writtenKeys, err := passStore.SetAttachmentIfEmpty(containerName, secrets)
172- if err != nil {
173- if errors.Is(err, domain.ErrSecretsAlreadyExist) {
174- return fmt.Errorf("%w; refusing to delete plaintext env file automatically", err)
175- }
176- return fmt.Errorf("failed to migrate attachment secrets for %s: %w", containerName, err)
177- }
178-
179- if err := os.Remove(filePath); err != nil {
180- // Check if the stored secrets match what we just wrote
181- storedSecrets, getErr := passStore.GetAllAttachment(containerName)
182- if getErr == nil && secretsMatch(storedSecrets, secrets) {
183- // Migration was successful, removal failed but secrets are stored
184- log.Warn().
185- Str("file", filePath).
186- Str("container", containerName).
187- Msg("attachment secrets migrated successfully but failed to remove original file; treating as success")
188- return nil
189- }
190- // Attempt rollback: remove only the pass entries this migration wrote.
191- for _, key := range writtenKeys {
192- if delErr := passStore.DeleteAttachment(containerName, key); delErr != nil {
193- log.Error().
194- Err(delErr).
195- Str("container", containerName).
196- Str("key", key).
197- Msg("failed to rollback pass entry after file removal failure")
198- }
199- }
200- return fmt.Errorf("failed to remove migrated attachment env file %s: %w", filePath, err)
201- }
202-
203- log.Info().
204- Int(zerowrap.FieldCount, len(secrets)).
205- Str("container", containerName).
206- Str("file", filePath).
207- Msg("migrated attachment secrets to pass and removed original env file")
208-
209- return nil
210-}
211-
212-func extractContainerNameFromAttachmentFile(filename string) string {
213- name := strings.TrimSuffix(filename, ".env")
214- return name
215-}
216-
217-func secretsMatch(a, b map[string]string) bool {
218- if len(a) != len(b) {
219- return false
220- }
221- for k, v := range a {
222- bv, ok := b[k]
223- if !ok || bv != v {
224- return false
225- }
226- }
227- return true
228-}
D internal/app/migrate_env_test.go +0 -214
@@ -1,214 +0,0 @@
1-package app
2-
3-import (
4- "context"
5- "fmt"
6- "os"
7- "os/exec"
8- "path/filepath"
9- "testing"
10- "time"
11-
12- "github.com/bnema/zerowrap"
13- "github.com/stretchr/testify/assert"
14- "github.com/stretchr/testify/require"
15-
16- "github.com/bnema/gordon/internal/adapters/out/domainsecrets"
17- "github.com/bnema/gordon/internal/domain"
18-)
19-
20-func passCmd(args ...string) error {
21- ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
22- defer cancel()
23- return exec.CommandContext(ctx, "pass", args...).Run()
24-}
25-
26-func requirePass(t *testing.T) {
27- if err := passCmd("version"); err != nil {
28- t.Skip("pass not available")
29- }
30- if err := passCmd("ls"); err != nil {
31- t.Skip("pass store not initialized")
32- }
33-}
34-
35-func cleanupPassDomain(domainName string, keys []string) {
36- safeDomain, err := domain.SanitizeDomainForEnvFile(domainName)
37- if err != nil {
38- return
39- }
40-
41- for _, key := range keys {
42- path := fmt.Sprintf("%s/%s/%s", domainsecrets.PassDomainSecretsPath, safeDomain, key)
43- _ = passCmd("rm", "-f", path)
44- }
45-
46- manifestPath := fmt.Sprintf("%s/%s/.keys", domainsecrets.PassDomainSecretsPath, safeDomain)
47- _ = passCmd("rm", "-f", manifestPath)
48-}
49-
50-func TestMigrateEnvFile(t *testing.T) {
51- requirePass(t)
52-
53- tmpDir, err := os.MkdirTemp("", "migrate-env-test-*")
54- require.NoError(t, err)
55- defer os.RemoveAll(tmpDir)
56-
57- logger := zerowrap.Default()
58- store, err := domainsecrets.NewPassStore(logger)
59- require.NoError(t, err)
60-
61- domainName := fmt.Sprintf("test.%d.example.com", time.Now().UnixNano())
62- keys := []string{"API_KEY", "DB_PASSWORD"}
63- defer cleanupPassDomain(domainName, keys)
64-
65- envContent := "API_KEY=secret123\nDB_PASSWORD=pass456\n"
66- envFileName := domainName + ".env"
67- envFilePath := filepath.Join(tmpDir, envFileName)
68- err = os.WriteFile(envFilePath, []byte(envContent), 0600)
69- require.NoError(t, err)
70-
71- err = migrateEnvFile(tmpDir, envFileName, store, logger)
72- require.NoError(t, err)
73-
74- _, err = os.Stat(envFilePath)
75- assert.True(t, os.IsNotExist(err), "original .env file should be removed")
76-
77- _, err = os.Stat(envFilePath + ".migrated")
78- assert.True(t, os.IsNotExist(err), ".env.migrated plaintext should not be preserved by default")
79-
80- passKeys, err := store.ListKeys(domainName)
81- require.NoError(t, err)
82- assert.ElementsMatch(t, keys, passKeys)
83-
84- values, err := store.GetAll(domainName)
85- require.NoError(t, err)
86- assert.Equal(t, "secret123", values["API_KEY"])
87- assert.Equal(t, "pass456", values["DB_PASSWORD"])
88-}
89-
90-func cleanupPassAttachment(_ *testing.T, containerName string, keys []string) {
91- for _, key := range keys {
92- path := fmt.Sprintf("%s/%s/%s", domainsecrets.PassAttachmentPath, containerName, key)
93- _ = passCmd("rm", "-f", path)
94- }
95-
96- manifestPath := fmt.Sprintf("%s/%s/.keys", domainsecrets.PassAttachmentPath, containerName)
97- _ = passCmd("rm", "-f", manifestPath)
98-}
99-
100-func TestMigrateAttachmentEnvFile(t *testing.T) {
101- requirePass(t)
102-
103- tmpDir, err := os.MkdirTemp("", "migrate-attachment-env-test-*")
104- require.NoError(t, err)
105- defer os.RemoveAll(tmpDir)
106-
107- logger := zerowrap.Default()
108- store, err := domainsecrets.NewPassStore(logger)
109- require.NoError(t, err)
110-
111- containerName := fmt.Sprintf("gitea-postgres-%d", time.Now().UnixNano())
112- envFileName := "gordon-" + containerName + ".env"
113- // extractContainerNameFromAttachmentFile strips the .env suffix, so the
114- // stored name includes the "gordon-" prefix from the filename.
115- storedContainerName := extractContainerNameFromAttachmentFile(envFileName)
116- keys := []string{"POSTGRES_USER", "POSTGRES_PASSWORD"}
117- defer cleanupPassAttachment(t, storedContainerName, keys)
118-
119- envContent := "POSTGRES_USER=gitea\nPOSTGRES_PASSWORD=secret123\n"
120- envFilePath := filepath.Join(tmpDir, envFileName)
121- err = os.WriteFile(envFilePath, []byte(envContent), 0600)
122- require.NoError(t, err)
123-
124- err = migrateAttachmentEnvFile(tmpDir, envFileName, store, logger)
125- require.NoError(t, err)
126-
127- _, err = os.Stat(envFilePath)
128- assert.True(t, os.IsNotExist(err), "original .env file should be removed")
129-
130- _, err = os.Stat(envFilePath + ".migrated")
131- assert.True(t, os.IsNotExist(err), ".env.migrated plaintext should not be preserved by default")
132-
133- values, err := store.GetAllAttachment(storedContainerName)
134- require.NoError(t, err)
135- assert.Equal(t, "gitea", values["POSTGRES_USER"])
136- assert.Equal(t, "secret123", values["POSTGRES_PASSWORD"])
137-}
138-
139-func TestIsAttachmentEnvFile(t *testing.T) {
140- tests := []struct {
141- name string
142- filename string
143- want bool
144- }{
145- {
146- name: "attachment env file",
147- filename: "gordon-gitea-postgres.env",
148- want: true,
149- },
150- {
151- name: "attachment env file with complex name",
152- filename: "gordon-git-example-com-gitea-postgres.env",
153- want: true,
154- },
155- {
156- name: "plain domain env file",
157- filename: "example.com.env",
158- want: false,
159- },
160- {
161- name: "migrated attachment file",
162- filename: "gordon-gitea-postgres.env.migrated",
163- want: false,
164- },
165- {
166- name: "non-env file",
167- filename: "config.yaml",
168- want: false,
169- },
170- {
171- name: "gordon prefix but no env suffix",
172- filename: "gordon-something",
173- want: false,
174- },
175- }
176-
177- for _, tt := range tests {
178- t.Run(tt.name, func(t *testing.T) {
179- got := isAttachmentEnvFile(tt.filename)
180- assert.Equal(t, tt.want, got)
181- })
182- }
183-}
184-
185-func TestExtractContainerNameFromAttachmentFile(t *testing.T) {
186- tests := []struct {
187- name string
188- filename string
189- want string
190- }{
191- {
192- name: "simple attachment file",
193- filename: "gordon-gitea-postgres.env",
194- want: "gordon-gitea-postgres",
195- },
196- {
197- name: "complex attachment file",
198- filename: "gordon-git-example-com-gitea-redis.env",
199- want: "gordon-git-example-com-gitea-redis",
200- },
201- {
202- name: "single word container",
203- filename: "gordon-redis.env",
204- want: "gordon-redis",
205- },
206- }
207-
208- for _, tt := range tests {
209- t.Run(tt.name, func(t *testing.T) {
210- got := extractContainerNameFromAttachmentFile(tt.filename)
211- assert.Equal(t, tt.want, got)
212- })
213- }
214-}
M internal/app/run.go +6 -90
@@ -31,8 +31,6 @@ import (
31 acmelego "github.com/bnema/gordon/internal/adapters/out/acmelego"31 acmelego "github.com/bnema/gordon/internal/adapters/out/acmelego"
32 acmestore "github.com/bnema/gordon/internal/adapters/out/acmestore"32 acmestore "github.com/bnema/gordon/internal/adapters/out/acmestore"
33 "github.com/bnema/gordon/internal/adapters/out/docker"33 "github.com/bnema/gordon/internal/adapters/out/docker"
34- "github.com/bnema/gordon/internal/adapters/out/domainsecrets"
35- "github.com/bnema/gordon/internal/adapters/out/envloader"
36 "github.com/bnema/gordon/internal/adapters/out/eventbus"34 "github.com/bnema/gordon/internal/adapters/out/eventbus"
37 "github.com/bnema/gordon/internal/adapters/out/filesystem"35 "github.com/bnema/gordon/internal/adapters/out/filesystem"
38 "github.com/bnema/gordon/internal/adapters/out/httpprober"36 "github.com/bnema/gordon/internal/adapters/out/httpprober"
@@ -90,7 +88,6 @@ import (
90 "github.com/bnema/gordon/internal/usecase/publictls"88 "github.com/bnema/gordon/internal/usecase/publictls"
91 registrySvc "github.com/bnema/gordon/internal/usecase/registry"89 registrySvc "github.com/bnema/gordon/internal/usecase/registry"
92 "github.com/bnema/gordon/internal/usecase/registrystate"90 "github.com/bnema/gordon/internal/usecase/registrystate"
93- secretsSvc "github.com/bnema/gordon/internal/usecase/secrets"
94 servicecfg "github.com/bnema/gordon/internal/usecase/services"91 servicecfg "github.com/bnema/gordon/internal/usecase/services"
95 "github.com/bnema/gordon/internal/usecase/traffic"92 "github.com/bnema/gordon/internal/usecase/traffic"
96 volumesSvc "github.com/bnema/gordon/internal/usecase/volumes"93 volumesSvc "github.com/bnema/gordon/internal/usecase/volumes"
@@ -168,10 +165,6 @@ type Config struct {
168 } `mapstructure:"access_log"`165 } `mapstructure:"access_log"`
169 } `mapstructure:"logging"`166 } `mapstructure:"logging"`
170 167
171- Env struct {
172- Dir string `mapstructure:"dir"`
173- } `mapstructure:"env"`
174-
175 Volumes struct {168 Volumes struct {
176 AutoCreate bool `mapstructure:"auto_create"`169 AutoCreate bool `mapstructure:"auto_create"`
177 Prefix string `mapstructure:"prefix"`170 Prefix string `mapstructure:"prefix"`
@@ -298,11 +291,9 @@ type services struct {
298 backupStorage *filesystem.BackupStorage291 backupStorage *filesystem.BackupStorage
299 volumeBackupStore out.VolumeBackupStorage292 volumeBackupStore out.VolumeBackupStorage
300 volumeBackupCfg domain.VolumeBackupConfig293 volumeBackupCfg domain.VolumeBackupConfig
301- envLoader out.EnvLoader
302 logWriter *logwriter.LogWriter294 logWriter *logwriter.LogWriter
303 tokenStore out.TokenStore295 tokenStore out.TokenStore
304 configSvc *config.Service296 configSvc *config.Service
305- secretSvc *secretsSvc.Service
306 containerSvc *container.Service297 containerSvc *container.Service
307 backupSvc *backup.Service298 backupSvc *backup.Service
308 volumeBackupSvc *backup.VolumeService299 volumeBackupSvc *backup.VolumeService
@@ -320,7 +311,6 @@ type services struct {
320 httpsProxyHandler http.Handler311 httpsProxyHandler http.Handler
321 internalRegUser string312 internalRegUser string
322 internalRegPass string313 internalRegPass string
323- envDir string
324 maxBlobChunkSize int64314 maxBlobChunkSize int64
325 maxBlobSize int64315 maxBlobSize int64
326 caAdapter *pkiadapter.CA316 caAdapter *pkiadapter.CA
@@ -484,6 +474,7 @@ var retiredAppConfigKeys = []struct {
484 {"auto_route", "feature removed; declare explicit interfaces"},474 {"auto_route", "feature removed; declare explicit interfaces"},
485 {"auto_route_allowed_domains", "feature removed; declare explicit interfaces"},475 {"auto_route_allowed_domains", "feature removed; declare explicit interfaces"},
486 {"previews", "staging is an ordinary app file"},476 {"previews", "staging is an ordinary app file"},
477+ {"env", "remove the [env] section; declare [env] and [services.<name>.secrets] in app files"},
487 }478 }
488 479
489 // validateRetiredAppConfig rejects obsolete application configuration480 // validateRetiredAppConfig rejects obsolete application configuration
@@ -814,20 +805,14 @@ func startPublicTLSRuntime(ctx context.Context, svc publicTLSRuntime, log zerowr
814 return reconcileErr805 return reconcileErr
815 }806 }
816 807
817-// initSecrets creates the domain secret store, env loader, and secret service.808+// initSecrets validates the secrets backend and creates the app service
809+// secret provider.
818 func (si *serviceInit) initSecrets() error {810 func (si *serviceInit) initSecrets() error {
819- envDir, backend, passStore, domainSecretStore, err := createDomainSecretStore(si.cfg, si.log)811+ backend, err := resolveSecretsBackend(si.cfg.Auth.SecretsBackend)
820 if err != nil {812 if err != nil {
821- return err813+ return si.log.WrapErr(err, "failed to resolve secrets backend")
822- }
823- si.svc.envDir = envDir
824-
825- if si.svc.envLoader, err = createEnvLoader(backend, envDir, passStore, si.log); err != nil {
826- return err
827 }814 }
828-
829 si.svc.serviceSecretProvider = createStandaloneServiceSecretProvider(backend, resolveDataDir(si.cfg.Server.DataDir), si.log)815 si.svc.serviceSecretProvider = createStandaloneServiceSecretProvider(backend, resolveDataDir(si.cfg.Server.DataDir), si.log)
830- si.svc.secretSvc = secretsSvc.NewService(domainSecretStore, si.log, si.svc.eventBus)
831 return nil816 return nil
832 }817 }
833 818
@@ -1180,7 +1165,6 @@ func (si *serviceInit) initHandlers() {
1180 AuthSvc: si.svc.authSvc,1165 AuthSvc: si.svc.authSvc,
1181 ContainerSvc: si.svc.containerSvc,1166 ContainerSvc: si.svc.containerSvc,
1182 HealthSvc: si.svc.healthSvc,1167 HealthSvc: si.svc.healthSvc,
1183- SecretSvc: si.svc.secretSvc,
1184 LogSvc: si.svc.logSvc,1168 LogSvc: si.svc.logSvc,
1185 RegistrySvc: si.svc.registrySvc,1169 RegistrySvc: si.svc.registrySvc,
1186 ReloadTrigger: si.svc.reloadCoordinator,1170 ReloadTrigger: si.svc.reloadCoordinator,
@@ -1240,32 +1224,6 @@ func setupInternalRegistryAuth(svc *services, log zerowrap.Logger) error {
1240 return nil1224 return nil
1241 }1225 }
1242 1226
1243-func createDomainSecretStore(cfg Config, log zerowrap.Logger) (string, domain.SecretsBackend, *domainsecrets.PassStore, out.DomainSecretStore, error) {
1244- envDir := resolveEnvDir(cfg)
1245- backend, err := resolveSecretsBackend(cfg.Auth.SecretsBackend)
1246- if err != nil {
1247- return "", "", nil, nil, log.WrapErr(err, "failed to resolve secrets backend")
1248- }
1249-
1250- switch backend {
1251- case domain.SecretsBackendPass:
1252- passStore, err := domainsecrets.NewPassStore(log)
1253- if err != nil {
1254- return "", backend, nil, nil, log.WrapErr(err, "failed to create pass domain secret store")
1255- }
1256- if err := migrateEnvFilesToPass(envDir, passStore, log); err != nil {
1257- return "", backend, nil, nil, log.WrapErr(err, "failed to migrate env files to pass")
1258- }
1259- return envDir, backend, passStore, passStore, nil
1260- default:
1261- store, err := domainsecrets.NewFileStore(envDir, log)
1262- if err != nil {
1263- return "", backend, nil, nil, log.WrapErr(err, "failed to create domain secret store")
1264- }
1265- return envDir, backend, nil, store, nil
1266- }
1267-}
1268-
1269 // resolveLogFilePath returns the configured log file path or a default.1227 // resolveLogFilePath returns the configured log file path or a default.
1270 func resolveLogFilePath(cfg Config) string {1228 func resolveLogFilePath(cfg Config) string {
1271 if cfg.Logging.File.Path != "" {1229 if cfg.Logging.File.Path != "" {
@@ -1370,38 +1328,6 @@ func createStorage(cfg Config, log zerowrap.Logger) (*filesystem.BlobStorage, *f
1370 return blobStorage, manifestStorage, nil1328 return blobStorage, manifestStorage, nil
1371 }1329 }
1372 1330
1373-// createEnvLoader creates the environment loader with secret providers.
1374-func createEnvLoader(backend domain.SecretsBackend, envDir string, passStore *domainsecrets.PassStore, log zerowrap.Logger) (out.EnvLoader, error) {
1375- switch backend {
1376- case domain.SecretsBackendPass:
1377- loader, err := envloader.NewPassLoader(passStore, log)
1378- if err != nil {
1379- return nil, log.WrapErr(err, "failed to create pass env loader")
1380- }
1381- return loader, nil
1382- default:
1383- loader, err := envloader.NewFileLoader(envDir, log)
1384- if err != nil {
1385- return nil, log.WrapErr(err, "failed to create env loader")
1386- }
1387-
1388- // Register secret providers
1389- passProvider := secrets.NewPassProvider(log)
1390- if passProvider.IsAvailable() {
1391- loader.RegisterSecretProvider(passProvider)
1392- log.Debug().Msg("pass secret provider registered")
1393- }
1394-
1395- sopsProvider := secrets.NewSopsProvider(log)
1396- if sopsProvider.IsAvailable() {
1397- loader.RegisterSecretProvider(sopsProvider)
1398- log.Debug().Msg("sops secret provider registered")
1399- }
1400-
1401- return loader, nil
1402- }
1403-}
1404-
1405 // createLogWriter creates the container log writer.1331 // createLogWriter creates the container log writer.
1406 func createLogWriter(cfg Config, log zerowrap.Logger) (*logwriter.LogWriter, error) {1332 func createLogWriter(cfg Config, log zerowrap.Logger) (*logwriter.LogWriter, error) {
1407 if !cfg.Logging.ContainerLogs.Enabled {1333 if !cfg.Logging.ContainerLogs.Enabled {
@@ -1667,15 +1593,6 @@ func resolveDataDir(dataDir string) string {
1667 return dataDir1593 return dataDir
1668 }1594 }
1669 1595
1670-func resolveEnvDir(cfg Config) string {
1671- dataDir := resolveDataDir(cfg.Server.DataDir)
1672- envDir := cfg.Env.Dir
1673- if envDir == "" {
1674- envDir = filepath.Join(dataDir, "env")
1675- }
1676- return envDir
1677-}
1678-
1679 func resolveRegistryDomains(cfg Config) (string, []string) {1596 func resolveRegistryDomains(cfg Config) (string, []string) {
1680 registryDomain := cfg.Server.RegistryDomain1597 registryDomain := cfg.Server.RegistryDomain
1681 if registryDomain == "" {1598 if registryDomain == "" {
@@ -2265,7 +2182,7 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc
2265 if err != nil {2182 if err != nil {
2266 return nil, err2183 return nil, err
2267 }2184 }
2268- return container.NewService(svc.runtime, svc.envLoader, svc.eventBus, svc.logWriter, containerConfig), nil2185+ return container.NewService(svc.runtime, svc.eventBus, svc.logWriter, containerConfig), nil
2269 }2186 }
2270 2187
2271 type databaseBackupSettingsConfig struct {2188 type databaseBackupSettingsConfig struct {
@@ -4225,7 +4142,6 @@ func loadConfig(v *viper.Viper, configPath string) error {
4225 v.SetDefault("logging.access_log.max_age", 28)4142 v.SetDefault("logging.access_log.max_age", 28)
4226 v.SetDefault("logging.access_log.exclude_health_checks", true)4143 v.SetDefault("logging.access_log.exclude_health_checks", true)
4227 v.SetDefault("logging.access_log.syslog_identifier", "gordon-access")4144 v.SetDefault("logging.access_log.syslog_identifier", "gordon-access")
4228- v.SetDefault("env.dir", "") // defaults to {data_dir}/env when empty
4229 v.SetDefault("auth.enabled", true)4145 v.SetDefault("auth.enabled", true)
4230 // Note: auth.type defaults to "token" (the only supported mode)4146 // Note: auth.type defaults to "token" (the only supported mode)
4231 v.SetDefault("auth.secrets_backend", "")4147 v.SetDefault("auth.secrets_backend", "")
M internal/app/run_app_lifecycle_test.go +2 -2
@@ -141,7 +141,7 @@ func TestGracefulShutdown_FailsClosedWhenAppAdministrationDoesNotQuiesce(t *test
141 141
142 store := outmocks.NewMockAppState(t)142 store := outmocks.NewMockAppState(t)
143 143
144- containerSvc := container.NewService(nil, nil, nil, nil, container.Config{})144+ containerSvc := container.NewService(nil, nil, nil, container.Config{})
145 err := gracefulShutdown(nil, nil, nil, containerSvc, nil, nil, nil, nil, nil, admin, store, zerowrap.Default())145 err := gracefulShutdown(nil, nil, nil, containerSvc, nil, nil, nil, nil, nil, admin, store, zerowrap.Default())
146 146
147 require.Error(t, err)147 require.Error(t, err)
@@ -164,7 +164,7 @@ func TestGracefulShutdown_QuiescesAppAdministrationBeforeClosingState(t *testing
164 store := outmocks.NewMockAppState(t)164 store := outmocks.NewMockAppState(t)
165 store.EXPECT().Close().Run(func() { order = append(order, "state-close") }).Return(nil)165 store.EXPECT().Close().Run(func() { order = append(order, "state-close") }).Return(nil)
166 166
167- containerSvc := container.NewService(nil, nil, nil, nil, container.Config{})167+ containerSvc := container.NewService(nil, nil, nil, container.Config{})
168 require.NoError(t, gracefulShutdown(nil, nil, nil, containerSvc, nil, nil, nil, nil, nil, admin, store, zerowrap.Default()))168 require.NoError(t, gracefulShutdown(nil, nil, nil, containerSvc, nil, nil, nil, nil, nil, admin, store, zerowrap.Default()))
169 169
170 require.Equal(t, []string{"app-shutdown", "state-close"}, order)170 require.Equal(t, []string{"app-shutdown", "state-close"}, order)
M internal/app/run_entrypoint_migration_test.go +10 -0
@@ -36,3 +36,13 @@ func TestInitConfigRejectsRetiredAppKeys(t *testing.T) {
36 require.ErrorContains(t, err, "config-retired")36 require.ErrorContains(t, err, "config-retired")
37 require.ErrorContains(t, err, `"routes"`)37 require.ErrorContains(t, err, `"routes"`)
38 }38 }
39+
40+func TestInitConfigRejectsRetiredEnvSection(t *testing.T) {
41+ configPath := filepath.Join(t.TempDir(), "gordon.toml")
42+ require.NoError(t, os.WriteFile(configPath, []byte("[env]\ndir = \"/tmp/env\"\n"), 0o600))
43+
44+ _, _, err := initConfig(configPath)
45+
46+ require.ErrorContains(t, err, "config-retired")
47+ require.ErrorContains(t, err, `"env"`)
48+}
M internal/app/run_reload_test.go +4 -4
@@ -311,7 +311,7 @@ func TestServiceInit_ReloadRegistersCustomTLSMuxHTTPSFallback(t *testing.T) {
311 log: zerowrap.Default(),311 log: zerowrap.Default(),
312 svc: newTestServices(&services{312 svc: newTestServices(&services{
313 configSvc: configSvc,313 configSvc: configSvc,
314- containerSvc: container.NewService(nil, nil, nil, nil, container.Config{}),314+ containerSvc: container.NewService(nil, nil, nil, container.Config{}),
315 internalRegUser: "gordon",315 internalRegUser: "gordon",
316 internalRegPass: "secret",316 internalRegPass: "secret",
317 reloadCoordinator: newReloadCoordinator(v, &reloadRecorder{}, &proxyRecorder{}, nil, nil, nil, zerowrap.Default()),317 reloadCoordinator: newReloadCoordinator(v, &reloadRecorder{}, &proxyRecorder{}, nil, nil, nil, zerowrap.Default()),
@@ -361,7 +361,7 @@ func TestServiceInit_ReloadUpdatesManagementHosts(t *testing.T) {
361 log: zerowrap.Default(),361 log: zerowrap.Default(),
362 svc: newTestServices(&services{362 svc: newTestServices(&services{
363 configSvc: configSvc,363 configSvc: configSvc,
364- containerSvc: container.NewService(nil, nil, nil, nil, container.Config{}),364+ containerSvc: container.NewService(nil, nil, nil, container.Config{}),
365 internalRegUser: "gordon",365 internalRegUser: "gordon",
366 internalRegPass: "secret",366 internalRegPass: "secret",
367 reloadCoordinator: newReloadCoordinator(v, &reloadRecorder{}, &proxyRecorder{}, nil, nil, publicTLS, zerowrap.Default()),367 reloadCoordinator: newReloadCoordinator(v, &reloadRecorder{}, &proxyRecorder{}, nil, nil, publicTLS, zerowrap.Default()),
@@ -393,7 +393,7 @@ func TestServiceInit_RegisterReloadCoordinatorHooks_WiresContainerConfigApplier(
393 log: zerowrap.Default(),393 log: zerowrap.Default(),
394 svc: newTestServices(&services{394 svc: newTestServices(&services{
395 configSvc: configSvc,395 configSvc: configSvc,
396- containerSvc: container.NewService(nil, nil, nil, nil, container.Config{}),396+ containerSvc: container.NewService(nil, nil, nil, container.Config{}),
397 internalRegUser: "gordon",397 internalRegUser: "gordon",
398 internalRegPass: "secret",398 internalRegPass: "secret",
399 reloadCoordinator: newReloadCoordinator(v, &reloadRecorder{}, &proxyRecorder{}, nil, nil, nil, zerowrap.Default()),399 reloadCoordinator: newReloadCoordinator(v, &reloadRecorder{}, &proxyRecorder{}, nil, nil, nil, zerowrap.Default()),
@@ -429,7 +429,7 @@ func TestServiceInit_RegisterReloadCoordinatorHooks_AppliesTraffic(t *testing.T)
429 log: zerowrap.Default(),429 log: zerowrap.Default(),
430 svc: newTestServices(&services{430 svc: newTestServices(&services{
431 configSvc: configSvc,431 configSvc: configSvc,
432- containerSvc: container.NewService(nil, nil, nil, nil, container.Config{}),432+ containerSvc: container.NewService(nil, nil, nil, container.Config{}),
433 internalRegUser: "gordon",433 internalRegUser: "gordon",
434 internalRegPass: "secret",434 internalRegPass: "secret",
435 reloadCoordinator: newReloadCoordinator(v, &reloadRecorder{}, &proxyRecorder{}, nil, nil, nil, zerowrap.Default()),435 reloadCoordinator: newReloadCoordinator(v, &reloadRecorder{}, &proxyRecorder{}, nil, nil, nil, zerowrap.Default()),
D internal/boundaries/in/mocks/mock_secret_service.go +0 -309
@@ -1,309 +0,0 @@
1-// Code generated by mockery; DO NOT EDIT.
2-// github.com/vektra/mockery
3-// template: testify
4-
5-package mocks
6-
7-import (
8- "context"
9-
10- mock "github.com/stretchr/testify/mock"
11-)
12-
13-// NewMockSecretService creates a new instance of MockSecretService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
14-// The first argument is typically a *testing.T value.
15-func NewMockSecretService(t interface {
16- mock.TestingT
17- Cleanup(func())
18-}) *MockSecretService {
19- if helper, ok := t.(interface{ Helper() }); ok {
20- helper.Helper()
21- }
22-
23- mock := &MockSecretService{}
24- mock.Mock.Test(t)
25-
26- t.Cleanup(func() {
27- if helper, ok := t.(interface{ Helper() }); ok {
28- helper.Helper()
29- }
30- mock.AssertExpectations(t)
31- })
32-
33- return mock
34-}
35-
36-// MockSecretService is an autogenerated mock type for the SecretService type
37-type MockSecretService struct {
38- mock.Mock
39-}
40-
41-type MockSecretService_Expecter struct {
42- mock *mock.Mock
43-}
44-
45-func (_m *MockSecretService) EXPECT() *MockSecretService_Expecter {
46- return &MockSecretService_Expecter{mock: &_m.Mock}
47-}
48-
49-// Delete provides a mock function for the type MockSecretService
50-func (_mock *MockSecretService) Delete(ctx context.Context, domain string, key string) error {
51- ret := _mock.Called(ctx, domain, key)
52-
53- if len(ret) == 0 {
54- panic("no return value specified for Delete")
55- }
56-
57- var r0 error
58- if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok {
59- r0 = returnFunc(ctx, domain, key)
60- } else {
61- r0 = ret.Error(0)
62- }
63- return r0
64-}
65-
66-// MockSecretService_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'
67-type MockSecretService_Delete_Call struct {
68- *mock.Call
69-}
70-
71-// Delete is a helper method to define mock.On call
72-// - ctx context.Context
73-// - domain string
74-// - key string
75-func (_e *MockSecretService_Expecter) Delete(ctx any, domain any, key any) *MockSecretService_Delete_Call {
76- return &MockSecretService_Delete_Call{Call: _e.mock.On("Delete", ctx, domain, key)}
77-}
78-
79-func (_c *MockSecretService_Delete_Call) Run(run func(ctx context.Context, domain string, key string)) *MockSecretService_Delete_Call {
80- _c.Call.Run(func(args mock.Arguments) {
81- var arg0 context.Context
82- if args[0] != nil {
83- arg0 = args[0].(context.Context)
84- }
85- var arg1 string
86- if args[1] != nil {
87- arg1 = args[1].(string)
88- }
89- var arg2 string
90- if args[2] != nil {
91- arg2 = args[2].(string)
92- }
93- run(
94- arg0,
95- arg1,
96- arg2,
97- )
98- })
99- return _c
100-}
101-
102-func (_c *MockSecretService_Delete_Call) Return(err error) *MockSecretService_Delete_Call {
103- _c.Call.Return(err)
104- return _c
105-}
106-
107-func (_c *MockSecretService_Delete_Call) RunAndReturn(run func(ctx context.Context, domain string, key string) error) *MockSecretService_Delete_Call {
108- _c.Call.Return(run)
109- return _c
110-}
111-
112-// GetAll provides a mock function for the type MockSecretService
113-func (_mock *MockSecretService) GetAll(ctx context.Context, domain string) (map[string]string, error) {
114- ret := _mock.Called(ctx, domain)
115-
116- if len(ret) == 0 {
117- panic("no return value specified for GetAll")
118- }
119-
120- var r0 map[string]string
121- var r1 error
122- if returnFunc, ok := ret.Get(0).(func(context.Context, string) (map[string]string, error)); ok {
123- return returnFunc(ctx, domain)
124- }
125- if returnFunc, ok := ret.Get(0).(func(context.Context, string) map[string]string); ok {
126- r0 = returnFunc(ctx, domain)
127- } else {
128- if ret.Get(0) != nil {
129- r0 = ret.Get(0).(map[string]string)
130- }
131- }
132- if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
133- r1 = returnFunc(ctx, domain)
134- } else {
135- r1 = ret.Error(1)
136- }
137- return r0, r1
138-}
139-
140-// MockSecretService_GetAll_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAll'
141-type MockSecretService_GetAll_Call struct {
142- *mock.Call
143-}
144-
145-// GetAll is a helper method to define mock.On call
146-// - ctx context.Context
147-// - domain string
148-func (_e *MockSecretService_Expecter) GetAll(ctx any, domain any) *MockSecretService_GetAll_Call {
149- return &MockSecretService_GetAll_Call{Call: _e.mock.On("GetAll", ctx, domain)}
150-}
151-
152-func (_c *MockSecretService_GetAll_Call) Run(run func(ctx context.Context, domain string)) *MockSecretService_GetAll_Call {
153- _c.Call.Run(func(args mock.Arguments) {
154- var arg0 context.Context
155- if args[0] != nil {
156- arg0 = args[0].(context.Context)
157- }
158- var arg1 string
159- if args[1] != nil {
160- arg1 = args[1].(string)
161- }
162- run(
163- arg0,
164- arg1,
165- )
166- })
167- return _c
168-}
169-
170-func (_c *MockSecretService_GetAll_Call) Return(stringToString map[string]string, err error) *MockSecretService_GetAll_Call {
171- _c.Call.Return(stringToString, err)
172- return _c
173-}
174-
175-func (_c *MockSecretService_GetAll_Call) RunAndReturn(run func(ctx context.Context, domain string) (map[string]string, error)) *MockSecretService_GetAll_Call {
176- _c.Call.Return(run)
177- return _c
178-}
179-
180-// ListKeys provides a mock function for the type MockSecretService
181-func (_mock *MockSecretService) ListKeys(ctx context.Context, domain string) ([]string, error) {
182- ret := _mock.Called(ctx, domain)
183-
184- if len(ret) == 0 {
185- panic("no return value specified for ListKeys")
186- }
187-
188- var r0 []string
189- var r1 error
190- if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]string, error)); ok {
191- return returnFunc(ctx, domain)
192- }
193- if returnFunc, ok := ret.Get(0).(func(context.Context, string) []string); ok {
194- r0 = returnFunc(ctx, domain)
195- } else {
196- if ret.Get(0) != nil {
197- r0 = ret.Get(0).([]string)
198- }
199- }
200- if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
201- r1 = returnFunc(ctx, domain)
202- } else {
203- r1 = ret.Error(1)
204- }
205- return r0, r1
206-}
207-
208-// MockSecretService_ListKeys_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListKeys'
209-type MockSecretService_ListKeys_Call struct {
210- *mock.Call
211-}
212-
213-// ListKeys is a helper method to define mock.On call
214-// - ctx context.Context
215-// - domain string
216-func (_e *MockSecretService_Expecter) ListKeys(ctx any, domain any) *MockSecretService_ListKeys_Call {
217- return &MockSecretService_ListKeys_Call{Call: _e.mock.On("ListKeys", ctx, domain)}
218-}
219-
220-func (_c *MockSecretService_ListKeys_Call) Run(run func(ctx context.Context, domain string)) *MockSecretService_ListKeys_Call {
221- _c.Call.Run(func(args mock.Arguments) {
222- var arg0 context.Context
223- if args[0] != nil {
224- arg0 = args[0].(context.Context)
225- }
226- var arg1 string
227- if args[1] != nil {
228- arg1 = args[1].(string)
229- }
230- run(
231- arg0,
232- arg1,
233- )
234- })
235- return _c
236-}
237-
238-func (_c *MockSecretService_ListKeys_Call) Return(strings []string, err error) *MockSecretService_ListKeys_Call {
239- _c.Call.Return(strings, err)
240- return _c
241-}
242-
243-func (_c *MockSecretService_ListKeys_Call) RunAndReturn(run func(ctx context.Context, domain string) ([]string, error)) *MockSecretService_ListKeys_Call {
244- _c.Call.Return(run)
245- return _c
246-}
247-
248-// Set provides a mock function for the type MockSecretService
249-func (_mock *MockSecretService) Set(ctx context.Context, domain string, secrets map[string]string) error {
250- ret := _mock.Called(ctx, domain, secrets)
251-
252- if len(ret) == 0 {
253- panic("no return value specified for Set")
254- }
255-
256- var r0 error
257- if returnFunc, ok := ret.Get(0).(func(context.Context, string, map[string]string) error); ok {
258- r0 = returnFunc(ctx, domain, secrets)
259- } else {
260- r0 = ret.Error(0)
261- }
262- return r0
263-}
264-
265-// MockSecretService_Set_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Set'
266-type MockSecretService_Set_Call struct {
267- *mock.Call
268-}
269-
270-// Set is a helper method to define mock.On call
271-// - ctx context.Context
272-// - domain string
273-// - secrets map[string]string
274-func (_e *MockSecretService_Expecter) Set(ctx any, domain any, secrets any) *MockSecretService_Set_Call {
275- return &MockSecretService_Set_Call{Call: _e.mock.On("Set", ctx, domain, secrets)}
276-}
277-
278-func (_c *MockSecretService_Set_Call) Run(run func(ctx context.Context, domain string, secrets map[string]string)) *MockSecretService_Set_Call {
279- _c.Call.Run(func(args mock.Arguments) {
280- var arg0 context.Context
281- if args[0] != nil {
282- arg0 = args[0].(context.Context)
283- }
284- var arg1 string
285- if args[1] != nil {
286- arg1 = args[1].(string)
287- }
288- var arg2 map[string]string
289- if args[2] != nil {
290- arg2 = args[2].(map[string]string)
291- }
292- run(
293- arg0,
294- arg1,
295- arg2,
296- )
297- })
298- return _c
299-}
300-
301-func (_c *MockSecretService_Set_Call) Return(err error) *MockSecretService_Set_Call {
302- _c.Call.Return(err)
303- return _c
304-}
305-
306-func (_c *MockSecretService_Set_Call) RunAndReturn(run func(ctx context.Context, domain string, secrets map[string]string) error) *MockSecretService_Set_Call {
307- _c.Call.Return(run)
308- return _c
309-}
D internal/boundaries/in/secrets.go +0 -25
@@ -1,25 +0,0 @@
1-package in
2-
3-import (
4- "context"
5-)
6-
7-// SecretService defines the contract for managing domain-scoped secrets.
8-// These are environment variables stored per-domain for container injection.
9-type SecretService interface {
10- // ListKeys returns the list of secret keys for a domain (not values).
11- // Returns an error if the domain is invalid.
12- ListKeys(ctx context.Context, domain string) ([]string, error)
13-
14- // GetAll returns all secrets for a domain as a key-value map.
15- // Returns an error if the domain is invalid.
16- GetAll(ctx context.Context, domain string) (map[string]string, error)
17-
18- // Set sets or updates multiple secrets for a domain, merging with existing.
19- // Returns an error if the domain is invalid.
20- Set(ctx context.Context, domain string, secrets map[string]string) error
21-
22- // Delete removes a specific secret key from a domain.
23- // Returns an error if the domain is invalid.
24- Delete(ctx context.Context, domain, key string) error
25-}
D internal/boundaries/out/domain_secrets.go +0 -39
@@ -1,39 +0,0 @@
1-package out
2-
3-// AttachmentSecrets represents secrets for an attachment container.
4-type AttachmentSecrets struct {
5- // Service is the attachment service name (e.g., "gitea-postgres")
6- Service string
7- // Keys is the list of secret keys for this attachment
8- Keys []string
9-}
10-
11-// DomainSecretStore defines the contract for managing domain-scoped secrets.
12-// These are environment variables stored per-domain for container injection.
13-type DomainSecretStore interface {
14- // ListKeys returns the list of secret keys for a domain (not values).
15- ListKeys(domain string) ([]string, error)
16-
17- // GetAll returns all secrets for a domain as a key-value map.
18- GetAll(domain string) (map[string]string, error)
19-
20- // Set sets or updates multiple secrets for a domain, merging with existing.
21- Set(domain string, secrets map[string]string) error
22-
23- // Delete removes a specific secret key from a domain.
24- Delete(domain, key string) error
25-
26- // SetAttachment sets or updates multiple secrets for an attachment container.
27- SetAttachment(containerName string, secrets map[string]string) error
28-
29- // GetAllAttachment returns all secrets for an attachment container as a key-value map.
30- GetAllAttachment(containerName string) (map[string]string, error)
31-
32- // DeleteAttachment removes a specific secret key from an attachment container.
33- DeleteAttachment(containerName, key string) error
34-
35- // ListAttachmentKeys finds and returns secret keys for attachment containers
36- // associated with the given domain. Returns a list of AttachmentSecrets, one
37- // for each attachment that has secrets configured.
38- ListAttachmentKeys(domain string) ([]AttachmentSecrets, error)
39-}
D internal/boundaries/out/envloader.go +0 -16
@@ -1,16 +0,0 @@
1-package out
2-
3-import "context"
4-
5-// EnvLoader defines the contract for loading environment variables for containers.
6-type EnvLoader interface {
7- // LoadEnv loads environment variables for a given domain.
8- // Returns a slice of "KEY=VALUE" strings.
9- LoadEnv(ctx context.Context, domain string) ([]string, error)
10-
11- // CreateEnvFile creates an empty environment file for a new domain.
12- CreateEnvFile(ctx context.Context, domain string) error
13-
14- // EnvFileExists checks if an environment file exists for a domain.
15- EnvFileExists(domain string) (bool, error)
16-}
D internal/boundaries/out/mocks/mock_domain_secret_store.go +0 -522
@@ -1,522 +0,0 @@
1-// Code generated by mockery; DO NOT EDIT.
2-// github.com/vektra/mockery
3-// template: testify
4-
5-package mocks
6-
7-import (
8- "github.com/bnema/gordon/internal/boundaries/out"
9- mock "github.com/stretchr/testify/mock"
10-)
11-
12-// NewMockDomainSecretStore creates a new instance of MockDomainSecretStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
13-// The first argument is typically a *testing.T value.
14-func NewMockDomainSecretStore(t interface {
15- mock.TestingT
16- Cleanup(func())
17-}) *MockDomainSecretStore {
18- if helper, ok := t.(interface{ Helper() }); ok {
19- helper.Helper()
20- }
21-
22- mock := &MockDomainSecretStore{}
23- mock.Mock.Test(t)
24-
25- t.Cleanup(func() {
26- if helper, ok := t.(interface{ Helper() }); ok {
27- helper.Helper()
28- }
29- mock.AssertExpectations(t)
30- })
31-
32- return mock
33-}
34-
35-// MockDomainSecretStore is an autogenerated mock type for the DomainSecretStore type
36-type MockDomainSecretStore struct {
37- mock.Mock
38-}
39-
40-type MockDomainSecretStore_Expecter struct {
41- mock *mock.Mock
42-}
43-
44-func (_m *MockDomainSecretStore) EXPECT() *MockDomainSecretStore_Expecter {
45- return &MockDomainSecretStore_Expecter{mock: &_m.Mock}
46-}
47-
48-// Delete provides a mock function for the type MockDomainSecretStore
49-func (_mock *MockDomainSecretStore) Delete(domain string, key string) error {
50- ret := _mock.Called(domain, key)
51-
52- if len(ret) == 0 {
53- panic("no return value specified for Delete")
54- }
55-
56- var r0 error
57- if returnFunc, ok := ret.Get(0).(func(string, string) error); ok {
58- r0 = returnFunc(domain, key)
59- } else {
60- r0 = ret.Error(0)
61- }
62- return r0
63-}
64-
65-// MockDomainSecretStore_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'
66-type MockDomainSecretStore_Delete_Call struct {
67- *mock.Call
68-}
69-
70-// Delete is a helper method to define mock.On call
71-// - domain string
72-// - key string
73-func (_e *MockDomainSecretStore_Expecter) Delete(domain any, key any) *MockDomainSecretStore_Delete_Call {
74- return &MockDomainSecretStore_Delete_Call{Call: _e.mock.On("Delete", domain, key)}
75-}
76-
77-func (_c *MockDomainSecretStore_Delete_Call) Run(run func(domain string, key string)) *MockDomainSecretStore_Delete_Call {
78- _c.Call.Run(func(args mock.Arguments) {
79- var arg0 string
80- if args[0] != nil {
81- arg0 = args[0].(string)
82- }
83- var arg1 string
84- if args[1] != nil {
85- arg1 = args[1].(string)
86- }
87- run(
88- arg0,
89- arg1,
90- )
91- })
92- return _c
93-}
94-
95-func (_c *MockDomainSecretStore_Delete_Call) Return(err error) *MockDomainSecretStore_Delete_Call {
96- _c.Call.Return(err)
97- return _c
98-}
99-
100-func (_c *MockDomainSecretStore_Delete_Call) RunAndReturn(run func(domain string, key string) error) *MockDomainSecretStore_Delete_Call {
101- _c.Call.Return(run)
102- return _c
103-}
104-
105-// DeleteAttachment provides a mock function for the type MockDomainSecretStore
106-func (_mock *MockDomainSecretStore) DeleteAttachment(containerName string, key string) error {
107- ret := _mock.Called(containerName, key)
108-
109- if len(ret) == 0 {
110- panic("no return value specified for DeleteAttachment")
111- }
112-
113- var r0 error
114- if returnFunc, ok := ret.Get(0).(func(string, string) error); ok {
115- r0 = returnFunc(containerName, key)
116- } else {
117- r0 = ret.Error(0)
118- }
119- return r0
120-}
121-
122-// MockDomainSecretStore_DeleteAttachment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteAttachment'
123-type MockDomainSecretStore_DeleteAttachment_Call struct {
124- *mock.Call
125-}
126-
127-// DeleteAttachment is a helper method to define mock.On call
128-// - containerName string
129-// - key string
130-func (_e *MockDomainSecretStore_Expecter) DeleteAttachment(containerName any, key any) *MockDomainSecretStore_DeleteAttachment_Call {
131- return &MockDomainSecretStore_DeleteAttachment_Call{Call: _e.mock.On("DeleteAttachment", containerName, key)}
132-}
133-
134-func (_c *MockDomainSecretStore_DeleteAttachment_Call) Run(run func(containerName string, key string)) *MockDomainSecretStore_DeleteAttachment_Call {
135- _c.Call.Run(func(args mock.Arguments) {
136- var arg0 string
137- if args[0] != nil {
138- arg0 = args[0].(string)
139- }
140- var arg1 string
141- if args[1] != nil {
142- arg1 = args[1].(string)
143- }
144- run(
145- arg0,
146- arg1,
147- )
148- })
149- return _c
150-}
151-
152-func (_c *MockDomainSecretStore_DeleteAttachment_Call) Return(err error) *MockDomainSecretStore_DeleteAttachment_Call {
153- _c.Call.Return(err)
154- return _c
155-}
156-
157-func (_c *MockDomainSecretStore_DeleteAttachment_Call) RunAndReturn(run func(containerName string, key string) error) *MockDomainSecretStore_DeleteAttachment_Call {
158- _c.Call.Return(run)
159- return _c
160-}
161-
162-// GetAll provides a mock function for the type MockDomainSecretStore
163-func (_mock *MockDomainSecretStore) GetAll(domain string) (map[string]string, error) {
164- ret := _mock.Called(domain)
165-
166- if len(ret) == 0 {
167- panic("no return value specified for GetAll")
168- }
169-
170- var r0 map[string]string
171- var r1 error
172- if returnFunc, ok := ret.Get(0).(func(string) (map[string]string, error)); ok {
173- return returnFunc(domain)
174- }
175- if returnFunc, ok := ret.Get(0).(func(string) map[string]string); ok {
176- r0 = returnFunc(domain)
177- } else {
178- if ret.Get(0) != nil {
179- r0 = ret.Get(0).(map[string]string)
180- }
181- }
182- if returnFunc, ok := ret.Get(1).(func(string) error); ok {
183- r1 = returnFunc(domain)
184- } else {
185- r1 = ret.Error(1)
186- }
187- return r0, r1
188-}
189-
190-// MockDomainSecretStore_GetAll_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAll'
191-type MockDomainSecretStore_GetAll_Call struct {
192- *mock.Call
193-}
194-
195-// GetAll is a helper method to define mock.On call
196-// - domain string
197-func (_e *MockDomainSecretStore_Expecter) GetAll(domain any) *MockDomainSecretStore_GetAll_Call {
198- return &MockDomainSecretStore_GetAll_Call{Call: _e.mock.On("GetAll", domain)}
199-}
200-
201-func (_c *MockDomainSecretStore_GetAll_Call) Run(run func(domain string)) *MockDomainSecretStore_GetAll_Call {
202- _c.Call.Run(func(args mock.Arguments) {
203- var arg0 string
204- if args[0] != nil {
205- arg0 = args[0].(string)
206- }
207- run(
208- arg0,
209- )
210- })
211- return _c
212-}
213-
214-func (_c *MockDomainSecretStore_GetAll_Call) Return(stringToString map[string]string, err error) *MockDomainSecretStore_GetAll_Call {
215- _c.Call.Return(stringToString, err)
216- return _c
217-}
218-
219-func (_c *MockDomainSecretStore_GetAll_Call) RunAndReturn(run func(domain string) (map[string]string, error)) *MockDomainSecretStore_GetAll_Call {
220- _c.Call.Return(run)
221- return _c
222-}
223-
224-// GetAllAttachment provides a mock function for the type MockDomainSecretStore
225-func (_mock *MockDomainSecretStore) GetAllAttachment(containerName string) (map[string]string, error) {
226- ret := _mock.Called(containerName)
227-
228- if len(ret) == 0 {
229- panic("no return value specified for GetAllAttachment")
230- }
231-
232- var r0 map[string]string
233- var r1 error
234- if returnFunc, ok := ret.Get(0).(func(string) (map[string]string, error)); ok {
235- return returnFunc(containerName)
236- }
237- if returnFunc, ok := ret.Get(0).(func(string) map[string]string); ok {
238- r0 = returnFunc(containerName)
239- } else {
240- if ret.Get(0) != nil {
241- r0 = ret.Get(0).(map[string]string)
242- }
243- }
244- if returnFunc, ok := ret.Get(1).(func(string) error); ok {
245- r1 = returnFunc(containerName)
246- } else {
247- r1 = ret.Error(1)
248- }
249- return r0, r1
250-}
251-
252-// MockDomainSecretStore_GetAllAttachment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAllAttachment'
253-type MockDomainSecretStore_GetAllAttachment_Call struct {
254- *mock.Call
255-}
256-
257-// GetAllAttachment is a helper method to define mock.On call
258-// - containerName string
259-func (_e *MockDomainSecretStore_Expecter) GetAllAttachment(containerName any) *MockDomainSecretStore_GetAllAttachment_Call {
260- return &MockDomainSecretStore_GetAllAttachment_Call{Call: _e.mock.On("GetAllAttachment", containerName)}
261-}
262-
263-func (_c *MockDomainSecretStore_GetAllAttachment_Call) Run(run func(containerName string)) *MockDomainSecretStore_GetAllAttachment_Call {
264- _c.Call.Run(func(args mock.Arguments) {
265- var arg0 string
266- if args[0] != nil {
267- arg0 = args[0].(string)
268- }
269- run(
270- arg0,
271- )
272- })
273- return _c
274-}
275-
276-func (_c *MockDomainSecretStore_GetAllAttachment_Call) Return(stringToString map[string]string, err error) *MockDomainSecretStore_GetAllAttachment_Call {
277- _c.Call.Return(stringToString, err)
278- return _c
279-}
280-
281-func (_c *MockDomainSecretStore_GetAllAttachment_Call) RunAndReturn(run func(containerName string) (map[string]string, error)) *MockDomainSecretStore_GetAllAttachment_Call {
282- _c.Call.Return(run)
283- return _c
284-}
285-
286-// ListAttachmentKeys provides a mock function for the type MockDomainSecretStore
287-func (_mock *MockDomainSecretStore) ListAttachmentKeys(domain string) ([]out.AttachmentSecrets, error) {
288- ret := _mock.Called(domain)
289-
290- if len(ret) == 0 {
291- panic("no return value specified for ListAttachmentKeys")
292- }
293-
294- var r0 []out.AttachmentSecrets
295- var r1 error
296- if returnFunc, ok := ret.Get(0).(func(string) ([]out.AttachmentSecrets, error)); ok {
297- return returnFunc(domain)
298- }
299- if returnFunc, ok := ret.Get(0).(func(string) []out.AttachmentSecrets); ok {
300- r0 = returnFunc(domain)
301- } else {
302- if ret.Get(0) != nil {
303- r0 = ret.Get(0).([]out.AttachmentSecrets)
304- }
305- }
306- if returnFunc, ok := ret.Get(1).(func(string) error); ok {
307- r1 = returnFunc(domain)
308- } else {
309- r1 = ret.Error(1)
310- }
311- return r0, r1
312-}
313-
314-// MockDomainSecretStore_ListAttachmentKeys_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListAttachmentKeys'
315-type MockDomainSecretStore_ListAttachmentKeys_Call struct {
316- *mock.Call
317-}
318-
319-// ListAttachmentKeys is a helper method to define mock.On call
320-// - domain string
321-func (_e *MockDomainSecretStore_Expecter) ListAttachmentKeys(domain any) *MockDomainSecretStore_ListAttachmentKeys_Call {
322- return &MockDomainSecretStore_ListAttachmentKeys_Call{Call: _e.mock.On("ListAttachmentKeys", domain)}
323-}
324-
325-func (_c *MockDomainSecretStore_ListAttachmentKeys_Call) Run(run func(domain string)) *MockDomainSecretStore_ListAttachmentKeys_Call {
326- _c.Call.Run(func(args mock.Arguments) {
327- var arg0 string
328- if args[0] != nil {
329- arg0 = args[0].(string)
330- }
331- run(
332- arg0,
333- )
334- })
335- return _c
336-}
337-
338-func (_c *MockDomainSecretStore_ListAttachmentKeys_Call) Return(attachmentSecretss []out.AttachmentSecrets, err error) *MockDomainSecretStore_ListAttachmentKeys_Call {
339- _c.Call.Return(attachmentSecretss, err)
340- return _c
341-}
342-
343-func (_c *MockDomainSecretStore_ListAttachmentKeys_Call) RunAndReturn(run func(domain string) ([]out.AttachmentSecrets, error)) *MockDomainSecretStore_ListAttachmentKeys_Call {
344- _c.Call.Return(run)
345- return _c
346-}
347-
348-// ListKeys provides a mock function for the type MockDomainSecretStore
349-func (_mock *MockDomainSecretStore) ListKeys(domain string) ([]string, error) {
350- ret := _mock.Called(domain)
351-
352- if len(ret) == 0 {
353- panic("no return value specified for ListKeys")
354- }
355-
356- var r0 []string
357- var r1 error
358- if returnFunc, ok := ret.Get(0).(func(string) ([]string, error)); ok {
359- return returnFunc(domain)
360- }
361- if returnFunc, ok := ret.Get(0).(func(string) []string); ok {
362- r0 = returnFunc(domain)
363- } else {
364- if ret.Get(0) != nil {
365- r0 = ret.Get(0).([]string)
366- }
367- }
368- if returnFunc, ok := ret.Get(1).(func(string) error); ok {
369- r1 = returnFunc(domain)
370- } else {
371- r1 = ret.Error(1)
372- }
373- return r0, r1
374-}
375-
376-// MockDomainSecretStore_ListKeys_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListKeys'
377-type MockDomainSecretStore_ListKeys_Call struct {
378- *mock.Call
379-}
380-
381-// ListKeys is a helper method to define mock.On call
382-// - domain string
383-func (_e *MockDomainSecretStore_Expecter) ListKeys(domain any) *MockDomainSecretStore_ListKeys_Call {
384- return &MockDomainSecretStore_ListKeys_Call{Call: _e.mock.On("ListKeys", domain)}
385-}
386-
387-func (_c *MockDomainSecretStore_ListKeys_Call) Run(run func(domain string)) *MockDomainSecretStore_ListKeys_Call {
388- _c.Call.Run(func(args mock.Arguments) {
389- var arg0 string
390- if args[0] != nil {
391- arg0 = args[0].(string)
392- }
393- run(
394- arg0,
395- )
396- })
397- return _c
398-}
399-
400-func (_c *MockDomainSecretStore_ListKeys_Call) Return(strings []string, err error) *MockDomainSecretStore_ListKeys_Call {
401- _c.Call.Return(strings, err)
402- return _c
403-}
404-
405-func (_c *MockDomainSecretStore_ListKeys_Call) RunAndReturn(run func(domain string) ([]string, error)) *MockDomainSecretStore_ListKeys_Call {
406- _c.Call.Return(run)
407- return _c
408-}
409-
410-// Set provides a mock function for the type MockDomainSecretStore
411-func (_mock *MockDomainSecretStore) Set(domain string, secrets map[string]string) error {
412- ret := _mock.Called(domain, secrets)
413-
414- if len(ret) == 0 {
415- panic("no return value specified for Set")
416- }
417-
418- var r0 error
419- if returnFunc, ok := ret.Get(0).(func(string, map[string]string) error); ok {
420- r0 = returnFunc(domain, secrets)
421- } else {
422- r0 = ret.Error(0)
423- }
424- return r0
425-}
426-
427-// MockDomainSecretStore_Set_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Set'
428-type MockDomainSecretStore_Set_Call struct {
429- *mock.Call
430-}
431-
432-// Set is a helper method to define mock.On call
433-// - domain string
434-// - secrets map[string]string
435-func (_e *MockDomainSecretStore_Expecter) Set(domain any, secrets any) *MockDomainSecretStore_Set_Call {
436- return &MockDomainSecretStore_Set_Call{Call: _e.mock.On("Set", domain, secrets)}
437-}
438-
439-func (_c *MockDomainSecretStore_Set_Call) Run(run func(domain string, secrets map[string]string)) *MockDomainSecretStore_Set_Call {
440- _c.Call.Run(func(args mock.Arguments) {
441- var arg0 string
442- if args[0] != nil {
443- arg0 = args[0].(string)
444- }
445- var arg1 map[string]string
446- if args[1] != nil {
447- arg1 = args[1].(map[string]string)
448- }
449- run(
450- arg0,
451- arg1,
452- )
453- })
454- return _c
455-}
456-
457-func (_c *MockDomainSecretStore_Set_Call) Return(err error) *MockDomainSecretStore_Set_Call {
458- _c.Call.Return(err)
459- return _c
460-}
461-
462-func (_c *MockDomainSecretStore_Set_Call) RunAndReturn(run func(domain string, secrets map[string]string) error) *MockDomainSecretStore_Set_Call {
463- _c.Call.Return(run)
464- return _c
465-}
466-
467-// SetAttachment provides a mock function for the type MockDomainSecretStore
468-func (_mock *MockDomainSecretStore) SetAttachment(containerName string, secrets map[string]string) error {
469- ret := _mock.Called(containerName, secrets)
470-
471- if len(ret) == 0 {
472- panic("no return value specified for SetAttachment")
473- }
474-
475- var r0 error
476- if returnFunc, ok := ret.Get(0).(func(string, map[string]string) error); ok {
477- r0 = returnFunc(containerName, secrets)
478- } else {
479- r0 = ret.Error(0)
480- }
481- return r0
482-}
483-
484-// MockDomainSecretStore_SetAttachment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetAttachment'
485-type MockDomainSecretStore_SetAttachment_Call struct {
486- *mock.Call
487-}
488-
489-// SetAttachment is a helper method to define mock.On call
490-// - containerName string
491-// - secrets map[string]string
492-func (_e *MockDomainSecretStore_Expecter) SetAttachment(containerName any, secrets any) *MockDomainSecretStore_SetAttachment_Call {
493- return &MockDomainSecretStore_SetAttachment_Call{Call: _e.mock.On("SetAttachment", containerName, secrets)}
494-}
495-
496-func (_c *MockDomainSecretStore_SetAttachment_Call) Run(run func(containerName string, secrets map[string]string)) *MockDomainSecretStore_SetAttachment_Call {
497- _c.Call.Run(func(args mock.Arguments) {
498- var arg0 string
499- if args[0] != nil {
500- arg0 = args[0].(string)
501- }
502- var arg1 map[string]string
503- if args[1] != nil {
504- arg1 = args[1].(map[string]string)
505- }
506- run(
507- arg0,
508- arg1,
509- )
510- })
511- return _c
512-}
513-
514-func (_c *MockDomainSecretStore_SetAttachment_Call) Return(err error) *MockDomainSecretStore_SetAttachment_Call {
515- _c.Call.Return(err)
516- return _c
517-}
518-
519-func (_c *MockDomainSecretStore_SetAttachment_Call) RunAndReturn(run func(containerName string, secrets map[string]string) error) *MockDomainSecretStore_SetAttachment_Call {
520- _c.Call.Return(run)
521- return _c
522-}
D internal/boundaries/out/mocks/mock_env_loader.go +0 -232
@@ -1,232 +0,0 @@
1-// Code generated by mockery; DO NOT EDIT.
2-// github.com/vektra/mockery
3-// template: testify
4-
5-package mocks
6-
7-import (
8- "context"
9-
10- mock "github.com/stretchr/testify/mock"
11-)
12-
13-// NewMockEnvLoader creates a new instance of MockEnvLoader. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
14-// The first argument is typically a *testing.T value.
15-func NewMockEnvLoader(t interface {
16- mock.TestingT
17- Cleanup(func())
18-}) *MockEnvLoader {
19- if helper, ok := t.(interface{ Helper() }); ok {
20- helper.Helper()
21- }
22-
23- mock := &MockEnvLoader{}
24- mock.Mock.Test(t)
25-
26- t.Cleanup(func() {
27- if helper, ok := t.(interface{ Helper() }); ok {
28- helper.Helper()
29- }
30- mock.AssertExpectations(t)
31- })
32-
33- return mock
34-}
35-
36-// MockEnvLoader is an autogenerated mock type for the EnvLoader type
37-type MockEnvLoader struct {
38- mock.Mock
39-}
40-
41-type MockEnvLoader_Expecter struct {
42- mock *mock.Mock
43-}
44-
45-func (_m *MockEnvLoader) EXPECT() *MockEnvLoader_Expecter {
46- return &MockEnvLoader_Expecter{mock: &_m.Mock}
47-}
48-
49-// CreateEnvFile provides a mock function for the type MockEnvLoader
50-func (_mock *MockEnvLoader) CreateEnvFile(ctx context.Context, domain string) error {
51- ret := _mock.Called(ctx, domain)
52-
53- if len(ret) == 0 {
54- panic("no return value specified for CreateEnvFile")
55- }
56-
57- var r0 error
58- if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
59- r0 = returnFunc(ctx, domain)
60- } else {
61- r0 = ret.Error(0)
62- }
63- return r0
64-}
65-
66-// MockEnvLoader_CreateEnvFile_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateEnvFile'
67-type MockEnvLoader_CreateEnvFile_Call struct {
68- *mock.Call
69-}
70-
71-// CreateEnvFile is a helper method to define mock.On call
72-// - ctx context.Context
73-// - domain string
74-func (_e *MockEnvLoader_Expecter) CreateEnvFile(ctx any, domain any) *MockEnvLoader_CreateEnvFile_Call {
75- return &MockEnvLoader_CreateEnvFile_Call{Call: _e.mock.On("CreateEnvFile", ctx, domain)}
76-}
77-
78-func (_c *MockEnvLoader_CreateEnvFile_Call) Run(run func(ctx context.Context, domain string)) *MockEnvLoader_CreateEnvFile_Call {
79- _c.Call.Run(func(args mock.Arguments) {
80- var arg0 context.Context
81- if args[0] != nil {
82- arg0 = args[0].(context.Context)
83- }
84- var arg1 string
85- if args[1] != nil {
86- arg1 = args[1].(string)
87- }
88- run(
89- arg0,
90- arg1,
91- )
92- })
93- return _c
94-}
95-
96-func (_c *MockEnvLoader_CreateEnvFile_Call) Return(err error) *MockEnvLoader_CreateEnvFile_Call {
97- _c.Call.Return(err)
98- return _c
99-}
100-
101-func (_c *MockEnvLoader_CreateEnvFile_Call) RunAndReturn(run func(ctx context.Context, domain string) error) *MockEnvLoader_CreateEnvFile_Call {
102- _c.Call.Return(run)
103- return _c
104-}
105-
106-// EnvFileExists provides a mock function for the type MockEnvLoader
107-func (_mock *MockEnvLoader) EnvFileExists(domain string) (bool, error) {
108- ret := _mock.Called(domain)
109-
110- if len(ret) == 0 {
111- panic("no return value specified for EnvFileExists")
112- }
113-
114- var r0 bool
115- var r1 error
116- if returnFunc, ok := ret.Get(0).(func(string) (bool, error)); ok {
117- return returnFunc(domain)
118- }
119- if returnFunc, ok := ret.Get(0).(func(string) bool); ok {
120- r0 = returnFunc(domain)
121- } else {
122- r0 = ret.Get(0).(bool)
123- }
124- if returnFunc, ok := ret.Get(1).(func(string) error); ok {
125- r1 = returnFunc(domain)
126- } else {
127- r1 = ret.Error(1)
128- }
129- return r0, r1
130-}
131-
132-// MockEnvLoader_EnvFileExists_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnvFileExists'
133-type MockEnvLoader_EnvFileExists_Call struct {
134- *mock.Call
135-}
136-
137-// EnvFileExists is a helper method to define mock.On call
138-// - domain string
139-func (_e *MockEnvLoader_Expecter) EnvFileExists(domain any) *MockEnvLoader_EnvFileExists_Call {
140- return &MockEnvLoader_EnvFileExists_Call{Call: _e.mock.On("EnvFileExists", domain)}
141-}
142-
143-func (_c *MockEnvLoader_EnvFileExists_Call) Run(run func(domain string)) *MockEnvLoader_EnvFileExists_Call {
144- _c.Call.Run(func(args mock.Arguments) {
145- var arg0 string
146- if args[0] != nil {
147- arg0 = args[0].(string)
148- }
149- run(
150- arg0,
151- )
152- })
153- return _c
154-}
155-
156-func (_c *MockEnvLoader_EnvFileExists_Call) Return(b bool, err error) *MockEnvLoader_EnvFileExists_Call {
157- _c.Call.Return(b, err)
158- return _c
159-}
160-
161-func (_c *MockEnvLoader_EnvFileExists_Call) RunAndReturn(run func(domain string) (bool, error)) *MockEnvLoader_EnvFileExists_Call {
162- _c.Call.Return(run)
163- return _c
164-}
165-
166-// LoadEnv provides a mock function for the type MockEnvLoader
167-func (_mock *MockEnvLoader) LoadEnv(ctx context.Context, domain string) ([]string, error) {
168- ret := _mock.Called(ctx, domain)
169-
170- if len(ret) == 0 {
171- panic("no return value specified for LoadEnv")
172- }
173-
174- var r0 []string
175- var r1 error
176- if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]string, error)); ok {
177- return returnFunc(ctx, domain)
178- }
179- if returnFunc, ok := ret.Get(0).(func(context.Context, string) []string); ok {
180- r0 = returnFunc(ctx, domain)
181- } else {
182- if ret.Get(0) != nil {
183- r0 = ret.Get(0).([]string)
184- }
185- }
186- if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
187- r1 = returnFunc(ctx, domain)
188- } else {
189- r1 = ret.Error(1)
190- }
191- return r0, r1
192-}
193-
194-// MockEnvLoader_LoadEnv_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LoadEnv'
195-type MockEnvLoader_LoadEnv_Call struct {
196- *mock.Call
197-}
198-
199-// LoadEnv is a helper method to define mock.On call
200-// - ctx context.Context
201-// - domain string
202-func (_e *MockEnvLoader_Expecter) LoadEnv(ctx any, domain any) *MockEnvLoader_LoadEnv_Call {
203- return &MockEnvLoader_LoadEnv_Call{Call: _e.mock.On("LoadEnv", ctx, domain)}
204-}
205-
206-func (_c *MockEnvLoader_LoadEnv_Call) Run(run func(ctx context.Context, domain string)) *MockEnvLoader_LoadEnv_Call {
207- _c.Call.Run(func(args mock.Arguments) {
208- var arg0 context.Context
209- if args[0] != nil {
210- arg0 = args[0].(context.Context)
211- }
212- var arg1 string
213- if args[1] != nil {
214- arg1 = args[1].(string)
215- }
216- run(
217- arg0,
218- arg1,
219- )
220- })
221- return _c
222-}
223-
224-func (_c *MockEnvLoader_LoadEnv_Call) Return(strings []string, err error) *MockEnvLoader_LoadEnv_Call {
225- _c.Call.Return(strings, err)
226- return _c
227-}
228-
229-func (_c *MockEnvLoader_LoadEnv_Call) RunAndReturn(run func(ctx context.Context, domain string) ([]string, error)) *MockEnvLoader_LoadEnv_Call {
230- _c.Call.Return(run)
231- return _c
232-}
M internal/domain/auth.go +0 -6
@@ -77,7 +77,6 @@ const (
77 // Admin scope resource constants.77 // Admin scope resource constants.
78 const (78 const (
79 AdminResourceRoutes = "routes"79 AdminResourceRoutes = "routes"
80- AdminResourceSecrets = "secrets"
81 AdminResourceConfig = "config"80 AdminResourceConfig = "config"
82 AdminResourceStatus = "status"81 AdminResourceStatus = "status"
83 AdminResourceLogs = "logs"82 AdminResourceLogs = "logs"
@@ -287,11 +286,6 @@ func AdminScopeRoutes(actions ...string) string {
287 return fmt.Sprintf("%s:%s:%s", ScopeTypeAdmin, AdminResourceRoutes, strings.Join(actions, ","))286 return fmt.Sprintf("%s:%s:%s", ScopeTypeAdmin, AdminResourceRoutes, strings.Join(actions, ","))
288 }287 }
289 288
290-// AdminScopeSecrets creates an admin scope for secrets with the given actions.
291-func AdminScopeSecrets(actions ...string) string {
292- return fmt.Sprintf("%s:%s:%s", ScopeTypeAdmin, AdminResourceSecrets, strings.Join(actions, ","))
293-}
294-
295 // AdminScopeConfig creates an admin scope for config with the given actions.289 // AdminScopeConfig creates an admin scope for config with the given actions.
296 func AdminScopeConfig(actions ...string) string {290 func AdminScopeConfig(actions ...string) string {
297 return fmt.Sprintf("%s:%s:%s", ScopeTypeAdmin, AdminResourceConfig, strings.Join(actions, ","))291 return fmt.Sprintf("%s:%s:%s", ScopeTypeAdmin, AdminResourceConfig, strings.Join(actions, ","))
M internal/domain/env.go +2 -74
@@ -1,71 +1,15 @@
1 package domain1 package domain
2 2
3 import (3 import (
4- "bufio"
5- "bytes"
6 "regexp"4 "regexp"
7 "strings"5 "strings"
8 )6 )
9 7
10 var (8 var (
11- envKeyRegex = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)9+ envKeyRegex = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
12- containerNameRegex = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]*$`)10+ secretRefRegex = regexp.MustCompile(`\$\{(pass|sops):[^}]+\}`)
13- secretRefRegex = regexp.MustCompile(`\$\{(pass|sops):[^}]+\}`)
14 )11 )
15 12
16-// SanitizeDomainForEnvFile validates and sanitizes a domain name for env file storage.
17-// Returns the collision-resistant storage key used for filenames.
18-func SanitizeDomainForEnvFile(domainName string) (string, error) {
19- safeDomain, err := NewEnvStorageKey(domainName)
20- if err != nil {
21- return "", err
22- }
23-
24- return string(safeDomain), nil
25-}
26-
27-// ParseEnvData parses env data into a key-value map.
28-func ParseEnvData(data []byte) (map[string]string, error) {
29- secrets := make(map[string]string)
30- scanner := bufio.NewScanner(bytes.NewReader(data))
31- // Allow large env values (certs/keys) without hitting the default ~64KB limit.
32- scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
33- for scanner.Scan() {
34- line := strings.TrimSpace(scanner.Text())
35- if line == "" || strings.HasPrefix(line, "#") {
36- continue
37- }
38-
39- parts := strings.SplitN(line, "=", 2)
40- if len(parts) != 2 {
41- continue
42- }
43-
44- key := strings.TrimSpace(parts[0])
45- rawValue := parts[1]
46-
47- trimmedValue := strings.TrimSpace(rawValue)
48- value := trimmedValue
49-
50- if len(trimmedValue) >= 2 {
51- if (strings.HasPrefix(trimmedValue, "\"") && strings.HasSuffix(trimmedValue, "\"")) ||
52- (strings.HasPrefix(trimmedValue, "'") && strings.HasSuffix(trimmedValue, "'")) {
53- value = trimmedValue[1 : len(trimmedValue)-1]
54- }
55- }
56-
57- if key != "" {
58- secrets[key] = value
59- }
60- }
61-
62- if err := scanner.Err(); err != nil {
63- return nil, err
64- }
65-
66- return secrets, nil
67-}
68-
69 // ValidateEnvKey validates an env key for storage.13 // ValidateEnvKey validates an env key for storage.
70 func ValidateEnvKey(key string) error {14 func ValidateEnvKey(key string) error {
71 if key == "" {15 if key == "" {
@@ -87,19 +31,3 @@ func ValidateEnvKey(key string) error {
87 func ContainsSecretReference(value string) bool {31 func ContainsSecretReference(value string) bool {
88 return secretRefRegex.MatchString(value)32 return secretRefRegex.MatchString(value)
89 }33 }
90-
91-// ValidateContainerName validates a container name for attachment storage.
92-// Container names can contain alphanumeric characters, hyphens, and underscores,
93-// but must start with a letter.
94-func ValidateContainerName(name string) error {
95- if name == "" {
96- return ErrInvalidContainerName
97- }
98- if strings.Contains(name, "..") || strings.ContainsAny(name, "/\\") {
99- return ErrPathTraversal
100- }
101- if !containerNameRegex.MatchString(name) {
102- return ErrInvalidContainerName
103- }
104- return nil
105-}
M internal/domain/env_test.go +0 -176
@@ -1,147 +1,12 @@
1 package domain1 package domain
2 2
3 import (3 import (
4- "encoding/base64"
5- "strings"
6 "testing"4 "testing"
7 5
8 "github.com/stretchr/testify/assert"6 "github.com/stretchr/testify/assert"
9 "github.com/stretchr/testify/require"7 "github.com/stretchr/testify/require"
10 )8 )
11 9
12-func TestSanitizeDomainForEnvFile(t *testing.T) {
13- tests := []struct {
14- name string
15- domain string
16- want string
17- wantErr bool
18- }{
19- {name: "simple domain", domain: "example.com", want: base64.RawURLEncoding.EncodeToString([]byte("example.com"))},
20- {name: "subdomain", domain: "app.example.com", want: base64.RawURLEncoding.EncodeToString([]byte("app.example.com"))},
21- {name: "domain with port", domain: "example.com:8080", want: base64.RawURLEncoding.EncodeToString([]byte("example.com:8080"))},
22- {name: "domain with path", domain: "example.com/path", want: base64.RawURLEncoding.EncodeToString([]byte("example.com/path"))},
23- {name: "single char", domain: "a", want: base64.RawURLEncoding.EncodeToString([]byte("a"))},
24- {name: "hyphenated", domain: "my-app.example.com", want: base64.RawURLEncoding.EncodeToString([]byte("my-app.example.com"))},
25- {name: "empty", domain: "", wantErr: true},
26- {name: "path traversal", domain: "../etc/passwd", wantErr: true},
27- {name: "double dots", domain: "foo..bar", wantErr: true},
28- {name: "starts with dot", domain: ".hidden", wantErr: true},
29- {name: "ends with dot", domain: "trailing.", wantErr: true},
30- {name: "space", domain: "has space", wantErr: true},
31- {name: "special chars", domain: "bad$domain", wantErr: true},
32- {name: "underscore rejected as invalid domain character", domain: "example_com", wantErr: true},
33- }
34-
35- for _, tt := range tests {
36- t.Run(tt.name, func(t *testing.T) {
37- got, err := SanitizeDomainForEnvFile(tt.domain)
38- if tt.wantErr {
39- assert.Error(t, err)
40- return
41- }
42- require.NoError(t, err)
43- assert.Equal(t, tt.want, got)
44- })
45- }
46-}
47-
48-func TestSanitizeDomainForEnvFile_DistinguishesSeparators(t *testing.T) {
49- gotDot, err := SanitizeDomainForEnvFile("app.example.com")
50- require.NoError(t, err)
51-
52- gotPort, err := SanitizeDomainForEnvFile("app:example:com")
53- require.NoError(t, err)
54-
55- gotPath, err := SanitizeDomainForEnvFile("app/example/com")
56- require.NoError(t, err)
57-
58- assert.NotEqual(t, gotDot, gotPort)
59- assert.NotEqual(t, gotDot, gotPath)
60- assert.NotEqual(t, gotPort, gotPath)
61-}
62-
63-func TestParseEnvData(t *testing.T) {
64- tests := []struct {
65- name string
66- data string
67- want map[string]string
68- wantErr bool
69- }{
70- {
71- name: "simple key-value",
72- data: "FOO=bar\nBAZ=qux",
73- want: map[string]string{"FOO": "bar", "BAZ": "qux"},
74- },
75- {
76- name: "double-quoted value",
77- data: `KEY="hello world"`,
78- want: map[string]string{"KEY": "hello world"},
79- },
80- {
81- name: "single-quoted value",
82- data: `KEY='hello world'`,
83- want: map[string]string{"KEY": "hello world"},
84- },
85- {
86- name: "comment lines",
87- data: "# this is a comment\nKEY=val",
88- want: map[string]string{"KEY": "val"},
89- },
90- {
91- name: "empty lines",
92- data: "\n\nKEY=val\n\n",
93- want: map[string]string{"KEY": "val"},
94- },
95- {
96- name: "value with equals",
97- data: "URL=postgres://host:5432/db?opt=1",
98- want: map[string]string{"URL": "postgres://host:5432/db?opt=1"},
99- },
100- {
101- name: "no value",
102- data: "NOVALUE",
103- want: map[string]string{},
104- },
105- {
106- name: "empty value",
107- data: "KEY=",
108- want: map[string]string{"KEY": ""},
109- },
110- {
111- name: "empty input",
112- data: "",
113- want: map[string]string{},
114- },
115- {
116- name: "large value within buffer",
117- data: "BIG=" + strings.Repeat("x", 100000),
118- want: map[string]string{"BIG": strings.Repeat("x", 100000)},
119- },
120- {
121- name: "quoted value with inner spaces",
122- data: `KEY=" hello "`,
123- want: map[string]string{"KEY": " hello "},
124- },
125- {
126- name: "unquoted value with outer spaces",
127- data: "KEY= hello ",
128- want: map[string]string{"KEY": "hello"},
129- },
130- }
131-
132- for _, tt := range tests {
133- t.Run(tt.name, func(t *testing.T) {
134- got, err := ParseEnvData([]byte(tt.data))
135- if tt.wantErr {
136- assert.Error(t, err)
137- return
138- }
139- require.NoError(t, err)
140- assert.Equal(t, tt.want, got)
141- })
142- }
143-}
144-
145 func TestValidateEnvKey(t *testing.T) {10 func TestValidateEnvKey(t *testing.T) {
146 tests := []struct {11 tests := []struct {
147 name string12 name string
@@ -209,44 +74,3 @@ func TestContainsSecretReference(t *testing.T) {
209 })74 })
210 }75 }
211 }76 }
212-
213-func TestValidateContainerName(t *testing.T) {
214- tests := []struct {
215- name string
216- container string
217- wantErr bool
218- wantErrType error
219- }{
220- {name: "simple", container: "postgres", wantErr: false},
221- {name: "with hyphen", container: "gitea-postgres", wantErr: false},
222- {name: "with underscore", container: "gitea_postgres", wantErr: false},
223- {name: "complex real container", container: "gordon-git-example-com-gitea-postgres", wantErr: false},
224- {name: "starts with letter", container: "a-b", wantErr: false},
225- {name: "lowercase", container: "my-container", wantErr: false},
226- {name: "mixed case", container: "MyContainer-123", wantErr: false},
227- {name: "empty", container: "", wantErr: true, wantErrType: ErrInvalidContainerName},
228- {name: "starts with number", container: "1container", wantErr: true, wantErrType: ErrInvalidContainerName},
229- {name: "starts with hyphen", container: "-container", wantErr: true, wantErrType: ErrInvalidContainerName},
230- {name: "starts with underscore", container: "_container", wantErr: true, wantErrType: ErrInvalidContainerName},
231- {name: "contains slash", container: "container/name", wantErr: true, wantErrType: ErrPathTraversal},
232- {name: "contains backslash", container: `container\name`, wantErr: true, wantErrType: ErrPathTraversal},
233- {name: "path traversal", container: "..", wantErr: true, wantErrType: ErrPathTraversal},
234- {name: "contains dot", container: "container.name", wantErr: true, wantErrType: ErrInvalidContainerName},
235- {name: "contains space", container: "container name", wantErr: true, wantErrType: ErrInvalidContainerName},
236- {name: "contains special char", container: "container$name", wantErr: true, wantErrType: ErrInvalidContainerName},
237- }
238-
239- for _, tt := range tests {
240- t.Run(tt.name, func(t *testing.T) {
241- err := ValidateContainerName(tt.container)
242- if tt.wantErr {
243- require.Error(t, err)
244- if tt.wantErrType != nil {
245- assert.ErrorIs(t, err, tt.wantErrType)
246- }
247- } else {
248- assert.NoError(t, err)
249- }
250- })
251- }
252-}
D internal/domain/envfile.go +0 -47
@@ -1,47 +0,0 @@
1-package domain
2-
3-import (
4- "encoding/base64"
5- "regexp"
6- "strings"
7-)
8-
9-// envDomainRegex validates domain names used for env-backed secret storage.
10-// Allows: alphanumeric, dots, hyphens, colons (for ports), and forward slashes (for paths).
11-var envDomainRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9.:/-]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$`)
12-
13-// EnvStorageKey is the domain-level storage identifier used for env-backed secrets.
14-// It is deterministic, collision-resistant, and safe to use in file names.
15-type EnvStorageKey string
16-
17-// NewEnvStorageKey validates a route domain and returns the storage-safe identifier
18-// used by env/secrets adapters. The identifier is reversible and unique for the input.
19-func NewEnvStorageKey(domainName string) (EnvStorageKey, error) {
20- if err := ValidateEnvStorageDomain(domainName); err != nil {
21- return "", err
22- }
23-
24- return EnvStorageKey(base64.RawURLEncoding.EncodeToString([]byte(domainName))), nil
25-}
26-
27-// ValidateEnvStorageDomain validates domains used for env-backed secret storage.
28-func ValidateEnvStorageDomain(domainName string) error {
29- if domainName == "" {
30- return ErrPathTraversal
31- }
32-
33- if strings.Contains(domainName, "..") {
34- return ErrPathTraversal
35- }
36-
37- if !envDomainRegex.MatchString(domainName) {
38- return ErrPathTraversal
39- }
40-
41- return nil
42-}
43-
44-// FileName returns the on-disk env filename for this storage key.
45-func (k EnvStorageKey) FileName() string {
46- return string(k) + ".env"
47-}
M internal/domain/errors.go +0 -1
@@ -117,7 +117,6 @@ var (
117 ErrSecretNotFound = errors.New("secret not found")117 ErrSecretNotFound = errors.New("secret not found")
118 ErrSecretsAlreadyExist = errors.New("secrets already exist")118 ErrSecretsAlreadyExist = errors.New("secrets already exist")
119 ErrProviderNotFound = errors.New("secret provider not found")119 ErrProviderNotFound = errors.New("secret provider not found")
120- ErrInvalidContainerName = errors.New("invalid container name")
121 ErrAttachmentOwnershipMismatch = errors.New("attachment ownership mismatch")120 ErrAttachmentOwnershipMismatch = errors.New("attachment ownership mismatch")
122 ErrReadinessLogSizeExceeded = errors.New("readiness log exceeds maximum")121 ErrReadinessLogSizeExceeded = errors.New("readiness log exceeds maximum")
123 122
M internal/domain/event.go +3 -11
@@ -9,10 +9,9 @@ import (
9 type EventType string9 type EventType string
10 10
11 const (11 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- EventSecretsChanged EventType = "secrets.changed"
16 )15 )
17 16
18 // Event represents a domain event that occurred in the system.17 // Event represents a domain event that occurred in the system.
@@ -43,13 +42,6 @@ type ConfigReloadPayload struct {
43 UpdatedRoutes []string42 UpdatedRoutes []string
44 }43 }
45 44
46-// SecretsChangedPayload contains data for secrets.changed events.
47-type SecretsChangedPayload struct {
48- Domain string // Route domain whose secrets changed
49- Operation string // "set" or "delete"
50- Keys []string // Secret key names (not values)
51-}
52-
53 // Context keys for domain-level concerns.45 // Context keys for domain-level concerns.
54 type contextKey string46 type contextKey string
55 47
M internal/domain/route.go +1 -1
@@ -5,7 +5,7 @@ type Route struct {
5 Domain string5 Domain string
6 Image string6 Image string
7 HTTPS bool7 HTTPS bool
8- Env []string // Pre-resolved env vars ("KEY=VALUE"); when set, Deploy skips EnvLoader lookup.8+ Env []string // Pre-resolved env vars ("KEY=VALUE").
9 }9 }
10 10
11 // ProxyTarget represents the destination for proxying requests.11 // ProxyTarget represents the destination for proxying requests.
M internal/testutils/fixtures/configs/basic.toml +0 -4
@@ -18,10 +18,6 @@ auto_create = true
18 prefix = "gordon"18 prefix = "gordon"
19 preserve = true19 preserve = true
20 20
21-[env]
22-dir = "/tmp/env"
23-providers = ["pass", "sops"]
24-
25 [logging]21 [logging]
26 enabled = true22 enabled = true
27 level = "info"⏎̸23 level = "info"⏎̸
M internal/testutils/helpers.go +0 -4
@@ -59,10 +59,6 @@ auto_create = true
59 prefix = "gordon"59 prefix = "gordon"
60 preserve = true60 preserve = true
61 61
62-[env]
63-dir = "/tmp/env"
64-providers = ["pass", "sops"]
65-
66 [logging]62 [logging]
67 enabled = true63 enabled = true
68 level = "info"`64 level = "info"`
M internal/usecase/container/service.go +0 -3
@@ -29,7 +29,6 @@ type Config struct {
29 // Service implements the ContainerService interface.29 // Service implements the ContainerService interface.
30 type Service struct {30 type Service struct {
31 runtime out.ContainerRuntime31 runtime out.ContainerRuntime
32- envLoader out.EnvLoader
33 eventBus out.EventPublisher32 eventBus out.EventPublisher
34 logWriter out.ContainerLogWriter33 logWriter out.ContainerLogWriter
35 config Config34 config Config
@@ -39,14 +38,12 @@ type Service struct {
39 // NewService creates a new container service.38 // NewService creates a new container service.
40 func NewService(39 func NewService(
41 runtime out.ContainerRuntime,40 runtime out.ContainerRuntime,
42- envLoader out.EnvLoader,
43 eventBus out.EventPublisher,41 eventBus out.EventPublisher,
44 logWriter out.ContainerLogWriter,42 logWriter out.ContainerLogWriter,
45 config Config,43 config Config,
46 ) *Service {44 ) *Service {
47 return &Service{45 return &Service{
48 runtime: runtime,46 runtime: runtime,
49- envLoader: envLoader,
50 eventBus: eventBus,47 eventBus: eventBus,
51 logWriter: logWriter,48 logWriter: logWriter,
52 config: config,49 config: config,
M internal/usecase/container/service_test.go +1 -1
@@ -18,7 +18,7 @@ func testContext() context.Context {
18 18
19 func TestService_ListNetworks(t *testing.T) {19 func TestService_ListNetworks(t *testing.T) {
20 runtime := mocks.NewMockContainerRuntime(t)20 runtime := mocks.NewMockContainerRuntime(t)
21- svc := NewService(runtime, nil, nil, nil, Config{NetworkPrefix: "gordon"})21+ svc := NewService(runtime, nil, nil, Config{NetworkPrefix: "gordon"})
22 ctx := testContext()22 ctx := testContext()
23 23
24 runtime.EXPECT().ListNetworks(mock.Anything).Return([]*domain.NetworkInfo{24 runtime.EXPECT().ListNetworks(mock.Anything).Return([]*domain.NetworkInfo{
D internal/usecase/secrets/service.go +0 -197
@@ -1,197 +0,0 @@
1-// Package secrets implements the secret management use case.
2-package secrets
3-
4-import (
5- "context"
6- "errors"
7- "sort"
8- "strings"
9-
10- "github.com/bnema/zerowrap"
11-
12- "github.com/bnema/gordon/internal/boundaries/out"
13- "github.com/bnema/gordon/internal/domain"
14-)
15-
16-// Domain validation errors.
17-var (
18- ErrDomainEmpty = errors.New("domain cannot be empty")
19- ErrDomainTooLong = errors.New("domain exceeds maximum length of 253 characters")
20- ErrDomainPathTraversal = errors.New("domain contains path traversal sequence")
21- ErrDomainInvalidChars = errors.New("domain contains invalid characters")
22-)
23-
24-// Service implements the SecretService interface.
25-type Service struct {
26- store out.DomainSecretStore
27- log zerowrap.Logger
28- eventBus out.EventPublisher
29-}
30-
31-// NewService creates a new secrets service.
32-func NewService(store out.DomainSecretStore, log zerowrap.Logger, eventBus out.EventPublisher) *Service {
33- return &Service{
34- store: store,
35- log: log,
36- eventBus: eventBus,
37- }
38-}
39-
40-// publishSecretsChanged publishes a secrets.changed event.
41-// Failures are logged but do not fail the mutation - secret storage
42-// is the source of truth; the event is a best-effort notification.
43-func (s *Service) publishSecretsChanged(ctx context.Context, domainName, operation string, keys []string) {
44- if s.eventBus == nil {
45- return
46- }
47- log := zerowrap.FromCtx(ctx)
48- payload := domain.SecretsChangedPayload{
49- Domain: domainName,
50- Operation: operation,
51- Keys: keys,
52- }
53- if err := s.eventBus.Publish(domain.EventSecretsChanged, payload); err != nil {
54- log.Warn().Err(err).Str("domain", domainName).Msg("failed to publish secrets.changed event")
55- }
56-}
57-
58-// sortedKeys returns the keys of a map in sorted order.
59-func sortedKeys(m map[string]string) []string {
60- keys := make([]string, 0, len(m))
61- for k := range m {
62- keys = append(keys, k)
63- }
64- sort.Strings(keys)
65- return keys
66-}
67-
68-// ListKeys returns the list of secret keys for a domain (not values).
69-func (s *Service) ListKeys(ctx context.Context, domain string) ([]string, error) {
70- ctx = zerowrap.CtxWithFields(ctx, map[string]any{
71- zerowrap.FieldLayer: "usecase",
72- zerowrap.FieldUseCase: "ListKeys",
73- "domain": domain,
74- })
75- log := zerowrap.FromCtx(ctx)
76-
77- if err := ValidateDomain(domain); err != nil {
78- log.Warn().Err(err).Msg("domain validation failed")
79- return nil, err
80- }
81-
82- keys, err := s.store.ListKeys(domain)
83- if err != nil {
84- log.Error().Err(err).Msg("failed to list secret keys")
85- return nil, err
86- }
87-
88- log.Debug().Int("count", len(keys)).Msg("listed secret keys")
89- return keys, nil
90-}
91-
92-// GetAll returns all secrets for a domain as a key-value map.
93-func (s *Service) GetAll(ctx context.Context, domain string) (map[string]string, error) {
94- ctx = zerowrap.CtxWithFields(ctx, map[string]any{
95- zerowrap.FieldLayer: "usecase",
96- zerowrap.FieldUseCase: "GetAll",
97- "domain": domain,
98- })
99- log := zerowrap.FromCtx(ctx)
100-
101- if err := ValidateDomain(domain); err != nil {
102- log.Warn().Err(err).Msg("domain validation failed")
103- return nil, err
104- }
105-
106- secrets, err := s.store.GetAll(domain)
107- if err != nil {
108- log.Error().Err(err).Msg("failed to get secrets")
109- return nil, err
110- }
111-
112- log.Debug().Int("count", len(secrets)).Msg("retrieved secrets")
113- return secrets, nil
114-}
115-
116-// Set sets or updates multiple secrets for a domain, merging with existing.
117-func (s *Service) Set(ctx context.Context, domain string, secrets map[string]string) error {
118- ctx = zerowrap.CtxWithFields(ctx, map[string]any{
119- zerowrap.FieldLayer: "usecase",
120- zerowrap.FieldUseCase: "Set",
121- "domain": domain,
122- })
123- log := zerowrap.FromCtx(ctx)
124-
125- if err := ValidateDomain(domain); err != nil {
126- log.Warn().Err(err).Msg("domain validation failed")
127- return err
128- }
129-
130- if err := s.store.Set(domain, secrets); err != nil {
131- log.Error().Err(err).Msg("failed to set secrets")
132- return err
133- }
134-
135- s.publishSecretsChanged(ctx, domain, "set", sortedKeys(secrets))
136-
137- log.Info().Int("count", len(secrets)).Msg("secrets set")
138- return nil
139-}
140-
141-// Delete removes a specific secret key from a domain.
142-func (s *Service) Delete(ctx context.Context, domain, key string) error {
143- ctx = zerowrap.CtxWithFields(ctx, map[string]any{
144- zerowrap.FieldLayer: "usecase",
145- zerowrap.FieldUseCase: "Delete",
146- "domain": domain,
147- "key": key,
148- })
149- log := zerowrap.FromCtx(ctx)
150-
151- if err := ValidateDomain(domain); err != nil {
152- log.Warn().Err(err).Msg("domain validation failed")
153- return err
154- }
155-
156- if err := s.store.Delete(domain, key); err != nil {
157- log.Error().Err(err).Msg("failed to delete secret")
158- return err
159- }
160-
161- s.publishSecretsChanged(ctx, domain, "delete", []string{key})
162-
163- log.Info().Msg("secret deleted")
164- return nil
165-}
166-
167-// ValidateDomain validates that a domain is safe to use for secret storage.
168-// This prevents path traversal attacks and ensures the domain is valid.
169-func ValidateDomain(domainName string) error {
170- // Check for empty domain
171- if domainName == "" {
172- return ErrDomainEmpty
173- }
174-
175- // Check domain length (max DNS name length is 253)
176- if len(domainName) > 253 {
177- return ErrDomainTooLong
178- }
179-
180- // Check for path traversal attempts
181- if strings.Contains(domainName, "..") {
182- return ErrDomainPathTraversal
183- }
184-
185- // Check for null bytes (can cause issues in file paths)
186- if strings.ContainsRune(domainName, '\x00') {
187- return ErrDomainInvalidChars
188- }
189-
190- // Reuse the env-file sanitizer so validation matches the on-disk naming
191- // scheme and rejects ambiguous names that would collide after sanitization.
192- if _, err := domain.SanitizeDomainForEnvFile(domainName); err != nil {
193- return ErrDomainInvalidChars
194- }
195-
196- return nil
197-}
D internal/usecase/secrets/service_test.go +0 -435
@@ -1,435 +0,0 @@
1-package secrets
2-
3-import (
4- "context"
5- "strings"
6- "testing"
7-
8- "github.com/bnema/zerowrap"
9- "github.com/stretchr/testify/assert"
10- "github.com/stretchr/testify/mock"
11-
12- outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
13- "github.com/bnema/gordon/internal/domain"
14-)
15-
16-func testLogger() zerowrap.Logger {
17- return zerowrap.Default()
18-}
19-
20-func TestValidateDomain(t *testing.T) {
21- tests := []struct {
22- name string
23- domain string
24- wantErr error
25- }{
26- // Valid domains
27- {
28- name: "valid simple domain",
29- domain: "example.com",
30- wantErr: nil,
31- },
32- {
33- name: "valid subdomain",
34- domain: "app.example.com",
35- wantErr: nil,
36- },
37- {
38- name: "valid domain with port",
39- domain: "app.example.com:8080",
40- wantErr: nil,
41- },
42- {
43- name: "valid domain with hyphens",
44- domain: "my-app.example-site.com",
45- wantErr: nil,
46- },
47- {
48- name: "valid single label",
49- domain: "localhost",
50- wantErr: nil,
51- },
52- // Empty domain
53- {
54- name: "empty domain",
55- domain: "",
56- wantErr: ErrDomainEmpty,
57- },
58- // Path traversal attempts
59- {
60- name: "path traversal with double dots",
61- domain: "../etc/passwd",
62- wantErr: ErrDomainPathTraversal,
63- },
64- {
65- name: "path traversal in middle",
66- domain: "app/../etc/passwd",
67- wantErr: ErrDomainPathTraversal,
68- },
69- {
70- name: "path traversal at end",
71- domain: "app.example.com/..",
72- wantErr: ErrDomainPathTraversal,
73- },
74- {
75- name: "multiple path traversal",
76- domain: "..../....//etc/passwd",
77- wantErr: ErrDomainPathTraversal,
78- },
79- {
80- name: "encoded path traversal still blocked",
81- domain: "app..example.com",
82- wantErr: ErrDomainPathTraversal,
83- },
84- // Domain too long
85- {
86- name: "domain at max length (253)",
87- domain: strings.Repeat("a", 253),
88- wantErr: nil,
89- },
90- {
91- name: "domain exceeds max length",
92- domain: strings.Repeat("a", 254),
93- wantErr: ErrDomainTooLong,
94- },
95- {
96- name: "domain way too long",
97- domain: strings.Repeat("a", 1000),
98- wantErr: ErrDomainTooLong,
99- },
100- // Invalid characters
101- {
102- name: "null byte in domain",
103- domain: "app\x00.example.com",
104- wantErr: ErrDomainInvalidChars,
105- },
106- {
107- name: "null byte at start",
108- domain: "\x00example.com",
109- wantErr: ErrDomainInvalidChars,
110- },
111- {
112- name: "underscore rejected to avoid env filename collision",
113- domain: "app_example.com",
114- wantErr: ErrDomainInvalidChars,
115- },
116- }
117-
118- for _, tt := range tests {
119- t.Run(tt.name, func(t *testing.T) {
120- err := ValidateDomain(tt.domain)
121- if tt.wantErr != nil {
122- assert.ErrorIs(t, err, tt.wantErr)
123- } else {
124- assert.NoError(t, err)
125- }
126- })
127- }
128-}
129-
130-func TestValidateDomain_DistinguishesSeparatorsForEnvStorage(t *testing.T) {
131- for _, domain := range []string{"app.example.com", "app:example:com", "app/example/com"} {
132- assert.NoError(t, ValidateDomain(domain), "expected %q to remain valid", domain)
133- }
134-}
135-
136-func TestService_ListKeys_ValidationErrors(t *testing.T) {
137- store := outmocks.NewMockDomainSecretStore(t)
138- svc := NewService(store, testLogger(), nil)
139-
140- tests := []struct {
141- name string
142- domain string
143- wantErr error
144- }{
145- {
146- name: "empty domain",
147- domain: "",
148- wantErr: ErrDomainEmpty,
149- },
150- {
151- name: "path traversal",
152- domain: "../etc/passwd",
153- wantErr: ErrDomainPathTraversal,
154- },
155- {
156- name: "domain too long",
157- domain: strings.Repeat("a", 300),
158- wantErr: ErrDomainTooLong,
159- },
160- }
161-
162- for _, tt := range tests {
163- t.Run(tt.name, func(t *testing.T) {
164- keys, err := svc.ListKeys(context.Background(), tt.domain)
165- assert.Nil(t, keys)
166- assert.ErrorIs(t, err, tt.wantErr)
167- })
168- }
169-}
170-
171-func TestService_ListKeys_Success(t *testing.T) {
172- store := outmocks.NewMockDomainSecretStore(t)
173- svc := NewService(store, testLogger(), nil)
174-
175- expectedKeys := []string{"API_KEY", "DB_PASSWORD"}
176- store.EXPECT().ListKeys("app.example.com").Return(expectedKeys, nil)
177-
178- keys, err := svc.ListKeys(context.Background(), "app.example.com")
179- assert.NoError(t, err)
180- assert.Equal(t, expectedKeys, keys)
181-}
182-
183-func TestService_GetAll_ValidationErrors(t *testing.T) {
184- store := outmocks.NewMockDomainSecretStore(t)
185- svc := NewService(store, testLogger(), nil)
186-
187- tests := []struct {
188- name string
189- domain string
190- wantErr error
191- }{
192- {
193- name: "empty domain",
194- domain: "",
195- wantErr: ErrDomainEmpty,
196- },
197- {
198- name: "path traversal",
199- domain: "app/../secret",
200- wantErr: ErrDomainPathTraversal,
201- },
202- }
203-
204- for _, tt := range tests {
205- t.Run(tt.name, func(t *testing.T) {
206- secrets, err := svc.GetAll(context.Background(), tt.domain)
207- assert.Nil(t, secrets)
208- assert.ErrorIs(t, err, tt.wantErr)
209- })
210- }
211-}
212-
213-func TestService_GetAll_Success(t *testing.T) {
214- store := outmocks.NewMockDomainSecretStore(t)
215- svc := NewService(store, testLogger(), nil)
216-
217- expectedSecrets := map[string]string{"API_KEY": "secret123", "DB_PASSWORD": "pass456"}
218- store.EXPECT().GetAll("app.example.com").Return(expectedSecrets, nil)
219-
220- secrets, err := svc.GetAll(context.Background(), "app.example.com")
221- assert.NoError(t, err)
222- assert.Equal(t, expectedSecrets, secrets)
223-}
224-
225-func TestService_Set_ValidationErrors(t *testing.T) {
226- store := outmocks.NewMockDomainSecretStore(t)
227- svc := NewService(store, testLogger(), nil)
228-
229- secrets := map[string]string{"API_KEY": "secret123"}
230-
231- tests := []struct {
232- name string
233- domain string
234- wantErr error
235- }{
236- {
237- name: "empty domain",
238- domain: "",
239- wantErr: ErrDomainEmpty,
240- },
241- {
242- name: "path traversal",
243- domain: "../../../etc/passwd",
244- wantErr: ErrDomainPathTraversal,
245- },
246- }
247-
248- for _, tt := range tests {
249- t.Run(tt.name, func(t *testing.T) {
250- err := svc.Set(context.Background(), tt.domain, secrets)
251- assert.ErrorIs(t, err, tt.wantErr)
252- })
253- }
254-}
255-
256-func TestService_Set_Success(t *testing.T) {
257- store := outmocks.NewMockDomainSecretStore(t)
258- eventBus := outmocks.NewMockEventPublisher(t)
259- svc := NewService(store, testLogger(), eventBus)
260-
261- secrets := map[string]string{"API_KEY": "secret123"}
262- store.EXPECT().Set("app.example.com", secrets).Return(nil)
263- eventBus.EXPECT().Publish(domain.EventSecretsChanged, mock.AnythingOfType("domain.SecretsChangedPayload")).Return(nil)
264-
265- err := svc.Set(context.Background(), "app.example.com", secrets)
266- assert.NoError(t, err)
267-}
268-
269-func TestService_Set_PublishesSecretsChangedEvent(t *testing.T) {
270- store := outmocks.NewMockDomainSecretStore(t)
271- eventBus := outmocks.NewMockEventPublisher(t)
272- svc := NewService(store, testLogger(), eventBus)
273-
274- secrets := map[string]string{"API_KEY": "secret123", "DB_PASSWORD": "pass456"}
275- store.EXPECT().Set("app.example.com", secrets).Return(nil)
276- eventBus.EXPECT().Publish(domain.EventSecretsChanged, mock.MatchedBy(func(payload domain.SecretsChangedPayload) bool {
277- assert.Equal(t, "app.example.com", payload.Domain)
278- assert.Equal(t, "set", payload.Operation)
279- assert.ElementsMatch(t, []string{"API_KEY", "DB_PASSWORD"}, payload.Keys)
280- return true
281- })).Return(nil)
282-
283- err := svc.Set(context.Background(), "app.example.com", secrets)
284- assert.NoError(t, err)
285-}
286-
287-func TestService_Delete_ValidationErrors(t *testing.T) {
288- store := outmocks.NewMockDomainSecretStore(t)
289- svc := NewService(store, testLogger(), nil)
290-
291- tests := []struct {
292- name string
293- domain string
294- wantErr error
295- }{
296- {
297- name: "empty domain",
298- domain: "",
299- wantErr: ErrDomainEmpty,
300- },
301- {
302- name: "path traversal",
303- domain: "..%2F..%2Fetc%2Fpasswd",
304- wantErr: ErrDomainPathTraversal,
305- },
306- }
307-
308- for _, tt := range tests {
309- t.Run(tt.name, func(t *testing.T) {
310- err := svc.Delete(context.Background(), tt.domain, "API_KEY")
311- assert.ErrorIs(t, err, tt.wantErr)
312- })
313- }
314-}
315-
316-func TestService_Delete_Success(t *testing.T) {
317- store := outmocks.NewMockDomainSecretStore(t)
318- eventBus := outmocks.NewMockEventPublisher(t)
319- svc := NewService(store, testLogger(), eventBus)
320-
321- store.EXPECT().Delete("app.example.com", "API_KEY").Return(nil)
322- eventBus.EXPECT().Publish(domain.EventSecretsChanged, mock.AnythingOfType("domain.SecretsChangedPayload")).Return(nil)
323-
324- err := svc.Delete(context.Background(), "app.example.com", "API_KEY")
325- assert.NoError(t, err)
326-}
327-
328-func TestService_Delete_PublishesSecretsChangedEvent(t *testing.T) {
329- store := outmocks.NewMockDomainSecretStore(t)
330- eventBus := outmocks.NewMockEventPublisher(t)
331- svc := NewService(store, testLogger(), eventBus)
332-
333- store.EXPECT().Delete("app.example.com", "API_KEY").Return(nil)
334- eventBus.EXPECT().Publish(domain.EventSecretsChanged, mock.MatchedBy(func(payload domain.SecretsChangedPayload) bool {
335- assert.Equal(t, "app.example.com", payload.Domain)
336- assert.Equal(t, "delete", payload.Operation)
337- assert.Equal(t, []string{"API_KEY"}, payload.Keys)
338- return true
339- })).Return(nil)
340-
341- err := svc.Delete(context.Background(), "app.example.com", "API_KEY")
342- assert.NoError(t, err)
343-}
344-
345-func TestService_Set_SucceedsEvenIfEventPublishFails(t *testing.T) {
346- store := outmocks.NewMockDomainSecretStore(t)
347- eventBus := outmocks.NewMockEventPublisher(t)
348- svc := NewService(store, testLogger(), eventBus)
349-
350- secrets := map[string]string{"API_KEY": "secret123"}
351- store.EXPECT().Set("app.example.com", secrets).Return(nil)
352- eventBus.EXPECT().Publish(domain.EventSecretsChanged, mock.AnythingOfType("domain.SecretsChangedPayload")).Return(assert.AnError)
353-
354- err := svc.Set(context.Background(), "app.example.com", secrets)
355- assert.NoError(t, err)
356-}
357-
358-func TestService_NilEventPublisher_DoesNotPanic(t *testing.T) {
359- store := outmocks.NewMockDomainSecretStore(t)
360- svc := NewService(store, testLogger(), nil)
361-
362- secrets := map[string]string{"API_KEY": "secret123"}
363- store.EXPECT().Set("app.example.com", secrets).Return(nil)
364-
365- assert.NotPanics(t, func() {
366- err := svc.Set(context.Background(), "app.example.com", secrets)
367- assert.NoError(t, err)
368- })
369-}
370-
371-func TestService_StoreError_PropagatedCorrectly(t *testing.T) {
372- store := outmocks.NewMockDomainSecretStore(t)
373- svc := NewService(store, testLogger(), nil)
374-
375- storeErr := assert.AnError
376- store.EXPECT().ListKeys("app.example.com").Return(nil, storeErr)
377-
378- keys, err := svc.ListKeys(context.Background(), "app.example.com")
379- assert.Nil(t, keys)
380- assert.ErrorIs(t, err, storeErr)
381-}
382-
383-// TestPathTraversalVariants tests various path traversal attack patterns.
384-func TestPathTraversalVariants(t *testing.T) {
385- tests := []struct {
386- name string
387- domain string
388- }{
389- {"simple parent dir", ".."},
390- {"parent dir prefix", "../secret"},
391- {"parent dir suffix", "secret/.."},
392- {"parent dir in middle", "app/../secret"},
393- {"multiple parent dirs", "../../etc/passwd"},
394- {"windows style", "..\\..\\etc\\passwd"},
395- {"mixed slashes with dots", "app/./../../etc"},
396- {"double dots only", "...."},
397- {"triple dots", "..."},
398- {"dot dot in subdomain style", "app..evil.com"},
399- }
400-
401- for _, tt := range tests {
402- t.Run(tt.name, func(t *testing.T) {
403- err := ValidateDomain(tt.domain)
404- // All these should be rejected (either path traversal or other validation)
405- // The main goal is that ".." patterns are caught
406- if strings.Contains(tt.domain, "..") {
407- assert.ErrorIs(t, err, ErrDomainPathTraversal, "domain %q should be rejected for path traversal", tt.domain)
408- }
409- })
410- }
411-}
412-
413-// TestService_ValidationHappensBeforeStore ensures validation runs before any store calls.
414-func TestService_ValidationHappensBeforeStore(t *testing.T) {
415- store := outmocks.NewMockDomainSecretStore(t)
416- svc := NewService(store, testLogger(), nil)
417-
418- // With path traversal domain, store methods should never be called
419- // We don't set up any expectations, so if store is called, the test will fail
420-
421- _, err := svc.ListKeys(context.Background(), "../etc/passwd")
422- assert.ErrorIs(t, err, ErrDomainPathTraversal)
423-
424- _, err = svc.GetAll(context.Background(), "../etc/passwd")
425- assert.ErrorIs(t, err, ErrDomainPathTraversal)
426-
427- err = svc.Set(context.Background(), "../etc/passwd", map[string]string{"key": "value"})
428- assert.ErrorIs(t, err, ErrDomainPathTraversal)
429-
430- err = svc.Delete(context.Background(), "../etc/passwd", "key")
431- assert.ErrorIs(t, err, ErrDomainPathTraversal)
432-
433- // Verify no store methods were called (mockery would fail if unexpected calls happened)
434- mock.AssertExpectationsForObjects(t, store)
435-}
M wiki/examples/production.md +7 -23
@@ -40,10 +40,6 @@ max_age = 90
40 # Workload logs are read from the container runtime with40 # Workload logs are read from the container runtime with
41 # `gordon apps logs APP --service SERVICE`.41 # `gordon apps logs APP --service SERVICE`.
42 42
43-# Environment directory
44-[env]
45-dir = "~/.gordon/env"
46-
47 # Volume settings (all enabled by default)43 # Volume settings (all enabled by default)
48 [volumes]44 [volumes]
49 auto_create = true45 auto_create = true
@@ -82,26 +78,14 @@ openssl rand -base64 32 | pass insert -m gordon/auth/token_secret
82 gordon auth token generate --subject ci-bot --scopes push,pull --expiry 078 gordon auth token generate --subject ci-bot --scopes push,pull --expiry 0
83 ```79 ```
84 80
85-### 4. Create Environment Files81+### 4. Set App Secrets
82+
83+Declare public values under `[env]` and secret names under `[services.<name>.secrets]` in each app file, apply it, then set the values:
86 84
87 ```bash85 ```bash
88-# App environment86+gordon apps apply --file ./app.toml
89-cat > ~/.gordon/env/app_company_com.env <<EOF87+pass show company/db-password \
90-NODE_ENV=production88+ | gordon apps secrets set app --service web --stdin --key DATABASE_PASSWORD
91-PORT=3000
92-DATABASE_URL=postgresql://company-postgres:5432/app
93-DATABASE_PASSWORD=\${pass:company/db-password}
94-REDIS_URL=redis://company-redis:6379
95-EOF
96-
97-# API environment
98-cat > ~/.gordon/env/api_company_com.env <<EOF
99-NODE_ENV=production
100-PORT=8080
101-DATABASE_URL=postgresql://company-postgres:5432/api
102-DATABASE_PASSWORD=\${pass:company/db-password}
103-REDIS_URL=redis://company-redis:6379
104-EOF
105 ```89 ```
106 90
107 ### 5. Configure Cloudflare91 ### 5. Configure Cloudflare
@@ -148,7 +132,7 @@ vim ~/.config/gordon/gordon.toml
148 # Change: "app.company.com" = "company-app:v2.2.0"132 # Change: "app.company.com" = "company-app:v2.2.0"
149 133
150 # Reload to deploy134 # Reload to deploy
151-gordon reload135+gordon daemon reload
152 ```136 ```
153 137
154 ## Related138 ## Related
M wiki/guides/remote-cli.md +3 -3
@@ -9,7 +9,7 @@ Store the server URL and credentials with Gordon's remote configuration commands
9 Verify connectivity:9 Verify connectivity:
10 10
11 ```bash11 ```bash
12-gordon status --remote https://gordon.example.com --token "$GORDON_TOKEN"12+gordon daemon status --remote https://gordon.example.com --token "$GORDON_TOKEN"
13 ```13 ```
14 14
15 ## App management15 ## App management
@@ -46,10 +46,10 @@ gordon backups volume status
46 46
47 ## Logs47 ## Logs
48 48
49-`gordon logs` streams daemon process logs. Workload logs use the app command:49+`gordon daemon logs` streams daemon process logs. Workload logs use the app command:
50 50
51 ```bash51 ```bash
52-gordon logs --remote https://gordon.example.com --token "$GORDON_TOKEN"52+gordon daemon logs --remote https://gordon.example.com --token "$GORDON_TOKEN"
53 gordon apps logs APP --service SERVICE --remote https://gordon.example.com --token "$GORDON_TOKEN"53 gordon apps logs APP --service SERVICE --remote https://gordon.example.com --token "$GORDON_TOKEN"
54 ```54 ```
55 55
M wiki/guides/secrets-pass.md +6 -6
@@ -106,17 +106,17 @@ token_secret = "gordon/auth/token_secret"
106 # password_hash = "gordon/auth/password_hash"106 # password_hash = "gordon/auth/password_hash"
107 ```107 ```
108 108
109-### Using Route Secrets109+### Using App Secrets
110 110
111-With the pass backend, per-domain secrets are stored in pass, not `.env` files.111+Declare secret names in the app file under `[services.<name>.secrets]`, apply it, then set the values. Values are stored in pass under `gordon/apps/<uuid>/<service>/<name>`.
112 112
113 ```bash113 ```bash
114-# Store secrets for a domain114+gordon apps apply --file ./blog.toml
115-gordon secrets set app.mydomain.com DATABASE_URL "postgresql://user:pass@postgres:5432/app"115+printf 'DATABASE_URL=%s\n' "$(pass show myapp/database-url)" \
116-gordon secrets set app.mydomain.com API_KEY "your-api-key"116+ | gordon apps secrets set blog --service web --stdin
117 ```117 ```
118 118
119-Gordon migrates existing `.env` files on startup and renames them to `.env.migrated`.119+See [App Manifest](/docs/config/apps.md) and [Apps CLI](/docs/cli/apps.md).
120 120
121 ## Organizing Secrets121 ## Organizing Secrets
122 122
M wiki/guides/secrets-sops.md +1 -11
@@ -208,17 +208,7 @@ The path format is `file:key.path` where:
208 - `file` is the SOPS-encrypted file path208 - `file` is the SOPS-encrypted file path
209 - `key.path` is dot-notation to the value209 - `key.path` is dot-notation to the value
210 210
211-Route secrets remain in `.env` files with `${sops:...}` references. The SOPS backend is used for auth secrets (like `token_secret`) and provider lookups.211+The SOPS backend is used for auth secrets such as `token_secret`. App secret values are managed with `gordon apps secrets` (see [Apps CLI](/docs/cli/apps.md)).
212-
213-### Using Secrets in Environment Files
214-
215-Reference SOPS secrets in your app's environment files:
216-
217-```bash
218-# ~/.gordon/env/app_mydomain_com.env
219-DATABASE_URL=postgresql://user:${sops:secrets.yaml:database.password}@postgres:5432/app
220-API_KEY=${sops:secrets.yaml:api.key}
221-```
222 212
223 ## File Organization213 ## File Organization
224 214
M wiki/tutorials/first-deploy.md +3 -3
@@ -87,7 +87,7 @@ On your Gordon server, edit `~/.config/gordon/gordon.toml`:
87 Reload Gordon:87 Reload Gordon:
88 88
89 ```bash89 ```bash
90-gordon reload90+gordon daemon reload
91 ```91 ```
92 92
93 ### 5. Push to Deploy93 ### 5. Push to Deploy
@@ -130,7 +130,7 @@ docker login -u deploy -p <token> registry.mydomain.com
130 130
131 2. Check Gordon logs:131 2. Check Gordon logs:
132 ```bash132 ```bash
133- gordon logs -f133+ gordon daemon logs -f
134 ```134 ```
135 135
136 3. Ensure DNS points to your server136 3. Ensure DNS points to your server
@@ -159,6 +159,6 @@ Gordon replaces the running container: it withdraws traffic, stops and removes t
159 159
160 ## Next Steps160 ## Next Steps
161 161
162-- [Add environment variables](/docs/config/env.md)162+- [Add environment variables](/docs/config/apps.md)
163 - [Add a database](./postgres-service.md)163 - [Add a database](./postgres-service.md)
164 - [Set up CI/CD](/docs/deployment/github-actions.md)164 - [Set up CI/CD](/docs/deployment/github-actions.md)
M wiki/tutorials/postgres-service.md +1 -1
@@ -56,7 +56,7 @@ enabled = true
56 Reload Gordon:56 Reload Gordon:
57 57
58 ```bash58 ```bash
59-gordon reload59+gordon daemon reload
60 ```60 ```
61 61
62 ### 4. Configure Database Password62 ### 4. Configure Database Password

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