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:
1717 EventSubscriber:
1818 EventHandler:
1919 SecretProvider:
20- EnvLoader:
2120 ContainerLogWriter:
2221 Metrics:
2322 LogExporter:
@@ -27,7 +26,6 @@ packages:
2726 AppStateReader:
2827 ImageResolver:
2928 SecretWriter:
30- DomainSecretStore:
3129 RateLimiter:
3230 BackupStorage:
3331 HTTPChallengeSink:
@@ -52,7 +50,6 @@ packages:
5250 AuthService:
5351 HealthService:
5452 HTTPProber:
55- SecretService:
5653 LogService:
5754 VolumeService:
5855 PublicTLSService:
M README.md +9 -8
@@ -38,7 +38,7 @@ Build locally, push to your Gordon server, declare the app, deploy:
3838
3939 ```bash
4040 # Push the image (OCI transfer only, never deploys)
41-gordon push myapp --build --remote prod
41+gordon images push myapp --build --remote prod
4242
4343 # Declare the app (blog.toml references the pushed tag)
4444 gordon apps apply --file blog.toml --remote prod
@@ -47,7 +47,7 @@ gordon apps apply --file blog.toml --remote prod
4747 gordon apps deploy blog --remote prod
4848
4949 # Check status
50-gordon status
50+gordon daemon status
5151 gordon apps status blog --remote prod
5252 ```
5353
@@ -106,8 +106,10 @@ gordon apps deploy blog --remote prod
106106 | Command | Description |
107107 |---------|-------------|
108108 | `gordon serve` | Start the Gordon server |
109-| `gordon status` | Show server and app fleet status |
110-| `gordon config show` | Display installation configuration |
109+| `gordon daemon status` | Show server and app fleet status |
110+| `gordon daemon logs` | Show Gordon process logs |
111+| `gordon daemon reload` | Reload installation configuration |
112+| `gordon daemon config show` | Display installation configuration |
111113
112114 ### Applications
113115
@@ -123,23 +125,22 @@ gordon apps deploy blog --remote prod
123125 | `gordon apps stop APP` | Stop, preserve all data |
124126 | `gordon apps start APP` | Start a stopped app |
125127 | `gordon apps remove APP` | Remove workloads (volumes/secrets retained) |
126-| `gordon push [image]` | Tag and push an image (never deploys) |
127128
128129 ### Images & Registry
129130
130131 | Command | Description |
131132 |---------|-------------|
133+| `gordon images push [image]` | Tag and push an image (never deploys) |
132134 | `gordon images list` | List runtime and registry images |
133135 | `gordon images prune` | Clean up dangling images and old tags |
134136 | `gordon images tags <repo>` | List registry tags for a repository |
135137
136-### Secrets & Config
138+### Secrets
137139
138140 | Command | Description |
139141 |---------|-------------|
142+| `gordon apps secrets list APP` | List app secret names |
140143 | `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 |
143144
144145 ### Remotes & Auth
145146
M docs/cli/apps.md +2 -2
@@ -364,7 +364,7 @@ never accepted. `--service` is required when the app has several services.
364364
365365 ```bash
366366 # Push the image first (OCI transfer only)
367-gordon push myapp --build --remote prod
367+gordon images push myapp --build --remote prod
368368
369369 # Apply the manifest that references the pushed tag
370370 gordon apps apply --file blog.toml --remote prod
@@ -386,5 +386,5 @@ gordon apps status blog --remote prod
386386 ## Related
387387
388388 - [CLI Overview](./index.md)
389-- [Push Command](./push.md)
389+- [Images Commands](./images.md#gordon-images-push)
390390 - [Deployment](../deployment/index.md)
M docs/cli/auth.md +1 -3
@@ -135,7 +135,7 @@ Standard Go durations also work: `24h`, `30m`, `1h30m`
135135
136136 Registry scopes: `push`, `pull`, `push,pull`
137137
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`
139139
140140 Combine scopes with commas:
141141
@@ -277,8 +277,6 @@ Usage:
277277 | `admin:config:write` | Config write access |
278278 | `admin:status:read` | Read-only status/health |
279279 | `admin:logs:read` | Read-only logs access |
280-| `admin:secrets:read` | List secret keys |
281-| `admin:secrets:write` | Set/delete secrets |
282280
283281 ## Token Expiry Formats
284282
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 @@
11 # Images Command
22
3-List and prune runtime/registry images.
3+Push, list, and prune runtime/registry images.
44
55 ## gordon images
66
@@ -10,12 +10,126 @@ gordon images <subcommand>
1010
1111 Subcommands:
1212
13+- `push` - Tag and push an image to the Gordon registry (never deploys).
1314 - `list` - List runtime images and registry tags.
1415 - `prune` - Prune dangling runtime images and old registry tags.
1516 - `tags` - List registry tags for a specific repository.
1617
1718 > **Note:** Images commands require remote mode (`--remote` + `--token`, or configured remotes).
1819
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+
19133 ## gordon images list
20134
21135 ```bash
M docs/cli/index.md +22 -28
@@ -26,16 +26,8 @@ Management commands use the authenticated daemon API. By default they connect th
2626 |---------|-------------|---------------|
2727 | `gordon apps` | Manage applications, operations, and app secrets | [apps](./apps.md) |
2828 | `gordon backups` | Manage declared app database and volume backups | [backup](./backup.md) |
29-| `gordon config` | Show and validate server configuration | [config](./config.md) |
30-| `gordon images` | 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) |
29+| `gordon daemon` | Daemon status, logs, reload, config, TLS, traffic, and networks | [daemon](./daemon.md) |
30+| `gordon images` | Push, list, and prune images | [images](./images.md) |
3931 | `gordon volumes` | Manage volumes | [volumes](./volumes.md) |
4032
4133 ## Client Commands
@@ -54,7 +46,7 @@ gordon serve
5446 gordon serve --config /path/to/config.toml
5547
5648 # Reload installation configuration (never activates app state)
57-gordon reload
49+gordon daemon reload
5850
5951 # Applications (daemon-owned; fail without a reachable daemon)
6052 gordon apps apply --file blog.toml
@@ -70,15 +62,21 @@ gordon apps remove blog
7062 gordon apps operations show blog --key <operation-key>
7163 gordon apps operations watch blog --key <operation-key>
7264 gordon apps secrets list blog
73-gordon config validate --file /path/to/gordon.toml
65+
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
7472
7573 # Push an image (OCI transfer only; deploy separately)
76-gordon push myapp --build --remote prod
74+gordon images push myapp --build --remote prod
7775
7876 # View logs
79-gordon logs # Gordon process logs
80-gordon logs -f # Follow process logs
81-gordon logs -n 100 # Last 100 process-log lines
77+gordon daemon logs # Gordon process logs
78+gordon daemon logs -f # Follow process logs
79+gordon daemon logs -n 100 # Last 100 process-log lines
8280 gordon apps logs blog --service web # App service logs
8381 gordon apps logs blog --service web --follow # Follow app service logs
8482
@@ -86,8 +84,8 @@ gordon apps logs blog --service web --follow # Follow app service logs
8684 gordon version
8785
8886 # Traffic plane
89-gordon traffic status --remote prod
90-gordon traffic status --remote prod --json
87+gordon daemon traffic --remote prod
88+gordon daemon traffic --remote prod --json
9189
9290 # Backups
9391 gordon backups list
@@ -96,6 +94,7 @@ gordon backups volume run shop --service api --volume data
9694 gordon backups status
9795
9896 # Images
97+gordon images push myapp --build --remote prod
9998 gordon images list
10099 gordon images prune --dry-run
101100 gordon images prune --keep-releases 3
@@ -110,11 +109,6 @@ gordon auth token list
110109 gordon auth token revoke <token-id>
111110 gordon auth internal
112111
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-
118112 # Remotes
119113 gordon remotes add prod https://gordon.mydomain.com --token $TOKEN
120114 gordon remotes list
@@ -141,14 +135,14 @@ or `GORDON_REMOTE` environment variable. Use `--remote` and `--token` as global
141135 when you want to bypass your saved configuration.
142136
143137 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 the
138+**auto-infer a saved remote** for `gordon images push`. It probes your saved remotes and uses the
145139 remote automatically when exactly one matches. If multiple remotes match, Gordon stops with
146140 an ambiguity error and asks you to use `--remote`. If any remote probe fails, Gordon also
147141 stops rather than guessing.
148142
149143 ```bash
150144 # Auto-inferred single match from saved remotes
151-gordon push myapp --build
145+gordon images push myapp --build
152146 ```
153147
154148 **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`.
159153
160154 ```bash
161155 # Using flags (use the gordon_domain from remote Gordon config)
162-gordon status --remote https://gordon.example.com --token $TOKEN
156+gordon daemon status --remote https://gordon.example.com --token $TOKEN
163157
164158 # Against self-signed/private CA endpoint
165-gordon --remote https://gordon.example.com --token $TOKEN --insecure status
159+gordon --remote https://gordon.example.com --token $TOKEN --insecure daemon status
166160
167161 # Using environment variables
168162 export GORDON_REMOTE=https://gordon.example.com
169163 export GORDON_TOKEN=$TOKEN
170164 export GORDON_INSECURE=true
171-gordon status
165+gordon daemon status
172166 ```
173167
174168 ## 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>
164164
165165 When a remote is active, it's used automatically for most remote-capable commands without needing to specify `--remote` and `--token`.
166166
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`.
168168
169169 ```bash
170170 gordon remotes use prod
171-gordon secrets list app.com # Uses prod remote automatically
172-gordon status # Uses prod remote automatically
171+gordon apps list # Uses prod remote automatically
172+gordon daemon status # Uses prod remote automatically
173173 ```
174174
175175 ### Example
@@ -270,7 +270,7 @@ This allows overriding specific values while keeping defaults:
270270
271271 ```bash
272272 # Force one target
273-GORDON_REMOTE=prod gordon status
273+GORDON_REMOTE=prod gordon daemon status
274274 ```
275275
276276 ---
@@ -287,12 +287,12 @@ gordon remotes add dev https://gordon.dev.example.com --token-env DEV_TOKEN
287287
288288 # Work with prod
289289 gordon remotes use prod
290-gordon secrets list myapp.example.com
291-gordon status --remote prod
290+gordon apps list
291+gordon daemon status
292292
293293 # Switch to staging
294294 gordon remotes use staging
295-GORDON_REMOTE=staging gordon status
295+GORDON_REMOTE=staging gordon daemon status
296296 ```
297297
298298 ### CI/CD Pipeline
@@ -306,19 +306,19 @@ env:
306306 steps:
307307 - name: Push to Gordon
308308 run: |
309- gordon push myapp --build --remote ${{ secrets.GORDON_URL }}
309+ gordon images push myapp --build --remote ${{ secrets.GORDON_URL }}
310310 ```
311311
312312 ### Compare Environments
313313
314314 ```bash
315315 # Compare one target at a time
316-gordon status --remote https://gordon.example.com --token $PROD_TOKEN
317-GORDON_REMOTE=staging gordon status
316+gordon daemon status --remote https://gordon.example.com --token $PROD_TOKEN
317+GORDON_REMOTE=staging gordon daemon status
318318
319319 # Or switch between active remotes for other commands
320-gordon remotes use prod && gordon secrets list myapp.example.com
321-gordon remotes use staging && gordon secrets list myapp.example.com
320+gordon remotes use prod && gordon apps list
321+gordon remotes use staging && gordon apps list
322322 ```
323323
324324 ### 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
128128
129129 ---
130130
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-
223131 ## gordon version
224132
225133 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
164164 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.
165165
166166 ```bash
167-gordon reload
167+gordon daemon reload
168168 ```
169169
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.
171171
172172 ## Event System
173173
M docs/config/auth.md +0 -2
@@ -95,8 +95,6 @@ Admin scopes (for remote CLI):
9595 | `admin:logs:read` | Read-only logs access |
9696 | `admin:volumes:read` | List Docker volumes |
9797 | `admin:volumes:write` | Prune eligible Gordon-managed volumes |
98-| `admin:secrets:read` | List secret keys |
99-| `admin:secrets:write` | Set/delete secrets |
10098
10199 Examples:
102100
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
8989 logs = true # Export Gordon, access, and app logs
9090 trace_sample_rate = 1.0 # 0.0 = none, 1.0 = all
9191
92-# Installation secret store location
93-[env]
94-dir = "~/.gordon/env" # Default location
95-
9692 # Volume settings
9793 [volumes]
9894 auto_create = true # Auto-create from Dockerfile VOLUME
9995 prefix = "gordon" # Volume name prefix
10096 preserve = true # Keep volumes on container removal
10197
102-# Installation network policy (prefix filter for `gordon networks list`)
98+# Installation network policy (prefix filter for `gordon daemon networks`)
10399 [network_isolation]
104100 enabled = true # Gordon-managed network policy
105101 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
143139 | `[api.rate_limit]` | Rate limiting configuration | [Rate Limiting](./rate-limiting.md) |
144140 | `[logging]` | Logging configuration | [Logging](./logging.md) |
145141 | `[telemetry]` | OpenTelemetry observability export | [Telemetry](./telemetry.md) |
146-| `[env]` | Installation secret store location | [Environment](./env.md) |
147142 | `[volumes]` | Volume management | [Volumes](./volumes.md) |
148143 | `[network_isolation]` | Installation network policy | [Network Isolation](./network-isolation.md) |
149144 | `[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
203198 Gordon watches the configuration file and reloads automatically when changes are detected. You can also trigger a manual reload:
204199
205200 ```bash
206-gordon reload
201+gordon daemon reload
207202 ```
208203
209204 ### Hot-reloaded (no restart needed)
M docs/config/logging.md +3 -3
@@ -43,7 +43,7 @@ syslog_identifier = "gordon-access"
4343 | `file.max_backups` | int | `3` | Number of old files to keep |
4444 | `file.max_age` | int | `28` | Days to keep old files |
4545
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.
4747
4848 ### Workload Logs
4949
@@ -142,8 +142,8 @@ enabled = false
142142 ### Gordon Process Logs
143143
144144 ```bash
145-gordon logs -f
146-gordon logs -n 100
145+gordon daemon logs -f
146+gordon daemon logs -n 100
147147 tail -f ~/.gordon/logs/gordon.log
148148 journalctl --user -u gordon -f
149149 ```
M docs/config/network-isolation.md +2 -2
@@ -16,7 +16,7 @@ internal = false
1616 | Option | Type | Default | Description |
1717 |--------|------|---------|-------------|
1818 | `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` |
2020 | `internal` | bool | `false` | Create isolated Docker networks with Docker's `Internal` flag, blocking direct external egress from containers on those networks. |
2121
2222 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
3434 View Gordon-managed networks:
3535
3636 ```bash
37-gordon networks list
37+gordon daemon networks
3838 docker network ls | grep gordon
3939 ```
4040
M docs/config/reference.md +1 -7
@@ -110,12 +110,6 @@ metrics = true # Export metrics
110110 logs = true # Export Gordon, access, and app logs
111111 trace_sample_rate = 1.0 # Fraction of traces to sample (0.0–1.0)
112112
113-# =============================================================================
114-# ENVIRONMENT
115-# =============================================================================
116-[env]
117-dir = "" # Env files directory (default: {data_dir}/env)
118-
119113 # =============================================================================
120114 # CONTAINERS
121115 # =============================================================================
@@ -127,7 +121,7 @@ security_profile = "compat" # "compat" or "strict"
127121 # =============================================================================
128122 [network_isolation]
129123 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`
131125 internal = false # Create Docker internal networks (blocks direct egress)
132126
133127 # =============================================================================
M docs/config/secrets.md +1 -31
@@ -1,6 +1,6 @@
11 # Secrets Configuration
22
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>`).
44
55 ## Configuration
66
@@ -52,10 +52,6 @@ token_secret = "gordon/auth/token_secret" # Path in pass store
5252 - Standard Unix tooling
5353 - Works with team GPG keys
5454
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-
5955 ### SOPS
6056
6157 Uses Mozilla SOPS for encrypted file-based secrets:
@@ -75,12 +71,6 @@ brew install sops # macOS
7571 sops secrets.yaml
7672 ```
7773
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-
8474 **Benefits:**
8575 - Multiple encryption backends (AWS KMS, GCP KMS, Azure Key Vault, PGP)
8676 - YAML/JSON file encryption
@@ -117,26 +107,6 @@ echo "your-token-secret" > ~/.gordon/secrets/gordon/auth/token_secret
117107
118108 > **Warning:** Only use for local development. Secrets are stored in plain text.
119109
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-
140110 ## App Secret Values
141111
142112 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"]`.
8383 - 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.
8484 - Gordon installs no drivers, manages no quotas, and injects no device environment: images carry their own runtime expectations.
8585
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-
9086 ## External image registries
9187
9288 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"
3232 | `gordon_domain` | string | **required** | Domain for Gordon (registry + admin API) |
3333 | `registry_domain` | string | - | Deprecated migration key. Set `gordon_domain` instead. |
3434 | `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 |
3636 | `max_proxy_body_size` | string | `"512MB"` | Maximum request body size for proxied requests |
3737 | `max_blob_chunk_size` | string | `"95MB"` | Maximum request body size for a single registry blob upload chunk |
3838 | `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
220220 ├── registry/ # Container images and manifests
221221 │ ├── blobs/
222222 │ └── manifests/
223-├── env/ # Installation secret-store data
224223 ├── logs/ # Gordon process and file-based access logs
225224 │ ├── gordon.log
226225 │ └── 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
9191
9292 - [Traffic Plane Configuration](./traffic.md)
9393 - [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
110110
111111 For OpenObserve, copy the token from **Ingestion > OTLP** in the web UI.
112112
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:
114114
115115 | Method | How |
116116 |--------|-----|
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
178178 - [Server Settings](./server.md)
179179 - [App Manifest](./apps.md)
180180 - [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.
88 - Network access to the public Gordon HTTPS domain; do not expose or target the loopback `server.registry_port`
99 - Gordon binary (optional but recommended)
1010
11-## Recommended: gordon push
11+## Recommended: gordon images push
1212
1313 ### 1. Generate Deployment Token
1414
@@ -40,7 +40,7 @@ chmod +x /usr/local/bin/gordon
4040
4141 # 2. Build, push, and deploy
4242 export GORDON_TOKEN="$GORDON_TOKEN"
43-gordon push --build \
43+gordon images push --build \
4444 --remote "$GORDON_REMOTE" \
4545
4646 ```
@@ -52,7 +52,7 @@ Gordon auto-detects the version from CI environment variables:
5252 | GitHub Actions | `$GITHUB_REF` | `refs/tags/v1.2.0` |
5353 | GitLab CI | `$CI_COMMIT_TAG` | `v1.2.0` |
5454 | 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` |
5656
5757 ## Alternative: docker push
5858
@@ -89,7 +89,7 @@ pipeline {
8989 curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 \
9090 -o /usr/local/bin/gordon
9191 chmod +x /usr/local/bin/gordon
92- gordon push --build --remote "$GORDON_REMOTE"
92+ gordon images push --build --remote "$GORDON_REMOTE"
9393 '''
9494 }
9595 }
@@ -119,7 +119,7 @@ jobs:
119119 - run:
120120 name: Deploy
121121 command: |
122- gordon push --build \
122+ gordon images push --build \
123123 --remote "$GORDON_REMOTE"
124124
125125 workflows:
@@ -152,7 +152,7 @@ steps:
152152 - apk add --no-cache curl
153153 - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon
154154 - chmod +x /usr/local/bin/gordon
155- - gordon push --build --remote "$GORDON_REMOTE"
155+ - gordon images push --build --remote "$GORDON_REMOTE"
156156
157157 trigger:
158158 event:
@@ -188,7 +188,7 @@ gordon apps deploy myapp --remote "$GORDON_REMOTE"
188188
189189 Gordon could not detect a version tag. Either:
190190 - 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`
192192
193193 ### Large images time out
194194
@@ -200,4 +200,4 @@ Gordon uploads in 50MB chunks. For very large images (> 1GB), the push may take
200200 - [GitLab CI](./gitlab-ci.md)
201201 - [Deployment Overview](./index.md)
202202 - [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 @@
11 # GitHub Actions Deployment
22
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.
44
55 ## Prerequisites
66
@@ -8,9 +8,9 @@ Two approaches for deploying with GitHub Actions: the `gordon push` CLI (recomme
88 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
99 3. GitHub repository secrets configured
1010
11-## Recommended: gordon push
11+## Recommended: gordon images push
1212
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.
1414
1515 ### 1. Generate Token
1616
@@ -71,7 +71,7 @@ jobs:
7171 env:
7272 GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }}
7373 run: |
74- gordon push --build \
74+ gordon images push --build \
7575 --remote ${{ secrets.GORDON_REMOTE }}
7676 ```
7777
@@ -102,7 +102,7 @@ jobs:
102102 env:
103103 GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }}
104104 run: |
105- gordon push --build \
105+ gordon images push --build \
106106 --remote ${{ secrets.GORDON_REMOTE }} \
107107 --tag latest
108108 ```
@@ -149,7 +149,7 @@ jobs:
149149
150150 #### Monorepo
151151
152-Deploy multiple services with separate `gordon push` calls:
152+Deploy multiple services with separate `gordon images push` calls:
153153
154154 ```yaml
155155 name: Deploy Services
@@ -171,7 +171,7 @@ jobs:
171171 env:
172172 GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }}
173173 run: |
174- gordon push --build \
174+ gordon images push --build \
175175 --remote ${{ secrets.GORDON_REMOTE }} \
176176 --file ./services/api/Dockerfile \
177177 myapp-api
@@ -180,7 +180,7 @@ jobs:
180180 env:
181181 GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }}
182182 run: |
183- gordon push --build \
183+ gordon images push --build \
184184 --remote ${{ secrets.GORDON_REMOTE }} \
185185 --file ./services/web/Dockerfile \
186186 myapp-web
@@ -195,7 +195,7 @@ Pass build arguments to the Docker build:
195195 env:
196196 GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }}
197197 run: |
198- gordon push --build \
198+ gordon images push --build \
199199 --remote ${{ secrets.GORDON_REMOTE }} \
200200 --build-arg NODE_ENV=production \
201201 --build-arg API_URL=https://api.example.com \
@@ -478,5 +478,5 @@ Error: unauthorized: authentication required
478478 - [Generic CI](./generic-ci.md)
479479 - [Deployment Overview](./index.md)
480480 - [Authentication](../config/auth.md)
481-- [Push Command](../cli/push.md)
481+- [Images Commands](../cli/images.md#gordon-images-push)
482482 - [Rollback](./rollback.md)
M docs/deployment/gitlab-ci.md +6 -6
@@ -45,7 +45,7 @@ deploy:
4545 - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon
4646 - chmod +x /usr/local/bin/gordon
4747 script:
48- - gordon push --build
48+ - gordon images push --build
4949 --remote "$GORDON_REMOTE"
5050 --token "$GORDON_TOKEN"
5151
@@ -73,7 +73,7 @@ deploy:
7373 - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon
7474 - chmod +x /usr/local/bin/gordon
7575 script:
76- - gordon push --build
76+ - gordon images push --build
7777 --remote "$GORDON_REMOTE"
7878 --token "$GORDON_TOKEN"
7979
@@ -96,7 +96,7 @@ deploy:
9696 - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon
9797 - chmod +x /usr/local/bin/gordon
9898 script:
99- - gordon push --build
99+ - gordon images push --build
100100 --remote "$GORDON_REMOTE"
101101 --token "$GORDON_TOKEN"
102102
@@ -117,7 +117,7 @@ deploy:
117117 - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon
118118 - chmod +x /usr/local/bin/gordon
119119 script:
120- - gordon push --build
120+ - gordon images push --build
121121 --remote "$GORDON_REMOTE"
122122 --token "$GORDON_TOKEN"
123123
@@ -137,7 +137,7 @@ deploy:
137137 - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon
138138 - chmod +x /usr/local/bin/gordon
139139 script:
140- - gordon push --build
140+ - gordon images push --build
141141 --remote "$GORDON_REMOTE"
142142 --token "$GORDON_TOKEN"
143143 --build-arg NODE_ENV=production
@@ -208,4 +208,4 @@ GitLab CI clones your repository automatically. The build context defaults to th
208208 - [Generic CI](./generic-ci.md)
209209 - [Deployment Overview](./index.md)
210210 - [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 @@
22
33 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.
44
5-## Recommended: gordon push + apps deploy
5+## Recommended: gordon images push + apps deploy
66
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.
88
99 - Single secret (`GORDON_TOKEN`): auto-exchanges for a short-lived registry token
1010 - Auto-detects version from CI environment (`$GITHUB_REF`, `$CI_COMMIT_TAG`, `$BUILD_SOURCEBRANCH`, or `git describe`)
1111 - Chunked uploads (50MB chunks) — works behind Cloudflare and restrictive proxies
1212
1313 ```bash
14-gordon push --build --remote https://gordon.example.com
14+gordon images push --build --remote https://gordon.example.com
1515 ```
1616
1717 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
2525
2626 | Method | Best For | Secrets Needed | Registry Access | Deploy Control |
2727 |--------|----------|----------------|-----------------|----------------|
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) |
2929 | `docker push` + `apps deploy` | Simple setups, existing Docker workflows | 2 (`username` + `token`) | Via gordon domain (HTTPS) | Explicit (CLI-triggered) |
3030
31-### Method 1: gordon push + apps deploy (Recommended)
31+### Method 1: gordon images push + apps deploy (Recommended)
3232
3333 The Gordon CLI handles authentication, image building, and registry upload in a single step. Deploy stays explicit.
3434
@@ -38,7 +38,7 @@ The Gordon CLI handles authentication, image building, and registry upload in a
3838
3939 ```bash
4040 # Build and push (OCI transfer only)
41-gordon push --build --remote https://gordon.example.com
41+gordon images push --build --remote https://gordon.example.com
4242
4343 # Apply the manifest that references the pushed tag, then deploy
4444 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>
148148 gordon remotes use prod
149149
150150 # Build and push the image (OCI transfer only, never deploys)
151-gordon push myapp --build --remote prod
151+gordon images push myapp --build --remote prod
152152 ```
153153
154154 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
173173
174174 What these commands do:
175175
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.
177177 - `gordon apps apply` validates the manifest and persists it as desired state.
178178 - `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.
179179
@@ -191,7 +191,7 @@ Push a new image, update the manifest tag if needed, apply, deploy:
191191
192192 ```bash
193193 # Make changes, then build + push
194-gordon push myapp --build --remote prod
194+gordon images push myapp --build --remote prod
195195
196196 # Deploy the new tag (re-resolves mutable tags)
197197 gordon apps deploy blog --remote prod
M docs/index.md +0 -1
@@ -70,7 +70,6 @@ Gordon runs on your VPS and provides:
7070 ### Reference
7171
7272 - [Docker Labels](./reference/docker-labels.md) - Container and image labels
73-- [Environment Variables](./reference/env-variables.md) - Environment variable syntax
7473 - [Troubleshooting](./reference/troubleshooting.md) - Common issues and solutions
7574
7675 ## Requirements
M docs/installation.md +0 -1
@@ -377,7 +377,6 @@ Gordon stores data in the following locations:
377377 | `~/.config/gordon/gordon.toml` | Configuration file |
378378 | `~/.gordon/` | Default data directory |
379379 | `~/.gordon/registry/` | Container images |
380-| `~/.gordon/env/` | Environment files |
381380 | `~/.gordon/logs/` | Application logs |
382381 | `~/.gordon/secrets/` | Secrets (unsafe backend only) |
383382
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;
5757
5858 ## 2. Migrate domain secrets
5959
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:
6161
6262 ```bash
6363 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.
55 ## Contents
66
77 - [Docker Labels](./docker-labels.md) - Container and image labels used by Gordon
8-- [Environment Variables](./env-variables.md) - Environment variable syntax and interpolation
98 - [Telemetry & Metrics](../config/telemetry.md) - OpenTelemetry configuration, custom metrics, and trace spans
109 - [Troubleshooting](./troubleshooting.md) - Common issues and solutions
1110
M docs/reference/troubleshooting.md +11 -16
@@ -91,7 +91,7 @@ No restart needed — Podman reads this on each pull/push.
9191 1. Per-command flag:
9292
9393 ```bash
94- gordon push myapp:latest --insecure
94+ gordon images push myapp:latest --insecure
9595 ```
9696
9797 2. Environment variable:
@@ -164,7 +164,7 @@ gordon apps deploy app
164164
165165 2. Check Gordon logs:
166166 ```bash
167- gordon logs -f
167+ gordon daemon logs -f
168168 ```
169169
170170 3. Run container manually to debug:
@@ -174,28 +174,23 @@ gordon apps deploy app
174174
175175 ### Environment variables not loaded
176176
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.
178178
179179 **Solutions:**
180180
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:
182182 ```bash
183- ls ~/.gordon/env/
184- # Should show: app_mydomain_com.env (dots → underscores)
183+ gordon apps apply --file ./blog.toml
185184 ```
186185
187-2. Check file permissions:
186+2. Check which secret values are set (names only):
188187 ```bash
189- chmod 600 ~/.gordon/env/app_mydomain_com.env
188+ gordon apps secrets list blog
190189 ```
191190
192-3. Check file format (no spaces around `=`):
191+3. Deploy or restart: values apply on the next deploy/restart, never to running containers.
193192 ```bash
194- # Correct
195- KEY=value
196-
197- # Wrong
198- KEY = value
193+ gordon apps restart blog
199194 ```
200195
201196 ### Secrets not resolved
@@ -318,7 +313,7 @@ gordon apps deploy app
318313
319314 **Solution:** Manual reload:
320315 ```bash
321-gordon reload
316+gordon daemon reload
322317 ```
323318
324319 ### Stale `targets.toml` in config directory
@@ -372,7 +367,7 @@ path = "~/.gordon/logs/gordon.log"
372367 systemctl --user status gordon
373368
374369 # View Gordon logs
375-gordon logs -f
370+gordon daemon logs -f
376371 journalctl --user -u gordon -f
377372
378373 # 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
1313 - 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.
1414 - 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).
1515 - 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"`.
1719 - No historical rollback command: roll back by applying a manifest that references the previous tag and deploying again.
1820
1921 ### Migrating a v3 alpha app manifest
@@ -52,7 +54,7 @@ Names containing dots must be quoted: `[services."web.api"]` and `[[services."we
5254 ### Manual migration
5355
5456 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).
5658 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.
5759 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).
5860 5. `gordon apps apply --file <app>.toml`, then `gordon apps deploy <app>`.
@@ -150,7 +152,7 @@ registry_domain = "gordon.example.com"
150152 gordon_domain = "gordon.example.com"
151153 ```
152154
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`.
154156
155157 ### Staged Registry Host Rename
156158
@@ -477,7 +479,7 @@ If using pass or sops, update your secret paths:
477479 ```
478480 6. **Check logs** for any errors:
479481 ```bash
480- gordon logs
482+ gordon daemon logs
481483 ```
482484
483485 ## Getting Help
M gordon.toml.example +1 -4
@@ -62,7 +62,7 @@ security_profile = "compat"
6262
6363 [network_isolation]
6464 # Installation network policy for Gordon-managed networks (prefix filter
65-# for `gordon networks list`). Per-app isolation is removed: apps declare
65+# for `gordon daemon networks`). Per-app isolation is removed: apps declare
6666 # `[[network.shared]]` membership in their app file instead.
6767 enabled = true
6868 network_prefix = "gordon"
@@ -151,9 +151,6 @@ metrics = true
151151 logs = true
152152 trace_sample_rate = 1.0
153153
154-[env]
155-# dir = "~/.gordon/env"
156-
157154 [backups.databases]
158155 enabled = false
159156 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 {
3232 auto-route, network isolation, routes, and external routes.
3333
3434 Examples:
35- gordon config show
36- gordon config show --json
37- gordon config show --remote https://gordon.mydomain.com --token $TOKEN`,
35+ gordon daemon config show
36+ gordon daemon config show --json
37+ gordon daemon config show --remote prod`,
3838 RunE: func(cmd *cobra.Command, _ []string) error {
3939 ctx := cmd.Context()
4040 handle, err := resolveControlPlane(cliConfigPath)
M internal/adapters/in/cli/config_validate.go +1 -1
@@ -30,7 +30,7 @@ func newConfigValidateCmd() *cobra.Command {
3030 Args: cobra.NoArgs,
3131 RunE: func(cmd *cobra.Command, _ []string) error {
3232 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")
3434 }
3535 return runConfigValidate(cmd.OutOrStdout(), file, jsonOut)
3636 },
M internal/adapters/in/cli/controlplane.go +0 -4
@@ -27,10 +27,6 @@ type ControlPlane interface {
2727 SetAppSecrets(ctx context.Context, app string, req dto.AppSecretSetRequest) error
2828 DeleteAppSecret(ctx context.Context, app string, req dto.AppSecretDeleteRequest) error
2929
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-
3430 GetStatus(ctx context.Context) (*remote.Status, error)
3531 GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error)
3632 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
7272 return r.client.DeleteAppSecret(ctx, app, req)
7373 }
7474
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-
8775 func (r *remoteControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) {
8876 return r.client.GetStatus(ctx)
8977 }
M internal/adapters/in/cli/controlplane_resolver.go +0 -4
@@ -51,10 +51,6 @@ func newRemoteControlPlaneHandle(target *remote.ResolvedRemote) *controlPlaneHan
5151 return &controlPlaneHandle{plane: NewRemoteControlPlane(client), isRemote: true}
5252 }
5353
54-func resolveControlPlaneForDomain(ctx context.Context, _ string) (*controlPlaneHandle, error) {
55- return resolveControlPlaneWithInference(ctx, inferExplicitTarget)
56-}
57-
5854 func resolveControlPlaneForRepository(ctx context.Context, repository string) (*controlPlaneHandle, error) {
5955 return resolveControlPlaneWithInference(ctx, func(ctx context.Context) (*remote.ResolvedRemote, error) {
6056 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{
6969 func newImagesCmd() *cobra.Command {
7070 cmd := &cobra.Command{
7171 Use: "images",
72- Short: "List and prune images",
73- Long: `Inspect and clean up runtime and registry images through the selected daemon.`,
72+ Short: "Push, list, and prune images",
73+ Long: `Push, inspect, and clean up runtime and registry images through the selected daemon.`,
7474 }
7575
76+ cmd.AddCommand(newPushCmd())
7677 cmd.AddCommand(newImagesListCmd())
7778 cmd.AddCommand(newImagesPruneCmd())
7879 cmd.AddCommand(newImagesTagsCmd())
@@ -144,7 +145,7 @@ func newImagesTagsCmd() *cobra.Command {
144145 Examples:
145146 gordon images tags myapp
146147 gordon images tags myapp --json
147- gordon images tags myapp --remote https://gordon.mydomain.com --token $TOKEN`,
148+ gordon images tags myapp --remote prod`,
148149 Args: cobra.ExactArgs(1),
149150 RunE: func(cmd *cobra.Command, args []string) error {
150151 ctx := cmd.Context()
M internal/adapters/in/cli/json_parity_test.go +3 -4
@@ -12,7 +12,6 @@ func TestAllListCommands_AcceptJSONFlag(t *testing.T) {
1212 name string
1313 builder func() *cobra.Command
1414 }{
15- {"secrets list", newSecretsListCmd},
1615 {"images list", newImagesListCmd},
1716 {"backup list", newBackupListCmd},
1817 {"backup volumes list", newVolumeBackupListCmd},
@@ -20,10 +19,10 @@ func TestAllListCommands_AcceptJSONFlag(t *testing.T) {
2019 {"backup volumes status", newVolumeBackupStatusCmd},
2120 {"auth token list", newTokenListCmd},
2221 {"remotes list", newRemotesListCmd},
23- {"config show", newConfigShowCmd},
24- {"networks list", newNetworksListCmd},
22+ {"daemon config show", newConfigShowCmd},
23+ {"daemon networks", newNetworksCmd},
2524 {"images tags", newImagesTagsCmd},
26- {"tls status", newTLSStatusCmd},
25+ {"daemon tls", newTLSCmd},
2726 }
2827
2928 for _, tc := range commands {
M internal/adapters/in/cli/json_test.go +0 -25
@@ -10,19 +10,9 @@ import (
1010 "github.com/stretchr/testify/require"
1111
1212 "github.com/bnema/gordon/internal/adapters/dto"
13- "github.com/bnema/gordon/internal/adapters/in/cli/remote"
1413 "github.com/bnema/gordon/internal/domain"
1514 )
1615
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-
2616 func TestImagesList_JSONFlag_Accepted(t *testing.T) {
2717 cmd := newImagesListCmd()
2818 f := cmd.Flags().Lookup("json")
@@ -147,21 +137,6 @@ func TestRemotesList_JSONShape_RoundTripsRemoteObjects(t *testing.T) {
147137 assert.True(t, got[0].InsecureTLS)
148138 }
149139
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-
165140 func TestWriteJSON_ProducesValidIndentedJSON(t *testing.T) {
166141 var out bytes.Buffer
167142 err := writeJSON(&out, map[string]any{"ok": true})
M internal/adapters/in/cli/local.go +0 -61
@@ -4,25 +4,18 @@ package cli
44 import (
55 "context"
66 "fmt"
7- "path/filepath"
87 "strings"
98
10- "github.com/bnema/zerowrap"
119 "github.com/spf13/viper"
1210
13- "github.com/bnema/gordon/internal/adapters/out/domainsecrets"
1411 "github.com/bnema/gordon/internal/app"
1512 "github.com/bnema/gordon/internal/boundaries/in"
16- "github.com/bnema/gordon/internal/boundaries/out"
17- "github.com/bnema/gordon/internal/domain"
1813 "github.com/bnema/gordon/internal/usecase/config"
19- secretsSvc "github.com/bnema/gordon/internal/usecase/secrets"
2014 )
2115
2216 // LocalServices provides direct access to local services for CLI operations.
2317 type LocalServices struct {
2418 configSvc in.ConfigService
25- secretSvc in.SecretService
2619 dataDir string
2720 tlsEnabled bool
2821 }
@@ -32,11 +25,6 @@ func (l *LocalServices) GetConfigService() in.ConfigService {
3225 return l.configSvc
3326 }
3427
35-// GetSecretService returns the secret service.
36-func (l *LocalServices) GetSecretService() in.SecretService {
37- return l.secretSvc
38-}
39-
4028 // GetDataDir returns the data directory.
4129 func (l *LocalServices) GetDataDir() string {
4230 return l.dataDir
@@ -67,8 +55,6 @@ func GetLocalServices(cliConfigPath string) (*LocalServices, error) {
6755 // Config file not found is OK for some operations
6856 }
6957
70- log := zerowrap.New(cliLogConfig)
71-
7258 // Create config service (without event bus for CLI operations)
7359 configSvc := config.NewService(v, nil)
7460 ctx := context.Background()
@@ -76,27 +62,13 @@ func GetLocalServices(cliConfigPath string) (*LocalServices, error) {
7662 return nil, fmt.Errorf("failed to load configuration: %w", err)
7763 }
7864
79- // Determine data directory and env directory
8065 dataDir := v.GetString("server.data_dir")
8166 if dataDir == "" {
8267 dataDir = app.DefaultDataDir()
8368 }
8469
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-
9770 return &LocalServices{
9871 configSvc: configSvc,
99- secretSvc: secretSvc,
10072 dataDir: dataDir,
10173 tlsEnabled: hasLocalTLSCapableEntrypoint(v),
10274 }, nil
@@ -115,36 +87,3 @@ func hasLocalTLSCapableEntrypoint(v *viper.Viper) bool {
11587 }
11688 return false
11789 }
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
33 import (
44 "testing"
55
6- "github.com/bnema/zerowrap"
76 "github.com/spf13/viper"
87 "github.com/stretchr/testify/require"
9-
10- "github.com/bnema/gordon/internal/adapters/out/domainsecrets"
11- "github.com/bnema/gordon/internal/domain"
128 )
139
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-
4110 func TestHasLocalTLSCapableEntrypoint(t *testing.T) {
4211 for _, tt := range []struct {
4312 name string
@@ -56,38 +25,3 @@ func TestHasLocalTLSCapableEntrypoint(t *testing.T) {
5625 })
5726 }
5827 }
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
242242 return _c
243243 }
244244
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-
308245 // DeployApp provides a mock function for the type MockControlPlane
309246 func (_mock *MockControlPlane) DeployApp(ctx context.Context, app string, req dto.AppDeployRequest) (*dto.AppDeployResponse, string, error) {
310247 ret := _mock.Called(ctx, app, req)
@@ -1035,74 +972,6 @@ func (_c *MockControlPlane_ListNetworks_Call) RunAndReturn(run func(ctx context.
1035972 return _c
1036973 }
1037974
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-
1106975 // ListTags provides a mock function for the type MockControlPlane
1107976 func (_mock *MockControlPlane) ListTags(ctx context.Context, repository string) ([]string, error) {
1108977 ret := _mock.Called(ctx, repository)
@@ -1877,69 +1746,6 @@ func (_c *MockControlPlane_SetAppSecrets_Call) RunAndReturn(run func(ctx context
18771746 return _c
18781747 }
18791748
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-
19431749 // ShowApp provides a mock function for the type MockControlPlane
19441750 func (_mock *MockControlPlane) ShowApp(ctx context.Context, app string) (*dto.AppShowResponse, error) {
19451751 ret := _mock.Called(ctx, app)
M internal/adapters/in/cli/networks.go +5 -16
@@ -12,29 +12,18 @@ import (
1212 )
1313
1414 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 {
2615 var jsonOut bool
2716
2817 cmd := &cobra.Command{
29- Use: "list",
30- Short: "List Gordon-managed Docker networks",
18+ Use: "networks",
19+ Short: "List Gordon-managed networks",
3120 Long: `Display Docker networks managed by Gordon, including which containers
3221 are connected to each network.
3322
3423 Examples:
35- gordon networks list
36- gordon networks list --json
37- gordon networks list --remote https://gordon.mydomain.com --token $TOKEN`,
24+ gordon daemon networks
25+ gordon daemon networks --json
26+ gordon daemon networks --remote prod`,
3827 RunE: func(cmd *cobra.Command, _ []string) error {
3928 ctx := cmd.Context()
4029 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
6666 the manifest that references the pushed tag.
6767
6868 Examples:
69- gordon push myapp --build --remote ...
70- gordon push myapp:v1.2.3 --tag v1.2.3 --remote ...
71- gordon push registry.example.com/myapp:v1.2.3 --build --remote ...
72- gordon push --build --build-arg CGO_ENABLED=0 --remote ...`,
69+ gordon images push myapp --build --remote ...
70+ gordon images push myapp:v1.2.3 --tag v1.2.3 --remote ...
71+ gordon images push registry.example.com/myapp:v1.2.3 --build --remote ...
72+ gordon images push --build --build-arg CGO_ENABLED=0 --remote ...`,
7373 Args: cobra.MaximumNArgs(1),
7474 RunE: func(cmd *cobra.Command, args []string) error {
7575 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 {
181181 "Podman and other runtimes are not supported for --build.\n" +
182182 "Build the image manually and push with:\n" +
183183 " podman build -t <image> .\n" +
184- " gordon push <image>",
184+ " gordon images push <image>",
185185 )
186186 }
187187 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
481481 return nil, fmt.Errorf("request failed after retries")
482482 }
483483
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-
526484 // Status API
527485
528486 // 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 {
129129 return errors.Is(err, domain.ErrRouteNotFound)
130130 }
131131
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-
165132 func resolveExplicitTargetName() (string, bool) {
166133 if target := strings.TrimSpace(remoteFlag); target != "" {
167134 return target, true
@@ -203,12 +170,3 @@ func resolveInsecureForTarget(name string, entry remote.RemoteEntry) bool {
203170 func newInferenceTargetClient(name string, entry remote.RemoteEntry) *remote.Client {
204171 return remote.NewClient(entry.URL, remoteClientOptions(resolveTokenForTarget(name, entry), resolveInsecureForTarget(name, entry))...)
205172 }
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.
296296
297297 Examples:
298298 gordon remotes use prod
299- gordon status # Uses prod remote automatically`,
299+ gordon daemon status # Uses prod remote automatically`,
300300 Args: cobra.ExactArgs(1),
301301 RunE: func(cmd *cobra.Command, args []string) error {
302302 name := args[0]
M internal/adapters/in/cli/root.go +16 -57
@@ -55,11 +55,11 @@ func NewRootCmd() *cobra.Command {
5555 container registry, an app runtime, and a reverse proxy.
5656
5757 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.
5959
6060 Commands are organized by where they run:
6161 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.)
6363 Client-only: CLI utilities that don't require a running Gordon server`,
6464 }
6565
@@ -88,57 +88,16 @@ Commands are organized by where they run:
8888 caCmd.GroupID = groupServer
8989 rootCmd.AddCommand(caCmd)
9090
91- secretsCmd := newSecretsCmd()
92- secretsCmd.GroupID = groupManage
93- rootCmd.AddCommand(secretsCmd)
94-
95- pushCmd := newPushCmd()
96- pushCmd.GroupID = groupManage
97- rootCmd.AddCommand(pushCmd)
98-
99- reloadCmd := newReloadCmd()
100- reloadCmd.GroupID = groupManage
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)
91+ for _, cmd := range []*cobra.Command{
92+ newAppsCmd(),
93+ newBackupCmd(),
94+ newDaemonCmd(),
95+ newImagesCmd(),
96+ newVolumesCmd(),
97+ } {
98+ cmd.GroupID = groupManage
99+ rootCmd.AddCommand(cmd)
100+ }
142101
143102 // Client-only commands (no server needed)
144103 remotesCmd := newRemotesCmd()
@@ -257,12 +216,12 @@ Application workload output is read with gordon apps logs APP --service SVC,
257216 which resolves the app's active container through the daemon.
258217
259218 Examples:
260- gordon logs # Gordon process logs
261- gordon logs -f # Follow process logs
262- gordon logs -n 100 # Last 100 lines
219+ gordon daemon logs # Gordon process logs
220+ gordon daemon logs -f # Follow process logs
221+ gordon daemon logs -n 100 # Last 100 lines
263222
264223 Remote mode:
265- gordon logs --remote https://gordon.mydomain.com --token $TOKEN`,
224+ gordon daemon logs --remote prod`,
266225 Args: cobra.NoArgs,
267226 RunE: func(cmd *cobra.Command, args []string) error {
268227 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 (
1616 var tlsResolveControlPlane = resolveControlPlane
1717
1818 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 {
3019 var jsonOut bool
3120
3221 cmd := &cobra.Command{
33- Use: "status",
22+ Use: "tls",
3423 Short: "Show public TLS certificate status",
3524 RunE: func(cmd *cobra.Command, _ []string) error {
3625 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 (
1313
1414 var trafficResolveControlPlane = resolveControlPlane
1515
16-func newTrafficStatusCmd() *cobra.Command {
16+func newTrafficCmd() *cobra.Command {
1717 var jsonOut bool
1818 cmd := &cobra.Command{
19- Use: "status",
19+ Use: "traffic",
2020 Short: "Show traffic entrypoint, router, and counter status",
2121 Args: cobra.NoArgs,
2222 RunE: func(cmd *cobra.Command, args []string) error {
M internal/adapters/in/cli/traffic_test.go +2 -7
@@ -19,19 +19,14 @@ import (
1919
2020 func TestTrafficCommandExists(t *testing.T) {
2121 cmd := NewRootCmd()
22- traffic, _, err := cmd.Find([]string{"traffic"})
22+ traffic, _, err := cmd.Find([]string{"daemon", "traffic"})
2323 require.NoError(t, err)
2424 require.NotNil(t, traffic)
2525 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())
3126 }
3227
3328 func TestTrafficStatusRejectsArgs(t *testing.T) {
34- cmd := newTrafficStatusCmd()
29+ cmd := newTrafficCmd()
3530 require.Error(t, cmd.Args(cmd, []string{"extra"}))
3631 }
3732
M internal/adapters/in/http/admin/handler.go +0 -110
@@ -41,7 +41,6 @@ type Handler struct {
4141 volumeBackupSvc in.VolumeBackupService
4242 imageSvc in.ImageService
4343 healthSvc in.HealthService
44- secretSvc in.SecretService
4544 logSvc in.LogService
4645 volumeSvc in.VolumeService
4746 registrySvc in.RegistryService
@@ -115,7 +114,6 @@ type HandlerDeps struct {
115114 AuthSvc in.AuthService
116115 ContainerSvc in.ContainerService
117116 HealthSvc in.HealthService
118- SecretSvc in.SecretService
119117 LogSvc in.LogService
120118 RegistrySvc in.RegistryService
121119 Log zerowrap.Logger
@@ -139,7 +137,6 @@ func NewHandler(deps HandlerDeps) *Handler {
139137 volumeBackupSvc: deps.VolumeBackupSvc,
140138 imageSvc: deps.ImageSvc,
141139 healthSvc: deps.HealthSvc,
142- secretSvc: deps.SecretSvc,
143140 logSvc: deps.LogSvc,
144141 volumeSvc: deps.VolumeSvc,
145142 registrySvc: deps.RegistrySvc,
@@ -214,7 +211,6 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) {
214211 }{
215212 {"/apps", h.handleApps},
216213 {"/backups", h.handleBackups},
217- {"/secrets", h.handleSecrets},
218214 {"/tags", h.handleTags},
219215 {"/images", h.handleImages},
220216 {"/logs", h.handleLogs},
@@ -276,112 +272,6 @@ func (h *Handler) handleNetworks(w http.ResponseWriter, r *http.Request) {
276272 h.sendJSON(w, http.StatusOK, dto.NetworksResponse{Networks: response})
277273 }
278274
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-
385275 // handleHealth handles /admin/health endpoint.
386276 // Returns detailed health status for all routes including HTTP probe results.
387277 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 {
2828 AuthSvc: inmocks.NewMockAuthService(t),
2929 ContainerSvc: inmocks.NewMockContainerService(t),
3030 HealthSvc: inmocks.NewMockHealthService(t),
31- SecretSvc: inmocks.NewMockSecretService(t),
3231 Log: testLogger(),
3332 ReloadTrigger: noopReloadTrigger{},
3433 AppSvc: appSvc,
M internal/adapters/in/http/admin/handler_retired.go +2 -1
@@ -6,7 +6,7 @@ import (
66 "github.com/bnema/gordon/internal/adapters/dto"
77 )
88
9-// retiredMutationPaths are legacy mutation endpoints removed by the v3
9+// retiredMutationPaths are legacy endpoints removed by the v3
1010 // declarative-apps cutover. Only the 410 Gone rejection dispatcher below
1111 // remains; no old business handlers or compatibility behavior is kept.
1212 var retiredMutationPaths = []string{
@@ -23,6 +23,7 @@ var retiredMutationPaths = []string{
2323 "/preview",
2424 "/previews",
2525 "/autoroute/allowed-domains",
26+ "/secrets",
2627 }
2728
2829 // 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 {
2020 AuthSvc: inmocks.NewMockAuthService(t),
2121 ContainerSvc: inmocks.NewMockContainerService(t),
2222 HealthSvc: inmocks.NewMockHealthService(t),
23- SecretSvc: inmocks.NewMockSecretService(t),
2423 Log: testLogger(),
2524 ReloadTrigger: noopReloadTrigger{},
2625 })
@@ -46,6 +45,9 @@ func TestHandler_RetiredMutations_Return410(t *testing.T) {
4645 {http.MethodPost, "/admin/preview/blog"},
4746 {http.MethodGet, "/admin/previews"},
4847 {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"},
4951 }
5052 for _, target := range targets {
5153 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 {
8686 AuthSvc: inmocks.NewMockAuthService(t),
8787 ContainerSvc: inmocks.NewMockContainerService(t),
8888 HealthSvc: inmocks.NewMockHealthService(t),
89- SecretSvc: inmocks.NewMockSecretService(t),
9089 Log: testLogger(),
9190 ReloadTrigger: noopReloadTrigger{},
9291 }
@@ -156,180 +155,15 @@ func TestHandler_VolumesPrune_RequiresVolumesWriteScope(t *testing.T) {
156155
157156 // Routes endpoint tests
158157
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-
322158 func TestHandler_Status_RequiresReadScope(t *testing.T) {
323159 configSvc := inmocks.NewMockConfigService(t)
324160 authSvc := inmocks.NewMockAuthService(t)
325161 containerSvc := inmocks.NewMockContainerService(t)
326- secretSvc := inmocks.NewMockSecretService(t)
327162
328163 handler := newTestHandler(t, func(d *HandlerDeps) {
329164 d.ConfigSvc = configSvc
330165 d.AuthSvc = authSvc
331166 d.ContainerSvc = containerSvc
332- d.SecretSvc = secretSvc
333167 })
334168
335169 tests := []struct {
@@ -408,13 +242,11 @@ func TestHandler_Config_RequiresReadScope(t *testing.T) {
408242 configSvc := inmocks.NewMockConfigService(t)
409243 authSvc := inmocks.NewMockAuthService(t)
410244 containerSvc := inmocks.NewMockContainerService(t)
411- secretSvc := inmocks.NewMockSecretService(t)
412245
413246 handler := newTestHandler(t, func(d *HandlerDeps) {
414247 d.ConfigSvc = configSvc
415248 d.AuthSvc = authSvc
416249 d.ContainerSvc = containerSvc
417- d.SecretSvc = secretSvc
418250 })
419251
420252 tests := []struct {
@@ -468,14 +300,12 @@ func TestHandler_Reload_RequiresWriteScope(t *testing.T) {
468300 configSvc := inmocks.NewMockConfigService(t)
469301 authSvc := inmocks.NewMockAuthService(t)
470302 containerSvc := inmocks.NewMockContainerService(t)
471- secretSvc := inmocks.NewMockSecretService(t)
472303 reloadTrigger := &reloadTriggerRecorder{}
473304
474305 handler := newTestHandler(t, func(d *HandlerDeps) {
475306 d.ConfigSvc = configSvc
476307 d.AuthSvc = authSvc
477308 d.ContainerSvc = containerSvc
478- d.SecretSvc = secretSvc
479309 d.ReloadTrigger = reloadTrigger
480310 })
481311
@@ -567,61 +397,15 @@ func TestHandler_Reload_UsesDetachedContextWithDeadline(t *testing.T) {
567397
568398 // Functional tests
569399
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-
614400 func TestHandler_MethodNotAllowed(t *testing.T) {
615401 configSvc := inmocks.NewMockConfigService(t)
616402 authSvc := inmocks.NewMockAuthService(t)
617403 containerSvc := inmocks.NewMockContainerService(t)
618- secretSvc := inmocks.NewMockSecretService(t)
619404
620405 handler := newTestHandler(t, func(d *HandlerDeps) {
621406 d.ConfigSvc = configSvc
622407 d.AuthSvc = authSvc
623408 d.ContainerSvc = containerSvc
624- d.SecretSvc = secretSvc
625409 })
626410
627411 tests := []struct {
@@ -653,13 +437,11 @@ func TestHandler_Tags_InvalidRepository(t *testing.T) {
653437 configSvc := inmocks.NewMockConfigService(t)
654438 authSvc := inmocks.NewMockAuthService(t)
655439 containerSvc := inmocks.NewMockContainerService(t)
656- secretSvc := inmocks.NewMockSecretService(t)
657440
658441 handler := newTestHandler(t, func(d *HandlerDeps) {
659442 d.ConfigSvc = configSvc
660443 d.AuthSvc = authSvc
661444 d.ContainerSvc = containerSvc
662- d.SecretSvc = secretSvc
663445 })
664446
665447 tests := []struct {
@@ -688,14 +470,12 @@ func TestHandler_Tags_Success(t *testing.T) {
688470 configSvc := inmocks.NewMockConfigService(t)
689471 authSvc := inmocks.NewMockAuthService(t)
690472 containerSvc := inmocks.NewMockContainerService(t)
691- secretSvc := inmocks.NewMockSecretService(t)
692473 registrySvc := inmocks.NewMockRegistryService(t)
693474
694475 handler := newTestHandler(t, func(d *HandlerDeps) {
695476 d.ConfigSvc = configSvc
696477 d.AuthSvc = authSvc
697478 d.ContainerSvc = containerSvc
698- d.SecretSvc = secretSvc
699479 d.RegistrySvc = registrySvc
700480 })
701481
@@ -721,14 +501,12 @@ func TestHandler_Tags_Error(t *testing.T) {
721501 configSvc := inmocks.NewMockConfigService(t)
722502 authSvc := inmocks.NewMockAuthService(t)
723503 containerSvc := inmocks.NewMockContainerService(t)
724- secretSvc := inmocks.NewMockSecretService(t)
725504 registrySvc := inmocks.NewMockRegistryService(t)
726505
727506 handler := newTestHandler(t, func(d *HandlerDeps) {
728507 d.ConfigSvc = configSvc
729508 d.AuthSvc = authSvc
730509 d.ContainerSvc = containerSvc
731- d.SecretSvc = secretSvc
732510 d.RegistrySvc = registrySvc
733511 })
734512
@@ -748,14 +526,12 @@ func TestHandler_Tags_URLDecodedRepository(t *testing.T) {
748526 configSvc := inmocks.NewMockConfigService(t)
749527 authSvc := inmocks.NewMockAuthService(t)
750528 containerSvc := inmocks.NewMockContainerService(t)
751- secretSvc := inmocks.NewMockSecretService(t)
752529 registrySvc := inmocks.NewMockRegistryService(t)
753530
754531 handler := newTestHandler(t, func(d *HandlerDeps) {
755532 d.ConfigSvc = configSvc
756533 d.AuthSvc = authSvc
757534 d.ContainerSvc = containerSvc
758- d.SecretSvc = secretSvc
759535 d.RegistrySvc = registrySvc
760536 })
761537
@@ -775,13 +551,11 @@ func TestHandler_NotFound(t *testing.T) {
775551 configSvc := inmocks.NewMockConfigService(t)
776552 authSvc := inmocks.NewMockAuthService(t)
777553 containerSvc := inmocks.NewMockContainerService(t)
778- secretSvc := inmocks.NewMockSecretService(t)
779554
780555 handler := newTestHandler(t, func(d *HandlerDeps) {
781556 d.ConfigSvc = configSvc
782557 d.AuthSvc = authSvc
783558 d.ContainerSvc = containerSvc
784- d.SecretSvc = secretSvc
785559 })
786560
787561 req := httptest.NewRequest("GET", "/admin/unknown", nil)
@@ -797,7 +571,6 @@ func TestHandler_BackupsStatus(t *testing.T) {
797571 configSvc := inmocks.NewMockConfigService(t)
798572 authSvc := inmocks.NewMockAuthService(t)
799573 containerSvc := inmocks.NewMockContainerService(t)
800- secretSvc := inmocks.NewMockSecretService(t)
801574 backupSvc := inmocks.NewMockBackupService(t)
802575
803576 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) {
806579 d.ConfigSvc = configSvc
807580 d.AuthSvc = authSvc
808581 d.ContainerSvc = containerSvc
809- d.SecretSvc = secretSvc
810582 d.BackupSvc = backupSvc
811583 })
812584
@@ -826,7 +598,6 @@ func TestHandler_BackupsListApp(t *testing.T) {
826598 configSvc := inmocks.NewMockConfigService(t)
827599 authSvc := inmocks.NewMockAuthService(t)
828600 containerSvc := inmocks.NewMockContainerService(t)
829- secretSvc := inmocks.NewMockSecretService(t)
830601 backupSvc := inmocks.NewMockBackupService(t)
831602
832603 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) {
835606 d.ConfigSvc = configSvc
836607 d.AuthSvc = authSvc
837608 d.ContainerSvc = containerSvc
838- d.SecretSvc = secretSvc
839609 d.BackupSvc = backupSvc
840610 })
841611
@@ -853,7 +623,6 @@ func TestHandler_BackupsRunApp(t *testing.T) {
853623 configSvc := inmocks.NewMockConfigService(t)
854624 authSvc := inmocks.NewMockAuthService(t)
855625 containerSvc := inmocks.NewMockContainerService(t)
856- secretSvc := inmocks.NewMockSecretService(t)
857626 backupSvc := inmocks.NewMockBackupService(t)
858627
859628 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) {
862631 d.ConfigSvc = configSvc
863632 d.AuthSvc = authSvc
864633 d.ContainerSvc = containerSvc
865- d.SecretSvc = secretSvc
866634 d.BackupSvc = backupSvc
867635 })
868636
@@ -925,7 +693,6 @@ func TestHandler_BackupsRunApp_ChunkedBodyIsDecoded(t *testing.T) {
925693 configSvc := inmocks.NewMockConfigService(t)
926694 authSvc := inmocks.NewMockAuthService(t)
927695 containerSvc := inmocks.NewMockContainerService(t)
928- secretSvc := inmocks.NewMockSecretService(t)
929696 backupSvc := inmocks.NewMockBackupService(t)
930697
931698 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) {
934701 d.ConfigSvc = configSvc
935702 d.AuthSvc = authSvc
936703 d.ContainerSvc = containerSvc
937- d.SecretSvc = secretSvc
938704 d.BackupSvc = backupSvc
939705 })
940706
@@ -991,7 +757,6 @@ func TestHandler_ImagesGet_ReturnsMappedList(t *testing.T) {
991757 configSvc := inmocks.NewMockConfigService(t)
992758 authSvc := inmocks.NewMockAuthService(t)
993759 containerSvc := inmocks.NewMockContainerService(t)
994- secretSvc := inmocks.NewMockSecretService(t)
995760
996761 createdAt := time.Date(2026, time.February, 8, 14, 30, 0, 0, time.UTC)
997762 imageSvc := &stubImageService{
@@ -1021,7 +786,6 @@ func TestHandler_ImagesGet_ReturnsMappedList(t *testing.T) {
1021786 d.ConfigSvc = configSvc
1022787 d.AuthSvc = authSvc
1023788 d.ContainerSvc = containerSvc
1024- d.SecretSvc = secretSvc
1025789 d.ImageSvc = imageSvc
1026790 })
1027791
@@ -1059,7 +823,6 @@ func TestHandler_ImagesPrune_AcceptsOptionalKeepLast(t *testing.T) {
1059823 configSvc := inmocks.NewMockConfigService(t)
1060824 authSvc := inmocks.NewMockAuthService(t)
1061825 containerSvc := inmocks.NewMockContainerService(t)
1062- secretSvc := inmocks.NewMockSecretService(t)
1063826
1064827 tests := []struct {
1065828 name string
@@ -1088,7 +851,6 @@ func TestHandler_ImagesPrune_AcceptsOptionalKeepLast(t *testing.T) {
1088851 d.ConfigSvc = configSvc
1089852 d.AuthSvc = authSvc
1090853 d.ContainerSvc = containerSvc
1091- d.SecretSvc = secretSvc
1092854 d.ImageSvc = imageSvc
1093855 })
1094856
@@ -1111,7 +873,6 @@ func TestHandler_Images_ErrorMappingForServiceFailures(t *testing.T) {
1111873 configSvc := inmocks.NewMockConfigService(t)
1112874 authSvc := inmocks.NewMockAuthService(t)
1113875 containerSvc := inmocks.NewMockContainerService(t)
1114- secretSvc := inmocks.NewMockSecretService(t)
1115876
1116877 imageSvc := &stubImageService{
1117878 listImagesFunc: func(context.Context) ([]domain.ImageInfo, error) {
@@ -1123,7 +884,6 @@ func TestHandler_Images_ErrorMappingForServiceFailures(t *testing.T) {
1123884 d.ConfigSvc = configSvc
1124885 d.AuthSvc = authSvc
1125886 d.ContainerSvc = containerSvc
1126- d.SecretSvc = secretSvc
1127887 d.ImageSvc = imageSvc
1128888 })
1129889
@@ -1142,7 +902,6 @@ func TestHandler_Images_ErrorMappingForServiceFailures(t *testing.T) {
1142902 configSvc := inmocks.NewMockConfigService(t)
1143903 authSvc := inmocks.NewMockAuthService(t)
1144904 containerSvc := inmocks.NewMockContainerService(t)
1145- secretSvc := inmocks.NewMockSecretService(t)
1146905
1147906 imageSvc := &stubImageService{
1148907 pruneFunc: func(context.Context, domain.ImagePruneOptions) (domain.ImagePruneReport, error) {
@@ -1154,7 +913,6 @@ func TestHandler_Images_ErrorMappingForServiceFailures(t *testing.T) {
1154913 d.ConfigSvc = configSvc
1155914 d.AuthSvc = authSvc
1156915 d.ContainerSvc = containerSvc
1157- d.SecretSvc = secretSvc
1158916 d.ImageSvc = imageSvc
1159917 })
1160918
@@ -1175,13 +933,11 @@ func TestHandler_ImagesPrune_ValidationAndAvailability(t *testing.T) {
1175933 configSvc := inmocks.NewMockConfigService(t)
1176934 authSvc := inmocks.NewMockAuthService(t)
1177935 containerSvc := inmocks.NewMockContainerService(t)
1178- secretSvc := inmocks.NewMockSecretService(t)
1179936
1180937 handler := newTestHandler(t, func(d *HandlerDeps) {
1181938 d.ConfigSvc = configSvc
1182939 d.AuthSvc = authSvc
1183940 d.ContainerSvc = containerSvc
1184- d.SecretSvc = secretSvc
1185941 d.ImageSvc = &stubImageService{}
1186942 })
1187943
@@ -1199,13 +955,11 @@ func TestHandler_ImagesPrune_ValidationAndAvailability(t *testing.T) {
1199955 configSvc := inmocks.NewMockConfigService(t)
1200956 authSvc := inmocks.NewMockAuthService(t)
1201957 containerSvc := inmocks.NewMockContainerService(t)
1202- secretSvc := inmocks.NewMockSecretService(t)
1203958
1204959 handler := newTestHandler(t, func(d *HandlerDeps) {
1205960 d.ConfigSvc = configSvc
1206961 d.AuthSvc = authSvc
1207962 d.ContainerSvc = containerSvc
1208- d.SecretSvc = secretSvc
1209963 })
1210964
1211965 req := httptest.NewRequest("GET", "/admin/images", nil)
@@ -1223,7 +977,6 @@ func TestHandler_ImagesPrune_ScopeDefaults(t *testing.T) {
1223977 configSvc := inmocks.NewMockConfigService(t)
1224978 authSvc := inmocks.NewMockAuthService(t)
1225979 containerSvc := inmocks.NewMockContainerService(t)
1226- secretSvc := inmocks.NewMockSecretService(t)
1227980
1228981 tests := []struct {
1229982 name string
@@ -1281,7 +1034,6 @@ func TestHandler_ImagesPrune_ScopeDefaults(t *testing.T) {
12811034 d.ConfigSvc = configSvc
12821035 d.AuthSvc = authSvc
12831036 d.ContainerSvc = containerSvc
1284- d.SecretSvc = secretSvc
12851037 d.ImageSvc = imageSvc
12861038 })
12871039
@@ -1306,13 +1058,11 @@ func TestHandler_Images_Authorization(t *testing.T) {
13061058 configSvc := inmocks.NewMockConfigService(t)
13071059 authSvc := inmocks.NewMockAuthService(t)
13081060 containerSvc := inmocks.NewMockContainerService(t)
1309- secretSvc := inmocks.NewMockSecretService(t)
13101061
13111062 handler := newTestHandler(t, func(d *HandlerDeps) {
13121063 d.ConfigSvc = configSvc
13131064 d.AuthSvc = authSvc
13141065 d.ContainerSvc = containerSvc
1315- d.SecretSvc = secretSvc
13161066 d.ImageSvc = &stubImageService{}
13171067 })
13181068
M internal/adapters/in/http/admin/local_authority.go +0 -7
@@ -22,7 +22,6 @@ var localAuthorityScopes = []string{
2222 domain.AdminScopeApps(domain.AdminActionRead, domain.AdminActionWrite),
2323 domain.AdminScopeStatus(domain.AdminActionRead),
2424 domain.AdminScopeConfig(domain.AdminActionRead, domain.AdminActionWrite),
25- domain.AdminScopeSecrets(domain.AdminActionRead, domain.AdminActionWrite),
2625 domain.AdminScopeLogs(domain.AdminActionRead),
2726 domain.AdminScopeVolumes(domain.AdminActionRead, domain.AdminActionWrite),
2827 }
@@ -64,7 +63,6 @@ func localPathAllowed(method, cleanedPath string) bool {
6463 prefixRules := map[string]func(string, []string) bool{
6564 "apps": localAppPathAllowed,
6665 "backups": localBackupPathAllowed,
67- "secrets": localSecretPathAllowed,
6866 "logs": localLogPathAllowed,
6967 "tags": localTagPathAllowed,
7068 }
@@ -72,11 +70,6 @@ func localPathAllowed(method, cleanedPath string) bool {
7270 return ok && rule(method, parts)
7371 }
7472
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-
8073 func localLogPathAllowed(method string, parts []string) bool {
8174 return (len(parts) == 1 || len(parts) == 3 && parts[1] != "" && parts[2] != "") && method == http.MethodGet
8275 }
M internal/adapters/in/http/admin/local_authority_test.go +3 -5
@@ -35,8 +35,6 @@ func TestLocalAuthorityInjectsLeastPrivilegeScopes(t *testing.T) {
3535 {domain.AdminResourceStatus, domain.AdminActionRead},
3636 {domain.AdminResourceConfig, domain.AdminActionRead},
3737 {domain.AdminResourceConfig, domain.AdminActionWrite},
38- {domain.AdminResourceSecrets, domain.AdminActionRead},
39- {domain.AdminResourceSecrets, domain.AdminActionWrite},
4038 {domain.AdminResourceLogs, domain.AdminActionRead},
4139 {domain.AdminResourceVolumes, domain.AdminActionRead},
4240 {domain.AdminResourceVolumes, domain.AdminActionWrite},
@@ -44,6 +42,7 @@ func TestLocalAuthorityInjectsLeastPrivilegeScopes(t *testing.T) {
4442 assert.True(t, HasAccess(ctx, access.resource, access.action), "%s:%s", access.resource, access.action)
4543 }
4644 assert.False(t, HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionRead))
45+ assert.False(t, HasAccess(ctx, "secrets", domain.AdminActionRead))
4746 assert.False(t, HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionWrite))
4847 assert.False(t, HasAccess(ctx, domain.AdminResourceLogs, domain.AdminActionWrite))
4948 assert.False(t, HasAccess(ctx, domain.AdminResourceAll, domain.AdminActionAll))
@@ -70,9 +69,6 @@ func TestLocalAuthorityCanonicalAllowlist(t *testing.T) {
7069 {http.MethodGet, "/tags/repository"},
7170 {http.MethodGet, "/logs"},
7271 {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"},
7672 {http.MethodGet, "/backups"},
7773 {http.MethodGet, "/backups/status"},
7874 {http.MethodGet, "/backups/shop"},
@@ -116,6 +112,8 @@ func TestLocalAuthorityDeniesNearMissesAndNonCanonicalPaths(t *testing.T) {
116112 {http.MethodGet, "/health"},
117113 {http.MethodGet, "/appsX"},
118114 {http.MethodGet, "/secrets"},
115+ {http.MethodGet, "/secrets/blog.example.com"},
116+ {http.MethodPost, "/secrets/blog.example.com"},
119117 {http.MethodDelete, "/secrets/blog"},
120118 {http.MethodGet, "/images/anything"},
121119 {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 (
1010
1111 "github.com/bnema/gordon/internal/boundaries/in"
1212 configusecase "github.com/bnema/gordon/internal/usecase/config"
13- secretsusecase "github.com/bnema/gordon/internal/usecase/secrets"
1413 )
1514
1615 // Kernel provides in-process service access for local CLI execution.
@@ -19,7 +18,6 @@ import (
1918 type Kernel struct {
2019 authEnabled bool
2120 configSvc in.ConfigService
22- secretSvc in.SecretService
2321 containerSvc in.ContainerService
2422 backupSvc in.BackupService
2523 volumeBackupSvc in.VolumeBackupService
@@ -87,7 +85,6 @@ func newKernel(configPath string, initLog kernelLoggerInit) (*Kernel, error) {
8785 kernel := &Kernel{
8886 authEnabled: cfg.Auth.Enabled,
8987 configSvc: svc.configSvc,
90- secretSvc: svc.secretSvc,
9188 containerSvc: svc.containerSvc,
9289 backupSvc: svc.backupSvc,
9390 volumeBackupSvc: svc.volumeBackupSvc,
@@ -116,18 +113,9 @@ func newKernel(configPath string, initLog kernelLoggerInit) (*Kernel, error) {
116113 return nil, fmt.Errorf("failed to load configuration: %w", err)
117114 }
118115
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-
127116 return &Kernel{
128117 authEnabled: cfg.Auth.Enabled,
129118 configSvc: configSvc,
130- secretSvc: secretSvc,
131119 log: log,
132120 cleanup: cleanup,
133121 }, nil
@@ -162,8 +150,6 @@ func (k *Kernel) Close() error {
162150
163151 func (k *Kernel) Config() in.ConfigService { return k.configSvc }
164152
165-func (k *Kernel) Secrets() in.SecretService { return k.secretSvc }
166-
167153 func (k *Kernel) Container() in.ContainerService { return k.containerSvc }
168154
169155 func (k *Kernel) Backup() in.BackupService { return k.backupSvc }
M internal/app/kernel_test.go +0 -1
@@ -37,7 +37,6 @@ secrets_backend = "unsafe"
3737 t.Cleanup(func() { require.NoError(t, kernel.Close()) })
3838
3939 require.NotNil(t, kernel.Config())
40- require.NotNil(t, kernel.Secrets())
4140 }
4241
4342 // 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 (
3131 acmelego "github.com/bnema/gordon/internal/adapters/out/acmelego"
3232 acmestore "github.com/bnema/gordon/internal/adapters/out/acmestore"
3333 "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"
3634 "github.com/bnema/gordon/internal/adapters/out/eventbus"
3735 "github.com/bnema/gordon/internal/adapters/out/filesystem"
3836 "github.com/bnema/gordon/internal/adapters/out/httpprober"
@@ -90,7 +88,6 @@ import (
9088 "github.com/bnema/gordon/internal/usecase/publictls"
9189 registrySvc "github.com/bnema/gordon/internal/usecase/registry"
9290 "github.com/bnema/gordon/internal/usecase/registrystate"
93- secretsSvc "github.com/bnema/gordon/internal/usecase/secrets"
9491 servicecfg "github.com/bnema/gordon/internal/usecase/services"
9592 "github.com/bnema/gordon/internal/usecase/traffic"
9693 volumesSvc "github.com/bnema/gordon/internal/usecase/volumes"
@@ -168,10 +165,6 @@ type Config struct {
168165 } `mapstructure:"access_log"`
169166 } `mapstructure:"logging"`
170167
171- Env struct {
172- Dir string `mapstructure:"dir"`
173- } `mapstructure:"env"`
174-
175168 Volumes struct {
176169 AutoCreate bool `mapstructure:"auto_create"`
177170 Prefix string `mapstructure:"prefix"`
@@ -298,11 +291,9 @@ type services struct {
298291 backupStorage *filesystem.BackupStorage
299292 volumeBackupStore out.VolumeBackupStorage
300293 volumeBackupCfg domain.VolumeBackupConfig
301- envLoader out.EnvLoader
302294 logWriter *logwriter.LogWriter
303295 tokenStore out.TokenStore
304296 configSvc *config.Service
305- secretSvc *secretsSvc.Service
306297 containerSvc *container.Service
307298 backupSvc *backup.Service
308299 volumeBackupSvc *backup.VolumeService
@@ -320,7 +311,6 @@ type services struct {
320311 httpsProxyHandler http.Handler
321312 internalRegUser string
322313 internalRegPass string
323- envDir string
324314 maxBlobChunkSize int64
325315 maxBlobSize int64
326316 caAdapter *pkiadapter.CA
@@ -484,6 +474,7 @@ var retiredAppConfigKeys = []struct {
484474 {"auto_route", "feature removed; declare explicit interfaces"},
485475 {"auto_route_allowed_domains", "feature removed; declare explicit interfaces"},
486476 {"previews", "staging is an ordinary app file"},
477+ {"env", "remove the [env] section; declare [env] and [services.<name>.secrets] in app files"},
487478 }
488479
489480 // validateRetiredAppConfig rejects obsolete application configuration
@@ -814,20 +805,14 @@ func startPublicTLSRuntime(ctx context.Context, svc publicTLSRuntime, log zerowr
814805 return reconcileErr
815806 }
816807
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.
818810 func (si *serviceInit) initSecrets() error {
819- envDir, backend, passStore, domainSecretStore, err := createDomainSecretStore(si.cfg, si.log)
811+ backend, err := resolveSecretsBackend(si.cfg.Auth.SecretsBackend)
820812 if err != nil {
821- return err
822- }
823- si.svc.envDir = envDir
824-
825- if si.svc.envLoader, err = createEnvLoader(backend, envDir, passStore, si.log); err != nil {
826- return err
813+ return si.log.WrapErr(err, "failed to resolve secrets backend")
827814 }
828-
829815 si.svc.serviceSecretProvider = createStandaloneServiceSecretProvider(backend, resolveDataDir(si.cfg.Server.DataDir), si.log)
830- si.svc.secretSvc = secretsSvc.NewService(domainSecretStore, si.log, si.svc.eventBus)
831816 return nil
832817 }
833818
@@ -1180,7 +1165,6 @@ func (si *serviceInit) initHandlers() {
11801165 AuthSvc: si.svc.authSvc,
11811166 ContainerSvc: si.svc.containerSvc,
11821167 HealthSvc: si.svc.healthSvc,
1183- SecretSvc: si.svc.secretSvc,
11841168 LogSvc: si.svc.logSvc,
11851169 RegistrySvc: si.svc.registrySvc,
11861170 ReloadTrigger: si.svc.reloadCoordinator,
@@ -1240,32 +1224,6 @@ func setupInternalRegistryAuth(svc *services, log zerowrap.Logger) error {
12401224 return nil
12411225 }
12421226
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-
12691227 // resolveLogFilePath returns the configured log file path or a default.
12701228 func resolveLogFilePath(cfg Config) string {
12711229 if cfg.Logging.File.Path != "" {
@@ -1370,38 +1328,6 @@ func createStorage(cfg Config, log zerowrap.Logger) (*filesystem.BlobStorage, *f
13701328 return blobStorage, manifestStorage, nil
13711329 }
13721330
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-
14051331 // createLogWriter creates the container log writer.
14061332 func createLogWriter(cfg Config, log zerowrap.Logger) (*logwriter.LogWriter, error) {
14071333 if !cfg.Logging.ContainerLogs.Enabled {
@@ -1667,15 +1593,6 @@ func resolveDataDir(dataDir string) string {
16671593 return dataDir
16681594 }
16691595
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-
16791596 func resolveRegistryDomains(cfg Config) (string, []string) {
16801597 registryDomain := cfg.Server.RegistryDomain
16811598 if registryDomain == "" {
@@ -2265,7 +2182,7 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc
22652182 if err != nil {
22662183 return nil, err
22672184 }
2268- return container.NewService(svc.runtime, svc.envLoader, svc.eventBus, svc.logWriter, containerConfig), nil
2185+ return container.NewService(svc.runtime, svc.eventBus, svc.logWriter, containerConfig), nil
22692186 }
22702187
22712188 type databaseBackupSettingsConfig struct {
@@ -4225,7 +4142,6 @@ func loadConfig(v *viper.Viper, configPath string) error {
42254142 v.SetDefault("logging.access_log.max_age", 28)
42264143 v.SetDefault("logging.access_log.exclude_health_checks", true)
42274144 v.SetDefault("logging.access_log.syslog_identifier", "gordon-access")
4228- v.SetDefault("env.dir", "") // defaults to {data_dir}/env when empty
42294145 v.SetDefault("auth.enabled", true)
42304146 // Note: auth.type defaults to "token" (the only supported mode)
42314147 v.SetDefault("auth.secrets_backend", "")
M internal/app/run_app_lifecycle_test.go +2 -2
@@ -141,7 +141,7 @@ func TestGracefulShutdown_FailsClosedWhenAppAdministrationDoesNotQuiesce(t *test
141141
142142 store := outmocks.NewMockAppState(t)
143143
144- containerSvc := container.NewService(nil, nil, nil, nil, container.Config{})
144+ containerSvc := container.NewService(nil, nil, nil, container.Config{})
145145 err := gracefulShutdown(nil, nil, nil, containerSvc, nil, nil, nil, nil, nil, admin, store, zerowrap.Default())
146146
147147 require.Error(t, err)
@@ -164,7 +164,7 @@ func TestGracefulShutdown_QuiescesAppAdministrationBeforeClosingState(t *testing
164164 store := outmocks.NewMockAppState(t)
165165 store.EXPECT().Close().Run(func() { order = append(order, "state-close") }).Return(nil)
166166
167- containerSvc := container.NewService(nil, nil, nil, nil, container.Config{})
167+ containerSvc := container.NewService(nil, nil, nil, container.Config{})
168168 require.NoError(t, gracefulShutdown(nil, nil, nil, containerSvc, nil, nil, nil, nil, nil, admin, store, zerowrap.Default()))
169169
170170 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) {
3636 require.ErrorContains(t, err, "config-retired")
3737 require.ErrorContains(t, err, `"routes"`)
3838 }
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) {
311311 log: zerowrap.Default(),
312312 svc: newTestServices(&services{
313313 configSvc: configSvc,
314- containerSvc: container.NewService(nil, nil, nil, nil, container.Config{}),
314+ containerSvc: container.NewService(nil, nil, nil, container.Config{}),
315315 internalRegUser: "gordon",
316316 internalRegPass: "secret",
317317 reloadCoordinator: newReloadCoordinator(v, &reloadRecorder{}, &proxyRecorder{}, nil, nil, nil, zerowrap.Default()),
@@ -361,7 +361,7 @@ func TestServiceInit_ReloadUpdatesManagementHosts(t *testing.T) {
361361 log: zerowrap.Default(),
362362 svc: newTestServices(&services{
363363 configSvc: configSvc,
364- containerSvc: container.NewService(nil, nil, nil, nil, container.Config{}),
364+ containerSvc: container.NewService(nil, nil, nil, container.Config{}),
365365 internalRegUser: "gordon",
366366 internalRegPass: "secret",
367367 reloadCoordinator: newReloadCoordinator(v, &reloadRecorder{}, &proxyRecorder{}, nil, nil, publicTLS, zerowrap.Default()),
@@ -393,7 +393,7 @@ func TestServiceInit_RegisterReloadCoordinatorHooks_WiresContainerConfigApplier(
393393 log: zerowrap.Default(),
394394 svc: newTestServices(&services{
395395 configSvc: configSvc,
396- containerSvc: container.NewService(nil, nil, nil, nil, container.Config{}),
396+ containerSvc: container.NewService(nil, nil, nil, container.Config{}),
397397 internalRegUser: "gordon",
398398 internalRegPass: "secret",
399399 reloadCoordinator: newReloadCoordinator(v, &reloadRecorder{}, &proxyRecorder{}, nil, nil, nil, zerowrap.Default()),
@@ -429,7 +429,7 @@ func TestServiceInit_RegisterReloadCoordinatorHooks_AppliesTraffic(t *testing.T)
429429 log: zerowrap.Default(),
430430 svc: newTestServices(&services{
431431 configSvc: configSvc,
432- containerSvc: container.NewService(nil, nil, nil, nil, container.Config{}),
432+ containerSvc: container.NewService(nil, nil, nil, container.Config{}),
433433 internalRegUser: "gordon",
434434 internalRegPass: "secret",
435435 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 (
7777 // Admin scope resource constants.
7878 const (
7979 AdminResourceRoutes = "routes"
80- AdminResourceSecrets = "secrets"
8180 AdminResourceConfig = "config"
8281 AdminResourceStatus = "status"
8382 AdminResourceLogs = "logs"
@@ -287,11 +286,6 @@ func AdminScopeRoutes(actions ...string) string {
287286 return fmt.Sprintf("%s:%s:%s", ScopeTypeAdmin, AdminResourceRoutes, strings.Join(actions, ","))
288287 }
289288
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-
295289 // AdminScopeConfig creates an admin scope for config with the given actions.
296290 func AdminScopeConfig(actions ...string) string {
297291 return fmt.Sprintf("%s:%s:%s", ScopeTypeAdmin, AdminResourceConfig, strings.Join(actions, ","))
M internal/domain/env.go +2 -74
@@ -1,71 +1,15 @@
11 package domain
22
33 import (
4- "bufio"
5- "bytes"
64 "regexp"
75 "strings"
86 )
97
108 var (
11- envKeyRegex = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
12- containerNameRegex = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]*$`)
13- secretRefRegex = regexp.MustCompile(`\$\{(pass|sops):[^}]+\}`)
9+ envKeyRegex = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
10+ secretRefRegex = regexp.MustCompile(`\$\{(pass|sops):[^}]+\}`)
1411 )
1512
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-
6913 // ValidateEnvKey validates an env key for storage.
7014 func ValidateEnvKey(key string) error {
7115 if key == "" {
@@ -87,19 +31,3 @@ func ValidateEnvKey(key string) error {
8731 func ContainsSecretReference(value string) bool {
8832 return secretRefRegex.MatchString(value)
8933 }
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 @@
11 package domain
22
33 import (
4- "encoding/base64"
5- "strings"
64 "testing"
75
86 "github.com/stretchr/testify/assert"
97 "github.com/stretchr/testify/require"
108 )
119
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-
14510 func TestValidateEnvKey(t *testing.T) {
14611 tests := []struct {
14712 name string
@@ -209,44 +74,3 @@ func TestContainsSecretReference(t *testing.T) {
20974 })
21075 }
21176 }
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 (
117117 ErrSecretNotFound = errors.New("secret not found")
118118 ErrSecretsAlreadyExist = errors.New("secrets already exist")
119119 ErrProviderNotFound = errors.New("secret provider not found")
120- ErrInvalidContainerName = errors.New("invalid container name")
121120 ErrAttachmentOwnershipMismatch = errors.New("attachment ownership mismatch")
122121 ErrReadinessLogSizeExceeded = errors.New("readiness log exceeds maximum")
123122
M internal/domain/event.go +3 -11
@@ -9,10 +9,9 @@ import (
99 type EventType string
1010
1111 const (
12- EventImagePushed EventType = "image.pushed"
13- EventImageDeleted EventType = "image.deleted"
14- EventConfigReload EventType = "config.reload"
15- EventSecretsChanged EventType = "secrets.changed"
12+ EventImagePushed EventType = "image.pushed"
13+ EventImageDeleted EventType = "image.deleted"
14+ EventConfigReload EventType = "config.reload"
1615 )
1716
1817 // Event represents a domain event that occurred in the system.
@@ -43,13 +42,6 @@ type ConfigReloadPayload struct {
4342 UpdatedRoutes []string
4443 }
4544
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-
5345 // Context keys for domain-level concerns.
5446 type contextKey string
5547
M internal/domain/route.go +1 -1
@@ -5,7 +5,7 @@ type Route struct {
55 Domain string
66 Image string
77 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").
99 }
1010
1111 // ProxyTarget represents the destination for proxying requests.
M internal/testutils/fixtures/configs/basic.toml +0 -4
@@ -18,10 +18,6 @@ auto_create = true
1818 prefix = "gordon"
1919 preserve = true
2020
21-[env]
22-dir = "/tmp/env"
23-providers = ["pass", "sops"]
24-
2521 [logging]
2622 enabled = true
2723 level = "info"⏎̸
M internal/testutils/helpers.go +0 -4
@@ -59,10 +59,6 @@ auto_create = true
5959 prefix = "gordon"
6060 preserve = true
6161
62-[env]
63-dir = "/tmp/env"
64-providers = ["pass", "sops"]
65-
6662 [logging]
6763 enabled = true
6864 level = "info"`
M internal/usecase/container/service.go +0 -3
@@ -29,7 +29,6 @@ type Config struct {
2929 // Service implements the ContainerService interface.
3030 type Service struct {
3131 runtime out.ContainerRuntime
32- envLoader out.EnvLoader
3332 eventBus out.EventPublisher
3433 logWriter out.ContainerLogWriter
3534 config Config
@@ -39,14 +38,12 @@ type Service struct {
3938 // NewService creates a new container service.
4039 func NewService(
4140 runtime out.ContainerRuntime,
42- envLoader out.EnvLoader,
4341 eventBus out.EventPublisher,
4442 logWriter out.ContainerLogWriter,
4543 config Config,
4644 ) *Service {
4745 return &Service{
4846 runtime: runtime,
49- envLoader: envLoader,
5047 eventBus: eventBus,
5148 logWriter: logWriter,
5249 config: config,
M internal/usecase/container/service_test.go +1 -1
@@ -18,7 +18,7 @@ func testContext() context.Context {
1818
1919 func TestService_ListNetworks(t *testing.T) {
2020 runtime := mocks.NewMockContainerRuntime(t)
21- svc := NewService(runtime, nil, nil, nil, Config{NetworkPrefix: "gordon"})
21+ svc := NewService(runtime, nil, nil, Config{NetworkPrefix: "gordon"})
2222 ctx := testContext()
2323
2424 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
4040 # Workload logs are read from the container runtime with
4141 # `gordon apps logs APP --service SERVICE`.
4242
43-# Environment directory
44-[env]
45-dir = "~/.gordon/env"
46-
4743 # Volume settings (all enabled by default)
4844 [volumes]
4945 auto_create = true
@@ -82,26 +78,14 @@ openssl rand -base64 32 | pass insert -m gordon/auth/token_secret
8278 gordon auth token generate --subject ci-bot --scopes push,pull --expiry 0
8379 ```
8480
85-### 4. Create Environment Files
81+### 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:
8684
8785 ```bash
88-# App environment
89-cat > ~/.gordon/env/app_company_com.env <<EOF
90-NODE_ENV=production
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
86+gordon apps apply --file ./app.toml
87+pass show company/db-password \
88+ | gordon apps secrets set app --service web --stdin --key DATABASE_PASSWORD
10589 ```
10690
10791 ### 5. Configure Cloudflare
@@ -148,7 +132,7 @@ vim ~/.config/gordon/gordon.toml
148132 # Change: "app.company.com" = "company-app:v2.2.0"
149133
150134 # Reload to deploy
151-gordon reload
135+gordon daemon reload
152136 ```
153137
154138 ## 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
99 Verify connectivity:
1010
1111 ```bash
12-gordon status --remote https://gordon.example.com --token "$GORDON_TOKEN"
12+gordon daemon status --remote https://gordon.example.com --token "$GORDON_TOKEN"
1313 ```
1414
1515 ## App management
@@ -46,10 +46,10 @@ gordon backups volume status
4646
4747 ## Logs
4848
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:
5050
5151 ```bash
52-gordon logs --remote https://gordon.example.com --token "$GORDON_TOKEN"
52+gordon daemon logs --remote https://gordon.example.com --token "$GORDON_TOKEN"
5353 gordon apps logs APP --service SERVICE --remote https://gordon.example.com --token "$GORDON_TOKEN"
5454 ```
5555
M wiki/guides/secrets-pass.md +6 -6
@@ -106,17 +106,17 @@ token_secret = "gordon/auth/token_secret"
106106 # password_hash = "gordon/auth/password_hash"
107107 ```
108108
109-### Using Route Secrets
109+### Using App Secrets
110110
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>`.
112112
113113 ```bash
114-# Store secrets for a domain
115-gordon secrets set app.mydomain.com DATABASE_URL "postgresql://user:pass@postgres:5432/app"
116-gordon secrets set app.mydomain.com API_KEY "your-api-key"
114+gordon apps apply --file ./blog.toml
115+printf 'DATABASE_URL=%s\n' "$(pass show myapp/database-url)" \
116+ | gordon apps secrets set blog --service web --stdin
117117 ```
118118
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).
120120
121121 ## Organizing Secrets
122122
M wiki/guides/secrets-sops.md +1 -11
@@ -208,17 +208,7 @@ The path format is `file:key.path` where:
208208 - `file` is the SOPS-encrypted file path
209209 - `key.path` is dot-notation to the value
210210
211-Route secrets remain in `.env` files with `${sops:...}` references. The SOPS backend is used for auth secrets (like `token_secret`) and provider lookups.
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-```
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)).
222212
223213 ## File Organization
224214
M wiki/tutorials/first-deploy.md +3 -3
@@ -87,7 +87,7 @@ On your Gordon server, edit `~/.config/gordon/gordon.toml`:
8787 Reload Gordon:
8888
8989 ```bash
90-gordon reload
90+gordon daemon reload
9191 ```
9292
9393 ### 5. Push to Deploy
@@ -130,7 +130,7 @@ docker login -u deploy -p <token> registry.mydomain.com
130130
131131 2. Check Gordon logs:
132132 ```bash
133- gordon logs -f
133+ gordon daemon logs -f
134134 ```
135135
136136 3. Ensure DNS points to your server
@@ -159,6 +159,6 @@ Gordon replaces the running container: it withdraws traffic, stops and removes t
159159
160160 ## Next Steps
161161
162-- [Add environment variables](/docs/config/env.md)
162+- [Add environment variables](/docs/config/apps.md)
163163 - [Add a database](./postgres-service.md)
164164 - [Set up CI/CD](/docs/deployment/github-actions.md)
M wiki/tutorials/postgres-service.md +1 -1
@@ -56,7 +56,7 @@ enabled = true
5656 Reload Gordon:
5757
5858 ```bash
59-gordon reload
59+gordon daemon reload
6060 ```
6161
6262 ### 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