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 | 142 | Gordon automatically creates persistent storage from Dockerfile `VOLUME` directives: | |
| 143 | 143 | ||
| 144 | 144 | ```dockerfile | |
| 145 | - | FROM postgres:15 | |
| 145 | + | FROM postgres:18 | |
| 146 | 146 | VOLUME ["/var/lib/postgresql/data"] | |
| 147 | 147 | ``` | |
| 148 | 148 | ||
M docs/config/attachments.md +69 -3
| @@ -2,6 +2,17 @@ | |||
| 2 | 2 | ||
| 3 | 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 | 16 | ## Configuration | |
| 6 | 17 | ||
| 7 | 18 | ```toml | |
| @@ -65,7 +76,7 @@ Use Dockerfile VOLUME directives for persistent data: | |||
| 65 | 76 | ||
| 66 | 77 | ```dockerfile | |
| 67 | 78 | # postgres.Dockerfile | |
| 68 | - | FROM postgres:15 | |
| 79 | + | FROM postgres:18 | |
| 69 | 80 | VOLUME ["/var/lib/postgresql/data"] | |
| 70 | 81 | ENV POSTGRES_DB=myapp | |
| 71 | 82 | ENV POSTGRES_USER=app | |
| @@ -143,7 +154,7 @@ Multiple apps share the same service instances: | |||
| 143 | 154 | "app.mydomain.com" = "myapp:latest" | |
| 144 | 155 | ||
| 145 | 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 | 160 | ### Microservices with Shared Queue | |
| @@ -169,7 +180,7 @@ Build custom service images with your configuration: | |||
| 169 | 180 | ||
| 170 | 181 | ```dockerfile | |
| 171 | 182 | # my-postgres.Dockerfile | |
| 172 | - | FROM postgres:15 | |
| 183 | + | FROM postgres:18 | |
| 173 | 184 | VOLUME ["/var/lib/postgresql/data"] | |
| 174 | 185 | ENV POSTGRES_DB=production | |
| 175 | 186 | ENV POSTGRES_USER=appuser | |
| @@ -204,8 +215,63 @@ Gordon adds labels to attachment containers: | |||
| 204 | 215 | | `gordon.attachment` | `true` | | |
| 205 | 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 | 272 | ## Related | |
| 208 | 273 | ||
| 209 | 274 | - [Network Isolation](./network-isolation.md) | |
| 210 | 275 | - [Network Groups](./network-groups.md) | |
| 211 | 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 | 27 | ```toml | |
| 8 | - | [registry_auth] | |
| 9 | - | enabled = true | |
| 10 | - | type = "token" # "password" or "token" | |
| 28 | + | [auth] | |
| 29 | + | enabled = false | |
| 30 | + | ``` | |
| 11 | 31 | ||
| 12 | - | # For token auth: | |
| 13 | - | token_secret = "gordon/registry/token_secret" # Path in secrets backend | |
| 14 | - | token_expiry = "720h" # Duration or 0 for never | |
| 32 | + | ## Configuration | |
| 15 | 33 | ||
| 16 | - | # For password auth: | |
| 17 | - | # username = "deploy" | |
| 18 | - | # password_hash = "gordon/registry/password_hash" # Path in secrets backend | |
| 34 | + | ```toml | |
| 35 | + | [auth] | |
| 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 | 43 | ## Options | |
| 22 | 44 | ||
| 23 | 45 | | Option | Type | Default | Description | | |
| 24 | 46 | |--------|------|---------|-------------| | |
| 25 | - | | `enabled` | bool | `false` | Enable registry authentication | | |
| 47 | + | | `enabled` | bool | `true` | Enable authentication | | |
| 26 | 48 | | `type` | string | `"password"` | Auth type: `"password"` or `"token"` | | |
| 49 | + | | `secrets_backend` | string | `"unsafe"` | Secrets backend: `"pass"`, `"sops"`, or `"unsafe"` | | |
| 27 | 50 | | `username` | string | - | Username for password auth | | |
| 28 | 51 | | `password_hash` | string | - | Path to bcrypt hash in secrets backend | | |
| 29 | 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 | 67 | ## Authentication Types | |
| 33 | 68 | ||
| @@ -36,20 +71,18 @@ token_expiry = "720h" # Duration or 0 for never | |||
| 36 | 71 | JWT-based authentication, ideal for CI/CD pipelines: | |
| 37 | 72 | ||
| 38 | 73 | ```toml | |
| 39 | - | [secrets] | |
| 40 | - | backend = "pass" | |
| 41 | - | ||
| 42 | - | [registry_auth] | |
| 74 | + | [auth] | |
| 43 | 75 | enabled = true | |
| 44 | 76 | type = "token" | |
| 45 | - | token_secret = "gordon/registry/token_secret" | |
| 46 | - | token_expiry = "30d" # 30 days, or "0" for never. Also: 1y, 2w, 6M | |
| 77 | + | secrets_backend = "pass" | |
| 78 | + | token_secret = "gordon/auth/token_secret" | |
| 79 | + | token_expiry = "30d" | |
| 47 | 80 | ``` | |
| 48 | 81 | ||
| 49 | 82 | **Setup:** | |
| 50 | 83 | ```bash | |
| 51 | 84 | # Create token secret (random 32+ characters) | |
| 52 | - | pass insert gordon/registry/token_secret | |
| 85 | + | pass insert gordon/auth/token_secret | |
| 53 | 86 | ||
| 54 | 87 | # Generate a token | |
| 55 | 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 | 99 | # List all tokens | |
| 67 | 100 | gordon auth token list | |
| 68 | 101 | ||
| 69 | - | # Revoke a token | |
| 102 | + | # Revoke a specific token | |
| 70 | 103 | gordon auth token revoke <token-id> | |
| 104 | + | ||
| 105 | + | # Revoke all tokens | |
| 106 | + | gordon auth token revoke --all | |
| 71 | 107 | ``` | |
| 72 | 108 | ||
| 73 | 109 | ### Password Authentication | |
| @@ -75,14 +111,12 @@ gordon auth token revoke <token-id> | |||
| 75 | 111 | Simple username/password authentication: | |
| 76 | 112 | ||
| 77 | 113 | ```toml | |
| 78 | - | [secrets] | |
| 79 | - | backend = "pass" | |
| 80 | - | ||
| 81 | - | [registry_auth] | |
| 114 | + | [auth] | |
| 82 | 115 | enabled = true | |
| 83 | 116 | type = "password" | |
| 117 | + | secrets_backend = "pass" | |
| 84 | 118 | username = "deploy" | |
| 85 | - | password_hash = "gordon/registry/password_hash" | |
| 119 | + | password_hash = "gordon/auth/password_hash" | |
| 86 | 120 | ``` | |
| 87 | 121 | ||
| 88 | 122 | **Setup:** | |
| @@ -92,7 +126,7 @@ gordon auth password hash | |||
| 92 | 126 | # Enter password when prompted | |
| 93 | 127 | ||
| 94 | 128 | # Store hash in secrets backend | |
| 95 | - | pass insert gordon/registry/password_hash | |
| 129 | + | pass insert gordon/auth/password_hash | |
| 96 | 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 | 140 | Tokens can have different permission levels: | |
| 107 | 141 | ||
| 142 | + | ### Registry Scopes | |
| 143 | + | ||
| 108 | 144 | | Scope | Permission | | |
| 109 | 145 | |-------|------------| | |
| 110 | 146 | | `push` | Push images to registry | | |
| 111 | 147 | | `pull` | Pull images from registry | | |
| 112 | 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 | 166 | ```bash | |
| 115 | 167 | # Pull-only token (for read-only access) | |
| 116 | 168 | gordon auth token generate --subject reader --scopes pull --expiry 30d | |
| 117 | 169 | ||
| 118 | 170 | # Push-only token (for CI builds) | |
| 119 | 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 | 177 | ## Token Expiry | |
| 123 | 178 | ||
| 124 | 179 | Control how long tokens remain valid. Supports human-friendly units: | |
| 125 | - | - `d` - days (24 hours) | |
| 126 | - | - `w` - weeks (7 days) | |
| 127 | - | - `M` - months (30 days) | |
| 128 | - | - `y` - years (365 days) | |
| 180 | + | ||
| 181 | + | | Unit | Description | | |
| 182 | + | |------|-------------| | |
| 183 | + | | `d` | days (24 hours) | | |
| 184 | + | | `w` | weeks (7 days) | | |
| 185 | + | | `M` | months (30 days) | | |
| 186 | + | | `y` | years (365 days) | | |
| 129 | 187 | ||
| 130 | 188 | Compound durations work too: `1y6M`, `2w3d` | |
| 131 | 189 | ||
| @@ -138,39 +196,40 @@ gordon auth token generate --subject deploy --expiry 1y | |||
| 138 | 196 | ||
| 139 | 197 | # Expires in 30 days | |
| 140 | 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 | 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 | 205 | - Tokens from one Gordon instance won't work on another | |
| 151 | 206 | - Changing the `token_secret` invalidates all existing tokens | |
| 152 | 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 | 218 | ## Examples | |
| 155 | 219 | ||
| 156 | 220 | ### CI/CD Setup | |
| 157 | 221 | ||
| 158 | 222 | ```toml | |
| 159 | - | [secrets] | |
| 160 | - | backend = "pass" | |
| 161 | - | ||
| 162 | - | [registry_auth] | |
| 223 | + | [auth] | |
| 163 | 224 | enabled = true | |
| 164 | 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 | 230 | ```bash | |
| 169 | 231 | # Generate CI token | |
| 170 | 232 | gordon auth token generate --subject github-actions --scopes push,pull --expiry 0 | |
| 171 | - | ||
| 172 | - | # Token output: | |
| 173 | - | # eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... | |
| 174 | 233 | ``` | |
| 175 | 234 | ||
| 176 | 235 | In GitHub Actions: | |
| @@ -179,32 +238,38 @@ In GitHub Actions: | |||
| 179 | 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 | 243 | ```toml | |
| 185 | - | [registry_auth] | |
| 186 | - | enabled = false # Disable for local development | |
| 244 | + | [auth] | |
| 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 | 254 | ```toml | |
| 192 | - | [secrets] | |
| 193 | - | backend = "pass" | |
| 194 | - | ||
| 195 | - | [registry_auth] | |
| 255 | + | [auth] | |
| 196 | 256 | enabled = true | |
| 197 | - | type = "password" | |
| 198 | - | username = "deploy" | |
| 199 | - | password_hash = "gordon/registry/password_hash" | |
| 257 | + | type = "token" | |
| 258 | + | secrets_backend = "sops" | |
| 259 | + | token_secret = "gordon/auth/token_secret" | |
| 200 | 260 | ``` | |
| 201 | 261 | ||
| 202 | 262 | ## Internal Registry Auth | |
| 203 | 263 | ||
| 204 | 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 | 271 | ## Related | |
| 207 | 272 | ||
| 208 | - | - [Secrets Configuration](./secrets.md) | |
| 273 | + | - [Secret Providers](./secrets.md) | |
| 209 | 274 | - [CLI Commands](../cli/commands.md) | |
| 210 | 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 | 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 | 28 | > **Note:** This example shows production-style paths. Default paths use `~/.gordon/` for user installations. | |
| 27 | 29 | ||
| 28 | 30 | ```toml | |
| @@ -33,19 +35,16 @@ registry_port = 5000 # Registry port (default: 5000) | |||
| 33 | 35 | gordon_domain = "gordon.mydomain.com" # Required: Gordon domain (registry + API) | |
| 34 | 36 | # data_dir = "~/.gordon" # Default for user installations | |
| 35 | 37 | ||
| 36 | - | # Secrets backend | |
| 37 | - | [secrets] | |
| 38 | - | backend = "pass" # "pass", "sops", or "unsafe" | |
| 39 | - | ||
| 40 | - | # Registry authentication | |
| 41 | - | [registry_auth] | |
| 42 | - | enabled = true | |
| 38 | + | # Authentication (includes secrets backend) | |
| 39 | + | [auth] | |
| 40 | + | enabled = true # Enable registry authentication (default: true) | |
| 43 | 41 | type = "token" # "password" or "token" | |
| 42 | + | secrets_backend = "pass" # "pass", "sops", or "unsafe" | |
| 44 | 43 | # Password auth: | |
| 45 | 44 | # username = "deploy" | |
| 46 | - | # password_hash = "gordon/registry/password_hash" | |
| 45 | + | # password_hash = "gordon/auth/password_hash" | |
| 47 | 46 | # Token auth: | |
| 48 | - | token_secret = "gordon/registry/token_secret" | |
| 47 | + | token_secret = "gordon/auth/token_secret" | |
| 49 | 48 | token_expiry = "30d" # Duration (1y, 30d, 2w) or 0 for never | |
| 50 | 49 | ||
| 51 | 50 | # Deploy behavior | |
| @@ -111,8 +110,7 @@ enabled = false # Auto-create routes from image names | |||
| 111 | 110 | | Section | Description | Documentation | | |
| 112 | 111 | |---------|-------------|---------------| | |
| 113 | 112 | | `[server]` | Core server settings | [Server](./server.md) | | |
| 114 | - | | `[secrets]` | Secrets backend configuration | [Secrets](./secrets.md) | | |
| 115 | - | | `[registry_auth]` | Registry authentication | [Registry Auth](./registry-auth.md) | | |
| 113 | + | | `[auth]` | Authentication and secrets backend | [Auth](./auth.md) | | |
| 116 | 114 | | `[deploy]` | Deployment behavior | [Deploy](./deploy.md) | | |
| 117 | 115 | | `[logging]` | Logging configuration | [Logging](./logging.md) | | |
| 118 | 116 | | `[env]` | Environment variable settings | [Environment](./env.md) | | |
| @@ -131,10 +129,10 @@ enabled = false # Auto-create routes from image names | |||
| 131 | 129 | | `server.port` | `80` | | |
| 132 | 130 | | `server.registry_port` | `5000` | | |
| 133 | 131 | | `server.data_dir` | `~/.gordon` | | |
| 134 | - | | `secrets.backend` | `"unsafe"` | | |
| 135 | - | | `registry_auth.enabled` | `false` | | |
| 136 | - | | `registry_auth.type` | `"password"` | | |
| 137 | - | | `registry_auth.token_expiry` | `"30d"` | | |
| 132 | + | | `auth.enabled` | `true` | | |
| 133 | + | | `auth.type` | `"password"` | | |
| 134 | + | | `auth.secrets_backend` | `"unsafe"` | | |
| 135 | + | | `auth.token_expiry` | `"30d"` | | |
| 138 | 136 | | `deploy.pull_policy` | `"if-tag-changed"` | | |
| 139 | 137 | | `logging.level` | `"info"` | | |
| 140 | 138 | | `logging.format` | `"console"` | | |
| @@ -162,8 +160,7 @@ The following settings require a restart to take effect: | |||
| 162 | 160 | - `server.port` | |
| 163 | 161 | - `server.registry_port` | |
| 164 | 162 | - `server.data_dir` | |
| 165 | - | - `secrets.backend` | |
| 166 | - | - `registry_auth` settings | |
| 163 | + | - `auth` settings | |
| 167 | 164 | - `deploy.pull_policy` | |
| 168 | 165 | ||
| 169 | 166 | ## Environment Variable Override | |
| @@ -182,4 +179,4 @@ Pattern: `GORDON_SECTION_KEY` (uppercase, underscores instead of dots) | |||
| 182 | 179 | - [Server Configuration](./server.md) | |
| 183 | 180 | - [Routes Configuration](./routes.md) | |
| 184 | 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 | 5 | ## Configuration | |
| 6 | 6 | ||
| 7 | + | The secrets backend is configured within the `[auth]` section: | |
| 8 | + | ||
| 7 | 9 | ```toml | |
| 8 | - | [secrets] | |
| 9 | - | backend = "pass" # "pass", "sops", or "unsafe" | |
| 10 | + | [auth] | |
| 11 | + | secrets_backend = "pass" # "pass", "sops", or "unsafe" | |
| 10 | 12 | ``` | |
| 11 | 13 | ||
| 12 | 14 | ## Options | |
| 13 | 15 | ||
| 14 | 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 | 20 | ## Backends | |
| 19 | 21 | ||
| @@ -22,8 +24,8 @@ backend = "pass" # "pass", "sops", or "unsafe" | |||
| 22 | 24 | Uses the Unix password manager (`pass`) for secure secret storage: | |
| 23 | 25 | ||
| 24 | 26 | ```toml | |
| 25 | - | [secrets] | |
| 26 | - | backend = "pass" | |
| 27 | + | [auth] | |
| 28 | + | secrets_backend = "pass" | |
| 27 | 29 | ``` | |
| 28 | 30 | ||
| 29 | 31 | **Setup:** | |
| @@ -35,15 +37,15 @@ sudo apt install pass | |||
| 35 | 37 | pass init your-gpg-key-id | |
| 36 | 38 | ||
| 37 | 39 | # Store secrets | |
| 38 | - | pass insert gordon/registry/password_hash | |
| 39 | - | pass insert gordon/registry/token_secret | |
| 40 | + | pass insert gordon/auth/password_hash | |
| 41 | + | pass insert gordon/auth/token_secret | |
| 40 | 42 | ``` | |
| 41 | 43 | ||
| 42 | 44 | **Usage in config:** | |
| 43 | 45 | ```toml | |
| 44 | - | [registry_auth] | |
| 45 | - | password_hash = "gordon/registry/password_hash" # Path in pass store | |
| 46 | - | token_secret = "gordon/registry/token_secret" | |
| 46 | + | [auth] | |
| 47 | + | password_hash = "gordon/auth/password_hash" # Path in pass store | |
| 48 | + | token_secret = "gordon/auth/token_secret" | |
| 47 | 49 | ``` | |
| 48 | 50 | ||
| 49 | 51 | **Benefits:** | |
| @@ -57,8 +59,8 @@ token_secret = "gordon/registry/token_secret" | |||
| 57 | 59 | Uses Mozilla SOPS for encrypted file-based secrets: | |
| 58 | 60 | ||
| 59 | 61 | ```toml | |
| 60 | - | [secrets] | |
| 61 | - | backend = "sops" | |
| 62 | + | [auth] | |
| 63 | + | secrets_backend = "sops" | |
| 62 | 64 | ``` | |
| 63 | 65 | ||
| 64 | 66 | **Setup:** | |
| @@ -88,15 +90,15 @@ DB_PASSWORD=${sops:secrets.yaml:database.password} | |||
| 88 | 90 | Stores secrets as plain text files: | |
| 89 | 91 | ||
| 90 | 92 | ```toml | |
| 91 | - | [secrets] | |
| 92 | - | backend = "unsafe" | |
| 93 | + | [auth] | |
| 94 | + | secrets_backend = "unsafe" | |
| 93 | 95 | ``` | |
| 94 | 96 | ||
| 95 | 97 | **Storage location:** | |
| 96 | 98 | ``` | |
| 97 | 99 | {data_dir}/secrets/ | |
| 98 | 100 | ├── gordon/ | |
| 99 | - | │ └── registry/ | |
| 101 | + | │ └── auth/ | |
| 100 | 102 | │ ├── password_hash | |
| 101 | 103 | │ └── token_secret | |
| 102 | 104 | ``` | |
| @@ -104,8 +106,8 @@ backend = "unsafe" | |||
| 104 | 106 | **Usage:** | |
| 105 | 107 | ```bash | |
| 106 | 108 | # Create secret | |
| 107 | - | mkdir -p ~/.gordon/secrets/gordon/registry | |
| 108 | - | echo "your-bcrypt-hash" > ~/.gordon/secrets/gordon/registry/password_hash | |
| 109 | + | mkdir -p ~/.gordon/secrets/gordon/auth | |
| 110 | + | echo "your-bcrypt-hash" > ~/.gordon/secrets/gordon/auth/password_hash | |
| 109 | 111 | ``` | |
| 110 | 112 | ||
| 111 | 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 | 137 | ### Production with Pass | |
| 136 | 138 | ||
| 137 | 139 | ```toml | |
| 138 | - | [secrets] | |
| 139 | - | backend = "pass" | |
| 140 | - | ||
| 141 | - | [registry_auth] | |
| 140 | + | [auth] | |
| 142 | 141 | enabled = true | |
| 143 | 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 | 147 | ```bash | |
| 148 | 148 | # Setup | |
| 149 | - | pass insert gordon/registry/token_secret | |
| 149 | + | pass insert gordon/auth/token_secret | |
| 150 | 150 | # Enter a random 32+ character string | |
| 151 | 151 | ||
| 152 | 152 | # Generate tokens | |
| @@ -156,23 +156,19 @@ gordon auth token generate --subject deploy --expiry 0 | |||
| 156 | 156 | ### Development with Unsafe | |
| 157 | 157 | ||
| 158 | 158 | ```toml | |
| 159 | - | [secrets] | |
| 160 | - | backend = "unsafe" | |
| 161 | - | ||
| 162 | - | [registry_auth] | |
| 159 | + | [auth] | |
| 163 | 160 | enabled = false | |
| 161 | + | secrets_backend = "unsafe" | |
| 164 | 162 | ``` | |
| 165 | 163 | ||
| 166 | 164 | ### Enterprise with SOPS | |
| 167 | 165 | ||
| 168 | 166 | ```toml | |
| 169 | - | [secrets] | |
| 170 | - | backend = "sops" | |
| 171 | - | ||
| 172 | - | [registry_auth] | |
| 167 | + | [auth] | |
| 173 | 168 | enabled = true | |
| 174 | 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 | 174 | Environment file: | |
| @@ -194,6 +190,6 @@ JWT_SECRET=${sops:secrets.yaml:jwt.secret} | |||
| 194 | 190 | ||
| 195 | 191 | ## Related | |
| 196 | 192 | ||
| 197 | - | - [Registry Authentication](./registry-auth.md) | |
| 193 | + | - [Authentication](./auth.md) | |
| 198 | 194 | - [Environment Variables](./env.md) | |
| 199 | 195 | - [Configuration Overview](./index.md) | |
M docs/config/volumes.md +2 -2
| @@ -24,7 +24,7 @@ preserve = true | |||
| 24 | 24 | Gordon automatically creates Docker volumes from Dockerfile `VOLUME` directives: | |
| 25 | 25 | ||
| 26 | 26 | ```dockerfile | |
| 27 | - | FROM postgres:15 | |
| 27 | + | FROM postgres:18 | |
| 28 | 28 | VOLUME ["/var/lib/postgresql/data"] | |
| 29 | 29 | ``` | |
| 30 | 30 | ||
| @@ -76,7 +76,7 @@ With `preserve = false`: | |||
| 76 | 76 | ||
| 77 | 77 | ```dockerfile | |
| 78 | 78 | # my-postgres.Dockerfile | |
| 79 | - | FROM postgres:15 | |
| 79 | + | FROM postgres:18 | |
| 80 | 80 | VOLUME ["/var/lib/postgresql/data"] | |
| 81 | 81 | ENV POSTGRES_DB=myapp | |
| 82 | 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 | 25 | func newAuthCmd() *cobra.Command { | |
| 26 | 26 | cmd := &cobra.Command{ | |
| 27 | 27 | Use: "auth", | |
| 28 | - | Short: "Manage registry authentication", | |
| 29 | - | Long: `Commands for managing registry authentication tokens and passwords.`, | |
| 28 | + | Short: "Manage Gordon server authentication", | |
| 29 | + | Long: `Commands for managing Gordon server authentication tokens and passwords.`, | |
| 30 | 30 | } | |
| 31 | 31 | ||
| 32 | 32 | cmd.AddCommand(newAuthTokenCmd()) | |
| @@ -224,9 +224,9 @@ func newPasswordHashCmd() *cobra.Command { | |||
| 224 | 224 | ||
| 225 | 225 | The hash can be stored in your secrets backend and referenced in the config: | |
| 226 | 226 | ||
| 227 | - | [registry_auth] | |
| 227 | + | [auth] | |
| 228 | 228 | type = "password" | |
| 229 | - | password_hash = "gordon/registry/password_hash"`, | |
| 229 | + | password_hash = "gordon/auth/password_hash"`, | |
| 230 | 230 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 231 | 231 | return runPasswordHash() | |
| 232 | 232 | }, | |
| @@ -410,9 +410,9 @@ func runPasswordHash() error { | |||
| 410 | 410 | fmt.Println(hash) | |
| 411 | 411 | fmt.Println() | |
| 412 | 412 | fmt.Println("Then reference the path in your config:") | |
| 413 | - | fmt.Println(" [registry_auth]") | |
| 413 | + | fmt.Println(" [auth]") | |
| 414 | 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 | 417 | return nil | |
| 418 | 418 | } | |
| @@ -430,7 +430,7 @@ func loadAuthConfig(configPath string) (*cliConfig, error) { | |||
| 430 | 430 | ||
| 431 | 431 | // Set defaults | |
| 432 | 432 | v.SetDefault("server.data_dir", app.DefaultDataDir()) | |
| 433 | - | v.SetDefault("secrets.backend", "unsafe") | |
| 433 | + | v.SetDefault("auth.secrets_backend", "unsafe") | |
| 434 | 434 | ||
| 435 | 435 | app.ConfigureViper(v, configPath) | |
| 436 | 436 | ||
| @@ -446,7 +446,7 @@ func loadAuthConfig(configPath string) (*cliConfig, error) { | |||
| 446 | 446 | } | |
| 447 | 447 | ||
| 448 | 448 | // Determine backend | |
| 449 | - | switch v.GetString("secrets.backend") { | |
| 449 | + | switch v.GetString("auth.secrets_backend") { | |
| 450 | 450 | case "pass": | |
| 451 | 451 | cfg.Backend = domain.SecretsBackendPass | |
| 452 | 452 | case "sops": | |
| @@ -456,7 +456,7 @@ func loadAuthConfig(configPath string) (*cliConfig, error) { | |||
| 456 | 456 | } | |
| 457 | 457 | ||
| 458 | 458 | // Load token secret from secrets backend | |
| 459 | - | tokenSecretPath := v.GetString("registry_auth.token_secret") | |
| 459 | + | tokenSecretPath := v.GetString("auth.token_secret") | |
| 460 | 460 | if tokenSecretPath != "" { | |
| 461 | 461 | // Load actual secret from the configured backend | |
| 462 | 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 | 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 | 536 | // streamLogs handles SSE streaming for log endpoints. | |
| 462 | 537 | func (c *Client) streamLogs(ctx context.Context, path string) (<-chan string, error) { | |
| 463 | 538 | url := c.baseURL + "/admin" + path | |
M internal/adapters/in/cli/root.go +72 -16
| @@ -23,6 +23,13 @@ var ( | |||
| 23 | 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 | 33 | // NewRootCmd creates the root command for Gordon CLI. | |
| 27 | 34 | func NewRootCmd() *cobra.Command { | |
| 28 | 35 | rootCmd := &cobra.Command{ | |
| @@ -34,28 +41,77 @@ a Docker registry with automatic container deployment capabilities. | |||
| 34 | 41 | It listens for image pushes and automatically deploys containers based on | |
| 35 | 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 | |
| 38 | - | GORDON_REMOTE environment variable.`, | |
| 44 | + | Commands are organized by where they run: | |
| 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 | 57 | // Add persistent flags for remote targeting | |
| 42 | 58 | rootCmd.PersistentFlags().StringVar(&remoteFlag, "remote", "", "Remote Gordon URL (e.g., https://gordon.mydomain.com)") | |
| 43 | 59 | rootCmd.PersistentFlags().StringVar(&tokenFlag, "token", "", "Authentication token for remote") | |
| 44 | 60 | ||
| 45 | - | // Server commands | |
| 46 | - | rootCmd.AddCommand(newServeCmd()) | |
| 47 | - | rootCmd.AddCommand(newStartCmd()) // Deprecated alias for serve | |
| 48 | - | rootCmd.AddCommand(newReloadCmd()) | |
| 49 | - | rootCmd.AddCommand(newDeployCmd()) | |
| 50 | - | rootCmd.AddCommand(newLogsCmd()) | |
| 51 | - | rootCmd.AddCommand(newVersionCmd()) | |
| 52 | - | rootCmd.AddCommand(newAuthCmd()) | |
| 53 | - | ||
| 54 | - | // Remote management commands | |
| 55 | - | rootCmd.AddCommand(newRoutesCmd()) | |
| 56 | - | rootCmd.AddCommand(newSecretsCmd()) | |
| 57 | - | rootCmd.AddCommand(newRemotesCmd()) | |
| 58 | - | rootCmd.AddCommand(newStatusCmd()) | |
| 61 | + | // Server-only commands (must run on the Gordon host) | |
| 62 | + | serveCmd := newServeCmd() | |
| 63 | + | serveCmd.GroupID = groupServer | |
| 64 | + | rootCmd.AddCommand(serveCmd) | |
| 65 | + | ||
| 66 | + | startCmd := newStartCmd() // Deprecated alias for serve | |
| 67 | + | startCmd.GroupID = groupServer | |
| 68 | + | rootCmd.AddCommand(startCmd) | |
| 69 | + | ||
| 70 | + | authCmd := newAuthCmd() | |
| 71 | + | authCmd.GroupID = groupServer | |
| 72 | + | rootCmd.AddCommand(authCmd) | |
| 73 | + | ||
| 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 | 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 | 122 | path := strings.TrimPrefix(r.URL.Path, "/admin") | |
| 123 | 123 | ||
| 124 | 124 | // Route to appropriate handler | |
| 125 | - | switch { | |
| 126 | - | case path == "/routes" || strings.HasPrefix(path, "/routes/"): | |
| 127 | - | h.handleRoutes(w, r, path) | |
| 128 | - | case path == "/networks": | |
| 129 | - | h.handleNetworks(w, r) | |
| 130 | - | case path == "/secrets" || strings.HasPrefix(path, "/secrets/"): | |
| 131 | - | h.handleSecrets(w, r, path) | |
| 132 | - | case path == "/deploy" || strings.HasPrefix(path, "/deploy/"): | |
| 133 | - | h.handleDeploy(w, r, path) | |
| 134 | - | case path == "/logs" || strings.HasPrefix(path, "/logs/"): | |
| 135 | - | h.handleLogs(w, r, path) | |
| 136 | - | case path == "/status": | |
| 137 | - | h.handleStatus(w, r) | |
| 138 | - | case path == "/health": | |
| 139 | - | h.handleHealth(w, r) | |
| 140 | - | case path == "/reload": | |
| 141 | - | h.handleReload(w, r) | |
| 142 | - | case path == "/config": | |
| 143 | - | h.handleConfig(w, r) | |
| 144 | - | default: | |
| 145 | - | h.sendError(w, http.StatusNotFound, "route not found") | |
| 125 | + | if handler, ok := h.matchRoute(path); ok { | |
| 126 | + | handler(w, r, path) | |
| 127 | + | return | |
| 128 | + | } | |
| 129 | + | h.sendError(w, http.StatusNotFound, "route not found") | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | // routeHandler is the signature for path-based route handlers. | |
| 133 | + | type routeHandler func(w http.ResponseWriter, r *http.Request, path string) | |
| 134 | + | ||
| 135 | + | // matchRoute returns the handler for a given path, or false if not found. | |
| 136 | + | func (h *Handler) matchRoute(path string) (routeHandler, bool) { | |
| 137 | + | // Exact match routes | |
| 138 | + | exactRoutes := map[string]routeHandler{ | |
| 139 | + | "/networks": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleNetworks(w, r) }, | |
| 140 | + | "/status": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleStatus(w, r) }, | |
| 141 | + | "/health": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleHealth(w, r) }, | |
| 142 | + | "/reload": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleReload(w, r) }, | |
| 143 | + | "/config": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleConfig(w, r) }, | |
| 144 | + | } | |
| 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 | 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 | 90 | Dir string `mapstructure:"dir"` | |
| 91 | 91 | } `mapstructure:"env"` | |
| 92 | 92 | ||
| 93 | - | Secrets struct { | |
| 94 | - | Backend string `mapstructure:"backend"` // "pass", "sops", or "unsafe" | |
| 95 | - | } `mapstructure:"secrets"` | |
| 96 | - | ||
| 97 | - | RegistryAuth struct { | |
| 98 | - | Enabled bool `mapstructure:"enabled"` | |
| 99 | - | Type string `mapstructure:"type"` // "password" or "token" | |
| 100 | - | Username string `mapstructure:"username"` | |
| 101 | - | Password string `mapstructure:"password"` // deprecated: use password_hash | |
| 102 | - | PasswordHash string `mapstructure:"password_hash"` // path in secrets backend | |
| 103 | - | TokenSecret string `mapstructure:"token_secret"` // path in secrets backend | |
| 104 | - | TokenExpiry string `mapstructure:"token_expiry"` // e.g., "720h" | |
| 105 | - | } `mapstructure:"registry_auth"` | |
| 93 | + | Auth struct { | |
| 94 | + | Enabled bool `mapstructure:"enabled"` | |
| 95 | + | Type string `mapstructure:"type"` // "password" or "token" | |
| 96 | + | SecretsBackend string `mapstructure:"secrets_backend"` // "pass", "sops", or "unsafe" | |
| 97 | + | Username string `mapstructure:"username"` | |
| 98 | + | Password string `mapstructure:"password"` // deprecated: use password_hash | |
| 99 | + | PasswordHash string `mapstructure:"password_hash"` // path in secrets backend | |
| 100 | + | TokenSecret string `mapstructure:"token_secret"` // path in secrets backend | |
| 101 | + | TokenExpiry string `mapstructure:"token_expiry"` // e.g., "720h", "30d" | |
| 102 | + | } `mapstructure:"auth"` | |
| 106 | 103 | } | |
| 107 | 104 | ||
| 108 | 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 | 263 | // Generate internal registry credentials for loopback-only pulls | |
| 267 | - | if cfg.RegistryAuth.Enabled { | |
| 264 | + | if cfg.Auth.Enabled { | |
| 268 | 265 | svc.internalRegUser, svc.internalRegPass, err = generateInternalRegistryAuth() | |
| 269 | 266 | if err != nil { | |
| 270 | 267 | return nil, log.WrapErr(err, "failed to generate internal registry credentials") | |
| @@ -517,12 +514,12 @@ func GetInternalCredentials() (*InternalCredentials, error) { | |||
| 517 | 514 | ||
| 518 | 515 | // createAuthService creates the authentication service and token store. | |
| 519 | 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 | 518 | return nil, nil, nil | |
| 522 | 519 | } | |
| 523 | 520 | ||
| 524 | - | authType := resolveAuthType(cfg.RegistryAuth.Type) | |
| 525 | - | backend := resolveSecretsBackend(cfg.Secrets.Backend) | |
| 521 | + | authType := resolveAuthType(cfg.Auth.Type) | |
| 522 | + | backend := resolveSecretsBackend(cfg.Auth.SecretsBackend) | |
| 526 | 523 | dataDir := resolveDataDir(cfg.Server.DataDir) | |
| 527 | 524 | ||
| 528 | 525 | store, err := createTokenStore(authType, backend, dataDir, log) | |
| @@ -586,9 +583,9 @@ func createTokenStore(authType domain.AuthType, backend domain.SecretsBackend, d | |||
| 586 | 583 | ||
| 587 | 584 | func buildAuthConfig(ctx context.Context, cfg Config, authType domain.AuthType, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (auth.Config, error) { | |
| 588 | 585 | authConfig := auth.Config{ | |
| 589 | - | Enabled: cfg.RegistryAuth.Enabled, | |
| 586 | + | Enabled: cfg.Auth.Enabled, | |
| 590 | 587 | AuthType: authType, | |
| 591 | - | Username: cfg.RegistryAuth.Username, | |
| 588 | + | Username: cfg.Auth.Username, | |
| 592 | 589 | } | |
| 593 | 590 | ||
| 594 | 591 | switch authType { | |
| @@ -597,6 +594,9 @@ func buildAuthConfig(ctx context.Context, cfg Config, authType domain.AuthType, | |||
| 597 | 594 | if err != nil { | |
| 598 | 595 | return auth.Config{}, err | |
| 599 | 596 | } | |
| 597 | + | if hash == "" { | |
| 598 | + | return auth.Config{}, errAuthNotConfigured() | |
| 599 | + | } | |
| 600 | 600 | authConfig.PasswordHash = hash | |
| 601 | 601 | case domain.AuthTypeToken: | |
| 602 | 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 | 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 | 618 | func loadPasswordHash(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (string, error) { | |
| 614 | - | if cfg.RegistryAuth.PasswordHash != "" { | |
| 615 | - | hash, err := loadSecret(ctx, backend, cfg.RegistryAuth.PasswordHash, dataDir, log) | |
| 619 | + | if cfg.Auth.PasswordHash != "" { | |
| 620 | + | hash, err := loadSecret(ctx, backend, cfg.Auth.PasswordHash, dataDir, log) | |
| 616 | 621 | if err != nil { | |
| 617 | 622 | return "", log.WrapErr(err, "failed to load password hash") | |
| 618 | 623 | } | |
| 619 | 624 | return hash, nil | |
| 620 | 625 | } | |
| 621 | 626 | ||
| 622 | - | if cfg.RegistryAuth.Password != "" { | |
| 627 | + | if cfg.Auth.Password != "" { | |
| 623 | 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 | 632 | return "", nil | |
| @@ -633,7 +638,7 @@ func loadTokenConfig(ctx context.Context, cfg Config, backend domain.SecretsBack | |||
| 633 | 638 | return nil, 0, err | |
| 634 | 639 | } | |
| 635 | 640 | ||
| 636 | - | expiry, err := parseTokenExpiry(cfg.RegistryAuth.TokenExpiry) | |
| 641 | + | expiry, err := parseTokenExpiry(cfg.Auth.TokenExpiry) | |
| 637 | 642 | if err != nil { | |
| 638 | 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 | 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 | 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 | 655 | if err != nil { | |
| 651 | 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 | 697 | // createContainerService creates the container service with configuration. | |
| 693 | 698 | func createContainerService(v *viper.Viper, cfg Config, svc *services) *container.Service { | |
| 694 | 699 | containerConfig := container.Config{ | |
| 695 | - | RegistryAuthEnabled: cfg.RegistryAuth.Enabled, | |
| 700 | + | RegistryAuthEnabled: cfg.Auth.Enabled, | |
| 696 | 701 | RegistryDomain: cfg.Server.RegistryDomain, | |
| 697 | 702 | RegistryPort: cfg.Server.RegistryPort, | |
| 698 | - | RegistryUsername: cfg.RegistryAuth.Username, | |
| 699 | - | RegistryPassword: cfg.RegistryAuth.Password, | |
| 703 | + | RegistryUsername: cfg.Auth.Username, | |
| 704 | + | RegistryPassword: cfg.Auth.Password, | |
| 700 | 705 | InternalRegistryUsername: svc.internalRegUser, | |
| 701 | 706 | InternalRegistryPassword: svc.internalRegPass, | |
| 702 | 707 | PullPolicy: v.GetString("deploy.pull_policy"), | |
| @@ -803,7 +808,7 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||
| 803 | 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 | 812 | internalAuth := middleware.InternalRegistryAuth{ | |
| 808 | 813 | Username: svc.internalRegUser, | |
| 809 | 814 | Password: svc.internalRegPass, | |
| @@ -1143,10 +1148,10 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||
| 1143 | 1148 | v.SetDefault("logging.container_logs.max_backups", 3) | |
| 1144 | 1149 | v.SetDefault("logging.container_logs.max_age", 28) | |
| 1145 | 1150 | v.SetDefault("env.dir", "") // defaults to {data_dir}/env when empty | |
| 1146 | - | v.SetDefault("secrets.backend", "unsafe") | |
| 1147 | - | v.SetDefault("registry_auth.enabled", false) | |
| 1148 | - | v.SetDefault("registry_auth.type", "password") | |
| 1149 | - | v.SetDefault("registry_auth.token_expiry", "720h") | |
| 1151 | + | v.SetDefault("auth.enabled", true) | |
| 1152 | + | v.SetDefault("auth.type", "password") | |
| 1153 | + | v.SetDefault("auth.secrets_backend", "unsafe") | |
| 1154 | + | v.SetDefault("auth.token_expiry", "720h") | |
| 1150 | 1155 | v.SetDefault("auto_route.enabled", false) | |
| 1151 | 1156 | v.SetDefault("network_isolation.enabled", false) | |
| 1152 | 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 | 57 | // GetExternalRoutes returns all configured external routes. | |
| 58 | 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 | 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 | 104 | // AddRoute provides a mock function for the type MockConfigService | |
| 42 | 105 | func (_mock *MockConfigService) AddRoute(ctx context.Context, route domain.Route) error { | |
| 43 | 106 | ret := _mock.Called(ctx, route) | |
| @@ -95,6 +158,127 @@ func (_c *MockConfigService_AddRoute_Call) RunAndReturn(run func(ctx context.Con | |||
| 95 | 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 | 282 | // GetDataDir provides a mock function for the type MockConfigService | |
| 99 | 283 | func (_mock *MockConfigService) GetDataDir() string { | |
| 100 | 284 | ret := _mock.Called() | |
| @@ -621,6 +805,69 @@ func (_c *MockConfigService_Load_Call) RunAndReturn(run func(ctx context.Context | |||
| 621 | 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 | 871 | // RemoveRoute provides a mock function for the type MockConfigService | |
| 625 | 872 | func (_mock *MockConfigService) RemoveRoute(ctx context.Context, domain1 string) error { | |
| 626 | 873 | ret := _mock.Called(ctx, domain1) | |
M internal/domain/errors.go +6 -0
| @@ -62,4 +62,10 @@ var ( | |||
| 62 | 62 | ErrInvalidPath = errors.New("invalid path format") | |
| 63 | 63 | ErrInsufficientScope = errors.New("insufficient scope for operation") | |
| 64 | 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 | 44 | registry_domain = "registry.example.com" | |
| 45 | 45 | runtime = "docker" | |
| 46 | 46 | ||
| 47 | - | [registry_auth] | |
| 47 | + | [auth] | |
| 48 | 48 | enabled = true | |
| 49 | + | secrets_backend = "unsafe" | |
| 49 | 50 | username = "admin" | |
| 50 | 51 | password = "password123" | |
| 51 | 52 | ||
| @@ -72,7 +73,7 @@ level = "info"` | |||
| 72 | 73 | registry_domain = "registry.example.com" | |
| 73 | 74 | runtime = "docker" | |
| 74 | 75 | ||
| 75 | - | [registry_auth] | |
| 76 | + | [auth] | |
| 76 | 77 | enabled = false` | |
| 77 | 78 | case "invalid.toml": | |
| 78 | 79 | return `[server | |
M internal/usecase/config/service.go +156 -4
| @@ -101,9 +101,9 @@ func (s *Service) loadConfigValues() Config { | |||
| 101 | 101 | AutoRouteEnabled: s.viper.GetBool("auto_route.enabled"), | |
| 102 | 102 | NetworkIsolation: s.viper.GetBool("network_isolation.enabled"), | |
| 103 | 103 | NetworkPrefix: s.viper.GetString("network_isolation.network_prefix"), | |
| 104 | - | RegistryAuthEnabled: s.viper.GetBool("registry_auth.enabled"), | |
| 105 | - | RegistryAuthUsername: s.viper.GetString("registry_auth.username"), | |
| 106 | - | RegistryAuthPassword: s.viper.GetString("registry_auth.password"), | |
| 104 | + | RegistryAuthEnabled: s.viper.GetBool("auth.enabled"), | |
| 105 | + | RegistryAuthUsername: s.viper.GetString("auth.username"), | |
| 106 | + | RegistryAuthPassword: s.viper.GetString("auth.password"), | |
| 107 | 107 | VolumeAutoCreate: s.viper.GetBool("volumes.auto_create"), | |
| 108 | 108 | VolumePrefix: s.viper.GetString("volumes.prefix"), | |
| 109 | 109 | VolumePreserve: s.viper.GetBool("volumes.preserve"), | |
| @@ -333,8 +333,9 @@ func (s *Service) Save(ctx context.Context) error { | |||
| 333 | 333 | s.mu.Lock() | |
| 334 | 334 | defer s.mu.Unlock() | |
| 335 | 335 | ||
| 336 | - | // Update viper with current routes | |
| 336 | + | // Update viper with current config | |
| 337 | 337 | s.viper.Set("routes", s.config.Routes) | |
| 338 | + | s.viper.Set("attachments", s.config.Attachments) | |
| 338 | 339 | ||
| 339 | 340 | // Record save time to debounce file watcher events | |
| 340 | 341 | atomic.StoreInt64(&s.lastSaveTime, time.Now().UnixNano()) | |
| @@ -475,6 +476,7 @@ func (s *Service) GetNetworkGroups() map[string][]string { | |||
| 475 | 476 | } | |
| 476 | 477 | ||
| 477 | 478 | // GetAttachments returns attachment configuration. | |
| 479 | + | // Deprecated: Use GetAllAttachments instead. | |
| 478 | 480 | func (s *Service) GetAttachments() map[string][]string { | |
| 479 | 481 | s.mu.RLock() | |
| 480 | 482 | defer s.mu.RUnlock() | |
| @@ -486,6 +488,156 @@ func (s *Service) GetAttachments() map[string][]string { | |||
| 486 | 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 | 641 | // GetExternalRoutes returns all configured external routes. | |
| 490 | 642 | func (s *Service) GetExternalRoutes() map[string]string { | |
| 491 | 643 | s.mu.RLock() | |
M internal/usecase/config/service_test.go +379 -5
| @@ -28,9 +28,9 @@ func TestService_Load(t *testing.T) { | |||
| 28 | 28 | v.Set("auto_route.enabled", true) | |
| 29 | 29 | v.Set("network_isolation.enabled", true) | |
| 30 | 30 | v.Set("network_isolation.network_prefix", "gordon") | |
| 31 | - | v.Set("registry_auth.enabled", true) | |
| 32 | - | v.Set("registry_auth.username", "admin") | |
| 33 | - | v.Set("registry_auth.password", "secret") | |
| 31 | + | v.Set("auth.enabled", true) | |
| 32 | + | v.Set("auth.username", "admin") | |
| 33 | + | v.Set("auth.password", "secret") | |
| 34 | 34 | v.Set("volumes.auto_create", true) | |
| 35 | 35 | v.Set("volumes.prefix", "gordon") | |
| 36 | 36 | v.Set("volumes.preserve", false) | |
| @@ -332,7 +332,7 @@ func TestService_GetNetworkGroups(t *testing.T) { | |||
| 332 | 332 | func TestService_GetAttachments(t *testing.T) { | |
| 333 | 333 | v := viper.New() | |
| 334 | 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 | 338 | eventBus := mocks.NewMockEventPublisher(t) | |
| @@ -344,7 +344,381 @@ func TestService_GetAttachments(t *testing.T) { | |||
| 344 | 344 | attachments := svc.GetAttachments() | |
| 345 | 345 | ||
| 346 | 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 | 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 | 939 | func (s *Service) deployAttachments(ctx context.Context, domainName, networkName string) error { | |
| 940 | - | attachments, ok := s.config.Attachments[domainName] | |
| 941 | - | if !ok { | |
| 940 | + | // Collect attachments from both domain-specific and network group configs | |
| 941 | + | attachments := s.resolveAttachmentsForDomain(domainName) | |
| 942 | + | if len(attachments) == 0 { | |
| 942 | 943 | return nil | |
| 943 | 944 | } | |
| 944 | 945 | ||
| @@ -952,6 +953,41 @@ func (s *Service) deployAttachments(ctx context.Context, domainName, networkName | |||
| 952 | 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 | 991 | func (s *Service) getAttachmentsForDomain(ctx context.Context, domainName string) []domain.Attachment { | |
| 956 | 992 | containers, err := s.runtime.ListContainers(ctx, true) | |
| 957 | 993 | if err != nil { | |
M wiki/examples/production.md +5 -8
| @@ -19,15 +19,12 @@ port = 8080 # Cloudflare forwards 443 → 8080 | |||
| 19 | 19 | registry_port = 5000 | |
| 20 | 20 | gordon_domain = "gordon.company.com" | |
| 21 | 21 | ||
| 22 | - | # Use pass for secrets (recommended) | |
| 23 | - | [secrets] | |
| 24 | - | backend = "pass" | |
| 25 | - | ||
| 26 | - | # Token authentication for CI/CD | |
| 27 | - | [registry_auth] | |
| 22 | + | # Authentication with pass backend (recommended) | |
| 23 | + | [auth] | |
| 28 | 24 | enabled = true | |
| 29 | 25 | type = "token" | |
| 30 | - | token_secret = "gordon/registry/token_secret" | |
| 26 | + | secrets_backend = "pass" | |
| 27 | + | token_secret = "gordon/auth/token_secret" | |
| 31 | 28 | ||
| 32 | 29 | # File-based logging with rotation | |
| 33 | 30 | [logging] | |
| @@ -96,7 +93,7 @@ pass init your-gpg-key-id | |||
| 96 | 93 | ||
| 97 | 94 | ```bash | |
| 98 | 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 | 99 | ### 3. Generate CI Token | |
M wiki/guides/remote-cli.md +6 -3
| @@ -206,6 +206,9 @@ These commands work with remote targeting: | |||
| 206 | 206 | | `gordon routes add <domain> <image>` | Add a route | | |
| 207 | 207 | | `gordon routes remove <domain>` | Remove a route | | |
| 208 | 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 | 212 | | `gordon secrets list <domain>` | List secrets for a domain | | |
| 210 | 213 | | `gordon secrets set <domain> KEY=value` | Set secrets | | |
| 211 | 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 | 436 | ## Related | |
| 434 | 437 | ||
| 435 | - | - [CLI Commands](/docs/cli/commands.md) | |
| 436 | - | - [Authentication](/docs/config/registry-auth.md) | |
| 437 | - | - [Admin API](/docs/config/admin.md) | |
| 438 | + | - [CLI Commands](/docs/cli/index.md) | |
| 439 | + | - [Authentication](/docs/config/auth.md) | |
| 440 | + | - [Attachments](/docs/config/attachments.md) | |
M wiki/guides/secrets-pass.md +8 -10
| @@ -86,10 +86,10 @@ This creates `~/.password-store/`. | |||
| 86 | 86 | ```bash | |
| 87 | 87 | # Registry password hash (bcrypt) | |
| 88 | 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 | 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 | 95 | ## Gordon Configuration | |
| @@ -97,15 +97,13 @@ pass generate gordon/registry/token_secret 32 | |||
| 97 | 97 | ### Enable Pass Backend | |
| 98 | 98 | ||
| 99 | 99 | ```toml | |
| 100 | - | [secrets] | |
| 101 | - | backend = "pass" | |
| 102 | - | ||
| 103 | - | [registry_auth] | |
| 100 | + | [auth] | |
| 104 | 101 | enabled = true | |
| 105 | 102 | type = "token" | |
| 103 | + | secrets_backend = "pass" | |
| 106 | 104 | username = "admin" | |
| 107 | - | password_hash = "gordon/registry/password_hash" | |
| 108 | - | token_secret = "gordon/registry/token_secret" | |
| 105 | + | password_hash = "gordon/auth/password_hash" | |
| 106 | + | token_secret = "gordon/auth/token_secret" | |
| 109 | 107 | ``` | |
| 110 | 108 | ||
| 111 | 109 | ### Using Secrets in Environment Files | |
| @@ -126,7 +124,7 @@ Recommended directory structure in pass: | |||
| 126 | 124 | ``` | |
| 127 | 125 | ~/.password-store/ | |
| 128 | 126 | ├── gordon/ | |
| 129 | - | │ └── registry/ | |
| 127 | + | │ └── auth/ | |
| 130 | 128 | │ ├── password_hash.gpg | |
| 131 | 129 | │ └── token_secret.gpg | |
| 132 | 130 | ├── myapp/ | |
| @@ -286,6 +284,6 @@ chmod 700 ~/.password-store | |||
| 286 | 284 | ## Related | |
| 287 | 285 | ||
| 288 | 286 | - [Secrets Configuration](/docs/config/secrets.md) | |
| 289 | - | - [Registry Authentication](/docs/config/registry-auth.md) | |
| 287 | + | - [Authentication](/docs/config/auth.md) | |
| 290 | 288 | - [Running in Containers](/wiki/guides/running-in-container.md) | |
| 291 | 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 | 136 | This opens your editor. Add secrets in YAML format: | |
| 137 | 137 | ||
| 138 | 138 | ```yaml | |
| 139 | - | registry: | |
| 139 | + | auth: | |
| 140 | 140 | password_hash: "$2y$10$..." | |
| 141 | 141 | token_secret: "your-random-32-char-string-here" | |
| 142 | 142 | database: | |
| @@ -156,7 +156,7 @@ cat secrets.yaml | |||
| 156 | 156 | Output shows encrypted values: | |
| 157 | 157 | ||
| 158 | 158 | ```yaml | |
| 159 | - | registry: | |
| 159 | + | auth: | |
| 160 | 160 | password_hash: ENC[AES256_GCM,data:...,iv:...,tag:...] | |
| 161 | 161 | token_secret: ENC[AES256_GCM,data:...,iv:...,tag:...] | |
| 162 | 162 | database: | |
| @@ -195,15 +195,13 @@ sops edit secrets.yaml | |||
| 195 | 195 | ### Enable SOPS Backend | |
| 196 | 196 | ||
| 197 | 197 | ```toml | |
| 198 | - | [secrets] | |
| 199 | - | backend = "sops" | |
| 200 | - | ||
| 201 | - | [registry_auth] | |
| 198 | + | [auth] | |
| 202 | 199 | enabled = true | |
| 203 | 200 | type = "token" | |
| 201 | + | secrets_backend = "sops" | |
| 204 | 202 | username = "admin" | |
| 205 | - | password_hash = "secrets.yaml:registry.password_hash" | |
| 206 | - | token_secret = "secrets.yaml:registry.token_secret" | |
| 203 | + | password_hash = "secrets.yaml:auth.password_hash" | |
| 204 | + | token_secret = "secrets.yaml:auth.token_secret" | |
| 207 | 205 | ``` | |
| 208 | 206 | ||
| 209 | 207 | The path format is `file:key.path` where: | |
| @@ -424,6 +422,6 @@ ls -la ~/.config/sops/age/keys.txt | |||
| 424 | 422 | ## Related | |
| 425 | 423 | ||
| 426 | 424 | - [Secrets Configuration](/docs/config/secrets.md) | |
| 427 | - | - [Registry Authentication](/docs/config/registry-auth.md) | |
| 425 | + | - [Authentication](/docs/config/auth.md) | |
| 428 | 426 | - [Running in Containers](/wiki/guides/running-in-container.md) | |
| 429 | 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 | 20 | Create `postgres.Dockerfile`: | |
| 21 | 21 | ||
| 22 | 22 | ```dockerfile | |
| 23 | - | FROM postgres:15-alpine | |
| 23 | + | FROM postgres:18-alpine | |
| 24 | 24 | ||
| 25 | 25 | # Persistent data directory | |
| 26 | 26 | VOLUME ["/var/lib/postgresql/data"] | |