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 Unverified
B5690EEEBB952194The key is unknown - refs
- tag: v3.0.0-alpha.2
116 files · +876 -8390
M .mockery.yaml +0 -3
| @@ -17,7 +17,6 @@ packages: | |||
| 17 | 17 | EventSubscriber: | |
| 18 | 18 | EventHandler: | |
| 19 | 19 | SecretProvider: | |
| 20 | - | EnvLoader: | |
| 21 | 20 | ContainerLogWriter: | |
| 22 | 21 | Metrics: | |
| 23 | 22 | LogExporter: | |
| @@ -27,7 +26,6 @@ packages: | |||
| 27 | 26 | AppStateReader: | |
| 28 | 27 | ImageResolver: | |
| 29 | 28 | SecretWriter: | |
| 30 | - | DomainSecretStore: | |
| 31 | 29 | RateLimiter: | |
| 32 | 30 | BackupStorage: | |
| 33 | 31 | HTTPChallengeSink: | |
| @@ -52,7 +50,6 @@ packages: | |||
| 52 | 50 | AuthService: | |
| 53 | 51 | HealthService: | |
| 54 | 52 | HTTPProber: | |
| 55 | - | SecretService: | |
| 56 | 53 | LogService: | |
| 57 | 54 | VolumeService: | |
| 58 | 55 | PublicTLSService: | |
M README.md +9 -8
| @@ -38,7 +38,7 @@ Build locally, push to your Gordon server, declare the app, deploy: | |||
| 38 | 38 | ||
| 39 | 39 | ```bash | |
| 40 | 40 | # Push the image (OCI transfer only, never deploys) | |
| 41 | - | gordon push myapp --build --remote prod | |
| 41 | + | gordon images push myapp --build --remote prod | |
| 42 | 42 | ||
| 43 | 43 | # Declare the app (blog.toml references the pushed tag) | |
| 44 | 44 | gordon apps apply --file blog.toml --remote prod | |
| @@ -47,7 +47,7 @@ gordon apps apply --file blog.toml --remote prod | |||
| 47 | 47 | gordon apps deploy blog --remote prod | |
| 48 | 48 | ||
| 49 | 49 | # Check status | |
| 50 | - | gordon status | |
| 50 | + | gordon daemon status | |
| 51 | 51 | gordon apps status blog --remote prod | |
| 52 | 52 | ``` | |
| 53 | 53 | ||
| @@ -106,8 +106,10 @@ gordon apps deploy blog --remote prod | |||
| 106 | 106 | | Command | Description | | |
| 107 | 107 | |---------|-------------| | |
| 108 | 108 | | `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 | | |
| 111 | 113 | ||
| 112 | 114 | ### Applications | |
| 113 | 115 | ||
| @@ -123,23 +125,22 @@ gordon apps deploy blog --remote prod | |||
| 123 | 125 | | `gordon apps stop APP` | Stop, preserve all data | | |
| 124 | 126 | | `gordon apps start APP` | Start a stopped app | | |
| 125 | 127 | | `gordon apps remove APP` | Remove workloads (volumes/secrets retained) | | |
| 126 | - | | `gordon push [image]` | Tag and push an image (never deploys) | | |
| 127 | 128 | ||
| 128 | 129 | ### Images & Registry | |
| 129 | 130 | ||
| 130 | 131 | | Command | Description | | |
| 131 | 132 | |---------|-------------| | |
| 133 | + | | `gordon images push [image]` | Tag and push an image (never deploys) | | |
| 132 | 134 | | `gordon images list` | List runtime and registry images | | |
| 133 | 135 | | `gordon images prune` | Clean up dangling images and old tags | | |
| 134 | 136 | | `gordon images tags <repo>` | List registry tags for a repository | | |
| 135 | 137 | ||
| 136 | - | ### Secrets & Config | |
| 138 | + | ### Secrets | |
| 137 | 139 | ||
| 138 | 140 | | Command | Description | | |
| 139 | 141 | |---------|-------------| | |
| 142 | + | | `gordon apps secrets list APP` | List app secret names | | |
| 140 | 143 | | `gordon apps secrets set APP --service SVC KEY=VAL` | Set app secret values | | |
| 141 | - | | `gordon secrets list <domain>` | List installation secret keys | | |
| 142 | - | | `gordon secrets set <domain> --from-file PATH` | Set installation secrets | | |
| 143 | 144 | ||
| 144 | 145 | ### Remotes & Auth | |
| 145 | 146 | ||
M docs/cli/apps.md +2 -2
| @@ -364,7 +364,7 @@ never accepted. `--service` is required when the app has several services. | |||
| 364 | 364 | ||
| 365 | 365 | ```bash | |
| 366 | 366 | # Push the image first (OCI transfer only) | |
| 367 | - | gordon push myapp --build --remote prod | |
| 367 | + | gordon images push myapp --build --remote prod | |
| 368 | 368 | ||
| 369 | 369 | # Apply the manifest that references the pushed tag | |
| 370 | 370 | gordon apps apply --file blog.toml --remote prod | |
| @@ -386,5 +386,5 @@ gordon apps status blog --remote prod | |||
| 386 | 386 | ## Related | |
| 387 | 387 | ||
| 388 | 388 | - [CLI Overview](./index.md) | |
| 389 | - | - [Push Command](./push.md) | |
| 389 | + | - [Images Commands](./images.md#gordon-images-push) | |
| 390 | 390 | - [Deployment](../deployment/index.md) | |
M docs/cli/auth.md +1 -3
| @@ -135,7 +135,7 @@ Standard Go durations also work: `24h`, `30m`, `1h30m` | |||
| 135 | 135 | ||
| 136 | 136 | Registry scopes: `push`, `pull`, `push,pull` | |
| 137 | 137 | ||
| 138 | - | Admin scopes: `admin:*:*`, `admin:apps:read`, `admin:apps:write`, `admin:config:read`, `admin:config:write`, `admin:status:read`, `admin:logs:read`, `admin:volumes:read`, `admin:volumes:write`, `admin:secrets:read`, `admin:secrets:write` | |
| 138 | + | Admin scopes: `admin:*:*`, `admin:apps:read`, `admin:apps:write`, `admin:config:read`, `admin:config:write`, `admin:status:read`, `admin:logs:read`, `admin:volumes:read`, `admin:volumes:write` | |
| 139 | 139 | ||
| 140 | 140 | Combine scopes with commas: | |
| 141 | 141 | ||
| @@ -277,8 +277,6 @@ Usage: | |||
| 277 | 277 | | `admin:config:write` | Config write access | | |
| 278 | 278 | | `admin:status:read` | Read-only status/health | | |
| 279 | 279 | | `admin:logs:read` | Read-only logs access | | |
| 280 | - | | `admin:secrets:read` | List secret keys | | |
| 281 | - | | `admin:secrets:write` | Set/delete secrets | | |
| 282 | 280 | ||
| 283 | 281 | ## Token Expiry Formats | |
| 284 | 282 | ||
D docs/cli/config.md +0 -112
| @@ -1,112 +0,0 @@ | |||
| 1 | - | # Config Commands | |
| 2 | - | ||
| 3 | - | Inspect Gordon server configuration. | |
| 4 | - | ||
| 5 | - | Remote targeting uses client config or an active remote by default. | |
| 6 | - | Use `--remote` and `--token` to override. See [CLI Overview](./index.md). | |
| 7 | - | ||
| 8 | - | ## gordon config | |
| 9 | - | ||
| 10 | - | ### Subcommands | |
| 11 | - | ||
| 12 | - | | Subcommand | Description | | |
| 13 | - | |------------|-------------| | |
| 14 | - | | `show` | Show server configuration | | |
| 15 | - | | `validate` | Statically validate a local configuration file | | |
| 16 | - | ||
| 17 | - | --- | |
| 18 | - | ||
| 19 | - | ## gordon config show | |
| 20 | - | ||
| 21 | - | Display the Gordon installation configuration including server settings, | |
| 22 | - | network isolation, volumes, and external route domains. App routes live | |
| 23 | - | under `gordon apps show`, never here. Sensitive filesystem paths and upstream external route targets are redacted by default. | |
| 24 | - | ||
| 25 | - | ```bash | |
| 26 | - | gordon config show | |
| 27 | - | gordon config show --json | |
| 28 | - | gordon config show --remote https://gordon.mydomain.com --token $TOKEN | |
| 29 | - | ``` | |
| 30 | - | ||
| 31 | - | ### Flags | |
| 32 | - | ||
| 33 | - | | Flag | Description | | |
| 34 | - | |------|-------------| | |
| 35 | - | | `--json` | Output as JSON | | |
| 36 | - | ||
| 37 | - | ### JSON Output | |
| 38 | - | ||
| 39 | - | ```json | |
| 40 | - | { | |
| 41 | - | "server": { | |
| 42 | - | "port": 1111, | |
| 43 | - | "registry_port": 5000, | |
| 44 | - | "registry_domain": "reg.example.com" | |
| 45 | - | }, | |
| 46 | - | "network_isolation": { | |
| 47 | - | "enabled": true, | |
| 48 | - | "prefix": "gordon" | |
| 49 | - | }, | |
| 50 | - | "volumes": { | |
| 51 | - | "auto_create": true, | |
| 52 | - | "prefix": "gordon", | |
| 53 | - | "preserve": true | |
| 54 | - | }, | |
| 55 | - | "external_routes": [ | |
| 56 | - | {"domain": "reg.example.com"} | |
| 57 | - | ] | |
| 58 | - | } | |
| 59 | - | ``` | |
| 60 | - | ||
| 61 | - | External route targets and `server.data_dir` are intentionally omitted from the default admin config response because they reveal internal network and filesystem layout. | |
| 62 | - | ||
| 63 | - | --- | |
| 64 | - | ||
| 65 | - | ## gordon config validate | |
| 66 | - | ||
| 67 | - | Statically validates a candidate configuration file before it is installed. | |
| 68 | - | This command is local-only: `--remote` is rejected. Validation is static — | |
| 69 | - | runtime, ACTIVE-state, secret, pull, and listener checks are not performed. | |
| 70 | - | A file that fails validation exits non-zero; `--json` is still written first. | |
| 71 | - | ||
| 72 | - | ```bash | |
| 73 | - | gordon config validate --file ./gordon.toml | |
| 74 | - | gordon config validate --file ./gordon.toml --json | |
| 75 | - | ``` | |
| 76 | - | ||
| 77 | - | ### Flags | |
| 78 | - | ||
| 79 | - | | Flag | Description | | |
| 80 | - | |------|-------------| | |
| 81 | - | | `--file` | Local candidate configuration file (required) | | |
| 82 | - | | `--json` | Output as JSON | | |
| 83 | - | ||
| 84 | - | ### JSON Output | |
| 85 | - | ||
| 86 | - | On success: | |
| 87 | - | ||
| 88 | - | ```json | |
| 89 | - | { | |
| 90 | - | "valid": true, | |
| 91 | - | "diagnostics": [], | |
| 92 | - | "scope": "static" | |
| 93 | - | } | |
| 94 | - | ``` | |
| 95 | - | ||
| 96 | - | On failure, `valid` is `false` and `diagnostics` carries the failure: | |
| 97 | - | ||
| 98 | - | ```json | |
| 99 | - | { | |
| 100 | - | "valid": false, | |
| 101 | - | "diagnostics": [ | |
| 102 | - | {"code": "config-invalid", "key": "", "message": "configuration failed static validation"} | |
| 103 | - | ], | |
| 104 | - | "scope": "static" | |
| 105 | - | } | |
| 106 | - | ``` | |
| 107 | - | ||
| 108 | - | ## Related | |
| 109 | - | ||
| 110 | - | - [CLI Overview](./index.md) | |
| 111 | - | - [Status Command](./status.md) | |
| 112 | - | - [Apps Commands](./apps.md) | |
A docs/cli/daemon.md +518 -0
| @@ -0,0 +1,518 @@ | |||
| 1 | + | # Daemon Commands | |
| 2 | + | ||
| 3 | + | Inspect and operate the Gordon daemon: status, process logs, reload, configuration, TLS, traffic, and networks. | |
| 4 | + | ||
| 5 | + | These commands target the local daemon through its owner-only socket, or the daemon selected with `--remote`, `GORDON_REMOTE`, or the active remote. See [CLI Overview](./index.md). | |
| 6 | + | ||
| 7 | + | ## gordon daemon | |
| 8 | + | ||
| 9 | + | | Subcommand | Description | | |
| 10 | + | |------------|-------------| | |
| 11 | + | | `status` | Show daemon status and the app fleet summary | | |
| 12 | + | | `logs` | Show daemon process logs | | |
| 13 | + | | `reload` | Reload installation configuration | | |
| 14 | + | | `config show` | Show installation configuration | | |
| 15 | + | | `config validate` | Statically validate a local configuration file | | |
| 16 | + | | `tls` | Show public TLS certificate status | | |
| 17 | + | | `traffic` | Show traffic entrypoint, router, and counter status | | |
| 18 | + | | `networks` | List Gordon-managed networks | | |
| 19 | + | ||
| 20 | + | --- | |
| 21 | + | ||
| 22 | + | ## gordon daemon status | |
| 23 | + | ||
| 24 | + | Display installation identity plus one status line per app (from desired/active state, no container inspection). Per-service detail lives under `gordon apps show APP` and `gordon apps status APP`. | |
| 25 | + | ||
| 26 | + | ```bash | |
| 27 | + | gordon daemon status | |
| 28 | + | gordon daemon status --remote prod | |
| 29 | + | ``` | |
| 30 | + | ||
| 31 | + | `gordon daemon status` works in local mode and remote mode. | |
| 32 | + | ||
| 33 | + | - Local mode reads status from in-process services. | |
| 34 | + | - Remote mode reads status from the target admin API. | |
| 35 | + | ||
| 36 | + | ### Output | |
| 37 | + | ||
| 38 | + | ``` | |
| 39 | + | Gordon Status | |
| 40 | + | ||
| 41 | + | Gordon Domain: gordon.example.com | |
| 42 | + | Registry Port: 5000 | |
| 43 | + | Server Port: 8088 | |
| 44 | + | Apps: 3 | |
| 45 | + | Network Isolation: false | |
| 46 | + | ||
| 47 | + | Container Status: | |
| 48 | + | blog: active | |
| 49 | + | shop: deploying | |
| 50 | + | old-site: stopped | |
| 51 | + | ``` | |
| 52 | + | ||
| 53 | + | ### Information Displayed | |
| 54 | + | ||
| 55 | + | | Field | Description | | |
| 56 | + | |-------|-------------| | |
| 57 | + | | Gordon Domain | Public Gordon domain from configuration | | |
| 58 | + | | Registry Port | Docker registry port | | |
| 59 | + | | Server Port | Gordon admin port | | |
| 60 | + | | Apps | Total apps in desired/active state | | |
| 61 | + | | Network Isolation | Whether installation network policy is enabled | | |
| 62 | + | | Container Status | Fleet status per app (see states below) | | |
| 63 | + | ||
| 64 | + | ### App States | |
| 65 | + | ||
| 66 | + | | State | Description | | |
| 67 | + | |-------|-------------| | |
| 68 | + | | active | App deployed and converged on desired state | | |
| 69 | + | | deploying | Desired state diverges from effective state | | |
| 70 | + | | pending | App applied but never deployed | | |
| 71 | + | | stopped | Durable stopped intent (stays stopped across reboot) | | |
| 72 | + | ||
| 73 | + | ### Flags | |
| 74 | + | ||
| 75 | + | Uses the global remote flags: | |
| 76 | + | ||
| 77 | + | | Flag | Description | | |
| 78 | + | |------|-------------| | |
| 79 | + | | `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) | | |
| 80 | + | | `--token-file` | Read the remote token from a mode 0600 file | | |
| 81 | + | ||
| 82 | + | ### Environment Variables | |
| 83 | + | ||
| 84 | + | | Variable | Description | | |
| 85 | + | |----------|-------------| | |
| 86 | + | | `GORDON_REMOTE` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) | | |
| 87 | + | | `GORDON_TOKEN` | Authentication token | | |
| 88 | + | ||
| 89 | + | ### Examples | |
| 90 | + | ||
| 91 | + | ### Check Local or Remote Status | |
| 92 | + | ||
| 93 | + | ```bash | |
| 94 | + | # Local | |
| 95 | + | gordon daemon status | |
| 96 | + | ||
| 97 | + | # Using a saved remote | |
| 98 | + | gordon daemon status --remote prod | |
| 99 | + | ||
| 100 | + | # Using environment variables | |
| 101 | + | export GORDON_REMOTE=https://gordon.mydomain.com | |
| 102 | + | export GORDON_TOKEN=your-token | |
| 103 | + | gordon daemon status | |
| 104 | + | ``` | |
| 105 | + | ||
| 106 | + | ### Quick Fleet Check | |
| 107 | + | ||
| 108 | + | ```bash | |
| 109 | + | # Check for non-converged apps | |
| 110 | + | gordon daemon status --remote prod | grep -E "(deploying|pending|stopped)" | |
| 111 | + | ``` | |
| 112 | + | ||
| 113 | + | ### Required Permissions (Remote Only) | |
| 114 | + | ||
| 115 | + | Remote status calls require `admin:status:read` scope in the authentication token. | |
| 116 | + | ||
| 117 | + | ```bash | |
| 118 | + | # Generate token with required scope | |
| 119 | + | gordon auth token generate --subject admin --scopes admin:status:read | |
| 120 | + | ``` | |
| 121 | + | ||
| 122 | + | --- | |
| 123 | + | ||
| 124 | + | ## gordon daemon logs | |
| 125 | + | ||
| 126 | + | Display Gordon daemon process logs. Application workload output is read with | |
| 127 | + | `gordon apps logs APP --service SVC`, which resolves the app's active container | |
| 128 | + | through the daemon. | |
| 129 | + | ||
| 130 | + | ### Synopsis | |
| 131 | + | ||
| 132 | + | ```bash | |
| 133 | + | gordon daemon logs [options] | |
| 134 | + | ``` | |
| 135 | + | ||
| 136 | + | ### Options | |
| 137 | + | ||
| 138 | + | | Option | Short | Default | Description | | |
| 139 | + | |--------|-------|---------|-------------| | |
| 140 | + | | `--config` | `-c` | Auto | Path to config file | | |
| 141 | + | | `--follow` | `-f` | false | Follow log output (like `tail -f`) | | |
| 142 | + | | `--lines` | `-n` | 50 | Number of lines to show | | |
| 143 | + | | `--remote, -r` | | | Remote name or URL (e.g., prod, https://gordon.mydomain.com) | | |
| 144 | + | | `--token-file` | | | Read the remote token from a mode 0600 file | | |
| 145 | + | ||
| 146 | + | Remote targeting uses client config or an active remote by default. Use | |
| 147 | + | `--remote` and `--token-file` to override. See [CLI Overview](./index.md). | |
| 148 | + | ||
| 149 | + | Remote log access requires an admin token with `admin:logs:read` (or `admin:*:*`). `admin:status:read` is not sufficient for logs. | |
| 150 | + | ||
| 151 | + | ### Examples | |
| 152 | + | ||
| 153 | + | ```bash | |
| 154 | + | # Gordon process logs | |
| 155 | + | gordon daemon logs # Last 50 lines | |
| 156 | + | gordon daemon logs -f # Follow logs | |
| 157 | + | gordon daemon logs -n 100 # Last 100 lines | |
| 158 | + | gordon daemon logs -f -n 200 # Follow, starting from last 200 lines | |
| 159 | + | ||
| 160 | + | # App service logs | |
| 161 | + | gordon apps logs blog --service web | |
| 162 | + | gordon apps logs blog --service web --follow --remote prod | |
| 163 | + | ||
| 164 | + | # Remote process logs (override) | |
| 165 | + | gordon daemon logs --remote prod | |
| 166 | + | ``` | |
| 167 | + | ||
| 168 | + | ### Log Locations | |
| 169 | + | ||
| 170 | + | ```bash | |
| 171 | + | # Using gordon daemon logs | |
| 172 | + | gordon daemon logs -f | |
| 173 | + | ||
| 174 | + | # Direct file access | |
| 175 | + | tail -f ~/.gordon/logs/gordon.log | |
| 176 | + | ||
| 177 | + | # With systemd | |
| 178 | + | journalctl --user -u gordon -f | |
| 179 | + | ||
| 180 | + | # App service logs through Gordon | |
| 181 | + | gordon apps logs blog --service web --tail 50 | |
| 182 | + | gordon apps logs blog --service web --follow | |
| 183 | + | ``` | |
| 184 | + | ||
| 185 | + | --- | |
| 186 | + | ||
| 187 | + | ## gordon daemon reload | |
| 188 | + | ||
| 189 | + | Reload installation configuration. | |
| 190 | + | ||
| 191 | + | ### Synopsis | |
| 192 | + | ||
| 193 | + | ```bash | |
| 194 | + | gordon daemon reload | |
| 195 | + | ``` | |
| 196 | + | ||
| 197 | + | ### Description | |
| 198 | + | ||
| 199 | + | Sends `SIGUSR1` to the running Gordon process, triggering: | |
| 200 | + | ||
| 201 | + | - Installation settings reload (live keys apply, restart-required keys are reported) | |
| 202 | + | - Traffic/proxy state refresh from ACTIVE app projection | |
| 203 | + | ||
| 204 | + | Reload never activates pending desired app state, re-resolves images, or flips intent. Obsolete application keys (`routes`, `attachments`, `services`, `auto`, previews, …) are rejected with a `config-retired` diagnostic before any mutation. | |
| 205 | + | ||
| 206 | + | ### Example | |
| 207 | + | ||
| 208 | + | ```bash | |
| 209 | + | # After editing gordon.toml, apply changes without restart | |
| 210 | + | vim ~/.config/gordon/gordon.toml | |
| 211 | + | gordon daemon reload | |
| 212 | + | ``` | |
| 213 | + | ||
| 214 | + | --- | |
| 215 | + | ||
| 216 | + | ## gordon daemon config show | |
| 217 | + | ||
| 218 | + | Display the Gordon installation configuration including server settings, | |
| 219 | + | network isolation, volumes, and external route domains. App routes live | |
| 220 | + | under `gordon apps show`, never here. Sensitive filesystem paths and upstream external route targets are redacted by default. | |
| 221 | + | ||
| 222 | + | ```bash | |
| 223 | + | gordon daemon config show | |
| 224 | + | gordon daemon config show --json | |
| 225 | + | gordon daemon config show --remote prod | |
| 226 | + | ``` | |
| 227 | + | ||
| 228 | + | ### Flags | |
| 229 | + | ||
| 230 | + | | Flag | Description | | |
| 231 | + | |------|-------------| | |
| 232 | + | | `--json` | Output as JSON | | |
| 233 | + | ||
| 234 | + | ### JSON Output | |
| 235 | + | ||
| 236 | + | ```json | |
| 237 | + | { | |
| 238 | + | "server": { | |
| 239 | + | "port": 1111, | |
| 240 | + | "registry_port": 5000, | |
| 241 | + | "registry_domain": "reg.example.com" | |
| 242 | + | }, | |
| 243 | + | "network_isolation": { | |
| 244 | + | "enabled": true, | |
| 245 | + | "prefix": "gordon" | |
| 246 | + | }, | |
| 247 | + | "volumes": { | |
| 248 | + | "auto_create": true, | |
| 249 | + | "prefix": "gordon", | |
| 250 | + | "preserve": true | |
| 251 | + | }, | |
| 252 | + | "external_routes": [ | |
| 253 | + | {"domain": "reg.example.com"} | |
| 254 | + | ] | |
| 255 | + | } | |
| 256 | + | ``` | |
| 257 | + | ||
| 258 | + | External route targets and `server.data_dir` are intentionally omitted from the default admin config response because they reveal internal network and filesystem layout. | |
| 259 | + | ||
| 260 | + | --- | |
| 261 | + | ||
| 262 | + | ## gordon daemon config validate | |
| 263 | + | ||
| 264 | + | Statically validates a candidate configuration file before it is installed. | |
| 265 | + | This command is local-only: `--remote` is rejected. Validation is static — | |
| 266 | + | runtime, ACTIVE-state, secret, pull, and listener checks are not performed. | |
| 267 | + | A file that fails validation exits non-zero; `--json` is still written first. | |
| 268 | + | ||
| 269 | + | ```bash | |
| 270 | + | gordon daemon config validate --file ./gordon.toml | |
| 271 | + | gordon daemon config validate --file ./gordon.toml --json | |
| 272 | + | ``` | |
| 273 | + | ||
| 274 | + | ### Flags | |
| 275 | + | ||
| 276 | + | | Flag | Description | | |
| 277 | + | |------|-------------| | |
| 278 | + | | `--file` | Local candidate configuration file (required) | | |
| 279 | + | | `--json` | Output as JSON | | |
| 280 | + | ||
| 281 | + | ### JSON Output | |
| 282 | + | ||
| 283 | + | On success: | |
| 284 | + | ||
| 285 | + | ```json | |
| 286 | + | { | |
| 287 | + | "valid": true, | |
| 288 | + | "diagnostics": [], | |
| 289 | + | "scope": "static" | |
| 290 | + | } | |
| 291 | + | ``` | |
| 292 | + | ||
| 293 | + | On failure, `valid` is `false` and `diagnostics` carries the failure: | |
| 294 | + | ||
| 295 | + | ```json | |
| 296 | + | { | |
| 297 | + | "valid": false, | |
| 298 | + | "diagnostics": [ | |
| 299 | + | {"code": "config-invalid", "key": "", "message": "configuration failed static validation"} | |
| 300 | + | ], | |
| 301 | + | "scope": "static" | |
| 302 | + | } | |
| 303 | + | ``` | |
| 304 | + | ||
| 305 | + | --- | |
| 306 | + | ||
| 307 | + | ## gordon daemon tls | |
| 308 | + | ||
| 309 | + | Inspect public TLS/ACME certificate status. | |
| 310 | + | ||
| 311 | + | Gordon serves normal HTTPS fallback on TLS-capable entrypoints such as `entrypoints.edge` with `protocol = "smart_tcp"`. Certificate priority is static certificates first, then public ACME certificates, then Gordon's internal CA. | |
| 312 | + | ||
| 313 | + | ACME challenge notes: | |
| 314 | + | ||
| 315 | + | - DNS-01 (`cloudflare-dns-01`) does not require a special external port 80 edge. | |
| 316 | + | - HTTP-01 requires an HTTP-capable smart TCP entrypoint reachable on external port 80 for every hostname being validated. | |
| 317 | + | - TLS-ALPN-01 is not supported. | |
| 318 | + | ||
| 319 | + | Display the current public TLS/ACME certificate status, including ACME mode, | |
| 320 | + | certificate details, route coverage, and any errors. | |
| 321 | + | ||
| 322 | + | ```bash | |
| 323 | + | gordon daemon tls | |
| 324 | + | gordon daemon tls --json | |
| 325 | + | gordon daemon tls --remote prod | |
| 326 | + | ``` | |
| 327 | + | ||
| 328 | + | ### Flags | |
| 329 | + | ||
| 330 | + | | Flag | Description | | |
| 331 | + | |------|-------------| | |
| 332 | + | | `--json` | Output as JSON | | |
| 333 | + | ||
| 334 | + | ### Human Output | |
| 335 | + | ||
| 336 | + | ```text | |
| 337 | + | Public TLS / ACME Status | |
| 338 | + | ||
| 339 | + | ACME: enabled | |
| 340 | + | Configured Mode: auto | |
| 341 | + | Effective Mode: http-01 | |
| 342 | + | Reason: configured | |
| 343 | + | Token Source: env | |
| 344 | + | ||
| 345 | + | Certificates | |
| 346 | + | ID: cert-abc123 | |
| 347 | + | Names: example.com, www.example.com | |
| 348 | + | Status: valid | |
| 349 | + | Not After: 2026-05-29 12:00:00 | |
| 350 | + | ||
| 351 | + | Route Coverage | |
| 352 | + | example.com covered=yes covered_by=cert-abc123 | |
| 353 | + | internal.local covered=no error=self-signed cert | |
| 354 | + | ||
| 355 | + | Errors | |
| 356 | + | route internal.local has no ACME cert | |
| 357 | + | ``` | |
| 358 | + | ||
| 359 | + | ### JSON Output | |
| 360 | + | ||
| 361 | + | ```json | |
| 362 | + | { | |
| 363 | + | "acme_enabled": true, | |
| 364 | + | "configured_mode": "auto", | |
| 365 | + | "effective_mode": "http-01", | |
| 366 | + | "selection_reason": "configured", | |
| 367 | + | "token_source": "env", | |
| 368 | + | "certificates": [ | |
| 369 | + | { | |
| 370 | + | "id": "cert-abc123", | |
| 371 | + | "names": ["example.com", "www.example.com"], | |
| 372 | + | "challenge": "http-01", | |
| 373 | + | "status": "valid", | |
| 374 | + | "not_after": "2026-05-29T12:00:00Z", | |
| 375 | + | "renewal_pending": false | |
| 376 | + | } | |
| 377 | + | ], | |
| 378 | + | "routes": [ | |
| 379 | + | { | |
| 380 | + | "domain": "example.com", | |
| 381 | + | "covered": true, | |
| 382 | + | "covered_by": "cert-abc123", | |
| 383 | + | "required_acme": true | |
| 384 | + | }, | |
| 385 | + | { | |
| 386 | + | "domain": "internal.local", | |
| 387 | + | "covered": false, | |
| 388 | + | "required_acme": false, | |
| 389 | + | "error": "self-signed cert" | |
| 390 | + | } | |
| 391 | + | ], | |
| 392 | + | "errors": ["route internal.local has no ACME cert"] | |
| 393 | + | } | |
| 394 | + | ``` | |
| 395 | + | ||
| 396 | + | ### Token Source | |
| 397 | + | ||
| 398 | + | The `token_source` field indicates where the ACME token was sourced from | |
| 399 | + | (e.g., `env`, `file`, `config`). The token value is never displayed. | |
| 400 | + | ||
| 401 | + | --- | |
| 402 | + | ||
| 403 | + | ## gordon daemon traffic | |
| 404 | + | ||
| 405 | + | ```bash | |
| 406 | + | gordon daemon traffic --remote prod | |
| 407 | + | gordon daemon traffic --remote https://gordon.example.com --json | |
| 408 | + | ``` | |
| 409 | + | ||
| 410 | + | Remote mode queries the running Gordon admin API. Local mode does not synthesize runtime traffic state from a fresh config load; use a configured remote target for authoritative counters and reload status. | |
| 411 | + | ||
| 412 | + | ### Flags | |
| 413 | + | ||
| 414 | + | | Flag | Description | | |
| 415 | + | |------|-------------| | |
| 416 | + | | `--json` | Output machine-readable JSON | | |
| 417 | + | | `--remote`, `-r` | Remote Gordon instance or saved remote name | | |
| 418 | + | ||
| 419 | + | ### JSON Output | |
| 420 | + | ||
| 421 | + | ```json | |
| 422 | + | { | |
| 423 | + | "last_reload_status": "ok", | |
| 424 | + | "entrypoints": [ | |
| 425 | + | { | |
| 426 | + | "name": "postgres", | |
| 427 | + | "address": "0.0.0.0:5432", | |
| 428 | + | "protocol": "tcp", | |
| 429 | + | "active": true, | |
| 430 | + | "active_tcp_connections": 1, | |
| 431 | + | "active_udp_sessions": 0, | |
| 432 | + | "total_accepted": 12, | |
| 433 | + | "total_refused": 0, | |
| 434 | + | "total_errors": 0, | |
| 435 | + | "bytes_in": 4096, | |
| 436 | + | "bytes_out": 8192, | |
| 437 | + | "smart_tcp": { | |
| 438 | + | "http_accepted": 0, | |
| 439 | + | "h2c_accepted": 0, | |
| 440 | + | "https_fallback_accepted": 0, | |
| 441 | + | "tls_passthrough_accepted": 0, | |
| 442 | + | "raw_fallback_accepted": 0, | |
| 443 | + | "entrypoint_cidr_refused": 0, | |
| 444 | + | "raw_fallback_cidr_refused": 0, | |
| 445 | + | "proxy_refused": 0, | |
| 446 | + | "unknown_no_fallback_refused": 0, | |
| 447 | + | "malformed_rejected": 0, | |
| 448 | + | "sniff_timeout": 0, | |
| 449 | + | "client_hello_too_large": 0 | |
| 450 | + | } | |
| 451 | + | } | |
| 452 | + | ], | |
| 453 | + | "routers": [], | |
| 454 | + | "services": [], | |
| 455 | + | "counters": { | |
| 456 | + | "active_tcp_connections": 1, | |
| 457 | + | "active_udp_sessions": 0, | |
| 458 | + | "total_accepted": 12, | |
| 459 | + | "total_refused": 0, | |
| 460 | + | "total_errors": 0, | |
| 461 | + | "bytes_in": 4096, | |
| 462 | + | "bytes_out": 8192, | |
| 463 | + | "smart_tcp": { | |
| 464 | + | "http_accepted": 0, | |
| 465 | + | "h2c_accepted": 0, | |
| 466 | + | "https_fallback_accepted": 0, | |
| 467 | + | "tls_passthrough_accepted": 0, | |
| 468 | + | "raw_fallback_accepted": 0, | |
| 469 | + | "entrypoint_cidr_refused": 0, | |
| 470 | + | "raw_fallback_cidr_refused": 0, | |
| 471 | + | "proxy_refused": 0, | |
| 472 | + | "unknown_no_fallback_refused": 0, | |
| 473 | + | "malformed_rejected": 0, | |
| 474 | + | "sniff_timeout": 0, | |
| 475 | + | "client_hello_too_large": 0 | |
| 476 | + | } | |
| 477 | + | } | |
| 478 | + | } | |
| 479 | + | ``` | |
| 480 | + | ||
| 481 | + | --- | |
| 482 | + | ||
| 483 | + | ## gordon daemon networks | |
| 484 | + | ||
| 485 | + | Display Docker networks managed by Gordon, including which containers | |
| 486 | + | are connected to each network. | |
| 487 | + | ||
| 488 | + | ```bash | |
| 489 | + | gordon daemon networks | |
| 490 | + | gordon daemon networks --json | |
| 491 | + | gordon daemon networks --remote prod | |
| 492 | + | ``` | |
| 493 | + | ||
| 494 | + | ### Flags | |
| 495 | + | ||
| 496 | + | | Flag | Description | | |
| 497 | + | |------|-------------| | |
| 498 | + | | `--json` | Output as JSON | | |
| 499 | + | ||
| 500 | + | ### JSON Output | |
| 501 | + | ||
| 502 | + | ```json | |
| 503 | + | [ | |
| 504 | + | { | |
| 505 | + | "name": "gordon_myapp", | |
| 506 | + | "driver": "bridge", | |
| 507 | + | "containers": ["container1", "container2"] | |
| 508 | + | } | |
| 509 | + | ] | |
| 510 | + | ``` | |
| 511 | + | ||
| 512 | + | ## Related | |
| 513 | + | ||
| 514 | + | - [CLI Overview](./index.md) | |
| 515 | + | - [Apps Commands](./apps.md) | |
| 516 | + | - [Serve Command](./serve.md) | |
| 517 | + | - [Traffic configuration](../config/traffic.md) | |
| 518 | + | - [Remote CLI Management](/wiki/guides/remote-cli.md) | |
M docs/cli/images.md +115 -1
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | # Images Command | |
| 2 | 2 | ||
| 3 | - | List and prune runtime/registry images. | |
| 3 | + | Push, list, and prune runtime/registry images. | |
| 4 | 4 | ||
| 5 | 5 | ## gordon images | |
| 6 | 6 | ||
| @@ -10,12 +10,126 @@ gordon images <subcommand> | |||
| 10 | 10 | ||
| 11 | 11 | Subcommands: | |
| 12 | 12 | ||
| 13 | + | - `push` - Tag and push an image to the Gordon registry (never deploys). | |
| 13 | 14 | - `list` - List runtime images and registry tags. | |
| 14 | 15 | - `prune` - Prune dangling runtime images and old registry tags. | |
| 15 | 16 | - `tags` - List registry tags for a specific repository. | |
| 16 | 17 | ||
| 17 | 18 | > **Note:** Images commands require remote mode (`--remote` + `--token`, or configured remotes). | |
| 18 | 19 | ||
| 20 | + | ## gordon images push | |
| 21 | + | ||
| 22 | + | Tag and push an image to the Gordon registry. Push transfers OCI content | |
| 23 | + | only: it never deploys. Deploy separately with `gordon apps deploy` after | |
| 24 | + | applying the manifest that references the pushed tag. | |
| 25 | + | ||
| 26 | + | ### Synopsis | |
| 27 | + | ||
| 28 | + | ```bash | |
| 29 | + | gordon images push [image] [options] | |
| 30 | + | ``` | |
| 31 | + | ||
| 32 | + | ### Arguments | |
| 33 | + | ||
| 34 | + | | Argument | Description | | |
| 35 | + | |----------|-------------| | |
| 36 | + | | `[image]` | Image name to push (optional). If omitted, auto-detected from Dockerfile labels or current directory name | | |
| 37 | + | ||
| 38 | + | ### Options | |
| 39 | + | ||
| 40 | + | | Option | Description | | |
| 41 | + | |--------|-------------| | |
| 42 | + | | `--build` | Build the image first using `docker buildx` | | |
| 43 | + | | `-f, --file` | Path to Dockerfile (default: `./Dockerfile`, used with `--build`) | | |
| 44 | + | | `--platform` | Target platform for buildx (default: `linux/amd64`) | | |
| 45 | + | | `--build-arg` | Additional build args (repeatable, `KEY=VALUE`) | | |
| 46 | + | | `--tag` | Override pushed version tag (default: tag ref from CI, then `git describe --tags --dirty`) | | |
| 47 | + | | `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) | | |
| 48 | + | | `--token` | Authentication token for remote | | |
| 49 | + | ||
| 50 | + | ### Description | |
| 51 | + | ||
| 52 | + | `gordon images push` tags the selected image for the Gordon registry and pushes it. | |
| 53 | + | ||
| 54 | + | If you do not pass `--remote` and no active remote is configured, Gordon tries to | |
| 55 | + | infer the correct saved remote by probing your saved remotes for the image. | |
| 56 | + | It auto-selects only when exactly one remote matches. If multiple remotes match, | |
| 57 | + | or a saved remote cannot be probed safely, the command stops and asks you to use | |
| 58 | + | `--remote` explicitly. | |
| 59 | + | ||
| 60 | + | Domain-style push targets are retired: push an image name, then | |
| 61 | + | `gordon apps apply` the manifest that references the pushed tag. | |
| 62 | + | ||
| 63 | + | - The version tag defaults to a CI tag ref (like `refs/tags/v1.2.3`) when available, | |
| 64 | + | then falls back to `git describe --tags --dirty` (for example | |
| 65 | + | `v1.2.3-4-gabc1234` or `v1.2.3-dirty`). If no tag is found, `latest` is used. | |
| 66 | + | - When `--build` is set, the command builds with `docker buildx build --load` | |
| 67 | + | and injects `VERSION`, `GIT_TAG`, `GIT_SHA`, and `BUILD_TIME` into the build | |
| 68 | + | environment plus any `--build-arg` values. To use these in your Dockerfile, | |
| 69 | + | declare them with `ARG` (e.g., `ARG VERSION`) then reference via `ENV` or | |
| 70 | + | in build steps. | |
| 71 | + | - Use `-f/--file` to build from a Dockerfile outside the current directory root. | |
| 72 | + | - The version tag and `latest` are both pushed (unless the version is `latest`). | |
| 73 | + | ||
| 74 | + | ### Authentication | |
| 75 | + | ||
| 76 | + | When used with `--remote`, gordon images push authenticates in two ways: | |
| 77 | + | ||
| 78 | + | - **Admin API** (tag listing for inference): uses `--token` or `$GORDON_TOKEN` as Bearer token | |
| 79 | + | - **Registry push**: automatically exchanges the token for a short-lived (5 min) registry access token via `/auth/token` -- no `docker login` required | |
| 80 | + | ||
| 81 | + | This means CI/CD pipelines only need a single secret (`GORDON_TOKEN`). | |
| 82 | + | ||
| 83 | + | ### Version Auto-Detection | |
| 84 | + | ||
| 85 | + | Gordon reads version tags from CI environment variables (in priority order): | |
| 86 | + | ||
| 87 | + | | CI System | Variable | Example | | |
| 88 | + | |-----------|----------|---------| | |
| 89 | + | | GitHub Actions | `$GITHUB_REF` | `refs/tags/v1.2.0` | | |
| 90 | + | | GitHub Actions | `$GITHUB_REF_TYPE` + `$GITHUB_REF_NAME` | `tag` + `v1.2.0` | | |
| 91 | + | | GitLab CI | `$CI_COMMIT_TAG` | `v1.2.0` | | |
| 92 | + | | Azure DevOps | `$BUILD_SOURCEBRANCH` | `refs/tags/v1.2.0` | | |
| 93 | + | | Any | `git describe --tags` | `v1.2.3-4-gabc1234` | | |
| 94 | + | | Fallback | - | `latest` | | |
| 95 | + | ||
| 96 | + | ### Examples | |
| 97 | + | ||
| 98 | + | ```bash | |
| 99 | + | # Build, push (auto-detect image name) | |
| 100 | + | gordon images push --build --remote https://gordon.example.com | |
| 101 | + | ||
| 102 | + | # Push an image | |
| 103 | + | gordon images push myapp --remote https://gordon.example.com | |
| 104 | + | ||
| 105 | + | # Push a fully qualified ref | |
| 106 | + | gordon images push registry.example.com/myapp:v1.2.3 --build --remote https://gordon.example.com | |
| 107 | + | ||
| 108 | + | # Push existing local image with an explicit tag | |
| 109 | + | gordon images push myapp --tag v1.2.0 --remote https://gordon.example.com | |
| 110 | + | ||
| 111 | + | # Build for ARM and pass build args | |
| 112 | + | gordon images push myapp --build --platform linux/arm64 --build-arg CGO_ENABLED=0 --remote https://gordon.example.com | |
| 113 | + | ||
| 114 | + | # Build from a custom Dockerfile path | |
| 115 | + | gordon images push myapp --build -f docker/app/Dockerfile --remote https://gordon.example.com | |
| 116 | + | ||
| 117 | + | # CI/CD usage (single env var, no docker login needed) | |
| 118 | + | export GORDON_TOKEN="your-token" | |
| 119 | + | gordon images push myapp --build --remote https://gordon.example.com | |
| 120 | + | ``` | |
| 121 | + | ||
| 122 | + | ### Notes | |
| 123 | + | ||
| 124 | + | - Remote mode required. See [CLI Overview](./index.md) for targeting options. | |
| 125 | + | - `--build` requires Docker with Buildx. Docker Desktop includes it; on Linux, | |
| 126 | + | install the `docker-buildx-plugin` package. | |
| 127 | + | - Gordon uses native registry uploads instead of shelling out to `docker push`. | |
| 128 | + | Image layers are sent in 50MB chunks, which stays under Cloudflare's 100MB | |
| 129 | + | per-request limit so proxied pushes keep working. Keep the server's | |
| 130 | + | `max_blob_chunk_size` larger than the client chunk size; the default `95MB` | |
| 131 | + | works out of the box. | |
| 132 | + | ||
| 19 | 133 | ## gordon images list | |
| 20 | 134 | ||
| 21 | 135 | ```bash | |
M docs/cli/index.md +22 -28
| @@ -26,16 +26,8 @@ Management commands use the authenticated daemon API. By default they connect th | |||
| 26 | 26 | |---------|-------------|---------------| | |
| 27 | 27 | | `gordon apps` | Manage applications, operations, and app secrets | [apps](./apps.md) | | |
| 28 | 28 | | `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) | | |
| 39 | 31 | | `gordon volumes` | Manage volumes | [volumes](./volumes.md) | | |
| 40 | 32 | ||
| 41 | 33 | ## Client Commands | |
| @@ -54,7 +46,7 @@ gordon serve | |||
| 54 | 46 | gordon serve --config /path/to/config.toml | |
| 55 | 47 | ||
| 56 | 48 | # Reload installation configuration (never activates app state) | |
| 57 | - | gordon reload | |
| 49 | + | gordon daemon reload | |
| 58 | 50 | ||
| 59 | 51 | # Applications (daemon-owned; fail without a reachable daemon) | |
| 60 | 52 | gordon apps apply --file blog.toml | |
| @@ -70,15 +62,21 @@ gordon apps remove blog | |||
| 70 | 62 | gordon apps operations show blog --key <operation-key> | |
| 71 | 63 | gordon apps operations watch blog --key <operation-key> | |
| 72 | 64 | 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 | |
| 74 | 72 | ||
| 75 | 73 | # Push an image (OCI transfer only; deploy separately) | |
| 76 | - | gordon push myapp --build --remote prod | |
| 74 | + | gordon images push myapp --build --remote prod | |
| 77 | 75 | ||
| 78 | 76 | # 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 | |
| 82 | 80 | gordon apps logs blog --service web # App service logs | |
| 83 | 81 | gordon apps logs blog --service web --follow # Follow app service logs | |
| 84 | 82 | ||
| @@ -86,8 +84,8 @@ gordon apps logs blog --service web --follow # Follow app service logs | |||
| 86 | 84 | gordon version | |
| 87 | 85 | ||
| 88 | 86 | # 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 | |
| 91 | 89 | ||
| 92 | 90 | # Backups | |
| 93 | 91 | gordon backups list | |
| @@ -96,6 +94,7 @@ gordon backups volume run shop --service api --volume data | |||
| 96 | 94 | gordon backups status | |
| 97 | 95 | ||
| 98 | 96 | # Images | |
| 97 | + | gordon images push myapp --build --remote prod | |
| 99 | 98 | gordon images list | |
| 100 | 99 | gordon images prune --dry-run | |
| 101 | 100 | gordon images prune --keep-releases 3 | |
| @@ -110,11 +109,6 @@ gordon auth token list | |||
| 110 | 109 | gordon auth token revoke <token-id> | |
| 111 | 110 | gordon auth internal | |
| 112 | 111 | ||
| 113 | - | # Secrets | |
| 114 | - | gordon secrets list myapp.example.com | |
| 115 | - | gordon secrets set myapp.example.com --from-file ./app.env | |
| 116 | - | gordon secrets remove myapp.example.com DATABASE_URL | |
| 117 | - | ||
| 118 | 112 | # Remotes | |
| 119 | 113 | gordon remotes add prod https://gordon.mydomain.com --token $TOKEN | |
| 120 | 114 | gordon remotes list | |
| @@ -141,14 +135,14 @@ or `GORDON_REMOTE` environment variable. Use `--remote` and `--token` as global | |||
| 141 | 135 | when you want to bypass your saved configuration. | |
| 142 | 136 | ||
| 143 | 137 | 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 | |
| 145 | 139 | remote automatically when exactly one matches. If multiple remotes match, Gordon stops with | |
| 146 | 140 | an ambiguity error and asks you to use `--remote`. If any remote probe fails, Gordon also | |
| 147 | 141 | stops rather than guessing. | |
| 148 | 142 | ||
| 149 | 143 | ```bash | |
| 150 | 144 | # Auto-inferred single match from saved remotes | |
| 151 | - | gordon push myapp --build | |
| 145 | + | gordon images push myapp --build | |
| 152 | 146 | ``` | |
| 153 | 147 | ||
| 154 | 148 | **Important:** The remote URL must be the `gordon_domain` configured on the remote Gordon instance. This is the domain that serves both the container registry and the Admin API. | |
| @@ -159,16 +153,16 @@ or in a specific entry in `~/.config/gordon/remotes.toml`. | |||
| 159 | 153 | ||
| 160 | 154 | ```bash | |
| 161 | 155 | # 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 | |
| 163 | 157 | ||
| 164 | 158 | # 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 | |
| 166 | 160 | ||
| 167 | 161 | # Using environment variables | |
| 168 | 162 | export GORDON_REMOTE=https://gordon.example.com | |
| 169 | 163 | export GORDON_TOKEN=$TOKEN | |
| 170 | 164 | export GORDON_INSECURE=true | |
| 171 | - | gordon status | |
| 165 | + | gordon daemon status | |
| 172 | 166 | ``` | |
| 173 | 167 | ||
| 174 | 168 | ## Exit Codes | |
D docs/cli/networks.md +0 -49
| @@ -1,49 +0,0 @@ | |||
| 1 | - | # Networks Commands | |
| 2 | - | ||
| 3 | - | Inspect Gordon-managed Docker networks. | |
| 4 | - | ||
| 5 | - | Remote targeting uses client config or an active remote by default. | |
| 6 | - | Use `--remote` and `--token` to override. See [CLI Overview](./index.md). | |
| 7 | - | ||
| 8 | - | ## gordon networks | |
| 9 | - | ||
| 10 | - | ### Subcommands | |
| 11 | - | ||
| 12 | - | | Subcommand | Description | | |
| 13 | - | |------------|-------------| | |
| 14 | - | | `list` | List Gordon-managed Docker networks | | |
| 15 | - | ||
| 16 | - | --- | |
| 17 | - | ||
| 18 | - | ## gordon networks list | |
| 19 | - | ||
| 20 | - | Display Docker networks managed by Gordon, including which containers | |
| 21 | - | are connected to each network. | |
| 22 | - | ||
| 23 | - | ```bash | |
| 24 | - | gordon networks list | |
| 25 | - | gordon networks list --json | |
| 26 | - | gordon networks list --remote https://gordon.mydomain.com --token $TOKEN | |
| 27 | - | ``` | |
| 28 | - | ||
| 29 | - | ### Flags | |
| 30 | - | ||
| 31 | - | | Flag | Description | | |
| 32 | - | |------|-------------| | |
| 33 | - | | `--json` | Output as JSON | | |
| 34 | - | ||
| 35 | - | ### JSON Output | |
| 36 | - | ||
| 37 | - | ```json | |
| 38 | - | [ | |
| 39 | - | { | |
| 40 | - | "name": "gordon_myapp", | |
| 41 | - | "driver": "bridge", | |
| 42 | - | "containers": ["container1", "container2"] | |
| 43 | - | } | |
| 44 | - | ] | |
| 45 | - | ``` | |
| 46 | - | ||
| 47 | - | ## Related | |
| 48 | - | ||
| 49 | - | - [CLI Overview](./index.md) | |
D docs/cli/push.md +0 -119
| @@ -1,119 +0,0 @@ | |||
| 1 | - | # Push Command | |
| 2 | - | ||
| 3 | - | Tag and push an image to the Gordon registry. Push transfers OCI content | |
| 4 | - | only: it never deploys. Deploy separately with `gordon apps deploy` after | |
| 5 | - | applying the manifest that references the pushed tag. | |
| 6 | - | ||
| 7 | - | ## gordon push | |
| 8 | - | ||
| 9 | - | ### Synopsis | |
| 10 | - | ||
| 11 | - | ```bash | |
| 12 | - | gordon push [image] [options] | |
| 13 | - | ``` | |
| 14 | - | ||
| 15 | - | ### Arguments | |
| 16 | - | ||
| 17 | - | | Argument | Description | | |
| 18 | - | |----------|-------------| | |
| 19 | - | | `[image]` | Image name to push (optional). If omitted, auto-detected from Dockerfile labels or current directory name | | |
| 20 | - | ||
| 21 | - | ### Options | |
| 22 | - | ||
| 23 | - | | Option | Description | | |
| 24 | - | |--------|-------------| | |
| 25 | - | | `--build` | Build the image first using `docker buildx` | | |
| 26 | - | | `-f, --file` | Path to Dockerfile (default: `./Dockerfile`, used with `--build`) | | |
| 27 | - | | `--platform` | Target platform for buildx (default: `linux/amd64`) | | |
| 28 | - | | `--build-arg` | Additional build args (repeatable, `KEY=VALUE`) | | |
| 29 | - | | `--tag` | Override pushed version tag (default: tag ref from CI, then `git describe --tags --dirty`) | | |
| 30 | - | | `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) | | |
| 31 | - | | `--token` | Authentication token for remote | | |
| 32 | - | ||
| 33 | - | ### Description | |
| 34 | - | ||
| 35 | - | `gordon push` tags the selected image for the Gordon registry and pushes it. | |
| 36 | - | ||
| 37 | - | If you do not pass `--remote` and no active remote is configured, Gordon tries to | |
| 38 | - | infer the correct saved remote by probing your saved remotes for the image. | |
| 39 | - | It auto-selects only when exactly one remote matches. If multiple remotes match, | |
| 40 | - | or a saved remote cannot be probed safely, the command stops and asks you to use | |
| 41 | - | `--remote` explicitly. | |
| 42 | - | ||
| 43 | - | Domain-style push targets are retired: push an image name, then | |
| 44 | - | `gordon apps apply` the manifest that references the pushed tag. | |
| 45 | - | ||
| 46 | - | - The version tag defaults to a CI tag ref (like `refs/tags/v1.2.3`) when available, | |
| 47 | - | then falls back to `git describe --tags --dirty` (for example | |
| 48 | - | `v1.2.3-4-gabc1234` or `v1.2.3-dirty`). If no tag is found, `latest` is used. | |
| 49 | - | - When `--build` is set, the command builds with `docker buildx build --load` | |
| 50 | - | and injects `VERSION`, `GIT_TAG`, `GIT_SHA`, and `BUILD_TIME` into the build | |
| 51 | - | environment plus any `--build-arg` values. To use these in your Dockerfile, | |
| 52 | - | declare them with `ARG` (e.g., `ARG VERSION`) then reference via `ENV` or | |
| 53 | - | in build steps. | |
| 54 | - | - Use `-f/--file` to build from a Dockerfile outside the current directory root. | |
| 55 | - | - The version tag and `latest` are both pushed (unless the version is `latest`). | |
| 56 | - | ||
| 57 | - | ### Authentication | |
| 58 | - | ||
| 59 | - | When used with `--remote`, gordon push authenticates in two ways: | |
| 60 | - | ||
| 61 | - | - **Admin API** (tag listing for inference): uses `--token` or `$GORDON_TOKEN` as Bearer token | |
| 62 | - | - **Registry push**: automatically exchanges the token for a short-lived (5 min) registry access token via `/auth/token` -- no `docker login` required | |
| 63 | - | ||
| 64 | - | This means CI/CD pipelines only need a single secret (`GORDON_TOKEN`). | |
| 65 | - | ||
| 66 | - | ### Version Auto-Detection | |
| 67 | - | ||
| 68 | - | Gordon reads version tags from CI environment variables (in priority order): | |
| 69 | - | ||
| 70 | - | | CI System | Variable | Example | | |
| 71 | - | |-----------|----------|---------| | |
| 72 | - | | GitHub Actions | `$GITHUB_REF` | `refs/tags/v1.2.0` | | |
| 73 | - | | GitHub Actions | `$GITHUB_REF_TYPE` + `$GITHUB_REF_NAME` | `tag` + `v1.2.0` | | |
| 74 | - | | GitLab CI | `$CI_COMMIT_TAG` | `v1.2.0` | | |
| 75 | - | | Azure DevOps | `$BUILD_SOURCEBRANCH` | `refs/tags/v1.2.0` | | |
| 76 | - | | Any | `git describe --tags` | `v1.2.3-4-gabc1234` | | |
| 77 | - | | Fallback | - | `latest` | | |
| 78 | - | ||
| 79 | - | ### Examples | |
| 80 | - | ||
| 81 | - | ```bash | |
| 82 | - | # Build, push (auto-detect image name) | |
| 83 | - | gordon push --build --remote https://gordon.example.com | |
| 84 | - | ||
| 85 | - | # Push an image | |
| 86 | - | gordon push myapp --remote https://gordon.example.com | |
| 87 | - | ||
| 88 | - | # Push a fully qualified ref | |
| 89 | - | gordon push registry.example.com/myapp:v1.2.3 --build --remote https://gordon.example.com | |
| 90 | - | ||
| 91 | - | # Push existing local image with an explicit tag | |
| 92 | - | gordon push myapp --tag v1.2.0 --remote https://gordon.example.com | |
| 93 | - | ||
| 94 | - | # Build for ARM and pass build args | |
| 95 | - | gordon push myapp --build --platform linux/arm64 --build-arg CGO_ENABLED=0 --remote https://gordon.example.com | |
| 96 | - | ||
| 97 | - | # Build from a custom Dockerfile path | |
| 98 | - | gordon push myapp --build -f docker/app/Dockerfile --remote https://gordon.example.com | |
| 99 | - | ||
| 100 | - | # CI/CD usage (single env var, no docker login needed) | |
| 101 | - | export GORDON_TOKEN="your-token" | |
| 102 | - | gordon push myapp --build --remote https://gordon.example.com | |
| 103 | - | ``` | |
| 104 | - | ||
| 105 | - | ### Notes | |
| 106 | - | ||
| 107 | - | - Remote mode required. See [CLI Overview](./index.md) for targeting options. | |
| 108 | - | - `--build` requires Docker with Buildx. Docker Desktop includes it; on Linux, | |
| 109 | - | install the `docker-buildx-plugin` package. | |
| 110 | - | - Gordon uses native registry uploads instead of shelling out to `docker push`. | |
| 111 | - | Image layers are sent in 50MB chunks, which stays under Cloudflare's 100MB | |
| 112 | - | per-request limit so proxied pushes keep working. Keep the server's | |
| 113 | - | `max_blob_chunk_size` larger than the client chunk size; the default `95MB` | |
| 114 | - | works out of the box. | |
| 115 | - | ||
| 116 | - | ## Related | |
| 117 | - | ||
| 118 | - | - [CLI Overview](./index.md) | |
| 119 | - | - [Authentication](../config/auth.md) | |
M docs/cli/remotes.md +12 -12
| @@ -164,12 +164,12 @@ gordon remotes use <name> | |||
| 164 | 164 | ||
| 165 | 165 | When a remote is active, it's used automatically for most remote-capable commands without needing to specify `--remote` and `--token`. | |
| 166 | 166 | ||
| 167 | - | If no remote is active and you do not pass `--remote`, Gordon can also auto-infer a saved remote for `gordon push` and `gordon images tags <repository>`. It only auto-selects when exactly one saved remote matches. Ambiguous matches and probe failures require an explicit `--remote`. | |
| 167 | + | If no remote is active and you do not pass `--remote`, Gordon can also auto-infer a saved remote for `gordon images push` and `gordon images tags <repository>`. It only auto-selects when exactly one saved remote matches. Ambiguous matches and probe failures require an explicit `--remote`. | |
| 168 | 168 | ||
| 169 | 169 | ```bash | |
| 170 | 170 | 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 | |
| 173 | 173 | ``` | |
| 174 | 174 | ||
| 175 | 175 | ### Example | |
| @@ -270,7 +270,7 @@ This allows overriding specific values while keeping defaults: | |||
| 270 | 270 | ||
| 271 | 271 | ```bash | |
| 272 | 272 | # Force one target | |
| 273 | - | GORDON_REMOTE=prod gordon status | |
| 273 | + | GORDON_REMOTE=prod gordon daemon status | |
| 274 | 274 | ``` | |
| 275 | 275 | ||
| 276 | 276 | --- | |
| @@ -287,12 +287,12 @@ gordon remotes add dev https://gordon.dev.example.com --token-env DEV_TOKEN | |||
| 287 | 287 | ||
| 288 | 288 | # Work with prod | |
| 289 | 289 | gordon remotes use prod | |
| 290 | - | gordon secrets list myapp.example.com | |
| 291 | - | gordon status --remote prod | |
| 290 | + | gordon apps list | |
| 291 | + | gordon daemon status | |
| 292 | 292 | ||
| 293 | 293 | # Switch to staging | |
| 294 | 294 | gordon remotes use staging | |
| 295 | - | GORDON_REMOTE=staging gordon status | |
| 295 | + | GORDON_REMOTE=staging gordon daemon status | |
| 296 | 296 | ``` | |
| 297 | 297 | ||
| 298 | 298 | ### CI/CD Pipeline | |
| @@ -306,19 +306,19 @@ env: | |||
| 306 | 306 | steps: | |
| 307 | 307 | - name: Push to Gordon | |
| 308 | 308 | run: | | |
| 309 | - | gordon push myapp --build --remote ${{ secrets.GORDON_URL }} | |
| 309 | + | gordon images push myapp --build --remote ${{ secrets.GORDON_URL }} | |
| 310 | 310 | ``` | |
| 311 | 311 | ||
| 312 | 312 | ### Compare Environments | |
| 313 | 313 | ||
| 314 | 314 | ```bash | |
| 315 | 315 | # 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 | |
| 318 | 318 | ||
| 319 | 319 | # 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 | |
| 322 | 322 | ``` | |
| 323 | 323 | ||
| 324 | 324 | ### Private Admin + Wildcard App Domains | |
D docs/cli/secrets.md +0 -199
| @@ -1,199 +0,0 @@ | |||
| 1 | - | # Secrets Commands | |
| 2 | - | ||
| 3 | - | Manage installation secrets on local or remote Gordon instances. | |
| 4 | - | ||
| 5 | - | Storage depends on the secrets backend: | |
| 6 | - | - `pass`: secrets are stored in pass under `gordon/env/<domain>/<KEY>` | |
| 7 | - | - `sops`: secrets live in domain `.env` files and can reference SOPS-encrypted values | |
| 8 | - | - `unsafe`: secrets are stored in plain-text domain `.env` files | |
| 9 | - | ||
| 10 | - | App secret VALUES stay in pass under | |
| 11 | - | `gordon/apps/<uuid>/<service>/<name>` and are managed with | |
| 12 | - | `gordon apps secrets`. The commands below manage installation secrets only. | |
| 13 | - | ||
| 14 | - | ## gordon secrets | |
| 15 | - | ||
| 16 | - | ### Subcommands | |
| 17 | - | ||
| 18 | - | | Subcommand | Description | | |
| 19 | - | |------------|-------------| | |
| 20 | - | | `list` | List all secret keys for a domain | | |
| 21 | - | | `set` | Set secrets for a domain from a file | | |
| 22 | - | | `remove` | Remove a secret | | |
| 23 | - | ||
| 24 | - | --- | |
| 25 | - | ||
| 26 | - | ## gordon secrets list | |
| 27 | - | ||
| 28 | - | List all secret key names for a specific domain. Only key names are shown, never values. | |
| 29 | - | ||
| 30 | - | ```bash | |
| 31 | - | gordon secrets list <domain> | |
| 32 | - | ``` | |
| 33 | - | ||
| 34 | - | ### Arguments | |
| 35 | - | ||
| 36 | - | | Argument | Description | | |
| 37 | - | |----------|-------------| | |
| 38 | - | | `<domain>` | The domain name to list secrets for | | |
| 39 | - | ||
| 40 | - | ### Options | |
| 41 | - | ||
| 42 | - | | Option | Description | | |
| 43 | - | |--------|-------------| | |
| 44 | - | | `--json` | Output secrets as JSON | | |
| 45 | - | | `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) | | |
| 46 | - | | `--token` | Authentication token for remote | | |
| 47 | - | ||
| 48 | - | ### Examples | |
| 49 | - | ||
| 50 | - | ```bash | |
| 51 | - | # Local | |
| 52 | - | gordon secrets list myapp.example.com | |
| 53 | - | ||
| 54 | - | # Remote (override) | |
| 55 | - | gordon secrets list myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN | |
| 56 | - | ``` | |
| 57 | - | ||
| 58 | - | ### Output | |
| 59 | - | ||
| 60 | - | ``` | |
| 61 | - | Secrets for app.mydomain.com | |
| 62 | - | ||
| 63 | - | Key Value | |
| 64 | - | DATABASE_URL (hidden) | |
| 65 | - | API_KEY (hidden) | |
| 66 | - | ``` | |
| 67 | - | ||
| 68 | - | ### JSON Output | |
| 69 | - | ||
| 70 | - | ```bash | |
| 71 | - | gordon secrets list app.mydomain.com --json | |
| 72 | - | ``` | |
| 73 | - | ||
| 74 | - | ```json | |
| 75 | - | { | |
| 76 | - | "domain": "app.mydomain.com", | |
| 77 | - | "keys": ["API_KEY", "DATABASE_URL"] | |
| 78 | - | } | |
| 79 | - | ``` | |
| 80 | - | ||
| 81 | - | --- | |
| 82 | - | ||
| 83 | - | ## gordon secrets set | |
| 84 | - | ||
| 85 | - | Set secrets for a domain from a mode 0600 file containing one `KEY=value` pair per line. | |
| 86 | - | ||
| 87 | - | ```bash | |
| 88 | - | gordon secrets set <domain> --from-file <path> | |
| 89 | - | ``` | |
| 90 | - | ||
| 91 | - | ### Arguments | |
| 92 | - | ||
| 93 | - | | Argument | Description | | |
| 94 | - | |----------|-------------| | |
| 95 | - | | `<domain>` | The domain name to set secrets for | | |
| 96 | - | ||
| 97 | - | ### Options | |
| 98 | - | ||
| 99 | - | | Option | Description | | |
| 100 | - | |--------|-------------| | |
| 101 | - | | `--from-file` | Read KEY=value lines from a mode 0600 file (required) | | |
| 102 | - | | `--json` | Output as JSON | | |
| 103 | - | | `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) | | |
| 104 | - | | `--token` | Authentication token for remote | | |
| 105 | - | ||
| 106 | - | ### Examples | |
| 107 | - | ||
| 108 | - | ```bash | |
| 109 | - | # Local | |
| 110 | - | gordon secrets set myapp.example.com --from-file ./app.env | |
| 111 | - | ||
| 112 | - | # Remote (override) | |
| 113 | - | gordon secrets set myapp.example.com --from-file ./app.env --remote https://gordon.mydomain.com --token $TOKEN | |
| 114 | - | ``` | |
| 115 | - | ||
| 116 | - | --- | |
| 117 | - | ||
| 118 | - | ## gordon secrets remove | |
| 119 | - | ||
| 120 | - | Remove a secret from a domain. | |
| 121 | - | ||
| 122 | - | ```bash | |
| 123 | - | gordon secrets remove <domain> <key> | |
| 124 | - | ``` | |
| 125 | - | ||
| 126 | - | ### Arguments | |
| 127 | - | ||
| 128 | - | | Argument | Description | | |
| 129 | - | |----------|-------------| | |
| 130 | - | | `<domain>` | The domain name | | |
| 131 | - | | `<key>` | The secret key to remove | | |
| 132 | - | ||
| 133 | - | ### Options | |
| 134 | - | ||
| 135 | - | | Option | Description | | |
| 136 | - | |--------|-------------| | |
| 137 | - | | `--force`, `-f` | Remove without confirmation | | |
| 138 | - | | `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) | | |
| 139 | - | | `--token` | Authentication token for remote | | |
| 140 | - | ||
| 141 | - | ### Examples | |
| 142 | - | ||
| 143 | - | ```bash | |
| 144 | - | # Local | |
| 145 | - | gordon secrets remove myapp.example.com DATABASE_URL | |
| 146 | - | ||
| 147 | - | # Remote (override) | |
| 148 | - | gordon secrets remove myapp.example.com DATABASE_URL --remote https://gordon.mydomain.com --token $TOKEN | |
| 149 | - | ``` | |
| 150 | - | ||
| 151 | - | --- | |
| 152 | - | ||
| 153 | - | ## Workflow Examples | |
| 154 | - | ||
| 155 | - | ### Setting Up Application Secrets | |
| 156 | - | ||
| 157 | - | ```bash | |
| 158 | - | # Write secrets to a protected file | |
| 159 | - | cat > app.env <<'EOF' | |
| 160 | - | DATABASE_URL=postgres://user:pass@postgres:5432/mydb | |
| 161 | - | API_KEY=your-api-key | |
| 162 | - | EOF | |
| 163 | - | chmod 600 app.env | |
| 164 | - | ||
| 165 | - | gordon secrets set myapp.example.com --from-file ./app.env | |
| 166 | - | ||
| 167 | - | # Verify | |
| 168 | - | gordon secrets list myapp.example.com | |
| 169 | - | ``` | |
| 170 | - | ||
| 171 | - | ### CI/CD Secret Management | |
| 172 | - | ||
| 173 | - | ```bash | |
| 174 | - | # In your CI/CD pipeline | |
| 175 | - | export GORDON_REMOTE=https://gordon.mydomain.com | |
| 176 | - | export GORDON_TOKEN=$GORDON_TOKEN | |
| 177 | - | ||
| 178 | - | # Update secrets | |
| 179 | - | gordon secrets set myapp.example.com --from-file ./app.env | |
| 180 | - | ``` | |
| 181 | - | ||
| 182 | - | ### Rotating Secrets | |
| 183 | - | ||
| 184 | - | ```bash | |
| 185 | - | # Generate new secret | |
| 186 | - | NEW_JWT_SECRET=$(openssl rand -base64 32) | |
| 187 | - | printf 'JWT_SECRET=%s\n' "$NEW_JWT_SECRET" > rotate.env | |
| 188 | - | chmod 600 rotate.env | |
| 189 | - | ||
| 190 | - | # Update the secret | |
| 191 | - | gordon secrets set myapp.example.com --from-file ./rotate.env | |
| 192 | - | ``` | |
| 193 | - | ||
| 194 | - | ## Related | |
| 195 | - | ||
| 196 | - | - [CLI Overview](./index.md) | |
| 197 | - | - [Secrets Configuration](../config/secrets.md) | |
| 198 | - | - [Environment Variables](../config/env.md) | |
| 199 | - | - [Apps Commands](./apps.md) | |
M docs/cli/serve.md +0 -92
| @@ -128,98 +128,6 @@ Reload and startup recovery never activate pending desired app state, re-resolve | |||
| 128 | 128 | ||
| 129 | 129 | --- | |
| 130 | 130 | ||
| 131 | - | ## gordon reload | |
| 132 | - | ||
| 133 | - | Reload installation configuration. | |
| 134 | - | ||
| 135 | - | ### Synopsis | |
| 136 | - | ||
| 137 | - | ```bash | |
| 138 | - | gordon reload | |
| 139 | - | ``` | |
| 140 | - | ||
| 141 | - | ### Description | |
| 142 | - | ||
| 143 | - | Sends `SIGUSR1` to the running Gordon process, triggering: | |
| 144 | - | ||
| 145 | - | - Installation settings reload (live keys apply, restart-required keys are reported) | |
| 146 | - | - Traffic/proxy state refresh from ACTIVE app projection | |
| 147 | - | ||
| 148 | - | Reload never activates pending desired app state, re-resolves images, or flips intent. Obsolete application keys (`routes`, `attachments`, `services`, `auto`, previews, …) are rejected with a `config-retired` diagnostic before any mutation. | |
| 149 | - | ||
| 150 | - | ### Example | |
| 151 | - | ||
| 152 | - | ```bash | |
| 153 | - | # After editing gordon.toml, apply changes without restart | |
| 154 | - | vim ~/.config/gordon/gordon.toml | |
| 155 | - | gordon reload | |
| 156 | - | ``` | |
| 157 | - | ||
| 158 | - | --- | |
| 159 | - | ||
| 160 | - | ## gordon logs | |
| 161 | - | ||
| 162 | - | Display Gordon daemon process logs. Application workload output is read with | |
| 163 | - | `gordon apps logs APP --service SVC`, which resolves the app's active container | |
| 164 | - | through the daemon. | |
| 165 | - | ||
| 166 | - | ### Synopsis | |
| 167 | - | ||
| 168 | - | ```bash | |
| 169 | - | gordon logs [options] | |
| 170 | - | ``` | |
| 171 | - | ||
| 172 | - | ### Options | |
| 173 | - | ||
| 174 | - | | Option | Short | Default | Description | | |
| 175 | - | |--------|-------|---------|-------------| | |
| 176 | - | | `--config` | `-c` | Auto | Path to config file | | |
| 177 | - | | `--follow` | `-f` | false | Follow log output (like `tail -f`) | | |
| 178 | - | | `--lines` | `-n` | 50 | Number of lines to show | | |
| 179 | - | | `--remote, -r` | | | Remote name or URL (e.g., prod, https://gordon.mydomain.com) | | |
| 180 | - | | `--token` | | | Authentication token for remote | | |
| 181 | - | ||
| 182 | - | Remote targeting uses client config or an active remote by default. Use | |
| 183 | - | `--remote` and `--token` to override. See [CLI Overview](./index.md). | |
| 184 | - | ||
| 185 | - | Remote log access requires an admin token with `admin:logs:read` (or `admin:*:*`). `admin:status:read` is not sufficient for logs. | |
| 186 | - | ||
| 187 | - | ### Examples | |
| 188 | - | ||
| 189 | - | ```bash | |
| 190 | - | # Gordon process logs | |
| 191 | - | gordon logs # Last 50 lines | |
| 192 | - | gordon logs -f # Follow logs | |
| 193 | - | gordon logs -n 100 # Last 100 lines | |
| 194 | - | gordon logs -f -n 200 # Follow, starting from last 200 lines | |
| 195 | - | ||
| 196 | - | # App service logs | |
| 197 | - | gordon apps logs blog --service web | |
| 198 | - | gordon apps logs blog --service web --follow --remote prod | |
| 199 | - | ||
| 200 | - | # Remote process logs (override) | |
| 201 | - | gordon logs --remote https://gordon.mydomain.com --token $TOKEN | |
| 202 | - | ``` | |
| 203 | - | ||
| 204 | - | ### Log Locations | |
| 205 | - | ||
| 206 | - | ```bash | |
| 207 | - | # Using gordon logs | |
| 208 | - | gordon logs -f | |
| 209 | - | ||
| 210 | - | # Direct file access | |
| 211 | - | tail -f ~/.gordon/logs/gordon.log | |
| 212 | - | ||
| 213 | - | # With systemd | |
| 214 | - | journalctl --user -u gordon -f | |
| 215 | - | ||
| 216 | - | # App service logs through Gordon | |
| 217 | - | gordon apps logs blog --service web --tail 50 | |
| 218 | - | gordon apps logs blog --service web --follow | |
| 219 | - | ``` | |
| 220 | - | ||
| 221 | - | --- | |
| 222 | - | ||
| 223 | 131 | ## gordon version | |
| 224 | 132 | ||
| 225 | 133 | Print version information. | |
D docs/cli/status.md +0 -110
| @@ -1,110 +0,0 @@ | |||
| 1 | - | # Status Command | |
| 2 | - | ||
| 3 | - | Show Gordon server status and the app fleet summary. | |
| 4 | - | ||
| 5 | - | ## gordon status | |
| 6 | - | ||
| 7 | - | Display installation identity plus one status line per app (from desired/active state, no container inspection). Per-service detail lives under `gordon apps show APP` and `gordon apps status APP`. | |
| 8 | - | ||
| 9 | - | ```bash | |
| 10 | - | gordon status | |
| 11 | - | gordon status --remote https://gordon.mydomain.com --token $TOKEN | |
| 12 | - | ``` | |
| 13 | - | ||
| 14 | - | `gordon status` works in local mode and remote mode. | |
| 15 | - | ||
| 16 | - | - Local mode reads status from in-process services. | |
| 17 | - | - Remote mode reads status from the target admin API. | |
| 18 | - | ||
| 19 | - | ### Output | |
| 20 | - | ||
| 21 | - | ``` | |
| 22 | - | Gordon Status | |
| 23 | - | ||
| 24 | - | Gordon Domain: gordon.example.com | |
| 25 | - | Registry Port: 5000 | |
| 26 | - | Server Port: 8088 | |
| 27 | - | Apps: 3 | |
| 28 | - | Network Isolation: false | |
| 29 | - | ||
| 30 | - | Container Status: | |
| 31 | - | blog: active | |
| 32 | - | shop: deploying | |
| 33 | - | old-site: stopped | |
| 34 | - | ``` | |
| 35 | - | ||
| 36 | - | ### Information Displayed | |
| 37 | - | ||
| 38 | - | | Field | Description | | |
| 39 | - | |-------|-------------| | |
| 40 | - | | Gordon Domain | Public Gordon domain from configuration | | |
| 41 | - | | Registry Port | Docker registry port | | |
| 42 | - | | Server Port | Gordon admin port | | |
| 43 | - | | Routes | Total apps in desired/active state | | |
| 44 | - | | Network Isolation | Whether installation network policy is enabled | | |
| 45 | - | | Container Status | Fleet status per app (see states below) | | |
| 46 | - | ||
| 47 | - | ### App States | |
| 48 | - | ||
| 49 | - | | State | Description | | |
| 50 | - | |-------|-------------| | |
| 51 | - | | active | App deployed and converged on desired state | | |
| 52 | - | | deploying | Desired state diverges from effective state | | |
| 53 | - | | pending | App applied but never deployed | | |
| 54 | - | | stopped | Durable stopped intent (stays stopped across reboot) | | |
| 55 | - | ||
| 56 | - | ## Flags | |
| 57 | - | ||
| 58 | - | The status command uses global flags for remote access: | |
| 59 | - | ||
| 60 | - | | Flag | Description | | |
| 61 | - | |------|-------------| | |
| 62 | - | | `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) | | |
| 63 | - | | `--token` | Authentication token | | |
| 64 | - | ||
| 65 | - | ## Environment Variables | |
| 66 | - | ||
| 67 | - | | Variable | Description | | |
| 68 | - | |----------|-------------| | |
| 69 | - | | `GORDON_REMOTE` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) | | |
| 70 | - | | `GORDON_TOKEN` | Authentication token | | |
| 71 | - | ||
| 72 | - | ## Examples | |
| 73 | - | ||
| 74 | - | ### Check Local or Remote Status | |
| 75 | - | ||
| 76 | - | ```bash | |
| 77 | - | # Local | |
| 78 | - | gordon status | |
| 79 | - | ||
| 80 | - | # Using flags | |
| 81 | - | gordon status --remote https://gordon.mydomain.com --token $TOKEN | |
| 82 | - | ||
| 83 | - | # Using environment variables | |
| 84 | - | export GORDON_REMOTE=https://gordon.mydomain.com | |
| 85 | - | export GORDON_TOKEN=your-token | |
| 86 | - | gordon status | |
| 87 | - | ``` | |
| 88 | - | ||
| 89 | - | ### Quick Fleet Check | |
| 90 | - | ||
| 91 | - | ```bash | |
| 92 | - | # Check for non-converged apps | |
| 93 | - | gordon status --remote https://gordon.mydomain.com --token $TOKEN | grep -E "(deploying|pending|stopped)" | |
| 94 | - | ``` | |
| 95 | - | ||
| 96 | - | ## Required Permissions (Remote Only) | |
| 97 | - | ||
| 98 | - | Remote status calls require `admin:status:read` scope in the authentication token. | |
| 99 | - | ||
| 100 | - | ```bash | |
| 101 | - | # Generate token with required scope | |
| 102 | - | gordon auth token generate --subject admin --scopes admin:status:read | |
| 103 | - | ``` | |
| 104 | - | ||
| 105 | - | ## Related | |
| 106 | - | ||
| 107 | - | - [Serve Command](./serve.md) | |
| 108 | - | - [Apps Commands](./apps.md) | |
| 109 | - | - [CLI Overview](./index.md) | |
| 110 | - | - [Remote CLI Management](/wiki/guides/remote-cli.md) | |
D docs/cli/tls.md +0 -113
| @@ -1,113 +0,0 @@ | |||
| 1 | - | # TLS Commands | |
| 2 | - | ||
| 3 | - | Inspect public TLS/ACME certificate status. | |
| 4 | - | ||
| 5 | - | Gordon serves normal HTTPS fallback on TLS-capable entrypoints such as `entrypoints.edge` with `protocol = "smart_tcp"`. Certificate priority is static certificates first, then public ACME certificates, then Gordon's internal CA. | |
| 6 | - | ||
| 7 | - | ACME challenge notes: | |
| 8 | - | ||
| 9 | - | - DNS-01 (`cloudflare-dns-01`) does not require a special external port 80 edge. | |
| 10 | - | - HTTP-01 requires an HTTP-capable smart TCP entrypoint reachable on external port 80 for every hostname being validated. | |
| 11 | - | - TLS-ALPN-01 is not supported. | |
| 12 | - | ||
| 13 | - | Remote targeting uses client config or an active remote by default. | |
| 14 | - | Use `--remote` and `--token` to override. See [CLI Overview](./index.md). | |
| 15 | - | ||
| 16 | - | ## gordon tls | |
| 17 | - | ||
| 18 | - | ### Subcommands | |
| 19 | - | ||
| 20 | - | | Subcommand | Description | | |
| 21 | - | |------------|-------------| | |
| 22 | - | | `status` | Show public TLS certificate status | | |
| 23 | - | ||
| 24 | - | --- | |
| 25 | - | ||
| 26 | - | ## gordon tls status | |
| 27 | - | ||
| 28 | - | Display the current public TLS/ACME certificate status, including ACME mode, | |
| 29 | - | certificate details, route coverage, and any errors. | |
| 30 | - | ||
| 31 | - | ```bash | |
| 32 | - | gordon tls status | |
| 33 | - | gordon tls status --json | |
| 34 | - | gordon tls status --remote https://gordon.mydomain.com --token $TOKEN | |
| 35 | - | ``` | |
| 36 | - | ||
| 37 | - | ### Flags | |
| 38 | - | ||
| 39 | - | | Flag | Description | | |
| 40 | - | |------|-------------| | |
| 41 | - | | `--json` | Output as JSON | | |
| 42 | - | ||
| 43 | - | ### Human Output | |
| 44 | - | ||
| 45 | - | ```text | |
| 46 | - | Public TLS / ACME Status | |
| 47 | - | ||
| 48 | - | ACME: enabled | |
| 49 | - | Configured Mode: auto | |
| 50 | - | Effective Mode: http-01 | |
| 51 | - | Reason: configured | |
| 52 | - | Token Source: env | |
| 53 | - | ||
| 54 | - | Certificates | |
| 55 | - | ID: cert-abc123 | |
| 56 | - | Names: example.com, www.example.com | |
| 57 | - | Status: valid | |
| 58 | - | Not After: 2026-05-29 12:00:00 | |
| 59 | - | ||
| 60 | - | Route Coverage | |
| 61 | - | example.com covered=yes covered_by=cert-abc123 | |
| 62 | - | internal.local covered=no error=self-signed cert | |
| 63 | - | ||
| 64 | - | Errors | |
| 65 | - | route internal.local has no ACME cert | |
| 66 | - | ``` | |
| 67 | - | ||
| 68 | - | ### JSON Output | |
| 69 | - | ||
| 70 | - | ```json | |
| 71 | - | { | |
| 72 | - | "acme_enabled": true, | |
| 73 | - | "configured_mode": "auto", | |
| 74 | - | "effective_mode": "http-01", | |
| 75 | - | "selection_reason": "configured", | |
| 76 | - | "token_source": "env", | |
| 77 | - | "certificates": [ | |
| 78 | - | { | |
| 79 | - | "id": "cert-abc123", | |
| 80 | - | "names": ["example.com", "www.example.com"], | |
| 81 | - | "challenge": "http-01", | |
| 82 | - | "status": "valid", | |
| 83 | - | "not_after": "2026-05-29T12:00:00Z", | |
| 84 | - | "renewal_pending": false | |
| 85 | - | } | |
| 86 | - | ], | |
| 87 | - | "routes": [ | |
| 88 | - | { | |
| 89 | - | "domain": "example.com", | |
| 90 | - | "covered": true, | |
| 91 | - | "covered_by": "cert-abc123", | |
| 92 | - | "required_acme": true | |
| 93 | - | }, | |
| 94 | - | { | |
| 95 | - | "domain": "internal.local", | |
| 96 | - | "covered": false, | |
| 97 | - | "required_acme": false, | |
| 98 | - | "error": "self-signed cert" | |
| 99 | - | } | |
| 100 | - | ], | |
| 101 | - | "errors": ["route internal.local has no ACME cert"] | |
| 102 | - | } | |
| 103 | - | ``` | |
| 104 | - | ||
| 105 | - | ### Token Source | |
| 106 | - | ||
| 107 | - | The `token_source` field indicates where the ACME token was sourced from | |
| 108 | - | (e.g., `env`, `file`, `config`). The token value is never displayed. | |
| 109 | - | ||
| 110 | - | ## Related | |
| 111 | - | ||
| 112 | - | - [CLI Overview](./index.md) | |
| 113 | - | - [Status Command](./status.md) | |
D docs/cli/traffic.md +0 -93
| @@ -1,93 +0,0 @@ | |||
| 1 | - | # gordon traffic | |
| 2 | - | ||
| 3 | - | Inspect Gordon traffic plane status. | |
| 4 | - | ||
| 5 | - | ## Subcommands | |
| 6 | - | ||
| 7 | - | | Command | Description | | |
| 8 | - | |---------|-------------| | |
| 9 | - | | `gordon traffic status` | Show traffic entrypoints, counters, and reload status | | |
| 10 | - | ||
| 11 | - | ## Usage | |
| 12 | - | ||
| 13 | - | ```bash | |
| 14 | - | gordon traffic status --remote prod | |
| 15 | - | gordon traffic status --remote https://gordon.example.com --json | |
| 16 | - | ``` | |
| 17 | - | ||
| 18 | - | Remote mode queries the running Gordon admin API. Local mode does not synthesize runtime traffic state from a fresh config load; use a configured remote target for authoritative counters and reload status. | |
| 19 | - | ||
| 20 | - | ## Flags | |
| 21 | - | ||
| 22 | - | | Flag | Description | | |
| 23 | - | |------|-------------| | |
| 24 | - | | `--json` | Output machine-readable JSON | | |
| 25 | - | | `--remote`, `-r` | Remote Gordon instance or saved remote name | | |
| 26 | - | ||
| 27 | - | ## JSON Output | |
| 28 | - | ||
| 29 | - | ```json | |
| 30 | - | { | |
| 31 | - | "last_reload_status": "ok", | |
| 32 | - | "entrypoints": [ | |
| 33 | - | { | |
| 34 | - | "name": "postgres", | |
| 35 | - | "address": "0.0.0.0:5432", | |
| 36 | - | "protocol": "tcp", | |
| 37 | - | "active": true, | |
| 38 | - | "active_tcp_connections": 1, | |
| 39 | - | "active_udp_sessions": 0, | |
| 40 | - | "total_accepted": 12, | |
| 41 | - | "total_refused": 0, | |
| 42 | - | "total_errors": 0, | |
| 43 | - | "bytes_in": 4096, | |
| 44 | - | "bytes_out": 8192, | |
| 45 | - | "smart_tcp": { | |
| 46 | - | "http_accepted": 0, | |
| 47 | - | "h2c_accepted": 0, | |
| 48 | - | "https_fallback_accepted": 0, | |
| 49 | - | "tls_passthrough_accepted": 0, | |
| 50 | - | "raw_fallback_accepted": 0, | |
| 51 | - | "entrypoint_cidr_refused": 0, | |
| 52 | - | "raw_fallback_cidr_refused": 0, | |
| 53 | - | "proxy_refused": 0, | |
| 54 | - | "unknown_no_fallback_refused": 0, | |
| 55 | - | "malformed_rejected": 0, | |
| 56 | - | "sniff_timeout": 0, | |
| 57 | - | "client_hello_too_large": 0 | |
| 58 | - | } | |
| 59 | - | } | |
| 60 | - | ], | |
| 61 | - | "routers": [], | |
| 62 | - | "services": [], | |
| 63 | - | "counters": { | |
| 64 | - | "active_tcp_connections": 1, | |
| 65 | - | "active_udp_sessions": 0, | |
| 66 | - | "total_accepted": 12, | |
| 67 | - | "total_refused": 0, | |
| 68 | - | "total_errors": 0, | |
| 69 | - | "bytes_in": 4096, | |
| 70 | - | "bytes_out": 8192, | |
| 71 | - | "smart_tcp": { | |
| 72 | - | "http_accepted": 0, | |
| 73 | - | "h2c_accepted": 0, | |
| 74 | - | "https_fallback_accepted": 0, | |
| 75 | - | "tls_passthrough_accepted": 0, | |
| 76 | - | "raw_fallback_accepted": 0, | |
| 77 | - | "entrypoint_cidr_refused": 0, | |
| 78 | - | "raw_fallback_cidr_refused": 0, | |
| 79 | - | "proxy_refused": 0, | |
| 80 | - | "unknown_no_fallback_refused": 0, | |
| 81 | - | "malformed_rejected": 0, | |
| 82 | - | "sniff_timeout": 0, | |
| 83 | - | "client_hello_too_large": 0 | |
| 84 | - | } | |
| 85 | - | } | |
| 86 | - | } | |
| 87 | - | ``` | |
| 88 | - | ||
| 89 | - | ## Related | |
| 90 | - | ||
| 91 | - | - [Traffic configuration](../config/traffic.md) | |
| 92 | - | - [Standalone services](../config/services.md) | |
| 93 | - | - [Server status](./status.md) | |
M docs/concepts.md +2 -2
| @@ -164,10 +164,10 @@ Secret values stay in pass under `gordon/apps/<uuid>/<service>/<name>`, keyed by | |||
| 164 | 164 | Gordon watches `gordon.toml` and reloads installation-only settings (entrypoints, TLS, limits, external routes). Reload never activates pending app desired state, never re-resolves image tags, and never starts app workloads. | |
| 165 | 165 | ||
| 166 | 166 | ```bash | |
| 167 | - | gordon reload | |
| 167 | + | gordon daemon reload | |
| 168 | 168 | ``` | |
| 169 | 169 | ||
| 170 | - | `gordon reload` sends `SIGUSR1` to the running Gordon process. `gordon.toml` holds installation settings only — app workloads live in app files. | |
| 170 | + | `gordon daemon reload` sends `SIGUSR1` to the running Gordon process. `gordon.toml` holds installation settings only — app workloads live in app files. | |
| 171 | 171 | ||
| 172 | 172 | ## Event System | |
| 173 | 173 | ||
M docs/config/auth.md +0 -2
| @@ -95,8 +95,6 @@ Admin scopes (for remote CLI): | |||
| 95 | 95 | | `admin:logs:read` | Read-only logs access | | |
| 96 | 96 | | `admin:volumes:read` | List Docker volumes | | |
| 97 | 97 | | `admin:volumes:write` | Prune eligible Gordon-managed volumes | | |
| 98 | - | | `admin:secrets:read` | List secret keys | | |
| 99 | - | | `admin:secrets:write` | Set/delete secrets | | |
| 100 | 98 | ||
| 101 | 99 | Examples: | |
| 102 | 100 | ||
D docs/config/env.md +0 -64
| @@ -1,64 +0,0 @@ | |||
| 1 | - | # Environment Variables | |
| 2 | - | ||
| 3 | - | App environment comes from two places: public `[env]` in the app file (injected into all services at deploy) and per-service `[services.<name>.secrets]` (values in pass, resolved at deploy). This page covers the installation `[env]` store location and the provider syntax used in env files. | |
| 4 | - | ||
| 5 | - | ## Configuration | |
| 6 | - | ||
| 7 | - | ```toml | |
| 8 | - | [env] | |
| 9 | - | dir = "~/.gordon/env" # Default location | |
| 10 | - | ``` | |
| 11 | - | ||
| 12 | - | ## Options | |
| 13 | - | ||
| 14 | - | | Option | Type | Default | Description | | |
| 15 | - | |--------|------|---------|-------------| | |
| 16 | - | | `dir` | string | `~/.gordon/env` | Directory for the installation secret store | | |
| 17 | - | ||
| 18 | - | ## How It Works | |
| 19 | - | ||
| 20 | - | The `[env]` directory backs the installation secret store: | |
| 21 | - | ||
| 22 | - | - With the `pass` backend, `gordon secrets set <domain> --from-file` stores per-domain secrets in pass under `gordon/env/<sanitized-domain>/<KEY>` (dots/colons/slashes → underscores). Env files are not used for secrets. | |
| 23 | - | - With the `pass` backend, Gordon imports eligible plaintext `.env` files into pass at startup and removes each source file only after every entry is stored successfully. If a destination entry already exists or an import fails, Gordon leaves the source file in place for operator review. | |
| 24 | - | - With the `sops` or `unsafe` backend, env files remain the source of truth; use `${sops:...}` syntax for encrypted values when `secrets_backend = "sops"`. | |
| 25 | - | ||
| 26 | - | App containers do NOT read these files: services receive the app file's `[env]` plus their resolved `[services.<name>.secrets]` at deploy time (see [App Manifest](./apps.md)). | |
| 27 | - | ||
| 28 | - | ## Secret Provider Syntax | |
| 29 | - | ||
| 30 | - | Reference secrets from configured backends in env files: | |
| 31 | - | ||
| 32 | - | ### Pass (Unix Password Manager) | |
| 33 | - | ||
| 34 | - | ```bash | |
| 35 | - | DATABASE_PASSWORD=${pass:myapp/database/password} | |
| 36 | - | API_SECRET=${pass:company/api-secret} | |
| 37 | - | JWT_KEY=${pass:production/jwt-signing-key} | |
| 38 | - | ``` | |
| 39 | - | ||
| 40 | - | ### SOPS (Encrypted Files) | |
| 41 | - | ||
| 42 | - | ```bash | |
| 43 | - | DATABASE_PASSWORD=${sops:secrets.yaml:database.password} | |
| 44 | - | API_SECRET=${sops:production.yaml:api.secret} | |
| 45 | - | STRIPE_KEY=${sops:secrets.yaml:stripe.api_key} | |
| 46 | - | ``` | |
| 47 | - | ||
| 48 | - | ### Syntax Reference | |
| 49 | - | ||
| 50 | - | | Provider | Syntax | Example | | |
| 51 | - | |----------|--------|---------| | |
| 52 | - | | pass | `${pass:<path>}` | `${pass:myapp/db-password}` | | |
| 53 | - | | sops | `${sops:<file>:<key.path>}` | `${sops:secrets.yaml:db.password}` | | |
| 54 | - | ||
| 55 | - | ## File Permissions | |
| 56 | - | ||
| 57 | - | The env directory uses secure permissions: | |
| 58 | - | - Directory: `0700` (owner only) | |
| 59 | - | - Files: `0600` (owner only) | |
| 60 | - | ||
| 61 | - | ## Related | |
| 62 | - | ||
| 63 | - | - [Secrets Configuration](./secrets.md) | |
| 64 | - | - [App Manifest](./apps.md) | |
M docs/config/index.md +2 -7
| @@ -89,17 +89,13 @@ metrics = true # Export metrics | |||
| 89 | 89 | logs = true # Export Gordon, access, and app logs | |
| 90 | 90 | trace_sample_rate = 1.0 # 0.0 = none, 1.0 = all | |
| 91 | 91 | ||
| 92 | - | # Installation secret store location | |
| 93 | - | [env] | |
| 94 | - | dir = "~/.gordon/env" # Default location | |
| 95 | - | ||
| 96 | 92 | # Volume settings | |
| 97 | 93 | [volumes] | |
| 98 | 94 | auto_create = true # Auto-create from Dockerfile VOLUME | |
| 99 | 95 | prefix = "gordon" # Volume name prefix | |
| 100 | 96 | preserve = true # Keep volumes on container removal | |
| 101 | 97 | ||
| 102 | - | # Installation network policy (prefix filter for `gordon networks list`) | |
| 98 | + | # Installation network policy (prefix filter for `gordon daemon networks`) | |
| 103 | 99 | [network_isolation] | |
| 104 | 100 | enabled = true # Gordon-managed network policy | |
| 105 | 101 | network_prefix = "gordon" # Network name prefix | |
| @@ -143,7 +139,6 @@ Docker Hub (`docker.io` and `registry-1.docker.io`), `ghcr.io`, `quay.io`, and G | |||
| 143 | 139 | | `[api.rate_limit]` | Rate limiting configuration | [Rate Limiting](./rate-limiting.md) | | |
| 144 | 140 | | `[logging]` | Logging configuration | [Logging](./logging.md) | | |
| 145 | 141 | | `[telemetry]` | OpenTelemetry observability export | [Telemetry](./telemetry.md) | | |
| 146 | - | | `[env]` | Installation secret store location | [Environment](./env.md) | | |
| 147 | 142 | | `[volumes]` | Volume management | [Volumes](./volumes.md) | | |
| 148 | 143 | | `[network_isolation]` | Installation network policy | [Network Isolation](./network-isolation.md) | | |
| 149 | 144 | | `[external_routes]` | Non-containerized service proxying | [External Routes](./external-routes.md) | | |
| @@ -203,7 +198,7 @@ When `auth.enabled=false`, Gordon runs in local-only mode: `/admin/*` is not reg | |||
| 203 | 198 | Gordon watches the configuration file and reloads automatically when changes are detected. You can also trigger a manual reload: | |
| 204 | 199 | ||
| 205 | 200 | ```bash | |
| 206 | - | gordon reload | |
| 201 | + | gordon daemon reload | |
| 207 | 202 | ``` | |
| 208 | 203 | ||
| 209 | 204 | ### Hot-reloaded (no restart needed) | |
M docs/config/logging.md +3 -3
| @@ -43,7 +43,7 @@ syslog_identifier = "gordon-access" | |||
| 43 | 43 | | `file.max_backups` | int | `3` | Number of old files to keep | | |
| 44 | 44 | | `file.max_age` | int | `28` | Days to keep old files | | |
| 45 | 45 | ||
| 46 | - | The Admin API and `gordon logs` read from the process log file. Keep `logging.file.enabled` set to `true` if you need process log streaming. | |
| 46 | + | The Admin API and `gordon daemon logs` read from the process log file. Keep `logging.file.enabled` set to `true` if you need process log streaming. | |
| 47 | 47 | ||
| 48 | 48 | ### Workload Logs | |
| 49 | 49 | ||
| @@ -142,8 +142,8 @@ enabled = false | |||
| 142 | 142 | ### Gordon Process Logs | |
| 143 | 143 | ||
| 144 | 144 | ```bash | |
| 145 | - | gordon logs -f | |
| 146 | - | gordon logs -n 100 | |
| 145 | + | gordon daemon logs -f | |
| 146 | + | gordon daemon logs -n 100 | |
| 147 | 147 | tail -f ~/.gordon/logs/gordon.log | |
| 148 | 148 | journalctl --user -u gordon -f | |
| 149 | 149 | ``` | |
M docs/config/network-isolation.md +2 -2
| @@ -16,7 +16,7 @@ internal = false | |||
| 16 | 16 | | Option | Type | Default | Description | | |
| 17 | 17 | |--------|------|---------|-------------| | |
| 18 | 18 | | `enabled` | bool | `true` | Enable Gordon-managed network policy | | |
| 19 | - | | `network_prefix` | string | `"gordon"` | Prefix filter for `gordon networks list` | | |
| 19 | + | | `network_prefix` | string | `"gordon"` | Prefix filter for `gordon daemon networks` | | |
| 20 | 20 | | `internal` | bool | `false` | Create isolated Docker networks with Docker's `Internal` flag, blocking direct external egress from containers on those networks. | | |
| 21 | 21 | ||
| 22 | 22 | Per-app isolation is declared in app files, not here: each app gets a private network automatically, and services can join named shared networks with `[[network.shared]]` (see [App Manifest](./apps.md)). Deploy adds AND removes memberships without disconnecting unrelated services. Shared networks are created/reused only within verified Gordon ownership. | |
| @@ -34,7 +34,7 @@ Readiness helpers never join shared networks and exist only on the target app's | |||
| 34 | 34 | View Gordon-managed networks: | |
| 35 | 35 | ||
| 36 | 36 | ```bash | |
| 37 | - | gordon networks list | |
| 37 | + | gordon daemon networks | |
| 38 | 38 | docker network ls | grep gordon | |
| 39 | 39 | ``` | |
| 40 | 40 | ||
M docs/config/reference.md +1 -7
| @@ -110,12 +110,6 @@ metrics = true # Export metrics | |||
| 110 | 110 | logs = true # Export Gordon, access, and app logs | |
| 111 | 111 | trace_sample_rate = 1.0 # Fraction of traces to sample (0.0–1.0) | |
| 112 | 112 | ||
| 113 | - | # ============================================================================= | |
| 114 | - | # ENVIRONMENT | |
| 115 | - | # ============================================================================= | |
| 116 | - | [env] | |
| 117 | - | dir = "" # Env files directory (default: {data_dir}/env) | |
| 118 | - | ||
| 119 | 113 | # ============================================================================= | |
| 120 | 114 | # CONTAINERS | |
| 121 | 115 | # ============================================================================= | |
| @@ -127,7 +121,7 @@ security_profile = "compat" # "compat" or "strict" | |||
| 127 | 121 | # ============================================================================= | |
| 128 | 122 | [network_isolation] | |
| 129 | 123 | enabled = true # Installation network policy for Gordon-managed networks | |
| 130 | - | network_prefix = "gordon" # Prefix filter for `gordon networks list` | |
| 124 | + | network_prefix = "gordon" # Prefix filter for `gordon daemon networks` | |
| 131 | 125 | internal = false # Create Docker internal networks (blocks direct egress) | |
| 132 | 126 | ||
| 133 | 127 | # ============================================================================= | |
M docs/config/secrets.md +1 -31
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | # Secrets Configuration | |
| 2 | 2 | ||
| 3 | - | Configure how Gordon stores and retrieves sensitive data. Two separate stores exist: installation secrets (this page) and app secret values (managed with `gordon apps secrets`, values in pass under `gordon/apps/<uuid>/<service>/<name>`). | |
| 3 | + | Configure how Gordon stores and retrieves sensitive data: auth secrets such as `token_secret` (this page) and app secret values (managed with `gordon apps secrets`, values in pass under `gordon/apps/<uuid>/<service>/<name>`). | |
| 4 | 4 | ||
| 5 | 5 | ## Configuration | |
| 6 | 6 | ||
| @@ -52,10 +52,6 @@ token_secret = "gordon/auth/token_secret" # Path in pass store | |||
| 52 | 52 | - Standard Unix tooling | |
| 53 | 53 | - Works with team GPG keys | |
| 54 | 54 | ||
| 55 | - | **Installation secrets storage:** | |
| 56 | - | - `gordon secrets set <domain> --from-file` stores per-domain secrets in pass under `gordon/env/<sanitized-domain>/<KEY>` (dots/colons/slashes → underscores) | |
| 57 | - | - Eligible plaintext `.env` files are imported into pass at startup and removed only after all entries are stored successfully; conflicts or failures leave the source file for operator review | |
| 58 | - | ||
| 59 | 55 | ### SOPS | |
| 60 | 56 | ||
| 61 | 57 | Uses Mozilla SOPS for encrypted file-based secrets: | |
| @@ -75,12 +71,6 @@ brew install sops # macOS | |||
| 75 | 71 | sops secrets.yaml | |
| 76 | 72 | ``` | |
| 77 | 73 | ||
| 78 | - | **Usage in env files:** | |
| 79 | - | ```bash | |
| 80 | - | API_SECRET=${sops:secrets.yaml:api.secret} | |
| 81 | - | DB_PASSWORD=${sops:secrets.yaml:database.password} | |
| 82 | - | ``` | |
| 83 | - | ||
| 84 | 74 | **Benefits:** | |
| 85 | 75 | - Multiple encryption backends (AWS KMS, GCP KMS, Azure Key Vault, PGP) | |
| 86 | 76 | - YAML/JSON file encryption | |
| @@ -117,26 +107,6 @@ echo "your-token-secret" > ~/.gordon/secrets/gordon/auth/token_secret | |||
| 117 | 107 | ||
| 118 | 108 | > **Warning:** Only use for local development. Secrets are stored in plain text. | |
| 119 | 109 | ||
| 120 | - | ## Secret Provider Syntax | |
| 121 | - | ||
| 122 | - | In environment files, reference secrets using provider syntax: | |
| 123 | - | ||
| 124 | - | ### Pass Provider | |
| 125 | - | ||
| 126 | - | ```bash | |
| 127 | - | # ${pass:<path>} | |
| 128 | - | DATABASE_PASSWORD=${pass:myapp/database/password} | |
| 129 | - | API_KEY=${pass:myapp/api-key} | |
| 130 | - | ``` | |
| 131 | - | ||
| 132 | - | ### SOPS Provider | |
| 133 | - | ||
| 134 | - | ```bash | |
| 135 | - | # ${sops:<file>:<key.path>} | |
| 136 | - | DATABASE_PASSWORD=${sops:secrets.yaml:database.password} | |
| 137 | - | API_SECRET=${sops:production.yaml:api.key} | |
| 138 | - | ``` | |
| 139 | - | ||
| 140 | 110 | ## App Secret Values | |
| 141 | 111 | ||
| 142 | 112 | App secret names are registered in the app manifest (`[services.<name>.secrets]` maps ENV name to secret name); values are written separately and stay in pass: | |
M docs/config/security-hardening.md +0 -4
| @@ -83,10 +83,6 @@ The manifest references only the logical name: `devices = ["transcode-gpu"]`. | |||
| 83 | 83 | - Device-bearing creates require Podman 5.4+ or Docker 28.3+ with native CDI configured. Older or unrecognized engines fail with a structured `runtime-unsupported` error and never run without devices. | |
| 84 | 84 | - Gordon installs no drivers, manages no quotas, and injects no device environment: images carry their own runtime expectations. | |
| 85 | 85 | ||
| 86 | - | ## Pass import plaintext handling | |
| 87 | - | ||
| 88 | - | With the `pass` backend, Gordon imports eligible plaintext `.env` files at startup. It removes a source file only after every entry is stored successfully. If a destination entry already exists or an import fails, Gordon fails closed and leaves the plaintext source in place for operator review. | |
| 89 | - | ||
| 90 | 86 | ## External image registries | |
| 91 | 87 | ||
| 92 | 88 | Docker Hub, `ghcr.io`, `quay.io`, and Gordon's configured registry are always allowed. Add every other registry hostname and non-default port explicitly: | |
M docs/config/server.md +1 -2
| @@ -32,7 +32,7 @@ protocol = "smart_tcp" | |||
| 32 | 32 | | `gordon_domain` | string | **required** | Domain for Gordon (registry + admin API) | | |
| 33 | 33 | | `registry_domain` | string | - | Deprecated migration key. Set `gordon_domain` instead. | | |
| 34 | 34 | | `legacy_registry_domains` | []string | `[]` | Additional Gordon registry hosts treated as aliases during staged migration. See [Upgrading: Staged Registry Host Rename](../upgrading.md#staged-registry-host-rename). | | |
| 35 | - | | `data_dir` | string | `~/.gordon` | Directory for registry data, logs, and env files | | |
| 35 | + | | `data_dir` | string | `~/.gordon` | Directory for registry data, logs, and state | | |
| 36 | 36 | | `max_proxy_body_size` | string | `"512MB"` | Maximum request body size for proxied requests | | |
| 37 | 37 | | `max_blob_chunk_size` | string | `"95MB"` | Maximum request body size for a single registry blob upload chunk | | |
| 38 | 38 | | `max_blob_size` | string | `"1GB"` | Maximum cumulative size for one registry blob/layer upload | | |
| @@ -220,7 +220,6 @@ data_dir = "~/.gordon" # Default for user installations | |||
| 220 | 220 | ├── registry/ # Container images and manifests | |
| 221 | 221 | │ ├── blobs/ | |
| 222 | 222 | │ └── manifests/ | |
| 223 | - | ├── env/ # Installation secret-store data | |
| 224 | 223 | ├── logs/ # Gordon process and file-based access logs | |
| 225 | 224 | │ ├── gordon.log | |
| 226 | 225 | │ └── access.log | |
M docs/config/services.md +1 -1
| @@ -91,4 +91,4 @@ By default Gordon removes old or disabled service containers while preserving vo | |||
| 91 | 91 | ||
| 92 | 92 | - [Traffic Plane Configuration](./traffic.md) | |
| 93 | 93 | - [Configuration Reference](./reference.md) | |
| 94 | - | - [CLI traffic status](../cli/traffic.md) | |
| 94 | + | - [CLI traffic status](../cli/daemon.md#gordon-daemon-traffic) | |
M docs/config/telemetry.md +1 -1
| @@ -110,7 +110,7 @@ Set `auth_token` to the Base64-encoded `user:password` string. Gordon sends it a | |||
| 110 | 110 | ||
| 111 | 111 | For OpenObserve, copy the token from **Ingestion > OTLP** in the web UI. | |
| 112 | 112 | ||
| 113 | - | Since Gordon itself is the platform (not a managed container), it does not use `gordon secrets set`. Store the token with one of these methods: | |
| 113 | + | Gordon itself is the platform, not an app, so the token does not go through `gordon apps secrets`. Store it with one of these methods: | |
| 114 | 114 | ||
| 115 | 115 | | Method | How | | |
| 116 | 116 | |--------|-----| | |
M docs/config/traffic.md +1 -1
| @@ -178,4 +178,4 @@ UDP sessions are keyed by client address and expire after `idle_timeout`. If `ma | |||
| 178 | 178 | - [Server Settings](./server.md) | |
| 179 | 179 | - [App Manifest](./apps.md) | |
| 180 | 180 | - [Standalone Services](./services.md) | |
| 181 | - | - [CLI traffic status](../cli/traffic.md) | |
| 181 | + | - [CLI traffic status](../cli/daemon.md#gordon-daemon-traffic) | |
M docs/deployment/generic-ci.md +8 -8
| @@ -8,7 +8,7 @@ Deploy with Gordon from any CI/CD system. | |||
| 8 | 8 | - Network access to the public Gordon HTTPS domain; do not expose or target the loopback `server.registry_port` | |
| 9 | 9 | - Gordon binary (optional but recommended) | |
| 10 | 10 | ||
| 11 | - | ## Recommended: gordon push | |
| 11 | + | ## Recommended: gordon images push | |
| 12 | 12 | ||
| 13 | 13 | ### 1. Generate Deployment Token | |
| 14 | 14 | ||
| @@ -40,7 +40,7 @@ chmod +x /usr/local/bin/gordon | |||
| 40 | 40 | ||
| 41 | 41 | # 2. Build, push, and deploy | |
| 42 | 42 | export GORDON_TOKEN="$GORDON_TOKEN" | |
| 43 | - | gordon push --build \ | |
| 43 | + | gordon images push --build \ | |
| 44 | 44 | --remote "$GORDON_REMOTE" \ | |
| 45 | 45 | ||
| 46 | 46 | ``` | |
| @@ -52,7 +52,7 @@ Gordon auto-detects the version from CI environment variables: | |||
| 52 | 52 | | GitHub Actions | `$GITHUB_REF` | `refs/tags/v1.2.0` | | |
| 53 | 53 | | GitLab CI | `$CI_COMMIT_TAG` | `v1.2.0` | | |
| 54 | 54 | | Azure DevOps | `$BUILD_SOURCEBRANCH` | `refs/tags/v1.2.0` | | |
| 55 | - | | Other | `--tag` flag | `gordon push --tag v1.2.0` | | |
| 55 | + | | Other | `--tag` flag | `gordon images push --tag v1.2.0` | | |
| 56 | 56 | ||
| 57 | 57 | ## Alternative: docker push | |
| 58 | 58 | ||
| @@ -89,7 +89,7 @@ pipeline { | |||
| 89 | 89 | curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 \ | |
| 90 | 90 | -o /usr/local/bin/gordon | |
| 91 | 91 | chmod +x /usr/local/bin/gordon | |
| 92 | - | gordon push --build --remote "$GORDON_REMOTE" | |
| 92 | + | gordon images push --build --remote "$GORDON_REMOTE" | |
| 93 | 93 | ''' | |
| 94 | 94 | } | |
| 95 | 95 | } | |
| @@ -119,7 +119,7 @@ jobs: | |||
| 119 | 119 | - run: | |
| 120 | 120 | name: Deploy | |
| 121 | 121 | command: | | |
| 122 | - | gordon push --build \ | |
| 122 | + | gordon images push --build \ | |
| 123 | 123 | --remote "$GORDON_REMOTE" | |
| 124 | 124 | ||
| 125 | 125 | workflows: | |
| @@ -152,7 +152,7 @@ steps: | |||
| 152 | 152 | - apk add --no-cache curl | |
| 153 | 153 | - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon | |
| 154 | 154 | - chmod +x /usr/local/bin/gordon | |
| 155 | - | - gordon push --build --remote "$GORDON_REMOTE" | |
| 155 | + | - gordon images push --build --remote "$GORDON_REMOTE" | |
| 156 | 156 | ||
| 157 | 157 | trigger: | |
| 158 | 158 | event: | |
| @@ -188,7 +188,7 @@ gordon apps deploy myapp --remote "$GORDON_REMOTE" | |||
| 188 | 188 | ||
| 189 | 189 | Gordon could not detect a version tag. Either: | |
| 190 | 190 | - Tag your git repo: `git tag v1.0.0 && git push --tags` | |
| 191 | - | - Pass explicitly: `gordon push --tag v1.0.0` | |
| 191 | + | - Pass explicitly: `gordon images push --tag v1.0.0` | |
| 192 | 192 | ||
| 193 | 193 | ### Large images time out | |
| 194 | 194 | ||
| @@ -200,4 +200,4 @@ Gordon uploads in 50MB chunks. For very large images (> 1GB), the push may take | |||
| 200 | 200 | - [GitLab CI](./gitlab-ci.md) | |
| 201 | 201 | - [Deployment Overview](./index.md) | |
| 202 | 202 | - [Authentication](../config/auth.md) | |
| 203 | - | - [Push Command](../cli/push.md) | |
| 203 | + | - [Images Commands](../cli/images.md#gordon-images-push) | |
M docs/deployment/github-actions.md +10 -10
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | # GitHub Actions Deployment | |
| 2 | 2 | ||
| 3 | - | Two approaches for deploying with GitHub Actions: the `gordon push` CLI (recommended) or a Docker-based workflow using Gordon's official action. | |
| 3 | + | Two approaches for deploying with GitHub Actions: the `gordon images push` CLI (recommended) or a Docker-based workflow using Gordon's official action. | |
| 4 | 4 | ||
| 5 | 5 | ## Prerequisites | |
| 6 | 6 | ||
| @@ -8,9 +8,9 @@ Two approaches for deploying with GitHub Actions: the `gordon push` CLI (recomme | |||
| 8 | 8 | 2. Deployment token generated with the minimum required scopes: `push,pull` for image transfer, plus `admin:apps:read,admin:apps:write` only when the workflow applies or deploys apps | |
| 9 | 9 | 3. GitHub repository secrets configured | |
| 10 | 10 | ||
| 11 | - | ## Recommended: gordon push | |
| 11 | + | ## Recommended: gordon images push | |
| 12 | 12 | ||
| 13 | - | Use the `gordon push` CLI for a lightweight, single-step build and deploy. | |
| 13 | + | Use the `gordon images push` CLI for a lightweight, single-step build and deploy. | |
| 14 | 14 | ||
| 15 | 15 | ### 1. Generate Token | |
| 16 | 16 | ||
| @@ -71,7 +71,7 @@ jobs: | |||
| 71 | 71 | env: | |
| 72 | 72 | GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }} | |
| 73 | 73 | run: | | |
| 74 | - | gordon push --build \ | |
| 74 | + | gordon images push --build \ | |
| 75 | 75 | --remote ${{ secrets.GORDON_REMOTE }} | |
| 76 | 76 | ``` | |
| 77 | 77 | ||
| @@ -102,7 +102,7 @@ jobs: | |||
| 102 | 102 | env: | |
| 103 | 103 | GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }} | |
| 104 | 104 | run: | | |
| 105 | - | gordon push --build \ | |
| 105 | + | gordon images push --build \ | |
| 106 | 106 | --remote ${{ secrets.GORDON_REMOTE }} \ | |
| 107 | 107 | --tag latest | |
| 108 | 108 | ``` | |
| @@ -149,7 +149,7 @@ jobs: | |||
| 149 | 149 | ||
| 150 | 150 | #### Monorepo | |
| 151 | 151 | ||
| 152 | - | Deploy multiple services with separate `gordon push` calls: | |
| 152 | + | Deploy multiple services with separate `gordon images push` calls: | |
| 153 | 153 | ||
| 154 | 154 | ```yaml | |
| 155 | 155 | name: Deploy Services | |
| @@ -171,7 +171,7 @@ jobs: | |||
| 171 | 171 | env: | |
| 172 | 172 | GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }} | |
| 173 | 173 | run: | | |
| 174 | - | gordon push --build \ | |
| 174 | + | gordon images push --build \ | |
| 175 | 175 | --remote ${{ secrets.GORDON_REMOTE }} \ | |
| 176 | 176 | --file ./services/api/Dockerfile \ | |
| 177 | 177 | myapp-api | |
| @@ -180,7 +180,7 @@ jobs: | |||
| 180 | 180 | env: | |
| 181 | 181 | GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }} | |
| 182 | 182 | run: | | |
| 183 | - | gordon push --build \ | |
| 183 | + | gordon images push --build \ | |
| 184 | 184 | --remote ${{ secrets.GORDON_REMOTE }} \ | |
| 185 | 185 | --file ./services/web/Dockerfile \ | |
| 186 | 186 | myapp-web | |
| @@ -195,7 +195,7 @@ Pass build arguments to the Docker build: | |||
| 195 | 195 | env: | |
| 196 | 196 | GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }} | |
| 197 | 197 | run: | | |
| 198 | - | gordon push --build \ | |
| 198 | + | gordon images push --build \ | |
| 199 | 199 | --remote ${{ secrets.GORDON_REMOTE }} \ | |
| 200 | 200 | --build-arg NODE_ENV=production \ | |
| 201 | 201 | --build-arg API_URL=https://api.example.com \ | |
| @@ -478,5 +478,5 @@ Error: unauthorized: authentication required | |||
| 478 | 478 | - [Generic CI](./generic-ci.md) | |
| 479 | 479 | - [Deployment Overview](./index.md) | |
| 480 | 480 | - [Authentication](../config/auth.md) | |
| 481 | - | - [Push Command](../cli/push.md) | |
| 481 | + | - [Images Commands](../cli/images.md#gordon-images-push) | |
| 482 | 482 | - [Rollback](./rollback.md) | |
M docs/deployment/gitlab-ci.md +6 -6
| @@ -45,7 +45,7 @@ deploy: | |||
| 45 | 45 | - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon | |
| 46 | 46 | - chmod +x /usr/local/bin/gordon | |
| 47 | 47 | script: | |
| 48 | - | - gordon push --build | |
| 48 | + | - gordon images push --build | |
| 49 | 49 | --remote "$GORDON_REMOTE" | |
| 50 | 50 | --token "$GORDON_TOKEN" | |
| 51 | 51 | ||
| @@ -73,7 +73,7 @@ deploy: | |||
| 73 | 73 | - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon | |
| 74 | 74 | - chmod +x /usr/local/bin/gordon | |
| 75 | 75 | script: | |
| 76 | - | - gordon push --build | |
| 76 | + | - gordon images push --build | |
| 77 | 77 | --remote "$GORDON_REMOTE" | |
| 78 | 78 | --token "$GORDON_TOKEN" | |
| 79 | 79 | ||
| @@ -96,7 +96,7 @@ deploy: | |||
| 96 | 96 | - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon | |
| 97 | 97 | - chmod +x /usr/local/bin/gordon | |
| 98 | 98 | script: | |
| 99 | - | - gordon push --build | |
| 99 | + | - gordon images push --build | |
| 100 | 100 | --remote "$GORDON_REMOTE" | |
| 101 | 101 | --token "$GORDON_TOKEN" | |
| 102 | 102 | ||
| @@ -117,7 +117,7 @@ deploy: | |||
| 117 | 117 | - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon | |
| 118 | 118 | - chmod +x /usr/local/bin/gordon | |
| 119 | 119 | script: | |
| 120 | - | - gordon push --build | |
| 120 | + | - gordon images push --build | |
| 121 | 121 | --remote "$GORDON_REMOTE" | |
| 122 | 122 | --token "$GORDON_TOKEN" | |
| 123 | 123 | ||
| @@ -137,7 +137,7 @@ deploy: | |||
| 137 | 137 | - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon | |
| 138 | 138 | - chmod +x /usr/local/bin/gordon | |
| 139 | 139 | script: | |
| 140 | - | - gordon push --build | |
| 140 | + | - gordon images push --build | |
| 141 | 141 | --remote "$GORDON_REMOTE" | |
| 142 | 142 | --token "$GORDON_TOKEN" | |
| 143 | 143 | --build-arg NODE_ENV=production | |
| @@ -208,4 +208,4 @@ GitLab CI clones your repository automatically. The build context defaults to th | |||
| 208 | 208 | - [Generic CI](./generic-ci.md) | |
| 209 | 209 | - [Deployment Overview](./index.md) | |
| 210 | 210 | - [Authentication](../config/auth.md) | |
| 211 | - | - [Push Command](../cli/push.md) | |
| 211 | + | - [Images Commands](../cli/images.md#gordon-images-push) | |
M docs/deployment/index.md +6 -6
| @@ -2,16 +2,16 @@ | |||
| 2 | 2 | ||
| 3 | 3 | Gordon deploys apps in two explicit steps: push the image to its built-in registry, then apply the app manifest and deploy. Push transfers OCI content only — it never deploys, creates routes, or modifies manifests. | |
| 4 | 4 | ||
| 5 | - | ## Recommended: gordon push + apps deploy | |
| 5 | + | ## Recommended: gordon images push + apps deploy | |
| 6 | 6 | ||
| 7 | - | `gordon push --build --remote` builds, pushes, and stores the image from CI/CD pipelines. Activation is a separate explicit step with the Gordon CLI. | |
| 7 | + | `gordon images push --build --remote` builds, pushes, and stores the image from CI/CD pipelines. Activation is a separate explicit step with the Gordon CLI. | |
| 8 | 8 | ||
| 9 | 9 | - Single secret (`GORDON_TOKEN`): auto-exchanges for a short-lived registry token | |
| 10 | 10 | - Auto-detects version from CI environment (`$GITHUB_REF`, `$CI_COMMIT_TAG`, `$BUILD_SOURCEBRANCH`, or `git describe`) | |
| 11 | 11 | - Chunked uploads (50MB chunks) — works behind Cloudflare and restrictive proxies | |
| 12 | 12 | ||
| 13 | 13 | ```bash | |
| 14 | - | gordon push --build --remote https://gordon.example.com | |
| 14 | + | gordon images push --build --remote https://gordon.example.com | |
| 15 | 15 | ``` | |
| 16 | 16 | ||
| 17 | 17 | Then activate (from CI with the Gordon binary, or from your machine): | |
| @@ -25,10 +25,10 @@ gordon apps deploy blog --remote https://gordon.example.com | |||
| 25 | 25 | ||
| 26 | 26 | | Method | Best For | Secrets Needed | Registry Access | Deploy Control | | |
| 27 | 27 | |--------|----------|----------------|-----------------|----------------| | |
| 28 | - | | `gordon push` + `apps deploy` (Recommended) | CI/CD pipelines | 1 (`GORDON_TOKEN`) | Via gordon domain (HTTPS) | Explicit (CLI-triggered) | | |
| 28 | + | | `gordon images push` + `apps deploy` (Recommended) | CI/CD pipelines | 1 (`GORDON_TOKEN`) | Via gordon domain (HTTPS) | Explicit (CLI-triggered) | | |
| 29 | 29 | | `docker push` + `apps deploy` | Simple setups, existing Docker workflows | 2 (`username` + `token`) | Via gordon domain (HTTPS) | Explicit (CLI-triggered) | | |
| 30 | 30 | ||
| 31 | - | ### Method 1: gordon push + apps deploy (Recommended) | |
| 31 | + | ### Method 1: gordon images push + apps deploy (Recommended) | |
| 32 | 32 | ||
| 33 | 33 | The Gordon CLI handles authentication, image building, and registry upload in a single step. Deploy stays explicit. | |
| 34 | 34 | ||
| @@ -38,7 +38,7 @@ The Gordon CLI handles authentication, image building, and registry upload in a | |||
| 38 | 38 | ||
| 39 | 39 | ```bash | |
| 40 | 40 | # 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 | |
| 42 | 42 | ||
| 43 | 43 | # Apply the manifest that references the pushed tag, then deploy | |
| 44 | 44 | gordon apps apply --file blog.toml --remote https://gordon.example.com | |
M docs/getting-started.md +3 -3
| @@ -148,7 +148,7 @@ gordon remotes add prod https://gordon.mydomain.com --token <your-token> | |||
| 148 | 148 | gordon remotes use prod | |
| 149 | 149 | ||
| 150 | 150 | # 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 | |
| 152 | 152 | ``` | |
| 153 | 153 | ||
| 154 | 154 | Write the app file (`blog.toml`). The file is intended for Git: it must never contain secret values. | |
| @@ -173,7 +173,7 @@ gordon apps deploy blog --remote prod | |||
| 173 | 173 | ||
| 174 | 174 | What these commands do: | |
| 175 | 175 | ||
| 176 | - | - `gordon push` builds, uploads, and stores the image. It never deploys. | |
| 176 | + | - `gordon images push` builds, uploads, and stores the image. It never deploys. | |
| 177 | 177 | - `gordon apps apply` validates the manifest and persists it as desired state. | |
| 178 | 178 | - `gordon apps deploy` activates the accepted revision: runs preflight, withdraws the service from traffic, stops and removes the old container, starts the new container, waits for readiness, publishes the new `ACTIVE` state, and routes traffic to it. | |
| 179 | 179 | ||
| @@ -191,7 +191,7 @@ Push a new image, update the manifest tag if needed, apply, deploy: | |||
| 191 | 191 | ||
| 192 | 192 | ```bash | |
| 193 | 193 | # Make changes, then build + push | |
| 194 | - | gordon push myapp --build --remote prod | |
| 194 | + | gordon images push myapp --build --remote prod | |
| 195 | 195 | ||
| 196 | 196 | # Deploy the new tag (re-resolves mutable tags) | |
| 197 | 197 | gordon apps deploy blog --remote prod | |
M docs/index.md +0 -1
| @@ -70,7 +70,6 @@ Gordon runs on your VPS and provides: | |||
| 70 | 70 | ### Reference | |
| 71 | 71 | ||
| 72 | 72 | - [Docker Labels](./reference/docker-labels.md) - Container and image labels | |
| 73 | - | - [Environment Variables](./reference/env-variables.md) - Environment variable syntax | |
| 74 | 73 | - [Troubleshooting](./reference/troubleshooting.md) - Common issues and solutions | |
| 75 | 74 | ||
| 76 | 75 | ## Requirements | |
M docs/installation.md +0 -1
| @@ -377,7 +377,6 @@ Gordon stores data in the following locations: | |||
| 377 | 377 | | `~/.config/gordon/gordon.toml` | Configuration file | | |
| 378 | 378 | | `~/.gordon/` | Default data directory | | |
| 379 | 379 | | `~/.gordon/registry/` | Container images | | |
| 380 | - | | `~/.gordon/env/` | Environment files | | |
| 381 | 380 | | `~/.gordon/logs/` | Application logs | | |
| 382 | 381 | | `~/.gordon/secrets/` | Secrets (unsafe backend only) | | |
| 383 | 382 | ||
M docs/migrate-to-v3.md +1 -1
| @@ -57,7 +57,7 @@ Manifests written for an early v3 alpha used `[[service]]` with a `name` field; | |||
| 57 | 57 | ||
| 58 | 58 | ## 2. Migrate domain secrets | |
| 59 | 59 | ||
| 60 | - | Gordon does not convert domain-scoped secrets into app secrets. Inventory the old key names, declare them under `[services.<name>.secrets]`, then apply the manifest before setting values: | |
| 60 | + | Gordon does not convert domain-scoped secrets into app secrets, and v3 has no command to list them. With the `pass` backend, inventory the old key names with `pass ls gordon/env`, declare them under `[services.<name>.secrets]`, then apply the manifest before setting values: | |
| 61 | 61 | ||
| 62 | 62 | ```bash | |
| 63 | 63 | gordon apps apply --file ./example-app.toml --remote production | |
D docs/reference/env-variables.md +0 -222
| @@ -1,222 +0,0 @@ | |||
| 1 | - | # Environment Variables Reference | |
| 2 | - | ||
| 3 | - | How Gordon handles environment variables for containers. | |
| 4 | - | ||
| 5 | - | ## Variable Sources | |
| 6 | - | ||
| 7 | - | Environment variables are loaded from multiple sources and merged: | |
| 8 | - | ||
| 9 | - | | Source | Priority | Description | | |
| 10 | - | |--------|----------|-------------| | |
| 11 | - | | Dockerfile `ENV` | Lowest | Default values from image | | |
| 12 | - | | `.env` file | Highest | Per-route overrides | | |
| 13 | - | ||
| 14 | - | Higher priority sources override lower priority values. | |
| 15 | - | ||
| 16 | - | ## Environment File Location | |
| 17 | - | ||
| 18 | - | Files are stored in the env directory (default: `~/.gordon/env/`): | |
| 19 | - | ||
| 20 | - | ``` | |
| 21 | - | ~/.gordon/env/ | |
| 22 | - | ├── app_mydomain_com.env | |
| 23 | - | ├── api_mydomain_com.env | |
| 24 | - | └── admin_mydomain_com.env | |
| 25 | - | ``` | |
| 26 | - | ||
| 27 | - | **Naming:** Replace dots with underscores, add `.env` suffix. | |
| 28 | - | ||
| 29 | - | | Domain | File Name | | |
| 30 | - | |--------|-----------| | |
| 31 | - | | `app.mydomain.com` | `app_mydomain_com.env` | | |
| 32 | - | | `api.company.io` | `api_company_io.env` | | |
| 33 | - | | `staging.app.dev` | `staging_app_dev.env` | | |
| 34 | - | ||
| 35 | - | ## File Format | |
| 36 | - | ||
| 37 | - | Standard `.env` format: | |
| 38 | - | ||
| 39 | - | ```bash | |
| 40 | - | # Comments start with # | |
| 41 | - | KEY=value | |
| 42 | - | ANOTHER_KEY=another value | |
| 43 | - | ||
| 44 | - | # Quotes are optional but recommended for values with spaces | |
| 45 | - | MESSAGE="Hello World" | |
| 46 | - | ||
| 47 | - | # No spaces around = | |
| 48 | - | DATABASE_URL=postgresql://localhost:5432/mydb | |
| 49 | - | ``` | |
| 50 | - | ||
| 51 | - | ## Secret Provider Syntax | |
| 52 | - | ||
| 53 | - | Reference secrets from configured backends: | |
| 54 | - | ||
| 55 | - | ### Pass Provider | |
| 56 | - | ||
| 57 | - | ```bash | |
| 58 | - | # Syntax: ${pass:<path>} | |
| 59 | - | DATABASE_PASSWORD=${pass:myapp/database/password} | |
| 60 | - | API_KEY=${pass:company/api-key} | |
| 61 | - | JWT_SECRET=${pass:production/jwt-secret} | |
| 62 | - | ``` | |
| 63 | - | ||
| 64 | - | ### SOPS Provider | |
| 65 | - | ||
| 66 | - | ```bash | |
| 67 | - | # Syntax: ${sops:<file>:<key.path>} | |
| 68 | - | DATABASE_PASSWORD=${sops:secrets.yaml:database.password} | |
| 69 | - | API_SECRET=${sops:production.yaml:api.secret.key} | |
| 70 | - | STRIPE_KEY=${sops:secrets.yaml:stripe.api_key} | |
| 71 | - | ``` | |
| 72 | - | ||
| 73 | - | ## Variable Expansion | |
| 74 | - | ||
| 75 | - | ### From Secrets | |
| 76 | - | ||
| 77 | - | ```bash | |
| 78 | - | # Password from pass | |
| 79 | - | DB_PASSWORD=${pass:myapp/db-password} | |
| 80 | - | ||
| 81 | - | # Then use in connection string | |
| 82 | - | DATABASE_URL=postgresql://user:${DB_PASSWORD}@db:5432/mydb | |
| 83 | - | ``` | |
| 84 | - | ||
| 85 | - | ### Static Values | |
| 86 | - | ||
| 87 | - | ```bash | |
| 88 | - | # Simple values | |
| 89 | - | NODE_ENV=production | |
| 90 | - | PORT=3000 | |
| 91 | - | ||
| 92 | - | # Values with special characters (use quotes) | |
| 93 | - | MESSAGE="Hello, World!" | |
| 94 | - | JSON_CONFIG='{"key": "value"}' | |
| 95 | - | ``` | |
| 96 | - | ||
| 97 | - | ## Dockerfile ENV | |
| 98 | - | ||
| 99 | - | Variables from Dockerfile serve as defaults: | |
| 100 | - | ||
| 101 | - | ```dockerfile | |
| 102 | - | FROM node:18 | |
| 103 | - | ENV NODE_ENV=development | |
| 104 | - | ENV PORT=3000 | |
| 105 | - | ENV LOG_LEVEL=info | |
| 106 | - | ``` | |
| 107 | - | ||
| 108 | - | Override in `.env` file: | |
| 109 | - | ||
| 110 | - | ```bash | |
| 111 | - | # Override NODE_ENV, keep PORT and LOG_LEVEL defaults | |
| 112 | - | NODE_ENV=production | |
| 113 | - | ``` | |
| 114 | - | ||
| 115 | - | Result: | |
| 116 | - | - `NODE_ENV=production` (from .env) | |
| 117 | - | - `PORT=3000` (from Dockerfile) | |
| 118 | - | - `LOG_LEVEL=info` (from Dockerfile) | |
| 119 | - | ||
| 120 | - | ## Common Variables | |
| 121 | - | ||
| 122 | - | ### Node.js | |
| 123 | - | ||
| 124 | - | ```bash | |
| 125 | - | NODE_ENV=production | |
| 126 | - | PORT=3000 | |
| 127 | - | LOG_LEVEL=warn | |
| 128 | - | ``` | |
| 129 | - | ||
| 130 | - | ### Python | |
| 131 | - | ||
| 132 | - | ```bash | |
| 133 | - | FLASK_ENV=production | |
| 134 | - | DJANGO_SETTINGS_MODULE=myapp.settings.production | |
| 135 | - | PYTHONUNBUFFERED=1 | |
| 136 | - | ``` | |
| 137 | - | ||
| 138 | - | ### Database Connections | |
| 139 | - | ||
| 140 | - | ```bash | |
| 141 | - | # PostgreSQL | |
| 142 | - | DATABASE_URL=postgresql://user:pass@postgres:5432/mydb | |
| 143 | - | ||
| 144 | - | # MySQL | |
| 145 | - | DATABASE_URL=mysql://user:pass@mysql:3306/mydb | |
| 146 | - | ||
| 147 | - | # Redis | |
| 148 | - | REDIS_URL=redis://redis:6379/0 | |
| 149 | - | ``` | |
| 150 | - | ||
| 151 | - | ### External Services | |
| 152 | - | ||
| 153 | - | ```bash | |
| 154 | - | # AWS | |
| 155 | - | AWS_ACCESS_KEY_ID=${pass:aws/access-key} | |
| 156 | - | AWS_SECRET_ACCESS_KEY=${pass:aws/secret-key} | |
| 157 | - | AWS_REGION=us-east-1 | |
| 158 | - | ||
| 159 | - | # Stripe | |
| 160 | - | STRIPE_SECRET_KEY=${pass:stripe/secret-key} | |
| 161 | - | STRIPE_PUBLISHABLE_KEY=pk_live_... | |
| 162 | - | ||
| 163 | - | # SendGrid | |
| 164 | - | SENDGRID_API_KEY=${pass:sendgrid/api-key} | |
| 165 | - | ``` | |
| 166 | - | ||
| 167 | - | ## Examples | |
| 168 | - | ||
| 169 | - | ### Minimal | |
| 170 | - | ||
| 171 | - | ```bash | |
| 172 | - | NODE_ENV=production | |
| 173 | - | PORT=3000 | |
| 174 | - | ``` | |
| 175 | - | ||
| 176 | - | ### With Database | |
| 177 | - | ||
| 178 | - | ```bash | |
| 179 | - | NODE_ENV=production | |
| 180 | - | PORT=3000 | |
| 181 | - | DATABASE_URL=postgresql://postgres:5432/myapp | |
| 182 | - | DATABASE_PASSWORD=${pass:myapp/db-password} | |
| 183 | - | ``` | |
| 184 | - | ||
| 185 | - | ### Full Production | |
| 186 | - | ||
| 187 | - | ```bash | |
| 188 | - | # Application | |
| 189 | - | NODE_ENV=production | |
| 190 | - | PORT=3000 | |
| 191 | - | LOG_LEVEL=warn | |
| 192 | - | ||
| 193 | - | # Database | |
| 194 | - | DATABASE_URL=postgresql://postgres:5432/production | |
| 195 | - | DATABASE_PASSWORD=${pass:company/db-password} | |
| 196 | - | ||
| 197 | - | # Cache | |
| 198 | - | REDIS_URL=redis://redis:6379/0 | |
| 199 | - | ||
| 200 | - | # External APIs | |
| 201 | - | STRIPE_SECRET_KEY=${pass:company/stripe-secret} | |
| 202 | - | SENDGRID_API_KEY=${pass:company/sendgrid-key} | |
| 203 | - | ||
| 204 | - | # Auth | |
| 205 | - | JWT_SECRET=${pass:company/jwt-secret} | |
| 206 | - | SESSION_SECRET=${pass:company/session-secret} | |
| 207 | - | ||
| 208 | - | # Feature flags | |
| 209 | - | ENABLE_ANALYTICS=true | |
| 210 | - | MAINTENANCE_MODE=false | |
| 211 | - | ``` | |
| 212 | - | ||
| 213 | - | ## Viewing Container Environment | |
| 214 | - | ||
| 215 | - | ```bash | |
| 216 | - | docker inspect gordon-app-mydomain-com --format '{{json .Config.Env}}' | jq | |
| 217 | - | ``` | |
| 218 | - | ||
| 219 | - | ## Related | |
| 220 | - | ||
| 221 | - | - [Environment Configuration](../config/env.md) | |
| 222 | - | - [Secrets Configuration](../config/secrets.md) | |
M docs/reference/index.md +0 -1
| @@ -5,7 +5,6 @@ Technical reference documentation for Gordon. | |||
| 5 | 5 | ## Contents | |
| 6 | 6 | ||
| 7 | 7 | - [Docker Labels](./docker-labels.md) - Container and image labels used by Gordon | |
| 8 | - | - [Environment Variables](./env-variables.md) - Environment variable syntax and interpolation | |
| 9 | 8 | - [Telemetry & Metrics](../config/telemetry.md) - OpenTelemetry configuration, custom metrics, and trace spans | |
| 10 | 9 | - [Troubleshooting](./troubleshooting.md) - Common issues and solutions | |
| 11 | 10 | ||
M docs/reference/troubleshooting.md +11 -16
| @@ -91,7 +91,7 @@ No restart needed — Podman reads this on each pull/push. | |||
| 91 | 91 | 1. Per-command flag: | |
| 92 | 92 | ||
| 93 | 93 | ```bash | |
| 94 | - | gordon push myapp:latest --insecure | |
| 94 | + | gordon images push myapp:latest --insecure | |
| 95 | 95 | ``` | |
| 96 | 96 | ||
| 97 | 97 | 2. Environment variable: | |
| @@ -164,7 +164,7 @@ gordon apps deploy app | |||
| 164 | 164 | ||
| 165 | 165 | 2. Check Gordon logs: | |
| 166 | 166 | ```bash | |
| 167 | - | gordon logs -f | |
| 167 | + | gordon daemon logs -f | |
| 168 | 168 | ``` | |
| 169 | 169 | ||
| 170 | 170 | 3. Run container manually to debug: | |
| @@ -174,28 +174,23 @@ gordon apps deploy app | |||
| 174 | 174 | ||
| 175 | 175 | ### Environment variables not loaded | |
| 176 | 176 | ||
| 177 | - | **Cause:** Env file not found or wrong format. | |
| 177 | + | **Cause:** The variable is not declared in the app file, or its secret value was never set. | |
| 178 | 178 | ||
| 179 | 179 | **Solutions:** | |
| 180 | 180 | ||
| 181 | - | 1. Check file exists with correct name: | |
| 181 | + | 1. Declare public values under `[env]` and service values under `[services.<name>.secrets]` in the app file, then apply it: | |
| 182 | 182 | ```bash | |
| 183 | - | ls ~/.gordon/env/ | |
| 184 | - | # Should show: app_mydomain_com.env (dots → underscores) | |
| 183 | + | gordon apps apply --file ./blog.toml | |
| 185 | 184 | ``` | |
| 186 | 185 | ||
| 187 | - | 2. Check file permissions: | |
| 186 | + | 2. Check which secret values are set (names only): | |
| 188 | 187 | ```bash | |
| 189 | - | chmod 600 ~/.gordon/env/app_mydomain_com.env | |
| 188 | + | gordon apps secrets list blog | |
| 190 | 189 | ``` | |
| 191 | 190 | ||
| 192 | - | 3. Check file format (no spaces around `=`): | |
| 191 | + | 3. Deploy or restart: values apply on the next deploy/restart, never to running containers. | |
| 193 | 192 | ```bash | |
| 194 | - | # Correct | |
| 195 | - | KEY=value | |
| 196 | - | ||
| 197 | - | # Wrong | |
| 198 | - | KEY = value | |
| 193 | + | gordon apps restart blog | |
| 199 | 194 | ``` | |
| 200 | 195 | ||
| 201 | 196 | ### Secrets not resolved | |
| @@ -318,7 +313,7 @@ gordon apps deploy app | |||
| 318 | 313 | ||
| 319 | 314 | **Solution:** Manual reload: | |
| 320 | 315 | ```bash | |
| 321 | - | gordon reload | |
| 316 | + | gordon daemon reload | |
| 322 | 317 | ``` | |
| 323 | 318 | ||
| 324 | 319 | ### Stale `targets.toml` in config directory | |
| @@ -372,7 +367,7 @@ path = "~/.gordon/logs/gordon.log" | |||
| 372 | 367 | systemctl --user status gordon | |
| 373 | 368 | ||
| 374 | 369 | # View Gordon logs | |
| 375 | - | gordon logs -f | |
| 370 | + | gordon daemon logs -f | |
| 376 | 371 | journalctl --user -u gordon -f | |
| 377 | 372 | ||
| 378 | 373 | # List containers | |
M docs/upgrading.md +6 -4
| @@ -13,7 +13,9 @@ Follow [Migrate to Gordon v3](./migrate-to-v3.md) for the complete cutover proce | |||
| 13 | 13 | - App-workload keys in `gordon.toml`: `[routes]`, `[attachments]`, `[network_groups]`, `[service_routes]`, `[auto_route]` (+ `_allowed_domains`), and `[previews]`. Gordon fails boot/reload closed with a `config-retired` diagnostic naming the fix when any of them is present — never a silent migration. | |
| 14 | 14 | - Installation-level `[[services]]` (standalone L4 workloads) and `[[network_services]]` (L4 traffic plane) stay valid. Only their old app-workload semantics were removed; declare application workloads in standalone files instead. See [Standalone Services](./config/services.md). | |
| 15 | 15 | - CLI: `pin`, `preview`, `attachments`, `bootstrap`, `autoroute allow`, `routes add/remove/purge`, push deploy/route inference, implicit deploys on push/reload, label/env-file inference. Removed HTTP mutation endpoints answer `410 Gone`. | |
| 16 | - | - Scopes `admin:routes:*` are replaced by `admin:apps:read` (list, show, diff, status) and `admin:apps:write` (apply, deploy, lifecycle, secrets). Regenerate CI tokens, e.g. `--scopes "push,pull,admin:apps:read,admin:apps:write"`. | |
| 16 | + | - Domain secrets: `gordon secrets`, `/admin/secrets` (now `410 Gone`), the `admin:secrets:*` scopes, the `[env]` config section, and startup `.env` import into pass. Use `gordon apps secrets`. Existing `gordon/env/...` pass entries are left untouched. | |
| 17 | + | - CLI layout: `gordon push` is now `gordon images push`. `status`, `logs`, `reload`, `config`, `tls status`, `traffic status`, and `networks list` moved under `gordon daemon` (`gordon daemon status`, `gordon daemon tls`, `gordon daemon traffic`, `gordon daemon networks`, …). There are no aliases. | |
| 18 | + | - Scopes `admin:routes:*` and `admin:secrets:*` are replaced by `admin:apps:read` (list, show, diff, status) and `admin:apps:write` (apply, deploy, lifecycle, secrets). Regenerate CI tokens, e.g. `--scopes "push,pull,admin:apps:read,admin:apps:write"`. | |
| 17 | 19 | - No historical rollback command: roll back by applying a manifest that references the previous tag and deploying again. | |
| 18 | 20 | ||
| 19 | 21 | ### Migrating a v3 alpha app manifest | |
| @@ -52,7 +54,7 @@ Names containing dots must be quoted: `[services."web.api"]` and `[[services."we | |||
| 52 | 54 | ### Manual migration | |
| 53 | 55 | ||
| 54 | 56 | 1. Back up databases/volumes and pass entries with the existing procedures. Record original ownership and image versions. No update hook deletes volumes: unknown resources are preserved, never adopted. | |
| 55 | - | 2. Delete the removed keys from `gordon.toml` (installation settings only: entrypoints, TLS, limits, external routes, images policy, backups destinations stay). | |
| 57 | + | 2. Delete the removed keys, including `[env]`, from `gordon.toml` (installation settings only: entrypoints, TLS, limits, external routes, images policy, backups destinations stay). | |
| 56 | 58 | 3. Write one `<app>.toml` per app (see [App Manifest](./config/apps.md)): services, `[[services.<name>.http]]` hosts, `[services.<name>.secrets]` names, volumes, `[[network.shared]]`, backup declarations. | |
| 57 | 59 | 4. Migrate secret values explicitly with `gordon apps secrets set` after applying each manifest. Gordon does not copy `gordon/env/<domain>/...` entries into `gordon/apps/<uuid>/<service>/...`; follow the [v3 secrets migration procedure](./migrate-to-v3.md#2-migrate-domain-secrets). | |
| 58 | 60 | 5. `gordon apps apply --file <app>.toml`, then `gordon apps deploy <app>`. | |
| @@ -150,7 +152,7 @@ registry_domain = "gordon.example.com" | |||
| 150 | 152 | gordon_domain = "gordon.example.com" | |
| 151 | 153 | ``` | |
| 152 | 154 | ||
| 153 | - | If you do not migrate, `gordon status --remote ...` and `gordon apps list --remote ...` can fail with `/auth/token` `404`, and `reg-domain/v2/` or `/admin/status` can return `404`. | |
| 155 | + | If you do not migrate, `gordon daemon status --remote ...` and `gordon apps list --remote ...` can fail with `/auth/token` `404`, and `reg-domain/v2/` or `/admin/status` can return `404`. | |
| 154 | 156 | ||
| 155 | 157 | ### Staged Registry Host Rename | |
| 156 | 158 | ||
| @@ -477,7 +479,7 @@ If using pass or sops, update your secret paths: | |||
| 477 | 479 | ``` | |
| 478 | 480 | 6. **Check logs** for any errors: | |
| 479 | 481 | ```bash | |
| 480 | - | gordon logs | |
| 482 | + | gordon daemon logs | |
| 481 | 483 | ``` | |
| 482 | 484 | ||
| 483 | 485 | ## Getting Help | |
M gordon.toml.example +1 -4
| @@ -62,7 +62,7 @@ security_profile = "compat" | |||
| 62 | 62 | ||
| 63 | 63 | [network_isolation] | |
| 64 | 64 | # 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 | |
| 66 | 66 | # `[[network.shared]]` membership in their app file instead. | |
| 67 | 67 | enabled = true | |
| 68 | 68 | network_prefix = "gordon" | |
| @@ -151,9 +151,6 @@ metrics = true | |||
| 151 | 151 | logs = true | |
| 152 | 152 | trace_sample_rate = 1.0 | |
| 153 | 153 | ||
| 154 | - | [env] | |
| 155 | - | # dir = "~/.gordon/env" | |
| 156 | - | ||
| 157 | 154 | [backups.databases] | |
| 158 | 155 | enabled = false | |
| 159 | 156 | schedule = "daily" | |
D internal/adapters/dto/admin_secrets.go +0 -12
| @@ -1,12 +0,0 @@ | |||
| 1 | - | package dto | |
| 2 | - | ||
| 3 | - | // SecretsListResponse represents a list of secret keys for a domain. | |
| 4 | - | type SecretsListResponse struct { | |
| 5 | - | Domain string `json:"domain"` | |
| 6 | - | Keys []string `json:"keys"` | |
| 7 | - | } | |
| 8 | - | ||
| 9 | - | // SecretsStatusResponse represents a secrets write response. | |
| 10 | - | type SecretsStatusResponse struct { | |
| 11 | - | Status string `json:"status"` | |
| 12 | - | } | |
M internal/adapters/in/cli/config.go +3 -3
| @@ -32,9 +32,9 @@ func newConfigShowCmd() *cobra.Command { | |||
| 32 | 32 | auto-route, network isolation, routes, and external routes. | |
| 33 | 33 | ||
| 34 | 34 | 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`, | |
| 38 | 38 | RunE: func(cmd *cobra.Command, _ []string) error { | |
| 39 | 39 | ctx := cmd.Context() | |
| 40 | 40 | handle, err := resolveControlPlane(cliConfigPath) | |
M internal/adapters/in/cli/config_validate.go +1 -1
| @@ -30,7 +30,7 @@ func newConfigValidateCmd() *cobra.Command { | |||
| 30 | 30 | Args: cobra.NoArgs, | |
| 31 | 31 | RunE: func(cmd *cobra.Command, _ []string) error { | |
| 32 | 32 | if remoteFlag != "" { | |
| 33 | - | return fmt.Errorf("config validate is local-only; --remote is not supported") | |
| 33 | + | return fmt.Errorf("daemon config validate is local-only; --remote is not supported") | |
| 34 | 34 | } | |
| 35 | 35 | return runConfigValidate(cmd.OutOrStdout(), file, jsonOut) | |
| 36 | 36 | }, | |
M internal/adapters/in/cli/controlplane.go +0 -4
| @@ -27,10 +27,6 @@ type ControlPlane interface { | |||
| 27 | 27 | SetAppSecrets(ctx context.Context, app string, req dto.AppSecretSetRequest) error | |
| 28 | 28 | DeleteAppSecret(ctx context.Context, app string, req dto.AppSecretDeleteRequest) error | |
| 29 | 29 | ||
| 30 | - | ListSecrets(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) | |
| 31 | - | SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error | |
| 32 | - | DeleteSecret(ctx context.Context, secretDomain, key string) error | |
| 33 | - | ||
| 34 | 30 | GetStatus(ctx context.Context) (*remote.Status, error) | |
| 35 | 31 | GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error) | |
| 36 | 32 | GetTrafficStatus(ctx context.Context) (*dto.TrafficStatusResponse, error) | |
M internal/adapters/in/cli/controlplane_remote.go +0 -12
| @@ -72,18 +72,6 @@ func (r *remoteControlPlane) DeleteAppSecret(ctx context.Context, app string, re | |||
| 72 | 72 | return r.client.DeleteAppSecret(ctx, app, req) | |
| 73 | 73 | } | |
| 74 | 74 | ||
| 75 | - | func (r *remoteControlPlane) ListSecrets(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) { | |
| 76 | - | return r.client.ListSecretsWithAttachments(ctx, secretDomain) | |
| 77 | - | } | |
| 78 | - | ||
| 79 | - | func (r *remoteControlPlane) SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error { | |
| 80 | - | return r.client.SetSecrets(ctx, secretDomain, secrets) | |
| 81 | - | } | |
| 82 | - | ||
| 83 | - | func (r *remoteControlPlane) DeleteSecret(ctx context.Context, secretDomain, key string) error { | |
| 84 | - | return r.client.DeleteSecret(ctx, secretDomain, key) | |
| 85 | - | } | |
| 86 | - | ||
| 87 | 75 | func (r *remoteControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) { | |
| 88 | 76 | return r.client.GetStatus(ctx) | |
| 89 | 77 | } | |
M internal/adapters/in/cli/controlplane_resolver.go +0 -4
| @@ -51,10 +51,6 @@ func newRemoteControlPlaneHandle(target *remote.ResolvedRemote) *controlPlaneHan | |||
| 51 | 51 | return &controlPlaneHandle{plane: NewRemoteControlPlane(client), isRemote: true} | |
| 52 | 52 | } | |
| 53 | 53 | ||
| 54 | - | func resolveControlPlaneForDomain(ctx context.Context, _ string) (*controlPlaneHandle, error) { | |
| 55 | - | return resolveControlPlaneWithInference(ctx, inferExplicitTarget) | |
| 56 | - | } | |
| 57 | - | ||
| 58 | 54 | func resolveControlPlaneForRepository(ctx context.Context, repository string) (*controlPlaneHandle, error) { | |
| 59 | 55 | return resolveControlPlaneWithInference(ctx, func(ctx context.Context) (*remote.ResolvedRemote, error) { | |
| 60 | 56 | return inferRemoteForRepository(ctx, repository) | |
A internal/adapters/in/cli/daemon.go +29 -0
| @@ -0,0 +1,29 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | import "github.com/spf13/cobra" | |
| 4 | + | ||
| 5 | + | // newDaemonCmd groups commands that inspect or operate the Gordon daemon | |
| 6 | + | // itself rather than the apps it runs. They target the local daemon or the | |
| 7 | + | // one selected with --remote. | |
| 8 | + | func newDaemonCmd() *cobra.Command { | |
| 9 | + | cmd := &cobra.Command{ | |
| 10 | + | Use: "daemon", | |
| 11 | + | Short: "Inspect and operate the Gordon daemon", | |
| 12 | + | Long: `Inspect and operate the Gordon daemon: status, process logs, | |
| 13 | + | configuration, TLS, traffic, and networks. | |
| 14 | + | ||
| 15 | + | Targets the local daemon, or the one selected with --remote.`, | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | cmd.AddCommand( | |
| 19 | + | newStatusCmd(), | |
| 20 | + | newLogsCmd(), | |
| 21 | + | newReloadCmd(), | |
| 22 | + | newConfigCmd(), | |
| 23 | + | newTLSCmd(), | |
| 24 | + | newTrafficCmd(), | |
| 25 | + | newNetworksCmd(), | |
| 26 | + | ) | |
| 27 | + | ||
| 28 | + | return cmd | |
| 29 | + | } | |
M internal/adapters/in/cli/images.go +4 -3
| @@ -69,10 +69,11 @@ var imagesListTableColumns = []components.TableColumn{ | |||
| 69 | 69 | func newImagesCmd() *cobra.Command { | |
| 70 | 70 | cmd := &cobra.Command{ | |
| 71 | 71 | 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.`, | |
| 74 | 74 | } | |
| 75 | 75 | ||
| 76 | + | cmd.AddCommand(newPushCmd()) | |
| 76 | 77 | cmd.AddCommand(newImagesListCmd()) | |
| 77 | 78 | cmd.AddCommand(newImagesPruneCmd()) | |
| 78 | 79 | cmd.AddCommand(newImagesTagsCmd()) | |
| @@ -144,7 +145,7 @@ func newImagesTagsCmd() *cobra.Command { | |||
| 144 | 145 | Examples: | |
| 145 | 146 | gordon images tags myapp | |
| 146 | 147 | gordon images tags myapp --json | |
| 147 | - | gordon images tags myapp --remote https://gordon.mydomain.com --token $TOKEN`, | |
| 148 | + | gordon images tags myapp --remote prod`, | |
| 148 | 149 | Args: cobra.ExactArgs(1), | |
| 149 | 150 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 150 | 151 | ctx := cmd.Context() | |
M internal/adapters/in/cli/json_parity_test.go +3 -4
| @@ -12,7 +12,6 @@ func TestAllListCommands_AcceptJSONFlag(t *testing.T) { | |||
| 12 | 12 | name string | |
| 13 | 13 | builder func() *cobra.Command | |
| 14 | 14 | }{ | |
| 15 | - | {"secrets list", newSecretsListCmd}, | |
| 16 | 15 | {"images list", newImagesListCmd}, | |
| 17 | 16 | {"backup list", newBackupListCmd}, | |
| 18 | 17 | {"backup volumes list", newVolumeBackupListCmd}, | |
| @@ -20,10 +19,10 @@ func TestAllListCommands_AcceptJSONFlag(t *testing.T) { | |||
| 20 | 19 | {"backup volumes status", newVolumeBackupStatusCmd}, | |
| 21 | 20 | {"auth token list", newTokenListCmd}, | |
| 22 | 21 | {"remotes list", newRemotesListCmd}, | |
| 23 | - | {"config show", newConfigShowCmd}, | |
| 24 | - | {"networks list", newNetworksListCmd}, | |
| 22 | + | {"daemon config show", newConfigShowCmd}, | |
| 23 | + | {"daemon networks", newNetworksCmd}, | |
| 25 | 24 | {"images tags", newImagesTagsCmd}, | |
| 26 | - | {"tls status", newTLSStatusCmd}, | |
| 25 | + | {"daemon tls", newTLSCmd}, | |
| 27 | 26 | } | |
| 28 | 27 | ||
| 29 | 28 | for _, tc := range commands { | |
M internal/adapters/in/cli/json_test.go +0 -25
| @@ -10,19 +10,9 @@ import ( | |||
| 10 | 10 | "github.com/stretchr/testify/require" | |
| 11 | 11 | ||
| 12 | 12 | "github.com/bnema/gordon/internal/adapters/dto" | |
| 13 | - | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |
| 14 | 13 | "github.com/bnema/gordon/internal/domain" | |
| 15 | 14 | ) | |
| 16 | 15 | ||
| 17 | - | func TestSecretsList_JSONFlag_Accepted(t *testing.T) { | |
| 18 | - | cmd := newSecretsListCmd() | |
| 19 | - | f := cmd.Flags().Lookup("json") | |
| 20 | - | assert.NotNil(t, f) | |
| 21 | - | if f != nil { | |
| 22 | - | assert.Equal(t, "false", f.DefValue) | |
| 23 | - | } | |
| 24 | - | } | |
| 25 | - | ||
| 26 | 16 | func TestImagesList_JSONFlag_Accepted(t *testing.T) { | |
| 27 | 17 | cmd := newImagesListCmd() | |
| 28 | 18 | f := cmd.Flags().Lookup("json") | |
| @@ -147,21 +137,6 @@ func TestRemotesList_JSONShape_RoundTripsRemoteObjects(t *testing.T) { | |||
| 147 | 137 | assert.True(t, got[0].InsecureTLS) | |
| 148 | 138 | } | |
| 149 | 139 | ||
| 150 | - | func TestSecretsList_JSONShape_RoundTripsPayload(t *testing.T) { | |
| 151 | - | payload := remote.SecretsListResult{ | |
| 152 | - | Domain: "app.example.com", | |
| 153 | - | Keys: []string{"API_KEY"}, | |
| 154 | - | } | |
| 155 | - | ||
| 156 | - | encoded, err := json.Marshal(payload) | |
| 157 | - | require.NoError(t, err) | |
| 158 | - | ||
| 159 | - | var got remote.SecretsListResult | |
| 160 | - | require.NoError(t, json.Unmarshal(encoded, &got)) | |
| 161 | - | assert.Equal(t, payload.Domain, got.Domain) | |
| 162 | - | assert.Equal(t, payload.Keys, got.Keys) | |
| 163 | - | } | |
| 164 | - | ||
| 165 | 140 | func TestWriteJSON_ProducesValidIndentedJSON(t *testing.T) { | |
| 166 | 141 | var out bytes.Buffer | |
| 167 | 142 | err := writeJSON(&out, map[string]any{"ok": true}) | |
M internal/adapters/in/cli/local.go +0 -61
| @@ -4,25 +4,18 @@ package cli | |||
| 4 | 4 | import ( | |
| 5 | 5 | "context" | |
| 6 | 6 | "fmt" | |
| 7 | - | "path/filepath" | |
| 8 | 7 | "strings" | |
| 9 | 8 | ||
| 10 | - | "github.com/bnema/zerowrap" | |
| 11 | 9 | "github.com/spf13/viper" | |
| 12 | 10 | ||
| 13 | - | "github.com/bnema/gordon/internal/adapters/out/domainsecrets" | |
| 14 | 11 | "github.com/bnema/gordon/internal/app" | |
| 15 | 12 | "github.com/bnema/gordon/internal/boundaries/in" | |
| 16 | - | "github.com/bnema/gordon/internal/boundaries/out" | |
| 17 | - | "github.com/bnema/gordon/internal/domain" | |
| 18 | 13 | "github.com/bnema/gordon/internal/usecase/config" | |
| 19 | - | secretsSvc "github.com/bnema/gordon/internal/usecase/secrets" | |
| 20 | 14 | ) | |
| 21 | 15 | ||
| 22 | 16 | // LocalServices provides direct access to local services for CLI operations. | |
| 23 | 17 | type LocalServices struct { | |
| 24 | 18 | configSvc in.ConfigService | |
| 25 | - | secretSvc in.SecretService | |
| 26 | 19 | dataDir string | |
| 27 | 20 | tlsEnabled bool | |
| 28 | 21 | } | |
| @@ -32,11 +25,6 @@ func (l *LocalServices) GetConfigService() in.ConfigService { | |||
| 32 | 25 | return l.configSvc | |
| 33 | 26 | } | |
| 34 | 27 | ||
| 35 | - | // GetSecretService returns the secret service. | |
| 36 | - | func (l *LocalServices) GetSecretService() in.SecretService { | |
| 37 | - | return l.secretSvc | |
| 38 | - | } | |
| 39 | - | ||
| 40 | 28 | // GetDataDir returns the data directory. | |
| 41 | 29 | func (l *LocalServices) GetDataDir() string { | |
| 42 | 30 | return l.dataDir | |
| @@ -67,8 +55,6 @@ func GetLocalServices(cliConfigPath string) (*LocalServices, error) { | |||
| 67 | 55 | // Config file not found is OK for some operations | |
| 68 | 56 | } | |
| 69 | 57 | ||
| 70 | - | log := zerowrap.New(cliLogConfig) | |
| 71 | - | ||
| 72 | 58 | // Create config service (without event bus for CLI operations) | |
| 73 | 59 | configSvc := config.NewService(v, nil) | |
| 74 | 60 | ctx := context.Background() | |
| @@ -76,27 +62,13 @@ func GetLocalServices(cliConfigPath string) (*LocalServices, error) { | |||
| 76 | 62 | return nil, fmt.Errorf("failed to load configuration: %w", err) | |
| 77 | 63 | } | |
| 78 | 64 | ||
| 79 | - | // Determine data directory and env directory | |
| 80 | 65 | dataDir := v.GetString("server.data_dir") | |
| 81 | 66 | if dataDir == "" { | |
| 82 | 67 | dataDir = app.DefaultDataDir() | |
| 83 | 68 | } | |
| 84 | 69 | ||
| 85 | - | envDir := v.GetString("env.dir") | |
| 86 | - | if envDir == "" { | |
| 87 | - | envDir = filepath.Join(dataDir, "env") | |
| 88 | - | } | |
| 89 | - | ||
| 90 | - | // Create domain secret store using configured backend (pass or file-based) | |
| 91 | - | domainSecretStore, err := createLocalDomainSecretStore(v, envDir, log) | |
| 92 | - | if err != nil { | |
| 93 | - | return nil, fmt.Errorf("failed to create domain secret store: %w", err) | |
| 94 | - | } | |
| 95 | - | secretSvc := secretsSvc.NewService(domainSecretStore, log, nil) | |
| 96 | - | ||
| 97 | 70 | return &LocalServices{ | |
| 98 | 71 | configSvc: configSvc, | |
| 99 | - | secretSvc: secretSvc, | |
| 100 | 72 | dataDir: dataDir, | |
| 101 | 73 | tlsEnabled: hasLocalTLSCapableEntrypoint(v), | |
| 102 | 74 | }, nil | |
| @@ -115,36 +87,3 @@ func hasLocalTLSCapableEntrypoint(v *viper.Viper) bool { | |||
| 115 | 87 | } | |
| 116 | 88 | return false | |
| 117 | 89 | } | |
| 118 | - | ||
| 119 | - | func createLocalDomainSecretStore(v *viper.Viper, envDir string, log zerowrap.Logger) (out.DomainSecretStore, error) { | |
| 120 | - | backend := resolveLocalSecretsBackend(v) | |
| 121 | - | switch backend { | |
| 122 | - | case domain.SecretsBackendPass: | |
| 123 | - | return domainsecrets.NewPassStore(log) | |
| 124 | - | case domain.SecretsBackendSops: | |
| 125 | - | return nil, fmt.Errorf("sops backend not yet supported for domain secrets") | |
| 126 | - | default: | |
| 127 | - | return domainsecrets.NewFileStore(envDir, log) | |
| 128 | - | } | |
| 129 | - | } | |
| 130 | - | ||
| 131 | - | func resolveLocalSecretsBackend(v *viper.Viper) domain.SecretsBackend { | |
| 132 | - | backend := strings.TrimSpace(v.GetString("auth.secrets_backend")) | |
| 133 | - | if backend == "" { | |
| 134 | - | // Legacy key support for older configs. | |
| 135 | - | backend = strings.TrimSpace(v.GetString("secrets.backend")) | |
| 136 | - | } | |
| 137 | - | ||
| 138 | - | switch backend { | |
| 139 | - | case "pass": | |
| 140 | - | return domain.SecretsBackendPass | |
| 141 | - | case "sops": | |
| 142 | - | return domain.SecretsBackendSops | |
| 143 | - | case "unsafe", "": | |
| 144 | - | return domain.SecretsBackendUnsafe | |
| 145 | - | default: | |
| 146 | - | log := zerowrap.Default() | |
| 147 | - | log.Warn().Str("backend", backend).Msg("unrecognized secrets backend, falling back to unsafe") | |
| 148 | - | return domain.SecretsBackendUnsafe | |
| 149 | - | } | |
| 150 | - | } | |
M internal/adapters/in/cli/local_test.go +0 -66
| @@ -3,41 +3,10 @@ package cli | |||
| 3 | 3 | import ( | |
| 4 | 4 | "testing" | |
| 5 | 5 | ||
| 6 | - | "github.com/bnema/zerowrap" | |
| 7 | 6 | "github.com/spf13/viper" | |
| 8 | 7 | "github.com/stretchr/testify/require" | |
| 9 | - | ||
| 10 | - | "github.com/bnema/gordon/internal/adapters/out/domainsecrets" | |
| 11 | - | "github.com/bnema/gordon/internal/domain" | |
| 12 | 8 | ) | |
| 13 | 9 | ||
| 14 | - | func TestResolveLocalSecretsBackend(t *testing.T) { | |
| 15 | - | t.Run("auth backend takes precedence", func(t *testing.T) { | |
| 16 | - | v := viper.New() | |
| 17 | - | v.Set("auth.secrets_backend", "pass") | |
| 18 | - | v.Set("secrets.backend", "unsafe") | |
| 19 | - | ||
| 20 | - | got := resolveLocalSecretsBackend(v) | |
| 21 | - | require.Equal(t, domain.SecretsBackendPass, got) | |
| 22 | - | }) | |
| 23 | - | ||
| 24 | - | t.Run("falls back to legacy secrets backend key", func(t *testing.T) { | |
| 25 | - | v := viper.New() | |
| 26 | - | v.Set("secrets.backend", "pass") | |
| 27 | - | ||
| 28 | - | got := resolveLocalSecretsBackend(v) | |
| 29 | - | require.Equal(t, domain.SecretsBackendPass, got) | |
| 30 | - | }) | |
| 31 | - | ||
| 32 | - | t.Run("defaults to unsafe for unknown backend", func(t *testing.T) { | |
| 33 | - | v := viper.New() | |
| 34 | - | v.Set("auth.secrets_backend", "wat") | |
| 35 | - | ||
| 36 | - | got := resolveLocalSecretsBackend(v) | |
| 37 | - | require.Equal(t, domain.SecretsBackendUnsafe, got) | |
| 38 | - | }) | |
| 39 | - | } | |
| 40 | - | ||
| 41 | 10 | func TestHasLocalTLSCapableEntrypoint(t *testing.T) { | |
| 42 | 11 | for _, tt := range []struct { | |
| 43 | 12 | name string | |
| @@ -56,38 +25,3 @@ func TestHasLocalTLSCapableEntrypoint(t *testing.T) { | |||
| 56 | 25 | }) | |
| 57 | 26 | } | |
| 58 | 27 | } | |
| 59 | - | ||
| 60 | - | func TestCreateLocalDomainSecretStore_UsesPassStoreForPass(t *testing.T) { | |
| 61 | - | // pass(1) must be available on the system for this test | |
| 62 | - | if _, err := domainsecrets.NewPassStore(zerowrap.New(zerowrap.Config{Level: "error", Format: "console"})); err != nil { | |
| 63 | - | t.Skipf("pass store not available: %v", err) | |
| 64 | - | } | |
| 65 | - | ||
| 66 | - | v := viper.New() | |
| 67 | - | v.Set("auth.secrets_backend", "pass") | |
| 68 | - | ||
| 69 | - | log := zerowrap.New(zerowrap.Config{Level: "error", Format: "console"}) | |
| 70 | - | store, err := createLocalDomainSecretStore(v, t.TempDir(), log) | |
| 71 | - | require.NoError(t, err) | |
| 72 | - | require.IsType(t, &domainsecrets.PassStore{}, store) | |
| 73 | - | } | |
| 74 | - | ||
| 75 | - | func TestCreateLocalDomainSecretStore_RejectsSopsBackend(t *testing.T) { | |
| 76 | - | v := viper.New() | |
| 77 | - | v.Set("auth.secrets_backend", "sops") | |
| 78 | - | ||
| 79 | - | log := zerowrap.New(zerowrap.Config{Level: "error", Format: "console"}) | |
| 80 | - | _, err := createLocalDomainSecretStore(v, t.TempDir(), log) | |
| 81 | - | require.Error(t, err) | |
| 82 | - | require.Contains(t, err.Error(), "sops backend not yet supported") | |
| 83 | - | } | |
| 84 | - | ||
| 85 | - | func TestCreateLocalDomainSecretStore_UsesFileStoreForUnsafe(t *testing.T) { | |
| 86 | - | v := viper.New() | |
| 87 | - | v.Set("auth.secrets_backend", "unsafe") | |
| 88 | - | ||
| 89 | - | log := zerowrap.New(zerowrap.Config{Level: "error", Format: "console"}) | |
| 90 | - | store, err := createLocalDomainSecretStore(v, t.TempDir(), log) | |
| 91 | - | require.NoError(t, err) | |
| 92 | - | require.IsType(t, &domainsecrets.FileStore{}, store) | |
| 93 | - | } | |
M internal/adapters/in/cli/mocks/mock_control_plane.go +0 -194
| @@ -242,69 +242,6 @@ func (_c *MockControlPlane_DeleteAppSecret_Call) RunAndReturn(run func(ctx conte | |||
| 242 | 242 | return _c | |
| 243 | 243 | } | |
| 244 | 244 | ||
| 245 | - | // DeleteSecret provides a mock function for the type MockControlPlane | |
| 246 | - | func (_mock *MockControlPlane) DeleteSecret(ctx context.Context, secretDomain string, key string) error { | |
| 247 | - | ret := _mock.Called(ctx, secretDomain, key) | |
| 248 | - | ||
| 249 | - | if len(ret) == 0 { | |
| 250 | - | panic("no return value specified for DeleteSecret") | |
| 251 | - | } | |
| 252 | - | ||
| 253 | - | var r0 error | |
| 254 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok { | |
| 255 | - | r0 = returnFunc(ctx, secretDomain, key) | |
| 256 | - | } else { | |
| 257 | - | r0 = ret.Error(0) | |
| 258 | - | } | |
| 259 | - | return r0 | |
| 260 | - | } | |
| 261 | - | ||
| 262 | - | // MockControlPlane_DeleteSecret_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteSecret' | |
| 263 | - | type MockControlPlane_DeleteSecret_Call struct { | |
| 264 | - | *mock.Call | |
| 265 | - | } | |
| 266 | - | ||
| 267 | - | // DeleteSecret is a helper method to define mock.On call | |
| 268 | - | // - ctx context.Context | |
| 269 | - | // - secretDomain string | |
| 270 | - | // - key string | |
| 271 | - | func (_e *MockControlPlane_Expecter) DeleteSecret(ctx any, secretDomain any, key any) *MockControlPlane_DeleteSecret_Call { | |
| 272 | - | return &MockControlPlane_DeleteSecret_Call{Call: _e.mock.On("DeleteSecret", ctx, secretDomain, key)} | |
| 273 | - | } | |
| 274 | - | ||
| 275 | - | func (_c *MockControlPlane_DeleteSecret_Call) Run(run func(ctx context.Context, secretDomain string, key string)) *MockControlPlane_DeleteSecret_Call { | |
| 276 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 277 | - | var arg0 context.Context | |
| 278 | - | if args[0] != nil { | |
| 279 | - | arg0 = args[0].(context.Context) | |
| 280 | - | } | |
| 281 | - | var arg1 string | |
| 282 | - | if args[1] != nil { | |
| 283 | - | arg1 = args[1].(string) | |
| 284 | - | } | |
| 285 | - | var arg2 string | |
| 286 | - | if args[2] != nil { | |
| 287 | - | arg2 = args[2].(string) | |
| 288 | - | } | |
| 289 | - | run( | |
| 290 | - | arg0, | |
| 291 | - | arg1, | |
| 292 | - | arg2, | |
| 293 | - | ) | |
| 294 | - | }) | |
| 295 | - | return _c | |
| 296 | - | } | |
| 297 | - | ||
| 298 | - | func (_c *MockControlPlane_DeleteSecret_Call) Return(err error) *MockControlPlane_DeleteSecret_Call { | |
| 299 | - | _c.Call.Return(err) | |
| 300 | - | return _c | |
| 301 | - | } | |
| 302 | - | ||
| 303 | - | func (_c *MockControlPlane_DeleteSecret_Call) RunAndReturn(run func(ctx context.Context, secretDomain string, key string) error) *MockControlPlane_DeleteSecret_Call { | |
| 304 | - | _c.Call.Return(run) | |
| 305 | - | return _c | |
| 306 | - | } | |
| 307 | - | ||
| 308 | 245 | // DeployApp provides a mock function for the type MockControlPlane | |
| 309 | 246 | func (_mock *MockControlPlane) DeployApp(ctx context.Context, app string, req dto.AppDeployRequest) (*dto.AppDeployResponse, string, error) { | |
| 310 | 247 | ret := _mock.Called(ctx, app, req) | |
| @@ -1035,74 +972,6 @@ func (_c *MockControlPlane_ListNetworks_Call) RunAndReturn(run func(ctx context. | |||
| 1035 | 972 | return _c | |
| 1036 | 973 | } | |
| 1037 | 974 | ||
| 1038 | - | // ListSecrets provides a mock function for the type MockControlPlane | |
| 1039 | - | func (_mock *MockControlPlane) ListSecrets(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) { | |
| 1040 | - | ret := _mock.Called(ctx, secretDomain) | |
| 1041 | - | ||
| 1042 | - | if len(ret) == 0 { | |
| 1043 | - | panic("no return value specified for ListSecrets") | |
| 1044 | - | } | |
| 1045 | - | ||
| 1046 | - | var r0 *remote.SecretsListResult | |
| 1047 | - | var r1 error | |
| 1048 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (*remote.SecretsListResult, error)); ok { | |
| 1049 | - | return returnFunc(ctx, secretDomain) | |
| 1050 | - | } | |
| 1051 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string) *remote.SecretsListResult); ok { | |
| 1052 | - | r0 = returnFunc(ctx, secretDomain) | |
| 1053 | - | } else { | |
| 1054 | - | if ret.Get(0) != nil { | |
| 1055 | - | r0 = ret.Get(0).(*remote.SecretsListResult) | |
| 1056 | - | } | |
| 1057 | - | } | |
| 1058 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | |
| 1059 | - | r1 = returnFunc(ctx, secretDomain) | |
| 1060 | - | } else { | |
| 1061 | - | r1 = ret.Error(1) | |
| 1062 | - | } | |
| 1063 | - | return r0, r1 | |
| 1064 | - | } | |
| 1065 | - | ||
| 1066 | - | // MockControlPlane_ListSecrets_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListSecrets' | |
| 1067 | - | type MockControlPlane_ListSecrets_Call struct { | |
| 1068 | - | *mock.Call | |
| 1069 | - | } | |
| 1070 | - | ||
| 1071 | - | // ListSecrets is a helper method to define mock.On call | |
| 1072 | - | // - ctx context.Context | |
| 1073 | - | // - secretDomain string | |
| 1074 | - | func (_e *MockControlPlane_Expecter) ListSecrets(ctx any, secretDomain any) *MockControlPlane_ListSecrets_Call { | |
| 1075 | - | return &MockControlPlane_ListSecrets_Call{Call: _e.mock.On("ListSecrets", ctx, secretDomain)} | |
| 1076 | - | } | |
| 1077 | - | ||
| 1078 | - | func (_c *MockControlPlane_ListSecrets_Call) Run(run func(ctx context.Context, secretDomain string)) *MockControlPlane_ListSecrets_Call { | |
| 1079 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 1080 | - | var arg0 context.Context | |
| 1081 | - | if args[0] != nil { | |
| 1082 | - | arg0 = args[0].(context.Context) | |
| 1083 | - | } | |
| 1084 | - | var arg1 string | |
| 1085 | - | if args[1] != nil { | |
| 1086 | - | arg1 = args[1].(string) | |
| 1087 | - | } | |
| 1088 | - | run( | |
| 1089 | - | arg0, | |
| 1090 | - | arg1, | |
| 1091 | - | ) | |
| 1092 | - | }) | |
| 1093 | - | return _c | |
| 1094 | - | } | |
| 1095 | - | ||
| 1096 | - | func (_c *MockControlPlane_ListSecrets_Call) Return(secretsListResult *remote.SecretsListResult, err error) *MockControlPlane_ListSecrets_Call { | |
| 1097 | - | _c.Call.Return(secretsListResult, err) | |
| 1098 | - | return _c | |
| 1099 | - | } | |
| 1100 | - | ||
| 1101 | - | func (_c *MockControlPlane_ListSecrets_Call) RunAndReturn(run func(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error)) *MockControlPlane_ListSecrets_Call { | |
| 1102 | - | _c.Call.Return(run) | |
| 1103 | - | return _c | |
| 1104 | - | } | |
| 1105 | - | ||
| 1106 | 975 | // ListTags provides a mock function for the type MockControlPlane | |
| 1107 | 976 | func (_mock *MockControlPlane) ListTags(ctx context.Context, repository string) ([]string, error) { | |
| 1108 | 977 | ret := _mock.Called(ctx, repository) | |
| @@ -1877,69 +1746,6 @@ func (_c *MockControlPlane_SetAppSecrets_Call) RunAndReturn(run func(ctx context | |||
| 1877 | 1746 | return _c | |
| 1878 | 1747 | } | |
| 1879 | 1748 | ||
| 1880 | - | // SetSecrets provides a mock function for the type MockControlPlane | |
| 1881 | - | func (_mock *MockControlPlane) SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error { | |
| 1882 | - | ret := _mock.Called(ctx, secretDomain, secrets) | |
| 1883 | - | ||
| 1884 | - | if len(ret) == 0 { | |
| 1885 | - | panic("no return value specified for SetSecrets") | |
| 1886 | - | } | |
| 1887 | - | ||
| 1888 | - | var r0 error | |
| 1889 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, map[string]string) error); ok { | |
| 1890 | - | r0 = returnFunc(ctx, secretDomain, secrets) | |
| 1891 | - | } else { | |
| 1892 | - | r0 = ret.Error(0) | |
| 1893 | - | } | |
| 1894 | - | return r0 | |
| 1895 | - | } | |
| 1896 | - | ||
| 1897 | - | // MockControlPlane_SetSecrets_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetSecrets' | |
| 1898 | - | type MockControlPlane_SetSecrets_Call struct { | |
| 1899 | - | *mock.Call | |
| 1900 | - | } | |
| 1901 | - | ||
| 1902 | - | // SetSecrets is a helper method to define mock.On call | |
| 1903 | - | // - ctx context.Context | |
| 1904 | - | // - secretDomain string | |
| 1905 | - | // - secrets map[string]string | |
| 1906 | - | func (_e *MockControlPlane_Expecter) SetSecrets(ctx any, secretDomain any, secrets any) *MockControlPlane_SetSecrets_Call { | |
| 1907 | - | return &MockControlPlane_SetSecrets_Call{Call: _e.mock.On("SetSecrets", ctx, secretDomain, secrets)} | |
| 1908 | - | } | |
| 1909 | - | ||
| 1910 | - | func (_c *MockControlPlane_SetSecrets_Call) Run(run func(ctx context.Context, secretDomain string, secrets map[string]string)) *MockControlPlane_SetSecrets_Call { | |
| 1911 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 1912 | - | var arg0 context.Context | |
| 1913 | - | if args[0] != nil { | |
| 1914 | - | arg0 = args[0].(context.Context) | |
| 1915 | - | } | |
| 1916 | - | var arg1 string | |
| 1917 | - | if args[1] != nil { | |
| 1918 | - | arg1 = args[1].(string) | |
| 1919 | - | } | |
| 1920 | - | var arg2 map[string]string | |
| 1921 | - | if args[2] != nil { | |
| 1922 | - | arg2 = args[2].(map[string]string) | |
| 1923 | - | } | |
| 1924 | - | run( | |
| 1925 | - | arg0, | |
| 1926 | - | arg1, | |
| 1927 | - | arg2, | |
| 1928 | - | ) | |
| 1929 | - | }) | |
| 1930 | - | return _c | |
| 1931 | - | } | |
| 1932 | - | ||
| 1933 | - | func (_c *MockControlPlane_SetSecrets_Call) Return(err error) *MockControlPlane_SetSecrets_Call { | |
| 1934 | - | _c.Call.Return(err) | |
| 1935 | - | return _c | |
| 1936 | - | } | |
| 1937 | - | ||
| 1938 | - | func (_c *MockControlPlane_SetSecrets_Call) RunAndReturn(run func(ctx context.Context, secretDomain string, secrets map[string]string) error) *MockControlPlane_SetSecrets_Call { | |
| 1939 | - | _c.Call.Return(run) | |
| 1940 | - | return _c | |
| 1941 | - | } | |
| 1942 | - | ||
| 1943 | 1749 | // ShowApp provides a mock function for the type MockControlPlane | |
| 1944 | 1750 | func (_mock *MockControlPlane) ShowApp(ctx context.Context, app string) (*dto.AppShowResponse, error) { | |
| 1945 | 1751 | ret := _mock.Called(ctx, app) | |
M internal/adapters/in/cli/networks.go +5 -16
| @@ -12,29 +12,18 @@ import ( | |||
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | 14 | func newNetworksCmd() *cobra.Command { | |
| 15 | - | cmd := &cobra.Command{ | |
| 16 | - | Use: "networks", | |
| 17 | - | Short: "Inspect Gordon-managed networks", | |
| 18 | - | } | |
| 19 | - | ||
| 20 | - | cmd.AddCommand(newNetworksListCmd()) | |
| 21 | - | ||
| 22 | - | return cmd | |
| 23 | - | } | |
| 24 | - | ||
| 25 | - | func newNetworksListCmd() *cobra.Command { | |
| 26 | 15 | var jsonOut bool | |
| 27 | 16 | ||
| 28 | 17 | cmd := &cobra.Command{ | |
| 29 | - | Use: "list", | |
| 30 | - | Short: "List Gordon-managed Docker networks", | |
| 18 | + | Use: "networks", | |
| 19 | + | Short: "List Gordon-managed networks", | |
| 31 | 20 | Long: `Display Docker networks managed by Gordon, including which containers | |
| 32 | 21 | are connected to each network. | |
| 33 | 22 | ||
| 34 | 23 | 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`, | |
| 38 | 27 | RunE: func(cmd *cobra.Command, _ []string) error { | |
| 39 | 28 | ctx := cmd.Context() | |
| 40 | 29 | handle, err := resolveControlPlane(cliConfigPath) | |
M internal/adapters/in/cli/push.go +4 -4
| @@ -66,10 +66,10 @@ deploys. Deploy separately with ` + "`gordon apps deploy`" + ` after applying | |||
| 66 | 66 | the manifest that references the pushed tag. | |
| 67 | 67 | ||
| 68 | 68 | 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 ...`, | |
| 73 | 73 | Args: cobra.MaximumNArgs(1), | |
| 74 | 74 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 75 | 75 | var imageArg string | |
M internal/adapters/in/cli/push_image_ops.go +1 -1
| @@ -181,7 +181,7 @@ func (d *dockerImageOps) Build(ctx context.Context, args []string) error { | |||
| 181 | 181 | "Podman and other runtimes are not supported for --build.\n" + | |
| 182 | 182 | "Build the image manually and push with:\n" + | |
| 183 | 183 | " podman build -t <image> .\n" + | |
| 184 | - | " gordon push <image>", | |
| 184 | + | " gordon images push <image>", | |
| 185 | 185 | ) | |
| 186 | 186 | } | |
| 187 | 187 | cmd := exec.CommandContext(ctx, "docker", args...) // #nosec G204 | |
M internal/adapters/in/cli/remote/client.go +0 -42
| @@ -481,48 +481,6 @@ func (c *Client) requestWithRetry(ctx context.Context, method, path string, body | |||
| 481 | 481 | return nil, fmt.Errorf("request failed after retries") | |
| 482 | 482 | } | |
| 483 | 483 | ||
| 484 | - | // Secrets API | |
| 485 | - | ||
| 486 | - | // SecretsListResult contains domain secret keys. | |
| 487 | - | type SecretsListResult struct { | |
| 488 | - | Domain string `json:"domain"` | |
| 489 | - | Keys []string `json:"keys"` | |
| 490 | - | } | |
| 491 | - | ||
| 492 | - | // ListSecretsWithAttachments returns domain secret keys. Attachment | |
| 493 | - | // containers are retired: the daemon answers without attachment keys. | |
| 494 | - | func (c *Client) ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*SecretsListResult, error) { | |
| 495 | - | resp, err := c.request(ctx, http.MethodGet, "/secrets/"+url.PathEscape(secretDomain), nil) | |
| 496 | - | if err != nil { | |
| 497 | - | return nil, err | |
| 498 | - | } | |
| 499 | - | ||
| 500 | - | var result SecretsListResult | |
| 501 | - | if err := parseResponse(resp, &result); err != nil { | |
| 502 | - | return nil, err | |
| 503 | - | } | |
| 504 | - | ||
| 505 | - | return &result, nil | |
| 506 | - | } | |
| 507 | - | ||
| 508 | - | // SetSecrets sets secrets for a domain. | |
| 509 | - | func (c *Client) SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error { | |
| 510 | - | resp, err := c.request(ctx, http.MethodPost, "/secrets/"+url.PathEscape(secretDomain), secrets) | |
| 511 | - | if err != nil { | |
| 512 | - | return err | |
| 513 | - | } | |
| 514 | - | return parseResponse(resp, nil) | |
| 515 | - | } | |
| 516 | - | ||
| 517 | - | // DeleteSecret removes a secret from a domain. | |
| 518 | - | func (c *Client) DeleteSecret(ctx context.Context, secretDomain, key string) error { | |
| 519 | - | resp, err := c.request(ctx, http.MethodDelete, "/secrets/"+url.PathEscape(secretDomain)+"/"+url.PathEscape(key), nil) | |
| 520 | - | if err != nil { | |
| 521 | - | return err | |
| 522 | - | } | |
| 523 | - | return parseResponse(resp, nil) | |
| 524 | - | } | |
| 525 | - | ||
| 526 | 484 | // Status API | |
| 527 | 485 | ||
| 528 | 486 | // Status represents the Gordon server status. | |
M internal/adapters/in/cli/remote_inference.go +0 -42
| @@ -129,39 +129,6 @@ func isRemoteNotFoundError(err error) bool { | |||
| 129 | 129 | return errors.Is(err, domain.ErrRouteNotFound) | |
| 130 | 130 | } | |
| 131 | 131 | ||
| 132 | - | func resolveExplicitRemote() (*remote.ResolvedRemote, bool, error) { | |
| 133 | - | target, ok := resolveExplicitTargetName() | |
| 134 | - | if !ok { | |
| 135 | - | return nil, false, nil | |
| 136 | - | } | |
| 137 | - | ||
| 138 | - | if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") { | |
| 139 | - | return &remote.ResolvedRemote{ | |
| 140 | - | URL: target, | |
| 141 | - | Token: resolveTokenForTarget("", remote.RemoteEntry{}), | |
| 142 | - | InsecureTLS: resolveInsecureForTarget("", remote.RemoteEntry{}), | |
| 143 | - | }, true, nil | |
| 144 | - | } | |
| 145 | - | ||
| 146 | - | remotes, err := remote.LoadRemotes("") | |
| 147 | - | if err != nil { | |
| 148 | - | return nil, false, err | |
| 149 | - | } | |
| 150 | - | ||
| 151 | - | if remotes != nil { | |
| 152 | - | if entry, found := remotes.Remotes[target]; found { | |
| 153 | - | return &remote.ResolvedRemote{ | |
| 154 | - | Name: target, | |
| 155 | - | URL: entry.URL, | |
| 156 | - | Token: resolveTokenForTarget(target, entry), | |
| 157 | - | InsecureTLS: resolveInsecureForTarget(target, entry), | |
| 158 | - | }, true, nil | |
| 159 | - | } | |
| 160 | - | } | |
| 161 | - | ||
| 162 | - | return nil, false, nil | |
| 163 | - | } | |
| 164 | - | ||
| 165 | 132 | func resolveExplicitTargetName() (string, bool) { | |
| 166 | 133 | if target := strings.TrimSpace(remoteFlag); target != "" { | |
| 167 | 134 | return target, true | |
| @@ -203,12 +170,3 @@ func resolveInsecureForTarget(name string, entry remote.RemoteEntry) bool { | |||
| 203 | 170 | func newInferenceTargetClient(name string, entry remote.RemoteEntry) *remote.Client { | |
| 204 | 171 | return remote.NewClient(entry.URL, remoteClientOptions(resolveTokenForTarget(name, entry), resolveInsecureForTarget(name, entry))...) | |
| 205 | 172 | } | |
| 206 | - | ||
| 207 | - | // inferExplicitTarget resolves --remote/GORDON_REMOTE without probing saved remotes. | |
| 208 | - | func inferExplicitTarget(_ context.Context) (*remote.ResolvedRemote, error) { | |
| 209 | - | resolved, ok, err := resolveExplicitRemote() | |
| 210 | - | if err != nil || !ok { | |
| 211 | - | return nil, err | |
| 212 | - | } | |
| 213 | - | return resolved, nil | |
| 214 | - | } | |
M internal/adapters/in/cli/remotes.go +1 -1
| @@ -296,7 +296,7 @@ without needing to specify --remote. | |||
| 296 | 296 | ||
| 297 | 297 | Examples: | |
| 298 | 298 | gordon remotes use prod | |
| 299 | - | gordon status # Uses prod remote automatically`, | |
| 299 | + | gordon daemon status # Uses prod remote automatically`, | |
| 300 | 300 | Args: cobra.ExactArgs(1), | |
| 301 | 301 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 302 | 302 | name := args[0] | |
M internal/adapters/in/cli/root.go +16 -57
| @@ -55,11 +55,11 @@ func NewRootCmd() *cobra.Command { | |||
| 55 | 55 | container registry, an app runtime, and a reverse proxy. | |
| 56 | 56 | ||
| 57 | 57 | Applications are declared in standalone TOML files and managed with | |
| 58 | - | gordon apps; push transfers OCI content only and never deploys. | |
| 58 | + | gordon apps; gordon images push transfers OCI content only and never deploys. | |
| 59 | 59 | ||
| 60 | 60 | Commands are organized by where they run: | |
| 61 | 61 | Server-only: Run on the machine hosting Gordon (serve, auth, ca) | |
| 62 | - | Management: Work locally or remotely via --remote flag (apps, secrets, etc.) | |
| 62 | + | Management: Work locally or remotely via --remote flag (apps, daemon, images, etc.) | |
| 63 | 63 | Client-only: CLI utilities that don't require a running Gordon server`, | |
| 64 | 64 | } | |
| 65 | 65 | ||
| @@ -88,57 +88,16 @@ Commands are organized by where they run: | |||
| 88 | 88 | caCmd.GroupID = groupServer | |
| 89 | 89 | rootCmd.AddCommand(caCmd) | |
| 90 | 90 | ||
| 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 | + | } | |
| 142 | 101 | ||
| 143 | 102 | // Client-only commands (no server needed) | |
| 144 | 103 | remotesCmd := newRemotesCmd() | |
| @@ -257,12 +216,12 @@ Application workload output is read with gordon apps logs APP --service SVC, | |||
| 257 | 216 | which resolves the app's active container through the daemon. | |
| 258 | 217 | ||
| 259 | 218 | 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 | |
| 263 | 222 | ||
| 264 | 223 | Remote mode: | |
| 265 | - | gordon logs --remote https://gordon.mydomain.com --token $TOKEN`, | |
| 224 | + | gordon daemon logs --remote prod`, | |
| 266 | 225 | Args: cobra.NoArgs, | |
| 267 | 226 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 268 | 227 | return runLogs(cmd.Context(), logsConfigPath, follow, lines, cmd.OutOrStdout()) | |
D internal/adapters/in/cli/secrets.go +0 -252
| @@ -1,252 +0,0 @@ | |||
| 1 | - | package cli | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "context" | |
| 5 | - | "fmt" | |
| 6 | - | "strings" | |
| 7 | - | ||
| 8 | - | "github.com/bnema/gordon/internal/adapters/in/cli/ui/components" | |
| 9 | - | "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" | |
| 10 | - | ||
| 11 | - | "github.com/spf13/cobra" | |
| 12 | - | ) | |
| 13 | - | ||
| 14 | - | // newSecretsCmd creates the secrets command group. | |
| 15 | - | func newSecretsCmd() *cobra.Command { | |
| 16 | - | cmd := &cobra.Command{ | |
| 17 | - | Use: "secrets", | |
| 18 | - | Short: "Manage installation secrets", | |
| 19 | - | Long: `Manage installation secrets (per-host entries such as service auth). | |
| 20 | - | ||
| 21 | - | Application secret VALUES are managed with gordon apps secrets and stay | |
| 22 | - | in pass under gordon/apps/<uuid>/<service>/<name>. The commands below | |
| 23 | - | manage the installation secret store only. | |
| 24 | - | ||
| 25 | - | When targeting a remote Gordon instance (via --remote flag or GORDON_REMOTE env var), | |
| 26 | - | these commands operate on the remote server.`, | |
| 27 | - | } | |
| 28 | - | ||
| 29 | - | cmd.AddCommand(newSecretsListCmd()) | |
| 30 | - | cmd.AddCommand(newSecretsSetCmd()) | |
| 31 | - | cmd.AddCommand(newSecretsRemoveCmd()) | |
| 32 | - | ||
| 33 | - | return cmd | |
| 34 | - | } | |
| 35 | - | ||
| 36 | - | // newSecretsListCmd creates the secrets list command. | |
| 37 | - | func newSecretsListCmd() *cobra.Command { | |
| 38 | - | var jsonOut bool | |
| 39 | - | ||
| 40 | - | cmd := &cobra.Command{ | |
| 41 | - | Use: "list <domain>", | |
| 42 | - | Short: "List secrets for a domain", | |
| 43 | - | Long: `List all secret keys configured for a domain. | |
| 44 | - | ||
| 45 | - | Note: Only secret keys are shown, not values (for security). | |
| 46 | - | ||
| 47 | - | Examples: | |
| 48 | - | gordon secrets list app.mydomain.com | |
| 49 | - | gordon --remote https://gordon.mydomain.com secrets list api.mydomain.com`, | |
| 50 | - | Args: cobra.ExactArgs(1), | |
| 51 | - | RunE: func(cmd *cobra.Command, args []string) error { | |
| 52 | - | return runSecretsListCmd(cmd, args, jsonOut) | |
| 53 | - | }, | |
| 54 | - | } | |
| 55 | - | ||
| 56 | - | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |
| 57 | - | ||
| 58 | - | return cmd | |
| 59 | - | } | |
| 60 | - | ||
| 61 | - | // runSecretsListCmd executes the secrets list command. | |
| 62 | - | func runSecretsListCmd(cmd *cobra.Command, args []string, jsonOut bool) error { | |
| 63 | - | ctx := cmd.Context() | |
| 64 | - | secretDomain := args[0] | |
| 65 | - | ||
| 66 | - | handle, err := resolveControlPlaneForDomain(ctx, secretDomain) | |
| 67 | - | if err != nil { | |
| 68 | - | return err | |
| 69 | - | } | |
| 70 | - | defer handle.close() | |
| 71 | - | ||
| 72 | - | keys, err := fetchSecrets(ctx, handle.plane, secretDomain) | |
| 73 | - | if err != nil { | |
| 74 | - | return err | |
| 75 | - | } | |
| 76 | - | ||
| 77 | - | if len(keys) == 0 { | |
| 78 | - | if jsonOut { | |
| 79 | - | return writeJSON(cmd.OutOrStdout(), map[string]any{ | |
| 80 | - | "domain": secretDomain, | |
| 81 | - | "keys": []string{}, | |
| 82 | - | }) | |
| 83 | - | } | |
| 84 | - | fmt.Println(styles.Theme.Muted.Render(fmt.Sprintf("No secrets configured for %s", secretDomain))) | |
| 85 | - | return nil | |
| 86 | - | } | |
| 87 | - | ||
| 88 | - | if jsonOut { | |
| 89 | - | return writeJSON(cmd.OutOrStdout(), map[string]any{ | |
| 90 | - | "domain": secretDomain, | |
| 91 | - | "keys": keys, | |
| 92 | - | }) | |
| 93 | - | } | |
| 94 | - | ||
| 95 | - | title := fmt.Sprintf("Secrets for %s", secretDomain) | |
| 96 | - | if !handle.isRemote { | |
| 97 | - | title = fmt.Sprintf("Secrets for %s (local)", secretDomain) | |
| 98 | - | } | |
| 99 | - | fmt.Println(styles.Theme.Title.Render(title)) | |
| 100 | - | fmt.Println() | |
| 101 | - | ||
| 102 | - | rows := buildSecretsTableRows(keys) | |
| 103 | - | ||
| 104 | - | table := components.NewTable( | |
| 105 | - | components.WithColumns([]components.TableColumn{ | |
| 106 | - | {Title: "Key", Width: 45}, | |
| 107 | - | {Title: "Value", Width: 10}, | |
| 108 | - | }), | |
| 109 | - | components.WithRows(rows), | |
| 110 | - | ) | |
| 111 | - | ||
| 112 | - | fmt.Println(table.View()) | |
| 113 | - | return nil | |
| 114 | - | } | |
| 115 | - | ||
| 116 | - | // fetchSecrets retrieves secret keys from the selected control plane. | |
| 117 | - | func fetchSecrets(ctx context.Context, cp ControlPlane, secretDomain string) ([]string, error) { | |
| 118 | - | result, err := cp.ListSecrets(ctx, secretDomain) | |
| 119 | - | if err != nil { | |
| 120 | - | return nil, fmt.Errorf("failed to list secrets: %w", err) | |
| 121 | - | } | |
| 122 | - | return result.Keys, nil | |
| 123 | - | } | |
| 124 | - | ||
| 125 | - | // buildSecretsTableRows builds table rows for secret keys. | |
| 126 | - | func buildSecretsTableRows(keys []string) [][]string { | |
| 127 | - | var rows [][]string | |
| 128 | - | for _, key := range keys { | |
| 129 | - | rows = append(rows, []string{key, styles.Theme.Muted.Render("(hidden)")}) | |
| 130 | - | } | |
| 131 | - | return rows | |
| 132 | - | } | |
| 133 | - | ||
| 134 | - | // newSecretsSetCmd creates the secrets set command. | |
| 135 | - | func newSecretsSetCmd() *cobra.Command { | |
| 136 | - | var fromFile string | |
| 137 | - | var jsonOut bool | |
| 138 | - | ||
| 139 | - | cmd := &cobra.Command{ | |
| 140 | - | Use: "set <domain> --from-file <path>", | |
| 141 | - | Short: "Set installation secrets for a domain", | |
| 142 | - | Long: `Set one or more installation secrets for a domain. | |
| 143 | - | ||
| 144 | - | Secrets are read from a mode 0600 file containing one KEY=value pair per line. | |
| 145 | - | For app secret values, use gordon apps secrets instead. | |
| 146 | - | ||
| 147 | - | Examples: | |
| 148 | - | gordon secrets set app.mydomain.com --from-file ./app.env`, | |
| 149 | - | Args: cobra.ExactArgs(1), | |
| 150 | - | RunE: func(cmd *cobra.Command, args []string) error { | |
| 151 | - | ctx := cmd.Context() | |
| 152 | - | secretDomain := args[0] | |
| 153 | - | content, err := readProtectedSecretFile(fromFile) | |
| 154 | - | if err != nil { | |
| 155 | - | return fmt.Errorf("read secrets: %w", err) | |
| 156 | - | } | |
| 157 | - | ||
| 158 | - | secrets := make(map[string]string) | |
| 159 | - | for _, pair := range strings.Split(content, "\n") { | |
| 160 | - | parts := strings.SplitN(pair, "=", 2) | |
| 161 | - | if len(parts) != 2 { | |
| 162 | - | return fmt.Errorf("invalid format: %s (expected KEY=value)", pair) | |
| 163 | - | } | |
| 164 | - | secrets[parts[0]] = parts[1] | |
| 165 | - | } | |
| 166 | - | ||
| 167 | - | handle, err := resolveControlPlaneForDomain(ctx, secretDomain) | |
| 168 | - | if err != nil { | |
| 169 | - | return err | |
| 170 | - | } | |
| 171 | - | defer handle.close() | |
| 172 | - | if err := handle.plane.SetSecrets(ctx, secretDomain, secrets); err != nil { | |
| 173 | - | return fmt.Errorf("failed to set secrets: %w", err) | |
| 174 | - | } | |
| 175 | - | ||
| 176 | - | target := secretDomain | |
| 177 | - | ||
| 178 | - | if jsonOut { | |
| 179 | - | return writeJSON(cmd.OutOrStdout(), map[string]any{ | |
| 180 | - | "domain": secretDomain, "count": len(secrets), "updated": true, | |
| 181 | - | }) | |
| 182 | - | } | |
| 183 | - | if len(secrets) == 1 { | |
| 184 | - | for key := range secrets { | |
| 185 | - | return cliWriteLine(cmd.OutOrStdout(), styles.RenderSuccess(fmt.Sprintf("Secret set: %s on %s", key, target))) | |
| 186 | - | } | |
| 187 | - | } | |
| 188 | - | return cliWriteLine(cmd.OutOrStdout(), styles.RenderSuccess(fmt.Sprintf("Set %d secrets for %s", len(secrets), target))) | |
| 189 | - | }, | |
| 190 | - | } | |
| 191 | - | ||
| 192 | - | cmd.Flags().StringVar(&fromFile, "from-file", "", "Read KEY=value lines from a mode 0600 file") | |
| 193 | - | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |
| 194 | - | _ = cmd.MarkFlagRequired("from-file") | |
| 195 | - | ||
| 196 | - | return cmd | |
| 197 | - | } | |
| 198 | - | ||
| 199 | - | // newSecretsRemoveCmd creates the secrets remove command. | |
| 200 | - | func newSecretsRemoveCmd() *cobra.Command { | |
| 201 | - | var force bool | |
| 202 | - | ||
| 203 | - | cmd := &cobra.Command{ | |
| 204 | - | Use: "remove <domain> <key>", | |
| 205 | - | Short: "Remove an installation secret", | |
| 206 | - | Long: `Remove an installation secret from a domain. | |
| 207 | - | ||
| 208 | - | For app secret values, use gordon apps secrets delete instead. | |
| 209 | - | ||
| 210 | - | Examples: | |
| 211 | - | gordon secrets remove app.mydomain.com OLD_API_KEY | |
| 212 | - | gordon secrets remove app.mydomain.com OLD_API_KEY --force`, | |
| 213 | - | Args: cobra.ExactArgs(2), | |
| 214 | - | RunE: func(cmd *cobra.Command, args []string) error { | |
| 215 | - | ctx := cmd.Context() | |
| 216 | - | secretDomain := args[0] | |
| 217 | - | key := args[1] | |
| 218 | - | ||
| 219 | - | target := secretDomain | |
| 220 | - | ||
| 221 | - | // Confirm unless --force | |
| 222 | - | if !force { | |
| 223 | - | confirmed, err := components.RunConfirm( | |
| 224 | - | fmt.Sprintf("Remove secret '%s' from %s?", key, target), | |
| 225 | - | ) | |
| 226 | - | if err != nil { | |
| 227 | - | return err | |
| 228 | - | } | |
| 229 | - | if !confirmed { | |
| 230 | - | fmt.Println(styles.Theme.Muted.Render("Cancelled")) | |
| 231 | - | return nil | |
| 232 | - | } | |
| 233 | - | } | |
| 234 | - | ||
| 235 | - | handle, err := resolveControlPlaneForDomain(ctx, secretDomain) | |
| 236 | - | if err != nil { | |
| 237 | - | return err | |
| 238 | - | } | |
| 239 | - | defer handle.close() | |
| 240 | - | if err := handle.plane.DeleteSecret(ctx, secretDomain, key); err != nil { | |
| 241 | - | return fmt.Errorf("failed to remove secret: %w", err) | |
| 242 | - | } | |
| 243 | - | ||
| 244 | - | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret removed from %s: %s", target, key))) | |
| 245 | - | return nil | |
| 246 | - | }, | |
| 247 | - | } | |
| 248 | - | ||
| 249 | - | cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation") | |
| 250 | - | ||
| 251 | - | return cmd | |
| 252 | - | } | |
M internal/adapters/in/cli/tls.go +1 -12
| @@ -16,21 +16,10 @@ import ( | |||
| 16 | 16 | var tlsResolveControlPlane = resolveControlPlane | |
| 17 | 17 | ||
| 18 | 18 | func newTLSCmd() *cobra.Command { | |
| 19 | - | cmd := &cobra.Command{ | |
| 20 | - | Use: "tls", | |
| 21 | - | Short: "Inspect TLS certificate status", | |
| 22 | - | } | |
| 23 | - | ||
| 24 | - | cmd.AddCommand(newTLSStatusCmd()) | |
| 25 | - | ||
| 26 | - | return cmd | |
| 27 | - | } | |
| 28 | - | ||
| 29 | - | func newTLSStatusCmd() *cobra.Command { | |
| 30 | 19 | var jsonOut bool | |
| 31 | 20 | ||
| 32 | 21 | cmd := &cobra.Command{ | |
| 33 | - | Use: "status", | |
| 22 | + | Use: "tls", | |
| 34 | 23 | Short: "Show public TLS certificate status", | |
| 35 | 24 | RunE: func(cmd *cobra.Command, _ []string) error { | |
| 36 | 25 | handle, err := tlsResolveControlPlane(cliConfigPath) | |
D internal/adapters/in/cli/traffic.go +0 -12
| @@ -1,12 +0,0 @@ | |||
| 1 | - | package cli | |
| 2 | - | ||
| 3 | - | import "github.com/spf13/cobra" | |
| 4 | - | ||
| 5 | - | func newTrafficCmd() *cobra.Command { | |
| 6 | - | cmd := &cobra.Command{ | |
| 7 | - | Use: "traffic", | |
| 8 | - | Short: "Inspect remote traffic plane status", | |
| 9 | - | } | |
| 10 | - | cmd.AddCommand(newTrafficStatusCmd()) | |
| 11 | - | return cmd | |
| 12 | - | } | |
M internal/adapters/in/cli/traffic_status.go +2 -2
| @@ -13,10 +13,10 @@ import ( | |||
| 13 | 13 | ||
| 14 | 14 | var trafficResolveControlPlane = resolveControlPlane | |
| 15 | 15 | ||
| 16 | - | func newTrafficStatusCmd() *cobra.Command { | |
| 16 | + | func newTrafficCmd() *cobra.Command { | |
| 17 | 17 | var jsonOut bool | |
| 18 | 18 | cmd := &cobra.Command{ | |
| 19 | - | Use: "status", | |
| 19 | + | Use: "traffic", | |
| 20 | 20 | Short: "Show traffic entrypoint, router, and counter status", | |
| 21 | 21 | Args: cobra.NoArgs, | |
| 22 | 22 | RunE: func(cmd *cobra.Command, args []string) error { | |
M internal/adapters/in/cli/traffic_test.go +2 -7
| @@ -19,19 +19,14 @@ import ( | |||
| 19 | 19 | ||
| 20 | 20 | func TestTrafficCommandExists(t *testing.T) { | |
| 21 | 21 | cmd := NewRootCmd() | |
| 22 | - | traffic, _, err := cmd.Find([]string{"traffic"}) | |
| 22 | + | traffic, _, err := cmd.Find([]string{"daemon", "traffic"}) | |
| 23 | 23 | require.NoError(t, err) | |
| 24 | 24 | require.NotNil(t, traffic) | |
| 25 | 25 | assert.Equal(t, "traffic", traffic.Name()) | |
| 26 | - | ||
| 27 | - | status, _, err := cmd.Find([]string{"traffic", "status"}) | |
| 28 | - | require.NoError(t, err) | |
| 29 | - | require.NotNil(t, status) | |
| 30 | - | assert.Equal(t, "status", status.Name()) | |
| 31 | 26 | } | |
| 32 | 27 | ||
| 33 | 28 | func TestTrafficStatusRejectsArgs(t *testing.T) { | |
| 34 | - | cmd := newTrafficStatusCmd() | |
| 29 | + | cmd := newTrafficCmd() | |
| 35 | 30 | require.Error(t, cmd.Args(cmd, []string{"extra"})) | |
| 36 | 31 | } | |
| 37 | 32 | ||
M internal/adapters/in/http/admin/handler.go +0 -110
| @@ -41,7 +41,6 @@ type Handler struct { | |||
| 41 | 41 | volumeBackupSvc in.VolumeBackupService | |
| 42 | 42 | imageSvc in.ImageService | |
| 43 | 43 | healthSvc in.HealthService | |
| 44 | - | secretSvc in.SecretService | |
| 45 | 44 | logSvc in.LogService | |
| 46 | 45 | volumeSvc in.VolumeService | |
| 47 | 46 | registrySvc in.RegistryService | |
| @@ -115,7 +114,6 @@ type HandlerDeps struct { | |||
| 115 | 114 | AuthSvc in.AuthService | |
| 116 | 115 | ContainerSvc in.ContainerService | |
| 117 | 116 | HealthSvc in.HealthService | |
| 118 | - | SecretSvc in.SecretService | |
| 119 | 117 | LogSvc in.LogService | |
| 120 | 118 | RegistrySvc in.RegistryService | |
| 121 | 119 | Log zerowrap.Logger | |
| @@ -139,7 +137,6 @@ func NewHandler(deps HandlerDeps) *Handler { | |||
| 139 | 137 | volumeBackupSvc: deps.VolumeBackupSvc, | |
| 140 | 138 | imageSvc: deps.ImageSvc, | |
| 141 | 139 | healthSvc: deps.HealthSvc, | |
| 142 | - | secretSvc: deps.SecretSvc, | |
| 143 | 140 | logSvc: deps.LogSvc, | |
| 144 | 141 | volumeSvc: deps.VolumeSvc, | |
| 145 | 142 | registrySvc: deps.RegistrySvc, | |
| @@ -214,7 +211,6 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) { | |||
| 214 | 211 | }{ | |
| 215 | 212 | {"/apps", h.handleApps}, | |
| 216 | 213 | {"/backups", h.handleBackups}, | |
| 217 | - | {"/secrets", h.handleSecrets}, | |
| 218 | 214 | {"/tags", h.handleTags}, | |
| 219 | 215 | {"/images", h.handleImages}, | |
| 220 | 216 | {"/logs", h.handleLogs}, | |
| @@ -276,112 +272,6 @@ func (h *Handler) handleNetworks(w http.ResponseWriter, r *http.Request) { | |||
| 276 | 272 | h.sendJSON(w, http.StatusOK, dto.NetworksResponse{Networks: response}) | |
| 277 | 273 | } | |
| 278 | 274 | ||
| 279 | - | // handleSecrets handles /admin/secrets endpoints. | |
| 280 | - | func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path string) { | |
| 281 | - | // Parse path: /secrets/{domain} or /secrets/{domain}/{key} | |
| 282 | - | parts := strings.Split(strings.TrimPrefix(path, "/secrets/"), "/") | |
| 283 | - | if len(parts) == 0 || parts[0] == "" { | |
| 284 | - | h.sendError(w, http.StatusBadRequest, "domain required") | |
| 285 | - | return | |
| 286 | - | } | |
| 287 | - | ||
| 288 | - | secretDomain := parts[0] | |
| 289 | - | ||
| 290 | - | secretKey := "" | |
| 291 | - | if len(parts) > 1 { | |
| 292 | - | secretKey = parts[1] | |
| 293 | - | } | |
| 294 | - | ||
| 295 | - | switch r.Method { | |
| 296 | - | case http.MethodGet: | |
| 297 | - | h.handleSecretsGet(w, r, secretDomain) | |
| 298 | - | case http.MethodPost: | |
| 299 | - | h.handleSecretsPost(w, r, secretDomain) | |
| 300 | - | case http.MethodDelete: | |
| 301 | - | h.handleSecretsDelete(w, r, secretDomain, secretKey) | |
| 302 | - | default: | |
| 303 | - | h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") | |
| 304 | - | } | |
| 305 | - | } | |
| 306 | - | ||
| 307 | - | // handleSecretsPost handles POST /admin/secrets/{domain} - set secrets. | |
| 308 | - | func (h *Handler) handleSecretsPost(w http.ResponseWriter, r *http.Request, secretDomain string) { | |
| 309 | - | ctx := r.Context() | |
| 310 | - | log := zerowrap.FromCtx(ctx) | |
| 311 | - | ||
| 312 | - | if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) { | |
| 313 | - | h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write") | |
| 314 | - | return | |
| 315 | - | } | |
| 316 | - | ||
| 317 | - | r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize) | |
| 318 | - | ||
| 319 | - | var data map[string]string | |
| 320 | - | if err := json.NewDecoder(r.Body).Decode(&data); err != nil { | |
| 321 | - | log.Warn().Err(err).Msg("invalid secrets JSON") | |
| 322 | - | h.sendError(w, http.StatusBadRequest, "invalid JSON") | |
| 323 | - | return | |
| 324 | - | } | |
| 325 | - | ||
| 326 | - | if err := h.secretSvc.Set(ctx, secretDomain, data); err != nil { | |
| 327 | - | log.Error().Err(err).Str("domain", secretDomain).Msg("failed to set secrets") | |
| 328 | - | h.sendError(w, http.StatusBadRequest, "invalid domain") | |
| 329 | - | return | |
| 330 | - | } | |
| 331 | - | ||
| 332 | - | log.Info().Str("domain", secretDomain).Int("count", len(data)).Msg("secrets set") | |
| 333 | - | h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "updated"}) | |
| 334 | - | } | |
| 335 | - | ||
| 336 | - | // handleSecretsDelete handles DELETE /admin/secrets/{domain}/{key} - delete a secret. | |
| 337 | - | func (h *Handler) handleSecretsDelete(w http.ResponseWriter, r *http.Request, secretDomain, secretKey string) { | |
| 338 | - | ctx := r.Context() | |
| 339 | - | log := zerowrap.FromCtx(ctx) | |
| 340 | - | ||
| 341 | - | if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) { | |
| 342 | - | h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write") | |
| 343 | - | return | |
| 344 | - | } | |
| 345 | - | if secretKey == "" { | |
| 346 | - | h.sendError(w, http.StatusBadRequest, "key required in path") | |
| 347 | - | return | |
| 348 | - | } | |
| 349 | - | ||
| 350 | - | if err := h.secretSvc.Delete(ctx, secretDomain, secretKey); err != nil { | |
| 351 | - | log.Error().Err(err).Str("domain", secretDomain).Str("key", secretKey).Msg("failed to delete secret") | |
| 352 | - | h.sendError(w, http.StatusBadRequest, "invalid domain") | |
| 353 | - | return | |
| 354 | - | } | |
| 355 | - | ||
| 356 | - | log.Info().Str("domain", secretDomain).Str("key", secretKey).Msg("secret deleted") | |
| 357 | - | h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "deleted"}) | |
| 358 | - | } | |
| 359 | - | ||
| 360 | - | // handleSecretsGet handles GET /admin/secrets/{domain} - list secrets. | |
| 361 | - | func (h *Handler) handleSecretsGet(w http.ResponseWriter, r *http.Request, secretDomain string) { | |
| 362 | - | ctx := r.Context() | |
| 363 | - | log := zerowrap.FromCtx(ctx) | |
| 364 | - | ||
| 365 | - | // Check read permission | |
| 366 | - | if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionRead) { | |
| 367 | - | h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:read") | |
| 368 | - | return | |
| 369 | - | } | |
| 370 | - | ||
| 371 | - | // List secrets for domain (names only, not values). | |
| 372 | - | keys, err := h.secretSvc.ListKeys(ctx, secretDomain) | |
| 373 | - | if err != nil { | |
| 374 | - | log.Error().Err(err).Str("domain", secretDomain).Msg("failed to list secrets") | |
| 375 | - | h.sendError(w, http.StatusBadRequest, "invalid domain") | |
| 376 | - | return | |
| 377 | - | } | |
| 378 | - | ||
| 379 | - | h.sendJSON(w, http.StatusOK, dto.SecretsListResponse{ | |
| 380 | - | Domain: secretDomain, | |
| 381 | - | Keys: keys, | |
| 382 | - | }) | |
| 383 | - | } | |
| 384 | - | ||
| 385 | 275 | // handleHealth handles /admin/health endpoint. | |
| 386 | 276 | // Returns detailed health status for all routes including HTTP probe results. | |
| 387 | 277 | func (h *Handler) handleHealth(w http.ResponseWriter, r *http.Request) { | |
M internal/adapters/in/http/admin/handler_apps_test.go +0 -1
| @@ -28,7 +28,6 @@ func appsTestHandler(t *testing.T, appSvc *inmocks.MockAppService) *Handler { | |||
| 28 | 28 | AuthSvc: inmocks.NewMockAuthService(t), | |
| 29 | 29 | ContainerSvc: inmocks.NewMockContainerService(t), | |
| 30 | 30 | HealthSvc: inmocks.NewMockHealthService(t), | |
| 31 | - | SecretSvc: inmocks.NewMockSecretService(t), | |
| 32 | 31 | Log: testLogger(), | |
| 33 | 32 | ReloadTrigger: noopReloadTrigger{}, | |
| 34 | 33 | AppSvc: appSvc, | |
M internal/adapters/in/http/admin/handler_retired.go +2 -1
| @@ -6,7 +6,7 @@ import ( | |||
| 6 | 6 | "github.com/bnema/gordon/internal/adapters/dto" | |
| 7 | 7 | ) | |
| 8 | 8 | ||
| 9 | - | // retiredMutationPaths are legacy mutation endpoints removed by the v3 | |
| 9 | + | // retiredMutationPaths are legacy endpoints removed by the v3 | |
| 10 | 10 | // declarative-apps cutover. Only the 410 Gone rejection dispatcher below | |
| 11 | 11 | // remains; no old business handlers or compatibility behavior is kept. | |
| 12 | 12 | var retiredMutationPaths = []string{ | |
| @@ -23,6 +23,7 @@ var retiredMutationPaths = []string{ | |||
| 23 | 23 | "/preview", | |
| 24 | 24 | "/previews", | |
| 25 | 25 | "/autoroute/allowed-domains", | |
| 26 | + | "/secrets", | |
| 26 | 27 | } | |
| 27 | 28 | ||
| 28 | 29 | // isRetiredMutation reports whether a request path targets a removed | |
M internal/adapters/in/http/admin/handler_retired_test.go +3 -1
| @@ -20,7 +20,6 @@ func retiredTestHandler(t *testing.T) *Handler { | |||
| 20 | 20 | AuthSvc: inmocks.NewMockAuthService(t), | |
| 21 | 21 | ContainerSvc: inmocks.NewMockContainerService(t), | |
| 22 | 22 | HealthSvc: inmocks.NewMockHealthService(t), | |
| 23 | - | SecretSvc: inmocks.NewMockSecretService(t), | |
| 24 | 23 | Log: testLogger(), | |
| 25 | 24 | ReloadTrigger: noopReloadTrigger{}, | |
| 26 | 25 | }) | |
| @@ -46,6 +45,9 @@ func TestHandler_RetiredMutations_Return410(t *testing.T) { | |||
| 46 | 45 | {http.MethodPost, "/admin/preview/blog"}, | |
| 47 | 46 | {http.MethodGet, "/admin/previews"}, | |
| 48 | 47 | {http.MethodPost, "/admin/autoroute/allowed-domains"}, | |
| 48 | + | {http.MethodGet, "/admin/secrets/example.com"}, | |
| 49 | + | {http.MethodPost, "/admin/secrets/example.com"}, | |
| 50 | + | {http.MethodDelete, "/admin/secrets/example.com/KEY"}, | |
| 49 | 51 | } | |
| 50 | 52 | for _, target := range targets { | |
| 51 | 53 | req := httptest.NewRequest(target.method, target.path, nil) | |
M internal/adapters/in/http/admin/handler_test.go +0 -250
| @@ -86,7 +86,6 @@ func newTestHandler(t *testing.T, opts ...func(*HandlerDeps)) *Handler { | |||
| 86 | 86 | AuthSvc: inmocks.NewMockAuthService(t), | |
| 87 | 87 | ContainerSvc: inmocks.NewMockContainerService(t), | |
| 88 | 88 | HealthSvc: inmocks.NewMockHealthService(t), | |
| 89 | - | SecretSvc: inmocks.NewMockSecretService(t), | |
| 90 | 89 | Log: testLogger(), | |
| 91 | 90 | ReloadTrigger: noopReloadTrigger{}, | |
| 92 | 91 | } | |
| @@ -156,180 +155,15 @@ func TestHandler_VolumesPrune_RequiresVolumesWriteScope(t *testing.T) { | |||
| 156 | 155 | ||
| 157 | 156 | // Routes endpoint tests | |
| 158 | 157 | ||
| 159 | - | func TestHandler_SecretsGet_RequiresReadScope(t *testing.T) { | |
| 160 | - | configSvc := inmocks.NewMockConfigService(t) | |
| 161 | - | authSvc := inmocks.NewMockAuthService(t) | |
| 162 | - | containerSvc := inmocks.NewMockContainerService(t) | |
| 163 | - | secretSvc := inmocks.NewMockSecretService(t) | |
| 164 | - | ||
| 165 | - | handler := newTestHandler(t, func(d *HandlerDeps) { | |
| 166 | - | d.ConfigSvc = configSvc | |
| 167 | - | d.AuthSvc = authSvc | |
| 168 | - | d.ContainerSvc = containerSvc | |
| 169 | - | d.SecretSvc = secretSvc | |
| 170 | - | }) | |
| 171 | - | ||
| 172 | - | tests := []struct { | |
| 173 | - | name string | |
| 174 | - | scopes []string | |
| 175 | - | wantStatus int | |
| 176 | - | }{ | |
| 177 | - | { | |
| 178 | - | name: "secrets read access granted", | |
| 179 | - | scopes: []string{"admin:secrets:read"}, | |
| 180 | - | wantStatus: http.StatusOK, | |
| 181 | - | }, | |
| 182 | - | { | |
| 183 | - | name: "secrets wildcard access granted", | |
| 184 | - | scopes: []string{"admin:secrets:*"}, | |
| 185 | - | wantStatus: http.StatusOK, | |
| 186 | - | }, | |
| 187 | - | { | |
| 188 | - | name: "all admin access granted", | |
| 189 | - | scopes: []string{"admin:*:*"}, | |
| 190 | - | wantStatus: http.StatusOK, | |
| 191 | - | }, | |
| 192 | - | { | |
| 193 | - | name: "only write access denied", | |
| 194 | - | scopes: []string{"admin:secrets:write"}, | |
| 195 | - | wantStatus: http.StatusForbidden, | |
| 196 | - | }, | |
| 197 | - | { | |
| 198 | - | name: "wrong resource denied", | |
| 199 | - | scopes: []string{"admin:routes:read"}, | |
| 200 | - | wantStatus: http.StatusForbidden, | |
| 201 | - | }, | |
| 202 | - | } | |
| 203 | - | ||
| 204 | - | for _, tt := range tests { | |
| 205 | - | t.Run(tt.name, func(t *testing.T) { | |
| 206 | - | if tt.wantStatus == http.StatusOK { | |
| 207 | - | secretSvc.EXPECT().ListKeys(mock.Anything, "app.example.com").Return([]string{}, nil).Maybe() | |
| 208 | - | } | |
| 209 | - | ||
| 210 | - | req := httptest.NewRequest("GET", "/admin/secrets/app.example.com", nil) | |
| 211 | - | req = req.WithContext(ctxWithScopes(tt.scopes...)) | |
| 212 | - | rec := httptest.NewRecorder() | |
| 213 | - | ||
| 214 | - | handler.ServeHTTP(rec, req) | |
| 215 | - | ||
| 216 | - | assert.Equal(t, tt.wantStatus, rec.Code) | |
| 217 | - | }) | |
| 218 | - | } | |
| 219 | - | } | |
| 220 | - | ||
| 221 | - | func TestHandler_SecretsPost_RequiresWriteScope(t *testing.T) { | |
| 222 | - | configSvc := inmocks.NewMockConfigService(t) | |
| 223 | - | authSvc := inmocks.NewMockAuthService(t) | |
| 224 | - | containerSvc := inmocks.NewMockContainerService(t) | |
| 225 | - | secretSvc := inmocks.NewMockSecretService(t) | |
| 226 | - | ||
| 227 | - | handler := newTestHandler(t, func(d *HandlerDeps) { | |
| 228 | - | d.ConfigSvc = configSvc | |
| 229 | - | d.AuthSvc = authSvc | |
| 230 | - | d.ContainerSvc = containerSvc | |
| 231 | - | d.SecretSvc = secretSvc | |
| 232 | - | }) | |
| 233 | - | ||
| 234 | - | secretsJSON := `{"API_KEY": "secret123"}` | |
| 235 | - | ||
| 236 | - | tests := []struct { | |
| 237 | - | name string | |
| 238 | - | scopes []string | |
| 239 | - | wantStatus int | |
| 240 | - | }{ | |
| 241 | - | { | |
| 242 | - | name: "secrets write access granted", | |
| 243 | - | scopes: []string{"admin:secrets:write"}, | |
| 244 | - | wantStatus: http.StatusOK, | |
| 245 | - | }, | |
| 246 | - | { | |
| 247 | - | name: "only read access denied", | |
| 248 | - | scopes: []string{"admin:secrets:read"}, | |
| 249 | - | wantStatus: http.StatusForbidden, | |
| 250 | - | }, | |
| 251 | - | { | |
| 252 | - | name: "wrong resource denied", | |
| 253 | - | scopes: []string{"admin:routes:write"}, | |
| 254 | - | wantStatus: http.StatusForbidden, | |
| 255 | - | }, | |
| 256 | - | } | |
| 257 | - | ||
| 258 | - | for _, tt := range tests { | |
| 259 | - | t.Run(tt.name, func(t *testing.T) { | |
| 260 | - | if tt.wantStatus == http.StatusOK { | |
| 261 | - | secretSvc.EXPECT().Set(mock.Anything, "app.example.com", mock.AnythingOfType("map[string]string")).Return(nil).Maybe() | |
| 262 | - | } | |
| 263 | - | ||
| 264 | - | req := httptest.NewRequest("POST", "/admin/secrets/app.example.com", bytes.NewBufferString(secretsJSON)) | |
| 265 | - | req = req.WithContext(ctxWithScopes(tt.scopes...)) | |
| 266 | - | rec := httptest.NewRecorder() | |
| 267 | - | ||
| 268 | - | handler.ServeHTTP(rec, req) | |
| 269 | - | ||
| 270 | - | assert.Equal(t, tt.wantStatus, rec.Code) | |
| 271 | - | }) | |
| 272 | - | } | |
| 273 | - | } | |
| 274 | - | ||
| 275 | - | func TestHandler_SecretsDelete_RequiresWriteScope(t *testing.T) { | |
| 276 | - | configSvc := inmocks.NewMockConfigService(t) | |
| 277 | - | authSvc := inmocks.NewMockAuthService(t) | |
| 278 | - | containerSvc := inmocks.NewMockContainerService(t) | |
| 279 | - | secretSvc := inmocks.NewMockSecretService(t) | |
| 280 | - | ||
| 281 | - | handler := newTestHandler(t, func(d *HandlerDeps) { | |
| 282 | - | d.ConfigSvc = configSvc | |
| 283 | - | d.AuthSvc = authSvc | |
| 284 | - | d.ContainerSvc = containerSvc | |
| 285 | - | d.SecretSvc = secretSvc | |
| 286 | - | }) | |
| 287 | - | ||
| 288 | - | tests := []struct { | |
| 289 | - | name string | |
| 290 | - | scopes []string | |
| 291 | - | wantStatus int | |
| 292 | - | }{ | |
| 293 | - | { | |
| 294 | - | name: "secrets write access granted", | |
| 295 | - | scopes: []string{"admin:secrets:write"}, | |
| 296 | - | wantStatus: http.StatusOK, | |
| 297 | - | }, | |
| 298 | - | { | |
| 299 | - | name: "only read access denied", | |
| 300 | - | scopes: []string{"admin:secrets:read"}, | |
| 301 | - | wantStatus: http.StatusForbidden, | |
| 302 | - | }, | |
| 303 | - | } | |
| 304 | - | ||
| 305 | - | for _, tt := range tests { | |
| 306 | - | t.Run(tt.name, func(t *testing.T) { | |
| 307 | - | if tt.wantStatus == http.StatusOK { | |
| 308 | - | secretSvc.EXPECT().Delete(mock.Anything, "app.example.com", "API_KEY").Return(nil).Maybe() | |
| 309 | - | } | |
| 310 | - | ||
| 311 | - | req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/API_KEY", nil) | |
| 312 | - | req = req.WithContext(ctxWithScopes(tt.scopes...)) | |
| 313 | - | rec := httptest.NewRecorder() | |
| 314 | - | ||
| 315 | - | handler.ServeHTTP(rec, req) | |
| 316 | - | ||
| 317 | - | assert.Equal(t, tt.wantStatus, rec.Code) | |
| 318 | - | }) | |
| 319 | - | } | |
| 320 | - | } | |
| 321 | - | ||
| 322 | 158 | func TestHandler_Status_RequiresReadScope(t *testing.T) { | |
| 323 | 159 | configSvc := inmocks.NewMockConfigService(t) | |
| 324 | 160 | authSvc := inmocks.NewMockAuthService(t) | |
| 325 | 161 | containerSvc := inmocks.NewMockContainerService(t) | |
| 326 | - | secretSvc := inmocks.NewMockSecretService(t) | |
| 327 | 162 | ||
| 328 | 163 | handler := newTestHandler(t, func(d *HandlerDeps) { | |
| 329 | 164 | d.ConfigSvc = configSvc | |
| 330 | 165 | d.AuthSvc = authSvc | |
| 331 | 166 | d.ContainerSvc = containerSvc | |
| 332 | - | d.SecretSvc = secretSvc | |
| 333 | 167 | }) | |
| 334 | 168 | ||
| 335 | 169 | tests := []struct { | |
| @@ -408,13 +242,11 @@ func TestHandler_Config_RequiresReadScope(t *testing.T) { | |||
| 408 | 242 | configSvc := inmocks.NewMockConfigService(t) | |
| 409 | 243 | authSvc := inmocks.NewMockAuthService(t) | |
| 410 | 244 | containerSvc := inmocks.NewMockContainerService(t) | |
| 411 | - | secretSvc := inmocks.NewMockSecretService(t) | |
| 412 | 245 | ||
| 413 | 246 | handler := newTestHandler(t, func(d *HandlerDeps) { | |
| 414 | 247 | d.ConfigSvc = configSvc | |
| 415 | 248 | d.AuthSvc = authSvc | |
| 416 | 249 | d.ContainerSvc = containerSvc | |
| 417 | - | d.SecretSvc = secretSvc | |
| 418 | 250 | }) | |
| 419 | 251 | ||
| 420 | 252 | tests := []struct { | |
| @@ -468,14 +300,12 @@ func TestHandler_Reload_RequiresWriteScope(t *testing.T) { | |||
| 468 | 300 | configSvc := inmocks.NewMockConfigService(t) | |
| 469 | 301 | authSvc := inmocks.NewMockAuthService(t) | |
| 470 | 302 | containerSvc := inmocks.NewMockContainerService(t) | |
| 471 | - | secretSvc := inmocks.NewMockSecretService(t) | |
| 472 | 303 | reloadTrigger := &reloadTriggerRecorder{} | |
| 473 | 304 | ||
| 474 | 305 | handler := newTestHandler(t, func(d *HandlerDeps) { | |
| 475 | 306 | d.ConfigSvc = configSvc | |
| 476 | 307 | d.AuthSvc = authSvc | |
| 477 | 308 | d.ContainerSvc = containerSvc | |
| 478 | - | d.SecretSvc = secretSvc | |
| 479 | 309 | d.ReloadTrigger = reloadTrigger | |
| 480 | 310 | }) | |
| 481 | 311 | ||
| @@ -567,61 +397,15 @@ func TestHandler_Reload_UsesDetachedContextWithDeadline(t *testing.T) { | |||
| 567 | 397 | ||
| 568 | 398 | // Functional tests | |
| 569 | 399 | ||
| 570 | - | func TestHandler_Secrets_MissingDomain(t *testing.T) { | |
| 571 | - | configSvc := inmocks.NewMockConfigService(t) | |
| 572 | - | authSvc := inmocks.NewMockAuthService(t) | |
| 573 | - | containerSvc := inmocks.NewMockContainerService(t) | |
| 574 | - | secretSvc := inmocks.NewMockSecretService(t) | |
| 575 | - | ||
| 576 | - | handler := newTestHandler(t, func(d *HandlerDeps) { | |
| 577 | - | d.ConfigSvc = configSvc | |
| 578 | - | d.AuthSvc = authSvc | |
| 579 | - | d.ContainerSvc = containerSvc | |
| 580 | - | d.SecretSvc = secretSvc | |
| 581 | - | }) | |
| 582 | - | ||
| 583 | - | req := httptest.NewRequest("GET", "/admin/secrets/", nil) | |
| 584 | - | req = req.WithContext(ctxWithScopes("admin:secrets:read")) | |
| 585 | - | rec := httptest.NewRecorder() | |
| 586 | - | ||
| 587 | - | handler.ServeHTTP(rec, req) | |
| 588 | - | ||
| 589 | - | assert.Equal(t, http.StatusBadRequest, rec.Code) | |
| 590 | - | } | |
| 591 | - | ||
| 592 | - | func TestHandler_SecretsDelete_MissingKey(t *testing.T) { | |
| 593 | - | configSvc := inmocks.NewMockConfigService(t) | |
| 594 | - | authSvc := inmocks.NewMockAuthService(t) | |
| 595 | - | containerSvc := inmocks.NewMockContainerService(t) | |
| 596 | - | secretSvc := inmocks.NewMockSecretService(t) | |
| 597 | - | ||
| 598 | - | handler := newTestHandler(t, func(d *HandlerDeps) { | |
| 599 | - | d.ConfigSvc = configSvc | |
| 600 | - | d.AuthSvc = authSvc | |
| 601 | - | d.ContainerSvc = containerSvc | |
| 602 | - | d.SecretSvc = secretSvc | |
| 603 | - | }) | |
| 604 | - | ||
| 605 | - | req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com", nil) | |
| 606 | - | req = req.WithContext(ctxWithScopes("admin:secrets:write")) | |
| 607 | - | rec := httptest.NewRecorder() | |
| 608 | - | ||
| 609 | - | handler.ServeHTTP(rec, req) | |
| 610 | - | ||
| 611 | - | assert.Equal(t, http.StatusBadRequest, rec.Code) | |
| 612 | - | } | |
| 613 | - | ||
| 614 | 400 | func TestHandler_MethodNotAllowed(t *testing.T) { | |
| 615 | 401 | configSvc := inmocks.NewMockConfigService(t) | |
| 616 | 402 | authSvc := inmocks.NewMockAuthService(t) | |
| 617 | 403 | containerSvc := inmocks.NewMockContainerService(t) | |
| 618 | - | secretSvc := inmocks.NewMockSecretService(t) | |
| 619 | 404 | ||
| 620 | 405 | handler := newTestHandler(t, func(d *HandlerDeps) { | |
| 621 | 406 | d.ConfigSvc = configSvc | |
| 622 | 407 | d.AuthSvc = authSvc | |
| 623 | 408 | d.ContainerSvc = containerSvc | |
| 624 | - | d.SecretSvc = secretSvc | |
| 625 | 409 | }) | |
| 626 | 410 | ||
| 627 | 411 | tests := []struct { | |
| @@ -653,13 +437,11 @@ func TestHandler_Tags_InvalidRepository(t *testing.T) { | |||
| 653 | 437 | configSvc := inmocks.NewMockConfigService(t) | |
| 654 | 438 | authSvc := inmocks.NewMockAuthService(t) | |
| 655 | 439 | containerSvc := inmocks.NewMockContainerService(t) | |
| 656 | - | secretSvc := inmocks.NewMockSecretService(t) | |
| 657 | 440 | ||
| 658 | 441 | handler := newTestHandler(t, func(d *HandlerDeps) { | |
| 659 | 442 | d.ConfigSvc = configSvc | |
| 660 | 443 | d.AuthSvc = authSvc | |
| 661 | 444 | d.ContainerSvc = containerSvc | |
| 662 | - | d.SecretSvc = secretSvc | |
| 663 | 445 | }) | |
| 664 | 446 | ||
| 665 | 447 | tests := []struct { | |
| @@ -688,14 +470,12 @@ func TestHandler_Tags_Success(t *testing.T) { | |||
| 688 | 470 | configSvc := inmocks.NewMockConfigService(t) | |
| 689 | 471 | authSvc := inmocks.NewMockAuthService(t) | |
| 690 | 472 | containerSvc := inmocks.NewMockContainerService(t) | |
| 691 | - | secretSvc := inmocks.NewMockSecretService(t) | |
| 692 | 473 | registrySvc := inmocks.NewMockRegistryService(t) | |
| 693 | 474 | ||
| 694 | 475 | handler := newTestHandler(t, func(d *HandlerDeps) { | |
| 695 | 476 | d.ConfigSvc = configSvc | |
| 696 | 477 | d.AuthSvc = authSvc | |
| 697 | 478 | d.ContainerSvc = containerSvc | |
| 698 | - | d.SecretSvc = secretSvc | |
| 699 | 479 | d.RegistrySvc = registrySvc | |
| 700 | 480 | }) | |
| 701 | 481 | ||
| @@ -721,14 +501,12 @@ func TestHandler_Tags_Error(t *testing.T) { | |||
| 721 | 501 | configSvc := inmocks.NewMockConfigService(t) | |
| 722 | 502 | authSvc := inmocks.NewMockAuthService(t) | |
| 723 | 503 | containerSvc := inmocks.NewMockContainerService(t) | |
| 724 | - | secretSvc := inmocks.NewMockSecretService(t) | |
| 725 | 504 | registrySvc := inmocks.NewMockRegistryService(t) | |
| 726 | 505 | ||
| 727 | 506 | handler := newTestHandler(t, func(d *HandlerDeps) { | |
| 728 | 507 | d.ConfigSvc = configSvc | |
| 729 | 508 | d.AuthSvc = authSvc | |
| 730 | 509 | d.ContainerSvc = containerSvc | |
| 731 | - | d.SecretSvc = secretSvc | |
| 732 | 510 | d.RegistrySvc = registrySvc | |
| 733 | 511 | }) | |
| 734 | 512 | ||
| @@ -748,14 +526,12 @@ func TestHandler_Tags_URLDecodedRepository(t *testing.T) { | |||
| 748 | 526 | configSvc := inmocks.NewMockConfigService(t) | |
| 749 | 527 | authSvc := inmocks.NewMockAuthService(t) | |
| 750 | 528 | containerSvc := inmocks.NewMockContainerService(t) | |
| 751 | - | secretSvc := inmocks.NewMockSecretService(t) | |
| 752 | 529 | registrySvc := inmocks.NewMockRegistryService(t) | |
| 753 | 530 | ||
| 754 | 531 | handler := newTestHandler(t, func(d *HandlerDeps) { | |
| 755 | 532 | d.ConfigSvc = configSvc | |
| 756 | 533 | d.AuthSvc = authSvc | |
| 757 | 534 | d.ContainerSvc = containerSvc | |
| 758 | - | d.SecretSvc = secretSvc | |
| 759 | 535 | d.RegistrySvc = registrySvc | |
| 760 | 536 | }) | |
| 761 | 537 | ||
| @@ -775,13 +551,11 @@ func TestHandler_NotFound(t *testing.T) { | |||
| 775 | 551 | configSvc := inmocks.NewMockConfigService(t) | |
| 776 | 552 | authSvc := inmocks.NewMockAuthService(t) | |
| 777 | 553 | containerSvc := inmocks.NewMockContainerService(t) | |
| 778 | - | secretSvc := inmocks.NewMockSecretService(t) | |
| 779 | 554 | ||
| 780 | 555 | handler := newTestHandler(t, func(d *HandlerDeps) { | |
| 781 | 556 | d.ConfigSvc = configSvc | |
| 782 | 557 | d.AuthSvc = authSvc | |
| 783 | 558 | d.ContainerSvc = containerSvc | |
| 784 | - | d.SecretSvc = secretSvc | |
| 785 | 559 | }) | |
| 786 | 560 | ||
| 787 | 561 | req := httptest.NewRequest("GET", "/admin/unknown", nil) | |
| @@ -797,7 +571,6 @@ func TestHandler_BackupsStatus(t *testing.T) { | |||
| 797 | 571 | configSvc := inmocks.NewMockConfigService(t) | |
| 798 | 572 | authSvc := inmocks.NewMockAuthService(t) | |
| 799 | 573 | containerSvc := inmocks.NewMockContainerService(t) | |
| 800 | - | secretSvc := inmocks.NewMockSecretService(t) | |
| 801 | 574 | backupSvc := inmocks.NewMockBackupService(t) | |
| 802 | 575 | ||
| 803 | 576 | backupSvc.EXPECT().Status(mock.Anything).Return([]domain.BackupJob{{App: "shop", Service: "api", DBName: "orders", Status: domain.BackupStatusCompleted, FilePath: "/var/lib/gordon/backups/private.bak"}}, nil) | |
| @@ -806,7 +579,6 @@ func TestHandler_BackupsStatus(t *testing.T) { | |||
| 806 | 579 | d.ConfigSvc = configSvc | |
| 807 | 580 | d.AuthSvc = authSvc | |
| 808 | 581 | d.ContainerSvc = containerSvc | |
| 809 | - | d.SecretSvc = secretSvc | |
| 810 | 582 | d.BackupSvc = backupSvc | |
| 811 | 583 | }) | |
| 812 | 584 | ||
| @@ -826,7 +598,6 @@ func TestHandler_BackupsListApp(t *testing.T) { | |||
| 826 | 598 | configSvc := inmocks.NewMockConfigService(t) | |
| 827 | 599 | authSvc := inmocks.NewMockAuthService(t) | |
| 828 | 600 | containerSvc := inmocks.NewMockContainerService(t) | |
| 829 | - | secretSvc := inmocks.NewMockSecretService(t) | |
| 830 | 601 | backupSvc := inmocks.NewMockBackupService(t) | |
| 831 | 602 | ||
| 832 | 603 | backupSvc.EXPECT().ListBackups(mock.Anything, "shop").Return([]domain.BackupJob{{App: "shop", Service: "api", DBName: "orders", Status: domain.BackupStatusCompleted}}, nil) | |
| @@ -835,7 +606,6 @@ func TestHandler_BackupsListApp(t *testing.T) { | |||
| 835 | 606 | d.ConfigSvc = configSvc | |
| 836 | 607 | d.AuthSvc = authSvc | |
| 837 | 608 | d.ContainerSvc = containerSvc | |
| 838 | - | d.SecretSvc = secretSvc | |
| 839 | 609 | d.BackupSvc = backupSvc | |
| 840 | 610 | }) | |
| 841 | 611 | ||
| @@ -853,7 +623,6 @@ func TestHandler_BackupsRunApp(t *testing.T) { | |||
| 853 | 623 | configSvc := inmocks.NewMockConfigService(t) | |
| 854 | 624 | authSvc := inmocks.NewMockAuthService(t) | |
| 855 | 625 | containerSvc := inmocks.NewMockContainerService(t) | |
| 856 | - | secretSvc := inmocks.NewMockSecretService(t) | |
| 857 | 626 | backupSvc := inmocks.NewMockBackupService(t) | |
| 858 | 627 | ||
| 859 | 628 | backupSvc.EXPECT().RunBackup(mock.Anything, "shop", "api", "orders").Return(&domain.BackupResult{Job: domain.BackupJob{App: "shop", Service: "api", DBName: "orders", Status: domain.BackupStatusCompleted}}, nil) | |
| @@ -862,7 +631,6 @@ func TestHandler_BackupsRunApp(t *testing.T) { | |||
| 862 | 631 | d.ConfigSvc = configSvc | |
| 863 | 632 | d.AuthSvc = authSvc | |
| 864 | 633 | d.ContainerSvc = containerSvc | |
| 865 | - | d.SecretSvc = secretSvc | |
| 866 | 634 | d.BackupSvc = backupSvc | |
| 867 | 635 | }) | |
| 868 | 636 | ||
| @@ -925,7 +693,6 @@ func TestHandler_BackupsRunApp_ChunkedBodyIsDecoded(t *testing.T) { | |||
| 925 | 693 | configSvc := inmocks.NewMockConfigService(t) | |
| 926 | 694 | authSvc := inmocks.NewMockAuthService(t) | |
| 927 | 695 | containerSvc := inmocks.NewMockContainerService(t) | |
| 928 | - | secretSvc := inmocks.NewMockSecretService(t) | |
| 929 | 696 | backupSvc := inmocks.NewMockBackupService(t) | |
| 930 | 697 | ||
| 931 | 698 | backupSvc.EXPECT().RunBackup(mock.Anything, "shop", "api", "orders").Return(&domain.BackupResult{Job: domain.BackupJob{App: "shop", Service: "api", DBName: "orders", Status: domain.BackupStatusCompleted}}, nil) | |
| @@ -934,7 +701,6 @@ func TestHandler_BackupsRunApp_ChunkedBodyIsDecoded(t *testing.T) { | |||
| 934 | 701 | d.ConfigSvc = configSvc | |
| 935 | 702 | d.AuthSvc = authSvc | |
| 936 | 703 | d.ContainerSvc = containerSvc | |
| 937 | - | d.SecretSvc = secretSvc | |
| 938 | 704 | d.BackupSvc = backupSvc | |
| 939 | 705 | }) | |
| 940 | 706 | ||
| @@ -991,7 +757,6 @@ func TestHandler_ImagesGet_ReturnsMappedList(t *testing.T) { | |||
| 991 | 757 | configSvc := inmocks.NewMockConfigService(t) | |
| 992 | 758 | authSvc := inmocks.NewMockAuthService(t) | |
| 993 | 759 | containerSvc := inmocks.NewMockContainerService(t) | |
| 994 | - | secretSvc := inmocks.NewMockSecretService(t) | |
| 995 | 760 | ||
| 996 | 761 | createdAt := time.Date(2026, time.February, 8, 14, 30, 0, 0, time.UTC) | |
| 997 | 762 | imageSvc := &stubImageService{ | |
| @@ -1021,7 +786,6 @@ func TestHandler_ImagesGet_ReturnsMappedList(t *testing.T) { | |||
| 1021 | 786 | d.ConfigSvc = configSvc | |
| 1022 | 787 | d.AuthSvc = authSvc | |
| 1023 | 788 | d.ContainerSvc = containerSvc | |
| 1024 | - | d.SecretSvc = secretSvc | |
| 1025 | 789 | d.ImageSvc = imageSvc | |
| 1026 | 790 | }) | |
| 1027 | 791 | ||
| @@ -1059,7 +823,6 @@ func TestHandler_ImagesPrune_AcceptsOptionalKeepLast(t *testing.T) { | |||
| 1059 | 823 | configSvc := inmocks.NewMockConfigService(t) | |
| 1060 | 824 | authSvc := inmocks.NewMockAuthService(t) | |
| 1061 | 825 | containerSvc := inmocks.NewMockContainerService(t) | |
| 1062 | - | secretSvc := inmocks.NewMockSecretService(t) | |
| 1063 | 826 | ||
| 1064 | 827 | tests := []struct { | |
| 1065 | 828 | name string | |
| @@ -1088,7 +851,6 @@ func TestHandler_ImagesPrune_AcceptsOptionalKeepLast(t *testing.T) { | |||
| 1088 | 851 | d.ConfigSvc = configSvc | |
| 1089 | 852 | d.AuthSvc = authSvc | |
| 1090 | 853 | d.ContainerSvc = containerSvc | |
| 1091 | - | d.SecretSvc = secretSvc | |
| 1092 | 854 | d.ImageSvc = imageSvc | |
| 1093 | 855 | }) | |
| 1094 | 856 | ||
| @@ -1111,7 +873,6 @@ func TestHandler_Images_ErrorMappingForServiceFailures(t *testing.T) { | |||
| 1111 | 873 | configSvc := inmocks.NewMockConfigService(t) | |
| 1112 | 874 | authSvc := inmocks.NewMockAuthService(t) | |
| 1113 | 875 | containerSvc := inmocks.NewMockContainerService(t) | |
| 1114 | - | secretSvc := inmocks.NewMockSecretService(t) | |
| 1115 | 876 | ||
| 1116 | 877 | imageSvc := &stubImageService{ | |
| 1117 | 878 | listImagesFunc: func(context.Context) ([]domain.ImageInfo, error) { | |
| @@ -1123,7 +884,6 @@ func TestHandler_Images_ErrorMappingForServiceFailures(t *testing.T) { | |||
| 1123 | 884 | d.ConfigSvc = configSvc | |
| 1124 | 885 | d.AuthSvc = authSvc | |
| 1125 | 886 | d.ContainerSvc = containerSvc | |
| 1126 | - | d.SecretSvc = secretSvc | |
| 1127 | 887 | d.ImageSvc = imageSvc | |
| 1128 | 888 | }) | |
| 1129 | 889 | ||
| @@ -1142,7 +902,6 @@ func TestHandler_Images_ErrorMappingForServiceFailures(t *testing.T) { | |||
| 1142 | 902 | configSvc := inmocks.NewMockConfigService(t) | |
| 1143 | 903 | authSvc := inmocks.NewMockAuthService(t) | |
| 1144 | 904 | containerSvc := inmocks.NewMockContainerService(t) | |
| 1145 | - | secretSvc := inmocks.NewMockSecretService(t) | |
| 1146 | 905 | ||
| 1147 | 906 | imageSvc := &stubImageService{ | |
| 1148 | 907 | pruneFunc: func(context.Context, domain.ImagePruneOptions) (domain.ImagePruneReport, error) { | |
| @@ -1154,7 +913,6 @@ func TestHandler_Images_ErrorMappingForServiceFailures(t *testing.T) { | |||
| 1154 | 913 | d.ConfigSvc = configSvc | |
| 1155 | 914 | d.AuthSvc = authSvc | |
| 1156 | 915 | d.ContainerSvc = containerSvc | |
| 1157 | - | d.SecretSvc = secretSvc | |
| 1158 | 916 | d.ImageSvc = imageSvc | |
| 1159 | 917 | }) | |
| 1160 | 918 | ||
| @@ -1175,13 +933,11 @@ func TestHandler_ImagesPrune_ValidationAndAvailability(t *testing.T) { | |||
| 1175 | 933 | configSvc := inmocks.NewMockConfigService(t) | |
| 1176 | 934 | authSvc := inmocks.NewMockAuthService(t) | |
| 1177 | 935 | containerSvc := inmocks.NewMockContainerService(t) | |
| 1178 | - | secretSvc := inmocks.NewMockSecretService(t) | |
| 1179 | 936 | ||
| 1180 | 937 | handler := newTestHandler(t, func(d *HandlerDeps) { | |
| 1181 | 938 | d.ConfigSvc = configSvc | |
| 1182 | 939 | d.AuthSvc = authSvc | |
| 1183 | 940 | d.ContainerSvc = containerSvc | |
| 1184 | - | d.SecretSvc = secretSvc | |
| 1185 | 941 | d.ImageSvc = &stubImageService{} | |
| 1186 | 942 | }) | |
| 1187 | 943 | ||
| @@ -1199,13 +955,11 @@ func TestHandler_ImagesPrune_ValidationAndAvailability(t *testing.T) { | |||
| 1199 | 955 | configSvc := inmocks.NewMockConfigService(t) | |
| 1200 | 956 | authSvc := inmocks.NewMockAuthService(t) | |
| 1201 | 957 | containerSvc := inmocks.NewMockContainerService(t) | |
| 1202 | - | secretSvc := inmocks.NewMockSecretService(t) | |
| 1203 | 958 | ||
| 1204 | 959 | handler := newTestHandler(t, func(d *HandlerDeps) { | |
| 1205 | 960 | d.ConfigSvc = configSvc | |
| 1206 | 961 | d.AuthSvc = authSvc | |
| 1207 | 962 | d.ContainerSvc = containerSvc | |
| 1208 | - | d.SecretSvc = secretSvc | |
| 1209 | 963 | }) | |
| 1210 | 964 | ||
| 1211 | 965 | req := httptest.NewRequest("GET", "/admin/images", nil) | |
| @@ -1223,7 +977,6 @@ func TestHandler_ImagesPrune_ScopeDefaults(t *testing.T) { | |||
| 1223 | 977 | configSvc := inmocks.NewMockConfigService(t) | |
| 1224 | 978 | authSvc := inmocks.NewMockAuthService(t) | |
| 1225 | 979 | containerSvc := inmocks.NewMockContainerService(t) | |
| 1226 | - | secretSvc := inmocks.NewMockSecretService(t) | |
| 1227 | 980 | ||
| 1228 | 981 | tests := []struct { | |
| 1229 | 982 | name string | |
| @@ -1281,7 +1034,6 @@ func TestHandler_ImagesPrune_ScopeDefaults(t *testing.T) { | |||
| 1281 | 1034 | d.ConfigSvc = configSvc | |
| 1282 | 1035 | d.AuthSvc = authSvc | |
| 1283 | 1036 | d.ContainerSvc = containerSvc | |
| 1284 | - | d.SecretSvc = secretSvc | |
| 1285 | 1037 | d.ImageSvc = imageSvc | |
| 1286 | 1038 | }) | |
| 1287 | 1039 | ||
| @@ -1306,13 +1058,11 @@ func TestHandler_Images_Authorization(t *testing.T) { | |||
| 1306 | 1058 | configSvc := inmocks.NewMockConfigService(t) | |
| 1307 | 1059 | authSvc := inmocks.NewMockAuthService(t) | |
| 1308 | 1060 | containerSvc := inmocks.NewMockContainerService(t) | |
| 1309 | - | secretSvc := inmocks.NewMockSecretService(t) | |
| 1310 | 1061 | ||
| 1311 | 1062 | handler := newTestHandler(t, func(d *HandlerDeps) { | |
| 1312 | 1063 | d.ConfigSvc = configSvc | |
| 1313 | 1064 | d.AuthSvc = authSvc | |
| 1314 | 1065 | d.ContainerSvc = containerSvc | |
| 1315 | - | d.SecretSvc = secretSvc | |
| 1316 | 1066 | d.ImageSvc = &stubImageService{} | |
| 1317 | 1067 | }) | |
| 1318 | 1068 | ||
M internal/adapters/in/http/admin/local_authority.go +0 -7
| @@ -22,7 +22,6 @@ var localAuthorityScopes = []string{ | |||
| 22 | 22 | domain.AdminScopeApps(domain.AdminActionRead, domain.AdminActionWrite), | |
| 23 | 23 | domain.AdminScopeStatus(domain.AdminActionRead), | |
| 24 | 24 | domain.AdminScopeConfig(domain.AdminActionRead, domain.AdminActionWrite), | |
| 25 | - | domain.AdminScopeSecrets(domain.AdminActionRead, domain.AdminActionWrite), | |
| 26 | 25 | domain.AdminScopeLogs(domain.AdminActionRead), | |
| 27 | 26 | domain.AdminScopeVolumes(domain.AdminActionRead, domain.AdminActionWrite), | |
| 28 | 27 | } | |
| @@ -64,7 +63,6 @@ func localPathAllowed(method, cleanedPath string) bool { | |||
| 64 | 63 | prefixRules := map[string]func(string, []string) bool{ | |
| 65 | 64 | "apps": localAppPathAllowed, | |
| 66 | 65 | "backups": localBackupPathAllowed, | |
| 67 | - | "secrets": localSecretPathAllowed, | |
| 68 | 66 | "logs": localLogPathAllowed, | |
| 69 | 67 | "tags": localTagPathAllowed, | |
| 70 | 68 | } | |
| @@ -72,11 +70,6 @@ func localPathAllowed(method, cleanedPath string) bool { | |||
| 72 | 70 | return ok && rule(method, parts) | |
| 73 | 71 | } | |
| 74 | 72 | ||
| 75 | - | func localSecretPathAllowed(method string, parts []string) bool { | |
| 76 | - | return len(parts) == 2 && parts[1] != "" && (method == http.MethodGet || method == http.MethodPost) || | |
| 77 | - | len(parts) == 3 && parts[1] != "" && parts[2] != "" && method == http.MethodDelete | |
| 78 | - | } | |
| 79 | - | ||
| 80 | 73 | func localLogPathAllowed(method string, parts []string) bool { | |
| 81 | 74 | return (len(parts) == 1 || len(parts) == 3 && parts[1] != "" && parts[2] != "") && method == http.MethodGet | |
| 82 | 75 | } | |
M internal/adapters/in/http/admin/local_authority_test.go +3 -5
| @@ -35,8 +35,6 @@ func TestLocalAuthorityInjectsLeastPrivilegeScopes(t *testing.T) { | |||
| 35 | 35 | {domain.AdminResourceStatus, domain.AdminActionRead}, | |
| 36 | 36 | {domain.AdminResourceConfig, domain.AdminActionRead}, | |
| 37 | 37 | {domain.AdminResourceConfig, domain.AdminActionWrite}, | |
| 38 | - | {domain.AdminResourceSecrets, domain.AdminActionRead}, | |
| 39 | - | {domain.AdminResourceSecrets, domain.AdminActionWrite}, | |
| 40 | 38 | {domain.AdminResourceLogs, domain.AdminActionRead}, | |
| 41 | 39 | {domain.AdminResourceVolumes, domain.AdminActionRead}, | |
| 42 | 40 | {domain.AdminResourceVolumes, domain.AdminActionWrite}, | |
| @@ -44,6 +42,7 @@ func TestLocalAuthorityInjectsLeastPrivilegeScopes(t *testing.T) { | |||
| 44 | 42 | assert.True(t, HasAccess(ctx, access.resource, access.action), "%s:%s", access.resource, access.action) | |
| 45 | 43 | } | |
| 46 | 44 | assert.False(t, HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionRead)) | |
| 45 | + | assert.False(t, HasAccess(ctx, "secrets", domain.AdminActionRead)) | |
| 47 | 46 | assert.False(t, HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionWrite)) | |
| 48 | 47 | assert.False(t, HasAccess(ctx, domain.AdminResourceLogs, domain.AdminActionWrite)) | |
| 49 | 48 | assert.False(t, HasAccess(ctx, domain.AdminResourceAll, domain.AdminActionAll)) | |
| @@ -70,9 +69,6 @@ func TestLocalAuthorityCanonicalAllowlist(t *testing.T) { | |||
| 70 | 69 | {http.MethodGet, "/tags/repository"}, | |
| 71 | 70 | {http.MethodGet, "/logs"}, | |
| 72 | 71 | {http.MethodGet, "/logs/blog/web"}, | |
| 73 | - | {http.MethodGet, "/secrets/blog.example.com"}, | |
| 74 | - | {http.MethodPost, "/secrets/blog.example.com"}, | |
| 75 | - | {http.MethodDelete, "/secrets/blog.example.com/API_KEY"}, | |
| 76 | 72 | {http.MethodGet, "/backups"}, | |
| 77 | 73 | {http.MethodGet, "/backups/status"}, | |
| 78 | 74 | {http.MethodGet, "/backups/shop"}, | |
| @@ -116,6 +112,8 @@ func TestLocalAuthorityDeniesNearMissesAndNonCanonicalPaths(t *testing.T) { | |||
| 116 | 112 | {http.MethodGet, "/health"}, | |
| 117 | 113 | {http.MethodGet, "/appsX"}, | |
| 118 | 114 | {http.MethodGet, "/secrets"}, | |
| 115 | + | {http.MethodGet, "/secrets/blog.example.com"}, | |
| 116 | + | {http.MethodPost, "/secrets/blog.example.com"}, | |
| 119 | 117 | {http.MethodDelete, "/secrets/blog"}, | |
| 120 | 118 | {http.MethodGet, "/images/anything"}, | |
| 121 | 119 | {http.MethodGet, "/tags/repository/extra"}, | |
D internal/adapters/out/domainsecrets/helpers_test.go +0 -25
| @@ -1,25 +0,0 @@ | |||
| 1 | - | package domainsecrets | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "fmt" | |
| 5 | - | "testing" | |
| 6 | - | ||
| 7 | - | "github.com/bnema/zerowrap" | |
| 8 | - | ) | |
| 9 | - | ||
| 10 | - | func testLogger() zerowrap.Logger { | |
| 11 | - | return zerowrap.Default() | |
| 12 | - | } | |
| 13 | - | ||
| 14 | - | // CleanupPassAttachment removes attachment secrets from pass for testing. | |
| 15 | - | // It's a test helper that should be called with defer to ensure cleanup. | |
| 16 | - | // This function is exported to be shared across test packages. | |
| 17 | - | func CleanupPassAttachment(_ *testing.T, containerName string, keys []string) { | |
| 18 | - | for _, key := range keys { | |
| 19 | - | path := fmt.Sprintf("%s/%s/%s", PassAttachmentPath, containerName, key) | |
| 20 | - | _ = passCmd("rm", "-f", path) | |
| 21 | - | } | |
| 22 | - | ||
| 23 | - | manifestPath := fmt.Sprintf("%s/%s/.keys", PassAttachmentPath, containerName) | |
| 24 | - | _ = passCmd("rm", "-f", manifestPath) | |
| 25 | - | } | |
D internal/adapters/out/domainsecrets/pass_store.go +0 -934
| @@ -1,934 +0,0 @@ | |||
| 1 | - | // Package domainsecrets implements the DomainSecretStore adapter using pass. | |
| 2 | - | package domainsecrets | |
| 3 | - | ||
| 4 | - | import ( | |
| 5 | - | "context" | |
| 6 | - | "fmt" | |
| 7 | - | "os/exec" | |
| 8 | - | "regexp" | |
| 9 | - | "sort" | |
| 10 | - | "strings" | |
| 11 | - | "sync" | |
| 12 | - | "time" | |
| 13 | - | ||
| 14 | - | "github.com/bnema/zerowrap" | |
| 15 | - | ||
| 16 | - | "github.com/bnema/gordon/internal/adapters/out/secrets" | |
| 17 | - | "github.com/bnema/gordon/internal/boundaries/out" | |
| 18 | - | "github.com/bnema/gordon/internal/domain" | |
| 19 | - | ) | |
| 20 | - | ||
| 21 | - | // ansiRegex matches ANSI escape sequences for stripping from pass output. | |
| 22 | - | var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*m`) | |
| 23 | - | ||
| 24 | - | const ( | |
| 25 | - | PassDomainSecretsPath = "gordon/env" //nolint:gosec // Not a credential, this is a pass store path. | |
| 26 | - | PassAttachmentPath = "gordon/env/attachments" //nolint:gosec // Not a credential, this is a pass store path. | |
| 27 | - | ) | |
| 28 | - | ||
| 29 | - | // PassStore implements the DomainSecretStore interface using the pass password manager. | |
| 30 | - | type PassStore struct { | |
| 31 | - | mu sync.Mutex | |
| 32 | - | timeout time.Duration | |
| 33 | - | log zerowrap.Logger | |
| 34 | - | } | |
| 35 | - | ||
| 36 | - | // NewPassStore creates a new pass-based domain secret store. | |
| 37 | - | func NewPassStore(log zerowrap.Logger) (*PassStore, error) { | |
| 38 | - | // Use a timeout so a stalled GPG agent or keyring does not hang startup. | |
| 39 | - | probeCtx, probeCancel := context.WithTimeout(context.Background(), 10*time.Second) | |
| 40 | - | defer probeCancel() | |
| 41 | - | if err := exec.CommandContext(probeCtx, "pass", "version").Run(); err != nil { //nolint:gosec // binary is a constant ("pass"), no user input | |
| 42 | - | return nil, fmt.Errorf("pass is not available: %w", err) | |
| 43 | - | } | |
| 44 | - | ||
| 45 | - | log.Debug(). | |
| 46 | - | Str(zerowrap.FieldLayer, "adapter"). | |
| 47 | - | Str(zerowrap.FieldAdapter, "domainsecrets"). | |
| 48 | - | Str("provider", "pass"). | |
| 49 | - | Msg("domain secret store initialized") | |
| 50 | - | ||
| 51 | - | return &PassStore{ | |
| 52 | - | timeout: 10 * time.Second, | |
| 53 | - | log: log, | |
| 54 | - | }, nil | |
| 55 | - | } | |
| 56 | - | ||
| 57 | - | // ListKeys returns the list of secret keys for a domain (not values). | |
| 58 | - | func (s *PassStore) ListKeys(domainName string) ([]string, error) { | |
| 59 | - | manifestPath, err := s.manifestPath(domainName) | |
| 60 | - | if err != nil { | |
| 61 | - | return nil, err | |
| 62 | - | } | |
| 63 | - | ||
| 64 | - | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |
| 65 | - | defer cancel() | |
| 66 | - | ||
| 67 | - | content, exists, err := s.passShow(ctx, manifestPath) | |
| 68 | - | if err != nil { | |
| 69 | - | return nil, err | |
| 70 | - | } | |
| 71 | - | keys := []string{} | |
| 72 | - | if exists { | |
| 73 | - | for _, line := range strings.Split(content, "\n") { | |
| 74 | - | key := strings.TrimSpace(line) | |
| 75 | - | if key == "" { | |
| 76 | - | continue | |
| 77 | - | } | |
| 78 | - | keys = append(keys, key) | |
| 79 | - | } | |
| 80 | - | } | |
| 81 | - | sort.Strings(keys) | |
| 82 | - | ||
| 83 | - | // Recover keys that may exist in pass but are missing from the manifest. | |
| 84 | - | discovered, err := s.listDomainKeys(domainName) | |
| 85 | - | if err != nil { | |
| 86 | - | return nil, err | |
| 87 | - | } | |
| 88 | - | ||
| 89 | - | merged, changed := mergeUniqueKeys(keys, discovered) | |
| 90 | - | if changed { | |
| 91 | - | // Best-effort self-heal of stale manifest. | |
| 92 | - | writeCtx, writeCancel := context.WithTimeout(context.Background(), s.timeout) | |
| 93 | - | writeErr := s.passInsert(writeCtx, manifestPath, strings.Join(merged, "\n")) | |
| 94 | - | writeCancel() | |
| 95 | - | if writeErr != nil { | |
| 96 | - | s.log.Warn(). | |
| 97 | - | Str(zerowrap.FieldLayer, "adapter"). | |
| 98 | - | Str(zerowrap.FieldAdapter, "domainsecrets"). | |
| 99 | - | Str("domain", domainName). | |
| 100 | - | Err(writeErr). | |
| 101 | - | Msg("failed to self-heal pass manifest, continuing with recovered keys") | |
| 102 | - | } | |
| 103 | - | } | |
| 104 | - | ||
| 105 | - | return merged, nil | |
| 106 | - | } | |
| 107 | - | ||
| 108 | - | // GetAll returns all secrets for a domain as a key-value map. | |
| 109 | - | func (s *PassStore) GetAll(domainName string) (map[string]string, error) { | |
| 110 | - | keys, err := s.ListKeys(domainName) | |
| 111 | - | if err != nil { | |
| 112 | - | return nil, err | |
| 113 | - | } | |
| 114 | - | ||
| 115 | - | secretsMap := make(map[string]string) | |
| 116 | - | for _, key := range keys { | |
| 117 | - | keyPath, err := s.keyPath(domainName, key) | |
| 118 | - | if err != nil { | |
| 119 | - | return nil, err | |
| 120 | - | } | |
| 121 | - | ||
| 122 | - | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |
| 123 | - | value, exists, err := s.passShow(ctx, keyPath) | |
| 124 | - | cancel() | |
| 125 | - | if err != nil { | |
| 126 | - | return nil, err | |
| 127 | - | } | |
| 128 | - | if !exists { | |
| 129 | - | s.log.Warn(). | |
| 130 | - | Str(zerowrap.FieldLayer, "adapter"). | |
| 131 | - | Str(zerowrap.FieldAdapter, "domainsecrets"). | |
| 132 | - | Str("domain", domainName). | |
| 133 | - | Str("key", key). | |
| 134 | - | Msg("secret listed in manifest but missing in pass") | |
| 135 | - | continue | |
| 136 | - | } | |
| 137 | - | secretsMap[key] = value | |
| 138 | - | } | |
| 139 | - | ||
| 140 | - | return secretsMap, nil | |
| 141 | - | } | |
| 142 | - | ||
| 143 | - | // SetIfEmpty sets multiple secrets for a domain only when no keys already exist. | |
| 144 | - | // It performs the precondition check inside the store adapter so migration | |
| 145 | - | // callers cannot accidentally split the check and write into separate steps. | |
| 146 | - | func (s *PassStore) SetIfEmpty(domainName string, secretsMap map[string]string) ([]string, error) { | |
| 147 | - | s.mu.Lock() | |
| 148 | - | defer s.mu.Unlock() | |
| 149 | - | ||
| 150 | - | existingKeys, err := s.ListKeys(domainName) | |
| 151 | - | if err != nil { | |
| 152 | - | return nil, err | |
| 153 | - | } | |
| 154 | - | if len(existingKeys) > 0 { | |
| 155 | - | return nil, fmt.Errorf("%w: pass secrets already exist for %s (found %d keys)", domain.ErrSecretsAlreadyExist, domainName, len(existingKeys)) | |
| 156 | - | } | |
| 157 | - | if err := s.Set(domainName, secretsMap); err != nil { | |
| 158 | - | return nil, err | |
| 159 | - | } | |
| 160 | - | return sortedMapKeys(secretsMap), nil | |
| 161 | - | } | |
| 162 | - | ||
| 163 | - | // Set sets or updates multiple secrets for a domain. | |
| 164 | - | func (s *PassStore) Set(domainName string, secretsMap map[string]string) error { | |
| 165 | - | if _, err := s.domainPath(domainName); err != nil { | |
| 166 | - | return err | |
| 167 | - | } | |
| 168 | - | ||
| 169 | - | existingKeys, err := s.ListKeys(domainName) | |
| 170 | - | if err != nil { | |
| 171 | - | return err | |
| 172 | - | } | |
| 173 | - | ||
| 174 | - | existingSet := make(map[string]struct{}, len(existingKeys)) | |
| 175 | - | for _, key := range existingKeys { | |
| 176 | - | existingSet[key] = struct{}{} | |
| 177 | - | } | |
| 178 | - | ||
| 179 | - | insertedNewPaths := make([]string, 0, len(secretsMap)) | |
| 180 | - | for key, value := range secretsMap { | |
| 181 | - | if err := domain.ValidateEnvKey(key); err != nil { | |
| 182 | - | return err | |
| 183 | - | } | |
| 184 | - | ||
| 185 | - | keyPath, err := s.keyPath(domainName, key) | |
| 186 | - | if err != nil { | |
| 187 | - | return err | |
| 188 | - | } | |
| 189 | - | ||
| 190 | - | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |
| 191 | - | err = s.passInsert(ctx, keyPath, value) | |
| 192 | - | cancel() | |
| 193 | - | if err != nil { | |
| 194 | - | s.cleanupInsertedPaths(insertedNewPaths) | |
| 195 | - | return fmt.Errorf("failed to store secret %s: %w", key, err) | |
| 196 | - | } | |
| 197 | - | ||
| 198 | - | if _, exists := existingSet[key]; !exists { | |
| 199 | - | insertedNewPaths = append(insertedNewPaths, keyPath) | |
| 200 | - | } | |
| 201 | - | } | |
| 202 | - | ||
| 203 | - | keySet := make(map[string]struct{}, len(existingKeys)+len(secretsMap)) | |
| 204 | - | for _, key := range existingKeys { | |
| 205 | - | keySet[key] = struct{}{} | |
| 206 | - | } | |
| 207 | - | for key := range secretsMap { | |
| 208 | - | keySet[key] = struct{}{} | |
| 209 | - | } | |
| 210 | - | ||
| 211 | - | keys := make([]string, 0, len(keySet)) | |
| 212 | - | for key := range keySet { | |
| 213 | - | keys = append(keys, key) | |
| 214 | - | } | |
| 215 | - | sort.Strings(keys) | |
| 216 | - | ||
| 217 | - | manifestPath, err := s.manifestPath(domainName) | |
| 218 | - | if err != nil { | |
| 219 | - | return err | |
| 220 | - | } | |
| 221 | - | ||
| 222 | - | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |
| 223 | - | if err := s.passInsert(ctx, manifestPath, strings.Join(keys, "\n")); err != nil { | |
| 224 | - | cancel() | |
| 225 | - | s.cleanupInsertedPaths(insertedNewPaths) | |
| 226 | - | return fmt.Errorf("failed to update manifest: %w", err) | |
| 227 | - | } | |
| 228 | - | cancel() | |
| 229 | - | ||
| 230 | - | return nil | |
| 231 | - | } | |
| 232 | - | ||
| 233 | - | // Delete removes a specific secret key from a domain. | |
| 234 | - | func (s *PassStore) Delete(domainName, key string) error { | |
| 235 | - | if _, err := s.domainPath(domainName); err != nil { | |
| 236 | - | return err | |
| 237 | - | } | |
| 238 | - | ||
| 239 | - | keyPath, err := s.keyPath(domainName, key) | |
| 240 | - | if err != nil { | |
| 241 | - | return err | |
| 242 | - | } | |
| 243 | - | ||
| 244 | - | rmCtx, rmCancel := context.WithTimeout(context.Background(), s.timeout) | |
| 245 | - | defer rmCancel() | |
| 246 | - | if err := s.passRemove(rmCtx, keyPath); err != nil { | |
| 247 | - | return err | |
| 248 | - | } | |
| 249 | - | ||
| 250 | - | keys, err := s.ListKeys(domainName) | |
| 251 | - | if err != nil { | |
| 252 | - | return err | |
| 253 | - | } | |
| 254 | - | ||
| 255 | - | updated := make([]string, 0, len(keys)) | |
| 256 | - | for _, existingKey := range keys { | |
| 257 | - | if existingKey == key { | |
| 258 | - | continue | |
| 259 | - | } | |
| 260 | - | updated = append(updated, existingKey) | |
| 261 | - | } | |
| 262 | - | sort.Strings(updated) | |
| 263 | - | ||
| 264 | - | manifestPath, err := s.manifestPath(domainName) | |
| 265 | - | if err != nil { | |
| 266 | - | return err | |
| 267 | - | } | |
| 268 | - | ||
| 269 | - | insertCtx, insertCancel := context.WithTimeout(context.Background(), s.timeout) | |
| 270 | - | defer insertCancel() | |
| 271 | - | if err := s.passInsert(insertCtx, manifestPath, strings.Join(updated, "\n")); err != nil { | |
| 272 | - | return fmt.Errorf("failed to update manifest: %w", err) | |
| 273 | - | } | |
| 274 | - | ||
| 275 | - | return nil | |
| 276 | - | } | |
| 277 | - | ||
| 278 | - | // SetAttachmentIfEmpty sets attachment secrets only when no keys already exist. | |
| 279 | - | // It performs the precondition check inside the store adapter so migration | |
| 280 | - | // callers cannot accidentally split the check and write into separate steps. | |
| 281 | - | func (s *PassStore) SetAttachmentIfEmpty(containerName string, secretsMap map[string]string) ([]string, error) { | |
| 282 | - | s.mu.Lock() | |
| 283 | - | defer s.mu.Unlock() | |
| 284 | - | ||
| 285 | - | existingKeys, err := s.listAttachmentKeysRecover(containerName) | |
| 286 | - | if err != nil { | |
| 287 | - | return nil, err | |
| 288 | - | } | |
| 289 | - | if len(existingKeys) > 0 { | |
| 290 | - | return nil, fmt.Errorf("%w: pass secrets already exist for attachment %s (found %d keys)", domain.ErrSecretsAlreadyExist, containerName, len(existingKeys)) | |
| 291 | - | } | |
| 292 | - | if err := s.SetAttachment(containerName, secretsMap); err != nil { | |
| 293 | - | return nil, err | |
| 294 | - | } | |
| 295 | - | return sortedMapKeys(secretsMap), nil | |
| 296 | - | } | |
| 297 | - | ||
| 298 | - | // SetAttachment sets or updates multiple secrets for an attachment container. | |
| 299 | - | func (s *PassStore) SetAttachment(containerName string, secretsMap map[string]string) error { | |
| 300 | - | if _, err := s.attachmentPath(containerName); err != nil { | |
| 301 | - | return err | |
| 302 | - | } | |
| 303 | - | ||
| 304 | - | existingKeys, err := s.listAttachmentKeys(containerName) | |
| 305 | - | if err != nil { | |
| 306 | - | return err | |
| 307 | - | } | |
| 308 | - | ||
| 309 | - | existingSet := make(map[string]struct{}, len(existingKeys)) | |
| 310 | - | for _, key := range existingKeys { | |
| 311 | - | existingSet[key] = struct{}{} | |
| 312 | - | } | |
| 313 | - | ||
| 314 | - | insertedNewPaths := make([]string, 0, len(secretsMap)) | |
| 315 | - | for key, value := range secretsMap { | |
| 316 | - | if err := domain.ValidateEnvKey(key); err != nil { | |
| 317 | - | return err | |
| 318 | - | } | |
| 319 | - | ||
| 320 | - | keyPath, err := s.attachmentKeyPath(containerName, key) | |
| 321 | - | if err != nil { | |
| 322 | - | return err | |
| 323 | - | } | |
| 324 | - | ||
| 325 | - | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |
| 326 | - | err = s.passInsert(ctx, keyPath, value) | |
| 327 | - | cancel() | |
| 328 | - | if err != nil { | |
| 329 | - | s.cleanupInsertedPaths(insertedNewPaths) | |
| 330 | - | return fmt.Errorf("failed to store attachment secret %s: %w", key, err) | |
| 331 | - | } | |
| 332 | - | ||
| 333 | - | if _, exists := existingSet[key]; !exists { | |
| 334 | - | insertedNewPaths = append(insertedNewPaths, keyPath) | |
| 335 | - | } | |
| 336 | - | } | |
| 337 | - | ||
| 338 | - | keySet := make(map[string]struct{}, len(existingKeys)+len(secretsMap)) | |
| 339 | - | for _, key := range existingKeys { | |
| 340 | - | keySet[key] = struct{}{} | |
| 341 | - | } | |
| 342 | - | for key := range secretsMap { | |
| 343 | - | keySet[key] = struct{}{} | |
| 344 | - | } | |
| 345 | - | ||
| 346 | - | keys := make([]string, 0, len(keySet)) | |
| 347 | - | for key := range keySet { | |
| 348 | - | keys = append(keys, key) | |
| 349 | - | } | |
| 350 | - | sort.Strings(keys) | |
| 351 | - | ||
| 352 | - | manifestPath, err := s.attachmentManifestPath(containerName) | |
| 353 | - | if err != nil { | |
| 354 | - | return err | |
| 355 | - | } | |
| 356 | - | ||
| 357 | - | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |
| 358 | - | if err := s.passInsert(ctx, manifestPath, strings.Join(keys, "\n")); err != nil { | |
| 359 | - | cancel() | |
| 360 | - | s.cleanupInsertedPaths(insertedNewPaths) | |
| 361 | - | return fmt.Errorf("failed to update attachment manifest: %w", err) | |
| 362 | - | } | |
| 363 | - | cancel() | |
| 364 | - | ||
| 365 | - | return nil | |
| 366 | - | } | |
| 367 | - | ||
| 368 | - | // DeleteAttachment removes a specific secret key from an attachment container. | |
| 369 | - | func (s *PassStore) DeleteAttachment(containerName, key string) error { | |
| 370 | - | if _, err := s.attachmentPath(containerName); err != nil { | |
| 371 | - | return err | |
| 372 | - | } | |
| 373 | - | ||
| 374 | - | keyPath, err := s.attachmentKeyPath(containerName, key) | |
| 375 | - | if err != nil { | |
| 376 | - | return err | |
| 377 | - | } | |
| 378 | - | ||
| 379 | - | rmCtx, rmCancel := context.WithTimeout(context.Background(), s.timeout) | |
| 380 | - | defer rmCancel() | |
| 381 | - | if err := s.passRemove(rmCtx, keyPath); err != nil { | |
| 382 | - | return err | |
| 383 | - | } | |
| 384 | - | ||
| 385 | - | keys, err := s.listAttachmentKeys(containerName) | |
| 386 | - | if err != nil { | |
| 387 | - | return err | |
| 388 | - | } | |
| 389 | - | ||
| 390 | - | updated := make([]string, 0, len(keys)) | |
| 391 | - | for _, existingKey := range keys { | |
| 392 | - | if existingKey == key { | |
| 393 | - | continue | |
| 394 | - | } | |
| 395 | - | updated = append(updated, existingKey) | |
| 396 | - | } | |
| 397 | - | sort.Strings(updated) | |
| 398 | - | ||
| 399 | - | manifestPath, err := s.attachmentManifestPath(containerName) | |
| 400 | - | if err != nil { | |
| 401 | - | return err | |
| 402 | - | } | |
| 403 | - | ||
| 404 | - | insertCtx, insertCancel := context.WithTimeout(context.Background(), s.timeout) | |
| 405 | - | defer insertCancel() | |
| 406 | - | if err := s.passInsert(insertCtx, manifestPath, strings.Join(updated, "\n")); err != nil { | |
| 407 | - | return fmt.Errorf("failed to update attachment manifest: %w", err) | |
| 408 | - | } | |
| 409 | - | ||
| 410 | - | return nil | |
| 411 | - | } | |
| 412 | - | ||
| 413 | - | // GetAllAttachment returns all secrets for an attachment container as a key-value map. | |
| 414 | - | func (s *PassStore) GetAllAttachment(containerName string) (map[string]string, error) { | |
| 415 | - | keys, err := s.listAttachmentKeys(containerName) | |
| 416 | - | if err != nil { | |
| 417 | - | return nil, err | |
| 418 | - | } | |
| 419 | - | ||
| 420 | - | secretsMap := make(map[string]string) | |
| 421 | - | for _, key := range keys { | |
| 422 | - | keyPath, err := s.attachmentKeyPath(containerName, key) | |
| 423 | - | if err != nil { | |
| 424 | - | return nil, err | |
| 425 | - | } | |
| 426 | - | ||
| 427 | - | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |
| 428 | - | value, exists, err := s.passShow(ctx, keyPath) | |
| 429 | - | cancel() | |
| 430 | - | if err != nil { | |
| 431 | - | return nil, err | |
| 432 | - | } | |
| 433 | - | if !exists { | |
| 434 | - | s.log.Warn(). | |
| 435 | - | Str(zerowrap.FieldLayer, "adapter"). | |
| 436 | - | Str(zerowrap.FieldAdapter, "domainsecrets"). | |
| 437 | - | Str("container", containerName). | |
| 438 | - | Str("key", key). | |
| 439 | - | Msg("attachment secret listed in manifest but missing in pass") | |
| 440 | - | continue | |
| 441 | - | } | |
| 442 | - | secretsMap[key] = value | |
| 443 | - | } | |
| 444 | - | ||
| 445 | - | return secretsMap, nil | |
| 446 | - | } | |
| 447 | - | ||
| 448 | - | // ListAttachmentKeys finds attachment secrets for a domain from pass. | |
| 449 | - | // Supports both new (collision-resistant) and legacy container naming for backwards compatibility. | |
| 450 | - | func (s *PassStore) ListAttachmentKeys(domainName string) ([]out.AttachmentSecrets, error) { | |
| 451 | - | if _, err := s.domainPath(domainName); err != nil { | |
| 452 | - | return nil, err | |
| 453 | - | } | |
| 454 | - | ||
| 455 | - | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |
| 456 | - | defer cancel() | |
| 457 | - | ||
| 458 | - | containers, err := s.listTopLevelEntries(ctx, PassAttachmentPath) | |
| 459 | - | if err != nil { | |
| 460 | - | return nil, err | |
| 461 | - | } | |
| 462 | - | ||
| 463 | - | // Try both new and legacy sanitization for backwards compatibility | |
| 464 | - | sanitizedDomain := domain.SanitizeDomainForContainer(domainName) | |
| 465 | - | sanitizedDomainLegacy := domain.SanitizeDomainForContainerLegacy(domainName) | |
| 466 | - | prefixes := []string{ | |
| 467 | - | "gordon-" + sanitizedDomain + "-", // New format (collision-resistant) | |
| 468 | - | "gordon-" + sanitizedDomainLegacy + "-", // Old format (buggy but backwards compatible) | |
| 469 | - | } | |
| 470 | - | ||
| 471 | - | seen := make(map[string]bool) | |
| 472 | - | var results []out.AttachmentSecrets | |
| 473 | - | for _, containerName := range containers { | |
| 474 | - | matches := false | |
| 475 | - | for _, prefix := range prefixes { | |
| 476 | - | if strings.HasPrefix(containerName, prefix) { | |
| 477 | - | matches = true | |
| 478 | - | break | |
| 479 | - | } | |
| 480 | - | } | |
| 481 | - | if !matches { | |
| 482 | - | continue | |
| 483 | - | } | |
| 484 | - | ||
| 485 | - | // Deduplicate results | |
| 486 | - | if seen[containerName] { | |
| 487 | - | continue | |
| 488 | - | } | |
| 489 | - | seen[containerName] = true | |
| 490 | - | ||
| 491 | - | manifestPath := fmt.Sprintf("%s/%s/.keys", PassAttachmentPath, containerName) | |
| 492 | - | if err := secrets.ValidatePath(manifestPath); err != nil { | |
| 493 | - | return nil, err | |
| 494 | - | } | |
| 495 | - | ||
| 496 | - | showCtx, showCancel := context.WithTimeout(context.Background(), s.timeout) | |
| 497 | - | content, exists, err := s.passShow(showCtx, manifestPath) | |
| 498 | - | showCancel() | |
| 499 | - | if err != nil { | |
| 500 | - | return nil, err | |
| 501 | - | } | |
| 502 | - | if !exists { | |
| 503 | - | continue | |
| 504 | - | } | |
| 505 | - | ||
| 506 | - | keys := []string{} | |
| 507 | - | for _, line := range strings.Split(content, "\n") { | |
| 508 | - | key := strings.TrimSpace(line) | |
| 509 | - | if key == "" { | |
| 510 | - | continue | |
| 511 | - | } | |
| 512 | - | keys = append(keys, key) | |
| 513 | - | } | |
| 514 | - | ||
| 515 | - | if len(keys) > 0 { | |
| 516 | - | results = append(results, out.AttachmentSecrets{ | |
| 517 | - | Service: containerName, | |
| 518 | - | Keys: keys, | |
| 519 | - | }) | |
| 520 | - | } | |
| 521 | - | } | |
| 522 | - | ||
| 523 | - | return results, nil | |
| 524 | - | } | |
| 525 | - | ||
| 526 | - | func (s *PassStore) domainPath(domainName string) (string, error) { | |
| 527 | - | safeDomain, err := s.sanitizeDomain(domainName) | |
| 528 | - | if err != nil { | |
| 529 | - | return "", err | |
| 530 | - | } | |
| 531 | - | path := fmt.Sprintf("%s/%s", PassDomainSecretsPath, safeDomain) | |
| 532 | - | if err := secrets.ValidatePath(path); err != nil { | |
| 533 | - | return "", err | |
| 534 | - | } | |
| 535 | - | return path, nil | |
| 536 | - | } | |
| 537 | - | ||
| 538 | - | func (s *PassStore) keyPath(domainName, key string) (string, error) { | |
| 539 | - | domainPath, err := s.domainPath(domainName) | |
| 540 | - | if err != nil { | |
| 541 | - | return "", err | |
| 542 | - | } | |
| 543 | - | if err := domain.ValidateEnvKey(key); err != nil { | |
| 544 | - | return "", err | |
| 545 | - | } | |
| 546 | - | path := fmt.Sprintf("%s/%s", domainPath, key) | |
| 547 | - | if err := secrets.ValidatePath(path); err != nil { | |
| 548 | - | return "", err | |
| 549 | - | } | |
| 550 | - | return path, nil | |
| 551 | - | } | |
| 552 | - | ||
| 553 | - | func (s *PassStore) manifestPath(domainName string) (string, error) { | |
| 554 | - | domainPath, err := s.domainPath(domainName) | |
| 555 | - | if err != nil { | |
| 556 | - | return "", err | |
| 557 | - | } | |
| 558 | - | path := fmt.Sprintf("%s/.keys", domainPath) | |
| 559 | - | if err := secrets.ValidatePath(path); err != nil { | |
| 560 | - | return "", err | |
| 561 | - | } | |
| 562 | - | return path, nil | |
| 563 | - | } | |
| 564 | - | ||
| 565 | - | func (s *PassStore) listDomainKeys(domainName string) ([]string, error) { | |
| 566 | - | basePath, err := s.domainPath(domainName) | |
| 567 | - | if err != nil { | |
| 568 | - | return nil, err | |
| 569 | - | } | |
| 570 | - | ||
| 571 | - | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |
| 572 | - | defer cancel() | |
| 573 | - | ||
| 574 | - | entries, err := s.listTopLevelEntries(ctx, basePath) | |
| 575 | - | if err != nil { | |
| 576 | - | return nil, err | |
| 577 | - | } | |
| 578 | - | ||
| 579 | - | keys := make([]string, 0, len(entries)) | |
| 580 | - | for _, entry := range entries { | |
| 581 | - | if entry == ".keys" { | |
| 582 | - | continue | |
| 583 | - | } | |
| 584 | - | if err := domain.ValidateEnvKey(entry); err != nil { | |
| 585 | - | continue | |
| 586 | - | } | |
| 587 | - | keys = append(keys, entry) | |
| 588 | - | } | |
| 589 | - | ||
| 590 | - | sort.Strings(keys) | |
| 591 | - | return keys, nil | |
| 592 | - | } | |
| 593 | - | ||
| 594 | - | func (s *PassStore) attachmentPath(containerName string) (string, error) { | |
| 595 | - | if err := domain.ValidateContainerName(containerName); err != nil { | |
| 596 | - | return "", err | |
| 597 | - | } | |
| 598 | - | path := fmt.Sprintf("%s/%s", PassAttachmentPath, containerName) | |
| 599 | - | if err := secrets.ValidatePath(path); err != nil { | |
| 600 | - | return "", err | |
| 601 | - | } | |
| 602 | - | return path, nil | |
| 603 | - | } | |
| 604 | - | ||
| 605 | - | func (s *PassStore) attachmentKeyPath(containerName, key string) (string, error) { | |
| 606 | - | attachmentPath, err := s.attachmentPath(containerName) | |
| 607 | - | if err != nil { | |
| 608 | - | return "", err | |
| 609 | - | } | |
| 610 | - | if err := domain.ValidateEnvKey(key); err != nil { | |
| 611 | - | return "", err | |
| 612 | - | } | |
| 613 | - | path := fmt.Sprintf("%s/%s", attachmentPath, key) | |
| 614 | - | if err := secrets.ValidatePath(path); err != nil { | |
| 615 | - | return "", err | |
| 616 | - | } | |
| 617 | - | return path, nil | |
| 618 | - | } | |
| 619 | - | ||
| 620 | - | func (s *PassStore) attachmentManifestPath(containerName string) (string, error) { | |
| 621 | - | attachmentPath, err := s.attachmentPath(containerName) | |
| 622 | - | if err != nil { | |
| 623 | - | return "", err | |
| 624 | - | } | |
| 625 | - | path := fmt.Sprintf("%s/.keys", attachmentPath) | |
| 626 | - | if err := secrets.ValidatePath(path); err != nil { | |
| 627 | - | return "", err | |
| 628 | - | } | |
| 629 | - | return path, nil | |
| 630 | - | } | |
| 631 | - | ||
| 632 | - | func (s *PassStore) listAttachmentKeys(containerName string) ([]string, error) { | |
| 633 | - | manifestPath, err := s.attachmentManifestPath(containerName) | |
| 634 | - | if err != nil { | |
| 635 | - | return nil, err | |
| 636 | - | } | |
| 637 | - | ||
| 638 | - | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |
| 639 | - | defer cancel() | |
| 640 | - | ||
| 641 | - | content, exists, err := s.passShow(ctx, manifestPath) | |
| 642 | - | if err != nil { | |
| 643 | - | return nil, err | |
| 644 | - | } | |
| 645 | - | if !exists { | |
| 646 | - | return []string{}, nil | |
| 647 | - | } | |
| 648 | - | ||
| 649 | - | keys := []string{} | |
| 650 | - | for _, line := range strings.Split(content, "\n") { | |
| 651 | - | key := strings.TrimSpace(line) | |
| 652 | - | if key == "" { | |
| 653 | - | continue | |
| 654 | - | } | |
| 655 | - | keys = append(keys, key) | |
| 656 | - | } | |
| 657 | - | ||
| 658 | - | sort.Strings(keys) | |
| 659 | - | return keys, nil | |
| 660 | - | } | |
| 661 | - | ||
| 662 | - | func (s *PassStore) listAttachmentKeysRecover(containerName string) ([]string, error) { | |
| 663 | - | keys, err := s.listAttachmentKeys(containerName) | |
| 664 | - | if err != nil { | |
| 665 | - | return nil, err | |
| 666 | - | } | |
| 667 | - | discovered, err := s.discoverAttachmentKeys(containerName) | |
| 668 | - | if err != nil { | |
| 669 | - | return nil, err | |
| 670 | - | } | |
| 671 | - | merged, changed := mergeUniqueKeys(keys, discovered) | |
| 672 | - | if changed { | |
| 673 | - | manifestPath, err := s.attachmentManifestPath(containerName) | |
| 674 | - | if err != nil { | |
| 675 | - | return nil, err | |
| 676 | - | } | |
| 677 | - | writeCtx, writeCancel := context.WithTimeout(context.Background(), s.timeout) | |
| 678 | - | writeErr := s.passInsert(writeCtx, manifestPath, strings.Join(merged, "\n")) | |
| 679 | - | writeCancel() | |
| 680 | - | if writeErr != nil { | |
| 681 | - | s.log.Warn(). | |
| 682 | - | Str(zerowrap.FieldLayer, "adapter"). | |
| 683 | - | Str(zerowrap.FieldAdapter, "domainsecrets"). | |
| 684 | - | Str("container", containerName). | |
| 685 | - | Err(writeErr). | |
| 686 | - | Msg("failed to self-heal attachment pass manifest, continuing with recovered keys") | |
| 687 | - | } | |
| 688 | - | } | |
| 689 | - | return merged, nil | |
| 690 | - | } | |
| 691 | - | ||
| 692 | - | func (s *PassStore) discoverAttachmentKeys(containerName string) ([]string, error) { | |
| 693 | - | basePath, err := s.attachmentPath(containerName) | |
| 694 | - | if err != nil { | |
| 695 | - | return nil, err | |
| 696 | - | } | |
| 697 | - | ||
| 698 | - | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |
| 699 | - | defer cancel() | |
| 700 | - | ||
| 701 | - | entries, err := s.listTopLevelEntries(ctx, basePath) | |
| 702 | - | if err != nil { | |
| 703 | - | return nil, err | |
| 704 | - | } | |
| 705 | - | ||
| 706 | - | keys := make([]string, 0, len(entries)) | |
| 707 | - | for _, entry := range entries { | |
| 708 | - | if entry == ".keys" { | |
| 709 | - | continue | |
| 710 | - | } | |
| 711 | - | if err := domain.ValidateEnvKey(entry); err != nil { | |
| 712 | - | continue | |
| 713 | - | } | |
| 714 | - | keys = append(keys, entry) | |
| 715 | - | } | |
| 716 | - | ||
| 717 | - | sort.Strings(keys) | |
| 718 | - | return keys, nil | |
| 719 | - | } | |
| 720 | - | ||
| 721 | - | func (s *PassStore) sanitizeDomain(domainName string) (string, error) { | |
| 722 | - | safeDomain, err := domain.SanitizeDomainForEnvFile(domainName) | |
| 723 | - | if err != nil { | |
| 724 | - | s.log.Warn(). | |
| 725 | - | Str(zerowrap.FieldLayer, "adapter"). | |
| 726 | - | Str(zerowrap.FieldAdapter, "domainsecrets"). | |
| 727 | - | Str("domain", domainName). | |
| 728 | - | Err(err). | |
| 729 | - | Msg("rejected invalid domain") | |
| 730 | - | return "", domain.ErrPathTraversal | |
| 731 | - | } | |
| 732 | - | return safeDomain, nil | |
| 733 | - | } | |
| 734 | - | ||
| 735 | - | // ManifestExists checks if the manifest exists for a domain. | |
| 736 | - | func (s *PassStore) ManifestExists(domainName string) (bool, error) { | |
| 737 | - | manifestPath, err := s.manifestPath(domainName) | |
| 738 | - | if err != nil { | |
| 739 | - | return false, err | |
| 740 | - | } | |
| 741 | - | ||
| 742 | - | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |
| 743 | - | defer cancel() | |
| 744 | - | ||
| 745 | - | _, exists, err := s.passShow(ctx, manifestPath) | |
| 746 | - | if err != nil { | |
| 747 | - | return false, err | |
| 748 | - | } | |
| 749 | - | ||
| 750 | - | return exists, nil | |
| 751 | - | } | |
| 752 | - | ||
| 753 | - | func (s *PassStore) passInsert(ctx context.Context, path, value string) error { | |
| 754 | - | cmd := exec.CommandContext(ctx, "pass", "insert", "-m", "-f", path) //nolint:gosec // binary is constant ("pass"); path arguments validated by secrets path validator | |
| 755 | - | cmd.Stdin = strings.NewReader(value) | |
| 756 | - | output, err := cmd.CombinedOutput() | |
| 757 | - | if err != nil { | |
| 758 | - | return fmt.Errorf("pass insert failed: %s: %w", strings.TrimSpace(string(output)), err) | |
| 759 | - | } | |
| 760 | - | return nil | |
| 761 | - | } | |
| 762 | - | ||
| 763 | - | func (s *PassStore) passRemove(ctx context.Context, path string) error { | |
| 764 | - | cmd := exec.CommandContext(ctx, "pass", "rm", "-f", path) //nolint:gosec // binary is constant ("pass"); path arguments validated by secrets path validator | |
| 765 | - | output, err := cmd.CombinedOutput() | |
| 766 | - | if err != nil { | |
| 767 | - | if passEntryMissing(string(output)) { | |
| 768 | - | return nil | |
| 769 | - | } | |
| 770 | - | return fmt.Errorf("pass rm failed: %s: %w", strings.TrimSpace(string(output)), err) | |
| 771 | - | } | |
| 772 | - | return nil | |
| 773 | - | } | |
| 774 | - | ||
| 775 | - | func (s *PassStore) passShow(ctx context.Context, path string) (string, bool, error) { | |
| 776 | - | cmd := exec.CommandContext(ctx, "pass", "show", path) //nolint:gosec // binary is constant ("pass"); path arguments validated by secrets path validator | |
| 777 | - | output, err := cmd.CombinedOutput() | |
| 778 | - | if err != nil { | |
| 779 | - | if passEntryMissing(string(output)) { | |
| 780 | - | return "", false, nil | |
| 781 | - | } | |
| 782 | - | return "", false, fmt.Errorf("pass show failed: %s: %w", strings.TrimSpace(string(output)), err) | |
| 783 | - | } | |
| 784 | - | ||
| 785 | - | clean := ansiRegex.ReplaceAllString(string(output), "") | |
| 786 | - | clean = strings.TrimRight(clean, "\r\n") | |
| 787 | - | return clean, true, nil | |
| 788 | - | } | |
| 789 | - | ||
| 790 | - | func (s *PassStore) listTopLevelEntries(ctx context.Context, basePath string) ([]string, error) { | |
| 791 | - | if err := secrets.ValidatePath(basePath); err != nil { | |
| 792 | - | return nil, err | |
| 793 | - | } | |
| 794 | - | ||
| 795 | - | cmd := exec.CommandContext(ctx, "pass", "ls", basePath) //nolint:gosec // binary is constant ("pass"); path arguments validated by secrets path validator | |
| 796 | - | output, err := cmd.CombinedOutput() | |
| 797 | - | if err != nil { | |
| 798 | - | if passEntryMissing(string(output)) { | |
| 799 | - | return []string{}, nil | |
| 800 | - | } | |
| 801 | - | return nil, fmt.Errorf("pass ls failed: %s: %w", strings.TrimSpace(string(output)), err) | |
| 802 | - | } | |
| 803 | - | ||
| 804 | - | entries := []string{} | |
| 805 | - | for _, entry := range parsePassListOutput(basePath, string(output)) { | |
| 806 | - | if entry.depth == 1 { | |
| 807 | - | entries = append(entries, entry.name) | |
| 808 | - | } | |
| 809 | - | } | |
| 810 | - | ||
| 811 | - | return entries, nil | |
| 812 | - | } | |
| 813 | - | ||
| 814 | - | type passListEntry struct { | |
| 815 | - | name string | |
| 816 | - | depth int | |
| 817 | - | } | |
| 818 | - | ||
| 819 | - | func parsePassListOutput(basePath, output string) []passListEntry { | |
| 820 | - | lines := strings.Split(output, "\n") | |
| 821 | - | entries := []passListEntry{} | |
| 822 | - | ||
| 823 | - | for _, line := range lines { | |
| 824 | - | line = ansiRegex.ReplaceAllString(line, "") | |
| 825 | - | if strings.TrimSpace(line) == "" { | |
| 826 | - | continue | |
| 827 | - | } | |
| 828 | - | ||
| 829 | - | depth := 0 | |
| 830 | - | prefixLoop: | |
| 831 | - | for { | |
| 832 | - | switch { | |
| 833 | - | case strings.HasPrefix(line, "│ "): | |
| 834 | - | line = strings.TrimPrefix(line, "│ ") | |
| 835 | - | depth++ | |
| 836 | - | case strings.HasPrefix(line, "| "): | |
| 837 | - | line = strings.TrimPrefix(line, "| ") | |
| 838 | - | depth++ | |
| 839 | - | case strings.HasPrefix(line, " "): | |
| 840 | - | line = strings.TrimPrefix(line, " ") | |
| 841 | - | depth++ | |
| 842 | - | default: | |
| 843 | - | break prefixLoop | |
| 844 | - | } | |
| 845 | - | } | |
| 846 | - | ||
| 847 | - | switch { | |
| 848 | - | case strings.HasPrefix(line, "├── "): | |
| 849 | - | line = strings.TrimPrefix(line, "├── ") | |
| 850 | - | depth++ | |
| 851 | - | case strings.HasPrefix(line, "└── "): | |
| 852 | - | line = strings.TrimPrefix(line, "└── ") | |
| 853 | - | depth++ | |
| 854 | - | case strings.HasPrefix(line, "|-- "): | |
| 855 | - | line = strings.TrimPrefix(line, "|-- ") | |
| 856 | - | depth++ | |
| 857 | - | case strings.HasPrefix(line, "`-- "): | |
| 858 | - | line = strings.TrimPrefix(line, "`-- ") | |
| 859 | - | depth++ | |
| 860 | - | } | |
| 861 | - | ||
| 862 | - | name := strings.TrimSpace(line) | |
| 863 | - | if name == "" || name == basePath { | |
| 864 | - | continue | |
| 865 | - | } | |
| 866 | - | if depth == 0 { | |
| 867 | - | continue | |
| 868 | - | } | |
| 869 | - | ||
| 870 | - | entries = append(entries, passListEntry{name: name, depth: depth}) | |
| 871 | - | } | |
| 872 | - | ||
| 873 | - | return entries | |
| 874 | - | } | |
| 875 | - | ||
| 876 | - | func passEntryMissing(output string) bool { | |
| 877 | - | clean := ansiRegex.ReplaceAllString(output, "") | |
| 878 | - | lower := strings.ToLower(clean) | |
| 879 | - | return strings.Contains(lower, "not in the password store") || | |
| 880 | - | strings.Contains(lower, "password store is empty") | |
| 881 | - | } | |
| 882 | - | ||
| 883 | - | func sortedMapKeys(values map[string]string) []string { | |
| 884 | - | keys := make([]string, 0, len(values)) | |
| 885 | - | for key := range values { | |
| 886 | - | keys = append(keys, key) | |
| 887 | - | } | |
| 888 | - | sort.Strings(keys) | |
| 889 | - | return keys | |
| 890 | - | } | |
| 891 | - | ||
| 892 | - | func mergeUniqueKeys(primary, secondary []string) ([]string, bool) { | |
| 893 | - | mergedSet := make(map[string]struct{}, len(primary)+len(secondary)) | |
| 894 | - | for _, key := range primary { | |
| 895 | - | mergedSet[key] = struct{}{} | |
| 896 | - | } | |
| 897 | - | for _, key := range secondary { | |
| 898 | - | mergedSet[key] = struct{}{} | |
| 899 | - | } | |
| 900 | - | ||
| 901 | - | merged := make([]string, 0, len(mergedSet)) | |
| 902 | - | for key := range mergedSet { | |
| 903 | - | merged = append(merged, key) | |
| 904 | - | } | |
| 905 | - | sort.Strings(merged) | |
| 906 | - | ||
| 907 | - | if len(merged) != len(primary) { | |
| 908 | - | return merged, true | |
| 909 | - | } | |
| 910 | - | ||
| 911 | - | for i := range primary { | |
| 912 | - | if primary[i] != merged[i] { | |
| 913 | - | return merged, true | |
| 914 | - | } | |
| 915 | - | } | |
| 916 | - | ||
| 917 | - | return merged, false | |
| 918 | - | } | |
| 919 | - | ||
| 920 | - | func (s *PassStore) cleanupInsertedPaths(paths []string) { | |
| 921 | - | if len(paths) == 0 { | |
| 922 | - | return | |
| 923 | - | } | |
| 924 | - | ||
| 925 | - | for _, path := range paths { | |
| 926 | - | // Wrap in a closure so defer cancel() runs at the end of each iteration, | |
| 927 | - | // not at the end of cleanupInsertedPaths (which would delay all cancels). | |
| 928 | - | func(p string) { | |
| 929 | - | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |
| 930 | - | defer cancel() | |
| 931 | - | _ = s.passRemove(ctx, p) | |
| 932 | - | }(path) | |
| 933 | - | } | |
| 934 | - | } | |
D internal/adapters/out/domainsecrets/pass_store_test.go +0 -330
| @@ -1,330 +0,0 @@ | |||
| 1 | - | package domainsecrets | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "context" | |
| 5 | - | "fmt" | |
| 6 | - | "os/exec" | |
| 7 | - | "strings" | |
| 8 | - | "testing" | |
| 9 | - | "time" | |
| 10 | - | ||
| 11 | - | "github.com/stretchr/testify/assert" | |
| 12 | - | "github.com/stretchr/testify/require" | |
| 13 | - | ||
| 14 | - | "github.com/bnema/gordon/internal/domain" | |
| 15 | - | ) | |
| 16 | - | ||
| 17 | - | func passCmd(args ...string) error { | |
| 18 | - | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) | |
| 19 | - | defer cancel() | |
| 20 | - | return exec.CommandContext(ctx, "pass", args...).Run() | |
| 21 | - | } | |
| 22 | - | ||
| 23 | - | func passInsertValue(path, value string) error { | |
| 24 | - | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) | |
| 25 | - | defer cancel() | |
| 26 | - | cmd := exec.CommandContext(ctx, "pass", "insert", "-m", "-f", path) | |
| 27 | - | cmd.Stdin = strings.NewReader(value) | |
| 28 | - | _, err := cmd.CombinedOutput() | |
| 29 | - | return err | |
| 30 | - | } | |
| 31 | - | ||
| 32 | - | func passShow(path string) (string, error) { | |
| 33 | - | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) | |
| 34 | - | defer cancel() | |
| 35 | - | out, err := exec.CommandContext(ctx, "pass", "show", path).CombinedOutput() | |
| 36 | - | if err != nil { | |
| 37 | - | return "", err | |
| 38 | - | } | |
| 39 | - | return strings.TrimSpace(string(out)), nil | |
| 40 | - | } | |
| 41 | - | ||
| 42 | - | func requirePass(t *testing.T) { | |
| 43 | - | if err := passCmd("version"); err != nil { | |
| 44 | - | t.Skip("pass not available") | |
| 45 | - | } | |
| 46 | - | if err := passCmd("ls"); err != nil { | |
| 47 | - | t.Skip("pass store not initialized") | |
| 48 | - | } | |
| 49 | - | } | |
| 50 | - | ||
| 51 | - | func cleanupPassDomain(_ *testing.T, domainName string, keys []string) { | |
| 52 | - | safeDomain, err := domain.SanitizeDomainForEnvFile(domainName) | |
| 53 | - | if err != nil { | |
| 54 | - | return | |
| 55 | - | } | |
| 56 | - | ||
| 57 | - | for _, key := range keys { | |
| 58 | - | path := fmt.Sprintf("%s/%s/%s", PassDomainSecretsPath, safeDomain, key) | |
| 59 | - | _ = passCmd("rm", "-f", path) | |
| 60 | - | } | |
| 61 | - | ||
| 62 | - | manifestPath := fmt.Sprintf("%s/%s/.keys", PassDomainSecretsPath, safeDomain) | |
| 63 | - | _ = passCmd("rm", "-f", manifestPath) | |
| 64 | - | } | |
| 65 | - | ||
| 66 | - | func TestPassStore_SetGetDelete(t *testing.T) { | |
| 67 | - | requirePass(t) | |
| 68 | - | ||
| 69 | - | store, err := NewPassStore(testLogger()) | |
| 70 | - | require.NoError(t, err) | |
| 71 | - | ||
| 72 | - | domainName := fmt.Sprintf("test.%d.example.com", time.Now().UnixNano()) | |
| 73 | - | keys := []string{"API_KEY", "DB_PASSWORD"} | |
| 74 | - | defer cleanupPassDomain(t, domainName, keys) | |
| 75 | - | ||
| 76 | - | secretsMap := map[string]string{ | |
| 77 | - | "API_KEY": "alpha", | |
| 78 | - | "DB_PASSWORD": "bravo", | |
| 79 | - | } | |
| 80 | - | ||
| 81 | - | err = store.Set(domainName, secretsMap) | |
| 82 | - | require.NoError(t, err) | |
| 83 | - | ||
| 84 | - | keysList, err := store.ListKeys(domainName) | |
| 85 | - | require.NoError(t, err) | |
| 86 | - | assert.Len(t, keysList, 2) | |
| 87 | - | assert.ElementsMatch(t, keys, keysList) | |
| 88 | - | ||
| 89 | - | values, err := store.GetAll(domainName) | |
| 90 | - | require.NoError(t, err) | |
| 91 | - | assert.Equal(t, "alpha", values["API_KEY"]) | |
| 92 | - | assert.Equal(t, "bravo", values["DB_PASSWORD"]) | |
| 93 | - | ||
| 94 | - | err = store.Delete(domainName, "DB_PASSWORD") | |
| 95 | - | require.NoError(t, err) | |
| 96 | - | ||
| 97 | - | keysList, err = store.ListKeys(domainName) | |
| 98 | - | require.NoError(t, err) | |
| 99 | - | assert.ElementsMatch(t, []string{"API_KEY"}, keysList) | |
| 100 | - | } | |
| 101 | - | ||
| 102 | - | func TestPassStore_SetGetAttachment(t *testing.T) { | |
| 103 | - | requirePass(t) | |
| 104 | - | ||
| 105 | - | store, err := NewPassStore(testLogger()) | |
| 106 | - | require.NoError(t, err) | |
| 107 | - | ||
| 108 | - | containerName := fmt.Sprintf("gitea-postgres-%d", time.Now().UnixNano()) | |
| 109 | - | keys := []string{"POSTGRES_USER", "POSTGRES_PASSWORD"} | |
| 110 | - | defer CleanupPassAttachment(t, containerName, keys) | |
| 111 | - | ||
| 112 | - | secretsMap := map[string]string{ | |
| 113 | - | "POSTGRES_USER": "gitea", | |
| 114 | - | "POSTGRES_PASSWORD": "secret123", | |
| 115 | - | } | |
| 116 | - | ||
| 117 | - | err = store.SetAttachment(containerName, secretsMap) | |
| 118 | - | require.NoError(t, err) | |
| 119 | - | ||
| 120 | - | values, err := store.GetAllAttachment(containerName) | |
| 121 | - | require.NoError(t, err) | |
| 122 | - | assert.Equal(t, "gitea", values["POSTGRES_USER"]) | |
| 123 | - | assert.Equal(t, "secret123", values["POSTGRES_PASSWORD"]) | |
| 124 | - | } | |
| 125 | - | ||
| 126 | - | func TestPassStore_ListAttachmentKeys_AfterSet(t *testing.T) { | |
| 127 | - | requirePass(t) | |
| 128 | - | ||
| 129 | - | store, err := NewPassStore(testLogger()) | |
| 130 | - | require.NoError(t, err) | |
| 131 | - | ||
| 132 | - | containerName := fmt.Sprintf("redis-cache-%d", time.Now().UnixNano()) | |
| 133 | - | keys := []string{"REDIS_PASSWORD"} | |
| 134 | - | defer CleanupPassAttachment(t, containerName, keys) | |
| 135 | - | ||
| 136 | - | secretsMap := map[string]string{ | |
| 137 | - | "REDIS_PASSWORD": "redis123", | |
| 138 | - | } | |
| 139 | - | ||
| 140 | - | err = store.SetAttachment(containerName, secretsMap) | |
| 141 | - | require.NoError(t, err) | |
| 142 | - | ||
| 143 | - | values, err := store.GetAllAttachment(containerName) | |
| 144 | - | require.NoError(t, err) | |
| 145 | - | assert.Len(t, values, 1) | |
| 146 | - | assert.Equal(t, "redis123", values["REDIS_PASSWORD"]) | |
| 147 | - | } | |
| 148 | - | ||
| 149 | - | func TestPassStore_DeleteAttachment(t *testing.T) { | |
| 150 | - | requirePass(t) | |
| 151 | - | ||
| 152 | - | store, err := NewPassStore(testLogger()) | |
| 153 | - | require.NoError(t, err) | |
| 154 | - | ||
| 155 | - | containerName := fmt.Sprintf("gitea-postgres-%d", time.Now().UnixNano()) | |
| 156 | - | keys := []string{"POSTGRES_USER", "POSTGRES_PASSWORD"} | |
| 157 | - | defer CleanupPassAttachment(t, containerName, keys) | |
| 158 | - | ||
| 159 | - | // Set 2 secrets | |
| 160 | - | secretsMap := map[string]string{ | |
| 161 | - | "POSTGRES_USER": "gitea", | |
| 162 | - | "POSTGRES_PASSWORD": "secret123", | |
| 163 | - | } | |
| 164 | - | err = store.SetAttachment(containerName, secretsMap) | |
| 165 | - | require.NoError(t, err) | |
| 166 | - | ||
| 167 | - | // Delete 1 | |
| 168 | - | err = store.DeleteAttachment(containerName, "POSTGRES_PASSWORD") | |
| 169 | - | require.NoError(t, err) | |
| 170 | - | ||
| 171 | - | // Verify remaining via GetAllAttachment | |
| 172 | - | values, err := store.GetAllAttachment(containerName) | |
| 173 | - | require.NoError(t, err) | |
| 174 | - | assert.Len(t, values, 1) | |
| 175 | - | assert.Equal(t, "gitea", values["POSTGRES_USER"]) | |
| 176 | - | _, exists := values["POSTGRES_PASSWORD"] | |
| 177 | - | assert.False(t, exists) | |
| 178 | - | } | |
| 179 | - | ||
| 180 | - | func TestPassStore_Delete_Idempotent(t *testing.T) { | |
| 181 | - | requirePass(t) | |
| 182 | - | ||
| 183 | - | store, err := NewPassStore(testLogger()) | |
| 184 | - | require.NoError(t, err) | |
| 185 | - | ||
| 186 | - | domainName := fmt.Sprintf("idempotent.%d.example.com", time.Now().UnixNano()) | |
| 187 | - | keys := []string{"API_KEY"} | |
| 188 | - | defer cleanupPassDomain(t, domainName, keys) | |
| 189 | - | ||
| 190 | - | err = store.Set(domainName, map[string]string{"API_KEY": "alpha"}) | |
| 191 | - | require.NoError(t, err) | |
| 192 | - | ||
| 193 | - | err = store.Delete(domainName, "API_KEY") | |
| 194 | - | require.NoError(t, err) | |
| 195 | - | ||
| 196 | - | // Second delete of an already-removed key must be a no-op. | |
| 197 | - | err = store.Delete(domainName, "API_KEY") | |
| 198 | - | require.NoError(t, err) | |
| 199 | - | ||
| 200 | - | values, err := store.GetAll(domainName) | |
| 201 | - | require.NoError(t, err) | |
| 202 | - | assert.NotContains(t, values, "API_KEY") | |
| 203 | - | } | |
| 204 | - | ||
| 205 | - | func TestPassStore_DeleteAttachment_Idempotent(t *testing.T) { | |
| 206 | - | requirePass(t) | |
| 207 | - | ||
| 208 | - | store, err := NewPassStore(testLogger()) | |
| 209 | - | require.NoError(t, err) | |
| 210 | - | ||
| 211 | - | containerName := fmt.Sprintf("idempotent-attachment-%d", time.Now().UnixNano()) | |
| 212 | - | keys := []string{"POSTGRES_PASSWORD"} | |
| 213 | - | defer CleanupPassAttachment(t, containerName, keys) | |
| 214 | - | ||
| 215 | - | err = store.SetAttachment(containerName, map[string]string{"POSTGRES_PASSWORD": "secret123"}) | |
| 216 | - | require.NoError(t, err) | |
| 217 | - | ||
| 218 | - | err = store.DeleteAttachment(containerName, "POSTGRES_PASSWORD") | |
| 219 | - | require.NoError(t, err) | |
| 220 | - | ||
| 221 | - | // Second delete of an already-removed key must be a no-op. | |
| 222 | - | err = store.DeleteAttachment(containerName, "POSTGRES_PASSWORD") | |
| 223 | - | require.NoError(t, err) | |
| 224 | - | ||
| 225 | - | values, err := store.GetAllAttachment(containerName) | |
| 226 | - | require.NoError(t, err) | |
| 227 | - | assert.NotContains(t, values, "POSTGRES_PASSWORD") | |
| 228 | - | } | |
| 229 | - | ||
| 230 | - | func TestPassStore_SetAttachment_OverwriteValue(t *testing.T) { | |
| 231 | - | requirePass(t) | |
| 232 | - | ||
| 233 | - | store, err := NewPassStore(testLogger()) | |
| 234 | - | require.NoError(t, err) | |
| 235 | - | ||
| 236 | - | containerName := fmt.Sprintf("attachment-overwrite-%d", time.Now().UnixNano()) | |
| 237 | - | keys := []string{"REDIS_PASSWORD"} | |
| 238 | - | defer CleanupPassAttachment(t, containerName, keys) | |
| 239 | - | ||
| 240 | - | err = store.SetAttachment(containerName, map[string]string{"REDIS_PASSWORD": "first"}) | |
| 241 | - | require.NoError(t, err) | |
| 242 | - | ||
| 243 | - | // Re-setting same key with a different value should deterministically overwrite. | |
| 244 | - | err = store.SetAttachment(containerName, map[string]string{"REDIS_PASSWORD": "second"}) | |
| 245 | - | require.NoError(t, err) | |
| 246 | - | ||
| 247 | - | values, err := store.GetAllAttachment(containerName) | |
| 248 | - | require.NoError(t, err) | |
| 249 | - | assert.Equal(t, "second", values["REDIS_PASSWORD"]) | |
| 250 | - | } | |
| 251 | - | ||
| 252 | - | func TestPassStore_ListKeys_RecoversOrphanedEntries(t *testing.T) { | |
| 253 | - | requirePass(t) | |
| 254 | - | ||
| 255 | - | store, err := NewPassStore(testLogger()) | |
| 256 | - | require.NoError(t, err) | |
| 257 | - | ||
| 258 | - | domainName := fmt.Sprintf("orphan.%d.example.com", time.Now().UnixNano()) | |
| 259 | - | keys := []string{"EXISTING", "ORIGIN"} | |
| 260 | - | defer cleanupPassDomain(t, domainName, keys) | |
| 261 | - | ||
| 262 | - | err = store.Set(domainName, map[string]string{"EXISTING": "present"}) | |
| 263 | - | require.NoError(t, err) | |
| 264 | - | ||
| 265 | - | safeDomain, err := domain.SanitizeDomainForEnvFile(domainName) | |
| 266 | - | require.NoError(t, err) | |
| 267 | - | ||
| 268 | - | orphanPath := fmt.Sprintf("%s/%s/ORIGIN", PassDomainSecretsPath, safeDomain) | |
| 269 | - | err = passInsertValue(orphanPath, "https://example.com") | |
| 270 | - | require.NoError(t, err) | |
| 271 | - | ||
| 272 | - | manifestPath := fmt.Sprintf("%s/%s/.keys", PassDomainSecretsPath, safeDomain) | |
| 273 | - | err = passInsertValue(manifestPath, "EXISTING\n") | |
| 274 | - | require.NoError(t, err) | |
| 275 | - | ||
| 276 | - | listed, err := store.ListKeys(domainName) | |
| 277 | - | require.NoError(t, err) | |
| 278 | - | assert.ElementsMatch(t, []string{"EXISTING", "ORIGIN"}, listed) | |
| 279 | - | ||
| 280 | - | values, err := store.GetAll(domainName) | |
| 281 | - | require.NoError(t, err) | |
| 282 | - | assert.Equal(t, "present", values["EXISTING"]) | |
| 283 | - | assert.Equal(t, "https://example.com", values["ORIGIN"]) | |
| 284 | - | ||
| 285 | - | manifest, err := passShow(manifestPath) | |
| 286 | - | require.NoError(t, err) | |
| 287 | - | assert.Contains(t, manifest, "EXISTING") | |
| 288 | - | assert.Contains(t, manifest, "ORIGIN") | |
| 289 | - | } | |
| 290 | - | ||
| 291 | - | func TestParsePassListOutput(t *testing.T) { | |
| 292 | - | tests := []struct { | |
| 293 | - | name string | |
| 294 | - | basePath string | |
| 295 | - | output string | |
| 296 | - | want []passListEntry | |
| 297 | - | }{ | |
| 298 | - | { | |
| 299 | - | name: "simple tree", | |
| 300 | - | basePath: "domain", | |
| 301 | - | output: "domain\n├── key1\n└── key2", | |
| 302 | - | want: []passListEntry{{name: "key1", depth: 1}, {name: "key2", depth: 1}}, | |
| 303 | - | }, | |
| 304 | - | { | |
| 305 | - | name: "nested tree", | |
| 306 | - | basePath: "domain", | |
| 307 | - | output: "domain\n│ ├── subkey1\n│ └── subkey2\n└── key1", | |
| 308 | - | want: []passListEntry{{name: "subkey1", depth: 2}, {name: "subkey2", depth: 2}, {name: "key1", depth: 1}}, | |
| 309 | - | }, | |
| 310 | - | { | |
| 311 | - | name: "empty output", | |
| 312 | - | basePath: "domain", | |
| 313 | - | output: "", | |
| 314 | - | want: []passListEntry{}, | |
| 315 | - | }, | |
| 316 | - | { | |
| 317 | - | name: "ASCII fallback chars", | |
| 318 | - | basePath: "domain", | |
| 319 | - | output: "domain\n| |-- subkey1\n`-- key1", | |
| 320 | - | want: []passListEntry{{name: "subkey1", depth: 2}, {name: "key1", depth: 1}}, | |
| 321 | - | }, | |
| 322 | - | } | |
| 323 | - | ||
| 324 | - | for _, tt := range tests { | |
| 325 | - | t.Run(tt.name, func(t *testing.T) { | |
| 326 | - | got := parsePassListOutput(tt.basePath, tt.output) | |
| 327 | - | assert.Equal(t, tt.want, got) | |
| 328 | - | }) | |
| 329 | - | } | |
| 330 | - | } | |
D internal/adapters/out/domainsecrets/store.go +0 -554
| @@ -1,554 +0,0 @@ | |||
| 1 | - | // Package domainsecrets implements the DomainSecretStore adapter using filesystem-based env files. | |
| 2 | - | package domainsecrets | |
| 3 | - | ||
| 4 | - | import ( | |
| 5 | - | "bufio" | |
| 6 | - | "fmt" | |
| 7 | - | "os" | |
| 8 | - | "path/filepath" | |
| 9 | - | "sort" | |
| 10 | - | "strings" | |
| 11 | - | ||
| 12 | - | "github.com/bnema/zerowrap" | |
| 13 | - | ||
| 14 | - | "github.com/bnema/gordon/internal/boundaries/out" | |
| 15 | - | "github.com/bnema/gordon/internal/domain" | |
| 16 | - | ) | |
| 17 | - | ||
| 18 | - | // FileStore implements the DomainSecretStore interface using filesystem-based env files. | |
| 19 | - | // This adapter is responsible only for file I/O operations; domain validation | |
| 20 | - | // should be performed by the use case layer before calling these methods. | |
| 21 | - | type FileStore struct { | |
| 22 | - | envDir string | |
| 23 | - | log zerowrap.Logger | |
| 24 | - | } | |
| 25 | - | ||
| 26 | - | // NewFileStore creates a new file-based domain secret store. | |
| 27 | - | func NewFileStore(envDir string, log zerowrap.Logger) (*FileStore, error) { | |
| 28 | - | // Ensure env directory exists | |
| 29 | - | if err := os.MkdirAll(envDir, 0700); err != nil { | |
| 30 | - | return nil, fmt.Errorf("failed to create env directory %s: %w", envDir, err) | |
| 31 | - | } | |
| 32 | - | ||
| 33 | - | log.Debug(). | |
| 34 | - | Str(zerowrap.FieldLayer, "adapter"). | |
| 35 | - | Str(zerowrap.FieldAdapter, "domainsecrets"). | |
| 36 | - | Str("env_dir", envDir). | |
| 37 | - | Msg("domain secret store initialized") | |
| 38 | - | ||
| 39 | - | return &FileStore{ | |
| 40 | - | envDir: envDir, | |
| 41 | - | log: log, | |
| 42 | - | }, nil | |
| 43 | - | } | |
| 44 | - | ||
| 45 | - | // ListKeys returns the list of secret keys for a domain (not values). | |
| 46 | - | func (s *FileStore) ListKeys(domainName string) ([]string, error) { | |
| 47 | - | envFile, err := s.validateEnvFilePath(domainName) | |
| 48 | - | if err != nil { | |
| 49 | - | return nil, err | |
| 50 | - | } | |
| 51 | - | ||
| 52 | - | file, err := os.Open(envFile) | |
| 53 | - | if err != nil { | |
| 54 | - | if os.IsNotExist(err) { | |
| 55 | - | return []string{}, nil | |
| 56 | - | } | |
| 57 | - | return nil, fmt.Errorf("failed to open env file: %w", err) | |
| 58 | - | } | |
| 59 | - | defer file.Close() | |
| 60 | - | ||
| 61 | - | var keys []string | |
| 62 | - | scanner := bufio.NewScanner(file) | |
| 63 | - | for scanner.Scan() { | |
| 64 | - | line := strings.TrimSpace(scanner.Text()) | |
| 65 | - | // Skip empty lines and comments | |
| 66 | - | if line == "" || strings.HasPrefix(line, "#") { | |
| 67 | - | continue | |
| 68 | - | } | |
| 69 | - | // Extract key from KEY=value | |
| 70 | - | if idx := strings.Index(line, "="); idx > 0 { | |
| 71 | - | keys = append(keys, line[:idx]) | |
| 72 | - | } | |
| 73 | - | } | |
| 74 | - | ||
| 75 | - | if err := scanner.Err(); err != nil { | |
| 76 | - | return nil, fmt.Errorf("failed to read env file: %w", err) | |
| 77 | - | } | |
| 78 | - | ||
| 79 | - | return keys, nil | |
| 80 | - | } | |
| 81 | - | ||
| 82 | - | // GetAll returns all secrets for a domain as a key-value map. | |
| 83 | - | func (s *FileStore) GetAll(domainName string) (map[string]string, error) { | |
| 84 | - | envFile, err := s.validateEnvFilePath(domainName) | |
| 85 | - | if err != nil { | |
| 86 | - | return nil, err | |
| 87 | - | } | |
| 88 | - | ||
| 89 | - | data, err := os.ReadFile(envFile) | |
| 90 | - | if err != nil { | |
| 91 | - | if os.IsNotExist(err) { | |
| 92 | - | return map[string]string{}, nil | |
| 93 | - | } | |
| 94 | - | return nil, fmt.Errorf("failed to read env file: %w", err) | |
| 95 | - | } | |
| 96 | - | ||
| 97 | - | secrets, err := domain.ParseEnvData(data) | |
| 98 | - | if err != nil { | |
| 99 | - | return nil, fmt.Errorf("failed to parse env file: %w", err) | |
| 100 | - | } | |
| 101 | - | ||
| 102 | - | return secrets, nil | |
| 103 | - | } | |
| 104 | - | ||
| 105 | - | // Set sets or updates multiple secrets for a domain, merging with existing. | |
| 106 | - | func (s *FileStore) Set(domainName string, secrets map[string]string) error { | |
| 107 | - | // Validate domain first | |
| 108 | - | if _, err := s.validateEnvFilePath(domainName); err != nil { | |
| 109 | - | return err | |
| 110 | - | } | |
| 111 | - | ||
| 112 | - | // Ensure env directory exists | |
| 113 | - | if err := os.MkdirAll(s.envDir, 0700); err != nil { | |
| 114 | - | return fmt.Errorf("failed to create env directory: %w", err) | |
| 115 | - | } | |
| 116 | - | ||
| 117 | - | // Read existing secrets | |
| 118 | - | existing, err := s.GetAll(domainName) | |
| 119 | - | if err != nil { | |
| 120 | - | return err | |
| 121 | - | } | |
| 122 | - | ||
| 123 | - | // Merge new secrets with existing | |
| 124 | - | for key, value := range secrets { | |
| 125 | - | existing[key] = value | |
| 126 | - | } | |
| 127 | - | ||
| 128 | - | // Write back atomically | |
| 129 | - | return s.writeSecretsAtomic(domainName, existing) | |
| 130 | - | } | |
| 131 | - | ||
| 132 | - | // Delete removes a specific secret key from a domain. | |
| 133 | - | func (s *FileStore) Delete(domainName, key string) error { | |
| 134 | - | // Validate domain first | |
| 135 | - | if _, err := s.validateEnvFilePath(domainName); err != nil { | |
| 136 | - | return err | |
| 137 | - | } | |
| 138 | - | ||
| 139 | - | // Read existing secrets | |
| 140 | - | existing, err := s.GetAll(domainName) | |
| 141 | - | if err != nil { | |
| 142 | - | return err | |
| 143 | - | } | |
| 144 | - | ||
| 145 | - | // Remove the key | |
| 146 | - | delete(existing, key) | |
| 147 | - | ||
| 148 | - | // Write back atomically | |
| 149 | - | return s.writeSecretsAtomic(domainName, existing) | |
| 150 | - | } | |
| 151 | - | ||
| 152 | - | // SetAttachment sets or updates multiple secrets for an attachment container. | |
| 153 | - | func (s *FileStore) SetAttachment(containerName string, secrets map[string]string) error { | |
| 154 | - | // Validate container name first | |
| 155 | - | if _, err := s.validateAttachmentEnvFilePath(containerName); err != nil { | |
| 156 | - | return err | |
| 157 | - | } | |
| 158 | - | ||
| 159 | - | // Ensure env directory exists | |
| 160 | - | if err := os.MkdirAll(s.envDir, 0700); err != nil { | |
| 161 | - | return fmt.Errorf("failed to create env directory: %w", err) | |
| 162 | - | } | |
| 163 | - | ||
| 164 | - | // Read existing secrets | |
| 165 | - | existing, err := s.GetAllAttachment(containerName) | |
| 166 | - | if err != nil { | |
| 167 | - | return err | |
| 168 | - | } | |
| 169 | - | ||
| 170 | - | // Merge new secrets with existing | |
| 171 | - | for key, value := range secrets { | |
| 172 | - | existing[key] = value | |
| 173 | - | } | |
| 174 | - | ||
| 175 | - | // Write back atomically | |
| 176 | - | return s.writeAttachmentSecretsAtomic(containerName, existing) | |
| 177 | - | } | |
| 178 | - | ||
| 179 | - | // DeleteAttachment removes a specific secret key from an attachment container. | |
| 180 | - | func (s *FileStore) DeleteAttachment(containerName, key string) error { | |
| 181 | - | // Validate container name first | |
| 182 | - | if _, err := s.validateAttachmentEnvFilePath(containerName); err != nil { | |
| 183 | - | return err | |
| 184 | - | } | |
| 185 | - | ||
| 186 | - | // Read existing secrets | |
| 187 | - | existing, err := s.GetAllAttachment(containerName) | |
| 188 | - | if err != nil { | |
| 189 | - | return err | |
| 190 | - | } | |
| 191 | - | ||
| 192 | - | // Remove the key (no-op if not present) | |
| 193 | - | delete(existing, key) | |
| 194 | - | ||
| 195 | - | // Write back atomically | |
| 196 | - | return s.writeAttachmentSecretsAtomic(containerName, existing) | |
| 197 | - | } | |
| 198 | - | ||
| 199 | - | // GetAllAttachment returns all secrets for an attachment container as a key-value map. | |
| 200 | - | func (s *FileStore) GetAllAttachment(containerName string) (map[string]string, error) { | |
| 201 | - | envFile, err := s.validateAttachmentEnvFilePath(containerName) | |
| 202 | - | if err != nil { | |
| 203 | - | return nil, err | |
| 204 | - | } | |
| 205 | - | ||
| 206 | - | data, err := os.ReadFile(envFile) | |
| 207 | - | if err != nil { | |
| 208 | - | if os.IsNotExist(err) { | |
| 209 | - | return map[string]string{}, nil | |
| 210 | - | } | |
| 211 | - | return nil, fmt.Errorf("failed to read attachment env file: %w", err) | |
| 212 | - | } | |
| 213 | - | ||
| 214 | - | secrets, err := domain.ParseEnvData(data) | |
| 215 | - | if err != nil { | |
| 216 | - | return nil, fmt.Errorf("failed to parse attachment env file: %w", err) | |
| 217 | - | } | |
| 218 | - | ||
| 219 | - | return secrets, nil | |
| 220 | - | } | |
| 221 | - | ||
| 222 | - | // validateSecretValues checks that no secret value contains newline characters. | |
| 223 | - | func validateSecretValues(secrets map[string]string) error { | |
| 224 | - | for key, value := range secrets { | |
| 225 | - | if strings.ContainsAny(value, "\n\r") { | |
| 226 | - | return fmt.Errorf("secret value for %q contains newline characters", key) | |
| 227 | - | } | |
| 228 | - | } | |
| 229 | - | return nil | |
| 230 | - | } | |
| 231 | - | ||
| 232 | - | // writeSecretsAtomic writes all secrets to the domain's env file atomically. | |
| 233 | - | // It writes to a temporary file first, syncs it, then renames to the final path. | |
| 234 | - | func (s *FileStore) writeSecretsAtomic(domainName string, secrets map[string]string) error { | |
| 235 | - | if err := validateSecretValues(secrets); err != nil { | |
| 236 | - | return fmt.Errorf("invalid domain secret values for %q: %w", domainName, err) | |
| 237 | - | } | |
| 238 | - | ||
| 239 | - | envFile, err := s.validateEnvFilePath(domainName) | |
| 240 | - | if err != nil { | |
| 241 | - | return err | |
| 242 | - | } | |
| 243 | - | tmpFile := envFile + ".tmp" | |
| 244 | - | ||
| 245 | - | file, err := os.OpenFile(tmpFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) | |
| 246 | - | if err != nil { | |
| 247 | - | return fmt.Errorf("failed to create temp env file: %w", err) | |
| 248 | - | } | |
| 249 | - | ||
| 250 | - | // Write header comment | |
| 251 | - | if _, err := fmt.Fprintf(file, "# Environment variables for %s\n", domainName); err != nil { | |
| 252 | - | file.Close() | |
| 253 | - | os.Remove(tmpFile) | |
| 254 | - | return fmt.Errorf("failed to write header: %w", err) | |
| 255 | - | } | |
| 256 | - | if _, err := fmt.Fprintf(file, "# Managed by Gordon admin API\n\n"); err != nil { | |
| 257 | - | file.Close() | |
| 258 | - | os.Remove(tmpFile) | |
| 259 | - | return fmt.Errorf("failed to write header: %w", err) | |
| 260 | - | } | |
| 261 | - | ||
| 262 | - | // Write each secret in sorted order for deterministic output | |
| 263 | - | keys := make([]string, 0, len(secrets)) | |
| 264 | - | for key := range secrets { | |
| 265 | - | keys = append(keys, key) | |
| 266 | - | } | |
| 267 | - | sort.Strings(keys) | |
| 268 | - | ||
| 269 | - | for _, key := range keys { | |
| 270 | - | if _, err := fmt.Fprintf(file, "%s=%s\n", key, secrets[key]); err != nil { | |
| 271 | - | file.Close() | |
| 272 | - | os.Remove(tmpFile) | |
| 273 | - | return fmt.Errorf("failed to write secret %s: %w", key, err) | |
| 274 | - | } | |
| 275 | - | } | |
| 276 | - | ||
| 277 | - | // Sync to ensure data is on disk before rename | |
| 278 | - | if err := file.Sync(); err != nil { | |
| 279 | - | file.Close() | |
| 280 | - | os.Remove(tmpFile) | |
| 281 | - | return fmt.Errorf("failed to sync env file: %w", err) | |
| 282 | - | } | |
| 283 | - | ||
| 284 | - | if err := file.Close(); err != nil { | |
| 285 | - | os.Remove(tmpFile) | |
| 286 | - | return fmt.Errorf("failed to close env file: %w", err) | |
| 287 | - | } | |
| 288 | - | ||
| 289 | - | // Atomic rename | |
| 290 | - | if err := os.Rename(tmpFile, envFile); err != nil { | |
| 291 | - | os.Remove(tmpFile) | |
| 292 | - | return fmt.Errorf("failed to rename env file: %w", err) | |
| 293 | - | } | |
| 294 | - | ||
| 295 | - | return nil | |
| 296 | - | } | |
| 297 | - | ||
| 298 | - | // getEnvFilePath converts a domain to its env file path. | |
| 299 | - | // This must match the naming convention in envloader.FileLoader.getEnvFilePath. | |
| 300 | - | // | |
| 301 | - | // SECURITY: Validates domain and ensures the resulting path stays within envDir. | |
| 302 | - | func (s *FileStore) getEnvFilePath(domainName string) (string, error) { | |
| 303 | - | storageKey, err := domain.NewEnvStorageKey(domainName) | |
| 304 | - | if err != nil { | |
| 305 | - | s.log.Warn(). | |
| 306 | - | Str(zerowrap.FieldLayer, "adapter"). | |
| 307 | - | Str(zerowrap.FieldAdapter, "domainsecrets"). | |
| 308 | - | Str("domain", domainName). | |
| 309 | - | Err(err). | |
| 310 | - | Msg("rejected invalid domain") | |
| 311 | - | return "", err | |
| 312 | - | } | |
| 313 | - | ||
| 314 | - | fullPath := filepath.Join(s.envDir, storageKey.FileName()) | |
| 315 | - | ||
| 316 | - | // SECURITY: Final validation - ensure path stays within envDir | |
| 317 | - | cleanPath := filepath.Clean(fullPath) | |
| 318 | - | cleanEnvDir := filepath.Clean(s.envDir) | |
| 319 | - | if !strings.HasPrefix(cleanPath, cleanEnvDir+string(filepath.Separator)) { | |
| 320 | - | s.log.Error(). | |
| 321 | - | Str(zerowrap.FieldLayer, "adapter"). | |
| 322 | - | Str(zerowrap.FieldAdapter, "domainsecrets"). | |
| 323 | - | Str("domain", domainName). | |
| 324 | - | Str("attempted_path", fullPath). | |
| 325 | - | Msg("path traversal attempt blocked - path escapes env directory") | |
| 326 | - | return "", domain.ErrPathTraversal | |
| 327 | - | } | |
| 328 | - | ||
| 329 | - | return fullPath, nil | |
| 330 | - | } | |
| 331 | - | ||
| 332 | - | // validateEnvFilePath validates that a domain produces a valid env file path. | |
| 333 | - | // Returns an error if the domain is invalid or would result in path traversal. | |
| 334 | - | func (s *FileStore) validateEnvFilePath(domainName string) (string, error) { | |
| 335 | - | return s.getEnvFilePath(domainName) | |
| 336 | - | } | |
| 337 | - | ||
| 338 | - | // getAttachmentEnvFilePath converts a container name to its attachment env file path. | |
| 339 | - | // Attachment env files follow the pattern: gordon-<containerName>.env | |
| 340 | - | // | |
| 341 | - | // SECURITY: Validates container name and ensures the resulting path stays within envDir. | |
| 342 | - | func (s *FileStore) getAttachmentEnvFilePath(containerName string) (string, error) { | |
| 343 | - | if err := domain.ValidateContainerName(containerName); err != nil { | |
| 344 | - | s.log.Warn(). | |
| 345 | - | Str(zerowrap.FieldLayer, "adapter"). | |
| 346 | - | Str(zerowrap.FieldAdapter, "domainsecrets"). | |
| 347 | - | Str("container", containerName). | |
| 348 | - | Err(err). | |
| 349 | - | Msg("rejected invalid container name") | |
| 350 | - | return "", err | |
| 351 | - | } | |
| 352 | - | ||
| 353 | - | fullPath := filepath.Join(s.envDir, "gordon-"+containerName+".env") | |
| 354 | - | ||
| 355 | - | // SECURITY: Final validation - ensure path stays within envDir | |
| 356 | - | cleanPath := filepath.Clean(fullPath) | |
| 357 | - | cleanEnvDir := filepath.Clean(s.envDir) | |
| 358 | - | if !strings.HasPrefix(cleanPath, cleanEnvDir+string(filepath.Separator)) { | |
| 359 | - | s.log.Error(). | |
| 360 | - | Str(zerowrap.FieldLayer, "adapter"). | |
| 361 | - | Str(zerowrap.FieldAdapter, "domainsecrets"). | |
| 362 | - | Str("container", containerName). | |
| 363 | - | Str("attempted_path", fullPath). | |
| 364 | - | Msg("path traversal attempt blocked - path escapes env directory") | |
| 365 | - | return "", domain.ErrPathTraversal | |
| 366 | - | } | |
| 367 | - | ||
| 368 | - | return fullPath, nil | |
| 369 | - | } | |
| 370 | - | ||
| 371 | - | // validateAttachmentEnvFilePath validates that a container name produces a valid attachment env file path. | |
| 372 | - | // Returns an error if the container name is invalid or would result in path traversal. | |
| 373 | - | func (s *FileStore) validateAttachmentEnvFilePath(containerName string) (string, error) { | |
| 374 | - | return s.getAttachmentEnvFilePath(containerName) | |
| 375 | - | } | |
| 376 | - | ||
| 377 | - | // writeAttachmentSecretsAtomic writes all secrets to the attachment's env file atomically. | |
| 378 | - | // It writes to a temporary file first, syncs it, then renames to the final path. | |
| 379 | - | func (s *FileStore) writeAttachmentSecretsAtomic(containerName string, secrets map[string]string) error { | |
| 380 | - | if err := validateSecretValues(secrets); err != nil { | |
| 381 | - | return fmt.Errorf("invalid attachment secret values for %q: %w", containerName, err) | |
| 382 | - | } | |
| 383 | - | ||
| 384 | - | envFile, err := s.validateAttachmentEnvFilePath(containerName) | |
| 385 | - | if err != nil { | |
| 386 | - | return err | |
| 387 | - | } | |
| 388 | - | tmpFile := envFile + ".tmp" | |
| 389 | - | ||
| 390 | - | file, err := os.OpenFile(tmpFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) | |
| 391 | - | if err != nil { | |
| 392 | - | return fmt.Errorf("failed to create temp attachment env file: %w", err) | |
| 393 | - | } | |
| 394 | - | ||
| 395 | - | // Write header comment | |
| 396 | - | if _, err := fmt.Fprintf(file, "# Environment variables for attachment %s\n", containerName); err != nil { | |
| 397 | - | file.Close() | |
| 398 | - | os.Remove(tmpFile) | |
| 399 | - | return fmt.Errorf("failed to write header: %w", err) | |
| 400 | - | } | |
| 401 | - | if _, err := fmt.Fprintf(file, "# Managed by Gordon admin API\n\n"); err != nil { | |
| 402 | - | file.Close() | |
| 403 | - | os.Remove(tmpFile) | |
| 404 | - | return fmt.Errorf("failed to write header: %w", err) | |
| 405 | - | } | |
| 406 | - | ||
| 407 | - | // Write each secret in sorted order for deterministic output | |
| 408 | - | keys := make([]string, 0, len(secrets)) | |
| 409 | - | for key := range secrets { | |
| 410 | - | keys = append(keys, key) | |
| 411 | - | } | |
| 412 | - | sort.Strings(keys) | |
| 413 | - | ||
| 414 | - | for _, key := range keys { | |
| 415 | - | if _, err := fmt.Fprintf(file, "%s=%s\n", key, secrets[key]); err != nil { | |
| 416 | - | file.Close() | |
| 417 | - | os.Remove(tmpFile) | |
| 418 | - | return fmt.Errorf("failed to write secret %s: %w", key, err) | |
| 419 | - | } | |
| 420 | - | } | |
| 421 | - | ||
| 422 | - | // Sync to ensure data is on disk before rename | |
| 423 | - | if err := file.Sync(); err != nil { | |
| 424 | - | file.Close() | |
| 425 | - | os.Remove(tmpFile) | |
| 426 | - | return fmt.Errorf("failed to sync attachment env file: %w", err) | |
| 427 | - | } | |
| 428 | - | ||
| 429 | - | if err := file.Close(); err != nil { | |
| 430 | - | os.Remove(tmpFile) | |
| 431 | - | return fmt.Errorf("failed to close attachment env file: %w", err) | |
| 432 | - | } | |
| 433 | - | ||
| 434 | - | // Atomic rename | |
| 435 | - | if err := os.Rename(tmpFile, envFile); err != nil { | |
| 436 | - | os.Remove(tmpFile) | |
| 437 | - | return fmt.Errorf("failed to rename attachment env file: %w", err) | |
| 438 | - | } | |
| 439 | - | ||
| 440 | - | return nil | |
| 441 | - | } | |
| 442 | - | ||
| 443 | - | // ListAttachmentKeys finds attachment env files for a domain and returns their keys. | |
| 444 | - | // Supports both new (collision-resistant) and legacy container naming for backwards compatibility. | |
| 445 | - | // Attachment env files follow the naming pattern: gordon-{sanitized-domain}-{service}.env | |
| 446 | - | func (s *FileStore) ListAttachmentKeys(domainName string) ([]out.AttachmentSecrets, error) { | |
| 447 | - | // Try both new and legacy sanitization for backwards compatibility | |
| 448 | - | sanitized := domain.SanitizeDomainForContainer(domainName) | |
| 449 | - | sanitizedLegacy := domain.SanitizeDomainForContainerLegacy(domainName) | |
| 450 | - | prefixes := []string{ | |
| 451 | - | "gordon-" + sanitized + "-", // New format (collision-resistant) | |
| 452 | - | "gordon-" + sanitizedLegacy + "-", // Old format (buggy but backwards compatible) | |
| 453 | - | } | |
| 454 | - | ||
| 455 | - | // List all files in env directory | |
| 456 | - | entries, err := os.ReadDir(s.envDir) | |
| 457 | - | if err != nil { | |
| 458 | - | if os.IsNotExist(err) { | |
| 459 | - | return nil, nil | |
| 460 | - | } | |
| 461 | - | return nil, fmt.Errorf("failed to read env directory: %w", err) | |
| 462 | - | } | |
| 463 | - | ||
| 464 | - | seen := make(map[string]bool) | |
| 465 | - | var results []out.AttachmentSecrets | |
| 466 | - | for _, entry := range entries { | |
| 467 | - | if entry.IsDir() { | |
| 468 | - | continue | |
| 469 | - | } | |
| 470 | - | name := entry.Name() | |
| 471 | - | ||
| 472 | - | // Check if this matches any of our prefixes | |
| 473 | - | var matchedPrefix string | |
| 474 | - | for _, prefix := range prefixes { | |
| 475 | - | if strings.HasPrefix(name, prefix) && strings.HasSuffix(name, ".env") { | |
| 476 | - | matchedPrefix = prefix | |
| 477 | - | break | |
| 478 | - | } | |
| 479 | - | } | |
| 480 | - | if matchedPrefix == "" { | |
| 481 | - | continue | |
| 482 | - | } | |
| 483 | - | ||
| 484 | - | // Extract container name from filename | |
| 485 | - | containerName := strings.TrimSuffix(name, ".env") | |
| 486 | - | ||
| 487 | - | // Deduplicate results | |
| 488 | - | if seen[containerName] { | |
| 489 | - | continue | |
| 490 | - | } | |
| 491 | - | seen[containerName] = true | |
| 492 | - | ||
| 493 | - | // Extract service name from filename | |
| 494 | - | // e.g., "gordon-git-bnema-dev-gitea-postgres.env" → "gitea-postgres" | |
| 495 | - | serviceName := strings.TrimPrefix(name, matchedPrefix) | |
| 496 | - | serviceName = strings.TrimSuffix(serviceName, ".env") | |
| 497 | - | if serviceName == "" { | |
| 498 | - | continue | |
| 499 | - | } | |
| 500 | - | ||
| 501 | - | // Read keys from this file using existing method | |
| 502 | - | // Note: We use the container name directly since it matches the env file naming | |
| 503 | - | keys, err := s.listKeysFromFile(filepath.Join(s.envDir, name)) | |
| 504 | - | if err != nil { | |
| 505 | - | s.log.Warn(). | |
| 506 | - | Err(err). | |
| 507 | - | Str("file", name). | |
| 508 | - | Str("domain", domainName). | |
| 509 | - | Msg("failed to read attachment secrets file") | |
| 510 | - | continue | |
| 511 | - | } | |
| 512 | - | ||
| 513 | - | if len(keys) > 0 { | |
| 514 | - | results = append(results, out.AttachmentSecrets{ | |
| 515 | - | Service: containerName, | |
| 516 | - | Keys: keys, | |
| 517 | - | }) | |
| 518 | - | } | |
| 519 | - | } | |
| 520 | - | ||
| 521 | - | return results, nil | |
| 522 | - | } | |
| 523 | - | ||
| 524 | - | // listKeysFromFile reads secret keys from a specific env file path. | |
| 525 | - | func (s *FileStore) listKeysFromFile(filePath string) ([]string, error) { | |
| 526 | - | file, err := os.Open(filePath) | |
| 527 | - | if err != nil { | |
| 528 | - | if os.IsNotExist(err) { | |
| 529 | - | return []string{}, nil | |
| 530 | - | } | |
| 531 | - | return nil, fmt.Errorf("failed to open env file: %w", err) | |
| 532 | - | } | |
| 533 | - | defer file.Close() | |
| 534 | - | ||
| 535 | - | var keys []string | |
| 536 | - | scanner := bufio.NewScanner(file) | |
| 537 | - | for scanner.Scan() { | |
| 538 | - | line := strings.TrimSpace(scanner.Text()) | |
| 539 | - | // Skip empty lines and comments | |
| 540 | - | if line == "" || strings.HasPrefix(line, "#") { | |
| 541 | - | continue | |
| 542 | - | } | |
| 543 | - | // Extract key from KEY=value | |
| 544 | - | if idx := strings.Index(line, "="); idx > 0 { | |
| 545 | - | keys = append(keys, line[:idx]) | |
| 546 | - | } | |
| 547 | - | } | |
| 548 | - | ||
| 549 | - | if err := scanner.Err(); err != nil { | |
| 550 | - | return nil, fmt.Errorf("failed to read env file: %w", err) | |
| 551 | - | } | |
| 552 | - | ||
| 553 | - | return keys, nil | |
| 554 | - | } | |
D internal/adapters/out/domainsecrets/store_test.go +0 -432
| @@ -1,432 +0,0 @@ | |||
| 1 | - | package domainsecrets | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "errors" | |
| 5 | - | "os" | |
| 6 | - | "path/filepath" | |
| 7 | - | "testing" | |
| 8 | - | ||
| 9 | - | "github.com/stretchr/testify/assert" | |
| 10 | - | "github.com/stretchr/testify/require" | |
| 11 | - | ||
| 12 | - | "github.com/bnema/gordon/internal/domain" | |
| 13 | - | ) | |
| 14 | - | ||
| 15 | - | func TestFileStore_PathTraversal(t *testing.T) { | |
| 16 | - | tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*") | |
| 17 | - | require.NoError(t, err) | |
| 18 | - | defer os.RemoveAll(tmpDir) | |
| 19 | - | ||
| 20 | - | store, err := NewFileStore(tmpDir, testLogger()) | |
| 21 | - | require.NoError(t, err) | |
| 22 | - | ||
| 23 | - | tests := []struct { | |
| 24 | - | name string | |
| 25 | - | domain string | |
| 26 | - | wantErr bool | |
| 27 | - | }{ | |
| 28 | - | // Valid domains | |
| 29 | - | {"simple domain", "example.com", false}, | |
| 30 | - | {"subdomain", "sub.example.com", false}, | |
| 31 | - | {"domain with port", "example.com:8080", false}, | |
| 32 | - | {"domain with path", "example.com/path", false}, | |
| 33 | - | {"hyphenated domain", "my-app.example.com", false}, | |
| 34 | - | {"single char", "a", false}, | |
| 35 | - | ||
| 36 | - | // Path traversal attempts - should fail | |
| 37 | - | {"path traversal with dots", "../../../etc/passwd", true}, | |
| 38 | - | {"path traversal in middle", "example.com/../../../etc/passwd", true}, | |
| 39 | - | {"double dots only", "..", true}, | |
| 40 | - | {"path traversal at end", "example.com/..", true}, | |
| 41 | - | ||
| 42 | - | // Invalid format - should fail | |
| 43 | - | {"empty domain", "", true}, | |
| 44 | - | {"starts with dot", ".example.com", true}, | |
| 45 | - | {"ends with dot", "example.com.", true}, | |
| 46 | - | {"special chars semicolon", "example;rm -rf /", true}, | |
| 47 | - | {"null byte", "example\x00.com", true}, | |
| 48 | - | {"newline", "example\n.com", true}, | |
| 49 | - | } | |
| 50 | - | ||
| 51 | - | for _, tt := range tests { | |
| 52 | - | t.Run(tt.name, func(t *testing.T) { | |
| 53 | - | // Test ListKeys | |
| 54 | - | _, err := store.ListKeys(tt.domain) | |
| 55 | - | if tt.wantErr { | |
| 56 | - | require.Error(t, err) | |
| 57 | - | assert.True(t, errors.Is(err, domain.ErrPathTraversal), "expected ErrPathTraversal, got: %v", err) | |
| 58 | - | } else { | |
| 59 | - | // No error, or file not found (which is fine) | |
| 60 | - | if err != nil { | |
| 61 | - | assert.False(t, errors.Is(err, domain.ErrPathTraversal), "unexpected ErrPathTraversal") | |
| 62 | - | } | |
| 63 | - | } | |
| 64 | - | ||
| 65 | - | // Test Set | |
| 66 | - | err = store.Set(tt.domain, map[string]string{"KEY": "value"}) | |
| 67 | - | if tt.wantErr { | |
| 68 | - | require.Error(t, err) | |
| 69 | - | assert.True(t, errors.Is(err, domain.ErrPathTraversal), "expected ErrPathTraversal, got: %v", err) | |
| 70 | - | } else { | |
| 71 | - | assert.NoError(t, err) | |
| 72 | - | } | |
| 73 | - | ||
| 74 | - | // Test GetAll | |
| 75 | - | _, err = store.GetAll(tt.domain) | |
| 76 | - | if tt.wantErr { | |
| 77 | - | require.Error(t, err) | |
| 78 | - | assert.True(t, errors.Is(err, domain.ErrPathTraversal), "expected ErrPathTraversal, got: %v", err) | |
| 79 | - | } | |
| 80 | - | ||
| 81 | - | // Test Delete | |
| 82 | - | err = store.Delete(tt.domain, "KEY") | |
| 83 | - | if tt.wantErr { | |
| 84 | - | require.Error(t, err) | |
| 85 | - | assert.True(t, errors.Is(err, domain.ErrPathTraversal), "expected ErrPathTraversal, got: %v", err) | |
| 86 | - | } | |
| 87 | - | }) | |
| 88 | - | } | |
| 89 | - | } | |
| 90 | - | ||
| 91 | - | func TestFileStore_PathContainment(t *testing.T) { | |
| 92 | - | tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*") | |
| 93 | - | require.NoError(t, err) | |
| 94 | - | defer os.RemoveAll(tmpDir) | |
| 95 | - | ||
| 96 | - | store, err := NewFileStore(tmpDir, testLogger()) | |
| 97 | - | require.NoError(t, err) | |
| 98 | - | ||
| 99 | - | // Valid domain - file should be created inside tmpDir | |
| 100 | - | err = store.Set("example.com", map[string]string{"KEY": "value"}) | |
| 101 | - | require.NoError(t, err) | |
| 102 | - | ||
| 103 | - | // Verify file exists inside tmpDir | |
| 104 | - | expectedFile := filepath.Join(tmpDir, "ZXhhbXBsZS5jb20.env") | |
| 105 | - | _, err = os.Stat(expectedFile) | |
| 106 | - | assert.NoError(t, err, "expected env file to exist at %s", expectedFile) | |
| 107 | - | ||
| 108 | - | // Verify no file was created outside tmpDir | |
| 109 | - | entries, err := filepath.Glob(tmpDir + "/../*") | |
| 110 | - | require.NoError(t, err) | |
| 111 | - | for _, entry := range entries { | |
| 112 | - | if filepath.Base(entry) != filepath.Base(tmpDir) { | |
| 113 | - | // Check it's not our test file escaped | |
| 114 | - | assert.NotContains(t, entry, ".env", "unexpected .env file outside tmpDir: %s", entry) | |
| 115 | - | } | |
| 116 | - | } | |
| 117 | - | } | |
| 118 | - | ||
| 119 | - | func TestFileStore_DistinctDomainsUseDistinctFiles(t *testing.T) { | |
| 120 | - | store, err := NewFileStore(t.TempDir(), testLogger()) | |
| 121 | - | require.NoError(t, err) | |
| 122 | - | ||
| 123 | - | dotPath, err := store.validateEnvFilePath("app.example.com") | |
| 124 | - | require.NoError(t, err) | |
| 125 | - | ||
| 126 | - | portPath, err := store.validateEnvFilePath("app:example:com") | |
| 127 | - | require.NoError(t, err) | |
| 128 | - | ||
| 129 | - | pathPath, err := store.validateEnvFilePath("app/example/com") | |
| 130 | - | require.NoError(t, err) | |
| 131 | - | ||
| 132 | - | assert.NotEqual(t, dotPath, portPath) | |
| 133 | - | assert.NotEqual(t, dotPath, pathPath) | |
| 134 | - | assert.NotEqual(t, portPath, pathPath) | |
| 135 | - | } | |
| 136 | - | ||
| 137 | - | func TestSanitizeDomainForContainer(t *testing.T) { | |
| 138 | - | tests := []struct { | |
| 139 | - | domain string | |
| 140 | - | expected string | |
| 141 | - | description string | |
| 142 | - | }{ | |
| 143 | - | { | |
| 144 | - | domain: "git.example.com", | |
| 145 | - | expected: "git__example__com", | |
| 146 | - | description: "Dots become double underscores", | |
| 147 | - | }, | |
| 148 | - | { | |
| 149 | - | domain: "git-example.com", | |
| 150 | - | expected: "git-example__com", | |
| 151 | - | description: "Hyphens preserved, dots become underscores", | |
| 152 | - | }, | |
| 153 | - | { | |
| 154 | - | domain: "app:8080.example.com", | |
| 155 | - | expected: "app-_8080__example__com", | |
| 156 | - | description: "Colons become hyphen-underscore", | |
| 157 | - | }, | |
| 158 | - | { | |
| 159 | - | domain: "git.example.com:3000", | |
| 160 | - | expected: "git__example__com-_3000", | |
| 161 | - | description: "Multiple separators handled distinctly", | |
| 162 | - | }, | |
| 163 | - | { | |
| 164 | - | domain: "simple.com", | |
| 165 | - | expected: "simple__com", | |
| 166 | - | description: "Simple domain", | |
| 167 | - | }, | |
| 168 | - | } | |
| 169 | - | ||
| 170 | - | for _, tt := range tests { | |
| 171 | - | t.Run(tt.domain, func(t *testing.T) { | |
| 172 | - | result := domain.SanitizeDomainForContainer(tt.domain) | |
| 173 | - | assert.Equal(t, tt.expected, result, "sanitization should match expected") | |
| 174 | - | }) | |
| 175 | - | } | |
| 176 | - | ||
| 177 | - | // Verify no collisions between potentially conflicting domains | |
| 178 | - | t.Run("NoCollisions", func(t *testing.T) { | |
| 179 | - | domains := []string{ | |
| 180 | - | "git.example.com", | |
| 181 | - | "git-example.com", | |
| 182 | - | "app:8080.example.com", | |
| 183 | - | "app-8080-example.com", | |
| 184 | - | } | |
| 185 | - | ||
| 186 | - | results := make(map[string]string) | |
| 187 | - | for _, d := range domains { | |
| 188 | - | result := domain.SanitizeDomainForContainer(d) | |
| 189 | - | if original, exists := results[result]; exists { | |
| 190 | - | t.Errorf("COLLISION: %q and %q both sanitize to %q", original, d, result) | |
| 191 | - | } | |
| 192 | - | results[result] = d | |
| 193 | - | } | |
| 194 | - | }) | |
| 195 | - | } | |
| 196 | - | ||
| 197 | - | func TestFileStore_AttachmentOperations(t *testing.T) { | |
| 198 | - | tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*") | |
| 199 | - | require.NoError(t, err) | |
| 200 | - | defer os.RemoveAll(tmpDir) | |
| 201 | - | ||
| 202 | - | store, err := NewFileStore(tmpDir, testLogger()) | |
| 203 | - | require.NoError(t, err) | |
| 204 | - | ||
| 205 | - | containerName := "gitea-postgres" | |
| 206 | - | ||
| 207 | - | t.Run("SetAttachment creates new file", func(t *testing.T) { | |
| 208 | - | err := store.SetAttachment(containerName, map[string]string{ | |
| 209 | - | "POSTGRES_DB": "gitea", | |
| 210 | - | "POSTGRES_PASSWORD": "secret123", | |
| 211 | - | }) | |
| 212 | - | require.NoError(t, err) | |
| 213 | - | ||
| 214 | - | // Verify file exists | |
| 215 | - | expectedFile := filepath.Join(tmpDir, "gordon-"+containerName+".env") | |
| 216 | - | _, err = os.Stat(expectedFile) | |
| 217 | - | assert.NoError(t, err, "expected attachment env file to exist at %s", expectedFile) | |
| 218 | - | }) | |
| 219 | - | ||
| 220 | - | t.Run("GetAllAttachment retrieves secrets", func(t *testing.T) { | |
| 221 | - | secrets, err := store.GetAllAttachment(containerName) | |
| 222 | - | require.NoError(t, err) | |
| 223 | - | assert.Equal(t, "gitea", secrets["POSTGRES_DB"]) | |
| 224 | - | assert.Equal(t, "secret123", secrets["POSTGRES_PASSWORD"]) | |
| 225 | - | }) | |
| 226 | - | ||
| 227 | - | t.Run("SetAttachment merges with existing", func(t *testing.T) { | |
| 228 | - | err := store.SetAttachment(containerName, map[string]string{ | |
| 229 | - | "NEW_VAR": "new_value", | |
| 230 | - | }) | |
| 231 | - | require.NoError(t, err) | |
| 232 | - | ||
| 233 | - | // Should have both old and new secrets | |
| 234 | - | secrets, err := store.GetAllAttachment(containerName) | |
| 235 | - | require.NoError(t, err) | |
| 236 | - | assert.Equal(t, "gitea", secrets["POSTGRES_DB"]) | |
| 237 | - | assert.Equal(t, "secret123", secrets["POSTGRES_PASSWORD"]) | |
| 238 | - | assert.Equal(t, "new_value", secrets["NEW_VAR"]) | |
| 239 | - | }) | |
| 240 | - | ||
| 241 | - | t.Run("GetAllAttachment returns empty map for non-existent container", func(t *testing.T) { | |
| 242 | - | secrets, err := store.GetAllAttachment("non-existent") | |
| 243 | - | require.NoError(t, err) | |
| 244 | - | assert.Empty(t, secrets) | |
| 245 | - | }) | |
| 246 | - | ||
| 247 | - | t.Run("SetAttachment with hyphenated container name", func(t *testing.T) { | |
| 248 | - | hyphenatedContainer := "gordon-git-example-com-gitea-postgres" | |
| 249 | - | err := store.SetAttachment(hyphenatedContainer, map[string]string{ | |
| 250 | - | "KEY": "value", | |
| 251 | - | }) | |
| 252 | - | require.NoError(t, err) | |
| 253 | - | ||
| 254 | - | secrets, err := store.GetAllAttachment(hyphenatedContainer) | |
| 255 | - | require.NoError(t, err) | |
| 256 | - | assert.Equal(t, "value", secrets["KEY"]) | |
| 257 | - | }) | |
| 258 | - | } | |
| 259 | - | ||
| 260 | - | func TestFileStore_AttachmentPathTraversal(t *testing.T) { | |
| 261 | - | tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*") | |
| 262 | - | require.NoError(t, err) | |
| 263 | - | defer os.RemoveAll(tmpDir) | |
| 264 | - | ||
| 265 | - | store, err := NewFileStore(tmpDir, testLogger()) | |
| 266 | - | require.NoError(t, err) | |
| 267 | - | ||
| 268 | - | tests := []struct { | |
| 269 | - | name string | |
| 270 | - | container string | |
| 271 | - | wantSetErr bool | |
| 272 | - | wantGetErr bool | |
| 273 | - | }{ | |
| 274 | - | // Valid container names | |
| 275 | - | {"simple", "postgres", false, false}, | |
| 276 | - | {"with hyphen", "gitea-postgres", false, false}, | |
| 277 | - | {"with underscore", "gitea_postgres", false, false}, | |
| 278 | - | {"complex real container", "gordon-git-example-com-gitea-postgres", false, false}, | |
| 279 | - | ||
| 280 | - | // Invalid container names - should fail | |
| 281 | - | {"empty", "", true, true}, | |
| 282 | - | {"starts with number", "1container", true, true}, | |
| 283 | - | {"starts with hyphen", "-container", true, true}, | |
| 284 | - | {"starts with underscore", "_container", true, true}, | |
| 285 | - | {"path traversal", "../etc", true, true}, | |
| 286 | - | {"contains slash", "container/name", true, true}, | |
| 287 | - | {"contains backslash", "container\\name", true, true}, | |
| 288 | - | } | |
| 289 | - | ||
| 290 | - | for _, tt := range tests { | |
| 291 | - | t.Run(tt.name, func(t *testing.T) { | |
| 292 | - | // Test SetAttachment | |
| 293 | - | err := store.SetAttachment(tt.container, map[string]string{"KEY": "value"}) | |
| 294 | - | if tt.wantSetErr { | |
| 295 | - | require.Error(t, err) | |
| 296 | - | } else { | |
| 297 | - | assert.NoError(t, err) | |
| 298 | - | } | |
| 299 | - | ||
| 300 | - | // Test GetAllAttachment | |
| 301 | - | _, err = store.GetAllAttachment(tt.container) | |
| 302 | - | if tt.wantGetErr { | |
| 303 | - | require.Error(t, err) | |
| 304 | - | } else { | |
| 305 | - | if !tt.wantSetErr { | |
| 306 | - | // Only check no error if we successfully set it | |
| 307 | - | assert.NoError(t, err) | |
| 308 | - | } | |
| 309 | - | } | |
| 310 | - | }) | |
| 311 | - | } | |
| 312 | - | } | |
| 313 | - | ||
| 314 | - | func TestFileStore_ValidDomainOperations(t *testing.T) { | |
| 315 | - | tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*") | |
| 316 | - | require.NoError(t, err) | |
| 317 | - | defer os.RemoveAll(tmpDir) | |
| 318 | - | ||
| 319 | - | store, err := NewFileStore(tmpDir, testLogger()) | |
| 320 | - | require.NoError(t, err) | |
| 321 | - | ||
| 322 | - | domain := "test.example.com" | |
| 323 | - | ||
| 324 | - | // Set secrets | |
| 325 | - | err = store.Set(domain, map[string]string{ | |
| 326 | - | "DB_HOST": "localhost", | |
| 327 | - | "DB_PASSWORD": "secret123", | |
| 328 | - | }) | |
| 329 | - | require.NoError(t, err) | |
| 330 | - | ||
| 331 | - | // Get all secrets | |
| 332 | - | secrets, err := store.GetAll(domain) | |
| 333 | - | require.NoError(t, err) | |
| 334 | - | assert.Equal(t, "localhost", secrets["DB_HOST"]) | |
| 335 | - | assert.Equal(t, "secret123", secrets["DB_PASSWORD"]) | |
| 336 | - | ||
| 337 | - | // List keys | |
| 338 | - | keys, err := store.ListKeys(domain) | |
| 339 | - | require.NoError(t, err) | |
| 340 | - | assert.Len(t, keys, 2) | |
| 341 | - | assert.Contains(t, keys, "DB_HOST") | |
| 342 | - | assert.Contains(t, keys, "DB_PASSWORD") | |
| 343 | - | ||
| 344 | - | // Delete a key | |
| 345 | - | err = store.Delete(domain, "DB_PASSWORD") | |
| 346 | - | require.NoError(t, err) | |
| 347 | - | ||
| 348 | - | // Verify deletion | |
| 349 | - | secrets, err = store.GetAll(domain) | |
| 350 | - | require.NoError(t, err) | |
| 351 | - | assert.Len(t, secrets, 1) | |
| 352 | - | assert.Equal(t, "localhost", secrets["DB_HOST"]) | |
| 353 | - | _, exists := secrets["DB_PASSWORD"] | |
| 354 | - | assert.False(t, exists) | |
| 355 | - | } | |
| 356 | - | ||
| 357 | - | func TestFileStore_DeleteAttachment(t *testing.T) { | |
| 358 | - | tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*") | |
| 359 | - | require.NoError(t, err) | |
| 360 | - | defer os.RemoveAll(tmpDir) | |
| 361 | - | ||
| 362 | - | store, err := NewFileStore(tmpDir, testLogger()) | |
| 363 | - | require.NoError(t, err) | |
| 364 | - | ||
| 365 | - | containerName := "gitea-postgres" | |
| 366 | - | ||
| 367 | - | // Seed with two secrets | |
| 368 | - | err = store.SetAttachment(containerName, map[string]string{ | |
| 369 | - | "POSTGRES_USER": "gitea", | |
| 370 | - | "POSTGRES_PASSWORD": "secret123", | |
| 371 | - | }) | |
| 372 | - | require.NoError(t, err) | |
| 373 | - | ||
| 374 | - | // Delete one key | |
| 375 | - | err = store.DeleteAttachment(containerName, "POSTGRES_PASSWORD") | |
| 376 | - | require.NoError(t, err) | |
| 377 | - | ||
| 378 | - | // Verify remaining | |
| 379 | - | secrets, err := store.GetAllAttachment(containerName) | |
| 380 | - | require.NoError(t, err) | |
| 381 | - | assert.Len(t, secrets, 1) | |
| 382 | - | assert.Equal(t, "gitea", secrets["POSTGRES_USER"]) | |
| 383 | - | _, exists := secrets["POSTGRES_PASSWORD"] | |
| 384 | - | assert.False(t, exists) | |
| 385 | - | ||
| 386 | - | // Delete nonexistent key - should be no-op | |
| 387 | - | err = store.DeleteAttachment(containerName, "NONEXISTENT_KEY") | |
| 388 | - | assert.NoError(t, err) | |
| 389 | - | ||
| 390 | - | // Verify secrets unchanged after no-op delete | |
| 391 | - | secrets, err = store.GetAllAttachment(containerName) | |
| 392 | - | require.NoError(t, err) | |
| 393 | - | assert.Len(t, secrets, 1) | |
| 394 | - | assert.Equal(t, "gitea", secrets["POSTGRES_USER"]) | |
| 395 | - | } | |
| 396 | - | ||
| 397 | - | func TestGetEnvFilePath(t *testing.T) { | |
| 398 | - | tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*") | |
| 399 | - | require.NoError(t, err) | |
| 400 | - | defer os.RemoveAll(tmpDir) | |
| 401 | - | ||
| 402 | - | store, err := NewFileStore(tmpDir, testLogger()) | |
| 403 | - | require.NoError(t, err) | |
| 404 | - | ||
| 405 | - | tests := []struct { | |
| 406 | - | domain string | |
| 407 | - | wantEmpty bool | |
| 408 | - | wantFilename string | |
| 409 | - | }{ | |
| 410 | - | {"example.com", false, "ZXhhbXBsZS5jb20.env"}, | |
| 411 | - | {"sub.example.com", false, "c3ViLmV4YW1wbGUuY29t.env"}, | |
| 412 | - | {"example.com:8080", false, "ZXhhbXBsZS5jb206ODA4MA.env"}, | |
| 413 | - | {"example.com/path", false, "ZXhhbXBsZS5jb20vcGF0aA.env"}, | |
| 414 | - | {"../evil", true, ""}, | |
| 415 | - | {"", true, ""}, | |
| 416 | - | } | |
| 417 | - | ||
| 418 | - | for _, tt := range tests { | |
| 419 | - | t.Run(tt.domain, func(t *testing.T) { | |
| 420 | - | path, err := store.getEnvFilePath(tt.domain) | |
| 421 | - | ||
| 422 | - | if tt.wantEmpty { | |
| 423 | - | assert.Error(t, err) | |
| 424 | - | assert.Empty(t, path) | |
| 425 | - | } else { | |
| 426 | - | require.NoError(t, err) | |
| 427 | - | assert.NotEmpty(t, path) | |
| 428 | - | assert.Equal(t, filepath.Join(tmpDir, tt.wantFilename), path) | |
| 429 | - | } | |
| 430 | - | }) | |
| 431 | - | } | |
| 432 | - | } | |
D internal/adapters/out/envloader/file.go +0 -253
| @@ -1,253 +0,0 @@ | |||
| 1 | - | // Package envloader implements the environment variable loader adapter. | |
| 2 | - | package envloader | |
| 3 | - | ||
| 4 | - | import ( | |
| 5 | - | "bufio" | |
| 6 | - | "context" | |
| 7 | - | "fmt" | |
| 8 | - | "os" | |
| 9 | - | "path/filepath" | |
| 10 | - | "strings" | |
| 11 | - | ||
| 12 | - | "github.com/bnema/zerowrap" | |
| 13 | - | ||
| 14 | - | "github.com/bnema/gordon/internal/boundaries/out" | |
| 15 | - | "github.com/bnema/gordon/internal/domain" | |
| 16 | - | ) | |
| 17 | - | ||
| 18 | - | // FileLoader implements the EnvLoader interface using filesystem-based env files. | |
| 19 | - | type FileLoader struct { | |
| 20 | - | envDir string | |
| 21 | - | secretProviders map[string]out.SecretProvider | |
| 22 | - | log zerowrap.Logger | |
| 23 | - | } | |
| 24 | - | ||
| 25 | - | // NewFileLoader creates a new file-based environment loader. | |
| 26 | - | func NewFileLoader(envDir string, log zerowrap.Logger) (*FileLoader, error) { | |
| 27 | - | // Ensure env directory exists | |
| 28 | - | if err := os.MkdirAll(envDir, 0700); err != nil { | |
| 29 | - | return nil, fmt.Errorf("failed to create env directory %s: %w", envDir, err) | |
| 30 | - | } | |
| 31 | - | ||
| 32 | - | log.Debug(). | |
| 33 | - | Str(zerowrap.FieldLayer, "adapter"). | |
| 34 | - | Str(zerowrap.FieldAdapter, "envloader"). | |
| 35 | - | Str("env_dir", envDir). | |
| 36 | - | Msg("env loader initialized") | |
| 37 | - | ||
| 38 | - | return &FileLoader{ | |
| 39 | - | envDir: envDir, | |
| 40 | - | secretProviders: make(map[string]out.SecretProvider), | |
| 41 | - | log: log, | |
| 42 | - | }, nil | |
| 43 | - | } | |
| 44 | - | ||
| 45 | - | // RegisterSecretProvider registers a secret provider for resolving secrets. | |
| 46 | - | func (l *FileLoader) RegisterSecretProvider(provider out.SecretProvider) { | |
| 47 | - | l.secretProviders[provider.Name()] = provider | |
| 48 | - | l.log.Debug(). | |
| 49 | - | Str(zerowrap.FieldLayer, "adapter"). | |
| 50 | - | Str(zerowrap.FieldAdapter, "envloader"). | |
| 51 | - | Str("provider", provider.Name()). | |
| 52 | - | Msg("secret provider registered") | |
| 53 | - | } | |
| 54 | - | ||
| 55 | - | // LoadEnv loads environment variables for a given domain. | |
| 56 | - | func (l *FileLoader) LoadEnv(ctx context.Context, domain string) ([]string, error) { | |
| 57 | - | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 58 | - | zerowrap.FieldLayer: "adapter", | |
| 59 | - | zerowrap.FieldAdapter: "envloader", | |
| 60 | - | zerowrap.FieldAction: "LoadEnv", | |
| 61 | - | "domain": domain, | |
| 62 | - | }) | |
| 63 | - | log := zerowrap.FromCtx(ctx) | |
| 64 | - | ||
| 65 | - | envVars := []string{} | |
| 66 | - | envFile, err := l.getEnvFilePath(domain) | |
| 67 | - | if err != nil { | |
| 68 | - | return nil, log.WrapErr(err, "invalid env file domain") | |
| 69 | - | } | |
| 70 | - | ||
| 71 | - | // Check if env file exists | |
| 72 | - | if _, err := os.Stat(envFile); os.IsNotExist(err) { | |
| 73 | - | log.Debug().Str("env_file", envFile).Msg("no env file found for route") | |
| 74 | - | return envVars, nil | |
| 75 | - | } | |
| 76 | - | ||
| 77 | - | log.Debug().Str("env_file", envFile).Msg("loading env file for route") | |
| 78 | - | ||
| 79 | - | file, err := os.Open(envFile) | |
| 80 | - | if err != nil { | |
| 81 | - | return nil, log.WrapErr(err, "failed to open env file") | |
| 82 | - | } | |
| 83 | - | defer file.Close() | |
| 84 | - | ||
| 85 | - | scanner := bufio.NewScanner(file) | |
| 86 | - | lineNum := 0 | |
| 87 | - | ||
| 88 | - | for scanner.Scan() { | |
| 89 | - | lineNum++ | |
| 90 | - | line := strings.TrimSpace(scanner.Text()) | |
| 91 | - | ||
| 92 | - | // Skip empty lines and comments | |
| 93 | - | if line == "" || strings.HasPrefix(line, "#") { | |
| 94 | - | continue | |
| 95 | - | } | |
| 96 | - | ||
| 97 | - | // Parse KEY=VALUE format | |
| 98 | - | parts := strings.SplitN(line, "=", 2) | |
| 99 | - | if len(parts) != 2 { | |
| 100 | - | log.Warn(). | |
| 101 | - | Int("line", lineNum). | |
| 102 | - | Msg("invalid env file line format, skipping") | |
| 103 | - | continue | |
| 104 | - | } | |
| 105 | - | ||
| 106 | - | key := strings.TrimSpace(parts[0]) | |
| 107 | - | value := strings.TrimSpace(parts[1]) | |
| 108 | - | ||
| 109 | - | // Remove quotes if present | |
| 110 | - | if len(value) >= 2 { | |
| 111 | - | if (strings.HasPrefix(value, "\"") && strings.HasSuffix(value, "\"")) || | |
| 112 | - | (strings.HasPrefix(value, "'") && strings.HasSuffix(value, "'")) { | |
| 113 | - | value = value[1 : len(value)-1] | |
| 114 | - | } | |
| 115 | - | } | |
| 116 | - | ||
| 117 | - | // Resolve secrets if value contains secret syntax | |
| 118 | - | resolvedValue, err := l.resolveSecrets(ctx, value) | |
| 119 | - | if err != nil { | |
| 120 | - | return nil, log.WrapErrWithFields( | |
| 121 | - | fmt.Errorf("key %s on line %d: %w", key, lineNum, err), | |
| 122 | - | "failed to resolve secret", | |
| 123 | - | map[string]any{ | |
| 124 | - | "key": key, | |
| 125 | - | "line": lineNum, | |
| 126 | - | }, | |
| 127 | - | ) | |
| 128 | - | } | |
| 129 | - | ||
| 130 | - | envVars = append(envVars, fmt.Sprintf("%s=%s", key, resolvedValue)) | |
| 131 | - | } | |
| 132 | - | ||
| 133 | - | if err := scanner.Err(); err != nil { | |
| 134 | - | return nil, log.WrapErr(err, "error reading env file") | |
| 135 | - | } | |
| 136 | - | ||
| 137 | - | log.Info().Int(zerowrap.FieldCount, len(envVars)).Msg("loaded environment variables for route") | |
| 138 | - | ||
| 139 | - | return envVars, nil | |
| 140 | - | } | |
| 141 | - | ||
| 142 | - | // CreateEnvFile creates an empty environment file for a new domain. | |
| 143 | - | func (l *FileLoader) CreateEnvFile(ctx context.Context, domain string) error { | |
| 144 | - | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 145 | - | zerowrap.FieldLayer: "adapter", | |
| 146 | - | zerowrap.FieldAdapter: "envloader", | |
| 147 | - | zerowrap.FieldAction: "CreateEnvFile", | |
| 148 | - | "domain": domain, | |
| 149 | - | }) | |
| 150 | - | log := zerowrap.FromCtx(ctx) | |
| 151 | - | ||
| 152 | - | envFile, err := l.getEnvFilePath(domain) | |
| 153 | - | if err != nil { | |
| 154 | - | return log.WrapErr(err, "invalid env file domain") | |
| 155 | - | } | |
| 156 | - | ||
| 157 | - | // Check if file already exists | |
| 158 | - | if _, err := os.Stat(envFile); err == nil { | |
| 159 | - | log.Debug().Str("env_file", envFile).Msg("env file already exists, skipping creation") | |
| 160 | - | return nil | |
| 161 | - | } else if !os.IsNotExist(err) { | |
| 162 | - | return log.WrapErr(err, "error checking env file") | |
| 163 | - | } | |
| 164 | - | ||
| 165 | - | // Create empty env file with helpful comments | |
| 166 | - | content := fmt.Sprintf(`# Environment variables for route: %s | |
| 167 | - | # Add KEY=VALUE pairs, one per line | |
| 168 | - | # Secrets can be referenced with ${provider:path} syntax | |
| 169 | - | ||
| 170 | - | `, domain) | |
| 171 | - | ||
| 172 | - | if err := os.WriteFile(envFile, []byte(content), 0600); err != nil { | |
| 173 | - | return log.WrapErr(err, "failed to create env file") | |
| 174 | - | } | |
| 175 | - | ||
| 176 | - | log.Info().Str("env_file", envFile).Msg("created empty env file for route") | |
| 177 | - | ||
| 178 | - | return nil | |
| 179 | - | } | |
| 180 | - | ||
| 181 | - | // EnvFileExists checks if an environment file exists for a domain. | |
| 182 | - | func (l *FileLoader) EnvFileExists(domain string) (bool, error) { | |
| 183 | - | envFile, err := l.getEnvFilePath(domain) | |
| 184 | - | if err != nil { | |
| 185 | - | return false, err | |
| 186 | - | } | |
| 187 | - | ||
| 188 | - | _, err = os.Stat(envFile) | |
| 189 | - | if os.IsNotExist(err) { | |
| 190 | - | return false, nil | |
| 191 | - | } | |
| 192 | - | if err != nil { | |
| 193 | - | return false, err | |
| 194 | - | } | |
| 195 | - | ||
| 196 | - | return true, nil | |
| 197 | - | } | |
| 198 | - | ||
| 199 | - | // resolveSecrets resolves any secret references in the value. | |
| 200 | - | // Secret syntax: ${provider:path} | |
| 201 | - | func (l *FileLoader) resolveSecrets(ctx context.Context, value string) (string, error) { | |
| 202 | - | // Check if value contains secret syntax: ${provider:path} | |
| 203 | - | if !strings.Contains(value, "${") { | |
| 204 | - | return value, nil | |
| 205 | - | } | |
| 206 | - | ||
| 207 | - | result := value | |
| 208 | - | for { | |
| 209 | - | start := strings.Index(result, "${") | |
| 210 | - | if start == -1 { | |
| 211 | - | break | |
| 212 | - | } | |
| 213 | - | ||
| 214 | - | end := strings.Index(result[start:], "}") | |
| 215 | - | if end == -1 { | |
| 216 | - | return "", fmt.Errorf("unclosed secret syntax") | |
| 217 | - | } | |
| 218 | - | end += start | |
| 219 | - | ||
| 220 | - | secretRef := result[start+2 : end] | |
| 221 | - | parts := strings.SplitN(secretRef, ":", 2) | |
| 222 | - | if len(parts) != 2 { | |
| 223 | - | return "", fmt.Errorf("invalid secret syntax: expected 'provider:path'") | |
| 224 | - | } | |
| 225 | - | ||
| 226 | - | providerName := parts[0] | |
| 227 | - | secretPath := parts[1] | |
| 228 | - | ||
| 229 | - | provider, exists := l.secretProviders[providerName] | |
| 230 | - | if !exists { | |
| 231 | - | return "", fmt.Errorf("unknown secret provider: %s", providerName) | |
| 232 | - | } | |
| 233 | - | ||
| 234 | - | secretValue, err := provider.GetSecret(ctx, secretPath) | |
| 235 | - | if err != nil { | |
| 236 | - | return "", fmt.Errorf("failed to get secret from provider %s", providerName) | |
| 237 | - | } | |
| 238 | - | ||
| 239 | - | // Replace the secret reference with the actual value | |
| 240 | - | result = result[:start] + secretValue + result[end+1:] | |
| 241 | - | } | |
| 242 | - | ||
| 243 | - | return result, nil | |
| 244 | - | } | |
| 245 | - | ||
| 246 | - | func (l *FileLoader) getEnvFilePath(domainName string) (string, error) { | |
| 247 | - | storageKey, err := domain.NewEnvStorageKey(domainName) | |
| 248 | - | if err != nil { | |
| 249 | - | return "", err | |
| 250 | - | } | |
| 251 | - | ||
| 252 | - | return filepath.Join(l.envDir, storageKey.FileName()), nil | |
| 253 | - | } | |
D internal/adapters/out/envloader/file_test.go +0 -368
| @@ -1,368 +0,0 @@ | |||
| 1 | - | package envloader | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "bytes" | |
| 5 | - | "context" | |
| 6 | - | "errors" | |
| 7 | - | "os" | |
| 8 | - | "path/filepath" | |
| 9 | - | "testing" | |
| 10 | - | ||
| 11 | - | "github.com/bnema/zerowrap" | |
| 12 | - | "github.com/stretchr/testify/assert" | |
| 13 | - | "github.com/stretchr/testify/require" | |
| 14 | - | ||
| 15 | - | "github.com/bnema/gordon/internal/boundaries/out/mocks" | |
| 16 | - | "github.com/bnema/gordon/internal/domain" | |
| 17 | - | ) | |
| 18 | - | ||
| 19 | - | func TestFileLoader_ResolveSecrets(t *testing.T) { | |
| 20 | - | // Create a temp directory for tests | |
| 21 | - | tmpDir := t.TempDir() | |
| 22 | - | log := zerowrap.New(zerowrap.Config{Level: "fatal"}) | |
| 23 | - | ||
| 24 | - | loader, err := NewFileLoader(tmpDir, log) | |
| 25 | - | require.NoError(t, err) | |
| 26 | - | ||
| 27 | - | ctx := context.Background() | |
| 28 | - | ||
| 29 | - | t.Run("no secret syntax returns value unchanged", func(t *testing.T) { | |
| 30 | - | result, err := loader.resolveSecrets(ctx, "plain-value") | |
| 31 | - | assert.NoError(t, err) | |
| 32 | - | assert.Equal(t, "plain-value", result) | |
| 33 | - | }) | |
| 34 | - | ||
| 35 | - | t.Run("unclosed secret syntax returns error", func(t *testing.T) { | |
| 36 | - | _, err := loader.resolveSecrets(ctx, "${pass:secret") | |
| 37 | - | assert.Error(t, err) | |
| 38 | - | assert.Contains(t, err.Error(), "unclosed secret syntax") | |
| 39 | - | }) | |
| 40 | - | ||
| 41 | - | t.Run("invalid secret syntax without colon returns error", func(t *testing.T) { | |
| 42 | - | _, err := loader.resolveSecrets(ctx, "${invalid}") | |
| 43 | - | assert.Error(t, err) | |
| 44 | - | assert.Contains(t, err.Error(), "invalid secret syntax") | |
| 45 | - | }) | |
| 46 | - | ||
| 47 | - | t.Run("unknown provider returns error", func(t *testing.T) { | |
| 48 | - | _, err := loader.resolveSecrets(ctx, "${unknown:path}") | |
| 49 | - | assert.Error(t, err) | |
| 50 | - | assert.Contains(t, err.Error(), "unknown secret provider") | |
| 51 | - | }) | |
| 52 | - | ||
| 53 | - | t.Run("pass provider resolves secret", func(t *testing.T) { | |
| 54 | - | mockPass := mocks.NewMockSecretProvider(t) | |
| 55 | - | mockPass.EXPECT().Name().Return("pass") | |
| 56 | - | mockPass.EXPECT().GetSecret(ctx, "company/api-key").Return("secret-value-123", nil) | |
| 57 | - | ||
| 58 | - | loader.RegisterSecretProvider(mockPass) | |
| 59 | - | ||
| 60 | - | result, err := loader.resolveSecrets(ctx, "${pass:company/api-key}") | |
| 61 | - | assert.NoError(t, err) | |
| 62 | - | assert.Equal(t, "secret-value-123", result) | |
| 63 | - | }) | |
| 64 | - | ||
| 65 | - | t.Run("sops provider resolves secret", func(t *testing.T) { | |
| 66 | - | mockSops := mocks.NewMockSecretProvider(t) | |
| 67 | - | mockSops.EXPECT().Name().Return("sops") | |
| 68 | - | mockSops.EXPECT().GetSecret(ctx, "secrets.yaml:app.password").Return("sops-secret", nil) | |
| 69 | - | ||
| 70 | - | loader.RegisterSecretProvider(mockSops) | |
| 71 | - | ||
| 72 | - | result, err := loader.resolveSecrets(ctx, "${sops:secrets.yaml:app.password}") | |
| 73 | - | assert.NoError(t, err) | |
| 74 | - | assert.Equal(t, "sops-secret", result) | |
| 75 | - | }) | |
| 76 | - | ||
| 77 | - | t.Run("multiple secrets in one value", func(t *testing.T) { | |
| 78 | - | mockPass := mocks.NewMockSecretProvider(t) | |
| 79 | - | mockPass.EXPECT().Name().Return("pass") | |
| 80 | - | mockPass.EXPECT().GetSecret(ctx, "db/user").Return("admin", nil) | |
| 81 | - | mockPass.EXPECT().GetSecret(ctx, "db/pass").Return("secret123", nil) | |
| 82 | - | ||
| 83 | - | loader.RegisterSecretProvider(mockPass) | |
| 84 | - | ||
| 85 | - | result, err := loader.resolveSecrets(ctx, "postgresql://${pass:db/user}:${pass:db/pass}@localhost:5432/db") | |
| 86 | - | assert.NoError(t, err) | |
| 87 | - | assert.Equal(t, "postgresql://admin:secret123@localhost:5432/db", result) | |
| 88 | - | }) | |
| 89 | - | ||
| 90 | - | t.Run("provider error is propagated", func(t *testing.T) { | |
| 91 | - | mockPass := mocks.NewMockSecretProvider(t) | |
| 92 | - | mockPass.EXPECT().Name().Return("pass") | |
| 93 | - | mockPass.EXPECT().GetSecret(ctx, "missing/secret").Return("", assert.AnError) | |
| 94 | - | ||
| 95 | - | loader.RegisterSecretProvider(mockPass) | |
| 96 | - | ||
| 97 | - | _, err := loader.resolveSecrets(ctx, "${pass:missing/secret}") | |
| 98 | - | assert.Error(t, err) | |
| 99 | - | assert.Contains(t, err.Error(), "failed to get secret from provider") | |
| 100 | - | }) | |
| 101 | - | } | |
| 102 | - | ||
| 103 | - | func TestFileLoader_LoadEnv(t *testing.T) { | |
| 104 | - | log := zerowrap.New(zerowrap.Config{Level: "fatal"}) | |
| 105 | - | ctx := context.Background() | |
| 106 | - | ||
| 107 | - | t.Run("missing env file returns empty slice", func(t *testing.T) { | |
| 108 | - | tmpDir := t.TempDir() | |
| 109 | - | loader, err := NewFileLoader(tmpDir, log) | |
| 110 | - | require.NoError(t, err) | |
| 111 | - | ||
| 112 | - | envVars, err := loader.LoadEnv(ctx, "nonexistent.example.com") | |
| 113 | - | assert.NoError(t, err) | |
| 114 | - | assert.Empty(t, envVars) | |
| 115 | - | }) | |
| 116 | - | ||
| 117 | - | t.Run("loads simple env file", func(t *testing.T) { | |
| 118 | - | tmpDir := t.TempDir() | |
| 119 | - | loader, err := NewFileLoader(tmpDir, log) | |
| 120 | - | require.NoError(t, err) | |
| 121 | - | ||
| 122 | - | // Create env file | |
| 123 | - | envContent := `# Comment line | |
| 124 | - | NODE_ENV=production | |
| 125 | - | API_URL=https://api.example.com | |
| 126 | - | ` | |
| 127 | - | storageKey, err := domain.NewEnvStorageKey("app.example.com") | |
| 128 | - | require.NoError(t, err) | |
| 129 | - | envFile := filepath.Join(tmpDir, storageKey.FileName()) | |
| 130 | - | require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600)) | |
| 131 | - | ||
| 132 | - | envVars, err := loader.LoadEnv(ctx, "app.example.com") | |
| 133 | - | assert.NoError(t, err) | |
| 134 | - | assert.Len(t, envVars, 2) | |
| 135 | - | assert.Contains(t, envVars, "NODE_ENV=production") | |
| 136 | - | assert.Contains(t, envVars, "API_URL=https://api.example.com") | |
| 137 | - | }) | |
| 138 | - | ||
| 139 | - | t.Run("loads env file with secrets", func(t *testing.T) { | |
| 140 | - | tmpDir := t.TempDir() | |
| 141 | - | loader, err := NewFileLoader(tmpDir, log) | |
| 142 | - | require.NoError(t, err) | |
| 143 | - | ||
| 144 | - | // Register mock provider | |
| 145 | - | mockPass := mocks.NewMockSecretProvider(t) | |
| 146 | - | mockPass.EXPECT().Name().Return("pass") | |
| 147 | - | mockPass.EXPECT().GetSecret(ctx, "app/api-key").Return("super-secret-key", nil) | |
| 148 | - | loader.RegisterSecretProvider(mockPass) | |
| 149 | - | ||
| 150 | - | // Create env file with secret reference | |
| 151 | - | envContent := `NODE_ENV=production | |
| 152 | - | API_KEY=${pass:app/api-key} | |
| 153 | - | ` | |
| 154 | - | storageKey, err := domain.NewEnvStorageKey("app.example.com") | |
| 155 | - | require.NoError(t, err) | |
| 156 | - | envFile := filepath.Join(tmpDir, storageKey.FileName()) | |
| 157 | - | require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600)) | |
| 158 | - | ||
| 159 | - | envVars, err := loader.LoadEnv(ctx, "app.example.com") | |
| 160 | - | assert.NoError(t, err) | |
| 161 | - | assert.Len(t, envVars, 2) | |
| 162 | - | assert.Contains(t, envVars, "NODE_ENV=production") | |
| 163 | - | assert.Contains(t, envVars, "API_KEY=super-secret-key") | |
| 164 | - | }) | |
| 165 | - | ||
| 166 | - | t.Run("handles quoted values", func(t *testing.T) { | |
| 167 | - | tmpDir := t.TempDir() | |
| 168 | - | loader, err := NewFileLoader(tmpDir, log) | |
| 169 | - | require.NoError(t, err) | |
| 170 | - | ||
| 171 | - | envContent := `DOUBLE_QUOTED="hello world" | |
| 172 | - | SINGLE_QUOTED='hello world' | |
| 173 | - | ` | |
| 174 | - | storageKey, err := domain.NewEnvStorageKey("app.example.com") | |
| 175 | - | require.NoError(t, err) | |
| 176 | - | envFile := filepath.Join(tmpDir, storageKey.FileName()) | |
| 177 | - | require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600)) | |
| 178 | - | ||
| 179 | - | envVars, err := loader.LoadEnv(ctx, "app.example.com") | |
| 180 | - | assert.NoError(t, err) | |
| 181 | - | assert.Len(t, envVars, 2) | |
| 182 | - | assert.Contains(t, envVars, "DOUBLE_QUOTED=hello world") | |
| 183 | - | assert.Contains(t, envVars, "SINGLE_QUOTED=hello world") | |
| 184 | - | }) | |
| 185 | - | ||
| 186 | - | t.Run("skips invalid lines", func(t *testing.T) { | |
| 187 | - | tmpDir := t.TempDir() | |
| 188 | - | loader, err := NewFileLoader(tmpDir, log) | |
| 189 | - | require.NoError(t, err) | |
| 190 | - | ||
| 191 | - | envContent := `VALID=value | |
| 192 | - | invalid line without equals | |
| 193 | - | ALSO_VALID=another | |
| 194 | - | ` | |
| 195 | - | storageKey, err := domain.NewEnvStorageKey("app.example.com") | |
| 196 | - | require.NoError(t, err) | |
| 197 | - | envFile := filepath.Join(tmpDir, storageKey.FileName()) | |
| 198 | - | require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600)) | |
| 199 | - | ||
| 200 | - | envVars, err := loader.LoadEnv(ctx, "app.example.com") | |
| 201 | - | assert.NoError(t, err) | |
| 202 | - | assert.Len(t, envVars, 2) | |
| 203 | - | assert.Contains(t, envVars, "VALID=value") | |
| 204 | - | assert.Contains(t, envVars, "ALSO_VALID=another") | |
| 205 | - | }) | |
| 206 | - | } | |
| 207 | - | ||
| 208 | - | func TestFileLoader_LoadEnv_RedactsSecretBearingDiagnostics(t *testing.T) { | |
| 209 | - | ctx := context.Background() | |
| 210 | - | ||
| 211 | - | t.Run("invalid line warning omits raw line content", func(t *testing.T) { | |
| 212 | - | var logs bytes.Buffer | |
| 213 | - | log := zerowrap.New(zerowrap.Config{Level: "warn", Format: "json", Output: &logs}) | |
| 214 | - | loader, err := NewFileLoader(t.TempDir(), log) | |
| 215 | - | require.NoError(t, err) | |
| 216 | - | ctx := zerowrap.WithCtx(ctx, log) | |
| 217 | - | ||
| 218 | - | envFile, err := loader.getEnvFilePath("app.example.com") | |
| 219 | - | require.NoError(t, err) | |
| 220 | - | require.NoError(t, os.WriteFile(envFile, []byte("valid line leaks super-secret-token\nVALID=value\n"), 0600)) | |
| 221 | - | ||
| 222 | - | envVars, err := loader.LoadEnv(ctx, "app.example.com") | |
| 223 | - | require.NoError(t, err) | |
| 224 | - | assert.Equal(t, []string{"VALID=value"}, envVars) | |
| 225 | - | assert.NotContains(t, logs.String(), "super-secret-token") | |
| 226 | - | assert.NotContains(t, logs.String(), "valid line leaks") | |
| 227 | - | assert.Contains(t, logs.String(), "line") | |
| 228 | - | }) | |
| 229 | - | ||
| 230 | - | t.Run("secret resolution error omits unclosed raw value", func(t *testing.T) { | |
| 231 | - | log := zerowrap.New(zerowrap.Config{Level: "fatal"}) | |
| 232 | - | loader, err := NewFileLoader(t.TempDir(), log) | |
| 233 | - | require.NoError(t, err) | |
| 234 | - | ||
| 235 | - | envFile, err := loader.getEnvFilePath("app.example.com") | |
| 236 | - | require.NoError(t, err) | |
| 237 | - | require.NoError(t, os.WriteFile(envFile, []byte("DATABASE_URL=postgres://user:${pass:db-password-super-secret\n"), 0600)) | |
| 238 | - | ||
| 239 | - | _, err = loader.LoadEnv(ctx, "app.example.com") | |
| 240 | - | require.Error(t, err) | |
| 241 | - | assert.Contains(t, err.Error(), "failed to resolve secret") | |
| 242 | - | assert.Contains(t, err.Error(), "DATABASE_URL") | |
| 243 | - | assert.NotContains(t, err.Error(), "postgres://user") | |
| 244 | - | assert.NotContains(t, err.Error(), "db-password-super-secret") | |
| 245 | - | }) | |
| 246 | - | ||
| 247 | - | t.Run("secret resolution error omits malformed closed secret ref", func(t *testing.T) { | |
| 248 | - | log := zerowrap.New(zerowrap.Config{Level: "fatal"}) | |
| 249 | - | loader, err := NewFileLoader(t.TempDir(), log) | |
| 250 | - | require.NoError(t, err) | |
| 251 | - | ||
| 252 | - | envFile, err := loader.getEnvFilePath("app.example.com") | |
| 253 | - | require.NoError(t, err) | |
| 254 | - | require.NoError(t, os.WriteFile(envFile, []byte("TOKEN=${db-password-super-secret}\n"), 0600)) | |
| 255 | - | ||
| 256 | - | _, err = loader.LoadEnv(ctx, "app.example.com") | |
| 257 | - | require.Error(t, err) | |
| 258 | - | assert.Contains(t, err.Error(), "failed to resolve secret") | |
| 259 | - | assert.Contains(t, err.Error(), "TOKEN") | |
| 260 | - | assert.NotContains(t, err.Error(), "db-password-super-secret") | |
| 261 | - | }) | |
| 262 | - | ||
| 263 | - | t.Run("secret resolution error omits provider error details", func(t *testing.T) { | |
| 264 | - | log := zerowrap.New(zerowrap.Config{Level: "fatal"}) | |
| 265 | - | loader, err := NewFileLoader(t.TempDir(), log) | |
| 266 | - | require.NoError(t, err) | |
| 267 | - | ||
| 268 | - | mockPass := mocks.NewMockSecretProvider(t) | |
| 269 | - | mockPass.EXPECT().Name().Return("pass") | |
| 270 | - | mockPass.EXPECT().GetSecret(ctx, "db-password-super-secret").Return("", errors.New("backend leaked db-password-super-secret")) | |
| 271 | - | loader.RegisterSecretProvider(mockPass) | |
| 272 | - | ||
| 273 | - | envFile, err := loader.getEnvFilePath("app.example.com") | |
| 274 | - | require.NoError(t, err) | |
| 275 | - | require.NoError(t, os.WriteFile(envFile, []byte("TOKEN=${pass:db-password-super-secret}\n"), 0600)) | |
| 276 | - | ||
| 277 | - | _, err = loader.LoadEnv(ctx, "app.example.com") | |
| 278 | - | require.Error(t, err) | |
| 279 | - | assert.Contains(t, err.Error(), "failed to resolve secret") | |
| 280 | - | assert.Contains(t, err.Error(), "TOKEN") | |
| 281 | - | assert.Contains(t, err.Error(), "provider pass") | |
| 282 | - | assert.NotContains(t, err.Error(), "db-password-super-secret") | |
| 283 | - | }) | |
| 284 | - | } | |
| 285 | - | ||
| 286 | - | func TestFileLoader_GetEnvFilePath(t *testing.T) { | |
| 287 | - | tmpDir := t.TempDir() | |
| 288 | - | log := zerowrap.New(zerowrap.Config{Level: "fatal"}) | |
| 289 | - | ||
| 290 | - | loader, err := NewFileLoader(tmpDir, log) | |
| 291 | - | require.NoError(t, err) | |
| 292 | - | ||
| 293 | - | tests := []struct { | |
| 294 | - | domain string | |
| 295 | - | expected string | |
| 296 | - | }{ | |
| 297 | - | {"app.example.com", "YXBwLmV4YW1wbGUuY29t.env"}, | |
| 298 | - | {"api.sub.example.com", "YXBpLnN1Yi5leGFtcGxlLmNvbQ.env"}, | |
| 299 | - | {"localhost:8080", "bG9jYWxob3N0OjgwODA.env"}, | |
| 300 | - | } | |
| 301 | - | ||
| 302 | - | for _, tt := range tests { | |
| 303 | - | t.Run(tt.domain, func(t *testing.T) { | |
| 304 | - | result, err := loader.getEnvFilePath(tt.domain) | |
| 305 | - | require.NoError(t, err) | |
| 306 | - | assert.Equal(t, filepath.Join(tmpDir, tt.expected), result) | |
| 307 | - | }) | |
| 308 | - | } | |
| 309 | - | } | |
| 310 | - | ||
| 311 | - | func TestFileLoader_GetEnvFilePath_DistinguishesSeparators(t *testing.T) { | |
| 312 | - | tmpDir := t.TempDir() | |
| 313 | - | log := zerowrap.New(zerowrap.Config{Level: "fatal"}) | |
| 314 | - | ||
| 315 | - | loader, err := NewFileLoader(tmpDir, log) | |
| 316 | - | require.NoError(t, err) | |
| 317 | - | ||
| 318 | - | dotPath, err := loader.getEnvFilePath("app.example.com") | |
| 319 | - | require.NoError(t, err) | |
| 320 | - | portPath, err := loader.getEnvFilePath("app:example:com") | |
| 321 | - | require.NoError(t, err) | |
| 322 | - | slashPath, err := loader.getEnvFilePath("app/example/com") | |
| 323 | - | require.NoError(t, err) | |
| 324 | - | ||
| 325 | - | assert.NotEqual(t, dotPath, portPath) | |
| 326 | - | assert.NotEqual(t, dotPath, slashPath) | |
| 327 | - | assert.NotEqual(t, portPath, slashPath) | |
| 328 | - | } | |
| 329 | - | ||
| 330 | - | func TestFileLoader_GetEnvFilePath_RejectsInvalidDomain(t *testing.T) { | |
| 331 | - | tmpDir := t.TempDir() | |
| 332 | - | log := zerowrap.New(zerowrap.Config{Level: "fatal"}) | |
| 333 | - | ||
| 334 | - | loader, err := NewFileLoader(tmpDir, log) | |
| 335 | - | require.NoError(t, err) | |
| 336 | - | ||
| 337 | - | _, err = loader.getEnvFilePath("../etc/passwd") | |
| 338 | - | require.ErrorIs(t, err, domain.ErrPathTraversal) | |
| 339 | - | } | |
| 340 | - | ||
| 341 | - | func TestFileLoader_EnvFileExists(t *testing.T) { | |
| 342 | - | log := zerowrap.New(zerowrap.Config{Level: "fatal"}) | |
| 343 | - | ||
| 344 | - | t.Run("returns false nil when file is missing", func(t *testing.T) { | |
| 345 | - | tmpDir := t.TempDir() | |
| 346 | - | loader, err := NewFileLoader(tmpDir, log) | |
| 347 | - | require.NoError(t, err) | |
| 348 | - | ||
| 349 | - | exists, err := loader.EnvFileExists("app.example.com") | |
| 350 | - | require.NoError(t, err) | |
| 351 | - | assert.False(t, exists) | |
| 352 | - | }) | |
| 353 | - | ||
| 354 | - | t.Run("returns true nil when file exists", func(t *testing.T) { | |
| 355 | - | tmpDir := t.TempDir() | |
| 356 | - | loader, err := NewFileLoader(tmpDir, log) | |
| 357 | - | require.NoError(t, err) | |
| 358 | - | ||
| 359 | - | storageKey, err := domain.NewEnvStorageKey("app.example.com") | |
| 360 | - | require.NoError(t, err) | |
| 361 | - | envFile := filepath.Join(tmpDir, storageKey.FileName()) | |
| 362 | - | require.NoError(t, os.WriteFile(envFile, []byte("KEY=value\n"), 0600)) | |
| 363 | - | ||
| 364 | - | exists, err := loader.EnvFileExists("app.example.com") | |
| 365 | - | require.NoError(t, err) | |
| 366 | - | assert.True(t, exists) | |
| 367 | - | }) | |
| 368 | - | } | |
D internal/adapters/out/envloader/pass_loader.go +0 -117
| @@ -1,117 +0,0 @@ | |||
| 1 | - | // Package envloader implements the environment variable loader adapter. | |
| 2 | - | package envloader | |
| 3 | - | ||
| 4 | - | import ( | |
| 5 | - | "context" | |
| 6 | - | "fmt" | |
| 7 | - | "sort" | |
| 8 | - | "strings" | |
| 9 | - | ||
| 10 | - | "github.com/bnema/zerowrap" | |
| 11 | - | ||
| 12 | - | "github.com/bnema/gordon/internal/adapters/out/domainsecrets" | |
| 13 | - | ) | |
| 14 | - | ||
| 15 | - | // PassLoader implements the EnvLoader interface using pass-backed secrets. | |
| 16 | - | type PassLoader struct { | |
| 17 | - | store *domainsecrets.PassStore | |
| 18 | - | log zerowrap.Logger | |
| 19 | - | } | |
| 20 | - | ||
| 21 | - | // NewPassLoader creates a new pass-based environment loader. | |
| 22 | - | func NewPassLoader(store *domainsecrets.PassStore, log zerowrap.Logger) (*PassLoader, error) { | |
| 23 | - | if store == nil { | |
| 24 | - | return nil, fmt.Errorf("pass store is required") | |
| 25 | - | } | |
| 26 | - | ||
| 27 | - | log.Debug(). | |
| 28 | - | Str(zerowrap.FieldLayer, "adapter"). | |
| 29 | - | Str(zerowrap.FieldAdapter, "envloader"). | |
| 30 | - | Str("provider", "pass"). | |
| 31 | - | Msg("env loader initialized") | |
| 32 | - | ||
| 33 | - | return &PassLoader{ | |
| 34 | - | store: store, | |
| 35 | - | log: log, | |
| 36 | - | }, nil | |
| 37 | - | } | |
| 38 | - | ||
| 39 | - | // LoadEnv loads environment variables for a given domain. | |
| 40 | - | func (l *PassLoader) LoadEnv(ctx context.Context, domain string) ([]string, error) { | |
| 41 | - | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 42 | - | zerowrap.FieldLayer: "adapter", | |
| 43 | - | zerowrap.FieldAdapter: "envloader", | |
| 44 | - | zerowrap.FieldAction: "LoadEnv", | |
| 45 | - | "domain": domain, | |
| 46 | - | }) | |
| 47 | - | log := zerowrap.FromCtx(ctx) | |
| 48 | - | ||
| 49 | - | var secretsMap map[string]string | |
| 50 | - | var err error | |
| 51 | - | ||
| 52 | - | if isAttachmentContainer(domain) { | |
| 53 | - | secretsMap, err = l.store.GetAllAttachment(domain) | |
| 54 | - | if err != nil { | |
| 55 | - | return nil, log.WrapErr(err, "failed to load attachment env from pass") | |
| 56 | - | } | |
| 57 | - | } else { | |
| 58 | - | secretsMap, err = l.store.GetAll(domain) | |
| 59 | - | if err != nil { | |
| 60 | - | return nil, log.WrapErr(err, "failed to load env from pass") | |
| 61 | - | } | |
| 62 | - | } | |
| 63 | - | ||
| 64 | - | keys := make([]string, 0, len(secretsMap)) | |
| 65 | - | for key := range secretsMap { | |
| 66 | - | keys = append(keys, key) | |
| 67 | - | } | |
| 68 | - | sort.Strings(keys) | |
| 69 | - | ||
| 70 | - | envVars := make([]string, 0, len(keys)) | |
| 71 | - | for _, key := range keys { | |
| 72 | - | envVars = append(envVars, fmt.Sprintf("%s=%s", key, secretsMap[key])) | |
| 73 | - | } | |
| 74 | - | ||
| 75 | - | log.Info().Int(zerowrap.FieldCount, len(envVars)).Msg("loaded environment variables for route") | |
| 76 | - | ||
| 77 | - | return envVars, nil | |
| 78 | - | } | |
| 79 | - | ||
| 80 | - | // isAttachmentContainer determines whether a given name refers to an attachment container | |
| 81 | - | // or a domain. It uses a simple heuristic: attachment container names always start with | |
| 82 | - | // the "gordon-" prefix (e.g., "gordon-git-example-com-gitea-postgres"). | |
| 83 | - | // | |
| 84 | - | // LIMITATION: This heuristic assumes domains never start with "gordon-". A domain like | |
| 85 | - | // "gordon-app.example.com" would be incorrectly classified as an attachment container. | |
| 86 | - | // This is an acceptable trade-off for simplicity, but if such domains become common, | |
| 87 | - | // this should be replaced with a more robust detection mechanism (e.g., checking both | |
| 88 | - | // GetAll() and GetAllAttachment() and seeing which returns results). | |
| 89 | - | func isAttachmentContainer(name string) bool { | |
| 90 | - | // Attachment container names start with "gordon-" and never contain dots. | |
| 91 | - | // Domain names (including preview domains like "gordon--test.bnema.dev") | |
| 92 | - | // always contain dots, so checking for dots distinguishes them. | |
| 93 | - | return strings.HasPrefix(name, "gordon-") && !strings.Contains(name, ".") | |
| 94 | - | } | |
| 95 | - | ||
| 96 | - | // CreateEnvFile is a no-op for pass-backed loader. | |
| 97 | - | func (l *PassLoader) CreateEnvFile(ctx context.Context, domain string) error { | |
| 98 | - | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 99 | - | zerowrap.FieldLayer: "adapter", | |
| 100 | - | zerowrap.FieldAdapter: "envloader", | |
| 101 | - | zerowrap.FieldAction: "CreateEnvFile", | |
| 102 | - | "domain": domain, | |
| 103 | - | }) | |
| 104 | - | log := zerowrap.FromCtx(ctx) | |
| 105 | - | log.Debug().Msg("pass loader does not create env files") | |
| 106 | - | return nil | |
| 107 | - | } | |
| 108 | - | ||
| 109 | - | // EnvFileExists checks if a manifest exists for a domain in pass. | |
| 110 | - | func (l *PassLoader) EnvFileExists(domain string) (bool, error) { | |
| 111 | - | exists, err := l.store.ManifestExists(domain) | |
| 112 | - | if err != nil { | |
| 113 | - | l.log.Warn().Err(err).Str("domain", domain).Msg("failed to check pass manifest") | |
| 114 | - | return false, err | |
| 115 | - | } | |
| 116 | - | return exists, nil | |
| 117 | - | } | |
D internal/adapters/out/envloader/pass_loader_test.go +0 -108
| @@ -1,108 +0,0 @@ | |||
| 1 | - | package envloader | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "context" | |
| 5 | - | "fmt" | |
| 6 | - | "os/exec" | |
| 7 | - | "testing" | |
| 8 | - | "time" | |
| 9 | - | ||
| 10 | - | "github.com/stretchr/testify/assert" | |
| 11 | - | "github.com/stretchr/testify/require" | |
| 12 | - | ||
| 13 | - | "github.com/bnema/gordon/internal/adapters/out/domainsecrets" | |
| 14 | - | "github.com/bnema/zerowrap" | |
| 15 | - | ) | |
| 16 | - | ||
| 17 | - | func passCmd(args ...string) error { | |
| 18 | - | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) | |
| 19 | - | defer cancel() | |
| 20 | - | return exec.CommandContext(ctx, "pass", args...).Run() | |
| 21 | - | } | |
| 22 | - | ||
| 23 | - | func requirePass(t *testing.T) { | |
| 24 | - | if err := passCmd("version"); err != nil { | |
| 25 | - | t.Skip("pass not available") | |
| 26 | - | } | |
| 27 | - | if err := passCmd("ls"); err != nil { | |
| 28 | - | t.Skip("pass store not initialized") | |
| 29 | - | } | |
| 30 | - | } | |
| 31 | - | ||
| 32 | - | func cleanupPassEnv(t *testing.T, path string) { | |
| 33 | - | _ = passCmd("rm", "-rf", path) | |
| 34 | - | } | |
| 35 | - | ||
| 36 | - | func TestPassLoader_LoadEnv_Domain(t *testing.T) { | |
| 37 | - | requirePass(t) | |
| 38 | - | ||
| 39 | - | log := zerowrap.New(zerowrap.Config{Level: "fatal"}) | |
| 40 | - | store, err := domainsecrets.NewPassStore(log) | |
| 41 | - | require.NoError(t, err) | |
| 42 | - | ||
| 43 | - | loader, err := NewPassLoader(store, log) | |
| 44 | - | require.NoError(t, err) | |
| 45 | - | ||
| 46 | - | domainName := fmt.Sprintf("test.%d.example.com", time.Now().UnixNano()) | |
| 47 | - | defer cleanupPassEnv(t, fmt.Sprintf("%s/%s", domainsecrets.PassDomainSecretsPath, domainName)) | |
| 48 | - | ||
| 49 | - | secretsMap := map[string]string{ | |
| 50 | - | "API_KEY": "test123", | |
| 51 | - | "DB_HOST": "localhost", | |
| 52 | - | } | |
| 53 | - | err = store.Set(domainName, secretsMap) | |
| 54 | - | require.NoError(t, err) | |
| 55 | - | ||
| 56 | - | ctx := context.Background() | |
| 57 | - | envVars, err := loader.LoadEnv(ctx, domainName) | |
| 58 | - | require.NoError(t, err) | |
| 59 | - | assert.Len(t, envVars, 2) | |
| 60 | - | assert.Contains(t, envVars, "API_KEY=test123") | |
| 61 | - | assert.Contains(t, envVars, "DB_HOST=localhost") | |
| 62 | - | } | |
| 63 | - | ||
| 64 | - | func TestPassLoader_LoadEnv_Attachment(t *testing.T) { | |
| 65 | - | requirePass(t) | |
| 66 | - | ||
| 67 | - | log := zerowrap.New(zerowrap.Config{Level: "fatal"}) | |
| 68 | - | store, err := domainsecrets.NewPassStore(log) | |
| 69 | - | require.NoError(t, err) | |
| 70 | - | ||
| 71 | - | loader, err := NewPassLoader(store, log) | |
| 72 | - | require.NoError(t, err) | |
| 73 | - | ||
| 74 | - | containerName := fmt.Sprintf("gordon-gitea-postgres-%d", time.Now().UnixNano()) | |
| 75 | - | defer cleanupPassEnv(t, fmt.Sprintf("%s/%s", domainsecrets.PassAttachmentPath, containerName)) | |
| 76 | - | ||
| 77 | - | secretsMap := map[string]string{ | |
| 78 | - | "POSTGRES_USER": "gitea", | |
| 79 | - | "POSTGRES_PASSWORD": "secret123", | |
| 80 | - | } | |
| 81 | - | err = store.SetAttachment(containerName, secretsMap) | |
| 82 | - | require.NoError(t, err) | |
| 83 | - | ||
| 84 | - | ctx := context.Background() | |
| 85 | - | envVars, err := loader.LoadEnv(ctx, containerName) | |
| 86 | - | require.NoError(t, err) | |
| 87 | - | assert.Len(t, envVars, 2) | |
| 88 | - | assert.Contains(t, envVars, "POSTGRES_USER=gitea") | |
| 89 | - | assert.Contains(t, envVars, "POSTGRES_PASSWORD=secret123") | |
| 90 | - | } | |
| 91 | - | ||
| 92 | - | func TestPassLoader_LoadEnv_EmptyDomain(t *testing.T) { | |
| 93 | - | requirePass(t) | |
| 94 | - | ||
| 95 | - | log := zerowrap.New(zerowrap.Config{Level: "fatal"}) | |
| 96 | - | store, err := domainsecrets.NewPassStore(log) | |
| 97 | - | require.NoError(t, err) | |
| 98 | - | ||
| 99 | - | loader, err := NewPassLoader(store, log) | |
| 100 | - | require.NoError(t, err) | |
| 101 | - | ||
| 102 | - | domainName := fmt.Sprintf("empty.%d.example.com", time.Now().UnixNano()) | |
| 103 | - | ||
| 104 | - | ctx := context.Background() | |
| 105 | - | envVars, err := loader.LoadEnv(ctx, domainName) | |
| 106 | - | require.NoError(t, err) | |
| 107 | - | assert.Len(t, envVars, 0) | |
| 108 | - | } | |
M internal/app/kernel.go +0 -14
| @@ -10,7 +10,6 @@ import ( | |||
| 10 | 10 | ||
| 11 | 11 | "github.com/bnema/gordon/internal/boundaries/in" | |
| 12 | 12 | configusecase "github.com/bnema/gordon/internal/usecase/config" | |
| 13 | - | secretsusecase "github.com/bnema/gordon/internal/usecase/secrets" | |
| 14 | 13 | ) | |
| 15 | 14 | ||
| 16 | 15 | // Kernel provides in-process service access for local CLI execution. | |
| @@ -19,7 +18,6 @@ import ( | |||
| 19 | 18 | type Kernel struct { | |
| 20 | 19 | authEnabled bool | |
| 21 | 20 | configSvc in.ConfigService | |
| 22 | - | secretSvc in.SecretService | |
| 23 | 21 | containerSvc in.ContainerService | |
| 24 | 22 | backupSvc in.BackupService | |
| 25 | 23 | volumeBackupSvc in.VolumeBackupService | |
| @@ -87,7 +85,6 @@ func newKernel(configPath string, initLog kernelLoggerInit) (*Kernel, error) { | |||
| 87 | 85 | kernel := &Kernel{ | |
| 88 | 86 | authEnabled: cfg.Auth.Enabled, | |
| 89 | 87 | configSvc: svc.configSvc, | |
| 90 | - | secretSvc: svc.secretSvc, | |
| 91 | 88 | containerSvc: svc.containerSvc, | |
| 92 | 89 | backupSvc: svc.backupSvc, | |
| 93 | 90 | volumeBackupSvc: svc.volumeBackupSvc, | |
| @@ -116,18 +113,9 @@ func newKernel(configPath string, initLog kernelLoggerInit) (*Kernel, error) { | |||
| 116 | 113 | return nil, fmt.Errorf("failed to load configuration: %w", err) | |
| 117 | 114 | } | |
| 118 | 115 | ||
| 119 | - | _, _, _, domainSecretStore, err := createDomainSecretStore(cfg, log) | |
| 120 | - | if err != nil { | |
| 121 | - | cleanup() | |
| 122 | - | return nil, fmt.Errorf("failed to create local secret store: %w", err) | |
| 123 | - | } | |
| 124 | - | ||
| 125 | - | secretSvc := secretsusecase.NewService(domainSecretStore, log, nil) | |
| 126 | - | ||
| 127 | 116 | return &Kernel{ | |
| 128 | 117 | authEnabled: cfg.Auth.Enabled, | |
| 129 | 118 | configSvc: configSvc, | |
| 130 | - | secretSvc: secretSvc, | |
| 131 | 119 | log: log, | |
| 132 | 120 | cleanup: cleanup, | |
| 133 | 121 | }, nil | |
| @@ -162,8 +150,6 @@ func (k *Kernel) Close() error { | |||
| 162 | 150 | ||
| 163 | 151 | func (k *Kernel) Config() in.ConfigService { return k.configSvc } | |
| 164 | 152 | ||
| 165 | - | func (k *Kernel) Secrets() in.SecretService { return k.secretSvc } | |
| 166 | - | ||
| 167 | 153 | func (k *Kernel) Container() in.ContainerService { return k.containerSvc } | |
| 168 | 154 | ||
| 169 | 155 | func (k *Kernel) Backup() in.BackupService { return k.backupSvc } | |
M internal/app/kernel_test.go +0 -1
| @@ -37,7 +37,6 @@ secrets_backend = "unsafe" | |||
| 37 | 37 | t.Cleanup(func() { require.NoError(t, kernel.Close()) }) | |
| 38 | 38 | ||
| 39 | 39 | require.NotNil(t, kernel.Config()) | |
| 40 | - | require.NotNil(t, kernel.Secrets()) | |
| 41 | 40 | } | |
| 42 | 41 | ||
| 43 | 42 | // TestKernelClose_QuiescenceTimeoutSkipsCleanup proves the fail-closed kernel | |
D internal/app/migrate_env.go +0 -228
| @@ -1,228 +0,0 @@ | |||
| 1 | - | package app | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "errors" | |
| 5 | - | "fmt" | |
| 6 | - | "os" | |
| 7 | - | "path/filepath" | |
| 8 | - | "strings" | |
| 9 | - | ||
| 10 | - | "github.com/bnema/zerowrap" | |
| 11 | - | ||
| 12 | - | "github.com/bnema/gordon/internal/adapters/out/domainsecrets" | |
| 13 | - | "github.com/bnema/gordon/internal/domain" | |
| 14 | - | ) | |
| 15 | - | ||
| 16 | - | func migrateEnvFilesToPass(envDir string, passStore *domainsecrets.PassStore, log zerowrap.Logger) error { | |
| 17 | - | entries, err := readEnvDir(envDir) | |
| 18 | - | if err != nil { | |
| 19 | - | return err | |
| 20 | - | } | |
| 21 | - | ||
| 22 | - | for _, entry := range entries { | |
| 23 | - | if entry.IsDir() { | |
| 24 | - | continue | |
| 25 | - | } | |
| 26 | - | name := entry.Name() | |
| 27 | - | if !isPlainEnvFile(name) { | |
| 28 | - | continue | |
| 29 | - | } | |
| 30 | - | if err := migrateEnvFile(envDir, name, passStore, log); err != nil { | |
| 31 | - | return err | |
| 32 | - | } | |
| 33 | - | } | |
| 34 | - | ||
| 35 | - | if err := migrateAttachmentEnvFilesToPass(envDir, passStore, log); err != nil { | |
| 36 | - | return err | |
| 37 | - | } | |
| 38 | - | ||
| 39 | - | return nil | |
| 40 | - | } | |
| 41 | - | ||
| 42 | - | func readEnvDir(envDir string) ([]os.DirEntry, error) { | |
| 43 | - | entries, err := os.ReadDir(envDir) | |
| 44 | - | if err != nil { | |
| 45 | - | if os.IsNotExist(err) { | |
| 46 | - | return nil, nil | |
| 47 | - | } | |
| 48 | - | return nil, fmt.Errorf("failed to read env directory: %w", err) | |
| 49 | - | } | |
| 50 | - | return entries, nil | |
| 51 | - | } | |
| 52 | - | ||
| 53 | - | func isPlainEnvFile(name string) bool { | |
| 54 | - | if !strings.HasSuffix(name, ".env") || strings.HasSuffix(name, ".env.migrated") { | |
| 55 | - | return false | |
| 56 | - | } | |
| 57 | - | // Skip attachment env files: gordon-<sanitized-domain>-<service>.env | |
| 58 | - | if strings.HasPrefix(name, "gordon-") { | |
| 59 | - | return false | |
| 60 | - | } | |
| 61 | - | return true | |
| 62 | - | } | |
| 63 | - | ||
| 64 | - | func migrateEnvFile(envDir, name string, passStore *domainsecrets.PassStore, log zerowrap.Logger) error { | |
| 65 | - | filePath := filepath.Join(envDir, name) | |
| 66 | - | data, err := os.ReadFile(filePath) | |
| 67 | - | if err != nil { | |
| 68 | - | return fmt.Errorf("failed to read env file %s: %w", filePath, err) | |
| 69 | - | } | |
| 70 | - | ||
| 71 | - | domainName := strings.TrimSuffix(name, ".env") | |
| 72 | - | secrets, err := domain.ParseEnvData(data) | |
| 73 | - | if err != nil { | |
| 74 | - | return fmt.Errorf("failed to parse env file %s: %w", filePath, err) | |
| 75 | - | } | |
| 76 | - | if len(secrets) == 0 { | |
| 77 | - | log.Info(). | |
| 78 | - | Str("file", filePath). | |
| 79 | - | Msg("no secrets found in env file; skipping migration") | |
| 80 | - | return nil | |
| 81 | - | } | |
| 82 | - | ||
| 83 | - | writtenKeys, err := passStore.SetIfEmpty(domainName, secrets) | |
| 84 | - | if err != nil { | |
| 85 | - | if errors.Is(err, domain.ErrSecretsAlreadyExist) { | |
| 86 | - | return fmt.Errorf("%w; refusing to delete plaintext env file automatically", err) | |
| 87 | - | } | |
| 88 | - | return fmt.Errorf("failed to migrate secrets for %s: %w", domainName, err) | |
| 89 | - | } | |
| 90 | - | ||
| 91 | - | if err := os.Remove(filePath); err != nil { | |
| 92 | - | // Check if the stored secrets match what we just wrote | |
| 93 | - | storedSecrets, getErr := passStore.GetAll(domainName) | |
| 94 | - | if getErr == nil && secretsMatch(storedSecrets, secrets) { | |
| 95 | - | // Migration was successful, removal failed but secrets are stored | |
| 96 | - | log.Warn(). | |
| 97 | - | Str("file", filePath). | |
| 98 | - | Str("domain", domainName). | |
| 99 | - | Msg("secrets migrated successfully but failed to remove original file; treating as success") | |
| 100 | - | return nil | |
| 101 | - | } | |
| 102 | - | // Attempt rollback: remove only the pass entries this migration wrote. | |
| 103 | - | for _, key := range writtenKeys { | |
| 104 | - | if delErr := passStore.Delete(domainName, key); delErr != nil { | |
| 105 | - | log.Error(). | |
| 106 | - | Err(delErr). | |
| 107 | - | Str("domain", domainName). | |
| 108 | - | Str("key", key). | |
| 109 | - | Msg("failed to rollback pass entry after file removal failure") | |
| 110 | - | } | |
| 111 | - | } | |
| 112 | - | return fmt.Errorf("failed to remove migrated env file %s: %w", filePath, err) | |
| 113 | - | } | |
| 114 | - | ||
| 115 | - | log.Info(). | |
| 116 | - | Int(zerowrap.FieldCount, len(secrets)). | |
| 117 | - | Str("domain", domainName). | |
| 118 | - | Msg("migrated secrets for domain from plain text to pass and removed original env file") | |
| 119 | - | ||
| 120 | - | return nil | |
| 121 | - | } | |
| 122 | - | ||
| 123 | - | func migrateAttachmentEnvFilesToPass(envDir string, passStore *domainsecrets.PassStore, log zerowrap.Logger) error { | |
| 124 | - | entries, err := readEnvDir(envDir) | |
| 125 | - | if err != nil { | |
| 126 | - | return err | |
| 127 | - | } | |
| 128 | - | ||
| 129 | - | for _, entry := range entries { | |
| 130 | - | if entry.IsDir() { | |
| 131 | - | continue | |
| 132 | - | } | |
| 133 | - | name := entry.Name() | |
| 134 | - | if !isAttachmentEnvFile(name) { | |
| 135 | - | continue | |
| 136 | - | } | |
| 137 | - | if err := migrateAttachmentEnvFile(envDir, name, passStore, log); err != nil { | |
| 138 | - | return err | |
| 139 | - | } | |
| 140 | - | } | |
| 141 | - | ||
| 142 | - | return nil | |
| 143 | - | } | |
| 144 | - | ||
| 145 | - | func isAttachmentEnvFile(name string) bool { | |
| 146 | - | if !strings.HasSuffix(name, ".env") || strings.HasSuffix(name, ".env.migrated") { | |
| 147 | - | return false | |
| 148 | - | } | |
| 149 | - | return strings.HasPrefix(name, "gordon-") | |
| 150 | - | } | |
| 151 | - | ||
| 152 | - | func migrateAttachmentEnvFile(envDir, name string, passStore *domainsecrets.PassStore, log zerowrap.Logger) error { | |
| 153 | - | filePath := filepath.Join(envDir, name) | |
| 154 | - | data, err := os.ReadFile(filePath) | |
| 155 | - | if err != nil { | |
| 156 | - | return fmt.Errorf("failed to read attachment env file %s: %w", filePath, err) | |
| 157 | - | } | |
| 158 | - | ||
| 159 | - | containerName := extractContainerNameFromAttachmentFile(name) | |
| 160 | - | secrets, err := domain.ParseEnvData(data) | |
| 161 | - | if err != nil { | |
| 162 | - | return fmt.Errorf("failed to parse attachment env file %s: %w", filePath, err) | |
| 163 | - | } | |
| 164 | - | if len(secrets) == 0 { | |
| 165 | - | log.Info(). | |
| 166 | - | Str("file", filePath). | |
| 167 | - | Msg("no secrets found in attachment env file; skipping migration") | |
| 168 | - | return nil | |
| 169 | - | } | |
| 170 | - | ||
| 171 | - | writtenKeys, err := passStore.SetAttachmentIfEmpty(containerName, secrets) | |
| 172 | - | if err != nil { | |
| 173 | - | if errors.Is(err, domain.ErrSecretsAlreadyExist) { | |
| 174 | - | return fmt.Errorf("%w; refusing to delete plaintext env file automatically", err) | |
| 175 | - | } | |
| 176 | - | return fmt.Errorf("failed to migrate attachment secrets for %s: %w", containerName, err) | |
| 177 | - | } | |
| 178 | - | ||
| 179 | - | if err := os.Remove(filePath); err != nil { | |
| 180 | - | // Check if the stored secrets match what we just wrote | |
| 181 | - | storedSecrets, getErr := passStore.GetAllAttachment(containerName) | |
| 182 | - | if getErr == nil && secretsMatch(storedSecrets, secrets) { | |
| 183 | - | // Migration was successful, removal failed but secrets are stored | |
| 184 | - | log.Warn(). | |
| 185 | - | Str("file", filePath). | |
| 186 | - | Str("container", containerName). | |
| 187 | - | Msg("attachment secrets migrated successfully but failed to remove original file; treating as success") | |
| 188 | - | return nil | |
| 189 | - | } | |
| 190 | - | // Attempt rollback: remove only the pass entries this migration wrote. | |
| 191 | - | for _, key := range writtenKeys { | |
| 192 | - | if delErr := passStore.DeleteAttachment(containerName, key); delErr != nil { | |
| 193 | - | log.Error(). | |
| 194 | - | Err(delErr). | |
| 195 | - | Str("container", containerName). | |
| 196 | - | Str("key", key). | |
| 197 | - | Msg("failed to rollback pass entry after file removal failure") | |
| 198 | - | } | |
| 199 | - | } | |
| 200 | - | return fmt.Errorf("failed to remove migrated attachment env file %s: %w", filePath, err) | |
| 201 | - | } | |
| 202 | - | ||
| 203 | - | log.Info(). | |
| 204 | - | Int(zerowrap.FieldCount, len(secrets)). | |
| 205 | - | Str("container", containerName). | |
| 206 | - | Str("file", filePath). | |
| 207 | - | Msg("migrated attachment secrets to pass and removed original env file") | |
| 208 | - | ||
| 209 | - | return nil | |
| 210 | - | } | |
| 211 | - | ||
| 212 | - | func extractContainerNameFromAttachmentFile(filename string) string { | |
| 213 | - | name := strings.TrimSuffix(filename, ".env") | |
| 214 | - | return name | |
| 215 | - | } | |
| 216 | - | ||
| 217 | - | func secretsMatch(a, b map[string]string) bool { | |
| 218 | - | if len(a) != len(b) { | |
| 219 | - | return false | |
| 220 | - | } | |
| 221 | - | for k, v := range a { | |
| 222 | - | bv, ok := b[k] | |
| 223 | - | if !ok || bv != v { | |
| 224 | - | return false | |
| 225 | - | } | |
| 226 | - | } | |
| 227 | - | return true | |
| 228 | - | } | |
D internal/app/migrate_env_test.go +0 -214
| @@ -1,214 +0,0 @@ | |||
| 1 | - | package app | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "context" | |
| 5 | - | "fmt" | |
| 6 | - | "os" | |
| 7 | - | "os/exec" | |
| 8 | - | "path/filepath" | |
| 9 | - | "testing" | |
| 10 | - | "time" | |
| 11 | - | ||
| 12 | - | "github.com/bnema/zerowrap" | |
| 13 | - | "github.com/stretchr/testify/assert" | |
| 14 | - | "github.com/stretchr/testify/require" | |
| 15 | - | ||
| 16 | - | "github.com/bnema/gordon/internal/adapters/out/domainsecrets" | |
| 17 | - | "github.com/bnema/gordon/internal/domain" | |
| 18 | - | ) | |
| 19 | - | ||
| 20 | - | func passCmd(args ...string) error { | |
| 21 | - | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) | |
| 22 | - | defer cancel() | |
| 23 | - | return exec.CommandContext(ctx, "pass", args...).Run() | |
| 24 | - | } | |
| 25 | - | ||
| 26 | - | func requirePass(t *testing.T) { | |
| 27 | - | if err := passCmd("version"); err != nil { | |
| 28 | - | t.Skip("pass not available") | |
| 29 | - | } | |
| 30 | - | if err := passCmd("ls"); err != nil { | |
| 31 | - | t.Skip("pass store not initialized") | |
| 32 | - | } | |
| 33 | - | } | |
| 34 | - | ||
| 35 | - | func cleanupPassDomain(domainName string, keys []string) { | |
| 36 | - | safeDomain, err := domain.SanitizeDomainForEnvFile(domainName) | |
| 37 | - | if err != nil { | |
| 38 | - | return | |
| 39 | - | } | |
| 40 | - | ||
| 41 | - | for _, key := range keys { | |
| 42 | - | path := fmt.Sprintf("%s/%s/%s", domainsecrets.PassDomainSecretsPath, safeDomain, key) | |
| 43 | - | _ = passCmd("rm", "-f", path) | |
| 44 | - | } | |
| 45 | - | ||
| 46 | - | manifestPath := fmt.Sprintf("%s/%s/.keys", domainsecrets.PassDomainSecretsPath, safeDomain) | |
| 47 | - | _ = passCmd("rm", "-f", manifestPath) | |
| 48 | - | } | |
| 49 | - | ||
| 50 | - | func TestMigrateEnvFile(t *testing.T) { | |
| 51 | - | requirePass(t) | |
| 52 | - | ||
| 53 | - | tmpDir, err := os.MkdirTemp("", "migrate-env-test-*") | |
| 54 | - | require.NoError(t, err) | |
| 55 | - | defer os.RemoveAll(tmpDir) | |
| 56 | - | ||
| 57 | - | logger := zerowrap.Default() | |
| 58 | - | store, err := domainsecrets.NewPassStore(logger) | |
| 59 | - | require.NoError(t, err) | |
| 60 | - | ||
| 61 | - | domainName := fmt.Sprintf("test.%d.example.com", time.Now().UnixNano()) | |
| 62 | - | keys := []string{"API_KEY", "DB_PASSWORD"} | |
| 63 | - | defer cleanupPassDomain(domainName, keys) | |
| 64 | - | ||
| 65 | - | envContent := "API_KEY=secret123\nDB_PASSWORD=pass456\n" | |
| 66 | - | envFileName := domainName + ".env" | |
| 67 | - | envFilePath := filepath.Join(tmpDir, envFileName) | |
| 68 | - | err = os.WriteFile(envFilePath, []byte(envContent), 0600) | |
| 69 | - | require.NoError(t, err) | |
| 70 | - | ||
| 71 | - | err = migrateEnvFile(tmpDir, envFileName, store, logger) | |
| 72 | - | require.NoError(t, err) | |
| 73 | - | ||
| 74 | - | _, err = os.Stat(envFilePath) | |
| 75 | - | assert.True(t, os.IsNotExist(err), "original .env file should be removed") | |
| 76 | - | ||
| 77 | - | _, err = os.Stat(envFilePath + ".migrated") | |
| 78 | - | assert.True(t, os.IsNotExist(err), ".env.migrated plaintext should not be preserved by default") | |
| 79 | - | ||
| 80 | - | passKeys, err := store.ListKeys(domainName) | |
| 81 | - | require.NoError(t, err) | |
| 82 | - | assert.ElementsMatch(t, keys, passKeys) | |
| 83 | - | ||
| 84 | - | values, err := store.GetAll(domainName) | |
| 85 | - | require.NoError(t, err) | |
| 86 | - | assert.Equal(t, "secret123", values["API_KEY"]) | |
| 87 | - | assert.Equal(t, "pass456", values["DB_PASSWORD"]) | |
| 88 | - | } | |
| 89 | - | ||
| 90 | - | func cleanupPassAttachment(_ *testing.T, containerName string, keys []string) { | |
| 91 | - | for _, key := range keys { | |
| 92 | - | path := fmt.Sprintf("%s/%s/%s", domainsecrets.PassAttachmentPath, containerName, key) | |
| 93 | - | _ = passCmd("rm", "-f", path) | |
| 94 | - | } | |
| 95 | - | ||
| 96 | - | manifestPath := fmt.Sprintf("%s/%s/.keys", domainsecrets.PassAttachmentPath, containerName) | |
| 97 | - | _ = passCmd("rm", "-f", manifestPath) | |
| 98 | - | } | |
| 99 | - | ||
| 100 | - | func TestMigrateAttachmentEnvFile(t *testing.T) { | |
| 101 | - | requirePass(t) | |
| 102 | - | ||
| 103 | - | tmpDir, err := os.MkdirTemp("", "migrate-attachment-env-test-*") | |
| 104 | - | require.NoError(t, err) | |
| 105 | - | defer os.RemoveAll(tmpDir) | |
| 106 | - | ||
| 107 | - | logger := zerowrap.Default() | |
| 108 | - | store, err := domainsecrets.NewPassStore(logger) | |
| 109 | - | require.NoError(t, err) | |
| 110 | - | ||
| 111 | - | containerName := fmt.Sprintf("gitea-postgres-%d", time.Now().UnixNano()) | |
| 112 | - | envFileName := "gordon-" + containerName + ".env" | |
| 113 | - | // extractContainerNameFromAttachmentFile strips the .env suffix, so the | |
| 114 | - | // stored name includes the "gordon-" prefix from the filename. | |
| 115 | - | storedContainerName := extractContainerNameFromAttachmentFile(envFileName) | |
| 116 | - | keys := []string{"POSTGRES_USER", "POSTGRES_PASSWORD"} | |
| 117 | - | defer cleanupPassAttachment(t, storedContainerName, keys) | |
| 118 | - | ||
| 119 | - | envContent := "POSTGRES_USER=gitea\nPOSTGRES_PASSWORD=secret123\n" | |
| 120 | - | envFilePath := filepath.Join(tmpDir, envFileName) | |
| 121 | - | err = os.WriteFile(envFilePath, []byte(envContent), 0600) | |
| 122 | - | require.NoError(t, err) | |
| 123 | - | ||
| 124 | - | err = migrateAttachmentEnvFile(tmpDir, envFileName, store, logger) | |
| 125 | - | require.NoError(t, err) | |
| 126 | - | ||
| 127 | - | _, err = os.Stat(envFilePath) | |
| 128 | - | assert.True(t, os.IsNotExist(err), "original .env file should be removed") | |
| 129 | - | ||
| 130 | - | _, err = os.Stat(envFilePath + ".migrated") | |
| 131 | - | assert.True(t, os.IsNotExist(err), ".env.migrated plaintext should not be preserved by default") | |
| 132 | - | ||
| 133 | - | values, err := store.GetAllAttachment(storedContainerName) | |
| 134 | - | require.NoError(t, err) | |
| 135 | - | assert.Equal(t, "gitea", values["POSTGRES_USER"]) | |
| 136 | - | assert.Equal(t, "secret123", values["POSTGRES_PASSWORD"]) | |
| 137 | - | } | |
| 138 | - | ||
| 139 | - | func TestIsAttachmentEnvFile(t *testing.T) { | |
| 140 | - | tests := []struct { | |
| 141 | - | name string | |
| 142 | - | filename string | |
| 143 | - | want bool | |
| 144 | - | }{ | |
| 145 | - | { | |
| 146 | - | name: "attachment env file", | |
| 147 | - | filename: "gordon-gitea-postgres.env", | |
| 148 | - | want: true, | |
| 149 | - | }, | |
| 150 | - | { | |
| 151 | - | name: "attachment env file with complex name", | |
| 152 | - | filename: "gordon-git-example-com-gitea-postgres.env", | |
| 153 | - | want: true, | |
| 154 | - | }, | |
| 155 | - | { | |
| 156 | - | name: "plain domain env file", | |
| 157 | - | filename: "example.com.env", | |
| 158 | - | want: false, | |
| 159 | - | }, | |
| 160 | - | { | |
| 161 | - | name: "migrated attachment file", | |
| 162 | - | filename: "gordon-gitea-postgres.env.migrated", | |
| 163 | - | want: false, | |
| 164 | - | }, | |
| 165 | - | { | |
| 166 | - | name: "non-env file", | |
| 167 | - | filename: "config.yaml", | |
| 168 | - | want: false, | |
| 169 | - | }, | |
| 170 | - | { | |
| 171 | - | name: "gordon prefix but no env suffix", | |
| 172 | - | filename: "gordon-something", | |
| 173 | - | want: false, | |
| 174 | - | }, | |
| 175 | - | } | |
| 176 | - | ||
| 177 | - | for _, tt := range tests { | |
| 178 | - | t.Run(tt.name, func(t *testing.T) { | |
| 179 | - | got := isAttachmentEnvFile(tt.filename) | |
| 180 | - | assert.Equal(t, tt.want, got) | |
| 181 | - | }) | |
| 182 | - | } | |
| 183 | - | } | |
| 184 | - | ||
| 185 | - | func TestExtractContainerNameFromAttachmentFile(t *testing.T) { | |
| 186 | - | tests := []struct { | |
| 187 | - | name string | |
| 188 | - | filename string | |
| 189 | - | want string | |
| 190 | - | }{ | |
| 191 | - | { | |
| 192 | - | name: "simple attachment file", | |
| 193 | - | filename: "gordon-gitea-postgres.env", | |
| 194 | - | want: "gordon-gitea-postgres", | |
| 195 | - | }, | |
| 196 | - | { | |
| 197 | - | name: "complex attachment file", | |
| 198 | - | filename: "gordon-git-example-com-gitea-redis.env", | |
| 199 | - | want: "gordon-git-example-com-gitea-redis", | |
| 200 | - | }, | |
| 201 | - | { | |
| 202 | - | name: "single word container", | |
| 203 | - | filename: "gordon-redis.env", | |
| 204 | - | want: "gordon-redis", | |
| 205 | - | }, | |
| 206 | - | } | |
| 207 | - | ||
| 208 | - | for _, tt := range tests { | |
| 209 | - | t.Run(tt.name, func(t *testing.T) { | |
| 210 | - | got := extractContainerNameFromAttachmentFile(tt.filename) | |
| 211 | - | assert.Equal(t, tt.want, got) | |
| 212 | - | }) | |
| 213 | - | } | |
| 214 | - | } | |
M internal/app/run.go +6 -90
| @@ -31,8 +31,6 @@ import ( | |||
| 31 | 31 | acmelego "github.com/bnema/gordon/internal/adapters/out/acmelego" | |
| 32 | 32 | acmestore "github.com/bnema/gordon/internal/adapters/out/acmestore" | |
| 33 | 33 | "github.com/bnema/gordon/internal/adapters/out/docker" | |
| 34 | - | "github.com/bnema/gordon/internal/adapters/out/domainsecrets" | |
| 35 | - | "github.com/bnema/gordon/internal/adapters/out/envloader" | |
| 36 | 34 | "github.com/bnema/gordon/internal/adapters/out/eventbus" | |
| 37 | 35 | "github.com/bnema/gordon/internal/adapters/out/filesystem" | |
| 38 | 36 | "github.com/bnema/gordon/internal/adapters/out/httpprober" | |
| @@ -90,7 +88,6 @@ import ( | |||
| 90 | 88 | "github.com/bnema/gordon/internal/usecase/publictls" | |
| 91 | 89 | registrySvc "github.com/bnema/gordon/internal/usecase/registry" | |
| 92 | 90 | "github.com/bnema/gordon/internal/usecase/registrystate" | |
| 93 | - | secretsSvc "github.com/bnema/gordon/internal/usecase/secrets" | |
| 94 | 91 | servicecfg "github.com/bnema/gordon/internal/usecase/services" | |
| 95 | 92 | "github.com/bnema/gordon/internal/usecase/traffic" | |
| 96 | 93 | volumesSvc "github.com/bnema/gordon/internal/usecase/volumes" | |
| @@ -168,10 +165,6 @@ type Config struct { | |||
| 168 | 165 | } `mapstructure:"access_log"` | |
| 169 | 166 | } `mapstructure:"logging"` | |
| 170 | 167 | ||
| 171 | - | Env struct { | |
| 172 | - | Dir string `mapstructure:"dir"` | |
| 173 | - | } `mapstructure:"env"` | |
| 174 | - | ||
| 175 | 168 | Volumes struct { | |
| 176 | 169 | AutoCreate bool `mapstructure:"auto_create"` | |
| 177 | 170 | Prefix string `mapstructure:"prefix"` | |
| @@ -298,11 +291,9 @@ type services struct { | |||
| 298 | 291 | backupStorage *filesystem.BackupStorage | |
| 299 | 292 | volumeBackupStore out.VolumeBackupStorage | |
| 300 | 293 | volumeBackupCfg domain.VolumeBackupConfig | |
| 301 | - | envLoader out.EnvLoader | |
| 302 | 294 | logWriter *logwriter.LogWriter | |
| 303 | 295 | tokenStore out.TokenStore | |
| 304 | 296 | configSvc *config.Service | |
| 305 | - | secretSvc *secretsSvc.Service | |
| 306 | 297 | containerSvc *container.Service | |
| 307 | 298 | backupSvc *backup.Service | |
| 308 | 299 | volumeBackupSvc *backup.VolumeService | |
| @@ -320,7 +311,6 @@ type services struct { | |||
| 320 | 311 | httpsProxyHandler http.Handler | |
| 321 | 312 | internalRegUser string | |
| 322 | 313 | internalRegPass string | |
| 323 | - | envDir string | |
| 324 | 314 | maxBlobChunkSize int64 | |
| 325 | 315 | maxBlobSize int64 | |
| 326 | 316 | caAdapter *pkiadapter.CA | |
| @@ -484,6 +474,7 @@ var retiredAppConfigKeys = []struct { | |||
| 484 | 474 | {"auto_route", "feature removed; declare explicit interfaces"}, | |
| 485 | 475 | {"auto_route_allowed_domains", "feature removed; declare explicit interfaces"}, | |
| 486 | 476 | {"previews", "staging is an ordinary app file"}, | |
| 477 | + | {"env", "remove the [env] section; declare [env] and [services.<name>.secrets] in app files"}, | |
| 487 | 478 | } | |
| 488 | 479 | ||
| 489 | 480 | // validateRetiredAppConfig rejects obsolete application configuration | |
| @@ -814,20 +805,14 @@ func startPublicTLSRuntime(ctx context.Context, svc publicTLSRuntime, log zerowr | |||
| 814 | 805 | return reconcileErr | |
| 815 | 806 | } | |
| 816 | 807 | ||
| 817 | - | // initSecrets creates the domain secret store, env loader, and secret service. | |
| 808 | + | // initSecrets validates the secrets backend and creates the app service | |
| 809 | + | // secret provider. | |
| 818 | 810 | func (si *serviceInit) initSecrets() error { | |
| 819 | - | envDir, backend, passStore, domainSecretStore, err := createDomainSecretStore(si.cfg, si.log) | |
| 811 | + | backend, err := resolveSecretsBackend(si.cfg.Auth.SecretsBackend) | |
| 820 | 812 | 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") | |
| 827 | 814 | } | |
| 828 | - | ||
| 829 | 815 | si.svc.serviceSecretProvider = createStandaloneServiceSecretProvider(backend, resolveDataDir(si.cfg.Server.DataDir), si.log) | |
| 830 | - | si.svc.secretSvc = secretsSvc.NewService(domainSecretStore, si.log, si.svc.eventBus) | |
| 831 | 816 | return nil | |
| 832 | 817 | } | |
| 833 | 818 | ||
| @@ -1180,7 +1165,6 @@ func (si *serviceInit) initHandlers() { | |||
| 1180 | 1165 | AuthSvc: si.svc.authSvc, | |
| 1181 | 1166 | ContainerSvc: si.svc.containerSvc, | |
| 1182 | 1167 | HealthSvc: si.svc.healthSvc, | |
| 1183 | - | SecretSvc: si.svc.secretSvc, | |
| 1184 | 1168 | LogSvc: si.svc.logSvc, | |
| 1185 | 1169 | RegistrySvc: si.svc.registrySvc, | |
| 1186 | 1170 | ReloadTrigger: si.svc.reloadCoordinator, | |
| @@ -1240,32 +1224,6 @@ func setupInternalRegistryAuth(svc *services, log zerowrap.Logger) error { | |||
| 1240 | 1224 | return nil | |
| 1241 | 1225 | } | |
| 1242 | 1226 | ||
| 1243 | - | func createDomainSecretStore(cfg Config, log zerowrap.Logger) (string, domain.SecretsBackend, *domainsecrets.PassStore, out.DomainSecretStore, error) { | |
| 1244 | - | envDir := resolveEnvDir(cfg) | |
| 1245 | - | backend, err := resolveSecretsBackend(cfg.Auth.SecretsBackend) | |
| 1246 | - | if err != nil { | |
| 1247 | - | return "", "", nil, nil, log.WrapErr(err, "failed to resolve secrets backend") | |
| 1248 | - | } | |
| 1249 | - | ||
| 1250 | - | switch backend { | |
| 1251 | - | case domain.SecretsBackendPass: | |
| 1252 | - | passStore, err := domainsecrets.NewPassStore(log) | |
| 1253 | - | if err != nil { | |
| 1254 | - | return "", backend, nil, nil, log.WrapErr(err, "failed to create pass domain secret store") | |
| 1255 | - | } | |
| 1256 | - | if err := migrateEnvFilesToPass(envDir, passStore, log); err != nil { | |
| 1257 | - | return "", backend, nil, nil, log.WrapErr(err, "failed to migrate env files to pass") | |
| 1258 | - | } | |
| 1259 | - | return envDir, backend, passStore, passStore, nil | |
| 1260 | - | default: | |
| 1261 | - | store, err := domainsecrets.NewFileStore(envDir, log) | |
| 1262 | - | if err != nil { | |
| 1263 | - | return "", backend, nil, nil, log.WrapErr(err, "failed to create domain secret store") | |
| 1264 | - | } | |
| 1265 | - | return envDir, backend, nil, store, nil | |
| 1266 | - | } | |
| 1267 | - | } | |
| 1268 | - | ||
| 1269 | 1227 | // resolveLogFilePath returns the configured log file path or a default. | |
| 1270 | 1228 | func resolveLogFilePath(cfg Config) string { | |
| 1271 | 1229 | if cfg.Logging.File.Path != "" { | |
| @@ -1370,38 +1328,6 @@ func createStorage(cfg Config, log zerowrap.Logger) (*filesystem.BlobStorage, *f | |||
| 1370 | 1328 | return blobStorage, manifestStorage, nil | |
| 1371 | 1329 | } | |
| 1372 | 1330 | ||
| 1373 | - | // createEnvLoader creates the environment loader with secret providers. | |
| 1374 | - | func createEnvLoader(backend domain.SecretsBackend, envDir string, passStore *domainsecrets.PassStore, log zerowrap.Logger) (out.EnvLoader, error) { | |
| 1375 | - | switch backend { | |
| 1376 | - | case domain.SecretsBackendPass: | |
| 1377 | - | loader, err := envloader.NewPassLoader(passStore, log) | |
| 1378 | - | if err != nil { | |
| 1379 | - | return nil, log.WrapErr(err, "failed to create pass env loader") | |
| 1380 | - | } | |
| 1381 | - | return loader, nil | |
| 1382 | - | default: | |
| 1383 | - | loader, err := envloader.NewFileLoader(envDir, log) | |
| 1384 | - | if err != nil { | |
| 1385 | - | return nil, log.WrapErr(err, "failed to create env loader") | |
| 1386 | - | } | |
| 1387 | - | ||
| 1388 | - | // Register secret providers | |
| 1389 | - | passProvider := secrets.NewPassProvider(log) | |
| 1390 | - | if passProvider.IsAvailable() { | |
| 1391 | - | loader.RegisterSecretProvider(passProvider) | |
| 1392 | - | log.Debug().Msg("pass secret provider registered") | |
| 1393 | - | } | |
| 1394 | - | ||
| 1395 | - | sopsProvider := secrets.NewSopsProvider(log) | |
| 1396 | - | if sopsProvider.IsAvailable() { | |
| 1397 | - | loader.RegisterSecretProvider(sopsProvider) | |
| 1398 | - | log.Debug().Msg("sops secret provider registered") | |
| 1399 | - | } | |
| 1400 | - | ||
| 1401 | - | return loader, nil | |
| 1402 | - | } | |
| 1403 | - | } | |
| 1404 | - | ||
| 1405 | 1331 | // createLogWriter creates the container log writer. | |
| 1406 | 1332 | func createLogWriter(cfg Config, log zerowrap.Logger) (*logwriter.LogWriter, error) { | |
| 1407 | 1333 | if !cfg.Logging.ContainerLogs.Enabled { | |
| @@ -1667,15 +1593,6 @@ func resolveDataDir(dataDir string) string { | |||
| 1667 | 1593 | return dataDir | |
| 1668 | 1594 | } | |
| 1669 | 1595 | ||
| 1670 | - | func resolveEnvDir(cfg Config) string { | |
| 1671 | - | dataDir := resolveDataDir(cfg.Server.DataDir) | |
| 1672 | - | envDir := cfg.Env.Dir | |
| 1673 | - | if envDir == "" { | |
| 1674 | - | envDir = filepath.Join(dataDir, "env") | |
| 1675 | - | } | |
| 1676 | - | return envDir | |
| 1677 | - | } | |
| 1678 | - | ||
| 1679 | 1596 | func resolveRegistryDomains(cfg Config) (string, []string) { | |
| 1680 | 1597 | registryDomain := cfg.Server.RegistryDomain | |
| 1681 | 1598 | if registryDomain == "" { | |
| @@ -2265,7 +2182,7 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc | |||
| 2265 | 2182 | if err != nil { | |
| 2266 | 2183 | return nil, err | |
| 2267 | 2184 | } | |
| 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 | |
| 2269 | 2186 | } | |
| 2270 | 2187 | ||
| 2271 | 2188 | type databaseBackupSettingsConfig struct { | |
| @@ -4225,7 +4142,6 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||
| 4225 | 4142 | v.SetDefault("logging.access_log.max_age", 28) | |
| 4226 | 4143 | v.SetDefault("logging.access_log.exclude_health_checks", true) | |
| 4227 | 4144 | v.SetDefault("logging.access_log.syslog_identifier", "gordon-access") | |
| 4228 | - | v.SetDefault("env.dir", "") // defaults to {data_dir}/env when empty | |
| 4229 | 4145 | v.SetDefault("auth.enabled", true) | |
| 4230 | 4146 | // Note: auth.type defaults to "token" (the only supported mode) | |
| 4231 | 4147 | v.SetDefault("auth.secrets_backend", "") | |
M internal/app/run_app_lifecycle_test.go +2 -2
| @@ -141,7 +141,7 @@ func TestGracefulShutdown_FailsClosedWhenAppAdministrationDoesNotQuiesce(t *test | |||
| 141 | 141 | ||
| 142 | 142 | store := outmocks.NewMockAppState(t) | |
| 143 | 143 | ||
| 144 | - | containerSvc := container.NewService(nil, nil, nil, nil, container.Config{}) | |
| 144 | + | containerSvc := container.NewService(nil, nil, nil, container.Config{}) | |
| 145 | 145 | err := gracefulShutdown(nil, nil, nil, containerSvc, nil, nil, nil, nil, nil, admin, store, zerowrap.Default()) | |
| 146 | 146 | ||
| 147 | 147 | require.Error(t, err) | |
| @@ -164,7 +164,7 @@ func TestGracefulShutdown_QuiescesAppAdministrationBeforeClosingState(t *testing | |||
| 164 | 164 | store := outmocks.NewMockAppState(t) | |
| 165 | 165 | store.EXPECT().Close().Run(func() { order = append(order, "state-close") }).Return(nil) | |
| 166 | 166 | ||
| 167 | - | containerSvc := container.NewService(nil, nil, nil, nil, container.Config{}) | |
| 167 | + | containerSvc := container.NewService(nil, nil, nil, container.Config{}) | |
| 168 | 168 | require.NoError(t, gracefulShutdown(nil, nil, nil, containerSvc, nil, nil, nil, nil, nil, admin, store, zerowrap.Default())) | |
| 169 | 169 | ||
| 170 | 170 | require.Equal(t, []string{"app-shutdown", "state-close"}, order) | |
M internal/app/run_entrypoint_migration_test.go +10 -0
| @@ -36,3 +36,13 @@ func TestInitConfigRejectsRetiredAppKeys(t *testing.T) { | |||
| 36 | 36 | require.ErrorContains(t, err, "config-retired") | |
| 37 | 37 | require.ErrorContains(t, err, `"routes"`) | |
| 38 | 38 | } | |
| 39 | + | ||
| 40 | + | func TestInitConfigRejectsRetiredEnvSection(t *testing.T) { | |
| 41 | + | configPath := filepath.Join(t.TempDir(), "gordon.toml") | |
| 42 | + | require.NoError(t, os.WriteFile(configPath, []byte("[env]\ndir = \"/tmp/env\"\n"), 0o600)) | |
| 43 | + | ||
| 44 | + | _, _, err := initConfig(configPath) | |
| 45 | + | ||
| 46 | + | require.ErrorContains(t, err, "config-retired") | |
| 47 | + | require.ErrorContains(t, err, `"env"`) | |
| 48 | + | } | |
M internal/app/run_reload_test.go +4 -4
| @@ -311,7 +311,7 @@ func TestServiceInit_ReloadRegistersCustomTLSMuxHTTPSFallback(t *testing.T) { | |||
| 311 | 311 | log: zerowrap.Default(), | |
| 312 | 312 | svc: newTestServices(&services{ | |
| 313 | 313 | configSvc: configSvc, | |
| 314 | - | containerSvc: container.NewService(nil, nil, nil, nil, container.Config{}), | |
| 314 | + | containerSvc: container.NewService(nil, nil, nil, container.Config{}), | |
| 315 | 315 | internalRegUser: "gordon", | |
| 316 | 316 | internalRegPass: "secret", | |
| 317 | 317 | reloadCoordinator: newReloadCoordinator(v, &reloadRecorder{}, &proxyRecorder{}, nil, nil, nil, zerowrap.Default()), | |
| @@ -361,7 +361,7 @@ func TestServiceInit_ReloadUpdatesManagementHosts(t *testing.T) { | |||
| 361 | 361 | log: zerowrap.Default(), | |
| 362 | 362 | svc: newTestServices(&services{ | |
| 363 | 363 | configSvc: configSvc, | |
| 364 | - | containerSvc: container.NewService(nil, nil, nil, nil, container.Config{}), | |
| 364 | + | containerSvc: container.NewService(nil, nil, nil, container.Config{}), | |
| 365 | 365 | internalRegUser: "gordon", | |
| 366 | 366 | internalRegPass: "secret", | |
| 367 | 367 | reloadCoordinator: newReloadCoordinator(v, &reloadRecorder{}, &proxyRecorder{}, nil, nil, publicTLS, zerowrap.Default()), | |
| @@ -393,7 +393,7 @@ func TestServiceInit_RegisterReloadCoordinatorHooks_WiresContainerConfigApplier( | |||
| 393 | 393 | log: zerowrap.Default(), | |
| 394 | 394 | svc: newTestServices(&services{ | |
| 395 | 395 | configSvc: configSvc, | |
| 396 | - | containerSvc: container.NewService(nil, nil, nil, nil, container.Config{}), | |
| 396 | + | containerSvc: container.NewService(nil, nil, nil, container.Config{}), | |
| 397 | 397 | internalRegUser: "gordon", | |
| 398 | 398 | internalRegPass: "secret", | |
| 399 | 399 | reloadCoordinator: newReloadCoordinator(v, &reloadRecorder{}, &proxyRecorder{}, nil, nil, nil, zerowrap.Default()), | |
| @@ -429,7 +429,7 @@ func TestServiceInit_RegisterReloadCoordinatorHooks_AppliesTraffic(t *testing.T) | |||
| 429 | 429 | log: zerowrap.Default(), | |
| 430 | 430 | svc: newTestServices(&services{ | |
| 431 | 431 | configSvc: configSvc, | |
| 432 | - | containerSvc: container.NewService(nil, nil, nil, nil, container.Config{}), | |
| 432 | + | containerSvc: container.NewService(nil, nil, nil, container.Config{}), | |
| 433 | 433 | internalRegUser: "gordon", | |
| 434 | 434 | internalRegPass: "secret", | |
| 435 | 435 | reloadCoordinator: newReloadCoordinator(v, &reloadRecorder{}, &proxyRecorder{}, nil, nil, nil, zerowrap.Default()), | |
D internal/boundaries/in/mocks/mock_secret_service.go +0 -309
| @@ -1,309 +0,0 @@ | |||
| 1 | - | // Code generated by mockery; DO NOT EDIT. | |
| 2 | - | // github.com/vektra/mockery | |
| 3 | - | // template: testify | |
| 4 | - | ||
| 5 | - | package mocks | |
| 6 | - | ||
| 7 | - | import ( | |
| 8 | - | "context" | |
| 9 | - | ||
| 10 | - | mock "github.com/stretchr/testify/mock" | |
| 11 | - | ) | |
| 12 | - | ||
| 13 | - | // NewMockSecretService creates a new instance of MockSecretService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 14 | - | // The first argument is typically a *testing.T value. | |
| 15 | - | func NewMockSecretService(t interface { | |
| 16 | - | mock.TestingT | |
| 17 | - | Cleanup(func()) | |
| 18 | - | }) *MockSecretService { | |
| 19 | - | if helper, ok := t.(interface{ Helper() }); ok { | |
| 20 | - | helper.Helper() | |
| 21 | - | } | |
| 22 | - | ||
| 23 | - | mock := &MockSecretService{} | |
| 24 | - | mock.Mock.Test(t) | |
| 25 | - | ||
| 26 | - | t.Cleanup(func() { | |
| 27 | - | if helper, ok := t.(interface{ Helper() }); ok { | |
| 28 | - | helper.Helper() | |
| 29 | - | } | |
| 30 | - | mock.AssertExpectations(t) | |
| 31 | - | }) | |
| 32 | - | ||
| 33 | - | return mock | |
| 34 | - | } | |
| 35 | - | ||
| 36 | - | // MockSecretService is an autogenerated mock type for the SecretService type | |
| 37 | - | type MockSecretService struct { | |
| 38 | - | mock.Mock | |
| 39 | - | } | |
| 40 | - | ||
| 41 | - | type MockSecretService_Expecter struct { | |
| 42 | - | mock *mock.Mock | |
| 43 | - | } | |
| 44 | - | ||
| 45 | - | func (_m *MockSecretService) EXPECT() *MockSecretService_Expecter { | |
| 46 | - | return &MockSecretService_Expecter{mock: &_m.Mock} | |
| 47 | - | } | |
| 48 | - | ||
| 49 | - | // Delete provides a mock function for the type MockSecretService | |
| 50 | - | func (_mock *MockSecretService) Delete(ctx context.Context, domain string, key string) error { | |
| 51 | - | ret := _mock.Called(ctx, domain, key) | |
| 52 | - | ||
| 53 | - | if len(ret) == 0 { | |
| 54 | - | panic("no return value specified for Delete") | |
| 55 | - | } | |
| 56 | - | ||
| 57 | - | var r0 error | |
| 58 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok { | |
| 59 | - | r0 = returnFunc(ctx, domain, key) | |
| 60 | - | } else { | |
| 61 | - | r0 = ret.Error(0) | |
| 62 | - | } | |
| 63 | - | return r0 | |
| 64 | - | } | |
| 65 | - | ||
| 66 | - | // MockSecretService_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete' | |
| 67 | - | type MockSecretService_Delete_Call struct { | |
| 68 | - | *mock.Call | |
| 69 | - | } | |
| 70 | - | ||
| 71 | - | // Delete is a helper method to define mock.On call | |
| 72 | - | // - ctx context.Context | |
| 73 | - | // - domain string | |
| 74 | - | // - key string | |
| 75 | - | func (_e *MockSecretService_Expecter) Delete(ctx any, domain any, key any) *MockSecretService_Delete_Call { | |
| 76 | - | return &MockSecretService_Delete_Call{Call: _e.mock.On("Delete", ctx, domain, key)} | |
| 77 | - | } | |
| 78 | - | ||
| 79 | - | func (_c *MockSecretService_Delete_Call) Run(run func(ctx context.Context, domain string, key string)) *MockSecretService_Delete_Call { | |
| 80 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 81 | - | var arg0 context.Context | |
| 82 | - | if args[0] != nil { | |
| 83 | - | arg0 = args[0].(context.Context) | |
| 84 | - | } | |
| 85 | - | var arg1 string | |
| 86 | - | if args[1] != nil { | |
| 87 | - | arg1 = args[1].(string) | |
| 88 | - | } | |
| 89 | - | var arg2 string | |
| 90 | - | if args[2] != nil { | |
| 91 | - | arg2 = args[2].(string) | |
| 92 | - | } | |
| 93 | - | run( | |
| 94 | - | arg0, | |
| 95 | - | arg1, | |
| 96 | - | arg2, | |
| 97 | - | ) | |
| 98 | - | }) | |
| 99 | - | return _c | |
| 100 | - | } | |
| 101 | - | ||
| 102 | - | func (_c *MockSecretService_Delete_Call) Return(err error) *MockSecretService_Delete_Call { | |
| 103 | - | _c.Call.Return(err) | |
| 104 | - | return _c | |
| 105 | - | } | |
| 106 | - | ||
| 107 | - | func (_c *MockSecretService_Delete_Call) RunAndReturn(run func(ctx context.Context, domain string, key string) error) *MockSecretService_Delete_Call { | |
| 108 | - | _c.Call.Return(run) | |
| 109 | - | return _c | |
| 110 | - | } | |
| 111 | - | ||
| 112 | - | // GetAll provides a mock function for the type MockSecretService | |
| 113 | - | func (_mock *MockSecretService) GetAll(ctx context.Context, domain string) (map[string]string, error) { | |
| 114 | - | ret := _mock.Called(ctx, domain) | |
| 115 | - | ||
| 116 | - | if len(ret) == 0 { | |
| 117 | - | panic("no return value specified for GetAll") | |
| 118 | - | } | |
| 119 | - | ||
| 120 | - | var r0 map[string]string | |
| 121 | - | var r1 error | |
| 122 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (map[string]string, error)); ok { | |
| 123 | - | return returnFunc(ctx, domain) | |
| 124 | - | } | |
| 125 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string) map[string]string); ok { | |
| 126 | - | r0 = returnFunc(ctx, domain) | |
| 127 | - | } else { | |
| 128 | - | if ret.Get(0) != nil { | |
| 129 | - | r0 = ret.Get(0).(map[string]string) | |
| 130 | - | } | |
| 131 | - | } | |
| 132 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | |
| 133 | - | r1 = returnFunc(ctx, domain) | |
| 134 | - | } else { | |
| 135 | - | r1 = ret.Error(1) | |
| 136 | - | } | |
| 137 | - | return r0, r1 | |
| 138 | - | } | |
| 139 | - | ||
| 140 | - | // MockSecretService_GetAll_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAll' | |
| 141 | - | type MockSecretService_GetAll_Call struct { | |
| 142 | - | *mock.Call | |
| 143 | - | } | |
| 144 | - | ||
| 145 | - | // GetAll is a helper method to define mock.On call | |
| 146 | - | // - ctx context.Context | |
| 147 | - | // - domain string | |
| 148 | - | func (_e *MockSecretService_Expecter) GetAll(ctx any, domain any) *MockSecretService_GetAll_Call { | |
| 149 | - | return &MockSecretService_GetAll_Call{Call: _e.mock.On("GetAll", ctx, domain)} | |
| 150 | - | } | |
| 151 | - | ||
| 152 | - | func (_c *MockSecretService_GetAll_Call) Run(run func(ctx context.Context, domain string)) *MockSecretService_GetAll_Call { | |
| 153 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 154 | - | var arg0 context.Context | |
| 155 | - | if args[0] != nil { | |
| 156 | - | arg0 = args[0].(context.Context) | |
| 157 | - | } | |
| 158 | - | var arg1 string | |
| 159 | - | if args[1] != nil { | |
| 160 | - | arg1 = args[1].(string) | |
| 161 | - | } | |
| 162 | - | run( | |
| 163 | - | arg0, | |
| 164 | - | arg1, | |
| 165 | - | ) | |
| 166 | - | }) | |
| 167 | - | return _c | |
| 168 | - | } | |
| 169 | - | ||
| 170 | - | func (_c *MockSecretService_GetAll_Call) Return(stringToString map[string]string, err error) *MockSecretService_GetAll_Call { | |
| 171 | - | _c.Call.Return(stringToString, err) | |
| 172 | - | return _c | |
| 173 | - | } | |
| 174 | - | ||
| 175 | - | func (_c *MockSecretService_GetAll_Call) RunAndReturn(run func(ctx context.Context, domain string) (map[string]string, error)) *MockSecretService_GetAll_Call { | |
| 176 | - | _c.Call.Return(run) | |
| 177 | - | return _c | |
| 178 | - | } | |
| 179 | - | ||
| 180 | - | // ListKeys provides a mock function for the type MockSecretService | |
| 181 | - | func (_mock *MockSecretService) ListKeys(ctx context.Context, domain string) ([]string, error) { | |
| 182 | - | ret := _mock.Called(ctx, domain) | |
| 183 | - | ||
| 184 | - | if len(ret) == 0 { | |
| 185 | - | panic("no return value specified for ListKeys") | |
| 186 | - | } | |
| 187 | - | ||
| 188 | - | var r0 []string | |
| 189 | - | var r1 error | |
| 190 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]string, error)); ok { | |
| 191 | - | return returnFunc(ctx, domain) | |
| 192 | - | } | |
| 193 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string) []string); ok { | |
| 194 | - | r0 = returnFunc(ctx, domain) | |
| 195 | - | } else { | |
| 196 | - | if ret.Get(0) != nil { | |
| 197 | - | r0 = ret.Get(0).([]string) | |
| 198 | - | } | |
| 199 | - | } | |
| 200 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | |
| 201 | - | r1 = returnFunc(ctx, domain) | |
| 202 | - | } else { | |
| 203 | - | r1 = ret.Error(1) | |
| 204 | - | } | |
| 205 | - | return r0, r1 | |
| 206 | - | } | |
| 207 | - | ||
| 208 | - | // MockSecretService_ListKeys_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListKeys' | |
| 209 | - | type MockSecretService_ListKeys_Call struct { | |
| 210 | - | *mock.Call | |
| 211 | - | } | |
| 212 | - | ||
| 213 | - | // ListKeys is a helper method to define mock.On call | |
| 214 | - | // - ctx context.Context | |
| 215 | - | // - domain string | |
| 216 | - | func (_e *MockSecretService_Expecter) ListKeys(ctx any, domain any) *MockSecretService_ListKeys_Call { | |
| 217 | - | return &MockSecretService_ListKeys_Call{Call: _e.mock.On("ListKeys", ctx, domain)} | |
| 218 | - | } | |
| 219 | - | ||
| 220 | - | func (_c *MockSecretService_ListKeys_Call) Run(run func(ctx context.Context, domain string)) *MockSecretService_ListKeys_Call { | |
| 221 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 222 | - | var arg0 context.Context | |
| 223 | - | if args[0] != nil { | |
| 224 | - | arg0 = args[0].(context.Context) | |
| 225 | - | } | |
| 226 | - | var arg1 string | |
| 227 | - | if args[1] != nil { | |
| 228 | - | arg1 = args[1].(string) | |
| 229 | - | } | |
| 230 | - | run( | |
| 231 | - | arg0, | |
| 232 | - | arg1, | |
| 233 | - | ) | |
| 234 | - | }) | |
| 235 | - | return _c | |
| 236 | - | } | |
| 237 | - | ||
| 238 | - | func (_c *MockSecretService_ListKeys_Call) Return(strings []string, err error) *MockSecretService_ListKeys_Call { | |
| 239 | - | _c.Call.Return(strings, err) | |
| 240 | - | return _c | |
| 241 | - | } | |
| 242 | - | ||
| 243 | - | func (_c *MockSecretService_ListKeys_Call) RunAndReturn(run func(ctx context.Context, domain string) ([]string, error)) *MockSecretService_ListKeys_Call { | |
| 244 | - | _c.Call.Return(run) | |
| 245 | - | return _c | |
| 246 | - | } | |
| 247 | - | ||
| 248 | - | // Set provides a mock function for the type MockSecretService | |
| 249 | - | func (_mock *MockSecretService) Set(ctx context.Context, domain string, secrets map[string]string) error { | |
| 250 | - | ret := _mock.Called(ctx, domain, secrets) | |
| 251 | - | ||
| 252 | - | if len(ret) == 0 { | |
| 253 | - | panic("no return value specified for Set") | |
| 254 | - | } | |
| 255 | - | ||
| 256 | - | var r0 error | |
| 257 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, map[string]string) error); ok { | |
| 258 | - | r0 = returnFunc(ctx, domain, secrets) | |
| 259 | - | } else { | |
| 260 | - | r0 = ret.Error(0) | |
| 261 | - | } | |
| 262 | - | return r0 | |
| 263 | - | } | |
| 264 | - | ||
| 265 | - | // MockSecretService_Set_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Set' | |
| 266 | - | type MockSecretService_Set_Call struct { | |
| 267 | - | *mock.Call | |
| 268 | - | } | |
| 269 | - | ||
| 270 | - | // Set is a helper method to define mock.On call | |
| 271 | - | // - ctx context.Context | |
| 272 | - | // - domain string | |
| 273 | - | // - secrets map[string]string | |
| 274 | - | func (_e *MockSecretService_Expecter) Set(ctx any, domain any, secrets any) *MockSecretService_Set_Call { | |
| 275 | - | return &MockSecretService_Set_Call{Call: _e.mock.On("Set", ctx, domain, secrets)} | |
| 276 | - | } | |
| 277 | - | ||
| 278 | - | func (_c *MockSecretService_Set_Call) Run(run func(ctx context.Context, domain string, secrets map[string]string)) *MockSecretService_Set_Call { | |
| 279 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 280 | - | var arg0 context.Context | |
| 281 | - | if args[0] != nil { | |
| 282 | - | arg0 = args[0].(context.Context) | |
| 283 | - | } | |
| 284 | - | var arg1 string | |
| 285 | - | if args[1] != nil { | |
| 286 | - | arg1 = args[1].(string) | |
| 287 | - | } | |
| 288 | - | var arg2 map[string]string | |
| 289 | - | if args[2] != nil { | |
| 290 | - | arg2 = args[2].(map[string]string) | |
| 291 | - | } | |
| 292 | - | run( | |
| 293 | - | arg0, | |
| 294 | - | arg1, | |
| 295 | - | arg2, | |
| 296 | - | ) | |
| 297 | - | }) | |
| 298 | - | return _c | |
| 299 | - | } | |
| 300 | - | ||
| 301 | - | func (_c *MockSecretService_Set_Call) Return(err error) *MockSecretService_Set_Call { | |
| 302 | - | _c.Call.Return(err) | |
| 303 | - | return _c | |
| 304 | - | } | |
| 305 | - | ||
| 306 | - | func (_c *MockSecretService_Set_Call) RunAndReturn(run func(ctx context.Context, domain string, secrets map[string]string) error) *MockSecretService_Set_Call { | |
| 307 | - | _c.Call.Return(run) | |
| 308 | - | return _c | |
| 309 | - | } | |
D internal/boundaries/in/secrets.go +0 -25
| @@ -1,25 +0,0 @@ | |||
| 1 | - | package in | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "context" | |
| 5 | - | ) | |
| 6 | - | ||
| 7 | - | // SecretService defines the contract for managing domain-scoped secrets. | |
| 8 | - | // These are environment variables stored per-domain for container injection. | |
| 9 | - | type SecretService interface { | |
| 10 | - | // ListKeys returns the list of secret keys for a domain (not values). | |
| 11 | - | // Returns an error if the domain is invalid. | |
| 12 | - | ListKeys(ctx context.Context, domain string) ([]string, error) | |
| 13 | - | ||
| 14 | - | // GetAll returns all secrets for a domain as a key-value map. | |
| 15 | - | // Returns an error if the domain is invalid. | |
| 16 | - | GetAll(ctx context.Context, domain string) (map[string]string, error) | |
| 17 | - | ||
| 18 | - | // Set sets or updates multiple secrets for a domain, merging with existing. | |
| 19 | - | // Returns an error if the domain is invalid. | |
| 20 | - | Set(ctx context.Context, domain string, secrets map[string]string) error | |
| 21 | - | ||
| 22 | - | // Delete removes a specific secret key from a domain. | |
| 23 | - | // Returns an error if the domain is invalid. | |
| 24 | - | Delete(ctx context.Context, domain, key string) error | |
| 25 | - | } | |
D internal/boundaries/out/domain_secrets.go +0 -39
| @@ -1,39 +0,0 @@ | |||
| 1 | - | package out | |
| 2 | - | ||
| 3 | - | // AttachmentSecrets represents secrets for an attachment container. | |
| 4 | - | type AttachmentSecrets struct { | |
| 5 | - | // Service is the attachment service name (e.g., "gitea-postgres") | |
| 6 | - | Service string | |
| 7 | - | // Keys is the list of secret keys for this attachment | |
| 8 | - | Keys []string | |
| 9 | - | } | |
| 10 | - | ||
| 11 | - | // DomainSecretStore defines the contract for managing domain-scoped secrets. | |
| 12 | - | // These are environment variables stored per-domain for container injection. | |
| 13 | - | type DomainSecretStore interface { | |
| 14 | - | // ListKeys returns the list of secret keys for a domain (not values). | |
| 15 | - | ListKeys(domain string) ([]string, error) | |
| 16 | - | ||
| 17 | - | // GetAll returns all secrets for a domain as a key-value map. | |
| 18 | - | GetAll(domain string) (map[string]string, error) | |
| 19 | - | ||
| 20 | - | // Set sets or updates multiple secrets for a domain, merging with existing. | |
| 21 | - | Set(domain string, secrets map[string]string) error | |
| 22 | - | ||
| 23 | - | // Delete removes a specific secret key from a domain. | |
| 24 | - | Delete(domain, key string) error | |
| 25 | - | ||
| 26 | - | // SetAttachment sets or updates multiple secrets for an attachment container. | |
| 27 | - | SetAttachment(containerName string, secrets map[string]string) error | |
| 28 | - | ||
| 29 | - | // GetAllAttachment returns all secrets for an attachment container as a key-value map. | |
| 30 | - | GetAllAttachment(containerName string) (map[string]string, error) | |
| 31 | - | ||
| 32 | - | // DeleteAttachment removes a specific secret key from an attachment container. | |
| 33 | - | DeleteAttachment(containerName, key string) error | |
| 34 | - | ||
| 35 | - | // ListAttachmentKeys finds and returns secret keys for attachment containers | |
| 36 | - | // associated with the given domain. Returns a list of AttachmentSecrets, one | |
| 37 | - | // for each attachment that has secrets configured. | |
| 38 | - | ListAttachmentKeys(domain string) ([]AttachmentSecrets, error) | |
| 39 | - | } | |
D internal/boundaries/out/envloader.go +0 -16
| @@ -1,16 +0,0 @@ | |||
| 1 | - | package out | |
| 2 | - | ||
| 3 | - | import "context" | |
| 4 | - | ||
| 5 | - | // EnvLoader defines the contract for loading environment variables for containers. | |
| 6 | - | type EnvLoader interface { | |
| 7 | - | // LoadEnv loads environment variables for a given domain. | |
| 8 | - | // Returns a slice of "KEY=VALUE" strings. | |
| 9 | - | LoadEnv(ctx context.Context, domain string) ([]string, error) | |
| 10 | - | ||
| 11 | - | // CreateEnvFile creates an empty environment file for a new domain. | |
| 12 | - | CreateEnvFile(ctx context.Context, domain string) error | |
| 13 | - | ||
| 14 | - | // EnvFileExists checks if an environment file exists for a domain. | |
| 15 | - | EnvFileExists(domain string) (bool, error) | |
| 16 | - | } | |
D internal/boundaries/out/mocks/mock_domain_secret_store.go +0 -522
| @@ -1,522 +0,0 @@ | |||
| 1 | - | // Code generated by mockery; DO NOT EDIT. | |
| 2 | - | // github.com/vektra/mockery | |
| 3 | - | // template: testify | |
| 4 | - | ||
| 5 | - | package mocks | |
| 6 | - | ||
| 7 | - | import ( | |
| 8 | - | "github.com/bnema/gordon/internal/boundaries/out" | |
| 9 | - | mock "github.com/stretchr/testify/mock" | |
| 10 | - | ) | |
| 11 | - | ||
| 12 | - | // NewMockDomainSecretStore creates a new instance of MockDomainSecretStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 13 | - | // The first argument is typically a *testing.T value. | |
| 14 | - | func NewMockDomainSecretStore(t interface { | |
| 15 | - | mock.TestingT | |
| 16 | - | Cleanup(func()) | |
| 17 | - | }) *MockDomainSecretStore { | |
| 18 | - | if helper, ok := t.(interface{ Helper() }); ok { | |
| 19 | - | helper.Helper() | |
| 20 | - | } | |
| 21 | - | ||
| 22 | - | mock := &MockDomainSecretStore{} | |
| 23 | - | mock.Mock.Test(t) | |
| 24 | - | ||
| 25 | - | t.Cleanup(func() { | |
| 26 | - | if helper, ok := t.(interface{ Helper() }); ok { | |
| 27 | - | helper.Helper() | |
| 28 | - | } | |
| 29 | - | mock.AssertExpectations(t) | |
| 30 | - | }) | |
| 31 | - | ||
| 32 | - | return mock | |
| 33 | - | } | |
| 34 | - | ||
| 35 | - | // MockDomainSecretStore is an autogenerated mock type for the DomainSecretStore type | |
| 36 | - | type MockDomainSecretStore struct { | |
| 37 | - | mock.Mock | |
| 38 | - | } | |
| 39 | - | ||
| 40 | - | type MockDomainSecretStore_Expecter struct { | |
| 41 | - | mock *mock.Mock | |
| 42 | - | } | |
| 43 | - | ||
| 44 | - | func (_m *MockDomainSecretStore) EXPECT() *MockDomainSecretStore_Expecter { | |
| 45 | - | return &MockDomainSecretStore_Expecter{mock: &_m.Mock} | |
| 46 | - | } | |
| 47 | - | ||
| 48 | - | // Delete provides a mock function for the type MockDomainSecretStore | |
| 49 | - | func (_mock *MockDomainSecretStore) Delete(domain string, key string) error { | |
| 50 | - | ret := _mock.Called(domain, key) | |
| 51 | - | ||
| 52 | - | if len(ret) == 0 { | |
| 53 | - | panic("no return value specified for Delete") | |
| 54 | - | } | |
| 55 | - | ||
| 56 | - | var r0 error | |
| 57 | - | if returnFunc, ok := ret.Get(0).(func(string, string) error); ok { | |
| 58 | - | r0 = returnFunc(domain, key) | |
| 59 | - | } else { | |
| 60 | - | r0 = ret.Error(0) | |
| 61 | - | } | |
| 62 | - | return r0 | |
| 63 | - | } | |
| 64 | - | ||
| 65 | - | // MockDomainSecretStore_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete' | |
| 66 | - | type MockDomainSecretStore_Delete_Call struct { | |
| 67 | - | *mock.Call | |
| 68 | - | } | |
| 69 | - | ||
| 70 | - | // Delete is a helper method to define mock.On call | |
| 71 | - | // - domain string | |
| 72 | - | // - key string | |
| 73 | - | func (_e *MockDomainSecretStore_Expecter) Delete(domain any, key any) *MockDomainSecretStore_Delete_Call { | |
| 74 | - | return &MockDomainSecretStore_Delete_Call{Call: _e.mock.On("Delete", domain, key)} | |
| 75 | - | } | |
| 76 | - | ||
| 77 | - | func (_c *MockDomainSecretStore_Delete_Call) Run(run func(domain string, key string)) *MockDomainSecretStore_Delete_Call { | |
| 78 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 79 | - | var arg0 string | |
| 80 | - | if args[0] != nil { | |
| 81 | - | arg0 = args[0].(string) | |
| 82 | - | } | |
| 83 | - | var arg1 string | |
| 84 | - | if args[1] != nil { | |
| 85 | - | arg1 = args[1].(string) | |
| 86 | - | } | |
| 87 | - | run( | |
| 88 | - | arg0, | |
| 89 | - | arg1, | |
| 90 | - | ) | |
| 91 | - | }) | |
| 92 | - | return _c | |
| 93 | - | } | |
| 94 | - | ||
| 95 | - | func (_c *MockDomainSecretStore_Delete_Call) Return(err error) *MockDomainSecretStore_Delete_Call { | |
| 96 | - | _c.Call.Return(err) | |
| 97 | - | return _c | |
| 98 | - | } | |
| 99 | - | ||
| 100 | - | func (_c *MockDomainSecretStore_Delete_Call) RunAndReturn(run func(domain string, key string) error) *MockDomainSecretStore_Delete_Call { | |
| 101 | - | _c.Call.Return(run) | |
| 102 | - | return _c | |
| 103 | - | } | |
| 104 | - | ||
| 105 | - | // DeleteAttachment provides a mock function for the type MockDomainSecretStore | |
| 106 | - | func (_mock *MockDomainSecretStore) DeleteAttachment(containerName string, key string) error { | |
| 107 | - | ret := _mock.Called(containerName, key) | |
| 108 | - | ||
| 109 | - | if len(ret) == 0 { | |
| 110 | - | panic("no return value specified for DeleteAttachment") | |
| 111 | - | } | |
| 112 | - | ||
| 113 | - | var r0 error | |
| 114 | - | if returnFunc, ok := ret.Get(0).(func(string, string) error); ok { | |
| 115 | - | r0 = returnFunc(containerName, key) | |
| 116 | - | } else { | |
| 117 | - | r0 = ret.Error(0) | |
| 118 | - | } | |
| 119 | - | return r0 | |
| 120 | - | } | |
| 121 | - | ||
| 122 | - | // MockDomainSecretStore_DeleteAttachment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteAttachment' | |
| 123 | - | type MockDomainSecretStore_DeleteAttachment_Call struct { | |
| 124 | - | *mock.Call | |
| 125 | - | } | |
| 126 | - | ||
| 127 | - | // DeleteAttachment is a helper method to define mock.On call | |
| 128 | - | // - containerName string | |
| 129 | - | // - key string | |
| 130 | - | func (_e *MockDomainSecretStore_Expecter) DeleteAttachment(containerName any, key any) *MockDomainSecretStore_DeleteAttachment_Call { | |
| 131 | - | return &MockDomainSecretStore_DeleteAttachment_Call{Call: _e.mock.On("DeleteAttachment", containerName, key)} | |
| 132 | - | } | |
| 133 | - | ||
| 134 | - | func (_c *MockDomainSecretStore_DeleteAttachment_Call) Run(run func(containerName string, key string)) *MockDomainSecretStore_DeleteAttachment_Call { | |
| 135 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 136 | - | var arg0 string | |
| 137 | - | if args[0] != nil { | |
| 138 | - | arg0 = args[0].(string) | |
| 139 | - | } | |
| 140 | - | var arg1 string | |
| 141 | - | if args[1] != nil { | |
| 142 | - | arg1 = args[1].(string) | |
| 143 | - | } | |
| 144 | - | run( | |
| 145 | - | arg0, | |
| 146 | - | arg1, | |
| 147 | - | ) | |
| 148 | - | }) | |
| 149 | - | return _c | |
| 150 | - | } | |
| 151 | - | ||
| 152 | - | func (_c *MockDomainSecretStore_DeleteAttachment_Call) Return(err error) *MockDomainSecretStore_DeleteAttachment_Call { | |
| 153 | - | _c.Call.Return(err) | |
| 154 | - | return _c | |
| 155 | - | } | |
| 156 | - | ||
| 157 | - | func (_c *MockDomainSecretStore_DeleteAttachment_Call) RunAndReturn(run func(containerName string, key string) error) *MockDomainSecretStore_DeleteAttachment_Call { | |
| 158 | - | _c.Call.Return(run) | |
| 159 | - | return _c | |
| 160 | - | } | |
| 161 | - | ||
| 162 | - | // GetAll provides a mock function for the type MockDomainSecretStore | |
| 163 | - | func (_mock *MockDomainSecretStore) GetAll(domain string) (map[string]string, error) { | |
| 164 | - | ret := _mock.Called(domain) | |
| 165 | - | ||
| 166 | - | if len(ret) == 0 { | |
| 167 | - | panic("no return value specified for GetAll") | |
| 168 | - | } | |
| 169 | - | ||
| 170 | - | var r0 map[string]string | |
| 171 | - | var r1 error | |
| 172 | - | if returnFunc, ok := ret.Get(0).(func(string) (map[string]string, error)); ok { | |
| 173 | - | return returnFunc(domain) | |
| 174 | - | } | |
| 175 | - | if returnFunc, ok := ret.Get(0).(func(string) map[string]string); ok { | |
| 176 | - | r0 = returnFunc(domain) | |
| 177 | - | } else { | |
| 178 | - | if ret.Get(0) != nil { | |
| 179 | - | r0 = ret.Get(0).(map[string]string) | |
| 180 | - | } | |
| 181 | - | } | |
| 182 | - | if returnFunc, ok := ret.Get(1).(func(string) error); ok { | |
| 183 | - | r1 = returnFunc(domain) | |
| 184 | - | } else { | |
| 185 | - | r1 = ret.Error(1) | |
| 186 | - | } | |
| 187 | - | return r0, r1 | |
| 188 | - | } | |
| 189 | - | ||
| 190 | - | // MockDomainSecretStore_GetAll_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAll' | |
| 191 | - | type MockDomainSecretStore_GetAll_Call struct { | |
| 192 | - | *mock.Call | |
| 193 | - | } | |
| 194 | - | ||
| 195 | - | // GetAll is a helper method to define mock.On call | |
| 196 | - | // - domain string | |
| 197 | - | func (_e *MockDomainSecretStore_Expecter) GetAll(domain any) *MockDomainSecretStore_GetAll_Call { | |
| 198 | - | return &MockDomainSecretStore_GetAll_Call{Call: _e.mock.On("GetAll", domain)} | |
| 199 | - | } | |
| 200 | - | ||
| 201 | - | func (_c *MockDomainSecretStore_GetAll_Call) Run(run func(domain string)) *MockDomainSecretStore_GetAll_Call { | |
| 202 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 203 | - | var arg0 string | |
| 204 | - | if args[0] != nil { | |
| 205 | - | arg0 = args[0].(string) | |
| 206 | - | } | |
| 207 | - | run( | |
| 208 | - | arg0, | |
| 209 | - | ) | |
| 210 | - | }) | |
| 211 | - | return _c | |
| 212 | - | } | |
| 213 | - | ||
| 214 | - | func (_c *MockDomainSecretStore_GetAll_Call) Return(stringToString map[string]string, err error) *MockDomainSecretStore_GetAll_Call { | |
| 215 | - | _c.Call.Return(stringToString, err) | |
| 216 | - | return _c | |
| 217 | - | } | |
| 218 | - | ||
| 219 | - | func (_c *MockDomainSecretStore_GetAll_Call) RunAndReturn(run func(domain string) (map[string]string, error)) *MockDomainSecretStore_GetAll_Call { | |
| 220 | - | _c.Call.Return(run) | |
| 221 | - | return _c | |
| 222 | - | } | |
| 223 | - | ||
| 224 | - | // GetAllAttachment provides a mock function for the type MockDomainSecretStore | |
| 225 | - | func (_mock *MockDomainSecretStore) GetAllAttachment(containerName string) (map[string]string, error) { | |
| 226 | - | ret := _mock.Called(containerName) | |
| 227 | - | ||
| 228 | - | if len(ret) == 0 { | |
| 229 | - | panic("no return value specified for GetAllAttachment") | |
| 230 | - | } | |
| 231 | - | ||
| 232 | - | var r0 map[string]string | |
| 233 | - | var r1 error | |
| 234 | - | if returnFunc, ok := ret.Get(0).(func(string) (map[string]string, error)); ok { | |
| 235 | - | return returnFunc(containerName) | |
| 236 | - | } | |
| 237 | - | if returnFunc, ok := ret.Get(0).(func(string) map[string]string); ok { | |
| 238 | - | r0 = returnFunc(containerName) | |
| 239 | - | } else { | |
| 240 | - | if ret.Get(0) != nil { | |
| 241 | - | r0 = ret.Get(0).(map[string]string) | |
| 242 | - | } | |
| 243 | - | } | |
| 244 | - | if returnFunc, ok := ret.Get(1).(func(string) error); ok { | |
| 245 | - | r1 = returnFunc(containerName) | |
| 246 | - | } else { | |
| 247 | - | r1 = ret.Error(1) | |
| 248 | - | } | |
| 249 | - | return r0, r1 | |
| 250 | - | } | |
| 251 | - | ||
| 252 | - | // MockDomainSecretStore_GetAllAttachment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAllAttachment' | |
| 253 | - | type MockDomainSecretStore_GetAllAttachment_Call struct { | |
| 254 | - | *mock.Call | |
| 255 | - | } | |
| 256 | - | ||
| 257 | - | // GetAllAttachment is a helper method to define mock.On call | |
| 258 | - | // - containerName string | |
| 259 | - | func (_e *MockDomainSecretStore_Expecter) GetAllAttachment(containerName any) *MockDomainSecretStore_GetAllAttachment_Call { | |
| 260 | - | return &MockDomainSecretStore_GetAllAttachment_Call{Call: _e.mock.On("GetAllAttachment", containerName)} | |
| 261 | - | } | |
| 262 | - | ||
| 263 | - | func (_c *MockDomainSecretStore_GetAllAttachment_Call) Run(run func(containerName string)) *MockDomainSecretStore_GetAllAttachment_Call { | |
| 264 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 265 | - | var arg0 string | |
| 266 | - | if args[0] != nil { | |
| 267 | - | arg0 = args[0].(string) | |
| 268 | - | } | |
| 269 | - | run( | |
| 270 | - | arg0, | |
| 271 | - | ) | |
| 272 | - | }) | |
| 273 | - | return _c | |
| 274 | - | } | |
| 275 | - | ||
| 276 | - | func (_c *MockDomainSecretStore_GetAllAttachment_Call) Return(stringToString map[string]string, err error) *MockDomainSecretStore_GetAllAttachment_Call { | |
| 277 | - | _c.Call.Return(stringToString, err) | |
| 278 | - | return _c | |
| 279 | - | } | |
| 280 | - | ||
| 281 | - | func (_c *MockDomainSecretStore_GetAllAttachment_Call) RunAndReturn(run func(containerName string) (map[string]string, error)) *MockDomainSecretStore_GetAllAttachment_Call { | |
| 282 | - | _c.Call.Return(run) | |
| 283 | - | return _c | |
| 284 | - | } | |
| 285 | - | ||
| 286 | - | // ListAttachmentKeys provides a mock function for the type MockDomainSecretStore | |
| 287 | - | func (_mock *MockDomainSecretStore) ListAttachmentKeys(domain string) ([]out.AttachmentSecrets, error) { | |
| 288 | - | ret := _mock.Called(domain) | |
| 289 | - | ||
| 290 | - | if len(ret) == 0 { | |
| 291 | - | panic("no return value specified for ListAttachmentKeys") | |
| 292 | - | } | |
| 293 | - | ||
| 294 | - | var r0 []out.AttachmentSecrets | |
| 295 | - | var r1 error | |
| 296 | - | if returnFunc, ok := ret.Get(0).(func(string) ([]out.AttachmentSecrets, error)); ok { | |
| 297 | - | return returnFunc(domain) | |
| 298 | - | } | |
| 299 | - | if returnFunc, ok := ret.Get(0).(func(string) []out.AttachmentSecrets); ok { | |
| 300 | - | r0 = returnFunc(domain) | |
| 301 | - | } else { | |
| 302 | - | if ret.Get(0) != nil { | |
| 303 | - | r0 = ret.Get(0).([]out.AttachmentSecrets) | |
| 304 | - | } | |
| 305 | - | } | |
| 306 | - | if returnFunc, ok := ret.Get(1).(func(string) error); ok { | |
| 307 | - | r1 = returnFunc(domain) | |
| 308 | - | } else { | |
| 309 | - | r1 = ret.Error(1) | |
| 310 | - | } | |
| 311 | - | return r0, r1 | |
| 312 | - | } | |
| 313 | - | ||
| 314 | - | // MockDomainSecretStore_ListAttachmentKeys_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListAttachmentKeys' | |
| 315 | - | type MockDomainSecretStore_ListAttachmentKeys_Call struct { | |
| 316 | - | *mock.Call | |
| 317 | - | } | |
| 318 | - | ||
| 319 | - | // ListAttachmentKeys is a helper method to define mock.On call | |
| 320 | - | // - domain string | |
| 321 | - | func (_e *MockDomainSecretStore_Expecter) ListAttachmentKeys(domain any) *MockDomainSecretStore_ListAttachmentKeys_Call { | |
| 322 | - | return &MockDomainSecretStore_ListAttachmentKeys_Call{Call: _e.mock.On("ListAttachmentKeys", domain)} | |
| 323 | - | } | |
| 324 | - | ||
| 325 | - | func (_c *MockDomainSecretStore_ListAttachmentKeys_Call) Run(run func(domain string)) *MockDomainSecretStore_ListAttachmentKeys_Call { | |
| 326 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 327 | - | var arg0 string | |
| 328 | - | if args[0] != nil { | |
| 329 | - | arg0 = args[0].(string) | |
| 330 | - | } | |
| 331 | - | run( | |
| 332 | - | arg0, | |
| 333 | - | ) | |
| 334 | - | }) | |
| 335 | - | return _c | |
| 336 | - | } | |
| 337 | - | ||
| 338 | - | func (_c *MockDomainSecretStore_ListAttachmentKeys_Call) Return(attachmentSecretss []out.AttachmentSecrets, err error) *MockDomainSecretStore_ListAttachmentKeys_Call { | |
| 339 | - | _c.Call.Return(attachmentSecretss, err) | |
| 340 | - | return _c | |
| 341 | - | } | |
| 342 | - | ||
| 343 | - | func (_c *MockDomainSecretStore_ListAttachmentKeys_Call) RunAndReturn(run func(domain string) ([]out.AttachmentSecrets, error)) *MockDomainSecretStore_ListAttachmentKeys_Call { | |
| 344 | - | _c.Call.Return(run) | |
| 345 | - | return _c | |
| 346 | - | } | |
| 347 | - | ||
| 348 | - | // ListKeys provides a mock function for the type MockDomainSecretStore | |
| 349 | - | func (_mock *MockDomainSecretStore) ListKeys(domain string) ([]string, error) { | |
| 350 | - | ret := _mock.Called(domain) | |
| 351 | - | ||
| 352 | - | if len(ret) == 0 { | |
| 353 | - | panic("no return value specified for ListKeys") | |
| 354 | - | } | |
| 355 | - | ||
| 356 | - | var r0 []string | |
| 357 | - | var r1 error | |
| 358 | - | if returnFunc, ok := ret.Get(0).(func(string) ([]string, error)); ok { | |
| 359 | - | return returnFunc(domain) | |
| 360 | - | } | |
| 361 | - | if returnFunc, ok := ret.Get(0).(func(string) []string); ok { | |
| 362 | - | r0 = returnFunc(domain) | |
| 363 | - | } else { | |
| 364 | - | if ret.Get(0) != nil { | |
| 365 | - | r0 = ret.Get(0).([]string) | |
| 366 | - | } | |
| 367 | - | } | |
| 368 | - | if returnFunc, ok := ret.Get(1).(func(string) error); ok { | |
| 369 | - | r1 = returnFunc(domain) | |
| 370 | - | } else { | |
| 371 | - | r1 = ret.Error(1) | |
| 372 | - | } | |
| 373 | - | return r0, r1 | |
| 374 | - | } | |
| 375 | - | ||
| 376 | - | // MockDomainSecretStore_ListKeys_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListKeys' | |
| 377 | - | type MockDomainSecretStore_ListKeys_Call struct { | |
| 378 | - | *mock.Call | |
| 379 | - | } | |
| 380 | - | ||
| 381 | - | // ListKeys is a helper method to define mock.On call | |
| 382 | - | // - domain string | |
| 383 | - | func (_e *MockDomainSecretStore_Expecter) ListKeys(domain any) *MockDomainSecretStore_ListKeys_Call { | |
| 384 | - | return &MockDomainSecretStore_ListKeys_Call{Call: _e.mock.On("ListKeys", domain)} | |
| 385 | - | } | |
| 386 | - | ||
| 387 | - | func (_c *MockDomainSecretStore_ListKeys_Call) Run(run func(domain string)) *MockDomainSecretStore_ListKeys_Call { | |
| 388 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 389 | - | var arg0 string | |
| 390 | - | if args[0] != nil { | |
| 391 | - | arg0 = args[0].(string) | |
| 392 | - | } | |
| 393 | - | run( | |
| 394 | - | arg0, | |
| 395 | - | ) | |
| 396 | - | }) | |
| 397 | - | return _c | |
| 398 | - | } | |
| 399 | - | ||
| 400 | - | func (_c *MockDomainSecretStore_ListKeys_Call) Return(strings []string, err error) *MockDomainSecretStore_ListKeys_Call { | |
| 401 | - | _c.Call.Return(strings, err) | |
| 402 | - | return _c | |
| 403 | - | } | |
| 404 | - | ||
| 405 | - | func (_c *MockDomainSecretStore_ListKeys_Call) RunAndReturn(run func(domain string) ([]string, error)) *MockDomainSecretStore_ListKeys_Call { | |
| 406 | - | _c.Call.Return(run) | |
| 407 | - | return _c | |
| 408 | - | } | |
| 409 | - | ||
| 410 | - | // Set provides a mock function for the type MockDomainSecretStore | |
| 411 | - | func (_mock *MockDomainSecretStore) Set(domain string, secrets map[string]string) error { | |
| 412 | - | ret := _mock.Called(domain, secrets) | |
| 413 | - | ||
| 414 | - | if len(ret) == 0 { | |
| 415 | - | panic("no return value specified for Set") | |
| 416 | - | } | |
| 417 | - | ||
| 418 | - | var r0 error | |
| 419 | - | if returnFunc, ok := ret.Get(0).(func(string, map[string]string) error); ok { | |
| 420 | - | r0 = returnFunc(domain, secrets) | |
| 421 | - | } else { | |
| 422 | - | r0 = ret.Error(0) | |
| 423 | - | } | |
| 424 | - | return r0 | |
| 425 | - | } | |
| 426 | - | ||
| 427 | - | // MockDomainSecretStore_Set_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Set' | |
| 428 | - | type MockDomainSecretStore_Set_Call struct { | |
| 429 | - | *mock.Call | |
| 430 | - | } | |
| 431 | - | ||
| 432 | - | // Set is a helper method to define mock.On call | |
| 433 | - | // - domain string | |
| 434 | - | // - secrets map[string]string | |
| 435 | - | func (_e *MockDomainSecretStore_Expecter) Set(domain any, secrets any) *MockDomainSecretStore_Set_Call { | |
| 436 | - | return &MockDomainSecretStore_Set_Call{Call: _e.mock.On("Set", domain, secrets)} | |
| 437 | - | } | |
| 438 | - | ||
| 439 | - | func (_c *MockDomainSecretStore_Set_Call) Run(run func(domain string, secrets map[string]string)) *MockDomainSecretStore_Set_Call { | |
| 440 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 441 | - | var arg0 string | |
| 442 | - | if args[0] != nil { | |
| 443 | - | arg0 = args[0].(string) | |
| 444 | - | } | |
| 445 | - | var arg1 map[string]string | |
| 446 | - | if args[1] != nil { | |
| 447 | - | arg1 = args[1].(map[string]string) | |
| 448 | - | } | |
| 449 | - | run( | |
| 450 | - | arg0, | |
| 451 | - | arg1, | |
| 452 | - | ) | |
| 453 | - | }) | |
| 454 | - | return _c | |
| 455 | - | } | |
| 456 | - | ||
| 457 | - | func (_c *MockDomainSecretStore_Set_Call) Return(err error) *MockDomainSecretStore_Set_Call { | |
| 458 | - | _c.Call.Return(err) | |
| 459 | - | return _c | |
| 460 | - | } | |
| 461 | - | ||
| 462 | - | func (_c *MockDomainSecretStore_Set_Call) RunAndReturn(run func(domain string, secrets map[string]string) error) *MockDomainSecretStore_Set_Call { | |
| 463 | - | _c.Call.Return(run) | |
| 464 | - | return _c | |
| 465 | - | } | |
| 466 | - | ||
| 467 | - | // SetAttachment provides a mock function for the type MockDomainSecretStore | |
| 468 | - | func (_mock *MockDomainSecretStore) SetAttachment(containerName string, secrets map[string]string) error { | |
| 469 | - | ret := _mock.Called(containerName, secrets) | |
| 470 | - | ||
| 471 | - | if len(ret) == 0 { | |
| 472 | - | panic("no return value specified for SetAttachment") | |
| 473 | - | } | |
| 474 | - | ||
| 475 | - | var r0 error | |
| 476 | - | if returnFunc, ok := ret.Get(0).(func(string, map[string]string) error); ok { | |
| 477 | - | r0 = returnFunc(containerName, secrets) | |
| 478 | - | } else { | |
| 479 | - | r0 = ret.Error(0) | |
| 480 | - | } | |
| 481 | - | return r0 | |
| 482 | - | } | |
| 483 | - | ||
| 484 | - | // MockDomainSecretStore_SetAttachment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetAttachment' | |
| 485 | - | type MockDomainSecretStore_SetAttachment_Call struct { | |
| 486 | - | *mock.Call | |
| 487 | - | } | |
| 488 | - | ||
| 489 | - | // SetAttachment is a helper method to define mock.On call | |
| 490 | - | // - containerName string | |
| 491 | - | // - secrets map[string]string | |
| 492 | - | func (_e *MockDomainSecretStore_Expecter) SetAttachment(containerName any, secrets any) *MockDomainSecretStore_SetAttachment_Call { | |
| 493 | - | return &MockDomainSecretStore_SetAttachment_Call{Call: _e.mock.On("SetAttachment", containerName, secrets)} | |
| 494 | - | } | |
| 495 | - | ||
| 496 | - | func (_c *MockDomainSecretStore_SetAttachment_Call) Run(run func(containerName string, secrets map[string]string)) *MockDomainSecretStore_SetAttachment_Call { | |
| 497 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 498 | - | var arg0 string | |
| 499 | - | if args[0] != nil { | |
| 500 | - | arg0 = args[0].(string) | |
| 501 | - | } | |
| 502 | - | var arg1 map[string]string | |
| 503 | - | if args[1] != nil { | |
| 504 | - | arg1 = args[1].(map[string]string) | |
| 505 | - | } | |
| 506 | - | run( | |
| 507 | - | arg0, | |
| 508 | - | arg1, | |
| 509 | - | ) | |
| 510 | - | }) | |
| 511 | - | return _c | |
| 512 | - | } | |
| 513 | - | ||
| 514 | - | func (_c *MockDomainSecretStore_SetAttachment_Call) Return(err error) *MockDomainSecretStore_SetAttachment_Call { | |
| 515 | - | _c.Call.Return(err) | |
| 516 | - | return _c | |
| 517 | - | } | |
| 518 | - | ||
| 519 | - | func (_c *MockDomainSecretStore_SetAttachment_Call) RunAndReturn(run func(containerName string, secrets map[string]string) error) *MockDomainSecretStore_SetAttachment_Call { | |
| 520 | - | _c.Call.Return(run) | |
| 521 | - | return _c | |
| 522 | - | } | |
D internal/boundaries/out/mocks/mock_env_loader.go +0 -232
| @@ -1,232 +0,0 @@ | |||
| 1 | - | // Code generated by mockery; DO NOT EDIT. | |
| 2 | - | // github.com/vektra/mockery | |
| 3 | - | // template: testify | |
| 4 | - | ||
| 5 | - | package mocks | |
| 6 | - | ||
| 7 | - | import ( | |
| 8 | - | "context" | |
| 9 | - | ||
| 10 | - | mock "github.com/stretchr/testify/mock" | |
| 11 | - | ) | |
| 12 | - | ||
| 13 | - | // NewMockEnvLoader creates a new instance of MockEnvLoader. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 14 | - | // The first argument is typically a *testing.T value. | |
| 15 | - | func NewMockEnvLoader(t interface { | |
| 16 | - | mock.TestingT | |
| 17 | - | Cleanup(func()) | |
| 18 | - | }) *MockEnvLoader { | |
| 19 | - | if helper, ok := t.(interface{ Helper() }); ok { | |
| 20 | - | helper.Helper() | |
| 21 | - | } | |
| 22 | - | ||
| 23 | - | mock := &MockEnvLoader{} | |
| 24 | - | mock.Mock.Test(t) | |
| 25 | - | ||
| 26 | - | t.Cleanup(func() { | |
| 27 | - | if helper, ok := t.(interface{ Helper() }); ok { | |
| 28 | - | helper.Helper() | |
| 29 | - | } | |
| 30 | - | mock.AssertExpectations(t) | |
| 31 | - | }) | |
| 32 | - | ||
| 33 | - | return mock | |
| 34 | - | } | |
| 35 | - | ||
| 36 | - | // MockEnvLoader is an autogenerated mock type for the EnvLoader type | |
| 37 | - | type MockEnvLoader struct { | |
| 38 | - | mock.Mock | |
| 39 | - | } | |
| 40 | - | ||
| 41 | - | type MockEnvLoader_Expecter struct { | |
| 42 | - | mock *mock.Mock | |
| 43 | - | } | |
| 44 | - | ||
| 45 | - | func (_m *MockEnvLoader) EXPECT() *MockEnvLoader_Expecter { | |
| 46 | - | return &MockEnvLoader_Expecter{mock: &_m.Mock} | |
| 47 | - | } | |
| 48 | - | ||
| 49 | - | // CreateEnvFile provides a mock function for the type MockEnvLoader | |
| 50 | - | func (_mock *MockEnvLoader) CreateEnvFile(ctx context.Context, domain string) error { | |
| 51 | - | ret := _mock.Called(ctx, domain) | |
| 52 | - | ||
| 53 | - | if len(ret) == 0 { | |
| 54 | - | panic("no return value specified for CreateEnvFile") | |
| 55 | - | } | |
| 56 | - | ||
| 57 | - | var r0 error | |
| 58 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok { | |
| 59 | - | r0 = returnFunc(ctx, domain) | |
| 60 | - | } else { | |
| 61 | - | r0 = ret.Error(0) | |
| 62 | - | } | |
| 63 | - | return r0 | |
| 64 | - | } | |
| 65 | - | ||
| 66 | - | // MockEnvLoader_CreateEnvFile_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateEnvFile' | |
| 67 | - | type MockEnvLoader_CreateEnvFile_Call struct { | |
| 68 | - | *mock.Call | |
| 69 | - | } | |
| 70 | - | ||
| 71 | - | // CreateEnvFile is a helper method to define mock.On call | |
| 72 | - | // - ctx context.Context | |
| 73 | - | // - domain string | |
| 74 | - | func (_e *MockEnvLoader_Expecter) CreateEnvFile(ctx any, domain any) *MockEnvLoader_CreateEnvFile_Call { | |
| 75 | - | return &MockEnvLoader_CreateEnvFile_Call{Call: _e.mock.On("CreateEnvFile", ctx, domain)} | |
| 76 | - | } | |
| 77 | - | ||
| 78 | - | func (_c *MockEnvLoader_CreateEnvFile_Call) Run(run func(ctx context.Context, domain string)) *MockEnvLoader_CreateEnvFile_Call { | |
| 79 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 80 | - | var arg0 context.Context | |
| 81 | - | if args[0] != nil { | |
| 82 | - | arg0 = args[0].(context.Context) | |
| 83 | - | } | |
| 84 | - | var arg1 string | |
| 85 | - | if args[1] != nil { | |
| 86 | - | arg1 = args[1].(string) | |
| 87 | - | } | |
| 88 | - | run( | |
| 89 | - | arg0, | |
| 90 | - | arg1, | |
| 91 | - | ) | |
| 92 | - | }) | |
| 93 | - | return _c | |
| 94 | - | } | |
| 95 | - | ||
| 96 | - | func (_c *MockEnvLoader_CreateEnvFile_Call) Return(err error) *MockEnvLoader_CreateEnvFile_Call { | |
| 97 | - | _c.Call.Return(err) | |
| 98 | - | return _c | |
| 99 | - | } | |
| 100 | - | ||
| 101 | - | func (_c *MockEnvLoader_CreateEnvFile_Call) RunAndReturn(run func(ctx context.Context, domain string) error) *MockEnvLoader_CreateEnvFile_Call { | |
| 102 | - | _c.Call.Return(run) | |
| 103 | - | return _c | |
| 104 | - | } | |
| 105 | - | ||
| 106 | - | // EnvFileExists provides a mock function for the type MockEnvLoader | |
| 107 | - | func (_mock *MockEnvLoader) EnvFileExists(domain string) (bool, error) { | |
| 108 | - | ret := _mock.Called(domain) | |
| 109 | - | ||
| 110 | - | if len(ret) == 0 { | |
| 111 | - | panic("no return value specified for EnvFileExists") | |
| 112 | - | } | |
| 113 | - | ||
| 114 | - | var r0 bool | |
| 115 | - | var r1 error | |
| 116 | - | if returnFunc, ok := ret.Get(0).(func(string) (bool, error)); ok { | |
| 117 | - | return returnFunc(domain) | |
| 118 | - | } | |
| 119 | - | if returnFunc, ok := ret.Get(0).(func(string) bool); ok { | |
| 120 | - | r0 = returnFunc(domain) | |
| 121 | - | } else { | |
| 122 | - | r0 = ret.Get(0).(bool) | |
| 123 | - | } | |
| 124 | - | if returnFunc, ok := ret.Get(1).(func(string) error); ok { | |
| 125 | - | r1 = returnFunc(domain) | |
| 126 | - | } else { | |
| 127 | - | r1 = ret.Error(1) | |
| 128 | - | } | |
| 129 | - | return r0, r1 | |
| 130 | - | } | |
| 131 | - | ||
| 132 | - | // MockEnvLoader_EnvFileExists_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnvFileExists' | |
| 133 | - | type MockEnvLoader_EnvFileExists_Call struct { | |
| 134 | - | *mock.Call | |
| 135 | - | } | |
| 136 | - | ||
| 137 | - | // EnvFileExists is a helper method to define mock.On call | |
| 138 | - | // - domain string | |
| 139 | - | func (_e *MockEnvLoader_Expecter) EnvFileExists(domain any) *MockEnvLoader_EnvFileExists_Call { | |
| 140 | - | return &MockEnvLoader_EnvFileExists_Call{Call: _e.mock.On("EnvFileExists", domain)} | |
| 141 | - | } | |
| 142 | - | ||
| 143 | - | func (_c *MockEnvLoader_EnvFileExists_Call) Run(run func(domain string)) *MockEnvLoader_EnvFileExists_Call { | |
| 144 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 145 | - | var arg0 string | |
| 146 | - | if args[0] != nil { | |
| 147 | - | arg0 = args[0].(string) | |
| 148 | - | } | |
| 149 | - | run( | |
| 150 | - | arg0, | |
| 151 | - | ) | |
| 152 | - | }) | |
| 153 | - | return _c | |
| 154 | - | } | |
| 155 | - | ||
| 156 | - | func (_c *MockEnvLoader_EnvFileExists_Call) Return(b bool, err error) *MockEnvLoader_EnvFileExists_Call { | |
| 157 | - | _c.Call.Return(b, err) | |
| 158 | - | return _c | |
| 159 | - | } | |
| 160 | - | ||
| 161 | - | func (_c *MockEnvLoader_EnvFileExists_Call) RunAndReturn(run func(domain string) (bool, error)) *MockEnvLoader_EnvFileExists_Call { | |
| 162 | - | _c.Call.Return(run) | |
| 163 | - | return _c | |
| 164 | - | } | |
| 165 | - | ||
| 166 | - | // LoadEnv provides a mock function for the type MockEnvLoader | |
| 167 | - | func (_mock *MockEnvLoader) LoadEnv(ctx context.Context, domain string) ([]string, error) { | |
| 168 | - | ret := _mock.Called(ctx, domain) | |
| 169 | - | ||
| 170 | - | if len(ret) == 0 { | |
| 171 | - | panic("no return value specified for LoadEnv") | |
| 172 | - | } | |
| 173 | - | ||
| 174 | - | var r0 []string | |
| 175 | - | var r1 error | |
| 176 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]string, error)); ok { | |
| 177 | - | return returnFunc(ctx, domain) | |
| 178 | - | } | |
| 179 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string) []string); ok { | |
| 180 | - | r0 = returnFunc(ctx, domain) | |
| 181 | - | } else { | |
| 182 | - | if ret.Get(0) != nil { | |
| 183 | - | r0 = ret.Get(0).([]string) | |
| 184 | - | } | |
| 185 | - | } | |
| 186 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | |
| 187 | - | r1 = returnFunc(ctx, domain) | |
| 188 | - | } else { | |
| 189 | - | r1 = ret.Error(1) | |
| 190 | - | } | |
| 191 | - | return r0, r1 | |
| 192 | - | } | |
| 193 | - | ||
| 194 | - | // MockEnvLoader_LoadEnv_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LoadEnv' | |
| 195 | - | type MockEnvLoader_LoadEnv_Call struct { | |
| 196 | - | *mock.Call | |
| 197 | - | } | |
| 198 | - | ||
| 199 | - | // LoadEnv is a helper method to define mock.On call | |
| 200 | - | // - ctx context.Context | |
| 201 | - | // - domain string | |
| 202 | - | func (_e *MockEnvLoader_Expecter) LoadEnv(ctx any, domain any) *MockEnvLoader_LoadEnv_Call { | |
| 203 | - | return &MockEnvLoader_LoadEnv_Call{Call: _e.mock.On("LoadEnv", ctx, domain)} | |
| 204 | - | } | |
| 205 | - | ||
| 206 | - | func (_c *MockEnvLoader_LoadEnv_Call) Run(run func(ctx context.Context, domain string)) *MockEnvLoader_LoadEnv_Call { | |
| 207 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 208 | - | var arg0 context.Context | |
| 209 | - | if args[0] != nil { | |
| 210 | - | arg0 = args[0].(context.Context) | |
| 211 | - | } | |
| 212 | - | var arg1 string | |
| 213 | - | if args[1] != nil { | |
| 214 | - | arg1 = args[1].(string) | |
| 215 | - | } | |
| 216 | - | run( | |
| 217 | - | arg0, | |
| 218 | - | arg1, | |
| 219 | - | ) | |
| 220 | - | }) | |
| 221 | - | return _c | |
| 222 | - | } | |
| 223 | - | ||
| 224 | - | func (_c *MockEnvLoader_LoadEnv_Call) Return(strings []string, err error) *MockEnvLoader_LoadEnv_Call { | |
| 225 | - | _c.Call.Return(strings, err) | |
| 226 | - | return _c | |
| 227 | - | } | |
| 228 | - | ||
| 229 | - | func (_c *MockEnvLoader_LoadEnv_Call) RunAndReturn(run func(ctx context.Context, domain string) ([]string, error)) *MockEnvLoader_LoadEnv_Call { | |
| 230 | - | _c.Call.Return(run) | |
| 231 | - | return _c | |
| 232 | - | } | |
M internal/domain/auth.go +0 -6
| @@ -77,7 +77,6 @@ const ( | |||
| 77 | 77 | // Admin scope resource constants. | |
| 78 | 78 | const ( | |
| 79 | 79 | AdminResourceRoutes = "routes" | |
| 80 | - | AdminResourceSecrets = "secrets" | |
| 81 | 80 | AdminResourceConfig = "config" | |
| 82 | 81 | AdminResourceStatus = "status" | |
| 83 | 82 | AdminResourceLogs = "logs" | |
| @@ -287,11 +286,6 @@ func AdminScopeRoutes(actions ...string) string { | |||
| 287 | 286 | return fmt.Sprintf("%s:%s:%s", ScopeTypeAdmin, AdminResourceRoutes, strings.Join(actions, ",")) | |
| 288 | 287 | } | |
| 289 | 288 | ||
| 290 | - | // AdminScopeSecrets creates an admin scope for secrets with the given actions. | |
| 291 | - | func AdminScopeSecrets(actions ...string) string { | |
| 292 | - | return fmt.Sprintf("%s:%s:%s", ScopeTypeAdmin, AdminResourceSecrets, strings.Join(actions, ",")) | |
| 293 | - | } | |
| 294 | - | ||
| 295 | 289 | // AdminScopeConfig creates an admin scope for config with the given actions. | |
| 296 | 290 | func AdminScopeConfig(actions ...string) string { | |
| 297 | 291 | return fmt.Sprintf("%s:%s:%s", ScopeTypeAdmin, AdminResourceConfig, strings.Join(actions, ",")) | |
M internal/domain/env.go +2 -74
| @@ -1,71 +1,15 @@ | |||
| 1 | 1 | package domain | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "bufio" | |
| 5 | - | "bytes" | |
| 6 | 4 | "regexp" | |
| 7 | 5 | "strings" | |
| 8 | 6 | ) | |
| 9 | 7 | ||
| 10 | 8 | 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):[^}]+\}`) | |
| 14 | 11 | ) | |
| 15 | 12 | ||
| 16 | - | // SanitizeDomainForEnvFile validates and sanitizes a domain name for env file storage. | |
| 17 | - | // Returns the collision-resistant storage key used for filenames. | |
| 18 | - | func SanitizeDomainForEnvFile(domainName string) (string, error) { | |
| 19 | - | safeDomain, err := NewEnvStorageKey(domainName) | |
| 20 | - | if err != nil { | |
| 21 | - | return "", err | |
| 22 | - | } | |
| 23 | - | ||
| 24 | - | return string(safeDomain), nil | |
| 25 | - | } | |
| 26 | - | ||
| 27 | - | // ParseEnvData parses env data into a key-value map. | |
| 28 | - | func ParseEnvData(data []byte) (map[string]string, error) { | |
| 29 | - | secrets := make(map[string]string) | |
| 30 | - | scanner := bufio.NewScanner(bytes.NewReader(data)) | |
| 31 | - | // Allow large env values (certs/keys) without hitting the default ~64KB limit. | |
| 32 | - | scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) | |
| 33 | - | for scanner.Scan() { | |
| 34 | - | line := strings.TrimSpace(scanner.Text()) | |
| 35 | - | if line == "" || strings.HasPrefix(line, "#") { | |
| 36 | - | continue | |
| 37 | - | } | |
| 38 | - | ||
| 39 | - | parts := strings.SplitN(line, "=", 2) | |
| 40 | - | if len(parts) != 2 { | |
| 41 | - | continue | |
| 42 | - | } | |
| 43 | - | ||
| 44 | - | key := strings.TrimSpace(parts[0]) | |
| 45 | - | rawValue := parts[1] | |
| 46 | - | ||
| 47 | - | trimmedValue := strings.TrimSpace(rawValue) | |
| 48 | - | value := trimmedValue | |
| 49 | - | ||
| 50 | - | if len(trimmedValue) >= 2 { | |
| 51 | - | if (strings.HasPrefix(trimmedValue, "\"") && strings.HasSuffix(trimmedValue, "\"")) || | |
| 52 | - | (strings.HasPrefix(trimmedValue, "'") && strings.HasSuffix(trimmedValue, "'")) { | |
| 53 | - | value = trimmedValue[1 : len(trimmedValue)-1] | |
| 54 | - | } | |
| 55 | - | } | |
| 56 | - | ||
| 57 | - | if key != "" { | |
| 58 | - | secrets[key] = value | |
| 59 | - | } | |
| 60 | - | } | |
| 61 | - | ||
| 62 | - | if err := scanner.Err(); err != nil { | |
| 63 | - | return nil, err | |
| 64 | - | } | |
| 65 | - | ||
| 66 | - | return secrets, nil | |
| 67 | - | } | |
| 68 | - | ||
| 69 | 13 | // ValidateEnvKey validates an env key for storage. | |
| 70 | 14 | func ValidateEnvKey(key string) error { | |
| 71 | 15 | if key == "" { | |
| @@ -87,19 +31,3 @@ func ValidateEnvKey(key string) error { | |||
| 87 | 31 | func ContainsSecretReference(value string) bool { | |
| 88 | 32 | return secretRefRegex.MatchString(value) | |
| 89 | 33 | } | |
| 90 | - | ||
| 91 | - | // ValidateContainerName validates a container name for attachment storage. | |
| 92 | - | // Container names can contain alphanumeric characters, hyphens, and underscores, | |
| 93 | - | // but must start with a letter. | |
| 94 | - | func ValidateContainerName(name string) error { | |
| 95 | - | if name == "" { | |
| 96 | - | return ErrInvalidContainerName | |
| 97 | - | } | |
| 98 | - | if strings.Contains(name, "..") || strings.ContainsAny(name, "/\\") { | |
| 99 | - | return ErrPathTraversal | |
| 100 | - | } | |
| 101 | - | if !containerNameRegex.MatchString(name) { | |
| 102 | - | return ErrInvalidContainerName | |
| 103 | - | } | |
| 104 | - | return nil | |
| 105 | - | } | |
M internal/domain/env_test.go +0 -176
| @@ -1,147 +1,12 @@ | |||
| 1 | 1 | package domain | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "encoding/base64" | |
| 5 | - | "strings" | |
| 6 | 4 | "testing" | |
| 7 | 5 | ||
| 8 | 6 | "github.com/stretchr/testify/assert" | |
| 9 | 7 | "github.com/stretchr/testify/require" | |
| 10 | 8 | ) | |
| 11 | 9 | ||
| 12 | - | func TestSanitizeDomainForEnvFile(t *testing.T) { | |
| 13 | - | tests := []struct { | |
| 14 | - | name string | |
| 15 | - | domain string | |
| 16 | - | want string | |
| 17 | - | wantErr bool | |
| 18 | - | }{ | |
| 19 | - | {name: "simple domain", domain: "example.com", want: base64.RawURLEncoding.EncodeToString([]byte("example.com"))}, | |
| 20 | - | {name: "subdomain", domain: "app.example.com", want: base64.RawURLEncoding.EncodeToString([]byte("app.example.com"))}, | |
| 21 | - | {name: "domain with port", domain: "example.com:8080", want: base64.RawURLEncoding.EncodeToString([]byte("example.com:8080"))}, | |
| 22 | - | {name: "domain with path", domain: "example.com/path", want: base64.RawURLEncoding.EncodeToString([]byte("example.com/path"))}, | |
| 23 | - | {name: "single char", domain: "a", want: base64.RawURLEncoding.EncodeToString([]byte("a"))}, | |
| 24 | - | {name: "hyphenated", domain: "my-app.example.com", want: base64.RawURLEncoding.EncodeToString([]byte("my-app.example.com"))}, | |
| 25 | - | {name: "empty", domain: "", wantErr: true}, | |
| 26 | - | {name: "path traversal", domain: "../etc/passwd", wantErr: true}, | |
| 27 | - | {name: "double dots", domain: "foo..bar", wantErr: true}, | |
| 28 | - | {name: "starts with dot", domain: ".hidden", wantErr: true}, | |
| 29 | - | {name: "ends with dot", domain: "trailing.", wantErr: true}, | |
| 30 | - | {name: "space", domain: "has space", wantErr: true}, | |
| 31 | - | {name: "special chars", domain: "bad$domain", wantErr: true}, | |
| 32 | - | {name: "underscore rejected as invalid domain character", domain: "example_com", wantErr: true}, | |
| 33 | - | } | |
| 34 | - | ||
| 35 | - | for _, tt := range tests { | |
| 36 | - | t.Run(tt.name, func(t *testing.T) { | |
| 37 | - | got, err := SanitizeDomainForEnvFile(tt.domain) | |
| 38 | - | if tt.wantErr { | |
| 39 | - | assert.Error(t, err) | |
| 40 | - | return | |
| 41 | - | } | |
| 42 | - | require.NoError(t, err) | |
| 43 | - | assert.Equal(t, tt.want, got) | |
| 44 | - | }) | |
| 45 | - | } | |
| 46 | - | } | |
| 47 | - | ||
| 48 | - | func TestSanitizeDomainForEnvFile_DistinguishesSeparators(t *testing.T) { | |
| 49 | - | gotDot, err := SanitizeDomainForEnvFile("app.example.com") | |
| 50 | - | require.NoError(t, err) | |
| 51 | - | ||
| 52 | - | gotPort, err := SanitizeDomainForEnvFile("app:example:com") | |
| 53 | - | require.NoError(t, err) | |
| 54 | - | ||
| 55 | - | gotPath, err := SanitizeDomainForEnvFile("app/example/com") | |
| 56 | - | require.NoError(t, err) | |
| 57 | - | ||
| 58 | - | assert.NotEqual(t, gotDot, gotPort) | |
| 59 | - | assert.NotEqual(t, gotDot, gotPath) | |
| 60 | - | assert.NotEqual(t, gotPort, gotPath) | |
| 61 | - | } | |
| 62 | - | ||
| 63 | - | func TestParseEnvData(t *testing.T) { | |
| 64 | - | tests := []struct { | |
| 65 | - | name string | |
| 66 | - | data string | |
| 67 | - | want map[string]string | |
| 68 | - | wantErr bool | |
| 69 | - | }{ | |
| 70 | - | { | |
| 71 | - | name: "simple key-value", | |
| 72 | - | data: "FOO=bar\nBAZ=qux", | |
| 73 | - | want: map[string]string{"FOO": "bar", "BAZ": "qux"}, | |
| 74 | - | }, | |
| 75 | - | { | |
| 76 | - | name: "double-quoted value", | |
| 77 | - | data: `KEY="hello world"`, | |
| 78 | - | want: map[string]string{"KEY": "hello world"}, | |
| 79 | - | }, | |
| 80 | - | { | |
| 81 | - | name: "single-quoted value", | |
| 82 | - | data: `KEY='hello world'`, | |
| 83 | - | want: map[string]string{"KEY": "hello world"}, | |
| 84 | - | }, | |
| 85 | - | { | |
| 86 | - | name: "comment lines", | |
| 87 | - | data: "# this is a comment\nKEY=val", | |
| 88 | - | want: map[string]string{"KEY": "val"}, | |
| 89 | - | }, | |
| 90 | - | { | |
| 91 | - | name: "empty lines", | |
| 92 | - | data: "\n\nKEY=val\n\n", | |
| 93 | - | want: map[string]string{"KEY": "val"}, | |
| 94 | - | }, | |
| 95 | - | { | |
| 96 | - | name: "value with equals", | |
| 97 | - | data: "URL=postgres://host:5432/db?opt=1", | |
| 98 | - | want: map[string]string{"URL": "postgres://host:5432/db?opt=1"}, | |
| 99 | - | }, | |
| 100 | - | { | |
| 101 | - | name: "no value", | |
| 102 | - | data: "NOVALUE", | |
| 103 | - | want: map[string]string{}, | |
| 104 | - | }, | |
| 105 | - | { | |
| 106 | - | name: "empty value", | |
| 107 | - | data: "KEY=", | |
| 108 | - | want: map[string]string{"KEY": ""}, | |
| 109 | - | }, | |
| 110 | - | { | |
| 111 | - | name: "empty input", | |
| 112 | - | data: "", | |
| 113 | - | want: map[string]string{}, | |
| 114 | - | }, | |
| 115 | - | { | |
| 116 | - | name: "large value within buffer", | |
| 117 | - | data: "BIG=" + strings.Repeat("x", 100000), | |
| 118 | - | want: map[string]string{"BIG": strings.Repeat("x", 100000)}, | |
| 119 | - | }, | |
| 120 | - | { | |
| 121 | - | name: "quoted value with inner spaces", | |
| 122 | - | data: `KEY=" hello "`, | |
| 123 | - | want: map[string]string{"KEY": " hello "}, | |
| 124 | - | }, | |
| 125 | - | { | |
| 126 | - | name: "unquoted value with outer spaces", | |
| 127 | - | data: "KEY= hello ", | |
| 128 | - | want: map[string]string{"KEY": "hello"}, | |
| 129 | - | }, | |
| 130 | - | } | |
| 131 | - | ||
| 132 | - | for _, tt := range tests { | |
| 133 | - | t.Run(tt.name, func(t *testing.T) { | |
| 134 | - | got, err := ParseEnvData([]byte(tt.data)) | |
| 135 | - | if tt.wantErr { | |
| 136 | - | assert.Error(t, err) | |
| 137 | - | return | |
| 138 | - | } | |
| 139 | - | require.NoError(t, err) | |
| 140 | - | assert.Equal(t, tt.want, got) | |
| 141 | - | }) | |
| 142 | - | } | |
| 143 | - | } | |
| 144 | - | ||
| 145 | 10 | func TestValidateEnvKey(t *testing.T) { | |
| 146 | 11 | tests := []struct { | |
| 147 | 12 | name string | |
| @@ -209,44 +74,3 @@ func TestContainsSecretReference(t *testing.T) { | |||
| 209 | 74 | }) | |
| 210 | 75 | } | |
| 211 | 76 | } | |
| 212 | - | ||
| 213 | - | func TestValidateContainerName(t *testing.T) { | |
| 214 | - | tests := []struct { | |
| 215 | - | name string | |
| 216 | - | container string | |
| 217 | - | wantErr bool | |
| 218 | - | wantErrType error | |
| 219 | - | }{ | |
| 220 | - | {name: "simple", container: "postgres", wantErr: false}, | |
| 221 | - | {name: "with hyphen", container: "gitea-postgres", wantErr: false}, | |
| 222 | - | {name: "with underscore", container: "gitea_postgres", wantErr: false}, | |
| 223 | - | {name: "complex real container", container: "gordon-git-example-com-gitea-postgres", wantErr: false}, | |
| 224 | - | {name: "starts with letter", container: "a-b", wantErr: false}, | |
| 225 | - | {name: "lowercase", container: "my-container", wantErr: false}, | |
| 226 | - | {name: "mixed case", container: "MyContainer-123", wantErr: false}, | |
| 227 | - | {name: "empty", container: "", wantErr: true, wantErrType: ErrInvalidContainerName}, | |
| 228 | - | {name: "starts with number", container: "1container", wantErr: true, wantErrType: ErrInvalidContainerName}, | |
| 229 | - | {name: "starts with hyphen", container: "-container", wantErr: true, wantErrType: ErrInvalidContainerName}, | |
| 230 | - | {name: "starts with underscore", container: "_container", wantErr: true, wantErrType: ErrInvalidContainerName}, | |
| 231 | - | {name: "contains slash", container: "container/name", wantErr: true, wantErrType: ErrPathTraversal}, | |
| 232 | - | {name: "contains backslash", container: `container\name`, wantErr: true, wantErrType: ErrPathTraversal}, | |
| 233 | - | {name: "path traversal", container: "..", wantErr: true, wantErrType: ErrPathTraversal}, | |
| 234 | - | {name: "contains dot", container: "container.name", wantErr: true, wantErrType: ErrInvalidContainerName}, | |
| 235 | - | {name: "contains space", container: "container name", wantErr: true, wantErrType: ErrInvalidContainerName}, | |
| 236 | - | {name: "contains special char", container: "container$name", wantErr: true, wantErrType: ErrInvalidContainerName}, | |
| 237 | - | } | |
| 238 | - | ||
| 239 | - | for _, tt := range tests { | |
| 240 | - | t.Run(tt.name, func(t *testing.T) { | |
| 241 | - | err := ValidateContainerName(tt.container) | |
| 242 | - | if tt.wantErr { | |
| 243 | - | require.Error(t, err) | |
| 244 | - | if tt.wantErrType != nil { | |
| 245 | - | assert.ErrorIs(t, err, tt.wantErrType) | |
| 246 | - | } | |
| 247 | - | } else { | |
| 248 | - | assert.NoError(t, err) | |
| 249 | - | } | |
| 250 | - | }) | |
| 251 | - | } | |
| 252 | - | } | |
D internal/domain/envfile.go +0 -47
| @@ -1,47 +0,0 @@ | |||
| 1 | - | package domain | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "encoding/base64" | |
| 5 | - | "regexp" | |
| 6 | - | "strings" | |
| 7 | - | ) | |
| 8 | - | ||
| 9 | - | // envDomainRegex validates domain names used for env-backed secret storage. | |
| 10 | - | // Allows: alphanumeric, dots, hyphens, colons (for ports), and forward slashes (for paths). | |
| 11 | - | var envDomainRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9.:/-]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$`) | |
| 12 | - | ||
| 13 | - | // EnvStorageKey is the domain-level storage identifier used for env-backed secrets. | |
| 14 | - | // It is deterministic, collision-resistant, and safe to use in file names. | |
| 15 | - | type EnvStorageKey string | |
| 16 | - | ||
| 17 | - | // NewEnvStorageKey validates a route domain and returns the storage-safe identifier | |
| 18 | - | // used by env/secrets adapters. The identifier is reversible and unique for the input. | |
| 19 | - | func NewEnvStorageKey(domainName string) (EnvStorageKey, error) { | |
| 20 | - | if err := ValidateEnvStorageDomain(domainName); err != nil { | |
| 21 | - | return "", err | |
| 22 | - | } | |
| 23 | - | ||
| 24 | - | return EnvStorageKey(base64.RawURLEncoding.EncodeToString([]byte(domainName))), nil | |
| 25 | - | } | |
| 26 | - | ||
| 27 | - | // ValidateEnvStorageDomain validates domains used for env-backed secret storage. | |
| 28 | - | func ValidateEnvStorageDomain(domainName string) error { | |
| 29 | - | if domainName == "" { | |
| 30 | - | return ErrPathTraversal | |
| 31 | - | } | |
| 32 | - | ||
| 33 | - | if strings.Contains(domainName, "..") { | |
| 34 | - | return ErrPathTraversal | |
| 35 | - | } | |
| 36 | - | ||
| 37 | - | if !envDomainRegex.MatchString(domainName) { | |
| 38 | - | return ErrPathTraversal | |
| 39 | - | } | |
| 40 | - | ||
| 41 | - | return nil | |
| 42 | - | } | |
| 43 | - | ||
| 44 | - | // FileName returns the on-disk env filename for this storage key. | |
| 45 | - | func (k EnvStorageKey) FileName() string { | |
| 46 | - | return string(k) + ".env" | |
| 47 | - | } | |
M internal/domain/errors.go +0 -1
| @@ -117,7 +117,6 @@ var ( | |||
| 117 | 117 | ErrSecretNotFound = errors.New("secret not found") | |
| 118 | 118 | ErrSecretsAlreadyExist = errors.New("secrets already exist") | |
| 119 | 119 | ErrProviderNotFound = errors.New("secret provider not found") | |
| 120 | - | ErrInvalidContainerName = errors.New("invalid container name") | |
| 121 | 120 | ErrAttachmentOwnershipMismatch = errors.New("attachment ownership mismatch") | |
| 122 | 121 | ErrReadinessLogSizeExceeded = errors.New("readiness log exceeds maximum") | |
| 123 | 122 | ||
M internal/domain/event.go +3 -11
| @@ -9,10 +9,9 @@ import ( | |||
| 9 | 9 | type EventType string | |
| 10 | 10 | ||
| 11 | 11 | 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" | |
| 16 | 15 | ) | |
| 17 | 16 | ||
| 18 | 17 | // Event represents a domain event that occurred in the system. | |
| @@ -43,13 +42,6 @@ type ConfigReloadPayload struct { | |||
| 43 | 42 | UpdatedRoutes []string | |
| 44 | 43 | } | |
| 45 | 44 | ||
| 46 | - | // SecretsChangedPayload contains data for secrets.changed events. | |
| 47 | - | type SecretsChangedPayload struct { | |
| 48 | - | Domain string // Route domain whose secrets changed | |
| 49 | - | Operation string // "set" or "delete" | |
| 50 | - | Keys []string // Secret key names (not values) | |
| 51 | - | } | |
| 52 | - | ||
| 53 | 45 | // Context keys for domain-level concerns. | |
| 54 | 46 | type contextKey string | |
| 55 | 47 | ||
M internal/domain/route.go +1 -1
| @@ -5,7 +5,7 @@ type Route struct { | |||
| 5 | 5 | Domain string | |
| 6 | 6 | Image string | |
| 7 | 7 | HTTPS bool | |
| 8 | - | Env []string // Pre-resolved env vars ("KEY=VALUE"); when set, Deploy skips EnvLoader lookup. | |
| 8 | + | Env []string // Pre-resolved env vars ("KEY=VALUE"). | |
| 9 | 9 | } | |
| 10 | 10 | ||
| 11 | 11 | // ProxyTarget represents the destination for proxying requests. | |
M internal/testutils/fixtures/configs/basic.toml +0 -4
| @@ -18,10 +18,6 @@ auto_create = true | |||
| 18 | 18 | prefix = "gordon" | |
| 19 | 19 | preserve = true | |
| 20 | 20 | ||
| 21 | - | [env] | |
| 22 | - | dir = "/tmp/env" | |
| 23 | - | providers = ["pass", "sops"] | |
| 24 | - | ||
| 25 | 21 | [logging] | |
| 26 | 22 | enabled = true | |
| 27 | 23 | level = "info"⏎̸ | |
M internal/testutils/helpers.go +0 -4
| @@ -59,10 +59,6 @@ auto_create = true | |||
| 59 | 59 | prefix = "gordon" | |
| 60 | 60 | preserve = true | |
| 61 | 61 | ||
| 62 | - | [env] | |
| 63 | - | dir = "/tmp/env" | |
| 64 | - | providers = ["pass", "sops"] | |
| 65 | - | ||
| 66 | 62 | [logging] | |
| 67 | 63 | enabled = true | |
| 68 | 64 | level = "info"` | |
M internal/usecase/container/service.go +0 -3
| @@ -29,7 +29,6 @@ type Config struct { | |||
| 29 | 29 | // Service implements the ContainerService interface. | |
| 30 | 30 | type Service struct { | |
| 31 | 31 | runtime out.ContainerRuntime | |
| 32 | - | envLoader out.EnvLoader | |
| 33 | 32 | eventBus out.EventPublisher | |
| 34 | 33 | logWriter out.ContainerLogWriter | |
| 35 | 34 | config Config | |
| @@ -39,14 +38,12 @@ type Service struct { | |||
| 39 | 38 | // NewService creates a new container service. | |
| 40 | 39 | func NewService( | |
| 41 | 40 | runtime out.ContainerRuntime, | |
| 42 | - | envLoader out.EnvLoader, | |
| 43 | 41 | eventBus out.EventPublisher, | |
| 44 | 42 | logWriter out.ContainerLogWriter, | |
| 45 | 43 | config Config, | |
| 46 | 44 | ) *Service { | |
| 47 | 45 | return &Service{ | |
| 48 | 46 | runtime: runtime, | |
| 49 | - | envLoader: envLoader, | |
| 50 | 47 | eventBus: eventBus, | |
| 51 | 48 | logWriter: logWriter, | |
| 52 | 49 | config: config, | |
M internal/usecase/container/service_test.go +1 -1
| @@ -18,7 +18,7 @@ func testContext() context.Context { | |||
| 18 | 18 | ||
| 19 | 19 | func TestService_ListNetworks(t *testing.T) { | |
| 20 | 20 | runtime := mocks.NewMockContainerRuntime(t) | |
| 21 | - | svc := NewService(runtime, nil, nil, nil, Config{NetworkPrefix: "gordon"}) | |
| 21 | + | svc := NewService(runtime, nil, nil, Config{NetworkPrefix: "gordon"}) | |
| 22 | 22 | ctx := testContext() | |
| 23 | 23 | ||
| 24 | 24 | runtime.EXPECT().ListNetworks(mock.Anything).Return([]*domain.NetworkInfo{ | |
D internal/usecase/secrets/service.go +0 -197
| @@ -1,197 +0,0 @@ | |||
| 1 | - | // Package secrets implements the secret management use case. | |
| 2 | - | package secrets | |
| 3 | - | ||
| 4 | - | import ( | |
| 5 | - | "context" | |
| 6 | - | "errors" | |
| 7 | - | "sort" | |
| 8 | - | "strings" | |
| 9 | - | ||
| 10 | - | "github.com/bnema/zerowrap" | |
| 11 | - | ||
| 12 | - | "github.com/bnema/gordon/internal/boundaries/out" | |
| 13 | - | "github.com/bnema/gordon/internal/domain" | |
| 14 | - | ) | |
| 15 | - | ||
| 16 | - | // Domain validation errors. | |
| 17 | - | var ( | |
| 18 | - | ErrDomainEmpty = errors.New("domain cannot be empty") | |
| 19 | - | ErrDomainTooLong = errors.New("domain exceeds maximum length of 253 characters") | |
| 20 | - | ErrDomainPathTraversal = errors.New("domain contains path traversal sequence") | |
| 21 | - | ErrDomainInvalidChars = errors.New("domain contains invalid characters") | |
| 22 | - | ) | |
| 23 | - | ||
| 24 | - | // Service implements the SecretService interface. | |
| 25 | - | type Service struct { | |
| 26 | - | store out.DomainSecretStore | |
| 27 | - | log zerowrap.Logger | |
| 28 | - | eventBus out.EventPublisher | |
| 29 | - | } | |
| 30 | - | ||
| 31 | - | // NewService creates a new secrets service. | |
| 32 | - | func NewService(store out.DomainSecretStore, log zerowrap.Logger, eventBus out.EventPublisher) *Service { | |
| 33 | - | return &Service{ | |
| 34 | - | store: store, | |
| 35 | - | log: log, | |
| 36 | - | eventBus: eventBus, | |
| 37 | - | } | |
| 38 | - | } | |
| 39 | - | ||
| 40 | - | // publishSecretsChanged publishes a secrets.changed event. | |
| 41 | - | // Failures are logged but do not fail the mutation - secret storage | |
| 42 | - | // is the source of truth; the event is a best-effort notification. | |
| 43 | - | func (s *Service) publishSecretsChanged(ctx context.Context, domainName, operation string, keys []string) { | |
| 44 | - | if s.eventBus == nil { | |
| 45 | - | return | |
| 46 | - | } | |
| 47 | - | log := zerowrap.FromCtx(ctx) | |
| 48 | - | payload := domain.SecretsChangedPayload{ | |
| 49 | - | Domain: domainName, | |
| 50 | - | Operation: operation, | |
| 51 | - | Keys: keys, | |
| 52 | - | } | |
| 53 | - | if err := s.eventBus.Publish(domain.EventSecretsChanged, payload); err != nil { | |
| 54 | - | log.Warn().Err(err).Str("domain", domainName).Msg("failed to publish secrets.changed event") | |
| 55 | - | } | |
| 56 | - | } | |
| 57 | - | ||
| 58 | - | // sortedKeys returns the keys of a map in sorted order. | |
| 59 | - | func sortedKeys(m map[string]string) []string { | |
| 60 | - | keys := make([]string, 0, len(m)) | |
| 61 | - | for k := range m { | |
| 62 | - | keys = append(keys, k) | |
| 63 | - | } | |
| 64 | - | sort.Strings(keys) | |
| 65 | - | return keys | |
| 66 | - | } | |
| 67 | - | ||
| 68 | - | // ListKeys returns the list of secret keys for a domain (not values). | |
| 69 | - | func (s *Service) ListKeys(ctx context.Context, domain string) ([]string, error) { | |
| 70 | - | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 71 | - | zerowrap.FieldLayer: "usecase", | |
| 72 | - | zerowrap.FieldUseCase: "ListKeys", | |
| 73 | - | "domain": domain, | |
| 74 | - | }) | |
| 75 | - | log := zerowrap.FromCtx(ctx) | |
| 76 | - | ||
| 77 | - | if err := ValidateDomain(domain); err != nil { | |
| 78 | - | log.Warn().Err(err).Msg("domain validation failed") | |
| 79 | - | return nil, err | |
| 80 | - | } | |
| 81 | - | ||
| 82 | - | keys, err := s.store.ListKeys(domain) | |
| 83 | - | if err != nil { | |
| 84 | - | log.Error().Err(err).Msg("failed to list secret keys") | |
| 85 | - | return nil, err | |
| 86 | - | } | |
| 87 | - | ||
| 88 | - | log.Debug().Int("count", len(keys)).Msg("listed secret keys") | |
| 89 | - | return keys, nil | |
| 90 | - | } | |
| 91 | - | ||
| 92 | - | // GetAll returns all secrets for a domain as a key-value map. | |
| 93 | - | func (s *Service) GetAll(ctx context.Context, domain string) (map[string]string, error) { | |
| 94 | - | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 95 | - | zerowrap.FieldLayer: "usecase", | |
| 96 | - | zerowrap.FieldUseCase: "GetAll", | |
| 97 | - | "domain": domain, | |
| 98 | - | }) | |
| 99 | - | log := zerowrap.FromCtx(ctx) | |
| 100 | - | ||
| 101 | - | if err := ValidateDomain(domain); err != nil { | |
| 102 | - | log.Warn().Err(err).Msg("domain validation failed") | |
| 103 | - | return nil, err | |
| 104 | - | } | |
| 105 | - | ||
| 106 | - | secrets, err := s.store.GetAll(domain) | |
| 107 | - | if err != nil { | |
| 108 | - | log.Error().Err(err).Msg("failed to get secrets") | |
| 109 | - | return nil, err | |
| 110 | - | } | |
| 111 | - | ||
| 112 | - | log.Debug().Int("count", len(secrets)).Msg("retrieved secrets") | |
| 113 | - | return secrets, nil | |
| 114 | - | } | |
| 115 | - | ||
| 116 | - | // Set sets or updates multiple secrets for a domain, merging with existing. | |
| 117 | - | func (s *Service) Set(ctx context.Context, domain string, secrets map[string]string) error { | |
| 118 | - | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 119 | - | zerowrap.FieldLayer: "usecase", | |
| 120 | - | zerowrap.FieldUseCase: "Set", | |
| 121 | - | "domain": domain, | |
| 122 | - | }) | |
| 123 | - | log := zerowrap.FromCtx(ctx) | |
| 124 | - | ||
| 125 | - | if err := ValidateDomain(domain); err != nil { | |
| 126 | - | log.Warn().Err(err).Msg("domain validation failed") | |
| 127 | - | return err | |
| 128 | - | } | |
| 129 | - | ||
| 130 | - | if err := s.store.Set(domain, secrets); err != nil { | |
| 131 | - | log.Error().Err(err).Msg("failed to set secrets") | |
| 132 | - | return err | |
| 133 | - | } | |
| 134 | - | ||
| 135 | - | s.publishSecretsChanged(ctx, domain, "set", sortedKeys(secrets)) | |
| 136 | - | ||
| 137 | - | log.Info().Int("count", len(secrets)).Msg("secrets set") | |
| 138 | - | return nil | |
| 139 | - | } | |
| 140 | - | ||
| 141 | - | // Delete removes a specific secret key from a domain. | |
| 142 | - | func (s *Service) Delete(ctx context.Context, domain, key string) error { | |
| 143 | - | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 144 | - | zerowrap.FieldLayer: "usecase", | |
| 145 | - | zerowrap.FieldUseCase: "Delete", | |
| 146 | - | "domain": domain, | |
| 147 | - | "key": key, | |
| 148 | - | }) | |
| 149 | - | log := zerowrap.FromCtx(ctx) | |
| 150 | - | ||
| 151 | - | if err := ValidateDomain(domain); err != nil { | |
| 152 | - | log.Warn().Err(err).Msg("domain validation failed") | |
| 153 | - | return err | |
| 154 | - | } | |
| 155 | - | ||
| 156 | - | if err := s.store.Delete(domain, key); err != nil { | |
| 157 | - | log.Error().Err(err).Msg("failed to delete secret") | |
| 158 | - | return err | |
| 159 | - | } | |
| 160 | - | ||
| 161 | - | s.publishSecretsChanged(ctx, domain, "delete", []string{key}) | |
| 162 | - | ||
| 163 | - | log.Info().Msg("secret deleted") | |
| 164 | - | return nil | |
| 165 | - | } | |
| 166 | - | ||
| 167 | - | // ValidateDomain validates that a domain is safe to use for secret storage. | |
| 168 | - | // This prevents path traversal attacks and ensures the domain is valid. | |
| 169 | - | func ValidateDomain(domainName string) error { | |
| 170 | - | // Check for empty domain | |
| 171 | - | if domainName == "" { | |
| 172 | - | return ErrDomainEmpty | |
| 173 | - | } | |
| 174 | - | ||
| 175 | - | // Check domain length (max DNS name length is 253) | |
| 176 | - | if len(domainName) > 253 { | |
| 177 | - | return ErrDomainTooLong | |
| 178 | - | } | |
| 179 | - | ||
| 180 | - | // Check for path traversal attempts | |
| 181 | - | if strings.Contains(domainName, "..") { | |
| 182 | - | return ErrDomainPathTraversal | |
| 183 | - | } | |
| 184 | - | ||
| 185 | - | // Check for null bytes (can cause issues in file paths) | |
| 186 | - | if strings.ContainsRune(domainName, '\x00') { | |
| 187 | - | return ErrDomainInvalidChars | |
| 188 | - | } | |
| 189 | - | ||
| 190 | - | // Reuse the env-file sanitizer so validation matches the on-disk naming | |
| 191 | - | // scheme and rejects ambiguous names that would collide after sanitization. | |
| 192 | - | if _, err := domain.SanitizeDomainForEnvFile(domainName); err != nil { | |
| 193 | - | return ErrDomainInvalidChars | |
| 194 | - | } | |
| 195 | - | ||
| 196 | - | return nil | |
| 197 | - | } | |
D internal/usecase/secrets/service_test.go +0 -435
| @@ -1,435 +0,0 @@ | |||
| 1 | - | package secrets | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "context" | |
| 5 | - | "strings" | |
| 6 | - | "testing" | |
| 7 | - | ||
| 8 | - | "github.com/bnema/zerowrap" | |
| 9 | - | "github.com/stretchr/testify/assert" | |
| 10 | - | "github.com/stretchr/testify/mock" | |
| 11 | - | ||
| 12 | - | outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks" | |
| 13 | - | "github.com/bnema/gordon/internal/domain" | |
| 14 | - | ) | |
| 15 | - | ||
| 16 | - | func testLogger() zerowrap.Logger { | |
| 17 | - | return zerowrap.Default() | |
| 18 | - | } | |
| 19 | - | ||
| 20 | - | func TestValidateDomain(t *testing.T) { | |
| 21 | - | tests := []struct { | |
| 22 | - | name string | |
| 23 | - | domain string | |
| 24 | - | wantErr error | |
| 25 | - | }{ | |
| 26 | - | // Valid domains | |
| 27 | - | { | |
| 28 | - | name: "valid simple domain", | |
| 29 | - | domain: "example.com", | |
| 30 | - | wantErr: nil, | |
| 31 | - | }, | |
| 32 | - | { | |
| 33 | - | name: "valid subdomain", | |
| 34 | - | domain: "app.example.com", | |
| 35 | - | wantErr: nil, | |
| 36 | - | }, | |
| 37 | - | { | |
| 38 | - | name: "valid domain with port", | |
| 39 | - | domain: "app.example.com:8080", | |
| 40 | - | wantErr: nil, | |
| 41 | - | }, | |
| 42 | - | { | |
| 43 | - | name: "valid domain with hyphens", | |
| 44 | - | domain: "my-app.example-site.com", | |
| 45 | - | wantErr: nil, | |
| 46 | - | }, | |
| 47 | - | { | |
| 48 | - | name: "valid single label", | |
| 49 | - | domain: "localhost", | |
| 50 | - | wantErr: nil, | |
| 51 | - | }, | |
| 52 | - | // Empty domain | |
| 53 | - | { | |
| 54 | - | name: "empty domain", | |
| 55 | - | domain: "", | |
| 56 | - | wantErr: ErrDomainEmpty, | |
| 57 | - | }, | |
| 58 | - | // Path traversal attempts | |
| 59 | - | { | |
| 60 | - | name: "path traversal with double dots", | |
| 61 | - | domain: "../etc/passwd", | |
| 62 | - | wantErr: ErrDomainPathTraversal, | |
| 63 | - | }, | |
| 64 | - | { | |
| 65 | - | name: "path traversal in middle", | |
| 66 | - | domain: "app/../etc/passwd", | |
| 67 | - | wantErr: ErrDomainPathTraversal, | |
| 68 | - | }, | |
| 69 | - | { | |
| 70 | - | name: "path traversal at end", | |
| 71 | - | domain: "app.example.com/..", | |
| 72 | - | wantErr: ErrDomainPathTraversal, | |
| 73 | - | }, | |
| 74 | - | { | |
| 75 | - | name: "multiple path traversal", | |
| 76 | - | domain: "..../....//etc/passwd", | |
| 77 | - | wantErr: ErrDomainPathTraversal, | |
| 78 | - | }, | |
| 79 | - | { | |
| 80 | - | name: "encoded path traversal still blocked", | |
| 81 | - | domain: "app..example.com", | |
| 82 | - | wantErr: ErrDomainPathTraversal, | |
| 83 | - | }, | |
| 84 | - | // Domain too long | |
| 85 | - | { | |
| 86 | - | name: "domain at max length (253)", | |
| 87 | - | domain: strings.Repeat("a", 253), | |
| 88 | - | wantErr: nil, | |
| 89 | - | }, | |
| 90 | - | { | |
| 91 | - | name: "domain exceeds max length", | |
| 92 | - | domain: strings.Repeat("a", 254), | |
| 93 | - | wantErr: ErrDomainTooLong, | |
| 94 | - | }, | |
| 95 | - | { | |
| 96 | - | name: "domain way too long", | |
| 97 | - | domain: strings.Repeat("a", 1000), | |
| 98 | - | wantErr: ErrDomainTooLong, | |
| 99 | - | }, | |
| 100 | - | // Invalid characters | |
| 101 | - | { | |
| 102 | - | name: "null byte in domain", | |
| 103 | - | domain: "app\x00.example.com", | |
| 104 | - | wantErr: ErrDomainInvalidChars, | |
| 105 | - | }, | |
| 106 | - | { | |
| 107 | - | name: "null byte at start", | |
| 108 | - | domain: "\x00example.com", | |
| 109 | - | wantErr: ErrDomainInvalidChars, | |
| 110 | - | }, | |
| 111 | - | { | |
| 112 | - | name: "underscore rejected to avoid env filename collision", | |
| 113 | - | domain: "app_example.com", | |
| 114 | - | wantErr: ErrDomainInvalidChars, | |
| 115 | - | }, | |
| 116 | - | } | |
| 117 | - | ||
| 118 | - | for _, tt := range tests { | |
| 119 | - | t.Run(tt.name, func(t *testing.T) { | |
| 120 | - | err := ValidateDomain(tt.domain) | |
| 121 | - | if tt.wantErr != nil { | |
| 122 | - | assert.ErrorIs(t, err, tt.wantErr) | |
| 123 | - | } else { | |
| 124 | - | assert.NoError(t, err) | |
| 125 | - | } | |
| 126 | - | }) | |
| 127 | - | } | |
| 128 | - | } | |
| 129 | - | ||
| 130 | - | func TestValidateDomain_DistinguishesSeparatorsForEnvStorage(t *testing.T) { | |
| 131 | - | for _, domain := range []string{"app.example.com", "app:example:com", "app/example/com"} { | |
| 132 | - | assert.NoError(t, ValidateDomain(domain), "expected %q to remain valid", domain) | |
| 133 | - | } | |
| 134 | - | } | |
| 135 | - | ||
| 136 | - | func TestService_ListKeys_ValidationErrors(t *testing.T) { | |
| 137 | - | store := outmocks.NewMockDomainSecretStore(t) | |
| 138 | - | svc := NewService(store, testLogger(), nil) | |
| 139 | - | ||
| 140 | - | tests := []struct { | |
| 141 | - | name string | |
| 142 | - | domain string | |
| 143 | - | wantErr error | |
| 144 | - | }{ | |
| 145 | - | { | |
| 146 | - | name: "empty domain", | |
| 147 | - | domain: "", | |
| 148 | - | wantErr: ErrDomainEmpty, | |
| 149 | - | }, | |
| 150 | - | { | |
| 151 | - | name: "path traversal", | |
| 152 | - | domain: "../etc/passwd", | |
| 153 | - | wantErr: ErrDomainPathTraversal, | |
| 154 | - | }, | |
| 155 | - | { | |
| 156 | - | name: "domain too long", | |
| 157 | - | domain: strings.Repeat("a", 300), | |
| 158 | - | wantErr: ErrDomainTooLong, | |
| 159 | - | }, | |
| 160 | - | } | |
| 161 | - | ||
| 162 | - | for _, tt := range tests { | |
| 163 | - | t.Run(tt.name, func(t *testing.T) { | |
| 164 | - | keys, err := svc.ListKeys(context.Background(), tt.domain) | |
| 165 | - | assert.Nil(t, keys) | |
| 166 | - | assert.ErrorIs(t, err, tt.wantErr) | |
| 167 | - | }) | |
| 168 | - | } | |
| 169 | - | } | |
| 170 | - | ||
| 171 | - | func TestService_ListKeys_Success(t *testing.T) { | |
| 172 | - | store := outmocks.NewMockDomainSecretStore(t) | |
| 173 | - | svc := NewService(store, testLogger(), nil) | |
| 174 | - | ||
| 175 | - | expectedKeys := []string{"API_KEY", "DB_PASSWORD"} | |
| 176 | - | store.EXPECT().ListKeys("app.example.com").Return(expectedKeys, nil) | |
| 177 | - | ||
| 178 | - | keys, err := svc.ListKeys(context.Background(), "app.example.com") | |
| 179 | - | assert.NoError(t, err) | |
| 180 | - | assert.Equal(t, expectedKeys, keys) | |
| 181 | - | } | |
| 182 | - | ||
| 183 | - | func TestService_GetAll_ValidationErrors(t *testing.T) { | |
| 184 | - | store := outmocks.NewMockDomainSecretStore(t) | |
| 185 | - | svc := NewService(store, testLogger(), nil) | |
| 186 | - | ||
| 187 | - | tests := []struct { | |
| 188 | - | name string | |
| 189 | - | domain string | |
| 190 | - | wantErr error | |
| 191 | - | }{ | |
| 192 | - | { | |
| 193 | - | name: "empty domain", | |
| 194 | - | domain: "", | |
| 195 | - | wantErr: ErrDomainEmpty, | |
| 196 | - | }, | |
| 197 | - | { | |
| 198 | - | name: "path traversal", | |
| 199 | - | domain: "app/../secret", | |
| 200 | - | wantErr: ErrDomainPathTraversal, | |
| 201 | - | }, | |
| 202 | - | } | |
| 203 | - | ||
| 204 | - | for _, tt := range tests { | |
| 205 | - | t.Run(tt.name, func(t *testing.T) { | |
| 206 | - | secrets, err := svc.GetAll(context.Background(), tt.domain) | |
| 207 | - | assert.Nil(t, secrets) | |
| 208 | - | assert.ErrorIs(t, err, tt.wantErr) | |
| 209 | - | }) | |
| 210 | - | } | |
| 211 | - | } | |
| 212 | - | ||
| 213 | - | func TestService_GetAll_Success(t *testing.T) { | |
| 214 | - | store := outmocks.NewMockDomainSecretStore(t) | |
| 215 | - | svc := NewService(store, testLogger(), nil) | |
| 216 | - | ||
| 217 | - | expectedSecrets := map[string]string{"API_KEY": "secret123", "DB_PASSWORD": "pass456"} | |
| 218 | - | store.EXPECT().GetAll("app.example.com").Return(expectedSecrets, nil) | |
| 219 | - | ||
| 220 | - | secrets, err := svc.GetAll(context.Background(), "app.example.com") | |
| 221 | - | assert.NoError(t, err) | |
| 222 | - | assert.Equal(t, expectedSecrets, secrets) | |
| 223 | - | } | |
| 224 | - | ||
| 225 | - | func TestService_Set_ValidationErrors(t *testing.T) { | |
| 226 | - | store := outmocks.NewMockDomainSecretStore(t) | |
| 227 | - | svc := NewService(store, testLogger(), nil) | |
| 228 | - | ||
| 229 | - | secrets := map[string]string{"API_KEY": "secret123"} | |
| 230 | - | ||
| 231 | - | tests := []struct { | |
| 232 | - | name string | |
| 233 | - | domain string | |
| 234 | - | wantErr error | |
| 235 | - | }{ | |
| 236 | - | { | |
| 237 | - | name: "empty domain", | |
| 238 | - | domain: "", | |
| 239 | - | wantErr: ErrDomainEmpty, | |
| 240 | - | }, | |
| 241 | - | { | |
| 242 | - | name: "path traversal", | |
| 243 | - | domain: "../../../etc/passwd", | |
| 244 | - | wantErr: ErrDomainPathTraversal, | |
| 245 | - | }, | |
| 246 | - | } | |
| 247 | - | ||
| 248 | - | for _, tt := range tests { | |
| 249 | - | t.Run(tt.name, func(t *testing.T) { | |
| 250 | - | err := svc.Set(context.Background(), tt.domain, secrets) | |
| 251 | - | assert.ErrorIs(t, err, tt.wantErr) | |
| 252 | - | }) | |
| 253 | - | } | |
| 254 | - | } | |
| 255 | - | ||
| 256 | - | func TestService_Set_Success(t *testing.T) { | |
| 257 | - | store := outmocks.NewMockDomainSecretStore(t) | |
| 258 | - | eventBus := outmocks.NewMockEventPublisher(t) | |
| 259 | - | svc := NewService(store, testLogger(), eventBus) | |
| 260 | - | ||
| 261 | - | secrets := map[string]string{"API_KEY": "secret123"} | |
| 262 | - | store.EXPECT().Set("app.example.com", secrets).Return(nil) | |
| 263 | - | eventBus.EXPECT().Publish(domain.EventSecretsChanged, mock.AnythingOfType("domain.SecretsChangedPayload")).Return(nil) | |
| 264 | - | ||
| 265 | - | err := svc.Set(context.Background(), "app.example.com", secrets) | |
| 266 | - | assert.NoError(t, err) | |
| 267 | - | } | |
| 268 | - | ||
| 269 | - | func TestService_Set_PublishesSecretsChangedEvent(t *testing.T) { | |
| 270 | - | store := outmocks.NewMockDomainSecretStore(t) | |
| 271 | - | eventBus := outmocks.NewMockEventPublisher(t) | |
| 272 | - | svc := NewService(store, testLogger(), eventBus) | |
| 273 | - | ||
| 274 | - | secrets := map[string]string{"API_KEY": "secret123", "DB_PASSWORD": "pass456"} | |
| 275 | - | store.EXPECT().Set("app.example.com", secrets).Return(nil) | |
| 276 | - | eventBus.EXPECT().Publish(domain.EventSecretsChanged, mock.MatchedBy(func(payload domain.SecretsChangedPayload) bool { | |
| 277 | - | assert.Equal(t, "app.example.com", payload.Domain) | |
| 278 | - | assert.Equal(t, "set", payload.Operation) | |
| 279 | - | assert.ElementsMatch(t, []string{"API_KEY", "DB_PASSWORD"}, payload.Keys) | |
| 280 | - | return true | |
| 281 | - | })).Return(nil) | |
| 282 | - | ||
| 283 | - | err := svc.Set(context.Background(), "app.example.com", secrets) | |
| 284 | - | assert.NoError(t, err) | |
| 285 | - | } | |
| 286 | - | ||
| 287 | - | func TestService_Delete_ValidationErrors(t *testing.T) { | |
| 288 | - | store := outmocks.NewMockDomainSecretStore(t) | |
| 289 | - | svc := NewService(store, testLogger(), nil) | |
| 290 | - | ||
| 291 | - | tests := []struct { | |
| 292 | - | name string | |
| 293 | - | domain string | |
| 294 | - | wantErr error | |
| 295 | - | }{ | |
| 296 | - | { | |
| 297 | - | name: "empty domain", | |
| 298 | - | domain: "", | |
| 299 | - | wantErr: ErrDomainEmpty, | |
| 300 | - | }, | |
| 301 | - | { | |
| 302 | - | name: "path traversal", | |
| 303 | - | domain: "..%2F..%2Fetc%2Fpasswd", | |
| 304 | - | wantErr: ErrDomainPathTraversal, | |
| 305 | - | }, | |
| 306 | - | } | |
| 307 | - | ||
| 308 | - | for _, tt := range tests { | |
| 309 | - | t.Run(tt.name, func(t *testing.T) { | |
| 310 | - | err := svc.Delete(context.Background(), tt.domain, "API_KEY") | |
| 311 | - | assert.ErrorIs(t, err, tt.wantErr) | |
| 312 | - | }) | |
| 313 | - | } | |
| 314 | - | } | |
| 315 | - | ||
| 316 | - | func TestService_Delete_Success(t *testing.T) { | |
| 317 | - | store := outmocks.NewMockDomainSecretStore(t) | |
| 318 | - | eventBus := outmocks.NewMockEventPublisher(t) | |
| 319 | - | svc := NewService(store, testLogger(), eventBus) | |
| 320 | - | ||
| 321 | - | store.EXPECT().Delete("app.example.com", "API_KEY").Return(nil) | |
| 322 | - | eventBus.EXPECT().Publish(domain.EventSecretsChanged, mock.AnythingOfType("domain.SecretsChangedPayload")).Return(nil) | |
| 323 | - | ||
| 324 | - | err := svc.Delete(context.Background(), "app.example.com", "API_KEY") | |
| 325 | - | assert.NoError(t, err) | |
| 326 | - | } | |
| 327 | - | ||
| 328 | - | func TestService_Delete_PublishesSecretsChangedEvent(t *testing.T) { | |
| 329 | - | store := outmocks.NewMockDomainSecretStore(t) | |
| 330 | - | eventBus := outmocks.NewMockEventPublisher(t) | |
| 331 | - | svc := NewService(store, testLogger(), eventBus) | |
| 332 | - | ||
| 333 | - | store.EXPECT().Delete("app.example.com", "API_KEY").Return(nil) | |
| 334 | - | eventBus.EXPECT().Publish(domain.EventSecretsChanged, mock.MatchedBy(func(payload domain.SecretsChangedPayload) bool { | |
| 335 | - | assert.Equal(t, "app.example.com", payload.Domain) | |
| 336 | - | assert.Equal(t, "delete", payload.Operation) | |
| 337 | - | assert.Equal(t, []string{"API_KEY"}, payload.Keys) | |
| 338 | - | return true | |
| 339 | - | })).Return(nil) | |
| 340 | - | ||
| 341 | - | err := svc.Delete(context.Background(), "app.example.com", "API_KEY") | |
| 342 | - | assert.NoError(t, err) | |
| 343 | - | } | |
| 344 | - | ||
| 345 | - | func TestService_Set_SucceedsEvenIfEventPublishFails(t *testing.T) { | |
| 346 | - | store := outmocks.NewMockDomainSecretStore(t) | |
| 347 | - | eventBus := outmocks.NewMockEventPublisher(t) | |
| 348 | - | svc := NewService(store, testLogger(), eventBus) | |
| 349 | - | ||
| 350 | - | secrets := map[string]string{"API_KEY": "secret123"} | |
| 351 | - | store.EXPECT().Set("app.example.com", secrets).Return(nil) | |
| 352 | - | eventBus.EXPECT().Publish(domain.EventSecretsChanged, mock.AnythingOfType("domain.SecretsChangedPayload")).Return(assert.AnError) | |
| 353 | - | ||
| 354 | - | err := svc.Set(context.Background(), "app.example.com", secrets) | |
| 355 | - | assert.NoError(t, err) | |
| 356 | - | } | |
| 357 | - | ||
| 358 | - | func TestService_NilEventPublisher_DoesNotPanic(t *testing.T) { | |
| 359 | - | store := outmocks.NewMockDomainSecretStore(t) | |
| 360 | - | svc := NewService(store, testLogger(), nil) | |
| 361 | - | ||
| 362 | - | secrets := map[string]string{"API_KEY": "secret123"} | |
| 363 | - | store.EXPECT().Set("app.example.com", secrets).Return(nil) | |
| 364 | - | ||
| 365 | - | assert.NotPanics(t, func() { | |
| 366 | - | err := svc.Set(context.Background(), "app.example.com", secrets) | |
| 367 | - | assert.NoError(t, err) | |
| 368 | - | }) | |
| 369 | - | } | |
| 370 | - | ||
| 371 | - | func TestService_StoreError_PropagatedCorrectly(t *testing.T) { | |
| 372 | - | store := outmocks.NewMockDomainSecretStore(t) | |
| 373 | - | svc := NewService(store, testLogger(), nil) | |
| 374 | - | ||
| 375 | - | storeErr := assert.AnError | |
| 376 | - | store.EXPECT().ListKeys("app.example.com").Return(nil, storeErr) | |
| 377 | - | ||
| 378 | - | keys, err := svc.ListKeys(context.Background(), "app.example.com") | |
| 379 | - | assert.Nil(t, keys) | |
| 380 | - | assert.ErrorIs(t, err, storeErr) | |
| 381 | - | } | |
| 382 | - | ||
| 383 | - | // TestPathTraversalVariants tests various path traversal attack patterns. | |
| 384 | - | func TestPathTraversalVariants(t *testing.T) { | |
| 385 | - | tests := []struct { | |
| 386 | - | name string | |
| 387 | - | domain string | |
| 388 | - | }{ | |
| 389 | - | {"simple parent dir", ".."}, | |
| 390 | - | {"parent dir prefix", "../secret"}, | |
| 391 | - | {"parent dir suffix", "secret/.."}, | |
| 392 | - | {"parent dir in middle", "app/../secret"}, | |
| 393 | - | {"multiple parent dirs", "../../etc/passwd"}, | |
| 394 | - | {"windows style", "..\\..\\etc\\passwd"}, | |
| 395 | - | {"mixed slashes with dots", "app/./../../etc"}, | |
| 396 | - | {"double dots only", "...."}, | |
| 397 | - | {"triple dots", "..."}, | |
| 398 | - | {"dot dot in subdomain style", "app..evil.com"}, | |
| 399 | - | } | |
| 400 | - | ||
| 401 | - | for _, tt := range tests { | |
| 402 | - | t.Run(tt.name, func(t *testing.T) { | |
| 403 | - | err := ValidateDomain(tt.domain) | |
| 404 | - | // All these should be rejected (either path traversal or other validation) | |
| 405 | - | // The main goal is that ".." patterns are caught | |
| 406 | - | if strings.Contains(tt.domain, "..") { | |
| 407 | - | assert.ErrorIs(t, err, ErrDomainPathTraversal, "domain %q should be rejected for path traversal", tt.domain) | |
| 408 | - | } | |
| 409 | - | }) | |
| 410 | - | } | |
| 411 | - | } | |
| 412 | - | ||
| 413 | - | // TestService_ValidationHappensBeforeStore ensures validation runs before any store calls. | |
| 414 | - | func TestService_ValidationHappensBeforeStore(t *testing.T) { | |
| 415 | - | store := outmocks.NewMockDomainSecretStore(t) | |
| 416 | - | svc := NewService(store, testLogger(), nil) | |
| 417 | - | ||
| 418 | - | // With path traversal domain, store methods should never be called | |
| 419 | - | // We don't set up any expectations, so if store is called, the test will fail | |
| 420 | - | ||
| 421 | - | _, err := svc.ListKeys(context.Background(), "../etc/passwd") | |
| 422 | - | assert.ErrorIs(t, err, ErrDomainPathTraversal) | |
| 423 | - | ||
| 424 | - | _, err = svc.GetAll(context.Background(), "../etc/passwd") | |
| 425 | - | assert.ErrorIs(t, err, ErrDomainPathTraversal) | |
| 426 | - | ||
| 427 | - | err = svc.Set(context.Background(), "../etc/passwd", map[string]string{"key": "value"}) | |
| 428 | - | assert.ErrorIs(t, err, ErrDomainPathTraversal) | |
| 429 | - | ||
| 430 | - | err = svc.Delete(context.Background(), "../etc/passwd", "key") | |
| 431 | - | assert.ErrorIs(t, err, ErrDomainPathTraversal) | |
| 432 | - | ||
| 433 | - | // Verify no store methods were called (mockery would fail if unexpected calls happened) | |
| 434 | - | mock.AssertExpectationsForObjects(t, store) | |
| 435 | - | } | |
M wiki/examples/production.md +7 -23
| @@ -40,10 +40,6 @@ max_age = 90 | |||
| 40 | 40 | # Workload logs are read from the container runtime with | |
| 41 | 41 | # `gordon apps logs APP --service SERVICE`. | |
| 42 | 42 | ||
| 43 | - | # Environment directory | |
| 44 | - | [env] | |
| 45 | - | dir = "~/.gordon/env" | |
| 46 | - | ||
| 47 | 43 | # Volume settings (all enabled by default) | |
| 48 | 44 | [volumes] | |
| 49 | 45 | auto_create = true | |
| @@ -82,26 +78,14 @@ openssl rand -base64 32 | pass insert -m gordon/auth/token_secret | |||
| 82 | 78 | gordon auth token generate --subject ci-bot --scopes push,pull --expiry 0 | |
| 83 | 79 | ``` | |
| 84 | 80 | ||
| 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: | |
| 86 | 84 | ||
| 87 | 85 | ```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 | |
| 105 | 89 | ``` | |
| 106 | 90 | ||
| 107 | 91 | ### 5. Configure Cloudflare | |
| @@ -148,7 +132,7 @@ vim ~/.config/gordon/gordon.toml | |||
| 148 | 132 | # Change: "app.company.com" = "company-app:v2.2.0" | |
| 149 | 133 | ||
| 150 | 134 | # Reload to deploy | |
| 151 | - | gordon reload | |
| 135 | + | gordon daemon reload | |
| 152 | 136 | ``` | |
| 153 | 137 | ||
| 154 | 138 | ## Related | |
M wiki/guides/remote-cli.md +3 -3
| @@ -9,7 +9,7 @@ Store the server URL and credentials with Gordon's remote configuration commands | |||
| 9 | 9 | Verify connectivity: | |
| 10 | 10 | ||
| 11 | 11 | ```bash | |
| 12 | - | gordon status --remote https://gordon.example.com --token "$GORDON_TOKEN" | |
| 12 | + | gordon daemon status --remote https://gordon.example.com --token "$GORDON_TOKEN" | |
| 13 | 13 | ``` | |
| 14 | 14 | ||
| 15 | 15 | ## App management | |
| @@ -46,10 +46,10 @@ gordon backups volume status | |||
| 46 | 46 | ||
| 47 | 47 | ## Logs | |
| 48 | 48 | ||
| 49 | - | `gordon logs` streams daemon process logs. Workload logs use the app command: | |
| 49 | + | `gordon daemon logs` streams daemon process logs. Workload logs use the app command: | |
| 50 | 50 | ||
| 51 | 51 | ```bash | |
| 52 | - | gordon logs --remote https://gordon.example.com --token "$GORDON_TOKEN" | |
| 52 | + | gordon daemon logs --remote https://gordon.example.com --token "$GORDON_TOKEN" | |
| 53 | 53 | gordon apps logs APP --service SERVICE --remote https://gordon.example.com --token "$GORDON_TOKEN" | |
| 54 | 54 | ``` | |
| 55 | 55 | ||
M wiki/guides/secrets-pass.md +6 -6
| @@ -106,17 +106,17 @@ token_secret = "gordon/auth/token_secret" | |||
| 106 | 106 | # password_hash = "gordon/auth/password_hash" | |
| 107 | 107 | ``` | |
| 108 | 108 | ||
| 109 | - | ### Using Route Secrets | |
| 109 | + | ### Using App Secrets | |
| 110 | 110 | ||
| 111 | - | With the pass backend, per-domain secrets are stored in pass, not `.env` files. | |
| 111 | + | Declare secret names in the app file under `[services.<name>.secrets]`, apply it, then set the values. Values are stored in pass under `gordon/apps/<uuid>/<service>/<name>`. | |
| 112 | 112 | ||
| 113 | 113 | ```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 | |
| 117 | 117 | ``` | |
| 118 | 118 | ||
| 119 | - | Gordon migrates existing `.env` files on startup and renames them to `.env.migrated`. | |
| 119 | + | See [App Manifest](/docs/config/apps.md) and [Apps CLI](/docs/cli/apps.md). | |
| 120 | 120 | ||
| 121 | 121 | ## Organizing Secrets | |
| 122 | 122 | ||
M wiki/guides/secrets-sops.md +1 -11
| @@ -208,17 +208,7 @@ The path format is `file:key.path` where: | |||
| 208 | 208 | - `file` is the SOPS-encrypted file path | |
| 209 | 209 | - `key.path` is dot-notation to the value | |
| 210 | 210 | ||
| 211 | - | Route secrets remain in `.env` files with `${sops:...}` references. The SOPS backend is used for auth secrets (like `token_secret`) and provider lookups. | |
| 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)). | |
| 222 | 212 | ||
| 223 | 213 | ## File Organization | |
| 224 | 214 | ||
M wiki/tutorials/first-deploy.md +3 -3
| @@ -87,7 +87,7 @@ On your Gordon server, edit `~/.config/gordon/gordon.toml`: | |||
| 87 | 87 | Reload Gordon: | |
| 88 | 88 | ||
| 89 | 89 | ```bash | |
| 90 | - | gordon reload | |
| 90 | + | gordon daemon reload | |
| 91 | 91 | ``` | |
| 92 | 92 | ||
| 93 | 93 | ### 5. Push to Deploy | |
| @@ -130,7 +130,7 @@ docker login -u deploy -p <token> registry.mydomain.com | |||
| 130 | 130 | ||
| 131 | 131 | 2. Check Gordon logs: | |
| 132 | 132 | ```bash | |
| 133 | - | gordon logs -f | |
| 133 | + | gordon daemon logs -f | |
| 134 | 134 | ``` | |
| 135 | 135 | ||
| 136 | 136 | 3. Ensure DNS points to your server | |
| @@ -159,6 +159,6 @@ Gordon replaces the running container: it withdraws traffic, stops and removes t | |||
| 159 | 159 | ||
| 160 | 160 | ## Next Steps | |
| 161 | 161 | ||
| 162 | - | - [Add environment variables](/docs/config/env.md) | |
| 162 | + | - [Add environment variables](/docs/config/apps.md) | |
| 163 | 163 | - [Add a database](./postgres-service.md) | |
| 164 | 164 | - [Set up CI/CD](/docs/deployment/github-actions.md) | |
M wiki/tutorials/postgres-service.md +1 -1
| @@ -56,7 +56,7 @@ enabled = true | |||
| 56 | 56 | Reload Gordon: | |
| 57 | 57 | ||
| 58 | 58 | ```bash | |
| 59 | - | gordon reload | |
| 59 | + | gordon daemon reload | |
| 60 | 60 | ``` | |
| 61 | 61 | ||
| 62 | 62 | ### 4. Configure Database Password | |