diff --git a/.gitignore b/.gitignore index 9d09909..ed603ce 100644 --- a/.gitignore +++ b/.gitignore @@ -1,188 +1,25 @@ -########### -# Project # -########### - -hack/tools/bin - -cmd/testing/ - -kubeconfig.yaml - -artifacts/ -images.txt -images.json -admission-images.txt - -# make output -bin/ -out/ - -hack/generate-internal-groups.sh -hack/generate-controller-registration.sh - -########## -# Golang # -########## - -# Binaries for programs and plugins -*.exe -*.exe~ -*.dll -*.so -*.dylib - -# Test binary, built with `go test -c` -*.test - -# Output of the go coverage tool, specifically when used with LiteIDE -*.out - -# Dependency directories (remove the comment below to include it) -# vendor/ - -__debug_bin* - -########## -# Linux # -########## - -*~ - -# temporary files which can be created if a process still has a handle open of a deleted file -.fuse_hidden* - -# KDE directory preferences -.directory - -# Linux trash folder which might appear on any partition or disk -.Trash-* - -# .nfs files are created when an open file is removed but is still being accessed -.nfs* - -########### -# Windows # -########### - -# Windows thumbnail cache files -Thumbs.db -Thumbs.db:encryptable -ehthumbs.db -ehthumbs_vista.db - -# Dump file -*.stackdump - -# Folder config file -[Dd]esktop.ini - -# Recycle Bin used on file shares -$RECYCLE.BIN/ - -# Windows Installer files -*.cab -*.msi -*.msix -*.msm -*.msp - -# Windows shortcuts -*.lnk - -######### -# macOS # -######### - -# General -.DS_Store -.AppleDouble -.LSOverride - -# Icon must end with two \r -Icon - -# Thumbnails -._* - -# Files that might appear in the root of a volume -.DocumentRevisions-V100 -.fseventsd -.Spotlight-V100 -.TemporaryItems -.Trashes -.VolumeIcon.icns -.com.apple.timemachine.donotpresent - -# Directories potentially created on remote AFP share -.AppleDB -.AppleDesktop -Network Trash Folder -Temporary Items -.apdisk - -########## -# VSCODE # -########## - -.vscode/* -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json -*.code-workspace - -# Local History for Visual Studio Code -.history/ - -############# -# JetBrains # -############# - +.vscode .idea -# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider -# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 - -# User-specific stuff -.idea/**/workspace.xml -.idea/**/tasks.xml -.idea/**/usage.statistics.xml -.idea/**/dictionaries -.idea/**/shelf - -# AWS User-specific -.idea/**/aws.xml - -# Generated files -.idea/**/contentModel.xml - -# Sensitive or high-churn files -.idea/**/dataSources/ -.idea/**/dataSources.ids -.idea/**/dataSources.local.xml -.idea/**/sqlDataSources.xml -.idea/**/dynamic.xml -.idea/**/uiDesigner.xml -.idea/**/dbnavigator.xml - -# Mongo Explorer plugin -.idea/**/mongoSettings.xml - -# File-based project format -*.iws - -# mpeltonen/sbt-idea plugin -.idea_modules/ - -# JIRA plugin -atlassian-ide-plugin.xml +/dev +/secrets +/hack/tools/bin/* +!/hack/tools/bin/.gitkeep +cover.out +cover.html +.envrc + +# test binaries and reports +*.test +junit.xml -# Cursive Clojure plugin -.idea/replstate.xml +# files related to ondemand plugin +quotas* +hmac -# Crashlytics plugin (for Android Studio and IntelliJ) -com_crashlytics_export_strings.xml -crashlytics.properties -crashlytics-build.properties -fabric.properties +cloud.yaml +cloud-config.yaml +__debug_* -# Editor-based Rest Client -.idea/httpRequests +test/e2e/inventory* +test/e2e/sa-key* +test/e2e/kubeconfig* diff --git a/.golangci.yaml b/.golangci.yaml index 053ac7d..a41069f 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -1,78 +1,99 @@ version: "2" - run: issues-exit-code: 1 tests: true - linters: default: none enable: + - bodyclose - copyloopvar + - dogsled + - dupl + - exhaustive + - funlen - ginkgolinter + - goconst - gocritic - - gosec + - gocyclo + - goprintffuncname + - govet - importas + - ineffassign - misspell - modernize - - nilerr + - nakedret + - noctx - nolintlint - - prealloc - revive + - rowserrcheck - staticcheck - unconvert - unparam + - unused - whitespace settings: + dupl: + threshold: 100 + exhaustive: + default-signifies-exhaustive: true + funlen: + lines: 100 + statements: 50 + goconst: + min-len: 2 + min-occurrences: 5 + ignore-string-values: + - 'true' + - 'false' + gocritic: + disabled-checks: + - dupImport + - ifElseChain + - octalLiteral + - whyNoLint + - wrapperFunc + - sprintfQuotedString + enabled-tags: + - diagnostic + - experimental + - opinionated + - performance + - style + gocyclo: + min-complexity: 15 importas: alias: - # internal packages - - pkg: github.com/stackitcloud/application-load-balancer-controller/v2/pkg/apis/stackit/v1alpha1 - alias: stackitv1alpha1 - # External imported packages + # kubernetes packages - pkg: k8s.io/api/(\w+)/(v[\w\d]+) alias: $1$2 - pkg: k8s.io/apimachinery/pkg/apis/(\w+)/(v[\w\d]+) alias: $1$2 + - pkg: k8s.io/apiextensions-apiserver/pkg/apis/(\w+)/(v[\w\d]+) + alias: $1$2 - pkg: k8s.io/apimachinery/pkg/api/([^m]\w+) alias: api${1} + - pkg: k8s.io/apimachinery/pkg/api/meta/table + alias: metatable - pkg: k8s.io/apimachinery/pkg/util/(\w+) alias: util${1} - - pkg: k8s.io/autoscaler/vertical-pod-autoscaler/pkg/apis/autoscaling.k8s.io/v1 - alias: vpaautoscalingv1 + - pkg: k8s.io/client-go/tools/clientcmd/api/(\w+) + alias: clientcmd${1} - pkg: sigs.k8s.io/controller-runtime/pkg/client/fake alias: fakeclient - pkg: sigs.k8s.io/controller-runtime/pkg/log/zap alias: logzap - pkg: sigs.k8s.io/controller-runtime/pkg/log alias: logf - - pkg: go.uber.org/mock/gomock - alias: gmock - # gardener/gardener packages - - pkg: github.com/gardener/gardener/pkg/component/(\w+)/constants - alias: ${1}constants - - pkg: github.com/gardener/gardener/extensions/pkg/webhook - alias: extensionswebhook - - pkg: github.com/gardener/gardener/extensions/pkg/util/secret/manager - alias: extensionssecretmanager - - pkg: github.com/gardener/gardener/pkg/utils/gardener - alias: gutil - - pkg: github.com/gardener/gardener/pkg/utils/kubernetes - alias: kutil - - pkg: github.com/gardener/etcd-druid/api/core/v1alpha1 - alias: druidcorev1alpha1 - - pkg: github.com/gardener/etcd-druid/api/core/crds - alias: druidcorecrds + lll: + line-length: 165 misspell: locale: US nolintlint: - require-specific: true + allow-unused: false # report any unused nolint directives + require-explanation: true # require an explanation for nolint directives + require-specific: true # require nolint directives to be specific about which linter is being skipped revive: - rules: - - name: context-as-argument - - name: duplicated-imports - - name: early-return - - name: exported - - name: unreachable-code + confidence: 0 exclusions: generated: lax presets: @@ -82,28 +103,39 @@ linters: - std-error-handling rules: - linters: - - gosec + - goconst + - noctx + - dupl + - funlen path: _test\.go - linters: - - revive - path: _test\.go - text: dot-imports - - linters: # ignore long lines in copyright - - lll - source: "^// Copyright" + - gocritic + text: uncheckedInlineErr:.+client.Ignore(NotFound|AlreadyExists) - linters: - nolintlint - text: "should be written without leading space as `//nolint" # don't require machine-readable nolint directives (i.e. with no leading space) + text: should be written without leading space as `//nolint # don't require machine-readable nolint directives (i.e. with no leading space) + - linters: + - revive + - staticcheck + text: should not use dot imports + - linters: + - revive + text: 'var-naming: avoid meaningless package names' + path: 'pkg/csi/util/*' + - linters: + - revive + text: 'var-naming: avoid package names that conflict with Go standard library package names' paths: - - zz_generated\..*\.go$ - + - third_party$ + - builtin$ + - examples$ + - pkg/imagesync/third_party/.* formatters: enable: - - gofmt - settings: - gofmt: - rewrite-rules: - - pattern: 'interface{}' - replacement: 'any' + - goimports exclusions: generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/README.md b/README.md new file mode 100644 index 0000000..dad86cf --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# Application load balancer (ALB) controller + +[![Go Reference](https://pkg.go.dev/badge/github.com/stackitcloud/application-load-balancer-controller.svg)](https://pkg.go.dev/github.com/stackitcloud/application-load-balancer-controller) + +- [User docs](./docs/user.md) \ No newline at end of file diff --git a/cmd/application-load-balancer-controller/main.go b/cmd/application-load-balancer-controller/main.go index 147db1c..ae13aba 100644 --- a/cmd/application-load-balancer-controller/main.go +++ b/cmd/application-load-balancer-controller/main.go @@ -1,7 +1,149 @@ package main -import "fmt" +import ( + "flag" + "os" + "github.com/stackitcloud/application-load-balancer-controller/pkg/controller/ingress" + albclient "github.com/stackitcloud/application-load-balancer-controller/pkg/stackit" + stackitconfig "github.com/stackitcloud/application-load-balancer-controller/pkg/stackit/config" + sdkconfig "github.com/stackitcloud/stackit-sdk-go/core/config" + albsdk "github.com/stackitcloud/stackit-sdk-go/services/alb/v2api" + certsdk "github.com/stackitcloud/stackit-sdk-go/services/certificates/v2api" + + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + _ "k8s.io/client-go/plugin/pkg/client/auth" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/healthz" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" +) + +var ( + scheme = runtime.NewScheme() + setupLog = ctrl.Log.WithName("setup") +) + +func init() { + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) +} + +// options holds the command-line options used to initialize the controller manager. +type options struct { + metricsAddr string + enableLeaderElection bool + leaderElectionNamespace string + leaderElectionID string + probeAddr string + cloudConfig string +} + +// nolint:funlen // This function isn't awfully complex. func main() { - fmt.Println("Application load balancer controller") + var opts options + + flag.StringVar(&opts.metricsAddr, "metrics-bind-address", "0", "The address the metrics endpoint binds to. "+ + "Use :8443 for HTTPS or :8080 for HTTP, or leave as 0 to disable the metrics service.") + flag.StringVar(&opts.probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") + flag.BoolVar(&opts.enableLeaderElection, "leader-elect", true, + "Enable leader election for controller manager. "+ + "Enabling this will ensure there is only one active controller manager.") + flag.StringVar(&opts.leaderElectionNamespace, "leader-election-namespace", "default", "The namespace in which the leader "+ + "election resource will be created.") + flag.StringVar(&opts.leaderElectionID, "leader-election-id", "application-load-balancer-controller.stackit.cloud", "The name of the resource that "+ + "leader election will use for holding the leader lock.") + flag.StringVar(&opts.cloudConfig, "cloud-config", "cloud.yaml", "The path to the cloud config file.") + + zapOpts := zap.Options{ + Development: true, + } + zapOpts.BindFlags(flag.CommandLine) + flag.Parse() + ctrl.SetLogger(zap.New(zap.UseFlagOptions(&zapOpts))) + + config, err := stackitconfig.ReadALBConfigFromFile(opts.cloudConfig) + if err != nil { + setupLog.Error(err, "Failed to read cloud config") + os.Exit(1) + } + + mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ + Scheme: scheme, + Metrics: metricsserver.Options{ + BindAddress: opts.metricsAddr, + }, + HealthProbeBindAddress: opts.probeAddr, + LeaderElection: opts.enableLeaderElection, + LeaderElectionID: opts.leaderElectionID, + LeaderElectionNamespace: opts.leaderElectionNamespace, + LeaderElectionReleaseOnCancel: true, + }) + if err != nil { + setupLog.Error(err, "unable to start manager") + os.Exit(1) + } + albOpts := []sdkconfig.ConfigurationOption{} + if config.Global.APIEndpoints.ApplicationLoadBalancerAPI != "" { + albOpts = append(albOpts, sdkconfig.WithEndpoint(config.Global.APIEndpoints.ApplicationLoadBalancerAPI)) + } + + certOpts := []sdkconfig.ConfigurationOption{} + if config.Global.APIEndpoints.ApplicationLoadBalancerCertificateAPI != "" { + certOpts = append(certOpts, sdkconfig.WithEndpoint(config.Global.APIEndpoints.ApplicationLoadBalancerCertificateAPI)) + } + + // Setup ALB API client + sdkClient, err := albsdk.NewAPIClient(albOpts...) + if err != nil { + setupLog.Error(err, "unable to create ALB SDK client", "controller", "IngressClass") + os.Exit(1) + } + albClient, err := albclient.NewApplicationLoadBalancerClient(sdkClient) + if err != nil { + setupLog.Error(err, "unable to create ALB client", "controller", "IngressClass") + os.Exit(1) + } + + // Setup Certificates API client + certificateAPI, err := certsdk.NewAPIClient(certOpts...) + if err != nil { + setupLog.Error(err, "unable to create certificate SDK client", "controller", "IngressClass") + os.Exit(1) + } + certificateClient, err := albclient.NewCertClient(certificateAPI) + if err != nil { + setupLog.Error(err, "unable to create Certificates client", "controller", "IngressClass") + os.Exit(1) + } + + ctx := ctrl.SetupSignalHandler() + + if err = (&ingress.IngressClassReconciler{ + Client: mgr.GetClient(), + Recorder: mgr.GetEventRecorderFor("ingressclass-controller"), + ALBClient: albClient, + CertificateClient: certificateClient, + Scheme: mgr.GetScheme(), + ALBConfig: config, + }).SetupWithManager(ctx, mgr, ""); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "IngressClass") + os.Exit(1) + } + + if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { + setupLog.Error(err, "unable to set up health check") + os.Exit(1) + } + if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { + setupLog.Error(err, "unable to set up ready check") + os.Exit(1) + } + + setupLog.Info("starting manager") + if err := mgr.Start(ctx); err != nil { + setupLog.Error(err, "problem running manager") + os.Exit(1) + } } diff --git a/docs/user.md b/docs/user.md new file mode 100644 index 0000000..2f7c7db --- /dev/null +++ b/docs/user.md @@ -0,0 +1,220 @@ +# Application Load Balancer Controller User Documentation + +The STACKIT Application Load Balancer Controller (ALBC) exposes HTTP/HTTPS applications by provisioning and configuring managed STACKIT ALBs based on native Kubernetes Ingress resources. + +### Quick start + +To expose an application, you need to deploy three core resources: an IngressClass to provision the ALB, a Service to expose your pods, and an Ingress to define the routing. + +#### The ALB (IngressClass) + +Creating an IngressClass provisions the managed ALB instance. By default, the ALB is assigned a public ephemeral IP address, unless you configure it as an internal ALB or assign a pre-existing static IP via annotations (see [Annotations](#configuration)). + +If no Ingress resources are currently linked to this class, the ALB acts as an empty listener that returns an HTTP 404 Not Found. + +You must include the `alb.stackit.cloud/network-mode: "NodePort"` annotation on the IngressClass. This is mandatory because it tells the ALB how to reach your cluster, instructing the load balancer to route incoming traffic directly to the node ports on your cluster's worker nodes. At the moment, `NodePort` is the only supported network mode. + +```YAML +apiVersion: networking.k8s.io/v1 +kind: IngressClass +metadata: + name: stackit-alb + annotations: + alb.stackit.cloud/network-mode: "NodePort" +spec: + controller: stackit.cloud/alb-ingress +``` + +#### The backend (Service) + +Expose your application pods using a Kubernetes Service. + +```YAML +apiVersion: v1 +kind: Service +metadata: + name: service-a + namespace: default + labels: + app: service-a +spec: + type: NodePort + ports: + - port: 80 + protocol: TCP + targetPort: 80 + selector: + app: service-a +``` + +#### The routing (Ingress) + +Create the Ingress resource to route incoming traffic to your backend Service. Link it to your ALB by referencing the IngressClass name. + +```YAML +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: service-ingress + namespace: default +spec: + ingressClassName: stackit-alb + rules: + - host: app.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: service-a + port: + number: 80 +``` + +The path type `ImplementationSpecific` is currently treated as `Exact`. Regex matchers are not allowed. + +The annotation `kubernetes.io/ingress.class` is not supported. Use `.spec.ingressClassName` instead. + +### Ingress grouping & ALB lifecycle + +The controller automatically merges all Ingress resources that reference the same IngressClass onto a single, shared ALB instance. To provision completely isolated ALBs (for example, to separate public and internal traffic or to assign different static IPs) you must create a distinct IngressClass for each one. + +If you delete all Ingress resources associated with a specific class, the controller deliberately does not delete the underlying ALB infrastructure. Instead, it transitions the ALB into an empty state that returns HTTP 404s. This behavior preserves your allocated IP address and prevents unnecessary infrastructure recreation delays. To completely delete the ALB and release its associated resources, you must delete the IngressClass. + +### Rule precedence + +When multiple Ingress resources share an ALB, their routing rules are evaluated chronologically by default, meaning older Ingress resources take precedence based on their CreationTimestamp. The precedence is only important if not all rules can be admitted to the load balancer. You can override this default order by adding the `alb.stackit.cloud/priority` annotation to an Ingress. Higher integer values are evaluated first, and in the event of a tie, the controller falls back to the creation timestamp. Within an ingress, rules are evaluated top to bottom. + +After the admission phase, rules are ordered differently to prefer more specific matchers. Using the following criteria: +- By path type: `Exact`, `ImplementationSpecific`, `Prefix` +- By path length, longest first +- By path lexicographically + +Note, that an ingress with a higher priority does not match first. It only means that it is preferred if not all rules can be admitted to the load balancer. + +### TLS and Certificate Rotation + +The minimal Ingress example in the Quick Start section shows an HTTP configuration. To expose your application securely via HTTPS, the ALB Ingress controller supports TLS termination using standard Kubernetes TLS Secrets. + +This functionality integrates seamlessly with tools like cert-manager to automate certificate provisioning and renewal. When a Secret is referenced in the Ingress `tls` block, the controller automatically handles the certificate deployment on the ALB. It continuously monitors the Secret for changes, such as during automated certificate rotation, and updates the ALB without manual intervention. Once a TLS Secret is no longer referenced by any Ingress on that ALB, it is automatically removed. + +By default, standard unencrypted HTTP traffic will still be possible alongside HTTPS to make automated ACME certificate challenges possible. If you want to restrict traffic so the Ingress is not reachable via standard HTTP, you can add the `alb.stackit.cloud/https-only: "true"` annotation to your Ingress or IngressClass resource. + +**Important:** Because the ALB selects certificates purely based on Server Name Indication (SNI), a certificate from one Ingress can impact others sharing the same ALB. To prevent unintended certificate serving, ensure your Ingress resources have no overlapping DNS names, use distinct ports, or separate them entirely using distinct IngressClasses. + +```YAML +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: secure-ingress + namespace: default +spec: + ingressClassName: stackit-alb + tls: + - hosts: + - secure.example.com + secretName: my-tls-secret + rules: + - host: secure.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: service-a + port: + number: 80 +``` + +The field `Ingress.spec.tls.hosts` is ignored by the controller. The ALB takes the host information directly from the certificates. + +### Supported Ingress Backends + +Currently, the STACKIT ALB Ingress controller only supports Kubernetes Service backends. Routing traffic to Resource backends (such as individual Pods or other custom resources) is not supported at this time. + +### Validating Webhook + +The ALB integration deploys a background validating webhook running alongside the ALB Ingress controller. This webhook automatically reviews incoming Ingress and IngressClass object modifications (creations and updates) preventing invalid properties or conflicting parameters from being applied. + +### Optimizing traffic with externalTrafficPolicy + +By default, Kubernetes Services use `externalTrafficPolicy: Cluster`. Under this policy, every worker node passes the ALB's health checks because kube-proxy accepts the incoming traffic on any node and automatically routes it over the internal network to a node that is actually running your pod. + +However, this setup can cause issues when pods are terminating or nodes are scaling down. Because the ALB relies on passively probing the data port, it only detects failures through connection timeouts. This means the ALB might still send traffic to a node while its pods are actively shutting down, or during the brief window after a node goes down but before the next health probe officially fails. Routing new user requests during this delay results in dropped connections and timeout errors. + +To prevent these dropped connections during deployments and cluster downscaling, you can change your Service to use `externalTrafficPolicy: Local`. + +**Important:** For this to work, your backend Service must be defined as `type: LoadBalancer`. While Kubernetes technically allows setting `externalTrafficPolicy: Local` on a standard `NodePort` Service, it will not generate the required `healthCheckNodePort`. Additionally, because `type: LoadBalancer` natively triggers the cluster's default Cloud Controller Manager to automatically provision a Network Load Balancer (NLB), you must also specify the `loadBalancerClass` field. This ensures the STACKIT ALB controller takes an ownership of the service and prevents an unwanted NLB from being created. + +When correctly configured, Kubernetes exposes a dedicated health check port (healthCheckNodePort) on every node. The STACKIT ALB controller automatically detects this and reconfigures the ALB to probe this health port instead of the standard data port. If a node lacks active pods, or if its pods enter a Terminating state, the health port instantly returns an HTTP 503 error. The ALB registers the failure immediately and pulls the node out of rotation before user connections can be dropped. As an added benefit, this policy also eliminates internal network hops and preserves the client's original IP address. + +To enable this behavior, update your backend Service configuration: +```YAML +apiVersion: v1 +kind: Service +metadata: + name: service-a + namespace: default + labels: + app: service-a +spec: + type: LoadBalancer + loadBalancerClass: alb + externalTrafficPolicy: Local + ports: + - port: 80 + protocol: TCP + targetPort: 80 + selector: + app: service-a +``` + +### Limits + +The following limitations are imposed directly by the STACKIT ALB API (not the controller itself): +- Maximum targets per pool: An individual target pool can contain a maximum of 250 targets. +- Maximum listeners per ALB: A single ALB instance supports a maximum of 20 listeners. + +#### When to watch out for target limits + +A "target" in a pool corresponds directly to a worker node in your cluster. If you run a large cluster with a high number of worker nodes, or expect your cluster to dynamically scale to a large size, keep this limit in mind since a single backend Service port mapping cannot route traffic to more than 250 worker nodes simultaneously. + +#### When to watch out for the listener limit + +Because each IngressClass provisions a dedicated ALB instance, hitting the 20-listener threshold is rarely an issue for a basic setup but becomes a real risk when you start stacking custom ports across multiple applications sharing that same ALB. If your Ingress resources use the `alb.stackit.cloud/http-port` or `alb.stackit.cloud/https-port` annotations to expose different apps on unique custom port numbers, each distinctive port allocates its own listener on the shared ALB instance. This risk compounds quickly when those applications also require TLS encryption; since the controller must keep an extra HTTP listener active alongside the HTTPS listener to smoothly process automated ACME certificate challenges, a single secure app immediately consumes two slots instead of one, accelerating how fast you approach the API limit if multiple unique custom ports are configured. + +### Configuration + +Configure the STACKIT Application Load Balancer using the following annotations. + +| Annotation | Type | Allowed On | Requirement | Description | +| :--- | :--- | :--- | :--- | :--- | +| `alb.stackit.cloud/network-mode` | String | IngressClass | Mandatory | Routing mode (currently only `NodePort` supported). | +| `alb.stackit.cloud/external-address` | String | IngressClass | Optional | Uses a specific STACKIT floating IP instead of an ephemeral one. | +| `alb.stackit.cloud/internal` | Boolean | IngressClass | Optional | If `true`, the ALB is not exposed via a public IP. | +| `alb.stackit.cloud/plan-id` | String | IngressClass | Optional | Sets the service plan for the ALB. | +| `alb.stackit.cloud/priority` | Integer | Ingress | Optional | Defines the evaluation priority of the Ingress. Higher number takes priority. Defaults to zero. | +| `alb.stackit.cloud/web-application-firewall-name` | String | IngressClass | Optional | Attaches a STACKIT WAF configuration to the listeners. | +| `alb.stackit.cloud/websocket` | Boolean | IngressClass, Ingress | Optional | If `true`, enables WebSocket support for the ALB or specific paths. | +| `alb.stackit.cloud/http-port` | Integer | Ingress | Optional | If set, specifies a custom HTTP port (Default is 80). | +| `alb.stackit.cloud/https-port` | Integer | Ingress | Optional | If set, specifies a custom HTTPS port (Default is 443). | +| `alb.stackit.cloud/https-only` | Boolean | Ingress | Optional | If true, the Ingress will not be reachable via HTTP and only via HTTPS | +| `alb.stackit.cloud/traget-pool-tls-enabled` | Boolean | IngressClass, Ingress, Service | Optional | Enables TLS bridging using OS trusted CAs. | +| `alb.stackit.cloud/traget-pool-tls-custom-ca` | String | IngressClass, Ingress, Service | Optional | Enables TLS bridging with a custom CA. | +| `alb.stackit.cloud/traget-pool-tls-skip-certificate-validation`| Boolean | IngressClass, Ingress, Service | Optional | Enables TLS bridging but skips certificate validation. | +| `alb.stackit.cloud/allowed-source-ranges`| String | IngressClass | Accepts a comma-separated list of IP ranges. E.g. 10.0.0.0/24,1.2.3.4/32. If unset, all IPs are allowed. | + +### Known Limitations + +#### Support for `defaultBackend` + +The ALB Ingress Controller currently does not support the `defaultBackend` field on Ingress resources. Customers should avoid relying on this feature as it will be ignored during ALB reconciliation. + +#### Dummy listener for empty application load balancers + +Currently, application load balancers require at least one listener. +If the ingress class results in zero listeners, a dummy listener on port 80 is added to be able to create the load balancer. +This listener always returns the HTTP status code 404. +Common scenarios where this can happen is when there are zero ingresses or an HTTPS-only load balancer does not have any certificates yet. diff --git a/go.mod b/go.mod index d438315..82051d9 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,77 @@ module github.com/stackitcloud/application-load-balancer-controller -go 1.26.1 +go 1.26.3 + +require ( + github.com/google/uuid v1.6.0 + github.com/onsi/ginkgo/v2 v2.32.0 + github.com/onsi/gomega v1.42.1 + github.com/stackitcloud/stackit-sdk-go/core v0.26.0 + github.com/stackitcloud/stackit-sdk-go/services/alb v0.14.2 + github.com/stackitcloud/stackit-sdk-go/services/certificates v1.8.0 + go.uber.org/mock v0.6.0 + gopkg.in/yaml.v3 v3.0.1 + k8s.io/api v0.36.2 + k8s.io/apimachinery v0.36.2 + k8s.io/client-go v0.36.2 + k8s.io/utils v0.0.0-20260617174310-a95e086a2553 + sigs.k8s.io/controller-runtime v0.24.1 +) + +require ( + github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/emicklei/go-restful/v3 v3.13.0 // indirect + github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/zapr v1.3.0 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/go-task/slim-sprig/v3 v3.0.0 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.67.5 // indirect + github.com/prometheus/procfs v0.19.2 // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.1 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/mod v0.36.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/term v0.44.0 // indirect + golang.org/x/text v0.38.0 // indirect + golang.org/x/time v0.14.0 // indirect + golang.org/x/tools v0.45.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect + google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + k8s.io/apiextensions-apiserver v0.36.0 // indirect + k8s.io/klog/v2 v2.140.0 // indirect + k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5d30255 --- /dev/null +++ b/go.sum @@ -0,0 +1,203 @@ +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= +github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= +github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= +github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= +github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= +github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= +github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= +github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= +github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= +github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg= +github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= +github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= +github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= +github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= +github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/onsi/ginkgo/v2 v2.32.0 h1:Hw7s2pVrQo/8Yz5N77qdnpHaoc+c6cC9WIV1Jce+J6E= +github.com/onsi/ginkgo/v2 v2.32.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= +github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I= +github.com/onsi/gomega v1.42.1/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= +github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stackitcloud/stackit-sdk-go/core v0.26.0 h1:jQEb9gkehfp6VCP6TcYk7BI10cz4l0KM2L6hqYBH2QA= +github.com/stackitcloud/stackit-sdk-go/core v0.26.0/go.mod h1:WU1hhxnjXw2EV7CYa1nlEvNpMiRY6CvmIOaHuL3pOaA= +github.com/stackitcloud/stackit-sdk-go/services/alb v0.14.2 h1:hGzfOJjlCRoFpri5eYIiwhE27qu02pKZLprKvbsTC/w= +github.com/stackitcloud/stackit-sdk-go/services/alb v0.14.2/go.mod h1:eK6oRB5Tmpt6KbXQ4UYBGg2LgW5bPtVoncL9E8JSRww= +github.com/stackitcloud/stackit-sdk-go/services/certificates v1.8.0 h1:bINitVHAyfFfRhkt8/eXDXEjpuH72n9HykZhthGkEg4= +github.com/stackitcloud/stackit-sdk-go/services/certificates v1.8.0/go.mod h1:eJpB3/pukz+KzVPVHQ4g3DVtQkxGga18VbFBhq9ugdY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= +gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.36.2 h1:TF6YDLIzKfccK7cq9YpTcGX8TJmEkHVRv78DM51fRYY= +k8s.io/api v0.36.2/go.mod h1:F4LbMO4brjZYh7yFkXWhynSvtB7YauxV4c+HHkNRGNg= +k8s.io/apiextensions-apiserver v0.36.0 h1:Wt7E8J+VBCbj4FjiBfDTK/neXDDjyJVJc7xfuOHImZ0= +k8s.io/apiextensions-apiserver v0.36.0/go.mod h1:kGDjH0msuiIB3tgsYRV0kS9GqpMYMUsQ3GHv7TApyug= +k8s.io/apimachinery v0.36.2 h1:0PE/W/WNy1UX61NLbXY5TMbJ6UwLL6E6lAPkYrKFxbQ= +k8s.io/apimachinery v0.36.2/go.mod h1:fvf/HOLXq9RId0rnDIbN1OEBvHXdQbLMM8nu0LcBUf4= +k8s.io/client-go v0.36.2 h1:bfgxmFKc9CgqsgX4xKLAAdmTQlWee7Ob/HlDOrJ5TBI= +k8s.io/client-go v0.36.2/go.mod h1:1vgO4OAlfPnoLcb+Rze2GF5rAr14w8qjrYMoyXJzQj0= +k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= +k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= +k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hkbPJgdATINPMAxaynU2Ovg= +k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= +k8s.io/utils v0.0.0-20260617174310-a95e086a2553 h1:hmGqDecjc8d7HVzWzRFl0QD9bYuYKbBEG7t8xwnVxfI= +k8s.io/utils v0.0.0-20260617174310-a95e086a2553/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +sigs.k8s.io/controller-runtime v0.24.1 h1:miPEwrmirImAvgME1L9qebGHrOnGJoVmVdtOU9fRfo4= +sigs.k8s.io/controller-runtime v0.24.1/go.mod h1:vFkfY5fGt5xAC/sKb8IBFKgWPNKG9OUG29dR8Y2wImw= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/pkg/controller/ingress/ingressclass_controller.go b/pkg/controller/ingress/ingressclass_controller.go new file mode 100644 index 0000000..093790c --- /dev/null +++ b/pkg/controller/ingress/ingressclass_controller.go @@ -0,0 +1,200 @@ +package ingress + +import ( + "context" + "fmt" + "time" + + "github.com/stackitcloud/application-load-balancer-controller/pkg/controller/ingress/spec" + "github.com/stackitcloud/application-load-balancer-controller/pkg/stackit" + stackitconfig "github.com/stackitcloud/application-load-balancer-controller/pkg/stackit/config" + networkingv1 "k8s.io/api/networking/v1" + apiequality "k8s.io/apimachinery/pkg/api/equality" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" +) + +const ( + // finalizerName is the name of the finalizer that is added to Ingress and IngressClass + finalizerName = "stackit.cloud/alb-ingress" + // controllerName is the name of the ALB controller that the IngressClass should point to for reconciliation + controllerName = "stackit.cloud/alb-ingress" +) + +// IngressClassReconciler reconciles a IngressClass object +type IngressClassReconciler struct { //nolint:revive // Naming this ClassReconciler would be confusing. + Client client.Client + Recorder record.EventRecorder + ALBClient stackit.ApplicationLoadBalancerClient + CertificateClient stackit.CertificatesClient + Scheme *runtime.Scheme + ALBConfig stackitconfig.ALBConfig +} + +func (r *IngressClassReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + log := ctrl.LoggerFrom(ctx) + ingressClass := &networkingv1.IngressClass{} + err := r.Client.Get(ctx, req.NamespacedName, ingressClass) + if err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + // Check if the IngressClass points to the ALB controller + if ingressClass.Spec.Controller != controllerName { + // If this IngressClass doesn't point to the ALB controller, ignore this IngressClass + return ctrl.Result{}, nil + } + + log.V(2).Info("Reconciling IngressClass") + + if !ingressClass.DeletionTimestamp.IsZero() { + err := r.handleIngressClassDeletion(ctx, ingressClass) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to handle IngressClass deletion: %w", err) + } + return ctrl.Result{}, nil + } + + // Add finalizer to the IngressClass if not already added. + if controllerutil.AddFinalizer(ingressClass, finalizerName) { + err := r.Client.Update(ctx, ingressClass) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to add finalizer to IngressClass: %w", err) + } + ctrl.LoggerFrom(ctx).Info("Added finalizer") + return ctrl.Result{}, nil + } + + if err := r.reconcileALBResources(ctx, ingressClass); err != nil { + return ctrl.Result{}, fmt.Errorf("failed to reconcile ALB resources: %w", err) + } + + requeue, err := r.updateStatus(ctx, ingressClass) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to update ingress status: %w", err) + } + + log.V(1).Info("Successfully reconciled IngressClass", "Name", ingressClass.Name) + + return requeue, nil +} + +// updateStatus updates the status of the Ingresses with the ALB IP address +func (r *IngressClassReconciler) updateStatus(ctx context.Context, ingressClass *networkingv1.IngressClass) (ctrl.Result, error) { + alb, err := r.ALBClient.GetLoadBalancer(ctx, r.ALBConfig.Global.ProjectID, r.ALBConfig.Global.Region, spec.LoadBalancerName(ingressClass)) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to get load balancer: %w", err) + } + + if *alb.Status != stackit.LBStatusReady { + // ALB is not yet ready, requeue + return ctrl.Result{RequeueAfter: 10 * time.Second}, nil + } + + var albIP string + if alb.ExternalAddress != nil && *alb.ExternalAddress != "" { + albIP = *alb.ExternalAddress + } else if alb.PrivateAddress != nil && *alb.PrivateAddress != "" { + albIP = *alb.PrivateAddress + } + + if albIP == "" { + return ctrl.Result{}, fmt.Errorf("alb is ready but has no IPs %v", alb.Name) + } + + ingresses, err := r.getIngressesForIngressClass(ctx, ingressClass) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to get ingresses: %w", err) + } + + for i := range ingresses { + ingress := &ingresses[i] + before := ingress.DeepCopy() + + ingress.Status.LoadBalancer.Ingress = []networkingv1.IngressLoadBalancerIngress{ + { + IP: albIP, + }, + } + + if apiequality.Semantic.DeepEqual(before, ingress) { + continue + } + patch := client.MergeFrom(before) + if err := r.Client.Status().Patch(ctx, ingress, patch); err != nil { + return ctrl.Result{}, fmt.Errorf("failed to patch ingress status object: %w", err) + } + } + + return ctrl.Result{}, nil +} + +func (r *IngressClassReconciler) getIngressesForIngressClass(ctx context.Context, ingressClass *networkingv1.IngressClass) ([]networkingv1.Ingress, error) { + ingresses := networkingv1.IngressList{} + if err := r.Client.List(ctx, &ingresses, client.MatchingFields{fieldIndexIngressClass: ingressClass.Name}); err != nil { + return nil, err + } + return ingresses.Items, nil +} + +// handleIngressClassDeletion handles the deletion of IngressClass resource. +// It does not wait until all ingresses are deleted. It just removes the status from the ingresses and removes the ALB. +// If this blocked the IngressClass would be there forever as there is no ownerReference in the ingresses. +func (r *IngressClassReconciler) handleIngressClassDeletion( + ctx context.Context, + ingressClass *networkingv1.IngressClass, +) error { + ingresses, err := r.getIngressesForIngressClass(ctx, ingressClass) + if err != nil { + return err + } + + for i := range ingresses { + ingress := &ingresses[i] + before := ingress.DeepCopy() + + ingress.Status.LoadBalancer.Ingress = []networkingv1.IngressLoadBalancerIngress{} + + if apiequality.Semantic.DeepEqual(before, ingress) { + continue + } + patch := client.MergeFrom(before) + if err := r.Client.Status().Patch(ctx, ingress, patch); err != nil { + return fmt.Errorf("failed to patch ingress %s: %w", client.ObjectKeyFromObject(ingress), err) + } + } + + // The API returns 200 if the load balancer doesn't exist. + err = r.ALBClient.DeleteLoadBalancer(ctx, r.ALBConfig.Global.ProjectID, r.ALBConfig.Global.Region, spec.LoadBalancerName(ingressClass)) + if err != nil { + return fmt.Errorf("failed to delete load balancer: %w", err) + } + ctrl.LoggerFrom(ctx).Info("Deleted load balancer") + + // TODO: Wait for load balancer to be deleted or remove all certificates references to delete certificates without errors. + + ingressClassCertificates, err := r.getCertificatesForIngressClass(ctx, ingressClass) + if err != nil { + return err + } + for i := range ingressClassCertificates { + cert := &ingressClassCertificates[i] + if err := r.CertificateClient.DeleteCertificate(ctx, r.ALBConfig.Global.ProjectID, r.ALBConfig.Global.Region, *cert.Id); err != nil { + return fmt.Errorf("failed to delete certificate %q: %w", *cert.Id, err) + } + ctrl.LoggerFrom(ctx).Info("Deleted certificate", "id", *cert.Id, "name", *cert.Name) + } + + if controllerutil.RemoveFinalizer(ingressClass, finalizerName) { + err = r.Client.Update(ctx, ingressClass) + if err != nil { + return fmt.Errorf("failed to remove finalizer from IngressClass: %w", err) + } + ctrl.LoggerFrom(ctx).Info("Removed finalizer") + } + + return nil +} diff --git a/pkg/controller/ingress/ingressclass_controller_test.go b/pkg/controller/ingress/ingressclass_controller_test.go new file mode 100644 index 0000000..e040e14 --- /dev/null +++ b/pkg/controller/ingress/ingressclass_controller_test.go @@ -0,0 +1,319 @@ +package ingress_test + +import ( + "context" + "errors" + "fmt" + "sync" + "sync/atomic" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/stackitcloud/application-load-balancer-controller/pkg/controller/ingress" + "github.com/stackitcloud/application-load-balancer-controller/pkg/controller/ingress/spec" + "github.com/stackitcloud/application-load-balancer-controller/pkg/controller/ingress/spec/testdata" + "github.com/stackitcloud/application-load-balancer-controller/pkg/stackit" + stackitconfig "github.com/stackitcloud/application-load-balancer-controller/pkg/stackit/config" + "github.com/stackitcloud/application-load-balancer-controller/pkg/testutil" + . "github.com/stackitcloud/application-load-balancer-controller/pkg/testutil/ingress" + . "github.com/stackitcloud/application-load-balancer-controller/pkg/testutil/service" + albsdk "github.com/stackitcloud/stackit-sdk-go/services/alb/v2api" + certsdk "github.com/stackitcloud/stackit-sdk-go/services/certificates/v2api" + "go.uber.org/mock/gomock" + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + projectID = "dummy-project-id" + region = "eu01" + networkID = "my-network" + controllerName = "stackit.cloud/alb-ingress" + finalizerName = "stackit.cloud/alb-ingress" + targetCertID = "real-certificate-uuid-abc-123" +) + +var _ = Describe("IngressClassController", func() { + var ( + recorder *record.FakeRecorder + + // namespace is the namespace in which all namespaced resources of the test case should go. + // It is cleaned up automatically when the test ends and all resource deletions will be finalized before the test case completes. + namespace *corev1.Namespace + + mockCtrl *gomock.Controller + albClient *stackit.MockApplicationLoadBalancerClient + certClient *stackit.MockCertificatesClient + + node corev1.Node + + mgrContext context.Context + mgrCancel context.CancelFunc + managerTerminated sync.WaitGroup + ) + + BeforeEach(func(ctx context.Context) { + + mockCtrl = gomock.NewController(GinkgoT()) + recorder = record.NewFakeRecorder(10) + + albClient = stackit.NewMockApplicationLoadBalancerClient(mockCtrl) + certClient = stackit.NewMockCertificatesClient(mockCtrl) + mgrContext, mgrCancel = context.WithCancel(context.Background()) + + namespace = &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "stackit-alb-ingress-test-", + }, + } + Expect(k8sClient.Create(ctx, namespace)).To(Succeed()) + DeferCleanup(func(ctx context.Context) { + // There is no namespace controller deployed. So the content of the namespace won't be cleaned up by Kubernetes itself. + Expect(k8sClient.Delete(ctx, namespace)).To(Succeed()) + }) + + node = corev1.Node{ + ObjectMeta: metav1.ObjectMeta{Name: "test-node"}, + Status: corev1.NodeStatus{ + Addresses: []corev1.NodeAddress{{Type: corev1.NodeInternalIP, Address: "10.10.10.10"}}, + }, + } + testutil.CreateKubernetesResourceAndDeferDeletion(ctx, k8sClient, &node) + + mgr, err := ctrl.NewManager(cfg, ctrl.Options{ + Scheme: scheme.Scheme, + }) + Expect(err).NotTo(HaveOccurred()) + + reconciler := ingress.IngressClassReconciler{ + Recorder: recorder, + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + ALBClient: albClient, + CertificateClient: certClient, + ALBConfig: stackitconfig.ALBConfig{ + Global: stackitconfig.GlobalOpts{ + ProjectID: projectID, + Region: region, + }, + ApplicationLoadBalancer: stackitconfig.ApplicationLoadBalancerOpts{NetworkID: networkID}}, + } + + Expect(reconciler.SetupWithManager(ctx, mgr, namespace.Name)).To(Succeed()) + + managerTerminated.Add(1) + go func() { + defer GinkgoRecover() + err = mgr.Start(mgrContext) + managerTerminated.Done() + Expect(err).NotTo(HaveOccurred()) + }() + DeferCleanup(func() { + mgrCancel() + // Canceling the context doesn't cause the manager to stop immediately. + // We have to wait for manager.Start() to return to ensure that the manager doesn't "spill" into the next test case. + managerTerminated.Wait() + mockCtrl.Finish() + }) + + }) + + Context("when the IngressClass does not match controller", func() { + It("should ignore the IngressClass and not append finalizers", func(ctx context.Context) { + ignoredIngressClass := &networkingv1.IngressClass{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "ignored-ingressclass-", + }, + Spec: networkingv1.IngressClassSpec{ + Controller: "some.other/controller", + }, + } + testutil.CreateKubernetesResourceAndDeferDeletion(ctx, k8sClient, ignoredIngressClass) + + Consistently(func(g Gomega) { + err := k8sClient.Get(ctx, client.ObjectKeyFromObject(ignoredIngressClass), ignoredIngressClass) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(ignoredIngressClass.Finalizers).To(BeEmpty()) + }, "2s", "200ms").Should(Succeed()) + + }) + }) + + It("should create an empty ALB for an ingress class matching the controller", func(ctx context.Context) { + getLoadBalancerResponse := &atomic.Pointer[albsdk.LoadBalancer]{} + certClient.EXPECT().ListCertificate(gomock.Any(), gomock.Any(), gomock.Any()).Return(new(certsdk.ListCertificatesResponse{ + Items: []certsdk.GetCertificateResponse{}, + }), nil).AnyTimes() + albClient.EXPECT().GetLoadBalancer(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, _, _, _ string) (*albsdk.LoadBalancer, error) { + lb := getLoadBalancerResponse.Load() + if lb == nil { + return nil, stackit.ErrorNotFound + } + return lb, nil + }).AnyTimes() + albClient.EXPECT().CreateLoadBalancer(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, _, _ string, create *albsdk.CreateLoadBalancerPayload) (*albsdk.LoadBalancer, error) { + response := albsdk.LoadBalancer(*create) + response.Version = new("version-after-create") + response.ExternalAddress = new("127.0.0.1") + response.Status = new(stackit.LBStatusReady) + getLoadBalancerResponse.Store(&response) + return &response, nil + }).Times(1) + + ingressClass := &networkingv1.IngressClass{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "managed-ingressclass-", + }, + Spec: networkingv1.IngressClassSpec{ + Controller: controllerName, + }, + } + Expect(k8sClient.Create(ctx, ingressClass)).To(Succeed()) + DeferCleanup(func(ctx context.Context) { + albClient.EXPECT().DeleteLoadBalancer(gomock.Any(), projectID, region, spec.LoadBalancerName(ingressClass)).Times(1) + testutil.DeleteAndWaitForKubernetesResource(ctx, k8sClient, ingressClass) + }) + + WaitUntilFinalizerAttached(ctx, k8sClient, ingressClass) + + Eventually(getLoadBalancerResponse).Should(testutil.HaveAtomicValue[albsdk.LoadBalancer](Not(BeNil()))) + }) + + // The ALB is already created when BeforeEach completes. + Context("with IngressClass matching the controller", func() { + var ( + ingressClass *networkingv1.IngressClass + + getLoadBalancerResponse *atomic.Pointer[albsdk.LoadBalancer] + listCertificatesResponse *atomic.Pointer[certsdk.ListCertificatesResponse] + ) + + BeforeEach(func(ctx context.Context) { + getLoadBalancerResponse = &atomic.Pointer[albsdk.LoadBalancer]{} + listCertificatesResponse = &atomic.Pointer[certsdk.ListCertificatesResponse]{} + listCertificatesResponse.Store(&certsdk.ListCertificatesResponse{Items: []certsdk.GetCertificateResponse{}}) + + certClient.EXPECT().ListCertificate(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, _, _ string) (*certsdk.ListCertificatesResponse, error) { + return listCertificatesResponse.Load(), nil + }).AnyTimes() + + albClient.EXPECT().GetLoadBalancer(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, _, _, _ string) (*albsdk.LoadBalancer, error) { + lb := getLoadBalancerResponse.Load() + if lb == nil { + return nil, stackit.ErrorNotFound + } + return lb, nil + }).AnyTimes() + albClient.EXPECT().CreateLoadBalancer(gomock.Any(), projectID, region, gomock.Any()).DoAndReturn(func(_ context.Context, _, _ string, create *albsdk.CreateLoadBalancerPayload) (*albsdk.LoadBalancer, error) { + response := albsdk.LoadBalancer(*create) + response.Version = new("version-after-create") + response.ExternalAddress = new("127.0.0.1") + response.Status = new(stackit.LBStatusReady) + getLoadBalancerResponse.Store(&response) + return &response, nil + }).Times(1) + + ingressClass = &networkingv1.IngressClass{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "ingressclass-", + }, + Spec: networkingv1.IngressClassSpec{ + Controller: controllerName, + }, + } + Expect(k8sClient.Create(ctx, ingressClass)).To(Succeed()) + DeferCleanup(func(ctx context.Context) { + albClient.EXPECT().DeleteLoadBalancer(gomock.Any(), projectID, region, spec.LoadBalancerName(ingressClass)).MinTimes(1) + testutil.DeleteAndWaitForKubernetesResource(ctx, k8sClient, ingressClass) + }) + + // Wait for CreateLoadBalancer to be called, i.e. getLoadBalancerResponse to not be nil. + Eventually(getLoadBalancerResponse).Should(testutil.HaveAtomicValue[albsdk.LoadBalancer](Not(BeNil()))) + }) + + It("should create certificate and reference it in ALB", func(ctx context.Context) { + updateRequest := &atomic.Pointer[albsdk.UpdateLoadBalancerPayload]{} + certClient.EXPECT().CreateCertificate(gomock.Any(), projectID, region, gomock.Any()).DoAndReturn(func(_ context.Context, _, _ string, certificate *certsdk.CreateCertificatePayload) (*certsdk.GetCertificateResponse, error) { + fingerprint, err := spec.ValidateTLSCertAndFingerprint([]byte(*certificate.PublicKey), []byte(*certificate.PrivateKey)) + if err != nil { + return nil, fmt.Errorf("invalid certificate: %w", err) + } + response := certsdk.GetCertificateResponse{ + Name: certificate.Name, + Id: new("random-certificate-id"), + Labels: certificate.Labels, + Data: &certsdk.Data{ + FingerprintSha256: new(fingerprint), + }, + PublicKey: certificate.PublicKey, + } + listCertificatesResponse.Store(&certsdk.ListCertificatesResponse{ + Items: []certsdk.GetCertificateResponse{response}, + }) + return &response, nil + }).Times(1) + certClient.EXPECT().DeleteCertificate(gomock.Any(), projectID, region, "random-certificate-id").Return(nil).AnyTimes() + albClient.EXPECT().UpdateLoadBalancer(gomock.Any(), projectID, region, gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, _, _, _ string, update *albsdk.UpdateLoadBalancerPayload) (*albsdk.LoadBalancer, error) { + response := albsdk.LoadBalancer(*update) + response.Version = new("version-after-update") + response.ExternalAddress = new("127.0.0.1") + response.Status = new(stackit.LBStatusReady) + getLoadBalancerResponse.Store(&response) + + updateRequest.Store(update) + return (*albsdk.LoadBalancer)(update), nil + }).MinTimes(1) + + secret := corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Namespace: corev1.NamespaceDefault, Name: "my-tls-cert"}, + Type: corev1.SecretTypeTLS, + Data: map[string][]byte{ + corev1.TLSCertKey: []byte(testdata.FixtureTLS1PublicKey), + corev1.TLSPrivateKeyKey: []byte(testdata.FixtureTLS1PrivateKey), + }, + } + testutil.CreateKubernetesResourceAndDeferDeletion(ctx, k8sClient, &secret) + service := Service(corev1.NamespaceDefault, "my-service", WithServiceType(corev1.ServiceTypeNodePort), WithPort("http", 80, 30000, corev1.ProtocolTCP)) + testutil.CreateKubernetesResourceAndDeferDeletion(ctx, k8sClient, &service) + ingress := Ingress(corev1.NamespaceDefault, "my-ingress", WithIngressClass(ingressClass.Name), WithTLSSecret(secret.Name), + WithRule("my-host.local", WithPath("/", new(networkingv1.PathTypePrefix), service.Name, networkingv1.ServiceBackendPort{Number: 80})), + ) + testutil.CreateKubernetesResourceAndDeferDeletion(ctx, k8sClient, &ingress) + + // Depending on in which order the secret and service hit the cache the first update might not yet include the certificate. + Eventually(updateRequest).Should(testutil.HaveAtomicValue[albsdk.UpdateLoadBalancerPayload]( + WithTransform(func(u *albsdk.UpdateLoadBalancerPayload) ([]string, error) { + if u == nil { + return nil, errors.New("no update happened") + } + if len(u.Listeners) != 2 { + return nil, errors.New("expect two listeners") + } + httpsListener := u.Listeners[1] + if httpsListener.Https == nil || httpsListener.Https.CertificateConfig == nil { + return nil, errors.New("certificates config is nil") + } + return httpsListener.Https.CertificateConfig.CertificateIds, nil + }, ConsistOf("random-certificate-id")), + )) + }) + }) + +}) + +// WaitUntilFinalizerAttached blocks until the controller successfully injects our tracking string +func WaitUntilFinalizerAttached(ctx context.Context, cl client.Client, ic *networkingv1.IngressClass) { + GinkgoHelper() // Tells Ginkgo to report failures on the line that calls this function, not here! + + reconciledIngressClass := &networkingv1.IngressClass{} + Eventually(func(g Gomega) { + err := cl.Get(ctx, client.ObjectKeyFromObject(ic), reconciledIngressClass) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(reconciledIngressClass.Finalizers).To(ContainElement(finalizerName)) + }, "5s", "200ms").Should(Succeed()) +} diff --git a/pkg/controller/ingress/setup.go b/pkg/controller/ingress/setup.go new file mode 100644 index 0000000..46a95dd --- /dev/null +++ b/pkg/controller/ingress/setup.go @@ -0,0 +1,236 @@ +package ingress + +import ( + "context" + "reflect" + + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +const ( + // fieldIndexIngressClass indexes the ingress class on an ingress. + fieldIndexIngressClass = ".spec.ingressClassName" + // fieldIndexService indexes all service references on an ingress. An ingress can be indexed multiple times. + fieldIndexService = ".spec.rules.http.paths.backend.service.name" + // fieldIndexSecret indexes all secret references on an ingress. An ingress can be indexed multiple times. + fieldIndexSecret = ".spec.tls.secret" +) + +// SetupWithManager sets up the controller with the Manager. +func (r *IngressClassReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manager, ctrlName string) error { + mgr.GetCache().IndexField(ctx, &networkingv1.Ingress{}, fieldIndexIngressClass, func(o client.Object) []string { + ingress := o.(*networkingv1.Ingress) + if ingress.Spec.IngressClassName == nil { + return nil + } + return []string{*ingress.Spec.IngressClassName} + }) + + mgr.GetCache().IndexField(ctx, &networkingv1.Ingress{}, fieldIndexService, func(o client.Object) []string { + ingress := o.(*networkingv1.Ingress) + refs := []string{} + if ingress.Spec.DefaultBackend != nil && ingress.Spec.DefaultBackend.Service.Name != "" { + refs = append(refs, ingress.Spec.DefaultBackend.Service.Name) + } + for i := range ingress.Spec.Rules { + rule := &ingress.Spec.Rules[i] + if rule.HTTP == nil { + continue + } + for j := range rule.HTTP.Paths { + path := &rule.HTTP.Paths[j] + if path.Backend.Service != nil && path.Backend.Service.Name != "" { + refs = append(refs, path.Backend.Service.Name) + } + } + } + return refs + }) + + mgr.GetCache().IndexField(ctx, &networkingv1.Ingress{}, fieldIndexSecret, func(o client.Object) []string { + ingress := o.(*networkingv1.Ingress) + refs := []string{} + for i := range ingress.Spec.TLS { + refs = append(refs, ingress.Spec.TLS[i].SecretName) + + } + return refs + }) + + if ctrlName == "" { + ctrlName = "ingressclass" + } + + return ctrl.NewControllerManagedBy(mgr). + For(&networkingv1.IngressClass{}, builder.WithPredicates(ingressClassPredicate())). + Watches(&corev1.Node{}, nodeEventHandler(r.Client), builder.WithPredicates(nodePredicate())). + Watches(&networkingv1.Ingress{}, ingressEventHandler(r.Client)). + Watches(&corev1.Secret{}, secretEventHandler(r.Client)). + Watches(&corev1.Service{}, serviceEventHandler(r.Client)). + Named(ctrlName). + Complete(r) +} + +// secretEventHandler returns all ingress classes that have at least one ingress that references the given secret. +func secretEventHandler(c client.Client) handler.EventHandler { + return handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, o client.Object) []ctrl.Request { + // Filter out non-TLS Secrets. + secret, ok := o.(*corev1.Secret) + if !ok || secret.Type != corev1.SecretTypeTLS { + return nil + } + + ingresses := &networkingv1.IngressList{} + err := c.List(ctx, ingresses, client.InNamespace(secret.Namespace), client.MatchingFields{fieldIndexSecret: secret.Name}) + if err != nil { + return nil + } + + classes := map[string]any{} + for i := range ingresses.Items { + ingress := &ingresses.Items[i] + if ingress.Spec.IngressClassName != nil && *ingress.Spec.IngressClassName == "" { + classes[*ingress.Spec.IngressClassName] = nil + } + } + + reqs := []ctrl.Request{} + for className := range classes { + class := &networkingv1.IngressClass{} + if err := c.Get(ctx, types.NamespacedName{Name: className}, class); err != nil { + continue + } + if class.Spec.Controller == controllerName { + reqs = append(reqs, reconcile.Request{NamespacedName: types.NamespacedName{Name: className}}) + } + } + return reqs + }) +} + +// serviceEventHandler returns all ingress classes that have at least one ingress that references the given service. +func serviceEventHandler(c client.Client) handler.EventHandler { + return handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, o client.Object) []ctrl.Request { + service, ok := o.(*corev1.Service) + if !ok { + return nil + } + + ingresses := &networkingv1.IngressList{} + err := c.List(ctx, ingresses, client.InNamespace(service.Namespace), client.MatchingFields{fieldIndexService: service.Name}) + if err != nil { + return nil + } + + classes := map[string]any{} + for i := range ingresses.Items { + ingress := &ingresses.Items[i] + if ingress.Spec.IngressClassName != nil && *ingress.Spec.IngressClassName == "" { + classes[*ingress.Spec.IngressClassName] = nil + } + } + + reqs := []ctrl.Request{} + for className := range classes { + class := &networkingv1.IngressClass{} + if err := c.Get(ctx, types.NamespacedName{Name: className}, class); err != nil { + continue + } + if class.Spec.Controller == controllerName { + reqs = append(reqs, reconcile.Request{NamespacedName: types.NamespacedName{Name: className}}) + } + } + return reqs + }) +} + +func nodeEventHandler(c client.Client) handler.EventHandler { + return handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, _ client.Object) []ctrl.Request { + ingressClassList := &networkingv1.IngressClassList{} + err := c.List(ctx, ingressClassList) + if err != nil { + return nil + } + requestList := []ctrl.Request{} + for i := range ingressClassList.Items { + if ingressClassList.Items[i].Spec.Controller != controllerName { + continue + } + requestList = append(requestList, ctrl.Request{ + NamespacedName: client.ObjectKeyFromObject(new(ingressClassList.Items[i])), + }) + } + return requestList + }) +} + +func ingressEventHandler(c client.Client) handler.EventHandler { + return handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, o client.Object) []ctrl.Request { + ingress, ok := o.(*networkingv1.Ingress) + if !ok || ingress.Spec.IngressClassName == nil { + return nil + } + + ingressClass := &networkingv1.IngressClass{} + err := c.Get(ctx, client.ObjectKey{Name: *ingress.Spec.IngressClassName}, ingressClass) + if err != nil { + return nil + } + + if ingressClass.Spec.Controller != controllerName { + return nil + } + + return []ctrl.Request{ + { + NamespacedName: client.ObjectKeyFromObject(ingressClass), + }, + } + }) +} + +func nodePredicate() predicate.Predicate { + return predicate.Funcs{ + CreateFunc: func(_ event.CreateEvent) bool { + return true + }, + UpdateFunc: func(e event.UpdateEvent) bool { + oldNode, ok := e.ObjectOld.(*corev1.Node) + if !ok { + return false + } + newNode, ok := e.ObjectNew.(*corev1.Node) + if !ok { + return false + } + + // TODO: include more updates such as annotations + return !reflect.DeepEqual(oldNode.Status.Addresses, newNode.Status.Addresses) + }, + DeleteFunc: func(_ event.DeleteEvent) bool { + return true + }, + GenericFunc: func(_ event.GenericEvent) bool { + return true + }, + } +} + +func ingressClassPredicate() predicate.Predicate { + return predicate.NewPredicateFuncs(func(object client.Object) bool { + ingressClass, ok := object.(*networkingv1.IngressClass) + if !ok { + return false + } + return ingressClass.Spec.Controller == controllerName + }) +} diff --git a/pkg/controller/ingress/spec/annotations.go b/pkg/controller/ingress/spec/annotations.go new file mode 100644 index 0000000..f5a9be4 --- /dev/null +++ b/pkg/controller/ingress/spec/annotations.go @@ -0,0 +1,108 @@ +package spec + +import ( + "strconv" + + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + // AnnotationExternalIP references a STACKIT public IP that should be used by the application load balancer. + // If set it will be used instead of an ephemeral IP. The IP must be created by the customer. When the service is deleted, + // the public IP will not be deleted. The IP is ignored if the alb.stackit.cloud/internal-alb is set. + // If the annotation is set after the creation it must match the ephemeral IP. + // This will promote the ephemeral IP to a static IP. + // Can be set on IngressClass. + AnnotationExternalIP = "alb.stackit.cloud/external-address" + // AnnotationInternal If true, the application load balancer is not exposed via a public IP. + // Can be set on IngressClass. + AnnotationInternal = "alb.stackit.cloud/internal-alb" + // AnnotationPlanID sets the plan for the ALB. + // Can be set on IngressClass. + AnnotationPlanID = "alb.stackit.cloud/plan-id" + + // AnnotationTargetPoolTLSEnabled If true, the application load balancer enables TLS bridging. + // It uses the trusted CAs from the operating system for validation. + // Can be set on IngressClass, Ingress and Service. + AnnotationTargetPoolTLSEnabled = "alb.stackit.cloud/target-pool-tls-enabled" + // AnnotationTargetPoolTLSCustomCa If set, the application load balancer enables TLS bridging with a custom CA provided as value. Is this an inlined field? What is its format? Can this be set to an empty string to reset it? + // Can be set on IngressClass, Ingress and Service + AnnotationTargetPoolTLSCustomCa = "alb.stackit.cloud/target-pool-tls-custom-ca" + // AnnotationTargetPoolTLSSkipCertificateValidation If true, the application load balancer enables TLS bridging but skips validation. + // Can be set on IngressClass, Ingress and Service. + AnnotationTargetPoolTLSSkipCertificateValidation = "alb.stackit.cloud/target-pool-tls-skip-certificate-validation" + + // AnnotationHTTPPort Specifies the HTTP port. + // Can be set on IngressClass and Ingress. + AnnotationHTTPPort = "alb.stackit.cloud/http-port" + // AnnotationHTTPSPort Specifies the HTTPS port. + // Can be set on IngressClass and Ingress. + AnnotationHTTPSPort = "alb.stackit.cloud/https-port" + // AnnotationHTTPSOnly if true, the ingress will not be reachable via HTTP. + // Setting this to true requires that the ingress has a TLS certificate. + // Can be set on IngressClass and Ingress. + AnnotationHTTPSOnly = "alb.stackit.cloud/https-only" + + // AnnotationWebSocket accepts a bool to decide whether websocket support is enabled. + // Can be set on IngressClass and Ingress. + AnnotationWebSocket = "alb.stackit.cloud/websocket" + + // AnnotationWAFName accepts a string and must reference a web application firewall that already exists. + // Can be set on IngressClass and applies to all ports. + AnnotationWAFName = "alb.stackit.cloud/web-application-firewall-name" + + // AnnotationPriority is used to set the priority of the Ingress. Can be only set on ingress objects. + // Can be set on IngressClass and Ingress. + AnnotationPriority = "alb.stackit.cloud/priority" + + // AnnotationAllowedSourceRanges accept a comma-separated list of IP ranges. E.g. 10.0.0.0/24,1.2.3.4/32. + // Can be set on IngressClass and applies to all ports. + AnnotationAllowedSourceRanges = "alb.stackit.cloud/allowed-source-ranges" +) + +// GetAnnotation retrieves an annotation value from objects. +// If multiple objects contain the annotation, the first object containing the annotation takes precedence. +// If no object contains the annotation then defaultValue is returned. +// +// GetAnnotation parses the value of the annotation and return type T. +// If T is string then the value is returned raw. +// For int and bool Atoi and ParseBool are called respectively. +// If parsing fails or T is any other type, defaultValue is returned. +// Only the first found value is parsed. +func GetAnnotation[T any](annotation string, defaultValue T, objects ...client.Object) T { + var rawVal string + var found bool + + // Iterate through sources (e.g., Ingress, then IngressClass) + for _, object := range objects { + if val, exists := object.GetAnnotations()[annotation]; exists { + rawVal = val + found = true + break + } + } + + if !found { + return defaultValue + } + + var result any + var err error + + switch any(defaultValue).(type) { + case string: + return any(rawVal).(T) + case int: + result, err = strconv.Atoi(rawVal) + case bool: + result, err = strconv.ParseBool(rawVal) + default: + return defaultValue + } + + if err != nil { + return defaultValue + } + + return result.(T) +} diff --git a/pkg/controller/ingress/spec/events.go b/pkg/controller/ingress/spec/events.go new file mode 100644 index 0000000..8b44385 --- /dev/null +++ b/pkg/controller/ingress/spec/events.go @@ -0,0 +1,33 @@ +package spec + +import ( + "fmt" + + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/client-go/tools/record" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +type ErrorEvent struct { + Ingress client.Object + Description string + FieldPath *field.Path +} + +func (e *ErrorEvent) Error() string { + if e.FieldPath != nil { + return fmt.Sprintf("%s: %s", e.FieldPath.String(), e.Description) + } + return e.Description +} + +func (e *ErrorEvent) RecordEvent(class *networkingv1.IngressClass, recorder record.EventRecorder) { + if e.Ingress.GetName() == "" { + return + } + + recorder.Eventf(class, corev1.EventTypeWarning, "IngressWarning", "Error in %s in Namespace %s: %s", e.Ingress.GetName(), e.Ingress.GetNamespace(), e.Error()) + recorder.Event(e.Ingress, corev1.EventTypeWarning, "IngressWarning", e.Error()) +} diff --git a/pkg/controller/ingress/spec/labels.go b/pkg/controller/ingress/spec/labels.go new file mode 100644 index 0000000..90a0655 --- /dev/null +++ b/pkg/controller/ingress/spec/labels.go @@ -0,0 +1,32 @@ +package spec + +import ( + "regexp" + "strings" +) + +const ( + + // prefixALBIngressController is the prefix for all labels associated with ingress controllers + prefixALBIngressController = "alb-ingress-controller-" + // LabelIngressClassUID is the unique key that identifies resources + // owned by a specific IngressClass. + LabelIngressClassUID = prefixALBIngressController + "ingress-class-uid" +) + +// Replace non-alphanumeric characters (except '-', '_', '.') with '-' +var reg = regexp.MustCompile(`[^-a-zA-Z0-9_.]+`) + +func SanitizeLabelValue(input string) string { + sanitized := reg.ReplaceAllString(input, "-") + + // Ensure the label starts and ends with an alphanumeric character + sanitized = strings.Trim(sanitized, "-_.") + + // Ensure the label is not longer than 63 characters + if len(sanitized) > 63 { + sanitized = sanitized[:63] + } + + return sanitized +} diff --git a/pkg/controller/ingress/spec/labels_test.go b/pkg/controller/ingress/spec/labels_test.go new file mode 100644 index 0000000..d9ad4a0 --- /dev/null +++ b/pkg/controller/ingress/spec/labels_test.go @@ -0,0 +1,35 @@ +package spec + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("SanitizeLabelValue", func() { + It("should replace non-alphanumeric characters with hyphens", func() { + result := SanitizeLabelValue("test-label_with.special@chars!") + Expect(result).To(Equal("test-label_with.special-chars")) + }) + + It("should trim hyphens, underscores, and dots from the beginning and end", func() { + result := SanitizeLabelValue("...test-label---") + Expect(result).To(Equal("test-label")) + }) + + It("should truncate labels longer than 63 characters", func() { + longLabel := "this-is-a-very-long-label-that-should-be-truncated-to-63-characters-1234567890" + result := SanitizeLabelValue(longLabel) + Expect(len(result)).To(BeNumerically("<=", 63)) + Expect(result).To(Equal("this-is-a-very-long-label-that-should-be-truncated-to-63-charac")) + }) + + It("should handle empty string", func() { + result := SanitizeLabelValue("") + Expect(result).To(Equal("")) + }) + + It("should handle string with only invalid characters", func() { + result := SanitizeLabelValue("!@#$%^&*()") + Expect(result).To(Equal("")) + }) +}) diff --git a/pkg/controller/ingress/spec/limits.go b/pkg/controller/ingress/spec/limits.go new file mode 100644 index 0000000..2d88e34 --- /dev/null +++ b/pkg/controller/ingress/spec/limits.go @@ -0,0 +1,5 @@ +package spec + +// LimitTargetPools is the maximum amount of target pools allowed in the ALB API. +// Because of how we create target pools per path this is the most limiting factor right now and we don't have to check limits for paths. +const LimitTargetPools = 20 diff --git a/pkg/controller/ingress/spec/name.go b/pkg/controller/ingress/spec/name.go new file mode 100644 index 0000000..9f4bbbe --- /dev/null +++ b/pkg/controller/ingress/spec/name.go @@ -0,0 +1,13 @@ +package spec + +import ( + "fmt" + + networkingv1 "k8s.io/api/networking/v1" +) + +// LoadBalancerName returns the desired name for a load balancer. +// The ingress class must have a UID. +func LoadBalancerName(ingressClass *networkingv1.IngressClass) string { + return fmt.Sprintf("k8s-ingress-%s", ingressClass.UID) +} diff --git a/pkg/controller/ingress/spec/suite_test.go b/pkg/controller/ingress/spec/suite_test.go new file mode 100644 index 0000000..5e599bc --- /dev/null +++ b/pkg/controller/ingress/spec/suite_test.go @@ -0,0 +1,13 @@ +package spec + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestSpec(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "ALB Spec") +} diff --git a/pkg/controller/ingress/spec/testdata/certs.go b/pkg/controller/ingress/spec/testdata/certs.go new file mode 100644 index 0000000..1fab5dd --- /dev/null +++ b/pkg/controller/ingress/spec/testdata/certs.go @@ -0,0 +1,257 @@ +package testdata + +const ( + FixtureTLS1PublicKey = `-----BEGIN CERTIFICATE----- +MIIFmzCCA4OgAwIBAgIUbhg0VsnIT3fREtGHtyj1YYY1mkUwDQYJKoZIhvcNAQEL +BQAwXTELMAkGA1UEBhMCREUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM +GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDEWMBQGA1UEAwwNbXktaG9zdC5sb2Nh +bDAeFw0yNjA2MTYwODU4MzVaFw0yNzA2MTYwODU4MzVaMF0xCzAJBgNVBAYTAkRF +MRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRz +IFB0eSBMdGQxFjAUBgNVBAMMDW15LWhvc3QubG9jYWwwggIiMA0GCSqGSIb3DQEB +AQUAA4ICDwAwggIKAoICAQDBwBCu7Bc77uMgUOslDJUObgG5FZUYWzdo6owK6Qmo +aNfvjmwwkbMHLqu8t6ZNi9UoRTJ1G9GeM8JtPL+bikKu1ZjN2MbO6VHI3xy0Az85 +r2/FKta1faFcrV7Vul/zJqAljf4qeTK31mFmZq1is86Q0wYcEf3qnNDafN5ThGT/ +F7akDlKTDG1RmyXHw+/90TINZ6q8Rqf5kI3EV63zlrG6iRJ38Dphge8Hk+ZGjURm +qx7Jz2iJkRGbIB53ZDEBk+KWM6K7iUbswmJv4qyat8P7Bv2Iisob9LVhU//852f+ +vdmdxoebUn6dGjsNv9lX0qKiEzcE1Lm2SPNIB3bfY5xNnKNjCT7qZ4NXKoeTTwLK +S+gN8zcY3Sdb8kyCKmhIGA4TXsQEyhzG/YwYGE/VgOEgv324VDGB5FcT+VcjZiHD +6nzDfqKH3NkaJ70PsCa4t3scHogkWQLnGMJd2/T+t/L3tVPZaJearexh6RUZJlIW +gCCAMqJPoALKzGrfSHhiy5L+ghpEgSnh4ZiWYxNbPcbGOXygxOZVnNc4y1PNb+vX +hXGU16wSoWQf8cZA0WDKiXLFz7qM6tAS49PJsHalWryE3qO741D/fOgl7Nzsi6MR +0lMsR9pCptIPPmiY/5f6pFxgS08IJFhaxAybCEuroLLBdXBMcD2SmSP7Scm2az09 +1QIDAQABo1MwUTAdBgNVHQ4EFgQUjdu/uxlLXaaafQIdx6gZZ45cxgswHwYDVR0j +BBgwFoAUjdu/uxlLXaaafQIdx6gZZ45cxgswDwYDVR0TAQH/BAUwAwEB/zANBgkq +hkiG9w0BAQsFAAOCAgEAX+/DmcP+iAqOo0WaOOvM7V4Iz8EAXSRdgMgi+xPRH8Dt +gYe1xc0eb3UJkkeOusrQKfEXbC47X905aAGACPNqLs+Mm40h8bctAqKExgFM7noM +8OK/y1I3RjDtbCMHJ5uCanuuqgVpXuuSWOafwY21n2mPi15+wjYJlk9YOVPAXkIl +wHpWwGv+4uuD0ppTHwF2bLFpypeVSsVLQdQ/F6H2K6QFIaHXhMZm2m1wLdD8AuiU +1AagiwOQwnGcSzKSjptO1DjWlJOPffcAzO2zXq3HT4Y3debbiKIY5uhXJfU7u82D +Q45dms99DN6FzFONf92NfHI48PAmHXFD8xoKOYejcsV/Fe0coccCbbj/wlReVabt +PE0skr0z12hPkQ6+BQri2nxKqbQPCyLKQNJ4p1ku2v73TX0zd2fU+P3mV0UoFovF +/8vOqc6J+MyrDSzvqdunEPL8pG6ziGnhC2fT2e41LYKWQqkBjFIQnEeTcr0pVdiG +R4dGu19QV3PBoX2IbLexndiYGCJuBsKpjIu5C4Z5BibXXZdngPwpWdaoG2DZQZ2s +okmiQzkHzZ3ADR/UVqTDICjr8gEzjZRfgwEt+jIkgEV7i5S9GS9miyzUKPi6pEuL +JGVFbYQdFntS/izqlEV0L+3te0WKQIEX6Sq8wdxg0twpRdzaMepJiLTYi/YxJa8= +-----END CERTIFICATE-----` + FixtureTLS1PrivateKey = `-----BEGIN PRIVATE KEY----- +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 +UoA+9Hd41MHTo2Frp1cML2BpdbK/ +-----END PRIVATE KEY-----` + FixtureTLS1FingerprintSHA256 = "279e27ca342d1ebe302a492f5a43254f74a4e0e790e5f9448bb2f7e95ec74989" + + FixtureTLS2PublicKey = `-----BEGIN CERTIFICATE----- +MIIFKzCCAxOgAwIBAgIUbCN76MgIJJQtGv9R+rIMVp8BCwowDQYJKoZIhvcNAQEL +BQAwJTELMAkGA1UEBhMCREUxFjAUBgNVBAMMDW15LWhvc3QubG9jYWwwHhcNMjYw +NjI2MTE1NTUwWhcNMjcwNjI2MTE1NTUwWjAlMQswCQYDVQQGEwJERTEWMBQGA1UE +AwwNbXktaG9zdC5sb2NhbDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIB +ALUYZjv0XLs6CC2E33Sc8x+7JPRAdWkrPIA3/ZdhL8OHh2+Ove25gRjTgNTVq2qt +8RpuJS+mChGG3sVT29pX8ll0yG/gEvJ2hyEGKz/51SwVorXmdIxN2YAyezTmo1UT +fWj5denXzukyD3AiTXS6j8DcsqcV/vj3RxZMFT1kTobCvir9KRjDoiVbGcVFFqtl +RCqajG+7AJOsHCH5EdAsonwyCsMloXeZEXbbrhoKVktn6JGl3AjbK7Go8pg+402h +sQjw221RyQN+CPSZ9WfPSy1mZjuMSIL5u5JuvVa1lgRf9eQRpvXd6zsNR/4szNFf +O7jwnIv7P+1y9PvhZg1FpRNAnFSeofwiHt19Zt6K0XQaK3zmLMLxGAThzrbfcXsM +XiWiUSqwelB0rsoz/m9zHvf7+V1fZQDTgcgOXRn+4ssojA7K5d/vSClXJvLrh+BD +pbtudQTMPKhNBDT954A/eCmp/nie8tqGoOKtXwE6DdCTeWq5I6j86wdXVSs4RNrQ +3Zpa2GtMYxgsus0UYQLEzrHzqdrOhhS3XE8+XdJ87zkMp1cXKxP32sf9UEYmOXG1 +kCpGnIRoor6DNm7vUorOE13Zj4Io4FW+NMFU5f4QqcZzXb578Ttc15tK2CgROTm3 +c6pPtRZA9LqlLCVtLFv+YNORwOFchNItAl3mB/EQfM0rAgMBAAGjUzBRMB0GA1Ud +DgQWBBSX7LimGKT3xv/sij1Fw6fuvFZUdjAfBgNVHSMEGDAWgBSX7LimGKT3xv/s +ij1Fw6fuvFZUdjAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4ICAQCY +XrBaCuAAkWO9yKNy6yOxSMRoh8JNUjbtB3qjNFHJ8+5ztyFKSUpQUSEaCoPT8bVu +iZQG76iR6wSlJEPeREvAvC2RcDlJUWTunZJDEAyzaWdjbFGNg0b+hJBOchq14obb +NLnKtB+COB+Raw+wWE+Q8pnReGWMSzwD4mn19ch8+q02rNlQeWoBCHUKMHlxgDAg +/K28k5LOpOeai6x+4TYj0mJfGM1qce6Q99kABYx6oJxAXCorP2KoZ/kyZYaOVqlD +fcimou8fObDBkK+9AQa/MCn+AHCQrmTyU+MtOlQd93f86qybSzofiPeAOW0mU+0D +wJZbULYcPQSveEMo4WvHV85VqRkWjPy+t6gwgROXgrxB1qqFUSZkRUjun5sJzk0A +KM4L1TKkCzr7yWsudRtJM/RY/ES7kHPxPsYr8oBzobZaPTyr6Y3rAqC0HyRt+BkA +FhW3zjMtYY59cWNtdAVgvmPsosg1IBffDVdZKYV2q1/cZSHiZDwAd9zz+xgpv9BI +D22flZSlKcSTMqadG1WGD1CVWYUX+YIvRvubD9etPGM5i+kZc975wRutyNy0qw/3 +EgbIyrMv9eBKYjXmYRl3UHBapnbmFsn4JOsB+dKF61EWHLUyMFTxQWlchiwMhG2Q +tG9slDBO9ftUzm1ZL/WOkopeBXmzXaMFSUOl2NsLtQ== +-----END CERTIFICATE-----` + FixtureTLS2PrivateKey = `-----BEGIN PRIVATE KEY----- +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 +XSMBrQNdfooue8bAjlkWKtAju+ns +-----END PRIVATE KEY-----` + FixtureTLS2FingerprintSHA256 = "2edd2fce36dab51b899aa4b06ef43c92bf516e46c7471c24de9e7d370cff7865" + + FixtureTLS3PublicKey = `-----BEGIN CERTIFICATE----- +MIIFKzCCAxOgAwIBAgIUDFVa465Gur76s3wCQvx9PNG7ukkwDQYJKoZIhvcNAQEL +BQAwJTELMAkGA1UEBhMCQVQxFjAUBgNVBAMMDW15LWhvc3QubG9jYWwwHhcNMjYw +NjI2MTMyNDQzWhcNMjcwNjI2MTMyNDQzWjAlMQswCQYDVQQGEwJBVDEWMBQGA1UE +AwwNbXktaG9zdC5sb2NhbDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIB +AKBdKLztlS/nJkZEH7Q4VqQc9uaiykYcrqKHrazE1tcYXD3RJFwpgeB/OeMX8W5R +n0AUkN0V7Vw7dNVIDD7YRS1qkrFXXDyIQcQrDCvx1mzBGQ509ewGhBrgk348OXxo +O2YjqnU4Q9WZXRsGUZOdO5m9jMhRn5Ua74A98UoHcdPggxh0BdHD7n1se3pZMXvw +MyrCiNsHXswkdcW0k3Mvx29Z/90I96nNnrPFNLA0RtUveUCvPG6bWokdZlErRDkD +7OH7RKsZUowWzXKQtSQ1UwkGZ7/Jd5ZJuSWKdDUy+dVTZJiAz9+3nsWmVSHw4mM/ +f/gB+ZcLl/MnWBTpOVbQtvlxA7PJ07G+L5W9znqgtj13cYQeLRKLB/dxF6J7ce0G +aE8R5z8dbZPl6dnQ4iAhVHb4Ug8w+YkP1XKwHTii/rwU+ETaLgXjHHT5uDEZIfaD +4rg1k9MdWRdKjye5y5XDmiYtio69e7a/4sfj44ECpF/STCXDuGq9UbdM0/NryLqR +AiIumXLQ7LtKZrZyB2FKrJd75QT3srGB67tg4svicT3DHBmXi+XS6e6eQLf+hgZo +J/csdS9Pr5SGmJvahWX1KTMvhXd8RvjaNlVe2r5tPLSVFsQPUXSUg5kcCAmJA5go +j91OJygB8iYLOOyBxIeGj54OnNoBghGMMgWDVvE26QmDAgMBAAGjUzBRMB0GA1Ud +DgQWBBQPp6+L+L0NHtDBelVrQ1BJa79vTDAfBgNVHSMEGDAWgBQPp6+L+L0NHtDB +elVrQ1BJa79vTDAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4ICAQAf +g3OsrVuJiIkp3djtLemk2qikfIaoeDss9g1Ps4BDX6UiLXZcRhTaT7Ra/sYEFF/O +T0UWiK9JCYEttMH8zDeIGk10H6jqrxwNGbYRxrDa2SWzklEZ5NriKYGQLiF+riZj +njY0zoYgqoi3NUP4YS1e2DDomxVnXFyGG+WAJb+aaUhFx4UCa1sl2tx7JPxe2pHW +OEPE4sVIBRxETNfSsvIyTkY4wTtKilDZHnsg5RFpyqFffFIcXt2bQ7MUbZIHMe4O +RjfLf9EPbCxhA9gaS/7vMlcfRl8K5lkElMR6hg8f2YD9n0iy/7/hD/h25y14FSPt +9Fhb47LnNbHeYaIaNxQ+7RTQdJI1kqP2cFIJ2cnPsB9R7v0huLU0c5wR8h/04UfX +ZNZZawIW2AJ+6zOod6MMozYqaQCElM1FSH7Tp3VNcMju6oxZ2/j5g632NqhzvUsP +VbNXT+ACdQerUjAmJxj3DIZWaUnC/WUsvny1yXBeYHkmZlFw/hXw1qQBcpjD3R4p +eF56NxXCxxUKQ/j1pcvaTE6Df3kx33/BjQn+NNwLYs1D/XoTbQcvN48u8jn9rv+K +YNCQedMVLi1Uac3u78QwdTxo4bRVy46R961Z/yzyKHEpBQ2Y6DdgkNyrBaX7rDS7 +bgjoKD1uUi4jq+/5XK0Paq5RXwv0zt4q9DP9bBPVVw== +-----END CERTIFICATE-----` + FixtureTLS3PrivateKey = `-----BEGIN PRIVATE KEY----- +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 +jX1tGAXv9xD3zyBA6yR60ghqNLTJcg== +-----END PRIVATE KEY-----` + FixtureTLS3FingerprintSHA256 = "af9d866cce3f82c8fd5d096d3c77229738b498c3031a06d7fe42fe129c457dff" +) diff --git a/pkg/controller/ingress/spec/worktree.go b/pkg/controller/ingress/spec/worktree.go new file mode 100644 index 0000000..c9fe517 --- /dev/null +++ b/pkg/controller/ingress/spec/worktree.go @@ -0,0 +1,621 @@ +package spec + +import ( + "cmp" + "crypto/sha256" + cryptotls "crypto/tls" + "encoding/hex" + "fmt" + "maps" + "slices" + "strings" + + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + + albsdk "github.com/stackitcloud/stackit-sdk-go/services/alb/v2api" + certsdk "github.com/stackitcloud/stackit-sdk-go/services/certificates/v2api" +) + +type CertificateFingerprint string + +// WorkTreeALB is a temporary structure to build up an ALB specification from ingresses. +// It contains the relevant logic to merge multiple ingresses and report errors for invalid or conflicting ingresses. +// +// The zero value is invalid. Use BuildTree() to create a work tree. +// +// Look at the methods how a work tree can be used. +type WorkTreeALB struct { + ingressClass *networkingv1.IngressClass + planID string + waf string + accessControl *albsdk.LoadbalancerOptionAccessControl + internalLB bool + externalIP string + + listeners map[int16]*workTreeListener + // We can already create the real type because there is nothing to merge or track. + targetPools map[ingressPathReference]*albsdk.TargetPool + certificates map[CertificateFingerprint]WorkTreeCertificate + + existingALB *albsdk.LoadBalancer +} + +type protocol string + +const ( + protocolHTTP protocol = "PROTOCOL_HTTP" + protocolHTTPS protocol = "PROTOCOL_HTTPS" +) + +type workTreeListener struct { + hosts map[string]*workTreeHost + protocol protocol +} + +type pathWithType struct { + pathType networkingv1.PathType + path string +} + +type workTreeHost struct { + paths map[pathWithType]*workTreePath +} + +type ingressPathReference struct { + namespace string + name string + uid string + ruleIndex int + pathIndex int +} + +// toTargetPoolName returns the desired target pool name for this path reference. +// It globally identifies this path via UID of the ingress. +func (i *ingressPathReference) toTargetPoolName() string { + return fmt.Sprintf("%s-%d-%d", i.uid, i.ruleIndex, i.pathIndex) +} + +type workTreePath struct { + path pathWithType + ingressPathReference ingressPathReference + websocket bool +} + +type WorkTreeCertificate struct { + PublicKey string + PrivateKey string + // Ports tracks all HTTPS ports that use that certificate. The values of the map are not used. Only presence matters. + Ports map[int16]any +} + +// BuildTree creates a new work tree. +// It tries to fit as much ingresses into the work tree as possible, bound by the limits of the application load balancer. +// +// Every ingress rule translates into 1 or 2 rules in the ALB, depending on the protocols used for that ingress. +// +// If existingALB is nil it is assumed that no load balancer exists yet. +// existingALB is used to pick up fields that are already set, most notably the version for the update payload. +// +// The arguments must only contain data related to the ingress class. +// I.e. all ingresses will be processed regardless of their ingress class reference. +// +// This function changes the order of the slice ingresses. +func BuildTree( //nolint:gocyclo,funlen // Breaking up this function won't make it much simpler. + ingressClass *networkingv1.IngressClass, + ingresses []networkingv1.Ingress, + secrets []corev1.Secret, + services []corev1.Service, + nodes []corev1.Node, + existingALB *albsdk.LoadBalancer, +) (*WorkTreeALB, []ErrorEvent) { + errors := []ErrorEvent{} + + servicesMap := map[types.NamespacedName]corev1.Service{} + for i := range services { + servicesMap[client.ObjectKeyFromObject(&services[i])] = services[i] + } + secretsMap := map[types.NamespacedName]corev1.Secret{} + for i := range secrets { + secretsMap[client.ObjectKeyFromObject(&secrets[i])] = secrets[i] + } + + targets := getTargetsOfNodes(nodes) + + tree := &WorkTreeALB{ + ingressClass: ingressClass, + planID: GetAnnotation(AnnotationPlanID, "", ingressClass), + waf: GetAnnotation(AnnotationWAFName, "", ingressClass), + internalLB: GetAnnotation(AnnotationInternal, false, ingressClass), + externalIP: GetAnnotation(AnnotationExternalIP, "", ingressClass), + + listeners: map[int16]*workTreeListener{}, + targetPools: map[ingressPathReference]*albsdk.TargetPool{}, + existingALB: existingALB, + certificates: map[CertificateFingerprint]WorkTreeCertificate{}, + } + + addAccessControlToTree(tree, ingressClass) + + slices.SortFunc(ingresses, func(a, b networkingv1.Ingress) int { + if diff := GetAnnotation(AnnotationPriority, 0, &b) - GetAnnotation(AnnotationPriority, 0, &a); diff != 0 { + return diff + } + if diff := a.CreationTimestamp.Compare(b.CreationTimestamp.Time); diff != 0 { + return diff + } + return cmp.Compare(fmt.Sprintf("%s/%s", a.Namespace, a.Name), + fmt.Sprintf("%s/%s", b.Namespace, b.Name)) + }) + for i := range ingresses { + ingress := &ingresses[i] + httpsOnly := GetAnnotation(AnnotationHTTPSOnly, false, ingress, ingressClass) + httpPort := GetAnnotation(AnnotationHTTPPort, 80, ingress, ingressClass) + httpsPort := GetAnnotation(AnnotationHTTPSPort, 443, ingress, ingressClass) + + for tlsIndex, tls := range ingress.Spec.TLS { + secret, exists := secretsMap[types.NamespacedName{Namespace: ingress.Namespace, Name: tls.SecretName}] + if !exists { + errors = append(errors, ErrorEvent{ + Ingress: ingress, + FieldPath: field.NewPath("spec", "tls").Index(tlsIndex).Child("secretName"), + Description: "TLS secret doesn't exist", + }) + continue + } + if secret.Type != corev1.SecretTypeTLS { + errors = append(errors, ErrorEvent{ + Ingress: ingress, + FieldPath: field.NewPath("spec", "tls").Index(tlsIndex).Child("secretName"), + Description: "TLS secret isn't of type kubernetes.io/tls", + }) + continue + } + + fingerprint, err := ValidateTLSCertAndFingerprint(secret.Data[corev1.TLSCertKey], secret.Data[corev1.TLSPrivateKeyKey]) + if err != nil { + errors = append(errors, ErrorEvent{ + Ingress: ingress, + FieldPath: field.NewPath("spec", "tls").Index(tlsIndex).Child("secretName"), + Description: fmt.Sprintf("invalid certificate: %s", err.Error()), + }) + continue + } + + if _, exists := tree.certificates[CertificateFingerprint(fingerprint)]; !exists { + tree.certificates[CertificateFingerprint(fingerprint)] = WorkTreeCertificate{ + PublicKey: string(secret.Data[corev1.TLSCertKey]), + PrivateKey: string(secret.Data[corev1.TLSPrivateKeyKey]), + Ports: map[int16]any{}, + } + } + tree.certificates[CertificateFingerprint(fingerprint)].Ports[int16(httpsPort)] = nil + } + + for ruleIndex, rule := range ingress.Spec.Rules { + // TODO: support rules that don't have a path + for pathIndex, path := range rule.HTTP.Paths { + ingressPathReference := ingressPathReference{namespace: ingress.Namespace, name: ingress.Name, uid: string(ingress.UID), ruleIndex: ruleIndex, pathIndex: pathIndex} + + targetPool, e := buildTargetPool(tree, ingressClass, targets, ingress, ruleIndex, path, pathIndex, servicesMap) + errors = append(errors, e...) + if targetPool == nil { + continue // If the target pool is invalid we do not add any rules. + } + + var httpAdded, httpsAdded bool + if !httpsOnly { + httpAdded, e = addPathToTree(tree, ingressClass, ingress, rule, ruleIndex, path, pathIndex, int16(httpPort), protocolHTTP) + errors = append(errors, e...) + } + if len(ingress.Spec.TLS) > 0 { + httpsAdded, e = addPathToTree(tree, ingressClass, ingress, rule, ruleIndex, path, pathIndex, int16(httpsPort), protocolHTTPS) + errors = append(errors, e...) + } + + // We only add the target pool if at least one rule was added that references the target pool. + if httpAdded || httpsAdded { + tree.targetPools[ingressPathReference] = targetPool + } + } + } + } + + return tree, errors +} + +func addAccessControlToTree(tree *WorkTreeALB, ingressClass *networkingv1.IngressClass) { + annotation := GetAnnotation(AnnotationAllowedSourceRanges, "", ingressClass) + if annotation == "" { + return + } + ranges := strings.Split(annotation, ",") + tree.accessControl = &albsdk.LoadbalancerOptionAccessControl{ + AllowedSourceRanges: ranges, + } +} + +// addPathToTree adds the given path to tree under the given port and protocol. +// It implicitly creates listeners and hosts that don't exist yet in tree. +func addPathToTree(tree *WorkTreeALB, ingressClass *networkingv1.IngressClass, ingress *networkingv1.Ingress, rule networkingv1.IngressRule, ruleIndex int, path networkingv1.HTTPIngressPath, pathIndex int, port int16, protocol protocol) (added bool, errors []ErrorEvent) { + _pathWithType := pathWithType{pathType: ptr.Deref(path.PathType, networkingv1.PathTypeExact), path: path.Path} + ingressPathReference := ingressPathReference{namespace: ingress.Namespace, name: ingress.Name, uid: string(ingress.UID), ruleIndex: ruleIndex, pathIndex: pathIndex} + + listener, exists := tree.listeners[port] + if !exists { + listener = &workTreeListener{ + hosts: map[string]*workTreeHost{}, + protocol: protocol, + } + } + if listener.protocol != protocol { + // TODO: This error is redundant if the ingress contains multiple rules. Move this check "up". + errors = append(errors, ErrorEvent{ + Ingress: ingress, + FieldPath: field.NewPath("spec"), + Description: fmt.Sprintf("Listener with port %d has protocol %s but ingress uses the port for %s", port, listener.protocol, protocol), + }) + return false, errors + } + + host, exists := listener.hosts[rule.Host] + if !exists { + host = &workTreeHost{ + paths: map[pathWithType]*workTreePath{}, + } + } + + albPath, exists := host.paths[_pathWithType] + if exists { + errors = append(errors, ErrorEvent{ + Ingress: ingress, + FieldPath: field.NewPath("spec", "rules").Index(ruleIndex).Child("paths").Index(pathIndex), + Description: "Path already exists", + }) + return false, errors + } + albPath = &workTreePath{ + path: _pathWithType, + ingressPathReference: ingressPathReference, + websocket: GetAnnotation(AnnotationWebSocket, false, ingress, ingressClass), + } + + // We assign listener and host whether they exist or not. If they already exist we assign them to the same pointer. + tree.listeners[port] = listener + listener.hosts[rule.Host] = host + + host.paths[_pathWithType] = albPath + return true, errors +} + +// buildTargetPool builds a target pool for the provided path. +// It uses tree to validate the returned target pool against the existing state. +// +// This function doesn't mutate tree or any other arguments. +// If the target pool is not valid nil is returned together with a list of errors. +func buildTargetPool(tree *WorkTreeALB, ingressClass *networkingv1.IngressClass, targets []albsdk.Target, ingress *networkingv1.Ingress, ruleIndex int, path networkingv1.HTTPIngressPath, pathIndex int, servicesMap map[types.NamespacedName]corev1.Service) (*albsdk.TargetPool, []ErrorEvent) { + errors := []ErrorEvent{} + + ingressPathReference := ingressPathReference{namespace: ingress.Namespace, name: ingress.Name, uid: string(ingress.UID), ruleIndex: ruleIndex, pathIndex: pathIndex} + + _, exists := tree.targetPools[ingressPathReference] + if !exists { + if len(tree.targetPools) >= LimitTargetPools { + errors = append(errors, ErrorEvent{ + Ingress: ingress, + FieldPath: field.NewPath("spec", "rules").Index(ruleIndex).Child("paths").Index(pathIndex), + Description: "Target pool limit reached. Path will be ignored.", + }) + } + } + targetPool := &albsdk.TargetPool{} + + // TODO: Support other backends than services. + + service, exists := servicesMap[types.NamespacedName{Namespace: ingress.Namespace, Name: path.Backend.Service.Name}] + if !exists { + errors = append(errors, ErrorEvent{ + Ingress: ingress, + FieldPath: field.NewPath("spec", "rules").Index(ruleIndex).Child("paths").Index(pathIndex).Child("backend", "service", "name"), + Description: "Service doesn't exist", + }) + return nil, errors + } + if service.Spec.Type != corev1.ServiceTypeNodePort && service.Spec.Type != corev1.ServiceTypeLoadBalancer { + errors = append(errors, ErrorEvent{ + Ingress: ingress, + FieldPath: field.NewPath("spec", "rules").Index(ruleIndex).Child("paths").Index(pathIndex).Child("backend", "service", "name"), + Description: "Service is not of type NodePort or LoadBalancer", + }) + return nil, errors + } + nodePort := int32(0) + for _, port := range service.Spec.Ports { + if port.Port == path.Backend.Service.Port.Number || + port.Name == path.Backend.Service.Port.Name { + if port.NodePort == 0 { + errors = append(errors, ErrorEvent{ + Ingress: ingress, + FieldPath: field.NewPath("spec", "rules").Index(ruleIndex).Child("paths").Index(pathIndex).Child("backend", "service"), + Description: "Service port doesn't have a node port", + }) + continue + } + nodePort = port.NodePort + } + } + if nodePort == 0 { + errors = append(errors, ErrorEvent{ + Ingress: ingress, + FieldPath: field.NewPath("spec", "rules").Index(ruleIndex).Child("paths").Index(pathIndex).Child("backend", "service"), + Description: "Port not found in service", + }) + return nil, errors + } + + targetPool.Name = new(ingressPathReference.toTargetPoolName()) + targetPool.TargetPort = new(nodePort) + targetPool.Targets = targets + targetPool.TlsConfig = &albsdk.TlsConfig{ + Enabled: new(GetAnnotation(AnnotationTargetPoolTLSEnabled, false, &service, ingress, ingressClass)), + SkipCertificateValidation: new(GetAnnotation(AnnotationTargetPoolTLSSkipCertificateValidation, false, &service, ingress, ingressClass)), + } + if ca := GetAnnotation(AnnotationTargetPoolTLSCustomCa, "", &service, ingress, ingressClass); ca != "" { + targetPool.TlsConfig.CustomCa = new(ca) + } + // If externalTrafficPolicy=Cluster we use the default TCP health check on the node port itself. + if service.Spec.ExternalTrafficPolicy == corev1.ServiceExternalTrafficPolicyLocal { + targetPool.ActiveHealthCheck = &albsdk.ActiveHealthCheck{ + AltPort: &service.Spec.HealthCheckNodePort, + HttpHealthChecks: &albsdk.HttpHealthChecks{ + Path: new("/healthz"), + OkStatuses: []string{"200"}, + }, + HealthyThreshold: new(int32(1)), + Interval: new("5s"), + IntervalJitter: new("0s"), + Timeout: new("3s"), + UnhealthyThreshold: new(int32(3)), + } + } + + return targetPool, errors +} + +// ValidateTLSCertAndFingerprint ensures that the private and public are parseable. +// If they are parseable then the SHA256 hash of the public key is returned. +func ValidateTLSCertAndFingerprint(publicKey, privateKey []byte) (string, error) { + cert, err := cryptotls.X509KeyPair(publicKey, privateKey) + if err != nil { + return "", err + } + sha256Hash := sha256.Sum256(cert.Leaf.Raw) + return hex.EncodeToString(sha256Hash[:]), nil +} + +func getTargetsOfNodes(nodes []corev1.Node) []albsdk.Target { + // TODO: remove nodes that are in deletion + targets := []albsdk.Target{} + for i := range nodes { + node := &nodes[i] + for j := range node.Status.Addresses { + address := node.Status.Addresses[j] + if address.Type == corev1.NodeInternalIP { + targets = append(targets, albsdk.Target{ + DisplayName: &node.Name, + Ip: &address.Address, + }) + break + } + } + } + return targets +} + +// GetMissingCertificates returns all certificates that are required by t except those that it finds in existingCert. +// It can be used to create all remaining certificates required to create the ALB. +// +// This function uses the SHA256 fingerprint from the response to match existing certificates. +func (t *WorkTreeALB) GetMissingCertificates(existingCerts []certsdk.GetCertificateResponse) map[CertificateFingerprint]WorkTreeCertificate { + missingCerts := map[CertificateFingerprint]WorkTreeCertificate{} + existingCertsMap := map[CertificateFingerprint]any{} + for _, cert := range existingCerts { + if cert.Data == nil || cert.Data.FingerprintSha256 == nil { + continue + } + existingCertsMap[CertificateFingerprint(*cert.Data.FingerprintSha256)] = nil + } + + for fingerprint, cert := range t.certificates { + if _, exists := existingCertsMap[fingerprint]; exists { + continue + } + missingCerts[fingerprint] = cert + } + return missingCerts +} + +// GetUnusedCertificates returns all certificates in existingCerts that are not referenced in t. +func (t *WorkTreeALB) GetUnusedCertificates(existingCerts map[CertificateFingerprint]string) map[CertificateFingerprint]string { + unused := maps.Clone(existingCerts) + for fingerprint := range t.certificates { + delete(unused, fingerprint) + } + return unused +} + +// ToCreatePayload return the payload to request the creation of the ALB in the API based on t. +// +// certificateIDMap must contain all certificates that exist in the API for this ALB. +// Certificates that are referenced in t but missing in certificateIDMap are not included in the payload. +func (t *WorkTreeALB) ToCreatePayload( //nolint:gocyclo,funlen // Breaking up this function won't make it much simpler. + certificateIDMap map[CertificateFingerprint]string, + networkID string, + region string, +) *albsdk.CreateLoadBalancerPayload { + listeners := []albsdk.Listener{} + for port, listener := range t.listeners { + hosts := []albsdk.HostConfig{} + for hostname, host := range listener.hosts { + paths := slices.Collect(maps.Values(host.paths)) + typeRank := map[networkingv1.PathType]int{ + networkingv1.PathTypeExact: 1, + networkingv1.PathTypeImplementationSpecific: 2, + networkingv1.PathTypePrefix: 3, + } + slices.SortFunc(paths, func(a, b *workTreePath) int { + if x := cmp.Compare(typeRank[a.path.pathType], typeRank[b.path.pathType]); x != 0 { + return x + } + if x := cmp.Compare(len(b.path.path), len(a.path.path)); x != 0 { + return x + } + return cmp.Compare(a.path.path, b.path.path) + }) + rules := []albsdk.Rule{} + for _, path := range paths { + rule := albsdk.Rule{ + TargetPool: new(path.ingressPathReference.toTargetPoolName()), + WebSocket: &path.websocket, + } + + switch path.path.pathType { + case networkingv1.PathTypeExact: + rule.Path = new(albsdk.Path{ + ExactMatch: new(path.path.path), + }) + case networkingv1.PathTypeImplementationSpecific: + rule.Path = new(albsdk.Path{ + ExactMatch: new(path.path.path), + }) + default: + rule.Path = new(albsdk.Path{ + Prefix: new(path.path.path), + }) + } + + rules = append(rules, rule) + } + + hosts = append(hosts, albsdk.HostConfig{ + Host: &hostname, + Rules: rules, + }) + } + + var https *albsdk.ProtocolOptionsHTTPS + protocol := "PROTOCOL_HTTP" + if listener.protocol == protocolHTTPS { + protocol = "PROTOCOL_HTTPS" + https = &albsdk.ProtocolOptionsHTTPS{ + CertificateConfig: &albsdk.CertificateConfig{ + CertificateIds: []string{}, + }, + } + for fingerprint, cert := range t.certificates { + if _, intendedForPort := cert.Ports[port]; !intendedForPort { + continue + } + if id, exists := certificateIDMap[fingerprint]; exists { + https.CertificateConfig.CertificateIds = append(https.CertificateConfig.CertificateIds, id) + } + } + if len(https.CertificateConfig.CertificateIds) == 0 { + // The API doesn't allow an HTTPS port without certificate. So we drop the port if no certificate was provided. + continue + } + } + + var waf *string + if t.waf != "" { + waf = new(t.waf) + } + listeners = append(listeners, albsdk.Listener{ + Name: new(fmt.Sprintf("port-%d", port)), + WafConfigName: waf, + Protocol: &protocol, + Port: new(int32(port)), + Http: &albsdk.ProtocolOptionsHTTP{ + Hosts: hosts, + }, + Https: https, + }) + } + + if len(listeners) == 0 { + // The ALB doesn't allow zero listeners. To already create it we create an empty listener on port 80. + listeners = append(listeners, albsdk.Listener{ + Name: new(fmt.Sprintf("dummy-port-%d", 80)), + Protocol: new(string(protocolHTTP)), + Port: new(int32(80)), + Http: &albsdk.ProtocolOptionsHTTP{ + Hosts: []albsdk.HostConfig{}, + }, + }) + } + + targetPools := []albsdk.TargetPool{} + for _, targetPool := range t.targetPools { + targetPools = append(targetPools, *targetPool) + } + slices.SortFunc(targetPools, func(a, b albsdk.TargetPool) int { + return cmp.Compare(*a.Name, *b.Name) + }) + + var externalAddress *string + if t.externalIP != "" { + externalAddress = new(t.externalIP) + } + + return &albsdk.CreateLoadBalancerPayload{ + DisableTargetSecurityGroupAssignment: new(true), // TODO: Make this configurable via flag. + Name: new(fmt.Sprintf("k8s-ingress-%s", t.ingressClass.UID)), + Labels: &map[string]string{ + "ingress-class-uid": string(t.ingressClass.UID), + }, + Listeners: listeners, + Networks: []albsdk.Network{ + { + NetworkId: new(networkID), + Role: new("ROLE_LISTENERS_AND_TARGETS"), + }, + }, + ExternalAddress: externalAddress, + Options: &albsdk.LoadBalancerOptions{ + EphemeralAddress: new(t.externalIP == ""), + AccessControl: t.accessControl, + PrivateNetworkOnly: new(t.internalLB), + }, + PlanId: &t.planID, + Region: new(region), + TargetPools: targetPools, + } +} + +// ToUpdatePayload creates the payload to update a load balancer from the work tree. +// It requires that existingALB was not nil when BuildTree was called. +// +// See ToCreatePayload for more details. +// +// The log configuration is taking from the existing load balancer to allow for out-of-band changes of this field. +// +// The output is deterministic for easier change detection. //TODO: Make sure this is actually the case. +func (t *WorkTreeALB) ToUpdatePayload( + certificateIDMap map[CertificateFingerprint]string, + networkID string, + region string, +) *albsdk.UpdateLoadBalancerPayload { + create := t.ToCreatePayload(certificateIDMap, networkID, region) + update := new(albsdk.UpdateLoadBalancerPayload(*create)) + if t.existingALB.Options != nil && t.existingALB.Options.Observability != nil && t.existingALB.Options.Observability.Logs != nil { + update.Options.Observability = &albsdk.LoadbalancerOptionObservability{ + Logs: t.existingALB.Options.Observability.Logs, + } + } + update.Version = t.existingALB.Version + return update +} diff --git a/pkg/controller/ingress/spec/worktree_test.go b/pkg/controller/ingress/spec/worktree_test.go new file mode 100644 index 0000000..efd5018 --- /dev/null +++ b/pkg/controller/ingress/spec/worktree_test.go @@ -0,0 +1,575 @@ +package spec + +import ( + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + . "github.com/onsi/gomega/gstruct" + "github.com/stackitcloud/application-load-balancer-controller/pkg/controller/ingress/spec/testdata" + "github.com/stackitcloud/application-load-balancer-controller/pkg/testutil" + . "github.com/stackitcloud/application-load-balancer-controller/pkg/testutil/ingress" + . "github.com/stackitcloud/application-load-balancer-controller/pkg/testutil/service" + "github.com/stackitcloud/stackit-sdk-go/services/alb/v2api" + + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/validation/field" +) + +var _ = Describe("WorkTreeALB", func() { + It("should sort rules from most to least-specific even if their priority is inversed", func() { + tree, errs := BuildTree(&networkingv1.IngressClass{}, []networkingv1.Ingress{ + Ingress( + "default", "ingress-with-higher-priority", + WithAnnotation(AnnotationPriority, "5"), + WithRule("my-host.local", + WithPath("/prefix/b", new(networkingv1.PathTypePrefix), "my-service", networkingv1.ServiceBackendPort{Number: 1337}), + WithPath("/exact/b", new(networkingv1.PathTypeExact), "my-service", networkingv1.ServiceBackendPort{Number: 1337}), + WithPath("/exact/b/b", new(networkingv1.PathTypeExact), "my-service", networkingv1.ServiceBackendPort{Number: 1337}), + ), + ), + Ingress( + "default", "ingress-with-lower-priority", + WithAnnotation(AnnotationPriority, "4"), + WithRule("my-host.local", + WithPath("/prefix/a", new(networkingv1.PathTypePrefix), "my-service", networkingv1.ServiceBackendPort{Number: 1337}), + WithPath("/exact/a", new(networkingv1.PathTypeExact), "my-service", networkingv1.ServiceBackendPort{Number: 1337}), + WithPath("/exact/a/a", new(networkingv1.PathTypeExact), "my-service", networkingv1.ServiceBackendPort{Number: 1337}), + ), + ), + Ingress( + "default", "ingress-with-default-priority", + WithRule("my-host.local", + WithPath("/implementation-specific", new(networkingv1.PathTypeImplementationSpecific), "my-service", networkingv1.ServiceBackendPort{Number: 1337}), + ), + ), + }, nil, []corev1.Service{ + Service(corev1.NamespaceDefault, "my-service", WithServiceType(corev1.ServiceTypeNodePort), WithPort("my-port", 1337, 30000, corev1.ProtocolTCP)), + }, nil, nil) + Expect(errs).To(BeEmpty()) + createPayload := tree.ToCreatePayload(nil, "", "") + Expect(createPayload.Listeners[0].Http.Hosts[0].Host).To(HaveValue(Equal("my-host.local"))) + Expect(createPayload.Listeners[0].Http.Hosts[0].Rules).To(HaveLen(7)) + Expect(createPayload.Listeners[0].Http.Hosts[0].Rules[0].Path.ExactMatch).To(HaveValue(Equal("/exact/a/a"))) + Expect(createPayload.Listeners[0].Http.Hosts[0].Rules[1].Path.ExactMatch).To(HaveValue(Equal("/exact/b/b"))) + Expect(createPayload.Listeners[0].Http.Hosts[0].Rules[2].Path.ExactMatch).To(HaveValue(Equal("/exact/a"))) + Expect(createPayload.Listeners[0].Http.Hosts[0].Rules[3].Path.ExactMatch).To(HaveValue(Equal("/exact/b"))) + Expect(createPayload.Listeners[0].Http.Hosts[0].Rules[4].Path.ExactMatch).To(HaveValue(Equal("/implementation-specific"))) + Expect(createPayload.Listeners[0].Http.Hosts[0].Rules[5].Path.Prefix).To(HaveValue(Equal("/prefix/a"))) + Expect(createPayload.Listeners[0].Http.Hosts[0].Rules[6].Path.Prefix).To(HaveValue(Equal("/prefix/b"))) + }) + + It("should match rules against correct node ports", func() { + const host = "my-host.local" + tree, errs := BuildTree(&networkingv1.IngressClass{}, []networkingv1.Ingress{ + Ingress( + "default", "ingress-to-node-port-5000", WithUID("uid-1"), + WithRule(host, WithPath("/5000", new(networkingv1.PathTypeExact), "service-a", networkingv1.ServiceBackendPort{Number: 1337})), + ), + Ingress( + "default", "ingress-to-node-port-5001", WithUID("uid-2"), + WithRule(host, WithPath("/5001", new(networkingv1.PathTypeExact), "service-a", networkingv1.ServiceBackendPort{Name: "1338"})), + ), + Ingress( + "default", "ingress-to-node-port-5002", WithUID("uid-3"), + WithRule(host, WithPath("/5002", new(networkingv1.PathTypeExact), "service-a", networkingv1.ServiceBackendPort{Number: 1339})), + ), + Ingress( + "default", "ingress-to-node-port-5003", WithUID("uid-4"), + WithRule(host, WithPath("/5003", new(networkingv1.PathTypeExact), "service-b", networkingv1.ServiceBackendPort{Number: 1337})), + ), + }, nil, []corev1.Service{ + Service("default", "service-a", WithServiceType(corev1.ServiceTypeNodePort), + WithPort("1337", 1337, 5000, corev1.ProtocolTCP), + WithPort("1338", 1338, 5001, corev1.ProtocolTCP), + WithPort("1339", 1339, 5002, corev1.ProtocolTCP), + ), + Service("default", "service-b", WithServiceType(corev1.ServiceTypeNodePort), + WithPort("1337", 1337, 5003, corev1.ProtocolTCP), + ), + }, nil, nil) + Expect(errs).To(BeEmpty()) + + createPayload := tree.ToCreatePayload(nil, "", "") + + Expect(createPayload.Listeners[0].Http.Hosts[0].Host).To(HaveValue(Equal(host))) + + // The following assertions require that target pool are sorted by the ingress UID and path. + Expect(createPayload.Listeners[0].Http.Hosts[0].Rules[0].Path.ExactMatch).To(HaveValue(Equal("/5000"))) + Expect(createPayload.TargetPools[0].Name).To(Equal(createPayload.Listeners[0].Http.Hosts[0].Rules[0].TargetPool)) + Expect(createPayload.TargetPools[0].TargetPort).To(HaveValue(Equal(int32(5000)))) + + Expect(createPayload.Listeners[0].Http.Hosts[0].Rules[1].Path.ExactMatch).To(HaveValue(Equal("/5001"))) + Expect(createPayload.TargetPools[1].Name).To(Equal(createPayload.Listeners[0].Http.Hosts[0].Rules[1].TargetPool)) + Expect(createPayload.TargetPools[1].TargetPort).To(HaveValue(Equal(int32(5001)))) + + Expect(createPayload.Listeners[0].Http.Hosts[0].Rules[2].Path.ExactMatch).To(HaveValue(Equal("/5002"))) + Expect(createPayload.TargetPools[2].Name).To(Equal(createPayload.Listeners[0].Http.Hosts[0].Rules[2].TargetPool)) + Expect(createPayload.TargetPools[2].TargetPort).To(HaveValue(Equal(int32(5002)))) + + Expect(createPayload.Listeners[0].Http.Hosts[0].Rules[3].Path.ExactMatch).To(HaveValue(Equal("/5003"))) + Expect(createPayload.TargetPools[3].Name).To(Equal(createPayload.Listeners[0].Http.Hosts[0].Rules[3].TargetPool)) + Expect(createPayload.TargetPools[3].TargetPort).To(HaveValue(Equal(int32(5003)))) + }) + + It("should return an error when the TLS secret doesn't exist", func() { + _, errs := BuildTree( + &networkingv1.IngressClass{}, + []networkingv1.Ingress{ + Ingress(corev1.NamespaceDefault, "ingress-with-tls-secret-reference", WithTLSSecret("doesnt-exist")), + }, + nil, nil, nil, nil, + ) + + Expect(errs).To(HaveLen(1)) + Expect(errs[0].Description).To(Equal("TLS secret doesn't exist")) + }) + + It("should return an error when the TLS secret isn't of type TLS", func() { + _, errs := BuildTree( + &networkingv1.IngressClass{}, + []networkingv1.Ingress{ + Ingress(corev1.NamespaceDefault, "ingress-with-tls-secret-reference", WithTLSSecret("non-tls")), + }, + []corev1.Secret{ + { + ObjectMeta: metav1.ObjectMeta{Namespace: corev1.NamespaceDefault, Name: "non-tls"}, + Type: corev1.SecretTypeDockerConfigJson, // Not TLS + }, + }, nil, nil, nil, + ) + + Expect(errs).To(HaveLen(1)) + Expect(errs[0].Description).To(Equal("TLS secret isn't of type kubernetes.io/tls")) + }) + + It("should return an error when the TLS secret isn't of type TLS", func() { + _, errs := BuildTree( + &networkingv1.IngressClass{}, + []networkingv1.Ingress{ + Ingress(corev1.NamespaceDefault, "ingress-with-tls-secret-reference", WithTLSSecret("non-tls")), + }, + []corev1.Secret{ + { + ObjectMeta: metav1.ObjectMeta{Namespace: corev1.NamespaceDefault, Name: "non-tls"}, + Type: corev1.SecretTypeDockerConfigJson, // Not TLS + }, + }, nil, nil, nil, + ) + + Expect(errs).To(HaveLen(1)) + Expect(errs[0].Description).To(Equal("TLS secret isn't of type kubernetes.io/tls")) + }) + + It("should return an error when TLS secret parsing fails", func() { + _, errs := BuildTree( + &networkingv1.IngressClass{}, + []networkingv1.Ingress{ + Ingress(corev1.NamespaceDefault, "ingress-with-tls-secret-reference", WithTLSSecret("invalid-tls")), + }, + []corev1.Secret{ + { + ObjectMeta: metav1.ObjectMeta{Namespace: corev1.NamespaceDefault, Name: "invalid-tls"}, + Type: corev1.SecretTypeTLS, + Data: map[string][]byte{ + corev1.TLSCertKey: []byte("invalid cert"), + corev1.TLSPrivateKeyKey: []byte(testdata.FixtureTLS1PrivateKey), + }, + }, + }, nil, nil, nil, + ) + + Expect(errs).To(HaveLen(1)) + Expect(errs[0].Description).To(Equal("invalid certificate: tls: failed to find any PEM data in certificate input")) + }) + + It("should process TLS secret correctly and return it as missing certificate", func() { + tree, errs := BuildTree( + &networkingv1.IngressClass{}, + []networkingv1.Ingress{ + Ingress(corev1.NamespaceDefault, "ingress-with-tls-secret-reference", WithTLSSecret("my-tls")), + }, + []corev1.Secret{ + { + ObjectMeta: metav1.ObjectMeta{Namespace: corev1.NamespaceDefault, Name: "my-tls"}, + Type: corev1.SecretTypeTLS, + Data: map[string][]byte{ + corev1.TLSCertKey: []byte(testdata.FixtureTLS1PublicKey), + corev1.TLSPrivateKeyKey: []byte(testdata.FixtureTLS1PrivateKey), + }, + }, + }, nil, nil, nil, + ) + + Expect(errs).To(BeEmpty()) + Expect(tree.GetMissingCertificates(nil)).To(ConsistOf( + WorkTreeCertificate{ + PublicKey: testdata.FixtureTLS1PublicKey, + PrivateKey: testdata.FixtureTLS1PrivateKey, + }, + )) + }) + + It("should use TLS certificates only on ports that reference it", func() { + tree, errs := BuildTree( + &networkingv1.IngressClass{ + ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{AnnotationHTTPSOnly: "true"}}, + }, + []networkingv1.Ingress{ + Ingress(corev1.NamespaceDefault, "ingress-a", WithTLSSecret("shared-cert"), WithTLSSecret("cert-for-a"), + WithRule("host-a.local", WithPath("/", new(networkingv1.PathTypePrefix), "my-service", networkingv1.ServiceBackendPort{Number: 80})), + ), + Ingress(corev1.NamespaceDefault, "ingress-b", WithTLSSecret("shared-cert"), WithTLSSecret("cert-for-b"), WithAnnotation(AnnotationHTTPSPort, "444"), + WithRule("host-b.local", WithPath("/", new(networkingv1.PathTypePrefix), "my-service", networkingv1.ServiceBackendPort{Number: 80})), + ), + }, + []corev1.Secret{ + { + ObjectMeta: metav1.ObjectMeta{Namespace: corev1.NamespaceDefault, Name: "cert-for-a"}, + Type: corev1.SecretTypeTLS, + Data: map[string][]byte{ + corev1.TLSCertKey: []byte(testdata.FixtureTLS1PublicKey), + corev1.TLSPrivateKeyKey: []byte(testdata.FixtureTLS1PrivateKey), + }, + }, + { + ObjectMeta: metav1.ObjectMeta{Namespace: corev1.NamespaceDefault, Name: "cert-for-b"}, + Type: corev1.SecretTypeTLS, + Data: map[string][]byte{ + corev1.TLSCertKey: []byte(testdata.FixtureTLS2PublicKey), + corev1.TLSPrivateKeyKey: []byte(testdata.FixtureTLS2PrivateKey), + }, + }, + { + ObjectMeta: metav1.ObjectMeta{Namespace: corev1.NamespaceDefault, Name: "shared-cert"}, + Type: corev1.SecretTypeTLS, + Data: map[string][]byte{ + corev1.TLSCertKey: []byte(testdata.FixtureTLS3PublicKey), + corev1.TLSPrivateKeyKey: []byte(testdata.FixtureTLS3PrivateKey), + }, + }, + }, []corev1.Service{ + Service(corev1.NamespaceDefault, "my-service", WithServiceType(corev1.ServiceTypeNodePort), WithPort("my-port", 80, 30000, corev1.ProtocolTCP)), + }, nil, nil, + ) + + Expect(errs).To(BeEmpty()) + create := tree.ToCreatePayload(map[CertificateFingerprint]string{ + testdata.FixtureTLS1FingerprintSHA256: "id-cert-1", + testdata.FixtureTLS2FingerprintSHA256: "id-cert-2", + testdata.FixtureTLS3FingerprintSHA256: "id-cert-3", + }, "my-network", "region") + Expect(create.Listeners).To(HaveLen(2)) + Expect(create.Listeners[0].Port).To(HaveValue(BeEquivalentTo(443))) + Expect(create.Listeners[0].Https.CertificateConfig.CertificateIds).To(ConsistOf( + "id-cert-1", + "id-cert-3", + )) + Expect(create.Listeners[1].Port).To(HaveValue(BeEquivalentTo(444))) + Expect(create.Listeners[1].Https.CertificateConfig.CertificateIds).To(ConsistOf( + "id-cert-2", + "id-cert-3", + )) + }) + + It("should enable websocket if enable on ingress class", func() { + tree, errs := BuildTree( + &networkingv1.IngressClass{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + AnnotationWebSocket: "true", + }, + }, + }, + []networkingv1.Ingress{ + Ingress(corev1.NamespaceDefault, "ingress-1", WithRule("my-host.local", + WithPath("/a", new(networkingv1.PathTypePrefix), "my-service", networkingv1.ServiceBackendPort{Number: 80}), + )), + Ingress(corev1.NamespaceDefault, "ingress-1", WithAnnotation(AnnotationWebSocket, "false"), WithRule("my-host.local", + WithPath("/b", new(networkingv1.PathTypePrefix), "my-service", networkingv1.ServiceBackendPort{Number: 80}), + )), + }, + nil, + []corev1.Service{ + Service(corev1.NamespaceDefault, "my-service", WithServiceType(corev1.ServiceTypeNodePort), WithPort("my-port", 80, 30000, corev1.ProtocolTCP)), + }, nil, nil, + ) + + Expect(errs).To(BeEmpty()) + create := tree.ToCreatePayload(nil, "network-id", "region") + Expect(create.Listeners).To(HaveLen(1)) + Expect(create.Listeners[0].Http.Hosts).To(HaveLen(1)) + Expect(create.Listeners[0].Http.Hosts[0].Rules).To(HaveLen(2)) + Expect(create.Listeners[0].Http.Hosts[0].Rules[0].Path.Prefix).To(HaveValue(Equal("/a"))) + Expect(create.Listeners[0].Http.Hosts[0].Rules[0].WebSocket).To(HaveValue(BeTrue())) + Expect(create.Listeners[0].Http.Hosts[0].Rules[1].Path.Prefix).To(HaveValue(Equal("/b"))) + Expect(create.Listeners[0].Http.Hosts[0].Rules[1].WebSocket).To(Or(BeNil(), HaveValue(BeFalse()))) + }) + + It("should enable websocket if enable on ingress", func() { + tree, errs := BuildTree( + &networkingv1.IngressClass{}, + []networkingv1.Ingress{ + Ingress(corev1.NamespaceDefault, "ingress-1", WithRule("my-host.local", + WithPath("/a", new(networkingv1.PathTypePrefix), "my-service", networkingv1.ServiceBackendPort{Number: 80}), + )), + Ingress(corev1.NamespaceDefault, "ingress-1", WithAnnotation(AnnotationWebSocket, "true"), WithRule("my-host.local", + WithPath("/b", new(networkingv1.PathTypePrefix), "my-service", networkingv1.ServiceBackendPort{Number: 80}), + )), + }, + nil, + []corev1.Service{ + Service(corev1.NamespaceDefault, "my-service", WithServiceType(corev1.ServiceTypeNodePort), WithPort("my-port", 80, 30000, corev1.ProtocolTCP)), + }, nil, nil, + ) + + Expect(errs).To(BeEmpty()) + create := tree.ToCreatePayload(nil, "network-id", "region") + Expect(create.Listeners).To(HaveLen(1)) + Expect(create.Listeners[0].Http.Hosts).To(HaveLen(1)) + Expect(create.Listeners[0].Http.Hosts[0].Rules).To(HaveLen(2)) + Expect(create.Listeners[0].Http.Hosts[0].Rules[0].Path.Prefix).To(HaveValue(Equal("/a"))) + Expect(create.Listeners[0].Http.Hosts[0].Rules[0].WebSocket).To(HaveValue(Or(BeNil(), HaveValue(BeFalse())))) + Expect(create.Listeners[0].Http.Hosts[0].Rules[1].Path.Prefix).To(HaveValue(Equal("/b"))) + Expect(create.Listeners[0].Http.Hosts[0].Rules[1].WebSocket).To(HaveValue(BeTrue())) + }) + + It("should set WAF on all ports if specified on ingress class", func() { + tree, errs := BuildTree( + &networkingv1.IngressClass{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + AnnotationWAFName: "my-waf", + }, + }, + }, + []networkingv1.Ingress{ + Ingress(corev1.NamespaceDefault, "ingress-1", WithRule("my-host.local", + WithPath("/", new(networkingv1.PathTypePrefix), "my-service", networkingv1.ServiceBackendPort{Number: 80}), + )), + Ingress(corev1.NamespaceDefault, "ingress-1", WithAnnotation(AnnotationHTTPPort, "8080"), WithRule("my-host.local", + WithPath("/", new(networkingv1.PathTypePrefix), "my-service", networkingv1.ServiceBackendPort{Number: 80}), + )), + }, + nil, + []corev1.Service{ + Service(corev1.NamespaceDefault, "my-service", WithServiceType(corev1.ServiceTypeNodePort), WithPort("my-port", 80, 30000, corev1.ProtocolTCP)), + }, nil, nil, + ) + + Expect(errs).To(BeEmpty()) + create := tree.ToCreatePayload(nil, "network-id", "region") + Expect(create.Listeners).To(HaveLen(2)) + Expect(create.Listeners[0].WafConfigName).To(HaveValue(Equal("my-waf"))) + Expect(create.Listeners[1].WafConfigName).To(HaveValue(Equal("my-waf"))) + }) + + It("should set allowed source range on all ports if specified on ingress class", func() { + tree, errs := BuildTree( + &networkingv1.IngressClass{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + AnnotationAllowedSourceRanges: "10.0.0.0/24,1.2.3.4/32", + }, + }, + }, nil, nil, nil, nil, nil, + ) + + Expect(errs).To(BeEmpty()) + create := tree.ToCreatePayload(nil, "network-id", "region") + Expect(create.Options.AccessControl.AllowedSourceRanges).To(HaveExactElements("10.0.0.0/24", "1.2.3.4/32")) + }) + + It("should set ALB to internal if annotation is true", func() { + tree, errs := BuildTree( + &networkingv1.IngressClass{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + AnnotationInternal: "true", + }, + }, + }, nil, nil, nil, nil, nil, + ) + + Expect(errs).To(BeEmpty()) + create := tree.ToCreatePayload(nil, "network-id", "region") + Expect(create.Options.PrivateNetworkOnly).To(HaveValue(BeTrue())) + }) + + It("should set ALB to static if annotation contains IP", func() { + tree, errs := BuildTree( + &networkingv1.IngressClass{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + AnnotationExternalIP: "1.2.3.4", + }, + }, + }, nil, nil, nil, nil, nil, + ) + + Expect(errs).To(BeEmpty()) + create := tree.ToCreatePayload(nil, "network-id", "region") + Expect(create.ExternalAddress).To(HaveValue(Equal("1.2.3.4"))) + Expect(create.Options.EphemeralAddress).To(HaveValue(BeFalse())) + }) + + It("should return errors for paths that exceed the target pool limit", func() { + ingresses := []networkingv1.Ingress{} + for i := range 8 { // 8 * 3 paths = 24 + ingresses = append(ingresses, Ingress(corev1.NamespaceDefault, fmt.Sprintf("ingress-%d", i), WithAnnotation(AnnotationPriority, fmt.Sprintf("%d", i)), + WithRule("my-host.local", + WithPath(fmt.Sprintf("/%d", i*3), new(networkingv1.PathTypeExact), "my-service", networkingv1.ServiceBackendPort{Number: 80}), + WithPath(fmt.Sprintf("/%d", i*3+1), new(networkingv1.PathTypeExact), "my-service", networkingv1.ServiceBackendPort{Number: 80}), + WithPath(fmt.Sprintf("/%d", i*3+2), new(networkingv1.PathTypeExact), "my-service", networkingv1.ServiceBackendPort{Number: 80}), + ))) + } + _, errs := BuildTree( + &networkingv1.IngressClass{}, ingresses, nil, []corev1.Service{ + Service(corev1.NamespaceDefault, "my-service", WithServiceType(corev1.ServiceTypeNodePort), WithPort("my-port", 80, 30000, corev1.ProtocolTCP)), + }, nil, nil, + ) + + Expect(errs).To(ConsistOf( + MatchAllFields(Fields{ + "Ingress": testutil.HaveName("ingress-0"), + "Description": Equal("Target pool limit reached. Path will be ignored."), + "FieldPath": Equal(field.NewPath("spec", "rules").Index(0).Child("paths").Index(0)), + }), + MatchAllFields(Fields{ + "Ingress": testutil.HaveName("ingress-0"), + "Description": Equal("Target pool limit reached. Path will be ignored."), + "FieldPath": Equal(field.NewPath("spec", "rules").Index(0).Child("paths").Index(1)), + }), + MatchAllFields(Fields{ + "Ingress": testutil.HaveName("ingress-0"), + "Description": Equal("Target pool limit reached. Path will be ignored."), + "FieldPath": Equal(field.NewPath("spec", "rules").Index(0).Child("paths").Index(2)), + }), + MatchAllFields(Fields{ + "Ingress": testutil.HaveName("ingress-1"), + "Description": Equal("Target pool limit reached. Path will be ignored."), + "FieldPath": Equal(field.NewPath("spec", "rules").Index(0).Child("paths").Index(2)), + }), + )) + }) + + It("should set target pool TLS settings", func() { + tree, errs := BuildTree( + &networkingv1.IngressClass{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + AnnotationTargetPoolTLSEnabled: "true", + }, + }, + }, + []networkingv1.Ingress{ + Ingress(corev1.NamespaceDefault, "ingress-1", WithUID("uid-1"), WithRule("my-host.local", + WithPath("/inherit", new(networkingv1.PathTypePrefix), "my-service", networkingv1.ServiceBackendPort{Number: 80}), + WithPath("/overwrite-disable-on-service", new(networkingv1.PathTypePrefix), "service-with-tls-disabled", networkingv1.ServiceBackendPort{Number: 80}), + )), + Ingress(corev1.NamespaceDefault, "ingress-2", WithUID("uid-2"), WithAnnotation(AnnotationTargetPoolTLSEnabled, "false"), WithRule("my-host.local", + WithPath("/overwrite-disable-on-ingress", new(networkingv1.PathTypePrefix), "my-service", networkingv1.ServiceBackendPort{Number: 80}), + )), + Ingress(corev1.NamespaceDefault, "ingress-3", WithUID("uid-3"), WithAnnotation(AnnotationTargetPoolTLSCustomCa, "custom-ca"), WithRule("my-host.local", + WithPath("/custom-ca", new(networkingv1.PathTypePrefix), "my-service", networkingv1.ServiceBackendPort{Number: 80}), + )), + Ingress(corev1.NamespaceDefault, "ingress-4", WithUID("uid-4"), WithAnnotation(AnnotationTargetPoolTLSSkipCertificateValidation, "true"), WithRule("my-host.local", + WithPath("/skip-validation", new(networkingv1.PathTypePrefix), "my-service", networkingv1.ServiceBackendPort{Number: 80}), + )), + }, + nil, + []corev1.Service{ + Service(corev1.NamespaceDefault, "my-service", WithServiceType(corev1.ServiceTypeNodePort), WithPort("my-port", 80, 30000, corev1.ProtocolTCP)), + Service(corev1.NamespaceDefault, "service-with-tls-disabled", WithServiceAnnotation(AnnotationTargetPoolTLSEnabled, "false"), WithServiceType(corev1.ServiceTypeNodePort), WithPort("my-port", 80, 30001, corev1.ProtocolTCP)), + }, nil, nil, + ) + + Expect(errs).To(BeEmpty()) + create := tree.ToCreatePayload(nil, "network-id", "region") + Expect(create.TargetPools).To(HaveLen(5)) + Expect(create.TargetPools[0].TlsConfig.Enabled).To(HaveValue(BeTrue())) + Expect(create.TargetPools[1].TlsConfig.Enabled).To(HaveValue(BeFalse())) + Expect(create.TargetPools[2].TlsConfig.Enabled).To(HaveValue(BeFalse())) + Expect(create.TargetPools[3].TlsConfig.CustomCa).To(HaveValue(Equal("custom-ca"))) + Expect(create.TargetPools[4].TlsConfig.SkipCertificateValidation).To(HaveValue(BeTrue())) + }) + + It("should use the log configuration from the existing load balancer", func() { + tree, errs := BuildTree( + &networkingv1.IngressClass{}, nil, nil, nil, nil, &v2api.LoadBalancer{ + Options: &v2api.LoadBalancerOptions{ + Observability: &v2api.LoadbalancerOptionObservability{ + Logs: &v2api.LoadbalancerOptionLogs{ + CredentialsRef: new("my-creds"), + PushUrl: new("my-push-url"), + }, + }, + }, + }, + ) + + Expect(errs).To(BeEmpty()) + update := tree.ToUpdatePayload(nil, "network-id", "region") + Expect(update.Options.Observability.Logs.CredentialsRef).To(HaveValue(Equal("my-creds"))) + Expect(update.Options.Observability.Logs.PushUrl).To(HaveValue(Equal("my-push-url"))) + }) + + It("should use the version from the existing load balancer in update payload", func() { + tree, errs := BuildTree( + &networkingv1.IngressClass{}, nil, nil, nil, nil, &v2api.LoadBalancer{ + Version: new("current-version"), + }, + ) + + Expect(errs).To(BeEmpty()) + update := tree.ToUpdatePayload(nil, "network-id", "region") + Expect(update.Version).To(HaveValue(Equal("current-version"))) + }) + + It("should turn implementation-specific paths into exact matchers", func() { + tree, errs := BuildTree( + &networkingv1.IngressClass{}, + []networkingv1.Ingress{ + Ingress(corev1.NamespaceDefault, "ingress-1", WithRule("my-host.local", + WithPath("/a", new(networkingv1.PathTypeImplementationSpecific), "my-service", networkingv1.ServiceBackendPort{Number: 80}), + )), + }, + nil, + []corev1.Service{ + Service(corev1.NamespaceDefault, "my-service", WithServiceType(corev1.ServiceTypeNodePort), WithPort("my-port", 80, 30000, corev1.ProtocolTCP)), + }, nil, nil, + ) + + Expect(errs).To(BeEmpty()) + create := tree.ToCreatePayload(nil, "network-id", "region") + Expect(create.Listeners[0].Http.Hosts[0].Rules[0].Path.ExactMatch).To(HaveValue(Equal("/a"))) + }) + + It("should return an error on duplicate paths", func() { + _, errs := BuildTree( + &networkingv1.IngressClass{}, + []networkingv1.Ingress{ + Ingress(corev1.NamespaceDefault, "ingress-1", WithRule("my-host.local", + WithPath("/", new(networkingv1.PathTypePrefix), "my-service", networkingv1.ServiceBackendPort{Number: 80}), + )), + Ingress(corev1.NamespaceDefault, "ingress-2", WithAnnotation(AnnotationPriority, "10"), WithRule("my-host.local", + WithPath("/", new(networkingv1.PathTypePrefix), "my-service", networkingv1.ServiceBackendPort{Number: 80}), + )), + }, + nil, + []corev1.Service{ + Service(corev1.NamespaceDefault, "my-service", WithServiceType(corev1.ServiceTypeNodePort), WithPort("my-port", 80, 30000, corev1.ProtocolTCP)), + }, nil, nil, + ) + + Expect(errs).To(ConsistOf( + MatchAllFields(Fields{ + "Ingress": testutil.HaveName("ingress-1"), + "Description": Equal("Path already exists"), + "FieldPath": Equal(field.NewPath("spec", "rules").Index(0).Child("paths").Index(0)), + }), + )) + }) +}) diff --git a/pkg/controller/ingress/suite_test.go b/pkg/controller/ingress/suite_test.go new file mode 100644 index 0000000..1ec0bae --- /dev/null +++ b/pkg/controller/ingress/suite_test.go @@ -0,0 +1,51 @@ +package ingress_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" +) + +var ( + testEnv *envtest.Environment + cfg *rest.Config + k8sClient client.Client +) + +func TestControllers(t *testing.T) { + RegisterFailHandler(Fail) + + RunSpecs(t, "Controller Suite") +} + +var _ = BeforeSuite(func() { + logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) + + var err error + + By("bootstrapping test environment") + testEnv = &envtest.Environment{} + + // cfg is defined in this file globally. + cfg, err = testEnv.Start() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg).NotTo(BeNil()) + + k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) + Expect(err).NotTo(HaveOccurred()) + Expect(k8sClient).NotTo(BeNil()) +}) + +var _ = AfterSuite(func() { + By("tearing down the test environment") + err := testEnv.Stop() + Expect(err).NotTo(HaveOccurred()) +}) diff --git a/pkg/controller/ingress/update.go b/pkg/controller/ingress/update.go new file mode 100644 index 0000000..8c5f5ee --- /dev/null +++ b/pkg/controller/ingress/update.go @@ -0,0 +1,336 @@ +package ingress + +import ( + "context" + "errors" + "fmt" + "slices" + + "github.com/stackitcloud/application-load-balancer-controller/pkg/controller/ingress/spec" + "github.com/stackitcloud/application-load-balancer-controller/pkg/stackit" + albsdk "github.com/stackitcloud/stackit-sdk-go/services/alb/v2api" + certsdk "github.com/stackitcloud/stackit-sdk-go/services/certificates/v2api" + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func (r *IngressClassReconciler) reconcileALBResources(ctx context.Context, ingressClass *networkingv1.IngressClass) error { //nolint:gocyclo,funlen // Breaking up this function won't make it much simpler. + ingresses, err := r.getIngressesForIngressClass(ctx, ingressClass) + if err != nil { + return fmt.Errorf("failed to get ingresses for class: %w", err) + } + + secrets, err := r.getTLSSecretsFromIngresses(ctx, ingresses) + if err != nil { + return fmt.Errorf("failed to get secrets for ingresses: %w", err) + } + + services, err := r.getServicesForIngresses(ctx, ingresses) + if err != nil { + return fmt.Errorf("failed to get services for ingresses: %w", err) + } + + nodes := corev1.NodeList{} + if err := r.Client.List(ctx, &nodes); err != nil { + return fmt.Errorf("failed to get nodes: %w", err) + } + + existingALB, err := r.ALBClient.GetLoadBalancer(ctx, r.ALBConfig.Global.ProjectID, r.ALBConfig.Global.Region, spec.LoadBalancerName(ingressClass)) + if err != nil && !errors.Is(err, stackit.ErrorNotFound) { + return fmt.Errorf("failed to get load balancer: %w", err) + } + if errors.Is(err, stackit.ErrorNotFound) { + existingALB = nil + } + + tree, errs := spec.BuildTree( + ingressClass, + ingresses, + secrets, + services, + nodes.Items, + existingALB, + ) + + for _, err := range errs { + err.RecordEvent(ingressClass, r.Recorder) + } + + // ingressClassCertificates contains all certificates that belong to the reconciled ingress class. + // Certificates that are created in this function are to be added to this slice. + ingressClassCertificates, err := r.getCertificatesForIngressClass(ctx, ingressClass) + if err != nil { + return err + } + + missingCertificates := tree.GetMissingCertificates(ingressClassCertificates) + for fingerprint, c := range missingCertificates { + createCertificatePayload := &certsdk.CreateCertificatePayload{ + Name: new("k8s-ingress-" + string(ingressClass.UID)), + ProjectId: &r.ALBConfig.Global.ProjectID, + PrivateKey: new(c.PrivateKey), + PublicKey: new(c.PublicKey), + Labels: &map[string]string{ + spec.LabelIngressClassUID: string(ingressClass.UID), + }, + } + response, err := r.CertificateClient.CreateCertificate(ctx, r.ALBConfig.Global.ProjectID, r.ALBConfig.Global.Region, createCertificatePayload) + if err != nil { + // TODO: Gracefully deal with errors + return fmt.Errorf("failed to create certificate: %w", err) + } + ctrl.LoggerFrom(ctx).Info("Created certificate", "id", response.Id, "fingerprint", fingerprint) + ingressClassCertificates = append(ingressClassCertificates, *response) + } + + certIDMap := map[spec.CertificateFingerprint]string{} + // duplicateCerts contains all certificates that are duplicates of others (in certIDMap) by fingerprint. + // Because they might still be used by the ALB they must only be removed after the ALB was updated. + // Which certificate is a duplicate and which is "original" depends on the order in ingressClassCertificates. + duplicateCerts := []string{} + for _, cert := range ingressClassCertificates { + if id, exists := certIDMap[spec.CertificateFingerprint(*cert.Data.FingerprintSha256)]; exists { + duplicateCerts = append(duplicateCerts, id) + continue + } + certIDMap[spec.CertificateFingerprint(*cert.Data.FingerprintSha256)] = *cert.Id + } + + if existingALB == nil { + create := tree.ToCreatePayload(certIDMap, r.ALBConfig.ApplicationLoadBalancer.NetworkID, r.ALBConfig.Global.Region) + alb, err := r.ALBClient.CreateLoadBalancer(ctx, r.ALBConfig.Global.ProjectID, r.ALBConfig.Global.Region, create) + if err != nil { + return fmt.Errorf("failed to create load balancer: %w", err) + } + ctrl.LoggerFrom(ctx).Info("Created application load balancer", "name", create.Name, "version", *alb.Version) + } else { + update := tree.ToUpdatePayload(certIDMap, r.ALBConfig.ApplicationLoadBalancer.NetworkID, r.ALBConfig.Global.Region) + if updateNeeded(existingALB, update) { + alb, err := r.ALBClient.UpdateLoadBalancer(ctx, r.ALBConfig.Global.ProjectID, r.ALBConfig.Global.Region, *update.Name, update) + if err != nil { + return fmt.Errorf("failed to update load balancer: %w", err) + } + ctrl.LoggerFrom(ctx).Info("Updated application load balancer", "name", update.Name, "version", *alb.Version) + } + } + + for _, cert := range duplicateCerts { + if err := r.CertificateClient.DeleteCertificate(ctx, r.ALBConfig.Global.ProjectID, r.ALBConfig.Global.Region, cert); err != nil { + // TODO: fail gracefully + return fmt.Errorf("failed to delete duplicate certificate %q: %w", cert, err) + } + ctrl.LoggerFrom(ctx).Info("Deleted duplicate certificate", "id", cert) + } + + unused := tree.GetUnusedCertificates(certIDMap) + for fingerprint, id := range unused { + if err := r.CertificateClient.DeleteCertificate(ctx, r.ALBConfig.Global.ProjectID, r.ALBConfig.Global.Region, id); err != nil { + // TODO: fail gracefully + return fmt.Errorf("failed to delete unused certificate %q: %w", id, err) + } + ctrl.LoggerFrom(ctx).Info("Deleted unused certificate", "id", id, "fingerprint", fingerprint) + } + + return nil +} + +// getServicesForIngresses returns all services that are referenced anywhere in any of the ingresses. +// It ignores services that are not found. +// TODO: Support resource backends (that reference services). +func (r *IngressClassReconciler) getServicesForIngresses(ctx context.Context, ingresses []networkingv1.Ingress) ([]corev1.Service, error) { + // TODO: This and the next function can be generalized with a NamespacedReferenceList function. Possibly with a callback function for the indexes. Should return a map indexed with types.NamespacedName. + services := []corev1.Service{} + for i := range ingresses { + ingress := ingresses[i] + for ruleIndex, rule := range ingress.Spec.Rules { + for pathIndex, path := range rule.HTTP.Paths { + if path.Backend.Service.Name == "" { + continue + } + service := corev1.Service{} + err := r.Client.Get(ctx, types.NamespacedName{Namespace: ingress.Namespace, Name: path.Backend.Service.Name}, &service) + if client.IgnoreNotFound(err) != nil { + return nil, fmt.Errorf( + "failed to get service %s referenced in ingress %s at rule %d and path %d (zero-indexed): %w", + types.NamespacedName{Namespace: ingress.Namespace, Name: path.Backend.Service.Name}, + client.ObjectKeyFromObject(&ingress), + ruleIndex, pathIndex, err, + ) + } + if !apierrors.IsNotFound(err) { + services = append(services, service) + } + } + } + if ingress.Spec.DefaultBackend == nil || ingress.Spec.DefaultBackend.Service == nil || ingress.Spec.DefaultBackend.Service.Name == "" { + continue + } + service := corev1.Service{} + err := r.Client.Get(ctx, types.NamespacedName{Namespace: ingress.Namespace, Name: ingress.Spec.DefaultBackend.Service.Name}, &service) + if client.IgnoreNotFound(err) != nil { + return nil, fmt.Errorf( + "failed to get service %s referenced in the default backend of ingress %s: %w", + types.NamespacedName{Namespace: ingress.Namespace, Name: ingress.Spec.DefaultBackend.Service.Name}, + client.ObjectKeyFromObject(&ingress), + err, + ) + } + if !apierrors.IsNotFound(err) { + services = append(services, service) + } + } + return services, nil +} + +func (r *IngressClassReconciler) getTLSSecretsFromIngresses(ctx context.Context, ingresses []networkingv1.Ingress) ([]corev1.Secret, error) { + secrets := []corev1.Secret{} + for i := range ingresses { + ingress := ingresses[i] + for tlsIndex, tls := range ingress.Spec.TLS { + secret := corev1.Secret{} + err := r.Client.Get(ctx, types.NamespacedName{Namespace: ingress.Namespace, Name: tls.SecretName}, &secret) + if client.IgnoreNotFound(err) != nil { + return nil, fmt.Errorf( + "failed to get secret %s referenced in the ingress %s at position %d: %w", + types.NamespacedName{Namespace: ingress.Namespace, Name: tls.SecretName}, + client.ObjectKeyFromObject(&ingress), + tlsIndex, err, + ) + } + if !apierrors.IsNotFound(err) { + secrets = append(secrets, secret) + } + } + } + return secrets, nil +} + +// getCertificatesForIngressClass returns all certificates matching the ingress class via label. +func (r *IngressClassReconciler) getCertificatesForIngressClass(ctx context.Context, ingressClass *networkingv1.IngressClass) ([]certsdk.GetCertificateResponse, error) { + // TODO: deal with paging + projectCertificates, err := r.CertificateClient.ListCertificate(ctx, r.ALBConfig.Global.ProjectID, r.ALBConfig.Global.Region) + if err != nil { + return nil, fmt.Errorf("failed to list certificates: %w", err) + } + + ingressClassCertificates := []certsdk.GetCertificateResponse{} + for _, cert := range projectCertificates.Items { + if cert.Labels != nil && (*cert.Labels)[spec.LabelIngressClassUID] == string(ingressClass.UID) { + ingressClassCertificates = append(ingressClassCertificates, cert) + } + } + + return ingressClassCertificates, nil +} + +func updateNeeded(alb *albsdk.LoadBalancer, albPayload *albsdk.UpdateLoadBalancerPayload) bool { + return listenersChanged(alb.Listeners, albPayload.Listeners) || targetPoolsChanged(alb.TargetPools, albPayload.TargetPools) || optionsChanged(alb.Options, albPayload.Options) +} + +func listenersChanged(current, desired []albsdk.Listener) bool { + if len(current) != len(desired) { + return true + } + for i := range current { + c, d := current[i], desired[i] + + if ptr.Deref(c.Protocol, "") != ptr.Deref(d.Protocol, "") || + ptr.Deref(c.Port, 0) != ptr.Deref(d.Port, 0) || + ptr.Deref(c.WafConfigName, "") != ptr.Deref(d.WafConfigName, "") { + return true + } + + if httpOptionsChanged(c.Http, d.Http) || httpsOptionsChanged(c.Https, d.Https) { + return true + } + } + return false +} + +func httpOptionsChanged(c, d *albsdk.ProtocolOptionsHTTP) bool { + if c == nil && d == nil { + return false + } + if c == nil || d == nil || len(c.Hosts) != len(d.Hosts) { + return true + } + + for i := range c.Hosts { + ch, dh := c.Hosts[i], d.Hosts[i] + if ptr.Deref(ch.Host, "") != ptr.Deref(dh.Host, "") || len(ch.Rules) != len(dh.Rules) { + return true + } + + for j := range ch.Rules { + cr, dr := ch.Rules[j], dh.Rules[j] + if pathChanged(cr.Path, dr.Path) { + return true + } + if ptr.Deref(cr.WebSocket, false) != ptr.Deref(dr.WebSocket, false) || + ptr.Deref(cr.TargetPool, "") != ptr.Deref(dr.TargetPool, "") { + return true + } + } + } + return false +} + +func pathChanged(c, d *albsdk.Path) bool { + if c == nil && d == nil { + return false + } + if c == nil || d == nil { + return true + } + return ptr.Deref(c.Prefix, "") != ptr.Deref(d.Prefix, "") || ptr.Deref(c.ExactMatch, "") != ptr.Deref(d.ExactMatch, "") +} + +func httpsOptionsChanged(c, d *albsdk.ProtocolOptionsHTTPS) bool { + if c == nil && d == nil { + return false + } + if c == nil || d == nil { + return true + } + return len(c.CertificateConfig.CertificateIds) != len(d.CertificateConfig.CertificateIds) +} + +func targetPoolsChanged(current, desired []albsdk.TargetPool) bool { + if len(current) != len(desired) { + return true + } + for i := range current { + c, d := current[i], desired[i] + + if ptr.Deref(c.Name, "") != ptr.Deref(d.Name, "") || + ptr.Deref(c.TargetPort, 0) != ptr.Deref(d.TargetPort, 0) || + len(c.Targets) != len(d.Targets) { + return true + } + + if (c.TlsConfig == nil) != (d.TlsConfig == nil) { + return true + } + if c.TlsConfig != nil && d.TlsConfig != nil { + if ptr.Deref(c.TlsConfig.SkipCertificateValidation, false) != ptr.Deref(d.TlsConfig.SkipCertificateValidation, false) || + ptr.Deref(c.TlsConfig.CustomCa, "") != ptr.Deref(d.TlsConfig.CustomCa, "") { + return true + } + } + } + return false +} + +func optionsChanged(current, desired *albsdk.LoadBalancerOptions) bool { + a := ptr.Deref(ptr.Deref(current, albsdk.LoadBalancerOptions{}).AccessControl, albsdk.LoadbalancerOptionAccessControl{}) + b := ptr.Deref(ptr.Deref(desired, albsdk.LoadBalancerOptions{}).AccessControl, albsdk.LoadbalancerOptionAccessControl{}) + if a.AllowedSourceRanges == nil || b.AllowedSourceRanges == nil { + return a.AllowedSourceRanges != nil || b.AllowedSourceRanges != nil + } + return !slices.Equal(a.AllowedSourceRanges, b.AllowedSourceRanges) +} diff --git a/pkg/controller/ingress/update_test.go b/pkg/controller/ingress/update_test.go new file mode 100644 index 0000000..3478c80 --- /dev/null +++ b/pkg/controller/ingress/update_test.go @@ -0,0 +1,302 @@ +package ingress + +import ( + "testing" + + albsdk "github.com/stackitcloud/stackit-sdk-go/services/alb/v2api" + "k8s.io/utils/ptr" +) + +func Test_updateNeeded(t *testing.T) { + tests := []struct { + name string + current *albsdk.LoadBalancer + desired *albsdk.UpdateLoadBalancerPayload + expected bool + }{ + { + name: "no changes", + current: &albsdk.LoadBalancer{ + Listeners: []albsdk.Listener{ + {Port: ptr.To[int32](80)}, + }, + }, + desired: &albsdk.UpdateLoadBalancerPayload{ + Listeners: []albsdk.Listener{ + {Port: ptr.To[int32](80)}, + }, + }, + expected: false, + }, + { + name: "port changed", + current: &albsdk.LoadBalancer{ + Listeners: []albsdk.Listener{ + {Port: ptr.To[int32](80)}, + }, + }, + desired: &albsdk.UpdateLoadBalancerPayload{ + Listeners: []albsdk.Listener{ + {Port: ptr.To[int32](443)}, + }, + }, + expected: true, + }, + { + name: "waf config changed", + current: &albsdk.LoadBalancer{ + Listeners: []albsdk.Listener{ + {WafConfigName: new("waf-1")}, + }, + }, + desired: &albsdk.UpdateLoadBalancerPayload{ + Listeners: []albsdk.Listener{ + {WafConfigName: new("waf-2")}, + }, + }, + expected: true, + }, + { + name: "path prefix changed", + current: &albsdk.LoadBalancer{ + Listeners: []albsdk.Listener{ + { + Http: &albsdk.ProtocolOptionsHTTP{ + Hosts: []albsdk.HostConfig{ + { + Rules: []albsdk.Rule{ + {Path: &albsdk.Path{Prefix: new("/api")}}, + }, + }, + }, + }, + }, + }, + }, + desired: &albsdk.UpdateLoadBalancerPayload{ + Listeners: []albsdk.Listener{ + { + Http: &albsdk.ProtocolOptionsHTTP{ + Hosts: []albsdk.HostConfig{ + { + Rules: []albsdk.Rule{ + {Path: &albsdk.Path{Prefix: new("/v2")}}, + }, + }, + }, + }, + }, + }, + }, + expected: true, + }, + { + name: "path exact match changed", + current: &albsdk.LoadBalancer{ + Listeners: []albsdk.Listener{ + { + Http: &albsdk.ProtocolOptionsHTTP{ + Hosts: []albsdk.HostConfig{ + { + Rules: []albsdk.Rule{ + {Path: &albsdk.Path{ExactMatch: new("/api")}}, + }, + }, + }, + }, + }, + }, + }, + desired: &albsdk.UpdateLoadBalancerPayload{ + Listeners: []albsdk.Listener{ + { + Http: &albsdk.ProtocolOptionsHTTP{ + Hosts: []albsdk.HostConfig{ + { + Rules: []albsdk.Rule{ + {Path: &albsdk.Path{ExactMatch: new("/v2")}}, + }, + }, + }, + }, + }, + }, + }, + expected: true, + }, + { + name: "websocket changed", + current: &albsdk.LoadBalancer{ + Listeners: []albsdk.Listener{ + { + Http: &albsdk.ProtocolOptionsHTTP{ + Hosts: []albsdk.HostConfig{ + { + Rules: []albsdk.Rule{ + {WebSocket: new(false)}, + }, + }, + }, + }, + }, + }, + }, + desired: &albsdk.UpdateLoadBalancerPayload{ + Listeners: []albsdk.Listener{ + { + Http: &albsdk.ProtocolOptionsHTTP{ + Hosts: []albsdk.HostConfig{ + { + Rules: []albsdk.Rule{ + {WebSocket: new(true)}, + }, + }, + }, + }, + }, + }, + }, + expected: true, + }, + { + name: "https certificates changed", + current: &albsdk.LoadBalancer{ + Listeners: []albsdk.Listener{ + { + Https: &albsdk.ProtocolOptionsHTTPS{ + CertificateConfig: &albsdk.CertificateConfig{ + CertificateIds: []string{"cert1"}, + }, + }, + }, + }, + }, + desired: &albsdk.UpdateLoadBalancerPayload{ + Listeners: []albsdk.Listener{ + { + Https: &albsdk.ProtocolOptionsHTTPS{ + CertificateConfig: &albsdk.CertificateConfig{ + CertificateIds: []string{"cert1", "cert2"}, + }, + }, + }, + }, + }, + expected: true, + }, + { + name: "target pool port changed", + current: &albsdk.LoadBalancer{ + TargetPools: []albsdk.TargetPool{ + {TargetPort: ptr.To[int32](80)}, + }, + }, + desired: &albsdk.UpdateLoadBalancerPayload{ + TargetPools: []albsdk.TargetPool{ + {TargetPort: ptr.To[int32](443)}, + }, + }, + expected: true, + }, + { + name: "target pool tls validation changed", + current: &albsdk.LoadBalancer{ + TargetPools: []albsdk.TargetPool{ + { + TlsConfig: &albsdk.TlsConfig{ + SkipCertificateValidation: new(false), + }, + }, + }, + }, + desired: &albsdk.UpdateLoadBalancerPayload{ + TargetPools: []albsdk.TargetPool{ + { + TlsConfig: &albsdk.TlsConfig{ + SkipCertificateValidation: new(true), + }, + }, + }, + }, + expected: true, + }, + { + name: "ACL added", + current: &albsdk.LoadBalancer{ + Options: &albsdk.LoadBalancerOptions{ + AccessControl: nil, + }, + }, + desired: &albsdk.UpdateLoadBalancerPayload{ + Options: &albsdk.LoadBalancerOptions{ + AccessControl: &albsdk.LoadbalancerOptionAccessControl{AllowedSourceRanges: []string{"1.2.3.4/32"}}, + }, + }, + expected: true, + }, + { + name: "ACL removed", + current: &albsdk.LoadBalancer{ + Options: &albsdk.LoadBalancerOptions{ + AccessControl: &albsdk.LoadbalancerOptionAccessControl{AllowedSourceRanges: []string{"1.2.3.4/32"}}, + }, + }, + desired: &albsdk.UpdateLoadBalancerPayload{ + Options: &albsdk.LoadBalancerOptions{ + AccessControl: nil, + }, + }, + expected: true, + }, + { + name: "ACL changed", + current: &albsdk.LoadBalancer{ + Options: &albsdk.LoadBalancerOptions{ + AccessControl: &albsdk.LoadbalancerOptionAccessControl{AllowedSourceRanges: []string{"1.2.3.4/32"}}, + }, + }, + desired: &albsdk.UpdateLoadBalancerPayload{ + Options: &albsdk.LoadBalancerOptions{ + AccessControl: &albsdk.LoadbalancerOptionAccessControl{AllowedSourceRanges: []string{"2.3.4.5/32"}}, + }, + }, + expected: true, + }, + { + name: "ACL unchanged", + current: &albsdk.LoadBalancer{ + Options: &albsdk.LoadBalancerOptions{ + AccessControl: &albsdk.LoadbalancerOptionAccessControl{AllowedSourceRanges: []string{"1.2.3.4/32"}}, + }, + }, + desired: &albsdk.UpdateLoadBalancerPayload{ + Options: &albsdk.LoadBalancerOptions{ + AccessControl: &albsdk.LoadbalancerOptionAccessControl{AllowedSourceRanges: []string{"1.2.3.4/32"}}, + }, + }, + expected: false, + }, + { + name: "ACL none", + current: &albsdk.LoadBalancer{ + Options: &albsdk.LoadBalancerOptions{ + AccessControl: nil, + }, + }, + desired: &albsdk.UpdateLoadBalancerPayload{ + Options: &albsdk.LoadBalancerOptions{ + AccessControl: nil, + }, + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := updateNeeded(tt.current, tt.desired); got != tt.expected { + t.Errorf("updateNeeded() = %v, want %v", got, tt.expected) + } + }) + } +} diff --git a/pkg/stackit/applicationloadbalancer.go b/pkg/stackit/applicationloadbalancer.go new file mode 100644 index 0000000..3e10f50 --- /dev/null +++ b/pkg/stackit/applicationloadbalancer.go @@ -0,0 +1,112 @@ +package stackit + +import ( + "context" + + "github.com/google/uuid" + + albsdk "github.com/stackitcloud/stackit-sdk-go/services/alb/v2api" +) + +type ProjectStatus string + +const ( + LBStatusReady = "STATUS_READY" + LBStatusTerminating = "STATUS_TERMINATING" + LBStatusError = "STATUS_ERROR" + + ProtocolHTTP = "PROTOCOL_HTTP" + ProtocolHTTPS = "PROTOCOL_HTTPS" + + ProjectStatusDisabled ProjectStatus = "STATUS_DISABLED" +) + +type ApplicationLoadBalancerClient interface { + GetLoadBalancer(ctx context.Context, projectID, region, name string) (*albsdk.LoadBalancer, error) + DeleteLoadBalancer(ctx context.Context, projectID, region, name string) error + CreateLoadBalancer(ctx context.Context, projectID, region string, albsdk *albsdk.CreateLoadBalancerPayload) (*albsdk.LoadBalancer, error) + UpdateLoadBalancer(ctx context.Context, projectID, region, name string, update *albsdk.UpdateLoadBalancerPayload) (*albsdk.LoadBalancer, error) + UpdateTargetPool(ctx context.Context, projectID, region, name string, targetPoolName string, payload albsdk.UpdateTargetPoolPayload) error + CreateCredentials(ctx context.Context, projectID, region string, payload albsdk.CreateCredentialsPayload) (*albsdk.CreateCredentialsResponse, error) + ListCredentials(ctx context.Context, projectID, region string) (*albsdk.ListCredentialsResponse, error) + GetCredentials(ctx context.Context, projectID, region, credentialRef string) (*albsdk.GetCredentialsResponse, error) + UpdateCredentials(ctx context.Context, projectID, region, credentialRef string, payload albsdk.UpdateCredentialsPayload) error + DeleteCredentials(ctx context.Context, projectID, region, credentialRef string) error +} + +type applicationLoadBalancerClient struct { + client *albsdk.APIClient +} + +var _ ApplicationLoadBalancerClient = (*applicationLoadBalancerClient)(nil) + +func NewApplicationLoadBalancerClient(cl *albsdk.APIClient) (ApplicationLoadBalancerClient, error) { + return &applicationLoadBalancerClient{client: cl}, nil +} + +func (cl applicationLoadBalancerClient) GetLoadBalancer(ctx context.Context, projectID, region, name string) (*albsdk.LoadBalancer, error) { + lb, err := cl.client.DefaultAPI.GetLoadBalancer(ctx, projectID, region, name).Execute() + if isOpenAPINotFound(err) { + return lb, ErrorNotFound + } + return lb, err +} + +// DeleteLoadBalancer returns no error if the load balancer doesn't exist. +func (cl applicationLoadBalancerClient) DeleteLoadBalancer(ctx context.Context, projectID, region, name string) error { + _, err := cl.client.DefaultAPI.DeleteLoadBalancer(ctx, projectID, region, name).Execute() + return err +} + +// CreateLoadBalancer returns ErrorNotFound if the project is not enabled. +func (cl applicationLoadBalancerClient) CreateLoadBalancer(ctx context.Context, projectID, region string, create *albsdk.CreateLoadBalancerPayload) (*albsdk.LoadBalancer, error) { + lb, err := cl.client.DefaultAPI.CreateLoadBalancer(ctx, projectID, region).CreateLoadBalancerPayload(*create).XRequestID(uuid.NewString()).Execute() + if isOpenAPINotFound(err) { + return lb, ErrorNotFound + } + return lb, err +} + +func (cl applicationLoadBalancerClient) UpdateLoadBalancer(ctx context.Context, projectID, region, name string, update *albsdk.UpdateLoadBalancerPayload) ( + *albsdk.LoadBalancer, error, +) { + return cl.client.DefaultAPI.UpdateLoadBalancer(ctx, projectID, region, name).UpdateLoadBalancerPayload(*update).Execute() +} + +func (cl applicationLoadBalancerClient) UpdateTargetPool(ctx context.Context, projectID, region, name, targetPoolName string, payload albsdk.UpdateTargetPoolPayload) error { + _, err := cl.client.DefaultAPI.UpdateTargetPool(ctx, projectID, region, name, targetPoolName).UpdateTargetPoolPayload(payload).Execute() + return err +} + +func (cl applicationLoadBalancerClient) CreateCredentials( + ctx context.Context, + projectID string, + region string, + payload albsdk.CreateCredentialsPayload, +) (*albsdk.CreateCredentialsResponse, error) { + return cl.client.DefaultAPI.CreateCredentials(ctx, projectID, region).CreateCredentialsPayload(payload).XRequestID(uuid.NewString()).Execute() +} + +func (cl applicationLoadBalancerClient) ListCredentials(ctx context.Context, projectID, region string) (*albsdk.ListCredentialsResponse, error) { + return cl.client.DefaultAPI.ListCredentials(ctx, projectID, region).Execute() +} + +func (cl applicationLoadBalancerClient) GetCredentials(ctx context.Context, projectID, region, credentialsRef string) (*albsdk.GetCredentialsResponse, error) { + return cl.client.DefaultAPI.GetCredentials(ctx, projectID, region, credentialsRef).Execute() +} + +func (cl applicationLoadBalancerClient) UpdateCredentials(ctx context.Context, projectID, region, credentialsRef string, payload albsdk.UpdateCredentialsPayload) error { + _, err := cl.client.DefaultAPI.UpdateCredentials(ctx, projectID, region, credentialsRef).UpdateCredentialsPayload(payload).Execute() + if err != nil { + return err + } + return nil +} + +func (cl applicationLoadBalancerClient) DeleteCredentials(ctx context.Context, projectID, region, credentialsRef string) error { + _, err := cl.client.DefaultAPI.DeleteCredentials(ctx, projectID, region, credentialsRef).Execute() + if err != nil { + return err + } + return nil +} diff --git a/pkg/stackit/applicationloadbalancer_mock.go b/pkg/stackit/applicationloadbalancer_mock.go new file mode 100644 index 0000000..c77b237 --- /dev/null +++ b/pkg/stackit/applicationloadbalancer_mock.go @@ -0,0 +1,188 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./pkg/stackit (interfaces: ApplicationLoadBalancerClient) +// +// Generated by this command: +// +// mockgen -destination ./pkg/stackit/applicationloadbalancer_mock.go -package stackit ./pkg/stackit ApplicationLoadBalancerClient +// + +// Package stackit is a generated GoMock package. +package stackit + +import ( + context "context" + reflect "reflect" + + v2api "github.com/stackitcloud/stackit-sdk-go/services/alb/v2api" + gomock "go.uber.org/mock/gomock" +) + +// MockApplicationLoadBalancerClient is a mock of ApplicationLoadBalancerClient interface. +type MockApplicationLoadBalancerClient struct { + ctrl *gomock.Controller + recorder *MockApplicationLoadBalancerClientMockRecorder + isgomock struct{} +} + +// MockApplicationLoadBalancerClientMockRecorder is the mock recorder for MockApplicationLoadBalancerClient. +type MockApplicationLoadBalancerClientMockRecorder struct { + mock *MockApplicationLoadBalancerClient +} + +// NewMockApplicationLoadBalancerClient creates a new mock instance. +func NewMockApplicationLoadBalancerClient(ctrl *gomock.Controller) *MockApplicationLoadBalancerClient { + mock := &MockApplicationLoadBalancerClient{ctrl: ctrl} + mock.recorder = &MockApplicationLoadBalancerClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockApplicationLoadBalancerClient) EXPECT() *MockApplicationLoadBalancerClientMockRecorder { + return m.recorder +} + +// CreateCredentials mocks base method. +func (m *MockApplicationLoadBalancerClient) CreateCredentials(ctx context.Context, projectID, region string, payload v2api.CreateCredentialsPayload) (*v2api.CreateCredentialsResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateCredentials", ctx, projectID, region, payload) + ret0, _ := ret[0].(*v2api.CreateCredentialsResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateCredentials indicates an expected call of CreateCredentials. +func (mr *MockApplicationLoadBalancerClientMockRecorder) CreateCredentials(ctx, projectID, region, payload any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateCredentials", reflect.TypeOf((*MockApplicationLoadBalancerClient)(nil).CreateCredentials), ctx, projectID, region, payload) +} + +// CreateLoadBalancer mocks base method. +func (m *MockApplicationLoadBalancerClient) CreateLoadBalancer(ctx context.Context, projectID, region string, albsdk *v2api.CreateLoadBalancerPayload) (*v2api.LoadBalancer, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateLoadBalancer", ctx, projectID, region, albsdk) + ret0, _ := ret[0].(*v2api.LoadBalancer) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateLoadBalancer indicates an expected call of CreateLoadBalancer. +func (mr *MockApplicationLoadBalancerClientMockRecorder) CreateLoadBalancer(ctx, projectID, region, albsdk any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateLoadBalancer", reflect.TypeOf((*MockApplicationLoadBalancerClient)(nil).CreateLoadBalancer), ctx, projectID, region, albsdk) +} + +// DeleteCredentials mocks base method. +func (m *MockApplicationLoadBalancerClient) DeleteCredentials(ctx context.Context, projectID, region, credentialRef string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteCredentials", ctx, projectID, region, credentialRef) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteCredentials indicates an expected call of DeleteCredentials. +func (mr *MockApplicationLoadBalancerClientMockRecorder) DeleteCredentials(ctx, projectID, region, credentialRef any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteCredentials", reflect.TypeOf((*MockApplicationLoadBalancerClient)(nil).DeleteCredentials), ctx, projectID, region, credentialRef) +} + +// DeleteLoadBalancer mocks base method. +func (m *MockApplicationLoadBalancerClient) DeleteLoadBalancer(ctx context.Context, projectID, region, name string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteLoadBalancer", ctx, projectID, region, name) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteLoadBalancer indicates an expected call of DeleteLoadBalancer. +func (mr *MockApplicationLoadBalancerClientMockRecorder) DeleteLoadBalancer(ctx, projectID, region, name any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteLoadBalancer", reflect.TypeOf((*MockApplicationLoadBalancerClient)(nil).DeleteLoadBalancer), ctx, projectID, region, name) +} + +// GetCredentials mocks base method. +func (m *MockApplicationLoadBalancerClient) GetCredentials(ctx context.Context, projectID, region, credentialRef string) (*v2api.GetCredentialsResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetCredentials", ctx, projectID, region, credentialRef) + ret0, _ := ret[0].(*v2api.GetCredentialsResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetCredentials indicates an expected call of GetCredentials. +func (mr *MockApplicationLoadBalancerClientMockRecorder) GetCredentials(ctx, projectID, region, credentialRef any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCredentials", reflect.TypeOf((*MockApplicationLoadBalancerClient)(nil).GetCredentials), ctx, projectID, region, credentialRef) +} + +// GetLoadBalancer mocks base method. +func (m *MockApplicationLoadBalancerClient) GetLoadBalancer(ctx context.Context, projectID, region, name string) (*v2api.LoadBalancer, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetLoadBalancer", ctx, projectID, region, name) + ret0, _ := ret[0].(*v2api.LoadBalancer) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetLoadBalancer indicates an expected call of GetLoadBalancer. +func (mr *MockApplicationLoadBalancerClientMockRecorder) GetLoadBalancer(ctx, projectID, region, name any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLoadBalancer", reflect.TypeOf((*MockApplicationLoadBalancerClient)(nil).GetLoadBalancer), ctx, projectID, region, name) +} + +// ListCredentials mocks base method. +func (m *MockApplicationLoadBalancerClient) ListCredentials(ctx context.Context, projectID, region string) (*v2api.ListCredentialsResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListCredentials", ctx, projectID, region) + ret0, _ := ret[0].(*v2api.ListCredentialsResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListCredentials indicates an expected call of ListCredentials. +func (mr *MockApplicationLoadBalancerClientMockRecorder) ListCredentials(ctx, projectID, region any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListCredentials", reflect.TypeOf((*MockApplicationLoadBalancerClient)(nil).ListCredentials), ctx, projectID, region) +} + +// UpdateCredentials mocks base method. +func (m *MockApplicationLoadBalancerClient) UpdateCredentials(ctx context.Context, projectID, region, credentialRef string, payload v2api.UpdateCredentialsPayload) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateCredentials", ctx, projectID, region, credentialRef, payload) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateCredentials indicates an expected call of UpdateCredentials. +func (mr *MockApplicationLoadBalancerClientMockRecorder) UpdateCredentials(ctx, projectID, region, credentialRef, payload any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateCredentials", reflect.TypeOf((*MockApplicationLoadBalancerClient)(nil).UpdateCredentials), ctx, projectID, region, credentialRef, payload) +} + +// UpdateLoadBalancer mocks base method. +func (m *MockApplicationLoadBalancerClient) UpdateLoadBalancer(ctx context.Context, projectID, region, name string, update *v2api.UpdateLoadBalancerPayload) (*v2api.LoadBalancer, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateLoadBalancer", ctx, projectID, region, name, update) + ret0, _ := ret[0].(*v2api.LoadBalancer) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateLoadBalancer indicates an expected call of UpdateLoadBalancer. +func (mr *MockApplicationLoadBalancerClientMockRecorder) UpdateLoadBalancer(ctx, projectID, region, name, update any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateLoadBalancer", reflect.TypeOf((*MockApplicationLoadBalancerClient)(nil).UpdateLoadBalancer), ctx, projectID, region, name, update) +} + +// UpdateTargetPool mocks base method. +func (m *MockApplicationLoadBalancerClient) UpdateTargetPool(ctx context.Context, projectID, region, name, targetPoolName string, payload v2api.UpdateTargetPoolPayload) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateTargetPool", ctx, projectID, region, name, targetPoolName, payload) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateTargetPool indicates an expected call of UpdateTargetPool. +func (mr *MockApplicationLoadBalancerClientMockRecorder) UpdateTargetPool(ctx, projectID, region, name, targetPoolName, payload any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTargetPool", reflect.TypeOf((*MockApplicationLoadBalancerClient)(nil).UpdateTargetPool), ctx, projectID, region, name, targetPoolName, payload) +} diff --git a/pkg/stackit/applicationloadbalancercertificates.go b/pkg/stackit/applicationloadbalancercertificates.go new file mode 100644 index 0000000..ce777a2 --- /dev/null +++ b/pkg/stackit/applicationloadbalancercertificates.go @@ -0,0 +1,51 @@ +package stackit + +import ( + "context" + + certsdk "github.com/stackitcloud/stackit-sdk-go/services/certificates/v2api" +) + +type CertificatesClient interface { + // TODO: hard-code region and project into client to make client interaction easier. + GetCertificate(ctx context.Context, projectID, region, name string) (*certsdk.GetCertificateResponse, error) + DeleteCertificate(ctx context.Context, projectID, region, name string) error + CreateCertificate(ctx context.Context, projectID, region string, certificate *certsdk.CreateCertificatePayload) (*certsdk.GetCertificateResponse, error) + ListCertificate(ctx context.Context, projectID, region string) (*certsdk.ListCertificatesResponse, error) +} + +type certClient struct { + client *certsdk.APIClient +} + +var _ CertificatesClient = (*certClient)(nil) + +func NewCertClient(cl *certsdk.APIClient) (CertificatesClient, error) { + return &certClient{client: cl}, nil +} + +func (cl certClient) GetCertificate(ctx context.Context, projectID, region, name string) (*certsdk.GetCertificateResponse, error) { + cert, err := cl.client.DefaultAPI.GetCertificate(ctx, projectID, region, name).Execute() + if isOpenAPINotFound(err) { + return cert, ErrorNotFound + } + return cert, err +} + +func (cl certClient) DeleteCertificate(ctx context.Context, projectID, region, name string) error { + _, err := cl.client.DefaultAPI.DeleteCertificate(ctx, projectID, region, name).Execute() + return err +} + +func (cl certClient) CreateCertificate(ctx context.Context, projectID, region string, certificate *certsdk.CreateCertificatePayload) (*certsdk.GetCertificateResponse, error) { + cert, err := cl.client.DefaultAPI.CreateCertificate(ctx, projectID, region).CreateCertificatePayload(*certificate).Execute() + if isOpenAPINotFound(err) { + return cert, ErrorNotFound + } + return cert, err +} + +func (cl certClient) ListCertificate(ctx context.Context, projectID, region string) (*certsdk.ListCertificatesResponse, error) { + certs, err := cl.client.DefaultAPI.ListCertificates(ctx, projectID, region).Execute() + return certs, err +} diff --git a/pkg/stackit/applicationloadbalancercertificates_mock.go b/pkg/stackit/applicationloadbalancercertificates_mock.go new file mode 100644 index 0000000..a9a4e6b --- /dev/null +++ b/pkg/stackit/applicationloadbalancercertificates_mock.go @@ -0,0 +1,101 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./pkg/stackit (interfaces: CertificatesClient) +// +// Generated by this command: +// +// mockgen -destination ./pkg/stackit/applicationloadbalancercertificates_mock.go -package stackit ./pkg/stackit CertificatesClient +// + +// Package stackit is a generated GoMock package. +package stackit + +import ( + context "context" + reflect "reflect" + + v2api "github.com/stackitcloud/stackit-sdk-go/services/certificates/v2api" + gomock "go.uber.org/mock/gomock" +) + +// MockCertificatesClient is a mock of CertificatesClient interface. +type MockCertificatesClient struct { + ctrl *gomock.Controller + recorder *MockCertificatesClientMockRecorder + isgomock struct{} +} + +// MockCertificatesClientMockRecorder is the mock recorder for MockCertificatesClient. +type MockCertificatesClientMockRecorder struct { + mock *MockCertificatesClient +} + +// NewMockCertificatesClient creates a new mock instance. +func NewMockCertificatesClient(ctrl *gomock.Controller) *MockCertificatesClient { + mock := &MockCertificatesClient{ctrl: ctrl} + mock.recorder = &MockCertificatesClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockCertificatesClient) EXPECT() *MockCertificatesClientMockRecorder { + return m.recorder +} + +// CreateCertificate mocks base method. +func (m *MockCertificatesClient) CreateCertificate(ctx context.Context, projectID, region string, certificate *v2api.CreateCertificatePayload) (*v2api.GetCertificateResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateCertificate", ctx, projectID, region, certificate) + ret0, _ := ret[0].(*v2api.GetCertificateResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateCertificate indicates an expected call of CreateCertificate. +func (mr *MockCertificatesClientMockRecorder) CreateCertificate(ctx, projectID, region, certificate any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateCertificate", reflect.TypeOf((*MockCertificatesClient)(nil).CreateCertificate), ctx, projectID, region, certificate) +} + +// DeleteCertificate mocks base method. +func (m *MockCertificatesClient) DeleteCertificate(ctx context.Context, projectID, region, name string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteCertificate", ctx, projectID, region, name) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteCertificate indicates an expected call of DeleteCertificate. +func (mr *MockCertificatesClientMockRecorder) DeleteCertificate(ctx, projectID, region, name any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteCertificate", reflect.TypeOf((*MockCertificatesClient)(nil).DeleteCertificate), ctx, projectID, region, name) +} + +// GetCertificate mocks base method. +func (m *MockCertificatesClient) GetCertificate(ctx context.Context, projectID, region, name string) (*v2api.GetCertificateResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetCertificate", ctx, projectID, region, name) + ret0, _ := ret[0].(*v2api.GetCertificateResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetCertificate indicates an expected call of GetCertificate. +func (mr *MockCertificatesClientMockRecorder) GetCertificate(ctx, projectID, region, name any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCertificate", reflect.TypeOf((*MockCertificatesClient)(nil).GetCertificate), ctx, projectID, region, name) +} + +// ListCertificate mocks base method. +func (m *MockCertificatesClient) ListCertificate(ctx context.Context, projectID, region string) (*v2api.ListCertificatesResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListCertificate", ctx, projectID, region) + ret0, _ := ret[0].(*v2api.ListCertificatesResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListCertificate indicates an expected call of ListCertificate. +func (mr *MockCertificatesClientMockRecorder) ListCertificate(ctx, projectID, region any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListCertificate", reflect.TypeOf((*MockCertificatesClient)(nil).ListCertificate), ctx, projectID, region) +} diff --git a/pkg/stackit/client.go b/pkg/stackit/client.go new file mode 100644 index 0000000..6b91e7c --- /dev/null +++ b/pkg/stackit/client.go @@ -0,0 +1,22 @@ +package stackit + +import ( + "errors" + "net/http" + + oapiError "github.com/stackitcloud/stackit-sdk-go/core/oapierror" +) + +var ErrorNotFound = errors.New("not found") + +func isOpenAPINotFound(err error) bool { + apiErr := &oapiError.GenericOpenAPIError{} + if !errors.As(err, &apiErr) { + return false + } + return apiErr.StatusCode == http.StatusNotFound +} + +func IsNotFound(err error) bool { + return errors.Is(err, ErrorNotFound) +} diff --git a/pkg/stackit/config/config.go b/pkg/stackit/config/config.go new file mode 100644 index 0000000..6082d17 --- /dev/null +++ b/pkg/stackit/config/config.go @@ -0,0 +1,64 @@ +package config + +import ( + "errors" + "io" + "os" + + "gopkg.in/yaml.v3" +) + +type GlobalOpts struct { + ProjectID string `yaml:"projectId"` + Region string `yaml:"region"` + APIEndpoints APIEndpoints `yaml:"apiEndpoints"` +} + +type APIEndpoints struct { + IaasAPI string `yaml:"iaasApi"` + LoadBalancerAPI string `yaml:"loadBalancerApi"` + ApplicationLoadBalancerAPI string `yaml:"applicationLoadBalancerApi"` + ApplicationLoadBalancerCertificateAPI string `yaml:"applicationLoadBalancerCertificateApi"` +} + +type ALBConfig struct { + Global GlobalOpts `yaml:"global"` + ApplicationLoadBalancer ApplicationLoadBalancerOpts `yaml:"applicationLoadBalancer"` +} +type ApplicationLoadBalancerOpts struct { + NetworkID string `yaml:"networkId"` +} + +func readFile(path string) ([]byte, error) { + file, err := os.Open(path) + if err != nil { + return []byte{}, err + } + defer file.Close() + + return io.ReadAll(file) +} + +func ReadALBConfigFromFile(path string) (ALBConfig, error) { + content, err := readFile(path) + if err != nil { + return ALBConfig{}, err + } + + config := ALBConfig{} + err = yaml.Unmarshal(content, &config) + if err != nil { + return ALBConfig{}, err + } + + if config.Global.ProjectID == "" { + return ALBConfig{}, errors.New("project ID must be set") + } + if config.Global.Region == "" { + return ALBConfig{}, errors.New("region must be set") + } + if config.ApplicationLoadBalancer.NetworkID == "" { + return ALBConfig{}, errors.New("network ID must be set") + } + return config, nil +} diff --git a/pkg/stackit/suite_test.go b/pkg/stackit/suite_test.go new file mode 100644 index 0000000..5450af3 --- /dev/null +++ b/pkg/stackit/suite_test.go @@ -0,0 +1,13 @@ +package stackit + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestSTACKITProvider(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "CSI STACKIT Provider Suite") +} diff --git a/pkg/testutil/ingress/ingress.go b/pkg/testutil/ingress/ingress.go new file mode 100644 index 0000000..75743a6 --- /dev/null +++ b/pkg/testutil/ingress/ingress.go @@ -0,0 +1,103 @@ +// revive:disable:exported // This file will be dot-imported. + +package ingress + +import ( + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +// Ingress constructs an ingress for testing purposes. +func Ingress(namespace, name string, opts ...IngressOption) networkingv1.Ingress { + i := networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: name, + Annotations: map[string]string{}, + }, + } + for _, o := range opts { + o.applyToIngress(&i) + } + return i +} + +type IngressOption interface { + applyToIngress(ingress *networkingv1.Ingress) +} + +type ingressOptionFunc func(ingress *networkingv1.Ingress) + +func (f ingressOptionFunc) applyToIngress(ingress *networkingv1.Ingress) { + f(ingress) +} + +func WithUID(uid string) IngressOption { + return ingressOptionFunc(func(ingress *networkingv1.Ingress) { + ingress.UID = types.UID(uid) + }) +} + +func WithIngressClass(ingressClass string) IngressOption { + return ingressOptionFunc(func(ingress *networkingv1.Ingress) { + ingress.Spec.IngressClassName = new(ingressClass) + }) +} + +func WithAnnotation(key, value string) IngressOption { + return ingressOptionFunc(func(ingress *networkingv1.Ingress) { + ingress.Annotations[key] = value + }) +} + +func WithTLSSecret(secretName string) IngressOption { + return ingressOptionFunc(func(ingress *networkingv1.Ingress) { + ingress.Spec.TLS = append(ingress.Spec.TLS, networkingv1.IngressTLS{ + SecretName: secretName, + }) + }) +} + +func WithRule(host string, opts ...RuleOptions) IngressOption { + return ingressOptionFunc(func(ingress *networkingv1.Ingress) { + rule := networkingv1.IngressRule{ + Host: host, + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{}, + }, + } + for _, o := range opts { + o.applyToRule(&rule) + } + ingress.Spec.Rules = append(ingress.Spec.Rules, rule) + }) +} + +type RuleOptions interface { + applyToRule(rule *networkingv1.IngressRule) +} + +type ruleOptionsFunc func(rule *networkingv1.IngressRule) + +func (f ruleOptionsFunc) applyToRule(rule *networkingv1.IngressRule) { + f(rule) +} + +func WithPath(path string, _type *networkingv1.PathType, serviceName string, serviceBackendPort networkingv1.ServiceBackendPort) RuleOptions { + return ruleOptionsFunc(func(rule *networkingv1.IngressRule) { + if rule.HTTP.Paths == nil { + rule.HTTP.Paths = []networkingv1.HTTPIngressPath{} + } + rule.HTTP.Paths = append(rule.HTTP.Paths, networkingv1.HTTPIngressPath{ + PathType: _type, + Path: path, + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: serviceName, + Port: serviceBackendPort, + }, + }, + }) + }) +} diff --git a/pkg/testutil/service/service.go b/pkg/testutil/service/service.go new file mode 100644 index 0000000..c9a1d0c --- /dev/null +++ b/pkg/testutil/service/service.go @@ -0,0 +1,58 @@ +// revive:disable:exported // This file will be dot-imported. + +package service + +import ( + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func Service(namespace, name string, opts ...ServiceOption) corev1.Service { + service := corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: name, + Annotations: map[string]string{}, + }, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{}, + }, + } + for _, o := range opts { + o.ApplyToService(&service) + } + return service +} + +type ServiceOption interface { + ApplyToService(service *corev1.Service) +} + +type serviceOptionFunc func(service *corev1.Service) + +func (f serviceOptionFunc) ApplyToService(service *corev1.Service) { + f(service) +} + +func WithPort(name string, port, nodePort int32, protocol corev1.Protocol) ServiceOption { + return serviceOptionFunc(func(service *corev1.Service) { + service.Spec.Ports = append(service.Spec.Ports, corev1.ServicePort{ + Name: name, + Port: port, + NodePort: nodePort, + Protocol: protocol, + }) + }) +} + +func WithServiceType(_type corev1.ServiceType) ServiceOption { + return serviceOptionFunc(func(service *corev1.Service) { + service.Spec.Type = _type + }) +} + +func WithServiceAnnotation(key, value string) ServiceOption { + return serviceOptionFunc(func(service *corev1.Service) { + service.Annotations[key] = value + }) +} diff --git a/pkg/testutil/testutil.go b/pkg/testutil/testutil.go new file mode 100644 index 0000000..2b1536f --- /dev/null +++ b/pkg/testutil/testutil.go @@ -0,0 +1,44 @@ +package testutil + +import ( + "context" + "sync/atomic" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/onsi/gomega/types" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func DeleteAndWaitForKubernetesResource(ctx context.Context, cl client.Client, obj client.Object) { + GinkgoHelper() + Expect(cl.Delete(ctx, obj)).To(Succeed()) + Eventually(func(g Gomega, ctx context.Context) { + g.Expect(cl.Get(ctx, client.ObjectKeyFromObject(obj), obj)).Should(WithTransform(apierrors.IsNotFound, BeTrue()), "Expected resource %s to eventually be deleted", client.ObjectKeyFromObject(obj)) + }).WithContext(ctx).Should(Succeed()) +} + +func HaveAtomicValue[T any](matcher types.GomegaMatcher) types.GomegaMatcher { + return WithTransform(func(a *atomic.Pointer[T]) *T { + t := a.Load() + return t + }, matcher) +} + +// HaveName expects a Kubernetes resource to have the given name. +func HaveName(name string) types.GomegaMatcher { + return WithTransform(func(o client.Object) string { + return o.GetName() + }, Equal(name)) +} + +// CreateKubernetesResourceAndDeferDeletion creates obj via cl and registers a callback to clean up some object again. +// The clean up waits until the object is gone from the API, i.e. are finalizer must be removed. +func CreateKubernetesResourceAndDeferDeletion(ctx context.Context, cl client.Client, obj client.Object) { + GinkgoHelper() + Expect(cl.Create(ctx, obj)).To(Succeed()) + DeferCleanup(func(ctx context.Context) { + DeleteAndWaitForKubernetesResource(ctx, cl, obj) + }) +}