diff --git a/docs/loadbalancer-annotations.md b/docs/loadbalancer-annotations.md index 64c02c0..f8cd447 100644 --- a/docs/loadbalancer-annotations.md +++ b/docs/loadbalancer-annotations.md @@ -69,6 +69,12 @@ The default value is `5s`. The duration are go's time.Duration (ex: `1s`, `2m`, This is the annotation to set the number of consecutive unsuccessful health checks, after wich the server will be considered dead. The default value is `5`. +### `service.beta.kubernetes.io/scw-loadbalancer-health-check-port` +This is the annotation to explicitly define the port used for health checks. +It is possible to set a single port for all backends like `18080` or per port like `80:10080;443:10443`. +The port must be a valid TCP/UDP port (1-65535). +If not set, the service port is used as the health check port. + ### `service.beta.kubernetes.io/scw-loadbalancer-health-check-http-uri` This is the annotation to set the URI that is used by the `http` health check. It is possible to set the uri per port, like `80:/;443,8443:mydomain.tld/healthz`. diff --git a/scaleway/loadbalancers.go b/scaleway/loadbalancers.go index 59c5ed1..735757d 100644 --- a/scaleway/loadbalancers.go +++ b/scaleway/loadbalancers.go @@ -1158,9 +1158,13 @@ func servicePortToBackend(service *v1.Service, loadbalancer *scwlb.LB, port v1.S return nil, err } - healthCheck := &scwlb.HealthCheck{ - Port: port.NodePort, + healthCheck := &scwlb.HealthCheck{} + + healthCheckPort, err := getHealthCheckPort(service, port.NodePort) + if err != nil { + return nil, err } + healthCheck.Port = healthCheckPort healthCheckDelay, err := getHealthCheckDelay(service) if err != nil { @@ -1649,7 +1653,7 @@ func (l *loadbalancers) updateBackend(service *v1.Service, loadbalancer *scwlb.L if _, err := l.api.UpdateHealthCheck(&scwlb.ZonedAPIUpdateHealthCheckRequest{ Zone: loadbalancer.Zone, BackendID: backend.ID, - Port: backend.ForwardPort, + Port: backend.HealthCheck.Port, CheckDelay: backend.HealthCheck.CheckDelay, CheckTimeout: backend.HealthCheck.CheckTimeout, CheckMaxRetries: backend.HealthCheck.CheckMaxRetries, diff --git a/scaleway/loadbalancers_annotations.go b/scaleway/loadbalancers_annotations.go index d10944b..3e190b5 100644 --- a/scaleway/loadbalancers_annotations.go +++ b/scaleway/loadbalancers_annotations.go @@ -86,6 +86,12 @@ const ( // NB: Required when setting service.beta.kubernetes.io/scw-loadbalancer-health-check-type to "pgsql" serviceAnnotationLoadBalancerHealthCheckPgsqlUser = "service.beta.kubernetes.io/scw-loadbalancer-health-check-pgsql-user" + // serviceAnnotationLoadBalancerHealthCheckPort is the annotation to explicitly define the port used for health checks + // It is possible to set a single port for all backends like "18080" or per port like "80:10080;443:10443" + // The port must be a valid TCP/UDP port (1-65535) + // If not set, the service port is used as the health check port + serviceAnnotationLoadBalancerHealthCheckPort = "service.beta.kubernetes.io/scw-loadbalancer-health-check-port" + // serviceAnnotationLoadBalancerSendProxyV2 is the annotation that enables PROXY protocol version 2 (must be supported by backend servers) // The default value is "false" and the possible values are "false" or "true" // or a comma delimited list of the service port on which to apply the proxy protocol (for instance "80,443") @@ -629,6 +635,38 @@ func getHealthCheckTransientCheckDelay(service *v1.Service) (*scw.Duration, erro }, nil } +// getHealthCheckPort returns the port to use for health checks. +// It supports per-port configuration with the format "80:10080;443:10443" or a single port like "18080". +// If the annotation is not set, it returns the provided nodePort as default. +func getHealthCheckPort(service *v1.Service, nodePort int32) (int32, error) { + annotation, ok := service.Annotations[serviceAnnotationLoadBalancerHealthCheckPort] + if !ok { + return nodePort, nil + } + + portStr, err := getValueForPort(service, nodePort, annotation) + if err != nil { + klog.Errorf("could not get value for annotation %s and port %d", serviceAnnotationLoadBalancerHealthCheckPort, nodePort) + return 0, err + } + + if portStr == "" { + return nodePort, nil + } + + port, err := strconv.ParseInt(portStr, 10, 32) + if err != nil { + klog.Errorf("invalid value for annotation %s: %s is not a valid port number", serviceAnnotationLoadBalancerHealthCheckPort, portStr) + return 0, errLoadBalancerInvalidAnnotation + } + if port < 1 || port > 65535 { + klog.Errorf("invalid value for annotation %s: port %d is out of range (1-65535)", serviceAnnotationLoadBalancerHealthCheckPort, port) + return 0, errLoadBalancerInvalidAnnotation + } + + return int32(port), nil +} + func getForceInternalIP(service *v1.Service) bool { forceInternalIP, ok := service.Annotations[serviceAnnotationLoadBalancerForceInternalIP] if !ok { diff --git a/scaleway/loadbalancers_test.go b/scaleway/loadbalancers_test.go index 7110673..b4033d4 100644 --- a/scaleway/loadbalancers_test.go +++ b/scaleway/loadbalancers_test.go @@ -283,6 +283,178 @@ func TestGetValueForPort(t *testing.T) { } } +func TestGetHealthCheckPort(t *testing.T) { + testCases := []struct { + name string + svc *v1.Service + nodePort int32 + result int32 + errMessage string + }{ + { + name: "no annotation, returns nodePort", + svc: &v1.Service{ + Spec: v1.ServiceSpec{ + Ports: []v1.ServicePort{ + { + NodePort: 30080, + Port: 80, + }, + }, + }, + }, + nodePort: 30080, + result: 30080, + errMessage: "", + }, + { + name: "minimum valid port", + svc: &v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "service.beta.kubernetes.io/scw-loadbalancer-health-check-port": "1", + }, + }, + Spec: v1.ServiceSpec{ + Ports: []v1.ServicePort{ + { + NodePort: 30080, + Port: 80, + }, + }, + }, + }, + nodePort: 30080, + result: 1, + errMessage: "", + }, + { + name: "maximum valid port", + svc: &v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "service.beta.kubernetes.io/scw-loadbalancer-health-check-port": "65535", + }, + }, + Spec: v1.ServiceSpec{ + Ports: []v1.ServicePort{ + { + NodePort: 30080, + Port: 80, + }, + }, + }, + }, + nodePort: 30080, + result: 65535, + errMessage: "", + }, + // Error cases + { + name: "port too low (0)", + svc: &v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "service.beta.kubernetes.io/scw-loadbalancer-health-check-port": "0", + }, + }, + Spec: v1.ServiceSpec{ + Ports: []v1.ServicePort{ + { + NodePort: 30080, + Port: 80, + }, + }, + }, + }, + nodePort: 30080, + result: 0, + errMessage: "load balancer invalid annotation", + }, + { + name: "port too high (65536)", + svc: &v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "service.beta.kubernetes.io/scw-loadbalancer-health-check-port": "65536", + }, + }, + Spec: v1.ServiceSpec{ + Ports: []v1.ServicePort{ + { + NodePort: 30080, + Port: 80, + }, + }, + }, + }, + nodePort: 30080, + result: 0, + errMessage: "load balancer invalid annotation", + }, + { + name: "negative port", + svc: &v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "service.beta.kubernetes.io/scw-loadbalancer-health-check-port": "-1", + }, + }, + Spec: v1.ServiceSpec{ + Ports: []v1.ServicePort{ + { + NodePort: 30080, + Port: 80, + }, + }, + }, + }, + nodePort: 30080, + result: 0, + errMessage: "load balancer invalid annotation", + }, + { + name: "non-numeric value", + svc: &v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "service.beta.kubernetes.io/scw-loadbalancer-health-check-port": "not-a-number", + }, + }, + Spec: v1.ServiceSpec{ + Ports: []v1.ServicePort{ + { + NodePort: 30080, + Port: 80, + }, + }, + }, + }, + nodePort: 30080, + result: 0, + errMessage: "load balancer invalid annotation", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result, err := getHealthCheckPort(tc.svc, tc.nodePort) + if result != tc.result { + t.Errorf("getHealthCheckPort: got %d, expected %d", result, tc.result) + } + if err == nil && tc.errMessage != "" { + t.Errorf("getHealthCheckPort: expected error %q, got nil", tc.errMessage) + } + if err != nil && tc.errMessage == "" { + t.Errorf("getHealthCheckPort: unexpected error %v", err) + } + if err != nil && tc.errMessage != "" && err.Error() != tc.errMessage { + t.Errorf("getHealthCheckPort: got error %q, expected %q", err.Error(), tc.errMessage) + } + }) + } +} + func TestFilterNodes(t *testing.T) { service := &v1.Service{ ObjectMeta: metav1.ObjectMeta{