detached c672160

fix: respect gordon.port label during port selection (#118)

Port selection ignored explicit gordon.port/gordon.proxy.port labels,
causing Gordon to pick the wrong port (e.g. SSH 22 instead of HTTP 3000)
on multi-port images.

Three independent bugs fixed:
- Propagate image labels to container labels so readiness probes find them
- Apply sortPortsHTTPFirst in GetContainerNetworkInfo as fallback defense
- Container-mode proxy resolution now checks labels before network detection

Also canonicalizes gordon.port as preferred label, with gordon.proxy.port
kept as deprecated alias, and fixes getProxyPort to check both labels.
commit
c6721609490030cb27461775120ba543be7c568e
author
brice · · 2026-03-15 07:53 UTC
committer
brice · · 2026-03-15 08:06 UTC
parent
240ae02
signature
unsigned
8 files · +508 -41
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 +2 -1
@@ -15,7 +15,8 @@ const (
1515 // exposing secret values.
1616 LabelEnvHash = "gordon.env-hash"
1717
18- // Image labels (set in Dockerfile)
18+ // LabelProxyPort is a deprecated alias for LabelPort.
19+ // Kept for backward compatibility; prefer LabelPort for new images.
1920 LabelProxyPort = "gordon.proxy.port"
2021
2122 // Auto-route image labels (for automatic route discovery)
@@ -15,7 +15,8 @@ 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 is a deprecated alias for LabelPort.
19+ // Kept for backward compatibility; prefer LabelPort for new images.
19 LabelProxyPort = "gordon.proxy.port"20 LabelProxyPort = "gordon.proxy.port"
20 21
21 // Auto-route image labels (for automatic route discovery)22 // Auto-route image labels (for automatic route discovery)
M internal/usecase/container/service.go +60 -29
@@ -210,25 +210,47 @@ 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+ }
215243
216244 return &domain.ContainerConfig{
217- Image: actualImageRef,
245+ Image: in.ImageRef,
218246 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,
247+ Ports: in.ExposedPorts,
248+ Env: in.EnvVars,
249+ Volumes: in.Volumes,
250+ NetworkMode: in.NetworkName,
251+ Hostname: in.Domain,
252+ Labels: labels,
253+ AutoRemove: false,
232254 }
233255 }
234256
@@ -416,6 +438,7 @@ type deployResources struct {
416438 networkName string
417439 actualImageRef string
418440 exposedPorts []int
441+ imageLabels map[string]string
419442 envVars []string
420443 envHash string
421444 volumes map[string]string
@@ -516,6 +539,12 @@ func (s *Service) prepareDeployResources(ctx context.Context, route domain.Route
516539 exposedPorts = []int{80, 8080, 3000}
517540 }
518541
542+ imageLabels, err := s.runtime.GetImageLabels(ctx, actualImageRef)
543+ if err != nil {
544+ log.Warn().Err(err).Msg("failed to get image labels, skipping label propagation")
545+ imageLabels = nil
546+ }
547+
519548 envVars, err := s.loadEnvironment(ctx, route.Domain, actualImageRef)
520549 if err != nil {
521550 return nil, err
@@ -531,6 +560,7 @@ func (s *Service) prepareDeployResources(ctx context.Context, route domain.Route
531560 networkName: networkName,
532561 actualImageRef: actualImageRef,
533562 exposedPorts: exposedPorts,
563+ imageLabels: imageLabels,
534564 envVars: envVars,
535565 envHash: envHash,
536566 volumes: volumes,
@@ -543,17 +573,18 @@ func (s *Service) createStartedContainer(ctx context.Context, route domain.Route
543573
544574 log := zerowrap.FromCtx(ctx)
545575
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- )
576+ containerConfig := s.buildContainerConfig(containerConfigInput{
577+ Domain: route.Domain,
578+ Image: route.Image,
579+ ImageRef: resources.actualImageRef,
580+ ExposedPorts: resources.exposedPorts,
581+ EnvVars: resources.envVars,
582+ EnvHash: resources.envHash,
583+ Volumes: resources.volumes,
584+ NetworkName: resources.networkName,
585+ ImageLabels: resources.imageLabels,
586+ Existing: existing,
587+ })
557588
558589 newContainer, err := s.runtime.CreateContainer(ctx, containerConfig)
559590 if err != nil {
@@ -2524,10 +2555,10 @@ func (s *Service) resolveProbeEndpoint(ctx context.Context, containerID string,
25242555 internalPort := 0
25252556 if containerConfig != nil {
25262557 if labels := containerConfig.Labels; labels != nil {
2527- for _, key := range []string{domain.LabelProxyPort, domain.LabelPort} {
2558+ for _, key := range []string{domain.LabelPort, domain.LabelProxyPort} {
25282559 if portStr, ok := labels[key]; ok && portStr != "" {
25292560 port, err := strconv.Atoi(portStr)
2530- if err == nil && port > 0 {
2561+ if err == nil && port > 0 && port <= 65535 {
25312562 internalPort = port
25322563 break
25332564 }
@@ -210,25 +210,47 @@ 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+ }
215 243
216 return &domain.ContainerConfig{244 return &domain.ContainerConfig{
217- Image: actualImageRef,245+ Image: in.ImageRef,
218 Name: containerName,246 Name: containerName,
219- Ports: exposedPorts,247+ Ports: in.ExposedPorts,
220- Env: envVars,248+ Env: in.EnvVars,
221- Volumes: volumes,249+ Volumes: in.Volumes,
222- NetworkMode: networkName,250+ NetworkMode: in.NetworkName,
223- Hostname: containerDomain,251+ Hostname: in.Domain,
224- Labels: map[string]string{252+ Labels: labels,
225- domain.LabelDomain: containerDomain,253+ AutoRemove: false,
226- domain.LabelEnvHash: envHash,
227- domain.LabelImage: image,
228- domain.LabelManaged: "true",
229- domain.LabelRoute: containerDomain,
230- },
231- AutoRemove: false,
232 }254 }
233 }255 }
234 256
@@ -416,6 +438,7 @@ type deployResources struct {
416 networkName string438 networkName string
417 actualImageRef string439 actualImageRef string
418 exposedPorts []int440 exposedPorts []int
441+ imageLabels map[string]string
419 envVars []string442 envVars []string
420 envHash string443 envHash string
421 volumes map[string]string444 volumes map[string]string
@@ -516,6 +539,12 @@ func (s *Service) prepareDeployResources(ctx context.Context, route domain.Route
516 exposedPorts = []int{80, 8080, 3000}539 exposedPorts = []int{80, 8080, 3000}
517 }540 }
518 541
542+ imageLabels, err := s.runtime.GetImageLabels(ctx, actualImageRef)
543+ if err != nil {
544+ log.Warn().Err(err).Msg("failed to get image labels, skipping label propagation")
545+ imageLabels = nil
546+ }
547+
519 envVars, err := s.loadEnvironment(ctx, route.Domain, actualImageRef)548 envVars, err := s.loadEnvironment(ctx, route.Domain, actualImageRef)
520 if err != nil {549 if err != nil {
521 return nil, err550 return nil, err
@@ -531,6 +560,7 @@ func (s *Service) prepareDeployResources(ctx context.Context, route domain.Route
531 networkName: networkName,560 networkName: networkName,
532 actualImageRef: actualImageRef,561 actualImageRef: actualImageRef,
533 exposedPorts: exposedPorts,562 exposedPorts: exposedPorts,
563+ imageLabels: imageLabels,
534 envVars: envVars,564 envVars: envVars,
535 envHash: envHash,565 envHash: envHash,
536 volumes: volumes,566 volumes: volumes,
@@ -543,17 +573,18 @@ func (s *Service) createStartedContainer(ctx context.Context, route domain.Route
543 573
544 log := zerowrap.FromCtx(ctx)574 log := zerowrap.FromCtx(ctx)
545 575
546- containerConfig := s.buildContainerConfig(576+ containerConfig := s.buildContainerConfig(containerConfigInput{
547- route.Domain,577+ Domain: route.Domain,
548- route.Image,578+ Image: route.Image,
549- resources.actualImageRef,579+ ImageRef: resources.actualImageRef,
550- resources.exposedPorts,580+ ExposedPorts: resources.exposedPorts,
551- resources.envVars,581+ EnvVars: resources.envVars,
552- resources.envHash,582+ EnvHash: resources.envHash,
553- resources.volumes,583+ Volumes: resources.volumes,
554- resources.networkName,584+ NetworkName: resources.networkName,
555- existing,585+ ImageLabels: resources.imageLabels,
556- )586+ Existing: existing,
587+ })
557 588
558 newContainer, err := s.runtime.CreateContainer(ctx, containerConfig)589 newContainer, err := s.runtime.CreateContainer(ctx, containerConfig)
559 if err != nil {590 if err != nil {
@@ -2524,10 +2555,10 @@ func (s *Service) resolveProbeEndpoint(ctx context.Context, containerID string,
2524 internalPort := 02555 internalPort := 0
2525 if containerConfig != nil {2556 if containerConfig != nil {
2526 if labels := containerConfig.Labels; labels != nil {2557 if labels := containerConfig.Labels; labels != nil {
2527- for _, key := range []string{domain.LabelProxyPort, domain.LabelPort} {2558+ for _, key := range []string{domain.LabelPort, domain.LabelProxyPort} {
2528 if portStr, ok := labels[key]; ok && portStr != "" {2559 if portStr, ok := labels[key]; ok && portStr != "" {
2529 port, err := strconv.Atoi(portStr)2560 port, err := strconv.Atoi(portStr)
2530- if err == nil && port > 0 {2561+ if err == nil && port > 0 && port <= 65535 {
2531 internalPort = port2562 internalPort = port
2532 break2563 break
2533 }2564 }
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_ProbeUsesGordonPortLabel(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.port label — should be used for probe endpoint
235+ containerConfig := &domain.ContainerConfig{
236+ Ports: []int{22, 3000},
237+ Labels: map[string]string{
238+ domain.LabelPort: "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_ProbeUsesDeprecatedProxyPortLabel(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.proxy.port — should still work
268+ containerConfig := &domain.ContainerConfig{
269+ Ports: []int{22, 3000},
270+ Labels: map[string]string{
271+ domain.LabelProxyPort: "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_GordonPortTakesPriorityOverProxyPort(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.port (8080) should win over gordon.proxy.port (3000)
298+ containerConfig := &domain.ContainerConfig{
299+ Ports: []int{22, 3000, 8080},
300+ Labels: map[string]string{
301+ domain.LabelPort: "8080",
302+ domain.LabelProxyPort: "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.port=8080, NOT gordon.proxy.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_ProbeUsesGordonPortLabel(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.port label — should be used for probe endpoint
235+ containerConfig := &domain.ContainerConfig{
236+ Ports: []int{22, 3000},
237+ Labels: map[string]string{
238+ domain.LabelPort: "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_ProbeUsesDeprecatedProxyPortLabel(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.proxy.port — should still work
268+ containerConfig := &domain.ContainerConfig{
269+ Ports: []int{22, 3000},
270+ Labels: map[string]string{
271+ domain.LabelProxyPort: "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_GordonPortTakesPriorityOverProxyPort(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.port (8080) should win over gordon.proxy.port (3000)
298+ containerConfig := &domain.ContainerConfig{
299+ Ports: []int{22, 3000, 8080},
300+ Labels: map[string]string{
301+ domain.LabelPort: "8080",
302+ domain.LabelProxyPort: "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.port=8080, NOT gordon.proxy.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.port and gordon.health
3412+ runtime.EXPECT().GetImageLabels(mock.Anything, "gitea/gitea:latest").Return(map[string]string{
3413+ domain.LabelPort: "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.port was propagated from image labels
3431+ if cfg.Labels[domain.LabelPort] != "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.port and gordon.health
3412+ runtime.EXPECT().GetImageLabels(mock.Anything, "gitea/gitea:latest").Return(map[string]string{
3413+ domain.LabelPort: "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.port was propagated from image labels
3431+ if cfg.Labels[domain.LabelPort] != "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.port first, then the deprecated gordon.proxy.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.port preferred, gordon.proxy.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.LabelPort, domain.LabelProxyPort} {
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.port first, then the deprecated gordon.proxy.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.port preferred, gordon.proxy.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.LabelPort, domain.LabelProxyPort} {
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_UsesGordonPortLabel(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.port=3000 label — should use 3000, not first exposed port
599+ runtime.EXPECT().GetImageLabels(mock.Anything, "gitea/gitea:latest").Return(map[string]string{
600+ domain.LabelPort: "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_UsesDeprecatedProxyPortLabel(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.proxy.port label — should still work
635+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(map[string]string{
636+ domain.LabelProxyPort: "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_GordonPortWinsOverProxyPort(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.port=9000 should win over gordon.proxy.port=3000
668+ runtime.EXPECT().GetImageLabels(mock.Anything, "dualapp:latest").Return(map[string]string{
669+ domain.LabelPort: "9000",
670+ domain.LabelProxyPort: "3000",
671+ }, nil)
672+
673+ // Should use 9000 (gordon.port), NOT 3000 (gordon.proxy.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_UsesGordonPortLabel(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.port=3000 label — should use 3000, not first exposed port
599+ runtime.EXPECT().GetImageLabels(mock.Anything, "gitea/gitea:latest").Return(map[string]string{
600+ domain.LabelPort: "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_UsesDeprecatedProxyPortLabel(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.proxy.port label — should still work
635+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(map[string]string{
636+ domain.LabelProxyPort: "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_GordonPortWinsOverProxyPort(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.port=9000 should win over gordon.proxy.port=3000
668+ runtime.EXPECT().GetImageLabels(mock.Anything, "dualapp:latest").Return(map[string]string{
669+ domain.LabelPort: "9000",
670+ domain.LabelProxyPort: "3000",
671+ }, nil)
672+
673+ // Should use 9000 (gordon.port), NOT 3000 (gordon.proxy.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