diff --git a/docs/reference/load_balancer_annotations.md b/docs/reference/load_balancer_annotations.md index 8fd25e85f..9777a9416 100644 --- a/docs/reference/load_balancer_annotations.md +++ b/docs/reference/load_balancer_annotations.md @@ -28,7 +28,7 @@ This page contains all annotations, which can be specified at a Service of type | `load-balancer.hetzner.cloud/node-selector` | `string` | `-` | `No` | Can be set to restrict which Nodes are added as targets to the Load Balancer. It accepts a Kubernetes label selector string, using either the set-based or equality-based formats. If the selector can not be parsed, the targets in the Load Balancer are not updated and an Event is created with the error message. Format: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#label-selectors | | `load-balancer.hetzner.cloud/uses-proxyprotocol` | `bool` | `false` | `No` | Specifies if the Load Balancer services should use the proxy protocol. | | `load-balancer.hetzner.cloud/http-cookie-name` | `string` | `-` | `No` | Specifies the cookie name when using HTTP or HTTPS as protocol. | -| `load-balancer.hetzner.cloud/http-cookie-lifetime` | `int` | `-` | `No` | Specifies the lifetime of the HTTP cookie. | +| `load-balancer.hetzner.cloud/http-cookie-lifetime` | `duration` | `-` | `No` | Specifies the lifetime of the HTTP cookie. | | `load-balancer.hetzner.cloud/http-timeout-idle` | `duration` | `-` | `No` | Specifies the idle timeout for the client and server side. Must be between 30s and 300s. | | `load-balancer.hetzner.cloud/certificate-type` | `uploaded \| managed` | `uploaded` | `No` | Defines the type of certificate the Load Balancer should use. | | `load-balancer.hetzner.cloud/http-certificates` | `string` | `-` | `No` | A comma separated list of IDs or Names of Certificates assigned to the service. | @@ -38,8 +38,8 @@ This page contains all annotations, which can be specified at a Service of type | `load-balancer.hetzner.cloud/http-sticky-sessions` | `bool` | `false` | `No` | Enables the sticky sessions feature of Hetzner Cloud HTTP Load Balancers. | | `load-balancer.hetzner.cloud/health-check-protocol` | `tcp \| http \| https` | `tcp` | `No` | Sets the protocol the health check should be performed over. | | `load-balancer.hetzner.cloud/health-check-port` | `int` | `-` | `No` | Specifies the port the health check is be performed on. | -| `load-balancer.hetzner.cloud/health-check-interval` | `int` | `-` | `No` | Specifies the interval in which time we perform a health check in seconds. | -| `load-balancer.hetzner.cloud/health-check-timeout` | `int` | `-` | `No` | Specifies the timeout of a single health check. | +| `load-balancer.hetzner.cloud/health-check-interval` | `duration` | `-` | `No` | Specifies the interval in which we perform a health check. | +| `load-balancer.hetzner.cloud/health-check-timeout` | `duration` | `-` | `No` | Specifies the timeout of a single health check. | | `load-balancer.hetzner.cloud/health-check-retries` | `int` | `-` | `No` | Specifies the number of time a health check is retried until a target is marked as unhealthy. | | `load-balancer.hetzner.cloud/health-check-http-domain` | `string` | `-` | `No` | Specifies the domain we try to access when performing the health check. | | `load-balancer.hetzner.cloud/health-check-http-path` | `string` | `-` | `No` | Specifies the path we try to access when performing the health check. | diff --git a/hcloud/load_balancers.go b/hcloud/load_balancers.go index 64d8767f3..18d5c9e27 100644 --- a/hcloud/load_balancers.go +++ b/hcloud/load_balancers.go @@ -43,17 +43,15 @@ func newLoadBalancers(lbOps LoadBalancerOps, lbCfg *config.LoadBalancerConfigura } func matchNodeSelector(svc *corev1.Service, nodes []*corev1.Node) ([]*corev1.Node, error) { - var ( - err error - selectedNodes []*corev1.Node - ) + var selectedNodes []*corev1.Node selector := labels.Everything() - if v, ok := annotation.LBNodeSelector.StringFromService(svc); ok { - selector, err = labels.Parse(v) + if v, err := annotation.LBNodeSelector.FromService(svc); err == nil { + parsed, err := labels.Parse(v) if err != nil { return nil, fmt.Errorf("unable to parse the node-selector annotation: %w", err) } + selector = parsed } for _, n := range nodes { @@ -79,7 +77,7 @@ func (l *loadBalancers) GetLoadBalancer( return nil, false, fmt.Errorf("%s: %w", op, err) } - if v, ok := annotation.LBHostname.StringFromService(service); ok { + if v, err := annotation.LBHostname.FromService(service); err == nil { return &corev1.LoadBalancerStatus{ Ingress: []corev1.LoadBalancerIngress{{Hostname: v}}, }, true, nil @@ -94,7 +92,7 @@ func (l *loadBalancers) GetLoadBalancer( } func (l *loadBalancers) GetLoadBalancerName(_ context.Context, _ string, service *corev1.Service) string { - if v, ok := annotation.LBName.StringFromService(service); ok { + if v, err := annotation.LBName.FromService(service); err == nil { return v } return cloudprovider.DefaultLoadBalancerName(service) @@ -196,7 +194,7 @@ func (l *loadBalancers) EnsureLoadBalancer( // Either set the Hostname or the IPs (below). // See: https://github.com/kubernetes/kubernetes/issues/66607 - if v, ok := annotation.LBHostname.StringFromService(svc); ok { + if v, err := annotation.LBHostname.FromService(svc); err == nil { return &corev1.LoadBalancerStatus{ Ingress: []corev1.LoadBalancerIngress{{Hostname: v}}, }, nil @@ -262,7 +260,7 @@ func (l *loadBalancers) buildLoadBalancerStatusIngress(lb *hcloud.LoadBalancer, } func (l *loadBalancers) getPrivateIngressEnabled(svc *corev1.Service) (bool, error) { - disable, err := annotation.LBDisablePrivateIngress.BoolFromService(svc) + disable, err := annotation.LBDisablePrivateIngress.FromService(svc) if err == nil { return !disable, nil } @@ -273,7 +271,7 @@ func (l *loadBalancers) getPrivateIngressEnabled(svc *corev1.Service) (bool, err } func (l *loadBalancers) getProxyProtocolEnabled(svc *corev1.Service) (bool, error) { - enable, err := annotation.LBSvcProxyProtocol.BoolFromService(svc) + enable, err := annotation.LBSvcProxyProtocol.FromService(svc) if err == nil { return enable, nil } @@ -287,7 +285,7 @@ func (l *loadBalancers) getProxyProtocolEnabled(svc *corev1.Service) (bool, erro } func (l *loadBalancers) getIPv6Enabled(svc *corev1.Service) (bool, error) { - disable, err := annotation.LBIPv6Disabled.BoolFromService(svc) + disable, err := annotation.LBIPv6Disabled.FromService(svc) if err == nil { return !disable, nil } diff --git a/internal/annotation/annotation.go b/internal/annotation/annotation.go new file mode 100644 index 000000000..3b6b6cd3e --- /dev/null +++ b/internal/annotation/annotation.go @@ -0,0 +1,161 @@ +// Package annotation defines the Kubernetes annotations that configure the +// resources managed by the cloud controller manager. +// +// Every annotation is declared with the type of its value, so reading one +// yields that type and nothing else: +// +// const LBUsePrivateIP Bool = "load-balancer.hetzner.cloud/use-private-ip" +// +// usePrivateIP, err := LBUsePrivateIP.FromService(svc) +// +// Annotations are declared as constants of a named string type. The reference +// documentation in docs/reference is generated from those declarations by +// tools/doc_generation.go, which expects constants with a string literal. +package annotation + +import ( + "errors" + "fmt" + "net" + "strconv" + "strings" + "time" + + corev1 "k8s.io/api/core/v1" + + "github.com/hetznercloud/hcloud-go/v2/hcloud" +) + +var ErrNotSet = errors.New("not set") + +type Annotation[T any] interface { + FromService(svc *corev1.Service) (T, error) +} + +type ( + String string + Bool string + Int string + Duration string // e.g. "30s" or "1h" + Strings string // comma separated list of strings + IP string + Protocol string // Load Balancer service protocol + AlgorithmType string // Load Balancer algorithm type + // Certificates is an annotation holding a comma separated list of Certificates, + // referenced either by ID or by name. + Certificates string +) + +func (a String) FromService(svc *corev1.Service) (string, error) { + return value(string(a), svc) +} + +func (a Bool) FromService(svc *corev1.Service) (bool, error) { + return parse(string(a), svc, strconv.ParseBool) +} + +func (a Int) FromService(svc *corev1.Service) (int, error) { + return parse(string(a), svc, strconv.Atoi) +} + +func (a Duration) FromService(svc *corev1.Service) (time.Duration, error) { + return parse(string(a), svc, time.ParseDuration) +} + +func (a Strings) FromService(svc *corev1.Service) ([]string, error) { + return parse(string(a), svc, func(v string) ([]string, error) { + return strings.Split(v, ","), nil + }) +} + +func (a IP) FromService(svc *corev1.Service) (net.IP, error) { + return parse(string(a), svc, parseIP) +} + +func (a Protocol) FromService(svc *corev1.Service) (hcloud.LoadBalancerServiceProtocol, error) { + return parse(string(a), svc, parseServiceProtocol) +} + +func (a AlgorithmType) FromService(svc *corev1.Service) (hcloud.LoadBalancerAlgorithmType, error) { + return parse(string(a), svc, parseAlgorithmType) +} + +func (a Certificates) FromService(svc *corev1.Service) ([]*hcloud.Certificate, error) { + return parse(string(a), svc, parseCertificates) +} + +// value returns the raw value of the annotation with name from svc. +func value(name string, svc *corev1.Service) (string, error) { + v, ok := svc.Annotations[name] + if !ok { + return "", fmt.Errorf("%s: %w", name, ErrNotSet) + } + return v, nil +} + +func parse[T any](name string, svc *corev1.Service, convert func(string) (T, error)) (T, error) { + var zero T + + v, err := value(name, svc) + if err != nil { + return zero, err + } + + converted, err := convert(v) + if err != nil { + return zero, fmt.Errorf("%s: %w", name, err) + } + + return converted, nil +} + +func parseIP(v string) (net.IP, error) { + ip := net.ParseIP(v) + if ip == nil { + return nil, fmt.Errorf("invalid ip address: %s", v) + } + return ip, nil +} + +func parseAlgorithmType(v string) (hcloud.LoadBalancerAlgorithmType, error) { + algorithm := hcloud.LoadBalancerAlgorithmType(strings.ToLower(v)) + + switch algorithm { + case hcloud.LoadBalancerAlgorithmTypeLeastConnections, + hcloud.LoadBalancerAlgorithmTypeRoundRobin: + return algorithm, nil + default: + return "", fmt.Errorf("invalid: %s", v) + } +} + +func parseServiceProtocol(v string) (hcloud.LoadBalancerServiceProtocol, error) { + protocol := hcloud.LoadBalancerServiceProtocol(strings.ToLower(v)) + + switch protocol { + case hcloud.LoadBalancerServiceProtocolTCP, + hcloud.LoadBalancerServiceProtocolHTTP, + hcloud.LoadBalancerServiceProtocolHTTPS: + return protocol, nil + default: + return "", fmt.Errorf("invalid: %s", v) + } +} + +func parseCertificates(v string) ([]*hcloud.Certificate, error) { + values := strings.Split(v, ",") + certificates := make([]*hcloud.Certificate, len(values)) + + for i, value := range values { + id, err := strconv.ParseInt(value, 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: value} + continue + } + certificates[i] = &hcloud.Certificate{ID: id} + } + + return certificates, nil +} diff --git a/internal/annotation/annotation_test.go b/internal/annotation/annotation_test.go new file mode 100644 index 000000000..c4e12e6e0 --- /dev/null +++ b/internal/annotation/annotation_test.go @@ -0,0 +1,331 @@ +package annotation_test + +import ( + "errors" + "net" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + + "github.com/hetznercloud/hcloud-cloud-controller-manager/internal/annotation" + "github.com/hetznercloud/hcloud-go/v2/hcloud" +) + +// Every accessor is exercised through the same annotation name, declared once +// per type. +const annName = "some/annotation" + +const ( + annString annotation.String = annName + annBool annotation.Bool = annName + annInt annotation.Int = annName + annDuration annotation.Duration = annName + annStrings annotation.Strings = annName + annIP annotation.IP = annName + annProtocol annotation.Protocol = annName + annAlgorithmType annotation.AlgorithmType = annName + annCertificates annotation.Certificates = annName +) + +func TestString(t *testing.T) { + tests := []accessorTest{ + { + name: "value as string", + value: "some value", + expected: "some value", + }, + { + // An annotation set to the empty string is set, and some settings + // use that to opt out of a cluster-wide default. + name: "value set to the empty string", + value: "", + expected: "", + }, + { + name: "value not set", + notSet: true, + expected: "", + err: errors.New(annName + ": not set"), + }, + } + + runAllAccessorTests(t, tests, func(svc *corev1.Service) (any, error) { + return annString.FromService(svc) + }) + + // The harness always assigns an annotation map, so a Service without any + // annotations is covered separately. + t.Run("Service has no annotations", func(t *testing.T) { + actual, err := annString.FromService(&corev1.Service{}) + + assert.ErrorIs(t, err, annotation.ErrNotSet) + assert.Empty(t, actual) + }) +} + +func TestBool(t *testing.T) { + tests := []accessorTest{ + { + name: "value set to true", + value: "true", + expected: true, + }, + { + name: "value set to false", + value: "false", + expected: false, + }, + { + name: "value not set", + notSet: true, + expected: false, + err: annotation.ErrNotSet, + }, + { + name: "value invalid", + value: "invalid", + expected: false, + err: strconv.ErrSyntax, + }, + } + + runAllAccessorTests(t, tests, func(svc *corev1.Service) (any, error) { + return annBool.FromService(svc) + }) +} + +func TestInt(t *testing.T) { + tests := []accessorTest{ + { + name: "value set to 10", + value: "10", + expected: 10, + }, + { + name: "value not set", + notSet: true, + expected: 0, + err: errors.New(annName + ": not set"), + }, + { + name: "value invalid", + value: "invalid", + expected: 0, + err: strconv.ErrSyntax, + }, + } + + runAllAccessorTests(t, tests, func(svc *corev1.Service) (any, error) { + return annInt.FromService(svc) + }) +} + +func TestDuration(t *testing.T) { + tests := []accessorTest{ + { + name: "value set", + value: "1h", + expected: time.Hour, + }, + { + name: "value not set", + notSet: true, + err: annotation.ErrNotSet, + }, + { + name: "value invalid", + value: "invalid", + err: errors.New(annName + `: time: invalid duration "invalid"`), + }, + } + + runAllAccessorTests(t, tests, func(svc *corev1.Service) (any, error) { + return annDuration.FromService(svc) + }) +} + +func TestStrings(t *testing.T) { + tests := []accessorTest{ + { + name: "value set", + value: "a,b,c", + expected: []string{"a", "b", "c"}, + }, + { + name: "value missing", + notSet: true, + err: annotation.ErrNotSet, + }, + } + + runAllAccessorTests(t, tests, func(svc *corev1.Service) (any, error) { + return annStrings.FromService(svc) + }) +} + +func TestIP(t *testing.T) { + tests := []accessorTest{ + { + name: "value set to valid IPv4", + value: "1.2.3.4", + expected: net.ParseIP("1.2.3.4"), + }, + { + name: "value set to valid IPv6", + value: "3c2e:2ef9:a7e9:1a5b:30ba:4912:e3fe:91b2", + expected: net.ParseIP("3c2e:2ef9:a7e9:1a5b:30ba:4912:e3fe:91b2"), + }, + { + name: "value invalid", + value: "invalid", + err: errors.New(annName + ": invalid ip address: invalid"), + }, + { + name: "value not set", + notSet: true, + err: annotation.ErrNotSet, + }, + } + + runAllAccessorTests(t, tests, func(svc *corev1.Service) (any, error) { + return annIP.FromService(svc) + }) +} + +func TestProtocol(t *testing.T) { + tests := []accessorTest{ + { + name: "value set", + value: string(hcloud.LoadBalancerServiceProtocolHTTP), + expected: hcloud.LoadBalancerServiceProtocolHTTP, + }, + { + name: "value is uppercased", + value: "HTTPS", + expected: hcloud.LoadBalancerServiceProtocolHTTPS, + }, + { + name: "value not set", + notSet: true, + err: annotation.ErrNotSet, + }, + { + name: "value invalid", + value: "invalid", + err: errors.New(annName + ": invalid: invalid"), + }, + } + + runAllAccessorTests(t, tests, func(svc *corev1.Service) (any, error) { + return annProtocol.FromService(svc) + }) +} + +func TestAlgorithmType(t *testing.T) { + tests := []accessorTest{ + { + name: "value set", + value: string(hcloud.LoadBalancerAlgorithmTypeLeastConnections), + expected: hcloud.LoadBalancerAlgorithmTypeLeastConnections, + }, + { + name: "value is uppercased", + value: "ROUND_ROBIN", + expected: hcloud.LoadBalancerAlgorithmTypeRoundRobin, + }, + { + name: "value not set", + notSet: true, + err: annotation.ErrNotSet, + }, + { + name: "value invalid", + value: "invalid", + err: errors.New(annName + ": invalid: invalid"), + }, + } + + runAllAccessorTests(t, tests, func(svc *corev1.Service) (any, error) { + return annAlgorithmType.FromService(svc) + }) +} + +func TestCertificates(t *testing.T) { + tests := []accessorTest{ + { + name: "ids set", + value: "3,5", + expected: []*hcloud.Certificate{{ID: 3}, {ID: 5}}, + }, + { + name: "names set", + value: "cert-1,cert-2", + expected: []*hcloud.Certificate{{Name: "cert-1"}, {Name: "cert-2"}}, + }, + { + name: "ids and names mixed", + value: "3,cert-2", + expected: []*hcloud.Certificate{{ID: 3}, {Name: "cert-2"}}, + }, + { + name: "value not set", + notSet: true, + err: annotation.ErrNotSet, + }, + } + + runAllAccessorTests(t, tests, func(svc *corev1.Service) (any, error) { + return annCertificates.FromService(svc) + }) +} + +type accessorTest struct { + name string + // value is put on the Service unless notSet is true. + value string + notSet bool + err error + expected any +} + +func (tt *accessorTest) run(t *testing.T, call func(svc *corev1.Service) (any, error)) { + t.Helper() + + var svc corev1.Service + svc.Annotations = map[string]string{} + + if !tt.notSet { + svc.Annotations[annName] = tt.value + } + + actual, err := call(&svc) + if tt.err != nil { + if errors.Is(err, tt.err) { + return + } + assert.EqualError(t, err, tt.err.Error()) + return + } + assert.NoError(t, err) + // Don't use assert.Equal to compare nil values, as it requires the nil + // values to be casted to the correct type. + if tt.expected == nil && actual == nil { + return + } + assert.Equal(t, tt.expected, actual) +} + +func runAllAccessorTests( + t *testing.T, tests []accessorTest, call func(svc *corev1.Service) (any, error), +) { + t.Helper() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.run(t, call) + }) + } +} diff --git a/internal/annotation/load_balancer.go b/internal/annotation/load_balancer.go index 5ea68931f..9fbe08cc0 100644 --- a/internal/annotation/load_balancer.go +++ b/internal/annotation/load_balancer.go @@ -4,104 +4,85 @@ const ( // LBPublicIPv4 is the public IPv4 address assigned to the Load Balancer by // the backend. // - // Type: string // Read-only: true - LBPublicIPv4 Name = "load-balancer.hetzner.cloud/ipv4" + LBPublicIPv4 String = "load-balancer.hetzner.cloud/ipv4" // LBPublicIPv4RDNS is the reverse DNS record assigned to the IPv4 address of // the Load Balancer. // - // Type: string // Read-only: true - LBPublicIPv4RDNS Name = "load-balancer.hetzner.cloud/ipv4-rdns" + LBPublicIPv4RDNS String = "load-balancer.hetzner.cloud/ipv4-rdns" // LBPublicIPv6 is the public IPv6 address assigned to the Load Balancer by // the backend. // - // Type: string // Read-only: true - LBPublicIPv6 Name = "load-balancer.hetzner.cloud/ipv6" + LBPublicIPv6 String = "load-balancer.hetzner.cloud/ipv6" // LBPublicIPv6RDNS is the reverse DNS record assigned to the IPv6 address of // the Load Balancer. // - // Type: string // Read-only: true - LBPublicIPv6RDNS Name = "load-balancer.hetzner.cloud/ipv6-rdns" + LBPublicIPv6RDNS String = "load-balancer.hetzner.cloud/ipv6-rdns" // LBIPv6Disabled disables the use of IPv6 for the Load Balancer. // Set this annotation if you use external-dns. // - // Type: bool // Default: false - LBIPv6Disabled Name = "load-balancer.hetzner.cloud/ipv6-disabled" + LBIPv6Disabled Bool = "load-balancer.hetzner.cloud/ipv6-disabled" // LBName is the name of the Load Balancer. The name will be visible in // the Hetzner Cloud API console. - // - // Type: string - LBName Name = "load-balancer.hetzner.cloud/name" + LBName String = "load-balancer.hetzner.cloud/name" // LBDisablePublicNetwork disables the public network of the Hetzner Cloud // Load Balancer. It will still have a public network assigned, but all // traffic is routed over the private network. // - // Type: bool // Default: false - LBDisablePublicNetwork Name = "load-balancer.hetzner.cloud/disable-public-network" + LBDisablePublicNetwork Bool = "load-balancer.hetzner.cloud/disable-public-network" // LBDisablePrivateIngress disables the use of the private network for // ingress. // - // Type: bool // Default: false - LBDisablePrivateIngress Name = "load-balancer.hetzner.cloud/disable-private-ingress" + LBDisablePrivateIngress Bool = "load-balancer.hetzner.cloud/disable-private-ingress" // LBUsePrivateIP configures the Load Balancer to use the private IP for // Load Balancer server targets. // - // Type: bool // Default: false - LBUsePrivateIP Name = "load-balancer.hetzner.cloud/use-private-ip" + LBUsePrivateIP Bool = "load-balancer.hetzner.cloud/use-private-ip" // LBPrivateIPv4 specifies the IPv4 address to assign to the load balancer in the // private network that it's attached to. - // - // Type: string - LBPrivateIPv4 Name = "load-balancer.hetzner.cloud/private-ipv4" + LBPrivateIPv4 String = "load-balancer.hetzner.cloud/private-ipv4" // PrivateSubnetIPRange specifies an existing subnet to which the load balancer will be attached. // The value must be in the CIDR notation. The subnet must belong to the network defined // in the CCM configuration and must already exist. // See: https://docs.hetzner.cloud/reference/cloud#network-actions-add-a-subnet-to-a-network - // - // Type: string - PrivateSubnetIPRange Name = "load-balancer.hetzner.cloud/private-subnet-ip-range" + PrivateSubnetIPRange String = "load-balancer.hetzner.cloud/private-subnet-ip-range" // LBHostname specifies the hostname of the Load Balancer. This will be // used as ingress address instead of the Load Balancer IP addresses if // specified. - // - // Type: string - LBHostname Name = "load-balancer.hetzner.cloud/hostname" + LBHostname String = "load-balancer.hetzner.cloud/hostname" // LBSvcProtocol specifies the protocol of the service. // - // Type: tcp | http | https // Default: tcp - LBSvcProtocol Name = "load-balancer.hetzner.cloud/protocol" + LBSvcProtocol Protocol = "load-balancer.hetzner.cloud/protocol" // LBAlgorithmType specifies the algorithm type of the Load Balancer. // - // Type: round_robin | least_connections // Default: round_robin - LBAlgorithmType Name = "load-balancer.hetzner.cloud/algorithm-type" + LBAlgorithmType AlgorithmType = "load-balancer.hetzner.cloud/algorithm-type" // LBType specifies the type of the Load Balancer. // - // Type: string // Default: lb11 - LBType Name = "load-balancer.hetzner.cloud/type" + LBType String = "load-balancer.hetzner.cloud/type" // LBLocation specifies the location where the Load Balancer will be // created in. @@ -112,9 +93,7 @@ const ( // will lead to the load balancer getting new public IPs assigned. // // Mutually exclusive with [LBNetworkZone]. - // - // Type: string - LBLocation Name = "load-balancer.hetzner.cloud/location" + LBLocation String = "load-balancer.hetzner.cloud/location" // LBNetworkZone specifies the network zone where the Load Balancer will be // created in. @@ -126,9 +105,7 @@ const ( // assigned. // // Mutually exclusive with [LBLocation]. - // - // Type: string - LBNetworkZone Name = "load-balancer.hetzner.cloud/network-zone" + LBNetworkZone String = "load-balancer.hetzner.cloud/network-zone" // LBNodeSelector can be set to restrict which Nodes are added as targets to the // Load Balancer. It accepts a Kubernetes label selector string, using either the @@ -138,53 +115,40 @@ const ( // updated and an Event is created with the error message. // // Format: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#label-selectors - // - // Type: string - LBNodeSelector Name = "load-balancer.hetzner.cloud/node-selector" + LBNodeSelector String = "load-balancer.hetzner.cloud/node-selector" // LBSvcProxyProtocol specifies if the Load Balancer services should // use the proxy protocol. // - // Type: bool // Default: false - LBSvcProxyProtocol Name = "load-balancer.hetzner.cloud/uses-proxyprotocol" + LBSvcProxyProtocol Bool = "load-balancer.hetzner.cloud/uses-proxyprotocol" // LBSvcHTTPCookieName specifies the cookie name when using HTTP or HTTPS // as protocol. - // - // Type: string - LBSvcHTTPCookieName Name = "load-balancer.hetzner.cloud/http-cookie-name" + LBSvcHTTPCookieName String = "load-balancer.hetzner.cloud/http-cookie-name" // LBSvcHTTPCookieLifetime specifies the lifetime of the HTTP cookie. - // - // Type: int - LBSvcHTTPCookieLifetime Name = "load-balancer.hetzner.cloud/http-cookie-lifetime" + LBSvcHTTPCookieLifetime Duration = "load-balancer.hetzner.cloud/http-cookie-lifetime" // LBSvcHTTPTimeoutIdle specifies the idle timeout for the client and // server side. Must be between 30s and 300s. - // - // Type: duration - LBSvcHTTPTimeoutIdle Name = "load-balancer.hetzner.cloud/http-timeout-idle" + LBSvcHTTPTimeoutIdle Duration = "load-balancer.hetzner.cloud/http-timeout-idle" // LBSvcHTTPCertificateType defines the type of certificate the Load // Balancer should use. // // Type: uploaded | managed // Default: uploaded - LBSvcHTTPCertificateType Name = "load-balancer.hetzner.cloud/certificate-type" + LBSvcHTTPCertificateType String = "load-balancer.hetzner.cloud/certificate-type" // LBSvcHTTPCertificates a comma separated list of IDs or Names of // Certificates assigned to the service. - // - // Type: string - LBSvcHTTPCertificates Name = "load-balancer.hetzner.cloud/http-certificates" + LBSvcHTTPCertificates Certificates = "load-balancer.hetzner.cloud/http-certificates" // LBSvcHTTPManagedCertificateName contains the name of the managed // certificate to create by the Cloud Controller manager. Ignored if // [LBSvcHTTPCertificateType] is missing or set to "uploaded". - // - // Type: string - LBSvcHTTPManagedCertificateName Name = "load-balancer.hetzner.cloud/http-managed-certificate-name" + LBSvcHTTPManagedCertificateName String = "load-balancer.hetzner.cloud/http-managed-certificate-name" // LBSvcHTTPManagedCertificateUseACMEStaging tells the cloud controller manager to create // the certificate using Let's Encrypt staging. @@ -193,93 +157,70 @@ const ( // Users should not use this annotation. There is no guarantee that it // remains or continues to function as it currently functions. // - // Type: bool // Default: false // Internal: true - LBSvcHTTPManagedCertificateUseACMEStaging Name = "load-balancer.hetzner.cloud/http-managed-certificate-acme-staging" + LBSvcHTTPManagedCertificateUseACMEStaging Bool = "load-balancer.hetzner.cloud/http-managed-certificate-acme-staging" // LBSvcHTTPManagedCertificateDomains contains a comma separated list of the // domain names of the managed certificate. // // All domains are used to create a single managed certificate. - // - // Type: string - LBSvcHTTPManagedCertificateDomains Name = "load-balancer.hetzner.cloud/http-managed-certificate-domains" + LBSvcHTTPManagedCertificateDomains Strings = "load-balancer.hetzner.cloud/http-managed-certificate-domains" // LBSvcRedirectHTTP create a redirect from HTTP to HTTPS. // - // Type: bool // Default: false - LBSvcRedirectHTTP Name = "load-balancer.hetzner.cloud/http-redirect-http" + LBSvcRedirectHTTP Bool = "load-balancer.hetzner.cloud/http-redirect-http" // LBSvcHTTPStickySessions enables the sticky sessions feature of Hetzner // Cloud HTTP Load Balancers. // - // Type: bool // Default: false - LBSvcHTTPStickySessions Name = "load-balancer.hetzner.cloud/http-sticky-sessions" + LBSvcHTTPStickySessions Bool = "load-balancer.hetzner.cloud/http-sticky-sessions" // LBSvcHealthCheckProtocol sets the protocol the health check should be // performed over. // - // Type: tcp | http | https // Default: tcp - LBSvcHealthCheckProtocol Name = "load-balancer.hetzner.cloud/health-check-protocol" + LBSvcHealthCheckProtocol Protocol = "load-balancer.hetzner.cloud/health-check-protocol" // LBSvcHealthCheckPort specifies the port the health check is be performed // on. - // - // Type: int - LBSvcHealthCheckPort Name = "load-balancer.hetzner.cloud/health-check-port" + LBSvcHealthCheckPort Int = "load-balancer.hetzner.cloud/health-check-port" - // LBSvcHealthCheckInterval specifies the interval in which time we perform - // a health check in seconds. - // - // Type: int - LBSvcHealthCheckInterval Name = "load-balancer.hetzner.cloud/health-check-interval" + // LBSvcHealthCheckInterval specifies the interval in which we perform a + // health check. + LBSvcHealthCheckInterval Duration = "load-balancer.hetzner.cloud/health-check-interval" // LBSvcHealthCheckTimeout specifies the timeout of a single health check. - // - // Type: int - LBSvcHealthCheckTimeout Name = "load-balancer.hetzner.cloud/health-check-timeout" + LBSvcHealthCheckTimeout Duration = "load-balancer.hetzner.cloud/health-check-timeout" // LBSvcHealthCheckRetries specifies the number of time a health check is // retried until a target is marked as unhealthy. - // - // Type: int - LBSvcHealthCheckRetries Name = "load-balancer.hetzner.cloud/health-check-retries" + LBSvcHealthCheckRetries Int = "load-balancer.hetzner.cloud/health-check-retries" // LBSvcHealthCheckHTTPDomain specifies the domain we try to access when // performing the health check. - // - // Type: string - LBSvcHealthCheckHTTPDomain Name = "load-balancer.hetzner.cloud/health-check-http-domain" + LBSvcHealthCheckHTTPDomain String = "load-balancer.hetzner.cloud/health-check-http-domain" // LBSvcHealthCheckHTTPPath specifies the path we try to access when // performing the health check. - // - // Type: string - LBSvcHealthCheckHTTPPath Name = "load-balancer.hetzner.cloud/health-check-http-path" + LBSvcHealthCheckHTTPPath String = "load-balancer.hetzner.cloud/health-check-http-path" // LBSvcHealthCheckHTTPValidateCertificate specifies whether the health // check should validate the SSL certificate that comes from the target // nodes. - // - // Type: bool - LBSvcHealthCheckHTTPValidateCertificate Name = "load-balancer.hetzner.cloud/health-check-http-validate-certificate" + LBSvcHealthCheckHTTPValidateCertificate Bool = "load-balancer.hetzner.cloud/health-check-http-validate-certificate" // LBSvcHealthCheckHTTPStatusCodes is a comma separated list of HTTP status // codes which we expect. - // - // Type: string - LBSvcHealthCheckHTTPStatusCodes Name = "load-balancer.hetzner.cloud/http-status-codes" + LBSvcHealthCheckHTTPStatusCodes Strings = "load-balancer.hetzner.cloud/http-status-codes" // LBID is the ID assigned to the Hetzner Cloud Load Balancer by the // backend. // // Deprecated: This annotation is not used. It is reserved for possible future use. // - // Type: string // Read-only: true - LBID Name = "load-balancer.hetzner.cloud/id" + LBID String = "load-balancer.hetzner.cloud/id" ) diff --git a/internal/annotation/name.go b/internal/annotation/name.go deleted file mode 100644 index 1b0e67432..000000000 --- a/internal/annotation/name.go +++ /dev/null @@ -1,333 +0,0 @@ -package annotation - -import ( - "errors" - "fmt" - "net" - "strconv" - "strings" - "time" - - corev1 "k8s.io/api/core/v1" - - "github.com/hetznercloud/hcloud-cloud-controller-manager/internal/metrics" - "github.com/hetznercloud/hcloud-go/v2/hcloud" -) - -// ErrNotSet signals that an annotation was not set. -var ErrNotSet = errors.New("not set") - -// Name defines the name of a K8S annotation. -type Name string - -// StringFromService retrieves the value belonging to the annotation from svc. -// -// If svc has no value for the annotation the second return value is false. -func (s Name) StringFromService(svc *corev1.Service) (string, bool) { - if svc.Annotations == nil { - return "", false - } - v, ok := svc.Annotations[string(s)] - return v, ok -} - -// StringsFromService retrieves the []string value belonging to the annotation -// from svc. -// -// StringsFromService returns ErrNotSet annotation was not set. -func (s Name) StringsFromService(svc *corev1.Service) ([]string, error) { - const op = "annotation/Name.StringsFromService" - metrics.OperationCalled.WithLabelValues(op).Inc() - - var ss []string - - err := s.applyToValue(op, svc, func(v string) error { - ss = strings.Split(v, ",") - return nil - }) - - return ss, err -} - -// BoolFromService retrieves the boolean value belonging to the annotation from -// svc. -// -// BoolFromService returns an error if the value could not be converted to a -// boolean, or the annotation was not set. In the case of a missing value, the -// error wraps ErrNotSet. -func (s Name) BoolFromService(svc *corev1.Service) (bool, error) { - const op = "annotation/Name.BoolFromService" - metrics.OperationCalled.WithLabelValues(op).Inc() - - v, ok := s.StringFromService(svc) - if !ok { - return false, fmt.Errorf("%s: %v: %w", op, s, ErrNotSet) - } - b, err := strconv.ParseBool(v) - if err != nil { - return false, fmt.Errorf("%s: %v: %w", op, s, err) - } - return b, nil -} - -// IntFromService retrieves the int value belonging to the annotation from svc. -// -// IntFromService returns an error if the value could not be converted to an -// int, or the annotation was not set. In the case of a missing value, the -// error wraps ErrNotSet. -func (s Name) IntFromService(svc *corev1.Service) (int, error) { - const op = "annotation/Name.IntFromService" - metrics.OperationCalled.WithLabelValues(op).Inc() - - v, ok := s.StringFromService(svc) - if !ok { - return 0, fmt.Errorf("%s: %v: %w", op, s, ErrNotSet) - } - i, err := strconv.Atoi(v) - if err != nil { - return 0, fmt.Errorf("%s: %v: %w", op, s, err) - } - return i, nil -} - -// IntsFromService retrieves the []int value belonging to the annotation from -// svc. -// -// IntsFromService returns an error if the value could not be converted to a -// []int, or the annotation was not set. In the case of a missing value, the -// error wraps ErrNotSet. -func (s Name) IntsFromService(svc *corev1.Service) ([]int, error) { - const op = "annotation/Name.IntsFromService" - metrics.OperationCalled.WithLabelValues(op).Inc() - - var is []int - - err := s.applyToValue(op, svc, func(v string) error { - ss := strings.Split(v, ",") - is = make([]int, len(ss)) - - for i, s := range ss { - iv, err := strconv.Atoi(s) - if err != nil { - return err - } - is[i] = iv - } - return nil - }) - - return is, err -} - -// IPFromService retrieves the net.IP value belonging to the annotation from -// svc. -// -// IPFromService returns an error if the value could not be converted to a -// net.IP, or the annotation was not set. In the case of a missing value, the -// error wraps ErrNotSet. -func (s Name) IPFromService(svc *corev1.Service) (net.IP, error) { - const op = "annotation/Name.IPFromService" - metrics.OperationCalled.WithLabelValues(op).Inc() - - var ip net.IP - - err := s.applyToValue(op, svc, func(v string) error { - ip = net.ParseIP(v) - if ip == nil { - return fmt.Errorf("invalid ip address: %s", v) - } - return nil - }) - - return ip, err -} - -// DurationFromService retrieves the time.Duration value belonging to the -// annotation from svc. -// -// DurationFromService returns an error if the value could not be converted to -// a time.Duration, or the annotation was not set. In the case of a missing -// value, the error wraps ErrNotSet. -func (s Name) DurationFromService(svc *corev1.Service) (time.Duration, error) { - const op = "annotation/Name.DurationFromService" - metrics.OperationCalled.WithLabelValues(op).Inc() - - var d time.Duration - - err := s.applyToValue(op, svc, func(v string) error { - var err error - - d, err = time.ParseDuration(v) - return err - }) - - return d, err -} - -// LBSvcProtocolFromService retrieves the hcloud.LoadBalancerServiceProtocol -// value belonging to the annotation from svc. -// -// LBSvcProtocolFromService returns an error if the value could not be -// converted to a hcloud.LoadBalancerServiceProtocol, or the annotation was not -// set. In the case of a missing value, the error wraps ErrNotSet. -func (s Name) LBSvcProtocolFromService(svc *corev1.Service) (hcloud.LoadBalancerServiceProtocol, error) { - const op = "annotation/Name.LBSvcProtocolFromService" - metrics.OperationCalled.WithLabelValues(op).Inc() - - var p hcloud.LoadBalancerServiceProtocol - - err := s.applyToValue(op, svc, func(v string) error { - var err error - - p, err = validateServiceProtocol(v) - return err - }) - - return p, err -} - -// LBAlgorithmTypeFromService retrieves the hcloud.LoadBalancerAlgorithmType -// value belonging to the annotation from svc. -// -// LBAlgorithmTypeFromService returns an error if the value could not be -// converted to a hcloud.LoadBalancerAlgorithmType, or the annotation was not -// set. In the case of a missing value, the error wraps ErrNotSet. -func (s Name) LBAlgorithmTypeFromService(svc *corev1.Service) (hcloud.LoadBalancerAlgorithmType, error) { - const op = "annotation/Name.LBAlgorithmTypeFromService" - metrics.OperationCalled.WithLabelValues(op).Inc() - - var alg hcloud.LoadBalancerAlgorithmType - - err := s.applyToValue(op, svc, func(v string) error { - var err error - - alg, err = validateAlgorithmType(v) - return err - }) - - return alg, err -} - -// NetworkZoneFromService retrieves the hcloud.NetworkZone value belonging to -// the annotation from svc. -// -// NetworkZoneFromService returns ErrNotSet if the annotation was not set. -func (s Name) NetworkZoneFromService(svc *corev1.Service) (hcloud.NetworkZone, error) { - const op = "annotation/Name.NetworkZoneFromService" - metrics.OperationCalled.WithLabelValues(op).Inc() - - var nz hcloud.NetworkZone - - err := s.applyToValue(op, svc, func(v string) error { - nz = hcloud.NetworkZone(v) - return nil - }) - - return nz, err -} - -// CertificatesFromService retrieves the []*hcloud.Certificate value belonging -// to the annotation from svc. -// -// CertificatesFromService returns an error if the value could not be converted -// to a []*hcloud.Certificate, or the annotation was not set. In the case of a -// missing value, the error wraps ErrNotSet. -func (s Name) CertificatesFromService(svc *corev1.Service) ([]*hcloud.Certificate, error) { - const op = "annotation/Name.CertificatesFromService" - metrics.OperationCalled.WithLabelValues(op).Inc() - - var cs []*hcloud.Certificate - - err := s.applyToValue(op, svc, func(v string) error { - ss := strings.Split(v, ",") - cs = make([]*hcloud.Certificate, len(ss)) - - for i, s := range ss { - id, err := strconv.ParseInt(s, 10, 64) - if err != nil { - // If we could not parse the string as an integer we assume it - // is a name not an id. - cs[i] = &hcloud.Certificate{Name: s} - continue - } - cs[i] = &hcloud.Certificate{ID: id} - } - - return nil - }) - - return cs, err -} - -// CertificateTypeFromService retrieves the hcloud.CertificateType value -// belonging to the annotation from svc. -// -// CertificateTypeFromService returns an error if the value could not be -// converted to a hcloud.CertificateType. In the case of a missing value, the -// error wraps ErrNotSet. -func (s Name) CertificateTypeFromService(svc *corev1.Service) (hcloud.CertificateType, error) { - const op = "annotation/Name.CertificateTypeFromService" - metrics.OperationCalled.WithLabelValues(op).Inc() - - var ct hcloud.CertificateType - - err := s.applyToValue(op, svc, func(v string) error { - switch strings.ToLower(v) { - case string(hcloud.CertificateTypeUploaded): - ct = hcloud.CertificateTypeUploaded - case string(hcloud.CertificateTypeManaged): - ct = hcloud.CertificateTypeManaged - default: - return fmt.Errorf("%s: unsupported certificate type: %s", op, v) - } - return nil - }) - - return ct, err -} - -func (s Name) applyToValue(op string, svc *corev1.Service, f func(string) error) error { - v, ok := s.StringFromService(svc) - if !ok { - return fmt.Errorf("%s: %v: %w", op, s, ErrNotSet) - } - if err := f(v); err != nil { - return fmt.Errorf("%s: %w", op, err) - } - return nil -} - -func validateAlgorithmType(algorithmType string) (hcloud.LoadBalancerAlgorithmType, error) { - const op = "annotation/validateAlgorithmType" - metrics.OperationCalled.WithLabelValues(op).Inc() - - algorithmType = strings.ToLower(algorithmType) // Lowercase because all our protocols are lowercase - hcloudAlgorithmType := hcloud.LoadBalancerAlgorithmType(algorithmType) - - switch hcloudAlgorithmType { - case hcloud.LoadBalancerAlgorithmTypeLeastConnections: - case hcloud.LoadBalancerAlgorithmTypeRoundRobin: - default: - return "", fmt.Errorf("%s: invalid: %s", op, algorithmType) - } - - return hcloudAlgorithmType, nil -} - -func validateServiceProtocol(protocol string) (hcloud.LoadBalancerServiceProtocol, error) { - const op = "annotation/validateServiceProtocol" - metrics.OperationCalled.WithLabelValues(op).Inc() - - protocol = strings.ToLower(protocol) // Lowercase because all our protocols are lowercase - hcloudProtocol := hcloud.LoadBalancerServiceProtocol(protocol) - switch hcloudProtocol { - case hcloud.LoadBalancerServiceProtocolTCP: - case hcloud.LoadBalancerServiceProtocolHTTPS: - case hcloud.LoadBalancerServiceProtocolHTTP: - // Valid - break - default: - return "", fmt.Errorf("%s: invalid: %s", op, protocol) - } - return hcloudProtocol, nil -} diff --git a/internal/annotation/name_test.go b/internal/annotation/name_test.go deleted file mode 100644 index 1a5cbf7c4..000000000 --- a/internal/annotation/name_test.go +++ /dev/null @@ -1,392 +0,0 @@ -package annotation_test - -import ( - "errors" - "fmt" - "net" - "strconv" - "testing" - "time" - - "github.com/stretchr/testify/assert" - corev1 "k8s.io/api/core/v1" - - "github.com/hetznercloud/hcloud-cloud-controller-manager/internal/annotation" - "github.com/hetznercloud/hcloud-go/v2/hcloud" -) - -const ann annotation.Name = "some/annotation" - -func TestName_StringFromService(t *testing.T) { - tests := []struct { - name string - svcAnnotations map[annotation.Name]string - ok bool - expected string - }{ - { - name: "value as string", - svcAnnotations: map[annotation.Name]string{ann: "some value"}, - ok: true, - expected: "some value", - }, - { - name: "Service has no annotations", - }, - { - name: "value not set", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var svc corev1.Service - svc.Annotations = map[string]string{} - - for k, v := range tt.svcAnnotations { - svc.Annotations[string(k)] = v - } - actual, ok := ann.StringFromService(&svc) - assert.Equal(t, tt.ok, ok) - assert.Equal(t, tt.expected, actual) - }) - } -} - -func TestName_StringsFromService(t *testing.T) { - tests := []typedAccessorTest{ - { - name: "value set", - svcAnnotations: map[annotation.Name]string{ - ann: "a,b,c", - }, - expected: []string{"a", "b", "c"}, - }, - { - name: "value missing", - err: annotation.ErrNotSet, - }, - } - - runAllTypedAccessorTests(t, tests, func(svc *corev1.Service) (any, error) { - return ann.StringsFromService(svc) - }) -} - -func TestName_BoolFromService(t *testing.T) { - tests := []typedAccessorTest{ - { - name: "value set to true", - svcAnnotations: map[annotation.Name]string{ann: "true"}, - expected: true, - }, - { - name: "value set to false", - svcAnnotations: map[annotation.Name]string{ann: "false"}, - expected: false, - }, - { - name: "value missing", - expected: false, - err: annotation.ErrNotSet, - }, - { - name: "value invalid", - svcAnnotations: map[annotation.Name]string{ann: "invalid"}, - expected: false, - err: strconv.ErrSyntax, - }, - } - - runAllTypedAccessorTests(t, tests, func(svc *corev1.Service) (any, error) { - return ann.BoolFromService(svc) - }) -} - -func TestName_IntFromService(t *testing.T) { - tests := []typedAccessorTest{ - { - name: "value set to 10", - svcAnnotations: map[annotation.Name]string{ann: "10"}, - expected: 10, - }, - { - name: "value missing", - expected: 0, - err: fmt.Errorf("annotation/Name.IntFromService: %s: %w", ann, annotation.ErrNotSet), - }, - { - name: "value invalid", - svcAnnotations: map[annotation.Name]string{ann: "invalid"}, - expected: 0, - err: strconv.ErrSyntax, - }, - } - - runAllTypedAccessorTests(t, tests, func(svc *corev1.Service) (any, error) { - return ann.IntFromService(svc) - }) -} - -func TestName_IntsFromService(t *testing.T) { - tests := []typedAccessorTest{ - { - name: "value set", - svcAnnotations: map[annotation.Name]string{ - ann: "5,8", - }, - expected: []int{5, 8}, - }, - { - name: "value missing", - err: annotation.ErrNotSet, - }, - { - name: "value invalid", - svcAnnotations: map[annotation.Name]string{ann: "invalid"}, - err: strconv.ErrSyntax, - }, - } - - runAllTypedAccessorTests(t, tests, func(svc *corev1.Service) (any, error) { - return ann.IntsFromService(svc) - }) -} - -func TestName_IPFromService(t *testing.T) { - tests := []typedAccessorTest{ - { - name: "value set to valid IPv4", - svcAnnotations: map[annotation.Name]string{ - ann: "1.2.3.4", - }, - expected: net.ParseIP("1.2.3.4"), - }, - { - name: "value set to valid IPv6", - svcAnnotations: map[annotation.Name]string{ - ann: "3c2e:2ef9:a7e9:1a5b:30ba:4912:e3fe:91b2", - }, - expected: net.ParseIP("3c2e:2ef9:a7e9:1a5b:30ba:4912:e3fe:91b2"), - }, - { - name: "value invalid", - svcAnnotations: map[annotation.Name]string{ - ann: "invalid", - }, - err: errors.New("annotation/Name.IPFromService: invalid ip address: invalid"), - }, - { - name: "value not set", - err: annotation.ErrNotSet, - }, - } - - runAllTypedAccessorTests(t, tests, func(svc *corev1.Service) (any, error) { - return ann.IPFromService(svc) - }) -} - -func TestName_DurationFromService(t *testing.T) { - tests := []typedAccessorTest{ - { - name: "value set", - svcAnnotations: map[annotation.Name]string{ - ann: "1h", - }, - expected: time.Hour, - }, - { - name: "value not set", - err: annotation.ErrNotSet, - }, - { - name: "value invalid", - svcAnnotations: map[annotation.Name]string{ - ann: "invalid", - }, - err: errors.New("annotation/Name.DurationFromService: time: invalid duration \"invalid\""), - }, - } - - runAllTypedAccessorTests(t, tests, func(svc *corev1.Service) (any, error) { - return ann.DurationFromService(svc) - }) -} - -func TestName_LBSvcProtocolFromService(t *testing.T) { - tests := []typedAccessorTest{ - { - name: "value set", - svcAnnotations: map[annotation.Name]string{ - ann: string(hcloud.LoadBalancerServiceProtocolHTTP), - }, - expected: hcloud.LoadBalancerServiceProtocolHTTP, - }, - { - name: "value not set", - err: annotation.ErrNotSet, - }, - { - name: "value invalid", - svcAnnotations: map[annotation.Name]string{ - ann: "invalid", - }, - err: errors.New("annotation/Name.LBSvcProtocolFromService: annotation/validateServiceProtocol: invalid: invalid"), - }, - } - - runAllTypedAccessorTests(t, tests, func(svc *corev1.Service) (any, error) { - return ann.LBSvcProtocolFromService(svc) - }) -} - -func TestName_LBAlgorithmTypeFromService(t *testing.T) { - tests := []typedAccessorTest{ - { - name: "value set", - svcAnnotations: map[annotation.Name]string{ - ann: string(hcloud.LoadBalancerAlgorithmTypeLeastConnections), - }, - expected: hcloud.LoadBalancerAlgorithmTypeLeastConnections, - }, - { - name: "value not set", - err: annotation.ErrNotSet, - }, - { - name: "value invalid", - svcAnnotations: map[annotation.Name]string{ - ann: "invalid", - }, - err: errors.New("annotation/Name.LBAlgorithmTypeFromService: annotation/validateAlgorithmType: invalid: invalid"), - }, - } - - runAllTypedAccessorTests(t, tests, func(svc *corev1.Service) (any, error) { - return ann.LBAlgorithmTypeFromService(svc) - }) -} - -func TestName_NetworkZoneFromService(t *testing.T) { - tests := []typedAccessorTest{ - { - name: "value set", - svcAnnotations: map[annotation.Name]string{ - ann: string(hcloud.NetworkZoneEUCentral), - }, - expected: hcloud.NetworkZoneEUCentral, - }, - { - name: "value not set", - err: annotation.ErrNotSet, - }, - } - - runAllTypedAccessorTests(t, tests, func(svc *corev1.Service) (any, error) { - return ann.NetworkZoneFromService(svc) - }) -} - -func TestName_CertificatesFromService(t *testing.T) { - tests := []typedAccessorTest{ - { - name: "ids set", - svcAnnotations: map[annotation.Name]string{ - ann: "3,5", - }, - expected: []*hcloud.Certificate{{ID: 3}, {ID: 5}}, - }, - { - name: "names set", - svcAnnotations: map[annotation.Name]string{ - ann: "cert-1,cert-2", - }, - expected: []*hcloud.Certificate{{Name: "cert-1"}, {Name: "cert-2"}}, - }, - { - name: "value not set", - err: annotation.ErrNotSet, - }, - } - - runAllTypedAccessorTests(t, tests, func(svc *corev1.Service) (any, error) { - return ann.CertificatesFromService(svc) - }) -} - -func TestName_CertificateTypeFromService(t *testing.T) { - tests := []typedAccessorTest{ - { - name: "uploaded certificate", - svcAnnotations: map[annotation.Name]string{ - ann: string(hcloud.CertificateTypeUploaded), - }, - expected: hcloud.CertificateTypeUploaded, - }, - { - name: "managed certificate", - svcAnnotations: map[annotation.Name]string{ - ann: string(hcloud.CertificateTypeManaged), - }, - expected: hcloud.CertificateTypeManaged, - }, - { - name: "unsupported certificate type", - svcAnnotations: map[annotation.Name]string{ - ann: "unsupported type", - }, - err: fmt.Errorf("annotation/Name.CertificateTypeFromService: annotation/Name.CertificateTypeFromService: unsupported certificate type: unsupported type"), - }, - } - - runAllTypedAccessorTests(t, tests, func(svc *corev1.Service) (any, error) { - return ann.CertificateTypeFromService(svc) - }) -} - -type typedAccessorTest struct { - name string - svcAnnotations map[annotation.Name]string - err error - expected any -} - -func (tt *typedAccessorTest) run(t *testing.T, call func(svc *corev1.Service) (any, error)) { - t.Helper() - - var svc corev1.Service - svc.Annotations = map[string]string{} - - for k, v := range tt.svcAnnotations { - svc.Annotations[string(k)] = v - } - - actual, err := call(&svc) - if tt.err != nil { - if errors.Is(err, tt.err) { - return - } - assert.EqualError(t, err, tt.err.Error()) - return - } - assert.NoError(t, err) - // Don't use assert.Equal to compare nil values, as it requires the nil - // values to be casted to the correct type. - if tt.expected == nil && actual == nil { - return - } - assert.Equal(t, tt.expected, actual) -} - -func runAllTypedAccessorTests( - t *testing.T, tests []typedAccessorTest, call func(svc *corev1.Service) (any, error), -) { - t.Helper() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - tt.run(t, call) - }) - } -} diff --git a/internal/hcops/load_balancer.go b/internal/hcops/load_balancer.go index 57b22a43c..27ed43515 100644 --- a/internal/hcops/load_balancer.go +++ b/internal/hcops/load_balancer.go @@ -127,7 +127,7 @@ func (l *LoadBalancerOps) getType(ctx context.Context, svc *corev1.Service) (*hc lbTypeName = l.Cfg.LoadBalancer.Type } - if v, ok := annotation.LBType.StringFromService(svc); ok { + if v, err := annotation.LBType.FromService(svc); err == nil { lbTypeName = v } @@ -195,7 +195,7 @@ func (l *LoadBalancerOps) Create( if l.Cfg.LoadBalancer.Location != "" { opts.Location = &hcloud.Location{Name: l.Cfg.LoadBalancer.Location} } - if v, ok := annotation.LBLocation.StringFromService(svc); ok { + if v, err := annotation.LBLocation.FromService(svc); err == nil { if v == "" { // Allow resetting the location in case someone wants to specify a network zone in an annotation // and a location as default. @@ -205,7 +205,7 @@ func (l *LoadBalancerOps) Create( } } opts.NetworkZone = hcloud.NetworkZone(l.Cfg.LoadBalancer.NetworkZone) - if v, ok := annotation.LBNetworkZone.StringFromService(svc); ok { + if v, err := annotation.LBNetworkZone.FromService(svc); err == nil { opts.NetworkZone = hcloud.NetworkZone(v) } if opts.Location == nil && opts.NetworkZone == "" { @@ -215,7 +215,7 @@ func (l *LoadBalancerOps) Create( opts.NetworkZone = "" } - algType, err := annotation.LBAlgorithmType.LBAlgorithmTypeFromService(svc) + algType, err := annotation.LBAlgorithmType.FromService(svc) switch { case err == nil: opts.Algorithm = &hcloud.LoadBalancerAlgorithm{Type: algType} @@ -227,7 +227,7 @@ func (l *LoadBalancerOps) Create( return nil, fmt.Errorf("%s: %w", op, err) } - disablePubIface, err := annotation.LBDisablePublicNetwork.BoolFromService(svc) + disablePubIface, err := annotation.LBDisablePublicNetwork.FromService(svc) switch { case err == nil: opts.PublicInterface = new(!disablePubIface) @@ -354,7 +354,7 @@ func (l *LoadBalancerOps) changeHCLBInfo(ctx context.Context, lb *hcloud.LoadBal update = true } - if lbName, ok := annotation.LBName.StringFromService(svc); ok && lbName != lb.Name { + if lbName, err := annotation.LBName.FromService(svc); err == nil && lbName != lb.Name { opts.Name = lbName update = true } @@ -377,11 +377,14 @@ func (l *LoadBalancerOps) changeIPv4RDNS(ctx context.Context, lb *hcloud.LoadBal const op = "hcops/LoadBalancerOps.changeIPv4RDNS" metrics.OperationCalled.WithLabelValues(op).Inc() - rdns, ok := annotation.LBPublicIPv4RDNS.StringFromService(svc) + rdns, err := annotation.LBPublicIPv4RDNS.FromService(svc) // If the annotation is not set, no changes are needed - if !ok { + if errors.Is(err, annotation.ErrNotSet) { return false, nil } + if err != nil { + return false, fmt.Errorf("%s: %w", op, err) + } // If the annotation and the actual value match, no changes are needed if rdns == lb.PublicNet.IPv4.DNSPtr { return false, nil @@ -402,11 +405,14 @@ func (l *LoadBalancerOps) changeIPv6RDNS(ctx context.Context, lb *hcloud.LoadBal const op = "hcops/LoadBalancerOps.changeIPv6RDNS" metrics.OperationCalled.WithLabelValues(op).Inc() - rdns, ok := annotation.LBPublicIPv6RDNS.StringFromService(svc) + rdns, err := annotation.LBPublicIPv6RDNS.FromService(svc) // If the annotation is not set, no changes are needed - if !ok { + if errors.Is(err, annotation.ErrNotSet) { return false, nil } + if err != nil { + return false, fmt.Errorf("%s: %w", op, err) + } // If the annotation and the actual value match, no changes are needed if rdns == lb.PublicNet.IPv6.DNSPtr { return false, nil @@ -427,7 +433,7 @@ func (l *LoadBalancerOps) changeAlgorithm(ctx context.Context, lb *hcloud.LoadBa const op = "hcops/LoadBalancerOps.changeAlgorithm" metrics.OperationCalled.WithLabelValues(op).Inc() - at, err := annotation.LBAlgorithmType.LBAlgorithmTypeFromService(svc) + at, err := annotation.LBAlgorithmType.FromService(svc) if err != nil { if errors.Is(err, annotation.ErrNotSet) { if l.Cfg.LoadBalancer.AlgorithmType == "" { @@ -494,7 +500,8 @@ func (l *LoadBalancerOps) detachFromNetwork(ctx context.Context, lb *hcloud.Load var changed bool - privateIPv4, privateIPv4configured := annotation.LBPrivateIPv4.StringFromService(svc) + privateIPv4, err := annotation.LBPrivateIPv4.FromService(svc) + privateIPv4configured := err == nil for _, lbpn := range lb.PrivateNet { // Don't detach the Load Balancer from the network it is supposed to // be attached to and the current private IP of the load balancer matches @@ -521,10 +528,10 @@ func (l *LoadBalancerOps) attachToNetwork(ctx context.Context, lb *hcloud.LoadBa const op = "hcops/LoadBalancerOps.attachToNetwork" metrics.OperationCalled.WithLabelValues(op).Inc() - var err error - - privateIPv4String, privateIPv4configured := annotation.LBPrivateIPv4.StringFromService(svc) - subnetString, subnetConfigured := annotation.PrivateSubnetIPRange.StringFromService(svc) + privateIPv4String, err := annotation.LBPrivateIPv4.FromService(svc) + privateIPv4configured := err == nil + subnetString, err := annotation.PrivateSubnetIPRange.FromService(svc) + subnetConfigured := err == nil if !subnetConfigured && l.Cfg.LoadBalancer.PrivateSubnetIPRange != "" { subnetString = l.Cfg.LoadBalancer.PrivateSubnetIPRange subnetConfigured = true @@ -601,7 +608,7 @@ func (l *LoadBalancerOps) togglePublicInterface(ctx context.Context, lb *hcloud. var a *hcloud.Action - disable, err := annotation.LBDisablePublicNetwork.BoolFromService(svc) + disable, err := annotation.LBDisablePublicNetwork.FromService(svc) var desiredDisable *bool switch { case err == nil: @@ -950,7 +957,7 @@ func (l *LoadBalancerOps) emitMaxTargetsReachedError(node *corev1.Node, svc *cor } func (l *LoadBalancerOps) getPrivateIPEnabled(svc *corev1.Service) (bool, error) { - usePrivateIP, err := annotation.LBUsePrivateIP.BoolFromService(svc) + usePrivateIP, err := annotation.LBUsePrivateIP.FromService(svc) if err != nil { if errors.Is(err, annotation.ErrNotSet) { return l.Cfg.LoadBalancer.PrivateIPEnabled, nil @@ -1064,14 +1071,16 @@ func (l *LoadBalancerOps) reconcileManagedCertificate(ctx context.Context, svc * const op = "hcops/LoadBalancerOps.reconcileManagedCertificate" metrics.OperationCalled.WithLabelValues(op).Inc() - if typ, ok := annotation.LBSvcHTTPCertificateType.StringFromService(svc); !ok || typ != string(hcloud.CertificateTypeManaged) { + // Compared as a raw string: only the exact value selects managed + // certificates, and an unset annotation reads as the empty string. + if typ, _ := annotation.LBSvcHTTPCertificateType.FromService(svc); typ != string(hcloud.CertificateTypeManaged) { return nil } - name, ok := annotation.LBSvcHTTPManagedCertificateName.StringFromService(svc) - if !ok || name == "" { + name, _ := annotation.LBSvcHTTPManagedCertificateName.FromService(svc) + if name == "" { name = fmt.Sprintf("ccm-managed-certificate-%s", svc.ObjectMeta.UID) } - domains, err := annotation.LBSvcHTTPManagedCertificateDomains.StringsFromService(svc) + domains, err := annotation.LBSvcHTTPManagedCertificateDomains.FromService(svc) if errors.Is(err, annotation.ErrNotSet) { return fmt.Errorf("%s: no domains for managed certificate", op) } @@ -1081,7 +1090,7 @@ func (l *LoadBalancerOps) reconcileManagedCertificate(ctx context.Context, svc * // It's ok to ignore the error here. We are only interested if the // annotation is set and parseable as a truthy boolean. Anything else tells // us we do not want to use ACME staging. - if ok, _ := annotation.LBSvcHTTPManagedCertificateUseACMEStaging.BoolFromService(svc); ok { + if ok, _ := annotation.LBSvcHTTPManagedCertificateUseACMEStaging.FromService(svc); ok { labels["HC-Use-Staging-CA"] = "true" } err = l.CertOps.CreateManagedCertificate(ctx, hcloud.CertificateCreateOpts{ @@ -1146,7 +1155,7 @@ func (b *hclbServiceOptsBuilder) extract() { b.destinationPort = int(b.Port.NodePort) b.do(func() error { - pp, err := annotation.LBSvcProxyProtocol.BoolFromService(b.Service) + pp, err := annotation.LBSvcProxyProtocol.FromService(b.Service) if err == nil { b.proxyProtocol = new(pp) return nil @@ -1160,7 +1169,7 @@ func (b *hclbServiceOptsBuilder) extract() { b.protocol = hcloud.LoadBalancerServiceProtocolTCP b.do(func() error { - p, err := annotation.LBSvcProtocol.LBSvcProtocolFromService(b.Service) + p, err := annotation.LBSvcProtocol.FromService(b.Service) if errors.Is(err, annotation.ErrNotSet) { return nil } @@ -1171,13 +1180,13 @@ func (b *hclbServiceOptsBuilder) extract() { return nil }) - if v, ok := annotation.LBSvcHTTPCookieName.StringFromService(b.Service); ok { + if v, err := annotation.LBSvcHTTPCookieName.FromService(b.Service); err == nil { b.httpOpts.CookieName = &v b.addHTTP = true } b.do(func() error { - lt, err := annotation.LBSvcHTTPCookieLifetime.DurationFromService(b.Service) + lt, err := annotation.LBSvcHTTPCookieLifetime.FromService(b.Service) if errors.Is(err, annotation.ErrNotSet) { return nil } @@ -1190,7 +1199,7 @@ func (b *hclbServiceOptsBuilder) extract() { }) b.do(func() error { - timeout, err := annotation.LBSvcHTTPTimeoutIdle.DurationFromService(b.Service) + timeout, err := annotation.LBSvcHTTPTimeoutIdle.FromService(b.Service) if errors.Is(err, annotation.ErrNotSet) { return nil } @@ -1203,13 +1212,13 @@ func (b *hclbServiceOptsBuilder) extract() { }) b.do(func() error { - certtyp, ok := annotation.LBSvcHTTPCertificateType.StringFromService(b.Service) - if ok && certtyp == string(hcloud.CertificateTypeManaged) { + certtyp, _ := annotation.LBSvcHTTPCertificateType.FromService(b.Service) + if certtyp == string(hcloud.CertificateTypeManaged) { // Continue with managed certificates below return nil } - certs, err := annotation.LBSvcHTTPCertificates.CertificatesFromService(b.Service) + certs, err := annotation.LBSvcHTTPCertificates.FromService(b.Service) if errors.Is(err, annotation.ErrNotSet) { return nil } @@ -1230,8 +1239,8 @@ func (b *hclbServiceOptsBuilder) extract() { }) b.do(func() error { - certtyp, ok := annotation.LBSvcHTTPCertificateType.StringFromService(b.Service) - if !ok || certtyp != string(hcloud.CertificateTypeManaged) { + certtyp, _ := annotation.LBSvcHTTPCertificateType.FromService(b.Service) + if certtyp != string(hcloud.CertificateTypeManaged) { // Not a a managed certificate. return nil } @@ -1250,7 +1259,7 @@ func (b *hclbServiceOptsBuilder) extract() { }) b.do(func() error { - redirectHTTP, err := annotation.LBSvcRedirectHTTP.BoolFromService(b.Service) + redirectHTTP, err := annotation.LBSvcRedirectHTTP.FromService(b.Service) if errors.Is(err, annotation.ErrNotSet) { return nil } @@ -1263,7 +1272,7 @@ func (b *hclbServiceOptsBuilder) extract() { }) b.do(func() error { - stickySessions, err := annotation.LBSvcHTTPStickySessions.BoolFromService(b.Service) + stickySessions, err := annotation.LBSvcHTTPStickySessions.FromService(b.Service) if errors.Is(err, annotation.ErrNotSet) { return nil } @@ -1303,7 +1312,7 @@ func (b *hclbServiceOptsBuilder) extractHealthCheck() { metrics.OperationCalled.WithLabelValues(op).Inc() b.do(func() error { - p, err := annotation.LBSvcHealthCheckProtocol.LBSvcProtocolFromService(b.Service) + p, err := annotation.LBSvcHealthCheckProtocol.FromService(b.Service) if errors.Is(err, annotation.ErrNotSet) { // Set the service protocol but do not set the addHealthCheck flag. // This way the health check is configured using the service @@ -1321,7 +1330,7 @@ func (b *hclbServiceOptsBuilder) extractHealthCheck() { }) b.do(func() error { - hcPort, err := annotation.LBSvcHealthCheckPort.IntFromService(b.Service) + hcPort, err := annotation.LBSvcHealthCheckPort.FromService(b.Service) if errors.Is(err, annotation.ErrNotSet) { return nil } @@ -1334,7 +1343,7 @@ func (b *hclbServiceOptsBuilder) extractHealthCheck() { }) b.do(func() error { - hcInterval, err := annotation.LBSvcHealthCheckInterval.DurationFromService(b.Service) + hcInterval, err := annotation.LBSvcHealthCheckInterval.FromService(b.Service) if err == nil { b.healthCheckOpts.Interval = &hcInterval b.addHealthCheck = true @@ -1351,7 +1360,7 @@ func (b *hclbServiceOptsBuilder) extractHealthCheck() { }) b.do(func() error { - t, err := annotation.LBSvcHealthCheckTimeout.DurationFromService(b.Service) + t, err := annotation.LBSvcHealthCheckTimeout.FromService(b.Service) if err == nil { b.healthCheckOpts.Timeout = &t b.addHealthCheck = true @@ -1368,7 +1377,7 @@ func (b *hclbServiceOptsBuilder) extractHealthCheck() { }) b.do(func() error { - v, err := annotation.LBSvcHealthCheckRetries.IntFromService(b.Service) + v, err := annotation.LBSvcHealthCheckRetries.FromService(b.Service) if err == nil { b.healthCheckOpts.Retries = &v b.addHealthCheck = true @@ -1388,16 +1397,16 @@ func (b *hclbServiceOptsBuilder) extractHealthCheck() { return } - if v, ok := annotation.LBSvcHealthCheckHTTPDomain.StringFromService(b.Service); ok { + if v, err := annotation.LBSvcHealthCheckHTTPDomain.FromService(b.Service); err == nil { b.healthCheckOpts.httpOpts.Domain = &v } - if v, ok := annotation.LBSvcHealthCheckHTTPPath.StringFromService(b.Service); ok { + if v, err := annotation.LBSvcHealthCheckHTTPPath.FromService(b.Service); err == nil { b.healthCheckOpts.httpOpts.Path = &v } b.do(func() error { - tls, err := annotation.LBSvcHealthCheckHTTPValidateCertificate.BoolFromService(b.Service) + tls, err := annotation.LBSvcHealthCheckHTTPValidateCertificate.FromService(b.Service) if errors.Is(err, annotation.ErrNotSet) { return nil } @@ -1409,7 +1418,7 @@ func (b *hclbServiceOptsBuilder) extractHealthCheck() { }) b.do(func() error { - scs, err := annotation.LBSvcHealthCheckHTTPStatusCodes.StringsFromService(b.Service) + scs, err := annotation.LBSvcHealthCheckHTTPStatusCodes.FromService(b.Service) if errors.Is(err, annotation.ErrNotSet) { return nil } diff --git a/internal/hcops/load_balancer_test.go b/internal/hcops/load_balancer_test.go index f99fca0bc..ac38c68a8 100644 --- a/internal/hcops/load_balancer_test.go +++ b/internal/hcops/load_balancer_test.go @@ -471,7 +471,7 @@ func TestLoadBalancerOps_Create(t *testing.T) { string(annotation.LBLocation): "nbg1", string(annotation.LBAlgorithmType): "invalidType", }, - err: fmt.Errorf("hcops/LoadBalancerOps.Create: annotation/Name.LBAlgorithmTypeFromService: annotation/validateAlgorithmType: invalid: invalidtype"), + err: fmt.Errorf("hcops/LoadBalancerOps.Create: load-balancer.hetzner.cloud/algorithm-type: invalid: invalidType"), }, { name: "disable public interface", @@ -661,7 +661,7 @@ func TestLoadBalancerOps_ReconcileHCLB(t *testing.T) { perform: func(t *testing.T, tt *LBReconcilementTestCase) { changed, err := tt.fx.LBOps.ReconcileHCLB(tt.fx.Ctx, tt.initialLB, tt.service) assert.EqualError(t, err, - "hcops/LoadBalancerOps.ReconcileHCLB: hcops/LoadBalancerOps.changeAlgorithm: annotation/Name.LBAlgorithmTypeFromService: annotation/validateAlgorithmType: invalid: invalidtype") + "hcops/LoadBalancerOps.ReconcileHCLB: hcops/LoadBalancerOps.changeAlgorithm: load-balancer.hetzner.cloud/algorithm-type: invalid: invalidType") assert.False(t, changed) }, }, diff --git a/tools/doc_generation.go b/tools/doc_generation.go index ef54faaff..7d6b5d6ef 100644 --- a/tools/doc_generation.go +++ b/tools/doc_generation.go @@ -16,6 +16,20 @@ type TemplateData struct { ConstTable string } +// docTypes maps the type an annotation is declared with to the type shown in +// the reference documentation. +var docTypes = map[string]string{ + "String": "string", + "Bool": "bool", + "Int": "int", + "Duration": "duration", + "Strings": "string", + "IP": "string", + "Protocol": "tcp | http | https", + "AlgorithmType": "round_robin | least_connections", + "Certificates": "string", +} + type ConstantDocTable struct { entries map[string]*DocEntry } @@ -23,6 +37,7 @@ type ConstantDocTable struct { type DocEntry struct { rawCommentLines []string constName string + declaredType string pos int Description string @@ -38,10 +53,11 @@ func NewDocTable() *ConstantDocTable { } } -func (t *ConstantDocTable) AddEntry(constValue, constName string, pos int) { +func (t *ConstantDocTable) AddEntry(constValue, constName, declaredType string, pos int) { t.entries[constValue] = &DocEntry{ rawCommentLines: make([]string, 0), constName: constName, + declaredType: declaredType, pos: pos, } } @@ -62,6 +78,14 @@ func (t *ConstantDocTable) FromAST(node ast.Node) (*ConstantDocTable, error) { for constValue, entry := range t.entries { commentBuilder := strings.Builder{} + if entry.declaredType != "" { + docType, ok := docTypes[entry.declaredType] + if !ok { + return nil, fmt.Errorf("unknown type %s for %s", entry.declaredType, constValue) + } + entry.Type = docType + } + for i, line := range entry.rawCommentLines { if val := parseMetadataValue(line, "Type: "); val != "" { entry.Type = val @@ -198,7 +222,12 @@ func (t *ConstantDocTable) visitFunc() func(n ast.Node) bool { constName := valueSpec.Names[0].Name value := strings.ReplaceAll(literal.Value, "\"", "") - t.AddEntry(value, constName, pos) + var declaredType string + if ident, ok := valueSpec.Type.(*ast.Ident); ok { + declaredType = ident.Name + } + + t.AddEntry(value, constName, declaredType, pos) for _, comment := range valueSpec.Doc.List { t.AppendComment(value, comment.Text)