detached e86854e

Merge pull request #119 from bnema/fix/proxy-port-label-ignored

fix: respect gordon.port label during port selection (#118)
commit
e86854e44cea3834a190acf874f625abef95b921
author
bnema · · 2026-03-15 08:47 UTC
committer
GitHub · · 2026-03-15 08:47 UTC
merge of
c52b906 + bae2d02 · diff against the first parent
signature
unsigned
13 files · +557 -65
M docs/config/auto-route.md +5 -5
@@ -175,7 +175,7 @@ The preferred method for automatic routing uses Dockerfile labels. This gives yo
175175 | Label | Description | Example |
176176 |-------|-------------|---------|
177177 | `gordon.domains` | Comma-separated list of domains | `app.example.com,www.app.example.com` |
178-| `gordon.port` | Container port to proxy | `3000` |
178+| `gordon.proxy.port` | Container port to proxy | `3000` |
179179 | `gordon.health` | Health check endpoint path | `/health` |
180180 | `gordon.env-file` | Path to .env file in image | `/app/.env.example` |
181181
@@ -189,7 +189,7 @@ RUN npm install
189189
190190 # Gordon routing labels
191191 LABEL gordon.domains="myapp.example.com,www.myapp.example.com"
192-LABEL gordon.port="3000"
192+LABEL gordon.proxy.port="3000"
193193 LABEL gordon.health="/api/health"
194194 LABEL gordon.env-file="/app/.env.example"
195195
@@ -203,7 +203,7 @@ When you push an image with Gordon labels:
203203
204204 1. Gordon reads the `gordon.domains` label
205205 2. Creates routes for each domain automatically
206-3. Uses `gordon.port` for the proxy target (or first EXPOSE port)
206+3. Uses `gordon.proxy.port` for the proxy target, otherwise auto-detects an exposed port preferring common HTTP ports
207207 4. Configures health checks if `gordon.health` is set
208208 5. Extracts environment variables from `gordon.env-file` if specified
209209
@@ -241,7 +241,7 @@ Gordon will:
241241
242242 ### Best Practices
243243
244-1. **Always set `gordon.port`** - Don't rely on EXPOSE detection
244+1. **Always set `gordon.proxy.port`** - Don't rely on EXPOSE detection
245245 2. **Use `gordon.health`** - Enables reliable deployment verification
246246 3. **Include `.env.example`** - Self-documenting environment requirements
247247 4. **Separate domains with commas** - No spaces around commas
@@ -261,7 +261,7 @@ COPY .env.example .
261261
262262 # Gordon labels
263263 LABEL gordon.domains="api.mycompany.com"
264-LABEL gordon.port="8080"
264+LABEL gordon.proxy.port="8080"
265265 LABEL gordon.health="/health"
266266 LABEL gordon.env-file="/app/.env.example"
267267
@@ -175,7 +175,7 @@ The preferred method for automatic routing uses Dockerfile labels. This gives yo
175 | Label | Description | Example |175 | Label | Description | Example |
176 |-------|-------------|---------|176 |-------|-------------|---------|
177 | `gordon.domains` | Comma-separated list of domains | `app.example.com,www.app.example.com` |177 | `gordon.domains` | Comma-separated list of domains | `app.example.com,www.app.example.com` |
178-| `gordon.port` | Container port to proxy | `3000` |178+| `gordon.proxy.port` | Container port to proxy | `3000` |
179 | `gordon.health` | Health check endpoint path | `/health` |179 | `gordon.health` | Health check endpoint path | `/health` |
180 | `gordon.env-file` | Path to .env file in image | `/app/.env.example` |180 | `gordon.env-file` | Path to .env file in image | `/app/.env.example` |
181 181
@@ -189,7 +189,7 @@ RUN npm install
189 189
190 # Gordon routing labels190 # Gordon routing labels
191 LABEL gordon.domains="myapp.example.com,www.myapp.example.com"191 LABEL gordon.domains="myapp.example.com,www.myapp.example.com"
192-LABEL gordon.port="3000"192+LABEL gordon.proxy.port="3000"
193 LABEL gordon.health="/api/health"193 LABEL gordon.health="/api/health"
194 LABEL gordon.env-file="/app/.env.example"194 LABEL gordon.env-file="/app/.env.example"
195 195
@@ -203,7 +203,7 @@ When you push an image with Gordon labels:
203 203
204 1. Gordon reads the `gordon.domains` label204 1. Gordon reads the `gordon.domains` label
205 2. Creates routes for each domain automatically205 2. Creates routes for each domain automatically
206-3. Uses `gordon.port` for the proxy target (or first EXPOSE port)206+3. Uses `gordon.proxy.port` for the proxy target, otherwise auto-detects an exposed port preferring common HTTP ports
207 4. Configures health checks if `gordon.health` is set207 4. Configures health checks if `gordon.health` is set
208 5. Extracts environment variables from `gordon.env-file` if specified208 5. Extracts environment variables from `gordon.env-file` if specified
209 209
@@ -241,7 +241,7 @@ Gordon will:
241 241
242 ### Best Practices242 ### Best Practices
243 243
244-1. **Always set `gordon.port`** - Don't rely on EXPOSE detection244+1. **Always set `gordon.proxy.port`** - Don't rely on EXPOSE detection
245 2. **Use `gordon.health`** - Enables reliable deployment verification245 2. **Use `gordon.health`** - Enables reliable deployment verification
246 3. **Include `.env.example`** - Self-documenting environment requirements246 3. **Include `.env.example`** - Self-documenting environment requirements
247 4. **Separate domains with commas** - No spaces around commas247 4. **Separate domains with commas** - No spaces around commas
@@ -261,7 +261,7 @@ COPY .env.example .
261 261
262 # Gordon labels262 # Gordon labels
263 LABEL gordon.domains="api.mycompany.com"263 LABEL gordon.domains="api.mycompany.com"
264-LABEL gordon.port="8080"264+LABEL gordon.proxy.port="8080"
265 LABEL gordon.health="/health"265 LABEL gordon.health="/health"
266 LABEL gordon.env-file="/app/.env.example"266 LABEL gordon.env-file="/app/.env.example"
267 267
M docs/reference/docker-labels.md +13 -2
@@ -14,6 +14,17 @@ Gordon adds these labels to managed containers:
1414 | `gordon.route` | Domain name | Route this container handles |
1515 | `gordon.created` | Timestamp | When Gordon created the container |
1616
17+### Propagated Image Labels
18+
19+When an image defines these labels, Gordon copies them onto the container for runtime use (readiness probing, proxy routing):
20+
21+| Label | Example | Description |
22+|-------|---------|-------------|
23+| `gordon.proxy.port` | `"3000"` | Port the proxy routes HTTP traffic to |
24+| `gordon.health` | `"/healthz"` | Health check endpoint for readiness probing |
25+
26+These labels only appear on containers whose source image defined them.
27+
1728 ### Attachment Labels
1829
1930 Additional labels for attachment containers:
@@ -42,8 +53,7 @@ Labels you can set in your Dockerfile:
4253 | Label | Example | Description |
4354 |-------|---------|-------------|
4455 | `gordon.domains` | `"app.example.com,www.app.example.com"` | Comma-separated domains for auto-route |
45-| `gordon.port` | `"3000"` | Port to proxy HTTP traffic to |
46-| `gordon.proxy.port` | `"3000"` | Port to proxy HTTP traffic to (legacy alias for `gordon.port`) |
56+| `gordon.proxy.port` | `"3000"` | Port to proxy HTTP traffic to |
4757 | `gordon.health` | `"/healthz"` | HTTP health check endpoint path for readiness probing |
4858 | `gordon.env-file` | `"/app/.env.example"` | Path to env template file inside the image |
4959
@@ -108,6 +118,7 @@ Example output:
108118 "gordon.domain": "app.mydomain.com",
109119 "gordon.image": "myapp:latest",
110120 "gordon.managed": "true",
121+ "gordon.proxy.port": "3000",
111122 "gordon.route": "app.mydomain.com"
112123 }
113124 ```
@@ -14,6 +14,17 @@ Gordon adds these labels to managed containers:
14 | `gordon.route` | Domain name | Route this container handles |14 | `gordon.route` | Domain name | Route this container handles |
15 | `gordon.created` | Timestamp | When Gordon created the container |15 | `gordon.created` | Timestamp | When Gordon created the container |
16 16
17+### Propagated Image Labels
18+
19+When an image defines these labels, Gordon copies them onto the container for runtime use (readiness probing, proxy routing):
20+
21+| Label | Example | Description |
22+|-------|---------|-------------|
23+| `gordon.proxy.port` | `"3000"` | Port the proxy routes HTTP traffic to |
24+| `gordon.health` | `"/healthz"` | Health check endpoint for readiness probing |
25+
26+These labels only appear on containers whose source image defined them.
27+
17 ### Attachment Labels28 ### Attachment Labels
18 29
19 Additional labels for attachment containers:30 Additional labels for attachment containers:
@@ -42,8 +53,7 @@ Labels you can set in your Dockerfile:
42 | Label | Example | Description |53 | Label | Example | Description |
43 |-------|---------|-------------|54 |-------|---------|-------------|
44 | `gordon.domains` | `"app.example.com,www.app.example.com"` | Comma-separated domains for auto-route |55 | `gordon.domains` | `"app.example.com,www.app.example.com"` | Comma-separated domains for auto-route |
45-| `gordon.port` | `"3000"` | Port to proxy HTTP traffic to |56+| `gordon.proxy.port` | `"3000"` | Port to proxy HTTP traffic to |
46-| `gordon.proxy.port` | `"3000"` | Port to proxy HTTP traffic to (legacy alias for `gordon.port`) |
47 | `gordon.health` | `"/healthz"` | HTTP health check endpoint path for readiness probing |57 | `gordon.health` | `"/healthz"` | HTTP health check endpoint path for readiness probing |
48 | `gordon.env-file` | `"/app/.env.example"` | Path to env template file inside the image |58 | `gordon.env-file` | `"/app/.env.example"` | Path to env template file inside the image |
49 59
@@ -108,6 +118,7 @@ Example output:
108 "gordon.domain": "app.mydomain.com",118 "gordon.domain": "app.mydomain.com",
109 "gordon.image": "myapp:latest",119 "gordon.image": "myapp:latest",
110 "gordon.managed": "true",120 "gordon.managed": "true",
121+ "gordon.proxy.port": "3000",
111 "gordon.route": "app.mydomain.com"122 "gordon.route": "app.mydomain.com"
112 }123 }
113 ```124 ```
M internal/adapters/in/cli/push_test.go +11 -11
@@ -357,20 +357,20 @@ func TestParseDockerfileLabels(t *testing.T) {
357357 {
358358 name: "multiple labels",
359359 content: `LABEL gordon.domain="myapp.example.com"
360-LABEL gordon.port="8080"
360+LABEL gordon.proxy.port="8080"
361361 LABEL gordon.health="/health"`,
362362 expected: map[string]string{
363- "gordon.domain": "myapp.example.com",
364- "gordon.port": "8080",
365- "gordon.health": "/health",
363+ "gordon.domain": "myapp.example.com",
364+ "gordon.proxy.port": "8080",
365+ "gordon.health": "/health",
366366 },
367367 },
368368 {
369369 name: "multi-label on one line",
370- content: `LABEL gordon.domain="myapp.example.com" gordon.port="8080"`,
370+ content: `LABEL gordon.domain="myapp.example.com" gordon.proxy.port="8080"`,
371371 expected: map[string]string{
372- "gordon.domain": "myapp.example.com",
373- "gordon.port": "8080",
372+ "gordon.domain": "myapp.example.com",
373+ "gordon.proxy.port": "8080",
374374 },
375375 },
376376 {
@@ -471,13 +471,13 @@ func TestSplitLabelPairs(t *testing.T) {
471471 },
472472 {
473473 name: "two pairs",
474- content: `gordon.domain="test.com" gordon.port="8080"`,
475- expected: []string{`gordon.domain="test.com"`, `gordon.port="8080"`},
474+ content: `gordon.domain="test.com" gordon.proxy.port="8080"`,
475+ expected: []string{`gordon.domain="test.com"`, `gordon.proxy.port="8080"`},
476476 },
477477 {
478478 name: "unquoted",
479- content: `gordon.domain=test.com gordon.port=8080`,
480- expected: []string{`gordon.domain=test.com`, `gordon.port=8080`},
479+ content: `gordon.domain=test.com gordon.proxy.port=8080`,
480+ expected: []string{`gordon.domain=test.com`, `gordon.proxy.port=8080`},
481481 },
482482 {
483483 name: "quoted with spaces in value",
@@ -357,20 +357,20 @@ func TestParseDockerfileLabels(t *testing.T) {
357 {357 {
358 name: "multiple labels",358 name: "multiple labels",
359 content: `LABEL gordon.domain="myapp.example.com"359 content: `LABEL gordon.domain="myapp.example.com"
360-LABEL gordon.port="8080"360+LABEL gordon.proxy.port="8080"
361 LABEL gordon.health="/health"`,361 LABEL gordon.health="/health"`,
362 expected: map[string]string{362 expected: map[string]string{
363- "gordon.domain": "myapp.example.com",363+ "gordon.domain": "myapp.example.com",
364- "gordon.port": "8080",364+ "gordon.proxy.port": "8080",
365- "gordon.health": "/health",365+ "gordon.health": "/health",
366 },366 },
367 },367 },
368 {368 {
369 name: "multi-label on one line",369 name: "multi-label on one line",
370- content: `LABEL gordon.domain="myapp.example.com" gordon.port="8080"`,370+ content: `LABEL gordon.domain="myapp.example.com" gordon.proxy.port="8080"`,
371 expected: map[string]string{371 expected: map[string]string{
372- "gordon.domain": "myapp.example.com",372+ "gordon.domain": "myapp.example.com",
373- "gordon.port": "8080",373+ "gordon.proxy.port": "8080",
374 },374 },
375 },375 },
376 {376 {
@@ -471,13 +471,13 @@ func TestSplitLabelPairs(t *testing.T) {
471 },471 },
472 {472 {
473 name: "two pairs",473 name: "two pairs",
474- content: `gordon.domain="test.com" gordon.port="8080"`,474+ content: `gordon.domain="test.com" gordon.proxy.port="8080"`,
475- expected: []string{`gordon.domain="test.com"`, `gordon.port="8080"`},475+ expected: []string{`gordon.domain="test.com"`, `gordon.proxy.port="8080"`},
476 },476 },
477 {477 {
478 name: "unquoted",478 name: "unquoted",
479- content: `gordon.domain=test.com gordon.port=8080`,479+ content: `gordon.domain=test.com gordon.proxy.port=8080`,
480- expected: []string{`gordon.domain=test.com`, `gordon.port=8080`},480+ expected: []string{`gordon.domain=test.com`, `gordon.proxy.port=8080`},
481 },481 },
482 {482 {
483 name: "quoted with spaces in value",483 name: "quoted with spaces in value",
M internal/adapters/out/docker/runtime.go +2 -1
@@ -890,7 +890,8 @@ func (r *Runtime) GetContainerNetworkInfo(ctx context.Context, containerID strin
890890 return "", 0, fmt.Errorf("no EXPOSE directives found for container %s - please add EXPOSE directive to Dockerfile", containerID)
891891 }
892892
893- // Use the first exposed port
893+ // Sort ports with HTTP-friendly ports first, pushing SSH (22) to the end
894+ sortPortsHTTPFirst(exposedPorts)
894895 selectedPort := exposedPorts[0]
895896
896897 log.Info().
@@ -890,7 +890,8 @@ func (r *Runtime) GetContainerNetworkInfo(ctx context.Context, containerID strin
890 return "", 0, fmt.Errorf("no EXPOSE directives found for container %s - please add EXPOSE directive to Dockerfile", containerID)890 return "", 0, fmt.Errorf("no EXPOSE directives found for container %s - please add EXPOSE directive to Dockerfile", containerID)
891 }891 }
892 892
893- // Use the first exposed port893+ // Sort ports with HTTP-friendly ports first, pushing SSH (22) to the end
894+ sortPortsHTTPFirst(exposedPorts)
894 selectedPort := exposedPorts[0]895 selectedPort := exposedPorts[0]
895 896
896 log.Info().897 log.Info().
A internal/adapters/out/docker/runtime_port_sort_test.go +65 -0
@@ -0,0 +1,65 @@
1+package docker
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+)
8+
9+func Test_sortPortsHTTPFirst(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ input []int
13+ expected []int
14+ }{
15+ {
16+ name: "single port unchanged",
17+ input: []int{8080},
18+ expected: []int{8080},
19+ },
20+ {
21+ name: "SSH port pushed to end",
22+ input: []int{22, 3000},
23+ expected: []int{3000, 22},
24+ },
25+ {
26+ name: "HTTP ports sorted by priority",
27+ input: []int{3000, 80, 8080},
28+ expected: []int{80, 8080, 3000},
29+ },
30+ {
31+ name: "SSH pushed behind non-priority ports",
32+ input: []int{22, 4000},
33+ expected: []int{4000, 22},
34+ },
35+ {
36+ name: "mixed priority and non-priority ports",
37+ input: []int{22, 3000, 4000, 80},
38+ expected: []int{80, 3000, 4000, 22},
39+ },
40+ {
41+ name: "all non-priority ports sorted ascending",
42+ input: []int{9090, 7000, 6000},
43+ expected: []int{6000, 7000, 9090},
44+ },
45+ {
46+ name: "empty slice is no-op",
47+ input: []int{},
48+ expected: []int{},
49+ },
50+ {
51+ name: "gitea-style multi-port image",
52+ input: []int{22, 3000},
53+ expected: []int{3000, 22},
54+ },
55+ }
56+
57+ for _, tt := range tests {
58+ t.Run(tt.name, func(t *testing.T) {
59+ ports := make([]int, len(tt.input))
60+ copy(ports, tt.input)
61+ sortPortsHTTPFirst(ports)
62+ assert.Equal(t, tt.expected, ports)
63+ })
64+ }
65+}
@@ -0,0 +1,65 @@
1+package docker
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+)
8+
9+func Test_sortPortsHTTPFirst(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ input []int
13+ expected []int
14+ }{
15+ {
16+ name: "single port unchanged",
17+ input: []int{8080},
18+ expected: []int{8080},
19+ },
20+ {
21+ name: "SSH port pushed to end",
22+ input: []int{22, 3000},
23+ expected: []int{3000, 22},
24+ },
25+ {
26+ name: "HTTP ports sorted by priority",
27+ input: []int{3000, 80, 8080},
28+ expected: []int{80, 8080, 3000},
29+ },
30+ {
31+ name: "SSH pushed behind non-priority ports",
32+ input: []int{22, 4000},
33+ expected: []int{4000, 22},
34+ },
35+ {
36+ name: "mixed priority and non-priority ports",
37+ input: []int{22, 3000, 4000, 80},
38+ expected: []int{80, 3000, 4000, 22},
39+ },
40+ {
41+ name: "all non-priority ports sorted ascending",
42+ input: []int{9090, 7000, 6000},
43+ expected: []int{6000, 7000, 9090},
44+ },
45+ {
46+ name: "empty slice is no-op",
47+ input: []int{},
48+ expected: []int{},
49+ },
50+ {
51+ name: "gitea-style multi-port image",
52+ input: []int{22, 3000},
53+ expected: []int{3000, 22},
54+ },
55+ }
56+
57+ for _, tt := range tests {
58+ t.Run(tt.name, func(t *testing.T) {
59+ ports := make([]int, len(tt.input))
60+ copy(ports, tt.input)
61+ sortPortsHTTPFirst(ports)
62+ assert.Equal(t, tt.expected, ports)
63+ })
64+ }
65+}
M internal/domain/labels.go +3 -2
@@ -15,7 +15,7 @@ const (
1515 // exposing secret values.
1616 LabelEnvHash = "gordon.env-hash"
1717
18- // Image labels (set in Dockerfile)
18+ // LabelProxyPort specifies the container port to proxy HTTP traffic to.
1919 LabelProxyPort = "gordon.proxy.port"
2020
2121 // Auto-route image labels (for automatic route discovery)
@@ -23,7 +23,8 @@ const (
2323 LabelDomains = "gordon.domains"
2424 // LabelHealth specifies the health check endpoint path.
2525 LabelHealth = "gordon.health"
26- // LabelPort specifies the container port to proxy.
26+ // LabelPort is a deprecated alias for LabelProxyPort.
27+ // Kept for backward compatibility; prefer LabelProxyPort for new images.
2728 LabelPort = "gordon.port"
2829 // LabelEnvFile specifies the path to .env file inside the image.
2930 LabelEnvFile = "gordon.env-file"
@@ -15,7 +15,7 @@ const (
15 // exposing secret values.15 // exposing secret values.
16 LabelEnvHash = "gordon.env-hash"16 LabelEnvHash = "gordon.env-hash"
17 17
18- // Image labels (set in Dockerfile)18+ // LabelProxyPort specifies the container port to proxy HTTP traffic to.
19 LabelProxyPort = "gordon.proxy.port"19 LabelProxyPort = "gordon.proxy.port"
20 20
21 // Auto-route image labels (for automatic route discovery)21 // Auto-route image labels (for automatic route discovery)
@@ -23,7 +23,8 @@ const (
23 LabelDomains = "gordon.domains"23 LabelDomains = "gordon.domains"
24 // LabelHealth specifies the health check endpoint path.24 // LabelHealth specifies the health check endpoint path.
25 LabelHealth = "gordon.health"25 LabelHealth = "gordon.health"
26- // LabelPort specifies the container port to proxy.26+ // LabelPort is a deprecated alias for LabelProxyPort.
27+ // Kept for backward compatibility; prefer LabelProxyPort for new images.
27 LabelPort = "gordon.port"28 LabelPort = "gordon.port"
28 // LabelEnvFile specifies the path to .env file inside the image.29 // LabelEnvFile specifies the path to .env file inside the image.
29 LabelEnvFile = "gordon.env-file"30 LabelEnvFile = "gordon.env-file"
M internal/usecase/container/autoroute.go +5 -2
@@ -414,8 +414,11 @@ func parseImageLabels(configData []byte) (*domain.ImageLabels, error) {
414414 labels.Health = strings.TrimSpace(v)
415415 }
416416
417- if v, ok := config.Config.Labels[domain.LabelPort]; ok {
418- labels.Port = strings.TrimSpace(v)
417+ for _, key := range []string{domain.LabelProxyPort, domain.LabelPort} {
418+ if v, ok := config.Config.Labels[key]; ok {
419+ labels.Port = strings.TrimSpace(v)
420+ break
421+ }
419422 }
420423
421424 if v, ok := config.Config.Labels[domain.LabelEnvFile]; ok {
@@ -414,8 +414,11 @@ func parseImageLabels(configData []byte) (*domain.ImageLabels, error) {
414 labels.Health = strings.TrimSpace(v)414 labels.Health = strings.TrimSpace(v)
415 }415 }
416 416
417- if v, ok := config.Config.Labels[domain.LabelPort]; ok {417+ for _, key := range []string{domain.LabelProxyPort, domain.LabelPort} {
418- labels.Port = strings.TrimSpace(v)418+ if v, ok := config.Config.Labels[key]; ok {
419+ labels.Port = strings.TrimSpace(v)
420+ break
421+ }
419 }422 }
420 423
421 if v, ok := config.Config.Labels[domain.LabelEnvFile]; ok {424 if v, ok := config.Config.Labels[domain.LabelEnvFile]; ok {
M internal/usecase/container/autoroute_test.go +4 -4
@@ -263,10 +263,10 @@ func TestParseImageLabels(t *testing.T) {
263263 config: map[string]any{
264264 "config": map[string]any{
265265 "Labels": map[string]any{
266- "gordon.domain": "app.example.com",
267- "gordon.health": "/healthz",
268- "gordon.port": "8080",
269- "gordon.env-file": ".env.prod",
266+ "gordon.domain": "app.example.com",
267+ "gordon.health": "/healthz",
268+ "gordon.proxy.port": "8080",
269+ "gordon.env-file": ".env.prod",
270270 },
271271 },
272272 },
@@ -263,10 +263,10 @@ func TestParseImageLabels(t *testing.T) {
263 config: map[string]any{263 config: map[string]any{
264 "config": map[string]any{264 "config": map[string]any{
265 "Labels": map[string]any{265 "Labels": map[string]any{
266- "gordon.domain": "app.example.com",266+ "gordon.domain": "app.example.com",
267- "gordon.health": "/healthz",267+ "gordon.health": "/healthz",
268- "gordon.port": "8080",268+ "gordon.proxy.port": "8080",
269- "gordon.env-file": ".env.prod",269+ "gordon.env-file": ".env.prod",
270 },270 },
271 },271 },
272 },272 },
M internal/usecase/container/service.go +70 -28
@@ -210,25 +210,58 @@ func (s *Service) deploymentContainerName(containerDomain string, existing *doma
210210 }
211211 }
212212
213-func (s *Service) buildContainerConfig(containerDomain, image, actualImageRef string, exposedPorts []int, envVars []string, envHash string, volumes map[string]string, networkName string, existing *domain.Container) *domain.ContainerConfig {
214- containerName := s.deploymentContainerName(containerDomain, existing)
213+// containerConfigInput groups the parameters for building a container configuration.
214+type containerConfigInput struct {
215+ Domain string
216+ Image string
217+ ImageRef string
218+ ExposedPorts []int
219+ EnvVars []string
220+ EnvHash string
221+ Volumes map[string]string
222+ NetworkName string
223+ ImageLabels map[string]string
224+ Existing *domain.Container
225+}
226+
227+func (s *Service) buildContainerConfig(in containerConfigInput) *domain.ContainerConfig {
228+ containerName := s.deploymentContainerName(in.Domain, in.Existing)
229+ labels := map[string]string{
230+ domain.LabelDomain: in.Domain,
231+ domain.LabelEnvHash: in.EnvHash,
232+ domain.LabelImage: in.Image,
233+ domain.LabelManaged: "true",
234+ domain.LabelRoute: in.Domain,
235+ }
236+
237+ // Propagate proxy/port labels from image so readiness probes can find them
238+ for _, key := range []string{domain.LabelProxyPort, domain.LabelPort, domain.LabelHealth} {
239+ if v, ok := in.ImageLabels[key]; ok && v != "" {
240+ labels[key] = v
241+ }
242+ }
243+
244+ // Ensure the label-specified proxy port is included in exposed ports
245+ // so Docker creates a host port binding for it.
246+ ports := in.ExposedPorts
247+ if portStr, ok := labels[domain.LabelProxyPort]; ok {
248+ if port, err := strconv.Atoi(portStr); err == nil && port > 0 && port <= 65535 {
249+ if !slices.Contains(ports, port) {
250+ ports = append(ports, port)
251+ }
252+ }
253+ }
215254
216255 return &domain.ContainerConfig{
217- Image: actualImageRef,
256+ Image: in.ImageRef,
218257 Name: containerName,
219- Ports: exposedPorts,
220- Env: envVars,
221- Volumes: volumes,
222- NetworkMode: networkName,
223- Hostname: containerDomain,
224- Labels: map[string]string{
225- domain.LabelDomain: containerDomain,
226- domain.LabelEnvHash: envHash,
227- domain.LabelImage: image,
228- domain.LabelManaged: "true",
229- domain.LabelRoute: containerDomain,
230- },
231- AutoRemove: false,
258+ Ports: ports,
259+ Env: in.EnvVars,
260+ Volumes: in.Volumes,
261+ NetworkMode: in.NetworkName,
262+ Hostname: in.Domain,
263+ Labels: labels,
264+ AutoRemove: false,
232265 }
233266 }
234267
@@ -416,6 +449,7 @@ type deployResources struct {
416449 networkName string
417450 actualImageRef string
418451 exposedPorts []int
452+ imageLabels map[string]string
419453 envVars []string
420454 envHash string
421455 volumes map[string]string
@@ -516,6 +550,12 @@ func (s *Service) prepareDeployResources(ctx context.Context, route domain.Route
516550 exposedPorts = []int{80, 8080, 3000}
517551 }
518552
553+ imageLabels, err := s.runtime.GetImageLabels(ctx, actualImageRef)
554+ if err != nil {
555+ log.Warn().Err(err).Msg("failed to get image labels, skipping label propagation")
556+ imageLabels = nil
557+ }
558+
519559 envVars, err := s.loadEnvironment(ctx, route.Domain, actualImageRef)
520560 if err != nil {
521561 return nil, err
@@ -531,6 +571,7 @@ func (s *Service) prepareDeployResources(ctx context.Context, route domain.Route
531571 networkName: networkName,
532572 actualImageRef: actualImageRef,
533573 exposedPorts: exposedPorts,
574+ imageLabels: imageLabels,
534575 envVars: envVars,
535576 envHash: envHash,
536577 volumes: volumes,
@@ -543,17 +584,18 @@ func (s *Service) createStartedContainer(ctx context.Context, route domain.Route
543584
544585 log := zerowrap.FromCtx(ctx)
545586
546- containerConfig := s.buildContainerConfig(
547- route.Domain,
548- route.Image,
549- resources.actualImageRef,
550- resources.exposedPorts,
551- resources.envVars,
552- resources.envHash,
553- resources.volumes,
554- resources.networkName,
555- existing,
556- )
587+ containerConfig := s.buildContainerConfig(containerConfigInput{
588+ Domain: route.Domain,
589+ Image: route.Image,
590+ ImageRef: resources.actualImageRef,
591+ ExposedPorts: resources.exposedPorts,
592+ EnvVars: resources.envVars,
593+ EnvHash: resources.envHash,
594+ Volumes: resources.volumes,
595+ NetworkName: resources.networkName,
596+ ImageLabels: resources.imageLabels,
597+ Existing: existing,
598+ })
557599
558600 newContainer, err := s.runtime.CreateContainer(ctx, containerConfig)
559601 if err != nil {
@@ -2527,7 +2569,7 @@ func (s *Service) resolveProbeEndpoint(ctx context.Context, containerID string,
25272569 for _, key := range []string{domain.LabelProxyPort, domain.LabelPort} {
25282570 if portStr, ok := labels[key]; ok && portStr != "" {
25292571 port, err := strconv.Atoi(portStr)
2530- if err == nil && port > 0 {
2572+ if err == nil && port > 0 && port <= 65535 {
25312573 internalPort = port
25322574 break
25332575 }
@@ -210,25 +210,58 @@ func (s *Service) deploymentContainerName(containerDomain string, existing *doma
210 }210 }
211 }211 }
212 212
213-func (s *Service) buildContainerConfig(containerDomain, image, actualImageRef string, exposedPorts []int, envVars []string, envHash string, volumes map[string]string, networkName string, existing *domain.Container) *domain.ContainerConfig {213+// containerConfigInput groups the parameters for building a container configuration.
214- containerName := s.deploymentContainerName(containerDomain, existing)214+type containerConfigInput struct {
215+ Domain string
216+ Image string
217+ ImageRef string
218+ ExposedPorts []int
219+ EnvVars []string
220+ EnvHash string
221+ Volumes map[string]string
222+ NetworkName string
223+ ImageLabels map[string]string
224+ Existing *domain.Container
225+}
226+
227+func (s *Service) buildContainerConfig(in containerConfigInput) *domain.ContainerConfig {
228+ containerName := s.deploymentContainerName(in.Domain, in.Existing)
229+ labels := map[string]string{
230+ domain.LabelDomain: in.Domain,
231+ domain.LabelEnvHash: in.EnvHash,
232+ domain.LabelImage: in.Image,
233+ domain.LabelManaged: "true",
234+ domain.LabelRoute: in.Domain,
235+ }
236+
237+ // Propagate proxy/port labels from image so readiness probes can find them
238+ for _, key := range []string{domain.LabelProxyPort, domain.LabelPort, domain.LabelHealth} {
239+ if v, ok := in.ImageLabels[key]; ok && v != "" {
240+ labels[key] = v
241+ }
242+ }
243+
244+ // Ensure the label-specified proxy port is included in exposed ports
245+ // so Docker creates a host port binding for it.
246+ ports := in.ExposedPorts
247+ if portStr, ok := labels[domain.LabelProxyPort]; ok {
248+ if port, err := strconv.Atoi(portStr); err == nil && port > 0 && port <= 65535 {
249+ if !slices.Contains(ports, port) {
250+ ports = append(ports, port)
251+ }
252+ }
253+ }
215 254
216 return &domain.ContainerConfig{255 return &domain.ContainerConfig{
217- Image: actualImageRef,256+ Image: in.ImageRef,
218 Name: containerName,257 Name: containerName,
219- Ports: exposedPorts,258+ Ports: ports,
220- Env: envVars,259+ Env: in.EnvVars,
221- Volumes: volumes,260+ Volumes: in.Volumes,
222- NetworkMode: networkName,261+ NetworkMode: in.NetworkName,
223- Hostname: containerDomain,262+ Hostname: in.Domain,
224- Labels: map[string]string{263+ Labels: labels,
225- domain.LabelDomain: containerDomain,264+ AutoRemove: false,
226- domain.LabelEnvHash: envHash,
227- domain.LabelImage: image,
228- domain.LabelManaged: "true",
229- domain.LabelRoute: containerDomain,
230- },
231- AutoRemove: false,
232 }265 }
233 }266 }
234 267
@@ -416,6 +449,7 @@ type deployResources struct {
416 networkName string449 networkName string
417 actualImageRef string450 actualImageRef string
418 exposedPorts []int451 exposedPorts []int
452+ imageLabels map[string]string
419 envVars []string453 envVars []string
420 envHash string454 envHash string
421 volumes map[string]string455 volumes map[string]string
@@ -516,6 +550,12 @@ func (s *Service) prepareDeployResources(ctx context.Context, route domain.Route
516 exposedPorts = []int{80, 8080, 3000}550 exposedPorts = []int{80, 8080, 3000}
517 }551 }
518 552
553+ imageLabels, err := s.runtime.GetImageLabels(ctx, actualImageRef)
554+ if err != nil {
555+ log.Warn().Err(err).Msg("failed to get image labels, skipping label propagation")
556+ imageLabels = nil
557+ }
558+
519 envVars, err := s.loadEnvironment(ctx, route.Domain, actualImageRef)559 envVars, err := s.loadEnvironment(ctx, route.Domain, actualImageRef)
520 if err != nil {560 if err != nil {
521 return nil, err561 return nil, err
@@ -531,6 +571,7 @@ func (s *Service) prepareDeployResources(ctx context.Context, route domain.Route
531 networkName: networkName,571 networkName: networkName,
532 actualImageRef: actualImageRef,572 actualImageRef: actualImageRef,
533 exposedPorts: exposedPorts,573 exposedPorts: exposedPorts,
574+ imageLabels: imageLabels,
534 envVars: envVars,575 envVars: envVars,
535 envHash: envHash,576 envHash: envHash,
536 volumes: volumes,577 volumes: volumes,
@@ -543,17 +584,18 @@ func (s *Service) createStartedContainer(ctx context.Context, route domain.Route
543 584
544 log := zerowrap.FromCtx(ctx)585 log := zerowrap.FromCtx(ctx)
545 586
546- containerConfig := s.buildContainerConfig(587+ containerConfig := s.buildContainerConfig(containerConfigInput{
547- route.Domain,588+ Domain: route.Domain,
548- route.Image,589+ Image: route.Image,
549- resources.actualImageRef,590+ ImageRef: resources.actualImageRef,
550- resources.exposedPorts,591+ ExposedPorts: resources.exposedPorts,
551- resources.envVars,592+ EnvVars: resources.envVars,
552- resources.envHash,593+ EnvHash: resources.envHash,
553- resources.volumes,594+ Volumes: resources.volumes,
554- resources.networkName,595+ NetworkName: resources.networkName,
555- existing,596+ ImageLabels: resources.imageLabels,
556- )597+ Existing: existing,
598+ })
557 599
558 newContainer, err := s.runtime.CreateContainer(ctx, containerConfig)600 newContainer, err := s.runtime.CreateContainer(ctx, containerConfig)
559 if err != nil {601 if err != nil {
@@ -2527,7 +2569,7 @@ func (s *Service) resolveProbeEndpoint(ctx context.Context, containerID string,
2527 for _, key := range []string{domain.LabelProxyPort, domain.LabelPort} {2569 for _, key := range []string{domain.LabelProxyPort, domain.LabelPort} {
2528 if portStr, ok := labels[key]; ok && portStr != "" {2570 if portStr, ok := labels[key]; ok && portStr != "" {
2529 port, err := strconv.Atoi(portStr)2571 port, err := strconv.Atoi(portStr)
2530- if err == nil && port > 0 {2572+ if err == nil && port > 0 && port <= 65535 {
2531 internalPort = port2573 internalPort = port
2532 break2574 break
2533 }2575 }
M internal/usecase/container/service_readiness_test.go +96 -0
@@ -216,3 +216,99 @@ func TestService_WaitForHealthy_EmptyStatusIsReportedAsStarting(t *testing.T) {
216216 assert.Error(t, err)
217217 assert.True(t, strings.Contains(err.Error(), "last status: starting"))
218218 }
219+
220+func TestService_WaitForReady_ProbeUsesGordonProxyPortLabel(t *testing.T) {
221+ runtime := mocks.NewMockContainerRuntime(t)
222+ svc := NewService(runtime, nil, nil, nil, Config{
223+ ReadinessMode: "auto",
224+ HTTPProbeTimeout: 50 * time.Millisecond,
225+ })
226+
227+ ctx := testContext()
228+ containerID := "container-port-label"
229+ labelPort := 3000
230+
231+ probeIP, probePort := reserveEphemeralAddr(t)
232+ _ = probeIP
233+
234+ // Container config has gordon.proxy.port label — should be used for probe endpoint
235+ containerConfig := &domain.ContainerConfig{
236+ Ports: []int{22, 3000},
237+ Labels: map[string]string{
238+ domain.LabelProxyPort: "3000",
239+ },
240+ }
241+
242+ runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once()
243+ runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once()
244+ // Should use label port (3000), NOT call GetContainerNetworkInfo
245+ runtime.EXPECT().GetContainerPort(mock.Anything, containerID, labelPort).Return(probePort, nil).Once()
246+
247+ err := svc.waitForReady(ctx, containerID, containerConfig)
248+ // HTTP probe will timeout (no server), but the important thing is it used port 3000
249+ assert.Error(t, err)
250+ assert.Contains(t, err.Error(), "HTTP alive probe timeout")
251+}
252+
253+func TestService_WaitForReady_ProbeUsesDeprecatedPortLabel(t *testing.T) {
254+ runtime := mocks.NewMockContainerRuntime(t)
255+ svc := NewService(runtime, nil, nil, nil, Config{
256+ ReadinessMode: "auto",
257+ HTTPProbeTimeout: 50 * time.Millisecond,
258+ })
259+
260+ ctx := testContext()
261+ containerID := "container-proxy-port"
262+ labelPort := 3000
263+
264+ probeIP, probePort := reserveEphemeralAddr(t)
265+ _ = probeIP // probeIP not used directly but port is
266+
267+ // Container config has deprecated gordon.port — should still work
268+ containerConfig := &domain.ContainerConfig{
269+ Ports: []int{22, 3000},
270+ Labels: map[string]string{
271+ domain.LabelPort: "3000",
272+ },
273+ }
274+
275+ runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once()
276+ runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once()
277+ runtime.EXPECT().GetContainerPort(mock.Anything, containerID, labelPort).Return(probePort, nil).Once()
278+
279+ err := svc.waitForReady(ctx, containerID, containerConfig)
280+ assert.Error(t, err)
281+ assert.Contains(t, err.Error(), "HTTP alive probe timeout")
282+}
283+
284+func TestService_WaitForReady_ProxyPortTakesPriorityOverPort(t *testing.T) {
285+ runtime := mocks.NewMockContainerRuntime(t)
286+ svc := NewService(runtime, nil, nil, nil, Config{
287+ ReadinessMode: "auto",
288+ HTTPProbeTimeout: 50 * time.Millisecond,
289+ })
290+
291+ ctx := testContext()
292+ containerID := "container-priority"
293+
294+ probeIP, probePort := reserveEphemeralAddr(t)
295+ _ = probeIP
296+
297+ // Both labels set — gordon.proxy.port (8080) should win over gordon.port (3000)
298+ containerConfig := &domain.ContainerConfig{
299+ Ports: []int{22, 3000, 8080},
300+ Labels: map[string]string{
301+ domain.LabelProxyPort: "8080",
302+ domain.LabelPort: "3000",
303+ },
304+ }
305+
306+ runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once()
307+ runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once()
308+ // Should use gordon.proxy.port=8080, NOT gordon.port=3000
309+ runtime.EXPECT().GetContainerPort(mock.Anything, containerID, 8080).Return(probePort, nil).Once()
310+
311+ err := svc.waitForReady(ctx, containerID, containerConfig)
312+ assert.Error(t, err)
313+ assert.Contains(t, err.Error(), "HTTP alive probe timeout")
314+}
@@ -216,3 +216,99 @@ func TestService_WaitForHealthy_EmptyStatusIsReportedAsStarting(t *testing.T) {
216 assert.Error(t, err)216 assert.Error(t, err)
217 assert.True(t, strings.Contains(err.Error(), "last status: starting"))217 assert.True(t, strings.Contains(err.Error(), "last status: starting"))
218 }218 }
219+
220+func TestService_WaitForReady_ProbeUsesGordonProxyPortLabel(t *testing.T) {
221+ runtime := mocks.NewMockContainerRuntime(t)
222+ svc := NewService(runtime, nil, nil, nil, Config{
223+ ReadinessMode: "auto",
224+ HTTPProbeTimeout: 50 * time.Millisecond,
225+ })
226+
227+ ctx := testContext()
228+ containerID := "container-port-label"
229+ labelPort := 3000
230+
231+ probeIP, probePort := reserveEphemeralAddr(t)
232+ _ = probeIP
233+
234+ // Container config has gordon.proxy.port label — should be used for probe endpoint
235+ containerConfig := &domain.ContainerConfig{
236+ Ports: []int{22, 3000},
237+ Labels: map[string]string{
238+ domain.LabelProxyPort: "3000",
239+ },
240+ }
241+
242+ runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once()
243+ runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once()
244+ // Should use label port (3000), NOT call GetContainerNetworkInfo
245+ runtime.EXPECT().GetContainerPort(mock.Anything, containerID, labelPort).Return(probePort, nil).Once()
246+
247+ err := svc.waitForReady(ctx, containerID, containerConfig)
248+ // HTTP probe will timeout (no server), but the important thing is it used port 3000
249+ assert.Error(t, err)
250+ assert.Contains(t, err.Error(), "HTTP alive probe timeout")
251+}
252+
253+func TestService_WaitForReady_ProbeUsesDeprecatedPortLabel(t *testing.T) {
254+ runtime := mocks.NewMockContainerRuntime(t)
255+ svc := NewService(runtime, nil, nil, nil, Config{
256+ ReadinessMode: "auto",
257+ HTTPProbeTimeout: 50 * time.Millisecond,
258+ })
259+
260+ ctx := testContext()
261+ containerID := "container-proxy-port"
262+ labelPort := 3000
263+
264+ probeIP, probePort := reserveEphemeralAddr(t)
265+ _ = probeIP // probeIP not used directly but port is
266+
267+ // Container config has deprecated gordon.port — should still work
268+ containerConfig := &domain.ContainerConfig{
269+ Ports: []int{22, 3000},
270+ Labels: map[string]string{
271+ domain.LabelPort: "3000",
272+ },
273+ }
274+
275+ runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once()
276+ runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once()
277+ runtime.EXPECT().GetContainerPort(mock.Anything, containerID, labelPort).Return(probePort, nil).Once()
278+
279+ err := svc.waitForReady(ctx, containerID, containerConfig)
280+ assert.Error(t, err)
281+ assert.Contains(t, err.Error(), "HTTP alive probe timeout")
282+}
283+
284+func TestService_WaitForReady_ProxyPortTakesPriorityOverPort(t *testing.T) {
285+ runtime := mocks.NewMockContainerRuntime(t)
286+ svc := NewService(runtime, nil, nil, nil, Config{
287+ ReadinessMode: "auto",
288+ HTTPProbeTimeout: 50 * time.Millisecond,
289+ })
290+
291+ ctx := testContext()
292+ containerID := "container-priority"
293+
294+ probeIP, probePort := reserveEphemeralAddr(t)
295+ _ = probeIP
296+
297+ // Both labels set — gordon.proxy.port (8080) should win over gordon.port (3000)
298+ containerConfig := &domain.ContainerConfig{
299+ Ports: []int{22, 3000, 8080},
300+ Labels: map[string]string{
301+ domain.LabelProxyPort: "8080",
302+ domain.LabelPort: "3000",
303+ },
304+ }
305+
306+ runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once()
307+ runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once()
308+ // Should use gordon.proxy.port=8080, NOT gordon.port=3000
309+ runtime.EXPECT().GetContainerPort(mock.Anything, containerID, 8080).Return(probePort, nil).Once()
310+
311+ err := svc.waitForReady(ctx, containerID, containerConfig)
312+ assert.Error(t, err)
313+ assert.Contains(t, err.Error(), "HTTP alive probe timeout")
314+}
M internal/usecase/container/service_test.go +125 -0
@@ -222,6 +222,7 @@ func TestService_Deploy_Success(t *testing.T) {
222222
223223 // Get exposed ports
224224 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
225+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil)
225226
226227 // Load environment
227228 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{"FOO=bar"}, nil)
@@ -298,6 +299,7 @@ func TestService_Deploy_ReadinessRecoveryWindow_AllowsTransientFlap(t *testing.T
298299 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
299300 runtime.EXPECT().PullImage(mock.Anything, "myapp:latest").Return(nil)
300301 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
302+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil)
301303 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
302304 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
303305
@@ -457,6 +459,7 @@ func TestService_Deploy_ReplacesExistingContainer(t *testing.T) {
457459 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
458460 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
459461 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
462+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil)
460463
461464 // Environment
462465 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
@@ -551,6 +554,7 @@ func TestService_Deploy_ResolvesExistingFromRuntime_WhenMemoryStale(t *testing.T
551554 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
552555 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
553556 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
557+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil)
554558
555559 // Environment
556560 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
@@ -630,6 +634,7 @@ func TestService_Deploy_SkipRedundantDeploy_GetImageIDError(t *testing.T) {
630634 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
631635 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
632636 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
637+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil)
633638 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
634639 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
635640
@@ -684,6 +689,7 @@ func TestService_Deploy_WithNetworkIsolation(t *testing.T) {
684689 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
685690 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
686691 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
692+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil)
687693 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
688694 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
689695
@@ -740,6 +746,7 @@ func TestService_Deploy_WithVolumeAutoCreate(t *testing.T) {
740746 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
741747 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
742748 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
749+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil)
743750 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
744751 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
745752
@@ -1471,6 +1478,7 @@ func TestService_Deploy_InternalDeployForcesPull(t *testing.T) {
14711478
14721479 // Get exposed ports
14731480 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "registry.example.com/myapp:latest").Return([]int{8080}, nil)
1481+ runtime.EXPECT().GetImageLabels(mock.Anything, "registry.example.com/myapp:latest").Return(nil, nil)
14741482
14751483 // Load environment
14761484 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
@@ -1532,6 +1540,7 @@ func TestService_AutoStart_StartsNewContainers(t *testing.T) {
15321540 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Times(2)
15331541 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp1:latest", "myapp2:latest"}, nil).Times(2)
15341542 runtime.EXPECT().GetImageExposedPorts(mock.Anything, mock.AnythingOfType("string")).Return([]int{8080}, nil).Times(2)
1543+ runtime.EXPECT().GetImageLabels(mock.Anything, mock.AnythingOfType("string")).Return(nil, nil).Times(2)
15351544 envLoader.EXPECT().LoadEnv(mock.Anything, mock.AnythingOfType("string")).Return([]string{}, nil).Times(2)
15361545 runtime.EXPECT().InspectImageEnv(mock.Anything, mock.AnythingOfType("string")).Return([]string{}, nil).Times(2)
15371546
@@ -1584,6 +1593,7 @@ func TestService_AutoStart_SkipsExistingContainers(t *testing.T) {
15841593 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Once()
15851594 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp2:latest"}, nil).Once()
15861595 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp2:latest").Return([]int{8080}, nil).Once()
1596+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp2:latest").Return(nil, nil).Once()
15871597 envLoader.EXPECT().LoadEnv(mock.Anything, "app2.example.com").Return([]string{}, nil).Once()
15881598 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp2:latest").Return([]string{}, nil).Once()
15891599 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{
@@ -1661,6 +1671,7 @@ func TestService_AutoStart_UsesInternalDeployContext(t *testing.T) {
16611671 runtime.EXPECT().TagImage(mock.Anything, "localhost:5000/myapp:latest", "reg.example.com/myapp:latest").Return(nil)
16621672 runtime.EXPECT().UntagImage(mock.Anything, "localhost:5000/myapp:latest").Return(nil)
16631673 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "reg.example.com/myapp:latest").Return([]int{8080}, nil)
1674+ runtime.EXPECT().GetImageLabels(mock.Anything, "reg.example.com/myapp:latest").Return(nil, nil)
16641675 envLoader.EXPECT().LoadEnv(mock.Anything, "app1.example.com").Return([]string{}, nil)
16651676 runtime.EXPECT().InspectImageEnv(mock.Anything, "reg.example.com/myapp:latest").Return([]string{}, nil)
16661677 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{
@@ -1724,6 +1735,7 @@ func TestService_Deploy_OrphanCleanupSkipsTrackedContainer(t *testing.T) {
17241735 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
17251736 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
17261737 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
1738+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil)
17271739
17281740 // Environment
17291741 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
@@ -1815,6 +1827,7 @@ func TestService_Deploy_OrphanCleanupRemovesTrueOrphans(t *testing.T) {
18151827 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
18161828 runtime.EXPECT().PullImage(mock.Anything, "myapp:v1").Return(nil)
18171829 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v1").Return([]int{8080}, nil)
1830+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v1").Return(nil, nil)
18181831
18191832 // Environment
18201833 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
@@ -1892,6 +1905,7 @@ func TestService_Deploy_OrphanCleanupRemovesStaleNewContainer(t *testing.T) {
18921905 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
18931906 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
18941907 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
1908+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil)
18951909 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
18961910 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
18971911
@@ -1955,6 +1969,7 @@ func TestService_Deploy_TrackedTempContainerUsesAlternateTempName(t *testing.T)
19551969 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
19561970 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
19571971 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
1972+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil)
19581973 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
19591974 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
19601975
@@ -2138,6 +2153,7 @@ func TestService_Deploy_ConcurrentSameDomain(t *testing.T) {
21382153 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Times(2)
21392154 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil).Times(2)
21402155 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil).Times(2)
2156+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil).Times(2)
21412157 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return(nil, nil).Times(2)
21422158 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return(nil, nil).Times(2)
21432159
@@ -2277,6 +2293,7 @@ func TestService_Deploy_CacheInvalidationBeforeOldContainerStop(t *testing.T) {
22772293 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
22782294 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:v2"}, nil)
22792295 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
2296+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil)
22802297 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
22812298 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
22822299
@@ -2348,6 +2365,7 @@ func TestService_Deploy_NilCacheInvalidator(t *testing.T) {
23482365 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
23492366 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:v2"}, nil)
23502367 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
2368+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil)
23512369 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
23522370 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
23532371
@@ -2407,6 +2425,7 @@ func TestService_Deploy_SkipsRedundantDeploy(t *testing.T) {
24072425 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
24082426 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
24092427 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
2428+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil)
24102429 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
24112430 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
24122431
@@ -2463,6 +2482,7 @@ func TestService_Deploy_SkipsRedundantDeploy_WhenTrackedImageIDMissing(t *testin
24632482 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
24642483 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
24652484 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
2485+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil)
24662486 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
24672487 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
24682488
@@ -2517,6 +2537,7 @@ func TestService_Deploy_DoesNotSkipWhenImageIDDiffers(t *testing.T) {
25172537 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
25182538 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
25192539 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
2540+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil)
25202541 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
25212542 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
25222543
@@ -2619,6 +2640,7 @@ func TestService_Deploy_SkipRedundantDeploy_ContainerNotRunning(t *testing.T) {
26192640 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
26202641 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
26212642 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
2643+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil)
26222644 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
26232645 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
26242646
@@ -2678,6 +2700,7 @@ func TestService_Deploy_DoesNotSkip_WhenEnvHashDiffers(t *testing.T) {
26782700 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
26792701 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
26802702 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
2703+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil)
26812704 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
26822705 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
26832706
@@ -2732,6 +2755,7 @@ func TestService_Deploy_DoesNotSkip_WhenEnvHashMissing(t *testing.T) {
27322755 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
27332756 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
27342757 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
2758+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil)
27352759 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
27362760 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
27372761
@@ -2873,6 +2897,7 @@ func TestService_Deploy_OrphanCleanup_NeverKillsRunningCanonical(t *testing.T) {
28732897 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
28742898 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
28752899 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
2900+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil)
28762901
28772902 // Environment
28782903 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
@@ -2958,6 +2983,7 @@ func TestService_Deploy_RollbackOnPostSwitchCrash(t *testing.T) {
29582983 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
29592984 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
29602985 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
2986+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil)
29612987
29622988 // Environment
29632989 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
@@ -3050,6 +3076,7 @@ func TestService_Deploy_StabilizationSuccess(t *testing.T) {
30503076 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
30513077 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
30523078 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
3079+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil)
30533080
30543081 // Environment
30553082 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
@@ -3135,6 +3162,7 @@ func TestService_Deploy_ZeroDowntime_OldNeverStoppedBeforeNewReady(t *testing.T)
31353162 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
31363163 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
31373164 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
3165+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil)
31383166
31393167 // Environment
31403168 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
@@ -3226,6 +3254,7 @@ func TestService_Deploy_ZeroDowntime_ReadinessFailure_OldUntouched(t *testing.T)
32263254 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
32273255 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
32283256 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
3257+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil)
32293258
32303259 // Environment
32313260 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
@@ -3303,6 +3332,7 @@ func TestService_Deploy_ZeroDowntime_NoExisting_FirstDeploy(t *testing.T) {
33033332 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
33043333 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
33053334 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
3335+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil)
33063336
33073337 // Environment
33083338 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
@@ -3345,3 +3375,98 @@ func TestService_Deploy_ZeroDowntime_NoExisting_FirstDeploy(t *testing.T) {
33453375 assert.True(t, exists)
33463376 assert.Equal(t, "first-container", tracked.ID)
33473377 }
3378+
3379+func TestService_Deploy_PropagatesImageLabelsToContainerConfig(t *testing.T) {
3380+ runtime := mocks.NewMockContainerRuntime(t)
3381+ envLoader := mocks.NewMockEnvLoader(t)
3382+ eventBus := mocks.NewMockEventPublisher(t)
3383+
3384+ config := Config{
3385+ NetworkIsolation: false,
3386+ VolumeAutoCreate: false,
3387+ ReadinessDelay: time.Millisecond,
3388+ DrainDelay: time.Millisecond,
3389+ DrainDelayConfigured: true,
3390+ }
3391+
3392+ svc := NewService(runtime, envLoader, eventBus, nil, config)
3393+ ctx := testContext()
3394+
3395+ route := domain.Route{
3396+ Domain: "gitea.example.com",
3397+ Image: "gitea/gitea:latest",
3398+ }
3399+
3400+ // No container in memory — runtime resolution returns nothing
3401+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
3402+ // No orphaned containers
3403+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
3404+
3405+ // Image is found locally
3406+ runtime.EXPECT().ListImages(mock.Anything).Return([]string{"gitea/gitea:latest"}, nil)
3407+
3408+ // Exposed ports — image exposes both SSH and HTTP
3409+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, "gitea/gitea:latest").Return([]int{22, 3000}, nil)
3410+
3411+ // Image labels include gordon.proxy.port and gordon.health
3412+ runtime.EXPECT().GetImageLabels(mock.Anything, "gitea/gitea:latest").Return(map[string]string{
3413+ domain.LabelProxyPort: "3000",
3414+ domain.LabelHealth: "/healthz",
3415+ "some.other.label": "ignored",
3416+ }, nil)
3417+
3418+ // Load environment
3419+ envLoader.EXPECT().LoadEnv(mock.Anything, "gitea.example.com").Return(nil, nil)
3420+ runtime.EXPECT().InspectImageEnv(mock.Anything, "gitea/gitea:latest").Return(nil, nil)
3421+
3422+ // Create container — ASSERT that labels include propagated image labels
3423+ createdContainer := &domain.Container{
3424+ ID: "c-gitea-123",
3425+ Name: "gordon-gitea.example.com",
3426+ Image: "gitea/gitea:latest",
3427+ Status: "created",
3428+ }
3429+ runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
3430+ // Verify gordon.proxy.port was propagated from image labels
3431+ if cfg.Labels[domain.LabelProxyPort] != "3000" {
3432+ return false
3433+ }
3434+ // Verify gordon.health was propagated from image labels
3435+ if cfg.Labels[domain.LabelHealth] != "/healthz" {
3436+ return false
3437+ }
3438+ // Verify non-gordon labels were NOT propagated
3439+ if _, exists := cfg.Labels["some.other.label"]; exists {
3440+ return false
3441+ }
3442+ // Verify standard container labels are still present
3443+ if cfg.Labels[domain.LabelManaged] != "true" {
3444+ return false
3445+ }
3446+ return true
3447+ })).Return(createdContainer, nil)
3448+
3449+ runtime.EXPECT().StartContainer(mock.Anything, "c-gitea-123").Return(nil)
3450+
3451+ // Readiness: delay mode (minimal)
3452+ runtime.EXPECT().IsContainerRunning(mock.Anything, "c-gitea-123").Return(true, nil).Times(2)
3453+
3454+ // Re-inspect
3455+ runningContainer := &domain.Container{
3456+ ID: "c-gitea-123",
3457+ Name: "gordon-gitea.example.com",
3458+ Image: "gitea/gitea:latest",
3459+ Status: "running",
3460+ Ports: []int{22, 3000},
3461+ }
3462+ runtime.EXPECT().InspectContainer(mock.Anything, "c-gitea-123").Return(runningContainer, nil)
3463+
3464+ // Publish event
3465+ eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
3466+
3467+ result, err := svc.Deploy(ctx, route)
3468+
3469+ require.NoError(t, err)
3470+ assert.Equal(t, "c-gitea-123", result.ID)
3471+ assert.Equal(t, "running", result.Status)
3472+}
@@ -222,6 +222,7 @@ func TestService_Deploy_Success(t *testing.T) {
222 222
223 // Get exposed ports223 // Get exposed ports
224 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)224 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
225+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil)
225 226
226 // Load environment227 // Load environment
227 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{"FOO=bar"}, nil)228 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{"FOO=bar"}, nil)
@@ -298,6 +299,7 @@ func TestService_Deploy_ReadinessRecoveryWindow_AllowsTransientFlap(t *testing.T
298 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)299 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
299 runtime.EXPECT().PullImage(mock.Anything, "myapp:latest").Return(nil)300 runtime.EXPECT().PullImage(mock.Anything, "myapp:latest").Return(nil)
300 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)301 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
302+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil)
301 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)303 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
302 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)304 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
303 305
@@ -457,6 +459,7 @@ func TestService_Deploy_ReplacesExistingContainer(t *testing.T) {
457 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)459 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
458 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)460 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
459 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)461 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
462+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil)
460 463
461 // Environment464 // Environment
462 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)465 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
@@ -551,6 +554,7 @@ func TestService_Deploy_ResolvesExistingFromRuntime_WhenMemoryStale(t *testing.T
551 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)554 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
552 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)555 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
553 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)556 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
557+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil)
554 558
555 // Environment559 // Environment
556 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)560 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
@@ -630,6 +634,7 @@ func TestService_Deploy_SkipRedundantDeploy_GetImageIDError(t *testing.T) {
630 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)634 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
631 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)635 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
632 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)636 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
637+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil)
633 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)638 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
634 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)639 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
635 640
@@ -684,6 +689,7 @@ func TestService_Deploy_WithNetworkIsolation(t *testing.T) {
684 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)689 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
685 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)690 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
686 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)691 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
692+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil)
687 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)693 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
688 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)694 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
689 695
@@ -740,6 +746,7 @@ func TestService_Deploy_WithVolumeAutoCreate(t *testing.T) {
740 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)746 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
741 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)747 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
742 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)748 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
749+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil)
743 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)750 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
744 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)751 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
745 752
@@ -1471,6 +1478,7 @@ func TestService_Deploy_InternalDeployForcesPull(t *testing.T) {
1471 1478
1472 // Get exposed ports1479 // Get exposed ports
1473 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "registry.example.com/myapp:latest").Return([]int{8080}, nil)1480 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "registry.example.com/myapp:latest").Return([]int{8080}, nil)
1481+ runtime.EXPECT().GetImageLabels(mock.Anything, "registry.example.com/myapp:latest").Return(nil, nil)
1474 1482
1475 // Load environment1483 // Load environment
1476 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)1484 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
@@ -1532,6 +1540,7 @@ func TestService_AutoStart_StartsNewContainers(t *testing.T) {
1532 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Times(2)1540 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Times(2)
1533 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp1:latest", "myapp2:latest"}, nil).Times(2)1541 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp1:latest", "myapp2:latest"}, nil).Times(2)
1534 runtime.EXPECT().GetImageExposedPorts(mock.Anything, mock.AnythingOfType("string")).Return([]int{8080}, nil).Times(2)1542 runtime.EXPECT().GetImageExposedPorts(mock.Anything, mock.AnythingOfType("string")).Return([]int{8080}, nil).Times(2)
1543+ runtime.EXPECT().GetImageLabels(mock.Anything, mock.AnythingOfType("string")).Return(nil, nil).Times(2)
1535 envLoader.EXPECT().LoadEnv(mock.Anything, mock.AnythingOfType("string")).Return([]string{}, nil).Times(2)1544 envLoader.EXPECT().LoadEnv(mock.Anything, mock.AnythingOfType("string")).Return([]string{}, nil).Times(2)
1536 runtime.EXPECT().InspectImageEnv(mock.Anything, mock.AnythingOfType("string")).Return([]string{}, nil).Times(2)1545 runtime.EXPECT().InspectImageEnv(mock.Anything, mock.AnythingOfType("string")).Return([]string{}, nil).Times(2)
1537 1546
@@ -1584,6 +1593,7 @@ func TestService_AutoStart_SkipsExistingContainers(t *testing.T) {
1584 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Once()1593 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Once()
1585 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp2:latest"}, nil).Once()1594 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp2:latest"}, nil).Once()
1586 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp2:latest").Return([]int{8080}, nil).Once()1595 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp2:latest").Return([]int{8080}, nil).Once()
1596+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp2:latest").Return(nil, nil).Once()
1587 envLoader.EXPECT().LoadEnv(mock.Anything, "app2.example.com").Return([]string{}, nil).Once()1597 envLoader.EXPECT().LoadEnv(mock.Anything, "app2.example.com").Return([]string{}, nil).Once()
1588 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp2:latest").Return([]string{}, nil).Once()1598 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp2:latest").Return([]string{}, nil).Once()
1589 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{1599 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{
@@ -1661,6 +1671,7 @@ func TestService_AutoStart_UsesInternalDeployContext(t *testing.T) {
1661 runtime.EXPECT().TagImage(mock.Anything, "localhost:5000/myapp:latest", "reg.example.com/myapp:latest").Return(nil)1671 runtime.EXPECT().TagImage(mock.Anything, "localhost:5000/myapp:latest", "reg.example.com/myapp:latest").Return(nil)
1662 runtime.EXPECT().UntagImage(mock.Anything, "localhost:5000/myapp:latest").Return(nil)1672 runtime.EXPECT().UntagImage(mock.Anything, "localhost:5000/myapp:latest").Return(nil)
1663 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "reg.example.com/myapp:latest").Return([]int{8080}, nil)1673 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "reg.example.com/myapp:latest").Return([]int{8080}, nil)
1674+ runtime.EXPECT().GetImageLabels(mock.Anything, "reg.example.com/myapp:latest").Return(nil, nil)
1664 envLoader.EXPECT().LoadEnv(mock.Anything, "app1.example.com").Return([]string{}, nil)1675 envLoader.EXPECT().LoadEnv(mock.Anything, "app1.example.com").Return([]string{}, nil)
1665 runtime.EXPECT().InspectImageEnv(mock.Anything, "reg.example.com/myapp:latest").Return([]string{}, nil)1676 runtime.EXPECT().InspectImageEnv(mock.Anything, "reg.example.com/myapp:latest").Return([]string{}, nil)
1666 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{1677 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{
@@ -1724,6 +1735,7 @@ func TestService_Deploy_OrphanCleanupSkipsTrackedContainer(t *testing.T) {
1724 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)1735 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
1725 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)1736 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
1726 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)1737 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
1738+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil)
1727 1739
1728 // Environment1740 // Environment
1729 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)1741 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
@@ -1815,6 +1827,7 @@ func TestService_Deploy_OrphanCleanupRemovesTrueOrphans(t *testing.T) {
1815 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)1827 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
1816 runtime.EXPECT().PullImage(mock.Anything, "myapp:v1").Return(nil)1828 runtime.EXPECT().PullImage(mock.Anything, "myapp:v1").Return(nil)
1817 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v1").Return([]int{8080}, nil)1829 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v1").Return([]int{8080}, nil)
1830+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v1").Return(nil, nil)
1818 1831
1819 // Environment1832 // Environment
1820 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)1833 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
@@ -1892,6 +1905,7 @@ func TestService_Deploy_OrphanCleanupRemovesStaleNewContainer(t *testing.T) {
1892 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)1905 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
1893 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)1906 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
1894 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)1907 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
1908+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil)
1895 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)1909 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
1896 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)1910 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
1897 1911
@@ -1955,6 +1969,7 @@ func TestService_Deploy_TrackedTempContainerUsesAlternateTempName(t *testing.T)
1955 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)1969 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
1956 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)1970 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
1957 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)1971 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
1972+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil)
1958 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)1973 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
1959 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)1974 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
1960 1975
@@ -2138,6 +2153,7 @@ func TestService_Deploy_ConcurrentSameDomain(t *testing.T) {
2138 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Times(2)2153 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Times(2)
2139 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil).Times(2)2154 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil).Times(2)
2140 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil).Times(2)2155 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil).Times(2)
2156+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil).Times(2)
2141 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return(nil, nil).Times(2)2157 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return(nil, nil).Times(2)
2142 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return(nil, nil).Times(2)2158 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return(nil, nil).Times(2)
2143 2159
@@ -2277,6 +2293,7 @@ func TestService_Deploy_CacheInvalidationBeforeOldContainerStop(t *testing.T) {
2277 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)2293 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
2278 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:v2"}, nil)2294 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:v2"}, nil)
2279 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)2295 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
2296+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil)
2280 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)2297 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
2281 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)2298 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
2282 2299
@@ -2348,6 +2365,7 @@ func TestService_Deploy_NilCacheInvalidator(t *testing.T) {
2348 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)2365 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
2349 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:v2"}, nil)2366 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:v2"}, nil)
2350 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)2367 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
2368+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil)
2351 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)2369 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
2352 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)2370 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
2353 2371
@@ -2407,6 +2425,7 @@ func TestService_Deploy_SkipsRedundantDeploy(t *testing.T) {
2407 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)2425 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
2408 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)2426 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
2409 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)2427 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
2428+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil)
2410 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)2429 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
2411 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)2430 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
2412 2431
@@ -2463,6 +2482,7 @@ func TestService_Deploy_SkipsRedundantDeploy_WhenTrackedImageIDMissing(t *testin
2463 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)2482 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
2464 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)2483 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
2465 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)2484 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
2485+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil)
2466 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)2486 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
2467 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)2487 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
2468 2488
@@ -2517,6 +2537,7 @@ func TestService_Deploy_DoesNotSkipWhenImageIDDiffers(t *testing.T) {
2517 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)2537 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
2518 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)2538 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
2519 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)2539 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
2540+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil)
2520 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)2541 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
2521 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)2542 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
2522 2543
@@ -2619,6 +2640,7 @@ func TestService_Deploy_SkipRedundantDeploy_ContainerNotRunning(t *testing.T) {
2619 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)2640 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
2620 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)2641 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
2621 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)2642 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
2643+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil)
2622 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)2644 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
2623 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)2645 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
2624 2646
@@ -2678,6 +2700,7 @@ func TestService_Deploy_DoesNotSkip_WhenEnvHashDiffers(t *testing.T) {
2678 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)2700 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
2679 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)2701 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
2680 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)2702 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
2703+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil)
2681 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)2704 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
2682 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)2705 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
2683 2706
@@ -2732,6 +2755,7 @@ func TestService_Deploy_DoesNotSkip_WhenEnvHashMissing(t *testing.T) {
2732 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)2755 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
2733 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)2756 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
2734 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)2757 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
2758+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil)
2735 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)2759 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
2736 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)2760 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
2737 2761
@@ -2873,6 +2897,7 @@ func TestService_Deploy_OrphanCleanup_NeverKillsRunningCanonical(t *testing.T) {
2873 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)2897 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
2874 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)2898 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
2875 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)2899 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
2900+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil)
2876 2901
2877 // Environment2902 // Environment
2878 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)2903 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
@@ -2958,6 +2983,7 @@ func TestService_Deploy_RollbackOnPostSwitchCrash(t *testing.T) {
2958 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)2983 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
2959 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)2984 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
2960 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)2985 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
2986+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil)
2961 2987
2962 // Environment2988 // Environment
2963 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)2989 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
@@ -3050,6 +3076,7 @@ func TestService_Deploy_StabilizationSuccess(t *testing.T) {
3050 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)3076 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
3051 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)3077 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
3052 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)3078 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
3079+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil)
3053 3080
3054 // Environment3081 // Environment
3055 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)3082 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
@@ -3135,6 +3162,7 @@ func TestService_Deploy_ZeroDowntime_OldNeverStoppedBeforeNewReady(t *testing.T)
3135 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)3162 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
3136 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)3163 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
3137 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)3164 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
3165+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil)
3138 3166
3139 // Environment3167 // Environment
3140 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)3168 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
@@ -3226,6 +3254,7 @@ func TestService_Deploy_ZeroDowntime_ReadinessFailure_OldUntouched(t *testing.T)
3226 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)3254 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
3227 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)3255 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
3228 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)3256 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
3257+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil)
3229 3258
3230 // Environment3259 // Environment
3231 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)3260 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
@@ -3303,6 +3332,7 @@ func TestService_Deploy_ZeroDowntime_NoExisting_FirstDeploy(t *testing.T) {
3303 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)3332 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
3304 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)3333 runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
3305 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)3334 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
3335+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil)
3306 3336
3307 // Environment3337 // Environment
3308 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)3338 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
@@ -3345,3 +3375,98 @@ func TestService_Deploy_ZeroDowntime_NoExisting_FirstDeploy(t *testing.T) {
3345 assert.True(t, exists)3375 assert.True(t, exists)
3346 assert.Equal(t, "first-container", tracked.ID)3376 assert.Equal(t, "first-container", tracked.ID)
3347 }3377 }
3378+
3379+func TestService_Deploy_PropagatesImageLabelsToContainerConfig(t *testing.T) {
3380+ runtime := mocks.NewMockContainerRuntime(t)
3381+ envLoader := mocks.NewMockEnvLoader(t)
3382+ eventBus := mocks.NewMockEventPublisher(t)
3383+
3384+ config := Config{
3385+ NetworkIsolation: false,
3386+ VolumeAutoCreate: false,
3387+ ReadinessDelay: time.Millisecond,
3388+ DrainDelay: time.Millisecond,
3389+ DrainDelayConfigured: true,
3390+ }
3391+
3392+ svc := NewService(runtime, envLoader, eventBus, nil, config)
3393+ ctx := testContext()
3394+
3395+ route := domain.Route{
3396+ Domain: "gitea.example.com",
3397+ Image: "gitea/gitea:latest",
3398+ }
3399+
3400+ // No container in memory — runtime resolution returns nothing
3401+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
3402+ // No orphaned containers
3403+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
3404+
3405+ // Image is found locally
3406+ runtime.EXPECT().ListImages(mock.Anything).Return([]string{"gitea/gitea:latest"}, nil)
3407+
3408+ // Exposed ports — image exposes both SSH and HTTP
3409+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, "gitea/gitea:latest").Return([]int{22, 3000}, nil)
3410+
3411+ // Image labels include gordon.proxy.port and gordon.health
3412+ runtime.EXPECT().GetImageLabels(mock.Anything, "gitea/gitea:latest").Return(map[string]string{
3413+ domain.LabelProxyPort: "3000",
3414+ domain.LabelHealth: "/healthz",
3415+ "some.other.label": "ignored",
3416+ }, nil)
3417+
3418+ // Load environment
3419+ envLoader.EXPECT().LoadEnv(mock.Anything, "gitea.example.com").Return(nil, nil)
3420+ runtime.EXPECT().InspectImageEnv(mock.Anything, "gitea/gitea:latest").Return(nil, nil)
3421+
3422+ // Create container — ASSERT that labels include propagated image labels
3423+ createdContainer := &domain.Container{
3424+ ID: "c-gitea-123",
3425+ Name: "gordon-gitea.example.com",
3426+ Image: "gitea/gitea:latest",
3427+ Status: "created",
3428+ }
3429+ runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
3430+ // Verify gordon.proxy.port was propagated from image labels
3431+ if cfg.Labels[domain.LabelProxyPort] != "3000" {
3432+ return false
3433+ }
3434+ // Verify gordon.health was propagated from image labels
3435+ if cfg.Labels[domain.LabelHealth] != "/healthz" {
3436+ return false
3437+ }
3438+ // Verify non-gordon labels were NOT propagated
3439+ if _, exists := cfg.Labels["some.other.label"]; exists {
3440+ return false
3441+ }
3442+ // Verify standard container labels are still present
3443+ if cfg.Labels[domain.LabelManaged] != "true" {
3444+ return false
3445+ }
3446+ return true
3447+ })).Return(createdContainer, nil)
3448+
3449+ runtime.EXPECT().StartContainer(mock.Anything, "c-gitea-123").Return(nil)
3450+
3451+ // Readiness: delay mode (minimal)
3452+ runtime.EXPECT().IsContainerRunning(mock.Anything, "c-gitea-123").Return(true, nil).Times(2)
3453+
3454+ // Re-inspect
3455+ runningContainer := &domain.Container{
3456+ ID: "c-gitea-123",
3457+ Name: "gordon-gitea.example.com",
3458+ Image: "gitea/gitea:latest",
3459+ Status: "running",
3460+ Ports: []int{22, 3000},
3461+ }
3462+ runtime.EXPECT().InspectContainer(mock.Anything, "c-gitea-123").Return(runningContainer, nil)
3463+
3464+ // Publish event
3465+ eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
3466+
3467+ result, err := svc.Deploy(ctx, route)
3468+
3469+ require.NoError(t, err)
3470+ assert.Equal(t, "c-gitea-123", result.ID)
3471+ assert.Equal(t, "running", result.Status)
3472+}
M internal/usecase/proxy/service.go +19 -10
@@ -211,13 +211,18 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (target *dom
211211
212212 if s.isRunningInContainer() {
213213 // Gordon is in a container - use container network
214- containerIP, containerPort, err := s.runtime.GetContainerNetworkInfo(ctx, container.ID)
214+ // Determine target port: check for explicit label first, then fall back to network detection
215+ targetPort, err := s.getProxyPort(ctx, container.Image)
216+ if err != nil {
217+ return nil, log.WrapErr(err, "failed to determine proxy port")
218+ }
219+ containerIP, _, err := s.runtime.GetContainerNetworkInfo(ctx, container.ID)
215220 if err != nil {
216221 return nil, log.WrapErrWithFields(err, "failed to get container network info", map[string]any{zerowrap.FieldEntityID: container.ID})
217222 }
218223 target = &domain.ProxyTarget{
219224 Host: containerIP,
220- Port: containerPort,
225+ Port: targetPort,
221226 ContainerID: container.ID,
222227 Scheme: "http",
223228 }
@@ -688,23 +693,27 @@ func (s *Service) isRunningInContainer() bool {
688693 }
689694
690695 // getProxyPort determines the port to proxy to for an image.
691-// It checks for the gordon.proxy.port label first, then falls back to the first exposed port.
696+// It checks for gordon.proxy.port first, then the deprecated gordon.port alias, then falls back to the first exposed port.
692697 func (s *Service) getProxyPort(ctx context.Context, imageRef string) (int, error) {
693698 log := zerowrap.FromCtx(ctx)
694699
695700 log.Debug().Str("image_ref", imageRef).Msg("determining proxy port for image")
696701
697- // Check for explicit port label
702+ // Check for explicit port label (gordon.proxy.port preferred, gordon.port as deprecated alias)
698703 labels, err := s.runtime.GetImageLabels(ctx, imageRef)
699704 if err != nil {
700705 log.Debug().Err(err).Msg("failed to get image labels, falling back to exposed ports")
701- } else if portStr, ok := labels[domain.LabelProxyPort]; ok {
702- port, err := strconv.Atoi(portStr)
703- if err == nil && port > 0 {
704- log.Debug().Int("port", port).Msg("using proxy port from image label")
705- return port, nil
706+ } else {
707+ for _, key := range []string{domain.LabelProxyPort, domain.LabelPort} {
708+ if portStr, ok := labels[key]; ok && portStr != "" {
709+ port, err := strconv.Atoi(portStr)
710+ if err == nil && port > 0 && port <= 65535 {
711+ log.Debug().Int("port", port).Str("label", key).Msg("using proxy port from image label")
712+ return port, nil
713+ }
714+ log.Warn().Str("label", key).Str("port_value", portStr).Msg("invalid port label value")
715+ }
706716 }
707- log.Warn().Str("port_value", portStr).Msg("invalid gordon.proxy.port label value")
708717 }
709718
710719 // Fall back to first exposed port
@@ -211,13 +211,18 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (target *dom
211 211
212 if s.isRunningInContainer() {212 if s.isRunningInContainer() {
213 // Gordon is in a container - use container network213 // Gordon is in a container - use container network
214- containerIP, containerPort, err := s.runtime.GetContainerNetworkInfo(ctx, container.ID)214+ // Determine target port: check for explicit label first, then fall back to network detection
215+ targetPort, err := s.getProxyPort(ctx, container.Image)
216+ if err != nil {
217+ return nil, log.WrapErr(err, "failed to determine proxy port")
218+ }
219+ containerIP, _, err := s.runtime.GetContainerNetworkInfo(ctx, container.ID)
215 if err != nil {220 if err != nil {
216 return nil, log.WrapErrWithFields(err, "failed to get container network info", map[string]any{zerowrap.FieldEntityID: container.ID})221 return nil, log.WrapErrWithFields(err, "failed to get container network info", map[string]any{zerowrap.FieldEntityID: container.ID})
217 }222 }
218 target = &domain.ProxyTarget{223 target = &domain.ProxyTarget{
219 Host: containerIP,224 Host: containerIP,
220- Port: containerPort,225+ Port: targetPort,
221 ContainerID: container.ID,226 ContainerID: container.ID,
222 Scheme: "http",227 Scheme: "http",
223 }228 }
@@ -688,23 +693,27 @@ func (s *Service) isRunningInContainer() bool {
688 }693 }
689 694
690 // getProxyPort determines the port to proxy to for an image.695 // getProxyPort determines the port to proxy to for an image.
691-// It checks for the gordon.proxy.port label first, then falls back to the first exposed port.696+// It checks for gordon.proxy.port first, then the deprecated gordon.port alias, then falls back to the first exposed port.
692 func (s *Service) getProxyPort(ctx context.Context, imageRef string) (int, error) {697 func (s *Service) getProxyPort(ctx context.Context, imageRef string) (int, error) {
693 log := zerowrap.FromCtx(ctx)698 log := zerowrap.FromCtx(ctx)
694 699
695 log.Debug().Str("image_ref", imageRef).Msg("determining proxy port for image")700 log.Debug().Str("image_ref", imageRef).Msg("determining proxy port for image")
696 701
697- // Check for explicit port label702+ // Check for explicit port label (gordon.proxy.port preferred, gordon.port as deprecated alias)
698 labels, err := s.runtime.GetImageLabels(ctx, imageRef)703 labels, err := s.runtime.GetImageLabels(ctx, imageRef)
699 if err != nil {704 if err != nil {
700 log.Debug().Err(err).Msg("failed to get image labels, falling back to exposed ports")705 log.Debug().Err(err).Msg("failed to get image labels, falling back to exposed ports")
701- } else if portStr, ok := labels[domain.LabelProxyPort]; ok {706+ } else {
702- port, err := strconv.Atoi(portStr)707+ for _, key := range []string{domain.LabelProxyPort, domain.LabelPort} {
703- if err == nil && port > 0 {708+ if portStr, ok := labels[key]; ok && portStr != "" {
704- log.Debug().Int("port", port).Msg("using proxy port from image label")709+ port, err := strconv.Atoi(portStr)
705- return port, nil710+ if err == nil && port > 0 && port <= 65535 {
711+ log.Debug().Int("port", port).Str("label", key).Msg("using proxy port from image label")
712+ return port, nil
713+ }
714+ log.Warn().Str("label", key).Str("port_value", portStr).Msg("invalid port label value")
715+ }
706 }716 }
707- log.Warn().Str("port_value", portStr).Msg("invalid gordon.proxy.port label value")
708 }717 }
709 718
710 // Fall back to first exposed port719 // Fall back to first exposed port
M internal/usecase/proxy/service_test.go +139 -0
@@ -571,3 +571,142 @@ func TestDrainRegistryInFlightTimeout(t *testing.T) {
571571 t.Fatal("expected DrainRegistryInFlight to return false on timeout, got true")
572572 }
573573 }
574+
575+func TestService_GetTarget_HostMode_UsesProxyPortLabel(t *testing.T) {
576+ runtime := outmocks.NewMockContainerRuntime(t)
577+ containerSvc := inmocks.NewMockContainerService(t)
578+ configSvc := inmocks.NewMockConfigService(t)
579+
580+ svc := NewService(runtime, containerSvc, configSvc, Config{})
581+ ctx := testContext()
582+
583+ // No external routes
584+ configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{})
585+
586+ // Container exists for this domain
587+ container := &domain.Container{
588+ ID: "c-gitea",
589+ Image: "gitea/gitea:latest",
590+ }
591+ containerSvc.EXPECT().Get(mock.Anything, "git.example.com").Return(container, true)
592+
593+ // Route exists
594+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
595+ {Domain: "git.example.com", Image: "gitea/gitea:latest"},
596+ })
597+
598+ // Image has gordon.proxy.port=3000 label
599+ runtime.EXPECT().GetImageLabels(mock.Anything, "gitea/gitea:latest").Return(map[string]string{
600+ domain.LabelProxyPort: "3000",
601+ }, nil)
602+
603+ // Host port mapping for internal port 3000
604+ runtime.EXPECT().GetContainerPort(mock.Anything, "c-gitea", 3000).Return(32000, nil)
605+
606+ result, err := svc.GetTarget(ctx, "git.example.com")
607+
608+ assert.NoError(t, err)
609+ assert.Equal(t, "localhost", result.Host)
610+ assert.Equal(t, 32000, result.Port)
611+ assert.Equal(t, "c-gitea", result.ContainerID)
612+}
613+
614+func TestService_GetTarget_HostMode_UsesDeprecatedPortLabel(t *testing.T) {
615+ runtime := outmocks.NewMockContainerRuntime(t)
616+ containerSvc := inmocks.NewMockContainerService(t)
617+ configSvc := inmocks.NewMockConfigService(t)
618+
619+ svc := NewService(runtime, containerSvc, configSvc, Config{})
620+ ctx := testContext()
621+
622+ configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{})
623+
624+ container := &domain.Container{
625+ ID: "c-app",
626+ Image: "myapp:latest",
627+ }
628+ containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(container, true)
629+
630+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
631+ {Domain: "app.example.com", Image: "myapp:latest"},
632+ })
633+
634+ // Image has deprecated gordon.port label — should still work
635+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(map[string]string{
636+ domain.LabelPort: "8080",
637+ }, nil)
638+
639+ runtime.EXPECT().GetContainerPort(mock.Anything, "c-app", 8080).Return(33000, nil)
640+
641+ result, err := svc.GetTarget(ctx, "app.example.com")
642+
643+ assert.NoError(t, err)
644+ assert.Equal(t, 33000, result.Port)
645+}
646+
647+func TestService_GetTarget_HostMode_ProxyPortWinsOverPort(t *testing.T) {
648+ runtime := outmocks.NewMockContainerRuntime(t)
649+ containerSvc := inmocks.NewMockContainerService(t)
650+ configSvc := inmocks.NewMockConfigService(t)
651+
652+ svc := NewService(runtime, containerSvc, configSvc, Config{})
653+ ctx := testContext()
654+
655+ configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{})
656+
657+ container := &domain.Container{
658+ ID: "c-dual",
659+ Image: "dualapp:latest",
660+ }
661+ containerSvc.EXPECT().Get(mock.Anything, "dual.example.com").Return(container, true)
662+
663+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
664+ {Domain: "dual.example.com", Image: "dualapp:latest"},
665+ })
666+
667+ // Both labels set — gordon.proxy.port=9000 should win over gordon.port=3000
668+ runtime.EXPECT().GetImageLabels(mock.Anything, "dualapp:latest").Return(map[string]string{
669+ domain.LabelProxyPort: "9000",
670+ domain.LabelPort: "3000",
671+ }, nil)
672+
673+ // Should use 9000 (gordon.proxy.port), NOT 3000 (gordon.port)
674+ runtime.EXPECT().GetContainerPort(mock.Anything, "c-dual", 9000).Return(34000, nil)
675+
676+ result, err := svc.GetTarget(ctx, "dual.example.com")
677+
678+ assert.NoError(t, err)
679+ assert.Equal(t, 34000, result.Port)
680+}
681+
682+func TestService_GetTarget_HostMode_FallsBackToExposedPort(t *testing.T) {
683+ runtime := outmocks.NewMockContainerRuntime(t)
684+ containerSvc := inmocks.NewMockContainerService(t)
685+ configSvc := inmocks.NewMockConfigService(t)
686+
687+ svc := NewService(runtime, containerSvc, configSvc, Config{})
688+ ctx := testContext()
689+
690+ configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{})
691+
692+ container := &domain.Container{
693+ ID: "c-plain",
694+ Image: "plain:latest",
695+ }
696+ containerSvc.EXPECT().Get(mock.Anything, "plain.example.com").Return(container, true)
697+
698+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
699+ {Domain: "plain.example.com", Image: "plain:latest"},
700+ })
701+
702+ // No port labels — should fall back to first exposed port
703+ runtime.EXPECT().GetImageLabels(mock.Anything, "plain:latest").Return(map[string]string{}, nil)
704+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, "plain:latest").Return([]int{8080}, nil)
705+
706+ runtime.EXPECT().GetContainerPort(mock.Anything, "c-plain", 8080).Return(35000, nil)
707+
708+ result, err := svc.GetTarget(ctx, "plain.example.com")
709+
710+ assert.NoError(t, err)
711+ assert.Equal(t, 35000, result.Port)
712+}
@@ -571,3 +571,142 @@ func TestDrainRegistryInFlightTimeout(t *testing.T) {
571 t.Fatal("expected DrainRegistryInFlight to return false on timeout, got true")571 t.Fatal("expected DrainRegistryInFlight to return false on timeout, got true")
572 }572 }
573 }573 }
574+
575+func TestService_GetTarget_HostMode_UsesProxyPortLabel(t *testing.T) {
576+ runtime := outmocks.NewMockContainerRuntime(t)
577+ containerSvc := inmocks.NewMockContainerService(t)
578+ configSvc := inmocks.NewMockConfigService(t)
579+
580+ svc := NewService(runtime, containerSvc, configSvc, Config{})
581+ ctx := testContext()
582+
583+ // No external routes
584+ configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{})
585+
586+ // Container exists for this domain
587+ container := &domain.Container{
588+ ID: "c-gitea",
589+ Image: "gitea/gitea:latest",
590+ }
591+ containerSvc.EXPECT().Get(mock.Anything, "git.example.com").Return(container, true)
592+
593+ // Route exists
594+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
595+ {Domain: "git.example.com", Image: "gitea/gitea:latest"},
596+ })
597+
598+ // Image has gordon.proxy.port=3000 label
599+ runtime.EXPECT().GetImageLabels(mock.Anything, "gitea/gitea:latest").Return(map[string]string{
600+ domain.LabelProxyPort: "3000",
601+ }, nil)
602+
603+ // Host port mapping for internal port 3000
604+ runtime.EXPECT().GetContainerPort(mock.Anything, "c-gitea", 3000).Return(32000, nil)
605+
606+ result, err := svc.GetTarget(ctx, "git.example.com")
607+
608+ assert.NoError(t, err)
609+ assert.Equal(t, "localhost", result.Host)
610+ assert.Equal(t, 32000, result.Port)
611+ assert.Equal(t, "c-gitea", result.ContainerID)
612+}
613+
614+func TestService_GetTarget_HostMode_UsesDeprecatedPortLabel(t *testing.T) {
615+ runtime := outmocks.NewMockContainerRuntime(t)
616+ containerSvc := inmocks.NewMockContainerService(t)
617+ configSvc := inmocks.NewMockConfigService(t)
618+
619+ svc := NewService(runtime, containerSvc, configSvc, Config{})
620+ ctx := testContext()
621+
622+ configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{})
623+
624+ container := &domain.Container{
625+ ID: "c-app",
626+ Image: "myapp:latest",
627+ }
628+ containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(container, true)
629+
630+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
631+ {Domain: "app.example.com", Image: "myapp:latest"},
632+ })
633+
634+ // Image has deprecated gordon.port label — should still work
635+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(map[string]string{
636+ domain.LabelPort: "8080",
637+ }, nil)
638+
639+ runtime.EXPECT().GetContainerPort(mock.Anything, "c-app", 8080).Return(33000, nil)
640+
641+ result, err := svc.GetTarget(ctx, "app.example.com")
642+
643+ assert.NoError(t, err)
644+ assert.Equal(t, 33000, result.Port)
645+}
646+
647+func TestService_GetTarget_HostMode_ProxyPortWinsOverPort(t *testing.T) {
648+ runtime := outmocks.NewMockContainerRuntime(t)
649+ containerSvc := inmocks.NewMockContainerService(t)
650+ configSvc := inmocks.NewMockConfigService(t)
651+
652+ svc := NewService(runtime, containerSvc, configSvc, Config{})
653+ ctx := testContext()
654+
655+ configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{})
656+
657+ container := &domain.Container{
658+ ID: "c-dual",
659+ Image: "dualapp:latest",
660+ }
661+ containerSvc.EXPECT().Get(mock.Anything, "dual.example.com").Return(container, true)
662+
663+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
664+ {Domain: "dual.example.com", Image: "dualapp:latest"},
665+ })
666+
667+ // Both labels set — gordon.proxy.port=9000 should win over gordon.port=3000
668+ runtime.EXPECT().GetImageLabels(mock.Anything, "dualapp:latest").Return(map[string]string{
669+ domain.LabelProxyPort: "9000",
670+ domain.LabelPort: "3000",
671+ }, nil)
672+
673+ // Should use 9000 (gordon.proxy.port), NOT 3000 (gordon.port)
674+ runtime.EXPECT().GetContainerPort(mock.Anything, "c-dual", 9000).Return(34000, nil)
675+
676+ result, err := svc.GetTarget(ctx, "dual.example.com")
677+
678+ assert.NoError(t, err)
679+ assert.Equal(t, 34000, result.Port)
680+}
681+
682+func TestService_GetTarget_HostMode_FallsBackToExposedPort(t *testing.T) {
683+ runtime := outmocks.NewMockContainerRuntime(t)
684+ containerSvc := inmocks.NewMockContainerService(t)
685+ configSvc := inmocks.NewMockConfigService(t)
686+
687+ svc := NewService(runtime, containerSvc, configSvc, Config{})
688+ ctx := testContext()
689+
690+ configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{})
691+
692+ container := &domain.Container{
693+ ID: "c-plain",
694+ Image: "plain:latest",
695+ }
696+ containerSvc.EXPECT().Get(mock.Anything, "plain.example.com").Return(container, true)
697+
698+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
699+ {Domain: "plain.example.com", Image: "plain:latest"},
700+ })
701+
702+ // No port labels — should fall back to first exposed port
703+ runtime.EXPECT().GetImageLabels(mock.Anything, "plain:latest").Return(map[string]string{}, nil)
704+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, "plain:latest").Return([]int{8080}, nil)
705+
706+ runtime.EXPECT().GetContainerPort(mock.Anything, "c-plain", 8080).Return(35000, nil)
707+
708+ result, err := svc.GetTarget(ctx, "plain.example.com")
709+
710+ assert.NoError(t, err)
711+ assert.Equal(t, 35000, result.Port)
712+}

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