detached 9d2bfe7

refactor(proxy): replace getProxyPort with resolveTargetMetadata returning port+protocol

commit
9d2bfe7e65655f61d1e651e23197b3b095d309ec
author
brice · · 2026-03-15 22:32 UTC
parent
a05dc46
signature
unsigned
4 files · +286 -47
M internal/usecase/proxy/service.go +9 -47
@@ -211,10 +211,9 @@ 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- // Determine target port: check for explicit label first, then fall back to network detection
215- targetPort, err := s.getProxyPort(ctx, container.Image)
214+ meta, err := s.resolveTargetMetadata(ctx, container.Image)
216215 if err != nil {
217- return nil, log.WrapErr(err, "failed to determine proxy port")
216+ return nil, log.WrapErr(err, "failed to resolve target metadata")
218217 }
219218 containerIP, _, err := s.runtime.GetContainerNetworkInfo(ctx, container.ID)
220219 if err != nil {
@@ -222,9 +221,10 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (target *dom
222221 }
223222 target = &domain.ProxyTarget{
224223 Host: containerIP,
225- Port: targetPort,
224+ Port: meta.Port,
226225 ContainerID: container.ID,
227226 Scheme: "http",
227+ Protocol: meta.Protocol,
228228 }
229229 } else {
230230 // Gordon is on the host - use host port mapping
@@ -241,16 +241,14 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (target *dom
241241 return nil, domain.ErrRouteNotFound
242242 }
243243
244- // Determine target port: check for label first, then fall back to first exposed port
245- // Use container.Image (the actual running image) instead of route.Image (config shorthand)
246- targetPort, err := s.getProxyPort(ctx, container.Image)
244+ meta, err := s.resolveTargetMetadata(ctx, container.Image)
247245 if err != nil {
248- return nil, log.WrapErr(err, "failed to determine proxy port")
246+ return nil, log.WrapErr(err, "failed to resolve target metadata")
249247 }
250248
251- hostPort, err := s.runtime.GetContainerPort(ctx, container.ID, targetPort)
249+ hostPort, err := s.runtime.GetContainerPort(ctx, container.ID, meta.Port)
252250 if err != nil {
253- return nil, log.WrapErrWithFields(err, "failed to get host port mapping", map[string]any{"internal_port": targetPort})
251+ return nil, log.WrapErrWithFields(err, "failed to get host port mapping", map[string]any{"internal_port": meta.Port})
254252 }
255253
256254 target = &domain.ProxyTarget{
@@ -258,6 +256,7 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (target *dom
258256 Port: hostPort,
259257 ContainerID: container.ID,
260258 Scheme: "http",
259+ Protocol: meta.Protocol,
261260 }
262261 }
263262
@@ -691,40 +690,3 @@ func (s *Service) isRunningInContainer() bool {
691690
692691 return false
693692 }
694-
695-// getProxyPort determines the port to proxy to for an image.
696-// It checks for gordon.proxy.port first, then the deprecated gordon.port alias, then falls back to the first exposed port.
697-func (s *Service) getProxyPort(ctx context.Context, imageRef string) (int, error) {
698- log := zerowrap.FromCtx(ctx)
699-
700- log.Debug().Str("image_ref", imageRef).Msg("determining proxy port for image")
701-
702- // Check for explicit port label (gordon.proxy.port preferred, gordon.port as deprecated alias)
703- labels, err := s.runtime.GetImageLabels(ctx, imageRef)
704- if err != nil {
705- log.Debug().Err(err).Msg("failed to get image labels, falling back to exposed ports")
706- } else {
707- for _, key := range []string{domain.LabelProxyPort, domain.LabelPort} {
708- if portStr, ok := labels[key]; ok && portStr != "" {
709- port, err := strconv.Atoi(portStr)
710- if err == nil && port > 0 && port <= 65535 {
711- log.Debug().Int("port", port).Str("label", key).Msg("using proxy port from image label")
712- return port, nil
713- }
714- log.Warn().Str("label", key).Str("port_value", portStr).Msg("invalid port label value")
715- }
716- }
717- }
718-
719- // Fall back to first exposed port
720- exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, imageRef)
721- if err != nil {
722- return 0, err
723- }
724- if len(exposedPorts) == 0 {
725- return 0, fmt.Errorf("no exposed ports found for image %s", imageRef)
726- }
727-
728- log.Debug().Int("port", exposedPorts[0]).Msg("using first exposed port")
729- return exposedPorts[0], nil
730-}
@@ -211,10 +211,9 @@ 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- // Determine target port: check for explicit label first, then fall back to network detection214+ meta, err := s.resolveTargetMetadata(ctx, container.Image)
215- targetPort, err := s.getProxyPort(ctx, container.Image)
216 if err != nil {215 if err != nil {
217- return nil, log.WrapErr(err, "failed to determine proxy port")216+ return nil, log.WrapErr(err, "failed to resolve target metadata")
218 }217 }
219 containerIP, _, err := s.runtime.GetContainerNetworkInfo(ctx, container.ID)218 containerIP, _, err := s.runtime.GetContainerNetworkInfo(ctx, container.ID)
220 if err != nil {219 if err != nil {
@@ -222,9 +221,10 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (target *dom
222 }221 }
223 target = &domain.ProxyTarget{222 target = &domain.ProxyTarget{
224 Host: containerIP,223 Host: containerIP,
225- Port: targetPort,224+ Port: meta.Port,
226 ContainerID: container.ID,225 ContainerID: container.ID,
227 Scheme: "http",226 Scheme: "http",
227+ Protocol: meta.Protocol,
228 }228 }
229 } else {229 } else {
230 // Gordon is on the host - use host port mapping230 // Gordon is on the host - use host port mapping
@@ -241,16 +241,14 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (target *dom
241 return nil, domain.ErrRouteNotFound241 return nil, domain.ErrRouteNotFound
242 }242 }
243 243
244- // Determine target port: check for label first, then fall back to first exposed port244+ meta, err := s.resolveTargetMetadata(ctx, container.Image)
245- // Use container.Image (the actual running image) instead of route.Image (config shorthand)
246- targetPort, err := s.getProxyPort(ctx, container.Image)
247 if err != nil {245 if err != nil {
248- return nil, log.WrapErr(err, "failed to determine proxy port")246+ return nil, log.WrapErr(err, "failed to resolve target metadata")
249 }247 }
250 248
251- hostPort, err := s.runtime.GetContainerPort(ctx, container.ID, targetPort)249+ hostPort, err := s.runtime.GetContainerPort(ctx, container.ID, meta.Port)
252 if err != nil {250 if err != nil {
253- return nil, log.WrapErrWithFields(err, "failed to get host port mapping", map[string]any{"internal_port": targetPort})251+ return nil, log.WrapErrWithFields(err, "failed to get host port mapping", map[string]any{"internal_port": meta.Port})
254 }252 }
255 253
256 target = &domain.ProxyTarget{254 target = &domain.ProxyTarget{
@@ -258,6 +256,7 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (target *dom
258 Port: hostPort,256 Port: hostPort,
259 ContainerID: container.ID,257 ContainerID: container.ID,
260 Scheme: "http",258 Scheme: "http",
259+ Protocol: meta.Protocol,
261 }260 }
262 }261 }
263 262
@@ -691,40 +690,3 @@ func (s *Service) isRunningInContainer() bool {
691 690
692 return false691 return false
693 }692 }
694-
695-// getProxyPort determines the port to proxy to for an image.
696-// It checks for gordon.proxy.port first, then the deprecated gordon.port alias, then falls back to the first exposed port.
697-func (s *Service) getProxyPort(ctx context.Context, imageRef string) (int, error) {
698- log := zerowrap.FromCtx(ctx)
699-
700- log.Debug().Str("image_ref", imageRef).Msg("determining proxy port for image")
701-
702- // Check for explicit port label (gordon.proxy.port preferred, gordon.port as deprecated alias)
703- labels, err := s.runtime.GetImageLabels(ctx, imageRef)
704- if err != nil {
705- log.Debug().Err(err).Msg("failed to get image labels, falling back to exposed ports")
706- } else {
707- for _, key := range []string{domain.LabelProxyPort, domain.LabelPort} {
708- if portStr, ok := labels[key]; ok && portStr != "" {
709- port, err := strconv.Atoi(portStr)
710- if err == nil && port > 0 && port <= 65535 {
711- log.Debug().Int("port", port).Str("label", key).Msg("using proxy port from image label")
712- return port, nil
713- }
714- log.Warn().Str("label", key).Str("port_value", portStr).Msg("invalid port label value")
715- }
716- }
717- }
718-
719- // Fall back to first exposed port
720- exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, imageRef)
721- if err != nil {
722- return 0, err
723- }
724- if len(exposedPorts) == 0 {
725- return 0, fmt.Errorf("no exposed ports found for image %s", imageRef)
726- }
727-
728- log.Debug().Int("port", exposedPorts[0]).Msg("using first exposed port")
729- return exposedPorts[0], nil
730-}
M internal/usecase/proxy/service_test.go +66 -0
@@ -710,3 +710,69 @@ func TestService_GetTarget_HostMode_FallsBackToExposedPort(t *testing.T) {
710710 assert.NoError(t, err)
711711 assert.Equal(t, 35000, result.Port)
712712 }
713+
714+func TestService_GetTarget_HostMode_H2CProtocolPropagated(t *testing.T) {
715+ runtime := outmocks.NewMockContainerRuntime(t)
716+ containerSvc := inmocks.NewMockContainerService(t)
717+ configSvc := inmocks.NewMockConfigService(t)
718+
719+ svc := NewService(runtime, containerSvc, configSvc, Config{})
720+ ctx := testContext()
721+
722+ configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{})
723+
724+ container := &domain.Container{
725+ ID: "c-grpc",
726+ Image: "grpc-app:latest",
727+ }
728+ containerSvc.EXPECT().Get(mock.Anything, "grpc.example.com").Return(container, true)
729+
730+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
731+ {Domain: "grpc.example.com", Image: "grpc-app:latest"},
732+ })
733+
734+ runtime.EXPECT().GetImageLabels(mock.Anything, "grpc-app:latest").Return(map[string]string{
735+ domain.LabelProxyPort: "50051",
736+ domain.LabelProxyProtocol: "h2c",
737+ }, nil)
738+
739+ runtime.EXPECT().GetContainerPort(mock.Anything, "c-grpc", 50051).Return(50051, nil)
740+
741+ result, err := svc.GetTarget(ctx, "grpc.example.com")
742+
743+ assert.NoError(t, err)
744+ assert.Equal(t, "h2c", result.Protocol)
745+ assert.Equal(t, 50051, result.Port)
746+}
747+
748+func TestService_GetTarget_HostMode_DefaultProtocol(t *testing.T) {
749+ runtime := outmocks.NewMockContainerRuntime(t)
750+ containerSvc := inmocks.NewMockContainerService(t)
751+ configSvc := inmocks.NewMockConfigService(t)
752+
753+ svc := NewService(runtime, containerSvc, configSvc, Config{})
754+ ctx := testContext()
755+
756+ configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{})
757+
758+ container := &domain.Container{
759+ ID: "c-web",
760+ Image: "web:latest",
761+ }
762+ containerSvc.EXPECT().Get(mock.Anything, "web.example.com").Return(container, true)
763+
764+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
765+ {Domain: "web.example.com", Image: "web:latest"},
766+ })
767+
768+ runtime.EXPECT().GetImageLabels(mock.Anything, "web:latest").Return(map[string]string{
769+ domain.LabelProxyPort: "8080",
770+ }, nil)
771+
772+ runtime.EXPECT().GetContainerPort(mock.Anything, "c-web", 8080).Return(8080, nil)
773+
774+ result, err := svc.GetTarget(ctx, "web.example.com")
775+
776+ assert.NoError(t, err)
777+ assert.Equal(t, "", result.Protocol)
778+}
@@ -710,3 +710,69 @@ func TestService_GetTarget_HostMode_FallsBackToExposedPort(t *testing.T) {
710 assert.NoError(t, err)710 assert.NoError(t, err)
711 assert.Equal(t, 35000, result.Port)711 assert.Equal(t, 35000, result.Port)
712 }712 }
713+
714+func TestService_GetTarget_HostMode_H2CProtocolPropagated(t *testing.T) {
715+ runtime := outmocks.NewMockContainerRuntime(t)
716+ containerSvc := inmocks.NewMockContainerService(t)
717+ configSvc := inmocks.NewMockConfigService(t)
718+
719+ svc := NewService(runtime, containerSvc, configSvc, Config{})
720+ ctx := testContext()
721+
722+ configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{})
723+
724+ container := &domain.Container{
725+ ID: "c-grpc",
726+ Image: "grpc-app:latest",
727+ }
728+ containerSvc.EXPECT().Get(mock.Anything, "grpc.example.com").Return(container, true)
729+
730+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
731+ {Domain: "grpc.example.com", Image: "grpc-app:latest"},
732+ })
733+
734+ runtime.EXPECT().GetImageLabels(mock.Anything, "grpc-app:latest").Return(map[string]string{
735+ domain.LabelProxyPort: "50051",
736+ domain.LabelProxyProtocol: "h2c",
737+ }, nil)
738+
739+ runtime.EXPECT().GetContainerPort(mock.Anything, "c-grpc", 50051).Return(50051, nil)
740+
741+ result, err := svc.GetTarget(ctx, "grpc.example.com")
742+
743+ assert.NoError(t, err)
744+ assert.Equal(t, "h2c", result.Protocol)
745+ assert.Equal(t, 50051, result.Port)
746+}
747+
748+func TestService_GetTarget_HostMode_DefaultProtocol(t *testing.T) {
749+ runtime := outmocks.NewMockContainerRuntime(t)
750+ containerSvc := inmocks.NewMockContainerService(t)
751+ configSvc := inmocks.NewMockConfigService(t)
752+
753+ svc := NewService(runtime, containerSvc, configSvc, Config{})
754+ ctx := testContext()
755+
756+ configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{})
757+
758+ container := &domain.Container{
759+ ID: "c-web",
760+ Image: "web:latest",
761+ }
762+ containerSvc.EXPECT().Get(mock.Anything, "web.example.com").Return(container, true)
763+
764+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
765+ {Domain: "web.example.com", Image: "web:latest"},
766+ })
767+
768+ runtime.EXPECT().GetImageLabels(mock.Anything, "web:latest").Return(map[string]string{
769+ domain.LabelProxyPort: "8080",
770+ }, nil)
771+
772+ runtime.EXPECT().GetContainerPort(mock.Anything, "c-web", 8080).Return(8080, nil)
773+
774+ result, err := svc.GetTarget(ctx, "web.example.com")
775+
776+ assert.NoError(t, err)
777+ assert.Equal(t, "", result.Protocol)
778+}
A internal/usecase/proxy/target_metadata.go +74 -0
@@ -0,0 +1,74 @@
1+package proxy
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strconv"
7+
8+ "github.com/bnema/zerowrap"
9+
10+ "github.com/bnema/gordon/internal/domain"
11+)
12+
13+// TargetMetadata holds resolved backend connection metadata for a container image.
14+type TargetMetadata struct {
15+ Port int // The port to proxy traffic to
16+ Protocol string // "" for HTTP/1.1, "h2c" for cleartext HTTP/2
17+}
18+
19+// resolveTargetMetadata determines the port and protocol for an image by reading
20+// image labels. It checks gordon.proxy.port first, then the deprecated gordon.port
21+// alias, then falls back to the first exposed port. Protocol is read from
22+// gordon.proxy.protocol (only "h2c" is recognized; unknown values are ignored).
23+func (s *Service) resolveTargetMetadata(ctx context.Context, imageRef string) (TargetMetadata, error) {
24+ log := zerowrap.FromCtx(ctx)
25+ log.Debug().Str("image_ref", imageRef).Msg("resolving target metadata for image")
26+
27+ labels, err := s.runtime.GetImageLabels(ctx, imageRef)
28+ if err != nil {
29+ log.Debug().Err(err).Msg("failed to get image labels, falling back to exposed ports")
30+ labels = nil
31+ }
32+
33+ port := 0
34+ if labels != nil {
35+ for _, key := range []string{domain.LabelProxyPort, domain.LabelPort} {
36+ if portStr, ok := labels[key]; ok && portStr != "" {
37+ p, convErr := strconv.Atoi(portStr)
38+ if convErr == nil && p > 0 && p <= 65535 {
39+ log.Debug().Int("port", p).Str("label", key).Msg("using proxy port from image label")
40+ port = p
41+ break
42+ }
43+ log.Warn().Str("label", key).Str("port_value", portStr).Msg("invalid port label value")
44+ }
45+ }
46+ }
47+
48+ if port == 0 {
49+ exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, imageRef)
50+ if err != nil {
51+ return TargetMetadata{}, err
52+ }
53+ if len(exposedPorts) == 0 {
54+ return TargetMetadata{}, fmt.Errorf("no exposed ports found for image %s", imageRef)
55+ }
56+ port = exposedPorts[0]
57+ log.Debug().Int("port", port).Msg("using first exposed port")
58+ }
59+
60+ protocol := ""
61+ if labels != nil {
62+ proto := labels[domain.LabelProxyProtocol]
63+ switch proto {
64+ case "h2c":
65+ protocol = "h2c"
66+ log.Debug().Str("protocol", proto).Msg("container opts into h2c")
67+ case "":
68+ default:
69+ log.Warn().Str("protocol", proto).Msg("unknown proxy protocol label value, ignoring")
70+ }
71+ }
72+
73+ return TargetMetadata{Port: port, Protocol: protocol}, nil
74+}
@@ -0,0 +1,74 @@
1+package proxy
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strconv"
7+
8+ "github.com/bnema/zerowrap"
9+
10+ "github.com/bnema/gordon/internal/domain"
11+)
12+
13+// TargetMetadata holds resolved backend connection metadata for a container image.
14+type TargetMetadata struct {
15+ Port int // The port to proxy traffic to
16+ Protocol string // "" for HTTP/1.1, "h2c" for cleartext HTTP/2
17+}
18+
19+// resolveTargetMetadata determines the port and protocol for an image by reading
20+// image labels. It checks gordon.proxy.port first, then the deprecated gordon.port
21+// alias, then falls back to the first exposed port. Protocol is read from
22+// gordon.proxy.protocol (only "h2c" is recognized; unknown values are ignored).
23+func (s *Service) resolveTargetMetadata(ctx context.Context, imageRef string) (TargetMetadata, error) {
24+ log := zerowrap.FromCtx(ctx)
25+ log.Debug().Str("image_ref", imageRef).Msg("resolving target metadata for image")
26+
27+ labels, err := s.runtime.GetImageLabels(ctx, imageRef)
28+ if err != nil {
29+ log.Debug().Err(err).Msg("failed to get image labels, falling back to exposed ports")
30+ labels = nil
31+ }
32+
33+ port := 0
34+ if labels != nil {
35+ for _, key := range []string{domain.LabelProxyPort, domain.LabelPort} {
36+ if portStr, ok := labels[key]; ok && portStr != "" {
37+ p, convErr := strconv.Atoi(portStr)
38+ if convErr == nil && p > 0 && p <= 65535 {
39+ log.Debug().Int("port", p).Str("label", key).Msg("using proxy port from image label")
40+ port = p
41+ break
42+ }
43+ log.Warn().Str("label", key).Str("port_value", portStr).Msg("invalid port label value")
44+ }
45+ }
46+ }
47+
48+ if port == 0 {
49+ exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, imageRef)
50+ if err != nil {
51+ return TargetMetadata{}, err
52+ }
53+ if len(exposedPorts) == 0 {
54+ return TargetMetadata{}, fmt.Errorf("no exposed ports found for image %s", imageRef)
55+ }
56+ port = exposedPorts[0]
57+ log.Debug().Int("port", port).Msg("using first exposed port")
58+ }
59+
60+ protocol := ""
61+ if labels != nil {
62+ proto := labels[domain.LabelProxyProtocol]
63+ switch proto {
64+ case "h2c":
65+ protocol = "h2c"
66+ log.Debug().Str("protocol", proto).Msg("container opts into h2c")
67+ case "":
68+ default:
69+ log.Warn().Str("protocol", proto).Msg("unknown proxy protocol label value, ignoring")
70+ }
71+ }
72+
73+ return TargetMetadata{Port: port, Protocol: protocol}, nil
74+}
A internal/usecase/proxy/target_metadata_test.go +137 -0
@@ -0,0 +1,137 @@
1+package proxy
2+
3+import (
4+ "fmt"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+
9+ outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
10+ "github.com/bnema/gordon/internal/domain"
11+)
12+
13+func TestResolveTargetMetadata_PortAndProtocolFromLabels(t *testing.T) {
14+ runtime := outmocks.NewMockContainerRuntime(t)
15+ svc := &Service{runtime: runtime}
16+ ctx := testContext()
17+
18+ runtime.EXPECT().GetImageLabels(ctx, "grpc-app:latest").Return(map[string]string{
19+ domain.LabelProxyPort: "50051",
20+ domain.LabelProxyProtocol: "h2c",
21+ }, nil)
22+
23+ meta, err := svc.resolveTargetMetadata(ctx, "grpc-app:latest")
24+
25+ assert.NoError(t, err)
26+ assert.Equal(t, 50051, meta.Port)
27+ assert.Equal(t, "h2c", meta.Protocol)
28+}
29+
30+func TestResolveTargetMetadata_DefaultProtocolWhenAbsent(t *testing.T) {
31+ runtime := outmocks.NewMockContainerRuntime(t)
32+ svc := &Service{runtime: runtime}
33+ ctx := testContext()
34+
35+ runtime.EXPECT().GetImageLabels(ctx, "web:latest").Return(map[string]string{
36+ domain.LabelProxyPort: "8080",
37+ }, nil)
38+
39+ meta, err := svc.resolveTargetMetadata(ctx, "web:latest")
40+
41+ assert.NoError(t, err)
42+ assert.Equal(t, 8080, meta.Port)
43+ assert.Equal(t, "", meta.Protocol)
44+}
45+
46+func TestResolveTargetMetadata_DeprecatedPortLabel(t *testing.T) {
47+ runtime := outmocks.NewMockContainerRuntime(t)
48+ svc := &Service{runtime: runtime}
49+ ctx := testContext()
50+
51+ runtime.EXPECT().GetImageLabels(ctx, "old:latest").Return(map[string]string{
52+ domain.LabelPort: "3000",
53+ }, nil)
54+
55+ meta, err := svc.resolveTargetMetadata(ctx, "old:latest")
56+
57+ assert.NoError(t, err)
58+ assert.Equal(t, 3000, meta.Port)
59+ assert.Equal(t, "", meta.Protocol)
60+}
61+
62+func TestResolveTargetMetadata_ProxyPortWinsOverDeprecated(t *testing.T) {
63+ runtime := outmocks.NewMockContainerRuntime(t)
64+ svc := &Service{runtime: runtime}
65+ ctx := testContext()
66+
67+ runtime.EXPECT().GetImageLabels(ctx, "dual:latest").Return(map[string]string{
68+ domain.LabelProxyPort: "9000",
69+ domain.LabelPort: "3000",
70+ }, nil)
71+
72+ meta, err := svc.resolveTargetMetadata(ctx, "dual:latest")
73+
74+ assert.NoError(t, err)
75+ assert.Equal(t, 9000, meta.Port)
76+}
77+
78+func TestResolveTargetMetadata_FallsBackToExposedPort(t *testing.T) {
79+ runtime := outmocks.NewMockContainerRuntime(t)
80+ svc := &Service{runtime: runtime}
81+ ctx := testContext()
82+
83+ runtime.EXPECT().GetImageLabels(ctx, "plain:latest").Return(map[string]string{}, nil)
84+ runtime.EXPECT().GetImageExposedPorts(ctx, "plain:latest").Return([]int{8080}, nil)
85+
86+ meta, err := svc.resolveTargetMetadata(ctx, "plain:latest")
87+
88+ assert.NoError(t, err)
89+ assert.Equal(t, 8080, meta.Port)
90+ assert.Equal(t, "", meta.Protocol)
91+}
92+
93+func TestResolveTargetMetadata_UnknownProtocolIgnored(t *testing.T) {
94+ runtime := outmocks.NewMockContainerRuntime(t)
95+ svc := &Service{runtime: runtime}
96+ ctx := testContext()
97+
98+ runtime.EXPECT().GetImageLabels(ctx, "exotic:latest").Return(map[string]string{
99+ domain.LabelProxyPort: "8080",
100+ domain.LabelProxyProtocol: "quic",
101+ }, nil)
102+
103+ meta, err := svc.resolveTargetMetadata(ctx, "exotic:latest")
104+
105+ assert.NoError(t, err)
106+ assert.Equal(t, 8080, meta.Port)
107+ assert.Equal(t, "", meta.Protocol)
108+}
109+
110+func TestResolveTargetMetadata_NoPortsAvailable(t *testing.T) {
111+ runtime := outmocks.NewMockContainerRuntime(t)
112+ svc := &Service{runtime: runtime}
113+ ctx := testContext()
114+
115+ runtime.EXPECT().GetImageLabels(ctx, "empty:latest").Return(map[string]string{}, nil)
116+ runtime.EXPECT().GetImageExposedPorts(ctx, "empty:latest").Return([]int{}, nil)
117+
118+ _, err := svc.resolveTargetMetadata(ctx, "empty:latest")
119+
120+ assert.Error(t, err)
121+ assert.Contains(t, err.Error(), "no exposed ports")
122+}
123+
124+func TestResolveTargetMetadata_LabelErrorFallsBackToExposedPort(t *testing.T) {
125+ runtime := outmocks.NewMockContainerRuntime(t)
126+ svc := &Service{runtime: runtime}
127+ ctx := testContext()
128+
129+ runtime.EXPECT().GetImageLabels(ctx, "broken:latest").Return(nil, fmt.Errorf("image inspect failed"))
130+ runtime.EXPECT().GetImageExposedPorts(ctx, "broken:latest").Return([]int{3000}, nil)
131+
132+ meta, err := svc.resolveTargetMetadata(ctx, "broken:latest")
133+
134+ assert.NoError(t, err)
135+ assert.Equal(t, 3000, meta.Port)
136+ assert.Equal(t, "", meta.Protocol)
137+}
@@ -0,0 +1,137 @@
1+package proxy
2+
3+import (
4+ "fmt"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+
9+ outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
10+ "github.com/bnema/gordon/internal/domain"
11+)
12+
13+func TestResolveTargetMetadata_PortAndProtocolFromLabels(t *testing.T) {
14+ runtime := outmocks.NewMockContainerRuntime(t)
15+ svc := &Service{runtime: runtime}
16+ ctx := testContext()
17+
18+ runtime.EXPECT().GetImageLabels(ctx, "grpc-app:latest").Return(map[string]string{
19+ domain.LabelProxyPort: "50051",
20+ domain.LabelProxyProtocol: "h2c",
21+ }, nil)
22+
23+ meta, err := svc.resolveTargetMetadata(ctx, "grpc-app:latest")
24+
25+ assert.NoError(t, err)
26+ assert.Equal(t, 50051, meta.Port)
27+ assert.Equal(t, "h2c", meta.Protocol)
28+}
29+
30+func TestResolveTargetMetadata_DefaultProtocolWhenAbsent(t *testing.T) {
31+ runtime := outmocks.NewMockContainerRuntime(t)
32+ svc := &Service{runtime: runtime}
33+ ctx := testContext()
34+
35+ runtime.EXPECT().GetImageLabels(ctx, "web:latest").Return(map[string]string{
36+ domain.LabelProxyPort: "8080",
37+ }, nil)
38+
39+ meta, err := svc.resolveTargetMetadata(ctx, "web:latest")
40+
41+ assert.NoError(t, err)
42+ assert.Equal(t, 8080, meta.Port)
43+ assert.Equal(t, "", meta.Protocol)
44+}
45+
46+func TestResolveTargetMetadata_DeprecatedPortLabel(t *testing.T) {
47+ runtime := outmocks.NewMockContainerRuntime(t)
48+ svc := &Service{runtime: runtime}
49+ ctx := testContext()
50+
51+ runtime.EXPECT().GetImageLabels(ctx, "old:latest").Return(map[string]string{
52+ domain.LabelPort: "3000",
53+ }, nil)
54+
55+ meta, err := svc.resolveTargetMetadata(ctx, "old:latest")
56+
57+ assert.NoError(t, err)
58+ assert.Equal(t, 3000, meta.Port)
59+ assert.Equal(t, "", meta.Protocol)
60+}
61+
62+func TestResolveTargetMetadata_ProxyPortWinsOverDeprecated(t *testing.T) {
63+ runtime := outmocks.NewMockContainerRuntime(t)
64+ svc := &Service{runtime: runtime}
65+ ctx := testContext()
66+
67+ runtime.EXPECT().GetImageLabels(ctx, "dual:latest").Return(map[string]string{
68+ domain.LabelProxyPort: "9000",
69+ domain.LabelPort: "3000",
70+ }, nil)
71+
72+ meta, err := svc.resolveTargetMetadata(ctx, "dual:latest")
73+
74+ assert.NoError(t, err)
75+ assert.Equal(t, 9000, meta.Port)
76+}
77+
78+func TestResolveTargetMetadata_FallsBackToExposedPort(t *testing.T) {
79+ runtime := outmocks.NewMockContainerRuntime(t)
80+ svc := &Service{runtime: runtime}
81+ ctx := testContext()
82+
83+ runtime.EXPECT().GetImageLabels(ctx, "plain:latest").Return(map[string]string{}, nil)
84+ runtime.EXPECT().GetImageExposedPorts(ctx, "plain:latest").Return([]int{8080}, nil)
85+
86+ meta, err := svc.resolveTargetMetadata(ctx, "plain:latest")
87+
88+ assert.NoError(t, err)
89+ assert.Equal(t, 8080, meta.Port)
90+ assert.Equal(t, "", meta.Protocol)
91+}
92+
93+func TestResolveTargetMetadata_UnknownProtocolIgnored(t *testing.T) {
94+ runtime := outmocks.NewMockContainerRuntime(t)
95+ svc := &Service{runtime: runtime}
96+ ctx := testContext()
97+
98+ runtime.EXPECT().GetImageLabels(ctx, "exotic:latest").Return(map[string]string{
99+ domain.LabelProxyPort: "8080",
100+ domain.LabelProxyProtocol: "quic",
101+ }, nil)
102+
103+ meta, err := svc.resolveTargetMetadata(ctx, "exotic:latest")
104+
105+ assert.NoError(t, err)
106+ assert.Equal(t, 8080, meta.Port)
107+ assert.Equal(t, "", meta.Protocol)
108+}
109+
110+func TestResolveTargetMetadata_NoPortsAvailable(t *testing.T) {
111+ runtime := outmocks.NewMockContainerRuntime(t)
112+ svc := &Service{runtime: runtime}
113+ ctx := testContext()
114+
115+ runtime.EXPECT().GetImageLabels(ctx, "empty:latest").Return(map[string]string{}, nil)
116+ runtime.EXPECT().GetImageExposedPorts(ctx, "empty:latest").Return([]int{}, nil)
117+
118+ _, err := svc.resolveTargetMetadata(ctx, "empty:latest")
119+
120+ assert.Error(t, err)
121+ assert.Contains(t, err.Error(), "no exposed ports")
122+}
123+
124+func TestResolveTargetMetadata_LabelErrorFallsBackToExposedPort(t *testing.T) {
125+ runtime := outmocks.NewMockContainerRuntime(t)
126+ svc := &Service{runtime: runtime}
127+ ctx := testContext()
128+
129+ runtime.EXPECT().GetImageLabels(ctx, "broken:latest").Return(nil, fmt.Errorf("image inspect failed"))
130+ runtime.EXPECT().GetImageExposedPorts(ctx, "broken:latest").Return([]int{3000}, nil)
131+
132+ meta, err := svc.resolveTargetMetadata(ctx, "broken:latest")
133+
134+ assert.NoError(t, err)
135+ assert.Equal(t, 3000, meta.Port)
136+ assert.Equal(t, "", meta.Protocol)
137+}

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