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 | |||
| 211 | 211 | ||
| 212 | 212 | if s.isRunningInContainer() { | |
| 213 | 213 | // 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) | |
| 216 | 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 | 218 | containerIP, _, err := s.runtime.GetContainerNetworkInfo(ctx, container.ID) | |
| 220 | 219 | if err != nil { | |
| @@ -222,9 +221,10 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (target *dom | |||
| 222 | 221 | } | |
| 223 | 222 | target = &domain.ProxyTarget{ | |
| 224 | 223 | Host: containerIP, | |
| 225 | - | Port: targetPort, | |
| 224 | + | Port: meta.Port, | |
| 226 | 225 | ContainerID: container.ID, | |
| 227 | 226 | Scheme: "http", | |
| 227 | + | Protocol: meta.Protocol, | |
| 228 | 228 | } | |
| 229 | 229 | } else { | |
| 230 | 230 | // 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 | 241 | return nil, domain.ErrRouteNotFound | |
| 242 | 242 | } | |
| 243 | 243 | ||
| 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) | |
| 247 | 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 | 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 | 254 | target = &domain.ProxyTarget{ | |
| @@ -258,6 +256,7 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (target *dom | |||
| 258 | 256 | Port: hostPort, | |
| 259 | 257 | ContainerID: container.ID, | |
| 260 | 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 | 691 | 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 | - | } | |
| @@ -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 network | 213 | // Gordon is in a container - use container network | ||
| 214 | - | // Determine target port: check for explicit label first, then fall back to network detection | 214 | + | 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 mapping | 230 | // 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.ErrRouteNotFound | 241 | return nil, domain.ErrRouteNotFound | ||
| 242 | } | 242 | } | ||
| 243 | 243 | ||||
| 244 | - | // Determine target port: check for label first, then fall back to first exposed port | 244 | + | 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 false | 691 | 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) { | |||
| 710 | 710 | assert.NoError(t, err) | |
| 711 | 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 | + | } | |
| @@ -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 | + | } | |||