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 | 20 | EnvLoader: | |
| 21 | 21 | ContainerLogWriter: | |
| 22 | 22 | TokenStore: | |
| 23 | + | DomainSecretStore: | |
| 23 | 24 | gordon/internal/boundaries/in: | |
| 24 | 25 | interfaces: | |
| 25 | 26 | ContainerService: | |
| @@ -27,3 +28,6 @@ packages: | |||
| 27 | 28 | ProxyService: | |
| 28 | 29 | ConfigService: | |
| 29 | 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 | 63 | CMD wget --quiet --tries=1 --spider http://localhost:8080/health || exit 1 | |
| 64 | 64 | ||
| 65 | 65 | # Default command | |
| 66 | - | CMD ["./gordon", "start"] | |
| 66 | + | CMD ["./gordon", "serve"] | |
| 67 | 67 | ||
| 68 | 68 | # Metadata | |
| 69 | 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 | 27 | - Private Docker registry on your VPS | |
| 28 | 28 | - Domain-to-container routing via HTTP reverse proxy | |
| 29 | 29 | - Automatic deployment on image push | |
| 30 | + | - Auto-routing from image labels | |
| 31 | + | - Remote CLI management | |
| 30 | 32 | - Zero downtime updates | |
| 31 | 33 | - Persistent volumes from Dockerfile VOLUME directives | |
| 32 | 34 | - Environment variable management with secrets support | |
| @@ -48,7 +50,7 @@ curl -fsSL https://gordon.bnema.dev/install.sh | sh | |||
| 48 | 50 | # Or: curl -fsSL https://raw.githubusercontent.com/bnema/gordon/main/install.sh | sh | |
| 49 | 51 | ||
| 50 | 52 | # Start Gordon (generates config on first run) | |
| 51 | - | gordon start | |
| 53 | + | gordon serve | |
| 52 | 54 | ``` | |
| 53 | 55 | ||
| 54 | 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 | 7 | | Command | Description | | |
| 8 | 8 | |---------|-------------| | |
| 9 | - | | `gordon start` | Start the Gordon server | | |
| 9 | + | | `gordon serve` | Start the Gordon server | | |
| 10 | 10 | | `gordon reload` | Reload configuration and sync containers | | |
| 11 | 11 | | `gordon deploy` | Manually deploy or redeploy a specific route | | |
| 12 | 12 | | `gordon logs` | Display Gordon process logs | | |
| 13 | 13 | | `gordon version` | Print version information | | |
| 14 | 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 | 19 | ## Quick Reference | |
| 17 | 20 | ||
| 18 | 21 | ```bash | |
| 19 | 22 | # Start Gordon | |
| 20 | - | gordon start | |
| 21 | - | gordon start --config /path/to/config.toml | |
| 23 | + | gordon serve | |
| 24 | + | gordon serve --config /path/to/config.toml | |
| 22 | 25 | ||
| 23 | 26 | # Reload configuration | |
| 24 | 27 | gordon reload | |
| @@ -47,17 +50,35 @@ gordon auth internal | |||
| 47 | 50 | | Option | Description | | |
| 48 | 51 | |--------|-------------| | |
| 49 | 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 | 76 | Start the Gordon server with registry and proxy components. | |
| 56 | 77 | ||
| 57 | 78 | ### Synopsis | |
| 58 | 79 | ||
| 59 | 80 | ```bash | |
| 60 | - | gordon start [options] | |
| 81 | + | gordon serve [options] | |
| 61 | 82 | ``` | |
| 62 | 83 | ||
| 63 | 84 | ### Options | |
| @@ -90,7 +111,7 @@ On first run without a config file, Gordon creates a default configuration at `~ | |||
| 90 | 111 | ||
| 91 | 112 | ```bash | |
| 92 | 113 | # First run - creates default config | |
| 93 | - | gordon start | |
| 114 | + | gordon serve | |
| 94 | 115 | # Edit the config, then restart | |
| 95 | 116 | ``` | |
| 96 | 117 | ||
| @@ -98,15 +119,15 @@ gordon start | |||
| 98 | 119 | ||
| 99 | 120 | ```bash | |
| 100 | 121 | # Basic start | |
| 101 | - | gordon start | |
| 122 | + | gordon serve | |
| 102 | 123 | ||
| 103 | 124 | # With custom config | |
| 104 | - | gordon start --config /path/to/gordon.toml | |
| 105 | - | gordon start -c ./my-config.toml | |
| 125 | + | gordon serve --config /path/to/gordon.toml | |
| 126 | + | gordon serve -c ./my-config.toml | |
| 106 | 127 | ||
| 107 | 128 | # With environment override | |
| 108 | - | GORDON_SERVER_PORT=8080 gordon start | |
| 109 | - | GORDON_LOGGING_LEVEL=debug gordon start | |
| 129 | + | GORDON_SERVER_PORT=8080 gordon serve | |
| 130 | + | GORDON_LOGGING_LEVEL=debug gordon serve | |
| 110 | 131 | ``` | |
| 111 | 132 | ||
| 112 | 133 | ### Signals | |
| @@ -132,7 +153,7 @@ Description=Gordon Container Platform | |||
| 132 | 153 | [Service] | |
| 133 | 154 | Type=simple | |
| 134 | 155 | Restart=always | |
| 135 | - | ExecStart=/usr/local/bin/gordon start | |
| 156 | + | ExecStart=/usr/local/bin/gordon serve | |
| 136 | 157 | ||
| 137 | 158 | [Install] | |
| 138 | 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 | 666 | ## Exit Codes | |
| 533 | 667 | ||
| 534 | 668 | | Code | Meaning | | |
| @@ -542,8 +676,8 @@ password_hash = "gordon/registry/password_hash" | |||
| 542 | 676 | Gordon reads configuration from environment variables: | |
| 543 | 677 | ||
| 544 | 678 | ```bash | |
| 545 | - | GORDON_SERVER_PORT=8080 gordon start | |
| 546 | - | GORDON_LOGGING_LEVEL=debug gordon start | |
| 679 | + | GORDON_SERVER_PORT=8080 gordon serve | |
| 680 | + | GORDON_LOGGING_LEVEL=debug gordon serve | |
| 547 | 681 | ``` | |
| 548 | 682 | ||
| 549 | 683 | Pattern: `GORDON_SECTION_KEY` (uppercase, underscores) | |
M docs/config/auto-route.md +120 -6
| @@ -1,6 +1,9 @@ | |||
| 1 | 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 | 8 | ## Configuration | |
| 6 | 9 | ||
| @@ -123,7 +126,7 @@ enabled = true | |||
| 123 | 126 | ||
| 124 | 127 | ```toml | |
| 125 | 128 | [server] | |
| 126 | - | registry_domain = "registry.local" | |
| 129 | + | gordon_domain = "gordon.local" | |
| 127 | 130 | ||
| 128 | 131 | [auto_route] | |
| 129 | 132 | enabled = true | |
| @@ -133,8 +136,8 @@ enabled = true | |||
| 133 | 136 | ||
| 134 | 137 | Usage: | |
| 135 | 138 | ```bash | |
| 136 | - | docker push registry.local/myapp.local:latest | |
| 137 | - | docker push registry.local/api.local:latest | |
| 139 | + | docker push gordon.local/myapp.local:latest | |
| 140 | + | docker push gordon.local/api.local:latest | |
| 138 | 141 | # Both routes created automatically | |
| 139 | 142 | ``` | |
| 140 | 143 | ||
| @@ -152,14 +155,125 @@ enabled = true | |||
| 152 | 155 | # Other services can use auto-route | |
| 153 | 156 | ``` | |
| 154 | 157 | ||
| 155 | - | ## Limitations | |
| 158 | + | ## Limitations (Image Name Detection) | |
| 156 | 159 | ||
| 157 | 160 | - Auto-routes always use the pushed image tag | |
| 158 | 161 | - No support for HTTP-only routes (all HTTPS) | |
| 159 | 162 | - No automatic attachment configuration | |
| 160 | 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 | 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 | 7 | | Location | Purpose | | |
| 8 | 8 | |----------|---------| | |
| 9 | 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 | 12 | ## Minimal Configuration | |
| 13 | 13 | ||
| @@ -15,7 +15,7 @@ Gordon uses a single TOML configuration file located at `~/.config/gordon/gordon | |||
| 15 | 15 | [server] | |
| 16 | 16 | port = 8080 | |
| 17 | 17 | registry_port = 5000 | |
| 18 | - | registry_domain = "registry.mydomain.com" | |
| 18 | + | gordon_domain = "gordon.mydomain.com" | |
| 19 | 19 | ||
| 20 | 20 | [routes] | |
| 21 | 21 | "app.mydomain.com" = "myapp:latest" | |
| @@ -30,7 +30,7 @@ registry_domain = "registry.mydomain.com" | |||
| 30 | 30 | [server] | |
| 31 | 31 | port = 8080 # HTTP proxy port (default: 80) | |
| 32 | 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 | 34 | # data_dir = "~/.gordon" # Default for user installations | |
| 35 | 35 | ||
| 36 | 36 | # Secrets backend | |
| @@ -48,6 +48,10 @@ type = "token" # "password" or "token" | |||
| 48 | 48 | token_secret = "gordon/registry/token_secret" | |
| 49 | 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 | 55 | # Logging | |
| 52 | 56 | [logging] | |
| 53 | 57 | level = "info" # trace, debug, info, warn, error | |
| @@ -109,6 +113,7 @@ enabled = false # Auto-create routes from image names | |||
| 109 | 113 | | `[server]` | Core server settings | [Server](./server.md) | | |
| 110 | 114 | | `[secrets]` | Secrets backend configuration | [Secrets](./secrets.md) | | |
| 111 | 115 | | `[registry_auth]` | Registry authentication | [Registry Auth](./registry-auth.md) | | |
| 116 | + | | `[deploy]` | Deployment behavior | [Deploy](./deploy.md) | | |
| 112 | 117 | | `[logging]` | Logging configuration | [Logging](./logging.md) | | |
| 113 | 118 | | `[env]` | Environment variable settings | [Environment](./env.md) | | |
| 114 | 119 | | `[volumes]` | Volume management | [Volumes](./volumes.md) | | |
| @@ -130,6 +135,7 @@ enabled = false # Auto-create routes from image names | |||
| 130 | 135 | | `registry_auth.enabled` | `false` | | |
| 131 | 136 | | `registry_auth.type` | `"password"` | | |
| 132 | 137 | | `registry_auth.token_expiry` | `"720h"` | | |
| 138 | + | | `deploy.pull_policy` | `"if-tag-changed"` | | |
| 133 | 139 | | `logging.level` | `"info"` | | |
| 134 | 140 | | `logging.format` | `"console"` | | |
| 135 | 141 | | `logging.file.enabled` | `false` | | |
| @@ -158,14 +164,15 @@ The following settings require a restart to take effect: | |||
| 158 | 164 | - `server.data_dir` | |
| 159 | 165 | - `secrets.backend` | |
| 160 | 166 | - `registry_auth` settings | |
| 167 | + | - `deploy.pull_policy` | |
| 161 | 168 | ||
| 162 | 169 | ## Environment Variable Override | |
| 163 | 170 | ||
| 164 | 171 | Configuration values can be overridden with environment variables: | |
| 165 | 172 | ||
| 166 | 173 | ```bash | |
| 167 | - | GORDON_SERVER_PORT=8080 gordon start | |
| 168 | - | GORDON_LOGGING_LEVEL=debug gordon start | |
| 174 | + | GORDON_SERVER_PORT=8080 gordon serve | |
| 175 | + | GORDON_LOGGING_LEVEL=debug gordon serve | |
| 169 | 176 | ``` | |
| 170 | 177 | ||
| 171 | 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 | 8 | [server] | |
| 9 | 9 | port = 8080 # HTTP proxy port | |
| 10 | 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 | 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 | 19 | | `port` | int | `80` | HTTP proxy port for routing traffic to containers | | |
| 20 | 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 | 23 | | `data_dir` | string | `~/.gordon` | Directory for registry data, logs, and env files | | |
| 23 | 24 | ||
| 24 | 25 | ## Port Configuration | |
| @@ -54,19 +55,24 @@ Docker/Podman clients push to this port: | |||
| 54 | 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 | 62 | ```toml | |
| 62 | 63 | [server] | |
| 63 | - | registry_domain = "registry.mydomain.com" | |
| 64 | + | gordon_domain = "gordon.mydomain.com" | |
| 64 | 65 | ``` | |
| 65 | 66 | ||
| 66 | 67 | This domain is used for: | |
| 67 | - | - Docker login authentication | |
| 68 | - | - Image push/pull operations | |
| 69 | - | - Internal registry references | |
| 68 | + | - Docker login and image push/pull operations | |
| 69 | + | - Admin API access (`/admin/*` endpoints) | |
| 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 | 77 | ## Data Directory | |
| 72 | 78 | ||
| @@ -108,7 +114,7 @@ Gordon creates directories with secure permissions: | |||
| 108 | 114 | [server] | |
| 109 | 115 | port = 8080 | |
| 110 | 116 | registry_port = 5000 | |
| 111 | - | registry_domain = "registry.local" | |
| 117 | + | gordon_domain = "gordon.local" | |
| 112 | 118 | data_dir = "./dev-data" | |
| 113 | 119 | ``` | |
| 114 | 120 | ||
| @@ -118,7 +124,7 @@ data_dir = "./dev-data" | |||
| 118 | 124 | [server] | |
| 119 | 125 | port = 8080 | |
| 120 | 126 | registry_port = 5000 | |
| 121 | - | registry_domain = "registry.company.com" | |
| 127 | + | gordon_domain = "gordon.company.com" | |
| 122 | 128 | data_dir = "/var/lib/gordon" | |
| 123 | 129 | ``` | |
| 124 | 130 | ||
| @@ -128,7 +134,7 @@ data_dir = "/var/lib/gordon" | |||
| 128 | 134 | [server] | |
| 129 | 135 | port = 8080 # High port for rootless | |
| 130 | 136 | registry_port = 5000 | |
| 131 | - | registry_domain = "registry.mydomain.com" | |
| 137 | + | gordon_domain = "gordon.mydomain.com" | |
| 132 | 138 | ``` | |
| 133 | 139 | ||
| 134 | 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 | 21 | ```bash | |
| 22 | 22 | # First run creates the default config | |
| 23 | - | gordon start | |
| 23 | + | gordon serve | |
| 24 | 24 | # Press Ctrl+C to stop | |
| 25 | 25 | ``` | |
| 26 | 26 | ||
| @@ -34,7 +34,7 @@ Edit `~/.config/gordon/gordon.toml`: | |||
| 34 | 34 | [server] | |
| 35 | 35 | port = 8080 # Proxy port (use with Cloudflare) | |
| 36 | 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 | 39 | [routes] | |
| 40 | 40 | "app.mydomain.com" = "myapp:latest" # Domain → Image mapping | |
| @@ -63,7 +63,7 @@ Description=Gordon Container Platform | |||
| 63 | 63 | [Service] | |
| 64 | 64 | Type=simple | |
| 65 | 65 | Restart=always | |
| 66 | - | ExecStart=/usr/local/bin/gordon start | |
| 66 | + | ExecStart=/usr/local/bin/gordon serve | |
| 67 | 67 | ||
| 68 | 68 | [Install] | |
| 69 | 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 | 154 | ```bash | |
| 155 | 155 | # Run once to generate config, then stop | |
| 156 | - | gordon start | |
| 156 | + | gordon serve | |
| 157 | 157 | # Press Ctrl+C | |
| 158 | 158 | ||
| 159 | 159 | # Edit configuration | |
| @@ -166,7 +166,7 @@ Minimum required configuration: | |||
| 166 | 166 | [server] | |
| 167 | 167 | port = 8080 | |
| 168 | 168 | registry_port = 5000 | |
| 169 | - | registry_domain = "registry.yourdomain.com" | |
| 169 | + | gordon_domain = "gordon.yourdomain.com" | |
| 170 | 170 | ||
| 171 | 171 | [routes] | |
| 172 | 172 | "app.yourdomain.com" = "myapp:latest" | |
| @@ -189,7 +189,7 @@ After=podman.socket | |||
| 189 | 189 | Type=simple | |
| 190 | 190 | Restart=always | |
| 191 | 191 | RestartSec=5 | |
| 192 | - | ExecStart=/usr/local/bin/gordon start | |
| 192 | + | ExecStart=/usr/local/bin/gordon serve | |
| 193 | 193 | ||
| 194 | 194 | [Install] | |
| 195 | 195 | WantedBy=default.target | |
| @@ -219,7 +219,7 @@ Requires=docker.service | |||
| 219 | 219 | Type=simple | |
| 220 | 220 | Restart=always | |
| 221 | 221 | RestartSec=5 | |
| 222 | - | ExecStart=/usr/local/bin/gordon start | |
| 222 | + | ExecStart=/usr/local/bin/gordon serve | |
| 223 | 223 | User=root | |
| 224 | 224 | ||
| 225 | 225 | [Install] | |
M docs/reference/troubleshooting.md +1 -1
| @@ -35,7 +35,7 @@ Common issues and solutions when using Gordon. | |||
| 35 | 35 | ```bash | |
| 36 | 36 | systemctl --user status gordon | |
| 37 | 37 | # or | |
| 38 | - | pgrep -f "gordon start" | |
| 38 | + | pgrep -f "gordon serve" | |
| 39 | 39 | ``` | |
| 40 | 40 | ||
| 41 | 41 | 2. Check registry port is accessible: | |
M go.mod +20 -2
| @@ -4,6 +4,9 @@ go 1.25.5 | |||
| 4 | 4 | ||
| 5 | 5 | require ( | |
| 6 | 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 | 10 | github.com/docker/docker v28.5.2+incompatible | |
| 8 | 11 | github.com/docker/go-connections v0.5.0 | |
| 9 | 12 | github.com/golang-jwt/jwt/v5 v5.3.0 | |
| @@ -17,8 +20,23 @@ require ( | |||
| 17 | 20 | ) | |
| 18 | 21 | ||
| 19 | 22 | require ( | |
| 23 | + | github.com/atotto/clipboard v0.1.4 // indirect | |
| 24 | + | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect | |
| 20 | 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 | 38 | github.com/rs/zerolog v1.34.0 // indirect | |
| 39 | + | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect | |
| 22 | 40 | go.yaml.in/yaml/v3 v3.0.4 // indirect | |
| 23 | 41 | ) | |
| 24 | 42 | ||
| @@ -27,7 +45,7 @@ require ( | |||
| 27 | 45 | github.com/pmezard/go-difflib v1.0.0 // indirect | |
| 28 | 46 | github.com/stretchr/objx v0.5.2 // indirect | |
| 29 | 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 | 51 | require ( | |
| @@ -51,7 +69,7 @@ require ( | |||
| 51 | 69 | github.com/morikuni/aec v1.0.0 // indirect | |
| 52 | 70 | github.com/opencontainers/go-digest v1.0.0 // indirect | |
| 53 | 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 | 73 | github.com/pkg/errors v0.9.1 // indirect | |
| 56 | 74 | github.com/sagikazarmark/locafero v0.12.0 // indirect | |
| 57 | 75 | github.com/spf13/afero v1.15.0 | |
M go.sum +46 -2
| @@ -2,12 +2,34 @@ github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEK | |||
| 2 | 2 | github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= | |
| 3 | 3 | github.com/Microsoft/go-winio v0.4.14 h1:+hMXMk01us9KgxGb7ftKQt2Xpf5hH/yky+TDA+qxleU= | |
| 4 | 4 | github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA= | |
| 5 | + | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= | |
| 6 | + | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= | |
| 7 | + | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= | |
| 8 | + | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= | |
| 9 | + | github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= | |
| 10 | + | github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= | |
| 5 | 11 | github.com/bnema/zerowrap v1.3.0 h1:NHrO6d34rbFOmbLSO3n1TbqA/g5djgVfTGmCoVMOlDU= | |
| 6 | 12 | github.com/bnema/zerowrap v1.3.0/go.mod h1:30FCqzwS7FNTNH1GC+ScGC99GITOzOaCYW266L4QF5s= | |
| 7 | 13 | github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8= | |
| 8 | 14 | github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= | |
| 9 | 15 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= | |
| 10 | 16 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= | |
| 17 | + | github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= | |
| 18 | + | github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= | |
| 19 | + | github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= | |
| 20 | + | github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= | |
| 21 | + | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= | |
| 22 | + | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= | |
| 23 | + | github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= | |
| 24 | + | github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= | |
| 25 | + | github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= | |
| 26 | + | github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= | |
| 27 | + | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= | |
| 28 | + | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= | |
| 29 | + | github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= | |
| 30 | + | github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= | |
| 31 | + | github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= | |
| 32 | + | github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= | |
| 11 | 33 | github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= | |
| 12 | 34 | github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= | |
| 13 | 35 | github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= | |
| @@ -26,6 +48,8 @@ github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj | |||
| 26 | 48 | github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= | |
| 27 | 49 | github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= | |
| 28 | 50 | github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= | |
| 51 | + | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= | |
| 52 | + | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= | |
| 29 | 53 | github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= | |
| 30 | 54 | github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= | |
| 31 | 55 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= | |
| @@ -55,6 +79,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= | |||
| 55 | 79 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= | |
| 56 | 80 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= | |
| 57 | 81 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= | |
| 82 | + | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= | |
| 83 | + | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= | |
| 58 | 84 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= | |
| 59 | 85 | github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= | |
| 60 | 86 | github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= | |
| @@ -62,6 +88,10 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ | |||
| 62 | 88 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= | |
| 63 | 89 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= | |
| 64 | 90 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= | |
| 91 | + | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= | |
| 92 | + | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= | |
| 93 | + | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= | |
| 94 | + | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= | |
| 65 | 95 | github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= | |
| 66 | 96 | github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= | |
| 67 | 97 | github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= | |
| @@ -72,6 +102,12 @@ github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= | |||
| 72 | 102 | github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= | |
| 73 | 103 | github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= | |
| 74 | 104 | github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= | |
| 105 | + | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= | |
| 106 | + | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= | |
| 107 | + | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= | |
| 108 | + | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= | |
| 109 | + | github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= | |
| 110 | + | github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= | |
| 75 | 111 | github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= | |
| 76 | 112 | github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= | |
| 77 | 113 | github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= | |
| @@ -83,6 +119,9 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= | |||
| 83 | 119 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= | |
| 84 | 120 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= | |
| 85 | 121 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= | |
| 122 | + | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= | |
| 123 | + | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= | |
| 124 | + | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= | |
| 86 | 125 | github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= | |
| 87 | 126 | github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= | |
| 88 | 127 | github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= | |
| @@ -113,6 +152,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu | |||
| 113 | 152 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= | |
| 114 | 153 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= | |
| 115 | 154 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= | |
| 155 | + | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= | |
| 156 | + | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= | |
| 116 | 157 | go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= | |
| 117 | 158 | go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= | |
| 118 | 159 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= | |
| @@ -137,10 +178,13 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= | |||
| 137 | 178 | go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= | |
| 138 | 179 | golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= | |
| 139 | 180 | golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= | |
| 181 | + | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= | |
| 182 | + | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= | |
| 140 | 183 | golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= | |
| 141 | 184 | golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= | |
| 142 | 185 | golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= | |
| 143 | 186 | golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= | |
| 187 | + | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | |
| 144 | 188 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | |
| 145 | 189 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | |
| 146 | 190 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | |
| @@ -150,8 +194,8 @@ golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= | |||
| 150 | 194 | golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= | |
| 151 | 195 | golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= | |
| 152 | 196 | golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= | |
| 153 | - | golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= | |
| 154 | - | golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= | |
| 197 | + | golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= | |
| 198 | + | golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= | |
| 155 | 199 | google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 h1:Kog3KlB4xevJlAcbbbzPfRG0+X9fdoGM+UBRKVz6Wr0= | |
| 156 | 200 | google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237/go.mod h1:ezi0AVyMKDWy5xAncvjLWH7UcLBB5n7y2fQ8MzjJcto= | |
| 157 | 201 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 h1:cJfm9zPbe1e873mHJzmQ1nwVEeRDU/T1wXDK2kUSU34= | |
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 | 3 | package cli | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | - | "context" | |
| 7 | - | ||
| 6 | + | "gordon/internal/adapters/in/cli/remote" | |
| 8 | 7 | "gordon/internal/app" | |
| 9 | 8 | ||
| 10 | 9 | "github.com/spf13/cobra" | |
| @@ -15,6 +14,10 @@ var ( | |||
| 15 | 14 | Version = "dev" | |
| 16 | 15 | Commit = "unknown" | |
| 17 | 16 | BuildDate = "unknown" | |
| 17 | + | ||
| 18 | + | // Global flags for remote targeting | |
| 19 | + | remoteFlag string | |
| 20 | + | tokenFlag string | |
| 18 | 21 | ) | |
| 19 | 22 | ||
| 20 | 23 | // NewRootCmd creates the root command for Gordon CLI. | |
| @@ -26,37 +29,50 @@ func NewRootCmd() *cobra.Command { | |||
| 26 | 29 | a Docker registry with automatic container deployment capabilities. | |
| 27 | 30 | ||
| 28 | 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 | |
| 33 | - | rootCmd.AddCommand(newStartCmd()) | |
| 38 | + | // Add persistent flags for remote targeting | |
| 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 | 45 | rootCmd.AddCommand(newReloadCmd()) | |
| 35 | 46 | rootCmd.AddCommand(newDeployCmd()) | |
| 36 | 47 | rootCmd.AddCommand(newLogsCmd()) | |
| 37 | 48 | rootCmd.AddCommand(newVersionCmd()) | |
| 38 | 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 | 57 | return rootCmd | |
| 41 | 58 | } | |
| 42 | 59 | ||
| 43 | - | // newStartCmd creates the start command. | |
| 44 | - | func newStartCmd() *cobra.Command { | |
| 45 | - | var configPath string | |
| 46 | - | ||
| 47 | - | cmd := &cobra.Command{ | |
| 48 | - | Use: "start", | |
| 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 | - | }, | |
| 60 | + | // GetRemoteClient returns a remote client if targeting a remote instance, | |
| 61 | + | // or nil if running locally. | |
| 62 | + | func GetRemoteClient() (*remote.Client, bool) { | |
| 63 | + | url, token, isRemote := remote.ResolveRemote(remoteFlag, tokenFlag) | |
| 64 | + | if !isRemote { | |
| 65 | + | return nil, false | |
| 54 | 66 | } | |
| 55 | 67 | ||
| 56 | - | // Add flags | |
| 57 | - | cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file") | |
| 68 | + | client := remote.NewClient(url, remote.WithToken(token)) | |
| 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 | 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 | 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 | 51 | func RequestLogger(log zerowrap.Logger) func(http.Handler) http.Handler { | |
| 51 | 52 | return func(next http.Handler) http.Handler { | |
| 52 | 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 | 59 | // Get client IP (handle X-Forwarded-For and X-Real-IP headers) | |
| 59 | 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 | 66 | // Call the next handler | |
| 62 | 67 | next.ServeHTTP(rw, r) | |
| 63 | 68 | ||
M internal/adapters/in/http/registry/handler.go +4 -13
| @@ -2,7 +2,6 @@ | |||
| 2 | 2 | package registry | |
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | - | "crypto/sha256" | |
| 6 | 5 | "encoding/json" | |
| 7 | 6 | "errors" | |
| 8 | 7 | "fmt" | |
| @@ -14,7 +13,6 @@ import ( | |||
| 14 | 13 | "github.com/bnema/zerowrap" | |
| 15 | 14 | ||
| 16 | 15 | "gordon/internal/boundaries/in" | |
| 17 | - | "gordon/internal/boundaries/out" | |
| 18 | 16 | "gordon/internal/domain" | |
| 19 | 17 | "gordon/pkg/manifest" | |
| 20 | 18 | "gordon/pkg/validation" | |
| @@ -30,22 +28,16 @@ const ( | |||
| 30 | 28 | // Handler implements the HTTP handler for Docker Registry API v2. | |
| 31 | 29 | type Handler struct { | |
| 32 | 30 | registrySvc in.RegistryService | |
| 33 | - | blobStorage out.BlobStorage | |
| 34 | - | eventBus out.EventPublisher | |
| 35 | 31 | log zerowrap.Logger | |
| 36 | 32 | } | |
| 37 | 33 | ||
| 38 | 34 | // NewHandler creates a new registry HTTP handler. | |
| 39 | 35 | func NewHandler( | |
| 40 | 36 | registrySvc in.RegistryService, | |
| 41 | - | blobStorage out.BlobStorage, | |
| 42 | - | eventBus out.EventPublisher, | |
| 43 | 37 | log zerowrap.Logger, | |
| 44 | 38 | ) *Handler { | |
| 45 | 39 | return &Handler{ | |
| 46 | 40 | registrySvc: registrySvc, | |
| 47 | - | blobStorage: blobStorage, | |
| 48 | - | eventBus: eventBus, | |
| 49 | 41 | log: log, | |
| 50 | 42 | } | |
| 51 | 43 | } | |
| @@ -331,14 +323,13 @@ func (h *Handler) handlePutManifest(w http.ResponseWriter, r *http.Request) { | |||
| 331 | 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 | 328 | log.Error().Err(err).Str("name", name).Str("reference", reference).Msg("failed to store manifest") | |
| 336 | 329 | http.Error(w, "Internal Server Error", http.StatusInternalServerError) | |
| 337 | 330 | return | |
| 338 | 331 | } | |
| 339 | 332 | ||
| 340 | - | // Calculate digest for Docker-Content-Digest header | |
| 341 | - | digest := fmt.Sprintf("sha256:%x", sha256.Sum256(data)) | |
| 342 | 333 | w.Header().Set("Docker-Content-Digest", digest) | |
| 343 | 334 | w.Header().Set("Location", fmt.Sprintf("/v2/%s/manifests/%s", name, reference)) | |
| 344 | 335 | w.WriteHeader(http.StatusCreated) | |
| @@ -352,7 +343,7 @@ func (h *Handler) handleGetBlob(w http.ResponseWriter, r *http.Request) { | |||
| 352 | 343 | ||
| 353 | 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 | 347 | if err != nil { | |
| 357 | 348 | log.Warn().Err(err).Str("name", name).Str("digest", digest).Msg("blob not found") | |
| 358 | 349 | http.NotFound(w, r) | |
| @@ -422,7 +413,7 @@ func (h *Handler) handleBlobUpload(w http.ResponseWriter, r *http.Request) { | |||
| 422 | 413 | } | |
| 423 | 414 | ||
| 424 | 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 | 417 | if err != nil { | |
| 427 | 418 | log.Error().Err(err).Msg("failed to append blob chunk") | |
| 428 | 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 | 12 | "github.com/stretchr/testify/mock" | |
| 13 | 13 | ||
| 14 | 14 | inmocks "gordon/internal/boundaries/in/mocks" | |
| 15 | - | "gordon/internal/boundaries/out/mocks" | |
| 16 | 15 | "gordon/internal/domain" | |
| 17 | 16 | ) | |
| 18 | 17 | ||
| @@ -22,10 +21,8 @@ func testLogger() zerowrap.Logger { | |||
| 22 | 21 | ||
| 23 | 22 | func TestHandler_Base(t *testing.T) { | |
| 24 | 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 | 27 | req := httptest.NewRequest("GET", "/v2/", nil) | |
| 31 | 28 | rec := httptest.NewRecorder() | |
| @@ -38,10 +35,8 @@ func TestHandler_Base(t *testing.T) { | |||
| 38 | 35 | ||
| 39 | 36 | func TestHandler_NotFound(t *testing.T) { | |
| 40 | 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 | 41 | req := httptest.NewRequest("GET", "/v2/unknown/path", nil) | |
| 47 | 42 | rec := httptest.NewRecorder() | |
| @@ -53,10 +48,8 @@ func TestHandler_NotFound(t *testing.T) { | |||
| 53 | 48 | ||
| 54 | 49 | func TestHandler_GetManifest_Success(t *testing.T) { | |
| 55 | 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 | 54 | manifestData := []byte(`{"schemaVersion": 2}`) | |
| 62 | 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 | 72 | func TestHandler_GetManifest_HEAD(t *testing.T) { | |
| 80 | 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 | 77 | manifestData := []byte(`{"schemaVersion": 2}`) | |
| 87 | 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 | 96 | func TestHandler_GetManifest_NotFound(t *testing.T) { | |
| 106 | 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 | 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 | 111 | func TestHandler_GetManifest_NestedName(t *testing.T) { | |
| 123 | 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 | 116 | manifestData := []byte(`{"schemaVersion": 2}`) | |
| 130 | 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 | 132 | func TestHandler_PutManifest_Success(t *testing.T) { | |
| 146 | 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 | 137 | manifestData := []byte(`{"schemaVersion": 2}`) | |
| 153 | 138 | registrySvc.EXPECT().PutManifest(mock.Anything, mock.MatchedBy(func(m *domain.Manifest) bool { | |
| 154 | 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 | 142 | req := httptest.NewRequest("PUT", "/v2/myapp/manifests/latest", bytes.NewReader(manifestData)) | |
| 158 | 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 | 146 | handler.ServeHTTP(rec, req) | |
| 162 | 147 | ||
| 163 | 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 | 150 | assert.Contains(t, rec.Header().Get("Location"), "/v2/myapp/manifests/latest") | |
| 166 | 151 | } | |
| 167 | 152 | ||
| 168 | 153 | func TestHandler_PutManifest_MissingContentType(t *testing.T) { | |
| 169 | 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 | 158 | manifestData := []byte(`{"schemaVersion": 2}`) | |
| 176 | 159 | ||
| @@ -185,13 +168,11 @@ func TestHandler_PutManifest_MissingContentType(t *testing.T) { | |||
| 185 | 168 | ||
| 186 | 169 | func TestHandler_PutManifest_StorageError(t *testing.T) { | |
| 187 | 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 | 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 | 177 | req := httptest.NewRequest("PUT", "/v2/myapp/manifests/latest", bytes.NewReader(manifestData)) | |
| 197 | 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 | 186 | func TestHandler_ManifestRoutes_MethodNotAllowed(t *testing.T) { | |
| 206 | 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 | 191 | req := httptest.NewRequest("DELETE", "/v2/myapp/manifests/latest", nil) | |
| 213 | 192 | rec := httptest.NewRecorder() | |
| @@ -219,10 +198,8 @@ func TestHandler_ManifestRoutes_MethodNotAllowed(t *testing.T) { | |||
| 219 | 198 | ||
| 220 | 199 | func TestHandler_GetBlob_Success(t *testing.T) { | |
| 221 | 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 | 204 | // Create a temp file for http.ServeFile | |
| 228 | 205 | tmpFile, err := os.CreateTemp("", "blob-*") | |
| @@ -233,7 +210,7 @@ func TestHandler_GetBlob_Success(t *testing.T) { | |||
| 233 | 210 | _, err = tmpFile.Write(blobContent) | |
| 234 | 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 | 215 | req := httptest.NewRequest("GET", "/v2/myapp/blobs/sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", nil) | |
| 239 | 216 | rec := httptest.NewRecorder() | |
| @@ -246,12 +223,10 @@ func TestHandler_GetBlob_Success(t *testing.T) { | |||
| 246 | 223 | ||
| 247 | 224 | func TestHandler_GetBlob_NotFound(t *testing.T) { | |
| 248 | 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 | 231 | req := httptest.NewRequest("GET", "/v2/myapp/blobs/sha256:0000000000000000000000000000000000000000000000000000000000000000", nil) | |
| 257 | 232 | rec := httptest.NewRecorder() | |
| @@ -263,10 +238,8 @@ func TestHandler_GetBlob_NotFound(t *testing.T) { | |||
| 263 | 238 | ||
| 264 | 239 | func TestHandler_BlobRoutes_MethodNotAllowed(t *testing.T) { | |
| 265 | 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 | 244 | req := httptest.NewRequest("POST", "/v2/myapp/blobs/sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", nil) | |
| 272 | 245 | rec := httptest.NewRecorder() | |
| @@ -278,10 +251,8 @@ func TestHandler_BlobRoutes_MethodNotAllowed(t *testing.T) { | |||
| 278 | 251 | ||
| 279 | 252 | func TestHandler_StartBlobUpload_Success(t *testing.T) { | |
| 280 | 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 | 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 | 269 | func TestHandler_StartBlobUpload_Error(t *testing.T) { | |
| 299 | 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 | 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 | 284 | func TestHandler_BlobUpload_PATCH(t *testing.T) { | |
| 316 | 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 | 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 | 292 | req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/1234567890-myapp", bytes.NewReader(chunkData)) | |
| 326 | 293 | rec := httptest.NewRecorder() | |
| @@ -333,13 +300,11 @@ func TestHandler_BlobUpload_PATCH(t *testing.T) { | |||
| 333 | 300 | ||
| 334 | 301 | func TestHandler_BlobUpload_PUT_Finalize(t *testing.T) { | |
| 335 | 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 | 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 | 308 | registrySvc.EXPECT().FinishUpload(mock.Anything, "1234567890-myapp", "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(nil) | |
| 344 | 309 | ||
| 345 | 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 | 320 | func TestHandler_BlobUpload_PUT_DigestMismatch(t *testing.T) { | |
| 356 | 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 | 325 | chunkData := []byte("final chunk") | |
| 363 | 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 | 337 | func TestHandler_ListTags_Success(t *testing.T) { | |
| 375 | 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 | 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 | 355 | func TestHandler_ListTags_NotFound(t *testing.T) { | |
| 395 | 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 | 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 | 370 | func TestHandler_ListTags_MethodNotAllowed(t *testing.T) { | |
| 412 | 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 | 375 | req := httptest.NewRequest("POST", "/v2/myapp/tags/list", nil) | |
| 419 | 376 | rec := httptest.NewRecorder() | |
| @@ -425,10 +382,8 @@ func TestHandler_ListTags_MethodNotAllowed(t *testing.T) { | |||
| 425 | 382 | ||
| 426 | 383 | func TestHandler_RegisterRoutes(t *testing.T) { | |
| 427 | 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 | 388 | mux := http.NewServeMux() | |
| 434 | 389 | handler.RegisterRoutes(mux) | |
M internal/adapters/out/docker/runtime.go +142 -1
| @@ -2,11 +2,14 @@ | |||
| 2 | 2 | package docker | |
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | + | "archive/tar" | |
| 5 | 6 | "context" | |
| 6 | 7 | "encoding/base64" | |
| 7 | 8 | "encoding/json" | |
| 8 | 9 | "fmt" | |
| 9 | 10 | "io" | |
| 11 | + | "path/filepath" | |
| 12 | + | "sort" | |
| 10 | 13 | "strconv" | |
| 11 | 14 | "strings" | |
| 12 | 15 | ||
| @@ -264,7 +267,7 @@ func (r *Runtime) ListContainers(ctx context.Context, all bool) ([]*domain.Conta | |||
| 264 | 267 | ID: c.ID, | |
| 265 | 268 | Image: c.Image, | |
| 266 | 269 | Name: name, | |
| 267 | - | Status: c.Status, | |
| 270 | + | Status: c.State, // Use State (e.g., "running") not Status (e.g., "Up 2 days") | |
| 268 | 271 | Ports: ports, | |
| 269 | 272 | Labels: c.Labels, | |
| 270 | 273 | }) | |
| @@ -619,10 +622,42 @@ func (r *Runtime) GetImageExposedPorts(ctx context.Context, imageRef string) ([] | |||
| 619 | 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 | 629 | log.Info().Ints("exposed_ports", ports).Msg("found exposed ports in image") | |
| 623 | 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 | 661 | // GetContainerExposedPorts gets all exposed ports from a running container. | |
| 627 | 662 | func (r *Runtime) GetContainerExposedPorts(ctx context.Context, containerID string) ([]int, error) { | |
| 628 | 663 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| @@ -1018,3 +1053,109 @@ func (r *Runtime) DisconnectContainerFromNetwork(ctx context.Context, containerN | |||
| 1018 | 1053 | log.Info().Msg("container disconnected from network") | |
| 1019 | 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 | 22 | // Adapters - Output | |
| 23 | 23 | "gordon/internal/adapters/out/docker" | |
| 24 | + | "gordon/internal/adapters/out/domainsecrets" | |
| 24 | 25 | "gordon/internal/adapters/out/envloader" | |
| 25 | 26 | "gordon/internal/adapters/out/eventbus" | |
| 26 | 27 | "gordon/internal/adapters/out/filesystem" | |
| 28 | + | "gordon/internal/adapters/out/httpprober" | |
| 27 | 29 | "gordon/internal/adapters/out/logwriter" | |
| 28 | 30 | "gordon/internal/adapters/out/secrets" | |
| 29 | 31 | "gordon/internal/adapters/out/tokenstore" | |
| 30 | 32 | ||
| 33 | + | "golang.org/x/time/rate" | |
| 34 | + | ||
| 31 | 35 | // Adapters - Input | |
| 36 | + | "gordon/internal/adapters/in/http/admin" | |
| 32 | 37 | "gordon/internal/adapters/in/http/middleware" | |
| 33 | 38 | "gordon/internal/adapters/in/http/registry" | |
| 34 | 39 | ||
| @@ -42,8 +47,10 @@ import ( | |||
| 42 | 47 | "gordon/internal/usecase/auth" | |
| 43 | 48 | "gordon/internal/usecase/config" | |
| 44 | 49 | "gordon/internal/usecase/container" | |
| 50 | + | "gordon/internal/usecase/health" | |
| 45 | 51 | "gordon/internal/usecase/proxy" | |
| 46 | 52 | registrySvc "gordon/internal/usecase/registry" | |
| 53 | + | secretsSvc "gordon/internal/usecase/secrets" | |
| 47 | 54 | ) | |
| 48 | 55 | ||
| 49 | 56 | // Config holds the application configuration. | |
| @@ -51,7 +58,8 @@ type Config struct { | |||
| 51 | 58 | Server struct { | |
| 52 | 59 | Port int `mapstructure:"port"` | |
| 53 | 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 | 63 | DataDir string `mapstructure:"data_dir"` | |
| 56 | 64 | } `mapstructure:"server"` | |
| 57 | 65 | ||
| @@ -108,8 +116,10 @@ type services struct { | |||
| 108 | 116 | proxySvc *proxy.Service | |
| 109 | 117 | authSvc *auth.Service | |
| 110 | 118 | tokenHandler *registry.TokenHandler | |
| 119 | + | adminHandler *admin.Handler | |
| 111 | 120 | internalRegUser string | |
| 112 | 121 | internalRegPass string | |
| 122 | + | envDir string | |
| 113 | 123 | } | |
| 114 | 124 | ||
| 115 | 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 | 157 | // Register event handlers | |
| 148 | - | if err := registerEventHandlers(ctx, svc); err != nil { | |
| 158 | + | if err := registerEventHandlers(ctx, svc, cfg); err != nil { | |
| 149 | 159 | return err | |
| 150 | 160 | } | |
| 151 | 161 | ||
| @@ -180,6 +190,11 @@ func initConfig(configPath string) (*viper.Viper, Config, error) { | |||
| 180 | 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 | 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 | 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 | 317 | return svc, nil | |
| 279 | 318 | } | |
| 280 | 319 | ||
| @@ -636,6 +675,7 @@ func createContainerService(v *viper.Viper, cfg Config, svc *services) *containe | |||
| 636 | 675 | RegistryPassword: cfg.RegistryAuth.Password, | |
| 637 | 676 | InternalRegistryUsername: svc.internalRegUser, | |
| 638 | 677 | InternalRegistryPassword: svc.internalRegPass, | |
| 678 | + | PullPolicy: v.GetString("deploy.pull_policy"), | |
| 639 | 679 | VolumeAutoCreate: v.GetBool("volumes.auto_create"), | |
| 640 | 680 | VolumePrefix: v.GetString("volumes.prefix"), | |
| 641 | 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 | 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 | 693 | imagePushedHandler := container.NewImagePushedHandler(ctx, svc.containerSvc, svc.configSvc) | |
| 654 | 694 | if err := svc.eventBus.Subscribe(imagePushedHandler); err != nil { | |
| 655 | 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 | 705 | configReloadHandler := container.NewConfigReloadHandler(ctx, svc.containerSvc, svc.configSvc) | |
| 659 | 706 | if err := svc.eventBus.Subscribe(configReloadHandler); err != nil { | |
| 660 | 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 | 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 | 734 | func setupConfigHotReload(ctx context.Context, v *viper.Viper, svc *services, log zerowrap.Logger) { | |
| 684 | 735 | v.OnConfigChange(func(e fsnotify.Event) { | |
| 685 | 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 | 740 | return | |
| 690 | 741 | } | |
| 691 | 742 | ||
| 692 | - | if err := svc.configSvc.Load(ctx); err != nil { | |
| 693 | - | log.Error().Err(err).Msg("failed to reload configuration") | |
| 694 | - | return | |
| 695 | - | } | |
| 696 | - | ||
| 743 | + | // Update proxy config from viper (reads directly from viper, not memory) | |
| 697 | 744 | svc.proxySvc.UpdateConfig(proxy.Config{ | |
| 698 | 745 | RegistryDomain: v.GetString("server.registry_domain"), | |
| 699 | 746 | RegistryPort: v.GetInt("server.registry_port"), | |
| @@ -704,10 +751,7 @@ func setupConfigHotReload(ctx context.Context, v *viper.Viper, svc *services, lo | |||
| 704 | 751 | log.Warn().Err(err).Msg("failed to refresh proxy targets") | |
| 705 | 752 | } | |
| 706 | 753 | ||
| 707 | - | // Publish config reload event to trigger container updates | |
| 708 | - | if err := svc.eventBus.Publish(domain.EventConfigReload, nil); err != nil { | |
| 709 | - | log.Error().Err(err).Msg("failed to publish config reload event") | |
| 710 | - | } | |
| 754 | + | log.Debug().Msg("config hot reload complete (routes unchanged, memory is source of truth)") | |
| 711 | 755 | }) | |
| 712 | 756 | v.WatchConfig() | |
| 713 | 757 | } | |
| @@ -728,7 +772,7 @@ func syncAndAutoStart(ctx context.Context, svc *services, log zerowrap.Logger) { | |||
| 728 | 772 | ||
| 729 | 773 | // createHTTPHandlers creates HTTP handlers with middleware. | |
| 730 | 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 | 777 | registryMiddlewares := []func(http.Handler) http.Handler{ | |
| 734 | 778 | middleware.PanicRecovery(log), | |
| @@ -759,6 +803,22 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||
| 759 | 803 | } | |
| 760 | 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 | 822 | proxyMiddlewares := []func(http.Handler) http.Handler{ | |
| 763 | 823 | middleware.PanicRecovery(log), | |
| 764 | 824 | middleware.RequestLogger(log), | |
| @@ -1063,6 +1123,14 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||
| 1063 | 1123 | v.SetDefault("registry_auth.enabled", false) | |
| 1064 | 1124 | v.SetDefault("registry_auth.type", "password") | |
| 1065 | 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 | 1135 | ConfigureViper(v, configPath) | |
| 1068 | 1136 | ||
M internal/boundaries/in/config.go +6 -0
| @@ -14,6 +14,9 @@ type ConfigService interface { | |||
| 14 | 14 | // GetRoutes returns all configured routes. | |
| 15 | 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 | 20 | // AddRoute adds a new route to the configuration. | |
| 18 | 21 | AddRoute(ctx context.Context, route domain.Route) error | |
| 19 | 22 | ||
| @@ -23,6 +26,9 @@ type ConfigService interface { | |||
| 23 | 26 | // RemoveRoute removes a route from the configuration. | |
| 24 | 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 | 32 | // Watch starts watching for configuration changes. | |
| 27 | 33 | // The onChange callback is called when configuration changes are detected. | |
| 28 | 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 | 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 | 388 | // GetRoutes provides a mock function for the type MockConfigService | |
| 321 | 389 | func (_mock *MockConfigService) GetRoutes(ctx context.Context) []domain.Route { | |
| 322 | 390 | ret := _mock.Called(ctx) | |
| @@ -610,6 +678,57 @@ func (_c *MockConfigService_RemoveRoute_Call) RunAndReturn(run func(ctx context. | |||
| 610 | 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 | 732 | // UpdateRoute provides a mock function for the type MockConfigService | |
| 614 | 733 | func (_mock *MockConfigService) UpdateRoute(ctx context.Context, route domain.Route) error { | |
| 615 | 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 | 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 | 120 | // BlobExists provides a mock function for the type MockRegistryService | |
| 43 | 121 | func (_mock *MockRegistryService) BlobExists(ctx context.Context, digest string) bool { | |
| 44 | 122 | ret := _mock.Called(ctx, digest) | |
| @@ -347,6 +425,72 @@ func (_c *MockRegistryService_GetBlob_Call) RunAndReturn(run func(ctx context.Co | |||
| 347 | 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 | 494 | // GetManifest provides a mock function for the type MockRegistryService | |
| 351 | 495 | func (_mock *MockRegistryService) GetManifest(ctx context.Context, name string, reference string) (*domain.Manifest, error) { | |
| 352 | 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 | 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 | 769 | ret := _mock.Called(ctx, manifest) | |
| 626 | 770 | ||
| 627 | 771 | if len(ret) == 0 { | |
| 628 | 772 | panic("no return value specified for PutManifest") | |
| 629 | 773 | } | |
| 630 | 774 | ||
| 631 | - | var r0 error | |
| 632 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, *domain.Manifest) error); ok { | |
| 775 | + | var r0 string | |
| 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 | 781 | r0 = returnFunc(ctx, manifest) | |
| 634 | 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 | 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 | 820 | return _c | |
| 668 | 821 | } | |
| 669 | 822 | ||
| 670 | - | func (_c *MockRegistryService_PutManifest_Call) Return(err error) *MockRegistryService_PutManifest_Call { | |
| 671 | - | _c.Call.Return(err) | |
| 823 | + | func (_c *MockRegistryService_PutManifest_Call) Return(digest string, err error) *MockRegistryService_PutManifest_Call { | |
| 824 | + | _c.Call.Return(digest, err) | |
| 672 | 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 | 829 | _c.Call.Return(run) | |
| 677 | 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 | 11 | type RegistryService interface { | |
| 12 | 12 | // Manifest operations | |
| 13 | 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 | 15 | DeleteManifest(ctx context.Context, name, reference string) error | |
| 16 | 16 | ||
| 17 | 17 | // Blob operations | |
| 18 | 18 | GetBlob(ctx context.Context, digest string) (io.ReadCloser, error) | |
| 19 | + | GetBlobPath(ctx context.Context, digest string) (string, error) | |
| 19 | 20 | PutBlob(ctx context.Context, digest string, data io.Reader, size int64) error | |
| 20 | 21 | BlobExists(ctx context.Context, digest string) bool | |
| 21 | 22 | ||
| 22 | 23 | // Upload operations | |
| 23 | 24 | StartUpload(ctx context.Context, name string) (string, error) | |
| 25 | + | AppendBlobChunk(ctx context.Context, name, uuid string, chunk []byte) (int64, error) | |
| 24 | 26 | FinishUpload(ctx context.Context, uuid, digest string) error | |
| 25 | 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 | 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 | 91 | // Scope represents a Docker registry scope (repository:name:actions). | |
| 68 | 92 | // Format follows the Docker Registry v2 token scope specification. | |
| 69 | 93 | type Scope struct { | |
| @@ -127,3 +151,116 @@ func (s *Scope) CanAccess(repoName string, action string) bool { | |||
| 127 | 151 | func (s *Scope) String() string { | |
| 128 | 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 | 20 | ErrRouteNotFound = errors.New("route not found") | |
| 21 | 21 | ErrRouteExists = errors.New("route already exists") | |
| 22 | 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 | 25 | ErrNoTargetAvailable = errors.New("no target available for route") | |
| 24 | 26 | ||
| 25 | 27 | // Registry errors | |
M internal/domain/labels.go +10 -0
| @@ -13,4 +13,14 @@ const ( | |||
| 13 | 13 | ||
| 14 | 14 | // Image labels (set in Dockerfile) | |
| 15 | 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 | 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 | 14 | // Manifest represents an OCI image manifest. | |
| 6 | 15 | type Manifest struct { | |
| 7 | 16 | Name string | |
M internal/domain/route.go +10 -0
| @@ -28,3 +28,13 @@ type ExternalRoute struct { | |||
| 28 | 28 | Host string // e.g., "localhost" | |
| 29 | 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 | 5 | "context" | |
| 6 | 6 | "strings" | |
| 7 | 7 | "sync" | |
| 8 | + | "sync/atomic" | |
| 9 | + | "time" | |
| 8 | 10 | ||
| 9 | 11 | "github.com/bnema/zerowrap" | |
| 10 | 12 | "github.com/fsnotify/fsnotify" | |
| @@ -37,17 +39,20 @@ type Config struct { | |||
| 37 | 39 | ||
| 38 | 40 | // Service implements the ConfigService interface. | |
| 39 | 41 | type Service struct { | |
| 40 | - | viper *viper.Viper | |
| 41 | - | eventBus out.EventPublisher | |
| 42 | - | config Config | |
| 43 | - | mu sync.RWMutex | |
| 42 | + | viper *viper.Viper | |
| 43 | + | eventBus out.EventPublisher | |
| 44 | + | config Config | |
| 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 | 50 | // NewService creates a new config service. | |
| 47 | 51 | func NewService(v *viper.Viper, eventBus out.EventPublisher) *Service { | |
| 48 | 52 | return &Service{ | |
| 49 | - | viper: v, | |
| 50 | - | eventBus: eventBus, | |
| 53 | + | viper: v, | |
| 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 | 88 | // loadConfigValues loads simple config values from viper. | |
| 84 | 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 | 96 | return Config{ | |
| 86 | 97 | ServerPort: s.viper.GetInt("server.port"), | |
| 87 | 98 | RegistryPort: s.viper.GetInt("server.registry_port"), | |
| 88 | - | RegistryDomain: s.viper.GetString("server.registry_domain"), | |
| 99 | + | RegistryDomain: registryDomain, | |
| 89 | 100 | DataDir: s.viper.GetString("server.data_dir"), | |
| 90 | 101 | AutoRouteEnabled: s.viper.GetBool("auto_route.enabled"), | |
| 91 | 102 | NetworkIsolation: s.viper.GetBool("network_isolation.enabled"), | |
| @@ -171,7 +182,32 @@ func (s *Service) GetRoutes(_ context.Context) []domain.Route { | |||
| 171 | 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 | 211 | func (s *Service) AddRoute(ctx context.Context, route domain.Route) error { | |
| 176 | 212 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 177 | 213 | zerowrap.FieldLayer: "usecase", | |
| @@ -180,20 +216,41 @@ func (s *Service) AddRoute(ctx context.Context, route domain.Route) error { | |||
| 180 | 216 | }) | |
| 181 | 217 | log := zerowrap.FromCtx(ctx) | |
| 182 | 218 | ||
| 183 | - | s.mu.Lock() | |
| 184 | - | defer s.mu.Unlock() | |
| 219 | + | // Validate route | |
| 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 | 230 | if s.config.Routes == nil { | |
| 187 | 231 | s.config.Routes = make(map[string]string) | |
| 188 | 232 | } | |
| 189 | - | ||
| 190 | 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 | 249 | log.Info().Str("image", route.Image).Msg("route added to configuration") | |
| 193 | 250 | return nil | |
| 194 | 251 | } | |
| 195 | 252 | ||
| 196 | - | // UpdateRoute updates an existing route. | |
| 253 | + | // UpdateRoute updates an existing route and persists it. | |
| 197 | 254 | func (s *Service) UpdateRoute(ctx context.Context, route domain.Route) error { | |
| 198 | 255 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 199 | 256 | zerowrap.FieldLayer: "usecase", | |
| @@ -202,20 +259,38 @@ func (s *Service) UpdateRoute(ctx context.Context, route domain.Route) error { | |||
| 202 | 259 | }) | |
| 203 | 260 | log := zerowrap.FromCtx(ctx) | |
| 204 | 261 | ||
| 205 | - | s.mu.Lock() | |
| 206 | - | defer s.mu.Unlock() | |
| 262 | + | // Validate route | |
| 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 | 275 | return domain.ErrRouteNotFound | |
| 210 | 276 | } | |
| 211 | - | ||
| 212 | 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 | 289 | log.Info().Str("image", route.Image).Msg("route updated") | |
| 215 | 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 | 294 | func (s *Service) RemoveRoute(ctx context.Context, domainName string) error { | |
| 220 | 295 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 221 | 296 | zerowrap.FieldLayer: "usecase", | |
| @@ -224,24 +299,67 @@ func (s *Service) RemoveRoute(ctx context.Context, domainName string) error { | |||
| 224 | 299 | }) | |
| 225 | 300 | log := zerowrap.FromCtx(ctx) | |
| 226 | 301 | ||
| 302 | + | // Store previous value for rollback | |
| 227 | 303 | s.mu.Lock() | |
| 228 | - | defer s.mu.Unlock() | |
| 229 | - | ||
| 230 | - | if _, exists := s.config.Routes[domainName]; !exists { | |
| 304 | + | previousImage, exists := s.config.Routes[domainName] | |
| 305 | + | if !exists { | |
| 306 | + | s.mu.Unlock() | |
| 231 | 307 | return domain.ErrRouteNotFound | |
| 232 | 308 | } | |
| 233 | - | ||
| 234 | 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 | 321 | log.Info().Msg("route removed") | |
| 237 | 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 | 351 | // Watch starts watching for configuration changes. | |
| 241 | 352 | func (s *Service) Watch(ctx context.Context, onChange func()) error { | |
| 242 | 353 | log := zerowrap.FromCtx(ctx) | |
| 243 | 354 | ||
| 244 | 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 | 363 | log.Info().Str("file", e.Name).Msg("config file changed") | |
| 246 | 364 | ||
| 247 | 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 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 5 | 7 | "testing" | |
| 6 | 8 | ||
| 7 | 9 | "github.com/bnema/zerowrap" | |
| 8 | 10 | "github.com/spf13/viper" | |
| 9 | 11 | "github.com/stretchr/testify/assert" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 10 | 13 | ||
| 11 | 14 | "gordon/internal/boundaries/out/mocks" | |
| 12 | 15 | "gordon/internal/domain" | |
| @@ -95,73 +98,185 @@ func TestService_GetRoutes(t *testing.T) { | |||
| 95 | 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 | 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 | 108 | eventBus := mocks.NewMockEventPublisher(t) | |
| 101 | 109 | svc := NewService(v, eventBus) | |
| 102 | 110 | ctx := testContext() | |
| 103 | 111 | ||
| 104 | 112 | _ = svc.Load(ctx) | |
| 105 | 113 | ||
| 106 | - | route := domain.Route{ | |
| 107 | - | Domain: "new.example.com", | |
| 108 | - | Image: "newapp:latest", | |
| 109 | - | } | |
| 114 | + | t.Run("existing route", func(t *testing.T) { | |
| 115 | + | route, err := svc.GetRoute(ctx, "app.example.com") | |
| 116 | + | require.NoError(t, err) | |
| 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 | |
| 116 | - | config := svc.GetConfig() | |
| 117 | - | assert.Equal(t, "newapp:latest", config.Routes["new.example.com"]) | |
| 118 | - | } | |
| 137 | + | v := viper.New() | |
| 138 | + | v.SetConfigFile(configFile) | |
| 139 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 140 | + | svc := NewService(v, eventBus) | |
| 141 | + | ctx := testContext() | |
| 119 | 142 | ||
| 120 | - | func TestService_UpdateRoute(t *testing.T) { | |
| 121 | - | v := viper.New() | |
| 122 | - | v.Set("routes", map[string]interface{}{ | |
| 123 | - | "app.example.com": "myapp:v1", | |
| 143 | + | _ = svc.Load(ctx) | |
| 144 | + | ||
| 145 | + | route := domain.Route{ | |
| 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) | |
| 127 | - | svc := NewService(v, eventBus) | |
| 128 | - | ctx := testContext() | |
| 159 | + | t.Run("empty domain", func(t *testing.T) { | |
| 160 | + | v := viper.New() | |
| 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{ | |
| 133 | - | Domain: "app.example.com", | |
| 134 | - | Image: "myapp:v2", | |
| 135 | - | } | |
| 170 | + | err := svc.AddRoute(ctx, route) | |
| 171 | + | assert.ErrorIs(t, err, domain.ErrRouteDomainEmpty) | |
| 172 | + | }) | |
| 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() | |
| 142 | - | assert.Equal(t, "myapp:v2", config.Routes["app.example.com"]) | |
| 185 | + | err := svc.AddRoute(ctx, route) | |
| 186 | + | assert.ErrorIs(t, err, domain.ErrRouteImageEmpty) | |
| 187 | + | }) | |
| 143 | 188 | } | |
| 144 | 189 | ||
| 145 | - | func TestService_UpdateRoute_NotFound(t *testing.T) { | |
| 146 | - | v := viper.New() | |
| 147 | - | eventBus := mocks.NewMockEventPublisher(t) | |
| 148 | - | svc := NewService(v, eventBus) | |
| 149 | - | ctx := testContext() | |
| 190 | + | func TestService_UpdateRoute(t *testing.T) { | |
| 191 | + | t.Run("success", func(t *testing.T) { | |
| 192 | + | // Create temp config file for Save() to work | |
| 193 | + | tmpDir := t.TempDir() | |
| 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{ | |
| 154 | - | Domain: "nonexistent.example.com", | |
| 155 | - | Image: "myapp:latest", | |
| 156 | - | } | |
| 208 | + | _ = svc.Load(ctx) | |
| 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 | 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 | 278 | v := viper.New() | |
| 279 | + | v.SetConfigFile(configFile) | |
| 165 | 280 | v.Set("routes", map[string]interface{}{ | |
| 166 | 281 | "app.example.com": "myapp:latest", | |
| 167 | 282 | }) | |
| @@ -172,7 +287,7 @@ func TestService_RemoveRoute(t *testing.T) { | |||
| 172 | 287 | ||
| 173 | 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 | 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 | 25 | RegistryPassword string | |
| 26 | 26 | InternalRegistryUsername string | |
| 27 | 27 | InternalRegistryPassword string | |
| 28 | + | PullPolicy string | |
| 28 | 29 | VolumeAutoCreate bool | |
| 29 | 30 | VolumePrefix string | |
| 30 | 31 | VolumePreserve bool | |
| @@ -36,6 +37,12 @@ type Config struct { | |||
| 36 | 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 | 46 | // Service implements the ContainerService interface. | |
| 40 | 47 | type Service struct { | |
| 41 | 48 | runtime out.ContainerRuntime | |
| @@ -513,6 +520,23 @@ func (s *Service) buildImageRef(image string) string { | |||
| 513 | 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 | 540 | func (s *Service) pullRefForDeploy(ctx context.Context, imageRef string) (string, bool) { | |
| 517 | 541 | if !domain.IsInternalDeploy(ctx) { | |
| 518 | 542 | return imageRef, false | |
| @@ -521,7 +545,9 @@ func (s *Service) pullRefForDeploy(ctx context.Context, imageRef string) (string | |||
| 521 | 545 | } | |
| 522 | 546 | ||
| 523 | 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 | 551 | func (s *Service) ensureImage(ctx context.Context, imageRef string) (string, error) { | |
| 526 | 552 | ctx = zerowrap.CtxWithField(ctx, "image", imageRef) | |
| 527 | 553 | log := zerowrap.FromCtx(ctx) | |
| @@ -550,6 +576,15 @@ func (s *Service) ensureImage(ctx context.Context, imageRef string) (string, err | |||
| 550 | 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 | 588 | if err := s.tagImageIfNeeded(ctx, pullRef, imageRef); err != nil { | |
| 554 | 589 | return "", err | |
| 555 | 590 | } | |
| @@ -576,6 +611,18 @@ func (s *Service) ensureLocalImage(ctx context.Context, imageRef, pullRef string | |||
| 576 | 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 | 626 | localImages, err := s.runtime.ListImages(ctx) | |
| 580 | 627 | if err != nil { | |
| 581 | 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 | 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 | 698 | log := zerowrap.FromCtx(ctx) | |
| 645 | 699 | if err := s.runtime.TagImage(ctx, sourceRef, targetRef); err != nil { | |
| 646 | 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 | 91 | // Get target for this domain | |
| 92 | + | log.Debug().Str("resolving_target_for", r.Host).Msg("looking up proxy target") | |
| 92 | 93 | target, err := s.GetTarget(ctx, r.Host) | |
| 93 | 94 | if err != nil { | |
| 94 | 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 | 303 | return | |
| 303 | 304 | } | |
| 304 | 305 | ||
| 306 | + | log.Debug().Str("target_url", targetURL.String()).Msg("proxying to target") | |
| 307 | + | ||
| 305 | 308 | proxy := newReverseProxy(targetURL, | |
| 306 | 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 | 311 | http.Error(w, "Service Unavailable", http.StatusServiceUnavailable) | |
| 309 | 312 | }, | |
| 310 | 313 | func(resp *http.Response) error { | |
M internal/usecase/registry/service.go +46 -5
| @@ -3,6 +3,8 @@ package registry | |||
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | 5 | "context" | |
| 6 | + | "crypto/sha256" | |
| 7 | + | "fmt" | |
| 6 | 8 | "io" | |
| 7 | 9 | ||
| 8 | 10 | "github.com/bnema/zerowrap" | |
| @@ -54,8 +56,8 @@ func (s *Service) GetManifest(ctx context.Context, name, reference string) (*dom | |||
| 54 | 56 | }, nil | |
| 55 | 57 | } | |
| 56 | 58 | ||
| 57 | - | // PutManifest stores a manifest. | |
| 58 | - | func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) error { | |
| 59 | + | // PutManifest stores a manifest and returns the calculated digest. | |
| 60 | + | func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) (string, error) { | |
| 59 | 61 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 60 | 62 | zerowrap.FieldLayer: "usecase", | |
| 61 | 63 | zerowrap.FieldUseCase: "PutManifest", | |
| @@ -64,8 +66,11 @@ func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) er | |||
| 64 | 66 | }) | |
| 65 | 67 | log := zerowrap.FromCtx(ctx) | |
| 66 | 68 | ||
| 69 | + | // Calculate digest | |
| 70 | + | digest := fmt.Sprintf("sha256:%x", sha256.Sum256(manifest.Data)) | |
| 71 | + | ||
| 67 | 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 | 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") | |
| 84 | - | return nil | |
| 88 | + | log.Info().Str("digest", digest).Msg("manifest stored") | |
| 89 | + | return digest, nil | |
| 85 | 90 | } | |
| 86 | 91 | ||
| 87 | 92 | // DeleteManifest removes a manifest. | |
| @@ -119,6 +124,23 @@ func (s *Service) GetBlob(ctx context.Context, digest string) (io.ReadCloser, er | |||
| 119 | 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 | 144 | // PutBlob stores a blob with the given digest. | |
| 123 | 145 | func (s *Service) PutBlob(ctx context.Context, digest string, data io.Reader, size int64) error { | |
| 124 | 146 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| @@ -160,6 +182,25 @@ func (s *Service) StartUpload(ctx context.Context, name string) (string, error) | |||
| 160 | 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 | 204 | // FinishUpload completes a blob upload. | |
| 164 | 205 | func (s *Service) FinishUpload(ctx context.Context, uuid, digest string) error { | |
| 165 | 206 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
M internal/usecase/registry/service_test.go +7 -4
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "context" | |
| 6 | 6 | "errors" | |
| 7 | 7 | "io" | |
| 8 | + | "strings" | |
| 8 | 9 | "testing" | |
| 9 | 10 | ||
| 10 | 11 | "github.com/bnema/zerowrap" | |
| @@ -75,9 +76,11 @@ func TestService_PutManifest_Success(t *testing.T) { | |||
| 75 | 76 | manifestStorage.EXPECT().PutManifest("myapp", "latest", "application/vnd.docker.distribution.manifest.v2+json", manifest.Data).Return(nil) | |
| 76 | 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 | 81 | assert.NoError(t, err) | |
| 82 | + | assert.NotEmpty(t, digest) | |
| 83 | + | assert.True(t, strings.HasPrefix(digest, "sha256:")) | |
| 81 | 84 | } | |
| 82 | 85 | ||
| 83 | 86 | func TestService_PutManifest_StorageError(t *testing.T) { | |
| @@ -97,7 +100,7 @@ func TestService_PutManifest_StorageError(t *testing.T) { | |||
| 97 | 100 | ||
| 98 | 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 | 105 | assert.Error(t, err) | |
| 103 | 106 | assert.Contains(t, err.Error(), "failed to store manifest") | |
| @@ -122,7 +125,7 @@ func TestService_PutManifest_EventPublishError(t *testing.T) { | |||
| 122 | 125 | eventBus.EXPECT().Publish(domain.EventImagePushed, mock.Anything).Return(errors.New("event bus error")) | |
| 123 | 126 | ||
| 124 | 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 | 130 | assert.NoError(t, err) | |
| 128 | 131 | } | |
| @@ -144,7 +147,7 @@ func TestService_PutManifest_NilEventBus(t *testing.T) { | |||
| 144 | 147 | ||
| 145 | 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 | 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 | 15 | ```toml | |
| 16 | 16 | [server] | |
| 17 | - | registry_domain = "registry.local" | |
| 17 | + | gordon_domain = "gordon.local" | |
| 18 | 18 | ||
| 19 | 19 | [routes] | |
| 20 | 20 | "app.local" = "myapp:latest" | |
| @@ -25,7 +25,7 @@ registry_domain = "registry.local" | |||
| 25 | 25 | ```toml | |
| 26 | 26 | [server] | |
| 27 | 27 | port = 8080 | |
| 28 | - | registry_domain = "registry.local" | |
| 28 | + | gordon_domain = "gordon.local" | |
| 29 | 29 | ||
| 30 | 30 | [registry_auth] | |
| 31 | 31 | enabled = false | |
| @@ -42,7 +42,7 @@ enabled = true | |||
| 42 | 42 | ```toml | |
| 43 | 43 | [server] | |
| 44 | 44 | port = 8080 | |
| 45 | - | registry_domain = "registry.company.com" | |
| 45 | + | gordon_domain = "gordon.company.com" | |
| 46 | 46 | ||
| 47 | 47 | [secrets] | |
| 48 | 48 | backend = "pass" | |
M wiki/examples/minimal.md +5 -5
| @@ -16,7 +16,7 @@ The simplest working Gordon configuration. | |||
| 16 | 16 | [server] | |
| 17 | 17 | port = 8080 | |
| 18 | 18 | registry_port = 5000 | |
| 19 | - | registry_domain = "registry.local" | |
| 19 | + | gordon_domain = "gordon.local" | |
| 20 | 20 | ||
| 21 | 21 | # Authentication disabled for simplicity | |
| 22 | 22 | [registry_auth] | |
| @@ -32,21 +32,21 @@ enabled = false | |||
| 32 | 32 | ### 1. Add to /etc/hosts | |
| 33 | 33 | ||
| 34 | 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 | 38 | ### 2. Start Gordon | |
| 39 | 39 | ||
| 40 | 40 | ```bash | |
| 41 | - | gordon start | |
| 41 | + | gordon serve | |
| 42 | 42 | ``` | |
| 43 | 43 | ||
| 44 | 44 | ### 3. Build and Deploy | |
| 45 | 45 | ||
| 46 | 46 | ```bash | |
| 47 | 47 | docker build -t myapp . | |
| 48 | - | docker tag myapp registry.local:5000/myapp:latest | |
| 49 | - | docker push registry.local:5000/myapp:latest | |
| 48 | + | docker tag myapp gordon.local:5000/myapp:latest | |
| 49 | + | docker push gordon.local:5000/myapp:latest | |
| 50 | 50 | ``` | |
| 51 | 51 | ||
| 52 | 52 | ### 4. Access | |
M wiki/examples/production.md +3 -3
| @@ -17,7 +17,7 @@ A complete Gordon configuration for production environments. | |||
| 17 | 17 | [server] | |
| 18 | 18 | port = 8080 # Cloudflare forwards 443 → 8080 | |
| 19 | 19 | registry_port = 5000 | |
| 20 | - | registry_domain = "registry.company.com" | |
| 20 | + | gordon_domain = "gordon.company.com" | |
| 21 | 21 | ||
| 22 | 22 | # Use pass for secrets (recommended) | |
| 23 | 23 | [secrets] | |
| @@ -161,10 +161,10 @@ systemctl --user enable --now gordon | |||
| 161 | 161 | docker build -t company-app . | |
| 162 | 162 | ||
| 163 | 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 | 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 | 169 | # Update config with new version | |
| 170 | 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 | 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 | 18 | ## Secrets Management | |
| 15 | 19 | ||
| 16 | 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 | 109 | Generate initial config: | |
| 110 | 110 | ||
| 111 | 111 | ```bash | |
| 112 | - | gordon start | |
| 112 | + | gordon serve | |
| 113 | 113 | # Press Ctrl+C after config is created | |
| 114 | 114 | ``` | |
| 115 | 115 | ||
| @@ -119,7 +119,7 @@ Edit `~/.config/gordon/gordon.toml`: | |||
| 119 | 119 | [server] | |
| 120 | 120 | port = 8080 # Must match firewall forward (80 → 8080) | |
| 121 | 121 | registry_port = 5000 | |
| 122 | - | registry_domain = "registry.mydomain.com" | |
| 122 | + | gordon_domain = "gordon.mydomain.com" | |
| 123 | 123 | ||
| 124 | 124 | [routes] | |
| 125 | 125 | "app.mydomain.com" = "myapp:latest" | |
| @@ -138,7 +138,7 @@ After=podman.socket | |||
| 138 | 138 | Type=simple | |
| 139 | 139 | Restart=always | |
| 140 | 140 | RestartSec=5 | |
| 141 | - | ExecStart=/usr/local/bin/gordon start | |
| 141 | + | ExecStart=/usr/local/bin/gordon serve | |
| 142 | 142 | ||
| 143 | 143 | [Install] | |
| 144 | 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 | 45 | [server] | |
| 46 | 46 | port = 8080 | |
| 47 | 47 | registry_port = 5000 | |
| 48 | - | registry_domain = "registry.mydomain.com" | |
| 48 | + | gordon_domain = "gordon.mydomain.com" | |
| 49 | 49 | ||
| 50 | 50 | [routes] | |
| 51 | 51 | "app.mydomain.com" = "myapp:latest" | |