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 | EventSubscriber: | 17 | EventSubscriber: | ||
| 18 | EventHandler: | 18 | EventHandler: | ||
| 19 | SecretProvider: | 19 | SecretProvider: | ||
| 20 | - | EnvLoader: | |||
| 21 | ContainerLogWriter: | 20 | ContainerLogWriter: | ||
| 22 | Metrics: | 21 | Metrics: | ||
| 23 | LogExporter: | 22 | LogExporter: | ||
| @@ -27,7 +26,6 @@ packages: | |||||
| 27 | AppStateReader: | 26 | AppStateReader: | ||
| 28 | ImageResolver: | 27 | ImageResolver: | ||
| 29 | SecretWriter: | 28 | SecretWriter: | ||
| 30 | - | DomainSecretStore: | |||
| 31 | RateLimiter: | 29 | RateLimiter: | ||
| 32 | BackupStorage: | 30 | BackupStorage: | ||
| 33 | HTTPChallengeSink: | 31 | HTTPChallengeSink: | ||
| @@ -52,7 +50,6 @@ packages: | |||||
| 52 | AuthService: | 50 | AuthService: | ||
| 53 | HealthService: | 51 | HealthService: | ||
| 54 | HTTPProber: | 52 | HTTPProber: | ||
| 55 | - | SecretService: | |||
| 56 | LogService: | 53 | LogService: | ||
| 57 | VolumeService: | 54 | VolumeService: | ||
| 58 | PublicTLSService: | 55 | PublicTLSService: | ||
M README.md +9 -8
| @@ -38,7 +38,7 @@ Build locally, push to your Gordon server, declare the app, deploy: | |||||
| 38 | 38 | ||||
| 39 | ```bash | 39 | ```bash | ||
| 40 | # Push the image (OCI transfer only, never deploys) | 40 | # Push the image (OCI transfer only, never deploys) | ||
| 41 | - | gordon push myapp --build --remote prod | 41 | + | gordon images push myapp --build --remote prod |
| 42 | 42 | ||||
| 43 | # Declare the app (blog.toml references the pushed tag) | 43 | # Declare the app (blog.toml references the pushed tag) | ||
| 44 | gordon apps apply --file blog.toml --remote prod | 44 | gordon apps apply --file blog.toml --remote prod | ||
| @@ -47,7 +47,7 @@ gordon apps apply --file blog.toml --remote prod | |||||
| 47 | gordon apps deploy blog --remote prod | 47 | gordon apps deploy blog --remote prod | ||
| 48 | 48 | ||||
| 49 | # Check status | 49 | # Check status | ||
| 50 | - | gordon status | 50 | + | gordon daemon status |
| 51 | gordon apps status blog --remote prod | 51 | gordon apps status blog --remote prod | ||
| 52 | ``` | 52 | ``` | ||
| 53 | 53 | ||||
| @@ -106,8 +106,10 @@ gordon apps deploy blog --remote prod | |||||
| 106 | | Command | Description | | 106 | | Command | Description | | ||
| 107 | |---------|-------------| | 107 | |---------|-------------| | ||
| 108 | | `gordon serve` | Start the Gordon server | | 108 | | `gordon serve` | Start the Gordon server | | ||
| 109 | - | | `gordon status` | Show server and app fleet status | | 109 | + | | `gordon daemon status` | Show server and app fleet status | |
| 110 | - | | `gordon config show` | Display installation configuration | | 110 | + | | `gordon daemon logs` | Show Gordon process logs | |
| 111 | + | | `gordon daemon reload` | Reload installation configuration | | |||
| 112 | + | | `gordon daemon config show` | Display installation configuration | | |||
| 111 | 113 | ||||
| 112 | ### Applications | 114 | ### Applications | ||
| 113 | 115 | ||||
| @@ -123,23 +125,22 @@ gordon apps deploy blog --remote prod | |||||
| 123 | | `gordon apps stop APP` | Stop, preserve all data | | 125 | | `gordon apps stop APP` | Stop, preserve all data | | ||
| 124 | | `gordon apps start APP` | Start a stopped app | | 126 | | `gordon apps start APP` | Start a stopped app | | ||
| 125 | | `gordon apps remove APP` | Remove workloads (volumes/secrets retained) | | 127 | | `gordon apps remove APP` | Remove workloads (volumes/secrets retained) | | ||
| 126 | - | | `gordon push [image]` | Tag and push an image (never deploys) | | |||
| 127 | 128 | ||||
| 128 | ### Images & Registry | 129 | ### Images & Registry | ||
| 129 | 130 | ||||
| 130 | | Command | Description | | 131 | | Command | Description | | ||
| 131 | |---------|-------------| | 132 | |---------|-------------| | ||
| 133 | + | | `gordon images push [image]` | Tag and push an image (never deploys) | | |||
| 132 | | `gordon images list` | List runtime and registry images | | 134 | | `gordon images list` | List runtime and registry images | | ||
| 133 | | `gordon images prune` | Clean up dangling images and old tags | | 135 | | `gordon images prune` | Clean up dangling images and old tags | | ||
| 134 | | `gordon images tags <repo>` | List registry tags for a repository | | 136 | | `gordon images tags <repo>` | List registry tags for a repository | | ||
| 135 | 137 | ||||
| 136 | - | ### Secrets & Config | 138 | + | ### Secrets |
| 137 | 139 | ||||
| 138 | | Command | Description | | 140 | | Command | Description | | ||
| 139 | |---------|-------------| | 141 | |---------|-------------| | ||
| 142 | + | | `gordon apps secrets list APP` | List app secret names | | |||
| 140 | | `gordon apps secrets set APP --service SVC KEY=VAL` | Set app secret values | | 143 | | `gordon apps secrets set APP --service SVC KEY=VAL` | Set app secret values | | ||
| 141 | - | | `gordon secrets list <domain>` | List installation secret keys | | |||
| 142 | - | | `gordon secrets set <domain> --from-file PATH` | Set installation secrets | | |||
| 143 | 144 | ||||
| 144 | ### Remotes & Auth | 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 | ```bash | 365 | ```bash | ||
| 366 | # Push the image first (OCI transfer only) | 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 | # Apply the manifest that references the pushed tag | 369 | # Apply the manifest that references the pushed tag | ||
| 370 | gordon apps apply --file blog.toml --remote prod | 370 | gordon apps apply --file blog.toml --remote prod | ||
| @@ -386,5 +386,5 @@ gordon apps status blog --remote prod | |||||
| 386 | ## Related | 386 | ## Related | ||
| 387 | 387 | ||||
| 388 | - [CLI Overview](./index.md) | 388 | - [CLI Overview](./index.md) | ||
| 389 | - | - [Push Command](./push.md) | 389 | + | - [Images Commands](./images.md#gordon-images-push) |
| 390 | - [Deployment](../deployment/index.md) | 390 | - [Deployment](../deployment/index.md) | ||
M docs/cli/auth.md +1 -3
| @@ -135,7 +135,7 @@ Standard Go durations also work: `24h`, `30m`, `1h30m` | |||||
| 135 | 135 | ||||
| 136 | Registry scopes: `push`, `pull`, `push,pull` | 136 | Registry scopes: `push`, `pull`, `push,pull` | ||
| 137 | 137 | ||||
| 138 | - | Admin scopes: `admin:*:*`, `admin:apps:read`, `admin:apps:write`, `admin:config:read`, `admin:config:write`, `admin:status:read`, `admin:logs:read`, `admin:volumes:read`, `admin:volumes:write`, `admin:secrets:read`, `admin:secrets:write` | 138 | + | Admin scopes: `admin:*:*`, `admin:apps:read`, `admin:apps:write`, `admin:config:read`, `admin:config:write`, `admin:status:read`, `admin:logs:read`, `admin:volumes:read`, `admin:volumes:write` |
| 139 | 139 | ||||
| 140 | Combine scopes with commas: | 140 | Combine scopes with commas: | ||
| 141 | 141 | ||||
| @@ -277,8 +277,6 @@ Usage: | |||||
| 277 | | `admin:config:write` | Config write access | | 277 | | `admin:config:write` | Config write access | | ||
| 278 | | `admin:status:read` | Read-only status/health | | 278 | | `admin:status:read` | Read-only status/health | | ||
| 279 | | `admin:logs:read` | Read-only logs access | | 279 | | `admin:logs:read` | Read-only logs access | | ||
| 280 | - | | `admin:secrets:read` | List secret keys | | |||
| 281 | - | | `admin:secrets:write` | Set/delete secrets | | |||
| 282 | 280 | ||||
| 283 | ## Token Expiry Formats | 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 | # Images Command | 1 | # Images Command | ||
| 2 | 2 | ||||
| 3 | - | List and prune runtime/registry images. | 3 | + | Push, list, and prune runtime/registry images. |
| 4 | 4 | ||||
| 5 | ## gordon images | 5 | ## gordon images | ||
| 6 | 6 | ||||
| @@ -10,12 +10,126 @@ gordon images <subcommand> | |||||
| 10 | 10 | ||||
| 11 | Subcommands: | 11 | Subcommands: | ||
| 12 | 12 | ||||
| 13 | + | - `push` - Tag and push an image to the Gordon registry (never deploys). | |||
| 13 | - `list` - List runtime images and registry tags. | 14 | - `list` - List runtime images and registry tags. | ||
| 14 | - `prune` - Prune dangling runtime images and old registry tags. | 15 | - `prune` - Prune dangling runtime images and old registry tags. | ||
| 15 | - `tags` - List registry tags for a specific repository. | 16 | - `tags` - List registry tags for a specific repository. | ||
| 16 | 17 | ||||
| 17 | > **Note:** Images commands require remote mode (`--remote` + `--token`, or configured remotes). | 18 | > **Note:** Images commands require remote mode (`--remote` + `--token`, or configured remotes). | ||
| 18 | 19 | ||||
| 20 | + | ## gordon images push | |||
| 21 | + | ||||
| 22 | + | Tag and push an image to the Gordon registry. Push transfers OCI content | |||
| 23 | + | only: it never deploys. Deploy separately with `gordon apps deploy` after | |||
| 24 | + | applying the manifest that references the pushed tag. | |||
| 25 | + | ||||
| 26 | + | ### Synopsis | |||
| 27 | + | ||||
| 28 | + | ```bash | |||
| 29 | + | gordon images push [image] [options] | |||
| 30 | + | ``` | |||
| 31 | + | ||||
| 32 | + | ### Arguments | |||
| 33 | + | ||||
| 34 | + | | Argument | Description | | |||
| 35 | + | |----------|-------------| | |||
| 36 | + | | `[image]` | Image name to push (optional). If omitted, auto-detected from Dockerfile labels or current directory name | | |||
| 37 | + | ||||
| 38 | + | ### Options | |||
| 39 | + | ||||
| 40 | + | | Option | Description | | |||
| 41 | + | |--------|-------------| | |||
| 42 | + | | `--build` | Build the image first using `docker buildx` | | |||
| 43 | + | | `-f, --file` | Path to Dockerfile (default: `./Dockerfile`, used with `--build`) | | |||
| 44 | + | | `--platform` | Target platform for buildx (default: `linux/amd64`) | | |||
| 45 | + | | `--build-arg` | Additional build args (repeatable, `KEY=VALUE`) | | |||
| 46 | + | | `--tag` | Override pushed version tag (default: tag ref from CI, then `git describe --tags --dirty`) | | |||
| 47 | + | | `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) | | |||
| 48 | + | | `--token` | Authentication token for remote | | |||
| 49 | + | ||||
| 50 | + | ### Description | |||
| 51 | + | ||||
| 52 | + | `gordon images push` tags the selected image for the Gordon registry and pushes it. | |||
| 53 | + | ||||
| 54 | + | If you do not pass `--remote` and no active remote is configured, Gordon tries to | |||
| 55 | + | infer the correct saved remote by probing your saved remotes for the image. | |||
| 56 | + | It auto-selects only when exactly one remote matches. If multiple remotes match, | |||
| 57 | + | or a saved remote cannot be probed safely, the command stops and asks you to use | |||
| 58 | + | `--remote` explicitly. | |||
| 59 | + | ||||
| 60 | + | Domain-style push targets are retired: push an image name, then | |||
| 61 | + | `gordon apps apply` the manifest that references the pushed tag. | |||
| 62 | + | ||||
| 63 | + | - The version tag defaults to a CI tag ref (like `refs/tags/v1.2.3`) when available, | |||
| 64 | + | then falls back to `git describe --tags --dirty` (for example | |||
| 65 | + | `v1.2.3-4-gabc1234` or `v1.2.3-dirty`). If no tag is found, `latest` is used. | |||
| 66 | + | - When `--build` is set, the command builds with `docker buildx build --load` | |||
| 67 | + | and injects `VERSION`, `GIT_TAG`, `GIT_SHA`, and `BUILD_TIME` into the build | |||
| 68 | + | environment plus any `--build-arg` values. To use these in your Dockerfile, | |||
| 69 | + | declare them with `ARG` (e.g., `ARG VERSION`) then reference via `ENV` or | |||
| 70 | + | in build steps. | |||
| 71 | + | - Use `-f/--file` to build from a Dockerfile outside the current directory root. | |||
| 72 | + | - The version tag and `latest` are both pushed (unless the version is `latest`). | |||
| 73 | + | ||||
| 74 | + | ### Authentication | |||
| 75 | + | ||||
| 76 | + | When used with `--remote`, gordon images push authenticates in two ways: | |||
| 77 | + | ||||
| 78 | + | - **Admin API** (tag listing for inference): uses `--token` or `$GORDON_TOKEN` as Bearer token | |||
| 79 | + | - **Registry push**: automatically exchanges the token for a short-lived (5 min) registry access token via `/auth/token` -- no `docker login` required | |||
| 80 | + | ||||
| 81 | + | This means CI/CD pipelines only need a single secret (`GORDON_TOKEN`). | |||
| 82 | + | ||||
| 83 | + | ### Version Auto-Detection | |||
| 84 | + | ||||
| 85 | + | Gordon reads version tags from CI environment variables (in priority order): | |||
| 86 | + | ||||
| 87 | + | | CI System | Variable | Example | | |||
| 88 | + | |-----------|----------|---------| | |||
| 89 | + | | GitHub Actions | `$GITHUB_REF` | `refs/tags/v1.2.0` | | |||
| 90 | + | | GitHub Actions | `$GITHUB_REF_TYPE` + `$GITHUB_REF_NAME` | `tag` + `v1.2.0` | | |||
| 91 | + | | GitLab CI | `$CI_COMMIT_TAG` | `v1.2.0` | | |||
| 92 | + | | Azure DevOps | `$BUILD_SOURCEBRANCH` | `refs/tags/v1.2.0` | | |||
| 93 | + | | Any | `git describe --tags` | `v1.2.3-4-gabc1234` | | |||
| 94 | + | | Fallback | - | `latest` | | |||
| 95 | + | ||||
| 96 | + | ### Examples | |||
| 97 | + | ||||
| 98 | + | ```bash | |||
| 99 | + | # Build, push (auto-detect image name) | |||
| 100 | + | gordon images push --build --remote https://gordon.example.com | |||
| 101 | + | ||||
| 102 | + | # Push an image | |||
| 103 | + | gordon images push myapp --remote https://gordon.example.com | |||
| 104 | + | ||||
| 105 | + | # Push a fully qualified ref | |||
| 106 | + | gordon images push registry.example.com/myapp:v1.2.3 --build --remote https://gordon.example.com | |||
| 107 | + | ||||
| 108 | + | # Push existing local image with an explicit tag | |||
| 109 | + | gordon images push myapp --tag v1.2.0 --remote https://gordon.example.com | |||
| 110 | + | ||||
| 111 | + | # Build for ARM and pass build args | |||
| 112 | + | gordon images push myapp --build --platform linux/arm64 --build-arg CGO_ENABLED=0 --remote https://gordon.example.com | |||
| 113 | + | ||||
| 114 | + | # Build from a custom Dockerfile path | |||
| 115 | + | gordon images push myapp --build -f docker/app/Dockerfile --remote https://gordon.example.com | |||
| 116 | + | ||||
| 117 | + | # CI/CD usage (single env var, no docker login needed) | |||
| 118 | + | export GORDON_TOKEN="your-token" | |||
| 119 | + | gordon images push myapp --build --remote https://gordon.example.com | |||
| 120 | + | ``` | |||
| 121 | + | ||||
| 122 | + | ### Notes | |||
| 123 | + | ||||
| 124 | + | - Remote mode required. See [CLI Overview](./index.md) for targeting options. | |||
| 125 | + | - `--build` requires Docker with Buildx. Docker Desktop includes it; on Linux, | |||
| 126 | + | install the `docker-buildx-plugin` package. | |||
| 127 | + | - Gordon uses native registry uploads instead of shelling out to `docker push`. | |||
| 128 | + | Image layers are sent in 50MB chunks, which stays under Cloudflare's 100MB | |||
| 129 | + | per-request limit so proxied pushes keep working. Keep the server's | |||
| 130 | + | `max_blob_chunk_size` larger than the client chunk size; the default `95MB` | |||
| 131 | + | works out of the box. | |||
| 132 | + | ||||
| 19 | ## gordon images list | 133 | ## gordon images list | ||
| 20 | 134 | ||||
| 21 | ```bash | 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 | | `gordon apps` | Manage applications, operations, and app secrets | [apps](./apps.md) | | 27 | | `gordon apps` | Manage applications, operations, and app secrets | [apps](./apps.md) | | ||
| 28 | | `gordon backups` | Manage declared app database and volume backups | [backup](./backup.md) | | 28 | | `gordon backups` | Manage declared app database and volume backups | [backup](./backup.md) | | ||
| 29 | - | | `gordon config` | Show and validate server configuration | [config](./config.md) | | 29 | + | | `gordon daemon` | Daemon status, logs, reload, config, TLS, traffic, and networks | [daemon](./daemon.md) | |
| 30 | - | | `gordon images` | List and prune images | [images](./images.md) | | 30 | + | | `gordon images` | Push, list, and prune images | [images](./images.md) | |
| 31 | - | | `gordon logs` | Display Gordon process logs | [serve](./serve.md#gordon-logs) | | |||
| 32 | - | | `gordon networks list` | List Gordon-managed Docker networks | [networks](./networks.md) | | |||
| 33 | - | | `gordon push` | Tag and push an image (never deploys) | [push](./push.md) | | |||
| 34 | - | | `gordon reload` | Reload installation configuration | [serve](./serve.md#gordon-reload) | | |||
| 35 | - | | `gordon secrets` | Manage installation secrets | [secrets](./secrets.md) | | |||
| 36 | - | | `gordon status` | Show Gordon server status | [status](./status.md) | | |||
| 37 | - | | `gordon tls` | Inspect TLS status | [tls](./tls.md) | | |||
| 38 | - | | `gordon traffic` | Inspect traffic plane status | [traffic](./traffic.md) | | |||
| 39 | | `gordon volumes` | Manage volumes | [volumes](./volumes.md) | | 31 | | `gordon volumes` | Manage volumes | [volumes](./volumes.md) | | ||
| 40 | 32 | ||||
| 41 | ## Client Commands | 33 | ## Client Commands | ||
| @@ -54,7 +46,7 @@ gordon serve | |||||
| 54 | gordon serve --config /path/to/config.toml | 46 | gordon serve --config /path/to/config.toml | ||
| 55 | 47 | ||||
| 56 | # Reload installation configuration (never activates app state) | 48 | # Reload installation configuration (never activates app state) | ||
| 57 | - | gordon reload | 49 | + | gordon daemon reload |
| 58 | 50 | ||||
| 59 | # Applications (daemon-owned; fail without a reachable daemon) | 51 | # Applications (daemon-owned; fail without a reachable daemon) | ||
| 60 | gordon apps apply --file blog.toml | 52 | gordon apps apply --file blog.toml | ||
| @@ -70,15 +62,21 @@ gordon apps remove blog | |||||
| 70 | gordon apps operations show blog --key <operation-key> | 62 | gordon apps operations show blog --key <operation-key> | ||
| 71 | gordon apps operations watch blog --key <operation-key> | 63 | gordon apps operations watch blog --key <operation-key> | ||
| 72 | gordon apps secrets list blog | 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 | # Push an image (OCI transfer only; deploy separately) | 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 | # View logs | 76 | # View logs | ||
| 79 | - | gordon logs # Gordon process logs | 77 | + | gordon daemon logs # Gordon process logs |
| 80 | - | gordon logs -f # Follow process logs | 78 | + | gordon daemon logs -f # Follow process logs |
| 81 | - | gordon logs -n 100 # Last 100 process-log lines | 79 | + | gordon daemon logs -n 100 # Last 100 process-log lines |
| 82 | gordon apps logs blog --service web # App service logs | 80 | gordon apps logs blog --service web # App service logs | ||
| 83 | gordon apps logs blog --service web --follow # Follow app service logs | 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 | gordon version | 84 | gordon version | ||
| 87 | 85 | ||||
| 88 | # Traffic plane | 86 | # Traffic plane | ||
| 89 | - | gordon traffic status --remote prod | 87 | + | gordon daemon traffic --remote prod |
| 90 | - | gordon traffic status --remote prod --json | 88 | + | gordon daemon traffic --remote prod --json |
| 91 | 89 | ||||
| 92 | # Backups | 90 | # Backups | ||
| 93 | gordon backups list | 91 | gordon backups list | ||
| @@ -96,6 +94,7 @@ gordon backups volume run shop --service api --volume data | |||||
| 96 | gordon backups status | 94 | gordon backups status | ||
| 97 | 95 | ||||
| 98 | # Images | 96 | # Images | ||
| 97 | + | gordon images push myapp --build --remote prod | |||
| 99 | gordon images list | 98 | gordon images list | ||
| 100 | gordon images prune --dry-run | 99 | gordon images prune --dry-run | ||
| 101 | gordon images prune --keep-releases 3 | 100 | gordon images prune --keep-releases 3 | ||
| @@ -110,11 +109,6 @@ gordon auth token list | |||||
| 110 | gordon auth token revoke <token-id> | 109 | gordon auth token revoke <token-id> | ||
| 111 | gordon auth internal | 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 | # Remotes | 112 | # Remotes | ||
| 119 | gordon remotes add prod https://gordon.mydomain.com --token $TOKEN | 113 | gordon remotes add prod https://gordon.mydomain.com --token $TOKEN | ||
| 120 | gordon remotes list | 114 | gordon remotes list | ||
| @@ -141,14 +135,14 @@ or `GORDON_REMOTE` environment variable. Use `--remote` and `--token` as global | |||||
| 141 | when you want to bypass your saved configuration. | 135 | when you want to bypass your saved configuration. | ||
| 142 | 136 | ||||
| 143 | When no explicit remote is selected and no active remote is configured, Gordon can | 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 | remote automatically when exactly one matches. If multiple remotes match, Gordon stops with | 139 | remote automatically when exactly one matches. If multiple remotes match, Gordon stops with | ||
| 146 | an ambiguity error and asks you to use `--remote`. If any remote probe fails, Gordon also | 140 | an ambiguity error and asks you to use `--remote`. If any remote probe fails, Gordon also | ||
| 147 | stops rather than guessing. | 141 | stops rather than guessing. | ||
| 148 | 142 | ||||
| 149 | ```bash | 143 | ```bash | ||
| 150 | # Auto-inferred single match from saved remotes | 144 | # Auto-inferred single match from saved remotes | ||
| 151 | - | gordon push myapp --build | 145 | + | gordon images push myapp --build |
| 152 | ``` | 146 | ``` | ||
| 153 | 147 | ||||
| 154 | **Important:** The remote URL must be the `gordon_domain` configured on the remote Gordon instance. This is the domain that serves both the container registry and the Admin API. | 148 | **Important:** The remote URL must be the `gordon_domain` configured on the remote Gordon instance. This is the domain that serves both the container registry and the Admin API. | ||
| @@ -159,16 +153,16 @@ or in a specific entry in `~/.config/gordon/remotes.toml`. | |||||
| 159 | 153 | ||||
| 160 | ```bash | 154 | ```bash | ||
| 161 | # Using flags (use the gordon_domain from remote Gordon config) | 155 | # Using flags (use the gordon_domain from remote Gordon config) | ||
| 162 | - | gordon status --remote https://gordon.example.com --token $TOKEN | 156 | + | gordon daemon status --remote https://gordon.example.com --token $TOKEN |
| 163 | 157 | ||||
| 164 | # Against self-signed/private CA endpoint | 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 | # Using environment variables | 161 | # Using environment variables | ||
| 168 | export GORDON_REMOTE=https://gordon.example.com | 162 | export GORDON_REMOTE=https://gordon.example.com | ||
| 169 | export GORDON_TOKEN=$TOKEN | 163 | export GORDON_TOKEN=$TOKEN | ||
| 170 | export GORDON_INSECURE=true | 164 | export GORDON_INSECURE=true | ||
| 171 | - | gordon status | 165 | + | gordon daemon status |
| 172 | ``` | 166 | ``` | ||
| 173 | 167 | ||||
| 174 | ## Exit Codes | 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 | When a remote is active, it's used automatically for most remote-capable commands without needing to specify `--remote` and `--token`. | 165 | When a remote is active, it's used automatically for most remote-capable commands without needing to specify `--remote` and `--token`. | ||
| 166 | 166 | ||||
| 167 | - | If no remote is active and you do not pass `--remote`, Gordon can also auto-infer a saved remote for `gordon push` and `gordon images tags <repository>`. It only auto-selects when exactly one saved remote matches. Ambiguous matches and probe failures require an explicit `--remote`. | 167 | + | If no remote is active and you do not pass `--remote`, Gordon can also auto-infer a saved remote for `gordon images push` and `gordon images tags <repository>`. It only auto-selects when exactly one saved remote matches. Ambiguous matches and probe failures require an explicit `--remote`. |
| 168 | 168 | ||||
| 169 | ```bash | 169 | ```bash | ||
| 170 | gordon remotes use prod | 170 | gordon remotes use prod | ||
| 171 | - | gordon secrets list app.com # Uses prod remote automatically | 171 | + | gordon apps list # Uses prod remote automatically |
| 172 | - | gordon status # Uses prod remote automatically | 172 | + | gordon daemon status # Uses prod remote automatically |
| 173 | ``` | 173 | ``` | ||
| 174 | 174 | ||||
| 175 | ### Example | 175 | ### Example | ||
| @@ -270,7 +270,7 @@ This allows overriding specific values while keeping defaults: | |||||
| 270 | 270 | ||||
| 271 | ```bash | 271 | ```bash | ||
| 272 | # Force one target | 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 | # Work with prod | 288 | # Work with prod | ||
| 289 | gordon remotes use prod | 289 | gordon remotes use prod | ||
| 290 | - | gordon secrets list myapp.example.com | 290 | + | gordon apps list |
| 291 | - | gordon status --remote prod | 291 | + | gordon daemon status |
| 292 | 292 | ||||
| 293 | # Switch to staging | 293 | # Switch to staging | ||
| 294 | gordon remotes use staging | 294 | gordon remotes use staging | ||
| 295 | - | GORDON_REMOTE=staging gordon status | 295 | + | GORDON_REMOTE=staging gordon daemon status |
| 296 | ``` | 296 | ``` | ||
| 297 | 297 | ||||
| 298 | ### CI/CD Pipeline | 298 | ### CI/CD Pipeline | ||
| @@ -306,19 +306,19 @@ env: | |||||
| 306 | steps: | 306 | steps: | ||
| 307 | - name: Push to Gordon | 307 | - name: Push to Gordon | ||
| 308 | run: | | 308 | run: | | ||
| 309 | - | gordon push myapp --build --remote ${{ secrets.GORDON_URL }} | 309 | + | gordon images push myapp --build --remote ${{ secrets.GORDON_URL }} |
| 310 | ``` | 310 | ``` | ||
| 311 | 311 | ||||
| 312 | ### Compare Environments | 312 | ### Compare Environments | ||
| 313 | 313 | ||||
| 314 | ```bash | 314 | ```bash | ||
| 315 | # Compare one target at a time | 315 | # Compare one target at a time | ||
| 316 | - | gordon status --remote https://gordon.example.com --token $PROD_TOKEN | 316 | + | gordon daemon status --remote https://gordon.example.com --token $PROD_TOKEN |
| 317 | - | GORDON_REMOTE=staging gordon status | 317 | + | GORDON_REMOTE=staging gordon daemon status |
| 318 | 318 | ||||
| 319 | # Or switch between active remotes for other commands | 319 | # Or switch between active remotes for other commands | ||
| 320 | - | gordon remotes use prod && gordon secrets list myapp.example.com | 320 | + | gordon remotes use prod && gordon apps list |
| 321 | - | gordon remotes use staging && gordon secrets list myapp.example.com | 321 | + | gordon remotes use staging && gordon apps list |
| 322 | ``` | 322 | ``` | ||
| 323 | 323 | ||||
| 324 | ### Private Admin + Wildcard App Domains | 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 | ## gordon version | 131 | ## gordon version | ||
| 224 | 132 | ||||
| 225 | Print version information. | 133 | Print version information. | ||
D docs/cli/status.md +0 -110
| @@ -1,110 +0,0 @@ | |||||
| 1 | - | # Status Command | |||
| 2 | - | ||||
| 3 | - | Show Gordon server status and the app fleet summary. | |||
| 4 | - | ||||
| 5 | - | ## gordon status | |||
| 6 | - | ||||
| 7 | - | Display installation identity plus one status line per app (from desired/active state, no container inspection). Per-service detail lives under `gordon apps show APP` and `gordon apps status APP`. | |||
| 8 | - | ||||
| 9 | - | ```bash | |||
| 10 | - | gordon status | |||
| 11 | - | gordon status --remote https://gordon.mydomain.com --token $TOKEN | |||
| 12 | - | ``` | |||
| 13 | - | ||||
| 14 | - | `gordon status` works in local mode and remote mode. | |||
| 15 | - | ||||
| 16 | - | - Local mode reads status from in-process services. | |||
| 17 | - | - Remote mode reads status from the target admin API. | |||
| 18 | - | ||||
| 19 | - | ### Output | |||
| 20 | - | ||||
| 21 | - | ``` | |||
| 22 | - | Gordon Status | |||
| 23 | - | ||||
| 24 | - | Gordon Domain: gordon.example.com | |||
| 25 | - | Registry Port: 5000 | |||
| 26 | - | Server Port: 8088 | |||
| 27 | - | Apps: 3 | |||
| 28 | - | Network Isolation: false | |||
| 29 | - | ||||
| 30 | - | Container Status: | |||
| 31 | - | blog: active | |||
| 32 | - | shop: deploying | |||
| 33 | - | old-site: stopped | |||
| 34 | - | ``` | |||
| 35 | - | ||||
| 36 | - | ### Information Displayed | |||
| 37 | - | ||||
| 38 | - | | Field | Description | | |||
| 39 | - | |-------|-------------| | |||
| 40 | - | | Gordon Domain | Public Gordon domain from configuration | | |||
| 41 | - | | Registry Port | Docker registry port | | |||
| 42 | - | | Server Port | Gordon admin port | | |||
| 43 | - | | Routes | Total apps in desired/active state | | |||
| 44 | - | | Network Isolation | Whether installation network policy is enabled | | |||
| 45 | - | | Container Status | Fleet status per app (see states below) | | |||
| 46 | - | ||||
| 47 | - | ### App States | |||
| 48 | - | ||||
| 49 | - | | State | Description | | |||
| 50 | - | |-------|-------------| | |||
| 51 | - | | active | App deployed and converged on desired state | | |||
| 52 | - | | deploying | Desired state diverges from effective state | | |||
| 53 | - | | pending | App applied but never deployed | | |||
| 54 | - | | stopped | Durable stopped intent (stays stopped across reboot) | | |||
| 55 | - | ||||
| 56 | - | ## Flags | |||
| 57 | - | ||||
| 58 | - | The status command uses global flags for remote access: | |||
| 59 | - | ||||
| 60 | - | | Flag | Description | | |||
| 61 | - | |------|-------------| | |||
| 62 | - | | `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) | | |||
| 63 | - | | `--token` | Authentication token | | |||
| 64 | - | ||||
| 65 | - | ## Environment Variables | |||
| 66 | - | ||||
| 67 | - | | Variable | Description | | |||
| 68 | - | |----------|-------------| | |||
| 69 | - | | `GORDON_REMOTE` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) | | |||
| 70 | - | | `GORDON_TOKEN` | Authentication token | | |||
| 71 | - | ||||
| 72 | - | ## Examples | |||
| 73 | - | ||||
| 74 | - | ### Check Local or Remote Status | |||
| 75 | - | ||||
| 76 | - | ```bash | |||
| 77 | - | # Local | |||
| 78 | - | gordon status | |||
| 79 | - | ||||
| 80 | - | # Using flags | |||
| 81 | - | gordon status --remote https://gordon.mydomain.com --token $TOKEN | |||
| 82 | - | ||||
| 83 | - | # Using environment variables | |||
| 84 | - | export GORDON_REMOTE=https://gordon.mydomain.com | |||
| 85 | - | export GORDON_TOKEN=your-token | |||
| 86 | - | gordon status | |||
| 87 | - | ``` | |||
| 88 | - | ||||
| 89 | - | ### Quick Fleet Check | |||
| 90 | - | ||||
| 91 | - | ```bash | |||
| 92 | - | # Check for non-converged apps | |||
| 93 | - | gordon status --remote https://gordon.mydomain.com --token $TOKEN | grep -E "(deploying|pending|stopped)" | |||
| 94 | - | ``` | |||
| 95 | - | ||||
| 96 | - | ## Required Permissions (Remote Only) | |||
| 97 | - | ||||
| 98 | - | Remote status calls require `admin:status:read` scope in the authentication token. | |||
| 99 | - | ||||
| 100 | - | ```bash | |||
| 101 | - | # Generate token with required scope | |||
| 102 | - | gordon auth token generate --subject admin --scopes admin:status:read | |||
| 103 | - | ``` | |||
| 104 | - | ||||
| 105 | - | ## Related | |||
| 106 | - | ||||
| 107 | - | - [Serve Command](./serve.md) | |||
| 108 | - | - [Apps Commands](./apps.md) | |||
| 109 | - | - [CLI Overview](./index.md) | |||
| 110 | - | - [Remote CLI Management](/wiki/guides/remote-cli.md) | |||
D docs/cli/tls.md +0 -113
| @@ -1,113 +0,0 @@ | |||||
| 1 | - | # TLS Commands | |||
| 2 | - | ||||
| 3 | - | Inspect public TLS/ACME certificate status. | |||
| 4 | - | ||||
| 5 | - | Gordon serves normal HTTPS fallback on TLS-capable entrypoints such as `entrypoints.edge` with `protocol = "smart_tcp"`. Certificate priority is static certificates first, then public ACME certificates, then Gordon's internal CA. | |||
| 6 | - | ||||
| 7 | - | ACME challenge notes: | |||
| 8 | - | ||||
| 9 | - | - DNS-01 (`cloudflare-dns-01`) does not require a special external port 80 edge. | |||
| 10 | - | - HTTP-01 requires an HTTP-capable smart TCP entrypoint reachable on external port 80 for every hostname being validated. | |||
| 11 | - | - TLS-ALPN-01 is not supported. | |||
| 12 | - | ||||
| 13 | - | Remote targeting uses client config or an active remote by default. | |||
| 14 | - | Use `--remote` and `--token` to override. See [CLI Overview](./index.md). | |||
| 15 | - | ||||
| 16 | - | ## gordon tls | |||
| 17 | - | ||||
| 18 | - | ### Subcommands | |||
| 19 | - | ||||
| 20 | - | | Subcommand | Description | | |||
| 21 | - | |------------|-------------| | |||
| 22 | - | | `status` | Show public TLS certificate status | | |||
| 23 | - | ||||
| 24 | - | --- | |||
| 25 | - | ||||
| 26 | - | ## gordon tls status | |||
| 27 | - | ||||
| 28 | - | Display the current public TLS/ACME certificate status, including ACME mode, | |||
| 29 | - | certificate details, route coverage, and any errors. | |||
| 30 | - | ||||
| 31 | - | ```bash | |||
| 32 | - | gordon tls status | |||
| 33 | - | gordon tls status --json | |||
| 34 | - | gordon tls status --remote https://gordon.mydomain.com --token $TOKEN | |||
| 35 | - | ``` | |||
| 36 | - | ||||
| 37 | - | ### Flags | |||
| 38 | - | ||||
| 39 | - | | Flag | Description | | |||
| 40 | - | |------|-------------| | |||
| 41 | - | | `--json` | Output as JSON | | |||
| 42 | - | ||||
| 43 | - | ### Human Output | |||
| 44 | - | ||||
| 45 | - | ```text | |||
| 46 | - | Public TLS / ACME Status | |||
| 47 | - | ||||
| 48 | - | ACME: enabled | |||
| 49 | - | Configured Mode: auto | |||
| 50 | - | Effective Mode: http-01 | |||
| 51 | - | Reason: configured | |||
| 52 | - | Token Source: env | |||
| 53 | - | ||||
| 54 | - | Certificates | |||
| 55 | - | ID: cert-abc123 | |||
| 56 | - | Names: example.com, www.example.com | |||
| 57 | - | Status: valid | |||
| 58 | - | Not After: 2026-05-29 12:00:00 | |||
| 59 | - | ||||
| 60 | - | Route Coverage | |||
| 61 | - | example.com covered=yes covered_by=cert-abc123 | |||
| 62 | - | internal.local covered=no error=self-signed cert | |||
| 63 | - | ||||
| 64 | - | Errors | |||
| 65 | - | route internal.local has no ACME cert | |||
| 66 | - | ``` | |||
| 67 | - | ||||
| 68 | - | ### JSON Output | |||
| 69 | - | ||||
| 70 | - | ```json | |||
| 71 | - | { | |||
| 72 | - | "acme_enabled": true, | |||
| 73 | - | "configured_mode": "auto", | |||
| 74 | - | "effective_mode": "http-01", | |||
| 75 | - | "selection_reason": "configured", | |||
| 76 | - | "token_source": "env", | |||
| 77 | - | "certificates": [ | |||
| 78 | - | { | |||
| 79 | - | "id": "cert-abc123", | |||
| 80 | - | "names": ["example.com", "www.example.com"], | |||
| 81 | - | "challenge": "http-01", | |||
| 82 | - | "status": "valid", | |||
| 83 | - | "not_after": "2026-05-29T12:00:00Z", | |||
| 84 | - | "renewal_pending": false | |||
| 85 | - | } | |||
| 86 | - | ], | |||
| 87 | - | "routes": [ | |||
| 88 | - | { | |||
| 89 | - | "domain": "example.com", | |||
| 90 | - | "covered": true, | |||
| 91 | - | "covered_by": "cert-abc123", | |||
| 92 | - | "required_acme": true | |||
| 93 | - | }, | |||
| 94 | - | { | |||
| 95 | - | "domain": "internal.local", | |||
| 96 | - | "covered": false, | |||
| 97 | - | "required_acme": false, | |||
| 98 | - | "error": "self-signed cert" | |||
| 99 | - | } | |||
| 100 | - | ], | |||
| 101 | - | "errors": ["route internal.local has no ACME cert"] | |||
| 102 | - | } | |||
| 103 | - | ``` | |||
| 104 | - | ||||
| 105 | - | ### Token Source | |||
| 106 | - | ||||
| 107 | - | The `token_source` field indicates where the ACME token was sourced from | |||
| 108 | - | (e.g., `env`, `file`, `config`). The token value is never displayed. | |||
| 109 | - | ||||
| 110 | - | ## Related | |||
| 111 | - | ||||
| 112 | - | - [CLI Overview](./index.md) | |||
| 113 | - | - [Status Command](./status.md) | |||
D docs/cli/traffic.md +0 -93
| @@ -1,93 +0,0 @@ | |||||
| 1 | - | # gordon traffic | |||
| 2 | - | ||||
| 3 | - | Inspect Gordon traffic plane status. | |||
| 4 | - | ||||
| 5 | - | ## Subcommands | |||
| 6 | - | ||||
| 7 | - | | Command | Description | | |||
| 8 | - | |---------|-------------| | |||
| 9 | - | | `gordon traffic status` | Show traffic entrypoints, counters, and reload status | | |||
| 10 | - | ||||
| 11 | - | ## Usage | |||
| 12 | - | ||||
| 13 | - | ```bash | |||
| 14 | - | gordon traffic status --remote prod | |||
| 15 | - | gordon traffic status --remote https://gordon.example.com --json | |||
| 16 | - | ``` | |||
| 17 | - | ||||
| 18 | - | Remote mode queries the running Gordon admin API. Local mode does not synthesize runtime traffic state from a fresh config load; use a configured remote target for authoritative counters and reload status. | |||
| 19 | - | ||||
| 20 | - | ## Flags | |||
| 21 | - | ||||
| 22 | - | | Flag | Description | | |||
| 23 | - | |------|-------------| | |||
| 24 | - | | `--json` | Output machine-readable JSON | | |||
| 25 | - | | `--remote`, `-r` | Remote Gordon instance or saved remote name | | |||
| 26 | - | ||||
| 27 | - | ## JSON Output | |||
| 28 | - | ||||
| 29 | - | ```json | |||
| 30 | - | { | |||
| 31 | - | "last_reload_status": "ok", | |||
| 32 | - | "entrypoints": [ | |||
| 33 | - | { | |||
| 34 | - | "name": "postgres", | |||
| 35 | - | "address": "0.0.0.0:5432", | |||
| 36 | - | "protocol": "tcp", | |||
| 37 | - | "active": true, | |||
| 38 | - | "active_tcp_connections": 1, | |||
| 39 | - | "active_udp_sessions": 0, | |||
| 40 | - | "total_accepted": 12, | |||
| 41 | - | "total_refused": 0, | |||
| 42 | - | "total_errors": 0, | |||
| 43 | - | "bytes_in": 4096, | |||
| 44 | - | "bytes_out": 8192, | |||
| 45 | - | "smart_tcp": { | |||
| 46 | - | "http_accepted": 0, | |||
| 47 | - | "h2c_accepted": 0, | |||
| 48 | - | "https_fallback_accepted": 0, | |||
| 49 | - | "tls_passthrough_accepted": 0, | |||
| 50 | - | "raw_fallback_accepted": 0, | |||
| 51 | - | "entrypoint_cidr_refused": 0, | |||
| 52 | - | "raw_fallback_cidr_refused": 0, | |||
| 53 | - | "proxy_refused": 0, | |||
| 54 | - | "unknown_no_fallback_refused": 0, | |||
| 55 | - | "malformed_rejected": 0, | |||
| 56 | - | "sniff_timeout": 0, | |||
| 57 | - | "client_hello_too_large": 0 | |||
| 58 | - | } | |||
| 59 | - | } | |||
| 60 | - | ], | |||
| 61 | - | "routers": [], | |||
| 62 | - | "services": [], | |||
| 63 | - | "counters": { | |||
| 64 | - | "active_tcp_connections": 1, | |||
| 65 | - | "active_udp_sessions": 0, | |||
| 66 | - | "total_accepted": 12, | |||
| 67 | - | "total_refused": 0, | |||
| 68 | - | "total_errors": 0, | |||
| 69 | - | "bytes_in": 4096, | |||
| 70 | - | "bytes_out": 8192, | |||
| 71 | - | "smart_tcp": { | |||
| 72 | - | "http_accepted": 0, | |||
| 73 | - | "h2c_accepted": 0, | |||
| 74 | - | "https_fallback_accepted": 0, | |||
| 75 | - | "tls_passthrough_accepted": 0, | |||
| 76 | - | "raw_fallback_accepted": 0, | |||
| 77 | - | "entrypoint_cidr_refused": 0, | |||
| 78 | - | "raw_fallback_cidr_refused": 0, | |||
| 79 | - | "proxy_refused": 0, | |||
| 80 | - | "unknown_no_fallback_refused": 0, | |||
| 81 | - | "malformed_rejected": 0, | |||
| 82 | - | "sniff_timeout": 0, | |||
| 83 | - | "client_hello_too_large": 0 | |||
| 84 | - | } | |||
| 85 | - | } | |||
| 86 | - | } | |||
| 87 | - | ``` | |||
| 88 | - | ||||
| 89 | - | ## Related | |||
| 90 | - | ||||
| 91 | - | - [Traffic configuration](../config/traffic.md) | |||
| 92 | - | - [Standalone services](../config/services.md) | |||
| 93 | - | - [Server status](./status.md) | |||
M docs/concepts.md +2 -2
| @@ -164,10 +164,10 @@ Secret values stay in pass under `gordon/apps/<uuid>/<service>/<name>`, keyed by | |||||
| 164 | Gordon watches `gordon.toml` and reloads installation-only settings (entrypoints, TLS, limits, external routes). Reload never activates pending app desired state, never re-resolves image tags, and never starts app workloads. | 164 | Gordon watches `gordon.toml` and reloads installation-only settings (entrypoints, TLS, limits, external routes). Reload never activates pending app desired state, never re-resolves image tags, and never starts app workloads. | ||
| 165 | 165 | ||||
| 166 | ```bash | 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 | ## Event System | 172 | ## Event System | ||
| 173 | 173 | ||||
M docs/config/auth.md +0 -2
| @@ -95,8 +95,6 @@ Admin scopes (for remote CLI): | |||||
| 95 | | `admin:logs:read` | Read-only logs access | | 95 | | `admin:logs:read` | Read-only logs access | | ||
| 96 | | `admin:volumes:read` | List Docker volumes | | 96 | | `admin:volumes:read` | List Docker volumes | | ||
| 97 | | `admin:volumes:write` | Prune eligible Gordon-managed volumes | | 97 | | `admin:volumes:write` | Prune eligible Gordon-managed volumes | | ||
| 98 | - | | `admin:secrets:read` | List secret keys | | |||
| 99 | - | | `admin:secrets:write` | Set/delete secrets | | |||
| 100 | 98 | ||||
| 101 | Examples: | 99 | Examples: | ||
| 102 | 100 | ||||
D docs/config/env.md +0 -64
| @@ -1,64 +0,0 @@ | |||||
| 1 | - | # Environment Variables | |||
| 2 | - | ||||
| 3 | - | App environment comes from two places: public `[env]` in the app file (injected into all services at deploy) and per-service `[services.<name>.secrets]` (values in pass, resolved at deploy). This page covers the installation `[env]` store location and the provider syntax used in env files. | |||
| 4 | - | ||||
| 5 | - | ## Configuration | |||
| 6 | - | ||||
| 7 | - | ```toml | |||
| 8 | - | [env] | |||
| 9 | - | dir = "~/.gordon/env" # Default location | |||
| 10 | - | ``` | |||
| 11 | - | ||||
| 12 | - | ## Options | |||
| 13 | - | ||||
| 14 | - | | Option | Type | Default | Description | | |||
| 15 | - | |--------|------|---------|-------------| | |||
| 16 | - | | `dir` | string | `~/.gordon/env` | Directory for the installation secret store | | |||
| 17 | - | ||||
| 18 | - | ## How It Works | |||
| 19 | - | ||||
| 20 | - | The `[env]` directory backs the installation secret store: | |||
| 21 | - | ||||
| 22 | - | - With the `pass` backend, `gordon secrets set <domain> --from-file` stores per-domain secrets in pass under `gordon/env/<sanitized-domain>/<KEY>` (dots/colons/slashes → underscores). Env files are not used for secrets. | |||
| 23 | - | - With the `pass` backend, Gordon imports eligible plaintext `.env` files into pass at startup and removes each source file only after every entry is stored successfully. If a destination entry already exists or an import fails, Gordon leaves the source file in place for operator review. | |||
| 24 | - | - With the `sops` or `unsafe` backend, env files remain the source of truth; use `${sops:...}` syntax for encrypted values when `secrets_backend = "sops"`. | |||
| 25 | - | ||||
| 26 | - | App containers do NOT read these files: services receive the app file's `[env]` plus their resolved `[services.<name>.secrets]` at deploy time (see [App Manifest](./apps.md)). | |||
| 27 | - | ||||
| 28 | - | ## Secret Provider Syntax | |||
| 29 | - | ||||
| 30 | - | Reference secrets from configured backends in env files: | |||
| 31 | - | ||||
| 32 | - | ### Pass (Unix Password Manager) | |||
| 33 | - | ||||
| 34 | - | ```bash | |||
| 35 | - | DATABASE_PASSWORD=${pass:myapp/database/password} | |||
| 36 | - | API_SECRET=${pass:company/api-secret} | |||
| 37 | - | JWT_KEY=${pass:production/jwt-signing-key} | |||
| 38 | - | ``` | |||
| 39 | - | ||||
| 40 | - | ### SOPS (Encrypted Files) | |||
| 41 | - | ||||
| 42 | - | ```bash | |||
| 43 | - | DATABASE_PASSWORD=${sops:secrets.yaml:database.password} | |||
| 44 | - | API_SECRET=${sops:production.yaml:api.secret} | |||
| 45 | - | STRIPE_KEY=${sops:secrets.yaml:stripe.api_key} | |||
| 46 | - | ``` | |||
| 47 | - | ||||
| 48 | - | ### Syntax Reference | |||
| 49 | - | ||||
| 50 | - | | Provider | Syntax | Example | | |||
| 51 | - | |----------|--------|---------| | |||
| 52 | - | | pass | `${pass:<path>}` | `${pass:myapp/db-password}` | | |||
| 53 | - | | sops | `${sops:<file>:<key.path>}` | `${sops:secrets.yaml:db.password}` | | |||
| 54 | - | ||||
| 55 | - | ## File Permissions | |||
| 56 | - | ||||
| 57 | - | The env directory uses secure permissions: | |||
| 58 | - | - Directory: `0700` (owner only) | |||
| 59 | - | - Files: `0600` (owner only) | |||
| 60 | - | ||||
| 61 | - | ## Related | |||
| 62 | - | ||||
| 63 | - | - [Secrets Configuration](./secrets.md) | |||
| 64 | - | - [App Manifest](./apps.md) | |||
M docs/config/index.md +2 -7
| @@ -89,17 +89,13 @@ metrics = true # Export metrics | |||||
| 89 | logs = true # Export Gordon, access, and app logs | 89 | logs = true # Export Gordon, access, and app logs | ||
| 90 | trace_sample_rate = 1.0 # 0.0 = none, 1.0 = all | 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 | # Volume settings | 92 | # Volume settings | ||
| 97 | [volumes] | 93 | [volumes] | ||
| 98 | auto_create = true # Auto-create from Dockerfile VOLUME | 94 | auto_create = true # Auto-create from Dockerfile VOLUME | ||
| 99 | prefix = "gordon" # Volume name prefix | 95 | prefix = "gordon" # Volume name prefix | ||
| 100 | preserve = true # Keep volumes on container removal | 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 | [network_isolation] | 99 | [network_isolation] | ||
| 104 | enabled = true # Gordon-managed network policy | 100 | enabled = true # Gordon-managed network policy | ||
| 105 | network_prefix = "gordon" # Network name prefix | 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 | | `[api.rate_limit]` | Rate limiting configuration | [Rate Limiting](./rate-limiting.md) | | 139 | | `[api.rate_limit]` | Rate limiting configuration | [Rate Limiting](./rate-limiting.md) | | ||
| 144 | | `[logging]` | Logging configuration | [Logging](./logging.md) | | 140 | | `[logging]` | Logging configuration | [Logging](./logging.md) | | ||
| 145 | | `[telemetry]` | OpenTelemetry observability export | [Telemetry](./telemetry.md) | | 141 | | `[telemetry]` | OpenTelemetry observability export | [Telemetry](./telemetry.md) | | ||
| 146 | - | | `[env]` | Installation secret store location | [Environment](./env.md) | | |||
| 147 | | `[volumes]` | Volume management | [Volumes](./volumes.md) | | 142 | | `[volumes]` | Volume management | [Volumes](./volumes.md) | | ||
| 148 | | `[network_isolation]` | Installation network policy | [Network Isolation](./network-isolation.md) | | 143 | | `[network_isolation]` | Installation network policy | [Network Isolation](./network-isolation.md) | | ||
| 149 | | `[external_routes]` | Non-containerized service proxying | [External Routes](./external-routes.md) | | 144 | | `[external_routes]` | Non-containerized service proxying | [External Routes](./external-routes.md) | | ||
| @@ -203,7 +198,7 @@ When `auth.enabled=false`, Gordon runs in local-only mode: `/admin/*` is not reg | |||||
| 203 | Gordon watches the configuration file and reloads automatically when changes are detected. You can also trigger a manual reload: | 198 | Gordon watches the configuration file and reloads automatically when changes are detected. You can also trigger a manual reload: | ||
| 204 | 199 | ||||
| 205 | ```bash | 200 | ```bash | ||
| 206 | - | gordon reload | 201 | + | gordon daemon reload |
| 207 | ``` | 202 | ``` | ||
| 208 | 203 | ||||
| 209 | ### Hot-reloaded (no restart needed) | 204 | ### Hot-reloaded (no restart needed) | ||
M docs/config/logging.md +3 -3
| @@ -43,7 +43,7 @@ syslog_identifier = "gordon-access" | |||||
| 43 | | `file.max_backups` | int | `3` | Number of old files to keep | | 43 | | `file.max_backups` | int | `3` | Number of old files to keep | | ||
| 44 | | `file.max_age` | int | `28` | Days to keep old files | | 44 | | `file.max_age` | int | `28` | Days to keep old files | | ||
| 45 | 45 | ||||
| 46 | - | The Admin API and `gordon logs` read from the process log file. Keep `logging.file.enabled` set to `true` if you need process log streaming. | 46 | + | The Admin API and `gordon daemon logs` read from the process log file. Keep `logging.file.enabled` set to `true` if you need process log streaming. |
| 47 | 47 | ||||
| 48 | ### Workload Logs | 48 | ### Workload Logs | ||
| 49 | 49 | ||||
| @@ -142,8 +142,8 @@ enabled = false | |||||
| 142 | ### Gordon Process Logs | 142 | ### Gordon Process Logs | ||
| 143 | 143 | ||||
| 144 | ```bash | 144 | ```bash | ||
| 145 | - | gordon logs -f | 145 | + | gordon daemon logs -f |
| 146 | - | gordon logs -n 100 | 146 | + | gordon daemon logs -n 100 |
| 147 | tail -f ~/.gordon/logs/gordon.log | 147 | tail -f ~/.gordon/logs/gordon.log | ||
| 148 | journalctl --user -u gordon -f | 148 | journalctl --user -u gordon -f | ||
| 149 | ``` | 149 | ``` | ||
M docs/config/network-isolation.md +2 -2
| @@ -16,7 +16,7 @@ internal = false | |||||
| 16 | | Option | Type | Default | Description | | 16 | | Option | Type | Default | Description | | ||
| 17 | |--------|------|---------|-------------| | 17 | |--------|------|---------|-------------| | ||
| 18 | | `enabled` | bool | `true` | Enable Gordon-managed network policy | | 18 | | `enabled` | bool | `true` | Enable Gordon-managed network policy | | ||
| 19 | - | | `network_prefix` | string | `"gordon"` | Prefix filter for `gordon networks list` | | 19 | + | | `network_prefix` | string | `"gordon"` | Prefix filter for `gordon daemon networks` | |
| 20 | | `internal` | bool | `false` | Create isolated Docker networks with Docker's `Internal` flag, blocking direct external egress from containers on those networks. | | 20 | | `internal` | bool | `false` | Create isolated Docker networks with Docker's `Internal` flag, blocking direct external egress from containers on those networks. | | ||
| 21 | 21 | ||||
| 22 | Per-app isolation is declared in app files, not here: each app gets a private network automatically, and services can join named shared networks with `[[network.shared]]` (see [App Manifest](./apps.md)). Deploy adds AND removes memberships without disconnecting unrelated services. Shared networks are created/reused only within verified Gordon ownership. | 22 | Per-app isolation is declared in app files, not here: each app gets a private network automatically, and services can join named shared networks with `[[network.shared]]` (see [App Manifest](./apps.md)). Deploy adds AND removes memberships without disconnecting unrelated services. Shared networks are created/reused only within verified Gordon ownership. | ||
| @@ -34,7 +34,7 @@ Readiness helpers never join shared networks and exist only on the target app's | |||||
| 34 | View Gordon-managed networks: | 34 | View Gordon-managed networks: | ||
| 35 | 35 | ||||
| 36 | ```bash | 36 | ```bash | ||
| 37 | - | gordon networks list | 37 | + | gordon daemon networks |
| 38 | docker network ls | grep gordon | 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 | logs = true # Export Gordon, access, and app logs | 110 | logs = true # Export Gordon, access, and app logs | ||
| 111 | trace_sample_rate = 1.0 # Fraction of traces to sample (0.0–1.0) | 111 | trace_sample_rate = 1.0 # Fraction of traces to sample (0.0–1.0) | ||
| 112 | 112 | ||||
| 113 | - | # ============================================================================= | |||
| 114 | - | # ENVIRONMENT | |||
| 115 | - | # ============================================================================= | |||
| 116 | - | [env] | |||
| 117 | - | dir = "" # Env files directory (default: {data_dir}/env) | |||
| 118 | - | ||||
| 119 | # ============================================================================= | 113 | # ============================================================================= | ||
| 120 | # CONTAINERS | 114 | # CONTAINERS | ||
| 121 | # ============================================================================= | 115 | # ============================================================================= | ||
| @@ -127,7 +121,7 @@ security_profile = "compat" # "compat" or "strict" | |||||
| 127 | # ============================================================================= | 121 | # ============================================================================= | ||
| 128 | [network_isolation] | 122 | [network_isolation] | ||
| 129 | enabled = true # Installation network policy for Gordon-managed networks | 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 | internal = false # Create Docker internal networks (blocks direct egress) | 125 | internal = false # Create Docker internal networks (blocks direct egress) | ||
| 132 | 126 | ||||
| 133 | # ============================================================================= | 127 | # ============================================================================= | ||
M docs/config/secrets.md +1 -31
| @@ -1,6 +1,6 @@ | |||||
| 1 | # Secrets Configuration | 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 | ## Configuration | 5 | ## Configuration | ||
| 6 | 6 | ||||
| @@ -52,10 +52,6 @@ token_secret = "gordon/auth/token_secret" # Path in pass store | |||||
| 52 | - Standard Unix tooling | 52 | - Standard Unix tooling | ||
| 53 | - Works with team GPG keys | 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 | ### SOPS | 55 | ### SOPS | ||
| 60 | 56 | ||||
| 61 | Uses Mozilla SOPS for encrypted file-based secrets: | 57 | Uses Mozilla SOPS for encrypted file-based secrets: | ||
| @@ -75,12 +71,6 @@ brew install sops # macOS | |||||
| 75 | sops secrets.yaml | 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 | **Benefits:** | 74 | **Benefits:** | ||
| 85 | - Multiple encryption backends (AWS KMS, GCP KMS, Azure Key Vault, PGP) | 75 | - Multiple encryption backends (AWS KMS, GCP KMS, Azure Key Vault, PGP) | ||
| 86 | - YAML/JSON file encryption | 76 | - YAML/JSON file encryption | ||
| @@ -117,26 +107,6 @@ echo "your-token-secret" > ~/.gordon/secrets/gordon/auth/token_secret | |||||
| 117 | 107 | ||||
| 118 | > **Warning:** Only use for local development. Secrets are stored in plain text. | 108 | > **Warning:** Only use for local development. Secrets are stored in plain text. | ||
| 119 | 109 | ||||
| 120 | - | ## Secret Provider Syntax | |||
| 121 | - | ||||
| 122 | - | In environment files, reference secrets using provider syntax: | |||
| 123 | - | ||||
| 124 | - | ### Pass Provider | |||
| 125 | - | ||||
| 126 | - | ```bash | |||
| 127 | - | # ${pass:<path>} | |||
| 128 | - | DATABASE_PASSWORD=${pass:myapp/database/password} | |||
| 129 | - | API_KEY=${pass:myapp/api-key} | |||
| 130 | - | ``` | |||
| 131 | - | ||||
| 132 | - | ### SOPS Provider | |||
| 133 | - | ||||
| 134 | - | ```bash | |||
| 135 | - | # ${sops:<file>:<key.path>} | |||
| 136 | - | DATABASE_PASSWORD=${sops:secrets.yaml:database.password} | |||
| 137 | - | API_SECRET=${sops:production.yaml:api.key} | |||
| 138 | - | ``` | |||
| 139 | - | ||||
| 140 | ## App Secret Values | 110 | ## App Secret Values | ||
| 141 | 111 | ||||
| 142 | App secret names are registered in the app manifest (`[services.<name>.secrets]` maps ENV name to secret name); values are written separately and stay in pass: | 112 | App secret names are registered in the app manifest (`[services.<name>.secrets]` maps ENV name to secret name); values are written separately and stay in pass: | ||
M docs/config/security-hardening.md +0 -4
| @@ -83,10 +83,6 @@ The manifest references only the logical name: `devices = ["transcode-gpu"]`. | |||||
| 83 | - Device-bearing creates require Podman 5.4+ or Docker 28.3+ with native CDI configured. Older or unrecognized engines fail with a structured `runtime-unsupported` error and never run without devices. | 83 | - Device-bearing creates require Podman 5.4+ or Docker 28.3+ with native CDI configured. Older or unrecognized engines fail with a structured `runtime-unsupported` error and never run without devices. | ||
| 84 | - Gordon installs no drivers, manages no quotas, and injects no device environment: images carry their own runtime expectations. | 84 | - Gordon installs no drivers, manages no quotas, and injects no device environment: images carry their own runtime expectations. | ||
| 85 | 85 | ||||
| 86 | - | ## Pass import plaintext handling | |||
| 87 | - | ||||
| 88 | - | With the `pass` backend, Gordon imports eligible plaintext `.env` files at startup. It removes a source file only after every entry is stored successfully. If a destination entry already exists or an import fails, Gordon fails closed and leaves the plaintext source in place for operator review. | |||
| 89 | - | ||||
| 90 | ## External image registries | 86 | ## External image registries | ||
| 91 | 87 | ||||
| 92 | Docker Hub, `ghcr.io`, `quay.io`, and Gordon's configured registry are always allowed. Add every other registry hostname and non-default port explicitly: | 88 | Docker Hub, `ghcr.io`, `quay.io`, and Gordon's configured registry are always allowed. Add every other registry hostname and non-default port explicitly: | ||
M docs/config/server.md +1 -2
| @@ -32,7 +32,7 @@ protocol = "smart_tcp" | |||||
| 32 | | `gordon_domain` | string | **required** | Domain for Gordon (registry + admin API) | | 32 | | `gordon_domain` | string | **required** | Domain for Gordon (registry + admin API) | | ||
| 33 | | `registry_domain` | string | - | Deprecated migration key. Set `gordon_domain` instead. | | 33 | | `registry_domain` | string | - | Deprecated migration key. Set `gordon_domain` instead. | | ||
| 34 | | `legacy_registry_domains` | []string | `[]` | Additional Gordon registry hosts treated as aliases during staged migration. See [Upgrading: Staged Registry Host Rename](../upgrading.md#staged-registry-host-rename). | | 34 | | `legacy_registry_domains` | []string | `[]` | Additional Gordon registry hosts treated as aliases during staged migration. See [Upgrading: Staged Registry Host Rename](../upgrading.md#staged-registry-host-rename). | | ||
| 35 | - | | `data_dir` | string | `~/.gordon` | Directory for registry data, logs, and env files | | 35 | + | | `data_dir` | string | `~/.gordon` | Directory for registry data, logs, and state | |
| 36 | | `max_proxy_body_size` | string | `"512MB"` | Maximum request body size for proxied requests | | 36 | | `max_proxy_body_size` | string | `"512MB"` | Maximum request body size for proxied requests | | ||
| 37 | | `max_blob_chunk_size` | string | `"95MB"` | Maximum request body size for a single registry blob upload chunk | | 37 | | `max_blob_chunk_size` | string | `"95MB"` | Maximum request body size for a single registry blob upload chunk | | ||
| 38 | | `max_blob_size` | string | `"1GB"` | Maximum cumulative size for one registry blob/layer upload | | 38 | | `max_blob_size` | string | `"1GB"` | Maximum cumulative size for one registry blob/layer upload | | ||
| @@ -220,7 +220,6 @@ data_dir = "~/.gordon" # Default for user installations | |||||
| 220 | ├── registry/ # Container images and manifests | 220 | ├── registry/ # Container images and manifests | ||
| 221 | │ ├── blobs/ | 221 | │ ├── blobs/ | ||
| 222 | │ └── manifests/ | 222 | │ └── manifests/ | ||
| 223 | - | ├── env/ # Installation secret-store data | |||
| 224 | ├── logs/ # Gordon process and file-based access logs | 223 | ├── logs/ # Gordon process and file-based access logs | ||
| 225 | │ ├── gordon.log | 224 | │ ├── gordon.log | ||
| 226 | │ └── access.log | 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 | - [Traffic Plane Configuration](./traffic.md) | 92 | - [Traffic Plane Configuration](./traffic.md) | ||
| 93 | - [Configuration Reference](./reference.md) | 93 | - [Configuration Reference](./reference.md) | ||
| 94 | - | - [CLI traffic status](../cli/traffic.md) | 94 | + | - [CLI traffic status](../cli/daemon.md#gordon-daemon-traffic) |
M docs/config/telemetry.md +1 -1
| @@ -110,7 +110,7 @@ Set `auth_token` to the Base64-encoded `user:password` string. Gordon sends it a | |||||
| 110 | 110 | ||||
| 111 | For OpenObserve, copy the token from **Ingestion > OTLP** in the web UI. | 111 | For OpenObserve, copy the token from **Ingestion > OTLP** in the web UI. | ||
| 112 | 112 | ||||
| 113 | - | Since Gordon itself is the platform (not a managed container), it does not use `gordon secrets set`. Store the token with one of these methods: | 113 | + | Gordon itself is the platform, not an app, so the token does not go through `gordon apps secrets`. Store it with one of these methods: |
| 114 | 114 | ||||
| 115 | | Method | How | | 115 | | Method | How | | ||
| 116 | |--------|-----| | 116 | |--------|-----| | ||
M docs/config/traffic.md +1 -1
| @@ -178,4 +178,4 @@ UDP sessions are keyed by client address and expire after `idle_timeout`. If `ma | |||||
| 178 | - [Server Settings](./server.md) | 178 | - [Server Settings](./server.md) | ||
| 179 | - [App Manifest](./apps.md) | 179 | - [App Manifest](./apps.md) | ||
| 180 | - [Standalone Services](./services.md) | 180 | - [Standalone Services](./services.md) | ||
| 181 | - | - [CLI traffic status](../cli/traffic.md) | 181 | + | - [CLI traffic status](../cli/daemon.md#gordon-daemon-traffic) |
M docs/deployment/generic-ci.md +8 -8
| @@ -8,7 +8,7 @@ Deploy with Gordon from any CI/CD system. | |||||
| 8 | - Network access to the public Gordon HTTPS domain; do not expose or target the loopback `server.registry_port` | 8 | - Network access to the public Gordon HTTPS domain; do not expose or target the loopback `server.registry_port` | ||
| 9 | - Gordon binary (optional but recommended) | 9 | - Gordon binary (optional but recommended) | ||
| 10 | 10 | ||||
| 11 | - | ## Recommended: gordon push | 11 | + | ## Recommended: gordon images push |
| 12 | 12 | ||||
| 13 | ### 1. Generate Deployment Token | 13 | ### 1. Generate Deployment Token | ||
| 14 | 14 | ||||
| @@ -40,7 +40,7 @@ chmod +x /usr/local/bin/gordon | |||||
| 40 | 40 | ||||
| 41 | # 2. Build, push, and deploy | 41 | # 2. Build, push, and deploy | ||
| 42 | export GORDON_TOKEN="$GORDON_TOKEN" | 42 | export GORDON_TOKEN="$GORDON_TOKEN" | ||
| 43 | - | gordon push --build \ | 43 | + | gordon images push --build \ |
| 44 | --remote "$GORDON_REMOTE" \ | 44 | --remote "$GORDON_REMOTE" \ | ||
| 45 | 45 | ||||
| 46 | ``` | 46 | ``` | ||
| @@ -52,7 +52,7 @@ Gordon auto-detects the version from CI environment variables: | |||||
| 52 | | GitHub Actions | `$GITHUB_REF` | `refs/tags/v1.2.0` | | 52 | | GitHub Actions | `$GITHUB_REF` | `refs/tags/v1.2.0` | | ||
| 53 | | GitLab CI | `$CI_COMMIT_TAG` | `v1.2.0` | | 53 | | GitLab CI | `$CI_COMMIT_TAG` | `v1.2.0` | | ||
| 54 | | Azure DevOps | `$BUILD_SOURCEBRANCH` | `refs/tags/v1.2.0` | | 54 | | Azure DevOps | `$BUILD_SOURCEBRANCH` | `refs/tags/v1.2.0` | | ||
| 55 | - | | Other | `--tag` flag | `gordon push --tag v1.2.0` | | 55 | + | | Other | `--tag` flag | `gordon images push --tag v1.2.0` | |
| 56 | 56 | ||||
| 57 | ## Alternative: docker push | 57 | ## Alternative: docker push | ||
| 58 | 58 | ||||
| @@ -89,7 +89,7 @@ pipeline { | |||||
| 89 | curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 \ | 89 | curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 \ | ||
| 90 | -o /usr/local/bin/gordon | 90 | -o /usr/local/bin/gordon | ||
| 91 | chmod +x /usr/local/bin/gordon | 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 | - run: | 119 | - run: | ||
| 120 | name: Deploy | 120 | name: Deploy | ||
| 121 | command: | | 121 | command: | | ||
| 122 | - | gordon push --build \ | 122 | + | gordon images push --build \ |
| 123 | --remote "$GORDON_REMOTE" | 123 | --remote "$GORDON_REMOTE" | ||
| 124 | 124 | ||||
| 125 | workflows: | 125 | workflows: | ||
| @@ -152,7 +152,7 @@ steps: | |||||
| 152 | - apk add --no-cache curl | 152 | - apk add --no-cache curl | ||
| 153 | - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon | 153 | - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon | ||
| 154 | - chmod +x /usr/local/bin/gordon | 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 | trigger: | 157 | trigger: | ||
| 158 | event: | 158 | event: | ||
| @@ -188,7 +188,7 @@ gordon apps deploy myapp --remote "$GORDON_REMOTE" | |||||
| 188 | 188 | ||||
| 189 | Gordon could not detect a version tag. Either: | 189 | Gordon could not detect a version tag. Either: | ||
| 190 | - Tag your git repo: `git tag v1.0.0 && git push --tags` | 190 | - Tag your git repo: `git tag v1.0.0 && git push --tags` | ||
| 191 | - | - Pass explicitly: `gordon push --tag v1.0.0` | 191 | + | - Pass explicitly: `gordon images push --tag v1.0.0` |
| 192 | 192 | ||||
| 193 | ### Large images time out | 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 | - [GitLab CI](./gitlab-ci.md) | 200 | - [GitLab CI](./gitlab-ci.md) | ||
| 201 | - [Deployment Overview](./index.md) | 201 | - [Deployment Overview](./index.md) | ||
| 202 | - [Authentication](../config/auth.md) | 202 | - [Authentication](../config/auth.md) | ||
| 203 | - | - [Push Command](../cli/push.md) | 203 | + | - [Images Commands](../cli/images.md#gordon-images-push) |
M docs/deployment/github-actions.md +10 -10
| @@ -1,6 +1,6 @@ | |||||
| 1 | # GitHub Actions Deployment | 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 | ## Prerequisites | 5 | ## Prerequisites | ||
| 6 | 6 | ||||
| @@ -8,9 +8,9 @@ Two approaches for deploying with GitHub Actions: the `gordon push` CLI (recomme | |||||
| 8 | 2. Deployment token generated with the minimum required scopes: `push,pull` for image transfer, plus `admin:apps:read,admin:apps:write` only when the workflow applies or deploys apps | 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 | 3. GitHub repository secrets configured | 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 | ### 1. Generate Token | 15 | ### 1. Generate Token | ||
| 16 | 16 | ||||
| @@ -71,7 +71,7 @@ jobs: | |||||
| 71 | env: | 71 | env: | ||
| 72 | GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }} | 72 | GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }} | ||
| 73 | run: | | 73 | run: | | ||
| 74 | - | gordon push --build \ | 74 | + | gordon images push --build \ |
| 75 | --remote ${{ secrets.GORDON_REMOTE }} | 75 | --remote ${{ secrets.GORDON_REMOTE }} | ||
| 76 | ``` | 76 | ``` | ||
| 77 | 77 | ||||
| @@ -102,7 +102,7 @@ jobs: | |||||
| 102 | env: | 102 | env: | ||
| 103 | GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }} | 103 | GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }} | ||
| 104 | run: | | 104 | run: | | ||
| 105 | - | gordon push --build \ | 105 | + | gordon images push --build \ |
| 106 | --remote ${{ secrets.GORDON_REMOTE }} \ | 106 | --remote ${{ secrets.GORDON_REMOTE }} \ | ||
| 107 | --tag latest | 107 | --tag latest | ||
| 108 | ``` | 108 | ``` | ||
| @@ -149,7 +149,7 @@ jobs: | |||||
| 149 | 149 | ||||
| 150 | #### Monorepo | 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 | ```yaml | 154 | ```yaml | ||
| 155 | name: Deploy Services | 155 | name: Deploy Services | ||
| @@ -171,7 +171,7 @@ jobs: | |||||
| 171 | env: | 171 | env: | ||
| 172 | GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }} | 172 | GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }} | ||
| 173 | run: | | 173 | run: | | ||
| 174 | - | gordon push --build \ | 174 | + | gordon images push --build \ |
| 175 | --remote ${{ secrets.GORDON_REMOTE }} \ | 175 | --remote ${{ secrets.GORDON_REMOTE }} \ | ||
| 176 | --file ./services/api/Dockerfile \ | 176 | --file ./services/api/Dockerfile \ | ||
| 177 | myapp-api | 177 | myapp-api | ||
| @@ -180,7 +180,7 @@ jobs: | |||||
| 180 | env: | 180 | env: | ||
| 181 | GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }} | 181 | GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }} | ||
| 182 | run: | | 182 | run: | | ||
| 183 | - | gordon push --build \ | 183 | + | gordon images push --build \ |
| 184 | --remote ${{ secrets.GORDON_REMOTE }} \ | 184 | --remote ${{ secrets.GORDON_REMOTE }} \ | ||
| 185 | --file ./services/web/Dockerfile \ | 185 | --file ./services/web/Dockerfile \ | ||
| 186 | myapp-web | 186 | myapp-web | ||
| @@ -195,7 +195,7 @@ Pass build arguments to the Docker build: | |||||
| 195 | env: | 195 | env: | ||
| 196 | GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }} | 196 | GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }} | ||
| 197 | run: | | 197 | run: | | ||
| 198 | - | gordon push --build \ | 198 | + | gordon images push --build \ |
| 199 | --remote ${{ secrets.GORDON_REMOTE }} \ | 199 | --remote ${{ secrets.GORDON_REMOTE }} \ | ||
| 200 | --build-arg NODE_ENV=production \ | 200 | --build-arg NODE_ENV=production \ | ||
| 201 | --build-arg API_URL=https://api.example.com \ | 201 | --build-arg API_URL=https://api.example.com \ | ||
| @@ -478,5 +478,5 @@ Error: unauthorized: authentication required | |||||
| 478 | - [Generic CI](./generic-ci.md) | 478 | - [Generic CI](./generic-ci.md) | ||
| 479 | - [Deployment Overview](./index.md) | 479 | - [Deployment Overview](./index.md) | ||
| 480 | - [Authentication](../config/auth.md) | 480 | - [Authentication](../config/auth.md) | ||
| 481 | - | - [Push Command](../cli/push.md) | 481 | + | - [Images Commands](../cli/images.md#gordon-images-push) |
| 482 | - [Rollback](./rollback.md) | 482 | - [Rollback](./rollback.md) | ||
M docs/deployment/gitlab-ci.md +6 -6
| @@ -45,7 +45,7 @@ deploy: | |||||
| 45 | - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon | 45 | - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon | ||
| 46 | - chmod +x /usr/local/bin/gordon | 46 | - chmod +x /usr/local/bin/gordon | ||
| 47 | script: | 47 | script: | ||
| 48 | - | - gordon push --build | 48 | + | - gordon images push --build |
| 49 | --remote "$GORDON_REMOTE" | 49 | --remote "$GORDON_REMOTE" | ||
| 50 | --token "$GORDON_TOKEN" | 50 | --token "$GORDON_TOKEN" | ||
| 51 | 51 | ||||
| @@ -73,7 +73,7 @@ deploy: | |||||
| 73 | - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon | 73 | - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon | ||
| 74 | - chmod +x /usr/local/bin/gordon | 74 | - chmod +x /usr/local/bin/gordon | ||
| 75 | script: | 75 | script: | ||
| 76 | - | - gordon push --build | 76 | + | - gordon images push --build |
| 77 | --remote "$GORDON_REMOTE" | 77 | --remote "$GORDON_REMOTE" | ||
| 78 | --token "$GORDON_TOKEN" | 78 | --token "$GORDON_TOKEN" | ||
| 79 | 79 | ||||
| @@ -96,7 +96,7 @@ deploy: | |||||
| 96 | - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon | 96 | - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon | ||
| 97 | - chmod +x /usr/local/bin/gordon | 97 | - chmod +x /usr/local/bin/gordon | ||
| 98 | script: | 98 | script: | ||
| 99 | - | - gordon push --build | 99 | + | - gordon images push --build |
| 100 | --remote "$GORDON_REMOTE" | 100 | --remote "$GORDON_REMOTE" | ||
| 101 | --token "$GORDON_TOKEN" | 101 | --token "$GORDON_TOKEN" | ||
| 102 | 102 | ||||
| @@ -117,7 +117,7 @@ deploy: | |||||
| 117 | - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon | 117 | - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon | ||
| 118 | - chmod +x /usr/local/bin/gordon | 118 | - chmod +x /usr/local/bin/gordon | ||
| 119 | script: | 119 | script: | ||
| 120 | - | - gordon push --build | 120 | + | - gordon images push --build |
| 121 | --remote "$GORDON_REMOTE" | 121 | --remote "$GORDON_REMOTE" | ||
| 122 | --token "$GORDON_TOKEN" | 122 | --token "$GORDON_TOKEN" | ||
| 123 | 123 | ||||
| @@ -137,7 +137,7 @@ deploy: | |||||
| 137 | - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon | 137 | - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon | ||
| 138 | - chmod +x /usr/local/bin/gordon | 138 | - chmod +x /usr/local/bin/gordon | ||
| 139 | script: | 139 | script: | ||
| 140 | - | - gordon push --build | 140 | + | - gordon images push --build |
| 141 | --remote "$GORDON_REMOTE" | 141 | --remote "$GORDON_REMOTE" | ||
| 142 | --token "$GORDON_TOKEN" | 142 | --token "$GORDON_TOKEN" | ||
| 143 | --build-arg NODE_ENV=production | 143 | --build-arg NODE_ENV=production | ||
| @@ -208,4 +208,4 @@ GitLab CI clones your repository automatically. The build context defaults to th | |||||
| 208 | - [Generic CI](./generic-ci.md) | 208 | - [Generic CI](./generic-ci.md) | ||
| 209 | - [Deployment Overview](./index.md) | 209 | - [Deployment Overview](./index.md) | ||
| 210 | - [Authentication](../config/auth.md) | 210 | - [Authentication](../config/auth.md) | ||
| 211 | - | - [Push Command](../cli/push.md) | 211 | + | - [Images Commands](../cli/images.md#gordon-images-push) |
M docs/deployment/index.md +6 -6
| @@ -2,16 +2,16 @@ | |||||
| 2 | 2 | ||||
| 3 | Gordon deploys apps in two explicit steps: push the image to its built-in registry, then apply the app manifest and deploy. Push transfers OCI content only — it never deploys, creates routes, or modifies manifests. | 3 | Gordon deploys apps in two explicit steps: push the image to its built-in registry, then apply the app manifest and deploy. Push transfers OCI content only — it never deploys, creates routes, or modifies manifests. | ||
| 4 | 4 | ||||
| 5 | - | ## Recommended: gordon push + apps 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 | - Single secret (`GORDON_TOKEN`): auto-exchanges for a short-lived registry token | 9 | - Single secret (`GORDON_TOKEN`): auto-exchanges for a short-lived registry token | ||
| 10 | - Auto-detects version from CI environment (`$GITHUB_REF`, `$CI_COMMIT_TAG`, `$BUILD_SOURCEBRANCH`, or `git describe`) | 10 | - Auto-detects version from CI environment (`$GITHUB_REF`, `$CI_COMMIT_TAG`, `$BUILD_SOURCEBRANCH`, or `git describe`) | ||
| 11 | - Chunked uploads (50MB chunks) — works behind Cloudflare and restrictive proxies | 11 | - Chunked uploads (50MB chunks) — works behind Cloudflare and restrictive proxies | ||
| 12 | 12 | ||||
| 13 | ```bash | 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 | Then activate (from CI with the Gordon binary, or from your machine): | 17 | Then activate (from CI with the Gordon binary, or from your machine): | ||
| @@ -25,10 +25,10 @@ gordon apps deploy blog --remote https://gordon.example.com | |||||
| 25 | 25 | ||||
| 26 | | Method | Best For | Secrets Needed | Registry Access | Deploy Control | | 26 | | Method | Best For | Secrets Needed | Registry Access | Deploy Control | | ||
| 27 | |--------|----------|----------------|-----------------|----------------| | 27 | |--------|----------|----------------|-----------------|----------------| | ||
| 28 | - | | `gordon push` + `apps deploy` (Recommended) | CI/CD pipelines | 1 (`GORDON_TOKEN`) | Via gordon domain (HTTPS) | Explicit (CLI-triggered) | | 28 | + | | `gordon images push` + `apps deploy` (Recommended) | CI/CD pipelines | 1 (`GORDON_TOKEN`) | Via gordon domain (HTTPS) | Explicit (CLI-triggered) | |
| 29 | | `docker push` + `apps deploy` | Simple setups, existing Docker workflows | 2 (`username` + `token`) | Via gordon domain (HTTPS) | Explicit (CLI-triggered) | | 29 | | `docker push` + `apps deploy` | Simple setups, existing Docker workflows | 2 (`username` + `token`) | Via gordon domain (HTTPS) | Explicit (CLI-triggered) | | ||
| 30 | 30 | ||||
| 31 | - | ### Method 1: gordon push + apps deploy (Recommended) | 31 | + | ### Method 1: gordon images push + apps deploy (Recommended) |
| 32 | 32 | ||||
| 33 | The Gordon CLI handles authentication, image building, and registry upload in a single step. Deploy stays explicit. | 33 | The Gordon CLI handles authentication, image building, and registry upload in a single step. Deploy stays explicit. | ||
| 34 | 34 | ||||
| @@ -38,7 +38,7 @@ The Gordon CLI handles authentication, image building, and registry upload in a | |||||
| 38 | 38 | ||||
| 39 | ```bash | 39 | ```bash | ||
| 40 | # Build and push (OCI transfer only) | 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 | # Apply the manifest that references the pushed tag, then deploy | 43 | # Apply the manifest that references the pushed tag, then deploy | ||
| 44 | gordon apps apply --file blog.toml --remote https://gordon.example.com | 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 | gordon remotes use prod | 148 | gordon remotes use prod | ||
| 149 | 149 | ||||
| 150 | # Build and push the image (OCI transfer only, never deploys) | 150 | # Build and push the image (OCI transfer only, never deploys) | ||
| 151 | - | gordon push myapp --build --remote prod | 151 | + | gordon images push myapp --build --remote prod |
| 152 | ``` | 152 | ``` | ||
| 153 | 153 | ||||
| 154 | Write the app file (`blog.toml`). The file is intended for Git: it must never contain secret values. | 154 | Write the app file (`blog.toml`). The file is intended for Git: it must never contain secret values. | ||
| @@ -173,7 +173,7 @@ gordon apps deploy blog --remote prod | |||||
| 173 | 173 | ||||
| 174 | What these commands do: | 174 | What these commands do: | ||
| 175 | 175 | ||||
| 176 | - | - `gordon push` builds, uploads, and stores the image. It never deploys. | 176 | + | - `gordon images push` builds, uploads, and stores the image. It never deploys. |
| 177 | - `gordon apps apply` validates the manifest and persists it as desired state. | 177 | - `gordon apps apply` validates the manifest and persists it as desired state. | ||
| 178 | - `gordon apps deploy` activates the accepted revision: runs preflight, withdraws the service from traffic, stops and removes the old container, starts the new container, waits for readiness, publishes the new `ACTIVE` state, and routes traffic to it. | 178 | - `gordon apps deploy` activates the accepted revision: runs preflight, withdraws the service from traffic, stops and removes the old container, starts the new container, waits for readiness, publishes the new `ACTIVE` state, and routes traffic to it. | ||
| 179 | 179 | ||||
| @@ -191,7 +191,7 @@ Push a new image, update the manifest tag if needed, apply, deploy: | |||||
| 191 | 191 | ||||
| 192 | ```bash | 192 | ```bash | ||
| 193 | # Make changes, then build + push | 193 | # Make changes, then build + push | ||
| 194 | - | gordon push myapp --build --remote prod | 194 | + | gordon images push myapp --build --remote prod |
| 195 | 195 | ||||
| 196 | # Deploy the new tag (re-resolves mutable tags) | 196 | # Deploy the new tag (re-resolves mutable tags) | ||
| 197 | gordon apps deploy blog --remote prod | 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 | ### Reference | 70 | ### Reference | ||
| 71 | 71 | ||||
| 72 | - [Docker Labels](./reference/docker-labels.md) - Container and image labels | 72 | - [Docker Labels](./reference/docker-labels.md) - Container and image labels | ||
| 73 | - | - [Environment Variables](./reference/env-variables.md) - Environment variable syntax | |||
| 74 | - [Troubleshooting](./reference/troubleshooting.md) - Common issues and solutions | 73 | - [Troubleshooting](./reference/troubleshooting.md) - Common issues and solutions | ||
| 75 | 74 | ||||
| 76 | ## Requirements | 75 | ## Requirements | ||
M docs/installation.md +0 -1
| @@ -377,7 +377,6 @@ Gordon stores data in the following locations: | |||||
| 377 | | `~/.config/gordon/gordon.toml` | Configuration file | | 377 | | `~/.config/gordon/gordon.toml` | Configuration file | | ||
| 378 | | `~/.gordon/` | Default data directory | | 378 | | `~/.gordon/` | Default data directory | | ||
| 379 | | `~/.gordon/registry/` | Container images | | 379 | | `~/.gordon/registry/` | Container images | | ||
| 380 | - | | `~/.gordon/env/` | Environment files | | |||
| 381 | | `~/.gordon/logs/` | Application logs | | 380 | | `~/.gordon/logs/` | Application logs | | ||
| 382 | | `~/.gordon/secrets/` | Secrets (unsafe backend only) | | 381 | | `~/.gordon/secrets/` | Secrets (unsafe backend only) | | ||
| 383 | 382 | ||||
M docs/migrate-to-v3.md +1 -1
| @@ -57,7 +57,7 @@ Manifests written for an early v3 alpha used `[[service]]` with a `name` field; | |||||
| 57 | 57 | ||||
| 58 | ## 2. Migrate domain secrets | 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 | ```bash | 62 | ```bash | ||
| 63 | gordon apps apply --file ./example-app.toml --remote production | 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 | ## Contents | 5 | ## Contents | ||
| 6 | 6 | ||||
| 7 | - [Docker Labels](./docker-labels.md) - Container and image labels used by Gordon | 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 | - [Telemetry & Metrics](../config/telemetry.md) - OpenTelemetry configuration, custom metrics, and trace spans | 8 | - [Telemetry & Metrics](../config/telemetry.md) - OpenTelemetry configuration, custom metrics, and trace spans | ||
| 10 | - [Troubleshooting](./troubleshooting.md) - Common issues and solutions | 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 | 1. Per-command flag: | 91 | 1. Per-command flag: | ||
| 92 | 92 | ||||
| 93 | ```bash | 93 | ```bash | ||
| 94 | - | gordon push myapp:latest --insecure | 94 | + | gordon images push myapp:latest --insecure |
| 95 | ``` | 95 | ``` | ||
| 96 | 96 | ||||
| 97 | 2. Environment variable: | 97 | 2. Environment variable: | ||
| @@ -164,7 +164,7 @@ gordon apps deploy app | |||||
| 164 | 164 | ||||
| 165 | 2. Check Gordon logs: | 165 | 2. Check Gordon logs: | ||
| 166 | ```bash | 166 | ```bash | ||
| 167 | - | gordon logs -f | 167 | + | gordon daemon logs -f |
| 168 | ``` | 168 | ``` | ||
| 169 | 169 | ||||
| 170 | 3. Run container manually to debug: | 170 | 3. Run container manually to debug: | ||
| @@ -174,28 +174,23 @@ gordon apps deploy app | |||||
| 174 | 174 | ||||
| 175 | ### Environment variables not loaded | 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 | **Solutions:** | 179 | **Solutions:** | ||
| 180 | 180 | ||||
| 181 | - | 1. Check file exists with correct name: | 181 | + | 1. Declare public values under `[env]` and service values under `[services.<name>.secrets]` in the app file, then apply it: |
| 182 | ```bash | 182 | ```bash | ||
| 183 | - | ls ~/.gordon/env/ | 183 | + | gordon apps apply --file ./blog.toml |
| 184 | - | # Should show: app_mydomain_com.env (dots → underscores) | |||
| 185 | ``` | 184 | ``` | ||
| 186 | 185 | ||||
| 187 | - | 2. Check file permissions: | 186 | + | 2. Check which secret values are set (names only): |
| 188 | ```bash | 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 | ```bash | 192 | ```bash | ||
| 194 | - | # Correct | 193 | + | gordon apps restart blog |
| 195 | - | KEY=value | |||
| 196 | - | ||||
| 197 | - | # Wrong | |||
| 198 | - | KEY = value | |||
| 199 | ``` | 194 | ``` | ||
| 200 | 195 | ||||
| 201 | ### Secrets not resolved | 196 | ### Secrets not resolved | ||
| @@ -318,7 +313,7 @@ gordon apps deploy app | |||||
| 318 | 313 | ||||
| 319 | **Solution:** Manual reload: | 314 | **Solution:** Manual reload: | ||
| 320 | ```bash | 315 | ```bash | ||
| 321 | - | gordon reload | 316 | + | gordon daemon reload |
| 322 | ``` | 317 | ``` | ||
| 323 | 318 | ||||
| 324 | ### Stale `targets.toml` in config directory | 319 | ### Stale `targets.toml` in config directory | ||
| @@ -372,7 +367,7 @@ path = "~/.gordon/logs/gordon.log" | |||||
| 372 | systemctl --user status gordon | 367 | systemctl --user status gordon | ||
| 373 | 368 | ||||
| 374 | # View Gordon logs | 369 | # View Gordon logs | ||
| 375 | - | gordon logs -f | 370 | + | gordon daemon logs -f |
| 376 | journalctl --user -u gordon -f | 371 | journalctl --user -u gordon -f | ||
| 377 | 372 | ||||
| 378 | # List containers | 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 | - App-workload keys in `gordon.toml`: `[routes]`, `[attachments]`, `[network_groups]`, `[service_routes]`, `[auto_route]` (+ `_allowed_domains`), and `[previews]`. Gordon fails boot/reload closed with a `config-retired` diagnostic naming the fix when any of them is present — never a silent migration. | 13 | - App-workload keys in `gordon.toml`: `[routes]`, `[attachments]`, `[network_groups]`, `[service_routes]`, `[auto_route]` (+ `_allowed_domains`), and `[previews]`. Gordon fails boot/reload closed with a `config-retired` diagnostic naming the fix when any of them is present — never a silent migration. | ||
| 14 | - Installation-level `[[services]]` (standalone L4 workloads) and `[[network_services]]` (L4 traffic plane) stay valid. Only their old app-workload semantics were removed; declare application workloads in standalone files instead. See [Standalone Services](./config/services.md). | 14 | - Installation-level `[[services]]` (standalone L4 workloads) and `[[network_services]]` (L4 traffic plane) stay valid. Only their old app-workload semantics were removed; declare application workloads in standalone files instead. See [Standalone Services](./config/services.md). | ||
| 15 | - CLI: `pin`, `preview`, `attachments`, `bootstrap`, `autoroute allow`, `routes add/remove/purge`, push deploy/route inference, implicit deploys on push/reload, label/env-file inference. Removed HTTP mutation endpoints answer `410 Gone`. | 15 | - CLI: `pin`, `preview`, `attachments`, `bootstrap`, `autoroute allow`, `routes add/remove/purge`, push deploy/route inference, implicit deploys on push/reload, label/env-file inference. Removed HTTP mutation endpoints answer `410 Gone`. | ||
| 16 | - | - Scopes `admin:routes:*` are replaced by `admin:apps:read` (list, show, diff, status) and `admin:apps:write` (apply, deploy, lifecycle, secrets). Regenerate CI tokens, e.g. `--scopes "push,pull,admin:apps:read,admin:apps:write"`. | 16 | + | - Domain secrets: `gordon secrets`, `/admin/secrets` (now `410 Gone`), the `admin:secrets:*` scopes, the `[env]` config section, and startup `.env` import into pass. Use `gordon apps secrets`. Existing `gordon/env/...` pass entries are left untouched. |
| 17 | + | - CLI layout: `gordon push` is now `gordon images push`. `status`, `logs`, `reload`, `config`, `tls status`, `traffic status`, and `networks list` moved under `gordon daemon` (`gordon daemon status`, `gordon daemon tls`, `gordon daemon traffic`, `gordon daemon networks`, …). There are no aliases. | |||
| 18 | + | - Scopes `admin:routes:*` and `admin:secrets:*` are replaced by `admin:apps:read` (list, show, diff, status) and `admin:apps:write` (apply, deploy, lifecycle, secrets). Regenerate CI tokens, e.g. `--scopes "push,pull,admin:apps:read,admin:apps:write"`. | |||
| 17 | - No historical rollback command: roll back by applying a manifest that references the previous tag and deploying again. | 19 | - No historical rollback command: roll back by applying a manifest that references the previous tag and deploying again. | ||
| 18 | 20 | ||||
| 19 | ### Migrating a v3 alpha app manifest | 21 | ### Migrating a v3 alpha app manifest | ||
| @@ -52,7 +54,7 @@ Names containing dots must be quoted: `[services."web.api"]` and `[[services."we | |||||
| 52 | ### Manual migration | 54 | ### Manual migration | ||
| 53 | 55 | ||||
| 54 | 1. Back up databases/volumes and pass entries with the existing procedures. Record original ownership and image versions. No update hook deletes volumes: unknown resources are preserved, never adopted. | 56 | 1. Back up databases/volumes and pass entries with the existing procedures. Record original ownership and image versions. No update hook deletes volumes: unknown resources are preserved, never adopted. | ||
| 55 | - | 2. Delete the removed keys from `gordon.toml` (installation settings only: entrypoints, TLS, limits, external routes, images policy, backups destinations stay). | 57 | + | 2. Delete the removed keys, including `[env]`, from `gordon.toml` (installation settings only: entrypoints, TLS, limits, external routes, images policy, backups destinations stay). |
| 56 | 3. Write one `<app>.toml` per app (see [App Manifest](./config/apps.md)): services, `[[services.<name>.http]]` hosts, `[services.<name>.secrets]` names, volumes, `[[network.shared]]`, backup declarations. | 58 | 3. Write one `<app>.toml` per app (see [App Manifest](./config/apps.md)): services, `[[services.<name>.http]]` hosts, `[services.<name>.secrets]` names, volumes, `[[network.shared]]`, backup declarations. | ||
| 57 | 4. Migrate secret values explicitly with `gordon apps secrets set` after applying each manifest. Gordon does not copy `gordon/env/<domain>/...` entries into `gordon/apps/<uuid>/<service>/...`; follow the [v3 secrets migration procedure](./migrate-to-v3.md#2-migrate-domain-secrets). | 59 | 4. Migrate secret values explicitly with `gordon apps secrets set` after applying each manifest. Gordon does not copy `gordon/env/<domain>/...` entries into `gordon/apps/<uuid>/<service>/...`; follow the [v3 secrets migration procedure](./migrate-to-v3.md#2-migrate-domain-secrets). | ||
| 58 | 5. `gordon apps apply --file <app>.toml`, then `gordon apps deploy <app>`. | 60 | 5. `gordon apps apply --file <app>.toml`, then `gordon apps deploy <app>`. | ||
| @@ -150,7 +152,7 @@ registry_domain = "gordon.example.com" | |||||
| 150 | gordon_domain = "gordon.example.com" | 152 | gordon_domain = "gordon.example.com" | ||
| 151 | ``` | 153 | ``` | ||
| 152 | 154 | ||||
| 153 | - | If you do not migrate, `gordon status --remote ...` and `gordon apps list --remote ...` can fail with `/auth/token` `404`, and `reg-domain/v2/` or `/admin/status` can return `404`. | 155 | + | If you do not migrate, `gordon daemon status --remote ...` and `gordon apps list --remote ...` can fail with `/auth/token` `404`, and `reg-domain/v2/` or `/admin/status` can return `404`. |
| 154 | 156 | ||||
| 155 | ### Staged Registry Host Rename | 157 | ### Staged Registry Host Rename | ||
| 156 | 158 | ||||
| @@ -477,7 +479,7 @@ If using pass or sops, update your secret paths: | |||||
| 477 | ``` | 479 | ``` | ||
| 478 | 6. **Check logs** for any errors: | 480 | 6. **Check logs** for any errors: | ||
| 479 | ```bash | 481 | ```bash | ||
| 480 | - | gordon logs | 482 | + | gordon daemon logs |
| 481 | ``` | 483 | ``` | ||
| 482 | 484 | ||||
| 483 | ## Getting Help | 485 | ## Getting Help | ||
M gordon.toml.example +1 -4
| @@ -62,7 +62,7 @@ security_profile = "compat" | |||||
| 62 | 62 | ||||
| 63 | [network_isolation] | 63 | [network_isolation] | ||
| 64 | # Installation network policy for Gordon-managed networks (prefix filter | 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 | # `[[network.shared]]` membership in their app file instead. | 66 | # `[[network.shared]]` membership in their app file instead. | ||
| 67 | enabled = true | 67 | enabled = true | ||
| 68 | network_prefix = "gordon" | 68 | network_prefix = "gordon" | ||
| @@ -151,9 +151,6 @@ metrics = true | |||||
| 151 | logs = true | 151 | logs = true | ||
| 152 | trace_sample_rate = 1.0 | 152 | trace_sample_rate = 1.0 | ||
| 153 | 153 | ||||
| 154 | - | [env] | |||
| 155 | - | # dir = "~/.gordon/env" | |||
| 156 | - | ||||
| 157 | [backups.databases] | 154 | [backups.databases] | ||
| 158 | enabled = false | 155 | enabled = false | ||
| 159 | schedule = "daily" | 156 | schedule = "daily" | ||
D internal/adapters/dto/admin_secrets.go +0 -12
| @@ -1,12 +0,0 @@ | |||||
| 1 | - | package dto | |||
| 2 | - | ||||
| 3 | - | // SecretsListResponse represents a list of secret keys for a domain. | |||
| 4 | - | type SecretsListResponse struct { | |||
| 5 | - | Domain string `json:"domain"` | |||
| 6 | - | Keys []string `json:"keys"` | |||
| 7 | - | } | |||
| 8 | - | ||||
| 9 | - | // SecretsStatusResponse represents a secrets write response. | |||
| 10 | - | type SecretsStatusResponse struct { | |||
| 11 | - | Status string `json:"status"` | |||
| 12 | - | } | |||
M internal/adapters/in/cli/config.go +3 -3
| @@ -32,9 +32,9 @@ func newConfigShowCmd() *cobra.Command { | |||||
| 32 | auto-route, network isolation, routes, and external routes. | 32 | auto-route, network isolation, routes, and external routes. | ||
| 33 | 33 | ||||
| 34 | Examples: | 34 | Examples: | ||
| 35 | - | gordon config show | 35 | + | gordon daemon config show |
| 36 | - | gordon config show --json | 36 | + | gordon daemon config show --json |
| 37 | - | gordon config show --remote https://gordon.mydomain.com --token $TOKEN`, | 37 | + | gordon daemon config show --remote prod`, |
| 38 | RunE: func(cmd *cobra.Command, _ []string) error { | 38 | RunE: func(cmd *cobra.Command, _ []string) error { | ||
| 39 | ctx := cmd.Context() | 39 | ctx := cmd.Context() | ||
| 40 | handle, err := resolveControlPlane(cliConfigPath) | 40 | handle, err := resolveControlPlane(cliConfigPath) | ||
M internal/adapters/in/cli/config_validate.go +1 -1
| @@ -30,7 +30,7 @@ func newConfigValidateCmd() *cobra.Command { | |||||
| 30 | Args: cobra.NoArgs, | 30 | Args: cobra.NoArgs, | ||
| 31 | RunE: func(cmd *cobra.Command, _ []string) error { | 31 | RunE: func(cmd *cobra.Command, _ []string) error { | ||
| 32 | if remoteFlag != "" { | 32 | if remoteFlag != "" { | ||
| 33 | - | return fmt.Errorf("config validate is local-only; --remote is not supported") | 33 | + | return fmt.Errorf("daemon config validate is local-only; --remote is not supported") |
| 34 | } | 34 | } | ||
| 35 | return runConfigValidate(cmd.OutOrStdout(), file, jsonOut) | 35 | return runConfigValidate(cmd.OutOrStdout(), file, jsonOut) | ||
| 36 | }, | 36 | }, | ||
M internal/adapters/in/cli/controlplane.go +0 -4
| @@ -27,10 +27,6 @@ type ControlPlane interface { | |||||
| 27 | SetAppSecrets(ctx context.Context, app string, req dto.AppSecretSetRequest) error | 27 | SetAppSecrets(ctx context.Context, app string, req dto.AppSecretSetRequest) error | ||
| 28 | DeleteAppSecret(ctx context.Context, app string, req dto.AppSecretDeleteRequest) error | 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 | GetStatus(ctx context.Context) (*remote.Status, error) | 30 | GetStatus(ctx context.Context) (*remote.Status, error) | ||
| 35 | GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error) | 31 | GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error) | ||
| 36 | GetTrafficStatus(ctx context.Context) (*dto.TrafficStatusResponse, error) | 32 | GetTrafficStatus(ctx context.Context) (*dto.TrafficStatusResponse, error) | ||
M internal/adapters/in/cli/controlplane_remote.go +0 -12
| @@ -72,18 +72,6 @@ func (r *remoteControlPlane) DeleteAppSecret(ctx context.Context, app string, re | |||||
| 72 | return r.client.DeleteAppSecret(ctx, app, req) | 72 | return r.client.DeleteAppSecret(ctx, app, req) | ||
| 73 | } | 73 | } | ||
| 74 | 74 | ||||
| 75 | - | func (r *remoteControlPlane) ListSecrets(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) { | |||
| 76 | - | return r.client.ListSecretsWithAttachments(ctx, secretDomain) | |||
| 77 | - | } | |||
| 78 | - | ||||
| 79 | - | func (r *remoteControlPlane) SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error { | |||
| 80 | - | return r.client.SetSecrets(ctx, secretDomain, secrets) | |||
| 81 | - | } | |||
| 82 | - | ||||
| 83 | - | func (r *remoteControlPlane) DeleteSecret(ctx context.Context, secretDomain, key string) error { | |||
| 84 | - | return r.client.DeleteSecret(ctx, secretDomain, key) | |||
| 85 | - | } | |||
| 86 | - | ||||
| 87 | func (r *remoteControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) { | 75 | func (r *remoteControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) { | ||
| 88 | return r.client.GetStatus(ctx) | 76 | return r.client.GetStatus(ctx) | ||
| 89 | } | 77 | } | ||
M internal/adapters/in/cli/controlplane_resolver.go +0 -4
| @@ -51,10 +51,6 @@ func newRemoteControlPlaneHandle(target *remote.ResolvedRemote) *controlPlaneHan | |||||
| 51 | return &controlPlaneHandle{plane: NewRemoteControlPlane(client), isRemote: true} | 51 | return &controlPlaneHandle{plane: NewRemoteControlPlane(client), isRemote: true} | ||
| 52 | } | 52 | } | ||
| 53 | 53 | ||||
| 54 | - | func resolveControlPlaneForDomain(ctx context.Context, _ string) (*controlPlaneHandle, error) { | |||
| 55 | - | return resolveControlPlaneWithInference(ctx, inferExplicitTarget) | |||
| 56 | - | } | |||
| 57 | - | ||||
| 58 | func resolveControlPlaneForRepository(ctx context.Context, repository string) (*controlPlaneHandle, error) { | 54 | func resolveControlPlaneForRepository(ctx context.Context, repository string) (*controlPlaneHandle, error) { | ||
| 59 | return resolveControlPlaneWithInference(ctx, func(ctx context.Context) (*remote.ResolvedRemote, error) { | 55 | return resolveControlPlaneWithInference(ctx, func(ctx context.Context) (*remote.ResolvedRemote, error) { | ||
| 60 | return inferRemoteForRepository(ctx, repository) | 56 | return inferRemoteForRepository(ctx, repository) | ||
A internal/adapters/in/cli/daemon.go +29 -0
| @@ -0,0 +1,29 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import "github.com/spf13/cobra" | |||
| 4 | + | ||||
| 5 | + | // newDaemonCmd groups commands that inspect or operate the Gordon daemon | |||
| 6 | + | // itself rather than the apps it runs. They target the local daemon or the | |||
| 7 | + | // one selected with --remote. | |||
| 8 | + | func newDaemonCmd() *cobra.Command { | |||
| 9 | + | cmd := &cobra.Command{ | |||
| 10 | + | Use: "daemon", | |||
| 11 | + | Short: "Inspect and operate the Gordon daemon", | |||
| 12 | + | Long: `Inspect and operate the Gordon daemon: status, process logs, | |||
| 13 | + | configuration, TLS, traffic, and networks. | |||
| 14 | + | ||||
| 15 | + | Targets the local daemon, or the one selected with --remote.`, | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | cmd.AddCommand( | |||
| 19 | + | newStatusCmd(), | |||
| 20 | + | newLogsCmd(), | |||
| 21 | + | newReloadCmd(), | |||
| 22 | + | newConfigCmd(), | |||
| 23 | + | newTLSCmd(), | |||
| 24 | + | newTrafficCmd(), | |||
| 25 | + | newNetworksCmd(), | |||
| 26 | + | ) | |||
| 27 | + | ||||
| 28 | + | return cmd | |||
| 29 | + | } | |||
M internal/adapters/in/cli/images.go +4 -3
| @@ -69,10 +69,11 @@ var imagesListTableColumns = []components.TableColumn{ | |||||
| 69 | func newImagesCmd() *cobra.Command { | 69 | func newImagesCmd() *cobra.Command { | ||
| 70 | cmd := &cobra.Command{ | 70 | cmd := &cobra.Command{ | ||
| 71 | Use: "images", | 71 | Use: "images", | ||
| 72 | - | Short: "List and prune images", | 72 | + | Short: "Push, list, and prune images", |
| 73 | - | Long: `Inspect and clean up runtime and registry images through the selected daemon.`, | 73 | + | Long: `Push, inspect, and clean up runtime and registry images through the selected daemon.`, |
| 74 | } | 74 | } | ||
| 75 | 75 | ||||
| 76 | + | cmd.AddCommand(newPushCmd()) | |||
| 76 | cmd.AddCommand(newImagesListCmd()) | 77 | cmd.AddCommand(newImagesListCmd()) | ||
| 77 | cmd.AddCommand(newImagesPruneCmd()) | 78 | cmd.AddCommand(newImagesPruneCmd()) | ||
| 78 | cmd.AddCommand(newImagesTagsCmd()) | 79 | cmd.AddCommand(newImagesTagsCmd()) | ||
| @@ -144,7 +145,7 @@ func newImagesTagsCmd() *cobra.Command { | |||||
| 144 | Examples: | 145 | Examples: | ||
| 145 | gordon images tags myapp | 146 | gordon images tags myapp | ||
| 146 | gordon images tags myapp --json | 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 | Args: cobra.ExactArgs(1), | 149 | Args: cobra.ExactArgs(1), | ||
| 149 | RunE: func(cmd *cobra.Command, args []string) error { | 150 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 150 | ctx := cmd.Context() | 151 | ctx := cmd.Context() | ||
M internal/adapters/in/cli/json_parity_test.go +3 -4
| @@ -12,7 +12,6 @@ func TestAllListCommands_AcceptJSONFlag(t *testing.T) { | |||||
| 12 | name string | 12 | name string | ||
| 13 | builder func() *cobra.Command | 13 | builder func() *cobra.Command | ||
| 14 | }{ | 14 | }{ | ||
| 15 | - | {"secrets list", newSecretsListCmd}, | |||
| 16 | {"images list", newImagesListCmd}, | 15 | {"images list", newImagesListCmd}, | ||
| 17 | {"backup list", newBackupListCmd}, | 16 | {"backup list", newBackupListCmd}, | ||
| 18 | {"backup volumes list", newVolumeBackupListCmd}, | 17 | {"backup volumes list", newVolumeBackupListCmd}, | ||
| @@ -20,10 +19,10 @@ func TestAllListCommands_AcceptJSONFlag(t *testing.T) { | |||||
| 20 | {"backup volumes status", newVolumeBackupStatusCmd}, | 19 | {"backup volumes status", newVolumeBackupStatusCmd}, | ||
| 21 | {"auth token list", newTokenListCmd}, | 20 | {"auth token list", newTokenListCmd}, | ||
| 22 | {"remotes list", newRemotesListCmd}, | 21 | {"remotes list", newRemotesListCmd}, | ||
| 23 | - | {"config show", newConfigShowCmd}, | 22 | + | {"daemon config show", newConfigShowCmd}, |
| 24 | - | {"networks list", newNetworksListCmd}, | 23 | + | {"daemon networks", newNetworksCmd}, |
| 25 | {"images tags", newImagesTagsCmd}, | 24 | {"images tags", newImagesTagsCmd}, | ||
| 26 | - | {"tls status", newTLSStatusCmd}, | 25 | + | {"daemon tls", newTLSCmd}, |
| 27 | } | 26 | } | ||
| 28 | 27 | ||||
| 29 | for _, tc := range commands { | 28 | for _, tc := range commands { | ||
M internal/adapters/in/cli/json_test.go +0 -25
| @@ -10,19 +10,9 @@ import ( | |||||
| 10 | "github.com/stretchr/testify/require" | 10 | "github.com/stretchr/testify/require" | ||
| 11 | 11 | ||||
| 12 | "github.com/bnema/gordon/internal/adapters/dto" | 12 | "github.com/bnema/gordon/internal/adapters/dto" | ||
| 13 | - | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |||
| 14 | "github.com/bnema/gordon/internal/domain" | 13 | "github.com/bnema/gordon/internal/domain" | ||
| 15 | ) | 14 | ) | ||
| 16 | 15 | ||||
| 17 | - | func TestSecretsList_JSONFlag_Accepted(t *testing.T) { | |||
| 18 | - | cmd := newSecretsListCmd() | |||
| 19 | - | f := cmd.Flags().Lookup("json") | |||
| 20 | - | assert.NotNil(t, f) | |||
| 21 | - | if f != nil { | |||
| 22 | - | assert.Equal(t, "false", f.DefValue) | |||
| 23 | - | } | |||
| 24 | - | } | |||
| 25 | - | ||||
| 26 | func TestImagesList_JSONFlag_Accepted(t *testing.T) { | 16 | func TestImagesList_JSONFlag_Accepted(t *testing.T) { | ||
| 27 | cmd := newImagesListCmd() | 17 | cmd := newImagesListCmd() | ||
| 28 | f := cmd.Flags().Lookup("json") | 18 | f := cmd.Flags().Lookup("json") | ||
| @@ -147,21 +137,6 @@ func TestRemotesList_JSONShape_RoundTripsRemoteObjects(t *testing.T) { | |||||
| 147 | assert.True(t, got[0].InsecureTLS) | 137 | assert.True(t, got[0].InsecureTLS) | ||
| 148 | } | 138 | } | ||
| 149 | 139 | ||||
| 150 | - | func TestSecretsList_JSONShape_RoundTripsPayload(t *testing.T) { | |||
| 151 | - | payload := remote.SecretsListResult{ | |||
| 152 | - | Domain: "app.example.com", | |||
| 153 | - | Keys: []string{"API_KEY"}, | |||
| 154 | - | } | |||
| 155 | - | ||||
| 156 | - | encoded, err := json.Marshal(payload) | |||
| 157 | - | require.NoError(t, err) | |||
| 158 | - | ||||
| 159 | - | var got remote.SecretsListResult | |||
| 160 | - | require.NoError(t, json.Unmarshal(encoded, &got)) | |||
| 161 | - | assert.Equal(t, payload.Domain, got.Domain) | |||
| 162 | - | assert.Equal(t, payload.Keys, got.Keys) | |||
| 163 | - | } | |||
| 164 | - | ||||
| 165 | func TestWriteJSON_ProducesValidIndentedJSON(t *testing.T) { | 140 | func TestWriteJSON_ProducesValidIndentedJSON(t *testing.T) { | ||
| 166 | var out bytes.Buffer | 141 | var out bytes.Buffer | ||
| 167 | err := writeJSON(&out, map[string]any{"ok": true}) | 142 | err := writeJSON(&out, map[string]any{"ok": true}) | ||
M internal/adapters/in/cli/local.go +0 -61
| @@ -4,25 +4,18 @@ package cli | |||||
| 4 | import ( | 4 | import ( | ||
| 5 | "context" | 5 | "context" | ||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | - | "path/filepath" | |||
| 8 | "strings" | 7 | "strings" | ||
| 9 | 8 | ||||
| 10 | - | "github.com/bnema/zerowrap" | |||
| 11 | "github.com/spf13/viper" | 9 | "github.com/spf13/viper" | ||
| 12 | 10 | ||||
| 13 | - | "github.com/bnema/gordon/internal/adapters/out/domainsecrets" | |||
| 14 | "github.com/bnema/gordon/internal/app" | 11 | "github.com/bnema/gordon/internal/app" | ||
| 15 | "github.com/bnema/gordon/internal/boundaries/in" | 12 | "github.com/bnema/gordon/internal/boundaries/in" | ||
| 16 | - | "github.com/bnema/gordon/internal/boundaries/out" | |||
| 17 | - | "github.com/bnema/gordon/internal/domain" | |||
| 18 | "github.com/bnema/gordon/internal/usecase/config" | 13 | "github.com/bnema/gordon/internal/usecase/config" | ||
| 19 | - | secretsSvc "github.com/bnema/gordon/internal/usecase/secrets" | |||
| 20 | ) | 14 | ) | ||
| 21 | 15 | ||||
| 22 | // LocalServices provides direct access to local services for CLI operations. | 16 | // LocalServices provides direct access to local services for CLI operations. | ||
| 23 | type LocalServices struct { | 17 | type LocalServices struct { | ||
| 24 | configSvc in.ConfigService | 18 | configSvc in.ConfigService | ||
| 25 | - | secretSvc in.SecretService | |||
| 26 | dataDir string | 19 | dataDir string | ||
| 27 | tlsEnabled bool | 20 | tlsEnabled bool | ||
| 28 | } | 21 | } | ||
| @@ -32,11 +25,6 @@ func (l *LocalServices) GetConfigService() in.ConfigService { | |||||
| 32 | return l.configSvc | 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 | // GetDataDir returns the data directory. | 28 | // GetDataDir returns the data directory. | ||
| 41 | func (l *LocalServices) GetDataDir() string { | 29 | func (l *LocalServices) GetDataDir() string { | ||
| 42 | return l.dataDir | 30 | return l.dataDir | ||
| @@ -67,8 +55,6 @@ func GetLocalServices(cliConfigPath string) (*LocalServices, error) { | |||||
| 67 | // Config file not found is OK for some operations | 55 | // Config file not found is OK for some operations | ||
| 68 | } | 56 | } | ||
| 69 | 57 | ||||
| 70 | - | log := zerowrap.New(cliLogConfig) | |||
| 71 | - | ||||
| 72 | // Create config service (without event bus for CLI operations) | 58 | // Create config service (without event bus for CLI operations) | ||
| 73 | configSvc := config.NewService(v, nil) | 59 | configSvc := config.NewService(v, nil) | ||
| 74 | ctx := context.Background() | 60 | ctx := context.Background() | ||
| @@ -76,27 +62,13 @@ func GetLocalServices(cliConfigPath string) (*LocalServices, error) { | |||||
| 76 | return nil, fmt.Errorf("failed to load configuration: %w", err) | 62 | return nil, fmt.Errorf("failed to load configuration: %w", err) | ||
| 77 | } | 63 | } | ||
| 78 | 64 | ||||
| 79 | - | // Determine data directory and env directory | |||
| 80 | dataDir := v.GetString("server.data_dir") | 65 | dataDir := v.GetString("server.data_dir") | ||
| 81 | if dataDir == "" { | 66 | if dataDir == "" { | ||
| 82 | dataDir = app.DefaultDataDir() | 67 | dataDir = app.DefaultDataDir() | ||
| 83 | } | 68 | } | ||
| 84 | 69 | ||||
| 85 | - | envDir := v.GetString("env.dir") | |||
| 86 | - | if envDir == "" { | |||
| 87 | - | envDir = filepath.Join(dataDir, "env") | |||
| 88 | - | } | |||
| 89 | - | ||||
| 90 | - | // Create domain secret store using configured backend (pass or file-based) | |||
| 91 | - | domainSecretStore, err := createLocalDomainSecretStore(v, envDir, log) | |||
| 92 | - | if err != nil { | |||
| 93 | - | return nil, fmt.Errorf("failed to create domain secret store: %w", err) | |||
| 94 | - | } | |||
| 95 | - | secretSvc := secretsSvc.NewService(domainSecretStore, log, nil) | |||
| 96 | - | ||||
| 97 | return &LocalServices{ | 70 | return &LocalServices{ | ||
| 98 | configSvc: configSvc, | 71 | configSvc: configSvc, | ||
| 99 | - | secretSvc: secretSvc, | |||
| 100 | dataDir: dataDir, | 72 | dataDir: dataDir, | ||
| 101 | tlsEnabled: hasLocalTLSCapableEntrypoint(v), | 73 | tlsEnabled: hasLocalTLSCapableEntrypoint(v), | ||
| 102 | }, nil | 74 | }, nil | ||
| @@ -115,36 +87,3 @@ func hasLocalTLSCapableEntrypoint(v *viper.Viper) bool { | |||||
| 115 | } | 87 | } | ||
| 116 | return false | 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 | import ( | 3 | import ( | ||
| 4 | "testing" | 4 | "testing" | ||
| 5 | 5 | ||||
| 6 | - | "github.com/bnema/zerowrap" | |||
| 7 | "github.com/spf13/viper" | 6 | "github.com/spf13/viper" | ||
| 8 | "github.com/stretchr/testify/require" | 7 | "github.com/stretchr/testify/require" | ||
| 9 | - | ||||
| 10 | - | "github.com/bnema/gordon/internal/adapters/out/domainsecrets" | |||
| 11 | - | "github.com/bnema/gordon/internal/domain" | |||
| 12 | ) | 8 | ) | ||
| 13 | 9 | ||||
| 14 | - | func TestResolveLocalSecretsBackend(t *testing.T) { | |||
| 15 | - | t.Run("auth backend takes precedence", func(t *testing.T) { | |||
| 16 | - | v := viper.New() | |||
| 17 | - | v.Set("auth.secrets_backend", "pass") | |||
| 18 | - | v.Set("secrets.backend", "unsafe") | |||
| 19 | - | ||||
| 20 | - | got := resolveLocalSecretsBackend(v) | |||
| 21 | - | require.Equal(t, domain.SecretsBackendPass, got) | |||
| 22 | - | }) | |||
| 23 | - | ||||
| 24 | - | t.Run("falls back to legacy secrets backend key", func(t *testing.T) { | |||
| 25 | - | v := viper.New() | |||
| 26 | - | v.Set("secrets.backend", "pass") | |||
| 27 | - | ||||
| 28 | - | got := resolveLocalSecretsBackend(v) | |||
| 29 | - | require.Equal(t, domain.SecretsBackendPass, got) | |||
| 30 | - | }) | |||
| 31 | - | ||||
| 32 | - | t.Run("defaults to unsafe for unknown backend", func(t *testing.T) { | |||
| 33 | - | v := viper.New() | |||
| 34 | - | v.Set("auth.secrets_backend", "wat") | |||
| 35 | - | ||||
| 36 | - | got := resolveLocalSecretsBackend(v) | |||
| 37 | - | require.Equal(t, domain.SecretsBackendUnsafe, got) | |||
| 38 | - | }) | |||
| 39 | - | } | |||
| 40 | - | ||||
| 41 | func TestHasLocalTLSCapableEntrypoint(t *testing.T) { | 10 | func TestHasLocalTLSCapableEntrypoint(t *testing.T) { | ||
| 42 | for _, tt := range []struct { | 11 | for _, tt := range []struct { | ||
| 43 | name string | 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 | return _c | 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 | // DeployApp provides a mock function for the type MockControlPlane | 245 | // DeployApp provides a mock function for the type MockControlPlane | ||
| 309 | func (_mock *MockControlPlane) DeployApp(ctx context.Context, app string, req dto.AppDeployRequest) (*dto.AppDeployResponse, string, error) { | 246 | func (_mock *MockControlPlane) DeployApp(ctx context.Context, app string, req dto.AppDeployRequest) (*dto.AppDeployResponse, string, error) { | ||
| 310 | ret := _mock.Called(ctx, app, req) | 247 | ret := _mock.Called(ctx, app, req) | ||
| @@ -1035,74 +972,6 @@ func (_c *MockControlPlane_ListNetworks_Call) RunAndReturn(run func(ctx context. | |||||
| 1035 | return _c | 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 | // ListTags provides a mock function for the type MockControlPlane | 975 | // ListTags provides a mock function for the type MockControlPlane | ||
| 1107 | func (_mock *MockControlPlane) ListTags(ctx context.Context, repository string) ([]string, error) { | 976 | func (_mock *MockControlPlane) ListTags(ctx context.Context, repository string) ([]string, error) { | ||
| 1108 | ret := _mock.Called(ctx, repository) | 977 | ret := _mock.Called(ctx, repository) | ||
| @@ -1877,69 +1746,6 @@ func (_c *MockControlPlane_SetAppSecrets_Call) RunAndReturn(run func(ctx context | |||||
| 1877 | return _c | 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 | // ShowApp provides a mock function for the type MockControlPlane | 1749 | // ShowApp provides a mock function for the type MockControlPlane | ||
| 1944 | func (_mock *MockControlPlane) ShowApp(ctx context.Context, app string) (*dto.AppShowResponse, error) { | 1750 | func (_mock *MockControlPlane) ShowApp(ctx context.Context, app string) (*dto.AppShowResponse, error) { | ||
| 1945 | ret := _mock.Called(ctx, app) | 1751 | ret := _mock.Called(ctx, app) | ||
M internal/adapters/in/cli/networks.go +5 -16
| @@ -12,29 +12,18 @@ import ( | |||||
| 12 | ) | 12 | ) | ||
| 13 | 13 | ||||
| 14 | func newNetworksCmd() *cobra.Command { | 14 | func newNetworksCmd() *cobra.Command { | ||
| 15 | - | cmd := &cobra.Command{ | |||
| 16 | - | Use: "networks", | |||
| 17 | - | Short: "Inspect Gordon-managed networks", | |||
| 18 | - | } | |||
| 19 | - | ||||
| 20 | - | cmd.AddCommand(newNetworksListCmd()) | |||
| 21 | - | ||||
| 22 | - | return cmd | |||
| 23 | - | } | |||
| 24 | - | ||||
| 25 | - | func newNetworksListCmd() *cobra.Command { | |||
| 26 | var jsonOut bool | 15 | var jsonOut bool | ||
| 27 | 16 | ||||
| 28 | cmd := &cobra.Command{ | 17 | cmd := &cobra.Command{ | ||
| 29 | - | Use: "list", | 18 | + | Use: "networks", |
| 30 | - | Short: "List Gordon-managed Docker networks", | 19 | + | Short: "List Gordon-managed networks", |
| 31 | Long: `Display Docker networks managed by Gordon, including which containers | 20 | Long: `Display Docker networks managed by Gordon, including which containers | ||
| 32 | are connected to each network. | 21 | are connected to each network. | ||
| 33 | 22 | ||||
| 34 | Examples: | 23 | Examples: | ||
| 35 | - | gordon networks list | 24 | + | gordon daemon networks |
| 36 | - | gordon networks list --json | 25 | + | gordon daemon networks --json |
| 37 | - | gordon networks list --remote https://gordon.mydomain.com --token $TOKEN`, | 26 | + | gordon daemon networks --remote prod`, |
| 38 | RunE: func(cmd *cobra.Command, _ []string) error { | 27 | RunE: func(cmd *cobra.Command, _ []string) error { | ||
| 39 | ctx := cmd.Context() | 28 | ctx := cmd.Context() | ||
| 40 | handle, err := resolveControlPlane(cliConfigPath) | 29 | handle, err := resolveControlPlane(cliConfigPath) | ||
M internal/adapters/in/cli/push.go +4 -4
| @@ -66,10 +66,10 @@ deploys. Deploy separately with ` + "`gordon apps deploy`" + ` after applying | |||||
| 66 | the manifest that references the pushed tag. | 66 | the manifest that references the pushed tag. | ||
| 67 | 67 | ||||
| 68 | Examples: | 68 | Examples: | ||
| 69 | - | gordon push myapp --build --remote ... | 69 | + | gordon images push myapp --build --remote ... |
| 70 | - | gordon push myapp:v1.2.3 --tag v1.2.3 --remote ... | 70 | + | gordon images push myapp:v1.2.3 --tag v1.2.3 --remote ... |
| 71 | - | gordon push registry.example.com/myapp:v1.2.3 --build --remote ... | 71 | + | gordon images push registry.example.com/myapp:v1.2.3 --build --remote ... |
| 72 | - | gordon push --build --build-arg CGO_ENABLED=0 --remote ...`, | 72 | + | gordon images push --build --build-arg CGO_ENABLED=0 --remote ...`, |
| 73 | Args: cobra.MaximumNArgs(1), | 73 | Args: cobra.MaximumNArgs(1), | ||
| 74 | RunE: func(cmd *cobra.Command, args []string) error { | 74 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 75 | var imageArg string | 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 | "Podman and other runtimes are not supported for --build.\n" + | 181 | "Podman and other runtimes are not supported for --build.\n" + | ||
| 182 | "Build the image manually and push with:\n" + | 182 | "Build the image manually and push with:\n" + | ||
| 183 | " podman build -t <image> .\n" + | 183 | " podman build -t <image> .\n" + | ||
| 184 | - | " gordon push <image>", | 184 | + | " gordon images push <image>", |
| 185 | ) | 185 | ) | ||
| 186 | } | 186 | } | ||
| 187 | cmd := exec.CommandContext(ctx, "docker", args...) // #nosec G204 | 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 | return nil, fmt.Errorf("request failed after retries") | 481 | return nil, fmt.Errorf("request failed after retries") | ||
| 482 | } | 482 | } | ||
| 483 | 483 | ||||
| 484 | - | // Secrets API | |||
| 485 | - | ||||
| 486 | - | // SecretsListResult contains domain secret keys. | |||
| 487 | - | type SecretsListResult struct { | |||
| 488 | - | Domain string `json:"domain"` | |||
| 489 | - | Keys []string `json:"keys"` | |||
| 490 | - | } | |||
| 491 | - | ||||
| 492 | - | // ListSecretsWithAttachments returns domain secret keys. Attachment | |||
| 493 | - | // containers are retired: the daemon answers without attachment keys. | |||
| 494 | - | func (c *Client) ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*SecretsListResult, error) { | |||
| 495 | - | resp, err := c.request(ctx, http.MethodGet, "/secrets/"+url.PathEscape(secretDomain), nil) | |||
| 496 | - | if err != nil { | |||
| 497 | - | return nil, err | |||
| 498 | - | } | |||
| 499 | - | ||||
| 500 | - | var result SecretsListResult | |||
| 501 | - | if err := parseResponse(resp, &result); err != nil { | |||
| 502 | - | return nil, err | |||
| 503 | - | } | |||
| 504 | - | ||||
| 505 | - | return &result, nil | |||
| 506 | - | } | |||
| 507 | - | ||||
| 508 | - | // SetSecrets sets secrets for a domain. | |||
| 509 | - | func (c *Client) SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error { | |||
| 510 | - | resp, err := c.request(ctx, http.MethodPost, "/secrets/"+url.PathEscape(secretDomain), secrets) | |||
| 511 | - | if err != nil { | |||
| 512 | - | return err | |||
| 513 | - | } | |||
| 514 | - | return parseResponse(resp, nil) | |||
| 515 | - | } | |||
| 516 | - | ||||
| 517 | - | // DeleteSecret removes a secret from a domain. | |||
| 518 | - | func (c *Client) DeleteSecret(ctx context.Context, secretDomain, key string) error { | |||
| 519 | - | resp, err := c.request(ctx, http.MethodDelete, "/secrets/"+url.PathEscape(secretDomain)+"/"+url.PathEscape(key), nil) | |||
| 520 | - | if err != nil { | |||
| 521 | - | return err | |||
| 522 | - | } | |||
| 523 | - | return parseResponse(resp, nil) | |||
| 524 | - | } | |||
| 525 | - | ||||
| 526 | // Status API | 484 | // Status API | ||
| 527 | 485 | ||||
| 528 | // Status represents the Gordon server status. | 486 | // Status represents the Gordon server status. | ||
M internal/adapters/in/cli/remote_inference.go +0 -42
| @@ -129,39 +129,6 @@ func isRemoteNotFoundError(err error) bool { | |||||
| 129 | return errors.Is(err, domain.ErrRouteNotFound) | 129 | return errors.Is(err, domain.ErrRouteNotFound) | ||
| 130 | } | 130 | } | ||
| 131 | 131 | ||||
| 132 | - | func resolveExplicitRemote() (*remote.ResolvedRemote, bool, error) { | |||
| 133 | - | target, ok := resolveExplicitTargetName() | |||
| 134 | - | if !ok { | |||
| 135 | - | return nil, false, nil | |||
| 136 | - | } | |||
| 137 | - | ||||
| 138 | - | if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") { | |||
| 139 | - | return &remote.ResolvedRemote{ | |||
| 140 | - | URL: target, | |||
| 141 | - | Token: resolveTokenForTarget("", remote.RemoteEntry{}), | |||
| 142 | - | InsecureTLS: resolveInsecureForTarget("", remote.RemoteEntry{}), | |||
| 143 | - | }, true, nil | |||
| 144 | - | } | |||
| 145 | - | ||||
| 146 | - | remotes, err := remote.LoadRemotes("") | |||
| 147 | - | if err != nil { | |||
| 148 | - | return nil, false, err | |||
| 149 | - | } | |||
| 150 | - | ||||
| 151 | - | if remotes != nil { | |||
| 152 | - | if entry, found := remotes.Remotes[target]; found { | |||
| 153 | - | return &remote.ResolvedRemote{ | |||
| 154 | - | Name: target, | |||
| 155 | - | URL: entry.URL, | |||
| 156 | - | Token: resolveTokenForTarget(target, entry), | |||
| 157 | - | InsecureTLS: resolveInsecureForTarget(target, entry), | |||
| 158 | - | }, true, nil | |||
| 159 | - | } | |||
| 160 | - | } | |||
| 161 | - | ||||
| 162 | - | return nil, false, nil | |||
| 163 | - | } | |||
| 164 | - | ||||
| 165 | func resolveExplicitTargetName() (string, bool) { | 132 | func resolveExplicitTargetName() (string, bool) { | ||
| 166 | if target := strings.TrimSpace(remoteFlag); target != "" { | 133 | if target := strings.TrimSpace(remoteFlag); target != "" { | ||
| 167 | return target, true | 134 | return target, true | ||
| @@ -203,12 +170,3 @@ func resolveInsecureForTarget(name string, entry remote.RemoteEntry) bool { | |||||
| 203 | func newInferenceTargetClient(name string, entry remote.RemoteEntry) *remote.Client { | 170 | func newInferenceTargetClient(name string, entry remote.RemoteEntry) *remote.Client { | ||
| 204 | return remote.NewClient(entry.URL, remoteClientOptions(resolveTokenForTarget(name, entry), resolveInsecureForTarget(name, entry))...) | 171 | return remote.NewClient(entry.URL, remoteClientOptions(resolveTokenForTarget(name, entry), resolveInsecureForTarget(name, entry))...) | ||
| 205 | } | 172 | } | ||
| 206 | - | ||||
| 207 | - | // inferExplicitTarget resolves --remote/GORDON_REMOTE without probing saved remotes. | |||
| 208 | - | func inferExplicitTarget(_ context.Context) (*remote.ResolvedRemote, error) { | |||
| 209 | - | resolved, ok, err := resolveExplicitRemote() | |||
| 210 | - | if err != nil || !ok { | |||
| 211 | - | return nil, err | |||
| 212 | - | } | |||
| 213 | - | return resolved, nil | |||
| 214 | - | } | |||
M internal/adapters/in/cli/remotes.go +1 -1
| @@ -296,7 +296,7 @@ without needing to specify --remote. | |||||
| 296 | 296 | ||||
| 297 | Examples: | 297 | Examples: | ||
| 298 | gordon remotes use prod | 298 | gordon remotes use prod | ||
| 299 | - | gordon status # Uses prod remote automatically`, | 299 | + | gordon daemon status # Uses prod remote automatically`, |
| 300 | Args: cobra.ExactArgs(1), | 300 | Args: cobra.ExactArgs(1), | ||
| 301 | RunE: func(cmd *cobra.Command, args []string) error { | 301 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 302 | name := args[0] | 302 | name := args[0] | ||
M internal/adapters/in/cli/root.go +16 -57
| @@ -55,11 +55,11 @@ func NewRootCmd() *cobra.Command { | |||||
| 55 | container registry, an app runtime, and a reverse proxy. | 55 | container registry, an app runtime, and a reverse proxy. | ||
| 56 | 56 | ||||
| 57 | Applications are declared in standalone TOML files and managed with | 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 | Commands are organized by where they run: | 60 | Commands are organized by where they run: | ||
| 61 | Server-only: Run on the machine hosting Gordon (serve, auth, ca) | 61 | Server-only: Run on the machine hosting Gordon (serve, auth, ca) | ||
| 62 | - | Management: Work locally or remotely via --remote flag (apps, secrets, etc.) | 62 | + | Management: Work locally or remotely via --remote flag (apps, daemon, images, etc.) |
| 63 | Client-only: CLI utilities that don't require a running Gordon server`, | 63 | Client-only: CLI utilities that don't require a running Gordon server`, | ||
| 64 | } | 64 | } | ||
| 65 | 65 | ||||
| @@ -88,57 +88,16 @@ Commands are organized by where they run: | |||||
| 88 | caCmd.GroupID = groupServer | 88 | caCmd.GroupID = groupServer | ||
| 89 | rootCmd.AddCommand(caCmd) | 89 | rootCmd.AddCommand(caCmd) | ||
| 90 | 90 | ||||
| 91 | - | secretsCmd := newSecretsCmd() | 91 | + | for _, cmd := range []*cobra.Command{ |
| 92 | - | secretsCmd.GroupID = groupManage | 92 | + | newAppsCmd(), |
| 93 | - | rootCmd.AddCommand(secretsCmd) | 93 | + | newBackupCmd(), |
| 94 | - | 94 | + | newDaemonCmd(), | |
| 95 | - | pushCmd := newPushCmd() | 95 | + | newImagesCmd(), |
| 96 | - | pushCmd.GroupID = groupManage | 96 | + | newVolumesCmd(), |
| 97 | - | rootCmd.AddCommand(pushCmd) | 97 | + | } { |
| 98 | - | 98 | + | cmd.GroupID = groupManage | |
| 99 | - | reloadCmd := newReloadCmd() | 99 | + | rootCmd.AddCommand(cmd) |
| 100 | - | reloadCmd.GroupID = groupManage | 100 | + | } |
| 101 | - | rootCmd.AddCommand(reloadCmd) | |||
| 102 | - | ||||
| 103 | - | logsCmd := newLogsCmd() | |||
| 104 | - | logsCmd.GroupID = groupManage | |||
| 105 | - | rootCmd.AddCommand(logsCmd) | |||
| 106 | - | ||||
| 107 | - | statusCmd := newStatusCmd() | |||
| 108 | - | statusCmd.GroupID = groupManage | |||
| 109 | - | rootCmd.AddCommand(statusCmd) | |||
| 110 | - | ||||
| 111 | - | backupCmd := newBackupCmd() | |||
| 112 | - | backupCmd.GroupID = groupManage | |||
| 113 | - | rootCmd.AddCommand(backupCmd) | |||
| 114 | - | ||||
| 115 | - | imagesCmd := newImagesCmd() | |||
| 116 | - | imagesCmd.GroupID = groupManage | |||
| 117 | - | rootCmd.AddCommand(imagesCmd) | |||
| 118 | - | ||||
| 119 | - | configCmd := newConfigCmd() | |||
| 120 | - | configCmd.GroupID = groupManage | |||
| 121 | - | rootCmd.AddCommand(configCmd) | |||
| 122 | - | ||||
| 123 | - | networksCmd := newNetworksCmd() | |||
| 124 | - | networksCmd.GroupID = groupManage | |||
| 125 | - | rootCmd.AddCommand(networksCmd) | |||
| 126 | - | ||||
| 127 | - | volumesCmd := newVolumesCmd() | |||
| 128 | - | volumesCmd.GroupID = groupManage | |||
| 129 | - | rootCmd.AddCommand(volumesCmd) | |||
| 130 | - | ||||
| 131 | - | tlsCmd := newTLSCmd() | |||
| 132 | - | tlsCmd.GroupID = groupManage | |||
| 133 | - | rootCmd.AddCommand(tlsCmd) | |||
| 134 | - | ||||
| 135 | - | trafficCmd := newTrafficCmd() | |||
| 136 | - | trafficCmd.GroupID = groupManage | |||
| 137 | - | rootCmd.AddCommand(trafficCmd) | |||
| 138 | - | ||||
| 139 | - | appsCmd := newAppsCmd() | |||
| 140 | - | appsCmd.GroupID = groupManage | |||
| 141 | - | rootCmd.AddCommand(appsCmd) | |||
| 142 | 101 | ||||
| 143 | // Client-only commands (no server needed) | 102 | // Client-only commands (no server needed) | ||
| 144 | remotesCmd := newRemotesCmd() | 103 | remotesCmd := newRemotesCmd() | ||
| @@ -257,12 +216,12 @@ Application workload output is read with gordon apps logs APP --service SVC, | |||||
| 257 | which resolves the app's active container through the daemon. | 216 | which resolves the app's active container through the daemon. | ||
| 258 | 217 | ||||
| 259 | Examples: | 218 | Examples: | ||
| 260 | - | gordon logs # Gordon process logs | 219 | + | gordon daemon logs # Gordon process logs |
| 261 | - | gordon logs -f # Follow process logs | 220 | + | gordon daemon logs -f # Follow process logs |
| 262 | - | gordon logs -n 100 # Last 100 lines | 221 | + | gordon daemon logs -n 100 # Last 100 lines |
| 263 | 222 | ||||
| 264 | Remote mode: | 223 | Remote mode: | ||
| 265 | - | gordon logs --remote https://gordon.mydomain.com --token $TOKEN`, | 224 | + | gordon daemon logs --remote prod`, |
| 266 | Args: cobra.NoArgs, | 225 | Args: cobra.NoArgs, | ||
| 267 | RunE: func(cmd *cobra.Command, args []string) error { | 226 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 268 | return runLogs(cmd.Context(), logsConfigPath, follow, lines, cmd.OutOrStdout()) | 227 | return runLogs(cmd.Context(), logsConfigPath, follow, lines, cmd.OutOrStdout()) | ||
D internal/adapters/in/cli/secrets.go +0 -252
| @@ -1,252 +0,0 @@ | |||||
| 1 | - | package cli | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "context" | |||
| 5 | - | "fmt" | |||
| 6 | - | "strings" | |||
| 7 | - | ||||
| 8 | - | "github.com/bnema/gordon/internal/adapters/in/cli/ui/components" | |||
| 9 | - | "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" | |||
| 10 | - | ||||
| 11 | - | "github.com/spf13/cobra" | |||
| 12 | - | ) | |||
| 13 | - | ||||
| 14 | - | // newSecretsCmd creates the secrets command group. | |||
| 15 | - | func newSecretsCmd() *cobra.Command { | |||
| 16 | - | cmd := &cobra.Command{ | |||
| 17 | - | Use: "secrets", | |||
| 18 | - | Short: "Manage installation secrets", | |||
| 19 | - | Long: `Manage installation secrets (per-host entries such as service auth). | |||
| 20 | - | ||||
| 21 | - | Application secret VALUES are managed with gordon apps secrets and stay | |||
| 22 | - | in pass under gordon/apps/<uuid>/<service>/<name>. The commands below | |||
| 23 | - | manage the installation secret store only. | |||
| 24 | - | ||||
| 25 | - | When targeting a remote Gordon instance (via --remote flag or GORDON_REMOTE env var), | |||
| 26 | - | these commands operate on the remote server.`, | |||
| 27 | - | } | |||
| 28 | - | ||||
| 29 | - | cmd.AddCommand(newSecretsListCmd()) | |||
| 30 | - | cmd.AddCommand(newSecretsSetCmd()) | |||
| 31 | - | cmd.AddCommand(newSecretsRemoveCmd()) | |||
| 32 | - | ||||
| 33 | - | return cmd | |||
| 34 | - | } | |||
| 35 | - | ||||
| 36 | - | // newSecretsListCmd creates the secrets list command. | |||
| 37 | - | func newSecretsListCmd() *cobra.Command { | |||
| 38 | - | var jsonOut bool | |||
| 39 | - | ||||
| 40 | - | cmd := &cobra.Command{ | |||
| 41 | - | Use: "list <domain>", | |||
| 42 | - | Short: "List secrets for a domain", | |||
| 43 | - | Long: `List all secret keys configured for a domain. | |||
| 44 | - | ||||
| 45 | - | Note: Only secret keys are shown, not values (for security). | |||
| 46 | - | ||||
| 47 | - | Examples: | |||
| 48 | - | gordon secrets list app.mydomain.com | |||
| 49 | - | gordon --remote https://gordon.mydomain.com secrets list api.mydomain.com`, | |||
| 50 | - | Args: cobra.ExactArgs(1), | |||
| 51 | - | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 52 | - | return runSecretsListCmd(cmd, args, jsonOut) | |||
| 53 | - | }, | |||
| 54 | - | } | |||
| 55 | - | ||||
| 56 | - | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |||
| 57 | - | ||||
| 58 | - | return cmd | |||
| 59 | - | } | |||
| 60 | - | ||||
| 61 | - | // runSecretsListCmd executes the secrets list command. | |||
| 62 | - | func runSecretsListCmd(cmd *cobra.Command, args []string, jsonOut bool) error { | |||
| 63 | - | ctx := cmd.Context() | |||
| 64 | - | secretDomain := args[0] | |||
| 65 | - | ||||
| 66 | - | handle, err := resolveControlPlaneForDomain(ctx, secretDomain) | |||
| 67 | - | if err != nil { | |||
| 68 | - | return err | |||
| 69 | - | } | |||
| 70 | - | defer handle.close() | |||
| 71 | - | ||||
| 72 | - | keys, err := fetchSecrets(ctx, handle.plane, secretDomain) | |||
| 73 | - | if err != nil { | |||
| 74 | - | return err | |||
| 75 | - | } | |||
| 76 | - | ||||
| 77 | - | if len(keys) == 0 { | |||
| 78 | - | if jsonOut { | |||
| 79 | - | return writeJSON(cmd.OutOrStdout(), map[string]any{ | |||
| 80 | - | "domain": secretDomain, | |||
| 81 | - | "keys": []string{}, | |||
| 82 | - | }) | |||
| 83 | - | } | |||
| 84 | - | fmt.Println(styles.Theme.Muted.Render(fmt.Sprintf("No secrets configured for %s", secretDomain))) | |||
| 85 | - | return nil | |||
| 86 | - | } | |||
| 87 | - | ||||
| 88 | - | if jsonOut { | |||
| 89 | - | return writeJSON(cmd.OutOrStdout(), map[string]any{ | |||
| 90 | - | "domain": secretDomain, | |||
| 91 | - | "keys": keys, | |||
| 92 | - | }) | |||
| 93 | - | } | |||
| 94 | - | ||||
| 95 | - | title := fmt.Sprintf("Secrets for %s", secretDomain) | |||
| 96 | - | if !handle.isRemote { | |||
| 97 | - | title = fmt.Sprintf("Secrets for %s (local)", secretDomain) | |||
| 98 | - | } | |||
| 99 | - | fmt.Println(styles.Theme.Title.Render(title)) | |||
| 100 | - | fmt.Println() | |||
| 101 | - | ||||
| 102 | - | rows := buildSecretsTableRows(keys) | |||
| 103 | - | ||||
| 104 | - | table := components.NewTable( | |||
| 105 | - | components.WithColumns([]components.TableColumn{ | |||
| 106 | - | {Title: "Key", Width: 45}, | |||
| 107 | - | {Title: "Value", Width: 10}, | |||
| 108 | - | }), | |||
| 109 | - | components.WithRows(rows), | |||
| 110 | - | ) | |||
| 111 | - | ||||
| 112 | - | fmt.Println(table.View()) | |||
| 113 | - | return nil | |||
| 114 | - | } | |||
| 115 | - | ||||
| 116 | - | // fetchSecrets retrieves secret keys from the selected control plane. | |||
| 117 | - | func fetchSecrets(ctx context.Context, cp ControlPlane, secretDomain string) ([]string, error) { | |||
| 118 | - | result, err := cp.ListSecrets(ctx, secretDomain) | |||
| 119 | - | if err != nil { | |||
| 120 | - | return nil, fmt.Errorf("failed to list secrets: %w", err) | |||
| 121 | - | } | |||
| 122 | - | return result.Keys, nil | |||
| 123 | - | } | |||
| 124 | - | ||||
| 125 | - | // buildSecretsTableRows builds table rows for secret keys. | |||
| 126 | - | func buildSecretsTableRows(keys []string) [][]string { | |||
| 127 | - | var rows [][]string | |||
| 128 | - | for _, key := range keys { | |||
| 129 | - | rows = append(rows, []string{key, styles.Theme.Muted.Render("(hidden)")}) | |||
| 130 | - | } | |||
| 131 | - | return rows | |||
| 132 | - | } | |||
| 133 | - | ||||
| 134 | - | // newSecretsSetCmd creates the secrets set command. | |||
| 135 | - | func newSecretsSetCmd() *cobra.Command { | |||
| 136 | - | var fromFile string | |||
| 137 | - | var jsonOut bool | |||
| 138 | - | ||||
| 139 | - | cmd := &cobra.Command{ | |||
| 140 | - | Use: "set <domain> --from-file <path>", | |||
| 141 | - | Short: "Set installation secrets for a domain", | |||
| 142 | - | Long: `Set one or more installation secrets for a domain. | |||
| 143 | - | ||||
| 144 | - | Secrets are read from a mode 0600 file containing one KEY=value pair per line. | |||
| 145 | - | For app secret values, use gordon apps secrets instead. | |||
| 146 | - | ||||
| 147 | - | Examples: | |||
| 148 | - | gordon secrets set app.mydomain.com --from-file ./app.env`, | |||
| 149 | - | Args: cobra.ExactArgs(1), | |||
| 150 | - | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 151 | - | ctx := cmd.Context() | |||
| 152 | - | secretDomain := args[0] | |||
| 153 | - | content, err := readProtectedSecretFile(fromFile) | |||
| 154 | - | if err != nil { | |||
| 155 | - | return fmt.Errorf("read secrets: %w", err) | |||
| 156 | - | } | |||
| 157 | - | ||||
| 158 | - | secrets := make(map[string]string) | |||
| 159 | - | for _, pair := range strings.Split(content, "\n") { | |||
| 160 | - | parts := strings.SplitN(pair, "=", 2) | |||
| 161 | - | if len(parts) != 2 { | |||
| 162 | - | return fmt.Errorf("invalid format: %s (expected KEY=value)", pair) | |||
| 163 | - | } | |||
| 164 | - | secrets[parts[0]] = parts[1] | |||
| 165 | - | } | |||
| 166 | - | ||||
| 167 | - | handle, err := resolveControlPlaneForDomain(ctx, secretDomain) | |||
| 168 | - | if err != nil { | |||
| 169 | - | return err | |||
| 170 | - | } | |||
| 171 | - | defer handle.close() | |||
| 172 | - | if err := handle.plane.SetSecrets(ctx, secretDomain, secrets); err != nil { | |||
| 173 | - | return fmt.Errorf("failed to set secrets: %w", err) | |||
| 174 | - | } | |||
| 175 | - | ||||
| 176 | - | target := secretDomain | |||
| 177 | - | ||||
| 178 | - | if jsonOut { | |||
| 179 | - | return writeJSON(cmd.OutOrStdout(), map[string]any{ | |||
| 180 | - | "domain": secretDomain, "count": len(secrets), "updated": true, | |||
| 181 | - | }) | |||
| 182 | - | } | |||
| 183 | - | if len(secrets) == 1 { | |||
| 184 | - | for key := range secrets { | |||
| 185 | - | return cliWriteLine(cmd.OutOrStdout(), styles.RenderSuccess(fmt.Sprintf("Secret set: %s on %s", key, target))) | |||
| 186 | - | } | |||
| 187 | - | } | |||
| 188 | - | return cliWriteLine(cmd.OutOrStdout(), styles.RenderSuccess(fmt.Sprintf("Set %d secrets for %s", len(secrets), target))) | |||
| 189 | - | }, | |||
| 190 | - | } | |||
| 191 | - | ||||
| 192 | - | cmd.Flags().StringVar(&fromFile, "from-file", "", "Read KEY=value lines from a mode 0600 file") | |||
| 193 | - | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |||
| 194 | - | _ = cmd.MarkFlagRequired("from-file") | |||
| 195 | - | ||||
| 196 | - | return cmd | |||
| 197 | - | } | |||
| 198 | - | ||||
| 199 | - | // newSecretsRemoveCmd creates the secrets remove command. | |||
| 200 | - | func newSecretsRemoveCmd() *cobra.Command { | |||
| 201 | - | var force bool | |||
| 202 | - | ||||
| 203 | - | cmd := &cobra.Command{ | |||
| 204 | - | Use: "remove <domain> <key>", | |||
| 205 | - | Short: "Remove an installation secret", | |||
| 206 | - | Long: `Remove an installation secret from a domain. | |||
| 207 | - | ||||
| 208 | - | For app secret values, use gordon apps secrets delete instead. | |||
| 209 | - | ||||
| 210 | - | Examples: | |||
| 211 | - | gordon secrets remove app.mydomain.com OLD_API_KEY | |||
| 212 | - | gordon secrets remove app.mydomain.com OLD_API_KEY --force`, | |||
| 213 | - | Args: cobra.ExactArgs(2), | |||
| 214 | - | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 215 | - | ctx := cmd.Context() | |||
| 216 | - | secretDomain := args[0] | |||
| 217 | - | key := args[1] | |||
| 218 | - | ||||
| 219 | - | target := secretDomain | |||
| 220 | - | ||||
| 221 | - | // Confirm unless --force | |||
| 222 | - | if !force { | |||
| 223 | - | confirmed, err := components.RunConfirm( | |||
| 224 | - | fmt.Sprintf("Remove secret '%s' from %s?", key, target), | |||
| 225 | - | ) | |||
| 226 | - | if err != nil { | |||
| 227 | - | return err | |||
| 228 | - | } | |||
| 229 | - | if !confirmed { | |||
| 230 | - | fmt.Println(styles.Theme.Muted.Render("Cancelled")) | |||
| 231 | - | return nil | |||
| 232 | - | } | |||
| 233 | - | } | |||
| 234 | - | ||||
| 235 | - | handle, err := resolveControlPlaneForDomain(ctx, secretDomain) | |||
| 236 | - | if err != nil { | |||
| 237 | - | return err | |||
| 238 | - | } | |||
| 239 | - | defer handle.close() | |||
| 240 | - | if err := handle.plane.DeleteSecret(ctx, secretDomain, key); err != nil { | |||
| 241 | - | return fmt.Errorf("failed to remove secret: %w", err) | |||
| 242 | - | } | |||
| 243 | - | ||||
| 244 | - | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret removed from %s: %s", target, key))) | |||
| 245 | - | return nil | |||
| 246 | - | }, | |||
| 247 | - | } | |||
| 248 | - | ||||
| 249 | - | cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation") | |||
| 250 | - | ||||
| 251 | - | return cmd | |||
| 252 | - | } | |||
M internal/adapters/in/cli/tls.go +1 -12
| @@ -16,21 +16,10 @@ import ( | |||||
| 16 | var tlsResolveControlPlane = resolveControlPlane | 16 | var tlsResolveControlPlane = resolveControlPlane | ||
| 17 | 17 | ||||
| 18 | func newTLSCmd() *cobra.Command { | 18 | func newTLSCmd() *cobra.Command { | ||
| 19 | - | cmd := &cobra.Command{ | |||
| 20 | - | Use: "tls", | |||
| 21 | - | Short: "Inspect TLS certificate status", | |||
| 22 | - | } | |||
| 23 | - | ||||
| 24 | - | cmd.AddCommand(newTLSStatusCmd()) | |||
| 25 | - | ||||
| 26 | - | return cmd | |||
| 27 | - | } | |||
| 28 | - | ||||
| 29 | - | func newTLSStatusCmd() *cobra.Command { | |||
| 30 | var jsonOut bool | 19 | var jsonOut bool | ||
| 31 | 20 | ||||
| 32 | cmd := &cobra.Command{ | 21 | cmd := &cobra.Command{ | ||
| 33 | - | Use: "status", | 22 | + | Use: "tls", |
| 34 | Short: "Show public TLS certificate status", | 23 | Short: "Show public TLS certificate status", | ||
| 35 | RunE: func(cmd *cobra.Command, _ []string) error { | 24 | RunE: func(cmd *cobra.Command, _ []string) error { | ||
| 36 | handle, err := tlsResolveControlPlane(cliConfigPath) | 25 | handle, err := tlsResolveControlPlane(cliConfigPath) | ||
D internal/adapters/in/cli/traffic.go +0 -12
| @@ -1,12 +0,0 @@ | |||||
| 1 | - | package cli | |||
| 2 | - | ||||
| 3 | - | import "github.com/spf13/cobra" | |||
| 4 | - | ||||
| 5 | - | func newTrafficCmd() *cobra.Command { | |||
| 6 | - | cmd := &cobra.Command{ | |||
| 7 | - | Use: "traffic", | |||
| 8 | - | Short: "Inspect remote traffic plane status", | |||
| 9 | - | } | |||
| 10 | - | cmd.AddCommand(newTrafficStatusCmd()) | |||
| 11 | - | return cmd | |||
| 12 | - | } | |||
M internal/adapters/in/cli/traffic_status.go +2 -2
| @@ -13,10 +13,10 @@ import ( | |||||
| 13 | 13 | ||||
| 14 | var trafficResolveControlPlane = resolveControlPlane | 14 | var trafficResolveControlPlane = resolveControlPlane | ||
| 15 | 15 | ||||
| 16 | - | func newTrafficStatusCmd() *cobra.Command { | 16 | + | func newTrafficCmd() *cobra.Command { |
| 17 | var jsonOut bool | 17 | var jsonOut bool | ||
| 18 | cmd := &cobra.Command{ | 18 | cmd := &cobra.Command{ | ||
| 19 | - | Use: "status", | 19 | + | Use: "traffic", |
| 20 | Short: "Show traffic entrypoint, router, and counter status", | 20 | Short: "Show traffic entrypoint, router, and counter status", | ||
| 21 | Args: cobra.NoArgs, | 21 | Args: cobra.NoArgs, | ||
| 22 | RunE: func(cmd *cobra.Command, args []string) error { | 22 | RunE: func(cmd *cobra.Command, args []string) error { | ||
M internal/adapters/in/cli/traffic_test.go +2 -7
| @@ -19,19 +19,14 @@ import ( | |||||
| 19 | 19 | ||||
| 20 | func TestTrafficCommandExists(t *testing.T) { | 20 | func TestTrafficCommandExists(t *testing.T) { | ||
| 21 | cmd := NewRootCmd() | 21 | cmd := NewRootCmd() | ||
| 22 | - | traffic, _, err := cmd.Find([]string{"traffic"}) | 22 | + | traffic, _, err := cmd.Find([]string{"daemon", "traffic"}) |
| 23 | require.NoError(t, err) | 23 | require.NoError(t, err) | ||
| 24 | require.NotNil(t, traffic) | 24 | require.NotNil(t, traffic) | ||
| 25 | assert.Equal(t, "traffic", traffic.Name()) | 25 | assert.Equal(t, "traffic", traffic.Name()) | ||
| 26 | - | ||||
| 27 | - | status, _, err := cmd.Find([]string{"traffic", "status"}) | |||
| 28 | - | require.NoError(t, err) | |||
| 29 | - | require.NotNil(t, status) | |||
| 30 | - | assert.Equal(t, "status", status.Name()) | |||
| 31 | } | 26 | } | ||
| 32 | 27 | ||||
| 33 | func TestTrafficStatusRejectsArgs(t *testing.T) { | 28 | func TestTrafficStatusRejectsArgs(t *testing.T) { | ||
| 34 | - | cmd := newTrafficStatusCmd() | 29 | + | cmd := newTrafficCmd() |
| 35 | require.Error(t, cmd.Args(cmd, []string{"extra"})) | 30 | require.Error(t, cmd.Args(cmd, []string{"extra"})) | ||
| 36 | } | 31 | } | ||
| 37 | 32 | ||||
M internal/adapters/in/http/admin/handler.go +0 -110
| @@ -41,7 +41,6 @@ type Handler struct { | |||||
| 41 | volumeBackupSvc in.VolumeBackupService | 41 | volumeBackupSvc in.VolumeBackupService | ||
| 42 | imageSvc in.ImageService | 42 | imageSvc in.ImageService | ||
| 43 | healthSvc in.HealthService | 43 | healthSvc in.HealthService | ||
| 44 | - | secretSvc in.SecretService | |||
| 45 | logSvc in.LogService | 44 | logSvc in.LogService | ||
| 46 | volumeSvc in.VolumeService | 45 | volumeSvc in.VolumeService | ||
| 47 | registrySvc in.RegistryService | 46 | registrySvc in.RegistryService | ||
| @@ -115,7 +114,6 @@ type HandlerDeps struct { | |||||
| 115 | AuthSvc in.AuthService | 114 | AuthSvc in.AuthService | ||
| 116 | ContainerSvc in.ContainerService | 115 | ContainerSvc in.ContainerService | ||
| 117 | HealthSvc in.HealthService | 116 | HealthSvc in.HealthService | ||
| 118 | - | SecretSvc in.SecretService | |||
| 119 | LogSvc in.LogService | 117 | LogSvc in.LogService | ||
| 120 | RegistrySvc in.RegistryService | 118 | RegistrySvc in.RegistryService | ||
| 121 | Log zerowrap.Logger | 119 | Log zerowrap.Logger | ||
| @@ -139,7 +137,6 @@ func NewHandler(deps HandlerDeps) *Handler { | |||||
| 139 | volumeBackupSvc: deps.VolumeBackupSvc, | 137 | volumeBackupSvc: deps.VolumeBackupSvc, | ||
| 140 | imageSvc: deps.ImageSvc, | 138 | imageSvc: deps.ImageSvc, | ||
| 141 | healthSvc: deps.HealthSvc, | 139 | healthSvc: deps.HealthSvc, | ||
| 142 | - | secretSvc: deps.SecretSvc, | |||
| 143 | logSvc: deps.LogSvc, | 140 | logSvc: deps.LogSvc, | ||
| 144 | volumeSvc: deps.VolumeSvc, | 141 | volumeSvc: deps.VolumeSvc, | ||
| 145 | registrySvc: deps.RegistrySvc, | 142 | registrySvc: deps.RegistrySvc, | ||
| @@ -214,7 +211,6 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) { | |||||
| 214 | }{ | 211 | }{ | ||
| 215 | {"/apps", h.handleApps}, | 212 | {"/apps", h.handleApps}, | ||
| 216 | {"/backups", h.handleBackups}, | 213 | {"/backups", h.handleBackups}, | ||
| 217 | - | {"/secrets", h.handleSecrets}, | |||
| 218 | {"/tags", h.handleTags}, | 214 | {"/tags", h.handleTags}, | ||
| 219 | {"/images", h.handleImages}, | 215 | {"/images", h.handleImages}, | ||
| 220 | {"/logs", h.handleLogs}, | 216 | {"/logs", h.handleLogs}, | ||
| @@ -276,112 +272,6 @@ func (h *Handler) handleNetworks(w http.ResponseWriter, r *http.Request) { | |||||
| 276 | h.sendJSON(w, http.StatusOK, dto.NetworksResponse{Networks: response}) | 272 | h.sendJSON(w, http.StatusOK, dto.NetworksResponse{Networks: response}) | ||
| 277 | } | 273 | } | ||
| 278 | 274 | ||||
| 279 | - | // handleSecrets handles /admin/secrets endpoints. | |||
| 280 | - | func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path string) { | |||
| 281 | - | // Parse path: /secrets/{domain} or /secrets/{domain}/{key} | |||
| 282 | - | parts := strings.Split(strings.TrimPrefix(path, "/secrets/"), "/") | |||
| 283 | - | if len(parts) == 0 || parts[0] == "" { | |||
| 284 | - | h.sendError(w, http.StatusBadRequest, "domain required") | |||
| 285 | - | return | |||
| 286 | - | } | |||
| 287 | - | ||||
| 288 | - | secretDomain := parts[0] | |||
| 289 | - | ||||
| 290 | - | secretKey := "" | |||
| 291 | - | if len(parts) > 1 { | |||
| 292 | - | secretKey = parts[1] | |||
| 293 | - | } | |||
| 294 | - | ||||
| 295 | - | switch r.Method { | |||
| 296 | - | case http.MethodGet: | |||
| 297 | - | h.handleSecretsGet(w, r, secretDomain) | |||
| 298 | - | case http.MethodPost: | |||
| 299 | - | h.handleSecretsPost(w, r, secretDomain) | |||
| 300 | - | case http.MethodDelete: | |||
| 301 | - | h.handleSecretsDelete(w, r, secretDomain, secretKey) | |||
| 302 | - | default: | |||
| 303 | - | h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") | |||
| 304 | - | } | |||
| 305 | - | } | |||
| 306 | - | ||||
| 307 | - | // handleSecretsPost handles POST /admin/secrets/{domain} - set secrets. | |||
| 308 | - | func (h *Handler) handleSecretsPost(w http.ResponseWriter, r *http.Request, secretDomain string) { | |||
| 309 | - | ctx := r.Context() | |||
| 310 | - | log := zerowrap.FromCtx(ctx) | |||
| 311 | - | ||||
| 312 | - | if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) { | |||
| 313 | - | h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write") | |||
| 314 | - | return | |||
| 315 | - | } | |||
| 316 | - | ||||
| 317 | - | r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize) | |||
| 318 | - | ||||
| 319 | - | var data map[string]string | |||
| 320 | - | if err := json.NewDecoder(r.Body).Decode(&data); err != nil { | |||
| 321 | - | log.Warn().Err(err).Msg("invalid secrets JSON") | |||
| 322 | - | h.sendError(w, http.StatusBadRequest, "invalid JSON") | |||
| 323 | - | return | |||
| 324 | - | } | |||
| 325 | - | ||||
| 326 | - | if err := h.secretSvc.Set(ctx, secretDomain, data); err != nil { | |||
| 327 | - | log.Error().Err(err).Str("domain", secretDomain).Msg("failed to set secrets") | |||
| 328 | - | h.sendError(w, http.StatusBadRequest, "invalid domain") | |||
| 329 | - | return | |||
| 330 | - | } | |||
| 331 | - | ||||
| 332 | - | log.Info().Str("domain", secretDomain).Int("count", len(data)).Msg("secrets set") | |||
| 333 | - | h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "updated"}) | |||
| 334 | - | } | |||
| 335 | - | ||||
| 336 | - | // handleSecretsDelete handles DELETE /admin/secrets/{domain}/{key} - delete a secret. | |||
| 337 | - | func (h *Handler) handleSecretsDelete(w http.ResponseWriter, r *http.Request, secretDomain, secretKey string) { | |||
| 338 | - | ctx := r.Context() | |||
| 339 | - | log := zerowrap.FromCtx(ctx) | |||
| 340 | - | ||||
| 341 | - | if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) { | |||
| 342 | - | h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write") | |||
| 343 | - | return | |||
| 344 | - | } | |||
| 345 | - | if secretKey == "" { | |||
| 346 | - | h.sendError(w, http.StatusBadRequest, "key required in path") | |||
| 347 | - | return | |||
| 348 | - | } | |||
| 349 | - | ||||
| 350 | - | if err := h.secretSvc.Delete(ctx, secretDomain, secretKey); err != nil { | |||
| 351 | - | log.Error().Err(err).Str("domain", secretDomain).Str("key", secretKey).Msg("failed to delete secret") | |||
| 352 | - | h.sendError(w, http.StatusBadRequest, "invalid domain") | |||
| 353 | - | return | |||
| 354 | - | } | |||
| 355 | - | ||||
| 356 | - | log.Info().Str("domain", secretDomain).Str("key", secretKey).Msg("secret deleted") | |||
| 357 | - | h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "deleted"}) | |||
| 358 | - | } | |||
| 359 | - | ||||
| 360 | - | // handleSecretsGet handles GET /admin/secrets/{domain} - list secrets. | |||
| 361 | - | func (h *Handler) handleSecretsGet(w http.ResponseWriter, r *http.Request, secretDomain string) { | |||
| 362 | - | ctx := r.Context() | |||
| 363 | - | log := zerowrap.FromCtx(ctx) | |||
| 364 | - | ||||
| 365 | - | // Check read permission | |||
| 366 | - | if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionRead) { | |||
| 367 | - | h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:read") | |||
| 368 | - | return | |||
| 369 | - | } | |||
| 370 | - | ||||
| 371 | - | // List secrets for domain (names only, not values). | |||
| 372 | - | keys, err := h.secretSvc.ListKeys(ctx, secretDomain) | |||
| 373 | - | if err != nil { | |||
| 374 | - | log.Error().Err(err).Str("domain", secretDomain).Msg("failed to list secrets") | |||
| 375 | - | h.sendError(w, http.StatusBadRequest, "invalid domain") | |||
| 376 | - | return | |||
| 377 | - | } | |||
| 378 | - | ||||
| 379 | - | h.sendJSON(w, http.StatusOK, dto.SecretsListResponse{ | |||
| 380 | - | Domain: secretDomain, | |||
| 381 | - | Keys: keys, | |||
| 382 | - | }) | |||
| 383 | - | } | |||
| 384 | - | ||||
| 385 | // handleHealth handles /admin/health endpoint. | 275 | // handleHealth handles /admin/health endpoint. | ||
| 386 | // Returns detailed health status for all routes including HTTP probe results. | 276 | // Returns detailed health status for all routes including HTTP probe results. | ||
| 387 | func (h *Handler) handleHealth(w http.ResponseWriter, r *http.Request) { | 277 | func (h *Handler) handleHealth(w http.ResponseWriter, r *http.Request) { | ||
M internal/adapters/in/http/admin/handler_apps_test.go +0 -1
| @@ -28,7 +28,6 @@ func appsTestHandler(t *testing.T, appSvc *inmocks.MockAppService) *Handler { | |||||
| 28 | AuthSvc: inmocks.NewMockAuthService(t), | 28 | AuthSvc: inmocks.NewMockAuthService(t), | ||
| 29 | ContainerSvc: inmocks.NewMockContainerService(t), | 29 | ContainerSvc: inmocks.NewMockContainerService(t), | ||
| 30 | HealthSvc: inmocks.NewMockHealthService(t), | 30 | HealthSvc: inmocks.NewMockHealthService(t), | ||
| 31 | - | SecretSvc: inmocks.NewMockSecretService(t), | |||
| 32 | Log: testLogger(), | 31 | Log: testLogger(), | ||
| 33 | ReloadTrigger: noopReloadTrigger{}, | 32 | ReloadTrigger: noopReloadTrigger{}, | ||
| 34 | AppSvc: appSvc, | 33 | AppSvc: appSvc, | ||
M internal/adapters/in/http/admin/handler_retired.go +2 -1
| @@ -6,7 +6,7 @@ import ( | |||||
| 6 | "github.com/bnema/gordon/internal/adapters/dto" | 6 | "github.com/bnema/gordon/internal/adapters/dto" | ||
| 7 | ) | 7 | ) | ||
| 8 | 8 | ||||
| 9 | - | // retiredMutationPaths are legacy mutation endpoints removed by the v3 | 9 | + | // retiredMutationPaths are legacy endpoints removed by the v3 |
| 10 | // declarative-apps cutover. Only the 410 Gone rejection dispatcher below | 10 | // declarative-apps cutover. Only the 410 Gone rejection dispatcher below | ||
| 11 | // remains; no old business handlers or compatibility behavior is kept. | 11 | // remains; no old business handlers or compatibility behavior is kept. | ||
| 12 | var retiredMutationPaths = []string{ | 12 | var retiredMutationPaths = []string{ | ||
| @@ -23,6 +23,7 @@ var retiredMutationPaths = []string{ | |||||
| 23 | "/preview", | 23 | "/preview", | ||
| 24 | "/previews", | 24 | "/previews", | ||
| 25 | "/autoroute/allowed-domains", | 25 | "/autoroute/allowed-domains", | ||
| 26 | + | "/secrets", | |||
| 26 | } | 27 | } | ||
| 27 | 28 | ||||
| 28 | // isRetiredMutation reports whether a request path targets a removed | 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 | AuthSvc: inmocks.NewMockAuthService(t), | 20 | AuthSvc: inmocks.NewMockAuthService(t), | ||
| 21 | ContainerSvc: inmocks.NewMockContainerService(t), | 21 | ContainerSvc: inmocks.NewMockContainerService(t), | ||
| 22 | HealthSvc: inmocks.NewMockHealthService(t), | 22 | HealthSvc: inmocks.NewMockHealthService(t), | ||
| 23 | - | SecretSvc: inmocks.NewMockSecretService(t), | |||
| 24 | Log: testLogger(), | 23 | Log: testLogger(), | ||
| 25 | ReloadTrigger: noopReloadTrigger{}, | 24 | ReloadTrigger: noopReloadTrigger{}, | ||
| 26 | }) | 25 | }) | ||
| @@ -46,6 +45,9 @@ func TestHandler_RetiredMutations_Return410(t *testing.T) { | |||||
| 46 | {http.MethodPost, "/admin/preview/blog"}, | 45 | {http.MethodPost, "/admin/preview/blog"}, | ||
| 47 | {http.MethodGet, "/admin/previews"}, | 46 | {http.MethodGet, "/admin/previews"}, | ||
| 48 | {http.MethodPost, "/admin/autoroute/allowed-domains"}, | 47 | {http.MethodPost, "/admin/autoroute/allowed-domains"}, | ||
| 48 | + | {http.MethodGet, "/admin/secrets/example.com"}, | |||
| 49 | + | {http.MethodPost, "/admin/secrets/example.com"}, | |||
| 50 | + | {http.MethodDelete, "/admin/secrets/example.com/KEY"}, | |||
| 49 | } | 51 | } | ||
| 50 | for _, target := range targets { | 52 | for _, target := range targets { | ||
| 51 | req := httptest.NewRequest(target.method, target.path, nil) | 53 | req := httptest.NewRequest(target.method, target.path, nil) | ||
M internal/adapters/in/http/admin/handler_test.go +0 -250
| @@ -86,7 +86,6 @@ func newTestHandler(t *testing.T, opts ...func(*HandlerDeps)) *Handler { | |||||
| 86 | AuthSvc: inmocks.NewMockAuthService(t), | 86 | AuthSvc: inmocks.NewMockAuthService(t), | ||
| 87 | ContainerSvc: inmocks.NewMockContainerService(t), | 87 | ContainerSvc: inmocks.NewMockContainerService(t), | ||
| 88 | HealthSvc: inmocks.NewMockHealthService(t), | 88 | HealthSvc: inmocks.NewMockHealthService(t), | ||
| 89 | - | SecretSvc: inmocks.NewMockSecretService(t), | |||
| 90 | Log: testLogger(), | 89 | Log: testLogger(), | ||
| 91 | ReloadTrigger: noopReloadTrigger{}, | 90 | ReloadTrigger: noopReloadTrigger{}, | ||
| 92 | } | 91 | } | ||
| @@ -156,180 +155,15 @@ func TestHandler_VolumesPrune_RequiresVolumesWriteScope(t *testing.T) { | |||||
| 156 | 155 | ||||
| 157 | // Routes endpoint tests | 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 | func TestHandler_Status_RequiresReadScope(t *testing.T) { | 158 | func TestHandler_Status_RequiresReadScope(t *testing.T) { | ||
| 323 | configSvc := inmocks.NewMockConfigService(t) | 159 | configSvc := inmocks.NewMockConfigService(t) | ||
| 324 | authSvc := inmocks.NewMockAuthService(t) | 160 | authSvc := inmocks.NewMockAuthService(t) | ||
| 325 | containerSvc := inmocks.NewMockContainerService(t) | 161 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 326 | - | secretSvc := inmocks.NewMockSecretService(t) | |||
| 327 | 162 | ||||
| 328 | handler := newTestHandler(t, func(d *HandlerDeps) { | 163 | handler := newTestHandler(t, func(d *HandlerDeps) { | ||
| 329 | d.ConfigSvc = configSvc | 164 | d.ConfigSvc = configSvc | ||
| 330 | d.AuthSvc = authSvc | 165 | d.AuthSvc = authSvc | ||
| 331 | d.ContainerSvc = containerSvc | 166 | d.ContainerSvc = containerSvc | ||
| 332 | - | d.SecretSvc = secretSvc | |||
| 333 | }) | 167 | }) | ||
| 334 | 168 | ||||
| 335 | tests := []struct { | 169 | tests := []struct { | ||
| @@ -408,13 +242,11 @@ func TestHandler_Config_RequiresReadScope(t *testing.T) { | |||||
| 408 | configSvc := inmocks.NewMockConfigService(t) | 242 | configSvc := inmocks.NewMockConfigService(t) | ||
| 409 | authSvc := inmocks.NewMockAuthService(t) | 243 | authSvc := inmocks.NewMockAuthService(t) | ||
| 410 | containerSvc := inmocks.NewMockContainerService(t) | 244 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 411 | - | secretSvc := inmocks.NewMockSecretService(t) | |||
| 412 | 245 | ||||
| 413 | handler := newTestHandler(t, func(d *HandlerDeps) { | 246 | handler := newTestHandler(t, func(d *HandlerDeps) { | ||
| 414 | d.ConfigSvc = configSvc | 247 | d.ConfigSvc = configSvc | ||
| 415 | d.AuthSvc = authSvc | 248 | d.AuthSvc = authSvc | ||
| 416 | d.ContainerSvc = containerSvc | 249 | d.ContainerSvc = containerSvc | ||
| 417 | - | d.SecretSvc = secretSvc | |||
| 418 | }) | 250 | }) | ||
| 419 | 251 | ||||
| 420 | tests := []struct { | 252 | tests := []struct { | ||
| @@ -468,14 +300,12 @@ func TestHandler_Reload_RequiresWriteScope(t *testing.T) { | |||||
| 468 | configSvc := inmocks.NewMockConfigService(t) | 300 | configSvc := inmocks.NewMockConfigService(t) | ||
| 469 | authSvc := inmocks.NewMockAuthService(t) | 301 | authSvc := inmocks.NewMockAuthService(t) | ||
| 470 | containerSvc := inmocks.NewMockContainerService(t) | 302 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 471 | - | secretSvc := inmocks.NewMockSecretService(t) | |||
| 472 | reloadTrigger := &reloadTriggerRecorder{} | 303 | reloadTrigger := &reloadTriggerRecorder{} | ||
| 473 | 304 | ||||
| 474 | handler := newTestHandler(t, func(d *HandlerDeps) { | 305 | handler := newTestHandler(t, func(d *HandlerDeps) { | ||
| 475 | d.ConfigSvc = configSvc | 306 | d.ConfigSvc = configSvc | ||
| 476 | d.AuthSvc = authSvc | 307 | d.AuthSvc = authSvc | ||
| 477 | d.ContainerSvc = containerSvc | 308 | d.ContainerSvc = containerSvc | ||
| 478 | - | d.SecretSvc = secretSvc | |||
| 479 | d.ReloadTrigger = reloadTrigger | 309 | d.ReloadTrigger = reloadTrigger | ||
| 480 | }) | 310 | }) | ||
| 481 | 311 | ||||
| @@ -567,61 +397,15 @@ func TestHandler_Reload_UsesDetachedContextWithDeadline(t *testing.T) { | |||||
| 567 | 397 | ||||
| 568 | // Functional tests | 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 | func TestHandler_MethodNotAllowed(t *testing.T) { | 400 | func TestHandler_MethodNotAllowed(t *testing.T) { | ||
| 615 | configSvc := inmocks.NewMockConfigService(t) | 401 | configSvc := inmocks.NewMockConfigService(t) | ||
| 616 | authSvc := inmocks.NewMockAuthService(t) | 402 | authSvc := inmocks.NewMockAuthService(t) | ||
| 617 | containerSvc := inmocks.NewMockContainerService(t) | 403 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 618 | - | secretSvc := inmocks.NewMockSecretService(t) | |||
| 619 | 404 | ||||
| 620 | handler := newTestHandler(t, func(d *HandlerDeps) { | 405 | handler := newTestHandler(t, func(d *HandlerDeps) { | ||
| 621 | d.ConfigSvc = configSvc | 406 | d.ConfigSvc = configSvc | ||
| 622 | d.AuthSvc = authSvc | 407 | d.AuthSvc = authSvc | ||
| 623 | d.ContainerSvc = containerSvc | 408 | d.ContainerSvc = containerSvc | ||
| 624 | - | d.SecretSvc = secretSvc | |||
| 625 | }) | 409 | }) | ||
| 626 | 410 | ||||
| 627 | tests := []struct { | 411 | tests := []struct { | ||
| @@ -653,13 +437,11 @@ func TestHandler_Tags_InvalidRepository(t *testing.T) { | |||||
| 653 | configSvc := inmocks.NewMockConfigService(t) | 437 | configSvc := inmocks.NewMockConfigService(t) | ||
| 654 | authSvc := inmocks.NewMockAuthService(t) | 438 | authSvc := inmocks.NewMockAuthService(t) | ||
| 655 | containerSvc := inmocks.NewMockContainerService(t) | 439 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 656 | - | secretSvc := inmocks.NewMockSecretService(t) | |||
| 657 | 440 | ||||
| 658 | handler := newTestHandler(t, func(d *HandlerDeps) { | 441 | handler := newTestHandler(t, func(d *HandlerDeps) { | ||
| 659 | d.ConfigSvc = configSvc | 442 | d.ConfigSvc = configSvc | ||
| 660 | d.AuthSvc = authSvc | 443 | d.AuthSvc = authSvc | ||
| 661 | d.ContainerSvc = containerSvc | 444 | d.ContainerSvc = containerSvc | ||
| 662 | - | d.SecretSvc = secretSvc | |||
| 663 | }) | 445 | }) | ||
| 664 | 446 | ||||
| 665 | tests := []struct { | 447 | tests := []struct { | ||
| @@ -688,14 +470,12 @@ func TestHandler_Tags_Success(t *testing.T) { | |||||
| 688 | configSvc := inmocks.NewMockConfigService(t) | 470 | configSvc := inmocks.NewMockConfigService(t) | ||
| 689 | authSvc := inmocks.NewMockAuthService(t) | 471 | authSvc := inmocks.NewMockAuthService(t) | ||
| 690 | containerSvc := inmocks.NewMockContainerService(t) | 472 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 691 | - | secretSvc := inmocks.NewMockSecretService(t) | |||
| 692 | registrySvc := inmocks.NewMockRegistryService(t) | 473 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 693 | 474 | ||||
| 694 | handler := newTestHandler(t, func(d *HandlerDeps) { | 475 | handler := newTestHandler(t, func(d *HandlerDeps) { | ||
| 695 | d.ConfigSvc = configSvc | 476 | d.ConfigSvc = configSvc | ||
| 696 | d.AuthSvc = authSvc | 477 | d.AuthSvc = authSvc | ||
| 697 | d.ContainerSvc = containerSvc | 478 | d.ContainerSvc = containerSvc | ||
| 698 | - | d.SecretSvc = secretSvc | |||
| 699 | d.RegistrySvc = registrySvc | 479 | d.RegistrySvc = registrySvc | ||
| 700 | }) | 480 | }) | ||
| 701 | 481 | ||||
| @@ -721,14 +501,12 @@ func TestHandler_Tags_Error(t *testing.T) { | |||||
| 721 | configSvc := inmocks.NewMockConfigService(t) | 501 | configSvc := inmocks.NewMockConfigService(t) | ||
| 722 | authSvc := inmocks.NewMockAuthService(t) | 502 | authSvc := inmocks.NewMockAuthService(t) | ||
| 723 | containerSvc := inmocks.NewMockContainerService(t) | 503 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 724 | - | secretSvc := inmocks.NewMockSecretService(t) | |||
| 725 | registrySvc := inmocks.NewMockRegistryService(t) | 504 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 726 | 505 | ||||
| 727 | handler := newTestHandler(t, func(d *HandlerDeps) { | 506 | handler := newTestHandler(t, func(d *HandlerDeps) { | ||
| 728 | d.ConfigSvc = configSvc | 507 | d.ConfigSvc = configSvc | ||
| 729 | d.AuthSvc = authSvc | 508 | d.AuthSvc = authSvc | ||
| 730 | d.ContainerSvc = containerSvc | 509 | d.ContainerSvc = containerSvc | ||
| 731 | - | d.SecretSvc = secretSvc | |||
| 732 | d.RegistrySvc = registrySvc | 510 | d.RegistrySvc = registrySvc | ||
| 733 | }) | 511 | }) | ||
| 734 | 512 | ||||
| @@ -748,14 +526,12 @@ func TestHandler_Tags_URLDecodedRepository(t *testing.T) { | |||||
| 748 | configSvc := inmocks.NewMockConfigService(t) | 526 | configSvc := inmocks.NewMockConfigService(t) | ||
| 749 | authSvc := inmocks.NewMockAuthService(t) | 527 | authSvc := inmocks.NewMockAuthService(t) | ||
| 750 | containerSvc := inmocks.NewMockContainerService(t) | 528 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 751 | - | secretSvc := inmocks.NewMockSecretService(t) | |||
| 752 | registrySvc := inmocks.NewMockRegistryService(t) | 529 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 753 | 530 | ||||
| 754 | handler := newTestHandler(t, func(d *HandlerDeps) { | 531 | handler := newTestHandler(t, func(d *HandlerDeps) { | ||
| 755 | d.ConfigSvc = configSvc | 532 | d.ConfigSvc = configSvc | ||
| 756 | d.AuthSvc = authSvc | 533 | d.AuthSvc = authSvc | ||
| 757 | d.ContainerSvc = containerSvc | 534 | d.ContainerSvc = containerSvc | ||
| 758 | - | d.SecretSvc = secretSvc | |||
| 759 | d.RegistrySvc = registrySvc | 535 | d.RegistrySvc = registrySvc | ||
| 760 | }) | 536 | }) | ||
| 761 | 537 | ||||
| @@ -775,13 +551,11 @@ func TestHandler_NotFound(t *testing.T) { | |||||
| 775 | configSvc := inmocks.NewMockConfigService(t) | 551 | configSvc := inmocks.NewMockConfigService(t) | ||
| 776 | authSvc := inmocks.NewMockAuthService(t) | 552 | authSvc := inmocks.NewMockAuthService(t) | ||
| 777 | containerSvc := inmocks.NewMockContainerService(t) | 553 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 778 | - | secretSvc := inmocks.NewMockSecretService(t) | |||
| 779 | 554 | ||||
| 780 | handler := newTestHandler(t, func(d *HandlerDeps) { | 555 | handler := newTestHandler(t, func(d *HandlerDeps) { | ||
| 781 | d.ConfigSvc = configSvc | 556 | d.ConfigSvc = configSvc | ||
| 782 | d.AuthSvc = authSvc | 557 | d.AuthSvc = authSvc | ||
| 783 | d.ContainerSvc = containerSvc | 558 | d.ContainerSvc = containerSvc | ||
| 784 | - | d.SecretSvc = secretSvc | |||
| 785 | }) | 559 | }) | ||
| 786 | 560 | ||||
| 787 | req := httptest.NewRequest("GET", "/admin/unknown", nil) | 561 | req := httptest.NewRequest("GET", "/admin/unknown", nil) | ||
| @@ -797,7 +571,6 @@ func TestHandler_BackupsStatus(t *testing.T) { | |||||
| 797 | configSvc := inmocks.NewMockConfigService(t) | 571 | configSvc := inmocks.NewMockConfigService(t) | ||
| 798 | authSvc := inmocks.NewMockAuthService(t) | 572 | authSvc := inmocks.NewMockAuthService(t) | ||
| 799 | containerSvc := inmocks.NewMockContainerService(t) | 573 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 800 | - | secretSvc := inmocks.NewMockSecretService(t) | |||
| 801 | backupSvc := inmocks.NewMockBackupService(t) | 574 | backupSvc := inmocks.NewMockBackupService(t) | ||
| 802 | 575 | ||||
| 803 | backupSvc.EXPECT().Status(mock.Anything).Return([]domain.BackupJob{{App: "shop", Service: "api", DBName: "orders", Status: domain.BackupStatusCompleted, FilePath: "/var/lib/gordon/backups/private.bak"}}, nil) | 576 | backupSvc.EXPECT().Status(mock.Anything).Return([]domain.BackupJob{{App: "shop", Service: "api", DBName: "orders", Status: domain.BackupStatusCompleted, FilePath: "/var/lib/gordon/backups/private.bak"}}, nil) | ||
| @@ -806,7 +579,6 @@ func TestHandler_BackupsStatus(t *testing.T) { | |||||
| 806 | d.ConfigSvc = configSvc | 579 | d.ConfigSvc = configSvc | ||
| 807 | d.AuthSvc = authSvc | 580 | d.AuthSvc = authSvc | ||
| 808 | d.ContainerSvc = containerSvc | 581 | d.ContainerSvc = containerSvc | ||
| 809 | - | d.SecretSvc = secretSvc | |||
| 810 | d.BackupSvc = backupSvc | 582 | d.BackupSvc = backupSvc | ||
| 811 | }) | 583 | }) | ||
| 812 | 584 | ||||
| @@ -826,7 +598,6 @@ func TestHandler_BackupsListApp(t *testing.T) { | |||||
| 826 | configSvc := inmocks.NewMockConfigService(t) | 598 | configSvc := inmocks.NewMockConfigService(t) | ||
| 827 | authSvc := inmocks.NewMockAuthService(t) | 599 | authSvc := inmocks.NewMockAuthService(t) | ||
| 828 | containerSvc := inmocks.NewMockContainerService(t) | 600 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 829 | - | secretSvc := inmocks.NewMockSecretService(t) | |||
| 830 | backupSvc := inmocks.NewMockBackupService(t) | 601 | backupSvc := inmocks.NewMockBackupService(t) | ||
| 831 | 602 | ||||
| 832 | backupSvc.EXPECT().ListBackups(mock.Anything, "shop").Return([]domain.BackupJob{{App: "shop", Service: "api", DBName: "orders", Status: domain.BackupStatusCompleted}}, nil) | 603 | backupSvc.EXPECT().ListBackups(mock.Anything, "shop").Return([]domain.BackupJob{{App: "shop", Service: "api", DBName: "orders", Status: domain.BackupStatusCompleted}}, nil) | ||
| @@ -835,7 +606,6 @@ func TestHandler_BackupsListApp(t *testing.T) { | |||||
| 835 | d.ConfigSvc = configSvc | 606 | d.ConfigSvc = configSvc | ||
| 836 | d.AuthSvc = authSvc | 607 | d.AuthSvc = authSvc | ||
| 837 | d.ContainerSvc = containerSvc | 608 | d.ContainerSvc = containerSvc | ||
| 838 | - | d.SecretSvc = secretSvc | |||
| 839 | d.BackupSvc = backupSvc | 609 | d.BackupSvc = backupSvc | ||
| 840 | }) | 610 | }) | ||
| 841 | 611 | ||||
| @@ -853,7 +623,6 @@ func TestHandler_BackupsRunApp(t *testing.T) { | |||||
| 853 | configSvc := inmocks.NewMockConfigService(t) | 623 | configSvc := inmocks.NewMockConfigService(t) | ||
| 854 | authSvc := inmocks.NewMockAuthService(t) | 624 | authSvc := inmocks.NewMockAuthService(t) | ||
| 855 | containerSvc := inmocks.NewMockContainerService(t) | 625 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 856 | - | secretSvc := inmocks.NewMockSecretService(t) | |||
| 857 | backupSvc := inmocks.NewMockBackupService(t) | 626 | backupSvc := inmocks.NewMockBackupService(t) | ||
| 858 | 627 | ||||
| 859 | backupSvc.EXPECT().RunBackup(mock.Anything, "shop", "api", "orders").Return(&domain.BackupResult{Job: domain.BackupJob{App: "shop", Service: "api", DBName: "orders", Status: domain.BackupStatusCompleted}}, nil) | 628 | backupSvc.EXPECT().RunBackup(mock.Anything, "shop", "api", "orders").Return(&domain.BackupResult{Job: domain.BackupJob{App: "shop", Service: "api", DBName: "orders", Status: domain.BackupStatusCompleted}}, nil) | ||
| @@ -862,7 +631,6 @@ func TestHandler_BackupsRunApp(t *testing.T) { | |||||
| 862 | d.ConfigSvc = configSvc | 631 | d.ConfigSvc = configSvc | ||
| 863 | d.AuthSvc = authSvc | 632 | d.AuthSvc = authSvc | ||
| 864 | d.ContainerSvc = containerSvc | 633 | d.ContainerSvc = containerSvc | ||
| 865 | - | d.SecretSvc = secretSvc | |||
| 866 | d.BackupSvc = backupSvc | 634 | d.BackupSvc = backupSvc | ||
| 867 | }) | 635 | }) | ||
| 868 | 636 | ||||
| @@ -925,7 +693,6 @@ func TestHandler_BackupsRunApp_ChunkedBodyIsDecoded(t *testing.T) { | |||||
| 925 | configSvc := inmocks.NewMockConfigService(t) | 693 | configSvc := inmocks.NewMockConfigService(t) | ||
| 926 | authSvc := inmocks.NewMockAuthService(t) | 694 | authSvc := inmocks.NewMockAuthService(t) | ||
| 927 | containerSvc := inmocks.NewMockContainerService(t) | 695 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 928 | - | secretSvc := inmocks.NewMockSecretService(t) | |||
| 929 | backupSvc := inmocks.NewMockBackupService(t) | 696 | backupSvc := inmocks.NewMockBackupService(t) | ||
| 930 | 697 | ||||
| 931 | backupSvc.EXPECT().RunBackup(mock.Anything, "shop", "api", "orders").Return(&domain.BackupResult{Job: domain.BackupJob{App: "shop", Service: "api", DBName: "orders", Status: domain.BackupStatusCompleted}}, nil) | 698 | backupSvc.EXPECT().RunBackup(mock.Anything, "shop", "api", "orders").Return(&domain.BackupResult{Job: domain.BackupJob{App: "shop", Service: "api", DBName: "orders", Status: domain.BackupStatusCompleted}}, nil) | ||
| @@ -934,7 +701,6 @@ func TestHandler_BackupsRunApp_ChunkedBodyIsDecoded(t *testing.T) { | |||||
| 934 | d.ConfigSvc = configSvc | 701 | d.ConfigSvc = configSvc | ||
| 935 | d.AuthSvc = authSvc | 702 | d.AuthSvc = authSvc | ||
| 936 | d.ContainerSvc = containerSvc | 703 | d.ContainerSvc = containerSvc | ||
| 937 | - | d.SecretSvc = secretSvc | |||
| 938 | d.BackupSvc = backupSvc | 704 | d.BackupSvc = backupSvc | ||
| 939 | }) | 705 | }) | ||
| 940 | 706 | ||||
| @@ -991,7 +757,6 @@ func TestHandler_ImagesGet_ReturnsMappedList(t *testing.T) { | |||||
| 991 | configSvc := inmocks.NewMockConfigService(t) | 757 | configSvc := inmocks.NewMockConfigService(t) | ||
| 992 | authSvc := inmocks.NewMockAuthService(t) | 758 | authSvc := inmocks.NewMockAuthService(t) | ||
| 993 | containerSvc := inmocks.NewMockContainerService(t) | 759 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 994 | - | secretSvc := inmocks.NewMockSecretService(t) | |||
| 995 | 760 | ||||
| 996 | createdAt := time.Date(2026, time.February, 8, 14, 30, 0, 0, time.UTC) | 761 | createdAt := time.Date(2026, time.February, 8, 14, 30, 0, 0, time.UTC) | ||
| 997 | imageSvc := &stubImageService{ | 762 | imageSvc := &stubImageService{ | ||
| @@ -1021,7 +786,6 @@ func TestHandler_ImagesGet_ReturnsMappedList(t *testing.T) { | |||||
| 1021 | d.ConfigSvc = configSvc | 786 | d.ConfigSvc = configSvc | ||
| 1022 | d.AuthSvc = authSvc | 787 | d.AuthSvc = authSvc | ||
| 1023 | d.ContainerSvc = containerSvc | 788 | d.ContainerSvc = containerSvc | ||
| 1024 | - | d.SecretSvc = secretSvc | |||
| 1025 | d.ImageSvc = imageSvc | 789 | d.ImageSvc = imageSvc | ||
| 1026 | }) | 790 | }) | ||
| 1027 | 791 | ||||
| @@ -1059,7 +823,6 @@ func TestHandler_ImagesPrune_AcceptsOptionalKeepLast(t *testing.T) { | |||||
| 1059 | configSvc := inmocks.NewMockConfigService(t) | 823 | configSvc := inmocks.NewMockConfigService(t) | ||
| 1060 | authSvc := inmocks.NewMockAuthService(t) | 824 | authSvc := inmocks.NewMockAuthService(t) | ||
| 1061 | containerSvc := inmocks.NewMockContainerService(t) | 825 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 1062 | - | secretSvc := inmocks.NewMockSecretService(t) | |||
| 1063 | 826 | ||||
| 1064 | tests := []struct { | 827 | tests := []struct { | ||
| 1065 | name string | 828 | name string | ||
| @@ -1088,7 +851,6 @@ func TestHandler_ImagesPrune_AcceptsOptionalKeepLast(t *testing.T) { | |||||
| 1088 | d.ConfigSvc = configSvc | 851 | d.ConfigSvc = configSvc | ||
| 1089 | d.AuthSvc = authSvc | 852 | d.AuthSvc = authSvc | ||
| 1090 | d.ContainerSvc = containerSvc | 853 | d.ContainerSvc = containerSvc | ||
| 1091 | - | d.SecretSvc = secretSvc | |||
| 1092 | d.ImageSvc = imageSvc | 854 | d.ImageSvc = imageSvc | ||
| 1093 | }) | 855 | }) | ||
| 1094 | 856 | ||||
| @@ -1111,7 +873,6 @@ func TestHandler_Images_ErrorMappingForServiceFailures(t *testing.T) { | |||||
| 1111 | configSvc := inmocks.NewMockConfigService(t) | 873 | configSvc := inmocks.NewMockConfigService(t) | ||
| 1112 | authSvc := inmocks.NewMockAuthService(t) | 874 | authSvc := inmocks.NewMockAuthService(t) | ||
| 1113 | containerSvc := inmocks.NewMockContainerService(t) | 875 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 1114 | - | secretSvc := inmocks.NewMockSecretService(t) | |||
| 1115 | 876 | ||||
| 1116 | imageSvc := &stubImageService{ | 877 | imageSvc := &stubImageService{ | ||
| 1117 | listImagesFunc: func(context.Context) ([]domain.ImageInfo, error) { | 878 | listImagesFunc: func(context.Context) ([]domain.ImageInfo, error) { | ||
| @@ -1123,7 +884,6 @@ func TestHandler_Images_ErrorMappingForServiceFailures(t *testing.T) { | |||||
| 1123 | d.ConfigSvc = configSvc | 884 | d.ConfigSvc = configSvc | ||
| 1124 | d.AuthSvc = authSvc | 885 | d.AuthSvc = authSvc | ||
| 1125 | d.ContainerSvc = containerSvc | 886 | d.ContainerSvc = containerSvc | ||
| 1126 | - | d.SecretSvc = secretSvc | |||
| 1127 | d.ImageSvc = imageSvc | 887 | d.ImageSvc = imageSvc | ||
| 1128 | }) | 888 | }) | ||
| 1129 | 889 | ||||
| @@ -1142,7 +902,6 @@ func TestHandler_Images_ErrorMappingForServiceFailures(t *testing.T) { | |||||
| 1142 | configSvc := inmocks.NewMockConfigService(t) | 902 | configSvc := inmocks.NewMockConfigService(t) | ||
| 1143 | authSvc := inmocks.NewMockAuthService(t) | 903 | authSvc := inmocks.NewMockAuthService(t) | ||
| 1144 | containerSvc := inmocks.NewMockContainerService(t) | 904 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 1145 | - | secretSvc := inmocks.NewMockSecretService(t) | |||
| 1146 | 905 | ||||
| 1147 | imageSvc := &stubImageService{ | 906 | imageSvc := &stubImageService{ | ||
| 1148 | pruneFunc: func(context.Context, domain.ImagePruneOptions) (domain.ImagePruneReport, error) { | 907 | pruneFunc: func(context.Context, domain.ImagePruneOptions) (domain.ImagePruneReport, error) { | ||
| @@ -1154,7 +913,6 @@ func TestHandler_Images_ErrorMappingForServiceFailures(t *testing.T) { | |||||
| 1154 | d.ConfigSvc = configSvc | 913 | d.ConfigSvc = configSvc | ||
| 1155 | d.AuthSvc = authSvc | 914 | d.AuthSvc = authSvc | ||
| 1156 | d.ContainerSvc = containerSvc | 915 | d.ContainerSvc = containerSvc | ||
| 1157 | - | d.SecretSvc = secretSvc | |||
| 1158 | d.ImageSvc = imageSvc | 916 | d.ImageSvc = imageSvc | ||
| 1159 | }) | 917 | }) | ||
| 1160 | 918 | ||||
| @@ -1175,13 +933,11 @@ func TestHandler_ImagesPrune_ValidationAndAvailability(t *testing.T) { | |||||
| 1175 | configSvc := inmocks.NewMockConfigService(t) | 933 | configSvc := inmocks.NewMockConfigService(t) | ||
| 1176 | authSvc := inmocks.NewMockAuthService(t) | 934 | authSvc := inmocks.NewMockAuthService(t) | ||
| 1177 | containerSvc := inmocks.NewMockContainerService(t) | 935 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 1178 | - | secretSvc := inmocks.NewMockSecretService(t) | |||
| 1179 | 936 | ||||
| 1180 | handler := newTestHandler(t, func(d *HandlerDeps) { | 937 | handler := newTestHandler(t, func(d *HandlerDeps) { | ||
| 1181 | d.ConfigSvc = configSvc | 938 | d.ConfigSvc = configSvc | ||
| 1182 | d.AuthSvc = authSvc | 939 | d.AuthSvc = authSvc | ||
| 1183 | d.ContainerSvc = containerSvc | 940 | d.ContainerSvc = containerSvc | ||
| 1184 | - | d.SecretSvc = secretSvc | |||
| 1185 | d.ImageSvc = &stubImageService{} | 941 | d.ImageSvc = &stubImageService{} | ||
| 1186 | }) | 942 | }) | ||
| 1187 | 943 | ||||
| @@ -1199,13 +955,11 @@ func TestHandler_ImagesPrune_ValidationAndAvailability(t *testing.T) { | |||||
| 1199 | configSvc := inmocks.NewMockConfigService(t) | 955 | configSvc := inmocks.NewMockConfigService(t) | ||
| 1200 | authSvc := inmocks.NewMockAuthService(t) | 956 | authSvc := inmocks.NewMockAuthService(t) | ||
| 1201 | containerSvc := inmocks.NewMockContainerService(t) | 957 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 1202 | - | secretSvc := inmocks.NewMockSecretService(t) | |||
| 1203 | 958 | ||||
| 1204 | handler := newTestHandler(t, func(d *HandlerDeps) { | 959 | handler := newTestHandler(t, func(d *HandlerDeps) { | ||
| 1205 | d.ConfigSvc = configSvc | 960 | d.ConfigSvc = configSvc | ||
| 1206 | d.AuthSvc = authSvc | 961 | d.AuthSvc = authSvc | ||
| 1207 | d.ContainerSvc = containerSvc | 962 | d.ContainerSvc = containerSvc | ||
| 1208 | - | d.SecretSvc = secretSvc | |||
| 1209 | }) | 963 | }) | ||
| 1210 | 964 | ||||
| 1211 | req := httptest.NewRequest("GET", "/admin/images", nil) | 965 | req := httptest.NewRequest("GET", "/admin/images", nil) | ||
| @@ -1223,7 +977,6 @@ func TestHandler_ImagesPrune_ScopeDefaults(t *testing.T) { | |||||
| 1223 | configSvc := inmocks.NewMockConfigService(t) | 977 | configSvc := inmocks.NewMockConfigService(t) | ||
| 1224 | authSvc := inmocks.NewMockAuthService(t) | 978 | authSvc := inmocks.NewMockAuthService(t) | ||
| 1225 | containerSvc := inmocks.NewMockContainerService(t) | 979 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 1226 | - | secretSvc := inmocks.NewMockSecretService(t) | |||
| 1227 | 980 | ||||
| 1228 | tests := []struct { | 981 | tests := []struct { | ||
| 1229 | name string | 982 | name string | ||
| @@ -1281,7 +1034,6 @@ func TestHandler_ImagesPrune_ScopeDefaults(t *testing.T) { | |||||
| 1281 | d.ConfigSvc = configSvc | 1034 | d.ConfigSvc = configSvc | ||
| 1282 | d.AuthSvc = authSvc | 1035 | d.AuthSvc = authSvc | ||
| 1283 | d.ContainerSvc = containerSvc | 1036 | d.ContainerSvc = containerSvc | ||
| 1284 | - | d.SecretSvc = secretSvc | |||
| 1285 | d.ImageSvc = imageSvc | 1037 | d.ImageSvc = imageSvc | ||
| 1286 | }) | 1038 | }) | ||
| 1287 | 1039 | ||||
| @@ -1306,13 +1058,11 @@ func TestHandler_Images_Authorization(t *testing.T) { | |||||
| 1306 | configSvc := inmocks.NewMockConfigService(t) | 1058 | configSvc := inmocks.NewMockConfigService(t) | ||
| 1307 | authSvc := inmocks.NewMockAuthService(t) | 1059 | authSvc := inmocks.NewMockAuthService(t) | ||
| 1308 | containerSvc := inmocks.NewMockContainerService(t) | 1060 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 1309 | - | secretSvc := inmocks.NewMockSecretService(t) | |||
| 1310 | 1061 | ||||
| 1311 | handler := newTestHandler(t, func(d *HandlerDeps) { | 1062 | handler := newTestHandler(t, func(d *HandlerDeps) { | ||
| 1312 | d.ConfigSvc = configSvc | 1063 | d.ConfigSvc = configSvc | ||
| 1313 | d.AuthSvc = authSvc | 1064 | d.AuthSvc = authSvc | ||
| 1314 | d.ContainerSvc = containerSvc | 1065 | d.ContainerSvc = containerSvc | ||
| 1315 | - | d.SecretSvc = secretSvc | |||
| 1316 | d.ImageSvc = &stubImageService{} | 1066 | d.ImageSvc = &stubImageService{} | ||
| 1317 | }) | 1067 | }) | ||
| 1318 | 1068 | ||||
M internal/adapters/in/http/admin/local_authority.go +0 -7
| @@ -22,7 +22,6 @@ var localAuthorityScopes = []string{ | |||||
| 22 | domain.AdminScopeApps(domain.AdminActionRead, domain.AdminActionWrite), | 22 | domain.AdminScopeApps(domain.AdminActionRead, domain.AdminActionWrite), | ||
| 23 | domain.AdminScopeStatus(domain.AdminActionRead), | 23 | domain.AdminScopeStatus(domain.AdminActionRead), | ||
| 24 | domain.AdminScopeConfig(domain.AdminActionRead, domain.AdminActionWrite), | 24 | domain.AdminScopeConfig(domain.AdminActionRead, domain.AdminActionWrite), | ||
| 25 | - | domain.AdminScopeSecrets(domain.AdminActionRead, domain.AdminActionWrite), | |||
| 26 | domain.AdminScopeLogs(domain.AdminActionRead), | 25 | domain.AdminScopeLogs(domain.AdminActionRead), | ||
| 27 | domain.AdminScopeVolumes(domain.AdminActionRead, domain.AdminActionWrite), | 26 | domain.AdminScopeVolumes(domain.AdminActionRead, domain.AdminActionWrite), | ||
| 28 | } | 27 | } | ||
| @@ -64,7 +63,6 @@ func localPathAllowed(method, cleanedPath string) bool { | |||||
| 64 | prefixRules := map[string]func(string, []string) bool{ | 63 | prefixRules := map[string]func(string, []string) bool{ | ||
| 65 | "apps": localAppPathAllowed, | 64 | "apps": localAppPathAllowed, | ||
| 66 | "backups": localBackupPathAllowed, | 65 | "backups": localBackupPathAllowed, | ||
| 67 | - | "secrets": localSecretPathAllowed, | |||
| 68 | "logs": localLogPathAllowed, | 66 | "logs": localLogPathAllowed, | ||
| 69 | "tags": localTagPathAllowed, | 67 | "tags": localTagPathAllowed, | ||
| 70 | } | 68 | } | ||
| @@ -72,11 +70,6 @@ func localPathAllowed(method, cleanedPath string) bool { | |||||
| 72 | return ok && rule(method, parts) | 70 | return ok && rule(method, parts) | ||
| 73 | } | 71 | } | ||
| 74 | 72 | ||||
| 75 | - | func localSecretPathAllowed(method string, parts []string) bool { | |||
| 76 | - | return len(parts) == 2 && parts[1] != "" && (method == http.MethodGet || method == http.MethodPost) || | |||
| 77 | - | len(parts) == 3 && parts[1] != "" && parts[2] != "" && method == http.MethodDelete | |||
| 78 | - | } | |||
| 79 | - | ||||
| 80 | func localLogPathAllowed(method string, parts []string) bool { | 73 | func localLogPathAllowed(method string, parts []string) bool { | ||
| 81 | return (len(parts) == 1 || len(parts) == 3 && parts[1] != "" && parts[2] != "") && method == http.MethodGet | 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 | {domain.AdminResourceStatus, domain.AdminActionRead}, | 35 | {domain.AdminResourceStatus, domain.AdminActionRead}, | ||
| 36 | {domain.AdminResourceConfig, domain.AdminActionRead}, | 36 | {domain.AdminResourceConfig, domain.AdminActionRead}, | ||
| 37 | {domain.AdminResourceConfig, domain.AdminActionWrite}, | 37 | {domain.AdminResourceConfig, domain.AdminActionWrite}, | ||
| 38 | - | {domain.AdminResourceSecrets, domain.AdminActionRead}, | |||
| 39 | - | {domain.AdminResourceSecrets, domain.AdminActionWrite}, | |||
| 40 | {domain.AdminResourceLogs, domain.AdminActionRead}, | 38 | {domain.AdminResourceLogs, domain.AdminActionRead}, | ||
| 41 | {domain.AdminResourceVolumes, domain.AdminActionRead}, | 39 | {domain.AdminResourceVolumes, domain.AdminActionRead}, | ||
| 42 | {domain.AdminResourceVolumes, domain.AdminActionWrite}, | 40 | {domain.AdminResourceVolumes, domain.AdminActionWrite}, | ||
| @@ -44,6 +42,7 @@ func TestLocalAuthorityInjectsLeastPrivilegeScopes(t *testing.T) { | |||||
| 44 | assert.True(t, HasAccess(ctx, access.resource, access.action), "%s:%s", access.resource, access.action) | 42 | assert.True(t, HasAccess(ctx, access.resource, access.action), "%s:%s", access.resource, access.action) | ||
| 45 | } | 43 | } | ||
| 46 | assert.False(t, HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionRead)) | 44 | assert.False(t, HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionRead)) | ||
| 45 | + | assert.False(t, HasAccess(ctx, "secrets", domain.AdminActionRead)) | |||
| 47 | assert.False(t, HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionWrite)) | 46 | assert.False(t, HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionWrite)) | ||
| 48 | assert.False(t, HasAccess(ctx, domain.AdminResourceLogs, domain.AdminActionWrite)) | 47 | assert.False(t, HasAccess(ctx, domain.AdminResourceLogs, domain.AdminActionWrite)) | ||
| 49 | assert.False(t, HasAccess(ctx, domain.AdminResourceAll, domain.AdminActionAll)) | 48 | assert.False(t, HasAccess(ctx, domain.AdminResourceAll, domain.AdminActionAll)) | ||
| @@ -70,9 +69,6 @@ func TestLocalAuthorityCanonicalAllowlist(t *testing.T) { | |||||
| 70 | {http.MethodGet, "/tags/repository"}, | 69 | {http.MethodGet, "/tags/repository"}, | ||
| 71 | {http.MethodGet, "/logs"}, | 70 | {http.MethodGet, "/logs"}, | ||
| 72 | {http.MethodGet, "/logs/blog/web"}, | 71 | {http.MethodGet, "/logs/blog/web"}, | ||
| 73 | - | {http.MethodGet, "/secrets/blog.example.com"}, | |||
| 74 | - | {http.MethodPost, "/secrets/blog.example.com"}, | |||
| 75 | - | {http.MethodDelete, "/secrets/blog.example.com/API_KEY"}, | |||
| 76 | {http.MethodGet, "/backups"}, | 72 | {http.MethodGet, "/backups"}, | ||
| 77 | {http.MethodGet, "/backups/status"}, | 73 | {http.MethodGet, "/backups/status"}, | ||
| 78 | {http.MethodGet, "/backups/shop"}, | 74 | {http.MethodGet, "/backups/shop"}, | ||
| @@ -116,6 +112,8 @@ func TestLocalAuthorityDeniesNearMissesAndNonCanonicalPaths(t *testing.T) { | |||||
| 116 | {http.MethodGet, "/health"}, | 112 | {http.MethodGet, "/health"}, | ||
| 117 | {http.MethodGet, "/appsX"}, | 113 | {http.MethodGet, "/appsX"}, | ||
| 118 | {http.MethodGet, "/secrets"}, | 114 | {http.MethodGet, "/secrets"}, | ||
| 115 | + | {http.MethodGet, "/secrets/blog.example.com"}, | |||
| 116 | + | {http.MethodPost, "/secrets/blog.example.com"}, | |||
| 119 | {http.MethodDelete, "/secrets/blog"}, | 117 | {http.MethodDelete, "/secrets/blog"}, | ||
| 120 | {http.MethodGet, "/images/anything"}, | 118 | {http.MethodGet, "/images/anything"}, | ||
| 121 | {http.MethodGet, "/tags/repository/extra"}, | 119 | {http.MethodGet, "/tags/repository/extra"}, | ||
D internal/adapters/out/domainsecrets/helpers_test.go +0 -25
| @@ -1,25 +0,0 @@ | |||||
| 1 | - | package domainsecrets | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "fmt" | |||
| 5 | - | "testing" | |||
| 6 | - | ||||
| 7 | - | "github.com/bnema/zerowrap" | |||
| 8 | - | ) | |||
| 9 | - | ||||
| 10 | - | func testLogger() zerowrap.Logger { | |||
| 11 | - | return zerowrap.Default() | |||
| 12 | - | } | |||
| 13 | - | ||||
| 14 | - | // CleanupPassAttachment removes attachment secrets from pass for testing. | |||
| 15 | - | // It's a test helper that should be called with defer to ensure cleanup. | |||
| 16 | - | // This function is exported to be shared across test packages. | |||
| 17 | - | func CleanupPassAttachment(_ *testing.T, containerName string, keys []string) { | |||
| 18 | - | for _, key := range keys { | |||
| 19 | - | path := fmt.Sprintf("%s/%s/%s", PassAttachmentPath, containerName, key) | |||
| 20 | - | _ = passCmd("rm", "-f", path) | |||
| 21 | - | } | |||
| 22 | - | ||||
| 23 | - | manifestPath := fmt.Sprintf("%s/%s/.keys", PassAttachmentPath, containerName) | |||
| 24 | - | _ = passCmd("rm", "-f", manifestPath) | |||
| 25 | - | } | |||
D internal/adapters/out/domainsecrets/pass_store.go +0 -934
| @@ -1,934 +0,0 @@ | |||||
| 1 | - | // Package domainsecrets implements the DomainSecretStore adapter using pass. | |||
| 2 | - | package domainsecrets | |||
| 3 | - | ||||
| 4 | - | import ( | |||
| 5 | - | "context" | |||
| 6 | - | "fmt" | |||
| 7 | - | "os/exec" | |||
| 8 | - | "regexp" | |||
| 9 | - | "sort" | |||
| 10 | - | "strings" | |||
| 11 | - | "sync" | |||
| 12 | - | "time" | |||
| 13 | - | ||||
| 14 | - | "github.com/bnema/zerowrap" | |||
| 15 | - | ||||
| 16 | - | "github.com/bnema/gordon/internal/adapters/out/secrets" | |||
| 17 | - | "github.com/bnema/gordon/internal/boundaries/out" | |||
| 18 | - | "github.com/bnema/gordon/internal/domain" | |||
| 19 | - | ) | |||
| 20 | - | ||||
| 21 | - | // ansiRegex matches ANSI escape sequences for stripping from pass output. | |||
| 22 | - | var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*m`) | |||
| 23 | - | ||||
| 24 | - | const ( | |||
| 25 | - | PassDomainSecretsPath = "gordon/env" //nolint:gosec // Not a credential, this is a pass store path. | |||
| 26 | - | PassAttachmentPath = "gordon/env/attachments" //nolint:gosec // Not a credential, this is a pass store path. | |||
| 27 | - | ) | |||
| 28 | - | ||||
| 29 | - | // PassStore implements the DomainSecretStore interface using the pass password manager. | |||
| 30 | - | type PassStore struct { | |||
| 31 | - | mu sync.Mutex | |||
| 32 | - | timeout time.Duration | |||
| 33 | - | log zerowrap.Logger | |||
| 34 | - | } | |||
| 35 | - | ||||
| 36 | - | // NewPassStore creates a new pass-based domain secret store. | |||
| 37 | - | func NewPassStore(log zerowrap.Logger) (*PassStore, error) { | |||
| 38 | - | // Use a timeout so a stalled GPG agent or keyring does not hang startup. | |||
| 39 | - | probeCtx, probeCancel := context.WithTimeout(context.Background(), 10*time.Second) | |||
| 40 | - | defer probeCancel() | |||
| 41 | - | if err := exec.CommandContext(probeCtx, "pass", "version").Run(); err != nil { //nolint:gosec // binary is a constant ("pass"), no user input | |||
| 42 | - | return nil, fmt.Errorf("pass is not available: %w", err) | |||
| 43 | - | } | |||
| 44 | - | ||||
| 45 | - | log.Debug(). | |||
| 46 | - | Str(zerowrap.FieldLayer, "adapter"). | |||
| 47 | - | Str(zerowrap.FieldAdapter, "domainsecrets"). | |||
| 48 | - | Str("provider", "pass"). | |||
| 49 | - | Msg("domain secret store initialized") | |||
| 50 | - | ||||
| 51 | - | return &PassStore{ | |||
| 52 | - | timeout: 10 * time.Second, | |||
| 53 | - | log: log, | |||
| 54 | - | }, nil | |||
| 55 | - | } | |||
| 56 | - | ||||
| 57 | - | // ListKeys returns the list of secret keys for a domain (not values). | |||
| 58 | - | func (s *PassStore) ListKeys(domainName string) ([]string, error) { | |||
| 59 | - | manifestPath, err := s.manifestPath(domainName) | |||
| 60 | - | if err != nil { | |||
| 61 | - | return nil, err | |||
| 62 | - | } | |||
| 63 | - | ||||
| 64 | - | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |||
| 65 | - | defer cancel() | |||
| 66 | - | ||||
| 67 | - | content, exists, err := s.passShow(ctx, manifestPath) | |||
| 68 | - | if err != nil { | |||
| 69 | - | return nil, err | |||
| 70 | - | } | |||
| 71 | - | keys := []string{} | |||
| 72 | - | if exists { | |||
| 73 | - | for _, line := range strings.Split(content, "\n") { | |||
| 74 | - | key := strings.TrimSpace(line) | |||
| 75 | - | if key == "" { | |||
| 76 | - | continue | |||
| 77 | - | } | |||
| 78 | - | keys = append(keys, key) | |||
| 79 | - | } | |||
| 80 | - | } | |||
| 81 | - | sort.Strings(keys) | |||
| 82 | - | ||||
| 83 | - | // Recover keys that may exist in pass but are missing from the manifest. | |||
| 84 | - | discovered, err := s.listDomainKeys(domainName) | |||
| 85 | - | if err != nil { | |||
| 86 | - | return nil, err | |||
| 87 | - | } | |||
| 88 | - | ||||
| 89 | - | merged, changed := mergeUniqueKeys(keys, discovered) | |||
| 90 | - | if changed { | |||
| 91 | - | // Best-effort self-heal of stale manifest. | |||
| 92 | - | writeCtx, writeCancel := context.WithTimeout(context.Background(), s.timeout) | |||
| 93 | - | writeErr := s.passInsert(writeCtx, manifestPath, strings.Join(merged, "\n")) | |||
| 94 | - | writeCancel() | |||
| 95 | - | if writeErr != nil { | |||
| 96 | - | s.log.Warn(). | |||
| 97 | - | Str(zerowrap.FieldLayer, "adapter"). | |||
| 98 | - | Str(zerowrap.FieldAdapter, "domainsecrets"). | |||
| 99 | - | Str("domain", domainName). | |||
| 100 | - | Err(writeErr). | |||
| 101 | - | Msg("failed to self-heal pass manifest, continuing with recovered keys") | |||
| 102 | - | } | |||
| 103 | - | } | |||
| 104 | - | ||||
| 105 | - | return merged, nil | |||
| 106 | - | } | |||
| 107 | - | ||||
| 108 | - | // GetAll returns all secrets for a domain as a key-value map. | |||
| 109 | - | func (s *PassStore) GetAll(domainName string) (map[string]string, error) { | |||
| 110 | - | keys, err := s.ListKeys(domainName) | |||
| 111 | - | if err != nil { | |||
| 112 | - | return nil, err | |||
| 113 | - | } | |||
| 114 | - | ||||
| 115 | - | secretsMap := make(map[string]string) | |||
| 116 | - | for _, key := range keys { | |||
| 117 | - | keyPath, err := s.keyPath(domainName, key) | |||
| 118 | - | if err != nil { | |||
| 119 | - | return nil, err | |||
| 120 | - | } | |||
| 121 | - | ||||
| 122 | - | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |||
| 123 | - | value, exists, err := s.passShow(ctx, keyPath) | |||
| 124 | - | cancel() | |||
| 125 | - | if err != nil { | |||
| 126 | - | return nil, err | |||
| 127 | - | } | |||
| 128 | - | if !exists { | |||
| 129 | - | s.log.Warn(). | |||
| 130 | - | Str(zerowrap.FieldLayer, "adapter"). | |||
| 131 | - | Str(zerowrap.FieldAdapter, "domainsecrets"). | |||
| 132 | - | Str("domain", domainName). | |||
| 133 | - | Str("key", key). | |||
| 134 | - | Msg("secret listed in manifest but missing in pass") | |||
| 135 | - | continue | |||
| 136 | - | } | |||
| 137 | - | secretsMap[key] = value | |||
| 138 | - | } | |||
| 139 | - | ||||
| 140 | - | return secretsMap, nil | |||
| 141 | - | } | |||
| 142 | - | ||||
| 143 | - | // SetIfEmpty sets multiple secrets for a domain only when no keys already exist. | |||
| 144 | - | // It performs the precondition check inside the store adapter so migration | |||
| 145 | - | // callers cannot accidentally split the check and write into separate steps. | |||
| 146 | - | func (s *PassStore) SetIfEmpty(domainName string, secretsMap map[string]string) ([]string, error) { | |||
| 147 | - | s.mu.Lock() | |||
| 148 | - | defer s.mu.Unlock() | |||
| 149 | - | ||||
| 150 | - | existingKeys, err := s.ListKeys(domainName) | |||
| 151 | - | if err != nil { | |||
| 152 | - | return nil, err | |||
| 153 | - | } | |||
| 154 | - | if len(existingKeys) > 0 { | |||
| 155 | - | return nil, fmt.Errorf("%w: pass secrets already exist for %s (found %d keys)", domain.ErrSecretsAlreadyExist, domainName, len(existingKeys)) | |||
| 156 | - | } | |||
| 157 | - | if err := s.Set(domainName, secretsMap); err != nil { | |||
| 158 | - | return nil, err | |||
| 159 | - | } | |||
| 160 | - | return sortedMapKeys(secretsMap), nil | |||
| 161 | - | } | |||
| 162 | - | ||||
| 163 | - | // Set sets or updates multiple secrets for a domain. | |||
| 164 | - | func (s *PassStore) Set(domainName string, secretsMap map[string]string) error { | |||
| 165 | - | if _, err := s.domainPath(domainName); err != nil { | |||
| 166 | - | return err | |||
| 167 | - | } | |||
| 168 | - | ||||
| 169 | - | existingKeys, err := s.ListKeys(domainName) | |||
| 170 | - | if err != nil { | |||
| 171 | - | return err | |||
| 172 | - | } | |||
| 173 | - | ||||
| 174 | - | existingSet := make(map[string]struct{}, len(existingKeys)) | |||
| 175 | - | for _, key := range existingKeys { | |||
| 176 | - | existingSet[key] = struct{}{} | |||
| 177 | - | } | |||
| 178 | - | ||||
| 179 | - | insertedNewPaths := make([]string, 0, len(secretsMap)) | |||
| 180 | - | for key, value := range secretsMap { | |||
| 181 | - | if err := domain.ValidateEnvKey(key); err != nil { | |||
| 182 | - | return err | |||
| 183 | - | } | |||
| 184 | - | ||||
| 185 | - | keyPath, err := s.keyPath(domainName, key) | |||
| 186 | - | if err != nil { | |||
| 187 | - | return err | |||
| 188 | - | } | |||
| 189 | - | ||||
| 190 | - | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |||
| 191 | - | err = s.passInsert(ctx, keyPath, value) | |||
| 192 | - | cancel() | |||
| 193 | - | if err != nil { | |||
| 194 | - | s.cleanupInsertedPaths(insertedNewPaths) | |||
| 195 | - | return fmt.Errorf("failed to store secret %s: %w", key, err) | |||
| 196 | - | } | |||
| 197 | - | ||||
| 198 | - | if _, exists := existingSet[key]; !exists { | |||
| 199 | - | insertedNewPaths = append(insertedNewPaths, keyPath) | |||
| 200 | - | } | |||
| 201 | - | } | |||
| 202 | - | ||||
| 203 | - | keySet := make(map[string]struct{}, len(existingKeys)+len(secretsMap)) | |||
| 204 | - | for _, key := range existingKeys { | |||
| 205 | - | keySet[key] = struct{}{} | |||
| 206 | - | } | |||
| 207 | - | for key := range secretsMap { | |||
| 208 | - | keySet[key] = struct{}{} | |||
| 209 | - | } | |||
| 210 | - | ||||
| 211 | - | keys := make([]string, 0, len(keySet)) | |||
| 212 | - | for key := range keySet { | |||
| 213 | - | keys = append(keys, key) | |||
| 214 | - | } | |||
| 215 | - | sort.Strings(keys) | |||
| 216 | - | ||||
| 217 | - | manifestPath, err := s.manifestPath(domainName) | |||
| 218 | - | if err != nil { | |||
| 219 | - | return err | |||
| 220 | - | } | |||
| 221 | - | ||||
| 222 | - | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |||
| 223 | - | if err := s.passInsert(ctx, manifestPath, strings.Join(keys, "\n")); err != nil { | |||
| 224 | - | cancel() | |||
| 225 | - | s.cleanupInsertedPaths(insertedNewPaths) | |||
| 226 | - | return fmt.Errorf("failed to update manifest: %w", err) | |||
| 227 | - | } | |||
| 228 | - | cancel() | |||
| 229 | - | ||||
| 230 | - | return nil | |||
| 231 | - | } | |||
| 232 | - | ||||
| 233 | - | // Delete removes a specific secret key from a domain. | |||
| 234 | - | func (s *PassStore) Delete(domainName, key string) error { | |||
| 235 | - | if _, err := s.domainPath(domainName); err != nil { | |||
| 236 | - | return err | |||
| 237 | - | } | |||
| 238 | - | ||||
| 239 | - | keyPath, err := s.keyPath(domainName, key) | |||
| 240 | - | if err != nil { | |||
| 241 | - | return err | |||
| 242 | - | } | |||
| 243 | - | ||||
| 244 | - | rmCtx, rmCancel := context.WithTimeout(context.Background(), s.timeout) | |||
| 245 | - | defer rmCancel() | |||
| 246 | - | if err := s.passRemove(rmCtx, keyPath); err != nil { | |||
| 247 | - | return err | |||
| 248 | - | } | |||
| 249 | - | ||||
| 250 | - | keys, err := s.ListKeys(domainName) | |||
| 251 | - | if err != nil { | |||
| 252 | - | return err | |||
| 253 | - | } | |||
| 254 | - | ||||
| 255 | - | updated := make([]string, 0, len(keys)) | |||
| 256 | - | for _, existingKey := range keys { | |||
| 257 | - | if existingKey == key { | |||
| 258 | - | continue | |||
| 259 | - | } | |||
| 260 | - | updated = append(updated, existingKey) | |||
| 261 | - | } | |||
| 262 | - | sort.Strings(updated) | |||
| 263 | - | ||||
| 264 | - | manifestPath, err := s.manifestPath(domainName) | |||
| 265 | - | if err != nil { | |||
| 266 | - | return err | |||
| 267 | - | } | |||
| 268 | - | ||||
| 269 | - | insertCtx, insertCancel := context.WithTimeout(context.Background(), s.timeout) | |||
| 270 | - | defer insertCancel() | |||
| 271 | - | if err := s.passInsert(insertCtx, manifestPath, strings.Join(updated, "\n")); err != nil { | |||
| 272 | - | return fmt.Errorf("failed to update manifest: %w", err) | |||
| 273 | - | } | |||
| 274 | - | ||||
| 275 | - | return nil | |||
| 276 | - | } | |||
| 277 | - | ||||
| 278 | - | // SetAttachmentIfEmpty sets attachment secrets only when no keys already exist. | |||
| 279 | - | // It performs the precondition check inside the store adapter so migration | |||
| 280 | - | // callers cannot accidentally split the check and write into separate steps. | |||
| 281 | - | func (s *PassStore) SetAttachmentIfEmpty(containerName string, secretsMap map[string]string) ([]string, error) { | |||
| 282 | - | s.mu.Lock() | |||
| 283 | - | defer s.mu.Unlock() | |||
| 284 | - | ||||
| 285 | - | existingKeys, err := s.listAttachmentKeysRecover(containerName) | |||
| 286 | - | if err != nil { | |||
| 287 | - | return nil, err | |||
| 288 | - | } | |||
| 289 | - | if len(existingKeys) > 0 { | |||
| 290 | - | return nil, fmt.Errorf("%w: pass secrets already exist for attachment %s (found %d keys)", domain.ErrSecretsAlreadyExist, containerName, len(existingKeys)) | |||
| 291 | - | } | |||
| 292 | - | if err := s.SetAttachment(containerName, secretsMap); err != nil { | |||
| 293 | - | return nil, err | |||
| 294 | - | } | |||
| 295 | - | return sortedMapKeys(secretsMap), nil | |||
| 296 | - | } | |||
| 297 | - | ||||
| 298 | - | // SetAttachment sets or updates multiple secrets for an attachment container. | |||
| 299 | - | func (s *PassStore) SetAttachment(containerName string, secretsMap map[string]string) error { | |||
| 300 | - | if _, err := s.attachmentPath(containerName); err != nil { | |||
| 301 | - | return err | |||
| 302 | - | } | |||
| 303 | - | ||||
| 304 | - | existingKeys, err := s.listAttachmentKeys(containerName) | |||
| 305 | - | if err != nil { | |||
| 306 | - | return err | |||
| 307 | - | } | |||
| 308 | - | ||||
| 309 | - | existingSet := make(map[string]struct{}, len(existingKeys)) | |||
| 310 | - | for _, key := range existingKeys { | |||
| 311 | - | existingSet[key] = struct{}{} | |||
| 312 | - | } | |||
| 313 | - | ||||
| 314 | - | insertedNewPaths := make([]string, 0, len(secretsMap)) | |||
| 315 | - | for key, value := range secretsMap { | |||
| 316 | - | if err := domain.ValidateEnvKey(key); err != nil { | |||
| 317 | - | return err | |||
| 318 | - | } | |||
| 319 | - | ||||
| 320 | - | keyPath, err := s.attachmentKeyPath(containerName, key) | |||
| 321 | - | if err != nil { | |||
| 322 | - | return err | |||
| 323 | - | } | |||
| 324 | - | ||||
| 325 | - | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |||
| 326 | - | err = s.passInsert(ctx, keyPath, value) | |||
| 327 | - | cancel() | |||
| 328 | - | if err != nil { | |||
| 329 | - | s.cleanupInsertedPaths(insertedNewPaths) | |||
| 330 | - | return fmt.Errorf("failed to store attachment secret %s: %w", key, err) | |||
| 331 | - | } | |||
| 332 | - | ||||
| 333 | - | if _, exists := existingSet[key]; !exists { | |||
| 334 | - | insertedNewPaths = append(insertedNewPaths, keyPath) | |||
| 335 | - | } | |||
| 336 | - | } | |||
| 337 | - | ||||
| 338 | - | keySet := make(map[string]struct{}, len(existingKeys)+len(secretsMap)) | |||
| 339 | - | for _, key := range existingKeys { | |||
| 340 | - | keySet[key] = struct{}{} | |||
| 341 | - | } | |||
| 342 | - | for key := range secretsMap { | |||
| 343 | - | keySet[key] = struct{}{} | |||
| 344 | - | } | |||
| 345 | - | ||||
| 346 | - | keys := make([]string, 0, len(keySet)) | |||
| 347 | - | for key := range keySet { | |||
| 348 | - | keys = append(keys, key) | |||
| 349 | - | } | |||
| 350 | - | sort.Strings(keys) | |||
| 351 | - | ||||
| 352 | - | manifestPath, err := s.attachmentManifestPath(containerName) | |||
| 353 | - | if err != nil { | |||
| 354 | - | return err | |||
| 355 | - | } | |||
| 356 | - | ||||
| 357 | - | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |||
| 358 | - | if err := s.passInsert(ctx, manifestPath, strings.Join(keys, "\n")); err != nil { | |||
| 359 | - | cancel() | |||
| 360 | - | s.cleanupInsertedPaths(insertedNewPaths) | |||
| 361 | - | return fmt.Errorf("failed to update attachment manifest: %w", err) | |||
| 362 | - | } | |||
| 363 | - | cancel() | |||
| 364 | - | ||||
| 365 | - | return nil | |||
| 366 | - | } | |||
| 367 | - | ||||
| 368 | - | // DeleteAttachment removes a specific secret key from an attachment container. | |||
| 369 | - | func (s *PassStore) DeleteAttachment(containerName, key string) error { | |||
| 370 | - | if _, err := s.attachmentPath(containerName); err != nil { | |||
| 371 | - | return err | |||
| 372 | - | } | |||
| 373 | - | ||||
| 374 | - | keyPath, err := s.attachmentKeyPath(containerName, key) | |||
| 375 | - | if err != nil { | |||
| 376 | - | return err | |||
| 377 | - | } | |||
| 378 | - | ||||
| 379 | - | rmCtx, rmCancel := context.WithTimeout(context.Background(), s.timeout) | |||
| 380 | - | defer rmCancel() | |||
| 381 | - | if err := s.passRemove(rmCtx, keyPath); err != nil { | |||
| 382 | - | return err | |||
| 383 | - | } | |||
| 384 | - | ||||
| 385 | - | keys, err := s.listAttachmentKeys(containerName) | |||
| 386 | - | if err != nil { | |||
| 387 | - | return err | |||
| 388 | - | } | |||
| 389 | - | ||||
| 390 | - | updated := make([]string, 0, len(keys)) | |||
| 391 | - | for _, existingKey := range keys { | |||
| 392 | - | if existingKey == key { | |||
| 393 | - | continue | |||
| 394 | - | } | |||
| 395 | - | updated = append(updated, existingKey) | |||
| 396 | - | } | |||
| 397 | - | sort.Strings(updated) | |||
| 398 | - | ||||
| 399 | - | manifestPath, err := s.attachmentManifestPath(containerName) | |||
| 400 | - | if err != nil { | |||
| 401 | - | return err | |||
| 402 | - | } | |||
| 403 | - | ||||
| 404 | - | insertCtx, insertCancel := context.WithTimeout(context.Background(), s.timeout) | |||
| 405 | - | defer insertCancel() | |||
| 406 | - | if err := s.passInsert(insertCtx, manifestPath, strings.Join(updated, "\n")); err != nil { | |||
| 407 | - | return fmt.Errorf("failed to update attachment manifest: %w", err) | |||
| 408 | - | } | |||
| 409 | - | ||||
| 410 | - | return nil | |||
| 411 | - | } | |||
| 412 | - | ||||
| 413 | - | // GetAllAttachment returns all secrets for an attachment container as a key-value map. | |||
| 414 | - | func (s *PassStore) GetAllAttachment(containerName string) (map[string]string, error) { | |||
| 415 | - | keys, err := s.listAttachmentKeys(containerName) | |||
| 416 | - | if err != nil { | |||
| 417 | - | return nil, err | |||
| 418 | - | } | |||
| 419 | - | ||||
| 420 | - | secretsMap := make(map[string]string) | |||
| 421 | - | for _, key := range keys { | |||
| 422 | - | keyPath, err := s.attachmentKeyPath(containerName, key) | |||
| 423 | - | if err != nil { | |||
| 424 | - | return nil, err | |||
| 425 | - | } | |||
| 426 | - | ||||
| 427 | - | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |||
| 428 | - | value, exists, err := s.passShow(ctx, keyPath) | |||
| 429 | - | cancel() | |||
| 430 | - | if err != nil { | |||
| 431 | - | return nil, err | |||
| 432 | - | } | |||
| 433 | - | if !exists { | |||
| 434 | - | s.log.Warn(). | |||
| 435 | - | Str(zerowrap.FieldLayer, "adapter"). | |||
| 436 | - | Str(zerowrap.FieldAdapter, "domainsecrets"). | |||
| 437 | - | Str("container", containerName). | |||
| 438 | - | Str("key", key). | |||
| 439 | - | Msg("attachment secret listed in manifest but missing in pass") | |||
| 440 | - | continue | |||
| 441 | - | } | |||
| 442 | - | secretsMap[key] = value | |||
| 443 | - | } | |||
| 444 | - | ||||
| 445 | - | return secretsMap, nil | |||
| 446 | - | } | |||
| 447 | - | ||||
| 448 | - | // ListAttachmentKeys finds attachment secrets for a domain from pass. | |||
| 449 | - | // Supports both new (collision-resistant) and legacy container naming for backwards compatibility. | |||
| 450 | - | func (s *PassStore) ListAttachmentKeys(domainName string) ([]out.AttachmentSecrets, error) { | |||
| 451 | - | if _, err := s.domainPath(domainName); err != nil { | |||
| 452 | - | return nil, err | |||
| 453 | - | } | |||
| 454 | - | ||||
| 455 | - | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |||
| 456 | - | defer cancel() | |||
| 457 | - | ||||
| 458 | - | containers, err := s.listTopLevelEntries(ctx, PassAttachmentPath) | |||
| 459 | - | if err != nil { | |||
| 460 | - | return nil, err | |||
| 461 | - | } | |||
| 462 | - | ||||
| 463 | - | // Try both new and legacy sanitization for backwards compatibility | |||
| 464 | - | sanitizedDomain := domain.SanitizeDomainForContainer(domainName) | |||
| 465 | - | sanitizedDomainLegacy := domain.SanitizeDomainForContainerLegacy(domainName) | |||
| 466 | - | prefixes := []string{ | |||
| 467 | - | "gordon-" + sanitizedDomain + "-", // New format (collision-resistant) | |||
| 468 | - | "gordon-" + sanitizedDomainLegacy + "-", // Old format (buggy but backwards compatible) | |||
| 469 | - | } | |||
| 470 | - | ||||
| 471 | - | seen := make(map[string]bool) | |||
| 472 | - | var results []out.AttachmentSecrets | |||
| 473 | - | for _, containerName := range containers { | |||
| 474 | - | matches := false | |||
| 475 | - | for _, prefix := range prefixes { | |||
| 476 | - | if strings.HasPrefix(containerName, prefix) { | |||
| 477 | - | matches = true | |||
| 478 | - | break | |||
| 479 | - | } | |||
| 480 | - | } | |||
| 481 | - | if !matches { | |||
| 482 | - | continue | |||
| 483 | - | } | |||
| 484 | - | ||||
| 485 | - | // Deduplicate results | |||
| 486 | - | if seen[containerName] { | |||
| 487 | - | continue | |||
| 488 | - | } | |||
| 489 | - | seen[containerName] = true | |||
| 490 | - | ||||
| 491 | - | manifestPath := fmt.Sprintf("%s/%s/.keys", PassAttachmentPath, containerName) | |||
| 492 | - | if err := secrets.ValidatePath(manifestPath); err != nil { | |||
| 493 | - | return nil, err | |||
| 494 | - | } | |||
| 495 | - | ||||
| 496 | - | showCtx, showCancel := context.WithTimeout(context.Background(), s.timeout) | |||
| 497 | - | content, exists, err := s.passShow(showCtx, manifestPath) | |||
| 498 | - | showCancel() | |||
| 499 | - | if err != nil { | |||
| 500 | - | return nil, err | |||
| 501 | - | } | |||
| 502 | - | if !exists { | |||
| 503 | - | continue | |||
| 504 | - | } | |||
| 505 | - | ||||
| 506 | - | keys := []string{} | |||
| 507 | - | for _, line := range strings.Split(content, "\n") { | |||
| 508 | - | key := strings.TrimSpace(line) | |||
| 509 | - | if key == "" { | |||
| 510 | - | continue | |||
| 511 | - | } | |||
| 512 | - | keys = append(keys, key) | |||
| 513 | - | } | |||
| 514 | - | ||||
| 515 | - | if len(keys) > 0 { | |||
| 516 | - | results = append(results, out.AttachmentSecrets{ | |||
| 517 | - | Service: containerName, | |||
| 518 | - | Keys: keys, | |||
| 519 | - | }) | |||
| 520 | - | } | |||
| 521 | - | } | |||
| 522 | - | ||||
| 523 | - | return results, nil | |||
| 524 | - | } | |||
| 525 | - | ||||
| 526 | - | func (s *PassStore) domainPath(domainName string) (string, error) { | |||
| 527 | - | safeDomain, err := s.sanitizeDomain(domainName) | |||
| 528 | - | if err != nil { | |||
| 529 | - | return "", err | |||
| 530 | - | } | |||
| 531 | - | path := fmt.Sprintf("%s/%s", PassDomainSecretsPath, safeDomain) | |||
| 532 | - | if err := secrets.ValidatePath(path); err != nil { | |||
| 533 | - | return "", err | |||
| 534 | - | } | |||
| 535 | - | return path, nil | |||
| 536 | - | } | |||
| 537 | - | ||||
| 538 | - | func (s *PassStore) keyPath(domainName, key string) (string, error) { | |||
| 539 | - | domainPath, err := s.domainPath(domainName) | |||
| 540 | - | if err != nil { | |||
| 541 | - | return "", err | |||
| 542 | - | } | |||
| 543 | - | if err := domain.ValidateEnvKey(key); err != nil { | |||
| 544 | - | return "", err | |||
| 545 | - | } | |||
| 546 | - | path := fmt.Sprintf("%s/%s", domainPath, key) | |||
| 547 | - | if err := secrets.ValidatePath(path); err != nil { | |||
| 548 | - | return "", err | |||
| 549 | - | } | |||
| 550 | - | return path, nil | |||
| 551 | - | } | |||
| 552 | - | ||||
| 553 | - | func (s *PassStore) manifestPath(domainName string) (string, error) { | |||
| 554 | - | domainPath, err := s.domainPath(domainName) | |||
| 555 | - | if err != nil { | |||
| 556 | - | return "", err | |||
| 557 | - | } | |||
| 558 | - | path := fmt.Sprintf("%s/.keys", domainPath) | |||
| 559 | - | if err := secrets.ValidatePath(path); err != nil { | |||
| 560 | - | return "", err | |||
| 561 | - | } | |||
| 562 | - | return path, nil | |||
| 563 | - | } | |||
| 564 | - | ||||
| 565 | - | func (s *PassStore) listDomainKeys(domainName string) ([]string, error) { | |||
| 566 | - | basePath, err := s.domainPath(domainName) | |||
| 567 | - | if err != nil { | |||
| 568 | - | return nil, err | |||
| 569 | - | } | |||
| 570 | - | ||||
| 571 | - | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |||
| 572 | - | defer cancel() | |||
| 573 | - | ||||
| 574 | - | entries, err := s.listTopLevelEntries(ctx, basePath) | |||
| 575 | - | if err != nil { | |||
| 576 | - | return nil, err | |||
| 577 | - | } | |||
| 578 | - | ||||
| 579 | - | keys := make([]string, 0, len(entries)) | |||
| 580 | - | for _, entry := range entries { | |||
| 581 | - | if entry == ".keys" { | |||
| 582 | - | continue | |||
| 583 | - | } | |||
| 584 | - | if err := domain.ValidateEnvKey(entry); err != nil { | |||
| 585 | - | continue | |||
| 586 | - | } | |||
| 587 | - | keys = append(keys, entry) | |||
| 588 | - | } | |||
| 589 | - | ||||
| 590 | - | sort.Strings(keys) | |||
| 591 | - | return keys, nil | |||
| 592 | - | } | |||
| 593 | - | ||||
| 594 | - | func (s *PassStore) attachmentPath(containerName string) (string, error) { | |||
| 595 | - | if err := domain.ValidateContainerName(containerName); err != nil { | |||
| 596 | - | return "", err | |||
| 597 | - | } | |||
| 598 | - | path := fmt.Sprintf("%s/%s", PassAttachmentPath, containerName) | |||
| 599 | - | if err := secrets.ValidatePath(path); err != nil { | |||
| 600 | - | return "", err | |||
| 601 | - | } | |||
| 602 | - | return path, nil | |||
| 603 | - | } | |||
| 604 | - | ||||
| 605 | - | func (s *PassStore) attachmentKeyPath(containerName, key string) (string, error) { | |||
| 606 | - | attachmentPath, err := s.attachmentPath(containerName) | |||
| 607 | - | if err != nil { | |||
| 608 | - | return "", err | |||
| 609 | - | } | |||
| 610 | - | if err := domain.ValidateEnvKey(key); err != nil { | |||
| 611 | - | return "", err | |||
| 612 | - | } | |||
| 613 | - | path := fmt.Sprintf("%s/%s", attachmentPath, key) | |||
| 614 | - | if err := secrets.ValidatePath(path); err != nil { | |||
| 615 | - | return "", err | |||
| 616 | - | } | |||
| 617 | - | return path, nil | |||
| 618 | - | } | |||
| 619 | - | ||||
| 620 | - | func (s *PassStore) attachmentManifestPath(containerName string) (string, error) { | |||
| 621 | - | attachmentPath, err := s.attachmentPath(containerName) | |||
| 622 | - | if err != nil { | |||
| 623 | - | return "", err | |||
| 624 | - | } | |||
| 625 | - | path := fmt.Sprintf("%s/.keys", attachmentPath) | |||
| 626 | - | if err := secrets.ValidatePath(path); err != nil { | |||
| 627 | - | return "", err | |||
| 628 | - | } | |||
| 629 | - | return path, nil | |||
| 630 | - | } | |||
| 631 | - | ||||
| 632 | - | func (s *PassStore) listAttachmentKeys(containerName string) ([]string, error) { | |||
| 633 | - | manifestPath, err := s.attachmentManifestPath(containerName) | |||
| 634 | - | if err != nil { | |||
| 635 | - | return nil, err | |||
| 636 | - | } | |||
| 637 | - | ||||
| 638 | - | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |||
| 639 | - | defer cancel() | |||
| 640 | - | ||||
| 641 | - | content, exists, err := s.passShow(ctx, manifestPath) | |||
| 642 | - | if err != nil { | |||
| 643 | - | return nil, err | |||
| 644 | - | } | |||
| 645 | - | if !exists { | |||
| 646 | - | return []string{}, nil | |||
| 647 | - | } | |||
| 648 | - | ||||
| 649 | - | keys := []string{} | |||
| 650 | - | for _, line := range strings.Split(content, "\n") { | |||
| 651 | - | key := strings.TrimSpace(line) | |||
| 652 | - | if key == "" { | |||
| 653 | - | continue | |||
| 654 | - | } | |||
| 655 | - | keys = append(keys, key) | |||
| 656 | - | } | |||
| 657 | - | ||||
| 658 | - | sort.Strings(keys) | |||
| 659 | - | return keys, nil | |||
| 660 | - | } | |||
| 661 | - | ||||
| 662 | - | func (s *PassStore) listAttachmentKeysRecover(containerName string) ([]string, error) { | |||
| 663 | - | keys, err := s.listAttachmentKeys(containerName) | |||
| 664 | - | if err != nil { | |||
| 665 | - | return nil, err | |||
| 666 | - | } | |||
| 667 | - | discovered, err := s.discoverAttachmentKeys(containerName) | |||
| 668 | - | if err != nil { | |||
| 669 | - | return nil, err | |||
| 670 | - | } | |||
| 671 | - | merged, changed := mergeUniqueKeys(keys, discovered) | |||
| 672 | - | if changed { | |||
| 673 | - | manifestPath, err := s.attachmentManifestPath(containerName) | |||
| 674 | - | if err != nil { | |||
| 675 | - | return nil, err | |||
| 676 | - | } | |||
| 677 | - | writeCtx, writeCancel := context.WithTimeout(context.Background(), s.timeout) | |||
| 678 | - | writeErr := s.passInsert(writeCtx, manifestPath, strings.Join(merged, "\n")) | |||
| 679 | - | writeCancel() | |||
| 680 | - | if writeErr != nil { | |||
| 681 | - | s.log.Warn(). | |||
| 682 | - | Str(zerowrap.FieldLayer, "adapter"). | |||
| 683 | - | Str(zerowrap.FieldAdapter, "domainsecrets"). | |||
| 684 | - | Str("container", containerName). | |||
| 685 | - | Err(writeErr). | |||
| 686 | - | Msg("failed to self-heal attachment pass manifest, continuing with recovered keys") | |||
| 687 | - | } | |||
| 688 | - | } | |||
| 689 | - | return merged, nil | |||
| 690 | - | } | |||
| 691 | - | ||||
| 692 | - | func (s *PassStore) discoverAttachmentKeys(containerName string) ([]string, error) { | |||
| 693 | - | basePath, err := s.attachmentPath(containerName) | |||
| 694 | - | if err != nil { | |||
| 695 | - | return nil, err | |||
| 696 | - | } | |||
| 697 | - | ||||
| 698 | - | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |||
| 699 | - | defer cancel() | |||
| 700 | - | ||||
| 701 | - | entries, err := s.listTopLevelEntries(ctx, basePath) | |||
| 702 | - | if err != nil { | |||
| 703 | - | return nil, err | |||
| 704 | - | } | |||
| 705 | - | ||||
| 706 | - | keys := make([]string, 0, len(entries)) | |||
| 707 | - | for _, entry := range entries { | |||
| 708 | - | if entry == ".keys" { | |||
| 709 | - | continue | |||
| 710 | - | } | |||
| 711 | - | if err := domain.ValidateEnvKey(entry); err != nil { | |||
| 712 | - | continue | |||
| 713 | - | } | |||
| 714 | - | keys = append(keys, entry) | |||
| 715 | - | } | |||
| 716 | - | ||||
| 717 | - | sort.Strings(keys) | |||
| 718 | - | return keys, nil | |||
| 719 | - | } | |||
| 720 | - | ||||
| 721 | - | func (s *PassStore) sanitizeDomain(domainName string) (string, error) { | |||
| 722 | - | safeDomain, err := domain.SanitizeDomainForEnvFile(domainName) | |||
| 723 | - | if err != nil { | |||
| 724 | - | s.log.Warn(). | |||
| 725 | - | Str(zerowrap.FieldLayer, "adapter"). | |||
| 726 | - | Str(zerowrap.FieldAdapter, "domainsecrets"). | |||
| 727 | - | Str("domain", domainName). | |||
| 728 | - | Err(err). | |||
| 729 | - | Msg("rejected invalid domain") | |||
| 730 | - | return "", domain.ErrPathTraversal | |||
| 731 | - | } | |||
| 732 | - | return safeDomain, nil | |||
| 733 | - | } | |||
| 734 | - | ||||
| 735 | - | // ManifestExists checks if the manifest exists for a domain. | |||
| 736 | - | func (s *PassStore) ManifestExists(domainName string) (bool, error) { | |||
| 737 | - | manifestPath, err := s.manifestPath(domainName) | |||
| 738 | - | if err != nil { | |||
| 739 | - | return false, err | |||
| 740 | - | } | |||
| 741 | - | ||||
| 742 | - | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |||
| 743 | - | defer cancel() | |||
| 744 | - | ||||
| 745 | - | _, exists, err := s.passShow(ctx, manifestPath) | |||
| 746 | - | if err != nil { | |||
| 747 | - | return false, err | |||
| 748 | - | } | |||
| 749 | - | ||||
| 750 | - | return exists, nil | |||
| 751 | - | } | |||
| 752 | - | ||||
| 753 | - | func (s *PassStore) passInsert(ctx context.Context, path, value string) error { | |||
| 754 | - | cmd := exec.CommandContext(ctx, "pass", "insert", "-m", "-f", path) //nolint:gosec // binary is constant ("pass"); path arguments validated by secrets path validator | |||
| 755 | - | cmd.Stdin = strings.NewReader(value) | |||
| 756 | - | output, err := cmd.CombinedOutput() | |||
| 757 | - | if err != nil { | |||
| 758 | - | return fmt.Errorf("pass insert failed: %s: %w", strings.TrimSpace(string(output)), err) | |||
| 759 | - | } | |||
| 760 | - | return nil | |||
| 761 | - | } | |||
| 762 | - | ||||
| 763 | - | func (s *PassStore) passRemove(ctx context.Context, path string) error { | |||
| 764 | - | cmd := exec.CommandContext(ctx, "pass", "rm", "-f", path) //nolint:gosec // binary is constant ("pass"); path arguments validated by secrets path validator | |||
| 765 | - | output, err := cmd.CombinedOutput() | |||
| 766 | - | if err != nil { | |||
| 767 | - | if passEntryMissing(string(output)) { | |||
| 768 | - | return nil | |||
| 769 | - | } | |||
| 770 | - | return fmt.Errorf("pass rm failed: %s: %w", strings.TrimSpace(string(output)), err) | |||
| 771 | - | } | |||
| 772 | - | return nil | |||
| 773 | - | } | |||
| 774 | - | ||||
| 775 | - | func (s *PassStore) passShow(ctx context.Context, path string) (string, bool, error) { | |||
| 776 | - | cmd := exec.CommandContext(ctx, "pass", "show", path) //nolint:gosec // binary is constant ("pass"); path arguments validated by secrets path validator | |||
| 777 | - | output, err := cmd.CombinedOutput() | |||
| 778 | - | if err != nil { | |||
| 779 | - | if passEntryMissing(string(output)) { | |||
| 780 | - | return "", false, nil | |||
| 781 | - | } | |||
| 782 | - | return "", false, fmt.Errorf("pass show failed: %s: %w", strings.TrimSpace(string(output)), err) | |||
| 783 | - | } | |||
| 784 | - | ||||
| 785 | - | clean := ansiRegex.ReplaceAllString(string(output), "") | |||
| 786 | - | clean = strings.TrimRight(clean, "\r\n") | |||
| 787 | - | return clean, true, nil | |||
| 788 | - | } | |||
| 789 | - | ||||
| 790 | - | func (s *PassStore) listTopLevelEntries(ctx context.Context, basePath string) ([]string, error) { | |||
| 791 | - | if err := secrets.ValidatePath(basePath); err != nil { | |||
| 792 | - | return nil, err | |||
| 793 | - | } | |||
| 794 | - | ||||
| 795 | - | cmd := exec.CommandContext(ctx, "pass", "ls", basePath) //nolint:gosec // binary is constant ("pass"); path arguments validated by secrets path validator | |||
| 796 | - | output, err := cmd.CombinedOutput() | |||
| 797 | - | if err != nil { | |||
| 798 | - | if passEntryMissing(string(output)) { | |||
| 799 | - | return []string{}, nil | |||
| 800 | - | } | |||
| 801 | - | return nil, fmt.Errorf("pass ls failed: %s: %w", strings.TrimSpace(string(output)), err) | |||
| 802 | - | } | |||
| 803 | - | ||||
| 804 | - | entries := []string{} | |||
| 805 | - | for _, entry := range parsePassListOutput(basePath, string(output)) { | |||
| 806 | - | if entry.depth == 1 { | |||
| 807 | - | entries = append(entries, entry.name) | |||
| 808 | - | } | |||
| 809 | - | } | |||
| 810 | - | ||||
| 811 | - | return entries, nil | |||
| 812 | - | } | |||
| 813 | - | ||||
| 814 | - | type passListEntry struct { | |||
| 815 | - | name string | |||
| 816 | - | depth int | |||
| 817 | - | } | |||
| 818 | - | ||||
| 819 | - | func parsePassListOutput(basePath, output string) []passListEntry { | |||
| 820 | - | lines := strings.Split(output, "\n") | |||
| 821 | - | entries := []passListEntry{} | |||
| 822 | - | ||||
| 823 | - | for _, line := range lines { | |||
| 824 | - | line = ansiRegex.ReplaceAllString(line, "") | |||
| 825 | - | if strings.TrimSpace(line) == "" { | |||
| 826 | - | continue | |||
| 827 | - | } | |||
| 828 | - | ||||
| 829 | - | depth := 0 | |||
| 830 | - | prefixLoop: | |||
| 831 | - | for { | |||
| 832 | - | switch { | |||
| 833 | - | case strings.HasPrefix(line, "│ "): | |||
| 834 | - | line = strings.TrimPrefix(line, "│ ") | |||
| 835 | - | depth++ | |||
| 836 | - | case strings.HasPrefix(line, "| "): | |||
| 837 | - | line = strings.TrimPrefix(line, "| ") | |||
| 838 | - | depth++ | |||
| 839 | - | case strings.HasPrefix(line, " "): | |||
| 840 | - | line = strings.TrimPrefix(line, " ") | |||
| 841 | - | depth++ | |||
| 842 | - | default: | |||
| 843 | - | break prefixLoop | |||
| 844 | - | } | |||
| 845 | - | } | |||
| 846 | - | ||||
| 847 | - | switch { | |||
| 848 | - | case strings.HasPrefix(line, "├── "): | |||
| 849 | - | line = strings.TrimPrefix(line, "├── ") | |||
| 850 | - | depth++ | |||
| 851 | - | case strings.HasPrefix(line, "└── "): | |||
| 852 | - | line = strings.TrimPrefix(line, "└── ") | |||
| 853 | - | depth++ | |||
| 854 | - | case strings.HasPrefix(line, "|-- "): | |||
| 855 | - | line = strings.TrimPrefix(line, "|-- ") | |||
| 856 | - | depth++ | |||
| 857 | - | case strings.HasPrefix(line, "`-- "): | |||
| 858 | - | line = strings.TrimPrefix(line, "`-- ") | |||
| 859 | - | depth++ | |||
| 860 | - | } | |||
| 861 | - | ||||
| 862 | - | name := strings.TrimSpace(line) | |||
| 863 | - | if name == "" || name == basePath { | |||
| 864 | - | continue | |||
| 865 | - | } | |||
| 866 | - | if depth == 0 { | |||
| 867 | - | continue | |||
| 868 | - | } | |||
| 869 | - | ||||
| 870 | - | entries = append(entries, passListEntry{name: name, depth: depth}) | |||
| 871 | - | } | |||
| 872 | - | ||||
| 873 | - | return entries | |||
| 874 | - | } | |||
| 875 | - | ||||
| 876 | - | func passEntryMissing(output string) bool { | |||
| 877 | - | clean := ansiRegex.ReplaceAllString(output, "") | |||
| 878 | - | lower := strings.ToLower(clean) | |||
| 879 | - | return strings.Contains(lower, "not in the password store") || | |||
| 880 | - | strings.Contains(lower, "password store is empty") | |||
| 881 | - | } | |||
| 882 | - | ||||
| 883 | - | func sortedMapKeys(values map[string]string) []string { | |||
| 884 | - | keys := make([]string, 0, len(values)) | |||
| 885 | - | for key := range values { | |||
| 886 | - | keys = append(keys, key) | |||
| 887 | - | } | |||
| 888 | - | sort.Strings(keys) | |||
| 889 | - | return keys | |||
| 890 | - | } | |||
| 891 | - | ||||
| 892 | - | func mergeUniqueKeys(primary, secondary []string) ([]string, bool) { | |||
| 893 | - | mergedSet := make(map[string]struct{}, len(primary)+len(secondary)) | |||
| 894 | - | for _, key := range primary { | |||
| 895 | - | mergedSet[key] = struct{}{} | |||
| 896 | - | } | |||
| 897 | - | for _, key := range secondary { | |||
| 898 | - | mergedSet[key] = struct{}{} | |||
| 899 | - | } | |||
| 900 | - | ||||
| 901 | - | merged := make([]string, 0, len(mergedSet)) | |||
| 902 | - | for key := range mergedSet { | |||
| 903 | - | merged = append(merged, key) | |||
| 904 | - | } | |||
| 905 | - | sort.Strings(merged) | |||
| 906 | - | ||||
| 907 | - | if len(merged) != len(primary) { | |||
| 908 | - | return merged, true | |||
| 909 | - | } | |||
| 910 | - | ||||
| 911 | - | for i := range primary { | |||
| 912 | - | if primary[i] != merged[i] { | |||
| 913 | - | return merged, true | |||
| 914 | - | } | |||
| 915 | - | } | |||
| 916 | - | ||||
| 917 | - | return merged, false | |||
| 918 | - | } | |||
| 919 | - | ||||
| 920 | - | func (s *PassStore) cleanupInsertedPaths(paths []string) { | |||
| 921 | - | if len(paths) == 0 { | |||
| 922 | - | return | |||
| 923 | - | } | |||
| 924 | - | ||||
| 925 | - | for _, path := range paths { | |||
| 926 | - | // Wrap in a closure so defer cancel() runs at the end of each iteration, | |||
| 927 | - | // not at the end of cleanupInsertedPaths (which would delay all cancels). | |||
| 928 | - | func(p string) { | |||
| 929 | - | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |||
| 930 | - | defer cancel() | |||
| 931 | - | _ = s.passRemove(ctx, p) | |||
| 932 | - | }(path) | |||
| 933 | - | } | |||
| 934 | - | } | |||
D internal/adapters/out/domainsecrets/pass_store_test.go +0 -330
| @@ -1,330 +0,0 @@ | |||||
| 1 | - | package domainsecrets | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "context" | |||
| 5 | - | "fmt" | |||
| 6 | - | "os/exec" | |||
| 7 | - | "strings" | |||
| 8 | - | "testing" | |||
| 9 | - | "time" | |||
| 10 | - | ||||
| 11 | - | "github.com/stretchr/testify/assert" | |||
| 12 | - | "github.com/stretchr/testify/require" | |||
| 13 | - | ||||
| 14 | - | "github.com/bnema/gordon/internal/domain" | |||
| 15 | - | ) | |||
| 16 | - | ||||
| 17 | - | func passCmd(args ...string) error { | |||
| 18 | - | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) | |||
| 19 | - | defer cancel() | |||
| 20 | - | return exec.CommandContext(ctx, "pass", args...).Run() | |||
| 21 | - | } | |||
| 22 | - | ||||
| 23 | - | func passInsertValue(path, value string) error { | |||
| 24 | - | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) | |||
| 25 | - | defer cancel() | |||
| 26 | - | cmd := exec.CommandContext(ctx, "pass", "insert", "-m", "-f", path) | |||
| 27 | - | cmd.Stdin = strings.NewReader(value) | |||
| 28 | - | _, err := cmd.CombinedOutput() | |||
| 29 | - | return err | |||
| 30 | - | } | |||
| 31 | - | ||||
| 32 | - | func passShow(path string) (string, error) { | |||
| 33 | - | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) | |||
| 34 | - | defer cancel() | |||
| 35 | - | out, err := exec.CommandContext(ctx, "pass", "show", path).CombinedOutput() | |||
| 36 | - | if err != nil { | |||
| 37 | - | return "", err | |||
| 38 | - | } | |||
| 39 | - | return strings.TrimSpace(string(out)), nil | |||
| 40 | - | } | |||
| 41 | - | ||||
| 42 | - | func requirePass(t *testing.T) { | |||
| 43 | - | if err := passCmd("version"); err != nil { | |||
| 44 | - | t.Skip("pass not available") | |||
| 45 | - | } | |||
| 46 | - | if err := passCmd("ls"); err != nil { | |||
| 47 | - | t.Skip("pass store not initialized") | |||
| 48 | - | } | |||
| 49 | - | } | |||
| 50 | - | ||||
| 51 | - | func cleanupPassDomain(_ *testing.T, domainName string, keys []string) { | |||
| 52 | - | safeDomain, err := domain.SanitizeDomainForEnvFile(domainName) | |||
| 53 | - | if err != nil { | |||
| 54 | - | return | |||
| 55 | - | } | |||
| 56 | - | ||||
| 57 | - | for _, key := range keys { | |||
| 58 | - | path := fmt.Sprintf("%s/%s/%s", PassDomainSecretsPath, safeDomain, key) | |||
| 59 | - | _ = passCmd("rm", "-f", path) | |||
| 60 | - | } | |||
| 61 | - | ||||
| 62 | - | manifestPath := fmt.Sprintf("%s/%s/.keys", PassDomainSecretsPath, safeDomain) | |||
| 63 | - | _ = passCmd("rm", "-f", manifestPath) | |||
| 64 | - | } | |||
| 65 | - | ||||
| 66 | - | func TestPassStore_SetGetDelete(t *testing.T) { | |||
| 67 | - | requirePass(t) | |||
| 68 | - | ||||
| 69 | - | store, err := NewPassStore(testLogger()) | |||
| 70 | - | require.NoError(t, err) | |||
| 71 | - | ||||
| 72 | - | domainName := fmt.Sprintf("test.%d.example.com", time.Now().UnixNano()) | |||
| 73 | - | keys := []string{"API_KEY", "DB_PASSWORD"} | |||
| 74 | - | defer cleanupPassDomain(t, domainName, keys) | |||
| 75 | - | ||||
| 76 | - | secretsMap := map[string]string{ | |||
| 77 | - | "API_KEY": "alpha", | |||
| 78 | - | "DB_PASSWORD": "bravo", | |||
| 79 | - | } | |||
| 80 | - | ||||
| 81 | - | err = store.Set(domainName, secretsMap) | |||
| 82 | - | require.NoError(t, err) | |||
| 83 | - | ||||
| 84 | - | keysList, err := store.ListKeys(domainName) | |||
| 85 | - | require.NoError(t, err) | |||
| 86 | - | assert.Len(t, keysList, 2) | |||
| 87 | - | assert.ElementsMatch(t, keys, keysList) | |||
| 88 | - | ||||
| 89 | - | values, err := store.GetAll(domainName) | |||
| 90 | - | require.NoError(t, err) | |||
| 91 | - | assert.Equal(t, "alpha", values["API_KEY"]) | |||
| 92 | - | assert.Equal(t, "bravo", values["DB_PASSWORD"]) | |||
| 93 | - | ||||
| 94 | - | err = store.Delete(domainName, "DB_PASSWORD") | |||
| 95 | - | require.NoError(t, err) | |||
| 96 | - | ||||
| 97 | - | keysList, err = store.ListKeys(domainName) | |||
| 98 | - | require.NoError(t, err) | |||
| 99 | - | assert.ElementsMatch(t, []string{"API_KEY"}, keysList) | |||
| 100 | - | } | |||
| 101 | - | ||||
| 102 | - | func TestPassStore_SetGetAttachment(t *testing.T) { | |||
| 103 | - | requirePass(t) | |||
| 104 | - | ||||
| 105 | - | store, err := NewPassStore(testLogger()) | |||
| 106 | - | require.NoError(t, err) | |||
| 107 | - | ||||
| 108 | - | containerName := fmt.Sprintf("gitea-postgres-%d", time.Now().UnixNano()) | |||
| 109 | - | keys := []string{"POSTGRES_USER", "POSTGRES_PASSWORD"} | |||
| 110 | - | defer CleanupPassAttachment(t, containerName, keys) | |||
| 111 | - | ||||
| 112 | - | secretsMap := map[string]string{ | |||
| 113 | - | "POSTGRES_USER": "gitea", | |||
| 114 | - | "POSTGRES_PASSWORD": "secret123", | |||
| 115 | - | } | |||
| 116 | - | ||||
| 117 | - | err = store.SetAttachment(containerName, secretsMap) | |||
| 118 | - | require.NoError(t, err) | |||
| 119 | - | ||||
| 120 | - | values, err := store.GetAllAttachment(containerName) | |||
| 121 | - | require.NoError(t, err) | |||
| 122 | - | assert.Equal(t, "gitea", values["POSTGRES_USER"]) | |||
| 123 | - | assert.Equal(t, "secret123", values["POSTGRES_PASSWORD"]) | |||
| 124 | - | } | |||
| 125 | - | ||||
| 126 | - | func TestPassStore_ListAttachmentKeys_AfterSet(t *testing.T) { | |||
| 127 | - | requirePass(t) | |||
| 128 | - | ||||
| 129 | - | store, err := NewPassStore(testLogger()) | |||
| 130 | - | require.NoError(t, err) | |||
| 131 | - | ||||
| 132 | - | containerName := fmt.Sprintf("redis-cache-%d", time.Now().UnixNano()) | |||
| 133 | - | keys := []string{"REDIS_PASSWORD"} | |||
| 134 | - | defer CleanupPassAttachment(t, containerName, keys) | |||
| 135 | - | ||||
| 136 | - | secretsMap := map[string]string{ | |||
| 137 | - | "REDIS_PASSWORD": "redis123", | |||
| 138 | - | } | |||
| 139 | - | ||||
| 140 | - | err = store.SetAttachment(containerName, secretsMap) | |||
| 141 | - | require.NoError(t, err) | |||
| 142 | - | ||||
| 143 | - | values, err := store.GetAllAttachment(containerName) | |||
| 144 | - | require.NoError(t, err) | |||
| 145 | - | assert.Len(t, values, 1) | |||
| 146 | - | assert.Equal(t, "redis123", values["REDIS_PASSWORD"]) | |||
| 147 | - | } | |||
| 148 | - | ||||
| 149 | - | func TestPassStore_DeleteAttachment(t *testing.T) { | |||
| 150 | - | requirePass(t) | |||
| 151 | - | ||||
| 152 | - | store, err := NewPassStore(testLogger()) | |||
| 153 | - | require.NoError(t, err) | |||
| 154 | - | ||||
| 155 | - | containerName := fmt.Sprintf("gitea-postgres-%d", time.Now().UnixNano()) | |||
| 156 | - | keys := []string{"POSTGRES_USER", "POSTGRES_PASSWORD"} | |||
| 157 | - | defer CleanupPassAttachment(t, containerName, keys) | |||
| 158 | - | ||||
| 159 | - | // Set 2 secrets | |||
| 160 | - | secretsMap := map[string]string{ | |||
| 161 | - | "POSTGRES_USER": "gitea", | |||
| 162 | - | "POSTGRES_PASSWORD": "secret123", | |||
| 163 | - | } | |||
| 164 | - | err = store.SetAttachment(containerName, secretsMap) | |||
| 165 | - | require.NoError(t, err) | |||
| 166 | - | ||||
| 167 | - | // Delete 1 | |||
| 168 | - | err = store.DeleteAttachment(containerName, "POSTGRES_PASSWORD") | |||
| 169 | - | require.NoError(t, err) | |||
| 170 | - | ||||
| 171 | - | // Verify remaining via GetAllAttachment | |||
| 172 | - | values, err := store.GetAllAttachment(containerName) | |||
| 173 | - | require.NoError(t, err) | |||
| 174 | - | assert.Len(t, values, 1) | |||
| 175 | - | assert.Equal(t, "gitea", values["POSTGRES_USER"]) | |||
| 176 | - | _, exists := values["POSTGRES_PASSWORD"] | |||
| 177 | - | assert.False(t, exists) | |||
| 178 | - | } | |||
| 179 | - | ||||
| 180 | - | func TestPassStore_Delete_Idempotent(t *testing.T) { | |||
| 181 | - | requirePass(t) | |||
| 182 | - | ||||
| 183 | - | store, err := NewPassStore(testLogger()) | |||
| 184 | - | require.NoError(t, err) | |||
| 185 | - | ||||
| 186 | - | domainName := fmt.Sprintf("idempotent.%d.example.com", time.Now().UnixNano()) | |||
| 187 | - | keys := []string{"API_KEY"} | |||
| 188 | - | defer cleanupPassDomain(t, domainName, keys) | |||
| 189 | - | ||||
| 190 | - | err = store.Set(domainName, map[string]string{"API_KEY": "alpha"}) | |||
| 191 | - | require.NoError(t, err) | |||
| 192 | - | ||||
| 193 | - | err = store.Delete(domainName, "API_KEY") | |||
| 194 | - | require.NoError(t, err) | |||
| 195 | - | ||||
| 196 | - | // Second delete of an already-removed key must be a no-op. | |||
| 197 | - | err = store.Delete(domainName, "API_KEY") | |||
| 198 | - | require.NoError(t, err) | |||
| 199 | - | ||||
| 200 | - | values, err := store.GetAll(domainName) | |||
| 201 | - | require.NoError(t, err) | |||
| 202 | - | assert.NotContains(t, values, "API_KEY") | |||
| 203 | - | } | |||
| 204 | - | ||||
| 205 | - | func TestPassStore_DeleteAttachment_Idempotent(t *testing.T) { | |||
| 206 | - | requirePass(t) | |||
| 207 | - | ||||
| 208 | - | store, err := NewPassStore(testLogger()) | |||
| 209 | - | require.NoError(t, err) | |||
| 210 | - | ||||
| 211 | - | containerName := fmt.Sprintf("idempotent-attachment-%d", time.Now().UnixNano()) | |||
| 212 | - | keys := []string{"POSTGRES_PASSWORD"} | |||
| 213 | - | defer CleanupPassAttachment(t, containerName, keys) | |||
| 214 | - | ||||
| 215 | - | err = store.SetAttachment(containerName, map[string]string{"POSTGRES_PASSWORD": "secret123"}) | |||
| 216 | - | require.NoError(t, err) | |||
| 217 | - | ||||
| 218 | - | err = store.DeleteAttachment(containerName, "POSTGRES_PASSWORD") | |||
| 219 | - | require.NoError(t, err) | |||
| 220 | - | ||||
| 221 | - | // Second delete of an already-removed key must be a no-op. | |||
| 222 | - | err = store.DeleteAttachment(containerName, "POSTGRES_PASSWORD") | |||
| 223 | - | require.NoError(t, err) | |||
| 224 | - | ||||
| 225 | - | values, err := store.GetAllAttachment(containerName) | |||
| 226 | - | require.NoError(t, err) | |||
| 227 | - | assert.NotContains(t, values, "POSTGRES_PASSWORD") | |||
| 228 | - | } | |||
| 229 | - | ||||
| 230 | - | func TestPassStore_SetAttachment_OverwriteValue(t *testing.T) { | |||
| 231 | - | requirePass(t) | |||
| 232 | - | ||||
| 233 | - | store, err := NewPassStore(testLogger()) | |||
| 234 | - | require.NoError(t, err) | |||
| 235 | - | ||||
| 236 | - | containerName := fmt.Sprintf("attachment-overwrite-%d", time.Now().UnixNano()) | |||
| 237 | - | keys := []string{"REDIS_PASSWORD"} | |||
| 238 | - | defer CleanupPassAttachment(t, containerName, keys) | |||
| 239 | - | ||||
| 240 | - | err = store.SetAttachment(containerName, map[string]string{"REDIS_PASSWORD": "first"}) | |||
| 241 | - | require.NoError(t, err) | |||
| 242 | - | ||||
| 243 | - | // Re-setting same key with a different value should deterministically overwrite. | |||
| 244 | - | err = store.SetAttachment(containerName, map[string]string{"REDIS_PASSWORD": "second"}) | |||
| 245 | - | require.NoError(t, err) | |||
| 246 | - | ||||
| 247 | - | values, err := store.GetAllAttachment(containerName) | |||
| 248 | - | require.NoError(t, err) | |||
| 249 | - | assert.Equal(t, "second", values["REDIS_PASSWORD"]) | |||
| 250 | - | } | |||
| 251 | - | ||||
| 252 | - | func TestPassStore_ListKeys_RecoversOrphanedEntries(t *testing.T) { | |||
| 253 | - | requirePass(t) | |||
| 254 | - | ||||
| 255 | - | store, err := NewPassStore(testLogger()) | |||
| 256 | - | require.NoError(t, err) | |||
| 257 | - | ||||
| 258 | - | domainName := fmt.Sprintf("orphan.%d.example.com", time.Now().UnixNano()) | |||
| 259 | - | keys := []string{"EXISTING", "ORIGIN"} | |||
| 260 | - | defer cleanupPassDomain(t, domainName, keys) | |||
| 261 | - | ||||
| 262 | - | err = store.Set(domainName, map[string]string{"EXISTING": "present"}) | |||
| 263 | - | require.NoError(t, err) | |||
| 264 | - | ||||
| 265 | - | safeDomain, err := domain.SanitizeDomainForEnvFile(domainName) | |||
| 266 | - | require.NoError(t, err) | |||
| 267 | - | ||||
| 268 | - | orphanPath := fmt.Sprintf("%s/%s/ORIGIN", PassDomainSecretsPath, safeDomain) | |||
| 269 | - | err = passInsertValue(orphanPath, "https://example.com") | |||
| 270 | - | require.NoError(t, err) | |||
| 271 | - | ||||
| 272 | - | manifestPath := fmt.Sprintf("%s/%s/.keys", PassDomainSecretsPath, safeDomain) | |||
| 273 | - | err = passInsertValue(manifestPath, "EXISTING\n") | |||
| 274 | - | require.NoError(t, err) | |||
| 275 | - | ||||
| 276 | - | listed, err := store.ListKeys(domainName) | |||
| 277 | - | require.NoError(t, err) | |||
| 278 | - | assert.ElementsMatch(t, []string{"EXISTING", "ORIGIN"}, listed) | |||
| 279 | - | ||||
| 280 | - | values, err := store.GetAll(domainName) | |||
| 281 | - | require.NoError(t, err) | |||
| 282 | - | assert.Equal(t, "present", values["EXISTING"]) | |||
| 283 | - | assert.Equal(t, "https://example.com", values["ORIGIN"]) | |||
| 284 | - | ||||
| 285 | - | manifest, err := passShow(manifestPath) | |||
| 286 | - | require.NoError(t, err) | |||
| 287 | - | assert.Contains(t, manifest, "EXISTING") | |||
| 288 | - | assert.Contains(t, manifest, "ORIGIN") | |||
| 289 | - | } | |||
| 290 | - | ||||
| 291 | - | func TestParsePassListOutput(t *testing.T) { | |||
| 292 | - | tests := []struct { | |||
| 293 | - | name string | |||
| 294 | - | basePath string | |||
| 295 | - | output string | |||
| 296 | - | want []passListEntry | |||
| 297 | - | }{ | |||
| 298 | - | { | |||
| 299 | - | name: "simple tree", | |||
| 300 | - | basePath: "domain", | |||
| 301 | - | output: "domain\n├── key1\n└── key2", | |||
| 302 | - | want: []passListEntry{{name: "key1", depth: 1}, {name: "key2", depth: 1}}, | |||
| 303 | - | }, | |||
| 304 | - | { | |||
| 305 | - | name: "nested tree", | |||
| 306 | - | basePath: "domain", | |||
| 307 | - | output: "domain\n│ ├── subkey1\n│ └── subkey2\n└── key1", | |||
| 308 | - | want: []passListEntry{{name: "subkey1", depth: 2}, {name: "subkey2", depth: 2}, {name: "key1", depth: 1}}, | |||
| 309 | - | }, | |||
| 310 | - | { | |||
| 311 | - | name: "empty output", | |||
| 312 | - | basePath: "domain", | |||
| 313 | - | output: "", | |||
| 314 | - | want: []passListEntry{}, | |||
| 315 | - | }, | |||
| 316 | - | { | |||
| 317 | - | name: "ASCII fallback chars", | |||
| 318 | - | basePath: "domain", | |||
| 319 | - | output: "domain\n| |-- subkey1\n`-- key1", | |||
| 320 | - | want: []passListEntry{{name: "subkey1", depth: 2}, {name: "key1", depth: 1}}, | |||
| 321 | - | }, | |||
| 322 | - | } | |||
| 323 | - | ||||
| 324 | - | for _, tt := range tests { | |||
| 325 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 326 | - | got := parsePassListOutput(tt.basePath, tt.output) | |||
| 327 | - | assert.Equal(t, tt.want, got) | |||
| 328 | - | }) | |||
| 329 | - | } | |||
| 330 | - | } | |||
D internal/adapters/out/domainsecrets/store.go +0 -554
| @@ -1,554 +0,0 @@ | |||||
| 1 | - | // Package domainsecrets implements the DomainSecretStore adapter using filesystem-based env files. | |||
| 2 | - | package domainsecrets | |||
| 3 | - | ||||
| 4 | - | import ( | |||
| 5 | - | "bufio" | |||
| 6 | - | "fmt" | |||
| 7 | - | "os" | |||
| 8 | - | "path/filepath" | |||
| 9 | - | "sort" | |||
| 10 | - | "strings" | |||
| 11 | - | ||||
| 12 | - | "github.com/bnema/zerowrap" | |||
| 13 | - | ||||
| 14 | - | "github.com/bnema/gordon/internal/boundaries/out" | |||
| 15 | - | "github.com/bnema/gordon/internal/domain" | |||
| 16 | - | ) | |||
| 17 | - | ||||
| 18 | - | // FileStore implements the DomainSecretStore interface using filesystem-based env files. | |||
| 19 | - | // This adapter is responsible only for file I/O operations; domain validation | |||
| 20 | - | // should be performed by the use case layer before calling these methods. | |||
| 21 | - | type FileStore struct { | |||
| 22 | - | envDir string | |||
| 23 | - | log zerowrap.Logger | |||
| 24 | - | } | |||
| 25 | - | ||||
| 26 | - | // NewFileStore creates a new file-based domain secret store. | |||
| 27 | - | func NewFileStore(envDir string, log zerowrap.Logger) (*FileStore, error) { | |||
| 28 | - | // Ensure env directory exists | |||
| 29 | - | if err := os.MkdirAll(envDir, 0700); err != nil { | |||
| 30 | - | return nil, fmt.Errorf("failed to create env directory %s: %w", envDir, err) | |||
| 31 | - | } | |||
| 32 | - | ||||
| 33 | - | log.Debug(). | |||
| 34 | - | Str(zerowrap.FieldLayer, "adapter"). | |||
| 35 | - | Str(zerowrap.FieldAdapter, "domainsecrets"). | |||
| 36 | - | Str("env_dir", envDir). | |||
| 37 | - | Msg("domain secret store initialized") | |||
| 38 | - | ||||
| 39 | - | return &FileStore{ | |||
| 40 | - | envDir: envDir, | |||
| 41 | - | log: log, | |||
| 42 | - | }, nil | |||
| 43 | - | } | |||
| 44 | - | ||||
| 45 | - | // ListKeys returns the list of secret keys for a domain (not values). | |||
| 46 | - | func (s *FileStore) ListKeys(domainName string) ([]string, error) { | |||
| 47 | - | envFile, err := s.validateEnvFilePath(domainName) | |||
| 48 | - | if err != nil { | |||
| 49 | - | return nil, err | |||
| 50 | - | } | |||
| 51 | - | ||||
| 52 | - | file, err := os.Open(envFile) | |||
| 53 | - | if err != nil { | |||
| 54 | - | if os.IsNotExist(err) { | |||
| 55 | - | return []string{}, nil | |||
| 56 | - | } | |||
| 57 | - | return nil, fmt.Errorf("failed to open env file: %w", err) | |||
| 58 | - | } | |||
| 59 | - | defer file.Close() | |||
| 60 | - | ||||
| 61 | - | var keys []string | |||
| 62 | - | scanner := bufio.NewScanner(file) | |||
| 63 | - | for scanner.Scan() { | |||
| 64 | - | line := strings.TrimSpace(scanner.Text()) | |||
| 65 | - | // Skip empty lines and comments | |||
| 66 | - | if line == "" || strings.HasPrefix(line, "#") { | |||
| 67 | - | continue | |||
| 68 | - | } | |||
| 69 | - | // Extract key from KEY=value | |||
| 70 | - | if idx := strings.Index(line, "="); idx > 0 { | |||
| 71 | - | keys = append(keys, line[:idx]) | |||
| 72 | - | } | |||
| 73 | - | } | |||
| 74 | - | ||||
| 75 | - | if err := scanner.Err(); err != nil { | |||
| 76 | - | return nil, fmt.Errorf("failed to read env file: %w", err) | |||
| 77 | - | } | |||
| 78 | - | ||||
| 79 | - | return keys, nil | |||
| 80 | - | } | |||
| 81 | - | ||||
| 82 | - | // GetAll returns all secrets for a domain as a key-value map. | |||
| 83 | - | func (s *FileStore) GetAll(domainName string) (map[string]string, error) { | |||
| 84 | - | envFile, err := s.validateEnvFilePath(domainName) | |||
| 85 | - | if err != nil { | |||
| 86 | - | return nil, err | |||
| 87 | - | } | |||
| 88 | - | ||||
| 89 | - | data, err := os.ReadFile(envFile) | |||
| 90 | - | if err != nil { | |||
| 91 | - | if os.IsNotExist(err) { | |||
| 92 | - | return map[string]string{}, nil | |||
| 93 | - | } | |||
| 94 | - | return nil, fmt.Errorf("failed to read env file: %w", err) | |||
| 95 | - | } | |||
| 96 | - | ||||
| 97 | - | secrets, err := domain.ParseEnvData(data) | |||
| 98 | - | if err != nil { | |||
| 99 | - | return nil, fmt.Errorf("failed to parse env file: %w", err) | |||
| 100 | - | } | |||
| 101 | - | ||||
| 102 | - | return secrets, nil | |||
| 103 | - | } | |||
| 104 | - | ||||
| 105 | - | // Set sets or updates multiple secrets for a domain, merging with existing. | |||
| 106 | - | func (s *FileStore) Set(domainName string, secrets map[string]string) error { | |||
| 107 | - | // Validate domain first | |||
| 108 | - | if _, err := s.validateEnvFilePath(domainName); err != nil { | |||
| 109 | - | return err | |||
| 110 | - | } | |||
| 111 | - | ||||
| 112 | - | // Ensure env directory exists | |||
| 113 | - | if err := os.MkdirAll(s.envDir, 0700); err != nil { | |||
| 114 | - | return fmt.Errorf("failed to create env directory: %w", err) | |||
| 115 | - | } | |||
| 116 | - | ||||
| 117 | - | // Read existing secrets | |||
| 118 | - | existing, err := s.GetAll(domainName) | |||
| 119 | - | if err != nil { | |||
| 120 | - | return err | |||
| 121 | - | } | |||
| 122 | - | ||||
| 123 | - | // Merge new secrets with existing | |||
| 124 | - | for key, value := range secrets { | |||
| 125 | - | existing[key] = value | |||
| 126 | - | } | |||
| 127 | - | ||||
| 128 | - | // Write back atomically | |||
| 129 | - | return s.writeSecretsAtomic(domainName, existing) | |||
| 130 | - | } | |||
| 131 | - | ||||
| 132 | - | // Delete removes a specific secret key from a domain. | |||
| 133 | - | func (s *FileStore) Delete(domainName, key string) error { | |||
| 134 | - | // Validate domain first | |||
| 135 | - | if _, err := s.validateEnvFilePath(domainName); err != nil { | |||
| 136 | - | return err | |||
| 137 | - | } | |||
| 138 | - | ||||
| 139 | - | // Read existing secrets | |||
| 140 | - | existing, err := s.GetAll(domainName) | |||
| 141 | - | if err != nil { | |||
| 142 | - | return err | |||
| 143 | - | } | |||
| 144 | - | ||||
| 145 | - | // Remove the key | |||
| 146 | - | delete(existing, key) | |||
| 147 | - | ||||
| 148 | - | // Write back atomically | |||
| 149 | - | return s.writeSecretsAtomic(domainName, existing) | |||
| 150 | - | } | |||
| 151 | - | ||||
| 152 | - | // SetAttachment sets or updates multiple secrets for an attachment container. | |||
| 153 | - | func (s *FileStore) SetAttachment(containerName string, secrets map[string]string) error { | |||
| 154 | - | // Validate container name first | |||
| 155 | - | if _, err := s.validateAttachmentEnvFilePath(containerName); err != nil { | |||
| 156 | - | return err | |||
| 157 | - | } | |||
| 158 | - | ||||
| 159 | - | // Ensure env directory exists | |||
| 160 | - | if err := os.MkdirAll(s.envDir, 0700); err != nil { | |||
| 161 | - | return fmt.Errorf("failed to create env directory: %w", err) | |||
| 162 | - | } | |||
| 163 | - | ||||
| 164 | - | // Read existing secrets | |||
| 165 | - | existing, err := s.GetAllAttachment(containerName) | |||
| 166 | - | if err != nil { | |||
| 167 | - | return err | |||
| 168 | - | } | |||
| 169 | - | ||||
| 170 | - | // Merge new secrets with existing | |||
| 171 | - | for key, value := range secrets { | |||
| 172 | - | existing[key] = value | |||
| 173 | - | } | |||
| 174 | - | ||||
| 175 | - | // Write back atomically | |||
| 176 | - | return s.writeAttachmentSecretsAtomic(containerName, existing) | |||
| 177 | - | } | |||
| 178 | - | ||||
| 179 | - | // DeleteAttachment removes a specific secret key from an attachment container. | |||
| 180 | - | func (s *FileStore) DeleteAttachment(containerName, key string) error { | |||
| 181 | - | // Validate container name first | |||
| 182 | - | if _, err := s.validateAttachmentEnvFilePath(containerName); err != nil { | |||
| 183 | - | return err | |||
| 184 | - | } | |||
| 185 | - | ||||
| 186 | - | // Read existing secrets | |||
| 187 | - | existing, err := s.GetAllAttachment(containerName) | |||
| 188 | - | if err != nil { | |||
| 189 | - | return err | |||
| 190 | - | } | |||
| 191 | - | ||||
| 192 | - | // Remove the key (no-op if not present) | |||
| 193 | - | delete(existing, key) | |||
| 194 | - | ||||
| 195 | - | // Write back atomically | |||
| 196 | - | return s.writeAttachmentSecretsAtomic(containerName, existing) | |||
| 197 | - | } | |||
| 198 | - | ||||
| 199 | - | // GetAllAttachment returns all secrets for an attachment container as a key-value map. | |||
| 200 | - | func (s *FileStore) GetAllAttachment(containerName string) (map[string]string, error) { | |||
| 201 | - | envFile, err := s.validateAttachmentEnvFilePath(containerName) | |||
| 202 | - | if err != nil { | |||
| 203 | - | return nil, err | |||
| 204 | - | } | |||
| 205 | - | ||||
| 206 | - | data, err := os.ReadFile(envFile) | |||
| 207 | - | if err != nil { | |||
| 208 | - | if os.IsNotExist(err) { | |||
| 209 | - | return map[string]string{}, nil | |||
| 210 | - | } | |||
| 211 | - | return nil, fmt.Errorf("failed to read attachment env file: %w", err) | |||
| 212 | - | } | |||
| 213 | - | ||||
| 214 | - | secrets, err := domain.ParseEnvData(data) | |||
| 215 | - | if err != nil { | |||
| 216 | - | return nil, fmt.Errorf("failed to parse attachment env file: %w", err) | |||
| 217 | - | } | |||
| 218 | - | ||||
| 219 | - | return secrets, nil | |||
| 220 | - | } | |||
| 221 | - | ||||
| 222 | - | // validateSecretValues checks that no secret value contains newline characters. | |||
| 223 | - | func validateSecretValues(secrets map[string]string) error { | |||
| 224 | - | for key, value := range secrets { | |||
| 225 | - | if strings.ContainsAny(value, "\n\r") { | |||
| 226 | - | return fmt.Errorf("secret value for %q contains newline characters", key) | |||
| 227 | - | } | |||
| 228 | - | } | |||
| 229 | - | return nil | |||
| 230 | - | } | |||
| 231 | - | ||||
| 232 | - | // writeSecretsAtomic writes all secrets to the domain's env file atomically. | |||
| 233 | - | // It writes to a temporary file first, syncs it, then renames to the final path. | |||
| 234 | - | func (s *FileStore) writeSecretsAtomic(domainName string, secrets map[string]string) error { | |||
| 235 | - | if err := validateSecretValues(secrets); err != nil { | |||
| 236 | - | return fmt.Errorf("invalid domain secret values for %q: %w", domainName, err) | |||
| 237 | - | } | |||
| 238 | - | ||||
| 239 | - | envFile, err := s.validateEnvFilePath(domainName) | |||
| 240 | - | if err != nil { | |||
| 241 | - | return err | |||
| 242 | - | } | |||
| 243 | - | tmpFile := envFile + ".tmp" | |||
| 244 | - | ||||
| 245 | - | file, err := os.OpenFile(tmpFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) | |||
| 246 | - | if err != nil { | |||
| 247 | - | return fmt.Errorf("failed to create temp env file: %w", err) | |||
| 248 | - | } | |||
| 249 | - | ||||
| 250 | - | // Write header comment | |||
| 251 | - | if _, err := fmt.Fprintf(file, "# Environment variables for %s\n", domainName); err != nil { | |||
| 252 | - | file.Close() | |||
| 253 | - | os.Remove(tmpFile) | |||
| 254 | - | return fmt.Errorf("failed to write header: %w", err) | |||
| 255 | - | } | |||
| 256 | - | if _, err := fmt.Fprintf(file, "# Managed by Gordon admin API\n\n"); err != nil { | |||
| 257 | - | file.Close() | |||
| 258 | - | os.Remove(tmpFile) | |||
| 259 | - | return fmt.Errorf("failed to write header: %w", err) | |||
| 260 | - | } | |||
| 261 | - | ||||
| 262 | - | // Write each secret in sorted order for deterministic output | |||
| 263 | - | keys := make([]string, 0, len(secrets)) | |||
| 264 | - | for key := range secrets { | |||
| 265 | - | keys = append(keys, key) | |||
| 266 | - | } | |||
| 267 | - | sort.Strings(keys) | |||
| 268 | - | ||||
| 269 | - | for _, key := range keys { | |||
| 270 | - | if _, err := fmt.Fprintf(file, "%s=%s\n", key, secrets[key]); err != nil { | |||
| 271 | - | file.Close() | |||
| 272 | - | os.Remove(tmpFile) | |||
| 273 | - | return fmt.Errorf("failed to write secret %s: %w", key, err) | |||
| 274 | - | } | |||
| 275 | - | } | |||
| 276 | - | ||||
| 277 | - | // Sync to ensure data is on disk before rename | |||
| 278 | - | if err := file.Sync(); err != nil { | |||
| 279 | - | file.Close() | |||
| 280 | - | os.Remove(tmpFile) | |||
| 281 | - | return fmt.Errorf("failed to sync env file: %w", err) | |||
| 282 | - | } | |||
| 283 | - | ||||
| 284 | - | if err := file.Close(); err != nil { | |||
| 285 | - | os.Remove(tmpFile) | |||
| 286 | - | return fmt.Errorf("failed to close env file: %w", err) | |||
| 287 | - | } | |||
| 288 | - | ||||
| 289 | - | // Atomic rename | |||
| 290 | - | if err := os.Rename(tmpFile, envFile); err != nil { | |||
| 291 | - | os.Remove(tmpFile) | |||
| 292 | - | return fmt.Errorf("failed to rename env file: %w", err) | |||
| 293 | - | } | |||
| 294 | - | ||||
| 295 | - | return nil | |||
| 296 | - | } | |||
| 297 | - | ||||
| 298 | - | // getEnvFilePath converts a domain to its env file path. | |||
| 299 | - | // This must match the naming convention in envloader.FileLoader.getEnvFilePath. | |||
| 300 | - | // | |||
| 301 | - | // SECURITY: Validates domain and ensures the resulting path stays within envDir. | |||
| 302 | - | func (s *FileStore) getEnvFilePath(domainName string) (string, error) { | |||
| 303 | - | storageKey, err := domain.NewEnvStorageKey(domainName) | |||
| 304 | - | if err != nil { | |||
| 305 | - | s.log.Warn(). | |||
| 306 | - | Str(zerowrap.FieldLayer, "adapter"). | |||
| 307 | - | Str(zerowrap.FieldAdapter, "domainsecrets"). | |||
| 308 | - | Str("domain", domainName). | |||
| 309 | - | Err(err). | |||
| 310 | - | Msg("rejected invalid domain") | |||
| 311 | - | return "", err | |||
| 312 | - | } | |||
| 313 | - | ||||
| 314 | - | fullPath := filepath.Join(s.envDir, storageKey.FileName()) | |||
| 315 | - | ||||
| 316 | - | // SECURITY: Final validation - ensure path stays within envDir | |||
| 317 | - | cleanPath := filepath.Clean(fullPath) | |||
| 318 | - | cleanEnvDir := filepath.Clean(s.envDir) | |||
| 319 | - | if !strings.HasPrefix(cleanPath, cleanEnvDir+string(filepath.Separator)) { | |||
| 320 | - | s.log.Error(). | |||
| 321 | - | Str(zerowrap.FieldLayer, "adapter"). | |||
| 322 | - | Str(zerowrap.FieldAdapter, "domainsecrets"). | |||
| 323 | - | Str("domain", domainName). | |||
| 324 | - | Str("attempted_path", fullPath). | |||
| 325 | - | Msg("path traversal attempt blocked - path escapes env directory") | |||
| 326 | - | return "", domain.ErrPathTraversal | |||
| 327 | - | } | |||
| 328 | - | ||||
| 329 | - | return fullPath, nil | |||
| 330 | - | } | |||
| 331 | - | ||||
| 332 | - | // validateEnvFilePath validates that a domain produces a valid env file path. | |||
| 333 | - | // Returns an error if the domain is invalid or would result in path traversal. | |||
| 334 | - | func (s *FileStore) validateEnvFilePath(domainName string) (string, error) { | |||
| 335 | - | return s.getEnvFilePath(domainName) | |||
| 336 | - | } | |||
| 337 | - | ||||
| 338 | - | // getAttachmentEnvFilePath converts a container name to its attachment env file path. | |||
| 339 | - | // Attachment env files follow the pattern: gordon-<containerName>.env | |||
| 340 | - | // | |||
| 341 | - | // SECURITY: Validates container name and ensures the resulting path stays within envDir. | |||
| 342 | - | func (s *FileStore) getAttachmentEnvFilePath(containerName string) (string, error) { | |||
| 343 | - | if err := domain.ValidateContainerName(containerName); err != nil { | |||
| 344 | - | s.log.Warn(). | |||
| 345 | - | Str(zerowrap.FieldLayer, "adapter"). | |||
| 346 | - | Str(zerowrap.FieldAdapter, "domainsecrets"). | |||
| 347 | - | Str("container", containerName). | |||
| 348 | - | Err(err). | |||
| 349 | - | Msg("rejected invalid container name") | |||
| 350 | - | return "", err | |||
| 351 | - | } | |||
| 352 | - | ||||
| 353 | - | fullPath := filepath.Join(s.envDir, "gordon-"+containerName+".env") | |||
| 354 | - | ||||
| 355 | - | // SECURITY: Final validation - ensure path stays within envDir | |||
| 356 | - | cleanPath := filepath.Clean(fullPath) | |||
| 357 | - | cleanEnvDir := filepath.Clean(s.envDir) | |||
| 358 | - | if !strings.HasPrefix(cleanPath, cleanEnvDir+string(filepath.Separator)) { | |||
| 359 | - | s.log.Error(). | |||
| 360 | - | Str(zerowrap.FieldLayer, "adapter"). | |||
| 361 | - | Str(zerowrap.FieldAdapter, "domainsecrets"). | |||
| 362 | - | Str("container", containerName). | |||
| 363 | - | Str("attempted_path", fullPath). | |||
| 364 | - | Msg("path traversal attempt blocked - path escapes env directory") | |||
| 365 | - | return "", domain.ErrPathTraversal | |||
| 366 | - | } | |||
| 367 | - | ||||
| 368 | - | return fullPath, nil | |||
| 369 | - | } | |||
| 370 | - | ||||
| 371 | - | // validateAttachmentEnvFilePath validates that a container name produces a valid attachment env file path. | |||
| 372 | - | // Returns an error if the container name is invalid or would result in path traversal. | |||
| 373 | - | func (s *FileStore) validateAttachmentEnvFilePath(containerName string) (string, error) { | |||
| 374 | - | return s.getAttachmentEnvFilePath(containerName) | |||
| 375 | - | } | |||
| 376 | - | ||||
| 377 | - | // writeAttachmentSecretsAtomic writes all secrets to the attachment's env file atomically. | |||
| 378 | - | // It writes to a temporary file first, syncs it, then renames to the final path. | |||
| 379 | - | func (s *FileStore) writeAttachmentSecretsAtomic(containerName string, secrets map[string]string) error { | |||
| 380 | - | if err := validateSecretValues(secrets); err != nil { | |||
| 381 | - | return fmt.Errorf("invalid attachment secret values for %q: %w", containerName, err) | |||
| 382 | - | } | |||
| 383 | - | ||||
| 384 | - | envFile, err := s.validateAttachmentEnvFilePath(containerName) | |||
| 385 | - | if err != nil { | |||
| 386 | - | return err | |||
| 387 | - | } | |||
| 388 | - | tmpFile := envFile + ".tmp" | |||
| 389 | - | ||||
| 390 | - | file, err := os.OpenFile(tmpFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) | |||
| 391 | - | if err != nil { | |||
| 392 | - | return fmt.Errorf("failed to create temp attachment env file: %w", err) | |||
| 393 | - | } | |||
| 394 | - | ||||
| 395 | - | // Write header comment | |||
| 396 | - | if _, err := fmt.Fprintf(file, "# Environment variables for attachment %s\n", containerName); err != nil { | |||
| 397 | - | file.Close() | |||
| 398 | - | os.Remove(tmpFile) | |||
| 399 | - | return fmt.Errorf("failed to write header: %w", err) | |||
| 400 | - | } | |||
| 401 | - | if _, err := fmt.Fprintf(file, "# Managed by Gordon admin API\n\n"); err != nil { | |||
| 402 | - | file.Close() | |||
| 403 | - | os.Remove(tmpFile) | |||
| 404 | - | return fmt.Errorf("failed to write header: %w", err) | |||
| 405 | - | } | |||
| 406 | - | ||||
| 407 | - | // Write each secret in sorted order for deterministic output | |||
| 408 | - | keys := make([]string, 0, len(secrets)) | |||
| 409 | - | for key := range secrets { | |||
| 410 | - | keys = append(keys, key) | |||
| 411 | - | } | |||
| 412 | - | sort.Strings(keys) | |||
| 413 | - | ||||
| 414 | - | for _, key := range keys { | |||
| 415 | - | if _, err := fmt.Fprintf(file, "%s=%s\n", key, secrets[key]); err != nil { | |||
| 416 | - | file.Close() | |||
| 417 | - | os.Remove(tmpFile) | |||
| 418 | - | return fmt.Errorf("failed to write secret %s: %w", key, err) | |||
| 419 | - | } | |||
| 420 | - | } | |||
| 421 | - | ||||
| 422 | - | // Sync to ensure data is on disk before rename | |||
| 423 | - | if err := file.Sync(); err != nil { | |||
| 424 | - | file.Close() | |||
| 425 | - | os.Remove(tmpFile) | |||
| 426 | - | return fmt.Errorf("failed to sync attachment env file: %w", err) | |||
| 427 | - | } | |||
| 428 | - | ||||
| 429 | - | if err := file.Close(); err != nil { | |||
| 430 | - | os.Remove(tmpFile) | |||
| 431 | - | return fmt.Errorf("failed to close attachment env file: %w", err) | |||
| 432 | - | } | |||
| 433 | - | ||||
| 434 | - | // Atomic rename | |||
| 435 | - | if err := os.Rename(tmpFile, envFile); err != nil { | |||
| 436 | - | os.Remove(tmpFile) | |||
| 437 | - | return fmt.Errorf("failed to rename attachment env file: %w", err) | |||
| 438 | - | } | |||
| 439 | - | ||||
| 440 | - | return nil | |||
| 441 | - | } | |||
| 442 | - | ||||
| 443 | - | // ListAttachmentKeys finds attachment env files for a domain and returns their keys. | |||
| 444 | - | // Supports both new (collision-resistant) and legacy container naming for backwards compatibility. | |||
| 445 | - | // Attachment env files follow the naming pattern: gordon-{sanitized-domain}-{service}.env | |||
| 446 | - | func (s *FileStore) ListAttachmentKeys(domainName string) ([]out.AttachmentSecrets, error) { | |||
| 447 | - | // Try both new and legacy sanitization for backwards compatibility | |||
| 448 | - | sanitized := domain.SanitizeDomainForContainer(domainName) | |||
| 449 | - | sanitizedLegacy := domain.SanitizeDomainForContainerLegacy(domainName) | |||
| 450 | - | prefixes := []string{ | |||
| 451 | - | "gordon-" + sanitized + "-", // New format (collision-resistant) | |||
| 452 | - | "gordon-" + sanitizedLegacy + "-", // Old format (buggy but backwards compatible) | |||
| 453 | - | } | |||
| 454 | - | ||||
| 455 | - | // List all files in env directory | |||
| 456 | - | entries, err := os.ReadDir(s.envDir) | |||
| 457 | - | if err != nil { | |||
| 458 | - | if os.IsNotExist(err) { | |||
| 459 | - | return nil, nil | |||
| 460 | - | } | |||
| 461 | - | return nil, fmt.Errorf("failed to read env directory: %w", err) | |||
| 462 | - | } | |||
| 463 | - | ||||
| 464 | - | seen := make(map[string]bool) | |||
| 465 | - | var results []out.AttachmentSecrets | |||
| 466 | - | for _, entry := range entries { | |||
| 467 | - | if entry.IsDir() { | |||
| 468 | - | continue | |||
| 469 | - | } | |||
| 470 | - | name := entry.Name() | |||
| 471 | - | ||||
| 472 | - | // Check if this matches any of our prefixes | |||
| 473 | - | var matchedPrefix string | |||
| 474 | - | for _, prefix := range prefixes { | |||
| 475 | - | if strings.HasPrefix(name, prefix) && strings.HasSuffix(name, ".env") { | |||
| 476 | - | matchedPrefix = prefix | |||
| 477 | - | break | |||
| 478 | - | } | |||
| 479 | - | } | |||
| 480 | - | if matchedPrefix == "" { | |||
| 481 | - | continue | |||
| 482 | - | } | |||
| 483 | - | ||||
| 484 | - | // Extract container name from filename | |||
| 485 | - | containerName := strings.TrimSuffix(name, ".env") | |||
| 486 | - | ||||
| 487 | - | // Deduplicate results | |||
| 488 | - | if seen[containerName] { | |||
| 489 | - | continue | |||
| 490 | - | } | |||
| 491 | - | seen[containerName] = true | |||
| 492 | - | ||||
| 493 | - | // Extract service name from filename | |||
| 494 | - | // e.g., "gordon-git-bnema-dev-gitea-postgres.env" → "gitea-postgres" | |||
| 495 | - | serviceName := strings.TrimPrefix(name, matchedPrefix) | |||
| 496 | - | serviceName = strings.TrimSuffix(serviceName, ".env") | |||
| 497 | - | if serviceName == "" { | |||
| 498 | - | continue | |||
| 499 | - | } | |||
| 500 | - | ||||
| 501 | - | // Read keys from this file using existing method | |||
| 502 | - | // Note: We use the container name directly since it matches the env file naming | |||
| 503 | - | keys, err := s.listKeysFromFile(filepath.Join(s.envDir, name)) | |||
| 504 | - | if err != nil { | |||
| 505 | - | s.log.Warn(). | |||
| 506 | - | Err(err). | |||
| 507 | - | Str("file", name). | |||
| 508 | - | Str("domain", domainName). | |||
| 509 | - | Msg("failed to read attachment secrets file") | |||
| 510 | - | continue | |||
| 511 | - | } | |||
| 512 | - | ||||
| 513 | - | if len(keys) > 0 { | |||
| 514 | - | results = append(results, out.AttachmentSecrets{ | |||
| 515 | - | Service: containerName, | |||
| 516 | - | Keys: keys, | |||
| 517 | - | }) | |||
| 518 | - | } | |||
| 519 | - | } | |||
| 520 | - | ||||
| 521 | - | return results, nil | |||
| 522 | - | } | |||
| 523 | - | ||||
| 524 | - | // listKeysFromFile reads secret keys from a specific env file path. | |||
| 525 | - | func (s *FileStore) listKeysFromFile(filePath string) ([]string, error) { | |||
| 526 | - | file, err := os.Open(filePath) | |||
| 527 | - | if err != nil { | |||
| 528 | - | if os.IsNotExist(err) { | |||
| 529 | - | return []string{}, nil | |||
| 530 | - | } | |||
| 531 | - | return nil, fmt.Errorf("failed to open env file: %w", err) | |||
| 532 | - | } | |||
| 533 | - | defer file.Close() | |||
| 534 | - | ||||
| 535 | - | var keys []string | |||
| 536 | - | scanner := bufio.NewScanner(file) | |||
| 537 | - | for scanner.Scan() { | |||
| 538 | - | line := strings.TrimSpace(scanner.Text()) | |||
| 539 | - | // Skip empty lines and comments | |||
| 540 | - | if line == "" || strings.HasPrefix(line, "#") { | |||
| 541 | - | continue | |||
| 542 | - | } | |||
| 543 | - | // Extract key from KEY=value | |||
| 544 | - | if idx := strings.Index(line, "="); idx > 0 { | |||
| 545 | - | keys = append(keys, line[:idx]) | |||
| 546 | - | } | |||
| 547 | - | } | |||
| 548 | - | ||||
| 549 | - | if err := scanner.Err(); err != nil { | |||
| 550 | - | return nil, fmt.Errorf("failed to read env file: %w", err) | |||
| 551 | - | } | |||
| 552 | - | ||||
| 553 | - | return keys, nil | |||
| 554 | - | } | |||
D internal/adapters/out/domainsecrets/store_test.go +0 -432
| @@ -1,432 +0,0 @@ | |||||
| 1 | - | package domainsecrets | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "errors" | |||
| 5 | - | "os" | |||
| 6 | - | "path/filepath" | |||
| 7 | - | "testing" | |||
| 8 | - | ||||
| 9 | - | "github.com/stretchr/testify/assert" | |||
| 10 | - | "github.com/stretchr/testify/require" | |||
| 11 | - | ||||
| 12 | - | "github.com/bnema/gordon/internal/domain" | |||
| 13 | - | ) | |||
| 14 | - | ||||
| 15 | - | func TestFileStore_PathTraversal(t *testing.T) { | |||
| 16 | - | tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*") | |||
| 17 | - | require.NoError(t, err) | |||
| 18 | - | defer os.RemoveAll(tmpDir) | |||
| 19 | - | ||||
| 20 | - | store, err := NewFileStore(tmpDir, testLogger()) | |||
| 21 | - | require.NoError(t, err) | |||
| 22 | - | ||||
| 23 | - | tests := []struct { | |||
| 24 | - | name string | |||
| 25 | - | domain string | |||
| 26 | - | wantErr bool | |||
| 27 | - | }{ | |||
| 28 | - | // Valid domains | |||
| 29 | - | {"simple domain", "example.com", false}, | |||
| 30 | - | {"subdomain", "sub.example.com", false}, | |||
| 31 | - | {"domain with port", "example.com:8080", false}, | |||
| 32 | - | {"domain with path", "example.com/path", false}, | |||
| 33 | - | {"hyphenated domain", "my-app.example.com", false}, | |||
| 34 | - | {"single char", "a", false}, | |||
| 35 | - | ||||
| 36 | - | // Path traversal attempts - should fail | |||
| 37 | - | {"path traversal with dots", "../../../etc/passwd", true}, | |||
| 38 | - | {"path traversal in middle", "example.com/../../../etc/passwd", true}, | |||
| 39 | - | {"double dots only", "..", true}, | |||
| 40 | - | {"path traversal at end", "example.com/..", true}, | |||
| 41 | - | ||||
| 42 | - | // Invalid format - should fail | |||
| 43 | - | {"empty domain", "", true}, | |||
| 44 | - | {"starts with dot", ".example.com", true}, | |||
| 45 | - | {"ends with dot", "example.com.", true}, | |||
| 46 | - | {"special chars semicolon", "example;rm -rf /", true}, | |||
| 47 | - | {"null byte", "example\x00.com", true}, | |||
| 48 | - | {"newline", "example\n.com", true}, | |||
| 49 | - | } | |||
| 50 | - | ||||
| 51 | - | for _, tt := range tests { | |||
| 52 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 53 | - | // Test ListKeys | |||
| 54 | - | _, err := store.ListKeys(tt.domain) | |||
| 55 | - | if tt.wantErr { | |||
| 56 | - | require.Error(t, err) | |||
| 57 | - | assert.True(t, errors.Is(err, domain.ErrPathTraversal), "expected ErrPathTraversal, got: %v", err) | |||
| 58 | - | } else { | |||
| 59 | - | // No error, or file not found (which is fine) | |||
| 60 | - | if err != nil { | |||
| 61 | - | assert.False(t, errors.Is(err, domain.ErrPathTraversal), "unexpected ErrPathTraversal") | |||
| 62 | - | } | |||
| 63 | - | } | |||
| 64 | - | ||||
| 65 | - | // Test Set | |||
| 66 | - | err = store.Set(tt.domain, map[string]string{"KEY": "value"}) | |||
| 67 | - | if tt.wantErr { | |||
| 68 | - | require.Error(t, err) | |||
| 69 | - | assert.True(t, errors.Is(err, domain.ErrPathTraversal), "expected ErrPathTraversal, got: %v", err) | |||
| 70 | - | } else { | |||
| 71 | - | assert.NoError(t, err) | |||
| 72 | - | } | |||
| 73 | - | ||||
| 74 | - | // Test GetAll | |||
| 75 | - | _, err = store.GetAll(tt.domain) | |||
| 76 | - | if tt.wantErr { | |||
| 77 | - | require.Error(t, err) | |||
| 78 | - | assert.True(t, errors.Is(err, domain.ErrPathTraversal), "expected ErrPathTraversal, got: %v", err) | |||
| 79 | - | } | |||
| 80 | - | ||||
| 81 | - | // Test Delete | |||
| 82 | - | err = store.Delete(tt.domain, "KEY") | |||
| 83 | - | if tt.wantErr { | |||
| 84 | - | require.Error(t, err) | |||
| 85 | - | assert.True(t, errors.Is(err, domain.ErrPathTraversal), "expected ErrPathTraversal, got: %v", err) | |||
| 86 | - | } | |||
| 87 | - | }) | |||
| 88 | - | } | |||
| 89 | - | } | |||
| 90 | - | ||||
| 91 | - | func TestFileStore_PathContainment(t *testing.T) { | |||
| 92 | - | tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*") | |||
| 93 | - | require.NoError(t, err) | |||
| 94 | - | defer os.RemoveAll(tmpDir) | |||
| 95 | - | ||||
| 96 | - | store, err := NewFileStore(tmpDir, testLogger()) | |||
| 97 | - | require.NoError(t, err) | |||
| 98 | - | ||||
| 99 | - | // Valid domain - file should be created inside tmpDir | |||
| 100 | - | err = store.Set("example.com", map[string]string{"KEY": "value"}) | |||
| 101 | - | require.NoError(t, err) | |||
| 102 | - | ||||
| 103 | - | // Verify file exists inside tmpDir | |||
| 104 | - | expectedFile := filepath.Join(tmpDir, "ZXhhbXBsZS5jb20.env") | |||
| 105 | - | _, err = os.Stat(expectedFile) | |||
| 106 | - | assert.NoError(t, err, "expected env file to exist at %s", expectedFile) | |||
| 107 | - | ||||
| 108 | - | // Verify no file was created outside tmpDir | |||
| 109 | - | entries, err := filepath.Glob(tmpDir + "/../*") | |||
| 110 | - | require.NoError(t, err) | |||
| 111 | - | for _, entry := range entries { | |||
| 112 | - | if filepath.Base(entry) != filepath.Base(tmpDir) { | |||
| 113 | - | // Check it's not our test file escaped | |||
| 114 | - | assert.NotContains(t, entry, ".env", "unexpected .env file outside tmpDir: %s", entry) | |||
| 115 | - | } | |||
| 116 | - | } | |||
| 117 | - | } | |||
| 118 | - | ||||
| 119 | - | func TestFileStore_DistinctDomainsUseDistinctFiles(t *testing.T) { | |||
| 120 | - | store, err := NewFileStore(t.TempDir(), testLogger()) | |||
| 121 | - | require.NoError(t, err) | |||
| 122 | - | ||||
| 123 | - | dotPath, err := store.validateEnvFilePath("app.example.com") | |||
| 124 | - | require.NoError(t, err) | |||
| 125 | - | ||||
| 126 | - | portPath, err := store.validateEnvFilePath("app:example:com") | |||
| 127 | - | require.NoError(t, err) | |||
| 128 | - | ||||
| 129 | - | pathPath, err := store.validateEnvFilePath("app/example/com") | |||
| 130 | - | require.NoError(t, err) | |||
| 131 | - | ||||
| 132 | - | assert.NotEqual(t, dotPath, portPath) | |||
| 133 | - | assert.NotEqual(t, dotPath, pathPath) | |||
| 134 | - | assert.NotEqual(t, portPath, pathPath) | |||
| 135 | - | } | |||
| 136 | - | ||||
| 137 | - | func TestSanitizeDomainForContainer(t *testing.T) { | |||
| 138 | - | tests := []struct { | |||
| 139 | - | domain string | |||
| 140 | - | expected string | |||
| 141 | - | description string | |||
| 142 | - | }{ | |||
| 143 | - | { | |||
| 144 | - | domain: "git.example.com", | |||
| 145 | - | expected: "git__example__com", | |||
| 146 | - | description: "Dots become double underscores", | |||
| 147 | - | }, | |||
| 148 | - | { | |||
| 149 | - | domain: "git-example.com", | |||
| 150 | - | expected: "git-example__com", | |||
| 151 | - | description: "Hyphens preserved, dots become underscores", | |||
| 152 | - | }, | |||
| 153 | - | { | |||
| 154 | - | domain: "app:8080.example.com", | |||
| 155 | - | expected: "app-_8080__example__com", | |||
| 156 | - | description: "Colons become hyphen-underscore", | |||
| 157 | - | }, | |||
| 158 | - | { | |||
| 159 | - | domain: "git.example.com:3000", | |||
| 160 | - | expected: "git__example__com-_3000", | |||
| 161 | - | description: "Multiple separators handled distinctly", | |||
| 162 | - | }, | |||
| 163 | - | { | |||
| 164 | - | domain: "simple.com", | |||
| 165 | - | expected: "simple__com", | |||
| 166 | - | description: "Simple domain", | |||
| 167 | - | }, | |||
| 168 | - | } | |||
| 169 | - | ||||
| 170 | - | for _, tt := range tests { | |||
| 171 | - | t.Run(tt.domain, func(t *testing.T) { | |||
| 172 | - | result := domain.SanitizeDomainForContainer(tt.domain) | |||
| 173 | - | assert.Equal(t, tt.expected, result, "sanitization should match expected") | |||
| 174 | - | }) | |||
| 175 | - | } | |||
| 176 | - | ||||
| 177 | - | // Verify no collisions between potentially conflicting domains | |||
| 178 | - | t.Run("NoCollisions", func(t *testing.T) { | |||
| 179 | - | domains := []string{ | |||
| 180 | - | "git.example.com", | |||
| 181 | - | "git-example.com", | |||
| 182 | - | "app:8080.example.com", | |||
| 183 | - | "app-8080-example.com", | |||
| 184 | - | } | |||
| 185 | - | ||||
| 186 | - | results := make(map[string]string) | |||
| 187 | - | for _, d := range domains { | |||
| 188 | - | result := domain.SanitizeDomainForContainer(d) | |||
| 189 | - | if original, exists := results[result]; exists { | |||
| 190 | - | t.Errorf("COLLISION: %q and %q both sanitize to %q", original, d, result) | |||
| 191 | - | } | |||
| 192 | - | results[result] = d | |||
| 193 | - | } | |||
| 194 | - | }) | |||
| 195 | - | } | |||
| 196 | - | ||||
| 197 | - | func TestFileStore_AttachmentOperations(t *testing.T) { | |||
| 198 | - | tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*") | |||
| 199 | - | require.NoError(t, err) | |||
| 200 | - | defer os.RemoveAll(tmpDir) | |||
| 201 | - | ||||
| 202 | - | store, err := NewFileStore(tmpDir, testLogger()) | |||
| 203 | - | require.NoError(t, err) | |||
| 204 | - | ||||
| 205 | - | containerName := "gitea-postgres" | |||
| 206 | - | ||||
| 207 | - | t.Run("SetAttachment creates new file", func(t *testing.T) { | |||
| 208 | - | err := store.SetAttachment(containerName, map[string]string{ | |||
| 209 | - | "POSTGRES_DB": "gitea", | |||
| 210 | - | "POSTGRES_PASSWORD": "secret123", | |||
| 211 | - | }) | |||
| 212 | - | require.NoError(t, err) | |||
| 213 | - | ||||
| 214 | - | // Verify file exists | |||
| 215 | - | expectedFile := filepath.Join(tmpDir, "gordon-"+containerName+".env") | |||
| 216 | - | _, err = os.Stat(expectedFile) | |||
| 217 | - | assert.NoError(t, err, "expected attachment env file to exist at %s", expectedFile) | |||
| 218 | - | }) | |||
| 219 | - | ||||
| 220 | - | t.Run("GetAllAttachment retrieves secrets", func(t *testing.T) { | |||
| 221 | - | secrets, err := store.GetAllAttachment(containerName) | |||
| 222 | - | require.NoError(t, err) | |||
| 223 | - | assert.Equal(t, "gitea", secrets["POSTGRES_DB"]) | |||
| 224 | - | assert.Equal(t, "secret123", secrets["POSTGRES_PASSWORD"]) | |||
| 225 | - | }) | |||
| 226 | - | ||||
| 227 | - | t.Run("SetAttachment merges with existing", func(t *testing.T) { | |||
| 228 | - | err := store.SetAttachment(containerName, map[string]string{ | |||
| 229 | - | "NEW_VAR": "new_value", | |||
| 230 | - | }) | |||
| 231 | - | require.NoError(t, err) | |||
| 232 | - | ||||
| 233 | - | // Should have both old and new secrets | |||
| 234 | - | secrets, err := store.GetAllAttachment(containerName) | |||
| 235 | - | require.NoError(t, err) | |||
| 236 | - | assert.Equal(t, "gitea", secrets["POSTGRES_DB"]) | |||
| 237 | - | assert.Equal(t, "secret123", secrets["POSTGRES_PASSWORD"]) | |||
| 238 | - | assert.Equal(t, "new_value", secrets["NEW_VAR"]) | |||
| 239 | - | }) | |||
| 240 | - | ||||
| 241 | - | t.Run("GetAllAttachment returns empty map for non-existent container", func(t *testing.T) { | |||
| 242 | - | secrets, err := store.GetAllAttachment("non-existent") | |||
| 243 | - | require.NoError(t, err) | |||
| 244 | - | assert.Empty(t, secrets) | |||
| 245 | - | }) | |||
| 246 | - | ||||
| 247 | - | t.Run("SetAttachment with hyphenated container name", func(t *testing.T) { | |||
| 248 | - | hyphenatedContainer := "gordon-git-example-com-gitea-postgres" | |||
| 249 | - | err := store.SetAttachment(hyphenatedContainer, map[string]string{ | |||
| 250 | - | "KEY": "value", | |||
| 251 | - | }) | |||
| 252 | - | require.NoError(t, err) | |||
| 253 | - | ||||
| 254 | - | secrets, err := store.GetAllAttachment(hyphenatedContainer) | |||
| 255 | - | require.NoError(t, err) | |||
| 256 | - | assert.Equal(t, "value", secrets["KEY"]) | |||
| 257 | - | }) | |||
| 258 | - | } | |||
| 259 | - | ||||
| 260 | - | func TestFileStore_AttachmentPathTraversal(t *testing.T) { | |||
| 261 | - | tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*") | |||
| 262 | - | require.NoError(t, err) | |||
| 263 | - | defer os.RemoveAll(tmpDir) | |||
| 264 | - | ||||
| 265 | - | store, err := NewFileStore(tmpDir, testLogger()) | |||
| 266 | - | require.NoError(t, err) | |||
| 267 | - | ||||
| 268 | - | tests := []struct { | |||
| 269 | - | name string | |||
| 270 | - | container string | |||
| 271 | - | wantSetErr bool | |||
| 272 | - | wantGetErr bool | |||
| 273 | - | }{ | |||
| 274 | - | // Valid container names | |||
| 275 | - | {"simple", "postgres", false, false}, | |||
| 276 | - | {"with hyphen", "gitea-postgres", false, false}, | |||
| 277 | - | {"with underscore", "gitea_postgres", false, false}, | |||
| 278 | - | {"complex real container", "gordon-git-example-com-gitea-postgres", false, false}, | |||
| 279 | - | ||||
| 280 | - | // Invalid container names - should fail | |||
| 281 | - | {"empty", "", true, true}, | |||
| 282 | - | {"starts with number", "1container", true, true}, | |||
| 283 | - | {"starts with hyphen", "-container", true, true}, | |||
| 284 | - | {"starts with underscore", "_container", true, true}, | |||
| 285 | - | {"path traversal", "../etc", true, true}, | |||
| 286 | - | {"contains slash", "container/name", true, true}, | |||
| 287 | - | {"contains backslash", "container\\name", true, true}, | |||
| 288 | - | } | |||
| 289 | - | ||||
| 290 | - | for _, tt := range tests { | |||
| 291 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 292 | - | // Test SetAttachment | |||
| 293 | - | err := store.SetAttachment(tt.container, map[string]string{"KEY": "value"}) | |||
| 294 | - | if tt.wantSetErr { | |||
| 295 | - | require.Error(t, err) | |||
| 296 | - | } else { | |||
| 297 | - | assert.NoError(t, err) | |||
| 298 | - | } | |||
| 299 | - | ||||
| 300 | - | // Test GetAllAttachment | |||
| 301 | - | _, err = store.GetAllAttachment(tt.container) | |||
| 302 | - | if tt.wantGetErr { | |||
| 303 | - | require.Error(t, err) | |||
| 304 | - | } else { | |||
| 305 | - | if !tt.wantSetErr { | |||
| 306 | - | // Only check no error if we successfully set it | |||
| 307 | - | assert.NoError(t, err) | |||
| 308 | - | } | |||
| 309 | - | } | |||
| 310 | - | }) | |||
| 311 | - | } | |||
| 312 | - | } | |||
| 313 | - | ||||
| 314 | - | func TestFileStore_ValidDomainOperations(t *testing.T) { | |||
| 315 | - | tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*") | |||
| 316 | - | require.NoError(t, err) | |||
| 317 | - | defer os.RemoveAll(tmpDir) | |||
| 318 | - | ||||
| 319 | - | store, err := NewFileStore(tmpDir, testLogger()) | |||
| 320 | - | require.NoError(t, err) | |||
| 321 | - | ||||
| 322 | - | domain := "test.example.com" | |||
| 323 | - | ||||
| 324 | - | // Set secrets | |||
| 325 | - | err = store.Set(domain, map[string]string{ | |||
| 326 | - | "DB_HOST": "localhost", | |||
| 327 | - | "DB_PASSWORD": "secret123", | |||
| 328 | - | }) | |||
| 329 | - | require.NoError(t, err) | |||
| 330 | - | ||||
| 331 | - | // Get all secrets | |||
| 332 | - | secrets, err := store.GetAll(domain) | |||
| 333 | - | require.NoError(t, err) | |||
| 334 | - | assert.Equal(t, "localhost", secrets["DB_HOST"]) | |||
| 335 | - | assert.Equal(t, "secret123", secrets["DB_PASSWORD"]) | |||
| 336 | - | ||||
| 337 | - | // List keys | |||
| 338 | - | keys, err := store.ListKeys(domain) | |||
| 339 | - | require.NoError(t, err) | |||
| 340 | - | assert.Len(t, keys, 2) | |||
| 341 | - | assert.Contains(t, keys, "DB_HOST") | |||
| 342 | - | assert.Contains(t, keys, "DB_PASSWORD") | |||
| 343 | - | ||||
| 344 | - | // Delete a key | |||
| 345 | - | err = store.Delete(domain, "DB_PASSWORD") | |||
| 346 | - | require.NoError(t, err) | |||
| 347 | - | ||||
| 348 | - | // Verify deletion | |||
| 349 | - | secrets, err = store.GetAll(domain) | |||
| 350 | - | require.NoError(t, err) | |||
| 351 | - | assert.Len(t, secrets, 1) | |||
| 352 | - | assert.Equal(t, "localhost", secrets["DB_HOST"]) | |||
| 353 | - | _, exists := secrets["DB_PASSWORD"] | |||
| 354 | - | assert.False(t, exists) | |||
| 355 | - | } | |||
| 356 | - | ||||
| 357 | - | func TestFileStore_DeleteAttachment(t *testing.T) { | |||
| 358 | - | tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*") | |||
| 359 | - | require.NoError(t, err) | |||
| 360 | - | defer os.RemoveAll(tmpDir) | |||
| 361 | - | ||||
| 362 | - | store, err := NewFileStore(tmpDir, testLogger()) | |||
| 363 | - | require.NoError(t, err) | |||
| 364 | - | ||||
| 365 | - | containerName := "gitea-postgres" | |||
| 366 | - | ||||
| 367 | - | // Seed with two secrets | |||
| 368 | - | err = store.SetAttachment(containerName, map[string]string{ | |||
| 369 | - | "POSTGRES_USER": "gitea", | |||
| 370 | - | "POSTGRES_PASSWORD": "secret123", | |||
| 371 | - | }) | |||
| 372 | - | require.NoError(t, err) | |||
| 373 | - | ||||
| 374 | - | // Delete one key | |||
| 375 | - | err = store.DeleteAttachment(containerName, "POSTGRES_PASSWORD") | |||
| 376 | - | require.NoError(t, err) | |||
| 377 | - | ||||
| 378 | - | // Verify remaining | |||
| 379 | - | secrets, err := store.GetAllAttachment(containerName) | |||
| 380 | - | require.NoError(t, err) | |||
| 381 | - | assert.Len(t, secrets, 1) | |||
| 382 | - | assert.Equal(t, "gitea", secrets["POSTGRES_USER"]) | |||
| 383 | - | _, exists := secrets["POSTGRES_PASSWORD"] | |||
| 384 | - | assert.False(t, exists) | |||
| 385 | - | ||||
| 386 | - | // Delete nonexistent key - should be no-op | |||
| 387 | - | err = store.DeleteAttachment(containerName, "NONEXISTENT_KEY") | |||
| 388 | - | assert.NoError(t, err) | |||
| 389 | - | ||||
| 390 | - | // Verify secrets unchanged after no-op delete | |||
| 391 | - | secrets, err = store.GetAllAttachment(containerName) | |||
| 392 | - | require.NoError(t, err) | |||
| 393 | - | assert.Len(t, secrets, 1) | |||
| 394 | - | assert.Equal(t, "gitea", secrets["POSTGRES_USER"]) | |||
| 395 | - | } | |||
| 396 | - | ||||
| 397 | - | func TestGetEnvFilePath(t *testing.T) { | |||
| 398 | - | tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*") | |||
| 399 | - | require.NoError(t, err) | |||
| 400 | - | defer os.RemoveAll(tmpDir) | |||
| 401 | - | ||||
| 402 | - | store, err := NewFileStore(tmpDir, testLogger()) | |||
| 403 | - | require.NoError(t, err) | |||
| 404 | - | ||||
| 405 | - | tests := []struct { | |||
| 406 | - | domain string | |||
| 407 | - | wantEmpty bool | |||
| 408 | - | wantFilename string | |||
| 409 | - | }{ | |||
| 410 | - | {"example.com", false, "ZXhhbXBsZS5jb20.env"}, | |||
| 411 | - | {"sub.example.com", false, "c3ViLmV4YW1wbGUuY29t.env"}, | |||
| 412 | - | {"example.com:8080", false, "ZXhhbXBsZS5jb206ODA4MA.env"}, | |||
| 413 | - | {"example.com/path", false, "ZXhhbXBsZS5jb20vcGF0aA.env"}, | |||
| 414 | - | {"../evil", true, ""}, | |||
| 415 | - | {"", true, ""}, | |||
| 416 | - | } | |||
| 417 | - | ||||
| 418 | - | for _, tt := range tests { | |||
| 419 | - | t.Run(tt.domain, func(t *testing.T) { | |||
| 420 | - | path, err := store.getEnvFilePath(tt.domain) | |||
| 421 | - | ||||
| 422 | - | if tt.wantEmpty { | |||
| 423 | - | assert.Error(t, err) | |||
| 424 | - | assert.Empty(t, path) | |||
| 425 | - | } else { | |||
| 426 | - | require.NoError(t, err) | |||
| 427 | - | assert.NotEmpty(t, path) | |||
| 428 | - | assert.Equal(t, filepath.Join(tmpDir, tt.wantFilename), path) | |||
| 429 | - | } | |||
| 430 | - | }) | |||
| 431 | - | } | |||
| 432 | - | } | |||
D internal/adapters/out/envloader/file.go +0 -253
| @@ -1,253 +0,0 @@ | |||||
| 1 | - | // Package envloader implements the environment variable loader adapter. | |||
| 2 | - | package envloader | |||
| 3 | - | ||||
| 4 | - | import ( | |||
| 5 | - | "bufio" | |||
| 6 | - | "context" | |||
| 7 | - | "fmt" | |||
| 8 | - | "os" | |||
| 9 | - | "path/filepath" | |||
| 10 | - | "strings" | |||
| 11 | - | ||||
| 12 | - | "github.com/bnema/zerowrap" | |||
| 13 | - | ||||
| 14 | - | "github.com/bnema/gordon/internal/boundaries/out" | |||
| 15 | - | "github.com/bnema/gordon/internal/domain" | |||
| 16 | - | ) | |||
| 17 | - | ||||
| 18 | - | // FileLoader implements the EnvLoader interface using filesystem-based env files. | |||
| 19 | - | type FileLoader struct { | |||
| 20 | - | envDir string | |||
| 21 | - | secretProviders map[string]out.SecretProvider | |||
| 22 | - | log zerowrap.Logger | |||
| 23 | - | } | |||
| 24 | - | ||||
| 25 | - | // NewFileLoader creates a new file-based environment loader. | |||
| 26 | - | func NewFileLoader(envDir string, log zerowrap.Logger) (*FileLoader, error) { | |||
| 27 | - | // Ensure env directory exists | |||
| 28 | - | if err := os.MkdirAll(envDir, 0700); err != nil { | |||
| 29 | - | return nil, fmt.Errorf("failed to create env directory %s: %w", envDir, err) | |||
| 30 | - | } | |||
| 31 | - | ||||
| 32 | - | log.Debug(). | |||
| 33 | - | Str(zerowrap.FieldLayer, "adapter"). | |||
| 34 | - | Str(zerowrap.FieldAdapter, "envloader"). | |||
| 35 | - | Str("env_dir", envDir). | |||
| 36 | - | Msg("env loader initialized") | |||
| 37 | - | ||||
| 38 | - | return &FileLoader{ | |||
| 39 | - | envDir: envDir, | |||
| 40 | - | secretProviders: make(map[string]out.SecretProvider), | |||
| 41 | - | log: log, | |||
| 42 | - | }, nil | |||
| 43 | - | } | |||
| 44 | - | ||||
| 45 | - | // RegisterSecretProvider registers a secret provider for resolving secrets. | |||
| 46 | - | func (l *FileLoader) RegisterSecretProvider(provider out.SecretProvider) { | |||
| 47 | - | l.secretProviders[provider.Name()] = provider | |||
| 48 | - | l.log.Debug(). | |||
| 49 | - | Str(zerowrap.FieldLayer, "adapter"). | |||
| 50 | - | Str(zerowrap.FieldAdapter, "envloader"). | |||
| 51 | - | Str("provider", provider.Name()). | |||
| 52 | - | Msg("secret provider registered") | |||
| 53 | - | } | |||
| 54 | - | ||||
| 55 | - | // LoadEnv loads environment variables for a given domain. | |||
| 56 | - | func (l *FileLoader) LoadEnv(ctx context.Context, domain string) ([]string, error) { | |||
| 57 | - | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |||
| 58 | - | zerowrap.FieldLayer: "adapter", | |||
| 59 | - | zerowrap.FieldAdapter: "envloader", | |||
| 60 | - | zerowrap.FieldAction: "LoadEnv", | |||
| 61 | - | "domain": domain, | |||
| 62 | - | }) | |||
| 63 | - | log := zerowrap.FromCtx(ctx) | |||
| 64 | - | ||||
| 65 | - | envVars := []string{} | |||
| 66 | - | envFile, err := l.getEnvFilePath(domain) | |||
| 67 | - | if err != nil { | |||
| 68 | - | return nil, log.WrapErr(err, "invalid env file domain") | |||
| 69 | - | } | |||
| 70 | - | ||||
| 71 | - | // Check if env file exists | |||
| 72 | - | if _, err := os.Stat(envFile); os.IsNotExist(err) { | |||
| 73 | - | log.Debug().Str("env_file", envFile).Msg("no env file found for route") | |||
| 74 | - | return envVars, nil | |||
| 75 | - | } | |||
| 76 | - | ||||
| 77 | - | log.Debug().Str("env_file", envFile).Msg("loading env file for route") | |||
| 78 | - | ||||
| 79 | - | file, err := os.Open(envFile) | |||
| 80 | - | if err != nil { | |||
| 81 | - | return nil, log.WrapErr(err, "failed to open env file") | |||
| 82 | - | } | |||
| 83 | - | defer file.Close() | |||
| 84 | - | ||||
| 85 | - | scanner := bufio.NewScanner(file) | |||
| 86 | - | lineNum := 0 | |||
| 87 | - | ||||
| 88 | - | for scanner.Scan() { | |||
| 89 | - | lineNum++ | |||
| 90 | - | line := strings.TrimSpace(scanner.Text()) | |||
| 91 | - | ||||
| 92 | - | // Skip empty lines and comments | |||
| 93 | - | if line == "" || strings.HasPrefix(line, "#") { | |||
| 94 | - | continue | |||
| 95 | - | } | |||
| 96 | - | ||||
| 97 | - | // Parse KEY=VALUE format | |||
| 98 | - | parts := strings.SplitN(line, "=", 2) | |||
| 99 | - | if len(parts) != 2 { | |||
| 100 | - | log.Warn(). | |||
| 101 | - | Int("line", lineNum). | |||
| 102 | - | Msg("invalid env file line format, skipping") | |||
| 103 | - | continue | |||
| 104 | - | } | |||
| 105 | - | ||||
| 106 | - | key := strings.TrimSpace(parts[0]) | |||
| 107 | - | value := strings.TrimSpace(parts[1]) | |||
| 108 | - | ||||
| 109 | - | // Remove quotes if present | |||
| 110 | - | if len(value) >= 2 { | |||
| 111 | - | if (strings.HasPrefix(value, "\"") && strings.HasSuffix(value, "\"")) || | |||
| 112 | - | (strings.HasPrefix(value, "'") && strings.HasSuffix(value, "'")) { | |||
| 113 | - | value = value[1 : len(value)-1] | |||
| 114 | - | } | |||
| 115 | - | } | |||
| 116 | - | ||||
| 117 | - | // Resolve secrets if value contains secret syntax | |||
| 118 | - | resolvedValue, err := l.resolveSecrets(ctx, value) | |||
| 119 | - | if err != nil { | |||
| 120 | - | return nil, log.WrapErrWithFields( | |||
| 121 | - | fmt.Errorf("key %s on line %d: %w", key, lineNum, err), | |||
| 122 | - | "failed to resolve secret", | |||
| 123 | - | map[string]any{ | |||
| 124 | - | "key": key, | |||
| 125 | - | "line": lineNum, | |||
| 126 | - | }, | |||
| 127 | - | ) | |||
| 128 | - | } | |||
| 129 | - | ||||
| 130 | - | envVars = append(envVars, fmt.Sprintf("%s=%s", key, resolvedValue)) | |||
| 131 | - | } | |||
| 132 | - | ||||
| 133 | - | if err := scanner.Err(); err != nil { | |||
| 134 | - | return nil, log.WrapErr(err, "error reading env file") | |||
| 135 | - | } | |||
| 136 | - | ||||
| 137 | - | log.Info().Int(zerowrap.FieldCount, len(envVars)).Msg("loaded environment variables for route") | |||
| 138 | - | ||||
| 139 | - | return envVars, nil | |||
| 140 | - | } | |||
| 141 | - | ||||
| 142 | - | // CreateEnvFile creates an empty environment file for a new domain. | |||
| 143 | - | func (l *FileLoader) CreateEnvFile(ctx context.Context, domain string) error { | |||
| 144 | - | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |||
| 145 | - | zerowrap.FieldLayer: "adapter", | |||
| 146 | - | zerowrap.FieldAdapter: "envloader", | |||
| 147 | - | zerowrap.FieldAction: "CreateEnvFile", | |||
| 148 | - | "domain": domain, | |||
| 149 | - | }) | |||
| 150 | - | log := zerowrap.FromCtx(ctx) | |||
| 151 | - | ||||
| 152 | - | envFile, err := l.getEnvFilePath(domain) | |||
| 153 | - | if err != nil { | |||
| 154 | - | return log.WrapErr(err, "invalid env file domain") | |||
| 155 | - | } | |||
| 156 | - | ||||
| 157 | - | // Check if file already exists | |||
| 158 | - | if _, err := os.Stat(envFile); err == nil { | |||
| 159 | - | log.Debug().Str("env_file", envFile).Msg("env file already exists, skipping creation") | |||
| 160 | - | return nil | |||
| 161 | - | } else if !os.IsNotExist(err) { | |||
| 162 | - | return log.WrapErr(err, "error checking env file") | |||
| 163 | - | } | |||
| 164 | - | ||||
| 165 | - | // Create empty env file with helpful comments | |||
| 166 | - | content := fmt.Sprintf(`# Environment variables for route: %s | |||
| 167 | - | # Add KEY=VALUE pairs, one per line | |||
| 168 | - | # Secrets can be referenced with ${provider:path} syntax | |||
| 169 | - | ||||
| 170 | - | `, domain) | |||
| 171 | - | ||||
| 172 | - | if err := os.WriteFile(envFile, []byte(content), 0600); err != nil { | |||
| 173 | - | return log.WrapErr(err, "failed to create env file") | |||
| 174 | - | } | |||
| 175 | - | ||||
| 176 | - | log.Info().Str("env_file", envFile).Msg("created empty env file for route") | |||
| 177 | - | ||||
| 178 | - | return nil | |||
| 179 | - | } | |||
| 180 | - | ||||
| 181 | - | // EnvFileExists checks if an environment file exists for a domain. | |||
| 182 | - | func (l *FileLoader) EnvFileExists(domain string) (bool, error) { | |||
| 183 | - | envFile, err := l.getEnvFilePath(domain) | |||
| 184 | - | if err != nil { | |||
| 185 | - | return false, err | |||
| 186 | - | } | |||
| 187 | - | ||||
| 188 | - | _, err = os.Stat(envFile) | |||
| 189 | - | if os.IsNotExist(err) { | |||
| 190 | - | return false, nil | |||
| 191 | - | } | |||
| 192 | - | if err != nil { | |||
| 193 | - | return false, err | |||
| 194 | - | } | |||
| 195 | - | ||||
| 196 | - | return true, nil | |||
| 197 | - | } | |||
| 198 | - | ||||
| 199 | - | // resolveSecrets resolves any secret references in the value. | |||
| 200 | - | // Secret syntax: ${provider:path} | |||
| 201 | - | func (l *FileLoader) resolveSecrets(ctx context.Context, value string) (string, error) { | |||
| 202 | - | // Check if value contains secret syntax: ${provider:path} | |||
| 203 | - | if !strings.Contains(value, "${") { | |||
| 204 | - | return value, nil | |||
| 205 | - | } | |||
| 206 | - | ||||
| 207 | - | result := value | |||
| 208 | - | for { | |||
| 209 | - | start := strings.Index(result, "${") | |||
| 210 | - | if start == -1 { | |||
| 211 | - | break | |||
| 212 | - | } | |||
| 213 | - | ||||
| 214 | - | end := strings.Index(result[start:], "}") | |||
| 215 | - | if end == -1 { | |||
| 216 | - | return "", fmt.Errorf("unclosed secret syntax") | |||
| 217 | - | } | |||
| 218 | - | end += start | |||
| 219 | - | ||||
| 220 | - | secretRef := result[start+2 : end] | |||
| 221 | - | parts := strings.SplitN(secretRef, ":", 2) | |||
| 222 | - | if len(parts) != 2 { | |||
| 223 | - | return "", fmt.Errorf("invalid secret syntax: expected 'provider:path'") | |||
| 224 | - | } | |||
| 225 | - | ||||
| 226 | - | providerName := parts[0] | |||
| 227 | - | secretPath := parts[1] | |||
| 228 | - | ||||
| 229 | - | provider, exists := l.secretProviders[providerName] | |||
| 230 | - | if !exists { | |||
| 231 | - | return "", fmt.Errorf("unknown secret provider: %s", providerName) | |||
| 232 | - | } | |||
| 233 | - | ||||
| 234 | - | secretValue, err := provider.GetSecret(ctx, secretPath) | |||
| 235 | - | if err != nil { | |||
| 236 | - | return "", fmt.Errorf("failed to get secret from provider %s", providerName) | |||
| 237 | - | } | |||
| 238 | - | ||||
| 239 | - | // Replace the secret reference with the actual value | |||
| 240 | - | result = result[:start] + secretValue + result[end+1:] | |||
| 241 | - | } | |||
| 242 | - | ||||
| 243 | - | return result, nil | |||
| 244 | - | } | |||
| 245 | - | ||||
| 246 | - | func (l *FileLoader) getEnvFilePath(domainName string) (string, error) { | |||
| 247 | - | storageKey, err := domain.NewEnvStorageKey(domainName) | |||
| 248 | - | if err != nil { | |||
| 249 | - | return "", err | |||
| 250 | - | } | |||
| 251 | - | ||||
| 252 | - | return filepath.Join(l.envDir, storageKey.FileName()), nil | |||
| 253 | - | } | |||
D internal/adapters/out/envloader/file_test.go +0 -368
| @@ -1,368 +0,0 @@ | |||||
| 1 | - | package envloader | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "bytes" | |||
| 5 | - | "context" | |||
| 6 | - | "errors" | |||
| 7 | - | "os" | |||
| 8 | - | "path/filepath" | |||
| 9 | - | "testing" | |||
| 10 | - | ||||
| 11 | - | "github.com/bnema/zerowrap" | |||
| 12 | - | "github.com/stretchr/testify/assert" | |||
| 13 | - | "github.com/stretchr/testify/require" | |||
| 14 | - | ||||
| 15 | - | "github.com/bnema/gordon/internal/boundaries/out/mocks" | |||
| 16 | - | "github.com/bnema/gordon/internal/domain" | |||
| 17 | - | ) | |||
| 18 | - | ||||
| 19 | - | func TestFileLoader_ResolveSecrets(t *testing.T) { | |||
| 20 | - | // Create a temp directory for tests | |||
| 21 | - | tmpDir := t.TempDir() | |||
| 22 | - | log := zerowrap.New(zerowrap.Config{Level: "fatal"}) | |||
| 23 | - | ||||
| 24 | - | loader, err := NewFileLoader(tmpDir, log) | |||
| 25 | - | require.NoError(t, err) | |||
| 26 | - | ||||
| 27 | - | ctx := context.Background() | |||
| 28 | - | ||||
| 29 | - | t.Run("no secret syntax returns value unchanged", func(t *testing.T) { | |||
| 30 | - | result, err := loader.resolveSecrets(ctx, "plain-value") | |||
| 31 | - | assert.NoError(t, err) | |||
| 32 | - | assert.Equal(t, "plain-value", result) | |||
| 33 | - | }) | |||
| 34 | - | ||||
| 35 | - | t.Run("unclosed secret syntax returns error", func(t *testing.T) { | |||
| 36 | - | _, err := loader.resolveSecrets(ctx, "${pass:secret") | |||
| 37 | - | assert.Error(t, err) | |||
| 38 | - | assert.Contains(t, err.Error(), "unclosed secret syntax") | |||
| 39 | - | }) | |||
| 40 | - | ||||
| 41 | - | t.Run("invalid secret syntax without colon returns error", func(t *testing.T) { | |||
| 42 | - | _, err := loader.resolveSecrets(ctx, "${invalid}") | |||
| 43 | - | assert.Error(t, err) | |||
| 44 | - | assert.Contains(t, err.Error(), "invalid secret syntax") | |||
| 45 | - | }) | |||
| 46 | - | ||||
| 47 | - | t.Run("unknown provider returns error", func(t *testing.T) { | |||
| 48 | - | _, err := loader.resolveSecrets(ctx, "${unknown:path}") | |||
| 49 | - | assert.Error(t, err) | |||
| 50 | - | assert.Contains(t, err.Error(), "unknown secret provider") | |||
| 51 | - | }) | |||
| 52 | - | ||||
| 53 | - | t.Run("pass provider resolves secret", func(t *testing.T) { | |||
| 54 | - | mockPass := mocks.NewMockSecretProvider(t) | |||
| 55 | - | mockPass.EXPECT().Name().Return("pass") | |||
| 56 | - | mockPass.EXPECT().GetSecret(ctx, "company/api-key").Return("secret-value-123", nil) | |||
| 57 | - | ||||
| 58 | - | loader.RegisterSecretProvider(mockPass) | |||
| 59 | - | ||||
| 60 | - | result, err := loader.resolveSecrets(ctx, "${pass:company/api-key}") | |||
| 61 | - | assert.NoError(t, err) | |||
| 62 | - | assert.Equal(t, "secret-value-123", result) | |||
| 63 | - | }) | |||
| 64 | - | ||||
| 65 | - | t.Run("sops provider resolves secret", func(t *testing.T) { | |||
| 66 | - | mockSops := mocks.NewMockSecretProvider(t) | |||
| 67 | - | mockSops.EXPECT().Name().Return("sops") | |||
| 68 | - | mockSops.EXPECT().GetSecret(ctx, "secrets.yaml:app.password").Return("sops-secret", nil) | |||
| 69 | - | ||||
| 70 | - | loader.RegisterSecretProvider(mockSops) | |||
| 71 | - | ||||
| 72 | - | result, err := loader.resolveSecrets(ctx, "${sops:secrets.yaml:app.password}") | |||
| 73 | - | assert.NoError(t, err) | |||
| 74 | - | assert.Equal(t, "sops-secret", result) | |||
| 75 | - | }) | |||
| 76 | - | ||||
| 77 | - | t.Run("multiple secrets in one value", func(t *testing.T) { | |||
| 78 | - | mockPass := mocks.NewMockSecretProvider(t) | |||
| 79 | - | mockPass.EXPECT().Name().Return("pass") | |||
| 80 | - | mockPass.EXPECT().GetSecret(ctx, "db/user").Return("admin", nil) | |||
| 81 | - | mockPass.EXPECT().GetSecret(ctx, "db/pass").Return("secret123", nil) | |||
| 82 | - | ||||
| 83 | - | loader.RegisterSecretProvider(mockPass) | |||
| 84 | - | ||||
| 85 | - | result, err := loader.resolveSecrets(ctx, "postgresql://${pass:db/user}:${pass:db/pass}@localhost:5432/db") | |||
| 86 | - | assert.NoError(t, err) | |||
| 87 | - | assert.Equal(t, "postgresql://admin:secret123@localhost:5432/db", result) | |||
| 88 | - | }) | |||
| 89 | - | ||||
| 90 | - | t.Run("provider error is propagated", func(t *testing.T) { | |||
| 91 | - | mockPass := mocks.NewMockSecretProvider(t) | |||
| 92 | - | mockPass.EXPECT().Name().Return("pass") | |||
| 93 | - | mockPass.EXPECT().GetSecret(ctx, "missing/secret").Return("", assert.AnError) | |||
| 94 | - | ||||
| 95 | - | loader.RegisterSecretProvider(mockPass) | |||
| 96 | - | ||||
| 97 | - | _, err := loader.resolveSecrets(ctx, "${pass:missing/secret}") | |||
| 98 | - | assert.Error(t, err) | |||
| 99 | - | assert.Contains(t, err.Error(), "failed to get secret from provider") | |||
| 100 | - | }) | |||
| 101 | - | } | |||
| 102 | - | ||||
| 103 | - | func TestFileLoader_LoadEnv(t *testing.T) { | |||
| 104 | - | log := zerowrap.New(zerowrap.Config{Level: "fatal"}) | |||
| 105 | - | ctx := context.Background() | |||
| 106 | - | ||||
| 107 | - | t.Run("missing env file returns empty slice", func(t *testing.T) { | |||
| 108 | - | tmpDir := t.TempDir() | |||
| 109 | - | loader, err := NewFileLoader(tmpDir, log) | |||
| 110 | - | require.NoError(t, err) | |||
| 111 | - | ||||
| 112 | - | envVars, err := loader.LoadEnv(ctx, "nonexistent.example.com") | |||
| 113 | - | assert.NoError(t, err) | |||
| 114 | - | assert.Empty(t, envVars) | |||
| 115 | - | }) | |||
| 116 | - | ||||
| 117 | - | t.Run("loads simple env file", func(t *testing.T) { | |||
| 118 | - | tmpDir := t.TempDir() | |||
| 119 | - | loader, err := NewFileLoader(tmpDir, log) | |||
| 120 | - | require.NoError(t, err) | |||
| 121 | - | ||||
| 122 | - | // Create env file | |||
| 123 | - | envContent := `# Comment line | |||
| 124 | - | NODE_ENV=production | |||
| 125 | - | API_URL=https://api.example.com | |||
| 126 | - | ` | |||
| 127 | - | storageKey, err := domain.NewEnvStorageKey("app.example.com") | |||
| 128 | - | require.NoError(t, err) | |||
| 129 | - | envFile := filepath.Join(tmpDir, storageKey.FileName()) | |||
| 130 | - | require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600)) | |||
| 131 | - | ||||
| 132 | - | envVars, err := loader.LoadEnv(ctx, "app.example.com") | |||
| 133 | - | assert.NoError(t, err) | |||
| 134 | - | assert.Len(t, envVars, 2) | |||
| 135 | - | assert.Contains(t, envVars, "NODE_ENV=production") | |||
| 136 | - | assert.Contains(t, envVars, "API_URL=https://api.example.com") | |||
| 137 | - | }) | |||
| 138 | - | ||||
| 139 | - | t.Run("loads env file with secrets", func(t *testing.T) { | |||
| 140 | - | tmpDir := t.TempDir() | |||
| 141 | - | loader, err := NewFileLoader(tmpDir, log) | |||
| 142 | - | require.NoError(t, err) | |||
| 143 | - | ||||
| 144 | - | // Register mock provider | |||
| 145 | - | mockPass := mocks.NewMockSecretProvider(t) | |||
| 146 | - | mockPass.EXPECT().Name().Return("pass") | |||
| 147 | - | mockPass.EXPECT().GetSecret(ctx, "app/api-key").Return("super-secret-key", nil) | |||
| 148 | - | loader.RegisterSecretProvider(mockPass) | |||
| 149 | - | ||||
| 150 | - | // Create env file with secret reference | |||
| 151 | - | envContent := `NODE_ENV=production | |||
| 152 | - | API_KEY=${pass:app/api-key} | |||
| 153 | - | ` | |||
| 154 | - | storageKey, err := domain.NewEnvStorageKey("app.example.com") | |||
| 155 | - | require.NoError(t, err) | |||
| 156 | - | envFile := filepath.Join(tmpDir, storageKey.FileName()) | |||
| 157 | - | require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600)) | |||
| 158 | - | ||||
| 159 | - | envVars, err := loader.LoadEnv(ctx, "app.example.com") | |||
| 160 | - | assert.NoError(t, err) | |||
| 161 | - | assert.Len(t, envVars, 2) | |||
| 162 | - | assert.Contains(t, envVars, "NODE_ENV=production") | |||
| 163 | - | assert.Contains(t, envVars, "API_KEY=super-secret-key") | |||
| 164 | - | }) | |||
| 165 | - | ||||
| 166 | - | t.Run("handles quoted values", func(t *testing.T) { | |||
| 167 | - | tmpDir := t.TempDir() | |||
| 168 | - | loader, err := NewFileLoader(tmpDir, log) | |||
| 169 | - | require.NoError(t, err) | |||
| 170 | - | ||||
| 171 | - | envContent := `DOUBLE_QUOTED="hello world" | |||
| 172 | - | SINGLE_QUOTED='hello world' | |||
| 173 | - | ` | |||
| 174 | - | storageKey, err := domain.NewEnvStorageKey("app.example.com") | |||
| 175 | - | require.NoError(t, err) | |||
| 176 | - | envFile := filepath.Join(tmpDir, storageKey.FileName()) | |||
| 177 | - | require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600)) | |||
| 178 | - | ||||
| 179 | - | envVars, err := loader.LoadEnv(ctx, "app.example.com") | |||
| 180 | - | assert.NoError(t, err) | |||
| 181 | - | assert.Len(t, envVars, 2) | |||
| 182 | - | assert.Contains(t, envVars, "DOUBLE_QUOTED=hello world") | |||
| 183 | - | assert.Contains(t, envVars, "SINGLE_QUOTED=hello world") | |||
| 184 | - | }) | |||
| 185 | - | ||||
| 186 | - | t.Run("skips invalid lines", func(t *testing.T) { | |||
| 187 | - | tmpDir := t.TempDir() | |||
| 188 | - | loader, err := NewFileLoader(tmpDir, log) | |||
| 189 | - | require.NoError(t, err) | |||
| 190 | - | ||||
| 191 | - | envContent := `VALID=value | |||
| 192 | - | invalid line without equals | |||
| 193 | - | ALSO_VALID=another | |||
| 194 | - | ` | |||
| 195 | - | storageKey, err := domain.NewEnvStorageKey("app.example.com") | |||
| 196 | - | require.NoError(t, err) | |||
| 197 | - | envFile := filepath.Join(tmpDir, storageKey.FileName()) | |||
| 198 | - | require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600)) | |||
| 199 | - | ||||
| 200 | - | envVars, err := loader.LoadEnv(ctx, "app.example.com") | |||
| 201 | - | assert.NoError(t, err) | |||
| 202 | - | assert.Len(t, envVars, 2) | |||
| 203 | - | assert.Contains(t, envVars, "VALID=value") | |||
| 204 | - | assert.Contains(t, envVars, "ALSO_VALID=another") | |||
| 205 | - | }) | |||
| 206 | - | } | |||
| 207 | - | ||||
| 208 | - | func TestFileLoader_LoadEnv_RedactsSecretBearingDiagnostics(t *testing.T) { | |||
| 209 | - | ctx := context.Background() | |||
| 210 | - | ||||
| 211 | - | t.Run("invalid line warning omits raw line content", func(t *testing.T) { | |||
| 212 | - | var logs bytes.Buffer | |||
| 213 | - | log := zerowrap.New(zerowrap.Config{Level: "warn", Format: "json", Output: &logs}) | |||
| 214 | - | loader, err := NewFileLoader(t.TempDir(), log) | |||
| 215 | - | require.NoError(t, err) | |||
| 216 | - | ctx := zerowrap.WithCtx(ctx, log) | |||
| 217 | - | ||||
| 218 | - | envFile, err := loader.getEnvFilePath("app.example.com") | |||
| 219 | - | require.NoError(t, err) | |||
| 220 | - | require.NoError(t, os.WriteFile(envFile, []byte("valid line leaks super-secret-token\nVALID=value\n"), 0600)) | |||
| 221 | - | ||||
| 222 | - | envVars, err := loader.LoadEnv(ctx, "app.example.com") | |||
| 223 | - | require.NoError(t, err) | |||
| 224 | - | assert.Equal(t, []string{"VALID=value"}, envVars) | |||
| 225 | - | assert.NotContains(t, logs.String(), "super-secret-token") | |||
| 226 | - | assert.NotContains(t, logs.String(), "valid line leaks") | |||
| 227 | - | assert.Contains(t, logs.String(), "line") | |||
| 228 | - | }) | |||
| 229 | - | ||||
| 230 | - | t.Run("secret resolution error omits unclosed raw value", func(t *testing.T) { | |||
| 231 | - | log := zerowrap.New(zerowrap.Config{Level: "fatal"}) | |||
| 232 | - | loader, err := NewFileLoader(t.TempDir(), log) | |||
| 233 | - | require.NoError(t, err) | |||
| 234 | - | ||||
| 235 | - | envFile, err := loader.getEnvFilePath("app.example.com") | |||
| 236 | - | require.NoError(t, err) | |||
| 237 | - | require.NoError(t, os.WriteFile(envFile, []byte("DATABASE_URL=postgres://user:${pass:db-password-super-secret\n"), 0600)) | |||
| 238 | - | ||||
| 239 | - | _, err = loader.LoadEnv(ctx, "app.example.com") | |||
| 240 | - | require.Error(t, err) | |||
| 241 | - | assert.Contains(t, err.Error(), "failed to resolve secret") | |||
| 242 | - | assert.Contains(t, err.Error(), "DATABASE_URL") | |||
| 243 | - | assert.NotContains(t, err.Error(), "postgres://user") | |||
| 244 | - | assert.NotContains(t, err.Error(), "db-password-super-secret") | |||
| 245 | - | }) | |||
| 246 | - | ||||
| 247 | - | t.Run("secret resolution error omits malformed closed secret ref", func(t *testing.T) { | |||
| 248 | - | log := zerowrap.New(zerowrap.Config{Level: "fatal"}) | |||
| 249 | - | loader, err := NewFileLoader(t.TempDir(), log) | |||
| 250 | - | require.NoError(t, err) | |||
| 251 | - | ||||
| 252 | - | envFile, err := loader.getEnvFilePath("app.example.com") | |||
| 253 | - | require.NoError(t, err) | |||
| 254 | - | require.NoError(t, os.WriteFile(envFile, []byte("TOKEN=${db-password-super-secret}\n"), 0600)) | |||
| 255 | - | ||||
| 256 | - | _, err = loader.LoadEnv(ctx, "app.example.com") | |||
| 257 | - | require.Error(t, err) | |||
| 258 | - | assert.Contains(t, err.Error(), "failed to resolve secret") | |||
| 259 | - | assert.Contains(t, err.Error(), "TOKEN") | |||
| 260 | - | assert.NotContains(t, err.Error(), "db-password-super-secret") | |||
| 261 | - | }) | |||
| 262 | - | ||||
| 263 | - | t.Run("secret resolution error omits provider error details", func(t *testing.T) { | |||
| 264 | - | log := zerowrap.New(zerowrap.Config{Level: "fatal"}) | |||
| 265 | - | loader, err := NewFileLoader(t.TempDir(), log) | |||
| 266 | - | require.NoError(t, err) | |||
| 267 | - | ||||
| 268 | - | mockPass := mocks.NewMockSecretProvider(t) | |||
| 269 | - | mockPass.EXPECT().Name().Return("pass") | |||
| 270 | - | mockPass.EXPECT().GetSecret(ctx, "db-password-super-secret").Return("", errors.New("backend leaked db-password-super-secret")) | |||
| 271 | - | loader.RegisterSecretProvider(mockPass) | |||
| 272 | - | ||||
| 273 | - | envFile, err := loader.getEnvFilePath("app.example.com") | |||
| 274 | - | require.NoError(t, err) | |||
| 275 | - | require.NoError(t, os.WriteFile(envFile, []byte("TOKEN=${pass:db-password-super-secret}\n"), 0600)) | |||
| 276 | - | ||||
| 277 | - | _, err = loader.LoadEnv(ctx, "app.example.com") | |||
| 278 | - | require.Error(t, err) | |||
| 279 | - | assert.Contains(t, err.Error(), "failed to resolve secret") | |||
| 280 | - | assert.Contains(t, err.Error(), "TOKEN") | |||
| 281 | - | assert.Contains(t, err.Error(), "provider pass") | |||
| 282 | - | assert.NotContains(t, err.Error(), "db-password-super-secret") | |||
| 283 | - | }) | |||
| 284 | - | } | |||
| 285 | - | ||||
| 286 | - | func TestFileLoader_GetEnvFilePath(t *testing.T) { | |||
| 287 | - | tmpDir := t.TempDir() | |||
| 288 | - | log := zerowrap.New(zerowrap.Config{Level: "fatal"}) | |||
| 289 | - | ||||
| 290 | - | loader, err := NewFileLoader(tmpDir, log) | |||
| 291 | - | require.NoError(t, err) | |||
| 292 | - | ||||
| 293 | - | tests := []struct { | |||
| 294 | - | domain string | |||
| 295 | - | expected string | |||
| 296 | - | }{ | |||
| 297 | - | {"app.example.com", "YXBwLmV4YW1wbGUuY29t.env"}, | |||
| 298 | - | {"api.sub.example.com", "YXBpLnN1Yi5leGFtcGxlLmNvbQ.env"}, | |||
| 299 | - | {"localhost:8080", "bG9jYWxob3N0OjgwODA.env"}, | |||
| 300 | - | } | |||
| 301 | - | ||||
| 302 | - | for _, tt := range tests { | |||
| 303 | - | t.Run(tt.domain, func(t *testing.T) { | |||
| 304 | - | result, err := loader.getEnvFilePath(tt.domain) | |||
| 305 | - | require.NoError(t, err) | |||
| 306 | - | assert.Equal(t, filepath.Join(tmpDir, tt.expected), result) | |||
| 307 | - | }) | |||
| 308 | - | } | |||
| 309 | - | } | |||
| 310 | - | ||||
| 311 | - | func TestFileLoader_GetEnvFilePath_DistinguishesSeparators(t *testing.T) { | |||
| 312 | - | tmpDir := t.TempDir() | |||
| 313 | - | log := zerowrap.New(zerowrap.Config{Level: "fatal"}) | |||
| 314 | - | ||||
| 315 | - | loader, err := NewFileLoader(tmpDir, log) | |||
| 316 | - | require.NoError(t, err) | |||
| 317 | - | ||||
| 318 | - | dotPath, err := loader.getEnvFilePath("app.example.com") | |||
| 319 | - | require.NoError(t, err) | |||
| 320 | - | portPath, err := loader.getEnvFilePath("app:example:com") | |||
| 321 | - | require.NoError(t, err) | |||
| 322 | - | slashPath, err := loader.getEnvFilePath("app/example/com") | |||
| 323 | - | require.NoError(t, err) | |||
| 324 | - | ||||
| 325 | - | assert.NotEqual(t, dotPath, portPath) | |||
| 326 | - | assert.NotEqual(t, dotPath, slashPath) | |||
| 327 | - | assert.NotEqual(t, portPath, slashPath) | |||
| 328 | - | } | |||
| 329 | - | ||||
| 330 | - | func TestFileLoader_GetEnvFilePath_RejectsInvalidDomain(t *testing.T) { | |||
| 331 | - | tmpDir := t.TempDir() | |||
| 332 | - | log := zerowrap.New(zerowrap.Config{Level: "fatal"}) | |||
| 333 | - | ||||
| 334 | - | loader, err := NewFileLoader(tmpDir, log) | |||
| 335 | - | require.NoError(t, err) | |||
| 336 | - | ||||
| 337 | - | _, err = loader.getEnvFilePath("../etc/passwd") | |||
| 338 | - | require.ErrorIs(t, err, domain.ErrPathTraversal) | |||
| 339 | - | } | |||
| 340 | - | ||||
| 341 | - | func TestFileLoader_EnvFileExists(t *testing.T) { | |||
| 342 | - | log := zerowrap.New(zerowrap.Config{Level: "fatal"}) | |||
| 343 | - | ||||
| 344 | - | t.Run("returns false nil when file is missing", func(t *testing.T) { | |||
| 345 | - | tmpDir := t.TempDir() | |||
| 346 | - | loader, err := NewFileLoader(tmpDir, log) | |||
| 347 | - | require.NoError(t, err) | |||
| 348 | - | ||||
| 349 | - | exists, err := loader.EnvFileExists("app.example.com") | |||
| 350 | - | require.NoError(t, err) | |||
| 351 | - | assert.False(t, exists) | |||
| 352 | - | }) | |||
| 353 | - | ||||
| 354 | - | t.Run("returns true nil when file exists", func(t *testing.T) { | |||
| 355 | - | tmpDir := t.TempDir() | |||
| 356 | - | loader, err := NewFileLoader(tmpDir, log) | |||
| 357 | - | require.NoError(t, err) | |||
| 358 | - | ||||
| 359 | - | storageKey, err := domain.NewEnvStorageKey("app.example.com") | |||
| 360 | - | require.NoError(t, err) | |||
| 361 | - | envFile := filepath.Join(tmpDir, storageKey.FileName()) | |||
| 362 | - | require.NoError(t, os.WriteFile(envFile, []byte("KEY=value\n"), 0600)) | |||
| 363 | - | ||||
| 364 | - | exists, err := loader.EnvFileExists("app.example.com") | |||
| 365 | - | require.NoError(t, err) | |||
| 366 | - | assert.True(t, exists) | |||
| 367 | - | }) | |||
| 368 | - | } | |||
D internal/adapters/out/envloader/pass_loader.go +0 -117
| @@ -1,117 +0,0 @@ | |||||
| 1 | - | // Package envloader implements the environment variable loader adapter. | |||
| 2 | - | package envloader | |||
| 3 | - | ||||
| 4 | - | import ( | |||
| 5 | - | "context" | |||
| 6 | - | "fmt" | |||
| 7 | - | "sort" | |||
| 8 | - | "strings" | |||
| 9 | - | ||||
| 10 | - | "github.com/bnema/zerowrap" | |||
| 11 | - | ||||
| 12 | - | "github.com/bnema/gordon/internal/adapters/out/domainsecrets" | |||
| 13 | - | ) | |||
| 14 | - | ||||
| 15 | - | // PassLoader implements the EnvLoader interface using pass-backed secrets. | |||
| 16 | - | type PassLoader struct { | |||
| 17 | - | store *domainsecrets.PassStore | |||
| 18 | - | log zerowrap.Logger | |||
| 19 | - | } | |||
| 20 | - | ||||
| 21 | - | // NewPassLoader creates a new pass-based environment loader. | |||
| 22 | - | func NewPassLoader(store *domainsecrets.PassStore, log zerowrap.Logger) (*PassLoader, error) { | |||
| 23 | - | if store == nil { | |||
| 24 | - | return nil, fmt.Errorf("pass store is required") | |||
| 25 | - | } | |||
| 26 | - | ||||
| 27 | - | log.Debug(). | |||
| 28 | - | Str(zerowrap.FieldLayer, "adapter"). | |||
| 29 | - | Str(zerowrap.FieldAdapter, "envloader"). | |||
| 30 | - | Str("provider", "pass"). | |||
| 31 | - | Msg("env loader initialized") | |||
| 32 | - | ||||
| 33 | - | return &PassLoader{ | |||
| 34 | - | store: store, | |||
| 35 | - | log: log, | |||
| 36 | - | }, nil | |||
| 37 | - | } | |||
| 38 | - | ||||
| 39 | - | // LoadEnv loads environment variables for a given domain. | |||
| 40 | - | func (l *PassLoader) LoadEnv(ctx context.Context, domain string) ([]string, error) { | |||
| 41 | - | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |||
| 42 | - | zerowrap.FieldLayer: "adapter", | |||
| 43 | - | zerowrap.FieldAdapter: "envloader", | |||
| 44 | - | zerowrap.FieldAction: "LoadEnv", | |||
| 45 | - | "domain": domain, | |||
| 46 | - | }) | |||
| 47 | - | log := zerowrap.FromCtx(ctx) | |||
| 48 | - | ||||
| 49 | - | var secretsMap map[string]string | |||
| 50 | - | var err error | |||
| 51 | - | ||||
| 52 | - | if isAttachmentContainer(domain) { | |||
| 53 | - | secretsMap, err = l.store.GetAllAttachment(domain) | |||
| 54 | - | if err != nil { | |||
| 55 | - | return nil, log.WrapErr(err, "failed to load attachment env from pass") | |||
| 56 | - | } | |||
| 57 | - | } else { | |||
| 58 | - | secretsMap, err = l.store.GetAll(domain) | |||
| 59 | - | if err != nil { | |||
| 60 | - | return nil, log.WrapErr(err, "failed to load env from pass") | |||
| 61 | - | } | |||
| 62 | - | } | |||
| 63 | - | ||||
| 64 | - | keys := make([]string, 0, len(secretsMap)) | |||
| 65 | - | for key := range secretsMap { | |||
| 66 | - | keys = append(keys, key) | |||
| 67 | - | } | |||
| 68 | - | sort.Strings(keys) | |||
| 69 | - | ||||
| 70 | - | envVars := make([]string, 0, len(keys)) | |||
| 71 | - | for _, key := range keys { | |||
| 72 | - | envVars = append(envVars, fmt.Sprintf("%s=%s", key, secretsMap[key])) | |||
| 73 | - | } | |||
| 74 | - | ||||
| 75 | - | log.Info().Int(zerowrap.FieldCount, len(envVars)).Msg("loaded environment variables for route") | |||
| 76 | - | ||||
| 77 | - | return envVars, nil | |||
| 78 | - | } | |||
| 79 | - | ||||
| 80 | - | // isAttachmentContainer determines whether a given name refers to an attachment container | |||
| 81 | - | // or a domain. It uses a simple heuristic: attachment container names always start with | |||
| 82 | - | // the "gordon-" prefix (e.g., "gordon-git-example-com-gitea-postgres"). | |||
| 83 | - | // | |||
| 84 | - | // LIMITATION: This heuristic assumes domains never start with "gordon-". A domain like | |||
| 85 | - | // "gordon-app.example.com" would be incorrectly classified as an attachment container. | |||
| 86 | - | // This is an acceptable trade-off for simplicity, but if such domains become common, | |||
| 87 | - | // this should be replaced with a more robust detection mechanism (e.g., checking both | |||
| 88 | - | // GetAll() and GetAllAttachment() and seeing which returns results). | |||
| 89 | - | func isAttachmentContainer(name string) bool { | |||
| 90 | - | // Attachment container names start with "gordon-" and never contain dots. | |||
| 91 | - | // Domain names (including preview domains like "gordon--test.bnema.dev") | |||
| 92 | - | // always contain dots, so checking for dots distinguishes them. | |||
| 93 | - | return strings.HasPrefix(name, "gordon-") && !strings.Contains(name, ".") | |||
| 94 | - | } | |||
| 95 | - | ||||
| 96 | - | // CreateEnvFile is a no-op for pass-backed loader. | |||
| 97 | - | func (l *PassLoader) CreateEnvFile(ctx context.Context, domain string) error { | |||
| 98 | - | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |||
| 99 | - | zerowrap.FieldLayer: "adapter", | |||
| 100 | - | zerowrap.FieldAdapter: "envloader", | |||
| 101 | - | zerowrap.FieldAction: "CreateEnvFile", | |||
| 102 | - | "domain": domain, | |||
| 103 | - | }) | |||
| 104 | - | log := zerowrap.FromCtx(ctx) | |||
| 105 | - | log.Debug().Msg("pass loader does not create env files") | |||
| 106 | - | return nil | |||
| 107 | - | } | |||
| 108 | - | ||||
| 109 | - | // EnvFileExists checks if a manifest exists for a domain in pass. | |||
| 110 | - | func (l *PassLoader) EnvFileExists(domain string) (bool, error) { | |||
| 111 | - | exists, err := l.store.ManifestExists(domain) | |||
| 112 | - | if err != nil { | |||
| 113 | - | l.log.Warn().Err(err).Str("domain", domain).Msg("failed to check pass manifest") | |||
| 114 | - | return false, err | |||
| 115 | - | } | |||
| 116 | - | return exists, nil | |||
| 117 | - | } | |||
D internal/adapters/out/envloader/pass_loader_test.go +0 -108
| @@ -1,108 +0,0 @@ | |||||
| 1 | - | package envloader | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "context" | |||
| 5 | - | "fmt" | |||
| 6 | - | "os/exec" | |||
| 7 | - | "testing" | |||
| 8 | - | "time" | |||
| 9 | - | ||||
| 10 | - | "github.com/stretchr/testify/assert" | |||
| 11 | - | "github.com/stretchr/testify/require" | |||
| 12 | - | ||||
| 13 | - | "github.com/bnema/gordon/internal/adapters/out/domainsecrets" | |||
| 14 | - | "github.com/bnema/zerowrap" | |||
| 15 | - | ) | |||
| 16 | - | ||||
| 17 | - | func passCmd(args ...string) error { | |||
| 18 | - | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) | |||
| 19 | - | defer cancel() | |||
| 20 | - | return exec.CommandContext(ctx, "pass", args...).Run() | |||
| 21 | - | } | |||
| 22 | - | ||||
| 23 | - | func requirePass(t *testing.T) { | |||
| 24 | - | if err := passCmd("version"); err != nil { | |||
| 25 | - | t.Skip("pass not available") | |||
| 26 | - | } | |||
| 27 | - | if err := passCmd("ls"); err != nil { | |||
| 28 | - | t.Skip("pass store not initialized") | |||
| 29 | - | } | |||
| 30 | - | } | |||
| 31 | - | ||||
| 32 | - | func cleanupPassEnv(t *testing.T, path string) { | |||
| 33 | - | _ = passCmd("rm", "-rf", path) | |||
| 34 | - | } | |||
| 35 | - | ||||
| 36 | - | func TestPassLoader_LoadEnv_Domain(t *testing.T) { | |||
| 37 | - | requirePass(t) | |||
| 38 | - | ||||
| 39 | - | log := zerowrap.New(zerowrap.Config{Level: "fatal"}) | |||
| 40 | - | store, err := domainsecrets.NewPassStore(log) | |||
| 41 | - | require.NoError(t, err) | |||
| 42 | - | ||||
| 43 | - | loader, err := NewPassLoader(store, log) | |||
| 44 | - | require.NoError(t, err) | |||
| 45 | - | ||||
| 46 | - | domainName := fmt.Sprintf("test.%d.example.com", time.Now().UnixNano()) | |||
| 47 | - | defer cleanupPassEnv(t, fmt.Sprintf("%s/%s", domainsecrets.PassDomainSecretsPath, domainName)) | |||
| 48 | - | ||||
| 49 | - | secretsMap := map[string]string{ | |||
| 50 | - | "API_KEY": "test123", | |||
| 51 | - | "DB_HOST": "localhost", | |||
| 52 | - | } | |||
| 53 | - | err = store.Set(domainName, secretsMap) | |||
| 54 | - | require.NoError(t, err) | |||
| 55 | - | ||||
| 56 | - | ctx := context.Background() | |||
| 57 | - | envVars, err := loader.LoadEnv(ctx, domainName) | |||
| 58 | - | require.NoError(t, err) | |||
| 59 | - | assert.Len(t, envVars, 2) | |||
| 60 | - | assert.Contains(t, envVars, "API_KEY=test123") | |||
| 61 | - | assert.Contains(t, envVars, "DB_HOST=localhost") | |||
| 62 | - | } | |||
| 63 | - | ||||
| 64 | - | func TestPassLoader_LoadEnv_Attachment(t *testing.T) { | |||
| 65 | - | requirePass(t) | |||
| 66 | - | ||||
| 67 | - | log := zerowrap.New(zerowrap.Config{Level: "fatal"}) | |||
| 68 | - | store, err := domainsecrets.NewPassStore(log) | |||
| 69 | - | require.NoError(t, err) | |||
| 70 | - | ||||
| 71 | - | loader, err := NewPassLoader(store, log) | |||
| 72 | - | require.NoError(t, err) | |||
| 73 | - | ||||
| 74 | - | containerName := fmt.Sprintf("gordon-gitea-postgres-%d", time.Now().UnixNano()) | |||
| 75 | - | defer cleanupPassEnv(t, fmt.Sprintf("%s/%s", domainsecrets.PassAttachmentPath, containerName)) | |||
| 76 | - | ||||
| 77 | - | secretsMap := map[string]string{ | |||
| 78 | - | "POSTGRES_USER": "gitea", | |||
| 79 | - | "POSTGRES_PASSWORD": "secret123", | |||
| 80 | - | } | |||
| 81 | - | err = store.SetAttachment(containerName, secretsMap) | |||
| 82 | - | require.NoError(t, err) | |||
| 83 | - | ||||
| 84 | - | ctx := context.Background() | |||
| 85 | - | envVars, err := loader.LoadEnv(ctx, containerName) | |||
| 86 | - | require.NoError(t, err) | |||
| 87 | - | assert.Len(t, envVars, 2) | |||
| 88 | - | assert.Contains(t, envVars, "POSTGRES_USER=gitea") | |||
| 89 | - | assert.Contains(t, envVars, "POSTGRES_PASSWORD=secret123") | |||
| 90 | - | } | |||
| 91 | - | ||||
| 92 | - | func TestPassLoader_LoadEnv_EmptyDomain(t *testing.T) { | |||
| 93 | - | requirePass(t) | |||
| 94 | - | ||||
| 95 | - | log := zerowrap.New(zerowrap.Config{Level: "fatal"}) | |||
| 96 | - | store, err := domainsecrets.NewPassStore(log) | |||
| 97 | - | require.NoError(t, err) | |||
| 98 | - | ||||
| 99 | - | loader, err := NewPassLoader(store, log) | |||
| 100 | - | require.NoError(t, err) | |||
| 101 | - | ||||
| 102 | - | domainName := fmt.Sprintf("empty.%d.example.com", time.Now().UnixNano()) | |||
| 103 | - | ||||
| 104 | - | ctx := context.Background() | |||
| 105 | - | envVars, err := loader.LoadEnv(ctx, domainName) | |||
| 106 | - | require.NoError(t, err) | |||
| 107 | - | assert.Len(t, envVars, 0) | |||
| 108 | - | } | |||
M internal/app/kernel.go +0 -14
| @@ -10,7 +10,6 @@ import ( | |||||
| 10 | 10 | ||||
| 11 | "github.com/bnema/gordon/internal/boundaries/in" | 11 | "github.com/bnema/gordon/internal/boundaries/in" | ||
| 12 | configusecase "github.com/bnema/gordon/internal/usecase/config" | 12 | configusecase "github.com/bnema/gordon/internal/usecase/config" | ||
| 13 | - | secretsusecase "github.com/bnema/gordon/internal/usecase/secrets" | |||
| 14 | ) | 13 | ) | ||
| 15 | 14 | ||||
| 16 | // Kernel provides in-process service access for local CLI execution. | 15 | // Kernel provides in-process service access for local CLI execution. | ||
| @@ -19,7 +18,6 @@ import ( | |||||
| 19 | type Kernel struct { | 18 | type Kernel struct { | ||
| 20 | authEnabled bool | 19 | authEnabled bool | ||
| 21 | configSvc in.ConfigService | 20 | configSvc in.ConfigService | ||
| 22 | - | secretSvc in.SecretService | |||
| 23 | containerSvc in.ContainerService | 21 | containerSvc in.ContainerService | ||
| 24 | backupSvc in.BackupService | 22 | backupSvc in.BackupService | ||
| 25 | volumeBackupSvc in.VolumeBackupService | 23 | volumeBackupSvc in.VolumeBackupService | ||
| @@ -87,7 +85,6 @@ func newKernel(configPath string, initLog kernelLoggerInit) (*Kernel, error) { | |||||
| 87 | kernel := &Kernel{ | 85 | kernel := &Kernel{ | ||
| 88 | authEnabled: cfg.Auth.Enabled, | 86 | authEnabled: cfg.Auth.Enabled, | ||
| 89 | configSvc: svc.configSvc, | 87 | configSvc: svc.configSvc, | ||
| 90 | - | secretSvc: svc.secretSvc, | |||
| 91 | containerSvc: svc.containerSvc, | 88 | containerSvc: svc.containerSvc, | ||
| 92 | backupSvc: svc.backupSvc, | 89 | backupSvc: svc.backupSvc, | ||
| 93 | volumeBackupSvc: svc.volumeBackupSvc, | 90 | volumeBackupSvc: svc.volumeBackupSvc, | ||
| @@ -116,18 +113,9 @@ func newKernel(configPath string, initLog kernelLoggerInit) (*Kernel, error) { | |||||
| 116 | return nil, fmt.Errorf("failed to load configuration: %w", err) | 113 | return nil, fmt.Errorf("failed to load configuration: %w", err) | ||
| 117 | } | 114 | } | ||
| 118 | 115 | ||||
| 119 | - | _, _, _, domainSecretStore, err := createDomainSecretStore(cfg, log) | |||
| 120 | - | if err != nil { | |||
| 121 | - | cleanup() | |||
| 122 | - | return nil, fmt.Errorf("failed to create local secret store: %w", err) | |||
| 123 | - | } | |||
| 124 | - | ||||
| 125 | - | secretSvc := secretsusecase.NewService(domainSecretStore, log, nil) | |||
| 126 | - | ||||
| 127 | return &Kernel{ | 116 | return &Kernel{ | ||
| 128 | authEnabled: cfg.Auth.Enabled, | 117 | authEnabled: cfg.Auth.Enabled, | ||
| 129 | configSvc: configSvc, | 118 | configSvc: configSvc, | ||
| 130 | - | secretSvc: secretSvc, | |||
| 131 | log: log, | 119 | log: log, | ||
| 132 | cleanup: cleanup, | 120 | cleanup: cleanup, | ||
| 133 | }, nil | 121 | }, nil | ||
| @@ -162,8 +150,6 @@ func (k *Kernel) Close() error { | |||||
| 162 | 150 | ||||
| 163 | func (k *Kernel) Config() in.ConfigService { return k.configSvc } | 151 | func (k *Kernel) Config() in.ConfigService { return k.configSvc } | ||
| 164 | 152 | ||||
| 165 | - | func (k *Kernel) Secrets() in.SecretService { return k.secretSvc } | |||
| 166 | - | ||||
| 167 | func (k *Kernel) Container() in.ContainerService { return k.containerSvc } | 153 | func (k *Kernel) Container() in.ContainerService { return k.containerSvc } | ||
| 168 | 154 | ||||
| 169 | func (k *Kernel) Backup() in.BackupService { return k.backupSvc } | 155 | func (k *Kernel) Backup() in.BackupService { return k.backupSvc } | ||
M internal/app/kernel_test.go +0 -1
| @@ -37,7 +37,6 @@ secrets_backend = "unsafe" | |||||
| 37 | t.Cleanup(func() { require.NoError(t, kernel.Close()) }) | 37 | t.Cleanup(func() { require.NoError(t, kernel.Close()) }) | ||
| 38 | 38 | ||||
| 39 | require.NotNil(t, kernel.Config()) | 39 | require.NotNil(t, kernel.Config()) | ||
| 40 | - | require.NotNil(t, kernel.Secrets()) | |||
| 41 | } | 40 | } | ||
| 42 | 41 | ||||
| 43 | // TestKernelClose_QuiescenceTimeoutSkipsCleanup proves the fail-closed kernel | 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 | acmelego "github.com/bnema/gordon/internal/adapters/out/acmelego" | 31 | acmelego "github.com/bnema/gordon/internal/adapters/out/acmelego" | ||
| 32 | acmestore "github.com/bnema/gordon/internal/adapters/out/acmestore" | 32 | acmestore "github.com/bnema/gordon/internal/adapters/out/acmestore" | ||
| 33 | "github.com/bnema/gordon/internal/adapters/out/docker" | 33 | "github.com/bnema/gordon/internal/adapters/out/docker" | ||
| 34 | - | "github.com/bnema/gordon/internal/adapters/out/domainsecrets" | |||
| 35 | - | "github.com/bnema/gordon/internal/adapters/out/envloader" | |||
| 36 | "github.com/bnema/gordon/internal/adapters/out/eventbus" | 34 | "github.com/bnema/gordon/internal/adapters/out/eventbus" | ||
| 37 | "github.com/bnema/gordon/internal/adapters/out/filesystem" | 35 | "github.com/bnema/gordon/internal/adapters/out/filesystem" | ||
| 38 | "github.com/bnema/gordon/internal/adapters/out/httpprober" | 36 | "github.com/bnema/gordon/internal/adapters/out/httpprober" | ||
| @@ -90,7 +88,6 @@ import ( | |||||
| 90 | "github.com/bnema/gordon/internal/usecase/publictls" | 88 | "github.com/bnema/gordon/internal/usecase/publictls" | ||
| 91 | registrySvc "github.com/bnema/gordon/internal/usecase/registry" | 89 | registrySvc "github.com/bnema/gordon/internal/usecase/registry" | ||
| 92 | "github.com/bnema/gordon/internal/usecase/registrystate" | 90 | "github.com/bnema/gordon/internal/usecase/registrystate" | ||
| 93 | - | secretsSvc "github.com/bnema/gordon/internal/usecase/secrets" | |||
| 94 | servicecfg "github.com/bnema/gordon/internal/usecase/services" | 91 | servicecfg "github.com/bnema/gordon/internal/usecase/services" | ||
| 95 | "github.com/bnema/gordon/internal/usecase/traffic" | 92 | "github.com/bnema/gordon/internal/usecase/traffic" | ||
| 96 | volumesSvc "github.com/bnema/gordon/internal/usecase/volumes" | 93 | volumesSvc "github.com/bnema/gordon/internal/usecase/volumes" | ||
| @@ -168,10 +165,6 @@ type Config struct { | |||||
| 168 | } `mapstructure:"access_log"` | 165 | } `mapstructure:"access_log"` | ||
| 169 | } `mapstructure:"logging"` | 166 | } `mapstructure:"logging"` | ||
| 170 | 167 | ||||
| 171 | - | Env struct { | |||
| 172 | - | Dir string `mapstructure:"dir"` | |||
| 173 | - | } `mapstructure:"env"` | |||
| 174 | - | ||||
| 175 | Volumes struct { | 168 | Volumes struct { | ||
| 176 | AutoCreate bool `mapstructure:"auto_create"` | 169 | AutoCreate bool `mapstructure:"auto_create"` | ||
| 177 | Prefix string `mapstructure:"prefix"` | 170 | Prefix string `mapstructure:"prefix"` | ||
| @@ -298,11 +291,9 @@ type services struct { | |||||
| 298 | backupStorage *filesystem.BackupStorage | 291 | backupStorage *filesystem.BackupStorage | ||
| 299 | volumeBackupStore out.VolumeBackupStorage | 292 | volumeBackupStore out.VolumeBackupStorage | ||
| 300 | volumeBackupCfg domain.VolumeBackupConfig | 293 | volumeBackupCfg domain.VolumeBackupConfig | ||
| 301 | - | envLoader out.EnvLoader | |||
| 302 | logWriter *logwriter.LogWriter | 294 | logWriter *logwriter.LogWriter | ||
| 303 | tokenStore out.TokenStore | 295 | tokenStore out.TokenStore | ||
| 304 | configSvc *config.Service | 296 | configSvc *config.Service | ||
| 305 | - | secretSvc *secretsSvc.Service | |||
| 306 | containerSvc *container.Service | 297 | containerSvc *container.Service | ||
| 307 | backupSvc *backup.Service | 298 | backupSvc *backup.Service | ||
| 308 | volumeBackupSvc *backup.VolumeService | 299 | volumeBackupSvc *backup.VolumeService | ||
| @@ -320,7 +311,6 @@ type services struct { | |||||
| 320 | httpsProxyHandler http.Handler | 311 | httpsProxyHandler http.Handler | ||
| 321 | internalRegUser string | 312 | internalRegUser string | ||
| 322 | internalRegPass string | 313 | internalRegPass string | ||
| 323 | - | envDir string | |||
| 324 | maxBlobChunkSize int64 | 314 | maxBlobChunkSize int64 | ||
| 325 | maxBlobSize int64 | 315 | maxBlobSize int64 | ||
| 326 | caAdapter *pkiadapter.CA | 316 | caAdapter *pkiadapter.CA | ||
| @@ -484,6 +474,7 @@ var retiredAppConfigKeys = []struct { | |||||
| 484 | {"auto_route", "feature removed; declare explicit interfaces"}, | 474 | {"auto_route", "feature removed; declare explicit interfaces"}, | ||
| 485 | {"auto_route_allowed_domains", "feature removed; declare explicit interfaces"}, | 475 | {"auto_route_allowed_domains", "feature removed; declare explicit interfaces"}, | ||
| 486 | {"previews", "staging is an ordinary app file"}, | 476 | {"previews", "staging is an ordinary app file"}, | ||
| 477 | + | {"env", "remove the [env] section; declare [env] and [services.<name>.secrets] in app files"}, | |||
| 487 | } | 478 | } | ||
| 488 | 479 | ||||
| 489 | // validateRetiredAppConfig rejects obsolete application configuration | 480 | // validateRetiredAppConfig rejects obsolete application configuration | ||
| @@ -814,20 +805,14 @@ func startPublicTLSRuntime(ctx context.Context, svc publicTLSRuntime, log zerowr | |||||
| 814 | return reconcileErr | 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 | func (si *serviceInit) initSecrets() error { | 810 | func (si *serviceInit) initSecrets() error { | ||
| 819 | - | envDir, backend, passStore, domainSecretStore, err := createDomainSecretStore(si.cfg, si.log) | 811 | + | backend, err := resolveSecretsBackend(si.cfg.Auth.SecretsBackend) |
| 820 | if err != nil { | 812 | if err != nil { | ||
| 821 | - | return err | 813 | + | return si.log.WrapErr(err, "failed to resolve secrets backend") |
| 822 | - | } | |||
| 823 | - | si.svc.envDir = envDir | |||
| 824 | - | ||||
| 825 | - | if si.svc.envLoader, err = createEnvLoader(backend, envDir, passStore, si.log); err != nil { | |||
| 826 | - | return err | |||
| 827 | } | 814 | } | ||
| 828 | - | ||||
| 829 | si.svc.serviceSecretProvider = createStandaloneServiceSecretProvider(backend, resolveDataDir(si.cfg.Server.DataDir), si.log) | 815 | si.svc.serviceSecretProvider = createStandaloneServiceSecretProvider(backend, resolveDataDir(si.cfg.Server.DataDir), si.log) | ||
| 830 | - | si.svc.secretSvc = secretsSvc.NewService(domainSecretStore, si.log, si.svc.eventBus) | |||
| 831 | return nil | 816 | return nil | ||
| 832 | } | 817 | } | ||
| 833 | 818 | ||||
| @@ -1180,7 +1165,6 @@ func (si *serviceInit) initHandlers() { | |||||
| 1180 | AuthSvc: si.svc.authSvc, | 1165 | AuthSvc: si.svc.authSvc, | ||
| 1181 | ContainerSvc: si.svc.containerSvc, | 1166 | ContainerSvc: si.svc.containerSvc, | ||
| 1182 | HealthSvc: si.svc.healthSvc, | 1167 | HealthSvc: si.svc.healthSvc, | ||
| 1183 | - | SecretSvc: si.svc.secretSvc, | |||
| 1184 | LogSvc: si.svc.logSvc, | 1168 | LogSvc: si.svc.logSvc, | ||
| 1185 | RegistrySvc: si.svc.registrySvc, | 1169 | RegistrySvc: si.svc.registrySvc, | ||
| 1186 | ReloadTrigger: si.svc.reloadCoordinator, | 1170 | ReloadTrigger: si.svc.reloadCoordinator, | ||
| @@ -1240,32 +1224,6 @@ func setupInternalRegistryAuth(svc *services, log zerowrap.Logger) error { | |||||
| 1240 | return nil | 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 | // resolveLogFilePath returns the configured log file path or a default. | 1227 | // resolveLogFilePath returns the configured log file path or a default. | ||
| 1270 | func resolveLogFilePath(cfg Config) string { | 1228 | func resolveLogFilePath(cfg Config) string { | ||
| 1271 | if cfg.Logging.File.Path != "" { | 1229 | if cfg.Logging.File.Path != "" { | ||
| @@ -1370,38 +1328,6 @@ func createStorage(cfg Config, log zerowrap.Logger) (*filesystem.BlobStorage, *f | |||||
| 1370 | return blobStorage, manifestStorage, nil | 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 | // createLogWriter creates the container log writer. | 1331 | // createLogWriter creates the container log writer. | ||
| 1406 | func createLogWriter(cfg Config, log zerowrap.Logger) (*logwriter.LogWriter, error) { | 1332 | func createLogWriter(cfg Config, log zerowrap.Logger) (*logwriter.LogWriter, error) { | ||
| 1407 | if !cfg.Logging.ContainerLogs.Enabled { | 1333 | if !cfg.Logging.ContainerLogs.Enabled { | ||
| @@ -1667,15 +1593,6 @@ func resolveDataDir(dataDir string) string { | |||||
| 1667 | return dataDir | 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 | func resolveRegistryDomains(cfg Config) (string, []string) { | 1596 | func resolveRegistryDomains(cfg Config) (string, []string) { | ||
| 1680 | registryDomain := cfg.Server.RegistryDomain | 1597 | registryDomain := cfg.Server.RegistryDomain | ||
| 1681 | if registryDomain == "" { | 1598 | if registryDomain == "" { | ||
| @@ -2265,7 +2182,7 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc | |||||
| 2265 | if err != nil { | 2182 | if err != nil { | ||
| 2266 | return nil, err | 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 | type databaseBackupSettingsConfig struct { | 2188 | type databaseBackupSettingsConfig struct { | ||
| @@ -4225,7 +4142,6 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||||
| 4225 | v.SetDefault("logging.access_log.max_age", 28) | 4142 | v.SetDefault("logging.access_log.max_age", 28) | ||
| 4226 | v.SetDefault("logging.access_log.exclude_health_checks", true) | 4143 | v.SetDefault("logging.access_log.exclude_health_checks", true) | ||
| 4227 | v.SetDefault("logging.access_log.syslog_identifier", "gordon-access") | 4144 | v.SetDefault("logging.access_log.syslog_identifier", "gordon-access") | ||
| 4228 | - | v.SetDefault("env.dir", "") // defaults to {data_dir}/env when empty | |||
| 4229 | v.SetDefault("auth.enabled", true) | 4145 | v.SetDefault("auth.enabled", true) | ||
| 4230 | // Note: auth.type defaults to "token" (the only supported mode) | 4146 | // Note: auth.type defaults to "token" (the only supported mode) | ||
| 4231 | v.SetDefault("auth.secrets_backend", "") | 4147 | v.SetDefault("auth.secrets_backend", "") | ||
M internal/app/run_app_lifecycle_test.go +2 -2
| @@ -141,7 +141,7 @@ func TestGracefulShutdown_FailsClosedWhenAppAdministrationDoesNotQuiesce(t *test | |||||
| 141 | 141 | ||||
| 142 | store := outmocks.NewMockAppState(t) | 142 | store := outmocks.NewMockAppState(t) | ||
| 143 | 143 | ||||
| 144 | - | containerSvc := container.NewService(nil, nil, nil, nil, container.Config{}) | 144 | + | containerSvc := container.NewService(nil, nil, nil, container.Config{}) |
| 145 | err := gracefulShutdown(nil, nil, nil, containerSvc, nil, nil, nil, nil, nil, admin, store, zerowrap.Default()) | 145 | err := gracefulShutdown(nil, nil, nil, containerSvc, nil, nil, nil, nil, nil, admin, store, zerowrap.Default()) | ||
| 146 | 146 | ||||
| 147 | require.Error(t, err) | 147 | require.Error(t, err) | ||
| @@ -164,7 +164,7 @@ func TestGracefulShutdown_QuiescesAppAdministrationBeforeClosingState(t *testing | |||||
| 164 | store := outmocks.NewMockAppState(t) | 164 | store := outmocks.NewMockAppState(t) | ||
| 165 | store.EXPECT().Close().Run(func() { order = append(order, "state-close") }).Return(nil) | 165 | store.EXPECT().Close().Run(func() { order = append(order, "state-close") }).Return(nil) | ||
| 166 | 166 | ||||
| 167 | - | containerSvc := container.NewService(nil, nil, nil, nil, container.Config{}) | 167 | + | containerSvc := container.NewService(nil, nil, nil, container.Config{}) |
| 168 | require.NoError(t, gracefulShutdown(nil, nil, nil, containerSvc, nil, nil, nil, nil, nil, admin, store, zerowrap.Default())) | 168 | require.NoError(t, gracefulShutdown(nil, nil, nil, containerSvc, nil, nil, nil, nil, nil, admin, store, zerowrap.Default())) | ||
| 169 | 169 | ||||
| 170 | require.Equal(t, []string{"app-shutdown", "state-close"}, order) | 170 | require.Equal(t, []string{"app-shutdown", "state-close"}, order) | ||
M internal/app/run_entrypoint_migration_test.go +10 -0
| @@ -36,3 +36,13 @@ func TestInitConfigRejectsRetiredAppKeys(t *testing.T) { | |||||
| 36 | require.ErrorContains(t, err, "config-retired") | 36 | require.ErrorContains(t, err, "config-retired") | ||
| 37 | require.ErrorContains(t, err, `"routes"`) | 37 | require.ErrorContains(t, err, `"routes"`) | ||
| 38 | } | 38 | } | ||
| 39 | + | ||||
| 40 | + | func TestInitConfigRejectsRetiredEnvSection(t *testing.T) { | |||
| 41 | + | configPath := filepath.Join(t.TempDir(), "gordon.toml") | |||
| 42 | + | require.NoError(t, os.WriteFile(configPath, []byte("[env]\ndir = \"/tmp/env\"\n"), 0o600)) | |||
| 43 | + | ||||
| 44 | + | _, _, err := initConfig(configPath) | |||
| 45 | + | ||||
| 46 | + | require.ErrorContains(t, err, "config-retired") | |||
| 47 | + | require.ErrorContains(t, err, `"env"`) | |||
| 48 | + | } | |||
M internal/app/run_reload_test.go +4 -4
| @@ -311,7 +311,7 @@ func TestServiceInit_ReloadRegistersCustomTLSMuxHTTPSFallback(t *testing.T) { | |||||
| 311 | log: zerowrap.Default(), | 311 | log: zerowrap.Default(), | ||
| 312 | svc: newTestServices(&services{ | 312 | svc: newTestServices(&services{ | ||
| 313 | configSvc: configSvc, | 313 | configSvc: configSvc, | ||
| 314 | - | containerSvc: container.NewService(nil, nil, nil, nil, container.Config{}), | 314 | + | containerSvc: container.NewService(nil, nil, nil, container.Config{}), |
| 315 | internalRegUser: "gordon", | 315 | internalRegUser: "gordon", | ||
| 316 | internalRegPass: "secret", | 316 | internalRegPass: "secret", | ||
| 317 | reloadCoordinator: newReloadCoordinator(v, &reloadRecorder{}, &proxyRecorder{}, nil, nil, nil, zerowrap.Default()), | 317 | reloadCoordinator: newReloadCoordinator(v, &reloadRecorder{}, &proxyRecorder{}, nil, nil, nil, zerowrap.Default()), | ||
| @@ -361,7 +361,7 @@ func TestServiceInit_ReloadUpdatesManagementHosts(t *testing.T) { | |||||
| 361 | log: zerowrap.Default(), | 361 | log: zerowrap.Default(), | ||
| 362 | svc: newTestServices(&services{ | 362 | svc: newTestServices(&services{ | ||
| 363 | configSvc: configSvc, | 363 | configSvc: configSvc, | ||
| 364 | - | containerSvc: container.NewService(nil, nil, nil, nil, container.Config{}), | 364 | + | containerSvc: container.NewService(nil, nil, nil, container.Config{}), |
| 365 | internalRegUser: "gordon", | 365 | internalRegUser: "gordon", | ||
| 366 | internalRegPass: "secret", | 366 | internalRegPass: "secret", | ||
| 367 | reloadCoordinator: newReloadCoordinator(v, &reloadRecorder{}, &proxyRecorder{}, nil, nil, publicTLS, zerowrap.Default()), | 367 | reloadCoordinator: newReloadCoordinator(v, &reloadRecorder{}, &proxyRecorder{}, nil, nil, publicTLS, zerowrap.Default()), | ||
| @@ -393,7 +393,7 @@ func TestServiceInit_RegisterReloadCoordinatorHooks_WiresContainerConfigApplier( | |||||
| 393 | log: zerowrap.Default(), | 393 | log: zerowrap.Default(), | ||
| 394 | svc: newTestServices(&services{ | 394 | svc: newTestServices(&services{ | ||
| 395 | configSvc: configSvc, | 395 | configSvc: configSvc, | ||
| 396 | - | containerSvc: container.NewService(nil, nil, nil, nil, container.Config{}), | 396 | + | containerSvc: container.NewService(nil, nil, nil, container.Config{}), |
| 397 | internalRegUser: "gordon", | 397 | internalRegUser: "gordon", | ||
| 398 | internalRegPass: "secret", | 398 | internalRegPass: "secret", | ||
| 399 | reloadCoordinator: newReloadCoordinator(v, &reloadRecorder{}, &proxyRecorder{}, nil, nil, nil, zerowrap.Default()), | 399 | reloadCoordinator: newReloadCoordinator(v, &reloadRecorder{}, &proxyRecorder{}, nil, nil, nil, zerowrap.Default()), | ||
| @@ -429,7 +429,7 @@ func TestServiceInit_RegisterReloadCoordinatorHooks_AppliesTraffic(t *testing.T) | |||||
| 429 | log: zerowrap.Default(), | 429 | log: zerowrap.Default(), | ||
| 430 | svc: newTestServices(&services{ | 430 | svc: newTestServices(&services{ | ||
| 431 | configSvc: configSvc, | 431 | configSvc: configSvc, | ||
| 432 | - | containerSvc: container.NewService(nil, nil, nil, nil, container.Config{}), | 432 | + | containerSvc: container.NewService(nil, nil, nil, container.Config{}), |
| 433 | internalRegUser: "gordon", | 433 | internalRegUser: "gordon", | ||
| 434 | internalRegPass: "secret", | 434 | internalRegPass: "secret", | ||
| 435 | reloadCoordinator: newReloadCoordinator(v, &reloadRecorder{}, &proxyRecorder{}, nil, nil, nil, zerowrap.Default()), | 435 | reloadCoordinator: newReloadCoordinator(v, &reloadRecorder{}, &proxyRecorder{}, nil, nil, nil, zerowrap.Default()), | ||
D internal/boundaries/in/mocks/mock_secret_service.go +0 -309
| @@ -1,309 +0,0 @@ | |||||
| 1 | - | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | - | // github.com/vektra/mockery | |||
| 3 | - | // template: testify | |||
| 4 | - | ||||
| 5 | - | package mocks | |||
| 6 | - | ||||
| 7 | - | import ( | |||
| 8 | - | "context" | |||
| 9 | - | ||||
| 10 | - | mock "github.com/stretchr/testify/mock" | |||
| 11 | - | ) | |||
| 12 | - | ||||
| 13 | - | // NewMockSecretService creates a new instance of MockSecretService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 14 | - | // The first argument is typically a *testing.T value. | |||
| 15 | - | func NewMockSecretService(t interface { | |||
| 16 | - | mock.TestingT | |||
| 17 | - | Cleanup(func()) | |||
| 18 | - | }) *MockSecretService { | |||
| 19 | - | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 20 | - | helper.Helper() | |||
| 21 | - | } | |||
| 22 | - | ||||
| 23 | - | mock := &MockSecretService{} | |||
| 24 | - | mock.Mock.Test(t) | |||
| 25 | - | ||||
| 26 | - | t.Cleanup(func() { | |||
| 27 | - | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 28 | - | helper.Helper() | |||
| 29 | - | } | |||
| 30 | - | mock.AssertExpectations(t) | |||
| 31 | - | }) | |||
| 32 | - | ||||
| 33 | - | return mock | |||
| 34 | - | } | |||
| 35 | - | ||||
| 36 | - | // MockSecretService is an autogenerated mock type for the SecretService type | |||
| 37 | - | type MockSecretService struct { | |||
| 38 | - | mock.Mock | |||
| 39 | - | } | |||
| 40 | - | ||||
| 41 | - | type MockSecretService_Expecter struct { | |||
| 42 | - | mock *mock.Mock | |||
| 43 | - | } | |||
| 44 | - | ||||
| 45 | - | func (_m *MockSecretService) EXPECT() *MockSecretService_Expecter { | |||
| 46 | - | return &MockSecretService_Expecter{mock: &_m.Mock} | |||
| 47 | - | } | |||
| 48 | - | ||||
| 49 | - | // Delete provides a mock function for the type MockSecretService | |||
| 50 | - | func (_mock *MockSecretService) Delete(ctx context.Context, domain string, key string) error { | |||
| 51 | - | ret := _mock.Called(ctx, domain, key) | |||
| 52 | - | ||||
| 53 | - | if len(ret) == 0 { | |||
| 54 | - | panic("no return value specified for Delete") | |||
| 55 | - | } | |||
| 56 | - | ||||
| 57 | - | var r0 error | |||
| 58 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok { | |||
| 59 | - | r0 = returnFunc(ctx, domain, key) | |||
| 60 | - | } else { | |||
| 61 | - | r0 = ret.Error(0) | |||
| 62 | - | } | |||
| 63 | - | return r0 | |||
| 64 | - | } | |||
| 65 | - | ||||
| 66 | - | // MockSecretService_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete' | |||
| 67 | - | type MockSecretService_Delete_Call struct { | |||
| 68 | - | *mock.Call | |||
| 69 | - | } | |||
| 70 | - | ||||
| 71 | - | // Delete is a helper method to define mock.On call | |||
| 72 | - | // - ctx context.Context | |||
| 73 | - | // - domain string | |||
| 74 | - | // - key string | |||
| 75 | - | func (_e *MockSecretService_Expecter) Delete(ctx any, domain any, key any) *MockSecretService_Delete_Call { | |||
| 76 | - | return &MockSecretService_Delete_Call{Call: _e.mock.On("Delete", ctx, domain, key)} | |||
| 77 | - | } | |||
| 78 | - | ||||
| 79 | - | func (_c *MockSecretService_Delete_Call) Run(run func(ctx context.Context, domain string, key string)) *MockSecretService_Delete_Call { | |||
| 80 | - | _c.Call.Run(func(args mock.Arguments) { | |||
| 81 | - | var arg0 context.Context | |||
| 82 | - | if args[0] != nil { | |||
| 83 | - | arg0 = args[0].(context.Context) | |||
| 84 | - | } | |||
| 85 | - | var arg1 string | |||
| 86 | - | if args[1] != nil { | |||
| 87 | - | arg1 = args[1].(string) | |||
| 88 | - | } | |||
| 89 | - | var arg2 string | |||
| 90 | - | if args[2] != nil { | |||
| 91 | - | arg2 = args[2].(string) | |||
| 92 | - | } | |||
| 93 | - | run( | |||
| 94 | - | arg0, | |||
| 95 | - | arg1, | |||
| 96 | - | arg2, | |||
| 97 | - | ) | |||
| 98 | - | }) | |||
| 99 | - | return _c | |||
| 100 | - | } | |||
| 101 | - | ||||
| 102 | - | func (_c *MockSecretService_Delete_Call) Return(err error) *MockSecretService_Delete_Call { | |||
| 103 | - | _c.Call.Return(err) | |||
| 104 | - | return _c | |||
| 105 | - | } | |||
| 106 | - | ||||
| 107 | - | func (_c *MockSecretService_Delete_Call) RunAndReturn(run func(ctx context.Context, domain string, key string) error) *MockSecretService_Delete_Call { | |||
| 108 | - | _c.Call.Return(run) | |||
| 109 | - | return _c | |||
| 110 | - | } | |||
| 111 | - | ||||
| 112 | - | // GetAll provides a mock function for the type MockSecretService | |||
| 113 | - | func (_mock *MockSecretService) GetAll(ctx context.Context, domain string) (map[string]string, error) { | |||
| 114 | - | ret := _mock.Called(ctx, domain) | |||
| 115 | - | ||||
| 116 | - | if len(ret) == 0 { | |||
| 117 | - | panic("no return value specified for GetAll") | |||
| 118 | - | } | |||
| 119 | - | ||||
| 120 | - | var r0 map[string]string | |||
| 121 | - | var r1 error | |||
| 122 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (map[string]string, error)); ok { | |||
| 123 | - | return returnFunc(ctx, domain) | |||
| 124 | - | } | |||
| 125 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string) map[string]string); ok { | |||
| 126 | - | r0 = returnFunc(ctx, domain) | |||
| 127 | - | } else { | |||
| 128 | - | if ret.Get(0) != nil { | |||
| 129 | - | r0 = ret.Get(0).(map[string]string) | |||
| 130 | - | } | |||
| 131 | - | } | |||
| 132 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | |||
| 133 | - | r1 = returnFunc(ctx, domain) | |||
| 134 | - | } else { | |||
| 135 | - | r1 = ret.Error(1) | |||
| 136 | - | } | |||
| 137 | - | return r0, r1 | |||
| 138 | - | } | |||
| 139 | - | ||||
| 140 | - | // MockSecretService_GetAll_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAll' | |||
| 141 | - | type MockSecretService_GetAll_Call struct { | |||
| 142 | - | *mock.Call | |||
| 143 | - | } | |||
| 144 | - | ||||
| 145 | - | // GetAll is a helper method to define mock.On call | |||
| 146 | - | // - ctx context.Context | |||
| 147 | - | // - domain string | |||
| 148 | - | func (_e *MockSecretService_Expecter) GetAll(ctx any, domain any) *MockSecretService_GetAll_Call { | |||
| 149 | - | return &MockSecretService_GetAll_Call{Call: _e.mock.On("GetAll", ctx, domain)} | |||
| 150 | - | } | |||
| 151 | - | ||||
| 152 | - | func (_c *MockSecretService_GetAll_Call) Run(run func(ctx context.Context, domain string)) *MockSecretService_GetAll_Call { | |||
| 153 | - | _c.Call.Run(func(args mock.Arguments) { | |||
| 154 | - | var arg0 context.Context | |||
| 155 | - | if args[0] != nil { | |||
| 156 | - | arg0 = args[0].(context.Context) | |||
| 157 | - | } | |||
| 158 | - | var arg1 string | |||
| 159 | - | if args[1] != nil { | |||
| 160 | - | arg1 = args[1].(string) | |||
| 161 | - | } | |||
| 162 | - | run( | |||
| 163 | - | arg0, | |||
| 164 | - | arg1, | |||
| 165 | - | ) | |||
| 166 | - | }) | |||
| 167 | - | return _c | |||
| 168 | - | } | |||
| 169 | - | ||||
| 170 | - | func (_c *MockSecretService_GetAll_Call) Return(stringToString map[string]string, err error) *MockSecretService_GetAll_Call { | |||
| 171 | - | _c.Call.Return(stringToString, err) | |||
| 172 | - | return _c | |||
| 173 | - | } | |||
| 174 | - | ||||
| 175 | - | func (_c *MockSecretService_GetAll_Call) RunAndReturn(run func(ctx context.Context, domain string) (map[string]string, error)) *MockSecretService_GetAll_Call { | |||
| 176 | - | _c.Call.Return(run) | |||
| 177 | - | return _c | |||
| 178 | - | } | |||
| 179 | - | ||||
| 180 | - | // ListKeys provides a mock function for the type MockSecretService | |||
| 181 | - | func (_mock *MockSecretService) ListKeys(ctx context.Context, domain string) ([]string, error) { | |||
| 182 | - | ret := _mock.Called(ctx, domain) | |||
| 183 | - | ||||
| 184 | - | if len(ret) == 0 { | |||
| 185 | - | panic("no return value specified for ListKeys") | |||
| 186 | - | } | |||
| 187 | - | ||||
| 188 | - | var r0 []string | |||
| 189 | - | var r1 error | |||
| 190 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]string, error)); ok { | |||
| 191 | - | return returnFunc(ctx, domain) | |||
| 192 | - | } | |||
| 193 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string) []string); ok { | |||
| 194 | - | r0 = returnFunc(ctx, domain) | |||
| 195 | - | } else { | |||
| 196 | - | if ret.Get(0) != nil { | |||
| 197 | - | r0 = ret.Get(0).([]string) | |||
| 198 | - | } | |||
| 199 | - | } | |||
| 200 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | |||
| 201 | - | r1 = returnFunc(ctx, domain) | |||
| 202 | - | } else { | |||
| 203 | - | r1 = ret.Error(1) | |||
| 204 | - | } | |||
| 205 | - | return r0, r1 | |||
| 206 | - | } | |||
| 207 | - | ||||
| 208 | - | // MockSecretService_ListKeys_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListKeys' | |||
| 209 | - | type MockSecretService_ListKeys_Call struct { | |||
| 210 | - | *mock.Call | |||
| 211 | - | } | |||
| 212 | - | ||||
| 213 | - | // ListKeys is a helper method to define mock.On call | |||
| 214 | - | // - ctx context.Context | |||
| 215 | - | // - domain string | |||
| 216 | - | func (_e *MockSecretService_Expecter) ListKeys(ctx any, domain any) *MockSecretService_ListKeys_Call { | |||
| 217 | - | return &MockSecretService_ListKeys_Call{Call: _e.mock.On("ListKeys", ctx, domain)} | |||
| 218 | - | } | |||
| 219 | - | ||||
| 220 | - | func (_c *MockSecretService_ListKeys_Call) Run(run func(ctx context.Context, domain string)) *MockSecretService_ListKeys_Call { | |||
| 221 | - | _c.Call.Run(func(args mock.Arguments) { | |||
| 222 | - | var arg0 context.Context | |||
| 223 | - | if args[0] != nil { | |||
| 224 | - | arg0 = args[0].(context.Context) | |||
| 225 | - | } | |||
| 226 | - | var arg1 string | |||
| 227 | - | if args[1] != nil { | |||
| 228 | - | arg1 = args[1].(string) | |||
| 229 | - | } | |||
| 230 | - | run( | |||
| 231 | - | arg0, | |||
| 232 | - | arg1, | |||
| 233 | - | ) | |||
| 234 | - | }) | |||
| 235 | - | return _c | |||
| 236 | - | } | |||
| 237 | - | ||||
| 238 | - | func (_c *MockSecretService_ListKeys_Call) Return(strings []string, err error) *MockSecretService_ListKeys_Call { | |||
| 239 | - | _c.Call.Return(strings, err) | |||
| 240 | - | return _c | |||
| 241 | - | } | |||
| 242 | - | ||||
| 243 | - | func (_c *MockSecretService_ListKeys_Call) RunAndReturn(run func(ctx context.Context, domain string) ([]string, error)) *MockSecretService_ListKeys_Call { | |||
| 244 | - | _c.Call.Return(run) | |||
| 245 | - | return _c | |||
| 246 | - | } | |||
| 247 | - | ||||
| 248 | - | // Set provides a mock function for the type MockSecretService | |||
| 249 | - | func (_mock *MockSecretService) Set(ctx context.Context, domain string, secrets map[string]string) error { | |||
| 250 | - | ret := _mock.Called(ctx, domain, secrets) | |||
| 251 | - | ||||
| 252 | - | if len(ret) == 0 { | |||
| 253 | - | panic("no return value specified for Set") | |||
| 254 | - | } | |||
| 255 | - | ||||
| 256 | - | var r0 error | |||
| 257 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, map[string]string) error); ok { | |||
| 258 | - | r0 = returnFunc(ctx, domain, secrets) | |||
| 259 | - | } else { | |||
| 260 | - | r0 = ret.Error(0) | |||
| 261 | - | } | |||
| 262 | - | return r0 | |||
| 263 | - | } | |||
| 264 | - | ||||
| 265 | - | // MockSecretService_Set_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Set' | |||
| 266 | - | type MockSecretService_Set_Call struct { | |||
| 267 | - | *mock.Call | |||
| 268 | - | } | |||
| 269 | - | ||||
| 270 | - | // Set is a helper method to define mock.On call | |||
| 271 | - | // - ctx context.Context | |||
| 272 | - | // - domain string | |||
| 273 | - | // - secrets map[string]string | |||
| 274 | - | func (_e *MockSecretService_Expecter) Set(ctx any, domain any, secrets any) *MockSecretService_Set_Call { | |||
| 275 | - | return &MockSecretService_Set_Call{Call: _e.mock.On("Set", ctx, domain, secrets)} | |||
| 276 | - | } | |||
| 277 | - | ||||
| 278 | - | func (_c *MockSecretService_Set_Call) Run(run func(ctx context.Context, domain string, secrets map[string]string)) *MockSecretService_Set_Call { | |||
| 279 | - | _c.Call.Run(func(args mock.Arguments) { | |||
| 280 | - | var arg0 context.Context | |||
| 281 | - | if args[0] != nil { | |||
| 282 | - | arg0 = args[0].(context.Context) | |||
| 283 | - | } | |||
| 284 | - | var arg1 string | |||
| 285 | - | if args[1] != nil { | |||
| 286 | - | arg1 = args[1].(string) | |||
| 287 | - | } | |||
| 288 | - | var arg2 map[string]string | |||
| 289 | - | if args[2] != nil { | |||
| 290 | - | arg2 = args[2].(map[string]string) | |||
| 291 | - | } | |||
| 292 | - | run( | |||
| 293 | - | arg0, | |||
| 294 | - | arg1, | |||
| 295 | - | arg2, | |||
| 296 | - | ) | |||
| 297 | - | }) | |||
| 298 | - | return _c | |||
| 299 | - | } | |||
| 300 | - | ||||
| 301 | - | func (_c *MockSecretService_Set_Call) Return(err error) *MockSecretService_Set_Call { | |||
| 302 | - | _c.Call.Return(err) | |||
| 303 | - | return _c | |||
| 304 | - | } | |||
| 305 | - | ||||
| 306 | - | func (_c *MockSecretService_Set_Call) RunAndReturn(run func(ctx context.Context, domain string, secrets map[string]string) error) *MockSecretService_Set_Call { | |||
| 307 | - | _c.Call.Return(run) | |||
| 308 | - | return _c | |||
| 309 | - | } | |||
D internal/boundaries/in/secrets.go +0 -25
| @@ -1,25 +0,0 @@ | |||||
| 1 | - | package in | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "context" | |||
| 5 | - | ) | |||
| 6 | - | ||||
| 7 | - | // SecretService defines the contract for managing domain-scoped secrets. | |||
| 8 | - | // These are environment variables stored per-domain for container injection. | |||
| 9 | - | type SecretService interface { | |||
| 10 | - | // ListKeys returns the list of secret keys for a domain (not values). | |||
| 11 | - | // Returns an error if the domain is invalid. | |||
| 12 | - | ListKeys(ctx context.Context, domain string) ([]string, error) | |||
| 13 | - | ||||
| 14 | - | // GetAll returns all secrets for a domain as a key-value map. | |||
| 15 | - | // Returns an error if the domain is invalid. | |||
| 16 | - | GetAll(ctx context.Context, domain string) (map[string]string, error) | |||
| 17 | - | ||||
| 18 | - | // Set sets or updates multiple secrets for a domain, merging with existing. | |||
| 19 | - | // Returns an error if the domain is invalid. | |||
| 20 | - | Set(ctx context.Context, domain string, secrets map[string]string) error | |||
| 21 | - | ||||
| 22 | - | // Delete removes a specific secret key from a domain. | |||
| 23 | - | // Returns an error if the domain is invalid. | |||
| 24 | - | Delete(ctx context.Context, domain, key string) error | |||
| 25 | - | } | |||
D internal/boundaries/out/domain_secrets.go +0 -39
| @@ -1,39 +0,0 @@ | |||||
| 1 | - | package out | |||
| 2 | - | ||||
| 3 | - | // AttachmentSecrets represents secrets for an attachment container. | |||
| 4 | - | type AttachmentSecrets struct { | |||
| 5 | - | // Service is the attachment service name (e.g., "gitea-postgres") | |||
| 6 | - | Service string | |||
| 7 | - | // Keys is the list of secret keys for this attachment | |||
| 8 | - | Keys []string | |||
| 9 | - | } | |||
| 10 | - | ||||
| 11 | - | // DomainSecretStore defines the contract for managing domain-scoped secrets. | |||
| 12 | - | // These are environment variables stored per-domain for container injection. | |||
| 13 | - | type DomainSecretStore interface { | |||
| 14 | - | // ListKeys returns the list of secret keys for a domain (not values). | |||
| 15 | - | ListKeys(domain string) ([]string, error) | |||
| 16 | - | ||||
| 17 | - | // GetAll returns all secrets for a domain as a key-value map. | |||
| 18 | - | GetAll(domain string) (map[string]string, error) | |||
| 19 | - | ||||
| 20 | - | // Set sets or updates multiple secrets for a domain, merging with existing. | |||
| 21 | - | Set(domain string, secrets map[string]string) error | |||
| 22 | - | ||||
| 23 | - | // Delete removes a specific secret key from a domain. | |||
| 24 | - | Delete(domain, key string) error | |||
| 25 | - | ||||
| 26 | - | // SetAttachment sets or updates multiple secrets for an attachment container. | |||
| 27 | - | SetAttachment(containerName string, secrets map[string]string) error | |||
| 28 | - | ||||
| 29 | - | // GetAllAttachment returns all secrets for an attachment container as a key-value map. | |||
| 30 | - | GetAllAttachment(containerName string) (map[string]string, error) | |||
| 31 | - | ||||
| 32 | - | // DeleteAttachment removes a specific secret key from an attachment container. | |||
| 33 | - | DeleteAttachment(containerName, key string) error | |||
| 34 | - | ||||
| 35 | - | // ListAttachmentKeys finds and returns secret keys for attachment containers | |||
| 36 | - | // associated with the given domain. Returns a list of AttachmentSecrets, one | |||
| 37 | - | // for each attachment that has secrets configured. | |||
| 38 | - | ListAttachmentKeys(domain string) ([]AttachmentSecrets, error) | |||
| 39 | - | } | |||
D internal/boundaries/out/envloader.go +0 -16
| @@ -1,16 +0,0 @@ | |||||
| 1 | - | package out | |||
| 2 | - | ||||
| 3 | - | import "context" | |||
| 4 | - | ||||
| 5 | - | // EnvLoader defines the contract for loading environment variables for containers. | |||
| 6 | - | type EnvLoader interface { | |||
| 7 | - | // LoadEnv loads environment variables for a given domain. | |||
| 8 | - | // Returns a slice of "KEY=VALUE" strings. | |||
| 9 | - | LoadEnv(ctx context.Context, domain string) ([]string, error) | |||
| 10 | - | ||||
| 11 | - | // CreateEnvFile creates an empty environment file for a new domain. | |||
| 12 | - | CreateEnvFile(ctx context.Context, domain string) error | |||
| 13 | - | ||||
| 14 | - | // EnvFileExists checks if an environment file exists for a domain. | |||
| 15 | - | EnvFileExists(domain string) (bool, error) | |||
| 16 | - | } | |||
D internal/boundaries/out/mocks/mock_domain_secret_store.go +0 -522
| @@ -1,522 +0,0 @@ | |||||
| 1 | - | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | - | // github.com/vektra/mockery | |||
| 3 | - | // template: testify | |||
| 4 | - | ||||
| 5 | - | package mocks | |||
| 6 | - | ||||
| 7 | - | import ( | |||
| 8 | - | "github.com/bnema/gordon/internal/boundaries/out" | |||
| 9 | - | mock "github.com/stretchr/testify/mock" | |||
| 10 | - | ) | |||
| 11 | - | ||||
| 12 | - | // NewMockDomainSecretStore creates a new instance of MockDomainSecretStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 13 | - | // The first argument is typically a *testing.T value. | |||
| 14 | - | func NewMockDomainSecretStore(t interface { | |||
| 15 | - | mock.TestingT | |||
| 16 | - | Cleanup(func()) | |||
| 17 | - | }) *MockDomainSecretStore { | |||
| 18 | - | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 19 | - | helper.Helper() | |||
| 20 | - | } | |||
| 21 | - | ||||
| 22 | - | mock := &MockDomainSecretStore{} | |||
| 23 | - | mock.Mock.Test(t) | |||
| 24 | - | ||||
| 25 | - | t.Cleanup(func() { | |||
| 26 | - | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 27 | - | helper.Helper() | |||
| 28 | - | } | |||
| 29 | - | mock.AssertExpectations(t) | |||
| 30 | - | }) | |||
| 31 | - | ||||
| 32 | - | return mock | |||
| 33 | - | } | |||
| 34 | - | ||||
| 35 | - | // MockDomainSecretStore is an autogenerated mock type for the DomainSecretStore type | |||
| 36 | - | type MockDomainSecretStore struct { | |||
| 37 | - | mock.Mock | |||
| 38 | - | } | |||
| 39 | - | ||||
| 40 | - | type MockDomainSecretStore_Expecter struct { | |||
| 41 | - | mock *mock.Mock | |||
| 42 | - | } | |||
| 43 | - | ||||
| 44 | - | func (_m *MockDomainSecretStore) EXPECT() *MockDomainSecretStore_Expecter { | |||
| 45 | - | return &MockDomainSecretStore_Expecter{mock: &_m.Mock} | |||
| 46 | - | } | |||
| 47 | - | ||||
| 48 | - | // Delete provides a mock function for the type MockDomainSecretStore | |||
| 49 | - | func (_mock *MockDomainSecretStore) Delete(domain string, key string) error { | |||
| 50 | - | ret := _mock.Called(domain, key) | |||
| 51 | - | ||||
| 52 | - | if len(ret) == 0 { | |||
| 53 | - | panic("no return value specified for Delete") | |||
| 54 | - | } | |||
| 55 | - | ||||
| 56 | - | var r0 error | |||
| 57 | - | if returnFunc, ok := ret.Get(0).(func(string, string) error); ok { | |||
| 58 | - | r0 = returnFunc(domain, key) | |||
| 59 | - | } else { | |||
| 60 | - | r0 = ret.Error(0) | |||
| 61 | - | } | |||
| 62 | - | return r0 | |||
| 63 | - | } | |||
| 64 | - | ||||
| 65 | - | // MockDomainSecretStore_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete' | |||
| 66 | - | type MockDomainSecretStore_Delete_Call struct { | |||
| 67 | - | *mock.Call | |||
| 68 | - | } | |||
| 69 | - | ||||
| 70 | - | // Delete is a helper method to define mock.On call | |||
| 71 | - | // - domain string | |||
| 72 | - | // - key string | |||
| 73 | - | func (_e *MockDomainSecretStore_Expecter) Delete(domain any, key any) *MockDomainSecretStore_Delete_Call { | |||
| 74 | - | return &MockDomainSecretStore_Delete_Call{Call: _e.mock.On("Delete", domain, key)} | |||
| 75 | - | } | |||
| 76 | - | ||||
| 77 | - | func (_c *MockDomainSecretStore_Delete_Call) Run(run func(domain string, key string)) *MockDomainSecretStore_Delete_Call { | |||
| 78 | - | _c.Call.Run(func(args mock.Arguments) { | |||
| 79 | - | var arg0 string | |||
| 80 | - | if args[0] != nil { | |||
| 81 | - | arg0 = args[0].(string) | |||
| 82 | - | } | |||
| 83 | - | var arg1 string | |||
| 84 | - | if args[1] != nil { | |||
| 85 | - | arg1 = args[1].(string) | |||
| 86 | - | } | |||
| 87 | - | run( | |||
| 88 | - | arg0, | |||
| 89 | - | arg1, | |||
| 90 | - | ) | |||
| 91 | - | }) | |||
| 92 | - | return _c | |||
| 93 | - | } | |||
| 94 | - | ||||
| 95 | - | func (_c *MockDomainSecretStore_Delete_Call) Return(err error) *MockDomainSecretStore_Delete_Call { | |||
| 96 | - | _c.Call.Return(err) | |||
| 97 | - | return _c | |||
| 98 | - | } | |||
| 99 | - | ||||
| 100 | - | func (_c *MockDomainSecretStore_Delete_Call) RunAndReturn(run func(domain string, key string) error) *MockDomainSecretStore_Delete_Call { | |||
| 101 | - | _c.Call.Return(run) | |||
| 102 | - | return _c | |||
| 103 | - | } | |||
| 104 | - | ||||
| 105 | - | // DeleteAttachment provides a mock function for the type MockDomainSecretStore | |||
| 106 | - | func (_mock *MockDomainSecretStore) DeleteAttachment(containerName string, key string) error { | |||
| 107 | - | ret := _mock.Called(containerName, key) | |||
| 108 | - | ||||
| 109 | - | if len(ret) == 0 { | |||
| 110 | - | panic("no return value specified for DeleteAttachment") | |||
| 111 | - | } | |||
| 112 | - | ||||
| 113 | - | var r0 error | |||
| 114 | - | if returnFunc, ok := ret.Get(0).(func(string, string) error); ok { | |||
| 115 | - | r0 = returnFunc(containerName, key) | |||
| 116 | - | } else { | |||
| 117 | - | r0 = ret.Error(0) | |||
| 118 | - | } | |||
| 119 | - | return r0 | |||
| 120 | - | } | |||
| 121 | - | ||||
| 122 | - | // MockDomainSecretStore_DeleteAttachment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteAttachment' | |||
| 123 | - | type MockDomainSecretStore_DeleteAttachment_Call struct { | |||
| 124 | - | *mock.Call | |||
| 125 | - | } | |||
| 126 | - | ||||
| 127 | - | // DeleteAttachment is a helper method to define mock.On call | |||
| 128 | - | // - containerName string | |||
| 129 | - | // - key string | |||
| 130 | - | func (_e *MockDomainSecretStore_Expecter) DeleteAttachment(containerName any, key any) *MockDomainSecretStore_DeleteAttachment_Call { | |||
| 131 | - | return &MockDomainSecretStore_DeleteAttachment_Call{Call: _e.mock.On("DeleteAttachment", containerName, key)} | |||
| 132 | - | } | |||
| 133 | - | ||||
| 134 | - | func (_c *MockDomainSecretStore_DeleteAttachment_Call) Run(run func(containerName string, key string)) *MockDomainSecretStore_DeleteAttachment_Call { | |||
| 135 | - | _c.Call.Run(func(args mock.Arguments) { | |||
| 136 | - | var arg0 string | |||
| 137 | - | if args[0] != nil { | |||
| 138 | - | arg0 = args[0].(string) | |||
| 139 | - | } | |||
| 140 | - | var arg1 string | |||
| 141 | - | if args[1] != nil { | |||
| 142 | - | arg1 = args[1].(string) | |||
| 143 | - | } | |||
| 144 | - | run( | |||
| 145 | - | arg0, | |||
| 146 | - | arg1, | |||
| 147 | - | ) | |||
| 148 | - | }) | |||
| 149 | - | return _c | |||
| 150 | - | } | |||
| 151 | - | ||||
| 152 | - | func (_c *MockDomainSecretStore_DeleteAttachment_Call) Return(err error) *MockDomainSecretStore_DeleteAttachment_Call { | |||
| 153 | - | _c.Call.Return(err) | |||
| 154 | - | return _c | |||
| 155 | - | } | |||
| 156 | - | ||||
| 157 | - | func (_c *MockDomainSecretStore_DeleteAttachment_Call) RunAndReturn(run func(containerName string, key string) error) *MockDomainSecretStore_DeleteAttachment_Call { | |||
| 158 | - | _c.Call.Return(run) | |||
| 159 | - | return _c | |||
| 160 | - | } | |||
| 161 | - | ||||
| 162 | - | // GetAll provides a mock function for the type MockDomainSecretStore | |||
| 163 | - | func (_mock *MockDomainSecretStore) GetAll(domain string) (map[string]string, error) { | |||
| 164 | - | ret := _mock.Called(domain) | |||
| 165 | - | ||||
| 166 | - | if len(ret) == 0 { | |||
| 167 | - | panic("no return value specified for GetAll") | |||
| 168 | - | } | |||
| 169 | - | ||||
| 170 | - | var r0 map[string]string | |||
| 171 | - | var r1 error | |||
| 172 | - | if returnFunc, ok := ret.Get(0).(func(string) (map[string]string, error)); ok { | |||
| 173 | - | return returnFunc(domain) | |||
| 174 | - | } | |||
| 175 | - | if returnFunc, ok := ret.Get(0).(func(string) map[string]string); ok { | |||
| 176 | - | r0 = returnFunc(domain) | |||
| 177 | - | } else { | |||
| 178 | - | if ret.Get(0) != nil { | |||
| 179 | - | r0 = ret.Get(0).(map[string]string) | |||
| 180 | - | } | |||
| 181 | - | } | |||
| 182 | - | if returnFunc, ok := ret.Get(1).(func(string) error); ok { | |||
| 183 | - | r1 = returnFunc(domain) | |||
| 184 | - | } else { | |||
| 185 | - | r1 = ret.Error(1) | |||
| 186 | - | } | |||
| 187 | - | return r0, r1 | |||
| 188 | - | } | |||
| 189 | - | ||||
| 190 | - | // MockDomainSecretStore_GetAll_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAll' | |||
| 191 | - | type MockDomainSecretStore_GetAll_Call struct { | |||
| 192 | - | *mock.Call | |||
| 193 | - | } | |||
| 194 | - | ||||
| 195 | - | // GetAll is a helper method to define mock.On call | |||
| 196 | - | // - domain string | |||
| 197 | - | func (_e *MockDomainSecretStore_Expecter) GetAll(domain any) *MockDomainSecretStore_GetAll_Call { | |||
| 198 | - | return &MockDomainSecretStore_GetAll_Call{Call: _e.mock.On("GetAll", domain)} | |||
| 199 | - | } | |||
| 200 | - | ||||
| 201 | - | func (_c *MockDomainSecretStore_GetAll_Call) Run(run func(domain string)) *MockDomainSecretStore_GetAll_Call { | |||
| 202 | - | _c.Call.Run(func(args mock.Arguments) { | |||
| 203 | - | var arg0 string | |||
| 204 | - | if args[0] != nil { | |||
| 205 | - | arg0 = args[0].(string) | |||
| 206 | - | } | |||
| 207 | - | run( | |||
| 208 | - | arg0, | |||
| 209 | - | ) | |||
| 210 | - | }) | |||
| 211 | - | return _c | |||
| 212 | - | } | |||
| 213 | - | ||||
| 214 | - | func (_c *MockDomainSecretStore_GetAll_Call) Return(stringToString map[string]string, err error) *MockDomainSecretStore_GetAll_Call { | |||
| 215 | - | _c.Call.Return(stringToString, err) | |||
| 216 | - | return _c | |||
| 217 | - | } | |||
| 218 | - | ||||
| 219 | - | func (_c *MockDomainSecretStore_GetAll_Call) RunAndReturn(run func(domain string) (map[string]string, error)) *MockDomainSecretStore_GetAll_Call { | |||
| 220 | - | _c.Call.Return(run) | |||
| 221 | - | return _c | |||
| 222 | - | } | |||
| 223 | - | ||||
| 224 | - | // GetAllAttachment provides a mock function for the type MockDomainSecretStore | |||
| 225 | - | func (_mock *MockDomainSecretStore) GetAllAttachment(containerName string) (map[string]string, error) { | |||
| 226 | - | ret := _mock.Called(containerName) | |||
| 227 | - | ||||
| 228 | - | if len(ret) == 0 { | |||
| 229 | - | panic("no return value specified for GetAllAttachment") | |||
| 230 | - | } | |||
| 231 | - | ||||
| 232 | - | var r0 map[string]string | |||
| 233 | - | var r1 error | |||
| 234 | - | if returnFunc, ok := ret.Get(0).(func(string) (map[string]string, error)); ok { | |||
| 235 | - | return returnFunc(containerName) | |||
| 236 | - | } | |||
| 237 | - | if returnFunc, ok := ret.Get(0).(func(string) map[string]string); ok { | |||
| 238 | - | r0 = returnFunc(containerName) | |||
| 239 | - | } else { | |||
| 240 | - | if ret.Get(0) != nil { | |||
| 241 | - | r0 = ret.Get(0).(map[string]string) | |||
| 242 | - | } | |||
| 243 | - | } | |||
| 244 | - | if returnFunc, ok := ret.Get(1).(func(string) error); ok { | |||
| 245 | - | r1 = returnFunc(containerName) | |||
| 246 | - | } else { | |||
| 247 | - | r1 = ret.Error(1) | |||
| 248 | - | } | |||
| 249 | - | return r0, r1 | |||
| 250 | - | } | |||
| 251 | - | ||||
| 252 | - | // MockDomainSecretStore_GetAllAttachment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAllAttachment' | |||
| 253 | - | type MockDomainSecretStore_GetAllAttachment_Call struct { | |||
| 254 | - | *mock.Call | |||
| 255 | - | } | |||
| 256 | - | ||||
| 257 | - | // GetAllAttachment is a helper method to define mock.On call | |||
| 258 | - | // - containerName string | |||
| 259 | - | func (_e *MockDomainSecretStore_Expecter) GetAllAttachment(containerName any) *MockDomainSecretStore_GetAllAttachment_Call { | |||
| 260 | - | return &MockDomainSecretStore_GetAllAttachment_Call{Call: _e.mock.On("GetAllAttachment", containerName)} | |||
| 261 | - | } | |||
| 262 | - | ||||
| 263 | - | func (_c *MockDomainSecretStore_GetAllAttachment_Call) Run(run func(containerName string)) *MockDomainSecretStore_GetAllAttachment_Call { | |||
| 264 | - | _c.Call.Run(func(args mock.Arguments) { | |||
| 265 | - | var arg0 string | |||
| 266 | - | if args[0] != nil { | |||
| 267 | - | arg0 = args[0].(string) | |||
| 268 | - | } | |||
| 269 | - | run( | |||
| 270 | - | arg0, | |||
| 271 | - | ) | |||
| 272 | - | }) | |||
| 273 | - | return _c | |||
| 274 | - | } | |||
| 275 | - | ||||
| 276 | - | func (_c *MockDomainSecretStore_GetAllAttachment_Call) Return(stringToString map[string]string, err error) *MockDomainSecretStore_GetAllAttachment_Call { | |||
| 277 | - | _c.Call.Return(stringToString, err) | |||
| 278 | - | return _c | |||
| 279 | - | } | |||
| 280 | - | ||||
| 281 | - | func (_c *MockDomainSecretStore_GetAllAttachment_Call) RunAndReturn(run func(containerName string) (map[string]string, error)) *MockDomainSecretStore_GetAllAttachment_Call { | |||
| 282 | - | _c.Call.Return(run) | |||
| 283 | - | return _c | |||
| 284 | - | } | |||
| 285 | - | ||||
| 286 | - | // ListAttachmentKeys provides a mock function for the type MockDomainSecretStore | |||
| 287 | - | func (_mock *MockDomainSecretStore) ListAttachmentKeys(domain string) ([]out.AttachmentSecrets, error) { | |||
| 288 | - | ret := _mock.Called(domain) | |||
| 289 | - | ||||
| 290 | - | if len(ret) == 0 { | |||
| 291 | - | panic("no return value specified for ListAttachmentKeys") | |||
| 292 | - | } | |||
| 293 | - | ||||
| 294 | - | var r0 []out.AttachmentSecrets | |||
| 295 | - | var r1 error | |||
| 296 | - | if returnFunc, ok := ret.Get(0).(func(string) ([]out.AttachmentSecrets, error)); ok { | |||
| 297 | - | return returnFunc(domain) | |||
| 298 | - | } | |||
| 299 | - | if returnFunc, ok := ret.Get(0).(func(string) []out.AttachmentSecrets); ok { | |||
| 300 | - | r0 = returnFunc(domain) | |||
| 301 | - | } else { | |||
| 302 | - | if ret.Get(0) != nil { | |||
| 303 | - | r0 = ret.Get(0).([]out.AttachmentSecrets) | |||
| 304 | - | } | |||
| 305 | - | } | |||
| 306 | - | if returnFunc, ok := ret.Get(1).(func(string) error); ok { | |||
| 307 | - | r1 = returnFunc(domain) | |||
| 308 | - | } else { | |||
| 309 | - | r1 = ret.Error(1) | |||
| 310 | - | } | |||
| 311 | - | return r0, r1 | |||
| 312 | - | } | |||
| 313 | - | ||||
| 314 | - | // MockDomainSecretStore_ListAttachmentKeys_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListAttachmentKeys' | |||
| 315 | - | type MockDomainSecretStore_ListAttachmentKeys_Call struct { | |||
| 316 | - | *mock.Call | |||
| 317 | - | } | |||
| 318 | - | ||||
| 319 | - | // ListAttachmentKeys is a helper method to define mock.On call | |||
| 320 | - | // - domain string | |||
| 321 | - | func (_e *MockDomainSecretStore_Expecter) ListAttachmentKeys(domain any) *MockDomainSecretStore_ListAttachmentKeys_Call { | |||
| 322 | - | return &MockDomainSecretStore_ListAttachmentKeys_Call{Call: _e.mock.On("ListAttachmentKeys", domain)} | |||
| 323 | - | } | |||
| 324 | - | ||||
| 325 | - | func (_c *MockDomainSecretStore_ListAttachmentKeys_Call) Run(run func(domain string)) *MockDomainSecretStore_ListAttachmentKeys_Call { | |||
| 326 | - | _c.Call.Run(func(args mock.Arguments) { | |||
| 327 | - | var arg0 string | |||
| 328 | - | if args[0] != nil { | |||
| 329 | - | arg0 = args[0].(string) | |||
| 330 | - | } | |||
| 331 | - | run( | |||
| 332 | - | arg0, | |||
| 333 | - | ) | |||
| 334 | - | }) | |||
| 335 | - | return _c | |||
| 336 | - | } | |||
| 337 | - | ||||
| 338 | - | func (_c *MockDomainSecretStore_ListAttachmentKeys_Call) Return(attachmentSecretss []out.AttachmentSecrets, err error) *MockDomainSecretStore_ListAttachmentKeys_Call { | |||
| 339 | - | _c.Call.Return(attachmentSecretss, err) | |||
| 340 | - | return _c | |||
| 341 | - | } | |||
| 342 | - | ||||
| 343 | - | func (_c *MockDomainSecretStore_ListAttachmentKeys_Call) RunAndReturn(run func(domain string) ([]out.AttachmentSecrets, error)) *MockDomainSecretStore_ListAttachmentKeys_Call { | |||
| 344 | - | _c.Call.Return(run) | |||
| 345 | - | return _c | |||
| 346 | - | } | |||
| 347 | - | ||||
| 348 | - | // ListKeys provides a mock function for the type MockDomainSecretStore | |||
| 349 | - | func (_mock *MockDomainSecretStore) ListKeys(domain string) ([]string, error) { | |||
| 350 | - | ret := _mock.Called(domain) | |||
| 351 | - | ||||
| 352 | - | if len(ret) == 0 { | |||
| 353 | - | panic("no return value specified for ListKeys") | |||
| 354 | - | } | |||
| 355 | - | ||||
| 356 | - | var r0 []string | |||
| 357 | - | var r1 error | |||
| 358 | - | if returnFunc, ok := ret.Get(0).(func(string) ([]string, error)); ok { | |||
| 359 | - | return returnFunc(domain) | |||
| 360 | - | } | |||
| 361 | - | if returnFunc, ok := ret.Get(0).(func(string) []string); ok { | |||
| 362 | - | r0 = returnFunc(domain) | |||
| 363 | - | } else { | |||
| 364 | - | if ret.Get(0) != nil { | |||
| 365 | - | r0 = ret.Get(0).([]string) | |||
| 366 | - | } | |||
| 367 | - | } | |||
| 368 | - | if returnFunc, ok := ret.Get(1).(func(string) error); ok { | |||
| 369 | - | r1 = returnFunc(domain) | |||
| 370 | - | } else { | |||
| 371 | - | r1 = ret.Error(1) | |||
| 372 | - | } | |||
| 373 | - | return r0, r1 | |||
| 374 | - | } | |||
| 375 | - | ||||
| 376 | - | // MockDomainSecretStore_ListKeys_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListKeys' | |||
| 377 | - | type MockDomainSecretStore_ListKeys_Call struct { | |||
| 378 | - | *mock.Call | |||
| 379 | - | } | |||
| 380 | - | ||||
| 381 | - | // ListKeys is a helper method to define mock.On call | |||
| 382 | - | // - domain string | |||
| 383 | - | func (_e *MockDomainSecretStore_Expecter) ListKeys(domain any) *MockDomainSecretStore_ListKeys_Call { | |||
| 384 | - | return &MockDomainSecretStore_ListKeys_Call{Call: _e.mock.On("ListKeys", domain)} | |||
| 385 | - | } | |||
| 386 | - | ||||
| 387 | - | func (_c *MockDomainSecretStore_ListKeys_Call) Run(run func(domain string)) *MockDomainSecretStore_ListKeys_Call { | |||
| 388 | - | _c.Call.Run(func(args mock.Arguments) { | |||
| 389 | - | var arg0 string | |||
| 390 | - | if args[0] != nil { | |||
| 391 | - | arg0 = args[0].(string) | |||
| 392 | - | } | |||
| 393 | - | run( | |||
| 394 | - | arg0, | |||
| 395 | - | ) | |||
| 396 | - | }) | |||
| 397 | - | return _c | |||
| 398 | - | } | |||
| 399 | - | ||||
| 400 | - | func (_c *MockDomainSecretStore_ListKeys_Call) Return(strings []string, err error) *MockDomainSecretStore_ListKeys_Call { | |||
| 401 | - | _c.Call.Return(strings, err) | |||
| 402 | - | return _c | |||
| 403 | - | } | |||
| 404 | - | ||||
| 405 | - | func (_c *MockDomainSecretStore_ListKeys_Call) RunAndReturn(run func(domain string) ([]string, error)) *MockDomainSecretStore_ListKeys_Call { | |||
| 406 | - | _c.Call.Return(run) | |||
| 407 | - | return _c | |||
| 408 | - | } | |||
| 409 | - | ||||
| 410 | - | // Set provides a mock function for the type MockDomainSecretStore | |||
| 411 | - | func (_mock *MockDomainSecretStore) Set(domain string, secrets map[string]string) error { | |||
| 412 | - | ret := _mock.Called(domain, secrets) | |||
| 413 | - | ||||
| 414 | - | if len(ret) == 0 { | |||
| 415 | - | panic("no return value specified for Set") | |||
| 416 | - | } | |||
| 417 | - | ||||
| 418 | - | var r0 error | |||
| 419 | - | if returnFunc, ok := ret.Get(0).(func(string, map[string]string) error); ok { | |||
| 420 | - | r0 = returnFunc(domain, secrets) | |||
| 421 | - | } else { | |||
| 422 | - | r0 = ret.Error(0) | |||
| 423 | - | } | |||
| 424 | - | return r0 | |||
| 425 | - | } | |||
| 426 | - | ||||
| 427 | - | // MockDomainSecretStore_Set_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Set' | |||
| 428 | - | type MockDomainSecretStore_Set_Call struct { | |||
| 429 | - | *mock.Call | |||
| 430 | - | } | |||
| 431 | - | ||||
| 432 | - | // Set is a helper method to define mock.On call | |||
| 433 | - | // - domain string | |||
| 434 | - | // - secrets map[string]string | |||
| 435 | - | func (_e *MockDomainSecretStore_Expecter) Set(domain any, secrets any) *MockDomainSecretStore_Set_Call { | |||
| 436 | - | return &MockDomainSecretStore_Set_Call{Call: _e.mock.On("Set", domain, secrets)} | |||
| 437 | - | } | |||
| 438 | - | ||||
| 439 | - | func (_c *MockDomainSecretStore_Set_Call) Run(run func(domain string, secrets map[string]string)) *MockDomainSecretStore_Set_Call { | |||
| 440 | - | _c.Call.Run(func(args mock.Arguments) { | |||
| 441 | - | var arg0 string | |||
| 442 | - | if args[0] != nil { | |||
| 443 | - | arg0 = args[0].(string) | |||
| 444 | - | } | |||
| 445 | - | var arg1 map[string]string | |||
| 446 | - | if args[1] != nil { | |||
| 447 | - | arg1 = args[1].(map[string]string) | |||
| 448 | - | } | |||
| 449 | - | run( | |||
| 450 | - | arg0, | |||
| 451 | - | arg1, | |||
| 452 | - | ) | |||
| 453 | - | }) | |||
| 454 | - | return _c | |||
| 455 | - | } | |||
| 456 | - | ||||
| 457 | - | func (_c *MockDomainSecretStore_Set_Call) Return(err error) *MockDomainSecretStore_Set_Call { | |||
| 458 | - | _c.Call.Return(err) | |||
| 459 | - | return _c | |||
| 460 | - | } | |||
| 461 | - | ||||
| 462 | - | func (_c *MockDomainSecretStore_Set_Call) RunAndReturn(run func(domain string, secrets map[string]string) error) *MockDomainSecretStore_Set_Call { | |||
| 463 | - | _c.Call.Return(run) | |||
| 464 | - | return _c | |||
| 465 | - | } | |||
| 466 | - | ||||
| 467 | - | // SetAttachment provides a mock function for the type MockDomainSecretStore | |||
| 468 | - | func (_mock *MockDomainSecretStore) SetAttachment(containerName string, secrets map[string]string) error { | |||
| 469 | - | ret := _mock.Called(containerName, secrets) | |||
| 470 | - | ||||
| 471 | - | if len(ret) == 0 { | |||
| 472 | - | panic("no return value specified for SetAttachment") | |||
| 473 | - | } | |||
| 474 | - | ||||
| 475 | - | var r0 error | |||
| 476 | - | if returnFunc, ok := ret.Get(0).(func(string, map[string]string) error); ok { | |||
| 477 | - | r0 = returnFunc(containerName, secrets) | |||
| 478 | - | } else { | |||
| 479 | - | r0 = ret.Error(0) | |||
| 480 | - | } | |||
| 481 | - | return r0 | |||
| 482 | - | } | |||
| 483 | - | ||||
| 484 | - | // MockDomainSecretStore_SetAttachment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetAttachment' | |||
| 485 | - | type MockDomainSecretStore_SetAttachment_Call struct { | |||
| 486 | - | *mock.Call | |||
| 487 | - | } | |||
| 488 | - | ||||
| 489 | - | // SetAttachment is a helper method to define mock.On call | |||
| 490 | - | // - containerName string | |||
| 491 | - | // - secrets map[string]string | |||
| 492 | - | func (_e *MockDomainSecretStore_Expecter) SetAttachment(containerName any, secrets any) *MockDomainSecretStore_SetAttachment_Call { | |||
| 493 | - | return &MockDomainSecretStore_SetAttachment_Call{Call: _e.mock.On("SetAttachment", containerName, secrets)} | |||
| 494 | - | } | |||
| 495 | - | ||||
| 496 | - | func (_c *MockDomainSecretStore_SetAttachment_Call) Run(run func(containerName string, secrets map[string]string)) *MockDomainSecretStore_SetAttachment_Call { | |||
| 497 | - | _c.Call.Run(func(args mock.Arguments) { | |||
| 498 | - | var arg0 string | |||
| 499 | - | if args[0] != nil { | |||
| 500 | - | arg0 = args[0].(string) | |||
| 501 | - | } | |||
| 502 | - | var arg1 map[string]string | |||
| 503 | - | if args[1] != nil { | |||
| 504 | - | arg1 = args[1].(map[string]string) | |||
| 505 | - | } | |||
| 506 | - | run( | |||
| 507 | - | arg0, | |||
| 508 | - | arg1, | |||
| 509 | - | ) | |||
| 510 | - | }) | |||
| 511 | - | return _c | |||
| 512 | - | } | |||
| 513 | - | ||||
| 514 | - | func (_c *MockDomainSecretStore_SetAttachment_Call) Return(err error) *MockDomainSecretStore_SetAttachment_Call { | |||
| 515 | - | _c.Call.Return(err) | |||
| 516 | - | return _c | |||
| 517 | - | } | |||
| 518 | - | ||||
| 519 | - | func (_c *MockDomainSecretStore_SetAttachment_Call) RunAndReturn(run func(containerName string, secrets map[string]string) error) *MockDomainSecretStore_SetAttachment_Call { | |||
| 520 | - | _c.Call.Return(run) | |||
| 521 | - | return _c | |||
| 522 | - | } | |||
D internal/boundaries/out/mocks/mock_env_loader.go +0 -232
| @@ -1,232 +0,0 @@ | |||||
| 1 | - | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | - | // github.com/vektra/mockery | |||
| 3 | - | // template: testify | |||
| 4 | - | ||||
| 5 | - | package mocks | |||
| 6 | - | ||||
| 7 | - | import ( | |||
| 8 | - | "context" | |||
| 9 | - | ||||
| 10 | - | mock "github.com/stretchr/testify/mock" | |||
| 11 | - | ) | |||
| 12 | - | ||||
| 13 | - | // NewMockEnvLoader creates a new instance of MockEnvLoader. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 14 | - | // The first argument is typically a *testing.T value. | |||
| 15 | - | func NewMockEnvLoader(t interface { | |||
| 16 | - | mock.TestingT | |||
| 17 | - | Cleanup(func()) | |||
| 18 | - | }) *MockEnvLoader { | |||
| 19 | - | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 20 | - | helper.Helper() | |||
| 21 | - | } | |||
| 22 | - | ||||
| 23 | - | mock := &MockEnvLoader{} | |||
| 24 | - | mock.Mock.Test(t) | |||
| 25 | - | ||||
| 26 | - | t.Cleanup(func() { | |||
| 27 | - | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 28 | - | helper.Helper() | |||
| 29 | - | } | |||
| 30 | - | mock.AssertExpectations(t) | |||
| 31 | - | }) | |||
| 32 | - | ||||
| 33 | - | return mock | |||
| 34 | - | } | |||
| 35 | - | ||||
| 36 | - | // MockEnvLoader is an autogenerated mock type for the EnvLoader type | |||
| 37 | - | type MockEnvLoader struct { | |||
| 38 | - | mock.Mock | |||
| 39 | - | } | |||
| 40 | - | ||||
| 41 | - | type MockEnvLoader_Expecter struct { | |||
| 42 | - | mock *mock.Mock | |||
| 43 | - | } | |||
| 44 | - | ||||
| 45 | - | func (_m *MockEnvLoader) EXPECT() *MockEnvLoader_Expecter { | |||
| 46 | - | return &MockEnvLoader_Expecter{mock: &_m.Mock} | |||
| 47 | - | } | |||
| 48 | - | ||||
| 49 | - | // CreateEnvFile provides a mock function for the type MockEnvLoader | |||
| 50 | - | func (_mock *MockEnvLoader) CreateEnvFile(ctx context.Context, domain string) error { | |||
| 51 | - | ret := _mock.Called(ctx, domain) | |||
| 52 | - | ||||
| 53 | - | if len(ret) == 0 { | |||
| 54 | - | panic("no return value specified for CreateEnvFile") | |||
| 55 | - | } | |||
| 56 | - | ||||
| 57 | - | var r0 error | |||
| 58 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok { | |||
| 59 | - | r0 = returnFunc(ctx, domain) | |||
| 60 | - | } else { | |||
| 61 | - | r0 = ret.Error(0) | |||
| 62 | - | } | |||
| 63 | - | return r0 | |||
| 64 | - | } | |||
| 65 | - | ||||
| 66 | - | // MockEnvLoader_CreateEnvFile_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateEnvFile' | |||
| 67 | - | type MockEnvLoader_CreateEnvFile_Call struct { | |||
| 68 | - | *mock.Call | |||
| 69 | - | } | |||
| 70 | - | ||||
| 71 | - | // CreateEnvFile is a helper method to define mock.On call | |||
| 72 | - | // - ctx context.Context | |||
| 73 | - | // - domain string | |||
| 74 | - | func (_e *MockEnvLoader_Expecter) CreateEnvFile(ctx any, domain any) *MockEnvLoader_CreateEnvFile_Call { | |||
| 75 | - | return &MockEnvLoader_CreateEnvFile_Call{Call: _e.mock.On("CreateEnvFile", ctx, domain)} | |||
| 76 | - | } | |||
| 77 | - | ||||
| 78 | - | func (_c *MockEnvLoader_CreateEnvFile_Call) Run(run func(ctx context.Context, domain string)) *MockEnvLoader_CreateEnvFile_Call { | |||
| 79 | - | _c.Call.Run(func(args mock.Arguments) { | |||
| 80 | - | var arg0 context.Context | |||
| 81 | - | if args[0] != nil { | |||
| 82 | - | arg0 = args[0].(context.Context) | |||
| 83 | - | } | |||
| 84 | - | var arg1 string | |||
| 85 | - | if args[1] != nil { | |||
| 86 | - | arg1 = args[1].(string) | |||
| 87 | - | } | |||
| 88 | - | run( | |||
| 89 | - | arg0, | |||
| 90 | - | arg1, | |||
| 91 | - | ) | |||
| 92 | - | }) | |||
| 93 | - | return _c | |||
| 94 | - | } | |||
| 95 | - | ||||
| 96 | - | func (_c *MockEnvLoader_CreateEnvFile_Call) Return(err error) *MockEnvLoader_CreateEnvFile_Call { | |||
| 97 | - | _c.Call.Return(err) | |||
| 98 | - | return _c | |||
| 99 | - | } | |||
| 100 | - | ||||
| 101 | - | func (_c *MockEnvLoader_CreateEnvFile_Call) RunAndReturn(run func(ctx context.Context, domain string) error) *MockEnvLoader_CreateEnvFile_Call { | |||
| 102 | - | _c.Call.Return(run) | |||
| 103 | - | return _c | |||
| 104 | - | } | |||
| 105 | - | ||||
| 106 | - | // EnvFileExists provides a mock function for the type MockEnvLoader | |||
| 107 | - | func (_mock *MockEnvLoader) EnvFileExists(domain string) (bool, error) { | |||
| 108 | - | ret := _mock.Called(domain) | |||
| 109 | - | ||||
| 110 | - | if len(ret) == 0 { | |||
| 111 | - | panic("no return value specified for EnvFileExists") | |||
| 112 | - | } | |||
| 113 | - | ||||
| 114 | - | var r0 bool | |||
| 115 | - | var r1 error | |||
| 116 | - | if returnFunc, ok := ret.Get(0).(func(string) (bool, error)); ok { | |||
| 117 | - | return returnFunc(domain) | |||
| 118 | - | } | |||
| 119 | - | if returnFunc, ok := ret.Get(0).(func(string) bool); ok { | |||
| 120 | - | r0 = returnFunc(domain) | |||
| 121 | - | } else { | |||
| 122 | - | r0 = ret.Get(0).(bool) | |||
| 123 | - | } | |||
| 124 | - | if returnFunc, ok := ret.Get(1).(func(string) error); ok { | |||
| 125 | - | r1 = returnFunc(domain) | |||
| 126 | - | } else { | |||
| 127 | - | r1 = ret.Error(1) | |||
| 128 | - | } | |||
| 129 | - | return r0, r1 | |||
| 130 | - | } | |||
| 131 | - | ||||
| 132 | - | // MockEnvLoader_EnvFileExists_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnvFileExists' | |||
| 133 | - | type MockEnvLoader_EnvFileExists_Call struct { | |||
| 134 | - | *mock.Call | |||
| 135 | - | } | |||
| 136 | - | ||||
| 137 | - | // EnvFileExists is a helper method to define mock.On call | |||
| 138 | - | // - domain string | |||
| 139 | - | func (_e *MockEnvLoader_Expecter) EnvFileExists(domain any) *MockEnvLoader_EnvFileExists_Call { | |||
| 140 | - | return &MockEnvLoader_EnvFileExists_Call{Call: _e.mock.On("EnvFileExists", domain)} | |||
| 141 | - | } | |||
| 142 | - | ||||
| 143 | - | func (_c *MockEnvLoader_EnvFileExists_Call) Run(run func(domain string)) *MockEnvLoader_EnvFileExists_Call { | |||
| 144 | - | _c.Call.Run(func(args mock.Arguments) { | |||
| 145 | - | var arg0 string | |||
| 146 | - | if args[0] != nil { | |||
| 147 | - | arg0 = args[0].(string) | |||
| 148 | - | } | |||
| 149 | - | run( | |||
| 150 | - | arg0, | |||
| 151 | - | ) | |||
| 152 | - | }) | |||
| 153 | - | return _c | |||
| 154 | - | } | |||
| 155 | - | ||||
| 156 | - | func (_c *MockEnvLoader_EnvFileExists_Call) Return(b bool, err error) *MockEnvLoader_EnvFileExists_Call { | |||
| 157 | - | _c.Call.Return(b, err) | |||
| 158 | - | return _c | |||
| 159 | - | } | |||
| 160 | - | ||||
| 161 | - | func (_c *MockEnvLoader_EnvFileExists_Call) RunAndReturn(run func(domain string) (bool, error)) *MockEnvLoader_EnvFileExists_Call { | |||
| 162 | - | _c.Call.Return(run) | |||
| 163 | - | return _c | |||
| 164 | - | } | |||
| 165 | - | ||||
| 166 | - | // LoadEnv provides a mock function for the type MockEnvLoader | |||
| 167 | - | func (_mock *MockEnvLoader) LoadEnv(ctx context.Context, domain string) ([]string, error) { | |||
| 168 | - | ret := _mock.Called(ctx, domain) | |||
| 169 | - | ||||
| 170 | - | if len(ret) == 0 { | |||
| 171 | - | panic("no return value specified for LoadEnv") | |||
| 172 | - | } | |||
| 173 | - | ||||
| 174 | - | var r0 []string | |||
| 175 | - | var r1 error | |||
| 176 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]string, error)); ok { | |||
| 177 | - | return returnFunc(ctx, domain) | |||
| 178 | - | } | |||
| 179 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string) []string); ok { | |||
| 180 | - | r0 = returnFunc(ctx, domain) | |||
| 181 | - | } else { | |||
| 182 | - | if ret.Get(0) != nil { | |||
| 183 | - | r0 = ret.Get(0).([]string) | |||
| 184 | - | } | |||
| 185 | - | } | |||
| 186 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | |||
| 187 | - | r1 = returnFunc(ctx, domain) | |||
| 188 | - | } else { | |||
| 189 | - | r1 = ret.Error(1) | |||
| 190 | - | } | |||
| 191 | - | return r0, r1 | |||
| 192 | - | } | |||
| 193 | - | ||||
| 194 | - | // MockEnvLoader_LoadEnv_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LoadEnv' | |||
| 195 | - | type MockEnvLoader_LoadEnv_Call struct { | |||
| 196 | - | *mock.Call | |||
| 197 | - | } | |||
| 198 | - | ||||
| 199 | - | // LoadEnv is a helper method to define mock.On call | |||
| 200 | - | // - ctx context.Context | |||
| 201 | - | // - domain string | |||
| 202 | - | func (_e *MockEnvLoader_Expecter) LoadEnv(ctx any, domain any) *MockEnvLoader_LoadEnv_Call { | |||
| 203 | - | return &MockEnvLoader_LoadEnv_Call{Call: _e.mock.On("LoadEnv", ctx, domain)} | |||
| 204 | - | } | |||
| 205 | - | ||||
| 206 | - | func (_c *MockEnvLoader_LoadEnv_Call) Run(run func(ctx context.Context, domain string)) *MockEnvLoader_LoadEnv_Call { | |||
| 207 | - | _c.Call.Run(func(args mock.Arguments) { | |||
| 208 | - | var arg0 context.Context | |||
| 209 | - | if args[0] != nil { | |||
| 210 | - | arg0 = args[0].(context.Context) | |||
| 211 | - | } | |||
| 212 | - | var arg1 string | |||
| 213 | - | if args[1] != nil { | |||
| 214 | - | arg1 = args[1].(string) | |||
| 215 | - | } | |||
| 216 | - | run( | |||
| 217 | - | arg0, | |||
| 218 | - | arg1, | |||
| 219 | - | ) | |||
| 220 | - | }) | |||
| 221 | - | return _c | |||
| 222 | - | } | |||
| 223 | - | ||||
| 224 | - | func (_c *MockEnvLoader_LoadEnv_Call) Return(strings []string, err error) *MockEnvLoader_LoadEnv_Call { | |||
| 225 | - | _c.Call.Return(strings, err) | |||
| 226 | - | return _c | |||
| 227 | - | } | |||
| 228 | - | ||||
| 229 | - | func (_c *MockEnvLoader_LoadEnv_Call) RunAndReturn(run func(ctx context.Context, domain string) ([]string, error)) *MockEnvLoader_LoadEnv_Call { | |||
| 230 | - | _c.Call.Return(run) | |||
| 231 | - | return _c | |||
| 232 | - | } | |||
M internal/domain/auth.go +0 -6
| @@ -77,7 +77,6 @@ const ( | |||||
| 77 | // Admin scope resource constants. | 77 | // Admin scope resource constants. | ||
| 78 | const ( | 78 | const ( | ||
| 79 | AdminResourceRoutes = "routes" | 79 | AdminResourceRoutes = "routes" | ||
| 80 | - | AdminResourceSecrets = "secrets" | |||
| 81 | AdminResourceConfig = "config" | 80 | AdminResourceConfig = "config" | ||
| 82 | AdminResourceStatus = "status" | 81 | AdminResourceStatus = "status" | ||
| 83 | AdminResourceLogs = "logs" | 82 | AdminResourceLogs = "logs" | ||
| @@ -287,11 +286,6 @@ func AdminScopeRoutes(actions ...string) string { | |||||
| 287 | return fmt.Sprintf("%s:%s:%s", ScopeTypeAdmin, AdminResourceRoutes, strings.Join(actions, ",")) | 286 | return fmt.Sprintf("%s:%s:%s", ScopeTypeAdmin, AdminResourceRoutes, strings.Join(actions, ",")) | ||
| 288 | } | 287 | } | ||
| 289 | 288 | ||||
| 290 | - | // AdminScopeSecrets creates an admin scope for secrets with the given actions. | |||
| 291 | - | func AdminScopeSecrets(actions ...string) string { | |||
| 292 | - | return fmt.Sprintf("%s:%s:%s", ScopeTypeAdmin, AdminResourceSecrets, strings.Join(actions, ",")) | |||
| 293 | - | } | |||
| 294 | - | ||||
| 295 | // AdminScopeConfig creates an admin scope for config with the given actions. | 289 | // AdminScopeConfig creates an admin scope for config with the given actions. | ||
| 296 | func AdminScopeConfig(actions ...string) string { | 290 | func AdminScopeConfig(actions ...string) string { | ||
| 297 | return fmt.Sprintf("%s:%s:%s", ScopeTypeAdmin, AdminResourceConfig, strings.Join(actions, ",")) | 291 | return fmt.Sprintf("%s:%s:%s", ScopeTypeAdmin, AdminResourceConfig, strings.Join(actions, ",")) | ||
M internal/domain/env.go +2 -74
| @@ -1,71 +1,15 @@ | |||||
| 1 | package domain | 1 | package domain | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "bufio" | |||
| 5 | - | "bytes" | |||
| 6 | "regexp" | 4 | "regexp" | ||
| 7 | "strings" | 5 | "strings" | ||
| 8 | ) | 6 | ) | ||
| 9 | 7 | ||||
| 10 | var ( | 8 | var ( | ||
| 11 | - | envKeyRegex = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) | 9 | + | envKeyRegex = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) |
| 12 | - | containerNameRegex = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]*$`) | 10 | + | secretRefRegex = regexp.MustCompile(`\$\{(pass|sops):[^}]+\}`) |
| 13 | - | secretRefRegex = regexp.MustCompile(`\$\{(pass|sops):[^}]+\}`) | |||
| 14 | ) | 11 | ) | ||
| 15 | 12 | ||||
| 16 | - | // SanitizeDomainForEnvFile validates and sanitizes a domain name for env file storage. | |||
| 17 | - | // Returns the collision-resistant storage key used for filenames. | |||
| 18 | - | func SanitizeDomainForEnvFile(domainName string) (string, error) { | |||
| 19 | - | safeDomain, err := NewEnvStorageKey(domainName) | |||
| 20 | - | if err != nil { | |||
| 21 | - | return "", err | |||
| 22 | - | } | |||
| 23 | - | ||||
| 24 | - | return string(safeDomain), nil | |||
| 25 | - | } | |||
| 26 | - | ||||
| 27 | - | // ParseEnvData parses env data into a key-value map. | |||
| 28 | - | func ParseEnvData(data []byte) (map[string]string, error) { | |||
| 29 | - | secrets := make(map[string]string) | |||
| 30 | - | scanner := bufio.NewScanner(bytes.NewReader(data)) | |||
| 31 | - | // Allow large env values (certs/keys) without hitting the default ~64KB limit. | |||
| 32 | - | scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) | |||
| 33 | - | for scanner.Scan() { | |||
| 34 | - | line := strings.TrimSpace(scanner.Text()) | |||
| 35 | - | if line == "" || strings.HasPrefix(line, "#") { | |||
| 36 | - | continue | |||
| 37 | - | } | |||
| 38 | - | ||||
| 39 | - | parts := strings.SplitN(line, "=", 2) | |||
| 40 | - | if len(parts) != 2 { | |||
| 41 | - | continue | |||
| 42 | - | } | |||
| 43 | - | ||||
| 44 | - | key := strings.TrimSpace(parts[0]) | |||
| 45 | - | rawValue := parts[1] | |||
| 46 | - | ||||
| 47 | - | trimmedValue := strings.TrimSpace(rawValue) | |||
| 48 | - | value := trimmedValue | |||
| 49 | - | ||||
| 50 | - | if len(trimmedValue) >= 2 { | |||
| 51 | - | if (strings.HasPrefix(trimmedValue, "\"") && strings.HasSuffix(trimmedValue, "\"")) || | |||
| 52 | - | (strings.HasPrefix(trimmedValue, "'") && strings.HasSuffix(trimmedValue, "'")) { | |||
| 53 | - | value = trimmedValue[1 : len(trimmedValue)-1] | |||
| 54 | - | } | |||
| 55 | - | } | |||
| 56 | - | ||||
| 57 | - | if key != "" { | |||
| 58 | - | secrets[key] = value | |||
| 59 | - | } | |||
| 60 | - | } | |||
| 61 | - | ||||
| 62 | - | if err := scanner.Err(); err != nil { | |||
| 63 | - | return nil, err | |||
| 64 | - | } | |||
| 65 | - | ||||
| 66 | - | return secrets, nil | |||
| 67 | - | } | |||
| 68 | - | ||||
| 69 | // ValidateEnvKey validates an env key for storage. | 13 | // ValidateEnvKey validates an env key for storage. | ||
| 70 | func ValidateEnvKey(key string) error { | 14 | func ValidateEnvKey(key string) error { | ||
| 71 | if key == "" { | 15 | if key == "" { | ||
| @@ -87,19 +31,3 @@ func ValidateEnvKey(key string) error { | |||||
| 87 | func ContainsSecretReference(value string) bool { | 31 | func ContainsSecretReference(value string) bool { | ||
| 88 | return secretRefRegex.MatchString(value) | 32 | return secretRefRegex.MatchString(value) | ||
| 89 | } | 33 | } | ||
| 90 | - | ||||
| 91 | - | // ValidateContainerName validates a container name for attachment storage. | |||
| 92 | - | // Container names can contain alphanumeric characters, hyphens, and underscores, | |||
| 93 | - | // but must start with a letter. | |||
| 94 | - | func ValidateContainerName(name string) error { | |||
| 95 | - | if name == "" { | |||
| 96 | - | return ErrInvalidContainerName | |||
| 97 | - | } | |||
| 98 | - | if strings.Contains(name, "..") || strings.ContainsAny(name, "/\\") { | |||
| 99 | - | return ErrPathTraversal | |||
| 100 | - | } | |||
| 101 | - | if !containerNameRegex.MatchString(name) { | |||
| 102 | - | return ErrInvalidContainerName | |||
| 103 | - | } | |||
| 104 | - | return nil | |||
| 105 | - | } | |||
M internal/domain/env_test.go +0 -176
| @@ -1,147 +1,12 @@ | |||||
| 1 | package domain | 1 | package domain | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "encoding/base64" | |||
| 5 | - | "strings" | |||
| 6 | "testing" | 4 | "testing" | ||
| 7 | 5 | ||||
| 8 | "github.com/stretchr/testify/assert" | 6 | "github.com/stretchr/testify/assert" | ||
| 9 | "github.com/stretchr/testify/require" | 7 | "github.com/stretchr/testify/require" | ||
| 10 | ) | 8 | ) | ||
| 11 | 9 | ||||
| 12 | - | func TestSanitizeDomainForEnvFile(t *testing.T) { | |||
| 13 | - | tests := []struct { | |||
| 14 | - | name string | |||
| 15 | - | domain string | |||
| 16 | - | want string | |||
| 17 | - | wantErr bool | |||
| 18 | - | }{ | |||
| 19 | - | {name: "simple domain", domain: "example.com", want: base64.RawURLEncoding.EncodeToString([]byte("example.com"))}, | |||
| 20 | - | {name: "subdomain", domain: "app.example.com", want: base64.RawURLEncoding.EncodeToString([]byte("app.example.com"))}, | |||
| 21 | - | {name: "domain with port", domain: "example.com:8080", want: base64.RawURLEncoding.EncodeToString([]byte("example.com:8080"))}, | |||
| 22 | - | {name: "domain with path", domain: "example.com/path", want: base64.RawURLEncoding.EncodeToString([]byte("example.com/path"))}, | |||
| 23 | - | {name: "single char", domain: "a", want: base64.RawURLEncoding.EncodeToString([]byte("a"))}, | |||
| 24 | - | {name: "hyphenated", domain: "my-app.example.com", want: base64.RawURLEncoding.EncodeToString([]byte("my-app.example.com"))}, | |||
| 25 | - | {name: "empty", domain: "", wantErr: true}, | |||
| 26 | - | {name: "path traversal", domain: "../etc/passwd", wantErr: true}, | |||
| 27 | - | {name: "double dots", domain: "foo..bar", wantErr: true}, | |||
| 28 | - | {name: "starts with dot", domain: ".hidden", wantErr: true}, | |||
| 29 | - | {name: "ends with dot", domain: "trailing.", wantErr: true}, | |||
| 30 | - | {name: "space", domain: "has space", wantErr: true}, | |||
| 31 | - | {name: "special chars", domain: "bad$domain", wantErr: true}, | |||
| 32 | - | {name: "underscore rejected as invalid domain character", domain: "example_com", wantErr: true}, | |||
| 33 | - | } | |||
| 34 | - | ||||
| 35 | - | for _, tt := range tests { | |||
| 36 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 37 | - | got, err := SanitizeDomainForEnvFile(tt.domain) | |||
| 38 | - | if tt.wantErr { | |||
| 39 | - | assert.Error(t, err) | |||
| 40 | - | return | |||
| 41 | - | } | |||
| 42 | - | require.NoError(t, err) | |||
| 43 | - | assert.Equal(t, tt.want, got) | |||
| 44 | - | }) | |||
| 45 | - | } | |||
| 46 | - | } | |||
| 47 | - | ||||
| 48 | - | func TestSanitizeDomainForEnvFile_DistinguishesSeparators(t *testing.T) { | |||
| 49 | - | gotDot, err := SanitizeDomainForEnvFile("app.example.com") | |||
| 50 | - | require.NoError(t, err) | |||
| 51 | - | ||||
| 52 | - | gotPort, err := SanitizeDomainForEnvFile("app:example:com") | |||
| 53 | - | require.NoError(t, err) | |||
| 54 | - | ||||
| 55 | - | gotPath, err := SanitizeDomainForEnvFile("app/example/com") | |||
| 56 | - | require.NoError(t, err) | |||
| 57 | - | ||||
| 58 | - | assert.NotEqual(t, gotDot, gotPort) | |||
| 59 | - | assert.NotEqual(t, gotDot, gotPath) | |||
| 60 | - | assert.NotEqual(t, gotPort, gotPath) | |||
| 61 | - | } | |||
| 62 | - | ||||
| 63 | - | func TestParseEnvData(t *testing.T) { | |||
| 64 | - | tests := []struct { | |||
| 65 | - | name string | |||
| 66 | - | data string | |||
| 67 | - | want map[string]string | |||
| 68 | - | wantErr bool | |||
| 69 | - | }{ | |||
| 70 | - | { | |||
| 71 | - | name: "simple key-value", | |||
| 72 | - | data: "FOO=bar\nBAZ=qux", | |||
| 73 | - | want: map[string]string{"FOO": "bar", "BAZ": "qux"}, | |||
| 74 | - | }, | |||
| 75 | - | { | |||
| 76 | - | name: "double-quoted value", | |||
| 77 | - | data: `KEY="hello world"`, | |||
| 78 | - | want: map[string]string{"KEY": "hello world"}, | |||
| 79 | - | }, | |||
| 80 | - | { | |||
| 81 | - | name: "single-quoted value", | |||
| 82 | - | data: `KEY='hello world'`, | |||
| 83 | - | want: map[string]string{"KEY": "hello world"}, | |||
| 84 | - | }, | |||
| 85 | - | { | |||
| 86 | - | name: "comment lines", | |||
| 87 | - | data: "# this is a comment\nKEY=val", | |||
| 88 | - | want: map[string]string{"KEY": "val"}, | |||
| 89 | - | }, | |||
| 90 | - | { | |||
| 91 | - | name: "empty lines", | |||
| 92 | - | data: "\n\nKEY=val\n\n", | |||
| 93 | - | want: map[string]string{"KEY": "val"}, | |||
| 94 | - | }, | |||
| 95 | - | { | |||
| 96 | - | name: "value with equals", | |||
| 97 | - | data: "URL=postgres://host:5432/db?opt=1", | |||
| 98 | - | want: map[string]string{"URL": "postgres://host:5432/db?opt=1"}, | |||
| 99 | - | }, | |||
| 100 | - | { | |||
| 101 | - | name: "no value", | |||
| 102 | - | data: "NOVALUE", | |||
| 103 | - | want: map[string]string{}, | |||
| 104 | - | }, | |||
| 105 | - | { | |||
| 106 | - | name: "empty value", | |||
| 107 | - | data: "KEY=", | |||
| 108 | - | want: map[string]string{"KEY": ""}, | |||
| 109 | - | }, | |||
| 110 | - | { | |||
| 111 | - | name: "empty input", | |||
| 112 | - | data: "", | |||
| 113 | - | want: map[string]string{}, | |||
| 114 | - | }, | |||
| 115 | - | { | |||
| 116 | - | name: "large value within buffer", | |||
| 117 | - | data: "BIG=" + strings.Repeat("x", 100000), | |||
| 118 | - | want: map[string]string{"BIG": strings.Repeat("x", 100000)}, | |||
| 119 | - | }, | |||
| 120 | - | { | |||
| 121 | - | name: "quoted value with inner spaces", | |||
| 122 | - | data: `KEY=" hello "`, | |||
| 123 | - | want: map[string]string{"KEY": " hello "}, | |||
| 124 | - | }, | |||
| 125 | - | { | |||
| 126 | - | name: "unquoted value with outer spaces", | |||
| 127 | - | data: "KEY= hello ", | |||
| 128 | - | want: map[string]string{"KEY": "hello"}, | |||
| 129 | - | }, | |||
| 130 | - | } | |||
| 131 | - | ||||
| 132 | - | for _, tt := range tests { | |||
| 133 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 134 | - | got, err := ParseEnvData([]byte(tt.data)) | |||
| 135 | - | if tt.wantErr { | |||
| 136 | - | assert.Error(t, err) | |||
| 137 | - | return | |||
| 138 | - | } | |||
| 139 | - | require.NoError(t, err) | |||
| 140 | - | assert.Equal(t, tt.want, got) | |||
| 141 | - | }) | |||
| 142 | - | } | |||
| 143 | - | } | |||
| 144 | - | ||||
| 145 | func TestValidateEnvKey(t *testing.T) { | 10 | func TestValidateEnvKey(t *testing.T) { | ||
| 146 | tests := []struct { | 11 | tests := []struct { | ||
| 147 | name string | 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 | ErrSecretNotFound = errors.New("secret not found") | 117 | ErrSecretNotFound = errors.New("secret not found") | ||
| 118 | ErrSecretsAlreadyExist = errors.New("secrets already exist") | 118 | ErrSecretsAlreadyExist = errors.New("secrets already exist") | ||
| 119 | ErrProviderNotFound = errors.New("secret provider not found") | 119 | ErrProviderNotFound = errors.New("secret provider not found") | ||
| 120 | - | ErrInvalidContainerName = errors.New("invalid container name") | |||
| 121 | ErrAttachmentOwnershipMismatch = errors.New("attachment ownership mismatch") | 120 | ErrAttachmentOwnershipMismatch = errors.New("attachment ownership mismatch") | ||
| 122 | ErrReadinessLogSizeExceeded = errors.New("readiness log exceeds maximum") | 121 | ErrReadinessLogSizeExceeded = errors.New("readiness log exceeds maximum") | ||
| 123 | 122 | ||||
M internal/domain/event.go +3 -11
| @@ -9,10 +9,9 @@ import ( | |||||
| 9 | type EventType string | 9 | type EventType string | ||
| 10 | 10 | ||||
| 11 | const ( | 11 | const ( | ||
| 12 | - | EventImagePushed EventType = "image.pushed" | 12 | + | EventImagePushed EventType = "image.pushed" |
| 13 | - | EventImageDeleted EventType = "image.deleted" | 13 | + | EventImageDeleted EventType = "image.deleted" |
| 14 | - | EventConfigReload EventType = "config.reload" | 14 | + | EventConfigReload EventType = "config.reload" |
| 15 | - | EventSecretsChanged EventType = "secrets.changed" | |||
| 16 | ) | 15 | ) | ||
| 17 | 16 | ||||
| 18 | // Event represents a domain event that occurred in the system. | 17 | // Event represents a domain event that occurred in the system. | ||
| @@ -43,13 +42,6 @@ type ConfigReloadPayload struct { | |||||
| 43 | UpdatedRoutes []string | 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 | // Context keys for domain-level concerns. | 45 | // Context keys for domain-level concerns. | ||
| 54 | type contextKey string | 46 | type contextKey string | ||
| 55 | 47 | ||||
M internal/domain/route.go +1 -1
| @@ -5,7 +5,7 @@ type Route struct { | |||||
| 5 | Domain string | 5 | Domain string | ||
| 6 | Image string | 6 | Image string | ||
| 7 | HTTPS bool | 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 | // ProxyTarget represents the destination for proxying requests. | 11 | // ProxyTarget represents the destination for proxying requests. | ||
M internal/testutils/fixtures/configs/basic.toml +0 -4
| @@ -18,10 +18,6 @@ auto_create = true | |||||
| 18 | prefix = "gordon" | 18 | prefix = "gordon" | ||
| 19 | preserve = true | 19 | preserve = true | ||
| 20 | 20 | ||||
| 21 | - | [env] | |||
| 22 | - | dir = "/tmp/env" | |||
| 23 | - | providers = ["pass", "sops"] | |||
| 24 | - | ||||
| 25 | [logging] | 21 | [logging] | ||
| 26 | enabled = true | 22 | enabled = true | ||
| 27 | level = "info"⏎̸ | 23 | level = "info"⏎̸ | ||
M internal/testutils/helpers.go +0 -4
| @@ -59,10 +59,6 @@ auto_create = true | |||||
| 59 | prefix = "gordon" | 59 | prefix = "gordon" | ||
| 60 | preserve = true | 60 | preserve = true | ||
| 61 | 61 | ||||
| 62 | - | [env] | |||
| 63 | - | dir = "/tmp/env" | |||
| 64 | - | providers = ["pass", "sops"] | |||
| 65 | - | ||||
| 66 | [logging] | 62 | [logging] | ||
| 67 | enabled = true | 63 | enabled = true | ||
| 68 | level = "info"` | 64 | level = "info"` | ||
M internal/usecase/container/service.go +0 -3
| @@ -29,7 +29,6 @@ type Config struct { | |||||
| 29 | // Service implements the ContainerService interface. | 29 | // Service implements the ContainerService interface. | ||
| 30 | type Service struct { | 30 | type Service struct { | ||
| 31 | runtime out.ContainerRuntime | 31 | runtime out.ContainerRuntime | ||
| 32 | - | envLoader out.EnvLoader | |||
| 33 | eventBus out.EventPublisher | 32 | eventBus out.EventPublisher | ||
| 34 | logWriter out.ContainerLogWriter | 33 | logWriter out.ContainerLogWriter | ||
| 35 | config Config | 34 | config Config | ||
| @@ -39,14 +38,12 @@ type Service struct { | |||||
| 39 | // NewService creates a new container service. | 38 | // NewService creates a new container service. | ||
| 40 | func NewService( | 39 | func NewService( | ||
| 41 | runtime out.ContainerRuntime, | 40 | runtime out.ContainerRuntime, | ||
| 42 | - | envLoader out.EnvLoader, | |||
| 43 | eventBus out.EventPublisher, | 41 | eventBus out.EventPublisher, | ||
| 44 | logWriter out.ContainerLogWriter, | 42 | logWriter out.ContainerLogWriter, | ||
| 45 | config Config, | 43 | config Config, | ||
| 46 | ) *Service { | 44 | ) *Service { | ||
| 47 | return &Service{ | 45 | return &Service{ | ||
| 48 | runtime: runtime, | 46 | runtime: runtime, | ||
| 49 | - | envLoader: envLoader, | |||
| 50 | eventBus: eventBus, | 47 | eventBus: eventBus, | ||
| 51 | logWriter: logWriter, | 48 | logWriter: logWriter, | ||
| 52 | config: config, | 49 | config: config, | ||
M internal/usecase/container/service_test.go +1 -1
| @@ -18,7 +18,7 @@ func testContext() context.Context { | |||||
| 18 | 18 | ||||
| 19 | func TestService_ListNetworks(t *testing.T) { | 19 | func TestService_ListNetworks(t *testing.T) { | ||
| 20 | runtime := mocks.NewMockContainerRuntime(t) | 20 | runtime := mocks.NewMockContainerRuntime(t) | ||
| 21 | - | svc := NewService(runtime, nil, nil, nil, Config{NetworkPrefix: "gordon"}) | 21 | + | svc := NewService(runtime, nil, nil, Config{NetworkPrefix: "gordon"}) |
| 22 | ctx := testContext() | 22 | ctx := testContext() | ||
| 23 | 23 | ||||
| 24 | runtime.EXPECT().ListNetworks(mock.Anything).Return([]*domain.NetworkInfo{ | 24 | runtime.EXPECT().ListNetworks(mock.Anything).Return([]*domain.NetworkInfo{ | ||
D internal/usecase/secrets/service.go +0 -197
| @@ -1,197 +0,0 @@ | |||||
| 1 | - | // Package secrets implements the secret management use case. | |||
| 2 | - | package secrets | |||
| 3 | - | ||||
| 4 | - | import ( | |||
| 5 | - | "context" | |||
| 6 | - | "errors" | |||
| 7 | - | "sort" | |||
| 8 | - | "strings" | |||
| 9 | - | ||||
| 10 | - | "github.com/bnema/zerowrap" | |||
| 11 | - | ||||
| 12 | - | "github.com/bnema/gordon/internal/boundaries/out" | |||
| 13 | - | "github.com/bnema/gordon/internal/domain" | |||
| 14 | - | ) | |||
| 15 | - | ||||
| 16 | - | // Domain validation errors. | |||
| 17 | - | var ( | |||
| 18 | - | ErrDomainEmpty = errors.New("domain cannot be empty") | |||
| 19 | - | ErrDomainTooLong = errors.New("domain exceeds maximum length of 253 characters") | |||
| 20 | - | ErrDomainPathTraversal = errors.New("domain contains path traversal sequence") | |||
| 21 | - | ErrDomainInvalidChars = errors.New("domain contains invalid characters") | |||
| 22 | - | ) | |||
| 23 | - | ||||
| 24 | - | // Service implements the SecretService interface. | |||
| 25 | - | type Service struct { | |||
| 26 | - | store out.DomainSecretStore | |||
| 27 | - | log zerowrap.Logger | |||
| 28 | - | eventBus out.EventPublisher | |||
| 29 | - | } | |||
| 30 | - | ||||
| 31 | - | // NewService creates a new secrets service. | |||
| 32 | - | func NewService(store out.DomainSecretStore, log zerowrap.Logger, eventBus out.EventPublisher) *Service { | |||
| 33 | - | return &Service{ | |||
| 34 | - | store: store, | |||
| 35 | - | log: log, | |||
| 36 | - | eventBus: eventBus, | |||
| 37 | - | } | |||
| 38 | - | } | |||
| 39 | - | ||||
| 40 | - | // publishSecretsChanged publishes a secrets.changed event. | |||
| 41 | - | // Failures are logged but do not fail the mutation - secret storage | |||
| 42 | - | // is the source of truth; the event is a best-effort notification. | |||
| 43 | - | func (s *Service) publishSecretsChanged(ctx context.Context, domainName, operation string, keys []string) { | |||
| 44 | - | if s.eventBus == nil { | |||
| 45 | - | return | |||
| 46 | - | } | |||
| 47 | - | log := zerowrap.FromCtx(ctx) | |||
| 48 | - | payload := domain.SecretsChangedPayload{ | |||
| 49 | - | Domain: domainName, | |||
| 50 | - | Operation: operation, | |||
| 51 | - | Keys: keys, | |||
| 52 | - | } | |||
| 53 | - | if err := s.eventBus.Publish(domain.EventSecretsChanged, payload); err != nil { | |||
| 54 | - | log.Warn().Err(err).Str("domain", domainName).Msg("failed to publish secrets.changed event") | |||
| 55 | - | } | |||
| 56 | - | } | |||
| 57 | - | ||||
| 58 | - | // sortedKeys returns the keys of a map in sorted order. | |||
| 59 | - | func sortedKeys(m map[string]string) []string { | |||
| 60 | - | keys := make([]string, 0, len(m)) | |||
| 61 | - | for k := range m { | |||
| 62 | - | keys = append(keys, k) | |||
| 63 | - | } | |||
| 64 | - | sort.Strings(keys) | |||
| 65 | - | return keys | |||
| 66 | - | } | |||
| 67 | - | ||||
| 68 | - | // ListKeys returns the list of secret keys for a domain (not values). | |||
| 69 | - | func (s *Service) ListKeys(ctx context.Context, domain string) ([]string, error) { | |||
| 70 | - | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |||
| 71 | - | zerowrap.FieldLayer: "usecase", | |||
| 72 | - | zerowrap.FieldUseCase: "ListKeys", | |||
| 73 | - | "domain": domain, | |||
| 74 | - | }) | |||
| 75 | - | log := zerowrap.FromCtx(ctx) | |||
| 76 | - | ||||
| 77 | - | if err := ValidateDomain(domain); err != nil { | |||
| 78 | - | log.Warn().Err(err).Msg("domain validation failed") | |||
| 79 | - | return nil, err | |||
| 80 | - | } | |||
| 81 | - | ||||
| 82 | - | keys, err := s.store.ListKeys(domain) | |||
| 83 | - | if err != nil { | |||
| 84 | - | log.Error().Err(err).Msg("failed to list secret keys") | |||
| 85 | - | return nil, err | |||
| 86 | - | } | |||
| 87 | - | ||||
| 88 | - | log.Debug().Int("count", len(keys)).Msg("listed secret keys") | |||
| 89 | - | return keys, nil | |||
| 90 | - | } | |||
| 91 | - | ||||
| 92 | - | // GetAll returns all secrets for a domain as a key-value map. | |||
| 93 | - | func (s *Service) GetAll(ctx context.Context, domain string) (map[string]string, error) { | |||
| 94 | - | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |||
| 95 | - | zerowrap.FieldLayer: "usecase", | |||
| 96 | - | zerowrap.FieldUseCase: "GetAll", | |||
| 97 | - | "domain": domain, | |||
| 98 | - | }) | |||
| 99 | - | log := zerowrap.FromCtx(ctx) | |||
| 100 | - | ||||
| 101 | - | if err := ValidateDomain(domain); err != nil { | |||
| 102 | - | log.Warn().Err(err).Msg("domain validation failed") | |||
| 103 | - | return nil, err | |||
| 104 | - | } | |||
| 105 | - | ||||
| 106 | - | secrets, err := s.store.GetAll(domain) | |||
| 107 | - | if err != nil { | |||
| 108 | - | log.Error().Err(err).Msg("failed to get secrets") | |||
| 109 | - | return nil, err | |||
| 110 | - | } | |||
| 111 | - | ||||
| 112 | - | log.Debug().Int("count", len(secrets)).Msg("retrieved secrets") | |||
| 113 | - | return secrets, nil | |||
| 114 | - | } | |||
| 115 | - | ||||
| 116 | - | // Set sets or updates multiple secrets for a domain, merging with existing. | |||
| 117 | - | func (s *Service) Set(ctx context.Context, domain string, secrets map[string]string) error { | |||
| 118 | - | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |||
| 119 | - | zerowrap.FieldLayer: "usecase", | |||
| 120 | - | zerowrap.FieldUseCase: "Set", | |||
| 121 | - | "domain": domain, | |||
| 122 | - | }) | |||
| 123 | - | log := zerowrap.FromCtx(ctx) | |||
| 124 | - | ||||
| 125 | - | if err := ValidateDomain(domain); err != nil { | |||
| 126 | - | log.Warn().Err(err).Msg("domain validation failed") | |||
| 127 | - | return err | |||
| 128 | - | } | |||
| 129 | - | ||||
| 130 | - | if err := s.store.Set(domain, secrets); err != nil { | |||
| 131 | - | log.Error().Err(err).Msg("failed to set secrets") | |||
| 132 | - | return err | |||
| 133 | - | } | |||
| 134 | - | ||||
| 135 | - | s.publishSecretsChanged(ctx, domain, "set", sortedKeys(secrets)) | |||
| 136 | - | ||||
| 137 | - | log.Info().Int("count", len(secrets)).Msg("secrets set") | |||
| 138 | - | return nil | |||
| 139 | - | } | |||
| 140 | - | ||||
| 141 | - | // Delete removes a specific secret key from a domain. | |||
| 142 | - | func (s *Service) Delete(ctx context.Context, domain, key string) error { | |||
| 143 | - | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |||
| 144 | - | zerowrap.FieldLayer: "usecase", | |||
| 145 | - | zerowrap.FieldUseCase: "Delete", | |||
| 146 | - | "domain": domain, | |||
| 147 | - | "key": key, | |||
| 148 | - | }) | |||
| 149 | - | log := zerowrap.FromCtx(ctx) | |||
| 150 | - | ||||
| 151 | - | if err := ValidateDomain(domain); err != nil { | |||
| 152 | - | log.Warn().Err(err).Msg("domain validation failed") | |||
| 153 | - | return err | |||
| 154 | - | } | |||
| 155 | - | ||||
| 156 | - | if err := s.store.Delete(domain, key); err != nil { | |||
| 157 | - | log.Error().Err(err).Msg("failed to delete secret") | |||
| 158 | - | return err | |||
| 159 | - | } | |||
| 160 | - | ||||
| 161 | - | s.publishSecretsChanged(ctx, domain, "delete", []string{key}) | |||
| 162 | - | ||||
| 163 | - | log.Info().Msg("secret deleted") | |||
| 164 | - | return nil | |||
| 165 | - | } | |||
| 166 | - | ||||
| 167 | - | // ValidateDomain validates that a domain is safe to use for secret storage. | |||
| 168 | - | // This prevents path traversal attacks and ensures the domain is valid. | |||
| 169 | - | func ValidateDomain(domainName string) error { | |||
| 170 | - | // Check for empty domain | |||
| 171 | - | if domainName == "" { | |||
| 172 | - | return ErrDomainEmpty | |||
| 173 | - | } | |||
| 174 | - | ||||
| 175 | - | // Check domain length (max DNS name length is 253) | |||
| 176 | - | if len(domainName) > 253 { | |||
| 177 | - | return ErrDomainTooLong | |||
| 178 | - | } | |||
| 179 | - | ||||
| 180 | - | // Check for path traversal attempts | |||
| 181 | - | if strings.Contains(domainName, "..") { | |||
| 182 | - | return ErrDomainPathTraversal | |||
| 183 | - | } | |||
| 184 | - | ||||
| 185 | - | // Check for null bytes (can cause issues in file paths) | |||
| 186 | - | if strings.ContainsRune(domainName, '\x00') { | |||
| 187 | - | return ErrDomainInvalidChars | |||
| 188 | - | } | |||
| 189 | - | ||||
| 190 | - | // Reuse the env-file sanitizer so validation matches the on-disk naming | |||
| 191 | - | // scheme and rejects ambiguous names that would collide after sanitization. | |||
| 192 | - | if _, err := domain.SanitizeDomainForEnvFile(domainName); err != nil { | |||
| 193 | - | return ErrDomainInvalidChars | |||
| 194 | - | } | |||
| 195 | - | ||||
| 196 | - | return nil | |||
| 197 | - | } | |||
D internal/usecase/secrets/service_test.go +0 -435
| @@ -1,435 +0,0 @@ | |||||
| 1 | - | package secrets | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "context" | |||
| 5 | - | "strings" | |||
| 6 | - | "testing" | |||
| 7 | - | ||||
| 8 | - | "github.com/bnema/zerowrap" | |||
| 9 | - | "github.com/stretchr/testify/assert" | |||
| 10 | - | "github.com/stretchr/testify/mock" | |||
| 11 | - | ||||
| 12 | - | outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks" | |||
| 13 | - | "github.com/bnema/gordon/internal/domain" | |||
| 14 | - | ) | |||
| 15 | - | ||||
| 16 | - | func testLogger() zerowrap.Logger { | |||
| 17 | - | return zerowrap.Default() | |||
| 18 | - | } | |||
| 19 | - | ||||
| 20 | - | func TestValidateDomain(t *testing.T) { | |||
| 21 | - | tests := []struct { | |||
| 22 | - | name string | |||
| 23 | - | domain string | |||
| 24 | - | wantErr error | |||
| 25 | - | }{ | |||
| 26 | - | // Valid domains | |||
| 27 | - | { | |||
| 28 | - | name: "valid simple domain", | |||
| 29 | - | domain: "example.com", | |||
| 30 | - | wantErr: nil, | |||
| 31 | - | }, | |||
| 32 | - | { | |||
| 33 | - | name: "valid subdomain", | |||
| 34 | - | domain: "app.example.com", | |||
| 35 | - | wantErr: nil, | |||
| 36 | - | }, | |||
| 37 | - | { | |||
| 38 | - | name: "valid domain with port", | |||
| 39 | - | domain: "app.example.com:8080", | |||
| 40 | - | wantErr: nil, | |||
| 41 | - | }, | |||
| 42 | - | { | |||
| 43 | - | name: "valid domain with hyphens", | |||
| 44 | - | domain: "my-app.example-site.com", | |||
| 45 | - | wantErr: nil, | |||
| 46 | - | }, | |||
| 47 | - | { | |||
| 48 | - | name: "valid single label", | |||
| 49 | - | domain: "localhost", | |||
| 50 | - | wantErr: nil, | |||
| 51 | - | }, | |||
| 52 | - | // Empty domain | |||
| 53 | - | { | |||
| 54 | - | name: "empty domain", | |||
| 55 | - | domain: "", | |||
| 56 | - | wantErr: ErrDomainEmpty, | |||
| 57 | - | }, | |||
| 58 | - | // Path traversal attempts | |||
| 59 | - | { | |||
| 60 | - | name: "path traversal with double dots", | |||
| 61 | - | domain: "../etc/passwd", | |||
| 62 | - | wantErr: ErrDomainPathTraversal, | |||
| 63 | - | }, | |||
| 64 | - | { | |||
| 65 | - | name: "path traversal in middle", | |||
| 66 | - | domain: "app/../etc/passwd", | |||
| 67 | - | wantErr: ErrDomainPathTraversal, | |||
| 68 | - | }, | |||
| 69 | - | { | |||
| 70 | - | name: "path traversal at end", | |||
| 71 | - | domain: "app.example.com/..", | |||
| 72 | - | wantErr: ErrDomainPathTraversal, | |||
| 73 | - | }, | |||
| 74 | - | { | |||
| 75 | - | name: "multiple path traversal", | |||
| 76 | - | domain: "..../....//etc/passwd", | |||
| 77 | - | wantErr: ErrDomainPathTraversal, | |||
| 78 | - | }, | |||
| 79 | - | { | |||
| 80 | - | name: "encoded path traversal still blocked", | |||
| 81 | - | domain: "app..example.com", | |||
| 82 | - | wantErr: ErrDomainPathTraversal, | |||
| 83 | - | }, | |||
| 84 | - | // Domain too long | |||
| 85 | - | { | |||
| 86 | - | name: "domain at max length (253)", | |||
| 87 | - | domain: strings.Repeat("a", 253), | |||
| 88 | - | wantErr: nil, | |||
| 89 | - | }, | |||
| 90 | - | { | |||
| 91 | - | name: "domain exceeds max length", | |||
| 92 | - | domain: strings.Repeat("a", 254), | |||
| 93 | - | wantErr: ErrDomainTooLong, | |||
| 94 | - | }, | |||
| 95 | - | { | |||
| 96 | - | name: "domain way too long", | |||
| 97 | - | domain: strings.Repeat("a", 1000), | |||
| 98 | - | wantErr: ErrDomainTooLong, | |||
| 99 | - | }, | |||
| 100 | - | // Invalid characters | |||
| 101 | - | { | |||
| 102 | - | name: "null byte in domain", | |||
| 103 | - | domain: "app\x00.example.com", | |||
| 104 | - | wantErr: ErrDomainInvalidChars, | |||
| 105 | - | }, | |||
| 106 | - | { | |||
| 107 | - | name: "null byte at start", | |||
| 108 | - | domain: "\x00example.com", | |||
| 109 | - | wantErr: ErrDomainInvalidChars, | |||
| 110 | - | }, | |||
| 111 | - | { | |||
| 112 | - | name: "underscore rejected to avoid env filename collision", | |||
| 113 | - | domain: "app_example.com", | |||
| 114 | - | wantErr: ErrDomainInvalidChars, | |||
| 115 | - | }, | |||
| 116 | - | } | |||
| 117 | - | ||||
| 118 | - | for _, tt := range tests { | |||
| 119 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 120 | - | err := ValidateDomain(tt.domain) | |||
| 121 | - | if tt.wantErr != nil { | |||
| 122 | - | assert.ErrorIs(t, err, tt.wantErr) | |||
| 123 | - | } else { | |||
| 124 | - | assert.NoError(t, err) | |||
| 125 | - | } | |||
| 126 | - | }) | |||
| 127 | - | } | |||
| 128 | - | } | |||
| 129 | - | ||||
| 130 | - | func TestValidateDomain_DistinguishesSeparatorsForEnvStorage(t *testing.T) { | |||
| 131 | - | for _, domain := range []string{"app.example.com", "app:example:com", "app/example/com"} { | |||
| 132 | - | assert.NoError(t, ValidateDomain(domain), "expected %q to remain valid", domain) | |||
| 133 | - | } | |||
| 134 | - | } | |||
| 135 | - | ||||
| 136 | - | func TestService_ListKeys_ValidationErrors(t *testing.T) { | |||
| 137 | - | store := outmocks.NewMockDomainSecretStore(t) | |||
| 138 | - | svc := NewService(store, testLogger(), nil) | |||
| 139 | - | ||||
| 140 | - | tests := []struct { | |||
| 141 | - | name string | |||
| 142 | - | domain string | |||
| 143 | - | wantErr error | |||
| 144 | - | }{ | |||
| 145 | - | { | |||
| 146 | - | name: "empty domain", | |||
| 147 | - | domain: "", | |||
| 148 | - | wantErr: ErrDomainEmpty, | |||
| 149 | - | }, | |||
| 150 | - | { | |||
| 151 | - | name: "path traversal", | |||
| 152 | - | domain: "../etc/passwd", | |||
| 153 | - | wantErr: ErrDomainPathTraversal, | |||
| 154 | - | }, | |||
| 155 | - | { | |||
| 156 | - | name: "domain too long", | |||
| 157 | - | domain: strings.Repeat("a", 300), | |||
| 158 | - | wantErr: ErrDomainTooLong, | |||
| 159 | - | }, | |||
| 160 | - | } | |||
| 161 | - | ||||
| 162 | - | for _, tt := range tests { | |||
| 163 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 164 | - | keys, err := svc.ListKeys(context.Background(), tt.domain) | |||
| 165 | - | assert.Nil(t, keys) | |||
| 166 | - | assert.ErrorIs(t, err, tt.wantErr) | |||
| 167 | - | }) | |||
| 168 | - | } | |||
| 169 | - | } | |||
| 170 | - | ||||
| 171 | - | func TestService_ListKeys_Success(t *testing.T) { | |||
| 172 | - | store := outmocks.NewMockDomainSecretStore(t) | |||
| 173 | - | svc := NewService(store, testLogger(), nil) | |||
| 174 | - | ||||
| 175 | - | expectedKeys := []string{"API_KEY", "DB_PASSWORD"} | |||
| 176 | - | store.EXPECT().ListKeys("app.example.com").Return(expectedKeys, nil) | |||
| 177 | - | ||||
| 178 | - | keys, err := svc.ListKeys(context.Background(), "app.example.com") | |||
| 179 | - | assert.NoError(t, err) | |||
| 180 | - | assert.Equal(t, expectedKeys, keys) | |||
| 181 | - | } | |||
| 182 | - | ||||
| 183 | - | func TestService_GetAll_ValidationErrors(t *testing.T) { | |||
| 184 | - | store := outmocks.NewMockDomainSecretStore(t) | |||
| 185 | - | svc := NewService(store, testLogger(), nil) | |||
| 186 | - | ||||
| 187 | - | tests := []struct { | |||
| 188 | - | name string | |||
| 189 | - | domain string | |||
| 190 | - | wantErr error | |||
| 191 | - | }{ | |||
| 192 | - | { | |||
| 193 | - | name: "empty domain", | |||
| 194 | - | domain: "", | |||
| 195 | - | wantErr: ErrDomainEmpty, | |||
| 196 | - | }, | |||
| 197 | - | { | |||
| 198 | - | name: "path traversal", | |||
| 199 | - | domain: "app/../secret", | |||
| 200 | - | wantErr: ErrDomainPathTraversal, | |||
| 201 | - | }, | |||
| 202 | - | } | |||
| 203 | - | ||||
| 204 | - | for _, tt := range tests { | |||
| 205 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 206 | - | secrets, err := svc.GetAll(context.Background(), tt.domain) | |||
| 207 | - | assert.Nil(t, secrets) | |||
| 208 | - | assert.ErrorIs(t, err, tt.wantErr) | |||
| 209 | - | }) | |||
| 210 | - | } | |||
| 211 | - | } | |||
| 212 | - | ||||
| 213 | - | func TestService_GetAll_Success(t *testing.T) { | |||
| 214 | - | store := outmocks.NewMockDomainSecretStore(t) | |||
| 215 | - | svc := NewService(store, testLogger(), nil) | |||
| 216 | - | ||||
| 217 | - | expectedSecrets := map[string]string{"API_KEY": "secret123", "DB_PASSWORD": "pass456"} | |||
| 218 | - | store.EXPECT().GetAll("app.example.com").Return(expectedSecrets, nil) | |||
| 219 | - | ||||
| 220 | - | secrets, err := svc.GetAll(context.Background(), "app.example.com") | |||
| 221 | - | assert.NoError(t, err) | |||
| 222 | - | assert.Equal(t, expectedSecrets, secrets) | |||
| 223 | - | } | |||
| 224 | - | ||||
| 225 | - | func TestService_Set_ValidationErrors(t *testing.T) { | |||
| 226 | - | store := outmocks.NewMockDomainSecretStore(t) | |||
| 227 | - | svc := NewService(store, testLogger(), nil) | |||
| 228 | - | ||||
| 229 | - | secrets := map[string]string{"API_KEY": "secret123"} | |||
| 230 | - | ||||
| 231 | - | tests := []struct { | |||
| 232 | - | name string | |||
| 233 | - | domain string | |||
| 234 | - | wantErr error | |||
| 235 | - | }{ | |||
| 236 | - | { | |||
| 237 | - | name: "empty domain", | |||
| 238 | - | domain: "", | |||
| 239 | - | wantErr: ErrDomainEmpty, | |||
| 240 | - | }, | |||
| 241 | - | { | |||
| 242 | - | name: "path traversal", | |||
| 243 | - | domain: "../../../etc/passwd", | |||
| 244 | - | wantErr: ErrDomainPathTraversal, | |||
| 245 | - | }, | |||
| 246 | - | } | |||
| 247 | - | ||||
| 248 | - | for _, tt := range tests { | |||
| 249 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 250 | - | err := svc.Set(context.Background(), tt.domain, secrets) | |||
| 251 | - | assert.ErrorIs(t, err, tt.wantErr) | |||
| 252 | - | }) | |||
| 253 | - | } | |||
| 254 | - | } | |||
| 255 | - | ||||
| 256 | - | func TestService_Set_Success(t *testing.T) { | |||
| 257 | - | store := outmocks.NewMockDomainSecretStore(t) | |||
| 258 | - | eventBus := outmocks.NewMockEventPublisher(t) | |||
| 259 | - | svc := NewService(store, testLogger(), eventBus) | |||
| 260 | - | ||||
| 261 | - | secrets := map[string]string{"API_KEY": "secret123"} | |||
| 262 | - | store.EXPECT().Set("app.example.com", secrets).Return(nil) | |||
| 263 | - | eventBus.EXPECT().Publish(domain.EventSecretsChanged, mock.AnythingOfType("domain.SecretsChangedPayload")).Return(nil) | |||
| 264 | - | ||||
| 265 | - | err := svc.Set(context.Background(), "app.example.com", secrets) | |||
| 266 | - | assert.NoError(t, err) | |||
| 267 | - | } | |||
| 268 | - | ||||
| 269 | - | func TestService_Set_PublishesSecretsChangedEvent(t *testing.T) { | |||
| 270 | - | store := outmocks.NewMockDomainSecretStore(t) | |||
| 271 | - | eventBus := outmocks.NewMockEventPublisher(t) | |||
| 272 | - | svc := NewService(store, testLogger(), eventBus) | |||
| 273 | - | ||||
| 274 | - | secrets := map[string]string{"API_KEY": "secret123", "DB_PASSWORD": "pass456"} | |||
| 275 | - | store.EXPECT().Set("app.example.com", secrets).Return(nil) | |||
| 276 | - | eventBus.EXPECT().Publish(domain.EventSecretsChanged, mock.MatchedBy(func(payload domain.SecretsChangedPayload) bool { | |||
| 277 | - | assert.Equal(t, "app.example.com", payload.Domain) | |||
| 278 | - | assert.Equal(t, "set", payload.Operation) | |||
| 279 | - | assert.ElementsMatch(t, []string{"API_KEY", "DB_PASSWORD"}, payload.Keys) | |||
| 280 | - | return true | |||
| 281 | - | })).Return(nil) | |||
| 282 | - | ||||
| 283 | - | err := svc.Set(context.Background(), "app.example.com", secrets) | |||
| 284 | - | assert.NoError(t, err) | |||
| 285 | - | } | |||
| 286 | - | ||||
| 287 | - | func TestService_Delete_ValidationErrors(t *testing.T) { | |||
| 288 | - | store := outmocks.NewMockDomainSecretStore(t) | |||
| 289 | - | svc := NewService(store, testLogger(), nil) | |||
| 290 | - | ||||
| 291 | - | tests := []struct { | |||
| 292 | - | name string | |||
| 293 | - | domain string | |||
| 294 | - | wantErr error | |||
| 295 | - | }{ | |||
| 296 | - | { | |||
| 297 | - | name: "empty domain", | |||
| 298 | - | domain: "", | |||
| 299 | - | wantErr: ErrDomainEmpty, | |||
| 300 | - | }, | |||
| 301 | - | { | |||
| 302 | - | name: "path traversal", | |||
| 303 | - | domain: "..%2F..%2Fetc%2Fpasswd", | |||
| 304 | - | wantErr: ErrDomainPathTraversal, | |||
| 305 | - | }, | |||
| 306 | - | } | |||
| 307 | - | ||||
| 308 | - | for _, tt := range tests { | |||
| 309 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 310 | - | err := svc.Delete(context.Background(), tt.domain, "API_KEY") | |||
| 311 | - | assert.ErrorIs(t, err, tt.wantErr) | |||
| 312 | - | }) | |||
| 313 | - | } | |||
| 314 | - | } | |||
| 315 | - | ||||
| 316 | - | func TestService_Delete_Success(t *testing.T) { | |||
| 317 | - | store := outmocks.NewMockDomainSecretStore(t) | |||
| 318 | - | eventBus := outmocks.NewMockEventPublisher(t) | |||
| 319 | - | svc := NewService(store, testLogger(), eventBus) | |||
| 320 | - | ||||
| 321 | - | store.EXPECT().Delete("app.example.com", "API_KEY").Return(nil) | |||
| 322 | - | eventBus.EXPECT().Publish(domain.EventSecretsChanged, mock.AnythingOfType("domain.SecretsChangedPayload")).Return(nil) | |||
| 323 | - | ||||
| 324 | - | err := svc.Delete(context.Background(), "app.example.com", "API_KEY") | |||
| 325 | - | assert.NoError(t, err) | |||
| 326 | - | } | |||
| 327 | - | ||||
| 328 | - | func TestService_Delete_PublishesSecretsChangedEvent(t *testing.T) { | |||
| 329 | - | store := outmocks.NewMockDomainSecretStore(t) | |||
| 330 | - | eventBus := outmocks.NewMockEventPublisher(t) | |||
| 331 | - | svc := NewService(store, testLogger(), eventBus) | |||
| 332 | - | ||||
| 333 | - | store.EXPECT().Delete("app.example.com", "API_KEY").Return(nil) | |||
| 334 | - | eventBus.EXPECT().Publish(domain.EventSecretsChanged, mock.MatchedBy(func(payload domain.SecretsChangedPayload) bool { | |||
| 335 | - | assert.Equal(t, "app.example.com", payload.Domain) | |||
| 336 | - | assert.Equal(t, "delete", payload.Operation) | |||
| 337 | - | assert.Equal(t, []string{"API_KEY"}, payload.Keys) | |||
| 338 | - | return true | |||
| 339 | - | })).Return(nil) | |||
| 340 | - | ||||
| 341 | - | err := svc.Delete(context.Background(), "app.example.com", "API_KEY") | |||
| 342 | - | assert.NoError(t, err) | |||
| 343 | - | } | |||
| 344 | - | ||||
| 345 | - | func TestService_Set_SucceedsEvenIfEventPublishFails(t *testing.T) { | |||
| 346 | - | store := outmocks.NewMockDomainSecretStore(t) | |||
| 347 | - | eventBus := outmocks.NewMockEventPublisher(t) | |||
| 348 | - | svc := NewService(store, testLogger(), eventBus) | |||
| 349 | - | ||||
| 350 | - | secrets := map[string]string{"API_KEY": "secret123"} | |||
| 351 | - | store.EXPECT().Set("app.example.com", secrets).Return(nil) | |||
| 352 | - | eventBus.EXPECT().Publish(domain.EventSecretsChanged, mock.AnythingOfType("domain.SecretsChangedPayload")).Return(assert.AnError) | |||
| 353 | - | ||||
| 354 | - | err := svc.Set(context.Background(), "app.example.com", secrets) | |||
| 355 | - | assert.NoError(t, err) | |||
| 356 | - | } | |||
| 357 | - | ||||
| 358 | - | func TestService_NilEventPublisher_DoesNotPanic(t *testing.T) { | |||
| 359 | - | store := outmocks.NewMockDomainSecretStore(t) | |||
| 360 | - | svc := NewService(store, testLogger(), nil) | |||
| 361 | - | ||||
| 362 | - | secrets := map[string]string{"API_KEY": "secret123"} | |||
| 363 | - | store.EXPECT().Set("app.example.com", secrets).Return(nil) | |||
| 364 | - | ||||
| 365 | - | assert.NotPanics(t, func() { | |||
| 366 | - | err := svc.Set(context.Background(), "app.example.com", secrets) | |||
| 367 | - | assert.NoError(t, err) | |||
| 368 | - | }) | |||
| 369 | - | } | |||
| 370 | - | ||||
| 371 | - | func TestService_StoreError_PropagatedCorrectly(t *testing.T) { | |||
| 372 | - | store := outmocks.NewMockDomainSecretStore(t) | |||
| 373 | - | svc := NewService(store, testLogger(), nil) | |||
| 374 | - | ||||
| 375 | - | storeErr := assert.AnError | |||
| 376 | - | store.EXPECT().ListKeys("app.example.com").Return(nil, storeErr) | |||
| 377 | - | ||||
| 378 | - | keys, err := svc.ListKeys(context.Background(), "app.example.com") | |||
| 379 | - | assert.Nil(t, keys) | |||
| 380 | - | assert.ErrorIs(t, err, storeErr) | |||
| 381 | - | } | |||
| 382 | - | ||||
| 383 | - | // TestPathTraversalVariants tests various path traversal attack patterns. | |||
| 384 | - | func TestPathTraversalVariants(t *testing.T) { | |||
| 385 | - | tests := []struct { | |||
| 386 | - | name string | |||
| 387 | - | domain string | |||
| 388 | - | }{ | |||
| 389 | - | {"simple parent dir", ".."}, | |||
| 390 | - | {"parent dir prefix", "../secret"}, | |||
| 391 | - | {"parent dir suffix", "secret/.."}, | |||
| 392 | - | {"parent dir in middle", "app/../secret"}, | |||
| 393 | - | {"multiple parent dirs", "../../etc/passwd"}, | |||
| 394 | - | {"windows style", "..\\..\\etc\\passwd"}, | |||
| 395 | - | {"mixed slashes with dots", "app/./../../etc"}, | |||
| 396 | - | {"double dots only", "...."}, | |||
| 397 | - | {"triple dots", "..."}, | |||
| 398 | - | {"dot dot in subdomain style", "app..evil.com"}, | |||
| 399 | - | } | |||
| 400 | - | ||||
| 401 | - | for _, tt := range tests { | |||
| 402 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 403 | - | err := ValidateDomain(tt.domain) | |||
| 404 | - | // All these should be rejected (either path traversal or other validation) | |||
| 405 | - | // The main goal is that ".." patterns are caught | |||
| 406 | - | if strings.Contains(tt.domain, "..") { | |||
| 407 | - | assert.ErrorIs(t, err, ErrDomainPathTraversal, "domain %q should be rejected for path traversal", tt.domain) | |||
| 408 | - | } | |||
| 409 | - | }) | |||
| 410 | - | } | |||
| 411 | - | } | |||
| 412 | - | ||||
| 413 | - | // TestService_ValidationHappensBeforeStore ensures validation runs before any store calls. | |||
| 414 | - | func TestService_ValidationHappensBeforeStore(t *testing.T) { | |||
| 415 | - | store := outmocks.NewMockDomainSecretStore(t) | |||
| 416 | - | svc := NewService(store, testLogger(), nil) | |||
| 417 | - | ||||
| 418 | - | // With path traversal domain, store methods should never be called | |||
| 419 | - | // We don't set up any expectations, so if store is called, the test will fail | |||
| 420 | - | ||||
| 421 | - | _, err := svc.ListKeys(context.Background(), "../etc/passwd") | |||
| 422 | - | assert.ErrorIs(t, err, ErrDomainPathTraversal) | |||
| 423 | - | ||||
| 424 | - | _, err = svc.GetAll(context.Background(), "../etc/passwd") | |||
| 425 | - | assert.ErrorIs(t, err, ErrDomainPathTraversal) | |||
| 426 | - | ||||
| 427 | - | err = svc.Set(context.Background(), "../etc/passwd", map[string]string{"key": "value"}) | |||
| 428 | - | assert.ErrorIs(t, err, ErrDomainPathTraversal) | |||
| 429 | - | ||||
| 430 | - | err = svc.Delete(context.Background(), "../etc/passwd", "key") | |||
| 431 | - | assert.ErrorIs(t, err, ErrDomainPathTraversal) | |||
| 432 | - | ||||
| 433 | - | // Verify no store methods were called (mockery would fail if unexpected calls happened) | |||
| 434 | - | mock.AssertExpectationsForObjects(t, store) | |||
| 435 | - | } | |||
M wiki/examples/production.md +7 -23
| @@ -40,10 +40,6 @@ max_age = 90 | |||||
| 40 | # Workload logs are read from the container runtime with | 40 | # Workload logs are read from the container runtime with | ||
| 41 | # `gordon apps logs APP --service SERVICE`. | 41 | # `gordon apps logs APP --service SERVICE`. | ||
| 42 | 42 | ||||
| 43 | - | # Environment directory | |||
| 44 | - | [env] | |||
| 45 | - | dir = "~/.gordon/env" | |||
| 46 | - | ||||
| 47 | # Volume settings (all enabled by default) | 43 | # Volume settings (all enabled by default) | ||
| 48 | [volumes] | 44 | [volumes] | ||
| 49 | auto_create = true | 45 | auto_create = true | ||
| @@ -82,26 +78,14 @@ openssl rand -base64 32 | pass insert -m gordon/auth/token_secret | |||||
| 82 | gordon auth token generate --subject ci-bot --scopes push,pull --expiry 0 | 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 | ```bash | 85 | ```bash | ||
| 88 | - | # App environment | 86 | + | gordon apps apply --file ./app.toml |
| 89 | - | cat > ~/.gordon/env/app_company_com.env <<EOF | 87 | + | pass show company/db-password \ |
| 90 | - | NODE_ENV=production | 88 | + | | gordon apps secrets set app --service web --stdin --key DATABASE_PASSWORD |
| 91 | - | PORT=3000 | |||
| 92 | - | DATABASE_URL=postgresql://company-postgres:5432/app | |||
| 93 | - | DATABASE_PASSWORD=\${pass:company/db-password} | |||
| 94 | - | REDIS_URL=redis://company-redis:6379 | |||
| 95 | - | EOF | |||
| 96 | - | ||||
| 97 | - | # API environment | |||
| 98 | - | cat > ~/.gordon/env/api_company_com.env <<EOF | |||
| 99 | - | NODE_ENV=production | |||
| 100 | - | PORT=8080 | |||
| 101 | - | DATABASE_URL=postgresql://company-postgres:5432/api | |||
| 102 | - | DATABASE_PASSWORD=\${pass:company/db-password} | |||
| 103 | - | REDIS_URL=redis://company-redis:6379 | |||
| 104 | - | EOF | |||
| 105 | ``` | 89 | ``` | ||
| 106 | 90 | ||||
| 107 | ### 5. Configure Cloudflare | 91 | ### 5. Configure Cloudflare | ||
| @@ -148,7 +132,7 @@ vim ~/.config/gordon/gordon.toml | |||||
| 148 | # Change: "app.company.com" = "company-app:v2.2.0" | 132 | # Change: "app.company.com" = "company-app:v2.2.0" | ||
| 149 | 133 | ||||
| 150 | # Reload to deploy | 134 | # Reload to deploy | ||
| 151 | - | gordon reload | 135 | + | gordon daemon reload |
| 152 | ``` | 136 | ``` | ||
| 153 | 137 | ||||
| 154 | ## Related | 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 | Verify connectivity: | 9 | Verify connectivity: | ||
| 10 | 10 | ||||
| 11 | ```bash | 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 | ## App management | 15 | ## App management | ||
| @@ -46,10 +46,10 @@ gordon backups volume status | |||||
| 46 | 46 | ||||
| 47 | ## Logs | 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 | ```bash | 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 | gordon apps logs APP --service SERVICE --remote https://gordon.example.com --token "$GORDON_TOKEN" | 53 | gordon apps logs APP --service SERVICE --remote https://gordon.example.com --token "$GORDON_TOKEN" | ||
| 54 | ``` | 54 | ``` | ||
| 55 | 55 | ||||
M wiki/guides/secrets-pass.md +6 -6
| @@ -106,17 +106,17 @@ token_secret = "gordon/auth/token_secret" | |||||
| 106 | # password_hash = "gordon/auth/password_hash" | 106 | # password_hash = "gordon/auth/password_hash" | ||
| 107 | ``` | 107 | ``` | ||
| 108 | 108 | ||||
| 109 | - | ### Using Route 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 | ```bash | 113 | ```bash | ||
| 114 | - | # Store secrets for a domain | 114 | + | gordon apps apply --file ./blog.toml |
| 115 | - | gordon secrets set app.mydomain.com DATABASE_URL "postgresql://user:pass@postgres:5432/app" | 115 | + | printf 'DATABASE_URL=%s\n' "$(pass show myapp/database-url)" \ |
| 116 | - | gordon secrets set app.mydomain.com API_KEY "your-api-key" | 116 | + | | gordon apps secrets set blog --service web --stdin |
| 117 | ``` | 117 | ``` | ||
| 118 | 118 | ||||
| 119 | - | Gordon migrates existing `.env` files on startup and renames them to `.env.migrated`. | 119 | + | See [App Manifest](/docs/config/apps.md) and [Apps CLI](/docs/cli/apps.md). |
| 120 | 120 | ||||
| 121 | ## Organizing Secrets | 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 | - `file` is the SOPS-encrypted file path | 208 | - `file` is the SOPS-encrypted file path | ||
| 209 | - `key.path` is dot-notation to the value | 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. | 211 | + | The SOPS backend is used for auth secrets such as `token_secret`. App secret values are managed with `gordon apps secrets` (see [Apps CLI](/docs/cli/apps.md)). |
| 212 | - | ||||
| 213 | - | ### Using Secrets in Environment Files | |||
| 214 | - | ||||
| 215 | - | Reference SOPS secrets in your app's environment files: | |||
| 216 | - | ||||
| 217 | - | ```bash | |||
| 218 | - | # ~/.gordon/env/app_mydomain_com.env | |||
| 219 | - | DATABASE_URL=postgresql://user:${sops:secrets.yaml:database.password}@postgres:5432/app | |||
| 220 | - | API_KEY=${sops:secrets.yaml:api.key} | |||
| 221 | - | ``` | |||
| 222 | 212 | ||||
| 223 | ## File Organization | 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 | Reload Gordon: | 87 | Reload Gordon: | ||
| 88 | 88 | ||||
| 89 | ```bash | 89 | ```bash | ||
| 90 | - | gordon reload | 90 | + | gordon daemon reload |
| 91 | ``` | 91 | ``` | ||
| 92 | 92 | ||||
| 93 | ### 5. Push to Deploy | 93 | ### 5. Push to Deploy | ||
| @@ -130,7 +130,7 @@ docker login -u deploy -p <token> registry.mydomain.com | |||||
| 130 | 130 | ||||
| 131 | 2. Check Gordon logs: | 131 | 2. Check Gordon logs: | ||
| 132 | ```bash | 132 | ```bash | ||
| 133 | - | gordon logs -f | 133 | + | gordon daemon logs -f |
| 134 | ``` | 134 | ``` | ||
| 135 | 135 | ||||
| 136 | 3. Ensure DNS points to your server | 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 | ## Next Steps | 160 | ## Next Steps | ||
| 161 | 161 | ||||
| 162 | - | - [Add environment variables](/docs/config/env.md) | 162 | + | - [Add environment variables](/docs/config/apps.md) |
| 163 | - [Add a database](./postgres-service.md) | 163 | - [Add a database](./postgres-service.md) | ||
| 164 | - [Set up CI/CD](/docs/deployment/github-actions.md) | 164 | - [Set up CI/CD](/docs/deployment/github-actions.md) | ||
M wiki/tutorials/postgres-service.md +1 -1
| @@ -56,7 +56,7 @@ enabled = true | |||||
| 56 | Reload Gordon: | 56 | Reload Gordon: | ||
| 57 | 57 | ||||
| 58 | ```bash | 58 | ```bash | ||
| 59 | - | gordon reload | 59 | + | gordon daemon reload |
| 60 | ``` | 60 | ``` | ||
| 61 | 61 | ||||
| 62 | ### 4. Configure Database Password | 62 | ### 4. Configure Database Password | ||