Merge pull request #22 from bnema/feature/deployment-experience-improvements
feat: deployment experience improvements
73 files · +9704 -266
M .mockery.yaml +4 -0
| @@ -20,6 +20,7 @@ packages: | |||||
| 20 | EnvLoader: | 20 | EnvLoader: | ||
| 21 | ContainerLogWriter: | 21 | ContainerLogWriter: | ||
| 22 | TokenStore: | 22 | TokenStore: | ||
| 23 | + | DomainSecretStore: | |||
| 23 | gordon/internal/boundaries/in: | 24 | gordon/internal/boundaries/in: | ||
| 24 | interfaces: | 25 | interfaces: | ||
| 25 | ContainerService: | 26 | ContainerService: | ||
| @@ -27,3 +28,6 @@ packages: | |||||
| 27 | ProxyService: | 28 | ProxyService: | ||
| 28 | ConfigService: | 29 | ConfigService: | ||
| 29 | AuthService: | 30 | AuthService: | ||
| 31 | + | HealthService: | |||
| 32 | + | HTTPProber: | |||
| 33 | + | SecretService: | |||
M Dockerfile +1 -1
| @@ -63,7 +63,7 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ | |||||
| 63 | CMD wget --quiet --tries=1 --spider http://localhost:8080/health || exit 1 | 63 | CMD wget --quiet --tries=1 --spider http://localhost:8080/health || exit 1 | ||
| 64 | 64 | ||||
| 65 | # Default command | 65 | # Default command | ||
| 66 | - | CMD ["./gordon", "start"] | 66 | + | CMD ["./gordon", "serve"] |
| 67 | 67 | ||||
| 68 | # Metadata | 68 | # Metadata | ||
| 69 | LABEL maintainer="bnemam" | 69 | LABEL maintainer="bnemam" | ||
M README.md +3 -1
| @@ -27,6 +27,8 @@ Build on your machine, push to deploy. Works from your laptop or CI. | |||||
| 27 | - Private Docker registry on your VPS | 27 | - Private Docker registry on your VPS | ||
| 28 | - Domain-to-container routing via HTTP reverse proxy | 28 | - Domain-to-container routing via HTTP reverse proxy | ||
| 29 | - Automatic deployment on image push | 29 | - Automatic deployment on image push | ||
| 30 | + | - Auto-routing from image labels | |||
| 31 | + | - Remote CLI management | |||
| 30 | - Zero downtime updates | 32 | - Zero downtime updates | ||
| 31 | - Persistent volumes from Dockerfile VOLUME directives | 33 | - Persistent volumes from Dockerfile VOLUME directives | ||
| 32 | - Environment variable management with secrets support | 34 | - Environment variable management with secrets support | ||
| @@ -48,7 +50,7 @@ curl -fsSL https://gordon.bnema.dev/install.sh | sh | |||||
| 48 | # Or: curl -fsSL https://raw.githubusercontent.com/bnema/gordon/main/install.sh | sh | 50 | # Or: curl -fsSL https://raw.githubusercontent.com/bnema/gordon/main/install.sh | sh | ||
| 49 | 51 | ||||
| 50 | # Start Gordon (generates config on first run) | 52 | # Start Gordon (generates config on first run) | ||
| 51 | - | gordon start | 53 | + | gordon serve |
| 52 | ``` | 54 | ``` | ||
| 53 | 55 | ||||
| 54 | Config is created at `~/.config/gordon/gordon.toml`. See the [Getting Started guide](https://gordon.bnema.dev/docs/getting-started) for complete setup instructions. | 56 | Config is created at `~/.config/gordon/gordon.toml`. See the [Getting Started guide](https://gordon.bnema.dev/docs/getting-started) for complete setup instructions. | ||
M docs/cli/commands.md +148 -14
| @@ -6,19 +6,22 @@ Gordon provides a command-line interface for server management, deployment, and | |||||
| 6 | 6 | ||||
| 7 | | Command | Description | | 7 | | Command | Description | | ||
| 8 | |---------|-------------| | 8 | |---------|-------------| | ||
| 9 | - | | `gordon start` | Start the Gordon server | | 9 | + | | `gordon serve` | Start the Gordon server | |
| 10 | | `gordon reload` | Reload configuration and sync containers | | 10 | | `gordon reload` | Reload configuration and sync containers | | ||
| 11 | | `gordon deploy` | Manually deploy or redeploy a specific route | | 11 | | `gordon deploy` | Manually deploy or redeploy a specific route | | ||
| 12 | | `gordon logs` | Display Gordon process logs | | 12 | | `gordon logs` | Display Gordon process logs | | ||
| 13 | | `gordon version` | Print version information | | 13 | | `gordon version` | Print version information | | ||
| 14 | | `gordon auth` | Manage registry authentication | | 14 | | `gordon auth` | Manage registry authentication | | ||
| 15 | + | | `gordon routes` | Manage routes (local or remote) | | |||
| 16 | + | | `gordon secrets` | Manage secrets (remote only) | | |||
| 17 | + | | `gordon remotes` | Manage saved remote Gordon instances | | |||
| 15 | 18 | ||||
| 16 | ## Quick Reference | 19 | ## Quick Reference | ||
| 17 | 20 | ||||
| 18 | ```bash | 21 | ```bash | ||
| 19 | # Start Gordon | 22 | # Start Gordon | ||
| 20 | - | gordon start | 23 | + | gordon serve |
| 21 | - | gordon start --config /path/to/config.toml | 24 | + | gordon serve --config /path/to/config.toml |
| 22 | 25 | ||||
| 23 | # Reload configuration | 26 | # Reload configuration | ||
| 24 | gordon reload | 27 | gordon reload | ||
| @@ -47,17 +50,35 @@ gordon auth internal | |||||
| 47 | | Option | Description | | 50 | | Option | Description | | ||
| 48 | |--------|-------------| | 51 | |--------|-------------| | ||
| 49 | | `-c, --config` | Path to configuration file | | 52 | | `-c, --config` | Path to configuration file | | ||
| 53 | + | | `--remote` | Remote Gordon URL (e.g., `https://gordon.mydomain.com`) | | |||
| 54 | + | | `--token` | Authentication token for remote | | |||
| 55 | + | ||||
| 56 | + | ### Remote Targeting | |||
| 57 | + | ||||
| 58 | + | The CLI can target remote Gordon instances using the `--remote` flag or `GORDON_REMOTE` environment variable. | |||
| 59 | + | ||||
| 60 | + | **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. | |||
| 61 | + | ||||
| 62 | + | ```bash | |||
| 63 | + | # Using flags (use the gordon_domain from remote Gordon config) | |||
| 64 | + | gordon routes list --remote https://gordon.mydomain.com --token $TOKEN | |||
| 65 | + | ||||
| 66 | + | # Using environment variables | |||
| 67 | + | export GORDON_REMOTE=https://gordon.mydomain.com | |||
| 68 | + | export GORDON_TOKEN=$TOKEN | |||
| 69 | + | gordon routes list | |||
| 70 | + | ``` | |||
| 50 | 71 | ||||
| 51 | --- | 72 | --- | ||
| 52 | 73 | ||||
| 53 | - | ## gordon start | 74 | + | ## gordon serve |
| 54 | 75 | ||||
| 55 | Start the Gordon server with registry and proxy components. | 76 | Start the Gordon server with registry and proxy components. | ||
| 56 | 77 | ||||
| 57 | ### Synopsis | 78 | ### Synopsis | ||
| 58 | 79 | ||||
| 59 | ```bash | 80 | ```bash | ||
| 60 | - | gordon start [options] | 81 | + | gordon serve [options] |
| 61 | ``` | 82 | ``` | ||
| 62 | 83 | ||||
| 63 | ### Options | 84 | ### Options | ||
| @@ -90,7 +111,7 @@ On first run without a config file, Gordon creates a default configuration at `~ | |||||
| 90 | 111 | ||||
| 91 | ```bash | 112 | ```bash | ||
| 92 | # First run - creates default config | 113 | # First run - creates default config | ||
| 93 | - | gordon start | 114 | + | gordon serve |
| 94 | # Edit the config, then restart | 115 | # Edit the config, then restart | ||
| 95 | ``` | 116 | ``` | ||
| 96 | 117 | ||||
| @@ -98,15 +119,15 @@ gordon start | |||||
| 98 | 119 | ||||
| 99 | ```bash | 120 | ```bash | ||
| 100 | # Basic start | 121 | # Basic start | ||
| 101 | - | gordon start | 122 | + | gordon serve |
| 102 | 123 | ||||
| 103 | # With custom config | 124 | # With custom config | ||
| 104 | - | gordon start --config /path/to/gordon.toml | 125 | + | gordon serve --config /path/to/gordon.toml |
| 105 | - | gordon start -c ./my-config.toml | 126 | + | gordon serve -c ./my-config.toml |
| 106 | 127 | ||||
| 107 | # With environment override | 128 | # With environment override | ||
| 108 | - | GORDON_SERVER_PORT=8080 gordon start | 129 | + | GORDON_SERVER_PORT=8080 gordon serve |
| 109 | - | GORDON_LOGGING_LEVEL=debug gordon start | 130 | + | GORDON_LOGGING_LEVEL=debug gordon serve |
| 110 | ``` | 131 | ``` | ||
| 111 | 132 | ||||
| 112 | ### Signals | 133 | ### Signals | ||
| @@ -132,7 +153,7 @@ Description=Gordon Container Platform | |||||
| 132 | [Service] | 153 | [Service] | ||
| 133 | Type=simple | 154 | Type=simple | ||
| 134 | Restart=always | 155 | Restart=always | ||
| 135 | - | ExecStart=/usr/local/bin/gordon start | 156 | + | ExecStart=/usr/local/bin/gordon serve |
| 136 | 157 | ||||
| 137 | [Install] | 158 | [Install] | ||
| 138 | WantedBy=default.target | 159 | WantedBy=default.target | ||
| @@ -529,6 +550,119 @@ password_hash = "gordon/registry/password_hash" | |||||
| 529 | 550 | ||||
| 530 | --- | 551 | --- | ||
| 531 | 552 | ||||
| 553 | + | ## gordon routes | |||
| 554 | + | ||||
| 555 | + | Manage routes on local or remote Gordon instances. | |||
| 556 | + | ||||
| 557 | + | ### Subcommands | |||
| 558 | + | ||||
| 559 | + | | Subcommand | Description | | |||
| 560 | + | |------------|-------------| | |||
| 561 | + | | `list` | List all routes | | |||
| 562 | + | | `add` | Add a new route | | |||
| 563 | + | | `remove` | Remove a route | | |||
| 564 | + | | `deploy` | Deploy a specific route | | |||
| 565 | + | ||||
| 566 | + | ### gordon routes list | |||
| 567 | + | ||||
| 568 | + | List all configured routes. | |||
| 569 | + | ||||
| 570 | + | ```bash | |||
| 571 | + | gordon routes list | |||
| 572 | + | gordon routes list --remote https://gordon.mydomain.com --token $TOKEN | |||
| 573 | + | ``` | |||
| 574 | + | ||||
| 575 | + | ### gordon routes add | |||
| 576 | + | ||||
| 577 | + | Add a new route. | |||
| 578 | + | ||||
| 579 | + | ```bash | |||
| 580 | + | gordon routes add <domain> <image> | |||
| 581 | + | gordon routes add myapp.example.com myapp:latest | |||
| 582 | + | ``` | |||
| 583 | + | ||||
| 584 | + | ### gordon routes remove | |||
| 585 | + | ||||
| 586 | + | Remove a route. | |||
| 587 | + | ||||
| 588 | + | ```bash | |||
| 589 | + | gordon routes remove <domain> | |||
| 590 | + | gordon routes remove myapp.example.com | |||
| 591 | + | ``` | |||
| 592 | + | ||||
| 593 | + | ### gordon routes deploy | |||
| 594 | + | ||||
| 595 | + | Deploy or redeploy a specific route. | |||
| 596 | + | ||||
| 597 | + | ```bash | |||
| 598 | + | gordon routes deploy <domain> | |||
| 599 | + | gordon routes deploy myapp.example.com | |||
| 600 | + | ``` | |||
| 601 | + | ||||
| 602 | + | --- | |||
| 603 | + | ||||
| 604 | + | ## gordon secrets | |||
| 605 | + | ||||
| 606 | + | Manage secrets on remote Gordon instances. Requires remote targeting. | |||
| 607 | + | ||||
| 608 | + | ### Subcommands | |||
| 609 | + | ||||
| 610 | + | | Subcommand | Description | | |||
| 611 | + | |------------|-------------| | |||
| 612 | + | | `list` | List all secrets | | |||
| 613 | + | | `get` | Get a secret value | | |||
| 614 | + | | `set` | Set a secret value | | |||
| 615 | + | | `delete` | Delete a secret | | |||
| 616 | + | ||||
| 617 | + | ### Examples | |||
| 618 | + | ||||
| 619 | + | ```bash | |||
| 620 | + | # List secrets | |||
| 621 | + | gordon secrets list --remote https://gordon.mydomain.com --token $TOKEN | |||
| 622 | + | ||||
| 623 | + | # Set a secret | |||
| 624 | + | gordon secrets set DATABASE_URL "postgres://..." --remote ... --token ... | |||
| 625 | + | ||||
| 626 | + | # Get a secret | |||
| 627 | + | gordon secrets get DATABASE_URL --remote ... --token ... | |||
| 628 | + | ||||
| 629 | + | # Delete a secret | |||
| 630 | + | gordon secrets delete DATABASE_URL --remote ... --token ... | |||
| 631 | + | ``` | |||
| 632 | + | ||||
| 633 | + | --- | |||
| 634 | + | ||||
| 635 | + | ## gordon remotes | |||
| 636 | + | ||||
| 637 | + | Manage saved remote Gordon instances for easier CLI usage. | |||
| 638 | + | ||||
| 639 | + | ### Subcommands | |||
| 640 | + | ||||
| 641 | + | | Subcommand | Description | | |||
| 642 | + | |------------|-------------| | |||
| 643 | + | | `list` | List saved remotes | | |||
| 644 | + | | `add` | Add a new remote | | |||
| 645 | + | | `remove` | Remove a saved remote | | |||
| 646 | + | | `use` | Set active remote | | |||
| 647 | + | ||||
| 648 | + | ### Examples | |||
| 649 | + | ||||
| 650 | + | ```bash | |||
| 651 | + | # Add a remote | |||
| 652 | + | gordon remotes add prod https://gordon.mydomain.com --token $TOKEN | |||
| 653 | + | ||||
| 654 | + | # List saved remotes | |||
| 655 | + | gordon remotes list | |||
| 656 | + | ||||
| 657 | + | # Set active remote | |||
| 658 | + | gordon remotes use prod | |||
| 659 | + | ||||
| 660 | + | # Now use without --remote flag | |||
| 661 | + | gordon routes list | |||
| 662 | + | ``` | |||
| 663 | + | ||||
| 664 | + | --- | |||
| 665 | + | ||||
| 532 | ## Exit Codes | 666 | ## Exit Codes | ||
| 533 | 667 | ||||
| 534 | | Code | Meaning | | 668 | | Code | Meaning | | ||
| @@ -542,8 +676,8 @@ password_hash = "gordon/registry/password_hash" | |||||
| 542 | Gordon reads configuration from environment variables: | 676 | Gordon reads configuration from environment variables: | ||
| 543 | 677 | ||||
| 544 | ```bash | 678 | ```bash | ||
| 545 | - | GORDON_SERVER_PORT=8080 gordon start | 679 | + | GORDON_SERVER_PORT=8080 gordon serve |
| 546 | - | GORDON_LOGGING_LEVEL=debug gordon start | 680 | + | GORDON_LOGGING_LEVEL=debug gordon serve |
| 547 | ``` | 681 | ``` | ||
| 548 | 682 | ||||
| 549 | Pattern: `GORDON_SECTION_KEY` (uppercase, underscores) | 683 | Pattern: `GORDON_SECTION_KEY` (uppercase, underscores) | ||
M docs/config/auto-route.md +120 -6
| @@ -1,6 +1,9 @@ | |||||
| 1 | # Auto Route Configuration | 1 | # Auto Route Configuration | ||
| 2 | 2 | ||||
| 3 | - | Automatically create routes from image names that contain domains. | 3 | + | Gordon supports two methods for automatic route creation: |
| 4 | + | ||||
| 5 | + | 1. **Image Name Detection** - Routes from domain-like image names | |||
| 6 | + | 2. **Image Labels** - Routes from Dockerfile labels (recommended) | |||
| 4 | 7 | ||||
| 5 | ## Configuration | 8 | ## Configuration | ||
| 6 | 9 | ||||
| @@ -123,7 +126,7 @@ enabled = true | |||||
| 123 | 126 | ||||
| 124 | ```toml | 127 | ```toml | ||
| 125 | [server] | 128 | [server] | ||
| 126 | - | registry_domain = "registry.local" | 129 | + | gordon_domain = "gordon.local" |
| 127 | 130 | ||||
| 128 | [auto_route] | 131 | [auto_route] | ||
| 129 | enabled = true | 132 | enabled = true | ||
| @@ -133,8 +136,8 @@ enabled = true | |||||
| 133 | 136 | ||||
| 134 | Usage: | 137 | Usage: | ||
| 135 | ```bash | 138 | ```bash | ||
| 136 | - | docker push registry.local/myapp.local:latest | 139 | + | docker push gordon.local/myapp.local:latest |
| 137 | - | docker push registry.local/api.local:latest | 140 | + | docker push gordon.local/api.local:latest |
| 138 | # Both routes created automatically | 141 | # Both routes created automatically | ||
| 139 | ``` | 142 | ``` | ||
| 140 | 143 | ||||
| @@ -152,14 +155,125 @@ enabled = true | |||||
| 152 | # Other services can use auto-route | 155 | # Other services can use auto-route | ||
| 153 | ``` | 156 | ``` | ||
| 154 | 157 | ||||
| 155 | - | ## Limitations | 158 | + | ## Limitations (Image Name Detection) |
| 156 | 159 | ||||
| 157 | - Auto-routes always use the pushed image tag | 160 | - Auto-routes always use the pushed image tag | ||
| 158 | - No support for HTTP-only routes (all HTTPS) | 161 | - No support for HTTP-only routes (all HTTPS) | ||
| 159 | - No automatic attachment configuration | 162 | - No automatic attachment configuration | ||
| 160 | - Cannot pin versions (always uses pushed tag) | 163 | - Cannot pin versions (always uses pushed tag) | ||
| 161 | 164 | ||||
| 162 | - | For more control, define routes manually in `[routes]`. | 165 | + | For more control, define routes manually in `[routes]` or use image labels. |
| 166 | + | ||||
| 167 | + | --- | |||
| 168 | + | ||||
| 169 | + | ## Image Labels (Recommended) | |||
| 170 | + | ||||
| 171 | + | The preferred method for automatic routing uses Dockerfile labels. This gives you full control over routing behavior directly in your application. | |||
| 172 | + | ||||
| 173 | + | ### Supported Labels | |||
| 174 | + | ||||
| 175 | + | | Label | Description | Example | | |||
| 176 | + | |-------|-------------|---------| | |||
| 177 | + | | `gordon.domains` | Comma-separated list of domains | `app.example.com,www.app.example.com` | | |||
| 178 | + | | `gordon.port` | Container port to proxy | `3000` | | |||
| 179 | + | | `gordon.health` | Health check endpoint path | `/health` | | |||
| 180 | + | | `gordon.env-file` | Path to .env file in image | `/app/.env.example` | | |||
| 181 | + | ||||
| 182 | + | ### Dockerfile Example | |||
| 183 | + | ||||
| 184 | + | ```dockerfile | |||
| 185 | + | FROM node:20-alpine | |||
| 186 | + | WORKDIR /app | |||
| 187 | + | COPY . . | |||
| 188 | + | RUN npm install | |||
| 189 | + | ||||
| 190 | + | # Gordon routing labels | |||
| 191 | + | LABEL gordon.domains="myapp.example.com,www.myapp.example.com" | |||
| 192 | + | LABEL gordon.port="3000" | |||
| 193 | + | LABEL gordon.health="/api/health" | |||
| 194 | + | LABEL gordon.env-file="/app/.env.example" | |||
| 195 | + | ||||
| 196 | + | EXPOSE 3000 | |||
| 197 | + | CMD ["npm", "start"] | |||
| 198 | + | ``` | |||
| 199 | + | ||||
| 200 | + | ### How It Works | |||
| 201 | + | ||||
| 202 | + | When you push an image with Gordon labels: | |||
| 203 | + | ||||
| 204 | + | 1. Gordon reads the `gordon.domains` label | |||
| 205 | + | 2. Creates routes for each domain automatically | |||
| 206 | + | 3. Uses `gordon.port` for the proxy target (or first EXPOSE port) | |||
| 207 | + | 4. Configures health checks if `gordon.health` is set | |||
| 208 | + | 5. Extracts environment variables from `gordon.env-file` if specified | |||
| 209 | + | ||||
| 210 | + | ### Label Priority | |||
| 211 | + | ||||
| 212 | + | Labels take precedence over image name detection: | |||
| 213 | + | ||||
| 214 | + | 1. **Labels present** → Use `gordon.domains` for routing | |||
| 215 | + | 2. **No labels** → Fall back to image name detection (if enabled) | |||
| 216 | + | 3. **Manual routes** → Always take highest priority | |||
| 217 | + | ||||
| 218 | + | ### Multi-Domain Example | |||
| 219 | + | ||||
| 220 | + | ```dockerfile | |||
| 221 | + | # Single image serving multiple domains | |||
| 222 | + | LABEL gordon.domains="api.example.com,api.staging.example.com" | |||
| 223 | + | ``` | |||
| 224 | + | ||||
| 225 | + | Both domains will route to the same container. | |||
| 226 | + | ||||
| 227 | + | ### Environment File Extraction | |||
| 228 | + | ||||
| 229 | + | The `gordon.env-file` label tells Gordon where to find a template `.env` file: | |||
| 230 | + | ||||
| 231 | + | ```dockerfile | |||
| 232 | + | # Include a .env.example in your image | |||
| 233 | + | COPY .env.example /app/.env.example | |||
| 234 | + | LABEL gordon.env-file="/app/.env.example" | |||
| 235 | + | ``` | |||
| 236 | + | ||||
| 237 | + | Gordon will: | |||
| 238 | + | 1. Extract the file from the image | |||
| 239 | + | 2. Store it in the env directory | |||
| 240 | + | 3. Use the variables for container deployment | |||
| 241 | + | ||||
| 242 | + | ### Best Practices | |||
| 243 | + | ||||
| 244 | + | 1. **Always set `gordon.port`** - Don't rely on EXPOSE detection | |||
| 245 | + | 2. **Use `gordon.health`** - Enables reliable deployment verification | |||
| 246 | + | 3. **Include `.env.example`** - Self-documenting environment requirements | |||
| 247 | + | 4. **Separate domains with commas** - No spaces around commas | |||
| 248 | + | ||||
| 249 | + | ### Example: Complete Setup | |||
| 250 | + | ||||
| 251 | + | ```dockerfile | |||
| 252 | + | FROM golang:1.25-alpine AS builder | |||
| 253 | + | WORKDIR /app | |||
| 254 | + | COPY . . | |||
| 255 | + | RUN go build -o server | |||
| 256 | + | ||||
| 257 | + | FROM alpine:3.21 | |||
| 258 | + | WORKDIR /app | |||
| 259 | + | COPY --from=builder /app/server . | |||
| 260 | + | COPY .env.example . | |||
| 261 | + | ||||
| 262 | + | # Gordon labels | |||
| 263 | + | LABEL gordon.domains="api.mycompany.com" | |||
| 264 | + | LABEL gordon.port="8080" | |||
| 265 | + | LABEL gordon.health="/health" | |||
| 266 | + | LABEL gordon.env-file="/app/.env.example" | |||
| 267 | + | ||||
| 268 | + | EXPOSE 8080 | |||
| 269 | + | CMD ["./server"] | |||
| 270 | + | ``` | |||
| 271 | + | ||||
| 272 | + | ```bash | |||
| 273 | + | # Push and Gordon handles everything | |||
| 274 | + | docker push registry.mycompany.com/api:latest | |||
| 275 | + | # Route automatically created: api.mycompany.com → container:8080 | |||
| 276 | + | ``` | |||
| 163 | 277 | ||||
| 164 | ## Related | 278 | ## Related | ||
| 165 | 279 | ||||
A docs/config/deploy.md +26 -0
| @@ -0,0 +1,26 @@ | |||||
| 1 | + | # Deploy Configuration | |||
| 2 | + | ||||
| 3 | + | Controls how Gordon pulls images during deployment. | |||
| 4 | + | ||||
| 5 | + | ## Example | |||
| 6 | + | ||||
| 7 | + | ```toml | |||
| 8 | + | [deploy] | |||
| 9 | + | pull_policy = "if-tag-changed" | |||
| 10 | + | ``` | |||
| 11 | + | ||||
| 12 | + | ## Settings | |||
| 13 | + | ||||
| 14 | + | ### `deploy.pull_policy` | |||
| 15 | + | ||||
| 16 | + | How to decide when to pull an image: | |||
| 17 | + | ||||
| 18 | + | - `always`: always pull, even if the tag exists locally. | |||
| 19 | + | - `if-not-present`: only pull when the tag is missing locally. | |||
| 20 | + | - `if-tag-changed`: pull for tag references to check for updates; skip pulls for digest references (`@sha256:...`). | |||
| 21 | + | ||||
| 22 | + | Default: `if-tag-changed` | |||
| 23 | + | ||||
| 24 | + | ## Notes | |||
| 25 | + | ||||
| 26 | + | - Changes to `deploy.pull_policy` require a restart to take effect. | |||
M docs/config/index.md +12 -5
| @@ -7,7 +7,7 @@ Gordon uses a single TOML configuration file located at `~/.config/gordon/gordon | |||||
| 7 | | Location | Purpose | | 7 | | Location | Purpose | | ||
| 8 | |----------|---------| | 8 | |----------|---------| | ||
| 9 | | `~/.config/gordon/gordon.toml` | Default configuration file | | 9 | | `~/.config/gordon/gordon.toml` | Default configuration file | | ||
| 10 | - | | Custom path via `--config` flag | `gordon start --config /path/to/config.toml` | | 10 | + | | Custom path via `--config` flag | `gordon serve --config /path/to/config.toml` | |
| 11 | 11 | ||||
| 12 | ## Minimal Configuration | 12 | ## Minimal Configuration | ||
| 13 | 13 | ||||
| @@ -15,7 +15,7 @@ Gordon uses a single TOML configuration file located at `~/.config/gordon/gordon | |||||
| 15 | [server] | 15 | [server] | ||
| 16 | port = 8080 | 16 | port = 8080 | ||
| 17 | registry_port = 5000 | 17 | registry_port = 5000 | ||
| 18 | - | registry_domain = "registry.mydomain.com" | 18 | + | gordon_domain = "gordon.mydomain.com" |
| 19 | 19 | ||||
| 20 | [routes] | 20 | [routes] | ||
| 21 | "app.mydomain.com" = "myapp:latest" | 21 | "app.mydomain.com" = "myapp:latest" | ||
| @@ -30,7 +30,7 @@ registry_domain = "registry.mydomain.com" | |||||
| 30 | [server] | 30 | [server] | ||
| 31 | port = 8080 # HTTP proxy port (default: 80) | 31 | port = 8080 # HTTP proxy port (default: 80) | ||
| 32 | registry_port = 5000 # Registry port (default: 5000) | 32 | registry_port = 5000 # Registry port (default: 5000) | ||
| 33 | - | registry_domain = "registry.mydomain.com" # Required: registry domain | 33 | + | gordon_domain = "gordon.mydomain.com" # Required: Gordon domain (registry + API) |
| 34 | # data_dir = "~/.gordon" # Default for user installations | 34 | # data_dir = "~/.gordon" # Default for user installations | ||
| 35 | 35 | ||||
| 36 | # Secrets backend | 36 | # Secrets backend | ||
| @@ -48,6 +48,10 @@ type = "token" # "password" or "token" | |||||
| 48 | token_secret = "gordon/registry/token_secret" | 48 | token_secret = "gordon/registry/token_secret" | ||
| 49 | token_expiry = "720h" # Duration or 0 for never | 49 | token_expiry = "720h" # Duration or 0 for never | ||
| 50 | 50 | ||||
| 51 | + | # Deploy behavior | |||
| 52 | + | [deploy] | |||
| 53 | + | pull_policy = "if-tag-changed" # always, if-not-present, if-tag-changed | |||
| 54 | + | ||||
| 51 | # Logging | 55 | # Logging | ||
| 52 | [logging] | 56 | [logging] | ||
| 53 | level = "info" # trace, debug, info, warn, error | 57 | level = "info" # trace, debug, info, warn, error | ||
| @@ -109,6 +113,7 @@ enabled = false # Auto-create routes from image names | |||||
| 109 | | `[server]` | Core server settings | [Server](./server.md) | | 113 | | `[server]` | Core server settings | [Server](./server.md) | | ||
| 110 | | `[secrets]` | Secrets backend configuration | [Secrets](./secrets.md) | | 114 | | `[secrets]` | Secrets backend configuration | [Secrets](./secrets.md) | | ||
| 111 | | `[registry_auth]` | Registry authentication | [Registry Auth](./registry-auth.md) | | 115 | | `[registry_auth]` | Registry authentication | [Registry Auth](./registry-auth.md) | | ||
| 116 | + | | `[deploy]` | Deployment behavior | [Deploy](./deploy.md) | | |||
| 112 | | `[logging]` | Logging configuration | [Logging](./logging.md) | | 117 | | `[logging]` | Logging configuration | [Logging](./logging.md) | | ||
| 113 | | `[env]` | Environment variable settings | [Environment](./env.md) | | 118 | | `[env]` | Environment variable settings | [Environment](./env.md) | | ||
| 114 | | `[volumes]` | Volume management | [Volumes](./volumes.md) | | 119 | | `[volumes]` | Volume management | [Volumes](./volumes.md) | | ||
| @@ -130,6 +135,7 @@ enabled = false # Auto-create routes from image names | |||||
| 130 | | `registry_auth.enabled` | `false` | | 135 | | `registry_auth.enabled` | `false` | | ||
| 131 | | `registry_auth.type` | `"password"` | | 136 | | `registry_auth.type` | `"password"` | | ||
| 132 | | `registry_auth.token_expiry` | `"720h"` | | 137 | | `registry_auth.token_expiry` | `"720h"` | | ||
| 138 | + | | `deploy.pull_policy` | `"if-tag-changed"` | | |||
| 133 | | `logging.level` | `"info"` | | 139 | | `logging.level` | `"info"` | | ||
| 134 | | `logging.format` | `"console"` | | 140 | | `logging.format` | `"console"` | | ||
| 135 | | `logging.file.enabled` | `false` | | 141 | | `logging.file.enabled` | `false` | | ||
| @@ -158,14 +164,15 @@ The following settings require a restart to take effect: | |||||
| 158 | - `server.data_dir` | 164 | - `server.data_dir` | ||
| 159 | - `secrets.backend` | 165 | - `secrets.backend` | ||
| 160 | - `registry_auth` settings | 166 | - `registry_auth` settings | ||
| 167 | + | - `deploy.pull_policy` | |||
| 161 | 168 | ||||
| 162 | ## Environment Variable Override | 169 | ## Environment Variable Override | ||
| 163 | 170 | ||||
| 164 | Configuration values can be overridden with environment variables: | 171 | Configuration values can be overridden with environment variables: | ||
| 165 | 172 | ||||
| 166 | ```bash | 173 | ```bash | ||
| 167 | - | GORDON_SERVER_PORT=8080 gordon start | 174 | + | GORDON_SERVER_PORT=8080 gordon serve |
| 168 | - | GORDON_LOGGING_LEVEL=debug gordon start | 175 | + | GORDON_LOGGING_LEVEL=debug gordon serve |
| 169 | ``` | 176 | ``` | ||
| 170 | 177 | ||||
| 171 | Pattern: `GORDON_SECTION_KEY` (uppercase, underscores instead of dots) | 178 | Pattern: `GORDON_SECTION_KEY` (uppercase, underscores instead of dots) | ||
M docs/config/server.md +17 -11
| @@ -8,7 +8,7 @@ Core server settings for Gordon. | |||||
| 8 | [server] | 8 | [server] | ||
| 9 | port = 8080 # HTTP proxy port | 9 | port = 8080 # HTTP proxy port | ||
| 10 | registry_port = 5000 # Container registry port | 10 | registry_port = 5000 # Container registry port | ||
| 11 | - | registry_domain = "registry.mydomain.com" # Registry domain (required) | 11 | + | gordon_domain = "gordon.mydomain.com" # Gordon domain (required) |
| 12 | # data_dir = "~/.gordon" # Data storage directory (default) | 12 | # data_dir = "~/.gordon" # Data storage directory (default) | ||
| 13 | ``` | 13 | ``` | ||
| 14 | 14 | ||||
| @@ -18,7 +18,8 @@ registry_domain = "registry.mydomain.com" # Registry domain (required) | |||||
| 18 | |--------|------|---------|-------------| | 18 | |--------|------|---------|-------------| | ||
| 19 | | `port` | int | `80` | HTTP proxy port for routing traffic to containers | | 19 | | `port` | int | `80` | HTTP proxy port for routing traffic to containers | | ||
| 20 | | `registry_port` | int | `5000` | Docker registry port for image push/pull | | 20 | | `registry_port` | int | `5000` | Docker registry port for image push/pull | | ||
| 21 | - | | `registry_domain` | string | **required** | Domain for the container registry | | 21 | + | | `gordon_domain` | string | **required** | Domain for Gordon (registry + admin API) | |
| 22 | + | | `registry_domain` | string | - | Deprecated alias for `gordon_domain` | | |||
| 22 | | `data_dir` | string | `~/.gordon` | Directory for registry data, logs, and env files | | 23 | | `data_dir` | string | `~/.gordon` | Directory for registry data, logs, and env files | | ||
| 23 | 24 | ||||
| 24 | ## Port Configuration | 25 | ## Port Configuration | ||
| @@ -54,19 +55,24 @@ Docker/Podman clients push to this port: | |||||
| 54 | docker push registry.mydomain.com:5000/myapp:latest | 55 | docker push registry.mydomain.com:5000/myapp:latest | ||
| 55 | ``` | 56 | ``` | ||
| 56 | 57 | ||||
| 57 | - | ## Registry Domain | 58 | + | ## Gordon Domain |
| 58 | 59 | ||||
| 59 | - | The `registry_domain` is required and must match your DNS configuration: | 60 | + | The `gordon_domain` is required and must match your DNS configuration: |
| 60 | 61 | ||||
| 61 | ```toml | 62 | ```toml | ||
| 62 | [server] | 63 | [server] | ||
| 63 | - | registry_domain = "registry.mydomain.com" | 64 | + | gordon_domain = "gordon.mydomain.com" |
| 64 | ``` | 65 | ``` | ||
| 65 | 66 | ||||
| 66 | This domain is used for: | 67 | This domain is used for: | ||
| 67 | - | - Docker login authentication | 68 | + | - Docker login and image push/pull operations |
| 68 | - | - Image push/pull operations | 69 | + | - Admin API access (`/admin/*` endpoints) |
| 69 | - | - Internal registry references | 70 | + | - CLI remote targeting (`gordon routes --remote https://gordon.mydomain.com`) |
| 71 | + | - Token endpoint for authentication (`/v2/token`) | |||
| 72 | + | ||||
| 73 | + | When requests arrive on the proxy port with this domain as the Host header, Gordon routes them to the backend services (registry and admin API). | |||
| 74 | + | ||||
| 75 | + | > **Note:** `registry_domain` is supported as a deprecated alias for backwards compatibility. | |||
| 70 | 76 | ||||
| 71 | ## Data Directory | 77 | ## Data Directory | ||
| 72 | 78 | ||||
| @@ -108,7 +114,7 @@ Gordon creates directories with secure permissions: | |||||
| 108 | [server] | 114 | [server] | ||
| 109 | port = 8080 | 115 | port = 8080 | ||
| 110 | registry_port = 5000 | 116 | registry_port = 5000 | ||
| 111 | - | registry_domain = "registry.local" | 117 | + | gordon_domain = "gordon.local" |
| 112 | data_dir = "./dev-data" | 118 | data_dir = "./dev-data" | ||
| 113 | ``` | 119 | ``` | ||
| 114 | 120 | ||||
| @@ -118,7 +124,7 @@ data_dir = "./dev-data" | |||||
| 118 | [server] | 124 | [server] | ||
| 119 | port = 8080 | 125 | port = 8080 | ||
| 120 | registry_port = 5000 | 126 | registry_port = 5000 | ||
| 121 | - | registry_domain = "registry.company.com" | 127 | + | gordon_domain = "gordon.company.com" |
| 122 | data_dir = "/var/lib/gordon" | 128 | data_dir = "/var/lib/gordon" | ||
| 123 | ``` | 129 | ``` | ||
| 124 | 130 | ||||
| @@ -128,7 +134,7 @@ data_dir = "/var/lib/gordon" | |||||
| 128 | [server] | 134 | [server] | ||
| 129 | port = 8080 # High port for rootless | 135 | port = 8080 # High port for rootless | ||
| 130 | registry_port = 5000 | 136 | registry_port = 5000 | ||
| 131 | - | registry_domain = "registry.mydomain.com" | 137 | + | gordon_domain = "gordon.mydomain.com" |
| 132 | ``` | 138 | ``` | ||
| 133 | 139 | ||||
| 134 | With firewall port forwarding: | 140 | With firewall port forwarding: | ||
M docs/getting-started.md +3 -3
| @@ -20,7 +20,7 @@ This script automatically detects your OS and architecture, downloads the approp | |||||
| 20 | 20 | ||||
| 21 | ```bash | 21 | ```bash | ||
| 22 | # First run creates the default config | 22 | # First run creates the default config | ||
| 23 | - | gordon start | 23 | + | gordon serve |
| 24 | # Press Ctrl+C to stop | 24 | # Press Ctrl+C to stop | ||
| 25 | ``` | 25 | ``` | ||
| 26 | 26 | ||||
| @@ -34,7 +34,7 @@ Edit `~/.config/gordon/gordon.toml`: | |||||
| 34 | [server] | 34 | [server] | ||
| 35 | port = 8080 # Proxy port (use with Cloudflare) | 35 | port = 8080 # Proxy port (use with Cloudflare) | ||
| 36 | registry_port = 5000 # Registry port | 36 | registry_port = 5000 # Registry port | ||
| 37 | - | registry_domain = "registry.mydomain.com" # Your registry domain | 37 | + | gordon_domain = "gordon.mydomain.com" # Your Gordon domain |
| 38 | 38 | ||||
| 39 | [routes] | 39 | [routes] | ||
| 40 | "app.mydomain.com" = "myapp:latest" # Domain → Image mapping | 40 | "app.mydomain.com" = "myapp:latest" # Domain → Image mapping | ||
| @@ -63,7 +63,7 @@ Description=Gordon Container Platform | |||||
| 63 | [Service] | 63 | [Service] | ||
| 64 | Type=simple | 64 | Type=simple | ||
| 65 | Restart=always | 65 | Restart=always | ||
| 66 | - | ExecStart=/usr/local/bin/gordon start | 66 | + | ExecStart=/usr/local/bin/gordon serve |
| 67 | 67 | ||||
| 68 | [Install] | 68 | [Install] | ||
| 69 | WantedBy=default.target | 69 | WantedBy=default.target | ||
M docs/installation.md +4 -4
| @@ -153,7 +153,7 @@ Gordon creates a default configuration on first run: | |||||
| 153 | 153 | ||||
| 154 | ```bash | 154 | ```bash | ||
| 155 | # Run once to generate config, then stop | 155 | # Run once to generate config, then stop | ||
| 156 | - | gordon start | 156 | + | gordon serve |
| 157 | # Press Ctrl+C | 157 | # Press Ctrl+C | ||
| 158 | 158 | ||||
| 159 | # Edit configuration | 159 | # Edit configuration | ||
| @@ -166,7 +166,7 @@ Minimum required configuration: | |||||
| 166 | [server] | 166 | [server] | ||
| 167 | port = 8080 | 167 | port = 8080 | ||
| 168 | registry_port = 5000 | 168 | registry_port = 5000 | ||
| 169 | - | registry_domain = "registry.yourdomain.com" | 169 | + | gordon_domain = "gordon.yourdomain.com" |
| 170 | 170 | ||||
| 171 | [routes] | 171 | [routes] | ||
| 172 | "app.yourdomain.com" = "myapp:latest" | 172 | "app.yourdomain.com" = "myapp:latest" | ||
| @@ -189,7 +189,7 @@ After=podman.socket | |||||
| 189 | Type=simple | 189 | Type=simple | ||
| 190 | Restart=always | 190 | Restart=always | ||
| 191 | RestartSec=5 | 191 | RestartSec=5 | ||
| 192 | - | ExecStart=/usr/local/bin/gordon start | 192 | + | ExecStart=/usr/local/bin/gordon serve |
| 193 | 193 | ||||
| 194 | [Install] | 194 | [Install] | ||
| 195 | WantedBy=default.target | 195 | WantedBy=default.target | ||
| @@ -219,7 +219,7 @@ Requires=docker.service | |||||
| 219 | Type=simple | 219 | Type=simple | ||
| 220 | Restart=always | 220 | Restart=always | ||
| 221 | RestartSec=5 | 221 | RestartSec=5 | ||
| 222 | - | ExecStart=/usr/local/bin/gordon start | 222 | + | ExecStart=/usr/local/bin/gordon serve |
| 223 | User=root | 223 | User=root | ||
| 224 | 224 | ||||
| 225 | [Install] | 225 | [Install] | ||
M docs/reference/troubleshooting.md +1 -1
| @@ -35,7 +35,7 @@ Common issues and solutions when using Gordon. | |||||
| 35 | ```bash | 35 | ```bash | ||
| 36 | systemctl --user status gordon | 36 | systemctl --user status gordon | ||
| 37 | # or | 37 | # or | ||
| 38 | - | pgrep -f "gordon start" | 38 | + | pgrep -f "gordon serve" |
| 39 | ``` | 39 | ``` | ||
| 40 | 40 | ||||
| 41 | 2. Check registry port is accessible: | 41 | 2. Check registry port is accessible: | ||
M go.mod +20 -2
| @@ -4,6 +4,9 @@ go 1.25.5 | |||||
| 4 | 4 | ||||
| 5 | require ( | 5 | require ( | ||
| 6 | github.com/bnema/zerowrap v1.3.0 | 6 | github.com/bnema/zerowrap v1.3.0 | ||
| 7 | + | github.com/charmbracelet/bubbles v0.21.0 | |||
| 8 | + | github.com/charmbracelet/bubbletea v1.3.10 | |||
| 9 | + | github.com/charmbracelet/lipgloss v1.1.0 | |||
| 7 | github.com/docker/docker v28.5.2+incompatible | 10 | github.com/docker/docker v28.5.2+incompatible | ||
| 8 | github.com/docker/go-connections v0.5.0 | 11 | github.com/docker/go-connections v0.5.0 | ||
| 9 | github.com/golang-jwt/jwt/v5 v5.3.0 | 12 | github.com/golang-jwt/jwt/v5 v5.3.0 | ||
| @@ -17,8 +20,23 @@ require ( | |||||
| 17 | ) | 20 | ) | ||
| 18 | 21 | ||||
| 19 | require ( | 22 | require ( | ||
| 23 | + | github.com/atotto/clipboard v0.1.4 // indirect | |||
| 24 | + | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect | |||
| 20 | github.com/cespare/xxhash/v2 v2.3.0 // indirect | 25 | github.com/cespare/xxhash/v2 v2.3.0 // indirect | ||
| 26 | + | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect | |||
| 27 | + | github.com/charmbracelet/x/ansi v0.10.1 // indirect | |||
| 28 | + | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect | |||
| 29 | + | github.com/charmbracelet/x/term v0.2.1 // indirect | |||
| 30 | + | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect | |||
| 31 | + | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect | |||
| 32 | + | github.com/mattn/go-localereader v0.0.1 // indirect | |||
| 33 | + | github.com/mattn/go-runewidth v0.0.16 // indirect | |||
| 34 | + | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect | |||
| 35 | + | github.com/muesli/cancelreader v0.2.2 // indirect | |||
| 36 | + | github.com/muesli/termenv v0.16.0 // indirect | |||
| 37 | + | github.com/rivo/uniseg v0.4.7 // indirect | |||
| 21 | github.com/rs/zerolog v1.34.0 // indirect | 38 | github.com/rs/zerolog v1.34.0 // indirect | ||
| 39 | + | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect | |||
| 22 | go.yaml.in/yaml/v3 v3.0.4 // indirect | 40 | go.yaml.in/yaml/v3 v3.0.4 // indirect | ||
| 23 | ) | 41 | ) | ||
| 24 | 42 | ||||
| @@ -27,7 +45,7 @@ require ( | |||||
| 27 | github.com/pmezard/go-difflib v1.0.0 // indirect | 45 | github.com/pmezard/go-difflib v1.0.0 // indirect | ||
| 28 | github.com/stretchr/objx v0.5.2 // indirect | 46 | github.com/stretchr/objx v0.5.2 // indirect | ||
| 29 | go.uber.org/mock v0.5.2 // direct | 47 | go.uber.org/mock v0.5.2 // direct | ||
| 30 | - | golang.org/x/time v0.12.0 // indirect | 48 | + | golang.org/x/time v0.14.0 |
| 31 | ) | 49 | ) | ||
| 32 | 50 | ||||
| 33 | require ( | 51 | require ( | ||
| @@ -51,7 +69,7 @@ require ( | |||||
| 51 | github.com/morikuni/aec v1.0.0 // indirect | 69 | github.com/morikuni/aec v1.0.0 // indirect | ||
| 52 | github.com/opencontainers/go-digest v1.0.0 // indirect | 70 | github.com/opencontainers/go-digest v1.0.0 // indirect | ||
| 53 | github.com/opencontainers/image-spec v1.1.1 // indirect | 71 | github.com/opencontainers/image-spec v1.1.1 // indirect | ||
| 54 | - | github.com/pelletier/go-toml/v2 v2.2.4 // indirect | 72 | + | github.com/pelletier/go-toml/v2 v2.2.4 |
| 55 | github.com/pkg/errors v0.9.1 // indirect | 73 | github.com/pkg/errors v0.9.1 // indirect | ||
| 56 | github.com/sagikazarmark/locafero v0.12.0 // indirect | 74 | github.com/sagikazarmark/locafero v0.12.0 // indirect | ||
| 57 | github.com/spf13/afero v1.15.0 | 75 | github.com/spf13/afero v1.15.0 | ||
M go.sum +46 -2 generated file: collapsed
A internal/adapters/in/cli/remote/client.go +321 -0
| @@ -0,0 +1,321 @@ | |||||
| 1 | + | // Package remote provides an HTTP client for connecting to remote Gordon instances. | |||
| 2 | + | package remote | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "bytes" | |||
| 6 | + | "context" | |||
| 7 | + | "encoding/json" | |||
| 8 | + | "fmt" | |||
| 9 | + | "io" | |||
| 10 | + | "net/http" | |||
| 11 | + | "strings" | |||
| 12 | + | "time" | |||
| 13 | + | ||||
| 14 | + | "gordon/internal/domain" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // Client is an HTTP client for the Gordon admin API. | |||
| 18 | + | type Client struct { | |||
| 19 | + | baseURL string | |||
| 20 | + | token string | |||
| 21 | + | httpClient *http.Client | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | // ClientOption configures the Client. | |||
| 25 | + | type ClientOption func(*Client) | |||
| 26 | + | ||||
| 27 | + | // NewClient creates a new remote Gordon client. | |||
| 28 | + | func NewClient(baseURL string, opts ...ClientOption) *Client { | |||
| 29 | + | // Normalize base URL | |||
| 30 | + | baseURL = strings.TrimSuffix(baseURL, "/") | |||
| 31 | + | ||||
| 32 | + | c := &Client{ | |||
| 33 | + | baseURL: baseURL, | |||
| 34 | + | httpClient: &http.Client{ | |||
| 35 | + | Timeout: 30 * time.Second, | |||
| 36 | + | }, | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | for _, opt := range opts { | |||
| 40 | + | opt(c) | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | return c | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // WithToken sets the authentication token. | |||
| 47 | + | func WithToken(token string) ClientOption { | |||
| 48 | + | return func(c *Client) { | |||
| 49 | + | c.token = token | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | // WithHTTPClient sets a custom HTTP client. | |||
| 54 | + | func WithHTTPClient(client *http.Client) ClientOption { | |||
| 55 | + | return func(c *Client) { | |||
| 56 | + | c.httpClient = client | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | // WithTimeout sets the HTTP client timeout. | |||
| 61 | + | func WithTimeout(timeout time.Duration) ClientOption { | |||
| 62 | + | return func(c *Client) { | |||
| 63 | + | c.httpClient.Timeout = timeout | |||
| 64 | + | } | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | // request performs an HTTP request to the admin API. | |||
| 68 | + | func (c *Client) request(ctx context.Context, method, path string, body any) (*http.Response, error) { | |||
| 69 | + | url := c.baseURL + "/admin" + path | |||
| 70 | + | ||||
| 71 | + | var bodyReader io.Reader | |||
| 72 | + | if body != nil { | |||
| 73 | + | jsonBody, err := json.Marshal(body) | |||
| 74 | + | if err != nil { | |||
| 75 | + | return nil, fmt.Errorf("failed to marshal request body: %w", err) | |||
| 76 | + | } | |||
| 77 | + | bodyReader = bytes.NewReader(jsonBody) | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | req, err := http.NewRequestWithContext(ctx, method, url, bodyReader) | |||
| 81 | + | if err != nil { | |||
| 82 | + | return nil, fmt.Errorf("failed to create request: %w", err) | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | req.Header.Set("Content-Type", "application/json") | |||
| 86 | + | req.Header.Set("Accept", "application/json") | |||
| 87 | + | ||||
| 88 | + | if c.token != "" { | |||
| 89 | + | req.Header.Set("Authorization", "Bearer "+c.token) | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | return c.httpClient.Do(req) | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | // parseResponse parses a JSON response into the given target. | |||
| 96 | + | func parseResponse(resp *http.Response, target any) error { | |||
| 97 | + | defer resp.Body.Close() | |||
| 98 | + | ||||
| 99 | + | if resp.StatusCode >= 400 { | |||
| 100 | + | body, _ := io.ReadAll(resp.Body) | |||
| 101 | + | var errResp struct { | |||
| 102 | + | Error string `json:"error"` | |||
| 103 | + | } | |||
| 104 | + | if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error != "" { | |||
| 105 | + | return fmt.Errorf("%s: %s", resp.Status, errResp.Error) | |||
| 106 | + | } | |||
| 107 | + | return fmt.Errorf("%s: %s", resp.Status, string(body)) | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | if target != nil { | |||
| 111 | + | if err := json.NewDecoder(resp.Body).Decode(target); err != nil { | |||
| 112 | + | return fmt.Errorf("failed to decode response: %w", err) | |||
| 113 | + | } | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | return nil | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | // Routes API | |||
| 120 | + | ||||
| 121 | + | // ListRoutes returns all configured routes. | |||
| 122 | + | func (c *Client) ListRoutes(ctx context.Context) ([]domain.Route, error) { | |||
| 123 | + | resp, err := c.request(ctx, http.MethodGet, "/routes", nil) | |||
| 124 | + | if err != nil { | |||
| 125 | + | return nil, err | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | var result struct { | |||
| 129 | + | Routes []domain.Route `json:"routes"` | |||
| 130 | + | } | |||
| 131 | + | if err := parseResponse(resp, &result); err != nil { | |||
| 132 | + | return nil, err | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | return result.Routes, nil | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | // GetRoute returns a specific route by domain. | |||
| 139 | + | func (c *Client) GetRoute(ctx context.Context, routeDomain string) (*domain.Route, error) { | |||
| 140 | + | resp, err := c.request(ctx, http.MethodGet, "/routes/"+routeDomain, nil) | |||
| 141 | + | if err != nil { | |||
| 142 | + | return nil, err | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | var route domain.Route | |||
| 146 | + | if err := parseResponse(resp, &route); err != nil { | |||
| 147 | + | return nil, err | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | return &route, nil | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | // AddRoute adds a new route. | |||
| 154 | + | func (c *Client) AddRoute(ctx context.Context, route domain.Route) error { | |||
| 155 | + | resp, err := c.request(ctx, http.MethodPost, "/routes", route) | |||
| 156 | + | if err != nil { | |||
| 157 | + | return err | |||
| 158 | + | } | |||
| 159 | + | return parseResponse(resp, nil) | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | // UpdateRoute updates an existing route. | |||
| 163 | + | func (c *Client) UpdateRoute(ctx context.Context, route domain.Route) error { | |||
| 164 | + | resp, err := c.request(ctx, http.MethodPut, "/routes/"+route.Domain, route) | |||
| 165 | + | if err != nil { | |||
| 166 | + | return err | |||
| 167 | + | } | |||
| 168 | + | return parseResponse(resp, nil) | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | // RemoveRoute removes a route by domain. | |||
| 172 | + | func (c *Client) RemoveRoute(ctx context.Context, routeDomain string) error { | |||
| 173 | + | resp, err := c.request(ctx, http.MethodDelete, "/routes/"+routeDomain, nil) | |||
| 174 | + | if err != nil { | |||
| 175 | + | return err | |||
| 176 | + | } | |||
| 177 | + | return parseResponse(resp, nil) | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | // Secrets API | |||
| 181 | + | ||||
| 182 | + | // ListSecrets returns the list of secret keys for a domain. | |||
| 183 | + | func (c *Client) ListSecrets(ctx context.Context, secretDomain string) ([]string, error) { | |||
| 184 | + | resp, err := c.request(ctx, http.MethodGet, "/secrets/"+secretDomain, nil) | |||
| 185 | + | if err != nil { | |||
| 186 | + | return nil, err | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | var result struct { | |||
| 190 | + | Domain string `json:"domain"` | |||
| 191 | + | Keys []string `json:"keys"` | |||
| 192 | + | } | |||
| 193 | + | if err := parseResponse(resp, &result); err != nil { | |||
| 194 | + | return nil, err | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | return result.Keys, nil | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | // SetSecrets sets secrets for a domain. | |||
| 201 | + | func (c *Client) SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error { | |||
| 202 | + | resp, err := c.request(ctx, http.MethodPost, "/secrets/"+secretDomain, secrets) | |||
| 203 | + | if err != nil { | |||
| 204 | + | return err | |||
| 205 | + | } | |||
| 206 | + | return parseResponse(resp, nil) | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | // DeleteSecret removes a secret from a domain. | |||
| 210 | + | func (c *Client) DeleteSecret(ctx context.Context, secretDomain, key string) error { | |||
| 211 | + | resp, err := c.request(ctx, http.MethodDelete, "/secrets/"+secretDomain+"/"+key, nil) | |||
| 212 | + | if err != nil { | |||
| 213 | + | return err | |||
| 214 | + | } | |||
| 215 | + | return parseResponse(resp, nil) | |||
| 216 | + | } | |||
| 217 | + | ||||
| 218 | + | // Status API | |||
| 219 | + | ||||
| 220 | + | // Status represents the Gordon server status. | |||
| 221 | + | type Status struct { | |||
| 222 | + | Routes int `json:"routes"` | |||
| 223 | + | RegistryDomain string `json:"registry_domain"` | |||
| 224 | + | RegistryPort int `json:"registry_port"` | |||
| 225 | + | ServerPort int `json:"server_port"` | |||
| 226 | + | AutoRoute bool `json:"auto_route"` | |||
| 227 | + | NetworkIsolation bool `json:"network_isolation"` | |||
| 228 | + | ContainerStatus map[string]string `json:"container_status"` | |||
| 229 | + | } | |||
| 230 | + | ||||
| 231 | + | // GetStatus returns the Gordon server status. | |||
| 232 | + | func (c *Client) GetStatus(ctx context.Context) (*Status, error) { | |||
| 233 | + | resp, err := c.request(ctx, http.MethodGet, "/status", nil) | |||
| 234 | + | if err != nil { | |||
| 235 | + | return nil, err | |||
| 236 | + | } | |||
| 237 | + | ||||
| 238 | + | var status Status | |||
| 239 | + | if err := parseResponse(resp, &status); err != nil { | |||
| 240 | + | return nil, err | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | return &status, nil | |||
| 244 | + | } | |||
| 245 | + | ||||
| 246 | + | // RouteHealth represents the health status of a route. | |||
| 247 | + | type RouteHealth struct { | |||
| 248 | + | ContainerStatus string `json:"container_status"` | |||
| 249 | + | HTTPStatus int `json:"http_status"` | |||
| 250 | + | ResponseTimeMs int64 `json:"response_time_ms"` | |||
| 251 | + | Healthy bool `json:"healthy"` | |||
| 252 | + | Error string `json:"error"` | |||
| 253 | + | } | |||
| 254 | + | ||||
| 255 | + | // GetHealth returns health status for all routes with HTTP probing. | |||
| 256 | + | func (c *Client) GetHealth(ctx context.Context) (map[string]*RouteHealth, error) { | |||
| 257 | + | resp, err := c.request(ctx, http.MethodGet, "/health", nil) | |||
| 258 | + | if err != nil { | |||
| 259 | + | return nil, err | |||
| 260 | + | } | |||
| 261 | + | ||||
| 262 | + | var result struct { | |||
| 263 | + | Health map[string]*RouteHealth `json:"health"` | |||
| 264 | + | } | |||
| 265 | + | if err := parseResponse(resp, &result); err != nil { | |||
| 266 | + | return nil, err | |||
| 267 | + | } | |||
| 268 | + | ||||
| 269 | + | return result.Health, nil | |||
| 270 | + | } | |||
| 271 | + | ||||
| 272 | + | // Reload triggers a configuration reload. | |||
| 273 | + | func (c *Client) Reload(ctx context.Context) error { | |||
| 274 | + | resp, err := c.request(ctx, http.MethodPost, "/reload", nil) | |||
| 275 | + | if err != nil { | |||
| 276 | + | return err | |||
| 277 | + | } | |||
| 278 | + | return parseResponse(resp, nil) | |||
| 279 | + | } | |||
| 280 | + | ||||
| 281 | + | // Config API | |||
| 282 | + | ||||
| 283 | + | // Config represents the Gordon configuration. | |||
| 284 | + | type Config struct { | |||
| 285 | + | Server struct { | |||
| 286 | + | Port int `json:"port"` | |||
| 287 | + | RegistryPort int `json:"registry_port"` | |||
| 288 | + | RegistryDomain string `json:"registry_domain"` | |||
| 289 | + | DataDir string `json:"data_dir"` | |||
| 290 | + | } `json:"server"` | |||
| 291 | + | AutoRoute struct { | |||
| 292 | + | Enabled bool `json:"enabled"` | |||
| 293 | + | } `json:"auto_route"` | |||
| 294 | + | NetworkIsolation struct { | |||
| 295 | + | Enabled bool `json:"enabled"` | |||
| 296 | + | Prefix string `json:"prefix"` | |||
| 297 | + | } `json:"network_isolation"` | |||
| 298 | + | Routes []domain.Route `json:"routes"` | |||
| 299 | + | ExternalRoutes map[string]string `json:"external_routes"` | |||
| 300 | + | } | |||
| 301 | + | ||||
| 302 | + | // GetConfig returns the Gordon configuration. | |||
| 303 | + | func (c *Client) GetConfig(ctx context.Context) (*Config, error) { | |||
| 304 | + | resp, err := c.request(ctx, http.MethodGet, "/config", nil) | |||
| 305 | + | if err != nil { | |||
| 306 | + | return nil, err | |||
| 307 | + | } | |||
| 308 | + | ||||
| 309 | + | var config Config | |||
| 310 | + | if err := parseResponse(resp, &config); err != nil { | |||
| 311 | + | return nil, err | |||
| 312 | + | } | |||
| 313 | + | ||||
| 314 | + | return &config, nil | |||
| 315 | + | } | |||
| 316 | + | ||||
| 317 | + | // Ping checks if the remote Gordon instance is reachable. | |||
| 318 | + | func (c *Client) Ping(ctx context.Context) error { | |||
| 319 | + | _, err := c.GetStatus(ctx) | |||
| 320 | + | return err | |||
| 321 | + | } | |||
A internal/adapters/in/cli/remote/config.go +270 -0
| @@ -0,0 +1,270 @@ | |||||
| 1 | + | package remote | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "os" | |||
| 6 | + | "path/filepath" | |||
| 7 | + | ||||
| 8 | + | "github.com/pelletier/go-toml/v2" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // ClientConfig represents the client-mode configuration. | |||
| 12 | + | type ClientConfig struct { | |||
| 13 | + | Client ClientSettings `toml:"client"` | |||
| 14 | + | Remotes map[string]RemoteEntry `toml:"remotes"` | |||
| 15 | + | Active string `toml:"active"` // Active remote name | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | // ClientSettings represents the [client] section. | |||
| 19 | + | type ClientSettings struct { | |||
| 20 | + | Mode string `toml:"mode"` // "local" (default) or "remote" | |||
| 21 | + | Remote string `toml:"remote"` // Remote Gordon URL | |||
| 22 | + | Token string `toml:"token"` // Auth token | |||
| 23 | + | TokenEnv string `toml:"token_env"` // Env var name for token | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | // RemoteEntry represents a saved remote in [remotes.*]. | |||
| 27 | + | type RemoteEntry struct { | |||
| 28 | + | URL string `toml:"url"` | |||
| 29 | + | Token string `toml:"token,omitempty"` | |||
| 30 | + | TokenEnv string `toml:"token_env,omitempty"` | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | // DefaultClientConfigPath returns the default client config path. | |||
| 34 | + | func DefaultClientConfigPath() string { | |||
| 35 | + | configDir, err := os.UserConfigDir() | |||
| 36 | + | if err != nil { | |||
| 37 | + | configDir = os.Getenv("HOME") | |||
| 38 | + | } | |||
| 39 | + | return filepath.Join(configDir, "gordon", "gordon.toml") | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // DefaultRemotesPath returns the default remotes config path. | |||
| 43 | + | func DefaultRemotesPath() string { | |||
| 44 | + | configDir, err := os.UserConfigDir() | |||
| 45 | + | if err != nil { | |||
| 46 | + | configDir = os.Getenv("HOME") | |||
| 47 | + | } | |||
| 48 | + | return filepath.Join(configDir, "gordon", "remotes.toml") | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // LoadClientConfig loads the client configuration. | |||
| 52 | + | func LoadClientConfig(path string) (*ClientConfig, error) { | |||
| 53 | + | if path == "" { | |||
| 54 | + | path = DefaultClientConfigPath() | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | data, err := os.ReadFile(path) | |||
| 58 | + | if err != nil { | |||
| 59 | + | if os.IsNotExist(err) { | |||
| 60 | + | // Return default config | |||
| 61 | + | return &ClientConfig{ | |||
| 62 | + | Client: ClientSettings{ | |||
| 63 | + | Mode: "local", | |||
| 64 | + | }, | |||
| 65 | + | Remotes: make(map[string]RemoteEntry), | |||
| 66 | + | }, nil | |||
| 67 | + | } | |||
| 68 | + | return nil, fmt.Errorf("failed to read config: %w", err) | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | var config ClientConfig | |||
| 72 | + | if err := toml.Unmarshal(data, &config); err != nil { | |||
| 73 | + | return nil, fmt.Errorf("failed to parse config: %w", err) | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | if config.Remotes == nil { | |||
| 77 | + | config.Remotes = make(map[string]RemoteEntry) | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | return &config, nil | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | // LoadRemotes loads the remotes configuration. | |||
| 84 | + | func LoadRemotes(path string) (*ClientConfig, error) { | |||
| 85 | + | if path == "" { | |||
| 86 | + | path = DefaultRemotesPath() | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | data, err := os.ReadFile(path) | |||
| 90 | + | if err != nil { | |||
| 91 | + | if os.IsNotExist(err) { | |||
| 92 | + | return &ClientConfig{ | |||
| 93 | + | Remotes: make(map[string]RemoteEntry), | |||
| 94 | + | }, nil | |||
| 95 | + | } | |||
| 96 | + | return nil, fmt.Errorf("failed to read remotes: %w", err) | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | var config ClientConfig | |||
| 100 | + | if err := toml.Unmarshal(data, &config); err != nil { | |||
| 101 | + | return nil, fmt.Errorf("failed to parse remotes: %w", err) | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | if config.Remotes == nil { | |||
| 105 | + | config.Remotes = make(map[string]RemoteEntry) | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | return &config, nil | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | // SaveRemotes saves the remotes configuration. | |||
| 112 | + | func SaveRemotes(path string, config *ClientConfig) error { | |||
| 113 | + | if path == "" { | |||
| 114 | + | path = DefaultRemotesPath() | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | // Ensure directory exists | |||
| 118 | + | dir := filepath.Dir(path) | |||
| 119 | + | if err := os.MkdirAll(dir, 0700); err != nil { | |||
| 120 | + | return fmt.Errorf("failed to create config directory: %w", err) | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | data, err := toml.Marshal(config) | |||
| 124 | + | if err != nil { | |||
| 125 | + | return fmt.Errorf("failed to marshal remotes: %w", err) | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | if err := os.WriteFile(path, data, 0600); err != nil { | |||
| 129 | + | return fmt.Errorf("failed to write remotes: %w", err) | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | return nil | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | // ResolveRemote resolves the remote URL and token from configuration. | |||
| 136 | + | // Precedence: flag > env > config > active remote | |||
| 137 | + | func ResolveRemote(flagRemote, flagToken string) (url, token string, isRemote bool) { | |||
| 138 | + | config, _ := LoadClientConfig("") | |||
| 139 | + | remotes, _ := LoadRemotes("") | |||
| 140 | + | ||||
| 141 | + | url, isRemote = resolveRemoteURL(flagRemote, config, remotes) | |||
| 142 | + | if isRemote { | |||
| 143 | + | token = resolveToken(flagToken, config, remotes) | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | return url, token, isRemote | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | // resolveRemoteURL resolves the remote URL from various sources. | |||
| 150 | + | func resolveRemoteURL(flagRemote string, config *ClientConfig, remotes *ClientConfig) (string, bool) { | |||
| 151 | + | // 1. Check flag | |||
| 152 | + | if flagRemote != "" { | |||
| 153 | + | return flagRemote, true | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | // 2. Check environment variable | |||
| 157 | + | if envRemote := os.Getenv("GORDON_REMOTE"); envRemote != "" { | |||
| 158 | + | return envRemote, true | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | // 3. Check client config | |||
| 162 | + | if config != nil && config.Client.Mode == "remote" && config.Client.Remote != "" { | |||
| 163 | + | return config.Client.Remote, true | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | // 4. Check active remote | |||
| 167 | + | if remotes != nil && remotes.Active != "" { | |||
| 168 | + | if remote, ok := remotes.Remotes[remotes.Active]; ok { | |||
| 169 | + | return remote.URL, true | |||
| 170 | + | } | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | return "", false | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | // resolveToken resolves the authentication token from various sources. | |||
| 177 | + | func resolveToken(flagToken string, config *ClientConfig, remotes *ClientConfig) string { | |||
| 178 | + | // 1. Flag token takes precedence | |||
| 179 | + | if flagToken != "" { | |||
| 180 | + | return flagToken | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | // 2. Environment variable | |||
| 184 | + | if envToken := os.Getenv("GORDON_TOKEN"); envToken != "" { | |||
| 185 | + | return envToken | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | // 3. Config token | |||
| 189 | + | if config != nil { | |||
| 190 | + | if config.Client.Token != "" { | |||
| 191 | + | return config.Client.Token | |||
| 192 | + | } | |||
| 193 | + | if config.Client.TokenEnv != "" { | |||
| 194 | + | return os.Getenv(config.Client.TokenEnv) | |||
| 195 | + | } | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | // 4. Active remote token | |||
| 199 | + | if remotes != nil && remotes.Active != "" { | |||
| 200 | + | if remote, ok := remotes.Remotes[remotes.Active]; ok { | |||
| 201 | + | if remote.Token != "" { | |||
| 202 | + | return remote.Token | |||
| 203 | + | } | |||
| 204 | + | if remote.TokenEnv != "" { | |||
| 205 | + | return os.Getenv(remote.TokenEnv) | |||
| 206 | + | } | |||
| 207 | + | } | |||
| 208 | + | } | |||
| 209 | + | ||||
| 210 | + | return "" | |||
| 211 | + | } | |||
| 212 | + | ||||
| 213 | + | // AddRemote adds a new remote to the remotes configuration. | |||
| 214 | + | func AddRemote(name, url, token string) error { | |||
| 215 | + | config, err := LoadRemotes("") | |||
| 216 | + | if err != nil { | |||
| 217 | + | return err | |||
| 218 | + | } | |||
| 219 | + | ||||
| 220 | + | config.Remotes[name] = RemoteEntry{ | |||
| 221 | + | URL: url, | |||
| 222 | + | Token: token, | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | return SaveRemotes("", config) | |||
| 226 | + | } | |||
| 227 | + | ||||
| 228 | + | // RemoveRemote removes a remote from the remotes configuration. | |||
| 229 | + | func RemoveRemote(name string) error { | |||
| 230 | + | config, err := LoadRemotes("") | |||
| 231 | + | if err != nil { | |||
| 232 | + | return err | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | delete(config.Remotes, name) | |||
| 236 | + | ||||
| 237 | + | // Clear active if it was the removed remote | |||
| 238 | + | if config.Active == name { | |||
| 239 | + | config.Active = "" | |||
| 240 | + | } | |||
| 241 | + | ||||
| 242 | + | return SaveRemotes("", config) | |||
| 243 | + | } | |||
| 244 | + | ||||
| 245 | + | // SetActiveRemote sets the active remote. | |||
| 246 | + | func SetActiveRemote(name string) error { | |||
| 247 | + | config, err := LoadRemotes("") | |||
| 248 | + | if err != nil { | |||
| 249 | + | return err | |||
| 250 | + | } | |||
| 251 | + | ||||
| 252 | + | // Verify remote exists | |||
| 253 | + | if _, ok := config.Remotes[name]; !ok { | |||
| 254 | + | return fmt.Errorf("remote '%s' not found", name) | |||
| 255 | + | } | |||
| 256 | + | ||||
| 257 | + | config.Active = name | |||
| 258 | + | ||||
| 259 | + | return SaveRemotes("", config) | |||
| 260 | + | } | |||
| 261 | + | ||||
| 262 | + | // ListRemotes returns all saved remotes. | |||
| 263 | + | func ListRemotes() (map[string]RemoteEntry, string, error) { | |||
| 264 | + | config, err := LoadRemotes("") | |||
| 265 | + | if err != nil { | |||
| 266 | + | return nil, "", err | |||
| 267 | + | } | |||
| 268 | + | ||||
| 269 | + | return config.Remotes, config.Active, nil | |||
| 270 | + | } | |||
A internal/adapters/in/cli/remotes.go +246 -0
| @@ -0,0 +1,246 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | ||||
| 6 | + | "gordon/internal/adapters/in/cli/remote" | |||
| 7 | + | "gordon/internal/adapters/in/cli/ui/components" | |||
| 8 | + | "gordon/internal/adapters/in/cli/ui/styles" | |||
| 9 | + | ||||
| 10 | + | "github.com/spf13/cobra" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // newRemotesCmd creates the remotes command group. | |||
| 14 | + | func newRemotesCmd() *cobra.Command { | |||
| 15 | + | cmd := &cobra.Command{ | |||
| 16 | + | Use: "remotes", | |||
| 17 | + | Short: "Manage saved remote Gordon instances", | |||
| 18 | + | Long: `Manage saved remote Gordon instances. | |||
| 19 | + | ||||
| 20 | + | Remotes allow you to save frequently used remote Gordon instances | |||
| 21 | + | and quickly switch between them. | |||
| 22 | + | ||||
| 23 | + | Configuration is stored in ~/.config/gordon/remotes.toml`, | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | cmd.AddCommand(newRemotesListCmd()) | |||
| 27 | + | cmd.AddCommand(newRemotesAddCmd()) | |||
| 28 | + | cmd.AddCommand(newRemotesRemoveCmd()) | |||
| 29 | + | cmd.AddCommand(newRemotesUseCmd()) | |||
| 30 | + | ||||
| 31 | + | return cmd | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | // newRemotesListCmd creates the remotes list command. | |||
| 35 | + | func newRemotesListCmd() *cobra.Command { | |||
| 36 | + | return &cobra.Command{ | |||
| 37 | + | Use: "list", | |||
| 38 | + | Short: "List saved remotes", | |||
| 39 | + | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 40 | + | remotes, active, err := remote.ListRemotes() | |||
| 41 | + | if err != nil { | |||
| 42 | + | return fmt.Errorf("failed to list remotes: %w", err) | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | if len(remotes) == 0 { | |||
| 46 | + | fmt.Println(styles.Theme.Muted.Render("No remotes configured")) | |||
| 47 | + | fmt.Println() | |||
| 48 | + | fmt.Println("Add a remote with:") | |||
| 49 | + | fmt.Println(styles.Theme.Bold.Render(" gordon remotes add <name> <url>")) | |||
| 50 | + | return nil | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | fmt.Println(styles.Theme.Title.Render("Saved Remotes")) | |||
| 54 | + | fmt.Println() | |||
| 55 | + | ||||
| 56 | + | // Build table rows | |||
| 57 | + | rows := make([][]string, 0, len(remotes)) | |||
| 58 | + | for name, r := range remotes { | |||
| 59 | + | status := "" | |||
| 60 | + | if name == active { | |||
| 61 | + | status = styles.Theme.Success.Render("active") | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | // Mask token if present | |||
| 65 | + | tokenStatus := styles.Theme.Muted.Render("none") | |||
| 66 | + | if r.Token != "" { | |||
| 67 | + | tokenStatus = styles.Theme.Success.Render("set") | |||
| 68 | + | } else if r.TokenEnv != "" { | |||
| 69 | + | tokenStatus = fmt.Sprintf("$%s", r.TokenEnv) | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | rows = append(rows, []string{name, r.URL, tokenStatus, status}) | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // Render table | |||
| 76 | + | table := components.NewTable( | |||
| 77 | + | components.WithColumns([]components.TableColumn{ | |||
| 78 | + | {Title: "Name", Width: 15}, | |||
| 79 | + | {Title: "URL", Width: 35}, | |||
| 80 | + | {Title: "Token", Width: 15}, | |||
| 81 | + | {Title: "Status", Width: 10}, | |||
| 82 | + | }), | |||
| 83 | + | components.WithRows(rows), | |||
| 84 | + | ) | |||
| 85 | + | ||||
| 86 | + | fmt.Println(table.View()) | |||
| 87 | + | ||||
| 88 | + | return nil | |||
| 89 | + | }, | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | // newRemotesAddCmd creates the remotes add command. | |||
| 94 | + | func newRemotesAddCmd() *cobra.Command { | |||
| 95 | + | var token string | |||
| 96 | + | var tokenEnv string | |||
| 97 | + | ||||
| 98 | + | cmd := &cobra.Command{ | |||
| 99 | + | Use: "add <name> <url>", | |||
| 100 | + | Short: "Add a new remote", | |||
| 101 | + | Long: `Add a new saved remote. | |||
| 102 | + | ||||
| 103 | + | Examples: | |||
| 104 | + | gordon remotes add prod https://gordon.mydomain.com | |||
| 105 | + | gordon remotes add prod https://gordon.mydomain.com --token eyJ... | |||
| 106 | + | gordon remotes add staging https://staging.mydomain.com --token-env STAGING_TOKEN`, | |||
| 107 | + | Args: cobra.ExactArgs(2), | |||
| 108 | + | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 109 | + | name := args[0] | |||
| 110 | + | url := args[1] | |||
| 111 | + | ||||
| 112 | + | // If token-env is provided, use that instead of token | |||
| 113 | + | finalToken := token | |||
| 114 | + | if tokenEnv != "" { | |||
| 115 | + | // Store token_env reference instead of actual token | |||
| 116 | + | if err := addRemoteWithEnv(name, url, tokenEnv); err != nil { | |||
| 117 | + | return fmt.Errorf("failed to add remote: %w", err) | |||
| 118 | + | } | |||
| 119 | + | } else { | |||
| 120 | + | if err := remote.AddRemote(name, url, finalToken); err != nil { | |||
| 121 | + | return fmt.Errorf("failed to add remote: %w", err) | |||
| 122 | + | } | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Remote added: %s -> %s", name, url))) | |||
| 126 | + | ||||
| 127 | + | if token == "" && tokenEnv == "" { | |||
| 128 | + | fmt.Println(styles.Theme.Muted.Render("Tip: Add a token with --token or --token-env for authenticated access")) | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | return nil | |||
| 132 | + | }, | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | cmd.Flags().StringVar(&token, "token", "", "Authentication token") | |||
| 136 | + | cmd.Flags().StringVar(&tokenEnv, "token-env", "", "Environment variable containing token") | |||
| 137 | + | ||||
| 138 | + | return cmd | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | // addRemoteWithEnv adds a remote with a token environment variable reference. | |||
| 142 | + | func addRemoteWithEnv(name, url, tokenEnv string) error { | |||
| 143 | + | config, err := remote.LoadRemotes("") | |||
| 144 | + | if err != nil { | |||
| 145 | + | return err | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | config.Remotes[name] = remote.RemoteEntry{ | |||
| 149 | + | URL: url, | |||
| 150 | + | TokenEnv: tokenEnv, | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | return remote.SaveRemotes("", config) | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | // newRemotesRemoveCmd creates the remotes remove command. | |||
| 157 | + | func newRemotesRemoveCmd() *cobra.Command { | |||
| 158 | + | var force bool | |||
| 159 | + | ||||
| 160 | + | cmd := &cobra.Command{ | |||
| 161 | + | Use: "remove <name>", | |||
| 162 | + | Aliases: []string{"rm"}, | |||
| 163 | + | Short: "Remove a remote", | |||
| 164 | + | Args: cobra.ExactArgs(1), | |||
| 165 | + | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 166 | + | name := args[0] | |||
| 167 | + | ||||
| 168 | + | // Check if remote exists | |||
| 169 | + | remotes, active, err := remote.ListRemotes() | |||
| 170 | + | if err != nil { | |||
| 171 | + | return fmt.Errorf("failed to list remotes: %w", err) | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | if _, exists := remotes[name]; !exists { | |||
| 175 | + | fmt.Println(styles.RenderError(fmt.Sprintf("Remote '%s' not found", name))) | |||
| 176 | + | return nil | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | // Confirm unless --force | |||
| 180 | + | if !force { | |||
| 181 | + | message := fmt.Sprintf("Remove remote '%s'?", name) | |||
| 182 | + | if name == active { | |||
| 183 | + | message = fmt.Sprintf("Remove remote '%s'? (currently active)", name) | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | confirmed, err := components.RunConfirm(message) | |||
| 187 | + | if err != nil { | |||
| 188 | + | return err | |||
| 189 | + | } | |||
| 190 | + | if !confirmed { | |||
| 191 | + | fmt.Println(styles.Theme.Muted.Render("Cancelled")) | |||
| 192 | + | return nil | |||
| 193 | + | } | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | if err := remote.RemoveRemote(name); err != nil { | |||
| 197 | + | return fmt.Errorf("failed to remove remote: %w", err) | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Remote removed: %s", name))) | |||
| 201 | + | return nil | |||
| 202 | + | }, | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation") | |||
| 206 | + | ||||
| 207 | + | return cmd | |||
| 208 | + | } | |||
| 209 | + | ||||
| 210 | + | // newRemotesUseCmd creates the remotes use command. | |||
| 211 | + | func newRemotesUseCmd() *cobra.Command { | |||
| 212 | + | return &cobra.Command{ | |||
| 213 | + | Use: "use <name>", | |||
| 214 | + | Short: "Set the active remote", | |||
| 215 | + | Long: `Set a remote as the active default. | |||
| 216 | + | ||||
| 217 | + | When a remote is active, it will be used automatically for remote commands | |||
| 218 | + | without needing to specify --remote. | |||
| 219 | + | ||||
| 220 | + | Examples: | |||
| 221 | + | gordon remotes use prod | |||
| 222 | + | gordon routes list # Uses prod remote automatically`, | |||
| 223 | + | Args: cobra.ExactArgs(1), | |||
| 224 | + | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 225 | + | name := args[0] | |||
| 226 | + | ||||
| 227 | + | if err := remote.SetActiveRemote(name); err != nil { | |||
| 228 | + | if err.Error() == fmt.Sprintf("remote '%s' not found", name) { | |||
| 229 | + | fmt.Println(styles.RenderError(fmt.Sprintf("Remote '%s' not found", name))) | |||
| 230 | + | fmt.Println() | |||
| 231 | + | fmt.Println("Available remotes:") | |||
| 232 | + | ||||
| 233 | + | remotes, _, _ := remote.ListRemotes() | |||
| 234 | + | for remoteName := range remotes { | |||
| 235 | + | fmt.Printf(" %s\n", remoteName) | |||
| 236 | + | } | |||
| 237 | + | return nil | |||
| 238 | + | } | |||
| 239 | + | return fmt.Errorf("failed to set active remote: %w", err) | |||
| 240 | + | } | |||
| 241 | + | ||||
| 242 | + | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Active remote set to '%s'", name))) | |||
| 243 | + | return nil | |||
| 244 | + | }, | |||
| 245 | + | } | |||
| 246 | + | } | |||
M internal/adapters/in/cli/root.go +35 -19
| @@ -3,8 +3,7 @@ | |||||
| 3 | package cli | 3 | package cli | ||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | - | "context" | 6 | + | "gordon/internal/adapters/in/cli/remote" |
| 7 | - | ||||
| 8 | "gordon/internal/app" | 7 | "gordon/internal/app" | ||
| 9 | 8 | ||||
| 10 | "github.com/spf13/cobra" | 9 | "github.com/spf13/cobra" | ||
| @@ -15,6 +14,10 @@ var ( | |||||
| 15 | Version = "dev" | 14 | Version = "dev" | ||
| 16 | Commit = "unknown" | 15 | Commit = "unknown" | ||
| 17 | BuildDate = "unknown" | 16 | BuildDate = "unknown" | ||
| 17 | + | ||||
| 18 | + | // Global flags for remote targeting | |||
| 19 | + | remoteFlag string | |||
| 20 | + | tokenFlag string | |||
| 18 | ) | 21 | ) | ||
| 19 | 22 | ||||
| 20 | // NewRootCmd creates the root command for Gordon CLI. | 23 | // NewRootCmd creates the root command for Gordon CLI. | ||
| @@ -26,37 +29,50 @@ func NewRootCmd() *cobra.Command { | |||||
| 26 | a Docker registry with automatic container deployment capabilities. | 29 | a Docker registry with automatic container deployment capabilities. | ||
| 27 | 30 | ||||
| 28 | It listens for image pushes and automatically deploys containers based on | 31 | It listens for image pushes and automatically deploys containers based on | ||
| 29 | - | configuration rules, making it ideal for single-server deployments.`, | 32 | + | configuration rules, making it ideal for single-server deployments. |
| 33 | + | ||||
| 34 | + | The CLI can target remote Gordon instances using the --remote flag or | |||
| 35 | + | GORDON_REMOTE environment variable.`, | |||
| 30 | } | 36 | } | ||
| 31 | 37 | ||||
| 32 | - | // Add subcommands | 38 | + | // Add persistent flags for remote targeting |
| 33 | - | rootCmd.AddCommand(newStartCmd()) | 39 | + | rootCmd.PersistentFlags().StringVar(&remoteFlag, "remote", "", "Remote Gordon URL (e.g., https://gordon.mydomain.com)") |
| 40 | + | rootCmd.PersistentFlags().StringVar(&tokenFlag, "token", "", "Authentication token for remote") | |||
| 41 | + | ||||
| 42 | + | // Server commands | |||
| 43 | + | rootCmd.AddCommand(newServeCmd()) | |||
| 44 | + | rootCmd.AddCommand(newStartCmd()) // Deprecated alias for serve | |||
| 34 | rootCmd.AddCommand(newReloadCmd()) | 45 | rootCmd.AddCommand(newReloadCmd()) | ||
| 35 | rootCmd.AddCommand(newDeployCmd()) | 46 | rootCmd.AddCommand(newDeployCmd()) | ||
| 36 | rootCmd.AddCommand(newLogsCmd()) | 47 | rootCmd.AddCommand(newLogsCmd()) | ||
| 37 | rootCmd.AddCommand(newVersionCmd()) | 48 | rootCmd.AddCommand(newVersionCmd()) | ||
| 38 | rootCmd.AddCommand(newAuthCmd()) | 49 | rootCmd.AddCommand(newAuthCmd()) | ||
| 39 | 50 | ||||
| 51 | + | // Remote management commands | |||
| 52 | + | rootCmd.AddCommand(newRoutesCmd()) | |||
| 53 | + | rootCmd.AddCommand(newSecretsCmd()) | |||
| 54 | + | rootCmd.AddCommand(newRemotesCmd()) | |||
| 55 | + | rootCmd.AddCommand(newStatusCmd()) | |||
| 56 | + | ||||
| 40 | return rootCmd | 57 | return rootCmd | ||
| 41 | } | 58 | } | ||
| 42 | 59 | ||||
| 43 | - | // newStartCmd creates the start command. | 60 | + | // GetRemoteClient returns a remote client if targeting a remote instance, |
| 44 | - | func newStartCmd() *cobra.Command { | 61 | + | // or nil if running locally. |
| 45 | - | var configPath string | 62 | + | func GetRemoteClient() (*remote.Client, bool) { |
| 46 | - | 63 | + | url, token, isRemote := remote.ResolveRemote(remoteFlag, tokenFlag) | |
| 47 | - | cmd := &cobra.Command{ | 64 | + | if !isRemote { |
| 48 | - | Use: "start", | 65 | + | return nil, false |
| 49 | - | Short: "Start the Gordon server", | |||
| 50 | - | Long: `Start the Gordon server, including the registry and proxy components.`, | |||
| 51 | - | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 52 | - | return app.Run(context.Background(), configPath) | |||
| 53 | - | }, | |||
| 54 | } | 66 | } | ||
| 55 | 67 | ||||
| 56 | - | // Add flags | 68 | + | client := remote.NewClient(url, remote.WithToken(token)) |
| 57 | - | cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file") | 69 | + | return client, true |
| 70 | + | } | |||
| 58 | 71 | ||||
| 59 | - | return cmd | 72 | + | // IsRemoteMode returns true if CLI is targeting a remote Gordon instance. |
| 73 | + | func IsRemoteMode() bool { | |||
| 74 | + | _, _, isRemote := remote.ResolveRemote(remoteFlag, tokenFlag) | |||
| 75 | + | return isRemote | |||
| 60 | } | 76 | } | ||
| 61 | 77 | ||||
| 62 | // newReloadCmd creates the reload command. | 78 | // newReloadCmd creates the reload command. | ||
A internal/adapters/in/cli/routes.go +300 -0
| @@ -0,0 +1,300 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "os" | |||
| 7 | + | "strings" | |||
| 8 | + | ||||
| 9 | + | "gordon/internal/adapters/in/cli/remote" | |||
| 10 | + | "gordon/internal/adapters/in/cli/ui/components" | |||
| 11 | + | "gordon/internal/adapters/in/cli/ui/styles" | |||
| 12 | + | "gordon/internal/domain" | |||
| 13 | + | ||||
| 14 | + | "github.com/spf13/cobra" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // truncateImage shortens long image references for display. | |||
| 18 | + | // For digests (image@sha256:...), shows first 12 chars of digest. | |||
| 19 | + | // For tags, truncates to maxLen with ellipsis if needed. | |||
| 20 | + | func truncateImage(image string, maxLen int) string { | |||
| 21 | + | // Handle digest references: image@sha256:abc123... | |||
| 22 | + | if idx := strings.Index(image, "@sha256:"); idx != -1 { | |||
| 23 | + | name := image[:idx] | |||
| 24 | + | digest := image[idx+8:] // Skip "@sha256:" | |||
| 25 | + | if len(digest) > 12 { | |||
| 26 | + | digest = digest[:12] | |||
| 27 | + | } | |||
| 28 | + | short := fmt.Sprintf("%s@sha256:%s…", name, digest) | |||
| 29 | + | if len(short) > maxLen { | |||
| 30 | + | // Truncate name if still too long | |||
| 31 | + | available := maxLen - len("@sha256:") - 12 - 1 // 1 for ellipsis | |||
| 32 | + | if available > 3 { | |||
| 33 | + | name = name[:available-1] + "…" | |||
| 34 | + | } | |||
| 35 | + | short = fmt.Sprintf("%s@sha256:%s…", name, digest) | |||
| 36 | + | } | |||
| 37 | + | return short | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | // Regular tag: truncate if needed | |||
| 41 | + | if len(image) > maxLen { | |||
| 42 | + | return image[:maxLen-1] + "…" | |||
| 43 | + | } | |||
| 44 | + | return image | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | // newRoutesCmd creates the routes command group. | |||
| 48 | + | func newRoutesCmd() *cobra.Command { | |||
| 49 | + | cmd := &cobra.Command{ | |||
| 50 | + | Use: "routes", | |||
| 51 | + | Short: "Manage routes", | |||
| 52 | + | Long: `Manage Gordon routes. Routes map domains to container images. | |||
| 53 | + | ||||
| 54 | + | When targeting a remote Gordon instance (via --remote flag or GORDON_REMOTE env var), | |||
| 55 | + | these commands operate on the remote server. Otherwise, they require access to | |||
| 56 | + | the local Gordon configuration.`, | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | cmd.AddCommand(newRoutesListCmd()) | |||
| 60 | + | cmd.AddCommand(newRoutesAddCmd()) | |||
| 61 | + | cmd.AddCommand(newRoutesRemoveCmd()) | |||
| 62 | + | ||||
| 63 | + | return cmd | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | // formatHTTPStatus formats the HTTP status for display. | |||
| 67 | + | func formatHTTPStatus(health *remote.RouteHealth) string { | |||
| 68 | + | if health == nil { | |||
| 69 | + | return styles.Theme.Muted.Render("-") | |||
| 70 | + | } | |||
| 71 | + | if health.HTTPStatus == 0 { | |||
| 72 | + | if health.Error != "" { | |||
| 73 | + | return styles.Theme.BadgeError.Render("err") | |||
| 74 | + | } | |||
| 75 | + | return styles.Theme.Muted.Render("-") | |||
| 76 | + | } | |||
| 77 | + | status := fmt.Sprintf("%d", health.HTTPStatus) | |||
| 78 | + | if health.ResponseTimeMs > 0 { | |||
| 79 | + | status = fmt.Sprintf("%d (%dms)", health.HTTPStatus, health.ResponseTimeMs) | |||
| 80 | + | } | |||
| 81 | + | if health.HTTPStatus >= 200 && health.HTTPStatus < 400 { | |||
| 82 | + | return styles.Theme.BadgeSuccess.Render(status) | |||
| 83 | + | } | |||
| 84 | + | return styles.Theme.BadgeError.Render(status) | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | // newRoutesListCmd creates the routes list command. | |||
| 88 | + | func newRoutesListCmd() *cobra.Command { | |||
| 89 | + | return &cobra.Command{ | |||
| 90 | + | Use: "list", | |||
| 91 | + | Short: "List all routes", | |||
| 92 | + | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 93 | + | ctx := context.Background() | |||
| 94 | + | ||||
| 95 | + | client, isRemote := GetRemoteClient() | |||
| 96 | + | if !isRemote { | |||
| 97 | + | fmt.Println(styles.RenderError("routes command requires --remote flag or GORDON_REMOTE env var")) | |||
| 98 | + | fmt.Println(styles.Theme.Muted.Render("Local route management is not yet supported. Use the config file directly.")) | |||
| 99 | + | return nil | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | routes, err := client.ListRoutes(ctx) | |||
| 103 | + | if err != nil { | |||
| 104 | + | return fmt.Errorf("failed to list routes: %w", err) | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | if len(routes) == 0 { | |||
| 108 | + | fmt.Println(styles.Theme.Muted.Render("No routes configured")) | |||
| 109 | + | return nil | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | // Get health status for each route (includes container status and HTTP probe) | |||
| 113 | + | health, _ := client.GetHealth(ctx) | |||
| 114 | + | if health == nil { | |||
| 115 | + | health = make(map[string]*remote.RouteHealth) | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | // Build table rows | |||
| 119 | + | const imageColWidth = 35 | |||
| 120 | + | rows := make([][]string, len(routes)) | |||
| 121 | + | for i, route := range routes { | |||
| 122 | + | routeHealth := health[route.Domain] | |||
| 123 | + | ||||
| 124 | + | // Container status column | |||
| 125 | + | containerStatus := "unknown" | |||
| 126 | + | if routeHealth != nil { | |||
| 127 | + | containerStatus = routeHealth.ContainerStatus | |||
| 128 | + | } | |||
| 129 | + | containerBadge := components.ContainerStatusBadge(containerStatus) | |||
| 130 | + | ||||
| 131 | + | // HTTP status column | |||
| 132 | + | httpStatus := formatHTTPStatus(routeHealth) | |||
| 133 | + | ||||
| 134 | + | displayImage := truncateImage(route.Image, imageColWidth) | |||
| 135 | + | rows[i] = []string{route.Domain, displayImage, containerBadge, httpStatus} | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | // Render table | |||
| 139 | + | table := components.NewTable( | |||
| 140 | + | components.WithColumns([]components.TableColumn{ | |||
| 141 | + | {Title: "Domain", Width: 25}, | |||
| 142 | + | {Title: "Image", Width: imageColWidth}, | |||
| 143 | + | {Title: "Container", Width: 12}, | |||
| 144 | + | {Title: "HTTP", Width: 14}, | |||
| 145 | + | }), | |||
| 146 | + | components.WithRows(rows), | |||
| 147 | + | ) | |||
| 148 | + | ||||
| 149 | + | fmt.Println(styles.Theme.Title.Render("Routes")) | |||
| 150 | + | fmt.Println() | |||
| 151 | + | fmt.Println(table.View()) | |||
| 152 | + | ||||
| 153 | + | return nil | |||
| 154 | + | }, | |||
| 155 | + | } | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | // newRoutesAddCmd creates the routes add command. | |||
| 159 | + | func newRoutesAddCmd() *cobra.Command { | |||
| 160 | + | var image string | |||
| 161 | + | ||||
| 162 | + | cmd := &cobra.Command{ | |||
| 163 | + | Use: "add <domain>", | |||
| 164 | + | Short: "Add a new route", | |||
| 165 | + | Long: `Add a new route mapping a domain to a container image. | |||
| 166 | + | ||||
| 167 | + | Examples: | |||
| 168 | + | gordon routes add app.mydomain.com --image myapp:latest | |||
| 169 | + | gordon --remote https://gordon.mydomain.com routes add api.mydomain.com --image api:v2`, | |||
| 170 | + | Args: cobra.ExactArgs(1), | |||
| 171 | + | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 172 | + | ctx := context.Background() | |||
| 173 | + | routeDomain := args[0] | |||
| 174 | + | ||||
| 175 | + | client, isRemote := GetRemoteClient() | |||
| 176 | + | if !isRemote { | |||
| 177 | + | fmt.Println(styles.RenderError("routes command requires --remote flag or GORDON_REMOTE env var")) | |||
| 178 | + | return nil | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | if image == "" { | |||
| 182 | + | fmt.Println(styles.RenderError("--image flag is required")) | |||
| 183 | + | return nil | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | route := domain.Route{ | |||
| 187 | + | Domain: routeDomain, | |||
| 188 | + | Image: image, | |||
| 189 | + | } | |||
| 190 | + | ||||
| 191 | + | if err := client.AddRoute(ctx, route); err != nil { | |||
| 192 | + | return fmt.Errorf("failed to add route: %w", err) | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Route added: %s -> %s", routeDomain, image))) | |||
| 196 | + | return nil | |||
| 197 | + | }, | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | cmd.Flags().StringVarP(&image, "image", "i", "", "Container image (required)") | |||
| 201 | + | _ = cmd.MarkFlagRequired("image") | |||
| 202 | + | ||||
| 203 | + | return cmd | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | // newRoutesRemoveCmd creates the routes remove command. | |||
| 207 | + | func newRoutesRemoveCmd() *cobra.Command { | |||
| 208 | + | var force bool | |||
| 209 | + | ||||
| 210 | + | cmd := &cobra.Command{ | |||
| 211 | + | Use: "remove <domain>", | |||
| 212 | + | Short: "Remove a route", | |||
| 213 | + | Long: `Remove a route by its domain name. | |||
| 214 | + | ||||
| 215 | + | Examples: | |||
| 216 | + | gordon routes remove app.mydomain.com | |||
| 217 | + | gordon routes remove app.mydomain.com --force`, | |||
| 218 | + | Args: cobra.ExactArgs(1), | |||
| 219 | + | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 220 | + | ctx := context.Background() | |||
| 221 | + | routeDomain := args[0] | |||
| 222 | + | ||||
| 223 | + | client, isRemote := GetRemoteClient() | |||
| 224 | + | if !isRemote { | |||
| 225 | + | fmt.Println(styles.RenderError("routes command requires --remote flag or GORDON_REMOTE env var")) | |||
| 226 | + | return nil | |||
| 227 | + | } | |||
| 228 | + | ||||
| 229 | + | // Confirm unless --force | |||
| 230 | + | if !force { | |||
| 231 | + | confirmed, err := components.RunConfirm( | |||
| 232 | + | fmt.Sprintf("Remove route '%s'?", routeDomain), | |||
| 233 | + | components.WithDescription("This will stop and remove the associated container."), | |||
| 234 | + | ) | |||
| 235 | + | if err != nil { | |||
| 236 | + | return err | |||
| 237 | + | } | |||
| 238 | + | if !confirmed { | |||
| 239 | + | fmt.Println(styles.Theme.Muted.Render("Cancelled")) | |||
| 240 | + | return nil | |||
| 241 | + | } | |||
| 242 | + | } | |||
| 243 | + | ||||
| 244 | + | if err := client.RemoveRoute(ctx, routeDomain); err != nil { | |||
| 245 | + | return fmt.Errorf("failed to remove route: %w", err) | |||
| 246 | + | } | |||
| 247 | + | ||||
| 248 | + | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Route removed: %s", routeDomain))) | |||
| 249 | + | return nil | |||
| 250 | + | }, | |||
| 251 | + | } | |||
| 252 | + | ||||
| 253 | + | cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation") | |||
| 254 | + | ||||
| 255 | + | return cmd | |||
| 256 | + | } | |||
| 257 | + | ||||
| 258 | + | // newStatusCmd creates the status command. | |||
| 259 | + | func newStatusCmd() *cobra.Command { | |||
| 260 | + | return &cobra.Command{ | |||
| 261 | + | Use: "status", | |||
| 262 | + | Short: "Show Gordon server status", | |||
| 263 | + | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 264 | + | ctx := context.Background() | |||
| 265 | + | ||||
| 266 | + | client, isRemote := GetRemoteClient() | |||
| 267 | + | if !isRemote { | |||
| 268 | + | fmt.Println(styles.RenderError("status command requires --remote flag or GORDON_REMOTE env var")) | |||
| 269 | + | os.Exit(1) | |||
| 270 | + | } | |||
| 271 | + | ||||
| 272 | + | status, err := client.GetStatus(ctx) | |||
| 273 | + | if err != nil { | |||
| 274 | + | return fmt.Errorf("failed to get status: %w", err) | |||
| 275 | + | } | |||
| 276 | + | ||||
| 277 | + | // Display status | |||
| 278 | + | fmt.Println(styles.Theme.Title.Render("Gordon Status")) | |||
| 279 | + | fmt.Println() | |||
| 280 | + | ||||
| 281 | + | fmt.Printf("%s %s\n", styles.Theme.Bold.Render("Domain:"), status.RegistryDomain) | |||
| 282 | + | fmt.Printf("%s %d\n", styles.Theme.Bold.Render("Registry Port:"), status.RegistryPort) | |||
| 283 | + | fmt.Printf("%s %d\n", styles.Theme.Bold.Render("Server Port:"), status.ServerPort) | |||
| 284 | + | fmt.Printf("%s %d\n", styles.Theme.Bold.Render("Routes:"), status.Routes) | |||
| 285 | + | fmt.Printf("%s %v\n", styles.Theme.Bold.Render("Auto-Route:"), status.AutoRoute) | |||
| 286 | + | fmt.Printf("%s %v\n", styles.Theme.Bold.Render("Network Isolation:"), status.NetworkIsolation) | |||
| 287 | + | ||||
| 288 | + | if len(status.ContainerStatus) > 0 { | |||
| 289 | + | fmt.Println() | |||
| 290 | + | fmt.Println(styles.Theme.Bold.Render("Container Status:")) | |||
| 291 | + | for domain, containerStatus := range status.ContainerStatus { | |||
| 292 | + | badge := components.ContainerStatusBadge(containerStatus) | |||
| 293 | + | fmt.Printf(" %s: %s\n", domain, badge) | |||
| 294 | + | } | |||
| 295 | + | } | |||
| 296 | + | ||||
| 297 | + | return nil | |||
| 298 | + | }, | |||
| 299 | + | } | |||
| 300 | + | } | |||
A internal/adapters/in/cli/secrets.go +196 -0
| @@ -0,0 +1,196 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "strings" | |||
| 7 | + | ||||
| 8 | + | "gordon/internal/adapters/in/cli/ui/components" | |||
| 9 | + | "gordon/internal/adapters/in/cli/ui/styles" | |||
| 10 | + | ||||
| 11 | + | "github.com/spf13/cobra" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // newSecretsCmd creates the secrets command group. | |||
| 15 | + | func newSecretsCmd() *cobra.Command { | |||
| 16 | + | cmd := &cobra.Command{ | |||
| 17 | + | Use: "secrets", | |||
| 18 | + | Short: "Manage secrets", | |||
| 19 | + | Long: `Manage secrets (environment variables) for routes. | |||
| 20 | + | ||||
| 21 | + | Secrets are stored per-domain and injected into containers as environment variables. | |||
| 22 | + | ||||
| 23 | + | When targeting a remote Gordon instance (via --remote flag or GORDON_REMOTE env var), | |||
| 24 | + | these commands operate on the remote server.`, | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | cmd.AddCommand(newSecretsListCmd()) | |||
| 28 | + | cmd.AddCommand(newSecretsSetCmd()) | |||
| 29 | + | cmd.AddCommand(newSecretsRemoveCmd()) | |||
| 30 | + | ||||
| 31 | + | return cmd | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | // newSecretsListCmd creates the secrets list command. | |||
| 35 | + | func newSecretsListCmd() *cobra.Command { | |||
| 36 | + | return &cobra.Command{ | |||
| 37 | + | Use: "list <domain>", | |||
| 38 | + | Short: "List secrets for a domain", | |||
| 39 | + | Long: `List all secret keys configured for a domain. | |||
| 40 | + | ||||
| 41 | + | Note: Only secret keys are shown, not values (for security). | |||
| 42 | + | ||||
| 43 | + | Examples: | |||
| 44 | + | gordon secrets list app.mydomain.com | |||
| 45 | + | gordon --remote https://gordon.mydomain.com secrets list api.mydomain.com`, | |||
| 46 | + | Args: cobra.ExactArgs(1), | |||
| 47 | + | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 48 | + | ctx := context.Background() | |||
| 49 | + | domain := args[0] | |||
| 50 | + | ||||
| 51 | + | client, isRemote := GetRemoteClient() | |||
| 52 | + | if !isRemote { | |||
| 53 | + | fmt.Println(styles.RenderError("secrets command requires --remote flag or GORDON_REMOTE env var")) | |||
| 54 | + | fmt.Println(styles.Theme.Muted.Render("Local secret management is not yet supported.")) | |||
| 55 | + | return nil | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | keys, err := client.ListSecrets(ctx, domain) | |||
| 59 | + | if err != nil { | |||
| 60 | + | return fmt.Errorf("failed to list secrets: %w", err) | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | if len(keys) == 0 { | |||
| 64 | + | fmt.Println(styles.Theme.Muted.Render(fmt.Sprintf("No secrets configured for %s", domain))) | |||
| 65 | + | return nil | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | fmt.Println(styles.Theme.Title.Render(fmt.Sprintf("Secrets for %s", domain))) | |||
| 69 | + | fmt.Println() | |||
| 70 | + | ||||
| 71 | + | // Build table rows | |||
| 72 | + | rows := make([][]string, len(keys)) | |||
| 73 | + | for i, key := range keys { | |||
| 74 | + | rows[i] = []string{key, styles.Theme.Muted.Render("(hidden)")} | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | // Render table | |||
| 78 | + | table := components.NewTable( | |||
| 79 | + | components.WithColumns([]components.TableColumn{ | |||
| 80 | + | {Title: "Key", Width: 30}, | |||
| 81 | + | {Title: "Value", Width: 20}, | |||
| 82 | + | }), | |||
| 83 | + | components.WithRows(rows), | |||
| 84 | + | ) | |||
| 85 | + | ||||
| 86 | + | fmt.Println(table.View()) | |||
| 87 | + | ||||
| 88 | + | return nil | |||
| 89 | + | }, | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | // newSecretsSetCmd creates the secrets set command. | |||
| 94 | + | func newSecretsSetCmd() *cobra.Command { | |||
| 95 | + | return &cobra.Command{ | |||
| 96 | + | Use: "set <domain> <KEY=value>...", | |||
| 97 | + | Short: "Set secrets for a domain", | |||
| 98 | + | Long: `Set one or more secrets for a domain. | |||
| 99 | + | ||||
| 100 | + | Secrets are specified as KEY=value pairs. Multiple secrets can be set at once. | |||
| 101 | + | ||||
| 102 | + | Examples: | |||
| 103 | + | gordon secrets set app.mydomain.com DATABASE_URL=postgres://localhost/db | |||
| 104 | + | gordon secrets set app.mydomain.com API_KEY=secret123 DEBUG=false | |||
| 105 | + | gordon --remote https://gordon.mydomain.com secrets set api.mydomain.com TOKEN=abc`, | |||
| 106 | + | Args: cobra.MinimumNArgs(2), | |||
| 107 | + | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 108 | + | ctx := context.Background() | |||
| 109 | + | domain := args[0] | |||
| 110 | + | pairs := args[1:] | |||
| 111 | + | ||||
| 112 | + | client, isRemote := GetRemoteClient() | |||
| 113 | + | if !isRemote { | |||
| 114 | + | fmt.Println(styles.RenderError("secrets command requires --remote flag or GORDON_REMOTE env var")) | |||
| 115 | + | return nil | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | // Parse KEY=value pairs | |||
| 119 | + | secrets := make(map[string]string) | |||
| 120 | + | for _, pair := range pairs { | |||
| 121 | + | parts := strings.SplitN(pair, "=", 2) | |||
| 122 | + | if len(parts) != 2 { | |||
| 123 | + | fmt.Println(styles.RenderError(fmt.Sprintf("Invalid format: %s (expected KEY=value)", pair))) | |||
| 124 | + | return nil | |||
| 125 | + | } | |||
| 126 | + | secrets[parts[0]] = parts[1] | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | if err := client.SetSecrets(ctx, domain, secrets); err != nil { | |||
| 130 | + | return fmt.Errorf("failed to set secrets: %w", err) | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | if len(secrets) == 1 { | |||
| 134 | + | for key := range secrets { | |||
| 135 | + | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret set: %s", key))) | |||
| 136 | + | } | |||
| 137 | + | } else { | |||
| 138 | + | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Set %d secrets for %s", len(secrets), domain))) | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | return nil | |||
| 142 | + | }, | |||
| 143 | + | } | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | // newSecretsRemoveCmd creates the secrets remove command. | |||
| 147 | + | func newSecretsRemoveCmd() *cobra.Command { | |||
| 148 | + | var force bool | |||
| 149 | + | ||||
| 150 | + | cmd := &cobra.Command{ | |||
| 151 | + | Use: "remove <domain> <key>", | |||
| 152 | + | Short: "Remove a secret", | |||
| 153 | + | Long: `Remove a secret from a domain. | |||
| 154 | + | ||||
| 155 | + | Examples: | |||
| 156 | + | gordon secrets remove app.mydomain.com OLD_API_KEY | |||
| 157 | + | gordon secrets remove app.mydomain.com OLD_API_KEY --force`, | |||
| 158 | + | Args: cobra.ExactArgs(2), | |||
| 159 | + | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 160 | + | ctx := context.Background() | |||
| 161 | + | domain := args[0] | |||
| 162 | + | key := args[1] | |||
| 163 | + | ||||
| 164 | + | client, isRemote := GetRemoteClient() | |||
| 165 | + | if !isRemote { | |||
| 166 | + | fmt.Println(styles.RenderError("secrets command requires --remote flag or GORDON_REMOTE env var")) | |||
| 167 | + | return nil | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | // Confirm unless --force | |||
| 171 | + | if !force { | |||
| 172 | + | confirmed, err := components.RunConfirm( | |||
| 173 | + | fmt.Sprintf("Remove secret '%s' from %s?", key, domain), | |||
| 174 | + | ) | |||
| 175 | + | if err != nil { | |||
| 176 | + | return err | |||
| 177 | + | } | |||
| 178 | + | if !confirmed { | |||
| 179 | + | fmt.Println(styles.Theme.Muted.Render("Cancelled")) | |||
| 180 | + | return nil | |||
| 181 | + | } | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | if err := client.DeleteSecret(ctx, domain, key); err != nil { | |||
| 185 | + | return fmt.Errorf("failed to remove secret: %w", err) | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret removed: %s", key))) | |||
| 189 | + | return nil | |||
| 190 | + | }, | |||
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation") | |||
| 194 | + | ||||
| 195 | + | return cmd | |||
| 196 | + | } | |||
A internal/adapters/in/cli/serve.go +52 -0
| @@ -0,0 +1,52 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "os" | |||
| 7 | + | ||||
| 8 | + | "gordon/internal/app" | |||
| 9 | + | ||||
| 10 | + | "github.com/spf13/cobra" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // newServeCmd creates the serve command. | |||
| 14 | + | func newServeCmd() *cobra.Command { | |||
| 15 | + | var configPath string | |||
| 16 | + | ||||
| 17 | + | cmd := &cobra.Command{ | |||
| 18 | + | Use: "serve", | |||
| 19 | + | Short: "Start the Gordon server", | |||
| 20 | + | Long: `Start the Gordon server, including the registry and proxy components.`, | |||
| 21 | + | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 22 | + | return app.Run(context.Background(), configPath) | |||
| 23 | + | }, | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | // Add flags | |||
| 27 | + | cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file") | |||
| 28 | + | ||||
| 29 | + | return cmd | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | // newStartCmd creates a deprecated alias for serve. | |||
| 33 | + | func newStartCmd() *cobra.Command { | |||
| 34 | + | var configPath string | |||
| 35 | + | ||||
| 36 | + | cmd := &cobra.Command{ | |||
| 37 | + | Use: "start", | |||
| 38 | + | Short: "Start the Gordon server (deprecated: use 'serve')", | |||
| 39 | + | Long: `Start the Gordon server. This command is deprecated, please use 'gordon serve' instead.`, | |||
| 40 | + | Deprecated: "use 'gordon serve' instead", | |||
| 41 | + | Hidden: true, // Hide from help but still works | |||
| 42 | + | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 43 | + | fmt.Fprintln(os.Stderr, "\nWarning: 'gordon start' is deprecated, use 'gordon serve' instead") | |||
| 44 | + | return app.Run(context.Background(), configPath) | |||
| 45 | + | }, | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // Add flags (same as serve) | |||
| 49 | + | cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file") | |||
| 50 | + | ||||
| 51 | + | return cmd | |||
| 52 | + | } | |||
A internal/adapters/in/cli/ui/components/confirm.go +202 -0
| @@ -0,0 +1,202 @@ | |||||
| 1 | + | package components | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "strings" | |||
| 6 | + | ||||
| 7 | + | "gordon/internal/adapters/in/cli/ui/styles" | |||
| 8 | + | ||||
| 9 | + | tea "github.com/charmbracelet/bubbletea" | |||
| 10 | + | "github.com/charmbracelet/lipgloss" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // ConfirmResult represents the result of a confirmation dialog. | |||
| 14 | + | type ConfirmResult int | |||
| 15 | + | ||||
| 16 | + | const ( | |||
| 17 | + | ConfirmPending ConfirmResult = iota | |||
| 18 | + | ConfirmYes | |||
| 19 | + | ConfirmNo | |||
| 20 | + | ConfirmCancelled | |||
| 21 | + | ) | |||
| 22 | + | ||||
| 23 | + | // ConfirmModel is a Yes/No confirmation dialog. | |||
| 24 | + | type ConfirmModel struct { | |||
| 25 | + | question string | |||
| 26 | + | description string | |||
| 27 | + | yesLabel string | |||
| 28 | + | noLabel string | |||
| 29 | + | focused bool // true = Yes is focused, false = No is focused | |||
| 30 | + | result ConfirmResult | |||
| 31 | + | width int | |||
| 32 | + | ||||
| 33 | + | // Styles | |||
| 34 | + | questionStyle lipgloss.Style | |||
| 35 | + | descriptionStyle lipgloss.Style | |||
| 36 | + | buttonStyle lipgloss.Style | |||
| 37 | + | focusedStyle lipgloss.Style | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | // ConfirmOption configures a ConfirmModel. | |||
| 41 | + | type ConfirmOption func(*ConfirmModel) | |||
| 42 | + | ||||
| 43 | + | // NewConfirm creates a new confirmation dialog. | |||
| 44 | + | func NewConfirm(question string, opts ...ConfirmOption) ConfirmModel { | |||
| 45 | + | m := ConfirmModel{ | |||
| 46 | + | question: question, | |||
| 47 | + | yesLabel: "Yes", | |||
| 48 | + | noLabel: "No", | |||
| 49 | + | focused: false, // Default to No | |||
| 50 | + | result: ConfirmPending, | |||
| 51 | + | width: 50, | |||
| 52 | + | questionStyle: styles.Theme.Bold, | |||
| 53 | + | descriptionStyle: styles.Theme.Muted, | |||
| 54 | + | buttonStyle: lipgloss.NewStyle(). | |||
| 55 | + | Padding(0, 2). | |||
| 56 | + | Foreground(styles.ColorText), | |||
| 57 | + | focusedStyle: lipgloss.NewStyle(). | |||
| 58 | + | Padding(0, 2). | |||
| 59 | + | Bold(true). | |||
| 60 | + | Foreground(styles.ColorBg). | |||
| 61 | + | Background(styles.ColorPrimary), | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | for _, opt := range opts { | |||
| 65 | + | opt(&m) | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | return m | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | // WithDescription adds a description to the confirmation. | |||
| 72 | + | func WithDescription(desc string) ConfirmOption { | |||
| 73 | + | return func(m *ConfirmModel) { | |||
| 74 | + | m.description = desc | |||
| 75 | + | } | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | // WithDefaultYes sets Yes as the default selection. | |||
| 79 | + | func WithDefaultYes() ConfirmOption { | |||
| 80 | + | return func(m *ConfirmModel) { | |||
| 81 | + | m.focused = true | |||
| 82 | + | } | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | // WithLabels customizes the Yes/No labels. | |||
| 86 | + | func WithLabels(yes, no string) ConfirmOption { | |||
| 87 | + | return func(m *ConfirmModel) { | |||
| 88 | + | m.yesLabel = yes | |||
| 89 | + | m.noLabel = no | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | // WithWidth sets the dialog width. | |||
| 94 | + | func WithWidth(w int) ConfirmOption { | |||
| 95 | + | return func(m *ConfirmModel) { | |||
| 96 | + | m.width = w | |||
| 97 | + | } | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | // Init implements tea.Model. | |||
| 101 | + | func (m ConfirmModel) Init() tea.Cmd { | |||
| 102 | + | return nil | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | // Update implements tea.Model. | |||
| 106 | + | func (m ConfirmModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |||
| 107 | + | switch msg := msg.(type) { | |||
| 108 | + | case tea.KeyMsg: | |||
| 109 | + | switch msg.String() { | |||
| 110 | + | case "left", "h", "tab", "shift+tab": | |||
| 111 | + | m.focused = !m.focused | |||
| 112 | + | case "right", "l": | |||
| 113 | + | m.focused = !m.focused | |||
| 114 | + | case "y", "Y": | |||
| 115 | + | m.result = ConfirmYes | |||
| 116 | + | return m, tea.Quit | |||
| 117 | + | case "n", "N": | |||
| 118 | + | m.result = ConfirmNo | |||
| 119 | + | return m, tea.Quit | |||
| 120 | + | case "enter": | |||
| 121 | + | if m.focused { | |||
| 122 | + | m.result = ConfirmYes | |||
| 123 | + | } else { | |||
| 124 | + | m.result = ConfirmNo | |||
| 125 | + | } | |||
| 126 | + | return m, tea.Quit | |||
| 127 | + | case "esc", "ctrl+c", "q": | |||
| 128 | + | m.result = ConfirmCancelled | |||
| 129 | + | return m, tea.Quit | |||
| 130 | + | } | |||
| 131 | + | } | |||
| 132 | + | return m, nil | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | // View implements tea.Model. | |||
| 136 | + | func (m ConfirmModel) View() string { | |||
| 137 | + | var b strings.Builder | |||
| 138 | + | ||||
| 139 | + | // Question | |||
| 140 | + | b.WriteString(m.questionStyle.Render(m.question)) | |||
| 141 | + | b.WriteString("\n") | |||
| 142 | + | ||||
| 143 | + | // Description | |||
| 144 | + | if m.description != "" { | |||
| 145 | + | b.WriteString(m.descriptionStyle.Render(m.description)) | |||
| 146 | + | b.WriteString("\n") | |||
| 147 | + | } | |||
| 148 | + | b.WriteString("\n") | |||
| 149 | + | ||||
| 150 | + | // Buttons | |||
| 151 | + | var yesButton, noButton string | |||
| 152 | + | if m.focused { | |||
| 153 | + | yesButton = m.focusedStyle.Render(m.yesLabel) | |||
| 154 | + | noButton = m.buttonStyle.Render(m.noLabel) | |||
| 155 | + | } else { | |||
| 156 | + | yesButton = m.buttonStyle.Render(m.yesLabel) | |||
| 157 | + | noButton = m.focusedStyle.Render(m.noLabel) | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | buttons := lipgloss.JoinHorizontal(lipgloss.Center, yesButton, " ", noButton) | |||
| 161 | + | b.WriteString(buttons) | |||
| 162 | + | b.WriteString("\n\n") | |||
| 163 | + | ||||
| 164 | + | // Help | |||
| 165 | + | help := styles.RenderKeyHelp("y/n", "select") + " " + | |||
| 166 | + | styles.RenderKeyHelp("enter", "confirm") + " " + | |||
| 167 | + | styles.RenderKeyHelp("esc", "cancel") | |||
| 168 | + | b.WriteString(help) | |||
| 169 | + | ||||
| 170 | + | return b.String() | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | // Result returns the confirmation result. | |||
| 174 | + | func (m ConfirmModel) Result() ConfirmResult { | |||
| 175 | + | return m.result | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | // Confirmed returns true if the user confirmed. | |||
| 179 | + | func (m ConfirmModel) Confirmed() bool { | |||
| 180 | + | return m.result == ConfirmYes | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | // Cancelled returns true if the user cancelled. | |||
| 184 | + | func (m ConfirmModel) Cancelled() bool { | |||
| 185 | + | return m.result == ConfirmCancelled | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | // RunConfirm runs a confirmation dialog and returns the result. | |||
| 189 | + | // This is a convenience function for simple confirmations. | |||
| 190 | + | func RunConfirm(question string, opts ...ConfirmOption) (bool, error) { | |||
| 191 | + | m := NewConfirm(question, opts...) | |||
| 192 | + | p := tea.NewProgram(m) | |||
| 193 | + | finalModel, err := p.Run() | |||
| 194 | + | if err != nil { | |||
| 195 | + | return false, fmt.Errorf("error running confirmation: %w", err) | |||
| 196 | + | } | |||
| 197 | + | result := finalModel.(ConfirmModel) | |||
| 198 | + | if result.Cancelled() { | |||
| 199 | + | return false, nil | |||
| 200 | + | } | |||
| 201 | + | return result.Confirmed(), nil | |||
| 202 | + | } | |||
A internal/adapters/in/cli/ui/components/form.go +358 -0
| @@ -0,0 +1,358 @@ | |||||
| 1 | + | package components | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "strings" | |||
| 6 | + | ||||
| 7 | + | "gordon/internal/adapters/in/cli/ui/styles" | |||
| 8 | + | ||||
| 9 | + | "github.com/charmbracelet/bubbles/textinput" | |||
| 10 | + | tea "github.com/charmbracelet/bubbletea" | |||
| 11 | + | "github.com/charmbracelet/lipgloss" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // FieldType defines the type of form field. | |||
| 15 | + | type FieldType int | |||
| 16 | + | ||||
| 17 | + | const ( | |||
| 18 | + | FieldTypeText FieldType = iota | |||
| 19 | + | FieldTypePassword | |||
| 20 | + | FieldTypeSelect | |||
| 21 | + | ) | |||
| 22 | + | ||||
| 23 | + | // FormField defines a form field configuration. | |||
| 24 | + | type FormField struct { | |||
| 25 | + | Key string | |||
| 26 | + | Label string | |||
| 27 | + | Placeholder string | |||
| 28 | + | Default string | |||
| 29 | + | Required bool | |||
| 30 | + | Type FieldType | |||
| 31 | + | Options []string // For select fields | |||
| 32 | + | Validator func(string) error | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | // FormModel is an interactive form component. | |||
| 36 | + | type FormModel struct { | |||
| 37 | + | title string | |||
| 38 | + | description string | |||
| 39 | + | fields []FormField | |||
| 40 | + | inputs []textinput.Model | |||
| 41 | + | focused int | |||
| 42 | + | submitted bool | |||
| 43 | + | cancelled bool | |||
| 44 | + | errors []string | |||
| 45 | + | width int | |||
| 46 | + | ||||
| 47 | + | // Styles | |||
| 48 | + | titleStyle lipgloss.Style | |||
| 49 | + | descriptionStyle lipgloss.Style | |||
| 50 | + | labelStyle lipgloss.Style | |||
| 51 | + | errorStyle lipgloss.Style | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | // FormOption configures a FormModel. | |||
| 55 | + | type FormOption func(*FormModel) | |||
| 56 | + | ||||
| 57 | + | // NewForm creates a new form with the given fields. | |||
| 58 | + | func NewForm(fields []FormField, opts ...FormOption) FormModel { | |||
| 59 | + | inputs := make([]textinput.Model, len(fields)) | |||
| 60 | + | errors := make([]string, len(fields)) | |||
| 61 | + | ||||
| 62 | + | for i, field := range fields { | |||
| 63 | + | ti := textinput.New() | |||
| 64 | + | ti.Placeholder = field.Placeholder | |||
| 65 | + | ti.SetValue(field.Default) | |||
| 66 | + | ti.CharLimit = 256 | |||
| 67 | + | ti.Width = 40 | |||
| 68 | + | ||||
| 69 | + | if field.Type == FieldTypePassword { | |||
| 70 | + | ti.EchoMode = textinput.EchoPassword | |||
| 71 | + | ti.EchoCharacter = '*' | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | if i == 0 { | |||
| 75 | + | ti.Focus() | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | inputs[i] = ti | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | m := FormModel{ | |||
| 82 | + | fields: fields, | |||
| 83 | + | inputs: inputs, | |||
| 84 | + | focused: 0, | |||
| 85 | + | errors: errors, | |||
| 86 | + | width: 50, | |||
| 87 | + | titleStyle: styles.Theme.Title, | |||
| 88 | + | descriptionStyle: styles.Theme.Muted, | |||
| 89 | + | labelStyle: styles.Theme.FormLabel, | |||
| 90 | + | errorStyle: styles.Theme.FormError, | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | for _, opt := range opts { | |||
| 94 | + | opt(&m) | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | return m | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | // WithFormTitle sets the form title. | |||
| 101 | + | func WithFormTitle(title string) FormOption { | |||
| 102 | + | return func(m *FormModel) { | |||
| 103 | + | m.title = title | |||
| 104 | + | } | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | // WithFormDescription sets the form description. | |||
| 108 | + | func WithFormDescription(desc string) FormOption { | |||
| 109 | + | return func(m *FormModel) { | |||
| 110 | + | m.description = desc | |||
| 111 | + | } | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | // WithFormWidth sets the form width. | |||
| 115 | + | func WithFormWidth(w int) FormOption { | |||
| 116 | + | return func(m *FormModel) { | |||
| 117 | + | m.width = w | |||
| 118 | + | for i := range m.inputs { | |||
| 119 | + | m.inputs[i].Width = w - 4 // Account for padding | |||
| 120 | + | } | |||
| 121 | + | } | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | // Init implements tea.Model. | |||
| 125 | + | func (m FormModel) Init() tea.Cmd { | |||
| 126 | + | return textinput.Blink | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | // Update implements tea.Model. | |||
| 130 | + | func (m FormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |||
| 131 | + | var cmds []tea.Cmd | |||
| 132 | + | ||||
| 133 | + | switch msg := msg.(type) { | |||
| 134 | + | case tea.KeyMsg: | |||
| 135 | + | switch msg.String() { | |||
| 136 | + | case "ctrl+c", "esc": | |||
| 137 | + | m.cancelled = true | |||
| 138 | + | return m, tea.Quit | |||
| 139 | + | case "tab", "down": | |||
| 140 | + | m.focusNext() | |||
| 141 | + | case "shift+tab", "up": | |||
| 142 | + | m.focusPrev() | |||
| 143 | + | case "enter": | |||
| 144 | + | if m.focused == len(m.fields)-1 { | |||
| 145 | + | // On last field, submit | |||
| 146 | + | if m.validate() { | |||
| 147 | + | m.submitted = true | |||
| 148 | + | return m, tea.Quit | |||
| 149 | + | } | |||
| 150 | + | } else { | |||
| 151 | + | m.focusNext() | |||
| 152 | + | } | |||
| 153 | + | } | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | // Update the focused input | |||
| 157 | + | var cmd tea.Cmd | |||
| 158 | + | m.inputs[m.focused], cmd = m.inputs[m.focused].Update(msg) | |||
| 159 | + | cmds = append(cmds, cmd) | |||
| 160 | + | ||||
| 161 | + | return m, tea.Batch(cmds...) | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | // focusNext moves focus to the next field. | |||
| 165 | + | func (m *FormModel) focusNext() { | |||
| 166 | + | m.inputs[m.focused].Blur() | |||
| 167 | + | m.focused = (m.focused + 1) % len(m.fields) | |||
| 168 | + | m.inputs[m.focused].Focus() | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | // focusPrev moves focus to the previous field. | |||
| 172 | + | func (m *FormModel) focusPrev() { | |||
| 173 | + | m.inputs[m.focused].Blur() | |||
| 174 | + | m.focused-- | |||
| 175 | + | if m.focused < 0 { | |||
| 176 | + | m.focused = len(m.fields) - 1 | |||
| 177 | + | } | |||
| 178 | + | m.inputs[m.focused].Focus() | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | // validate validates all fields. | |||
| 182 | + | func (m *FormModel) validate() bool { | |||
| 183 | + | valid := true | |||
| 184 | + | for i, field := range m.fields { | |||
| 185 | + | value := m.inputs[i].Value() | |||
| 186 | + | m.errors[i] = "" | |||
| 187 | + | ||||
| 188 | + | // Check required | |||
| 189 | + | if field.Required && strings.TrimSpace(value) == "" { | |||
| 190 | + | m.errors[i] = "This field is required" | |||
| 191 | + | valid = false | |||
| 192 | + | continue | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | // Run custom validator | |||
| 196 | + | if field.Validator != nil && value != "" { | |||
| 197 | + | if err := field.Validator(value); err != nil { | |||
| 198 | + | m.errors[i] = err.Error() | |||
| 199 | + | valid = false | |||
| 200 | + | } | |||
| 201 | + | } | |||
| 202 | + | } | |||
| 203 | + | return valid | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | // View implements tea.Model. | |||
| 207 | + | func (m FormModel) View() string { | |||
| 208 | + | var b strings.Builder | |||
| 209 | + | ||||
| 210 | + | // Title | |||
| 211 | + | if m.title != "" { | |||
| 212 | + | b.WriteString(m.titleStyle.Render(m.title)) | |||
| 213 | + | b.WriteString("\n") | |||
| 214 | + | } | |||
| 215 | + | ||||
| 216 | + | // Description | |||
| 217 | + | if m.description != "" { | |||
| 218 | + | b.WriteString(m.descriptionStyle.Render(m.description)) | |||
| 219 | + | b.WriteString("\n") | |||
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | if m.title != "" || m.description != "" { | |||
| 223 | + | b.WriteString("\n") | |||
| 224 | + | } | |||
| 225 | + | ||||
| 226 | + | // Fields | |||
| 227 | + | for i, field := range m.fields { | |||
| 228 | + | // Label | |||
| 229 | + | label := field.Label | |||
| 230 | + | if field.Required { | |||
| 231 | + | label += " *" | |||
| 232 | + | } | |||
| 233 | + | b.WriteString(m.labelStyle.Render(label)) | |||
| 234 | + | b.WriteString("\n") | |||
| 235 | + | ||||
| 236 | + | // Input | |||
| 237 | + | b.WriteString(m.inputs[i].View()) | |||
| 238 | + | b.WriteString("\n") | |||
| 239 | + | ||||
| 240 | + | // Error | |||
| 241 | + | if m.errors[i] != "" { | |||
| 242 | + | b.WriteString(m.errorStyle.Render(styles.IconError + " " + m.errors[i])) | |||
| 243 | + | b.WriteString("\n") | |||
| 244 | + | } | |||
| 245 | + | ||||
| 246 | + | b.WriteString("\n") | |||
| 247 | + | } | |||
| 248 | + | ||||
| 249 | + | // Help | |||
| 250 | + | help := styles.RenderKeyHelp("tab/shift+tab", "navigate") + " " + | |||
| 251 | + | styles.RenderKeyHelp("enter", "submit") + " " + | |||
| 252 | + | styles.RenderKeyHelp("esc", "cancel") | |||
| 253 | + | b.WriteString(help) | |||
| 254 | + | ||||
| 255 | + | return b.String() | |||
| 256 | + | } | |||
| 257 | + | ||||
| 258 | + | // Values returns the form values as a map. | |||
| 259 | + | func (m FormModel) Values() map[string]string { | |||
| 260 | + | values := make(map[string]string) | |||
| 261 | + | for i, field := range m.fields { | |||
| 262 | + | values[field.Key] = m.inputs[i].Value() | |||
| 263 | + | } | |||
| 264 | + | return values | |||
| 265 | + | } | |||
| 266 | + | ||||
| 267 | + | // Value returns the value for a specific field. | |||
| 268 | + | func (m FormModel) Value(key string) string { | |||
| 269 | + | for i, field := range m.fields { | |||
| 270 | + | if field.Key == key { | |||
| 271 | + | return m.inputs[i].Value() | |||
| 272 | + | } | |||
| 273 | + | } | |||
| 274 | + | return "" | |||
| 275 | + | } | |||
| 276 | + | ||||
| 277 | + | // Submitted returns true if the form was submitted. | |||
| 278 | + | func (m FormModel) Submitted() bool { | |||
| 279 | + | return m.submitted | |||
| 280 | + | } | |||
| 281 | + | ||||
| 282 | + | // Cancelled returns true if the form was cancelled. | |||
| 283 | + | func (m FormModel) Cancelled() bool { | |||
| 284 | + | return m.cancelled | |||
| 285 | + | } | |||
| 286 | + | ||||
| 287 | + | // RunForm runs a form and returns the values. | |||
| 288 | + | // This is a convenience function for simple forms. | |||
| 289 | + | func RunForm(title string, fields []FormField) (map[string]string, error) { | |||
| 290 | + | m := NewForm(fields, WithFormTitle(title)) | |||
| 291 | + | p := tea.NewProgram(m) | |||
| 292 | + | finalModel, err := p.Run() | |||
| 293 | + | if err != nil { | |||
| 294 | + | return nil, fmt.Errorf("error running form: %w", err) | |||
| 295 | + | } | |||
| 296 | + | result := finalModel.(FormModel) | |||
| 297 | + | if result.Cancelled() { | |||
| 298 | + | return nil, nil | |||
| 299 | + | } | |||
| 300 | + | if !result.Submitted() { | |||
| 301 | + | return nil, nil | |||
| 302 | + | } | |||
| 303 | + | return result.Values(), nil | |||
| 304 | + | } | |||
| 305 | + | ||||
| 306 | + | // TextField creates a text field configuration. | |||
| 307 | + | func TextField(key, label string, opts ...func(*FormField)) FormField { | |||
| 308 | + | f := FormField{ | |||
| 309 | + | Key: key, | |||
| 310 | + | Label: label, | |||
| 311 | + | Type: FieldTypeText, | |||
| 312 | + | } | |||
| 313 | + | for _, opt := range opts { | |||
| 314 | + | opt(&f) | |||
| 315 | + | } | |||
| 316 | + | return f | |||
| 317 | + | } | |||
| 318 | + | ||||
| 319 | + | // PasswordField creates a password field configuration. | |||
| 320 | + | func PasswordField(key, label string, opts ...func(*FormField)) FormField { | |||
| 321 | + | f := FormField{ | |||
| 322 | + | Key: key, | |||
| 323 | + | Label: label, | |||
| 324 | + | Type: FieldTypePassword, | |||
| 325 | + | } | |||
| 326 | + | for _, opt := range opts { | |||
| 327 | + | opt(&f) | |||
| 328 | + | } | |||
| 329 | + | return f | |||
| 330 | + | } | |||
| 331 | + | ||||
| 332 | + | // Required marks a field as required. | |||
| 333 | + | func Required() func(*FormField) { | |||
| 334 | + | return func(f *FormField) { | |||
| 335 | + | f.Required = true | |||
| 336 | + | } | |||
| 337 | + | } | |||
| 338 | + | ||||
| 339 | + | // WithPlaceholder sets the field placeholder. | |||
| 340 | + | func WithPlaceholder(p string) func(*FormField) { | |||
| 341 | + | return func(f *FormField) { | |||
| 342 | + | f.Placeholder = p | |||
| 343 | + | } | |||
| 344 | + | } | |||
| 345 | + | ||||
| 346 | + | // WithDefault sets the field default value. | |||
| 347 | + | func WithDefault(d string) func(*FormField) { | |||
| 348 | + | return func(f *FormField) { | |||
| 349 | + | f.Default = d | |||
| 350 | + | } | |||
| 351 | + | } | |||
| 352 | + | ||||
| 353 | + | // WithValidator sets the field validator. | |||
| 354 | + | func WithValidator(v func(string) error) func(*FormField) { | |||
| 355 | + | return func(f *FormField) { | |||
| 356 | + | f.Validator = v | |||
| 357 | + | } | |||
| 358 | + | } | |||
A internal/adapters/in/cli/ui/components/spinner.go +117 -0
| @@ -0,0 +1,117 @@ | |||||
| 1 | + | // Package components provides reusable TUI components for Gordon's CLI. | |||
| 2 | + | package components | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "gordon/internal/adapters/in/cli/ui/styles" | |||
| 6 | + | ||||
| 7 | + | "github.com/charmbracelet/bubbles/spinner" | |||
| 8 | + | tea "github.com/charmbracelet/bubbletea" | |||
| 9 | + | "github.com/charmbracelet/lipgloss" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // SpinnerModel wraps the bubbles spinner with Gordon styling. | |||
| 13 | + | type SpinnerModel struct { | |||
| 14 | + | spinner spinner.Model | |||
| 15 | + | message string | |||
| 16 | + | style lipgloss.Style | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | // SpinnerOption configures a SpinnerModel. | |||
| 20 | + | type SpinnerOption func(*SpinnerModel) | |||
| 21 | + | ||||
| 22 | + | // NewSpinner creates a new spinner with Gordon styling. | |||
| 23 | + | func NewSpinner(opts ...SpinnerOption) SpinnerModel { | |||
| 24 | + | s := spinner.New() | |||
| 25 | + | s.Spinner = spinner.Dot | |||
| 26 | + | s.Style = lipgloss.NewStyle().Foreground(styles.ColorPrimary) | |||
| 27 | + | ||||
| 28 | + | m := SpinnerModel{ | |||
| 29 | + | spinner: s, | |||
| 30 | + | message: "Loading...", | |||
| 31 | + | style: styles.Theme.Body, | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | for _, opt := range opts { | |||
| 35 | + | opt(&m) | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | return m | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // WithMessage sets the spinner message. | |||
| 42 | + | func WithMessage(msg string) SpinnerOption { | |||
| 43 | + | return func(m *SpinnerModel) { | |||
| 44 | + | m.message = msg | |||
| 45 | + | } | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // WithSpinnerType sets the spinner animation type. | |||
| 49 | + | func WithSpinnerType(t spinner.Spinner) SpinnerOption { | |||
| 50 | + | return func(m *SpinnerModel) { | |||
| 51 | + | m.spinner.Spinner = t | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | // WithSpinnerColor sets the spinner color. | |||
| 56 | + | func WithSpinnerColor(color lipgloss.Color) SpinnerOption { | |||
| 57 | + | return func(m *SpinnerModel) { | |||
| 58 | + | m.spinner.Style = lipgloss.NewStyle().Foreground(color) | |||
| 59 | + | } | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | // Init implements tea.Model. | |||
| 63 | + | func (m SpinnerModel) Init() tea.Cmd { | |||
| 64 | + | return m.spinner.Tick | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | // Update implements tea.Model. | |||
| 68 | + | func (m SpinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |||
| 69 | + | var cmd tea.Cmd | |||
| 70 | + | m.spinner, cmd = m.spinner.Update(msg) | |||
| 71 | + | return m, cmd | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | // View implements tea.Model. | |||
| 75 | + | func (m SpinnerModel) View() string { | |||
| 76 | + | return m.spinner.View() + " " + m.style.Render(m.message) | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | // SetMessage updates the spinner message. | |||
| 80 | + | func (m *SpinnerModel) SetMessage(msg string) { | |||
| 81 | + | m.message = msg | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | // Tick returns the spinner tick command for animation. | |||
| 85 | + | func (m SpinnerModel) Tick() tea.Cmd { | |||
| 86 | + | return m.spinner.Tick | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | // Common spinner types with Gordon colors. | |||
| 90 | + | var ( | |||
| 91 | + | // SpinnerDot is the default dot spinner. | |||
| 92 | + | SpinnerDot = spinner.Dot | |||
| 93 | + | ||||
| 94 | + | // SpinnerLine is a simple line spinner. | |||
| 95 | + | SpinnerLine = spinner.Line | |||
| 96 | + | ||||
| 97 | + | // SpinnerMiniDot is a smaller dot spinner. | |||
| 98 | + | SpinnerMiniDot = spinner.MiniDot | |||
| 99 | + | ||||
| 100 | + | // SpinnerJump is a jumping spinner. | |||
| 101 | + | SpinnerJump = spinner.Jump | |||
| 102 | + | ||||
| 103 | + | // SpinnerPulse is a pulsing spinner. | |||
| 104 | + | SpinnerPulse = spinner.Pulse | |||
| 105 | + | ||||
| 106 | + | // SpinnerPoints is a points spinner. | |||
| 107 | + | SpinnerPoints = spinner.Points | |||
| 108 | + | ||||
| 109 | + | // SpinnerGlobe is a globe spinner. | |||
| 110 | + | SpinnerGlobe = spinner.Globe | |||
| 111 | + | ||||
| 112 | + | // SpinnerMoon is a moon phase spinner. | |||
| 113 | + | SpinnerMoon = spinner.Moon | |||
| 114 | + | ||||
| 115 | + | // SpinnerMonkey is a monkey spinner. | |||
| 116 | + | SpinnerMonkey = spinner.Monkey | |||
| 117 | + | ) | |||
A internal/adapters/in/cli/ui/components/status.go +158 -0
| @@ -0,0 +1,158 @@ | |||||
| 1 | + | package components | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | ||||
| 6 | + | "gordon/internal/adapters/in/cli/ui/styles" | |||
| 7 | + | ||||
| 8 | + | "github.com/charmbracelet/lipgloss" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // Status represents a status type for rendering. | |||
| 12 | + | type Status int | |||
| 13 | + | ||||
| 14 | + | const ( | |||
| 15 | + | StatusSuccess Status = iota | |||
| 16 | + | StatusError | |||
| 17 | + | StatusWarning | |||
| 18 | + | StatusInfo | |||
| 19 | + | StatusPending | |||
| 20 | + | StatusRunning | |||
| 21 | + | StatusStopped | |||
| 22 | + | ) | |||
| 23 | + | ||||
| 24 | + | // StatusConfig holds configuration for status rendering. | |||
| 25 | + | type StatusConfig struct { | |||
| 26 | + | Status Status | |||
| 27 | + | Label string | |||
| 28 | + | Icon string | |||
| 29 | + | Style lipgloss.Style | |||
| 30 | + | Badge bool // If true, render as a badge with background | |||
| 31 | + | Compact bool // If true, render icon only | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | // DefaultStatusConfigs provides default configurations for each status type. | |||
| 35 | + | var DefaultStatusConfigs = map[Status]StatusConfig{ | |||
| 36 | + | StatusSuccess: { | |||
| 37 | + | Icon: styles.IconSuccess, | |||
| 38 | + | Style: styles.Theme.Success, | |||
| 39 | + | }, | |||
| 40 | + | StatusError: { | |||
| 41 | + | Icon: styles.IconError, | |||
| 42 | + | Style: styles.Theme.Error, | |||
| 43 | + | }, | |||
| 44 | + | StatusWarning: { | |||
| 45 | + | Icon: styles.IconWarning, | |||
| 46 | + | Style: styles.Theme.Warning, | |||
| 47 | + | }, | |||
| 48 | + | StatusInfo: { | |||
| 49 | + | Icon: styles.IconInfo, | |||
| 50 | + | Style: styles.Theme.Info, | |||
| 51 | + | }, | |||
| 52 | + | StatusPending: { | |||
| 53 | + | Icon: styles.IconPending, | |||
| 54 | + | Style: styles.Theme.Muted, | |||
| 55 | + | }, | |||
| 56 | + | StatusRunning: { | |||
| 57 | + | Icon: styles.IconSuccess, | |||
| 58 | + | Style: styles.Theme.Success, | |||
| 59 | + | }, | |||
| 60 | + | StatusStopped: { | |||
| 61 | + | Icon: styles.IconError, | |||
| 62 | + | Style: styles.Theme.Error, | |||
| 63 | + | }, | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | // RenderStatus renders a status with icon and optional label. | |||
| 67 | + | func RenderStatus(status Status, label string) string { | |||
| 68 | + | config := DefaultStatusConfigs[status] | |||
| 69 | + | if label == "" { | |||
| 70 | + | return config.Style.Render(config.Icon) | |||
| 71 | + | } | |||
| 72 | + | return config.Style.Render(config.Icon + " " + label) | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // RenderStatusBadge renders a status as a badge with background. | |||
| 76 | + | func RenderStatusBadge(status Status, label string) string { | |||
| 77 | + | var badgeStyle lipgloss.Style | |||
| 78 | + | switch status { | |||
| 79 | + | case StatusSuccess, StatusRunning: | |||
| 80 | + | badgeStyle = styles.Theme.BadgeSuccess | |||
| 81 | + | case StatusError, StatusStopped: | |||
| 82 | + | badgeStyle = styles.Theme.BadgeError | |||
| 83 | + | case StatusWarning: | |||
| 84 | + | badgeStyle = styles.Theme.BadgeWarning | |||
| 85 | + | case StatusPending: | |||
| 86 | + | badgeStyle = styles.Theme.BadgePending | |||
| 87 | + | default: | |||
| 88 | + | badgeStyle = styles.Theme.BadgeInfo | |||
| 89 | + | } | |||
| 90 | + | return badgeStyle.Render(label) | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | // ParseStatus converts a string status to Status type. | |||
| 94 | + | func ParseStatus(s string) Status { | |||
| 95 | + | switch strings.ToLower(s) { | |||
| 96 | + | case "success", "ok", "active", "healthy": | |||
| 97 | + | return StatusSuccess | |||
| 98 | + | case "error", "failed", "unhealthy": | |||
| 99 | + | return StatusError | |||
| 100 | + | case "warning", "degraded": | |||
| 101 | + | return StatusWarning | |||
| 102 | + | case "info": | |||
| 103 | + | return StatusInfo | |||
| 104 | + | case "pending", "starting", "waiting": | |||
| 105 | + | return StatusPending | |||
| 106 | + | case "running", "up": | |||
| 107 | + | return StatusRunning | |||
| 108 | + | case "stopped", "down", "exited": | |||
| 109 | + | return StatusStopped | |||
| 110 | + | default: | |||
| 111 | + | return StatusInfo | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | // StatusIndicator is a simple status indicator component. | |||
| 116 | + | type StatusIndicator struct { | |||
| 117 | + | Status Status | |||
| 118 | + | Label string | |||
| 119 | + | Badge bool | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | // NewStatusIndicator creates a new status indicator. | |||
| 123 | + | func NewStatusIndicator(status Status, label string) StatusIndicator { | |||
| 124 | + | return StatusIndicator{ | |||
| 125 | + | Status: status, | |||
| 126 | + | Label: label, | |||
| 127 | + | Badge: false, | |||
| 128 | + | } | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | // NewStatusBadge creates a new status badge (with background). | |||
| 132 | + | func NewStatusBadge(status Status, label string) StatusIndicator { | |||
| 133 | + | return StatusIndicator{ | |||
| 134 | + | Status: status, | |||
| 135 | + | Label: label, | |||
| 136 | + | Badge: true, | |||
| 137 | + | } | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | // View renders the status indicator. | |||
| 141 | + | func (s StatusIndicator) View() string { | |||
| 142 | + | if s.Badge { | |||
| 143 | + | return RenderStatusBadge(s.Status, s.Label) | |||
| 144 | + | } | |||
| 145 | + | return RenderStatus(s.Status, s.Label) | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | // ContainerStatusIndicator renders container status with appropriate styling. | |||
| 149 | + | func ContainerStatusIndicator(status string) string { | |||
| 150 | + | parsed := ParseStatus(status) | |||
| 151 | + | return RenderStatus(parsed, status) | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | // ContainerStatusBadge renders container status as a badge. | |||
| 155 | + | func ContainerStatusBadge(status string) string { | |||
| 156 | + | parsed := ParseStatus(status) | |||
| 157 | + | return RenderStatusBadge(parsed, status) | |||
| 158 | + | } | |||
A internal/adapters/in/cli/ui/components/table.go +210 -0
| @@ -0,0 +1,210 @@ | |||||
| 1 | + | package components | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "gordon/internal/adapters/in/cli/ui/styles" | |||
| 5 | + | ||||
| 6 | + | "github.com/charmbracelet/lipgloss" | |||
| 7 | + | "github.com/charmbracelet/lipgloss/table" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // TableColumn defines a table column. | |||
| 11 | + | type TableColumn struct { | |||
| 12 | + | Title string | |||
| 13 | + | Width int | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | // TableModel is a styled table component. | |||
| 17 | + | type TableModel struct { | |||
| 18 | + | columns []TableColumn | |||
| 19 | + | rows [][]string | |||
| 20 | + | border lipgloss.Border | |||
| 21 | + | borderStyle lipgloss.Style | |||
| 22 | + | headerStyle lipgloss.Style | |||
| 23 | + | cellStyle lipgloss.Style | |||
| 24 | + | oddStyle lipgloss.Style | |||
| 25 | + | evenStyle lipgloss.Style | |||
| 26 | + | width int | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | // TableOption configures a TableModel. | |||
| 30 | + | type TableOption func(*TableModel) | |||
| 31 | + | ||||
| 32 | + | // NewTable creates a new styled table. | |||
| 33 | + | func NewTable(opts ...TableOption) *TableModel { | |||
| 34 | + | t := &TableModel{ | |||
| 35 | + | border: lipgloss.RoundedBorder(), | |||
| 36 | + | borderStyle: lipgloss.NewStyle().Foreground(styles.ColorBorder), | |||
| 37 | + | headerStyle: lipgloss.NewStyle(). | |||
| 38 | + | Bold(true). | |||
| 39 | + | Foreground(styles.ColorPrimary). | |||
| 40 | + | Padding(0, 1), | |||
| 41 | + | cellStyle: lipgloss.NewStyle(). | |||
| 42 | + | Foreground(styles.ColorText). | |||
| 43 | + | Padding(0, 1), | |||
| 44 | + | oddStyle: lipgloss.NewStyle(). | |||
| 45 | + | Foreground(styles.ColorText). | |||
| 46 | + | Padding(0, 1), | |||
| 47 | + | evenStyle: lipgloss.NewStyle(). | |||
| 48 | + | Foreground(styles.ColorTextMuted). | |||
| 49 | + | Padding(0, 1), | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | for _, opt := range opts { | |||
| 53 | + | opt(t) | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | return t | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | // WithColumns sets the table columns. | |||
| 60 | + | func WithColumns(cols []TableColumn) TableOption { | |||
| 61 | + | return func(t *TableModel) { | |||
| 62 | + | t.columns = cols | |||
| 63 | + | } | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | // WithRows sets the table rows. | |||
| 67 | + | func WithRows(rows [][]string) TableOption { | |||
| 68 | + | return func(t *TableModel) { | |||
| 69 | + | t.rows = rows | |||
| 70 | + | } | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | // WithBorder sets the table border style. | |||
| 74 | + | func WithBorder(b lipgloss.Border) TableOption { | |||
| 75 | + | return func(t *TableModel) { | |||
| 76 | + | t.border = b | |||
| 77 | + | } | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | // WithTableWidth sets the table width. | |||
| 81 | + | func WithTableWidth(w int) TableOption { | |||
| 82 | + | return func(t *TableModel) { | |||
| 83 | + | t.width = w | |||
| 84 | + | } | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | // WithHeaderStyle sets the header style. | |||
| 88 | + | func WithHeaderStyle(s lipgloss.Style) TableOption { | |||
| 89 | + | return func(t *TableModel) { | |||
| 90 | + | t.headerStyle = s | |||
| 91 | + | } | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | // WithCellStyle sets the cell style. | |||
| 95 | + | func WithCellStyle(s lipgloss.Style) TableOption { | |||
| 96 | + | return func(t *TableModel) { | |||
| 97 | + | t.cellStyle = s | |||
| 98 | + | t.oddStyle = s | |||
| 99 | + | t.evenStyle = s | |||
| 100 | + | } | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | // WithAlternateRows enables alternating row colors. | |||
| 104 | + | func WithAlternateRows(odd, even lipgloss.Style) TableOption { | |||
| 105 | + | return func(t *TableModel) { | |||
| 106 | + | t.oddStyle = odd | |||
| 107 | + | t.evenStyle = even | |||
| 108 | + | } | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | // SetColumns sets the table columns. | |||
| 112 | + | func (t *TableModel) SetColumns(cols []TableColumn) { | |||
| 113 | + | t.columns = cols | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | // SetRows sets the table rows. | |||
| 117 | + | func (t *TableModel) SetRows(rows [][]string) { | |||
| 118 | + | t.rows = rows | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | // AddRow adds a row to the table. | |||
| 122 | + | func (t *TableModel) AddRow(row []string) { | |||
| 123 | + | t.rows = append(t.rows, row) | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | // ClearRows removes all rows from the table. | |||
| 127 | + | func (t *TableModel) ClearRows() { | |||
| 128 | + | t.rows = nil | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | // Render renders the table as a string. | |||
| 132 | + | func (t *TableModel) Render() string { | |||
| 133 | + | if len(t.columns) == 0 { | |||
| 134 | + | return "" | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | // Extract headers | |||
| 138 | + | headers := make([]string, len(t.columns)) | |||
| 139 | + | for i, col := range t.columns { | |||
| 140 | + | headers[i] = col.Title | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | // Create table | |||
| 144 | + | tbl := table.New(). | |||
| 145 | + | Border(t.border). | |||
| 146 | + | BorderStyle(t.borderStyle). | |||
| 147 | + | Headers(headers...). | |||
| 148 | + | Rows(t.rows...). | |||
| 149 | + | StyleFunc(func(row, col int) lipgloss.Style { | |||
| 150 | + | if row == table.HeaderRow { | |||
| 151 | + | return t.headerStyle | |||
| 152 | + | } | |||
| 153 | + | if row%2 == 0 { | |||
| 154 | + | return t.evenStyle | |||
| 155 | + | } | |||
| 156 | + | return t.oddStyle | |||
| 157 | + | }) | |||
| 158 | + | ||||
| 159 | + | // Apply width if set | |||
| 160 | + | if t.width > 0 { | |||
| 161 | + | tbl = tbl.Width(t.width) | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | return tbl.String() | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | // View returns the table view (alias for Render). | |||
| 168 | + | func (t *TableModel) View() string { | |||
| 169 | + | return t.Render() | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | // SimpleTable creates a simple table with headers and rows. | |||
| 173 | + | // This is a convenience function for quick table rendering. | |||
| 174 | + | func SimpleTable(headers []string, rows [][]string) string { | |||
| 175 | + | cols := make([]TableColumn, len(headers)) | |||
| 176 | + | for i, h := range headers { | |||
| 177 | + | cols[i] = TableColumn{Title: h} | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | t := NewTable( | |||
| 181 | + | WithColumns(cols), | |||
| 182 | + | WithRows(rows), | |||
| 183 | + | ) | |||
| 184 | + | ||||
| 185 | + | return t.Render() | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | // RouteTable creates a table specifically for displaying routes. | |||
| 189 | + | func RouteTable(routes [][]string) string { | |||
| 190 | + | return SimpleTable( | |||
| 191 | + | []string{"Domain", "Image", "Status"}, | |||
| 192 | + | routes, | |||
| 193 | + | ) | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | // TokenTable creates a table specifically for displaying tokens. | |||
| 197 | + | func TokenTable(tokens [][]string) string { | |||
| 198 | + | return SimpleTable( | |||
| 199 | + | []string{"ID", "Subject", "Scopes", "Expires", "Revoked"}, | |||
| 200 | + | tokens, | |||
| 201 | + | ) | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | // SecretTable creates a table specifically for displaying secrets. | |||
| 205 | + | func SecretTable(secrets [][]string) string { | |||
| 206 | + | return SimpleTable( | |||
| 207 | + | []string{"Key", "Value"}, | |||
| 208 | + | secrets, | |||
| 209 | + | ) | |||
| 210 | + | } | |||
A internal/adapters/in/cli/ui/styles/colors.go +88 -0
| @@ -0,0 +1,88 @@ | |||||
| 1 | + | // Package styles provides a unified styling system for Gordon's TUI. | |||
| 2 | + | // The color palette is inspired by the Gordon webapp's terminal-themed design. | |||
| 3 | + | package styles | |||
| 4 | + | ||||
| 5 | + | import "github.com/charmbracelet/lipgloss" | |||
| 6 | + | ||||
| 7 | + | // Color definitions using the Gordon design system. | |||
| 8 | + | // These colors are adapted from the webapp CSS for terminal use. | |||
| 9 | + | var ( | |||
| 10 | + | // Primary colors - Terminal Green | |||
| 11 | + | Green50 = lipgloss.Color("#e8fdf4") | |||
| 12 | + | Green100 = lipgloss.Color("#c5fae4") | |||
| 13 | + | Green200 = lipgloss.Color("#8ef5c8") | |||
| 14 | + | Green300 = lipgloss.Color("#4dead7") | |||
| 15 | + | Green400 = lipgloss.Color("#1adf9a") | |||
| 16 | + | Green500 = lipgloss.Color("#00cc6a") | |||
| 17 | + | Green600 = lipgloss.Color("#00995a") | |||
| 18 | + | Green700 = lipgloss.Color("#007a4a") | |||
| 19 | + | Green800 = lipgloss.Color("#005c38") | |||
| 20 | + | Green900 = lipgloss.Color("#003d25") | |||
| 21 | + | ||||
| 22 | + | // Secondary colors - Cyan | |||
| 23 | + | Cyan50 = lipgloss.Color("#e6f9ff") | |||
| 24 | + | Cyan100 = lipgloss.Color("#c2f0ff") | |||
| 25 | + | Cyan200 = lipgloss.Color("#85e0ff") | |||
| 26 | + | Cyan300 = lipgloss.Color("#47d1ff") | |||
| 27 | + | Cyan400 = lipgloss.Color("#1ac5ff") | |||
| 28 | + | Cyan500 = lipgloss.Color("#00a0cc") | |||
| 29 | + | Cyan600 = lipgloss.Color("#0088b3") | |||
| 30 | + | Cyan700 = lipgloss.Color("#006d8f") | |||
| 31 | + | Cyan800 = lipgloss.Color("#00526c") | |||
| 32 | + | Cyan900 = lipgloss.Color("#003748") | |||
| 33 | + | ||||
| 34 | + | // Accent colors - Violet | |||
| 35 | + | Violet50 = lipgloss.Color("#f5f3ff") | |||
| 36 | + | Violet100 = lipgloss.Color("#ede9fe") | |||
| 37 | + | Violet200 = lipgloss.Color("#ddd6fe") | |||
| 38 | + | Violet300 = lipgloss.Color("#c4b5fd") | |||
| 39 | + | Violet400 = lipgloss.Color("#a78bfa") | |||
| 40 | + | Violet500 = lipgloss.Color("#8b5cf6") | |||
| 41 | + | Violet600 = lipgloss.Color("#7c3aed") | |||
| 42 | + | Violet700 = lipgloss.Color("#6d28d9") | |||
| 43 | + | Violet800 = lipgloss.Color("#5b21b6") | |||
| 44 | + | Violet900 = lipgloss.Color("#4c1d95") | |||
| 45 | + | ||||
| 46 | + | // Neutral colors - for text and backgrounds | |||
| 47 | + | Neutral50 = lipgloss.Color("#fafafa") | |||
| 48 | + | Neutral100 = lipgloss.Color("#f5f5f5") | |||
| 49 | + | Neutral200 = lipgloss.Color("#e5e5e5") | |||
| 50 | + | Neutral300 = lipgloss.Color("#d4d4d4") | |||
| 51 | + | Neutral400 = lipgloss.Color("#a3a3a3") | |||
| 52 | + | Neutral500 = lipgloss.Color("#737373") | |||
| 53 | + | Neutral600 = lipgloss.Color("#525252") | |||
| 54 | + | Neutral700 = lipgloss.Color("#404040") | |||
| 55 | + | Neutral800 = lipgloss.Color("#262626") | |||
| 56 | + | Neutral900 = lipgloss.Color("#171717") | |||
| 57 | + | Neutral950 = lipgloss.Color("#0a0a0a") | |||
| 58 | + | ||||
| 59 | + | // Terminal neon colors (for dark terminal backgrounds) | |||
| 60 | + | NeonGreen = lipgloss.Color("#00ff88") | |||
| 61 | + | NeonCyan = lipgloss.Color("#00ccff") | |||
| 62 | + | NeonViolet = lipgloss.Color("#a78bfa") | |||
| 63 | + | NeonRed = lipgloss.Color("#ff4444") | |||
| 64 | + | NeonYellow = lipgloss.Color("#fbbf24") | |||
| 65 | + | ||||
| 66 | + | // Semantic colors | |||
| 67 | + | ColorPrimary = NeonGreen | |||
| 68 | + | ColorSecondary = NeonCyan | |||
| 69 | + | ColorAccent = NeonViolet | |||
| 70 | + | ColorSuccess = NeonGreen | |||
| 71 | + | ColorWarning = NeonYellow | |||
| 72 | + | ColorError = NeonRed | |||
| 73 | + | ColorInfo = NeonCyan | |||
| 74 | + | ||||
| 75 | + | // Text colors | |||
| 76 | + | ColorText = Neutral200 | |||
| 77 | + | ColorTextMuted = Neutral500 | |||
| 78 | + | ColorTextBright = lipgloss.Color("#ffffff") | |||
| 79 | + | ||||
| 80 | + | // Background colors | |||
| 81 | + | ColorBg = lipgloss.Color("#000000") | |||
| 82 | + | ColorBgSurface = Neutral900 | |||
| 83 | + | ColorBgMuted = Neutral800 | |||
| 84 | + | ||||
| 85 | + | // Border colors | |||
| 86 | + | ColorBorder = Neutral700 | |||
| 87 | + | ColorBorderMuted = Neutral800 | |||
| 88 | + | ) | |||
A internal/adapters/in/cli/ui/styles/icons.go +79 -0
| @@ -0,0 +1,79 @@ | |||||
| 1 | + | package styles | |||
| 2 | + | ||||
| 3 | + | // Nerd Font icons for terminal UI. | |||
| 4 | + | // These icons require a Nerd Font compatible terminal font. | |||
| 5 | + | // Fallback ASCII alternatives are provided where practical. | |||
| 6 | + | const ( | |||
| 7 | + | // Status indicators | |||
| 8 | + | IconSuccess = "" // nf-fa-check (U+F00C) | |||
| 9 | + | IconError = "" // nf-fa-times (U+F00D) | |||
| 10 | + | IconWarning = "" // nf-fa-exclamation_triangle (U+F071) | |||
| 11 | + | IconInfo = "" // nf-fa-info_circle (U+F05A) | |||
| 12 | + | IconPending = "" // nf-fa-clock_o (U+F017) | |||
| 13 | + | ||||
| 14 | + | // Objects | |||
| 15 | + | IconRoute = "" // nf-fa-sitemap (U+F0E8) | |||
| 16 | + | IconSecret = "" // nf-fa-lock (U+F023) | |||
| 17 | + | IconToken = "" // nf-fa-key (U+F084) | |||
| 18 | + | IconContainer = "" // nf-oct-container (U+F489) | |||
| 19 | + | IconServer = "" // nf-fa-server (U+F233) | |||
| 20 | + | IconNetwork = "" // nf-md-lan (U+F06F3) | |||
| 21 | + | IconVolume = "" // nf-fa-database (U+F1C0) | |||
| 22 | + | IconImage = "" // nf-fa-archive (U+F187) | |||
| 23 | + | ||||
| 24 | + | // Actions | |||
| 25 | + | IconAdd = "" // nf-fa-plus (U+F067) | |||
| 26 | + | IconRemove = "" // nf-fa-minus (U+F068) | |||
| 27 | + | IconEdit = "" // nf-fa-pencil (U+F040) | |||
| 28 | + | IconRefresh = "" // nf-fa-refresh (U+F021) | |||
| 29 | + | IconUpload = "" // nf-fa-cloud_upload (U+F0EE) | |||
| 30 | + | IconDelete = "" // nf-fa-trash (U+F1F8) | |||
| 31 | + | ||||
| 32 | + | // Navigation | |||
| 33 | + | IconArrowRight = "" // nf-fa-arrow_right (U+F061) | |||
| 34 | + | IconArrowLeft = "" // nf-fa-arrow_left (U+F060) | |||
| 35 | + | IconArrowUp = "" // nf-fa-arrow_up (U+F062) | |||
| 36 | + | IconArrowDown = "" // nf-fa-arrow_down (U+F063) | |||
| 37 | + | IconChevron = "" // nf-fa-chevron_right (U+F054) | |||
| 38 | + | ||||
| 39 | + | // UI elements | |||
| 40 | + | IconBullet = "▸" // Simple triangle bullet | |||
| 41 | + | IconDot = "●" // Filled circle | |||
| 42 | + | IconDotEmpty = "○" // Empty circle | |||
| 43 | + | IconCheckbox = "" // nf-fa-square_o (U+F096) | |||
| 44 | + | IconChecked = "" // nf-fa-check_square_o (U+F046) | |||
| 45 | + | IconRadio = "" // nf-fa-circle_o (U+F10C) | |||
| 46 | + | IconRadioCheck = "" // nf-fa-dot_circle_o (U+F192) | |||
| 47 | + | ||||
| 48 | + | // Spinners (individual frames) | |||
| 49 | + | SpinnerDot = "⣾⣽⣻⢿⡿⣟⣯⣷" | |||
| 50 | + | SpinnerLine = "|/-\\" | |||
| 51 | + | SpinnerCircle = "◐◓◑◒" | |||
| 52 | + | SpinnerBrail = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" | |||
| 53 | + | ||||
| 54 | + | // Borders and decorations | |||
| 55 | + | BorderHorizontal = "─" | |||
| 56 | + | BorderVertical = "│" | |||
| 57 | + | BorderCornerTL = "┌" | |||
| 58 | + | BorderCornerTR = "┐" | |||
| 59 | + | BorderCornerBL = "└" | |||
| 60 | + | BorderCornerBR = "┘" | |||
| 61 | + | BorderT = "┬" | |||
| 62 | + | BorderB = "┴" | |||
| 63 | + | BorderL = "├" | |||
| 64 | + | BorderR = "┤" | |||
| 65 | + | BorderCross = "┼" | |||
| 66 | + | ||||
| 67 | + | // Gordon branding | |||
| 68 | + | IconGordon = "" // nf-fa-cube (U+F1B2) | |||
| 69 | + | ) | |||
| 70 | + | ||||
| 71 | + | // ASCII fallback alternatives for terminals without Nerd Fonts. | |||
| 72 | + | const ( | |||
| 73 | + | AsciiSuccess = "[OK]" | |||
| 74 | + | AsciiError = "[X]" | |||
| 75 | + | AsciiWarning = "[!]" | |||
| 76 | + | AsciiInfo = "[i]" | |||
| 77 | + | AsciiBullet = ">" | |||
| 78 | + | AsciiDot = "*" | |||
| 79 | + | ) | |||
A internal/adapters/in/cli/ui/styles/theme.go +279 -0
| @@ -0,0 +1,279 @@ | |||||
| 1 | + | package styles | |||
| 2 | + | ||||
| 3 | + | import "github.com/charmbracelet/lipgloss" | |||
| 4 | + | ||||
| 5 | + | // Theme contains all composed styles for the Gordon TUI. | |||
| 6 | + | // Styles are organized by component type for easy discovery. | |||
| 7 | + | var Theme = struct { | |||
| 8 | + | // Text styles | |||
| 9 | + | Title lipgloss.Style | |||
| 10 | + | Subtitle lipgloss.Style | |||
| 11 | + | Heading lipgloss.Style | |||
| 12 | + | Body lipgloss.Style | |||
| 13 | + | Muted lipgloss.Style | |||
| 14 | + | Bold lipgloss.Style | |||
| 15 | + | Highlight lipgloss.Style | |||
| 16 | + | ||||
| 17 | + | // Status styles | |||
| 18 | + | Success lipgloss.Style | |||
| 19 | + | Error lipgloss.Style | |||
| 20 | + | Warning lipgloss.Style | |||
| 21 | + | Info lipgloss.Style | |||
| 22 | + | ||||
| 23 | + | // Badge styles (compact status indicators) | |||
| 24 | + | BadgeSuccess lipgloss.Style | |||
| 25 | + | BadgeError lipgloss.Style | |||
| 26 | + | BadgeWarning lipgloss.Style | |||
| 27 | + | BadgeInfo lipgloss.Style | |||
| 28 | + | BadgePending lipgloss.Style | |||
| 29 | + | ||||
| 30 | + | // Table styles | |||
| 31 | + | TableHeader lipgloss.Style | |||
| 32 | + | TableRow lipgloss.Style | |||
| 33 | + | TableRowSelected lipgloss.Style | |||
| 34 | + | TableRowAlt lipgloss.Style | |||
| 35 | + | TableCell lipgloss.Style | |||
| 36 | + | TableBorder lipgloss.Style | |||
| 37 | + | ||||
| 38 | + | // Form styles | |||
| 39 | + | FormLabel lipgloss.Style | |||
| 40 | + | FormInput lipgloss.Style | |||
| 41 | + | FormPlaceholder lipgloss.Style | |||
| 42 | + | FormFocused lipgloss.Style | |||
| 43 | + | FormError lipgloss.Style | |||
| 44 | + | ||||
| 45 | + | // List styles | |||
| 46 | + | ListItem lipgloss.Style | |||
| 47 | + | ListItemSelected lipgloss.Style | |||
| 48 | + | ListBullet lipgloss.Style | |||
| 49 | + | ||||
| 50 | + | // Box/Container styles | |||
| 51 | + | Box lipgloss.Style | |||
| 52 | + | BoxHighlight lipgloss.Style | |||
| 53 | + | BoxError lipgloss.Style | |||
| 54 | + | ||||
| 55 | + | // Banner/Header styles | |||
| 56 | + | Banner lipgloss.Style | |||
| 57 | + | BannerTitle lipgloss.Style | |||
| 58 | + | ||||
| 59 | + | // Help styles | |||
| 60 | + | HelpKey lipgloss.Style | |||
| 61 | + | HelpDesc lipgloss.Style | |||
| 62 | + | }{ | |||
| 63 | + | // Text styles | |||
| 64 | + | Title: lipgloss.NewStyle(). | |||
| 65 | + | Bold(true). | |||
| 66 | + | Foreground(ColorPrimary), | |||
| 67 | + | ||||
| 68 | + | Subtitle: lipgloss.NewStyle(). | |||
| 69 | + | Foreground(ColorTextMuted), | |||
| 70 | + | ||||
| 71 | + | Heading: lipgloss.NewStyle(). | |||
| 72 | + | Bold(true). | |||
| 73 | + | Foreground(ColorText), | |||
| 74 | + | ||||
| 75 | + | Body: lipgloss.NewStyle(). | |||
| 76 | + | Foreground(ColorText), | |||
| 77 | + | ||||
| 78 | + | Muted: lipgloss.NewStyle(). | |||
| 79 | + | Foreground(ColorTextMuted), | |||
| 80 | + | ||||
| 81 | + | Bold: lipgloss.NewStyle(). | |||
| 82 | + | Bold(true). | |||
| 83 | + | Foreground(ColorText), | |||
| 84 | + | ||||
| 85 | + | Highlight: lipgloss.NewStyle(). | |||
| 86 | + | Foreground(ColorPrimary), | |||
| 87 | + | ||||
| 88 | + | // Status styles | |||
| 89 | + | Success: lipgloss.NewStyle(). | |||
| 90 | + | Foreground(ColorSuccess), | |||
| 91 | + | ||||
| 92 | + | Error: lipgloss.NewStyle(). | |||
| 93 | + | Foreground(ColorError), | |||
| 94 | + | ||||
| 95 | + | Warning: lipgloss.NewStyle(). | |||
| 96 | + | Foreground(ColorWarning), | |||
| 97 | + | ||||
| 98 | + | Info: lipgloss.NewStyle(). | |||
| 99 | + | Foreground(ColorInfo), | |||
| 100 | + | ||||
| 101 | + | // Badge styles | |||
| 102 | + | BadgeSuccess: lipgloss.NewStyle(). | |||
| 103 | + | Foreground(ColorBg). | |||
| 104 | + | Background(ColorSuccess). | |||
| 105 | + | Padding(0, 1), | |||
| 106 | + | ||||
| 107 | + | BadgeError: lipgloss.NewStyle(). | |||
| 108 | + | Foreground(ColorBg). | |||
| 109 | + | Background(ColorError). | |||
| 110 | + | Padding(0, 1), | |||
| 111 | + | ||||
| 112 | + | BadgeWarning: lipgloss.NewStyle(). | |||
| 113 | + | Foreground(ColorBg). | |||
| 114 | + | Background(ColorWarning). | |||
| 115 | + | Padding(0, 1), | |||
| 116 | + | ||||
| 117 | + | BadgeInfo: lipgloss.NewStyle(). | |||
| 118 | + | Foreground(ColorBg). | |||
| 119 | + | Background(ColorInfo). | |||
| 120 | + | Padding(0, 1), | |||
| 121 | + | ||||
| 122 | + | BadgePending: lipgloss.NewStyle(). | |||
| 123 | + | Foreground(ColorBg). | |||
| 124 | + | Background(ColorTextMuted). | |||
| 125 | + | Padding(0, 1), | |||
| 126 | + | ||||
| 127 | + | // Table styles | |||
| 128 | + | TableHeader: lipgloss.NewStyle(). | |||
| 129 | + | Bold(true). | |||
| 130 | + | Foreground(ColorPrimary). | |||
| 131 | + | BorderBottom(true). | |||
| 132 | + | BorderStyle(lipgloss.NormalBorder()). | |||
| 133 | + | BorderForeground(ColorBorder), | |||
| 134 | + | ||||
| 135 | + | TableRow: lipgloss.NewStyle(). | |||
| 136 | + | Foreground(ColorText), | |||
| 137 | + | ||||
| 138 | + | TableRowSelected: lipgloss.NewStyle(). | |||
| 139 | + | Foreground(ColorPrimary). | |||
| 140 | + | Background(ColorBgMuted), | |||
| 141 | + | ||||
| 142 | + | TableRowAlt: lipgloss.NewStyle(). | |||
| 143 | + | Foreground(ColorTextMuted), | |||
| 144 | + | ||||
| 145 | + | TableCell: lipgloss.NewStyle(). | |||
| 146 | + | Padding(0, 1), | |||
| 147 | + | ||||
| 148 | + | TableBorder: lipgloss.NewStyle(). | |||
| 149 | + | Foreground(ColorBorder), | |||
| 150 | + | ||||
| 151 | + | // Form styles | |||
| 152 | + | FormLabel: lipgloss.NewStyle(). | |||
| 153 | + | Foreground(ColorText). | |||
| 154 | + | Bold(true), | |||
| 155 | + | ||||
| 156 | + | FormInput: lipgloss.NewStyle(). | |||
| 157 | + | Foreground(ColorText). | |||
| 158 | + | Background(ColorBgMuted). | |||
| 159 | + | Padding(0, 1), | |||
| 160 | + | ||||
| 161 | + | FormPlaceholder: lipgloss.NewStyle(). | |||
| 162 | + | Foreground(ColorTextMuted), | |||
| 163 | + | ||||
| 164 | + | FormFocused: lipgloss.NewStyle(). | |||
| 165 | + | Foreground(ColorPrimary). | |||
| 166 | + | Background(ColorBgMuted). | |||
| 167 | + | BorderLeft(true). | |||
| 168 | + | BorderStyle(lipgloss.NormalBorder()). | |||
| 169 | + | BorderForeground(ColorPrimary). | |||
| 170 | + | Padding(0, 1), | |||
| 171 | + | ||||
| 172 | + | FormError: lipgloss.NewStyle(). | |||
| 173 | + | Foreground(ColorError), | |||
| 174 | + | ||||
| 175 | + | // List styles | |||
| 176 | + | ListItem: lipgloss.NewStyle(). | |||
| 177 | + | Foreground(ColorText). | |||
| 178 | + | PaddingLeft(2), | |||
| 179 | + | ||||
| 180 | + | ListItemSelected: lipgloss.NewStyle(). | |||
| 181 | + | Foreground(ColorPrimary). | |||
| 182 | + | PaddingLeft(2), | |||
| 183 | + | ||||
| 184 | + | ListBullet: lipgloss.NewStyle(). | |||
| 185 | + | Foreground(ColorPrimary), | |||
| 186 | + | ||||
| 187 | + | // Box/Container styles | |||
| 188 | + | Box: lipgloss.NewStyle(). | |||
| 189 | + | Border(lipgloss.RoundedBorder()). | |||
| 190 | + | BorderForeground(ColorBorder). | |||
| 191 | + | Padding(1, 2), | |||
| 192 | + | ||||
| 193 | + | BoxHighlight: lipgloss.NewStyle(). | |||
| 194 | + | Border(lipgloss.RoundedBorder()). | |||
| 195 | + | BorderForeground(ColorPrimary). | |||
| 196 | + | Padding(1, 2), | |||
| 197 | + | ||||
| 198 | + | BoxError: lipgloss.NewStyle(). | |||
| 199 | + | Border(lipgloss.RoundedBorder()). | |||
| 200 | + | BorderForeground(ColorError). | |||
| 201 | + | Padding(1, 2), | |||
| 202 | + | ||||
| 203 | + | // Banner/Header styles | |||
| 204 | + | Banner: lipgloss.NewStyle(). | |||
| 205 | + | Border(lipgloss.RoundedBorder()). | |||
| 206 | + | BorderForeground(ColorPrimary). | |||
| 207 | + | Padding(0, 2), | |||
| 208 | + | ||||
| 209 | + | BannerTitle: lipgloss.NewStyle(). | |||
| 210 | + | Bold(true). | |||
| 211 | + | Foreground(ColorPrimary), | |||
| 212 | + | ||||
| 213 | + | // Help styles | |||
| 214 | + | HelpKey: lipgloss.NewStyle(). | |||
| 215 | + | Foreground(ColorPrimary), | |||
| 216 | + | ||||
| 217 | + | HelpDesc: lipgloss.NewStyle(). | |||
| 218 | + | Foreground(ColorTextMuted), | |||
| 219 | + | } | |||
| 220 | + | ||||
| 221 | + | // Render helpers for common patterns. | |||
| 222 | + | ||||
| 223 | + | // RenderStatus returns a styled status indicator with icon. | |||
| 224 | + | func RenderStatus(status string, running bool) string { | |||
| 225 | + | if running { | |||
| 226 | + | return Theme.Success.Render(IconSuccess + " " + status) | |||
| 227 | + | } | |||
| 228 | + | return Theme.Error.Render(IconError + " " + status) | |||
| 229 | + | } | |||
| 230 | + | ||||
| 231 | + | // RenderBadge returns a styled badge for the given status. | |||
| 232 | + | func RenderBadge(status string) string { | |||
| 233 | + | switch status { | |||
| 234 | + | case "running", "success", "ok", "active": | |||
| 235 | + | return Theme.BadgeSuccess.Render(status) | |||
| 236 | + | case "error", "failed", "stopped": | |||
| 237 | + | return Theme.BadgeError.Render(status) | |||
| 238 | + | case "warning", "degraded": | |||
| 239 | + | return Theme.BadgeWarning.Render(status) | |||
| 240 | + | case "pending", "starting": | |||
| 241 | + | return Theme.BadgePending.Render(status) | |||
| 242 | + | default: | |||
| 243 | + | return Theme.BadgeInfo.Render(status) | |||
| 244 | + | } | |||
| 245 | + | } | |||
| 246 | + | ||||
| 247 | + | // RenderKeyHelp returns formatted key binding help text. | |||
| 248 | + | func RenderKeyHelp(key, desc string) string { | |||
| 249 | + | return Theme.HelpKey.Render(key) + " " + Theme.HelpDesc.Render(desc) | |||
| 250 | + | } | |||
| 251 | + | ||||
| 252 | + | // RenderListItem returns a formatted list item with bullet. | |||
| 253 | + | func RenderListItem(item string, selected bool) string { | |||
| 254 | + | bullet := Theme.ListBullet.Render(IconBullet) | |||
| 255 | + | if selected { | |||
| 256 | + | return bullet + " " + Theme.ListItemSelected.Render(item) | |||
| 257 | + | } | |||
| 258 | + | return bullet + " " + Theme.ListItem.Render(item) | |||
| 259 | + | } | |||
| 260 | + | ||||
| 261 | + | // RenderError returns a styled error message. | |||
| 262 | + | func RenderError(msg string) string { | |||
| 263 | + | return Theme.Error.Render(IconError + " " + msg) | |||
| 264 | + | } | |||
| 265 | + | ||||
| 266 | + | // RenderSuccess returns a styled success message. | |||
| 267 | + | func RenderSuccess(msg string) string { | |||
| 268 | + | return Theme.Success.Render(IconSuccess + " " + msg) | |||
| 269 | + | } | |||
| 270 | + | ||||
| 271 | + | // RenderWarning returns a styled warning message. | |||
| 272 | + | func RenderWarning(msg string) string { | |||
| 273 | + | return Theme.Warning.Render(IconWarning + " " + msg) | |||
| 274 | + | } | |||
| 275 | + | ||||
| 276 | + | // RenderInfo returns a styled info message. | |||
| 277 | + | func RenderInfo(msg string) string { | |||
| 278 | + | return Theme.Info.Render(IconInfo + " " + msg) | |||
| 279 | + | } | |||
A internal/adapters/in/http/admin/handler.go +486 -0
| @@ -0,0 +1,486 @@ | |||||
| 1 | + | // Package admin implements the HTTP adapter for the admin API. | |||
| 2 | + | package admin | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "errors" | |||
| 7 | + | "net/http" | |||
| 8 | + | "strings" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/zerowrap" | |||
| 11 | + | ||||
| 12 | + | "gordon/internal/boundaries/in" | |||
| 13 | + | "gordon/internal/boundaries/out" | |||
| 14 | + | "gordon/internal/domain" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // maxAdminRequestSize is the maximum allowed size for admin API request bodies. | |||
| 18 | + | const maxAdminRequestSize = 1 << 20 // 1MB | |||
| 19 | + | ||||
| 20 | + | // Handler implements the HTTP handler for the admin API. | |||
| 21 | + | type Handler struct { | |||
| 22 | + | configSvc in.ConfigService | |||
| 23 | + | authSvc in.AuthService | |||
| 24 | + | containerSvc in.ContainerService | |||
| 25 | + | healthSvc in.HealthService | |||
| 26 | + | secretSvc in.SecretService | |||
| 27 | + | eventBus out.EventPublisher | |||
| 28 | + | log zerowrap.Logger | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | // NewHandler creates a new admin HTTP handler. | |||
| 32 | + | func NewHandler( | |||
| 33 | + | configSvc in.ConfigService, | |||
| 34 | + | authSvc in.AuthService, | |||
| 35 | + | containerSvc in.ContainerService, | |||
| 36 | + | healthSvc in.HealthService, | |||
| 37 | + | secretSvc in.SecretService, | |||
| 38 | + | eventBus out.EventPublisher, | |||
| 39 | + | log zerowrap.Logger, | |||
| 40 | + | ) *Handler { | |||
| 41 | + | return &Handler{ | |||
| 42 | + | configSvc: configSvc, | |||
| 43 | + | authSvc: authSvc, | |||
| 44 | + | containerSvc: containerSvc, | |||
| 45 | + | healthSvc: healthSvc, | |||
| 46 | + | secretSvc: secretSvc, | |||
| 47 | + | eventBus: eventBus, | |||
| 48 | + | log: log, | |||
| 49 | + | } | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | // RegisterRoutes registers the admin routes on the given mux. | |||
| 53 | + | func (h *Handler) RegisterRoutes(mux *http.ServeMux) { | |||
| 54 | + | mux.HandleFunc("/admin/", h.handleAdminRoutes) | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | // ServeHTTP implements http.Handler. | |||
| 58 | + | func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||
| 59 | + | h.handleAdminRoutes(w, r) | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | func (h *Handler) handleAdminRoutes(w http.ResponseWriter, r *http.Request) { | |||
| 63 | + | ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{ | |||
| 64 | + | zerowrap.FieldLayer: "adapter", | |||
| 65 | + | zerowrap.FieldAdapter: "http", | |||
| 66 | + | zerowrap.FieldHandler: "admin", | |||
| 67 | + | zerowrap.FieldMethod: r.Method, | |||
| 68 | + | zerowrap.FieldPath: r.URL.Path, | |||
| 69 | + | }) | |||
| 70 | + | r = r.WithContext(ctx) | |||
| 71 | + | ||||
| 72 | + | path := strings.TrimPrefix(r.URL.Path, "/admin") | |||
| 73 | + | ||||
| 74 | + | // Route to appropriate handler | |||
| 75 | + | switch { | |||
| 76 | + | case path == "/routes" || strings.HasPrefix(path, "/routes/"): | |||
| 77 | + | h.handleRoutes(w, r, path) | |||
| 78 | + | case path == "/secrets" || strings.HasPrefix(path, "/secrets/"): | |||
| 79 | + | h.handleSecrets(w, r, path) | |||
| 80 | + | case path == "/status": | |||
| 81 | + | h.handleStatus(w, r) | |||
| 82 | + | case path == "/health": | |||
| 83 | + | h.handleHealth(w, r) | |||
| 84 | + | case path == "/reload": | |||
| 85 | + | h.handleReload(w, r) | |||
| 86 | + | case path == "/config": | |||
| 87 | + | h.handleConfig(w, r) | |||
| 88 | + | default: | |||
| 89 | + | http.NotFound(w, r) | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | // sendJSON sends a JSON response. | |||
| 94 | + | func (h *Handler) sendJSON(w http.ResponseWriter, status int, data any) { | |||
| 95 | + | w.Header().Set("Content-Type", "application/json") | |||
| 96 | + | w.WriteHeader(status) | |||
| 97 | + | if data != nil { | |||
| 98 | + | _ = json.NewEncoder(w).Encode(data) | |||
| 99 | + | } | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | // sendError sends an error response. | |||
| 103 | + | func (h *Handler) sendError(w http.ResponseWriter, status int, message string) { | |||
| 104 | + | h.sendJSON(w, status, map[string]string{"error": message}) | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | // handleRoutes handles /admin/routes endpoints. | |||
| 108 | + | func (h *Handler) handleRoutes(w http.ResponseWriter, r *http.Request, path string) { | |||
| 109 | + | // Parse domain from path if present | |||
| 110 | + | routeDomain := strings.TrimPrefix(path, "/routes/") | |||
| 111 | + | if routeDomain == "/routes" { | |||
| 112 | + | routeDomain = "" | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | switch r.Method { | |||
| 116 | + | case http.MethodGet: | |||
| 117 | + | h.handleRoutesGet(w, r, routeDomain) | |||
| 118 | + | case http.MethodPost: | |||
| 119 | + | h.handleRoutesPost(w, r) | |||
| 120 | + | case http.MethodPut: | |||
| 121 | + | h.handleRoutesPut(w, r, routeDomain) | |||
| 122 | + | case http.MethodDelete: | |||
| 123 | + | h.handleRoutesDelete(w, r, routeDomain) | |||
| 124 | + | default: | |||
| 125 | + | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) | |||
| 126 | + | } | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | func (h *Handler) handleRoutesGet(w http.ResponseWriter, r *http.Request, routeDomain string) { | |||
| 130 | + | ctx := r.Context() | |||
| 131 | + | ||||
| 132 | + | // Check read permission | |||
| 133 | + | if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionRead) { | |||
| 134 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:read") | |||
| 135 | + | return | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | if routeDomain == "" { | |||
| 139 | + | routes := h.configSvc.GetRoutes(ctx) | |||
| 140 | + | h.sendJSON(w, http.StatusOK, map[string]any{"routes": routes}) | |||
| 141 | + | return | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | route, err := h.configSvc.GetRoute(ctx, routeDomain) | |||
| 145 | + | if err != nil { | |||
| 146 | + | h.sendError(w, http.StatusNotFound, "route not found") | |||
| 147 | + | return | |||
| 148 | + | } | |||
| 149 | + | h.sendJSON(w, http.StatusOK, route) | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | func (h *Handler) handleRoutesPost(w http.ResponseWriter, r *http.Request) { | |||
| 153 | + | ctx := r.Context() | |||
| 154 | + | log := zerowrap.FromCtx(ctx) | |||
| 155 | + | ||||
| 156 | + | // Check write permission | |||
| 157 | + | if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionWrite) { | |||
| 158 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:write") | |||
| 159 | + | return | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | // Limit request body size | |||
| 163 | + | r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize) | |||
| 164 | + | ||||
| 165 | + | var route domain.Route | |||
| 166 | + | if err := json.NewDecoder(r.Body).Decode(&route); err != nil { | |||
| 167 | + | log.Warn().Err(err).Msg("invalid route JSON") | |||
| 168 | + | h.sendError(w, http.StatusBadRequest, "invalid JSON") | |||
| 169 | + | return | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | if err := h.configSvc.AddRoute(ctx, route); err != nil { | |||
| 173 | + | log.Error().Err(err).Str("domain", route.Domain).Msg("failed to add route") | |||
| 174 | + | switch { | |||
| 175 | + | case errors.Is(err, domain.ErrRouteDomainEmpty), errors.Is(err, domain.ErrRouteImageEmpty): | |||
| 176 | + | h.sendError(w, http.StatusBadRequest, err.Error()) | |||
| 177 | + | default: | |||
| 178 | + | h.sendError(w, http.StatusInternalServerError, "failed to add route") | |||
| 179 | + | } | |||
| 180 | + | return | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | log.Info().Str("domain", route.Domain).Str("image", route.Image).Msg("route added") | |||
| 184 | + | h.sendJSON(w, http.StatusCreated, route) | |||
| 185 | + | } | |||
| 186 | + | ||||
| 187 | + | func (h *Handler) handleRoutesPut(w http.ResponseWriter, r *http.Request, routeDomain string) { | |||
| 188 | + | ctx := r.Context() | |||
| 189 | + | log := zerowrap.FromCtx(ctx) | |||
| 190 | + | ||||
| 191 | + | // Check write permission | |||
| 192 | + | if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionWrite) { | |||
| 193 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:write") | |||
| 194 | + | return | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | if routeDomain == "" { | |||
| 198 | + | h.sendError(w, http.StatusBadRequest, "domain required in path") | |||
| 199 | + | return | |||
| 200 | + | } | |||
| 201 | + | ||||
| 202 | + | // Limit request body size | |||
| 203 | + | r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize) | |||
| 204 | + | ||||
| 205 | + | var route domain.Route | |||
| 206 | + | if err := json.NewDecoder(r.Body).Decode(&route); err != nil { | |||
| 207 | + | log.Warn().Err(err).Msg("invalid route JSON") | |||
| 208 | + | h.sendError(w, http.StatusBadRequest, "invalid JSON") | |||
| 209 | + | return | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | route.Domain = routeDomain | |||
| 213 | + | ||||
| 214 | + | if err := h.configSvc.UpdateRoute(ctx, route); err != nil { | |||
| 215 | + | log.Error().Err(err).Str("domain", routeDomain).Msg("failed to update route") | |||
| 216 | + | switch { | |||
| 217 | + | case errors.Is(err, domain.ErrRouteNotFound): | |||
| 218 | + | h.sendError(w, http.StatusNotFound, "route not found") | |||
| 219 | + | case errors.Is(err, domain.ErrRouteDomainEmpty), errors.Is(err, domain.ErrRouteImageEmpty): | |||
| 220 | + | h.sendError(w, http.StatusBadRequest, err.Error()) | |||
| 221 | + | default: | |||
| 222 | + | h.sendError(w, http.StatusInternalServerError, "failed to update route") | |||
| 223 | + | } | |||
| 224 | + | return | |||
| 225 | + | } | |||
| 226 | + | ||||
| 227 | + | log.Info().Str("domain", route.Domain).Str("image", route.Image).Msg("route updated") | |||
| 228 | + | h.sendJSON(w, http.StatusOK, route) | |||
| 229 | + | } | |||
| 230 | + | ||||
| 231 | + | func (h *Handler) handleRoutesDelete(w http.ResponseWriter, r *http.Request, routeDomain string) { | |||
| 232 | + | ctx := r.Context() | |||
| 233 | + | log := zerowrap.FromCtx(ctx) | |||
| 234 | + | ||||
| 235 | + | // Check write permission | |||
| 236 | + | if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionWrite) { | |||
| 237 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:write") | |||
| 238 | + | return | |||
| 239 | + | } | |||
| 240 | + | ||||
| 241 | + | if routeDomain == "" { | |||
| 242 | + | h.sendError(w, http.StatusBadRequest, "domain required in path") | |||
| 243 | + | return | |||
| 244 | + | } | |||
| 245 | + | ||||
| 246 | + | if err := h.configSvc.RemoveRoute(ctx, routeDomain); err != nil { | |||
| 247 | + | log.Error().Err(err).Str("domain", routeDomain).Msg("failed to remove route") | |||
| 248 | + | h.sendError(w, http.StatusInternalServerError, "failed to remove route") | |||
| 249 | + | return | |||
| 250 | + | } | |||
| 251 | + | ||||
| 252 | + | log.Info().Str("domain", routeDomain).Msg("route removed") | |||
| 253 | + | h.sendJSON(w, http.StatusOK, map[string]string{"status": "removed"}) | |||
| 254 | + | } | |||
| 255 | + | ||||
| 256 | + | // handleSecrets handles /admin/secrets endpoints. | |||
| 257 | + | func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path string) { | |||
| 258 | + | ctx := r.Context() | |||
| 259 | + | log := zerowrap.FromCtx(ctx) | |||
| 260 | + | ||||
| 261 | + | // Parse path: /secrets/{domain} or /secrets/{domain}/{key} | |||
| 262 | + | parts := strings.Split(strings.TrimPrefix(path, "/secrets/"), "/") | |||
| 263 | + | if len(parts) == 0 || parts[0] == "" { | |||
| 264 | + | h.sendError(w, http.StatusBadRequest, "domain required") | |||
| 265 | + | return | |||
| 266 | + | } | |||
| 267 | + | ||||
| 268 | + | secretDomain := parts[0] | |||
| 269 | + | secretKey := "" | |||
| 270 | + | if len(parts) > 1 { | |||
| 271 | + | secretKey = parts[1] | |||
| 272 | + | } | |||
| 273 | + | ||||
| 274 | + | switch r.Method { | |||
| 275 | + | case http.MethodGet: | |||
| 276 | + | // Check read permission | |||
| 277 | + | if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionRead) { | |||
| 278 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:read") | |||
| 279 | + | return | |||
| 280 | + | } | |||
| 281 | + | // List secrets for domain (names only, not values) | |||
| 282 | + | keys, err := h.secretSvc.ListKeys(ctx, secretDomain) | |||
| 283 | + | if err != nil { | |||
| 284 | + | log.Error().Err(err).Str("domain", secretDomain).Msg("failed to list secrets") | |||
| 285 | + | h.sendError(w, http.StatusBadRequest, "invalid domain") | |||
| 286 | + | return | |||
| 287 | + | } | |||
| 288 | + | h.sendJSON(w, http.StatusOK, map[string]any{"domain": secretDomain, "keys": keys}) | |||
| 289 | + | ||||
| 290 | + | case http.MethodPost: | |||
| 291 | + | // Check write permission | |||
| 292 | + | if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) { | |||
| 293 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write") | |||
| 294 | + | return | |||
| 295 | + | } | |||
| 296 | + | ||||
| 297 | + | // Limit request body size | |||
| 298 | + | r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize) | |||
| 299 | + | ||||
| 300 | + | // Set secret(s) | |||
| 301 | + | var data map[string]string | |||
| 302 | + | if err := json.NewDecoder(r.Body).Decode(&data); err != nil { | |||
| 303 | + | log.Warn().Err(err).Msg("invalid secrets JSON") | |||
| 304 | + | h.sendError(w, http.StatusBadRequest, "invalid JSON") | |||
| 305 | + | return | |||
| 306 | + | } | |||
| 307 | + | ||||
| 308 | + | if err := h.secretSvc.Set(ctx, secretDomain, data); err != nil { | |||
| 309 | + | log.Error().Err(err).Str("domain", secretDomain).Msg("failed to set secrets") | |||
| 310 | + | h.sendError(w, http.StatusBadRequest, "invalid domain") | |||
| 311 | + | return | |||
| 312 | + | } | |||
| 313 | + | ||||
| 314 | + | log.Info().Str("domain", secretDomain).Int("count", len(data)).Msg("secrets set") | |||
| 315 | + | h.sendJSON(w, http.StatusOK, map[string]string{"status": "updated"}) | |||
| 316 | + | ||||
| 317 | + | case http.MethodDelete: | |||
| 318 | + | // Check write permission | |||
| 319 | + | if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) { | |||
| 320 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write") | |||
| 321 | + | return | |||
| 322 | + | } | |||
| 323 | + | if secretKey == "" { | |||
| 324 | + | h.sendError(w, http.StatusBadRequest, "key required in path") | |||
| 325 | + | return | |||
| 326 | + | } | |||
| 327 | + | ||||
| 328 | + | if err := h.secretSvc.Delete(ctx, secretDomain, secretKey); err != nil { | |||
| 329 | + | log.Error().Err(err).Str("domain", secretDomain).Str("key", secretKey).Msg("failed to delete secret") | |||
| 330 | + | h.sendError(w, http.StatusBadRequest, "invalid domain") | |||
| 331 | + | return | |||
| 332 | + | } | |||
| 333 | + | ||||
| 334 | + | log.Info().Str("domain", secretDomain).Str("key", secretKey).Msg("secret deleted") | |||
| 335 | + | h.sendJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) | |||
| 336 | + | ||||
| 337 | + | default: | |||
| 338 | + | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) | |||
| 339 | + | } | |||
| 340 | + | } | |||
| 341 | + | ||||
| 342 | + | // handleHealth handles /admin/health endpoint. | |||
| 343 | + | // Returns detailed health status for all routes including HTTP probe results. | |||
| 344 | + | func (h *Handler) handleHealth(w http.ResponseWriter, r *http.Request) { | |||
| 345 | + | if r.Method != http.MethodGet { | |||
| 346 | + | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) | |||
| 347 | + | return | |||
| 348 | + | } | |||
| 349 | + | ||||
| 350 | + | ctx := r.Context() | |||
| 351 | + | ||||
| 352 | + | // Check read permission | |||
| 353 | + | if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) { | |||
| 354 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read") | |||
| 355 | + | return | |||
| 356 | + | } | |||
| 357 | + | ||||
| 358 | + | health := h.healthSvc.CheckAllRoutes(ctx) | |||
| 359 | + | ||||
| 360 | + | // Convert to JSON-friendly format | |||
| 361 | + | result := make(map[string]any, len(health)) | |||
| 362 | + | for domain, h := range health { | |||
| 363 | + | result[domain] = map[string]any{ | |||
| 364 | + | "container_status": h.ContainerStatus, | |||
| 365 | + | "http_status": h.HTTPStatus, | |||
| 366 | + | "response_time_ms": h.ResponseTimeMs, | |||
| 367 | + | "healthy": h.Healthy, | |||
| 368 | + | "error": h.Error, | |||
| 369 | + | } | |||
| 370 | + | } | |||
| 371 | + | ||||
| 372 | + | h.sendJSON(w, http.StatusOK, map[string]any{"health": result}) | |||
| 373 | + | } | |||
| 374 | + | ||||
| 375 | + | // handleStatus handles /admin/status endpoint. | |||
| 376 | + | func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) { | |||
| 377 | + | if r.Method != http.MethodGet { | |||
| 378 | + | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) | |||
| 379 | + | return | |||
| 380 | + | } | |||
| 381 | + | ||||
| 382 | + | ctx := r.Context() | |||
| 383 | + | ||||
| 384 | + | // Check read permission | |||
| 385 | + | if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) { | |||
| 386 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read") | |||
| 387 | + | return | |||
| 388 | + | } | |||
| 389 | + | ||||
| 390 | + | routes := h.configSvc.GetRoutes(ctx) | |||
| 391 | + | ||||
| 392 | + | // Get container statuses | |||
| 393 | + | statuses := make(map[string]string) | |||
| 394 | + | for _, route := range routes { | |||
| 395 | + | status := "unknown" | |||
| 396 | + | container, ok := h.containerSvc.Get(ctx, route.Domain) | |||
| 397 | + | if ok && container != nil { | |||
| 398 | + | status = container.Status | |||
| 399 | + | } | |||
| 400 | + | statuses[route.Domain] = status | |||
| 401 | + | } | |||
| 402 | + | ||||
| 403 | + | status := map[string]any{ | |||
| 404 | + | "routes": len(routes), | |||
| 405 | + | "registry_domain": h.configSvc.GetRegistryDomain(), | |||
| 406 | + | "registry_port": h.configSvc.GetRegistryPort(), | |||
| 407 | + | "server_port": h.configSvc.GetServerPort(), | |||
| 408 | + | "auto_route": h.configSvc.IsAutoRouteEnabled(), | |||
| 409 | + | "network_isolation": h.configSvc.IsNetworkIsolationEnabled(), | |||
| 410 | + | "container_status": statuses, | |||
| 411 | + | } | |||
| 412 | + | ||||
| 413 | + | h.sendJSON(w, http.StatusOK, status) | |||
| 414 | + | } | |||
| 415 | + | ||||
| 416 | + | // handleReload handles /admin/reload endpoint. | |||
| 417 | + | // This reloads configuration from file into memory and triggers container sync. | |||
| 418 | + | func (h *Handler) handleReload(w http.ResponseWriter, r *http.Request) { | |||
| 419 | + | if r.Method != http.MethodPost { | |||
| 420 | + | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) | |||
| 421 | + | return | |||
| 422 | + | } | |||
| 423 | + | ||||
| 424 | + | ctx := r.Context() | |||
| 425 | + | log := zerowrap.FromCtx(ctx) | |||
| 426 | + | ||||
| 427 | + | // Check write permission (reload modifies state) | |||
| 428 | + | if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) { | |||
| 429 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write") | |||
| 430 | + | return | |||
| 431 | + | } | |||
| 432 | + | ||||
| 433 | + | // Reload configuration from file into memory | |||
| 434 | + | if err := h.configSvc.Load(ctx); err != nil { | |||
| 435 | + | log.Error().Err(err).Msg("failed to reload config") | |||
| 436 | + | h.sendError(w, http.StatusInternalServerError, "failed to reload config") | |||
| 437 | + | return | |||
| 438 | + | } | |||
| 439 | + | ||||
| 440 | + | // Publish manual reload event to sync containers | |||
| 441 | + | // ManualReloadHandler starts missing containers without restarting running ones | |||
| 442 | + | if h.eventBus != nil { | |||
| 443 | + | if err := h.eventBus.Publish(domain.EventManualReload, nil); err != nil { | |||
| 444 | + | log.Warn().Err(err).Msg("failed to publish manual reload event") | |||
| 445 | + | } | |||
| 446 | + | } | |||
| 447 | + | ||||
| 448 | + | log.Info().Msg("config reloaded via admin API") | |||
| 449 | + | h.sendJSON(w, http.StatusOK, map[string]string{"status": "reloaded"}) | |||
| 450 | + | } | |||
| 451 | + | ||||
| 452 | + | // handleConfig handles /admin/config endpoint. | |||
| 453 | + | func (h *Handler) handleConfig(w http.ResponseWriter, r *http.Request) { | |||
| 454 | + | if r.Method != http.MethodGet { | |||
| 455 | + | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) | |||
| 456 | + | return | |||
| 457 | + | } | |||
| 458 | + | ||||
| 459 | + | ctx := r.Context() | |||
| 460 | + | ||||
| 461 | + | // Check read permission | |||
| 462 | + | if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionRead) { | |||
| 463 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for config:read") | |||
| 464 | + | return | |||
| 465 | + | } | |||
| 466 | + | ||||
| 467 | + | config := map[string]any{ | |||
| 468 | + | "server": map[string]any{ | |||
| 469 | + | "port": h.configSvc.GetServerPort(), | |||
| 470 | + | "registry_port": h.configSvc.GetRegistryPort(), | |||
| 471 | + | "registry_domain": h.configSvc.GetRegistryDomain(), | |||
| 472 | + | "data_dir": h.configSvc.GetDataDir(), | |||
| 473 | + | }, | |||
| 474 | + | "auto_route": map[string]any{ | |||
| 475 | + | "enabled": h.configSvc.IsAutoRouteEnabled(), | |||
| 476 | + | }, | |||
| 477 | + | "network_isolation": map[string]any{ | |||
| 478 | + | "enabled": h.configSvc.IsNetworkIsolationEnabled(), | |||
| 479 | + | "prefix": h.configSvc.GetNetworkPrefix(), | |||
| 480 | + | }, | |||
| 481 | + | "routes": h.configSvc.GetRoutes(ctx), | |||
| 482 | + | "external_routes": h.configSvc.GetExternalRoutes(), | |||
| 483 | + | } | |||
| 484 | + | ||||
| 485 | + | h.sendJSON(w, http.StatusOK, config) | |||
| 486 | + | } | |||
A internal/adapters/in/http/admin/handler_test.go +792 -0
| @@ -0,0 +1,792 @@ | |||||
| 1 | + | package admin | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "context" | |||
| 6 | + | "encoding/json" | |||
| 7 | + | "net/http" | |||
| 8 | + | "net/http/httptest" | |||
| 9 | + | "testing" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/zerowrap" | |||
| 12 | + | "github.com/stretchr/testify/assert" | |||
| 13 | + | "github.com/stretchr/testify/mock" | |||
| 14 | + | ||||
| 15 | + | inmocks "gordon/internal/boundaries/in/mocks" | |||
| 16 | + | "gordon/internal/domain" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | func testLogger() zerowrap.Logger { | |||
| 20 | + | return zerowrap.Default() | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func ctxWithScopes(scopes ...string) context.Context { | |||
| 24 | + | ctx := context.Background() | |||
| 25 | + | return context.WithValue(ctx, ContextKeyScopes, scopes) | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | // Routes endpoint tests | |||
| 29 | + | ||||
| 30 | + | func TestHandler_RoutesGet_RequiresReadScope(t *testing.T) { | |||
| 31 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 32 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 33 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 34 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 35 | + | ||||
| 36 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger()) | |||
| 37 | + | ||||
| 38 | + | tests := []struct { | |||
| 39 | + | name string | |||
| 40 | + | scopes []string | |||
| 41 | + | wantStatus int | |||
| 42 | + | }{ | |||
| 43 | + | { | |||
| 44 | + | name: "routes read access granted", | |||
| 45 | + | scopes: []string{"admin:routes:read"}, | |||
| 46 | + | wantStatus: http.StatusOK, | |||
| 47 | + | }, | |||
| 48 | + | { | |||
| 49 | + | name: "routes wildcard access granted", | |||
| 50 | + | scopes: []string{"admin:routes:*"}, | |||
| 51 | + | wantStatus: http.StatusOK, | |||
| 52 | + | }, | |||
| 53 | + | { | |||
| 54 | + | name: "all admin access granted", | |||
| 55 | + | scopes: []string{"admin:*:*"}, | |||
| 56 | + | wantStatus: http.StatusOK, | |||
| 57 | + | }, | |||
| 58 | + | { | |||
| 59 | + | name: "only write access denied", | |||
| 60 | + | scopes: []string{"admin:routes:write"}, | |||
| 61 | + | wantStatus: http.StatusForbidden, | |||
| 62 | + | }, | |||
| 63 | + | { | |||
| 64 | + | name: "wrong resource denied", | |||
| 65 | + | scopes: []string{"admin:secrets:read"}, | |||
| 66 | + | wantStatus: http.StatusForbidden, | |||
| 67 | + | }, | |||
| 68 | + | { | |||
| 69 | + | name: "no scopes denied", | |||
| 70 | + | scopes: []string{}, | |||
| 71 | + | wantStatus: http.StatusForbidden, | |||
| 72 | + | }, | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | for _, tt := range tests { | |||
| 76 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 77 | + | if tt.wantStatus == http.StatusOK { | |||
| 78 | + | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}).Maybe() | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | req := httptest.NewRequest("GET", "/admin/routes", nil) | |||
| 82 | + | req = req.WithContext(ctxWithScopes(tt.scopes...)) | |||
| 83 | + | rec := httptest.NewRecorder() | |||
| 84 | + | ||||
| 85 | + | handler.ServeHTTP(rec, req) | |||
| 86 | + | ||||
| 87 | + | assert.Equal(t, tt.wantStatus, rec.Code) | |||
| 88 | + | }) | |||
| 89 | + | } | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | func TestHandler_RoutesPost_RequiresWriteScope(t *testing.T) { | |||
| 93 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 94 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 95 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 96 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 97 | + | ||||
| 98 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger()) | |||
| 99 | + | ||||
| 100 | + | routeJSON := `{"domain": "app.example.com", "image": "myapp:latest"}` | |||
| 101 | + | ||||
| 102 | + | tests := []struct { | |||
| 103 | + | name string | |||
| 104 | + | scopes []string | |||
| 105 | + | wantStatus int | |||
| 106 | + | }{ | |||
| 107 | + | { | |||
| 108 | + | name: "routes write access granted", | |||
| 109 | + | scopes: []string{"admin:routes:write"}, | |||
| 110 | + | wantStatus: http.StatusCreated, | |||
| 111 | + | }, | |||
| 112 | + | { | |||
| 113 | + | name: "routes wildcard access granted", | |||
| 114 | + | scopes: []string{"admin:routes:*"}, | |||
| 115 | + | wantStatus: http.StatusCreated, | |||
| 116 | + | }, | |||
| 117 | + | { | |||
| 118 | + | name: "all admin access granted", | |||
| 119 | + | scopes: []string{"admin:*:*"}, | |||
| 120 | + | wantStatus: http.StatusCreated, | |||
| 121 | + | }, | |||
| 122 | + | { | |||
| 123 | + | name: "only read access denied", | |||
| 124 | + | scopes: []string{"admin:routes:read"}, | |||
| 125 | + | wantStatus: http.StatusForbidden, | |||
| 126 | + | }, | |||
| 127 | + | { | |||
| 128 | + | name: "wrong resource denied", | |||
| 129 | + | scopes: []string{"admin:secrets:write"}, | |||
| 130 | + | wantStatus: http.StatusForbidden, | |||
| 131 | + | }, | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | for _, tt := range tests { | |||
| 135 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 136 | + | if tt.wantStatus == http.StatusCreated { | |||
| 137 | + | configSvc.EXPECT().AddRoute(mock.Anything, mock.AnythingOfType("domain.Route")).Return(nil).Maybe() | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(routeJSON)) | |||
| 141 | + | req = req.WithContext(ctxWithScopes(tt.scopes...)) | |||
| 142 | + | rec := httptest.NewRecorder() | |||
| 143 | + | ||||
| 144 | + | handler.ServeHTTP(rec, req) | |||
| 145 | + | ||||
| 146 | + | assert.Equal(t, tt.wantStatus, rec.Code) | |||
| 147 | + | }) | |||
| 148 | + | } | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | func TestHandler_RoutesPut_RequiresWriteScope(t *testing.T) { | |||
| 152 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 153 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 154 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 155 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 156 | + | ||||
| 157 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger()) | |||
| 158 | + | ||||
| 159 | + | routeJSON := `{"image": "myapp:v2"}` | |||
| 160 | + | ||||
| 161 | + | tests := []struct { | |||
| 162 | + | name string | |||
| 163 | + | scopes []string | |||
| 164 | + | wantStatus int | |||
| 165 | + | }{ | |||
| 166 | + | { | |||
| 167 | + | name: "routes write access granted", | |||
| 168 | + | scopes: []string{"admin:routes:write"}, | |||
| 169 | + | wantStatus: http.StatusOK, | |||
| 170 | + | }, | |||
| 171 | + | { | |||
| 172 | + | name: "only read access denied", | |||
| 173 | + | scopes: []string{"admin:routes:read"}, | |||
| 174 | + | wantStatus: http.StatusForbidden, | |||
| 175 | + | }, | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | for _, tt := range tests { | |||
| 179 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 180 | + | if tt.wantStatus == http.StatusOK { | |||
| 181 | + | configSvc.EXPECT().UpdateRoute(mock.Anything, mock.AnythingOfType("domain.Route")).Return(nil).Maybe() | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | req := httptest.NewRequest("PUT", "/admin/routes/app.example.com", bytes.NewBufferString(routeJSON)) | |||
| 185 | + | req = req.WithContext(ctxWithScopes(tt.scopes...)) | |||
| 186 | + | rec := httptest.NewRecorder() | |||
| 187 | + | ||||
| 188 | + | handler.ServeHTTP(rec, req) | |||
| 189 | + | ||||
| 190 | + | assert.Equal(t, tt.wantStatus, rec.Code) | |||
| 191 | + | }) | |||
| 192 | + | } | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | func TestHandler_RoutesDelete_RequiresWriteScope(t *testing.T) { | |||
| 196 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 197 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 198 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 199 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 200 | + | ||||
| 201 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger()) | |||
| 202 | + | ||||
| 203 | + | tests := []struct { | |||
| 204 | + | name string | |||
| 205 | + | scopes []string | |||
| 206 | + | wantStatus int | |||
| 207 | + | }{ | |||
| 208 | + | { | |||
| 209 | + | name: "routes write access granted", | |||
| 210 | + | scopes: []string{"admin:routes:write"}, | |||
| 211 | + | wantStatus: http.StatusOK, | |||
| 212 | + | }, | |||
| 213 | + | { | |||
| 214 | + | name: "only read access denied", | |||
| 215 | + | scopes: []string{"admin:routes:read"}, | |||
| 216 | + | wantStatus: http.StatusForbidden, | |||
| 217 | + | }, | |||
| 218 | + | } | |||
| 219 | + | ||||
| 220 | + | for _, tt := range tests { | |||
| 221 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 222 | + | if tt.wantStatus == http.StatusOK { | |||
| 223 | + | configSvc.EXPECT().RemoveRoute(mock.Anything, "app.example.com").Return(nil).Maybe() | |||
| 224 | + | } | |||
| 225 | + | ||||
| 226 | + | req := httptest.NewRequest("DELETE", "/admin/routes/app.example.com", nil) | |||
| 227 | + | req = req.WithContext(ctxWithScopes(tt.scopes...)) | |||
| 228 | + | rec := httptest.NewRecorder() | |||
| 229 | + | ||||
| 230 | + | handler.ServeHTTP(rec, req) | |||
| 231 | + | ||||
| 232 | + | assert.Equal(t, tt.wantStatus, rec.Code) | |||
| 233 | + | }) | |||
| 234 | + | } | |||
| 235 | + | } | |||
| 236 | + | ||||
| 237 | + | // Secrets endpoint tests | |||
| 238 | + | ||||
| 239 | + | func TestHandler_SecretsGet_RequiresReadScope(t *testing.T) { | |||
| 240 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 241 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 242 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 243 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 244 | + | ||||
| 245 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger()) | |||
| 246 | + | ||||
| 247 | + | tests := []struct { | |||
| 248 | + | name string | |||
| 249 | + | scopes []string | |||
| 250 | + | wantStatus int | |||
| 251 | + | }{ | |||
| 252 | + | { | |||
| 253 | + | name: "secrets read access granted", | |||
| 254 | + | scopes: []string{"admin:secrets:read"}, | |||
| 255 | + | wantStatus: http.StatusOK, | |||
| 256 | + | }, | |||
| 257 | + | { | |||
| 258 | + | name: "secrets wildcard access granted", | |||
| 259 | + | scopes: []string{"admin:secrets:*"}, | |||
| 260 | + | wantStatus: http.StatusOK, | |||
| 261 | + | }, | |||
| 262 | + | { | |||
| 263 | + | name: "all admin access granted", | |||
| 264 | + | scopes: []string{"admin:*:*"}, | |||
| 265 | + | wantStatus: http.StatusOK, | |||
| 266 | + | }, | |||
| 267 | + | { | |||
| 268 | + | name: "only write access denied", | |||
| 269 | + | scopes: []string{"admin:secrets:write"}, | |||
| 270 | + | wantStatus: http.StatusForbidden, | |||
| 271 | + | }, | |||
| 272 | + | { | |||
| 273 | + | name: "wrong resource denied", | |||
| 274 | + | scopes: []string{"admin:routes:read"}, | |||
| 275 | + | wantStatus: http.StatusForbidden, | |||
| 276 | + | }, | |||
| 277 | + | } | |||
| 278 | + | ||||
| 279 | + | for _, tt := range tests { | |||
| 280 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 281 | + | if tt.wantStatus == http.StatusOK { | |||
| 282 | + | secretSvc.EXPECT().ListKeys(mock.Anything, "app.example.com").Return([]string{}, nil).Maybe() | |||
| 283 | + | } | |||
| 284 | + | ||||
| 285 | + | req := httptest.NewRequest("GET", "/admin/secrets/app.example.com", nil) | |||
| 286 | + | req = req.WithContext(ctxWithScopes(tt.scopes...)) | |||
| 287 | + | rec := httptest.NewRecorder() | |||
| 288 | + | ||||
| 289 | + | handler.ServeHTTP(rec, req) | |||
| 290 | + | ||||
| 291 | + | assert.Equal(t, tt.wantStatus, rec.Code) | |||
| 292 | + | }) | |||
| 293 | + | } | |||
| 294 | + | } | |||
| 295 | + | ||||
| 296 | + | func TestHandler_SecretsPost_RequiresWriteScope(t *testing.T) { | |||
| 297 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 298 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 299 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 300 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 301 | + | ||||
| 302 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger()) | |||
| 303 | + | ||||
| 304 | + | secretsJSON := `{"API_KEY": "secret123"}` | |||
| 305 | + | ||||
| 306 | + | tests := []struct { | |||
| 307 | + | name string | |||
| 308 | + | scopes []string | |||
| 309 | + | wantStatus int | |||
| 310 | + | }{ | |||
| 311 | + | { | |||
| 312 | + | name: "secrets write access granted", | |||
| 313 | + | scopes: []string{"admin:secrets:write"}, | |||
| 314 | + | wantStatus: http.StatusOK, | |||
| 315 | + | }, | |||
| 316 | + | { | |||
| 317 | + | name: "only read access denied", | |||
| 318 | + | scopes: []string{"admin:secrets:read"}, | |||
| 319 | + | wantStatus: http.StatusForbidden, | |||
| 320 | + | }, | |||
| 321 | + | { | |||
| 322 | + | name: "wrong resource denied", | |||
| 323 | + | scopes: []string{"admin:routes:write"}, | |||
| 324 | + | wantStatus: http.StatusForbidden, | |||
| 325 | + | }, | |||
| 326 | + | } | |||
| 327 | + | ||||
| 328 | + | for _, tt := range tests { | |||
| 329 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 330 | + | if tt.wantStatus == http.StatusOK { | |||
| 331 | + | secretSvc.EXPECT().Set(mock.Anything, "app.example.com", mock.AnythingOfType("map[string]string")).Return(nil).Maybe() | |||
| 332 | + | } | |||
| 333 | + | ||||
| 334 | + | req := httptest.NewRequest("POST", "/admin/secrets/app.example.com", bytes.NewBufferString(secretsJSON)) | |||
| 335 | + | req = req.WithContext(ctxWithScopes(tt.scopes...)) | |||
| 336 | + | rec := httptest.NewRecorder() | |||
| 337 | + | ||||
| 338 | + | handler.ServeHTTP(rec, req) | |||
| 339 | + | ||||
| 340 | + | assert.Equal(t, tt.wantStatus, rec.Code) | |||
| 341 | + | }) | |||
| 342 | + | } | |||
| 343 | + | } | |||
| 344 | + | ||||
| 345 | + | func TestHandler_SecretsDelete_RequiresWriteScope(t *testing.T) { | |||
| 346 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 347 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 348 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 349 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 350 | + | ||||
| 351 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger()) | |||
| 352 | + | ||||
| 353 | + | tests := []struct { | |||
| 354 | + | name string | |||
| 355 | + | scopes []string | |||
| 356 | + | wantStatus int | |||
| 357 | + | }{ | |||
| 358 | + | { | |||
| 359 | + | name: "secrets write access granted", | |||
| 360 | + | scopes: []string{"admin:secrets:write"}, | |||
| 361 | + | wantStatus: http.StatusOK, | |||
| 362 | + | }, | |||
| 363 | + | { | |||
| 364 | + | name: "only read access denied", | |||
| 365 | + | scopes: []string{"admin:secrets:read"}, | |||
| 366 | + | wantStatus: http.StatusForbidden, | |||
| 367 | + | }, | |||
| 368 | + | } | |||
| 369 | + | ||||
| 370 | + | for _, tt := range tests { | |||
| 371 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 372 | + | if tt.wantStatus == http.StatusOK { | |||
| 373 | + | secretSvc.EXPECT().Delete(mock.Anything, "app.example.com", "API_KEY").Return(nil).Maybe() | |||
| 374 | + | } | |||
| 375 | + | ||||
| 376 | + | req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/API_KEY", nil) | |||
| 377 | + | req = req.WithContext(ctxWithScopes(tt.scopes...)) | |||
| 378 | + | rec := httptest.NewRecorder() | |||
| 379 | + | ||||
| 380 | + | handler.ServeHTTP(rec, req) | |||
| 381 | + | ||||
| 382 | + | assert.Equal(t, tt.wantStatus, rec.Code) | |||
| 383 | + | }) | |||
| 384 | + | } | |||
| 385 | + | } | |||
| 386 | + | ||||
| 387 | + | // Status endpoint tests | |||
| 388 | + | ||||
| 389 | + | func TestHandler_Status_RequiresReadScope(t *testing.T) { | |||
| 390 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 391 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 392 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 393 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 394 | + | ||||
| 395 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger()) | |||
| 396 | + | ||||
| 397 | + | tests := []struct { | |||
| 398 | + | name string | |||
| 399 | + | scopes []string | |||
| 400 | + | wantStatus int | |||
| 401 | + | }{ | |||
| 402 | + | { | |||
| 403 | + | name: "status read access granted", | |||
| 404 | + | scopes: []string{"admin:status:read"}, | |||
| 405 | + | wantStatus: http.StatusOK, | |||
| 406 | + | }, | |||
| 407 | + | { | |||
| 408 | + | name: "all admin access granted", | |||
| 409 | + | scopes: []string{"admin:*:*"}, | |||
| 410 | + | wantStatus: http.StatusOK, | |||
| 411 | + | }, | |||
| 412 | + | { | |||
| 413 | + | name: "wrong resource denied", | |||
| 414 | + | scopes: []string{"admin:routes:read"}, | |||
| 415 | + | wantStatus: http.StatusForbidden, | |||
| 416 | + | }, | |||
| 417 | + | } | |||
| 418 | + | ||||
| 419 | + | for _, tt := range tests { | |||
| 420 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 421 | + | if tt.wantStatus == http.StatusOK { | |||
| 422 | + | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}).Maybe() | |||
| 423 | + | configSvc.EXPECT().GetRegistryDomain().Return("registry.example.com").Maybe() | |||
| 424 | + | configSvc.EXPECT().GetRegistryPort().Return(5000).Maybe() | |||
| 425 | + | configSvc.EXPECT().GetServerPort().Return(8080).Maybe() | |||
| 426 | + | configSvc.EXPECT().IsAutoRouteEnabled().Return(true).Maybe() | |||
| 427 | + | configSvc.EXPECT().IsNetworkIsolationEnabled().Return(false).Maybe() | |||
| 428 | + | } | |||
| 429 | + | ||||
| 430 | + | req := httptest.NewRequest("GET", "/admin/status", nil) | |||
| 431 | + | req = req.WithContext(ctxWithScopes(tt.scopes...)) | |||
| 432 | + | rec := httptest.NewRecorder() | |||
| 433 | + | ||||
| 434 | + | handler.ServeHTTP(rec, req) | |||
| 435 | + | ||||
| 436 | + | assert.Equal(t, tt.wantStatus, rec.Code) | |||
| 437 | + | }) | |||
| 438 | + | } | |||
| 439 | + | } | |||
| 440 | + | ||||
| 441 | + | // Config endpoint tests | |||
| 442 | + | ||||
| 443 | + | func TestHandler_Config_RequiresReadScope(t *testing.T) { | |||
| 444 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 445 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 446 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 447 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 448 | + | ||||
| 449 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger()) | |||
| 450 | + | ||||
| 451 | + | tests := []struct { | |||
| 452 | + | name string | |||
| 453 | + | scopes []string | |||
| 454 | + | wantStatus int | |||
| 455 | + | }{ | |||
| 456 | + | { | |||
| 457 | + | name: "config read access granted", | |||
| 458 | + | scopes: []string{"admin:config:read"}, | |||
| 459 | + | wantStatus: http.StatusOK, | |||
| 460 | + | }, | |||
| 461 | + | { | |||
| 462 | + | name: "all admin access granted", | |||
| 463 | + | scopes: []string{"admin:*:*"}, | |||
| 464 | + | wantStatus: http.StatusOK, | |||
| 465 | + | }, | |||
| 466 | + | { | |||
| 467 | + | name: "wrong resource denied", | |||
| 468 | + | scopes: []string{"admin:routes:read"}, | |||
| 469 | + | wantStatus: http.StatusForbidden, | |||
| 470 | + | }, | |||
| 471 | + | } | |||
| 472 | + | ||||
| 473 | + | for _, tt := range tests { | |||
| 474 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 475 | + | if tt.wantStatus == http.StatusOK { | |||
| 476 | + | configSvc.EXPECT().GetServerPort().Return(8080).Maybe() | |||
| 477 | + | configSvc.EXPECT().GetRegistryPort().Return(5000).Maybe() | |||
| 478 | + | configSvc.EXPECT().GetRegistryDomain().Return("registry.example.com").Maybe() | |||
| 479 | + | configSvc.EXPECT().GetDataDir().Return("/var/lib/gordon").Maybe() | |||
| 480 | + | configSvc.EXPECT().IsAutoRouteEnabled().Return(true).Maybe() | |||
| 481 | + | configSvc.EXPECT().IsNetworkIsolationEnabled().Return(false).Maybe() | |||
| 482 | + | configSvc.EXPECT().GetNetworkPrefix().Return("gordon").Maybe() | |||
| 483 | + | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}).Maybe() | |||
| 484 | + | configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{}).Maybe() | |||
| 485 | + | } | |||
| 486 | + | ||||
| 487 | + | req := httptest.NewRequest("GET", "/admin/config", nil) | |||
| 488 | + | req = req.WithContext(ctxWithScopes(tt.scopes...)) | |||
| 489 | + | rec := httptest.NewRecorder() | |||
| 490 | + | ||||
| 491 | + | handler.ServeHTTP(rec, req) | |||
| 492 | + | ||||
| 493 | + | assert.Equal(t, tt.wantStatus, rec.Code) | |||
| 494 | + | }) | |||
| 495 | + | } | |||
| 496 | + | } | |||
| 497 | + | ||||
| 498 | + | // Reload endpoint tests | |||
| 499 | + | ||||
| 500 | + | func TestHandler_Reload_RequiresWriteScope(t *testing.T) { | |||
| 501 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 502 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 503 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 504 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 505 | + | ||||
| 506 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger()) | |||
| 507 | + | ||||
| 508 | + | tests := []struct { | |||
| 509 | + | name string | |||
| 510 | + | scopes []string | |||
| 511 | + | wantStatus int | |||
| 512 | + | }{ | |||
| 513 | + | { | |||
| 514 | + | name: "config write access granted", | |||
| 515 | + | scopes: []string{"admin:config:write"}, | |||
| 516 | + | wantStatus: http.StatusOK, | |||
| 517 | + | }, | |||
| 518 | + | { | |||
| 519 | + | name: "all admin access granted", | |||
| 520 | + | scopes: []string{"admin:*:*"}, | |||
| 521 | + | wantStatus: http.StatusOK, | |||
| 522 | + | }, | |||
| 523 | + | { | |||
| 524 | + | name: "only read access denied", | |||
| 525 | + | scopes: []string{"admin:config:read"}, | |||
| 526 | + | wantStatus: http.StatusForbidden, | |||
| 527 | + | }, | |||
| 528 | + | { | |||
| 529 | + | name: "wrong resource denied", | |||
| 530 | + | scopes: []string{"admin:routes:write"}, | |||
| 531 | + | wantStatus: http.StatusForbidden, | |||
| 532 | + | }, | |||
| 533 | + | } | |||
| 534 | + | ||||
| 535 | + | for _, tt := range tests { | |||
| 536 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 537 | + | if tt.wantStatus == http.StatusOK { | |||
| 538 | + | configSvc.EXPECT().Load(mock.Anything).Return(nil).Maybe() | |||
| 539 | + | } | |||
| 540 | + | ||||
| 541 | + | req := httptest.NewRequest("POST", "/admin/reload", nil) | |||
| 542 | + | req = req.WithContext(ctxWithScopes(tt.scopes...)) | |||
| 543 | + | rec := httptest.NewRecorder() | |||
| 544 | + | ||||
| 545 | + | handler.ServeHTTP(rec, req) | |||
| 546 | + | ||||
| 547 | + | assert.Equal(t, tt.wantStatus, rec.Code) | |||
| 548 | + | }) | |||
| 549 | + | } | |||
| 550 | + | } | |||
| 551 | + | ||||
| 552 | + | // Functional tests | |||
| 553 | + | ||||
| 554 | + | func TestHandler_RoutesGet_ReturnsRoutes(t *testing.T) { | |||
| 555 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 556 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 557 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 558 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 559 | + | ||||
| 560 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger()) | |||
| 561 | + | ||||
| 562 | + | expectedRoutes := []domain.Route{ | |||
| 563 | + | {Domain: "app1.example.com", Image: "app1:latest"}, | |||
| 564 | + | {Domain: "app2.example.com", Image: "app2:v1.0"}, | |||
| 565 | + | } | |||
| 566 | + | configSvc.EXPECT().GetRoutes(mock.Anything).Return(expectedRoutes) | |||
| 567 | + | ||||
| 568 | + | req := httptest.NewRequest("GET", "/admin/routes", nil) | |||
| 569 | + | req = req.WithContext(ctxWithScopes("admin:routes:read")) | |||
| 570 | + | rec := httptest.NewRecorder() | |||
| 571 | + | ||||
| 572 | + | handler.ServeHTTP(rec, req) | |||
| 573 | + | ||||
| 574 | + | assert.Equal(t, http.StatusOK, rec.Code) | |||
| 575 | + | ||||
| 576 | + | var response map[string]any | |||
| 577 | + | err := json.NewDecoder(rec.Body).Decode(&response) | |||
| 578 | + | assert.NoError(t, err) | |||
| 579 | + | ||||
| 580 | + | routes, ok := response["routes"].([]any) | |||
| 581 | + | assert.True(t, ok) | |||
| 582 | + | assert.Len(t, routes, 2) | |||
| 583 | + | } | |||
| 584 | + | ||||
| 585 | + | func TestHandler_RoutesGet_SingleRoute(t *testing.T) { | |||
| 586 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 587 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 588 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 589 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 590 | + | ||||
| 591 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger()) | |||
| 592 | + | ||||
| 593 | + | configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(&domain.Route{ | |||
| 594 | + | Domain: "app.example.com", Image: "app:latest", | |||
| 595 | + | }, nil) | |||
| 596 | + | ||||
| 597 | + | req := httptest.NewRequest("GET", "/admin/routes/app.example.com", nil) | |||
| 598 | + | req = req.WithContext(ctxWithScopes("admin:routes:read")) | |||
| 599 | + | rec := httptest.NewRecorder() | |||
| 600 | + | ||||
| 601 | + | handler.ServeHTTP(rec, req) | |||
| 602 | + | ||||
| 603 | + | assert.Equal(t, http.StatusOK, rec.Code) | |||
| 604 | + | ||||
| 605 | + | var route domain.Route | |||
| 606 | + | err := json.NewDecoder(rec.Body).Decode(&route) | |||
| 607 | + | assert.NoError(t, err) | |||
| 608 | + | assert.Equal(t, "app.example.com", route.Domain) | |||
| 609 | + | assert.Equal(t, "app:latest", route.Image) | |||
| 610 | + | } | |||
| 611 | + | ||||
| 612 | + | func TestHandler_RoutesGet_NotFound(t *testing.T) { | |||
| 613 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 614 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 615 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 616 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 617 | + | ||||
| 618 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger()) | |||
| 619 | + | ||||
| 620 | + | configSvc.EXPECT().GetRoute(mock.Anything, "nonexistent.example.com").Return(nil, domain.ErrRouteNotFound) | |||
| 621 | + | ||||
| 622 | + | req := httptest.NewRequest("GET", "/admin/routes/nonexistent.example.com", nil) | |||
| 623 | + | req = req.WithContext(ctxWithScopes("admin:routes:read")) | |||
| 624 | + | rec := httptest.NewRecorder() | |||
| 625 | + | ||||
| 626 | + | handler.ServeHTTP(rec, req) | |||
| 627 | + | ||||
| 628 | + | assert.Equal(t, http.StatusNotFound, rec.Code) | |||
| 629 | + | } | |||
| 630 | + | ||||
| 631 | + | func TestHandler_RoutesPost_MissingFields(t *testing.T) { | |||
| 632 | + | tests := []struct { | |||
| 633 | + | name string | |||
| 634 | + | body string | |||
| 635 | + | mockErr error | |||
| 636 | + | }{ | |||
| 637 | + | { | |||
| 638 | + | name: "missing domain", | |||
| 639 | + | body: `{"image": "app:latest"}`, | |||
| 640 | + | mockErr: domain.ErrRouteDomainEmpty, | |||
| 641 | + | }, | |||
| 642 | + | { | |||
| 643 | + | name: "missing image", | |||
| 644 | + | body: `{"domain": "app.example.com"}`, | |||
| 645 | + | mockErr: domain.ErrRouteImageEmpty, | |||
| 646 | + | }, | |||
| 647 | + | { | |||
| 648 | + | name: "empty object", | |||
| 649 | + | body: `{}`, | |||
| 650 | + | mockErr: domain.ErrRouteDomainEmpty, | |||
| 651 | + | }, | |||
| 652 | + | } | |||
| 653 | + | ||||
| 654 | + | for _, tt := range tests { | |||
| 655 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 656 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 657 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 658 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 659 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 660 | + | ||||
| 661 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger()) | |||
| 662 | + | ||||
| 663 | + | configSvc.EXPECT().AddRoute(mock.Anything, mock.Anything).Return(tt.mockErr) | |||
| 664 | + | ||||
| 665 | + | req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(tt.body)) | |||
| 666 | + | req = req.WithContext(ctxWithScopes("admin:routes:write")) | |||
| 667 | + | rec := httptest.NewRecorder() | |||
| 668 | + | ||||
| 669 | + | handler.ServeHTTP(rec, req) | |||
| 670 | + | ||||
| 671 | + | assert.Equal(t, http.StatusBadRequest, rec.Code) | |||
| 672 | + | }) | |||
| 673 | + | } | |||
| 674 | + | } | |||
| 675 | + | ||||
| 676 | + | func TestHandler_RoutesPut_MissingDomain(t *testing.T) { | |||
| 677 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 678 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 679 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 680 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 681 | + | ||||
| 682 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger()) | |||
| 683 | + | ||||
| 684 | + | req := httptest.NewRequest("PUT", "/admin/routes/", bytes.NewBufferString(`{"image": "app:latest"}`)) | |||
| 685 | + | req = req.WithContext(ctxWithScopes("admin:routes:write")) | |||
| 686 | + | rec := httptest.NewRecorder() | |||
| 687 | + | ||||
| 688 | + | handler.ServeHTTP(rec, req) | |||
| 689 | + | ||||
| 690 | + | assert.Equal(t, http.StatusBadRequest, rec.Code) | |||
| 691 | + | } | |||
| 692 | + | ||||
| 693 | + | func TestHandler_RoutesDelete_MissingDomain(t *testing.T) { | |||
| 694 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 695 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 696 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 697 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 698 | + | ||||
| 699 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger()) | |||
| 700 | + | ||||
| 701 | + | req := httptest.NewRequest("DELETE", "/admin/routes/", nil) | |||
| 702 | + | req = req.WithContext(ctxWithScopes("admin:routes:write")) | |||
| 703 | + | rec := httptest.NewRecorder() | |||
| 704 | + | ||||
| 705 | + | handler.ServeHTTP(rec, req) | |||
| 706 | + | ||||
| 707 | + | assert.Equal(t, http.StatusBadRequest, rec.Code) | |||
| 708 | + | } | |||
| 709 | + | ||||
| 710 | + | func TestHandler_Secrets_MissingDomain(t *testing.T) { | |||
| 711 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 712 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 713 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 714 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 715 | + | ||||
| 716 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger()) | |||
| 717 | + | ||||
| 718 | + | req := httptest.NewRequest("GET", "/admin/secrets/", nil) | |||
| 719 | + | req = req.WithContext(ctxWithScopes("admin:secrets:read")) | |||
| 720 | + | rec := httptest.NewRecorder() | |||
| 721 | + | ||||
| 722 | + | handler.ServeHTTP(rec, req) | |||
| 723 | + | ||||
| 724 | + | assert.Equal(t, http.StatusBadRequest, rec.Code) | |||
| 725 | + | } | |||
| 726 | + | ||||
| 727 | + | func TestHandler_SecretsDelete_MissingKey(t *testing.T) { | |||
| 728 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 729 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 730 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 731 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 732 | + | ||||
| 733 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger()) | |||
| 734 | + | ||||
| 735 | + | req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com", nil) | |||
| 736 | + | req = req.WithContext(ctxWithScopes("admin:secrets:write")) | |||
| 737 | + | rec := httptest.NewRecorder() | |||
| 738 | + | ||||
| 739 | + | handler.ServeHTTP(rec, req) | |||
| 740 | + | ||||
| 741 | + | assert.Equal(t, http.StatusBadRequest, rec.Code) | |||
| 742 | + | } | |||
| 743 | + | ||||
| 744 | + | func TestHandler_MethodNotAllowed(t *testing.T) { | |||
| 745 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 746 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 747 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 748 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 749 | + | ||||
| 750 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger()) | |||
| 751 | + | ||||
| 752 | + | tests := []struct { | |||
| 753 | + | method string | |||
| 754 | + | path string | |||
| 755 | + | }{ | |||
| 756 | + | {"DELETE", "/admin/status"}, | |||
| 757 | + | {"PUT", "/admin/status"}, | |||
| 758 | + | {"GET", "/admin/reload"}, | |||
| 759 | + | {"DELETE", "/admin/reload"}, | |||
| 760 | + | {"POST", "/admin/config"}, | |||
| 761 | + | {"DELETE", "/admin/config"}, | |||
| 762 | + | } | |||
| 763 | + | ||||
| 764 | + | for _, tt := range tests { | |||
| 765 | + | t.Run(tt.method+" "+tt.path, func(t *testing.T) { | |||
| 766 | + | req := httptest.NewRequest(tt.method, tt.path, nil) | |||
| 767 | + | req = req.WithContext(ctxWithScopes("admin:*:*")) | |||
| 768 | + | rec := httptest.NewRecorder() | |||
| 769 | + | ||||
| 770 | + | handler.ServeHTTP(rec, req) | |||
| 771 | + | ||||
| 772 | + | assert.Equal(t, http.StatusMethodNotAllowed, rec.Code) | |||
| 773 | + | }) | |||
| 774 | + | } | |||
| 775 | + | } | |||
| 776 | + | ||||
| 777 | + | func TestHandler_NotFound(t *testing.T) { | |||
| 778 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 779 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 780 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 781 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 782 | + | ||||
| 783 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger()) | |||
| 784 | + | ||||
| 785 | + | req := httptest.NewRequest("GET", "/admin/unknown", nil) | |||
| 786 | + | req = req.WithContext(ctxWithScopes("admin:*:*")) | |||
| 787 | + | rec := httptest.NewRecorder() | |||
| 788 | + | ||||
| 789 | + | handler.ServeHTTP(rec, req) | |||
| 790 | + | ||||
| 791 | + | assert.Equal(t, http.StatusNotFound, rec.Code) | |||
| 792 | + | } | |||
A internal/adapters/in/http/admin/middleware.go +152 -0
| @@ -0,0 +1,152 @@ | |||||
| 1 | + | package admin | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "net/http" | |||
| 7 | + | "strings" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/zerowrap" | |||
| 10 | + | "golang.org/x/time/rate" | |||
| 11 | + | ||||
| 12 | + | "gordon/internal/boundaries/in" | |||
| 13 | + | "gordon/internal/domain" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | // contextKey is a custom type for context keys to avoid collisions. | |||
| 17 | + | type contextKey string | |||
| 18 | + | ||||
| 19 | + | const ( | |||
| 20 | + | // ContextKeyScopes is the context key for storing token scopes. | |||
| 21 | + | ContextKeyScopes contextKey = "admin_scopes" | |||
| 22 | + | // ContextKeySubject is the context key for storing token subject. | |||
| 23 | + | ContextKeySubject contextKey = "admin_subject" | |||
| 24 | + | ) | |||
| 25 | + | ||||
| 26 | + | // AuthMiddleware creates middleware that validates admin API authentication. | |||
| 27 | + | // The limiter parameter is optional and can be nil to disable rate limiting. | |||
| 28 | + | func AuthMiddleware(authSvc in.AuthService, limiter *rate.Limiter, log zerowrap.Logger) func(http.Handler) http.Handler { | |||
| 29 | + | return func(next http.Handler) http.Handler { | |||
| 30 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 31 | + | ctx := r.Context() | |||
| 32 | + | ||||
| 33 | + | // Rate limit check (before any auth processing) | |||
| 34 | + | if limiter != nil && !limiter.Allow() { | |||
| 35 | + | w.Header().Set("Content-Type", "application/json") | |||
| 36 | + | w.Header().Set("Retry-After", "1") | |||
| 37 | + | w.WriteHeader(http.StatusTooManyRequests) | |||
| 38 | + | _ = json.NewEncoder(w).Encode(map[string]string{"error": "rate limit exceeded"}) | |||
| 39 | + | return | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // Check if auth is enabled | |||
| 43 | + | if !authSvc.IsEnabled() { | |||
| 44 | + | // Auth disabled, log warning and allow all requests | |||
| 45 | + | log.Warn(). | |||
| 46 | + | Str("path", r.URL.Path). | |||
| 47 | + | Str("method", r.Method). | |||
| 48 | + | Str("remote_addr", r.RemoteAddr). | |||
| 49 | + | Msg("auth disabled - allowing unauthenticated admin API access") | |||
| 50 | + | next.ServeHTTP(w, r) | |||
| 51 | + | return | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | // Extract token from Authorization header | |||
| 55 | + | auth := r.Header.Get("Authorization") | |||
| 56 | + | if auth == "" { | |||
| 57 | + | sendUnauthorized(w, "missing authorization header") | |||
| 58 | + | return | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // Support both "Bearer <token>" and direct token | |||
| 62 | + | token := auth | |||
| 63 | + | if strings.HasPrefix(auth, "Bearer ") { | |||
| 64 | + | token = strings.TrimPrefix(auth, "Bearer ") | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | // Validate token | |||
| 68 | + | claims, err := authSvc.ValidateToken(ctx, token) | |||
| 69 | + | if err != nil { | |||
| 70 | + | log.Warn().Err(err).Msg("invalid admin token") | |||
| 71 | + | sendUnauthorized(w, "invalid token") | |||
| 72 | + | return | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // Check if token has admin scopes | |||
| 76 | + | hasAdminScope := false | |||
| 77 | + | for _, scope := range claims.Scopes { | |||
| 78 | + | if strings.HasPrefix(scope, domain.ScopeTypeAdmin+":") { | |||
| 79 | + | hasAdminScope = true | |||
| 80 | + | break | |||
| 81 | + | } | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | if !hasAdminScope { | |||
| 85 | + | log.Warn().Str("subject", claims.Subject).Msg("token missing admin scopes") | |||
| 86 | + | sendForbidden(w, "admin scope required") | |||
| 87 | + | return | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | // Add claims to context for downstream handlers | |||
| 91 | + | ctx = context.WithValue(ctx, ContextKeyScopes, claims.Scopes) | |||
| 92 | + | ctx = context.WithValue(ctx, ContextKeySubject, claims.Subject) | |||
| 93 | + | ||||
| 94 | + | next.ServeHTTP(w, r.WithContext(ctx)) | |||
| 95 | + | }) | |||
| 96 | + | } | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | // RequireScope creates middleware that checks for a specific admin scope. | |||
| 100 | + | func RequireScope(resource, action string) func(http.Handler) http.Handler { | |||
| 101 | + | return func(next http.Handler) http.Handler { | |||
| 102 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 103 | + | ctx := r.Context() | |||
| 104 | + | ||||
| 105 | + | // Get scopes from context | |||
| 106 | + | scopes, ok := ctx.Value(ContextKeyScopes).([]string) | |||
| 107 | + | if !ok { | |||
| 108 | + | sendForbidden(w, "no scopes in context") | |||
| 109 | + | return | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | // Check if user has required scope | |||
| 113 | + | if !domain.HasAdminAccess(scopes, resource, action) { | |||
| 114 | + | sendForbidden(w, "insufficient permissions") | |||
| 115 | + | return | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | next.ServeHTTP(w, r) | |||
| 119 | + | }) | |||
| 120 | + | } | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | // GetSubject retrieves the authenticated subject from the context. | |||
| 124 | + | func GetSubject(ctx context.Context) string { | |||
| 125 | + | subject, _ := ctx.Value(ContextKeySubject).(string) | |||
| 126 | + | return subject | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | // GetScopes retrieves the token scopes from the context. | |||
| 130 | + | func GetScopes(ctx context.Context) []string { | |||
| 131 | + | scopes, _ := ctx.Value(ContextKeyScopes).([]string) | |||
| 132 | + | return scopes | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | // HasAccess checks if the context has access to the given resource and action. | |||
| 136 | + | func HasAccess(ctx context.Context, resource, action string) bool { | |||
| 137 | + | scopes := GetScopes(ctx) | |||
| 138 | + | return domain.HasAdminAccess(scopes, resource, action) | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | func sendUnauthorized(w http.ResponseWriter, message string) { | |||
| 142 | + | w.Header().Set("Content-Type", "application/json") | |||
| 143 | + | w.Header().Set("WWW-Authenticate", `Bearer realm="gordon-admin"`) | |||
| 144 | + | w.WriteHeader(http.StatusUnauthorized) | |||
| 145 | + | _ = json.NewEncoder(w).Encode(map[string]string{"error": message}) | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | func sendForbidden(w http.ResponseWriter, message string) { | |||
| 149 | + | w.Header().Set("Content-Type", "application/json") | |||
| 150 | + | w.WriteHeader(http.StatusForbidden) | |||
| 151 | + | _ = json.NewEncoder(w).Encode(map[string]string{"error": message}) | |||
| 152 | + | } | |||
M internal/adapters/in/http/middleware/logging.go +5 -0
| @@ -47,6 +47,7 @@ func (rw *ResponseWriter) BytesWritten() int { | |||||
| 47 | } | 47 | } | ||
| 48 | 48 | ||||
| 49 | // RequestLogger is a middleware that logs HTTP requests using zerowrap. | 49 | // RequestLogger is a middleware that logs HTTP requests using zerowrap. | ||
| 50 | + | // It also attaches the logger to the request context for downstream handlers. | |||
| 50 | func RequestLogger(log zerowrap.Logger) func(http.Handler) http.Handler { | 51 | func RequestLogger(log zerowrap.Logger) func(http.Handler) http.Handler { | ||
| 51 | return func(next http.Handler) http.Handler { | 52 | return func(next http.Handler) http.Handler { | ||
| 52 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | 53 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| @@ -58,6 +59,10 @@ func RequestLogger(log zerowrap.Logger) func(http.Handler) http.Handler { | |||||
| 58 | // Get client IP (handle X-Forwarded-For and X-Real-IP headers) | 59 | // Get client IP (handle X-Forwarded-For and X-Real-IP headers) | ||
| 59 | clientIP := getClientIP(r) | 60 | clientIP := getClientIP(r) | ||
| 60 | 61 | ||||
| 62 | + | // Attach the logger to the request context for downstream handlers | |||
| 63 | + | ctx := zerowrap.WithCtx(r.Context(), log) | |||
| 64 | + | r = r.WithContext(ctx) | |||
| 65 | + | ||||
| 61 | // Call the next handler | 66 | // Call the next handler | ||
| 62 | next.ServeHTTP(rw, r) | 67 | next.ServeHTTP(rw, r) | ||
| 63 | 68 | ||||
M internal/adapters/in/http/registry/handler.go +4 -13
| @@ -2,7 +2,6 @@ | |||||
| 2 | package registry | 2 | package registry | ||
| 3 | 3 | ||||
| 4 | import ( | 4 | import ( | ||
| 5 | - | "crypto/sha256" | |||
| 6 | "encoding/json" | 5 | "encoding/json" | ||
| 7 | "errors" | 6 | "errors" | ||
| 8 | "fmt" | 7 | "fmt" | ||
| @@ -14,7 +13,6 @@ import ( | |||||
| 14 | "github.com/bnema/zerowrap" | 13 | "github.com/bnema/zerowrap" | ||
| 15 | 14 | ||||
| 16 | "gordon/internal/boundaries/in" | 15 | "gordon/internal/boundaries/in" | ||
| 17 | - | "gordon/internal/boundaries/out" | |||
| 18 | "gordon/internal/domain" | 16 | "gordon/internal/domain" | ||
| 19 | "gordon/pkg/manifest" | 17 | "gordon/pkg/manifest" | ||
| 20 | "gordon/pkg/validation" | 18 | "gordon/pkg/validation" | ||
| @@ -30,22 +28,16 @@ const ( | |||||
| 30 | // Handler implements the HTTP handler for Docker Registry API v2. | 28 | // Handler implements the HTTP handler for Docker Registry API v2. | ||
| 31 | type Handler struct { | 29 | type Handler struct { | ||
| 32 | registrySvc in.RegistryService | 30 | registrySvc in.RegistryService | ||
| 33 | - | blobStorage out.BlobStorage | |||
| 34 | - | eventBus out.EventPublisher | |||
| 35 | log zerowrap.Logger | 31 | log zerowrap.Logger | ||
| 36 | } | 32 | } | ||
| 37 | 33 | ||||
| 38 | // NewHandler creates a new registry HTTP handler. | 34 | // NewHandler creates a new registry HTTP handler. | ||
| 39 | func NewHandler( | 35 | func NewHandler( | ||
| 40 | registrySvc in.RegistryService, | 36 | registrySvc in.RegistryService, | ||
| 41 | - | blobStorage out.BlobStorage, | |||
| 42 | - | eventBus out.EventPublisher, | |||
| 43 | log zerowrap.Logger, | 37 | log zerowrap.Logger, | ||
| 44 | ) *Handler { | 38 | ) *Handler { | ||
| 45 | return &Handler{ | 39 | return &Handler{ | ||
| 46 | registrySvc: registrySvc, | 40 | registrySvc: registrySvc, | ||
| 47 | - | blobStorage: blobStorage, | |||
| 48 | - | eventBus: eventBus, | |||
| 49 | log: log, | 41 | log: log, | ||
| 50 | } | 42 | } | ||
| 51 | } | 43 | } | ||
| @@ -331,14 +323,13 @@ func (h *Handler) handlePutManifest(w http.ResponseWriter, r *http.Request) { | |||||
| 331 | Annotations: annotations, | 323 | Annotations: annotations, | ||
| 332 | } | 324 | } | ||
| 333 | 325 | ||||
| 334 | - | if err := h.registrySvc.PutManifest(ctx, manifestObj); err != nil { | 326 | + | digest, err := h.registrySvc.PutManifest(ctx, manifestObj) |
| 327 | + | if err != nil { | |||
| 335 | log.Error().Err(err).Str("name", name).Str("reference", reference).Msg("failed to store manifest") | 328 | log.Error().Err(err).Str("name", name).Str("reference", reference).Msg("failed to store manifest") | ||
| 336 | http.Error(w, "Internal Server Error", http.StatusInternalServerError) | 329 | http.Error(w, "Internal Server Error", http.StatusInternalServerError) | ||
| 337 | return | 330 | return | ||
| 338 | } | 331 | } | ||
| 339 | 332 | ||||
| 340 | - | // Calculate digest for Docker-Content-Digest header | |||
| 341 | - | digest := fmt.Sprintf("sha256:%x", sha256.Sum256(data)) | |||
| 342 | w.Header().Set("Docker-Content-Digest", digest) | 333 | w.Header().Set("Docker-Content-Digest", digest) | ||
| 343 | w.Header().Set("Location", fmt.Sprintf("/v2/%s/manifests/%s", name, reference)) | 334 | w.Header().Set("Location", fmt.Sprintf("/v2/%s/manifests/%s", name, reference)) | ||
| 344 | w.WriteHeader(http.StatusCreated) | 335 | w.WriteHeader(http.StatusCreated) | ||
| @@ -352,7 +343,7 @@ func (h *Handler) handleGetBlob(w http.ResponseWriter, r *http.Request) { | |||||
| 352 | 343 | ||||
| 353 | log.Debug().Str("name", name).Str("digest", digest).Msg("GET blob") | 344 | log.Debug().Str("name", name).Str("digest", digest).Msg("GET blob") | ||
| 354 | 345 | ||||
| 355 | - | path, err := h.blobStorage.GetBlobPath(digest) | 346 | + | path, err := h.registrySvc.GetBlobPath(ctx, digest) |
| 356 | if err != nil { | 347 | if err != nil { | ||
| 357 | log.Warn().Err(err).Str("name", name).Str("digest", digest).Msg("blob not found") | 348 | log.Warn().Err(err).Str("name", name).Str("digest", digest).Msg("blob not found") | ||
| 358 | http.NotFound(w, r) | 349 | http.NotFound(w, r) | ||
| @@ -422,7 +413,7 @@ func (h *Handler) handleBlobUpload(w http.ResponseWriter, r *http.Request) { | |||||
| 422 | } | 413 | } | ||
| 423 | 414 | ||||
| 424 | // Append the chunk to the upload | 415 | // Append the chunk to the upload | ||
| 425 | - | length, err := h.blobStorage.AppendBlobChunk(name, uuid, chunk) | 416 | + | length, err := h.registrySvc.AppendBlobChunk(ctx, name, uuid, chunk) |
| 426 | if err != nil { | 417 | if err != nil { | ||
| 427 | log.Error().Err(err).Msg("failed to append blob chunk") | 418 | log.Error().Err(err).Msg("failed to append blob chunk") | ||
| 428 | http.Error(w, "Internal Server Error", http.StatusInternalServerError) | 419 | http.Error(w, "Internal Server Error", http.StatusInternalServerError) | ||
M internal/adapters/in/http/registry/handler_test.go +29 -74
| @@ -12,7 +12,6 @@ import ( | |||||
| 12 | "github.com/stretchr/testify/mock" | 12 | "github.com/stretchr/testify/mock" | ||
| 13 | 13 | ||||
| 14 | inmocks "gordon/internal/boundaries/in/mocks" | 14 | inmocks "gordon/internal/boundaries/in/mocks" | ||
| 15 | - | "gordon/internal/boundaries/out/mocks" | |||
| 16 | "gordon/internal/domain" | 15 | "gordon/internal/domain" | ||
| 17 | ) | 16 | ) | ||
| 18 | 17 | ||||
| @@ -22,10 +21,8 @@ func testLogger() zerowrap.Logger { | |||||
| 22 | 21 | ||||
| 23 | func TestHandler_Base(t *testing.T) { | 22 | func TestHandler_Base(t *testing.T) { | ||
| 24 | registrySvc := inmocks.NewMockRegistryService(t) | 23 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 25 | - | blobStorage := mocks.NewMockBlobStorage(t) | |||
| 26 | - | eventBus := mocks.NewMockEventPublisher(t) | |||
| 27 | 24 | ||||
| 28 | - | handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger()) | 25 | + | handler := NewHandler(registrySvc, testLogger()) |
| 29 | 26 | ||||
| 30 | req := httptest.NewRequest("GET", "/v2/", nil) | 27 | req := httptest.NewRequest("GET", "/v2/", nil) | ||
| 31 | rec := httptest.NewRecorder() | 28 | rec := httptest.NewRecorder() | ||
| @@ -38,10 +35,8 @@ func TestHandler_Base(t *testing.T) { | |||||
| 38 | 35 | ||||
| 39 | func TestHandler_NotFound(t *testing.T) { | 36 | func TestHandler_NotFound(t *testing.T) { | ||
| 40 | registrySvc := inmocks.NewMockRegistryService(t) | 37 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 41 | - | blobStorage := mocks.NewMockBlobStorage(t) | |||
| 42 | - | eventBus := mocks.NewMockEventPublisher(t) | |||
| 43 | 38 | ||||
| 44 | - | handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger()) | 39 | + | handler := NewHandler(registrySvc, testLogger()) |
| 45 | 40 | ||||
| 46 | req := httptest.NewRequest("GET", "/v2/unknown/path", nil) | 41 | req := httptest.NewRequest("GET", "/v2/unknown/path", nil) | ||
| 47 | rec := httptest.NewRecorder() | 42 | rec := httptest.NewRecorder() | ||
| @@ -53,10 +48,8 @@ func TestHandler_NotFound(t *testing.T) { | |||||
| 53 | 48 | ||||
| 54 | func TestHandler_GetManifest_Success(t *testing.T) { | 49 | func TestHandler_GetManifest_Success(t *testing.T) { | ||
| 55 | registrySvc := inmocks.NewMockRegistryService(t) | 50 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 56 | - | blobStorage := mocks.NewMockBlobStorage(t) | |||
| 57 | - | eventBus := mocks.NewMockEventPublisher(t) | |||
| 58 | 51 | ||||
| 59 | - | handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger()) | 52 | + | handler := NewHandler(registrySvc, testLogger()) |
| 60 | 53 | ||||
| 61 | manifestData := []byte(`{"schemaVersion": 2}`) | 54 | manifestData := []byte(`{"schemaVersion": 2}`) | ||
| 62 | registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "latest").Return(&domain.Manifest{ | 55 | registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "latest").Return(&domain.Manifest{ | ||
| @@ -78,10 +71,8 @@ func TestHandler_GetManifest_Success(t *testing.T) { | |||||
| 78 | 71 | ||||
| 79 | func TestHandler_GetManifest_HEAD(t *testing.T) { | 72 | func TestHandler_GetManifest_HEAD(t *testing.T) { | ||
| 80 | registrySvc := inmocks.NewMockRegistryService(t) | 73 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 81 | - | blobStorage := mocks.NewMockBlobStorage(t) | |||
| 82 | - | eventBus := mocks.NewMockEventPublisher(t) | |||
| 83 | 74 | ||||
| 84 | - | handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger()) | 75 | + | handler := NewHandler(registrySvc, testLogger()) |
| 85 | 76 | ||||
| 86 | manifestData := []byte(`{"schemaVersion": 2}`) | 77 | manifestData := []byte(`{"schemaVersion": 2}`) | ||
| 87 | registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "latest").Return(&domain.Manifest{ | 78 | registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "latest").Return(&domain.Manifest{ | ||
| @@ -104,10 +95,8 @@ func TestHandler_GetManifest_HEAD(t *testing.T) { | |||||
| 104 | 95 | ||||
| 105 | func TestHandler_GetManifest_NotFound(t *testing.T) { | 96 | func TestHandler_GetManifest_NotFound(t *testing.T) { | ||
| 106 | registrySvc := inmocks.NewMockRegistryService(t) | 97 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 107 | - | blobStorage := mocks.NewMockBlobStorage(t) | |||
| 108 | - | eventBus := mocks.NewMockEventPublisher(t) | |||
| 109 | 98 | ||||
| 110 | - | handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger()) | 99 | + | handler := NewHandler(registrySvc, testLogger()) |
| 111 | 100 | ||||
| 112 | registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "notexists").Return(nil, assert.AnError) | 101 | registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "notexists").Return(nil, assert.AnError) | ||
| 113 | 102 | ||||
| @@ -121,10 +110,8 @@ func TestHandler_GetManifest_NotFound(t *testing.T) { | |||||
| 121 | 110 | ||||
| 122 | func TestHandler_GetManifest_NestedName(t *testing.T) { | 111 | func TestHandler_GetManifest_NestedName(t *testing.T) { | ||
| 123 | registrySvc := inmocks.NewMockRegistryService(t) | 112 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 124 | - | blobStorage := mocks.NewMockBlobStorage(t) | |||
| 125 | - | eventBus := mocks.NewMockEventPublisher(t) | |||
| 126 | 113 | ||||
| 127 | - | handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger()) | 114 | + | handler := NewHandler(registrySvc, testLogger()) |
| 128 | 115 | ||||
| 129 | manifestData := []byte(`{"schemaVersion": 2}`) | 116 | manifestData := []byte(`{"schemaVersion": 2}`) | ||
| 130 | registrySvc.EXPECT().GetManifest(mock.Anything, "org/project/app", "v1.0").Return(&domain.Manifest{ | 117 | registrySvc.EXPECT().GetManifest(mock.Anything, "org/project/app", "v1.0").Return(&domain.Manifest{ | ||
| @@ -144,15 +131,13 @@ func TestHandler_GetManifest_NestedName(t *testing.T) { | |||||
| 144 | 131 | ||||
| 145 | func TestHandler_PutManifest_Success(t *testing.T) { | 132 | func TestHandler_PutManifest_Success(t *testing.T) { | ||
| 146 | registrySvc := inmocks.NewMockRegistryService(t) | 133 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 147 | - | blobStorage := mocks.NewMockBlobStorage(t) | |||
| 148 | - | eventBus := mocks.NewMockEventPublisher(t) | |||
| 149 | 134 | ||||
| 150 | - | handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger()) | 135 | + | handler := NewHandler(registrySvc, testLogger()) |
| 151 | 136 | ||||
| 152 | manifestData := []byte(`{"schemaVersion": 2}`) | 137 | manifestData := []byte(`{"schemaVersion": 2}`) | ||
| 153 | registrySvc.EXPECT().PutManifest(mock.Anything, mock.MatchedBy(func(m *domain.Manifest) bool { | 138 | registrySvc.EXPECT().PutManifest(mock.Anything, mock.MatchedBy(func(m *domain.Manifest) bool { | ||
| 154 | return m.Name == "myapp" && m.Reference == "latest" && m.ContentType == "application/vnd.docker.distribution.manifest.v2+json" | 139 | return m.Name == "myapp" && m.Reference == "latest" && m.ContentType == "application/vnd.docker.distribution.manifest.v2+json" | ||
| 155 | - | })).Return(nil) | 140 | + | })).Return("sha256:abc123", nil) |
| 156 | 141 | ||||
| 157 | req := httptest.NewRequest("PUT", "/v2/myapp/manifests/latest", bytes.NewReader(manifestData)) | 142 | req := httptest.NewRequest("PUT", "/v2/myapp/manifests/latest", bytes.NewReader(manifestData)) | ||
| 158 | req.Header.Set("Content-Type", "application/vnd.docker.distribution.manifest.v2+json") | 143 | req.Header.Set("Content-Type", "application/vnd.docker.distribution.manifest.v2+json") | ||
| @@ -161,16 +146,14 @@ func TestHandler_PutManifest_Success(t *testing.T) { | |||||
| 161 | handler.ServeHTTP(rec, req) | 146 | handler.ServeHTTP(rec, req) | ||
| 162 | 147 | ||||
| 163 | assert.Equal(t, http.StatusCreated, rec.Code) | 148 | assert.Equal(t, http.StatusCreated, rec.Code) | ||
| 164 | - | assert.NotEmpty(t, rec.Header().Get("Docker-Content-Digest")) | 149 | + | assert.Equal(t, "sha256:abc123", rec.Header().Get("Docker-Content-Digest")) |
| 165 | assert.Contains(t, rec.Header().Get("Location"), "/v2/myapp/manifests/latest") | 150 | assert.Contains(t, rec.Header().Get("Location"), "/v2/myapp/manifests/latest") | ||
| 166 | } | 151 | } | ||
| 167 | 152 | ||||
| 168 | func TestHandler_PutManifest_MissingContentType(t *testing.T) { | 153 | func TestHandler_PutManifest_MissingContentType(t *testing.T) { | ||
| 169 | registrySvc := inmocks.NewMockRegistryService(t) | 154 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 170 | - | blobStorage := mocks.NewMockBlobStorage(t) | |||
| 171 | - | eventBus := mocks.NewMockEventPublisher(t) | |||
| 172 | 155 | ||||
| 173 | - | handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger()) | 156 | + | handler := NewHandler(registrySvc, testLogger()) |
| 174 | 157 | ||||
| 175 | manifestData := []byte(`{"schemaVersion": 2}`) | 158 | manifestData := []byte(`{"schemaVersion": 2}`) | ||
| 176 | 159 | ||||
| @@ -185,13 +168,11 @@ func TestHandler_PutManifest_MissingContentType(t *testing.T) { | |||||
| 185 | 168 | ||||
| 186 | func TestHandler_PutManifest_StorageError(t *testing.T) { | 169 | func TestHandler_PutManifest_StorageError(t *testing.T) { | ||
| 187 | registrySvc := inmocks.NewMockRegistryService(t) | 170 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 188 | - | blobStorage := mocks.NewMockBlobStorage(t) | |||
| 189 | - | eventBus := mocks.NewMockEventPublisher(t) | |||
| 190 | 171 | ||||
| 191 | - | handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger()) | 172 | + | handler := NewHandler(registrySvc, testLogger()) |
| 192 | 173 | ||||
| 193 | manifestData := []byte(`{"schemaVersion": 2}`) | 174 | manifestData := []byte(`{"schemaVersion": 2}`) | ||
| 194 | - | registrySvc.EXPECT().PutManifest(mock.Anything, mock.Anything).Return(assert.AnError) | 175 | + | registrySvc.EXPECT().PutManifest(mock.Anything, mock.Anything).Return("", assert.AnError) |
| 195 | 176 | ||||
| 196 | req := httptest.NewRequest("PUT", "/v2/myapp/manifests/latest", bytes.NewReader(manifestData)) | 177 | req := httptest.NewRequest("PUT", "/v2/myapp/manifests/latest", bytes.NewReader(manifestData)) | ||
| 197 | req.Header.Set("Content-Type", "application/vnd.docker.distribution.manifest.v2+json") | 178 | req.Header.Set("Content-Type", "application/vnd.docker.distribution.manifest.v2+json") | ||
| @@ -204,10 +185,8 @@ func TestHandler_PutManifest_StorageError(t *testing.T) { | |||||
| 204 | 185 | ||||
| 205 | func TestHandler_ManifestRoutes_MethodNotAllowed(t *testing.T) { | 186 | func TestHandler_ManifestRoutes_MethodNotAllowed(t *testing.T) { | ||
| 206 | registrySvc := inmocks.NewMockRegistryService(t) | 187 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 207 | - | blobStorage := mocks.NewMockBlobStorage(t) | |||
| 208 | - | eventBus := mocks.NewMockEventPublisher(t) | |||
| 209 | 188 | ||||
| 210 | - | handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger()) | 189 | + | handler := NewHandler(registrySvc, testLogger()) |
| 211 | 190 | ||||
| 212 | req := httptest.NewRequest("DELETE", "/v2/myapp/manifests/latest", nil) | 191 | req := httptest.NewRequest("DELETE", "/v2/myapp/manifests/latest", nil) | ||
| 213 | rec := httptest.NewRecorder() | 192 | rec := httptest.NewRecorder() | ||
| @@ -219,10 +198,8 @@ func TestHandler_ManifestRoutes_MethodNotAllowed(t *testing.T) { | |||||
| 219 | 198 | ||||
| 220 | func TestHandler_GetBlob_Success(t *testing.T) { | 199 | func TestHandler_GetBlob_Success(t *testing.T) { | ||
| 221 | registrySvc := inmocks.NewMockRegistryService(t) | 200 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 222 | - | blobStorage := mocks.NewMockBlobStorage(t) | |||
| 223 | - | eventBus := mocks.NewMockEventPublisher(t) | |||
| 224 | 201 | ||||
| 225 | - | handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger()) | 202 | + | handler := NewHandler(registrySvc, testLogger()) |
| 226 | 203 | ||||
| 227 | // Create a temp file for http.ServeFile | 204 | // Create a temp file for http.ServeFile | ||
| 228 | tmpFile, err := os.CreateTemp("", "blob-*") | 205 | tmpFile, err := os.CreateTemp("", "blob-*") | ||
| @@ -233,7 +210,7 @@ func TestHandler_GetBlob_Success(t *testing.T) { | |||||
| 233 | _, err = tmpFile.Write(blobContent) | 210 | _, err = tmpFile.Write(blobContent) | ||
| 234 | assert.NoError(t, err) | 211 | assert.NoError(t, err) | ||
| 235 | 212 | ||||
| 236 | - | blobStorage.EXPECT().GetBlobPath("sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(tmpFile.Name(), nil) | 213 | + | registrySvc.EXPECT().GetBlobPath(mock.Anything, "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(tmpFile.Name(), nil) |
| 237 | 214 | ||||
| 238 | req := httptest.NewRequest("GET", "/v2/myapp/blobs/sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", nil) | 215 | req := httptest.NewRequest("GET", "/v2/myapp/blobs/sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", nil) | ||
| 239 | rec := httptest.NewRecorder() | 216 | rec := httptest.NewRecorder() | ||
| @@ -246,12 +223,10 @@ func TestHandler_GetBlob_Success(t *testing.T) { | |||||
| 246 | 223 | ||||
| 247 | func TestHandler_GetBlob_NotFound(t *testing.T) { | 224 | func TestHandler_GetBlob_NotFound(t *testing.T) { | ||
| 248 | registrySvc := inmocks.NewMockRegistryService(t) | 225 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 249 | - | blobStorage := mocks.NewMockBlobStorage(t) | |||
| 250 | - | eventBus := mocks.NewMockEventPublisher(t) | |||
| 251 | 226 | ||||
| 252 | - | handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger()) | 227 | + | handler := NewHandler(registrySvc, testLogger()) |
| 253 | 228 | ||||
| 254 | - | blobStorage.EXPECT().GetBlobPath("sha256:0000000000000000000000000000000000000000000000000000000000000000").Return("", assert.AnError) | 229 | + | registrySvc.EXPECT().GetBlobPath(mock.Anything, "sha256:0000000000000000000000000000000000000000000000000000000000000000").Return("", assert.AnError) |
| 255 | 230 | ||||
| 256 | req := httptest.NewRequest("GET", "/v2/myapp/blobs/sha256:0000000000000000000000000000000000000000000000000000000000000000", nil) | 231 | req := httptest.NewRequest("GET", "/v2/myapp/blobs/sha256:0000000000000000000000000000000000000000000000000000000000000000", nil) | ||
| 257 | rec := httptest.NewRecorder() | 232 | rec := httptest.NewRecorder() | ||
| @@ -263,10 +238,8 @@ func TestHandler_GetBlob_NotFound(t *testing.T) { | |||||
| 263 | 238 | ||||
| 264 | func TestHandler_BlobRoutes_MethodNotAllowed(t *testing.T) { | 239 | func TestHandler_BlobRoutes_MethodNotAllowed(t *testing.T) { | ||
| 265 | registrySvc := inmocks.NewMockRegistryService(t) | 240 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 266 | - | blobStorage := mocks.NewMockBlobStorage(t) | |||
| 267 | - | eventBus := mocks.NewMockEventPublisher(t) | |||
| 268 | 241 | ||||
| 269 | - | handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger()) | 242 | + | handler := NewHandler(registrySvc, testLogger()) |
| 270 | 243 | ||||
| 271 | req := httptest.NewRequest("POST", "/v2/myapp/blobs/sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", nil) | 244 | req := httptest.NewRequest("POST", "/v2/myapp/blobs/sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", nil) | ||
| 272 | rec := httptest.NewRecorder() | 245 | rec := httptest.NewRecorder() | ||
| @@ -278,10 +251,8 @@ func TestHandler_BlobRoutes_MethodNotAllowed(t *testing.T) { | |||||
| 278 | 251 | ||||
| 279 | func TestHandler_StartBlobUpload_Success(t *testing.T) { | 252 | func TestHandler_StartBlobUpload_Success(t *testing.T) { | ||
| 280 | registrySvc := inmocks.NewMockRegistryService(t) | 253 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 281 | - | blobStorage := mocks.NewMockBlobStorage(t) | |||
| 282 | - | eventBus := mocks.NewMockEventPublisher(t) | |||
| 283 | 254 | ||||
| 284 | - | handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger()) | 255 | + | handler := NewHandler(registrySvc, testLogger()) |
| 285 | 256 | ||||
| 286 | registrySvc.EXPECT().StartUpload(mock.Anything, "myapp").Return("1234567890-myapp", nil) | 257 | registrySvc.EXPECT().StartUpload(mock.Anything, "myapp").Return("1234567890-myapp", nil) | ||
| 287 | 258 | ||||
| @@ -297,10 +268,8 @@ func TestHandler_StartBlobUpload_Success(t *testing.T) { | |||||
| 297 | 268 | ||||
| 298 | func TestHandler_StartBlobUpload_Error(t *testing.T) { | 269 | func TestHandler_StartBlobUpload_Error(t *testing.T) { | ||
| 299 | registrySvc := inmocks.NewMockRegistryService(t) | 270 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 300 | - | blobStorage := mocks.NewMockBlobStorage(t) | |||
| 301 | - | eventBus := mocks.NewMockEventPublisher(t) | |||
| 302 | 271 | ||||
| 303 | - | handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger()) | 272 | + | handler := NewHandler(registrySvc, testLogger()) |
| 304 | 273 | ||||
| 305 | registrySvc.EXPECT().StartUpload(mock.Anything, "myapp").Return("", assert.AnError) | 274 | registrySvc.EXPECT().StartUpload(mock.Anything, "myapp").Return("", assert.AnError) | ||
| 306 | 275 | ||||
| @@ -314,13 +283,11 @@ func TestHandler_StartBlobUpload_Error(t *testing.T) { | |||||
| 314 | 283 | ||||
| 315 | func TestHandler_BlobUpload_PATCH(t *testing.T) { | 284 | func TestHandler_BlobUpload_PATCH(t *testing.T) { | ||
| 316 | registrySvc := inmocks.NewMockRegistryService(t) | 285 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 317 | - | blobStorage := mocks.NewMockBlobStorage(t) | |||
| 318 | - | eventBus := mocks.NewMockEventPublisher(t) | |||
| 319 | 286 | ||||
| 320 | - | handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger()) | 287 | + | handler := NewHandler(registrySvc, testLogger()) |
| 321 | 288 | ||||
| 322 | chunkData := []byte("chunk content") | 289 | chunkData := []byte("chunk content") | ||
| 323 | - | blobStorage.EXPECT().AppendBlobChunk("myapp", "1234567890-myapp", chunkData).Return(int64(len(chunkData)), nil) | 290 | + | registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "1234567890-myapp", chunkData).Return(int64(len(chunkData)), nil) |
| 324 | 291 | ||||
| 325 | req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/1234567890-myapp", bytes.NewReader(chunkData)) | 292 | req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/1234567890-myapp", bytes.NewReader(chunkData)) | ||
| 326 | rec := httptest.NewRecorder() | 293 | rec := httptest.NewRecorder() | ||
| @@ -333,13 +300,11 @@ func TestHandler_BlobUpload_PATCH(t *testing.T) { | |||||
| 333 | 300 | ||||
| 334 | func TestHandler_BlobUpload_PUT_Finalize(t *testing.T) { | 301 | func TestHandler_BlobUpload_PUT_Finalize(t *testing.T) { | ||
| 335 | registrySvc := inmocks.NewMockRegistryService(t) | 302 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 336 | - | blobStorage := mocks.NewMockBlobStorage(t) | |||
| 337 | - | eventBus := mocks.NewMockEventPublisher(t) | |||
| 338 | 303 | ||||
| 339 | - | handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger()) | 304 | + | handler := NewHandler(registrySvc, testLogger()) |
| 340 | 305 | ||||
| 341 | chunkData := []byte("final chunk") | 306 | chunkData := []byte("final chunk") | ||
| 342 | - | blobStorage.EXPECT().AppendBlobChunk("myapp", "1234567890-myapp", chunkData).Return(int64(len(chunkData)), nil) | 307 | + | registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "1234567890-myapp", chunkData).Return(int64(len(chunkData)), nil) |
| 343 | registrySvc.EXPECT().FinishUpload(mock.Anything, "1234567890-myapp", "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(nil) | 308 | registrySvc.EXPECT().FinishUpload(mock.Anything, "1234567890-myapp", "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(nil) | ||
| 344 | 309 | ||||
| 345 | req := httptest.NewRequest("PUT", "/v2/myapp/blobs/uploads/1234567890-myapp?digest=sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", bytes.NewReader(chunkData)) | 310 | req := httptest.NewRequest("PUT", "/v2/myapp/blobs/uploads/1234567890-myapp?digest=sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", bytes.NewReader(chunkData)) | ||
| @@ -354,10 +319,8 @@ func TestHandler_BlobUpload_PUT_Finalize(t *testing.T) { | |||||
| 354 | 319 | ||||
| 355 | func TestHandler_BlobUpload_PUT_DigestMismatch(t *testing.T) { | 320 | func TestHandler_BlobUpload_PUT_DigestMismatch(t *testing.T) { | ||
| 356 | registrySvc := inmocks.NewMockRegistryService(t) | 321 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 357 | - | blobStorage := mocks.NewMockBlobStorage(t) | |||
| 358 | - | eventBus := mocks.NewMockEventPublisher(t) | |||
| 359 | 322 | ||||
| 360 | - | handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger()) | 323 | + | handler := NewHandler(registrySvc, testLogger()) |
| 361 | 324 | ||||
| 362 | chunkData := []byte("final chunk") | 325 | chunkData := []byte("final chunk") | ||
| 363 | // Note: Invalid digest format is rejected by validation before reaching storage layer | 326 | // Note: Invalid digest format is rejected by validation before reaching storage layer | ||
| @@ -373,10 +336,8 @@ func TestHandler_BlobUpload_PUT_DigestMismatch(t *testing.T) { | |||||
| 373 | 336 | ||||
| 374 | func TestHandler_ListTags_Success(t *testing.T) { | 337 | func TestHandler_ListTags_Success(t *testing.T) { | ||
| 375 | registrySvc := inmocks.NewMockRegistryService(t) | 338 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 376 | - | blobStorage := mocks.NewMockBlobStorage(t) | |||
| 377 | - | eventBus := mocks.NewMockEventPublisher(t) | |||
| 378 | 339 | ||||
| 379 | - | handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger()) | 340 | + | handler := NewHandler(registrySvc, testLogger()) |
| 380 | 341 | ||||
| 381 | registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return([]string{"latest", "v1.0", "v2.0"}, nil) | 342 | registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return([]string{"latest", "v1.0", "v2.0"}, nil) | ||
| 382 | 343 | ||||
| @@ -393,10 +354,8 @@ func TestHandler_ListTags_Success(t *testing.T) { | |||||
| 393 | 354 | ||||
| 394 | func TestHandler_ListTags_NotFound(t *testing.T) { | 355 | func TestHandler_ListTags_NotFound(t *testing.T) { | ||
| 395 | registrySvc := inmocks.NewMockRegistryService(t) | 356 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 396 | - | blobStorage := mocks.NewMockBlobStorage(t) | |||
| 397 | - | eventBus := mocks.NewMockEventPublisher(t) | |||
| 398 | 357 | ||||
| 399 | - | handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger()) | 358 | + | handler := NewHandler(registrySvc, testLogger()) |
| 400 | 359 | ||||
| 401 | registrySvc.EXPECT().ListTags(mock.Anything, "notexists").Return(nil, assert.AnError) | 360 | registrySvc.EXPECT().ListTags(mock.Anything, "notexists").Return(nil, assert.AnError) | ||
| 402 | 361 | ||||
| @@ -410,10 +369,8 @@ func TestHandler_ListTags_NotFound(t *testing.T) { | |||||
| 410 | 369 | ||||
| 411 | func TestHandler_ListTags_MethodNotAllowed(t *testing.T) { | 370 | func TestHandler_ListTags_MethodNotAllowed(t *testing.T) { | ||
| 412 | registrySvc := inmocks.NewMockRegistryService(t) | 371 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 413 | - | blobStorage := mocks.NewMockBlobStorage(t) | |||
| 414 | - | eventBus := mocks.NewMockEventPublisher(t) | |||
| 415 | 372 | ||||
| 416 | - | handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger()) | 373 | + | handler := NewHandler(registrySvc, testLogger()) |
| 417 | 374 | ||||
| 418 | req := httptest.NewRequest("POST", "/v2/myapp/tags/list", nil) | 375 | req := httptest.NewRequest("POST", "/v2/myapp/tags/list", nil) | ||
| 419 | rec := httptest.NewRecorder() | 376 | rec := httptest.NewRecorder() | ||
| @@ -425,10 +382,8 @@ func TestHandler_ListTags_MethodNotAllowed(t *testing.T) { | |||||
| 425 | 382 | ||||
| 426 | func TestHandler_RegisterRoutes(t *testing.T) { | 383 | func TestHandler_RegisterRoutes(t *testing.T) { | ||
| 427 | registrySvc := inmocks.NewMockRegistryService(t) | 384 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 428 | - | blobStorage := mocks.NewMockBlobStorage(t) | |||
| 429 | - | eventBus := mocks.NewMockEventPublisher(t) | |||
| 430 | 385 | ||||
| 431 | - | handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger()) | 386 | + | handler := NewHandler(registrySvc, testLogger()) |
| 432 | 387 | ||||
| 433 | mux := http.NewServeMux() | 388 | mux := http.NewServeMux() | ||
| 434 | handler.RegisterRoutes(mux) | 389 | handler.RegisterRoutes(mux) | ||
M internal/adapters/out/docker/runtime.go +142 -1
| @@ -2,11 +2,14 @@ | |||||
| 2 | package docker | 2 | package docker | ||
| 3 | 3 | ||||
| 4 | import ( | 4 | import ( | ||
| 5 | + | "archive/tar" | |||
| 5 | "context" | 6 | "context" | ||
| 6 | "encoding/base64" | 7 | "encoding/base64" | ||
| 7 | "encoding/json" | 8 | "encoding/json" | ||
| 8 | "fmt" | 9 | "fmt" | ||
| 9 | "io" | 10 | "io" | ||
| 11 | + | "path/filepath" | |||
| 12 | + | "sort" | |||
| 10 | "strconv" | 13 | "strconv" | ||
| 11 | "strings" | 14 | "strings" | ||
| 12 | 15 | ||||
| @@ -264,7 +267,7 @@ func (r *Runtime) ListContainers(ctx context.Context, all bool) ([]*domain.Conta | |||||
| 264 | ID: c.ID, | 267 | ID: c.ID, | ||
| 265 | Image: c.Image, | 268 | Image: c.Image, | ||
| 266 | Name: name, | 269 | Name: name, | ||
| 267 | - | Status: c.Status, | 270 | + | Status: c.State, // Use State (e.g., "running") not Status (e.g., "Up 2 days") |
| 268 | Ports: ports, | 271 | Ports: ports, | ||
| 269 | Labels: c.Labels, | 272 | Labels: c.Labels, | ||
| 270 | }) | 273 | }) | ||
| @@ -619,10 +622,42 @@ func (r *Runtime) GetImageExposedPorts(ctx context.Context, imageRef string) ([] | |||||
| 619 | return nil, fmt.Errorf("no EXPOSE directives found in image %s - please add EXPOSE directive to Dockerfile", imageRef) | 622 | return nil, fmt.Errorf("no EXPOSE directives found in image %s - please add EXPOSE directive to Dockerfile", imageRef) | ||
| 620 | } | 623 | } | ||
| 621 | 624 | ||||
| 625 | + | // Sort ports with HTTP-friendly ports first (avoid SSH port 22, etc.) | |||
| 626 | + | // Priority: 80, 8080, 3000, 8000, 5000, then others sorted ascending | |||
| 627 | + | sortPortsHTTPFirst(ports) | |||
| 628 | + | ||||
| 622 | log.Info().Ints("exposed_ports", ports).Msg("found exposed ports in image") | 629 | log.Info().Ints("exposed_ports", ports).Msg("found exposed ports in image") | ||
| 623 | return ports, nil | 630 | return ports, nil | ||
| 624 | } | 631 | } | ||
| 625 | 632 | ||||
| 633 | + | // sortPortsHTTPFirst sorts ports with common HTTP ports first, avoiding SSH (22). | |||
| 634 | + | func sortPortsHTTPFirst(ports []int) { | |||
| 635 | + | priority := map[int]int{ | |||
| 636 | + | 80: 0, 443: 1, 8080: 2, 3000: 3, 8000: 4, 5000: 5, 9000: 6, | |||
| 637 | + | } | |||
| 638 | + | sort.Slice(ports, func(i, j int) bool { | |||
| 639 | + | pi, oki := priority[ports[i]] | |||
| 640 | + | pj, okj := priority[ports[j]] | |||
| 641 | + | if oki && okj { | |||
| 642 | + | return pi < pj | |||
| 643 | + | } | |||
| 644 | + | if oki { | |||
| 645 | + | return true | |||
| 646 | + | } | |||
| 647 | + | if okj { | |||
| 648 | + | return false | |||
| 649 | + | } | |||
| 650 | + | // Neither is a priority port - sort ascending but push 22 to the end | |||
| 651 | + | if ports[i] == 22 { | |||
| 652 | + | return false | |||
| 653 | + | } | |||
| 654 | + | if ports[j] == 22 { | |||
| 655 | + | return true | |||
| 656 | + | } | |||
| 657 | + | return ports[i] < ports[j] | |||
| 658 | + | }) | |||
| 659 | + | } | |||
| 660 | + | ||||
| 626 | // GetContainerExposedPorts gets all exposed ports from a running container. | 661 | // GetContainerExposedPorts gets all exposed ports from a running container. | ||
| 627 | func (r *Runtime) GetContainerExposedPorts(ctx context.Context, containerID string) ([]int, error) { | 662 | func (r *Runtime) GetContainerExposedPorts(ctx context.Context, containerID string) ([]int, error) { | ||
| 628 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | 663 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | ||
| @@ -1018,3 +1053,109 @@ func (r *Runtime) DisconnectContainerFromNetwork(ctx context.Context, containerN | |||||
| 1018 | log.Info().Msg("container disconnected from network") | 1053 | log.Info().Msg("container disconnected from network") | ||
| 1019 | return nil | 1054 | return nil | ||
| 1020 | } | 1055 | } | ||
| 1056 | + | ||||
| 1057 | + | // CopyFromContainer copies a file from a container to the host. | |||
| 1058 | + | // Returns the file contents as a byte slice. | |||
| 1059 | + | func (r *Runtime) CopyFromContainer(ctx context.Context, containerID, srcPath string) ([]byte, error) { | |||
| 1060 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |||
| 1061 | + | zerowrap.FieldLayer: "adapter", | |||
| 1062 | + | zerowrap.FieldAdapter: "docker", | |||
| 1063 | + | zerowrap.FieldAction: "CopyFromContainer", | |||
| 1064 | + | zerowrap.FieldEntityID: containerID, | |||
| 1065 | + | "src_path": srcPath, | |||
| 1066 | + | }) | |||
| 1067 | + | log := zerowrap.FromCtx(ctx) | |||
| 1068 | + | ||||
| 1069 | + | reader, _, err := r.client.CopyFromContainer(ctx, containerID, srcPath) | |||
| 1070 | + | if err != nil { | |||
| 1071 | + | return nil, log.WrapErr(err, "failed to copy from container") | |||
| 1072 | + | } | |||
| 1073 | + | defer reader.Close() | |||
| 1074 | + | ||||
| 1075 | + | // The response is a tar archive - we need to extract the file | |||
| 1076 | + | data, err := extractFileFromTar(reader, srcPath) | |||
| 1077 | + | if err != nil { | |||
| 1078 | + | return nil, log.WrapErr(err, "failed to extract file from tar") | |||
| 1079 | + | } | |||
| 1080 | + | ||||
| 1081 | + | log.Debug().Int("size", len(data)).Msg("file copied from container") | |||
| 1082 | + | return data, nil | |||
| 1083 | + | } | |||
| 1084 | + | ||||
| 1085 | + | // extractFileFromTar extracts a single file from a tar archive. | |||
| 1086 | + | func extractFileFromTar(reader io.Reader, targetPath string) ([]byte, error) { | |||
| 1087 | + | tr := tar.NewReader(reader) | |||
| 1088 | + | ||||
| 1089 | + | // The path in the tar may be relative or absolute | |||
| 1090 | + | // We need to match based on the filename | |||
| 1091 | + | targetName := filepath.Base(targetPath) | |||
| 1092 | + | ||||
| 1093 | + | for { | |||
| 1094 | + | header, err := tr.Next() | |||
| 1095 | + | if err == io.EOF { | |||
| 1096 | + | break | |||
| 1097 | + | } | |||
| 1098 | + | if err != nil { | |||
| 1099 | + | return nil, err | |||
| 1100 | + | } | |||
| 1101 | + | ||||
| 1102 | + | // Check if this is our target file | |||
| 1103 | + | if header.Typeflag == tar.TypeReg { | |||
| 1104 | + | headerName := filepath.Base(header.Name) | |||
| 1105 | + | if headerName == targetName { | |||
| 1106 | + | data, err := io.ReadAll(tr) | |||
| 1107 | + | if err != nil { | |||
| 1108 | + | return nil, err | |||
| 1109 | + | } | |||
| 1110 | + | return data, nil | |||
| 1111 | + | } | |||
| 1112 | + | } | |||
| 1113 | + | } | |||
| 1114 | + | ||||
| 1115 | + | return nil, fmt.Errorf("file not found in container: %s", targetPath) | |||
| 1116 | + | } | |||
| 1117 | + | ||||
| 1118 | + | // ExtractEnvFileFromImage extracts an env file from an image. | |||
| 1119 | + | // This creates a temporary container, copies the file, and removes the container. | |||
| 1120 | + | func (r *Runtime) ExtractEnvFileFromImage(ctx context.Context, imageRef, envFilePath string) ([]byte, error) { | |||
| 1121 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |||
| 1122 | + | zerowrap.FieldLayer: "adapter", | |||
| 1123 | + | zerowrap.FieldAdapter: "docker", | |||
| 1124 | + | zerowrap.FieldAction: "ExtractEnvFileFromImage", | |||
| 1125 | + | "image": imageRef, | |||
| 1126 | + | "env_file": envFilePath, | |||
| 1127 | + | }) | |||
| 1128 | + | log := zerowrap.FromCtx(ctx) | |||
| 1129 | + | ||||
| 1130 | + | log.Info().Msg("extracting env file from image") | |||
| 1131 | + | ||||
| 1132 | + | // Create a temporary container without starting it | |||
| 1133 | + | containerConfig := &container.Config{ | |||
| 1134 | + | Image: imageRef, | |||
| 1135 | + | Cmd: []string{"true"}, // Dummy command | |||
| 1136 | + | } | |||
| 1137 | + | ||||
| 1138 | + | resp, err := r.client.ContainerCreate(ctx, containerConfig, nil, nil, nil, "") | |||
| 1139 | + | if err != nil { | |||
| 1140 | + | return nil, log.WrapErr(err, "failed to create temporary container") | |||
| 1141 | + | } | |||
| 1142 | + | ||||
| 1143 | + | tempContainerID := resp.ID | |||
| 1144 | + | log.Debug().Str("temp_container_id", tempContainerID).Msg("created temporary container") | |||
| 1145 | + | ||||
| 1146 | + | // Ensure cleanup | |||
| 1147 | + | defer func() { | |||
| 1148 | + | if err := r.client.ContainerRemove(ctx, tempContainerID, container.RemoveOptions{Force: true}); err != nil { | |||
| 1149 | + | log.Warn().Err(err).Str("temp_container_id", tempContainerID).Msg("failed to remove temporary container") | |||
| 1150 | + | } | |||
| 1151 | + | }() | |||
| 1152 | + | ||||
| 1153 | + | // Copy the env file from the container | |||
| 1154 | + | data, err := r.CopyFromContainer(ctx, tempContainerID, envFilePath) | |||
| 1155 | + | if err != nil { | |||
| 1156 | + | return nil, err | |||
| 1157 | + | } | |||
| 1158 | + | ||||
| 1159 | + | log.Info().Int("size", len(data)).Msg("env file extracted from image") | |||
| 1160 | + | return data, nil | |||
| 1161 | + | } | |||
A internal/adapters/out/domainsecrets/store.go +209 -0
| @@ -0,0 +1,209 @@ | |||||
| 1 | + | // Package domainsecrets implements the DomainSecretStore adapter using filesystem-based env files. | |||
| 2 | + | package domainsecrets | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "bufio" | |||
| 6 | + | "fmt" | |||
| 7 | + | "os" | |||
| 8 | + | "path/filepath" | |||
| 9 | + | "strings" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/zerowrap" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // FileStore implements the DomainSecretStore interface using filesystem-based env files. | |||
| 15 | + | // This adapter is responsible only for file I/O operations; domain validation | |||
| 16 | + | // should be performed by the use case layer before calling these methods. | |||
| 17 | + | type FileStore struct { | |||
| 18 | + | envDir string | |||
| 19 | + | log zerowrap.Logger | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | // NewFileStore creates a new file-based domain secret store. | |||
| 23 | + | func NewFileStore(envDir string, log zerowrap.Logger) (*FileStore, error) { | |||
| 24 | + | // Ensure env directory exists | |||
| 25 | + | if err := os.MkdirAll(envDir, 0700); err != nil { | |||
| 26 | + | return nil, fmt.Errorf("failed to create env directory %s: %w", envDir, err) | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | log.Debug(). | |||
| 30 | + | Str(zerowrap.FieldLayer, "adapter"). | |||
| 31 | + | Str(zerowrap.FieldAdapter, "domainsecrets"). | |||
| 32 | + | Str("env_dir", envDir). | |||
| 33 | + | Msg("domain secret store initialized") | |||
| 34 | + | ||||
| 35 | + | return &FileStore{ | |||
| 36 | + | envDir: envDir, | |||
| 37 | + | log: log, | |||
| 38 | + | }, nil | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // ListKeys returns the list of secret keys for a domain (not values). | |||
| 42 | + | func (s *FileStore) ListKeys(domain string) ([]string, error) { | |||
| 43 | + | envFile := s.getEnvFilePath(domain) | |||
| 44 | + | ||||
| 45 | + | file, err := os.Open(envFile) | |||
| 46 | + | if err != nil { | |||
| 47 | + | if os.IsNotExist(err) { | |||
| 48 | + | return []string{}, nil | |||
| 49 | + | } | |||
| 50 | + | return nil, fmt.Errorf("failed to open env file: %w", err) | |||
| 51 | + | } | |||
| 52 | + | defer file.Close() | |||
| 53 | + | ||||
| 54 | + | var keys []string | |||
| 55 | + | scanner := bufio.NewScanner(file) | |||
| 56 | + | for scanner.Scan() { | |||
| 57 | + | line := strings.TrimSpace(scanner.Text()) | |||
| 58 | + | // Skip empty lines and comments | |||
| 59 | + | if line == "" || strings.HasPrefix(line, "#") { | |||
| 60 | + | continue | |||
| 61 | + | } | |||
| 62 | + | // Extract key from KEY=value | |||
| 63 | + | if idx := strings.Index(line, "="); idx > 0 { | |||
| 64 | + | keys = append(keys, line[:idx]) | |||
| 65 | + | } | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | if err := scanner.Err(); err != nil { | |||
| 69 | + | return nil, fmt.Errorf("failed to read env file: %w", err) | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | return keys, nil | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // GetAll returns all secrets for a domain as a key-value map. | |||
| 76 | + | func (s *FileStore) GetAll(domain string) (map[string]string, error) { | |||
| 77 | + | envFile := s.getEnvFilePath(domain) | |||
| 78 | + | ||||
| 79 | + | file, err := os.Open(envFile) | |||
| 80 | + | if err != nil { | |||
| 81 | + | if os.IsNotExist(err) { | |||
| 82 | + | return map[string]string{}, nil | |||
| 83 | + | } | |||
| 84 | + | return nil, fmt.Errorf("failed to open env file: %w", err) | |||
| 85 | + | } | |||
| 86 | + | defer file.Close() | |||
| 87 | + | ||||
| 88 | + | secrets := make(map[string]string) | |||
| 89 | + | scanner := bufio.NewScanner(file) | |||
| 90 | + | for scanner.Scan() { | |||
| 91 | + | line := strings.TrimSpace(scanner.Text()) | |||
| 92 | + | // Skip empty lines and comments | |||
| 93 | + | if line == "" || strings.HasPrefix(line, "#") { | |||
| 94 | + | continue | |||
| 95 | + | } | |||
| 96 | + | // Parse KEY=value | |||
| 97 | + | if idx := strings.Index(line, "="); idx > 0 { | |||
| 98 | + | key := line[:idx] | |||
| 99 | + | value := line[idx+1:] | |||
| 100 | + | secrets[key] = value | |||
| 101 | + | } | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | if err := scanner.Err(); err != nil { | |||
| 105 | + | return nil, fmt.Errorf("failed to read env file: %w", err) | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | return secrets, nil | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | // Set sets or updates multiple secrets for a domain, merging with existing. | |||
| 112 | + | func (s *FileStore) Set(domain string, secrets map[string]string) error { | |||
| 113 | + | // Ensure env directory exists | |||
| 114 | + | if err := os.MkdirAll(s.envDir, 0700); err != nil { | |||
| 115 | + | return fmt.Errorf("failed to create env directory: %w", err) | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | // Read existing secrets | |||
| 119 | + | existing, err := s.GetAll(domain) | |||
| 120 | + | if err != nil { | |||
| 121 | + | return err | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | // Merge new secrets with existing | |||
| 125 | + | for key, value := range secrets { | |||
| 126 | + | existing[key] = value | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | // Write back atomically | |||
| 130 | + | return s.writeSecretsAtomic(domain, existing) | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | // Delete removes a specific secret key from a domain. | |||
| 134 | + | func (s *FileStore) Delete(domain, key string) error { | |||
| 135 | + | // Read existing secrets | |||
| 136 | + | existing, err := s.GetAll(domain) | |||
| 137 | + | if err != nil { | |||
| 138 | + | return err | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | // Remove the key | |||
| 142 | + | delete(existing, key) | |||
| 143 | + | ||||
| 144 | + | // Write back atomically | |||
| 145 | + | return s.writeSecretsAtomic(domain, existing) | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | // writeSecretsAtomic writes all secrets to the domain's env file atomically. | |||
| 149 | + | // It writes to a temporary file first, syncs it, then renames to the final path. | |||
| 150 | + | func (s *FileStore) writeSecretsAtomic(domain string, secrets map[string]string) error { | |||
| 151 | + | envFile := s.getEnvFilePath(domain) | |||
| 152 | + | tmpFile := envFile + ".tmp" | |||
| 153 | + | ||||
| 154 | + | file, err := os.OpenFile(tmpFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) | |||
| 155 | + | if err != nil { | |||
| 156 | + | return fmt.Errorf("failed to create temp env file: %w", err) | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | // Write header comment | |||
| 160 | + | if _, err := fmt.Fprintf(file, "# Environment variables for %s\n", domain); err != nil { | |||
| 161 | + | file.Close() | |||
| 162 | + | os.Remove(tmpFile) | |||
| 163 | + | return fmt.Errorf("failed to write header: %w", err) | |||
| 164 | + | } | |||
| 165 | + | if _, err := fmt.Fprintf(file, "# Managed by Gordon admin API\n\n"); err != nil { | |||
| 166 | + | file.Close() | |||
| 167 | + | os.Remove(tmpFile) | |||
| 168 | + | return fmt.Errorf("failed to write header: %w", err) | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | // Write each secret | |||
| 172 | + | for key, value := range secrets { | |||
| 173 | + | if _, err := fmt.Fprintf(file, "%s=%s\n", key, value); err != nil { | |||
| 174 | + | file.Close() | |||
| 175 | + | os.Remove(tmpFile) | |||
| 176 | + | return fmt.Errorf("failed to write secret %s: %w", key, err) | |||
| 177 | + | } | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | // Sync to ensure data is on disk before rename | |||
| 181 | + | if err := file.Sync(); err != nil { | |||
| 182 | + | file.Close() | |||
| 183 | + | os.Remove(tmpFile) | |||
| 184 | + | return fmt.Errorf("failed to sync env file: %w", err) | |||
| 185 | + | } | |||
| 186 | + | ||||
| 187 | + | if err := file.Close(); err != nil { | |||
| 188 | + | os.Remove(tmpFile) | |||
| 189 | + | return fmt.Errorf("failed to close env file: %w", err) | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | // Atomic rename | |||
| 193 | + | if err := os.Rename(tmpFile, envFile); err != nil { | |||
| 194 | + | os.Remove(tmpFile) | |||
| 195 | + | return fmt.Errorf("failed to rename env file: %w", err) | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | return nil | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | // getEnvFilePath converts a domain to its env file path. | |||
| 202 | + | // This must match the naming convention in envloader.FileLoader.getEnvFilePath. | |||
| 203 | + | func (s *FileStore) getEnvFilePath(domain string) string { | |||
| 204 | + | // Create domain-safe filename (replace dots and other chars with underscores) | |||
| 205 | + | safeDomain := strings.ReplaceAll(domain, ".", "_") | |||
| 206 | + | safeDomain = strings.ReplaceAll(safeDomain, ":", "_") | |||
| 207 | + | safeDomain = strings.ReplaceAll(safeDomain, "/", "_") | |||
| 208 | + | return filepath.Join(s.envDir, safeDomain+".env") | |||
| 209 | + | } | |||
A internal/adapters/out/httpprober/prober.go +91 -0
| @@ -0,0 +1,91 @@ | |||||
| 1 | + | // Package httpprober provides HTTP probing functionality for health checks. | |||
| 2 | + | package httpprober | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "context" | |||
| 6 | + | "crypto/tls" | |||
| 7 | + | "fmt" | |||
| 8 | + | "net/http" | |||
| 9 | + | "time" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // DefaultTimeout is the default timeout for HTTP probes. | |||
| 13 | + | const DefaultTimeout = 5 * time.Second | |||
| 14 | + | ||||
| 15 | + | // Prober implements the HTTPProber interface for health checking. | |||
| 16 | + | type Prober struct { | |||
| 17 | + | client *http.Client | |||
| 18 | + | timeout time.Duration | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | // Option configures the Prober. | |||
| 22 | + | type Option func(*Prober) | |||
| 23 | + | ||||
| 24 | + | // WithTimeout sets the probe timeout. | |||
| 25 | + | func WithTimeout(timeout time.Duration) Option { | |||
| 26 | + | return func(p *Prober) { | |||
| 27 | + | p.timeout = timeout | |||
| 28 | + | } | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | // WithHTTPClient sets a custom HTTP client. | |||
| 32 | + | func WithHTTPClient(client *http.Client) Option { | |||
| 33 | + | return func(p *Prober) { | |||
| 34 | + | p.client = client | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // New creates a new HTTP prober. | |||
| 39 | + | func New(opts ...Option) *Prober { | |||
| 40 | + | p := &Prober{ | |||
| 41 | + | timeout: DefaultTimeout, | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | for _, opt := range opts { | |||
| 45 | + | opt(p) | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | if p.client == nil { | |||
| 49 | + | p.client = &http.Client{ | |||
| 50 | + | Timeout: p.timeout, | |||
| 51 | + | Transport: &http.Transport{ | |||
| 52 | + | // #nosec G402 - InsecureSkipVerify is intentional for health probing | |||
| 53 | + | // internal services that may use self-signed certificates. | |||
| 54 | + | // This prober only checks reachability, not data integrity. | |||
| 55 | + | TLSClientConfig: &tls.Config{ | |||
| 56 | + | InsecureSkipVerify: true, | |||
| 57 | + | }, | |||
| 58 | + | DisableKeepAlives: true, | |||
| 59 | + | }, | |||
| 60 | + | // Don't follow redirects - we want to see the actual response | |||
| 61 | + | CheckRedirect: func(req *http.Request, via []*http.Request) error { | |||
| 62 | + | return http.ErrUseLastResponse | |||
| 63 | + | }, | |||
| 64 | + | } | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | return p | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | // Probe sends an HTTP GET request and returns the status code and response time. | |||
| 71 | + | func (p *Prober) Probe(ctx context.Context, url string) (int, int64, error) { | |||
| 72 | + | start := time.Now() | |||
| 73 | + | ||||
| 74 | + | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) | |||
| 75 | + | if err != nil { | |||
| 76 | + | return 0, 0, fmt.Errorf("failed to create request: %w", err) | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | // Set a reasonable user agent | |||
| 80 | + | req.Header.Set("User-Agent", "Gordon-HealthCheck/1.0") | |||
| 81 | + | ||||
| 82 | + | resp, err := p.client.Do(req) | |||
| 83 | + | if err != nil { | |||
| 84 | + | elapsed := time.Since(start).Milliseconds() | |||
| 85 | + | return 0, elapsed, fmt.Errorf("request failed: %w", err) | |||
| 86 | + | } | |||
| 87 | + | defer resp.Body.Close() | |||
| 88 | + | ||||
| 89 | + | elapsed := time.Since(start).Milliseconds() | |||
| 90 | + | return resp.StatusCode, elapsed, nil | |||
| 91 | + | } | |||
M internal/app/run.go +81 -13
| @@ -21,14 +21,19 @@ import ( | |||||
| 21 | 21 | ||||
| 22 | // Adapters - Output | 22 | // Adapters - Output | ||
| 23 | "gordon/internal/adapters/out/docker" | 23 | "gordon/internal/adapters/out/docker" | ||
| 24 | + | "gordon/internal/adapters/out/domainsecrets" | |||
| 24 | "gordon/internal/adapters/out/envloader" | 25 | "gordon/internal/adapters/out/envloader" | ||
| 25 | "gordon/internal/adapters/out/eventbus" | 26 | "gordon/internal/adapters/out/eventbus" | ||
| 26 | "gordon/internal/adapters/out/filesystem" | 27 | "gordon/internal/adapters/out/filesystem" | ||
| 28 | + | "gordon/internal/adapters/out/httpprober" | |||
| 27 | "gordon/internal/adapters/out/logwriter" | 29 | "gordon/internal/adapters/out/logwriter" | ||
| 28 | "gordon/internal/adapters/out/secrets" | 30 | "gordon/internal/adapters/out/secrets" | ||
| 29 | "gordon/internal/adapters/out/tokenstore" | 31 | "gordon/internal/adapters/out/tokenstore" | ||
| 30 | 32 | ||||
| 33 | + | "golang.org/x/time/rate" | |||
| 34 | + | ||||
| 31 | // Adapters - Input | 35 | // Adapters - Input | ||
| 36 | + | "gordon/internal/adapters/in/http/admin" | |||
| 32 | "gordon/internal/adapters/in/http/middleware" | 37 | "gordon/internal/adapters/in/http/middleware" | ||
| 33 | "gordon/internal/adapters/in/http/registry" | 38 | "gordon/internal/adapters/in/http/registry" | ||
| 34 | 39 | ||||
| @@ -42,8 +47,10 @@ import ( | |||||
| 42 | "gordon/internal/usecase/auth" | 47 | "gordon/internal/usecase/auth" | ||
| 43 | "gordon/internal/usecase/config" | 48 | "gordon/internal/usecase/config" | ||
| 44 | "gordon/internal/usecase/container" | 49 | "gordon/internal/usecase/container" | ||
| 50 | + | "gordon/internal/usecase/health" | |||
| 45 | "gordon/internal/usecase/proxy" | 51 | "gordon/internal/usecase/proxy" | ||
| 46 | registrySvc "gordon/internal/usecase/registry" | 52 | registrySvc "gordon/internal/usecase/registry" | ||
| 53 | + | secretsSvc "gordon/internal/usecase/secrets" | |||
| 47 | ) | 54 | ) | ||
| 48 | 55 | ||||
| 49 | // Config holds the application configuration. | 56 | // Config holds the application configuration. | ||
| @@ -51,7 +58,8 @@ type Config struct { | |||||
| 51 | Server struct { | 58 | Server struct { | ||
| 52 | Port int `mapstructure:"port"` | 59 | Port int `mapstructure:"port"` | ||
| 53 | RegistryPort int `mapstructure:"registry_port"` | 60 | RegistryPort int `mapstructure:"registry_port"` | ||
| 54 | - | RegistryDomain string `mapstructure:"registry_domain"` | 61 | + | GordonDomain string `mapstructure:"gordon_domain"` |
| 62 | + | RegistryDomain string `mapstructure:"registry_domain"` // Deprecated: use gordon_domain | |||
| 55 | DataDir string `mapstructure:"data_dir"` | 63 | DataDir string `mapstructure:"data_dir"` | ||
| 56 | } `mapstructure:"server"` | 64 | } `mapstructure:"server"` | ||
| 57 | 65 | ||||
| @@ -108,8 +116,10 @@ type services struct { | |||||
| 108 | proxySvc *proxy.Service | 116 | proxySvc *proxy.Service | ||
| 109 | authSvc *auth.Service | 117 | authSvc *auth.Service | ||
| 110 | tokenHandler *registry.TokenHandler | 118 | tokenHandler *registry.TokenHandler | ||
| 119 | + | adminHandler *admin.Handler | |||
| 111 | internalRegUser string | 120 | internalRegUser string | ||
| 112 | internalRegPass string | 121 | internalRegPass string | ||
| 122 | + | envDir string | |||
| 113 | } | 123 | } | ||
| 114 | 124 | ||||
| 115 | // Run initializes and starts the Gordon application. | 125 | // Run initializes and starts the Gordon application. | ||
| @@ -145,7 +155,7 @@ func Run(ctx context.Context, configPath string) error { | |||||
| 145 | } | 155 | } | ||
| 146 | 156 | ||||
| 147 | // Register event handlers | 157 | // Register event handlers | ||
| 148 | - | if err := registerEventHandlers(ctx, svc); err != nil { | 158 | + | if err := registerEventHandlers(ctx, svc, cfg); err != nil { |
| 149 | return err | 159 | return err | ||
| 150 | } | 160 | } | ||
| 151 | 161 | ||||
| @@ -180,6 +190,11 @@ func initConfig(configPath string) (*viper.Viper, Config, error) { | |||||
| 180 | return nil, Config{}, fmt.Errorf("failed to unmarshal config: %w", err) | 190 | return nil, Config{}, fmt.Errorf("failed to unmarshal config: %w", err) | ||
| 181 | } | 191 | } | ||
| 182 | 192 | ||||
| 193 | + | // Normalize domain config: prefer gordon_domain over registry_domain | |||
| 194 | + | if cfg.Server.GordonDomain != "" { | |||
| 195 | + | cfg.Server.RegistryDomain = cfg.Server.GordonDomain | |||
| 196 | + | } | |||
| 197 | + | ||||
| 183 | return v, cfg, nil | 198 | return v, cfg, nil | ||
| 184 | } | 199 | } | ||
| 185 | 200 | ||||
| @@ -275,6 +290,30 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||||
| 275 | svc.tokenHandler = registry.NewTokenHandler(svc.authSvc, internalAuth, log) | 290 | svc.tokenHandler = registry.NewTokenHandler(svc.authSvc, internalAuth, log) | ||
| 276 | } | 291 | } | ||
| 277 | 292 | ||||
| 293 | + | // Determine env directory for admin API | |||
| 294 | + | dataDir := cfg.Server.DataDir | |||
| 295 | + | if dataDir == "" { | |||
| 296 | + | dataDir = DefaultDataDir() | |||
| 297 | + | } | |||
| 298 | + | svc.envDir = cfg.Env.Dir | |||
| 299 | + | if svc.envDir == "" { | |||
| 300 | + | svc.envDir = filepath.Join(dataDir, "env") | |||
| 301 | + | } | |||
| 302 | + | ||||
| 303 | + | // Create domain secret store and service | |||
| 304 | + | domainSecretStore, err := domainsecrets.NewFileStore(svc.envDir, log) | |||
| 305 | + | if err != nil { | |||
| 306 | + | return nil, log.WrapErr(err, "failed to create domain secret store") | |||
| 307 | + | } | |||
| 308 | + | secretSvc := secretsSvc.NewService(domainSecretStore, log) | |||
| 309 | + | ||||
| 310 | + | // Create health service for route health checking | |||
| 311 | + | prober := httpprober.New() | |||
| 312 | + | healthSvc := health.NewService(svc.configSvc, svc.containerSvc, prober, log) | |||
| 313 | + | ||||
| 314 | + | // Create admin handler for admin API | |||
| 315 | + | svc.adminHandler = admin.NewHandler(svc.configSvc, svc.authSvc, svc.containerSvc, healthSvc, secretSvc, svc.eventBus, log) | |||
| 316 | + | ||||
| 278 | return svc, nil | 317 | return svc, nil | ||
| 279 | } | 318 | } | ||
| 280 | 319 | ||||
| @@ -636,6 +675,7 @@ func createContainerService(v *viper.Viper, cfg Config, svc *services) *containe | |||||
| 636 | RegistryPassword: cfg.RegistryAuth.Password, | 675 | RegistryPassword: cfg.RegistryAuth.Password, | ||
| 637 | InternalRegistryUsername: svc.internalRegUser, | 676 | InternalRegistryUsername: svc.internalRegUser, | ||
| 638 | InternalRegistryPassword: svc.internalRegPass, | 677 | InternalRegistryPassword: svc.internalRegPass, | ||
| 678 | + | PullPolicy: v.GetString("deploy.pull_policy"), | |||
| 639 | VolumeAutoCreate: v.GetBool("volumes.auto_create"), | 679 | VolumeAutoCreate: v.GetBool("volumes.auto_create"), | ||
| 640 | VolumePrefix: v.GetString("volumes.prefix"), | 680 | VolumePrefix: v.GetString("volumes.prefix"), | ||
| 641 | VolumePreserve: v.GetBool("volumes.preserve"), | 681 | VolumePreserve: v.GetBool("volumes.preserve"), | ||
| @@ -649,12 +689,19 @@ func createContainerService(v *viper.Viper, cfg Config, svc *services) *containe | |||||
| 649 | } | 689 | } | ||
| 650 | 690 | ||||
| 651 | // registerEventHandlers registers all event handlers. | 691 | // registerEventHandlers registers all event handlers. | ||
| 652 | - | func registerEventHandlers(ctx context.Context, svc *services) error { | 692 | + | func registerEventHandlers(ctx context.Context, svc *services, cfg Config) error { |
| 653 | imagePushedHandler := container.NewImagePushedHandler(ctx, svc.containerSvc, svc.configSvc) | 693 | imagePushedHandler := container.NewImagePushedHandler(ctx, svc.containerSvc, svc.configSvc) | ||
| 654 | if err := svc.eventBus.Subscribe(imagePushedHandler); err != nil { | 694 | if err := svc.eventBus.Subscribe(imagePushedHandler); err != nil { | ||
| 655 | return fmt.Errorf("failed to subscribe image pushed handler: %w", err) | 695 | return fmt.Errorf("failed to subscribe image pushed handler: %w", err) | ||
| 656 | } | 696 | } | ||
| 657 | 697 | ||||
| 698 | + | // Auto-route handler for creating routes from image labels | |||
| 699 | + | autoRouteHandler := container.NewAutoRouteHandler(ctx, svc.configSvc, svc.containerSvc, svc.blobStorage, cfg.Server.RegistryDomain). | |||
| 700 | + | WithEnvExtractor(svc.runtime, svc.envDir) | |||
| 701 | + | if err := svc.eventBus.Subscribe(autoRouteHandler); err != nil { | |||
| 702 | + | return fmt.Errorf("failed to subscribe auto-route handler: %w", err) | |||
| 703 | + | } | |||
| 704 | + | ||||
| 658 | configReloadHandler := container.NewConfigReloadHandler(ctx, svc.containerSvc, svc.configSvc) | 705 | configReloadHandler := container.NewConfigReloadHandler(ctx, svc.containerSvc, svc.configSvc) | ||
| 659 | if err := svc.eventBus.Subscribe(configReloadHandler); err != nil { | 706 | if err := svc.eventBus.Subscribe(configReloadHandler); err != nil { | ||
| 660 | return fmt.Errorf("failed to subscribe config reload handler: %w", err) | 707 | return fmt.Errorf("failed to subscribe config reload handler: %w", err) | ||
| @@ -680,6 +727,10 @@ func registerEventHandlers(ctx context.Context, svc *services) error { | |||||
| 680 | } | 727 | } | ||
| 681 | 728 | ||||
| 682 | // setupConfigHotReload sets up Viper config hot reload. | 729 | // setupConfigHotReload sets up Viper config hot reload. | ||
| 730 | + | // NOTE: This does NOT reload routes into memory. Routes are managed via API | |||
| 731 | + | // (AddRoute/UpdateRoute/RemoveRoute) and memory is the source of truth. | |||
| 732 | + | // The file watcher only updates proxy config and refreshes targets. | |||
| 733 | + | // Manual config file edits to routes require a server restart. | |||
| 683 | func setupConfigHotReload(ctx context.Context, v *viper.Viper, svc *services, log zerowrap.Logger) { | 734 | func setupConfigHotReload(ctx context.Context, v *viper.Viper, svc *services, log zerowrap.Logger) { | ||
| 684 | v.OnConfigChange(func(e fsnotify.Event) { | 735 | v.OnConfigChange(func(e fsnotify.Event) { | ||
| 685 | log.Info().Str("file", e.Name).Msg("config file changed") | 736 | log.Info().Str("file", e.Name).Msg("config file changed") | ||
| @@ -689,11 +740,7 @@ func setupConfigHotReload(ctx context.Context, v *viper.Viper, svc *services, lo | |||||
| 689 | return | 740 | return | ||
| 690 | } | 741 | } | ||
| 691 | 742 | ||||
| 692 | - | if err := svc.configSvc.Load(ctx); err != nil { | 743 | + | // Update proxy config from viper (reads directly from viper, not memory) |
| 693 | - | log.Error().Err(err).Msg("failed to reload configuration") | |||
| 694 | - | return | |||
| 695 | - | } | |||
| 696 | - | ||||
| 697 | svc.proxySvc.UpdateConfig(proxy.Config{ | 744 | svc.proxySvc.UpdateConfig(proxy.Config{ | ||
| 698 | RegistryDomain: v.GetString("server.registry_domain"), | 745 | RegistryDomain: v.GetString("server.registry_domain"), | ||
| 699 | RegistryPort: v.GetInt("server.registry_port"), | 746 | RegistryPort: v.GetInt("server.registry_port"), | ||
| @@ -704,10 +751,7 @@ func setupConfigHotReload(ctx context.Context, v *viper.Viper, svc *services, lo | |||||
| 704 | log.Warn().Err(err).Msg("failed to refresh proxy targets") | 751 | log.Warn().Err(err).Msg("failed to refresh proxy targets") | ||
| 705 | } | 752 | } | ||
| 706 | 753 | ||||
| 707 | - | // Publish config reload event to trigger container updates | 754 | + | log.Debug().Msg("config hot reload complete (routes unchanged, memory is source of truth)") |
| 708 | - | if err := svc.eventBus.Publish(domain.EventConfigReload, nil); err != nil { | |||
| 709 | - | log.Error().Err(err).Msg("failed to publish config reload event") | |||
| 710 | - | } | |||
| 711 | }) | 755 | }) | ||
| 712 | v.WatchConfig() | 756 | v.WatchConfig() | ||
| 713 | } | 757 | } | ||
| @@ -728,7 +772,7 @@ func syncAndAutoStart(ctx context.Context, svc *services, log zerowrap.Logger) { | |||||
| 728 | 772 | ||||
| 729 | // createHTTPHandlers creates HTTP handlers with middleware. | 773 | // createHTTPHandlers creates HTTP handlers with middleware. | ||
| 730 | func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Handler, http.Handler) { | 774 | func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Handler, http.Handler) { | ||
| 731 | - | registryHandler := registry.NewHandler(svc.registrySvc, svc.blobStorage, svc.eventBus, log) | 775 | + | registryHandler := registry.NewHandler(svc.registrySvc, log) |
| 732 | 776 | ||||
| 733 | registryMiddlewares := []func(http.Handler) http.Handler{ | 777 | registryMiddlewares := []func(http.Handler) http.Handler{ | ||
| 734 | middleware.PanicRecovery(log), | 778 | middleware.PanicRecovery(log), | ||
| @@ -759,6 +803,22 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||||
| 759 | } | 803 | } | ||
| 760 | registryMux.Handle("/v2/", registryWithMiddleware) | 804 | registryMux.Handle("/v2/", registryWithMiddleware) | ||
| 761 | 805 | ||||
| 806 | + | // Add admin API handler with auth middleware | |||
| 807 | + | if svc.adminHandler != nil { | |||
| 808 | + | adminMiddlewares := []func(http.Handler) http.Handler{ | |||
| 809 | + | middleware.PanicRecovery(log), | |||
| 810 | + | middleware.RequestLogger(log), | |||
| 811 | + | } | |||
| 812 | + | // Add admin auth middleware if auth is enabled | |||
| 813 | + | if svc.authSvc != nil { | |||
| 814 | + | // Rate limiter for admin API: 10 req/s with burst of 20 | |||
| 815 | + | adminLimiter := rate.NewLimiter(rate.Limit(10), 20) | |||
| 816 | + | adminMiddlewares = append(adminMiddlewares, admin.AuthMiddleware(svc.authSvc, adminLimiter, log)) | |||
| 817 | + | } | |||
| 818 | + | adminWithMiddleware := middleware.Chain(adminMiddlewares...)(svc.adminHandler) | |||
| 819 | + | registryMux.Handle("/admin/", adminWithMiddleware) | |||
| 820 | + | } | |||
| 821 | + | ||||
| 762 | proxyMiddlewares := []func(http.Handler) http.Handler{ | 822 | proxyMiddlewares := []func(http.Handler) http.Handler{ | ||
| 763 | middleware.PanicRecovery(log), | 823 | middleware.PanicRecovery(log), | ||
| 764 | middleware.RequestLogger(log), | 824 | middleware.RequestLogger(log), | ||
| @@ -1063,6 +1123,14 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||||
| 1063 | v.SetDefault("registry_auth.enabled", false) | 1123 | v.SetDefault("registry_auth.enabled", false) | ||
| 1064 | v.SetDefault("registry_auth.type", "password") | 1124 | v.SetDefault("registry_auth.type", "password") | ||
| 1065 | v.SetDefault("registry_auth.token_expiry", "720h") | 1125 | v.SetDefault("registry_auth.token_expiry", "720h") | ||
| 1126 | + | v.SetDefault("auto_route.enabled", false) | |||
| 1127 | + | v.SetDefault("network_isolation.enabled", false) | |||
| 1128 | + | v.SetDefault("network_isolation.network_prefix", "gordon") | |||
| 1129 | + | v.SetDefault("network_isolation.dns_suffix", ".internal") | |||
| 1130 | + | v.SetDefault("volumes.auto_create", true) | |||
| 1131 | + | v.SetDefault("volumes.prefix", "gordon") | |||
| 1132 | + | v.SetDefault("volumes.preserve", true) | |||
| 1133 | + | v.SetDefault("deploy.pull_policy", container.PullPolicyIfTagChanged) | |||
| 1066 | 1134 | ||||
| 1067 | ConfigureViper(v, configPath) | 1135 | ConfigureViper(v, configPath) | ||
| 1068 | 1136 | ||||
M internal/boundaries/in/config.go +6 -0
| @@ -14,6 +14,9 @@ type ConfigService interface { | |||||
| 14 | // GetRoutes returns all configured routes. | 14 | // GetRoutes returns all configured routes. | ||
| 15 | GetRoutes(ctx context.Context) []domain.Route | 15 | GetRoutes(ctx context.Context) []domain.Route | ||
| 16 | 16 | ||||
| 17 | + | // GetRoute returns a single route by domain. | |||
| 18 | + | GetRoute(ctx context.Context, domain string) (*domain.Route, error) | |||
| 19 | + | ||||
| 17 | // AddRoute adds a new route to the configuration. | 20 | // AddRoute adds a new route to the configuration. | ||
| 18 | AddRoute(ctx context.Context, route domain.Route) error | 21 | AddRoute(ctx context.Context, route domain.Route) error | ||
| 19 | 22 | ||||
| @@ -23,6 +26,9 @@ type ConfigService interface { | |||||
| 23 | // RemoveRoute removes a route from the configuration. | 26 | // RemoveRoute removes a route from the configuration. | ||
| 24 | RemoveRoute(ctx context.Context, domain string) error | 27 | RemoveRoute(ctx context.Context, domain string) error | ||
| 25 | 28 | ||||
| 29 | + | // Save persists the current configuration to disk. | |||
| 30 | + | Save(ctx context.Context) error | |||
| 31 | + | ||||
| 26 | // Watch starts watching for configuration changes. | 32 | // Watch starts watching for configuration changes. | ||
| 27 | // The onChange callback is called when configuration changes are detected. | 33 | // The onChange callback is called when configuration changes are detected. | ||
| 28 | Watch(ctx context.Context, onChange func()) error | 34 | Watch(ctx context.Context, onChange func()) error | ||
A internal/boundaries/in/health.go +29 -0
| @@ -0,0 +1,29 @@ | |||||
| 1 | + | // Package in defines input ports (interfaces) for use cases. | |||
| 2 | + | // These interfaces define the contract between driving adapters (HTTP, CLI) | |||
| 3 | + | // and the business logic (use cases). | |||
| 4 | + | package in | |||
| 5 | + | ||||
| 6 | + | import ( | |||
| 7 | + | "context" | |||
| 8 | + | ||||
| 9 | + | "gordon/internal/domain" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // HealthService defines the contract for route health checking operations. | |||
| 13 | + | type HealthService interface { | |||
| 14 | + | // CheckRoute performs a health check on a single route. | |||
| 15 | + | // It checks both container status and HTTP reachability. | |||
| 16 | + | CheckRoute(ctx context.Context, route domain.Route) *domain.RouteHealth | |||
| 17 | + | ||||
| 18 | + | // CheckAllRoutes performs health checks on all configured routes. | |||
| 19 | + | // Returns a map of domain to health status. | |||
| 20 | + | CheckAllRoutes(ctx context.Context) map[string]*domain.RouteHealth | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | // HTTPProber defines the contract for HTTP health probing. | |||
| 24 | + | // This allows for easy mocking in tests. | |||
| 25 | + | type HTTPProber interface { | |||
| 26 | + | // Probe sends an HTTP request to the URL and returns status code and response time. | |||
| 27 | + | // Returns (statusCode, responseTimeMs, error). | |||
| 28 | + | Probe(ctx context.Context, url string) (int, int64, error) | |||
| 29 | + | } | |||
M internal/boundaries/in/mocks/mock_config_service.go +119 -0
| @@ -317,6 +317,74 @@ func (_c *MockConfigService_GetRegistryPort_Call) RunAndReturn(run func() int) * | |||||
| 317 | return _c | 317 | return _c | ||
| 318 | } | 318 | } | ||
| 319 | 319 | ||||
| 320 | + | // GetRoute provides a mock function for the type MockConfigService | |||
| 321 | + | func (_mock *MockConfigService) GetRoute(ctx context.Context, domain1 string) (*domain.Route, error) { | |||
| 322 | + | ret := _mock.Called(ctx, domain1) | |||
| 323 | + | ||||
| 324 | + | if len(ret) == 0 { | |||
| 325 | + | panic("no return value specified for GetRoute") | |||
| 326 | + | } | |||
| 327 | + | ||||
| 328 | + | var r0 *domain.Route | |||
| 329 | + | var r1 error | |||
| 330 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (*domain.Route, error)); ok { | |||
| 331 | + | return returnFunc(ctx, domain1) | |||
| 332 | + | } | |||
| 333 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) *domain.Route); ok { | |||
| 334 | + | r0 = returnFunc(ctx, domain1) | |||
| 335 | + | } else { | |||
| 336 | + | if ret.Get(0) != nil { | |||
| 337 | + | r0 = ret.Get(0).(*domain.Route) | |||
| 338 | + | } | |||
| 339 | + | } | |||
| 340 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | |||
| 341 | + | r1 = returnFunc(ctx, domain1) | |||
| 342 | + | } else { | |||
| 343 | + | r1 = ret.Error(1) | |||
| 344 | + | } | |||
| 345 | + | return r0, r1 | |||
| 346 | + | } | |||
| 347 | + | ||||
| 348 | + | // MockConfigService_GetRoute_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRoute' | |||
| 349 | + | type MockConfigService_GetRoute_Call struct { | |||
| 350 | + | *mock.Call | |||
| 351 | + | } | |||
| 352 | + | ||||
| 353 | + | // GetRoute is a helper method to define mock.On call | |||
| 354 | + | // - ctx context.Context | |||
| 355 | + | // - domain1 string | |||
| 356 | + | func (_e *MockConfigService_Expecter) GetRoute(ctx interface{}, domain1 interface{}) *MockConfigService_GetRoute_Call { | |||
| 357 | + | return &MockConfigService_GetRoute_Call{Call: _e.mock.On("GetRoute", ctx, domain1)} | |||
| 358 | + | } | |||
| 359 | + | ||||
| 360 | + | func (_c *MockConfigService_GetRoute_Call) Run(run func(ctx context.Context, domain1 string)) *MockConfigService_GetRoute_Call { | |||
| 361 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 362 | + | var arg0 context.Context | |||
| 363 | + | if args[0] != nil { | |||
| 364 | + | arg0 = args[0].(context.Context) | |||
| 365 | + | } | |||
| 366 | + | var arg1 string | |||
| 367 | + | if args[1] != nil { | |||
| 368 | + | arg1 = args[1].(string) | |||
| 369 | + | } | |||
| 370 | + | run( | |||
| 371 | + | arg0, | |||
| 372 | + | arg1, | |||
| 373 | + | ) | |||
| 374 | + | }) | |||
| 375 | + | return _c | |||
| 376 | + | } | |||
| 377 | + | ||||
| 378 | + | func (_c *MockConfigService_GetRoute_Call) Return(route *domain.Route, err error) *MockConfigService_GetRoute_Call { | |||
| 379 | + | _c.Call.Return(route, err) | |||
| 380 | + | return _c | |||
| 381 | + | } | |||
| 382 | + | ||||
| 383 | + | func (_c *MockConfigService_GetRoute_Call) RunAndReturn(run func(ctx context.Context, domain1 string) (*domain.Route, error)) *MockConfigService_GetRoute_Call { | |||
| 384 | + | _c.Call.Return(run) | |||
| 385 | + | return _c | |||
| 386 | + | } | |||
| 387 | + | ||||
| 320 | // GetRoutes provides a mock function for the type MockConfigService | 388 | // GetRoutes provides a mock function for the type MockConfigService | ||
| 321 | func (_mock *MockConfigService) GetRoutes(ctx context.Context) []domain.Route { | 389 | func (_mock *MockConfigService) GetRoutes(ctx context.Context) []domain.Route { | ||
| 322 | ret := _mock.Called(ctx) | 390 | ret := _mock.Called(ctx) | ||
| @@ -610,6 +678,57 @@ func (_c *MockConfigService_RemoveRoute_Call) RunAndReturn(run func(ctx context. | |||||
| 610 | return _c | 678 | return _c | ||
| 611 | } | 679 | } | ||
| 612 | 680 | ||||
| 681 | + | // Save provides a mock function for the type MockConfigService | |||
| 682 | + | func (_mock *MockConfigService) Save(ctx context.Context) error { | |||
| 683 | + | ret := _mock.Called(ctx) | |||
| 684 | + | ||||
| 685 | + | if len(ret) == 0 { | |||
| 686 | + | panic("no return value specified for Save") | |||
| 687 | + | } | |||
| 688 | + | ||||
| 689 | + | var r0 error | |||
| 690 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok { | |||
| 691 | + | r0 = returnFunc(ctx) | |||
| 692 | + | } else { | |||
| 693 | + | r0 = ret.Error(0) | |||
| 694 | + | } | |||
| 695 | + | return r0 | |||
| 696 | + | } | |||
| 697 | + | ||||
| 698 | + | // MockConfigService_Save_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Save' | |||
| 699 | + | type MockConfigService_Save_Call struct { | |||
| 700 | + | *mock.Call | |||
| 701 | + | } | |||
| 702 | + | ||||
| 703 | + | // Save is a helper method to define mock.On call | |||
| 704 | + | // - ctx context.Context | |||
| 705 | + | func (_e *MockConfigService_Expecter) Save(ctx interface{}) *MockConfigService_Save_Call { | |||
| 706 | + | return &MockConfigService_Save_Call{Call: _e.mock.On("Save", ctx)} | |||
| 707 | + | } | |||
| 708 | + | ||||
| 709 | + | func (_c *MockConfigService_Save_Call) Run(run func(ctx context.Context)) *MockConfigService_Save_Call { | |||
| 710 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 711 | + | var arg0 context.Context | |||
| 712 | + | if args[0] != nil { | |||
| 713 | + | arg0 = args[0].(context.Context) | |||
| 714 | + | } | |||
| 715 | + | run( | |||
| 716 | + | arg0, | |||
| 717 | + | ) | |||
| 718 | + | }) | |||
| 719 | + | return _c | |||
| 720 | + | } | |||
| 721 | + | ||||
| 722 | + | func (_c *MockConfigService_Save_Call) Return(err error) *MockConfigService_Save_Call { | |||
| 723 | + | _c.Call.Return(err) | |||
| 724 | + | return _c | |||
| 725 | + | } | |||
| 726 | + | ||||
| 727 | + | func (_c *MockConfigService_Save_Call) RunAndReturn(run func(ctx context.Context) error) *MockConfigService_Save_Call { | |||
| 728 | + | _c.Call.Return(run) | |||
| 729 | + | return _c | |||
| 730 | + | } | |||
| 731 | + | ||||
| 613 | // UpdateRoute provides a mock function for the type MockConfigService | 732 | // UpdateRoute provides a mock function for the type MockConfigService | ||
| 614 | func (_mock *MockConfigService) UpdateRoute(ctx context.Context, route domain.Route) error { | 733 | func (_mock *MockConfigService) UpdateRoute(ctx context.Context, route domain.Route) error { | ||
| 615 | ret := _mock.Called(ctx, route) | 734 | ret := _mock.Called(ctx, route) | ||
A internal/boundaries/in/mocks/mock_health_service.go +151 -0
| @@ -0,0 +1,151 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package mocks | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "context" | |||
| 9 | + | "gordon/internal/domain" | |||
| 10 | + | ||||
| 11 | + | mock "github.com/stretchr/testify/mock" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // NewMockHealthService creates a new instance of MockHealthService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 15 | + | // The first argument is typically a *testing.T value. | |||
| 16 | + | func NewMockHealthService(t interface { | |||
| 17 | + | mock.TestingT | |||
| 18 | + | Cleanup(func()) | |||
| 19 | + | }) *MockHealthService { | |||
| 20 | + | mock := &MockHealthService{} | |||
| 21 | + | mock.Mock.Test(t) | |||
| 22 | + | ||||
| 23 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |||
| 24 | + | ||||
| 25 | + | return mock | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | // MockHealthService is an autogenerated mock type for the HealthService type | |||
| 29 | + | type MockHealthService struct { | |||
| 30 | + | mock.Mock | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | type MockHealthService_Expecter struct { | |||
| 34 | + | mock *mock.Mock | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func (_m *MockHealthService) EXPECT() *MockHealthService_Expecter { | |||
| 38 | + | return &MockHealthService_Expecter{mock: &_m.Mock} | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // CheckAllRoutes provides a mock function for the type MockHealthService | |||
| 42 | + | func (_mock *MockHealthService) CheckAllRoutes(ctx context.Context) map[string]*domain.RouteHealth { | |||
| 43 | + | ret := _mock.Called(ctx) | |||
| 44 | + | ||||
| 45 | + | if len(ret) == 0 { | |||
| 46 | + | panic("no return value specified for CheckAllRoutes") | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | var r0 map[string]*domain.RouteHealth | |||
| 50 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) map[string]*domain.RouteHealth); ok { | |||
| 51 | + | r0 = returnFunc(ctx) | |||
| 52 | + | } else { | |||
| 53 | + | if ret.Get(0) != nil { | |||
| 54 | + | r0 = ret.Get(0).(map[string]*domain.RouteHealth) | |||
| 55 | + | } | |||
| 56 | + | } | |||
| 57 | + | return r0 | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | // MockHealthService_CheckAllRoutes_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CheckAllRoutes' | |||
| 61 | + | type MockHealthService_CheckAllRoutes_Call struct { | |||
| 62 | + | *mock.Call | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | // CheckAllRoutes is a helper method to define mock.On call | |||
| 66 | + | // - ctx context.Context | |||
| 67 | + | func (_e *MockHealthService_Expecter) CheckAllRoutes(ctx interface{}) *MockHealthService_CheckAllRoutes_Call { | |||
| 68 | + | return &MockHealthService_CheckAllRoutes_Call{Call: _e.mock.On("CheckAllRoutes", ctx)} | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | func (_c *MockHealthService_CheckAllRoutes_Call) Run(run func(ctx context.Context)) *MockHealthService_CheckAllRoutes_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 | + | run( | |||
| 78 | + | arg0, | |||
| 79 | + | ) | |||
| 80 | + | }) | |||
| 81 | + | return _c | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | func (_c *MockHealthService_CheckAllRoutes_Call) Return(stringToRouteHealth map[string]*domain.RouteHealth) *MockHealthService_CheckAllRoutes_Call { | |||
| 85 | + | _c.Call.Return(stringToRouteHealth) | |||
| 86 | + | return _c | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func (_c *MockHealthService_CheckAllRoutes_Call) RunAndReturn(run func(ctx context.Context) map[string]*domain.RouteHealth) *MockHealthService_CheckAllRoutes_Call { | |||
| 90 | + | _c.Call.Return(run) | |||
| 91 | + | return _c | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | // CheckRoute provides a mock function for the type MockHealthService | |||
| 95 | + | func (_mock *MockHealthService) CheckRoute(ctx context.Context, route domain.Route) *domain.RouteHealth { | |||
| 96 | + | ret := _mock.Called(ctx, route) | |||
| 97 | + | ||||
| 98 | + | if len(ret) == 0 { | |||
| 99 | + | panic("no return value specified for CheckRoute") | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | var r0 *domain.RouteHealth | |||
| 103 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Route) *domain.RouteHealth); ok { | |||
| 104 | + | r0 = returnFunc(ctx, route) | |||
| 105 | + | } else { | |||
| 106 | + | if ret.Get(0) != nil { | |||
| 107 | + | r0 = ret.Get(0).(*domain.RouteHealth) | |||
| 108 | + | } | |||
| 109 | + | } | |||
| 110 | + | return r0 | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | // MockHealthService_CheckRoute_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CheckRoute' | |||
| 114 | + | type MockHealthService_CheckRoute_Call struct { | |||
| 115 | + | *mock.Call | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | // CheckRoute is a helper method to define mock.On call | |||
| 119 | + | // - ctx context.Context | |||
| 120 | + | // - route domain.Route | |||
| 121 | + | func (_e *MockHealthService_Expecter) CheckRoute(ctx interface{}, route interface{}) *MockHealthService_CheckRoute_Call { | |||
| 122 | + | return &MockHealthService_CheckRoute_Call{Call: _e.mock.On("CheckRoute", ctx, route)} | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | func (_c *MockHealthService_CheckRoute_Call) Run(run func(ctx context.Context, route domain.Route)) *MockHealthService_CheckRoute_Call { | |||
| 126 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 127 | + | var arg0 context.Context | |||
| 128 | + | if args[0] != nil { | |||
| 129 | + | arg0 = args[0].(context.Context) | |||
| 130 | + | } | |||
| 131 | + | var arg1 domain.Route | |||
| 132 | + | if args[1] != nil { | |||
| 133 | + | arg1 = args[1].(domain.Route) | |||
| 134 | + | } | |||
| 135 | + | run( | |||
| 136 | + | arg0, | |||
| 137 | + | arg1, | |||
| 138 | + | ) | |||
| 139 | + | }) | |||
| 140 | + | return _c | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | func (_c *MockHealthService_CheckRoute_Call) Return(routeHealth *domain.RouteHealth) *MockHealthService_CheckRoute_Call { | |||
| 144 | + | _c.Call.Return(routeHealth) | |||
| 145 | + | return _c | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | func (_c *MockHealthService_CheckRoute_Call) RunAndReturn(run func(ctx context.Context, route domain.Route) *domain.RouteHealth) *MockHealthService_CheckRoute_Call { | |||
| 149 | + | _c.Call.Return(run) | |||
| 150 | + | return _c | |||
| 151 | + | } | |||
A internal/boundaries/in/mocks/mock_http_prober.go +110 -0
| @@ -0,0 +1,110 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package mocks | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "context" | |||
| 9 | + | ||||
| 10 | + | mock "github.com/stretchr/testify/mock" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // NewMockHTTPProber creates a new instance of MockHTTPProber. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 14 | + | // The first argument is typically a *testing.T value. | |||
| 15 | + | func NewMockHTTPProber(t interface { | |||
| 16 | + | mock.TestingT | |||
| 17 | + | Cleanup(func()) | |||
| 18 | + | }) *MockHTTPProber { | |||
| 19 | + | mock := &MockHTTPProber{} | |||
| 20 | + | mock.Mock.Test(t) | |||
| 21 | + | ||||
| 22 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |||
| 23 | + | ||||
| 24 | + | return mock | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | // MockHTTPProber is an autogenerated mock type for the HTTPProber type | |||
| 28 | + | type MockHTTPProber struct { | |||
| 29 | + | mock.Mock | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | type MockHTTPProber_Expecter struct { | |||
| 33 | + | mock *mock.Mock | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func (_m *MockHTTPProber) EXPECT() *MockHTTPProber_Expecter { | |||
| 37 | + | return &MockHTTPProber_Expecter{mock: &_m.Mock} | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | // Probe provides a mock function for the type MockHTTPProber | |||
| 41 | + | func (_mock *MockHTTPProber) Probe(ctx context.Context, url string) (int, int64, error) { | |||
| 42 | + | ret := _mock.Called(ctx, url) | |||
| 43 | + | ||||
| 44 | + | if len(ret) == 0 { | |||
| 45 | + | panic("no return value specified for Probe") | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | var r0 int | |||
| 49 | + | var r1 int64 | |||
| 50 | + | var r2 error | |||
| 51 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (int, int64, error)); ok { | |||
| 52 | + | return returnFunc(ctx, url) | |||
| 53 | + | } | |||
| 54 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) int); ok { | |||
| 55 | + | r0 = returnFunc(ctx, url) | |||
| 56 | + | } else { | |||
| 57 | + | r0 = ret.Get(0).(int) | |||
| 58 | + | } | |||
| 59 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string) int64); ok { | |||
| 60 | + | r1 = returnFunc(ctx, url) | |||
| 61 | + | } else { | |||
| 62 | + | r1 = ret.Get(1).(int64) | |||
| 63 | + | } | |||
| 64 | + | if returnFunc, ok := ret.Get(2).(func(context.Context, string) error); ok { | |||
| 65 | + | r2 = returnFunc(ctx, url) | |||
| 66 | + | } else { | |||
| 67 | + | r2 = ret.Error(2) | |||
| 68 | + | } | |||
| 69 | + | return r0, r1, r2 | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // MockHTTPProber_Probe_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Probe' | |||
| 73 | + | type MockHTTPProber_Probe_Call struct { | |||
| 74 | + | *mock.Call | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | // Probe is a helper method to define mock.On call | |||
| 78 | + | // - ctx context.Context | |||
| 79 | + | // - url string | |||
| 80 | + | func (_e *MockHTTPProber_Expecter) Probe(ctx interface{}, url interface{}) *MockHTTPProber_Probe_Call { | |||
| 81 | + | return &MockHTTPProber_Probe_Call{Call: _e.mock.On("Probe", ctx, url)} | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | func (_c *MockHTTPProber_Probe_Call) Run(run func(ctx context.Context, url string)) *MockHTTPProber_Probe_Call { | |||
| 85 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 86 | + | var arg0 context.Context | |||
| 87 | + | if args[0] != nil { | |||
| 88 | + | arg0 = args[0].(context.Context) | |||
| 89 | + | } | |||
| 90 | + | var arg1 string | |||
| 91 | + | if args[1] != nil { | |||
| 92 | + | arg1 = args[1].(string) | |||
| 93 | + | } | |||
| 94 | + | run( | |||
| 95 | + | arg0, | |||
| 96 | + | arg1, | |||
| 97 | + | ) | |||
| 98 | + | }) | |||
| 99 | + | return _c | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | func (_c *MockHTTPProber_Probe_Call) Return(n int, n1 int64, err error) *MockHTTPProber_Probe_Call { | |||
| 103 | + | _c.Call.Return(n, n1, err) | |||
| 104 | + | return _c | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func (_c *MockHTTPProber_Probe_Call) RunAndReturn(run func(ctx context.Context, url string) (int, int64, error)) *MockHTTPProber_Probe_Call { | |||
| 108 | + | _c.Call.Return(run) | |||
| 109 | + | return _c | |||
| 110 | + | } | |||
M internal/boundaries/in/mocks/mock_registry_service.go +161 -8
| @@ -39,6 +39,84 @@ func (_m *MockRegistryService) EXPECT() *MockRegistryService_Expecter { | |||||
| 39 | return &MockRegistryService_Expecter{mock: &_m.Mock} | 39 | return &MockRegistryService_Expecter{mock: &_m.Mock} | ||
| 40 | } | 40 | } | ||
| 41 | 41 | ||||
| 42 | + | // AppendBlobChunk provides a mock function for the type MockRegistryService | |||
| 43 | + | func (_mock *MockRegistryService) AppendBlobChunk(ctx context.Context, name string, uuid string, chunk []byte) (int64, error) { | |||
| 44 | + | ret := _mock.Called(ctx, name, uuid, chunk) | |||
| 45 | + | ||||
| 46 | + | if len(ret) == 0 { | |||
| 47 | + | panic("no return value specified for AppendBlobChunk") | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | var r0 int64 | |||
| 51 | + | var r1 error | |||
| 52 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, []byte) (int64, error)); ok { | |||
| 53 | + | return returnFunc(ctx, name, uuid, chunk) | |||
| 54 | + | } | |||
| 55 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, []byte) int64); ok { | |||
| 56 | + | r0 = returnFunc(ctx, name, uuid, chunk) | |||
| 57 | + | } else { | |||
| 58 | + | r0 = ret.Get(0).(int64) | |||
| 59 | + | } | |||
| 60 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, []byte) error); ok { | |||
| 61 | + | r1 = returnFunc(ctx, name, uuid, chunk) | |||
| 62 | + | } else { | |||
| 63 | + | r1 = ret.Error(1) | |||
| 64 | + | } | |||
| 65 | + | return r0, r1 | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | // MockRegistryService_AppendBlobChunk_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AppendBlobChunk' | |||
| 69 | + | type MockRegistryService_AppendBlobChunk_Call struct { | |||
| 70 | + | *mock.Call | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | // AppendBlobChunk is a helper method to define mock.On call | |||
| 74 | + | // - ctx context.Context | |||
| 75 | + | // - name string | |||
| 76 | + | // - uuid string | |||
| 77 | + | // - chunk []byte | |||
| 78 | + | func (_e *MockRegistryService_Expecter) AppendBlobChunk(ctx interface{}, name interface{}, uuid interface{}, chunk interface{}) *MockRegistryService_AppendBlobChunk_Call { | |||
| 79 | + | return &MockRegistryService_AppendBlobChunk_Call{Call: _e.mock.On("AppendBlobChunk", ctx, name, uuid, chunk)} | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | func (_c *MockRegistryService_AppendBlobChunk_Call) Run(run func(ctx context.Context, name string, uuid string, chunk []byte)) *MockRegistryService_AppendBlobChunk_Call { | |||
| 83 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 84 | + | var arg0 context.Context | |||
| 85 | + | if args[0] != nil { | |||
| 86 | + | arg0 = args[0].(context.Context) | |||
| 87 | + | } | |||
| 88 | + | var arg1 string | |||
| 89 | + | if args[1] != nil { | |||
| 90 | + | arg1 = args[1].(string) | |||
| 91 | + | } | |||
| 92 | + | var arg2 string | |||
| 93 | + | if args[2] != nil { | |||
| 94 | + | arg2 = args[2].(string) | |||
| 95 | + | } | |||
| 96 | + | var arg3 []byte | |||
| 97 | + | if args[3] != nil { | |||
| 98 | + | arg3 = args[3].([]byte) | |||
| 99 | + | } | |||
| 100 | + | run( | |||
| 101 | + | arg0, | |||
| 102 | + | arg1, | |||
| 103 | + | arg2, | |||
| 104 | + | arg3, | |||
| 105 | + | ) | |||
| 106 | + | }) | |||
| 107 | + | return _c | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | func (_c *MockRegistryService_AppendBlobChunk_Call) Return(n int64, err error) *MockRegistryService_AppendBlobChunk_Call { | |||
| 111 | + | _c.Call.Return(n, err) | |||
| 112 | + | return _c | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | func (_c *MockRegistryService_AppendBlobChunk_Call) RunAndReturn(run func(ctx context.Context, name string, uuid string, chunk []byte) (int64, error)) *MockRegistryService_AppendBlobChunk_Call { | |||
| 116 | + | _c.Call.Return(run) | |||
| 117 | + | return _c | |||
| 118 | + | } | |||
| 119 | + | ||||
| 42 | // BlobExists provides a mock function for the type MockRegistryService | 120 | // BlobExists provides a mock function for the type MockRegistryService | ||
| 43 | func (_mock *MockRegistryService) BlobExists(ctx context.Context, digest string) bool { | 121 | func (_mock *MockRegistryService) BlobExists(ctx context.Context, digest string) bool { | ||
| 44 | ret := _mock.Called(ctx, digest) | 122 | ret := _mock.Called(ctx, digest) | ||
| @@ -347,6 +425,72 @@ func (_c *MockRegistryService_GetBlob_Call) RunAndReturn(run func(ctx context.Co | |||||
| 347 | return _c | 425 | return _c | ||
| 348 | } | 426 | } | ||
| 349 | 427 | ||||
| 428 | + | // GetBlobPath provides a mock function for the type MockRegistryService | |||
| 429 | + | func (_mock *MockRegistryService) GetBlobPath(ctx context.Context, digest string) (string, error) { | |||
| 430 | + | ret := _mock.Called(ctx, digest) | |||
| 431 | + | ||||
| 432 | + | if len(ret) == 0 { | |||
| 433 | + | panic("no return value specified for GetBlobPath") | |||
| 434 | + | } | |||
| 435 | + | ||||
| 436 | + | var r0 string | |||
| 437 | + | var r1 error | |||
| 438 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (string, error)); ok { | |||
| 439 | + | return returnFunc(ctx, digest) | |||
| 440 | + | } | |||
| 441 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) string); ok { | |||
| 442 | + | r0 = returnFunc(ctx, digest) | |||
| 443 | + | } else { | |||
| 444 | + | r0 = ret.Get(0).(string) | |||
| 445 | + | } | |||
| 446 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | |||
| 447 | + | r1 = returnFunc(ctx, digest) | |||
| 448 | + | } else { | |||
| 449 | + | r1 = ret.Error(1) | |||
| 450 | + | } | |||
| 451 | + | return r0, r1 | |||
| 452 | + | } | |||
| 453 | + | ||||
| 454 | + | // MockRegistryService_GetBlobPath_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetBlobPath' | |||
| 455 | + | type MockRegistryService_GetBlobPath_Call struct { | |||
| 456 | + | *mock.Call | |||
| 457 | + | } | |||
| 458 | + | ||||
| 459 | + | // GetBlobPath is a helper method to define mock.On call | |||
| 460 | + | // - ctx context.Context | |||
| 461 | + | // - digest string | |||
| 462 | + | func (_e *MockRegistryService_Expecter) GetBlobPath(ctx interface{}, digest interface{}) *MockRegistryService_GetBlobPath_Call { | |||
| 463 | + | return &MockRegistryService_GetBlobPath_Call{Call: _e.mock.On("GetBlobPath", ctx, digest)} | |||
| 464 | + | } | |||
| 465 | + | ||||
| 466 | + | func (_c *MockRegistryService_GetBlobPath_Call) Run(run func(ctx context.Context, digest string)) *MockRegistryService_GetBlobPath_Call { | |||
| 467 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 468 | + | var arg0 context.Context | |||
| 469 | + | if args[0] != nil { | |||
| 470 | + | arg0 = args[0].(context.Context) | |||
| 471 | + | } | |||
| 472 | + | var arg1 string | |||
| 473 | + | if args[1] != nil { | |||
| 474 | + | arg1 = args[1].(string) | |||
| 475 | + | } | |||
| 476 | + | run( | |||
| 477 | + | arg0, | |||
| 478 | + | arg1, | |||
| 479 | + | ) | |||
| 480 | + | }) | |||
| 481 | + | return _c | |||
| 482 | + | } | |||
| 483 | + | ||||
| 484 | + | func (_c *MockRegistryService_GetBlobPath_Call) Return(s string, err error) *MockRegistryService_GetBlobPath_Call { | |||
| 485 | + | _c.Call.Return(s, err) | |||
| 486 | + | return _c | |||
| 487 | + | } | |||
| 488 | + | ||||
| 489 | + | func (_c *MockRegistryService_GetBlobPath_Call) RunAndReturn(run func(ctx context.Context, digest string) (string, error)) *MockRegistryService_GetBlobPath_Call { | |||
| 490 | + | _c.Call.Return(run) | |||
| 491 | + | return _c | |||
| 492 | + | } | |||
| 493 | + | ||||
| 350 | // GetManifest provides a mock function for the type MockRegistryService | 494 | // GetManifest provides a mock function for the type MockRegistryService | ||
| 351 | func (_mock *MockRegistryService) GetManifest(ctx context.Context, name string, reference string) (*domain.Manifest, error) { | 495 | func (_mock *MockRegistryService) GetManifest(ctx context.Context, name string, reference string) (*domain.Manifest, error) { | ||
| 352 | ret := _mock.Called(ctx, name, reference) | 496 | ret := _mock.Called(ctx, name, reference) | ||
| @@ -621,20 +765,29 @@ func (_c *MockRegistryService_PutBlob_Call) RunAndReturn(run func(ctx context.Co | |||||
| 621 | } | 765 | } | ||
| 622 | 766 | ||||
| 623 | // PutManifest provides a mock function for the type MockRegistryService | 767 | // PutManifest provides a mock function for the type MockRegistryService | ||
| 624 | - | func (_mock *MockRegistryService) PutManifest(ctx context.Context, manifest *domain.Manifest) error { | 768 | + | func (_mock *MockRegistryService) PutManifest(ctx context.Context, manifest *domain.Manifest) (string, error) { |
| 625 | ret := _mock.Called(ctx, manifest) | 769 | ret := _mock.Called(ctx, manifest) | ||
| 626 | 770 | ||||
| 627 | if len(ret) == 0 { | 771 | if len(ret) == 0 { | ||
| 628 | panic("no return value specified for PutManifest") | 772 | panic("no return value specified for PutManifest") | ||
| 629 | } | 773 | } | ||
| 630 | 774 | ||||
| 631 | - | var r0 error | 775 | + | var r0 string |
| 632 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, *domain.Manifest) error); ok { | 776 | + | var r1 error |
| 777 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, *domain.Manifest) (string, error)); ok { | |||
| 778 | + | return returnFunc(ctx, manifest) | |||
| 779 | + | } | |||
| 780 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, *domain.Manifest) string); ok { | |||
| 633 | r0 = returnFunc(ctx, manifest) | 781 | r0 = returnFunc(ctx, manifest) | ||
| 634 | } else { | 782 | } else { | ||
| 635 | - | r0 = ret.Error(0) | 783 | + | r0 = ret.Get(0).(string) |
| 636 | } | 784 | } | ||
| 637 | - | return r0 | 785 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, *domain.Manifest) error); ok { |
| 786 | + | r1 = returnFunc(ctx, manifest) | |||
| 787 | + | } else { | |||
| 788 | + | r1 = ret.Error(1) | |||
| 789 | + | } | |||
| 790 | + | return r0, r1 | |||
| 638 | } | 791 | } | ||
| 639 | 792 | ||||
| 640 | // MockRegistryService_PutManifest_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PutManifest' | 793 | // MockRegistryService_PutManifest_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PutManifest' | ||
| @@ -667,12 +820,12 @@ func (_c *MockRegistryService_PutManifest_Call) Run(run func(ctx context.Context | |||||
| 667 | return _c | 820 | return _c | ||
| 668 | } | 821 | } | ||
| 669 | 822 | ||||
| 670 | - | func (_c *MockRegistryService_PutManifest_Call) Return(err error) *MockRegistryService_PutManifest_Call { | 823 | + | func (_c *MockRegistryService_PutManifest_Call) Return(digest string, err error) *MockRegistryService_PutManifest_Call { |
| 671 | - | _c.Call.Return(err) | 824 | + | _c.Call.Return(digest, err) |
| 672 | return _c | 825 | return _c | ||
| 673 | } | 826 | } | ||
| 674 | 827 | ||||
| 675 | - | func (_c *MockRegistryService_PutManifest_Call) RunAndReturn(run func(ctx context.Context, manifest *domain.Manifest) error) *MockRegistryService_PutManifest_Call { | 828 | + | func (_c *MockRegistryService_PutManifest_Call) RunAndReturn(run func(ctx context.Context, manifest *domain.Manifest) (string, error)) *MockRegistryService_PutManifest_Call { |
| 676 | _c.Call.Return(run) | 829 | _c.Call.Return(run) | ||
| 677 | return _c | 830 | return _c | ||
| 678 | } | 831 | } | ||
A internal/boundaries/in/mocks/mock_secret_service.go +300 -0
| @@ -0,0 +1,300 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package mocks | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "context" | |||
| 9 | + | ||||
| 10 | + | mock "github.com/stretchr/testify/mock" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // NewMockSecretService creates a new instance of MockSecretService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 14 | + | // The first argument is typically a *testing.T value. | |||
| 15 | + | func NewMockSecretService(t interface { | |||
| 16 | + | mock.TestingT | |||
| 17 | + | Cleanup(func()) | |||
| 18 | + | }) *MockSecretService { | |||
| 19 | + | mock := &MockSecretService{} | |||
| 20 | + | mock.Mock.Test(t) | |||
| 21 | + | ||||
| 22 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |||
| 23 | + | ||||
| 24 | + | return mock | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | // MockSecretService is an autogenerated mock type for the SecretService type | |||
| 28 | + | type MockSecretService struct { | |||
| 29 | + | mock.Mock | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | type MockSecretService_Expecter struct { | |||
| 33 | + | mock *mock.Mock | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func (_m *MockSecretService) EXPECT() *MockSecretService_Expecter { | |||
| 37 | + | return &MockSecretService_Expecter{mock: &_m.Mock} | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | // Delete provides a mock function for the type MockSecretService | |||
| 41 | + | func (_mock *MockSecretService) Delete(ctx context.Context, domain string, key string) error { | |||
| 42 | + | ret := _mock.Called(ctx, domain, key) | |||
| 43 | + | ||||
| 44 | + | if len(ret) == 0 { | |||
| 45 | + | panic("no return value specified for Delete") | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | var r0 error | |||
| 49 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok { | |||
| 50 | + | r0 = returnFunc(ctx, domain, key) | |||
| 51 | + | } else { | |||
| 52 | + | r0 = ret.Error(0) | |||
| 53 | + | } | |||
| 54 | + | return r0 | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | // MockSecretService_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete' | |||
| 58 | + | type MockSecretService_Delete_Call struct { | |||
| 59 | + | *mock.Call | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | // Delete is a helper method to define mock.On call | |||
| 63 | + | // - ctx context.Context | |||
| 64 | + | // - domain string | |||
| 65 | + | // - key string | |||
| 66 | + | func (_e *MockSecretService_Expecter) Delete(ctx interface{}, domain interface{}, key interface{}) *MockSecretService_Delete_Call { | |||
| 67 | + | return &MockSecretService_Delete_Call{Call: _e.mock.On("Delete", ctx, domain, key)} | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | func (_c *MockSecretService_Delete_Call) Run(run func(ctx context.Context, domain string, key string)) *MockSecretService_Delete_Call { | |||
| 71 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 72 | + | var arg0 context.Context | |||
| 73 | + | if args[0] != nil { | |||
| 74 | + | arg0 = args[0].(context.Context) | |||
| 75 | + | } | |||
| 76 | + | var arg1 string | |||
| 77 | + | if args[1] != nil { | |||
| 78 | + | arg1 = args[1].(string) | |||
| 79 | + | } | |||
| 80 | + | var arg2 string | |||
| 81 | + | if args[2] != nil { | |||
| 82 | + | arg2 = args[2].(string) | |||
| 83 | + | } | |||
| 84 | + | run( | |||
| 85 | + | arg0, | |||
| 86 | + | arg1, | |||
| 87 | + | arg2, | |||
| 88 | + | ) | |||
| 89 | + | }) | |||
| 90 | + | return _c | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func (_c *MockSecretService_Delete_Call) Return(err error) *MockSecretService_Delete_Call { | |||
| 94 | + | _c.Call.Return(err) | |||
| 95 | + | return _c | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | func (_c *MockSecretService_Delete_Call) RunAndReturn(run func(ctx context.Context, domain string, key string) error) *MockSecretService_Delete_Call { | |||
| 99 | + | _c.Call.Return(run) | |||
| 100 | + | return _c | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | // GetAll provides a mock function for the type MockSecretService | |||
| 104 | + | func (_mock *MockSecretService) GetAll(ctx context.Context, domain string) (map[string]string, error) { | |||
| 105 | + | ret := _mock.Called(ctx, domain) | |||
| 106 | + | ||||
| 107 | + | if len(ret) == 0 { | |||
| 108 | + | panic("no return value specified for GetAll") | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | var r0 map[string]string | |||
| 112 | + | var r1 error | |||
| 113 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (map[string]string, error)); ok { | |||
| 114 | + | return returnFunc(ctx, domain) | |||
| 115 | + | } | |||
| 116 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) map[string]string); ok { | |||
| 117 | + | r0 = returnFunc(ctx, domain) | |||
| 118 | + | } else { | |||
| 119 | + | if ret.Get(0) != nil { | |||
| 120 | + | r0 = ret.Get(0).(map[string]string) | |||
| 121 | + | } | |||
| 122 | + | } | |||
| 123 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | |||
| 124 | + | r1 = returnFunc(ctx, domain) | |||
| 125 | + | } else { | |||
| 126 | + | r1 = ret.Error(1) | |||
| 127 | + | } | |||
| 128 | + | return r0, r1 | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | // MockSecretService_GetAll_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAll' | |||
| 132 | + | type MockSecretService_GetAll_Call struct { | |||
| 133 | + | *mock.Call | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | // GetAll is a helper method to define mock.On call | |||
| 137 | + | // - ctx context.Context | |||
| 138 | + | // - domain string | |||
| 139 | + | func (_e *MockSecretService_Expecter) GetAll(ctx interface{}, domain interface{}) *MockSecretService_GetAll_Call { | |||
| 140 | + | return &MockSecretService_GetAll_Call{Call: _e.mock.On("GetAll", ctx, domain)} | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | func (_c *MockSecretService_GetAll_Call) Run(run func(ctx context.Context, domain string)) *MockSecretService_GetAll_Call { | |||
| 144 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 145 | + | var arg0 context.Context | |||
| 146 | + | if args[0] != nil { | |||
| 147 | + | arg0 = args[0].(context.Context) | |||
| 148 | + | } | |||
| 149 | + | var arg1 string | |||
| 150 | + | if args[1] != nil { | |||
| 151 | + | arg1 = args[1].(string) | |||
| 152 | + | } | |||
| 153 | + | run( | |||
| 154 | + | arg0, | |||
| 155 | + | arg1, | |||
| 156 | + | ) | |||
| 157 | + | }) | |||
| 158 | + | return _c | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | func (_c *MockSecretService_GetAll_Call) Return(stringToString map[string]string, err error) *MockSecretService_GetAll_Call { | |||
| 162 | + | _c.Call.Return(stringToString, err) | |||
| 163 | + | return _c | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | func (_c *MockSecretService_GetAll_Call) RunAndReturn(run func(ctx context.Context, domain string) (map[string]string, error)) *MockSecretService_GetAll_Call { | |||
| 167 | + | _c.Call.Return(run) | |||
| 168 | + | return _c | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | // ListKeys provides a mock function for the type MockSecretService | |||
| 172 | + | func (_mock *MockSecretService) ListKeys(ctx context.Context, domain string) ([]string, error) { | |||
| 173 | + | ret := _mock.Called(ctx, domain) | |||
| 174 | + | ||||
| 175 | + | if len(ret) == 0 { | |||
| 176 | + | panic("no return value specified for ListKeys") | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | var r0 []string | |||
| 180 | + | var r1 error | |||
| 181 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]string, error)); ok { | |||
| 182 | + | return returnFunc(ctx, domain) | |||
| 183 | + | } | |||
| 184 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) []string); ok { | |||
| 185 | + | r0 = returnFunc(ctx, domain) | |||
| 186 | + | } else { | |||
| 187 | + | if ret.Get(0) != nil { | |||
| 188 | + | r0 = ret.Get(0).([]string) | |||
| 189 | + | } | |||
| 190 | + | } | |||
| 191 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | |||
| 192 | + | r1 = returnFunc(ctx, domain) | |||
| 193 | + | } else { | |||
| 194 | + | r1 = ret.Error(1) | |||
| 195 | + | } | |||
| 196 | + | return r0, r1 | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | // MockSecretService_ListKeys_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListKeys' | |||
| 200 | + | type MockSecretService_ListKeys_Call struct { | |||
| 201 | + | *mock.Call | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | // ListKeys is a helper method to define mock.On call | |||
| 205 | + | // - ctx context.Context | |||
| 206 | + | // - domain string | |||
| 207 | + | func (_e *MockSecretService_Expecter) ListKeys(ctx interface{}, domain interface{}) *MockSecretService_ListKeys_Call { | |||
| 208 | + | return &MockSecretService_ListKeys_Call{Call: _e.mock.On("ListKeys", ctx, domain)} | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | func (_c *MockSecretService_ListKeys_Call) Run(run func(ctx context.Context, domain string)) *MockSecretService_ListKeys_Call { | |||
| 212 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 213 | + | var arg0 context.Context | |||
| 214 | + | if args[0] != nil { | |||
| 215 | + | arg0 = args[0].(context.Context) | |||
| 216 | + | } | |||
| 217 | + | var arg1 string | |||
| 218 | + | if args[1] != nil { | |||
| 219 | + | arg1 = args[1].(string) | |||
| 220 | + | } | |||
| 221 | + | run( | |||
| 222 | + | arg0, | |||
| 223 | + | arg1, | |||
| 224 | + | ) | |||
| 225 | + | }) | |||
| 226 | + | return _c | |||
| 227 | + | } | |||
| 228 | + | ||||
| 229 | + | func (_c *MockSecretService_ListKeys_Call) Return(strings []string, err error) *MockSecretService_ListKeys_Call { | |||
| 230 | + | _c.Call.Return(strings, err) | |||
| 231 | + | return _c | |||
| 232 | + | } | |||
| 233 | + | ||||
| 234 | + | func (_c *MockSecretService_ListKeys_Call) RunAndReturn(run func(ctx context.Context, domain string) ([]string, error)) *MockSecretService_ListKeys_Call { | |||
| 235 | + | _c.Call.Return(run) | |||
| 236 | + | return _c | |||
| 237 | + | } | |||
| 238 | + | ||||
| 239 | + | // Set provides a mock function for the type MockSecretService | |||
| 240 | + | func (_mock *MockSecretService) Set(ctx context.Context, domain string, secrets map[string]string) error { | |||
| 241 | + | ret := _mock.Called(ctx, domain, secrets) | |||
| 242 | + | ||||
| 243 | + | if len(ret) == 0 { | |||
| 244 | + | panic("no return value specified for Set") | |||
| 245 | + | } | |||
| 246 | + | ||||
| 247 | + | var r0 error | |||
| 248 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, map[string]string) error); ok { | |||
| 249 | + | r0 = returnFunc(ctx, domain, secrets) | |||
| 250 | + | } else { | |||
| 251 | + | r0 = ret.Error(0) | |||
| 252 | + | } | |||
| 253 | + | return r0 | |||
| 254 | + | } | |||
| 255 | + | ||||
| 256 | + | // MockSecretService_Set_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Set' | |||
| 257 | + | type MockSecretService_Set_Call struct { | |||
| 258 | + | *mock.Call | |||
| 259 | + | } | |||
| 260 | + | ||||
| 261 | + | // Set is a helper method to define mock.On call | |||
| 262 | + | // - ctx context.Context | |||
| 263 | + | // - domain string | |||
| 264 | + | // - secrets map[string]string | |||
| 265 | + | func (_e *MockSecretService_Expecter) Set(ctx interface{}, domain interface{}, secrets interface{}) *MockSecretService_Set_Call { | |||
| 266 | + | return &MockSecretService_Set_Call{Call: _e.mock.On("Set", ctx, domain, secrets)} | |||
| 267 | + | } | |||
| 268 | + | ||||
| 269 | + | func (_c *MockSecretService_Set_Call) Run(run func(ctx context.Context, domain string, secrets map[string]string)) *MockSecretService_Set_Call { | |||
| 270 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 271 | + | var arg0 context.Context | |||
| 272 | + | if args[0] != nil { | |||
| 273 | + | arg0 = args[0].(context.Context) | |||
| 274 | + | } | |||
| 275 | + | var arg1 string | |||
| 276 | + | if args[1] != nil { | |||
| 277 | + | arg1 = args[1].(string) | |||
| 278 | + | } | |||
| 279 | + | var arg2 map[string]string | |||
| 280 | + | if args[2] != nil { | |||
| 281 | + | arg2 = args[2].(map[string]string) | |||
| 282 | + | } | |||
| 283 | + | run( | |||
| 284 | + | arg0, | |||
| 285 | + | arg1, | |||
| 286 | + | arg2, | |||
| 287 | + | ) | |||
| 288 | + | }) | |||
| 289 | + | return _c | |||
| 290 | + | } | |||
| 291 | + | ||||
| 292 | + | func (_c *MockSecretService_Set_Call) Return(err error) *MockSecretService_Set_Call { | |||
| 293 | + | _c.Call.Return(err) | |||
| 294 | + | return _c | |||
| 295 | + | } | |||
| 296 | + | ||||
| 297 | + | func (_c *MockSecretService_Set_Call) RunAndReturn(run func(ctx context.Context, domain string, secrets map[string]string) error) *MockSecretService_Set_Call { | |||
| 298 | + | _c.Call.Return(run) | |||
| 299 | + | return _c | |||
| 300 | + | } | |||
M internal/boundaries/in/registry.go +3 -1
| @@ -11,16 +11,18 @@ import ( | |||||
| 11 | type RegistryService interface { | 11 | type RegistryService interface { | ||
| 12 | // Manifest operations | 12 | // Manifest operations | ||
| 13 | GetManifest(ctx context.Context, name, reference string) (*domain.Manifest, error) | 13 | GetManifest(ctx context.Context, name, reference string) (*domain.Manifest, error) | ||
| 14 | - | PutManifest(ctx context.Context, manifest *domain.Manifest) error | 14 | + | PutManifest(ctx context.Context, manifest *domain.Manifest) (digest string, err error) |
| 15 | DeleteManifest(ctx context.Context, name, reference string) error | 15 | DeleteManifest(ctx context.Context, name, reference string) error | ||
| 16 | 16 | ||||
| 17 | // Blob operations | 17 | // Blob operations | ||
| 18 | GetBlob(ctx context.Context, digest string) (io.ReadCloser, error) | 18 | GetBlob(ctx context.Context, digest string) (io.ReadCloser, error) | ||
| 19 | + | GetBlobPath(ctx context.Context, digest string) (string, error) | |||
| 19 | PutBlob(ctx context.Context, digest string, data io.Reader, size int64) error | 20 | PutBlob(ctx context.Context, digest string, data io.Reader, size int64) error | ||
| 20 | BlobExists(ctx context.Context, digest string) bool | 21 | BlobExists(ctx context.Context, digest string) bool | ||
| 21 | 22 | ||||
| 22 | // Upload operations | 23 | // Upload operations | ||
| 23 | StartUpload(ctx context.Context, name string) (string, error) | 24 | StartUpload(ctx context.Context, name string) (string, error) | ||
| 25 | + | AppendBlobChunk(ctx context.Context, name, uuid string, chunk []byte) (int64, error) | |||
| 24 | FinishUpload(ctx context.Context, uuid, digest string) error | 26 | FinishUpload(ctx context.Context, uuid, digest string) error | ||
| 25 | CancelUpload(ctx context.Context, uuid string) error | 27 | CancelUpload(ctx context.Context, uuid string) error | ||
| 26 | 28 | ||||
A internal/boundaries/in/secrets.go +23 -0
| @@ -0,0 +1,23 @@ | |||||
| 1 | + | package in | |||
| 2 | + | ||||
| 3 | + | import "context" | |||
| 4 | + | ||||
| 5 | + | // SecretService defines the contract for managing domain-scoped secrets. | |||
| 6 | + | // These are environment variables stored per-domain for container injection. | |||
| 7 | + | type SecretService interface { | |||
| 8 | + | // ListKeys returns the list of secret keys for a domain (not values). | |||
| 9 | + | // Returns an error if the domain is invalid. | |||
| 10 | + | ListKeys(ctx context.Context, domain string) ([]string, error) | |||
| 11 | + | ||||
| 12 | + | // GetAll returns all secrets for a domain as a key-value map. | |||
| 13 | + | // Returns an error if the domain is invalid. | |||
| 14 | + | GetAll(ctx context.Context, domain string) (map[string]string, error) | |||
| 15 | + | ||||
| 16 | + | // Set sets or updates multiple secrets for a domain, merging with existing. | |||
| 17 | + | // Returns an error if the domain is invalid. | |||
| 18 | + | Set(ctx context.Context, domain string, secrets map[string]string) error | |||
| 19 | + | ||||
| 20 | + | // Delete removes a specific secret key from a domain. | |||
| 21 | + | // Returns an error if the domain is invalid. | |||
| 22 | + | Delete(ctx context.Context, domain, key string) error | |||
| 23 | + | } | |||
A internal/boundaries/out/domain_secrets.go +17 -0
| @@ -0,0 +1,17 @@ | |||||
| 1 | + | package out | |||
| 2 | + | ||||
| 3 | + | // DomainSecretStore defines the contract for managing domain-scoped secrets. | |||
| 4 | + | // These are environment variables stored per-domain for container injection. | |||
| 5 | + | type DomainSecretStore interface { | |||
| 6 | + | // ListKeys returns the list of secret keys for a domain (not values). | |||
| 7 | + | ListKeys(domain string) ([]string, error) | |||
| 8 | + | ||||
| 9 | + | // GetAll returns all secrets for a domain as a key-value map. | |||
| 10 | + | GetAll(domain string) (map[string]string, error) | |||
| 11 | + | ||||
| 12 | + | // Set sets or updates multiple secrets for a domain, merging with existing. | |||
| 13 | + | Set(domain string, secrets map[string]string) error | |||
| 14 | + | ||||
| 15 | + | // Delete removes a specific secret key from a domain. | |||
| 16 | + | Delete(domain, key string) error | |||
| 17 | + | } | |||
A internal/boundaries/out/mocks/mock_domain_secret_store.go +274 -0
| @@ -0,0 +1,274 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package mocks | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | mock "github.com/stretchr/testify/mock" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // NewMockDomainSecretStore creates a new instance of MockDomainSecretStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 12 | + | // The first argument is typically a *testing.T value. | |||
| 13 | + | func NewMockDomainSecretStore(t interface { | |||
| 14 | + | mock.TestingT | |||
| 15 | + | Cleanup(func()) | |||
| 16 | + | }) *MockDomainSecretStore { | |||
| 17 | + | mock := &MockDomainSecretStore{} | |||
| 18 | + | mock.Mock.Test(t) | |||
| 19 | + | ||||
| 20 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |||
| 21 | + | ||||
| 22 | + | return mock | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // MockDomainSecretStore is an autogenerated mock type for the DomainSecretStore type | |||
| 26 | + | type MockDomainSecretStore struct { | |||
| 27 | + | mock.Mock | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | type MockDomainSecretStore_Expecter struct { | |||
| 31 | + | mock *mock.Mock | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func (_m *MockDomainSecretStore) EXPECT() *MockDomainSecretStore_Expecter { | |||
| 35 | + | return &MockDomainSecretStore_Expecter{mock: &_m.Mock} | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // Delete provides a mock function for the type MockDomainSecretStore | |||
| 39 | + | func (_mock *MockDomainSecretStore) Delete(domain string, key string) error { | |||
| 40 | + | ret := _mock.Called(domain, key) | |||
| 41 | + | ||||
| 42 | + | if len(ret) == 0 { | |||
| 43 | + | panic("no return value specified for Delete") | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | var r0 error | |||
| 47 | + | if returnFunc, ok := ret.Get(0).(func(string, string) error); ok { | |||
| 48 | + | r0 = returnFunc(domain, key) | |||
| 49 | + | } else { | |||
| 50 | + | r0 = ret.Error(0) | |||
| 51 | + | } | |||
| 52 | + | return r0 | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | // MockDomainSecretStore_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete' | |||
| 56 | + | type MockDomainSecretStore_Delete_Call struct { | |||
| 57 | + | *mock.Call | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | // Delete is a helper method to define mock.On call | |||
| 61 | + | // - domain string | |||
| 62 | + | // - key string | |||
| 63 | + | func (_e *MockDomainSecretStore_Expecter) Delete(domain interface{}, key interface{}) *MockDomainSecretStore_Delete_Call { | |||
| 64 | + | return &MockDomainSecretStore_Delete_Call{Call: _e.mock.On("Delete", domain, key)} | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | func (_c *MockDomainSecretStore_Delete_Call) Run(run func(domain string, key string)) *MockDomainSecretStore_Delete_Call { | |||
| 68 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 69 | + | var arg0 string | |||
| 70 | + | if args[0] != nil { | |||
| 71 | + | arg0 = args[0].(string) | |||
| 72 | + | } | |||
| 73 | + | var arg1 string | |||
| 74 | + | if args[1] != nil { | |||
| 75 | + | arg1 = args[1].(string) | |||
| 76 | + | } | |||
| 77 | + | run( | |||
| 78 | + | arg0, | |||
| 79 | + | arg1, | |||
| 80 | + | ) | |||
| 81 | + | }) | |||
| 82 | + | return _c | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func (_c *MockDomainSecretStore_Delete_Call) Return(err error) *MockDomainSecretStore_Delete_Call { | |||
| 86 | + | _c.Call.Return(err) | |||
| 87 | + | return _c | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | func (_c *MockDomainSecretStore_Delete_Call) RunAndReturn(run func(domain string, key string) error) *MockDomainSecretStore_Delete_Call { | |||
| 91 | + | _c.Call.Return(run) | |||
| 92 | + | return _c | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | // GetAll provides a mock function for the type MockDomainSecretStore | |||
| 96 | + | func (_mock *MockDomainSecretStore) GetAll(domain string) (map[string]string, error) { | |||
| 97 | + | ret := _mock.Called(domain) | |||
| 98 | + | ||||
| 99 | + | if len(ret) == 0 { | |||
| 100 | + | panic("no return value specified for GetAll") | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | var r0 map[string]string | |||
| 104 | + | var r1 error | |||
| 105 | + | if returnFunc, ok := ret.Get(0).(func(string) (map[string]string, error)); ok { | |||
| 106 | + | return returnFunc(domain) | |||
| 107 | + | } | |||
| 108 | + | if returnFunc, ok := ret.Get(0).(func(string) map[string]string); ok { | |||
| 109 | + | r0 = returnFunc(domain) | |||
| 110 | + | } else { | |||
| 111 | + | if ret.Get(0) != nil { | |||
| 112 | + | r0 = ret.Get(0).(map[string]string) | |||
| 113 | + | } | |||
| 114 | + | } | |||
| 115 | + | if returnFunc, ok := ret.Get(1).(func(string) error); ok { | |||
| 116 | + | r1 = returnFunc(domain) | |||
| 117 | + | } else { | |||
| 118 | + | r1 = ret.Error(1) | |||
| 119 | + | } | |||
| 120 | + | return r0, r1 | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | // MockDomainSecretStore_GetAll_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAll' | |||
| 124 | + | type MockDomainSecretStore_GetAll_Call struct { | |||
| 125 | + | *mock.Call | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | // GetAll is a helper method to define mock.On call | |||
| 129 | + | // - domain string | |||
| 130 | + | func (_e *MockDomainSecretStore_Expecter) GetAll(domain interface{}) *MockDomainSecretStore_GetAll_Call { | |||
| 131 | + | return &MockDomainSecretStore_GetAll_Call{Call: _e.mock.On("GetAll", domain)} | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | func (_c *MockDomainSecretStore_GetAll_Call) Run(run func(domain string)) *MockDomainSecretStore_GetAll_Call { | |||
| 135 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 136 | + | var arg0 string | |||
| 137 | + | if args[0] != nil { | |||
| 138 | + | arg0 = args[0].(string) | |||
| 139 | + | } | |||
| 140 | + | run( | |||
| 141 | + | arg0, | |||
| 142 | + | ) | |||
| 143 | + | }) | |||
| 144 | + | return _c | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | func (_c *MockDomainSecretStore_GetAll_Call) Return(stringToString map[string]string, err error) *MockDomainSecretStore_GetAll_Call { | |||
| 148 | + | _c.Call.Return(stringToString, err) | |||
| 149 | + | return _c | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | func (_c *MockDomainSecretStore_GetAll_Call) RunAndReturn(run func(domain string) (map[string]string, error)) *MockDomainSecretStore_GetAll_Call { | |||
| 153 | + | _c.Call.Return(run) | |||
| 154 | + | return _c | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | // ListKeys provides a mock function for the type MockDomainSecretStore | |||
| 158 | + | func (_mock *MockDomainSecretStore) ListKeys(domain string) ([]string, error) { | |||
| 159 | + | ret := _mock.Called(domain) | |||
| 160 | + | ||||
| 161 | + | if len(ret) == 0 { | |||
| 162 | + | panic("no return value specified for ListKeys") | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | var r0 []string | |||
| 166 | + | var r1 error | |||
| 167 | + | if returnFunc, ok := ret.Get(0).(func(string) ([]string, error)); ok { | |||
| 168 | + | return returnFunc(domain) | |||
| 169 | + | } | |||
| 170 | + | if returnFunc, ok := ret.Get(0).(func(string) []string); ok { | |||
| 171 | + | r0 = returnFunc(domain) | |||
| 172 | + | } else { | |||
| 173 | + | if ret.Get(0) != nil { | |||
| 174 | + | r0 = ret.Get(0).([]string) | |||
| 175 | + | } | |||
| 176 | + | } | |||
| 177 | + | if returnFunc, ok := ret.Get(1).(func(string) error); ok { | |||
| 178 | + | r1 = returnFunc(domain) | |||
| 179 | + | } else { | |||
| 180 | + | r1 = ret.Error(1) | |||
| 181 | + | } | |||
| 182 | + | return r0, r1 | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | // MockDomainSecretStore_ListKeys_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListKeys' | |||
| 186 | + | type MockDomainSecretStore_ListKeys_Call struct { | |||
| 187 | + | *mock.Call | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | // ListKeys is a helper method to define mock.On call | |||
| 191 | + | // - domain string | |||
| 192 | + | func (_e *MockDomainSecretStore_Expecter) ListKeys(domain interface{}) *MockDomainSecretStore_ListKeys_Call { | |||
| 193 | + | return &MockDomainSecretStore_ListKeys_Call{Call: _e.mock.On("ListKeys", domain)} | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | func (_c *MockDomainSecretStore_ListKeys_Call) Run(run func(domain string)) *MockDomainSecretStore_ListKeys_Call { | |||
| 197 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 198 | + | var arg0 string | |||
| 199 | + | if args[0] != nil { | |||
| 200 | + | arg0 = args[0].(string) | |||
| 201 | + | } | |||
| 202 | + | run( | |||
| 203 | + | arg0, | |||
| 204 | + | ) | |||
| 205 | + | }) | |||
| 206 | + | return _c | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | func (_c *MockDomainSecretStore_ListKeys_Call) Return(strings []string, err error) *MockDomainSecretStore_ListKeys_Call { | |||
| 210 | + | _c.Call.Return(strings, err) | |||
| 211 | + | return _c | |||
| 212 | + | } | |||
| 213 | + | ||||
| 214 | + | func (_c *MockDomainSecretStore_ListKeys_Call) RunAndReturn(run func(domain string) ([]string, error)) *MockDomainSecretStore_ListKeys_Call { | |||
| 215 | + | _c.Call.Return(run) | |||
| 216 | + | return _c | |||
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | // Set provides a mock function for the type MockDomainSecretStore | |||
| 220 | + | func (_mock *MockDomainSecretStore) Set(domain string, secrets map[string]string) error { | |||
| 221 | + | ret := _mock.Called(domain, secrets) | |||
| 222 | + | ||||
| 223 | + | if len(ret) == 0 { | |||
| 224 | + | panic("no return value specified for Set") | |||
| 225 | + | } | |||
| 226 | + | ||||
| 227 | + | var r0 error | |||
| 228 | + | if returnFunc, ok := ret.Get(0).(func(string, map[string]string) error); ok { | |||
| 229 | + | r0 = returnFunc(domain, secrets) | |||
| 230 | + | } else { | |||
| 231 | + | r0 = ret.Error(0) | |||
| 232 | + | } | |||
| 233 | + | return r0 | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | // MockDomainSecretStore_Set_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Set' | |||
| 237 | + | type MockDomainSecretStore_Set_Call struct { | |||
| 238 | + | *mock.Call | |||
| 239 | + | } | |||
| 240 | + | ||||
| 241 | + | // Set is a helper method to define mock.On call | |||
| 242 | + | // - domain string | |||
| 243 | + | // - secrets map[string]string | |||
| 244 | + | func (_e *MockDomainSecretStore_Expecter) Set(domain interface{}, secrets interface{}) *MockDomainSecretStore_Set_Call { | |||
| 245 | + | return &MockDomainSecretStore_Set_Call{Call: _e.mock.On("Set", domain, secrets)} | |||
| 246 | + | } | |||
| 247 | + | ||||
| 248 | + | func (_c *MockDomainSecretStore_Set_Call) Run(run func(domain string, secrets map[string]string)) *MockDomainSecretStore_Set_Call { | |||
| 249 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 250 | + | var arg0 string | |||
| 251 | + | if args[0] != nil { | |||
| 252 | + | arg0 = args[0].(string) | |||
| 253 | + | } | |||
| 254 | + | var arg1 map[string]string | |||
| 255 | + | if args[1] != nil { | |||
| 256 | + | arg1 = args[1].(map[string]string) | |||
| 257 | + | } | |||
| 258 | + | run( | |||
| 259 | + | arg0, | |||
| 260 | + | arg1, | |||
| 261 | + | ) | |||
| 262 | + | }) | |||
| 263 | + | return _c | |||
| 264 | + | } | |||
| 265 | + | ||||
| 266 | + | func (_c *MockDomainSecretStore_Set_Call) Return(err error) *MockDomainSecretStore_Set_Call { | |||
| 267 | + | _c.Call.Return(err) | |||
| 268 | + | return _c | |||
| 269 | + | } | |||
| 270 | + | ||||
| 271 | + | func (_c *MockDomainSecretStore_Set_Call) RunAndReturn(run func(domain string, secrets map[string]string) error) *MockDomainSecretStore_Set_Call { | |||
| 272 | + | _c.Call.Return(run) | |||
| 273 | + | return _c | |||
| 274 | + | } | |||
M internal/domain/auth.go +137 -0
| @@ -64,6 +64,30 @@ const ( | |||||
| 64 | ScopeActionAll = "*" | 64 | ScopeActionAll = "*" | ||
| 65 | ) | 65 | ) | ||
| 66 | 66 | ||||
| 67 | + | // Admin scope type constants. | |||
| 68 | + | const ( | |||
| 69 | + | // ScopeTypeRepository is for Docker registry repository access. | |||
| 70 | + | ScopeTypeRepository = "repository" | |||
| 71 | + | // ScopeTypeAdmin is for admin API access. | |||
| 72 | + | ScopeTypeAdmin = "admin" | |||
| 73 | + | ) | |||
| 74 | + | ||||
| 75 | + | // Admin scope resource constants. | |||
| 76 | + | const ( | |||
| 77 | + | AdminResourceRoutes = "routes" | |||
| 78 | + | AdminResourceSecrets = "secrets" | |||
| 79 | + | AdminResourceConfig = "config" | |||
| 80 | + | AdminResourceStatus = "status" | |||
| 81 | + | AdminResourceAll = "*" | |||
| 82 | + | ) | |||
| 83 | + | ||||
| 84 | + | // Admin scope action constants. | |||
| 85 | + | const ( | |||
| 86 | + | AdminActionRead = "read" | |||
| 87 | + | AdminActionWrite = "write" | |||
| 88 | + | AdminActionAll = "*" | |||
| 89 | + | ) | |||
| 90 | + | ||||
| 67 | // Scope represents a Docker registry scope (repository:name:actions). | 91 | // Scope represents a Docker registry scope (repository:name:actions). | ||
| 68 | // Format follows the Docker Registry v2 token scope specification. | 92 | // Format follows the Docker Registry v2 token scope specification. | ||
| 69 | type Scope struct { | 93 | type Scope struct { | ||
| @@ -127,3 +151,116 @@ func (s *Scope) CanAccess(repoName string, action string) bool { | |||||
| 127 | func (s *Scope) String() string { | 151 | func (s *Scope) String() string { | ||
| 128 | return fmt.Sprintf("%s:%s:%s", s.Type, s.Name, strings.Join(s.Actions, ",")) | 152 | return fmt.Sprintf("%s:%s:%s", s.Type, s.Name, strings.Join(s.Actions, ",")) | ||
| 129 | } | 153 | } | ||
| 154 | + | ||||
| 155 | + | // IsAdminScope returns true if the scope is an admin scope. | |||
| 156 | + | func (s *Scope) IsAdminScope() bool { | |||
| 157 | + | return s.Type == ScopeTypeAdmin | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | // IsRepositoryScope returns true if the scope is a repository scope. | |||
| 161 | + | func (s *Scope) IsRepositoryScope() bool { | |||
| 162 | + | return s.Type == ScopeTypeRepository | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | // AdminScope represents an admin API scope (admin:resource:actions). | |||
| 166 | + | // Format: admin:routes:read,write or admin:*:* for full access. | |||
| 167 | + | type AdminScope struct { | |||
| 168 | + | Resource string // routes, secrets, config, status, or * | |||
| 169 | + | Actions []string // read, write, or * | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | // ParseAdminScope parses an admin scope string. | |||
| 173 | + | // Format: admin:resource:action1,action2 | |||
| 174 | + | func ParseAdminScope(s string) (*AdminScope, error) { | |||
| 175 | + | parts := strings.SplitN(s, ":", 3) | |||
| 176 | + | if len(parts) != 3 { | |||
| 177 | + | return nil, fmt.Errorf("invalid admin scope format: %s", s) | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | if parts[0] != ScopeTypeAdmin { | |||
| 181 | + | return nil, fmt.Errorf("not an admin scope: %s", s) | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | scope := &AdminScope{ | |||
| 185 | + | Resource: parts[1], | |||
| 186 | + | Actions: strings.Split(parts[2], ","), | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | // Trim whitespace from actions | |||
| 190 | + | for i, action := range scope.Actions { | |||
| 191 | + | scope.Actions[i] = strings.TrimSpace(action) | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | return scope, nil | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | // CanAccess checks if the admin scope grants the requested action on the resource. | |||
| 198 | + | func (s *AdminScope) CanAccess(resource string, action string) bool { | |||
| 199 | + | // Check resource match | |||
| 200 | + | if s.Resource != AdminResourceAll && s.Resource != resource { | |||
| 201 | + | return false | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | // Check action permission | |||
| 205 | + | for _, a := range s.Actions { | |||
| 206 | + | if a == AdminActionAll || a == action { | |||
| 207 | + | return true | |||
| 208 | + | } | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | return false | |||
| 212 | + | } | |||
| 213 | + | ||||
| 214 | + | // String returns the admin scope as a string. | |||
| 215 | + | func (s *AdminScope) String() string { | |||
| 216 | + | return fmt.Sprintf("%s:%s:%s", ScopeTypeAdmin, s.Resource, strings.Join(s.Actions, ",")) | |||
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | // HasAdminAccess checks if a list of scope strings grants admin access to the resource and action. | |||
| 220 | + | func HasAdminAccess(scopes []string, resource, action string) bool { | |||
| 221 | + | for _, scopeStr := range scopes { | |||
| 222 | + | scope, err := ParseScope(scopeStr) | |||
| 223 | + | if err != nil { | |||
| 224 | + | continue | |||
| 225 | + | } | |||
| 226 | + | ||||
| 227 | + | if !scope.IsAdminScope() { | |||
| 228 | + | continue | |||
| 229 | + | } | |||
| 230 | + | ||||
| 231 | + | adminScope, err := ParseAdminScope(scopeStr) | |||
| 232 | + | if err != nil { | |||
| 233 | + | continue | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | if adminScope.CanAccess(resource, action) { | |||
| 237 | + | return true | |||
| 238 | + | } | |||
| 239 | + | } | |||
| 240 | + | return false | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | // AdminScopeAll creates a full admin access scope string. | |||
| 244 | + | func AdminScopeAll() string { | |||
| 245 | + | return fmt.Sprintf("%s:%s:%s", ScopeTypeAdmin, AdminResourceAll, AdminActionAll) | |||
| 246 | + | } | |||
| 247 | + | ||||
| 248 | + | // AdminScopeRoutes creates an admin scope for routes with the given actions. | |||
| 249 | + | func AdminScopeRoutes(actions ...string) string { | |||
| 250 | + | return fmt.Sprintf("%s:%s:%s", ScopeTypeAdmin, AdminResourceRoutes, strings.Join(actions, ",")) | |||
| 251 | + | } | |||
| 252 | + | ||||
| 253 | + | // AdminScopeSecrets creates an admin scope for secrets with the given actions. | |||
| 254 | + | func AdminScopeSecrets(actions ...string) string { | |||
| 255 | + | return fmt.Sprintf("%s:%s:%s", ScopeTypeAdmin, AdminResourceSecrets, strings.Join(actions, ",")) | |||
| 256 | + | } | |||
| 257 | + | ||||
| 258 | + | // AdminScopeConfig creates an admin scope for config with the given actions. | |||
| 259 | + | func AdminScopeConfig(actions ...string) string { | |||
| 260 | + | return fmt.Sprintf("%s:%s:%s", ScopeTypeAdmin, AdminResourceConfig, strings.Join(actions, ",")) | |||
| 261 | + | } | |||
| 262 | + | ||||
| 263 | + | // AdminScopeStatus creates an admin scope for status with the given actions. | |||
| 264 | + | func AdminScopeStatus(actions ...string) string { | |||
| 265 | + | return fmt.Sprintf("%s:%s:%s", ScopeTypeAdmin, AdminResourceStatus, strings.Join(actions, ",")) | |||
| 266 | + | } | |||
M internal/domain/errors.go +2 -0
| @@ -20,6 +20,8 @@ var ( | |||||
| 20 | ErrRouteNotFound = errors.New("route not found") | 20 | ErrRouteNotFound = errors.New("route not found") | ||
| 21 | ErrRouteExists = errors.New("route already exists") | 21 | ErrRouteExists = errors.New("route already exists") | ||
| 22 | ErrInvalidRoute = errors.New("invalid route configuration") | 22 | ErrInvalidRoute = errors.New("invalid route configuration") | ||
| 23 | + | ErrRouteDomainEmpty = errors.New("route domain cannot be empty") | |||
| 24 | + | ErrRouteImageEmpty = errors.New("route image cannot be empty") | |||
| 23 | ErrNoTargetAvailable = errors.New("no target available for route") | 25 | ErrNoTargetAvailable = errors.New("no target available for route") | ||
| 24 | 26 | ||||
| 25 | // Registry errors | 27 | // Registry errors | ||
M internal/domain/labels.go +10 -0
| @@ -13,4 +13,14 @@ const ( | |||||
| 13 | 13 | ||||
| 14 | // Image labels (set in Dockerfile) | 14 | // Image labels (set in Dockerfile) | ||
| 15 | LabelProxyPort = "gordon.proxy.port" | 15 | LabelProxyPort = "gordon.proxy.port" | ||
| 16 | + | ||||
| 17 | + | // Auto-route image labels (for automatic route discovery) | |||
| 18 | + | // LabelDomains specifies multiple route domains (comma-separated). | |||
| 19 | + | LabelDomains = "gordon.domains" | |||
| 20 | + | // LabelHealth specifies the health check endpoint path. | |||
| 21 | + | LabelHealth = "gordon.health" | |||
| 22 | + | // LabelPort specifies the container port to proxy. | |||
| 23 | + | LabelPort = "gordon.port" | |||
| 24 | + | // LabelEnvFile specifies the path to .env file inside the image. | |||
| 25 | + | LabelEnvFile = "gordon.env-file" | |||
| 16 | ) | 26 | ) | ||
M internal/domain/registry.go +9 -0
| @@ -2,6 +2,15 @@ package domain | |||||
| 2 | 2 | ||||
| 3 | import "time" | 3 | import "time" | ||
| 4 | 4 | ||||
| 5 | + | // ImageLabels represents extracted Gordon labels from an image. | |||
| 6 | + | type ImageLabels struct { | |||
| 7 | + | Domain string // Primary domain | |||
| 8 | + | Domains []string // Additional domains | |||
| 9 | + | Health string // Health check path | |||
| 10 | + | Port string // Container port | |||
| 11 | + | EnvFile string // Env file path in image | |||
| 12 | + | } | |||
| 13 | + | ||||
| 5 | // Manifest represents an OCI image manifest. | 14 | // Manifest represents an OCI image manifest. | ||
| 6 | type Manifest struct { | 15 | type Manifest struct { | ||
| 7 | Name string | 16 | Name string | ||
M internal/domain/route.go +10 -0
| @@ -28,3 +28,13 @@ type ExternalRoute struct { | |||||
| 28 | Host string // e.g., "localhost" | 28 | Host string // e.g., "localhost" | ||
| 29 | Port int // e.g., 5000 | 29 | Port int // e.g., 5000 | ||
| 30 | } | 30 | } | ||
| 31 | + | ||||
| 32 | + | // RouteHealth represents the health status of a route. | |||
| 33 | + | type RouteHealth struct { | |||
| 34 | + | Domain string // The route domain | |||
| 35 | + | ContainerStatus string // Container state: "running", "stopped", etc. | |||
| 36 | + | HTTPStatus int // HTTP status code from probe (0 if unreachable) | |||
| 37 | + | ResponseTimeMs int64 // Response time in milliseconds | |||
| 38 | + | Healthy bool // True if container running AND HTTP 2xx/3xx | |||
| 39 | + | Error string // Error message if probe failed | |||
| 40 | + | } | |||
M internal/usecase/config/service.go +139 -21
| @@ -5,6 +5,8 @@ import ( | |||||
| 5 | "context" | 5 | "context" | ||
| 6 | "strings" | 6 | "strings" | ||
| 7 | "sync" | 7 | "sync" | ||
| 8 | + | "sync/atomic" | |||
| 9 | + | "time" | |||
| 8 | 10 | ||||
| 9 | "github.com/bnema/zerowrap" | 11 | "github.com/bnema/zerowrap" | ||
| 10 | "github.com/fsnotify/fsnotify" | 12 | "github.com/fsnotify/fsnotify" | ||
| @@ -37,17 +39,20 @@ type Config struct { | |||||
| 37 | 39 | ||||
| 38 | // Service implements the ConfigService interface. | 40 | // Service implements the ConfigService interface. | ||
| 39 | type Service struct { | 41 | type Service struct { | ||
| 40 | - | viper *viper.Viper | 42 | + | viper *viper.Viper |
| 41 | - | eventBus out.EventPublisher | 43 | + | eventBus out.EventPublisher |
| 42 | - | config Config | 44 | + | config Config |
| 43 | - | mu sync.RWMutex | 45 | + | mu sync.RWMutex |
| 46 | + | lastSaveTime int64 // Unix nano timestamp of last save (to debounce file watcher) | |||
| 47 | + | debounceDelay int64 // Debounce delay in nanoseconds (default 500ms) | |||
| 44 | } | 48 | } | ||
| 45 | 49 | ||||
| 46 | // NewService creates a new config service. | 50 | // NewService creates a new config service. | ||
| 47 | func NewService(v *viper.Viper, eventBus out.EventPublisher) *Service { | 51 | func NewService(v *viper.Viper, eventBus out.EventPublisher) *Service { | ||
| 48 | return &Service{ | 52 | return &Service{ | ||
| 49 | - | viper: v, | 53 | + | viper: v, |
| 50 | - | eventBus: eventBus, | 54 | + | eventBus: eventBus, |
| 55 | + | debounceDelay: int64(500 * time.Millisecond), // 500ms debounce for file watcher | |||
| 51 | } | 56 | } | ||
| 52 | } | 57 | } | ||
| 53 | 58 | ||||
| @@ -82,10 +87,16 @@ func (s *Service) Load(ctx context.Context) error { | |||||
| 82 | 87 | ||||
| 83 | // loadConfigValues loads simple config values from viper. | 88 | // loadConfigValues loads simple config values from viper. | ||
| 84 | func (s *Service) loadConfigValues() Config { | 89 | func (s *Service) loadConfigValues() Config { | ||
| 90 | + | // Prefer gordon_domain over registry_domain | |||
| 91 | + | registryDomain := s.viper.GetString("server.gordon_domain") | |||
| 92 | + | if registryDomain == "" { | |||
| 93 | + | registryDomain = s.viper.GetString("server.registry_domain") | |||
| 94 | + | } | |||
| 95 | + | ||||
| 85 | return Config{ | 96 | return Config{ | ||
| 86 | ServerPort: s.viper.GetInt("server.port"), | 97 | ServerPort: s.viper.GetInt("server.port"), | ||
| 87 | RegistryPort: s.viper.GetInt("server.registry_port"), | 98 | RegistryPort: s.viper.GetInt("server.registry_port"), | ||
| 88 | - | RegistryDomain: s.viper.GetString("server.registry_domain"), | 99 | + | RegistryDomain: registryDomain, |
| 89 | DataDir: s.viper.GetString("server.data_dir"), | 100 | DataDir: s.viper.GetString("server.data_dir"), | ||
| 90 | AutoRouteEnabled: s.viper.GetBool("auto_route.enabled"), | 101 | AutoRouteEnabled: s.viper.GetBool("auto_route.enabled"), | ||
| 91 | NetworkIsolation: s.viper.GetBool("network_isolation.enabled"), | 102 | NetworkIsolation: s.viper.GetBool("network_isolation.enabled"), | ||
| @@ -171,7 +182,32 @@ func (s *Service) GetRoutes(_ context.Context) []domain.Route { | |||||
| 171 | return routes | 182 | return routes | ||
| 172 | } | 183 | } | ||
| 173 | 184 | ||||
| 174 | - | // AddRoute adds a new route to the configuration. | 185 | + | // GetRoute returns a single route by domain. |
| 186 | + | func (s *Service) GetRoute(_ context.Context, domainName string) (*domain.Route, error) { | |||
| 187 | + | s.mu.RLock() | |||
| 188 | + | defer s.mu.RUnlock() | |||
| 189 | + | ||||
| 190 | + | image, exists := s.config.Routes[domainName] | |||
| 191 | + | if !exists { | |||
| 192 | + | return nil, domain.ErrRouteNotFound | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | route := &domain.Route{ | |||
| 196 | + | Domain: domainName, | |||
| 197 | + | Image: image, | |||
| 198 | + | HTTPS: true, | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | // Check if domain has http:// prefix | |||
| 202 | + | if strings.HasPrefix(domainName, "http://") { | |||
| 203 | + | route.Domain = strings.TrimPrefix(domainName, "http://") | |||
| 204 | + | route.HTTPS = false | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | return route, nil | |||
| 208 | + | } | |||
| 209 | + | ||||
| 210 | + | // AddRoute adds a new route to the configuration and persists it. | |||
| 175 | func (s *Service) AddRoute(ctx context.Context, route domain.Route) error { | 211 | func (s *Service) AddRoute(ctx context.Context, route domain.Route) error { | ||
| 176 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | 212 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | ||
| 177 | zerowrap.FieldLayer: "usecase", | 213 | zerowrap.FieldLayer: "usecase", | ||
| @@ -180,20 +216,41 @@ func (s *Service) AddRoute(ctx context.Context, route domain.Route) error { | |||||
| 180 | }) | 216 | }) | ||
| 181 | log := zerowrap.FromCtx(ctx) | 217 | log := zerowrap.FromCtx(ctx) | ||
| 182 | 218 | ||||
| 183 | - | s.mu.Lock() | 219 | + | // Validate route |
| 184 | - | defer s.mu.Unlock() | 220 | + | if route.Domain == "" { |
| 221 | + | return domain.ErrRouteDomainEmpty | |||
| 222 | + | } | |||
| 223 | + | if route.Image == "" { | |||
| 224 | + | return domain.ErrRouteImageEmpty | |||
| 225 | + | } | |||
| 185 | 226 | ||||
| 227 | + | // Store previous value for rollback | |||
| 228 | + | s.mu.Lock() | |||
| 229 | + | previousImage, existed := s.config.Routes[route.Domain] | |||
| 186 | if s.config.Routes == nil { | 230 | if s.config.Routes == nil { | ||
| 187 | s.config.Routes = make(map[string]string) | 231 | s.config.Routes = make(map[string]string) | ||
| 188 | } | 232 | } | ||
| 189 | - | ||||
| 190 | s.config.Routes[route.Domain] = route.Image | 233 | s.config.Routes[route.Domain] = route.Image | ||
| 234 | + | s.mu.Unlock() | |||
| 235 | + | ||||
| 236 | + | // Persist to disk - rollback on failure | |||
| 237 | + | if err := s.Save(ctx); err != nil { | |||
| 238 | + | log.Warn().Err(err).Msg("failed to persist route to disk, rolling back") | |||
| 239 | + | s.mu.Lock() | |||
| 240 | + | if existed { | |||
| 241 | + | s.config.Routes[route.Domain] = previousImage | |||
| 242 | + | } else { | |||
| 243 | + | delete(s.config.Routes, route.Domain) | |||
| 244 | + | } | |||
| 245 | + | s.mu.Unlock() | |||
| 246 | + | return err | |||
| 247 | + | } | |||
| 191 | 248 | ||||
| 192 | log.Info().Str("image", route.Image).Msg("route added to configuration") | 249 | log.Info().Str("image", route.Image).Msg("route added to configuration") | ||
| 193 | return nil | 250 | return nil | ||
| 194 | } | 251 | } | ||
| 195 | 252 | ||||
| 196 | - | // UpdateRoute updates an existing route. | 253 | + | // UpdateRoute updates an existing route and persists it. |
| 197 | func (s *Service) UpdateRoute(ctx context.Context, route domain.Route) error { | 254 | func (s *Service) UpdateRoute(ctx context.Context, route domain.Route) error { | ||
| 198 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | 255 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | ||
| 199 | zerowrap.FieldLayer: "usecase", | 256 | zerowrap.FieldLayer: "usecase", | ||
| @@ -202,20 +259,38 @@ func (s *Service) UpdateRoute(ctx context.Context, route domain.Route) error { | |||||
| 202 | }) | 259 | }) | ||
| 203 | log := zerowrap.FromCtx(ctx) | 260 | log := zerowrap.FromCtx(ctx) | ||
| 204 | 261 | ||||
| 205 | - | s.mu.Lock() | 262 | + | // Validate route |
| 206 | - | defer s.mu.Unlock() | 263 | + | if route.Domain == "" { |
| 264 | + | return domain.ErrRouteDomainEmpty | |||
| 265 | + | } | |||
| 266 | + | if route.Image == "" { | |||
| 267 | + | return domain.ErrRouteImageEmpty | |||
| 268 | + | } | |||
| 207 | 269 | ||||
| 208 | - | if _, exists := s.config.Routes[route.Domain]; !exists { | 270 | + | // Store previous value for rollback |
| 271 | + | s.mu.Lock() | |||
| 272 | + | previousImage, exists := s.config.Routes[route.Domain] | |||
| 273 | + | if !exists { | |||
| 274 | + | s.mu.Unlock() | |||
| 209 | return domain.ErrRouteNotFound | 275 | return domain.ErrRouteNotFound | ||
| 210 | } | 276 | } | ||
| 211 | - | ||||
| 212 | s.config.Routes[route.Domain] = route.Image | 277 | s.config.Routes[route.Domain] = route.Image | ||
| 278 | + | s.mu.Unlock() | |||
| 279 | + | ||||
| 280 | + | // Persist to disk - rollback on failure | |||
| 281 | + | if err := s.Save(ctx); err != nil { | |||
| 282 | + | log.Warn().Err(err).Msg("failed to persist route update to disk, rolling back") | |||
| 283 | + | s.mu.Lock() | |||
| 284 | + | s.config.Routes[route.Domain] = previousImage | |||
| 285 | + | s.mu.Unlock() | |||
| 286 | + | return err | |||
| 287 | + | } | |||
| 213 | 288 | ||||
| 214 | log.Info().Str("image", route.Image).Msg("route updated") | 289 | log.Info().Str("image", route.Image).Msg("route updated") | ||
| 215 | return nil | 290 | return nil | ||
| 216 | } | 291 | } | ||
| 217 | 292 | ||||
| 218 | - | // RemoveRoute removes a route from the configuration. | 293 | + | // RemoveRoute removes a route from the configuration and persists it. |
| 219 | func (s *Service) RemoveRoute(ctx context.Context, domainName string) error { | 294 | func (s *Service) RemoveRoute(ctx context.Context, domainName string) error { | ||
| 220 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | 295 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | ||
| 221 | zerowrap.FieldLayer: "usecase", | 296 | zerowrap.FieldLayer: "usecase", | ||
| @@ -224,24 +299,67 @@ func (s *Service) RemoveRoute(ctx context.Context, domainName string) error { | |||||
| 224 | }) | 299 | }) | ||
| 225 | log := zerowrap.FromCtx(ctx) | 300 | log := zerowrap.FromCtx(ctx) | ||
| 226 | 301 | ||||
| 302 | + | // Store previous value for rollback | |||
| 227 | s.mu.Lock() | 303 | s.mu.Lock() | ||
| 228 | - | defer s.mu.Unlock() | 304 | + | previousImage, exists := s.config.Routes[domainName] |
| 229 | - | 305 | + | if !exists { | |
| 230 | - | if _, exists := s.config.Routes[domainName]; !exists { | 306 | + | s.mu.Unlock() |
| 231 | return domain.ErrRouteNotFound | 307 | return domain.ErrRouteNotFound | ||
| 232 | } | 308 | } | ||
| 233 | - | ||||
| 234 | delete(s.config.Routes, domainName) | 309 | delete(s.config.Routes, domainName) | ||
| 310 | + | s.mu.Unlock() | |||
| 311 | + | ||||
| 312 | + | // Persist to disk - rollback on failure | |||
| 313 | + | if err := s.Save(ctx); err != nil { | |||
| 314 | + | log.Warn().Err(err).Msg("failed to persist route removal to disk, rolling back") | |||
| 315 | + | s.mu.Lock() | |||
| 316 | + | s.config.Routes[domainName] = previousImage | |||
| 317 | + | s.mu.Unlock() | |||
| 318 | + | return err | |||
| 319 | + | } | |||
| 235 | 320 | ||||
| 236 | log.Info().Msg("route removed") | 321 | log.Info().Msg("route removed") | ||
| 237 | return nil | 322 | return nil | ||
| 238 | } | 323 | } | ||
| 239 | 324 | ||||
| 325 | + | // Save persists the current configuration to disk. | |||
| 326 | + | func (s *Service) Save(ctx context.Context) error { | |||
| 327 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |||
| 328 | + | zerowrap.FieldLayer: "usecase", | |||
| 329 | + | zerowrap.FieldUseCase: "SaveConfig", | |||
| 330 | + | }) | |||
| 331 | + | log := zerowrap.FromCtx(ctx) | |||
| 332 | + | ||||
| 333 | + | s.mu.Lock() | |||
| 334 | + | defer s.mu.Unlock() | |||
| 335 | + | ||||
| 336 | + | // Update viper with current routes | |||
| 337 | + | s.viper.Set("routes", s.config.Routes) | |||
| 338 | + | ||||
| 339 | + | // Record save time to debounce file watcher events | |||
| 340 | + | atomic.StoreInt64(&s.lastSaveTime, time.Now().UnixNano()) | |||
| 341 | + | ||||
| 342 | + | // Write config to disk | |||
| 343 | + | if err := s.viper.WriteConfig(); err != nil { | |||
| 344 | + | return log.WrapErr(err, "failed to write config") | |||
| 345 | + | } | |||
| 346 | + | ||||
| 347 | + | log.Info().Msg("configuration saved to disk") | |||
| 348 | + | return nil | |||
| 349 | + | } | |||
| 350 | + | ||||
| 240 | // Watch starts watching for configuration changes. | 351 | // Watch starts watching for configuration changes. | ||
| 241 | func (s *Service) Watch(ctx context.Context, onChange func()) error { | 352 | func (s *Service) Watch(ctx context.Context, onChange func()) error { | ||
| 242 | log := zerowrap.FromCtx(ctx) | 353 | log := zerowrap.FromCtx(ctx) | ||
| 243 | 354 | ||||
| 244 | s.viper.OnConfigChange(func(e fsnotify.Event) { | 355 | s.viper.OnConfigChange(func(e fsnotify.Event) { | ||
| 356 | + | // Check if this event is within the debounce window of our own Save | |||
| 357 | + | lastSave := atomic.LoadInt64(&s.lastSaveTime) | |||
| 358 | + | if lastSave > 0 && time.Now().UnixNano()-lastSave < s.debounceDelay { | |||
| 359 | + | log.Debug().Str("file", e.Name).Msg("skipping config reload (triggered by save)") | |||
| 360 | + | return | |||
| 361 | + | } | |||
| 362 | + | ||||
| 245 | log.Info().Str("file", e.Name).Msg("config file changed") | 363 | log.Info().Str("file", e.Name).Msg("config file changed") | ||
| 246 | 364 | ||||
| 247 | if err := s.viper.ReadInConfig(); err != nil { | 365 | if err := s.viper.ReadInConfig(); err != nil { | ||
M internal/usecase/config/service_test.go +155 -40
| @@ -2,11 +2,14 @@ package config | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "os" | |||
| 6 | + | "path/filepath" | |||
| 5 | "testing" | 7 | "testing" | ||
| 6 | 8 | ||||
| 7 | "github.com/bnema/zerowrap" | 9 | "github.com/bnema/zerowrap" | ||
| 8 | "github.com/spf13/viper" | 10 | "github.com/spf13/viper" | ||
| 9 | "github.com/stretchr/testify/assert" | 11 | "github.com/stretchr/testify/assert" | ||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 10 | 13 | ||||
| 11 | "gordon/internal/boundaries/out/mocks" | 14 | "gordon/internal/boundaries/out/mocks" | ||
| 12 | "gordon/internal/domain" | 15 | "gordon/internal/domain" | ||
| @@ -95,73 +98,185 @@ func TestService_GetRoutes(t *testing.T) { | |||||
| 95 | assert.False(t, routeMap["insecure.example"].HTTPS) | 98 | assert.False(t, routeMap["insecure.example"].HTTPS) | ||
| 96 | } | 99 | } | ||
| 97 | 100 | ||||
| 98 | - | func TestService_AddRoute(t *testing.T) { | 101 | + | func TestService_GetRoute(t *testing.T) { |
| 99 | v := viper.New() | 102 | v := viper.New() | ||
| 103 | + | v.Set("routes", map[string]interface{}{ | |||
| 104 | + | "app.example.com": "myapp:latest", | |||
| 105 | + | "http://insecure.example": "insecureapp:latest", | |||
| 106 | + | }) | |||
| 107 | + | ||||
| 100 | eventBus := mocks.NewMockEventPublisher(t) | 108 | eventBus := mocks.NewMockEventPublisher(t) | ||
| 101 | svc := NewService(v, eventBus) | 109 | svc := NewService(v, eventBus) | ||
| 102 | ctx := testContext() | 110 | ctx := testContext() | ||
| 103 | 111 | ||||
| 104 | _ = svc.Load(ctx) | 112 | _ = svc.Load(ctx) | ||
| 105 | 113 | ||||
| 106 | - | route := domain.Route{ | 114 | + | t.Run("existing route", func(t *testing.T) { |
| 107 | - | Domain: "new.example.com", | 115 | + | route, err := svc.GetRoute(ctx, "app.example.com") |
| 108 | - | Image: "newapp:latest", | 116 | + | require.NoError(t, err) |
| 109 | - | } | 117 | + | assert.Equal(t, "app.example.com", route.Domain) |
| 118 | + | assert.Equal(t, "myapp:latest", route.Image) | |||
| 119 | + | assert.True(t, route.HTTPS) | |||
| 120 | + | }) | |||
| 110 | 121 | ||||
| 111 | - | err := svc.AddRoute(ctx, route) | 122 | + | t.Run("non-existent route", func(t *testing.T) { |
| 123 | + | route, err := svc.GetRoute(ctx, "notfound.example.com") | |||
| 124 | + | assert.ErrorIs(t, err, domain.ErrRouteNotFound) | |||
| 125 | + | assert.Nil(t, route) | |||
| 126 | + | }) | |||
| 127 | + | } | |||
| 112 | 128 | ||||
| 113 | - | assert.NoError(t, err) | 129 | + | func TestService_AddRoute(t *testing.T) { |
| 130 | + | t.Run("success", func(t *testing.T) { | |||
| 131 | + | // Create temp config file for Save() to work | |||
| 132 | + | tmpDir := t.TempDir() | |||
| 133 | + | configFile := filepath.Join(tmpDir, "gordon.toml") | |||
| 134 | + | err := os.WriteFile(configFile, []byte("[routes]\n"), 0600) | |||
| 135 | + | require.NoError(t, err) | |||
| 114 | 136 | ||||
| 115 | - | // Verify route was added | 137 | + | v := viper.New() |
| 116 | - | config := svc.GetConfig() | 138 | + | v.SetConfigFile(configFile) |
| 117 | - | assert.Equal(t, "newapp:latest", config.Routes["new.example.com"]) | 139 | + | eventBus := mocks.NewMockEventPublisher(t) |
| 118 | - | } | 140 | + | svc := NewService(v, eventBus) |
| 141 | + | ctx := testContext() | |||
| 119 | 142 | ||||
| 120 | - | func TestService_UpdateRoute(t *testing.T) { | 143 | + | _ = svc.Load(ctx) |
| 121 | - | v := viper.New() | 144 | + | |
| 122 | - | v.Set("routes", map[string]interface{}{ | 145 | + | route := domain.Route{ |
| 123 | - | "app.example.com": "myapp:v1", | 146 | + | Domain: "new.example.com", |
| 147 | + | Image: "newapp:latest", | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | err = svc.AddRoute(ctx, route) | |||
| 151 | + | ||||
| 152 | + | assert.NoError(t, err) | |||
| 153 | + | ||||
| 154 | + | // Verify route was added | |||
| 155 | + | config := svc.GetConfig() | |||
| 156 | + | assert.Equal(t, "newapp:latest", config.Routes["new.example.com"]) | |||
| 124 | }) | 157 | }) | ||
| 125 | 158 | ||||
| 126 | - | eventBus := mocks.NewMockEventPublisher(t) | 159 | + | t.Run("empty domain", func(t *testing.T) { |
| 127 | - | svc := NewService(v, eventBus) | 160 | + | v := viper.New() |
| 128 | - | ctx := testContext() | 161 | + | eventBus := mocks.NewMockEventPublisher(t) |
| 162 | + | svc := NewService(v, eventBus) | |||
| 163 | + | ctx := testContext() | |||
| 129 | 164 | ||||
| 130 | - | _ = svc.Load(ctx) | 165 | + | route := domain.Route{ |
| 166 | + | Domain: "", | |||
| 167 | + | Image: "myapp:latest", | |||
| 168 | + | } | |||
| 131 | 169 | ||||
| 132 | - | route := domain.Route{ | 170 | + | err := svc.AddRoute(ctx, route) |
| 133 | - | Domain: "app.example.com", | 171 | + | assert.ErrorIs(t, err, domain.ErrRouteDomainEmpty) |
| 134 | - | Image: "myapp:v2", | 172 | + | }) |
| 135 | - | } | |||
| 136 | 173 | ||||
| 137 | - | err := svc.UpdateRoute(ctx, route) | 174 | + | t.Run("empty image", func(t *testing.T) { |
| 175 | + | v := viper.New() | |||
| 176 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 177 | + | svc := NewService(v, eventBus) | |||
| 178 | + | ctx := testContext() | |||
| 138 | 179 | ||||
| 139 | - | assert.NoError(t, err) | 180 | + | route := domain.Route{ |
| 181 | + | Domain: "example.com", | |||
| 182 | + | Image: "", | |||
| 183 | + | } | |||
| 140 | 184 | ||||
| 141 | - | config := svc.GetConfig() | 185 | + | err := svc.AddRoute(ctx, route) |
| 142 | - | assert.Equal(t, "myapp:v2", config.Routes["app.example.com"]) | 186 | + | assert.ErrorIs(t, err, domain.ErrRouteImageEmpty) |
| 187 | + | }) | |||
| 143 | } | 188 | } | ||
| 144 | 189 | ||||
| 145 | - | func TestService_UpdateRoute_NotFound(t *testing.T) { | 190 | + | func TestService_UpdateRoute(t *testing.T) { |
| 146 | - | v := viper.New() | 191 | + | t.Run("success", func(t *testing.T) { |
| 147 | - | eventBus := mocks.NewMockEventPublisher(t) | 192 | + | // Create temp config file for Save() to work |
| 148 | - | svc := NewService(v, eventBus) | 193 | + | tmpDir := t.TempDir() |
| 149 | - | ctx := testContext() | 194 | + | configFile := filepath.Join(tmpDir, "gordon.toml") |
| 195 | + | err := os.WriteFile(configFile, []byte("[routes]\n\"app.example.com\" = \"myapp:v1\"\n"), 0600) | |||
| 196 | + | require.NoError(t, err) | |||
| 197 | + | ||||
| 198 | + | v := viper.New() | |||
| 199 | + | v.SetConfigFile(configFile) | |||
| 200 | + | v.Set("routes", map[string]any{ | |||
| 201 | + | "app.example.com": "myapp:v1", | |||
| 202 | + | }) | |||
| 150 | 203 | ||||
| 151 | - | _ = svc.Load(ctx) | 204 | + | eventBus := mocks.NewMockEventPublisher(t) |
| 205 | + | svc := NewService(v, eventBus) | |||
| 206 | + | ctx := testContext() | |||
| 152 | 207 | ||||
| 153 | - | route := domain.Route{ | 208 | + | _ = svc.Load(ctx) |
| 154 | - | Domain: "nonexistent.example.com", | |||
| 155 | - | Image: "myapp:latest", | |||
| 156 | - | } | |||
| 157 | 209 | ||||
| 158 | - | err := svc.UpdateRoute(ctx, route) | 210 | + | route := domain.Route{ |
| 211 | + | Domain: "app.example.com", | |||
| 212 | + | Image: "myapp:v2", | |||
| 213 | + | } | |||
| 159 | 214 | ||||
| 160 | - | assert.ErrorIs(t, err, domain.ErrRouteNotFound) | 215 | + | err = svc.UpdateRoute(ctx, route) |
| 216 | + | ||||
| 217 | + | assert.NoError(t, err) | |||
| 218 | + | ||||
| 219 | + | config := svc.GetConfig() | |||
| 220 | + | assert.Equal(t, "myapp:v2", config.Routes["app.example.com"]) | |||
| 221 | + | }) | |||
| 222 | + | ||||
| 223 | + | t.Run("not found", func(t *testing.T) { | |||
| 224 | + | v := viper.New() | |||
| 225 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 226 | + | svc := NewService(v, eventBus) | |||
| 227 | + | ctx := testContext() | |||
| 228 | + | ||||
| 229 | + | _ = svc.Load(ctx) | |||
| 230 | + | ||||
| 231 | + | route := domain.Route{ | |||
| 232 | + | Domain: "nonexistent.example.com", | |||
| 233 | + | Image: "myapp:latest", | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | err := svc.UpdateRoute(ctx, route) | |||
| 237 | + | assert.ErrorIs(t, err, domain.ErrRouteNotFound) | |||
| 238 | + | }) | |||
| 239 | + | ||||
| 240 | + | t.Run("empty domain", func(t *testing.T) { | |||
| 241 | + | v := viper.New() | |||
| 242 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 243 | + | svc := NewService(v, eventBus) | |||
| 244 | + | ctx := testContext() | |||
| 245 | + | ||||
| 246 | + | route := domain.Route{ | |||
| 247 | + | Domain: "", | |||
| 248 | + | Image: "myapp:latest", | |||
| 249 | + | } | |||
| 250 | + | ||||
| 251 | + | err := svc.UpdateRoute(ctx, route) | |||
| 252 | + | assert.ErrorIs(t, err, domain.ErrRouteDomainEmpty) | |||
| 253 | + | }) | |||
| 254 | + | ||||
| 255 | + | t.Run("empty image", func(t *testing.T) { | |||
| 256 | + | v := viper.New() | |||
| 257 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 258 | + | svc := NewService(v, eventBus) | |||
| 259 | + | ctx := testContext() | |||
| 260 | + | ||||
| 261 | + | route := domain.Route{ | |||
| 262 | + | Domain: "example.com", | |||
| 263 | + | Image: "", | |||
| 264 | + | } | |||
| 265 | + | ||||
| 266 | + | err := svc.UpdateRoute(ctx, route) | |||
| 267 | + | assert.ErrorIs(t, err, domain.ErrRouteImageEmpty) | |||
| 268 | + | }) | |||
| 161 | } | 269 | } | ||
| 162 | 270 | ||||
| 163 | func TestService_RemoveRoute(t *testing.T) { | 271 | func TestService_RemoveRoute(t *testing.T) { | ||
| 272 | + | // Create temp config file for Save() to work | |||
| 273 | + | tmpDir := t.TempDir() | |||
| 274 | + | configFile := filepath.Join(tmpDir, "gordon.toml") | |||
| 275 | + | err := os.WriteFile(configFile, []byte("[routes]\n\"app.example.com\" = \"myapp:latest\"\n"), 0600) | |||
| 276 | + | require.NoError(t, err) | |||
| 277 | + | ||||
| 164 | v := viper.New() | 278 | v := viper.New() | ||
| 279 | + | v.SetConfigFile(configFile) | |||
| 165 | v.Set("routes", map[string]interface{}{ | 280 | v.Set("routes", map[string]interface{}{ | ||
| 166 | "app.example.com": "myapp:latest", | 281 | "app.example.com": "myapp:latest", | ||
| 167 | }) | 282 | }) | ||
| @@ -172,7 +287,7 @@ func TestService_RemoveRoute(t *testing.T) { | |||||
| 172 | 287 | ||||
| 173 | _ = svc.Load(ctx) | 288 | _ = svc.Load(ctx) | ||
| 174 | 289 | ||||
| 175 | - | err := svc.RemoveRoute(ctx, "app.example.com") | 290 | + | err = svc.RemoveRoute(ctx, "app.example.com") |
| 176 | 291 | ||||
| 177 | assert.NoError(t, err) | 292 | assert.NoError(t, err) | ||
| 178 | 293 | ||||
A internal/usecase/container/autoroute.go +500 -0
| @@ -0,0 +1,500 @@ | |||||
| 1 | + | package container | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bufio" | |||
| 5 | + | "bytes" | |||
| 6 | + | "context" | |||
| 7 | + | "encoding/json" | |||
| 8 | + | "fmt" | |||
| 9 | + | "io" | |||
| 10 | + | "os" | |||
| 11 | + | "path/filepath" | |||
| 12 | + | "sort" | |||
| 13 | + | "strings" | |||
| 14 | + | ||||
| 15 | + | "github.com/bnema/zerowrap" | |||
| 16 | + | ||||
| 17 | + | "gordon/internal/boundaries/in" | |||
| 18 | + | "gordon/internal/boundaries/out" | |||
| 19 | + | "gordon/internal/domain" | |||
| 20 | + | ) | |||
| 21 | + | ||||
| 22 | + | // EnvFileExtractor defines the interface for extracting env files from images. | |||
| 23 | + | type EnvFileExtractor interface { | |||
| 24 | + | ExtractEnvFileFromImage(ctx context.Context, imageRef, envFilePath string) ([]byte, error) | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | // AutoRouteHandler handles image.pushed events for auto-route from labels. | |||
| 28 | + | type AutoRouteHandler struct { | |||
| 29 | + | configSvc in.ConfigService | |||
| 30 | + | containerSvc in.ContainerService | |||
| 31 | + | blobStorage out.BlobStorage | |||
| 32 | + | extractor EnvFileExtractor | |||
| 33 | + | registryDomain string | |||
| 34 | + | envDir string | |||
| 35 | + | ctx context.Context | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // NewAutoRouteHandler creates a new AutoRouteHandler. | |||
| 39 | + | func NewAutoRouteHandler( | |||
| 40 | + | ctx context.Context, | |||
| 41 | + | configSvc in.ConfigService, | |||
| 42 | + | containerSvc in.ContainerService, | |||
| 43 | + | blobStorage out.BlobStorage, | |||
| 44 | + | registryDomain string, | |||
| 45 | + | ) *AutoRouteHandler { | |||
| 46 | + | return &AutoRouteHandler{ | |||
| 47 | + | configSvc: configSvc, | |||
| 48 | + | containerSvc: containerSvc, | |||
| 49 | + | blobStorage: blobStorage, | |||
| 50 | + | registryDomain: registryDomain, | |||
| 51 | + | ctx: ctx, | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | // WithEnvExtractor sets the env file extractor for auto env-file copy feature. | |||
| 56 | + | func (h *AutoRouteHandler) WithEnvExtractor(extractor EnvFileExtractor, envDir string) *AutoRouteHandler { | |||
| 57 | + | h.extractor = extractor | |||
| 58 | + | h.envDir = envDir | |||
| 59 | + | return h | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | // Handle handles an image.pushed event and creates routes from labels. | |||
| 63 | + | func (h *AutoRouteHandler) Handle(event domain.Event) error { | |||
| 64 | + | ctx := zerowrap.CtxWithFields(h.ctx, map[string]any{ | |||
| 65 | + | zerowrap.FieldLayer: "usecase", | |||
| 66 | + | zerowrap.FieldHandler: "AutoRouteHandler", | |||
| 67 | + | zerowrap.FieldEvent: string(event.Type), | |||
| 68 | + | "event_id": event.ID, | |||
| 69 | + | }) | |||
| 70 | + | log := zerowrap.FromCtx(ctx) | |||
| 71 | + | ||||
| 72 | + | if !h.configSvc.IsAutoRouteEnabled() { | |||
| 73 | + | log.Debug().Msg("auto-route disabled, skipping label extraction") | |||
| 74 | + | return nil | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | payload, ok := event.Data.(domain.ImagePushedPayload) | |||
| 78 | + | if !ok { | |||
| 79 | + | log.Warn().Msg("invalid event payload type") | |||
| 80 | + | return nil | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | if len(payload.Manifest) == 0 { | |||
| 84 | + | log.Debug().Msg("no manifest data in event, skipping") | |||
| 85 | + | return nil | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | log.Info(). | |||
| 89 | + | Str("image", payload.Name). | |||
| 90 | + | Str("reference", payload.Reference). | |||
| 91 | + | Msg("processing image for auto-route labels") | |||
| 92 | + | ||||
| 93 | + | labels, err := h.extractLabels(ctx, payload.Manifest) | |||
| 94 | + | if err != nil { | |||
| 95 | + | log.Debug().Err(err).Msg("failed to extract labels, skipping auto-route") | |||
| 96 | + | return nil | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | domains := h.collectDomains(labels) | |||
| 100 | + | if len(domains) == 0 { | |||
| 101 | + | log.Debug().Str("image", payload.Name).Msg("no gordon.domain label found, skipping") | |||
| 102 | + | return nil | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | imageName := h.buildImageName(payload.Name, payload.Reference) | |||
| 106 | + | h.processRoutes(ctx, domains, imageName, labels) | |||
| 107 | + | ||||
| 108 | + | return nil | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | // collectDomains collects all domains from labels. | |||
| 112 | + | func (h *AutoRouteHandler) collectDomains(labels *domain.ImageLabels) []string { | |||
| 113 | + | var domains []string | |||
| 114 | + | if labels.Domain != "" { | |||
| 115 | + | domains = append(domains, labels.Domain) | |||
| 116 | + | } | |||
| 117 | + | domains = append(domains, labels.Domains...) | |||
| 118 | + | return domains | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | // buildImageName constructs the full image name with tag or digest. | |||
| 122 | + | // For digest references (sha256:...), uses @ separator per Docker spec. | |||
| 123 | + | // For tags, uses : separator. | |||
| 124 | + | func (h *AutoRouteHandler) buildImageName(name, reference string) string { | |||
| 125 | + | if reference == "" { | |||
| 126 | + | return name | |||
| 127 | + | } | |||
| 128 | + | // Digest references use @ separator, tags use : | |||
| 129 | + | if strings.HasPrefix(reference, "sha256:") { | |||
| 130 | + | return fmt.Sprintf("%s@%s", name, reference) | |||
| 131 | + | } | |||
| 132 | + | return fmt.Sprintf("%s:%s", name, reference) | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | // buildFullImageRef constructs a fully qualified image reference with registry domain. | |||
| 136 | + | // This is needed for Docker operations since images are stored with the registry prefix. | |||
| 137 | + | func (h *AutoRouteHandler) buildFullImageRef(imageName string) string { | |||
| 138 | + | if h.registryDomain == "" { | |||
| 139 | + | return imageName | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | // Don't prefix if already has registry domain | |||
| 143 | + | if strings.HasPrefix(imageName, h.registryDomain+"/") { | |||
| 144 | + | return imageName | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | // Don't prefix if it's an external registry reference (contains dots and slashes) | |||
| 148 | + | repoPart := strings.Split(imageName, ":")[0] | |||
| 149 | + | if strings.Contains(repoPart, "@") { | |||
| 150 | + | repoPart = strings.Split(imageName, "@")[0] | |||
| 151 | + | } | |||
| 152 | + | if strings.Contains(repoPart, ".") && strings.Contains(repoPart, "/") { | |||
| 153 | + | return imageName | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | return fmt.Sprintf("%s/%s", h.registryDomain, imageName) | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | // processRoutes processes each domain and creates/updates routes. | |||
| 160 | + | func (h *AutoRouteHandler) processRoutes(ctx context.Context, domains []string, imageName string, labels *domain.ImageLabels) { | |||
| 161 | + | log := zerowrap.FromCtx(ctx) | |||
| 162 | + | ||||
| 163 | + | // Build full image reference for env extraction (Docker needs fully qualified name) | |||
| 164 | + | fullImageRef := h.buildFullImageRef(imageName) | |||
| 165 | + | ||||
| 166 | + | for _, routeDomain := range domains { | |||
| 167 | + | routeDomain = strings.TrimSpace(routeDomain) | |||
| 168 | + | if routeDomain == "" { | |||
| 169 | + | continue | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | h.createOrUpdateRoute(ctx, routeDomain, imageName) | |||
| 173 | + | ||||
| 174 | + | if labels.EnvFile != "" && h.extractor != nil && h.envDir != "" { | |||
| 175 | + | // Use full image ref for extraction (Docker knows image by this name) | |||
| 176 | + | if err := h.extractAndMergeEnvFile(ctx, fullImageRef, routeDomain, labels.EnvFile); err != nil { | |||
| 177 | + | log.Warn(). | |||
| 178 | + | Err(err). | |||
| 179 | + | Str("domain", routeDomain). | |||
| 180 | + | Str("env_file", labels.EnvFile). | |||
| 181 | + | Msg("failed to extract env file from image") | |||
| 182 | + | } | |||
| 183 | + | } | |||
| 184 | + | } | |||
| 185 | + | } | |||
| 186 | + | ||||
| 187 | + | // createOrUpdateRoute creates a new route or updates an existing one. | |||
| 188 | + | // It also triggers deployment for new routes since the ImagePushedHandler | |||
| 189 | + | // may have already finished processing (handlers run concurrently). | |||
| 190 | + | func (h *AutoRouteHandler) createOrUpdateRoute(ctx context.Context, routeDomain, imageName string) { | |||
| 191 | + | log := zerowrap.FromCtx(ctx) | |||
| 192 | + | ||||
| 193 | + | route := domain.Route{ | |||
| 194 | + | Domain: routeDomain, | |||
| 195 | + | Image: imageName, | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | existingRoutes := h.configSvc.GetRoutes(ctx) | |||
| 199 | + | for _, existing := range existingRoutes { | |||
| 200 | + | if existing.Domain == routeDomain { | |||
| 201 | + | if existing.Image != imageName { | |||
| 202 | + | if err := h.configSvc.UpdateRoute(ctx, route); err != nil { | |||
| 203 | + | log.Warn().Err(err).Str("domain", routeDomain).Msg("failed to update route") | |||
| 204 | + | } else { | |||
| 205 | + | log.Info().Str("domain", routeDomain).Str("image", imageName).Msg("auto-route updated from image labels") | |||
| 206 | + | // Trigger deploy for updated route | |||
| 207 | + | h.triggerDeploy(ctx, route) | |||
| 208 | + | } | |||
| 209 | + | } | |||
| 210 | + | return | |||
| 211 | + | } | |||
| 212 | + | } | |||
| 213 | + | ||||
| 214 | + | if err := h.configSvc.AddRoute(ctx, route); err != nil { | |||
| 215 | + | log.Warn().Err(err).Str("domain", routeDomain).Msg("failed to add route") | |||
| 216 | + | } else { | |||
| 217 | + | log.Info().Str("domain", routeDomain).Str("image", imageName).Msg("auto-route added from image labels") | |||
| 218 | + | // Trigger deploy for new route since ImagePushedHandler may have already finished | |||
| 219 | + | h.triggerDeploy(ctx, route) | |||
| 220 | + | } | |||
| 221 | + | } | |||
| 222 | + | ||||
| 223 | + | // triggerDeploy initiates deployment for a route. | |||
| 224 | + | func (h *AutoRouteHandler) triggerDeploy(ctx context.Context, route domain.Route) { | |||
| 225 | + | log := zerowrap.FromCtx(ctx) | |||
| 226 | + | ||||
| 227 | + | if h.containerSvc == nil { | |||
| 228 | + | log.Debug().Str("domain", route.Domain).Msg("no container service, skipping deploy trigger") | |||
| 229 | + | return | |||
| 230 | + | } | |||
| 231 | + | ||||
| 232 | + | // Mark context as internal deploy - the event originated from our own registry, | |||
| 233 | + | // so we can use internal registry (localhost) for image pulls. | |||
| 234 | + | internalCtx := domain.WithInternalDeploy(ctx) | |||
| 235 | + | ||||
| 236 | + | if _, err := h.containerSvc.Deploy(internalCtx, route); err != nil { | |||
| 237 | + | log.Warn().Err(err).Str("domain", route.Domain).Msg("failed to trigger deploy for auto-route") | |||
| 238 | + | } else { | |||
| 239 | + | log.Info().Str("domain", route.Domain).Msg("deploy triggered for auto-route") | |||
| 240 | + | } | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | // extractAndMergeEnvFile extracts an env file from the image and merges it with existing secrets. | |||
| 244 | + | func (h *AutoRouteHandler) extractAndMergeEnvFile(ctx context.Context, imageRef, routeDomain, envFilePath string) error { | |||
| 245 | + | log := zerowrap.FromCtx(ctx) | |||
| 246 | + | ||||
| 247 | + | log.Info(). | |||
| 248 | + | Str("domain", routeDomain). | |||
| 249 | + | Str("image", imageRef). | |||
| 250 | + | Str("env_file", envFilePath). | |||
| 251 | + | Msg("extracting env file from image") | |||
| 252 | + | ||||
| 253 | + | // Extract env file from image | |||
| 254 | + | envData, err := h.extractor.ExtractEnvFileFromImage(ctx, imageRef, envFilePath) | |||
| 255 | + | if err != nil { | |||
| 256 | + | return fmt.Errorf("failed to extract env file: %w", err) | |||
| 257 | + | } | |||
| 258 | + | ||||
| 259 | + | if len(envData) == 0 { | |||
| 260 | + | log.Debug().Msg("env file is empty, skipping") | |||
| 261 | + | return nil | |||
| 262 | + | } | |||
| 263 | + | ||||
| 264 | + | // Parse the extracted env file | |||
| 265 | + | imageEnv, err := parseEnvFile(envData) | |||
| 266 | + | if err != nil { | |||
| 267 | + | return fmt.Errorf("failed to parse env file: %w", err) | |||
| 268 | + | } | |||
| 269 | + | ||||
| 270 | + | if len(imageEnv) == 0 { | |||
| 271 | + | log.Debug().Msg("no environment variables found in env file") | |||
| 272 | + | return nil | |||
| 273 | + | } | |||
| 274 | + | ||||
| 275 | + | // Load existing env file for this domain | |||
| 276 | + | envFileName := domainToEnvFileName(routeDomain) | |||
| 277 | + | envFileDst := filepath.Join(h.envDir, envFileName) | |||
| 278 | + | ||||
| 279 | + | existingEnv := make(map[string]string) | |||
| 280 | + | if data, err := os.ReadFile(envFileDst); err == nil { | |||
| 281 | + | existingEnv, _ = parseEnvFile(data) | |||
| 282 | + | } | |||
| 283 | + | ||||
| 284 | + | // Merge: image values are defaults, existing values win | |||
| 285 | + | merged := make(map[string]string) | |||
| 286 | + | for k, v := range imageEnv { | |||
| 287 | + | merged[k] = v | |||
| 288 | + | } | |||
| 289 | + | for k, v := range existingEnv { | |||
| 290 | + | merged[k] = v // Existing values override image values | |||
| 291 | + | } | |||
| 292 | + | ||||
| 293 | + | // Write merged env file | |||
| 294 | + | if err := os.MkdirAll(h.envDir, 0700); err != nil { | |||
| 295 | + | return fmt.Errorf("failed to create env directory: %w", err) | |||
| 296 | + | } | |||
| 297 | + | ||||
| 298 | + | if err := writeEnvFile(envFileDst, merged); err != nil { | |||
| 299 | + | return fmt.Errorf("failed to write env file: %w", err) | |||
| 300 | + | } | |||
| 301 | + | ||||
| 302 | + | log.Info(). | |||
| 303 | + | Str("domain", routeDomain). | |||
| 304 | + | Int("image_vars", len(imageEnv)). | |||
| 305 | + | Int("existing_vars", len(existingEnv)). | |||
| 306 | + | Int("merged_vars", len(merged)). | |||
| 307 | + | Msg("env file extracted and merged from image") | |||
| 308 | + | ||||
| 309 | + | return nil | |||
| 310 | + | } | |||
| 311 | + | ||||
| 312 | + | // CanHandle returns whether this handler can handle the given event type. | |||
| 313 | + | func (h *AutoRouteHandler) CanHandle(eventType domain.EventType) bool { | |||
| 314 | + | return eventType == domain.EventImagePushed | |||
| 315 | + | } | |||
| 316 | + | ||||
| 317 | + | // extractLabels extracts Gordon labels from an image manifest. | |||
| 318 | + | func (h *AutoRouteHandler) extractLabels(ctx context.Context, manifestData []byte) (*domain.ImageLabels, error) { | |||
| 319 | + | log := zerowrap.FromCtx(ctx) | |||
| 320 | + | ||||
| 321 | + | // Parse manifest to get config digest | |||
| 322 | + | configDigest, err := parseConfigDigest(manifestData) | |||
| 323 | + | if err != nil { | |||
| 324 | + | return nil, fmt.Errorf("failed to parse manifest: %w", err) | |||
| 325 | + | } | |||
| 326 | + | ||||
| 327 | + | if configDigest == "" { | |||
| 328 | + | return nil, fmt.Errorf("no config digest found in manifest") | |||
| 329 | + | } | |||
| 330 | + | ||||
| 331 | + | log.Debug().Str("config_digest", configDigest).Msg("found config digest") | |||
| 332 | + | ||||
| 333 | + | // Read config blob | |||
| 334 | + | reader, err := h.blobStorage.GetBlob(configDigest) | |||
| 335 | + | if err != nil { | |||
| 336 | + | return nil, fmt.Errorf("failed to get config blob: %w", err) | |||
| 337 | + | } | |||
| 338 | + | defer reader.Close() | |||
| 339 | + | ||||
| 340 | + | configData, err := io.ReadAll(reader) | |||
| 341 | + | if err != nil { | |||
| 342 | + | return nil, fmt.Errorf("failed to read config blob: %w", err) | |||
| 343 | + | } | |||
| 344 | + | ||||
| 345 | + | // Parse config to extract labels | |||
| 346 | + | labels, err := parseImageLabels(configData) | |||
| 347 | + | if err != nil { | |||
| 348 | + | return nil, fmt.Errorf("failed to parse config: %w", err) | |||
| 349 | + | } | |||
| 350 | + | ||||
| 351 | + | return labels, nil | |||
| 352 | + | } | |||
| 353 | + | ||||
| 354 | + | // manifestSchema represents the relevant parts of an OCI/Docker manifest. | |||
| 355 | + | type manifestSchema struct { | |||
| 356 | + | SchemaVersion int `json:"schemaVersion"` | |||
| 357 | + | MediaType string `json:"mediaType"` | |||
| 358 | + | Config struct { | |||
| 359 | + | MediaType string `json:"mediaType"` | |||
| 360 | + | Digest string `json:"digest"` | |||
| 361 | + | Size int64 `json:"size"` | |||
| 362 | + | } `json:"config"` | |||
| 363 | + | } | |||
| 364 | + | ||||
| 365 | + | // parseConfigDigest extracts the config digest from a manifest. | |||
| 366 | + | func parseConfigDigest(manifestData []byte) (string, error) { | |||
| 367 | + | var manifest manifestSchema | |||
| 368 | + | if err := json.Unmarshal(manifestData, &manifest); err != nil { | |||
| 369 | + | return "", err | |||
| 370 | + | } | |||
| 371 | + | ||||
| 372 | + | return manifest.Config.Digest, nil | |||
| 373 | + | } | |||
| 374 | + | ||||
| 375 | + | // imageConfig represents the relevant parts of an OCI/Docker image config. | |||
| 376 | + | type imageConfig struct { | |||
| 377 | + | Config struct { | |||
| 378 | + | Labels map[string]string `json:"Labels"` | |||
| 379 | + | } `json:"config"` | |||
| 380 | + | } | |||
| 381 | + | ||||
| 382 | + | // parseImageLabels extracts Gordon labels from an image config. | |||
| 383 | + | func parseImageLabels(configData []byte) (*domain.ImageLabels, error) { | |||
| 384 | + | var config imageConfig | |||
| 385 | + | if err := json.Unmarshal(configData, &config); err != nil { | |||
| 386 | + | return nil, err | |||
| 387 | + | } | |||
| 388 | + | ||||
| 389 | + | labels := &domain.ImageLabels{} | |||
| 390 | + | ||||
| 391 | + | if config.Config.Labels == nil { | |||
| 392 | + | return labels, nil | |||
| 393 | + | } | |||
| 394 | + | ||||
| 395 | + | // Extract gordon.* labels | |||
| 396 | + | if v, ok := config.Config.Labels[domain.LabelDomain]; ok { | |||
| 397 | + | labels.Domain = strings.TrimSpace(v) | |||
| 398 | + | } | |||
| 399 | + | ||||
| 400 | + | if v, ok := config.Config.Labels[domain.LabelDomains]; ok { | |||
| 401 | + | // Parse comma-separated domains | |||
| 402 | + | for _, d := range strings.Split(v, ",") { | |||
| 403 | + | d = strings.TrimSpace(d) | |||
| 404 | + | if d != "" { | |||
| 405 | + | labels.Domains = append(labels.Domains, d) | |||
| 406 | + | } | |||
| 407 | + | } | |||
| 408 | + | } | |||
| 409 | + | ||||
| 410 | + | if v, ok := config.Config.Labels[domain.LabelHealth]; ok { | |||
| 411 | + | labels.Health = strings.TrimSpace(v) | |||
| 412 | + | } | |||
| 413 | + | ||||
| 414 | + | if v, ok := config.Config.Labels[domain.LabelPort]; ok { | |||
| 415 | + | labels.Port = strings.TrimSpace(v) | |||
| 416 | + | } | |||
| 417 | + | ||||
| 418 | + | if v, ok := config.Config.Labels[domain.LabelEnvFile]; ok { | |||
| 419 | + | labels.EnvFile = strings.TrimSpace(v) | |||
| 420 | + | } | |||
| 421 | + | ||||
| 422 | + | return labels, nil | |||
| 423 | + | } | |||
| 424 | + | ||||
| 425 | + | // parseEnvFile parses an env file into a map of key-value pairs. | |||
| 426 | + | func parseEnvFile(data []byte) (map[string]string, error) { | |||
| 427 | + | env := make(map[string]string) | |||
| 428 | + | ||||
| 429 | + | scanner := bufio.NewScanner(bytes.NewReader(data)) | |||
| 430 | + | for scanner.Scan() { | |||
| 431 | + | line := strings.TrimSpace(scanner.Text()) | |||
| 432 | + | ||||
| 433 | + | // Skip empty lines and comments | |||
| 434 | + | if line == "" || strings.HasPrefix(line, "#") { | |||
| 435 | + | continue | |||
| 436 | + | } | |||
| 437 | + | ||||
| 438 | + | // Parse KEY=value | |||
| 439 | + | parts := strings.SplitN(line, "=", 2) | |||
| 440 | + | if len(parts) != 2 { | |||
| 441 | + | continue | |||
| 442 | + | } | |||
| 443 | + | ||||
| 444 | + | key := strings.TrimSpace(parts[0]) | |||
| 445 | + | value := strings.TrimSpace(parts[1]) | |||
| 446 | + | ||||
| 447 | + | // Remove surrounding quotes if present | |||
| 448 | + | if len(value) >= 2 { | |||
| 449 | + | if (value[0] == '"' && value[len(value)-1] == '"') || | |||
| 450 | + | (value[0] == '\'' && value[len(value)-1] == '\'') { | |||
| 451 | + | value = value[1 : len(value)-1] | |||
| 452 | + | } | |||
| 453 | + | } | |||
| 454 | + | ||||
| 455 | + | if key != "" { | |||
| 456 | + | env[key] = value | |||
| 457 | + | } | |||
| 458 | + | } | |||
| 459 | + | ||||
| 460 | + | if err := scanner.Err(); err != nil { | |||
| 461 | + | return nil, err | |||
| 462 | + | } | |||
| 463 | + | ||||
| 464 | + | return env, nil | |||
| 465 | + | } | |||
| 466 | + | ||||
| 467 | + | // domainToEnvFileName converts a domain to an env file name. | |||
| 468 | + | // e.g., "app.mydomain.com" -> "app_mydomain_com.env" | |||
| 469 | + | // Must match the naming convention in envloader.FileLoader.getEnvFilePath | |||
| 470 | + | func domainToEnvFileName(domainName string) string { | |||
| 471 | + | // Replace special characters with underscores (matches envloader) | |||
| 472 | + | name := strings.ReplaceAll(domainName, ".", "_") | |||
| 473 | + | name = strings.ReplaceAll(name, ":", "_") | |||
| 474 | + | name = strings.ReplaceAll(name, "/", "_") | |||
| 475 | + | return name + ".env" | |||
| 476 | + | } | |||
| 477 | + | ||||
| 478 | + | // writeEnvFile writes environment variables to a file. | |||
| 479 | + | func writeEnvFile(path string, env map[string]string) error { | |||
| 480 | + | var buf bytes.Buffer | |||
| 481 | + | ||||
| 482 | + | // Sort keys for consistent output | |||
| 483 | + | keys := make([]string, 0, len(env)) | |||
| 484 | + | for k := range env { | |||
| 485 | + | keys = append(keys, k) | |||
| 486 | + | } | |||
| 487 | + | sort.Strings(keys) | |||
| 488 | + | ||||
| 489 | + | // Write each key-value pair | |||
| 490 | + | for _, k := range keys { | |||
| 491 | + | v := env[k] | |||
| 492 | + | // Quote values that contain special characters | |||
| 493 | + | if strings.ContainsAny(v, " \t\n\"'$\\") { | |||
| 494 | + | v = fmt.Sprintf("\"%s\"", strings.ReplaceAll(v, "\"", "\\\"")) | |||
| 495 | + | } | |||
| 496 | + | buf.WriteString(fmt.Sprintf("%s=%s\n", k, v)) | |||
| 497 | + | } | |||
| 498 | + | ||||
| 499 | + | return os.WriteFile(path, buf.Bytes(), 0600) | |||
| 500 | + | } | |||
A internal/usecase/container/autoroute_test.go +888 -0
| @@ -0,0 +1,888 @@ | |||||
| 1 | + | package container | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "io" | |||
| 7 | + | "strings" | |||
| 8 | + | "testing" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/zerowrap" | |||
| 11 | + | "github.com/stretchr/testify/assert" | |||
| 12 | + | "github.com/stretchr/testify/mock" | |||
| 13 | + | "github.com/stretchr/testify/require" | |||
| 14 | + | ||||
| 15 | + | inmocks "gordon/internal/boundaries/in/mocks" | |||
| 16 | + | "gordon/internal/boundaries/out/mocks" | |||
| 17 | + | "gordon/internal/domain" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | // domainToEnvFileName tests | |||
| 21 | + | ||||
| 22 | + | func TestDomainToEnvFileName(t *testing.T) { | |||
| 23 | + | tests := []struct { | |||
| 24 | + | name string | |||
| 25 | + | domain string | |||
| 26 | + | expected string | |||
| 27 | + | }{ | |||
| 28 | + | { | |||
| 29 | + | name: "simple domain", | |||
| 30 | + | domain: "app.example.com", | |||
| 31 | + | expected: "app_example_com.env", | |||
| 32 | + | }, | |||
| 33 | + | { | |||
| 34 | + | name: "domain with port", | |||
| 35 | + | domain: "app.example.com:8080", | |||
| 36 | + | expected: "app_example_com_8080.env", | |||
| 37 | + | }, | |||
| 38 | + | { | |||
| 39 | + | name: "domain with path", | |||
| 40 | + | domain: "app.example.com/api", | |||
| 41 | + | expected: "app_example_com_api.env", | |||
| 42 | + | }, | |||
| 43 | + | { | |||
| 44 | + | name: "complex domain", | |||
| 45 | + | domain: "sub.app.example.com:443/v1", | |||
| 46 | + | expected: "sub_app_example_com_443_v1.env", | |||
| 47 | + | }, | |||
| 48 | + | { | |||
| 49 | + | name: "single word", | |||
| 50 | + | domain: "localhost", | |||
| 51 | + | expected: "localhost.env", | |||
| 52 | + | }, | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | for _, tt := range tests { | |||
| 56 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 57 | + | result := domainToEnvFileName(tt.domain) | |||
| 58 | + | assert.Equal(t, tt.expected, result) | |||
| 59 | + | }) | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | // parseEnvFile tests | |||
| 64 | + | ||||
| 65 | + | func TestParseEnvFile(t *testing.T) { | |||
| 66 | + | tests := []struct { | |||
| 67 | + | name string | |||
| 68 | + | input string | |||
| 69 | + | expected map[string]string | |||
| 70 | + | wantErr bool | |||
| 71 | + | }{ | |||
| 72 | + | { | |||
| 73 | + | name: "simple key-value pairs", | |||
| 74 | + | input: "FOO=bar\nBAZ=qux", | |||
| 75 | + | expected: map[string]string{ | |||
| 76 | + | "FOO": "bar", | |||
| 77 | + | "BAZ": "qux", | |||
| 78 | + | }, | |||
| 79 | + | }, | |||
| 80 | + | { | |||
| 81 | + | name: "with comments and empty lines", | |||
| 82 | + | input: "# This is a comment\nFOO=bar\n\n# Another comment\nBAZ=qux\n", | |||
| 83 | + | expected: map[string]string{ | |||
| 84 | + | "FOO": "bar", | |||
| 85 | + | "BAZ": "qux", | |||
| 86 | + | }, | |||
| 87 | + | }, | |||
| 88 | + | { | |||
| 89 | + | name: "with quoted values - double quotes", | |||
| 90 | + | input: `FOO="bar baz"`, | |||
| 91 | + | expected: map[string]string{ | |||
| 92 | + | "FOO": "bar baz", | |||
| 93 | + | }, | |||
| 94 | + | }, | |||
| 95 | + | { | |||
| 96 | + | name: "with quoted values - single quotes", | |||
| 97 | + | input: `FOO='bar baz'`, | |||
| 98 | + | expected: map[string]string{ | |||
| 99 | + | "FOO": "bar baz", | |||
| 100 | + | }, | |||
| 101 | + | }, | |||
| 102 | + | { | |||
| 103 | + | name: "value with equals sign", | |||
| 104 | + | input: "DATABASE_URL=postgres://user:pass@host/db?option=value", | |||
| 105 | + | expected: map[string]string{ | |||
| 106 | + | "DATABASE_URL": "postgres://user:pass@host/db?option=value", | |||
| 107 | + | }, | |||
| 108 | + | }, | |||
| 109 | + | { | |||
| 110 | + | name: "empty value", | |||
| 111 | + | input: "EMPTY=", | |||
| 112 | + | expected: map[string]string{ | |||
| 113 | + | "EMPTY": "", | |||
| 114 | + | }, | |||
| 115 | + | }, | |||
| 116 | + | { | |||
| 117 | + | name: "empty file", | |||
| 118 | + | input: "", | |||
| 119 | + | expected: map[string]string{}, | |||
| 120 | + | }, | |||
| 121 | + | { | |||
| 122 | + | name: "only comments", | |||
| 123 | + | input: "# comment 1\n# comment 2", | |||
| 124 | + | expected: map[string]string{}, | |||
| 125 | + | }, | |||
| 126 | + | { | |||
| 127 | + | name: "malformed line without equals", | |||
| 128 | + | input: "MALFORMED\nGOOD=value", | |||
| 129 | + | expected: map[string]string{"GOOD": "value"}, | |||
| 130 | + | }, | |||
| 131 | + | { | |||
| 132 | + | name: "whitespace trimming", | |||
| 133 | + | input: " FOO = bar \n BAZ=qux", | |||
| 134 | + | expected: map[string]string{ | |||
| 135 | + | "FOO": "bar", | |||
| 136 | + | "BAZ": "qux", | |||
| 137 | + | }, | |||
| 138 | + | }, | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | for _, tt := range tests { | |||
| 142 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 143 | + | result, err := parseEnvFile([]byte(tt.input)) | |||
| 144 | + | ||||
| 145 | + | if tt.wantErr { | |||
| 146 | + | assert.Error(t, err) | |||
| 147 | + | return | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | require.NoError(t, err) | |||
| 151 | + | assert.Equal(t, tt.expected, result) | |||
| 152 | + | }) | |||
| 153 | + | } | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | // parseConfigDigest tests | |||
| 157 | + | ||||
| 158 | + | func TestParseConfigDigest(t *testing.T) { | |||
| 159 | + | tests := []struct { | |||
| 160 | + | name string | |||
| 161 | + | manifest map[string]any | |||
| 162 | + | expected string | |||
| 163 | + | wantErr bool | |||
| 164 | + | }{ | |||
| 165 | + | { | |||
| 166 | + | name: "valid manifest v2", | |||
| 167 | + | manifest: map[string]any{ | |||
| 168 | + | "schemaVersion": 2, | |||
| 169 | + | "mediaType": "application/vnd.docker.distribution.manifest.v2+json", | |||
| 170 | + | "config": map[string]any{ | |||
| 171 | + | "mediaType": "application/vnd.docker.container.image.v1+json", | |||
| 172 | + | "digest": "sha256:abc123", | |||
| 173 | + | "size": 1234, | |||
| 174 | + | }, | |||
| 175 | + | }, | |||
| 176 | + | expected: "sha256:abc123", | |||
| 177 | + | }, | |||
| 178 | + | { | |||
| 179 | + | name: "OCI manifest", | |||
| 180 | + | manifest: map[string]any{ | |||
| 181 | + | "schemaVersion": 2, | |||
| 182 | + | "mediaType": "application/vnd.oci.image.manifest.v1+json", | |||
| 183 | + | "config": map[string]any{ | |||
| 184 | + | "mediaType": "application/vnd.oci.image.config.v1+json", | |||
| 185 | + | "digest": "sha256:def456", | |||
| 186 | + | "size": 5678, | |||
| 187 | + | }, | |||
| 188 | + | }, | |||
| 189 | + | expected: "sha256:def456", | |||
| 190 | + | }, | |||
| 191 | + | { | |||
| 192 | + | name: "empty config digest", | |||
| 193 | + | manifest: map[string]any{ | |||
| 194 | + | "schemaVersion": 2, | |||
| 195 | + | "config": map[string]any{ | |||
| 196 | + | "digest": "", | |||
| 197 | + | }, | |||
| 198 | + | }, | |||
| 199 | + | expected: "", | |||
| 200 | + | }, | |||
| 201 | + | } | |||
| 202 | + | ||||
| 203 | + | for _, tt := range tests { | |||
| 204 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 205 | + | data, err := json.Marshal(tt.manifest) | |||
| 206 | + | require.NoError(t, err) | |||
| 207 | + | ||||
| 208 | + | result, err := parseConfigDigest(data) | |||
| 209 | + | ||||
| 210 | + | if tt.wantErr { | |||
| 211 | + | assert.Error(t, err) | |||
| 212 | + | return | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | require.NoError(t, err) | |||
| 216 | + | assert.Equal(t, tt.expected, result) | |||
| 217 | + | }) | |||
| 218 | + | } | |||
| 219 | + | } | |||
| 220 | + | ||||
| 221 | + | func TestParseConfigDigest_InvalidJSON(t *testing.T) { | |||
| 222 | + | _, err := parseConfigDigest([]byte("invalid json")) | |||
| 223 | + | assert.Error(t, err) | |||
| 224 | + | } | |||
| 225 | + | ||||
| 226 | + | // parseImageLabels tests | |||
| 227 | + | ||||
| 228 | + | func TestParseImageLabels(t *testing.T) { | |||
| 229 | + | tests := []struct { | |||
| 230 | + | name string | |||
| 231 | + | config map[string]any | |||
| 232 | + | expected *domain.ImageLabels | |||
| 233 | + | }{ | |||
| 234 | + | { | |||
| 235 | + | name: "single domain label", | |||
| 236 | + | config: map[string]any{ | |||
| 237 | + | "config": map[string]any{ | |||
| 238 | + | "Labels": map[string]any{ | |||
| 239 | + | "gordon.domain": "app.example.com", | |||
| 240 | + | }, | |||
| 241 | + | }, | |||
| 242 | + | }, | |||
| 243 | + | expected: &domain.ImageLabels{ | |||
| 244 | + | Domain: "app.example.com", | |||
| 245 | + | }, | |||
| 246 | + | }, | |||
| 247 | + | { | |||
| 248 | + | name: "multiple domains label", | |||
| 249 | + | config: map[string]any{ | |||
| 250 | + | "config": map[string]any{ | |||
| 251 | + | "Labels": map[string]any{ | |||
| 252 | + | "gordon.domains": "app1.example.com, app2.example.com", | |||
| 253 | + | }, | |||
| 254 | + | }, | |||
| 255 | + | }, | |||
| 256 | + | expected: &domain.ImageLabels{ | |||
| 257 | + | Domains: []string{"app1.example.com", "app2.example.com"}, | |||
| 258 | + | }, | |||
| 259 | + | }, | |||
| 260 | + | { | |||
| 261 | + | name: "all gordon labels", | |||
| 262 | + | config: map[string]any{ | |||
| 263 | + | "config": map[string]any{ | |||
| 264 | + | "Labels": map[string]any{ | |||
| 265 | + | "gordon.domain": "app.example.com", | |||
| 266 | + | "gordon.health": "/healthz", | |||
| 267 | + | "gordon.port": "8080", | |||
| 268 | + | "gordon.env-file": ".env.prod", | |||
| 269 | + | }, | |||
| 270 | + | }, | |||
| 271 | + | }, | |||
| 272 | + | expected: &domain.ImageLabels{ | |||
| 273 | + | Domain: "app.example.com", | |||
| 274 | + | Health: "/healthz", | |||
| 275 | + | Port: "8080", | |||
| 276 | + | EnvFile: ".env.prod", | |||
| 277 | + | }, | |||
| 278 | + | }, | |||
| 279 | + | { | |||
| 280 | + | name: "no labels", | |||
| 281 | + | config: map[string]any{ | |||
| 282 | + | "config": map[string]any{ | |||
| 283 | + | "Labels": nil, | |||
| 284 | + | }, | |||
| 285 | + | }, | |||
| 286 | + | expected: &domain.ImageLabels{}, | |||
| 287 | + | }, | |||
| 288 | + | { | |||
| 289 | + | name: "no gordon labels", | |||
| 290 | + | config: map[string]any{ | |||
| 291 | + | "config": map[string]any{ | |||
| 292 | + | "Labels": map[string]any{ | |||
| 293 | + | "other.label": "value", | |||
| 294 | + | }, | |||
| 295 | + | }, | |||
| 296 | + | }, | |||
| 297 | + | expected: &domain.ImageLabels{}, | |||
| 298 | + | }, | |||
| 299 | + | { | |||
| 300 | + | name: "whitespace in labels", | |||
| 301 | + | config: map[string]any{ | |||
| 302 | + | "config": map[string]any{ | |||
| 303 | + | "Labels": map[string]any{ | |||
| 304 | + | "gordon.domain": " app.example.com ", | |||
| 305 | + | }, | |||
| 306 | + | }, | |||
| 307 | + | }, | |||
| 308 | + | expected: &domain.ImageLabels{ | |||
| 309 | + | Domain: "app.example.com", | |||
| 310 | + | }, | |||
| 311 | + | }, | |||
| 312 | + | } | |||
| 313 | + | ||||
| 314 | + | for _, tt := range tests { | |||
| 315 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 316 | + | data, err := json.Marshal(tt.config) | |||
| 317 | + | require.NoError(t, err) | |||
| 318 | + | ||||
| 319 | + | result, err := parseImageLabels(data) | |||
| 320 | + | require.NoError(t, err) | |||
| 321 | + | ||||
| 322 | + | assert.Equal(t, tt.expected.Domain, result.Domain) | |||
| 323 | + | assert.Equal(t, tt.expected.Domains, result.Domains) | |||
| 324 | + | assert.Equal(t, tt.expected.Health, result.Health) | |||
| 325 | + | assert.Equal(t, tt.expected.Port, result.Port) | |||
| 326 | + | assert.Equal(t, tt.expected.EnvFile, result.EnvFile) | |||
| 327 | + | }) | |||
| 328 | + | } | |||
| 329 | + | } | |||
| 330 | + | ||||
| 331 | + | func TestParseImageLabels_InvalidJSON(t *testing.T) { | |||
| 332 | + | _, err := parseImageLabels([]byte("invalid json")) | |||
| 333 | + | assert.Error(t, err) | |||
| 334 | + | } | |||
| 335 | + | ||||
| 336 | + | // AutoRouteHandler tests | |||
| 337 | + | ||||
| 338 | + | func TestAutoRouteHandler_CanHandle(t *testing.T) { | |||
| 339 | + | ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default()) | |||
| 340 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 341 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 342 | + | blobStorage := mocks.NewMockBlobStorage(t) | |||
| 343 | + | ||||
| 344 | + | handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com") | |||
| 345 | + | ||||
| 346 | + | assert.True(t, handler.CanHandle(domain.EventImagePushed)) | |||
| 347 | + | assert.False(t, handler.CanHandle(domain.EventConfigReload)) | |||
| 348 | + | assert.False(t, handler.CanHandle(domain.EventManualReload)) | |||
| 349 | + | } | |||
| 350 | + | ||||
| 351 | + | func TestAutoRouteHandler_Handle_DisabledAutoRoute(t *testing.T) { | |||
| 352 | + | ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default()) | |||
| 353 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 354 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 355 | + | blobStorage := mocks.NewMockBlobStorage(t) | |||
| 356 | + | ||||
| 357 | + | handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com") | |||
| 358 | + | ||||
| 359 | + | configSvc.EXPECT().IsAutoRouteEnabled().Return(false) | |||
| 360 | + | ||||
| 361 | + | event := domain.Event{ | |||
| 362 | + | ID: "event-123", | |||
| 363 | + | Type: domain.EventImagePushed, | |||
| 364 | + | Data: domain.ImagePushedPayload{ | |||
| 365 | + | Name: "myapp", | |||
| 366 | + | Reference: "latest", | |||
| 367 | + | Manifest: []byte(`{"schemaVersion": 2}`), | |||
| 368 | + | }, | |||
| 369 | + | } | |||
| 370 | + | ||||
| 371 | + | err := handler.Handle(event) | |||
| 372 | + | ||||
| 373 | + | assert.NoError(t, err) | |||
| 374 | + | } | |||
| 375 | + | ||||
| 376 | + | func TestAutoRouteHandler_Handle_InvalidPayload(t *testing.T) { | |||
| 377 | + | ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default()) | |||
| 378 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 379 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 380 | + | blobStorage := mocks.NewMockBlobStorage(t) | |||
| 381 | + | ||||
| 382 | + | handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com") | |||
| 383 | + | ||||
| 384 | + | configSvc.EXPECT().IsAutoRouteEnabled().Return(true) | |||
| 385 | + | ||||
| 386 | + | event := domain.Event{ | |||
| 387 | + | ID: "event-123", | |||
| 388 | + | Type: domain.EventImagePushed, | |||
| 389 | + | Data: "invalid payload type", | |||
| 390 | + | } | |||
| 391 | + | ||||
| 392 | + | err := handler.Handle(event) | |||
| 393 | + | ||||
| 394 | + | // Handler skips invalid payload, doesn't error | |||
| 395 | + | assert.NoError(t, err) | |||
| 396 | + | } | |||
| 397 | + | ||||
| 398 | + | func TestAutoRouteHandler_Handle_EmptyManifest(t *testing.T) { | |||
| 399 | + | ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default()) | |||
| 400 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 401 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 402 | + | blobStorage := mocks.NewMockBlobStorage(t) | |||
| 403 | + | ||||
| 404 | + | handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com") | |||
| 405 | + | ||||
| 406 | + | configSvc.EXPECT().IsAutoRouteEnabled().Return(true) | |||
| 407 | + | ||||
| 408 | + | event := domain.Event{ | |||
| 409 | + | ID: "event-123", | |||
| 410 | + | Type: domain.EventImagePushed, | |||
| 411 | + | Data: domain.ImagePushedPayload{ | |||
| 412 | + | Name: "myapp", | |||
| 413 | + | Reference: "latest", | |||
| 414 | + | Manifest: []byte{}, | |||
| 415 | + | }, | |||
| 416 | + | } | |||
| 417 | + | ||||
| 418 | + | err := handler.Handle(event) | |||
| 419 | + | ||||
| 420 | + | assert.NoError(t, err) | |||
| 421 | + | } | |||
| 422 | + | ||||
| 423 | + | func TestAutoRouteHandler_Handle_CreatesNewRoute(t *testing.T) { | |||
| 424 | + | ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default()) | |||
| 425 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 426 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 427 | + | blobStorage := mocks.NewMockBlobStorage(t) | |||
| 428 | + | ||||
| 429 | + | handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com") | |||
| 430 | + | ||||
| 431 | + | // Build manifest with config digest | |||
| 432 | + | manifest := map[string]any{ | |||
| 433 | + | "schemaVersion": 2, | |||
| 434 | + | "config": map[string]any{ | |||
| 435 | + | "digest": "sha256:configdigest123", | |||
| 436 | + | }, | |||
| 437 | + | } | |||
| 438 | + | manifestData, _ := json.Marshal(manifest) | |||
| 439 | + | ||||
| 440 | + | // Build image config with gordon labels | |||
| 441 | + | imageConfig := map[string]any{ | |||
| 442 | + | "config": map[string]any{ | |||
| 443 | + | "Labels": map[string]any{ | |||
| 444 | + | "gordon.domain": "app.example.com", | |||
| 445 | + | }, | |||
| 446 | + | }, | |||
| 447 | + | } | |||
| 448 | + | configData, _ := json.Marshal(imageConfig) | |||
| 449 | + | ||||
| 450 | + | configSvc.EXPECT().IsAutoRouteEnabled().Return(true) | |||
| 451 | + | blobStorage.EXPECT().GetBlob("sha256:configdigest123").Return(io.NopCloser(strings.NewReader(string(configData))), nil) | |||
| 452 | + | ||||
| 453 | + | // No existing routes | |||
| 454 | + | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}) | |||
| 455 | + | ||||
| 456 | + | // Expect route to be added | |||
| 457 | + | configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{ | |||
| 458 | + | Domain: "app.example.com", | |||
| 459 | + | Image: "myapp:latest", | |||
| 460 | + | }).Return(nil) | |||
| 461 | + | ||||
| 462 | + | // Expect deploy to be triggered | |||
| 463 | + | containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{ | |||
| 464 | + | Domain: "app.example.com", | |||
| 465 | + | Image: "myapp:latest", | |||
| 466 | + | }).Return(&domain.Container{ID: "container-1"}, nil) | |||
| 467 | + | ||||
| 468 | + | event := domain.Event{ | |||
| 469 | + | ID: "event-123", | |||
| 470 | + | Type: domain.EventImagePushed, | |||
| 471 | + | Data: domain.ImagePushedPayload{ | |||
| 472 | + | Name: "myapp", | |||
| 473 | + | Reference: "latest", | |||
| 474 | + | Manifest: manifestData, | |||
| 475 | + | }, | |||
| 476 | + | } | |||
| 477 | + | ||||
| 478 | + | err := handler.Handle(event) | |||
| 479 | + | ||||
| 480 | + | assert.NoError(t, err) | |||
| 481 | + | } | |||
| 482 | + | ||||
| 483 | + | func TestAutoRouteHandler_Handle_UpdatesExistingRoute(t *testing.T) { | |||
| 484 | + | ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default()) | |||
| 485 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 486 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 487 | + | blobStorage := mocks.NewMockBlobStorage(t) | |||
| 488 | + | ||||
| 489 | + | handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com") | |||
| 490 | + | ||||
| 491 | + | // Build manifest with config digest | |||
| 492 | + | manifest := map[string]any{ | |||
| 493 | + | "schemaVersion": 2, | |||
| 494 | + | "config": map[string]any{ | |||
| 495 | + | "digest": "sha256:configdigest123", | |||
| 496 | + | }, | |||
| 497 | + | } | |||
| 498 | + | manifestData, _ := json.Marshal(manifest) | |||
| 499 | + | ||||
| 500 | + | // Build image config with gordon labels | |||
| 501 | + | imageConfig := map[string]any{ | |||
| 502 | + | "config": map[string]any{ | |||
| 503 | + | "Labels": map[string]any{ | |||
| 504 | + | "gordon.domain": "app.example.com", | |||
| 505 | + | }, | |||
| 506 | + | }, | |||
| 507 | + | } | |||
| 508 | + | configData, _ := json.Marshal(imageConfig) | |||
| 509 | + | ||||
| 510 | + | configSvc.EXPECT().IsAutoRouteEnabled().Return(true) | |||
| 511 | + | blobStorage.EXPECT().GetBlob("sha256:configdigest123").Return(io.NopCloser(strings.NewReader(string(configData))), nil) | |||
| 512 | + | ||||
| 513 | + | // Existing route with different image | |||
| 514 | + | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{ | |||
| 515 | + | {Domain: "app.example.com", Image: "myapp:v1"}, | |||
| 516 | + | }) | |||
| 517 | + | ||||
| 518 | + | // Expect route to be updated | |||
| 519 | + | configSvc.EXPECT().UpdateRoute(mock.Anything, domain.Route{ | |||
| 520 | + | Domain: "app.example.com", | |||
| 521 | + | Image: "myapp:v2", | |||
| 522 | + | }).Return(nil) | |||
| 523 | + | ||||
| 524 | + | // Expect deploy to be triggered | |||
| 525 | + | containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{ | |||
| 526 | + | Domain: "app.example.com", | |||
| 527 | + | Image: "myapp:v2", | |||
| 528 | + | }).Return(&domain.Container{ID: "container-1"}, nil) | |||
| 529 | + | ||||
| 530 | + | event := domain.Event{ | |||
| 531 | + | ID: "event-123", | |||
| 532 | + | Type: domain.EventImagePushed, | |||
| 533 | + | Data: domain.ImagePushedPayload{ | |||
| 534 | + | Name: "myapp", | |||
| 535 | + | Reference: "v2", | |||
| 536 | + | Manifest: manifestData, | |||
| 537 | + | }, | |||
| 538 | + | } | |||
| 539 | + | ||||
| 540 | + | err := handler.Handle(event) | |||
| 541 | + | ||||
| 542 | + | assert.NoError(t, err) | |||
| 543 | + | } | |||
| 544 | + | ||||
| 545 | + | func TestAutoRouteHandler_Handle_NoUpdateIfSameImage(t *testing.T) { | |||
| 546 | + | ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default()) | |||
| 547 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 548 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 549 | + | blobStorage := mocks.NewMockBlobStorage(t) | |||
| 550 | + | ||||
| 551 | + | handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com") | |||
| 552 | + | ||||
| 553 | + | // Build manifest with config digest | |||
| 554 | + | manifest := map[string]any{ | |||
| 555 | + | "schemaVersion": 2, | |||
| 556 | + | "config": map[string]any{ | |||
| 557 | + | "digest": "sha256:configdigest123", | |||
| 558 | + | }, | |||
| 559 | + | } | |||
| 560 | + | manifestData, _ := json.Marshal(manifest) | |||
| 561 | + | ||||
| 562 | + | // Build image config with gordon labels | |||
| 563 | + | imageConfig := map[string]any{ | |||
| 564 | + | "config": map[string]any{ | |||
| 565 | + | "Labels": map[string]any{ | |||
| 566 | + | "gordon.domain": "app.example.com", | |||
| 567 | + | }, | |||
| 568 | + | }, | |||
| 569 | + | } | |||
| 570 | + | configData, _ := json.Marshal(imageConfig) | |||
| 571 | + | ||||
| 572 | + | configSvc.EXPECT().IsAutoRouteEnabled().Return(true) | |||
| 573 | + | blobStorage.EXPECT().GetBlob("sha256:configdigest123").Return(io.NopCloser(strings.NewReader(string(configData))), nil) | |||
| 574 | + | ||||
| 575 | + | // Existing route with same image - no update needed | |||
| 576 | + | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{ | |||
| 577 | + | {Domain: "app.example.com", Image: "myapp:latest"}, | |||
| 578 | + | }) | |||
| 579 | + | ||||
| 580 | + | // No AddRoute, UpdateRoute, or Deploy calls expected | |||
| 581 | + | ||||
| 582 | + | event := domain.Event{ | |||
| 583 | + | ID: "event-123", | |||
| 584 | + | Type: domain.EventImagePushed, | |||
| 585 | + | Data: domain.ImagePushedPayload{ | |||
| 586 | + | Name: "myapp", | |||
| 587 | + | Reference: "latest", | |||
| 588 | + | Manifest: manifestData, | |||
| 589 | + | }, | |||
| 590 | + | } | |||
| 591 | + | ||||
| 592 | + | err := handler.Handle(event) | |||
| 593 | + | ||||
| 594 | + | assert.NoError(t, err) | |||
| 595 | + | } | |||
| 596 | + | ||||
| 597 | + | func TestAutoRouteHandler_Handle_NoDomainLabel(t *testing.T) { | |||
| 598 | + | ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default()) | |||
| 599 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 600 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 601 | + | blobStorage := mocks.NewMockBlobStorage(t) | |||
| 602 | + | ||||
| 603 | + | handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com") | |||
| 604 | + | ||||
| 605 | + | // Build manifest with config digest | |||
| 606 | + | manifest := map[string]any{ | |||
| 607 | + | "schemaVersion": 2, | |||
| 608 | + | "config": map[string]any{ | |||
| 609 | + | "digest": "sha256:configdigest123", | |||
| 610 | + | }, | |||
| 611 | + | } | |||
| 612 | + | manifestData, _ := json.Marshal(manifest) | |||
| 613 | + | ||||
| 614 | + | // Build image config with no gordon labels | |||
| 615 | + | imageConfig := map[string]any{ | |||
| 616 | + | "config": map[string]any{ | |||
| 617 | + | "Labels": map[string]any{ | |||
| 618 | + | "other.label": "value", | |||
| 619 | + | }, | |||
| 620 | + | }, | |||
| 621 | + | } | |||
| 622 | + | configData, _ := json.Marshal(imageConfig) | |||
| 623 | + | ||||
| 624 | + | configSvc.EXPECT().IsAutoRouteEnabled().Return(true) | |||
| 625 | + | blobStorage.EXPECT().GetBlob("sha256:configdigest123").Return(io.NopCloser(strings.NewReader(string(configData))), nil) | |||
| 626 | + | ||||
| 627 | + | // No route operations expected | |||
| 628 | + | ||||
| 629 | + | event := domain.Event{ | |||
| 630 | + | ID: "event-123", | |||
| 631 | + | Type: domain.EventImagePushed, | |||
| 632 | + | Data: domain.ImagePushedPayload{ | |||
| 633 | + | Name: "myapp", | |||
| 634 | + | Reference: "latest", | |||
| 635 | + | Manifest: manifestData, | |||
| 636 | + | }, | |||
| 637 | + | } | |||
| 638 | + | ||||
| 639 | + | err := handler.Handle(event) | |||
| 640 | + | ||||
| 641 | + | assert.NoError(t, err) | |||
| 642 | + | } | |||
| 643 | + | ||||
| 644 | + | func TestAutoRouteHandler_Handle_MultipleDomains(t *testing.T) { | |||
| 645 | + | ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default()) | |||
| 646 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 647 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 648 | + | blobStorage := mocks.NewMockBlobStorage(t) | |||
| 649 | + | ||||
| 650 | + | handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com") | |||
| 651 | + | ||||
| 652 | + | // Build manifest with config digest | |||
| 653 | + | manifest := map[string]any{ | |||
| 654 | + | "schemaVersion": 2, | |||
| 655 | + | "config": map[string]any{ | |||
| 656 | + | "digest": "sha256:configdigest123", | |||
| 657 | + | }, | |||
| 658 | + | } | |||
| 659 | + | manifestData, _ := json.Marshal(manifest) | |||
| 660 | + | ||||
| 661 | + | // Build image config with multiple domains | |||
| 662 | + | imageConfig := map[string]any{ | |||
| 663 | + | "config": map[string]any{ | |||
| 664 | + | "Labels": map[string]any{ | |||
| 665 | + | "gordon.domain": "app.example.com", | |||
| 666 | + | "gordon.domains": "api.example.com, www.example.com", | |||
| 667 | + | }, | |||
| 668 | + | }, | |||
| 669 | + | } | |||
| 670 | + | configData, _ := json.Marshal(imageConfig) | |||
| 671 | + | ||||
| 672 | + | configSvc.EXPECT().IsAutoRouteEnabled().Return(true) | |||
| 673 | + | blobStorage.EXPECT().GetBlob("sha256:configdigest123").Return(io.NopCloser(strings.NewReader(string(configData))), nil) | |||
| 674 | + | ||||
| 675 | + | // No existing routes | |||
| 676 | + | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}).Times(3) | |||
| 677 | + | ||||
| 678 | + | // Expect routes to be added for all three domains | |||
| 679 | + | configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{ | |||
| 680 | + | Domain: "app.example.com", | |||
| 681 | + | Image: "myapp:latest", | |||
| 682 | + | }).Return(nil) | |||
| 683 | + | configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{ | |||
| 684 | + | Domain: "api.example.com", | |||
| 685 | + | Image: "myapp:latest", | |||
| 686 | + | }).Return(nil) | |||
| 687 | + | configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{ | |||
| 688 | + | Domain: "www.example.com", | |||
| 689 | + | Image: "myapp:latest", | |||
| 690 | + | }).Return(nil) | |||
| 691 | + | ||||
| 692 | + | // Expect deploy for all three | |||
| 693 | + | containerSvc.EXPECT().Deploy(mock.Anything, mock.AnythingOfType("domain.Route")).Return(&domain.Container{ID: "container-1"}, nil).Times(3) | |||
| 694 | + | ||||
| 695 | + | event := domain.Event{ | |||
| 696 | + | ID: "event-123", | |||
| 697 | + | Type: domain.EventImagePushed, | |||
| 698 | + | Data: domain.ImagePushedPayload{ | |||
| 699 | + | Name: "myapp", | |||
| 700 | + | Reference: "latest", | |||
| 701 | + | Manifest: manifestData, | |||
| 702 | + | }, | |||
| 703 | + | } | |||
| 704 | + | ||||
| 705 | + | err := handler.Handle(event) | |||
| 706 | + | ||||
| 707 | + | assert.NoError(t, err) | |||
| 708 | + | } | |||
| 709 | + | ||||
| 710 | + | func TestAutoRouteHandler_TriggerDeploy_NilContainerService(t *testing.T) { | |||
| 711 | + | ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default()) | |||
| 712 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 713 | + | blobStorage := mocks.NewMockBlobStorage(t) | |||
| 714 | + | ||||
| 715 | + | // Create handler without container service | |||
| 716 | + | handler := NewAutoRouteHandler(ctx, configSvc, nil, blobStorage, "registry.example.com") | |||
| 717 | + | ||||
| 718 | + | route := domain.Route{ | |||
| 719 | + | Domain: "app.example.com", | |||
| 720 | + | Image: "myapp:latest", | |||
| 721 | + | } | |||
| 722 | + | ||||
| 723 | + | // Should not panic, just log and skip | |||
| 724 | + | handler.triggerDeploy(ctx, route) | |||
| 725 | + | } | |||
| 726 | + | ||||
| 727 | + | // collectDomains tests | |||
| 728 | + | ||||
| 729 | + | func TestAutoRouteHandler_CollectDomains(t *testing.T) { | |||
| 730 | + | ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default()) | |||
| 731 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 732 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 733 | + | blobStorage := mocks.NewMockBlobStorage(t) | |||
| 734 | + | ||||
| 735 | + | handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com") | |||
| 736 | + | ||||
| 737 | + | tests := []struct { | |||
| 738 | + | name string | |||
| 739 | + | labels *domain.ImageLabels | |||
| 740 | + | expected []string | |||
| 741 | + | }{ | |||
| 742 | + | { | |||
| 743 | + | name: "single domain", | |||
| 744 | + | labels: &domain.ImageLabels{ | |||
| 745 | + | Domain: "app.example.com", | |||
| 746 | + | }, | |||
| 747 | + | expected: []string{"app.example.com"}, | |||
| 748 | + | }, | |||
| 749 | + | { | |||
| 750 | + | name: "domains list only", | |||
| 751 | + | labels: &domain.ImageLabels{ | |||
| 752 | + | Domains: []string{"api.example.com", "www.example.com"}, | |||
| 753 | + | }, | |||
| 754 | + | expected: []string{"api.example.com", "www.example.com"}, | |||
| 755 | + | }, | |||
| 756 | + | { | |||
| 757 | + | name: "both domain and domains", | |||
| 758 | + | labels: &domain.ImageLabels{ | |||
| 759 | + | Domain: "app.example.com", | |||
| 760 | + | Domains: []string{"api.example.com"}, | |||
| 761 | + | }, | |||
| 762 | + | expected: []string{"app.example.com", "api.example.com"}, | |||
| 763 | + | }, | |||
| 764 | + | { | |||
| 765 | + | name: "empty labels", | |||
| 766 | + | labels: &domain.ImageLabels{}, | |||
| 767 | + | expected: nil, | |||
| 768 | + | }, | |||
| 769 | + | } | |||
| 770 | + | ||||
| 771 | + | for _, tt := range tests { | |||
| 772 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 773 | + | result := handler.collectDomains(tt.labels) | |||
| 774 | + | assert.Equal(t, tt.expected, result) | |||
| 775 | + | }) | |||
| 776 | + | } | |||
| 777 | + | } | |||
| 778 | + | ||||
| 779 | + | // buildImageName tests | |||
| 780 | + | ||||
| 781 | + | func TestAutoRouteHandler_BuildImageName(t *testing.T) { | |||
| 782 | + | ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default()) | |||
| 783 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 784 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 785 | + | blobStorage := mocks.NewMockBlobStorage(t) | |||
| 786 | + | ||||
| 787 | + | handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com") | |||
| 788 | + | ||||
| 789 | + | tests := []struct { | |||
| 790 | + | name string | |||
| 791 | + | imageName string | |||
| 792 | + | reference string | |||
| 793 | + | expected string | |||
| 794 | + | }{ | |||
| 795 | + | { | |||
| 796 | + | name: "with tag", | |||
| 797 | + | imageName: "myapp", | |||
| 798 | + | reference: "latest", | |||
| 799 | + | expected: "myapp:latest", | |||
| 800 | + | }, | |||
| 801 | + | { | |||
| 802 | + | name: "with version tag", | |||
| 803 | + | imageName: "myapp", | |||
| 804 | + | reference: "v1.2.3", | |||
| 805 | + | expected: "myapp:v1.2.3", | |||
| 806 | + | }, | |||
| 807 | + | { | |||
| 808 | + | name: "empty reference", | |||
| 809 | + | imageName: "myapp", | |||
| 810 | + | reference: "", | |||
| 811 | + | expected: "myapp", | |||
| 812 | + | }, | |||
| 813 | + | { | |||
| 814 | + | name: "with digest", | |||
| 815 | + | imageName: "myapp", | |||
| 816 | + | reference: "sha256:abc123", | |||
| 817 | + | expected: "myapp@sha256:abc123", | |||
| 818 | + | }, | |||
| 819 | + | } | |||
| 820 | + | ||||
| 821 | + | for _, tt := range tests { | |||
| 822 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 823 | + | result := handler.buildImageName(tt.imageName, tt.reference) | |||
| 824 | + | assert.Equal(t, tt.expected, result) | |||
| 825 | + | }) | |||
| 826 | + | } | |||
| 827 | + | } | |||
| 828 | + | ||||
| 829 | + | // buildFullImageRef tests | |||
| 830 | + | ||||
| 831 | + | func TestAutoRouteHandler_BuildFullImageRef(t *testing.T) { | |||
| 832 | + | ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default()) | |||
| 833 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 834 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 835 | + | blobStorage := mocks.NewMockBlobStorage(t) | |||
| 836 | + | ||||
| 837 | + | tests := []struct { | |||
| 838 | + | name string | |||
| 839 | + | registryDomain string | |||
| 840 | + | imageName string | |||
| 841 | + | expected string | |||
| 842 | + | }{ | |||
| 843 | + | { | |||
| 844 | + | name: "simple image with registry", | |||
| 845 | + | registryDomain: "registry.example.com", | |||
| 846 | + | imageName: "myapp:latest", | |||
| 847 | + | expected: "registry.example.com/myapp:latest", | |||
| 848 | + | }, | |||
| 849 | + | { | |||
| 850 | + | name: "already has registry prefix", | |||
| 851 | + | registryDomain: "registry.example.com", | |||
| 852 | + | imageName: "registry.example.com/myapp:latest", | |||
| 853 | + | expected: "registry.example.com/myapp:latest", | |||
| 854 | + | }, | |||
| 855 | + | { | |||
| 856 | + | name: "external registry reference", | |||
| 857 | + | registryDomain: "registry.example.com", | |||
| 858 | + | imageName: "docker.io/library/nginx:latest", | |||
| 859 | + | expected: "docker.io/library/nginx:latest", | |||
| 860 | + | }, | |||
| 861 | + | { | |||
| 862 | + | name: "digest reference", | |||
| 863 | + | registryDomain: "registry.example.com", | |||
| 864 | + | imageName: "myapp@sha256:abc123def456", | |||
| 865 | + | expected: "registry.example.com/myapp@sha256:abc123def456", | |||
| 866 | + | }, | |||
| 867 | + | { | |||
| 868 | + | name: "empty registry domain", | |||
| 869 | + | registryDomain: "", | |||
| 870 | + | imageName: "myapp:latest", | |||
| 871 | + | expected: "myapp:latest", | |||
| 872 | + | }, | |||
| 873 | + | { | |||
| 874 | + | name: "image without tag", | |||
| 875 | + | registryDomain: "registry.example.com", | |||
| 876 | + | imageName: "myapp", | |||
| 877 | + | expected: "registry.example.com/myapp", | |||
| 878 | + | }, | |||
| 879 | + | } | |||
| 880 | + | ||||
| 881 | + | for _, tt := range tests { | |||
| 882 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 883 | + | handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, tt.registryDomain) | |||
| 884 | + | result := handler.buildFullImageRef(tt.imageName) | |||
| 885 | + | assert.Equal(t, tt.expected, result) | |||
| 886 | + | }) | |||
| 887 | + | } | |||
| 888 | + | } | |||
M internal/usecase/container/service.go +55 -1
| @@ -25,6 +25,7 @@ type Config struct { | |||||
| 25 | RegistryPassword string | 25 | RegistryPassword string | ||
| 26 | InternalRegistryUsername string | 26 | InternalRegistryUsername string | ||
| 27 | InternalRegistryPassword string | 27 | InternalRegistryPassword string | ||
| 28 | + | PullPolicy string | |||
| 28 | VolumeAutoCreate bool | 29 | VolumeAutoCreate bool | ||
| 29 | VolumePrefix string | 30 | VolumePrefix string | ||
| 30 | VolumePreserve bool | 31 | VolumePreserve bool | ||
| @@ -36,6 +37,12 @@ type Config struct { | |||||
| 36 | ReadinessDelay time.Duration // Delay after container starts before considering it ready | 37 | ReadinessDelay time.Duration // Delay after container starts before considering it ready | ||
| 37 | } | 38 | } | ||
| 38 | 39 | ||||
| 40 | + | const ( | |||
| 41 | + | PullPolicyAlways = "always" | |||
| 42 | + | PullPolicyIfNotPresent = "if-not-present" | |||
| 43 | + | PullPolicyIfTagChanged = "if-tag-changed" | |||
| 44 | + | ) | |||
| 45 | + | ||||
| 39 | // Service implements the ContainerService interface. | 46 | // Service implements the ContainerService interface. | ||
| 40 | type Service struct { | 47 | type Service struct { | ||
| 41 | runtime out.ContainerRuntime | 48 | runtime out.ContainerRuntime | ||
| @@ -513,6 +520,23 @@ func (s *Service) buildImageRef(image string) string { | |||||
| 513 | return fmt.Sprintf("%s/%s", s.config.RegistryDomain, image) | 520 | return fmt.Sprintf("%s/%s", s.config.RegistryDomain, image) | ||
| 514 | } | 521 | } | ||
| 515 | 522 | ||||
| 523 | + | func normalizePullPolicy(policy string) string { | |||
| 524 | + | switch strings.ToLower(strings.TrimSpace(policy)) { | |||
| 525 | + | case PullPolicyAlways: | |||
| 526 | + | return PullPolicyAlways | |||
| 527 | + | case PullPolicyIfTagChanged: | |||
| 528 | + | return PullPolicyIfTagChanged | |||
| 529 | + | case PullPolicyIfNotPresent: | |||
| 530 | + | return PullPolicyIfNotPresent | |||
| 531 | + | default: | |||
| 532 | + | return PullPolicyIfNotPresent | |||
| 533 | + | } | |||
| 534 | + | } | |||
| 535 | + | ||||
| 536 | + | func isDigestRef(imageRef string) bool { | |||
| 537 | + | return strings.Contains(imageRef, "@sha256:") | |||
| 538 | + | } | |||
| 539 | + | ||||
| 516 | func (s *Service) pullRefForDeploy(ctx context.Context, imageRef string) (string, bool) { | 540 | func (s *Service) pullRefForDeploy(ctx context.Context, imageRef string) (string, bool) { | ||
| 517 | if !domain.IsInternalDeploy(ctx) { | 541 | if !domain.IsInternalDeploy(ctx) { | ||
| 518 | return imageRef, false | 542 | return imageRef, false | ||
| @@ -521,7 +545,9 @@ func (s *Service) pullRefForDeploy(ctx context.Context, imageRef string) (string | |||||
| 521 | } | 545 | } | ||
| 522 | 546 | ||||
| 523 | // ensureImage ensures the image is available locally, pulling if needed. | 547 | // ensureImage ensures the image is available locally, pulling if needed. | ||
| 524 | - | // Returns the canonical image reference to use for container operations. | 548 | + | // Returns the image reference to use for container operations: |
| 549 | + | // - For digest references (@sha256:...), returns the pullRef since Docker can't tag digests | |||
| 550 | + | // - For tagged images, returns the original imageRef after tagging the pulled image | |||
| 525 | func (s *Service) ensureImage(ctx context.Context, imageRef string) (string, error) { | 551 | func (s *Service) ensureImage(ctx context.Context, imageRef string) (string, error) { | ||
| 526 | ctx = zerowrap.CtxWithField(ctx, "image", imageRef) | 552 | ctx = zerowrap.CtxWithField(ctx, "image", imageRef) | ||
| 527 | log := zerowrap.FromCtx(ctx) | 553 | log := zerowrap.FromCtx(ctx) | ||
| @@ -550,6 +576,15 @@ func (s *Service) ensureImage(ctx context.Context, imageRef string) (string, err | |||||
| 550 | return "", err | 576 | return "", err | ||
| 551 | } | 577 | } | ||
| 552 | 578 | ||||
| 579 | + | // For digest references, we can't create a tag (Docker doesn't allow it). | |||
| 580 | + | // In this case, use the pullRef directly since the image is available under that reference. | |||
| 581 | + | if strings.Contains(imageRef, "@sha256:") { | |||
| 582 | + | log.Info(). | |||
| 583 | + | Str("pull_ref", pullRef). | |||
| 584 | + | Msg("digest reference: using pull reference for container operations") | |||
| 585 | + | return pullRef, nil | |||
| 586 | + | } | |||
| 587 | + | ||||
| 553 | if err := s.tagImageIfNeeded(ctx, pullRef, imageRef); err != nil { | 588 | if err := s.tagImageIfNeeded(ctx, pullRef, imageRef); err != nil { | ||
| 554 | return "", err | 589 | return "", err | ||
| 555 | } | 590 | } | ||
| @@ -576,6 +611,18 @@ func (s *Service) ensureLocalImage(ctx context.Context, imageRef, pullRef string | |||||
| 576 | return false, nil | 611 | return false, nil | ||
| 577 | } | 612 | } | ||
| 578 | 613 | ||||
| 614 | + | pullPolicy := normalizePullPolicy(s.config.PullPolicy) | |||
| 615 | + | switch pullPolicy { | |||
| 616 | + | case PullPolicyAlways: | |||
| 617 | + | log.Info().Str("pull_policy", pullPolicy).Msg("pull policy forces image pull") | |||
| 618 | + | return false, nil | |||
| 619 | + | case PullPolicyIfTagChanged: | |||
| 620 | + | if !isDigestRef(imageRef) { | |||
| 621 | + | log.Info().Str("pull_policy", pullPolicy).Msg("tag reference detected, pulling to check for updates") | |||
| 622 | + | return false, nil | |||
| 623 | + | } | |||
| 624 | + | } | |||
| 625 | + | ||||
| 579 | localImages, err := s.runtime.ListImages(ctx) | 626 | localImages, err := s.runtime.ListImages(ctx) | ||
| 580 | if err != nil { | 627 | if err != nil { | ||
| 581 | log.WrapErr(err, "failed to list local images, will attempt pull") | 628 | log.WrapErr(err, "failed to list local images, will attempt pull") | ||
| @@ -641,6 +688,13 @@ func (s *Service) tagImageIfNeeded(ctx context.Context, sourceRef, targetRef str | |||||
| 641 | return nil | 688 | return nil | ||
| 642 | } | 689 | } | ||
| 643 | 690 | ||||
| 691 | + | // Cannot create a tag with a digest reference - Docker/Podman doesn't allow it. | |||
| 692 | + | // When using digest references (image@sha256:...), skip tagging as the image | |||
| 693 | + | // is already available by its digest. | |||
| 694 | + | if strings.Contains(targetRef, "@sha256:") { | |||
| 695 | + | return nil | |||
| 696 | + | } | |||
| 697 | + | ||||
| 644 | log := zerowrap.FromCtx(ctx) | 698 | log := zerowrap.FromCtx(ctx) | ||
| 645 | if err := s.runtime.TagImage(ctx, sourceRef, targetRef); err != nil { | 699 | if err := s.runtime.TagImage(ctx, sourceRef, targetRef); err != nil { | ||
| 646 | return log.WrapErr(err, "failed to tag image from pull reference") | 700 | return log.WrapErr(err, "failed to tag image from pull reference") | ||
A internal/usecase/health/service.go +134 -0
| @@ -0,0 +1,134 @@ | |||||
| 1 | + | // Package health implements the health check use case for routes. | |||
| 2 | + | package health | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "context" | |||
| 6 | + | "fmt" | |||
| 7 | + | "sync" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/zerowrap" | |||
| 10 | + | ||||
| 11 | + | "gordon/internal/boundaries/in" | |||
| 12 | + | "gordon/internal/domain" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // maxConcurrentProbes limits the number of concurrent health probes to prevent resource exhaustion. | |||
| 16 | + | const maxConcurrentProbes = 10 | |||
| 17 | + | ||||
| 18 | + | // Service implements the HealthService interface. | |||
| 19 | + | type Service struct { | |||
| 20 | + | configSvc in.ConfigService | |||
| 21 | + | containerSvc in.ContainerService | |||
| 22 | + | prober in.HTTPProber | |||
| 23 | + | log zerowrap.Logger | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | // NewService creates a new health service. | |||
| 27 | + | func NewService( | |||
| 28 | + | configSvc in.ConfigService, | |||
| 29 | + | containerSvc in.ContainerService, | |||
| 30 | + | prober in.HTTPProber, | |||
| 31 | + | log zerowrap.Logger, | |||
| 32 | + | ) *Service { | |||
| 33 | + | return &Service{ | |||
| 34 | + | configSvc: configSvc, | |||
| 35 | + | containerSvc: containerSvc, | |||
| 36 | + | prober: prober, | |||
| 37 | + | log: log, | |||
| 38 | + | } | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // CheckRoute performs a health check on a single route. | |||
| 42 | + | func (s *Service) CheckRoute(ctx context.Context, route domain.Route) *domain.RouteHealth { | |||
| 43 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |||
| 44 | + | zerowrap.FieldLayer: "usecase", | |||
| 45 | + | zerowrap.FieldUseCase: "CheckRoute", | |||
| 46 | + | "domain": route.Domain, | |||
| 47 | + | }) | |||
| 48 | + | log := zerowrap.FromCtx(ctx) | |||
| 49 | + | ||||
| 50 | + | health := &domain.RouteHealth{ | |||
| 51 | + | Domain: route.Domain, | |||
| 52 | + | ContainerStatus: "unknown", | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | // Check container status | |||
| 56 | + | container, exists := s.containerSvc.Get(ctx, route.Domain) | |||
| 57 | + | if !exists || container == nil { | |||
| 58 | + | health.ContainerStatus = "not found" | |||
| 59 | + | health.Error = "container not found" | |||
| 60 | + | log.Debug().Msg("container not found") | |||
| 61 | + | return health | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | health.ContainerStatus = container.Status | |||
| 65 | + | ||||
| 66 | + | // Only probe HTTP if container is running | |||
| 67 | + | if container.Status != string(domain.ContainerStatusRunning) { | |||
| 68 | + | health.Error = fmt.Sprintf("container is %s", container.Status) | |||
| 69 | + | log.Debug().Str("status", container.Status).Msg("container not running, skipping HTTP probe") | |||
| 70 | + | return health | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | // Probe HTTP endpoint | |||
| 74 | + | url := fmt.Sprintf("https://%s/", route.Domain) | |||
| 75 | + | statusCode, responseTime, err := s.prober.Probe(ctx, url) | |||
| 76 | + | if err != nil { | |||
| 77 | + | health.Error = err.Error() | |||
| 78 | + | log.Debug().Err(err).Str("url", url).Msg("HTTP probe failed") | |||
| 79 | + | return health | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | health.HTTPStatus = statusCode | |||
| 83 | + | health.ResponseTimeMs = responseTime | |||
| 84 | + | health.Healthy = statusCode >= 200 && statusCode < 400 | |||
| 85 | + | ||||
| 86 | + | log.Debug(). | |||
| 87 | + | Int("http_status", statusCode). | |||
| 88 | + | Int64("response_time_ms", responseTime). | |||
| 89 | + | Bool("healthy", health.Healthy). | |||
| 90 | + | Msg("health check complete") | |||
| 91 | + | ||||
| 92 | + | return health | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | // CheckAllRoutes performs health checks on all configured routes. | |||
| 96 | + | func (s *Service) CheckAllRoutes(ctx context.Context) map[string]*domain.RouteHealth { | |||
| 97 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |||
| 98 | + | zerowrap.FieldLayer: "usecase", | |||
| 99 | + | zerowrap.FieldUseCase: "CheckAllRoutes", | |||
| 100 | + | }) | |||
| 101 | + | log := zerowrap.FromCtx(ctx) | |||
| 102 | + | ||||
| 103 | + | routes := s.configSvc.GetRoutes(ctx) | |||
| 104 | + | results := make(map[string]*domain.RouteHealth, len(routes)) | |||
| 105 | + | ||||
| 106 | + | if len(routes) == 0 { | |||
| 107 | + | log.Debug().Msg("no routes configured") | |||
| 108 | + | return results | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | // Check routes concurrently with semaphore to limit resource usage | |||
| 112 | + | var mu sync.Mutex | |||
| 113 | + | var wg sync.WaitGroup | |||
| 114 | + | sem := make(chan struct{}, maxConcurrentProbes) | |||
| 115 | + | ||||
| 116 | + | for _, route := range routes { | |||
| 117 | + | wg.Add(1) | |||
| 118 | + | go func(r domain.Route) { | |||
| 119 | + | defer wg.Done() | |||
| 120 | + | sem <- struct{}{} // Acquire semaphore | |||
| 121 | + | defer func() { <-sem }() // Release semaphore | |||
| 122 | + | ||||
| 123 | + | health := s.CheckRoute(ctx, r) | |||
| 124 | + | mu.Lock() | |||
| 125 | + | results[r.Domain] = health | |||
| 126 | + | mu.Unlock() | |||
| 127 | + | }(route) | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | wg.Wait() | |||
| 131 | + | ||||
| 132 | + | log.Debug().Int("routes_checked", len(results)).Msg("all health checks complete") | |||
| 133 | + | return results | |||
| 134 | + | } | |||
A internal/usecase/health/service_test.go +184 -0
| @@ -0,0 +1,184 @@ | |||||
| 1 | + | package health | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/zerowrap" | |||
| 9 | + | "github.com/stretchr/testify/assert" | |||
| 10 | + | "github.com/stretchr/testify/mock" | |||
| 11 | + | ||||
| 12 | + | "gordon/internal/boundaries/in/mocks" | |||
| 13 | + | "gordon/internal/domain" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | func testLogger() zerowrap.Logger { | |||
| 17 | + | return zerowrap.Default() | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | func TestService_CheckRoute_ContainerNotFound(t *testing.T) { | |||
| 21 | + | configSvc := mocks.NewMockConfigService(t) | |||
| 22 | + | containerSvc := mocks.NewMockContainerService(t) | |||
| 23 | + | prober := mocks.NewMockHTTPProber(t) | |||
| 24 | + | ||||
| 25 | + | containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(nil, false) | |||
| 26 | + | ||||
| 27 | + | svc := NewService(configSvc, containerSvc, prober, testLogger()) | |||
| 28 | + | route := domain.Route{Domain: "app.example.com", Image: "myapp:latest"} | |||
| 29 | + | ||||
| 30 | + | health := svc.CheckRoute(context.Background(), route) | |||
| 31 | + | ||||
| 32 | + | assert.Equal(t, "app.example.com", health.Domain) | |||
| 33 | + | assert.Equal(t, "not found", health.ContainerStatus) | |||
| 34 | + | assert.Equal(t, 0, health.HTTPStatus) | |||
| 35 | + | assert.False(t, health.Healthy) | |||
| 36 | + | assert.Equal(t, "container not found", health.Error) | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | func TestService_CheckRoute_ContainerNotRunning(t *testing.T) { | |||
| 40 | + | configSvc := mocks.NewMockConfigService(t) | |||
| 41 | + | containerSvc := mocks.NewMockContainerService(t) | |||
| 42 | + | prober := mocks.NewMockHTTPProber(t) | |||
| 43 | + | ||||
| 44 | + | container := &domain.Container{ | |||
| 45 | + | ID: "abc123", | |||
| 46 | + | Status: "stopped", | |||
| 47 | + | } | |||
| 48 | + | containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(container, true) | |||
| 49 | + | ||||
| 50 | + | svc := NewService(configSvc, containerSvc, prober, testLogger()) | |||
| 51 | + | route := domain.Route{Domain: "app.example.com", Image: "myapp:latest"} | |||
| 52 | + | ||||
| 53 | + | health := svc.CheckRoute(context.Background(), route) | |||
| 54 | + | ||||
| 55 | + | assert.Equal(t, "app.example.com", health.Domain) | |||
| 56 | + | assert.Equal(t, "stopped", health.ContainerStatus) | |||
| 57 | + | assert.Equal(t, 0, health.HTTPStatus) | |||
| 58 | + | assert.False(t, health.Healthy) | |||
| 59 | + | assert.Contains(t, health.Error, "container is stopped") | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | func TestService_CheckRoute_HTTPProbeSuccess(t *testing.T) { | |||
| 63 | + | configSvc := mocks.NewMockConfigService(t) | |||
| 64 | + | containerSvc := mocks.NewMockContainerService(t) | |||
| 65 | + | prober := mocks.NewMockHTTPProber(t) | |||
| 66 | + | ||||
| 67 | + | container := &domain.Container{ | |||
| 68 | + | ID: "abc123", | |||
| 69 | + | Status: "running", | |||
| 70 | + | } | |||
| 71 | + | containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(container, true) | |||
| 72 | + | prober.EXPECT().Probe(mock.Anything, "https://app.example.com/").Return(200, int64(45), nil) | |||
| 73 | + | ||||
| 74 | + | svc := NewService(configSvc, containerSvc, prober, testLogger()) | |||
| 75 | + | route := domain.Route{Domain: "app.example.com", Image: "myapp:latest"} | |||
| 76 | + | ||||
| 77 | + | health := svc.CheckRoute(context.Background(), route) | |||
| 78 | + | ||||
| 79 | + | assert.Equal(t, "app.example.com", health.Domain) | |||
| 80 | + | assert.Equal(t, "running", health.ContainerStatus) | |||
| 81 | + | assert.Equal(t, 200, health.HTTPStatus) | |||
| 82 | + | assert.Equal(t, int64(45), health.ResponseTimeMs) | |||
| 83 | + | assert.True(t, health.Healthy) | |||
| 84 | + | assert.Empty(t, health.Error) | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | func TestService_CheckRoute_HTTPProbeFailure(t *testing.T) { | |||
| 88 | + | configSvc := mocks.NewMockConfigService(t) | |||
| 89 | + | containerSvc := mocks.NewMockContainerService(t) | |||
| 90 | + | prober := mocks.NewMockHTTPProber(t) | |||
| 91 | + | ||||
| 92 | + | container := &domain.Container{ | |||
| 93 | + | ID: "abc123", | |||
| 94 | + | Status: "running", | |||
| 95 | + | } | |||
| 96 | + | containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(container, true) | |||
| 97 | + | prober.EXPECT().Probe(mock.Anything, "https://app.example.com/").Return(0, int64(0), errors.New("connection refused")) | |||
| 98 | + | ||||
| 99 | + | svc := NewService(configSvc, containerSvc, prober, testLogger()) | |||
| 100 | + | route := domain.Route{Domain: "app.example.com", Image: "myapp:latest"} | |||
| 101 | + | ||||
| 102 | + | health := svc.CheckRoute(context.Background(), route) | |||
| 103 | + | ||||
| 104 | + | assert.Equal(t, "app.example.com", health.Domain) | |||
| 105 | + | assert.Equal(t, "running", health.ContainerStatus) | |||
| 106 | + | assert.Equal(t, 0, health.HTTPStatus) | |||
| 107 | + | assert.False(t, health.Healthy) | |||
| 108 | + | assert.Equal(t, "connection refused", health.Error) | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | func TestService_CheckRoute_HTTPStatus5xx(t *testing.T) { | |||
| 112 | + | configSvc := mocks.NewMockConfigService(t) | |||
| 113 | + | containerSvc := mocks.NewMockContainerService(t) | |||
| 114 | + | prober := mocks.NewMockHTTPProber(t) | |||
| 115 | + | ||||
| 116 | + | container := &domain.Container{ | |||
| 117 | + | ID: "abc123", | |||
| 118 | + | Status: "running", | |||
| 119 | + | } | |||
| 120 | + | containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(container, true) | |||
| 121 | + | prober.EXPECT().Probe(mock.Anything, "https://app.example.com/").Return(502, int64(100), nil) | |||
| 122 | + | ||||
| 123 | + | svc := NewService(configSvc, containerSvc, prober, testLogger()) | |||
| 124 | + | route := domain.Route{Domain: "app.example.com", Image: "myapp:latest"} | |||
| 125 | + | ||||
| 126 | + | health := svc.CheckRoute(context.Background(), route) | |||
| 127 | + | ||||
| 128 | + | assert.Equal(t, "app.example.com", health.Domain) | |||
| 129 | + | assert.Equal(t, "running", health.ContainerStatus) | |||
| 130 | + | assert.Equal(t, 502, health.HTTPStatus) | |||
| 131 | + | assert.Equal(t, int64(100), health.ResponseTimeMs) | |||
| 132 | + | assert.False(t, health.Healthy) // 5xx is not healthy | |||
| 133 | + | assert.Empty(t, health.Error) | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | func TestService_CheckAllRoutes(t *testing.T) { | |||
| 137 | + | configSvc := mocks.NewMockConfigService(t) | |||
| 138 | + | containerSvc := mocks.NewMockContainerService(t) | |||
| 139 | + | prober := mocks.NewMockHTTPProber(t) | |||
| 140 | + | ||||
| 141 | + | routes := []domain.Route{ | |||
| 142 | + | {Domain: "app1.example.com", Image: "app1:latest"}, | |||
| 143 | + | {Domain: "app2.example.com", Image: "app2:latest"}, | |||
| 144 | + | } | |||
| 145 | + | configSvc.EXPECT().GetRoutes(mock.Anything).Return(routes) | |||
| 146 | + | ||||
| 147 | + | container1 := &domain.Container{ID: "abc", Status: "running"} | |||
| 148 | + | container2 := &domain.Container{ID: "def", Status: "stopped"} | |||
| 149 | + | containerSvc.EXPECT().Get(mock.Anything, "app1.example.com").Return(container1, true) | |||
| 150 | + | containerSvc.EXPECT().Get(mock.Anything, "app2.example.com").Return(container2, true) | |||
| 151 | + | ||||
| 152 | + | // Only running containers get probed | |||
| 153 | + | prober.EXPECT().Probe(mock.Anything, "https://app1.example.com/").Return(200, int64(30), nil) | |||
| 154 | + | ||||
| 155 | + | svc := NewService(configSvc, containerSvc, prober, testLogger()) | |||
| 156 | + | ||||
| 157 | + | results := svc.CheckAllRoutes(context.Background()) | |||
| 158 | + | ||||
| 159 | + | assert.Len(t, results, 2) | |||
| 160 | + | ||||
| 161 | + | assert.NotNil(t, results["app1.example.com"]) | |||
| 162 | + | assert.Equal(t, "running", results["app1.example.com"].ContainerStatus) | |||
| 163 | + | assert.Equal(t, 200, results["app1.example.com"].HTTPStatus) | |||
| 164 | + | assert.True(t, results["app1.example.com"].Healthy) | |||
| 165 | + | ||||
| 166 | + | assert.NotNil(t, results["app2.example.com"]) | |||
| 167 | + | assert.Equal(t, "stopped", results["app2.example.com"].ContainerStatus) | |||
| 168 | + | assert.Equal(t, 0, results["app2.example.com"].HTTPStatus) | |||
| 169 | + | assert.False(t, results["app2.example.com"].Healthy) | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | func TestService_CheckAllRoutes_NoRoutes(t *testing.T) { | |||
| 173 | + | configSvc := mocks.NewMockConfigService(t) | |||
| 174 | + | containerSvc := mocks.NewMockContainerService(t) | |||
| 175 | + | prober := mocks.NewMockHTTPProber(t) | |||
| 176 | + | ||||
| 177 | + | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}) | |||
| 178 | + | ||||
| 179 | + | svc := NewService(configSvc, containerSvc, prober, testLogger()) | |||
| 180 | + | ||||
| 181 | + | results := svc.CheckAllRoutes(context.Background()) | |||
| 182 | + | ||||
| 183 | + | assert.Empty(t, results) | |||
| 184 | + | } | |||
M internal/usecase/proxy/service.go +4 -1
| @@ -89,6 +89,7 @@ func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||||
| 89 | } | 89 | } | ||
| 90 | 90 | ||||
| 91 | // Get target for this domain | 91 | // Get target for this domain | ||
| 92 | + | log.Debug().Str("resolving_target_for", r.Host).Msg("looking up proxy target") | |||
| 92 | target, err := s.GetTarget(ctx, r.Host) | 93 | target, err := s.GetTarget(ctx, r.Host) | ||
| 93 | if err != nil { | 94 | if err != nil { | ||
| 94 | log.Warn().Err(err).Msg("no route found for domain") | 95 | log.Warn().Err(err).Msg("no route found for domain") | ||
| @@ -302,9 +303,11 @@ func (s *Service) proxyToTarget(w http.ResponseWriter, r *http.Request, target * | |||||
| 302 | return | 303 | return | ||
| 303 | } | 304 | } | ||
| 304 | 305 | ||||
| 306 | + | log.Debug().Str("target_url", targetURL.String()).Msg("proxying to target") | |||
| 307 | + | ||||
| 305 | proxy := newReverseProxy(targetURL, | 308 | proxy := newReverseProxy(targetURL, | ||
| 306 | func(w http.ResponseWriter, _ *http.Request, err error) { | 309 | func(w http.ResponseWriter, _ *http.Request, err error) { | ||
| 307 | - | log.Error().Err(err).Str("target", targetURL.String()).Msg("proxy error") | 310 | + | log.Error().Err(err).Str("target", targetURL.String()).Msg("proxy error: connection failed") |
| 308 | http.Error(w, "Service Unavailable", http.StatusServiceUnavailable) | 311 | http.Error(w, "Service Unavailable", http.StatusServiceUnavailable) | ||
| 309 | }, | 312 | }, | ||
| 310 | func(resp *http.Response) error { | 313 | func(resp *http.Response) error { | ||
M internal/usecase/registry/service.go +46 -5
| @@ -3,6 +3,8 @@ package registry | |||||
| 3 | 3 | ||||
| 4 | import ( | 4 | import ( | ||
| 5 | "context" | 5 | "context" | ||
| 6 | + | "crypto/sha256" | |||
| 7 | + | "fmt" | |||
| 6 | "io" | 8 | "io" | ||
| 7 | 9 | ||||
| 8 | "github.com/bnema/zerowrap" | 10 | "github.com/bnema/zerowrap" | ||
| @@ -54,8 +56,8 @@ func (s *Service) GetManifest(ctx context.Context, name, reference string) (*dom | |||||
| 54 | }, nil | 56 | }, nil | ||
| 55 | } | 57 | } | ||
| 56 | 58 | ||||
| 57 | - | // PutManifest stores a manifest. | 59 | + | // PutManifest stores a manifest and returns the calculated digest. |
| 58 | - | func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) error { | 60 | + | func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) (string, error) { |
| 59 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | 61 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | ||
| 60 | zerowrap.FieldLayer: "usecase", | 62 | zerowrap.FieldLayer: "usecase", | ||
| 61 | zerowrap.FieldUseCase: "PutManifest", | 63 | zerowrap.FieldUseCase: "PutManifest", | ||
| @@ -64,8 +66,11 @@ func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) er | |||||
| 64 | }) | 66 | }) | ||
| 65 | log := zerowrap.FromCtx(ctx) | 67 | log := zerowrap.FromCtx(ctx) | ||
| 66 | 68 | ||||
| 69 | + | // Calculate digest | |||
| 70 | + | digest := fmt.Sprintf("sha256:%x", sha256.Sum256(manifest.Data)) | |||
| 71 | + | ||||
| 67 | if err := s.manifestStorage.PutManifest(manifest.Name, manifest.Reference, manifest.ContentType, manifest.Data); err != nil { | 72 | if err := s.manifestStorage.PutManifest(manifest.Name, manifest.Reference, manifest.ContentType, manifest.Data); err != nil { | ||
| 68 | - | return log.WrapErr(err, "failed to store manifest") | 73 | + | return "", log.WrapErr(err, "failed to store manifest") |
| 69 | } | 74 | } | ||
| 70 | 75 | ||||
| 71 | // Publish image pushed event | 76 | // Publish image pushed event | ||
| @@ -80,8 +85,8 @@ func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) er | |||||
| 80 | } | 85 | } | ||
| 81 | } | 86 | } | ||
| 82 | 87 | ||||
| 83 | - | log.Info().Msg("manifest stored") | 88 | + | log.Info().Str("digest", digest).Msg("manifest stored") |
| 84 | - | return nil | 89 | + | return digest, nil |
| 85 | } | 90 | } | ||
| 86 | 91 | ||||
| 87 | // DeleteManifest removes a manifest. | 92 | // DeleteManifest removes a manifest. | ||
| @@ -119,6 +124,23 @@ func (s *Service) GetBlob(ctx context.Context, digest string) (io.ReadCloser, er | |||||
| 119 | return reader, nil | 124 | return reader, nil | ||
| 120 | } | 125 | } | ||
| 121 | 126 | ||||
| 127 | + | // GetBlobPath returns the filesystem path to a blob for direct serving. | |||
| 128 | + | func (s *Service) GetBlobPath(ctx context.Context, digest string) (string, error) { | |||
| 129 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |||
| 130 | + | zerowrap.FieldLayer: "usecase", | |||
| 131 | + | zerowrap.FieldUseCase: "GetBlobPath", | |||
| 132 | + | "digest": digest, | |||
| 133 | + | }) | |||
| 134 | + | log := zerowrap.FromCtx(ctx) | |||
| 135 | + | ||||
| 136 | + | path, err := s.blobStorage.GetBlobPath(digest) | |||
| 137 | + | if err != nil { | |||
| 138 | + | return "", log.WrapErr(err, "failed to get blob path") | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | return path, nil | |||
| 142 | + | } | |||
| 143 | + | ||||
| 122 | // PutBlob stores a blob with the given digest. | 144 | // PutBlob stores a blob with the given digest. | ||
| 123 | func (s *Service) PutBlob(ctx context.Context, digest string, data io.Reader, size int64) error { | 145 | func (s *Service) PutBlob(ctx context.Context, digest string, data io.Reader, size int64) error { | ||
| 124 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | 146 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | ||
| @@ -160,6 +182,25 @@ func (s *Service) StartUpload(ctx context.Context, name string) (string, error) | |||||
| 160 | return uuid, nil | 182 | return uuid, nil | ||
| 161 | } | 183 | } | ||
| 162 | 184 | ||||
| 185 | + | // AppendBlobChunk appends data to an in-progress blob upload. | |||
| 186 | + | func (s *Service) AppendBlobChunk(ctx context.Context, name, uuid string, chunk []byte) (int64, error) { | |||
| 187 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |||
| 188 | + | zerowrap.FieldLayer: "usecase", | |||
| 189 | + | zerowrap.FieldUseCase: "AppendBlobChunk", | |||
| 190 | + | "name": name, | |||
| 191 | + | "uuid": uuid, | |||
| 192 | + | zerowrap.FieldSize: len(chunk), | |||
| 193 | + | }) | |||
| 194 | + | log := zerowrap.FromCtx(ctx) | |||
| 195 | + | ||||
| 196 | + | length, err := s.blobStorage.AppendBlobChunk(name, uuid, chunk) | |||
| 197 | + | if err != nil { | |||
| 198 | + | return 0, log.WrapErr(err, "failed to append blob chunk") | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | return length, nil | |||
| 202 | + | } | |||
| 203 | + | ||||
| 163 | // FinishUpload completes a blob upload. | 204 | // FinishUpload completes a blob upload. | ||
| 164 | func (s *Service) FinishUpload(ctx context.Context, uuid, digest string) error { | 205 | func (s *Service) FinishUpload(ctx context.Context, uuid, digest string) error { | ||
| 165 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | 206 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | ||
M internal/usecase/registry/service_test.go +7 -4
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "context" | 5 | "context" | ||
| 6 | "errors" | 6 | "errors" | ||
| 7 | "io" | 7 | "io" | ||
| 8 | + | "strings" | |||
| 8 | "testing" | 9 | "testing" | ||
| 9 | 10 | ||||
| 10 | "github.com/bnema/zerowrap" | 11 | "github.com/bnema/zerowrap" | ||
| @@ -75,9 +76,11 @@ func TestService_PutManifest_Success(t *testing.T) { | |||||
| 75 | manifestStorage.EXPECT().PutManifest("myapp", "latest", "application/vnd.docker.distribution.manifest.v2+json", manifest.Data).Return(nil) | 76 | manifestStorage.EXPECT().PutManifest("myapp", "latest", "application/vnd.docker.distribution.manifest.v2+json", manifest.Data).Return(nil) | ||
| 76 | eventBus.EXPECT().Publish(domain.EventImagePushed, mock.AnythingOfType("domain.ImagePushedPayload")).Return(nil) | 77 | eventBus.EXPECT().Publish(domain.EventImagePushed, mock.AnythingOfType("domain.ImagePushedPayload")).Return(nil) | ||
| 77 | 78 | ||||
| 78 | - | err := svc.PutManifest(ctx, manifest) | 79 | + | digest, err := svc.PutManifest(ctx, manifest) |
| 79 | 80 | ||||
| 80 | assert.NoError(t, err) | 81 | assert.NoError(t, err) | ||
| 82 | + | assert.NotEmpty(t, digest) | |||
| 83 | + | assert.True(t, strings.HasPrefix(digest, "sha256:")) | |||
| 81 | } | 84 | } | ||
| 82 | 85 | ||||
| 83 | func TestService_PutManifest_StorageError(t *testing.T) { | 86 | func TestService_PutManifest_StorageError(t *testing.T) { | ||
| @@ -97,7 +100,7 @@ func TestService_PutManifest_StorageError(t *testing.T) { | |||||
| 97 | 100 | ||||
| 98 | manifestStorage.EXPECT().PutManifest("myapp", "latest", "application/vnd.docker.distribution.manifest.v2+json", manifest.Data).Return(errors.New("storage full")) | 101 | manifestStorage.EXPECT().PutManifest("myapp", "latest", "application/vnd.docker.distribution.manifest.v2+json", manifest.Data).Return(errors.New("storage full")) | ||
| 99 | 102 | ||||
| 100 | - | err := svc.PutManifest(ctx, manifest) | 103 | + | _, err := svc.PutManifest(ctx, manifest) |
| 101 | 104 | ||||
| 102 | assert.Error(t, err) | 105 | assert.Error(t, err) | ||
| 103 | assert.Contains(t, err.Error(), "failed to store manifest") | 106 | assert.Contains(t, err.Error(), "failed to store manifest") | ||
| @@ -122,7 +125,7 @@ func TestService_PutManifest_EventPublishError(t *testing.T) { | |||||
| 122 | eventBus.EXPECT().Publish(domain.EventImagePushed, mock.Anything).Return(errors.New("event bus error")) | 125 | eventBus.EXPECT().Publish(domain.EventImagePushed, mock.Anything).Return(errors.New("event bus error")) | ||
| 123 | 126 | ||||
| 124 | // Should still succeed - event publish error is logged but doesn't fail the operation | 127 | // Should still succeed - event publish error is logged but doesn't fail the operation | ||
| 125 | - | err := svc.PutManifest(ctx, manifest) | 128 | + | _, err := svc.PutManifest(ctx, manifest) |
| 126 | 129 | ||||
| 127 | assert.NoError(t, err) | 130 | assert.NoError(t, err) | ||
| 128 | } | 131 | } | ||
| @@ -144,7 +147,7 @@ func TestService_PutManifest_NilEventBus(t *testing.T) { | |||||
| 144 | 147 | ||||
| 145 | manifestStorage.EXPECT().PutManifest("myapp", "latest", "application/vnd.docker.distribution.manifest.v2+json", manifest.Data).Return(nil) | 148 | manifestStorage.EXPECT().PutManifest("myapp", "latest", "application/vnd.docker.distribution.manifest.v2+json", manifest.Data).Return(nil) | ||
| 146 | 149 | ||||
| 147 | - | err := svc.PutManifest(ctx, manifest) | 150 | + | _, err := svc.PutManifest(ctx, manifest) |
| 148 | 151 | ||||
| 149 | assert.NoError(t, err) | 152 | assert.NoError(t, err) | ||
| 150 | } | 153 | } | ||
A internal/usecase/secrets/service.go +155 -0
| @@ -0,0 +1,155 @@ | |||||
| 1 | + | // Package secrets implements the secret management use case. | |||
| 2 | + | package secrets | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "context" | |||
| 6 | + | "errors" | |||
| 7 | + | "strings" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/zerowrap" | |||
| 10 | + | ||||
| 11 | + | "gordon/internal/boundaries/out" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // Domain validation errors. | |||
| 15 | + | var ( | |||
| 16 | + | ErrDomainEmpty = errors.New("domain cannot be empty") | |||
| 17 | + | ErrDomainTooLong = errors.New("domain exceeds maximum length of 253 characters") | |||
| 18 | + | ErrDomainPathTraversal = errors.New("domain contains path traversal sequence") | |||
| 19 | + | ErrDomainInvalidChars = errors.New("domain contains invalid characters") | |||
| 20 | + | ) | |||
| 21 | + | ||||
| 22 | + | // Service implements the SecretService interface. | |||
| 23 | + | type Service struct { | |||
| 24 | + | store out.DomainSecretStore | |||
| 25 | + | log zerowrap.Logger | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | // NewService creates a new secrets service. | |||
| 29 | + | func NewService(store out.DomainSecretStore, log zerowrap.Logger) *Service { | |||
| 30 | + | return &Service{ | |||
| 31 | + | store: store, | |||
| 32 | + | log: log, | |||
| 33 | + | } | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | // ListKeys returns the list of secret keys for a domain (not values). | |||
| 37 | + | func (s *Service) ListKeys(ctx context.Context, domain string) ([]string, error) { | |||
| 38 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |||
| 39 | + | zerowrap.FieldLayer: "usecase", | |||
| 40 | + | zerowrap.FieldUseCase: "ListKeys", | |||
| 41 | + | "domain": domain, | |||
| 42 | + | }) | |||
| 43 | + | log := zerowrap.FromCtx(ctx) | |||
| 44 | + | ||||
| 45 | + | if err := ValidateDomain(domain); err != nil { | |||
| 46 | + | log.Warn().Err(err).Msg("domain validation failed") | |||
| 47 | + | return nil, err | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | keys, err := s.store.ListKeys(domain) | |||
| 51 | + | if err != nil { | |||
| 52 | + | log.Error().Err(err).Msg("failed to list secret keys") | |||
| 53 | + | return nil, err | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | log.Debug().Int("count", len(keys)).Msg("listed secret keys") | |||
| 57 | + | return keys, nil | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | // GetAll returns all secrets for a domain as a key-value map. | |||
| 61 | + | func (s *Service) GetAll(ctx context.Context, domain string) (map[string]string, error) { | |||
| 62 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |||
| 63 | + | zerowrap.FieldLayer: "usecase", | |||
| 64 | + | zerowrap.FieldUseCase: "GetAll", | |||
| 65 | + | "domain": domain, | |||
| 66 | + | }) | |||
| 67 | + | log := zerowrap.FromCtx(ctx) | |||
| 68 | + | ||||
| 69 | + | if err := ValidateDomain(domain); err != nil { | |||
| 70 | + | log.Warn().Err(err).Msg("domain validation failed") | |||
| 71 | + | return nil, err | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | secrets, err := s.store.GetAll(domain) | |||
| 75 | + | if err != nil { | |||
| 76 | + | log.Error().Err(err).Msg("failed to get secrets") | |||
| 77 | + | return nil, err | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | log.Debug().Int("count", len(secrets)).Msg("retrieved secrets") | |||
| 81 | + | return secrets, nil | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | // Set sets or updates multiple secrets for a domain, merging with existing. | |||
| 85 | + | func (s *Service) Set(ctx context.Context, domain string, secrets map[string]string) error { | |||
| 86 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |||
| 87 | + | zerowrap.FieldLayer: "usecase", | |||
| 88 | + | zerowrap.FieldUseCase: "Set", | |||
| 89 | + | "domain": domain, | |||
| 90 | + | }) | |||
| 91 | + | log := zerowrap.FromCtx(ctx) | |||
| 92 | + | ||||
| 93 | + | if err := ValidateDomain(domain); err != nil { | |||
| 94 | + | log.Warn().Err(err).Msg("domain validation failed") | |||
| 95 | + | return err | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | if err := s.store.Set(domain, secrets); err != nil { | |||
| 99 | + | log.Error().Err(err).Msg("failed to set secrets") | |||
| 100 | + | return err | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | log.Info().Int("count", len(secrets)).Msg("secrets set") | |||
| 104 | + | return nil | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | // Delete removes a specific secret key from a domain. | |||
| 108 | + | func (s *Service) Delete(ctx context.Context, domain, key string) error { | |||
| 109 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |||
| 110 | + | zerowrap.FieldLayer: "usecase", | |||
| 111 | + | zerowrap.FieldUseCase: "Delete", | |||
| 112 | + | "domain": domain, | |||
| 113 | + | "key": key, | |||
| 114 | + | }) | |||
| 115 | + | log := zerowrap.FromCtx(ctx) | |||
| 116 | + | ||||
| 117 | + | if err := ValidateDomain(domain); err != nil { | |||
| 118 | + | log.Warn().Err(err).Msg("domain validation failed") | |||
| 119 | + | return err | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | if err := s.store.Delete(domain, key); err != nil { | |||
| 123 | + | log.Error().Err(err).Msg("failed to delete secret") | |||
| 124 | + | return err | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | log.Info().Msg("secret deleted") | |||
| 128 | + | return nil | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | // ValidateDomain validates that a domain is safe to use for secret storage. | |||
| 132 | + | // This prevents path traversal attacks and ensures the domain is valid. | |||
| 133 | + | func ValidateDomain(domain string) error { | |||
| 134 | + | // Check for empty domain | |||
| 135 | + | if domain == "" { | |||
| 136 | + | return ErrDomainEmpty | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | // Check domain length (max DNS name length is 253) | |||
| 140 | + | if len(domain) > 253 { | |||
| 141 | + | return ErrDomainTooLong | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | // Check for path traversal attempts | |||
| 145 | + | if strings.Contains(domain, "..") { | |||
| 146 | + | return ErrDomainPathTraversal | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | // Check for null bytes (can cause issues in file paths) | |||
| 150 | + | if strings.ContainsRune(domain, '\x00') { | |||
| 151 | + | return ErrDomainInvalidChars | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | return nil | |||
| 155 | + | } | |||
A internal/usecase/secrets/service_test.go +358 -0
| @@ -0,0 +1,358 @@ | |||||
| 1 | + | package secrets | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "strings" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/zerowrap" | |||
| 9 | + | "github.com/stretchr/testify/assert" | |||
| 10 | + | "github.com/stretchr/testify/mock" | |||
| 11 | + | ||||
| 12 | + | outmocks "gordon/internal/boundaries/out/mocks" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func testLogger() zerowrap.Logger { | |||
| 16 | + | return zerowrap.Default() | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | func TestValidateDomain(t *testing.T) { | |||
| 20 | + | tests := []struct { | |||
| 21 | + | name string | |||
| 22 | + | domain string | |||
| 23 | + | wantErr error | |||
| 24 | + | }{ | |||
| 25 | + | // Valid domains | |||
| 26 | + | { | |||
| 27 | + | name: "valid simple domain", | |||
| 28 | + | domain: "example.com", | |||
| 29 | + | wantErr: nil, | |||
| 30 | + | }, | |||
| 31 | + | { | |||
| 32 | + | name: "valid subdomain", | |||
| 33 | + | domain: "app.example.com", | |||
| 34 | + | wantErr: nil, | |||
| 35 | + | }, | |||
| 36 | + | { | |||
| 37 | + | name: "valid domain with port", | |||
| 38 | + | domain: "app.example.com:8080", | |||
| 39 | + | wantErr: nil, | |||
| 40 | + | }, | |||
| 41 | + | { | |||
| 42 | + | name: "valid domain with hyphens", | |||
| 43 | + | domain: "my-app.example-site.com", | |||
| 44 | + | wantErr: nil, | |||
| 45 | + | }, | |||
| 46 | + | { | |||
| 47 | + | name: "valid single label", | |||
| 48 | + | domain: "localhost", | |||
| 49 | + | wantErr: nil, | |||
| 50 | + | }, | |||
| 51 | + | // Empty domain | |||
| 52 | + | { | |||
| 53 | + | name: "empty domain", | |||
| 54 | + | domain: "", | |||
| 55 | + | wantErr: ErrDomainEmpty, | |||
| 56 | + | }, | |||
| 57 | + | // Path traversal attempts | |||
| 58 | + | { | |||
| 59 | + | name: "path traversal with double dots", | |||
| 60 | + | domain: "../etc/passwd", | |||
| 61 | + | wantErr: ErrDomainPathTraversal, | |||
| 62 | + | }, | |||
| 63 | + | { | |||
| 64 | + | name: "path traversal in middle", | |||
| 65 | + | domain: "app/../etc/passwd", | |||
| 66 | + | wantErr: ErrDomainPathTraversal, | |||
| 67 | + | }, | |||
| 68 | + | { | |||
| 69 | + | name: "path traversal at end", | |||
| 70 | + | domain: "app.example.com/..", | |||
| 71 | + | wantErr: ErrDomainPathTraversal, | |||
| 72 | + | }, | |||
| 73 | + | { | |||
| 74 | + | name: "multiple path traversal", | |||
| 75 | + | domain: "..../....//etc/passwd", | |||
| 76 | + | wantErr: ErrDomainPathTraversal, | |||
| 77 | + | }, | |||
| 78 | + | { | |||
| 79 | + | name: "encoded path traversal still blocked", | |||
| 80 | + | domain: "app..example.com", | |||
| 81 | + | wantErr: ErrDomainPathTraversal, | |||
| 82 | + | }, | |||
| 83 | + | // Domain too long | |||
| 84 | + | { | |||
| 85 | + | name: "domain at max length (253)", | |||
| 86 | + | domain: strings.Repeat("a", 253), | |||
| 87 | + | wantErr: nil, | |||
| 88 | + | }, | |||
| 89 | + | { | |||
| 90 | + | name: "domain exceeds max length", | |||
| 91 | + | domain: strings.Repeat("a", 254), | |||
| 92 | + | wantErr: ErrDomainTooLong, | |||
| 93 | + | }, | |||
| 94 | + | { | |||
| 95 | + | name: "domain way too long", | |||
| 96 | + | domain: strings.Repeat("a", 1000), | |||
| 97 | + | wantErr: ErrDomainTooLong, | |||
| 98 | + | }, | |||
| 99 | + | // Invalid characters | |||
| 100 | + | { | |||
| 101 | + | name: "null byte in domain", | |||
| 102 | + | domain: "app\x00.example.com", | |||
| 103 | + | wantErr: ErrDomainInvalidChars, | |||
| 104 | + | }, | |||
| 105 | + | { | |||
| 106 | + | name: "null byte at start", | |||
| 107 | + | domain: "\x00example.com", | |||
| 108 | + | wantErr: ErrDomainInvalidChars, | |||
| 109 | + | }, | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | for _, tt := range tests { | |||
| 113 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 114 | + | err := ValidateDomain(tt.domain) | |||
| 115 | + | if tt.wantErr != nil { | |||
| 116 | + | assert.ErrorIs(t, err, tt.wantErr) | |||
| 117 | + | } else { | |||
| 118 | + | assert.NoError(t, err) | |||
| 119 | + | } | |||
| 120 | + | }) | |||
| 121 | + | } | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | func TestService_ListKeys_ValidationErrors(t *testing.T) { | |||
| 125 | + | store := outmocks.NewMockDomainSecretStore(t) | |||
| 126 | + | svc := NewService(store, testLogger()) | |||
| 127 | + | ||||
| 128 | + | tests := []struct { | |||
| 129 | + | name string | |||
| 130 | + | domain string | |||
| 131 | + | wantErr error | |||
| 132 | + | }{ | |||
| 133 | + | { | |||
| 134 | + | name: "empty domain", | |||
| 135 | + | domain: "", | |||
| 136 | + | wantErr: ErrDomainEmpty, | |||
| 137 | + | }, | |||
| 138 | + | { | |||
| 139 | + | name: "path traversal", | |||
| 140 | + | domain: "../etc/passwd", | |||
| 141 | + | wantErr: ErrDomainPathTraversal, | |||
| 142 | + | }, | |||
| 143 | + | { | |||
| 144 | + | name: "domain too long", | |||
| 145 | + | domain: strings.Repeat("a", 300), | |||
| 146 | + | wantErr: ErrDomainTooLong, | |||
| 147 | + | }, | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | for _, tt := range tests { | |||
| 151 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 152 | + | keys, err := svc.ListKeys(context.Background(), tt.domain) | |||
| 153 | + | assert.Nil(t, keys) | |||
| 154 | + | assert.ErrorIs(t, err, tt.wantErr) | |||
| 155 | + | }) | |||
| 156 | + | } | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | func TestService_ListKeys_Success(t *testing.T) { | |||
| 160 | + | store := outmocks.NewMockDomainSecretStore(t) | |||
| 161 | + | svc := NewService(store, testLogger()) | |||
| 162 | + | ||||
| 163 | + | expectedKeys := []string{"API_KEY", "DB_PASSWORD"} | |||
| 164 | + | store.EXPECT().ListKeys("app.example.com").Return(expectedKeys, nil) | |||
| 165 | + | ||||
| 166 | + | keys, err := svc.ListKeys(context.Background(), "app.example.com") | |||
| 167 | + | assert.NoError(t, err) | |||
| 168 | + | assert.Equal(t, expectedKeys, keys) | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | func TestService_GetAll_ValidationErrors(t *testing.T) { | |||
| 172 | + | store := outmocks.NewMockDomainSecretStore(t) | |||
| 173 | + | svc := NewService(store, testLogger()) | |||
| 174 | + | ||||
| 175 | + | tests := []struct { | |||
| 176 | + | name string | |||
| 177 | + | domain string | |||
| 178 | + | wantErr error | |||
| 179 | + | }{ | |||
| 180 | + | { | |||
| 181 | + | name: "empty domain", | |||
| 182 | + | domain: "", | |||
| 183 | + | wantErr: ErrDomainEmpty, | |||
| 184 | + | }, | |||
| 185 | + | { | |||
| 186 | + | name: "path traversal", | |||
| 187 | + | domain: "app/../secret", | |||
| 188 | + | wantErr: ErrDomainPathTraversal, | |||
| 189 | + | }, | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | for _, tt := range tests { | |||
| 193 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 194 | + | secrets, err := svc.GetAll(context.Background(), tt.domain) | |||
| 195 | + | assert.Nil(t, secrets) | |||
| 196 | + | assert.ErrorIs(t, err, tt.wantErr) | |||
| 197 | + | }) | |||
| 198 | + | } | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | func TestService_GetAll_Success(t *testing.T) { | |||
| 202 | + | store := outmocks.NewMockDomainSecretStore(t) | |||
| 203 | + | svc := NewService(store, testLogger()) | |||
| 204 | + | ||||
| 205 | + | expectedSecrets := map[string]string{"API_KEY": "secret123", "DB_PASSWORD": "pass456"} | |||
| 206 | + | store.EXPECT().GetAll("app.example.com").Return(expectedSecrets, nil) | |||
| 207 | + | ||||
| 208 | + | secrets, err := svc.GetAll(context.Background(), "app.example.com") | |||
| 209 | + | assert.NoError(t, err) | |||
| 210 | + | assert.Equal(t, expectedSecrets, secrets) | |||
| 211 | + | } | |||
| 212 | + | ||||
| 213 | + | func TestService_Set_ValidationErrors(t *testing.T) { | |||
| 214 | + | store := outmocks.NewMockDomainSecretStore(t) | |||
| 215 | + | svc := NewService(store, testLogger()) | |||
| 216 | + | ||||
| 217 | + | secrets := map[string]string{"API_KEY": "secret123"} | |||
| 218 | + | ||||
| 219 | + | tests := []struct { | |||
| 220 | + | name string | |||
| 221 | + | domain string | |||
| 222 | + | wantErr error | |||
| 223 | + | }{ | |||
| 224 | + | { | |||
| 225 | + | name: "empty domain", | |||
| 226 | + | domain: "", | |||
| 227 | + | wantErr: ErrDomainEmpty, | |||
| 228 | + | }, | |||
| 229 | + | { | |||
| 230 | + | name: "path traversal", | |||
| 231 | + | domain: "../../../etc/passwd", | |||
| 232 | + | wantErr: ErrDomainPathTraversal, | |||
| 233 | + | }, | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | for _, tt := range tests { | |||
| 237 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 238 | + | err := svc.Set(context.Background(), tt.domain, secrets) | |||
| 239 | + | assert.ErrorIs(t, err, tt.wantErr) | |||
| 240 | + | }) | |||
| 241 | + | } | |||
| 242 | + | } | |||
| 243 | + | ||||
| 244 | + | func TestService_Set_Success(t *testing.T) { | |||
| 245 | + | store := outmocks.NewMockDomainSecretStore(t) | |||
| 246 | + | svc := NewService(store, testLogger()) | |||
| 247 | + | ||||
| 248 | + | secrets := map[string]string{"API_KEY": "secret123"} | |||
| 249 | + | store.EXPECT().Set("app.example.com", secrets).Return(nil) | |||
| 250 | + | ||||
| 251 | + | err := svc.Set(context.Background(), "app.example.com", secrets) | |||
| 252 | + | assert.NoError(t, err) | |||
| 253 | + | } | |||
| 254 | + | ||||
| 255 | + | func TestService_Delete_ValidationErrors(t *testing.T) { | |||
| 256 | + | store := outmocks.NewMockDomainSecretStore(t) | |||
| 257 | + | svc := NewService(store, testLogger()) | |||
| 258 | + | ||||
| 259 | + | tests := []struct { | |||
| 260 | + | name string | |||
| 261 | + | domain string | |||
| 262 | + | wantErr error | |||
| 263 | + | }{ | |||
| 264 | + | { | |||
| 265 | + | name: "empty domain", | |||
| 266 | + | domain: "", | |||
| 267 | + | wantErr: ErrDomainEmpty, | |||
| 268 | + | }, | |||
| 269 | + | { | |||
| 270 | + | name: "path traversal", | |||
| 271 | + | domain: "..%2F..%2Fetc%2Fpasswd", | |||
| 272 | + | wantErr: ErrDomainPathTraversal, | |||
| 273 | + | }, | |||
| 274 | + | } | |||
| 275 | + | ||||
| 276 | + | for _, tt := range tests { | |||
| 277 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 278 | + | err := svc.Delete(context.Background(), tt.domain, "API_KEY") | |||
| 279 | + | assert.ErrorIs(t, err, tt.wantErr) | |||
| 280 | + | }) | |||
| 281 | + | } | |||
| 282 | + | } | |||
| 283 | + | ||||
| 284 | + | func TestService_Delete_Success(t *testing.T) { | |||
| 285 | + | store := outmocks.NewMockDomainSecretStore(t) | |||
| 286 | + | svc := NewService(store, testLogger()) | |||
| 287 | + | ||||
| 288 | + | store.EXPECT().Delete("app.example.com", "API_KEY").Return(nil) | |||
| 289 | + | ||||
| 290 | + | err := svc.Delete(context.Background(), "app.example.com", "API_KEY") | |||
| 291 | + | assert.NoError(t, err) | |||
| 292 | + | } | |||
| 293 | + | ||||
| 294 | + | func TestService_StoreError_PropagatedCorrectly(t *testing.T) { | |||
| 295 | + | store := outmocks.NewMockDomainSecretStore(t) | |||
| 296 | + | svc := NewService(store, testLogger()) | |||
| 297 | + | ||||
| 298 | + | storeErr := assert.AnError | |||
| 299 | + | store.EXPECT().ListKeys("app.example.com").Return(nil, storeErr) | |||
| 300 | + | ||||
| 301 | + | keys, err := svc.ListKeys(context.Background(), "app.example.com") | |||
| 302 | + | assert.Nil(t, keys) | |||
| 303 | + | assert.ErrorIs(t, err, storeErr) | |||
| 304 | + | } | |||
| 305 | + | ||||
| 306 | + | // TestPathTraversalVariants tests various path traversal attack patterns. | |||
| 307 | + | func TestPathTraversalVariants(t *testing.T) { | |||
| 308 | + | tests := []struct { | |||
| 309 | + | name string | |||
| 310 | + | domain string | |||
| 311 | + | }{ | |||
| 312 | + | {"simple parent dir", ".."}, | |||
| 313 | + | {"parent dir prefix", "../secret"}, | |||
| 314 | + | {"parent dir suffix", "secret/.."}, | |||
| 315 | + | {"parent dir in middle", "app/../secret"}, | |||
| 316 | + | {"multiple parent dirs", "../../etc/passwd"}, | |||
| 317 | + | {"windows style", "..\\..\\etc\\passwd"}, | |||
| 318 | + | {"mixed slashes with dots", "app/./../../etc"}, | |||
| 319 | + | {"double dots only", "...."}, | |||
| 320 | + | {"triple dots", "..."}, | |||
| 321 | + | {"dot dot in subdomain style", "app..evil.com"}, | |||
| 322 | + | } | |||
| 323 | + | ||||
| 324 | + | for _, tt := range tests { | |||
| 325 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 326 | + | err := ValidateDomain(tt.domain) | |||
| 327 | + | // All these should be rejected (either path traversal or other validation) | |||
| 328 | + | // The main goal is that ".." patterns are caught | |||
| 329 | + | if strings.Contains(tt.domain, "..") { | |||
| 330 | + | assert.ErrorIs(t, err, ErrDomainPathTraversal, "domain %q should be rejected for path traversal", tt.domain) | |||
| 331 | + | } | |||
| 332 | + | }) | |||
| 333 | + | } | |||
| 334 | + | } | |||
| 335 | + | ||||
| 336 | + | // TestService_ValidationHappensBeforeStore ensures validation runs before any store calls. | |||
| 337 | + | func TestService_ValidationHappensBeforeStore(t *testing.T) { | |||
| 338 | + | store := outmocks.NewMockDomainSecretStore(t) | |||
| 339 | + | svc := NewService(store, testLogger()) | |||
| 340 | + | ||||
| 341 | + | // With path traversal domain, store methods should never be called | |||
| 342 | + | // We don't set up any expectations, so if store is called, the test will fail | |||
| 343 | + | ||||
| 344 | + | _, err := svc.ListKeys(context.Background(), "../etc/passwd") | |||
| 345 | + | assert.ErrorIs(t, err, ErrDomainPathTraversal) | |||
| 346 | + | ||||
| 347 | + | _, err = svc.GetAll(context.Background(), "../etc/passwd") | |||
| 348 | + | assert.ErrorIs(t, err, ErrDomainPathTraversal) | |||
| 349 | + | ||||
| 350 | + | err = svc.Set(context.Background(), "../etc/passwd", map[string]string{"key": "value"}) | |||
| 351 | + | assert.ErrorIs(t, err, ErrDomainPathTraversal) | |||
| 352 | + | ||||
| 353 | + | err = svc.Delete(context.Background(), "../etc/passwd", "key") | |||
| 354 | + | assert.ErrorIs(t, err, ErrDomainPathTraversal) | |||
| 355 | + | ||||
| 356 | + | // Verify no store methods were called (mockery would fail if unexpected calls happened) | |||
| 357 | + | mock.AssertExpectationsForObjects(t, store) | |||
| 358 | + | } | |||
M wiki/examples/index.md +3 -3
| @@ -14,7 +14,7 @@ Annotated Gordon configuration examples for common scenarios. | |||||
| 14 | 14 | ||||
| 15 | ```toml | 15 | ```toml | ||
| 16 | [server] | 16 | [server] | ||
| 17 | - | registry_domain = "registry.local" | 17 | + | gordon_domain = "gordon.local" |
| 18 | 18 | ||||
| 19 | [routes] | 19 | [routes] | ||
| 20 | "app.local" = "myapp:latest" | 20 | "app.local" = "myapp:latest" | ||
| @@ -25,7 +25,7 @@ registry_domain = "registry.local" | |||||
| 25 | ```toml | 25 | ```toml | ||
| 26 | [server] | 26 | [server] | ||
| 27 | port = 8080 | 27 | port = 8080 | ||
| 28 | - | registry_domain = "registry.local" | 28 | + | gordon_domain = "gordon.local" |
| 29 | 29 | ||||
| 30 | [registry_auth] | 30 | [registry_auth] | ||
| 31 | enabled = false | 31 | enabled = false | ||
| @@ -42,7 +42,7 @@ enabled = true | |||||
| 42 | ```toml | 42 | ```toml | ||
| 43 | [server] | 43 | [server] | ||
| 44 | port = 8080 | 44 | port = 8080 | ||
| 45 | - | registry_domain = "registry.company.com" | 45 | + | gordon_domain = "gordon.company.com" |
| 46 | 46 | ||||
| 47 | [secrets] | 47 | [secrets] | ||
| 48 | backend = "pass" | 48 | backend = "pass" | ||
M wiki/examples/minimal.md +5 -5
| @@ -16,7 +16,7 @@ The simplest working Gordon configuration. | |||||
| 16 | [server] | 16 | [server] | ||
| 17 | port = 8080 | 17 | port = 8080 | ||
| 18 | registry_port = 5000 | 18 | registry_port = 5000 | ||
| 19 | - | registry_domain = "registry.local" | 19 | + | gordon_domain = "gordon.local" |
| 20 | 20 | ||||
| 21 | # Authentication disabled for simplicity | 21 | # Authentication disabled for simplicity | ||
| 22 | [registry_auth] | 22 | [registry_auth] | ||
| @@ -32,21 +32,21 @@ enabled = false | |||||
| 32 | ### 1. Add to /etc/hosts | 32 | ### 1. Add to /etc/hosts | ||
| 33 | 33 | ||||
| 34 | ```bash | 34 | ```bash | ||
| 35 | - | echo "127.0.0.1 app.local registry.local" | sudo tee -a /etc/hosts | 35 | + | echo "127.0.0.1 app.local gordon.local" | sudo tee -a /etc/hosts |
| 36 | ``` | 36 | ``` | ||
| 37 | 37 | ||||
| 38 | ### 2. Start Gordon | 38 | ### 2. Start Gordon | ||
| 39 | 39 | ||||
| 40 | ```bash | 40 | ```bash | ||
| 41 | - | gordon start | 41 | + | gordon serve |
| 42 | ``` | 42 | ``` | ||
| 43 | 43 | ||||
| 44 | ### 3. Build and Deploy | 44 | ### 3. Build and Deploy | ||
| 45 | 45 | ||||
| 46 | ```bash | 46 | ```bash | ||
| 47 | docker build -t myapp . | 47 | docker build -t myapp . | ||
| 48 | - | docker tag myapp registry.local:5000/myapp:latest | 48 | + | docker tag myapp gordon.local:5000/myapp:latest |
| 49 | - | docker push registry.local:5000/myapp:latest | 49 | + | docker push gordon.local:5000/myapp:latest |
| 50 | ``` | 50 | ``` | ||
| 51 | 51 | ||||
| 52 | ### 4. Access | 52 | ### 4. Access | ||
M wiki/examples/production.md +3 -3
| @@ -17,7 +17,7 @@ A complete Gordon configuration for production environments. | |||||
| 17 | [server] | 17 | [server] | ||
| 18 | port = 8080 # Cloudflare forwards 443 → 8080 | 18 | port = 8080 # Cloudflare forwards 443 → 8080 | ||
| 19 | registry_port = 5000 | 19 | registry_port = 5000 | ||
| 20 | - | registry_domain = "registry.company.com" | 20 | + | gordon_domain = "gordon.company.com" |
| 21 | 21 | ||||
| 22 | # Use pass for secrets (recommended) | 22 | # Use pass for secrets (recommended) | ||
| 23 | [secrets] | 23 | [secrets] | ||
| @@ -161,10 +161,10 @@ systemctl --user enable --now gordon | |||||
| 161 | docker build -t company-app . | 161 | docker build -t company-app . | ||
| 162 | 162 | ||||
| 163 | # Tag with version | 163 | # Tag with version | ||
| 164 | - | docker tag company-app registry.company.com/company-app:v2.2.0 | 164 | + | docker tag company-app gordon.company.com/company-app:v2.2.0 |
| 165 | 165 | ||||
| 166 | # Push to deploy | 166 | # Push to deploy | ||
| 167 | - | docker push registry.company.com/company-app:v2.2.0 | 167 | + | docker push gordon.company.com/company-app:v2.2.0 |
| 168 | 168 | ||||
| 169 | # Update config with new version | 169 | # Update config with new version | ||
| 170 | vim ~/.config/gordon/gordon.toml | 170 | vim ~/.config/gordon/gordon.toml | ||
M wiki/guides/index.md +4 -0
| @@ -11,6 +11,10 @@ In-depth guides for specific Gordon setups and integrations. | |||||
| 11 | 11 | ||||
| 12 | - [Cloudflare DNS & Proxy Setup](./cloudflare-setup.md) - HTTPS termination with Cloudflare | 12 | - [Cloudflare DNS & Proxy Setup](./cloudflare-setup.md) - HTTPS termination with Cloudflare | ||
| 13 | 13 | ||||
| 14 | + | ## Remote Management | |||
| 15 | + | ||||
| 16 | + | - [Remote CLI Management](./remote-cli.md) - Manage Gordon instances remotely | |||
| 17 | + | ||||
| 14 | ## Secrets Management | 18 | ## Secrets Management | ||
| 15 | 19 | ||||
| 16 | - [Using Pass for Secrets](./secrets-pass.md) - Unix password manager integration | 20 | - [Using Pass for Secrets](./secrets-pass.md) - Unix password manager integration | ||
M wiki/guides/podman-rootless.md +3 -3
| @@ -109,7 +109,7 @@ sudo mv gordon /usr/local/bin/ | |||||
| 109 | Generate initial config: | 109 | Generate initial config: | ||
| 110 | 110 | ||||
| 111 | ```bash | 111 | ```bash | ||
| 112 | - | gordon start | 112 | + | gordon serve |
| 113 | # Press Ctrl+C after config is created | 113 | # Press Ctrl+C after config is created | ||
| 114 | ``` | 114 | ``` | ||
| 115 | 115 | ||||
| @@ -119,7 +119,7 @@ Edit `~/.config/gordon/gordon.toml`: | |||||
| 119 | [server] | 119 | [server] | ||
| 120 | port = 8080 # Must match firewall forward (80 → 8080) | 120 | port = 8080 # Must match firewall forward (80 → 8080) | ||
| 121 | registry_port = 5000 | 121 | registry_port = 5000 | ||
| 122 | - | registry_domain = "registry.mydomain.com" | 122 | + | gordon_domain = "gordon.mydomain.com" |
| 123 | 123 | ||||
| 124 | [routes] | 124 | [routes] | ||
| 125 | "app.mydomain.com" = "myapp:latest" | 125 | "app.mydomain.com" = "myapp:latest" | ||
| @@ -138,7 +138,7 @@ After=podman.socket | |||||
| 138 | Type=simple | 138 | Type=simple | ||
| 139 | Restart=always | 139 | Restart=always | ||
| 140 | RestartSec=5 | 140 | RestartSec=5 | ||
| 141 | - | ExecStart=/usr/local/bin/gordon start | 141 | + | ExecStart=/usr/local/bin/gordon serve |
| 142 | 142 | ||||
| 143 | [Install] | 143 | [Install] | ||
| 144 | WantedBy=default.target | 144 | WantedBy=default.target | ||
A wiki/guides/remote-cli.md +392 -0
| @@ -0,0 +1,392 @@ | |||||
| 1 | + | # Remote CLI Management | |||
| 2 | + | ||||
| 3 | + | Manage Gordon instances remotely using the CLI with the `--remote` flag or saved remotes. | |||
| 4 | + | ||||
| 5 | + | ## What You'll Learn | |||
| 6 | + | ||||
| 7 | + | - Using global `--remote` and `--token` flags | |||
| 8 | + | - Saving and managing remote connections | |||
| 9 | + | - Environment variable configuration | |||
| 10 | + | - Token security best practices | |||
| 11 | + | - Remote command workflows | |||
| 12 | + | ||||
| 13 | + | ## Prerequisites | |||
| 14 | + | ||||
| 15 | + | - Gordon CLI installed | |||
| 16 | + | - A remote Gordon instance running with admin API enabled | |||
| 17 | + | - Authentication token for the remote instance | |||
| 18 | + | ||||
| 19 | + | ## Understanding the Remote URL | |||
| 20 | + | ||||
| 21 | + | The `--remote` URL must be the `gordon_domain` configured on the remote Gordon instance. This domain serves both the container registry and the Admin API. | |||
| 22 | + | ||||
| 23 | + | ```toml | |||
| 24 | + | # On the remote Gordon server | |||
| 25 | + | [server] | |||
| 26 | + | gordon_domain = "gordon.example.com" # Use this as --remote URL | |||
| 27 | + | ``` | |||
| 28 | + | ||||
| 29 | + | The CLI connects to `https://gordon.example.com/admin/*` endpoints. | |||
| 30 | + | ||||
| 31 | + | ## Quick Start | |||
| 32 | + | ||||
| 33 | + | ### One-off Remote Command | |||
| 34 | + | ||||
| 35 | + | ```bash | |||
| 36 | + | gordon routes list --remote https://gordon.mydomain.com --token $TOKEN | |||
| 37 | + | ``` | |||
| 38 | + | ||||
| 39 | + | ### Using Environment Variables | |||
| 40 | + | ||||
| 41 | + | ```bash | |||
| 42 | + | export GORDON_REMOTE=https://gordon.mydomain.com | |||
| 43 | + | export GORDON_TOKEN=$TOKEN | |||
| 44 | + | gordon routes list | |||
| 45 | + | ``` | |||
| 46 | + | ||||
| 47 | + | ### Using Saved Remotes | |||
| 48 | + | ||||
| 49 | + | ```bash | |||
| 50 | + | # Add a remote | |||
| 51 | + | gordon remotes add prod https://gordon.mydomain.com --token-env PROD_TOKEN | |||
| 52 | + | ||||
| 53 | + | # Set as active | |||
| 54 | + | gordon remotes use prod | |||
| 55 | + | ||||
| 56 | + | # Now use without flags | |||
| 57 | + | gordon routes list | |||
| 58 | + | ``` | |||
| 59 | + | ||||
| 60 | + | ## Global Flags | |||
| 61 | + | ||||
| 62 | + | These flags are available on all commands: | |||
| 63 | + | ||||
| 64 | + | | Flag | Description | | |||
| 65 | + | |------|-------------| | |||
| 66 | + | | `--remote <URL>` | Remote Gordon URL | | |||
| 67 | + | | `--token <TOKEN>` | Authentication token | | |||
| 68 | + | ||||
| 69 | + | ```bash | |||
| 70 | + | # List routes on remote | |||
| 71 | + | gordon routes list --remote https://gordon.mydomain.com --token $TOKEN | |||
| 72 | + | ||||
| 73 | + | # Manage secrets on remote | |||
| 74 | + | gordon secrets list myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN | |||
| 75 | + | ``` | |||
| 76 | + | ||||
| 77 | + | ## Environment Variables | |||
| 78 | + | ||||
| 79 | + | | Variable | Description | | |||
| 80 | + | |----------|-------------| | |||
| 81 | + | | `GORDON_REMOTE` | Remote Gordon URL | | |||
| 82 | + | | `GORDON_TOKEN` | Authentication token | | |||
| 83 | + | ||||
| 84 | + | Environment variables are useful for CI/CD pipelines and shell sessions: | |||
| 85 | + | ||||
| 86 | + | ```bash | |||
| 87 | + | # Set for current session | |||
| 88 | + | export GORDON_REMOTE=https://gordon.mydomain.com | |||
| 89 | + | export GORDON_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... | |||
| 90 | + | ||||
| 91 | + | # Commands automatically use these | |||
| 92 | + | gordon routes list | |||
| 93 | + | gordon secrets list myapp.example.com | |||
| 94 | + | ``` | |||
| 95 | + | ||||
| 96 | + | ## Saved Remotes | |||
| 97 | + | ||||
| 98 | + | Save frequently used remotes to avoid repeating URLs and tokens. | |||
| 99 | + | ||||
| 100 | + | ### Configuration File | |||
| 101 | + | ||||
| 102 | + | Remotes are stored in `~/.config/gordon/remotes.toml`: | |||
| 103 | + | ||||
| 104 | + | ```toml | |||
| 105 | + | active = "prod" | |||
| 106 | + | ||||
| 107 | + | [remotes.prod] | |||
| 108 | + | url = "https://gordon.mydomain.com" | |||
| 109 | + | token_env = "PROD_TOKEN" | |||
| 110 | + | ||||
| 111 | + | [remotes.staging] | |||
| 112 | + | url = "https://staging.mydomain.com" | |||
| 113 | + | token = "eyJ..." | |||
| 114 | + | ``` | |||
| 115 | + | ||||
| 116 | + | ### Managing Remotes | |||
| 117 | + | ||||
| 118 | + | #### List Remotes | |||
| 119 | + | ||||
| 120 | + | ```bash | |||
| 121 | + | gordon remotes list | |||
| 122 | + | ``` | |||
| 123 | + | ||||
| 124 | + | Output: | |||
| 125 | + | ||||
| 126 | + | ``` | |||
| 127 | + | Saved Remotes | |||
| 128 | + | ||||
| 129 | + | Name URL Token Status | |||
| 130 | + | ────────────────────────────────────────────────────────────────────────────── | |||
| 131 | + | prod https://gordon.mydomain.com $PROD_TOKEN active | |||
| 132 | + | staging https://staging.mydomain.com set | |||
| 133 | + | dev https://dev.mydomain.com none | |||
| 134 | + | ``` | |||
| 135 | + | ||||
| 136 | + | #### Add a Remote | |||
| 137 | + | ||||
| 138 | + | ```bash | |||
| 139 | + | # Basic (no token) | |||
| 140 | + | gordon remotes add prod https://gordon.mydomain.com | |||
| 141 | + | ||||
| 142 | + | # With direct token | |||
| 143 | + | gordon remotes add prod https://gordon.mydomain.com --token eyJ... | |||
| 144 | + | ||||
| 145 | + | # With environment variable reference (recommended) | |||
| 146 | + | gordon remotes add prod https://gordon.mydomain.com --token-env PROD_TOKEN | |||
| 147 | + | ``` | |||
| 148 | + | ||||
| 149 | + | | Flag | Description | | |||
| 150 | + | |------|-------------| | |||
| 151 | + | | `--token <TOKEN>` | Store token directly in config | | |||
| 152 | + | | `--token-env <VAR>` | Store env variable name (token resolved at runtime) | | |||
| 153 | + | ||||
| 154 | + | #### Remove a Remote | |||
| 155 | + | ||||
| 156 | + | ```bash | |||
| 157 | + | # With confirmation prompt | |||
| 158 | + | gordon remotes remove staging | |||
| 159 | + | ||||
| 160 | + | # Skip confirmation | |||
| 161 | + | gordon remotes remove staging --force | |||
| 162 | + | ``` | |||
| 163 | + | ||||
| 164 | + | #### Set Active Remote | |||
| 165 | + | ||||
| 166 | + | ```bash | |||
| 167 | + | gordon remotes use prod | |||
| 168 | + | ``` | |||
| 169 | + | ||||
| 170 | + | When a remote is active, it's used automatically for all remote-capable commands: | |||
| 171 | + | ||||
| 172 | + | ```bash | |||
| 173 | + | gordon remotes use prod | |||
| 174 | + | gordon routes list # Uses prod remote | |||
| 175 | + | gordon secrets list app.com # Uses prod remote | |||
| 176 | + | ``` | |||
| 177 | + | ||||
| 178 | + | ## Resolution Precedence | |||
| 179 | + | ||||
| 180 | + | When multiple sources specify remote or token, the CLI uses this priority order: | |||
| 181 | + | ||||
| 182 | + | **Remote URL:** | |||
| 183 | + | 1. `--remote` flag | |||
| 184 | + | 2. `GORDON_REMOTE` environment variable | |||
| 185 | + | 3. Active remote from `remotes.toml` | |||
| 186 | + | ||||
| 187 | + | **Token:** | |||
| 188 | + | 1. `--token` flag | |||
| 189 | + | 2. `GORDON_TOKEN` environment variable | |||
| 190 | + | 3. Token from active remote in `remotes.toml` | |||
| 191 | + | ||||
| 192 | + | This allows overriding specific values while keeping defaults: | |||
| 193 | + | ||||
| 194 | + | ```bash | |||
| 195 | + | # Active remote is prod, but use different token | |||
| 196 | + | gordon routes list --token $TEMPORARY_TOKEN | |||
| 197 | + | ``` | |||
| 198 | + | ||||
| 199 | + | ## Commands Supporting Remote | |||
| 200 | + | ||||
| 201 | + | These commands work with remote targeting: | |||
| 202 | + | ||||
| 203 | + | | Command | Description | | |||
| 204 | + | |---------|-------------| | |||
| 205 | + | | `gordon routes list` | List all routes | | |||
| 206 | + | | `gordon routes add <domain> <image>` | Add a route | | |||
| 207 | + | | `gordon routes remove <domain>` | Remove a route | | |||
| 208 | + | | `gordon routes deploy <domain>` | Deploy/redeploy a route | | |||
| 209 | + | | `gordon secrets list <domain>` | List secrets for a domain | | |||
| 210 | + | | `gordon secrets set <domain> KEY=value` | Set secrets | | |||
| 211 | + | | `gordon secrets remove <domain> <key>` | Remove a secret | | |||
| 212 | + | | `gordon status` | Show server status | | |||
| 213 | + | ||||
| 214 | + | ## Token Security | |||
| 215 | + | ||||
| 216 | + | ### Option 1: Environment Variable Reference (Recommended) | |||
| 217 | + | ||||
| 218 | + | Store the environment variable name, not the actual token: | |||
| 219 | + | ||||
| 220 | + | ```bash | |||
| 221 | + | gordon remotes add prod https://gordon.mydomain.com --token-env PROD_TOKEN | |||
| 222 | + | ``` | |||
| 223 | + | ||||
| 224 | + | The `remotes.toml` stores only: | |||
| 225 | + | ||||
| 226 | + | ```toml | |||
| 227 | + | [remotes.prod] | |||
| 228 | + | url = "https://gordon.mydomain.com" | |||
| 229 | + | token_env = "PROD_TOKEN" | |||
| 230 | + | ``` | |||
| 231 | + | ||||
| 232 | + | At runtime, Gordon reads `$PROD_TOKEN` from the environment. | |||
| 233 | + | ||||
| 234 | + | **Benefits:** | |||
| 235 | + | - Token not stored in plaintext config | |||
| 236 | + | - Works with secret managers that inject env vars | |||
| 237 | + | - Easy rotation without editing config | |||
| 238 | + | ||||
| 239 | + | ### Option 2: Direct Token Storage | |||
| 240 | + | ||||
| 241 | + | ```bash | |||
| 242 | + | gordon remotes add prod https://gordon.mydomain.com --token eyJ... | |||
| 243 | + | ``` | |||
| 244 | + | ||||
| 245 | + | The token is stored directly in `remotes.toml`: | |||
| 246 | + | ||||
| 247 | + | ```toml | |||
| 248 | + | [remotes.prod] | |||
| 249 | + | url = "https://gordon.mydomain.com" | |||
| 250 | + | token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." | |||
| 251 | + | ``` | |||
| 252 | + | ||||
| 253 | + | **Security note:** Ensure `remotes.toml` has restricted permissions: | |||
| 254 | + | ||||
| 255 | + | ```bash | |||
| 256 | + | chmod 600 ~/.config/gordon/remotes.toml | |||
| 257 | + | ``` | |||
| 258 | + | ||||
| 259 | + | ### Option 3: Environment Variables Only | |||
| 260 | + | ||||
| 261 | + | For maximum security, don't save tokens at all: | |||
| 262 | + | ||||
| 263 | + | ```bash | |||
| 264 | + | # Add remote without token | |||
| 265 | + | gordon remotes add prod https://gordon.mydomain.com | |||
| 266 | + | ||||
| 267 | + | # Set token via environment | |||
| 268 | + | export GORDON_TOKEN=$TOKEN | |||
| 269 | + | ||||
| 270 | + | # Use normally | |||
| 271 | + | gordon remotes use prod | |||
| 272 | + | gordon routes list | |||
| 273 | + | ``` | |||
| 274 | + | ||||
| 275 | + | ## Workflow Examples | |||
| 276 | + | ||||
| 277 | + | ### Development Workflow | |||
| 278 | + | ||||
| 279 | + | ```bash | |||
| 280 | + | # Add dev and staging remotes | |||
| 281 | + | gordon remotes add dev https://gordon.dev.local --token-env DEV_TOKEN | |||
| 282 | + | gordon remotes add staging https://gordon.staging.example.com --token-env STAGING_TOKEN | |||
| 283 | + | ||||
| 284 | + | # Work with dev | |||
| 285 | + | gordon remotes use dev | |||
| 286 | + | gordon routes list | |||
| 287 | + | gordon routes add myapp.dev.local myapp:latest | |||
| 288 | + | ||||
| 289 | + | # Switch to staging | |||
| 290 | + | gordon remotes use staging | |||
| 291 | + | gordon routes list | |||
| 292 | + | ``` | |||
| 293 | + | ||||
| 294 | + | ### CI/CD Pipeline | |||
| 295 | + | ||||
| 296 | + | ```yaml | |||
| 297 | + | # GitHub Actions example | |||
| 298 | + | env: | |||
| 299 | + | GORDON_REMOTE: ${{ secrets.GORDON_URL }} | |||
| 300 | + | GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }} | |||
| 301 | + | ||||
| 302 | + | steps: | |||
| 303 | + | - name: Deploy to Gordon | |||
| 304 | + | run: | | |||
| 305 | + | gordon routes deploy myapp.example.com | |||
| 306 | + | ``` | |||
| 307 | + | ||||
| 308 | + | ```bash | |||
| 309 | + | # GitLab CI example | |||
| 310 | + | deploy: | |||
| 311 | + | script: | |||
| 312 | + | - export GORDON_REMOTE=$GORDON_URL | |||
| 313 | + | - export GORDON_TOKEN=$GORDON_TOKEN | |||
| 314 | + | - gordon routes deploy myapp.example.com | |||
| 315 | + | ``` | |||
| 316 | + | ||||
| 317 | + | ### Multi-Environment Management | |||
| 318 | + | ||||
| 319 | + | ```bash | |||
| 320 | + | # Add all environments | |||
| 321 | + | gordon remotes add prod https://gordon.example.com --token-env PROD_TOKEN | |||
| 322 | + | gordon remotes add staging https://gordon.staging.example.com --token-env STAGING_TOKEN | |||
| 323 | + | gordon remotes add dev https://gordon.dev.example.com --token-env DEV_TOKEN | |||
| 324 | + | ||||
| 325 | + | # Compare routes across environments | |||
| 326 | + | gordon routes list --remote https://gordon.example.com --token $PROD_TOKEN | |||
| 327 | + | gordon routes list --remote https://gordon.staging.example.com --token $STAGING_TOKEN | |||
| 328 | + | ||||
| 329 | + | # Or switch active | |||
| 330 | + | gordon remotes use prod && gordon routes list | |||
| 331 | + | gordon remotes use staging && gordon routes list | |||
| 332 | + | ``` | |||
| 333 | + | ||||
| 334 | + | ## Troubleshooting | |||
| 335 | + | ||||
| 336 | + | ### "unauthorized" Error | |||
| 337 | + | ||||
| 338 | + | Token is missing, expired, or invalid. | |||
| 339 | + | ||||
| 340 | + | ```bash | |||
| 341 | + | # Check if token is set | |||
| 342 | + | echo $GORDON_TOKEN | |||
| 343 | + | ||||
| 344 | + | # Verify token works | |||
| 345 | + | gordon routes list --remote https://gordon.mydomain.com --token $TOKEN | |||
| 346 | + | ``` | |||
| 347 | + | ||||
| 348 | + | ### "connection refused" Error | |||
| 349 | + | ||||
| 350 | + | Remote Gordon isn't running or URL is wrong. | |||
| 351 | + | ||||
| 352 | + | ```bash | |||
| 353 | + | # Verify URL is correct | |||
| 354 | + | curl https://gordon.mydomain.com/health | |||
| 355 | + | ||||
| 356 | + | # Check if admin API is enabled on remote | |||
| 357 | + | # The remote gordon.toml needs: | |||
| 358 | + | # [admin] | |||
| 359 | + | # enabled = true | |||
| 360 | + | ``` | |||
| 361 | + | ||||
| 362 | + | ### Remote Not Found | |||
| 363 | + | ||||
| 364 | + | The specified remote doesn't exist in `remotes.toml`. | |||
| 365 | + | ||||
| 366 | + | ```bash | |||
| 367 | + | # List available remotes | |||
| 368 | + | gordon remotes list | |||
| 369 | + | ||||
| 370 | + | # Add the remote | |||
| 371 | + | gordon remotes add myremote https://gordon.mydomain.com | |||
| 372 | + | ``` | |||
| 373 | + | ||||
| 374 | + | ### Active Remote Cleared | |||
| 375 | + | ||||
| 376 | + | After removing the active remote, you need to set a new active: | |||
| 377 | + | ||||
| 378 | + | ```bash | |||
| 379 | + | gordon remotes use prod | |||
| 380 | + | ``` | |||
| 381 | + | ||||
| 382 | + | Or use explicit flags: | |||
| 383 | + | ||||
| 384 | + | ```bash | |||
| 385 | + | gordon routes list --remote https://gordon.mydomain.com --token $TOKEN | |||
| 386 | + | ``` | |||
| 387 | + | ||||
| 388 | + | ## Related | |||
| 389 | + | ||||
| 390 | + | - [CLI Commands](/docs/cli/commands.md) | |||
| 391 | + | - [Authentication](/docs/config/registry-auth.md) | |||
| 392 | + | - [Admin API](/docs/config/admin.md) | |||
M wiki/guides/running-in-container.md +1 -1
| @@ -45,7 +45,7 @@ Create `gordon.toml`: | |||||
| 45 | [server] | 45 | [server] | ||
| 46 | port = 8080 | 46 | port = 8080 | ||
| 47 | registry_port = 5000 | 47 | registry_port = 5000 | ||
| 48 | - | registry_domain = "registry.mydomain.com" | 48 | + | gordon_domain = "gordon.mydomain.com" |
| 49 | 49 | ||||
| 50 | [routes] | 50 | [routes] | ||
| 51 | "app.mydomain.com" = "myapp:latest" | 51 | "app.mydomain.com" = "myapp:latest" | ||