From 1079c7553646505a2b857b5fc711ffc80df645c6 Mon Sep 17 00:00:00 2001 From: Carlo Field Date: Fri, 18 Jul 2025 16:26:33 +0200 Subject: [PATCH] feat: per-port protocol and certificates --- docs/load_balancers.md | 81 +++++++ internal/annotation/load_balancer.go | 12 + internal/annotation/name.go | 106 +++++++++ internal/annotation/per_port_test.go | 220 ++++++++++++++++++ internal/hcops/load_balancer.go | 52 ++++- internal/hcops/load_balancer_internal_test.go | 180 ++++++++++++++ internal/hcops/load_balancer_per_port_test.go | 131 +++++++++++ 7 files changed, 771 insertions(+), 11 deletions(-) create mode 100644 internal/annotation/per_port_test.go create mode 100644 internal/hcops/load_balancer_per_port_test.go diff --git a/docs/load_balancers.md b/docs/load_balancers.md index 4430fbcac..0ce9787a2 100644 --- a/docs/load_balancers.md +++ b/docs/load_balancers.md @@ -94,3 +94,84 @@ will delete the associated Load Balancer. If the Load Balancer is managed through Terraform, this causes problems. To disable this, you can enable deletion protection on the Load Balancer, this way hcloud-cloud-controller-manager will just skip deleting it when the associated `Service` is deleted. + +## Per-Port Protocol and Certificate Configuration + +The hcloud-cloud-controller-manager supports configuring different protocols and certificates for different ports of a single service using per-port annotations. + +### Per-Port Protocol Configuration + +Use the `load-balancer.hetzner.cloud/protocol-ports` annotation to specify different protocols for different ports: + +```yaml +apiVersion: v1 +kind: Service +metadata: + name: multi-protocol-service + annotations: + load-balancer.hetzner.cloud/protocol-ports: "80:http,443:https,9000:tcp" +spec: + type: LoadBalancer + ports: + - name: http + port: 80 + targetPort: 8080 + protocol: TCP + - name: https + port: 443 + targetPort: 8443 + protocol: TCP + - name: tcp + port: 9000 + targetPort: 9000 + protocol: TCP + selector: + app: my-app +``` + +### Per-Port Certificate Configuration + +Use the `load-balancer.hetzner.cloud/http-certificates-ports` annotation to specify different certificates for different HTTPS ports: + +```yaml +apiVersion: v1 +kind: Service +metadata: + name: multi-https-service + annotations: + load-balancer.hetzner.cloud/protocol-ports: "443:https,8443:https" + load-balancer.hetzner.cloud/http-certificates-ports: "443:cert1,cert2;8443:cert3" +spec: + type: LoadBalancer + ports: + - name: https-main + port: 443 + targetPort: 8443 + protocol: TCP + - name: https-alt + port: 8443 + targetPort: 8443 + protocol: TCP + selector: + app: my-app +``` + +### Format Specification + +**Protocol Ports Format:** +- Format: `"port:protocol,port:protocol,..."` +- Example: `"80:http,443:https,9000:tcp"` +- Supported protocols: `tcp`, `http`, `https` + +**Certificate Ports Format:** +- Format: `"port:cert1,cert2;port:cert3,..."` +- Example: `"443:cert1,cert2;8443:cert3"` +- Supports both certificate names and IDs +- Use semicolons (`;`) to separate different ports +- Use commas (`,`) to separate multiple certificates for the same port + +### Fallback Behavior + +- If per-port configuration is not specified for a port, the global annotation values are used +- Global annotations: `load-balancer.hetzner.cloud/protocol` and `load-balancer.hetzner.cloud/http-certificates` +- If no global annotation is set, defaults to `tcp` protocol diff --git a/internal/annotation/load_balancer.go b/internal/annotation/load_balancer.go index 8c48f64ba..4e6da3e29 100644 --- a/internal/annotation/load_balancer.go +++ b/internal/annotation/load_balancer.go @@ -54,6 +54,11 @@ const ( // values: tcp, http, https LBSvcProtocol Name = "load-balancer.hetzner.cloud/protocol" + // LBSvcProtocolPorts specifies the protocol per port for the service. This allows + // different ports to use different protocols. Format: "80:http,443:https,9000:tcp" + // If set, this takes precedence over LBSvcProtocol for the specified ports. + LBSvcProtocolPorts Name = "load-balancer.hetzner.cloud/protocol-ports" + // LBAlgorithmType specifies the algorithm type of the Load Balancer. // // Possible values: round_robin, least_connections @@ -129,6 +134,13 @@ const ( // HTTPS only. LBSvcHTTPCertificates Name = "load-balancer.hetzner.cloud/http-certificates" + // LBSvcHTTPCertificatesPorts specifies certificates per port for HTTPS services. + // Format: "443:cert1,cert2;8443:cert3,cert4" + // If set, this takes precedence over LBSvcHTTPCertificates for the specified ports. + // + // HTTPS only. + LBSvcHTTPCertificatesPorts Name = "load-balancer.hetzner.cloud/http-certificates-ports" + // LBSvcHTTPManagedCertificateName contains the names of the managed // certificate to create by the Cloud Controller manager. Ignored if // LBSvcHTTPCertificateType is missing or set to "uploaded". Optional. diff --git a/internal/annotation/name.go b/internal/annotation/name.go index 1b0e67432..59bf51872 100644 --- a/internal/annotation/name.go +++ b/internal/annotation/name.go @@ -259,6 +259,112 @@ func (s Name) CertificatesFromService(svc *corev1.Service) ([]*hcloud.Certificat return cs, err } +// ProtocolPortsFromService retrieves the protocol configuration per port from svc. +// The annotation format is "port:protocol,port:protocol" (e.g. "80:http,443:https,9000:tcp") +// +// Returns a map of port -> protocol. Returns an empty map if the annotation was not set. +func (s Name) ProtocolPortsFromService(svc *corev1.Service) (map[int]hcloud.LoadBalancerServiceProtocol, error) { + const op = "annotation/Name.ProtocolPortsFromService" + metrics.OperationCalled.WithLabelValues(op).Inc() + + result := make(map[int]hcloud.LoadBalancerServiceProtocol) + + v, ok := s.StringFromService(svc) + if !ok { + return result, nil // Return empty map if not set + } + + if strings.TrimSpace(v) == "" { + return result, nil + } + + pairs := strings.Split(v, ",") + for _, pair := range pairs { + parts := strings.Split(strings.TrimSpace(pair), ":") + if len(parts) != 2 { + return nil, fmt.Errorf("%s: invalid format for port:protocol pair: %s", op, pair) + } + + port, err := strconv.Atoi(strings.TrimSpace(parts[0])) + if err != nil { + return nil, fmt.Errorf("%s: invalid port number: %s", op, parts[0]) + } + + protocol, err := validateServiceProtocol(strings.TrimSpace(parts[1])) + if err != nil { + return nil, fmt.Errorf("%s: %w", op, err) + } + + result[port] = protocol + } + + return result, nil +} + +// CertificatePortsFromService retrieves the certificate configuration per port from svc. +// The annotation format is "port:cert1,cert2;port:cert3,cert4" (e.g. "443:cert1,cert2;8443:cert3") +// +// Returns a map of port -> certificates. Returns an empty map if the annotation was not set. +func (s Name) CertificatePortsFromService(svc *corev1.Service) (map[int][]*hcloud.Certificate, error) { + const op = "annotation/Name.CertificatePortsFromService" + metrics.OperationCalled.WithLabelValues(op).Inc() + + result := make(map[int][]*hcloud.Certificate) + + v, ok := s.StringFromService(svc) + if !ok { + return result, nil // Return empty map if not set + } + + if strings.TrimSpace(v) == "" { + return result, nil + } + + // Split by semicolon to get port configurations + portConfigs := strings.Split(v, ";") + for _, portConfig := range portConfigs { + portConfig = strings.TrimSpace(portConfig) + if portConfig == "" { + continue + } + + // Split by colon to get port and certificates + parts := strings.Split(portConfig, ":") + if len(parts) != 2 { + return nil, fmt.Errorf("%s: invalid format for port:certificates pair: %s", op, portConfig) + } + + port, err := strconv.Atoi(strings.TrimSpace(parts[0])) + if err != nil { + return nil, fmt.Errorf("%s: invalid port number: %s", op, parts[0]) + } + + // Parse certificates (same logic as CertificatesFromService) + certStrings := strings.Split(strings.TrimSpace(parts[1]), ",") + certificates := make([]*hcloud.Certificate, len(certStrings)) + + for i, certString := range certStrings { + certString = strings.TrimSpace(certString) + if certString == "" { + return nil, fmt.Errorf("%s: empty certificate reference", op) + } + + id, err := strconv.ParseInt(certString, 10, 64) + if err != nil { + // If we could not parse the string as an integer we assume it + // is a name not an id. + certificates[i] = &hcloud.Certificate{Name: certString} + } else { + certificates[i] = &hcloud.Certificate{ID: id} + } + } + + result[port] = certificates + } + + return result, nil +} + // CertificateTypeFromService retrieves the hcloud.CertificateType value // belonging to the annotation from svc. // diff --git a/internal/annotation/per_port_test.go b/internal/annotation/per_port_test.go new file mode 100644 index 000000000..c6367faf2 --- /dev/null +++ b/internal/annotation/per_port_test.go @@ -0,0 +1,220 @@ +package annotation + +import ( + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/hetznercloud/hcloud-go/v2/hcloud" + "github.com/stretchr/testify/assert" +) + +func TestName_ProtocolPortsFromService(t *testing.T) { + tests := []struct { + name string + annotation string + expected map[int]hcloud.LoadBalancerServiceProtocol + expectError bool + }{ + { + name: "valid protocol ports", + annotation: "80:http,443:https,9000:tcp", + expected: map[int]hcloud.LoadBalancerServiceProtocol{ + 80: hcloud.LoadBalancerServiceProtocolHTTP, + 443: hcloud.LoadBalancerServiceProtocolHTTPS, + 9000: hcloud.LoadBalancerServiceProtocolTCP, + }, + }, + { + name: "single protocol port", + annotation: "80:http", + expected: map[int]hcloud.LoadBalancerServiceProtocol{ + 80: hcloud.LoadBalancerServiceProtocolHTTP, + }, + }, + { + name: "empty annotation", + annotation: "", + expected: map[int]hcloud.LoadBalancerServiceProtocol{}, + }, + { + name: "invalid format - missing colon", + annotation: "80http", + expectError: true, + }, + { + name: "invalid format - invalid port", + annotation: "abc:http", + expectError: true, + }, + { + name: "invalid format - invalid protocol", + annotation: "80:invalid", + expectError: true, + }, + { + name: "whitespace handling", + annotation: " 80 : http , 443 : https ", + expected: map[int]hcloud.LoadBalancerServiceProtocol{ + 80: hcloud.LoadBalancerServiceProtocolHTTP, + 443: hcloud.LoadBalancerServiceProtocolHTTPS, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + string(LBSvcProtocolPorts): tt.annotation, + }, + }, + } + + result, err := LBSvcProtocolPorts.ProtocolPortsFromService(svc) + + if tt.expectError { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestName_ProtocolPortsFromService_NoAnnotation(t *testing.T) { + svc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{}, + }, + } + + result, err := LBSvcProtocolPorts.ProtocolPortsFromService(svc) + + assert.NoError(t, err) + assert.Empty(t, result) +} + +func TestName_CertificatePortsFromService(t *testing.T) { + tests := []struct { + name string + annotation string + expected map[int][]*hcloud.Certificate + expectError bool + }{ + { + name: "valid certificate ports", + annotation: "443:cert1,cert2;8443:cert3", + expected: map[int][]*hcloud.Certificate{ + 443: { + {Name: "cert1"}, + {Name: "cert2"}, + }, + 8443: { + {Name: "cert3"}, + }, + }, + }, + { + name: "single certificate port", + annotation: "443:cert1", + expected: map[int][]*hcloud.Certificate{ + 443: { + {Name: "cert1"}, + }, + }, + }, + { + name: "certificate IDs", + annotation: "443:123,456", + expected: map[int][]*hcloud.Certificate{ + 443: { + {ID: 123}, + {ID: 456}, + }, + }, + }, + { + name: "mixed names and IDs", + annotation: "443:cert1,123", + expected: map[int][]*hcloud.Certificate{ + 443: { + {Name: "cert1"}, + {ID: 123}, + }, + }, + }, + { + name: "empty annotation", + annotation: "", + expected: map[int][]*hcloud.Certificate{}, + }, + { + name: "invalid format - missing colon", + annotation: "443cert1", + expectError: true, + }, + { + name: "invalid format - invalid port", + annotation: "abc:cert1", + expectError: true, + }, + { + name: "invalid format - empty certificate", + annotation: "443:cert1,,cert2", + expectError: true, + }, + { + name: "whitespace handling", + annotation: " 443 : cert1 , cert2 ; 8443 : cert3 ", + expected: map[int][]*hcloud.Certificate{ + 443: { + {Name: "cert1"}, + {Name: "cert2"}, + }, + 8443: { + {Name: "cert3"}, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + string(LBSvcHTTPCertificatesPorts): tt.annotation, + }, + }, + } + + result, err := LBSvcHTTPCertificatesPorts.CertificatePortsFromService(svc) + + if tt.expectError { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestName_CertificatePortsFromService_NoAnnotation(t *testing.T) { + svc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{}, + }, + } + + result, err := LBSvcHTTPCertificatesPorts.CertificatePortsFromService(svc) + + assert.NoError(t, err) + assert.Empty(t, result) +} \ No newline at end of file diff --git a/internal/hcops/load_balancer.go b/internal/hcops/load_balancer.go index 84e709b37..9336bad7b 100644 --- a/internal/hcops/load_balancer.go +++ b/internal/hcops/load_balancer.go @@ -1050,6 +1050,19 @@ func (b *hclbServiceOptsBuilder) extract() { b.protocol = hcloud.LoadBalancerServiceProtocolTCP b.do(func() error { + // First check for per-port protocol configuration + portProtocols, err := annotation.LBSvcProtocolPorts.ProtocolPortsFromService(b.Service) + if err != nil { + return fmt.Errorf("%s: %w", op, err) + } + + // If per-port protocol is specified for this port, use it + if protocol, exists := portProtocols[b.listenPort]; exists { + b.protocol = protocol + return nil + } + + // Otherwise, fall back to the global protocol setting p, err := annotation.LBSvcProtocol.LBSvcProtocolFromService(b.Service) if errors.Is(err, annotation.ErrNotSet) { return nil @@ -1086,23 +1099,40 @@ func (b *hclbServiceOptsBuilder) extract() { return nil } - certs, err := annotation.LBSvcHTTPCertificates.CertificatesFromService(b.Service) - if errors.Is(err, annotation.ErrNotSet) { - return nil - } + var certs []*hcloud.Certificate + var err error + + // First check for per-port certificate configuration + portCerts, err := annotation.LBSvcHTTPCertificatesPorts.CertificatePortsFromService(b.Service) if err != nil { return fmt.Errorf("%s: %w", op, err) } - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() + // If per-port certificates are specified for this port, use them + if portCertificates, exists := portCerts[b.listenPort]; exists { + certs = portCertificates + } else { + // Otherwise, fall back to the global certificate setting + certs, err = annotation.LBSvcHTTPCertificates.CertificatesFromService(b.Service) + if errors.Is(err, annotation.ErrNotSet) { + return nil + } + if err != nil { + return fmt.Errorf("%s: %w", op, err) + } + } - certs, err = b.resolveCertsByNameOrID(ctx, certs) - if err != nil { - return fmt.Errorf("%s: %w", op, err) + if len(certs) > 0 { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + certs, err = b.resolveCertsByNameOrID(ctx, certs) + if err != nil { + return fmt.Errorf("%s: %w", op, err) + } + b.httpOpts.Certificates = certs + b.addHTTP = true } - b.httpOpts.Certificates = certs - b.addHTTP = true return nil }) diff --git a/internal/hcops/load_balancer_internal_test.go b/internal/hcops/load_balancer_internal_test.go index bb35101fc..2ffb9e4b6 100644 --- a/internal/hcops/load_balancer_internal_test.go +++ b/internal/hcops/load_balancer_internal_test.go @@ -360,6 +360,186 @@ func TestHCLBServiceOptsBuilder(t *testing.T) { }, }, }, + { + name: "per-port protocol configuration - HTTP", + servicePort: corev1.ServicePort{Port: 80, NodePort: 8080}, + serviceAnnotations: map[annotation.Name]string{ + annotation.LBSvcProtocol: string(hcloud.LoadBalancerServiceProtocolTCP), // Global default + annotation.LBSvcProtocolPorts: "80:http,443:https,9000:tcp", // Per-port override + }, + expectedAddOpts: hcloud.LoadBalancerAddServiceOpts{ + ListenPort: hcloud.Ptr(80), + DestinationPort: hcloud.Ptr(8080), + Protocol: hcloud.LoadBalancerServiceProtocolHTTP, // Should use per-port config + HealthCheck: &hcloud.LoadBalancerAddServiceOptsHealthCheck{ + Protocol: hcloud.LoadBalancerServiceProtocolTCP, + Port: hcloud.Ptr(8080), + }, + }, + expectedUpdateOpts: hcloud.LoadBalancerUpdateServiceOpts{ + DestinationPort: hcloud.Ptr(8080), + Protocol: hcloud.LoadBalancerServiceProtocolHTTP, + HealthCheck: &hcloud.LoadBalancerUpdateServiceOptsHealthCheck{ + Protocol: hcloud.LoadBalancerServiceProtocolTCP, + Port: hcloud.Ptr(8080), + }, + }, + }, + { + name: "per-port protocol configuration - HTTPS", + servicePort: corev1.ServicePort{Port: 443, NodePort: 8443}, + serviceAnnotations: map[annotation.Name]string{ + annotation.LBSvcProtocol: string(hcloud.LoadBalancerServiceProtocolTCP), // Global default + annotation.LBSvcProtocolPorts: "80:http,443:https,9000:tcp", // Per-port override + }, + expectedAddOpts: hcloud.LoadBalancerAddServiceOpts{ + ListenPort: hcloud.Ptr(443), + DestinationPort: hcloud.Ptr(8443), + Protocol: hcloud.LoadBalancerServiceProtocolHTTPS, // Should use per-port config + HealthCheck: &hcloud.LoadBalancerAddServiceOptsHealthCheck{ + Protocol: hcloud.LoadBalancerServiceProtocolTCP, + Port: hcloud.Ptr(8443), + }, + }, + expectedUpdateOpts: hcloud.LoadBalancerUpdateServiceOpts{ + DestinationPort: hcloud.Ptr(8443), + Protocol: hcloud.LoadBalancerServiceProtocolHTTPS, + HealthCheck: &hcloud.LoadBalancerUpdateServiceOptsHealthCheck{ + Protocol: hcloud.LoadBalancerServiceProtocolTCP, + Port: hcloud.Ptr(8443), + }, + }, + }, + { + name: "per-port protocol configuration - TCP fallback", + servicePort: corev1.ServicePort{Port: 9000, NodePort: 9000}, + serviceAnnotations: map[annotation.Name]string{ + annotation.LBSvcProtocol: string(hcloud.LoadBalancerServiceProtocolHTTP), // Global default + annotation.LBSvcProtocolPorts: "80:http,443:https,9000:tcp", // Per-port override + }, + expectedAddOpts: hcloud.LoadBalancerAddServiceOpts{ + ListenPort: hcloud.Ptr(9000), + DestinationPort: hcloud.Ptr(9000), + Protocol: hcloud.LoadBalancerServiceProtocolTCP, // Should use per-port config + HealthCheck: &hcloud.LoadBalancerAddServiceOptsHealthCheck{ + Protocol: hcloud.LoadBalancerServiceProtocolTCP, + Port: hcloud.Ptr(9000), + }, + }, + expectedUpdateOpts: hcloud.LoadBalancerUpdateServiceOpts{ + DestinationPort: hcloud.Ptr(9000), + Protocol: hcloud.LoadBalancerServiceProtocolTCP, + HealthCheck: &hcloud.LoadBalancerUpdateServiceOptsHealthCheck{ + Protocol: hcloud.LoadBalancerServiceProtocolTCP, + Port: hcloud.Ptr(9000), + }, + }, + }, + { + name: "per-port protocol configuration - not configured port uses global", + servicePort: corev1.ServicePort{Port: 8080, NodePort: 8080}, + serviceAnnotations: map[annotation.Name]string{ + annotation.LBSvcProtocol: string(hcloud.LoadBalancerServiceProtocolHTTP), // Global default + annotation.LBSvcProtocolPorts: "80:http,443:https,9000:tcp", // Per-port override + }, + expectedAddOpts: hcloud.LoadBalancerAddServiceOpts{ + ListenPort: hcloud.Ptr(8080), + DestinationPort: hcloud.Ptr(8080), + Protocol: hcloud.LoadBalancerServiceProtocolHTTP, // Should use global config + HealthCheck: &hcloud.LoadBalancerAddServiceOptsHealthCheck{ + Protocol: hcloud.LoadBalancerServiceProtocolTCP, + Port: hcloud.Ptr(8080), + }, + }, + expectedUpdateOpts: hcloud.LoadBalancerUpdateServiceOpts{ + DestinationPort: hcloud.Ptr(8080), + Protocol: hcloud.LoadBalancerServiceProtocolHTTP, + HealthCheck: &hcloud.LoadBalancerUpdateServiceOptsHealthCheck{ + Protocol: hcloud.LoadBalancerServiceProtocolTCP, + Port: hcloud.Ptr(8080), + }, + }, + }, + { + name: "per-port certificate configuration", + servicePort: corev1.ServicePort{Port: 443, NodePort: 8443}, + serviceAnnotations: map[annotation.Name]string{ + annotation.LBSvcProtocol: string(hcloud.LoadBalancerServiceProtocolHTTPS), + annotation.LBSvcHTTPCertificates: "global-cert1,global-cert2", // Global default + annotation.LBSvcHTTPCertificatesPorts: "443:port-cert1,port-cert2;8443:port-cert3", // Per-port override + }, + mock: func(_ *testing.T, tt *testCase) { + tt.certClient. + On("Get", mock.Anything, "port-cert1"). + Return(&hcloud.Certificate{ID: 1, Name: "port-cert1"}, nil, nil) + tt.certClient. + On("Get", mock.Anything, "port-cert2"). + Return(&hcloud.Certificate{ID: 2, Name: "port-cert2"}, nil, nil) + }, + expectedAddOpts: hcloud.LoadBalancerAddServiceOpts{ + ListenPort: hcloud.Ptr(443), + DestinationPort: hcloud.Ptr(8443), + Protocol: hcloud.LoadBalancerServiceProtocolHTTPS, + HTTP: &hcloud.LoadBalancerAddServiceOptsHTTP{ + Certificates: []*hcloud.Certificate{{ID: 1}, {ID: 2}}, // Should use per-port config + }, + HealthCheck: &hcloud.LoadBalancerAddServiceOptsHealthCheck{ + Protocol: hcloud.LoadBalancerServiceProtocolTCP, + Port: hcloud.Ptr(8443), + }, + }, + expectedUpdateOpts: hcloud.LoadBalancerUpdateServiceOpts{ + DestinationPort: hcloud.Ptr(8443), + Protocol: hcloud.LoadBalancerServiceProtocolHTTPS, + HTTP: &hcloud.LoadBalancerUpdateServiceOptsHTTP{ + Certificates: []*hcloud.Certificate{{ID: 1}, {ID: 2}}, + }, + HealthCheck: &hcloud.LoadBalancerUpdateServiceOptsHealthCheck{ + Protocol: hcloud.LoadBalancerServiceProtocolTCP, + Port: hcloud.Ptr(8443), + }, + }, + }, + { + name: "per-port certificate configuration - not configured port uses global", + servicePort: corev1.ServicePort{Port: 8443, NodePort: 8443}, + serviceAnnotations: map[annotation.Name]string{ + annotation.LBSvcProtocol: string(hcloud.LoadBalancerServiceProtocolHTTPS), + annotation.LBSvcHTTPCertificates: "global-cert1,global-cert2", // Global default + annotation.LBSvcHTTPCertificatesPorts: "443:port-cert1,port-cert2;9443:port-cert3", // Per-port override + }, + mock: func(_ *testing.T, tt *testCase) { + tt.certClient. + On("Get", mock.Anything, "global-cert1"). + Return(&hcloud.Certificate{ID: 10, Name: "global-cert1"}, nil, nil) + tt.certClient. + On("Get", mock.Anything, "global-cert2"). + Return(&hcloud.Certificate{ID: 11, Name: "global-cert2"}, nil, nil) + }, + expectedAddOpts: hcloud.LoadBalancerAddServiceOpts{ + ListenPort: hcloud.Ptr(8443), + DestinationPort: hcloud.Ptr(8443), + Protocol: hcloud.LoadBalancerServiceProtocolHTTPS, + HTTP: &hcloud.LoadBalancerAddServiceOptsHTTP{ + Certificates: []*hcloud.Certificate{{ID: 10}, {ID: 11}}, // Should use global config + }, + HealthCheck: &hcloud.LoadBalancerAddServiceOptsHealthCheck{ + Protocol: hcloud.LoadBalancerServiceProtocolTCP, + Port: hcloud.Ptr(8443), + }, + }, + expectedUpdateOpts: hcloud.LoadBalancerUpdateServiceOpts{ + DestinationPort: hcloud.Ptr(8443), + Protocol: hcloud.LoadBalancerServiceProtocolHTTPS, + HTTP: &hcloud.LoadBalancerUpdateServiceOptsHTTP{ + Certificates: []*hcloud.Certificate{{ID: 10}, {ID: 11}}, + }, + HealthCheck: &hcloud.LoadBalancerUpdateServiceOptsHealthCheck{ + Protocol: hcloud.LoadBalancerServiceProtocolTCP, + Port: hcloud.Ptr(8443), + }, + }, + }, } for _, tt := range tests { diff --git a/internal/hcops/load_balancer_per_port_test.go b/internal/hcops/load_balancer_per_port_test.go new file mode 100644 index 000000000..97d2d1b84 --- /dev/null +++ b/internal/hcops/load_balancer_per_port_test.go @@ -0,0 +1,131 @@ +package hcops + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + + "github.com/hetznercloud/hcloud-cloud-controller-manager/internal/annotation" + "github.com/hetznercloud/hcloud-cloud-controller-manager/internal/mocks" + "github.com/hetznercloud/hcloud-go/v2/hcloud" +) + +func TestRealWorldPerPortConfiguration(t *testing.T) { + certClient := &mocks.CertificateClient{} + certClient.Test(t) + + // Set up mock expectations for certificates + certClient. + On("Get", mock.Anything, "web-cert"). + Return(&hcloud.Certificate{ID: 1, Name: "web-cert"}, nil, nil) + certClient. + On("Get", mock.Anything, "api-cert"). + Return(&hcloud.Certificate{ID: 2, Name: "api-cert"}, nil, nil) + + service := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "example-service", + Namespace: "default", + UID: types.UID("test-uid"), + Annotations: map[string]string{ + string(annotation.LBSvcProtocol): "tcp", + string(annotation.LBSvcProtocolPorts): "80:http,443:https,9000:tcp", + string(annotation.LBSvcHTTPCertificatesPorts): "443:web-cert,api-cert", + }, + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeLoadBalancer, + Ports: []corev1.ServicePort{ + { + Name: "http", + Port: 80, + NodePort: 32080, + }, + { + Name: "https", + Port: 443, + NodePort: 32443, + }, + { + Name: "tcp", + Port: 9000, + NodePort: 32900, + }, + }, + }, + } + + testCases := []struct { + name string + port corev1.ServicePort + expectedProtocol hcloud.LoadBalancerServiceProtocol + expectedCertificates []*hcloud.Certificate + }{ + { + name: "HTTP port 80", + port: service.Spec.Ports[0], + expectedProtocol: hcloud.LoadBalancerServiceProtocolHTTP, + expectedCertificates: nil, + }, + { + name: "HTTPS port 443 with certificates", + port: service.Spec.Ports[1], + expectedProtocol: hcloud.LoadBalancerServiceProtocolHTTPS, + expectedCertificates: []*hcloud.Certificate{ + {ID: 1}, {ID: 2}, + }, + }, + { + name: "TCP port 9000", + port: service.Spec.Ports[2], + expectedProtocol: hcloud.LoadBalancerServiceProtocolTCP, + expectedCertificates: nil, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + builder := &hclbServiceOptsBuilder{ + Port: tc.port, + Service: service, + CertOps: &CertificateOps{CertClient: certClient}, + } + + addOpts, err := builder.buildAddServiceOpts() + assert.NoError(t, err) + + // Verify protocol + assert.Equal(t, tc.expectedProtocol, addOpts.Protocol) + + // Verify certificates + if tc.expectedCertificates != nil { + assert.NotNil(t, addOpts.HTTP) + assert.Equal(t, tc.expectedCertificates, addOpts.HTTP.Certificates) + } else { + if addOpts.HTTP != nil { + assert.Nil(t, addOpts.HTTP.Certificates) + } + } + + updateOpts, err := builder.buildUpdateServiceOpts() + assert.NoError(t, err) + + // Verify protocol + assert.Equal(t, tc.expectedProtocol, updateOpts.Protocol) + + // Verify certificates + if tc.expectedCertificates != nil { + assert.NotNil(t, updateOpts.HTTP) + assert.Equal(t, tc.expectedCertificates, updateOpts.HTTP.Certificates) + } else { + if updateOpts.HTTP != nil { + assert.Nil(t, updateOpts.HTTP.Certificates) + } + } + }) + } +} \ No newline at end of file