Merge pull request #32 from bnema/feat/v2.6.0-attachments-crud-auth-config
feat!: v2.6.0 - Unified auth config & attachments CRUD CLI
34 files · +3458 -980
A docs/cli/attachments.md +184 -0
| @@ -0,0 +1,184 @@ | |||||
| 1 | + | # Attachments Commands | |||
| 2 | + | ||||
| 3 | + | Manage attachments on local or remote Gordon instances. | |||
| 4 | + | ||||
| 5 | + | ## Requirements | |||
| 6 | + | ||||
| 7 | + | Attachments require `network_isolation.enabled = true` in your configuration. Without network isolation, containers use Docker's default bridge network which does not provide DNS resolution - your app won't be able to reach attachments by hostname (e.g., `postgres:5432`). | |||
| 8 | + | ||||
| 9 | + | The CLI will warn you if you try to add an attachment without network isolation enabled. | |||
| 10 | + | ||||
| 11 | + | ## gordon attachments | |||
| 12 | + | ||||
| 13 | + | ### Subcommands | |||
| 14 | + | ||||
| 15 | + | | Subcommand | Description | | |||
| 16 | + | |------------|-------------| | |||
| 17 | + | | `list` | List all attachments or attachments for a specific target | | |||
| 18 | + | | `add` | Add an attachment to a domain or network group | | |||
| 19 | + | | `remove` | Remove an attachment from a domain or network group | | |||
| 20 | + | ||||
| 21 | + | ### Alias | |||
| 22 | + | ||||
| 23 | + | `gordon attach` is an alias for `gordon attachments`: | |||
| 24 | + | ||||
| 25 | + | ```bash | |||
| 26 | + | gordon attach list | |||
| 27 | + | gordon attach add app.example.com postgres:18 | |||
| 28 | + | gordon attach remove app.example.com postgres:18 | |||
| 29 | + | ``` | |||
| 30 | + | ||||
| 31 | + | --- | |||
| 32 | + | ||||
| 33 | + | ## gordon attachments list | |||
| 34 | + | ||||
| 35 | + | List all configured attachments. | |||
| 36 | + | ||||
| 37 | + | ```bash | |||
| 38 | + | # List all attachments | |||
| 39 | + | gordon attachments list | |||
| 40 | + | ||||
| 41 | + | # List attachments for a specific domain or group | |||
| 42 | + | gordon attachments list app.example.com | |||
| 43 | + | gordon attachments list backend | |||
| 44 | + | ||||
| 45 | + | # Remote mode | |||
| 46 | + | gordon attachments list --remote https://gordon.mydomain.com --token $TOKEN | |||
| 47 | + | ``` | |||
| 48 | + | ||||
| 49 | + | ### Output | |||
| 50 | + | ||||
| 51 | + | ``` | |||
| 52 | + | Target Attachments | |||
| 53 | + | -------------------------------------------------------------------------------- | |||
| 54 | + | app.example.com postgres:18, redis:7-alpine | |||
| 55 | + | api.example.com postgres:18 | |||
| 56 | + | backend (group) rabbitmq:3-management | |||
| 57 | + | ``` | |||
| 58 | + | ||||
| 59 | + | --- | |||
| 60 | + | ||||
| 61 | + | ## gordon attachments add | |||
| 62 | + | ||||
| 63 | + | Add an attachment to a domain or network group. | |||
| 64 | + | ||||
| 65 | + | ```bash | |||
| 66 | + | gordon attachments add <domain-or-group> <image> | |||
| 67 | + | gordon attachments add app.example.com postgres:18 | |||
| 68 | + | gordon attachments add backend redis:7-alpine | |||
| 69 | + | ``` | |||
| 70 | + | ||||
| 71 | + | ### Arguments | |||
| 72 | + | ||||
| 73 | + | | Argument | Description | | |||
| 74 | + | |----------|-------------| | |||
| 75 | + | | `<domain-or-group>` | The domain name or network group name | | |||
| 76 | + | | `<image>` | The container image to attach | | |||
| 77 | + | ||||
| 78 | + | ### Options | |||
| 79 | + | ||||
| 80 | + | | Option | Description | | |||
| 81 | + | |--------|-------------| | |||
| 82 | + | | `--remote` | Remote Gordon URL | | |||
| 83 | + | | `--token` | Authentication token for remote | | |||
| 84 | + | ||||
| 85 | + | ### Examples | |||
| 86 | + | ||||
| 87 | + | ```bash | |||
| 88 | + | # Add database to a domain | |||
| 89 | + | gordon attachments add app.example.com postgres:18 | |||
| 90 | + | ||||
| 91 | + | # Add cache to a network group (shared by all domains in the group) | |||
| 92 | + | gordon attachments add backend redis:7-alpine | |||
| 93 | + | ||||
| 94 | + | # Remote | |||
| 95 | + | gordon attachments add app.example.com postgres:18 --remote https://gordon.mydomain.com --token $TOKEN | |||
| 96 | + | ``` | |||
| 97 | + | ||||
| 98 | + | --- | |||
| 99 | + | ||||
| 100 | + | ## gordon attachments remove | |||
| 101 | + | ||||
| 102 | + | Remove an attachment. | |||
| 103 | + | ||||
| 104 | + | ```bash | |||
| 105 | + | gordon attachments remove <domain-or-group> <image> | |||
| 106 | + | gordon attachments remove app.example.com postgres:18 | |||
| 107 | + | ``` | |||
| 108 | + | ||||
| 109 | + | ### Arguments | |||
| 110 | + | ||||
| 111 | + | | Argument | Description | | |||
| 112 | + | |----------|-------------| | |||
| 113 | + | | `<domain-or-group>` | The domain name or network group name | | |||
| 114 | + | | `<image>` | The container image to remove | | |||
| 115 | + | ||||
| 116 | + | ### Options | |||
| 117 | + | ||||
| 118 | + | | Option | Description | | |||
| 119 | + | |--------|-------------| | |||
| 120 | + | | `--remote` | Remote Gordon URL | | |||
| 121 | + | | `--token` | Authentication token for remote | | |||
| 122 | + | ||||
| 123 | + | ### Examples | |||
| 124 | + | ||||
| 125 | + | ```bash | |||
| 126 | + | # Remove database from a domain | |||
| 127 | + | gordon attachments remove app.example.com postgres:18 | |||
| 128 | + | ||||
| 129 | + | # Remove from network group | |||
| 130 | + | gordon attachments remove backend redis:7-alpine | |||
| 131 | + | ||||
| 132 | + | # Remote | |||
| 133 | + | gordon attachments remove app.example.com postgres:18 --remote https://gordon.mydomain.com --token $TOKEN | |||
| 134 | + | ``` | |||
| 135 | + | ||||
| 136 | + | --- | |||
| 137 | + | ||||
| 138 | + | ## Workflow Examples | |||
| 139 | + | ||||
| 140 | + | ### Add Database and Cache | |||
| 141 | + | ||||
| 142 | + | ```bash | |||
| 143 | + | # Add PostgreSQL database | |||
| 144 | + | gordon attachments add app.example.com postgres:18 | |||
| 145 | + | ||||
| 146 | + | # Add Redis cache | |||
| 147 | + | gordon attachments add app.example.com redis:7-alpine | |||
| 148 | + | ||||
| 149 | + | # Verify | |||
| 150 | + | gordon attachments list app.example.com | |||
| 151 | + | ``` | |||
| 152 | + | ||||
| 153 | + | ### Shared Services via Network Groups | |||
| 154 | + | ||||
| 155 | + | ```bash | |||
| 156 | + | # First, ensure you have a network group defined in your config: | |||
| 157 | + | # [network_groups] | |||
| 158 | + | # "backend" = ["app.example.com", "api.example.com"] | |||
| 159 | + | ||||
| 160 | + | # Add shared cache to the group | |||
| 161 | + | gordon attachments add backend redis:7-alpine | |||
| 162 | + | ||||
| 163 | + | # Both app.example.com and api.example.com can now access the shared Redis | |||
| 164 | + | ``` | |||
| 165 | + | ||||
| 166 | + | ### CI/CD Integration | |||
| 167 | + | ||||
| 168 | + | ```bash | |||
| 169 | + | # In your CI/CD pipeline | |||
| 170 | + | export GORDON_REMOTE=https://gordon.mydomain.com | |||
| 171 | + | export GORDON_TOKEN=$GORDON_TOKEN | |||
| 172 | + | ||||
| 173 | + | # Add new attachment | |||
| 174 | + | gordon attachments add app.example.com elasticsearch:8 | |||
| 175 | + | ||||
| 176 | + | # Trigger redeploy to pick up the new attachment | |||
| 177 | + | gordon routes deploy app.example.com | |||
| 178 | + | ``` | |||
| 179 | + | ||||
| 180 | + | ## Related | |||
| 181 | + | ||||
| 182 | + | - [CLI Overview](./index.md) | |||
| 183 | + | - [Attachments Configuration](../config/attachments.md) | |||
| 184 | + | - [Network Groups](../config/network-groups.md) | |||
A docs/cli/auth.md +268 -0
| @@ -0,0 +1,268 @@ | |||||
| 1 | + | # Authentication Commands | |||
| 2 | + | ||||
| 3 | + | Manage Gordon server authentication tokens and passwords. | |||
| 4 | + | ||||
| 5 | + | ## gordon auth | |||
| 6 | + | ||||
| 7 | + | ### Subcommands | |||
| 8 | + | ||||
| 9 | + | | Subcommand | Description | | |||
| 10 | + | |------------|-------------| | |||
| 11 | + | | `token generate` | Generate a new JWT token | | |||
| 12 | + | | `token list` | List all stored tokens | | |||
| 13 | + | | `token revoke` | Revoke a token by ID (use `--all` to revoke all tokens) | | |||
| 14 | + | | `password hash` | Generate bcrypt password hash | | |||
| 15 | + | | `internal` | Show internal registry credentials | | |||
| 16 | + | ||||
| 17 | + | --- | |||
| 18 | + | ||||
| 19 | + | ## gordon auth token generate | |||
| 20 | + | ||||
| 21 | + | Generate a new JWT authentication token for registry access. | |||
| 22 | + | ||||
| 23 | + | ```bash | |||
| 24 | + | gordon auth token generate --subject <name> [options] | |||
| 25 | + | ``` | |||
| 26 | + | ||||
| 27 | + | ### Options | |||
| 28 | + | ||||
| 29 | + | | Option | Default | Description | | |||
| 30 | + | |--------|---------|-------------| | |||
| 31 | + | | `--subject` | (required) | Username/subject for the token | | |||
| 32 | + | | `--scopes` | `push,pull` | Comma-separated scopes | | |||
| 33 | + | | `--expiry` | `30d` | Duration until expiry (0 = never) | | |||
| 34 | + | | `-c, --config` | Auto | Path to config file | | |||
| 35 | + | ||||
| 36 | + | ### Duration Format | |||
| 37 | + | ||||
| 38 | + | Supports human-friendly duration units: | |||
| 39 | + | ||||
| 40 | + | | Unit | Description | Example | | |||
| 41 | + | |------|-------------|---------| | |||
| 42 | + | | `d` | Days (24 hours) | `30d` | | |||
| 43 | + | | `w` | Weeks (7 days) | `2w` | | |||
| 44 | + | | `M` | Months (30 days) | `6M` | | |||
| 45 | + | | `y` | Years (365 days) | `1y` | | |||
| 46 | + | ||||
| 47 | + | Compound durations are also supported: `1y6M`, `2w3d`, `1d12h` | |||
| 48 | + | ||||
| 49 | + | Standard Go durations also work: `24h`, `30m`, `1h30m` | |||
| 50 | + | ||||
| 51 | + | ### Examples | |||
| 52 | + | ||||
| 53 | + | ```bash | |||
| 54 | + | # CI token that never expires | |||
| 55 | + | gordon auth token generate --subject github-actions --expiry 0 | |||
| 56 | + | ||||
| 57 | + | # Read-only token for 30 days | |||
| 58 | + | gordon auth token generate --subject reader --scopes pull --expiry 30d | |||
| 59 | + | ||||
| 60 | + | # Push-only token for 1 year | |||
| 61 | + | gordon auth token generate --subject builder --scopes push --expiry 1y | |||
| 62 | + | ||||
| 63 | + | # Temporary token (24 hours) | |||
| 64 | + | gordon auth token generate --subject temp --expiry 24h | |||
| 65 | + | ||||
| 66 | + | # Token for 6 months | |||
| 67 | + | gordon auth token generate --subject deploy --expiry 6M | |||
| 68 | + | ``` | |||
| 69 | + | ||||
| 70 | + | ### Output | |||
| 71 | + | ||||
| 72 | + | ``` | |||
| 73 | + | Token generated successfully! | |||
| 74 | + | Subject: github-actions | |||
| 75 | + | Scopes: push, pull | |||
| 76 | + | Expiry: never | |||
| 77 | + | ||||
| 78 | + | Token (use as password with docker login): | |||
| 79 | + | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... | |||
| 80 | + | ||||
| 81 | + | Usage: docker login -u github-actions -p <token> <registry> | |||
| 82 | + | ``` | |||
| 83 | + | ||||
| 84 | + | --- | |||
| 85 | + | ||||
| 86 | + | ## gordon auth token list | |||
| 87 | + | ||||
| 88 | + | List all stored authentication tokens. | |||
| 89 | + | ||||
| 90 | + | ```bash | |||
| 91 | + | gordon auth token list [options] | |||
| 92 | + | ``` | |||
| 93 | + | ||||
| 94 | + | ### Output | |||
| 95 | + | ||||
| 96 | + | ``` | |||
| 97 | + | ID Subject Expires Revoked | |||
| 98 | + | ------------------------------------------------------------------------------------------ | |||
| 99 | + | a1b2c3d4-e5f6-7890-abcd-ef1234567890 github-actions never no | |||
| 100 | + | b2c3d4e5-f6a7-8901-bcde-f12345678901 deploy-bot 2024-02-15 10:30 no | |||
| 101 | + | c3d4e5f6-a7b8-9012-cdef-123456789012 old-token 2024-01-01 00:00 yes | |||
| 102 | + | ``` | |||
| 103 | + | ||||
| 104 | + | --- | |||
| 105 | + | ||||
| 106 | + | ## gordon auth token revoke | |||
| 107 | + | ||||
| 108 | + | Revoke a token by its ID. | |||
| 109 | + | ||||
| 110 | + | ```bash | |||
| 111 | + | gordon auth token revoke <token-id> [options] | |||
| 112 | + | gordon auth token revoke --all # Revoke all tokens | |||
| 113 | + | ``` | |||
| 114 | + | ||||
| 115 | + | ### Example | |||
| 116 | + | ||||
| 117 | + | ```bash | |||
| 118 | + | gordon auth token revoke a1b2c3d4-e5f6-7890-abcd-ef1234567890 | |||
| 119 | + | ``` | |||
| 120 | + | ||||
| 121 | + | ### Output | |||
| 122 | + | ||||
| 123 | + | ``` | |||
| 124 | + | Token a1b2c3d4-e5f6-7890-abcd-ef1234567890 has been revoked. | |||
| 125 | + | ``` | |||
| 126 | + | ||||
| 127 | + | --- | |||
| 128 | + | ||||
| 129 | + | ## gordon auth password hash | |||
| 130 | + | ||||
| 131 | + | Generate a bcrypt hash for password authentication. | |||
| 132 | + | ||||
| 133 | + | ```bash | |||
| 134 | + | gordon auth password hash | |||
| 135 | + | ``` | |||
| 136 | + | ||||
| 137 | + | Interactively prompts for a password and outputs the bcrypt hash. | |||
| 138 | + | ||||
| 139 | + | ### Output | |||
| 140 | + | ||||
| 141 | + | ``` | |||
| 142 | + | Enter password: ******** | |||
| 143 | + | ||||
| 144 | + | Bcrypt hash (store in your secrets backend): | |||
| 145 | + | $2a$10$N9qo8uLOickgx2ZMRZoMye... | |||
| 146 | + | ||||
| 147 | + | Then reference the path in your config: | |||
| 148 | + | [auth] | |||
| 149 | + | type = "password" | |||
| 150 | + | password_hash = "gordon/auth/password_hash" | |||
| 151 | + | ``` | |||
| 152 | + | ||||
| 153 | + | --- | |||
| 154 | + | ||||
| 155 | + | ## gordon auth internal | |||
| 156 | + | ||||
| 157 | + | Display the auto-generated internal registry credentials. | |||
| 158 | + | ||||
| 159 | + | ```bash | |||
| 160 | + | gordon auth internal | |||
| 161 | + | ``` | |||
| 162 | + | ||||
| 163 | + | Gordon generates temporary credentials for internal communication with its local registry. These credentials are useful for manual recovery when debugging deployment issues. | |||
| 164 | + | ||||
| 165 | + | ### Output | |||
| 166 | + | ||||
| 167 | + | ``` | |||
| 168 | + | Internal Registry Credentials | |||
| 169 | + | ============================== | |||
| 170 | + | Username: gordon-internal | |||
| 171 | + | Password: 8f3a2b1c4d5e6f7a8b9c0d1e2f3a4b5c... | |||
| 172 | + | ||||
| 173 | + | Usage: | |||
| 174 | + | docker login localhost:5000 -u gordon-internal -p 8f3a2b1c4d5e6f7a8b9c0d1e2f3a4b5c... | |||
| 175 | + | ``` | |||
| 176 | + | ||||
| 177 | + | ### Notes | |||
| 178 | + | ||||
| 179 | + | - Credentials are regenerated each time Gordon starts | |||
| 180 | + | - Only available while Gordon is running | |||
| 181 | + | - Used for manual `docker pull` from the local registry during debugging | |||
| 182 | + | ||||
| 183 | + | ### Use Cases | |||
| 184 | + | ||||
| 185 | + | - Debugging image pull failures | |||
| 186 | + | - Manual recovery after deployment issues | |||
| 187 | + | - Inspecting images in the local registry | |||
| 188 | + | ||||
| 189 | + | --- | |||
| 190 | + | ||||
| 191 | + | ## Token Scopes | |||
| 192 | + | ||||
| 193 | + | | Scope | Permission | | |||
| 194 | + | |-------|------------| | |||
| 195 | + | | `push` | Push images to registry | | |||
| 196 | + | | `pull` | Pull images from registry | | |||
| 197 | + | | `push,pull` | Both push and pull (default) | | |||
| 198 | + | ||||
| 199 | + | ## Token Expiry Formats | |||
| 200 | + | ||||
| 201 | + | | Format | Duration | | |||
| 202 | + | |--------|----------| | |||
| 203 | + | | `0` | Never expires | | |||
| 204 | + | | `24h` | 24 hours | | |||
| 205 | + | | `720h` | 30 days | | |||
| 206 | + | | `8760h` | 1 year | | |||
| 207 | + | ||||
| 208 | + | --- | |||
| 209 | + | ||||
| 210 | + | ## Workflow Examples | |||
| 211 | + | ||||
| 212 | + | ### CI/CD Setup | |||
| 213 | + | ||||
| 214 | + | ```bash | |||
| 215 | + | # Generate token for CI | |||
| 216 | + | gordon auth token generate --subject ci-bot --scopes push,pull --expiry 0 | |||
| 217 | + | ||||
| 218 | + | # Save token as CI secret | |||
| 219 | + | # GitHub: Settings → Secrets → New repository secret | |||
| 220 | + | # GitLab: Settings → CI/CD → Variables | |||
| 221 | + | ``` | |||
| 222 | + | ||||
| 223 | + | ### Docker Login | |||
| 224 | + | ||||
| 225 | + | ```bash | |||
| 226 | + | # Using generated token | |||
| 227 | + | docker login -u ci-bot -p <token> registry.mydomain.com | |||
| 228 | + | ||||
| 229 | + | # In CI/CD | |||
| 230 | + | echo "$GORDON_TOKEN" | docker login -u ci-bot --password-stdin registry.mydomain.com | |||
| 231 | + | ``` | |||
| 232 | + | ||||
| 233 | + | ### Token Rotation | |||
| 234 | + | ||||
| 235 | + | ```bash | |||
| 236 | + | # Generate new token | |||
| 237 | + | gordon auth token generate --subject ci-bot --expiry 0 | |||
| 238 | + | ||||
| 239 | + | # Update CI secrets with new token | |||
| 240 | + | ||||
| 241 | + | # Revoke old token | |||
| 242 | + | gordon auth token revoke <old-token-id> | |||
| 243 | + | ``` | |||
| 244 | + | ||||
| 245 | + | ### Password Setup | |||
| 246 | + | ||||
| 247 | + | ```bash | |||
| 248 | + | # Generate hash | |||
| 249 | + | gordon auth password hash | |||
| 250 | + | # Enter: mypassword | |||
| 251 | + | ||||
| 252 | + | # Store in pass | |||
| 253 | + | pass insert gordon/auth/password_hash | |||
| 254 | + | # Paste: $2a$10$... | |||
| 255 | + | ||||
| 256 | + | # Configure gordon.toml | |||
| 257 | + | [auth] | |||
| 258 | + | enabled = true | |||
| 259 | + | type = "password" | |||
| 260 | + | secrets_backend = "pass" | |||
| 261 | + | username = "deploy" | |||
| 262 | + | password_hash = "gordon/auth/password_hash" | |||
| 263 | + | ``` | |||
| 264 | + | ||||
| 265 | + | ## Related | |||
| 266 | + | ||||
| 267 | + | - [CLI Overview](./index.md) | |||
| 268 | + | - [Authentication Configuration](../config/auth.md) | |||
D docs/cli/commands.md +0 -743
| @@ -1,743 +0,0 @@ | |||||
| 1 | - | # CLI Commands | |||
| 2 | - | ||||
| 3 | - | Gordon provides a command-line interface for server management, deployment, and authentication. | |||
| 4 | - | ||||
| 5 | - | ## Commands Overview | |||
| 6 | - | ||||
| 7 | - | | Command | Description | | |||
| 8 | - | |---------|-------------| | |||
| 9 | - | | `gordon serve` | Start the Gordon server | | |||
| 10 | - | | `gordon reload` | Reload configuration and sync containers | | |||
| 11 | - | | `gordon deploy` | Manually deploy or redeploy a specific route | | |||
| 12 | - | | `gordon logs` | Display Gordon process or container logs | | |||
| 13 | - | | `gordon version` | Print version information | | |||
| 14 | - | | `gordon auth` | Manage registry authentication | | |||
| 15 | - | | `gordon routes` | Manage routes (local or remote) | | |||
| 16 | - | | `gordon secrets` | Manage secrets (local or remote) | | |||
| 17 | - | | `gordon remotes` | Manage saved remote Gordon instances | | |||
| 18 | - | ||||
| 19 | - | ## Quick Reference | |||
| 20 | - | ||||
| 21 | - | ```bash | |||
| 22 | - | # Start Gordon | |||
| 23 | - | gordon serve | |||
| 24 | - | gordon serve --config /path/to/config.toml | |||
| 25 | - | ||||
| 26 | - | # Reload configuration | |||
| 27 | - | gordon reload | |||
| 28 | - | ||||
| 29 | - | # Deploy a specific route | |||
| 30 | - | gordon deploy myapp.example.com | |||
| 31 | - | ||||
| 32 | - | # View logs | |||
| 33 | - | gordon logs # Gordon process logs | |||
| 34 | - | gordon logs -f # Follow process logs | |||
| 35 | - | gordon logs -n 100 # Last 100 lines | |||
| 36 | - | gordon logs myapp.local # Container logs for myapp.local | |||
| 37 | - | gordon logs myapp.local -f # Follow container logs | |||
| 38 | - | ||||
| 39 | - | # Check version | |||
| 40 | - | gordon version | |||
| 41 | - | ||||
| 42 | - | # Authentication | |||
| 43 | - | gordon auth token generate --subject ci-bot --expiry 0 | |||
| 44 | - | gordon auth token list | |||
| 45 | - | gordon auth token revoke <token-id> | |||
| 46 | - | gordon auth password hash | |||
| 47 | - | gordon auth internal | |||
| 48 | - | ``` | |||
| 49 | - | ||||
| 50 | - | ## Global Options | |||
| 51 | - | ||||
| 52 | - | | Option | Description | | |||
| 53 | - | |--------|-------------| | |||
| 54 | - | | `-c, --config` | Path to configuration file | | |||
| 55 | - | | `--remote` | Remote Gordon URL (e.g., `https://gordon.mydomain.com`) | | |||
| 56 | - | | `--token` | Authentication token for remote | | |||
| 57 | - | ||||
| 58 | - | ### Remote Targeting | |||
| 59 | - | ||||
| 60 | - | The CLI can target remote Gordon instances using the `--remote` flag or `GORDON_REMOTE` environment variable. | |||
| 61 | - | ||||
| 62 | - | **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. | |||
| 63 | - | ||||
| 64 | - | ```bash | |||
| 65 | - | # Using flags (use the gordon_domain from remote Gordon config) | |||
| 66 | - | gordon routes list --remote https://gordon.mydomain.com --token $TOKEN | |||
| 67 | - | ||||
| 68 | - | # Using environment variables | |||
| 69 | - | export GORDON_REMOTE=https://gordon.mydomain.com | |||
| 70 | - | export GORDON_TOKEN=$TOKEN | |||
| 71 | - | gordon routes list | |||
| 72 | - | ``` | |||
| 73 | - | ||||
| 74 | - | --- | |||
| 75 | - | ||||
| 76 | - | ## gordon serve | |||
| 77 | - | ||||
| 78 | - | Start the Gordon server with registry and proxy components. | |||
| 79 | - | ||||
| 80 | - | ### Synopsis | |||
| 81 | - | ||||
| 82 | - | ```bash | |||
| 83 | - | gordon serve [options] | |||
| 84 | - | ``` | |||
| 85 | - | ||||
| 86 | - | ### Options | |||
| 87 | - | ||||
| 88 | - | | Option | Short | Default | Description | | |||
| 89 | - | |--------|-------|---------|-------------| | |||
| 90 | - | | `--config` | `-c` | Auto-detected | Path to configuration file | | |||
| 91 | - | ||||
| 92 | - | ### Description | |||
| 93 | - | ||||
| 94 | - | Starts the Gordon server, which includes: | |||
| 95 | - | ||||
| 96 | - | - **Container Registry** - Receives image pushes on the registry port | |||
| 97 | - | - **HTTP Proxy** - Routes traffic to containers on the proxy port | |||
| 98 | - | - **Event Bus** - Coordinates deployments and updates | |||
| 99 | - | - **Config Watcher** - Monitors configuration file for changes | |||
| 100 | - | ||||
| 101 | - | ### Configuration File Detection | |||
| 102 | - | ||||
| 103 | - | Gordon looks for configuration in this order: | |||
| 104 | - | ||||
| 105 | - | 1. Path specified with `--config` | |||
| 106 | - | 2. `/etc/gordon/gordon.toml` | |||
| 107 | - | 3. `~/.config/gordon/gordon.toml` | |||
| 108 | - | 4. `./gordon.toml` | |||
| 109 | - | ||||
| 110 | - | ### First Run | |||
| 111 | - | ||||
| 112 | - | On first run without a config file, Gordon creates a default configuration at `~/.config/gordon/gordon.toml`. | |||
| 113 | - | ||||
| 114 | - | ```bash | |||
| 115 | - | # First run - creates default config | |||
| 116 | - | gordon serve | |||
| 117 | - | # Edit the config, then restart | |||
| 118 | - | ``` | |||
| 119 | - | ||||
| 120 | - | ### Examples | |||
| 121 | - | ||||
| 122 | - | ```bash | |||
| 123 | - | # Basic start | |||
| 124 | - | gordon serve | |||
| 125 | - | ||||
| 126 | - | # With custom config | |||
| 127 | - | gordon serve --config /path/to/gordon.toml | |||
| 128 | - | gordon serve -c ./my-config.toml | |||
| 129 | - | ||||
| 130 | - | # With environment override | |||
| 131 | - | GORDON_SERVER_PORT=8080 gordon serve | |||
| 132 | - | GORDON_LOGGING_LEVEL=debug gordon serve | |||
| 133 | - | ``` | |||
| 134 | - | ||||
| 135 | - | ### Signals | |||
| 136 | - | ||||
| 137 | - | Gordon responds to these signals: | |||
| 138 | - | ||||
| 139 | - | | Signal | Action | | |||
| 140 | - | |--------|--------| | |||
| 141 | - | | `SIGTERM` | Graceful shutdown | | |||
| 142 | - | | `SIGINT` | Graceful shutdown (Ctrl+C) | | |||
| 143 | - | | `SIGUSR1` | Reload configuration | | |||
| 144 | - | | `SIGUSR2` | Manual deploy (used by `gordon deploy`) | | |||
| 145 | - | ||||
| 146 | - | ### Running with systemd | |||
| 147 | - | ||||
| 148 | - | ```bash | |||
| 149 | - | # Create user service | |||
| 150 | - | mkdir -p ~/.config/systemd/user | |||
| 151 | - | cat > ~/.config/systemd/user/gordon.service <<EOF | |||
| 152 | - | [Unit] | |||
| 153 | - | Description=Gordon Container Platform | |||
| 154 | - | ||||
| 155 | - | [Service] | |||
| 156 | - | Type=simple | |||
| 157 | - | Restart=always | |||
| 158 | - | ExecStart=/usr/local/bin/gordon serve | |||
| 159 | - | ||||
| 160 | - | [Install] | |||
| 161 | - | WantedBy=default.target | |||
| 162 | - | EOF | |||
| 163 | - | ||||
| 164 | - | # Enable and start | |||
| 165 | - | systemctl --user daemon-reload | |||
| 166 | - | systemctl --user enable --now gordon | |||
| 167 | - | sudo loginctl enable-linger $USER | |||
| 168 | - | ``` | |||
| 169 | - | ||||
| 170 | - | ### Startup Sequence | |||
| 171 | - | ||||
| 172 | - | 1. Load configuration | |||
| 173 | - | 2. Initialize logger | |||
| 174 | - | 3. Create PID file | |||
| 175 | - | 4. Connect to Docker/Podman runtime | |||
| 176 | - | 5. Create storage directories | |||
| 177 | - | 6. Initialize services (registry, proxy, auth) | |||
| 178 | - | 7. Register event handlers | |||
| 179 | - | 8. Start config file watcher | |||
| 180 | - | 9. Sync existing containers | |||
| 181 | - | 10. Start HTTP servers | |||
| 182 | - | ||||
| 183 | - | ### Shutdown Sequence | |||
| 184 | - | ||||
| 185 | - | 1. Receive shutdown signal | |||
| 186 | - | 2. Stop accepting new requests | |||
| 187 | - | 3. Complete in-flight requests | |||
| 188 | - | 4. Stop managed containers (if configured) | |||
| 189 | - | 5. Remove PID file | |||
| 190 | - | 6. Exit | |||
| 191 | - | ||||
| 192 | - | --- | |||
| 193 | - | ||||
| 194 | - | ## gordon reload | |||
| 195 | - | ||||
| 196 | - | Reload configuration and sync containers to match. | |||
| 197 | - | ||||
| 198 | - | ### Synopsis | |||
| 199 | - | ||||
| 200 | - | ```bash | |||
| 201 | - | gordon reload | |||
| 202 | - | ``` | |||
| 203 | - | ||||
| 204 | - | ### Description | |||
| 205 | - | ||||
| 206 | - | Sends `SIGUSR1` to the running Gordon process, triggering: | |||
| 207 | - | ||||
| 208 | - | - Configuration file reload | |||
| 209 | - | - Route synchronization | |||
| 210 | - | - Deployment of containers for routes missing containers | |||
| 211 | - | - Attachment deployment | |||
| 212 | - | ||||
| 213 | - | ### Example | |||
| 214 | - | ||||
| 215 | - | ```bash | |||
| 216 | - | # After editing gordon.toml, apply changes without restart | |||
| 217 | - | vim ~/.config/gordon/gordon.toml | |||
| 218 | - | gordon reload | |||
| 219 | - | ``` | |||
| 220 | - | ||||
| 221 | - | --- | |||
| 222 | - | ||||
| 223 | - | ## gordon deploy | |||
| 224 | - | ||||
| 225 | - | Manually deploy or redeploy a specific route. | |||
| 226 | - | ||||
| 227 | - | ### Synopsis | |||
| 228 | - | ||||
| 229 | - | ```bash | |||
| 230 | - | gordon deploy <domain> [options] | |||
| 231 | - | ``` | |||
| 232 | - | ||||
| 233 | - | ### Arguments | |||
| 234 | - | ||||
| 235 | - | | Argument | Description | | |||
| 236 | - | |----------|-------------| | |||
| 237 | - | | `<domain>` | The domain name of the route to deploy (required) | | |||
| 238 | - | ||||
| 239 | - | ### Options | |||
| 240 | - | ||||
| 241 | - | | Option | Description | | |||
| 242 | - | |--------|-------------| | |||
| 243 | - | | `--remote` | Remote Gordon URL | | |||
| 244 | - | | `--token` | Authentication token for remote | | |||
| 245 | - | ||||
| 246 | - | ### Description | |||
| 247 | - | ||||
| 248 | - | **Local mode:** Sends `SIGUSR2` to the Gordon process with the specified domain. | |||
| 249 | - | ||||
| 250 | - | **Remote mode:** Calls the remote Gordon Admin API to trigger deployment. | |||
| 251 | - | ||||
| 252 | - | Both modes trigger: | |||
| 253 | - | ||||
| 254 | - | - Fresh image pull (always pulls latest, ignoring cache) | |||
| 255 | - | - Container redeployment for the specified route | |||
| 256 | - | ||||
| 257 | - | ### Examples | |||
| 258 | - | ||||
| 259 | - | ```bash | |||
| 260 | - | # Local deployment | |||
| 261 | - | gordon deploy myapp.example.com | |||
| 262 | - | gordon deploy api.example.com | |||
| 263 | - | ||||
| 264 | - | # Remote deployment | |||
| 265 | - | gordon deploy myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN | |||
| 266 | - | ``` | |||
| 267 | - | ||||
| 268 | - | ### Use Cases | |||
| 269 | - | ||||
| 270 | - | - Recover from a failed deployment | |||
| 271 | - | - Force redeploy without pushing a new image | |||
| 272 | - | - Manual deployment when automatic deploy didn't trigger | |||
| 273 | - | - Trigger deployments on remote Gordon instances from CI/CD | |||
| 274 | - | ||||
| 275 | - | --- | |||
| 276 | - | ||||
| 277 | - | ## gordon logs | |||
| 278 | - | ||||
| 279 | - | Display Gordon process logs or container logs. | |||
| 280 | - | ||||
| 281 | - | ### Synopsis | |||
| 282 | - | ||||
| 283 | - | ```bash | |||
| 284 | - | gordon logs [domain] [options] | |||
| 285 | - | ``` | |||
| 286 | - | ||||
| 287 | - | ### Arguments | |||
| 288 | - | ||||
| 289 | - | | Argument | Description | | |||
| 290 | - | |----------|-------------| | |||
| 291 | - | | `[domain]` | Optional. Container domain to view logs for. Without this, shows Gordon process logs. | | |||
| 292 | - | ||||
| 293 | - | ### Options | |||
| 294 | - | ||||
| 295 | - | | Option | Short | Default | Description | | |||
| 296 | - | |--------|-------|---------|-------------| | |||
| 297 | - | | `--config` | `-c` | Auto | Path to config file | | |||
| 298 | - | | `--follow` | `-f` | false | Follow log output (like `tail -f`) | | |||
| 299 | - | | `--lines` | `-n` | 50 | Number of lines to show | | |||
| 300 | - | | `--remote` | | | Remote Gordon URL | | |||
| 301 | - | | `--token` | | | Authentication token for remote | | |||
| 302 | - | ||||
| 303 | - | ### Examples | |||
| 304 | - | ||||
| 305 | - | ```bash | |||
| 306 | - | # Gordon process logs | |||
| 307 | - | gordon logs # Last 50 lines | |||
| 308 | - | gordon logs -f # Follow logs | |||
| 309 | - | gordon logs -n 100 # Last 100 lines | |||
| 310 | - | gordon logs -f -n 200 # Follow, starting from last 200 lines | |||
| 311 | - | ||||
| 312 | - | # Container logs | |||
| 313 | - | gordon logs myapp.local # Last 50 lines from container | |||
| 314 | - | gordon logs myapp.local -f # Follow container logs | |||
| 315 | - | gordon logs myapp.local -n 100 # Last 100 lines from container | |||
| 316 | - | ||||
| 317 | - | # Remote mode | |||
| 318 | - | gordon logs --remote https://gordon.mydomain.com --token $TOKEN | |||
| 319 | - | gordon logs myapp.local --remote https://gordon.mydomain.com --token $TOKEN | |||
| 320 | - | ``` | |||
| 321 | - | ||||
| 322 | - | ### Log Locations | |||
| 323 | - | ||||
| 324 | - | ```bash | |||
| 325 | - | # Using gordon logs | |||
| 326 | - | gordon logs -f | |||
| 327 | - | ||||
| 328 | - | # Direct file access | |||
| 329 | - | tail -f ~/.gordon/logs/gordon.log | |||
| 330 | - | ||||
| 331 | - | # With systemd | |||
| 332 | - | journalctl --user -u gordon -f | |||
| 333 | - | ||||
| 334 | - | # Container logs via docker (local alternative) | |||
| 335 | - | docker logs --tail 50 myapp.local | |||
| 336 | - | docker logs -f myapp.local | |||
| 337 | - | ``` | |||
| 338 | - | ||||
| 339 | - | --- | |||
| 340 | - | ||||
| 341 | - | ## gordon version | |||
| 342 | - | ||||
| 343 | - | Print version information. | |||
| 344 | - | ||||
| 345 | - | ### Synopsis | |||
| 346 | - | ||||
| 347 | - | ```bash | |||
| 348 | - | gordon version | |||
| 349 | - | ``` | |||
| 350 | - | ||||
| 351 | - | ### Output | |||
| 352 | - | ||||
| 353 | - | ``` | |||
| 354 | - | Gordon v2.0.0 | |||
| 355 | - | Commit: abc1234 | |||
| 356 | - | Build Date: 2024-01-15 | |||
| 357 | - | ``` | |||
| 358 | - | ||||
| 359 | - | --- | |||
| 360 | - | ||||
| 361 | - | ## gordon auth | |||
| 362 | - | ||||
| 363 | - | Manage registry authentication tokens and passwords. | |||
| 364 | - | ||||
| 365 | - | ### Subcommands | |||
| 366 | - | ||||
| 367 | - | | Subcommand | Description | | |||
| 368 | - | |------------|-------------| | |||
| 369 | - | | `token generate` | Generate a new JWT token | | |||
| 370 | - | | `token list` | List all stored tokens | | |||
| 371 | - | | `token revoke` | Revoke a token by ID | | |||
| 372 | - | | `password hash` | Generate bcrypt password hash | | |||
| 373 | - | | `internal` | Show internal registry credentials | | |||
| 374 | - | ||||
| 375 | - | ### gordon auth token generate | |||
| 376 | - | ||||
| 377 | - | Generate a new JWT authentication token for registry access. | |||
| 378 | - | ||||
| 379 | - | ```bash | |||
| 380 | - | gordon auth token generate --subject <name> [options] | |||
| 381 | - | ``` | |||
| 382 | - | ||||
| 383 | - | **Options:** | |||
| 384 | - | ||||
| 385 | - | | Option | Default | Description | | |||
| 386 | - | |--------|---------|-------------| | |||
| 387 | - | | `--subject` | (required) | Username/subject for the token | | |||
| 388 | - | | `--scopes` | `push,pull` | Comma-separated scopes | | |||
| 389 | - | | `--expiry` | `30d` | Duration until expiry (0 = never) | | |||
| 390 | - | | `-c, --config` | Auto | Path to config file | | |||
| 391 | - | ||||
| 392 | - | **Duration format:** | |||
| 393 | - | ||||
| 394 | - | Supports human-friendly duration units: | |||
| 395 | - | ||||
| 396 | - | | Unit | Description | Example | | |||
| 397 | - | |------|-------------|---------| | |||
| 398 | - | | `d` | Days (24 hours) | `30d` | | |||
| 399 | - | | `w` | Weeks (7 days) | `2w` | | |||
| 400 | - | | `M` | Months (30 days) | `6M` | | |||
| 401 | - | | `y` | Years (365 days) | `1y` | | |||
| 402 | - | ||||
| 403 | - | Compound durations are also supported: `1y6M`, `2w3d`, `1d12h` | |||
| 404 | - | ||||
| 405 | - | Standard Go durations also work: `24h`, `30m`, `1h30m` | |||
| 406 | - | ||||
| 407 | - | **Examples:** | |||
| 408 | - | ||||
| 409 | - | ```bash | |||
| 410 | - | # CI token that never expires | |||
| 411 | - | gordon auth token generate --subject github-actions --expiry 0 | |||
| 412 | - | ||||
| 413 | - | # Read-only token for 30 days | |||
| 414 | - | gordon auth token generate --subject reader --scopes pull --expiry 30d | |||
| 415 | - | ||||
| 416 | - | # Push-only token for 1 year | |||
| 417 | - | gordon auth token generate --subject builder --scopes push --expiry 1y | |||
| 418 | - | ||||
| 419 | - | # Temporary token (24 hours) | |||
| 420 | - | gordon auth token generate --subject temp --expiry 24h | |||
| 421 | - | ||||
| 422 | - | # Token for 6 months | |||
| 423 | - | gordon auth token generate --subject deploy --expiry 6M | |||
| 424 | - | ``` | |||
| 425 | - | ||||
| 426 | - | **Output:** | |||
| 427 | - | ||||
| 428 | - | ``` | |||
| 429 | - | Token generated successfully! | |||
| 430 | - | Subject: github-actions | |||
| 431 | - | Scopes: push, pull | |||
| 432 | - | Expiry: never | |||
| 433 | - | ||||
| 434 | - | Token (use as password with docker login): | |||
| 435 | - | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... | |||
| 436 | - | ||||
| 437 | - | Usage: docker login -u github-actions -p <token> <registry> | |||
| 438 | - | ``` | |||
| 439 | - | ||||
| 440 | - | ### gordon auth token list | |||
| 441 | - | ||||
| 442 | - | List all stored authentication tokens. | |||
| 443 | - | ||||
| 444 | - | ```bash | |||
| 445 | - | gordon auth token list [options] | |||
| 446 | - | ``` | |||
| 447 | - | ||||
| 448 | - | **Output:** | |||
| 449 | - | ||||
| 450 | - | ``` | |||
| 451 | - | ID Subject Expires Revoked | |||
| 452 | - | ------------------------------------------------------------------------------------------ | |||
| 453 | - | a1b2c3d4-e5f6-7890-abcd-ef1234567890 github-actions never no | |||
| 454 | - | b2c3d4e5-f6a7-8901-bcde-f12345678901 deploy-bot 2024-02-15 10:30 no | |||
| 455 | - | c3d4e5f6-a7b8-9012-cdef-123456789012 old-token 2024-01-01 00:00 yes | |||
| 456 | - | ``` | |||
| 457 | - | ||||
| 458 | - | ### gordon auth token revoke | |||
| 459 | - | ||||
| 460 | - | Revoke a token by its ID. | |||
| 461 | - | ||||
| 462 | - | ```bash | |||
| 463 | - | gordon auth token revoke <token-id> [options] | |||
| 464 | - | ``` | |||
| 465 | - | ||||
| 466 | - | **Example:** | |||
| 467 | - | ||||
| 468 | - | ```bash | |||
| 469 | - | gordon auth token revoke a1b2c3d4-e5f6-7890-abcd-ef1234567890 | |||
| 470 | - | ``` | |||
| 471 | - | ||||
| 472 | - | **Output:** | |||
| 473 | - | ||||
| 474 | - | ``` | |||
| 475 | - | Token a1b2c3d4-e5f6-7890-abcd-ef1234567890 has been revoked. | |||
| 476 | - | ``` | |||
| 477 | - | ||||
| 478 | - | ### gordon auth password hash | |||
| 479 | - | ||||
| 480 | - | Generate a bcrypt hash for password authentication. | |||
| 481 | - | ||||
| 482 | - | ```bash | |||
| 483 | - | gordon auth password hash | |||
| 484 | - | ``` | |||
| 485 | - | ||||
| 486 | - | Interactively prompts for a password and outputs the bcrypt hash. | |||
| 487 | - | ||||
| 488 | - | **Output:** | |||
| 489 | - | ||||
| 490 | - | ``` | |||
| 491 | - | Enter password: ******** | |||
| 492 | - | ||||
| 493 | - | Bcrypt hash (store in your secrets backend): | |||
| 494 | - | $2a$10$N9qo8uLOickgx2ZMRZoMye... | |||
| 495 | - | ||||
| 496 | - | Then reference the path in your config: | |||
| 497 | - | [registry_auth] | |||
| 498 | - | type = "password" | |||
| 499 | - | password_hash = "gordon/registry/password_hash" | |||
| 500 | - | ``` | |||
| 501 | - | ||||
| 502 | - | ### gordon auth internal | |||
| 503 | - | ||||
| 504 | - | Display the auto-generated internal registry credentials. | |||
| 505 | - | ||||
| 506 | - | ```bash | |||
| 507 | - | gordon auth internal | |||
| 508 | - | ``` | |||
| 509 | - | ||||
| 510 | - | Gordon generates temporary credentials for internal communication with its local registry. These credentials are useful for manual recovery when debugging deployment issues. | |||
| 511 | - | ||||
| 512 | - | **Output:** | |||
| 513 | - | ||||
| 514 | - | ``` | |||
| 515 | - | Internal Registry Credentials | |||
| 516 | - | ============================== | |||
| 517 | - | Username: gordon-internal | |||
| 518 | - | Password: 8f3a2b1c4d5e6f7a8b9c0d1e2f3a4b5c... | |||
| 519 | - | ||||
| 520 | - | Usage: | |||
| 521 | - | docker login localhost:5000 -u gordon-internal -p 8f3a2b1c4d5e6f7a8b9c0d1e2f3a4b5c... | |||
| 522 | - | ``` | |||
| 523 | - | ||||
| 524 | - | **Notes:** | |||
| 525 | - | - Credentials are regenerated each time Gordon starts | |||
| 526 | - | - Only available while Gordon is running | |||
| 527 | - | - Used for manual `docker pull` from the local registry during debugging | |||
| 528 | - | ||||
| 529 | - | **Use cases:** | |||
| 530 | - | - Debugging image pull failures | |||
| 531 | - | - Manual recovery after deployment issues | |||
| 532 | - | - Inspecting images in the local registry | |||
| 533 | - | ||||
| 534 | - | --- | |||
| 535 | - | ||||
| 536 | - | ## Token Scopes | |||
| 537 | - | ||||
| 538 | - | | Scope | Permission | | |||
| 539 | - | |-------|------------| | |||
| 540 | - | | `push` | Push images to registry | | |||
| 541 | - | | `pull` | Pull images from registry | | |||
| 542 | - | | `push,pull` | Both push and pull (default) | | |||
| 543 | - | ||||
| 544 | - | ## Token Expiry Formats | |||
| 545 | - | ||||
| 546 | - | | Format | Duration | | |||
| 547 | - | |--------|----------| | |||
| 548 | - | | `0` | Never expires | | |||
| 549 | - | | `24h` | 24 hours | | |||
| 550 | - | | `720h` | 30 days | | |||
| 551 | - | | `8760h` | 1 year | | |||
| 552 | - | ||||
| 553 | - | --- | |||
| 554 | - | ||||
| 555 | - | ## Workflow Examples | |||
| 556 | - | ||||
| 557 | - | ### CI/CD Setup | |||
| 558 | - | ||||
| 559 | - | ```bash | |||
| 560 | - | # Generate token for CI | |||
| 561 | - | gordon auth token generate --subject ci-bot --scopes push,pull --expiry 0 | |||
| 562 | - | ||||
| 563 | - | # Save token as CI secret | |||
| 564 | - | # GitHub: Settings → Secrets → New repository secret | |||
| 565 | - | # GitLab: Settings → CI/CD → Variables | |||
| 566 | - | ``` | |||
| 567 | - | ||||
| 568 | - | ### Docker Login | |||
| 569 | - | ||||
| 570 | - | ```bash | |||
| 571 | - | # Using generated token | |||
| 572 | - | docker login -u ci-bot -p <token> registry.mydomain.com | |||
| 573 | - | ||||
| 574 | - | # In CI/CD | |||
| 575 | - | echo "$GORDON_TOKEN" | docker login -u ci-bot --password-stdin registry.mydomain.com | |||
| 576 | - | ``` | |||
| 577 | - | ||||
| 578 | - | ### Token Rotation | |||
| 579 | - | ||||
| 580 | - | ```bash | |||
| 581 | - | # Generate new token | |||
| 582 | - | gordon auth token generate --subject ci-bot --expiry 0 | |||
| 583 | - | ||||
| 584 | - | # Update CI secrets with new token | |||
| 585 | - | ||||
| 586 | - | # Revoke old token | |||
| 587 | - | gordon auth token revoke <old-token-id> | |||
| 588 | - | ``` | |||
| 589 | - | ||||
| 590 | - | ### Password Setup | |||
| 591 | - | ||||
| 592 | - | ```bash | |||
| 593 | - | # Generate hash | |||
| 594 | - | gordon auth password hash | |||
| 595 | - | # Enter: mypassword | |||
| 596 | - | ||||
| 597 | - | # Store in pass | |||
| 598 | - | pass insert gordon/registry/password_hash | |||
| 599 | - | # Paste: $2a$10$... | |||
| 600 | - | ||||
| 601 | - | # Configure gordon.toml | |||
| 602 | - | [registry_auth] | |||
| 603 | - | enabled = true | |||
| 604 | - | type = "password" | |||
| 605 | - | username = "deploy" | |||
| 606 | - | password_hash = "gordon/registry/password_hash" | |||
| 607 | - | ``` | |||
| 608 | - | ||||
| 609 | - | --- | |||
| 610 | - | ||||
| 611 | - | ## gordon routes | |||
| 612 | - | ||||
| 613 | - | Manage routes on local or remote Gordon instances. | |||
| 614 | - | ||||
| 615 | - | ### Subcommands | |||
| 616 | - | ||||
| 617 | - | | Subcommand | Description | | |||
| 618 | - | |------------|-------------| | |||
| 619 | - | | `list` | List all routes | | |||
| 620 | - | | `add` | Add a new route | | |||
| 621 | - | | `remove` | Remove a route | | |||
| 622 | - | | `deploy` | Deploy a specific route | | |||
| 623 | - | ||||
| 624 | - | ### gordon routes list | |||
| 625 | - | ||||
| 626 | - | List all configured routes. | |||
| 627 | - | ||||
| 628 | - | ```bash | |||
| 629 | - | gordon routes list | |||
| 630 | - | gordon routes list --remote https://gordon.mydomain.com --token $TOKEN | |||
| 631 | - | ``` | |||
| 632 | - | ||||
| 633 | - | ### gordon routes add | |||
| 634 | - | ||||
| 635 | - | Add a new route. | |||
| 636 | - | ||||
| 637 | - | ```bash | |||
| 638 | - | gordon routes add <domain> <image> | |||
| 639 | - | gordon routes add myapp.example.com myapp:latest | |||
| 640 | - | ``` | |||
| 641 | - | ||||
| 642 | - | ### gordon routes remove | |||
| 643 | - | ||||
| 644 | - | Remove a route. | |||
| 645 | - | ||||
| 646 | - | ```bash | |||
| 647 | - | gordon routes remove <domain> | |||
| 648 | - | gordon routes remove myapp.example.com | |||
| 649 | - | ``` | |||
| 650 | - | ||||
| 651 | - | ### gordon routes deploy | |||
| 652 | - | ||||
| 653 | - | Deploy or redeploy a specific route. | |||
| 654 | - | ||||
| 655 | - | ```bash | |||
| 656 | - | gordon routes deploy <domain> | |||
| 657 | - | gordon routes deploy myapp.example.com | |||
| 658 | - | ``` | |||
| 659 | - | ||||
| 660 | - | --- | |||
| 661 | - | ||||
| 662 | - | ## gordon secrets | |||
| 663 | - | ||||
| 664 | - | Manage secrets on local or remote Gordon instances. | |||
| 665 | - | ||||
| 666 | - | ### Subcommands | |||
| 667 | - | ||||
| 668 | - | | Subcommand | Description | | |||
| 669 | - | |------------|-------------| | |||
| 670 | - | | `list` | List all secrets for a domain | | |||
| 671 | - | | `set` | Set a secret value | | |||
| 672 | - | | `remove` | Remove a secret | | |||
| 673 | - | ||||
| 674 | - | ### Examples | |||
| 675 | - | ||||
| 676 | - | ```bash | |||
| 677 | - | # Local mode (reads/writes secrets directly) | |||
| 678 | - | gordon secrets list myapp.local | |||
| 679 | - | gordon secrets set myapp.local DATABASE_URL "postgres://..." | |||
| 680 | - | gordon secrets remove myapp.local DATABASE_URL | |||
| 681 | - | ||||
| 682 | - | # Remote mode | |||
| 683 | - | gordon secrets list myapp.local --remote https://gordon.mydomain.com --token $TOKEN | |||
| 684 | - | gordon secrets set myapp.local DATABASE_URL "postgres://..." --remote ... --token ... | |||
| 685 | - | gordon secrets remove myapp.local DATABASE_URL --remote ... --token ... | |||
| 686 | - | ``` | |||
| 687 | - | ||||
| 688 | - | --- | |||
| 689 | - | ||||
| 690 | - | ## gordon remotes | |||
| 691 | - | ||||
| 692 | - | Manage saved remote Gordon instances for easier CLI usage. | |||
| 693 | - | ||||
| 694 | - | ### Subcommands | |||
| 695 | - | ||||
| 696 | - | | Subcommand | Description | | |||
| 697 | - | |------------|-------------| | |||
| 698 | - | | `list` | List saved remotes | | |||
| 699 | - | | `add` | Add a new remote | | |||
| 700 | - | | `remove` | Remove a saved remote | | |||
| 701 | - | | `use` | Set active remote | | |||
| 702 | - | ||||
| 703 | - | ### Examples | |||
| 704 | - | ||||
| 705 | - | ```bash | |||
| 706 | - | # Add a remote | |||
| 707 | - | gordon remotes add prod https://gordon.mydomain.com --token $TOKEN | |||
| 708 | - | ||||
| 709 | - | # List saved remotes | |||
| 710 | - | gordon remotes list | |||
| 711 | - | ||||
| 712 | - | # Set active remote | |||
| 713 | - | gordon remotes use prod | |||
| 714 | - | ||||
| 715 | - | # Now use without --remote flag | |||
| 716 | - | gordon routes list | |||
| 717 | - | ``` | |||
| 718 | - | ||||
| 719 | - | --- | |||
| 720 | - | ||||
| 721 | - | ## Exit Codes | |||
| 722 | - | ||||
| 723 | - | | Code | Meaning | | |||
| 724 | - | |------|---------| | |||
| 725 | - | | 0 | Success | | |||
| 726 | - | | 1 | General error | | |||
| 727 | - | | 2 | Configuration error | | |||
| 728 | - | ||||
| 729 | - | ## Environment Variables | |||
| 730 | - | ||||
| 731 | - | Gordon reads configuration from environment variables: | |||
| 732 | - | ||||
| 733 | - | ```bash | |||
| 734 | - | GORDON_SERVER_PORT=8080 gordon serve | |||
| 735 | - | GORDON_LOGGING_LEVEL=debug gordon serve | |||
| 736 | - | ``` | |||
| 737 | - | ||||
| 738 | - | Pattern: `GORDON_SECTION_KEY` (uppercase, underscores) | |||
| 739 | - | ||||
| 740 | - | ## Related | |||
| 741 | - | ||||
| 742 | - | - [Core Concepts](../concepts.md) | |||
| 743 | - | - [Configuration Reference](../config/index.md) | |||
A docs/cli/index.md +136 -0
| @@ -0,0 +1,136 @@ | |||||
| 1 | + | # CLI Commands | |||
| 2 | + | ||||
| 3 | + | Gordon provides a command-line interface for server management, deployment, and authentication. | |||
| 4 | + | ||||
| 5 | + | Commands are organized by where they run: | |||
| 6 | + | ||||
| 7 | + | - **Server Commands** - Must run on the machine hosting Gordon | |||
| 8 | + | - **Management Commands** - Work locally or remotely via `--remote` flag | |||
| 9 | + | - **Client Commands** - CLI utilities that don't require a running Gordon server | |||
| 10 | + | ||||
| 11 | + | ## Server Commands (local only) | |||
| 12 | + | ||||
| 13 | + | | Command | Description | Documentation | | |||
| 14 | + | |---------|-------------|---------------| | |||
| 15 | + | | `gordon serve` | Start the Gordon server | [serve](./serve.md) | | |||
| 16 | + | | `gordon auth` | Manage Gordon server authentication | [auth](./auth.md) | | |||
| 17 | + | ||||
| 18 | + | ## Management Commands (local or --remote) | |||
| 19 | + | ||||
| 20 | + | | Command | Description | Documentation | | |||
| 21 | + | |---------|-------------|---------------| | |||
| 22 | + | | `gordon routes` | Manage routes | [routes](./routes.md) | | |||
| 23 | + | | `gordon attachments` | Manage container attachments | [attachments](./attachments.md) | | |||
| 24 | + | | `gordon secrets` | Manage secrets | [secrets](./secrets.md) | | |||
| 25 | + | | `gordon deploy` | Manually deploy or redeploy a route | [serve](./serve.md#gordon-deploy) | | |||
| 26 | + | | `gordon reload` | Reload configuration and sync containers | [serve](./serve.md#gordon-reload) | | |||
| 27 | + | | `gordon logs` | Display Gordon process or container logs | [serve](./serve.md#gordon-logs) | | |||
| 28 | + | | `gordon status` | Show Gordon server status | - | | |||
| 29 | + | ||||
| 30 | + | ## Client Commands | |||
| 31 | + | ||||
| 32 | + | | Command | Description | Documentation | | |||
| 33 | + | |---------|-------------|---------------| | |||
| 34 | + | | `gordon remotes` | Manage saved remote Gordon instances | [remotes](./remotes.md) | | |||
| 35 | + | | `gordon version` | Print version information | [serve](./serve.md#gordon-version) | | |||
| 36 | + | | `gordon completion` | Generate shell autocompletion scripts | - | | |||
| 37 | + | ||||
| 38 | + | ## Quick Reference | |||
| 39 | + | ||||
| 40 | + | ```bash | |||
| 41 | + | # Start Gordon | |||
| 42 | + | gordon serve | |||
| 43 | + | gordon serve --config /path/to/config.toml | |||
| 44 | + | ||||
| 45 | + | # Reload configuration | |||
| 46 | + | gordon reload | |||
| 47 | + | ||||
| 48 | + | # Deploy a specific route | |||
| 49 | + | gordon deploy myapp.example.com | |||
| 50 | + | ||||
| 51 | + | # View logs | |||
| 52 | + | gordon logs # Gordon process logs | |||
| 53 | + | gordon logs -f # Follow process logs | |||
| 54 | + | gordon logs -n 100 # Last 100 lines | |||
| 55 | + | gordon logs myapp.local # Container logs for myapp.local | |||
| 56 | + | gordon logs myapp.local -f # Follow container logs | |||
| 57 | + | ||||
| 58 | + | # Check version | |||
| 59 | + | gordon version | |||
| 60 | + | ||||
| 61 | + | # Authentication | |||
| 62 | + | gordon auth token generate --subject ci-bot --expiry 0 | |||
| 63 | + | gordon auth token list | |||
| 64 | + | gordon auth token revoke <token-id> | |||
| 65 | + | gordon auth password hash | |||
| 66 | + | gordon auth internal | |||
| 67 | + | ||||
| 68 | + | # Routes | |||
| 69 | + | gordon routes list | |||
| 70 | + | gordon routes add myapp.example.com myapp:latest | |||
| 71 | + | gordon routes remove myapp.example.com | |||
| 72 | + | gordon routes deploy myapp.example.com | |||
| 73 | + | ||||
| 74 | + | # Attachments | |||
| 75 | + | gordon attachments list | |||
| 76 | + | gordon attachments add app.example.com postgres:18 | |||
| 77 | + | gordon attachments remove app.example.com postgres:18 | |||
| 78 | + | ||||
| 79 | + | # Secrets | |||
| 80 | + | gordon secrets list myapp.local | |||
| 81 | + | gordon secrets set myapp.local DATABASE_URL "postgres://..." | |||
| 82 | + | gordon secrets remove myapp.local DATABASE_URL | |||
| 83 | + | ||||
| 84 | + | # Remotes | |||
| 85 | + | gordon remotes add prod https://gordon.mydomain.com --token $TOKEN | |||
| 86 | + | gordon remotes list | |||
| 87 | + | gordon remotes use prod | |||
| 88 | + | ``` | |||
| 89 | + | ||||
| 90 | + | ## Global Options | |||
| 91 | + | ||||
| 92 | + | | Option | Description | | |||
| 93 | + | |--------|-------------| | |||
| 94 | + | | `-c, --config` | Path to configuration file | | |||
| 95 | + | | `--remote` | Remote Gordon URL (e.g., `https://gordon.mydomain.com`) | | |||
| 96 | + | | `--token` | Authentication token for remote | | |||
| 97 | + | ||||
| 98 | + | ### Remote Targeting | |||
| 99 | + | ||||
| 100 | + | The CLI can target remote Gordon instances using the `--remote` flag or `GORDON_REMOTE` environment variable. | |||
| 101 | + | ||||
| 102 | + | **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. | |||
| 103 | + | ||||
| 104 | + | ```bash | |||
| 105 | + | # Using flags (use the gordon_domain from remote Gordon config) | |||
| 106 | + | gordon routes list --remote https://gordon.mydomain.com --token $TOKEN | |||
| 107 | + | ||||
| 108 | + | # Using environment variables | |||
| 109 | + | export GORDON_REMOTE=https://gordon.mydomain.com | |||
| 110 | + | export GORDON_TOKEN=$TOKEN | |||
| 111 | + | gordon routes list | |||
| 112 | + | ``` | |||
| 113 | + | ||||
| 114 | + | ## Exit Codes | |||
| 115 | + | ||||
| 116 | + | | Code | Meaning | | |||
| 117 | + | |------|---------| | |||
| 118 | + | | 0 | Success | | |||
| 119 | + | | 1 | General error | | |||
| 120 | + | | 2 | Configuration error | | |||
| 121 | + | ||||
| 122 | + | ## Environment Variables | |||
| 123 | + | ||||
| 124 | + | Gordon reads configuration from environment variables: | |||
| 125 | + | ||||
| 126 | + | ```bash | |||
| 127 | + | GORDON_SERVER_PORT=8080 gordon serve | |||
| 128 | + | GORDON_LOGGING_LEVEL=debug gordon serve | |||
| 129 | + | ``` | |||
| 130 | + | ||||
| 131 | + | Pattern: `GORDON_SECTION_KEY` (uppercase, underscores) | |||
| 132 | + | ||||
| 133 | + | ## Related | |||
| 134 | + | ||||
| 135 | + | - [Core Concepts](../concepts.md) | |||
| 136 | + | - [Configuration Reference](../config/index.md) | |||
A docs/cli/remotes.md +256 -0
| @@ -0,0 +1,256 @@ | |||||
| 1 | + | # Remotes Commands | |||
| 2 | + | ||||
| 3 | + | Manage saved remote Gordon instances for easier CLI usage. | |||
| 4 | + | ||||
| 5 | + | ## gordon remotes | |||
| 6 | + | ||||
| 7 | + | ### Subcommands | |||
| 8 | + | ||||
| 9 | + | | Subcommand | Description | | |||
| 10 | + | |------------|-------------| | |||
| 11 | + | | `list` | List saved remotes | | |||
| 12 | + | | `add` | Add a new remote | | |||
| 13 | + | | `remove` | Remove a saved remote | | |||
| 14 | + | | `use` | Set active remote | | |||
| 15 | + | ||||
| 16 | + | --- | |||
| 17 | + | ||||
| 18 | + | ## gordon remotes list | |||
| 19 | + | ||||
| 20 | + | List all saved remotes. | |||
| 21 | + | ||||
| 22 | + | ```bash | |||
| 23 | + | gordon remotes list | |||
| 24 | + | ``` | |||
| 25 | + | ||||
| 26 | + | ### Output | |||
| 27 | + | ||||
| 28 | + | ``` | |||
| 29 | + | Saved Remotes | |||
| 30 | + | ||||
| 31 | + | Name URL Token Status | |||
| 32 | + | ------------------------------------------------------------------------------- | |||
| 33 | + | prod https://gordon.mydomain.com $PROD_TOKEN active | |||
| 34 | + | staging https://staging.mydomain.com set | |||
| 35 | + | dev https://dev.mydomain.com none | |||
| 36 | + | ``` | |||
| 37 | + | ||||
| 38 | + | --- | |||
| 39 | + | ||||
| 40 | + | ## gordon remotes add | |||
| 41 | + | ||||
| 42 | + | Add a new remote. | |||
| 43 | + | ||||
| 44 | + | ```bash | |||
| 45 | + | gordon remotes add <name> <url> [options] | |||
| 46 | + | ``` | |||
| 47 | + | ||||
| 48 | + | ### Arguments | |||
| 49 | + | ||||
| 50 | + | | Argument | Description | | |||
| 51 | + | |----------|-------------| | |||
| 52 | + | | `<name>` | Name for the remote (e.g., `prod`, `staging`) | | |||
| 53 | + | | `<url>` | Gordon URL (the `gordon_domain` from remote config) | | |||
| 54 | + | ||||
| 55 | + | ### Options | |||
| 56 | + | ||||
| 57 | + | | Option | Description | | |||
| 58 | + | |--------|-------------| | |||
| 59 | + | | `--token` | Store token directly in config | | |||
| 60 | + | | `--token-env` | Store environment variable name (token resolved at runtime) | | |||
| 61 | + | ||||
| 62 | + | ### Examples | |||
| 63 | + | ||||
| 64 | + | ```bash | |||
| 65 | + | # Basic (no token) | |||
| 66 | + | gordon remotes add prod https://gordon.mydomain.com | |||
| 67 | + | ||||
| 68 | + | # With direct token | |||
| 69 | + | gordon remotes add prod https://gordon.mydomain.com --token eyJ... | |||
| 70 | + | ||||
| 71 | + | # With environment variable reference (recommended) | |||
| 72 | + | gordon remotes add prod https://gordon.mydomain.com --token-env PROD_TOKEN | |||
| 73 | + | ``` | |||
| 74 | + | ||||
| 75 | + | ### Token Security | |||
| 76 | + | ||||
| 77 | + | **Option 1: Environment Variable Reference (Recommended)** | |||
| 78 | + | ||||
| 79 | + | ```bash | |||
| 80 | + | gordon remotes add prod https://gordon.mydomain.com --token-env PROD_TOKEN | |||
| 81 | + | ``` | |||
| 82 | + | ||||
| 83 | + | The `remotes.toml` stores only the variable name: | |||
| 84 | + | ||||
| 85 | + | ```toml | |||
| 86 | + | [remotes.prod] | |||
| 87 | + | url = "https://gordon.mydomain.com" | |||
| 88 | + | token_env = "PROD_TOKEN" | |||
| 89 | + | ``` | |||
| 90 | + | ||||
| 91 | + | At runtime, Gordon reads `$PROD_TOKEN` from the environment. | |||
| 92 | + | ||||
| 93 | + | **Option 2: Direct Token Storage** | |||
| 94 | + | ||||
| 95 | + | ```bash | |||
| 96 | + | gordon remotes add prod https://gordon.mydomain.com --token eyJ... | |||
| 97 | + | ``` | |||
| 98 | + | ||||
| 99 | + | The token is stored directly in `~/.config/gordon/remotes.toml`. Ensure restricted permissions: | |||
| 100 | + | ||||
| 101 | + | ```bash | |||
| 102 | + | chmod 600 ~/.config/gordon/remotes.toml | |||
| 103 | + | ``` | |||
| 104 | + | ||||
| 105 | + | --- | |||
| 106 | + | ||||
| 107 | + | ## gordon remotes remove | |||
| 108 | + | ||||
| 109 | + | Remove a saved remote. | |||
| 110 | + | ||||
| 111 | + | ```bash | |||
| 112 | + | gordon remotes remove <name> | |||
| 113 | + | gordon remotes remove <name> --force # Skip confirmation | |||
| 114 | + | ``` | |||
| 115 | + | ||||
| 116 | + | ### Arguments | |||
| 117 | + | ||||
| 118 | + | | Argument | Description | | |||
| 119 | + | |----------|-------------| | |||
| 120 | + | | `<name>` | Name of the remote to remove | | |||
| 121 | + | ||||
| 122 | + | ### Options | |||
| 123 | + | ||||
| 124 | + | | Option | Description | | |||
| 125 | + | |--------|-------------| | |||
| 126 | + | | `--force` | Skip confirmation prompt | | |||
| 127 | + | ||||
| 128 | + | ### Example | |||
| 129 | + | ||||
| 130 | + | ```bash | |||
| 131 | + | gordon remotes remove staging | |||
| 132 | + | ``` | |||
| 133 | + | ||||
| 134 | + | --- | |||
| 135 | + | ||||
| 136 | + | ## gordon remotes use | |||
| 137 | + | ||||
| 138 | + | Set the active remote. | |||
| 139 | + | ||||
| 140 | + | ```bash | |||
| 141 | + | gordon remotes use <name> | |||
| 142 | + | ``` | |||
| 143 | + | ||||
| 144 | + | ### Arguments | |||
| 145 | + | ||||
| 146 | + | | Argument | Description | | |||
| 147 | + | |----------|-------------| | |||
| 148 | + | | `<name>` | Name of the remote to set as active | | |||
| 149 | + | ||||
| 150 | + | ### Description | |||
| 151 | + | ||||
| 152 | + | When a remote is active, it's used automatically for all remote-capable commands without needing to specify `--remote` and `--token`: | |||
| 153 | + | ||||
| 154 | + | ```bash | |||
| 155 | + | gordon remotes use prod | |||
| 156 | + | gordon routes list # Uses prod remote automatically | |||
| 157 | + | gordon secrets list app.com # Uses prod remote automatically | |||
| 158 | + | ``` | |||
| 159 | + | ||||
| 160 | + | ### Example | |||
| 161 | + | ||||
| 162 | + | ```bash | |||
| 163 | + | gordon remotes use prod | |||
| 164 | + | ``` | |||
| 165 | + | ||||
| 166 | + | --- | |||
| 167 | + | ||||
| 168 | + | ## Configuration File | |||
| 169 | + | ||||
| 170 | + | Remotes are stored in `~/.config/gordon/remotes.toml`: | |||
| 171 | + | ||||
| 172 | + | ```toml | |||
| 173 | + | active = "prod" | |||
| 174 | + | ||||
| 175 | + | [remotes.prod] | |||
| 176 | + | url = "https://gordon.mydomain.com" | |||
| 177 | + | token_env = "PROD_TOKEN" | |||
| 178 | + | ||||
| 179 | + | [remotes.staging] | |||
| 180 | + | url = "https://staging.mydomain.com" | |||
| 181 | + | token = "eyJ..." | |||
| 182 | + | ``` | |||
| 183 | + | ||||
| 184 | + | ## Resolution Precedence | |||
| 185 | + | ||||
| 186 | + | When multiple sources specify remote or token, the CLI uses this priority: | |||
| 187 | + | ||||
| 188 | + | **Remote URL:** | |||
| 189 | + | 1. `--remote` flag | |||
| 190 | + | 2. `GORDON_REMOTE` environment variable | |||
| 191 | + | 3. Active remote from `remotes.toml` | |||
| 192 | + | ||||
| 193 | + | **Token:** | |||
| 194 | + | 1. `--token` flag | |||
| 195 | + | 2. `GORDON_TOKEN` environment variable | |||
| 196 | + | 3. Token from active remote in `remotes.toml` | |||
| 197 | + | ||||
| 198 | + | This allows overriding specific values while keeping defaults: | |||
| 199 | + | ||||
| 200 | + | ```bash | |||
| 201 | + | # Active remote is prod, but use different token | |||
| 202 | + | gordon routes list --token $TEMPORARY_TOKEN | |||
| 203 | + | ``` | |||
| 204 | + | ||||
| 205 | + | --- | |||
| 206 | + | ||||
| 207 | + | ## Workflow Examples | |||
| 208 | + | ||||
| 209 | + | ### Multi-Environment Setup | |||
| 210 | + | ||||
| 211 | + | ```bash | |||
| 212 | + | # Add all environments | |||
| 213 | + | gordon remotes add prod https://gordon.example.com --token-env PROD_TOKEN | |||
| 214 | + | gordon remotes add staging https://gordon.staging.example.com --token-env STAGING_TOKEN | |||
| 215 | + | gordon remotes add dev https://gordon.dev.example.com --token-env DEV_TOKEN | |||
| 216 | + | ||||
| 217 | + | # Work with prod | |||
| 218 | + | gordon remotes use prod | |||
| 219 | + | gordon routes list | |||
| 220 | + | gordon secrets list myapp.example.com | |||
| 221 | + | ||||
| 222 | + | # Switch to staging | |||
| 223 | + | gordon remotes use staging | |||
| 224 | + | gordon routes list | |||
| 225 | + | ``` | |||
| 226 | + | ||||
| 227 | + | ### CI/CD Pipeline | |||
| 228 | + | ||||
| 229 | + | ```yaml | |||
| 230 | + | # GitHub Actions example | |||
| 231 | + | env: | |||
| 232 | + | GORDON_REMOTE: ${{ secrets.GORDON_URL }} | |||
| 233 | + | GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }} | |||
| 234 | + | ||||
| 235 | + | steps: | |||
| 236 | + | - name: Deploy to Gordon | |||
| 237 | + | run: | | |||
| 238 | + | gordon routes deploy myapp.example.com | |||
| 239 | + | ``` | |||
| 240 | + | ||||
| 241 | + | ### Compare Environments | |||
| 242 | + | ||||
| 243 | + | ```bash | |||
| 244 | + | # Compare routes across environments | |||
| 245 | + | gordon routes list --remote https://gordon.example.com --token $PROD_TOKEN | |||
| 246 | + | gordon routes list --remote https://gordon.staging.example.com --token $STAGING_TOKEN | |||
| 247 | + | ||||
| 248 | + | # Or switch between active remotes | |||
| 249 | + | gordon remotes use prod && gordon routes list | |||
| 250 | + | gordon remotes use staging && gordon routes list | |||
| 251 | + | ``` | |||
| 252 | + | ||||
| 253 | + | ## Related | |||
| 254 | + | ||||
| 255 | + | - [CLI Overview](./index.md) | |||
| 256 | + | - [Remote CLI Guide](/wiki/guides/remote-cli.md) | |||
A docs/cli/routes.md +148 -0
| @@ -0,0 +1,148 @@ | |||||
| 1 | + | # Routes Commands | |||
| 2 | + | ||||
| 3 | + | Manage routes on local or remote Gordon instances. | |||
| 4 | + | ||||
| 5 | + | ## gordon routes | |||
| 6 | + | ||||
| 7 | + | ### Subcommands | |||
| 8 | + | ||||
| 9 | + | | Subcommand | Description | | |||
| 10 | + | |------------|-------------| | |||
| 11 | + | | `list` | List all routes | | |||
| 12 | + | | `add` | Add a new route | | |||
| 13 | + | | `remove` | Remove a route | | |||
| 14 | + | | `deploy` | Deploy a specific route | | |||
| 15 | + | ||||
| 16 | + | --- | |||
| 17 | + | ||||
| 18 | + | ## gordon routes list | |||
| 19 | + | ||||
| 20 | + | List all configured routes. | |||
| 21 | + | ||||
| 22 | + | ```bash | |||
| 23 | + | gordon routes list | |||
| 24 | + | gordon routes list --remote https://gordon.mydomain.com --token $TOKEN | |||
| 25 | + | ``` | |||
| 26 | + | ||||
| 27 | + | ### Output | |||
| 28 | + | ||||
| 29 | + | ``` | |||
| 30 | + | Domain Image Status | |||
| 31 | + | -------------------------------------------------------------------------------- | |||
| 32 | + | app.example.com myapp:latest running | |||
| 33 | + | api.example.com myapi:v2.1.0 running | |||
| 34 | + | admin.example.com admin-panel:latest stopped | |||
| 35 | + | ``` | |||
| 36 | + | ||||
| 37 | + | --- | |||
| 38 | + | ||||
| 39 | + | ## gordon routes add | |||
| 40 | + | ||||
| 41 | + | Add a new route. | |||
| 42 | + | ||||
| 43 | + | ```bash | |||
| 44 | + | gordon routes add <domain> <image> | |||
| 45 | + | gordon routes add myapp.example.com myapp:latest | |||
| 46 | + | ``` | |||
| 47 | + | ||||
| 48 | + | ### Arguments | |||
| 49 | + | ||||
| 50 | + | | Argument | Description | | |||
| 51 | + | |----------|-------------| | |||
| 52 | + | | `<domain>` | The domain name for the route | | |||
| 53 | + | | `<image>` | The container image to deploy | | |||
| 54 | + | ||||
| 55 | + | ### Options | |||
| 56 | + | ||||
| 57 | + | | Option | Description | | |||
| 58 | + | |--------|-------------| | |||
| 59 | + | | `--remote` | Remote Gordon URL | | |||
| 60 | + | | `--token` | Authentication token for remote | | |||
| 61 | + | ||||
| 62 | + | ### Examples | |||
| 63 | + | ||||
| 64 | + | ```bash | |||
| 65 | + | # Local | |||
| 66 | + | gordon routes add myapp.example.com myapp:latest | |||
| 67 | + | gordon routes add api.example.com myapi:v2.1.0 | |||
| 68 | + | ||||
| 69 | + | # Remote | |||
| 70 | + | gordon routes add myapp.example.com myapp:latest --remote https://gordon.mydomain.com --token $TOKEN | |||
| 71 | + | ``` | |||
| 72 | + | ||||
| 73 | + | --- | |||
| 74 | + | ||||
| 75 | + | ## gordon routes remove | |||
| 76 | + | ||||
| 77 | + | Remove a route. | |||
| 78 | + | ||||
| 79 | + | ```bash | |||
| 80 | + | gordon routes remove <domain> | |||
| 81 | + | gordon routes remove myapp.example.com | |||
| 82 | + | ``` | |||
| 83 | + | ||||
| 84 | + | ### Arguments | |||
| 85 | + | ||||
| 86 | + | | Argument | Description | | |||
| 87 | + | |----------|-------------| | |||
| 88 | + | | `<domain>` | The domain name of the route to remove | | |||
| 89 | + | ||||
| 90 | + | ### Options | |||
| 91 | + | ||||
| 92 | + | | Option | Description | | |||
| 93 | + | |--------|-------------| | |||
| 94 | + | | `--remote` | Remote Gordon URL | | |||
| 95 | + | | `--token` | Authentication token for remote | | |||
| 96 | + | ||||
| 97 | + | ### Examples | |||
| 98 | + | ||||
| 99 | + | ```bash | |||
| 100 | + | # Local | |||
| 101 | + | gordon routes remove myapp.example.com | |||
| 102 | + | ||||
| 103 | + | # Remote | |||
| 104 | + | gordon routes remove myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN | |||
| 105 | + | ``` | |||
| 106 | + | ||||
| 107 | + | --- | |||
| 108 | + | ||||
| 109 | + | ## gordon routes deploy | |||
| 110 | + | ||||
| 111 | + | Deploy or redeploy a specific route. | |||
| 112 | + | ||||
| 113 | + | ```bash | |||
| 114 | + | gordon routes deploy <domain> | |||
| 115 | + | gordon routes deploy myapp.example.com | |||
| 116 | + | ``` | |||
| 117 | + | ||||
| 118 | + | ### Arguments | |||
| 119 | + | ||||
| 120 | + | | Argument | Description | | |||
| 121 | + | |----------|-------------| | |||
| 122 | + | | `<domain>` | The domain name of the route to deploy | | |||
| 123 | + | ||||
| 124 | + | ### Options | |||
| 125 | + | ||||
| 126 | + | | Option | Description | | |||
| 127 | + | |--------|-------------| | |||
| 128 | + | | `--remote` | Remote Gordon URL | | |||
| 129 | + | | `--token` | Authentication token for remote | | |||
| 130 | + | ||||
| 131 | + | ### Description | |||
| 132 | + | ||||
| 133 | + | Triggers a fresh image pull and container redeployment for the specified route. | |||
| 134 | + | ||||
| 135 | + | ### Examples | |||
| 136 | + | ||||
| 137 | + | ```bash | |||
| 138 | + | # Local | |||
| 139 | + | gordon routes deploy myapp.example.com | |||
| 140 | + | ||||
| 141 | + | # Remote | |||
| 142 | + | gordon routes deploy myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN | |||
| 143 | + | ``` | |||
| 144 | + | ||||
| 145 | + | ## Related | |||
| 146 | + | ||||
| 147 | + | - [CLI Overview](./index.md) | |||
| 148 | + | - [Routes Configuration](../config/routes.md) | |||
A docs/cli/secrets.md +182 -0
| @@ -0,0 +1,182 @@ | |||||
| 1 | + | # Secrets Commands | |||
| 2 | + | ||||
| 3 | + | Manage secrets on local or remote Gordon instances. | |||
| 4 | + | ||||
| 5 | + | ## gordon secrets | |||
| 6 | + | ||||
| 7 | + | ### Subcommands | |||
| 8 | + | ||||
| 9 | + | | Subcommand | Description | | |||
| 10 | + | |------------|-------------| | |||
| 11 | + | | `list` | List all secrets for a domain | | |||
| 12 | + | | `set` | Set a secret value | | |||
| 13 | + | | `remove` | Remove a secret | | |||
| 14 | + | ||||
| 15 | + | --- | |||
| 16 | + | ||||
| 17 | + | ## gordon secrets list | |||
| 18 | + | ||||
| 19 | + | List all secrets for a specific domain. | |||
| 20 | + | ||||
| 21 | + | ```bash | |||
| 22 | + | gordon secrets list <domain> | |||
| 23 | + | ``` | |||
| 24 | + | ||||
| 25 | + | ### Arguments | |||
| 26 | + | ||||
| 27 | + | | Argument | Description | | |||
| 28 | + | |----------|-------------| | |||
| 29 | + | | `<domain>` | The domain name to list secrets for | | |||
| 30 | + | ||||
| 31 | + | ### Options | |||
| 32 | + | ||||
| 33 | + | | Option | Description | | |||
| 34 | + | |--------|-------------| | |||
| 35 | + | | `--remote` | Remote Gordon URL | | |||
| 36 | + | | `--token` | Authentication token for remote | | |||
| 37 | + | ||||
| 38 | + | ### Examples | |||
| 39 | + | ||||
| 40 | + | ```bash | |||
| 41 | + | # Local | |||
| 42 | + | gordon secrets list myapp.local | |||
| 43 | + | ||||
| 44 | + | # Remote | |||
| 45 | + | gordon secrets list myapp.local --remote https://gordon.mydomain.com --token $TOKEN | |||
| 46 | + | ``` | |||
| 47 | + | ||||
| 48 | + | ### Output | |||
| 49 | + | ||||
| 50 | + | ``` | |||
| 51 | + | Secret Value (masked) | |||
| 52 | + | -------------------------------------------------------------------------------- | |||
| 53 | + | DATABASE_URL postgres://... | |||
| 54 | + | API_KEY **** | |||
| 55 | + | JWT_SECRET **** | |||
| 56 | + | ``` | |||
| 57 | + | ||||
| 58 | + | --- | |||
| 59 | + | ||||
| 60 | + | ## gordon secrets set | |||
| 61 | + | ||||
| 62 | + | Set a secret value for a domain. | |||
| 63 | + | ||||
| 64 | + | ```bash | |||
| 65 | + | gordon secrets set <domain> <key> <value> | |||
| 66 | + | gordon secrets set myapp.local DATABASE_URL "postgres://..." | |||
| 67 | + | ``` | |||
| 68 | + | ||||
| 69 | + | ### Arguments | |||
| 70 | + | ||||
| 71 | + | | Argument | Description | | |||
| 72 | + | |----------|-------------| | |||
| 73 | + | | `<domain>` | The domain name to set the secret for | | |||
| 74 | + | | `<key>` | The secret key (environment variable name) | | |||
| 75 | + | | `<value>` | The secret value | | |||
| 76 | + | ||||
| 77 | + | ### Options | |||
| 78 | + | ||||
| 79 | + | | Option | Description | | |||
| 80 | + | |--------|-------------| | |||
| 81 | + | | `--remote` | Remote Gordon URL | | |||
| 82 | + | | `--token` | Authentication token for remote | | |||
| 83 | + | ||||
| 84 | + | ### Examples | |||
| 85 | + | ||||
| 86 | + | ```bash | |||
| 87 | + | # Local | |||
| 88 | + | gordon secrets set myapp.local DATABASE_URL "postgres://user:pass@postgres:5432/db" | |||
| 89 | + | gordon secrets set myapp.local API_KEY "your-api-key" | |||
| 90 | + | ||||
| 91 | + | # Remote | |||
| 92 | + | gordon secrets set myapp.local DATABASE_URL "postgres://..." --remote https://gordon.mydomain.com --token $TOKEN | |||
| 93 | + | ``` | |||
| 94 | + | ||||
| 95 | + | --- | |||
| 96 | + | ||||
| 97 | + | ## gordon secrets remove | |||
| 98 | + | ||||
| 99 | + | Remove a secret from a domain. | |||
| 100 | + | ||||
| 101 | + | ```bash | |||
| 102 | + | gordon secrets remove <domain> <key> | |||
| 103 | + | ``` | |||
| 104 | + | ||||
| 105 | + | ### Arguments | |||
| 106 | + | ||||
| 107 | + | | Argument | Description | | |||
| 108 | + | |----------|-------------| | |||
| 109 | + | | `<domain>` | The domain name | | |||
| 110 | + | | `<key>` | The secret key to remove | | |||
| 111 | + | ||||
| 112 | + | ### Options | |||
| 113 | + | ||||
| 114 | + | | Option | Description | | |||
| 115 | + | |--------|-------------| | |||
| 116 | + | | `--remote` | Remote Gordon URL | | |||
| 117 | + | | `--token` | Authentication token for remote | | |||
| 118 | + | ||||
| 119 | + | ### Examples | |||
| 120 | + | ||||
| 121 | + | ```bash | |||
| 122 | + | # Local | |||
| 123 | + | gordon secrets remove myapp.local DATABASE_URL | |||
| 124 | + | ||||
| 125 | + | # Remote | |||
| 126 | + | gordon secrets remove myapp.local DATABASE_URL --remote https://gordon.mydomain.com --token $TOKEN | |||
| 127 | + | ``` | |||
| 128 | + | ||||
| 129 | + | --- | |||
| 130 | + | ||||
| 131 | + | ## Workflow Examples | |||
| 132 | + | ||||
| 133 | + | ### Setting Up Application Secrets | |||
| 134 | + | ||||
| 135 | + | ```bash | |||
| 136 | + | # Database connection | |||
| 137 | + | gordon secrets set myapp.local DATABASE_URL "postgres://user:pass@postgres:5432/mydb" | |||
| 138 | + | ||||
| 139 | + | # API keys | |||
| 140 | + | gordon secrets set myapp.local STRIPE_KEY "sk_live_..." | |||
| 141 | + | gordon secrets set myapp.local SENDGRID_KEY "SG..." | |||
| 142 | + | ||||
| 143 | + | # JWT secret | |||
| 144 | + | gordon secrets set myapp.local JWT_SECRET "your-jwt-secret-here" | |||
| 145 | + | ||||
| 146 | + | # Verify | |||
| 147 | + | gordon secrets list myapp.local | |||
| 148 | + | ``` | |||
| 149 | + | ||||
| 150 | + | ### CI/CD Secret Management | |||
| 151 | + | ||||
| 152 | + | ```bash | |||
| 153 | + | # In your CI/CD pipeline | |||
| 154 | + | export GORDON_REMOTE=https://gordon.mydomain.com | |||
| 155 | + | export GORDON_TOKEN=$GORDON_TOKEN | |||
| 156 | + | ||||
| 157 | + | # Update secrets before deploy | |||
| 158 | + | gordon secrets set myapp.example.com DATABASE_URL "$DATABASE_URL" | |||
| 159 | + | gordon secrets set myapp.example.com API_KEY "$API_KEY" | |||
| 160 | + | ||||
| 161 | + | # Deploy | |||
| 162 | + | gordon routes deploy myapp.example.com | |||
| 163 | + | ``` | |||
| 164 | + | ||||
| 165 | + | ### Rotating Secrets | |||
| 166 | + | ||||
| 167 | + | ```bash | |||
| 168 | + | # Generate new secret | |||
| 169 | + | NEW_JWT_SECRET=$(openssl rand -base64 32) | |||
| 170 | + | ||||
| 171 | + | # Update the secret | |||
| 172 | + | gordon secrets set myapp.local JWT_SECRET "$NEW_JWT_SECRET" | |||
| 173 | + | ||||
| 174 | + | # Redeploy to pick up new secret | |||
| 175 | + | gordon routes deploy myapp.local | |||
| 176 | + | ``` | |||
| 177 | + | ||||
| 178 | + | ## Related | |||
| 179 | + | ||||
| 180 | + | - [CLI Overview](./index.md) | |||
| 181 | + | - [Secrets Configuration](../config/secrets.md) | |||
| 182 | + | - [Environment Variables](../config/env.md) | |||
A docs/cli/serve.md +291 -0
| @@ -0,0 +1,291 @@ | |||||
| 1 | + | # Server Commands | |||
| 2 | + | ||||
| 3 | + | Commands for running and managing the Gordon server process. | |||
| 4 | + | ||||
| 5 | + | ## gordon serve | |||
| 6 | + | ||||
| 7 | + | Start the Gordon server with registry and proxy components. | |||
| 8 | + | ||||
| 9 | + | ### Synopsis | |||
| 10 | + | ||||
| 11 | + | ```bash | |||
| 12 | + | gordon serve [options] | |||
| 13 | + | ``` | |||
| 14 | + | ||||
| 15 | + | ### Options | |||
| 16 | + | ||||
| 17 | + | | Option | Short | Default | Description | | |||
| 18 | + | |--------|-------|---------|-------------| | |||
| 19 | + | | `--config` | `-c` | Auto-detected | Path to configuration file | | |||
| 20 | + | ||||
| 21 | + | ### Description | |||
| 22 | + | ||||
| 23 | + | Starts the Gordon server, which includes: | |||
| 24 | + | ||||
| 25 | + | - **Container Registry** - Receives image pushes on the registry port | |||
| 26 | + | - **HTTP Proxy** - Routes traffic to containers on the proxy port | |||
| 27 | + | - **Event Bus** - Coordinates deployments and updates | |||
| 28 | + | - **Config Watcher** - Monitors configuration file for changes | |||
| 29 | + | ||||
| 30 | + | ### Configuration File Detection | |||
| 31 | + | ||||
| 32 | + | Gordon looks for configuration in this order: | |||
| 33 | + | ||||
| 34 | + | 1. Path specified with `--config` | |||
| 35 | + | 2. `/etc/gordon/gordon.toml` | |||
| 36 | + | 3. `~/.config/gordon/gordon.toml` | |||
| 37 | + | 4. `./gordon.toml` | |||
| 38 | + | ||||
| 39 | + | ### First Run | |||
| 40 | + | ||||
| 41 | + | On first run without a config file, Gordon creates a default configuration at `~/.config/gordon/gordon.toml`. | |||
| 42 | + | ||||
| 43 | + | ```bash | |||
| 44 | + | # First run - creates default config | |||
| 45 | + | gordon serve | |||
| 46 | + | # Edit the config, then restart | |||
| 47 | + | ``` | |||
| 48 | + | ||||
| 49 | + | ### Examples | |||
| 50 | + | ||||
| 51 | + | ```bash | |||
| 52 | + | # Basic start | |||
| 53 | + | gordon serve | |||
| 54 | + | ||||
| 55 | + | # With custom config | |||
| 56 | + | gordon serve --config /path/to/gordon.toml | |||
| 57 | + | gordon serve -c ./my-config.toml | |||
| 58 | + | ||||
| 59 | + | # With environment override | |||
| 60 | + | GORDON_SERVER_PORT=8080 gordon serve | |||
| 61 | + | GORDON_LOGGING_LEVEL=debug gordon serve | |||
| 62 | + | ``` | |||
| 63 | + | ||||
| 64 | + | ### Signals | |||
| 65 | + | ||||
| 66 | + | Gordon responds to these signals: | |||
| 67 | + | ||||
| 68 | + | | Signal | Action | | |||
| 69 | + | |--------|--------| | |||
| 70 | + | | `SIGTERM` | Graceful shutdown | | |||
| 71 | + | | `SIGINT` | Graceful shutdown (Ctrl+C) | | |||
| 72 | + | | `SIGUSR1` | Reload configuration | | |||
| 73 | + | | `SIGUSR2` | Manual deploy (used by `gordon deploy`) | | |||
| 74 | + | ||||
| 75 | + | ### Running with systemd | |||
| 76 | + | ||||
| 77 | + | ```bash | |||
| 78 | + | # Create user service | |||
| 79 | + | mkdir -p ~/.config/systemd/user | |||
| 80 | + | cat > ~/.config/systemd/user/gordon.service <<EOF | |||
| 81 | + | [Unit] | |||
| 82 | + | Description=Gordon Container Platform | |||
| 83 | + | ||||
| 84 | + | [Service] | |||
| 85 | + | Type=simple | |||
| 86 | + | Restart=always | |||
| 87 | + | ExecStart=/usr/local/bin/gordon serve | |||
| 88 | + | ||||
| 89 | + | [Install] | |||
| 90 | + | WantedBy=default.target | |||
| 91 | + | EOF | |||
| 92 | + | ||||
| 93 | + | # Enable and start | |||
| 94 | + | systemctl --user daemon-reload | |||
| 95 | + | systemctl --user enable --now gordon | |||
| 96 | + | sudo loginctl enable-linger $USER | |||
| 97 | + | ``` | |||
| 98 | + | ||||
| 99 | + | ### Startup Sequence | |||
| 100 | + | ||||
| 101 | + | 1. Load configuration | |||
| 102 | + | 2. Initialize logger | |||
| 103 | + | 3. Create PID file | |||
| 104 | + | 4. Connect to Docker/Podman runtime | |||
| 105 | + | 5. Create storage directories | |||
| 106 | + | 6. Initialize services (registry, proxy, auth) | |||
| 107 | + | 7. Register event handlers | |||
| 108 | + | 8. Start config file watcher | |||
| 109 | + | 9. Sync existing containers | |||
| 110 | + | 10. Start HTTP servers | |||
| 111 | + | ||||
| 112 | + | ### Shutdown Sequence | |||
| 113 | + | ||||
| 114 | + | 1. Receive shutdown signal | |||
| 115 | + | 2. Stop accepting new requests | |||
| 116 | + | 3. Complete in-flight requests | |||
| 117 | + | 4. Stop managed containers (if configured) | |||
| 118 | + | 5. Remove PID file | |||
| 119 | + | 6. Exit | |||
| 120 | + | ||||
| 121 | + | --- | |||
| 122 | + | ||||
| 123 | + | ## gordon reload | |||
| 124 | + | ||||
| 125 | + | Reload configuration and sync containers to match. | |||
| 126 | + | ||||
| 127 | + | ### Synopsis | |||
| 128 | + | ||||
| 129 | + | ```bash | |||
| 130 | + | gordon reload | |||
| 131 | + | ``` | |||
| 132 | + | ||||
| 133 | + | ### Description | |||
| 134 | + | ||||
| 135 | + | Sends `SIGUSR1` to the running Gordon process, triggering: | |||
| 136 | + | ||||
| 137 | + | - Configuration file reload | |||
| 138 | + | - Route synchronization | |||
| 139 | + | - Deployment of containers for routes missing containers | |||
| 140 | + | - Attachment deployment | |||
| 141 | + | ||||
| 142 | + | ### Example | |||
| 143 | + | ||||
| 144 | + | ```bash | |||
| 145 | + | # After editing gordon.toml, apply changes without restart | |||
| 146 | + | vim ~/.config/gordon/gordon.toml | |||
| 147 | + | gordon reload | |||
| 148 | + | ``` | |||
| 149 | + | ||||
| 150 | + | --- | |||
| 151 | + | ||||
| 152 | + | ## gordon deploy | |||
| 153 | + | ||||
| 154 | + | Manually deploy or redeploy a specific route. | |||
| 155 | + | ||||
| 156 | + | ### Synopsis | |||
| 157 | + | ||||
| 158 | + | ```bash | |||
| 159 | + | gordon deploy <domain> [options] | |||
| 160 | + | ``` | |||
| 161 | + | ||||
| 162 | + | ### Arguments | |||
| 163 | + | ||||
| 164 | + | | Argument | Description | | |||
| 165 | + | |----------|-------------| | |||
| 166 | + | | `<domain>` | The domain name of the route to deploy (required) | | |||
| 167 | + | ||||
| 168 | + | ### Options | |||
| 169 | + | ||||
| 170 | + | | Option | Description | | |||
| 171 | + | |--------|-------------| | |||
| 172 | + | | `--remote` | Remote Gordon URL | | |||
| 173 | + | | `--token` | Authentication token for remote | | |||
| 174 | + | ||||
| 175 | + | ### Description | |||
| 176 | + | ||||
| 177 | + | **Local mode:** Sends `SIGUSR2` to the Gordon process with the specified domain. | |||
| 178 | + | ||||
| 179 | + | **Remote mode:** Calls the remote Gordon Admin API to trigger deployment. | |||
| 180 | + | ||||
| 181 | + | Both modes trigger: | |||
| 182 | + | ||||
| 183 | + | - Fresh image pull (always pulls latest, ignoring cache) | |||
| 184 | + | - Container redeployment for the specified route | |||
| 185 | + | ||||
| 186 | + | ### Examples | |||
| 187 | + | ||||
| 188 | + | ```bash | |||
| 189 | + | # Local deployment | |||
| 190 | + | gordon deploy myapp.example.com | |||
| 191 | + | gordon deploy api.example.com | |||
| 192 | + | ||||
| 193 | + | # Remote deployment | |||
| 194 | + | gordon deploy myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN | |||
| 195 | + | ``` | |||
| 196 | + | ||||
| 197 | + | ### Use Cases | |||
| 198 | + | ||||
| 199 | + | - Recover from a failed deployment | |||
| 200 | + | - Force redeploy without pushing a new image | |||
| 201 | + | - Manual deployment when automatic deploy didn't trigger | |||
| 202 | + | - Trigger deployments on remote Gordon instances from CI/CD | |||
| 203 | + | ||||
| 204 | + | --- | |||
| 205 | + | ||||
| 206 | + | ## gordon logs | |||
| 207 | + | ||||
| 208 | + | Display Gordon process logs or container logs. | |||
| 209 | + | ||||
| 210 | + | ### Synopsis | |||
| 211 | + | ||||
| 212 | + | ```bash | |||
| 213 | + | gordon logs [domain] [options] | |||
| 214 | + | ``` | |||
| 215 | + | ||||
| 216 | + | ### Arguments | |||
| 217 | + | ||||
| 218 | + | | Argument | Description | | |||
| 219 | + | |----------|-------------| | |||
| 220 | + | | `[domain]` | Optional. Container domain to view logs for. Without this, shows Gordon process logs. | | |||
| 221 | + | ||||
| 222 | + | ### Options | |||
| 223 | + | ||||
| 224 | + | | Option | Short | Default | Description | | |||
| 225 | + | |--------|-------|---------|-------------| | |||
| 226 | + | | `--config` | `-c` | Auto | Path to config file | | |||
| 227 | + | | `--follow` | `-f` | false | Follow log output (like `tail -f`) | | |||
| 228 | + | | `--lines` | `-n` | 50 | Number of lines to show | | |||
| 229 | + | | `--remote` | | | Remote Gordon URL | | |||
| 230 | + | | `--token` | | | Authentication token for remote | | |||
| 231 | + | ||||
| 232 | + | ### Examples | |||
| 233 | + | ||||
| 234 | + | ```bash | |||
| 235 | + | # Gordon process logs | |||
| 236 | + | gordon logs # Last 50 lines | |||
| 237 | + | gordon logs -f # Follow logs | |||
| 238 | + | gordon logs -n 100 # Last 100 lines | |||
| 239 | + | gordon logs -f -n 200 # Follow, starting from last 200 lines | |||
| 240 | + | ||||
| 241 | + | # Container logs | |||
| 242 | + | gordon logs myapp.local # Last 50 lines from container | |||
| 243 | + | gordon logs myapp.local -f # Follow container logs | |||
| 244 | + | gordon logs myapp.local -n 100 # Last 100 lines from container | |||
| 245 | + | ||||
| 246 | + | # Remote mode | |||
| 247 | + | gordon logs --remote https://gordon.mydomain.com --token $TOKEN | |||
| 248 | + | gordon logs myapp.local --remote https://gordon.mydomain.com --token $TOKEN | |||
| 249 | + | ``` | |||
| 250 | + | ||||
| 251 | + | ### Log Locations | |||
| 252 | + | ||||
| 253 | + | ```bash | |||
| 254 | + | # Using gordon logs | |||
| 255 | + | gordon logs -f | |||
| 256 | + | ||||
| 257 | + | # Direct file access | |||
| 258 | + | tail -f ~/.gordon/logs/gordon.log | |||
| 259 | + | ||||
| 260 | + | # With systemd | |||
| 261 | + | journalctl --user -u gordon -f | |||
| 262 | + | ||||
| 263 | + | # Container logs via docker (local alternative) | |||
| 264 | + | docker logs --tail 50 myapp.local | |||
| 265 | + | docker logs -f myapp.local | |||
| 266 | + | ``` | |||
| 267 | + | ||||
| 268 | + | --- | |||
| 269 | + | ||||
| 270 | + | ## gordon version | |||
| 271 | + | ||||
| 272 | + | Print version information. | |||
| 273 | + | ||||
| 274 | + | ### Synopsis | |||
| 275 | + | ||||
| 276 | + | ```bash | |||
| 277 | + | gordon version | |||
| 278 | + | ``` | |||
| 279 | + | ||||
| 280 | + | ### Output | |||
| 281 | + | ||||
| 282 | + | ``` | |||
| 283 | + | Gordon v2.0.0 | |||
| 284 | + | Commit: abc1234 | |||
| 285 | + | Build Date: 2024-01-15 | |||
| 286 | + | ``` | |||
| 287 | + | ||||
| 288 | + | ## Related | |||
| 289 | + | ||||
| 290 | + | - [CLI Overview](./index.md) | |||
| 291 | + | - [Configuration Reference](../config/index.md) | |||
M docs/concepts.md +1 -1
| @@ -142,7 +142,7 @@ Both `app.mydomain.com` and `api.mydomain.com` can access the shared services. | |||||
| 142 | Gordon automatically creates persistent storage from Dockerfile `VOLUME` directives: | 142 | Gordon automatically creates persistent storage from Dockerfile `VOLUME` directives: | ||
| 143 | 143 | ||||
| 144 | ```dockerfile | 144 | ```dockerfile | ||
| 145 | - | FROM postgres:15 | 145 | + | FROM postgres:18 |
| 146 | VOLUME ["/var/lib/postgresql/data"] | 146 | VOLUME ["/var/lib/postgresql/data"] | ||
| 147 | ``` | 147 | ``` | ||
| 148 | 148 | ||||
M docs/config/attachments.md +69 -3
| @@ -2,6 +2,17 @@ | |||||
| 2 | 2 | ||||
| 3 | Attach service dependencies (databases, caches, queues) to your applications. | 3 | Attach service dependencies (databases, caches, queues) to your applications. | ||
| 4 | 4 | ||||
| 5 | + | ## Requirements | |||
| 6 | + | ||||
| 7 | + | **Network isolation must be enabled** for attachments to work properly: | |||
| 8 | + | ||||
| 9 | + | ```toml | |||
| 10 | + | [network_isolation] | |||
| 11 | + | enabled = true | |||
| 12 | + | ``` | |||
| 13 | + | ||||
| 14 | + | Without network isolation, containers run on Docker's default bridge network which **does not provide DNS resolution**. Your application won't be able to reach attachments by hostname (e.g., `postgres:5432`). | |||
| 15 | + | ||||
| 5 | ## Configuration | 16 | ## Configuration | ||
| 6 | 17 | ||||
| 7 | ```toml | 18 | ```toml | ||
| @@ -65,7 +76,7 @@ Use Dockerfile VOLUME directives for persistent data: | |||||
| 65 | 76 | ||||
| 66 | ```dockerfile | 77 | ```dockerfile | ||
| 67 | # postgres.Dockerfile | 78 | # postgres.Dockerfile | ||
| 68 | - | FROM postgres:15 | 79 | + | FROM postgres:18 |
| 69 | VOLUME ["/var/lib/postgresql/data"] | 80 | VOLUME ["/var/lib/postgresql/data"] | ||
| 70 | ENV POSTGRES_DB=myapp | 81 | ENV POSTGRES_DB=myapp | ||
| 71 | ENV POSTGRES_USER=app | 82 | ENV POSTGRES_USER=app | ||
| @@ -143,7 +154,7 @@ Multiple apps share the same service instances: | |||||
| 143 | "app.mydomain.com" = "myapp:latest" | 154 | "app.mydomain.com" = "myapp:latest" | ||
| 144 | 155 | ||||
| 145 | [attachments] | 156 | [attachments] | ||
| 146 | - | "app.mydomain.com" = ["postgres:15", "redis:7-alpine"] | 157 | + | "app.mydomain.com" = ["postgres:18", "redis:7-alpine"] |
| 147 | ``` | 158 | ``` | ||
| 148 | 159 | ||||
| 149 | ### Microservices with Shared Queue | 160 | ### Microservices with Shared Queue | ||
| @@ -169,7 +180,7 @@ Build custom service images with your configuration: | |||||
| 169 | 180 | ||||
| 170 | ```dockerfile | 181 | ```dockerfile | ||
| 171 | # my-postgres.Dockerfile | 182 | # my-postgres.Dockerfile | ||
| 172 | - | FROM postgres:15 | 183 | + | FROM postgres:18 |
| 173 | VOLUME ["/var/lib/postgresql/data"] | 184 | VOLUME ["/var/lib/postgresql/data"] | ||
| 174 | ENV POSTGRES_DB=production | 185 | ENV POSTGRES_DB=production | ||
| 175 | ENV POSTGRES_USER=appuser | 186 | ENV POSTGRES_USER=appuser | ||
| @@ -204,8 +215,63 @@ Gordon adds labels to attachment containers: | |||||
| 204 | | `gordon.attachment` | `true` | | 215 | | `gordon.attachment` | `true` | | ||
| 205 | | `gordon.attached-to` | Domain or group name | | 216 | | `gordon.attached-to` | Domain or group name | | ||
| 206 | 217 | ||||
| 218 | + | ## CLI Management | |||
| 219 | + | ||||
| 220 | + | Manage attachments via the CLI without editing configuration files. | |||
| 221 | + | ||||
| 222 | + | ### List Attachments | |||
| 223 | + | ||||
| 224 | + | ```bash | |||
| 225 | + | # List all attachments | |||
| 226 | + | gordon attachments list | |||
| 227 | + | ||||
| 228 | + | # List attachments for a specific domain or network group | |||
| 229 | + | gordon attachments list app.mydomain.com | |||
| 230 | + | gordon attachments list backend | |||
| 231 | + | ||||
| 232 | + | # Remote mode | |||
| 233 | + | gordon attachments list --remote https://gordon.mydomain.com --token $TOKEN | |||
| 234 | + | ``` | |||
| 235 | + | ||||
| 236 | + | ### Add Attachments | |||
| 237 | + | ||||
| 238 | + | ```bash | |||
| 239 | + | # Add attachment to a domain | |||
| 240 | + | gordon attachments add app.mydomain.com postgres:18 | |||
| 241 | + | ||||
| 242 | + | # Add attachment to a network group | |||
| 243 | + | gordon attachments add backend redis:7-alpine | |||
| 244 | + | ||||
| 245 | + | # Remote mode | |||
| 246 | + | gordon attachments add app.mydomain.com postgres:18 --remote https://gordon.mydomain.com --token $TOKEN | |||
| 247 | + | ``` | |||
| 248 | + | ||||
| 249 | + | ### Remove Attachments | |||
| 250 | + | ||||
| 251 | + | ```bash | |||
| 252 | + | # Remove attachment from a domain | |||
| 253 | + | gordon attachments remove app.mydomain.com postgres:18 | |||
| 254 | + | ||||
| 255 | + | # Remove from network group | |||
| 256 | + | gordon attachments remove backend redis:7-alpine | |||
| 257 | + | ||||
| 258 | + | # Remote mode | |||
| 259 | + | gordon attachments remove app.mydomain.com postgres:18 --remote https://gordon.mydomain.com --token $TOKEN | |||
| 260 | + | ``` | |||
| 261 | + | ||||
| 262 | + | ### Alias | |||
| 263 | + | ||||
| 264 | + | The `gordon attach` command is an alias for `gordon attachments`: | |||
| 265 | + | ||||
| 266 | + | ```bash | |||
| 267 | + | gordon attach list | |||
| 268 | + | gordon attach add app.mydomain.com postgres:18 | |||
| 269 | + | gordon attach remove app.mydomain.com postgres:18 | |||
| 270 | + | ``` | |||
| 271 | + | ||||
| 207 | ## Related | 272 | ## Related | ||
| 208 | 273 | ||||
| 209 | - [Network Isolation](./network-isolation.md) | 274 | - [Network Isolation](./network-isolation.md) | ||
| 210 | - [Network Groups](./network-groups.md) | 275 | - [Network Groups](./network-groups.md) | ||
| 211 | - [Routes](./routes.md) | 276 | - [Routes](./routes.md) | ||
| 277 | + | - [CLI Commands](/docs/cli/index.md) | |||
R docs/config/registry-auth.md → docs/config/auth.md +121 -56 renamed, 52% similar
| @@ -1,33 +1,68 @@ | |||||
| 1 | - | # Registry Authentication | 1 | + | # Authentication |
| 2 | 2 | ||||
| 3 | - | Secure your container registry with password or token-based authentication. | 3 | + | Configure authentication for the Gordon registry and Admin API. |
| 4 | 4 | ||||
| 5 | - | ## Configuration | 5 | + | ## Quick Start |
| 6 | + | ||||
| 7 | + | Authentication is **enabled by default** for security. Gordon will not start without authentication configured. | |||
| 8 | + | ||||
| 9 | + | **Option 1: Password Authentication (simplest)** | |||
| 10 | + | ```bash | |||
| 11 | + | # 1. Generate password hash | |||
| 12 | + | gordon auth password hash | |||
| 13 | + | # Enter your password, copy the bcrypt hash | |||
| 14 | + | ||||
| 15 | + | # 2. Save hash to secrets file | |||
| 16 | + | mkdir -p ~/.gordon/secrets | |||
| 17 | + | echo '$2a$10$...' > ~/.gordon/secrets/password_hash | |||
| 6 | 18 | ||||
| 19 | + | # 3. Add to config.toml | |||
| 20 | + | cat >> ~/.gordon/config.toml << 'EOF' | |||
| 21 | + | [auth] | |||
| 22 | + | password_hash = "password_hash" | |||
| 23 | + | EOF | |||
| 24 | + | ``` | |||
| 25 | + | ||||
| 26 | + | **Option 2: Disable Authentication (development only)** | |||
| 7 | ```toml | 27 | ```toml | ||
| 8 | - | [registry_auth] | 28 | + | [auth] |
| 9 | - | enabled = true | 29 | + | enabled = false |
| 10 | - | type = "token" # "password" or "token" | 30 | + | ``` |
| 11 | 31 | ||||
| 12 | - | # For token auth: | 32 | + | ## Configuration |
| 13 | - | token_secret = "gordon/registry/token_secret" # Path in secrets backend | |||
| 14 | - | token_expiry = "720h" # Duration or 0 for never | |||
| 15 | 33 | ||||
| 16 | - | # For password auth: | 34 | + | ```toml |
| 17 | - | # username = "deploy" | 35 | + | [auth] |
| 18 | - | # password_hash = "gordon/registry/password_hash" # Path in secrets backend | 36 | + | enabled = true |
| 37 | + | type = "token" | |||
| 38 | + | secrets_backend = "pass" | |||
| 39 | + | token_secret = "gordon/auth/token_secret" | |||
| 40 | + | token_expiry = "30d" | |||
| 19 | ``` | 41 | ``` | ||
| 20 | 42 | ||||
| 21 | ## Options | 43 | ## Options | ||
| 22 | 44 | ||||
| 23 | | Option | Type | Default | Description | | 45 | | Option | Type | Default | Description | | ||
| 24 | |--------|------|---------|-------------| | 46 | |--------|------|---------|-------------| | ||
| 25 | - | | `enabled` | bool | `false` | Enable registry authentication | | 47 | + | | `enabled` | bool | `true` | Enable authentication | |
| 26 | | `type` | string | `"password"` | Auth type: `"password"` or `"token"` | | 48 | | `type` | string | `"password"` | Auth type: `"password"` or `"token"` | | ||
| 49 | + | | `secrets_backend` | string | `"unsafe"` | Secrets backend: `"pass"`, `"sops"`, or `"unsafe"` | | |||
| 27 | | `username` | string | - | Username for password auth | | 50 | | `username` | string | - | Username for password auth | | ||
| 28 | | `password_hash` | string | - | Path to bcrypt hash in secrets backend | | 51 | | `password_hash` | string | - | Path to bcrypt hash in secrets backend | | ||
| 29 | | `token_secret` | string | - | Path to JWT signing secret in secrets backend | | 52 | | `token_secret` | string | - | Path to JWT signing secret in secrets backend | | ||
| 30 | - | | `token_expiry` | string | `"30d"` | Token validity duration (0 = never expires). Supports: d (days), w (weeks), M (months), y (years) | | 53 | + | | `token_expiry` | string | `"30d"` | Token validity duration (0 = never expires) | |
| 54 | + | ||||
| 55 | + | ## Secrets Backend | |||
| 56 | + | ||||
| 57 | + | The `secrets_backend` option determines how Gordon retrieves sensitive values like `token_secret` and `password_hash`: | |||
| 58 | + | ||||
| 59 | + | | Backend | Description | | |||
| 60 | + | |---------|-------------| | |||
| 61 | + | | `pass` | Unix password manager (GPG-encrypted) | | |||
| 62 | + | | `sops` | Mozilla SOPS encrypted files | | |||
| 63 | + | | `unsafe` | Plain text files (development only) | | |||
| 64 | + | ||||
| 65 | + | See [Secret Providers](./secrets.md) for detailed configuration of each backend. | |||
| 31 | 66 | ||||
| 32 | ## Authentication Types | 67 | ## Authentication Types | ||
| 33 | 68 | ||||
| @@ -36,20 +71,18 @@ token_expiry = "720h" # Duration or 0 for never | |||||
| 36 | JWT-based authentication, ideal for CI/CD pipelines: | 71 | JWT-based authentication, ideal for CI/CD pipelines: | ||
| 37 | 72 | ||||
| 38 | ```toml | 73 | ```toml | ||
| 39 | - | [secrets] | 74 | + | [auth] |
| 40 | - | backend = "pass" | |||
| 41 | - | ||||
| 42 | - | [registry_auth] | |||
| 43 | enabled = true | 75 | enabled = true | ||
| 44 | type = "token" | 76 | type = "token" | ||
| 45 | - | token_secret = "gordon/registry/token_secret" | 77 | + | secrets_backend = "pass" |
| 46 | - | token_expiry = "30d" # 30 days, or "0" for never. Also: 1y, 2w, 6M | 78 | + | token_secret = "gordon/auth/token_secret" |
| 79 | + | token_expiry = "30d" | |||
| 47 | ``` | 80 | ``` | ||
| 48 | 81 | ||||
| 49 | **Setup:** | 82 | **Setup:** | ||
| 50 | ```bash | 83 | ```bash | ||
| 51 | # Create token secret (random 32+ characters) | 84 | # Create token secret (random 32+ characters) | ||
| 52 | - | pass insert gordon/registry/token_secret | 85 | + | pass insert gordon/auth/token_secret |
| 53 | 86 | ||||
| 54 | # Generate a token | 87 | # Generate a token | ||
| 55 | gordon auth token generate --subject ci-bot --scopes push,pull --expiry 0 | 88 | gordon auth token generate --subject ci-bot --scopes push,pull --expiry 0 | ||
| @@ -66,8 +99,11 @@ docker login -u ci-bot -p <token> registry.mydomain.com | |||||
| 66 | # List all tokens | 99 | # List all tokens | ||
| 67 | gordon auth token list | 100 | gordon auth token list | ||
| 68 | 101 | ||||
| 69 | - | # Revoke a token | 102 | + | # Revoke a specific token |
| 70 | gordon auth token revoke <token-id> | 103 | gordon auth token revoke <token-id> | ||
| 104 | + | ||||
| 105 | + | # Revoke all tokens | |||
| 106 | + | gordon auth token revoke --all | |||
| 71 | ``` | 107 | ``` | ||
| 72 | 108 | ||||
| 73 | ### Password Authentication | 109 | ### Password Authentication | ||
| @@ -75,14 +111,12 @@ gordon auth token revoke <token-id> | |||||
| 75 | Simple username/password authentication: | 111 | Simple username/password authentication: | ||
| 76 | 112 | ||||
| 77 | ```toml | 113 | ```toml | ||
| 78 | - | [secrets] | 114 | + | [auth] |
| 79 | - | backend = "pass" | |||
| 80 | - | ||||
| 81 | - | [registry_auth] | |||
| 82 | enabled = true | 115 | enabled = true | ||
| 83 | type = "password" | 116 | type = "password" | ||
| 117 | + | secrets_backend = "pass" | |||
| 84 | username = "deploy" | 118 | username = "deploy" | ||
| 85 | - | password_hash = "gordon/registry/password_hash" | 119 | + | password_hash = "gordon/auth/password_hash" |
| 86 | ``` | 120 | ``` | ||
| 87 | 121 | ||||
| 88 | **Setup:** | 122 | **Setup:** | ||
| @@ -92,7 +126,7 @@ gordon auth password hash | |||||
| 92 | # Enter password when prompted | 126 | # Enter password when prompted | ||
| 93 | 127 | ||||
| 94 | # Store hash in secrets backend | 128 | # Store hash in secrets backend | ||
| 95 | - | pass insert gordon/registry/password_hash | 129 | + | pass insert gordon/auth/password_hash |
| 96 | # Paste the bcrypt hash | 130 | # Paste the bcrypt hash | ||
| 97 | ``` | 131 | ``` | ||
| 98 | 132 | ||||
| @@ -105,27 +139,51 @@ docker login -u deploy -p <password> registry.mydomain.com | |||||
| 105 | 139 | ||||
| 106 | Tokens can have different permission levels: | 140 | Tokens can have different permission levels: | ||
| 107 | 141 | ||||
| 142 | + | ### Registry Scopes | |||
| 143 | + | ||||
| 108 | | Scope | Permission | | 144 | | Scope | Permission | | ||
| 109 | |-------|------------| | 145 | |-------|------------| | ||
| 110 | | `push` | Push images to registry | | 146 | | `push` | Push images to registry | | ||
| 111 | | `pull` | Pull images from registry | | 147 | | `pull` | Pull images from registry | | ||
| 112 | | `push,pull` | Both push and pull (default) | | 148 | | `push,pull` | Both push and pull (default) | | ||
| 113 | 149 | ||||
| 150 | + | ### Admin Scopes | |||
| 151 | + | ||||
| 152 | + | For remote CLI access via the Admin API: | |||
| 153 | + | ||||
| 154 | + | | Scope | Permission | | |||
| 155 | + | |-------|------------| | |||
| 156 | + | | `admin:*:*` | Full admin access | | |||
| 157 | + | | `admin:routes:read` | Read-only routes access | | |||
| 158 | + | | `admin:routes:write` | Routes write access | | |||
| 159 | + | | `admin:config:read` | Read-only config access | | |||
| 160 | + | | `admin:config:write` | Config write access | | |||
| 161 | + | | `admin:status:read` | Read-only status/health | | |||
| 162 | + | | `admin:secrets:read` | List secret keys | | |||
| 163 | + | | `admin:secrets:write` | Set/delete secrets | | |||
| 164 | + | ||||
| 165 | + | **Examples:** | |||
| 114 | ```bash | 166 | ```bash | ||
| 115 | # Pull-only token (for read-only access) | 167 | # Pull-only token (for read-only access) | ||
| 116 | gordon auth token generate --subject reader --scopes pull --expiry 30d | 168 | gordon auth token generate --subject reader --scopes pull --expiry 30d | ||
| 117 | 169 | ||||
| 118 | # Push-only token (for CI builds) | 170 | # Push-only token (for CI builds) | ||
| 119 | gordon auth token generate --subject builder --scopes push --expiry 0 | 171 | gordon auth token generate --subject builder --scopes push --expiry 0 | ||
| 172 | + | ||||
| 173 | + | # Full admin access (for remote CLI) | |||
| 174 | + | gordon auth token generate --subject admin --scopes "push,pull,admin:*:*" --expiry 0 | |||
| 120 | ``` | 175 | ``` | ||
| 121 | 176 | ||||
| 122 | ## Token Expiry | 177 | ## Token Expiry | ||
| 123 | 178 | ||||
| 124 | Control how long tokens remain valid. Supports human-friendly units: | 179 | Control how long tokens remain valid. Supports human-friendly units: | ||
| 125 | - | - `d` - days (24 hours) | 180 | + | |
| 126 | - | - `w` - weeks (7 days) | 181 | + | | Unit | Description | |
| 127 | - | - `M` - months (30 days) | 182 | + | |------|-------------| |
| 128 | - | - `y` - years (365 days) | 183 | + | | `d` | days (24 hours) | |
| 184 | + | | `w` | weeks (7 days) | | |||
| 185 | + | | `M` | months (30 days) | | |||
| 186 | + | | `y` | years (365 days) | | |||
| 129 | 187 | ||||
| 130 | Compound durations work too: `1y6M`, `2w3d` | 188 | Compound durations work too: `1y6M`, `2w3d` | ||
| 131 | 189 | ||||
| @@ -138,39 +196,40 @@ gordon auth token generate --subject deploy --expiry 1y | |||||
| 138 | 196 | ||||
| 139 | # Expires in 30 days | 197 | # Expires in 30 days | ||
| 140 | gordon auth token generate --subject temp --expiry 30d | 198 | gordon auth token generate --subject temp --expiry 30d | ||
| 141 | - | ||||
| 142 | - | # Expires in 2 weeks | |||
| 143 | - | gordon auth token generate --subject short --expiry 2w | |||
| 144 | ``` | 199 | ``` | ||
| 145 | 200 | ||||
| 146 | ## Instance-Specific Tokens | 201 | ## Instance-Specific Tokens | ||
| 147 | 202 | ||||
| 148 | - | Tokens are signed with the `token_secret`, which is unique to each Gordon instance. This means: | 203 | + | Tokens are signed with the `token_secret`, which is unique to each Gordon instance: |
| 149 | 204 | ||||
| 150 | - Tokens from one Gordon instance won't work on another | 205 | - Tokens from one Gordon instance won't work on another | ||
| 151 | - Changing the `token_secret` invalidates all existing tokens | 206 | - Changing the `token_secret` invalidates all existing tokens | ||
| 152 | - Each environment (dev, staging, prod) should have different secrets | 207 | - Each environment (dev, staging, prod) should have different secrets | ||
| 153 | 208 | ||||
| 209 | + | ## Development Setup | |||
| 210 | + | ||||
| 211 | + | For local development without authentication: | |||
| 212 | + | ||||
| 213 | + | ```toml | |||
| 214 | + | [auth] | |||
| 215 | + | enabled = false | |||
| 216 | + | ``` | |||
| 217 | + | ||||
| 154 | ## Examples | 218 | ## Examples | ||
| 155 | 219 | ||||
| 156 | ### CI/CD Setup | 220 | ### CI/CD Setup | ||
| 157 | 221 | ||||
| 158 | ```toml | 222 | ```toml | ||
| 159 | - | [secrets] | 223 | + | [auth] |
| 160 | - | backend = "pass" | |||
| 161 | - | ||||
| 162 | - | [registry_auth] | |||
| 163 | enabled = true | 224 | enabled = true | ||
| 164 | type = "token" | 225 | type = "token" | ||
| 165 | - | token_secret = "gordon/registry/token_secret" | 226 | + | secrets_backend = "pass" |
| 227 | + | token_secret = "gordon/auth/token_secret" | |||
| 166 | ``` | 228 | ``` | ||
| 167 | 229 | ||||
| 168 | ```bash | 230 | ```bash | ||
| 169 | # Generate CI token | 231 | # Generate CI token | ||
| 170 | gordon auth token generate --subject github-actions --scopes push,pull --expiry 0 | 232 | gordon auth token generate --subject github-actions --scopes push,pull --expiry 0 | ||
| 171 | - | ||||
| 172 | - | # Token output: | |||
| 173 | - | # eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... | |||
| 174 | ``` | 233 | ``` | ||
| 175 | 234 | ||||
| 176 | In GitHub Actions: | 235 | In GitHub Actions: | ||
| @@ -179,32 +238,38 @@ In GitHub Actions: | |||||
| 179 | run: docker login -u github-actions -p ${{ secrets.GORDON_TOKEN }} ${{ secrets.GORDON_REGISTRY }} | 238 | run: docker login -u github-actions -p ${{ secrets.GORDON_TOKEN }} ${{ secrets.GORDON_REGISTRY }} | ||
| 180 | ``` | 239 | ``` | ||
| 181 | 240 | ||||
| 182 | - | ### Development Setup | 241 | + | ### Production with Password |
| 183 | 242 | ||||
| 184 | ```toml | 243 | ```toml | ||
| 185 | - | [registry_auth] | 244 | + | [auth] |
| 186 | - | enabled = false # Disable for local development | 245 | + | enabled = true |
| 246 | + | type = "password" | |||
| 247 | + | secrets_backend = "pass" | |||
| 248 | + | username = "deploy" | |||
| 249 | + | password_hash = "gordon/auth/password_hash" | |||
| 187 | ``` | 250 | ``` | ||
| 188 | 251 | ||||
| 189 | - | ### Production with Password | 252 | + | ### Enterprise with SOPS |
| 190 | 253 | ||||
| 191 | ```toml | 254 | ```toml | ||
| 192 | - | [secrets] | 255 | + | [auth] |
| 193 | - | backend = "pass" | |||
| 194 | - | ||||
| 195 | - | [registry_auth] | |||
| 196 | enabled = true | 256 | enabled = true | ||
| 197 | - | type = "password" | 257 | + | type = "token" |
| 198 | - | username = "deploy" | 258 | + | secrets_backend = "sops" |
| 199 | - | password_hash = "gordon/registry/password_hash" | 259 | + | token_secret = "gordon/auth/token_secret" |
| 200 | ``` | 260 | ``` | ||
| 201 | 261 | ||||
| 202 | ## Internal Registry Auth | 262 | ## Internal Registry Auth | ||
| 203 | 263 | ||||
| 204 | Gordon generates internal credentials automatically when auth is enabled. These are used for loopback pulls when deploying containers and are regenerated on each restart. | 264 | Gordon generates internal credentials automatically when auth is enabled. These are used for loopback pulls when deploying containers and are regenerated on each restart. | ||
| 205 | 265 | ||||
| 266 | + | To view internal credentials (for troubleshooting): | |||
| 267 | + | ```bash | |||
| 268 | + | gordon auth internal | |||
| 269 | + | ``` | |||
| 270 | + | ||||
| 206 | ## Related | 271 | ## Related | ||
| 207 | 272 | ||||
| 208 | - | - [Secrets Configuration](./secrets.md) | 273 | + | - [Secret Providers](./secrets.md) |
| 209 | - [CLI Commands](../cli/commands.md) | 274 | - [CLI Commands](../cli/commands.md) | ||
| 210 | - [GitHub Actions Deployment](../deployment/github-actions.md) | 275 | - [GitHub Actions Deployment](../deployment/github-actions.md) | ||
M docs/config/index.md +15 -18
| @@ -23,6 +23,8 @@ gordon_domain = "gordon.mydomain.com" | |||||
| 23 | 23 | ||||
| 24 | ## Full Configuration Reference | 24 | ## Full Configuration Reference | ||
| 25 | 25 | ||||
| 26 | + | For a complete list of all configuration options with their default values, see the [Configuration Reference](./reference.md). | |||
| 27 | + | ||||
| 26 | > **Note:** This example shows production-style paths. Default paths use `~/.gordon/` for user installations. | 28 | > **Note:** This example shows production-style paths. Default paths use `~/.gordon/` for user installations. | ||
| 27 | 29 | ||||
| 28 | ```toml | 30 | ```toml | ||
| @@ -33,19 +35,16 @@ registry_port = 5000 # Registry port (default: 5000) | |||||
| 33 | gordon_domain = "gordon.mydomain.com" # Required: Gordon domain (registry + API) | 35 | gordon_domain = "gordon.mydomain.com" # Required: Gordon domain (registry + API) | ||
| 34 | # data_dir = "~/.gordon" # Default for user installations | 36 | # data_dir = "~/.gordon" # Default for user installations | ||
| 35 | 37 | ||||
| 36 | - | # Secrets backend | 38 | + | # Authentication (includes secrets backend) |
| 37 | - | [secrets] | 39 | + | [auth] |
| 38 | - | backend = "pass" # "pass", "sops", or "unsafe" | 40 | + | enabled = true # Enable registry authentication (default: true) |
| 39 | - | ||||
| 40 | - | # Registry authentication | |||
| 41 | - | [registry_auth] | |||
| 42 | - | enabled = true | |||
| 43 | type = "token" # "password" or "token" | 41 | type = "token" # "password" or "token" | ||
| 42 | + | secrets_backend = "pass" # "pass", "sops", or "unsafe" | |||
| 44 | # Password auth: | 43 | # Password auth: | ||
| 45 | # username = "deploy" | 44 | # username = "deploy" | ||
| 46 | - | # password_hash = "gordon/registry/password_hash" | 45 | + | # password_hash = "gordon/auth/password_hash" |
| 47 | # Token auth: | 46 | # Token auth: | ||
| 48 | - | token_secret = "gordon/registry/token_secret" | 47 | + | token_secret = "gordon/auth/token_secret" |
| 49 | token_expiry = "30d" # Duration (1y, 30d, 2w) or 0 for never | 48 | token_expiry = "30d" # Duration (1y, 30d, 2w) or 0 for never | ||
| 50 | 49 | ||||
| 51 | # Deploy behavior | 50 | # Deploy behavior | ||
| @@ -111,8 +110,7 @@ enabled = false # Auto-create routes from image names | |||||
| 111 | | Section | Description | Documentation | | 110 | | Section | Description | Documentation | | ||
| 112 | |---------|-------------|---------------| | 111 | |---------|-------------|---------------| | ||
| 113 | | `[server]` | Core server settings | [Server](./server.md) | | 112 | | `[server]` | Core server settings | [Server](./server.md) | | ||
| 114 | - | | `[secrets]` | Secrets backend configuration | [Secrets](./secrets.md) | | 113 | + | | `[auth]` | Authentication and secrets backend | [Auth](./auth.md) | |
| 115 | - | | `[registry_auth]` | Registry authentication | [Registry Auth](./registry-auth.md) | | |||
| 116 | | `[deploy]` | Deployment behavior | [Deploy](./deploy.md) | | 114 | | `[deploy]` | Deployment behavior | [Deploy](./deploy.md) | | ||
| 117 | | `[logging]` | Logging configuration | [Logging](./logging.md) | | 115 | | `[logging]` | Logging configuration | [Logging](./logging.md) | | ||
| 118 | | `[env]` | Environment variable settings | [Environment](./env.md) | | 116 | | `[env]` | Environment variable settings | [Environment](./env.md) | | ||
| @@ -131,10 +129,10 @@ enabled = false # Auto-create routes from image names | |||||
| 131 | | `server.port` | `80` | | 129 | | `server.port` | `80` | | ||
| 132 | | `server.registry_port` | `5000` | | 130 | | `server.registry_port` | `5000` | | ||
| 133 | | `server.data_dir` | `~/.gordon` | | 131 | | `server.data_dir` | `~/.gordon` | | ||
| 134 | - | | `secrets.backend` | `"unsafe"` | | 132 | + | | `auth.enabled` | `true` | |
| 135 | - | | `registry_auth.enabled` | `false` | | 133 | + | | `auth.type` | `"password"` | |
| 136 | - | | `registry_auth.type` | `"password"` | | 134 | + | | `auth.secrets_backend` | `"unsafe"` | |
| 137 | - | | `registry_auth.token_expiry` | `"30d"` | | 135 | + | | `auth.token_expiry` | `"30d"` | |
| 138 | | `deploy.pull_policy` | `"if-tag-changed"` | | 136 | | `deploy.pull_policy` | `"if-tag-changed"` | | ||
| 139 | | `logging.level` | `"info"` | | 137 | | `logging.level` | `"info"` | | ||
| 140 | | `logging.format` | `"console"` | | 138 | | `logging.format` | `"console"` | | ||
| @@ -162,8 +160,7 @@ The following settings require a restart to take effect: | |||||
| 162 | - `server.port` | 160 | - `server.port` | ||
| 163 | - `server.registry_port` | 161 | - `server.registry_port` | ||
| 164 | - `server.data_dir` | 162 | - `server.data_dir` | ||
| 165 | - | - `secrets.backend` | 163 | + | - `auth` settings |
| 166 | - | - `registry_auth` settings | |||
| 167 | - `deploy.pull_policy` | 164 | - `deploy.pull_policy` | ||
| 168 | 165 | ||||
| 169 | ## Environment Variable Override | 166 | ## Environment Variable Override | ||
| @@ -182,4 +179,4 @@ Pattern: `GORDON_SECTION_KEY` (uppercase, underscores instead of dots) | |||||
| 182 | - [Server Configuration](./server.md) | 179 | - [Server Configuration](./server.md) | ||
| 183 | - [Routes Configuration](./routes.md) | 180 | - [Routes Configuration](./routes.md) | ||
| 184 | - [External Routes](./external-routes.md) | 181 | - [External Routes](./external-routes.md) | ||
| 185 | - | - [Registry Authentication](./registry-auth.md) | 182 | + | - [Authentication](./auth.md) |
A docs/config/reference.md +203 -0
| @@ -0,0 +1,203 @@ | |||||
| 1 | + | # Configuration Reference | |||
| 2 | + | ||||
| 3 | + | Complete configuration reference with all options and their default values. | |||
| 4 | + | ||||
| 5 | + | ## Full Configuration Example | |||
| 6 | + | ||||
| 7 | + | ```toml | |||
| 8 | + | # ============================================================================= | |||
| 9 | + | # SERVER | |||
| 10 | + | # ============================================================================= | |||
| 11 | + | [server] | |||
| 12 | + | port = 80 # HTTP proxy port | |||
| 13 | + | registry_port = 5000 # Container registry port | |||
| 14 | + | gordon_domain = "" # Required: Gordon domain (registry + API) | |||
| 15 | + | data_dir = "~/.gordon" # Data directory (varies by install type) | |||
| 16 | + | ||||
| 17 | + | # ============================================================================= | |||
| 18 | + | # AUTHENTICATION (required - Gordon won't start without credentials configured) | |||
| 19 | + | # ============================================================================= | |||
| 20 | + | [auth] | |||
| 21 | + | enabled = true # Enable registry authentication (default: true) | |||
| 22 | + | type = "password" # "password" or "token" | |||
| 23 | + | secrets_backend = "unsafe" # "pass", "sops", or "unsafe" | |||
| 24 | + | username = "" # Username for password auth | |||
| 25 | + | password_hash = "" # Path in secrets backend to bcrypt hash (REQUIRED for password type) | |||
| 26 | + | token_secret = "" # Path in secrets backend to JWT signing key (REQUIRED for token type) | |||
| 27 | + | token_expiry = "720h" # Token expiry duration (720h = 30 days) | |||
| 28 | + | ||||
| 29 | + | # ============================================================================= | |||
| 30 | + | # LOGGING | |||
| 31 | + | # ============================================================================= | |||
| 32 | + | [logging] | |||
| 33 | + | level = "info" # "debug", "info", "warn", "error" | |||
| 34 | + | format = "console" # "console" or "json" | |||
| 35 | + | ||||
| 36 | + | [logging.file] | |||
| 37 | + | enabled = false # Enable file logging | |||
| 38 | + | path = "" # Log file path (default: {data_dir}/logs/gordon.log) | |||
| 39 | + | max_size = 100 # Max size in MB before rotation | |||
| 40 | + | max_backups = 3 # Number of old files to keep | |||
| 41 | + | max_age = 28 # Days to keep old files | |||
| 42 | + | ||||
| 43 | + | [logging.container_logs] | |||
| 44 | + | enabled = true # Enable container log collection | |||
| 45 | + | dir = "" # Log directory (default: {data_dir}/container-logs) | |||
| 46 | + | max_size = 100 # Max size in MB before rotation | |||
| 47 | + | max_backups = 3 # Number of old files to keep | |||
| 48 | + | max_age = 28 # Days to keep old files | |||
| 49 | + | ||||
| 50 | + | # ============================================================================= | |||
| 51 | + | # ENVIRONMENT | |||
| 52 | + | # ============================================================================= | |||
| 53 | + | [env] | |||
| 54 | + | dir = "" # Env files directory (default: {data_dir}/env) | |||
| 55 | + | ||||
| 56 | + | # ============================================================================= | |||
| 57 | + | # DEPLOYMENT | |||
| 58 | + | # ============================================================================= | |||
| 59 | + | [deploy] | |||
| 60 | + | pull_policy = "if-tag-changed" # "always", "if-tag-changed", "never" | |||
| 61 | + | ||||
| 62 | + | # ============================================================================= | |||
| 63 | + | # AUTO-ROUTE | |||
| 64 | + | # ============================================================================= | |||
| 65 | + | [auto_route] | |||
| 66 | + | enabled = false # Create routes from image labels automatically | |||
| 67 | + | ||||
| 68 | + | # ============================================================================= | |||
| 69 | + | # NETWORK ISOLATION | |||
| 70 | + | # ============================================================================= | |||
| 71 | + | [network_isolation] | |||
| 72 | + | enabled = false # Enable per-app Docker networks | |||
| 73 | + | network_prefix = "gordon" # Prefix for created networks | |||
| 74 | + | dns_suffix = ".internal" # DNS suffix for internal resolution | |||
| 75 | + | ||||
| 76 | + | # ============================================================================= | |||
| 77 | + | # VOLUMES | |||
| 78 | + | # ============================================================================= | |||
| 79 | + | [volumes] | |||
| 80 | + | auto_create = true # Auto-create volumes from Dockerfile VOLUME | |||
| 81 | + | prefix = "gordon" # Volume name prefix | |||
| 82 | + | preserve = true # Keep volumes when containers are removed | |||
| 83 | + | ||||
| 84 | + | # ============================================================================= | |||
| 85 | + | # ROUTES | |||
| 86 | + | # ============================================================================= | |||
| 87 | + | [routes] | |||
| 88 | + | # "domain.com" = "image:tag" | |||
| 89 | + | # "http://insecure.domain.com" = "image:tag" # HTTP-only (no HTTPS redirect) | |||
| 90 | + | ||||
| 91 | + | # ============================================================================= | |||
| 92 | + | # EXTERNAL ROUTES | |||
| 93 | + | # ============================================================================= | |||
| 94 | + | [external_routes] | |||
| 95 | + | # "domain.com" = "host:port" # Proxy to non-container services | |||
| 96 | + | ||||
| 97 | + | # ============================================================================= | |||
| 98 | + | # NETWORK GROUPS | |||
| 99 | + | # ============================================================================= | |||
| 100 | + | [network_groups] | |||
| 101 | + | # "group-name" = ["domain1.com", "domain2.com"] | |||
| 102 | + | ||||
| 103 | + | # ============================================================================= | |||
| 104 | + | # ATTACHMENTS | |||
| 105 | + | # ============================================================================= | |||
| 106 | + | [attachments] | |||
| 107 | + | # "domain-or-group" = ["image1:tag", "image2:tag"] | |||
| 108 | + | ``` | |||
| 109 | + | ||||
| 110 | + | ## Default Values Summary | |||
| 111 | + | ||||
| 112 | + | | Setting | Default | Description | | |||
| 113 | + | |---------|---------|-------------| | |||
| 114 | + | | `server.port` | `80` | HTTP proxy port | | |||
| 115 | + | | `server.registry_port` | `5000` | Container registry port | | |||
| 116 | + | | `server.gordon_domain` | `""` | **Required** - Gordon domain | | |||
| 117 | + | | `server.data_dir` | `~/.gordon` | Data directory | | |||
| 118 | + | | `auth.enabled` | `true` | Enable authentication | | |||
| 119 | + | | `auth.type` | `"password"` | Auth type | | |||
| 120 | + | | `auth.secrets_backend` | `"unsafe"` | Secrets storage | | |||
| 121 | + | | `auth.token_expiry` | `"720h"` | 30 days | | |||
| 122 | + | | `logging.level` | `"info"` | Log level | | |||
| 123 | + | | `logging.format` | `"console"` | Log format | | |||
| 124 | + | | `logging.file.enabled` | `false` | File logging disabled | | |||
| 125 | + | | `logging.file.max_size` | `100` | 100 MB | | |||
| 126 | + | | `logging.file.max_backups` | `3` | Keep 3 old files | | |||
| 127 | + | | `logging.file.max_age` | `28` | 28 days | | |||
| 128 | + | | `logging.container_logs.enabled` | `true` | Container logs enabled | | |||
| 129 | + | | `logging.container_logs.max_size` | `100` | 100 MB | | |||
| 130 | + | | `logging.container_logs.max_backups` | `3` | Keep 3 old files | | |||
| 131 | + | | `logging.container_logs.max_age` | `28` | 28 days | | |||
| 132 | + | | `deploy.pull_policy` | `"if-tag-changed"` | Pull on tag change | | |||
| 133 | + | | `auto_route.enabled` | `false` | Auto-route disabled | | |||
| 134 | + | | `network_isolation.enabled` | `false` | Isolation disabled | | |||
| 135 | + | | `network_isolation.network_prefix` | `"gordon"` | Network prefix | | |||
| 136 | + | | `network_isolation.dns_suffix` | `".internal"` | DNS suffix | | |||
| 137 | + | | `volumes.auto_create` | `true` | Auto-create volumes | | |||
| 138 | + | | `volumes.prefix` | `"gordon"` | Volume prefix | | |||
| 139 | + | | `volumes.preserve` | `true` | Keep volumes | | |||
| 140 | + | ||||
| 141 | + | ## Environment Variables | |||
| 142 | + | ||||
| 143 | + | All configuration options can be set via environment variables using the pattern: | |||
| 144 | + | ||||
| 145 | + | ``` | |||
| 146 | + | GORDON_<SECTION>_<KEY>=value | |||
| 147 | + | ``` | |||
| 148 | + | ||||
| 149 | + | Examples: | |||
| 150 | + | ```bash | |||
| 151 | + | GORDON_SERVER_PORT=8080 | |||
| 152 | + | GORDON_SERVER_GORDON_DOMAIN=gordon.example.com | |||
| 153 | + | GORDON_AUTH_ENABLED=true | |||
| 154 | + | GORDON_AUTH_TYPE=token | |||
| 155 | + | GORDON_LOGGING_LEVEL=debug | |||
| 156 | + | GORDON_NETWORK_ISOLATION_ENABLED=true | |||
| 157 | + | ``` | |||
| 158 | + | ||||
| 159 | + | Nested keys use underscores: | |||
| 160 | + | ```bash | |||
| 161 | + | GORDON_LOGGING_FILE_ENABLED=true | |||
| 162 | + | GORDON_LOGGING_FILE_MAX_SIZE=200 | |||
| 163 | + | GORDON_LOGGING_CONTAINER_LOGS_ENABLED=false | |||
| 164 | + | ``` | |||
| 165 | + | ||||
| 166 | + | ## Pull Policy Options | |||
| 167 | + | ||||
| 168 | + | | Value | Behavior | | |||
| 169 | + | |-------|----------| | |||
| 170 | + | | `"always"` | Always pull image before deploying | | |||
| 171 | + | | `"if-tag-changed"` | Pull only if image tag differs from running container | | |||
| 172 | + | | `"never"` | Never pull, use local image only | | |||
| 173 | + | ||||
| 174 | + | ## Auth Type Options | |||
| 175 | + | ||||
| 176 | + | | Value | Description | | |||
| 177 | + | |-------|-------------| | |||
| 178 | + | | `"password"` | HTTP Basic authentication with username/password | | |||
| 179 | + | | `"token"` | JWT token authentication | | |||
| 180 | + | ||||
| 181 | + | ## Secrets Backend Options | |||
| 182 | + | ||||
| 183 | + | | Value | Description | | |||
| 184 | + | |-------|-------------| | |||
| 185 | + | | `"pass"` | Unix password manager (recommended for production) | | |||
| 186 | + | | `"sops"` | Mozilla SOPS encrypted files | | |||
| 187 | + | | `"unsafe"` | Plain text files (development only) | | |||
| 188 | + | ||||
| 189 | + | ## Log Level Options | |||
| 190 | + | ||||
| 191 | + | | Value | Description | | |||
| 192 | + | |-------|-------------| | |||
| 193 | + | | `"debug"` | Verbose debugging information | | |||
| 194 | + | | `"info"` | General operational information | | |||
| 195 | + | | `"warn"` | Warning conditions | | |||
| 196 | + | | `"error"` | Error conditions only | | |||
| 197 | + | ||||
| 198 | + | ## Related | |||
| 199 | + | ||||
| 200 | + | - [Configuration Overview](./index.md) | |||
| 201 | + | - [Authentication](./auth.md) | |||
| 202 | + | - [Network Isolation](./network-isolation.md) | |||
| 203 | + | - [Volumes](./volumes.md) | |||
M docs/config/secrets.md +29 -33
| @@ -4,16 +4,18 @@ Configure how Gordon stores and retrieves sensitive data. | |||||
| 4 | 4 | ||||
| 5 | ## Configuration | 5 | ## Configuration | ||
| 6 | 6 | ||||
| 7 | + | The secrets backend is configured within the `[auth]` section: | |||
| 8 | + | ||||
| 7 | ```toml | 9 | ```toml | ||
| 8 | - | [secrets] | 10 | + | [auth] |
| 9 | - | backend = "pass" # "pass", "sops", or "unsafe" | 11 | + | secrets_backend = "pass" # "pass", "sops", or "unsafe" |
| 10 | ``` | 12 | ``` | ||
| 11 | 13 | ||||
| 12 | ## Options | 14 | ## Options | ||
| 13 | 15 | ||||
| 14 | | Option | Type | Default | Description | | 16 | | Option | Type | Default | Description | | ||
| 15 | |--------|------|---------|-------------| | 17 | |--------|------|---------|-------------| | ||
| 16 | - | | `backend` | string | `"unsafe"` | Secrets storage backend | | 18 | + | | `secrets_backend` | string | `"unsafe"` | Secrets storage backend | |
| 17 | 19 | ||||
| 18 | ## Backends | 20 | ## Backends | ||
| 19 | 21 | ||||
| @@ -22,8 +24,8 @@ backend = "pass" # "pass", "sops", or "unsafe" | |||||
| 22 | Uses the Unix password manager (`pass`) for secure secret storage: | 24 | Uses the Unix password manager (`pass`) for secure secret storage: | ||
| 23 | 25 | ||||
| 24 | ```toml | 26 | ```toml | ||
| 25 | - | [secrets] | 27 | + | [auth] |
| 26 | - | backend = "pass" | 28 | + | secrets_backend = "pass" |
| 27 | ``` | 29 | ``` | ||
| 28 | 30 | ||||
| 29 | **Setup:** | 31 | **Setup:** | ||
| @@ -35,15 +37,15 @@ sudo apt install pass | |||||
| 35 | pass init your-gpg-key-id | 37 | pass init your-gpg-key-id | ||
| 36 | 38 | ||||
| 37 | # Store secrets | 39 | # Store secrets | ||
| 38 | - | pass insert gordon/registry/password_hash | 40 | + | pass insert gordon/auth/password_hash |
| 39 | - | pass insert gordon/registry/token_secret | 41 | + | pass insert gordon/auth/token_secret |
| 40 | ``` | 42 | ``` | ||
| 41 | 43 | ||||
| 42 | **Usage in config:** | 44 | **Usage in config:** | ||
| 43 | ```toml | 45 | ```toml | ||
| 44 | - | [registry_auth] | 46 | + | [auth] |
| 45 | - | password_hash = "gordon/registry/password_hash" # Path in pass store | 47 | + | password_hash = "gordon/auth/password_hash" # Path in pass store |
| 46 | - | token_secret = "gordon/registry/token_secret" | 48 | + | token_secret = "gordon/auth/token_secret" |
| 47 | ``` | 49 | ``` | ||
| 48 | 50 | ||||
| 49 | **Benefits:** | 51 | **Benefits:** | ||
| @@ -57,8 +59,8 @@ token_secret = "gordon/registry/token_secret" | |||||
| 57 | Uses Mozilla SOPS for encrypted file-based secrets: | 59 | Uses Mozilla SOPS for encrypted file-based secrets: | ||
| 58 | 60 | ||||
| 59 | ```toml | 61 | ```toml | ||
| 60 | - | [secrets] | 62 | + | [auth] |
| 61 | - | backend = "sops" | 63 | + | secrets_backend = "sops" |
| 62 | ``` | 64 | ``` | ||
| 63 | 65 | ||||
| 64 | **Setup:** | 66 | **Setup:** | ||
| @@ -88,15 +90,15 @@ DB_PASSWORD=${sops:secrets.yaml:database.password} | |||||
| 88 | Stores secrets as plain text files: | 90 | Stores secrets as plain text files: | ||
| 89 | 91 | ||||
| 90 | ```toml | 92 | ```toml | ||
| 91 | - | [secrets] | 93 | + | [auth] |
| 92 | - | backend = "unsafe" | 94 | + | secrets_backend = "unsafe" |
| 93 | ``` | 95 | ``` | ||
| 94 | 96 | ||||
| 95 | **Storage location:** | 97 | **Storage location:** | ||
| 96 | ``` | 98 | ``` | ||
| 97 | {data_dir}/secrets/ | 99 | {data_dir}/secrets/ | ||
| 98 | ├── gordon/ | 100 | ├── gordon/ | ||
| 99 | - | │ └── registry/ | 101 | + | │ └── auth/ |
| 100 | │ ├── password_hash | 102 | │ ├── password_hash | ||
| 101 | │ └── token_secret | 103 | │ └── token_secret | ||
| 102 | ``` | 104 | ``` | ||
| @@ -104,8 +106,8 @@ backend = "unsafe" | |||||
| 104 | **Usage:** | 106 | **Usage:** | ||
| 105 | ```bash | 107 | ```bash | ||
| 106 | # Create secret | 108 | # Create secret | ||
| 107 | - | mkdir -p ~/.gordon/secrets/gordon/registry | 109 | + | mkdir -p ~/.gordon/secrets/gordon/auth |
| 108 | - | echo "your-bcrypt-hash" > ~/.gordon/secrets/gordon/registry/password_hash | 110 | + | echo "your-bcrypt-hash" > ~/.gordon/secrets/gordon/auth/password_hash |
| 109 | ``` | 111 | ``` | ||
| 110 | 112 | ||||
| 111 | > **Warning:** Only use for local development. Secrets are stored in plain text. | 113 | > **Warning:** Only use for local development. Secrets are stored in plain text. | ||
| @@ -135,18 +137,16 @@ API_SECRET=${sops:production.yaml:api.secret.key} | |||||
| 135 | ### Production with Pass | 137 | ### Production with Pass | ||
| 136 | 138 | ||||
| 137 | ```toml | 139 | ```toml | ||
| 138 | - | [secrets] | 140 | + | [auth] |
| 139 | - | backend = "pass" | |||
| 140 | - | ||||
| 141 | - | [registry_auth] | |||
| 142 | enabled = true | 141 | enabled = true | ||
| 143 | type = "token" | 142 | type = "token" | ||
| 144 | - | token_secret = "gordon/registry/token_secret" | 143 | + | secrets_backend = "pass" |
| 144 | + | token_secret = "gordon/auth/token_secret" | |||
| 145 | ``` | 145 | ``` | ||
| 146 | 146 | ||||
| 147 | ```bash | 147 | ```bash | ||
| 148 | # Setup | 148 | # Setup | ||
| 149 | - | pass insert gordon/registry/token_secret | 149 | + | pass insert gordon/auth/token_secret |
| 150 | # Enter a random 32+ character string | 150 | # Enter a random 32+ character string | ||
| 151 | 151 | ||||
| 152 | # Generate tokens | 152 | # Generate tokens | ||
| @@ -156,23 +156,19 @@ gordon auth token generate --subject deploy --expiry 0 | |||||
| 156 | ### Development with Unsafe | 156 | ### Development with Unsafe | ||
| 157 | 157 | ||||
| 158 | ```toml | 158 | ```toml | ||
| 159 | - | [secrets] | 159 | + | [auth] |
| 160 | - | backend = "unsafe" | |||
| 161 | - | ||||
| 162 | - | [registry_auth] | |||
| 163 | enabled = false | 160 | enabled = false | ||
| 161 | + | secrets_backend = "unsafe" | |||
| 164 | ``` | 162 | ``` | ||
| 165 | 163 | ||||
| 166 | ### Enterprise with SOPS | 164 | ### Enterprise with SOPS | ||
| 167 | 165 | ||||
| 168 | ```toml | 166 | ```toml | ||
| 169 | - | [secrets] | 167 | + | [auth] |
| 170 | - | backend = "sops" | |||
| 171 | - | ||||
| 172 | - | [registry_auth] | |||
| 173 | enabled = true | 168 | enabled = true | ||
| 174 | type = "token" | 169 | type = "token" | ||
| 175 | - | token_secret = "gordon/registry/token_secret" | 170 | + | secrets_backend = "sops" |
| 171 | + | token_secret = "gordon/auth/token_secret" | |||
| 176 | ``` | 172 | ``` | ||
| 177 | 173 | ||||
| 178 | Environment file: | 174 | Environment file: | ||
| @@ -194,6 +190,6 @@ JWT_SECRET=${sops:secrets.yaml:jwt.secret} | |||||
| 194 | 190 | ||||
| 195 | ## Related | 191 | ## Related | ||
| 196 | 192 | ||||
| 197 | - | - [Registry Authentication](./registry-auth.md) | 193 | + | - [Authentication](./auth.md) |
| 198 | - [Environment Variables](./env.md) | 194 | - [Environment Variables](./env.md) | ||
| 199 | - [Configuration Overview](./index.md) | 195 | - [Configuration Overview](./index.md) | ||
M docs/config/volumes.md +2 -2
| @@ -24,7 +24,7 @@ preserve = true | |||||
| 24 | Gordon automatically creates Docker volumes from Dockerfile `VOLUME` directives: | 24 | Gordon automatically creates Docker volumes from Dockerfile `VOLUME` directives: | ||
| 25 | 25 | ||||
| 26 | ```dockerfile | 26 | ```dockerfile | ||
| 27 | - | FROM postgres:15 | 27 | + | FROM postgres:18 |
| 28 | VOLUME ["/var/lib/postgresql/data"] | 28 | VOLUME ["/var/lib/postgresql/data"] | ||
| 29 | ``` | 29 | ``` | ||
| 30 | 30 | ||||
| @@ -76,7 +76,7 @@ With `preserve = false`: | |||||
| 76 | 76 | ||||
| 77 | ```dockerfile | 77 | ```dockerfile | ||
| 78 | # my-postgres.Dockerfile | 78 | # my-postgres.Dockerfile | ||
| 79 | - | FROM postgres:15 | 79 | + | FROM postgres:18 |
| 80 | VOLUME ["/var/lib/postgresql/data"] | 80 | VOLUME ["/var/lib/postgresql/data"] | ||
| 81 | ENV POSTGRES_DB=myapp | 81 | ENV POSTGRES_DB=myapp | ||
| 82 | ENV POSTGRES_USER=app | 82 | ENV POSTGRES_USER=app | ||
A internal/adapters/dto/admin_attachments.go +23 -0
| @@ -0,0 +1,23 @@ | |||||
| 1 | + | // Package dto provides shared data transfer objects for API responses. | |||
| 2 | + | package dto | |||
| 3 | + | ||||
| 4 | + | // AttachmentsConfigResponse represents all configured attachments. | |||
| 5 | + | type AttachmentsConfigResponse struct { | |||
| 6 | + | Attachments map[string][]string `json:"attachments"` | |||
| 7 | + | } | |||
| 8 | + | ||||
| 9 | + | // AttachmentConfigResponse represents attachments for a specific target. | |||
| 10 | + | type AttachmentConfigResponse struct { | |||
| 11 | + | Target string `json:"target"` | |||
| 12 | + | Images []string `json:"images"` | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | // AttachmentAddRequest represents a request to add an attachment. | |||
| 16 | + | type AttachmentAddRequest struct { | |||
| 17 | + | Image string `json:"image"` | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | // AttachmentStatusResponse represents attachment operation result. | |||
| 21 | + | type AttachmentStatusResponse struct { | |||
| 22 | + | Status string `json:"status"` | |||
| 23 | + | } | |||
A internal/adapters/in/cli/attachments.go +287 -0
| @@ -0,0 +1,287 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "sort" | |||
| 7 | + | ||||
| 8 | + | "gordon/internal/adapters/in/cli/remote" | |||
| 9 | + | "gordon/internal/adapters/in/cli/ui/components" | |||
| 10 | + | "gordon/internal/adapters/in/cli/ui/styles" | |||
| 11 | + | ||||
| 12 | + | "github.com/spf13/cobra" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // newAttachmentsCmd creates the attachments command group. | |||
| 16 | + | func newAttachmentsCmd() *cobra.Command { | |||
| 17 | + | cmd := &cobra.Command{ | |||
| 18 | + | Use: "attachments", | |||
| 19 | + | Aliases: []string{"attach"}, | |||
| 20 | + | Short: "Manage container attachments", | |||
| 21 | + | Long: `Manage container attachments (databases, caches, queues) in the configuration. | |||
| 22 | + | ||||
| 23 | + | Attachments are service dependencies that run alongside your application containers. | |||
| 24 | + | They are defined per-domain or per-network-group in the configuration. | |||
| 25 | + | ||||
| 26 | + | When targeting a remote Gordon instance (via --remote flag or GORDON_REMOTE env var), | |||
| 27 | + | these commands operate on the remote server. Otherwise, they require access to | |||
| 28 | + | the local Gordon configuration.`, | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | cmd.AddCommand(newAttachmentsListCmd()) | |||
| 32 | + | cmd.AddCommand(newAttachmentsAddCmd()) | |||
| 33 | + | cmd.AddCommand(newAttachmentsRemoveCmd()) | |||
| 34 | + | ||||
| 35 | + | return cmd | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // newAttachmentsListCmd creates the attachments list command. | |||
| 39 | + | func newAttachmentsListCmd() *cobra.Command { | |||
| 40 | + | return &cobra.Command{ | |||
| 41 | + | Use: "list [domain-or-group]", | |||
| 42 | + | Short: "List configured attachments", | |||
| 43 | + | Long: `List all configured attachments, or attachments for a specific domain/group. | |||
| 44 | + | ||||
| 45 | + | Examples: | |||
| 46 | + | gordon attachments list # List all attachments | |||
| 47 | + | gordon attachments list app.example.com # List attachments for domain | |||
| 48 | + | gordon attachments list backend # List attachments for network group`, | |||
| 49 | + | Args: cobra.MaximumNArgs(1), | |||
| 50 | + | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 51 | + | ctx := context.Background() | |||
| 52 | + | target := "" | |||
| 53 | + | if len(args) > 0 { | |||
| 54 | + | target = args[0] | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | client, isRemote := GetRemoteClient() | |||
| 58 | + | if isRemote { | |||
| 59 | + | return runAttachmentsListRemote(ctx, client, target) | |||
| 60 | + | } | |||
| 61 | + | return runAttachmentsListLocal(ctx, configPath, target) | |||
| 62 | + | }, | |||
| 63 | + | } | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | // runAttachmentsListRemote lists attachments from a remote Gordon instance. | |||
| 67 | + | func runAttachmentsListRemote(ctx context.Context, client *remote.Client, target string) error { | |||
| 68 | + | if target != "" { | |||
| 69 | + | // List for specific target | |||
| 70 | + | images, err := client.GetAttachmentsConfig(ctx, target) | |||
| 71 | + | if err != nil { | |||
| 72 | + | return fmt.Errorf("failed to get attachments: %w", err) | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | if len(images) == 0 { | |||
| 76 | + | fmt.Printf("No attachments configured for '%s'\n", target) | |||
| 77 | + | return nil | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | fmt.Println(styles.Theme.Title.Render(fmt.Sprintf("Attachments for %s", target))) | |||
| 81 | + | fmt.Println() | |||
| 82 | + | for _, img := range images { | |||
| 83 | + | fmt.Printf(" %s\n", img) | |||
| 84 | + | } | |||
| 85 | + | return nil | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | // List all attachments | |||
| 89 | + | attachments, err := client.GetAllAttachmentsConfig(ctx) | |||
| 90 | + | if err != nil { | |||
| 91 | + | return fmt.Errorf("failed to list attachments: %w", err) | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | if len(attachments) == 0 { | |||
| 95 | + | fmt.Println(styles.Theme.Muted.Render("No attachments configured")) | |||
| 96 | + | return nil | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | // Sort targets for consistent output | |||
| 100 | + | targets := make([]string, 0, len(attachments)) | |||
| 101 | + | for t := range attachments { | |||
| 102 | + | targets = append(targets, t) | |||
| 103 | + | } | |||
| 104 | + | sort.Strings(targets) | |||
| 105 | + | ||||
| 106 | + | fmt.Println(styles.Theme.Title.Render("Attachments")) | |||
| 107 | + | fmt.Println() | |||
| 108 | + | ||||
| 109 | + | for _, t := range targets { | |||
| 110 | + | images := attachments[t] | |||
| 111 | + | fmt.Printf("%s\n", styles.Theme.Bold.Render(t)) | |||
| 112 | + | for _, img := range images { | |||
| 113 | + | fmt.Printf(" %s\n", img) | |||
| 114 | + | } | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | return nil | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | // runAttachmentsListLocal lists attachments from local configuration. | |||
| 121 | + | func runAttachmentsListLocal(ctx context.Context, cfgPath string, target string) error { | |||
| 122 | + | local, err := GetLocalServices(cfgPath) | |||
| 123 | + | if err != nil { | |||
| 124 | + | return fmt.Errorf("failed to initialize local services: %w", err) | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | if target != "" { | |||
| 128 | + | // List for specific target | |||
| 129 | + | images, err := local.GetConfigService().GetAttachmentsFor(ctx, target) | |||
| 130 | + | if err != nil { | |||
| 131 | + | return fmt.Errorf("failed to get attachments: %w", err) | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | if len(images) == 0 { | |||
| 135 | + | fmt.Printf("No attachments configured for '%s'\n", target) | |||
| 136 | + | return nil | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | fmt.Println(styles.Theme.Title.Render(fmt.Sprintf("Attachments for %s (local)", target))) | |||
| 140 | + | fmt.Println() | |||
| 141 | + | for _, img := range images { | |||
| 142 | + | fmt.Printf(" %s\n", img) | |||
| 143 | + | } | |||
| 144 | + | return nil | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | // List all attachments | |||
| 148 | + | attachments := local.GetConfigService().GetAllAttachments(ctx) | |||
| 149 | + | ||||
| 150 | + | if len(attachments) == 0 { | |||
| 151 | + | fmt.Println(styles.Theme.Muted.Render("No attachments configured")) | |||
| 152 | + | return nil | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | // Sort targets for consistent output | |||
| 156 | + | targets := make([]string, 0, len(attachments)) | |||
| 157 | + | for t := range attachments { | |||
| 158 | + | targets = append(targets, t) | |||
| 159 | + | } | |||
| 160 | + | sort.Strings(targets) | |||
| 161 | + | ||||
| 162 | + | fmt.Println(styles.Theme.Title.Render("Attachments (local)")) | |||
| 163 | + | fmt.Println() | |||
| 164 | + | ||||
| 165 | + | for _, t := range targets { | |||
| 166 | + | images := attachments[t] | |||
| 167 | + | fmt.Printf("%s\n", styles.Theme.Bold.Render(t)) | |||
| 168 | + | for _, img := range images { | |||
| 169 | + | fmt.Printf(" %s\n", img) | |||
| 170 | + | } | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | return nil | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | // newAttachmentsAddCmd creates the attachments add command. | |||
| 177 | + | func newAttachmentsAddCmd() *cobra.Command { | |||
| 178 | + | cmd := &cobra.Command{ | |||
| 179 | + | Use: "add <domain-or-group> <image>", | |||
| 180 | + | Short: "Add an attachment", | |||
| 181 | + | Long: `Add a container attachment to a domain or network group. | |||
| 182 | + | ||||
| 183 | + | Note: Attachments require network_isolation.enabled = true in your configuration. | |||
| 184 | + | Without network isolation, containers use Docker's default bridge network which | |||
| 185 | + | does not provide DNS resolution - your app won't be able to reach attachments | |||
| 186 | + | by hostname (e.g., 'postgres:5432'). | |||
| 187 | + | ||||
| 188 | + | Examples: | |||
| 189 | + | gordon attachments add app.example.com postgres:18 | |||
| 190 | + | gordon attachments add backend redis:7-alpine | |||
| 191 | + | gordon --remote https://gordon.mydomain.com attachments add api.mydomain.com memcached:latest`, | |||
| 192 | + | Args: cobra.ExactArgs(2), | |||
| 193 | + | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 194 | + | ctx := context.Background() | |||
| 195 | + | target := args[0] | |||
| 196 | + | image := args[1] | |||
| 197 | + | ||||
| 198 | + | client, isRemote := GetRemoteClient() | |||
| 199 | + | if isRemote { | |||
| 200 | + | if err := client.AddAttachment(ctx, target, image); err != nil { | |||
| 201 | + | return fmt.Errorf("failed to add attachment: %w", err) | |||
| 202 | + | } | |||
| 203 | + | } else { | |||
| 204 | + | local, err := GetLocalServices(configPath) | |||
| 205 | + | if err != nil { | |||
| 206 | + | return fmt.Errorf("failed to initialize local services: %w", err) | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | // Warn if network isolation is disabled | |||
| 210 | + | if !local.GetConfigService().IsNetworkIsolationEnabled() { | |||
| 211 | + | fmt.Println(styles.RenderWarning("network_isolation.enabled = false")) | |||
| 212 | + | fmt.Println(styles.Theme.Muted.Render(" Attachments require network isolation for DNS resolution.")) | |||
| 213 | + | fmt.Println(styles.Theme.Muted.Render(" Without it, your app cannot reach attachments by hostname.")) | |||
| 214 | + | fmt.Println(styles.Theme.Muted.Render(" Enable with: [network_isolation] enabled = true")) | |||
| 215 | + | fmt.Println() | |||
| 216 | + | } | |||
| 217 | + | ||||
| 218 | + | if err := local.GetConfigService().AddAttachment(ctx, target, image); err != nil { | |||
| 219 | + | return fmt.Errorf("failed to add attachment: %w", err) | |||
| 220 | + | } | |||
| 221 | + | } | |||
| 222 | + | ||||
| 223 | + | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Attachment added: %s -> %s", target, image))) | |||
| 224 | + | return nil | |||
| 225 | + | }, | |||
| 226 | + | } | |||
| 227 | + | ||||
| 228 | + | return cmd | |||
| 229 | + | } | |||
| 230 | + | ||||
| 231 | + | // newAttachmentsRemoveCmd creates the attachments remove command. | |||
| 232 | + | func newAttachmentsRemoveCmd() *cobra.Command { | |||
| 233 | + | var force bool | |||
| 234 | + | ||||
| 235 | + | cmd := &cobra.Command{ | |||
| 236 | + | Use: "remove <domain-or-group> <image>", | |||
| 237 | + | Short: "Remove an attachment", | |||
| 238 | + | Long: `Remove a container attachment from a domain or network group. | |||
| 239 | + | ||||
| 240 | + | Examples: | |||
| 241 | + | gordon attachments remove app.example.com postgres:15 | |||
| 242 | + | gordon attachments remove backend redis:7-alpine --force`, | |||
| 243 | + | Args: cobra.ExactArgs(2), | |||
| 244 | + | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 245 | + | ctx := context.Background() | |||
| 246 | + | target := args[0] | |||
| 247 | + | image := args[1] | |||
| 248 | + | ||||
| 249 | + | // Confirm unless --force | |||
| 250 | + | if !force { | |||
| 251 | + | confirmed, err := components.RunConfirm( | |||
| 252 | + | fmt.Sprintf("Remove attachment '%s' from '%s'?", image, target), | |||
| 253 | + | components.WithDescription("This will remove the attachment from the configuration. The container will be stopped on next reload."), | |||
| 254 | + | ) | |||
| 255 | + | if err != nil { | |||
| 256 | + | return err | |||
| 257 | + | } | |||
| 258 | + | if !confirmed { | |||
| 259 | + | fmt.Println(styles.Theme.Muted.Render("Cancelled")) | |||
| 260 | + | return nil | |||
| 261 | + | } | |||
| 262 | + | } | |||
| 263 | + | ||||
| 264 | + | client, isRemote := GetRemoteClient() | |||
| 265 | + | if isRemote { | |||
| 266 | + | if err := client.RemoveAttachment(ctx, target, image); err != nil { | |||
| 267 | + | return fmt.Errorf("failed to remove attachment: %w", err) | |||
| 268 | + | } | |||
| 269 | + | } else { | |||
| 270 | + | local, err := GetLocalServices(configPath) | |||
| 271 | + | if err != nil { | |||
| 272 | + | return fmt.Errorf("failed to initialize local services: %w", err) | |||
| 273 | + | } | |||
| 274 | + | if err := local.GetConfigService().RemoveAttachment(ctx, target, image); err != nil { | |||
| 275 | + | return fmt.Errorf("failed to remove attachment: %w", err) | |||
| 276 | + | } | |||
| 277 | + | } | |||
| 278 | + | ||||
| 279 | + | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Attachment removed: %s -> %s", target, image))) | |||
| 280 | + | return nil | |||
| 281 | + | }, | |||
| 282 | + | } | |||
| 283 | + | ||||
| 284 | + | cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation") | |||
| 285 | + | ||||
| 286 | + | return cmd | |||
| 287 | + | } | |||
M internal/adapters/in/cli/auth.go +9 -9
| @@ -25,8 +25,8 @@ import ( | |||||
| 25 | func newAuthCmd() *cobra.Command { | 25 | func newAuthCmd() *cobra.Command { | ||
| 26 | cmd := &cobra.Command{ | 26 | cmd := &cobra.Command{ | ||
| 27 | Use: "auth", | 27 | Use: "auth", | ||
| 28 | - | Short: "Manage registry authentication", | 28 | + | Short: "Manage Gordon server authentication", |
| 29 | - | Long: `Commands for managing registry authentication tokens and passwords.`, | 29 | + | Long: `Commands for managing Gordon server authentication tokens and passwords.`, |
| 30 | } | 30 | } | ||
| 31 | 31 | ||||
| 32 | cmd.AddCommand(newAuthTokenCmd()) | 32 | cmd.AddCommand(newAuthTokenCmd()) | ||
| @@ -224,9 +224,9 @@ func newPasswordHashCmd() *cobra.Command { | |||||
| 224 | 224 | ||||
| 225 | The hash can be stored in your secrets backend and referenced in the config: | 225 | The hash can be stored in your secrets backend and referenced in the config: | ||
| 226 | 226 | ||||
| 227 | - | [registry_auth] | 227 | + | [auth] |
| 228 | type = "password" | 228 | type = "password" | ||
| 229 | - | password_hash = "gordon/registry/password_hash"`, | 229 | + | password_hash = "gordon/auth/password_hash"`, |
| 230 | RunE: func(cmd *cobra.Command, args []string) error { | 230 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 231 | return runPasswordHash() | 231 | return runPasswordHash() | ||
| 232 | }, | 232 | }, | ||
| @@ -410,9 +410,9 @@ func runPasswordHash() error { | |||||
| 410 | fmt.Println(hash) | 410 | fmt.Println(hash) | ||
| 411 | fmt.Println() | 411 | fmt.Println() | ||
| 412 | fmt.Println("Then reference the path in your config:") | 412 | fmt.Println("Then reference the path in your config:") | ||
| 413 | - | fmt.Println(" [registry_auth]") | 413 | + | fmt.Println(" [auth]") |
| 414 | fmt.Println(" type = \"password\"") | 414 | fmt.Println(" type = \"password\"") | ||
| 415 | - | fmt.Println(" password_hash = \"gordon/registry/password_hash\"") | 415 | + | fmt.Println(" password_hash = \"gordon/auth/password_hash\"") |
| 416 | 416 | ||||
| 417 | return nil | 417 | return nil | ||
| 418 | } | 418 | } | ||
| @@ -430,7 +430,7 @@ func loadAuthConfig(configPath string) (*cliConfig, error) { | |||||
| 430 | 430 | ||||
| 431 | // Set defaults | 431 | // Set defaults | ||
| 432 | v.SetDefault("server.data_dir", app.DefaultDataDir()) | 432 | v.SetDefault("server.data_dir", app.DefaultDataDir()) | ||
| 433 | - | v.SetDefault("secrets.backend", "unsafe") | 433 | + | v.SetDefault("auth.secrets_backend", "unsafe") |
| 434 | 434 | ||||
| 435 | app.ConfigureViper(v, configPath) | 435 | app.ConfigureViper(v, configPath) | ||
| 436 | 436 | ||||
| @@ -446,7 +446,7 @@ func loadAuthConfig(configPath string) (*cliConfig, error) { | |||||
| 446 | } | 446 | } | ||
| 447 | 447 | ||||
| 448 | // Determine backend | 448 | // Determine backend | ||
| 449 | - | switch v.GetString("secrets.backend") { | 449 | + | switch v.GetString("auth.secrets_backend") { |
| 450 | case "pass": | 450 | case "pass": | ||
| 451 | cfg.Backend = domain.SecretsBackendPass | 451 | cfg.Backend = domain.SecretsBackendPass | ||
| 452 | case "sops": | 452 | case "sops": | ||
| @@ -456,7 +456,7 @@ func loadAuthConfig(configPath string) (*cliConfig, error) { | |||||
| 456 | } | 456 | } | ||
| 457 | 457 | ||||
| 458 | // Load token secret from secrets backend | 458 | // Load token secret from secrets backend | ||
| 459 | - | tokenSecretPath := v.GetString("registry_auth.token_secret") | 459 | + | tokenSecretPath := v.GetString("auth.token_secret") |
| 460 | if tokenSecretPath != "" { | 460 | if tokenSecretPath != "" { | ||
| 461 | // Load actual secret from the configured backend | 461 | // Load actual secret from the configured backend | ||
| 462 | ctx := context.Background() | 462 | ctx := context.Background() | ||
M internal/adapters/in/cli/remote/client.go +75 -0
| @@ -458,6 +458,81 @@ func (c *Client) StreamContainerLogs(ctx context.Context, logDomain string, line | |||||
| 458 | return c.streamLogs(ctx, path) | 458 | return c.streamLogs(ctx, path) | ||
| 459 | } | 459 | } | ||
| 460 | 460 | ||||
| 461 | + | // Attachments Config API | |||
| 462 | + | ||||
| 463 | + | // GetAllAttachmentsConfig returns all configured attachments. | |||
| 464 | + | func (c *Client) GetAllAttachmentsConfig(ctx context.Context) (map[string][]string, error) { | |||
| 465 | + | resp, err := c.request(ctx, http.MethodGet, "/attachments", nil) | |||
| 466 | + | if err != nil { | |||
| 467 | + | return nil, err | |||
| 468 | + | } | |||
| 469 | + | ||||
| 470 | + | var result struct { | |||
| 471 | + | Attachments map[string][]string `json:"attachments"` | |||
| 472 | + | } | |||
| 473 | + | if err := parseResponse(resp, &result); err != nil { | |||
| 474 | + | return nil, err | |||
| 475 | + | } | |||
| 476 | + | ||||
| 477 | + | return result.Attachments, nil | |||
| 478 | + | } | |||
| 479 | + | ||||
| 480 | + | // GetAttachmentsConfig returns attachments for a specific domain/group from config. | |||
| 481 | + | func (c *Client) GetAttachmentsConfig(ctx context.Context, domainOrGroup string) ([]string, error) { | |||
| 482 | + | if domainOrGroup == "" { | |||
| 483 | + | return nil, fmt.Errorf("domain or group is required") | |||
| 484 | + | } | |||
| 485 | + | path := "/attachments/" + url.PathEscape(domainOrGroup) | |||
| 486 | + | resp, err := c.request(ctx, http.MethodGet, path, nil) | |||
| 487 | + | if err != nil { | |||
| 488 | + | return nil, err | |||
| 489 | + | } | |||
| 490 | + | ||||
| 491 | + | var result struct { | |||
| 492 | + | Target string `json:"target"` | |||
| 493 | + | Images []string `json:"images"` | |||
| 494 | + | } | |||
| 495 | + | if err := parseResponse(resp, &result); err != nil { | |||
| 496 | + | return nil, err | |||
| 497 | + | } | |||
| 498 | + | ||||
| 499 | + | return result.Images, nil | |||
| 500 | + | } | |||
| 501 | + | ||||
| 502 | + | // AddAttachment adds an attachment to a domain/group. | |||
| 503 | + | func (c *Client) AddAttachment(ctx context.Context, domainOrGroup, image string) error { | |||
| 504 | + | if domainOrGroup == "" { | |||
| 505 | + | return fmt.Errorf("domain or group is required") | |||
| 506 | + | } | |||
| 507 | + | if image == "" { | |||
| 508 | + | return fmt.Errorf("image is required") | |||
| 509 | + | } | |||
| 510 | + | path := "/attachments/" + url.PathEscape(domainOrGroup) | |||
| 511 | + | resp, err := c.request(ctx, http.MethodPost, path, struct { | |||
| 512 | + | Image string `json:"image"` | |||
| 513 | + | }{Image: image}) | |||
| 514 | + | if err != nil { | |||
| 515 | + | return err | |||
| 516 | + | } | |||
| 517 | + | return parseResponse(resp, nil) | |||
| 518 | + | } | |||
| 519 | + | ||||
| 520 | + | // RemoveAttachment removes an attachment from a domain/group. | |||
| 521 | + | func (c *Client) RemoveAttachment(ctx context.Context, domainOrGroup, image string) error { | |||
| 522 | + | if domainOrGroup == "" { | |||
| 523 | + | return fmt.Errorf("domain or group is required") | |||
| 524 | + | } | |||
| 525 | + | if image == "" { | |||
| 526 | + | return fmt.Errorf("image is required") | |||
| 527 | + | } | |||
| 528 | + | path := "/attachments/" + url.PathEscape(domainOrGroup) + "/" + url.PathEscape(image) | |||
| 529 | + | resp, err := c.request(ctx, http.MethodDelete, path, nil) | |||
| 530 | + | if err != nil { | |||
| 531 | + | return err | |||
| 532 | + | } | |||
| 533 | + | return parseResponse(resp, nil) | |||
| 534 | + | } | |||
| 535 | + | ||||
| 461 | // streamLogs handles SSE streaming for log endpoints. | 536 | // streamLogs handles SSE streaming for log endpoints. | ||
| 462 | func (c *Client) streamLogs(ctx context.Context, path string) (<-chan string, error) { | 537 | func (c *Client) streamLogs(ctx context.Context, path string) (<-chan string, error) { | ||
| 463 | url := c.baseURL + "/admin" + path | 538 | url := c.baseURL + "/admin" + path | ||
M internal/adapters/in/cli/root.go +72 -16
| @@ -23,6 +23,13 @@ var ( | |||||
| 23 | tokenFlag string | 23 | tokenFlag string | ||
| 24 | ) | 24 | ) | ||
| 25 | 25 | ||||
| 26 | + | // Command group IDs | |||
| 27 | + | const ( | |||
| 28 | + | groupServer = "server" | |||
| 29 | + | groupManage = "manage" | |||
| 30 | + | groupClient = "client" | |||
| 31 | + | ) | |||
| 32 | + | ||||
| 26 | // NewRootCmd creates the root command for Gordon CLI. | 33 | // NewRootCmd creates the root command for Gordon CLI. | ||
| 27 | func NewRootCmd() *cobra.Command { | 34 | func NewRootCmd() *cobra.Command { | ||
| 28 | rootCmd := &cobra.Command{ | 35 | rootCmd := &cobra.Command{ | ||
| @@ -34,28 +41,77 @@ a Docker registry with automatic container deployment capabilities. | |||||
| 34 | It listens for image pushes and automatically deploys containers based on | 41 | It listens for image pushes and automatically deploys containers based on | ||
| 35 | configuration rules, making it ideal for single-server deployments. | 42 | configuration rules, making it ideal for single-server deployments. | ||
| 36 | 43 | ||||
| 37 | - | The CLI can target remote Gordon instances using the --remote flag or | 44 | + | Commands are organized by where they run: |
| 38 | - | GORDON_REMOTE environment variable.`, | 45 | + | Server-only: Run on the machine hosting Gordon (serve, auth, reload) |
| 46 | + | Management: Work locally or remotely via --remote flag (routes, secrets, etc.) | |||
| 47 | + | Client-only: CLI utilities that don't require a running Gordon server`, | |||
| 39 | } | 48 | } | ||
| 40 | 49 | ||||
| 50 | + | // Define command groups (order matters for help output) | |||
| 51 | + | rootCmd.AddGroup( | |||
| 52 | + | &cobra.Group{ID: groupServer, Title: "Server Commands (local only):"}, | |||
| 53 | + | &cobra.Group{ID: groupManage, Title: "Management Commands (local or --remote):"}, | |||
| 54 | + | &cobra.Group{ID: groupClient, Title: "Client Commands:"}, | |||
| 55 | + | ) | |||
| 56 | + | ||||
| 41 | // Add persistent flags for remote targeting | 57 | // Add persistent flags for remote targeting | ||
| 42 | rootCmd.PersistentFlags().StringVar(&remoteFlag, "remote", "", "Remote Gordon URL (e.g., https://gordon.mydomain.com)") | 58 | rootCmd.PersistentFlags().StringVar(&remoteFlag, "remote", "", "Remote Gordon URL (e.g., https://gordon.mydomain.com)") | ||
| 43 | rootCmd.PersistentFlags().StringVar(&tokenFlag, "token", "", "Authentication token for remote") | 59 | rootCmd.PersistentFlags().StringVar(&tokenFlag, "token", "", "Authentication token for remote") | ||
| 44 | 60 | ||||
| 45 | - | // Server commands | 61 | + | // Server-only commands (must run on the Gordon host) |
| 46 | - | rootCmd.AddCommand(newServeCmd()) | 62 | + | serveCmd := newServeCmd() |
| 47 | - | rootCmd.AddCommand(newStartCmd()) // Deprecated alias for serve | 63 | + | serveCmd.GroupID = groupServer |
| 48 | - | rootCmd.AddCommand(newReloadCmd()) | 64 | + | rootCmd.AddCommand(serveCmd) |
| 49 | - | rootCmd.AddCommand(newDeployCmd()) | 65 | + | |
| 50 | - | rootCmd.AddCommand(newLogsCmd()) | 66 | + | startCmd := newStartCmd() // Deprecated alias for serve |
| 51 | - | rootCmd.AddCommand(newVersionCmd()) | 67 | + | startCmd.GroupID = groupServer |
| 52 | - | rootCmd.AddCommand(newAuthCmd()) | 68 | + | rootCmd.AddCommand(startCmd) |
| 53 | - | 69 | + | ||
| 54 | - | // Remote management commands | 70 | + | authCmd := newAuthCmd() |
| 55 | - | rootCmd.AddCommand(newRoutesCmd()) | 71 | + | authCmd.GroupID = groupServer |
| 56 | - | rootCmd.AddCommand(newSecretsCmd()) | 72 | + | rootCmd.AddCommand(authCmd) |
| 57 | - | rootCmd.AddCommand(newRemotesCmd()) | 73 | + | |
| 58 | - | rootCmd.AddCommand(newStatusCmd()) | 74 | + | // Management commands (work locally or via --remote) |
| 75 | + | routesCmd := newRoutesCmd() | |||
| 76 | + | routesCmd.GroupID = groupManage | |||
| 77 | + | rootCmd.AddCommand(routesCmd) | |||
| 78 | + | ||||
| 79 | + | attachmentsCmd := newAttachmentsCmd() | |||
| 80 | + | attachmentsCmd.GroupID = groupManage | |||
| 81 | + | rootCmd.AddCommand(attachmentsCmd) | |||
| 82 | + | ||||
| 83 | + | secretsCmd := newSecretsCmd() | |||
| 84 | + | secretsCmd.GroupID = groupManage | |||
| 85 | + | rootCmd.AddCommand(secretsCmd) | |||
| 86 | + | ||||
| 87 | + | deployCmd := newDeployCmd() | |||
| 88 | + | deployCmd.GroupID = groupManage | |||
| 89 | + | rootCmd.AddCommand(deployCmd) | |||
| 90 | + | ||||
| 91 | + | reloadCmd := newReloadCmd() | |||
| 92 | + | reloadCmd.GroupID = groupManage | |||
| 93 | + | rootCmd.AddCommand(reloadCmd) | |||
| 94 | + | ||||
| 95 | + | logsCmd := newLogsCmd() | |||
| 96 | + | logsCmd.GroupID = groupManage | |||
| 97 | + | rootCmd.AddCommand(logsCmd) | |||
| 98 | + | ||||
| 99 | + | statusCmd := newStatusCmd() | |||
| 100 | + | statusCmd.GroupID = groupManage | |||
| 101 | + | rootCmd.AddCommand(statusCmd) | |||
| 102 | + | ||||
| 103 | + | // Client-only commands (no server needed) | |||
| 104 | + | remotesCmd := newRemotesCmd() | |||
| 105 | + | remotesCmd.GroupID = groupClient | |||
| 106 | + | rootCmd.AddCommand(remotesCmd) | |||
| 107 | + | ||||
| 108 | + | versionCmd := newVersionCmd() | |||
| 109 | + | versionCmd.GroupID = groupClient | |||
| 110 | + | rootCmd.AddCommand(versionCmd) | |||
| 111 | + | ||||
| 112 | + | // Put help and completion in the client group | |||
| 113 | + | rootCmd.SetHelpCommandGroupID(groupClient) | |||
| 114 | + | rootCmd.SetCompletionCommandGroupID(groupClient) | |||
| 59 | 115 | ||||
| 60 | return rootCmd | 116 | return rootCmd | ||
| 61 | } | 117 | } | ||
M internal/adapters/in/http/admin/handler.go +180 -21
| @@ -122,28 +122,48 @@ func (h *Handler) handleAdminRoutes(w http.ResponseWriter, r *http.Request) { | |||||
| 122 | path := strings.TrimPrefix(r.URL.Path, "/admin") | 122 | path := strings.TrimPrefix(r.URL.Path, "/admin") | ||
| 123 | 123 | ||||
| 124 | // Route to appropriate handler | 124 | // Route to appropriate handler | ||
| 125 | - | switch { | 125 | + | if handler, ok := h.matchRoute(path); ok { |
| 126 | - | case path == "/routes" || strings.HasPrefix(path, "/routes/"): | 126 | + | handler(w, r, path) |
| 127 | - | h.handleRoutes(w, r, path) | 127 | + | return |
| 128 | - | case path == "/networks": | 128 | + | } |
| 129 | - | h.handleNetworks(w, r) | 129 | + | h.sendError(w, http.StatusNotFound, "route not found") |
| 130 | - | case path == "/secrets" || strings.HasPrefix(path, "/secrets/"): | 130 | + | } |
| 131 | - | h.handleSecrets(w, r, path) | 131 | + | |
| 132 | - | case path == "/deploy" || strings.HasPrefix(path, "/deploy/"): | 132 | + | // routeHandler is the signature for path-based route handlers. |
| 133 | - | h.handleDeploy(w, r, path) | 133 | + | type routeHandler func(w http.ResponseWriter, r *http.Request, path string) |
| 134 | - | case path == "/logs" || strings.HasPrefix(path, "/logs/"): | 134 | + | |
| 135 | - | h.handleLogs(w, r, path) | 135 | + | // matchRoute returns the handler for a given path, or false if not found. |
| 136 | - | case path == "/status": | 136 | + | func (h *Handler) matchRoute(path string) (routeHandler, bool) { |
| 137 | - | h.handleStatus(w, r) | 137 | + | // Exact match routes |
| 138 | - | case path == "/health": | 138 | + | exactRoutes := map[string]routeHandler{ |
| 139 | - | h.handleHealth(w, r) | 139 | + | "/networks": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleNetworks(w, r) }, |
| 140 | - | case path == "/reload": | 140 | + | "/status": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleStatus(w, r) }, |
| 141 | - | h.handleReload(w, r) | 141 | + | "/health": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleHealth(w, r) }, |
| 142 | - | case path == "/config": | 142 | + | "/reload": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleReload(w, r) }, |
| 143 | - | h.handleConfig(w, r) | 143 | + | "/config": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleConfig(w, r) }, |
| 144 | - | default: | 144 | + | } |
| 145 | - | h.sendError(w, http.StatusNotFound, "route not found") | 145 | + | if handler, ok := exactRoutes[path]; ok { |
| 146 | + | return handler, true | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | // Prefix match routes | |||
| 150 | + | prefixRoutes := []struct { | |||
| 151 | + | prefix string | |||
| 152 | + | handler routeHandler | |||
| 153 | + | }{ | |||
| 154 | + | {"/attachments", h.handleAttachmentsConfig}, | |||
| 155 | + | {"/routes", h.handleRoutes}, | |||
| 156 | + | {"/secrets", h.handleSecrets}, | |||
| 157 | + | {"/deploy", h.handleDeploy}, | |||
| 158 | + | {"/logs", h.handleLogs}, | |||
| 159 | + | } | |||
| 160 | + | for _, route := range prefixRoutes { | |||
| 161 | + | if path == route.prefix || strings.HasPrefix(path, route.prefix+"/") { | |||
| 162 | + | return route.handler, true | |||
| 163 | + | } | |||
| 146 | } | 164 | } | ||
| 165 | + | ||||
| 166 | + | return nil, false | |||
| 147 | } | 167 | } | ||
| 148 | 168 | ||||
| 149 | // sendJSON sends a JSON response. | 169 | // sendJSON sends a JSON response. | ||
| @@ -857,3 +877,142 @@ func (h *Handler) streamContainerLogs(w http.ResponseWriter, r *http.Request, lo | |||||
| 857 | } | 877 | } | ||
| 858 | } | 878 | } | ||
| 859 | } | 879 | } | ||
| 880 | + | ||||
| 881 | + | // handleAttachmentsConfig handles /admin/attachments endpoints for config-level attachments. | |||
| 882 | + | func (h *Handler) handleAttachmentsConfig(w http.ResponseWriter, r *http.Request, path string) { | |||
| 883 | + | // Parse target (domain or group) from path | |||
| 884 | + | target := strings.TrimPrefix(path, "/attachments/") | |||
| 885 | + | if target == "/attachments" { | |||
| 886 | + | target = "" | |||
| 887 | + | } | |||
| 888 | + | ||||
| 889 | + | switch r.Method { | |||
| 890 | + | case http.MethodGet: | |||
| 891 | + | h.handleAttachmentsConfigGet(w, r, target) | |||
| 892 | + | case http.MethodPost: | |||
| 893 | + | h.handleAttachmentsConfigPost(w, r, target) | |||
| 894 | + | case http.MethodDelete: | |||
| 895 | + | h.handleAttachmentsConfigDelete(w, r, target) | |||
| 896 | + | default: | |||
| 897 | + | h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") | |||
| 898 | + | } | |||
| 899 | + | } | |||
| 900 | + | ||||
| 901 | + | func (h *Handler) handleAttachmentsConfigGet(w http.ResponseWriter, r *http.Request, target string) { | |||
| 902 | + | ctx := r.Context() | |||
| 903 | + | ||||
| 904 | + | // Check read permission | |||
| 905 | + | if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionRead) { | |||
| 906 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for config:read") | |||
| 907 | + | return | |||
| 908 | + | } | |||
| 909 | + | ||||
| 910 | + | if target == "" { | |||
| 911 | + | // List all attachments | |||
| 912 | + | attachments := h.configSvc.GetAllAttachments(ctx) | |||
| 913 | + | h.sendJSON(w, http.StatusOK, dto.AttachmentsConfigResponse{Attachments: attachments}) | |||
| 914 | + | return | |||
| 915 | + | } | |||
| 916 | + | ||||
| 917 | + | // List attachments for specific target | |||
| 918 | + | images, err := h.configSvc.GetAttachmentsFor(ctx, target) | |||
| 919 | + | if err != nil { | |||
| 920 | + | if errors.Is(err, domain.ErrAttachmentNotFound) { | |||
| 921 | + | h.sendError(w, http.StatusNotFound, "no attachments found for target") | |||
| 922 | + | return | |||
| 923 | + | } | |||
| 924 | + | h.sendError(w, http.StatusInternalServerError, "failed to get attachments") | |||
| 925 | + | return | |||
| 926 | + | } | |||
| 927 | + | ||||
| 928 | + | h.sendJSON(w, http.StatusOK, dto.AttachmentConfigResponse{Target: target, Images: images}) | |||
| 929 | + | } | |||
| 930 | + | ||||
| 931 | + | func (h *Handler) handleAttachmentsConfigPost(w http.ResponseWriter, r *http.Request, target string) { | |||
| 932 | + | ctx := r.Context() | |||
| 933 | + | log := zerowrap.FromCtx(ctx) | |||
| 934 | + | ||||
| 935 | + | // Check write permission | |||
| 936 | + | if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) { | |||
| 937 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write") | |||
| 938 | + | return | |||
| 939 | + | } | |||
| 940 | + | ||||
| 941 | + | if target == "" { | |||
| 942 | + | h.sendError(w, http.StatusBadRequest, "target (domain or group) required in path") | |||
| 943 | + | return | |||
| 944 | + | } | |||
| 945 | + | ||||
| 946 | + | // Limit request body size | |||
| 947 | + | r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize) | |||
| 948 | + | ||||
| 949 | + | var req dto.AttachmentAddRequest | |||
| 950 | + | if err := json.NewDecoder(r.Body).Decode(&req); err != nil { | |||
| 951 | + | log.Warn().Err(err).Msg("invalid attachment JSON") | |||
| 952 | + | h.sendError(w, http.StatusBadRequest, "invalid JSON") | |||
| 953 | + | return | |||
| 954 | + | } | |||
| 955 | + | ||||
| 956 | + | if err := h.configSvc.AddAttachment(ctx, target, req.Image); err != nil { | |||
| 957 | + | log.Error().Err(err).Str("target", target).Str("image", req.Image).Msg("failed to add attachment") | |||
| 958 | + | switch { | |||
| 959 | + | case errors.Is(err, domain.ErrAttachmentExists): | |||
| 960 | + | h.sendError(w, http.StatusConflict, "attachment already exists") | |||
| 961 | + | case errors.Is(err, domain.ErrAttachmentImageEmpty): | |||
| 962 | + | h.sendError(w, http.StatusBadRequest, err.Error()) | |||
| 963 | + | case errors.Is(err, domain.ErrAttachmentTargetEmpty): | |||
| 964 | + | h.sendError(w, http.StatusBadRequest, err.Error()) | |||
| 965 | + | default: | |||
| 966 | + | h.sendError(w, http.StatusInternalServerError, "failed to add attachment") | |||
| 967 | + | } | |||
| 968 | + | return | |||
| 969 | + | } | |||
| 970 | + | ||||
| 971 | + | log.Info().Str("target", target).Str("image", req.Image).Msg("attachment added") | |||
| 972 | + | h.sendJSON(w, http.StatusCreated, dto.AttachmentStatusResponse{Status: "added"}) | |||
| 973 | + | } | |||
| 974 | + | ||||
| 975 | + | func (h *Handler) handleAttachmentsConfigDelete(w http.ResponseWriter, r *http.Request, target string) { | |||
| 976 | + | ctx := r.Context() | |||
| 977 | + | log := zerowrap.FromCtx(ctx) | |||
| 978 | + | ||||
| 979 | + | // Check write permission | |||
| 980 | + | if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) { | |||
| 981 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write") | |||
| 982 | + | return | |||
| 983 | + | } | |||
| 984 | + | ||||
| 985 | + | if target == "" { | |||
| 986 | + | h.sendError(w, http.StatusBadRequest, "target (domain or group) required in path") | |||
| 987 | + | return | |||
| 988 | + | } | |||
| 989 | + | ||||
| 990 | + | // Parse image from path: /attachments/{target}/{image} | |||
| 991 | + | // target at this point contains "{domain}/{image}" or just "{domain}" | |||
| 992 | + | parts := strings.SplitN(target, "/", 2) | |||
| 993 | + | if len(parts) != 2 { | |||
| 994 | + | h.sendError(w, http.StatusBadRequest, "image required in path: /attachments/{target}/{image}") | |||
| 995 | + | return | |||
| 996 | + | } | |||
| 997 | + | ||||
| 998 | + | domainOrGroup := parts[0] | |||
| 999 | + | image := parts[1] | |||
| 1000 | + | ||||
| 1001 | + | if err := h.configSvc.RemoveAttachment(ctx, domainOrGroup, image); err != nil { | |||
| 1002 | + | log.Error().Err(err).Str("target", domainOrGroup).Str("image", image).Msg("failed to remove attachment") | |||
| 1003 | + | switch { | |||
| 1004 | + | case errors.Is(err, domain.ErrAttachmentNotFound): | |||
| 1005 | + | h.sendError(w, http.StatusNotFound, "attachment not found") | |||
| 1006 | + | case errors.Is(err, domain.ErrAttachmentImageEmpty): | |||
| 1007 | + | h.sendError(w, http.StatusBadRequest, err.Error()) | |||
| 1008 | + | case errors.Is(err, domain.ErrAttachmentTargetEmpty): | |||
| 1009 | + | h.sendError(w, http.StatusBadRequest, err.Error()) | |||
| 1010 | + | default: | |||
| 1011 | + | h.sendError(w, http.StatusInternalServerError, "failed to remove attachment") | |||
| 1012 | + | } | |||
| 1013 | + | return | |||
| 1014 | + | } | |||
| 1015 | + | ||||
| 1016 | + | log.Info().Str("target", domainOrGroup).Str("image", image).Msg("attachment removed") | |||
| 1017 | + | h.sendJSON(w, http.StatusOK, dto.AttachmentStatusResponse{Status: "removed"}) | |||
| 1018 | + | } | |||
M internal/app/run.go +39 -34
| @@ -90,19 +90,16 @@ type Config struct { | |||||
| 90 | Dir string `mapstructure:"dir"` | 90 | Dir string `mapstructure:"dir"` | ||
| 91 | } `mapstructure:"env"` | 91 | } `mapstructure:"env"` | ||
| 92 | 92 | ||||
| 93 | - | Secrets struct { | 93 | + | Auth struct { |
| 94 | - | Backend string `mapstructure:"backend"` // "pass", "sops", or "unsafe" | 94 | + | Enabled bool `mapstructure:"enabled"` |
| 95 | - | } `mapstructure:"secrets"` | 95 | + | Type string `mapstructure:"type"` // "password" or "token" |
| 96 | - | 96 | + | SecretsBackend string `mapstructure:"secrets_backend"` // "pass", "sops", or "unsafe" | |
| 97 | - | RegistryAuth struct { | 97 | + | Username string `mapstructure:"username"` |
| 98 | - | Enabled bool `mapstructure:"enabled"` | 98 | + | Password string `mapstructure:"password"` // deprecated: use password_hash |
| 99 | - | Type string `mapstructure:"type"` // "password" or "token" | 99 | + | PasswordHash string `mapstructure:"password_hash"` // path in secrets backend |
| 100 | - | Username string `mapstructure:"username"` | 100 | + | TokenSecret string `mapstructure:"token_secret"` // path in secrets backend |
| 101 | - | Password string `mapstructure:"password"` // deprecated: use password_hash | 101 | + | TokenExpiry string `mapstructure:"token_expiry"` // e.g., "720h", "30d" |
| 102 | - | PasswordHash string `mapstructure:"password_hash"` // path in secrets backend | 102 | + | } `mapstructure:"auth"` |
| 103 | - | TokenSecret string `mapstructure:"token_secret"` // path in secrets backend | |||
| 104 | - | TokenExpiry string `mapstructure:"token_expiry"` // e.g., "720h" | |||
| 105 | - | } `mapstructure:"registry_auth"` | |||
| 106 | } | 103 | } | ||
| 107 | 104 | ||||
| 108 | // services holds all the services used by the application. | 105 | // services holds all the services used by the application. | ||
| @@ -264,7 +261,7 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||||
| 264 | } | 261 | } | ||
| 265 | 262 | ||||
| 266 | // Generate internal registry credentials for loopback-only pulls | 263 | // Generate internal registry credentials for loopback-only pulls | ||
| 267 | - | if cfg.RegistryAuth.Enabled { | 264 | + | if cfg.Auth.Enabled { |
| 268 | svc.internalRegUser, svc.internalRegPass, err = generateInternalRegistryAuth() | 265 | svc.internalRegUser, svc.internalRegPass, err = generateInternalRegistryAuth() | ||
| 269 | if err != nil { | 266 | if err != nil { | ||
| 270 | return nil, log.WrapErr(err, "failed to generate internal registry credentials") | 267 | return nil, log.WrapErr(err, "failed to generate internal registry credentials") | ||
| @@ -517,12 +514,12 @@ func GetInternalCredentials() (*InternalCredentials, error) { | |||||
| 517 | 514 | ||||
| 518 | // createAuthService creates the authentication service and token store. | 515 | // createAuthService creates the authentication service and token store. | ||
| 519 | func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (out.TokenStore, *auth.Service, error) { | 516 | func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (out.TokenStore, *auth.Service, error) { | ||
| 520 | - | if !cfg.RegistryAuth.Enabled { | 517 | + | if !cfg.Auth.Enabled { |
| 521 | return nil, nil, nil | 518 | return nil, nil, nil | ||
| 522 | } | 519 | } | ||
| 523 | 520 | ||||
| 524 | - | authType := resolveAuthType(cfg.RegistryAuth.Type) | 521 | + | authType := resolveAuthType(cfg.Auth.Type) |
| 525 | - | backend := resolveSecretsBackend(cfg.Secrets.Backend) | 522 | + | backend := resolveSecretsBackend(cfg.Auth.SecretsBackend) |
| 526 | dataDir := resolveDataDir(cfg.Server.DataDir) | 523 | dataDir := resolveDataDir(cfg.Server.DataDir) | ||
| 527 | 524 | ||||
| 528 | store, err := createTokenStore(authType, backend, dataDir, log) | 525 | store, err := createTokenStore(authType, backend, dataDir, log) | ||
| @@ -586,9 +583,9 @@ func createTokenStore(authType domain.AuthType, backend domain.SecretsBackend, d | |||||
| 586 | 583 | ||||
| 587 | func buildAuthConfig(ctx context.Context, cfg Config, authType domain.AuthType, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (auth.Config, error) { | 584 | func buildAuthConfig(ctx context.Context, cfg Config, authType domain.AuthType, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (auth.Config, error) { | ||
| 588 | authConfig := auth.Config{ | 585 | authConfig := auth.Config{ | ||
| 589 | - | Enabled: cfg.RegistryAuth.Enabled, | 586 | + | Enabled: cfg.Auth.Enabled, |
| 590 | AuthType: authType, | 587 | AuthType: authType, | ||
| 591 | - | Username: cfg.RegistryAuth.Username, | 588 | + | Username: cfg.Auth.Username, |
| 592 | } | 589 | } | ||
| 593 | 590 | ||||
| 594 | switch authType { | 591 | switch authType { | ||
| @@ -597,6 +594,9 @@ func buildAuthConfig(ctx context.Context, cfg Config, authType domain.AuthType, | |||||
| 597 | if err != nil { | 594 | if err != nil { | ||
| 598 | return auth.Config{}, err | 595 | return auth.Config{}, err | ||
| 599 | } | 596 | } | ||
| 597 | + | if hash == "" { | |||
| 598 | + | return auth.Config{}, errAuthNotConfigured() | |||
| 599 | + | } | |||
| 600 | authConfig.PasswordHash = hash | 600 | authConfig.PasswordHash = hash | ||
| 601 | case domain.AuthTypeToken: | 601 | case domain.AuthTypeToken: | ||
| 602 | secret, expiry, err := loadTokenConfig(ctx, cfg, backend, dataDir, log) | 602 | secret, expiry, err := loadTokenConfig(ctx, cfg, backend, dataDir, log) | ||
| @@ -610,18 +610,23 @@ func buildAuthConfig(ctx context.Context, cfg Config, authType domain.AuthType, | |||||
| 610 | return authConfig, nil | 610 | return authConfig, nil | ||
| 611 | } | 611 | } | ||
| 612 | 612 | ||||
| 613 | + | // errAuthNotConfigured returns an error when auth is enabled but credentials are not configured. | |||
| 614 | + | func errAuthNotConfigured() error { | |||
| 615 | + | return fmt.Errorf("auth is enabled by default, configure auth type and secrets backend. See: https://gordon.bnema.dev/docs/config/auth") | |||
| 616 | + | } | |||
| 617 | + | ||||
| 613 | func loadPasswordHash(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (string, error) { | 618 | func loadPasswordHash(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (string, error) { | ||
| 614 | - | if cfg.RegistryAuth.PasswordHash != "" { | 619 | + | if cfg.Auth.PasswordHash != "" { |
| 615 | - | hash, err := loadSecret(ctx, backend, cfg.RegistryAuth.PasswordHash, dataDir, log) | 620 | + | hash, err := loadSecret(ctx, backend, cfg.Auth.PasswordHash, dataDir, log) |
| 616 | if err != nil { | 621 | if err != nil { | ||
| 617 | return "", log.WrapErr(err, "failed to load password hash") | 622 | return "", log.WrapErr(err, "failed to load password hash") | ||
| 618 | } | 623 | } | ||
| 619 | return hash, nil | 624 | return hash, nil | ||
| 620 | } | 625 | } | ||
| 621 | 626 | ||||
| 622 | - | if cfg.RegistryAuth.Password != "" { | 627 | + | if cfg.Auth.Password != "" { |
| 623 | log.Warn().Msg("using plain password in config is deprecated, use password_hash with a secrets backend") | 628 | log.Warn().Msg("using plain password in config is deprecated, use password_hash with a secrets backend") | ||
| 624 | - | return cfg.RegistryAuth.Password, nil | 629 | + | return cfg.Auth.Password, nil |
| 625 | } | 630 | } | ||
| 626 | 631 | ||||
| 627 | return "", nil | 632 | return "", nil | ||
| @@ -633,7 +638,7 @@ func loadTokenConfig(ctx context.Context, cfg Config, backend domain.SecretsBack | |||||
| 633 | return nil, 0, err | 638 | return nil, 0, err | ||
| 634 | } | 639 | } | ||
| 635 | 640 | ||||
| 636 | - | expiry, err := parseTokenExpiry(cfg.RegistryAuth.TokenExpiry) | 641 | + | expiry, err := parseTokenExpiry(cfg.Auth.TokenExpiry) |
| 637 | if err != nil { | 642 | if err != nil { | ||
| 638 | return nil, 0, err | 643 | return nil, 0, err | ||
| 639 | } | 644 | } | ||
| @@ -642,11 +647,11 @@ func loadTokenConfig(ctx context.Context, cfg Config, backend domain.SecretsBack | |||||
| 642 | } | 647 | } | ||
| 643 | 648 | ||||
| 644 | func loadTokenSecret(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) ([]byte, error) { | 649 | func loadTokenSecret(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) ([]byte, error) { | ||
| 645 | - | if cfg.RegistryAuth.TokenSecret == "" { | 650 | + | if cfg.Auth.TokenSecret == "" { |
| 646 | return nil, fmt.Errorf("token_secret is required for token authentication") | 651 | return nil, fmt.Errorf("token_secret is required for token authentication") | ||
| 647 | } | 652 | } | ||
| 648 | 653 | ||||
| 649 | - | secret, err := loadSecret(ctx, backend, cfg.RegistryAuth.TokenSecret, dataDir, log) | 654 | + | secret, err := loadSecret(ctx, backend, cfg.Auth.TokenSecret, dataDir, log) |
| 650 | if err != nil { | 655 | if err != nil { | ||
| 651 | return nil, log.WrapErr(err, "failed to load token secret") | 656 | return nil, log.WrapErr(err, "failed to load token secret") | ||
| 652 | } | 657 | } | ||
| @@ -692,11 +697,11 @@ func loadSecret(ctx context.Context, backend domain.SecretsBackend, path, dataDi | |||||
| 692 | // createContainerService creates the container service with configuration. | 697 | // createContainerService creates the container service with configuration. | ||
| 693 | func createContainerService(v *viper.Viper, cfg Config, svc *services) *container.Service { | 698 | func createContainerService(v *viper.Viper, cfg Config, svc *services) *container.Service { | ||
| 694 | containerConfig := container.Config{ | 699 | containerConfig := container.Config{ | ||
| 695 | - | RegistryAuthEnabled: cfg.RegistryAuth.Enabled, | 700 | + | RegistryAuthEnabled: cfg.Auth.Enabled, |
| 696 | RegistryDomain: cfg.Server.RegistryDomain, | 701 | RegistryDomain: cfg.Server.RegistryDomain, | ||
| 697 | RegistryPort: cfg.Server.RegistryPort, | 702 | RegistryPort: cfg.Server.RegistryPort, | ||
| 698 | - | RegistryUsername: cfg.RegistryAuth.Username, | 703 | + | RegistryUsername: cfg.Auth.Username, |
| 699 | - | RegistryPassword: cfg.RegistryAuth.Password, | 704 | + | RegistryPassword: cfg.Auth.Password, |
| 700 | InternalRegistryUsername: svc.internalRegUser, | 705 | InternalRegistryUsername: svc.internalRegUser, | ||
| 701 | InternalRegistryPassword: svc.internalRegPass, | 706 | InternalRegistryPassword: svc.internalRegPass, | ||
| 702 | PullPolicy: v.GetString("deploy.pull_policy"), | 707 | PullPolicy: v.GetString("deploy.pull_policy"), | ||
| @@ -803,7 +808,7 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||||
| 803 | middleware.RequestLogger(log), | 808 | middleware.RequestLogger(log), | ||
| 804 | } | 809 | } | ||
| 805 | 810 | ||||
| 806 | - | if cfg.RegistryAuth.Enabled && svc.authSvc != nil { | 811 | + | if cfg.Auth.Enabled && svc.authSvc != nil { |
| 807 | internalAuth := middleware.InternalRegistryAuth{ | 812 | internalAuth := middleware.InternalRegistryAuth{ | ||
| 808 | Username: svc.internalRegUser, | 813 | Username: svc.internalRegUser, | ||
| 809 | Password: svc.internalRegPass, | 814 | Password: svc.internalRegPass, | ||
| @@ -1143,10 +1148,10 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||||
| 1143 | v.SetDefault("logging.container_logs.max_backups", 3) | 1148 | v.SetDefault("logging.container_logs.max_backups", 3) | ||
| 1144 | v.SetDefault("logging.container_logs.max_age", 28) | 1149 | v.SetDefault("logging.container_logs.max_age", 28) | ||
| 1145 | v.SetDefault("env.dir", "") // defaults to {data_dir}/env when empty | 1150 | v.SetDefault("env.dir", "") // defaults to {data_dir}/env when empty | ||
| 1146 | - | v.SetDefault("secrets.backend", "unsafe") | 1151 | + | v.SetDefault("auth.enabled", true) |
| 1147 | - | v.SetDefault("registry_auth.enabled", false) | 1152 | + | v.SetDefault("auth.type", "password") |
| 1148 | - | v.SetDefault("registry_auth.type", "password") | 1153 | + | v.SetDefault("auth.secrets_backend", "unsafe") |
| 1149 | - | v.SetDefault("registry_auth.token_expiry", "720h") | 1154 | + | v.SetDefault("auth.token_expiry", "720h") |
| 1150 | v.SetDefault("auto_route.enabled", false) | 1155 | v.SetDefault("auto_route.enabled", false) | ||
| 1151 | v.SetDefault("network_isolation.enabled", false) | 1156 | v.SetDefault("network_isolation.enabled", false) | ||
| 1152 | v.SetDefault("network_isolation.network_prefix", "gordon") | 1157 | v.SetDefault("network_isolation.network_prefix", "gordon") | ||
M internal/boundaries/in/config.go +12 -0
| @@ -56,4 +56,16 @@ type ConfigService interface { | |||||
| 56 | 56 | ||||
| 57 | // GetExternalRoutes returns all configured external routes. | 57 | // GetExternalRoutes returns all configured external routes. | ||
| 58 | GetExternalRoutes() map[string]string | 58 | GetExternalRoutes() map[string]string | ||
| 59 | + | ||||
| 60 | + | // GetAllAttachments returns all configured attachments. | |||
| 61 | + | GetAllAttachments(ctx context.Context) map[string][]string | |||
| 62 | + | ||||
| 63 | + | // GetAttachmentsFor returns attachments for a specific domain or network group. | |||
| 64 | + | GetAttachmentsFor(ctx context.Context, domainOrGroup string) ([]string, error) | |||
| 65 | + | ||||
| 66 | + | // AddAttachment adds an image to a domain/group's attachments. | |||
| 67 | + | AddAttachment(ctx context.Context, domainOrGroup, image string) error | |||
| 68 | + | ||||
| 69 | + | // RemoveAttachment removes an image from a domain/group's attachments. | |||
| 70 | + | RemoveAttachment(ctx context.Context, domainOrGroup, image string) error | |||
| 59 | } | 71 | } | ||
M internal/boundaries/in/mocks/mock_config_service.go +247 -0
| @@ -38,6 +38,69 @@ func (_m *MockConfigService) EXPECT() *MockConfigService_Expecter { | |||||
| 38 | return &MockConfigService_Expecter{mock: &_m.Mock} | 38 | return &MockConfigService_Expecter{mock: &_m.Mock} | ||
| 39 | } | 39 | } | ||
| 40 | 40 | ||||
| 41 | + | // AddAttachment provides a mock function for the type MockConfigService | |||
| 42 | + | func (_mock *MockConfigService) AddAttachment(ctx context.Context, domainOrGroup string, image string) error { | |||
| 43 | + | ret := _mock.Called(ctx, domainOrGroup, image) | |||
| 44 | + | ||||
| 45 | + | if len(ret) == 0 { | |||
| 46 | + | panic("no return value specified for AddAttachment") | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | var r0 error | |||
| 50 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok { | |||
| 51 | + | r0 = returnFunc(ctx, domainOrGroup, image) | |||
| 52 | + | } else { | |||
| 53 | + | r0 = ret.Error(0) | |||
| 54 | + | } | |||
| 55 | + | return r0 | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | // MockConfigService_AddAttachment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddAttachment' | |||
| 59 | + | type MockConfigService_AddAttachment_Call struct { | |||
| 60 | + | *mock.Call | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | // AddAttachment is a helper method to define mock.On call | |||
| 64 | + | // - ctx context.Context | |||
| 65 | + | // - domainOrGroup string | |||
| 66 | + | // - image string | |||
| 67 | + | func (_e *MockConfigService_Expecter) AddAttachment(ctx interface{}, domainOrGroup interface{}, image interface{}) *MockConfigService_AddAttachment_Call { | |||
| 68 | + | return &MockConfigService_AddAttachment_Call{Call: _e.mock.On("AddAttachment", ctx, domainOrGroup, image)} | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | func (_c *MockConfigService_AddAttachment_Call) Run(run func(ctx context.Context, domainOrGroup string, image string)) *MockConfigService_AddAttachment_Call { | |||
| 72 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 73 | + | var arg0 context.Context | |||
| 74 | + | if args[0] != nil { | |||
| 75 | + | arg0 = args[0].(context.Context) | |||
| 76 | + | } | |||
| 77 | + | var arg1 string | |||
| 78 | + | if args[1] != nil { | |||
| 79 | + | arg1 = args[1].(string) | |||
| 80 | + | } | |||
| 81 | + | var arg2 string | |||
| 82 | + | if args[2] != nil { | |||
| 83 | + | arg2 = args[2].(string) | |||
| 84 | + | } | |||
| 85 | + | run( | |||
| 86 | + | arg0, | |||
| 87 | + | arg1, | |||
| 88 | + | arg2, | |||
| 89 | + | ) | |||
| 90 | + | }) | |||
| 91 | + | return _c | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | func (_c *MockConfigService_AddAttachment_Call) Return(err error) *MockConfigService_AddAttachment_Call { | |||
| 95 | + | _c.Call.Return(err) | |||
| 96 | + | return _c | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | func (_c *MockConfigService_AddAttachment_Call) RunAndReturn(run func(ctx context.Context, domainOrGroup string, image string) error) *MockConfigService_AddAttachment_Call { | |||
| 100 | + | _c.Call.Return(run) | |||
| 101 | + | return _c | |||
| 102 | + | } | |||
| 103 | + | ||||
| 41 | // AddRoute provides a mock function for the type MockConfigService | 104 | // AddRoute provides a mock function for the type MockConfigService | ||
| 42 | func (_mock *MockConfigService) AddRoute(ctx context.Context, route domain.Route) error { | 105 | func (_mock *MockConfigService) AddRoute(ctx context.Context, route domain.Route) error { | ||
| 43 | ret := _mock.Called(ctx, route) | 106 | ret := _mock.Called(ctx, route) | ||
| @@ -95,6 +158,127 @@ func (_c *MockConfigService_AddRoute_Call) RunAndReturn(run func(ctx context.Con | |||||
| 95 | return _c | 158 | return _c | ||
| 96 | } | 159 | } | ||
| 97 | 160 | ||||
| 161 | + | // GetAllAttachments provides a mock function for the type MockConfigService | |||
| 162 | + | func (_mock *MockConfigService) GetAllAttachments(ctx context.Context) map[string][]string { | |||
| 163 | + | ret := _mock.Called(ctx) | |||
| 164 | + | ||||
| 165 | + | if len(ret) == 0 { | |||
| 166 | + | panic("no return value specified for GetAllAttachments") | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | var r0 map[string][]string | |||
| 170 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) map[string][]string); ok { | |||
| 171 | + | r0 = returnFunc(ctx) | |||
| 172 | + | } else { | |||
| 173 | + | if ret.Get(0) != nil { | |||
| 174 | + | r0 = ret.Get(0).(map[string][]string) | |||
| 175 | + | } | |||
| 176 | + | } | |||
| 177 | + | return r0 | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | // MockConfigService_GetAllAttachments_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAllAttachments' | |||
| 181 | + | type MockConfigService_GetAllAttachments_Call struct { | |||
| 182 | + | *mock.Call | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | // GetAllAttachments is a helper method to define mock.On call | |||
| 186 | + | // - ctx context.Context | |||
| 187 | + | func (_e *MockConfigService_Expecter) GetAllAttachments(ctx interface{}) *MockConfigService_GetAllAttachments_Call { | |||
| 188 | + | return &MockConfigService_GetAllAttachments_Call{Call: _e.mock.On("GetAllAttachments", ctx)} | |||
| 189 | + | } | |||
| 190 | + | ||||
| 191 | + | func (_c *MockConfigService_GetAllAttachments_Call) Run(run func(ctx context.Context)) *MockConfigService_GetAllAttachments_Call { | |||
| 192 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 193 | + | var arg0 context.Context | |||
| 194 | + | if args[0] != nil { | |||
| 195 | + | arg0 = args[0].(context.Context) | |||
| 196 | + | } | |||
| 197 | + | run( | |||
| 198 | + | arg0, | |||
| 199 | + | ) | |||
| 200 | + | }) | |||
| 201 | + | return _c | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | func (_c *MockConfigService_GetAllAttachments_Call) Return(stringToStrings map[string][]string) *MockConfigService_GetAllAttachments_Call { | |||
| 205 | + | _c.Call.Return(stringToStrings) | |||
| 206 | + | return _c | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | func (_c *MockConfigService_GetAllAttachments_Call) RunAndReturn(run func(ctx context.Context) map[string][]string) *MockConfigService_GetAllAttachments_Call { | |||
| 210 | + | _c.Call.Return(run) | |||
| 211 | + | return _c | |||
| 212 | + | } | |||
| 213 | + | ||||
| 214 | + | // GetAttachmentsFor provides a mock function for the type MockConfigService | |||
| 215 | + | func (_mock *MockConfigService) GetAttachmentsFor(ctx context.Context, domainOrGroup string) ([]string, error) { | |||
| 216 | + | ret := _mock.Called(ctx, domainOrGroup) | |||
| 217 | + | ||||
| 218 | + | if len(ret) == 0 { | |||
| 219 | + | panic("no return value specified for GetAttachmentsFor") | |||
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | var r0 []string | |||
| 223 | + | var r1 error | |||
| 224 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]string, error)); ok { | |||
| 225 | + | return returnFunc(ctx, domainOrGroup) | |||
| 226 | + | } | |||
| 227 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) []string); ok { | |||
| 228 | + | r0 = returnFunc(ctx, domainOrGroup) | |||
| 229 | + | } else { | |||
| 230 | + | if ret.Get(0) != nil { | |||
| 231 | + | r0 = ret.Get(0).([]string) | |||
| 232 | + | } | |||
| 233 | + | } | |||
| 234 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | |||
| 235 | + | r1 = returnFunc(ctx, domainOrGroup) | |||
| 236 | + | } else { | |||
| 237 | + | r1 = ret.Error(1) | |||
| 238 | + | } | |||
| 239 | + | return r0, r1 | |||
| 240 | + | } | |||
| 241 | + | ||||
| 242 | + | // MockConfigService_GetAttachmentsFor_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAttachmentsFor' | |||
| 243 | + | type MockConfigService_GetAttachmentsFor_Call struct { | |||
| 244 | + | *mock.Call | |||
| 245 | + | } | |||
| 246 | + | ||||
| 247 | + | // GetAttachmentsFor is a helper method to define mock.On call | |||
| 248 | + | // - ctx context.Context | |||
| 249 | + | // - domainOrGroup string | |||
| 250 | + | func (_e *MockConfigService_Expecter) GetAttachmentsFor(ctx interface{}, domainOrGroup interface{}) *MockConfigService_GetAttachmentsFor_Call { | |||
| 251 | + | return &MockConfigService_GetAttachmentsFor_Call{Call: _e.mock.On("GetAttachmentsFor", ctx, domainOrGroup)} | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | func (_c *MockConfigService_GetAttachmentsFor_Call) Run(run func(ctx context.Context, domainOrGroup string)) *MockConfigService_GetAttachmentsFor_Call { | |||
| 255 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 256 | + | var arg0 context.Context | |||
| 257 | + | if args[0] != nil { | |||
| 258 | + | arg0 = args[0].(context.Context) | |||
| 259 | + | } | |||
| 260 | + | var arg1 string | |||
| 261 | + | if args[1] != nil { | |||
| 262 | + | arg1 = args[1].(string) | |||
| 263 | + | } | |||
| 264 | + | run( | |||
| 265 | + | arg0, | |||
| 266 | + | arg1, | |||
| 267 | + | ) | |||
| 268 | + | }) | |||
| 269 | + | return _c | |||
| 270 | + | } | |||
| 271 | + | ||||
| 272 | + | func (_c *MockConfigService_GetAttachmentsFor_Call) Return(strings []string, err error) *MockConfigService_GetAttachmentsFor_Call { | |||
| 273 | + | _c.Call.Return(strings, err) | |||
| 274 | + | return _c | |||
| 275 | + | } | |||
| 276 | + | ||||
| 277 | + | func (_c *MockConfigService_GetAttachmentsFor_Call) RunAndReturn(run func(ctx context.Context, domainOrGroup string) ([]string, error)) *MockConfigService_GetAttachmentsFor_Call { | |||
| 278 | + | _c.Call.Return(run) | |||
| 279 | + | return _c | |||
| 280 | + | } | |||
| 281 | + | ||||
| 98 | // GetDataDir provides a mock function for the type MockConfigService | 282 | // GetDataDir provides a mock function for the type MockConfigService | ||
| 99 | func (_mock *MockConfigService) GetDataDir() string { | 283 | func (_mock *MockConfigService) GetDataDir() string { | ||
| 100 | ret := _mock.Called() | 284 | ret := _mock.Called() | ||
| @@ -621,6 +805,69 @@ func (_c *MockConfigService_Load_Call) RunAndReturn(run func(ctx context.Context | |||||
| 621 | return _c | 805 | return _c | ||
| 622 | } | 806 | } | ||
| 623 | 807 | ||||
| 808 | + | // RemoveAttachment provides a mock function for the type MockConfigService | |||
| 809 | + | func (_mock *MockConfigService) RemoveAttachment(ctx context.Context, domainOrGroup string, image string) error { | |||
| 810 | + | ret := _mock.Called(ctx, domainOrGroup, image) | |||
| 811 | + | ||||
| 812 | + | if len(ret) == 0 { | |||
| 813 | + | panic("no return value specified for RemoveAttachment") | |||
| 814 | + | } | |||
| 815 | + | ||||
| 816 | + | var r0 error | |||
| 817 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok { | |||
| 818 | + | r0 = returnFunc(ctx, domainOrGroup, image) | |||
| 819 | + | } else { | |||
| 820 | + | r0 = ret.Error(0) | |||
| 821 | + | } | |||
| 822 | + | return r0 | |||
| 823 | + | } | |||
| 824 | + | ||||
| 825 | + | // MockConfigService_RemoveAttachment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveAttachment' | |||
| 826 | + | type MockConfigService_RemoveAttachment_Call struct { | |||
| 827 | + | *mock.Call | |||
| 828 | + | } | |||
| 829 | + | ||||
| 830 | + | // RemoveAttachment is a helper method to define mock.On call | |||
| 831 | + | // - ctx context.Context | |||
| 832 | + | // - domainOrGroup string | |||
| 833 | + | // - image string | |||
| 834 | + | func (_e *MockConfigService_Expecter) RemoveAttachment(ctx interface{}, domainOrGroup interface{}, image interface{}) *MockConfigService_RemoveAttachment_Call { | |||
| 835 | + | return &MockConfigService_RemoveAttachment_Call{Call: _e.mock.On("RemoveAttachment", ctx, domainOrGroup, image)} | |||
| 836 | + | } | |||
| 837 | + | ||||
| 838 | + | func (_c *MockConfigService_RemoveAttachment_Call) Run(run func(ctx context.Context, domainOrGroup string, image string)) *MockConfigService_RemoveAttachment_Call { | |||
| 839 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 840 | + | var arg0 context.Context | |||
| 841 | + | if args[0] != nil { | |||
| 842 | + | arg0 = args[0].(context.Context) | |||
| 843 | + | } | |||
| 844 | + | var arg1 string | |||
| 845 | + | if args[1] != nil { | |||
| 846 | + | arg1 = args[1].(string) | |||
| 847 | + | } | |||
| 848 | + | var arg2 string | |||
| 849 | + | if args[2] != nil { | |||
| 850 | + | arg2 = args[2].(string) | |||
| 851 | + | } | |||
| 852 | + | run( | |||
| 853 | + | arg0, | |||
| 854 | + | arg1, | |||
| 855 | + | arg2, | |||
| 856 | + | ) | |||
| 857 | + | }) | |||
| 858 | + | return _c | |||
| 859 | + | } | |||
| 860 | + | ||||
| 861 | + | func (_c *MockConfigService_RemoveAttachment_Call) Return(err error) *MockConfigService_RemoveAttachment_Call { | |||
| 862 | + | _c.Call.Return(err) | |||
| 863 | + | return _c | |||
| 864 | + | } | |||
| 865 | + | ||||
| 866 | + | func (_c *MockConfigService_RemoveAttachment_Call) RunAndReturn(run func(ctx context.Context, domainOrGroup string, image string) error) *MockConfigService_RemoveAttachment_Call { | |||
| 867 | + | _c.Call.Return(run) | |||
| 868 | + | return _c | |||
| 869 | + | } | |||
| 870 | + | ||||
| 624 | // RemoveRoute provides a mock function for the type MockConfigService | 871 | // RemoveRoute provides a mock function for the type MockConfigService | ||
| 625 | func (_mock *MockConfigService) RemoveRoute(ctx context.Context, domain1 string) error { | 872 | func (_mock *MockConfigService) RemoveRoute(ctx context.Context, domain1 string) error { | ||
| 626 | ret := _mock.Called(ctx, domain1) | 873 | ret := _mock.Called(ctx, domain1) | ||
M internal/domain/errors.go +6 -0
| @@ -62,4 +62,10 @@ var ( | |||||
| 62 | ErrInvalidPath = errors.New("invalid path format") | 62 | ErrInvalidPath = errors.New("invalid path format") | ||
| 63 | ErrInsufficientScope = errors.New("insufficient scope for operation") | 63 | ErrInsufficientScope = errors.New("insufficient scope for operation") | ||
| 64 | ErrInvalidScope = errors.New("invalid scope format") | 64 | ErrInvalidScope = errors.New("invalid scope format") | ||
| 65 | + | ||||
| 66 | + | // Attachment errors | |||
| 67 | + | ErrAttachmentNotFound = errors.New("attachment not found") | |||
| 68 | + | ErrAttachmentExists = errors.New("attachment already exists") | |||
| 69 | + | ErrAttachmentImageEmpty = errors.New("attachment image cannot be empty") | |||
| 70 | + | ErrAttachmentTargetEmpty = errors.New("attachment target (domain or group) cannot be empty") | |||
| 65 | ) | 71 | ) | ||
M internal/testutils/helpers.go +3 -2
| @@ -44,8 +44,9 @@ registry_port = 5000 | |||||
| 44 | registry_domain = "registry.example.com" | 44 | registry_domain = "registry.example.com" | ||
| 45 | runtime = "docker" | 45 | runtime = "docker" | ||
| 46 | 46 | ||||
| 47 | - | [registry_auth] | 47 | + | [auth] |
| 48 | enabled = true | 48 | enabled = true | ||
| 49 | + | secrets_backend = "unsafe" | |||
| 49 | username = "admin" | 50 | username = "admin" | ||
| 50 | password = "password123" | 51 | password = "password123" | ||
| 51 | 52 | ||||
| @@ -72,7 +73,7 @@ level = "info"` | |||||
| 72 | registry_domain = "registry.example.com" | 73 | registry_domain = "registry.example.com" | ||
| 73 | runtime = "docker" | 74 | runtime = "docker" | ||
| 74 | 75 | ||||
| 75 | - | [registry_auth] | 76 | + | [auth] |
| 76 | enabled = false` | 77 | enabled = false` | ||
| 77 | case "invalid.toml": | 78 | case "invalid.toml": | ||
| 78 | return `[server | 79 | return `[server | ||
M internal/usecase/config/service.go +156 -4
| @@ -101,9 +101,9 @@ func (s *Service) loadConfigValues() Config { | |||||
| 101 | AutoRouteEnabled: s.viper.GetBool("auto_route.enabled"), | 101 | AutoRouteEnabled: s.viper.GetBool("auto_route.enabled"), | ||
| 102 | NetworkIsolation: s.viper.GetBool("network_isolation.enabled"), | 102 | NetworkIsolation: s.viper.GetBool("network_isolation.enabled"), | ||
| 103 | NetworkPrefix: s.viper.GetString("network_isolation.network_prefix"), | 103 | NetworkPrefix: s.viper.GetString("network_isolation.network_prefix"), | ||
| 104 | - | RegistryAuthEnabled: s.viper.GetBool("registry_auth.enabled"), | 104 | + | RegistryAuthEnabled: s.viper.GetBool("auth.enabled"), |
| 105 | - | RegistryAuthUsername: s.viper.GetString("registry_auth.username"), | 105 | + | RegistryAuthUsername: s.viper.GetString("auth.username"), |
| 106 | - | RegistryAuthPassword: s.viper.GetString("registry_auth.password"), | 106 | + | RegistryAuthPassword: s.viper.GetString("auth.password"), |
| 107 | VolumeAutoCreate: s.viper.GetBool("volumes.auto_create"), | 107 | VolumeAutoCreate: s.viper.GetBool("volumes.auto_create"), | ||
| 108 | VolumePrefix: s.viper.GetString("volumes.prefix"), | 108 | VolumePrefix: s.viper.GetString("volumes.prefix"), | ||
| 109 | VolumePreserve: s.viper.GetBool("volumes.preserve"), | 109 | VolumePreserve: s.viper.GetBool("volumes.preserve"), | ||
| @@ -333,8 +333,9 @@ func (s *Service) Save(ctx context.Context) error { | |||||
| 333 | s.mu.Lock() | 333 | s.mu.Lock() | ||
| 334 | defer s.mu.Unlock() | 334 | defer s.mu.Unlock() | ||
| 335 | 335 | ||||
| 336 | - | // Update viper with current routes | 336 | + | // Update viper with current config |
| 337 | s.viper.Set("routes", s.config.Routes) | 337 | s.viper.Set("routes", s.config.Routes) | ||
| 338 | + | s.viper.Set("attachments", s.config.Attachments) | |||
| 338 | 339 | ||||
| 339 | // Record save time to debounce file watcher events | 340 | // Record save time to debounce file watcher events | ||
| 340 | atomic.StoreInt64(&s.lastSaveTime, time.Now().UnixNano()) | 341 | atomic.StoreInt64(&s.lastSaveTime, time.Now().UnixNano()) | ||
| @@ -475,6 +476,7 @@ func (s *Service) GetNetworkGroups() map[string][]string { | |||||
| 475 | } | 476 | } | ||
| 476 | 477 | ||||
| 477 | // GetAttachments returns attachment configuration. | 478 | // GetAttachments returns attachment configuration. | ||
| 479 | + | // Deprecated: Use GetAllAttachments instead. | |||
| 478 | func (s *Service) GetAttachments() map[string][]string { | 480 | func (s *Service) GetAttachments() map[string][]string { | ||
| 479 | s.mu.RLock() | 481 | s.mu.RLock() | ||
| 480 | defer s.mu.RUnlock() | 482 | defer s.mu.RUnlock() | ||
| @@ -486,6 +488,156 @@ func (s *Service) GetAttachments() map[string][]string { | |||||
| 486 | return result | 488 | return result | ||
| 487 | } | 489 | } | ||
| 488 | 490 | ||||
| 491 | + | // GetAllAttachments returns all configured attachments. | |||
| 492 | + | func (s *Service) GetAllAttachments(_ context.Context) map[string][]string { | |||
| 493 | + | s.mu.RLock() | |||
| 494 | + | defer s.mu.RUnlock() | |||
| 495 | + | ||||
| 496 | + | result := make(map[string][]string) | |||
| 497 | + | for k, v := range s.config.Attachments { | |||
| 498 | + | result[k] = append([]string{}, v...) | |||
| 499 | + | } | |||
| 500 | + | return result | |||
| 501 | + | } | |||
| 502 | + | ||||
| 503 | + | // GetAttachmentsFor returns attachments for a specific domain or network group. | |||
| 504 | + | func (s *Service) GetAttachmentsFor(_ context.Context, domainOrGroup string) ([]string, error) { | |||
| 505 | + | s.mu.RLock() | |||
| 506 | + | defer s.mu.RUnlock() | |||
| 507 | + | ||||
| 508 | + | images, exists := s.config.Attachments[domainOrGroup] | |||
| 509 | + | if !exists { | |||
| 510 | + | return nil, domain.ErrAttachmentNotFound | |||
| 511 | + | } | |||
| 512 | + | ||||
| 513 | + | return append([]string{}, images...), nil | |||
| 514 | + | } | |||
| 515 | + | ||||
| 516 | + | // AddAttachment adds an image to a domain/group's attachments. | |||
| 517 | + | func (s *Service) AddAttachment(ctx context.Context, domainOrGroup, image string) error { | |||
| 518 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |||
| 519 | + | zerowrap.FieldLayer: "usecase", | |||
| 520 | + | zerowrap.FieldUseCase: "AddAttachment", | |||
| 521 | + | "target": domainOrGroup, | |||
| 522 | + | "image": image, | |||
| 523 | + | }) | |||
| 524 | + | log := zerowrap.FromCtx(ctx) | |||
| 525 | + | ||||
| 526 | + | // Validate input | |||
| 527 | + | if domainOrGroup == "" { | |||
| 528 | + | return domain.ErrAttachmentTargetEmpty | |||
| 529 | + | } | |||
| 530 | + | if image == "" { | |||
| 531 | + | return domain.ErrAttachmentImageEmpty | |||
| 532 | + | } | |||
| 533 | + | ||||
| 534 | + | s.mu.Lock() | |||
| 535 | + | ||||
| 536 | + | // Initialize map if needed | |||
| 537 | + | if s.config.Attachments == nil { | |||
| 538 | + | s.config.Attachments = make(map[string][]string) | |||
| 539 | + | } | |||
| 540 | + | ||||
| 541 | + | // Check if already exists | |||
| 542 | + | existing := s.config.Attachments[domainOrGroup] | |||
| 543 | + | for _, img := range existing { | |||
| 544 | + | if img == image { | |||
| 545 | + | s.mu.Unlock() | |||
| 546 | + | return domain.ErrAttachmentExists | |||
| 547 | + | } | |||
| 548 | + | } | |||
| 549 | + | ||||
| 550 | + | // Store previous value for rollback | |||
| 551 | + | previousImages := append([]string{}, existing...) | |||
| 552 | + | hadKey := len(existing) > 0 | |||
| 553 | + | ||||
| 554 | + | // Add the image | |||
| 555 | + | s.config.Attachments[domainOrGroup] = append(existing, image) | |||
| 556 | + | s.mu.Unlock() | |||
| 557 | + | ||||
| 558 | + | // Persist to disk - rollback on failure | |||
| 559 | + | if err := s.Save(ctx); err != nil { | |||
| 560 | + | log.Warn().Err(err).Msg("failed to persist attachment to disk, rolling back") | |||
| 561 | + | s.mu.Lock() | |||
| 562 | + | if hadKey { | |||
| 563 | + | s.config.Attachments[domainOrGroup] = previousImages | |||
| 564 | + | } else { | |||
| 565 | + | delete(s.config.Attachments, domainOrGroup) | |||
| 566 | + | } | |||
| 567 | + | s.mu.Unlock() | |||
| 568 | + | return err | |||
| 569 | + | } | |||
| 570 | + | ||||
| 571 | + | log.Info().Msg("attachment added to configuration") | |||
| 572 | + | return nil | |||
| 573 | + | } | |||
| 574 | + | ||||
| 575 | + | // RemoveAttachment removes an image from a domain/group's attachments. | |||
| 576 | + | func (s *Service) RemoveAttachment(ctx context.Context, domainOrGroup, image string) error { | |||
| 577 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |||
| 578 | + | zerowrap.FieldLayer: "usecase", | |||
| 579 | + | zerowrap.FieldUseCase: "RemoveAttachment", | |||
| 580 | + | "target": domainOrGroup, | |||
| 581 | + | "image": image, | |||
| 582 | + | }) | |||
| 583 | + | log := zerowrap.FromCtx(ctx) | |||
| 584 | + | ||||
| 585 | + | // Validate input | |||
| 586 | + | if domainOrGroup == "" { | |||
| 587 | + | return domain.ErrAttachmentTargetEmpty | |||
| 588 | + | } | |||
| 589 | + | if image == "" { | |||
| 590 | + | return domain.ErrAttachmentImageEmpty | |||
| 591 | + | } | |||
| 592 | + | ||||
| 593 | + | s.mu.Lock() | |||
| 594 | + | ||||
| 595 | + | existing, exists := s.config.Attachments[domainOrGroup] | |||
| 596 | + | if !exists { | |||
| 597 | + | s.mu.Unlock() | |||
| 598 | + | return domain.ErrAttachmentNotFound | |||
| 599 | + | } | |||
| 600 | + | ||||
| 601 | + | // Find and remove the image | |||
| 602 | + | found := false | |||
| 603 | + | newImages := make([]string, 0, len(existing)) | |||
| 604 | + | for _, img := range existing { | |||
| 605 | + | if img == image { | |||
| 606 | + | found = true | |||
| 607 | + | } else { | |||
| 608 | + | newImages = append(newImages, img) | |||
| 609 | + | } | |||
| 610 | + | } | |||
| 611 | + | ||||
| 612 | + | if !found { | |||
| 613 | + | s.mu.Unlock() | |||
| 614 | + | return domain.ErrAttachmentNotFound | |||
| 615 | + | } | |||
| 616 | + | ||||
| 617 | + | // Store previous value for rollback | |||
| 618 | + | previousImages := existing | |||
| 619 | + | ||||
| 620 | + | // Update or remove the key | |||
| 621 | + | if len(newImages) == 0 { | |||
| 622 | + | delete(s.config.Attachments, domainOrGroup) | |||
| 623 | + | } else { | |||
| 624 | + | s.config.Attachments[domainOrGroup] = newImages | |||
| 625 | + | } | |||
| 626 | + | s.mu.Unlock() | |||
| 627 | + | ||||
| 628 | + | // Persist to disk - rollback on failure | |||
| 629 | + | if err := s.Save(ctx); err != nil { | |||
| 630 | + | log.Warn().Err(err).Msg("failed to persist attachment removal to disk, rolling back") | |||
| 631 | + | s.mu.Lock() | |||
| 632 | + | s.config.Attachments[domainOrGroup] = previousImages | |||
| 633 | + | s.mu.Unlock() | |||
| 634 | + | return err | |||
| 635 | + | } | |||
| 636 | + | ||||
| 637 | + | log.Info().Msg("attachment removed from configuration") | |||
| 638 | + | return nil | |||
| 639 | + | } | |||
| 640 | + | ||||
| 489 | // GetExternalRoutes returns all configured external routes. | 641 | // GetExternalRoutes returns all configured external routes. | ||
| 490 | func (s *Service) GetExternalRoutes() map[string]string { | 642 | func (s *Service) GetExternalRoutes() map[string]string { | ||
| 491 | s.mu.RLock() | 643 | s.mu.RLock() | ||
M internal/usecase/config/service_test.go +379 -5
| @@ -28,9 +28,9 @@ func TestService_Load(t *testing.T) { | |||||
| 28 | v.Set("auto_route.enabled", true) | 28 | v.Set("auto_route.enabled", true) | ||
| 29 | v.Set("network_isolation.enabled", true) | 29 | v.Set("network_isolation.enabled", true) | ||
| 30 | v.Set("network_isolation.network_prefix", "gordon") | 30 | v.Set("network_isolation.network_prefix", "gordon") | ||
| 31 | - | v.Set("registry_auth.enabled", true) | 31 | + | v.Set("auth.enabled", true) |
| 32 | - | v.Set("registry_auth.username", "admin") | 32 | + | v.Set("auth.username", "admin") |
| 33 | - | v.Set("registry_auth.password", "secret") | 33 | + | v.Set("auth.password", "secret") |
| 34 | v.Set("volumes.auto_create", true) | 34 | v.Set("volumes.auto_create", true) | ||
| 35 | v.Set("volumes.prefix", "gordon") | 35 | v.Set("volumes.prefix", "gordon") | ||
| 36 | v.Set("volumes.preserve", false) | 36 | v.Set("volumes.preserve", false) | ||
| @@ -332,7 +332,7 @@ func TestService_GetNetworkGroups(t *testing.T) { | |||||
| 332 | func TestService_GetAttachments(t *testing.T) { | 332 | func TestService_GetAttachments(t *testing.T) { | ||
| 333 | v := viper.New() | 333 | v := viper.New() | ||
| 334 | v.Set("attachments", map[string]interface{}{ | 334 | v.Set("attachments", map[string]interface{}{ | ||
| 335 | - | "app.example.com": []interface{}{"redis:latest", "postgres:15"}, | 335 | + | "app.example.com": []interface{}{"redis:latest", "postgres:18"}, |
| 336 | }) | 336 | }) | ||
| 337 | 337 | ||||
| 338 | eventBus := mocks.NewMockEventPublisher(t) | 338 | eventBus := mocks.NewMockEventPublisher(t) | ||
| @@ -344,7 +344,381 @@ func TestService_GetAttachments(t *testing.T) { | |||||
| 344 | attachments := svc.GetAttachments() | 344 | attachments := svc.GetAttachments() | ||
| 345 | 345 | ||||
| 346 | assert.Len(t, attachments, 1) | 346 | assert.Len(t, attachments, 1) | ||
| 347 | - | assert.ElementsMatch(t, []string{"redis:latest", "postgres:15"}, attachments["app.example.com"]) | 347 | + | assert.ElementsMatch(t, []string{"redis:latest", "postgres:18"}, attachments["app.example.com"]) |
| 348 | + | } | |||
| 349 | + | ||||
| 350 | + | func TestService_GetAllAttachments(t *testing.T) { | |||
| 351 | + | t.Run("returns all attachments", func(t *testing.T) { | |||
| 352 | + | v := viper.New() | |||
| 353 | + | v.Set("attachments", map[string]interface{}{ | |||
| 354 | + | "app.example.com": []interface{}{"redis:latest", "postgres:18"}, | |||
| 355 | + | "api.example.com": []interface{}{"rabbitmq:3"}, | |||
| 356 | + | }) | |||
| 357 | + | ||||
| 358 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 359 | + | svc := NewService(v, eventBus) | |||
| 360 | + | ctx := testContext() | |||
| 361 | + | ||||
| 362 | + | _ = svc.Load(ctx) | |||
| 363 | + | ||||
| 364 | + | attachments := svc.GetAllAttachments(ctx) | |||
| 365 | + | ||||
| 366 | + | assert.Len(t, attachments, 2) | |||
| 367 | + | assert.ElementsMatch(t, []string{"redis:latest", "postgres:18"}, attachments["app.example.com"]) | |||
| 368 | + | assert.ElementsMatch(t, []string{"rabbitmq:3"}, attachments["api.example.com"]) | |||
| 369 | + | }) | |||
| 370 | + | ||||
| 371 | + | t.Run("returns empty map when no attachments", func(t *testing.T) { | |||
| 372 | + | v := viper.New() | |||
| 373 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 374 | + | svc := NewService(v, eventBus) | |||
| 375 | + | ctx := testContext() | |||
| 376 | + | ||||
| 377 | + | _ = svc.Load(ctx) | |||
| 378 | + | ||||
| 379 | + | attachments := svc.GetAllAttachments(ctx) | |||
| 380 | + | ||||
| 381 | + | assert.Empty(t, attachments) | |||
| 382 | + | }) | |||
| 383 | + | ||||
| 384 | + | t.Run("returns copy not reference", func(t *testing.T) { | |||
| 385 | + | v := viper.New() | |||
| 386 | + | v.Set("attachments", map[string]interface{}{ | |||
| 387 | + | "app.example.com": []interface{}{"redis:latest"}, | |||
| 388 | + | }) | |||
| 389 | + | ||||
| 390 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 391 | + | svc := NewService(v, eventBus) | |||
| 392 | + | ctx := testContext() | |||
| 393 | + | ||||
| 394 | + | _ = svc.Load(ctx) | |||
| 395 | + | ||||
| 396 | + | attachments := svc.GetAllAttachments(ctx) | |||
| 397 | + | // Modify the returned map | |||
| 398 | + | attachments["app.example.com"] = append(attachments["app.example.com"], "postgres:18") | |||
| 399 | + | ||||
| 400 | + | // Original should be unchanged | |||
| 401 | + | original := svc.GetAllAttachments(ctx) | |||
| 402 | + | assert.Len(t, original["app.example.com"], 1) | |||
| 403 | + | }) | |||
| 404 | + | } | |||
| 405 | + | ||||
| 406 | + | func TestService_GetAttachmentsFor(t *testing.T) { | |||
| 407 | + | t.Run("existing domain", func(t *testing.T) { | |||
| 408 | + | v := viper.New() | |||
| 409 | + | v.Set("attachments", map[string]interface{}{ | |||
| 410 | + | "app.example.com": []interface{}{"redis:latest", "postgres:18"}, | |||
| 411 | + | }) | |||
| 412 | + | ||||
| 413 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 414 | + | svc := NewService(v, eventBus) | |||
| 415 | + | ctx := testContext() | |||
| 416 | + | ||||
| 417 | + | _ = svc.Load(ctx) | |||
| 418 | + | ||||
| 419 | + | images, err := svc.GetAttachmentsFor(ctx, "app.example.com") | |||
| 420 | + | ||||
| 421 | + | require.NoError(t, err) | |||
| 422 | + | assert.ElementsMatch(t, []string{"redis:latest", "postgres:18"}, images) | |||
| 423 | + | }) | |||
| 424 | + | ||||
| 425 | + | t.Run("existing network group", func(t *testing.T) { | |||
| 426 | + | v := viper.New() | |||
| 427 | + | v.Set("attachments", map[string]interface{}{ | |||
| 428 | + | "backend": []interface{}{"rabbitmq:3", "redis:latest"}, | |||
| 429 | + | }) | |||
| 430 | + | ||||
| 431 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 432 | + | svc := NewService(v, eventBus) | |||
| 433 | + | ctx := testContext() | |||
| 434 | + | ||||
| 435 | + | _ = svc.Load(ctx) | |||
| 436 | + | ||||
| 437 | + | images, err := svc.GetAttachmentsFor(ctx, "backend") | |||
| 438 | + | ||||
| 439 | + | require.NoError(t, err) | |||
| 440 | + | assert.ElementsMatch(t, []string{"rabbitmq:3", "redis:latest"}, images) | |||
| 441 | + | }) | |||
| 442 | + | ||||
| 443 | + | t.Run("non-existent target", func(t *testing.T) { | |||
| 444 | + | v := viper.New() | |||
| 445 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 446 | + | svc := NewService(v, eventBus) | |||
| 447 | + | ctx := testContext() | |||
| 448 | + | ||||
| 449 | + | _ = svc.Load(ctx) | |||
| 450 | + | ||||
| 451 | + | images, err := svc.GetAttachmentsFor(ctx, "notfound.example.com") | |||
| 452 | + | ||||
| 453 | + | assert.ErrorIs(t, err, domain.ErrAttachmentNotFound) | |||
| 454 | + | assert.Nil(t, images) | |||
| 455 | + | }) | |||
| 456 | + | ||||
| 457 | + | t.Run("returns copy not reference", func(t *testing.T) { | |||
| 458 | + | v := viper.New() | |||
| 459 | + | v.Set("attachments", map[string]interface{}{ | |||
| 460 | + | "app.example.com": []interface{}{"redis:latest"}, | |||
| 461 | + | }) | |||
| 462 | + | ||||
| 463 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 464 | + | svc := NewService(v, eventBus) | |||
| 465 | + | ctx := testContext() | |||
| 466 | + | ||||
| 467 | + | _ = svc.Load(ctx) | |||
| 468 | + | ||||
| 469 | + | images, err := svc.GetAttachmentsFor(ctx, "app.example.com") | |||
| 470 | + | require.NoError(t, err) | |||
| 471 | + | ||||
| 472 | + | // Modify the returned slice (use _ to satisfy linter) | |||
| 473 | + | _ = append(images, "postgres:18") | |||
| 474 | + | ||||
| 475 | + | // Original should be unchanged | |||
| 476 | + | original, _ := svc.GetAttachmentsFor(ctx, "app.example.com") | |||
| 477 | + | assert.Len(t, original, 1) | |||
| 478 | + | }) | |||
| 479 | + | } | |||
| 480 | + | ||||
| 481 | + | func TestService_AddAttachment(t *testing.T) { | |||
| 482 | + | t.Run("success - new target", func(t *testing.T) { | |||
| 483 | + | tmpDir := t.TempDir() | |||
| 484 | + | configFile := filepath.Join(tmpDir, "gordon.toml") | |||
| 485 | + | err := os.WriteFile(configFile, []byte("[attachments]\n"), 0600) | |||
| 486 | + | require.NoError(t, err) | |||
| 487 | + | ||||
| 488 | + | v := viper.New() | |||
| 489 | + | v.SetConfigFile(configFile) | |||
| 490 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 491 | + | svc := NewService(v, eventBus) | |||
| 492 | + | ctx := testContext() | |||
| 493 | + | ||||
| 494 | + | _ = svc.Load(ctx) | |||
| 495 | + | ||||
| 496 | + | err = svc.AddAttachment(ctx, "app.example.com", "postgres:18") | |||
| 497 | + | ||||
| 498 | + | assert.NoError(t, err) | |||
| 499 | + | ||||
| 500 | + | // Verify attachment was added | |||
| 501 | + | config := svc.GetConfig() | |||
| 502 | + | assert.Contains(t, config.Attachments["app.example.com"], "postgres:18") | |||
| 503 | + | }) | |||
| 504 | + | ||||
| 505 | + | t.Run("success - existing target", func(t *testing.T) { | |||
| 506 | + | tmpDir := t.TempDir() | |||
| 507 | + | configFile := filepath.Join(tmpDir, "gordon.toml") | |||
| 508 | + | err := os.WriteFile(configFile, []byte("[attachments]\n\"app.example.com\" = [\"redis:latest\"]\n"), 0600) | |||
| 509 | + | require.NoError(t, err) | |||
| 510 | + | ||||
| 511 | + | v := viper.New() | |||
| 512 | + | v.SetConfigFile(configFile) | |||
| 513 | + | v.Set("attachments", map[string]interface{}{ | |||
| 514 | + | "app.example.com": []interface{}{"redis:latest"}, | |||
| 515 | + | }) | |||
| 516 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 517 | + | svc := NewService(v, eventBus) | |||
| 518 | + | ctx := testContext() | |||
| 519 | + | ||||
| 520 | + | _ = svc.Load(ctx) | |||
| 521 | + | ||||
| 522 | + | err = svc.AddAttachment(ctx, "app.example.com", "postgres:18") | |||
| 523 | + | ||||
| 524 | + | assert.NoError(t, err) | |||
| 525 | + | ||||
| 526 | + | // Verify attachment was added alongside existing | |||
| 527 | + | config := svc.GetConfig() | |||
| 528 | + | assert.ElementsMatch(t, []string{"redis:latest", "postgres:18"}, config.Attachments["app.example.com"]) | |||
| 529 | + | }) | |||
| 530 | + | ||||
| 531 | + | t.Run("duplicate attachment", func(t *testing.T) { | |||
| 532 | + | v := viper.New() | |||
| 533 | + | v.Set("attachments", map[string]interface{}{ | |||
| 534 | + | "app.example.com": []interface{}{"redis:latest"}, | |||
| 535 | + | }) | |||
| 536 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 537 | + | svc := NewService(v, eventBus) | |||
| 538 | + | ctx := testContext() | |||
| 539 | + | ||||
| 540 | + | _ = svc.Load(ctx) | |||
| 541 | + | ||||
| 542 | + | err := svc.AddAttachment(ctx, "app.example.com", "redis:latest") | |||
| 543 | + | ||||
| 544 | + | assert.ErrorIs(t, err, domain.ErrAttachmentExists) | |||
| 545 | + | }) | |||
| 546 | + | ||||
| 547 | + | t.Run("empty target", func(t *testing.T) { | |||
| 548 | + | v := viper.New() | |||
| 549 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 550 | + | svc := NewService(v, eventBus) | |||
| 551 | + | ctx := testContext() | |||
| 552 | + | ||||
| 553 | + | err := svc.AddAttachment(ctx, "", "postgres:18") | |||
| 554 | + | ||||
| 555 | + | assert.ErrorIs(t, err, domain.ErrAttachmentTargetEmpty) | |||
| 556 | + | }) | |||
| 557 | + | ||||
| 558 | + | t.Run("empty image", func(t *testing.T) { | |||
| 559 | + | v := viper.New() | |||
| 560 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 561 | + | svc := NewService(v, eventBus) | |||
| 562 | + | ctx := testContext() | |||
| 563 | + | ||||
| 564 | + | err := svc.AddAttachment(ctx, "app.example.com", "") | |||
| 565 | + | ||||
| 566 | + | assert.ErrorIs(t, err, domain.ErrAttachmentImageEmpty) | |||
| 567 | + | }) | |||
| 568 | + | ||||
| 569 | + | t.Run("network group target", func(t *testing.T) { | |||
| 570 | + | tmpDir := t.TempDir() | |||
| 571 | + | configFile := filepath.Join(tmpDir, "gordon.toml") | |||
| 572 | + | err := os.WriteFile(configFile, []byte("[attachments]\n"), 0600) | |||
| 573 | + | require.NoError(t, err) | |||
| 574 | + | ||||
| 575 | + | v := viper.New() | |||
| 576 | + | v.SetConfigFile(configFile) | |||
| 577 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 578 | + | svc := NewService(v, eventBus) | |||
| 579 | + | ctx := testContext() | |||
| 580 | + | ||||
| 581 | + | _ = svc.Load(ctx) | |||
| 582 | + | ||||
| 583 | + | err = svc.AddAttachment(ctx, "backend", "rabbitmq:3") | |||
| 584 | + | ||||
| 585 | + | assert.NoError(t, err) | |||
| 586 | + | ||||
| 587 | + | config := svc.GetConfig() | |||
| 588 | + | assert.Contains(t, config.Attachments["backend"], "rabbitmq:3") | |||
| 589 | + | }) | |||
| 590 | + | } | |||
| 591 | + | ||||
| 592 | + | func TestService_RemoveAttachment(t *testing.T) { | |||
| 593 | + | t.Run("success - single attachment", func(t *testing.T) { | |||
| 594 | + | tmpDir := t.TempDir() | |||
| 595 | + | configFile := filepath.Join(tmpDir, "gordon.toml") | |||
| 596 | + | err := os.WriteFile(configFile, []byte("[attachments]\n\"app.example.com\" = [\"redis:latest\"]\n"), 0600) | |||
| 597 | + | require.NoError(t, err) | |||
| 598 | + | ||||
| 599 | + | v := viper.New() | |||
| 600 | + | v.SetConfigFile(configFile) | |||
| 601 | + | v.Set("attachments", map[string]interface{}{ | |||
| 602 | + | "app.example.com": []interface{}{"redis:latest"}, | |||
| 603 | + | }) | |||
| 604 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 605 | + | svc := NewService(v, eventBus) | |||
| 606 | + | ctx := testContext() | |||
| 607 | + | ||||
| 608 | + | _ = svc.Load(ctx) | |||
| 609 | + | ||||
| 610 | + | err = svc.RemoveAttachment(ctx, "app.example.com", "redis:latest") | |||
| 611 | + | ||||
| 612 | + | assert.NoError(t, err) | |||
| 613 | + | ||||
| 614 | + | // Verify target was removed (no attachments left) | |||
| 615 | + | config := svc.GetConfig() | |||
| 616 | + | _, exists := config.Attachments["app.example.com"] | |||
| 617 | + | assert.False(t, exists) | |||
| 618 | + | }) | |||
| 619 | + | ||||
| 620 | + | t.Run("success - multiple attachments", func(t *testing.T) { | |||
| 621 | + | tmpDir := t.TempDir() | |||
| 622 | + | configFile := filepath.Join(tmpDir, "gordon.toml") | |||
| 623 | + | err := os.WriteFile(configFile, []byte("[attachments]\n\"app.example.com\" = [\"redis:latest\", \"postgres:18\"]\n"), 0600) | |||
| 624 | + | require.NoError(t, err) | |||
| 625 | + | ||||
| 626 | + | v := viper.New() | |||
| 627 | + | v.SetConfigFile(configFile) | |||
| 628 | + | v.Set("attachments", map[string]interface{}{ | |||
| 629 | + | "app.example.com": []interface{}{"redis:latest", "postgres:18"}, | |||
| 630 | + | }) | |||
| 631 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 632 | + | svc := NewService(v, eventBus) | |||
| 633 | + | ctx := testContext() | |||
| 634 | + | ||||
| 635 | + | _ = svc.Load(ctx) | |||
| 636 | + | ||||
| 637 | + | err = svc.RemoveAttachment(ctx, "app.example.com", "redis:latest") | |||
| 638 | + | ||||
| 639 | + | assert.NoError(t, err) | |||
| 640 | + | ||||
| 641 | + | // Verify only redis was removed | |||
| 642 | + | config := svc.GetConfig() | |||
| 643 | + | assert.Equal(t, []string{"postgres:18"}, config.Attachments["app.example.com"]) | |||
| 644 | + | }) | |||
| 645 | + | ||||
| 646 | + | t.Run("target not found", func(t *testing.T) { | |||
| 647 | + | v := viper.New() | |||
| 648 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 649 | + | svc := NewService(v, eventBus) | |||
| 650 | + | ctx := testContext() | |||
| 651 | + | ||||
| 652 | + | _ = svc.Load(ctx) | |||
| 653 | + | ||||
| 654 | + | err := svc.RemoveAttachment(ctx, "notfound.example.com", "redis:latest") | |||
| 655 | + | ||||
| 656 | + | assert.ErrorIs(t, err, domain.ErrAttachmentNotFound) | |||
| 657 | + | }) | |||
| 658 | + | ||||
| 659 | + | t.Run("image not found in target", func(t *testing.T) { | |||
| 660 | + | v := viper.New() | |||
| 661 | + | v.Set("attachments", map[string]interface{}{ | |||
| 662 | + | "app.example.com": []interface{}{"redis:latest"}, | |||
| 663 | + | }) | |||
| 664 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 665 | + | svc := NewService(v, eventBus) | |||
| 666 | + | ctx := testContext() | |||
| 667 | + | ||||
| 668 | + | _ = svc.Load(ctx) | |||
| 669 | + | ||||
| 670 | + | err := svc.RemoveAttachment(ctx, "app.example.com", "postgres:18") | |||
| 671 | + | ||||
| 672 | + | assert.ErrorIs(t, err, domain.ErrAttachmentNotFound) | |||
| 673 | + | }) | |||
| 674 | + | ||||
| 675 | + | t.Run("empty target", func(t *testing.T) { | |||
| 676 | + | v := viper.New() | |||
| 677 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 678 | + | svc := NewService(v, eventBus) | |||
| 679 | + | ctx := testContext() | |||
| 680 | + | ||||
| 681 | + | err := svc.RemoveAttachment(ctx, "", "redis:latest") | |||
| 682 | + | ||||
| 683 | + | assert.ErrorIs(t, err, domain.ErrAttachmentTargetEmpty) | |||
| 684 | + | }) | |||
| 685 | + | ||||
| 686 | + | t.Run("empty image", func(t *testing.T) { | |||
| 687 | + | v := viper.New() | |||
| 688 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 689 | + | svc := NewService(v, eventBus) | |||
| 690 | + | ctx := testContext() | |||
| 691 | + | ||||
| 692 | + | err := svc.RemoveAttachment(ctx, "app.example.com", "") | |||
| 693 | + | ||||
| 694 | + | assert.ErrorIs(t, err, domain.ErrAttachmentImageEmpty) | |||
| 695 | + | }) | |||
| 696 | + | ||||
| 697 | + | t.Run("network group target", func(t *testing.T) { | |||
| 698 | + | tmpDir := t.TempDir() | |||
| 699 | + | configFile := filepath.Join(tmpDir, "gordon.toml") | |||
| 700 | + | err := os.WriteFile(configFile, []byte("[attachments]\n\"backend\" = [\"rabbitmq:3\"]\n"), 0600) | |||
| 701 | + | require.NoError(t, err) | |||
| 702 | + | ||||
| 703 | + | v := viper.New() | |||
| 704 | + | v.SetConfigFile(configFile) | |||
| 705 | + | v.Set("attachments", map[string]interface{}{ | |||
| 706 | + | "backend": []interface{}{"rabbitmq:3"}, | |||
| 707 | + | }) | |||
| 708 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 709 | + | svc := NewService(v, eventBus) | |||
| 710 | + | ctx := testContext() | |||
| 711 | + | ||||
| 712 | + | _ = svc.Load(ctx) | |||
| 713 | + | ||||
| 714 | + | err = svc.RemoveAttachment(ctx, "backend", "rabbitmq:3") | |||
| 715 | + | ||||
| 716 | + | assert.NoError(t, err) | |||
| 717 | + | ||||
| 718 | + | config := svc.GetConfig() | |||
| 719 | + | _, exists := config.Attachments["backend"] | |||
| 720 | + | assert.False(t, exists) | |||
| 721 | + | }) | |||
| 348 | } | 722 | } | ||
| 349 | 723 | ||||
| 350 | func TestService_GetExternalRoutes(t *testing.T) { | 724 | func TestService_GetExternalRoutes(t *testing.T) { | ||
M internal/usecase/container/service.go +38 -2
| @@ -937,8 +937,9 @@ func (s *Service) cleanupNetworkIfEmpty(ctx context.Context, networkName string) | |||||
| 937 | } | 937 | } | ||
| 938 | 938 | ||||
| 939 | func (s *Service) deployAttachments(ctx context.Context, domainName, networkName string) error { | 939 | func (s *Service) deployAttachments(ctx context.Context, domainName, networkName string) error { | ||
| 940 | - | attachments, ok := s.config.Attachments[domainName] | 940 | + | // Collect attachments from both domain-specific and network group configs |
| 941 | - | if !ok { | 941 | + | attachments := s.resolveAttachmentsForDomain(domainName) |
| 942 | + | if len(attachments) == 0 { | |||
| 942 | return nil | 943 | return nil | ||
| 943 | } | 944 | } | ||
| 944 | 945 | ||||
| @@ -952,6 +953,41 @@ func (s *Service) deployAttachments(ctx context.Context, domainName, networkName | |||||
| 952 | return nil | 953 | return nil | ||
| 953 | } | 954 | } | ||
| 954 | 955 | ||||
| 956 | + | // resolveAttachmentsForDomain returns attachments for a domain by checking: | |||
| 957 | + | // 1. Direct domain attachments (attachments[domain]) | |||
| 958 | + | // 2. Network group attachments (attachments[group] where domain is in network_groups[group]) | |||
| 959 | + | func (s *Service) resolveAttachmentsForDomain(domainName string) []string { | |||
| 960 | + | seen := make(map[string]bool) | |||
| 961 | + | var result []string | |||
| 962 | + | ||||
| 963 | + | // First, add domain-specific attachments | |||
| 964 | + | if domainAttachments, ok := s.config.Attachments[domainName]; ok { | |||
| 965 | + | for _, img := range domainAttachments { | |||
| 966 | + | if !seen[img] { | |||
| 967 | + | seen[img] = true | |||
| 968 | + | result = append(result, img) | |||
| 969 | + | } | |||
| 970 | + | } | |||
| 971 | + | } | |||
| 972 | + | ||||
| 973 | + | // Then, find which network group this domain belongs to and add group attachments | |||
| 974 | + | for groupName, domains := range s.config.NetworkGroups { | |||
| 975 | + | if slices.Contains(domains, domainName) { | |||
| 976 | + | if groupAttachments, ok := s.config.Attachments[groupName]; ok { | |||
| 977 | + | for _, img := range groupAttachments { | |||
| 978 | + | if !seen[img] { | |||
| 979 | + | seen[img] = true | |||
| 980 | + | result = append(result, img) | |||
| 981 | + | } | |||
| 982 | + | } | |||
| 983 | + | } | |||
| 984 | + | break // Domain can only be in one network group | |||
| 985 | + | } | |||
| 986 | + | } | |||
| 987 | + | ||||
| 988 | + | return result | |||
| 989 | + | } | |||
| 990 | + | ||||
| 955 | func (s *Service) getAttachmentsForDomain(ctx context.Context, domainName string) []domain.Attachment { | 991 | func (s *Service) getAttachmentsForDomain(ctx context.Context, domainName string) []domain.Attachment { | ||
| 956 | containers, err := s.runtime.ListContainers(ctx, true) | 992 | containers, err := s.runtime.ListContainers(ctx, true) | ||
| 957 | if err != nil { | 993 | if err != nil { | ||
M wiki/examples/production.md +5 -8
| @@ -19,15 +19,12 @@ port = 8080 # Cloudflare forwards 443 → 8080 | |||||
| 19 | registry_port = 5000 | 19 | registry_port = 5000 | ||
| 20 | gordon_domain = "gordon.company.com" | 20 | gordon_domain = "gordon.company.com" | ||
| 21 | 21 | ||||
| 22 | - | # Use pass for secrets (recommended) | 22 | + | # Authentication with pass backend (recommended) |
| 23 | - | [secrets] | 23 | + | [auth] |
| 24 | - | backend = "pass" | |||
| 25 | - | ||||
| 26 | - | # Token authentication for CI/CD | |||
| 27 | - | [registry_auth] | |||
| 28 | enabled = true | 24 | enabled = true | ||
| 29 | type = "token" | 25 | type = "token" | ||
| 30 | - | token_secret = "gordon/registry/token_secret" | 26 | + | secrets_backend = "pass" |
| 27 | + | token_secret = "gordon/auth/token_secret" | |||
| 31 | 28 | ||||
| 32 | # File-based logging with rotation | 29 | # File-based logging with rotation | ||
| 33 | [logging] | 30 | [logging] | ||
| @@ -96,7 +93,7 @@ pass init your-gpg-key-id | |||||
| 96 | 93 | ||||
| 97 | ```bash | 94 | ```bash | ||
| 98 | # Generate random secret | 95 | # Generate random secret | ||
| 99 | - | openssl rand -base64 32 | pass insert -m gordon/registry/token_secret | 96 | + | openssl rand -base64 32 | pass insert -m gordon/auth/token_secret |
| 100 | ``` | 97 | ``` | ||
| 101 | 98 | ||||
| 102 | ### 3. Generate CI Token | 99 | ### 3. Generate CI Token | ||
M wiki/guides/remote-cli.md +6 -3
| @@ -206,6 +206,9 @@ These commands work with remote targeting: | |||||
| 206 | | `gordon routes add <domain> <image>` | Add a route | | 206 | | `gordon routes add <domain> <image>` | Add a route | | ||
| 207 | | `gordon routes remove <domain>` | Remove a route | | 207 | | `gordon routes remove <domain>` | Remove a route | | ||
| 208 | | `gordon routes deploy <domain>` | Deploy/redeploy a route | | 208 | | `gordon routes deploy <domain>` | Deploy/redeploy a route | | ||
| 209 | + | | `gordon attachments list [target]` | List all or targeted attachments | | |||
| 210 | + | | `gordon attachments add <target> <image>` | Add an attachment | | |||
| 211 | + | | `gordon attachments remove <target> <image>` | Remove an attachment | | |||
| 209 | | `gordon secrets list <domain>` | List secrets for a domain | | 212 | | `gordon secrets list <domain>` | List secrets for a domain | | ||
| 210 | | `gordon secrets set <domain> KEY=value` | Set secrets | | 213 | | `gordon secrets set <domain> KEY=value` | Set secrets | | ||
| 211 | | `gordon secrets remove <domain> <key>` | Remove a secret | | 214 | | `gordon secrets remove <domain> <key>` | Remove a secret | | ||
| @@ -432,6 +435,6 @@ See [Remote Logs Requirement](#remote-logs-requirement) for details. | |||||
| 432 | 435 | ||||
| 433 | ## Related | 436 | ## Related | ||
| 434 | 437 | ||||
| 435 | - | - [CLI Commands](/docs/cli/commands.md) | 438 | + | - [CLI Commands](/docs/cli/index.md) |
| 436 | - | - [Authentication](/docs/config/registry-auth.md) | 439 | + | - [Authentication](/docs/config/auth.md) |
| 437 | - | - [Admin API](/docs/config/admin.md) | 440 | + | - [Attachments](/docs/config/attachments.md) |
M wiki/guides/secrets-pass.md +8 -10
| @@ -86,10 +86,10 @@ This creates `~/.password-store/`. | |||||
| 86 | ```bash | 86 | ```bash | ||
| 87 | # Registry password hash (bcrypt) | 87 | # Registry password hash (bcrypt) | ||
| 88 | # Generate with: htpasswd -nbB admin yourpassword | cut -d: -f2 | 88 | # Generate with: htpasswd -nbB admin yourpassword | cut -d: -f2 | ||
| 89 | - | pass insert gordon/registry/password_hash | 89 | + | pass insert gordon/auth/password_hash |
| 90 | 90 | ||||
| 91 | # Token secret (random 32+ chars for JWT signing) | 91 | # Token secret (random 32+ chars for JWT signing) | ||
| 92 | - | pass generate gordon/registry/token_secret 32 | 92 | + | pass generate gordon/auth/token_secret 32 |
| 93 | ``` | 93 | ``` | ||
| 94 | 94 | ||||
| 95 | ## Gordon Configuration | 95 | ## Gordon Configuration | ||
| @@ -97,15 +97,13 @@ pass generate gordon/registry/token_secret 32 | |||||
| 97 | ### Enable Pass Backend | 97 | ### Enable Pass Backend | ||
| 98 | 98 | ||||
| 99 | ```toml | 99 | ```toml | ||
| 100 | - | [secrets] | 100 | + | [auth] |
| 101 | - | backend = "pass" | |||
| 102 | - | ||||
| 103 | - | [registry_auth] | |||
| 104 | enabled = true | 101 | enabled = true | ||
| 105 | type = "token" | 102 | type = "token" | ||
| 103 | + | secrets_backend = "pass" | |||
| 106 | username = "admin" | 104 | username = "admin" | ||
| 107 | - | password_hash = "gordon/registry/password_hash" | 105 | + | password_hash = "gordon/auth/password_hash" |
| 108 | - | token_secret = "gordon/registry/token_secret" | 106 | + | token_secret = "gordon/auth/token_secret" |
| 109 | ``` | 107 | ``` | ||
| 110 | 108 | ||||
| 111 | ### Using Secrets in Environment Files | 109 | ### Using Secrets in Environment Files | ||
| @@ -126,7 +124,7 @@ Recommended directory structure in pass: | |||||
| 126 | ``` | 124 | ``` | ||
| 127 | ~/.password-store/ | 125 | ~/.password-store/ | ||
| 128 | ├── gordon/ | 126 | ├── gordon/ | ||
| 129 | - | │ └── registry/ | 127 | + | │ └── auth/ |
| 130 | │ ├── password_hash.gpg | 128 | │ ├── password_hash.gpg | ||
| 131 | │ └── token_secret.gpg | 129 | │ └── token_secret.gpg | ||
| 132 | ├── myapp/ | 130 | ├── myapp/ | ||
| @@ -286,6 +284,6 @@ chmod 700 ~/.password-store | |||||
| 286 | ## Related | 284 | ## Related | ||
| 287 | 285 | ||||
| 288 | - [Secrets Configuration](/docs/config/secrets.md) | 286 | - [Secrets Configuration](/docs/config/secrets.md) | ||
| 289 | - | - [Registry Authentication](/docs/config/registry-auth.md) | 287 | + | - [Authentication](/docs/config/auth.md) |
| 290 | - [Running in Containers](/wiki/guides/running-in-container.md) | 288 | - [Running in Containers](/wiki/guides/running-in-container.md) | ||
| 291 | - [Using SOPS for Secrets](./secrets-sops.md) | 289 | - [Using SOPS for Secrets](./secrets-sops.md) | ||
M wiki/guides/secrets-sops.md +7 -9
| @@ -136,7 +136,7 @@ sops edit secrets.yaml | |||||
| 136 | This opens your editor. Add secrets in YAML format: | 136 | This opens your editor. Add secrets in YAML format: | ||
| 137 | 137 | ||||
| 138 | ```yaml | 138 | ```yaml | ||
| 139 | - | registry: | 139 | + | auth: |
| 140 | password_hash: "$2y$10$..." | 140 | password_hash: "$2y$10$..." | ||
| 141 | token_secret: "your-random-32-char-string-here" | 141 | token_secret: "your-random-32-char-string-here" | ||
| 142 | database: | 142 | database: | ||
| @@ -156,7 +156,7 @@ cat secrets.yaml | |||||
| 156 | Output shows encrypted values: | 156 | Output shows encrypted values: | ||
| 157 | 157 | ||||
| 158 | ```yaml | 158 | ```yaml | ||
| 159 | - | registry: | 159 | + | auth: |
| 160 | password_hash: ENC[AES256_GCM,data:...,iv:...,tag:...] | 160 | password_hash: ENC[AES256_GCM,data:...,iv:...,tag:...] | ||
| 161 | token_secret: ENC[AES256_GCM,data:...,iv:...,tag:...] | 161 | token_secret: ENC[AES256_GCM,data:...,iv:...,tag:...] | ||
| 162 | database: | 162 | database: | ||
| @@ -195,15 +195,13 @@ sops edit secrets.yaml | |||||
| 195 | ### Enable SOPS Backend | 195 | ### Enable SOPS Backend | ||
| 196 | 196 | ||||
| 197 | ```toml | 197 | ```toml | ||
| 198 | - | [secrets] | 198 | + | [auth] |
| 199 | - | backend = "sops" | |||
| 200 | - | ||||
| 201 | - | [registry_auth] | |||
| 202 | enabled = true | 199 | enabled = true | ||
| 203 | type = "token" | 200 | type = "token" | ||
| 201 | + | secrets_backend = "sops" | |||
| 204 | username = "admin" | 202 | username = "admin" | ||
| 205 | - | password_hash = "secrets.yaml:registry.password_hash" | 203 | + | password_hash = "secrets.yaml:auth.password_hash" |
| 206 | - | token_secret = "secrets.yaml:registry.token_secret" | 204 | + | token_secret = "secrets.yaml:auth.token_secret" |
| 207 | ``` | 205 | ``` | ||
| 208 | 206 | ||||
| 209 | The path format is `file:key.path` where: | 207 | The path format is `file:key.path` where: | ||
| @@ -424,6 +422,6 @@ ls -la ~/.config/sops/age/keys.txt | |||||
| 424 | ## Related | 422 | ## Related | ||
| 425 | 423 | ||||
| 426 | - [Secrets Configuration](/docs/config/secrets.md) | 424 | - [Secrets Configuration](/docs/config/secrets.md) | ||
| 427 | - | - [Registry Authentication](/docs/config/registry-auth.md) | 425 | + | - [Authentication](/docs/config/auth.md) |
| 428 | - [Running in Containers](/wiki/guides/running-in-container.md) | 426 | - [Running in Containers](/wiki/guides/running-in-container.md) | ||
| 429 | - [Using Pass for Secrets](./secrets-pass.md) | 427 | - [Using Pass for Secrets](./secrets-pass.md) | ||
M wiki/tutorials/postgres-service.md +1 -1
| @@ -20,7 +20,7 @@ Add a PostgreSQL database as an attachment to your application. | |||||
| 20 | Create `postgres.Dockerfile`: | 20 | Create `postgres.Dockerfile`: | ||
| 21 | 21 | ||||
| 22 | ```dockerfile | 22 | ```dockerfile | ||
| 23 | - | FROM postgres:15-alpine | 23 | + | FROM postgres:18-alpine |
| 24 | 24 | ||||
| 25 | # Persistent data directory | 25 | # Persistent data directory | ||
| 26 | VOLUME ["/var/lib/postgresql/data"] | 26 | VOLUME ["/var/lib/postgresql/data"] | ||