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:
2020 EnvLoader:
2121 ContainerLogWriter:
2222 TokenStore:
23+ DomainSecretStore:
2324 gordon/internal/boundaries/in:
2425 interfaces:
2526 ContainerService:
@@ -27,3 +28,6 @@ packages:
2728 ProxyService:
2829 ConfigService:
2930 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 \
6363 CMD wget --quiet --tries=1 --spider http://localhost:8080/health || exit 1
6464
6565 # Default command
66-CMD ["./gordon", "start"]
66+CMD ["./gordon", "serve"]
6767
6868 # Metadata
6969 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.
2727 - Private Docker registry on your VPS
2828 - Domain-to-container routing via HTTP reverse proxy
2929 - Automatic deployment on image push
30+- Auto-routing from image labels
31+- Remote CLI management
3032 - Zero downtime updates
3133 - Persistent volumes from Dockerfile VOLUME directives
3234 - Environment variable management with secrets support
@@ -48,7 +50,7 @@ curl -fsSL https://gordon.bnema.dev/install.sh | sh
4850 # Or: curl -fsSL https://raw.githubusercontent.com/bnema/gordon/main/install.sh | sh
4951
5052 # Start Gordon (generates config on first run)
51-gordon start
53+gordon serve
5254 ```
5355
5456 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
66
77 | Command | Description |
88 |---------|-------------|
9-| `gordon start` | Start the Gordon server |
9+| `gordon serve` | Start the Gordon server |
1010 | `gordon reload` | Reload configuration and sync containers |
1111 | `gordon deploy` | Manually deploy or redeploy a specific route |
1212 | `gordon logs` | Display Gordon process logs |
1313 | `gordon version` | Print version information |
1414 | `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 |
1518
1619 ## Quick Reference
1720
1821 ```bash
1922 # Start Gordon
20-gordon start
21-gordon start --config /path/to/config.toml
23+gordon serve
24+gordon serve --config /path/to/config.toml
2225
2326 # Reload configuration
2427 gordon reload
@@ -47,17 +50,35 @@ gordon auth internal
4750 | Option | Description |
4851 |--------|-------------|
4952 | `-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+```
5071
5172 ---
5273
53-## gordon start
74+## gordon serve
5475
5576 Start the Gordon server with registry and proxy components.
5677
5778 ### Synopsis
5879
5980 ```bash
60-gordon start [options]
81+gordon serve [options]
6182 ```
6283
6384 ### Options
@@ -90,7 +111,7 @@ On first run without a config file, Gordon creates a default configuration at `~
90111
91112 ```bash
92113 # First run - creates default config
93-gordon start
114+gordon serve
94115 # Edit the config, then restart
95116 ```
96117
@@ -98,15 +119,15 @@ gordon start
98119
99120 ```bash
100121 # Basic start
101-gordon start
122+gordon serve
102123
103124 # With custom config
104-gordon start --config /path/to/gordon.toml
105-gordon start -c ./my-config.toml
125+gordon serve --config /path/to/gordon.toml
126+gordon serve -c ./my-config.toml
106127
107128 # With environment override
108-GORDON_SERVER_PORT=8080 gordon start
109-GORDON_LOGGING_LEVEL=debug gordon start
129+GORDON_SERVER_PORT=8080 gordon serve
130+GORDON_LOGGING_LEVEL=debug gordon serve
110131 ```
111132
112133 ### Signals
@@ -132,7 +153,7 @@ Description=Gordon Container Platform
132153 [Service]
133154 Type=simple
134155 Restart=always
135-ExecStart=/usr/local/bin/gordon start
156+ExecStart=/usr/local/bin/gordon serve
136157
137158 [Install]
138159 WantedBy=default.target
@@ -529,6 +550,119 @@ password_hash = "gordon/registry/password_hash"
529550
530551 ---
531552
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+
532666 ## Exit Codes
533667
534668 | Code | Meaning |
@@ -542,8 +676,8 @@ password_hash = "gordon/registry/password_hash"
542676 Gordon reads configuration from environment variables:
543677
544678 ```bash
545-GORDON_SERVER_PORT=8080 gordon start
546-GORDON_LOGGING_LEVEL=debug gordon start
679+GORDON_SERVER_PORT=8080 gordon serve
680+GORDON_LOGGING_LEVEL=debug gordon serve
547681 ```
548682
549683 Pattern: `GORDON_SECTION_KEY` (uppercase, underscores)
M docs/config/auto-route.md +120 -6
@@ -1,6 +1,9 @@
11 # Auto Route Configuration
22
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)
47
58 ## Configuration
69
@@ -123,7 +126,7 @@ enabled = true
123126
124127 ```toml
125128 [server]
126-registry_domain = "registry.local"
129+gordon_domain = "gordon.local"
127130
128131 [auto_route]
129132 enabled = true
@@ -133,8 +136,8 @@ enabled = true
133136
134137 Usage:
135138 ```bash
136-docker push registry.local/myapp.local:latest
137-docker push registry.local/api.local:latest
139+docker push gordon.local/myapp.local:latest
140+docker push gordon.local/api.local:latest
138141 # Both routes created automatically
139142 ```
140143
@@ -152,14 +155,125 @@ enabled = true
152155 # Other services can use auto-route
153156 ```
154157
155-## Limitations
158+## Limitations (Image Name Detection)
156159
157160 - Auto-routes always use the pushed image tag
158161 - No support for HTTP-only routes (all HTTPS)
159162 - No automatic attachment configuration
160163 - Cannot pin versions (always uses pushed tag)
161164
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+```
163277
164278 ## Related
165279
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
77 | Location | Purpose |
88 |----------|---------|
99 | `~/.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` |
1111
1212 ## Minimal Configuration
1313
@@ -15,7 +15,7 @@ Gordon uses a single TOML configuration file located at `~/.config/gordon/gordon
1515 [server]
1616 port = 8080
1717 registry_port = 5000
18-registry_domain = "registry.mydomain.com"
18+gordon_domain = "gordon.mydomain.com"
1919
2020 [routes]
2121 "app.mydomain.com" = "myapp:latest"
@@ -30,7 +30,7 @@ registry_domain = "registry.mydomain.com"
3030 [server]
3131 port = 8080 # HTTP proxy port (default: 80)
3232 registry_port = 5000 # Registry port (default: 5000)
33-registry_domain = "registry.mydomain.com" # Required: registry domain
33+gordon_domain = "gordon.mydomain.com" # Required: Gordon domain (registry + API)
3434 # data_dir = "~/.gordon" # Default for user installations
3535
3636 # Secrets backend
@@ -48,6 +48,10 @@ type = "token" # "password" or "token"
4848 token_secret = "gordon/registry/token_secret"
4949 token_expiry = "720h" # Duration or 0 for never
5050
51+# Deploy behavior
52+[deploy]
53+pull_policy = "if-tag-changed" # always, if-not-present, if-tag-changed
54+
5155 # Logging
5256 [logging]
5357 level = "info" # trace, debug, info, warn, error
@@ -109,6 +113,7 @@ enabled = false # Auto-create routes from image names
109113 | `[server]` | Core server settings | [Server](./server.md) |
110114 | `[secrets]` | Secrets backend configuration | [Secrets](./secrets.md) |
111115 | `[registry_auth]` | Registry authentication | [Registry Auth](./registry-auth.md) |
116+| `[deploy]` | Deployment behavior | [Deploy](./deploy.md) |
112117 | `[logging]` | Logging configuration | [Logging](./logging.md) |
113118 | `[env]` | Environment variable settings | [Environment](./env.md) |
114119 | `[volumes]` | Volume management | [Volumes](./volumes.md) |
@@ -130,6 +135,7 @@ enabled = false # Auto-create routes from image names
130135 | `registry_auth.enabled` | `false` |
131136 | `registry_auth.type` | `"password"` |
132137 | `registry_auth.token_expiry` | `"720h"` |
138+| `deploy.pull_policy` | `"if-tag-changed"` |
133139 | `logging.level` | `"info"` |
134140 | `logging.format` | `"console"` |
135141 | `logging.file.enabled` | `false` |
@@ -158,14 +164,15 @@ The following settings require a restart to take effect:
158164 - `server.data_dir`
159165 - `secrets.backend`
160166 - `registry_auth` settings
167+- `deploy.pull_policy`
161168
162169 ## Environment Variable Override
163170
164171 Configuration values can be overridden with environment variables:
165172
166173 ```bash
167-GORDON_SERVER_PORT=8080 gordon start
168-GORDON_LOGGING_LEVEL=debug gordon start
174+GORDON_SERVER_PORT=8080 gordon serve
175+GORDON_LOGGING_LEVEL=debug gordon serve
169176 ```
170177
171178 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.
88 [server]
99 port = 8080 # HTTP proxy port
1010 registry_port = 5000 # Container registry port
11-registry_domain = "registry.mydomain.com" # Registry domain (required)
11+gordon_domain = "gordon.mydomain.com" # Gordon domain (required)
1212 # data_dir = "~/.gordon" # Data storage directory (default)
1313 ```
1414
@@ -18,7 +18,8 @@ registry_domain = "registry.mydomain.com" # Registry domain (required)
1818 |--------|------|---------|-------------|
1919 | `port` | int | `80` | HTTP proxy port for routing traffic to containers |
2020 | `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` |
2223 | `data_dir` | string | `~/.gordon` | Directory for registry data, logs, and env files |
2324
2425 ## Port Configuration
@@ -54,19 +55,24 @@ Docker/Podman clients push to this port:
5455 docker push registry.mydomain.com:5000/myapp:latest
5556 ```
5657
57-## Registry Domain
58+## Gordon Domain
5859
59-The `registry_domain` is required and must match your DNS configuration:
60+The `gordon_domain` is required and must match your DNS configuration:
6061
6162 ```toml
6263 [server]
63-registry_domain = "registry.mydomain.com"
64+gordon_domain = "gordon.mydomain.com"
6465 ```
6566
6667 This domain is used for:
67-- Docker login authentication
68-- Image push/pull operations
69-- Internal registry references
68+- Docker login and image push/pull operations
69+- Admin API access (`/admin/*` endpoints)
70+- CLI remote targeting (`gordon routes --remote https://gordon.mydomain.com`)
71+- Token endpoint for authentication (`/v2/token`)
72+
73+When requests arrive on the proxy port with this domain as the Host header, Gordon routes them to the backend services (registry and admin API).
74+
75+> **Note:** `registry_domain` is supported as a deprecated alias for backwards compatibility.
7076
7177 ## Data Directory
7278
@@ -108,7 +114,7 @@ Gordon creates directories with secure permissions:
108114 [server]
109115 port = 8080
110116 registry_port = 5000
111-registry_domain = "registry.local"
117+gordon_domain = "gordon.local"
112118 data_dir = "./dev-data"
113119 ```
114120
@@ -118,7 +124,7 @@ data_dir = "./dev-data"
118124 [server]
119125 port = 8080
120126 registry_port = 5000
121-registry_domain = "registry.company.com"
127+gordon_domain = "gordon.company.com"
122128 data_dir = "/var/lib/gordon"
123129 ```
124130
@@ -128,7 +134,7 @@ data_dir = "/var/lib/gordon"
128134 [server]
129135 port = 8080 # High port for rootless
130136 registry_port = 5000
131-registry_domain = "registry.mydomain.com"
137+gordon_domain = "gordon.mydomain.com"
132138 ```
133139
134140 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
2020
2121 ```bash
2222 # First run creates the default config
23-gordon start
23+gordon serve
2424 # Press Ctrl+C to stop
2525 ```
2626
@@ -34,7 +34,7 @@ Edit `~/.config/gordon/gordon.toml`:
3434 [server]
3535 port = 8080 # Proxy port (use with Cloudflare)
3636 registry_port = 5000 # Registry port
37-registry_domain = "registry.mydomain.com" # Your registry domain
37+gordon_domain = "gordon.mydomain.com" # Your Gordon domain
3838
3939 [routes]
4040 "app.mydomain.com" = "myapp:latest" # Domain → Image mapping
@@ -63,7 +63,7 @@ Description=Gordon Container Platform
6363 [Service]
6464 Type=simple
6565 Restart=always
66-ExecStart=/usr/local/bin/gordon start
66+ExecStart=/usr/local/bin/gordon serve
6767
6868 [Install]
6969 WantedBy=default.target
M docs/installation.md +4 -4
@@ -153,7 +153,7 @@ Gordon creates a default configuration on first run:
153153
154154 ```bash
155155 # Run once to generate config, then stop
156-gordon start
156+gordon serve
157157 # Press Ctrl+C
158158
159159 # Edit configuration
@@ -166,7 +166,7 @@ Minimum required configuration:
166166 [server]
167167 port = 8080
168168 registry_port = 5000
169-registry_domain = "registry.yourdomain.com"
169+gordon_domain = "gordon.yourdomain.com"
170170
171171 [routes]
172172 "app.yourdomain.com" = "myapp:latest"
@@ -189,7 +189,7 @@ After=podman.socket
189189 Type=simple
190190 Restart=always
191191 RestartSec=5
192-ExecStart=/usr/local/bin/gordon start
192+ExecStart=/usr/local/bin/gordon serve
193193
194194 [Install]
195195 WantedBy=default.target
@@ -219,7 +219,7 @@ Requires=docker.service
219219 Type=simple
220220 Restart=always
221221 RestartSec=5
222-ExecStart=/usr/local/bin/gordon start
222+ExecStart=/usr/local/bin/gordon serve
223223 User=root
224224
225225 [Install]
M docs/reference/troubleshooting.md +1 -1
@@ -35,7 +35,7 @@ Common issues and solutions when using Gordon.
3535 ```bash
3636 systemctl --user status gordon
3737 # or
38- pgrep -f "gordon start"
38+ pgrep -f "gordon serve"
3939 ```
4040
4141 2. Check registry port is accessible:
M go.mod +20 -2
@@ -4,6 +4,9 @@ go 1.25.5
44
55 require (
66 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
710 github.com/docker/docker v28.5.2+incompatible
811 github.com/docker/go-connections v0.5.0
912 github.com/golang-jwt/jwt/v5 v5.3.0
@@ -17,8 +20,23 @@ require (
1720 )
1821
1922 require (
23+ github.com/atotto/clipboard v0.1.4 // indirect
24+ github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
2025 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
2138 github.com/rs/zerolog v1.34.0 // indirect
39+ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
2240 go.yaml.in/yaml/v3 v3.0.4 // indirect
2341 )
2442
@@ -27,7 +45,7 @@ require (
2745 github.com/pmezard/go-difflib v1.0.0 // indirect
2846 github.com/stretchr/objx v0.5.2 // indirect
2947 go.uber.org/mock v0.5.2 // direct
30- golang.org/x/time v0.12.0 // indirect
48+ golang.org/x/time v0.14.0
3149 )
3250
3351 require (
@@ -51,7 +69,7 @@ require (
5169 github.com/morikuni/aec v1.0.0 // indirect
5270 github.com/opencontainers/go-digest v1.0.0 // indirect
5371 github.com/opencontainers/image-spec v1.1.1 // indirect
54- github.com/pelletier/go-toml/v2 v2.2.4 // indirect
72+ github.com/pelletier/go-toml/v2 v2.2.4
5573 github.com/pkg/errors v0.9.1 // indirect
5674 github.com/sagikazarmark/locafero v0.12.0 // indirect
5775 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 @@
33 package cli
44
55 import (
6- "context"
7-
6+ "gordon/internal/adapters/in/cli/remote"
87 "gordon/internal/app"
98
109 "github.com/spf13/cobra"
@@ -15,6 +14,10 @@ var (
1514 Version = "dev"
1615 Commit = "unknown"
1716 BuildDate = "unknown"
17+
18+ // Global flags for remote targeting
19+ remoteFlag string
20+ tokenFlag string
1821 )
1922
2023 // NewRootCmd creates the root command for Gordon CLI.
@@ -26,37 +29,50 @@ func NewRootCmd() *cobra.Command {
2629 a Docker registry with automatic container deployment capabilities.
2730
2831 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.`,
3036 }
3137
32- // Add subcommands
33- rootCmd.AddCommand(newStartCmd())
38+ // Add persistent flags for remote targeting
39+ rootCmd.PersistentFlags().StringVar(&remoteFlag, "remote", "", "Remote Gordon URL (e.g., https://gordon.mydomain.com)")
40+ rootCmd.PersistentFlags().StringVar(&tokenFlag, "token", "", "Authentication token for remote")
41+
42+ // Server commands
43+ rootCmd.AddCommand(newServeCmd())
44+ rootCmd.AddCommand(newStartCmd()) // Deprecated alias for serve
3445 rootCmd.AddCommand(newReloadCmd())
3546 rootCmd.AddCommand(newDeployCmd())
3647 rootCmd.AddCommand(newLogsCmd())
3748 rootCmd.AddCommand(newVersionCmd())
3849 rootCmd.AddCommand(newAuthCmd())
3950
51+ // Remote management commands
52+ rootCmd.AddCommand(newRoutesCmd())
53+ rootCmd.AddCommand(newSecretsCmd())
54+ rootCmd.AddCommand(newRemotesCmd())
55+ rootCmd.AddCommand(newStatusCmd())
56+
4057 return rootCmd
4158 }
4259
43-// newStartCmd creates the start command.
44-func newStartCmd() *cobra.Command {
45- var configPath string
46-
47- cmd := &cobra.Command{
48- Use: "start",
49- Short: "Start the Gordon server",
50- Long: `Start the Gordon server, including the registry and proxy components.`,
51- RunE: func(cmd *cobra.Command, args []string) error {
52- return app.Run(context.Background(), configPath)
53- },
60+// GetRemoteClient returns a remote client if targeting a remote instance,
61+// or nil if running locally.
62+func GetRemoteClient() (*remote.Client, bool) {
63+ url, token, isRemote := remote.ResolveRemote(remoteFlag, tokenFlag)
64+ if !isRemote {
65+ return nil, false
5466 }
5567
56- // Add flags
57- cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
68+ client := remote.NewClient(url, remote.WithToken(token))
69+ return client, true
70+}
5871
59- return cmd
72+// IsRemoteMode returns true if CLI is targeting a remote Gordon instance.
73+func IsRemoteMode() bool {
74+ _, _, isRemote := remote.ResolveRemote(remoteFlag, tokenFlag)
75+ return isRemote
6076 }
6177
6278 // 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 {
4747 }
4848
4949 // RequestLogger is a middleware that logs HTTP requests using zerowrap.
50+// It also attaches the logger to the request context for downstream handlers.
5051 func RequestLogger(log zerowrap.Logger) func(http.Handler) http.Handler {
5152 return func(next http.Handler) http.Handler {
5253 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -58,6 +59,10 @@ func RequestLogger(log zerowrap.Logger) func(http.Handler) http.Handler {
5859 // Get client IP (handle X-Forwarded-For and X-Real-IP headers)
5960 clientIP := getClientIP(r)
6061
62+ // Attach the logger to the request context for downstream handlers
63+ ctx := zerowrap.WithCtx(r.Context(), log)
64+ r = r.WithContext(ctx)
65+
6166 // Call the next handler
6267 next.ServeHTTP(rw, r)
6368
M internal/adapters/in/http/registry/handler.go +4 -13
@@ -2,7 +2,6 @@
22 package registry
33
44 import (
5- "crypto/sha256"
65 "encoding/json"
76 "errors"
87 "fmt"
@@ -14,7 +13,6 @@ import (
1413 "github.com/bnema/zerowrap"
1514
1615 "gordon/internal/boundaries/in"
17- "gordon/internal/boundaries/out"
1816 "gordon/internal/domain"
1917 "gordon/pkg/manifest"
2018 "gordon/pkg/validation"
@@ -30,22 +28,16 @@ const (
3028 // Handler implements the HTTP handler for Docker Registry API v2.
3129 type Handler struct {
3230 registrySvc in.RegistryService
33- blobStorage out.BlobStorage
34- eventBus out.EventPublisher
3531 log zerowrap.Logger
3632 }
3733
3834 // NewHandler creates a new registry HTTP handler.
3935 func NewHandler(
4036 registrySvc in.RegistryService,
41- blobStorage out.BlobStorage,
42- eventBus out.EventPublisher,
4337 log zerowrap.Logger,
4438 ) *Handler {
4539 return &Handler{
4640 registrySvc: registrySvc,
47- blobStorage: blobStorage,
48- eventBus: eventBus,
4941 log: log,
5042 }
5143 }
@@ -331,14 +323,13 @@ func (h *Handler) handlePutManifest(w http.ResponseWriter, r *http.Request) {
331323 Annotations: annotations,
332324 }
333325
334- if err := h.registrySvc.PutManifest(ctx, manifestObj); err != nil {
326+ digest, err := h.registrySvc.PutManifest(ctx, manifestObj)
327+ if err != nil {
335328 log.Error().Err(err).Str("name", name).Str("reference", reference).Msg("failed to store manifest")
336329 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
337330 return
338331 }
339332
340- // Calculate digest for Docker-Content-Digest header
341- digest := fmt.Sprintf("sha256:%x", sha256.Sum256(data))
342333 w.Header().Set("Docker-Content-Digest", digest)
343334 w.Header().Set("Location", fmt.Sprintf("/v2/%s/manifests/%s", name, reference))
344335 w.WriteHeader(http.StatusCreated)
@@ -352,7 +343,7 @@ func (h *Handler) handleGetBlob(w http.ResponseWriter, r *http.Request) {
352343
353344 log.Debug().Str("name", name).Str("digest", digest).Msg("GET blob")
354345
355- path, err := h.blobStorage.GetBlobPath(digest)
346+ path, err := h.registrySvc.GetBlobPath(ctx, digest)
356347 if err != nil {
357348 log.Warn().Err(err).Str("name", name).Str("digest", digest).Msg("blob not found")
358349 http.NotFound(w, r)
@@ -422,7 +413,7 @@ func (h *Handler) handleBlobUpload(w http.ResponseWriter, r *http.Request) {
422413 }
423414
424415 // 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)
426417 if err != nil {
427418 log.Error().Err(err).Msg("failed to append blob chunk")
428419 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
M internal/adapters/in/http/registry/handler_test.go +29 -74
@@ -12,7 +12,6 @@ import (
1212 "github.com/stretchr/testify/mock"
1313
1414 inmocks "gordon/internal/boundaries/in/mocks"
15- "gordon/internal/boundaries/out/mocks"
1615 "gordon/internal/domain"
1716 )
1817
@@ -22,10 +21,8 @@ func testLogger() zerowrap.Logger {
2221
2322 func TestHandler_Base(t *testing.T) {
2423 registrySvc := inmocks.NewMockRegistryService(t)
25- blobStorage := mocks.NewMockBlobStorage(t)
26- eventBus := mocks.NewMockEventPublisher(t)
2724
28- handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
25+ handler := NewHandler(registrySvc, testLogger())
2926
3027 req := httptest.NewRequest("GET", "/v2/", nil)
3128 rec := httptest.NewRecorder()
@@ -38,10 +35,8 @@ func TestHandler_Base(t *testing.T) {
3835
3936 func TestHandler_NotFound(t *testing.T) {
4037 registrySvc := inmocks.NewMockRegistryService(t)
41- blobStorage := mocks.NewMockBlobStorage(t)
42- eventBus := mocks.NewMockEventPublisher(t)
4338
44- handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
39+ handler := NewHandler(registrySvc, testLogger())
4540
4641 req := httptest.NewRequest("GET", "/v2/unknown/path", nil)
4742 rec := httptest.NewRecorder()
@@ -53,10 +48,8 @@ func TestHandler_NotFound(t *testing.T) {
5348
5449 func TestHandler_GetManifest_Success(t *testing.T) {
5550 registrySvc := inmocks.NewMockRegistryService(t)
56- blobStorage := mocks.NewMockBlobStorage(t)
57- eventBus := mocks.NewMockEventPublisher(t)
5851
59- handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
52+ handler := NewHandler(registrySvc, testLogger())
6053
6154 manifestData := []byte(`{"schemaVersion": 2}`)
6255 registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "latest").Return(&domain.Manifest{
@@ -78,10 +71,8 @@ func TestHandler_GetManifest_Success(t *testing.T) {
7871
7972 func TestHandler_GetManifest_HEAD(t *testing.T) {
8073 registrySvc := inmocks.NewMockRegistryService(t)
81- blobStorage := mocks.NewMockBlobStorage(t)
82- eventBus := mocks.NewMockEventPublisher(t)
8374
84- handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
75+ handler := NewHandler(registrySvc, testLogger())
8576
8677 manifestData := []byte(`{"schemaVersion": 2}`)
8778 registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "latest").Return(&domain.Manifest{
@@ -104,10 +95,8 @@ func TestHandler_GetManifest_HEAD(t *testing.T) {
10495
10596 func TestHandler_GetManifest_NotFound(t *testing.T) {
10697 registrySvc := inmocks.NewMockRegistryService(t)
107- blobStorage := mocks.NewMockBlobStorage(t)
108- eventBus := mocks.NewMockEventPublisher(t)
10998
110- handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
99+ handler := NewHandler(registrySvc, testLogger())
111100
112101 registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "notexists").Return(nil, assert.AnError)
113102
@@ -121,10 +110,8 @@ func TestHandler_GetManifest_NotFound(t *testing.T) {
121110
122111 func TestHandler_GetManifest_NestedName(t *testing.T) {
123112 registrySvc := inmocks.NewMockRegistryService(t)
124- blobStorage := mocks.NewMockBlobStorage(t)
125- eventBus := mocks.NewMockEventPublisher(t)
126113
127- handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
114+ handler := NewHandler(registrySvc, testLogger())
128115
129116 manifestData := []byte(`{"schemaVersion": 2}`)
130117 registrySvc.EXPECT().GetManifest(mock.Anything, "org/project/app", "v1.0").Return(&domain.Manifest{
@@ -144,15 +131,13 @@ func TestHandler_GetManifest_NestedName(t *testing.T) {
144131
145132 func TestHandler_PutManifest_Success(t *testing.T) {
146133 registrySvc := inmocks.NewMockRegistryService(t)
147- blobStorage := mocks.NewMockBlobStorage(t)
148- eventBus := mocks.NewMockEventPublisher(t)
149134
150- handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
135+ handler := NewHandler(registrySvc, testLogger())
151136
152137 manifestData := []byte(`{"schemaVersion": 2}`)
153138 registrySvc.EXPECT().PutManifest(mock.Anything, mock.MatchedBy(func(m *domain.Manifest) bool {
154139 return m.Name == "myapp" && m.Reference == "latest" && m.ContentType == "application/vnd.docker.distribution.manifest.v2+json"
155- })).Return(nil)
140+ })).Return("sha256:abc123", nil)
156141
157142 req := httptest.NewRequest("PUT", "/v2/myapp/manifests/latest", bytes.NewReader(manifestData))
158143 req.Header.Set("Content-Type", "application/vnd.docker.distribution.manifest.v2+json")
@@ -161,16 +146,14 @@ func TestHandler_PutManifest_Success(t *testing.T) {
161146 handler.ServeHTTP(rec, req)
162147
163148 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"))
165150 assert.Contains(t, rec.Header().Get("Location"), "/v2/myapp/manifests/latest")
166151 }
167152
168153 func TestHandler_PutManifest_MissingContentType(t *testing.T) {
169154 registrySvc := inmocks.NewMockRegistryService(t)
170- blobStorage := mocks.NewMockBlobStorage(t)
171- eventBus := mocks.NewMockEventPublisher(t)
172155
173- handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
156+ handler := NewHandler(registrySvc, testLogger())
174157
175158 manifestData := []byte(`{"schemaVersion": 2}`)
176159
@@ -185,13 +168,11 @@ func TestHandler_PutManifest_MissingContentType(t *testing.T) {
185168
186169 func TestHandler_PutManifest_StorageError(t *testing.T) {
187170 registrySvc := inmocks.NewMockRegistryService(t)
188- blobStorage := mocks.NewMockBlobStorage(t)
189- eventBus := mocks.NewMockEventPublisher(t)
190171
191- handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
172+ handler := NewHandler(registrySvc, testLogger())
192173
193174 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)
195176
196177 req := httptest.NewRequest("PUT", "/v2/myapp/manifests/latest", bytes.NewReader(manifestData))
197178 req.Header.Set("Content-Type", "application/vnd.docker.distribution.manifest.v2+json")
@@ -204,10 +185,8 @@ func TestHandler_PutManifest_StorageError(t *testing.T) {
204185
205186 func TestHandler_ManifestRoutes_MethodNotAllowed(t *testing.T) {
206187 registrySvc := inmocks.NewMockRegistryService(t)
207- blobStorage := mocks.NewMockBlobStorage(t)
208- eventBus := mocks.NewMockEventPublisher(t)
209188
210- handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
189+ handler := NewHandler(registrySvc, testLogger())
211190
212191 req := httptest.NewRequest("DELETE", "/v2/myapp/manifests/latest", nil)
213192 rec := httptest.NewRecorder()
@@ -219,10 +198,8 @@ func TestHandler_ManifestRoutes_MethodNotAllowed(t *testing.T) {
219198
220199 func TestHandler_GetBlob_Success(t *testing.T) {
221200 registrySvc := inmocks.NewMockRegistryService(t)
222- blobStorage := mocks.NewMockBlobStorage(t)
223- eventBus := mocks.NewMockEventPublisher(t)
224201
225- handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
202+ handler := NewHandler(registrySvc, testLogger())
226203
227204 // Create a temp file for http.ServeFile
228205 tmpFile, err := os.CreateTemp("", "blob-*")
@@ -233,7 +210,7 @@ func TestHandler_GetBlob_Success(t *testing.T) {
233210 _, err = tmpFile.Write(blobContent)
234211 assert.NoError(t, err)
235212
236- blobStorage.EXPECT().GetBlobPath("sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(tmpFile.Name(), nil)
213+ registrySvc.EXPECT().GetBlobPath(mock.Anything, "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(tmpFile.Name(), nil)
237214
238215 req := httptest.NewRequest("GET", "/v2/myapp/blobs/sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", nil)
239216 rec := httptest.NewRecorder()
@@ -246,12 +223,10 @@ func TestHandler_GetBlob_Success(t *testing.T) {
246223
247224 func TestHandler_GetBlob_NotFound(t *testing.T) {
248225 registrySvc := inmocks.NewMockRegistryService(t)
249- blobStorage := mocks.NewMockBlobStorage(t)
250- eventBus := mocks.NewMockEventPublisher(t)
251226
252- handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
227+ handler := NewHandler(registrySvc, testLogger())
253228
254- blobStorage.EXPECT().GetBlobPath("sha256:0000000000000000000000000000000000000000000000000000000000000000").Return("", assert.AnError)
229+ registrySvc.EXPECT().GetBlobPath(mock.Anything, "sha256:0000000000000000000000000000000000000000000000000000000000000000").Return("", assert.AnError)
255230
256231 req := httptest.NewRequest("GET", "/v2/myapp/blobs/sha256:0000000000000000000000000000000000000000000000000000000000000000", nil)
257232 rec := httptest.NewRecorder()
@@ -263,10 +238,8 @@ func TestHandler_GetBlob_NotFound(t *testing.T) {
263238
264239 func TestHandler_BlobRoutes_MethodNotAllowed(t *testing.T) {
265240 registrySvc := inmocks.NewMockRegistryService(t)
266- blobStorage := mocks.NewMockBlobStorage(t)
267- eventBus := mocks.NewMockEventPublisher(t)
268241
269- handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
242+ handler := NewHandler(registrySvc, testLogger())
270243
271244 req := httptest.NewRequest("POST", "/v2/myapp/blobs/sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", nil)
272245 rec := httptest.NewRecorder()
@@ -278,10 +251,8 @@ func TestHandler_BlobRoutes_MethodNotAllowed(t *testing.T) {
278251
279252 func TestHandler_StartBlobUpload_Success(t *testing.T) {
280253 registrySvc := inmocks.NewMockRegistryService(t)
281- blobStorage := mocks.NewMockBlobStorage(t)
282- eventBus := mocks.NewMockEventPublisher(t)
283254
284- handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
255+ handler := NewHandler(registrySvc, testLogger())
285256
286257 registrySvc.EXPECT().StartUpload(mock.Anything, "myapp").Return("1234567890-myapp", nil)
287258
@@ -297,10 +268,8 @@ func TestHandler_StartBlobUpload_Success(t *testing.T) {
297268
298269 func TestHandler_StartBlobUpload_Error(t *testing.T) {
299270 registrySvc := inmocks.NewMockRegistryService(t)
300- blobStorage := mocks.NewMockBlobStorage(t)
301- eventBus := mocks.NewMockEventPublisher(t)
302271
303- handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
272+ handler := NewHandler(registrySvc, testLogger())
304273
305274 registrySvc.EXPECT().StartUpload(mock.Anything, "myapp").Return("", assert.AnError)
306275
@@ -314,13 +283,11 @@ func TestHandler_StartBlobUpload_Error(t *testing.T) {
314283
315284 func TestHandler_BlobUpload_PATCH(t *testing.T) {
316285 registrySvc := inmocks.NewMockRegistryService(t)
317- blobStorage := mocks.NewMockBlobStorage(t)
318- eventBus := mocks.NewMockEventPublisher(t)
319286
320- handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
287+ handler := NewHandler(registrySvc, testLogger())
321288
322289 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)
324291
325292 req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/1234567890-myapp", bytes.NewReader(chunkData))
326293 rec := httptest.NewRecorder()
@@ -333,13 +300,11 @@ func TestHandler_BlobUpload_PATCH(t *testing.T) {
333300
334301 func TestHandler_BlobUpload_PUT_Finalize(t *testing.T) {
335302 registrySvc := inmocks.NewMockRegistryService(t)
336- blobStorage := mocks.NewMockBlobStorage(t)
337- eventBus := mocks.NewMockEventPublisher(t)
338303
339- handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
304+ handler := NewHandler(registrySvc, testLogger())
340305
341306 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)
343308 registrySvc.EXPECT().FinishUpload(mock.Anything, "1234567890-myapp", "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(nil)
344309
345310 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) {
354319
355320 func TestHandler_BlobUpload_PUT_DigestMismatch(t *testing.T) {
356321 registrySvc := inmocks.NewMockRegistryService(t)
357- blobStorage := mocks.NewMockBlobStorage(t)
358- eventBus := mocks.NewMockEventPublisher(t)
359322
360- handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
323+ handler := NewHandler(registrySvc, testLogger())
361324
362325 chunkData := []byte("final chunk")
363326 // Note: Invalid digest format is rejected by validation before reaching storage layer
@@ -373,10 +336,8 @@ func TestHandler_BlobUpload_PUT_DigestMismatch(t *testing.T) {
373336
374337 func TestHandler_ListTags_Success(t *testing.T) {
375338 registrySvc := inmocks.NewMockRegistryService(t)
376- blobStorage := mocks.NewMockBlobStorage(t)
377- eventBus := mocks.NewMockEventPublisher(t)
378339
379- handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
340+ handler := NewHandler(registrySvc, testLogger())
380341
381342 registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return([]string{"latest", "v1.0", "v2.0"}, nil)
382343
@@ -393,10 +354,8 @@ func TestHandler_ListTags_Success(t *testing.T) {
393354
394355 func TestHandler_ListTags_NotFound(t *testing.T) {
395356 registrySvc := inmocks.NewMockRegistryService(t)
396- blobStorage := mocks.NewMockBlobStorage(t)
397- eventBus := mocks.NewMockEventPublisher(t)
398357
399- handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
358+ handler := NewHandler(registrySvc, testLogger())
400359
401360 registrySvc.EXPECT().ListTags(mock.Anything, "notexists").Return(nil, assert.AnError)
402361
@@ -410,10 +369,8 @@ func TestHandler_ListTags_NotFound(t *testing.T) {
410369
411370 func TestHandler_ListTags_MethodNotAllowed(t *testing.T) {
412371 registrySvc := inmocks.NewMockRegistryService(t)
413- blobStorage := mocks.NewMockBlobStorage(t)
414- eventBus := mocks.NewMockEventPublisher(t)
415372
416- handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
373+ handler := NewHandler(registrySvc, testLogger())
417374
418375 req := httptest.NewRequest("POST", "/v2/myapp/tags/list", nil)
419376 rec := httptest.NewRecorder()
@@ -425,10 +382,8 @@ func TestHandler_ListTags_MethodNotAllowed(t *testing.T) {
425382
426383 func TestHandler_RegisterRoutes(t *testing.T) {
427384 registrySvc := inmocks.NewMockRegistryService(t)
428- blobStorage := mocks.NewMockBlobStorage(t)
429- eventBus := mocks.NewMockEventPublisher(t)
430385
431- handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
386+ handler := NewHandler(registrySvc, testLogger())
432387
433388 mux := http.NewServeMux()
434389 handler.RegisterRoutes(mux)
M internal/adapters/out/docker/runtime.go +142 -1
@@ -2,11 +2,14 @@
22 package docker
33
44 import (
5+ "archive/tar"
56 "context"
67 "encoding/base64"
78 "encoding/json"
89 "fmt"
910 "io"
11+ "path/filepath"
12+ "sort"
1013 "strconv"
1114 "strings"
1215
@@ -264,7 +267,7 @@ func (r *Runtime) ListContainers(ctx context.Context, all bool) ([]*domain.Conta
264267 ID: c.ID,
265268 Image: c.Image,
266269 Name: name,
267- Status: c.Status,
270+ Status: c.State, // Use State (e.g., "running") not Status (e.g., "Up 2 days")
268271 Ports: ports,
269272 Labels: c.Labels,
270273 })
@@ -619,10 +622,42 @@ func (r *Runtime) GetImageExposedPorts(ctx context.Context, imageRef string) ([]
619622 return nil, fmt.Errorf("no EXPOSE directives found in image %s - please add EXPOSE directive to Dockerfile", imageRef)
620623 }
621624
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+
622629 log.Info().Ints("exposed_ports", ports).Msg("found exposed ports in image")
623630 return ports, nil
624631 }
625632
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+
626661 // GetContainerExposedPorts gets all exposed ports from a running container.
627662 func (r *Runtime) GetContainerExposedPorts(ctx context.Context, containerID string) ([]int, error) {
628663 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
@@ -1018,3 +1053,109 @@ func (r *Runtime) DisconnectContainerFromNetwork(ctx context.Context, containerN
10181053 log.Info().Msg("container disconnected from network")
10191054 return nil
10201055 }
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 (
2121
2222 // Adapters - Output
2323 "gordon/internal/adapters/out/docker"
24+ "gordon/internal/adapters/out/domainsecrets"
2425 "gordon/internal/adapters/out/envloader"
2526 "gordon/internal/adapters/out/eventbus"
2627 "gordon/internal/adapters/out/filesystem"
28+ "gordon/internal/adapters/out/httpprober"
2729 "gordon/internal/adapters/out/logwriter"
2830 "gordon/internal/adapters/out/secrets"
2931 "gordon/internal/adapters/out/tokenstore"
3032
33+ "golang.org/x/time/rate"
34+
3135 // Adapters - Input
36+ "gordon/internal/adapters/in/http/admin"
3237 "gordon/internal/adapters/in/http/middleware"
3338 "gordon/internal/adapters/in/http/registry"
3439
@@ -42,8 +47,10 @@ import (
4247 "gordon/internal/usecase/auth"
4348 "gordon/internal/usecase/config"
4449 "gordon/internal/usecase/container"
50+ "gordon/internal/usecase/health"
4551 "gordon/internal/usecase/proxy"
4652 registrySvc "gordon/internal/usecase/registry"
53+ secretsSvc "gordon/internal/usecase/secrets"
4754 )
4855
4956 // Config holds the application configuration.
@@ -51,7 +58,8 @@ type Config struct {
5158 Server struct {
5259 Port int `mapstructure:"port"`
5360 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
5563 DataDir string `mapstructure:"data_dir"`
5664 } `mapstructure:"server"`
5765
@@ -108,8 +116,10 @@ type services struct {
108116 proxySvc *proxy.Service
109117 authSvc *auth.Service
110118 tokenHandler *registry.TokenHandler
119+ adminHandler *admin.Handler
111120 internalRegUser string
112121 internalRegPass string
122+ envDir string
113123 }
114124
115125 // Run initializes and starts the Gordon application.
@@ -145,7 +155,7 @@ func Run(ctx context.Context, configPath string) error {
145155 }
146156
147157 // Register event handlers
148- if err := registerEventHandlers(ctx, svc); err != nil {
158+ if err := registerEventHandlers(ctx, svc, cfg); err != nil {
149159 return err
150160 }
151161
@@ -180,6 +190,11 @@ func initConfig(configPath string) (*viper.Viper, Config, error) {
180190 return nil, Config{}, fmt.Errorf("failed to unmarshal config: %w", err)
181191 }
182192
193+ // Normalize domain config: prefer gordon_domain over registry_domain
194+ if cfg.Server.GordonDomain != "" {
195+ cfg.Server.RegistryDomain = cfg.Server.GordonDomain
196+ }
197+
183198 return v, cfg, nil
184199 }
185200
@@ -275,6 +290,30 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
275290 svc.tokenHandler = registry.NewTokenHandler(svc.authSvc, internalAuth, log)
276291 }
277292
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+
278317 return svc, nil
279318 }
280319
@@ -636,6 +675,7 @@ func createContainerService(v *viper.Viper, cfg Config, svc *services) *containe
636675 RegistryPassword: cfg.RegistryAuth.Password,
637676 InternalRegistryUsername: svc.internalRegUser,
638677 InternalRegistryPassword: svc.internalRegPass,
678+ PullPolicy: v.GetString("deploy.pull_policy"),
639679 VolumeAutoCreate: v.GetBool("volumes.auto_create"),
640680 VolumePrefix: v.GetString("volumes.prefix"),
641681 VolumePreserve: v.GetBool("volumes.preserve"),
@@ -649,12 +689,19 @@ func createContainerService(v *viper.Viper, cfg Config, svc *services) *containe
649689 }
650690
651691 // 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 {
653693 imagePushedHandler := container.NewImagePushedHandler(ctx, svc.containerSvc, svc.configSvc)
654694 if err := svc.eventBus.Subscribe(imagePushedHandler); err != nil {
655695 return fmt.Errorf("failed to subscribe image pushed handler: %w", err)
656696 }
657697
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+
658705 configReloadHandler := container.NewConfigReloadHandler(ctx, svc.containerSvc, svc.configSvc)
659706 if err := svc.eventBus.Subscribe(configReloadHandler); err != nil {
660707 return fmt.Errorf("failed to subscribe config reload handler: %w", err)
@@ -680,6 +727,10 @@ func registerEventHandlers(ctx context.Context, svc *services) error {
680727 }
681728
682729 // 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.
683734 func setupConfigHotReload(ctx context.Context, v *viper.Viper, svc *services, log zerowrap.Logger) {
684735 v.OnConfigChange(func(e fsnotify.Event) {
685736 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
689740 return
690741 }
691742
692- if err := svc.configSvc.Load(ctx); err != nil {
693- log.Error().Err(err).Msg("failed to reload configuration")
694- return
695- }
696-
743+ // Update proxy config from viper (reads directly from viper, not memory)
697744 svc.proxySvc.UpdateConfig(proxy.Config{
698745 RegistryDomain: v.GetString("server.registry_domain"),
699746 RegistryPort: v.GetInt("server.registry_port"),
@@ -704,10 +751,7 @@ func setupConfigHotReload(ctx context.Context, v *viper.Viper, svc *services, lo
704751 log.Warn().Err(err).Msg("failed to refresh proxy targets")
705752 }
706753
707- // Publish config reload event to trigger container updates
708- if err := svc.eventBus.Publish(domain.EventConfigReload, nil); err != nil {
709- log.Error().Err(err).Msg("failed to publish config reload event")
710- }
754+ log.Debug().Msg("config hot reload complete (routes unchanged, memory is source of truth)")
711755 })
712756 v.WatchConfig()
713757 }
@@ -728,7 +772,7 @@ func syncAndAutoStart(ctx context.Context, svc *services, log zerowrap.Logger) {
728772
729773 // createHTTPHandlers creates HTTP handlers with middleware.
730774 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)
732776
733777 registryMiddlewares := []func(http.Handler) http.Handler{
734778 middleware.PanicRecovery(log),
@@ -759,6 +803,22 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
759803 }
760804 registryMux.Handle("/v2/", registryWithMiddleware)
761805
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+
762822 proxyMiddlewares := []func(http.Handler) http.Handler{
763823 middleware.PanicRecovery(log),
764824 middleware.RequestLogger(log),
@@ -1063,6 +1123,14 @@ func loadConfig(v *viper.Viper, configPath string) error {
10631123 v.SetDefault("registry_auth.enabled", false)
10641124 v.SetDefault("registry_auth.type", "password")
10651125 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)
10661134
10671135 ConfigureViper(v, configPath)
10681136
M internal/boundaries/in/config.go +6 -0
@@ -14,6 +14,9 @@ type ConfigService interface {
1414 // GetRoutes returns all configured routes.
1515 GetRoutes(ctx context.Context) []domain.Route
1616
17+ // GetRoute returns a single route by domain.
18+ GetRoute(ctx context.Context, domain string) (*domain.Route, error)
19+
1720 // AddRoute adds a new route to the configuration.
1821 AddRoute(ctx context.Context, route domain.Route) error
1922
@@ -23,6 +26,9 @@ type ConfigService interface {
2326 // RemoveRoute removes a route from the configuration.
2427 RemoveRoute(ctx context.Context, domain string) error
2528
29+ // Save persists the current configuration to disk.
30+ Save(ctx context.Context) error
31+
2632 // Watch starts watching for configuration changes.
2733 // The onChange callback is called when configuration changes are detected.
2834 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) *
317317 return _c
318318 }
319319
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+
320388 // GetRoutes provides a mock function for the type MockConfigService
321389 func (_mock *MockConfigService) GetRoutes(ctx context.Context) []domain.Route {
322390 ret := _mock.Called(ctx)
@@ -610,6 +678,57 @@ func (_c *MockConfigService_RemoveRoute_Call) RunAndReturn(run func(ctx context.
610678 return _c
611679 }
612680
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+
613732 // UpdateRoute provides a mock function for the type MockConfigService
614733 func (_mock *MockConfigService) UpdateRoute(ctx context.Context, route domain.Route) error {
615734 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 {
3939 return &MockRegistryService_Expecter{mock: &_m.Mock}
4040 }
4141
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+
42120 // BlobExists provides a mock function for the type MockRegistryService
43121 func (_mock *MockRegistryService) BlobExists(ctx context.Context, digest string) bool {
44122 ret := _mock.Called(ctx, digest)
@@ -347,6 +425,72 @@ func (_c *MockRegistryService_GetBlob_Call) RunAndReturn(run func(ctx context.Co
347425 return _c
348426 }
349427
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+
350494 // GetManifest provides a mock function for the type MockRegistryService
351495 func (_mock *MockRegistryService) GetManifest(ctx context.Context, name string, reference string) (*domain.Manifest, error) {
352496 ret := _mock.Called(ctx, name, reference)
@@ -621,20 +765,29 @@ func (_c *MockRegistryService_PutBlob_Call) RunAndReturn(run func(ctx context.Co
621765 }
622766
623767 // 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) {
625769 ret := _mock.Called(ctx, manifest)
626770
627771 if len(ret) == 0 {
628772 panic("no return value specified for PutManifest")
629773 }
630774
631- var r0 error
632- if returnFunc, ok := ret.Get(0).(func(context.Context, *domain.Manifest) error); ok {
775+ var r0 string
776+ var r1 error
777+ if returnFunc, ok := ret.Get(0).(func(context.Context, *domain.Manifest) (string, error)); ok {
778+ return returnFunc(ctx, manifest)
779+ }
780+ if returnFunc, ok := ret.Get(0).(func(context.Context, *domain.Manifest) string); ok {
633781 r0 = returnFunc(ctx, manifest)
634782 } else {
635- r0 = ret.Error(0)
783+ r0 = ret.Get(0).(string)
636784 }
637- return r0
785+ if returnFunc, ok := ret.Get(1).(func(context.Context, *domain.Manifest) error); ok {
786+ r1 = returnFunc(ctx, manifest)
787+ } else {
788+ r1 = ret.Error(1)
789+ }
790+ return r0, r1
638791 }
639792
640793 // 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
667820 return _c
668821 }
669822
670-func (_c *MockRegistryService_PutManifest_Call) Return(err error) *MockRegistryService_PutManifest_Call {
671- _c.Call.Return(err)
823+func (_c *MockRegistryService_PutManifest_Call) Return(digest string, err error) *MockRegistryService_PutManifest_Call {
824+ _c.Call.Return(digest, err)
672825 return _c
673826 }
674827
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 {
676829 _c.Call.Return(run)
677830 return _c
678831 }
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 (
1111 type RegistryService interface {
1212 // Manifest operations
1313 GetManifest(ctx context.Context, name, reference string) (*domain.Manifest, error)
14- PutManifest(ctx context.Context, manifest *domain.Manifest) error
14+ PutManifest(ctx context.Context, manifest *domain.Manifest) (digest string, err error)
1515 DeleteManifest(ctx context.Context, name, reference string) error
1616
1717 // Blob operations
1818 GetBlob(ctx context.Context, digest string) (io.ReadCloser, error)
19+ GetBlobPath(ctx context.Context, digest string) (string, error)
1920 PutBlob(ctx context.Context, digest string, data io.Reader, size int64) error
2021 BlobExists(ctx context.Context, digest string) bool
2122
2223 // Upload operations
2324 StartUpload(ctx context.Context, name string) (string, error)
25+ AppendBlobChunk(ctx context.Context, name, uuid string, chunk []byte) (int64, error)
2426 FinishUpload(ctx context.Context, uuid, digest string) error
2527 CancelUpload(ctx context.Context, uuid string) error
2628
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 (
6464 ScopeActionAll = "*"
6565 )
6666
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+
6791 // Scope represents a Docker registry scope (repository:name:actions).
6892 // Format follows the Docker Registry v2 token scope specification.
6993 type Scope struct {
@@ -127,3 +151,116 @@ func (s *Scope) CanAccess(repoName string, action string) bool {
127151 func (s *Scope) String() string {
128152 return fmt.Sprintf("%s:%s:%s", s.Type, s.Name, strings.Join(s.Actions, ","))
129153 }
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 (
2020 ErrRouteNotFound = errors.New("route not found")
2121 ErrRouteExists = errors.New("route already exists")
2222 ErrInvalidRoute = errors.New("invalid route configuration")
23+ ErrRouteDomainEmpty = errors.New("route domain cannot be empty")
24+ ErrRouteImageEmpty = errors.New("route image cannot be empty")
2325 ErrNoTargetAvailable = errors.New("no target available for route")
2426
2527 // Registry errors
M internal/domain/labels.go +10 -0
@@ -13,4 +13,14 @@ const (
1313
1414 // Image labels (set in Dockerfile)
1515 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"
1626 )
M internal/domain/registry.go +9 -0
@@ -2,6 +2,15 @@ package domain
22
33 import "time"
44
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+
514 // Manifest represents an OCI image manifest.
615 type Manifest struct {
716 Name string
M internal/domain/route.go +10 -0
@@ -28,3 +28,13 @@ type ExternalRoute struct {
2828 Host string // e.g., "localhost"
2929 Port int // e.g., 5000
3030 }
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 (
55 "context"
66 "strings"
77 "sync"
8+ "sync/atomic"
9+ "time"
810
911 "github.com/bnema/zerowrap"
1012 "github.com/fsnotify/fsnotify"
@@ -37,17 +39,20 @@ type Config struct {
3739
3840 // Service implements the ConfigService interface.
3941 type Service struct {
40- viper *viper.Viper
41- eventBus out.EventPublisher
42- config Config
43- mu sync.RWMutex
42+ viper *viper.Viper
43+ eventBus out.EventPublisher
44+ config Config
45+ mu sync.RWMutex
46+ lastSaveTime int64 // Unix nano timestamp of last save (to debounce file watcher)
47+ debounceDelay int64 // Debounce delay in nanoseconds (default 500ms)
4448 }
4549
4650 // NewService creates a new config service.
4751 func NewService(v *viper.Viper, eventBus out.EventPublisher) *Service {
4852 return &Service{
49- viper: v,
50- eventBus: eventBus,
53+ viper: v,
54+ eventBus: eventBus,
55+ debounceDelay: int64(500 * time.Millisecond), // 500ms debounce for file watcher
5156 }
5257 }
5358
@@ -82,10 +87,16 @@ func (s *Service) Load(ctx context.Context) error {
8287
8388 // loadConfigValues loads simple config values from viper.
8489 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+
8596 return Config{
8697 ServerPort: s.viper.GetInt("server.port"),
8798 RegistryPort: s.viper.GetInt("server.registry_port"),
88- RegistryDomain: s.viper.GetString("server.registry_domain"),
99+ RegistryDomain: registryDomain,
89100 DataDir: s.viper.GetString("server.data_dir"),
90101 AutoRouteEnabled: s.viper.GetBool("auto_route.enabled"),
91102 NetworkIsolation: s.viper.GetBool("network_isolation.enabled"),
@@ -171,7 +182,32 @@ func (s *Service) GetRoutes(_ context.Context) []domain.Route {
171182 return routes
172183 }
173184
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.
175211 func (s *Service) AddRoute(ctx context.Context, route domain.Route) error {
176212 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
177213 zerowrap.FieldLayer: "usecase",
@@ -180,20 +216,41 @@ func (s *Service) AddRoute(ctx context.Context, route domain.Route) error {
180216 })
181217 log := zerowrap.FromCtx(ctx)
182218
183- s.mu.Lock()
184- defer s.mu.Unlock()
219+ // Validate route
220+ if route.Domain == "" {
221+ return domain.ErrRouteDomainEmpty
222+ }
223+ if route.Image == "" {
224+ return domain.ErrRouteImageEmpty
225+ }
185226
227+ // Store previous value for rollback
228+ s.mu.Lock()
229+ previousImage, existed := s.config.Routes[route.Domain]
186230 if s.config.Routes == nil {
187231 s.config.Routes = make(map[string]string)
188232 }
189-
190233 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+ }
191248
192249 log.Info().Str("image", route.Image).Msg("route added to configuration")
193250 return nil
194251 }
195252
196-// UpdateRoute updates an existing route.
253+// UpdateRoute updates an existing route and persists it.
197254 func (s *Service) UpdateRoute(ctx context.Context, route domain.Route) error {
198255 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
199256 zerowrap.FieldLayer: "usecase",
@@ -202,20 +259,38 @@ func (s *Service) UpdateRoute(ctx context.Context, route domain.Route) error {
202259 })
203260 log := zerowrap.FromCtx(ctx)
204261
205- s.mu.Lock()
206- defer s.mu.Unlock()
262+ // Validate route
263+ if route.Domain == "" {
264+ return domain.ErrRouteDomainEmpty
265+ }
266+ if route.Image == "" {
267+ return domain.ErrRouteImageEmpty
268+ }
207269
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()
209275 return domain.ErrRouteNotFound
210276 }
211-
212277 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+ }
213288
214289 log.Info().Str("image", route.Image).Msg("route updated")
215290 return nil
216291 }
217292
218-// RemoveRoute removes a route from the configuration.
293+// RemoveRoute removes a route from the configuration and persists it.
219294 func (s *Service) RemoveRoute(ctx context.Context, domainName string) error {
220295 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
221296 zerowrap.FieldLayer: "usecase",
@@ -224,24 +299,67 @@ func (s *Service) RemoveRoute(ctx context.Context, domainName string) error {
224299 })
225300 log := zerowrap.FromCtx(ctx)
226301
302+ // Store previous value for rollback
227303 s.mu.Lock()
228- defer s.mu.Unlock()
229-
230- if _, exists := s.config.Routes[domainName]; !exists {
304+ previousImage, exists := s.config.Routes[domainName]
305+ if !exists {
306+ s.mu.Unlock()
231307 return domain.ErrRouteNotFound
232308 }
233-
234309 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+ }
235320
236321 log.Info().Msg("route removed")
237322 return nil
238323 }
239324
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+
240351 // Watch starts watching for configuration changes.
241352 func (s *Service) Watch(ctx context.Context, onChange func()) error {
242353 log := zerowrap.FromCtx(ctx)
243354
244355 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+
245363 log.Info().Str("file", e.Name).Msg("config file changed")
246364
247365 if err := s.viper.ReadInConfig(); err != nil {
M internal/usecase/config/service_test.go +155 -40
@@ -2,11 +2,14 @@ package config
22
33 import (
44 "context"
5+ "os"
6+ "path/filepath"
57 "testing"
68
79 "github.com/bnema/zerowrap"
810 "github.com/spf13/viper"
911 "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
1013
1114 "gordon/internal/boundaries/out/mocks"
1215 "gordon/internal/domain"
@@ -95,73 +98,185 @@ func TestService_GetRoutes(t *testing.T) {
9598 assert.False(t, routeMap["insecure.example"].HTTPS)
9699 }
97100
98-func TestService_AddRoute(t *testing.T) {
101+func TestService_GetRoute(t *testing.T) {
99102 v := viper.New()
103+ v.Set("routes", map[string]interface{}{
104+ "app.example.com": "myapp:latest",
105+ "http://insecure.example": "insecureapp:latest",
106+ })
107+
100108 eventBus := mocks.NewMockEventPublisher(t)
101109 svc := NewService(v, eventBus)
102110 ctx := testContext()
103111
104112 _ = svc.Load(ctx)
105113
106- route := domain.Route{
107- Domain: "new.example.com",
108- Image: "newapp:latest",
109- }
114+ t.Run("existing route", func(t *testing.T) {
115+ route, err := svc.GetRoute(ctx, "app.example.com")
116+ require.NoError(t, err)
117+ assert.Equal(t, "app.example.com", route.Domain)
118+ assert.Equal(t, "myapp:latest", route.Image)
119+ assert.True(t, route.HTTPS)
120+ })
110121
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+}
112128
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)
114136
115- // Verify route was added
116- config := svc.GetConfig()
117- assert.Equal(t, "newapp:latest", config.Routes["new.example.com"])
118-}
137+ v := viper.New()
138+ v.SetConfigFile(configFile)
139+ eventBus := mocks.NewMockEventPublisher(t)
140+ svc := NewService(v, eventBus)
141+ ctx := testContext()
119142
120-func TestService_UpdateRoute(t *testing.T) {
121- v := viper.New()
122- v.Set("routes", map[string]interface{}{
123- "app.example.com": "myapp:v1",
143+ _ = svc.Load(ctx)
144+
145+ route := domain.Route{
146+ Domain: "new.example.com",
147+ Image: "newapp:latest",
148+ }
149+
150+ err = svc.AddRoute(ctx, route)
151+
152+ assert.NoError(t, err)
153+
154+ // Verify route was added
155+ config := svc.GetConfig()
156+ assert.Equal(t, "newapp:latest", config.Routes["new.example.com"])
124157 })
125158
126- eventBus := mocks.NewMockEventPublisher(t)
127- svc := NewService(v, eventBus)
128- ctx := testContext()
159+ t.Run("empty domain", func(t *testing.T) {
160+ v := viper.New()
161+ eventBus := mocks.NewMockEventPublisher(t)
162+ svc := NewService(v, eventBus)
163+ ctx := testContext()
129164
130- _ = svc.Load(ctx)
165+ route := domain.Route{
166+ Domain: "",
167+ Image: "myapp:latest",
168+ }
131169
132- route := domain.Route{
133- Domain: "app.example.com",
134- Image: "myapp:v2",
135- }
170+ err := svc.AddRoute(ctx, route)
171+ assert.ErrorIs(t, err, domain.ErrRouteDomainEmpty)
172+ })
136173
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()
138179
139- assert.NoError(t, err)
180+ route := domain.Route{
181+ Domain: "example.com",
182+ Image: "",
183+ }
140184
141- config := svc.GetConfig()
142- assert.Equal(t, "myapp:v2", config.Routes["app.example.com"])
185+ err := svc.AddRoute(ctx, route)
186+ assert.ErrorIs(t, err, domain.ErrRouteImageEmpty)
187+ })
143188 }
144189
145-func TestService_UpdateRoute_NotFound(t *testing.T) {
146- v := viper.New()
147- eventBus := mocks.NewMockEventPublisher(t)
148- svc := NewService(v, eventBus)
149- ctx := testContext()
190+func TestService_UpdateRoute(t *testing.T) {
191+ t.Run("success", func(t *testing.T) {
192+ // Create temp config file for Save() to work
193+ tmpDir := t.TempDir()
194+ configFile := filepath.Join(tmpDir, "gordon.toml")
195+ err := os.WriteFile(configFile, []byte("[routes]\n\"app.example.com\" = \"myapp:v1\"\n"), 0600)
196+ require.NoError(t, err)
197+
198+ v := viper.New()
199+ v.SetConfigFile(configFile)
200+ v.Set("routes", map[string]any{
201+ "app.example.com": "myapp:v1",
202+ })
150203
151- _ = svc.Load(ctx)
204+ eventBus := mocks.NewMockEventPublisher(t)
205+ svc := NewService(v, eventBus)
206+ ctx := testContext()
152207
153- route := domain.Route{
154- Domain: "nonexistent.example.com",
155- Image: "myapp:latest",
156- }
208+ _ = svc.Load(ctx)
157209
158- err := svc.UpdateRoute(ctx, route)
210+ route := domain.Route{
211+ Domain: "app.example.com",
212+ Image: "myapp:v2",
213+ }
159214
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+ })
161269 }
162270
163271 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+
164278 v := viper.New()
279+ v.SetConfigFile(configFile)
165280 v.Set("routes", map[string]interface{}{
166281 "app.example.com": "myapp:latest",
167282 })
@@ -172,7 +287,7 @@ func TestService_RemoveRoute(t *testing.T) {
172287
173288 _ = svc.Load(ctx)
174289
175- err := svc.RemoveRoute(ctx, "app.example.com")
290+ err = svc.RemoveRoute(ctx, "app.example.com")
176291
177292 assert.NoError(t, err)
178293
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 {
2525 RegistryPassword string
2626 InternalRegistryUsername string
2727 InternalRegistryPassword string
28+ PullPolicy string
2829 VolumeAutoCreate bool
2930 VolumePrefix string
3031 VolumePreserve bool
@@ -36,6 +37,12 @@ type Config struct {
3637 ReadinessDelay time.Duration // Delay after container starts before considering it ready
3738 }
3839
40+const (
41+ PullPolicyAlways = "always"
42+ PullPolicyIfNotPresent = "if-not-present"
43+ PullPolicyIfTagChanged = "if-tag-changed"
44+)
45+
3946 // Service implements the ContainerService interface.
4047 type Service struct {
4148 runtime out.ContainerRuntime
@@ -513,6 +520,23 @@ func (s *Service) buildImageRef(image string) string {
513520 return fmt.Sprintf("%s/%s", s.config.RegistryDomain, image)
514521 }
515522
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+
516540 func (s *Service) pullRefForDeploy(ctx context.Context, imageRef string) (string, bool) {
517541 if !domain.IsInternalDeploy(ctx) {
518542 return imageRef, false
@@ -521,7 +545,9 @@ func (s *Service) pullRefForDeploy(ctx context.Context, imageRef string) (string
521545 }
522546
523547 // 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
525551 func (s *Service) ensureImage(ctx context.Context, imageRef string) (string, error) {
526552 ctx = zerowrap.CtxWithField(ctx, "image", imageRef)
527553 log := zerowrap.FromCtx(ctx)
@@ -550,6 +576,15 @@ func (s *Service) ensureImage(ctx context.Context, imageRef string) (string, err
550576 return "", err
551577 }
552578
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+
553588 if err := s.tagImageIfNeeded(ctx, pullRef, imageRef); err != nil {
554589 return "", err
555590 }
@@ -576,6 +611,18 @@ func (s *Service) ensureLocalImage(ctx context.Context, imageRef, pullRef string
576611 return false, nil
577612 }
578613
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+
579626 localImages, err := s.runtime.ListImages(ctx)
580627 if err != nil {
581628 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
641688 return nil
642689 }
643690
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+
644698 log := zerowrap.FromCtx(ctx)
645699 if err := s.runtime.TagImage(ctx, sourceRef, targetRef); err != nil {
646700 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) {
8989 }
9090
9191 // Get target for this domain
92+ log.Debug().Str("resolving_target_for", r.Host).Msg("looking up proxy target")
9293 target, err := s.GetTarget(ctx, r.Host)
9394 if err != nil {
9495 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 *
302303 return
303304 }
304305
306+ log.Debug().Str("target_url", targetURL.String()).Msg("proxying to target")
307+
305308 proxy := newReverseProxy(targetURL,
306309 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")
308311 http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)
309312 },
310313 func(resp *http.Response) error {
M internal/usecase/registry/service.go +46 -5
@@ -3,6 +3,8 @@ package registry
33
44 import (
55 "context"
6+ "crypto/sha256"
7+ "fmt"
68 "io"
79
810 "github.com/bnema/zerowrap"
@@ -54,8 +56,8 @@ func (s *Service) GetManifest(ctx context.Context, name, reference string) (*dom
5456 }, nil
5557 }
5658
57-// PutManifest stores a manifest.
58-func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) error {
59+// PutManifest stores a manifest and returns the calculated digest.
60+func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) (string, error) {
5961 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
6062 zerowrap.FieldLayer: "usecase",
6163 zerowrap.FieldUseCase: "PutManifest",
@@ -64,8 +66,11 @@ func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) er
6466 })
6567 log := zerowrap.FromCtx(ctx)
6668
69+ // Calculate digest
70+ digest := fmt.Sprintf("sha256:%x", sha256.Sum256(manifest.Data))
71+
6772 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")
6974 }
7075
7176 // Publish image pushed event
@@ -80,8 +85,8 @@ func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) er
8085 }
8186 }
8287
83- log.Info().Msg("manifest stored")
84- return nil
88+ log.Info().Str("digest", digest).Msg("manifest stored")
89+ return digest, nil
8590 }
8691
8792 // DeleteManifest removes a manifest.
@@ -119,6 +124,23 @@ func (s *Service) GetBlob(ctx context.Context, digest string) (io.ReadCloser, er
119124 return reader, nil
120125 }
121126
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+
122144 // PutBlob stores a blob with the given digest.
123145 func (s *Service) PutBlob(ctx context.Context, digest string, data io.Reader, size int64) error {
124146 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
@@ -160,6 +182,25 @@ func (s *Service) StartUpload(ctx context.Context, name string) (string, error)
160182 return uuid, nil
161183 }
162184
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+
163204 // FinishUpload completes a blob upload.
164205 func (s *Service) FinishUpload(ctx context.Context, uuid, digest string) error {
165206 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
M internal/usecase/registry/service_test.go +7 -4
@@ -5,6 +5,7 @@ import (
55 "context"
66 "errors"
77 "io"
8+ "strings"
89 "testing"
910
1011 "github.com/bnema/zerowrap"
@@ -75,9 +76,11 @@ func TestService_PutManifest_Success(t *testing.T) {
7576 manifestStorage.EXPECT().PutManifest("myapp", "latest", "application/vnd.docker.distribution.manifest.v2+json", manifest.Data).Return(nil)
7677 eventBus.EXPECT().Publish(domain.EventImagePushed, mock.AnythingOfType("domain.ImagePushedPayload")).Return(nil)
7778
78- err := svc.PutManifest(ctx, manifest)
79+ digest, err := svc.PutManifest(ctx, manifest)
7980
8081 assert.NoError(t, err)
82+ assert.NotEmpty(t, digest)
83+ assert.True(t, strings.HasPrefix(digest, "sha256:"))
8184 }
8285
8386 func TestService_PutManifest_StorageError(t *testing.T) {
@@ -97,7 +100,7 @@ func TestService_PutManifest_StorageError(t *testing.T) {
97100
98101 manifestStorage.EXPECT().PutManifest("myapp", "latest", "application/vnd.docker.distribution.manifest.v2+json", manifest.Data).Return(errors.New("storage full"))
99102
100- err := svc.PutManifest(ctx, manifest)
103+ _, err := svc.PutManifest(ctx, manifest)
101104
102105 assert.Error(t, err)
103106 assert.Contains(t, err.Error(), "failed to store manifest")
@@ -122,7 +125,7 @@ func TestService_PutManifest_EventPublishError(t *testing.T) {
122125 eventBus.EXPECT().Publish(domain.EventImagePushed, mock.Anything).Return(errors.New("event bus error"))
123126
124127 // 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)
126129
127130 assert.NoError(t, err)
128131 }
@@ -144,7 +147,7 @@ func TestService_PutManifest_NilEventBus(t *testing.T) {
144147
145148 manifestStorage.EXPECT().PutManifest("myapp", "latest", "application/vnd.docker.distribution.manifest.v2+json", manifest.Data).Return(nil)
146149
147- err := svc.PutManifest(ctx, manifest)
150+ _, err := svc.PutManifest(ctx, manifest)
148151
149152 assert.NoError(t, err)
150153 }
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.
1414
1515 ```toml
1616 [server]
17-registry_domain = "registry.local"
17+gordon_domain = "gordon.local"
1818
1919 [routes]
2020 "app.local" = "myapp:latest"
@@ -25,7 +25,7 @@ registry_domain = "registry.local"
2525 ```toml
2626 [server]
2727 port = 8080
28-registry_domain = "registry.local"
28+gordon_domain = "gordon.local"
2929
3030 [registry_auth]
3131 enabled = false
@@ -42,7 +42,7 @@ enabled = true
4242 ```toml
4343 [server]
4444 port = 8080
45-registry_domain = "registry.company.com"
45+gordon_domain = "gordon.company.com"
4646
4747 [secrets]
4848 backend = "pass"
M wiki/examples/minimal.md +5 -5
@@ -16,7 +16,7 @@ The simplest working Gordon configuration.
1616 [server]
1717 port = 8080
1818 registry_port = 5000
19-registry_domain = "registry.local"
19+gordon_domain = "gordon.local"
2020
2121 # Authentication disabled for simplicity
2222 [registry_auth]
@@ -32,21 +32,21 @@ enabled = false
3232 ### 1. Add to /etc/hosts
3333
3434 ```bash
35-echo "127.0.0.1 app.local registry.local" | sudo tee -a /etc/hosts
35+echo "127.0.0.1 app.local gordon.local" | sudo tee -a /etc/hosts
3636 ```
3737
3838 ### 2. Start Gordon
3939
4040 ```bash
41-gordon start
41+gordon serve
4242 ```
4343
4444 ### 3. Build and Deploy
4545
4646 ```bash
4747 docker build -t myapp .
48-docker tag myapp registry.local:5000/myapp:latest
49-docker push registry.local:5000/myapp:latest
48+docker tag myapp gordon.local:5000/myapp:latest
49+docker push gordon.local:5000/myapp:latest
5050 ```
5151
5252 ### 4. Access
M wiki/examples/production.md +3 -3
@@ -17,7 +17,7 @@ A complete Gordon configuration for production environments.
1717 [server]
1818 port = 8080 # Cloudflare forwards 443 → 8080
1919 registry_port = 5000
20-registry_domain = "registry.company.com"
20+gordon_domain = "gordon.company.com"
2121
2222 # Use pass for secrets (recommended)
2323 [secrets]
@@ -161,10 +161,10 @@ systemctl --user enable --now gordon
161161 docker build -t company-app .
162162
163163 # Tag with version
164-docker tag company-app registry.company.com/company-app:v2.2.0
164+docker tag company-app gordon.company.com/company-app:v2.2.0
165165
166166 # Push to deploy
167-docker push registry.company.com/company-app:v2.2.0
167+docker push gordon.company.com/company-app:v2.2.0
168168
169169 # Update config with new version
170170 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.
1111
1212 - [Cloudflare DNS & Proxy Setup](./cloudflare-setup.md) - HTTPS termination with Cloudflare
1313
14+## Remote Management
15+
16+- [Remote CLI Management](./remote-cli.md) - Manage Gordon instances remotely
17+
1418 ## Secrets Management
1519
1620 - [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/
109109 Generate initial config:
110110
111111 ```bash
112-gordon start
112+gordon serve
113113 # Press Ctrl+C after config is created
114114 ```
115115
@@ -119,7 +119,7 @@ Edit `~/.config/gordon/gordon.toml`:
119119 [server]
120120 port = 8080 # Must match firewall forward (80 → 8080)
121121 registry_port = 5000
122-registry_domain = "registry.mydomain.com"
122+gordon_domain = "gordon.mydomain.com"
123123
124124 [routes]
125125 "app.mydomain.com" = "myapp:latest"
@@ -138,7 +138,7 @@ After=podman.socket
138138 Type=simple
139139 Restart=always
140140 RestartSec=5
141-ExecStart=/usr/local/bin/gordon start
141+ExecStart=/usr/local/bin/gordon serve
142142
143143 [Install]
144144 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`:
4545 [server]
4646 port = 8080
4747 registry_port = 5000
48-registry_domain = "registry.mydomain.com"
48+gordon_domain = "gordon.mydomain.com"
4949
5050 [routes]
5151 "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