detached fc65046

Merge pull request #22 from bnema/feature/deployment-experience-improvements

feat: deployment experience improvements
commit
fc65046b745e73d260317149791f61ebb5843cd2
author
bnema · · 2026-01-17 06:19 UTC
committer
GitHub · · 2026-01-17 06:19 UTC
merge of
aefe19e + 1466c20 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v2.3.0
73 files · +9704 -266
M .mockery.yaml +4 -0
@@ -20,6 +20,7 @@ packages:
20 EnvLoader:20 EnvLoader:
21 ContainerLogWriter:21 ContainerLogWriter:
22 TokenStore:22 TokenStore:
23+ DomainSecretStore:
23 gordon/internal/boundaries/in:24 gordon/internal/boundaries/in:
24 interfaces:25 interfaces:
25 ContainerService:26 ContainerService:
@@ -27,3 +28,6 @@ packages:
27 ProxyService:28 ProxyService:
28 ConfigService:29 ConfigService:
29 AuthService:30 AuthService:
31+ HealthService:
32+ HTTPProber:
33+ SecretService:
M Dockerfile +1 -1
@@ -63,7 +63,7 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
63 CMD wget --quiet --tries=1 --spider http://localhost:8080/health || exit 163 CMD wget --quiet --tries=1 --spider http://localhost:8080/health || exit 1
64 64
65 # Default command65 # Default command
66-CMD ["./gordon", "start"]66+CMD ["./gordon", "serve"]
67 67
68 # Metadata68 # Metadata
69 LABEL maintainer="bnemam"69 LABEL maintainer="bnemam"
M README.md +3 -1
@@ -27,6 +27,8 @@ Build on your machine, push to deploy. Works from your laptop or CI.
27 - Private Docker registry on your VPS27 - Private Docker registry on your VPS
28 - Domain-to-container routing via HTTP reverse proxy28 - Domain-to-container routing via HTTP reverse proxy
29 - Automatic deployment on image push29 - Automatic deployment on image push
30+- Auto-routing from image labels
31+- Remote CLI management
30 - Zero downtime updates32 - Zero downtime updates
31 - Persistent volumes from Dockerfile VOLUME directives33 - Persistent volumes from Dockerfile VOLUME directives
32 - Environment variable management with secrets support34 - Environment variable management with secrets support
@@ -48,7 +50,7 @@ curl -fsSL https://gordon.bnema.dev/install.sh | sh
48 # Or: curl -fsSL https://raw.githubusercontent.com/bnema/gordon/main/install.sh | sh50 # Or: curl -fsSL https://raw.githubusercontent.com/bnema/gordon/main/install.sh | sh
49 51
50 # Start Gordon (generates config on first run)52 # Start Gordon (generates config on first run)
51-gordon start53+gordon serve
52 ```54 ```
53 55
54 Config is created at `~/.config/gordon/gordon.toml`. See the [Getting Started guide](https://gordon.bnema.dev/docs/getting-started) for complete setup instructions.56 Config is created at `~/.config/gordon/gordon.toml`. See the [Getting Started guide](https://gordon.bnema.dev/docs/getting-started) for complete setup instructions.
M docs/cli/commands.md +148 -14
@@ -6,19 +6,22 @@ Gordon provides a command-line interface for server management, deployment, and
6 6
7 | Command | Description |7 | Command | Description |
8 |---------|-------------|8 |---------|-------------|
9-| `gordon start` | Start the Gordon server |9+| `gordon serve` | Start the Gordon server |
10 | `gordon reload` | Reload configuration and sync containers |10 | `gordon reload` | Reload configuration and sync containers |
11 | `gordon deploy` | Manually deploy or redeploy a specific route |11 | `gordon deploy` | Manually deploy or redeploy a specific route |
12 | `gordon logs` | Display Gordon process logs |12 | `gordon logs` | Display Gordon process logs |
13 | `gordon version` | Print version information |13 | `gordon version` | Print version information |
14 | `gordon auth` | Manage registry authentication |14 | `gordon auth` | Manage registry authentication |
15+| `gordon routes` | Manage routes (local or remote) |
16+| `gordon secrets` | Manage secrets (remote only) |
17+| `gordon remotes` | Manage saved remote Gordon instances |
15 18
16 ## Quick Reference19 ## Quick Reference
17 20
18 ```bash21 ```bash
19 # Start Gordon22 # Start Gordon
20-gordon start23+gordon serve
21-gordon start --config /path/to/config.toml24+gordon serve --config /path/to/config.toml
22 25
23 # Reload configuration26 # Reload configuration
24 gordon reload27 gordon reload
@@ -47,17 +50,35 @@ gordon auth internal
47 | Option | Description |50 | Option | Description |
48 |--------|-------------|51 |--------|-------------|
49 | `-c, --config` | Path to configuration file |52 | `-c, --config` | Path to configuration file |
53+| `--remote` | Remote Gordon URL (e.g., `https://gordon.mydomain.com`) |
54+| `--token` | Authentication token for remote |
55+
56+### Remote Targeting
57+
58+The CLI can target remote Gordon instances using the `--remote` flag or `GORDON_REMOTE` environment variable.
59+
60+**Important:** The remote URL must be the `gordon_domain` configured on the remote Gordon instance. This is the domain that serves both the container registry and the Admin API.
61+
62+```bash
63+# Using flags (use the gordon_domain from remote Gordon config)
64+gordon routes list --remote https://gordon.mydomain.com --token $TOKEN
65+
66+# Using environment variables
67+export GORDON_REMOTE=https://gordon.mydomain.com
68+export GORDON_TOKEN=$TOKEN
69+gordon routes list
70+```
50 71
51 ---72 ---
52 73
53-## gordon start74+## gordon serve
54 75
55 Start the Gordon server with registry and proxy components.76 Start the Gordon server with registry and proxy components.
56 77
57 ### Synopsis78 ### Synopsis
58 79
59 ```bash80 ```bash
60-gordon start [options]81+gordon serve [options]
61 ```82 ```
62 83
63 ### Options84 ### Options
@@ -90,7 +111,7 @@ On first run without a config file, Gordon creates a default configuration at `~
90 111
91 ```bash112 ```bash
92 # First run - creates default config113 # First run - creates default config
93-gordon start114+gordon serve
94 # Edit the config, then restart115 # Edit the config, then restart
95 ```116 ```
96 117
@@ -98,15 +119,15 @@ gordon start
98 119
99 ```bash120 ```bash
100 # Basic start121 # Basic start
101-gordon start122+gordon serve
102 123
103 # With custom config124 # With custom config
104-gordon start --config /path/to/gordon.toml125+gordon serve --config /path/to/gordon.toml
105-gordon start -c ./my-config.toml126+gordon serve -c ./my-config.toml
106 127
107 # With environment override128 # With environment override
108-GORDON_SERVER_PORT=8080 gordon start129+GORDON_SERVER_PORT=8080 gordon serve
109-GORDON_LOGGING_LEVEL=debug gordon start130+GORDON_LOGGING_LEVEL=debug gordon serve
110 ```131 ```
111 132
112 ### Signals133 ### Signals
@@ -132,7 +153,7 @@ Description=Gordon Container Platform
132 [Service]153 [Service]
133 Type=simple154 Type=simple
134 Restart=always155 Restart=always
135-ExecStart=/usr/local/bin/gordon start156+ExecStart=/usr/local/bin/gordon serve
136 157
137 [Install]158 [Install]
138 WantedBy=default.target159 WantedBy=default.target
@@ -529,6 +550,119 @@ password_hash = "gordon/registry/password_hash"
529 550
530 ---551 ---
531 552
553+## gordon routes
554+
555+Manage routes on local or remote Gordon instances.
556+
557+### Subcommands
558+
559+| Subcommand | Description |
560+|------------|-------------|
561+| `list` | List all routes |
562+| `add` | Add a new route |
563+| `remove` | Remove a route |
564+| `deploy` | Deploy a specific route |
565+
566+### gordon routes list
567+
568+List all configured routes.
569+
570+```bash
571+gordon routes list
572+gordon routes list --remote https://gordon.mydomain.com --token $TOKEN
573+```
574+
575+### gordon routes add
576+
577+Add a new route.
578+
579+```bash
580+gordon routes add <domain> <image>
581+gordon routes add myapp.example.com myapp:latest
582+```
583+
584+### gordon routes remove
585+
586+Remove a route.
587+
588+```bash
589+gordon routes remove <domain>
590+gordon routes remove myapp.example.com
591+```
592+
593+### gordon routes deploy
594+
595+Deploy or redeploy a specific route.
596+
597+```bash
598+gordon routes deploy <domain>
599+gordon routes deploy myapp.example.com
600+```
601+
602+---
603+
604+## gordon secrets
605+
606+Manage secrets on remote Gordon instances. Requires remote targeting.
607+
608+### Subcommands
609+
610+| Subcommand | Description |
611+|------------|-------------|
612+| `list` | List all secrets |
613+| `get` | Get a secret value |
614+| `set` | Set a secret value |
615+| `delete` | Delete a secret |
616+
617+### Examples
618+
619+```bash
620+# List secrets
621+gordon secrets list --remote https://gordon.mydomain.com --token $TOKEN
622+
623+# Set a secret
624+gordon secrets set DATABASE_URL "postgres://..." --remote ... --token ...
625+
626+# Get a secret
627+gordon secrets get DATABASE_URL --remote ... --token ...
628+
629+# Delete a secret
630+gordon secrets delete DATABASE_URL --remote ... --token ...
631+```
632+
633+---
634+
635+## gordon remotes
636+
637+Manage saved remote Gordon instances for easier CLI usage.
638+
639+### Subcommands
640+
641+| Subcommand | Description |
642+|------------|-------------|
643+| `list` | List saved remotes |
644+| `add` | Add a new remote |
645+| `remove` | Remove a saved remote |
646+| `use` | Set active remote |
647+
648+### Examples
649+
650+```bash
651+# Add a remote
652+gordon remotes add prod https://gordon.mydomain.com --token $TOKEN
653+
654+# List saved remotes
655+gordon remotes list
656+
657+# Set active remote
658+gordon remotes use prod
659+
660+# Now use without --remote flag
661+gordon routes list
662+```
663+
664+---
665+
532 ## Exit Codes666 ## Exit Codes
533 667
534 | Code | Meaning |668 | Code | Meaning |
@@ -542,8 +676,8 @@ password_hash = "gordon/registry/password_hash"
542 Gordon reads configuration from environment variables:676 Gordon reads configuration from environment variables:
543 677
544 ```bash678 ```bash
545-GORDON_SERVER_PORT=8080 gordon start679+GORDON_SERVER_PORT=8080 gordon serve
546-GORDON_LOGGING_LEVEL=debug gordon start680+GORDON_LOGGING_LEVEL=debug gordon serve
547 ```681 ```
548 682
549 Pattern: `GORDON_SECTION_KEY` (uppercase, underscores)683 Pattern: `GORDON_SECTION_KEY` (uppercase, underscores)
M docs/config/auto-route.md +120 -6
@@ -1,6 +1,9 @@
1 # Auto Route Configuration1 # 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 ## Configuration8 ## Configuration
6 9
@@ -123,7 +126,7 @@ enabled = true
123 126
124 ```toml127 ```toml
125 [server]128 [server]
126-registry_domain = "registry.local"129+gordon_domain = "gordon.local"
127 130
128 [auto_route]131 [auto_route]
129 enabled = true132 enabled = true
@@ -133,8 +136,8 @@ enabled = true
133 136
134 Usage:137 Usage:
135 ```bash138 ```bash
136-docker push registry.local/myapp.local:latest139+docker push gordon.local/myapp.local:latest
137-docker push registry.local/api.local:latest140+docker push gordon.local/api.local:latest
138 # Both routes created automatically141 # Both routes created automatically
139 ```142 ```
140 143
@@ -152,14 +155,125 @@ enabled = true
152 # Other services can use auto-route155 # Other services can use auto-route
153 ```156 ```
154 157
155-## Limitations158+## Limitations (Image Name Detection)
156 159
157 - Auto-routes always use the pushed image tag160 - Auto-routes always use the pushed image tag
158 - No support for HTTP-only routes (all HTTPS)161 - No support for HTTP-only routes (all HTTPS)
159 - No automatic attachment configuration162 - No automatic attachment configuration
160 - Cannot pin versions (always uses pushed tag)163 - Cannot pin versions (always uses pushed tag)
161 164
162-For more control, define routes manually in `[routes]`.165+For more control, define routes manually in `[routes]` or use image labels.
166+
167+---
168+
169+## Image Labels (Recommended)
170+
171+The preferred method for automatic routing uses Dockerfile labels. This gives you full control over routing behavior directly in your application.
172+
173+### Supported Labels
174+
175+| Label | Description | Example |
176+|-------|-------------|---------|
177+| `gordon.domains` | Comma-separated list of domains | `app.example.com,www.app.example.com` |
178+| `gordon.port` | Container port to proxy | `3000` |
179+| `gordon.health` | Health check endpoint path | `/health` |
180+| `gordon.env-file` | Path to .env file in image | `/app/.env.example` |
181+
182+### Dockerfile Example
183+
184+```dockerfile
185+FROM node:20-alpine
186+WORKDIR /app
187+COPY . .
188+RUN npm install
189+
190+# Gordon routing labels
191+LABEL gordon.domains="myapp.example.com,www.myapp.example.com"
192+LABEL gordon.port="3000"
193+LABEL gordon.health="/api/health"
194+LABEL gordon.env-file="/app/.env.example"
195+
196+EXPOSE 3000
197+CMD ["npm", "start"]
198+```
199+
200+### How It Works
201+
202+When you push an image with Gordon labels:
203+
204+1. Gordon reads the `gordon.domains` label
205+2. Creates routes for each domain automatically
206+3. Uses `gordon.port` for the proxy target (or first EXPOSE port)
207+4. Configures health checks if `gordon.health` is set
208+5. Extracts environment variables from `gordon.env-file` if specified
209+
210+### Label Priority
211+
212+Labels take precedence over image name detection:
213+
214+1. **Labels present** → Use `gordon.domains` for routing
215+2. **No labels** → Fall back to image name detection (if enabled)
216+3. **Manual routes** → Always take highest priority
217+
218+### Multi-Domain Example
219+
220+```dockerfile
221+# Single image serving multiple domains
222+LABEL gordon.domains="api.example.com,api.staging.example.com"
223+```
224+
225+Both domains will route to the same container.
226+
227+### Environment File Extraction
228+
229+The `gordon.env-file` label tells Gordon where to find a template `.env` file:
230+
231+```dockerfile
232+# Include a .env.example in your image
233+COPY .env.example /app/.env.example
234+LABEL gordon.env-file="/app/.env.example"
235+```
236+
237+Gordon will:
238+1. Extract the file from the image
239+2. Store it in the env directory
240+3. Use the variables for container deployment
241+
242+### Best Practices
243+
244+1. **Always set `gordon.port`** - Don't rely on EXPOSE detection
245+2. **Use `gordon.health`** - Enables reliable deployment verification
246+3. **Include `.env.example`** - Self-documenting environment requirements
247+4. **Separate domains with commas** - No spaces around commas
248+
249+### Example: Complete Setup
250+
251+```dockerfile
252+FROM golang:1.25-alpine AS builder
253+WORKDIR /app
254+COPY . .
255+RUN go build -o server
256+
257+FROM alpine:3.21
258+WORKDIR /app
259+COPY --from=builder /app/server .
260+COPY .env.example .
261+
262+# Gordon labels
263+LABEL gordon.domains="api.mycompany.com"
264+LABEL gordon.port="8080"
265+LABEL gordon.health="/health"
266+LABEL gordon.env-file="/app/.env.example"
267+
268+EXPOSE 8080
269+CMD ["./server"]
270+```
271+
272+```bash
273+# Push and Gordon handles everything
274+docker push registry.mycompany.com/api:latest
275+# Route automatically created: api.mycompany.com → container:8080
276+```
163 277
164 ## Related278 ## Related
165 279
A docs/config/deploy.md +26 -0
@@ -0,0 +1,26 @@
1+# Deploy Configuration
2+
3+Controls how Gordon pulls images during deployment.
4+
5+## Example
6+
7+```toml
8+[deploy]
9+pull_policy = "if-tag-changed"
10+```
11+
12+## Settings
13+
14+### `deploy.pull_policy`
15+
16+How to decide when to pull an image:
17+
18+- `always`: always pull, even if the tag exists locally.
19+- `if-not-present`: only pull when the tag is missing locally.
20+- `if-tag-changed`: pull for tag references to check for updates; skip pulls for digest references (`@sha256:...`).
21+
22+Default: `if-tag-changed`
23+
24+## Notes
25+
26+- Changes to `deploy.pull_policy` require a restart to take effect.
M docs/config/index.md +12 -5
@@ -7,7 +7,7 @@ Gordon uses a single TOML configuration file located at `~/.config/gordon/gordon
7 | Location | Purpose |7 | Location | Purpose |
8 |----------|---------|8 |----------|---------|
9 | `~/.config/gordon/gordon.toml` | Default configuration file |9 | `~/.config/gordon/gordon.toml` | Default configuration file |
10-| Custom path via `--config` flag | `gordon start --config /path/to/config.toml` |10+| Custom path via `--config` flag | `gordon serve --config /path/to/config.toml` |
11 11
12 ## Minimal Configuration12 ## Minimal Configuration
13 13
@@ -15,7 +15,7 @@ Gordon uses a single TOML configuration file located at `~/.config/gordon/gordon
15 [server]15 [server]
16 port = 808016 port = 8080
17 registry_port = 500017 registry_port = 5000
18-registry_domain = "registry.mydomain.com"18+gordon_domain = "gordon.mydomain.com"
19 19
20 [routes]20 [routes]
21 "app.mydomain.com" = "myapp:latest"21 "app.mydomain.com" = "myapp:latest"
@@ -30,7 +30,7 @@ registry_domain = "registry.mydomain.com"
30 [server]30 [server]
31 port = 8080 # HTTP proxy port (default: 80)31 port = 8080 # HTTP proxy port (default: 80)
32 registry_port = 5000 # Registry port (default: 5000)32 registry_port = 5000 # Registry port (default: 5000)
33-registry_domain = "registry.mydomain.com" # Required: registry domain33+gordon_domain = "gordon.mydomain.com" # Required: Gordon domain (registry + API)
34 # data_dir = "~/.gordon" # Default for user installations34 # data_dir = "~/.gordon" # Default for user installations
35 35
36 # Secrets backend36 # Secrets backend
@@ -48,6 +48,10 @@ type = "token" # "password" or "token"
48 token_secret = "gordon/registry/token_secret"48 token_secret = "gordon/registry/token_secret"
49 token_expiry = "720h" # Duration or 0 for never49 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 # Logging55 # Logging
52 [logging]56 [logging]
53 level = "info" # trace, debug, info, warn, error57 level = "info" # trace, debug, info, warn, error
@@ -109,6 +113,7 @@ enabled = false # Auto-create routes from image names
109 | `[server]` | Core server settings | [Server](./server.md) |113 | `[server]` | Core server settings | [Server](./server.md) |
110 | `[secrets]` | Secrets backend configuration | [Secrets](./secrets.md) |114 | `[secrets]` | Secrets backend configuration | [Secrets](./secrets.md) |
111 | `[registry_auth]` | Registry authentication | [Registry Auth](./registry-auth.md) |115 | `[registry_auth]` | Registry authentication | [Registry Auth](./registry-auth.md) |
116+| `[deploy]` | Deployment behavior | [Deploy](./deploy.md) |
112 | `[logging]` | Logging configuration | [Logging](./logging.md) |117 | `[logging]` | Logging configuration | [Logging](./logging.md) |
113 | `[env]` | Environment variable settings | [Environment](./env.md) |118 | `[env]` | Environment variable settings | [Environment](./env.md) |
114 | `[volumes]` | Volume management | [Volumes](./volumes.md) |119 | `[volumes]` | Volume management | [Volumes](./volumes.md) |
@@ -130,6 +135,7 @@ enabled = false # Auto-create routes from image names
130 | `registry_auth.enabled` | `false` |135 | `registry_auth.enabled` | `false` |
131 | `registry_auth.type` | `"password"` |136 | `registry_auth.type` | `"password"` |
132 | `registry_auth.token_expiry` | `"720h"` |137 | `registry_auth.token_expiry` | `"720h"` |
138+| `deploy.pull_policy` | `"if-tag-changed"` |
133 | `logging.level` | `"info"` |139 | `logging.level` | `"info"` |
134 | `logging.format` | `"console"` |140 | `logging.format` | `"console"` |
135 | `logging.file.enabled` | `false` |141 | `logging.file.enabled` | `false` |
@@ -158,14 +164,15 @@ The following settings require a restart to take effect:
158 - `server.data_dir`164 - `server.data_dir`
159 - `secrets.backend`165 - `secrets.backend`
160 - `registry_auth` settings166 - `registry_auth` settings
167+- `deploy.pull_policy`
161 168
162 ## Environment Variable Override169 ## Environment Variable Override
163 170
164 Configuration values can be overridden with environment variables:171 Configuration values can be overridden with environment variables:
165 172
166 ```bash173 ```bash
167-GORDON_SERVER_PORT=8080 gordon start174+GORDON_SERVER_PORT=8080 gordon serve
168-GORDON_LOGGING_LEVEL=debug gordon start175+GORDON_LOGGING_LEVEL=debug gordon serve
169 ```176 ```
170 177
171 Pattern: `GORDON_SECTION_KEY` (uppercase, underscores instead of dots)178 Pattern: `GORDON_SECTION_KEY` (uppercase, underscores instead of dots)
M docs/config/server.md +17 -11
@@ -8,7 +8,7 @@ Core server settings for Gordon.
8 [server]8 [server]
9 port = 8080 # HTTP proxy port9 port = 8080 # HTTP proxy port
10 registry_port = 5000 # Container registry port10 registry_port = 5000 # Container registry port
11-registry_domain = "registry.mydomain.com" # Registry domain (required)11+gordon_domain = "gordon.mydomain.com" # Gordon domain (required)
12 # data_dir = "~/.gordon" # Data storage directory (default)12 # data_dir = "~/.gordon" # Data storage directory (default)
13 ```13 ```
14 14
@@ -18,7 +18,8 @@ registry_domain = "registry.mydomain.com" # Registry domain (required)
18 |--------|------|---------|-------------|18 |--------|------|---------|-------------|
19 | `port` | int | `80` | HTTP proxy port for routing traffic to containers |19 | `port` | int | `80` | HTTP proxy port for routing traffic to containers |
20 | `registry_port` | int | `5000` | Docker registry port for image push/pull |20 | `registry_port` | int | `5000` | Docker registry port for image push/pull |
21-| `registry_domain` | string | **required** | Domain for the container registry |21+| `gordon_domain` | string | **required** | Domain for Gordon (registry + admin API) |
22+| `registry_domain` | string | - | Deprecated alias for `gordon_domain` |
22 | `data_dir` | string | `~/.gordon` | Directory for registry data, logs, and env files |23 | `data_dir` | string | `~/.gordon` | Directory for registry data, logs, and env files |
23 24
24 ## Port Configuration25 ## Port Configuration
@@ -54,19 +55,24 @@ Docker/Podman clients push to this port:
54 docker push registry.mydomain.com:5000/myapp:latest55 docker push registry.mydomain.com:5000/myapp:latest
55 ```56 ```
56 57
57-## Registry Domain58+## 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 ```toml62 ```toml
62 [server]63 [server]
63-registry_domain = "registry.mydomain.com"64+gordon_domain = "gordon.mydomain.com"
64 ```65 ```
65 66
66 This domain is used for:67 This domain is used for:
67-- Docker login authentication68+- Docker login and image push/pull operations
68-- Image push/pull operations69+- Admin API access (`/admin/*` endpoints)
69-- Internal registry references70+- CLI remote targeting (`gordon routes --remote https://gordon.mydomain.com`)
71+- Token endpoint for authentication (`/v2/token`)
72+
73+When requests arrive on the proxy port with this domain as the Host header, Gordon routes them to the backend services (registry and admin API).
74+
75+> **Note:** `registry_domain` is supported as a deprecated alias for backwards compatibility.
70 76
71 ## Data Directory77 ## Data Directory
72 78
@@ -108,7 +114,7 @@ Gordon creates directories with secure permissions:
108 [server]114 [server]
109 port = 8080115 port = 8080
110 registry_port = 5000116 registry_port = 5000
111-registry_domain = "registry.local"117+gordon_domain = "gordon.local"
112 data_dir = "./dev-data"118 data_dir = "./dev-data"
113 ```119 ```
114 120
@@ -118,7 +124,7 @@ data_dir = "./dev-data"
118 [server]124 [server]
119 port = 8080125 port = 8080
120 registry_port = 5000126 registry_port = 5000
121-registry_domain = "registry.company.com"127+gordon_domain = "gordon.company.com"
122 data_dir = "/var/lib/gordon"128 data_dir = "/var/lib/gordon"
123 ```129 ```
124 130
@@ -128,7 +134,7 @@ data_dir = "/var/lib/gordon"
128 [server]134 [server]
129 port = 8080 # High port for rootless135 port = 8080 # High port for rootless
130 registry_port = 5000136 registry_port = 5000
131-registry_domain = "registry.mydomain.com"137+gordon_domain = "gordon.mydomain.com"
132 ```138 ```
133 139
134 With firewall port forwarding:140 With firewall port forwarding:
M docs/getting-started.md +3 -3
@@ -20,7 +20,7 @@ This script automatically detects your OS and architecture, downloads the approp
20 20
21 ```bash21 ```bash
22 # First run creates the default config22 # First run creates the default config
23-gordon start23+gordon serve
24 # Press Ctrl+C to stop24 # Press Ctrl+C to stop
25 ```25 ```
26 26
@@ -34,7 +34,7 @@ Edit `~/.config/gordon/gordon.toml`:
34 [server]34 [server]
35 port = 8080 # Proxy port (use with Cloudflare)35 port = 8080 # Proxy port (use with Cloudflare)
36 registry_port = 5000 # Registry port36 registry_port = 5000 # Registry port
37-registry_domain = "registry.mydomain.com" # Your registry domain37+gordon_domain = "gordon.mydomain.com" # Your Gordon domain
38 38
39 [routes]39 [routes]
40 "app.mydomain.com" = "myapp:latest" # Domain → Image mapping40 "app.mydomain.com" = "myapp:latest" # Domain → Image mapping
@@ -63,7 +63,7 @@ Description=Gordon Container Platform
63 [Service]63 [Service]
64 Type=simple64 Type=simple
65 Restart=always65 Restart=always
66-ExecStart=/usr/local/bin/gordon start66+ExecStart=/usr/local/bin/gordon serve
67 67
68 [Install]68 [Install]
69 WantedBy=default.target69 WantedBy=default.target
M docs/installation.md +4 -4
@@ -153,7 +153,7 @@ Gordon creates a default configuration on first run:
153 153
154 ```bash154 ```bash
155 # Run once to generate config, then stop155 # Run once to generate config, then stop
156-gordon start156+gordon serve
157 # Press Ctrl+C157 # Press Ctrl+C
158 158
159 # Edit configuration159 # Edit configuration
@@ -166,7 +166,7 @@ Minimum required configuration:
166 [server]166 [server]
167 port = 8080167 port = 8080
168 registry_port = 5000168 registry_port = 5000
169-registry_domain = "registry.yourdomain.com"169+gordon_domain = "gordon.yourdomain.com"
170 170
171 [routes]171 [routes]
172 "app.yourdomain.com" = "myapp:latest"172 "app.yourdomain.com" = "myapp:latest"
@@ -189,7 +189,7 @@ After=podman.socket
189 Type=simple189 Type=simple
190 Restart=always190 Restart=always
191 RestartSec=5191 RestartSec=5
192-ExecStart=/usr/local/bin/gordon start192+ExecStart=/usr/local/bin/gordon serve
193 193
194 [Install]194 [Install]
195 WantedBy=default.target195 WantedBy=default.target
@@ -219,7 +219,7 @@ Requires=docker.service
219 Type=simple219 Type=simple
220 Restart=always220 Restart=always
221 RestartSec=5221 RestartSec=5
222-ExecStart=/usr/local/bin/gordon start222+ExecStart=/usr/local/bin/gordon serve
223 User=root223 User=root
224 224
225 [Install]225 [Install]
M docs/reference/troubleshooting.md +1 -1
@@ -35,7 +35,7 @@ Common issues and solutions when using Gordon.
35 ```bash35 ```bash
36 systemctl --user status gordon36 systemctl --user status gordon
37 # or37 # or
38- pgrep -f "gordon start"38+ pgrep -f "gordon serve"
39 ```39 ```
40 40
41 2. Check registry port is accessible:41 2. Check registry port is accessible:
M go.mod +20 -2
@@ -4,6 +4,9 @@ go 1.25.5
4 4
5 require (5 require (
6 github.com/bnema/zerowrap v1.3.06 github.com/bnema/zerowrap v1.3.0
7+ github.com/charmbracelet/bubbles v0.21.0
8+ github.com/charmbracelet/bubbletea v1.3.10
9+ github.com/charmbracelet/lipgloss v1.1.0
7 github.com/docker/docker v28.5.2+incompatible10 github.com/docker/docker v28.5.2+incompatible
8 github.com/docker/go-connections v0.5.011 github.com/docker/go-connections v0.5.0
9 github.com/golang-jwt/jwt/v5 v5.3.012 github.com/golang-jwt/jwt/v5 v5.3.0
@@ -17,8 +20,23 @@ require (
17 )20 )
18 21
19 require (22 require (
23+ github.com/atotto/clipboard v0.1.4 // indirect
24+ github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
20 github.com/cespare/xxhash/v2 v2.3.0 // indirect25 github.com/cespare/xxhash/v2 v2.3.0 // indirect
26+ github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
27+ github.com/charmbracelet/x/ansi v0.10.1 // indirect
28+ github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
29+ github.com/charmbracelet/x/term v0.2.1 // indirect
30+ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
31+ github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
32+ github.com/mattn/go-localereader v0.0.1 // indirect
33+ github.com/mattn/go-runewidth v0.0.16 // indirect
34+ github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
35+ github.com/muesli/cancelreader v0.2.2 // indirect
36+ github.com/muesli/termenv v0.16.0 // indirect
37+ github.com/rivo/uniseg v0.4.7 // indirect
21 github.com/rs/zerolog v1.34.0 // indirect38 github.com/rs/zerolog v1.34.0 // indirect
39+ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
22 go.yaml.in/yaml/v3 v3.0.4 // indirect40 go.yaml.in/yaml/v3 v3.0.4 // indirect
23 )41 )
24 42
@@ -27,7 +45,7 @@ require (
27 github.com/pmezard/go-difflib v1.0.0 // indirect45 github.com/pmezard/go-difflib v1.0.0 // indirect
28 github.com/stretchr/objx v0.5.2 // indirect46 github.com/stretchr/objx v0.5.2 // indirect
29 go.uber.org/mock v0.5.2 // direct47 go.uber.org/mock v0.5.2 // direct
30- golang.org/x/time v0.12.0 // indirect48+ golang.org/x/time v0.14.0
31 )49 )
32 50
33 require (51 require (
@@ -51,7 +69,7 @@ require (
51 github.com/morikuni/aec v1.0.0 // indirect69 github.com/morikuni/aec v1.0.0 // indirect
52 github.com/opencontainers/go-digest v1.0.0 // indirect70 github.com/opencontainers/go-digest v1.0.0 // indirect
53 github.com/opencontainers/image-spec v1.1.1 // indirect71 github.com/opencontainers/image-spec v1.1.1 // indirect
54- github.com/pelletier/go-toml/v2 v2.2.4 // indirect72+ github.com/pelletier/go-toml/v2 v2.2.4
55 github.com/pkg/errors v0.9.1 // indirect73 github.com/pkg/errors v0.9.1 // indirect
56 github.com/sagikazarmark/locafero v0.12.0 // indirect74 github.com/sagikazarmark/locafero v0.12.0 // indirect
57 github.com/spf13/afero v1.15.075 github.com/spf13/afero v1.15.0
M go.sum +46 -2 generated file: collapsed
A internal/adapters/in/cli/remote/client.go +321 -0
@@ -0,0 +1,321 @@
1+// Package remote provides an HTTP client for connecting to remote Gordon instances.
2+package remote
3+
4+import (
5+ "bytes"
6+ "context"
7+ "encoding/json"
8+ "fmt"
9+ "io"
10+ "net/http"
11+ "strings"
12+ "time"
13+
14+ "gordon/internal/domain"
15+)
16+
17+// Client is an HTTP client for the Gordon admin API.
18+type Client struct {
19+ baseURL string
20+ token string
21+ httpClient *http.Client
22+}
23+
24+// ClientOption configures the Client.
25+type ClientOption func(*Client)
26+
27+// NewClient creates a new remote Gordon client.
28+func NewClient(baseURL string, opts ...ClientOption) *Client {
29+ // Normalize base URL
30+ baseURL = strings.TrimSuffix(baseURL, "/")
31+
32+ c := &Client{
33+ baseURL: baseURL,
34+ httpClient: &http.Client{
35+ Timeout: 30 * time.Second,
36+ },
37+ }
38+
39+ for _, opt := range opts {
40+ opt(c)
41+ }
42+
43+ return c
44+}
45+
46+// WithToken sets the authentication token.
47+func WithToken(token string) ClientOption {
48+ return func(c *Client) {
49+ c.token = token
50+ }
51+}
52+
53+// WithHTTPClient sets a custom HTTP client.
54+func WithHTTPClient(client *http.Client) ClientOption {
55+ return func(c *Client) {
56+ c.httpClient = client
57+ }
58+}
59+
60+// WithTimeout sets the HTTP client timeout.
61+func WithTimeout(timeout time.Duration) ClientOption {
62+ return func(c *Client) {
63+ c.httpClient.Timeout = timeout
64+ }
65+}
66+
67+// request performs an HTTP request to the admin API.
68+func (c *Client) request(ctx context.Context, method, path string, body any) (*http.Response, error) {
69+ url := c.baseURL + "/admin" + path
70+
71+ var bodyReader io.Reader
72+ if body != nil {
73+ jsonBody, err := json.Marshal(body)
74+ if err != nil {
75+ return nil, fmt.Errorf("failed to marshal request body: %w", err)
76+ }
77+ bodyReader = bytes.NewReader(jsonBody)
78+ }
79+
80+ req, err := http.NewRequestWithContext(ctx, method, url, bodyReader)
81+ if err != nil {
82+ return nil, fmt.Errorf("failed to create request: %w", err)
83+ }
84+
85+ req.Header.Set("Content-Type", "application/json")
86+ req.Header.Set("Accept", "application/json")
87+
88+ if c.token != "" {
89+ req.Header.Set("Authorization", "Bearer "+c.token)
90+ }
91+
92+ return c.httpClient.Do(req)
93+}
94+
95+// parseResponse parses a JSON response into the given target.
96+func parseResponse(resp *http.Response, target any) error {
97+ defer resp.Body.Close()
98+
99+ if resp.StatusCode >= 400 {
100+ body, _ := io.ReadAll(resp.Body)
101+ var errResp struct {
102+ Error string `json:"error"`
103+ }
104+ if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error != "" {
105+ return fmt.Errorf("%s: %s", resp.Status, errResp.Error)
106+ }
107+ return fmt.Errorf("%s: %s", resp.Status, string(body))
108+ }
109+
110+ if target != nil {
111+ if err := json.NewDecoder(resp.Body).Decode(target); err != nil {
112+ return fmt.Errorf("failed to decode response: %w", err)
113+ }
114+ }
115+
116+ return nil
117+}
118+
119+// Routes API
120+
121+// ListRoutes returns all configured routes.
122+func (c *Client) ListRoutes(ctx context.Context) ([]domain.Route, error) {
123+ resp, err := c.request(ctx, http.MethodGet, "/routes", nil)
124+ if err != nil {
125+ return nil, err
126+ }
127+
128+ var result struct {
129+ Routes []domain.Route `json:"routes"`
130+ }
131+ if err := parseResponse(resp, &result); err != nil {
132+ return nil, err
133+ }
134+
135+ return result.Routes, nil
136+}
137+
138+// GetRoute returns a specific route by domain.
139+func (c *Client) GetRoute(ctx context.Context, routeDomain string) (*domain.Route, error) {
140+ resp, err := c.request(ctx, http.MethodGet, "/routes/"+routeDomain, nil)
141+ if err != nil {
142+ return nil, err
143+ }
144+
145+ var route domain.Route
146+ if err := parseResponse(resp, &route); err != nil {
147+ return nil, err
148+ }
149+
150+ return &route, nil
151+}
152+
153+// AddRoute adds a new route.
154+func (c *Client) AddRoute(ctx context.Context, route domain.Route) error {
155+ resp, err := c.request(ctx, http.MethodPost, "/routes", route)
156+ if err != nil {
157+ return err
158+ }
159+ return parseResponse(resp, nil)
160+}
161+
162+// UpdateRoute updates an existing route.
163+func (c *Client) UpdateRoute(ctx context.Context, route domain.Route) error {
164+ resp, err := c.request(ctx, http.MethodPut, "/routes/"+route.Domain, route)
165+ if err != nil {
166+ return err
167+ }
168+ return parseResponse(resp, nil)
169+}
170+
171+// RemoveRoute removes a route by domain.
172+func (c *Client) RemoveRoute(ctx context.Context, routeDomain string) error {
173+ resp, err := c.request(ctx, http.MethodDelete, "/routes/"+routeDomain, nil)
174+ if err != nil {
175+ return err
176+ }
177+ return parseResponse(resp, nil)
178+}
179+
180+// Secrets API
181+
182+// ListSecrets returns the list of secret keys for a domain.
183+func (c *Client) ListSecrets(ctx context.Context, secretDomain string) ([]string, error) {
184+ resp, err := c.request(ctx, http.MethodGet, "/secrets/"+secretDomain, nil)
185+ if err != nil {
186+ return nil, err
187+ }
188+
189+ var result struct {
190+ Domain string `json:"domain"`
191+ Keys []string `json:"keys"`
192+ }
193+ if err := parseResponse(resp, &result); err != nil {
194+ return nil, err
195+ }
196+
197+ return result.Keys, nil
198+}
199+
200+// SetSecrets sets secrets for a domain.
201+func (c *Client) SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error {
202+ resp, err := c.request(ctx, http.MethodPost, "/secrets/"+secretDomain, secrets)
203+ if err != nil {
204+ return err
205+ }
206+ return parseResponse(resp, nil)
207+}
208+
209+// DeleteSecret removes a secret from a domain.
210+func (c *Client) DeleteSecret(ctx context.Context, secretDomain, key string) error {
211+ resp, err := c.request(ctx, http.MethodDelete, "/secrets/"+secretDomain+"/"+key, nil)
212+ if err != nil {
213+ return err
214+ }
215+ return parseResponse(resp, nil)
216+}
217+
218+// Status API
219+
220+// Status represents the Gordon server status.
221+type Status struct {
222+ Routes int `json:"routes"`
223+ RegistryDomain string `json:"registry_domain"`
224+ RegistryPort int `json:"registry_port"`
225+ ServerPort int `json:"server_port"`
226+ AutoRoute bool `json:"auto_route"`
227+ NetworkIsolation bool `json:"network_isolation"`
228+ ContainerStatus map[string]string `json:"container_status"`
229+}
230+
231+// GetStatus returns the Gordon server status.
232+func (c *Client) GetStatus(ctx context.Context) (*Status, error) {
233+ resp, err := c.request(ctx, http.MethodGet, "/status", nil)
234+ if err != nil {
235+ return nil, err
236+ }
237+
238+ var status Status
239+ if err := parseResponse(resp, &status); err != nil {
240+ return nil, err
241+ }
242+
243+ return &status, nil
244+}
245+
246+// RouteHealth represents the health status of a route.
247+type RouteHealth struct {
248+ ContainerStatus string `json:"container_status"`
249+ HTTPStatus int `json:"http_status"`
250+ ResponseTimeMs int64 `json:"response_time_ms"`
251+ Healthy bool `json:"healthy"`
252+ Error string `json:"error"`
253+}
254+
255+// GetHealth returns health status for all routes with HTTP probing.
256+func (c *Client) GetHealth(ctx context.Context) (map[string]*RouteHealth, error) {
257+ resp, err := c.request(ctx, http.MethodGet, "/health", nil)
258+ if err != nil {
259+ return nil, err
260+ }
261+
262+ var result struct {
263+ Health map[string]*RouteHealth `json:"health"`
264+ }
265+ if err := parseResponse(resp, &result); err != nil {
266+ return nil, err
267+ }
268+
269+ return result.Health, nil
270+}
271+
272+// Reload triggers a configuration reload.
273+func (c *Client) Reload(ctx context.Context) error {
274+ resp, err := c.request(ctx, http.MethodPost, "/reload", nil)
275+ if err != nil {
276+ return err
277+ }
278+ return parseResponse(resp, nil)
279+}
280+
281+// Config API
282+
283+// Config represents the Gordon configuration.
284+type Config struct {
285+ Server struct {
286+ Port int `json:"port"`
287+ RegistryPort int `json:"registry_port"`
288+ RegistryDomain string `json:"registry_domain"`
289+ DataDir string `json:"data_dir"`
290+ } `json:"server"`
291+ AutoRoute struct {
292+ Enabled bool `json:"enabled"`
293+ } `json:"auto_route"`
294+ NetworkIsolation struct {
295+ Enabled bool `json:"enabled"`
296+ Prefix string `json:"prefix"`
297+ } `json:"network_isolation"`
298+ Routes []domain.Route `json:"routes"`
299+ ExternalRoutes map[string]string `json:"external_routes"`
300+}
301+
302+// GetConfig returns the Gordon configuration.
303+func (c *Client) GetConfig(ctx context.Context) (*Config, error) {
304+ resp, err := c.request(ctx, http.MethodGet, "/config", nil)
305+ if err != nil {
306+ return nil, err
307+ }
308+
309+ var config Config
310+ if err := parseResponse(resp, &config); err != nil {
311+ return nil, err
312+ }
313+
314+ return &config, nil
315+}
316+
317+// Ping checks if the remote Gordon instance is reachable.
318+func (c *Client) Ping(ctx context.Context) error {
319+ _, err := c.GetStatus(ctx)
320+ return err
321+}
A internal/adapters/in/cli/remote/config.go +270 -0
@@ -0,0 +1,270 @@
1+package remote
2+
3+import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+
8+ "github.com/pelletier/go-toml/v2"
9+)
10+
11+// ClientConfig represents the client-mode configuration.
12+type ClientConfig struct {
13+ Client ClientSettings `toml:"client"`
14+ Remotes map[string]RemoteEntry `toml:"remotes"`
15+ Active string `toml:"active"` // Active remote name
16+}
17+
18+// ClientSettings represents the [client] section.
19+type ClientSettings struct {
20+ Mode string `toml:"mode"` // "local" (default) or "remote"
21+ Remote string `toml:"remote"` // Remote Gordon URL
22+ Token string `toml:"token"` // Auth token
23+ TokenEnv string `toml:"token_env"` // Env var name for token
24+}
25+
26+// RemoteEntry represents a saved remote in [remotes.*].
27+type RemoteEntry struct {
28+ URL string `toml:"url"`
29+ Token string `toml:"token,omitempty"`
30+ TokenEnv string `toml:"token_env,omitempty"`
31+}
32+
33+// DefaultClientConfigPath returns the default client config path.
34+func DefaultClientConfigPath() string {
35+ configDir, err := os.UserConfigDir()
36+ if err != nil {
37+ configDir = os.Getenv("HOME")
38+ }
39+ return filepath.Join(configDir, "gordon", "gordon.toml")
40+}
41+
42+// DefaultRemotesPath returns the default remotes config path.
43+func DefaultRemotesPath() string {
44+ configDir, err := os.UserConfigDir()
45+ if err != nil {
46+ configDir = os.Getenv("HOME")
47+ }
48+ return filepath.Join(configDir, "gordon", "remotes.toml")
49+}
50+
51+// LoadClientConfig loads the client configuration.
52+func LoadClientConfig(path string) (*ClientConfig, error) {
53+ if path == "" {
54+ path = DefaultClientConfigPath()
55+ }
56+
57+ data, err := os.ReadFile(path)
58+ if err != nil {
59+ if os.IsNotExist(err) {
60+ // Return default config
61+ return &ClientConfig{
62+ Client: ClientSettings{
63+ Mode: "local",
64+ },
65+ Remotes: make(map[string]RemoteEntry),
66+ }, nil
67+ }
68+ return nil, fmt.Errorf("failed to read config: %w", err)
69+ }
70+
71+ var config ClientConfig
72+ if err := toml.Unmarshal(data, &config); err != nil {
73+ return nil, fmt.Errorf("failed to parse config: %w", err)
74+ }
75+
76+ if config.Remotes == nil {
77+ config.Remotes = make(map[string]RemoteEntry)
78+ }
79+
80+ return &config, nil
81+}
82+
83+// LoadRemotes loads the remotes configuration.
84+func LoadRemotes(path string) (*ClientConfig, error) {
85+ if path == "" {
86+ path = DefaultRemotesPath()
87+ }
88+
89+ data, err := os.ReadFile(path)
90+ if err != nil {
91+ if os.IsNotExist(err) {
92+ return &ClientConfig{
93+ Remotes: make(map[string]RemoteEntry),
94+ }, nil
95+ }
96+ return nil, fmt.Errorf("failed to read remotes: %w", err)
97+ }
98+
99+ var config ClientConfig
100+ if err := toml.Unmarshal(data, &config); err != nil {
101+ return nil, fmt.Errorf("failed to parse remotes: %w", err)
102+ }
103+
104+ if config.Remotes == nil {
105+ config.Remotes = make(map[string]RemoteEntry)
106+ }
107+
108+ return &config, nil
109+}
110+
111+// SaveRemotes saves the remotes configuration.
112+func SaveRemotes(path string, config *ClientConfig) error {
113+ if path == "" {
114+ path = DefaultRemotesPath()
115+ }
116+
117+ // Ensure directory exists
118+ dir := filepath.Dir(path)
119+ if err := os.MkdirAll(dir, 0700); err != nil {
120+ return fmt.Errorf("failed to create config directory: %w", err)
121+ }
122+
123+ data, err := toml.Marshal(config)
124+ if err != nil {
125+ return fmt.Errorf("failed to marshal remotes: %w", err)
126+ }
127+
128+ if err := os.WriteFile(path, data, 0600); err != nil {
129+ return fmt.Errorf("failed to write remotes: %w", err)
130+ }
131+
132+ return nil
133+}
134+
135+// ResolveRemote resolves the remote URL and token from configuration.
136+// Precedence: flag > env > config > active remote
137+func ResolveRemote(flagRemote, flagToken string) (url, token string, isRemote bool) {
138+ config, _ := LoadClientConfig("")
139+ remotes, _ := LoadRemotes("")
140+
141+ url, isRemote = resolveRemoteURL(flagRemote, config, remotes)
142+ if isRemote {
143+ token = resolveToken(flagToken, config, remotes)
144+ }
145+
146+ return url, token, isRemote
147+}
148+
149+// resolveRemoteURL resolves the remote URL from various sources.
150+func resolveRemoteURL(flagRemote string, config *ClientConfig, remotes *ClientConfig) (string, bool) {
151+ // 1. Check flag
152+ if flagRemote != "" {
153+ return flagRemote, true
154+ }
155+
156+ // 2. Check environment variable
157+ if envRemote := os.Getenv("GORDON_REMOTE"); envRemote != "" {
158+ return envRemote, true
159+ }
160+
161+ // 3. Check client config
162+ if config != nil && config.Client.Mode == "remote" && config.Client.Remote != "" {
163+ return config.Client.Remote, true
164+ }
165+
166+ // 4. Check active remote
167+ if remotes != nil && remotes.Active != "" {
168+ if remote, ok := remotes.Remotes[remotes.Active]; ok {
169+ return remote.URL, true
170+ }
171+ }
172+
173+ return "", false
174+}
175+
176+// resolveToken resolves the authentication token from various sources.
177+func resolveToken(flagToken string, config *ClientConfig, remotes *ClientConfig) string {
178+ // 1. Flag token takes precedence
179+ if flagToken != "" {
180+ return flagToken
181+ }
182+
183+ // 2. Environment variable
184+ if envToken := os.Getenv("GORDON_TOKEN"); envToken != "" {
185+ return envToken
186+ }
187+
188+ // 3. Config token
189+ if config != nil {
190+ if config.Client.Token != "" {
191+ return config.Client.Token
192+ }
193+ if config.Client.TokenEnv != "" {
194+ return os.Getenv(config.Client.TokenEnv)
195+ }
196+ }
197+
198+ // 4. Active remote token
199+ if remotes != nil && remotes.Active != "" {
200+ if remote, ok := remotes.Remotes[remotes.Active]; ok {
201+ if remote.Token != "" {
202+ return remote.Token
203+ }
204+ if remote.TokenEnv != "" {
205+ return os.Getenv(remote.TokenEnv)
206+ }
207+ }
208+ }
209+
210+ return ""
211+}
212+
213+// AddRemote adds a new remote to the remotes configuration.
214+func AddRemote(name, url, token string) error {
215+ config, err := LoadRemotes("")
216+ if err != nil {
217+ return err
218+ }
219+
220+ config.Remotes[name] = RemoteEntry{
221+ URL: url,
222+ Token: token,
223+ }
224+
225+ return SaveRemotes("", config)
226+}
227+
228+// RemoveRemote removes a remote from the remotes configuration.
229+func RemoveRemote(name string) error {
230+ config, err := LoadRemotes("")
231+ if err != nil {
232+ return err
233+ }
234+
235+ delete(config.Remotes, name)
236+
237+ // Clear active if it was the removed remote
238+ if config.Active == name {
239+ config.Active = ""
240+ }
241+
242+ return SaveRemotes("", config)
243+}
244+
245+// SetActiveRemote sets the active remote.
246+func SetActiveRemote(name string) error {
247+ config, err := LoadRemotes("")
248+ if err != nil {
249+ return err
250+ }
251+
252+ // Verify remote exists
253+ if _, ok := config.Remotes[name]; !ok {
254+ return fmt.Errorf("remote '%s' not found", name)
255+ }
256+
257+ config.Active = name
258+
259+ return SaveRemotes("", config)
260+}
261+
262+// ListRemotes returns all saved remotes.
263+func ListRemotes() (map[string]RemoteEntry, string, error) {
264+ config, err := LoadRemotes("")
265+ if err != nil {
266+ return nil, "", err
267+ }
268+
269+ return config.Remotes, config.Active, nil
270+}
A internal/adapters/in/cli/remotes.go +246 -0
@@ -0,0 +1,246 @@
1+package cli
2+
3+import (
4+ "fmt"
5+
6+ "gordon/internal/adapters/in/cli/remote"
7+ "gordon/internal/adapters/in/cli/ui/components"
8+ "gordon/internal/adapters/in/cli/ui/styles"
9+
10+ "github.com/spf13/cobra"
11+)
12+
13+// newRemotesCmd creates the remotes command group.
14+func newRemotesCmd() *cobra.Command {
15+ cmd := &cobra.Command{
16+ Use: "remotes",
17+ Short: "Manage saved remote Gordon instances",
18+ Long: `Manage saved remote Gordon instances.
19+
20+Remotes allow you to save frequently used remote Gordon instances
21+and quickly switch between them.
22+
23+Configuration is stored in ~/.config/gordon/remotes.toml`,
24+ }
25+
26+ cmd.AddCommand(newRemotesListCmd())
27+ cmd.AddCommand(newRemotesAddCmd())
28+ cmd.AddCommand(newRemotesRemoveCmd())
29+ cmd.AddCommand(newRemotesUseCmd())
30+
31+ return cmd
32+}
33+
34+// newRemotesListCmd creates the remotes list command.
35+func newRemotesListCmd() *cobra.Command {
36+ return &cobra.Command{
37+ Use: "list",
38+ Short: "List saved remotes",
39+ RunE: func(cmd *cobra.Command, args []string) error {
40+ remotes, active, err := remote.ListRemotes()
41+ if err != nil {
42+ return fmt.Errorf("failed to list remotes: %w", err)
43+ }
44+
45+ if len(remotes) == 0 {
46+ fmt.Println(styles.Theme.Muted.Render("No remotes configured"))
47+ fmt.Println()
48+ fmt.Println("Add a remote with:")
49+ fmt.Println(styles.Theme.Bold.Render(" gordon remotes add <name> <url>"))
50+ return nil
51+ }
52+
53+ fmt.Println(styles.Theme.Title.Render("Saved Remotes"))
54+ fmt.Println()
55+
56+ // Build table rows
57+ rows := make([][]string, 0, len(remotes))
58+ for name, r := range remotes {
59+ status := ""
60+ if name == active {
61+ status = styles.Theme.Success.Render("active")
62+ }
63+
64+ // Mask token if present
65+ tokenStatus := styles.Theme.Muted.Render("none")
66+ if r.Token != "" {
67+ tokenStatus = styles.Theme.Success.Render("set")
68+ } else if r.TokenEnv != "" {
69+ tokenStatus = fmt.Sprintf("$%s", r.TokenEnv)
70+ }
71+
72+ rows = append(rows, []string{name, r.URL, tokenStatus, status})
73+ }
74+
75+ // Render table
76+ table := components.NewTable(
77+ components.WithColumns([]components.TableColumn{
78+ {Title: "Name", Width: 15},
79+ {Title: "URL", Width: 35},
80+ {Title: "Token", Width: 15},
81+ {Title: "Status", Width: 10},
82+ }),
83+ components.WithRows(rows),
84+ )
85+
86+ fmt.Println(table.View())
87+
88+ return nil
89+ },
90+ }
91+}
92+
93+// newRemotesAddCmd creates the remotes add command.
94+func newRemotesAddCmd() *cobra.Command {
95+ var token string
96+ var tokenEnv string
97+
98+ cmd := &cobra.Command{
99+ Use: "add <name> <url>",
100+ Short: "Add a new remote",
101+ Long: `Add a new saved remote.
102+
103+Examples:
104+ gordon remotes add prod https://gordon.mydomain.com
105+ gordon remotes add prod https://gordon.mydomain.com --token eyJ...
106+ gordon remotes add staging https://staging.mydomain.com --token-env STAGING_TOKEN`,
107+ Args: cobra.ExactArgs(2),
108+ RunE: func(cmd *cobra.Command, args []string) error {
109+ name := args[0]
110+ url := args[1]
111+
112+ // If token-env is provided, use that instead of token
113+ finalToken := token
114+ if tokenEnv != "" {
115+ // Store token_env reference instead of actual token
116+ if err := addRemoteWithEnv(name, url, tokenEnv); err != nil {
117+ return fmt.Errorf("failed to add remote: %w", err)
118+ }
119+ } else {
120+ if err := remote.AddRemote(name, url, finalToken); err != nil {
121+ return fmt.Errorf("failed to add remote: %w", err)
122+ }
123+ }
124+
125+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Remote added: %s -> %s", name, url)))
126+
127+ if token == "" && tokenEnv == "" {
128+ fmt.Println(styles.Theme.Muted.Render("Tip: Add a token with --token or --token-env for authenticated access"))
129+ }
130+
131+ return nil
132+ },
133+ }
134+
135+ cmd.Flags().StringVar(&token, "token", "", "Authentication token")
136+ cmd.Flags().StringVar(&tokenEnv, "token-env", "", "Environment variable containing token")
137+
138+ return cmd
139+}
140+
141+// addRemoteWithEnv adds a remote with a token environment variable reference.
142+func addRemoteWithEnv(name, url, tokenEnv string) error {
143+ config, err := remote.LoadRemotes("")
144+ if err != nil {
145+ return err
146+ }
147+
148+ config.Remotes[name] = remote.RemoteEntry{
149+ URL: url,
150+ TokenEnv: tokenEnv,
151+ }
152+
153+ return remote.SaveRemotes("", config)
154+}
155+
156+// newRemotesRemoveCmd creates the remotes remove command.
157+func newRemotesRemoveCmd() *cobra.Command {
158+ var force bool
159+
160+ cmd := &cobra.Command{
161+ Use: "remove <name>",
162+ Aliases: []string{"rm"},
163+ Short: "Remove a remote",
164+ Args: cobra.ExactArgs(1),
165+ RunE: func(cmd *cobra.Command, args []string) error {
166+ name := args[0]
167+
168+ // Check if remote exists
169+ remotes, active, err := remote.ListRemotes()
170+ if err != nil {
171+ return fmt.Errorf("failed to list remotes: %w", err)
172+ }
173+
174+ if _, exists := remotes[name]; !exists {
175+ fmt.Println(styles.RenderError(fmt.Sprintf("Remote '%s' not found", name)))
176+ return nil
177+ }
178+
179+ // Confirm unless --force
180+ if !force {
181+ message := fmt.Sprintf("Remove remote '%s'?", name)
182+ if name == active {
183+ message = fmt.Sprintf("Remove remote '%s'? (currently active)", name)
184+ }
185+
186+ confirmed, err := components.RunConfirm(message)
187+ if err != nil {
188+ return err
189+ }
190+ if !confirmed {
191+ fmt.Println(styles.Theme.Muted.Render("Cancelled"))
192+ return nil
193+ }
194+ }
195+
196+ if err := remote.RemoveRemote(name); err != nil {
197+ return fmt.Errorf("failed to remove remote: %w", err)
198+ }
199+
200+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Remote removed: %s", name)))
201+ return nil
202+ },
203+ }
204+
205+ cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation")
206+
207+ return cmd
208+}
209+
210+// newRemotesUseCmd creates the remotes use command.
211+func newRemotesUseCmd() *cobra.Command {
212+ return &cobra.Command{
213+ Use: "use <name>",
214+ Short: "Set the active remote",
215+ Long: `Set a remote as the active default.
216+
217+When a remote is active, it will be used automatically for remote commands
218+without needing to specify --remote.
219+
220+Examples:
221+ gordon remotes use prod
222+ gordon routes list # Uses prod remote automatically`,
223+ Args: cobra.ExactArgs(1),
224+ RunE: func(cmd *cobra.Command, args []string) error {
225+ name := args[0]
226+
227+ if err := remote.SetActiveRemote(name); err != nil {
228+ if err.Error() == fmt.Sprintf("remote '%s' not found", name) {
229+ fmt.Println(styles.RenderError(fmt.Sprintf("Remote '%s' not found", name)))
230+ fmt.Println()
231+ fmt.Println("Available remotes:")
232+
233+ remotes, _, _ := remote.ListRemotes()
234+ for remoteName := range remotes {
235+ fmt.Printf(" %s\n", remoteName)
236+ }
237+ return nil
238+ }
239+ return fmt.Errorf("failed to set active remote: %w", err)
240+ }
241+
242+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Active remote set to '%s'", name)))
243+ return nil
244+ },
245+ }
246+}
M internal/adapters/in/cli/root.go +35 -19
@@ -3,8 +3,7 @@
3 package cli3 package cli
4 4
5 import (5 import (
6- "context"6+ "gordon/internal/adapters/in/cli/remote"
7-
8 "gordon/internal/app"7 "gordon/internal/app"
9 8
10 "github.com/spf13/cobra"9 "github.com/spf13/cobra"
@@ -15,6 +14,10 @@ var (
15 Version = "dev"14 Version = "dev"
16 Commit = "unknown"15 Commit = "unknown"
17 BuildDate = "unknown"16 BuildDate = "unknown"
17+
18+ // Global flags for remote targeting
19+ remoteFlag string
20+ tokenFlag string
18 )21 )
19 22
20 // NewRootCmd creates the root command for Gordon CLI.23 // NewRootCmd creates the root command for Gordon CLI.
@@ -26,37 +29,50 @@ func NewRootCmd() *cobra.Command {
26 a Docker registry with automatic container deployment capabilities.29 a Docker registry with automatic container deployment capabilities.
27 30
28 It listens for image pushes and automatically deploys containers based on31 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 subcommands38+ // Add persistent flags for remote targeting
33- rootCmd.AddCommand(newStartCmd())39+ rootCmd.PersistentFlags().StringVar(&remoteFlag, "remote", "", "Remote Gordon URL (e.g., https://gordon.mydomain.com)")
40+ rootCmd.PersistentFlags().StringVar(&tokenFlag, "token", "", "Authentication token for remote")
41+
42+ // Server commands
43+ rootCmd.AddCommand(newServeCmd())
44+ rootCmd.AddCommand(newStartCmd()) // Deprecated alias for serve
34 rootCmd.AddCommand(newReloadCmd())45 rootCmd.AddCommand(newReloadCmd())
35 rootCmd.AddCommand(newDeployCmd())46 rootCmd.AddCommand(newDeployCmd())
36 rootCmd.AddCommand(newLogsCmd())47 rootCmd.AddCommand(newLogsCmd())
37 rootCmd.AddCommand(newVersionCmd())48 rootCmd.AddCommand(newVersionCmd())
38 rootCmd.AddCommand(newAuthCmd())49 rootCmd.AddCommand(newAuthCmd())
39 50
51+ // Remote management commands
52+ rootCmd.AddCommand(newRoutesCmd())
53+ rootCmd.AddCommand(newSecretsCmd())
54+ rootCmd.AddCommand(newRemotesCmd())
55+ rootCmd.AddCommand(newStatusCmd())
56+
40 return rootCmd57 return rootCmd
41 }58 }
42 59
43-// newStartCmd creates the start command.60+// GetRemoteClient returns a remote client if targeting a remote instance,
44-func newStartCmd() *cobra.Command {61+// or nil if running locally.
45- var configPath string62+func GetRemoteClient() (*remote.Client, bool) {
46-63+ url, token, isRemote := remote.ResolveRemote(remoteFlag, tokenFlag)
47- cmd := &cobra.Command{64+ if !isRemote {
48- Use: "start",65+ return nil, false
49- Short: "Start the Gordon server",
50- Long: `Start the Gordon server, including the registry and proxy components.`,
51- RunE: func(cmd *cobra.Command, args []string) error {
52- return app.Run(context.Background(), configPath)
53- },
54 }66 }
55 67
56- // Add flags68+ client := remote.NewClient(url, remote.WithToken(token))
57- cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")69+ return client, true
70+}
58 71
59- return cmd72+// IsRemoteMode returns true if CLI is targeting a remote Gordon instance.
73+func IsRemoteMode() bool {
74+ _, _, isRemote := remote.ResolveRemote(remoteFlag, tokenFlag)
75+ return isRemote
60 }76 }
61 77
62 // newReloadCmd creates the reload command.78 // newReloadCmd creates the reload command.
A internal/adapters/in/cli/routes.go +300 -0
@@ -0,0 +1,300 @@
1+package cli
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+ "strings"
8+
9+ "gordon/internal/adapters/in/cli/remote"
10+ "gordon/internal/adapters/in/cli/ui/components"
11+ "gordon/internal/adapters/in/cli/ui/styles"
12+ "gordon/internal/domain"
13+
14+ "github.com/spf13/cobra"
15+)
16+
17+// truncateImage shortens long image references for display.
18+// For digests (image@sha256:...), shows first 12 chars of digest.
19+// For tags, truncates to maxLen with ellipsis if needed.
20+func truncateImage(image string, maxLen int) string {
21+ // Handle digest references: image@sha256:abc123...
22+ if idx := strings.Index(image, "@sha256:"); idx != -1 {
23+ name := image[:idx]
24+ digest := image[idx+8:] // Skip "@sha256:"
25+ if len(digest) > 12 {
26+ digest = digest[:12]
27+ }
28+ short := fmt.Sprintf("%s@sha256:%s…", name, digest)
29+ if len(short) > maxLen {
30+ // Truncate name if still too long
31+ available := maxLen - len("@sha256:") - 12 - 1 // 1 for ellipsis
32+ if available > 3 {
33+ name = name[:available-1] + "…"
34+ }
35+ short = fmt.Sprintf("%s@sha256:%s…", name, digest)
36+ }
37+ return short
38+ }
39+
40+ // Regular tag: truncate if needed
41+ if len(image) > maxLen {
42+ return image[:maxLen-1] + "…"
43+ }
44+ return image
45+}
46+
47+// newRoutesCmd creates the routes command group.
48+func newRoutesCmd() *cobra.Command {
49+ cmd := &cobra.Command{
50+ Use: "routes",
51+ Short: "Manage routes",
52+ Long: `Manage Gordon routes. Routes map domains to container images.
53+
54+When targeting a remote Gordon instance (via --remote flag or GORDON_REMOTE env var),
55+these commands operate on the remote server. Otherwise, they require access to
56+the local Gordon configuration.`,
57+ }
58+
59+ cmd.AddCommand(newRoutesListCmd())
60+ cmd.AddCommand(newRoutesAddCmd())
61+ cmd.AddCommand(newRoutesRemoveCmd())
62+
63+ return cmd
64+}
65+
66+// formatHTTPStatus formats the HTTP status for display.
67+func formatHTTPStatus(health *remote.RouteHealth) string {
68+ if health == nil {
69+ return styles.Theme.Muted.Render("-")
70+ }
71+ if health.HTTPStatus == 0 {
72+ if health.Error != "" {
73+ return styles.Theme.BadgeError.Render("err")
74+ }
75+ return styles.Theme.Muted.Render("-")
76+ }
77+ status := fmt.Sprintf("%d", health.HTTPStatus)
78+ if health.ResponseTimeMs > 0 {
79+ status = fmt.Sprintf("%d (%dms)", health.HTTPStatus, health.ResponseTimeMs)
80+ }
81+ if health.HTTPStatus >= 200 && health.HTTPStatus < 400 {
82+ return styles.Theme.BadgeSuccess.Render(status)
83+ }
84+ return styles.Theme.BadgeError.Render(status)
85+}
86+
87+// newRoutesListCmd creates the routes list command.
88+func newRoutesListCmd() *cobra.Command {
89+ return &cobra.Command{
90+ Use: "list",
91+ Short: "List all routes",
92+ RunE: func(cmd *cobra.Command, args []string) error {
93+ ctx := context.Background()
94+
95+ client, isRemote := GetRemoteClient()
96+ if !isRemote {
97+ fmt.Println(styles.RenderError("routes command requires --remote flag or GORDON_REMOTE env var"))
98+ fmt.Println(styles.Theme.Muted.Render("Local route management is not yet supported. Use the config file directly."))
99+ return nil
100+ }
101+
102+ routes, err := client.ListRoutes(ctx)
103+ if err != nil {
104+ return fmt.Errorf("failed to list routes: %w", err)
105+ }
106+
107+ if len(routes) == 0 {
108+ fmt.Println(styles.Theme.Muted.Render("No routes configured"))
109+ return nil
110+ }
111+
112+ // Get health status for each route (includes container status and HTTP probe)
113+ health, _ := client.GetHealth(ctx)
114+ if health == nil {
115+ health = make(map[string]*remote.RouteHealth)
116+ }
117+
118+ // Build table rows
119+ const imageColWidth = 35
120+ rows := make([][]string, len(routes))
121+ for i, route := range routes {
122+ routeHealth := health[route.Domain]
123+
124+ // Container status column
125+ containerStatus := "unknown"
126+ if routeHealth != nil {
127+ containerStatus = routeHealth.ContainerStatus
128+ }
129+ containerBadge := components.ContainerStatusBadge(containerStatus)
130+
131+ // HTTP status column
132+ httpStatus := formatHTTPStatus(routeHealth)
133+
134+ displayImage := truncateImage(route.Image, imageColWidth)
135+ rows[i] = []string{route.Domain, displayImage, containerBadge, httpStatus}
136+ }
137+
138+ // Render table
139+ table := components.NewTable(
140+ components.WithColumns([]components.TableColumn{
141+ {Title: "Domain", Width: 25},
142+ {Title: "Image", Width: imageColWidth},
143+ {Title: "Container", Width: 12},
144+ {Title: "HTTP", Width: 14},
145+ }),
146+ components.WithRows(rows),
147+ )
148+
149+ fmt.Println(styles.Theme.Title.Render("Routes"))
150+ fmt.Println()
151+ fmt.Println(table.View())
152+
153+ return nil
154+ },
155+ }
156+}
157+
158+// newRoutesAddCmd creates the routes add command.
159+func newRoutesAddCmd() *cobra.Command {
160+ var image string
161+
162+ cmd := &cobra.Command{
163+ Use: "add <domain>",
164+ Short: "Add a new route",
165+ Long: `Add a new route mapping a domain to a container image.
166+
167+Examples:
168+ gordon routes add app.mydomain.com --image myapp:latest
169+ gordon --remote https://gordon.mydomain.com routes add api.mydomain.com --image api:v2`,
170+ Args: cobra.ExactArgs(1),
171+ RunE: func(cmd *cobra.Command, args []string) error {
172+ ctx := context.Background()
173+ routeDomain := args[0]
174+
175+ client, isRemote := GetRemoteClient()
176+ if !isRemote {
177+ fmt.Println(styles.RenderError("routes command requires --remote flag or GORDON_REMOTE env var"))
178+ return nil
179+ }
180+
181+ if image == "" {
182+ fmt.Println(styles.RenderError("--image flag is required"))
183+ return nil
184+ }
185+
186+ route := domain.Route{
187+ Domain: routeDomain,
188+ Image: image,
189+ }
190+
191+ if err := client.AddRoute(ctx, route); err != nil {
192+ return fmt.Errorf("failed to add route: %w", err)
193+ }
194+
195+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Route added: %s -> %s", routeDomain, image)))
196+ return nil
197+ },
198+ }
199+
200+ cmd.Flags().StringVarP(&image, "image", "i", "", "Container image (required)")
201+ _ = cmd.MarkFlagRequired("image")
202+
203+ return cmd
204+}
205+
206+// newRoutesRemoveCmd creates the routes remove command.
207+func newRoutesRemoveCmd() *cobra.Command {
208+ var force bool
209+
210+ cmd := &cobra.Command{
211+ Use: "remove <domain>",
212+ Short: "Remove a route",
213+ Long: `Remove a route by its domain name.
214+
215+Examples:
216+ gordon routes remove app.mydomain.com
217+ gordon routes remove app.mydomain.com --force`,
218+ Args: cobra.ExactArgs(1),
219+ RunE: func(cmd *cobra.Command, args []string) error {
220+ ctx := context.Background()
221+ routeDomain := args[0]
222+
223+ client, isRemote := GetRemoteClient()
224+ if !isRemote {
225+ fmt.Println(styles.RenderError("routes command requires --remote flag or GORDON_REMOTE env var"))
226+ return nil
227+ }
228+
229+ // Confirm unless --force
230+ if !force {
231+ confirmed, err := components.RunConfirm(
232+ fmt.Sprintf("Remove route '%s'?", routeDomain),
233+ components.WithDescription("This will stop and remove the associated container."),
234+ )
235+ if err != nil {
236+ return err
237+ }
238+ if !confirmed {
239+ fmt.Println(styles.Theme.Muted.Render("Cancelled"))
240+ return nil
241+ }
242+ }
243+
244+ if err := client.RemoveRoute(ctx, routeDomain); err != nil {
245+ return fmt.Errorf("failed to remove route: %w", err)
246+ }
247+
248+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Route removed: %s", routeDomain)))
249+ return nil
250+ },
251+ }
252+
253+ cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation")
254+
255+ return cmd
256+}
257+
258+// newStatusCmd creates the status command.
259+func newStatusCmd() *cobra.Command {
260+ return &cobra.Command{
261+ Use: "status",
262+ Short: "Show Gordon server status",
263+ RunE: func(cmd *cobra.Command, args []string) error {
264+ ctx := context.Background()
265+
266+ client, isRemote := GetRemoteClient()
267+ if !isRemote {
268+ fmt.Println(styles.RenderError("status command requires --remote flag or GORDON_REMOTE env var"))
269+ os.Exit(1)
270+ }
271+
272+ status, err := client.GetStatus(ctx)
273+ if err != nil {
274+ return fmt.Errorf("failed to get status: %w", err)
275+ }
276+
277+ // Display status
278+ fmt.Println(styles.Theme.Title.Render("Gordon Status"))
279+ fmt.Println()
280+
281+ fmt.Printf("%s %s\n", styles.Theme.Bold.Render("Domain:"), status.RegistryDomain)
282+ fmt.Printf("%s %d\n", styles.Theme.Bold.Render("Registry Port:"), status.RegistryPort)
283+ fmt.Printf("%s %d\n", styles.Theme.Bold.Render("Server Port:"), status.ServerPort)
284+ fmt.Printf("%s %d\n", styles.Theme.Bold.Render("Routes:"), status.Routes)
285+ fmt.Printf("%s %v\n", styles.Theme.Bold.Render("Auto-Route:"), status.AutoRoute)
286+ fmt.Printf("%s %v\n", styles.Theme.Bold.Render("Network Isolation:"), status.NetworkIsolation)
287+
288+ if len(status.ContainerStatus) > 0 {
289+ fmt.Println()
290+ fmt.Println(styles.Theme.Bold.Render("Container Status:"))
291+ for domain, containerStatus := range status.ContainerStatus {
292+ badge := components.ContainerStatusBadge(containerStatus)
293+ fmt.Printf(" %s: %s\n", domain, badge)
294+ }
295+ }
296+
297+ return nil
298+ },
299+ }
300+}
A internal/adapters/in/cli/secrets.go +196 -0
@@ -0,0 +1,196 @@
1+package cli
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strings"
7+
8+ "gordon/internal/adapters/in/cli/ui/components"
9+ "gordon/internal/adapters/in/cli/ui/styles"
10+
11+ "github.com/spf13/cobra"
12+)
13+
14+// newSecretsCmd creates the secrets command group.
15+func newSecretsCmd() *cobra.Command {
16+ cmd := &cobra.Command{
17+ Use: "secrets",
18+ Short: "Manage secrets",
19+ Long: `Manage secrets (environment variables) for routes.
20+
21+Secrets are stored per-domain and injected into containers as environment variables.
22+
23+When targeting a remote Gordon instance (via --remote flag or GORDON_REMOTE env var),
24+these commands operate on the remote server.`,
25+ }
26+
27+ cmd.AddCommand(newSecretsListCmd())
28+ cmd.AddCommand(newSecretsSetCmd())
29+ cmd.AddCommand(newSecretsRemoveCmd())
30+
31+ return cmd
32+}
33+
34+// newSecretsListCmd creates the secrets list command.
35+func newSecretsListCmd() *cobra.Command {
36+ return &cobra.Command{
37+ Use: "list <domain>",
38+ Short: "List secrets for a domain",
39+ Long: `List all secret keys configured for a domain.
40+
41+Note: Only secret keys are shown, not values (for security).
42+
43+Examples:
44+ gordon secrets list app.mydomain.com
45+ gordon --remote https://gordon.mydomain.com secrets list api.mydomain.com`,
46+ Args: cobra.ExactArgs(1),
47+ RunE: func(cmd *cobra.Command, args []string) error {
48+ ctx := context.Background()
49+ domain := args[0]
50+
51+ client, isRemote := GetRemoteClient()
52+ if !isRemote {
53+ fmt.Println(styles.RenderError("secrets command requires --remote flag or GORDON_REMOTE env var"))
54+ fmt.Println(styles.Theme.Muted.Render("Local secret management is not yet supported."))
55+ return nil
56+ }
57+
58+ keys, err := client.ListSecrets(ctx, domain)
59+ if err != nil {
60+ return fmt.Errorf("failed to list secrets: %w", err)
61+ }
62+
63+ if len(keys) == 0 {
64+ fmt.Println(styles.Theme.Muted.Render(fmt.Sprintf("No secrets configured for %s", domain)))
65+ return nil
66+ }
67+
68+ fmt.Println(styles.Theme.Title.Render(fmt.Sprintf("Secrets for %s", domain)))
69+ fmt.Println()
70+
71+ // Build table rows
72+ rows := make([][]string, len(keys))
73+ for i, key := range keys {
74+ rows[i] = []string{key, styles.Theme.Muted.Render("(hidden)")}
75+ }
76+
77+ // Render table
78+ table := components.NewTable(
79+ components.WithColumns([]components.TableColumn{
80+ {Title: "Key", Width: 30},
81+ {Title: "Value", Width: 20},
82+ }),
83+ components.WithRows(rows),
84+ )
85+
86+ fmt.Println(table.View())
87+
88+ return nil
89+ },
90+ }
91+}
92+
93+// newSecretsSetCmd creates the secrets set command.
94+func newSecretsSetCmd() *cobra.Command {
95+ return &cobra.Command{
96+ Use: "set <domain> <KEY=value>...",
97+ Short: "Set secrets for a domain",
98+ Long: `Set one or more secrets for a domain.
99+
100+Secrets are specified as KEY=value pairs. Multiple secrets can be set at once.
101+
102+Examples:
103+ gordon secrets set app.mydomain.com DATABASE_URL=postgres://localhost/db
104+ gordon secrets set app.mydomain.com API_KEY=secret123 DEBUG=false
105+ gordon --remote https://gordon.mydomain.com secrets set api.mydomain.com TOKEN=abc`,
106+ Args: cobra.MinimumNArgs(2),
107+ RunE: func(cmd *cobra.Command, args []string) error {
108+ ctx := context.Background()
109+ domain := args[0]
110+ pairs := args[1:]
111+
112+ client, isRemote := GetRemoteClient()
113+ if !isRemote {
114+ fmt.Println(styles.RenderError("secrets command requires --remote flag or GORDON_REMOTE env var"))
115+ return nil
116+ }
117+
118+ // Parse KEY=value pairs
119+ secrets := make(map[string]string)
120+ for _, pair := range pairs {
121+ parts := strings.SplitN(pair, "=", 2)
122+ if len(parts) != 2 {
123+ fmt.Println(styles.RenderError(fmt.Sprintf("Invalid format: %s (expected KEY=value)", pair)))
124+ return nil
125+ }
126+ secrets[parts[0]] = parts[1]
127+ }
128+
129+ if err := client.SetSecrets(ctx, domain, secrets); err != nil {
130+ return fmt.Errorf("failed to set secrets: %w", err)
131+ }
132+
133+ if len(secrets) == 1 {
134+ for key := range secrets {
135+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret set: %s", key)))
136+ }
137+ } else {
138+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Set %d secrets for %s", len(secrets), domain)))
139+ }
140+
141+ return nil
142+ },
143+ }
144+}
145+
146+// newSecretsRemoveCmd creates the secrets remove command.
147+func newSecretsRemoveCmd() *cobra.Command {
148+ var force bool
149+
150+ cmd := &cobra.Command{
151+ Use: "remove <domain> <key>",
152+ Short: "Remove a secret",
153+ Long: `Remove a secret from a domain.
154+
155+Examples:
156+ gordon secrets remove app.mydomain.com OLD_API_KEY
157+ gordon secrets remove app.mydomain.com OLD_API_KEY --force`,
158+ Args: cobra.ExactArgs(2),
159+ RunE: func(cmd *cobra.Command, args []string) error {
160+ ctx := context.Background()
161+ domain := args[0]
162+ key := args[1]
163+
164+ client, isRemote := GetRemoteClient()
165+ if !isRemote {
166+ fmt.Println(styles.RenderError("secrets command requires --remote flag or GORDON_REMOTE env var"))
167+ return nil
168+ }
169+
170+ // Confirm unless --force
171+ if !force {
172+ confirmed, err := components.RunConfirm(
173+ fmt.Sprintf("Remove secret '%s' from %s?", key, domain),
174+ )
175+ if err != nil {
176+ return err
177+ }
178+ if !confirmed {
179+ fmt.Println(styles.Theme.Muted.Render("Cancelled"))
180+ return nil
181+ }
182+ }
183+
184+ if err := client.DeleteSecret(ctx, domain, key); err != nil {
185+ return fmt.Errorf("failed to remove secret: %w", err)
186+ }
187+
188+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret removed: %s", key)))
189+ return nil
190+ },
191+ }
192+
193+ cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation")
194+
195+ return cmd
196+}
A internal/adapters/in/cli/serve.go +52 -0
@@ -0,0 +1,52 @@
1+package cli
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+
8+ "gordon/internal/app"
9+
10+ "github.com/spf13/cobra"
11+)
12+
13+// newServeCmd creates the serve command.
14+func newServeCmd() *cobra.Command {
15+ var configPath string
16+
17+ cmd := &cobra.Command{
18+ Use: "serve",
19+ Short: "Start the Gordon server",
20+ Long: `Start the Gordon server, including the registry and proxy components.`,
21+ RunE: func(cmd *cobra.Command, args []string) error {
22+ return app.Run(context.Background(), configPath)
23+ },
24+ }
25+
26+ // Add flags
27+ cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
28+
29+ return cmd
30+}
31+
32+// newStartCmd creates a deprecated alias for serve.
33+func newStartCmd() *cobra.Command {
34+ var configPath string
35+
36+ cmd := &cobra.Command{
37+ Use: "start",
38+ Short: "Start the Gordon server (deprecated: use 'serve')",
39+ Long: `Start the Gordon server. This command is deprecated, please use 'gordon serve' instead.`,
40+ Deprecated: "use 'gordon serve' instead",
41+ Hidden: true, // Hide from help but still works
42+ RunE: func(cmd *cobra.Command, args []string) error {
43+ fmt.Fprintln(os.Stderr, "\nWarning: 'gordon start' is deprecated, use 'gordon serve' instead")
44+ return app.Run(context.Background(), configPath)
45+ },
46+ }
47+
48+ // Add flags (same as serve)
49+ cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
50+
51+ return cmd
52+}
A internal/adapters/in/cli/ui/components/confirm.go +202 -0
@@ -0,0 +1,202 @@
1+package components
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "gordon/internal/adapters/in/cli/ui/styles"
8+
9+ tea "github.com/charmbracelet/bubbletea"
10+ "github.com/charmbracelet/lipgloss"
11+)
12+
13+// ConfirmResult represents the result of a confirmation dialog.
14+type ConfirmResult int
15+
16+const (
17+ ConfirmPending ConfirmResult = iota
18+ ConfirmYes
19+ ConfirmNo
20+ ConfirmCancelled
21+)
22+
23+// ConfirmModel is a Yes/No confirmation dialog.
24+type ConfirmModel struct {
25+ question string
26+ description string
27+ yesLabel string
28+ noLabel string
29+ focused bool // true = Yes is focused, false = No is focused
30+ result ConfirmResult
31+ width int
32+
33+ // Styles
34+ questionStyle lipgloss.Style
35+ descriptionStyle lipgloss.Style
36+ buttonStyle lipgloss.Style
37+ focusedStyle lipgloss.Style
38+}
39+
40+// ConfirmOption configures a ConfirmModel.
41+type ConfirmOption func(*ConfirmModel)
42+
43+// NewConfirm creates a new confirmation dialog.
44+func NewConfirm(question string, opts ...ConfirmOption) ConfirmModel {
45+ m := ConfirmModel{
46+ question: question,
47+ yesLabel: "Yes",
48+ noLabel: "No",
49+ focused: false, // Default to No
50+ result: ConfirmPending,
51+ width: 50,
52+ questionStyle: styles.Theme.Bold,
53+ descriptionStyle: styles.Theme.Muted,
54+ buttonStyle: lipgloss.NewStyle().
55+ Padding(0, 2).
56+ Foreground(styles.ColorText),
57+ focusedStyle: lipgloss.NewStyle().
58+ Padding(0, 2).
59+ Bold(true).
60+ Foreground(styles.ColorBg).
61+ Background(styles.ColorPrimary),
62+ }
63+
64+ for _, opt := range opts {
65+ opt(&m)
66+ }
67+
68+ return m
69+}
70+
71+// WithDescription adds a description to the confirmation.
72+func WithDescription(desc string) ConfirmOption {
73+ return func(m *ConfirmModel) {
74+ m.description = desc
75+ }
76+}
77+
78+// WithDefaultYes sets Yes as the default selection.
79+func WithDefaultYes() ConfirmOption {
80+ return func(m *ConfirmModel) {
81+ m.focused = true
82+ }
83+}
84+
85+// WithLabels customizes the Yes/No labels.
86+func WithLabels(yes, no string) ConfirmOption {
87+ return func(m *ConfirmModel) {
88+ m.yesLabel = yes
89+ m.noLabel = no
90+ }
91+}
92+
93+// WithWidth sets the dialog width.
94+func WithWidth(w int) ConfirmOption {
95+ return func(m *ConfirmModel) {
96+ m.width = w
97+ }
98+}
99+
100+// Init implements tea.Model.
101+func (m ConfirmModel) Init() tea.Cmd {
102+ return nil
103+}
104+
105+// Update implements tea.Model.
106+func (m ConfirmModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
107+ switch msg := msg.(type) {
108+ case tea.KeyMsg:
109+ switch msg.String() {
110+ case "left", "h", "tab", "shift+tab":
111+ m.focused = !m.focused
112+ case "right", "l":
113+ m.focused = !m.focused
114+ case "y", "Y":
115+ m.result = ConfirmYes
116+ return m, tea.Quit
117+ case "n", "N":
118+ m.result = ConfirmNo
119+ return m, tea.Quit
120+ case "enter":
121+ if m.focused {
122+ m.result = ConfirmYes
123+ } else {
124+ m.result = ConfirmNo
125+ }
126+ return m, tea.Quit
127+ case "esc", "ctrl+c", "q":
128+ m.result = ConfirmCancelled
129+ return m, tea.Quit
130+ }
131+ }
132+ return m, nil
133+}
134+
135+// View implements tea.Model.
136+func (m ConfirmModel) View() string {
137+ var b strings.Builder
138+
139+ // Question
140+ b.WriteString(m.questionStyle.Render(m.question))
141+ b.WriteString("\n")
142+
143+ // Description
144+ if m.description != "" {
145+ b.WriteString(m.descriptionStyle.Render(m.description))
146+ b.WriteString("\n")
147+ }
148+ b.WriteString("\n")
149+
150+ // Buttons
151+ var yesButton, noButton string
152+ if m.focused {
153+ yesButton = m.focusedStyle.Render(m.yesLabel)
154+ noButton = m.buttonStyle.Render(m.noLabel)
155+ } else {
156+ yesButton = m.buttonStyle.Render(m.yesLabel)
157+ noButton = m.focusedStyle.Render(m.noLabel)
158+ }
159+
160+ buttons := lipgloss.JoinHorizontal(lipgloss.Center, yesButton, " ", noButton)
161+ b.WriteString(buttons)
162+ b.WriteString("\n\n")
163+
164+ // Help
165+ help := styles.RenderKeyHelp("y/n", "select") + " " +
166+ styles.RenderKeyHelp("enter", "confirm") + " " +
167+ styles.RenderKeyHelp("esc", "cancel")
168+ b.WriteString(help)
169+
170+ return b.String()
171+}
172+
173+// Result returns the confirmation result.
174+func (m ConfirmModel) Result() ConfirmResult {
175+ return m.result
176+}
177+
178+// Confirmed returns true if the user confirmed.
179+func (m ConfirmModel) Confirmed() bool {
180+ return m.result == ConfirmYes
181+}
182+
183+// Cancelled returns true if the user cancelled.
184+func (m ConfirmModel) Cancelled() bool {
185+ return m.result == ConfirmCancelled
186+}
187+
188+// RunConfirm runs a confirmation dialog and returns the result.
189+// This is a convenience function for simple confirmations.
190+func RunConfirm(question string, opts ...ConfirmOption) (bool, error) {
191+ m := NewConfirm(question, opts...)
192+ p := tea.NewProgram(m)
193+ finalModel, err := p.Run()
194+ if err != nil {
195+ return false, fmt.Errorf("error running confirmation: %w", err)
196+ }
197+ result := finalModel.(ConfirmModel)
198+ if result.Cancelled() {
199+ return false, nil
200+ }
201+ return result.Confirmed(), nil
202+}
A internal/adapters/in/cli/ui/components/form.go +358 -0
@@ -0,0 +1,358 @@
1+package components
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "gordon/internal/adapters/in/cli/ui/styles"
8+
9+ "github.com/charmbracelet/bubbles/textinput"
10+ tea "github.com/charmbracelet/bubbletea"
11+ "github.com/charmbracelet/lipgloss"
12+)
13+
14+// FieldType defines the type of form field.
15+type FieldType int
16+
17+const (
18+ FieldTypeText FieldType = iota
19+ FieldTypePassword
20+ FieldTypeSelect
21+)
22+
23+// FormField defines a form field configuration.
24+type FormField struct {
25+ Key string
26+ Label string
27+ Placeholder string
28+ Default string
29+ Required bool
30+ Type FieldType
31+ Options []string // For select fields
32+ Validator func(string) error
33+}
34+
35+// FormModel is an interactive form component.
36+type FormModel struct {
37+ title string
38+ description string
39+ fields []FormField
40+ inputs []textinput.Model
41+ focused int
42+ submitted bool
43+ cancelled bool
44+ errors []string
45+ width int
46+
47+ // Styles
48+ titleStyle lipgloss.Style
49+ descriptionStyle lipgloss.Style
50+ labelStyle lipgloss.Style
51+ errorStyle lipgloss.Style
52+}
53+
54+// FormOption configures a FormModel.
55+type FormOption func(*FormModel)
56+
57+// NewForm creates a new form with the given fields.
58+func NewForm(fields []FormField, opts ...FormOption) FormModel {
59+ inputs := make([]textinput.Model, len(fields))
60+ errors := make([]string, len(fields))
61+
62+ for i, field := range fields {
63+ ti := textinput.New()
64+ ti.Placeholder = field.Placeholder
65+ ti.SetValue(field.Default)
66+ ti.CharLimit = 256
67+ ti.Width = 40
68+
69+ if field.Type == FieldTypePassword {
70+ ti.EchoMode = textinput.EchoPassword
71+ ti.EchoCharacter = '*'
72+ }
73+
74+ if i == 0 {
75+ ti.Focus()
76+ }
77+
78+ inputs[i] = ti
79+ }
80+
81+ m := FormModel{
82+ fields: fields,
83+ inputs: inputs,
84+ focused: 0,
85+ errors: errors,
86+ width: 50,
87+ titleStyle: styles.Theme.Title,
88+ descriptionStyle: styles.Theme.Muted,
89+ labelStyle: styles.Theme.FormLabel,
90+ errorStyle: styles.Theme.FormError,
91+ }
92+
93+ for _, opt := range opts {
94+ opt(&m)
95+ }
96+
97+ return m
98+}
99+
100+// WithFormTitle sets the form title.
101+func WithFormTitle(title string) FormOption {
102+ return func(m *FormModel) {
103+ m.title = title
104+ }
105+}
106+
107+// WithFormDescription sets the form description.
108+func WithFormDescription(desc string) FormOption {
109+ return func(m *FormModel) {
110+ m.description = desc
111+ }
112+}
113+
114+// WithFormWidth sets the form width.
115+func WithFormWidth(w int) FormOption {
116+ return func(m *FormModel) {
117+ m.width = w
118+ for i := range m.inputs {
119+ m.inputs[i].Width = w - 4 // Account for padding
120+ }
121+ }
122+}
123+
124+// Init implements tea.Model.
125+func (m FormModel) Init() tea.Cmd {
126+ return textinput.Blink
127+}
128+
129+// Update implements tea.Model.
130+func (m FormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
131+ var cmds []tea.Cmd
132+
133+ switch msg := msg.(type) {
134+ case tea.KeyMsg:
135+ switch msg.String() {
136+ case "ctrl+c", "esc":
137+ m.cancelled = true
138+ return m, tea.Quit
139+ case "tab", "down":
140+ m.focusNext()
141+ case "shift+tab", "up":
142+ m.focusPrev()
143+ case "enter":
144+ if m.focused == len(m.fields)-1 {
145+ // On last field, submit
146+ if m.validate() {
147+ m.submitted = true
148+ return m, tea.Quit
149+ }
150+ } else {
151+ m.focusNext()
152+ }
153+ }
154+ }
155+
156+ // Update the focused input
157+ var cmd tea.Cmd
158+ m.inputs[m.focused], cmd = m.inputs[m.focused].Update(msg)
159+ cmds = append(cmds, cmd)
160+
161+ return m, tea.Batch(cmds...)
162+}
163+
164+// focusNext moves focus to the next field.
165+func (m *FormModel) focusNext() {
166+ m.inputs[m.focused].Blur()
167+ m.focused = (m.focused + 1) % len(m.fields)
168+ m.inputs[m.focused].Focus()
169+}
170+
171+// focusPrev moves focus to the previous field.
172+func (m *FormModel) focusPrev() {
173+ m.inputs[m.focused].Blur()
174+ m.focused--
175+ if m.focused < 0 {
176+ m.focused = len(m.fields) - 1
177+ }
178+ m.inputs[m.focused].Focus()
179+}
180+
181+// validate validates all fields.
182+func (m *FormModel) validate() bool {
183+ valid := true
184+ for i, field := range m.fields {
185+ value := m.inputs[i].Value()
186+ m.errors[i] = ""
187+
188+ // Check required
189+ if field.Required && strings.TrimSpace(value) == "" {
190+ m.errors[i] = "This field is required"
191+ valid = false
192+ continue
193+ }
194+
195+ // Run custom validator
196+ if field.Validator != nil && value != "" {
197+ if err := field.Validator(value); err != nil {
198+ m.errors[i] = err.Error()
199+ valid = false
200+ }
201+ }
202+ }
203+ return valid
204+}
205+
206+// View implements tea.Model.
207+func (m FormModel) View() string {
208+ var b strings.Builder
209+
210+ // Title
211+ if m.title != "" {
212+ b.WriteString(m.titleStyle.Render(m.title))
213+ b.WriteString("\n")
214+ }
215+
216+ // Description
217+ if m.description != "" {
218+ b.WriteString(m.descriptionStyle.Render(m.description))
219+ b.WriteString("\n")
220+ }
221+
222+ if m.title != "" || m.description != "" {
223+ b.WriteString("\n")
224+ }
225+
226+ // Fields
227+ for i, field := range m.fields {
228+ // Label
229+ label := field.Label
230+ if field.Required {
231+ label += " *"
232+ }
233+ b.WriteString(m.labelStyle.Render(label))
234+ b.WriteString("\n")
235+
236+ // Input
237+ b.WriteString(m.inputs[i].View())
238+ b.WriteString("\n")
239+
240+ // Error
241+ if m.errors[i] != "" {
242+ b.WriteString(m.errorStyle.Render(styles.IconError + " " + m.errors[i]))
243+ b.WriteString("\n")
244+ }
245+
246+ b.WriteString("\n")
247+ }
248+
249+ // Help
250+ help := styles.RenderKeyHelp("tab/shift+tab", "navigate") + " " +
251+ styles.RenderKeyHelp("enter", "submit") + " " +
252+ styles.RenderKeyHelp("esc", "cancel")
253+ b.WriteString(help)
254+
255+ return b.String()
256+}
257+
258+// Values returns the form values as a map.
259+func (m FormModel) Values() map[string]string {
260+ values := make(map[string]string)
261+ for i, field := range m.fields {
262+ values[field.Key] = m.inputs[i].Value()
263+ }
264+ return values
265+}
266+
267+// Value returns the value for a specific field.
268+func (m FormModel) Value(key string) string {
269+ for i, field := range m.fields {
270+ if field.Key == key {
271+ return m.inputs[i].Value()
272+ }
273+ }
274+ return ""
275+}
276+
277+// Submitted returns true if the form was submitted.
278+func (m FormModel) Submitted() bool {
279+ return m.submitted
280+}
281+
282+// Cancelled returns true if the form was cancelled.
283+func (m FormModel) Cancelled() bool {
284+ return m.cancelled
285+}
286+
287+// RunForm runs a form and returns the values.
288+// This is a convenience function for simple forms.
289+func RunForm(title string, fields []FormField) (map[string]string, error) {
290+ m := NewForm(fields, WithFormTitle(title))
291+ p := tea.NewProgram(m)
292+ finalModel, err := p.Run()
293+ if err != nil {
294+ return nil, fmt.Errorf("error running form: %w", err)
295+ }
296+ result := finalModel.(FormModel)
297+ if result.Cancelled() {
298+ return nil, nil
299+ }
300+ if !result.Submitted() {
301+ return nil, nil
302+ }
303+ return result.Values(), nil
304+}
305+
306+// TextField creates a text field configuration.
307+func TextField(key, label string, opts ...func(*FormField)) FormField {
308+ f := FormField{
309+ Key: key,
310+ Label: label,
311+ Type: FieldTypeText,
312+ }
313+ for _, opt := range opts {
314+ opt(&f)
315+ }
316+ return f
317+}
318+
319+// PasswordField creates a password field configuration.
320+func PasswordField(key, label string, opts ...func(*FormField)) FormField {
321+ f := FormField{
322+ Key: key,
323+ Label: label,
324+ Type: FieldTypePassword,
325+ }
326+ for _, opt := range opts {
327+ opt(&f)
328+ }
329+ return f
330+}
331+
332+// Required marks a field as required.
333+func Required() func(*FormField) {
334+ return func(f *FormField) {
335+ f.Required = true
336+ }
337+}
338+
339+// WithPlaceholder sets the field placeholder.
340+func WithPlaceholder(p string) func(*FormField) {
341+ return func(f *FormField) {
342+ f.Placeholder = p
343+ }
344+}
345+
346+// WithDefault sets the field default value.
347+func WithDefault(d string) func(*FormField) {
348+ return func(f *FormField) {
349+ f.Default = d
350+ }
351+}
352+
353+// WithValidator sets the field validator.
354+func WithValidator(v func(string) error) func(*FormField) {
355+ return func(f *FormField) {
356+ f.Validator = v
357+ }
358+}
A internal/adapters/in/cli/ui/components/spinner.go +117 -0
@@ -0,0 +1,117 @@
1+// Package components provides reusable TUI components for Gordon's CLI.
2+package components
3+
4+import (
5+ "gordon/internal/adapters/in/cli/ui/styles"
6+
7+ "github.com/charmbracelet/bubbles/spinner"
8+ tea "github.com/charmbracelet/bubbletea"
9+ "github.com/charmbracelet/lipgloss"
10+)
11+
12+// SpinnerModel wraps the bubbles spinner with Gordon styling.
13+type SpinnerModel struct {
14+ spinner spinner.Model
15+ message string
16+ style lipgloss.Style
17+}
18+
19+// SpinnerOption configures a SpinnerModel.
20+type SpinnerOption func(*SpinnerModel)
21+
22+// NewSpinner creates a new spinner with Gordon styling.
23+func NewSpinner(opts ...SpinnerOption) SpinnerModel {
24+ s := spinner.New()
25+ s.Spinner = spinner.Dot
26+ s.Style = lipgloss.NewStyle().Foreground(styles.ColorPrimary)
27+
28+ m := SpinnerModel{
29+ spinner: s,
30+ message: "Loading...",
31+ style: styles.Theme.Body,
32+ }
33+
34+ for _, opt := range opts {
35+ opt(&m)
36+ }
37+
38+ return m
39+}
40+
41+// WithMessage sets the spinner message.
42+func WithMessage(msg string) SpinnerOption {
43+ return func(m *SpinnerModel) {
44+ m.message = msg
45+ }
46+}
47+
48+// WithSpinnerType sets the spinner animation type.
49+func WithSpinnerType(t spinner.Spinner) SpinnerOption {
50+ return func(m *SpinnerModel) {
51+ m.spinner.Spinner = t
52+ }
53+}
54+
55+// WithSpinnerColor sets the spinner color.
56+func WithSpinnerColor(color lipgloss.Color) SpinnerOption {
57+ return func(m *SpinnerModel) {
58+ m.spinner.Style = lipgloss.NewStyle().Foreground(color)
59+ }
60+}
61+
62+// Init implements tea.Model.
63+func (m SpinnerModel) Init() tea.Cmd {
64+ return m.spinner.Tick
65+}
66+
67+// Update implements tea.Model.
68+func (m SpinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
69+ var cmd tea.Cmd
70+ m.spinner, cmd = m.spinner.Update(msg)
71+ return m, cmd
72+}
73+
74+// View implements tea.Model.
75+func (m SpinnerModel) View() string {
76+ return m.spinner.View() + " " + m.style.Render(m.message)
77+}
78+
79+// SetMessage updates the spinner message.
80+func (m *SpinnerModel) SetMessage(msg string) {
81+ m.message = msg
82+}
83+
84+// Tick returns the spinner tick command for animation.
85+func (m SpinnerModel) Tick() tea.Cmd {
86+ return m.spinner.Tick
87+}
88+
89+// Common spinner types with Gordon colors.
90+var (
91+ // SpinnerDot is the default dot spinner.
92+ SpinnerDot = spinner.Dot
93+
94+ // SpinnerLine is a simple line spinner.
95+ SpinnerLine = spinner.Line
96+
97+ // SpinnerMiniDot is a smaller dot spinner.
98+ SpinnerMiniDot = spinner.MiniDot
99+
100+ // SpinnerJump is a jumping spinner.
101+ SpinnerJump = spinner.Jump
102+
103+ // SpinnerPulse is a pulsing spinner.
104+ SpinnerPulse = spinner.Pulse
105+
106+ // SpinnerPoints is a points spinner.
107+ SpinnerPoints = spinner.Points
108+
109+ // SpinnerGlobe is a globe spinner.
110+ SpinnerGlobe = spinner.Globe
111+
112+ // SpinnerMoon is a moon phase spinner.
113+ SpinnerMoon = spinner.Moon
114+
115+ // SpinnerMonkey is a monkey spinner.
116+ SpinnerMonkey = spinner.Monkey
117+)
A internal/adapters/in/cli/ui/components/status.go +158 -0
@@ -0,0 +1,158 @@
1+package components
2+
3+import (
4+ "strings"
5+
6+ "gordon/internal/adapters/in/cli/ui/styles"
7+
8+ "github.com/charmbracelet/lipgloss"
9+)
10+
11+// Status represents a status type for rendering.
12+type Status int
13+
14+const (
15+ StatusSuccess Status = iota
16+ StatusError
17+ StatusWarning
18+ StatusInfo
19+ StatusPending
20+ StatusRunning
21+ StatusStopped
22+)
23+
24+// StatusConfig holds configuration for status rendering.
25+type StatusConfig struct {
26+ Status Status
27+ Label string
28+ Icon string
29+ Style lipgloss.Style
30+ Badge bool // If true, render as a badge with background
31+ Compact bool // If true, render icon only
32+}
33+
34+// DefaultStatusConfigs provides default configurations for each status type.
35+var DefaultStatusConfigs = map[Status]StatusConfig{
36+ StatusSuccess: {
37+ Icon: styles.IconSuccess,
38+ Style: styles.Theme.Success,
39+ },
40+ StatusError: {
41+ Icon: styles.IconError,
42+ Style: styles.Theme.Error,
43+ },
44+ StatusWarning: {
45+ Icon: styles.IconWarning,
46+ Style: styles.Theme.Warning,
47+ },
48+ StatusInfo: {
49+ Icon: styles.IconInfo,
50+ Style: styles.Theme.Info,
51+ },
52+ StatusPending: {
53+ Icon: styles.IconPending,
54+ Style: styles.Theme.Muted,
55+ },
56+ StatusRunning: {
57+ Icon: styles.IconSuccess,
58+ Style: styles.Theme.Success,
59+ },
60+ StatusStopped: {
61+ Icon: styles.IconError,
62+ Style: styles.Theme.Error,
63+ },
64+}
65+
66+// RenderStatus renders a status with icon and optional label.
67+func RenderStatus(status Status, label string) string {
68+ config := DefaultStatusConfigs[status]
69+ if label == "" {
70+ return config.Style.Render(config.Icon)
71+ }
72+ return config.Style.Render(config.Icon + " " + label)
73+}
74+
75+// RenderStatusBadge renders a status as a badge with background.
76+func RenderStatusBadge(status Status, label string) string {
77+ var badgeStyle lipgloss.Style
78+ switch status {
79+ case StatusSuccess, StatusRunning:
80+ badgeStyle = styles.Theme.BadgeSuccess
81+ case StatusError, StatusStopped:
82+ badgeStyle = styles.Theme.BadgeError
83+ case StatusWarning:
84+ badgeStyle = styles.Theme.BadgeWarning
85+ case StatusPending:
86+ badgeStyle = styles.Theme.BadgePending
87+ default:
88+ badgeStyle = styles.Theme.BadgeInfo
89+ }
90+ return badgeStyle.Render(label)
91+}
92+
93+// ParseStatus converts a string status to Status type.
94+func ParseStatus(s string) Status {
95+ switch strings.ToLower(s) {
96+ case "success", "ok", "active", "healthy":
97+ return StatusSuccess
98+ case "error", "failed", "unhealthy":
99+ return StatusError
100+ case "warning", "degraded":
101+ return StatusWarning
102+ case "info":
103+ return StatusInfo
104+ case "pending", "starting", "waiting":
105+ return StatusPending
106+ case "running", "up":
107+ return StatusRunning
108+ case "stopped", "down", "exited":
109+ return StatusStopped
110+ default:
111+ return StatusInfo
112+ }
113+}
114+
115+// StatusIndicator is a simple status indicator component.
116+type StatusIndicator struct {
117+ Status Status
118+ Label string
119+ Badge bool
120+}
121+
122+// NewStatusIndicator creates a new status indicator.
123+func NewStatusIndicator(status Status, label string) StatusIndicator {
124+ return StatusIndicator{
125+ Status: status,
126+ Label: label,
127+ Badge: false,
128+ }
129+}
130+
131+// NewStatusBadge creates a new status badge (with background).
132+func NewStatusBadge(status Status, label string) StatusIndicator {
133+ return StatusIndicator{
134+ Status: status,
135+ Label: label,
136+ Badge: true,
137+ }
138+}
139+
140+// View renders the status indicator.
141+func (s StatusIndicator) View() string {
142+ if s.Badge {
143+ return RenderStatusBadge(s.Status, s.Label)
144+ }
145+ return RenderStatus(s.Status, s.Label)
146+}
147+
148+// ContainerStatusIndicator renders container status with appropriate styling.
149+func ContainerStatusIndicator(status string) string {
150+ parsed := ParseStatus(status)
151+ return RenderStatus(parsed, status)
152+}
153+
154+// ContainerStatusBadge renders container status as a badge.
155+func ContainerStatusBadge(status string) string {
156+ parsed := ParseStatus(status)
157+ return RenderStatusBadge(parsed, status)
158+}
A internal/adapters/in/cli/ui/components/table.go +210 -0
@@ -0,0 +1,210 @@
1+package components
2+
3+import (
4+ "gordon/internal/adapters/in/cli/ui/styles"
5+
6+ "github.com/charmbracelet/lipgloss"
7+ "github.com/charmbracelet/lipgloss/table"
8+)
9+
10+// TableColumn defines a table column.
11+type TableColumn struct {
12+ Title string
13+ Width int
14+}
15+
16+// TableModel is a styled table component.
17+type TableModel struct {
18+ columns []TableColumn
19+ rows [][]string
20+ border lipgloss.Border
21+ borderStyle lipgloss.Style
22+ headerStyle lipgloss.Style
23+ cellStyle lipgloss.Style
24+ oddStyle lipgloss.Style
25+ evenStyle lipgloss.Style
26+ width int
27+}
28+
29+// TableOption configures a TableModel.
30+type TableOption func(*TableModel)
31+
32+// NewTable creates a new styled table.
33+func NewTable(opts ...TableOption) *TableModel {
34+ t := &TableModel{
35+ border: lipgloss.RoundedBorder(),
36+ borderStyle: lipgloss.NewStyle().Foreground(styles.ColorBorder),
37+ headerStyle: lipgloss.NewStyle().
38+ Bold(true).
39+ Foreground(styles.ColorPrimary).
40+ Padding(0, 1),
41+ cellStyle: lipgloss.NewStyle().
42+ Foreground(styles.ColorText).
43+ Padding(0, 1),
44+ oddStyle: lipgloss.NewStyle().
45+ Foreground(styles.ColorText).
46+ Padding(0, 1),
47+ evenStyle: lipgloss.NewStyle().
48+ Foreground(styles.ColorTextMuted).
49+ Padding(0, 1),
50+ }
51+
52+ for _, opt := range opts {
53+ opt(t)
54+ }
55+
56+ return t
57+}
58+
59+// WithColumns sets the table columns.
60+func WithColumns(cols []TableColumn) TableOption {
61+ return func(t *TableModel) {
62+ t.columns = cols
63+ }
64+}
65+
66+// WithRows sets the table rows.
67+func WithRows(rows [][]string) TableOption {
68+ return func(t *TableModel) {
69+ t.rows = rows
70+ }
71+}
72+
73+// WithBorder sets the table border style.
74+func WithBorder(b lipgloss.Border) TableOption {
75+ return func(t *TableModel) {
76+ t.border = b
77+ }
78+}
79+
80+// WithTableWidth sets the table width.
81+func WithTableWidth(w int) TableOption {
82+ return func(t *TableModel) {
83+ t.width = w
84+ }
85+}
86+
87+// WithHeaderStyle sets the header style.
88+func WithHeaderStyle(s lipgloss.Style) TableOption {
89+ return func(t *TableModel) {
90+ t.headerStyle = s
91+ }
92+}
93+
94+// WithCellStyle sets the cell style.
95+func WithCellStyle(s lipgloss.Style) TableOption {
96+ return func(t *TableModel) {
97+ t.cellStyle = s
98+ t.oddStyle = s
99+ t.evenStyle = s
100+ }
101+}
102+
103+// WithAlternateRows enables alternating row colors.
104+func WithAlternateRows(odd, even lipgloss.Style) TableOption {
105+ return func(t *TableModel) {
106+ t.oddStyle = odd
107+ t.evenStyle = even
108+ }
109+}
110+
111+// SetColumns sets the table columns.
112+func (t *TableModel) SetColumns(cols []TableColumn) {
113+ t.columns = cols
114+}
115+
116+// SetRows sets the table rows.
117+func (t *TableModel) SetRows(rows [][]string) {
118+ t.rows = rows
119+}
120+
121+// AddRow adds a row to the table.
122+func (t *TableModel) AddRow(row []string) {
123+ t.rows = append(t.rows, row)
124+}
125+
126+// ClearRows removes all rows from the table.
127+func (t *TableModel) ClearRows() {
128+ t.rows = nil
129+}
130+
131+// Render renders the table as a string.
132+func (t *TableModel) Render() string {
133+ if len(t.columns) == 0 {
134+ return ""
135+ }
136+
137+ // Extract headers
138+ headers := make([]string, len(t.columns))
139+ for i, col := range t.columns {
140+ headers[i] = col.Title
141+ }
142+
143+ // Create table
144+ tbl := table.New().
145+ Border(t.border).
146+ BorderStyle(t.borderStyle).
147+ Headers(headers...).
148+ Rows(t.rows...).
149+ StyleFunc(func(row, col int) lipgloss.Style {
150+ if row == table.HeaderRow {
151+ return t.headerStyle
152+ }
153+ if row%2 == 0 {
154+ return t.evenStyle
155+ }
156+ return t.oddStyle
157+ })
158+
159+ // Apply width if set
160+ if t.width > 0 {
161+ tbl = tbl.Width(t.width)
162+ }
163+
164+ return tbl.String()
165+}
166+
167+// View returns the table view (alias for Render).
168+func (t *TableModel) View() string {
169+ return t.Render()
170+}
171+
172+// SimpleTable creates a simple table with headers and rows.
173+// This is a convenience function for quick table rendering.
174+func SimpleTable(headers []string, rows [][]string) string {
175+ cols := make([]TableColumn, len(headers))
176+ for i, h := range headers {
177+ cols[i] = TableColumn{Title: h}
178+ }
179+
180+ t := NewTable(
181+ WithColumns(cols),
182+ WithRows(rows),
183+ )
184+
185+ return t.Render()
186+}
187+
188+// RouteTable creates a table specifically for displaying routes.
189+func RouteTable(routes [][]string) string {
190+ return SimpleTable(
191+ []string{"Domain", "Image", "Status"},
192+ routes,
193+ )
194+}
195+
196+// TokenTable creates a table specifically for displaying tokens.
197+func TokenTable(tokens [][]string) string {
198+ return SimpleTable(
199+ []string{"ID", "Subject", "Scopes", "Expires", "Revoked"},
200+ tokens,
201+ )
202+}
203+
204+// SecretTable creates a table specifically for displaying secrets.
205+func SecretTable(secrets [][]string) string {
206+ return SimpleTable(
207+ []string{"Key", "Value"},
208+ secrets,
209+ )
210+}
A internal/adapters/in/cli/ui/styles/colors.go +88 -0
@@ -0,0 +1,88 @@
1+// Package styles provides a unified styling system for Gordon's TUI.
2+// The color palette is inspired by the Gordon webapp's terminal-themed design.
3+package styles
4+
5+import "github.com/charmbracelet/lipgloss"
6+
7+// Color definitions using the Gordon design system.
8+// These colors are adapted from the webapp CSS for terminal use.
9+var (
10+ // Primary colors - Terminal Green
11+ Green50 = lipgloss.Color("#e8fdf4")
12+ Green100 = lipgloss.Color("#c5fae4")
13+ Green200 = lipgloss.Color("#8ef5c8")
14+ Green300 = lipgloss.Color("#4dead7")
15+ Green400 = lipgloss.Color("#1adf9a")
16+ Green500 = lipgloss.Color("#00cc6a")
17+ Green600 = lipgloss.Color("#00995a")
18+ Green700 = lipgloss.Color("#007a4a")
19+ Green800 = lipgloss.Color("#005c38")
20+ Green900 = lipgloss.Color("#003d25")
21+
22+ // Secondary colors - Cyan
23+ Cyan50 = lipgloss.Color("#e6f9ff")
24+ Cyan100 = lipgloss.Color("#c2f0ff")
25+ Cyan200 = lipgloss.Color("#85e0ff")
26+ Cyan300 = lipgloss.Color("#47d1ff")
27+ Cyan400 = lipgloss.Color("#1ac5ff")
28+ Cyan500 = lipgloss.Color("#00a0cc")
29+ Cyan600 = lipgloss.Color("#0088b3")
30+ Cyan700 = lipgloss.Color("#006d8f")
31+ Cyan800 = lipgloss.Color("#00526c")
32+ Cyan900 = lipgloss.Color("#003748")
33+
34+ // Accent colors - Violet
35+ Violet50 = lipgloss.Color("#f5f3ff")
36+ Violet100 = lipgloss.Color("#ede9fe")
37+ Violet200 = lipgloss.Color("#ddd6fe")
38+ Violet300 = lipgloss.Color("#c4b5fd")
39+ Violet400 = lipgloss.Color("#a78bfa")
40+ Violet500 = lipgloss.Color("#8b5cf6")
41+ Violet600 = lipgloss.Color("#7c3aed")
42+ Violet700 = lipgloss.Color("#6d28d9")
43+ Violet800 = lipgloss.Color("#5b21b6")
44+ Violet900 = lipgloss.Color("#4c1d95")
45+
46+ // Neutral colors - for text and backgrounds
47+ Neutral50 = lipgloss.Color("#fafafa")
48+ Neutral100 = lipgloss.Color("#f5f5f5")
49+ Neutral200 = lipgloss.Color("#e5e5e5")
50+ Neutral300 = lipgloss.Color("#d4d4d4")
51+ Neutral400 = lipgloss.Color("#a3a3a3")
52+ Neutral500 = lipgloss.Color("#737373")
53+ Neutral600 = lipgloss.Color("#525252")
54+ Neutral700 = lipgloss.Color("#404040")
55+ Neutral800 = lipgloss.Color("#262626")
56+ Neutral900 = lipgloss.Color("#171717")
57+ Neutral950 = lipgloss.Color("#0a0a0a")
58+
59+ // Terminal neon colors (for dark terminal backgrounds)
60+ NeonGreen = lipgloss.Color("#00ff88")
61+ NeonCyan = lipgloss.Color("#00ccff")
62+ NeonViolet = lipgloss.Color("#a78bfa")
63+ NeonRed = lipgloss.Color("#ff4444")
64+ NeonYellow = lipgloss.Color("#fbbf24")
65+
66+ // Semantic colors
67+ ColorPrimary = NeonGreen
68+ ColorSecondary = NeonCyan
69+ ColorAccent = NeonViolet
70+ ColorSuccess = NeonGreen
71+ ColorWarning = NeonYellow
72+ ColorError = NeonRed
73+ ColorInfo = NeonCyan
74+
75+ // Text colors
76+ ColorText = Neutral200
77+ ColorTextMuted = Neutral500
78+ ColorTextBright = lipgloss.Color("#ffffff")
79+
80+ // Background colors
81+ ColorBg = lipgloss.Color("#000000")
82+ ColorBgSurface = Neutral900
83+ ColorBgMuted = Neutral800
84+
85+ // Border colors
86+ ColorBorder = Neutral700
87+ ColorBorderMuted = Neutral800
88+)
A internal/adapters/in/cli/ui/styles/icons.go +79 -0
@@ -0,0 +1,79 @@
1+package styles
2+
3+// Nerd Font icons for terminal UI.
4+// These icons require a Nerd Font compatible terminal font.
5+// Fallback ASCII alternatives are provided where practical.
6+const (
7+ // Status indicators
8+ IconSuccess = "" // nf-fa-check (U+F00C)
9+ IconError = "" // nf-fa-times (U+F00D)
10+ IconWarning = "" // nf-fa-exclamation_triangle (U+F071)
11+ IconInfo = "" // nf-fa-info_circle (U+F05A)
12+ IconPending = "" // nf-fa-clock_o (U+F017)
13+
14+ // Objects
15+ IconRoute = "" // nf-fa-sitemap (U+F0E8)
16+ IconSecret = "" // nf-fa-lock (U+F023)
17+ IconToken = "" // nf-fa-key (U+F084)
18+ IconContainer = "" // nf-oct-container (U+F489)
19+ IconServer = "" // nf-fa-server (U+F233)
20+ IconNetwork = "󰛳" // nf-md-lan (U+F06F3)
21+ IconVolume = "" // nf-fa-database (U+F1C0)
22+ IconImage = "" // nf-fa-archive (U+F187)
23+
24+ // Actions
25+ IconAdd = "" // nf-fa-plus (U+F067)
26+ IconRemove = "" // nf-fa-minus (U+F068)
27+ IconEdit = "" // nf-fa-pencil (U+F040)
28+ IconRefresh = "" // nf-fa-refresh (U+F021)
29+ IconUpload = "" // nf-fa-cloud_upload (U+F0EE)
30+ IconDelete = "" // nf-fa-trash (U+F1F8)
31+
32+ // Navigation
33+ IconArrowRight = "" // nf-fa-arrow_right (U+F061)
34+ IconArrowLeft = "" // nf-fa-arrow_left (U+F060)
35+ IconArrowUp = "" // nf-fa-arrow_up (U+F062)
36+ IconArrowDown = "" // nf-fa-arrow_down (U+F063)
37+ IconChevron = "" // nf-fa-chevron_right (U+F054)
38+
39+ // UI elements
40+ IconBullet = "▸" // Simple triangle bullet
41+ IconDot = "●" // Filled circle
42+ IconDotEmpty = "○" // Empty circle
43+ IconCheckbox = "" // nf-fa-square_o (U+F096)
44+ IconChecked = "" // nf-fa-check_square_o (U+F046)
45+ IconRadio = "" // nf-fa-circle_o (U+F10C)
46+ IconRadioCheck = "" // nf-fa-dot_circle_o (U+F192)
47+
48+ // Spinners (individual frames)
49+ SpinnerDot = "⣾⣽⣻⢿⡿⣟⣯⣷"
50+ SpinnerLine = "|/-\\"
51+ SpinnerCircle = "◐◓◑◒"
52+ SpinnerBrail = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
53+
54+ // Borders and decorations
55+ BorderHorizontal = "─"
56+ BorderVertical = "│"
57+ BorderCornerTL = "┌"
58+ BorderCornerTR = "┐"
59+ BorderCornerBL = "└"
60+ BorderCornerBR = "┘"
61+ BorderT = "┬"
62+ BorderB = "┴"
63+ BorderL = "├"
64+ BorderR = "┤"
65+ BorderCross = "┼"
66+
67+ // Gordon branding
68+ IconGordon = "" // nf-fa-cube (U+F1B2)
69+)
70+
71+// ASCII fallback alternatives for terminals without Nerd Fonts.
72+const (
73+ AsciiSuccess = "[OK]"
74+ AsciiError = "[X]"
75+ AsciiWarning = "[!]"
76+ AsciiInfo = "[i]"
77+ AsciiBullet = ">"
78+ AsciiDot = "*"
79+)
A internal/adapters/in/cli/ui/styles/theme.go +279 -0
@@ -0,0 +1,279 @@
1+package styles
2+
3+import "github.com/charmbracelet/lipgloss"
4+
5+// Theme contains all composed styles for the Gordon TUI.
6+// Styles are organized by component type for easy discovery.
7+var Theme = struct {
8+ // Text styles
9+ Title lipgloss.Style
10+ Subtitle lipgloss.Style
11+ Heading lipgloss.Style
12+ Body lipgloss.Style
13+ Muted lipgloss.Style
14+ Bold lipgloss.Style
15+ Highlight lipgloss.Style
16+
17+ // Status styles
18+ Success lipgloss.Style
19+ Error lipgloss.Style
20+ Warning lipgloss.Style
21+ Info lipgloss.Style
22+
23+ // Badge styles (compact status indicators)
24+ BadgeSuccess lipgloss.Style
25+ BadgeError lipgloss.Style
26+ BadgeWarning lipgloss.Style
27+ BadgeInfo lipgloss.Style
28+ BadgePending lipgloss.Style
29+
30+ // Table styles
31+ TableHeader lipgloss.Style
32+ TableRow lipgloss.Style
33+ TableRowSelected lipgloss.Style
34+ TableRowAlt lipgloss.Style
35+ TableCell lipgloss.Style
36+ TableBorder lipgloss.Style
37+
38+ // Form styles
39+ FormLabel lipgloss.Style
40+ FormInput lipgloss.Style
41+ FormPlaceholder lipgloss.Style
42+ FormFocused lipgloss.Style
43+ FormError lipgloss.Style
44+
45+ // List styles
46+ ListItem lipgloss.Style
47+ ListItemSelected lipgloss.Style
48+ ListBullet lipgloss.Style
49+
50+ // Box/Container styles
51+ Box lipgloss.Style
52+ BoxHighlight lipgloss.Style
53+ BoxError lipgloss.Style
54+
55+ // Banner/Header styles
56+ Banner lipgloss.Style
57+ BannerTitle lipgloss.Style
58+
59+ // Help styles
60+ HelpKey lipgloss.Style
61+ HelpDesc lipgloss.Style
62+}{
63+ // Text styles
64+ Title: lipgloss.NewStyle().
65+ Bold(true).
66+ Foreground(ColorPrimary),
67+
68+ Subtitle: lipgloss.NewStyle().
69+ Foreground(ColorTextMuted),
70+
71+ Heading: lipgloss.NewStyle().
72+ Bold(true).
73+ Foreground(ColorText),
74+
75+ Body: lipgloss.NewStyle().
76+ Foreground(ColorText),
77+
78+ Muted: lipgloss.NewStyle().
79+ Foreground(ColorTextMuted),
80+
81+ Bold: lipgloss.NewStyle().
82+ Bold(true).
83+ Foreground(ColorText),
84+
85+ Highlight: lipgloss.NewStyle().
86+ Foreground(ColorPrimary),
87+
88+ // Status styles
89+ Success: lipgloss.NewStyle().
90+ Foreground(ColorSuccess),
91+
92+ Error: lipgloss.NewStyle().
93+ Foreground(ColorError),
94+
95+ Warning: lipgloss.NewStyle().
96+ Foreground(ColorWarning),
97+
98+ Info: lipgloss.NewStyle().
99+ Foreground(ColorInfo),
100+
101+ // Badge styles
102+ BadgeSuccess: lipgloss.NewStyle().
103+ Foreground(ColorBg).
104+ Background(ColorSuccess).
105+ Padding(0, 1),
106+
107+ BadgeError: lipgloss.NewStyle().
108+ Foreground(ColorBg).
109+ Background(ColorError).
110+ Padding(0, 1),
111+
112+ BadgeWarning: lipgloss.NewStyle().
113+ Foreground(ColorBg).
114+ Background(ColorWarning).
115+ Padding(0, 1),
116+
117+ BadgeInfo: lipgloss.NewStyle().
118+ Foreground(ColorBg).
119+ Background(ColorInfo).
120+ Padding(0, 1),
121+
122+ BadgePending: lipgloss.NewStyle().
123+ Foreground(ColorBg).
124+ Background(ColorTextMuted).
125+ Padding(0, 1),
126+
127+ // Table styles
128+ TableHeader: lipgloss.NewStyle().
129+ Bold(true).
130+ Foreground(ColorPrimary).
131+ BorderBottom(true).
132+ BorderStyle(lipgloss.NormalBorder()).
133+ BorderForeground(ColorBorder),
134+
135+ TableRow: lipgloss.NewStyle().
136+ Foreground(ColorText),
137+
138+ TableRowSelected: lipgloss.NewStyle().
139+ Foreground(ColorPrimary).
140+ Background(ColorBgMuted),
141+
142+ TableRowAlt: lipgloss.NewStyle().
143+ Foreground(ColorTextMuted),
144+
145+ TableCell: lipgloss.NewStyle().
146+ Padding(0, 1),
147+
148+ TableBorder: lipgloss.NewStyle().
149+ Foreground(ColorBorder),
150+
151+ // Form styles
152+ FormLabel: lipgloss.NewStyle().
153+ Foreground(ColorText).
154+ Bold(true),
155+
156+ FormInput: lipgloss.NewStyle().
157+ Foreground(ColorText).
158+ Background(ColorBgMuted).
159+ Padding(0, 1),
160+
161+ FormPlaceholder: lipgloss.NewStyle().
162+ Foreground(ColorTextMuted),
163+
164+ FormFocused: lipgloss.NewStyle().
165+ Foreground(ColorPrimary).
166+ Background(ColorBgMuted).
167+ BorderLeft(true).
168+ BorderStyle(lipgloss.NormalBorder()).
169+ BorderForeground(ColorPrimary).
170+ Padding(0, 1),
171+
172+ FormError: lipgloss.NewStyle().
173+ Foreground(ColorError),
174+
175+ // List styles
176+ ListItem: lipgloss.NewStyle().
177+ Foreground(ColorText).
178+ PaddingLeft(2),
179+
180+ ListItemSelected: lipgloss.NewStyle().
181+ Foreground(ColorPrimary).
182+ PaddingLeft(2),
183+
184+ ListBullet: lipgloss.NewStyle().
185+ Foreground(ColorPrimary),
186+
187+ // Box/Container styles
188+ Box: lipgloss.NewStyle().
189+ Border(lipgloss.RoundedBorder()).
190+ BorderForeground(ColorBorder).
191+ Padding(1, 2),
192+
193+ BoxHighlight: lipgloss.NewStyle().
194+ Border(lipgloss.RoundedBorder()).
195+ BorderForeground(ColorPrimary).
196+ Padding(1, 2),
197+
198+ BoxError: lipgloss.NewStyle().
199+ Border(lipgloss.RoundedBorder()).
200+ BorderForeground(ColorError).
201+ Padding(1, 2),
202+
203+ // Banner/Header styles
204+ Banner: lipgloss.NewStyle().
205+ Border(lipgloss.RoundedBorder()).
206+ BorderForeground(ColorPrimary).
207+ Padding(0, 2),
208+
209+ BannerTitle: lipgloss.NewStyle().
210+ Bold(true).
211+ Foreground(ColorPrimary),
212+
213+ // Help styles
214+ HelpKey: lipgloss.NewStyle().
215+ Foreground(ColorPrimary),
216+
217+ HelpDesc: lipgloss.NewStyle().
218+ Foreground(ColorTextMuted),
219+}
220+
221+// Render helpers for common patterns.
222+
223+// RenderStatus returns a styled status indicator with icon.
224+func RenderStatus(status string, running bool) string {
225+ if running {
226+ return Theme.Success.Render(IconSuccess + " " + status)
227+ }
228+ return Theme.Error.Render(IconError + " " + status)
229+}
230+
231+// RenderBadge returns a styled badge for the given status.
232+func RenderBadge(status string) string {
233+ switch status {
234+ case "running", "success", "ok", "active":
235+ return Theme.BadgeSuccess.Render(status)
236+ case "error", "failed", "stopped":
237+ return Theme.BadgeError.Render(status)
238+ case "warning", "degraded":
239+ return Theme.BadgeWarning.Render(status)
240+ case "pending", "starting":
241+ return Theme.BadgePending.Render(status)
242+ default:
243+ return Theme.BadgeInfo.Render(status)
244+ }
245+}
246+
247+// RenderKeyHelp returns formatted key binding help text.
248+func RenderKeyHelp(key, desc string) string {
249+ return Theme.HelpKey.Render(key) + " " + Theme.HelpDesc.Render(desc)
250+}
251+
252+// RenderListItem returns a formatted list item with bullet.
253+func RenderListItem(item string, selected bool) string {
254+ bullet := Theme.ListBullet.Render(IconBullet)
255+ if selected {
256+ return bullet + " " + Theme.ListItemSelected.Render(item)
257+ }
258+ return bullet + " " + Theme.ListItem.Render(item)
259+}
260+
261+// RenderError returns a styled error message.
262+func RenderError(msg string) string {
263+ return Theme.Error.Render(IconError + " " + msg)
264+}
265+
266+// RenderSuccess returns a styled success message.
267+func RenderSuccess(msg string) string {
268+ return Theme.Success.Render(IconSuccess + " " + msg)
269+}
270+
271+// RenderWarning returns a styled warning message.
272+func RenderWarning(msg string) string {
273+ return Theme.Warning.Render(IconWarning + " " + msg)
274+}
275+
276+// RenderInfo returns a styled info message.
277+func RenderInfo(msg string) string {
278+ return Theme.Info.Render(IconInfo + " " + msg)
279+}
A internal/adapters/in/http/admin/handler.go +486 -0
@@ -0,0 +1,486 @@
1+// Package admin implements the HTTP adapter for the admin API.
2+package admin
3+
4+import (
5+ "encoding/json"
6+ "errors"
7+ "net/http"
8+ "strings"
9+
10+ "github.com/bnema/zerowrap"
11+
12+ "gordon/internal/boundaries/in"
13+ "gordon/internal/boundaries/out"
14+ "gordon/internal/domain"
15+)
16+
17+// maxAdminRequestSize is the maximum allowed size for admin API request bodies.
18+const maxAdminRequestSize = 1 << 20 // 1MB
19+
20+// Handler implements the HTTP handler for the admin API.
21+type Handler struct {
22+ configSvc in.ConfigService
23+ authSvc in.AuthService
24+ containerSvc in.ContainerService
25+ healthSvc in.HealthService
26+ secretSvc in.SecretService
27+ eventBus out.EventPublisher
28+ log zerowrap.Logger
29+}
30+
31+// NewHandler creates a new admin HTTP handler.
32+func NewHandler(
33+ configSvc in.ConfigService,
34+ authSvc in.AuthService,
35+ containerSvc in.ContainerService,
36+ healthSvc in.HealthService,
37+ secretSvc in.SecretService,
38+ eventBus out.EventPublisher,
39+ log zerowrap.Logger,
40+) *Handler {
41+ return &Handler{
42+ configSvc: configSvc,
43+ authSvc: authSvc,
44+ containerSvc: containerSvc,
45+ healthSvc: healthSvc,
46+ secretSvc: secretSvc,
47+ eventBus: eventBus,
48+ log: log,
49+ }
50+}
51+
52+// RegisterRoutes registers the admin routes on the given mux.
53+func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
54+ mux.HandleFunc("/admin/", h.handleAdminRoutes)
55+}
56+
57+// ServeHTTP implements http.Handler.
58+func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
59+ h.handleAdminRoutes(w, r)
60+}
61+
62+func (h *Handler) handleAdminRoutes(w http.ResponseWriter, r *http.Request) {
63+ ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{
64+ zerowrap.FieldLayer: "adapter",
65+ zerowrap.FieldAdapter: "http",
66+ zerowrap.FieldHandler: "admin",
67+ zerowrap.FieldMethod: r.Method,
68+ zerowrap.FieldPath: r.URL.Path,
69+ })
70+ r = r.WithContext(ctx)
71+
72+ path := strings.TrimPrefix(r.URL.Path, "/admin")
73+
74+ // Route to appropriate handler
75+ switch {
76+ case path == "/routes" || strings.HasPrefix(path, "/routes/"):
77+ h.handleRoutes(w, r, path)
78+ case path == "/secrets" || strings.HasPrefix(path, "/secrets/"):
79+ h.handleSecrets(w, r, path)
80+ case path == "/status":
81+ h.handleStatus(w, r)
82+ case path == "/health":
83+ h.handleHealth(w, r)
84+ case path == "/reload":
85+ h.handleReload(w, r)
86+ case path == "/config":
87+ h.handleConfig(w, r)
88+ default:
89+ http.NotFound(w, r)
90+ }
91+}
92+
93+// sendJSON sends a JSON response.
94+func (h *Handler) sendJSON(w http.ResponseWriter, status int, data any) {
95+ w.Header().Set("Content-Type", "application/json")
96+ w.WriteHeader(status)
97+ if data != nil {
98+ _ = json.NewEncoder(w).Encode(data)
99+ }
100+}
101+
102+// sendError sends an error response.
103+func (h *Handler) sendError(w http.ResponseWriter, status int, message string) {
104+ h.sendJSON(w, status, map[string]string{"error": message})
105+}
106+
107+// handleRoutes handles /admin/routes endpoints.
108+func (h *Handler) handleRoutes(w http.ResponseWriter, r *http.Request, path string) {
109+ // Parse domain from path if present
110+ routeDomain := strings.TrimPrefix(path, "/routes/")
111+ if routeDomain == "/routes" {
112+ routeDomain = ""
113+ }
114+
115+ switch r.Method {
116+ case http.MethodGet:
117+ h.handleRoutesGet(w, r, routeDomain)
118+ case http.MethodPost:
119+ h.handleRoutesPost(w, r)
120+ case http.MethodPut:
121+ h.handleRoutesPut(w, r, routeDomain)
122+ case http.MethodDelete:
123+ h.handleRoutesDelete(w, r, routeDomain)
124+ default:
125+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
126+ }
127+}
128+
129+func (h *Handler) handleRoutesGet(w http.ResponseWriter, r *http.Request, routeDomain string) {
130+ ctx := r.Context()
131+
132+ // Check read permission
133+ if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionRead) {
134+ h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:read")
135+ return
136+ }
137+
138+ if routeDomain == "" {
139+ routes := h.configSvc.GetRoutes(ctx)
140+ h.sendJSON(w, http.StatusOK, map[string]any{"routes": routes})
141+ return
142+ }
143+
144+ route, err := h.configSvc.GetRoute(ctx, routeDomain)
145+ if err != nil {
146+ h.sendError(w, http.StatusNotFound, "route not found")
147+ return
148+ }
149+ h.sendJSON(w, http.StatusOK, route)
150+}
151+
152+func (h *Handler) handleRoutesPost(w http.ResponseWriter, r *http.Request) {
153+ ctx := r.Context()
154+ log := zerowrap.FromCtx(ctx)
155+
156+ // Check write permission
157+ if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionWrite) {
158+ h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:write")
159+ return
160+ }
161+
162+ // Limit request body size
163+ r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
164+
165+ var route domain.Route
166+ if err := json.NewDecoder(r.Body).Decode(&route); err != nil {
167+ log.Warn().Err(err).Msg("invalid route JSON")
168+ h.sendError(w, http.StatusBadRequest, "invalid JSON")
169+ return
170+ }
171+
172+ if err := h.configSvc.AddRoute(ctx, route); err != nil {
173+ log.Error().Err(err).Str("domain", route.Domain).Msg("failed to add route")
174+ switch {
175+ case errors.Is(err, domain.ErrRouteDomainEmpty), errors.Is(err, domain.ErrRouteImageEmpty):
176+ h.sendError(w, http.StatusBadRequest, err.Error())
177+ default:
178+ h.sendError(w, http.StatusInternalServerError, "failed to add route")
179+ }
180+ return
181+ }
182+
183+ log.Info().Str("domain", route.Domain).Str("image", route.Image).Msg("route added")
184+ h.sendJSON(w, http.StatusCreated, route)
185+}
186+
187+func (h *Handler) handleRoutesPut(w http.ResponseWriter, r *http.Request, routeDomain string) {
188+ ctx := r.Context()
189+ log := zerowrap.FromCtx(ctx)
190+
191+ // Check write permission
192+ if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionWrite) {
193+ h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:write")
194+ return
195+ }
196+
197+ if routeDomain == "" {
198+ h.sendError(w, http.StatusBadRequest, "domain required in path")
199+ return
200+ }
201+
202+ // Limit request body size
203+ r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
204+
205+ var route domain.Route
206+ if err := json.NewDecoder(r.Body).Decode(&route); err != nil {
207+ log.Warn().Err(err).Msg("invalid route JSON")
208+ h.sendError(w, http.StatusBadRequest, "invalid JSON")
209+ return
210+ }
211+
212+ route.Domain = routeDomain
213+
214+ if err := h.configSvc.UpdateRoute(ctx, route); err != nil {
215+ log.Error().Err(err).Str("domain", routeDomain).Msg("failed to update route")
216+ switch {
217+ case errors.Is(err, domain.ErrRouteNotFound):
218+ h.sendError(w, http.StatusNotFound, "route not found")
219+ case errors.Is(err, domain.ErrRouteDomainEmpty), errors.Is(err, domain.ErrRouteImageEmpty):
220+ h.sendError(w, http.StatusBadRequest, err.Error())
221+ default:
222+ h.sendError(w, http.StatusInternalServerError, "failed to update route")
223+ }
224+ return
225+ }
226+
227+ log.Info().Str("domain", route.Domain).Str("image", route.Image).Msg("route updated")
228+ h.sendJSON(w, http.StatusOK, route)
229+}
230+
231+func (h *Handler) handleRoutesDelete(w http.ResponseWriter, r *http.Request, routeDomain string) {
232+ ctx := r.Context()
233+ log := zerowrap.FromCtx(ctx)
234+
235+ // Check write permission
236+ if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionWrite) {
237+ h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:write")
238+ return
239+ }
240+
241+ if routeDomain == "" {
242+ h.sendError(w, http.StatusBadRequest, "domain required in path")
243+ return
244+ }
245+
246+ if err := h.configSvc.RemoveRoute(ctx, routeDomain); err != nil {
247+ log.Error().Err(err).Str("domain", routeDomain).Msg("failed to remove route")
248+ h.sendError(w, http.StatusInternalServerError, "failed to remove route")
249+ return
250+ }
251+
252+ log.Info().Str("domain", routeDomain).Msg("route removed")
253+ h.sendJSON(w, http.StatusOK, map[string]string{"status": "removed"})
254+}
255+
256+// handleSecrets handles /admin/secrets endpoints.
257+func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path string) {
258+ ctx := r.Context()
259+ log := zerowrap.FromCtx(ctx)
260+
261+ // Parse path: /secrets/{domain} or /secrets/{domain}/{key}
262+ parts := strings.Split(strings.TrimPrefix(path, "/secrets/"), "/")
263+ if len(parts) == 0 || parts[0] == "" {
264+ h.sendError(w, http.StatusBadRequest, "domain required")
265+ return
266+ }
267+
268+ secretDomain := parts[0]
269+ secretKey := ""
270+ if len(parts) > 1 {
271+ secretKey = parts[1]
272+ }
273+
274+ switch r.Method {
275+ case http.MethodGet:
276+ // Check read permission
277+ if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionRead) {
278+ h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:read")
279+ return
280+ }
281+ // List secrets for domain (names only, not values)
282+ keys, err := h.secretSvc.ListKeys(ctx, secretDomain)
283+ if err != nil {
284+ log.Error().Err(err).Str("domain", secretDomain).Msg("failed to list secrets")
285+ h.sendError(w, http.StatusBadRequest, "invalid domain")
286+ return
287+ }
288+ h.sendJSON(w, http.StatusOK, map[string]any{"domain": secretDomain, "keys": keys})
289+
290+ case http.MethodPost:
291+ // Check write permission
292+ if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) {
293+ h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write")
294+ return
295+ }
296+
297+ // Limit request body size
298+ r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
299+
300+ // Set secret(s)
301+ var data map[string]string
302+ if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
303+ log.Warn().Err(err).Msg("invalid secrets JSON")
304+ h.sendError(w, http.StatusBadRequest, "invalid JSON")
305+ return
306+ }
307+
308+ if err := h.secretSvc.Set(ctx, secretDomain, data); err != nil {
309+ log.Error().Err(err).Str("domain", secretDomain).Msg("failed to set secrets")
310+ h.sendError(w, http.StatusBadRequest, "invalid domain")
311+ return
312+ }
313+
314+ log.Info().Str("domain", secretDomain).Int("count", len(data)).Msg("secrets set")
315+ h.sendJSON(w, http.StatusOK, map[string]string{"status": "updated"})
316+
317+ case http.MethodDelete:
318+ // Check write permission
319+ if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) {
320+ h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write")
321+ return
322+ }
323+ if secretKey == "" {
324+ h.sendError(w, http.StatusBadRequest, "key required in path")
325+ return
326+ }
327+
328+ if err := h.secretSvc.Delete(ctx, secretDomain, secretKey); err != nil {
329+ log.Error().Err(err).Str("domain", secretDomain).Str("key", secretKey).Msg("failed to delete secret")
330+ h.sendError(w, http.StatusBadRequest, "invalid domain")
331+ return
332+ }
333+
334+ log.Info().Str("domain", secretDomain).Str("key", secretKey).Msg("secret deleted")
335+ h.sendJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
336+
337+ default:
338+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
339+ }
340+}
341+
342+// handleHealth handles /admin/health endpoint.
343+// Returns detailed health status for all routes including HTTP probe results.
344+func (h *Handler) handleHealth(w http.ResponseWriter, r *http.Request) {
345+ if r.Method != http.MethodGet {
346+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
347+ return
348+ }
349+
350+ ctx := r.Context()
351+
352+ // Check read permission
353+ if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
354+ h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
355+ return
356+ }
357+
358+ health := h.healthSvc.CheckAllRoutes(ctx)
359+
360+ // Convert to JSON-friendly format
361+ result := make(map[string]any, len(health))
362+ for domain, h := range health {
363+ result[domain] = map[string]any{
364+ "container_status": h.ContainerStatus,
365+ "http_status": h.HTTPStatus,
366+ "response_time_ms": h.ResponseTimeMs,
367+ "healthy": h.Healthy,
368+ "error": h.Error,
369+ }
370+ }
371+
372+ h.sendJSON(w, http.StatusOK, map[string]any{"health": result})
373+}
374+
375+// handleStatus handles /admin/status endpoint.
376+func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
377+ if r.Method != http.MethodGet {
378+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
379+ return
380+ }
381+
382+ ctx := r.Context()
383+
384+ // Check read permission
385+ if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
386+ h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
387+ return
388+ }
389+
390+ routes := h.configSvc.GetRoutes(ctx)
391+
392+ // Get container statuses
393+ statuses := make(map[string]string)
394+ for _, route := range routes {
395+ status := "unknown"
396+ container, ok := h.containerSvc.Get(ctx, route.Domain)
397+ if ok && container != nil {
398+ status = container.Status
399+ }
400+ statuses[route.Domain] = status
401+ }
402+
403+ status := map[string]any{
404+ "routes": len(routes),
405+ "registry_domain": h.configSvc.GetRegistryDomain(),
406+ "registry_port": h.configSvc.GetRegistryPort(),
407+ "server_port": h.configSvc.GetServerPort(),
408+ "auto_route": h.configSvc.IsAutoRouteEnabled(),
409+ "network_isolation": h.configSvc.IsNetworkIsolationEnabled(),
410+ "container_status": statuses,
411+ }
412+
413+ h.sendJSON(w, http.StatusOK, status)
414+}
415+
416+// handleReload handles /admin/reload endpoint.
417+// This reloads configuration from file into memory and triggers container sync.
418+func (h *Handler) handleReload(w http.ResponseWriter, r *http.Request) {
419+ if r.Method != http.MethodPost {
420+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
421+ return
422+ }
423+
424+ ctx := r.Context()
425+ log := zerowrap.FromCtx(ctx)
426+
427+ // Check write permission (reload modifies state)
428+ if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
429+ h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
430+ return
431+ }
432+
433+ // Reload configuration from file into memory
434+ if err := h.configSvc.Load(ctx); err != nil {
435+ log.Error().Err(err).Msg("failed to reload config")
436+ h.sendError(w, http.StatusInternalServerError, "failed to reload config")
437+ return
438+ }
439+
440+ // Publish manual reload event to sync containers
441+ // ManualReloadHandler starts missing containers without restarting running ones
442+ if h.eventBus != nil {
443+ if err := h.eventBus.Publish(domain.EventManualReload, nil); err != nil {
444+ log.Warn().Err(err).Msg("failed to publish manual reload event")
445+ }
446+ }
447+
448+ log.Info().Msg("config reloaded via admin API")
449+ h.sendJSON(w, http.StatusOK, map[string]string{"status": "reloaded"})
450+}
451+
452+// handleConfig handles /admin/config endpoint.
453+func (h *Handler) handleConfig(w http.ResponseWriter, r *http.Request) {
454+ if r.Method != http.MethodGet {
455+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
456+ return
457+ }
458+
459+ ctx := r.Context()
460+
461+ // Check read permission
462+ if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionRead) {
463+ h.sendError(w, http.StatusForbidden, "insufficient permissions for config:read")
464+ return
465+ }
466+
467+ config := map[string]any{
468+ "server": map[string]any{
469+ "port": h.configSvc.GetServerPort(),
470+ "registry_port": h.configSvc.GetRegistryPort(),
471+ "registry_domain": h.configSvc.GetRegistryDomain(),
472+ "data_dir": h.configSvc.GetDataDir(),
473+ },
474+ "auto_route": map[string]any{
475+ "enabled": h.configSvc.IsAutoRouteEnabled(),
476+ },
477+ "network_isolation": map[string]any{
478+ "enabled": h.configSvc.IsNetworkIsolationEnabled(),
479+ "prefix": h.configSvc.GetNetworkPrefix(),
480+ },
481+ "routes": h.configSvc.GetRoutes(ctx),
482+ "external_routes": h.configSvc.GetExternalRoutes(),
483+ }
484+
485+ h.sendJSON(w, http.StatusOK, config)
486+}
A internal/adapters/in/http/admin/handler_test.go +792 -0
@@ -0,0 +1,792 @@
1+package admin
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "net/http"
8+ "net/http/httptest"
9+ "testing"
10+
11+ "github.com/bnema/zerowrap"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/mock"
14+
15+ inmocks "gordon/internal/boundaries/in/mocks"
16+ "gordon/internal/domain"
17+)
18+
19+func testLogger() zerowrap.Logger {
20+ return zerowrap.Default()
21+}
22+
23+func ctxWithScopes(scopes ...string) context.Context {
24+ ctx := context.Background()
25+ return context.WithValue(ctx, ContextKeyScopes, scopes)
26+}
27+
28+// Routes endpoint tests
29+
30+func TestHandler_RoutesGet_RequiresReadScope(t *testing.T) {
31+ configSvc := inmocks.NewMockConfigService(t)
32+ authSvc := inmocks.NewMockAuthService(t)
33+ containerSvc := inmocks.NewMockContainerService(t)
34+ secretSvc := inmocks.NewMockSecretService(t)
35+
36+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger())
37+
38+ tests := []struct {
39+ name string
40+ scopes []string
41+ wantStatus int
42+ }{
43+ {
44+ name: "routes read access granted",
45+ scopes: []string{"admin:routes:read"},
46+ wantStatus: http.StatusOK,
47+ },
48+ {
49+ name: "routes wildcard access granted",
50+ scopes: []string{"admin:routes:*"},
51+ wantStatus: http.StatusOK,
52+ },
53+ {
54+ name: "all admin access granted",
55+ scopes: []string{"admin:*:*"},
56+ wantStatus: http.StatusOK,
57+ },
58+ {
59+ name: "only write access denied",
60+ scopes: []string{"admin:routes:write"},
61+ wantStatus: http.StatusForbidden,
62+ },
63+ {
64+ name: "wrong resource denied",
65+ scopes: []string{"admin:secrets:read"},
66+ wantStatus: http.StatusForbidden,
67+ },
68+ {
69+ name: "no scopes denied",
70+ scopes: []string{},
71+ wantStatus: http.StatusForbidden,
72+ },
73+ }
74+
75+ for _, tt := range tests {
76+ t.Run(tt.name, func(t *testing.T) {
77+ if tt.wantStatus == http.StatusOK {
78+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}).Maybe()
79+ }
80+
81+ req := httptest.NewRequest("GET", "/admin/routes", nil)
82+ req = req.WithContext(ctxWithScopes(tt.scopes...))
83+ rec := httptest.NewRecorder()
84+
85+ handler.ServeHTTP(rec, req)
86+
87+ assert.Equal(t, tt.wantStatus, rec.Code)
88+ })
89+ }
90+}
91+
92+func TestHandler_RoutesPost_RequiresWriteScope(t *testing.T) {
93+ configSvc := inmocks.NewMockConfigService(t)
94+ authSvc := inmocks.NewMockAuthService(t)
95+ containerSvc := inmocks.NewMockContainerService(t)
96+ secretSvc := inmocks.NewMockSecretService(t)
97+
98+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger())
99+
100+ routeJSON := `{"domain": "app.example.com", "image": "myapp:latest"}`
101+
102+ tests := []struct {
103+ name string
104+ scopes []string
105+ wantStatus int
106+ }{
107+ {
108+ name: "routes write access granted",
109+ scopes: []string{"admin:routes:write"},
110+ wantStatus: http.StatusCreated,
111+ },
112+ {
113+ name: "routes wildcard access granted",
114+ scopes: []string{"admin:routes:*"},
115+ wantStatus: http.StatusCreated,
116+ },
117+ {
118+ name: "all admin access granted",
119+ scopes: []string{"admin:*:*"},
120+ wantStatus: http.StatusCreated,
121+ },
122+ {
123+ name: "only read access denied",
124+ scopes: []string{"admin:routes:read"},
125+ wantStatus: http.StatusForbidden,
126+ },
127+ {
128+ name: "wrong resource denied",
129+ scopes: []string{"admin:secrets:write"},
130+ wantStatus: http.StatusForbidden,
131+ },
132+ }
133+
134+ for _, tt := range tests {
135+ t.Run(tt.name, func(t *testing.T) {
136+ if tt.wantStatus == http.StatusCreated {
137+ configSvc.EXPECT().AddRoute(mock.Anything, mock.AnythingOfType("domain.Route")).Return(nil).Maybe()
138+ }
139+
140+ req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(routeJSON))
141+ req = req.WithContext(ctxWithScopes(tt.scopes...))
142+ rec := httptest.NewRecorder()
143+
144+ handler.ServeHTTP(rec, req)
145+
146+ assert.Equal(t, tt.wantStatus, rec.Code)
147+ })
148+ }
149+}
150+
151+func TestHandler_RoutesPut_RequiresWriteScope(t *testing.T) {
152+ configSvc := inmocks.NewMockConfigService(t)
153+ authSvc := inmocks.NewMockAuthService(t)
154+ containerSvc := inmocks.NewMockContainerService(t)
155+ secretSvc := inmocks.NewMockSecretService(t)
156+
157+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger())
158+
159+ routeJSON := `{"image": "myapp:v2"}`
160+
161+ tests := []struct {
162+ name string
163+ scopes []string
164+ wantStatus int
165+ }{
166+ {
167+ name: "routes write access granted",
168+ scopes: []string{"admin:routes:write"},
169+ wantStatus: http.StatusOK,
170+ },
171+ {
172+ name: "only read access denied",
173+ scopes: []string{"admin:routes:read"},
174+ wantStatus: http.StatusForbidden,
175+ },
176+ }
177+
178+ for _, tt := range tests {
179+ t.Run(tt.name, func(t *testing.T) {
180+ if tt.wantStatus == http.StatusOK {
181+ configSvc.EXPECT().UpdateRoute(mock.Anything, mock.AnythingOfType("domain.Route")).Return(nil).Maybe()
182+ }
183+
184+ req := httptest.NewRequest("PUT", "/admin/routes/app.example.com", bytes.NewBufferString(routeJSON))
185+ req = req.WithContext(ctxWithScopes(tt.scopes...))
186+ rec := httptest.NewRecorder()
187+
188+ handler.ServeHTTP(rec, req)
189+
190+ assert.Equal(t, tt.wantStatus, rec.Code)
191+ })
192+ }
193+}
194+
195+func TestHandler_RoutesDelete_RequiresWriteScope(t *testing.T) {
196+ configSvc := inmocks.NewMockConfigService(t)
197+ authSvc := inmocks.NewMockAuthService(t)
198+ containerSvc := inmocks.NewMockContainerService(t)
199+ secretSvc := inmocks.NewMockSecretService(t)
200+
201+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger())
202+
203+ tests := []struct {
204+ name string
205+ scopes []string
206+ wantStatus int
207+ }{
208+ {
209+ name: "routes write access granted",
210+ scopes: []string{"admin:routes:write"},
211+ wantStatus: http.StatusOK,
212+ },
213+ {
214+ name: "only read access denied",
215+ scopes: []string{"admin:routes:read"},
216+ wantStatus: http.StatusForbidden,
217+ },
218+ }
219+
220+ for _, tt := range tests {
221+ t.Run(tt.name, func(t *testing.T) {
222+ if tt.wantStatus == http.StatusOK {
223+ configSvc.EXPECT().RemoveRoute(mock.Anything, "app.example.com").Return(nil).Maybe()
224+ }
225+
226+ req := httptest.NewRequest("DELETE", "/admin/routes/app.example.com", nil)
227+ req = req.WithContext(ctxWithScopes(tt.scopes...))
228+ rec := httptest.NewRecorder()
229+
230+ handler.ServeHTTP(rec, req)
231+
232+ assert.Equal(t, tt.wantStatus, rec.Code)
233+ })
234+ }
235+}
236+
237+// Secrets endpoint tests
238+
239+func TestHandler_SecretsGet_RequiresReadScope(t *testing.T) {
240+ configSvc := inmocks.NewMockConfigService(t)
241+ authSvc := inmocks.NewMockAuthService(t)
242+ containerSvc := inmocks.NewMockContainerService(t)
243+ secretSvc := inmocks.NewMockSecretService(t)
244+
245+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger())
246+
247+ tests := []struct {
248+ name string
249+ scopes []string
250+ wantStatus int
251+ }{
252+ {
253+ name: "secrets read access granted",
254+ scopes: []string{"admin:secrets:read"},
255+ wantStatus: http.StatusOK,
256+ },
257+ {
258+ name: "secrets wildcard access granted",
259+ scopes: []string{"admin:secrets:*"},
260+ wantStatus: http.StatusOK,
261+ },
262+ {
263+ name: "all admin access granted",
264+ scopes: []string{"admin:*:*"},
265+ wantStatus: http.StatusOK,
266+ },
267+ {
268+ name: "only write access denied",
269+ scopes: []string{"admin:secrets:write"},
270+ wantStatus: http.StatusForbidden,
271+ },
272+ {
273+ name: "wrong resource denied",
274+ scopes: []string{"admin:routes:read"},
275+ wantStatus: http.StatusForbidden,
276+ },
277+ }
278+
279+ for _, tt := range tests {
280+ t.Run(tt.name, func(t *testing.T) {
281+ if tt.wantStatus == http.StatusOK {
282+ secretSvc.EXPECT().ListKeys(mock.Anything, "app.example.com").Return([]string{}, nil).Maybe()
283+ }
284+
285+ req := httptest.NewRequest("GET", "/admin/secrets/app.example.com", nil)
286+ req = req.WithContext(ctxWithScopes(tt.scopes...))
287+ rec := httptest.NewRecorder()
288+
289+ handler.ServeHTTP(rec, req)
290+
291+ assert.Equal(t, tt.wantStatus, rec.Code)
292+ })
293+ }
294+}
295+
296+func TestHandler_SecretsPost_RequiresWriteScope(t *testing.T) {
297+ configSvc := inmocks.NewMockConfigService(t)
298+ authSvc := inmocks.NewMockAuthService(t)
299+ containerSvc := inmocks.NewMockContainerService(t)
300+ secretSvc := inmocks.NewMockSecretService(t)
301+
302+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger())
303+
304+ secretsJSON := `{"API_KEY": "secret123"}`
305+
306+ tests := []struct {
307+ name string
308+ scopes []string
309+ wantStatus int
310+ }{
311+ {
312+ name: "secrets write access granted",
313+ scopes: []string{"admin:secrets:write"},
314+ wantStatus: http.StatusOK,
315+ },
316+ {
317+ name: "only read access denied",
318+ scopes: []string{"admin:secrets:read"},
319+ wantStatus: http.StatusForbidden,
320+ },
321+ {
322+ name: "wrong resource denied",
323+ scopes: []string{"admin:routes:write"},
324+ wantStatus: http.StatusForbidden,
325+ },
326+ }
327+
328+ for _, tt := range tests {
329+ t.Run(tt.name, func(t *testing.T) {
330+ if tt.wantStatus == http.StatusOK {
331+ secretSvc.EXPECT().Set(mock.Anything, "app.example.com", mock.AnythingOfType("map[string]string")).Return(nil).Maybe()
332+ }
333+
334+ req := httptest.NewRequest("POST", "/admin/secrets/app.example.com", bytes.NewBufferString(secretsJSON))
335+ req = req.WithContext(ctxWithScopes(tt.scopes...))
336+ rec := httptest.NewRecorder()
337+
338+ handler.ServeHTTP(rec, req)
339+
340+ assert.Equal(t, tt.wantStatus, rec.Code)
341+ })
342+ }
343+}
344+
345+func TestHandler_SecretsDelete_RequiresWriteScope(t *testing.T) {
346+ configSvc := inmocks.NewMockConfigService(t)
347+ authSvc := inmocks.NewMockAuthService(t)
348+ containerSvc := inmocks.NewMockContainerService(t)
349+ secretSvc := inmocks.NewMockSecretService(t)
350+
351+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger())
352+
353+ tests := []struct {
354+ name string
355+ scopes []string
356+ wantStatus int
357+ }{
358+ {
359+ name: "secrets write access granted",
360+ scopes: []string{"admin:secrets:write"},
361+ wantStatus: http.StatusOK,
362+ },
363+ {
364+ name: "only read access denied",
365+ scopes: []string{"admin:secrets:read"},
366+ wantStatus: http.StatusForbidden,
367+ },
368+ }
369+
370+ for _, tt := range tests {
371+ t.Run(tt.name, func(t *testing.T) {
372+ if tt.wantStatus == http.StatusOK {
373+ secretSvc.EXPECT().Delete(mock.Anything, "app.example.com", "API_KEY").Return(nil).Maybe()
374+ }
375+
376+ req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/API_KEY", nil)
377+ req = req.WithContext(ctxWithScopes(tt.scopes...))
378+ rec := httptest.NewRecorder()
379+
380+ handler.ServeHTTP(rec, req)
381+
382+ assert.Equal(t, tt.wantStatus, rec.Code)
383+ })
384+ }
385+}
386+
387+// Status endpoint tests
388+
389+func TestHandler_Status_RequiresReadScope(t *testing.T) {
390+ configSvc := inmocks.NewMockConfigService(t)
391+ authSvc := inmocks.NewMockAuthService(t)
392+ containerSvc := inmocks.NewMockContainerService(t)
393+ secretSvc := inmocks.NewMockSecretService(t)
394+
395+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger())
396+
397+ tests := []struct {
398+ name string
399+ scopes []string
400+ wantStatus int
401+ }{
402+ {
403+ name: "status read access granted",
404+ scopes: []string{"admin:status:read"},
405+ wantStatus: http.StatusOK,
406+ },
407+ {
408+ name: "all admin access granted",
409+ scopes: []string{"admin:*:*"},
410+ wantStatus: http.StatusOK,
411+ },
412+ {
413+ name: "wrong resource denied",
414+ scopes: []string{"admin:routes:read"},
415+ wantStatus: http.StatusForbidden,
416+ },
417+ }
418+
419+ for _, tt := range tests {
420+ t.Run(tt.name, func(t *testing.T) {
421+ if tt.wantStatus == http.StatusOK {
422+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}).Maybe()
423+ configSvc.EXPECT().GetRegistryDomain().Return("registry.example.com").Maybe()
424+ configSvc.EXPECT().GetRegistryPort().Return(5000).Maybe()
425+ configSvc.EXPECT().GetServerPort().Return(8080).Maybe()
426+ configSvc.EXPECT().IsAutoRouteEnabled().Return(true).Maybe()
427+ configSvc.EXPECT().IsNetworkIsolationEnabled().Return(false).Maybe()
428+ }
429+
430+ req := httptest.NewRequest("GET", "/admin/status", nil)
431+ req = req.WithContext(ctxWithScopes(tt.scopes...))
432+ rec := httptest.NewRecorder()
433+
434+ handler.ServeHTTP(rec, req)
435+
436+ assert.Equal(t, tt.wantStatus, rec.Code)
437+ })
438+ }
439+}
440+
441+// Config endpoint tests
442+
443+func TestHandler_Config_RequiresReadScope(t *testing.T) {
444+ configSvc := inmocks.NewMockConfigService(t)
445+ authSvc := inmocks.NewMockAuthService(t)
446+ containerSvc := inmocks.NewMockContainerService(t)
447+ secretSvc := inmocks.NewMockSecretService(t)
448+
449+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger())
450+
451+ tests := []struct {
452+ name string
453+ scopes []string
454+ wantStatus int
455+ }{
456+ {
457+ name: "config read access granted",
458+ scopes: []string{"admin:config:read"},
459+ wantStatus: http.StatusOK,
460+ },
461+ {
462+ name: "all admin access granted",
463+ scopes: []string{"admin:*:*"},
464+ wantStatus: http.StatusOK,
465+ },
466+ {
467+ name: "wrong resource denied",
468+ scopes: []string{"admin:routes:read"},
469+ wantStatus: http.StatusForbidden,
470+ },
471+ }
472+
473+ for _, tt := range tests {
474+ t.Run(tt.name, func(t *testing.T) {
475+ if tt.wantStatus == http.StatusOK {
476+ configSvc.EXPECT().GetServerPort().Return(8080).Maybe()
477+ configSvc.EXPECT().GetRegistryPort().Return(5000).Maybe()
478+ configSvc.EXPECT().GetRegistryDomain().Return("registry.example.com").Maybe()
479+ configSvc.EXPECT().GetDataDir().Return("/var/lib/gordon").Maybe()
480+ configSvc.EXPECT().IsAutoRouteEnabled().Return(true).Maybe()
481+ configSvc.EXPECT().IsNetworkIsolationEnabled().Return(false).Maybe()
482+ configSvc.EXPECT().GetNetworkPrefix().Return("gordon").Maybe()
483+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}).Maybe()
484+ configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{}).Maybe()
485+ }
486+
487+ req := httptest.NewRequest("GET", "/admin/config", nil)
488+ req = req.WithContext(ctxWithScopes(tt.scopes...))
489+ rec := httptest.NewRecorder()
490+
491+ handler.ServeHTTP(rec, req)
492+
493+ assert.Equal(t, tt.wantStatus, rec.Code)
494+ })
495+ }
496+}
497+
498+// Reload endpoint tests
499+
500+func TestHandler_Reload_RequiresWriteScope(t *testing.T) {
501+ configSvc := inmocks.NewMockConfigService(t)
502+ authSvc := inmocks.NewMockAuthService(t)
503+ containerSvc := inmocks.NewMockContainerService(t)
504+ secretSvc := inmocks.NewMockSecretService(t)
505+
506+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger())
507+
508+ tests := []struct {
509+ name string
510+ scopes []string
511+ wantStatus int
512+ }{
513+ {
514+ name: "config write access granted",
515+ scopes: []string{"admin:config:write"},
516+ wantStatus: http.StatusOK,
517+ },
518+ {
519+ name: "all admin access granted",
520+ scopes: []string{"admin:*:*"},
521+ wantStatus: http.StatusOK,
522+ },
523+ {
524+ name: "only read access denied",
525+ scopes: []string{"admin:config:read"},
526+ wantStatus: http.StatusForbidden,
527+ },
528+ {
529+ name: "wrong resource denied",
530+ scopes: []string{"admin:routes:write"},
531+ wantStatus: http.StatusForbidden,
532+ },
533+ }
534+
535+ for _, tt := range tests {
536+ t.Run(tt.name, func(t *testing.T) {
537+ if tt.wantStatus == http.StatusOK {
538+ configSvc.EXPECT().Load(mock.Anything).Return(nil).Maybe()
539+ }
540+
541+ req := httptest.NewRequest("POST", "/admin/reload", nil)
542+ req = req.WithContext(ctxWithScopes(tt.scopes...))
543+ rec := httptest.NewRecorder()
544+
545+ handler.ServeHTTP(rec, req)
546+
547+ assert.Equal(t, tt.wantStatus, rec.Code)
548+ })
549+ }
550+}
551+
552+// Functional tests
553+
554+func TestHandler_RoutesGet_ReturnsRoutes(t *testing.T) {
555+ configSvc := inmocks.NewMockConfigService(t)
556+ authSvc := inmocks.NewMockAuthService(t)
557+ containerSvc := inmocks.NewMockContainerService(t)
558+ secretSvc := inmocks.NewMockSecretService(t)
559+
560+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger())
561+
562+ expectedRoutes := []domain.Route{
563+ {Domain: "app1.example.com", Image: "app1:latest"},
564+ {Domain: "app2.example.com", Image: "app2:v1.0"},
565+ }
566+ configSvc.EXPECT().GetRoutes(mock.Anything).Return(expectedRoutes)
567+
568+ req := httptest.NewRequest("GET", "/admin/routes", nil)
569+ req = req.WithContext(ctxWithScopes("admin:routes:read"))
570+ rec := httptest.NewRecorder()
571+
572+ handler.ServeHTTP(rec, req)
573+
574+ assert.Equal(t, http.StatusOK, rec.Code)
575+
576+ var response map[string]any
577+ err := json.NewDecoder(rec.Body).Decode(&response)
578+ assert.NoError(t, err)
579+
580+ routes, ok := response["routes"].([]any)
581+ assert.True(t, ok)
582+ assert.Len(t, routes, 2)
583+}
584+
585+func TestHandler_RoutesGet_SingleRoute(t *testing.T) {
586+ configSvc := inmocks.NewMockConfigService(t)
587+ authSvc := inmocks.NewMockAuthService(t)
588+ containerSvc := inmocks.NewMockContainerService(t)
589+ secretSvc := inmocks.NewMockSecretService(t)
590+
591+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger())
592+
593+ configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(&domain.Route{
594+ Domain: "app.example.com", Image: "app:latest",
595+ }, nil)
596+
597+ req := httptest.NewRequest("GET", "/admin/routes/app.example.com", nil)
598+ req = req.WithContext(ctxWithScopes("admin:routes:read"))
599+ rec := httptest.NewRecorder()
600+
601+ handler.ServeHTTP(rec, req)
602+
603+ assert.Equal(t, http.StatusOK, rec.Code)
604+
605+ var route domain.Route
606+ err := json.NewDecoder(rec.Body).Decode(&route)
607+ assert.NoError(t, err)
608+ assert.Equal(t, "app.example.com", route.Domain)
609+ assert.Equal(t, "app:latest", route.Image)
610+}
611+
612+func TestHandler_RoutesGet_NotFound(t *testing.T) {
613+ configSvc := inmocks.NewMockConfigService(t)
614+ authSvc := inmocks.NewMockAuthService(t)
615+ containerSvc := inmocks.NewMockContainerService(t)
616+ secretSvc := inmocks.NewMockSecretService(t)
617+
618+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger())
619+
620+ configSvc.EXPECT().GetRoute(mock.Anything, "nonexistent.example.com").Return(nil, domain.ErrRouteNotFound)
621+
622+ req := httptest.NewRequest("GET", "/admin/routes/nonexistent.example.com", nil)
623+ req = req.WithContext(ctxWithScopes("admin:routes:read"))
624+ rec := httptest.NewRecorder()
625+
626+ handler.ServeHTTP(rec, req)
627+
628+ assert.Equal(t, http.StatusNotFound, rec.Code)
629+}
630+
631+func TestHandler_RoutesPost_MissingFields(t *testing.T) {
632+ tests := []struct {
633+ name string
634+ body string
635+ mockErr error
636+ }{
637+ {
638+ name: "missing domain",
639+ body: `{"image": "app:latest"}`,
640+ mockErr: domain.ErrRouteDomainEmpty,
641+ },
642+ {
643+ name: "missing image",
644+ body: `{"domain": "app.example.com"}`,
645+ mockErr: domain.ErrRouteImageEmpty,
646+ },
647+ {
648+ name: "empty object",
649+ body: `{}`,
650+ mockErr: domain.ErrRouteDomainEmpty,
651+ },
652+ }
653+
654+ for _, tt := range tests {
655+ t.Run(tt.name, func(t *testing.T) {
656+ configSvc := inmocks.NewMockConfigService(t)
657+ authSvc := inmocks.NewMockAuthService(t)
658+ containerSvc := inmocks.NewMockContainerService(t)
659+ secretSvc := inmocks.NewMockSecretService(t)
660+
661+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger())
662+
663+ configSvc.EXPECT().AddRoute(mock.Anything, mock.Anything).Return(tt.mockErr)
664+
665+ req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(tt.body))
666+ req = req.WithContext(ctxWithScopes("admin:routes:write"))
667+ rec := httptest.NewRecorder()
668+
669+ handler.ServeHTTP(rec, req)
670+
671+ assert.Equal(t, http.StatusBadRequest, rec.Code)
672+ })
673+ }
674+}
675+
676+func TestHandler_RoutesPut_MissingDomain(t *testing.T) {
677+ configSvc := inmocks.NewMockConfigService(t)
678+ authSvc := inmocks.NewMockAuthService(t)
679+ containerSvc := inmocks.NewMockContainerService(t)
680+ secretSvc := inmocks.NewMockSecretService(t)
681+
682+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger())
683+
684+ req := httptest.NewRequest("PUT", "/admin/routes/", bytes.NewBufferString(`{"image": "app:latest"}`))
685+ req = req.WithContext(ctxWithScopes("admin:routes:write"))
686+ rec := httptest.NewRecorder()
687+
688+ handler.ServeHTTP(rec, req)
689+
690+ assert.Equal(t, http.StatusBadRequest, rec.Code)
691+}
692+
693+func TestHandler_RoutesDelete_MissingDomain(t *testing.T) {
694+ configSvc := inmocks.NewMockConfigService(t)
695+ authSvc := inmocks.NewMockAuthService(t)
696+ containerSvc := inmocks.NewMockContainerService(t)
697+ secretSvc := inmocks.NewMockSecretService(t)
698+
699+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger())
700+
701+ req := httptest.NewRequest("DELETE", "/admin/routes/", nil)
702+ req = req.WithContext(ctxWithScopes("admin:routes:write"))
703+ rec := httptest.NewRecorder()
704+
705+ handler.ServeHTTP(rec, req)
706+
707+ assert.Equal(t, http.StatusBadRequest, rec.Code)
708+}
709+
710+func TestHandler_Secrets_MissingDomain(t *testing.T) {
711+ configSvc := inmocks.NewMockConfigService(t)
712+ authSvc := inmocks.NewMockAuthService(t)
713+ containerSvc := inmocks.NewMockContainerService(t)
714+ secretSvc := inmocks.NewMockSecretService(t)
715+
716+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger())
717+
718+ req := httptest.NewRequest("GET", "/admin/secrets/", nil)
719+ req = req.WithContext(ctxWithScopes("admin:secrets:read"))
720+ rec := httptest.NewRecorder()
721+
722+ handler.ServeHTTP(rec, req)
723+
724+ assert.Equal(t, http.StatusBadRequest, rec.Code)
725+}
726+
727+func TestHandler_SecretsDelete_MissingKey(t *testing.T) {
728+ configSvc := inmocks.NewMockConfigService(t)
729+ authSvc := inmocks.NewMockAuthService(t)
730+ containerSvc := inmocks.NewMockContainerService(t)
731+ secretSvc := inmocks.NewMockSecretService(t)
732+
733+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger())
734+
735+ req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com", nil)
736+ req = req.WithContext(ctxWithScopes("admin:secrets:write"))
737+ rec := httptest.NewRecorder()
738+
739+ handler.ServeHTTP(rec, req)
740+
741+ assert.Equal(t, http.StatusBadRequest, rec.Code)
742+}
743+
744+func TestHandler_MethodNotAllowed(t *testing.T) {
745+ configSvc := inmocks.NewMockConfigService(t)
746+ authSvc := inmocks.NewMockAuthService(t)
747+ containerSvc := inmocks.NewMockContainerService(t)
748+ secretSvc := inmocks.NewMockSecretService(t)
749+
750+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger())
751+
752+ tests := []struct {
753+ method string
754+ path string
755+ }{
756+ {"DELETE", "/admin/status"},
757+ {"PUT", "/admin/status"},
758+ {"GET", "/admin/reload"},
759+ {"DELETE", "/admin/reload"},
760+ {"POST", "/admin/config"},
761+ {"DELETE", "/admin/config"},
762+ }
763+
764+ for _, tt := range tests {
765+ t.Run(tt.method+" "+tt.path, func(t *testing.T) {
766+ req := httptest.NewRequest(tt.method, tt.path, nil)
767+ req = req.WithContext(ctxWithScopes("admin:*:*"))
768+ rec := httptest.NewRecorder()
769+
770+ handler.ServeHTTP(rec, req)
771+
772+ assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
773+ })
774+ }
775+}
776+
777+func TestHandler_NotFound(t *testing.T) {
778+ configSvc := inmocks.NewMockConfigService(t)
779+ authSvc := inmocks.NewMockAuthService(t)
780+ containerSvc := inmocks.NewMockContainerService(t)
781+ secretSvc := inmocks.NewMockSecretService(t)
782+
783+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger())
784+
785+ req := httptest.NewRequest("GET", "/admin/unknown", nil)
786+ req = req.WithContext(ctxWithScopes("admin:*:*"))
787+ rec := httptest.NewRecorder()
788+
789+ handler.ServeHTTP(rec, req)
790+
791+ assert.Equal(t, http.StatusNotFound, rec.Code)
792+}
A internal/adapters/in/http/admin/middleware.go +152 -0
@@ -0,0 +1,152 @@
1+package admin
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "net/http"
7+ "strings"
8+
9+ "github.com/bnema/zerowrap"
10+ "golang.org/x/time/rate"
11+
12+ "gordon/internal/boundaries/in"
13+ "gordon/internal/domain"
14+)
15+
16+// contextKey is a custom type for context keys to avoid collisions.
17+type contextKey string
18+
19+const (
20+ // ContextKeyScopes is the context key for storing token scopes.
21+ ContextKeyScopes contextKey = "admin_scopes"
22+ // ContextKeySubject is the context key for storing token subject.
23+ ContextKeySubject contextKey = "admin_subject"
24+)
25+
26+// AuthMiddleware creates middleware that validates admin API authentication.
27+// The limiter parameter is optional and can be nil to disable rate limiting.
28+func AuthMiddleware(authSvc in.AuthService, limiter *rate.Limiter, log zerowrap.Logger) func(http.Handler) http.Handler {
29+ return func(next http.Handler) http.Handler {
30+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
31+ ctx := r.Context()
32+
33+ // Rate limit check (before any auth processing)
34+ if limiter != nil && !limiter.Allow() {
35+ w.Header().Set("Content-Type", "application/json")
36+ w.Header().Set("Retry-After", "1")
37+ w.WriteHeader(http.StatusTooManyRequests)
38+ _ = json.NewEncoder(w).Encode(map[string]string{"error": "rate limit exceeded"})
39+ return
40+ }
41+
42+ // Check if auth is enabled
43+ if !authSvc.IsEnabled() {
44+ // Auth disabled, log warning and allow all requests
45+ log.Warn().
46+ Str("path", r.URL.Path).
47+ Str("method", r.Method).
48+ Str("remote_addr", r.RemoteAddr).
49+ Msg("auth disabled - allowing unauthenticated admin API access")
50+ next.ServeHTTP(w, r)
51+ return
52+ }
53+
54+ // Extract token from Authorization header
55+ auth := r.Header.Get("Authorization")
56+ if auth == "" {
57+ sendUnauthorized(w, "missing authorization header")
58+ return
59+ }
60+
61+ // Support both "Bearer <token>" and direct token
62+ token := auth
63+ if strings.HasPrefix(auth, "Bearer ") {
64+ token = strings.TrimPrefix(auth, "Bearer ")
65+ }
66+
67+ // Validate token
68+ claims, err := authSvc.ValidateToken(ctx, token)
69+ if err != nil {
70+ log.Warn().Err(err).Msg("invalid admin token")
71+ sendUnauthorized(w, "invalid token")
72+ return
73+ }
74+
75+ // Check if token has admin scopes
76+ hasAdminScope := false
77+ for _, scope := range claims.Scopes {
78+ if strings.HasPrefix(scope, domain.ScopeTypeAdmin+":") {
79+ hasAdminScope = true
80+ break
81+ }
82+ }
83+
84+ if !hasAdminScope {
85+ log.Warn().Str("subject", claims.Subject).Msg("token missing admin scopes")
86+ sendForbidden(w, "admin scope required")
87+ return
88+ }
89+
90+ // Add claims to context for downstream handlers
91+ ctx = context.WithValue(ctx, ContextKeyScopes, claims.Scopes)
92+ ctx = context.WithValue(ctx, ContextKeySubject, claims.Subject)
93+
94+ next.ServeHTTP(w, r.WithContext(ctx))
95+ })
96+ }
97+}
98+
99+// RequireScope creates middleware that checks for a specific admin scope.
100+func RequireScope(resource, action string) func(http.Handler) http.Handler {
101+ return func(next http.Handler) http.Handler {
102+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
103+ ctx := r.Context()
104+
105+ // Get scopes from context
106+ scopes, ok := ctx.Value(ContextKeyScopes).([]string)
107+ if !ok {
108+ sendForbidden(w, "no scopes in context")
109+ return
110+ }
111+
112+ // Check if user has required scope
113+ if !domain.HasAdminAccess(scopes, resource, action) {
114+ sendForbidden(w, "insufficient permissions")
115+ return
116+ }
117+
118+ next.ServeHTTP(w, r)
119+ })
120+ }
121+}
122+
123+// GetSubject retrieves the authenticated subject from the context.
124+func GetSubject(ctx context.Context) string {
125+ subject, _ := ctx.Value(ContextKeySubject).(string)
126+ return subject
127+}
128+
129+// GetScopes retrieves the token scopes from the context.
130+func GetScopes(ctx context.Context) []string {
131+ scopes, _ := ctx.Value(ContextKeyScopes).([]string)
132+ return scopes
133+}
134+
135+// HasAccess checks if the context has access to the given resource and action.
136+func HasAccess(ctx context.Context, resource, action string) bool {
137+ scopes := GetScopes(ctx)
138+ return domain.HasAdminAccess(scopes, resource, action)
139+}
140+
141+func sendUnauthorized(w http.ResponseWriter, message string) {
142+ w.Header().Set("Content-Type", "application/json")
143+ w.Header().Set("WWW-Authenticate", `Bearer realm="gordon-admin"`)
144+ w.WriteHeader(http.StatusUnauthorized)
145+ _ = json.NewEncoder(w).Encode(map[string]string{"error": message})
146+}
147+
148+func sendForbidden(w http.ResponseWriter, message string) {
149+ w.Header().Set("Content-Type", "application/json")
150+ w.WriteHeader(http.StatusForbidden)
151+ _ = json.NewEncoder(w).Encode(map[string]string{"error": message})
152+}
M internal/adapters/in/http/middleware/logging.go +5 -0
@@ -47,6 +47,7 @@ func (rw *ResponseWriter) BytesWritten() int {
47 }47 }
48 48
49 // RequestLogger is a middleware that logs HTTP requests using zerowrap.49 // RequestLogger is a middleware that logs HTTP requests using zerowrap.
50+// It also attaches the logger to the request context for downstream handlers.
50 func RequestLogger(log zerowrap.Logger) func(http.Handler) http.Handler {51 func RequestLogger(log zerowrap.Logger) func(http.Handler) http.Handler {
51 return func(next http.Handler) http.Handler {52 return func(next http.Handler) http.Handler {
52 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {53 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -58,6 +59,10 @@ func RequestLogger(log zerowrap.Logger) func(http.Handler) http.Handler {
58 // Get client IP (handle X-Forwarded-For and X-Real-IP headers)59 // Get client IP (handle X-Forwarded-For and X-Real-IP headers)
59 clientIP := getClientIP(r)60 clientIP := getClientIP(r)
60 61
62+ // Attach the logger to the request context for downstream handlers
63+ ctx := zerowrap.WithCtx(r.Context(), log)
64+ r = r.WithContext(ctx)
65+
61 // Call the next handler66 // Call the next handler
62 next.ServeHTTP(rw, r)67 next.ServeHTTP(rw, r)
63 68
M internal/adapters/in/http/registry/handler.go +4 -13
@@ -2,7 +2,6 @@
2 package registry2 package registry
3 3
4 import (4 import (
5- "crypto/sha256"
6 "encoding/json"5 "encoding/json"
7 "errors"6 "errors"
8 "fmt"7 "fmt"
@@ -14,7 +13,6 @@ import (
14 "github.com/bnema/zerowrap"13 "github.com/bnema/zerowrap"
15 14
16 "gordon/internal/boundaries/in"15 "gordon/internal/boundaries/in"
17- "gordon/internal/boundaries/out"
18 "gordon/internal/domain"16 "gordon/internal/domain"
19 "gordon/pkg/manifest"17 "gordon/pkg/manifest"
20 "gordon/pkg/validation"18 "gordon/pkg/validation"
@@ -30,22 +28,16 @@ const (
30 // Handler implements the HTTP handler for Docker Registry API v2.28 // Handler implements the HTTP handler for Docker Registry API v2.
31 type Handler struct {29 type Handler struct {
32 registrySvc in.RegistryService30 registrySvc in.RegistryService
33- blobStorage out.BlobStorage
34- eventBus out.EventPublisher
35 log zerowrap.Logger31 log zerowrap.Logger
36 }32 }
37 33
38 // NewHandler creates a new registry HTTP handler.34 // NewHandler creates a new registry HTTP handler.
39 func NewHandler(35 func NewHandler(
40 registrySvc in.RegistryService,36 registrySvc in.RegistryService,
41- blobStorage out.BlobStorage,
42- eventBus out.EventPublisher,
43 log zerowrap.Logger,37 log zerowrap.Logger,
44 ) *Handler {38 ) *Handler {
45 return &Handler{39 return &Handler{
46 registrySvc: registrySvc,40 registrySvc: registrySvc,
47- blobStorage: blobStorage,
48- eventBus: eventBus,
49 log: log,41 log: log,
50 }42 }
51 }43 }
@@ -331,14 +323,13 @@ func (h *Handler) handlePutManifest(w http.ResponseWriter, r *http.Request) {
331 Annotations: annotations,323 Annotations: annotations,
332 }324 }
333 325
334- if err := h.registrySvc.PutManifest(ctx, manifestObj); err != nil {326+ digest, err := h.registrySvc.PutManifest(ctx, manifestObj)
327+ if err != nil {
335 log.Error().Err(err).Str("name", name).Str("reference", reference).Msg("failed to store manifest")328 log.Error().Err(err).Str("name", name).Str("reference", reference).Msg("failed to store manifest")
336 http.Error(w, "Internal Server Error", http.StatusInternalServerError)329 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
337 return330 return
338 }331 }
339 332
340- // Calculate digest for Docker-Content-Digest header
341- digest := fmt.Sprintf("sha256:%x", sha256.Sum256(data))
342 w.Header().Set("Docker-Content-Digest", digest)333 w.Header().Set("Docker-Content-Digest", digest)
343 w.Header().Set("Location", fmt.Sprintf("/v2/%s/manifests/%s", name, reference))334 w.Header().Set("Location", fmt.Sprintf("/v2/%s/manifests/%s", name, reference))
344 w.WriteHeader(http.StatusCreated)335 w.WriteHeader(http.StatusCreated)
@@ -352,7 +343,7 @@ func (h *Handler) handleGetBlob(w http.ResponseWriter, r *http.Request) {
352 343
353 log.Debug().Str("name", name).Str("digest", digest).Msg("GET blob")344 log.Debug().Str("name", name).Str("digest", digest).Msg("GET blob")
354 345
355- path, err := h.blobStorage.GetBlobPath(digest)346+ path, err := h.registrySvc.GetBlobPath(ctx, digest)
356 if err != nil {347 if err != nil {
357 log.Warn().Err(err).Str("name", name).Str("digest", digest).Msg("blob not found")348 log.Warn().Err(err).Str("name", name).Str("digest", digest).Msg("blob not found")
358 http.NotFound(w, r)349 http.NotFound(w, r)
@@ -422,7 +413,7 @@ func (h *Handler) handleBlobUpload(w http.ResponseWriter, r *http.Request) {
422 }413 }
423 414
424 // Append the chunk to the upload415 // Append the chunk to the upload
425- length, err := h.blobStorage.AppendBlobChunk(name, uuid, chunk)416+ length, err := h.registrySvc.AppendBlobChunk(ctx, name, uuid, chunk)
426 if err != nil {417 if err != nil {
427 log.Error().Err(err).Msg("failed to append blob chunk")418 log.Error().Err(err).Msg("failed to append blob chunk")
428 http.Error(w, "Internal Server Error", http.StatusInternalServerError)419 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
M internal/adapters/in/http/registry/handler_test.go +29 -74
@@ -12,7 +12,6 @@ import (
12 "github.com/stretchr/testify/mock"12 "github.com/stretchr/testify/mock"
13 13
14 inmocks "gordon/internal/boundaries/in/mocks"14 inmocks "gordon/internal/boundaries/in/mocks"
15- "gordon/internal/boundaries/out/mocks"
16 "gordon/internal/domain"15 "gordon/internal/domain"
17 )16 )
18 17
@@ -22,10 +21,8 @@ func testLogger() zerowrap.Logger {
22 21
23 func TestHandler_Base(t *testing.T) {22 func TestHandler_Base(t *testing.T) {
24 registrySvc := inmocks.NewMockRegistryService(t)23 registrySvc := inmocks.NewMockRegistryService(t)
25- blobStorage := mocks.NewMockBlobStorage(t)
26- eventBus := mocks.NewMockEventPublisher(t)
27 24
28- handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())25+ handler := NewHandler(registrySvc, testLogger())
29 26
30 req := httptest.NewRequest("GET", "/v2/", nil)27 req := httptest.NewRequest("GET", "/v2/", nil)
31 rec := httptest.NewRecorder()28 rec := httptest.NewRecorder()
@@ -38,10 +35,8 @@ func TestHandler_Base(t *testing.T) {
38 35
39 func TestHandler_NotFound(t *testing.T) {36 func TestHandler_NotFound(t *testing.T) {
40 registrySvc := inmocks.NewMockRegistryService(t)37 registrySvc := inmocks.NewMockRegistryService(t)
41- blobStorage := mocks.NewMockBlobStorage(t)
42- eventBus := mocks.NewMockEventPublisher(t)
43 38
44- handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())39+ handler := NewHandler(registrySvc, testLogger())
45 40
46 req := httptest.NewRequest("GET", "/v2/unknown/path", nil)41 req := httptest.NewRequest("GET", "/v2/unknown/path", nil)
47 rec := httptest.NewRecorder()42 rec := httptest.NewRecorder()
@@ -53,10 +48,8 @@ func TestHandler_NotFound(t *testing.T) {
53 48
54 func TestHandler_GetManifest_Success(t *testing.T) {49 func TestHandler_GetManifest_Success(t *testing.T) {
55 registrySvc := inmocks.NewMockRegistryService(t)50 registrySvc := inmocks.NewMockRegistryService(t)
56- blobStorage := mocks.NewMockBlobStorage(t)
57- eventBus := mocks.NewMockEventPublisher(t)
58 51
59- handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())52+ handler := NewHandler(registrySvc, testLogger())
60 53
61 manifestData := []byte(`{"schemaVersion": 2}`)54 manifestData := []byte(`{"schemaVersion": 2}`)
62 registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "latest").Return(&domain.Manifest{55 registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "latest").Return(&domain.Manifest{
@@ -78,10 +71,8 @@ func TestHandler_GetManifest_Success(t *testing.T) {
78 71
79 func TestHandler_GetManifest_HEAD(t *testing.T) {72 func TestHandler_GetManifest_HEAD(t *testing.T) {
80 registrySvc := inmocks.NewMockRegistryService(t)73 registrySvc := inmocks.NewMockRegistryService(t)
81- blobStorage := mocks.NewMockBlobStorage(t)
82- eventBus := mocks.NewMockEventPublisher(t)
83 74
84- handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())75+ handler := NewHandler(registrySvc, testLogger())
85 76
86 manifestData := []byte(`{"schemaVersion": 2}`)77 manifestData := []byte(`{"schemaVersion": 2}`)
87 registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "latest").Return(&domain.Manifest{78 registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "latest").Return(&domain.Manifest{
@@ -104,10 +95,8 @@ func TestHandler_GetManifest_HEAD(t *testing.T) {
104 95
105 func TestHandler_GetManifest_NotFound(t *testing.T) {96 func TestHandler_GetManifest_NotFound(t *testing.T) {
106 registrySvc := inmocks.NewMockRegistryService(t)97 registrySvc := inmocks.NewMockRegistryService(t)
107- blobStorage := mocks.NewMockBlobStorage(t)
108- eventBus := mocks.NewMockEventPublisher(t)
109 98
110- handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())99+ handler := NewHandler(registrySvc, testLogger())
111 100
112 registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "notexists").Return(nil, assert.AnError)101 registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "notexists").Return(nil, assert.AnError)
113 102
@@ -121,10 +110,8 @@ func TestHandler_GetManifest_NotFound(t *testing.T) {
121 110
122 func TestHandler_GetManifest_NestedName(t *testing.T) {111 func TestHandler_GetManifest_NestedName(t *testing.T) {
123 registrySvc := inmocks.NewMockRegistryService(t)112 registrySvc := inmocks.NewMockRegistryService(t)
124- blobStorage := mocks.NewMockBlobStorage(t)
125- eventBus := mocks.NewMockEventPublisher(t)
126 113
127- handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())114+ handler := NewHandler(registrySvc, testLogger())
128 115
129 manifestData := []byte(`{"schemaVersion": 2}`)116 manifestData := []byte(`{"schemaVersion": 2}`)
130 registrySvc.EXPECT().GetManifest(mock.Anything, "org/project/app", "v1.0").Return(&domain.Manifest{117 registrySvc.EXPECT().GetManifest(mock.Anything, "org/project/app", "v1.0").Return(&domain.Manifest{
@@ -144,15 +131,13 @@ func TestHandler_GetManifest_NestedName(t *testing.T) {
144 131
145 func TestHandler_PutManifest_Success(t *testing.T) {132 func TestHandler_PutManifest_Success(t *testing.T) {
146 registrySvc := inmocks.NewMockRegistryService(t)133 registrySvc := inmocks.NewMockRegistryService(t)
147- blobStorage := mocks.NewMockBlobStorage(t)
148- eventBus := mocks.NewMockEventPublisher(t)
149 134
150- handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())135+ handler := NewHandler(registrySvc, testLogger())
151 136
152 manifestData := []byte(`{"schemaVersion": 2}`)137 manifestData := []byte(`{"schemaVersion": 2}`)
153 registrySvc.EXPECT().PutManifest(mock.Anything, mock.MatchedBy(func(m *domain.Manifest) bool {138 registrySvc.EXPECT().PutManifest(mock.Anything, mock.MatchedBy(func(m *domain.Manifest) bool {
154 return m.Name == "myapp" && m.Reference == "latest" && m.ContentType == "application/vnd.docker.distribution.manifest.v2+json"139 return m.Name == "myapp" && m.Reference == "latest" && m.ContentType == "application/vnd.docker.distribution.manifest.v2+json"
155- })).Return(nil)140+ })).Return("sha256:abc123", nil)
156 141
157 req := httptest.NewRequest("PUT", "/v2/myapp/manifests/latest", bytes.NewReader(manifestData))142 req := httptest.NewRequest("PUT", "/v2/myapp/manifests/latest", bytes.NewReader(manifestData))
158 req.Header.Set("Content-Type", "application/vnd.docker.distribution.manifest.v2+json")143 req.Header.Set("Content-Type", "application/vnd.docker.distribution.manifest.v2+json")
@@ -161,16 +146,14 @@ func TestHandler_PutManifest_Success(t *testing.T) {
161 handler.ServeHTTP(rec, req)146 handler.ServeHTTP(rec, req)
162 147
163 assert.Equal(t, http.StatusCreated, rec.Code)148 assert.Equal(t, http.StatusCreated, rec.Code)
164- assert.NotEmpty(t, rec.Header().Get("Docker-Content-Digest"))149+ assert.Equal(t, "sha256:abc123", rec.Header().Get("Docker-Content-Digest"))
165 assert.Contains(t, rec.Header().Get("Location"), "/v2/myapp/manifests/latest")150 assert.Contains(t, rec.Header().Get("Location"), "/v2/myapp/manifests/latest")
166 }151 }
167 152
168 func TestHandler_PutManifest_MissingContentType(t *testing.T) {153 func TestHandler_PutManifest_MissingContentType(t *testing.T) {
169 registrySvc := inmocks.NewMockRegistryService(t)154 registrySvc := inmocks.NewMockRegistryService(t)
170- blobStorage := mocks.NewMockBlobStorage(t)
171- eventBus := mocks.NewMockEventPublisher(t)
172 155
173- handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())156+ handler := NewHandler(registrySvc, testLogger())
174 157
175 manifestData := []byte(`{"schemaVersion": 2}`)158 manifestData := []byte(`{"schemaVersion": 2}`)
176 159
@@ -185,13 +168,11 @@ func TestHandler_PutManifest_MissingContentType(t *testing.T) {
185 168
186 func TestHandler_PutManifest_StorageError(t *testing.T) {169 func TestHandler_PutManifest_StorageError(t *testing.T) {
187 registrySvc := inmocks.NewMockRegistryService(t)170 registrySvc := inmocks.NewMockRegistryService(t)
188- blobStorage := mocks.NewMockBlobStorage(t)
189- eventBus := mocks.NewMockEventPublisher(t)
190 171
191- handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())172+ handler := NewHandler(registrySvc, testLogger())
192 173
193 manifestData := []byte(`{"schemaVersion": 2}`)174 manifestData := []byte(`{"schemaVersion": 2}`)
194- registrySvc.EXPECT().PutManifest(mock.Anything, mock.Anything).Return(assert.AnError)175+ registrySvc.EXPECT().PutManifest(mock.Anything, mock.Anything).Return("", assert.AnError)
195 176
196 req := httptest.NewRequest("PUT", "/v2/myapp/manifests/latest", bytes.NewReader(manifestData))177 req := httptest.NewRequest("PUT", "/v2/myapp/manifests/latest", bytes.NewReader(manifestData))
197 req.Header.Set("Content-Type", "application/vnd.docker.distribution.manifest.v2+json")178 req.Header.Set("Content-Type", "application/vnd.docker.distribution.manifest.v2+json")
@@ -204,10 +185,8 @@ func TestHandler_PutManifest_StorageError(t *testing.T) {
204 185
205 func TestHandler_ManifestRoutes_MethodNotAllowed(t *testing.T) {186 func TestHandler_ManifestRoutes_MethodNotAllowed(t *testing.T) {
206 registrySvc := inmocks.NewMockRegistryService(t)187 registrySvc := inmocks.NewMockRegistryService(t)
207- blobStorage := mocks.NewMockBlobStorage(t)
208- eventBus := mocks.NewMockEventPublisher(t)
209 188
210- handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())189+ handler := NewHandler(registrySvc, testLogger())
211 190
212 req := httptest.NewRequest("DELETE", "/v2/myapp/manifests/latest", nil)191 req := httptest.NewRequest("DELETE", "/v2/myapp/manifests/latest", nil)
213 rec := httptest.NewRecorder()192 rec := httptest.NewRecorder()
@@ -219,10 +198,8 @@ func TestHandler_ManifestRoutes_MethodNotAllowed(t *testing.T) {
219 198
220 func TestHandler_GetBlob_Success(t *testing.T) {199 func TestHandler_GetBlob_Success(t *testing.T) {
221 registrySvc := inmocks.NewMockRegistryService(t)200 registrySvc := inmocks.NewMockRegistryService(t)
222- blobStorage := mocks.NewMockBlobStorage(t)
223- eventBus := mocks.NewMockEventPublisher(t)
224 201
225- handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())202+ handler := NewHandler(registrySvc, testLogger())
226 203
227 // Create a temp file for http.ServeFile204 // Create a temp file for http.ServeFile
228 tmpFile, err := os.CreateTemp("", "blob-*")205 tmpFile, err := os.CreateTemp("", "blob-*")
@@ -233,7 +210,7 @@ func TestHandler_GetBlob_Success(t *testing.T) {
233 _, err = tmpFile.Write(blobContent)210 _, err = tmpFile.Write(blobContent)
234 assert.NoError(t, err)211 assert.NoError(t, err)
235 212
236- blobStorage.EXPECT().GetBlobPath("sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(tmpFile.Name(), nil)213+ registrySvc.EXPECT().GetBlobPath(mock.Anything, "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(tmpFile.Name(), nil)
237 214
238 req := httptest.NewRequest("GET", "/v2/myapp/blobs/sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", nil)215 req := httptest.NewRequest("GET", "/v2/myapp/blobs/sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", nil)
239 rec := httptest.NewRecorder()216 rec := httptest.NewRecorder()
@@ -246,12 +223,10 @@ func TestHandler_GetBlob_Success(t *testing.T) {
246 223
247 func TestHandler_GetBlob_NotFound(t *testing.T) {224 func TestHandler_GetBlob_NotFound(t *testing.T) {
248 registrySvc := inmocks.NewMockRegistryService(t)225 registrySvc := inmocks.NewMockRegistryService(t)
249- blobStorage := mocks.NewMockBlobStorage(t)
250- eventBus := mocks.NewMockEventPublisher(t)
251 226
252- handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())227+ handler := NewHandler(registrySvc, testLogger())
253 228
254- blobStorage.EXPECT().GetBlobPath("sha256:0000000000000000000000000000000000000000000000000000000000000000").Return("", assert.AnError)229+ registrySvc.EXPECT().GetBlobPath(mock.Anything, "sha256:0000000000000000000000000000000000000000000000000000000000000000").Return("", assert.AnError)
255 230
256 req := httptest.NewRequest("GET", "/v2/myapp/blobs/sha256:0000000000000000000000000000000000000000000000000000000000000000", nil)231 req := httptest.NewRequest("GET", "/v2/myapp/blobs/sha256:0000000000000000000000000000000000000000000000000000000000000000", nil)
257 rec := httptest.NewRecorder()232 rec := httptest.NewRecorder()
@@ -263,10 +238,8 @@ func TestHandler_GetBlob_NotFound(t *testing.T) {
263 238
264 func TestHandler_BlobRoutes_MethodNotAllowed(t *testing.T) {239 func TestHandler_BlobRoutes_MethodNotAllowed(t *testing.T) {
265 registrySvc := inmocks.NewMockRegistryService(t)240 registrySvc := inmocks.NewMockRegistryService(t)
266- blobStorage := mocks.NewMockBlobStorage(t)
267- eventBus := mocks.NewMockEventPublisher(t)
268 241
269- handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())242+ handler := NewHandler(registrySvc, testLogger())
270 243
271 req := httptest.NewRequest("POST", "/v2/myapp/blobs/sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", nil)244 req := httptest.NewRequest("POST", "/v2/myapp/blobs/sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", nil)
272 rec := httptest.NewRecorder()245 rec := httptest.NewRecorder()
@@ -278,10 +251,8 @@ func TestHandler_BlobRoutes_MethodNotAllowed(t *testing.T) {
278 251
279 func TestHandler_StartBlobUpload_Success(t *testing.T) {252 func TestHandler_StartBlobUpload_Success(t *testing.T) {
280 registrySvc := inmocks.NewMockRegistryService(t)253 registrySvc := inmocks.NewMockRegistryService(t)
281- blobStorage := mocks.NewMockBlobStorage(t)
282- eventBus := mocks.NewMockEventPublisher(t)
283 254
284- handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())255+ handler := NewHandler(registrySvc, testLogger())
285 256
286 registrySvc.EXPECT().StartUpload(mock.Anything, "myapp").Return("1234567890-myapp", nil)257 registrySvc.EXPECT().StartUpload(mock.Anything, "myapp").Return("1234567890-myapp", nil)
287 258
@@ -297,10 +268,8 @@ func TestHandler_StartBlobUpload_Success(t *testing.T) {
297 268
298 func TestHandler_StartBlobUpload_Error(t *testing.T) {269 func TestHandler_StartBlobUpload_Error(t *testing.T) {
299 registrySvc := inmocks.NewMockRegistryService(t)270 registrySvc := inmocks.NewMockRegistryService(t)
300- blobStorage := mocks.NewMockBlobStorage(t)
301- eventBus := mocks.NewMockEventPublisher(t)
302 271
303- handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())272+ handler := NewHandler(registrySvc, testLogger())
304 273
305 registrySvc.EXPECT().StartUpload(mock.Anything, "myapp").Return("", assert.AnError)274 registrySvc.EXPECT().StartUpload(mock.Anything, "myapp").Return("", assert.AnError)
306 275
@@ -314,13 +283,11 @@ func TestHandler_StartBlobUpload_Error(t *testing.T) {
314 283
315 func TestHandler_BlobUpload_PATCH(t *testing.T) {284 func TestHandler_BlobUpload_PATCH(t *testing.T) {
316 registrySvc := inmocks.NewMockRegistryService(t)285 registrySvc := inmocks.NewMockRegistryService(t)
317- blobStorage := mocks.NewMockBlobStorage(t)
318- eventBus := mocks.NewMockEventPublisher(t)
319 286
320- handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())287+ handler := NewHandler(registrySvc, testLogger())
321 288
322 chunkData := []byte("chunk content")289 chunkData := []byte("chunk content")
323- blobStorage.EXPECT().AppendBlobChunk("myapp", "1234567890-myapp", chunkData).Return(int64(len(chunkData)), nil)290+ registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "1234567890-myapp", chunkData).Return(int64(len(chunkData)), nil)
324 291
325 req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/1234567890-myapp", bytes.NewReader(chunkData))292 req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/1234567890-myapp", bytes.NewReader(chunkData))
326 rec := httptest.NewRecorder()293 rec := httptest.NewRecorder()
@@ -333,13 +300,11 @@ func TestHandler_BlobUpload_PATCH(t *testing.T) {
333 300
334 func TestHandler_BlobUpload_PUT_Finalize(t *testing.T) {301 func TestHandler_BlobUpload_PUT_Finalize(t *testing.T) {
335 registrySvc := inmocks.NewMockRegistryService(t)302 registrySvc := inmocks.NewMockRegistryService(t)
336- blobStorage := mocks.NewMockBlobStorage(t)
337- eventBus := mocks.NewMockEventPublisher(t)
338 303
339- handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())304+ handler := NewHandler(registrySvc, testLogger())
340 305
341 chunkData := []byte("final chunk")306 chunkData := []byte("final chunk")
342- blobStorage.EXPECT().AppendBlobChunk("myapp", "1234567890-myapp", chunkData).Return(int64(len(chunkData)), nil)307+ registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "1234567890-myapp", chunkData).Return(int64(len(chunkData)), nil)
343 registrySvc.EXPECT().FinishUpload(mock.Anything, "1234567890-myapp", "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(nil)308 registrySvc.EXPECT().FinishUpload(mock.Anything, "1234567890-myapp", "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(nil)
344 309
345 req := httptest.NewRequest("PUT", "/v2/myapp/blobs/uploads/1234567890-myapp?digest=sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", bytes.NewReader(chunkData))310 req := httptest.NewRequest("PUT", "/v2/myapp/blobs/uploads/1234567890-myapp?digest=sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", bytes.NewReader(chunkData))
@@ -354,10 +319,8 @@ func TestHandler_BlobUpload_PUT_Finalize(t *testing.T) {
354 319
355 func TestHandler_BlobUpload_PUT_DigestMismatch(t *testing.T) {320 func TestHandler_BlobUpload_PUT_DigestMismatch(t *testing.T) {
356 registrySvc := inmocks.NewMockRegistryService(t)321 registrySvc := inmocks.NewMockRegistryService(t)
357- blobStorage := mocks.NewMockBlobStorage(t)
358- eventBus := mocks.NewMockEventPublisher(t)
359 322
360- handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())323+ handler := NewHandler(registrySvc, testLogger())
361 324
362 chunkData := []byte("final chunk")325 chunkData := []byte("final chunk")
363 // Note: Invalid digest format is rejected by validation before reaching storage layer326 // Note: Invalid digest format is rejected by validation before reaching storage layer
@@ -373,10 +336,8 @@ func TestHandler_BlobUpload_PUT_DigestMismatch(t *testing.T) {
373 336
374 func TestHandler_ListTags_Success(t *testing.T) {337 func TestHandler_ListTags_Success(t *testing.T) {
375 registrySvc := inmocks.NewMockRegistryService(t)338 registrySvc := inmocks.NewMockRegistryService(t)
376- blobStorage := mocks.NewMockBlobStorage(t)
377- eventBus := mocks.NewMockEventPublisher(t)
378 339
379- handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())340+ handler := NewHandler(registrySvc, testLogger())
380 341
381 registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return([]string{"latest", "v1.0", "v2.0"}, nil)342 registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return([]string{"latest", "v1.0", "v2.0"}, nil)
382 343
@@ -393,10 +354,8 @@ func TestHandler_ListTags_Success(t *testing.T) {
393 354
394 func TestHandler_ListTags_NotFound(t *testing.T) {355 func TestHandler_ListTags_NotFound(t *testing.T) {
395 registrySvc := inmocks.NewMockRegistryService(t)356 registrySvc := inmocks.NewMockRegistryService(t)
396- blobStorage := mocks.NewMockBlobStorage(t)
397- eventBus := mocks.NewMockEventPublisher(t)
398 357
399- handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())358+ handler := NewHandler(registrySvc, testLogger())
400 359
401 registrySvc.EXPECT().ListTags(mock.Anything, "notexists").Return(nil, assert.AnError)360 registrySvc.EXPECT().ListTags(mock.Anything, "notexists").Return(nil, assert.AnError)
402 361
@@ -410,10 +369,8 @@ func TestHandler_ListTags_NotFound(t *testing.T) {
410 369
411 func TestHandler_ListTags_MethodNotAllowed(t *testing.T) {370 func TestHandler_ListTags_MethodNotAllowed(t *testing.T) {
412 registrySvc := inmocks.NewMockRegistryService(t)371 registrySvc := inmocks.NewMockRegistryService(t)
413- blobStorage := mocks.NewMockBlobStorage(t)
414- eventBus := mocks.NewMockEventPublisher(t)
415 372
416- handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())373+ handler := NewHandler(registrySvc, testLogger())
417 374
418 req := httptest.NewRequest("POST", "/v2/myapp/tags/list", nil)375 req := httptest.NewRequest("POST", "/v2/myapp/tags/list", nil)
419 rec := httptest.NewRecorder()376 rec := httptest.NewRecorder()
@@ -425,10 +382,8 @@ func TestHandler_ListTags_MethodNotAllowed(t *testing.T) {
425 382
426 func TestHandler_RegisterRoutes(t *testing.T) {383 func TestHandler_RegisterRoutes(t *testing.T) {
427 registrySvc := inmocks.NewMockRegistryService(t)384 registrySvc := inmocks.NewMockRegistryService(t)
428- blobStorage := mocks.NewMockBlobStorage(t)
429- eventBus := mocks.NewMockEventPublisher(t)
430 385
431- handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())386+ handler := NewHandler(registrySvc, testLogger())
432 387
433 mux := http.NewServeMux()388 mux := http.NewServeMux()
434 handler.RegisterRoutes(mux)389 handler.RegisterRoutes(mux)
M internal/adapters/out/docker/runtime.go +142 -1
@@ -2,11 +2,14 @@
2 package docker2 package docker
3 3
4 import (4 import (
5+ "archive/tar"
5 "context"6 "context"
6 "encoding/base64"7 "encoding/base64"
7 "encoding/json"8 "encoding/json"
8 "fmt"9 "fmt"
9 "io"10 "io"
11+ "path/filepath"
12+ "sort"
10 "strconv"13 "strconv"
11 "strings"14 "strings"
12 15
@@ -264,7 +267,7 @@ func (r *Runtime) ListContainers(ctx context.Context, all bool) ([]*domain.Conta
264 ID: c.ID,267 ID: c.ID,
265 Image: c.Image,268 Image: c.Image,
266 Name: name,269 Name: name,
267- Status: c.Status,270+ Status: c.State, // Use State (e.g., "running") not Status (e.g., "Up 2 days")
268 Ports: ports,271 Ports: ports,
269 Labels: c.Labels,272 Labels: c.Labels,
270 })273 })
@@ -619,10 +622,42 @@ func (r *Runtime) GetImageExposedPorts(ctx context.Context, imageRef string) ([]
619 return nil, fmt.Errorf("no EXPOSE directives found in image %s - please add EXPOSE directive to Dockerfile", imageRef)622 return nil, fmt.Errorf("no EXPOSE directives found in image %s - please add EXPOSE directive to Dockerfile", imageRef)
620 }623 }
621 624
625+ // Sort ports with HTTP-friendly ports first (avoid SSH port 22, etc.)
626+ // Priority: 80, 8080, 3000, 8000, 5000, then others sorted ascending
627+ sortPortsHTTPFirst(ports)
628+
622 log.Info().Ints("exposed_ports", ports).Msg("found exposed ports in image")629 log.Info().Ints("exposed_ports", ports).Msg("found exposed ports in image")
623 return ports, nil630 return ports, nil
624 }631 }
625 632
633+// sortPortsHTTPFirst sorts ports with common HTTP ports first, avoiding SSH (22).
634+func sortPortsHTTPFirst(ports []int) {
635+ priority := map[int]int{
636+ 80: 0, 443: 1, 8080: 2, 3000: 3, 8000: 4, 5000: 5, 9000: 6,
637+ }
638+ sort.Slice(ports, func(i, j int) bool {
639+ pi, oki := priority[ports[i]]
640+ pj, okj := priority[ports[j]]
641+ if oki && okj {
642+ return pi < pj
643+ }
644+ if oki {
645+ return true
646+ }
647+ if okj {
648+ return false
649+ }
650+ // Neither is a priority port - sort ascending but push 22 to the end
651+ if ports[i] == 22 {
652+ return false
653+ }
654+ if ports[j] == 22 {
655+ return true
656+ }
657+ return ports[i] < ports[j]
658+ })
659+}
660+
626 // GetContainerExposedPorts gets all exposed ports from a running container.661 // GetContainerExposedPorts gets all exposed ports from a running container.
627 func (r *Runtime) GetContainerExposedPorts(ctx context.Context, containerID string) ([]int, error) {662 func (r *Runtime) GetContainerExposedPorts(ctx context.Context, containerID string) ([]int, error) {
628 ctx = zerowrap.CtxWithFields(ctx, map[string]any{663 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
@@ -1018,3 +1053,109 @@ func (r *Runtime) DisconnectContainerFromNetwork(ctx context.Context, containerN
1018 log.Info().Msg("container disconnected from network")1053 log.Info().Msg("container disconnected from network")
1019 return nil1054 return nil
1020 }1055 }
1056+
1057+// CopyFromContainer copies a file from a container to the host.
1058+// Returns the file contents as a byte slice.
1059+func (r *Runtime) CopyFromContainer(ctx context.Context, containerID, srcPath string) ([]byte, error) {
1060+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1061+ zerowrap.FieldLayer: "adapter",
1062+ zerowrap.FieldAdapter: "docker",
1063+ zerowrap.FieldAction: "CopyFromContainer",
1064+ zerowrap.FieldEntityID: containerID,
1065+ "src_path": srcPath,
1066+ })
1067+ log := zerowrap.FromCtx(ctx)
1068+
1069+ reader, _, err := r.client.CopyFromContainer(ctx, containerID, srcPath)
1070+ if err != nil {
1071+ return nil, log.WrapErr(err, "failed to copy from container")
1072+ }
1073+ defer reader.Close()
1074+
1075+ // The response is a tar archive - we need to extract the file
1076+ data, err := extractFileFromTar(reader, srcPath)
1077+ if err != nil {
1078+ return nil, log.WrapErr(err, "failed to extract file from tar")
1079+ }
1080+
1081+ log.Debug().Int("size", len(data)).Msg("file copied from container")
1082+ return data, nil
1083+}
1084+
1085+// extractFileFromTar extracts a single file from a tar archive.
1086+func extractFileFromTar(reader io.Reader, targetPath string) ([]byte, error) {
1087+ tr := tar.NewReader(reader)
1088+
1089+ // The path in the tar may be relative or absolute
1090+ // We need to match based on the filename
1091+ targetName := filepath.Base(targetPath)
1092+
1093+ for {
1094+ header, err := tr.Next()
1095+ if err == io.EOF {
1096+ break
1097+ }
1098+ if err != nil {
1099+ return nil, err
1100+ }
1101+
1102+ // Check if this is our target file
1103+ if header.Typeflag == tar.TypeReg {
1104+ headerName := filepath.Base(header.Name)
1105+ if headerName == targetName {
1106+ data, err := io.ReadAll(tr)
1107+ if err != nil {
1108+ return nil, err
1109+ }
1110+ return data, nil
1111+ }
1112+ }
1113+ }
1114+
1115+ return nil, fmt.Errorf("file not found in container: %s", targetPath)
1116+}
1117+
1118+// ExtractEnvFileFromImage extracts an env file from an image.
1119+// This creates a temporary container, copies the file, and removes the container.
1120+func (r *Runtime) ExtractEnvFileFromImage(ctx context.Context, imageRef, envFilePath string) ([]byte, error) {
1121+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1122+ zerowrap.FieldLayer: "adapter",
1123+ zerowrap.FieldAdapter: "docker",
1124+ zerowrap.FieldAction: "ExtractEnvFileFromImage",
1125+ "image": imageRef,
1126+ "env_file": envFilePath,
1127+ })
1128+ log := zerowrap.FromCtx(ctx)
1129+
1130+ log.Info().Msg("extracting env file from image")
1131+
1132+ // Create a temporary container without starting it
1133+ containerConfig := &container.Config{
1134+ Image: imageRef,
1135+ Cmd: []string{"true"}, // Dummy command
1136+ }
1137+
1138+ resp, err := r.client.ContainerCreate(ctx, containerConfig, nil, nil, nil, "")
1139+ if err != nil {
1140+ return nil, log.WrapErr(err, "failed to create temporary container")
1141+ }
1142+
1143+ tempContainerID := resp.ID
1144+ log.Debug().Str("temp_container_id", tempContainerID).Msg("created temporary container")
1145+
1146+ // Ensure cleanup
1147+ defer func() {
1148+ if err := r.client.ContainerRemove(ctx, tempContainerID, container.RemoveOptions{Force: true}); err != nil {
1149+ log.Warn().Err(err).Str("temp_container_id", tempContainerID).Msg("failed to remove temporary container")
1150+ }
1151+ }()
1152+
1153+ // Copy the env file from the container
1154+ data, err := r.CopyFromContainer(ctx, tempContainerID, envFilePath)
1155+ if err != nil {
1156+ return nil, err
1157+ }
1158+
1159+ log.Info().Int("size", len(data)).Msg("env file extracted from image")
1160+ return data, nil
1161+}
A internal/adapters/out/domainsecrets/store.go +209 -0
@@ -0,0 +1,209 @@
1+// Package domainsecrets implements the DomainSecretStore adapter using filesystem-based env files.
2+package domainsecrets
3+
4+import (
5+ "bufio"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+
11+ "github.com/bnema/zerowrap"
12+)
13+
14+// FileStore implements the DomainSecretStore interface using filesystem-based env files.
15+// This adapter is responsible only for file I/O operations; domain validation
16+// should be performed by the use case layer before calling these methods.
17+type FileStore struct {
18+ envDir string
19+ log zerowrap.Logger
20+}
21+
22+// NewFileStore creates a new file-based domain secret store.
23+func NewFileStore(envDir string, log zerowrap.Logger) (*FileStore, error) {
24+ // Ensure env directory exists
25+ if err := os.MkdirAll(envDir, 0700); err != nil {
26+ return nil, fmt.Errorf("failed to create env directory %s: %w", envDir, err)
27+ }
28+
29+ log.Debug().
30+ Str(zerowrap.FieldLayer, "adapter").
31+ Str(zerowrap.FieldAdapter, "domainsecrets").
32+ Str("env_dir", envDir).
33+ Msg("domain secret store initialized")
34+
35+ return &FileStore{
36+ envDir: envDir,
37+ log: log,
38+ }, nil
39+}
40+
41+// ListKeys returns the list of secret keys for a domain (not values).
42+func (s *FileStore) ListKeys(domain string) ([]string, error) {
43+ envFile := s.getEnvFilePath(domain)
44+
45+ file, err := os.Open(envFile)
46+ if err != nil {
47+ if os.IsNotExist(err) {
48+ return []string{}, nil
49+ }
50+ return nil, fmt.Errorf("failed to open env file: %w", err)
51+ }
52+ defer file.Close()
53+
54+ var keys []string
55+ scanner := bufio.NewScanner(file)
56+ for scanner.Scan() {
57+ line := strings.TrimSpace(scanner.Text())
58+ // Skip empty lines and comments
59+ if line == "" || strings.HasPrefix(line, "#") {
60+ continue
61+ }
62+ // Extract key from KEY=value
63+ if idx := strings.Index(line, "="); idx > 0 {
64+ keys = append(keys, line[:idx])
65+ }
66+ }
67+
68+ if err := scanner.Err(); err != nil {
69+ return nil, fmt.Errorf("failed to read env file: %w", err)
70+ }
71+
72+ return keys, nil
73+}
74+
75+// GetAll returns all secrets for a domain as a key-value map.
76+func (s *FileStore) GetAll(domain string) (map[string]string, error) {
77+ envFile := s.getEnvFilePath(domain)
78+
79+ file, err := os.Open(envFile)
80+ if err != nil {
81+ if os.IsNotExist(err) {
82+ return map[string]string{}, nil
83+ }
84+ return nil, fmt.Errorf("failed to open env file: %w", err)
85+ }
86+ defer file.Close()
87+
88+ secrets := make(map[string]string)
89+ scanner := bufio.NewScanner(file)
90+ for scanner.Scan() {
91+ line := strings.TrimSpace(scanner.Text())
92+ // Skip empty lines and comments
93+ if line == "" || strings.HasPrefix(line, "#") {
94+ continue
95+ }
96+ // Parse KEY=value
97+ if idx := strings.Index(line, "="); idx > 0 {
98+ key := line[:idx]
99+ value := line[idx+1:]
100+ secrets[key] = value
101+ }
102+ }
103+
104+ if err := scanner.Err(); err != nil {
105+ return nil, fmt.Errorf("failed to read env file: %w", err)
106+ }
107+
108+ return secrets, nil
109+}
110+
111+// Set sets or updates multiple secrets for a domain, merging with existing.
112+func (s *FileStore) Set(domain string, secrets map[string]string) error {
113+ // Ensure env directory exists
114+ if err := os.MkdirAll(s.envDir, 0700); err != nil {
115+ return fmt.Errorf("failed to create env directory: %w", err)
116+ }
117+
118+ // Read existing secrets
119+ existing, err := s.GetAll(domain)
120+ if err != nil {
121+ return err
122+ }
123+
124+ // Merge new secrets with existing
125+ for key, value := range secrets {
126+ existing[key] = value
127+ }
128+
129+ // Write back atomically
130+ return s.writeSecretsAtomic(domain, existing)
131+}
132+
133+// Delete removes a specific secret key from a domain.
134+func (s *FileStore) Delete(domain, key string) error {
135+ // Read existing secrets
136+ existing, err := s.GetAll(domain)
137+ if err != nil {
138+ return err
139+ }
140+
141+ // Remove the key
142+ delete(existing, key)
143+
144+ // Write back atomically
145+ return s.writeSecretsAtomic(domain, existing)
146+}
147+
148+// writeSecretsAtomic writes all secrets to the domain's env file atomically.
149+// It writes to a temporary file first, syncs it, then renames to the final path.
150+func (s *FileStore) writeSecretsAtomic(domain string, secrets map[string]string) error {
151+ envFile := s.getEnvFilePath(domain)
152+ tmpFile := envFile + ".tmp"
153+
154+ file, err := os.OpenFile(tmpFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
155+ if err != nil {
156+ return fmt.Errorf("failed to create temp env file: %w", err)
157+ }
158+
159+ // Write header comment
160+ if _, err := fmt.Fprintf(file, "# Environment variables for %s\n", domain); err != nil {
161+ file.Close()
162+ os.Remove(tmpFile)
163+ return fmt.Errorf("failed to write header: %w", err)
164+ }
165+ if _, err := fmt.Fprintf(file, "# Managed by Gordon admin API\n\n"); err != nil {
166+ file.Close()
167+ os.Remove(tmpFile)
168+ return fmt.Errorf("failed to write header: %w", err)
169+ }
170+
171+ // Write each secret
172+ for key, value := range secrets {
173+ if _, err := fmt.Fprintf(file, "%s=%s\n", key, value); err != nil {
174+ file.Close()
175+ os.Remove(tmpFile)
176+ return fmt.Errorf("failed to write secret %s: %w", key, err)
177+ }
178+ }
179+
180+ // Sync to ensure data is on disk before rename
181+ if err := file.Sync(); err != nil {
182+ file.Close()
183+ os.Remove(tmpFile)
184+ return fmt.Errorf("failed to sync env file: %w", err)
185+ }
186+
187+ if err := file.Close(); err != nil {
188+ os.Remove(tmpFile)
189+ return fmt.Errorf("failed to close env file: %w", err)
190+ }
191+
192+ // Atomic rename
193+ if err := os.Rename(tmpFile, envFile); err != nil {
194+ os.Remove(tmpFile)
195+ return fmt.Errorf("failed to rename env file: %w", err)
196+ }
197+
198+ return nil
199+}
200+
201+// getEnvFilePath converts a domain to its env file path.
202+// This must match the naming convention in envloader.FileLoader.getEnvFilePath.
203+func (s *FileStore) getEnvFilePath(domain string) string {
204+ // Create domain-safe filename (replace dots and other chars with underscores)
205+ safeDomain := strings.ReplaceAll(domain, ".", "_")
206+ safeDomain = strings.ReplaceAll(safeDomain, ":", "_")
207+ safeDomain = strings.ReplaceAll(safeDomain, "/", "_")
208+ return filepath.Join(s.envDir, safeDomain+".env")
209+}
A internal/adapters/out/httpprober/prober.go +91 -0
@@ -0,0 +1,91 @@
1+// Package httpprober provides HTTP probing functionality for health checks.
2+package httpprober
3+
4+import (
5+ "context"
6+ "crypto/tls"
7+ "fmt"
8+ "net/http"
9+ "time"
10+)
11+
12+// DefaultTimeout is the default timeout for HTTP probes.
13+const DefaultTimeout = 5 * time.Second
14+
15+// Prober implements the HTTPProber interface for health checking.
16+type Prober struct {
17+ client *http.Client
18+ timeout time.Duration
19+}
20+
21+// Option configures the Prober.
22+type Option func(*Prober)
23+
24+// WithTimeout sets the probe timeout.
25+func WithTimeout(timeout time.Duration) Option {
26+ return func(p *Prober) {
27+ p.timeout = timeout
28+ }
29+}
30+
31+// WithHTTPClient sets a custom HTTP client.
32+func WithHTTPClient(client *http.Client) Option {
33+ return func(p *Prober) {
34+ p.client = client
35+ }
36+}
37+
38+// New creates a new HTTP prober.
39+func New(opts ...Option) *Prober {
40+ p := &Prober{
41+ timeout: DefaultTimeout,
42+ }
43+
44+ for _, opt := range opts {
45+ opt(p)
46+ }
47+
48+ if p.client == nil {
49+ p.client = &http.Client{
50+ Timeout: p.timeout,
51+ Transport: &http.Transport{
52+ // #nosec G402 - InsecureSkipVerify is intentional for health probing
53+ // internal services that may use self-signed certificates.
54+ // This prober only checks reachability, not data integrity.
55+ TLSClientConfig: &tls.Config{
56+ InsecureSkipVerify: true,
57+ },
58+ DisableKeepAlives: true,
59+ },
60+ // Don't follow redirects - we want to see the actual response
61+ CheckRedirect: func(req *http.Request, via []*http.Request) error {
62+ return http.ErrUseLastResponse
63+ },
64+ }
65+ }
66+
67+ return p
68+}
69+
70+// Probe sends an HTTP GET request and returns the status code and response time.
71+func (p *Prober) Probe(ctx context.Context, url string) (int, int64, error) {
72+ start := time.Now()
73+
74+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
75+ if err != nil {
76+ return 0, 0, fmt.Errorf("failed to create request: %w", err)
77+ }
78+
79+ // Set a reasonable user agent
80+ req.Header.Set("User-Agent", "Gordon-HealthCheck/1.0")
81+
82+ resp, err := p.client.Do(req)
83+ if err != nil {
84+ elapsed := time.Since(start).Milliseconds()
85+ return 0, elapsed, fmt.Errorf("request failed: %w", err)
86+ }
87+ defer resp.Body.Close()
88+
89+ elapsed := time.Since(start).Milliseconds()
90+ return resp.StatusCode, elapsed, nil
91+}
M internal/app/run.go +81 -13
@@ -21,14 +21,19 @@ import (
21 21
22 // Adapters - Output22 // Adapters - Output
23 "gordon/internal/adapters/out/docker"23 "gordon/internal/adapters/out/docker"
24+ "gordon/internal/adapters/out/domainsecrets"
24 "gordon/internal/adapters/out/envloader"25 "gordon/internal/adapters/out/envloader"
25 "gordon/internal/adapters/out/eventbus"26 "gordon/internal/adapters/out/eventbus"
26 "gordon/internal/adapters/out/filesystem"27 "gordon/internal/adapters/out/filesystem"
28+ "gordon/internal/adapters/out/httpprober"
27 "gordon/internal/adapters/out/logwriter"29 "gordon/internal/adapters/out/logwriter"
28 "gordon/internal/adapters/out/secrets"30 "gordon/internal/adapters/out/secrets"
29 "gordon/internal/adapters/out/tokenstore"31 "gordon/internal/adapters/out/tokenstore"
30 32
33+ "golang.org/x/time/rate"
34+
31 // Adapters - Input35 // Adapters - Input
36+ "gordon/internal/adapters/in/http/admin"
32 "gordon/internal/adapters/in/http/middleware"37 "gordon/internal/adapters/in/http/middleware"
33 "gordon/internal/adapters/in/http/registry"38 "gordon/internal/adapters/in/http/registry"
34 39
@@ -42,8 +47,10 @@ import (
42 "gordon/internal/usecase/auth"47 "gordon/internal/usecase/auth"
43 "gordon/internal/usecase/config"48 "gordon/internal/usecase/config"
44 "gordon/internal/usecase/container"49 "gordon/internal/usecase/container"
50+ "gordon/internal/usecase/health"
45 "gordon/internal/usecase/proxy"51 "gordon/internal/usecase/proxy"
46 registrySvc "gordon/internal/usecase/registry"52 registrySvc "gordon/internal/usecase/registry"
53+ secretsSvc "gordon/internal/usecase/secrets"
47 )54 )
48 55
49 // Config holds the application configuration.56 // Config holds the application configuration.
@@ -51,7 +58,8 @@ type Config struct {
51 Server struct {58 Server struct {
52 Port int `mapstructure:"port"`59 Port int `mapstructure:"port"`
53 RegistryPort int `mapstructure:"registry_port"`60 RegistryPort int `mapstructure:"registry_port"`
54- RegistryDomain string `mapstructure:"registry_domain"`61+ GordonDomain string `mapstructure:"gordon_domain"`
62+ RegistryDomain string `mapstructure:"registry_domain"` // Deprecated: use gordon_domain
55 DataDir string `mapstructure:"data_dir"`63 DataDir string `mapstructure:"data_dir"`
56 } `mapstructure:"server"`64 } `mapstructure:"server"`
57 65
@@ -108,8 +116,10 @@ type services struct {
108 proxySvc *proxy.Service116 proxySvc *proxy.Service
109 authSvc *auth.Service117 authSvc *auth.Service
110 tokenHandler *registry.TokenHandler118 tokenHandler *registry.TokenHandler
119+ adminHandler *admin.Handler
111 internalRegUser string120 internalRegUser string
112 internalRegPass string121 internalRegPass string
122+ envDir string
113 }123 }
114 124
115 // Run initializes and starts the Gordon application.125 // Run initializes and starts the Gordon application.
@@ -145,7 +155,7 @@ func Run(ctx context.Context, configPath string) error {
145 }155 }
146 156
147 // Register event handlers157 // Register event handlers
148- if err := registerEventHandlers(ctx, svc); err != nil {158+ if err := registerEventHandlers(ctx, svc, cfg); err != nil {
149 return err159 return err
150 }160 }
151 161
@@ -180,6 +190,11 @@ func initConfig(configPath string) (*viper.Viper, Config, error) {
180 return nil, Config{}, fmt.Errorf("failed to unmarshal config: %w", err)190 return nil, Config{}, fmt.Errorf("failed to unmarshal config: %w", err)
181 }191 }
182 192
193+ // Normalize domain config: prefer gordon_domain over registry_domain
194+ if cfg.Server.GordonDomain != "" {
195+ cfg.Server.RegistryDomain = cfg.Server.GordonDomain
196+ }
197+
183 return v, cfg, nil198 return v, cfg, nil
184 }199 }
185 200
@@ -275,6 +290,30 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
275 svc.tokenHandler = registry.NewTokenHandler(svc.authSvc, internalAuth, log)290 svc.tokenHandler = registry.NewTokenHandler(svc.authSvc, internalAuth, log)
276 }291 }
277 292
293+ // Determine env directory for admin API
294+ dataDir := cfg.Server.DataDir
295+ if dataDir == "" {
296+ dataDir = DefaultDataDir()
297+ }
298+ svc.envDir = cfg.Env.Dir
299+ if svc.envDir == "" {
300+ svc.envDir = filepath.Join(dataDir, "env")
301+ }
302+
303+ // Create domain secret store and service
304+ domainSecretStore, err := domainsecrets.NewFileStore(svc.envDir, log)
305+ if err != nil {
306+ return nil, log.WrapErr(err, "failed to create domain secret store")
307+ }
308+ secretSvc := secretsSvc.NewService(domainSecretStore, log)
309+
310+ // Create health service for route health checking
311+ prober := httpprober.New()
312+ healthSvc := health.NewService(svc.configSvc, svc.containerSvc, prober, log)
313+
314+ // Create admin handler for admin API
315+ svc.adminHandler = admin.NewHandler(svc.configSvc, svc.authSvc, svc.containerSvc, healthSvc, secretSvc, svc.eventBus, log)
316+
278 return svc, nil317 return svc, nil
279 }318 }
280 319
@@ -636,6 +675,7 @@ func createContainerService(v *viper.Viper, cfg Config, svc *services) *containe
636 RegistryPassword: cfg.RegistryAuth.Password,675 RegistryPassword: cfg.RegistryAuth.Password,
637 InternalRegistryUsername: svc.internalRegUser,676 InternalRegistryUsername: svc.internalRegUser,
638 InternalRegistryPassword: svc.internalRegPass,677 InternalRegistryPassword: svc.internalRegPass,
678+ PullPolicy: v.GetString("deploy.pull_policy"),
639 VolumeAutoCreate: v.GetBool("volumes.auto_create"),679 VolumeAutoCreate: v.GetBool("volumes.auto_create"),
640 VolumePrefix: v.GetString("volumes.prefix"),680 VolumePrefix: v.GetString("volumes.prefix"),
641 VolumePreserve: v.GetBool("volumes.preserve"),681 VolumePreserve: v.GetBool("volumes.preserve"),
@@ -649,12 +689,19 @@ func createContainerService(v *viper.Viper, cfg Config, svc *services) *containe
649 }689 }
650 690
651 // registerEventHandlers registers all event handlers.691 // registerEventHandlers registers all event handlers.
652-func registerEventHandlers(ctx context.Context, svc *services) error {692+func registerEventHandlers(ctx context.Context, svc *services, cfg Config) error {
653 imagePushedHandler := container.NewImagePushedHandler(ctx, svc.containerSvc, svc.configSvc)693 imagePushedHandler := container.NewImagePushedHandler(ctx, svc.containerSvc, svc.configSvc)
654 if err := svc.eventBus.Subscribe(imagePushedHandler); err != nil {694 if err := svc.eventBus.Subscribe(imagePushedHandler); err != nil {
655 return fmt.Errorf("failed to subscribe image pushed handler: %w", err)695 return fmt.Errorf("failed to subscribe image pushed handler: %w", err)
656 }696 }
657 697
698+ // Auto-route handler for creating routes from image labels
699+ autoRouteHandler := container.NewAutoRouteHandler(ctx, svc.configSvc, svc.containerSvc, svc.blobStorage, cfg.Server.RegistryDomain).
700+ WithEnvExtractor(svc.runtime, svc.envDir)
701+ if err := svc.eventBus.Subscribe(autoRouteHandler); err != nil {
702+ return fmt.Errorf("failed to subscribe auto-route handler: %w", err)
703+ }
704+
658 configReloadHandler := container.NewConfigReloadHandler(ctx, svc.containerSvc, svc.configSvc)705 configReloadHandler := container.NewConfigReloadHandler(ctx, svc.containerSvc, svc.configSvc)
659 if err := svc.eventBus.Subscribe(configReloadHandler); err != nil {706 if err := svc.eventBus.Subscribe(configReloadHandler); err != nil {
660 return fmt.Errorf("failed to subscribe config reload handler: %w", err)707 return fmt.Errorf("failed to subscribe config reload handler: %w", err)
@@ -680,6 +727,10 @@ func registerEventHandlers(ctx context.Context, svc *services) error {
680 }727 }
681 728
682 // setupConfigHotReload sets up Viper config hot reload.729 // setupConfigHotReload sets up Viper config hot reload.
730+// NOTE: This does NOT reload routes into memory. Routes are managed via API
731+// (AddRoute/UpdateRoute/RemoveRoute) and memory is the source of truth.
732+// The file watcher only updates proxy config and refreshes targets.
733+// Manual config file edits to routes require a server restart.
683 func setupConfigHotReload(ctx context.Context, v *viper.Viper, svc *services, log zerowrap.Logger) {734 func setupConfigHotReload(ctx context.Context, v *viper.Viper, svc *services, log zerowrap.Logger) {
684 v.OnConfigChange(func(e fsnotify.Event) {735 v.OnConfigChange(func(e fsnotify.Event) {
685 log.Info().Str("file", e.Name).Msg("config file changed")736 log.Info().Str("file", e.Name).Msg("config file changed")
@@ -689,11 +740,7 @@ func setupConfigHotReload(ctx context.Context, v *viper.Viper, svc *services, lo
689 return740 return
690 }741 }
691 742
692- if err := svc.configSvc.Load(ctx); err != nil {743+ // Update proxy config from viper (reads directly from viper, not memory)
693- log.Error().Err(err).Msg("failed to reload configuration")
694- return
695- }
696-
697 svc.proxySvc.UpdateConfig(proxy.Config{744 svc.proxySvc.UpdateConfig(proxy.Config{
698 RegistryDomain: v.GetString("server.registry_domain"),745 RegistryDomain: v.GetString("server.registry_domain"),
699 RegistryPort: v.GetInt("server.registry_port"),746 RegistryPort: v.GetInt("server.registry_port"),
@@ -704,10 +751,7 @@ func setupConfigHotReload(ctx context.Context, v *viper.Viper, svc *services, lo
704 log.Warn().Err(err).Msg("failed to refresh proxy targets")751 log.Warn().Err(err).Msg("failed to refresh proxy targets")
705 }752 }
706 753
707- // Publish config reload event to trigger container updates754+ log.Debug().Msg("config hot reload complete (routes unchanged, memory is source of truth)")
708- if err := svc.eventBus.Publish(domain.EventConfigReload, nil); err != nil {
709- log.Error().Err(err).Msg("failed to publish config reload event")
710- }
711 })755 })
712 v.WatchConfig()756 v.WatchConfig()
713 }757 }
@@ -728,7 +772,7 @@ func syncAndAutoStart(ctx context.Context, svc *services, log zerowrap.Logger) {
728 772
729 // createHTTPHandlers creates HTTP handlers with middleware.773 // createHTTPHandlers creates HTTP handlers with middleware.
730 func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Handler, http.Handler) {774 func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Handler, http.Handler) {
731- registryHandler := registry.NewHandler(svc.registrySvc, svc.blobStorage, svc.eventBus, log)775+ registryHandler := registry.NewHandler(svc.registrySvc, log)
732 776
733 registryMiddlewares := []func(http.Handler) http.Handler{777 registryMiddlewares := []func(http.Handler) http.Handler{
734 middleware.PanicRecovery(log),778 middleware.PanicRecovery(log),
@@ -759,6 +803,22 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
759 }803 }
760 registryMux.Handle("/v2/", registryWithMiddleware)804 registryMux.Handle("/v2/", registryWithMiddleware)
761 805
806+ // Add admin API handler with auth middleware
807+ if svc.adminHandler != nil {
808+ adminMiddlewares := []func(http.Handler) http.Handler{
809+ middleware.PanicRecovery(log),
810+ middleware.RequestLogger(log),
811+ }
812+ // Add admin auth middleware if auth is enabled
813+ if svc.authSvc != nil {
814+ // Rate limiter for admin API: 10 req/s with burst of 20
815+ adminLimiter := rate.NewLimiter(rate.Limit(10), 20)
816+ adminMiddlewares = append(adminMiddlewares, admin.AuthMiddleware(svc.authSvc, adminLimiter, log))
817+ }
818+ adminWithMiddleware := middleware.Chain(adminMiddlewares...)(svc.adminHandler)
819+ registryMux.Handle("/admin/", adminWithMiddleware)
820+ }
821+
762 proxyMiddlewares := []func(http.Handler) http.Handler{822 proxyMiddlewares := []func(http.Handler) http.Handler{
763 middleware.PanicRecovery(log),823 middleware.PanicRecovery(log),
764 middleware.RequestLogger(log),824 middleware.RequestLogger(log),
@@ -1063,6 +1123,14 @@ func loadConfig(v *viper.Viper, configPath string) error {
1063 v.SetDefault("registry_auth.enabled", false)1123 v.SetDefault("registry_auth.enabled", false)
1064 v.SetDefault("registry_auth.type", "password")1124 v.SetDefault("registry_auth.type", "password")
1065 v.SetDefault("registry_auth.token_expiry", "720h")1125 v.SetDefault("registry_auth.token_expiry", "720h")
1126+ v.SetDefault("auto_route.enabled", false)
1127+ v.SetDefault("network_isolation.enabled", false)
1128+ v.SetDefault("network_isolation.network_prefix", "gordon")
1129+ v.SetDefault("network_isolation.dns_suffix", ".internal")
1130+ v.SetDefault("volumes.auto_create", true)
1131+ v.SetDefault("volumes.prefix", "gordon")
1132+ v.SetDefault("volumes.preserve", true)
1133+ v.SetDefault("deploy.pull_policy", container.PullPolicyIfTagChanged)
1066 1134
1067 ConfigureViper(v, configPath)1135 ConfigureViper(v, configPath)
1068 1136
M internal/boundaries/in/config.go +6 -0
@@ -14,6 +14,9 @@ type ConfigService interface {
14 // GetRoutes returns all configured routes.14 // GetRoutes returns all configured routes.
15 GetRoutes(ctx context.Context) []domain.Route15 GetRoutes(ctx context.Context) []domain.Route
16 16
17+ // GetRoute returns a single route by domain.
18+ GetRoute(ctx context.Context, domain string) (*domain.Route, error)
19+
17 // AddRoute adds a new route to the configuration.20 // AddRoute adds a new route to the configuration.
18 AddRoute(ctx context.Context, route domain.Route) error21 AddRoute(ctx context.Context, route domain.Route) error
19 22
@@ -23,6 +26,9 @@ type ConfigService interface {
23 // RemoveRoute removes a route from the configuration.26 // RemoveRoute removes a route from the configuration.
24 RemoveRoute(ctx context.Context, domain string) error27 RemoveRoute(ctx context.Context, domain string) error
25 28
29+ // Save persists the current configuration to disk.
30+ Save(ctx context.Context) error
31+
26 // Watch starts watching for configuration changes.32 // Watch starts watching for configuration changes.
27 // The onChange callback is called when configuration changes are detected.33 // The onChange callback is called when configuration changes are detected.
28 Watch(ctx context.Context, onChange func()) error34 Watch(ctx context.Context, onChange func()) error
A internal/boundaries/in/health.go +29 -0
@@ -0,0 +1,29 @@
1+// Package in defines input ports (interfaces) for use cases.
2+// These interfaces define the contract between driving adapters (HTTP, CLI)
3+// and the business logic (use cases).
4+package in
5+
6+import (
7+ "context"
8+
9+ "gordon/internal/domain"
10+)
11+
12+// HealthService defines the contract for route health checking operations.
13+type HealthService interface {
14+ // CheckRoute performs a health check on a single route.
15+ // It checks both container status and HTTP reachability.
16+ CheckRoute(ctx context.Context, route domain.Route) *domain.RouteHealth
17+
18+ // CheckAllRoutes performs health checks on all configured routes.
19+ // Returns a map of domain to health status.
20+ CheckAllRoutes(ctx context.Context) map[string]*domain.RouteHealth
21+}
22+
23+// HTTPProber defines the contract for HTTP health probing.
24+// This allows for easy mocking in tests.
25+type HTTPProber interface {
26+ // Probe sends an HTTP request to the URL and returns status code and response time.
27+ // Returns (statusCode, responseTimeMs, error).
28+ Probe(ctx context.Context, url string) (int, int64, error)
29+}
M internal/boundaries/in/mocks/mock_config_service.go +119 -0
@@ -317,6 +317,74 @@ func (_c *MockConfigService_GetRegistryPort_Call) RunAndReturn(run func() int) *
317 return _c317 return _c
318 }318 }
319 319
320+// GetRoute provides a mock function for the type MockConfigService
321+func (_mock *MockConfigService) GetRoute(ctx context.Context, domain1 string) (*domain.Route, error) {
322+ ret := _mock.Called(ctx, domain1)
323+
324+ if len(ret) == 0 {
325+ panic("no return value specified for GetRoute")
326+ }
327+
328+ var r0 *domain.Route
329+ var r1 error
330+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (*domain.Route, error)); ok {
331+ return returnFunc(ctx, domain1)
332+ }
333+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) *domain.Route); ok {
334+ r0 = returnFunc(ctx, domain1)
335+ } else {
336+ if ret.Get(0) != nil {
337+ r0 = ret.Get(0).(*domain.Route)
338+ }
339+ }
340+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
341+ r1 = returnFunc(ctx, domain1)
342+ } else {
343+ r1 = ret.Error(1)
344+ }
345+ return r0, r1
346+}
347+
348+// MockConfigService_GetRoute_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRoute'
349+type MockConfigService_GetRoute_Call struct {
350+ *mock.Call
351+}
352+
353+// GetRoute is a helper method to define mock.On call
354+// - ctx context.Context
355+// - domain1 string
356+func (_e *MockConfigService_Expecter) GetRoute(ctx interface{}, domain1 interface{}) *MockConfigService_GetRoute_Call {
357+ return &MockConfigService_GetRoute_Call{Call: _e.mock.On("GetRoute", ctx, domain1)}
358+}
359+
360+func (_c *MockConfigService_GetRoute_Call) Run(run func(ctx context.Context, domain1 string)) *MockConfigService_GetRoute_Call {
361+ _c.Call.Run(func(args mock.Arguments) {
362+ var arg0 context.Context
363+ if args[0] != nil {
364+ arg0 = args[0].(context.Context)
365+ }
366+ var arg1 string
367+ if args[1] != nil {
368+ arg1 = args[1].(string)
369+ }
370+ run(
371+ arg0,
372+ arg1,
373+ )
374+ })
375+ return _c
376+}
377+
378+func (_c *MockConfigService_GetRoute_Call) Return(route *domain.Route, err error) *MockConfigService_GetRoute_Call {
379+ _c.Call.Return(route, err)
380+ return _c
381+}
382+
383+func (_c *MockConfigService_GetRoute_Call) RunAndReturn(run func(ctx context.Context, domain1 string) (*domain.Route, error)) *MockConfigService_GetRoute_Call {
384+ _c.Call.Return(run)
385+ return _c
386+}
387+
320 // GetRoutes provides a mock function for the type MockConfigService388 // GetRoutes provides a mock function for the type MockConfigService
321 func (_mock *MockConfigService) GetRoutes(ctx context.Context) []domain.Route {389 func (_mock *MockConfigService) GetRoutes(ctx context.Context) []domain.Route {
322 ret := _mock.Called(ctx)390 ret := _mock.Called(ctx)
@@ -610,6 +678,57 @@ func (_c *MockConfigService_RemoveRoute_Call) RunAndReturn(run func(ctx context.
610 return _c678 return _c
611 }679 }
612 680
681+// Save provides a mock function for the type MockConfigService
682+func (_mock *MockConfigService) Save(ctx context.Context) error {
683+ ret := _mock.Called(ctx)
684+
685+ if len(ret) == 0 {
686+ panic("no return value specified for Save")
687+ }
688+
689+ var r0 error
690+ if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok {
691+ r0 = returnFunc(ctx)
692+ } else {
693+ r0 = ret.Error(0)
694+ }
695+ return r0
696+}
697+
698+// MockConfigService_Save_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Save'
699+type MockConfigService_Save_Call struct {
700+ *mock.Call
701+}
702+
703+// Save is a helper method to define mock.On call
704+// - ctx context.Context
705+func (_e *MockConfigService_Expecter) Save(ctx interface{}) *MockConfigService_Save_Call {
706+ return &MockConfigService_Save_Call{Call: _e.mock.On("Save", ctx)}
707+}
708+
709+func (_c *MockConfigService_Save_Call) Run(run func(ctx context.Context)) *MockConfigService_Save_Call {
710+ _c.Call.Run(func(args mock.Arguments) {
711+ var arg0 context.Context
712+ if args[0] != nil {
713+ arg0 = args[0].(context.Context)
714+ }
715+ run(
716+ arg0,
717+ )
718+ })
719+ return _c
720+}
721+
722+func (_c *MockConfigService_Save_Call) Return(err error) *MockConfigService_Save_Call {
723+ _c.Call.Return(err)
724+ return _c
725+}
726+
727+func (_c *MockConfigService_Save_Call) RunAndReturn(run func(ctx context.Context) error) *MockConfigService_Save_Call {
728+ _c.Call.Return(run)
729+ return _c
730+}
731+
613 // UpdateRoute provides a mock function for the type MockConfigService732 // UpdateRoute provides a mock function for the type MockConfigService
614 func (_mock *MockConfigService) UpdateRoute(ctx context.Context, route domain.Route) error {733 func (_mock *MockConfigService) UpdateRoute(ctx context.Context, route domain.Route) error {
615 ret := _mock.Called(ctx, route)734 ret := _mock.Called(ctx, route)
A internal/boundaries/in/mocks/mock_health_service.go +151 -0
@@ -0,0 +1,151 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+ "gordon/internal/domain"
10+
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMockHealthService creates a new instance of MockHealthService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMockHealthService(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MockHealthService {
20+ mock := &MockHealthService{}
21+ mock.Mock.Test(t)
22+
23+ t.Cleanup(func() { mock.AssertExpectations(t) })
24+
25+ return mock
26+}
27+
28+// MockHealthService is an autogenerated mock type for the HealthService type
29+type MockHealthService struct {
30+ mock.Mock
31+}
32+
33+type MockHealthService_Expecter struct {
34+ mock *mock.Mock
35+}
36+
37+func (_m *MockHealthService) EXPECT() *MockHealthService_Expecter {
38+ return &MockHealthService_Expecter{mock: &_m.Mock}
39+}
40+
41+// CheckAllRoutes provides a mock function for the type MockHealthService
42+func (_mock *MockHealthService) CheckAllRoutes(ctx context.Context) map[string]*domain.RouteHealth {
43+ ret := _mock.Called(ctx)
44+
45+ if len(ret) == 0 {
46+ panic("no return value specified for CheckAllRoutes")
47+ }
48+
49+ var r0 map[string]*domain.RouteHealth
50+ if returnFunc, ok := ret.Get(0).(func(context.Context) map[string]*domain.RouteHealth); ok {
51+ r0 = returnFunc(ctx)
52+ } else {
53+ if ret.Get(0) != nil {
54+ r0 = ret.Get(0).(map[string]*domain.RouteHealth)
55+ }
56+ }
57+ return r0
58+}
59+
60+// MockHealthService_CheckAllRoutes_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CheckAllRoutes'
61+type MockHealthService_CheckAllRoutes_Call struct {
62+ *mock.Call
63+}
64+
65+// CheckAllRoutes is a helper method to define mock.On call
66+// - ctx context.Context
67+func (_e *MockHealthService_Expecter) CheckAllRoutes(ctx interface{}) *MockHealthService_CheckAllRoutes_Call {
68+ return &MockHealthService_CheckAllRoutes_Call{Call: _e.mock.On("CheckAllRoutes", ctx)}
69+}
70+
71+func (_c *MockHealthService_CheckAllRoutes_Call) Run(run func(ctx context.Context)) *MockHealthService_CheckAllRoutes_Call {
72+ _c.Call.Run(func(args mock.Arguments) {
73+ var arg0 context.Context
74+ if args[0] != nil {
75+ arg0 = args[0].(context.Context)
76+ }
77+ run(
78+ arg0,
79+ )
80+ })
81+ return _c
82+}
83+
84+func (_c *MockHealthService_CheckAllRoutes_Call) Return(stringToRouteHealth map[string]*domain.RouteHealth) *MockHealthService_CheckAllRoutes_Call {
85+ _c.Call.Return(stringToRouteHealth)
86+ return _c
87+}
88+
89+func (_c *MockHealthService_CheckAllRoutes_Call) RunAndReturn(run func(ctx context.Context) map[string]*domain.RouteHealth) *MockHealthService_CheckAllRoutes_Call {
90+ _c.Call.Return(run)
91+ return _c
92+}
93+
94+// CheckRoute provides a mock function for the type MockHealthService
95+func (_mock *MockHealthService) CheckRoute(ctx context.Context, route domain.Route) *domain.RouteHealth {
96+ ret := _mock.Called(ctx, route)
97+
98+ if len(ret) == 0 {
99+ panic("no return value specified for CheckRoute")
100+ }
101+
102+ var r0 *domain.RouteHealth
103+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Route) *domain.RouteHealth); ok {
104+ r0 = returnFunc(ctx, route)
105+ } else {
106+ if ret.Get(0) != nil {
107+ r0 = ret.Get(0).(*domain.RouteHealth)
108+ }
109+ }
110+ return r0
111+}
112+
113+// MockHealthService_CheckRoute_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CheckRoute'
114+type MockHealthService_CheckRoute_Call struct {
115+ *mock.Call
116+}
117+
118+// CheckRoute is a helper method to define mock.On call
119+// - ctx context.Context
120+// - route domain.Route
121+func (_e *MockHealthService_Expecter) CheckRoute(ctx interface{}, route interface{}) *MockHealthService_CheckRoute_Call {
122+ return &MockHealthService_CheckRoute_Call{Call: _e.mock.On("CheckRoute", ctx, route)}
123+}
124+
125+func (_c *MockHealthService_CheckRoute_Call) Run(run func(ctx context.Context, route domain.Route)) *MockHealthService_CheckRoute_Call {
126+ _c.Call.Run(func(args mock.Arguments) {
127+ var arg0 context.Context
128+ if args[0] != nil {
129+ arg0 = args[0].(context.Context)
130+ }
131+ var arg1 domain.Route
132+ if args[1] != nil {
133+ arg1 = args[1].(domain.Route)
134+ }
135+ run(
136+ arg0,
137+ arg1,
138+ )
139+ })
140+ return _c
141+}
142+
143+func (_c *MockHealthService_CheckRoute_Call) Return(routeHealth *domain.RouteHealth) *MockHealthService_CheckRoute_Call {
144+ _c.Call.Return(routeHealth)
145+ return _c
146+}
147+
148+func (_c *MockHealthService_CheckRoute_Call) RunAndReturn(run func(ctx context.Context, route domain.Route) *domain.RouteHealth) *MockHealthService_CheckRoute_Call {
149+ _c.Call.Return(run)
150+ return _c
151+}
A internal/boundaries/in/mocks/mock_http_prober.go +110 -0
@@ -0,0 +1,110 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ mock "github.com/stretchr/testify/mock"
11+)
12+
13+// NewMockHTTPProber creates a new instance of MockHTTPProber. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
14+// The first argument is typically a *testing.T value.
15+func NewMockHTTPProber(t interface {
16+ mock.TestingT
17+ Cleanup(func())
18+}) *MockHTTPProber {
19+ mock := &MockHTTPProber{}
20+ mock.Mock.Test(t)
21+
22+ t.Cleanup(func() { mock.AssertExpectations(t) })
23+
24+ return mock
25+}
26+
27+// MockHTTPProber is an autogenerated mock type for the HTTPProber type
28+type MockHTTPProber struct {
29+ mock.Mock
30+}
31+
32+type MockHTTPProber_Expecter struct {
33+ mock *mock.Mock
34+}
35+
36+func (_m *MockHTTPProber) EXPECT() *MockHTTPProber_Expecter {
37+ return &MockHTTPProber_Expecter{mock: &_m.Mock}
38+}
39+
40+// Probe provides a mock function for the type MockHTTPProber
41+func (_mock *MockHTTPProber) Probe(ctx context.Context, url string) (int, int64, error) {
42+ ret := _mock.Called(ctx, url)
43+
44+ if len(ret) == 0 {
45+ panic("no return value specified for Probe")
46+ }
47+
48+ var r0 int
49+ var r1 int64
50+ var r2 error
51+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (int, int64, error)); ok {
52+ return returnFunc(ctx, url)
53+ }
54+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) int); ok {
55+ r0 = returnFunc(ctx, url)
56+ } else {
57+ r0 = ret.Get(0).(int)
58+ }
59+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) int64); ok {
60+ r1 = returnFunc(ctx, url)
61+ } else {
62+ r1 = ret.Get(1).(int64)
63+ }
64+ if returnFunc, ok := ret.Get(2).(func(context.Context, string) error); ok {
65+ r2 = returnFunc(ctx, url)
66+ } else {
67+ r2 = ret.Error(2)
68+ }
69+ return r0, r1, r2
70+}
71+
72+// MockHTTPProber_Probe_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Probe'
73+type MockHTTPProber_Probe_Call struct {
74+ *mock.Call
75+}
76+
77+// Probe is a helper method to define mock.On call
78+// - ctx context.Context
79+// - url string
80+func (_e *MockHTTPProber_Expecter) Probe(ctx interface{}, url interface{}) *MockHTTPProber_Probe_Call {
81+ return &MockHTTPProber_Probe_Call{Call: _e.mock.On("Probe", ctx, url)}
82+}
83+
84+func (_c *MockHTTPProber_Probe_Call) Run(run func(ctx context.Context, url string)) *MockHTTPProber_Probe_Call {
85+ _c.Call.Run(func(args mock.Arguments) {
86+ var arg0 context.Context
87+ if args[0] != nil {
88+ arg0 = args[0].(context.Context)
89+ }
90+ var arg1 string
91+ if args[1] != nil {
92+ arg1 = args[1].(string)
93+ }
94+ run(
95+ arg0,
96+ arg1,
97+ )
98+ })
99+ return _c
100+}
101+
102+func (_c *MockHTTPProber_Probe_Call) Return(n int, n1 int64, err error) *MockHTTPProber_Probe_Call {
103+ _c.Call.Return(n, n1, err)
104+ return _c
105+}
106+
107+func (_c *MockHTTPProber_Probe_Call) RunAndReturn(run func(ctx context.Context, url string) (int, int64, error)) *MockHTTPProber_Probe_Call {
108+ _c.Call.Return(run)
109+ return _c
110+}
M internal/boundaries/in/mocks/mock_registry_service.go +161 -8
@@ -39,6 +39,84 @@ func (_m *MockRegistryService) EXPECT() *MockRegistryService_Expecter {
39 return &MockRegistryService_Expecter{mock: &_m.Mock}39 return &MockRegistryService_Expecter{mock: &_m.Mock}
40 }40 }
41 41
42+// AppendBlobChunk provides a mock function for the type MockRegistryService
43+func (_mock *MockRegistryService) AppendBlobChunk(ctx context.Context, name string, uuid string, chunk []byte) (int64, error) {
44+ ret := _mock.Called(ctx, name, uuid, chunk)
45+
46+ if len(ret) == 0 {
47+ panic("no return value specified for AppendBlobChunk")
48+ }
49+
50+ var r0 int64
51+ var r1 error
52+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, []byte) (int64, error)); ok {
53+ return returnFunc(ctx, name, uuid, chunk)
54+ }
55+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, []byte) int64); ok {
56+ r0 = returnFunc(ctx, name, uuid, chunk)
57+ } else {
58+ r0 = ret.Get(0).(int64)
59+ }
60+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, []byte) error); ok {
61+ r1 = returnFunc(ctx, name, uuid, chunk)
62+ } else {
63+ r1 = ret.Error(1)
64+ }
65+ return r0, r1
66+}
67+
68+// MockRegistryService_AppendBlobChunk_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AppendBlobChunk'
69+type MockRegistryService_AppendBlobChunk_Call struct {
70+ *mock.Call
71+}
72+
73+// AppendBlobChunk is a helper method to define mock.On call
74+// - ctx context.Context
75+// - name string
76+// - uuid string
77+// - chunk []byte
78+func (_e *MockRegistryService_Expecter) AppendBlobChunk(ctx interface{}, name interface{}, uuid interface{}, chunk interface{}) *MockRegistryService_AppendBlobChunk_Call {
79+ return &MockRegistryService_AppendBlobChunk_Call{Call: _e.mock.On("AppendBlobChunk", ctx, name, uuid, chunk)}
80+}
81+
82+func (_c *MockRegistryService_AppendBlobChunk_Call) Run(run func(ctx context.Context, name string, uuid string, chunk []byte)) *MockRegistryService_AppendBlobChunk_Call {
83+ _c.Call.Run(func(args mock.Arguments) {
84+ var arg0 context.Context
85+ if args[0] != nil {
86+ arg0 = args[0].(context.Context)
87+ }
88+ var arg1 string
89+ if args[1] != nil {
90+ arg1 = args[1].(string)
91+ }
92+ var arg2 string
93+ if args[2] != nil {
94+ arg2 = args[2].(string)
95+ }
96+ var arg3 []byte
97+ if args[3] != nil {
98+ arg3 = args[3].([]byte)
99+ }
100+ run(
101+ arg0,
102+ arg1,
103+ arg2,
104+ arg3,
105+ )
106+ })
107+ return _c
108+}
109+
110+func (_c *MockRegistryService_AppendBlobChunk_Call) Return(n int64, err error) *MockRegistryService_AppendBlobChunk_Call {
111+ _c.Call.Return(n, err)
112+ return _c
113+}
114+
115+func (_c *MockRegistryService_AppendBlobChunk_Call) RunAndReturn(run func(ctx context.Context, name string, uuid string, chunk []byte) (int64, error)) *MockRegistryService_AppendBlobChunk_Call {
116+ _c.Call.Return(run)
117+ return _c
118+}
119+
42 // BlobExists provides a mock function for the type MockRegistryService120 // BlobExists provides a mock function for the type MockRegistryService
43 func (_mock *MockRegistryService) BlobExists(ctx context.Context, digest string) bool {121 func (_mock *MockRegistryService) BlobExists(ctx context.Context, digest string) bool {
44 ret := _mock.Called(ctx, digest)122 ret := _mock.Called(ctx, digest)
@@ -347,6 +425,72 @@ func (_c *MockRegistryService_GetBlob_Call) RunAndReturn(run func(ctx context.Co
347 return _c425 return _c
348 }426 }
349 427
428+// GetBlobPath provides a mock function for the type MockRegistryService
429+func (_mock *MockRegistryService) GetBlobPath(ctx context.Context, digest string) (string, error) {
430+ ret := _mock.Called(ctx, digest)
431+
432+ if len(ret) == 0 {
433+ panic("no return value specified for GetBlobPath")
434+ }
435+
436+ var r0 string
437+ var r1 error
438+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (string, error)); ok {
439+ return returnFunc(ctx, digest)
440+ }
441+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) string); ok {
442+ r0 = returnFunc(ctx, digest)
443+ } else {
444+ r0 = ret.Get(0).(string)
445+ }
446+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
447+ r1 = returnFunc(ctx, digest)
448+ } else {
449+ r1 = ret.Error(1)
450+ }
451+ return r0, r1
452+}
453+
454+// MockRegistryService_GetBlobPath_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetBlobPath'
455+type MockRegistryService_GetBlobPath_Call struct {
456+ *mock.Call
457+}
458+
459+// GetBlobPath is a helper method to define mock.On call
460+// - ctx context.Context
461+// - digest string
462+func (_e *MockRegistryService_Expecter) GetBlobPath(ctx interface{}, digest interface{}) *MockRegistryService_GetBlobPath_Call {
463+ return &MockRegistryService_GetBlobPath_Call{Call: _e.mock.On("GetBlobPath", ctx, digest)}
464+}
465+
466+func (_c *MockRegistryService_GetBlobPath_Call) Run(run func(ctx context.Context, digest string)) *MockRegistryService_GetBlobPath_Call {
467+ _c.Call.Run(func(args mock.Arguments) {
468+ var arg0 context.Context
469+ if args[0] != nil {
470+ arg0 = args[0].(context.Context)
471+ }
472+ var arg1 string
473+ if args[1] != nil {
474+ arg1 = args[1].(string)
475+ }
476+ run(
477+ arg0,
478+ arg1,
479+ )
480+ })
481+ return _c
482+}
483+
484+func (_c *MockRegistryService_GetBlobPath_Call) Return(s string, err error) *MockRegistryService_GetBlobPath_Call {
485+ _c.Call.Return(s, err)
486+ return _c
487+}
488+
489+func (_c *MockRegistryService_GetBlobPath_Call) RunAndReturn(run func(ctx context.Context, digest string) (string, error)) *MockRegistryService_GetBlobPath_Call {
490+ _c.Call.Return(run)
491+ return _c
492+}
493+
350 // GetManifest provides a mock function for the type MockRegistryService494 // GetManifest provides a mock function for the type MockRegistryService
351 func (_mock *MockRegistryService) GetManifest(ctx context.Context, name string, reference string) (*domain.Manifest, error) {495 func (_mock *MockRegistryService) GetManifest(ctx context.Context, name string, reference string) (*domain.Manifest, error) {
352 ret := _mock.Called(ctx, name, reference)496 ret := _mock.Called(ctx, name, reference)
@@ -621,20 +765,29 @@ func (_c *MockRegistryService_PutBlob_Call) RunAndReturn(run func(ctx context.Co
621 }765 }
622 766
623 // PutManifest provides a mock function for the type MockRegistryService767 // PutManifest provides a mock function for the type MockRegistryService
624-func (_mock *MockRegistryService) PutManifest(ctx context.Context, manifest *domain.Manifest) error {768+func (_mock *MockRegistryService) PutManifest(ctx context.Context, manifest *domain.Manifest) (string, error) {
625 ret := _mock.Called(ctx, manifest)769 ret := _mock.Called(ctx, manifest)
626 770
627 if len(ret) == 0 {771 if len(ret) == 0 {
628 panic("no return value specified for PutManifest")772 panic("no return value specified for PutManifest")
629 }773 }
630 774
631- var r0 error775+ var r0 string
632- if returnFunc, ok := ret.Get(0).(func(context.Context, *domain.Manifest) error); ok {776+ var r1 error
777+ if returnFunc, ok := ret.Get(0).(func(context.Context, *domain.Manifest) (string, error)); ok {
778+ return returnFunc(ctx, manifest)
779+ }
780+ if returnFunc, ok := ret.Get(0).(func(context.Context, *domain.Manifest) string); ok {
633 r0 = returnFunc(ctx, manifest)781 r0 = returnFunc(ctx, manifest)
634 } else {782 } else {
635- r0 = ret.Error(0)783+ r0 = ret.Get(0).(string)
636 }784 }
637- return r0785+ if returnFunc, ok := ret.Get(1).(func(context.Context, *domain.Manifest) error); ok {
786+ r1 = returnFunc(ctx, manifest)
787+ } else {
788+ r1 = ret.Error(1)
789+ }
790+ return r0, r1
638 }791 }
639 792
640 // MockRegistryService_PutManifest_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PutManifest'793 // MockRegistryService_PutManifest_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PutManifest'
@@ -667,12 +820,12 @@ func (_c *MockRegistryService_PutManifest_Call) Run(run func(ctx context.Context
667 return _c820 return _c
668 }821 }
669 822
670-func (_c *MockRegistryService_PutManifest_Call) Return(err error) *MockRegistryService_PutManifest_Call {823+func (_c *MockRegistryService_PutManifest_Call) Return(digest string, err error) *MockRegistryService_PutManifest_Call {
671- _c.Call.Return(err)824+ _c.Call.Return(digest, err)
672 return _c825 return _c
673 }826 }
674 827
675-func (_c *MockRegistryService_PutManifest_Call) RunAndReturn(run func(ctx context.Context, manifest *domain.Manifest) error) *MockRegistryService_PutManifest_Call {828+func (_c *MockRegistryService_PutManifest_Call) RunAndReturn(run func(ctx context.Context, manifest *domain.Manifest) (string, error)) *MockRegistryService_PutManifest_Call {
676 _c.Call.Return(run)829 _c.Call.Return(run)
677 return _c830 return _c
678 }831 }
A internal/boundaries/in/mocks/mock_secret_service.go +300 -0
@@ -0,0 +1,300 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ mock "github.com/stretchr/testify/mock"
11+)
12+
13+// NewMockSecretService creates a new instance of MockSecretService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
14+// The first argument is typically a *testing.T value.
15+func NewMockSecretService(t interface {
16+ mock.TestingT
17+ Cleanup(func())
18+}) *MockSecretService {
19+ mock := &MockSecretService{}
20+ mock.Mock.Test(t)
21+
22+ t.Cleanup(func() { mock.AssertExpectations(t) })
23+
24+ return mock
25+}
26+
27+// MockSecretService is an autogenerated mock type for the SecretService type
28+type MockSecretService struct {
29+ mock.Mock
30+}
31+
32+type MockSecretService_Expecter struct {
33+ mock *mock.Mock
34+}
35+
36+func (_m *MockSecretService) EXPECT() *MockSecretService_Expecter {
37+ return &MockSecretService_Expecter{mock: &_m.Mock}
38+}
39+
40+// Delete provides a mock function for the type MockSecretService
41+func (_mock *MockSecretService) Delete(ctx context.Context, domain string, key string) error {
42+ ret := _mock.Called(ctx, domain, key)
43+
44+ if len(ret) == 0 {
45+ panic("no return value specified for Delete")
46+ }
47+
48+ var r0 error
49+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok {
50+ r0 = returnFunc(ctx, domain, key)
51+ } else {
52+ r0 = ret.Error(0)
53+ }
54+ return r0
55+}
56+
57+// MockSecretService_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'
58+type MockSecretService_Delete_Call struct {
59+ *mock.Call
60+}
61+
62+// Delete is a helper method to define mock.On call
63+// - ctx context.Context
64+// - domain string
65+// - key string
66+func (_e *MockSecretService_Expecter) Delete(ctx interface{}, domain interface{}, key interface{}) *MockSecretService_Delete_Call {
67+ return &MockSecretService_Delete_Call{Call: _e.mock.On("Delete", ctx, domain, key)}
68+}
69+
70+func (_c *MockSecretService_Delete_Call) Run(run func(ctx context.Context, domain string, key string)) *MockSecretService_Delete_Call {
71+ _c.Call.Run(func(args mock.Arguments) {
72+ var arg0 context.Context
73+ if args[0] != nil {
74+ arg0 = args[0].(context.Context)
75+ }
76+ var arg1 string
77+ if args[1] != nil {
78+ arg1 = args[1].(string)
79+ }
80+ var arg2 string
81+ if args[2] != nil {
82+ arg2 = args[2].(string)
83+ }
84+ run(
85+ arg0,
86+ arg1,
87+ arg2,
88+ )
89+ })
90+ return _c
91+}
92+
93+func (_c *MockSecretService_Delete_Call) Return(err error) *MockSecretService_Delete_Call {
94+ _c.Call.Return(err)
95+ return _c
96+}
97+
98+func (_c *MockSecretService_Delete_Call) RunAndReturn(run func(ctx context.Context, domain string, key string) error) *MockSecretService_Delete_Call {
99+ _c.Call.Return(run)
100+ return _c
101+}
102+
103+// GetAll provides a mock function for the type MockSecretService
104+func (_mock *MockSecretService) GetAll(ctx context.Context, domain string) (map[string]string, error) {
105+ ret := _mock.Called(ctx, domain)
106+
107+ if len(ret) == 0 {
108+ panic("no return value specified for GetAll")
109+ }
110+
111+ var r0 map[string]string
112+ var r1 error
113+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (map[string]string, error)); ok {
114+ return returnFunc(ctx, domain)
115+ }
116+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) map[string]string); ok {
117+ r0 = returnFunc(ctx, domain)
118+ } else {
119+ if ret.Get(0) != nil {
120+ r0 = ret.Get(0).(map[string]string)
121+ }
122+ }
123+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
124+ r1 = returnFunc(ctx, domain)
125+ } else {
126+ r1 = ret.Error(1)
127+ }
128+ return r0, r1
129+}
130+
131+// MockSecretService_GetAll_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAll'
132+type MockSecretService_GetAll_Call struct {
133+ *mock.Call
134+}
135+
136+// GetAll is a helper method to define mock.On call
137+// - ctx context.Context
138+// - domain string
139+func (_e *MockSecretService_Expecter) GetAll(ctx interface{}, domain interface{}) *MockSecretService_GetAll_Call {
140+ return &MockSecretService_GetAll_Call{Call: _e.mock.On("GetAll", ctx, domain)}
141+}
142+
143+func (_c *MockSecretService_GetAll_Call) Run(run func(ctx context.Context, domain string)) *MockSecretService_GetAll_Call {
144+ _c.Call.Run(func(args mock.Arguments) {
145+ var arg0 context.Context
146+ if args[0] != nil {
147+ arg0 = args[0].(context.Context)
148+ }
149+ var arg1 string
150+ if args[1] != nil {
151+ arg1 = args[1].(string)
152+ }
153+ run(
154+ arg0,
155+ arg1,
156+ )
157+ })
158+ return _c
159+}
160+
161+func (_c *MockSecretService_GetAll_Call) Return(stringToString map[string]string, err error) *MockSecretService_GetAll_Call {
162+ _c.Call.Return(stringToString, err)
163+ return _c
164+}
165+
166+func (_c *MockSecretService_GetAll_Call) RunAndReturn(run func(ctx context.Context, domain string) (map[string]string, error)) *MockSecretService_GetAll_Call {
167+ _c.Call.Return(run)
168+ return _c
169+}
170+
171+// ListKeys provides a mock function for the type MockSecretService
172+func (_mock *MockSecretService) ListKeys(ctx context.Context, domain string) ([]string, error) {
173+ ret := _mock.Called(ctx, domain)
174+
175+ if len(ret) == 0 {
176+ panic("no return value specified for ListKeys")
177+ }
178+
179+ var r0 []string
180+ var r1 error
181+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]string, error)); ok {
182+ return returnFunc(ctx, domain)
183+ }
184+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) []string); ok {
185+ r0 = returnFunc(ctx, domain)
186+ } else {
187+ if ret.Get(0) != nil {
188+ r0 = ret.Get(0).([]string)
189+ }
190+ }
191+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
192+ r1 = returnFunc(ctx, domain)
193+ } else {
194+ r1 = ret.Error(1)
195+ }
196+ return r0, r1
197+}
198+
199+// MockSecretService_ListKeys_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListKeys'
200+type MockSecretService_ListKeys_Call struct {
201+ *mock.Call
202+}
203+
204+// ListKeys is a helper method to define mock.On call
205+// - ctx context.Context
206+// - domain string
207+func (_e *MockSecretService_Expecter) ListKeys(ctx interface{}, domain interface{}) *MockSecretService_ListKeys_Call {
208+ return &MockSecretService_ListKeys_Call{Call: _e.mock.On("ListKeys", ctx, domain)}
209+}
210+
211+func (_c *MockSecretService_ListKeys_Call) Run(run func(ctx context.Context, domain string)) *MockSecretService_ListKeys_Call {
212+ _c.Call.Run(func(args mock.Arguments) {
213+ var arg0 context.Context
214+ if args[0] != nil {
215+ arg0 = args[0].(context.Context)
216+ }
217+ var arg1 string
218+ if args[1] != nil {
219+ arg1 = args[1].(string)
220+ }
221+ run(
222+ arg0,
223+ arg1,
224+ )
225+ })
226+ return _c
227+}
228+
229+func (_c *MockSecretService_ListKeys_Call) Return(strings []string, err error) *MockSecretService_ListKeys_Call {
230+ _c.Call.Return(strings, err)
231+ return _c
232+}
233+
234+func (_c *MockSecretService_ListKeys_Call) RunAndReturn(run func(ctx context.Context, domain string) ([]string, error)) *MockSecretService_ListKeys_Call {
235+ _c.Call.Return(run)
236+ return _c
237+}
238+
239+// Set provides a mock function for the type MockSecretService
240+func (_mock *MockSecretService) Set(ctx context.Context, domain string, secrets map[string]string) error {
241+ ret := _mock.Called(ctx, domain, secrets)
242+
243+ if len(ret) == 0 {
244+ panic("no return value specified for Set")
245+ }
246+
247+ var r0 error
248+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, map[string]string) error); ok {
249+ r0 = returnFunc(ctx, domain, secrets)
250+ } else {
251+ r0 = ret.Error(0)
252+ }
253+ return r0
254+}
255+
256+// MockSecretService_Set_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Set'
257+type MockSecretService_Set_Call struct {
258+ *mock.Call
259+}
260+
261+// Set is a helper method to define mock.On call
262+// - ctx context.Context
263+// - domain string
264+// - secrets map[string]string
265+func (_e *MockSecretService_Expecter) Set(ctx interface{}, domain interface{}, secrets interface{}) *MockSecretService_Set_Call {
266+ return &MockSecretService_Set_Call{Call: _e.mock.On("Set", ctx, domain, secrets)}
267+}
268+
269+func (_c *MockSecretService_Set_Call) Run(run func(ctx context.Context, domain string, secrets map[string]string)) *MockSecretService_Set_Call {
270+ _c.Call.Run(func(args mock.Arguments) {
271+ var arg0 context.Context
272+ if args[0] != nil {
273+ arg0 = args[0].(context.Context)
274+ }
275+ var arg1 string
276+ if args[1] != nil {
277+ arg1 = args[1].(string)
278+ }
279+ var arg2 map[string]string
280+ if args[2] != nil {
281+ arg2 = args[2].(map[string]string)
282+ }
283+ run(
284+ arg0,
285+ arg1,
286+ arg2,
287+ )
288+ })
289+ return _c
290+}
291+
292+func (_c *MockSecretService_Set_Call) Return(err error) *MockSecretService_Set_Call {
293+ _c.Call.Return(err)
294+ return _c
295+}
296+
297+func (_c *MockSecretService_Set_Call) RunAndReturn(run func(ctx context.Context, domain string, secrets map[string]string) error) *MockSecretService_Set_Call {
298+ _c.Call.Return(run)
299+ return _c
300+}
M internal/boundaries/in/registry.go +3 -1
@@ -11,16 +11,18 @@ import (
11 type RegistryService interface {11 type RegistryService interface {
12 // Manifest operations12 // Manifest operations
13 GetManifest(ctx context.Context, name, reference string) (*domain.Manifest, error)13 GetManifest(ctx context.Context, name, reference string) (*domain.Manifest, error)
14- PutManifest(ctx context.Context, manifest *domain.Manifest) error14+ PutManifest(ctx context.Context, manifest *domain.Manifest) (digest string, err error)
15 DeleteManifest(ctx context.Context, name, reference string) error15 DeleteManifest(ctx context.Context, name, reference string) error
16 16
17 // Blob operations17 // Blob operations
18 GetBlob(ctx context.Context, digest string) (io.ReadCloser, error)18 GetBlob(ctx context.Context, digest string) (io.ReadCloser, error)
19+ GetBlobPath(ctx context.Context, digest string) (string, error)
19 PutBlob(ctx context.Context, digest string, data io.Reader, size int64) error20 PutBlob(ctx context.Context, digest string, data io.Reader, size int64) error
20 BlobExists(ctx context.Context, digest string) bool21 BlobExists(ctx context.Context, digest string) bool
21 22
22 // Upload operations23 // Upload operations
23 StartUpload(ctx context.Context, name string) (string, error)24 StartUpload(ctx context.Context, name string) (string, error)
25+ AppendBlobChunk(ctx context.Context, name, uuid string, chunk []byte) (int64, error)
24 FinishUpload(ctx context.Context, uuid, digest string) error26 FinishUpload(ctx context.Context, uuid, digest string) error
25 CancelUpload(ctx context.Context, uuid string) error27 CancelUpload(ctx context.Context, uuid string) error
26 28
A internal/boundaries/in/secrets.go +23 -0
@@ -0,0 +1,23 @@
1+package in
2+
3+import "context"
4+
5+// SecretService defines the contract for managing domain-scoped secrets.
6+// These are environment variables stored per-domain for container injection.
7+type SecretService interface {
8+ // ListKeys returns the list of secret keys for a domain (not values).
9+ // Returns an error if the domain is invalid.
10+ ListKeys(ctx context.Context, domain string) ([]string, error)
11+
12+ // GetAll returns all secrets for a domain as a key-value map.
13+ // Returns an error if the domain is invalid.
14+ GetAll(ctx context.Context, domain string) (map[string]string, error)
15+
16+ // Set sets or updates multiple secrets for a domain, merging with existing.
17+ // Returns an error if the domain is invalid.
18+ Set(ctx context.Context, domain string, secrets map[string]string) error
19+
20+ // Delete removes a specific secret key from a domain.
21+ // Returns an error if the domain is invalid.
22+ Delete(ctx context.Context, domain, key string) error
23+}
A internal/boundaries/out/domain_secrets.go +17 -0
@@ -0,0 +1,17 @@
1+package out
2+
3+// DomainSecretStore defines the contract for managing domain-scoped secrets.
4+// These are environment variables stored per-domain for container injection.
5+type DomainSecretStore interface {
6+ // ListKeys returns the list of secret keys for a domain (not values).
7+ ListKeys(domain string) ([]string, error)
8+
9+ // GetAll returns all secrets for a domain as a key-value map.
10+ GetAll(domain string) (map[string]string, error)
11+
12+ // Set sets or updates multiple secrets for a domain, merging with existing.
13+ Set(domain string, secrets map[string]string) error
14+
15+ // Delete removes a specific secret key from a domain.
16+ Delete(domain, key string) error
17+}
A internal/boundaries/out/mocks/mock_domain_secret_store.go +274 -0
@@ -0,0 +1,274 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ mock "github.com/stretchr/testify/mock"
9+)
10+
11+// NewMockDomainSecretStore creates a new instance of MockDomainSecretStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
12+// The first argument is typically a *testing.T value.
13+func NewMockDomainSecretStore(t interface {
14+ mock.TestingT
15+ Cleanup(func())
16+}) *MockDomainSecretStore {
17+ mock := &MockDomainSecretStore{}
18+ mock.Mock.Test(t)
19+
20+ t.Cleanup(func() { mock.AssertExpectations(t) })
21+
22+ return mock
23+}
24+
25+// MockDomainSecretStore is an autogenerated mock type for the DomainSecretStore type
26+type MockDomainSecretStore struct {
27+ mock.Mock
28+}
29+
30+type MockDomainSecretStore_Expecter struct {
31+ mock *mock.Mock
32+}
33+
34+func (_m *MockDomainSecretStore) EXPECT() *MockDomainSecretStore_Expecter {
35+ return &MockDomainSecretStore_Expecter{mock: &_m.Mock}
36+}
37+
38+// Delete provides a mock function for the type MockDomainSecretStore
39+func (_mock *MockDomainSecretStore) Delete(domain string, key string) error {
40+ ret := _mock.Called(domain, key)
41+
42+ if len(ret) == 0 {
43+ panic("no return value specified for Delete")
44+ }
45+
46+ var r0 error
47+ if returnFunc, ok := ret.Get(0).(func(string, string) error); ok {
48+ r0 = returnFunc(domain, key)
49+ } else {
50+ r0 = ret.Error(0)
51+ }
52+ return r0
53+}
54+
55+// MockDomainSecretStore_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'
56+type MockDomainSecretStore_Delete_Call struct {
57+ *mock.Call
58+}
59+
60+// Delete is a helper method to define mock.On call
61+// - domain string
62+// - key string
63+func (_e *MockDomainSecretStore_Expecter) Delete(domain interface{}, key interface{}) *MockDomainSecretStore_Delete_Call {
64+ return &MockDomainSecretStore_Delete_Call{Call: _e.mock.On("Delete", domain, key)}
65+}
66+
67+func (_c *MockDomainSecretStore_Delete_Call) Run(run func(domain string, key string)) *MockDomainSecretStore_Delete_Call {
68+ _c.Call.Run(func(args mock.Arguments) {
69+ var arg0 string
70+ if args[0] != nil {
71+ arg0 = args[0].(string)
72+ }
73+ var arg1 string
74+ if args[1] != nil {
75+ arg1 = args[1].(string)
76+ }
77+ run(
78+ arg0,
79+ arg1,
80+ )
81+ })
82+ return _c
83+}
84+
85+func (_c *MockDomainSecretStore_Delete_Call) Return(err error) *MockDomainSecretStore_Delete_Call {
86+ _c.Call.Return(err)
87+ return _c
88+}
89+
90+func (_c *MockDomainSecretStore_Delete_Call) RunAndReturn(run func(domain string, key string) error) *MockDomainSecretStore_Delete_Call {
91+ _c.Call.Return(run)
92+ return _c
93+}
94+
95+// GetAll provides a mock function for the type MockDomainSecretStore
96+func (_mock *MockDomainSecretStore) GetAll(domain string) (map[string]string, error) {
97+ ret := _mock.Called(domain)
98+
99+ if len(ret) == 0 {
100+ panic("no return value specified for GetAll")
101+ }
102+
103+ var r0 map[string]string
104+ var r1 error
105+ if returnFunc, ok := ret.Get(0).(func(string) (map[string]string, error)); ok {
106+ return returnFunc(domain)
107+ }
108+ if returnFunc, ok := ret.Get(0).(func(string) map[string]string); ok {
109+ r0 = returnFunc(domain)
110+ } else {
111+ if ret.Get(0) != nil {
112+ r0 = ret.Get(0).(map[string]string)
113+ }
114+ }
115+ if returnFunc, ok := ret.Get(1).(func(string) error); ok {
116+ r1 = returnFunc(domain)
117+ } else {
118+ r1 = ret.Error(1)
119+ }
120+ return r0, r1
121+}
122+
123+// MockDomainSecretStore_GetAll_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAll'
124+type MockDomainSecretStore_GetAll_Call struct {
125+ *mock.Call
126+}
127+
128+// GetAll is a helper method to define mock.On call
129+// - domain string
130+func (_e *MockDomainSecretStore_Expecter) GetAll(domain interface{}) *MockDomainSecretStore_GetAll_Call {
131+ return &MockDomainSecretStore_GetAll_Call{Call: _e.mock.On("GetAll", domain)}
132+}
133+
134+func (_c *MockDomainSecretStore_GetAll_Call) Run(run func(domain string)) *MockDomainSecretStore_GetAll_Call {
135+ _c.Call.Run(func(args mock.Arguments) {
136+ var arg0 string
137+ if args[0] != nil {
138+ arg0 = args[0].(string)
139+ }
140+ run(
141+ arg0,
142+ )
143+ })
144+ return _c
145+}
146+
147+func (_c *MockDomainSecretStore_GetAll_Call) Return(stringToString map[string]string, err error) *MockDomainSecretStore_GetAll_Call {
148+ _c.Call.Return(stringToString, err)
149+ return _c
150+}
151+
152+func (_c *MockDomainSecretStore_GetAll_Call) RunAndReturn(run func(domain string) (map[string]string, error)) *MockDomainSecretStore_GetAll_Call {
153+ _c.Call.Return(run)
154+ return _c
155+}
156+
157+// ListKeys provides a mock function for the type MockDomainSecretStore
158+func (_mock *MockDomainSecretStore) ListKeys(domain string) ([]string, error) {
159+ ret := _mock.Called(domain)
160+
161+ if len(ret) == 0 {
162+ panic("no return value specified for ListKeys")
163+ }
164+
165+ var r0 []string
166+ var r1 error
167+ if returnFunc, ok := ret.Get(0).(func(string) ([]string, error)); ok {
168+ return returnFunc(domain)
169+ }
170+ if returnFunc, ok := ret.Get(0).(func(string) []string); ok {
171+ r0 = returnFunc(domain)
172+ } else {
173+ if ret.Get(0) != nil {
174+ r0 = ret.Get(0).([]string)
175+ }
176+ }
177+ if returnFunc, ok := ret.Get(1).(func(string) error); ok {
178+ r1 = returnFunc(domain)
179+ } else {
180+ r1 = ret.Error(1)
181+ }
182+ return r0, r1
183+}
184+
185+// MockDomainSecretStore_ListKeys_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListKeys'
186+type MockDomainSecretStore_ListKeys_Call struct {
187+ *mock.Call
188+}
189+
190+// ListKeys is a helper method to define mock.On call
191+// - domain string
192+func (_e *MockDomainSecretStore_Expecter) ListKeys(domain interface{}) *MockDomainSecretStore_ListKeys_Call {
193+ return &MockDomainSecretStore_ListKeys_Call{Call: _e.mock.On("ListKeys", domain)}
194+}
195+
196+func (_c *MockDomainSecretStore_ListKeys_Call) Run(run func(domain string)) *MockDomainSecretStore_ListKeys_Call {
197+ _c.Call.Run(func(args mock.Arguments) {
198+ var arg0 string
199+ if args[0] != nil {
200+ arg0 = args[0].(string)
201+ }
202+ run(
203+ arg0,
204+ )
205+ })
206+ return _c
207+}
208+
209+func (_c *MockDomainSecretStore_ListKeys_Call) Return(strings []string, err error) *MockDomainSecretStore_ListKeys_Call {
210+ _c.Call.Return(strings, err)
211+ return _c
212+}
213+
214+func (_c *MockDomainSecretStore_ListKeys_Call) RunAndReturn(run func(domain string) ([]string, error)) *MockDomainSecretStore_ListKeys_Call {
215+ _c.Call.Return(run)
216+ return _c
217+}
218+
219+// Set provides a mock function for the type MockDomainSecretStore
220+func (_mock *MockDomainSecretStore) Set(domain string, secrets map[string]string) error {
221+ ret := _mock.Called(domain, secrets)
222+
223+ if len(ret) == 0 {
224+ panic("no return value specified for Set")
225+ }
226+
227+ var r0 error
228+ if returnFunc, ok := ret.Get(0).(func(string, map[string]string) error); ok {
229+ r0 = returnFunc(domain, secrets)
230+ } else {
231+ r0 = ret.Error(0)
232+ }
233+ return r0
234+}
235+
236+// MockDomainSecretStore_Set_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Set'
237+type MockDomainSecretStore_Set_Call struct {
238+ *mock.Call
239+}
240+
241+// Set is a helper method to define mock.On call
242+// - domain string
243+// - secrets map[string]string
244+func (_e *MockDomainSecretStore_Expecter) Set(domain interface{}, secrets interface{}) *MockDomainSecretStore_Set_Call {
245+ return &MockDomainSecretStore_Set_Call{Call: _e.mock.On("Set", domain, secrets)}
246+}
247+
248+func (_c *MockDomainSecretStore_Set_Call) Run(run func(domain string, secrets map[string]string)) *MockDomainSecretStore_Set_Call {
249+ _c.Call.Run(func(args mock.Arguments) {
250+ var arg0 string
251+ if args[0] != nil {
252+ arg0 = args[0].(string)
253+ }
254+ var arg1 map[string]string
255+ if args[1] != nil {
256+ arg1 = args[1].(map[string]string)
257+ }
258+ run(
259+ arg0,
260+ arg1,
261+ )
262+ })
263+ return _c
264+}
265+
266+func (_c *MockDomainSecretStore_Set_Call) Return(err error) *MockDomainSecretStore_Set_Call {
267+ _c.Call.Return(err)
268+ return _c
269+}
270+
271+func (_c *MockDomainSecretStore_Set_Call) RunAndReturn(run func(domain string, secrets map[string]string) error) *MockDomainSecretStore_Set_Call {
272+ _c.Call.Return(run)
273+ return _c
274+}
M internal/domain/auth.go +137 -0
@@ -64,6 +64,30 @@ const (
64 ScopeActionAll = "*"64 ScopeActionAll = "*"
65 )65 )
66 66
67+// Admin scope type constants.
68+const (
69+ // ScopeTypeRepository is for Docker registry repository access.
70+ ScopeTypeRepository = "repository"
71+ // ScopeTypeAdmin is for admin API access.
72+ ScopeTypeAdmin = "admin"
73+)
74+
75+// Admin scope resource constants.
76+const (
77+ AdminResourceRoutes = "routes"
78+ AdminResourceSecrets = "secrets"
79+ AdminResourceConfig = "config"
80+ AdminResourceStatus = "status"
81+ AdminResourceAll = "*"
82+)
83+
84+// Admin scope action constants.
85+const (
86+ AdminActionRead = "read"
87+ AdminActionWrite = "write"
88+ AdminActionAll = "*"
89+)
90+
67 // Scope represents a Docker registry scope (repository:name:actions).91 // Scope represents a Docker registry scope (repository:name:actions).
68 // Format follows the Docker Registry v2 token scope specification.92 // Format follows the Docker Registry v2 token scope specification.
69 type Scope struct {93 type Scope struct {
@@ -127,3 +151,116 @@ func (s *Scope) CanAccess(repoName string, action string) bool {
127 func (s *Scope) String() string {151 func (s *Scope) String() string {
128 return fmt.Sprintf("%s:%s:%s", s.Type, s.Name, strings.Join(s.Actions, ","))152 return fmt.Sprintf("%s:%s:%s", s.Type, s.Name, strings.Join(s.Actions, ","))
129 }153 }
154+
155+// IsAdminScope returns true if the scope is an admin scope.
156+func (s *Scope) IsAdminScope() bool {
157+ return s.Type == ScopeTypeAdmin
158+}
159+
160+// IsRepositoryScope returns true if the scope is a repository scope.
161+func (s *Scope) IsRepositoryScope() bool {
162+ return s.Type == ScopeTypeRepository
163+}
164+
165+// AdminScope represents an admin API scope (admin:resource:actions).
166+// Format: admin:routes:read,write or admin:*:* for full access.
167+type AdminScope struct {
168+ Resource string // routes, secrets, config, status, or *
169+ Actions []string // read, write, or *
170+}
171+
172+// ParseAdminScope parses an admin scope string.
173+// Format: admin:resource:action1,action2
174+func ParseAdminScope(s string) (*AdminScope, error) {
175+ parts := strings.SplitN(s, ":", 3)
176+ if len(parts) != 3 {
177+ return nil, fmt.Errorf("invalid admin scope format: %s", s)
178+ }
179+
180+ if parts[0] != ScopeTypeAdmin {
181+ return nil, fmt.Errorf("not an admin scope: %s", s)
182+ }
183+
184+ scope := &AdminScope{
185+ Resource: parts[1],
186+ Actions: strings.Split(parts[2], ","),
187+ }
188+
189+ // Trim whitespace from actions
190+ for i, action := range scope.Actions {
191+ scope.Actions[i] = strings.TrimSpace(action)
192+ }
193+
194+ return scope, nil
195+}
196+
197+// CanAccess checks if the admin scope grants the requested action on the resource.
198+func (s *AdminScope) CanAccess(resource string, action string) bool {
199+ // Check resource match
200+ if s.Resource != AdminResourceAll && s.Resource != resource {
201+ return false
202+ }
203+
204+ // Check action permission
205+ for _, a := range s.Actions {
206+ if a == AdminActionAll || a == action {
207+ return true
208+ }
209+ }
210+
211+ return false
212+}
213+
214+// String returns the admin scope as a string.
215+func (s *AdminScope) String() string {
216+ return fmt.Sprintf("%s:%s:%s", ScopeTypeAdmin, s.Resource, strings.Join(s.Actions, ","))
217+}
218+
219+// HasAdminAccess checks if a list of scope strings grants admin access to the resource and action.
220+func HasAdminAccess(scopes []string, resource, action string) bool {
221+ for _, scopeStr := range scopes {
222+ scope, err := ParseScope(scopeStr)
223+ if err != nil {
224+ continue
225+ }
226+
227+ if !scope.IsAdminScope() {
228+ continue
229+ }
230+
231+ adminScope, err := ParseAdminScope(scopeStr)
232+ if err != nil {
233+ continue
234+ }
235+
236+ if adminScope.CanAccess(resource, action) {
237+ return true
238+ }
239+ }
240+ return false
241+}
242+
243+// AdminScopeAll creates a full admin access scope string.
244+func AdminScopeAll() string {
245+ return fmt.Sprintf("%s:%s:%s", ScopeTypeAdmin, AdminResourceAll, AdminActionAll)
246+}
247+
248+// AdminScopeRoutes creates an admin scope for routes with the given actions.
249+func AdminScopeRoutes(actions ...string) string {
250+ return fmt.Sprintf("%s:%s:%s", ScopeTypeAdmin, AdminResourceRoutes, strings.Join(actions, ","))
251+}
252+
253+// AdminScopeSecrets creates an admin scope for secrets with the given actions.
254+func AdminScopeSecrets(actions ...string) string {
255+ return fmt.Sprintf("%s:%s:%s", ScopeTypeAdmin, AdminResourceSecrets, strings.Join(actions, ","))
256+}
257+
258+// AdminScopeConfig creates an admin scope for config with the given actions.
259+func AdminScopeConfig(actions ...string) string {
260+ return fmt.Sprintf("%s:%s:%s", ScopeTypeAdmin, AdminResourceConfig, strings.Join(actions, ","))
261+}
262+
263+// AdminScopeStatus creates an admin scope for status with the given actions.
264+func AdminScopeStatus(actions ...string) string {
265+ return fmt.Sprintf("%s:%s:%s", ScopeTypeAdmin, AdminResourceStatus, strings.Join(actions, ","))
266+}
M internal/domain/errors.go +2 -0
@@ -20,6 +20,8 @@ var (
20 ErrRouteNotFound = errors.New("route not found")20 ErrRouteNotFound = errors.New("route not found")
21 ErrRouteExists = errors.New("route already exists")21 ErrRouteExists = errors.New("route already exists")
22 ErrInvalidRoute = errors.New("invalid route configuration")22 ErrInvalidRoute = errors.New("invalid route configuration")
23+ ErrRouteDomainEmpty = errors.New("route domain cannot be empty")
24+ ErrRouteImageEmpty = errors.New("route image cannot be empty")
23 ErrNoTargetAvailable = errors.New("no target available for route")25 ErrNoTargetAvailable = errors.New("no target available for route")
24 26
25 // Registry errors27 // Registry errors
M internal/domain/labels.go +10 -0
@@ -13,4 +13,14 @@ const (
13 13
14 // Image labels (set in Dockerfile)14 // Image labels (set in Dockerfile)
15 LabelProxyPort = "gordon.proxy.port"15 LabelProxyPort = "gordon.proxy.port"
16+
17+ // Auto-route image labels (for automatic route discovery)
18+ // LabelDomains specifies multiple route domains (comma-separated).
19+ LabelDomains = "gordon.domains"
20+ // LabelHealth specifies the health check endpoint path.
21+ LabelHealth = "gordon.health"
22+ // LabelPort specifies the container port to proxy.
23+ LabelPort = "gordon.port"
24+ // LabelEnvFile specifies the path to .env file inside the image.
25+ LabelEnvFile = "gordon.env-file"
16 )26 )
M internal/domain/registry.go +9 -0
@@ -2,6 +2,15 @@ package domain
2 2
3 import "time"3 import "time"
4 4
5+// ImageLabels represents extracted Gordon labels from an image.
6+type ImageLabels struct {
7+ Domain string // Primary domain
8+ Domains []string // Additional domains
9+ Health string // Health check path
10+ Port string // Container port
11+ EnvFile string // Env file path in image
12+}
13+
5 // Manifest represents an OCI image manifest.14 // Manifest represents an OCI image manifest.
6 type Manifest struct {15 type Manifest struct {
7 Name string16 Name string
M internal/domain/route.go +10 -0
@@ -28,3 +28,13 @@ type ExternalRoute struct {
28 Host string // e.g., "localhost"28 Host string // e.g., "localhost"
29 Port int // e.g., 500029 Port int // e.g., 5000
30 }30 }
31+
32+// RouteHealth represents the health status of a route.
33+type RouteHealth struct {
34+ Domain string // The route domain
35+ ContainerStatus string // Container state: "running", "stopped", etc.
36+ HTTPStatus int // HTTP status code from probe (0 if unreachable)
37+ ResponseTimeMs int64 // Response time in milliseconds
38+ Healthy bool // True if container running AND HTTP 2xx/3xx
39+ Error string // Error message if probe failed
40+}
M internal/usecase/config/service.go +139 -21
@@ -5,6 +5,8 @@ import (
5 "context"5 "context"
6 "strings"6 "strings"
7 "sync"7 "sync"
8+ "sync/atomic"
9+ "time"
8 10
9 "github.com/bnema/zerowrap"11 "github.com/bnema/zerowrap"
10 "github.com/fsnotify/fsnotify"12 "github.com/fsnotify/fsnotify"
@@ -37,17 +39,20 @@ type Config struct {
37 39
38 // Service implements the ConfigService interface.40 // Service implements the ConfigService interface.
39 type Service struct {41 type Service struct {
40- viper *viper.Viper42+ viper *viper.Viper
41- eventBus out.EventPublisher43+ eventBus out.EventPublisher
42- config Config44+ config Config
43- mu sync.RWMutex45+ mu sync.RWMutex
46+ lastSaveTime int64 // Unix nano timestamp of last save (to debounce file watcher)
47+ debounceDelay int64 // Debounce delay in nanoseconds (default 500ms)
44 }48 }
45 49
46 // NewService creates a new config service.50 // NewService creates a new config service.
47 func NewService(v *viper.Viper, eventBus out.EventPublisher) *Service {51 func NewService(v *viper.Viper, eventBus out.EventPublisher) *Service {
48 return &Service{52 return &Service{
49- viper: v,53+ viper: v,
50- eventBus: eventBus,54+ eventBus: eventBus,
55+ debounceDelay: int64(500 * time.Millisecond), // 500ms debounce for file watcher
51 }56 }
52 }57 }
53 58
@@ -82,10 +87,16 @@ func (s *Service) Load(ctx context.Context) error {
82 87
83 // loadConfigValues loads simple config values from viper.88 // loadConfigValues loads simple config values from viper.
84 func (s *Service) loadConfigValues() Config {89 func (s *Service) loadConfigValues() Config {
90+ // Prefer gordon_domain over registry_domain
91+ registryDomain := s.viper.GetString("server.gordon_domain")
92+ if registryDomain == "" {
93+ registryDomain = s.viper.GetString("server.registry_domain")
94+ }
95+
85 return Config{96 return Config{
86 ServerPort: s.viper.GetInt("server.port"),97 ServerPort: s.viper.GetInt("server.port"),
87 RegistryPort: s.viper.GetInt("server.registry_port"),98 RegistryPort: s.viper.GetInt("server.registry_port"),
88- RegistryDomain: s.viper.GetString("server.registry_domain"),99+ RegistryDomain: registryDomain,
89 DataDir: s.viper.GetString("server.data_dir"),100 DataDir: s.viper.GetString("server.data_dir"),
90 AutoRouteEnabled: s.viper.GetBool("auto_route.enabled"),101 AutoRouteEnabled: s.viper.GetBool("auto_route.enabled"),
91 NetworkIsolation: s.viper.GetBool("network_isolation.enabled"),102 NetworkIsolation: s.viper.GetBool("network_isolation.enabled"),
@@ -171,7 +182,32 @@ func (s *Service) GetRoutes(_ context.Context) []domain.Route {
171 return routes182 return routes
172 }183 }
173 184
174-// AddRoute adds a new route to the configuration.185+// GetRoute returns a single route by domain.
186+func (s *Service) GetRoute(_ context.Context, domainName string) (*domain.Route, error) {
187+ s.mu.RLock()
188+ defer s.mu.RUnlock()
189+
190+ image, exists := s.config.Routes[domainName]
191+ if !exists {
192+ return nil, domain.ErrRouteNotFound
193+ }
194+
195+ route := &domain.Route{
196+ Domain: domainName,
197+ Image: image,
198+ HTTPS: true,
199+ }
200+
201+ // Check if domain has http:// prefix
202+ if strings.HasPrefix(domainName, "http://") {
203+ route.Domain = strings.TrimPrefix(domainName, "http://")
204+ route.HTTPS = false
205+ }
206+
207+ return route, nil
208+}
209+
210+// AddRoute adds a new route to the configuration and persists it.
175 func (s *Service) AddRoute(ctx context.Context, route domain.Route) error {211 func (s *Service) AddRoute(ctx context.Context, route domain.Route) error {
176 ctx = zerowrap.CtxWithFields(ctx, map[string]any{212 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
177 zerowrap.FieldLayer: "usecase",213 zerowrap.FieldLayer: "usecase",
@@ -180,20 +216,41 @@ func (s *Service) AddRoute(ctx context.Context, route domain.Route) error {
180 })216 })
181 log := zerowrap.FromCtx(ctx)217 log := zerowrap.FromCtx(ctx)
182 218
183- s.mu.Lock()219+ // Validate route
184- defer s.mu.Unlock()220+ if route.Domain == "" {
221+ return domain.ErrRouteDomainEmpty
222+ }
223+ if route.Image == "" {
224+ return domain.ErrRouteImageEmpty
225+ }
185 226
227+ // Store previous value for rollback
228+ s.mu.Lock()
229+ previousImage, existed := s.config.Routes[route.Domain]
186 if s.config.Routes == nil {230 if s.config.Routes == nil {
187 s.config.Routes = make(map[string]string)231 s.config.Routes = make(map[string]string)
188 }232 }
189-
190 s.config.Routes[route.Domain] = route.Image233 s.config.Routes[route.Domain] = route.Image
234+ s.mu.Unlock()
235+
236+ // Persist to disk - rollback on failure
237+ if err := s.Save(ctx); err != nil {
238+ log.Warn().Err(err).Msg("failed to persist route to disk, rolling back")
239+ s.mu.Lock()
240+ if existed {
241+ s.config.Routes[route.Domain] = previousImage
242+ } else {
243+ delete(s.config.Routes, route.Domain)
244+ }
245+ s.mu.Unlock()
246+ return err
247+ }
191 248
192 log.Info().Str("image", route.Image).Msg("route added to configuration")249 log.Info().Str("image", route.Image).Msg("route added to configuration")
193 return nil250 return nil
194 }251 }
195 252
196-// UpdateRoute updates an existing route.253+// UpdateRoute updates an existing route and persists it.
197 func (s *Service) UpdateRoute(ctx context.Context, route domain.Route) error {254 func (s *Service) UpdateRoute(ctx context.Context, route domain.Route) error {
198 ctx = zerowrap.CtxWithFields(ctx, map[string]any{255 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
199 zerowrap.FieldLayer: "usecase",256 zerowrap.FieldLayer: "usecase",
@@ -202,20 +259,38 @@ func (s *Service) UpdateRoute(ctx context.Context, route domain.Route) error {
202 })259 })
203 log := zerowrap.FromCtx(ctx)260 log := zerowrap.FromCtx(ctx)
204 261
205- s.mu.Lock()262+ // Validate route
206- defer s.mu.Unlock()263+ if route.Domain == "" {
264+ return domain.ErrRouteDomainEmpty
265+ }
266+ if route.Image == "" {
267+ return domain.ErrRouteImageEmpty
268+ }
207 269
208- if _, exists := s.config.Routes[route.Domain]; !exists {270+ // Store previous value for rollback
271+ s.mu.Lock()
272+ previousImage, exists := s.config.Routes[route.Domain]
273+ if !exists {
274+ s.mu.Unlock()
209 return domain.ErrRouteNotFound275 return domain.ErrRouteNotFound
210 }276 }
211-
212 s.config.Routes[route.Domain] = route.Image277 s.config.Routes[route.Domain] = route.Image
278+ s.mu.Unlock()
279+
280+ // Persist to disk - rollback on failure
281+ if err := s.Save(ctx); err != nil {
282+ log.Warn().Err(err).Msg("failed to persist route update to disk, rolling back")
283+ s.mu.Lock()
284+ s.config.Routes[route.Domain] = previousImage
285+ s.mu.Unlock()
286+ return err
287+ }
213 288
214 log.Info().Str("image", route.Image).Msg("route updated")289 log.Info().Str("image", route.Image).Msg("route updated")
215 return nil290 return nil
216 }291 }
217 292
218-// RemoveRoute removes a route from the configuration.293+// RemoveRoute removes a route from the configuration and persists it.
219 func (s *Service) RemoveRoute(ctx context.Context, domainName string) error {294 func (s *Service) RemoveRoute(ctx context.Context, domainName string) error {
220 ctx = zerowrap.CtxWithFields(ctx, map[string]any{295 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
221 zerowrap.FieldLayer: "usecase",296 zerowrap.FieldLayer: "usecase",
@@ -224,24 +299,67 @@ func (s *Service) RemoveRoute(ctx context.Context, domainName string) error {
224 })299 })
225 log := zerowrap.FromCtx(ctx)300 log := zerowrap.FromCtx(ctx)
226 301
302+ // Store previous value for rollback
227 s.mu.Lock()303 s.mu.Lock()
228- defer s.mu.Unlock()304+ previousImage, exists := s.config.Routes[domainName]
229-305+ if !exists {
230- if _, exists := s.config.Routes[domainName]; !exists {306+ s.mu.Unlock()
231 return domain.ErrRouteNotFound307 return domain.ErrRouteNotFound
232 }308 }
233-
234 delete(s.config.Routes, domainName)309 delete(s.config.Routes, domainName)
310+ s.mu.Unlock()
311+
312+ // Persist to disk - rollback on failure
313+ if err := s.Save(ctx); err != nil {
314+ log.Warn().Err(err).Msg("failed to persist route removal to disk, rolling back")
315+ s.mu.Lock()
316+ s.config.Routes[domainName] = previousImage
317+ s.mu.Unlock()
318+ return err
319+ }
235 320
236 log.Info().Msg("route removed")321 log.Info().Msg("route removed")
237 return nil322 return nil
238 }323 }
239 324
325+// Save persists the current configuration to disk.
326+func (s *Service) Save(ctx context.Context) error {
327+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
328+ zerowrap.FieldLayer: "usecase",
329+ zerowrap.FieldUseCase: "SaveConfig",
330+ })
331+ log := zerowrap.FromCtx(ctx)
332+
333+ s.mu.Lock()
334+ defer s.mu.Unlock()
335+
336+ // Update viper with current routes
337+ s.viper.Set("routes", s.config.Routes)
338+
339+ // Record save time to debounce file watcher events
340+ atomic.StoreInt64(&s.lastSaveTime, time.Now().UnixNano())
341+
342+ // Write config to disk
343+ if err := s.viper.WriteConfig(); err != nil {
344+ return log.WrapErr(err, "failed to write config")
345+ }
346+
347+ log.Info().Msg("configuration saved to disk")
348+ return nil
349+}
350+
240 // Watch starts watching for configuration changes.351 // Watch starts watching for configuration changes.
241 func (s *Service) Watch(ctx context.Context, onChange func()) error {352 func (s *Service) Watch(ctx context.Context, onChange func()) error {
242 log := zerowrap.FromCtx(ctx)353 log := zerowrap.FromCtx(ctx)
243 354
244 s.viper.OnConfigChange(func(e fsnotify.Event) {355 s.viper.OnConfigChange(func(e fsnotify.Event) {
356+ // Check if this event is within the debounce window of our own Save
357+ lastSave := atomic.LoadInt64(&s.lastSaveTime)
358+ if lastSave > 0 && time.Now().UnixNano()-lastSave < s.debounceDelay {
359+ log.Debug().Str("file", e.Name).Msg("skipping config reload (triggered by save)")
360+ return
361+ }
362+
245 log.Info().Str("file", e.Name).Msg("config file changed")363 log.Info().Str("file", e.Name).Msg("config file changed")
246 364
247 if err := s.viper.ReadInConfig(); err != nil {365 if err := s.viper.ReadInConfig(); err != nil {
M internal/usecase/config/service_test.go +155 -40
@@ -2,11 +2,14 @@ package config
2 2
3 import (3 import (
4 "context"4 "context"
5+ "os"
6+ "path/filepath"
5 "testing"7 "testing"
6 8
7 "github.com/bnema/zerowrap"9 "github.com/bnema/zerowrap"
8 "github.com/spf13/viper"10 "github.com/spf13/viper"
9 "github.com/stretchr/testify/assert"11 "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
10 13
11 "gordon/internal/boundaries/out/mocks"14 "gordon/internal/boundaries/out/mocks"
12 "gordon/internal/domain"15 "gordon/internal/domain"
@@ -95,73 +98,185 @@ func TestService_GetRoutes(t *testing.T) {
95 assert.False(t, routeMap["insecure.example"].HTTPS)98 assert.False(t, routeMap["insecure.example"].HTTPS)
96 }99 }
97 100
98-func TestService_AddRoute(t *testing.T) {101+func TestService_GetRoute(t *testing.T) {
99 v := viper.New()102 v := viper.New()
103+ v.Set("routes", map[string]interface{}{
104+ "app.example.com": "myapp:latest",
105+ "http://insecure.example": "insecureapp:latest",
106+ })
107+
100 eventBus := mocks.NewMockEventPublisher(t)108 eventBus := mocks.NewMockEventPublisher(t)
101 svc := NewService(v, eventBus)109 svc := NewService(v, eventBus)
102 ctx := testContext()110 ctx := testContext()
103 111
104 _ = svc.Load(ctx)112 _ = svc.Load(ctx)
105 113
106- route := domain.Route{114+ t.Run("existing route", func(t *testing.T) {
107- Domain: "new.example.com",115+ route, err := svc.GetRoute(ctx, "app.example.com")
108- Image: "newapp:latest",116+ require.NoError(t, err)
109- }117+ assert.Equal(t, "app.example.com", route.Domain)
118+ assert.Equal(t, "myapp:latest", route.Image)
119+ assert.True(t, route.HTTPS)
120+ })
110 121
111- err := svc.AddRoute(ctx, route)122+ t.Run("non-existent route", func(t *testing.T) {
123+ route, err := svc.GetRoute(ctx, "notfound.example.com")
124+ assert.ErrorIs(t, err, domain.ErrRouteNotFound)
125+ assert.Nil(t, route)
126+ })
127+}
112 128
113- assert.NoError(t, err)129+func TestService_AddRoute(t *testing.T) {
130+ t.Run("success", func(t *testing.T) {
131+ // Create temp config file for Save() to work
132+ tmpDir := t.TempDir()
133+ configFile := filepath.Join(tmpDir, "gordon.toml")
134+ err := os.WriteFile(configFile, []byte("[routes]\n"), 0600)
135+ require.NoError(t, err)
114 136
115- // Verify route was added137+ v := viper.New()
116- config := svc.GetConfig()138+ v.SetConfigFile(configFile)
117- assert.Equal(t, "newapp:latest", config.Routes["new.example.com"])139+ eventBus := mocks.NewMockEventPublisher(t)
118-}140+ svc := NewService(v, eventBus)
141+ ctx := testContext()
119 142
120-func TestService_UpdateRoute(t *testing.T) {143+ _ = svc.Load(ctx)
121- v := viper.New()144+
122- v.Set("routes", map[string]interface{}{145+ route := domain.Route{
123- "app.example.com": "myapp:v1",146+ Domain: "new.example.com",
147+ Image: "newapp:latest",
148+ }
149+
150+ err = svc.AddRoute(ctx, route)
151+
152+ assert.NoError(t, err)
153+
154+ // Verify route was added
155+ config := svc.GetConfig()
156+ assert.Equal(t, "newapp:latest", config.Routes["new.example.com"])
124 })157 })
125 158
126- eventBus := mocks.NewMockEventPublisher(t)159+ t.Run("empty domain", func(t *testing.T) {
127- svc := NewService(v, eventBus)160+ v := viper.New()
128- ctx := testContext()161+ eventBus := mocks.NewMockEventPublisher(t)
162+ svc := NewService(v, eventBus)
163+ ctx := testContext()
129 164
130- _ = svc.Load(ctx)165+ route := domain.Route{
166+ Domain: "",
167+ Image: "myapp:latest",
168+ }
131 169
132- route := domain.Route{170+ err := svc.AddRoute(ctx, route)
133- Domain: "app.example.com",171+ assert.ErrorIs(t, err, domain.ErrRouteDomainEmpty)
134- Image: "myapp:v2",172+ })
135- }
136 173
137- err := svc.UpdateRoute(ctx, route)174+ t.Run("empty image", func(t *testing.T) {
175+ v := viper.New()
176+ eventBus := mocks.NewMockEventPublisher(t)
177+ svc := NewService(v, eventBus)
178+ ctx := testContext()
138 179
139- assert.NoError(t, err)180+ route := domain.Route{
181+ Domain: "example.com",
182+ Image: "",
183+ }
140 184
141- config := svc.GetConfig()185+ err := svc.AddRoute(ctx, route)
142- assert.Equal(t, "myapp:v2", config.Routes["app.example.com"])186+ assert.ErrorIs(t, err, domain.ErrRouteImageEmpty)
187+ })
143 }188 }
144 189
145-func TestService_UpdateRoute_NotFound(t *testing.T) {190+func TestService_UpdateRoute(t *testing.T) {
146- v := viper.New()191+ t.Run("success", func(t *testing.T) {
147- eventBus := mocks.NewMockEventPublisher(t)192+ // Create temp config file for Save() to work
148- svc := NewService(v, eventBus)193+ tmpDir := t.TempDir()
149- ctx := testContext()194+ configFile := filepath.Join(tmpDir, "gordon.toml")
195+ err := os.WriteFile(configFile, []byte("[routes]\n\"app.example.com\" = \"myapp:v1\"\n"), 0600)
196+ require.NoError(t, err)
197+
198+ v := viper.New()
199+ v.SetConfigFile(configFile)
200+ v.Set("routes", map[string]any{
201+ "app.example.com": "myapp:v1",
202+ })
150 203
151- _ = svc.Load(ctx)204+ eventBus := mocks.NewMockEventPublisher(t)
205+ svc := NewService(v, eventBus)
206+ ctx := testContext()
152 207
153- route := domain.Route{208+ _ = svc.Load(ctx)
154- Domain: "nonexistent.example.com",
155- Image: "myapp:latest",
156- }
157 209
158- err := svc.UpdateRoute(ctx, route)210+ route := domain.Route{
211+ Domain: "app.example.com",
212+ Image: "myapp:v2",
213+ }
159 214
160- assert.ErrorIs(t, err, domain.ErrRouteNotFound)215+ err = svc.UpdateRoute(ctx, route)
216+
217+ assert.NoError(t, err)
218+
219+ config := svc.GetConfig()
220+ assert.Equal(t, "myapp:v2", config.Routes["app.example.com"])
221+ })
222+
223+ t.Run("not found", func(t *testing.T) {
224+ v := viper.New()
225+ eventBus := mocks.NewMockEventPublisher(t)
226+ svc := NewService(v, eventBus)
227+ ctx := testContext()
228+
229+ _ = svc.Load(ctx)
230+
231+ route := domain.Route{
232+ Domain: "nonexistent.example.com",
233+ Image: "myapp:latest",
234+ }
235+
236+ err := svc.UpdateRoute(ctx, route)
237+ assert.ErrorIs(t, err, domain.ErrRouteNotFound)
238+ })
239+
240+ t.Run("empty domain", func(t *testing.T) {
241+ v := viper.New()
242+ eventBus := mocks.NewMockEventPublisher(t)
243+ svc := NewService(v, eventBus)
244+ ctx := testContext()
245+
246+ route := domain.Route{
247+ Domain: "",
248+ Image: "myapp:latest",
249+ }
250+
251+ err := svc.UpdateRoute(ctx, route)
252+ assert.ErrorIs(t, err, domain.ErrRouteDomainEmpty)
253+ })
254+
255+ t.Run("empty image", func(t *testing.T) {
256+ v := viper.New()
257+ eventBus := mocks.NewMockEventPublisher(t)
258+ svc := NewService(v, eventBus)
259+ ctx := testContext()
260+
261+ route := domain.Route{
262+ Domain: "example.com",
263+ Image: "",
264+ }
265+
266+ err := svc.UpdateRoute(ctx, route)
267+ assert.ErrorIs(t, err, domain.ErrRouteImageEmpty)
268+ })
161 }269 }
162 270
163 func TestService_RemoveRoute(t *testing.T) {271 func TestService_RemoveRoute(t *testing.T) {
272+ // Create temp config file for Save() to work
273+ tmpDir := t.TempDir()
274+ configFile := filepath.Join(tmpDir, "gordon.toml")
275+ err := os.WriteFile(configFile, []byte("[routes]\n\"app.example.com\" = \"myapp:latest\"\n"), 0600)
276+ require.NoError(t, err)
277+
164 v := viper.New()278 v := viper.New()
279+ v.SetConfigFile(configFile)
165 v.Set("routes", map[string]interface{}{280 v.Set("routes", map[string]interface{}{
166 "app.example.com": "myapp:latest",281 "app.example.com": "myapp:latest",
167 })282 })
@@ -172,7 +287,7 @@ func TestService_RemoveRoute(t *testing.T) {
172 287
173 _ = svc.Load(ctx)288 _ = svc.Load(ctx)
174 289
175- err := svc.RemoveRoute(ctx, "app.example.com")290+ err = svc.RemoveRoute(ctx, "app.example.com")
176 291
177 assert.NoError(t, err)292 assert.NoError(t, err)
178 293
A internal/usecase/container/autoroute.go +500 -0
@@ -0,0 +1,500 @@
1+package container
2+
3+import (
4+ "bufio"
5+ "bytes"
6+ "context"
7+ "encoding/json"
8+ "fmt"
9+ "io"
10+ "os"
11+ "path/filepath"
12+ "sort"
13+ "strings"
14+
15+ "github.com/bnema/zerowrap"
16+
17+ "gordon/internal/boundaries/in"
18+ "gordon/internal/boundaries/out"
19+ "gordon/internal/domain"
20+)
21+
22+// EnvFileExtractor defines the interface for extracting env files from images.
23+type EnvFileExtractor interface {
24+ ExtractEnvFileFromImage(ctx context.Context, imageRef, envFilePath string) ([]byte, error)
25+}
26+
27+// AutoRouteHandler handles image.pushed events for auto-route from labels.
28+type AutoRouteHandler struct {
29+ configSvc in.ConfigService
30+ containerSvc in.ContainerService
31+ blobStorage out.BlobStorage
32+ extractor EnvFileExtractor
33+ registryDomain string
34+ envDir string
35+ ctx context.Context
36+}
37+
38+// NewAutoRouteHandler creates a new AutoRouteHandler.
39+func NewAutoRouteHandler(
40+ ctx context.Context,
41+ configSvc in.ConfigService,
42+ containerSvc in.ContainerService,
43+ blobStorage out.BlobStorage,
44+ registryDomain string,
45+) *AutoRouteHandler {
46+ return &AutoRouteHandler{
47+ configSvc: configSvc,
48+ containerSvc: containerSvc,
49+ blobStorage: blobStorage,
50+ registryDomain: registryDomain,
51+ ctx: ctx,
52+ }
53+}
54+
55+// WithEnvExtractor sets the env file extractor for auto env-file copy feature.
56+func (h *AutoRouteHandler) WithEnvExtractor(extractor EnvFileExtractor, envDir string) *AutoRouteHandler {
57+ h.extractor = extractor
58+ h.envDir = envDir
59+ return h
60+}
61+
62+// Handle handles an image.pushed event and creates routes from labels.
63+func (h *AutoRouteHandler) Handle(event domain.Event) error {
64+ ctx := zerowrap.CtxWithFields(h.ctx, map[string]any{
65+ zerowrap.FieldLayer: "usecase",
66+ zerowrap.FieldHandler: "AutoRouteHandler",
67+ zerowrap.FieldEvent: string(event.Type),
68+ "event_id": event.ID,
69+ })
70+ log := zerowrap.FromCtx(ctx)
71+
72+ if !h.configSvc.IsAutoRouteEnabled() {
73+ log.Debug().Msg("auto-route disabled, skipping label extraction")
74+ return nil
75+ }
76+
77+ payload, ok := event.Data.(domain.ImagePushedPayload)
78+ if !ok {
79+ log.Warn().Msg("invalid event payload type")
80+ return nil
81+ }
82+
83+ if len(payload.Manifest) == 0 {
84+ log.Debug().Msg("no manifest data in event, skipping")
85+ return nil
86+ }
87+
88+ log.Info().
89+ Str("image", payload.Name).
90+ Str("reference", payload.Reference).
91+ Msg("processing image for auto-route labels")
92+
93+ labels, err := h.extractLabels(ctx, payload.Manifest)
94+ if err != nil {
95+ log.Debug().Err(err).Msg("failed to extract labels, skipping auto-route")
96+ return nil
97+ }
98+
99+ domains := h.collectDomains(labels)
100+ if len(domains) == 0 {
101+ log.Debug().Str("image", payload.Name).Msg("no gordon.domain label found, skipping")
102+ return nil
103+ }
104+
105+ imageName := h.buildImageName(payload.Name, payload.Reference)
106+ h.processRoutes(ctx, domains, imageName, labels)
107+
108+ return nil
109+}
110+
111+// collectDomains collects all domains from labels.
112+func (h *AutoRouteHandler) collectDomains(labels *domain.ImageLabels) []string {
113+ var domains []string
114+ if labels.Domain != "" {
115+ domains = append(domains, labels.Domain)
116+ }
117+ domains = append(domains, labels.Domains...)
118+ return domains
119+}
120+
121+// buildImageName constructs the full image name with tag or digest.
122+// For digest references (sha256:...), uses @ separator per Docker spec.
123+// For tags, uses : separator.
124+func (h *AutoRouteHandler) buildImageName(name, reference string) string {
125+ if reference == "" {
126+ return name
127+ }
128+ // Digest references use @ separator, tags use :
129+ if strings.HasPrefix(reference, "sha256:") {
130+ return fmt.Sprintf("%s@%s", name, reference)
131+ }
132+ return fmt.Sprintf("%s:%s", name, reference)
133+}
134+
135+// buildFullImageRef constructs a fully qualified image reference with registry domain.
136+// This is needed for Docker operations since images are stored with the registry prefix.
137+func (h *AutoRouteHandler) buildFullImageRef(imageName string) string {
138+ if h.registryDomain == "" {
139+ return imageName
140+ }
141+
142+ // Don't prefix if already has registry domain
143+ if strings.HasPrefix(imageName, h.registryDomain+"/") {
144+ return imageName
145+ }
146+
147+ // Don't prefix if it's an external registry reference (contains dots and slashes)
148+ repoPart := strings.Split(imageName, ":")[0]
149+ if strings.Contains(repoPart, "@") {
150+ repoPart = strings.Split(imageName, "@")[0]
151+ }
152+ if strings.Contains(repoPart, ".") && strings.Contains(repoPart, "/") {
153+ return imageName
154+ }
155+
156+ return fmt.Sprintf("%s/%s", h.registryDomain, imageName)
157+}
158+
159+// processRoutes processes each domain and creates/updates routes.
160+func (h *AutoRouteHandler) processRoutes(ctx context.Context, domains []string, imageName string, labels *domain.ImageLabels) {
161+ log := zerowrap.FromCtx(ctx)
162+
163+ // Build full image reference for env extraction (Docker needs fully qualified name)
164+ fullImageRef := h.buildFullImageRef(imageName)
165+
166+ for _, routeDomain := range domains {
167+ routeDomain = strings.TrimSpace(routeDomain)
168+ if routeDomain == "" {
169+ continue
170+ }
171+
172+ h.createOrUpdateRoute(ctx, routeDomain, imageName)
173+
174+ if labels.EnvFile != "" && h.extractor != nil && h.envDir != "" {
175+ // Use full image ref for extraction (Docker knows image by this name)
176+ if err := h.extractAndMergeEnvFile(ctx, fullImageRef, routeDomain, labels.EnvFile); err != nil {
177+ log.Warn().
178+ Err(err).
179+ Str("domain", routeDomain).
180+ Str("env_file", labels.EnvFile).
181+ Msg("failed to extract env file from image")
182+ }
183+ }
184+ }
185+}
186+
187+// createOrUpdateRoute creates a new route or updates an existing one.
188+// It also triggers deployment for new routes since the ImagePushedHandler
189+// may have already finished processing (handlers run concurrently).
190+func (h *AutoRouteHandler) createOrUpdateRoute(ctx context.Context, routeDomain, imageName string) {
191+ log := zerowrap.FromCtx(ctx)
192+
193+ route := domain.Route{
194+ Domain: routeDomain,
195+ Image: imageName,
196+ }
197+
198+ existingRoutes := h.configSvc.GetRoutes(ctx)
199+ for _, existing := range existingRoutes {
200+ if existing.Domain == routeDomain {
201+ if existing.Image != imageName {
202+ if err := h.configSvc.UpdateRoute(ctx, route); err != nil {
203+ log.Warn().Err(err).Str("domain", routeDomain).Msg("failed to update route")
204+ } else {
205+ log.Info().Str("domain", routeDomain).Str("image", imageName).Msg("auto-route updated from image labels")
206+ // Trigger deploy for updated route
207+ h.triggerDeploy(ctx, route)
208+ }
209+ }
210+ return
211+ }
212+ }
213+
214+ if err := h.configSvc.AddRoute(ctx, route); err != nil {
215+ log.Warn().Err(err).Str("domain", routeDomain).Msg("failed to add route")
216+ } else {
217+ log.Info().Str("domain", routeDomain).Str("image", imageName).Msg("auto-route added from image labels")
218+ // Trigger deploy for new route since ImagePushedHandler may have already finished
219+ h.triggerDeploy(ctx, route)
220+ }
221+}
222+
223+// triggerDeploy initiates deployment for a route.
224+func (h *AutoRouteHandler) triggerDeploy(ctx context.Context, route domain.Route) {
225+ log := zerowrap.FromCtx(ctx)
226+
227+ if h.containerSvc == nil {
228+ log.Debug().Str("domain", route.Domain).Msg("no container service, skipping deploy trigger")
229+ return
230+ }
231+
232+ // Mark context as internal deploy - the event originated from our own registry,
233+ // so we can use internal registry (localhost) for image pulls.
234+ internalCtx := domain.WithInternalDeploy(ctx)
235+
236+ if _, err := h.containerSvc.Deploy(internalCtx, route); err != nil {
237+ log.Warn().Err(err).Str("domain", route.Domain).Msg("failed to trigger deploy for auto-route")
238+ } else {
239+ log.Info().Str("domain", route.Domain).Msg("deploy triggered for auto-route")
240+ }
241+}
242+
243+// extractAndMergeEnvFile extracts an env file from the image and merges it with existing secrets.
244+func (h *AutoRouteHandler) extractAndMergeEnvFile(ctx context.Context, imageRef, routeDomain, envFilePath string) error {
245+ log := zerowrap.FromCtx(ctx)
246+
247+ log.Info().
248+ Str("domain", routeDomain).
249+ Str("image", imageRef).
250+ Str("env_file", envFilePath).
251+ Msg("extracting env file from image")
252+
253+ // Extract env file from image
254+ envData, err := h.extractor.ExtractEnvFileFromImage(ctx, imageRef, envFilePath)
255+ if err != nil {
256+ return fmt.Errorf("failed to extract env file: %w", err)
257+ }
258+
259+ if len(envData) == 0 {
260+ log.Debug().Msg("env file is empty, skipping")
261+ return nil
262+ }
263+
264+ // Parse the extracted env file
265+ imageEnv, err := parseEnvFile(envData)
266+ if err != nil {
267+ return fmt.Errorf("failed to parse env file: %w", err)
268+ }
269+
270+ if len(imageEnv) == 0 {
271+ log.Debug().Msg("no environment variables found in env file")
272+ return nil
273+ }
274+
275+ // Load existing env file for this domain
276+ envFileName := domainToEnvFileName(routeDomain)
277+ envFileDst := filepath.Join(h.envDir, envFileName)
278+
279+ existingEnv := make(map[string]string)
280+ if data, err := os.ReadFile(envFileDst); err == nil {
281+ existingEnv, _ = parseEnvFile(data)
282+ }
283+
284+ // Merge: image values are defaults, existing values win
285+ merged := make(map[string]string)
286+ for k, v := range imageEnv {
287+ merged[k] = v
288+ }
289+ for k, v := range existingEnv {
290+ merged[k] = v // Existing values override image values
291+ }
292+
293+ // Write merged env file
294+ if err := os.MkdirAll(h.envDir, 0700); err != nil {
295+ return fmt.Errorf("failed to create env directory: %w", err)
296+ }
297+
298+ if err := writeEnvFile(envFileDst, merged); err != nil {
299+ return fmt.Errorf("failed to write env file: %w", err)
300+ }
301+
302+ log.Info().
303+ Str("domain", routeDomain).
304+ Int("image_vars", len(imageEnv)).
305+ Int("existing_vars", len(existingEnv)).
306+ Int("merged_vars", len(merged)).
307+ Msg("env file extracted and merged from image")
308+
309+ return nil
310+}
311+
312+// CanHandle returns whether this handler can handle the given event type.
313+func (h *AutoRouteHandler) CanHandle(eventType domain.EventType) bool {
314+ return eventType == domain.EventImagePushed
315+}
316+
317+// extractLabels extracts Gordon labels from an image manifest.
318+func (h *AutoRouteHandler) extractLabels(ctx context.Context, manifestData []byte) (*domain.ImageLabels, error) {
319+ log := zerowrap.FromCtx(ctx)
320+
321+ // Parse manifest to get config digest
322+ configDigest, err := parseConfigDigest(manifestData)
323+ if err != nil {
324+ return nil, fmt.Errorf("failed to parse manifest: %w", err)
325+ }
326+
327+ if configDigest == "" {
328+ return nil, fmt.Errorf("no config digest found in manifest")
329+ }
330+
331+ log.Debug().Str("config_digest", configDigest).Msg("found config digest")
332+
333+ // Read config blob
334+ reader, err := h.blobStorage.GetBlob(configDigest)
335+ if err != nil {
336+ return nil, fmt.Errorf("failed to get config blob: %w", err)
337+ }
338+ defer reader.Close()
339+
340+ configData, err := io.ReadAll(reader)
341+ if err != nil {
342+ return nil, fmt.Errorf("failed to read config blob: %w", err)
343+ }
344+
345+ // Parse config to extract labels
346+ labels, err := parseImageLabels(configData)
347+ if err != nil {
348+ return nil, fmt.Errorf("failed to parse config: %w", err)
349+ }
350+
351+ return labels, nil
352+}
353+
354+// manifestSchema represents the relevant parts of an OCI/Docker manifest.
355+type manifestSchema struct {
356+ SchemaVersion int `json:"schemaVersion"`
357+ MediaType string `json:"mediaType"`
358+ Config struct {
359+ MediaType string `json:"mediaType"`
360+ Digest string `json:"digest"`
361+ Size int64 `json:"size"`
362+ } `json:"config"`
363+}
364+
365+// parseConfigDigest extracts the config digest from a manifest.
366+func parseConfigDigest(manifestData []byte) (string, error) {
367+ var manifest manifestSchema
368+ if err := json.Unmarshal(manifestData, &manifest); err != nil {
369+ return "", err
370+ }
371+
372+ return manifest.Config.Digest, nil
373+}
374+
375+// imageConfig represents the relevant parts of an OCI/Docker image config.
376+type imageConfig struct {
377+ Config struct {
378+ Labels map[string]string `json:"Labels"`
379+ } `json:"config"`
380+}
381+
382+// parseImageLabels extracts Gordon labels from an image config.
383+func parseImageLabels(configData []byte) (*domain.ImageLabels, error) {
384+ var config imageConfig
385+ if err := json.Unmarshal(configData, &config); err != nil {
386+ return nil, err
387+ }
388+
389+ labels := &domain.ImageLabels{}
390+
391+ if config.Config.Labels == nil {
392+ return labels, nil
393+ }
394+
395+ // Extract gordon.* labels
396+ if v, ok := config.Config.Labels[domain.LabelDomain]; ok {
397+ labels.Domain = strings.TrimSpace(v)
398+ }
399+
400+ if v, ok := config.Config.Labels[domain.LabelDomains]; ok {
401+ // Parse comma-separated domains
402+ for _, d := range strings.Split(v, ",") {
403+ d = strings.TrimSpace(d)
404+ if d != "" {
405+ labels.Domains = append(labels.Domains, d)
406+ }
407+ }
408+ }
409+
410+ if v, ok := config.Config.Labels[domain.LabelHealth]; ok {
411+ labels.Health = strings.TrimSpace(v)
412+ }
413+
414+ if v, ok := config.Config.Labels[domain.LabelPort]; ok {
415+ labels.Port = strings.TrimSpace(v)
416+ }
417+
418+ if v, ok := config.Config.Labels[domain.LabelEnvFile]; ok {
419+ labels.EnvFile = strings.TrimSpace(v)
420+ }
421+
422+ return labels, nil
423+}
424+
425+// parseEnvFile parses an env file into a map of key-value pairs.
426+func parseEnvFile(data []byte) (map[string]string, error) {
427+ env := make(map[string]string)
428+
429+ scanner := bufio.NewScanner(bytes.NewReader(data))
430+ for scanner.Scan() {
431+ line := strings.TrimSpace(scanner.Text())
432+
433+ // Skip empty lines and comments
434+ if line == "" || strings.HasPrefix(line, "#") {
435+ continue
436+ }
437+
438+ // Parse KEY=value
439+ parts := strings.SplitN(line, "=", 2)
440+ if len(parts) != 2 {
441+ continue
442+ }
443+
444+ key := strings.TrimSpace(parts[0])
445+ value := strings.TrimSpace(parts[1])
446+
447+ // Remove surrounding quotes if present
448+ if len(value) >= 2 {
449+ if (value[0] == '"' && value[len(value)-1] == '"') ||
450+ (value[0] == '\'' && value[len(value)-1] == '\'') {
451+ value = value[1 : len(value)-1]
452+ }
453+ }
454+
455+ if key != "" {
456+ env[key] = value
457+ }
458+ }
459+
460+ if err := scanner.Err(); err != nil {
461+ return nil, err
462+ }
463+
464+ return env, nil
465+}
466+
467+// domainToEnvFileName converts a domain to an env file name.
468+// e.g., "app.mydomain.com" -> "app_mydomain_com.env"
469+// Must match the naming convention in envloader.FileLoader.getEnvFilePath
470+func domainToEnvFileName(domainName string) string {
471+ // Replace special characters with underscores (matches envloader)
472+ name := strings.ReplaceAll(domainName, ".", "_")
473+ name = strings.ReplaceAll(name, ":", "_")
474+ name = strings.ReplaceAll(name, "/", "_")
475+ return name + ".env"
476+}
477+
478+// writeEnvFile writes environment variables to a file.
479+func writeEnvFile(path string, env map[string]string) error {
480+ var buf bytes.Buffer
481+
482+ // Sort keys for consistent output
483+ keys := make([]string, 0, len(env))
484+ for k := range env {
485+ keys = append(keys, k)
486+ }
487+ sort.Strings(keys)
488+
489+ // Write each key-value pair
490+ for _, k := range keys {
491+ v := env[k]
492+ // Quote values that contain special characters
493+ if strings.ContainsAny(v, " \t\n\"'$\\") {
494+ v = fmt.Sprintf("\"%s\"", strings.ReplaceAll(v, "\"", "\\\""))
495+ }
496+ buf.WriteString(fmt.Sprintf("%s=%s\n", k, v))
497+ }
498+
499+ return os.WriteFile(path, buf.Bytes(), 0600)
500+}
A internal/usecase/container/autoroute_test.go +888 -0
@@ -0,0 +1,888 @@
1+package container
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "io"
7+ "strings"
8+ "testing"
9+
10+ "github.com/bnema/zerowrap"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/mock"
13+ "github.com/stretchr/testify/require"
14+
15+ inmocks "gordon/internal/boundaries/in/mocks"
16+ "gordon/internal/boundaries/out/mocks"
17+ "gordon/internal/domain"
18+)
19+
20+// domainToEnvFileName tests
21+
22+func TestDomainToEnvFileName(t *testing.T) {
23+ tests := []struct {
24+ name string
25+ domain string
26+ expected string
27+ }{
28+ {
29+ name: "simple domain",
30+ domain: "app.example.com",
31+ expected: "app_example_com.env",
32+ },
33+ {
34+ name: "domain with port",
35+ domain: "app.example.com:8080",
36+ expected: "app_example_com_8080.env",
37+ },
38+ {
39+ name: "domain with path",
40+ domain: "app.example.com/api",
41+ expected: "app_example_com_api.env",
42+ },
43+ {
44+ name: "complex domain",
45+ domain: "sub.app.example.com:443/v1",
46+ expected: "sub_app_example_com_443_v1.env",
47+ },
48+ {
49+ name: "single word",
50+ domain: "localhost",
51+ expected: "localhost.env",
52+ },
53+ }
54+
55+ for _, tt := range tests {
56+ t.Run(tt.name, func(t *testing.T) {
57+ result := domainToEnvFileName(tt.domain)
58+ assert.Equal(t, tt.expected, result)
59+ })
60+ }
61+}
62+
63+// parseEnvFile tests
64+
65+func TestParseEnvFile(t *testing.T) {
66+ tests := []struct {
67+ name string
68+ input string
69+ expected map[string]string
70+ wantErr bool
71+ }{
72+ {
73+ name: "simple key-value pairs",
74+ input: "FOO=bar\nBAZ=qux",
75+ expected: map[string]string{
76+ "FOO": "bar",
77+ "BAZ": "qux",
78+ },
79+ },
80+ {
81+ name: "with comments and empty lines",
82+ input: "# This is a comment\nFOO=bar\n\n# Another comment\nBAZ=qux\n",
83+ expected: map[string]string{
84+ "FOO": "bar",
85+ "BAZ": "qux",
86+ },
87+ },
88+ {
89+ name: "with quoted values - double quotes",
90+ input: `FOO="bar baz"`,
91+ expected: map[string]string{
92+ "FOO": "bar baz",
93+ },
94+ },
95+ {
96+ name: "with quoted values - single quotes",
97+ input: `FOO='bar baz'`,
98+ expected: map[string]string{
99+ "FOO": "bar baz",
100+ },
101+ },
102+ {
103+ name: "value with equals sign",
104+ input: "DATABASE_URL=postgres://user:pass@host/db?option=value",
105+ expected: map[string]string{
106+ "DATABASE_URL": "postgres://user:pass@host/db?option=value",
107+ },
108+ },
109+ {
110+ name: "empty value",
111+ input: "EMPTY=",
112+ expected: map[string]string{
113+ "EMPTY": "",
114+ },
115+ },
116+ {
117+ name: "empty file",
118+ input: "",
119+ expected: map[string]string{},
120+ },
121+ {
122+ name: "only comments",
123+ input: "# comment 1\n# comment 2",
124+ expected: map[string]string{},
125+ },
126+ {
127+ name: "malformed line without equals",
128+ input: "MALFORMED\nGOOD=value",
129+ expected: map[string]string{"GOOD": "value"},
130+ },
131+ {
132+ name: "whitespace trimming",
133+ input: " FOO = bar \n BAZ=qux",
134+ expected: map[string]string{
135+ "FOO": "bar",
136+ "BAZ": "qux",
137+ },
138+ },
139+ }
140+
141+ for _, tt := range tests {
142+ t.Run(tt.name, func(t *testing.T) {
143+ result, err := parseEnvFile([]byte(tt.input))
144+
145+ if tt.wantErr {
146+ assert.Error(t, err)
147+ return
148+ }
149+
150+ require.NoError(t, err)
151+ assert.Equal(t, tt.expected, result)
152+ })
153+ }
154+}
155+
156+// parseConfigDigest tests
157+
158+func TestParseConfigDigest(t *testing.T) {
159+ tests := []struct {
160+ name string
161+ manifest map[string]any
162+ expected string
163+ wantErr bool
164+ }{
165+ {
166+ name: "valid manifest v2",
167+ manifest: map[string]any{
168+ "schemaVersion": 2,
169+ "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
170+ "config": map[string]any{
171+ "mediaType": "application/vnd.docker.container.image.v1+json",
172+ "digest": "sha256:abc123",
173+ "size": 1234,
174+ },
175+ },
176+ expected: "sha256:abc123",
177+ },
178+ {
179+ name: "OCI manifest",
180+ manifest: map[string]any{
181+ "schemaVersion": 2,
182+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
183+ "config": map[string]any{
184+ "mediaType": "application/vnd.oci.image.config.v1+json",
185+ "digest": "sha256:def456",
186+ "size": 5678,
187+ },
188+ },
189+ expected: "sha256:def456",
190+ },
191+ {
192+ name: "empty config digest",
193+ manifest: map[string]any{
194+ "schemaVersion": 2,
195+ "config": map[string]any{
196+ "digest": "",
197+ },
198+ },
199+ expected: "",
200+ },
201+ }
202+
203+ for _, tt := range tests {
204+ t.Run(tt.name, func(t *testing.T) {
205+ data, err := json.Marshal(tt.manifest)
206+ require.NoError(t, err)
207+
208+ result, err := parseConfigDigest(data)
209+
210+ if tt.wantErr {
211+ assert.Error(t, err)
212+ return
213+ }
214+
215+ require.NoError(t, err)
216+ assert.Equal(t, tt.expected, result)
217+ })
218+ }
219+}
220+
221+func TestParseConfigDigest_InvalidJSON(t *testing.T) {
222+ _, err := parseConfigDigest([]byte("invalid json"))
223+ assert.Error(t, err)
224+}
225+
226+// parseImageLabels tests
227+
228+func TestParseImageLabels(t *testing.T) {
229+ tests := []struct {
230+ name string
231+ config map[string]any
232+ expected *domain.ImageLabels
233+ }{
234+ {
235+ name: "single domain label",
236+ config: map[string]any{
237+ "config": map[string]any{
238+ "Labels": map[string]any{
239+ "gordon.domain": "app.example.com",
240+ },
241+ },
242+ },
243+ expected: &domain.ImageLabels{
244+ Domain: "app.example.com",
245+ },
246+ },
247+ {
248+ name: "multiple domains label",
249+ config: map[string]any{
250+ "config": map[string]any{
251+ "Labels": map[string]any{
252+ "gordon.domains": "app1.example.com, app2.example.com",
253+ },
254+ },
255+ },
256+ expected: &domain.ImageLabels{
257+ Domains: []string{"app1.example.com", "app2.example.com"},
258+ },
259+ },
260+ {
261+ name: "all gordon labels",
262+ config: map[string]any{
263+ "config": map[string]any{
264+ "Labels": map[string]any{
265+ "gordon.domain": "app.example.com",
266+ "gordon.health": "/healthz",
267+ "gordon.port": "8080",
268+ "gordon.env-file": ".env.prod",
269+ },
270+ },
271+ },
272+ expected: &domain.ImageLabels{
273+ Domain: "app.example.com",
274+ Health: "/healthz",
275+ Port: "8080",
276+ EnvFile: ".env.prod",
277+ },
278+ },
279+ {
280+ name: "no labels",
281+ config: map[string]any{
282+ "config": map[string]any{
283+ "Labels": nil,
284+ },
285+ },
286+ expected: &domain.ImageLabels{},
287+ },
288+ {
289+ name: "no gordon labels",
290+ config: map[string]any{
291+ "config": map[string]any{
292+ "Labels": map[string]any{
293+ "other.label": "value",
294+ },
295+ },
296+ },
297+ expected: &domain.ImageLabels{},
298+ },
299+ {
300+ name: "whitespace in labels",
301+ config: map[string]any{
302+ "config": map[string]any{
303+ "Labels": map[string]any{
304+ "gordon.domain": " app.example.com ",
305+ },
306+ },
307+ },
308+ expected: &domain.ImageLabels{
309+ Domain: "app.example.com",
310+ },
311+ },
312+ }
313+
314+ for _, tt := range tests {
315+ t.Run(tt.name, func(t *testing.T) {
316+ data, err := json.Marshal(tt.config)
317+ require.NoError(t, err)
318+
319+ result, err := parseImageLabels(data)
320+ require.NoError(t, err)
321+
322+ assert.Equal(t, tt.expected.Domain, result.Domain)
323+ assert.Equal(t, tt.expected.Domains, result.Domains)
324+ assert.Equal(t, tt.expected.Health, result.Health)
325+ assert.Equal(t, tt.expected.Port, result.Port)
326+ assert.Equal(t, tt.expected.EnvFile, result.EnvFile)
327+ })
328+ }
329+}
330+
331+func TestParseImageLabels_InvalidJSON(t *testing.T) {
332+ _, err := parseImageLabels([]byte("invalid json"))
333+ assert.Error(t, err)
334+}
335+
336+// AutoRouteHandler tests
337+
338+func TestAutoRouteHandler_CanHandle(t *testing.T) {
339+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
340+ configSvc := inmocks.NewMockConfigService(t)
341+ containerSvc := inmocks.NewMockContainerService(t)
342+ blobStorage := mocks.NewMockBlobStorage(t)
343+
344+ handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
345+
346+ assert.True(t, handler.CanHandle(domain.EventImagePushed))
347+ assert.False(t, handler.CanHandle(domain.EventConfigReload))
348+ assert.False(t, handler.CanHandle(domain.EventManualReload))
349+}
350+
351+func TestAutoRouteHandler_Handle_DisabledAutoRoute(t *testing.T) {
352+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
353+ configSvc := inmocks.NewMockConfigService(t)
354+ containerSvc := inmocks.NewMockContainerService(t)
355+ blobStorage := mocks.NewMockBlobStorage(t)
356+
357+ handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
358+
359+ configSvc.EXPECT().IsAutoRouteEnabled().Return(false)
360+
361+ event := domain.Event{
362+ ID: "event-123",
363+ Type: domain.EventImagePushed,
364+ Data: domain.ImagePushedPayload{
365+ Name: "myapp",
366+ Reference: "latest",
367+ Manifest: []byte(`{"schemaVersion": 2}`),
368+ },
369+ }
370+
371+ err := handler.Handle(event)
372+
373+ assert.NoError(t, err)
374+}
375+
376+func TestAutoRouteHandler_Handle_InvalidPayload(t *testing.T) {
377+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
378+ configSvc := inmocks.NewMockConfigService(t)
379+ containerSvc := inmocks.NewMockContainerService(t)
380+ blobStorage := mocks.NewMockBlobStorage(t)
381+
382+ handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
383+
384+ configSvc.EXPECT().IsAutoRouteEnabled().Return(true)
385+
386+ event := domain.Event{
387+ ID: "event-123",
388+ Type: domain.EventImagePushed,
389+ Data: "invalid payload type",
390+ }
391+
392+ err := handler.Handle(event)
393+
394+ // Handler skips invalid payload, doesn't error
395+ assert.NoError(t, err)
396+}
397+
398+func TestAutoRouteHandler_Handle_EmptyManifest(t *testing.T) {
399+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
400+ configSvc := inmocks.NewMockConfigService(t)
401+ containerSvc := inmocks.NewMockContainerService(t)
402+ blobStorage := mocks.NewMockBlobStorage(t)
403+
404+ handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
405+
406+ configSvc.EXPECT().IsAutoRouteEnabled().Return(true)
407+
408+ event := domain.Event{
409+ ID: "event-123",
410+ Type: domain.EventImagePushed,
411+ Data: domain.ImagePushedPayload{
412+ Name: "myapp",
413+ Reference: "latest",
414+ Manifest: []byte{},
415+ },
416+ }
417+
418+ err := handler.Handle(event)
419+
420+ assert.NoError(t, err)
421+}
422+
423+func TestAutoRouteHandler_Handle_CreatesNewRoute(t *testing.T) {
424+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
425+ configSvc := inmocks.NewMockConfigService(t)
426+ containerSvc := inmocks.NewMockContainerService(t)
427+ blobStorage := mocks.NewMockBlobStorage(t)
428+
429+ handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
430+
431+ // Build manifest with config digest
432+ manifest := map[string]any{
433+ "schemaVersion": 2,
434+ "config": map[string]any{
435+ "digest": "sha256:configdigest123",
436+ },
437+ }
438+ manifestData, _ := json.Marshal(manifest)
439+
440+ // Build image config with gordon labels
441+ imageConfig := map[string]any{
442+ "config": map[string]any{
443+ "Labels": map[string]any{
444+ "gordon.domain": "app.example.com",
445+ },
446+ },
447+ }
448+ configData, _ := json.Marshal(imageConfig)
449+
450+ configSvc.EXPECT().IsAutoRouteEnabled().Return(true)
451+ blobStorage.EXPECT().GetBlob("sha256:configdigest123").Return(io.NopCloser(strings.NewReader(string(configData))), nil)
452+
453+ // No existing routes
454+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{})
455+
456+ // Expect route to be added
457+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{
458+ Domain: "app.example.com",
459+ Image: "myapp:latest",
460+ }).Return(nil)
461+
462+ // Expect deploy to be triggered
463+ containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
464+ Domain: "app.example.com",
465+ Image: "myapp:latest",
466+ }).Return(&domain.Container{ID: "container-1"}, nil)
467+
468+ event := domain.Event{
469+ ID: "event-123",
470+ Type: domain.EventImagePushed,
471+ Data: domain.ImagePushedPayload{
472+ Name: "myapp",
473+ Reference: "latest",
474+ Manifest: manifestData,
475+ },
476+ }
477+
478+ err := handler.Handle(event)
479+
480+ assert.NoError(t, err)
481+}
482+
483+func TestAutoRouteHandler_Handle_UpdatesExistingRoute(t *testing.T) {
484+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
485+ configSvc := inmocks.NewMockConfigService(t)
486+ containerSvc := inmocks.NewMockContainerService(t)
487+ blobStorage := mocks.NewMockBlobStorage(t)
488+
489+ handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
490+
491+ // Build manifest with config digest
492+ manifest := map[string]any{
493+ "schemaVersion": 2,
494+ "config": map[string]any{
495+ "digest": "sha256:configdigest123",
496+ },
497+ }
498+ manifestData, _ := json.Marshal(manifest)
499+
500+ // Build image config with gordon labels
501+ imageConfig := map[string]any{
502+ "config": map[string]any{
503+ "Labels": map[string]any{
504+ "gordon.domain": "app.example.com",
505+ },
506+ },
507+ }
508+ configData, _ := json.Marshal(imageConfig)
509+
510+ configSvc.EXPECT().IsAutoRouteEnabled().Return(true)
511+ blobStorage.EXPECT().GetBlob("sha256:configdigest123").Return(io.NopCloser(strings.NewReader(string(configData))), nil)
512+
513+ // Existing route with different image
514+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
515+ {Domain: "app.example.com", Image: "myapp:v1"},
516+ })
517+
518+ // Expect route to be updated
519+ configSvc.EXPECT().UpdateRoute(mock.Anything, domain.Route{
520+ Domain: "app.example.com",
521+ Image: "myapp:v2",
522+ }).Return(nil)
523+
524+ // Expect deploy to be triggered
525+ containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
526+ Domain: "app.example.com",
527+ Image: "myapp:v2",
528+ }).Return(&domain.Container{ID: "container-1"}, nil)
529+
530+ event := domain.Event{
531+ ID: "event-123",
532+ Type: domain.EventImagePushed,
533+ Data: domain.ImagePushedPayload{
534+ Name: "myapp",
535+ Reference: "v2",
536+ Manifest: manifestData,
537+ },
538+ }
539+
540+ err := handler.Handle(event)
541+
542+ assert.NoError(t, err)
543+}
544+
545+func TestAutoRouteHandler_Handle_NoUpdateIfSameImage(t *testing.T) {
546+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
547+ configSvc := inmocks.NewMockConfigService(t)
548+ containerSvc := inmocks.NewMockContainerService(t)
549+ blobStorage := mocks.NewMockBlobStorage(t)
550+
551+ handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
552+
553+ // Build manifest with config digest
554+ manifest := map[string]any{
555+ "schemaVersion": 2,
556+ "config": map[string]any{
557+ "digest": "sha256:configdigest123",
558+ },
559+ }
560+ manifestData, _ := json.Marshal(manifest)
561+
562+ // Build image config with gordon labels
563+ imageConfig := map[string]any{
564+ "config": map[string]any{
565+ "Labels": map[string]any{
566+ "gordon.domain": "app.example.com",
567+ },
568+ },
569+ }
570+ configData, _ := json.Marshal(imageConfig)
571+
572+ configSvc.EXPECT().IsAutoRouteEnabled().Return(true)
573+ blobStorage.EXPECT().GetBlob("sha256:configdigest123").Return(io.NopCloser(strings.NewReader(string(configData))), nil)
574+
575+ // Existing route with same image - no update needed
576+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
577+ {Domain: "app.example.com", Image: "myapp:latest"},
578+ })
579+
580+ // No AddRoute, UpdateRoute, or Deploy calls expected
581+
582+ event := domain.Event{
583+ ID: "event-123",
584+ Type: domain.EventImagePushed,
585+ Data: domain.ImagePushedPayload{
586+ Name: "myapp",
587+ Reference: "latest",
588+ Manifest: manifestData,
589+ },
590+ }
591+
592+ err := handler.Handle(event)
593+
594+ assert.NoError(t, err)
595+}
596+
597+func TestAutoRouteHandler_Handle_NoDomainLabel(t *testing.T) {
598+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
599+ configSvc := inmocks.NewMockConfigService(t)
600+ containerSvc := inmocks.NewMockContainerService(t)
601+ blobStorage := mocks.NewMockBlobStorage(t)
602+
603+ handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
604+
605+ // Build manifest with config digest
606+ manifest := map[string]any{
607+ "schemaVersion": 2,
608+ "config": map[string]any{
609+ "digest": "sha256:configdigest123",
610+ },
611+ }
612+ manifestData, _ := json.Marshal(manifest)
613+
614+ // Build image config with no gordon labels
615+ imageConfig := map[string]any{
616+ "config": map[string]any{
617+ "Labels": map[string]any{
618+ "other.label": "value",
619+ },
620+ },
621+ }
622+ configData, _ := json.Marshal(imageConfig)
623+
624+ configSvc.EXPECT().IsAutoRouteEnabled().Return(true)
625+ blobStorage.EXPECT().GetBlob("sha256:configdigest123").Return(io.NopCloser(strings.NewReader(string(configData))), nil)
626+
627+ // No route operations expected
628+
629+ event := domain.Event{
630+ ID: "event-123",
631+ Type: domain.EventImagePushed,
632+ Data: domain.ImagePushedPayload{
633+ Name: "myapp",
634+ Reference: "latest",
635+ Manifest: manifestData,
636+ },
637+ }
638+
639+ err := handler.Handle(event)
640+
641+ assert.NoError(t, err)
642+}
643+
644+func TestAutoRouteHandler_Handle_MultipleDomains(t *testing.T) {
645+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
646+ configSvc := inmocks.NewMockConfigService(t)
647+ containerSvc := inmocks.NewMockContainerService(t)
648+ blobStorage := mocks.NewMockBlobStorage(t)
649+
650+ handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
651+
652+ // Build manifest with config digest
653+ manifest := map[string]any{
654+ "schemaVersion": 2,
655+ "config": map[string]any{
656+ "digest": "sha256:configdigest123",
657+ },
658+ }
659+ manifestData, _ := json.Marshal(manifest)
660+
661+ // Build image config with multiple domains
662+ imageConfig := map[string]any{
663+ "config": map[string]any{
664+ "Labels": map[string]any{
665+ "gordon.domain": "app.example.com",
666+ "gordon.domains": "api.example.com, www.example.com",
667+ },
668+ },
669+ }
670+ configData, _ := json.Marshal(imageConfig)
671+
672+ configSvc.EXPECT().IsAutoRouteEnabled().Return(true)
673+ blobStorage.EXPECT().GetBlob("sha256:configdigest123").Return(io.NopCloser(strings.NewReader(string(configData))), nil)
674+
675+ // No existing routes
676+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}).Times(3)
677+
678+ // Expect routes to be added for all three domains
679+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{
680+ Domain: "app.example.com",
681+ Image: "myapp:latest",
682+ }).Return(nil)
683+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{
684+ Domain: "api.example.com",
685+ Image: "myapp:latest",
686+ }).Return(nil)
687+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{
688+ Domain: "www.example.com",
689+ Image: "myapp:latest",
690+ }).Return(nil)
691+
692+ // Expect deploy for all three
693+ containerSvc.EXPECT().Deploy(mock.Anything, mock.AnythingOfType("domain.Route")).Return(&domain.Container{ID: "container-1"}, nil).Times(3)
694+
695+ event := domain.Event{
696+ ID: "event-123",
697+ Type: domain.EventImagePushed,
698+ Data: domain.ImagePushedPayload{
699+ Name: "myapp",
700+ Reference: "latest",
701+ Manifest: manifestData,
702+ },
703+ }
704+
705+ err := handler.Handle(event)
706+
707+ assert.NoError(t, err)
708+}
709+
710+func TestAutoRouteHandler_TriggerDeploy_NilContainerService(t *testing.T) {
711+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
712+ configSvc := inmocks.NewMockConfigService(t)
713+ blobStorage := mocks.NewMockBlobStorage(t)
714+
715+ // Create handler without container service
716+ handler := NewAutoRouteHandler(ctx, configSvc, nil, blobStorage, "registry.example.com")
717+
718+ route := domain.Route{
719+ Domain: "app.example.com",
720+ Image: "myapp:latest",
721+ }
722+
723+ // Should not panic, just log and skip
724+ handler.triggerDeploy(ctx, route)
725+}
726+
727+// collectDomains tests
728+
729+func TestAutoRouteHandler_CollectDomains(t *testing.T) {
730+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
731+ configSvc := inmocks.NewMockConfigService(t)
732+ containerSvc := inmocks.NewMockContainerService(t)
733+ blobStorage := mocks.NewMockBlobStorage(t)
734+
735+ handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
736+
737+ tests := []struct {
738+ name string
739+ labels *domain.ImageLabels
740+ expected []string
741+ }{
742+ {
743+ name: "single domain",
744+ labels: &domain.ImageLabels{
745+ Domain: "app.example.com",
746+ },
747+ expected: []string{"app.example.com"},
748+ },
749+ {
750+ name: "domains list only",
751+ labels: &domain.ImageLabels{
752+ Domains: []string{"api.example.com", "www.example.com"},
753+ },
754+ expected: []string{"api.example.com", "www.example.com"},
755+ },
756+ {
757+ name: "both domain and domains",
758+ labels: &domain.ImageLabels{
759+ Domain: "app.example.com",
760+ Domains: []string{"api.example.com"},
761+ },
762+ expected: []string{"app.example.com", "api.example.com"},
763+ },
764+ {
765+ name: "empty labels",
766+ labels: &domain.ImageLabels{},
767+ expected: nil,
768+ },
769+ }
770+
771+ for _, tt := range tests {
772+ t.Run(tt.name, func(t *testing.T) {
773+ result := handler.collectDomains(tt.labels)
774+ assert.Equal(t, tt.expected, result)
775+ })
776+ }
777+}
778+
779+// buildImageName tests
780+
781+func TestAutoRouteHandler_BuildImageName(t *testing.T) {
782+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
783+ configSvc := inmocks.NewMockConfigService(t)
784+ containerSvc := inmocks.NewMockContainerService(t)
785+ blobStorage := mocks.NewMockBlobStorage(t)
786+
787+ handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
788+
789+ tests := []struct {
790+ name string
791+ imageName string
792+ reference string
793+ expected string
794+ }{
795+ {
796+ name: "with tag",
797+ imageName: "myapp",
798+ reference: "latest",
799+ expected: "myapp:latest",
800+ },
801+ {
802+ name: "with version tag",
803+ imageName: "myapp",
804+ reference: "v1.2.3",
805+ expected: "myapp:v1.2.3",
806+ },
807+ {
808+ name: "empty reference",
809+ imageName: "myapp",
810+ reference: "",
811+ expected: "myapp",
812+ },
813+ {
814+ name: "with digest",
815+ imageName: "myapp",
816+ reference: "sha256:abc123",
817+ expected: "myapp@sha256:abc123",
818+ },
819+ }
820+
821+ for _, tt := range tests {
822+ t.Run(tt.name, func(t *testing.T) {
823+ result := handler.buildImageName(tt.imageName, tt.reference)
824+ assert.Equal(t, tt.expected, result)
825+ })
826+ }
827+}
828+
829+// buildFullImageRef tests
830+
831+func TestAutoRouteHandler_BuildFullImageRef(t *testing.T) {
832+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
833+ configSvc := inmocks.NewMockConfigService(t)
834+ containerSvc := inmocks.NewMockContainerService(t)
835+ blobStorage := mocks.NewMockBlobStorage(t)
836+
837+ tests := []struct {
838+ name string
839+ registryDomain string
840+ imageName string
841+ expected string
842+ }{
843+ {
844+ name: "simple image with registry",
845+ registryDomain: "registry.example.com",
846+ imageName: "myapp:latest",
847+ expected: "registry.example.com/myapp:latest",
848+ },
849+ {
850+ name: "already has registry prefix",
851+ registryDomain: "registry.example.com",
852+ imageName: "registry.example.com/myapp:latest",
853+ expected: "registry.example.com/myapp:latest",
854+ },
855+ {
856+ name: "external registry reference",
857+ registryDomain: "registry.example.com",
858+ imageName: "docker.io/library/nginx:latest",
859+ expected: "docker.io/library/nginx:latest",
860+ },
861+ {
862+ name: "digest reference",
863+ registryDomain: "registry.example.com",
864+ imageName: "myapp@sha256:abc123def456",
865+ expected: "registry.example.com/myapp@sha256:abc123def456",
866+ },
867+ {
868+ name: "empty registry domain",
869+ registryDomain: "",
870+ imageName: "myapp:latest",
871+ expected: "myapp:latest",
872+ },
873+ {
874+ name: "image without tag",
875+ registryDomain: "registry.example.com",
876+ imageName: "myapp",
877+ expected: "registry.example.com/myapp",
878+ },
879+ }
880+
881+ for _, tt := range tests {
882+ t.Run(tt.name, func(t *testing.T) {
883+ handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, tt.registryDomain)
884+ result := handler.buildFullImageRef(tt.imageName)
885+ assert.Equal(t, tt.expected, result)
886+ })
887+ }
888+}
M internal/usecase/container/service.go +55 -1
@@ -25,6 +25,7 @@ type Config struct {
25 RegistryPassword string25 RegistryPassword string
26 InternalRegistryUsername string26 InternalRegistryUsername string
27 InternalRegistryPassword string27 InternalRegistryPassword string
28+ PullPolicy string
28 VolumeAutoCreate bool29 VolumeAutoCreate bool
29 VolumePrefix string30 VolumePrefix string
30 VolumePreserve bool31 VolumePreserve bool
@@ -36,6 +37,12 @@ type Config struct {
36 ReadinessDelay time.Duration // Delay after container starts before considering it ready37 ReadinessDelay time.Duration // Delay after container starts before considering it ready
37 }38 }
38 39
40+const (
41+ PullPolicyAlways = "always"
42+ PullPolicyIfNotPresent = "if-not-present"
43+ PullPolicyIfTagChanged = "if-tag-changed"
44+)
45+
39 // Service implements the ContainerService interface.46 // Service implements the ContainerService interface.
40 type Service struct {47 type Service struct {
41 runtime out.ContainerRuntime48 runtime out.ContainerRuntime
@@ -513,6 +520,23 @@ func (s *Service) buildImageRef(image string) string {
513 return fmt.Sprintf("%s/%s", s.config.RegistryDomain, image)520 return fmt.Sprintf("%s/%s", s.config.RegistryDomain, image)
514 }521 }
515 522
523+func normalizePullPolicy(policy string) string {
524+ switch strings.ToLower(strings.TrimSpace(policy)) {
525+ case PullPolicyAlways:
526+ return PullPolicyAlways
527+ case PullPolicyIfTagChanged:
528+ return PullPolicyIfTagChanged
529+ case PullPolicyIfNotPresent:
530+ return PullPolicyIfNotPresent
531+ default:
532+ return PullPolicyIfNotPresent
533+ }
534+}
535+
536+func isDigestRef(imageRef string) bool {
537+ return strings.Contains(imageRef, "@sha256:")
538+}
539+
516 func (s *Service) pullRefForDeploy(ctx context.Context, imageRef string) (string, bool) {540 func (s *Service) pullRefForDeploy(ctx context.Context, imageRef string) (string, bool) {
517 if !domain.IsInternalDeploy(ctx) {541 if !domain.IsInternalDeploy(ctx) {
518 return imageRef, false542 return imageRef, false
@@ -521,7 +545,9 @@ func (s *Service) pullRefForDeploy(ctx context.Context, imageRef string) (string
521 }545 }
522 546
523 // ensureImage ensures the image is available locally, pulling if needed.547 // ensureImage ensures the image is available locally, pulling if needed.
524-// Returns the canonical image reference to use for container operations.548+// Returns the image reference to use for container operations:
549+// - For digest references (@sha256:...), returns the pullRef since Docker can't tag digests
550+// - For tagged images, returns the original imageRef after tagging the pulled image
525 func (s *Service) ensureImage(ctx context.Context, imageRef string) (string, error) {551 func (s *Service) ensureImage(ctx context.Context, imageRef string) (string, error) {
526 ctx = zerowrap.CtxWithField(ctx, "image", imageRef)552 ctx = zerowrap.CtxWithField(ctx, "image", imageRef)
527 log := zerowrap.FromCtx(ctx)553 log := zerowrap.FromCtx(ctx)
@@ -550,6 +576,15 @@ func (s *Service) ensureImage(ctx context.Context, imageRef string) (string, err
550 return "", err576 return "", err
551 }577 }
552 578
579+ // For digest references, we can't create a tag (Docker doesn't allow it).
580+ // In this case, use the pullRef directly since the image is available under that reference.
581+ if strings.Contains(imageRef, "@sha256:") {
582+ log.Info().
583+ Str("pull_ref", pullRef).
584+ Msg("digest reference: using pull reference for container operations")
585+ return pullRef, nil
586+ }
587+
553 if err := s.tagImageIfNeeded(ctx, pullRef, imageRef); err != nil {588 if err := s.tagImageIfNeeded(ctx, pullRef, imageRef); err != nil {
554 return "", err589 return "", err
555 }590 }
@@ -576,6 +611,18 @@ func (s *Service) ensureLocalImage(ctx context.Context, imageRef, pullRef string
576 return false, nil611 return false, nil
577 }612 }
578 613
614+ pullPolicy := normalizePullPolicy(s.config.PullPolicy)
615+ switch pullPolicy {
616+ case PullPolicyAlways:
617+ log.Info().Str("pull_policy", pullPolicy).Msg("pull policy forces image pull")
618+ return false, nil
619+ case PullPolicyIfTagChanged:
620+ if !isDigestRef(imageRef) {
621+ log.Info().Str("pull_policy", pullPolicy).Msg("tag reference detected, pulling to check for updates")
622+ return false, nil
623+ }
624+ }
625+
579 localImages, err := s.runtime.ListImages(ctx)626 localImages, err := s.runtime.ListImages(ctx)
580 if err != nil {627 if err != nil {
581 log.WrapErr(err, "failed to list local images, will attempt pull")628 log.WrapErr(err, "failed to list local images, will attempt pull")
@@ -641,6 +688,13 @@ func (s *Service) tagImageIfNeeded(ctx context.Context, sourceRef, targetRef str
641 return nil688 return nil
642 }689 }
643 690
691+ // Cannot create a tag with a digest reference - Docker/Podman doesn't allow it.
692+ // When using digest references (image@sha256:...), skip tagging as the image
693+ // is already available by its digest.
694+ if strings.Contains(targetRef, "@sha256:") {
695+ return nil
696+ }
697+
644 log := zerowrap.FromCtx(ctx)698 log := zerowrap.FromCtx(ctx)
645 if err := s.runtime.TagImage(ctx, sourceRef, targetRef); err != nil {699 if err := s.runtime.TagImage(ctx, sourceRef, targetRef); err != nil {
646 return log.WrapErr(err, "failed to tag image from pull reference")700 return log.WrapErr(err, "failed to tag image from pull reference")
A internal/usecase/health/service.go +134 -0
@@ -0,0 +1,134 @@
1+// Package health implements the health check use case for routes.
2+package health
3+
4+import (
5+ "context"
6+ "fmt"
7+ "sync"
8+
9+ "github.com/bnema/zerowrap"
10+
11+ "gordon/internal/boundaries/in"
12+ "gordon/internal/domain"
13+)
14+
15+// maxConcurrentProbes limits the number of concurrent health probes to prevent resource exhaustion.
16+const maxConcurrentProbes = 10
17+
18+// Service implements the HealthService interface.
19+type Service struct {
20+ configSvc in.ConfigService
21+ containerSvc in.ContainerService
22+ prober in.HTTPProber
23+ log zerowrap.Logger
24+}
25+
26+// NewService creates a new health service.
27+func NewService(
28+ configSvc in.ConfigService,
29+ containerSvc in.ContainerService,
30+ prober in.HTTPProber,
31+ log zerowrap.Logger,
32+) *Service {
33+ return &Service{
34+ configSvc: configSvc,
35+ containerSvc: containerSvc,
36+ prober: prober,
37+ log: log,
38+ }
39+}
40+
41+// CheckRoute performs a health check on a single route.
42+func (s *Service) CheckRoute(ctx context.Context, route domain.Route) *domain.RouteHealth {
43+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
44+ zerowrap.FieldLayer: "usecase",
45+ zerowrap.FieldUseCase: "CheckRoute",
46+ "domain": route.Domain,
47+ })
48+ log := zerowrap.FromCtx(ctx)
49+
50+ health := &domain.RouteHealth{
51+ Domain: route.Domain,
52+ ContainerStatus: "unknown",
53+ }
54+
55+ // Check container status
56+ container, exists := s.containerSvc.Get(ctx, route.Domain)
57+ if !exists || container == nil {
58+ health.ContainerStatus = "not found"
59+ health.Error = "container not found"
60+ log.Debug().Msg("container not found")
61+ return health
62+ }
63+
64+ health.ContainerStatus = container.Status
65+
66+ // Only probe HTTP if container is running
67+ if container.Status != string(domain.ContainerStatusRunning) {
68+ health.Error = fmt.Sprintf("container is %s", container.Status)
69+ log.Debug().Str("status", container.Status).Msg("container not running, skipping HTTP probe")
70+ return health
71+ }
72+
73+ // Probe HTTP endpoint
74+ url := fmt.Sprintf("https://%s/", route.Domain)
75+ statusCode, responseTime, err := s.prober.Probe(ctx, url)
76+ if err != nil {
77+ health.Error = err.Error()
78+ log.Debug().Err(err).Str("url", url).Msg("HTTP probe failed")
79+ return health
80+ }
81+
82+ health.HTTPStatus = statusCode
83+ health.ResponseTimeMs = responseTime
84+ health.Healthy = statusCode >= 200 && statusCode < 400
85+
86+ log.Debug().
87+ Int("http_status", statusCode).
88+ Int64("response_time_ms", responseTime).
89+ Bool("healthy", health.Healthy).
90+ Msg("health check complete")
91+
92+ return health
93+}
94+
95+// CheckAllRoutes performs health checks on all configured routes.
96+func (s *Service) CheckAllRoutes(ctx context.Context) map[string]*domain.RouteHealth {
97+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
98+ zerowrap.FieldLayer: "usecase",
99+ zerowrap.FieldUseCase: "CheckAllRoutes",
100+ })
101+ log := zerowrap.FromCtx(ctx)
102+
103+ routes := s.configSvc.GetRoutes(ctx)
104+ results := make(map[string]*domain.RouteHealth, len(routes))
105+
106+ if len(routes) == 0 {
107+ log.Debug().Msg("no routes configured")
108+ return results
109+ }
110+
111+ // Check routes concurrently with semaphore to limit resource usage
112+ var mu sync.Mutex
113+ var wg sync.WaitGroup
114+ sem := make(chan struct{}, maxConcurrentProbes)
115+
116+ for _, route := range routes {
117+ wg.Add(1)
118+ go func(r domain.Route) {
119+ defer wg.Done()
120+ sem <- struct{}{} // Acquire semaphore
121+ defer func() { <-sem }() // Release semaphore
122+
123+ health := s.CheckRoute(ctx, r)
124+ mu.Lock()
125+ results[r.Domain] = health
126+ mu.Unlock()
127+ }(route)
128+ }
129+
130+ wg.Wait()
131+
132+ log.Debug().Int("routes_checked", len(results)).Msg("all health checks complete")
133+ return results
134+}
A internal/usecase/health/service_test.go +184 -0
@@ -0,0 +1,184 @@
1+package health
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/bnema/zerowrap"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/mock"
11+
12+ "gordon/internal/boundaries/in/mocks"
13+ "gordon/internal/domain"
14+)
15+
16+func testLogger() zerowrap.Logger {
17+ return zerowrap.Default()
18+}
19+
20+func TestService_CheckRoute_ContainerNotFound(t *testing.T) {
21+ configSvc := mocks.NewMockConfigService(t)
22+ containerSvc := mocks.NewMockContainerService(t)
23+ prober := mocks.NewMockHTTPProber(t)
24+
25+ containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(nil, false)
26+
27+ svc := NewService(configSvc, containerSvc, prober, testLogger())
28+ route := domain.Route{Domain: "app.example.com", Image: "myapp:latest"}
29+
30+ health := svc.CheckRoute(context.Background(), route)
31+
32+ assert.Equal(t, "app.example.com", health.Domain)
33+ assert.Equal(t, "not found", health.ContainerStatus)
34+ assert.Equal(t, 0, health.HTTPStatus)
35+ assert.False(t, health.Healthy)
36+ assert.Equal(t, "container not found", health.Error)
37+}
38+
39+func TestService_CheckRoute_ContainerNotRunning(t *testing.T) {
40+ configSvc := mocks.NewMockConfigService(t)
41+ containerSvc := mocks.NewMockContainerService(t)
42+ prober := mocks.NewMockHTTPProber(t)
43+
44+ container := &domain.Container{
45+ ID: "abc123",
46+ Status: "stopped",
47+ }
48+ containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(container, true)
49+
50+ svc := NewService(configSvc, containerSvc, prober, testLogger())
51+ route := domain.Route{Domain: "app.example.com", Image: "myapp:latest"}
52+
53+ health := svc.CheckRoute(context.Background(), route)
54+
55+ assert.Equal(t, "app.example.com", health.Domain)
56+ assert.Equal(t, "stopped", health.ContainerStatus)
57+ assert.Equal(t, 0, health.HTTPStatus)
58+ assert.False(t, health.Healthy)
59+ assert.Contains(t, health.Error, "container is stopped")
60+}
61+
62+func TestService_CheckRoute_HTTPProbeSuccess(t *testing.T) {
63+ configSvc := mocks.NewMockConfigService(t)
64+ containerSvc := mocks.NewMockContainerService(t)
65+ prober := mocks.NewMockHTTPProber(t)
66+
67+ container := &domain.Container{
68+ ID: "abc123",
69+ Status: "running",
70+ }
71+ containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(container, true)
72+ prober.EXPECT().Probe(mock.Anything, "https://app.example.com/").Return(200, int64(45), nil)
73+
74+ svc := NewService(configSvc, containerSvc, prober, testLogger())
75+ route := domain.Route{Domain: "app.example.com", Image: "myapp:latest"}
76+
77+ health := svc.CheckRoute(context.Background(), route)
78+
79+ assert.Equal(t, "app.example.com", health.Domain)
80+ assert.Equal(t, "running", health.ContainerStatus)
81+ assert.Equal(t, 200, health.HTTPStatus)
82+ assert.Equal(t, int64(45), health.ResponseTimeMs)
83+ assert.True(t, health.Healthy)
84+ assert.Empty(t, health.Error)
85+}
86+
87+func TestService_CheckRoute_HTTPProbeFailure(t *testing.T) {
88+ configSvc := mocks.NewMockConfigService(t)
89+ containerSvc := mocks.NewMockContainerService(t)
90+ prober := mocks.NewMockHTTPProber(t)
91+
92+ container := &domain.Container{
93+ ID: "abc123",
94+ Status: "running",
95+ }
96+ containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(container, true)
97+ prober.EXPECT().Probe(mock.Anything, "https://app.example.com/").Return(0, int64(0), errors.New("connection refused"))
98+
99+ svc := NewService(configSvc, containerSvc, prober, testLogger())
100+ route := domain.Route{Domain: "app.example.com", Image: "myapp:latest"}
101+
102+ health := svc.CheckRoute(context.Background(), route)
103+
104+ assert.Equal(t, "app.example.com", health.Domain)
105+ assert.Equal(t, "running", health.ContainerStatus)
106+ assert.Equal(t, 0, health.HTTPStatus)
107+ assert.False(t, health.Healthy)
108+ assert.Equal(t, "connection refused", health.Error)
109+}
110+
111+func TestService_CheckRoute_HTTPStatus5xx(t *testing.T) {
112+ configSvc := mocks.NewMockConfigService(t)
113+ containerSvc := mocks.NewMockContainerService(t)
114+ prober := mocks.NewMockHTTPProber(t)
115+
116+ container := &domain.Container{
117+ ID: "abc123",
118+ Status: "running",
119+ }
120+ containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(container, true)
121+ prober.EXPECT().Probe(mock.Anything, "https://app.example.com/").Return(502, int64(100), nil)
122+
123+ svc := NewService(configSvc, containerSvc, prober, testLogger())
124+ route := domain.Route{Domain: "app.example.com", Image: "myapp:latest"}
125+
126+ health := svc.CheckRoute(context.Background(), route)
127+
128+ assert.Equal(t, "app.example.com", health.Domain)
129+ assert.Equal(t, "running", health.ContainerStatus)
130+ assert.Equal(t, 502, health.HTTPStatus)
131+ assert.Equal(t, int64(100), health.ResponseTimeMs)
132+ assert.False(t, health.Healthy) // 5xx is not healthy
133+ assert.Empty(t, health.Error)
134+}
135+
136+func TestService_CheckAllRoutes(t *testing.T) {
137+ configSvc := mocks.NewMockConfigService(t)
138+ containerSvc := mocks.NewMockContainerService(t)
139+ prober := mocks.NewMockHTTPProber(t)
140+
141+ routes := []domain.Route{
142+ {Domain: "app1.example.com", Image: "app1:latest"},
143+ {Domain: "app2.example.com", Image: "app2:latest"},
144+ }
145+ configSvc.EXPECT().GetRoutes(mock.Anything).Return(routes)
146+
147+ container1 := &domain.Container{ID: "abc", Status: "running"}
148+ container2 := &domain.Container{ID: "def", Status: "stopped"}
149+ containerSvc.EXPECT().Get(mock.Anything, "app1.example.com").Return(container1, true)
150+ containerSvc.EXPECT().Get(mock.Anything, "app2.example.com").Return(container2, true)
151+
152+ // Only running containers get probed
153+ prober.EXPECT().Probe(mock.Anything, "https://app1.example.com/").Return(200, int64(30), nil)
154+
155+ svc := NewService(configSvc, containerSvc, prober, testLogger())
156+
157+ results := svc.CheckAllRoutes(context.Background())
158+
159+ assert.Len(t, results, 2)
160+
161+ assert.NotNil(t, results["app1.example.com"])
162+ assert.Equal(t, "running", results["app1.example.com"].ContainerStatus)
163+ assert.Equal(t, 200, results["app1.example.com"].HTTPStatus)
164+ assert.True(t, results["app1.example.com"].Healthy)
165+
166+ assert.NotNil(t, results["app2.example.com"])
167+ assert.Equal(t, "stopped", results["app2.example.com"].ContainerStatus)
168+ assert.Equal(t, 0, results["app2.example.com"].HTTPStatus)
169+ assert.False(t, results["app2.example.com"].Healthy)
170+}
171+
172+func TestService_CheckAllRoutes_NoRoutes(t *testing.T) {
173+ configSvc := mocks.NewMockConfigService(t)
174+ containerSvc := mocks.NewMockContainerService(t)
175+ prober := mocks.NewMockHTTPProber(t)
176+
177+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{})
178+
179+ svc := NewService(configSvc, containerSvc, prober, testLogger())
180+
181+ results := svc.CheckAllRoutes(context.Background())
182+
183+ assert.Empty(t, results)
184+}
M internal/usecase/proxy/service.go +4 -1
@@ -89,6 +89,7 @@ func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
89 }89 }
90 90
91 // Get target for this domain91 // Get target for this domain
92+ log.Debug().Str("resolving_target_for", r.Host).Msg("looking up proxy target")
92 target, err := s.GetTarget(ctx, r.Host)93 target, err := s.GetTarget(ctx, r.Host)
93 if err != nil {94 if err != nil {
94 log.Warn().Err(err).Msg("no route found for domain")95 log.Warn().Err(err).Msg("no route found for domain")
@@ -302,9 +303,11 @@ func (s *Service) proxyToTarget(w http.ResponseWriter, r *http.Request, target *
302 return303 return
303 }304 }
304 305
306+ log.Debug().Str("target_url", targetURL.String()).Msg("proxying to target")
307+
305 proxy := newReverseProxy(targetURL,308 proxy := newReverseProxy(targetURL,
306 func(w http.ResponseWriter, _ *http.Request, err error) {309 func(w http.ResponseWriter, _ *http.Request, err error) {
307- log.Error().Err(err).Str("target", targetURL.String()).Msg("proxy error")310+ log.Error().Err(err).Str("target", targetURL.String()).Msg("proxy error: connection failed")
308 http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)311 http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)
309 },312 },
310 func(resp *http.Response) error {313 func(resp *http.Response) error {
M internal/usecase/registry/service.go +46 -5
@@ -3,6 +3,8 @@ package registry
3 3
4 import (4 import (
5 "context"5 "context"
6+ "crypto/sha256"
7+ "fmt"
6 "io"8 "io"
7 9
8 "github.com/bnema/zerowrap"10 "github.com/bnema/zerowrap"
@@ -54,8 +56,8 @@ func (s *Service) GetManifest(ctx context.Context, name, reference string) (*dom
54 }, nil56 }, nil
55 }57 }
56 58
57-// PutManifest stores a manifest.59+// PutManifest stores a manifest and returns the calculated digest.
58-func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) error {60+func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) (string, error) {
59 ctx = zerowrap.CtxWithFields(ctx, map[string]any{61 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
60 zerowrap.FieldLayer: "usecase",62 zerowrap.FieldLayer: "usecase",
61 zerowrap.FieldUseCase: "PutManifest",63 zerowrap.FieldUseCase: "PutManifest",
@@ -64,8 +66,11 @@ func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) er
64 })66 })
65 log := zerowrap.FromCtx(ctx)67 log := zerowrap.FromCtx(ctx)
66 68
69+ // Calculate digest
70+ digest := fmt.Sprintf("sha256:%x", sha256.Sum256(manifest.Data))
71+
67 if err := s.manifestStorage.PutManifest(manifest.Name, manifest.Reference, manifest.ContentType, manifest.Data); err != nil {72 if err := s.manifestStorage.PutManifest(manifest.Name, manifest.Reference, manifest.ContentType, manifest.Data); err != nil {
68- return log.WrapErr(err, "failed to store manifest")73+ return "", log.WrapErr(err, "failed to store manifest")
69 }74 }
70 75
71 // Publish image pushed event76 // Publish image pushed event
@@ -80,8 +85,8 @@ func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) er
80 }85 }
81 }86 }
82 87
83- log.Info().Msg("manifest stored")88+ log.Info().Str("digest", digest).Msg("manifest stored")
84- return nil89+ return digest, nil
85 }90 }
86 91
87 // DeleteManifest removes a manifest.92 // DeleteManifest removes a manifest.
@@ -119,6 +124,23 @@ func (s *Service) GetBlob(ctx context.Context, digest string) (io.ReadCloser, er
119 return reader, nil124 return reader, nil
120 }125 }
121 126
127+// GetBlobPath returns the filesystem path to a blob for direct serving.
128+func (s *Service) GetBlobPath(ctx context.Context, digest string) (string, error) {
129+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
130+ zerowrap.FieldLayer: "usecase",
131+ zerowrap.FieldUseCase: "GetBlobPath",
132+ "digest": digest,
133+ })
134+ log := zerowrap.FromCtx(ctx)
135+
136+ path, err := s.blobStorage.GetBlobPath(digest)
137+ if err != nil {
138+ return "", log.WrapErr(err, "failed to get blob path")
139+ }
140+
141+ return path, nil
142+}
143+
122 // PutBlob stores a blob with the given digest.144 // PutBlob stores a blob with the given digest.
123 func (s *Service) PutBlob(ctx context.Context, digest string, data io.Reader, size int64) error {145 func (s *Service) PutBlob(ctx context.Context, digest string, data io.Reader, size int64) error {
124 ctx = zerowrap.CtxWithFields(ctx, map[string]any{146 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
@@ -160,6 +182,25 @@ func (s *Service) StartUpload(ctx context.Context, name string) (string, error)
160 return uuid, nil182 return uuid, nil
161 }183 }
162 184
185+// AppendBlobChunk appends data to an in-progress blob upload.
186+func (s *Service) AppendBlobChunk(ctx context.Context, name, uuid string, chunk []byte) (int64, error) {
187+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
188+ zerowrap.FieldLayer: "usecase",
189+ zerowrap.FieldUseCase: "AppendBlobChunk",
190+ "name": name,
191+ "uuid": uuid,
192+ zerowrap.FieldSize: len(chunk),
193+ })
194+ log := zerowrap.FromCtx(ctx)
195+
196+ length, err := s.blobStorage.AppendBlobChunk(name, uuid, chunk)
197+ if err != nil {
198+ return 0, log.WrapErr(err, "failed to append blob chunk")
199+ }
200+
201+ return length, nil
202+}
203+
163 // FinishUpload completes a blob upload.204 // FinishUpload completes a blob upload.
164 func (s *Service) FinishUpload(ctx context.Context, uuid, digest string) error {205 func (s *Service) FinishUpload(ctx context.Context, uuid, digest string) error {
165 ctx = zerowrap.CtxWithFields(ctx, map[string]any{206 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
M internal/usecase/registry/service_test.go +7 -4
@@ -5,6 +5,7 @@ import (
5 "context"5 "context"
6 "errors"6 "errors"
7 "io"7 "io"
8+ "strings"
8 "testing"9 "testing"
9 10
10 "github.com/bnema/zerowrap"11 "github.com/bnema/zerowrap"
@@ -75,9 +76,11 @@ func TestService_PutManifest_Success(t *testing.T) {
75 manifestStorage.EXPECT().PutManifest("myapp", "latest", "application/vnd.docker.distribution.manifest.v2+json", manifest.Data).Return(nil)76 manifestStorage.EXPECT().PutManifest("myapp", "latest", "application/vnd.docker.distribution.manifest.v2+json", manifest.Data).Return(nil)
76 eventBus.EXPECT().Publish(domain.EventImagePushed, mock.AnythingOfType("domain.ImagePushedPayload")).Return(nil)77 eventBus.EXPECT().Publish(domain.EventImagePushed, mock.AnythingOfType("domain.ImagePushedPayload")).Return(nil)
77 78
78- err := svc.PutManifest(ctx, manifest)79+ digest, err := svc.PutManifest(ctx, manifest)
79 80
80 assert.NoError(t, err)81 assert.NoError(t, err)
82+ assert.NotEmpty(t, digest)
83+ assert.True(t, strings.HasPrefix(digest, "sha256:"))
81 }84 }
82 85
83 func TestService_PutManifest_StorageError(t *testing.T) {86 func TestService_PutManifest_StorageError(t *testing.T) {
@@ -97,7 +100,7 @@ func TestService_PutManifest_StorageError(t *testing.T) {
97 100
98 manifestStorage.EXPECT().PutManifest("myapp", "latest", "application/vnd.docker.distribution.manifest.v2+json", manifest.Data).Return(errors.New("storage full"))101 manifestStorage.EXPECT().PutManifest("myapp", "latest", "application/vnd.docker.distribution.manifest.v2+json", manifest.Data).Return(errors.New("storage full"))
99 102
100- err := svc.PutManifest(ctx, manifest)103+ _, err := svc.PutManifest(ctx, manifest)
101 104
102 assert.Error(t, err)105 assert.Error(t, err)
103 assert.Contains(t, err.Error(), "failed to store manifest")106 assert.Contains(t, err.Error(), "failed to store manifest")
@@ -122,7 +125,7 @@ func TestService_PutManifest_EventPublishError(t *testing.T) {
122 eventBus.EXPECT().Publish(domain.EventImagePushed, mock.Anything).Return(errors.New("event bus error"))125 eventBus.EXPECT().Publish(domain.EventImagePushed, mock.Anything).Return(errors.New("event bus error"))
123 126
124 // Should still succeed - event publish error is logged but doesn't fail the operation127 // Should still succeed - event publish error is logged but doesn't fail the operation
125- err := svc.PutManifest(ctx, manifest)128+ _, err := svc.PutManifest(ctx, manifest)
126 129
127 assert.NoError(t, err)130 assert.NoError(t, err)
128 }131 }
@@ -144,7 +147,7 @@ func TestService_PutManifest_NilEventBus(t *testing.T) {
144 147
145 manifestStorage.EXPECT().PutManifest("myapp", "latest", "application/vnd.docker.distribution.manifest.v2+json", manifest.Data).Return(nil)148 manifestStorage.EXPECT().PutManifest("myapp", "latest", "application/vnd.docker.distribution.manifest.v2+json", manifest.Data).Return(nil)
146 149
147- err := svc.PutManifest(ctx, manifest)150+ _, err := svc.PutManifest(ctx, manifest)
148 151
149 assert.NoError(t, err)152 assert.NoError(t, err)
150 }153 }
A internal/usecase/secrets/service.go +155 -0
@@ -0,0 +1,155 @@
1+// Package secrets implements the secret management use case.
2+package secrets
3+
4+import (
5+ "context"
6+ "errors"
7+ "strings"
8+
9+ "github.com/bnema/zerowrap"
10+
11+ "gordon/internal/boundaries/out"
12+)
13+
14+// Domain validation errors.
15+var (
16+ ErrDomainEmpty = errors.New("domain cannot be empty")
17+ ErrDomainTooLong = errors.New("domain exceeds maximum length of 253 characters")
18+ ErrDomainPathTraversal = errors.New("domain contains path traversal sequence")
19+ ErrDomainInvalidChars = errors.New("domain contains invalid characters")
20+)
21+
22+// Service implements the SecretService interface.
23+type Service struct {
24+ store out.DomainSecretStore
25+ log zerowrap.Logger
26+}
27+
28+// NewService creates a new secrets service.
29+func NewService(store out.DomainSecretStore, log zerowrap.Logger) *Service {
30+ return &Service{
31+ store: store,
32+ log: log,
33+ }
34+}
35+
36+// ListKeys returns the list of secret keys for a domain (not values).
37+func (s *Service) ListKeys(ctx context.Context, domain string) ([]string, error) {
38+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
39+ zerowrap.FieldLayer: "usecase",
40+ zerowrap.FieldUseCase: "ListKeys",
41+ "domain": domain,
42+ })
43+ log := zerowrap.FromCtx(ctx)
44+
45+ if err := ValidateDomain(domain); err != nil {
46+ log.Warn().Err(err).Msg("domain validation failed")
47+ return nil, err
48+ }
49+
50+ keys, err := s.store.ListKeys(domain)
51+ if err != nil {
52+ log.Error().Err(err).Msg("failed to list secret keys")
53+ return nil, err
54+ }
55+
56+ log.Debug().Int("count", len(keys)).Msg("listed secret keys")
57+ return keys, nil
58+}
59+
60+// GetAll returns all secrets for a domain as a key-value map.
61+func (s *Service) GetAll(ctx context.Context, domain string) (map[string]string, error) {
62+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
63+ zerowrap.FieldLayer: "usecase",
64+ zerowrap.FieldUseCase: "GetAll",
65+ "domain": domain,
66+ })
67+ log := zerowrap.FromCtx(ctx)
68+
69+ if err := ValidateDomain(domain); err != nil {
70+ log.Warn().Err(err).Msg("domain validation failed")
71+ return nil, err
72+ }
73+
74+ secrets, err := s.store.GetAll(domain)
75+ if err != nil {
76+ log.Error().Err(err).Msg("failed to get secrets")
77+ return nil, err
78+ }
79+
80+ log.Debug().Int("count", len(secrets)).Msg("retrieved secrets")
81+ return secrets, nil
82+}
83+
84+// Set sets or updates multiple secrets for a domain, merging with existing.
85+func (s *Service) Set(ctx context.Context, domain string, secrets map[string]string) error {
86+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
87+ zerowrap.FieldLayer: "usecase",
88+ zerowrap.FieldUseCase: "Set",
89+ "domain": domain,
90+ })
91+ log := zerowrap.FromCtx(ctx)
92+
93+ if err := ValidateDomain(domain); err != nil {
94+ log.Warn().Err(err).Msg("domain validation failed")
95+ return err
96+ }
97+
98+ if err := s.store.Set(domain, secrets); err != nil {
99+ log.Error().Err(err).Msg("failed to set secrets")
100+ return err
101+ }
102+
103+ log.Info().Int("count", len(secrets)).Msg("secrets set")
104+ return nil
105+}
106+
107+// Delete removes a specific secret key from a domain.
108+func (s *Service) Delete(ctx context.Context, domain, key string) error {
109+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
110+ zerowrap.FieldLayer: "usecase",
111+ zerowrap.FieldUseCase: "Delete",
112+ "domain": domain,
113+ "key": key,
114+ })
115+ log := zerowrap.FromCtx(ctx)
116+
117+ if err := ValidateDomain(domain); err != nil {
118+ log.Warn().Err(err).Msg("domain validation failed")
119+ return err
120+ }
121+
122+ if err := s.store.Delete(domain, key); err != nil {
123+ log.Error().Err(err).Msg("failed to delete secret")
124+ return err
125+ }
126+
127+ log.Info().Msg("secret deleted")
128+ return nil
129+}
130+
131+// ValidateDomain validates that a domain is safe to use for secret storage.
132+// This prevents path traversal attacks and ensures the domain is valid.
133+func ValidateDomain(domain string) error {
134+ // Check for empty domain
135+ if domain == "" {
136+ return ErrDomainEmpty
137+ }
138+
139+ // Check domain length (max DNS name length is 253)
140+ if len(domain) > 253 {
141+ return ErrDomainTooLong
142+ }
143+
144+ // Check for path traversal attempts
145+ if strings.Contains(domain, "..") {
146+ return ErrDomainPathTraversal
147+ }
148+
149+ // Check for null bytes (can cause issues in file paths)
150+ if strings.ContainsRune(domain, '\x00') {
151+ return ErrDomainInvalidChars
152+ }
153+
154+ return nil
155+}
A internal/usecase/secrets/service_test.go +358 -0
@@ -0,0 +1,358 @@
1+package secrets
2+
3+import (
4+ "context"
5+ "strings"
6+ "testing"
7+
8+ "github.com/bnema/zerowrap"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/mock"
11+
12+ outmocks "gordon/internal/boundaries/out/mocks"
13+)
14+
15+func testLogger() zerowrap.Logger {
16+ return zerowrap.Default()
17+}
18+
19+func TestValidateDomain(t *testing.T) {
20+ tests := []struct {
21+ name string
22+ domain string
23+ wantErr error
24+ }{
25+ // Valid domains
26+ {
27+ name: "valid simple domain",
28+ domain: "example.com",
29+ wantErr: nil,
30+ },
31+ {
32+ name: "valid subdomain",
33+ domain: "app.example.com",
34+ wantErr: nil,
35+ },
36+ {
37+ name: "valid domain with port",
38+ domain: "app.example.com:8080",
39+ wantErr: nil,
40+ },
41+ {
42+ name: "valid domain with hyphens",
43+ domain: "my-app.example-site.com",
44+ wantErr: nil,
45+ },
46+ {
47+ name: "valid single label",
48+ domain: "localhost",
49+ wantErr: nil,
50+ },
51+ // Empty domain
52+ {
53+ name: "empty domain",
54+ domain: "",
55+ wantErr: ErrDomainEmpty,
56+ },
57+ // Path traversal attempts
58+ {
59+ name: "path traversal with double dots",
60+ domain: "../etc/passwd",
61+ wantErr: ErrDomainPathTraversal,
62+ },
63+ {
64+ name: "path traversal in middle",
65+ domain: "app/../etc/passwd",
66+ wantErr: ErrDomainPathTraversal,
67+ },
68+ {
69+ name: "path traversal at end",
70+ domain: "app.example.com/..",
71+ wantErr: ErrDomainPathTraversal,
72+ },
73+ {
74+ name: "multiple path traversal",
75+ domain: "..../....//etc/passwd",
76+ wantErr: ErrDomainPathTraversal,
77+ },
78+ {
79+ name: "encoded path traversal still blocked",
80+ domain: "app..example.com",
81+ wantErr: ErrDomainPathTraversal,
82+ },
83+ // Domain too long
84+ {
85+ name: "domain at max length (253)",
86+ domain: strings.Repeat("a", 253),
87+ wantErr: nil,
88+ },
89+ {
90+ name: "domain exceeds max length",
91+ domain: strings.Repeat("a", 254),
92+ wantErr: ErrDomainTooLong,
93+ },
94+ {
95+ name: "domain way too long",
96+ domain: strings.Repeat("a", 1000),
97+ wantErr: ErrDomainTooLong,
98+ },
99+ // Invalid characters
100+ {
101+ name: "null byte in domain",
102+ domain: "app\x00.example.com",
103+ wantErr: ErrDomainInvalidChars,
104+ },
105+ {
106+ name: "null byte at start",
107+ domain: "\x00example.com",
108+ wantErr: ErrDomainInvalidChars,
109+ },
110+ }
111+
112+ for _, tt := range tests {
113+ t.Run(tt.name, func(t *testing.T) {
114+ err := ValidateDomain(tt.domain)
115+ if tt.wantErr != nil {
116+ assert.ErrorIs(t, err, tt.wantErr)
117+ } else {
118+ assert.NoError(t, err)
119+ }
120+ })
121+ }
122+}
123+
124+func TestService_ListKeys_ValidationErrors(t *testing.T) {
125+ store := outmocks.NewMockDomainSecretStore(t)
126+ svc := NewService(store, testLogger())
127+
128+ tests := []struct {
129+ name string
130+ domain string
131+ wantErr error
132+ }{
133+ {
134+ name: "empty domain",
135+ domain: "",
136+ wantErr: ErrDomainEmpty,
137+ },
138+ {
139+ name: "path traversal",
140+ domain: "../etc/passwd",
141+ wantErr: ErrDomainPathTraversal,
142+ },
143+ {
144+ name: "domain too long",
145+ domain: strings.Repeat("a", 300),
146+ wantErr: ErrDomainTooLong,
147+ },
148+ }
149+
150+ for _, tt := range tests {
151+ t.Run(tt.name, func(t *testing.T) {
152+ keys, err := svc.ListKeys(context.Background(), tt.domain)
153+ assert.Nil(t, keys)
154+ assert.ErrorIs(t, err, tt.wantErr)
155+ })
156+ }
157+}
158+
159+func TestService_ListKeys_Success(t *testing.T) {
160+ store := outmocks.NewMockDomainSecretStore(t)
161+ svc := NewService(store, testLogger())
162+
163+ expectedKeys := []string{"API_KEY", "DB_PASSWORD"}
164+ store.EXPECT().ListKeys("app.example.com").Return(expectedKeys, nil)
165+
166+ keys, err := svc.ListKeys(context.Background(), "app.example.com")
167+ assert.NoError(t, err)
168+ assert.Equal(t, expectedKeys, keys)
169+}
170+
171+func TestService_GetAll_ValidationErrors(t *testing.T) {
172+ store := outmocks.NewMockDomainSecretStore(t)
173+ svc := NewService(store, testLogger())
174+
175+ tests := []struct {
176+ name string
177+ domain string
178+ wantErr error
179+ }{
180+ {
181+ name: "empty domain",
182+ domain: "",
183+ wantErr: ErrDomainEmpty,
184+ },
185+ {
186+ name: "path traversal",
187+ domain: "app/../secret",
188+ wantErr: ErrDomainPathTraversal,
189+ },
190+ }
191+
192+ for _, tt := range tests {
193+ t.Run(tt.name, func(t *testing.T) {
194+ secrets, err := svc.GetAll(context.Background(), tt.domain)
195+ assert.Nil(t, secrets)
196+ assert.ErrorIs(t, err, tt.wantErr)
197+ })
198+ }
199+}
200+
201+func TestService_GetAll_Success(t *testing.T) {
202+ store := outmocks.NewMockDomainSecretStore(t)
203+ svc := NewService(store, testLogger())
204+
205+ expectedSecrets := map[string]string{"API_KEY": "secret123", "DB_PASSWORD": "pass456"}
206+ store.EXPECT().GetAll("app.example.com").Return(expectedSecrets, nil)
207+
208+ secrets, err := svc.GetAll(context.Background(), "app.example.com")
209+ assert.NoError(t, err)
210+ assert.Equal(t, expectedSecrets, secrets)
211+}
212+
213+func TestService_Set_ValidationErrors(t *testing.T) {
214+ store := outmocks.NewMockDomainSecretStore(t)
215+ svc := NewService(store, testLogger())
216+
217+ secrets := map[string]string{"API_KEY": "secret123"}
218+
219+ tests := []struct {
220+ name string
221+ domain string
222+ wantErr error
223+ }{
224+ {
225+ name: "empty domain",
226+ domain: "",
227+ wantErr: ErrDomainEmpty,
228+ },
229+ {
230+ name: "path traversal",
231+ domain: "../../../etc/passwd",
232+ wantErr: ErrDomainPathTraversal,
233+ },
234+ }
235+
236+ for _, tt := range tests {
237+ t.Run(tt.name, func(t *testing.T) {
238+ err := svc.Set(context.Background(), tt.domain, secrets)
239+ assert.ErrorIs(t, err, tt.wantErr)
240+ })
241+ }
242+}
243+
244+func TestService_Set_Success(t *testing.T) {
245+ store := outmocks.NewMockDomainSecretStore(t)
246+ svc := NewService(store, testLogger())
247+
248+ secrets := map[string]string{"API_KEY": "secret123"}
249+ store.EXPECT().Set("app.example.com", secrets).Return(nil)
250+
251+ err := svc.Set(context.Background(), "app.example.com", secrets)
252+ assert.NoError(t, err)
253+}
254+
255+func TestService_Delete_ValidationErrors(t *testing.T) {
256+ store := outmocks.NewMockDomainSecretStore(t)
257+ svc := NewService(store, testLogger())
258+
259+ tests := []struct {
260+ name string
261+ domain string
262+ wantErr error
263+ }{
264+ {
265+ name: "empty domain",
266+ domain: "",
267+ wantErr: ErrDomainEmpty,
268+ },
269+ {
270+ name: "path traversal",
271+ domain: "..%2F..%2Fetc%2Fpasswd",
272+ wantErr: ErrDomainPathTraversal,
273+ },
274+ }
275+
276+ for _, tt := range tests {
277+ t.Run(tt.name, func(t *testing.T) {
278+ err := svc.Delete(context.Background(), tt.domain, "API_KEY")
279+ assert.ErrorIs(t, err, tt.wantErr)
280+ })
281+ }
282+}
283+
284+func TestService_Delete_Success(t *testing.T) {
285+ store := outmocks.NewMockDomainSecretStore(t)
286+ svc := NewService(store, testLogger())
287+
288+ store.EXPECT().Delete("app.example.com", "API_KEY").Return(nil)
289+
290+ err := svc.Delete(context.Background(), "app.example.com", "API_KEY")
291+ assert.NoError(t, err)
292+}
293+
294+func TestService_StoreError_PropagatedCorrectly(t *testing.T) {
295+ store := outmocks.NewMockDomainSecretStore(t)
296+ svc := NewService(store, testLogger())
297+
298+ storeErr := assert.AnError
299+ store.EXPECT().ListKeys("app.example.com").Return(nil, storeErr)
300+
301+ keys, err := svc.ListKeys(context.Background(), "app.example.com")
302+ assert.Nil(t, keys)
303+ assert.ErrorIs(t, err, storeErr)
304+}
305+
306+// TestPathTraversalVariants tests various path traversal attack patterns.
307+func TestPathTraversalVariants(t *testing.T) {
308+ tests := []struct {
309+ name string
310+ domain string
311+ }{
312+ {"simple parent dir", ".."},
313+ {"parent dir prefix", "../secret"},
314+ {"parent dir suffix", "secret/.."},
315+ {"parent dir in middle", "app/../secret"},
316+ {"multiple parent dirs", "../../etc/passwd"},
317+ {"windows style", "..\\..\\etc\\passwd"},
318+ {"mixed slashes with dots", "app/./../../etc"},
319+ {"double dots only", "...."},
320+ {"triple dots", "..."},
321+ {"dot dot in subdomain style", "app..evil.com"},
322+ }
323+
324+ for _, tt := range tests {
325+ t.Run(tt.name, func(t *testing.T) {
326+ err := ValidateDomain(tt.domain)
327+ // All these should be rejected (either path traversal or other validation)
328+ // The main goal is that ".." patterns are caught
329+ if strings.Contains(tt.domain, "..") {
330+ assert.ErrorIs(t, err, ErrDomainPathTraversal, "domain %q should be rejected for path traversal", tt.domain)
331+ }
332+ })
333+ }
334+}
335+
336+// TestService_ValidationHappensBeforeStore ensures validation runs before any store calls.
337+func TestService_ValidationHappensBeforeStore(t *testing.T) {
338+ store := outmocks.NewMockDomainSecretStore(t)
339+ svc := NewService(store, testLogger())
340+
341+ // With path traversal domain, store methods should never be called
342+ // We don't set up any expectations, so if store is called, the test will fail
343+
344+ _, err := svc.ListKeys(context.Background(), "../etc/passwd")
345+ assert.ErrorIs(t, err, ErrDomainPathTraversal)
346+
347+ _, err = svc.GetAll(context.Background(), "../etc/passwd")
348+ assert.ErrorIs(t, err, ErrDomainPathTraversal)
349+
350+ err = svc.Set(context.Background(), "../etc/passwd", map[string]string{"key": "value"})
351+ assert.ErrorIs(t, err, ErrDomainPathTraversal)
352+
353+ err = svc.Delete(context.Background(), "../etc/passwd", "key")
354+ assert.ErrorIs(t, err, ErrDomainPathTraversal)
355+
356+ // Verify no store methods were called (mockery would fail if unexpected calls happened)
357+ mock.AssertExpectationsForObjects(t, store)
358+}
M wiki/examples/index.md +3 -3
@@ -14,7 +14,7 @@ Annotated Gordon configuration examples for common scenarios.
14 14
15 ```toml15 ```toml
16 [server]16 [server]
17-registry_domain = "registry.local"17+gordon_domain = "gordon.local"
18 18
19 [routes]19 [routes]
20 "app.local" = "myapp:latest"20 "app.local" = "myapp:latest"
@@ -25,7 +25,7 @@ registry_domain = "registry.local"
25 ```toml25 ```toml
26 [server]26 [server]
27 port = 808027 port = 8080
28-registry_domain = "registry.local"28+gordon_domain = "gordon.local"
29 29
30 [registry_auth]30 [registry_auth]
31 enabled = false31 enabled = false
@@ -42,7 +42,7 @@ enabled = true
42 ```toml42 ```toml
43 [server]43 [server]
44 port = 808044 port = 8080
45-registry_domain = "registry.company.com"45+gordon_domain = "gordon.company.com"
46 46
47 [secrets]47 [secrets]
48 backend = "pass"48 backend = "pass"
M wiki/examples/minimal.md +5 -5
@@ -16,7 +16,7 @@ The simplest working Gordon configuration.
16 [server]16 [server]
17 port = 808017 port = 8080
18 registry_port = 500018 registry_port = 5000
19-registry_domain = "registry.local"19+gordon_domain = "gordon.local"
20 20
21 # Authentication disabled for simplicity21 # Authentication disabled for simplicity
22 [registry_auth]22 [registry_auth]
@@ -32,21 +32,21 @@ enabled = false
32 ### 1. Add to /etc/hosts32 ### 1. Add to /etc/hosts
33 33
34 ```bash34 ```bash
35-echo "127.0.0.1 app.local registry.local" | sudo tee -a /etc/hosts35+echo "127.0.0.1 app.local gordon.local" | sudo tee -a /etc/hosts
36 ```36 ```
37 37
38 ### 2. Start Gordon38 ### 2. Start Gordon
39 39
40 ```bash40 ```bash
41-gordon start41+gordon serve
42 ```42 ```
43 43
44 ### 3. Build and Deploy44 ### 3. Build and Deploy
45 45
46 ```bash46 ```bash
47 docker build -t myapp .47 docker build -t myapp .
48-docker tag myapp registry.local:5000/myapp:latest48+docker tag myapp gordon.local:5000/myapp:latest
49-docker push registry.local:5000/myapp:latest49+docker push gordon.local:5000/myapp:latest
50 ```50 ```
51 51
52 ### 4. Access52 ### 4. Access
M wiki/examples/production.md +3 -3
@@ -17,7 +17,7 @@ A complete Gordon configuration for production environments.
17 [server]17 [server]
18 port = 8080 # Cloudflare forwards 443 → 808018 port = 8080 # Cloudflare forwards 443 → 8080
19 registry_port = 500019 registry_port = 5000
20-registry_domain = "registry.company.com"20+gordon_domain = "gordon.company.com"
21 21
22 # Use pass for secrets (recommended)22 # Use pass for secrets (recommended)
23 [secrets]23 [secrets]
@@ -161,10 +161,10 @@ systemctl --user enable --now gordon
161 docker build -t company-app .161 docker build -t company-app .
162 162
163 # Tag with version163 # Tag with version
164-docker tag company-app registry.company.com/company-app:v2.2.0164+docker tag company-app gordon.company.com/company-app:v2.2.0
165 165
166 # Push to deploy166 # Push to deploy
167-docker push registry.company.com/company-app:v2.2.0167+docker push gordon.company.com/company-app:v2.2.0
168 168
169 # Update config with new version169 # Update config with new version
170 vim ~/.config/gordon/gordon.toml170 vim ~/.config/gordon/gordon.toml
M wiki/guides/index.md +4 -0
@@ -11,6 +11,10 @@ In-depth guides for specific Gordon setups and integrations.
11 11
12 - [Cloudflare DNS & Proxy Setup](./cloudflare-setup.md) - HTTPS termination with Cloudflare12 - [Cloudflare DNS & Proxy Setup](./cloudflare-setup.md) - HTTPS termination with Cloudflare
13 13
14+## Remote Management
15+
16+- [Remote CLI Management](./remote-cli.md) - Manage Gordon instances remotely
17+
14 ## Secrets Management18 ## Secrets Management
15 19
16 - [Using Pass for Secrets](./secrets-pass.md) - Unix password manager integration20 - [Using Pass for Secrets](./secrets-pass.md) - Unix password manager integration
M wiki/guides/podman-rootless.md +3 -3
@@ -109,7 +109,7 @@ sudo mv gordon /usr/local/bin/
109 Generate initial config:109 Generate initial config:
110 110
111 ```bash111 ```bash
112-gordon start112+gordon serve
113 # Press Ctrl+C after config is created113 # Press Ctrl+C after config is created
114 ```114 ```
115 115
@@ -119,7 +119,7 @@ Edit `~/.config/gordon/gordon.toml`:
119 [server]119 [server]
120 port = 8080 # Must match firewall forward (80 → 8080)120 port = 8080 # Must match firewall forward (80 → 8080)
121 registry_port = 5000121 registry_port = 5000
122-registry_domain = "registry.mydomain.com"122+gordon_domain = "gordon.mydomain.com"
123 123
124 [routes]124 [routes]
125 "app.mydomain.com" = "myapp:latest"125 "app.mydomain.com" = "myapp:latest"
@@ -138,7 +138,7 @@ After=podman.socket
138 Type=simple138 Type=simple
139 Restart=always139 Restart=always
140 RestartSec=5140 RestartSec=5
141-ExecStart=/usr/local/bin/gordon start141+ExecStart=/usr/local/bin/gordon serve
142 142
143 [Install]143 [Install]
144 WantedBy=default.target144 WantedBy=default.target
A wiki/guides/remote-cli.md +392 -0
@@ -0,0 +1,392 @@
1+# Remote CLI Management
2+
3+Manage Gordon instances remotely using the CLI with the `--remote` flag or saved remotes.
4+
5+## What You'll Learn
6+
7+- Using global `--remote` and `--token` flags
8+- Saving and managing remote connections
9+- Environment variable configuration
10+- Token security best practices
11+- Remote command workflows
12+
13+## Prerequisites
14+
15+- Gordon CLI installed
16+- A remote Gordon instance running with admin API enabled
17+- Authentication token for the remote instance
18+
19+## Understanding the Remote URL
20+
21+The `--remote` URL must be the `gordon_domain` configured on the remote Gordon instance. This domain serves both the container registry and the Admin API.
22+
23+```toml
24+# On the remote Gordon server
25+[server]
26+gordon_domain = "gordon.example.com" # Use this as --remote URL
27+```
28+
29+The CLI connects to `https://gordon.example.com/admin/*` endpoints.
30+
31+## Quick Start
32+
33+### One-off Remote Command
34+
35+```bash
36+gordon routes list --remote https://gordon.mydomain.com --token $TOKEN
37+```
38+
39+### Using Environment Variables
40+
41+```bash
42+export GORDON_REMOTE=https://gordon.mydomain.com
43+export GORDON_TOKEN=$TOKEN
44+gordon routes list
45+```
46+
47+### Using Saved Remotes
48+
49+```bash
50+# Add a remote
51+gordon remotes add prod https://gordon.mydomain.com --token-env PROD_TOKEN
52+
53+# Set as active
54+gordon remotes use prod
55+
56+# Now use without flags
57+gordon routes list
58+```
59+
60+## Global Flags
61+
62+These flags are available on all commands:
63+
64+| Flag | Description |
65+|------|-------------|
66+| `--remote <URL>` | Remote Gordon URL |
67+| `--token <TOKEN>` | Authentication token |
68+
69+```bash
70+# List routes on remote
71+gordon routes list --remote https://gordon.mydomain.com --token $TOKEN
72+
73+# Manage secrets on remote
74+gordon secrets list myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN
75+```
76+
77+## Environment Variables
78+
79+| Variable | Description |
80+|----------|-------------|
81+| `GORDON_REMOTE` | Remote Gordon URL |
82+| `GORDON_TOKEN` | Authentication token |
83+
84+Environment variables are useful for CI/CD pipelines and shell sessions:
85+
86+```bash
87+# Set for current session
88+export GORDON_REMOTE=https://gordon.mydomain.com
89+export GORDON_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
90+
91+# Commands automatically use these
92+gordon routes list
93+gordon secrets list myapp.example.com
94+```
95+
96+## Saved Remotes
97+
98+Save frequently used remotes to avoid repeating URLs and tokens.
99+
100+### Configuration File
101+
102+Remotes are stored in `~/.config/gordon/remotes.toml`:
103+
104+```toml
105+active = "prod"
106+
107+[remotes.prod]
108+url = "https://gordon.mydomain.com"
109+token_env = "PROD_TOKEN"
110+
111+[remotes.staging]
112+url = "https://staging.mydomain.com"
113+token = "eyJ..."
114+```
115+
116+### Managing Remotes
117+
118+#### List Remotes
119+
120+```bash
121+gordon remotes list
122+```
123+
124+Output:
125+
126+```
127+Saved Remotes
128+
129+Name URL Token Status
130+──────────────────────────────────────────────────────────────────────────────
131+prod https://gordon.mydomain.com $PROD_TOKEN active
132+staging https://staging.mydomain.com set
133+dev https://dev.mydomain.com none
134+```
135+
136+#### Add a Remote
137+
138+```bash
139+# Basic (no token)
140+gordon remotes add prod https://gordon.mydomain.com
141+
142+# With direct token
143+gordon remotes add prod https://gordon.mydomain.com --token eyJ...
144+
145+# With environment variable reference (recommended)
146+gordon remotes add prod https://gordon.mydomain.com --token-env PROD_TOKEN
147+```
148+
149+| Flag | Description |
150+|------|-------------|
151+| `--token <TOKEN>` | Store token directly in config |
152+| `--token-env <VAR>` | Store env variable name (token resolved at runtime) |
153+
154+#### Remove a Remote
155+
156+```bash
157+# With confirmation prompt
158+gordon remotes remove staging
159+
160+# Skip confirmation
161+gordon remotes remove staging --force
162+```
163+
164+#### Set Active Remote
165+
166+```bash
167+gordon remotes use prod
168+```
169+
170+When a remote is active, it's used automatically for all remote-capable commands:
171+
172+```bash
173+gordon remotes use prod
174+gordon routes list # Uses prod remote
175+gordon secrets list app.com # Uses prod remote
176+```
177+
178+## Resolution Precedence
179+
180+When multiple sources specify remote or token, the CLI uses this priority order:
181+
182+**Remote URL:**
183+1. `--remote` flag
184+2. `GORDON_REMOTE` environment variable
185+3. Active remote from `remotes.toml`
186+
187+**Token:**
188+1. `--token` flag
189+2. `GORDON_TOKEN` environment variable
190+3. Token from active remote in `remotes.toml`
191+
192+This allows overriding specific values while keeping defaults:
193+
194+```bash
195+# Active remote is prod, but use different token
196+gordon routes list --token $TEMPORARY_TOKEN
197+```
198+
199+## Commands Supporting Remote
200+
201+These commands work with remote targeting:
202+
203+| Command | Description |
204+|---------|-------------|
205+| `gordon routes list` | List all routes |
206+| `gordon routes add <domain> <image>` | Add a route |
207+| `gordon routes remove <domain>` | Remove a route |
208+| `gordon routes deploy <domain>` | Deploy/redeploy a route |
209+| `gordon secrets list <domain>` | List secrets for a domain |
210+| `gordon secrets set <domain> KEY=value` | Set secrets |
211+| `gordon secrets remove <domain> <key>` | Remove a secret |
212+| `gordon status` | Show server status |
213+
214+## Token Security
215+
216+### Option 1: Environment Variable Reference (Recommended)
217+
218+Store the environment variable name, not the actual token:
219+
220+```bash
221+gordon remotes add prod https://gordon.mydomain.com --token-env PROD_TOKEN
222+```
223+
224+The `remotes.toml` stores only:
225+
226+```toml
227+[remotes.prod]
228+url = "https://gordon.mydomain.com"
229+token_env = "PROD_TOKEN"
230+```
231+
232+At runtime, Gordon reads `$PROD_TOKEN` from the environment.
233+
234+**Benefits:**
235+- Token not stored in plaintext config
236+- Works with secret managers that inject env vars
237+- Easy rotation without editing config
238+
239+### Option 2: Direct Token Storage
240+
241+```bash
242+gordon remotes add prod https://gordon.mydomain.com --token eyJ...
243+```
244+
245+The token is stored directly in `remotes.toml`:
246+
247+```toml
248+[remotes.prod]
249+url = "https://gordon.mydomain.com"
250+token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
251+```
252+
253+**Security note:** Ensure `remotes.toml` has restricted permissions:
254+
255+```bash
256+chmod 600 ~/.config/gordon/remotes.toml
257+```
258+
259+### Option 3: Environment Variables Only
260+
261+For maximum security, don't save tokens at all:
262+
263+```bash
264+# Add remote without token
265+gordon remotes add prod https://gordon.mydomain.com
266+
267+# Set token via environment
268+export GORDON_TOKEN=$TOKEN
269+
270+# Use normally
271+gordon remotes use prod
272+gordon routes list
273+```
274+
275+## Workflow Examples
276+
277+### Development Workflow
278+
279+```bash
280+# Add dev and staging remotes
281+gordon remotes add dev https://gordon.dev.local --token-env DEV_TOKEN
282+gordon remotes add staging https://gordon.staging.example.com --token-env STAGING_TOKEN
283+
284+# Work with dev
285+gordon remotes use dev
286+gordon routes list
287+gordon routes add myapp.dev.local myapp:latest
288+
289+# Switch to staging
290+gordon remotes use staging
291+gordon routes list
292+```
293+
294+### CI/CD Pipeline
295+
296+```yaml
297+# GitHub Actions example
298+env:
299+ GORDON_REMOTE: ${{ secrets.GORDON_URL }}
300+ GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }}
301+
302+steps:
303+ - name: Deploy to Gordon
304+ run: |
305+ gordon routes deploy myapp.example.com
306+```
307+
308+```bash
309+# GitLab CI example
310+deploy:
311+ script:
312+ - export GORDON_REMOTE=$GORDON_URL
313+ - export GORDON_TOKEN=$GORDON_TOKEN
314+ - gordon routes deploy myapp.example.com
315+```
316+
317+### Multi-Environment Management
318+
319+```bash
320+# Add all environments
321+gordon remotes add prod https://gordon.example.com --token-env PROD_TOKEN
322+gordon remotes add staging https://gordon.staging.example.com --token-env STAGING_TOKEN
323+gordon remotes add dev https://gordon.dev.example.com --token-env DEV_TOKEN
324+
325+# Compare routes across environments
326+gordon routes list --remote https://gordon.example.com --token $PROD_TOKEN
327+gordon routes list --remote https://gordon.staging.example.com --token $STAGING_TOKEN
328+
329+# Or switch active
330+gordon remotes use prod && gordon routes list
331+gordon remotes use staging && gordon routes list
332+```
333+
334+## Troubleshooting
335+
336+### "unauthorized" Error
337+
338+Token is missing, expired, or invalid.
339+
340+```bash
341+# Check if token is set
342+echo $GORDON_TOKEN
343+
344+# Verify token works
345+gordon routes list --remote https://gordon.mydomain.com --token $TOKEN
346+```
347+
348+### "connection refused" Error
349+
350+Remote Gordon isn't running or URL is wrong.
351+
352+```bash
353+# Verify URL is correct
354+curl https://gordon.mydomain.com/health
355+
356+# Check if admin API is enabled on remote
357+# The remote gordon.toml needs:
358+# [admin]
359+# enabled = true
360+```
361+
362+### Remote Not Found
363+
364+The specified remote doesn't exist in `remotes.toml`.
365+
366+```bash
367+# List available remotes
368+gordon remotes list
369+
370+# Add the remote
371+gordon remotes add myremote https://gordon.mydomain.com
372+```
373+
374+### Active Remote Cleared
375+
376+After removing the active remote, you need to set a new active:
377+
378+```bash
379+gordon remotes use prod
380+```
381+
382+Or use explicit flags:
383+
384+```bash
385+gordon routes list --remote https://gordon.mydomain.com --token $TOKEN
386+```
387+
388+## Related
389+
390+- [CLI Commands](/docs/cli/commands.md)
391+- [Authentication](/docs/config/registry-auth.md)
392+- [Admin API](/docs/config/admin.md)
M wiki/guides/running-in-container.md +1 -1
@@ -45,7 +45,7 @@ Create `gordon.toml`:
45 [server]45 [server]
46 port = 808046 port = 8080
47 registry_port = 500047 registry_port = 5000
48-registry_domain = "registry.mydomain.com"48+gordon_domain = "gordon.mydomain.com"
49 49
50 [routes]50 [routes]
51 "app.mydomain.com" = "myapp:latest"51 "app.mydomain.com" = "myapp:latest"

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close