detached 64d39bf

Merge pull request #16 from bnema/fix/container-deployment-issues

fix(container): resolve deployment issues and add manual deploy command
commit
64d39bf5a417459f5f516b3d50862105f043d12b
author
bnema · · 2026-01-14 19:25 UTC
committer
GitHub · · 2026-01-14 19:34 UTC
merge of
4d32d3a + 0aa69fc · diff against the first parent
signature
unsigned
refs
tag: v2.2.0
30 files · +1623 -558
D docs/cli/auth.md +0 -224
@@ -1,224 +0,0 @@
1-# gordon auth
2-
3-Manage registry authentication tokens and passwords.
4-
5-## Synopsis
6-
7-```bash
8-gordon auth token generate [options]
9-gordon auth token list [options]
10-gordon auth token revoke <token-id> [options]
11-gordon auth password hash
12-```
13-
14-## Commands
15-
16-### gordon auth token generate
17-
18-Generate a new JWT authentication token for registry access.
19-
20-```bash
21-gordon auth token generate --subject <name> [options]
22-```
23-
24-**Options:**
25-
26-| Option | Default | Description |
27-|--------|---------|-------------|
28-| `--subject` | (required) | Username/subject for the token |
29-| `--scopes` | `push,pull` | Comma-separated scopes |
30-| `--expiry` | `720h` | Duration until expiry (0 = never) |
31-| `-c, --config` | Auto | Path to config file |
32-
33-**Examples:**
34-
35-```bash
36-# CI token that never expires
37-gordon auth token generate --subject github-actions --expiry 0
38-
39-# Read-only token
40-gordon auth token generate --subject reader --scopes pull --expiry 720h
41-
42-# Push-only token for build system
43-gordon auth token generate --subject builder --scopes push --expiry 0
44-
45-# Temporary token (24 hours)
46-gordon auth token generate --subject temp --expiry 24h
47-```
48-
49-**Output:**
50-
51-```
52-Token generated successfully!
53-Subject: github-actions
54-Scopes: push, pull
55-Expiry: never
56-
57-Token (use as password with docker login):
58-eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
59-
60-Usage: docker login -u github-actions -p <token> <registry>
61-```
62-
63-### gordon auth token list
64-
65-List all stored authentication tokens.
66-
67-```bash
68-gordon auth token list [options]
69-```
70-
71-**Options:**
72-
73-| Option | Default | Description |
74-|--------|---------|-------------|
75-| `-c, --config` | Auto | Path to config file |
76-
77-**Example:**
78-
79-```bash
80-gordon auth token list
81-```
82-
83-**Output:**
84-
85-```
86-ID Subject Expires Revoked
87-------------------------------------------------------------------------------------------
88-a1b2c3d4-e5f6-7890-abcd-ef1234567890 github-actions never no
89-b2c3d4e5-f6a7-8901-bcde-f12345678901 deploy-bot 2024-02-15 10:30 no
90-c3d4e5f6-a7b8-9012-cdef-123456789012 old-token 2024-01-01 00:00 yes
91-```
92-
93-### gordon auth token revoke
94-
95-Revoke a token by its ID.
96-
97-```bash
98-gordon auth token revoke <token-id> [options]
99-```
100-
101-**Options:**
102-
103-| Option | Default | Description |
104-|--------|---------|-------------|
105-| `-c, --config` | Auto | Path to config file |
106-
107-**Example:**
108-
109-```bash
110-gordon auth token revoke a1b2c3d4-e5f6-7890-abcd-ef1234567890
111-```
112-
113-**Output:**
114-
115-```
116-Token a1b2c3d4-e5f6-7890-abcd-ef1234567890 has been revoked.
117-```
118-
119-### gordon auth password hash
120-
121-Generate a bcrypt hash for password authentication.
122-
123-```bash
124-gordon auth password hash
125-```
126-
127-Interactively prompts for a password and outputs the bcrypt hash.
128-
129-**Example:**
130-
131-```bash
132-gordon auth password hash
133-```
134-
135-**Output:**
136-
137-```
138-Enter password: ********
139-
140-Bcrypt hash (store in your secrets backend):
141-$2a$10$N9qo8uLOickgx2ZMRZoMye...
142-
143-Then reference the path in your config:
144- [registry_auth]
145- type = "password"
146- password_hash = "gordon/registry/password_hash"
147-```
148-
149-## Token Scopes
150-
151-| Scope | Permission |
152-|-------|------------|
153-| `push` | Push images to registry |
154-| `pull` | Pull images from registry |
155-| `push,pull` | Both push and pull (default) |
156-
157-## Token Expiry Formats
158-
159-| Format | Duration |
160-|--------|----------|
161-| `0` | Never expires |
162-| `24h` | 24 hours |
163-| `720h` | 30 days |
164-| `8760h` | 1 year |
165-
166-## Workflow Examples
167-
168-### CI/CD Setup
169-
170-```bash
171-# Generate token for CI
172-gordon auth token generate --subject ci-bot --scopes push,pull --expiry 0
173-
174-# Save token as CI secret
175-# GitHub: Settings → Secrets → New repository secret
176-# GitLab: Settings → CI/CD → Variables
177-```
178-
179-### Docker Login
180-
181-```bash
182-# Using generated token
183-docker login -u ci-bot -p <token> registry.mydomain.com
184-
185-# In CI/CD
186-echo "$GORDON_TOKEN" | docker login -u ci-bot --password-stdin registry.mydomain.com
187-```
188-
189-### Token Rotation
190-
191-```bash
192-# Generate new token
193-gordon auth token generate --subject ci-bot --expiry 0
194-
195-# Update CI secrets with new token
196-
197-# Revoke old token
198-gordon auth token revoke <old-token-id>
199-```
200-
201-### Password Setup
202-
203-```bash
204-# Generate hash
205-gordon auth password hash
206-# Enter: mypassword
207-
208-# Store in pass
209-pass insert gordon/registry/password_hash
210-# Paste: $2a$10$...
211-
212-# Configure gordon.toml
213-[registry_auth]
214-enabled = true
215-type = "password"
216-username = "deploy"
217-password_hash = "gordon/registry/password_hash"
218-```
219-
220-## Related
221-
222-- [Registry Authentication](../config/registry-auth.md)
223-- [Secrets Configuration](../config/secrets.md)
224-- [CLI Overview](./index.md)
@@ -1,224 +0,0 @@
1-# gordon auth
2-
3-Manage registry authentication tokens and passwords.
4-
5-## Synopsis
6-
7-```bash
8-gordon auth token generate [options]
9-gordon auth token list [options]
10-gordon auth token revoke <token-id> [options]
11-gordon auth password hash
12-```
13-
14-## Commands
15-
16-### gordon auth token generate
17-
18-Generate a new JWT authentication token for registry access.
19-
20-```bash
21-gordon auth token generate --subject <name> [options]
22-```
23-
24-**Options:**
25-
26-| Option | Default | Description |
27-|--------|---------|-------------|
28-| `--subject` | (required) | Username/subject for the token |
29-| `--scopes` | `push,pull` | Comma-separated scopes |
30-| `--expiry` | `720h` | Duration until expiry (0 = never) |
31-| `-c, --config` | Auto | Path to config file |
32-
33-**Examples:**
34-
35-```bash
36-# CI token that never expires
37-gordon auth token generate --subject github-actions --expiry 0
38-
39-# Read-only token
40-gordon auth token generate --subject reader --scopes pull --expiry 720h
41-
42-# Push-only token for build system
43-gordon auth token generate --subject builder --scopes push --expiry 0
44-
45-# Temporary token (24 hours)
46-gordon auth token generate --subject temp --expiry 24h
47-```
48-
49-**Output:**
50-
51-```
52-Token generated successfully!
53-Subject: github-actions
54-Scopes: push, pull
55-Expiry: never
56-
57-Token (use as password with docker login):
58-eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
59-
60-Usage: docker login -u github-actions -p <token> <registry>
61-```
62-
63-### gordon auth token list
64-
65-List all stored authentication tokens.
66-
67-```bash
68-gordon auth token list [options]
69-```
70-
71-**Options:**
72-
73-| Option | Default | Description |
74-|--------|---------|-------------|
75-| `-c, --config` | Auto | Path to config file |
76-
77-**Example:**
78-
79-```bash
80-gordon auth token list
81-```
82-
83-**Output:**
84-
85-```
86-ID Subject Expires Revoked
87-------------------------------------------------------------------------------------------
88-a1b2c3d4-e5f6-7890-abcd-ef1234567890 github-actions never no
89-b2c3d4e5-f6a7-8901-bcde-f12345678901 deploy-bot 2024-02-15 10:30 no
90-c3d4e5f6-a7b8-9012-cdef-123456789012 old-token 2024-01-01 00:00 yes
91-```
92-
93-### gordon auth token revoke
94-
95-Revoke a token by its ID.
96-
97-```bash
98-gordon auth token revoke <token-id> [options]
99-```
100-
101-**Options:**
102-
103-| Option | Default | Description |
104-|--------|---------|-------------|
105-| `-c, --config` | Auto | Path to config file |
106-
107-**Example:**
108-
109-```bash
110-gordon auth token revoke a1b2c3d4-e5f6-7890-abcd-ef1234567890
111-```
112-
113-**Output:**
114-
115-```
116-Token a1b2c3d4-e5f6-7890-abcd-ef1234567890 has been revoked.
117-```
118-
119-### gordon auth password hash
120-
121-Generate a bcrypt hash for password authentication.
122-
123-```bash
124-gordon auth password hash
125-```
126-
127-Interactively prompts for a password and outputs the bcrypt hash.
128-
129-**Example:**
130-
131-```bash
132-gordon auth password hash
133-```
134-
135-**Output:**
136-
137-```
138-Enter password: ********
139-
140-Bcrypt hash (store in your secrets backend):
141-$2a$10$N9qo8uLOickgx2ZMRZoMye...
142-
143-Then reference the path in your config:
144- [registry_auth]
145- type = "password"
146- password_hash = "gordon/registry/password_hash"
147-```
148-
149-## Token Scopes
150-
151-| Scope | Permission |
152-|-------|------------|
153-| `push` | Push images to registry |
154-| `pull` | Pull images from registry |
155-| `push,pull` | Both push and pull (default) |
156-
157-## Token Expiry Formats
158-
159-| Format | Duration |
160-|--------|----------|
161-| `0` | Never expires |
162-| `24h` | 24 hours |
163-| `720h` | 30 days |
164-| `8760h` | 1 year |
165-
166-## Workflow Examples
167-
168-### CI/CD Setup
169-
170-```bash
171-# Generate token for CI
172-gordon auth token generate --subject ci-bot --scopes push,pull --expiry 0
173-
174-# Save token as CI secret
175-# GitHub: Settings → Secrets → New repository secret
176-# GitLab: Settings → CI/CD → Variables
177-```
178-
179-### Docker Login
180-
181-```bash
182-# Using generated token
183-docker login -u ci-bot -p <token> registry.mydomain.com
184-
185-# In CI/CD
186-echo "$GORDON_TOKEN" | docker login -u ci-bot --password-stdin registry.mydomain.com
187-```
188-
189-### Token Rotation
190-
191-```bash
192-# Generate new token
193-gordon auth token generate --subject ci-bot --expiry 0
194-
195-# Update CI secrets with new token
196-
197-# Revoke old token
198-gordon auth token revoke <old-token-id>
199-```
200-
201-### Password Setup
202-
203-```bash
204-# Generate hash
205-gordon auth password hash
206-# Enter: mypassword
207-
208-# Store in pass
209-pass insert gordon/registry/password_hash
210-# Paste: $2a$10$...
211-
212-# Configure gordon.toml
213-[registry_auth]
214-enabled = true
215-type = "password"
216-username = "deploy"
217-password_hash = "gordon/registry/password_hash"
218-```
219-
220-## Related
221-
222-- [Registry Authentication](../config/registry-auth.md)
223-- [Secrets Configuration](../config/secrets.md)
224-- [CLI Overview](./index.md)
A docs/cli/commands.md +552 -0
@@ -0,0 +1,552 @@
1+# CLI Commands
2+
3+Gordon provides a command-line interface for server management, deployment, and authentication.
4+
5+## Commands Overview
6+
7+| Command | Description |
8+|---------|-------------|
9+| `gordon start` | Start the Gordon server |
10+| `gordon reload` | Reload configuration and sync containers |
11+| `gordon deploy` | Manually deploy or redeploy a specific route |
12+| `gordon logs` | Display Gordon process logs |
13+| `gordon version` | Print version information |
14+| `gordon auth` | Manage registry authentication |
15+
16+## Quick Reference
17+
18+```bash
19+# Start Gordon
20+gordon start
21+gordon start --config /path/to/config.toml
22+
23+# Reload configuration
24+gordon reload
25+
26+# Deploy a specific route
27+gordon deploy myapp.example.com
28+
29+# View logs
30+gordon logs
31+gordon logs -f
32+gordon logs -n 100
33+
34+# Check version
35+gordon version
36+
37+# Authentication
38+gordon auth token generate --subject ci-bot --expiry 0
39+gordon auth token list
40+gordon auth token revoke <token-id>
41+gordon auth password hash
42+gordon auth internal
43+```
44+
45+## Global Options
46+
47+| Option | Description |
48+|--------|-------------|
49+| `-c, --config` | Path to configuration file |
50+
51+---
52+
53+## gordon start
54+
55+Start the Gordon server with registry and proxy components.
56+
57+### Synopsis
58+
59+```bash
60+gordon start [options]
61+```
62+
63+### Options
64+
65+| Option | Short | Default | Description |
66+|--------|-------|---------|-------------|
67+| `--config` | `-c` | Auto-detected | Path to configuration file |
68+
69+### Description
70+
71+Starts the Gordon server, which includes:
72+
73+- **Container Registry** - Receives image pushes on the registry port
74+- **HTTP Proxy** - Routes traffic to containers on the proxy port
75+- **Event Bus** - Coordinates deployments and updates
76+- **Config Watcher** - Monitors configuration file for changes
77+
78+### Configuration File Detection
79+
80+Gordon looks for configuration in this order:
81+
82+1. Path specified with `--config`
83+2. `/etc/gordon/gordon.toml`
84+3. `~/.config/gordon/gordon.toml`
85+4. `./gordon.toml`
86+
87+### First Run
88+
89+On first run without a config file, Gordon creates a default configuration at `~/.config/gordon/gordon.toml`.
90+
91+```bash
92+# First run - creates default config
93+gordon start
94+# Edit the config, then restart
95+```
96+
97+### Examples
98+
99+```bash
100+# Basic start
101+gordon start
102+
103+# With custom config
104+gordon start --config /path/to/gordon.toml
105+gordon start -c ./my-config.toml
106+
107+# With environment override
108+GORDON_SERVER_PORT=8080 gordon start
109+GORDON_LOGGING_LEVEL=debug gordon start
110+```
111+
112+### Signals
113+
114+Gordon responds to these signals:
115+
116+| Signal | Action |
117+|--------|--------|
118+| `SIGTERM` | Graceful shutdown |
119+| `SIGINT` | Graceful shutdown (Ctrl+C) |
120+| `SIGUSR1` | Reload configuration |
121+| `SIGUSR2` | Manual deploy (used by `gordon deploy`) |
122+
123+### Running with systemd
124+
125+```bash
126+# Create user service
127+mkdir -p ~/.config/systemd/user
128+cat > ~/.config/systemd/user/gordon.service <<EOF
129+[Unit]
130+Description=Gordon Container Platform
131+
132+[Service]
133+Type=simple
134+Restart=always
135+ExecStart=/usr/local/bin/gordon start
136+
137+[Install]
138+WantedBy=default.target
139+EOF
140+
141+# Enable and start
142+systemctl --user daemon-reload
143+systemctl --user enable --now gordon
144+sudo loginctl enable-linger $USER
145+```
146+
147+### Startup Sequence
148+
149+1. Load configuration
150+2. Initialize logger
151+3. Create PID file
152+4. Connect to Docker/Podman runtime
153+5. Create storage directories
154+6. Initialize services (registry, proxy, auth)
155+7. Register event handlers
156+8. Start config file watcher
157+9. Sync existing containers
158+10. Start HTTP servers
159+
160+### Shutdown Sequence
161+
162+1. Receive shutdown signal
163+2. Stop accepting new requests
164+3. Complete in-flight requests
165+4. Stop managed containers (if configured)
166+5. Remove PID file
167+6. Exit
168+
169+---
170+
171+## gordon reload
172+
173+Reload configuration and sync containers to match.
174+
175+### Synopsis
176+
177+```bash
178+gordon reload
179+```
180+
181+### Description
182+
183+Sends `SIGUSR1` to the running Gordon process, triggering:
184+
185+- Configuration file reload
186+- Route synchronization
187+- Deployment of containers for routes missing containers
188+- Attachment deployment
189+
190+### Example
191+
192+```bash
193+gordon reload
194+```
195+
196+---
197+
198+## gordon deploy
199+
200+Manually deploy or redeploy a specific route.
201+
202+### Synopsis
203+
204+```bash
205+gordon deploy <domain>
206+```
207+
208+### Arguments
209+
210+| Argument | Description |
211+|----------|-------------|
212+| `<domain>` | The domain name of the route to deploy (required) |
213+
214+### Description
215+
216+Sends `SIGUSR2` to the Gordon process with the specified domain, triggering:
217+
218+- Fresh image pull (always pulls latest, ignoring cache)
219+- Container redeployment for the specified route
220+
221+### Examples
222+
223+```bash
224+gordon deploy myapp.example.com
225+gordon deploy api.example.com
226+```
227+
228+### Use Cases
229+
230+- Recover from a failed deployment
231+- Force redeploy without pushing a new image
232+- Manual deployment when automatic deploy didn't trigger
233+
234+---
235+
236+## gordon logs
237+
238+Display Gordon process logs.
239+
240+### Synopsis
241+
242+```bash
243+gordon logs [options]
244+```
245+
246+### Options
247+
248+| Option | Short | Default | Description |
249+|--------|-------|---------|-------------|
250+| `--config` | `-c` | Auto | Path to config file |
251+| `--follow` | `-f` | false | Follow log output (like `tail -f`) |
252+| `--lines` | `-n` | 50 | Number of lines to show |
253+
254+### Examples
255+
256+```bash
257+gordon logs # Last 50 lines
258+gordon logs -f # Follow logs
259+gordon logs -n 100 # Last 100 lines
260+gordon logs -f -n 200 # Follow, starting from last 200 lines
261+```
262+
263+### Log Locations
264+
265+```bash
266+# Using gordon logs
267+gordon logs -f
268+
269+# Direct file access
270+tail -f ~/.gordon/logs/gordon.log
271+
272+# With systemd
273+journalctl --user -u gordon -f
274+```
275+
276+---
277+
278+## gordon version
279+
280+Print version information.
281+
282+### Synopsis
283+
284+```bash
285+gordon version
286+```
287+
288+### Output
289+
290+```
291+Gordon v2.0.0
292+Commit: abc1234
293+Build Date: 2024-01-15
294+```
295+
296+---
297+
298+## gordon auth
299+
300+Manage registry authentication tokens and passwords.
301+
302+### Subcommands
303+
304+| Subcommand | Description |
305+|------------|-------------|
306+| `token generate` | Generate a new JWT token |
307+| `token list` | List all stored tokens |
308+| `token revoke` | Revoke a token by ID |
309+| `password hash` | Generate bcrypt password hash |
310+| `internal` | Show internal registry credentials |
311+
312+### gordon auth token generate
313+
314+Generate a new JWT authentication token for registry access.
315+
316+```bash
317+gordon auth token generate --subject <name> [options]
318+```
319+
320+**Options:**
321+
322+| Option | Default | Description |
323+|--------|---------|-------------|
324+| `--subject` | (required) | Username/subject for the token |
325+| `--scopes` | `push,pull` | Comma-separated scopes |
326+| `--expiry` | `720h` | Duration until expiry (0 = never) |
327+| `-c, --config` | Auto | Path to config file |
328+
329+**Examples:**
330+
331+```bash
332+# CI token that never expires
333+gordon auth token generate --subject github-actions --expiry 0
334+
335+# Read-only token
336+gordon auth token generate --subject reader --scopes pull --expiry 720h
337+
338+# Push-only token for build system
339+gordon auth token generate --subject builder --scopes push --expiry 0
340+
341+# Temporary token (24 hours)
342+gordon auth token generate --subject temp --expiry 24h
343+```
344+
345+**Output:**
346+
347+```
348+Token generated successfully!
349+Subject: github-actions
350+Scopes: push, pull
351+Expiry: never
352+
353+Token (use as password with docker login):
354+eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
355+
356+Usage: docker login -u github-actions -p <token> <registry>
357+```
358+
359+### gordon auth token list
360+
361+List all stored authentication tokens.
362+
363+```bash
364+gordon auth token list [options]
365+```
366+
367+**Output:**
368+
369+```
370+ID Subject Expires Revoked
371+------------------------------------------------------------------------------------------
372+a1b2c3d4-e5f6-7890-abcd-ef1234567890 github-actions never no
373+b2c3d4e5-f6a7-8901-bcde-f12345678901 deploy-bot 2024-02-15 10:30 no
374+c3d4e5f6-a7b8-9012-cdef-123456789012 old-token 2024-01-01 00:00 yes
375+```
376+
377+### gordon auth token revoke
378+
379+Revoke a token by its ID.
380+
381+```bash
382+gordon auth token revoke <token-id> [options]
383+```
384+
385+**Example:**
386+
387+```bash
388+gordon auth token revoke a1b2c3d4-e5f6-7890-abcd-ef1234567890
389+```
390+
391+**Output:**
392+
393+```
394+Token a1b2c3d4-e5f6-7890-abcd-ef1234567890 has been revoked.
395+```
396+
397+### gordon auth password hash
398+
399+Generate a bcrypt hash for password authentication.
400+
401+```bash
402+gordon auth password hash
403+```
404+
405+Interactively prompts for a password and outputs the bcrypt hash.
406+
407+**Output:**
408+
409+```
410+Enter password: ********
411+
412+Bcrypt hash (store in your secrets backend):
413+$2a$10$N9qo8uLOickgx2ZMRZoMye...
414+
415+Then reference the path in your config:
416+ [registry_auth]
417+ type = "password"
418+ password_hash = "gordon/registry/password_hash"
419+```
420+
421+### gordon auth internal
422+
423+Display the auto-generated internal registry credentials.
424+
425+```bash
426+gordon auth internal
427+```
428+
429+Gordon generates temporary credentials for internal communication with its local registry. These credentials are useful for manual recovery when debugging deployment issues.
430+
431+**Output:**
432+
433+```
434+Internal Registry Credentials
435+==============================
436+Username: gordon-internal
437+Password: 8f3a2b1c4d5e6f7a8b9c0d1e2f3a4b5c...
438+
439+Usage:
440+ docker login localhost:5000 -u gordon-internal -p 8f3a2b1c4d5e6f7a8b9c0d1e2f3a4b5c...
441+```
442+
443+**Notes:**
444+- Credentials are regenerated each time Gordon starts
445+- Only available while Gordon is running
446+- Used for manual `docker pull` from the local registry during debugging
447+
448+**Use cases:**
449+- Debugging image pull failures
450+- Manual recovery after deployment issues
451+- Inspecting images in the local registry
452+
453+---
454+
455+## Token Scopes
456+
457+| Scope | Permission |
458+|-------|------------|
459+| `push` | Push images to registry |
460+| `pull` | Pull images from registry |
461+| `push,pull` | Both push and pull (default) |
462+
463+## Token Expiry Formats
464+
465+| Format | Duration |
466+|--------|----------|
467+| `0` | Never expires |
468+| `24h` | 24 hours |
469+| `720h` | 30 days |
470+| `8760h` | 1 year |
471+
472+---
473+
474+## Workflow Examples
475+
476+### CI/CD Setup
477+
478+```bash
479+# Generate token for CI
480+gordon auth token generate --subject ci-bot --scopes push,pull --expiry 0
481+
482+# Save token as CI secret
483+# GitHub: Settings → Secrets → New repository secret
484+# GitLab: Settings → CI/CD → Variables
485+```
486+
487+### Docker Login
488+
489+```bash
490+# Using generated token
491+docker login -u ci-bot -p <token> registry.mydomain.com
492+
493+# In CI/CD
494+echo "$GORDON_TOKEN" | docker login -u ci-bot --password-stdin registry.mydomain.com
495+```
496+
497+### Token Rotation
498+
499+```bash
500+# Generate new token
501+gordon auth token generate --subject ci-bot --expiry 0
502+
503+# Update CI secrets with new token
504+
505+# Revoke old token
506+gordon auth token revoke <old-token-id>
507+```
508+
509+### Password Setup
510+
511+```bash
512+# Generate hash
513+gordon auth password hash
514+# Enter: mypassword
515+
516+# Store in pass
517+pass insert gordon/registry/password_hash
518+# Paste: $2a$10$...
519+
520+# Configure gordon.toml
521+[registry_auth]
522+enabled = true
523+type = "password"
524+username = "deploy"
525+password_hash = "gordon/registry/password_hash"
526+```
527+
528+---
529+
530+## Exit Codes
531+
532+| Code | Meaning |
533+|------|---------|
534+| 0 | Success |
535+| 1 | General error |
536+| 2 | Configuration error |
537+
538+## Environment Variables
539+
540+Gordon reads configuration from environment variables:
541+
542+```bash
543+GORDON_SERVER_PORT=8080 gordon start
544+GORDON_LOGGING_LEVEL=debug gordon start
545+```
546+
547+Pattern: `GORDON_SECTION_KEY` (uppercase, underscores)
548+
549+## Related
550+
551+- [Core Concepts](../concepts.md)
552+- [Configuration Reference](../config/index.md)
@@ -0,0 +1,552 @@
1+# CLI Commands
2+
3+Gordon provides a command-line interface for server management, deployment, and authentication.
4+
5+## Commands Overview
6+
7+| Command | Description |
8+|---------|-------------|
9+| `gordon start` | Start the Gordon server |
10+| `gordon reload` | Reload configuration and sync containers |
11+| `gordon deploy` | Manually deploy or redeploy a specific route |
12+| `gordon logs` | Display Gordon process logs |
13+| `gordon version` | Print version information |
14+| `gordon auth` | Manage registry authentication |
15+
16+## Quick Reference
17+
18+```bash
19+# Start Gordon
20+gordon start
21+gordon start --config /path/to/config.toml
22+
23+# Reload configuration
24+gordon reload
25+
26+# Deploy a specific route
27+gordon deploy myapp.example.com
28+
29+# View logs
30+gordon logs
31+gordon logs -f
32+gordon logs -n 100
33+
34+# Check version
35+gordon version
36+
37+# Authentication
38+gordon auth token generate --subject ci-bot --expiry 0
39+gordon auth token list
40+gordon auth token revoke <token-id>
41+gordon auth password hash
42+gordon auth internal
43+```
44+
45+## Global Options
46+
47+| Option | Description |
48+|--------|-------------|
49+| `-c, --config` | Path to configuration file |
50+
51+---
52+
53+## gordon start
54+
55+Start the Gordon server with registry and proxy components.
56+
57+### Synopsis
58+
59+```bash
60+gordon start [options]
61+```
62+
63+### Options
64+
65+| Option | Short | Default | Description |
66+|--------|-------|---------|-------------|
67+| `--config` | `-c` | Auto-detected | Path to configuration file |
68+
69+### Description
70+
71+Starts the Gordon server, which includes:
72+
73+- **Container Registry** - Receives image pushes on the registry port
74+- **HTTP Proxy** - Routes traffic to containers on the proxy port
75+- **Event Bus** - Coordinates deployments and updates
76+- **Config Watcher** - Monitors configuration file for changes
77+
78+### Configuration File Detection
79+
80+Gordon looks for configuration in this order:
81+
82+1. Path specified with `--config`
83+2. `/etc/gordon/gordon.toml`
84+3. `~/.config/gordon/gordon.toml`
85+4. `./gordon.toml`
86+
87+### First Run
88+
89+On first run without a config file, Gordon creates a default configuration at `~/.config/gordon/gordon.toml`.
90+
91+```bash
92+# First run - creates default config
93+gordon start
94+# Edit the config, then restart
95+```
96+
97+### Examples
98+
99+```bash
100+# Basic start
101+gordon start
102+
103+# With custom config
104+gordon start --config /path/to/gordon.toml
105+gordon start -c ./my-config.toml
106+
107+# With environment override
108+GORDON_SERVER_PORT=8080 gordon start
109+GORDON_LOGGING_LEVEL=debug gordon start
110+```
111+
112+### Signals
113+
114+Gordon responds to these signals:
115+
116+| Signal | Action |
117+|--------|--------|
118+| `SIGTERM` | Graceful shutdown |
119+| `SIGINT` | Graceful shutdown (Ctrl+C) |
120+| `SIGUSR1` | Reload configuration |
121+| `SIGUSR2` | Manual deploy (used by `gordon deploy`) |
122+
123+### Running with systemd
124+
125+```bash
126+# Create user service
127+mkdir -p ~/.config/systemd/user
128+cat > ~/.config/systemd/user/gordon.service <<EOF
129+[Unit]
130+Description=Gordon Container Platform
131+
132+[Service]
133+Type=simple
134+Restart=always
135+ExecStart=/usr/local/bin/gordon start
136+
137+[Install]
138+WantedBy=default.target
139+EOF
140+
141+# Enable and start
142+systemctl --user daemon-reload
143+systemctl --user enable --now gordon
144+sudo loginctl enable-linger $USER
145+```
146+
147+### Startup Sequence
148+
149+1. Load configuration
150+2. Initialize logger
151+3. Create PID file
152+4. Connect to Docker/Podman runtime
153+5. Create storage directories
154+6. Initialize services (registry, proxy, auth)
155+7. Register event handlers
156+8. Start config file watcher
157+9. Sync existing containers
158+10. Start HTTP servers
159+
160+### Shutdown Sequence
161+
162+1. Receive shutdown signal
163+2. Stop accepting new requests
164+3. Complete in-flight requests
165+4. Stop managed containers (if configured)
166+5. Remove PID file
167+6. Exit
168+
169+---
170+
171+## gordon reload
172+
173+Reload configuration and sync containers to match.
174+
175+### Synopsis
176+
177+```bash
178+gordon reload
179+```
180+
181+### Description
182+
183+Sends `SIGUSR1` to the running Gordon process, triggering:
184+
185+- Configuration file reload
186+- Route synchronization
187+- Deployment of containers for routes missing containers
188+- Attachment deployment
189+
190+### Example
191+
192+```bash
193+gordon reload
194+```
195+
196+---
197+
198+## gordon deploy
199+
200+Manually deploy or redeploy a specific route.
201+
202+### Synopsis
203+
204+```bash
205+gordon deploy <domain>
206+```
207+
208+### Arguments
209+
210+| Argument | Description |
211+|----------|-------------|
212+| `<domain>` | The domain name of the route to deploy (required) |
213+
214+### Description
215+
216+Sends `SIGUSR2` to the Gordon process with the specified domain, triggering:
217+
218+- Fresh image pull (always pulls latest, ignoring cache)
219+- Container redeployment for the specified route
220+
221+### Examples
222+
223+```bash
224+gordon deploy myapp.example.com
225+gordon deploy api.example.com
226+```
227+
228+### Use Cases
229+
230+- Recover from a failed deployment
231+- Force redeploy without pushing a new image
232+- Manual deployment when automatic deploy didn't trigger
233+
234+---
235+
236+## gordon logs
237+
238+Display Gordon process logs.
239+
240+### Synopsis
241+
242+```bash
243+gordon logs [options]
244+```
245+
246+### Options
247+
248+| Option | Short | Default | Description |
249+|--------|-------|---------|-------------|
250+| `--config` | `-c` | Auto | Path to config file |
251+| `--follow` | `-f` | false | Follow log output (like `tail -f`) |
252+| `--lines` | `-n` | 50 | Number of lines to show |
253+
254+### Examples
255+
256+```bash
257+gordon logs # Last 50 lines
258+gordon logs -f # Follow logs
259+gordon logs -n 100 # Last 100 lines
260+gordon logs -f -n 200 # Follow, starting from last 200 lines
261+```
262+
263+### Log Locations
264+
265+```bash
266+# Using gordon logs
267+gordon logs -f
268+
269+# Direct file access
270+tail -f ~/.gordon/logs/gordon.log
271+
272+# With systemd
273+journalctl --user -u gordon -f
274+```
275+
276+---
277+
278+## gordon version
279+
280+Print version information.
281+
282+### Synopsis
283+
284+```bash
285+gordon version
286+```
287+
288+### Output
289+
290+```
291+Gordon v2.0.0
292+Commit: abc1234
293+Build Date: 2024-01-15
294+```
295+
296+---
297+
298+## gordon auth
299+
300+Manage registry authentication tokens and passwords.
301+
302+### Subcommands
303+
304+| Subcommand | Description |
305+|------------|-------------|
306+| `token generate` | Generate a new JWT token |
307+| `token list` | List all stored tokens |
308+| `token revoke` | Revoke a token by ID |
309+| `password hash` | Generate bcrypt password hash |
310+| `internal` | Show internal registry credentials |
311+
312+### gordon auth token generate
313+
314+Generate a new JWT authentication token for registry access.
315+
316+```bash
317+gordon auth token generate --subject <name> [options]
318+```
319+
320+**Options:**
321+
322+| Option | Default | Description |
323+|--------|---------|-------------|
324+| `--subject` | (required) | Username/subject for the token |
325+| `--scopes` | `push,pull` | Comma-separated scopes |
326+| `--expiry` | `720h` | Duration until expiry (0 = never) |
327+| `-c, --config` | Auto | Path to config file |
328+
329+**Examples:**
330+
331+```bash
332+# CI token that never expires
333+gordon auth token generate --subject github-actions --expiry 0
334+
335+# Read-only token
336+gordon auth token generate --subject reader --scopes pull --expiry 720h
337+
338+# Push-only token for build system
339+gordon auth token generate --subject builder --scopes push --expiry 0
340+
341+# Temporary token (24 hours)
342+gordon auth token generate --subject temp --expiry 24h
343+```
344+
345+**Output:**
346+
347+```
348+Token generated successfully!
349+Subject: github-actions
350+Scopes: push, pull
351+Expiry: never
352+
353+Token (use as password with docker login):
354+eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
355+
356+Usage: docker login -u github-actions -p <token> <registry>
357+```
358+
359+### gordon auth token list
360+
361+List all stored authentication tokens.
362+
363+```bash
364+gordon auth token list [options]
365+```
366+
367+**Output:**
368+
369+```
370+ID Subject Expires Revoked
371+------------------------------------------------------------------------------------------
372+a1b2c3d4-e5f6-7890-abcd-ef1234567890 github-actions never no
373+b2c3d4e5-f6a7-8901-bcde-f12345678901 deploy-bot 2024-02-15 10:30 no
374+c3d4e5f6-a7b8-9012-cdef-123456789012 old-token 2024-01-01 00:00 yes
375+```
376+
377+### gordon auth token revoke
378+
379+Revoke a token by its ID.
380+
381+```bash
382+gordon auth token revoke <token-id> [options]
383+```
384+
385+**Example:**
386+
387+```bash
388+gordon auth token revoke a1b2c3d4-e5f6-7890-abcd-ef1234567890
389+```
390+
391+**Output:**
392+
393+```
394+Token a1b2c3d4-e5f6-7890-abcd-ef1234567890 has been revoked.
395+```
396+
397+### gordon auth password hash
398+
399+Generate a bcrypt hash for password authentication.
400+
401+```bash
402+gordon auth password hash
403+```
404+
405+Interactively prompts for a password and outputs the bcrypt hash.
406+
407+**Output:**
408+
409+```
410+Enter password: ********
411+
412+Bcrypt hash (store in your secrets backend):
413+$2a$10$N9qo8uLOickgx2ZMRZoMye...
414+
415+Then reference the path in your config:
416+ [registry_auth]
417+ type = "password"
418+ password_hash = "gordon/registry/password_hash"
419+```
420+
421+### gordon auth internal
422+
423+Display the auto-generated internal registry credentials.
424+
425+```bash
426+gordon auth internal
427+```
428+
429+Gordon generates temporary credentials for internal communication with its local registry. These credentials are useful for manual recovery when debugging deployment issues.
430+
431+**Output:**
432+
433+```
434+Internal Registry Credentials
435+==============================
436+Username: gordon-internal
437+Password: 8f3a2b1c4d5e6f7a8b9c0d1e2f3a4b5c...
438+
439+Usage:
440+ docker login localhost:5000 -u gordon-internal -p 8f3a2b1c4d5e6f7a8b9c0d1e2f3a4b5c...
441+```
442+
443+**Notes:**
444+- Credentials are regenerated each time Gordon starts
445+- Only available while Gordon is running
446+- Used for manual `docker pull` from the local registry during debugging
447+
448+**Use cases:**
449+- Debugging image pull failures
450+- Manual recovery after deployment issues
451+- Inspecting images in the local registry
452+
453+---
454+
455+## Token Scopes
456+
457+| Scope | Permission |
458+|-------|------------|
459+| `push` | Push images to registry |
460+| `pull` | Pull images from registry |
461+| `push,pull` | Both push and pull (default) |
462+
463+## Token Expiry Formats
464+
465+| Format | Duration |
466+|--------|----------|
467+| `0` | Never expires |
468+| `24h` | 24 hours |
469+| `720h` | 30 days |
470+| `8760h` | 1 year |
471+
472+---
473+
474+## Workflow Examples
475+
476+### CI/CD Setup
477+
478+```bash
479+# Generate token for CI
480+gordon auth token generate --subject ci-bot --scopes push,pull --expiry 0
481+
482+# Save token as CI secret
483+# GitHub: Settings → Secrets → New repository secret
484+# GitLab: Settings → CI/CD → Variables
485+```
486+
487+### Docker Login
488+
489+```bash
490+# Using generated token
491+docker login -u ci-bot -p <token> registry.mydomain.com
492+
493+# In CI/CD
494+echo "$GORDON_TOKEN" | docker login -u ci-bot --password-stdin registry.mydomain.com
495+```
496+
497+### Token Rotation
498+
499+```bash
500+# Generate new token
501+gordon auth token generate --subject ci-bot --expiry 0
502+
503+# Update CI secrets with new token
504+
505+# Revoke old token
506+gordon auth token revoke <old-token-id>
507+```
508+
509+### Password Setup
510+
511+```bash
512+# Generate hash
513+gordon auth password hash
514+# Enter: mypassword
515+
516+# Store in pass
517+pass insert gordon/registry/password_hash
518+# Paste: $2a$10$...
519+
520+# Configure gordon.toml
521+[registry_auth]
522+enabled = true
523+type = "password"
524+username = "deploy"
525+password_hash = "gordon/registry/password_hash"
526+```
527+
528+---
529+
530+## Exit Codes
531+
532+| Code | Meaning |
533+|------|---------|
534+| 0 | Success |
535+| 1 | General error |
536+| 2 | Configuration error |
537+
538+## Environment Variables
539+
540+Gordon reads configuration from environment variables:
541+
542+```bash
543+GORDON_SERVER_PORT=8080 gordon start
544+GORDON_LOGGING_LEVEL=debug gordon start
545+```
546+
547+Pattern: `GORDON_SECTION_KEY` (uppercase, underscores)
548+
549+## Related
550+
551+- [Core Concepts](../concepts.md)
552+- [Configuration Reference](../config/index.md)
D docs/cli/index.md +0 -151
@@ -1,151 +0,0 @@
1-# CLI Reference
2-
3-Gordon provides a command-line interface for server management and authentication.
4-
5-## Commands
6-
7-| Command | Description |
8-|---------|-------------|
9-| `gordon start` | Start the Gordon server |
10-| `gordon reload` | Reload configuration and sync containers |
11-| `gordon logs` | Display Gordon process logs |
12-| `gordon version` | Print version information |
13-| `gordon auth` | Manage registry authentication |
14-
15-## Quick Reference
16-
17-```bash
18-# Start Gordon
19-gordon start
20-gordon start --config /path/to/config.toml
21-
22-# Reload configuration
23-gordon reload
24-
25-# View logs
26-gordon logs
27-gordon logs -f
28-gordon logs -n 100
29-
30-# Check version
31-gordon version
32-
33-# Authentication
34-gordon auth token generate --subject ci-bot --expiry 0
35-gordon auth token list
36-gordon auth token revoke <token-id>
37-gordon auth password hash
38-```
39-
40-## Global Options
41-
42-| Option | Description |
43-|--------|-------------|
44-| `-c, --config` | Path to configuration file |
45-
46-## Command Details
47-
48-### gordon start
49-
50-Starts the Gordon server with registry and proxy components.
51-
52-```bash
53-gordon start [options]
54-```
55-
56-**Options:**
57-- `-c, --config string` - Path to config file
58-
59-**Example:**
60-```bash
61-gordon start
62-gordon start --config ~/.config/gordon/gordon.toml
63-gordon start -c /etc/gordon/gordon.toml
64-```
65-
66-See [gordon start](./start.md) for details.
67-
68-### gordon reload
69-
70-Sends a reload signal to the running Gordon process. Reloads configuration and syncs containers to match.
71-
72-```bash
73-gordon reload
74-```
75-
76-This sends `SIGUSR1` to the Gordon process, triggering:
77-- Configuration file reload
78-- Route synchronization
79-- Attachment deployment
80-
81-### gordon logs
82-
83-Displays logs from the Gordon process.
84-
85-```bash
86-gordon logs [options]
87-```
88-
89-**Options:**
90-- `-c, --config string` - Path to config file
91-- `-f, --follow` - Follow log output (like `tail -f`)
92-- `-n, --lines int` - Number of lines to show (default: 50)
93-
94-**Examples:**
95-```bash
96-gordon logs # Last 50 lines
97-gordon logs -f # Follow logs
98-gordon logs -n 100 # Last 100 lines
99-gordon logs -f -n 200 # Follow, starting from last 200 lines
100-```
101-
102-### gordon version
103-
104-Prints version information.
105-
106-```bash
107-gordon version
108-```
109-
110-**Output:**
111-```
112-Gordon v2.0.0
113-Commit: abc1234
114-Build Date: 2024-01-15
115-```
116-
117-### gordon auth
118-
119-Manage registry authentication. See [gordon auth](./auth.md) for details.
120-
121-```bash
122-gordon auth token generate # Generate JWT token
123-gordon auth token list # List all tokens
124-gordon auth token revoke # Revoke a token
125-gordon auth password hash # Generate bcrypt hash
126-```
127-
128-## Exit Codes
129-
130-| Code | Meaning |
131-|------|---------|
132-| 0 | Success |
133-| 1 | General error |
134-| 2 | Configuration error |
135-
136-## Environment Variables
137-
138-Gordon reads configuration from environment variables:
139-
140-```bash
141-GORDON_SERVER_PORT=8080 gordon start
142-GORDON_LOGGING_LEVEL=debug gordon start
143-```
144-
145-Pattern: `GORDON_SECTION_KEY` (uppercase, underscores)
146-
147-## Related
148-
149-- [gordon start](./start.md)
150-- [gordon auth](./auth.md)
151-- [Configuration Reference](../config/index.md)
@@ -1,151 +0,0 @@
1-# CLI Reference
2-
3-Gordon provides a command-line interface for server management and authentication.
4-
5-## Commands
6-
7-| Command | Description |
8-|---------|-------------|
9-| `gordon start` | Start the Gordon server |
10-| `gordon reload` | Reload configuration and sync containers |
11-| `gordon logs` | Display Gordon process logs |
12-| `gordon version` | Print version information |
13-| `gordon auth` | Manage registry authentication |
14-
15-## Quick Reference
16-
17-```bash
18-# Start Gordon
19-gordon start
20-gordon start --config /path/to/config.toml
21-
22-# Reload configuration
23-gordon reload
24-
25-# View logs
26-gordon logs
27-gordon logs -f
28-gordon logs -n 100
29-
30-# Check version
31-gordon version
32-
33-# Authentication
34-gordon auth token generate --subject ci-bot --expiry 0
35-gordon auth token list
36-gordon auth token revoke <token-id>
37-gordon auth password hash
38-```
39-
40-## Global Options
41-
42-| Option | Description |
43-|--------|-------------|
44-| `-c, --config` | Path to configuration file |
45-
46-## Command Details
47-
48-### gordon start
49-
50-Starts the Gordon server with registry and proxy components.
51-
52-```bash
53-gordon start [options]
54-```
55-
56-**Options:**
57-- `-c, --config string` - Path to config file
58-
59-**Example:**
60-```bash
61-gordon start
62-gordon start --config ~/.config/gordon/gordon.toml
63-gordon start -c /etc/gordon/gordon.toml
64-```
65-
66-See [gordon start](./start.md) for details.
67-
68-### gordon reload
69-
70-Sends a reload signal to the running Gordon process. Reloads configuration and syncs containers to match.
71-
72-```bash
73-gordon reload
74-```
75-
76-This sends `SIGUSR1` to the Gordon process, triggering:
77-- Configuration file reload
78-- Route synchronization
79-- Attachment deployment
80-
81-### gordon logs
82-
83-Displays logs from the Gordon process.
84-
85-```bash
86-gordon logs [options]
87-```
88-
89-**Options:**
90-- `-c, --config string` - Path to config file
91-- `-f, --follow` - Follow log output (like `tail -f`)
92-- `-n, --lines int` - Number of lines to show (default: 50)
93-
94-**Examples:**
95-```bash
96-gordon logs # Last 50 lines
97-gordon logs -f # Follow logs
98-gordon logs -n 100 # Last 100 lines
99-gordon logs -f -n 200 # Follow, starting from last 200 lines
100-```
101-
102-### gordon version
103-
104-Prints version information.
105-
106-```bash
107-gordon version
108-```
109-
110-**Output:**
111-```
112-Gordon v2.0.0
113-Commit: abc1234
114-Build Date: 2024-01-15
115-```
116-
117-### gordon auth
118-
119-Manage registry authentication. See [gordon auth](./auth.md) for details.
120-
121-```bash
122-gordon auth token generate # Generate JWT token
123-gordon auth token list # List all tokens
124-gordon auth token revoke # Revoke a token
125-gordon auth password hash # Generate bcrypt hash
126-```
127-
128-## Exit Codes
129-
130-| Code | Meaning |
131-|------|---------|
132-| 0 | Success |
133-| 1 | General error |
134-| 2 | Configuration error |
135-
136-## Environment Variables
137-
138-Gordon reads configuration from environment variables:
139-
140-```bash
141-GORDON_SERVER_PORT=8080 gordon start
142-GORDON_LOGGING_LEVEL=debug gordon start
143-```
144-
145-Pattern: `GORDON_SECTION_KEY` (uppercase, underscores)
146-
147-## Related
148-
149-- [gordon start](./start.md)
150-- [gordon auth](./auth.md)
151-- [Configuration Reference](../config/index.md)
D docs/cli/start.md +0 -161
@@ -1,161 +0,0 @@
1-# gordon start
2-
3-Start the Gordon server with registry and proxy components.
4-
5-## Synopsis
6-
7-```bash
8-gordon start [options]
9-```
10-
11-## Options
12-
13-| Option | Short | Default | Description |
14-|--------|-------|---------|-------------|
15-| `--config` | `-c` | Auto-detected | Path to configuration file |
16-
17-## Description
18-
19-Starts the Gordon server, which includes:
20-
21-- **Container Registry** - Receives image pushes on the registry port
22-- **HTTP Proxy** - Routes traffic to containers on the proxy port
23-- **Event Bus** - Coordinates deployments and updates
24-- **Config Watcher** - Monitors configuration file for changes
25-
26-## Configuration File Detection
27-
28-Gordon looks for configuration in this order:
29-
30-1. Path specified with `--config`
31-2. `/etc/gordon/gordon.toml`
32-3. `~/.config/gordon/gordon.toml`
33-4. `./gordon.toml`
34-
35-## First Run
36-
37-On first run without a config file, Gordon creates a default configuration at `~/.config/gordon/gordon.toml`.
38-
39-```bash
40-# First run - creates default config
41-gordon start
42-# Edit the config, then restart
43-```
44-
45-## Examples
46-
47-### Basic Start
48-
49-```bash
50-gordon start
51-```
52-
53-### With Custom Config
54-
55-```bash
56-gordon start --config /path/to/gordon.toml
57-gordon start -c ./my-config.toml
58-```
59-
60-### With Environment Override
61-
62-```bash
63-GORDON_SERVER_PORT=8080 gordon start
64-GORDON_LOGGING_LEVEL=debug gordon start
65-```
66-
67-## Signals
68-
69-Gordon responds to these signals:
70-
71-| Signal | Action |
72-|--------|--------|
73-| `SIGTERM` | Graceful shutdown |
74-| `SIGINT` | Graceful shutdown (Ctrl+C) |
75-| `SIGUSR1` | Reload configuration |
76-
77-## Process Management
78-
79-### Running in Foreground
80-
81-```bash
82-gordon start
83-# Press Ctrl+C to stop
84-```
85-
86-### Running with systemd
87-
88-```bash
89-# Create user service
90-mkdir -p ~/.config/systemd/user
91-cat > ~/.config/systemd/user/gordon.service <<EOF
92-[Unit]
93-Description=Gordon Container Platform
94-
95-[Service]
96-Type=simple
97-Restart=always
98-ExecStart=/usr/local/bin/gordon start
99-
100-[Install]
101-WantedBy=default.target
102-EOF
103-
104-# Enable and start
105-systemctl --user daemon-reload
106-systemctl --user enable --now gordon
107-sudo loginctl enable-linger $USER
108-```
109-
110-### Check Status
111-
112-```bash
113-# systemd
114-systemctl --user status gordon
115-
116-# Process
117-pgrep -f "gordon start"
118-```
119-
120-## Startup Sequence
121-
122-1. Load configuration
123-2. Initialize logger
124-3. Create PID file
125-4. Connect to Docker/Podman runtime
126-5. Create storage directories
127-6. Initialize services (registry, proxy, auth)
128-7. Register event handlers
129-8. Start config file watcher
130-9. Sync existing containers
131-10. Start HTTP servers
132-
133-## Shutdown Sequence
134-
135-1. Receive shutdown signal
136-2. Stop accepting new requests
137-3. Complete in-flight requests
138-4. Stop managed containers (if configured)
139-5. Remove PID file
140-6. Exit
141-
142-## Logs
143-
144-View startup and runtime logs:
145-
146-```bash
147-# Using gordon logs
148-gordon logs -f
149-
150-# Direct file access
151-tail -f ~/.gordon/logs/gordon.log
152-
153-# With systemd
154-journalctl --user -u gordon -f
155-```
156-
157-## Related
158-
159-- [CLI Overview](./index.md)
160-- [Configuration](../config/index.md)
161-- [Installation](../installation.md)
@@ -1,161 +0,0 @@
1-# gordon start
2-
3-Start the Gordon server with registry and proxy components.
4-
5-## Synopsis
6-
7-```bash
8-gordon start [options]
9-```
10-
11-## Options
12-
13-| Option | Short | Default | Description |
14-|--------|-------|---------|-------------|
15-| `--config` | `-c` | Auto-detected | Path to configuration file |
16-
17-## Description
18-
19-Starts the Gordon server, which includes:
20-
21-- **Container Registry** - Receives image pushes on the registry port
22-- **HTTP Proxy** - Routes traffic to containers on the proxy port
23-- **Event Bus** - Coordinates deployments and updates
24-- **Config Watcher** - Monitors configuration file for changes
25-
26-## Configuration File Detection
27-
28-Gordon looks for configuration in this order:
29-
30-1. Path specified with `--config`
31-2. `/etc/gordon/gordon.toml`
32-3. `~/.config/gordon/gordon.toml`
33-4. `./gordon.toml`
34-
35-## First Run
36-
37-On first run without a config file, Gordon creates a default configuration at `~/.config/gordon/gordon.toml`.
38-
39-```bash
40-# First run - creates default config
41-gordon start
42-# Edit the config, then restart
43-```
44-
45-## Examples
46-
47-### Basic Start
48-
49-```bash
50-gordon start
51-```
52-
53-### With Custom Config
54-
55-```bash
56-gordon start --config /path/to/gordon.toml
57-gordon start -c ./my-config.toml
58-```
59-
60-### With Environment Override
61-
62-```bash
63-GORDON_SERVER_PORT=8080 gordon start
64-GORDON_LOGGING_LEVEL=debug gordon start
65-```
66-
67-## Signals
68-
69-Gordon responds to these signals:
70-
71-| Signal | Action |
72-|--------|--------|
73-| `SIGTERM` | Graceful shutdown |
74-| `SIGINT` | Graceful shutdown (Ctrl+C) |
75-| `SIGUSR1` | Reload configuration |
76-
77-## Process Management
78-
79-### Running in Foreground
80-
81-```bash
82-gordon start
83-# Press Ctrl+C to stop
84-```
85-
86-### Running with systemd
87-
88-```bash
89-# Create user service
90-mkdir -p ~/.config/systemd/user
91-cat > ~/.config/systemd/user/gordon.service <<EOF
92-[Unit]
93-Description=Gordon Container Platform
94-
95-[Service]
96-Type=simple
97-Restart=always
98-ExecStart=/usr/local/bin/gordon start
99-
100-[Install]
101-WantedBy=default.target
102-EOF
103-
104-# Enable and start
105-systemctl --user daemon-reload
106-systemctl --user enable --now gordon
107-sudo loginctl enable-linger $USER
108-```
109-
110-### Check Status
111-
112-```bash
113-# systemd
114-systemctl --user status gordon
115-
116-# Process
117-pgrep -f "gordon start"
118-```
119-
120-## Startup Sequence
121-
122-1. Load configuration
123-2. Initialize logger
124-3. Create PID file
125-4. Connect to Docker/Podman runtime
126-5. Create storage directories
127-6. Initialize services (registry, proxy, auth)
128-7. Register event handlers
129-8. Start config file watcher
130-9. Sync existing containers
131-10. Start HTTP servers
132-
133-## Shutdown Sequence
134-
135-1. Receive shutdown signal
136-2. Stop accepting new requests
137-3. Complete in-flight requests
138-4. Stop managed containers (if configured)
139-5. Remove PID file
140-6. Exit
141-
142-## Logs
143-
144-View startup and runtime logs:
145-
146-```bash
147-# Using gordon logs
148-gordon logs -f
149-
150-# Direct file access
151-tail -f ~/.gordon/logs/gordon.log
152-
153-# With systemd
154-journalctl --user -u gordon -f
155-```
156-
157-## Related
158-
159-- [CLI Overview](./index.md)
160-- [Configuration](../config/index.md)
161-- [Installation](../installation.md)
M docs/concepts.md +1 -0
@@ -208,6 +208,7 @@ Gordon uses an internal event system for coordination:
208208 | `image.pushed` | Image pushed to registry | Deploy container |
209209 | `config.reload` | Config file changed | Sync containers |
210210 | `manual.reload` | `gordon reload` command | Sync containers |
211+| `manual.deploy` | `gordon deploy <domain>` command | Deploy specific route |
211212 | `container.deployed` | Container started | Update proxy cache |
212213
213214 ## Container Labels
@@ -208,6 +208,7 @@ Gordon uses an internal event system for coordination:
208 | `image.pushed` | Image pushed to registry | Deploy container |208 | `image.pushed` | Image pushed to registry | Deploy container |
209 | `config.reload` | Config file changed | Sync containers |209 | `config.reload` | Config file changed | Sync containers |
210 | `manual.reload` | `gordon reload` command | Sync containers |210 | `manual.reload` | `gordon reload` command | Sync containers |
211+| `manual.deploy` | `gordon deploy <domain>` command | Deploy specific route |
211 | `container.deployed` | Container started | Update proxy cache |212 | `container.deployed` | Container started | Update proxy cache |
212 213
213 ## Container Labels214 ## Container Labels
A docs/config/external-routes.md +92 -0
@@ -0,0 +1,92 @@
1+# External Routes Configuration
2+
3+External routes allow proxying to non-containerized services running on the host or network.
4+
5+## Configuration
6+
7+```toml
8+[external_routes]
9+"service.mydomain.com" = "localhost:5000"
10+"cache.mydomain.com" = "192.168.1.100:6379"
11+```
12+
13+## Syntax
14+
15+```toml
16+[external_routes]
17+"<domain>" = "<host>:<port>"
18+```
19+
20+| Component | Description |
21+|-----------|-------------|
22+| `domain` | Fully qualified domain name |
23+| `host` | Target hostname or IP address |
24+| `port` | Target port number |
25+
26+## Use Cases
27+
28+### Docker Registry
29+
30+Proxy to a Docker registry running outside of Gordon:
31+
32+```toml
33+[external_routes]
34+"registry.mydomain.com" = "localhost:5000"
35+```
36+
37+### Database Admin Tools
38+
39+Proxy to database admin interfaces:
40+
41+```toml
42+[external_routes]
43+"pgadmin.mydomain.com" = "localhost:5050"
44+"redis-commander.mydomain.com" = "localhost:8081"
45+```
46+
47+### Legacy Services
48+
49+Proxy to services that can't be containerized:
50+
51+```toml
52+[external_routes]
53+"legacy-api.mydomain.com" = "192.168.1.50:8080"
54+```
55+
56+## How It Works
57+
58+1. Request arrives for `service.mydomain.com`
59+2. Gordon checks if domain matches an external route
60+3. If matched, proxies directly to the configured `host:port`
61+4. No container lookup is performed
62+
63+```
64+Client ─> Gordon Proxy ─> External Service
65+ (port 80) (configured host:port)
66+```
67+
68+## Limitations
69+
70+- HTTP only (no HTTPS upstream)
71+- No health checks
72+- No load balancing
73+
74+## Hot Reload
75+
76+External routes reload automatically when the config file changes:
77+
78+```bash
79+# Edit config
80+vim ~/.config/gordon/gordon.toml
81+
82+# Add external route
83+[external_routes]
84+"newservice.mydomain.com" = "localhost:9000"
85+
86+# Save - Gordon reloads automatically
87+```
88+
89+## Related
90+
91+- [Routes Configuration](./routes.md)
92+- [Configuration Overview](./index.md)
@@ -0,0 +1,92 @@
1+# External Routes Configuration
2+
3+External routes allow proxying to non-containerized services running on the host or network.
4+
5+## Configuration
6+
7+```toml
8+[external_routes]
9+"service.mydomain.com" = "localhost:5000"
10+"cache.mydomain.com" = "192.168.1.100:6379"
11+```
12+
13+## Syntax
14+
15+```toml
16+[external_routes]
17+"<domain>" = "<host>:<port>"
18+```
19+
20+| Component | Description |
21+|-----------|-------------|
22+| `domain` | Fully qualified domain name |
23+| `host` | Target hostname or IP address |
24+| `port` | Target port number |
25+
26+## Use Cases
27+
28+### Docker Registry
29+
30+Proxy to a Docker registry running outside of Gordon:
31+
32+```toml
33+[external_routes]
34+"registry.mydomain.com" = "localhost:5000"
35+```
36+
37+### Database Admin Tools
38+
39+Proxy to database admin interfaces:
40+
41+```toml
42+[external_routes]
43+"pgadmin.mydomain.com" = "localhost:5050"
44+"redis-commander.mydomain.com" = "localhost:8081"
45+```
46+
47+### Legacy Services
48+
49+Proxy to services that can't be containerized:
50+
51+```toml
52+[external_routes]
53+"legacy-api.mydomain.com" = "192.168.1.50:8080"
54+```
55+
56+## How It Works
57+
58+1. Request arrives for `service.mydomain.com`
59+2. Gordon checks if domain matches an external route
60+3. If matched, proxies directly to the configured `host:port`
61+4. No container lookup is performed
62+
63+```
64+Client ─> Gordon Proxy ─> External Service
65+ (port 80) (configured host:port)
66+```
67+
68+## Limitations
69+
70+- HTTP only (no HTTPS upstream)
71+- No health checks
72+- No load balancing
73+
74+## Hot Reload
75+
76+External routes reload automatically when the config file changes:
77+
78+```bash
79+# Edit config
80+vim ~/.config/gordon/gordon.toml
81+
82+# Add external route
83+[external_routes]
84+"newservice.mydomain.com" = "localhost:9000"
85+
86+# Save - Gordon reloads automatically
87+```
88+
89+## Related
90+
91+- [Routes Configuration](./routes.md)
92+- [Configuration Overview](./index.md)
M docs/config/index.md +2 -0
@@ -115,6 +115,7 @@ enabled = false # Auto-create routes from image names
115115 | `[network_isolation]` | Network isolation settings | [Network Isolation](./network-isolation.md) |
116116 | `[auto_route]` | Automatic route creation | [Auto Route](./auto-route.md) |
117117 | `[routes]` | Domain to image mapping | [Routes](./routes.md) |
118+| `[external_routes]` | Non-containerized service proxying | [External Routes](./external-routes.md) |
118119 | `[network_groups]` | Shared service networks | [Network Groups](./network-groups.md) |
119120 | `[attachments]` | Service dependencies | [Attachments](./attachments.md) |
120121
@@ -173,4 +174,5 @@ Pattern: `GORDON_SECTION_KEY` (uppercase, underscores instead of dots)
173174
174175 - [Server Configuration](./server.md)
175176 - [Routes Configuration](./routes.md)
177+- [External Routes](./external-routes.md)
176178 - [Registry Authentication](./registry-auth.md)
@@ -115,6 +115,7 @@ enabled = false # Auto-create routes from image names
115 | `[network_isolation]` | Network isolation settings | [Network Isolation](./network-isolation.md) |115 | `[network_isolation]` | Network isolation settings | [Network Isolation](./network-isolation.md) |
116 | `[auto_route]` | Automatic route creation | [Auto Route](./auto-route.md) |116 | `[auto_route]` | Automatic route creation | [Auto Route](./auto-route.md) |
117 | `[routes]` | Domain to image mapping | [Routes](./routes.md) |117 | `[routes]` | Domain to image mapping | [Routes](./routes.md) |
118+| `[external_routes]` | Non-containerized service proxying | [External Routes](./external-routes.md) |
118 | `[network_groups]` | Shared service networks | [Network Groups](./network-groups.md) |119 | `[network_groups]` | Shared service networks | [Network Groups](./network-groups.md) |
119 | `[attachments]` | Service dependencies | [Attachments](./attachments.md) |120 | `[attachments]` | Service dependencies | [Attachments](./attachments.md) |
120 121
@@ -173,4 +174,5 @@ Pattern: `GORDON_SECTION_KEY` (uppercase, underscores instead of dots)
173 174
174 - [Server Configuration](./server.md)175 - [Server Configuration](./server.md)
175 - [Routes Configuration](./routes.md)176 - [Routes Configuration](./routes.md)
177+- [External Routes](./external-routes.md)
176 - [Registry Authentication](./registry-auth.md)178 - [Registry Authentication](./registry-auth.md)
M docs/config/logging.md +1 -1
@@ -202,5 +202,5 @@ Sensitive values (passwords, tokens) are automatically redacted.
202202 ## Related
203203
204204 - [Configuration Overview](./index.md)
205-- [CLI Commands](../cli/index.md)
205+- [CLI Commands](../cli/commands.md)
206206 - [Troubleshooting](../reference/troubleshooting.md)
@@ -202,5 +202,5 @@ Sensitive values (passwords, tokens) are automatically redacted.
202 ## Related202 ## Related
203 203
204 - [Configuration Overview](./index.md)204 - [Configuration Overview](./index.md)
205-- [CLI Commands](../cli/index.md)205+- [CLI Commands](../cli/commands.md)
206 - [Troubleshooting](../reference/troubleshooting.md)206 - [Troubleshooting](../reference/troubleshooting.md)
M docs/config/registry-auth.md +1 -1
@@ -197,5 +197,5 @@ Gordon generates internal credentials automatically when auth is enabled. These
197197 ## Related
198198
199199 - [Secrets Configuration](./secrets.md)
200-- [CLI Auth Commands](../cli/auth.md)
200+- [CLI Commands](../cli/commands.md)
201201 - [GitHub Actions Deployment](../deployment/github-actions.md)
@@ -197,5 +197,5 @@ Gordon generates internal credentials automatically when auth is enabled. These
197 ## Related197 ## Related
198 198
199 - [Secrets Configuration](./secrets.md)199 - [Secrets Configuration](./secrets.md)
200-- [CLI Auth Commands](../cli/auth.md)200+- [CLI Commands](../cli/commands.md)
201 - [GitHub Actions Deployment](../deployment/github-actions.md)201 - [GitHub Actions Deployment](../deployment/github-actions.md)
M docs/config/routes.md +5 -0
@@ -194,8 +194,13 @@ Add to `/etc/hosts`:
194194 "portal.acme-corp.com" = "saas-app:v2.1.0"
195195 ```
196196
197+## External Services
198+
199+For non-containerized services (databases, legacy apps, etc.), see [External Routes](./external-routes.md).
200+
197201 ## Related
198202
199203 - [Configuration Overview](./index.md)
200204 - [Auto Route](./auto-route.md)
201205 - [Attachments](./attachments.md)
206+- [External Routes](./external-routes.md)
@@ -194,8 +194,13 @@ Add to `/etc/hosts`:
194 "portal.acme-corp.com" = "saas-app:v2.1.0"194 "portal.acme-corp.com" = "saas-app:v2.1.0"
195 ```195 ```
196 196
197+## External Services
198+
199+For non-containerized services (databases, legacy apps, etc.), see [External Routes](./external-routes.md).
200+
197 ## Related201 ## Related
198 202
199 - [Configuration Overview](./index.md)203 - [Configuration Overview](./index.md)
200 - [Auto Route](./auto-route.md)204 - [Auto Route](./auto-route.md)
201 - [Attachments](./attachments.md)205 - [Attachments](./attachments.md)
206+- [External Routes](./external-routes.md)
M docs/index.md +1 -3
@@ -55,9 +55,7 @@ Gordon runs on your VPS and provides:
5555
5656 ### CLI Reference
5757
58-- [CLI Overview](./cli/index.md) - Available commands
59-- [gordon start](./cli/start.md) - Start the server
60-- [gordon auth](./cli/auth.md) - Manage authentication
58+- [CLI Commands](./cli/commands.md) - All available commands
6159
6260 ### Deployment
6361
@@ -55,9 +55,7 @@ Gordon runs on your VPS and provides:
55 55
56 ### CLI Reference56 ### CLI Reference
57 57
58-- [CLI Overview](./cli/index.md) - Available commands58+- [CLI Commands](./cli/commands.md) - All available commands
59-- [gordon start](./cli/start.md) - Start the server
60-- [gordon auth](./cli/auth.md) - Manage authentication
61 59
62 ### Deployment60 ### Deployment
63 61
M docs/reference/troubleshooting.md +1 -1
@@ -334,4 +334,4 @@ curl -v http://localhost:8080/
334334
335335 - [Installation](../installation.md)
336336 - [Configuration](../config/index.md)
337-- [CLI Reference](../cli/index.md)
337+- [CLI Commands](../cli/commands.md)
@@ -334,4 +334,4 @@ curl -v http://localhost:8080/
334 334
335 - [Installation](../installation.md)335 - [Installation](../installation.md)
336 - [Configuration](../config/index.md)336 - [Configuration](../config/index.md)
337-- [CLI Reference](../cli/index.md)337+- [CLI Commands](../cli/commands.md)
M internal/adapters/in/cli/auth.go +39 -0
@@ -30,6 +30,7 @@ func newAuthCmd() *cobra.Command {
3030
3131 cmd.AddCommand(newAuthTokenCmd())
3232 cmd.AddCommand(newAuthPasswordCmd())
33+ cmd.AddCommand(newAuthInternalCmd())
3334
3435 return cmd
3536 }
@@ -132,6 +133,44 @@ func newAuthPasswordCmd() *cobra.Command {
132133 return cmd
133134 }
134135
136+// newAuthInternalCmd creates the internal credentials command.
137+func newAuthInternalCmd() *cobra.Command {
138+ return &cobra.Command{
139+ Use: "internal",
140+ Short: "Show internal registry credentials for manual recovery",
141+ Long: `Display the auto-generated internal registry credentials.
142+
143+These credentials are used by Gordon for pulling images from the local registry
144+(localhost:5000) during internal deploys. You can use them for manual recovery:
145+
146+ docker login localhost:5000 -u gordon-internal -p <password>
147+
148+Note: Credentials are regenerated each time Gordon starts, and are only
149+available while Gordon is running.`,
150+ RunE: func(cmd *cobra.Command, args []string) error {
151+ return runShowInternalAuth()
152+ },
153+ }
154+}
155+
156+// runShowInternalAuth displays the internal registry credentials.
157+func runShowInternalAuth() error {
158+ creds, err := app.GetInternalCredentials()
159+ if err != nil {
160+ return err
161+ }
162+
163+ fmt.Println("Internal Registry Credentials")
164+ fmt.Println("==============================")
165+ fmt.Printf("Username: %s\n", creds.Username)
166+ fmt.Printf("Password: %s\n", creds.Password)
167+ fmt.Println()
168+ fmt.Println("Usage:")
169+ fmt.Printf(" docker login localhost:5000 -u %s -p %s\n", creds.Username, creds.Password)
170+
171+ return nil
172+}
173+
135174 // newPasswordHashCmd creates the password hash command.
136175 func newPasswordHashCmd() *cobra.Command {
137176 return &cobra.Command{
@@ -30,6 +30,7 @@ func newAuthCmd() *cobra.Command {
30 30
31 cmd.AddCommand(newAuthTokenCmd())31 cmd.AddCommand(newAuthTokenCmd())
32 cmd.AddCommand(newAuthPasswordCmd())32 cmd.AddCommand(newAuthPasswordCmd())
33+ cmd.AddCommand(newAuthInternalCmd())
33 34
34 return cmd35 return cmd
35 }36 }
@@ -132,6 +133,44 @@ func newAuthPasswordCmd() *cobra.Command {
132 return cmd133 return cmd
133 }134 }
134 135
136+// newAuthInternalCmd creates the internal credentials command.
137+func newAuthInternalCmd() *cobra.Command {
138+ return &cobra.Command{
139+ Use: "internal",
140+ Short: "Show internal registry credentials for manual recovery",
141+ Long: `Display the auto-generated internal registry credentials.
142+
143+These credentials are used by Gordon for pulling images from the local registry
144+(localhost:5000) during internal deploys. You can use them for manual recovery:
145+
146+ docker login localhost:5000 -u gordon-internal -p <password>
147+
148+Note: Credentials are regenerated each time Gordon starts, and are only
149+available while Gordon is running.`,
150+ RunE: func(cmd *cobra.Command, args []string) error {
151+ return runShowInternalAuth()
152+ },
153+ }
154+}
155+
156+// runShowInternalAuth displays the internal registry credentials.
157+func runShowInternalAuth() error {
158+ creds, err := app.GetInternalCredentials()
159+ if err != nil {
160+ return err
161+ }
162+
163+ fmt.Println("Internal Registry Credentials")
164+ fmt.Println("==============================")
165+ fmt.Printf("Username: %s\n", creds.Username)
166+ fmt.Printf("Password: %s\n", creds.Password)
167+ fmt.Println()
168+ fmt.Println("Usage:")
169+ fmt.Printf(" docker login localhost:5000 -u %s -p %s\n", creds.Username, creds.Password)
170+
171+ return nil
172+}
173+
135 // newPasswordHashCmd creates the password hash command.174 // newPasswordHashCmd creates the password hash command.
136 func newPasswordHashCmd() *cobra.Command {175 func newPasswordHashCmd() *cobra.Command {
137 return &cobra.Command{176 return &cobra.Command{
A internal/adapters/in/cli/deploy.go +35 -0
@@ -0,0 +1,35 @@
1+package cli
2+
3+import (
4+ "fmt"
5+
6+ "github.com/spf13/cobra"
7+
8+ "gordon/internal/app"
9+)
10+
11+// newDeployCmd creates the deploy command.
12+func newDeployCmd() *cobra.Command {
13+ return &cobra.Command{
14+ Use: "deploy <domain>",
15+ Short: "Manually deploy or redeploy a route",
16+ Long: `Triggers a deployment for the specified route domain.
17+The route must be configured in config.toml.
18+
19+This will pull the latest image and deploy/redeploy the container,
20+even if a container is already running.
21+
22+Examples:
23+ gordon deploy myapp.example.com
24+ gordon deploy api.example.com`,
25+ Args: cobra.ExactArgs(1),
26+ RunE: func(cmd *cobra.Command, args []string) error {
27+ domain, err := app.SendDeploySignal(args[0])
28+ if err != nil {
29+ return err
30+ }
31+ fmt.Printf("Deploy signal sent for domain: %s\n", domain)
32+ return nil
33+ },
34+ }
35+}
@@ -0,0 +1,35 @@
1+package cli
2+
3+import (
4+ "fmt"
5+
6+ "github.com/spf13/cobra"
7+
8+ "gordon/internal/app"
9+)
10+
11+// newDeployCmd creates the deploy command.
12+func newDeployCmd() *cobra.Command {
13+ return &cobra.Command{
14+ Use: "deploy <domain>",
15+ Short: "Manually deploy or redeploy a route",
16+ Long: `Triggers a deployment for the specified route domain.
17+The route must be configured in config.toml.
18+
19+This will pull the latest image and deploy/redeploy the container,
20+even if a container is already running.
21+
22+Examples:
23+ gordon deploy myapp.example.com
24+ gordon deploy api.example.com`,
25+ Args: cobra.ExactArgs(1),
26+ RunE: func(cmd *cobra.Command, args []string) error {
27+ domain, err := app.SendDeploySignal(args[0])
28+ if err != nil {
29+ return err
30+ }
31+ fmt.Printf("Deploy signal sent for domain: %s\n", domain)
32+ return nil
33+ },
34+ }
35+}
M internal/adapters/in/cli/root.go +1 -0
@@ -32,6 +32,7 @@ configuration rules, making it ideal for single-server deployments.`,
3232 // Add subcommands
3333 rootCmd.AddCommand(newStartCmd())
3434 rootCmd.AddCommand(newReloadCmd())
35+ rootCmd.AddCommand(newDeployCmd())
3536 rootCmd.AddCommand(newLogsCmd())
3637 rootCmd.AddCommand(newVersionCmd())
3738 rootCmd.AddCommand(newAuthCmd())
@@ -32,6 +32,7 @@ configuration rules, making it ideal for single-server deployments.`,
32 // Add subcommands32 // Add subcommands
33 rootCmd.AddCommand(newStartCmd())33 rootCmd.AddCommand(newStartCmd())
34 rootCmd.AddCommand(newReloadCmd())34 rootCmd.AddCommand(newReloadCmd())
35+ rootCmd.AddCommand(newDeployCmd())
35 rootCmd.AddCommand(newLogsCmd())36 rootCmd.AddCommand(newLogsCmd())
36 rootCmd.AddCommand(newVersionCmd())37 rootCmd.AddCommand(newVersionCmd())
37 rootCmd.AddCommand(newAuthCmd())38 rootCmd.AddCommand(newAuthCmd())
M internal/app/run.go +189 -2
@@ -5,6 +5,7 @@ import (
55 "context"
66 "crypto/rand"
77 "encoding/hex"
8+ "encoding/json"
89 "fmt"
910 "net/http"
1011 "os"
@@ -242,6 +243,12 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
242243 if err != nil {
243244 return nil, log.WrapErr(err, "failed to generate internal registry credentials")
244245 }
246+
247+ // Persist credentials to file for CLI access (gordon auth internal)
248+ if err := persistInternalCredentials(svc.internalRegUser, svc.internalRegPass); err != nil {
249+ log.Warn().Err(err).Msg("failed to persist internal credentials for CLI access")
250+ }
251+
245252 log.Debug().Msg("internal registry auth generated for loopback pulls")
246253 }
247254
@@ -393,6 +400,57 @@ func randomTokenHex(size int) (string, error) {
393400 return hex.EncodeToString(buf), nil
394401 }
395402
403+// InternalCredentials holds the internal registry credentials for CLI access.
404+type InternalCredentials struct {
405+ Username string `json:"username"`
406+ Password string `json:"password"`
407+}
408+
409+// getInternalCredentialsFile returns the path to the internal credentials file.
410+func getInternalCredentialsFile() string {
411+ return filepath.Join(os.TempDir(), "gordon-internal-creds.json")
412+}
413+
414+// persistInternalCredentials saves the internal registry credentials to a temp file.
415+// Security note: Credentials are stored in the system temp directory with 0600 permissions.
416+// The file is cleaned up on graceful shutdown but may persist if Gordon crashes.
417+// These credentials are for internal loopback communication only and are regenerated on each start.
418+func persistInternalCredentials(username, password string) error {
419+ creds := InternalCredentials{
420+ Username: username,
421+ Password: password,
422+ }
423+ data, err := json.Marshal(creds)
424+ if err != nil {
425+ return fmt.Errorf("failed to marshal credentials: %w", err)
426+ }
427+ credFile := getInternalCredentialsFile()
428+ if err := os.WriteFile(credFile, data, 0600); err != nil {
429+ return fmt.Errorf("failed to write credentials file: %w", err)
430+ }
431+ return nil
432+}
433+
434+// cleanupInternalCredentials removes the internal credentials file.
435+func cleanupInternalCredentials() {
436+ _ = os.Remove(getInternalCredentialsFile())
437+}
438+
439+// GetInternalCredentials reads the internal registry credentials from file.
440+// This is used by the CLI to display credentials for manual recovery.
441+func GetInternalCredentials() (*InternalCredentials, error) {
442+ credFile := getInternalCredentialsFile()
443+ data, err := os.ReadFile(credFile)
444+ if err != nil {
445+ return nil, fmt.Errorf("failed to read credentials file (is Gordon running?): %w", err)
446+ }
447+ var creds InternalCredentials
448+ if err := json.Unmarshal(data, &creds); err != nil {
449+ return nil, fmt.Errorf("failed to parse credentials: %w", err)
450+ }
451+ return &creds, nil
452+}
453+
396454 // createAuthService creates the authentication service and token store.
397455 func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (out.TokenStore, *auth.Service, error) {
398456 if !cfg.RegistryAuth.Enabled {
@@ -606,6 +664,11 @@ func registerEventHandlers(ctx context.Context, svc *services) error {
606664 return fmt.Errorf("failed to subscribe manual reload handler: %w", err)
607665 }
608666
667+ manualDeployHandler := container.NewManualDeployHandler(ctx, svc.containerSvc, svc.configSvc)
668+ if err := svc.eventBus.Subscribe(manualDeployHandler); err != nil {
669+ return fmt.Errorf("failed to subscribe manual deploy handler: %w", err)
670+ }
671+
609672 // Proxy cache invalidation on container deployment (for zero-downtime)
610673 containerDeployedHandler := proxy.NewContainerDeployedHandler(ctx, svc.proxySvc)
611674 if err := svc.eventBus.Subscribe(containerDeployedHandler); err != nil {
@@ -635,6 +698,11 @@ func setupConfigHotReload(ctx context.Context, v *viper.Viper, svc *services, lo
635698 RegistryPort: v.GetInt("server.registry_port"),
636699 })
637700
701+ // Clear proxy target cache to pick up external route changes
702+ if err := svc.proxySvc.RefreshTargets(ctx); err != nil {
703+ log.Warn().Err(err).Msg("failed to refresh proxy targets")
704+ }
705+
638706 // Publish config reload event to trigger container updates
639707 if err := svc.eventBus.Publish(domain.EventConfigReload, nil); err != nil {
640708 log.Error().Err(err).Msg("failed to publish config reload event")
@@ -650,7 +718,8 @@ func syncAndAutoStart(ctx context.Context, svc *services, log zerowrap.Logger) {
650718 }
651719
652720 if svc.configSvc.IsAutoRouteEnabled() {
653- if err := svc.containerSvc.AutoStart(ctx); err != nil {
721+ routes := svc.configSvc.GetRoutes(ctx)
722+ if err := svc.containerSvc.AutoStart(ctx, routes); err != nil {
654723 log.Warn().Err(err).Msg("failed to auto-start containers")
655724 }
656725 }
@@ -704,7 +773,8 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
704773 // Signal handling notes:
705774 // - SIGINT/SIGTERM: Triggers graceful shutdown via signal.NotifyContext
706775 // - SIGUSR1: Triggers config reload without restart
707-// The deferred signal.Stop(reloadChan) ensures the signal handler is properly
776+// - SIGUSR2: Triggers manual deploy for a specific route
777+// The deferred signal.Stop calls ensure signal handlers are properly
708778 // cleaned up before program exit, preventing signal handler leaks.
709779 func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler http.Handler, containerSvc *container.Service, eventBus out.EventBus, log zerowrap.Logger) error {
710780 ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
@@ -717,6 +787,11 @@ func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler h
717787 signal.Notify(reloadChan, syscall.SIGUSR1)
718788 defer signal.Stop(reloadChan)
719789
790+ // Set up SIGUSR2 for manual deploy.
791+ deployChan := make(chan os.Signal, 1)
792+ signal.Notify(deployChan, syscall.SIGUSR2)
793+ defer signal.Stop(deployChan)
794+
720795 errChan := make(chan error, 2)
721796
722797 go startServer(fmt.Sprintf(":%d", cfg.Server.RegistryPort), registryHandler, "registry", errChan, log)
@@ -736,6 +811,17 @@ func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler h
736811 if err := eventBus.Publish(domain.EventManualReload, nil); err != nil {
737812 log.Error().Err(err).Msg("failed to publish manual reload event")
738813 }
814+ case <-deployChan:
815+ log.Info().Msg("deploy signal received (SIGUSR2)")
816+ domainName, err := readDeployRequest()
817+ if err != nil {
818+ log.Error().Err(err).Msg("failed to read deploy request")
819+ continue
820+ }
821+ payload := &domain.ManualDeployPayload{Domain: domainName}
822+ if err := eventBus.Publish(domain.EventManualDeploy, payload); err != nil {
823+ log.Error().Err(err).Str("domain", domainName).Msg("failed to publish manual deploy event")
824+ }
739825 case <-ctx.Done():
740826 log.Info().Msg("shutdown signal received")
741827 goto shutdown
@@ -749,6 +835,9 @@ shutdown:
749835 log.Warn().Err(err).Msg("error during container shutdown")
750836 }
751837
838+ // Clean up internal credentials file
839+ cleanupInternalCredentials()
840+
752841 log.Info().Msg("Gordon stopped")
753842 return nil
754843 }
@@ -801,6 +890,104 @@ func SendReloadSignal() error {
801890 return nil
802891 }
803892
893+// getDeployRequestFile returns the path to the deploy request file.
894+func getDeployRequestFile() string {
895+ return filepath.Join(os.TempDir(), "gordon-deploy-request")
896+}
897+
898+// writeDeployRequestFile creates the deploy request file exclusively with retry.
899+// This prevents race conditions when multiple deploy commands run simultaneously.
900+func writeDeployRequestFile(path string, data []byte, timeout time.Duration) error {
901+ deadline := time.Now().Add(timeout)
902+
903+ for {
904+ f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
905+ if err == nil {
906+ _, writeErr := f.Write(data)
907+ f.Close()
908+ if writeErr != nil {
909+ _ = os.Remove(path)
910+ return writeErr
911+ }
912+ return nil
913+ }
914+
915+ if os.IsExist(err) {
916+ if time.Now().After(deadline) {
917+ return fmt.Errorf("deploy request file still present after timeout; another deploy may be in progress")
918+ }
919+ time.Sleep(100 * time.Millisecond)
920+ continue
921+ }
922+
923+ return err
924+ }
925+}
926+
927+// SendDeploySignal triggers a manual deploy for a specific route via SIGUSR2.
928+// Returns the domain name on success for the caller to display.
929+func SendDeploySignal(domain string) (string, error) {
930+ deployFile := getDeployRequestFile()
931+ if err := writeDeployRequestFile(deployFile, []byte(domain), 5*time.Second); err != nil {
932+ return "", fmt.Errorf("failed to write deploy request: %w", err)
933+ }
934+
935+ // Find PID and send SIGUSR2
936+ pidFile := findPidFile()
937+ if pidFile == "" {
938+ _ = os.Remove(deployFile)
939+ return "", fmt.Errorf("gordon PID file not found, is Gordon running?")
940+ }
941+
942+ pidBytes, err := os.ReadFile(pidFile)
943+ if err != nil {
944+ _ = os.Remove(deployFile)
945+ return "", fmt.Errorf("failed to read PID file: %w", err)
946+ }
947+
948+ var pid int
949+ if _, err := fmt.Sscanf(string(pidBytes), "%d", &pid); err != nil {
950+ _ = os.Remove(deployFile)
951+ return "", fmt.Errorf("failed to parse PID: %w", err)
952+ }
953+
954+ process, err := os.FindProcess(pid)
955+ if err != nil {
956+ _ = os.Remove(deployFile)
957+ return "", fmt.Errorf("failed to find process: %w", err)
958+ }
959+
960+ if err := process.Signal(syscall.SIGUSR2); err != nil {
961+ _ = os.Remove(deployFile)
962+ return "", fmt.Errorf("failed to send deploy signal: %w", err)
963+ }
964+
965+ return domain, nil
966+}
967+
968+// readDeployRequest reads and removes the deploy request file atomically.
969+// Returns empty string if file doesn't exist (may have been consumed by another handler).
970+func readDeployRequest() (string, error) {
971+ deployFile := getDeployRequestFile()
972+
973+ // Rename to a temp file first to make the read-and-delete atomic
974+ tmpFile := deployFile + ".processing"
975+ if err := os.Rename(deployFile, tmpFile); err != nil {
976+ if os.IsNotExist(err) {
977+ return "", fmt.Errorf("deploy request file not found (may have been processed already)")
978+ }
979+ return "", fmt.Errorf("failed to acquire deploy request: %w", err)
980+ }
981+
982+ data, err := os.ReadFile(tmpFile)
983+ _ = os.Remove(tmpFile) // Always clean up
984+ if err != nil {
985+ return "", fmt.Errorf("failed to read deploy request: %w", err)
986+ }
987+
988+ return string(data), nil
989+}
990+
804991 // createPidFile creates a PID file for the Gordon process.
805992 func createPidFile(log zerowrap.Logger) string {
806993 pid := os.Getpid()
@@ -5,6 +5,7 @@ import (
5 "context"5 "context"
6 "crypto/rand"6 "crypto/rand"
7 "encoding/hex"7 "encoding/hex"
8+ "encoding/json"
8 "fmt"9 "fmt"
9 "net/http"10 "net/http"
10 "os"11 "os"
@@ -242,6 +243,12 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
242 if err != nil {243 if err != nil {
243 return nil, log.WrapErr(err, "failed to generate internal registry credentials")244 return nil, log.WrapErr(err, "failed to generate internal registry credentials")
244 }245 }
246+
247+ // Persist credentials to file for CLI access (gordon auth internal)
248+ if err := persistInternalCredentials(svc.internalRegUser, svc.internalRegPass); err != nil {
249+ log.Warn().Err(err).Msg("failed to persist internal credentials for CLI access")
250+ }
251+
245 log.Debug().Msg("internal registry auth generated for loopback pulls")252 log.Debug().Msg("internal registry auth generated for loopback pulls")
246 }253 }
247 254
@@ -393,6 +400,57 @@ func randomTokenHex(size int) (string, error) {
393 return hex.EncodeToString(buf), nil400 return hex.EncodeToString(buf), nil
394 }401 }
395 402
403+// InternalCredentials holds the internal registry credentials for CLI access.
404+type InternalCredentials struct {
405+ Username string `json:"username"`
406+ Password string `json:"password"`
407+}
408+
409+// getInternalCredentialsFile returns the path to the internal credentials file.
410+func getInternalCredentialsFile() string {
411+ return filepath.Join(os.TempDir(), "gordon-internal-creds.json")
412+}
413+
414+// persistInternalCredentials saves the internal registry credentials to a temp file.
415+// Security note: Credentials are stored in the system temp directory with 0600 permissions.
416+// The file is cleaned up on graceful shutdown but may persist if Gordon crashes.
417+// These credentials are for internal loopback communication only and are regenerated on each start.
418+func persistInternalCredentials(username, password string) error {
419+ creds := InternalCredentials{
420+ Username: username,
421+ Password: password,
422+ }
423+ data, err := json.Marshal(creds)
424+ if err != nil {
425+ return fmt.Errorf("failed to marshal credentials: %w", err)
426+ }
427+ credFile := getInternalCredentialsFile()
428+ if err := os.WriteFile(credFile, data, 0600); err != nil {
429+ return fmt.Errorf("failed to write credentials file: %w", err)
430+ }
431+ return nil
432+}
433+
434+// cleanupInternalCredentials removes the internal credentials file.
435+func cleanupInternalCredentials() {
436+ _ = os.Remove(getInternalCredentialsFile())
437+}
438+
439+// GetInternalCredentials reads the internal registry credentials from file.
440+// This is used by the CLI to display credentials for manual recovery.
441+func GetInternalCredentials() (*InternalCredentials, error) {
442+ credFile := getInternalCredentialsFile()
443+ data, err := os.ReadFile(credFile)
444+ if err != nil {
445+ return nil, fmt.Errorf("failed to read credentials file (is Gordon running?): %w", err)
446+ }
447+ var creds InternalCredentials
448+ if err := json.Unmarshal(data, &creds); err != nil {
449+ return nil, fmt.Errorf("failed to parse credentials: %w", err)
450+ }
451+ return &creds, nil
452+}
453+
396 // createAuthService creates the authentication service and token store.454 // createAuthService creates the authentication service and token store.
397 func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (out.TokenStore, *auth.Service, error) {455 func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (out.TokenStore, *auth.Service, error) {
398 if !cfg.RegistryAuth.Enabled {456 if !cfg.RegistryAuth.Enabled {
@@ -606,6 +664,11 @@ func registerEventHandlers(ctx context.Context, svc *services) error {
606 return fmt.Errorf("failed to subscribe manual reload handler: %w", err)664 return fmt.Errorf("failed to subscribe manual reload handler: %w", err)
607 }665 }
608 666
667+ manualDeployHandler := container.NewManualDeployHandler(ctx, svc.containerSvc, svc.configSvc)
668+ if err := svc.eventBus.Subscribe(manualDeployHandler); err != nil {
669+ return fmt.Errorf("failed to subscribe manual deploy handler: %w", err)
670+ }
671+
609 // Proxy cache invalidation on container deployment (for zero-downtime)672 // Proxy cache invalidation on container deployment (for zero-downtime)
610 containerDeployedHandler := proxy.NewContainerDeployedHandler(ctx, svc.proxySvc)673 containerDeployedHandler := proxy.NewContainerDeployedHandler(ctx, svc.proxySvc)
611 if err := svc.eventBus.Subscribe(containerDeployedHandler); err != nil {674 if err := svc.eventBus.Subscribe(containerDeployedHandler); err != nil {
@@ -635,6 +698,11 @@ func setupConfigHotReload(ctx context.Context, v *viper.Viper, svc *services, lo
635 RegistryPort: v.GetInt("server.registry_port"),698 RegistryPort: v.GetInt("server.registry_port"),
636 })699 })
637 700
701+ // Clear proxy target cache to pick up external route changes
702+ if err := svc.proxySvc.RefreshTargets(ctx); err != nil {
703+ log.Warn().Err(err).Msg("failed to refresh proxy targets")
704+ }
705+
638 // Publish config reload event to trigger container updates706 // Publish config reload event to trigger container updates
639 if err := svc.eventBus.Publish(domain.EventConfigReload, nil); err != nil {707 if err := svc.eventBus.Publish(domain.EventConfigReload, nil); err != nil {
640 log.Error().Err(err).Msg("failed to publish config reload event")708 log.Error().Err(err).Msg("failed to publish config reload event")
@@ -650,7 +718,8 @@ func syncAndAutoStart(ctx context.Context, svc *services, log zerowrap.Logger) {
650 }718 }
651 719
652 if svc.configSvc.IsAutoRouteEnabled() {720 if svc.configSvc.IsAutoRouteEnabled() {
653- if err := svc.containerSvc.AutoStart(ctx); err != nil {721+ routes := svc.configSvc.GetRoutes(ctx)
722+ if err := svc.containerSvc.AutoStart(ctx, routes); err != nil {
654 log.Warn().Err(err).Msg("failed to auto-start containers")723 log.Warn().Err(err).Msg("failed to auto-start containers")
655 }724 }
656 }725 }
@@ -704,7 +773,8 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
704 // Signal handling notes:773 // Signal handling notes:
705 // - SIGINT/SIGTERM: Triggers graceful shutdown via signal.NotifyContext774 // - SIGINT/SIGTERM: Triggers graceful shutdown via signal.NotifyContext
706 // - SIGUSR1: Triggers config reload without restart775 // - SIGUSR1: Triggers config reload without restart
707-// The deferred signal.Stop(reloadChan) ensures the signal handler is properly776+// - SIGUSR2: Triggers manual deploy for a specific route
777+// The deferred signal.Stop calls ensure signal handlers are properly
708 // cleaned up before program exit, preventing signal handler leaks.778 // cleaned up before program exit, preventing signal handler leaks.
709 func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler http.Handler, containerSvc *container.Service, eventBus out.EventBus, log zerowrap.Logger) error {779 func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler http.Handler, containerSvc *container.Service, eventBus out.EventBus, log zerowrap.Logger) error {
710 ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)780 ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
@@ -717,6 +787,11 @@ func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler h
717 signal.Notify(reloadChan, syscall.SIGUSR1)787 signal.Notify(reloadChan, syscall.SIGUSR1)
718 defer signal.Stop(reloadChan)788 defer signal.Stop(reloadChan)
719 789
790+ // Set up SIGUSR2 for manual deploy.
791+ deployChan := make(chan os.Signal, 1)
792+ signal.Notify(deployChan, syscall.SIGUSR2)
793+ defer signal.Stop(deployChan)
794+
720 errChan := make(chan error, 2)795 errChan := make(chan error, 2)
721 796
722 go startServer(fmt.Sprintf(":%d", cfg.Server.RegistryPort), registryHandler, "registry", errChan, log)797 go startServer(fmt.Sprintf(":%d", cfg.Server.RegistryPort), registryHandler, "registry", errChan, log)
@@ -736,6 +811,17 @@ func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler h
736 if err := eventBus.Publish(domain.EventManualReload, nil); err != nil {811 if err := eventBus.Publish(domain.EventManualReload, nil); err != nil {
737 log.Error().Err(err).Msg("failed to publish manual reload event")812 log.Error().Err(err).Msg("failed to publish manual reload event")
738 }813 }
814+ case <-deployChan:
815+ log.Info().Msg("deploy signal received (SIGUSR2)")
816+ domainName, err := readDeployRequest()
817+ if err != nil {
818+ log.Error().Err(err).Msg("failed to read deploy request")
819+ continue
820+ }
821+ payload := &domain.ManualDeployPayload{Domain: domainName}
822+ if err := eventBus.Publish(domain.EventManualDeploy, payload); err != nil {
823+ log.Error().Err(err).Str("domain", domainName).Msg("failed to publish manual deploy event")
824+ }
739 case <-ctx.Done():825 case <-ctx.Done():
740 log.Info().Msg("shutdown signal received")826 log.Info().Msg("shutdown signal received")
741 goto shutdown827 goto shutdown
@@ -749,6 +835,9 @@ shutdown:
749 log.Warn().Err(err).Msg("error during container shutdown")835 log.Warn().Err(err).Msg("error during container shutdown")
750 }836 }
751 837
838+ // Clean up internal credentials file
839+ cleanupInternalCredentials()
840+
752 log.Info().Msg("Gordon stopped")841 log.Info().Msg("Gordon stopped")
753 return nil842 return nil
754 }843 }
@@ -801,6 +890,104 @@ func SendReloadSignal() error {
801 return nil890 return nil
802 }891 }
803 892
893+// getDeployRequestFile returns the path to the deploy request file.
894+func getDeployRequestFile() string {
895+ return filepath.Join(os.TempDir(), "gordon-deploy-request")
896+}
897+
898+// writeDeployRequestFile creates the deploy request file exclusively with retry.
899+// This prevents race conditions when multiple deploy commands run simultaneously.
900+func writeDeployRequestFile(path string, data []byte, timeout time.Duration) error {
901+ deadline := time.Now().Add(timeout)
902+
903+ for {
904+ f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
905+ if err == nil {
906+ _, writeErr := f.Write(data)
907+ f.Close()
908+ if writeErr != nil {
909+ _ = os.Remove(path)
910+ return writeErr
911+ }
912+ return nil
913+ }
914+
915+ if os.IsExist(err) {
916+ if time.Now().After(deadline) {
917+ return fmt.Errorf("deploy request file still present after timeout; another deploy may be in progress")
918+ }
919+ time.Sleep(100 * time.Millisecond)
920+ continue
921+ }
922+
923+ return err
924+ }
925+}
926+
927+// SendDeploySignal triggers a manual deploy for a specific route via SIGUSR2.
928+// Returns the domain name on success for the caller to display.
929+func SendDeploySignal(domain string) (string, error) {
930+ deployFile := getDeployRequestFile()
931+ if err := writeDeployRequestFile(deployFile, []byte(domain), 5*time.Second); err != nil {
932+ return "", fmt.Errorf("failed to write deploy request: %w", err)
933+ }
934+
935+ // Find PID and send SIGUSR2
936+ pidFile := findPidFile()
937+ if pidFile == "" {
938+ _ = os.Remove(deployFile)
939+ return "", fmt.Errorf("gordon PID file not found, is Gordon running?")
940+ }
941+
942+ pidBytes, err := os.ReadFile(pidFile)
943+ if err != nil {
944+ _ = os.Remove(deployFile)
945+ return "", fmt.Errorf("failed to read PID file: %w", err)
946+ }
947+
948+ var pid int
949+ if _, err := fmt.Sscanf(string(pidBytes), "%d", &pid); err != nil {
950+ _ = os.Remove(deployFile)
951+ return "", fmt.Errorf("failed to parse PID: %w", err)
952+ }
953+
954+ process, err := os.FindProcess(pid)
955+ if err != nil {
956+ _ = os.Remove(deployFile)
957+ return "", fmt.Errorf("failed to find process: %w", err)
958+ }
959+
960+ if err := process.Signal(syscall.SIGUSR2); err != nil {
961+ _ = os.Remove(deployFile)
962+ return "", fmt.Errorf("failed to send deploy signal: %w", err)
963+ }
964+
965+ return domain, nil
966+}
967+
968+// readDeployRequest reads and removes the deploy request file atomically.
969+// Returns empty string if file doesn't exist (may have been consumed by another handler).
970+func readDeployRequest() (string, error) {
971+ deployFile := getDeployRequestFile()
972+
973+ // Rename to a temp file first to make the read-and-delete atomic
974+ tmpFile := deployFile + ".processing"
975+ if err := os.Rename(deployFile, tmpFile); err != nil {
976+ if os.IsNotExist(err) {
977+ return "", fmt.Errorf("deploy request file not found (may have been processed already)")
978+ }
979+ return "", fmt.Errorf("failed to acquire deploy request: %w", err)
980+ }
981+
982+ data, err := os.ReadFile(tmpFile)
983+ _ = os.Remove(tmpFile) // Always clean up
984+ if err != nil {
985+ return "", fmt.Errorf("failed to read deploy request: %w", err)
986+ }
987+
988+ return string(data), nil
989+}
990+
804 // createPidFile creates a PID file for the Gordon process.991 // createPidFile creates a PID file for the Gordon process.
805 func createPidFile(log zerowrap.Logger) string {992 func createPidFile(log zerowrap.Logger) string {
806 pid := os.Getpid()993 pid := os.Getpid()
M internal/boundaries/in/config.go +3 -0
@@ -47,4 +47,7 @@ type ConfigService interface {
4747
4848 // GetNetworkPrefix returns the prefix for created networks.
4949 GetNetworkPrefix() string
50+
51+ // GetExternalRoutes returns all configured external routes.
52+ GetExternalRoutes() map[string]string
5053 }
@@ -47,4 +47,7 @@ type ConfigService interface {
47 47
48 // GetNetworkPrefix returns the prefix for created networks.48 // GetNetworkPrefix returns the prefix for created networks.
49 GetNetworkPrefix() string49 GetNetworkPrefix() string
50+
51+ // GetExternalRoutes returns all configured external routes.
52+ GetExternalRoutes() map[string]string
50 }53 }
M internal/boundaries/in/container.go +2 -2
@@ -32,8 +32,8 @@ type ContainerService interface {
3232 // SyncContainers synchronizes containers with configured routes.
3333 SyncContainers(ctx context.Context) error
3434
35- // AutoStart starts containers for all configured routes.
36- AutoStart(ctx context.Context) error
35+ // AutoStart starts containers for the provided routes that aren't running.
36+ AutoStart(ctx context.Context, routes []domain.Route) error
3737
3838 // Shutdown gracefully shuts down all managed containers.
3939 Shutdown(ctx context.Context) error
@@ -32,8 +32,8 @@ type ContainerService interface {
32 // SyncContainers synchronizes containers with configured routes.32 // SyncContainers synchronizes containers with configured routes.
33 SyncContainers(ctx context.Context) error33 SyncContainers(ctx context.Context) error
34 34
35- // AutoStart starts containers for all configured routes.35+ // AutoStart starts containers for the provided routes that aren't running.
36- AutoStart(ctx context.Context) error36+ AutoStart(ctx context.Context, routes []domain.Route) error
37 37
38 // Shutdown gracefully shuts down all managed containers.38 // Shutdown gracefully shuts down all managed containers.
39 Shutdown(ctx context.Context) error39 Shutdown(ctx context.Context) error
M internal/boundaries/in/mocks/mock_config_service.go +46 -0
@@ -139,6 +139,52 @@ func (_c *MockConfigService_GetDataDir_Call) RunAndReturn(run func() string) *Mo
139139 return _c
140140 }
141141
142+// GetExternalRoutes provides a mock function for the type MockConfigService
143+func (_mock *MockConfigService) GetExternalRoutes() map[string]string {
144+ ret := _mock.Called()
145+
146+ if len(ret) == 0 {
147+ panic("no return value specified for GetExternalRoutes")
148+ }
149+
150+ var r0 map[string]string
151+ if returnFunc, ok := ret.Get(0).(func() map[string]string); ok {
152+ r0 = returnFunc()
153+ } else {
154+ if ret.Get(0) != nil {
155+ r0 = ret.Get(0).(map[string]string)
156+ }
157+ }
158+ return r0
159+}
160+
161+// MockConfigService_GetExternalRoutes_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetExternalRoutes'
162+type MockConfigService_GetExternalRoutes_Call struct {
163+ *mock.Call
164+}
165+
166+// GetExternalRoutes is a helper method to define mock.On call
167+func (_e *MockConfigService_Expecter) GetExternalRoutes() *MockConfigService_GetExternalRoutes_Call {
168+ return &MockConfigService_GetExternalRoutes_Call{Call: _e.mock.On("GetExternalRoutes")}
169+}
170+
171+func (_c *MockConfigService_GetExternalRoutes_Call) Run(run func()) *MockConfigService_GetExternalRoutes_Call {
172+ _c.Call.Run(func(args mock.Arguments) {
173+ run()
174+ })
175+ return _c
176+}
177+
178+func (_c *MockConfigService_GetExternalRoutes_Call) Return(stringToString map[string]string) *MockConfigService_GetExternalRoutes_Call {
179+ _c.Call.Return(stringToString)
180+ return _c
181+}
182+
183+func (_c *MockConfigService_GetExternalRoutes_Call) RunAndReturn(run func() map[string]string) *MockConfigService_GetExternalRoutes_Call {
184+ _c.Call.Return(run)
185+ return _c
186+}
187+
142188 // GetNetworkPrefix provides a mock function for the type MockConfigService
143189 func (_mock *MockConfigService) GetNetworkPrefix() string {
144190 ret := _mock.Called()
@@ -139,6 +139,52 @@ func (_c *MockConfigService_GetDataDir_Call) RunAndReturn(run func() string) *Mo
139 return _c139 return _c
140 }140 }
141 141
142+// GetExternalRoutes provides a mock function for the type MockConfigService
143+func (_mock *MockConfigService) GetExternalRoutes() map[string]string {
144+ ret := _mock.Called()
145+
146+ if len(ret) == 0 {
147+ panic("no return value specified for GetExternalRoutes")
148+ }
149+
150+ var r0 map[string]string
151+ if returnFunc, ok := ret.Get(0).(func() map[string]string); ok {
152+ r0 = returnFunc()
153+ } else {
154+ if ret.Get(0) != nil {
155+ r0 = ret.Get(0).(map[string]string)
156+ }
157+ }
158+ return r0
159+}
160+
161+// MockConfigService_GetExternalRoutes_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetExternalRoutes'
162+type MockConfigService_GetExternalRoutes_Call struct {
163+ *mock.Call
164+}
165+
166+// GetExternalRoutes is a helper method to define mock.On call
167+func (_e *MockConfigService_Expecter) GetExternalRoutes() *MockConfigService_GetExternalRoutes_Call {
168+ return &MockConfigService_GetExternalRoutes_Call{Call: _e.mock.On("GetExternalRoutes")}
169+}
170+
171+func (_c *MockConfigService_GetExternalRoutes_Call) Run(run func()) *MockConfigService_GetExternalRoutes_Call {
172+ _c.Call.Run(func(args mock.Arguments) {
173+ run()
174+ })
175+ return _c
176+}
177+
178+func (_c *MockConfigService_GetExternalRoutes_Call) Return(stringToString map[string]string) *MockConfigService_GetExternalRoutes_Call {
179+ _c.Call.Return(stringToString)
180+ return _c
181+}
182+
183+func (_c *MockConfigService_GetExternalRoutes_Call) RunAndReturn(run func() map[string]string) *MockConfigService_GetExternalRoutes_Call {
184+ _c.Call.Return(run)
185+ return _c
186+}
187+
142 // GetNetworkPrefix provides a mock function for the type MockConfigService188 // GetNetworkPrefix provides a mock function for the type MockConfigService
143 func (_mock *MockConfigService) GetNetworkPrefix() string {189 func (_mock *MockConfigService) GetNetworkPrefix() string {
144 ret := _mock.Called()190 ret := _mock.Called()
M internal/boundaries/in/mocks/mock_container_service.go +14 -8
@@ -39,16 +39,16 @@ func (_m *MockContainerService) EXPECT() *MockContainerService_Expecter {
3939 }
4040
4141 // AutoStart provides a mock function for the type MockContainerService
42-func (_mock *MockContainerService) AutoStart(ctx context.Context) error {
43- ret := _mock.Called(ctx)
42+func (_mock *MockContainerService) AutoStart(ctx context.Context, routes []domain.Route) error {
43+ ret := _mock.Called(ctx, routes)
4444
4545 if len(ret) == 0 {
4646 panic("no return value specified for AutoStart")
4747 }
4848
4949 var r0 error
50- if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok {
51- r0 = returnFunc(ctx)
50+ if returnFunc, ok := ret.Get(0).(func(context.Context, []domain.Route) error); ok {
51+ r0 = returnFunc(ctx, routes)
5252 } else {
5353 r0 = ret.Error(0)
5454 }
@@ -62,18 +62,24 @@ type MockContainerService_AutoStart_Call struct {
6262
6363 // AutoStart is a helper method to define mock.On call
6464 // - ctx context.Context
65-func (_e *MockContainerService_Expecter) AutoStart(ctx interface{}) *MockContainerService_AutoStart_Call {
66- return &MockContainerService_AutoStart_Call{Call: _e.mock.On("AutoStart", ctx)}
65+// - routes []domain.Route
66+func (_e *MockContainerService_Expecter) AutoStart(ctx interface{}, routes interface{}) *MockContainerService_AutoStart_Call {
67+ return &MockContainerService_AutoStart_Call{Call: _e.mock.On("AutoStart", ctx, routes)}
6768 }
6869
69-func (_c *MockContainerService_AutoStart_Call) Run(run func(ctx context.Context)) *MockContainerService_AutoStart_Call {
70+func (_c *MockContainerService_AutoStart_Call) Run(run func(ctx context.Context, routes []domain.Route)) *MockContainerService_AutoStart_Call {
7071 _c.Call.Run(func(args mock.Arguments) {
7172 var arg0 context.Context
7273 if args[0] != nil {
7374 arg0 = args[0].(context.Context)
7475 }
76+ var arg1 []domain.Route
77+ if args[1] != nil {
78+ arg1 = args[1].([]domain.Route)
79+ }
7580 run(
7681 arg0,
82+ arg1,
7783 )
7884 })
7985 return _c
@@ -84,7 +90,7 @@ func (_c *MockContainerService_AutoStart_Call) Return(err error) *MockContainerS
8490 return _c
8591 }
8692
87-func (_c *MockContainerService_AutoStart_Call) RunAndReturn(run func(ctx context.Context) error) *MockContainerService_AutoStart_Call {
93+func (_c *MockContainerService_AutoStart_Call) RunAndReturn(run func(ctx context.Context, routes []domain.Route) error) *MockContainerService_AutoStart_Call {
8894 _c.Call.Return(run)
8995 return _c
9096 }
@@ -39,16 +39,16 @@ func (_m *MockContainerService) EXPECT() *MockContainerService_Expecter {
39 }39 }
40 40
41 // AutoStart provides a mock function for the type MockContainerService41 // AutoStart provides a mock function for the type MockContainerService
42-func (_mock *MockContainerService) AutoStart(ctx context.Context) error {42+func (_mock *MockContainerService) AutoStart(ctx context.Context, routes []domain.Route) error {
43- ret := _mock.Called(ctx)43+ ret := _mock.Called(ctx, routes)
44 44
45 if len(ret) == 0 {45 if len(ret) == 0 {
46 panic("no return value specified for AutoStart")46 panic("no return value specified for AutoStart")
47 }47 }
48 48
49 var r0 error49 var r0 error
50- if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok {50+ if returnFunc, ok := ret.Get(0).(func(context.Context, []domain.Route) error); ok {
51- r0 = returnFunc(ctx)51+ r0 = returnFunc(ctx, routes)
52 } else {52 } else {
53 r0 = ret.Error(0)53 r0 = ret.Error(0)
54 }54 }
@@ -62,18 +62,24 @@ type MockContainerService_AutoStart_Call struct {
62 62
63 // AutoStart is a helper method to define mock.On call63 // AutoStart is a helper method to define mock.On call
64 // - ctx context.Context64 // - ctx context.Context
65-func (_e *MockContainerService_Expecter) AutoStart(ctx interface{}) *MockContainerService_AutoStart_Call {65+// - routes []domain.Route
66- return &MockContainerService_AutoStart_Call{Call: _e.mock.On("AutoStart", ctx)}66+func (_e *MockContainerService_Expecter) AutoStart(ctx interface{}, routes interface{}) *MockContainerService_AutoStart_Call {
67+ return &MockContainerService_AutoStart_Call{Call: _e.mock.On("AutoStart", ctx, routes)}
67 }68 }
68 69
69-func (_c *MockContainerService_AutoStart_Call) Run(run func(ctx context.Context)) *MockContainerService_AutoStart_Call {70+func (_c *MockContainerService_AutoStart_Call) Run(run func(ctx context.Context, routes []domain.Route)) *MockContainerService_AutoStart_Call {
70 _c.Call.Run(func(args mock.Arguments) {71 _c.Call.Run(func(args mock.Arguments) {
71 var arg0 context.Context72 var arg0 context.Context
72 if args[0] != nil {73 if args[0] != nil {
73 arg0 = args[0].(context.Context)74 arg0 = args[0].(context.Context)
74 }75 }
76+ var arg1 []domain.Route
77+ if args[1] != nil {
78+ arg1 = args[1].([]domain.Route)
79+ }
75 run(80 run(
76 arg0,81 arg0,
82+ arg1,
77 )83 )
78 })84 })
79 return _c85 return _c
@@ -84,7 +90,7 @@ func (_c *MockContainerService_AutoStart_Call) Return(err error) *MockContainerS
84 return _c90 return _c
85 }91 }
86 92
87-func (_c *MockContainerService_AutoStart_Call) RunAndReturn(run func(ctx context.Context) error) *MockContainerService_AutoStart_Call {93+func (_c *MockContainerService_AutoStart_Call) RunAndReturn(run func(ctx context.Context, routes []domain.Route) error) *MockContainerService_AutoStart_Call {
88 _c.Call.Return(run)94 _c.Call.Return(run)
89 return _c95 return _c
90 }96 }
M internal/domain/event.go +6 -0
@@ -13,6 +13,7 @@ const (
1313 EventImageDeleted EventType = "image.deleted"
1414 EventConfigReload EventType = "config.reload"
1515 EventManualReload EventType = "manual.reload"
16+ EventManualDeploy EventType = "manual.deploy"
1617 EventContainerStop EventType = "container.stop"
1718 EventContainerStart EventType = "container.start"
1819 EventContainerHealthCheck EventType = "container.health_check"
@@ -55,6 +56,11 @@ type ConfigReloadPayload struct {
5556 UpdatedRoutes []string
5657 }
5758
59+// ManualDeployPayload contains data for manual.deploy events.
60+type ManualDeployPayload struct {
61+ Domain string `json:"domain"`
62+}
63+
5864 // Context keys for domain-level concerns.
5965 type contextKey string
6066
@@ -13,6 +13,7 @@ const (
13 EventImageDeleted EventType = "image.deleted"13 EventImageDeleted EventType = "image.deleted"
14 EventConfigReload EventType = "config.reload"14 EventConfigReload EventType = "config.reload"
15 EventManualReload EventType = "manual.reload"15 EventManualReload EventType = "manual.reload"
16+ EventManualDeploy EventType = "manual.deploy"
16 EventContainerStop EventType = "container.stop"17 EventContainerStop EventType = "container.stop"
17 EventContainerStart EventType = "container.start"18 EventContainerStart EventType = "container.start"
18 EventContainerHealthCheck EventType = "container.health_check"19 EventContainerHealthCheck EventType = "container.health_check"
@@ -55,6 +56,11 @@ type ConfigReloadPayload struct {
55 UpdatedRoutes []string56 UpdatedRoutes []string
56 }57 }
57 58
59+// ManualDeployPayload contains data for manual.deploy events.
60+type ManualDeployPayload struct {
61+ Domain string `json:"domain"`
62+}
63+
58 // Context keys for domain-level concerns.64 // Context keys for domain-level concerns.
59 type contextKey string65 type contextKey string
60 66
M internal/domain/route.go +7 -0
@@ -21,3 +21,10 @@ type RouteMatch struct {
2121 Container *Container
2222 Target *ProxyTarget
2323 }
24+
25+// ExternalRoute represents a mapping from a domain to an external (non-container) service.
26+type ExternalRoute struct {
27+ Domain string // e.g., "reg.bnema.dev"
28+ Host string // e.g., "localhost"
29+ Port int // e.g., 5000
30+}
@@ -21,3 +21,10 @@ type RouteMatch struct {
21 Container *Container21 Container *Container
22 Target *ProxyTarget22 Target *ProxyTarget
23 }23 }
24+
25+// ExternalRoute represents a mapping from a domain to an external (non-container) service.
26+type ExternalRoute struct {
27+ Domain string // e.g., "reg.bnema.dev"
28+ Host string // e.g., "localhost"
29+ Port int // e.g., 5000
30+}
M internal/usecase/config/service.go +15 -0
@@ -24,6 +24,7 @@ type Config struct {
2424 NetworkIsolation bool
2525 NetworkPrefix string
2626 Routes map[string]string
27+ ExternalRoutes map[string]string // domain -> "host:port"
2728 RegistryAuthEnabled bool
2829 RegistryAuthUsername string
2930 RegistryAuthPassword string
@@ -66,6 +67,7 @@ func (s *Service) Load(ctx context.Context) error {
6667
6768 // Load complex nested structures
6869 s.config.Routes = loadStringMap(s.viper.Get("routes"))
70+ s.config.ExternalRoutes = loadStringMap(s.viper.Get("external_routes"))
6971 s.config.NetworkGroups = loadStringArrayMap(s.viper.Get("network_groups"))
7072 s.config.Attachments = loadStringArrayMap(s.viper.Get("attachments"))
7173
@@ -95,6 +97,7 @@ func (s *Service) loadConfigValues() Config {
9597 VolumePrefix: s.viper.GetString("volumes.prefix"),
9698 VolumePreserve: s.viper.GetBool("volumes.preserve"),
9799 Routes: make(map[string]string),
100+ ExternalRoutes: make(map[string]string),
98101 NetworkGroups: make(map[string][]string),
99102 Attachments: make(map[string][]string),
100103 }
@@ -365,6 +368,18 @@ func (s *Service) GetAttachments() map[string][]string {
365368 return result
366369 }
367370
371+// GetExternalRoutes returns all configured external routes.
372+func (s *Service) GetExternalRoutes() map[string]string {
373+ s.mu.RLock()
374+ defer s.mu.RUnlock()
375+
376+ result := make(map[string]string)
377+ for k, v := range s.config.ExternalRoutes {
378+ result[k] = v
379+ }
380+ return result
381+}
382+
368383 // ExtractDomainFromImageName extracts domain from image names like "myapp.bamen.dev:latest".
369384 func ExtractDomainFromImageName(imageName string) (string, bool) {
370385 parts := strings.Split(imageName, ":")
@@ -24,6 +24,7 @@ type Config struct {
24 NetworkIsolation bool24 NetworkIsolation bool
25 NetworkPrefix string25 NetworkPrefix string
26 Routes map[string]string26 Routes map[string]string
27+ ExternalRoutes map[string]string // domain -> "host:port"
27 RegistryAuthEnabled bool28 RegistryAuthEnabled bool
28 RegistryAuthUsername string29 RegistryAuthUsername string
29 RegistryAuthPassword string30 RegistryAuthPassword string
@@ -66,6 +67,7 @@ func (s *Service) Load(ctx context.Context) error {
66 67
67 // Load complex nested structures68 // Load complex nested structures
68 s.config.Routes = loadStringMap(s.viper.Get("routes"))69 s.config.Routes = loadStringMap(s.viper.Get("routes"))
70+ s.config.ExternalRoutes = loadStringMap(s.viper.Get("external_routes"))
69 s.config.NetworkGroups = loadStringArrayMap(s.viper.Get("network_groups"))71 s.config.NetworkGroups = loadStringArrayMap(s.viper.Get("network_groups"))
70 s.config.Attachments = loadStringArrayMap(s.viper.Get("attachments"))72 s.config.Attachments = loadStringArrayMap(s.viper.Get("attachments"))
71 73
@@ -95,6 +97,7 @@ func (s *Service) loadConfigValues() Config {
95 VolumePrefix: s.viper.GetString("volumes.prefix"),97 VolumePrefix: s.viper.GetString("volumes.prefix"),
96 VolumePreserve: s.viper.GetBool("volumes.preserve"),98 VolumePreserve: s.viper.GetBool("volumes.preserve"),
97 Routes: make(map[string]string),99 Routes: make(map[string]string),
100+ ExternalRoutes: make(map[string]string),
98 NetworkGroups: make(map[string][]string),101 NetworkGroups: make(map[string][]string),
99 Attachments: make(map[string][]string),102 Attachments: make(map[string][]string),
100 }103 }
@@ -365,6 +368,18 @@ func (s *Service) GetAttachments() map[string][]string {
365 return result368 return result
366 }369 }
367 370
371+// GetExternalRoutes returns all configured external routes.
372+func (s *Service) GetExternalRoutes() map[string]string {
373+ s.mu.RLock()
374+ defer s.mu.RUnlock()
375+
376+ result := make(map[string]string)
377+ for k, v := range s.config.ExternalRoutes {
378+ result[k] = v
379+ }
380+ return result
381+}
382+
368 // ExtractDomainFromImageName extracts domain from image names like "myapp.bamen.dev:latest".383 // ExtractDomainFromImageName extracts domain from image names like "myapp.bamen.dev:latest".
369 func ExtractDomainFromImageName(imageName string) (string, bool) {384 func ExtractDomainFromImageName(imageName string) (string, bool) {
370 parts := strings.Split(imageName, ":")385 parts := strings.Split(imageName, ":")
M internal/usecase/config/service_test.go +34 -0
@@ -232,6 +232,40 @@ func TestService_GetAttachments(t *testing.T) {
232232 assert.ElementsMatch(t, []string{"redis:latest", "postgres:15"}, attachments["app.example.com"])
233233 }
234234
235+func TestService_GetExternalRoutes(t *testing.T) {
236+ v := viper.New()
237+ v.Set("external_routes", map[string]interface{}{
238+ "reg.example.com": "localhost:5000",
239+ "cache.example.com": "127.0.0.1:6379",
240+ })
241+
242+ eventBus := mocks.NewMockEventPublisher(t)
243+ svc := NewService(v, eventBus)
244+ ctx := testContext()
245+
246+ _ = svc.Load(ctx)
247+
248+ routes := svc.GetExternalRoutes()
249+
250+ assert.Len(t, routes, 2)
251+ assert.Equal(t, "localhost:5000", routes["reg.example.com"])
252+ assert.Equal(t, "127.0.0.1:6379", routes["cache.example.com"])
253+}
254+
255+func TestService_GetExternalRoutes_Empty(t *testing.T) {
256+ v := viper.New()
257+
258+ eventBus := mocks.NewMockEventPublisher(t)
259+ svc := NewService(v, eventBus)
260+ ctx := testContext()
261+
262+ _ = svc.Load(ctx)
263+
264+ routes := svc.GetExternalRoutes()
265+
266+ assert.Empty(t, routes)
267+}
268+
235269 func TestExtractDomainFromImageName(t *testing.T) {
236270 tests := []struct {
237271 name string
@@ -232,6 +232,40 @@ func TestService_GetAttachments(t *testing.T) {
232 assert.ElementsMatch(t, []string{"redis:latest", "postgres:15"}, attachments["app.example.com"])232 assert.ElementsMatch(t, []string{"redis:latest", "postgres:15"}, attachments["app.example.com"])
233 }233 }
234 234
235+func TestService_GetExternalRoutes(t *testing.T) {
236+ v := viper.New()
237+ v.Set("external_routes", map[string]interface{}{
238+ "reg.example.com": "localhost:5000",
239+ "cache.example.com": "127.0.0.1:6379",
240+ })
241+
242+ eventBus := mocks.NewMockEventPublisher(t)
243+ svc := NewService(v, eventBus)
244+ ctx := testContext()
245+
246+ _ = svc.Load(ctx)
247+
248+ routes := svc.GetExternalRoutes()
249+
250+ assert.Len(t, routes, 2)
251+ assert.Equal(t, "localhost:5000", routes["reg.example.com"])
252+ assert.Equal(t, "127.0.0.1:6379", routes["cache.example.com"])
253+}
254+
255+func TestService_GetExternalRoutes_Empty(t *testing.T) {
256+ v := viper.New()
257+
258+ eventBus := mocks.NewMockEventPublisher(t)
259+ svc := NewService(v, eventBus)
260+ ctx := testContext()
261+
262+ _ = svc.Load(ctx)
263+
264+ routes := svc.GetExternalRoutes()
265+
266+ assert.Empty(t, routes)
267+}
268+
235 func TestExtractDomainFromImageName(t *testing.T) {269 func TestExtractDomainFromImageName(t *testing.T) {
236 tests := []struct {270 tests := []struct {
237 name string271 name string
M internal/usecase/container/events.go +76 -2
@@ -133,6 +133,12 @@ func (h *ConfigReloadHandler) Handle(event domain.Event) error {
133133
134134 log.Info().Msg("processing configuration reload event")
135135
136+ // Sync containers first to ensure we have accurate state
137+ // This removes tracking for containers that were stopped externally
138+ if err := h.containerSvc.SyncContainers(ctx); err != nil {
139+ log.Warn().Err(err).Msg("failed to sync containers before reload, proceeding with current state")
140+ }
141+
136142 currentContainers := h.containerSvc.List(ctx)
137143
138144 activeRoutes := make(map[string]*domain.Container)
@@ -159,13 +165,14 @@ func (h *ConfigReloadHandler) Handle(event domain.Event) error {
159165 }
160166 delete(activeRoutes, route.Domain)
161167 } else {
168+ // Route exists in config but no running container (new route or missing container)
162169 log.Info().
163170 Str("domain", route.Domain).
164171 Str("image", route.Image).
165- Msg("new route detected, deploying container")
172+ Msg("route missing container, deploying")
166173
167174 if _, err := h.containerSvc.Deploy(ctx, route); err != nil {
168- log.WrapErrWithFields(err, "failed to deploy container for new route", map[string]any{"domain": route.Domain})
175+ log.WrapErrWithFields(err, "failed to deploy container for route", map[string]any{"domain": route.Domain})
169176 }
170177 }
171178 }
@@ -223,6 +230,12 @@ func (h *ManualReloadHandler) Handle(event domain.Event) error {
223230
224231 log.Info().Msg("processing manual reload event - starting missing containers")
225232
233+ // Sync containers first to ensure we have accurate state
234+ // This removes tracking for containers that were stopped externally
235+ if err := h.containerSvc.SyncContainers(ctx); err != nil {
236+ log.Warn().Err(err).Msg("failed to sync containers before reload, proceeding with current state")
237+ }
238+
226239 routes := h.configSvc.GetRoutes(ctx)
227240 startedCount := 0
228241 skippedCount := 0
@@ -271,3 +284,64 @@ func (h *ManualReloadHandler) Handle(event domain.Event) error {
271284 func (h *ManualReloadHandler) CanHandle(eventType domain.EventType) bool {
272285 return eventType == domain.EventManualReload
273286 }
287+
288+// ManualDeployHandler handles manual.deploy events for specific routes.
289+type ManualDeployHandler struct {
290+ containerSvc in.ContainerService
291+ configSvc in.ConfigService
292+ ctx context.Context
293+}
294+
295+// NewManualDeployHandler creates a new ManualDeployHandler.
296+func NewManualDeployHandler(ctx context.Context, containerSvc in.ContainerService, configSvc in.ConfigService) *ManualDeployHandler {
297+ return &ManualDeployHandler{
298+ containerSvc: containerSvc,
299+ configSvc: configSvc,
300+ ctx: ctx,
301+ }
302+}
303+
304+// Handle handles a manual deploy event for a specific route.
305+func (h *ManualDeployHandler) Handle(event domain.Event) error {
306+ ctx := zerowrap.CtxWithFields(h.ctx, map[string]any{
307+ zerowrap.FieldLayer: "usecase",
308+ zerowrap.FieldHandler: "ManualDeployHandler",
309+ zerowrap.FieldEvent: string(event.Type),
310+ "event_id": event.ID,
311+ })
312+ log := zerowrap.FromCtx(ctx)
313+
314+ payload, ok := event.Data.(*domain.ManualDeployPayload)
315+ if !ok || payload == nil || payload.Domain == "" {
316+ return fmt.Errorf("invalid manual deploy payload")
317+ }
318+
319+ log.Info().Str("domain", payload.Domain).Msg("processing manual deploy event")
320+
321+ // Find the route in configuration
322+ routes := h.configSvc.GetRoutes(ctx)
323+ var targetRoute *domain.Route
324+ for _, r := range routes {
325+ if r.Domain == payload.Domain {
326+ targetRoute = &r
327+ break
328+ }
329+ }
330+
331+ if targetRoute == nil {
332+ return fmt.Errorf("route not found for domain: %s", payload.Domain)
333+ }
334+
335+ // Deploy the container (this will replace any existing container)
336+ if _, err := h.containerSvc.Deploy(ctx, *targetRoute); err != nil {
337+ return log.WrapErr(err, "failed to deploy container")
338+ }
339+
340+ log.Info().Str("domain", payload.Domain).Msg("manual deploy completed successfully")
341+ return nil
342+}
343+
344+// CanHandle returns whether this handler can handle the given event type.
345+func (h *ManualDeployHandler) CanHandle(eventType domain.EventType) bool {
346+ return eventType == domain.EventManualDeploy
347+}
@@ -133,6 +133,12 @@ func (h *ConfigReloadHandler) Handle(event domain.Event) error {
133 133
134 log.Info().Msg("processing configuration reload event")134 log.Info().Msg("processing configuration reload event")
135 135
136+ // Sync containers first to ensure we have accurate state
137+ // This removes tracking for containers that were stopped externally
138+ if err := h.containerSvc.SyncContainers(ctx); err != nil {
139+ log.Warn().Err(err).Msg("failed to sync containers before reload, proceeding with current state")
140+ }
141+
136 currentContainers := h.containerSvc.List(ctx)142 currentContainers := h.containerSvc.List(ctx)
137 143
138 activeRoutes := make(map[string]*domain.Container)144 activeRoutes := make(map[string]*domain.Container)
@@ -159,13 +165,14 @@ func (h *ConfigReloadHandler) Handle(event domain.Event) error {
159 }165 }
160 delete(activeRoutes, route.Domain)166 delete(activeRoutes, route.Domain)
161 } else {167 } else {
168+ // Route exists in config but no running container (new route or missing container)
162 log.Info().169 log.Info().
163 Str("domain", route.Domain).170 Str("domain", route.Domain).
164 Str("image", route.Image).171 Str("image", route.Image).
165- Msg("new route detected, deploying container")172+ Msg("route missing container, deploying")
166 173
167 if _, err := h.containerSvc.Deploy(ctx, route); err != nil {174 if _, err := h.containerSvc.Deploy(ctx, route); err != nil {
168- log.WrapErrWithFields(err, "failed to deploy container for new route", map[string]any{"domain": route.Domain})175+ log.WrapErrWithFields(err, "failed to deploy container for route", map[string]any{"domain": route.Domain})
169 }176 }
170 }177 }
171 }178 }
@@ -223,6 +230,12 @@ func (h *ManualReloadHandler) Handle(event domain.Event) error {
223 230
224 log.Info().Msg("processing manual reload event - starting missing containers")231 log.Info().Msg("processing manual reload event - starting missing containers")
225 232
233+ // Sync containers first to ensure we have accurate state
234+ // This removes tracking for containers that were stopped externally
235+ if err := h.containerSvc.SyncContainers(ctx); err != nil {
236+ log.Warn().Err(err).Msg("failed to sync containers before reload, proceeding with current state")
237+ }
238+
226 routes := h.configSvc.GetRoutes(ctx)239 routes := h.configSvc.GetRoutes(ctx)
227 startedCount := 0240 startedCount := 0
228 skippedCount := 0241 skippedCount := 0
@@ -271,3 +284,64 @@ func (h *ManualReloadHandler) Handle(event domain.Event) error {
271 func (h *ManualReloadHandler) CanHandle(eventType domain.EventType) bool {284 func (h *ManualReloadHandler) CanHandle(eventType domain.EventType) bool {
272 return eventType == domain.EventManualReload285 return eventType == domain.EventManualReload
273 }286 }
287+
288+// ManualDeployHandler handles manual.deploy events for specific routes.
289+type ManualDeployHandler struct {
290+ containerSvc in.ContainerService
291+ configSvc in.ConfigService
292+ ctx context.Context
293+}
294+
295+// NewManualDeployHandler creates a new ManualDeployHandler.
296+func NewManualDeployHandler(ctx context.Context, containerSvc in.ContainerService, configSvc in.ConfigService) *ManualDeployHandler {
297+ return &ManualDeployHandler{
298+ containerSvc: containerSvc,
299+ configSvc: configSvc,
300+ ctx: ctx,
301+ }
302+}
303+
304+// Handle handles a manual deploy event for a specific route.
305+func (h *ManualDeployHandler) Handle(event domain.Event) error {
306+ ctx := zerowrap.CtxWithFields(h.ctx, map[string]any{
307+ zerowrap.FieldLayer: "usecase",
308+ zerowrap.FieldHandler: "ManualDeployHandler",
309+ zerowrap.FieldEvent: string(event.Type),
310+ "event_id": event.ID,
311+ })
312+ log := zerowrap.FromCtx(ctx)
313+
314+ payload, ok := event.Data.(*domain.ManualDeployPayload)
315+ if !ok || payload == nil || payload.Domain == "" {
316+ return fmt.Errorf("invalid manual deploy payload")
317+ }
318+
319+ log.Info().Str("domain", payload.Domain).Msg("processing manual deploy event")
320+
321+ // Find the route in configuration
322+ routes := h.configSvc.GetRoutes(ctx)
323+ var targetRoute *domain.Route
324+ for _, r := range routes {
325+ if r.Domain == payload.Domain {
326+ targetRoute = &r
327+ break
328+ }
329+ }
330+
331+ if targetRoute == nil {
332+ return fmt.Errorf("route not found for domain: %s", payload.Domain)
333+ }
334+
335+ // Deploy the container (this will replace any existing container)
336+ if _, err := h.containerSvc.Deploy(ctx, *targetRoute); err != nil {
337+ return log.WrapErr(err, "failed to deploy container")
338+ }
339+
340+ log.Info().Str("domain", payload.Domain).Msg("manual deploy completed successfully")
341+ return nil
342+}
343+
344+// CanHandle returns whether this handler can handle the given event type.
345+func (h *ManualDeployHandler) CanHandle(eventType domain.EventType) bool {
346+ return eventType == domain.EventManualDeploy
347+}
M internal/usecase/container/events_test.go +151 -0
@@ -216,6 +216,9 @@ func TestConfigReloadHandler_Handle_DeploysNewRoutes(t *testing.T) {
216216
217217 handler := NewConfigReloadHandler(testCtx(), containerSvc, configSvc)
218218
219+ // SyncContainers is called first
220+ containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
221+
219222 // No existing containers
220223 containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{})
221224
@@ -245,6 +248,9 @@ func TestConfigReloadHandler_Handle_StopsRemovedRoutes(t *testing.T) {
245248
246249 handler := NewConfigReloadHandler(testCtx(), containerSvc, configSvc)
247250
251+ // SyncContainers is called first
252+ containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
253+
248254 // Existing container for a route that's no longer configured
249255 containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{
250256 "removed.example.com": {
@@ -278,6 +284,9 @@ func TestConfigReloadHandler_Handle_RedeploysChangedImage(t *testing.T) {
278284
279285 handler := NewConfigReloadHandler(testCtx(), containerSvc, configSvc)
280286
287+ // SyncContainers is called first
288+ containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
289+
281290 // Existing container with old image
282291 containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{
283292 "app.example.com": {
@@ -315,6 +324,9 @@ func TestConfigReloadHandler_Handle_NoChanges(t *testing.T) {
315324
316325 handler := NewConfigReloadHandler(testCtx(), containerSvc, configSvc)
317326
327+ // SyncContainers is called first
328+ containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
329+
318330 // Existing container matches config
319331 containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{
320332 "app.example.com": {
@@ -361,6 +373,9 @@ func TestManualReloadHandler_Handle_StartsOnlyMissingContainers(t *testing.T) {
361373
362374 handler := NewManualReloadHandler(testCtx(), containerSvc, configSvc)
363375
376+ // SyncContainers is called first
377+ containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
378+
364379 // Routes in config
365380 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
366381 {Domain: "app1.example.com", Image: "app1:latest"},
@@ -393,6 +408,9 @@ func TestManualReloadHandler_Handle_StartsMissingContainer(t *testing.T) {
393408
394409 handler := NewManualReloadHandler(testCtx(), containerSvc, configSvc)
395410
411+ // SyncContainers is called first
412+ containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
413+
396414 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
397415 {Domain: "app.example.com", Image: "app:latest"},
398416 })
@@ -421,6 +439,9 @@ func TestManualReloadHandler_Handle_DeployErrors(t *testing.T) {
421439
422440 handler := NewManualReloadHandler(testCtx(), containerSvc, configSvc)
423441
442+ // SyncContainers is called first
443+ containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
444+
424445 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
425446 {Domain: "app1.example.com", Image: "app1:latest"},
426447 {Domain: "app2.example.com", Image: "app2:latest"},
@@ -459,6 +480,9 @@ func TestManualReloadHandler_Handle_DoesNotRestartRunningContainers(t *testing.T
459480
460481 handler := NewManualReloadHandler(testCtx(), containerSvc, configSvc)
461482
483+ // SyncContainers is called first
484+ containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
485+
462486 // Route with running container
463487 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
464488 {Domain: "app.example.com", Image: "app:latest"},
@@ -477,3 +501,130 @@ func TestManualReloadHandler_Handle_DoesNotRestartRunningContainers(t *testing.T
477501
478502 assert.NoError(t, err)
479503 }
504+
505+// ManualDeployHandler tests
506+
507+func TestManualDeployHandler_CanHandle(t *testing.T) {
508+ containerSvc := inmocks.NewMockContainerService(t)
509+ configSvc := inmocks.NewMockConfigService(t)
510+
511+ handler := NewManualDeployHandler(testCtx(), containerSvc, configSvc)
512+
513+ assert.True(t, handler.CanHandle(domain.EventManualDeploy))
514+ assert.False(t, handler.CanHandle(domain.EventImagePushed))
515+ assert.False(t, handler.CanHandle(domain.EventManualReload))
516+}
517+
518+func TestManualDeployHandler_Handle_DeploysSpecificRoute(t *testing.T) {
519+ containerSvc := inmocks.NewMockContainerService(t)
520+ configSvc := inmocks.NewMockConfigService(t)
521+
522+ handler := NewManualDeployHandler(testCtx(), containerSvc, configSvc)
523+
524+ // Configure routes
525+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
526+ {Domain: "app1.example.com", Image: "app1:latest"},
527+ {Domain: "app2.example.com", Image: "app2:latest"},
528+ })
529+
530+ // Only the requested route should be deployed
531+ containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
532+ Domain: "app1.example.com",
533+ Image: "app1:latest",
534+ }).Return(&domain.Container{ID: "container-1"}, nil)
535+
536+ event := domain.Event{
537+ ID: "event-123",
538+ Type: domain.EventManualDeploy,
539+ Data: &domain.ManualDeployPayload{Domain: "app1.example.com"},
540+ }
541+
542+ err := handler.Handle(event)
543+
544+ assert.NoError(t, err)
545+}
546+
547+func TestManualDeployHandler_Handle_RouteNotFound(t *testing.T) {
548+ containerSvc := inmocks.NewMockContainerService(t)
549+ configSvc := inmocks.NewMockConfigService(t)
550+
551+ handler := NewManualDeployHandler(testCtx(), containerSvc, configSvc)
552+
553+ // No matching routes
554+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
555+ {Domain: "other.example.com", Image: "other:latest"},
556+ })
557+
558+ event := domain.Event{
559+ ID: "event-123",
560+ Type: domain.EventManualDeploy,
561+ Data: &domain.ManualDeployPayload{Domain: "unknown.example.com"},
562+ }
563+
564+ err := handler.Handle(event)
565+
566+ assert.Error(t, err)
567+ assert.Contains(t, err.Error(), "route not found")
568+}
569+
570+func TestManualDeployHandler_Handle_InvalidPayload(t *testing.T) {
571+ containerSvc := inmocks.NewMockContainerService(t)
572+ configSvc := inmocks.NewMockConfigService(t)
573+
574+ handler := NewManualDeployHandler(testCtx(), containerSvc, configSvc)
575+
576+ // Event with nil payload
577+ event := domain.Event{
578+ ID: "event-123",
579+ Type: domain.EventManualDeploy,
580+ Data: nil,
581+ }
582+
583+ err := handler.Handle(event)
584+
585+ assert.Error(t, err)
586+ assert.Contains(t, err.Error(), "invalid manual deploy payload")
587+}
588+
589+func TestManualDeployHandler_Handle_EmptyDomain(t *testing.T) {
590+ containerSvc := inmocks.NewMockContainerService(t)
591+ configSvc := inmocks.NewMockConfigService(t)
592+
593+ handler := NewManualDeployHandler(testCtx(), containerSvc, configSvc)
594+
595+ // Event with empty domain
596+ event := domain.Event{
597+ ID: "event-123",
598+ Type: domain.EventManualDeploy,
599+ Data: &domain.ManualDeployPayload{Domain: ""},
600+ }
601+
602+ err := handler.Handle(event)
603+
604+ assert.Error(t, err)
605+ assert.Contains(t, err.Error(), "invalid manual deploy payload")
606+}
607+
608+func TestManualDeployHandler_Handle_DeployError(t *testing.T) {
609+ containerSvc := inmocks.NewMockContainerService(t)
610+ configSvc := inmocks.NewMockConfigService(t)
611+
612+ handler := NewManualDeployHandler(testCtx(), containerSvc, configSvc)
613+
614+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
615+ {Domain: "app.example.com", Image: "app:latest"},
616+ })
617+
618+ containerSvc.EXPECT().Deploy(mock.Anything, mock.Anything).Return(nil, errors.New("deploy failed"))
619+
620+ event := domain.Event{
621+ ID: "event-123",
622+ Type: domain.EventManualDeploy,
623+ Data: &domain.ManualDeployPayload{Domain: "app.example.com"},
624+ }
625+
626+ err := handler.Handle(event)
627+
628+ assert.Error(t, err)
629+ assert.Contains(t, err.Error(), "failed to deploy")
630+}
@@ -216,6 +216,9 @@ func TestConfigReloadHandler_Handle_DeploysNewRoutes(t *testing.T) {
216 216
217 handler := NewConfigReloadHandler(testCtx(), containerSvc, configSvc)217 handler := NewConfigReloadHandler(testCtx(), containerSvc, configSvc)
218 218
219+ // SyncContainers is called first
220+ containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
221+
219 // No existing containers222 // No existing containers
220 containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{})223 containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{})
221 224
@@ -245,6 +248,9 @@ func TestConfigReloadHandler_Handle_StopsRemovedRoutes(t *testing.T) {
245 248
246 handler := NewConfigReloadHandler(testCtx(), containerSvc, configSvc)249 handler := NewConfigReloadHandler(testCtx(), containerSvc, configSvc)
247 250
251+ // SyncContainers is called first
252+ containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
253+
248 // Existing container for a route that's no longer configured254 // Existing container for a route that's no longer configured
249 containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{255 containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{
250 "removed.example.com": {256 "removed.example.com": {
@@ -278,6 +284,9 @@ func TestConfigReloadHandler_Handle_RedeploysChangedImage(t *testing.T) {
278 284
279 handler := NewConfigReloadHandler(testCtx(), containerSvc, configSvc)285 handler := NewConfigReloadHandler(testCtx(), containerSvc, configSvc)
280 286
287+ // SyncContainers is called first
288+ containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
289+
281 // Existing container with old image290 // Existing container with old image
282 containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{291 containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{
283 "app.example.com": {292 "app.example.com": {
@@ -315,6 +324,9 @@ func TestConfigReloadHandler_Handle_NoChanges(t *testing.T) {
315 324
316 handler := NewConfigReloadHandler(testCtx(), containerSvc, configSvc)325 handler := NewConfigReloadHandler(testCtx(), containerSvc, configSvc)
317 326
327+ // SyncContainers is called first
328+ containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
329+
318 // Existing container matches config330 // Existing container matches config
319 containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{331 containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{
320 "app.example.com": {332 "app.example.com": {
@@ -361,6 +373,9 @@ func TestManualReloadHandler_Handle_StartsOnlyMissingContainers(t *testing.T) {
361 373
362 handler := NewManualReloadHandler(testCtx(), containerSvc, configSvc)374 handler := NewManualReloadHandler(testCtx(), containerSvc, configSvc)
363 375
376+ // SyncContainers is called first
377+ containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
378+
364 // Routes in config379 // Routes in config
365 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{380 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
366 {Domain: "app1.example.com", Image: "app1:latest"},381 {Domain: "app1.example.com", Image: "app1:latest"},
@@ -393,6 +408,9 @@ func TestManualReloadHandler_Handle_StartsMissingContainer(t *testing.T) {
393 408
394 handler := NewManualReloadHandler(testCtx(), containerSvc, configSvc)409 handler := NewManualReloadHandler(testCtx(), containerSvc, configSvc)
395 410
411+ // SyncContainers is called first
412+ containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
413+
396 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{414 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
397 {Domain: "app.example.com", Image: "app:latest"},415 {Domain: "app.example.com", Image: "app:latest"},
398 })416 })
@@ -421,6 +439,9 @@ func TestManualReloadHandler_Handle_DeployErrors(t *testing.T) {
421 439
422 handler := NewManualReloadHandler(testCtx(), containerSvc, configSvc)440 handler := NewManualReloadHandler(testCtx(), containerSvc, configSvc)
423 441
442+ // SyncContainers is called first
443+ containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
444+
424 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{445 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
425 {Domain: "app1.example.com", Image: "app1:latest"},446 {Domain: "app1.example.com", Image: "app1:latest"},
426 {Domain: "app2.example.com", Image: "app2:latest"},447 {Domain: "app2.example.com", Image: "app2:latest"},
@@ -459,6 +480,9 @@ func TestManualReloadHandler_Handle_DoesNotRestartRunningContainers(t *testing.T
459 480
460 handler := NewManualReloadHandler(testCtx(), containerSvc, configSvc)481 handler := NewManualReloadHandler(testCtx(), containerSvc, configSvc)
461 482
483+ // SyncContainers is called first
484+ containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
485+
462 // Route with running container486 // Route with running container
463 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{487 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
464 {Domain: "app.example.com", Image: "app:latest"},488 {Domain: "app.example.com", Image: "app:latest"},
@@ -477,3 +501,130 @@ func TestManualReloadHandler_Handle_DoesNotRestartRunningContainers(t *testing.T
477 501
478 assert.NoError(t, err)502 assert.NoError(t, err)
479 }503 }
504+
505+// ManualDeployHandler tests
506+
507+func TestManualDeployHandler_CanHandle(t *testing.T) {
508+ containerSvc := inmocks.NewMockContainerService(t)
509+ configSvc := inmocks.NewMockConfigService(t)
510+
511+ handler := NewManualDeployHandler(testCtx(), containerSvc, configSvc)
512+
513+ assert.True(t, handler.CanHandle(domain.EventManualDeploy))
514+ assert.False(t, handler.CanHandle(domain.EventImagePushed))
515+ assert.False(t, handler.CanHandle(domain.EventManualReload))
516+}
517+
518+func TestManualDeployHandler_Handle_DeploysSpecificRoute(t *testing.T) {
519+ containerSvc := inmocks.NewMockContainerService(t)
520+ configSvc := inmocks.NewMockConfigService(t)
521+
522+ handler := NewManualDeployHandler(testCtx(), containerSvc, configSvc)
523+
524+ // Configure routes
525+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
526+ {Domain: "app1.example.com", Image: "app1:latest"},
527+ {Domain: "app2.example.com", Image: "app2:latest"},
528+ })
529+
530+ // Only the requested route should be deployed
531+ containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
532+ Domain: "app1.example.com",
533+ Image: "app1:latest",
534+ }).Return(&domain.Container{ID: "container-1"}, nil)
535+
536+ event := domain.Event{
537+ ID: "event-123",
538+ Type: domain.EventManualDeploy,
539+ Data: &domain.ManualDeployPayload{Domain: "app1.example.com"},
540+ }
541+
542+ err := handler.Handle(event)
543+
544+ assert.NoError(t, err)
545+}
546+
547+func TestManualDeployHandler_Handle_RouteNotFound(t *testing.T) {
548+ containerSvc := inmocks.NewMockContainerService(t)
549+ configSvc := inmocks.NewMockConfigService(t)
550+
551+ handler := NewManualDeployHandler(testCtx(), containerSvc, configSvc)
552+
553+ // No matching routes
554+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
555+ {Domain: "other.example.com", Image: "other:latest"},
556+ })
557+
558+ event := domain.Event{
559+ ID: "event-123",
560+ Type: domain.EventManualDeploy,
561+ Data: &domain.ManualDeployPayload{Domain: "unknown.example.com"},
562+ }
563+
564+ err := handler.Handle(event)
565+
566+ assert.Error(t, err)
567+ assert.Contains(t, err.Error(), "route not found")
568+}
569+
570+func TestManualDeployHandler_Handle_InvalidPayload(t *testing.T) {
571+ containerSvc := inmocks.NewMockContainerService(t)
572+ configSvc := inmocks.NewMockConfigService(t)
573+
574+ handler := NewManualDeployHandler(testCtx(), containerSvc, configSvc)
575+
576+ // Event with nil payload
577+ event := domain.Event{
578+ ID: "event-123",
579+ Type: domain.EventManualDeploy,
580+ Data: nil,
581+ }
582+
583+ err := handler.Handle(event)
584+
585+ assert.Error(t, err)
586+ assert.Contains(t, err.Error(), "invalid manual deploy payload")
587+}
588+
589+func TestManualDeployHandler_Handle_EmptyDomain(t *testing.T) {
590+ containerSvc := inmocks.NewMockContainerService(t)
591+ configSvc := inmocks.NewMockConfigService(t)
592+
593+ handler := NewManualDeployHandler(testCtx(), containerSvc, configSvc)
594+
595+ // Event with empty domain
596+ event := domain.Event{
597+ ID: "event-123",
598+ Type: domain.EventManualDeploy,
599+ Data: &domain.ManualDeployPayload{Domain: ""},
600+ }
601+
602+ err := handler.Handle(event)
603+
604+ assert.Error(t, err)
605+ assert.Contains(t, err.Error(), "invalid manual deploy payload")
606+}
607+
608+func TestManualDeployHandler_Handle_DeployError(t *testing.T) {
609+ containerSvc := inmocks.NewMockContainerService(t)
610+ configSvc := inmocks.NewMockConfigService(t)
611+
612+ handler := NewManualDeployHandler(testCtx(), containerSvc, configSvc)
613+
614+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
615+ {Domain: "app.example.com", Image: "app:latest"},
616+ })
617+
618+ containerSvc.EXPECT().Deploy(mock.Anything, mock.Anything).Return(nil, errors.New("deploy failed"))
619+
620+ event := domain.Event{
621+ ID: "event-123",
622+ Type: domain.EventManualDeploy,
623+ Data: &domain.ManualDeployPayload{Domain: "app.example.com"},
624+ }
625+
626+ err := handler.Handle(event)
627+
628+ assert.Error(t, err)
629+ assert.Contains(t, err.Error(), "failed to deploy")
630+}
M internal/usecase/container/service.go +49 -2
@@ -358,8 +358,48 @@ func (s *Service) SyncContainers(ctx context.Context) error {
358358 return nil
359359 }
360360
361-// AutoStart starts containers for configured routes.
362-func (s *Service) AutoStart(_ context.Context) error {
361+// AutoStart starts containers for the provided routes that aren't running.
362+func (s *Service) AutoStart(ctx context.Context, routes []domain.Route) error {
363+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
364+ zerowrap.FieldLayer: "usecase",
365+ zerowrap.FieldUseCase: "AutoStart",
366+ })
367+ log := zerowrap.FromCtx(ctx)
368+
369+ log.Info().Int("route_count", len(routes)).Msg("auto-starting containers for configured routes")
370+
371+ var started, skipped, errors int
372+ for _, route := range routes {
373+ // Check if container already exists and is running
374+ if _, exists := s.Get(ctx, route.Domain); exists {
375+ log.Debug().Str("domain", route.Domain).Msg("container already running, skipping")
376+ skipped++
377+ continue
378+ }
379+
380+ log.Info().
381+ Str("domain", route.Domain).
382+ Str("image", route.Image).
383+ Msg("auto-starting container for route")
384+
385+ if _, err := s.Deploy(ctx, route); err != nil {
386+ log.Warn().Err(err).Str("domain", route.Domain).Msg("failed to auto-start container")
387+ errors++
388+ continue
389+ }
390+
391+ started++
392+ }
393+
394+ log.Info().
395+ Int("started", started).
396+ Int("skipped", skipped).
397+ Int("errors", errors).
398+ Msg("auto-start completed")
399+
400+ if errors > 0 {
401+ return fmt.Errorf("auto-start completed with %d errors", errors)
402+ }
363403 return nil
364404 }
365405
@@ -524,6 +564,13 @@ func (s *Service) ensureImage(ctx context.Context, imageRef string) (string, err
524564 func (s *Service) ensureLocalImage(ctx context.Context, imageRef, pullRef string) (bool, error) {
525565 log := zerowrap.FromCtx(ctx)
526566
567+ // For internal deploys (image push events), always pull fresh image
568+ // because the same tag may reference new content (e.g., latest tag updated)
569+ if domain.IsInternalDeploy(ctx) {
570+ log.Info().Msg("internal deploy detected, forcing image pull to ensure latest content")
571+ return false, nil
572+ }
573+
527574 localImages, err := s.runtime.ListImages(ctx)
528575 if err != nil {
529576 log.WrapErr(err, "failed to list local images, will attempt pull")
@@ -358,8 +358,48 @@ func (s *Service) SyncContainers(ctx context.Context) error {
358 return nil358 return nil
359 }359 }
360 360
361-// AutoStart starts containers for configured routes.361+// AutoStart starts containers for the provided routes that aren't running.
362-func (s *Service) AutoStart(_ context.Context) error {362+func (s *Service) AutoStart(ctx context.Context, routes []domain.Route) error {
363+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
364+ zerowrap.FieldLayer: "usecase",
365+ zerowrap.FieldUseCase: "AutoStart",
366+ })
367+ log := zerowrap.FromCtx(ctx)
368+
369+ log.Info().Int("route_count", len(routes)).Msg("auto-starting containers for configured routes")
370+
371+ var started, skipped, errors int
372+ for _, route := range routes {
373+ // Check if container already exists and is running
374+ if _, exists := s.Get(ctx, route.Domain); exists {
375+ log.Debug().Str("domain", route.Domain).Msg("container already running, skipping")
376+ skipped++
377+ continue
378+ }
379+
380+ log.Info().
381+ Str("domain", route.Domain).
382+ Str("image", route.Image).
383+ Msg("auto-starting container for route")
384+
385+ if _, err := s.Deploy(ctx, route); err != nil {
386+ log.Warn().Err(err).Str("domain", route.Domain).Msg("failed to auto-start container")
387+ errors++
388+ continue
389+ }
390+
391+ started++
392+ }
393+
394+ log.Info().
395+ Int("started", started).
396+ Int("skipped", skipped).
397+ Int("errors", errors).
398+ Msg("auto-start completed")
399+
400+ if errors > 0 {
401+ return fmt.Errorf("auto-start completed with %d errors", errors)
402+ }
363 return nil403 return nil
364 }404 }
365 405
@@ -524,6 +564,13 @@ func (s *Service) ensureImage(ctx context.Context, imageRef string) (string, err
524 func (s *Service) ensureLocalImage(ctx context.Context, imageRef, pullRef string) (bool, error) {564 func (s *Service) ensureLocalImage(ctx context.Context, imageRef, pullRef string) (bool, error) {
525 log := zerowrap.FromCtx(ctx)565 log := zerowrap.FromCtx(ctx)
526 566
567+ // For internal deploys (image push events), always pull fresh image
568+ // because the same tag may reference new content (e.g., latest tag updated)
569+ if domain.IsInternalDeploy(ctx) {
570+ log.Info().Msg("internal deploy detected, forcing image pull to ensure latest content")
571+ return false, nil
572+ }
573+
527 localImages, err := s.runtime.ListImages(ctx)574 localImages, err := s.runtime.ListImages(ctx)
528 if err != nil {575 if err != nil {
529 log.WrapErr(err, "failed to list local images, will attempt pull")576 log.WrapErr(err, "failed to list local images, will attempt pull")
M internal/usecase/container/service_test.go +184 -0
@@ -711,3 +711,187 @@ func TestRewriteToLocalRegistry(t *testing.T) {
711711 })
712712 }
713713 }
714+
715+func TestService_Deploy_InternalDeployForcesPull(t *testing.T) {
716+ runtime := mocks.NewMockContainerRuntime(t)
717+ envLoader := mocks.NewMockEnvLoader(t)
718+ eventBus := mocks.NewMockEventPublisher(t)
719+
720+ config := Config{
721+ RegistryAuthEnabled: true,
722+ RegistryDomain: "registry.example.com",
723+ RegistryPort: 5000,
724+ InternalRegistryUsername: "internal",
725+ InternalRegistryPassword: "secret",
726+ ReadinessDelay: 0,
727+ }
728+
729+ svc := NewService(runtime, envLoader, eventBus, nil, config)
730+
731+ // Use internal deploy context (simulating image push event)
732+ ctx := domain.WithInternalDeploy(testContext())
733+
734+ route := domain.Route{
735+ Domain: "test.example.com",
736+ Image: "myapp:latest",
737+ }
738+
739+ // Setup mocks - no orphaned containers
740+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
741+
742+ // Even though image exists locally, internal deploy should force pull
743+ // Note: ensureLocalImage returns false for internal deploy, skipping the ListImages check
744+ runtime.EXPECT().PullImageWithAuth(mock.Anything, "localhost:5000/myapp:latest", "internal", "secret").Return(nil)
745+
746+ // Tag image to canonical name
747+ runtime.EXPECT().TagImage(mock.Anything, "localhost:5000/myapp:latest", "registry.example.com/myapp:latest").Return(nil)
748+ runtime.EXPECT().UntagImage(mock.Anything, "localhost:5000/myapp:latest").Return(nil)
749+
750+ // Get exposed ports
751+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, "registry.example.com/myapp:latest").Return([]int{8080}, nil)
752+
753+ // Load environment
754+ envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
755+ runtime.EXPECT().InspectImageEnv(mock.Anything, "registry.example.com/myapp:latest").Return([]string{}, nil)
756+
757+ // Create and start container
758+ createdContainer := &domain.Container{
759+ ID: "container-123",
760+ Name: "gordon-test.example.com",
761+ Status: "created",
762+ }
763+ runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(createdContainer, nil)
764+ runtime.EXPECT().StartContainer(mock.Anything, "container-123").Return(nil)
765+
766+ // Wait for ready
767+ runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(true, nil).Times(2)
768+
769+ // Re-inspect after start
770+ runningContainer := &domain.Container{
771+ ID: "container-123",
772+ Name: "gordon-test.example.com",
773+ Status: "running",
774+ Ports: []int{8080},
775+ }
776+ runtime.EXPECT().InspectContainer(mock.Anything, "container-123").Return(runningContainer, nil)
777+
778+ // Publish event
779+ eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
780+
781+ result, err := svc.Deploy(ctx, route)
782+
783+ assert.NoError(t, err)
784+ assert.NotNil(t, result)
785+ assert.Equal(t, "container-123", result.ID)
786+}
787+
788+func TestService_AutoStart_StartsNewContainers(t *testing.T) {
789+ runtime := mocks.NewMockContainerRuntime(t)
790+ envLoader := mocks.NewMockEnvLoader(t)
791+ eventBus := mocks.NewMockEventPublisher(t)
792+
793+ config := Config{
794+ ReadinessDelay: 0,
795+ }
796+ svc := NewService(runtime, envLoader, eventBus, nil, config)
797+ ctx := testContext()
798+
799+ routes := []domain.Route{
800+ {Domain: "app1.example.com", Image: "myapp1:latest"},
801+ {Domain: "app2.example.com", Image: "myapp2:latest"},
802+ }
803+
804+ // Setup mocks for first route deployment
805+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Times(2)
806+ runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp1:latest", "myapp2:latest"}, nil).Times(2)
807+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, mock.AnythingOfType("string")).Return([]int{8080}, nil).Times(2)
808+ envLoader.EXPECT().LoadEnv(mock.Anything, mock.AnythingOfType("string")).Return([]string{}, nil).Times(2)
809+ runtime.EXPECT().InspectImageEnv(mock.Anything, mock.AnythingOfType("string")).Return([]string{}, nil).Times(2)
810+
811+ // Create and start containers
812+ runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{
813+ ID: "container-1", Name: "gordon-app1.example.com", Status: "created",
814+ }, nil).Once()
815+ runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{
816+ ID: "container-2", Name: "gordon-app2.example.com", Status: "created",
817+ }, nil).Once()
818+ runtime.EXPECT().StartContainer(mock.Anything, mock.AnythingOfType("string")).Return(nil).Times(2)
819+ runtime.EXPECT().IsContainerRunning(mock.Anything, mock.AnythingOfType("string")).Return(true, nil).Times(4)
820+ runtime.EXPECT().InspectContainer(mock.Anything, mock.AnythingOfType("string")).Return(&domain.Container{
821+ Status: "running",
822+ }, nil).Times(2)
823+ eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil).Times(2)
824+
825+ err := svc.AutoStart(ctx, routes)
826+
827+ assert.NoError(t, err)
828+ assert.Len(t, svc.containers, 2)
829+}
830+
831+func TestService_AutoStart_SkipsExistingContainers(t *testing.T) {
832+ runtime := mocks.NewMockContainerRuntime(t)
833+ envLoader := mocks.NewMockEnvLoader(t)
834+ eventBus := mocks.NewMockEventPublisher(t)
835+
836+ config := Config{
837+ ReadinessDelay: 0,
838+ }
839+ svc := NewService(runtime, envLoader, eventBus, nil, config)
840+ ctx := testContext()
841+
842+ // Pre-populate with existing container
843+ svc.containers["app1.example.com"] = &domain.Container{
844+ ID: "existing-container",
845+ }
846+
847+ routes := []domain.Route{
848+ {Domain: "app1.example.com", Image: "myapp1:latest"}, // Already exists
849+ {Domain: "app2.example.com", Image: "myapp2:latest"}, // New route
850+ }
851+
852+ // Only deploy for app2 (app1 is skipped)
853+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Once()
854+ runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp2:latest"}, nil).Once()
855+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp2:latest").Return([]int{8080}, nil).Once()
856+ envLoader.EXPECT().LoadEnv(mock.Anything, "app2.example.com").Return([]string{}, nil).Once()
857+ runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp2:latest").Return([]string{}, nil).Once()
858+ runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{
859+ ID: "container-2", Status: "created",
860+ }, nil).Once()
861+ runtime.EXPECT().StartContainer(mock.Anything, "container-2").Return(nil).Once()
862+ runtime.EXPECT().IsContainerRunning(mock.Anything, "container-2").Return(true, nil).Times(2)
863+ runtime.EXPECT().InspectContainer(mock.Anything, "container-2").Return(&domain.Container{
864+ Status: "running",
865+ }, nil).Once()
866+ eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil).Once()
867+
868+ err := svc.AutoStart(ctx, routes)
869+
870+ assert.NoError(t, err)
871+ assert.Len(t, svc.containers, 2)
872+}
873+
874+func TestService_AutoStart_HandlesDeployErrors(t *testing.T) {
875+ runtime := mocks.NewMockContainerRuntime(t)
876+ envLoader := mocks.NewMockEnvLoader(t)
877+ eventBus := mocks.NewMockEventPublisher(t)
878+
879+ config := Config{}
880+ svc := NewService(runtime, envLoader, eventBus, nil, config)
881+ ctx := testContext()
882+
883+ routes := []domain.Route{
884+ {Domain: "app1.example.com", Image: "myapp1:latest"},
885+ }
886+
887+ // Setup mocks for failure
888+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
889+ runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
890+ runtime.EXPECT().PullImage(mock.Anything, "myapp1:latest").Return(errors.New("image not found"))
891+
892+ err := svc.AutoStart(ctx, routes)
893+
894+ // AutoStart should return error when some deployments fail
895+ assert.Error(t, err)
896+ assert.Contains(t, err.Error(), "auto-start completed with 1 errors")
897+}
@@ -711,3 +711,187 @@ func TestRewriteToLocalRegistry(t *testing.T) {
711 })711 })
712 }712 }
713 }713 }
714+
715+func TestService_Deploy_InternalDeployForcesPull(t *testing.T) {
716+ runtime := mocks.NewMockContainerRuntime(t)
717+ envLoader := mocks.NewMockEnvLoader(t)
718+ eventBus := mocks.NewMockEventPublisher(t)
719+
720+ config := Config{
721+ RegistryAuthEnabled: true,
722+ RegistryDomain: "registry.example.com",
723+ RegistryPort: 5000,
724+ InternalRegistryUsername: "internal",
725+ InternalRegistryPassword: "secret",
726+ ReadinessDelay: 0,
727+ }
728+
729+ svc := NewService(runtime, envLoader, eventBus, nil, config)
730+
731+ // Use internal deploy context (simulating image push event)
732+ ctx := domain.WithInternalDeploy(testContext())
733+
734+ route := domain.Route{
735+ Domain: "test.example.com",
736+ Image: "myapp:latest",
737+ }
738+
739+ // Setup mocks - no orphaned containers
740+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
741+
742+ // Even though image exists locally, internal deploy should force pull
743+ // Note: ensureLocalImage returns false for internal deploy, skipping the ListImages check
744+ runtime.EXPECT().PullImageWithAuth(mock.Anything, "localhost:5000/myapp:latest", "internal", "secret").Return(nil)
745+
746+ // Tag image to canonical name
747+ runtime.EXPECT().TagImage(mock.Anything, "localhost:5000/myapp:latest", "registry.example.com/myapp:latest").Return(nil)
748+ runtime.EXPECT().UntagImage(mock.Anything, "localhost:5000/myapp:latest").Return(nil)
749+
750+ // Get exposed ports
751+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, "registry.example.com/myapp:latest").Return([]int{8080}, nil)
752+
753+ // Load environment
754+ envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
755+ runtime.EXPECT().InspectImageEnv(mock.Anything, "registry.example.com/myapp:latest").Return([]string{}, nil)
756+
757+ // Create and start container
758+ createdContainer := &domain.Container{
759+ ID: "container-123",
760+ Name: "gordon-test.example.com",
761+ Status: "created",
762+ }
763+ runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(createdContainer, nil)
764+ runtime.EXPECT().StartContainer(mock.Anything, "container-123").Return(nil)
765+
766+ // Wait for ready
767+ runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(true, nil).Times(2)
768+
769+ // Re-inspect after start
770+ runningContainer := &domain.Container{
771+ ID: "container-123",
772+ Name: "gordon-test.example.com",
773+ Status: "running",
774+ Ports: []int{8080},
775+ }
776+ runtime.EXPECT().InspectContainer(mock.Anything, "container-123").Return(runningContainer, nil)
777+
778+ // Publish event
779+ eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
780+
781+ result, err := svc.Deploy(ctx, route)
782+
783+ assert.NoError(t, err)
784+ assert.NotNil(t, result)
785+ assert.Equal(t, "container-123", result.ID)
786+}
787+
788+func TestService_AutoStart_StartsNewContainers(t *testing.T) {
789+ runtime := mocks.NewMockContainerRuntime(t)
790+ envLoader := mocks.NewMockEnvLoader(t)
791+ eventBus := mocks.NewMockEventPublisher(t)
792+
793+ config := Config{
794+ ReadinessDelay: 0,
795+ }
796+ svc := NewService(runtime, envLoader, eventBus, nil, config)
797+ ctx := testContext()
798+
799+ routes := []domain.Route{
800+ {Domain: "app1.example.com", Image: "myapp1:latest"},
801+ {Domain: "app2.example.com", Image: "myapp2:latest"},
802+ }
803+
804+ // Setup mocks for first route deployment
805+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Times(2)
806+ runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp1:latest", "myapp2:latest"}, nil).Times(2)
807+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, mock.AnythingOfType("string")).Return([]int{8080}, nil).Times(2)
808+ envLoader.EXPECT().LoadEnv(mock.Anything, mock.AnythingOfType("string")).Return([]string{}, nil).Times(2)
809+ runtime.EXPECT().InspectImageEnv(mock.Anything, mock.AnythingOfType("string")).Return([]string{}, nil).Times(2)
810+
811+ // Create and start containers
812+ runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{
813+ ID: "container-1", Name: "gordon-app1.example.com", Status: "created",
814+ }, nil).Once()
815+ runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{
816+ ID: "container-2", Name: "gordon-app2.example.com", Status: "created",
817+ }, nil).Once()
818+ runtime.EXPECT().StartContainer(mock.Anything, mock.AnythingOfType("string")).Return(nil).Times(2)
819+ runtime.EXPECT().IsContainerRunning(mock.Anything, mock.AnythingOfType("string")).Return(true, nil).Times(4)
820+ runtime.EXPECT().InspectContainer(mock.Anything, mock.AnythingOfType("string")).Return(&domain.Container{
821+ Status: "running",
822+ }, nil).Times(2)
823+ eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil).Times(2)
824+
825+ err := svc.AutoStart(ctx, routes)
826+
827+ assert.NoError(t, err)
828+ assert.Len(t, svc.containers, 2)
829+}
830+
831+func TestService_AutoStart_SkipsExistingContainers(t *testing.T) {
832+ runtime := mocks.NewMockContainerRuntime(t)
833+ envLoader := mocks.NewMockEnvLoader(t)
834+ eventBus := mocks.NewMockEventPublisher(t)
835+
836+ config := Config{
837+ ReadinessDelay: 0,
838+ }
839+ svc := NewService(runtime, envLoader, eventBus, nil, config)
840+ ctx := testContext()
841+
842+ // Pre-populate with existing container
843+ svc.containers["app1.example.com"] = &domain.Container{
844+ ID: "existing-container",
845+ }
846+
847+ routes := []domain.Route{
848+ {Domain: "app1.example.com", Image: "myapp1:latest"}, // Already exists
849+ {Domain: "app2.example.com", Image: "myapp2:latest"}, // New route
850+ }
851+
852+ // Only deploy for app2 (app1 is skipped)
853+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Once()
854+ runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp2:latest"}, nil).Once()
855+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp2:latest").Return([]int{8080}, nil).Once()
856+ envLoader.EXPECT().LoadEnv(mock.Anything, "app2.example.com").Return([]string{}, nil).Once()
857+ runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp2:latest").Return([]string{}, nil).Once()
858+ runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{
859+ ID: "container-2", Status: "created",
860+ }, nil).Once()
861+ runtime.EXPECT().StartContainer(mock.Anything, "container-2").Return(nil).Once()
862+ runtime.EXPECT().IsContainerRunning(mock.Anything, "container-2").Return(true, nil).Times(2)
863+ runtime.EXPECT().InspectContainer(mock.Anything, "container-2").Return(&domain.Container{
864+ Status: "running",
865+ }, nil).Once()
866+ eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil).Once()
867+
868+ err := svc.AutoStart(ctx, routes)
869+
870+ assert.NoError(t, err)
871+ assert.Len(t, svc.containers, 2)
872+}
873+
874+func TestService_AutoStart_HandlesDeployErrors(t *testing.T) {
875+ runtime := mocks.NewMockContainerRuntime(t)
876+ envLoader := mocks.NewMockEnvLoader(t)
877+ eventBus := mocks.NewMockEventPublisher(t)
878+
879+ config := Config{}
880+ svc := NewService(runtime, envLoader, eventBus, nil, config)
881+ ctx := testContext()
882+
883+ routes := []domain.Route{
884+ {Domain: "app1.example.com", Image: "myapp1:latest"},
885+ }
886+
887+ // Setup mocks for failure
888+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
889+ runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
890+ runtime.EXPECT().PullImage(mock.Anything, "myapp1:latest").Return(errors.New("image not found"))
891+
892+ err := svc.AutoStart(ctx, routes)
893+
894+ // AutoStart should return error when some deployments fail
895+ assert.Error(t, err)
896+ assert.Contains(t, err.Error(), "auto-start completed with 1 errors")
897+}
M internal/usecase/proxy/service.go +31 -0
@@ -127,6 +127,37 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (*domain.Pro
127127 }
128128 s.mu.RUnlock()
129129
130+ // Check if this is an external route
131+ externalRoutes := s.configSvc.GetExternalRoutes()
132+ if targetAddr, ok := externalRoutes[domainName]; ok {
133+ host, portStr, err := net.SplitHostPort(targetAddr)
134+ if err != nil {
135+ return nil, log.WrapErrWithFields(err, "invalid external route target", map[string]any{"target": targetAddr})
136+ }
137+ port, err := strconv.Atoi(portStr)
138+ if err != nil {
139+ return nil, log.WrapErrWithFields(err, "invalid port in external route", map[string]any{"target": targetAddr})
140+ }
141+
142+ target := &domain.ProxyTarget{
143+ Host: host,
144+ Port: port,
145+ ContainerID: "", // Not a container
146+ Scheme: "http",
147+ }
148+
149+ // Cache external route target
150+ s.mu.Lock()
151+ s.targets[domainName] = target
152+ s.mu.Unlock()
153+
154+ log.Debug().
155+ Str("host", host).
156+ Int("port", port).
157+ Msg("using external route target")
158+ return target, nil
159+ }
160+
130161 // Get container for this domain
131162 container, exists := s.containerSvc.Get(ctx, domainName)
132163 if !exists {
@@ -127,6 +127,37 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (*domain.Pro
127 }127 }
128 s.mu.RUnlock()128 s.mu.RUnlock()
129 129
130+ // Check if this is an external route
131+ externalRoutes := s.configSvc.GetExternalRoutes()
132+ if targetAddr, ok := externalRoutes[domainName]; ok {
133+ host, portStr, err := net.SplitHostPort(targetAddr)
134+ if err != nil {
135+ return nil, log.WrapErrWithFields(err, "invalid external route target", map[string]any{"target": targetAddr})
136+ }
137+ port, err := strconv.Atoi(portStr)
138+ if err != nil {
139+ return nil, log.WrapErrWithFields(err, "invalid port in external route", map[string]any{"target": targetAddr})
140+ }
141+
142+ target := &domain.ProxyTarget{
143+ Host: host,
144+ Port: port,
145+ ContainerID: "", // Not a container
146+ Scheme: "http",
147+ }
148+
149+ // Cache external route target
150+ s.mu.Lock()
151+ s.targets[domainName] = target
152+ s.mu.Unlock()
153+
154+ log.Debug().
155+ Str("host", host).
156+ Int("port", port).
157+ Msg("using external route target")
158+ return target, nil
159+ }
160+
130 // Get container for this domain161 // Get container for this domain
131 container, exists := s.containerSvc.Get(ctx, domainName)162 container, exists := s.containerSvc.Get(ctx, domainName)
132 if !exists {163 if !exists {
M internal/usecase/proxy/service_test.go +85 -0
@@ -55,6 +55,8 @@ func TestService_GetTarget_ContainerNotFound(t *testing.T) {
5555 svc := NewService(runtime, containerSvc, configSvc, config)
5656 ctx := testContext()
5757
58+ // Mock external routes (empty - no match)
59+ configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{})
5860 containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(nil, false)
5961
6062 result, err := svc.GetTarget(ctx, "app.example.com")
@@ -63,6 +65,89 @@ func TestService_GetTarget_ContainerNotFound(t *testing.T) {
6365 assert.Nil(t, result)
6466 }
6567
68+func TestService_GetTarget_ExternalRoute(t *testing.T) {
69+ runtime := outmocks.NewMockContainerRuntime(t)
70+ containerSvc := inmocks.NewMockContainerService(t)
71+ configSvc := inmocks.NewMockConfigService(t)
72+
73+ svc := NewService(runtime, containerSvc, configSvc, Config{})
74+ ctx := testContext()
75+
76+ // Mock external routes
77+ configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{
78+ "reg.example.com": "localhost:5000",
79+ })
80+
81+ result, err := svc.GetTarget(ctx, "reg.example.com")
82+
83+ assert.NoError(t, err)
84+ assert.Equal(t, "localhost", result.Host)
85+ assert.Equal(t, 5000, result.Port)
86+ assert.Equal(t, "", result.ContainerID)
87+ assert.Equal(t, "http", result.Scheme)
88+}
89+
90+func TestService_GetTarget_ExternalRoute_Cached(t *testing.T) {
91+ runtime := outmocks.NewMockContainerRuntime(t)
92+ containerSvc := inmocks.NewMockContainerService(t)
93+ configSvc := inmocks.NewMockConfigService(t)
94+
95+ svc := NewService(runtime, containerSvc, configSvc, Config{})
96+ ctx := testContext()
97+
98+ // First call - should resolve external route
99+ configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{
100+ "reg.example.com": "localhost:5000",
101+ }).Once()
102+
103+ result1, err := svc.GetTarget(ctx, "reg.example.com")
104+ assert.NoError(t, err)
105+ assert.Equal(t, "localhost", result1.Host)
106+
107+ // Second call - should return from cache (no mock call needed)
108+ result2, err := svc.GetTarget(ctx, "reg.example.com")
109+ assert.NoError(t, err)
110+ assert.Equal(t, result1, result2)
111+}
112+
113+func TestService_GetTarget_ExternalRoute_InvalidTarget(t *testing.T) {
114+ runtime := outmocks.NewMockContainerRuntime(t)
115+ containerSvc := inmocks.NewMockContainerService(t)
116+ configSvc := inmocks.NewMockConfigService(t)
117+
118+ svc := NewService(runtime, containerSvc, configSvc, Config{})
119+ ctx := testContext()
120+
121+ // Mock external routes with invalid format (missing port)
122+ configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{
123+ "invalid.example.com": "not-valid-format",
124+ })
125+
126+ result, err := svc.GetTarget(ctx, "invalid.example.com")
127+
128+ assert.Error(t, err)
129+ assert.Nil(t, result)
130+}
131+
132+func TestService_GetTarget_ExternalRoute_InvalidPort(t *testing.T) {
133+ runtime := outmocks.NewMockContainerRuntime(t)
134+ containerSvc := inmocks.NewMockContainerService(t)
135+ configSvc := inmocks.NewMockConfigService(t)
136+
137+ svc := NewService(runtime, containerSvc, configSvc, Config{})
138+ ctx := testContext()
139+
140+ // Mock external routes with invalid port
141+ configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{
142+ "invalid.example.com": "localhost:abc",
143+ })
144+
145+ result, err := svc.GetTarget(ctx, "invalid.example.com")
146+
147+ assert.Error(t, err)
148+ assert.Nil(t, result)
149+}
150+
66151 func TestService_RegisterTarget(t *testing.T) {
67152 runtime := outmocks.NewMockContainerRuntime(t)
68153 containerSvc := inmocks.NewMockContainerService(t)
@@ -55,6 +55,8 @@ func TestService_GetTarget_ContainerNotFound(t *testing.T) {
55 svc := NewService(runtime, containerSvc, configSvc, config)55 svc := NewService(runtime, containerSvc, configSvc, config)
56 ctx := testContext()56 ctx := testContext()
57 57
58+ // Mock external routes (empty - no match)
59+ configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{})
58 containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(nil, false)60 containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(nil, false)
59 61
60 result, err := svc.GetTarget(ctx, "app.example.com")62 result, err := svc.GetTarget(ctx, "app.example.com")
@@ -63,6 +65,89 @@ func TestService_GetTarget_ContainerNotFound(t *testing.T) {
63 assert.Nil(t, result)65 assert.Nil(t, result)
64 }66 }
65 67
68+func TestService_GetTarget_ExternalRoute(t *testing.T) {
69+ runtime := outmocks.NewMockContainerRuntime(t)
70+ containerSvc := inmocks.NewMockContainerService(t)
71+ configSvc := inmocks.NewMockConfigService(t)
72+
73+ svc := NewService(runtime, containerSvc, configSvc, Config{})
74+ ctx := testContext()
75+
76+ // Mock external routes
77+ configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{
78+ "reg.example.com": "localhost:5000",
79+ })
80+
81+ result, err := svc.GetTarget(ctx, "reg.example.com")
82+
83+ assert.NoError(t, err)
84+ assert.Equal(t, "localhost", result.Host)
85+ assert.Equal(t, 5000, result.Port)
86+ assert.Equal(t, "", result.ContainerID)
87+ assert.Equal(t, "http", result.Scheme)
88+}
89+
90+func TestService_GetTarget_ExternalRoute_Cached(t *testing.T) {
91+ runtime := outmocks.NewMockContainerRuntime(t)
92+ containerSvc := inmocks.NewMockContainerService(t)
93+ configSvc := inmocks.NewMockConfigService(t)
94+
95+ svc := NewService(runtime, containerSvc, configSvc, Config{})
96+ ctx := testContext()
97+
98+ // First call - should resolve external route
99+ configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{
100+ "reg.example.com": "localhost:5000",
101+ }).Once()
102+
103+ result1, err := svc.GetTarget(ctx, "reg.example.com")
104+ assert.NoError(t, err)
105+ assert.Equal(t, "localhost", result1.Host)
106+
107+ // Second call - should return from cache (no mock call needed)
108+ result2, err := svc.GetTarget(ctx, "reg.example.com")
109+ assert.NoError(t, err)
110+ assert.Equal(t, result1, result2)
111+}
112+
113+func TestService_GetTarget_ExternalRoute_InvalidTarget(t *testing.T) {
114+ runtime := outmocks.NewMockContainerRuntime(t)
115+ containerSvc := inmocks.NewMockContainerService(t)
116+ configSvc := inmocks.NewMockConfigService(t)
117+
118+ svc := NewService(runtime, containerSvc, configSvc, Config{})
119+ ctx := testContext()
120+
121+ // Mock external routes with invalid format (missing port)
122+ configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{
123+ "invalid.example.com": "not-valid-format",
124+ })
125+
126+ result, err := svc.GetTarget(ctx, "invalid.example.com")
127+
128+ assert.Error(t, err)
129+ assert.Nil(t, result)
130+}
131+
132+func TestService_GetTarget_ExternalRoute_InvalidPort(t *testing.T) {
133+ runtime := outmocks.NewMockContainerRuntime(t)
134+ containerSvc := inmocks.NewMockContainerService(t)
135+ configSvc := inmocks.NewMockConfigService(t)
136+
137+ svc := NewService(runtime, containerSvc, configSvc, Config{})
138+ ctx := testContext()
139+
140+ // Mock external routes with invalid port
141+ configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{
142+ "invalid.example.com": "localhost:abc",
143+ })
144+
145+ result, err := svc.GetTarget(ctx, "invalid.example.com")
146+
147+ assert.Error(t, err)
148+ assert.Nil(t, result)
149+}
150+
66 func TestService_RegisterTarget(t *testing.T) {151 func TestService_RegisterTarget(t *testing.T) {
67 runtime := outmocks.NewMockContainerRuntime(t)152 runtime := outmocks.NewMockContainerRuntime(t)
68 containerSvc := inmocks.NewMockContainerService(t)153 containerSvc := inmocks.NewMockContainerService(t)

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