From 2f824df403dbcb51b3e180c90c2bd994399ad5e8 Mon Sep 17 00:00:00 2001 From: Kamil Przybyl Date: Tue, 23 Jun 2026 13:49:46 +0200 Subject: [PATCH 01/41] docs: add user documentation for ALB Ingress controller (#2) Co-authored-by: Kamil Przybyl --- README.md | 201 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..e425b94 --- /dev/null +++ b/README.md @@ -0,0 +1,201 @@ +# Application Load Balancer Controller Manager User Documentation + +The STACKIT Application Load Balancer Controller Manager (ALBCM) exposes HTTP/HTTPS applications by provisioning and configuring managed STACKIT ALBs based on native Kubernetes Ingress resources. + +### Enabling the ALB extension +The Application Load Balancer integration is disabled by default and can be activated for your cluster via the SKE-API by setting the enabled field to true inside the applicationLoadBalancer block under extensions: +```JSON +{ + "extensions": { + "applicationLoadBalancer": { + "enabled": true + } + } +} +``` + +### 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* section). + +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: CLusterIP + 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 +``` + +### 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 ordering +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. + +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. + +Note that the top-to-bottom order of paths defined within a single Ingress YAML is non-deterministic. If your application requires strict execution ordering, you must split the rules into separate Ingress resources and assign explicit priority annotations to each. + +### TLS and Certificate Rotation +The minimal Ingress example in the Quick Start section shows a plain, unencrypted 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 +``` + +### 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. | +| `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. | + +### Known Limitations + +#### defaultBackend support +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. + From 56ba53da49ed9b84218870b1799cfbf239f138e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Wed, 24 Jun 2026 22:23:10 +0200 Subject: [PATCH 02/41] Move ALBC from CCM repository --- .gitignore | 205 +------ .golangci.yaml | 133 ++-- .../main.go | 146 ++++- go.mod | 76 ++- go.sum | 203 +++++++ .../ingress/ingressclass_controller.go | 183 ++++++ .../ingress/ingressclass_controller_test.go | 473 +++++++++++++++ pkg/controller/ingress/setup.go | 170 ++++++ pkg/controller/ingress/spec/annotations.go | 112 ++++ pkg/controller/ingress/spec/events.go | 33 + pkg/controller/ingress/spec/name.go | 13 + pkg/controller/ingress/spec/suite_test.go | 13 + pkg/controller/ingress/spec/worktree.go | 568 ++++++++++++++++++ pkg/controller/ingress/spec/worktree_test.go | 377 ++++++++++++ pkg/controller/ingress/suite_test.go | 51 ++ pkg/controller/ingress/update.go | 319 ++++++++++ pkg/controller/ingress/update_test.go | 232 +++++++ pkg/labels/labels.go | 32 + pkg/labels/labels_test.go | 37 ++ pkg/labels/suit_test.go | 13 + pkg/stackit/applicationloadbalancer.go | 112 ++++ pkg/stackit/applicationloadbalancer_mock.go | 188 ++++++ .../applicationloadbalancercertificates.go | 51 ++ ...pplicationloadbalancercertificates_mock.go | 101 ++++ pkg/stackit/client.go | 22 + pkg/stackit/config/config.go | 64 ++ pkg/stackit/suite_test.go | 13 + pkg/testutil/ingress/ingress.go | 101 ++++ pkg/testutil/service/service.go | 49 ++ pkg/testutil/testutil.go | 28 + 30 files changed, 3880 insertions(+), 238 deletions(-) create mode 100644 go.sum create mode 100644 pkg/controller/ingress/ingressclass_controller.go create mode 100644 pkg/controller/ingress/ingressclass_controller_test.go create mode 100644 pkg/controller/ingress/setup.go create mode 100644 pkg/controller/ingress/spec/annotations.go create mode 100644 pkg/controller/ingress/spec/events.go create mode 100644 pkg/controller/ingress/spec/name.go create mode 100644 pkg/controller/ingress/spec/suite_test.go create mode 100644 pkg/controller/ingress/spec/worktree.go create mode 100644 pkg/controller/ingress/spec/worktree_test.go create mode 100644 pkg/controller/ingress/suite_test.go create mode 100644 pkg/controller/ingress/update.go create mode 100644 pkg/controller/ingress/update_test.go create mode 100644 pkg/labels/labels.go create mode 100644 pkg/labels/labels_test.go create mode 100644 pkg/labels/suit_test.go create mode 100644 pkg/stackit/applicationloadbalancer.go create mode 100644 pkg/stackit/applicationloadbalancer_mock.go create mode 100644 pkg/stackit/applicationloadbalancercertificates.go create mode 100644 pkg/stackit/applicationloadbalancercertificates_mock.go create mode 100644 pkg/stackit/client.go create mode 100644 pkg/stackit/config/config.go create mode 100644 pkg/stackit/suite_test.go create mode 100644 pkg/testutil/ingress/ingress.go create mode 100644 pkg/testutil/service/service.go create mode 100644 pkg/testutil/testutil.go 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..90e8a70 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,38 @@ linters: - std-error-handling rules: - linters: - - gosec + - goconst + - noctx + - dupl 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/cmd/application-load-balancer-controller/main.go b/cmd/application-load-balancer-controller/main.go index 147db1c..fea3ff9 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 // TODO: Refactor into smaller functions. 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", false, + "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", "d0fbe9c4.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/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..b88d7e6 --- /dev/null +++ b/pkg/controller/ingress/ingressclass_controller.go @@ -0,0 +1,183 @@ +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 + } + + // TODO: Use proper verbosity levels + log.V(10).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) + } + return ctrl.Result{}, nil + } + + if err := r.applyALB(ctx, ingressClass); err != nil { + return ctrl.Result{}, fmt.Errorf("failed to apply ALB: %w", err) + } + + requeue, err := r.updateStatus(ctx, ingressClass) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to update ingress status: %w", err) + } + + log.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 _, ingress := range ingresses { + 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 _, ingress := range ingresses { + 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 shoot object: %w", err) + } + } + + 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) + } + + // TODO: Delete all certificates for ingress ingress + + 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) + } + } + + 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..1d689fa --- /dev/null +++ b/pkg/controller/ingress/ingressclass_controller_test.go @@ -0,0 +1,473 @@ +package ingress_test + +import ( + "context" + "fmt" + "sync" + "sync/atomic" + "time" + + . "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/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 _ = FDescribe("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. + 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"}}, + }, + } + Expect(k8sClient.Create(ctx, &node)).To(Succeed()) + DeferCleanup(func(ctx context.Context) { + Expect(k8sClient.Delete(ctx, &node)).To(Succeed()) + }) + + 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", + }, + } + Expect(k8sClient.Create(ctx, ignoredIngressClass)).To(Succeed()) + DeferCleanup(func(ctx context.Context) { + testutil.DeleteAndWaitForKubernetesResource(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) { + 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()).Return(nil, stackit.ErrorNotFound).AnyTimes() + done := make(chan any) + albClient.EXPECT().CreateLoadBalancer(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, _, _ string, _ *albsdk.CreateLoadBalancerPayload) (*albsdk.LoadBalancer, error) { + // TODO: verify arguments + close(done) + return new(albsdk.LoadBalancer{}), nil + }).MinTimes(1) // TODO: Change to exactly once. + + ingressClass := &networkingv1.IngressClass{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "managed-ingressclass-", + }, + Spec: networkingv1.IngressClassSpec{ + Controller: controllerName, + }, + } + Expect(k8sClient.Create(ctx, ingressClass)).To(Succeed()) + DeferCleanup(func() { + testutil.DeleteAndWaitForKubernetesResource(ctx, k8sClient, ingressClass) + }) + + WaitUntilFinalizerAttached(ctx, k8sClient, ingressClass) + + Eventually(done).WithTimeout(5 * time.Second).Should(BeClosed()) + }) + + // 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(ctx context.Context, projectID, region string) (*certsdk.ListCertificatesResponse, error) { + return listCertificatesResponse.Load(), nil + }).AnyTimes() + + albClient.EXPECT().GetLoadBalancer(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, projectID, region, name 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) { + // TODO: check name + 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()) + // 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 referenced in ALB", func(ctx context.Context) { + updateRequest := &atomic.Pointer[albsdk.UpdateLoadBalancerPayload]{} + certClient.EXPECT().CreateCertificate(gomock.Any(), projectID, region, gomock.Any()).DoAndReturn(func(ctx context.Context, projectID, region 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{ + 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) + albClient.EXPECT().UpdateLoadBalancer(gomock.Any(), projectID, region, gomock.Any(), gomock.Any()).DoAndReturn(func(ctx context.Context, projectID, region, name 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 + }).Times(1) + + secret := corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Namespace: corev1.NamespaceDefault, Name: "my-tls-cert"}, + Type: corev1.SecretTypeTLS, + Data: map[string][]byte{ + corev1.TLSCertKey: []byte(fixtureTLSPublicKey), + corev1.TLSPrivateKeyKey: []byte(fixtureTLSPrivateKey), + }, + } + Expect(k8sClient.Create(ctx, &secret)).To(Succeed()) + service := Service(corev1.NamespaceDefault, "my-service", WithServiceType(corev1.ServiceTypeNodePort), WithPort("http", 80, 30000, corev1.ProtocolTCP)) + Expect(k8sClient.Create(ctx, &service)).To(Succeed()) + 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})), + ) + Expect(k8sClient.Create(ctx, &ingress)).To(Succeed()) + + Eventually(updateRequest).Should(testutil.HaveAtomicValue[albsdk.UpdateLoadBalancerPayload](Not(BeNil()))) + update := updateRequest.Load() + Expect(update.Version).To(HaveValue(Equal("version-after-create"))) + Expect(update.Listeners[1].Https.CertificateConfig.CertificateIds).To(ConsistOf("random-certificate-id")) + }) + + /* Context("When deleting an IngressClass", func() { + BeforeEach(func() { + // 1. Point our managed IngressClass definition to include the target testing labels + managedIngressClass = &networkingv1.IngressClass{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "managed-ingressclass-", + UID: "envtest-ic-uid", + Labels: map[string]string{ + labels.LabelIngressClassUID: "target-cloud-alb-id", + }, + }, + Spec: networkingv1.IngressClassSpec{Controller: controllerName}, + } + + setupMocks = func(m *stackit.MockApplicationLoadBalancerClient) { + m.EXPECT(). + GetLoadBalancer(gomock.Any(), projectID, region, gomock.Any()). + Return(&albsdk.LoadBalancer{Status: new("READY")}, nil). + AnyTimes() + m.EXPECT(). + UpdateLoadBalancer(gomock.Any(), projectID, region, gomock.Any(), gomock.Any()). + Return(&albsdk.LoadBalancer{Status: new("READY")}, nil). + AnyTimes() // "allow background threads update safely without breaking my test" + + m.EXPECT(). + DeleteLoadBalancer(gomock.Any(), projectID, region, gomock.Any()). + Return(nil). + Times(1) // Asserts that the controller MUST call this exactly 1 time! + + } + + }) + + It("should read the UID label, delete associated ALB and certificate ", func(ctx context.Context) { + + // should delete the associated ALB and Certificate + certClient.EXPECT(). + DeleteCertificate(gomock.Any(), projectID, region, targetCertID). + Return(nil). + AnyTimes() + + // Publish the labeled IngressClass to the test cluster + Expect(k8sClient.Create(ctx, managedIngressClass)).To(Succeed()) + + // Wait for the controller background loop to notice it and attach the finalizer + WaitUntilFinalizerAttached(ctx, k8sClient, managedIngressClass) + + // Issue the Delete call to test the teardown pipeline + Expect(k8sClient.Delete(ctx, managedIngressClass)).To(Succeed()) + + // Verify the finalizer gets scrubbed and the object disappears from the API Server + Eventually(func(g Gomega) { + var ic networkingv1.IngressClass + err := k8sClient.Get(ctx, client.ObjectKeyFromObject(managedIngressClass), &ic) + + g.Expect(apierrors.IsNotFound(err)).To(BeTrue(), "The object must be deleted completely") + }, "5s", "200ms").Should(Succeed()) + }) + }) */ + }) + +}) + +func testIngress(class *networkingv1.IngressClass, service *corev1.Service) *networkingv1.Ingress { + return &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{Name: "test-ingress", Namespace: service.Namespace}, + Spec: networkingv1.IngressSpec{ + IngressClassName: new(class.Name), + Rules: []networkingv1.IngressRule{ + { + Host: "example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/", + PathType: new(networkingv1.PathTypePrefix), + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: service.Name, + Port: networkingv1.ServiceBackendPort{Number: service.Spec.Ports[0].Port}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } +} + +// 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()) +} + +const ( + fixtureTLSPublicKey = `-----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-----` + fixtureTLSPrivateKey = `-----BEGIN PRIVATE KEY----- +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 +UoA+9Hd41MHTo2Frp1cML2BpdbK/ +-----END PRIVATE KEY-----` +) diff --git a/pkg/controller/ingress/setup.go b/pkg/controller/ingress/setup.go new file mode 100644 index 0000000..eb38e12 --- /dev/null +++ b/pkg/controller/ingress/setup.go @@ -0,0 +1,170 @@ +package ingress + +import ( + "context" + "reflect" + + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + 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" +) + +const ( + // fieldIndexIngressClass indexes the ingress class on an ingress. + fieldIndexIngressClass = ".spec.ingressClassName" +) + +// 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} + }) + + 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)). + // TODO: Services are missing + Named(ctrlName). + Complete(r) +} + +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 + } + + ingressList := &networkingv1.IngressList{} + if err := c.List(ctx, ingressList, client.InNamespace(secret.Namespace)); err != nil { + return nil + } + + classNames := make(map[string]struct{}) + for _, ingress := range ingressList.Items { + if ingress.Spec.IngressClassName == nil { + continue + } + + for _, tls := range ingress.Spec.TLS { + if tls.SecretName == secret.Name { + classNames[*ingress.Spec.IngressClassName] = struct{}{} + break + } + } + } + + var requestList []ctrl.Request + for className := range classNames { + ingressClass := &networkingv1.IngressClass{} + err := c.Get(ctx, client.ObjectKey{Name: className}, ingressClass) + if err != nil || ingressClass.Spec.Controller != controllerName { + continue + } + + requestList = append(requestList, ctrl.Request{ + NamespacedName: client.ObjectKeyFromObject(ingressClass), + }) + } + + return requestList + }) +} + +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..e7585cd --- /dev/null +++ b/pkg/controller/ingress/spec/annotations.go @@ -0,0 +1,112 @@ +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" + + // TODO: + AnnotationIngressClassName = "kubernetes.io/ingress.class" + + // TODO: source ACL +) + +// GetAnnotation retrieves an annotation value from objects. +// If multiple objects contain the annotation, the first object in the slice 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 latest found value is parsed. +// +// TODO: Return parser errors?! +// TODO: Allow unsetting a value by setting the annotation to an empty string?! +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..1f59a65 --- /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/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..64a94bc --- /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 TestStackit(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "ALB Spec") +} diff --git a/pkg/controller/ingress/spec/worktree.go b/pkg/controller/ingress/spec/worktree.go new file mode 100644 index 0000000..fe95374 --- /dev/null +++ b/pkg/controller/ingress/spec/worktree.go @@ -0,0 +1,568 @@ +package spec + +import ( + "cmp" + "crypto/sha256" + cryptotls "crypto/tls" + "encoding/hex" + "fmt" + "maps" + "slices" + "strconv" + + 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 + + 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 +} + +// 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 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( + 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 _, s := range services { + servicesMap[client.ObjectKeyFromObject(&s)] = s + } + secretsMap := map[types.NamespacedName]corev1.Secret{} + for _, s := range secrets { + secretsMap[client.ObjectKeyFromObject(&s)] = s + } + + targets := getTargetsOfNodes(nodes) + + tree := &WorkTreeALB{ + ingressClass: ingressClass, + planId: GetAnnotation(AnnotationPlanID, "", ingressClass), + waf: GetAnnotation(AnnotationWAFName, "", ingressClass), + + listeners: map[int16]*workTreeListener{}, + targetPools: map[ingressPathReference]*albsdk.TargetPool{}, + existingALB: existingALB, + certificates: map[CertificateFingerprint]WorkTreeCertificate{}, + } + + // TODO: Explain sorting + slices.SortFunc(ingresses, func(a, b networkingv1.Ingress) int { + if diff := GetAnnotation(AnnotationPriority, 0, &a) - GetAnnotation(AnnotationPriority, 0, &b); 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 _, ingress := range ingresses { + for tlsIndex, tls := range ingress.Spec.TLS { + // TODO: document that the host field is completely ignored + 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 + } + + tree.certificates[CertificateFingerprint(fingerprint)] = WorkTreeCertificate{ + PublicKey: string(secret.Data[corev1.TLSCertKey]), + PrivateKey: string(secret.Data[corev1.TLSPrivateKeyKey]), + } + } + 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} + + httpsOnly := GetAnnotation(AnnotationHTTPSOnly, false, &ingress, ingressClass) + httpPort := GetAnnotation(AnnotationHTTPPort, 80, &ingress, ingressClass) + httpsPort := GetAnnotation(AnnotationHTTPSPort, 443, &ingress, ingressClass) + + targetPool, e := buildTargetPool(tree, targets, ingress, rule, 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 +} + +// 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{}, + } + } + + // TODO: Define a semantic for ImplementationSpecific path. According to spec it MUST be supported. + albPath, exists := host.paths[_pathWithType] + if exists && albPath.ingressPathReference == ingressPathReference { + errors = append(errors, errorEvent{ + ingress: ingress, + fieldPath: field.NewPath("spec", "rules", strconv.Itoa(ruleIndex), "path", strconv.Itoa(pathIndex)), + description: "Path already exists", + }) + return false, errors + } + if !exists { + albPath = &workTreePath{ + path: _pathWithType, + ingressPathReference: ingressPathReference, + } + // TODO: check limits + } + albPath.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, targets []albsdk.Target, ingress networkingv1.Ingress, rule networkingv1.IngressRule, 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 { + // TODO: check limits. + } + 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 + // TODO: Use TCP health checks for eTP=Cluster + 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("1s"), + Timeout: new("1s"), + UnhealthyThreshold: new(int32(2)), + // TODO: Optimize interval etc. + } + } + + 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 { + targets := []albsdk.Target{} + for _, node := range nodes { + 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 return 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( + 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.PathTypePrefix: 2, + } + 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), + }) + 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{}, + }, + } + // TODO: Only use the certificates used for this port. + for fingerprint := range t.certificates { + 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("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.TargetPort, *b.TargetPort) + }) + + 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), + }, + // TODO: Support static IP and promotion but not demotion + Listeners: listeners, + Networks: []albsdk.Network{ + { + NetworkId: new(networkID), + Role: new("ROLE_LISTENERS_AND_TARGETS"), + }, + }, + Options: &albsdk.LoadBalancerOptions{ + EphemeralAddress: new(true), + // TODO: + }, + 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 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)) + // TODO: Take observability log config from existing LB. + 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..65d1193 --- /dev/null +++ b/pkg/controller/ingress/spec/worktree_test.go @@ -0,0 +1,377 @@ +package spec + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + . "github.com/stackitcloud/application-load-balancer-controller/pkg/testutil/ingress" + . "github.com/stackitcloud/application-load-balancer-controller/pkg/testutil/service" + + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +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}), + ), + ), + }, nil, []corev1.Service{ + Service(corev1.NamespaceDefault, "my-service", WithServiceType(corev1.ServiceTypeNodePort), WithPort("my-port", 1337, 30000, corev1.ProtocolTCP)), + }, nil, nil) + Expect(errs).To(HaveLen(0)) + 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(6)) + 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.Prefix).To(HaveValue(Equal("/prefix/a"))) + Expect(createPayload.Listeners[0].Http.Hosts[0].Rules[5].Path.Prefix).To(HaveValue(Equal("/prefix/b"))) + }) + + It("should match rules against correct node ports", func() { + const host = "my-host.local" + tree, _ := BuildTree(&networkingv1.IngressClass{}, []networkingv1.Ingress{ + Ingress( + "default", "ingress-to-node-port-5000", + WithRule(host, WithPath("/5000", new(networkingv1.PathTypeExact), "service-a", networkingv1.ServiceBackendPort{Number: 1337})), + ), + Ingress( + "default", "ingress-to-node-port-5001", + WithRule(host, WithPath("/5001", new(networkingv1.PathTypeExact), "service-a", networkingv1.ServiceBackendPort{Name: "1338"})), + ), + Ingress( + "default", "ingress-to-node-port-5002", + WithRule(host, WithPath("/5002", new(networkingv1.PathTypeExact), "service-a", networkingv1.ServiceBackendPort{Number: 1339})), + ), + Ingress( + "default", "ingress-to-node-port-5003", + WithRule(host, WithPath("/5003", new(networkingv1.PathTypeExact), "service-b", networkingv1.ServiceBackendPort{Number: 1337})), + ), + }, nil, []corev1.Service{ + Service("default", "service-a", + WithPort("1337", 1337, 5000, corev1.ProtocolTCP), + WithPort("1338", 1338, 5001, corev1.ProtocolTCP), + WithPort("1339", 1339, 5002, corev1.ProtocolTCP), + ), + Service("default", "service-b", + WithPort("1337", 1337, 5003, corev1.ProtocolTCP), + ), + }, nil, nil) + 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 target ports. + 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: v1.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: v1.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: v1.ObjectMeta{Namespace: corev1.NamespaceDefault, Name: "invalid-tls"}, + Type: corev1.SecretTypeTLS, + Data: map[string][]byte{ + corev1.TLSCertKey: []byte("invalid cert"), + corev1.TLSPrivateKeyKey: []byte(fixtureTLSPrivateKey), + }, + }, + }, 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", func() { + tree, errs := BuildTree( + &networkingv1.IngressClass{}, + []networkingv1.Ingress{ + Ingress(corev1.NamespaceDefault, "ingress-with-tls-secret-reference", WithTLSSecret("my-tls")), + }, + []corev1.Secret{ + { + ObjectMeta: v1.ObjectMeta{Namespace: corev1.NamespaceDefault, Name: "my-tls"}, + Type: corev1.SecretTypeTLS, + Data: map[string][]byte{ + corev1.TLSCertKey: []byte(fixtureTLSPublicKey), + corev1.TLSPrivateKeyKey: []byte(fixtureTLSPrivateKey), + }, + }, + }, nil, nil, nil, + ) + + Expect(errs).To(HaveLen(0)) + Expect(tree.GetMissingCertificates(nil)).To(ConsistOf( + WorkTreeCertificate{ + PublicKey: fixtureTLSPublicKey, + PrivateKey: fixtureTLSPrivateKey, + }, + )) + }) + + It("should enable websocket if enable on ingress class", func() { + tree, errs := BuildTree( + &networkingv1.IngressClass{ + ObjectMeta: v1.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(HaveLen(0)) + 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(HaveLen(0)) + 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: v1.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(HaveLen(0)) + 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"))) + }) +}) + +const ( + fixtureTLSPublicKey = `-----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-----` + fixtureTLSPrivateKey = `-----BEGIN PRIVATE KEY----- +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 +UoA+9Hd41MHTo2Frp1cML2BpdbK/ +-----END PRIVATE KEY-----` +) 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..e09e8ba --- /dev/null +++ b/pkg/controller/ingress/update.go @@ -0,0 +1,319 @@ +package ingress + +import ( + "context" + "errors" + "fmt" + + "github.com/stackitcloud/application-load-balancer-controller/pkg/controller/ingress/spec" + "github.com/stackitcloud/application-load-balancer-controller/pkg/labels" + "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) applyALB(ctx context.Context, ingressClass *networkingv1.IngressClass) error { + 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( // TODO: deal with errors + ingressClass, + ingresses, + secrets, + services, + nodes.Items, + existingALB, + ) + + for _, err := range errs { + ctrl.LoggerFrom(ctx).Info("Recorded ingress event", "event", err.Error()) + err.RecordEvent(ingressClass, r.Recorder) + } + + // TODO: Deal with paging. + projectCertificates, err := r.CertificateClient.ListCertificate(ctx, r.ALBConfig.Global.ProjectID, r.ALBConfig.Global.Region) + if err != nil { + return fmt.Errorf("failed to list certificates: %w", err) + } + + // 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 := []certsdk.GetCertificateResponse{} + for _, cert := range projectCertificates.Items { + if cert.Labels != nil && (*cert.Labels)[labels.LabelIngressClassUID] == string(ingressClass.UID) { + // TODO: Check for nil-ness in cert + ingressClassCertificates = append(ingressClassCertificates, cert) + } + } + + missingCertificates := tree.GetMissingCertificates(ingressClassCertificates) + for fingerprint, c := range missingCertificates { + createCertificatePayload := &certsdk.CreateCertificatePayload{ + Name: new(string("alb-cert")), // TODO: Add some identifying prefix and shorten it to 63 characters + ProjectId: &r.ALBConfig.Global.ProjectID, + PrivateKey: new(string(c.PrivateKey)), + PublicKey: new(string(c.PublicKey)), + Labels: &map[string]string{ + labels.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) + } + // TODO: Check for nil-ness in response + ctrl.LoggerFrom(ctx).Info("Created certificate", "id", response.Id, "fingerprint", fingerprint) + ingressClassCertificates = append(ingressClassCertificates, *response) + } + + certIDMap := map[spec.CertificateFingerprint]string{} + // deplicateCerts contains all certificates that are duplicates of others (in certIDMap) by fingerprint. + // Because they might still be used by the ALB the 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) + return nil // TODO: Early return here prevents certificate clean-up + } + + 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 _, ingress := range ingresses { + 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 _, ingress := range ingresses { + 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 +} + +func updateNeeded(alb *albsdk.LoadBalancer, albPayload *albsdk.UpdateLoadBalancerPayload) bool { + return listenersChanged(alb.Listeners, albPayload.Listeners) || targetPoolsChanged(alb.TargetPools, albPayload.TargetPools) +} + +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 +} diff --git a/pkg/controller/ingress/update_test.go b/pkg/controller/ingress/update_test.go new file mode 100644 index 0000000..cfc1c7d --- /dev/null +++ b/pkg/controller/ingress/update_test.go @@ -0,0 +1,232 @@ +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: ptr.To("waf-1")}, + }, + }, + desired: &albsdk.UpdateLoadBalancerPayload{ + Listeners: []albsdk.Listener{ + {WafConfigName: ptr.To("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: ptr.To("/api")}}, + }, + }, + }, + }, + }, + }, + }, + desired: &albsdk.UpdateLoadBalancerPayload{ + Listeners: []albsdk.Listener{ + { + Http: &albsdk.ProtocolOptionsHTTP{ + Hosts: []albsdk.HostConfig{ + { + Rules: []albsdk.Rule{ + {Path: &albsdk.Path{Prefix: ptr.To("/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: ptr.To("/api")}}, + }, + }, + }, + }, + }, + }, + }, + desired: &albsdk.UpdateLoadBalancerPayload{ + Listeners: []albsdk.Listener{ + { + Http: &albsdk.ProtocolOptionsHTTP{ + Hosts: []albsdk.HostConfig{ + { + Rules: []albsdk.Rule{ + {Path: &albsdk.Path{ExactMatch: ptr.To("/v2")}}, + }, + }, + }, + }, + }, + }, + }, + expected: true, + }, + { + name: "websocket changed", + current: &albsdk.LoadBalancer{ + Listeners: []albsdk.Listener{ + { + Http: &albsdk.ProtocolOptionsHTTP{ + Hosts: []albsdk.HostConfig{ + { + Rules: []albsdk.Rule{ + {WebSocket: ptr.To(false)}, + }, + }, + }, + }, + }, + }, + }, + desired: &albsdk.UpdateLoadBalancerPayload{ + Listeners: []albsdk.Listener{ + { + Http: &albsdk.ProtocolOptionsHTTP{ + Hosts: []albsdk.HostConfig{ + { + Rules: []albsdk.Rule{ + {WebSocket: ptr.To(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: ptr.To(false), + }, + }, + }, + }, + desired: &albsdk.UpdateLoadBalancerPayload{ + TargetPools: []albsdk.TargetPool{ + { + TlsConfig: &albsdk.TlsConfig{ + SkipCertificateValidation: ptr.To(true), + }, + }, + }, + }, + expected: true, + }, + } + + 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/labels/labels.go b/pkg/labels/labels.go new file mode 100644 index 0000000..14bc0ef --- /dev/null +++ b/pkg/labels/labels.go @@ -0,0 +1,32 @@ +package labels + +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 Sanitize(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/labels/labels_test.go b/pkg/labels/labels_test.go new file mode 100644 index 0000000..41cfc6c --- /dev/null +++ b/pkg/labels/labels_test.go @@ -0,0 +1,37 @@ +package labels + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Sanitize", func() { + Context("when sanitizing labels", func() { + It("should replace non-alphanumeric characters with hyphens", func() { + result := Sanitize("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 := Sanitize("...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 := Sanitize(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 := Sanitize("") + Expect(result).To(Equal("")) + }) + + It("should handle string with only invalid characters", func() { + result := Sanitize("!@#$%^&*()") + Expect(result).To(Equal("")) + }) + }) +}) diff --git a/pkg/labels/suit_test.go b/pkg/labels/suit_test.go new file mode 100644 index 0000000..a32e0d2 --- /dev/null +++ b/pkg/labels/suit_test.go @@ -0,0 +1,13 @@ +package labels + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestLabels(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Labels Suite") +} 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..2a5b2b0 --- /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 + 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..c47a82d --- /dev/null +++ b/pkg/testutil/ingress/ingress.go @@ -0,0 +1,101 @@ +package ingress + +import ( + networkingv1 "k8s.io/api/networking/v1" + v1 "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: v1.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) ingressOptionFunc { + 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..3b6ddad --- /dev/null +++ b/pkg/testutil/service/service.go @@ -0,0 +1,49 @@ +package service + +import ( + corev1 "k8s.io/api/core/v1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func Service(namespace, name string, opts ...ServiceOption) corev1.Service { + service := corev1.Service{ + ObjectMeta: v1.ObjectMeta{ + Namespace: namespace, + Name: name, + }, + 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 + }) +} diff --git a/pkg/testutil/testutil.go b/pkg/testutil/testutil.go new file mode 100644 index 0000000..4ef481e --- /dev/null +++ b/pkg/testutil/testutil.go @@ -0,0 +1,28 @@ +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) +} From 62d9cfe75f9bfad02e49946a00cb73fdae5a8aed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Thu, 25 Jun 2026 09:25:57 +0200 Subject: [PATCH 03/41] Fix some linter issues --- .../ingress/ingressclass_controller_test.go | 38 ++----------------- pkg/controller/ingress/spec/events.go | 6 +-- pkg/controller/ingress/spec/worktree.go | 34 ++++++++--------- pkg/controller/ingress/spec/worktree_test.go | 24 ++++++------ pkg/controller/ingress/update.go | 6 +-- pkg/controller/ingress/update_test.go | 20 +++++----- pkg/testutil/ingress/ingress.go | 6 +-- pkg/testutil/service/service.go | 4 +- pkg/testutil/testutil.go | 1 - 9 files changed, 54 insertions(+), 85 deletions(-) diff --git a/pkg/controller/ingress/ingressclass_controller_test.go b/pkg/controller/ingress/ingressclass_controller_test.go index 1d689fa..cd2eaeb 100644 --- a/pkg/controller/ingress/ingressclass_controller_test.go +++ b/pkg/controller/ingress/ingressclass_controller_test.go @@ -193,11 +193,11 @@ var _ = FDescribe("IngressClassController", func() { listCertificatesResponse = &atomic.Pointer[certsdk.ListCertificatesResponse]{} listCertificatesResponse.Store(&certsdk.ListCertificatesResponse{Items: []certsdk.GetCertificateResponse{}}) - certClient.EXPECT().ListCertificate(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(ctx context.Context, projectID, region string) (*certsdk.ListCertificatesResponse, error) { + 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, projectID, region, name string) (*albsdk.LoadBalancer, error) { + 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 @@ -229,7 +229,7 @@ var _ = FDescribe("IngressClassController", func() { It("should create certificate and referenced in ALB", func(ctx context.Context) { updateRequest := &atomic.Pointer[albsdk.UpdateLoadBalancerPayload]{} - certClient.EXPECT().CreateCertificate(gomock.Any(), projectID, region, gomock.Any()).DoAndReturn(func(ctx context.Context, projectID, region string, certificate *certsdk.CreateCertificatePayload) (*certsdk.GetCertificateResponse, error) { + certClient.EXPECT().CreateCertificate(gomock.Any(), projectID, region, gomock.Any()).DoAndReturn(func(_ context.Context, projectID, region 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) @@ -247,7 +247,7 @@ var _ = FDescribe("IngressClassController", func() { }) return &response, nil }).Times(1) - albClient.EXPECT().UpdateLoadBalancer(gomock.Any(), projectID, region, gomock.Any(), gomock.Any()).DoAndReturn(func(ctx context.Context, projectID, region, name string, update *albsdk.UpdateLoadBalancerPayload) (*albsdk.LoadBalancer, error) { + albClient.EXPECT().UpdateLoadBalancer(gomock.Any(), projectID, region, gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, projectID, region, name string, update *albsdk.UpdateLoadBalancerPayload) (*albsdk.LoadBalancer, error) { response := albsdk.LoadBalancer(*update) response.Version = new("version-after-update") response.ExternalAddress = new("127.0.0.1") @@ -343,36 +343,6 @@ var _ = FDescribe("IngressClassController", func() { }) -func testIngress(class *networkingv1.IngressClass, service *corev1.Service) *networkingv1.Ingress { - return &networkingv1.Ingress{ - ObjectMeta: metav1.ObjectMeta{Name: "test-ingress", Namespace: service.Namespace}, - Spec: networkingv1.IngressSpec{ - IngressClassName: new(class.Name), - Rules: []networkingv1.IngressRule{ - { - Host: "example.com", - IngressRuleValue: networkingv1.IngressRuleValue{ - HTTP: &networkingv1.HTTPIngressRuleValue{ - Paths: []networkingv1.HTTPIngressPath{ - { - Path: "/", - PathType: new(networkingv1.PathTypePrefix), - Backend: networkingv1.IngressBackend{ - Service: &networkingv1.IngressServiceBackend{ - Name: service.Name, - Port: networkingv1.ServiceBackendPort{Number: service.Spec.Ports[0].Port}, - }, - }, - }, - }, - }, - }, - }, - }, - }, - } -} - // 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! diff --git a/pkg/controller/ingress/spec/events.go b/pkg/controller/ingress/spec/events.go index 1f59a65..079d45c 100644 --- a/pkg/controller/ingress/spec/events.go +++ b/pkg/controller/ingress/spec/events.go @@ -10,20 +10,20 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" ) -type errorEvent struct { +type ErrorEvent struct { ingress client.Object description string fieldPath *field.Path } -func (e *errorEvent) Error() string { +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) { +func (e *ErrorEvent) RecordEvent(class *networkingv1.IngressClass, recorder record.EventRecorder) { if e.ingress.GetName() == "" { return } diff --git a/pkg/controller/ingress/spec/worktree.go b/pkg/controller/ingress/spec/worktree.go index fe95374..aa0b980 100644 --- a/pkg/controller/ingress/spec/worktree.go +++ b/pkg/controller/ingress/spec/worktree.go @@ -31,7 +31,7 @@ type CertificateFingerprint string // Look at the methods how a work tree can be used. type WorkTreeALB struct { ingressClass *networkingv1.IngressClass - planId string + planID string waf string listeners map[int16]*workTreeListener @@ -107,8 +107,8 @@ func BuildTree( services []corev1.Service, nodes []corev1.Node, existingALB *albsdk.LoadBalancer, -) (*WorkTreeALB, []errorEvent) { - errors := []errorEvent{} +) (*WorkTreeALB, []ErrorEvent) { + errors := []ErrorEvent{} servicesMap := map[types.NamespacedName]corev1.Service{} for _, s := range services { @@ -123,7 +123,7 @@ func BuildTree( tree := &WorkTreeALB{ ingressClass: ingressClass, - planId: GetAnnotation(AnnotationPlanID, "", ingressClass), + planID: GetAnnotation(AnnotationPlanID, "", ingressClass), waf: GetAnnotation(AnnotationWAFName, "", ingressClass), listeners: map[int16]*workTreeListener{}, @@ -148,7 +148,7 @@ func BuildTree( // TODO: document that the host field is completely ignored secret, exists := secretsMap[types.NamespacedName{Namespace: ingress.Namespace, Name: tls.SecretName}] if !exists { - errors = append(errors, errorEvent{ + errors = append(errors, ErrorEvent{ ingress: &ingress, fieldPath: field.NewPath("spec", "tls").Index(tlsIndex).Child("secretName"), description: "TLS secret doesn't exist", @@ -156,7 +156,7 @@ func BuildTree( continue } if secret.Type != corev1.SecretTypeTLS { - errors = append(errors, errorEvent{ + 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", @@ -166,7 +166,7 @@ func BuildTree( fingerprint, err := ValidateTLSCertAndFingerprint(secret.Data[corev1.TLSCertKey], secret.Data[corev1.TLSPrivateKeyKey]) if err != nil { - errors = append(errors, errorEvent{ + errors = append(errors, ErrorEvent{ ingress: &ingress, fieldPath: field.NewPath("spec", "tls").Index(tlsIndex).Child("secretName"), description: fmt.Sprintf("invalid certificate: %s", err.Error()), @@ -217,7 +217,7 @@ func BuildTree( // 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) { +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} @@ -230,7 +230,7 @@ func addPathToTree(tree *WorkTreeALB, ingressClass *networkingv1.IngressClass, i } if listener.protocol != protocol { // TODO: This error is redundant if the ingress contains multiple rules. Move this check "up". - errors = append(errors, errorEvent{ + 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), @@ -248,7 +248,7 @@ func addPathToTree(tree *WorkTreeALB, ingressClass *networkingv1.IngressClass, i // TODO: Define a semantic for ImplementationSpecific path. According to spec it MUST be supported. albPath, exists := host.paths[_pathWithType] if exists && albPath.ingressPathReference == ingressPathReference { - errors = append(errors, errorEvent{ + errors = append(errors, ErrorEvent{ ingress: ingress, fieldPath: field.NewPath("spec", "rules", strconv.Itoa(ruleIndex), "path", strconv.Itoa(pathIndex)), description: "Path already exists", @@ -277,8 +277,8 @@ func addPathToTree(tree *WorkTreeALB, ingressClass *networkingv1.IngressClass, i // // 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, targets []albsdk.Target, ingress networkingv1.Ingress, rule networkingv1.IngressRule, ruleIndex int, path networkingv1.HTTPIngressPath, pathIndex int, servicesMap map[types.NamespacedName]corev1.Service) (*albsdk.TargetPool, []errorEvent) { - errors := []errorEvent{} +func buildTargetPool(tree *WorkTreeALB, targets []albsdk.Target, ingress networkingv1.Ingress, rule networkingv1.IngressRule, 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} @@ -292,7 +292,7 @@ func buildTargetPool(tree *WorkTreeALB, targets []albsdk.Target, ingress network service, exists := servicesMap[types.NamespacedName{Namespace: ingress.Namespace, Name: path.Backend.Service.Name}] if !exists { - errors = append(errors, errorEvent{ + 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", @@ -300,7 +300,7 @@ func buildTargetPool(tree *WorkTreeALB, targets []albsdk.Target, ingress network return nil, errors } if service.Spec.Type != corev1.ServiceTypeNodePort && service.Spec.Type != corev1.ServiceTypeLoadBalancer { - errors = append(errors, errorEvent{ + 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", @@ -312,7 +312,7 @@ func buildTargetPool(tree *WorkTreeALB, targets []albsdk.Target, ingress network if port.Port == path.Backend.Service.Port.Number || port.Name == path.Backend.Service.Port.Name { if port.NodePort == 0 { - errors = append(errors, errorEvent{ + 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", @@ -323,7 +323,7 @@ func buildTargetPool(tree *WorkTreeALB, targets []albsdk.Target, ingress network } } if nodePort == 0 { - errors = append(errors, errorEvent{ + 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", @@ -543,7 +543,7 @@ func (t WorkTreeALB) ToCreatePayload( EphemeralAddress: new(true), // TODO: }, - PlanId: &t.planId, + PlanId: &t.planID, Region: new(region), TargetPools: targetPools, } diff --git a/pkg/controller/ingress/spec/worktree_test.go b/pkg/controller/ingress/spec/worktree_test.go index 65d1193..c74cd02 100644 --- a/pkg/controller/ingress/spec/worktree_test.go +++ b/pkg/controller/ingress/spec/worktree_test.go @@ -8,7 +8,7 @@ import ( corev1 "k8s.io/api/core/v1" networkingv1 "k8s.io/api/networking/v1" - v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) var _ = Describe("WorkTreeALB", func() { @@ -35,7 +35,7 @@ var _ = Describe("WorkTreeALB", func() { }, nil, []corev1.Service{ Service(corev1.NamespaceDefault, "my-service", WithServiceType(corev1.ServiceTypeNodePort), WithPort("my-port", 1337, 30000, corev1.ProtocolTCP)), }, nil, nil) - Expect(errs).To(HaveLen(0)) + 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(6)) @@ -119,7 +119,7 @@ var _ = Describe("WorkTreeALB", func() { }, []corev1.Secret{ { - ObjectMeta: v1.ObjectMeta{Namespace: corev1.NamespaceDefault, Name: "non-tls"}, + ObjectMeta: metav1.ObjectMeta{Namespace: corev1.NamespaceDefault, Name: "non-tls"}, Type: corev1.SecretTypeDockerConfigJson, // Not TLS }, }, nil, nil, nil, @@ -137,7 +137,7 @@ var _ = Describe("WorkTreeALB", func() { }, []corev1.Secret{ { - ObjectMeta: v1.ObjectMeta{Namespace: corev1.NamespaceDefault, Name: "non-tls"}, + ObjectMeta: metav1.ObjectMeta{Namespace: corev1.NamespaceDefault, Name: "non-tls"}, Type: corev1.SecretTypeDockerConfigJson, // Not TLS }, }, nil, nil, nil, @@ -155,7 +155,7 @@ var _ = Describe("WorkTreeALB", func() { }, []corev1.Secret{ { - ObjectMeta: v1.ObjectMeta{Namespace: corev1.NamespaceDefault, Name: "invalid-tls"}, + ObjectMeta: metav1.ObjectMeta{Namespace: corev1.NamespaceDefault, Name: "invalid-tls"}, Type: corev1.SecretTypeTLS, Data: map[string][]byte{ corev1.TLSCertKey: []byte("invalid cert"), @@ -177,7 +177,7 @@ var _ = Describe("WorkTreeALB", func() { }, []corev1.Secret{ { - ObjectMeta: v1.ObjectMeta{Namespace: corev1.NamespaceDefault, Name: "my-tls"}, + ObjectMeta: metav1.ObjectMeta{Namespace: corev1.NamespaceDefault, Name: "my-tls"}, Type: corev1.SecretTypeTLS, Data: map[string][]byte{ corev1.TLSCertKey: []byte(fixtureTLSPublicKey), @@ -187,7 +187,7 @@ var _ = Describe("WorkTreeALB", func() { }, nil, nil, nil, ) - Expect(errs).To(HaveLen(0)) + Expect(errs).To(BeEmpty()) Expect(tree.GetMissingCertificates(nil)).To(ConsistOf( WorkTreeCertificate{ PublicKey: fixtureTLSPublicKey, @@ -199,7 +199,7 @@ var _ = Describe("WorkTreeALB", func() { It("should enable websocket if enable on ingress class", func() { tree, errs := BuildTree( &networkingv1.IngressClass{ - ObjectMeta: v1.ObjectMeta{ + ObjectMeta: metav1.ObjectMeta{ Annotations: map[string]string{ AnnotationWebSocket: "true", }, @@ -219,7 +219,7 @@ var _ = Describe("WorkTreeALB", func() { }, nil, nil, ) - Expect(errs).To(HaveLen(0)) + 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)) @@ -247,7 +247,7 @@ var _ = Describe("WorkTreeALB", func() { }, nil, nil, ) - Expect(errs).To(HaveLen(0)) + 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)) @@ -261,7 +261,7 @@ var _ = Describe("WorkTreeALB", func() { It("should set WAF on all ports if specified on ingress class", func() { tree, errs := BuildTree( &networkingv1.IngressClass{ - ObjectMeta: v1.ObjectMeta{ + ObjectMeta: metav1.ObjectMeta{ Annotations: map[string]string{ AnnotationWAFName: "my-waf", }, @@ -281,7 +281,7 @@ var _ = Describe("WorkTreeALB", func() { }, nil, nil, ) - Expect(errs).To(HaveLen(0)) + 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"))) diff --git a/pkg/controller/ingress/update.go b/pkg/controller/ingress/update.go index e09e8ba..22e74b2 100644 --- a/pkg/controller/ingress/update.go +++ b/pkg/controller/ingress/update.go @@ -81,10 +81,10 @@ func (r *IngressClassReconciler) applyALB(ctx context.Context, ingressClass *net missingCertificates := tree.GetMissingCertificates(ingressClassCertificates) for fingerprint, c := range missingCertificates { createCertificatePayload := &certsdk.CreateCertificatePayload{ - Name: new(string("alb-cert")), // TODO: Add some identifying prefix and shorten it to 63 characters + Name: new("alb-cert"), // TODO: Add some identifying prefix and shorten it to 63 characters ProjectId: &r.ALBConfig.Global.ProjectID, - PrivateKey: new(string(c.PrivateKey)), - PublicKey: new(string(c.PublicKey)), + PrivateKey: new(c.PrivateKey), + PublicKey: new(c.PublicKey), Labels: &map[string]string{ labels.LabelIngressClassUID: string(ingressClass.UID), }, diff --git a/pkg/controller/ingress/update_test.go b/pkg/controller/ingress/update_test.go index cfc1c7d..d031656 100644 --- a/pkg/controller/ingress/update_test.go +++ b/pkg/controller/ingress/update_test.go @@ -46,12 +46,12 @@ func Test_updateNeeded(t *testing.T) { name: "waf config changed", current: &albsdk.LoadBalancer{ Listeners: []albsdk.Listener{ - {WafConfigName: ptr.To("waf-1")}, + {WafConfigName: new("waf-1")}, }, }, desired: &albsdk.UpdateLoadBalancerPayload{ Listeners: []albsdk.Listener{ - {WafConfigName: ptr.To("waf-2")}, + {WafConfigName: new("waf-2")}, }, }, expected: true, @@ -65,7 +65,7 @@ func Test_updateNeeded(t *testing.T) { Hosts: []albsdk.HostConfig{ { Rules: []albsdk.Rule{ - {Path: &albsdk.Path{Prefix: ptr.To("/api")}}, + {Path: &albsdk.Path{Prefix: new("/api")}}, }, }, }, @@ -80,7 +80,7 @@ func Test_updateNeeded(t *testing.T) { Hosts: []albsdk.HostConfig{ { Rules: []albsdk.Rule{ - {Path: &albsdk.Path{Prefix: ptr.To("/v2")}}, + {Path: &albsdk.Path{Prefix: new("/v2")}}, }, }, }, @@ -99,7 +99,7 @@ func Test_updateNeeded(t *testing.T) { Hosts: []albsdk.HostConfig{ { Rules: []albsdk.Rule{ - {Path: &albsdk.Path{ExactMatch: ptr.To("/api")}}, + {Path: &albsdk.Path{ExactMatch: new("/api")}}, }, }, }, @@ -114,7 +114,7 @@ func Test_updateNeeded(t *testing.T) { Hosts: []albsdk.HostConfig{ { Rules: []albsdk.Rule{ - {Path: &albsdk.Path{ExactMatch: ptr.To("/v2")}}, + {Path: &albsdk.Path{ExactMatch: new("/v2")}}, }, }, }, @@ -133,7 +133,7 @@ func Test_updateNeeded(t *testing.T) { Hosts: []albsdk.HostConfig{ { Rules: []albsdk.Rule{ - {WebSocket: ptr.To(false)}, + {WebSocket: new(false)}, }, }, }, @@ -148,7 +148,7 @@ func Test_updateNeeded(t *testing.T) { Hosts: []albsdk.HostConfig{ { Rules: []albsdk.Rule{ - {WebSocket: ptr.To(true)}, + {WebSocket: new(true)}, }, }, }, @@ -204,7 +204,7 @@ func Test_updateNeeded(t *testing.T) { TargetPools: []albsdk.TargetPool{ { TlsConfig: &albsdk.TlsConfig{ - SkipCertificateValidation: ptr.To(false), + SkipCertificateValidation: new(false), }, }, }, @@ -213,7 +213,7 @@ func Test_updateNeeded(t *testing.T) { TargetPools: []albsdk.TargetPool{ { TlsConfig: &albsdk.TlsConfig{ - SkipCertificateValidation: ptr.To(true), + SkipCertificateValidation: new(true), }, }, }, diff --git a/pkg/testutil/ingress/ingress.go b/pkg/testutil/ingress/ingress.go index c47a82d..809890d 100644 --- a/pkg/testutil/ingress/ingress.go +++ b/pkg/testutil/ingress/ingress.go @@ -2,14 +2,14 @@ package ingress import ( networkingv1 "k8s.io/api/networking/v1" - v1 "k8s.io/apimachinery/pkg/apis/meta/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: v1.ObjectMeta{ + ObjectMeta: metav1.ObjectMeta{ Namespace: namespace, Name: name, Annotations: map[string]string{}, @@ -49,7 +49,7 @@ func WithAnnotation(key, value string) IngressOption { }) } -func WithTLSSecret(secretName string) ingressOptionFunc { +func WithTLSSecret(secretName string) IngressOption { return ingressOptionFunc(func(ingress *networkingv1.Ingress) { ingress.Spec.TLS = append(ingress.Spec.TLS, networkingv1.IngressTLS{ SecretName: secretName, diff --git a/pkg/testutil/service/service.go b/pkg/testutil/service/service.go index 3b6ddad..40728bb 100644 --- a/pkg/testutil/service/service.go +++ b/pkg/testutil/service/service.go @@ -2,12 +2,12 @@ package service import ( corev1 "k8s.io/api/core/v1" - v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) func Service(namespace, name string, opts ...ServiceOption) corev1.Service { service := corev1.Service{ - ObjectMeta: v1.ObjectMeta{ + ObjectMeta: metav1.ObjectMeta{ Namespace: namespace, Name: name, }, diff --git a/pkg/testutil/testutil.go b/pkg/testutil/testutil.go index 4ef481e..b00f4cd 100644 --- a/pkg/testutil/testutil.go +++ b/pkg/testutil/testutil.go @@ -16,7 +16,6 @@ func DeleteAndWaitForKubernetesResource(ctx context.Context, cl client.Client, o 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()) } From 9cdcdb352366f2b947c0fe00f91f934d8c59826c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Thu, 25 Jun 2026 10:24:31 +0200 Subject: [PATCH 04/41] Fixed controller tests --- .../ingress/ingressclass_controller.go | 2 + .../ingress/ingressclass_controller_test.go | 61 +++++++++++++------ pkg/controller/ingress/spec/suite_test.go | 2 +- 3 files changed, 47 insertions(+), 18 deletions(-) diff --git a/pkg/controller/ingress/ingressclass_controller.go b/pkg/controller/ingress/ingressclass_controller.go index b88d7e6..d1e5a7d 100644 --- a/pkg/controller/ingress/ingressclass_controller.go +++ b/pkg/controller/ingress/ingressclass_controller.go @@ -65,6 +65,7 @@ func (r *IngressClassReconciler) Reconcile(ctx context.Context, req ctrl.Request 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 } @@ -177,6 +178,7 @@ func (r *IngressClassReconciler) handleIngressClassDeletion( 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 index cd2eaeb..47ca0e4 100644 --- a/pkg/controller/ingress/ingressclass_controller_test.go +++ b/pkg/controller/ingress/ingressclass_controller_test.go @@ -2,10 +2,10 @@ package ingress_test import ( "context" + "errors" "fmt" "sync" "sync/atomic" - "time" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -37,7 +37,7 @@ const ( targetCertID = "real-certificate-uuid-abc-123" ) -var _ = FDescribe("IngressClassController", func() { +var _ = Describe("IngressClassController", func() { var ( recorder *record.FakeRecorder @@ -150,16 +150,25 @@ var _ = FDescribe("IngressClassController", func() { }) It("should create an empty ALB for an ingress class matching the controller", func(ctx context.Context) { + var getLoadBalancerResponse *atomic.Pointer[albsdk.LoadBalancer] = &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()).Return(nil, stackit.ErrorNotFound).AnyTimes() - done := make(chan any) - albClient.EXPECT().CreateLoadBalancer(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, _, _ string, _ *albsdk.CreateLoadBalancerPayload) (*albsdk.LoadBalancer, error) { - // TODO: verify arguments - close(done) - return new(albsdk.LoadBalancer{}), nil - }).MinTimes(1) // TODO: Change to exactly once. + 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{ @@ -170,13 +179,14 @@ var _ = FDescribe("IngressClassController", func() { }, } Expect(k8sClient.Create(ctx, ingressClass)).To(Succeed()) - DeferCleanup(func() { + 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(done).WithTimeout(5 * time.Second).Should(BeClosed()) + Eventually(getLoadBalancerResponse).Should(testutil.HaveAtomicValue[albsdk.LoadBalancer](Not(BeNil()))) }) // The ALB is already created when BeforeEach completes. @@ -223,11 +233,16 @@ var _ = FDescribe("IngressClassController", func() { }, } 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) + }) + // 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 referenced in ALB", func(ctx context.Context) { + 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, projectID, region string, certificate *certsdk.CreateCertificatePayload) (*certsdk.GetCertificateResponse, error) { fingerprint, err := spec.ValidateTLSCertAndFingerprint([]byte(*certificate.PublicKey), []byte(*certificate.PrivateKey)) @@ -256,7 +271,7 @@ var _ = FDescribe("IngressClassController", func() { updateRequest.Store(update) return (*albsdk.LoadBalancer)(update), nil - }).Times(1) + }).MinTimes(1) secret := corev1.Secret{ ObjectMeta: metav1.ObjectMeta{Namespace: corev1.NamespaceDefault, Name: "my-tls-cert"}, @@ -274,10 +289,22 @@ var _ = FDescribe("IngressClassController", func() { ) Expect(k8sClient.Create(ctx, &ingress)).To(Succeed()) - Eventually(updateRequest).Should(testutil.HaveAtomicValue[albsdk.UpdateLoadBalancerPayload](Not(BeNil()))) - update := updateRequest.Load() - Expect(update.Version).To(HaveValue(Equal("version-after-create"))) - Expect(update.Listeners[1].Https.CertificateConfig.CertificateIds).To(ConsistOf("random-certificate-id")) + // 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")), + )) }) /* Context("When deleting an IngressClass", func() { diff --git a/pkg/controller/ingress/spec/suite_test.go b/pkg/controller/ingress/spec/suite_test.go index 64a94bc..5e599bc 100644 --- a/pkg/controller/ingress/spec/suite_test.go +++ b/pkg/controller/ingress/spec/suite_test.go @@ -7,7 +7,7 @@ import ( . "github.com/onsi/gomega" ) -func TestStackit(t *testing.T) { +func TestSpec(t *testing.T) { RegisterFailHandler(Fail) RunSpecs(t, "ALB Spec") } From 40658b01044b784a6a4f2063746e0da843d860c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Thu, 25 Jun 2026 10:56:53 +0200 Subject: [PATCH 05/41] Add event handler for services --- pkg/controller/ingress/setup.go | 63 ++++++++++++++++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/pkg/controller/ingress/setup.go b/pkg/controller/ingress/setup.go index eb38e12..4e2a911 100644 --- a/pkg/controller/ingress/setup.go +++ b/pkg/controller/ingress/setup.go @@ -6,17 +6,21 @@ import ( 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 a service reference on an ingress. + fieldIndexService = ".spec.rules.http.paths.backend.service.name" ) // SetupWithManager sets up the controller with the Manager. @@ -29,6 +33,27 @@ func (r *IngressClassReconciler) SetupWithManager(ctx context.Context, mgr ctrl. 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 + }) + if ctrlName == "" { ctrlName = "ingressclass" } @@ -38,7 +63,7 @@ func (r *IngressClassReconciler) SetupWithManager(ctx context.Context, mgr ctrl. Watches(&corev1.Node{}, nodeEventHandler(r.Client), builder.WithPredicates(nodePredicate())). Watches(&networkingv1.Ingress{}, ingressEventHandler(r.Client)). Watches(&corev1.Secret{}, secretEventHandler(r.Client)). - // TODO: Services are missing + Watches(&corev1.Service{}, serviceEventHandler(r.Client)). Named(ctrlName). Complete(r) } @@ -87,6 +112,42 @@ func secretEventHandler(c client.Client) handler.EventHandler { }) } +// serviceEventHandler returns all ingress classes that have at least one ingress that reference given secret. +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(context.Background(), 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(context.Background(), 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{} From 7ea52bae5d6d71802d42f44208a59e810a71e275 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Thu, 25 Jun 2026 11:00:24 +0200 Subject: [PATCH 06/41] Verbosity levels --- pkg/controller/ingress/ingressclass_controller.go | 5 ++--- pkg/controller/ingress/ingressclass_controller_test.go | 1 - pkg/controller/ingress/setup.go | 2 +- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/pkg/controller/ingress/ingressclass_controller.go b/pkg/controller/ingress/ingressclass_controller.go index d1e5a7d..9302e62 100644 --- a/pkg/controller/ingress/ingressclass_controller.go +++ b/pkg/controller/ingress/ingressclass_controller.go @@ -48,8 +48,7 @@ func (r *IngressClassReconciler) Reconcile(ctx context.Context, req ctrl.Request return ctrl.Result{}, nil } - // TODO: Use proper verbosity levels - log.V(10).Info("Reconciling IngressClass") + log.V(2).Info("Reconciling IngressClass") if !ingressClass.DeletionTimestamp.IsZero() { err := r.handleIngressClassDeletion(ctx, ingressClass) @@ -78,7 +77,7 @@ func (r *IngressClassReconciler) Reconcile(ctx context.Context, req ctrl.Request return ctrl.Result{}, fmt.Errorf("failed to update ingress status: %w", err) } - log.Info("Successfully reconciled IngressClass", "Name", ingressClass.Name) + log.V(1).Info("Successfully reconciled IngressClass", "Name", ingressClass.Name) return requeue, nil } diff --git a/pkg/controller/ingress/ingressclass_controller_test.go b/pkg/controller/ingress/ingressclass_controller_test.go index 47ca0e4..8629f90 100644 --- a/pkg/controller/ingress/ingressclass_controller_test.go +++ b/pkg/controller/ingress/ingressclass_controller_test.go @@ -215,7 +215,6 @@ var _ = Describe("IngressClassController", func() { return lb, nil }).AnyTimes() albClient.EXPECT().CreateLoadBalancer(gomock.Any(), projectID, region, gomock.Any()).DoAndReturn(func(_ context.Context, _, _ string, create *albsdk.CreateLoadBalancerPayload) (*albsdk.LoadBalancer, error) { - // TODO: check name response := albsdk.LoadBalancer(*create) response.Version = new("version-after-create") response.ExternalAddress = new("127.0.0.1") diff --git a/pkg/controller/ingress/setup.go b/pkg/controller/ingress/setup.go index 4e2a911..163a2f5 100644 --- a/pkg/controller/ingress/setup.go +++ b/pkg/controller/ingress/setup.go @@ -112,7 +112,7 @@ func secretEventHandler(c client.Client) handler.EventHandler { }) } -// serviceEventHandler returns all ingress classes that have at least one ingress that reference given secret. +// serviceEventHandler returns all ingress classes that have at least one ingress that references the given secret. func serviceEventHandler(c client.Client) handler.EventHandler { return handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, o client.Object) []ctrl.Request { service, ok := o.(*corev1.Service) From dc97548c842274b0831032af5c6d388cde71fb71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Thu, 25 Jun 2026 12:59:06 +0200 Subject: [PATCH 07/41] Delete certificates on ingress class deletion --- .../ingress/ingressclass_controller.go | 16 ++++++++- pkg/controller/ingress/update.go | 33 ++++++++++++------- 2 files changed, 36 insertions(+), 13 deletions(-) diff --git a/pkg/controller/ingress/ingressclass_controller.go b/pkg/controller/ingress/ingressclass_controller.go index 9302e62..2e30993 100644 --- a/pkg/controller/ingress/ingressclass_controller.go +++ b/pkg/controller/ingress/ingressclass_controller.go @@ -165,12 +165,26 @@ func (r *IngressClassReconciler) handleIngressClassDeletion( } } + // 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: Delete all certificates for ingress ingress + // 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) diff --git a/pkg/controller/ingress/update.go b/pkg/controller/ingress/update.go index 22e74b2..dc04666 100644 --- a/pkg/controller/ingress/update.go +++ b/pkg/controller/ingress/update.go @@ -62,20 +62,11 @@ func (r *IngressClassReconciler) applyALB(ctx context.Context, ingressClass *net err.RecordEvent(ingressClass, r.Recorder) } - // TODO: Deal with paging. - projectCertificates, err := r.CertificateClient.ListCertificate(ctx, r.ALBConfig.Global.ProjectID, r.ALBConfig.Global.Region) - if err != nil { - return fmt.Errorf("failed to list certificates: %w", err) - } - // 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 := []certsdk.GetCertificateResponse{} - for _, cert := range projectCertificates.Items { - if cert.Labels != nil && (*cert.Labels)[labels.LabelIngressClassUID] == string(ingressClass.UID) { - // TODO: Check for nil-ness in cert - ingressClassCertificates = append(ingressClassCertificates, cert) - } + ingressClassCertificates, err := r.getCertificatesForIngressClass(ctx, ingressClass) + if err != nil { + return err } missingCertificates := tree.GetMissingCertificates(ingressClassCertificates) @@ -220,6 +211,24 @@ func (r *IngressClassReconciler) getTLSSecretsFromIngresses(ctx context.Context, 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)[labels.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) } From ef8a935cb970ba574519a6738df839ce6c4e1226 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Thu, 25 Jun 2026 13:08:47 +0200 Subject: [PATCH 08/41] Move labels package into spec --- .../ingress/spec}/labels.go | 4 +- pkg/controller/ingress/spec/labels_test.go | 35 ++++++++++++++++++ pkg/controller/ingress/update.go | 5 +-- pkg/labels/labels_test.go | 37 ------------------- pkg/labels/suit_test.go | 13 ------- 5 files changed, 39 insertions(+), 55 deletions(-) rename pkg/{labels => controller/ingress/spec}/labels.go (92%) create mode 100644 pkg/controller/ingress/spec/labels_test.go delete mode 100644 pkg/labels/labels_test.go delete mode 100644 pkg/labels/suit_test.go diff --git a/pkg/labels/labels.go b/pkg/controller/ingress/spec/labels.go similarity index 92% rename from pkg/labels/labels.go rename to pkg/controller/ingress/spec/labels.go index 14bc0ef..90a0655 100644 --- a/pkg/labels/labels.go +++ b/pkg/controller/ingress/spec/labels.go @@ -1,4 +1,4 @@ -package labels +package spec import ( "regexp" @@ -17,7 +17,7 @@ const ( // Replace non-alphanumeric characters (except '-', '_', '.') with '-' var reg = regexp.MustCompile(`[^-a-zA-Z0-9_.]+`) -func Sanitize(input string) string { +func SanitizeLabelValue(input string) string { sanitized := reg.ReplaceAllString(input, "-") // Ensure the label starts and ends with an alphanumeric character 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/update.go b/pkg/controller/ingress/update.go index dc04666..7cf7f2d 100644 --- a/pkg/controller/ingress/update.go +++ b/pkg/controller/ingress/update.go @@ -6,7 +6,6 @@ import ( "fmt" "github.com/stackitcloud/application-load-balancer-controller/pkg/controller/ingress/spec" - "github.com/stackitcloud/application-load-balancer-controller/pkg/labels" "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" @@ -77,7 +76,7 @@ func (r *IngressClassReconciler) applyALB(ctx context.Context, ingressClass *net PrivateKey: new(c.PrivateKey), PublicKey: new(c.PublicKey), Labels: &map[string]string{ - labels.LabelIngressClassUID: string(ingressClass.UID), + spec.LabelIngressClassUID: string(ingressClass.UID), }, } response, err := r.CertificateClient.CreateCertificate(ctx, r.ALBConfig.Global.ProjectID, r.ALBConfig.Global.Region, createCertificatePayload) @@ -221,7 +220,7 @@ func (r *IngressClassReconciler) getCertificatesForIngressClass(ctx context.Cont ingressClassCertificates := []certsdk.GetCertificateResponse{} for _, cert := range projectCertificates.Items { - if cert.Labels != nil && (*cert.Labels)[labels.LabelIngressClassUID] == string(ingressClass.UID) { + if cert.Labels != nil && (*cert.Labels)[spec.LabelIngressClassUID] == string(ingressClass.UID) { ingressClassCertificates = append(ingressClassCertificates, cert) } } diff --git a/pkg/labels/labels_test.go b/pkg/labels/labels_test.go deleted file mode 100644 index 41cfc6c..0000000 --- a/pkg/labels/labels_test.go +++ /dev/null @@ -1,37 +0,0 @@ -package labels - -import ( - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Sanitize", func() { - Context("when sanitizing labels", func() { - It("should replace non-alphanumeric characters with hyphens", func() { - result := Sanitize("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 := Sanitize("...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 := Sanitize(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 := Sanitize("") - Expect(result).To(Equal("")) - }) - - It("should handle string with only invalid characters", func() { - result := Sanitize("!@#$%^&*()") - Expect(result).To(Equal("")) - }) - }) -}) diff --git a/pkg/labels/suit_test.go b/pkg/labels/suit_test.go deleted file mode 100644 index a32e0d2..0000000 --- a/pkg/labels/suit_test.go +++ /dev/null @@ -1,13 +0,0 @@ -package labels - -import ( - "testing" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestLabels(t *testing.T) { - RegisterFailHandler(Fail) - RunSpecs(t, "Labels Suite") -} From 8ff5db0cbe0ebedb0e74d294933b1521fdaf8139 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Thu, 25 Jun 2026 13:12:39 +0200 Subject: [PATCH 09/41] Clear some TODOs --- cmd/application-load-balancer-controller/main.go | 2 +- pkg/controller/ingress/update.go | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/cmd/application-load-balancer-controller/main.go b/cmd/application-load-balancer-controller/main.go index fea3ff9..7666d42 100644 --- a/cmd/application-load-balancer-controller/main.go +++ b/cmd/application-load-balancer-controller/main.go @@ -40,7 +40,7 @@ type options struct { cloudConfig string } -// nolint:funlen // TODO: Refactor into smaller functions. +// nolint:funlen func main() { var opts options diff --git a/pkg/controller/ingress/update.go b/pkg/controller/ingress/update.go index 7cf7f2d..8bc4704 100644 --- a/pkg/controller/ingress/update.go +++ b/pkg/controller/ingress/update.go @@ -47,7 +47,7 @@ func (r *IngressClassReconciler) applyALB(ctx context.Context, ingressClass *net existingALB = nil } - tree, errs := spec.BuildTree( // TODO: deal with errors + tree, errs := spec.BuildTree( ingressClass, ingresses, secrets, @@ -57,7 +57,6 @@ func (r *IngressClassReconciler) applyALB(ctx context.Context, ingressClass *net ) for _, err := range errs { - ctrl.LoggerFrom(ctx).Info("Recorded ingress event", "event", err.Error()) err.RecordEvent(ingressClass, r.Recorder) } From a74c86da8876781735290662d3f2394d48a85a2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Thu, 25 Jun 2026 13:24:27 +0200 Subject: [PATCH 10/41] Give certificates a proper name --- pkg/controller/ingress/ingressclass_controller.go | 4 ++-- pkg/controller/ingress/update.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/controller/ingress/ingressclass_controller.go b/pkg/controller/ingress/ingressclass_controller.go index 2e30993..8386bc2 100644 --- a/pkg/controller/ingress/ingressclass_controller.go +++ b/pkg/controller/ingress/ingressclass_controller.go @@ -68,8 +68,8 @@ func (r *IngressClassReconciler) Reconcile(ctx context.Context, req ctrl.Request return ctrl.Result{}, nil } - if err := r.applyALB(ctx, ingressClass); err != nil { - return ctrl.Result{}, fmt.Errorf("failed to apply ALB: %w", err) + 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) diff --git a/pkg/controller/ingress/update.go b/pkg/controller/ingress/update.go index 8bc4704..9ab02d3 100644 --- a/pkg/controller/ingress/update.go +++ b/pkg/controller/ingress/update.go @@ -18,7 +18,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" ) -func (r *IngressClassReconciler) applyALB(ctx context.Context, ingressClass *networkingv1.IngressClass) error { +func (r *IngressClassReconciler) reconcileALBResources(ctx context.Context, ingressClass *networkingv1.IngressClass) error { ingresses, err := r.getIngressesForIngressClass(ctx, ingressClass) if err != nil { return fmt.Errorf("failed to get ingresses for class: %w", err) @@ -70,7 +70,7 @@ func (r *IngressClassReconciler) applyALB(ctx context.Context, ingressClass *net missingCertificates := tree.GetMissingCertificates(ingressClassCertificates) for fingerprint, c := range missingCertificates { createCertificatePayload := &certsdk.CreateCertificatePayload{ - Name: new("alb-cert"), // TODO: Add some identifying prefix and shorten it to 63 characters + Name: new("k8s-ingress-" + string(ingressClass.UID)), ProjectId: &r.ALBConfig.Global.ProjectID, PrivateKey: new(c.PrivateKey), PublicKey: new(c.PublicKey), From 6438207f208bcd33c60a66c367149129b0e8078c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Thu, 25 Jun 2026 13:27:10 +0200 Subject: [PATCH 11/41] Remove unused certificates even after creation --- pkg/controller/ingress/update.go | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/pkg/controller/ingress/update.go b/pkg/controller/ingress/update.go index 9ab02d3..36751f3 100644 --- a/pkg/controller/ingress/update.go +++ b/pkg/controller/ingress/update.go @@ -83,7 +83,6 @@ func (r *IngressClassReconciler) reconcileALBResources(ctx context.Context, ingr // TODO: Gracefully deal with errors return fmt.Errorf("failed to create certificate: %w", err) } - // TODO: Check for nil-ness in response ctrl.LoggerFrom(ctx).Info("Created certificate", "id", response.Id, "fingerprint", fingerprint) ingressClassCertificates = append(ingressClassCertificates, *response) } @@ -108,16 +107,15 @@ func (r *IngressClassReconciler) reconcileALBResources(ctx context.Context, ingr return fmt.Errorf("failed to create load balancer: %w", err) } ctrl.LoggerFrom(ctx).Info("Created application load balancer", "name", create.Name, "version", *alb.Version) - return nil // TODO: Early return here prevents certificate clean-up - } - - 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) + } 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) } - ctrl.LoggerFrom(ctx).Info("Updated application load balancer", "name", update.Name, "version", *alb.Version) } for _, cert := range duplicateCerts { From 1c9d8e119e20d539efb17f7053fe6e4ae06e590e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Thu, 25 Jun 2026 13:33:05 +0200 Subject: [PATCH 12/41] Ingress class annotation --- README.md | 202 +------------------- docs/user.md | 203 +++++++++++++++++++++ pkg/controller/ingress/spec/annotations.go | 3 - 3 files changed, 206 insertions(+), 202 deletions(-) create mode 100644 docs/user.md diff --git a/README.md b/README.md index e425b94..dad86cf 100644 --- a/README.md +++ b/README.md @@ -1,201 +1,5 @@ -# Application Load Balancer Controller Manager User Documentation +# Application load balancer (ALB) controller -The STACKIT Application Load Balancer Controller Manager (ALBCM) exposes HTTP/HTTPS applications by provisioning and configuring managed STACKIT ALBs based on native Kubernetes Ingress resources. - -### Enabling the ALB extension -The Application Load Balancer integration is disabled by default and can be activated for your cluster via the SKE-API by setting the enabled field to true inside the applicationLoadBalancer block under extensions: -```JSON -{ - "extensions": { - "applicationLoadBalancer": { - "enabled": true - } - } -} -``` - -### 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* section). - -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: CLusterIP - 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 -``` - -### 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 ordering -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. - -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. - -Note that the top-to-bottom order of paths defined within a single Ingress YAML is non-deterministic. If your application requires strict execution ordering, you must split the rules into separate Ingress resources and assign explicit priority annotations to each. - -### TLS and Certificate Rotation -The minimal Ingress example in the Quick Start section shows a plain, unencrypted 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 -``` - -### 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. | -| `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. | - -### Known Limitations - -#### defaultBackend support -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. +[![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/docs/user.md b/docs/user.md new file mode 100644 index 0000000..143b1e5 --- /dev/null +++ b/docs/user.md @@ -0,0 +1,203 @@ +# Application Load Balancer Controller Manager User Documentation + +The STACKIT Application Load Balancer Controller Manager (ALBCM) exposes HTTP/HTTPS applications by provisioning and configuring managed STACKIT ALBs based on native Kubernetes Ingress resources. + +### Enabling the ALB extension +The Application Load Balancer integration is disabled by default and can be activated for your cluster via the SKE-API by setting the enabled field to true inside the applicationLoadBalancer block under extensions: +```JSON +{ + "extensions": { + "applicationLoadBalancer": { + "enabled": true + } + } +} +``` + +### 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* section). + +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: CLusterIP + 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 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 ordering +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. + +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. + +Note that the top-to-bottom order of paths defined within a single Ingress YAML is non-deterministic. If your application requires strict execution ordering, you must split the rules into separate Ingress resources and assign explicit priority annotations to each. + +### TLS and Certificate Rotation +The minimal Ingress example in the Quick Start section shows a plain, unencrypted 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 +``` + +### 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. | +| `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. | + +### Known Limitations + +#### defaultBackend support +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. + diff --git a/pkg/controller/ingress/spec/annotations.go b/pkg/controller/ingress/spec/annotations.go index e7585cd..9685fd2 100644 --- a/pkg/controller/ingress/spec/annotations.go +++ b/pkg/controller/ingress/spec/annotations.go @@ -55,9 +55,6 @@ const ( // Can be set on IngressClass and Ingress. AnnotationPriority = "alb.stackit.cloud/priority" - // TODO: - AnnotationIngressClassName = "kubernetes.io/ingress.class" - // TODO: source ACL ) From e0a040ea1fc519b9c4d3317fa28a55e509de5542 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Thu, 25 Jun 2026 14:44:58 +0200 Subject: [PATCH 13/41] Support source ranges --- docs/user.md | 1 + .../ingress/ingressclass_controller.go | 2 +- pkg/controller/ingress/spec/annotations.go | 4 +- pkg/controller/ingress/spec/worktree.go | 23 +++++- pkg/controller/ingress/spec/worktree_test.go | 16 +++++ pkg/controller/ingress/update.go | 12 +++- pkg/controller/ingress/update_test.go | 70 +++++++++++++++++++ 7 files changed, 122 insertions(+), 6 deletions(-) diff --git a/docs/user.md b/docs/user.md index 143b1e5..5d01245 100644 --- a/docs/user.md +++ b/docs/user.md @@ -195,6 +195,7 @@ Configure the STACKIT Application Load Balancer using the following annotations. | `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 diff --git a/pkg/controller/ingress/ingressclass_controller.go b/pkg/controller/ingress/ingressclass_controller.go index 8386bc2..d73e215 100644 --- a/pkg/controller/ingress/ingressclass_controller.go +++ b/pkg/controller/ingress/ingressclass_controller.go @@ -58,7 +58,7 @@ func (r *IngressClassReconciler) Reconcile(ctx context.Context, req ctrl.Request return ctrl.Result{}, nil } - // Add finalizer to the IngressClass if not already added + // Add finalizer to the IngressClass if not already added. if controllerutil.AddFinalizer(ingressClass, finalizerName) { err := r.Client.Update(ctx, ingressClass) if err != nil { diff --git a/pkg/controller/ingress/spec/annotations.go b/pkg/controller/ingress/spec/annotations.go index 9685fd2..33bd5f6 100644 --- a/pkg/controller/ingress/spec/annotations.go +++ b/pkg/controller/ingress/spec/annotations.go @@ -55,7 +55,9 @@ const ( // Can be set on IngressClass and Ingress. AnnotationPriority = "alb.stackit.cloud/priority" - // TODO: source ACL + // 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. diff --git a/pkg/controller/ingress/spec/worktree.go b/pkg/controller/ingress/spec/worktree.go index aa0b980..598ef44 100644 --- a/pkg/controller/ingress/spec/worktree.go +++ b/pkg/controller/ingress/spec/worktree.go @@ -9,6 +9,7 @@ import ( "maps" "slices" "strconv" + "strings" corev1 "k8s.io/api/core/v1" networkingv1 "k8s.io/api/networking/v1" @@ -30,9 +31,10 @@ type CertificateFingerprint string // // Look at the methods how a work tree can be used. type WorkTreeALB struct { - ingressClass *networkingv1.IngressClass - planID string - waf string + ingressClass *networkingv1.IngressClass + planID string + waf string + accessControl *albsdk.LoadbalancerOptionAccessControl listeners map[int16]*workTreeListener // We can already create the real type because there is nothing to merge or track. @@ -132,6 +134,8 @@ func BuildTree( certificates: map[CertificateFingerprint]WorkTreeCertificate{}, } + errors = append(errors, addAccessControlToTree(tree, ingressClass)...) + // TODO: Explain sorting slices.SortFunc(ingresses, func(a, b networkingv1.Ingress) int { if diff := GetAnnotation(AnnotationPriority, 0, &a) - GetAnnotation(AnnotationPriority, 0, &b); diff != 0 { @@ -215,6 +219,18 @@ func BuildTree( return tree, errors } +func addAccessControlToTree(tree *WorkTreeALB, ingressClass *networkingv1.IngressClass) []ErrorEvent { + annotation := GetAnnotation(AnnotationAllowedSourceRanges, "", ingressClass) + if annotation == "" { + return nil + } + ranges := strings.Split(annotation, ",") + tree.accessControl = &albsdk.LoadbalancerOptionAccessControl{ + AllowedSourceRanges: ranges, + } + return nil +} + // 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) { @@ -541,6 +557,7 @@ func (t WorkTreeALB) ToCreatePayload( }, Options: &albsdk.LoadBalancerOptions{ EphemeralAddress: new(true), + AccessControl: t.accessControl, // TODO: }, PlanId: &t.planID, diff --git a/pkg/controller/ingress/spec/worktree_test.go b/pkg/controller/ingress/spec/worktree_test.go index c74cd02..585442b 100644 --- a/pkg/controller/ingress/spec/worktree_test.go +++ b/pkg/controller/ingress/spec/worktree_test.go @@ -287,6 +287,22 @@ var _ = Describe("WorkTreeALB", func() { 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")) + }) }) const ( diff --git a/pkg/controller/ingress/update.go b/pkg/controller/ingress/update.go index 36751f3..fccbbda 100644 --- a/pkg/controller/ingress/update.go +++ b/pkg/controller/ingress/update.go @@ -4,6 +4,7 @@ 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" @@ -226,7 +227,7 @@ func (r *IngressClassReconciler) getCertificatesForIngressClass(ctx context.Cont } func updateNeeded(alb *albsdk.LoadBalancer, albPayload *albsdk.UpdateLoadBalancerPayload) bool { - return listenersChanged(alb.Listeners, albPayload.Listeners) || targetPoolsChanged(alb.TargetPools, albPayload.TargetPools) + return listenersChanged(alb.Listeners, albPayload.Listeners) || targetPoolsChanged(alb.TargetPools, albPayload.TargetPools) || optionsChanged(alb.Options, albPayload.Options) } func listenersChanged(current, desired []albsdk.Listener) bool { @@ -322,3 +323,12 @@ func targetPoolsChanged(current, desired []albsdk.TargetPool) bool { } 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 index d031656..3478c80 100644 --- a/pkg/controller/ingress/update_test.go +++ b/pkg/controller/ingress/update_test.go @@ -220,6 +220,76 @@ func Test_updateNeeded(t *testing.T) { }, 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 { From 6f7137e83466e65816d2dc898ee76bee44d65ec3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Thu, 25 Jun 2026 14:51:29 +0200 Subject: [PATCH 14/41] Do a couple of documentation TODOs --- docs/user.md | 4 +++- pkg/controller/ingress/spec/annotations.go | 3 --- pkg/controller/ingress/spec/worktree.go | 2 -- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/docs/user.md b/docs/user.md index 5d01245..01e4738 100644 --- a/docs/user.md +++ b/docs/user.md @@ -95,7 +95,7 @@ You can override this default order by adding the `alb.stackit.cloud/priority` a Note that the top-to-bottom order of paths defined within a single Ingress YAML is non-deterministic. If your application requires strict execution ordering, you must split the rules into separate Ingress resources and assign explicit priority annotations to each. ### TLS and Certificate Rotation -The minimal Ingress example in the Quick Start section shows a plain, unencrypted HTTP configuration. To expose your application securely via HTTPS, the ALB Ingress controller supports TLS termination using standard Kubernetes TLS Secrets. +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. @@ -128,6 +128,8 @@ spec: 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. diff --git a/pkg/controller/ingress/spec/annotations.go b/pkg/controller/ingress/spec/annotations.go index 33bd5f6..aac542a 100644 --- a/pkg/controller/ingress/spec/annotations.go +++ b/pkg/controller/ingress/spec/annotations.go @@ -69,9 +69,6 @@ const ( // For int and bool Atoi and ParseBool are called respectively. // If parsing fails or T is any other type, defaultValue is returned. // Only the latest found value is parsed. -// -// TODO: Return parser errors?! -// TODO: Allow unsetting a value by setting the annotation to an empty string?! func GetAnnotation[T any](annotation string, defaultValue T, objects ...client.Object) T { var rawVal string var found bool diff --git a/pkg/controller/ingress/spec/worktree.go b/pkg/controller/ingress/spec/worktree.go index 598ef44..2c4a999 100644 --- a/pkg/controller/ingress/spec/worktree.go +++ b/pkg/controller/ingress/spec/worktree.go @@ -136,7 +136,6 @@ func BuildTree( errors = append(errors, addAccessControlToTree(tree, ingressClass)...) - // TODO: Explain sorting slices.SortFunc(ingresses, func(a, b networkingv1.Ingress) int { if diff := GetAnnotation(AnnotationPriority, 0, &a) - GetAnnotation(AnnotationPriority, 0, &b); diff != 0 { return diff @@ -149,7 +148,6 @@ func BuildTree( }) for _, ingress := range ingresses { for tlsIndex, tls := range ingress.Spec.TLS { - // TODO: document that the host field is completely ignored secret, exists := secretsMap[types.NamespacedName{Namespace: ingress.Namespace, Name: tls.SecretName}] if !exists { errors = append(errors, ErrorEvent{ From 0a0ae5c0dec6b2fd2a3a8c3a8a44917848c3064b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Thu, 25 Jun 2026 15:43:20 +0200 Subject: [PATCH 15/41] Implement target pool limit --- docs/user.md | 2 +- pkg/controller/ingress/spec/events.go | 18 +++--- pkg/controller/ingress/spec/limits.go | 3 + pkg/controller/ingress/spec/worktree.go | 64 +++++++++++--------- pkg/controller/ingress/spec/worktree_test.go | 61 +++++++++++++++++-- pkg/testutil/testutil.go | 7 +++ 6 files changed, 112 insertions(+), 43 deletions(-) create mode 100644 pkg/controller/ingress/spec/limits.go diff --git a/docs/user.md b/docs/user.md index 01e4738..e6091c2 100644 --- a/docs/user.md +++ b/docs/user.md @@ -188,7 +188,7 @@ Configure the STACKIT Application Load Balancer using the following annotations. | `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. | +| `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). | diff --git a/pkg/controller/ingress/spec/events.go b/pkg/controller/ingress/spec/events.go index 079d45c..8b44385 100644 --- a/pkg/controller/ingress/spec/events.go +++ b/pkg/controller/ingress/spec/events.go @@ -11,23 +11,23 @@ import ( ) type ErrorEvent struct { - ingress client.Object - description string - fieldPath *field.Path + 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) + if e.FieldPath != nil { + return fmt.Sprintf("%s: %s", e.FieldPath.String(), e.Description) } - return e.description + return e.Description } func (e *ErrorEvent) RecordEvent(class *networkingv1.IngressClass, recorder record.EventRecorder) { - if e.ingress.GetName() == "" { + 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()) + 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/limits.go b/pkg/controller/ingress/spec/limits.go new file mode 100644 index 0000000..af6e7c3 --- /dev/null +++ b/pkg/controller/ingress/spec/limits.go @@ -0,0 +1,3 @@ +package spec + +const LimitTargetPools = 20 diff --git a/pkg/controller/ingress/spec/worktree.go b/pkg/controller/ingress/spec/worktree.go index 2c4a999..a257a20 100644 --- a/pkg/controller/ingress/spec/worktree.go +++ b/pkg/controller/ingress/spec/worktree.go @@ -137,7 +137,7 @@ func BuildTree( errors = append(errors, addAccessControlToTree(tree, ingressClass)...) slices.SortFunc(ingresses, func(a, b networkingv1.Ingress) int { - if diff := GetAnnotation(AnnotationPriority, 0, &a) - GetAnnotation(AnnotationPriority, 0, &b); diff != 0 { + if diff := GetAnnotation(AnnotationPriority, 0, &b) - GetAnnotation(AnnotationPriority, 0, &a); diff != 0 { return diff } if diff := a.CreationTimestamp.Compare(b.CreationTimestamp.Time); diff != 0 { @@ -151,17 +151,17 @@ func BuildTree( 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", + 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", + Ingress: &ingress, + FieldPath: field.NewPath("spec", "tls").Index(tlsIndex).Child("secretName"), + Description: "TLS secret isn't of type kubernetes.io/tls", }) continue } @@ -169,9 +169,9 @@ func BuildTree( 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()), + Ingress: &ingress, + FieldPath: field.NewPath("spec", "tls").Index(tlsIndex).Child("secretName"), + Description: fmt.Sprintf("invalid certificate: %s", err.Error()), }) continue } @@ -245,9 +245,9 @@ func addPathToTree(tree *WorkTreeALB, ingressClass *networkingv1.IngressClass, i 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), + 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 } @@ -263,9 +263,9 @@ func addPathToTree(tree *WorkTreeALB, ingressClass *networkingv1.IngressClass, i albPath, exists := host.paths[_pathWithType] if exists && albPath.ingressPathReference == ingressPathReference { errors = append(errors, ErrorEvent{ - ingress: ingress, - fieldPath: field.NewPath("spec", "rules", strconv.Itoa(ruleIndex), "path", strconv.Itoa(pathIndex)), - description: "Path already exists", + Ingress: ingress, + FieldPath: field.NewPath("spec", "rules", strconv.Itoa(ruleIndex), "path", strconv.Itoa(pathIndex)), + Description: "Path already exists", }) return false, errors } @@ -298,7 +298,13 @@ func buildTargetPool(tree *WorkTreeALB, targets []albsdk.Target, ingress network _, exists := tree.targetPools[ingressPathReference] if !exists { - // TODO: check limits. + 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{} @@ -307,17 +313,17 @@ func buildTargetPool(tree *WorkTreeALB, targets []albsdk.Target, ingress network 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", + 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", + 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 } @@ -327,9 +333,9 @@ func buildTargetPool(tree *WorkTreeALB, targets []albsdk.Target, ingress network 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", + 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 } @@ -338,9 +344,9 @@ func buildTargetPool(tree *WorkTreeALB, targets []albsdk.Target, ingress network } 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", + 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 } diff --git a/pkg/controller/ingress/spec/worktree_test.go b/pkg/controller/ingress/spec/worktree_test.go index 585442b..e089cb6 100644 --- a/pkg/controller/ingress/spec/worktree_test.go +++ b/pkg/controller/ingress/spec/worktree_test.go @@ -1,14 +1,19 @@ 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/testutil" . "github.com/stackitcloud/application-load-balancer-controller/pkg/testutil/ingress" . "github.com/stackitcloud/application-load-balancer-controller/pkg/testutil/service" 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() { @@ -108,7 +113,7 @@ var _ = Describe("WorkTreeALB", func() { ) Expect(errs).To(HaveLen(1)) - Expect(errs[0].description).To(Equal("TLS secret doesn't exist")) + 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() { @@ -126,7 +131,7 @@ var _ = Describe("WorkTreeALB", func() { ) Expect(errs).To(HaveLen(1)) - Expect(errs[0].description).To(Equal("TLS secret isn't of type kubernetes.io/tls")) + 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() { @@ -144,7 +149,7 @@ var _ = Describe("WorkTreeALB", func() { ) Expect(errs).To(HaveLen(1)) - Expect(errs[0].description).To(Equal("TLS secret isn't of type kubernetes.io/tls")) + 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() { @@ -166,7 +171,7 @@ var _ = Describe("WorkTreeALB", func() { ) Expect(errs).To(HaveLen(1)) - Expect(errs[0].description).To(Equal("invalid certificate: tls: failed to find any PEM data in certificate input")) + Expect(errs[0].Description).To(Equal("invalid certificate: tls: failed to find any PEM data in certificate input")) }) It("should process TLS secret correctly", func() { @@ -303,6 +308,54 @@ var _ = Describe("WorkTreeALB", func() { 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 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}), + ))) + } + tree, errs := BuildTree( + &networkingv1.IngressClass{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + AnnotationAllowedSourceRanges: "10.0.0.0/24,1.2.3.4/32", + }, + }, + }, 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)), + }), + )) + create := tree.ToCreatePayload(nil, "network-id", "region") + Expect(create.Options.AccessControl.AllowedSourceRanges).To(HaveExactElements("10.0.0.0/24", "1.2.3.4/32")) + }) }) const ( diff --git a/pkg/testutil/testutil.go b/pkg/testutil/testutil.go index b00f4cd..83ceacc 100644 --- a/pkg/testutil/testutil.go +++ b/pkg/testutil/testutil.go @@ -25,3 +25,10 @@ func HaveAtomicValue[T any](matcher types.GomegaMatcher) types.GomegaMatcher { 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)) +} From 5675ad5f1c841862aa3cdbd5c17a6b36459b64c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Thu, 25 Jun 2026 15:56:33 +0200 Subject: [PATCH 16/41] Document target pool limits --- pkg/controller/ingress/spec/limits.go | 2 ++ pkg/controller/ingress/spec/worktree.go | 1 - 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/controller/ingress/spec/limits.go b/pkg/controller/ingress/spec/limits.go index af6e7c3..2d88e34 100644 --- a/pkg/controller/ingress/spec/limits.go +++ b/pkg/controller/ingress/spec/limits.go @@ -1,3 +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/worktree.go b/pkg/controller/ingress/spec/worktree.go index a257a20..6698d76 100644 --- a/pkg/controller/ingress/spec/worktree.go +++ b/pkg/controller/ingress/spec/worktree.go @@ -274,7 +274,6 @@ func addPathToTree(tree *WorkTreeALB, ingressClass *networkingv1.IngressClass, i path: _pathWithType, ingressPathReference: ingressPathReference, } - // TODO: check limits } albPath.websocket = GetAnnotation(AnnotationWebSocket, false, ingress, ingressClass) From c23a556be1b67c8062113ca646f2324ed7db9d49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Thu, 25 Jun 2026 16:03:40 +0200 Subject: [PATCH 17/41] Fix test --- pkg/controller/ingress/spec/worktree_test.go | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/pkg/controller/ingress/spec/worktree_test.go b/pkg/controller/ingress/spec/worktree_test.go index e089cb6..c769885 100644 --- a/pkg/controller/ingress/spec/worktree_test.go +++ b/pkg/controller/ingress/spec/worktree_test.go @@ -319,14 +319,8 @@ var _ = Describe("WorkTreeALB", func() { WithPath(fmt.Sprintf("/%d", i*3+2), new(networkingv1.PathTypeExact), "my-service", networkingv1.ServiceBackendPort{Number: 80}), ))) } - tree, errs := BuildTree( - &networkingv1.IngressClass{ - ObjectMeta: metav1.ObjectMeta{ - Annotations: map[string]string{ - AnnotationAllowedSourceRanges: "10.0.0.0/24,1.2.3.4/32", - }, - }, - }, ingresses, nil, []corev1.Service{ + _, 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, ) @@ -353,8 +347,6 @@ var _ = Describe("WorkTreeALB", func() { "FieldPath": Equal(field.NewPath("spec", "rules").Index(0).Child("paths").Index(2)), }), )) - create := tree.ToCreatePayload(nil, "network-id", "region") - Expect(create.Options.AccessControl.AllowedSourceRanges).To(HaveExactElements("10.0.0.0/24", "1.2.3.4/32")) }) }) From d539b21a1a2b84fbed6ca9f818f18ed60682d01d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Thu, 25 Jun 2026 16:19:14 +0200 Subject: [PATCH 18/41] Support internal ALBs --- pkg/controller/ingress/spec/worktree.go | 7 +++++-- pkg/controller/ingress/spec/worktree_test.go | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/pkg/controller/ingress/spec/worktree.go b/pkg/controller/ingress/spec/worktree.go index 6698d76..98269ea 100644 --- a/pkg/controller/ingress/spec/worktree.go +++ b/pkg/controller/ingress/spec/worktree.go @@ -35,6 +35,7 @@ type WorkTreeALB struct { planID string waf string accessControl *albsdk.LoadbalancerOptionAccessControl + internalLB bool listeners map[int16]*workTreeListener // We can already create the real type because there is nothing to merge or track. @@ -127,6 +128,7 @@ func BuildTree( ingressClass: ingressClass, planID: GetAnnotation(AnnotationPlanID, "", ingressClass), waf: GetAnnotation(AnnotationWAFName, "", ingressClass), + internalLB: GetAnnotation(AnnotationInternal, false, ingressClass), listeners: map[int16]*workTreeListener{}, targetPools: map[ingressPathReference]*albsdk.TargetPool{}, @@ -559,8 +561,9 @@ func (t WorkTreeALB) ToCreatePayload( }, }, Options: &albsdk.LoadBalancerOptions{ - EphemeralAddress: new(true), - AccessControl: t.accessControl, + EphemeralAddress: new(true), + AccessControl: t.accessControl, + PrivateNetworkOnly: new(t.internalLB), // TODO: }, PlanId: &t.planID, diff --git a/pkg/controller/ingress/spec/worktree_test.go b/pkg/controller/ingress/spec/worktree_test.go index c769885..3821c22 100644 --- a/pkg/controller/ingress/spec/worktree_test.go +++ b/pkg/controller/ingress/spec/worktree_test.go @@ -309,6 +309,22 @@ var _ = Describe("WorkTreeALB", func() { 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 return errors for paths that exceed the target pool limit", func() { ingresses := []networkingv1.Ingress{} for i := range 8 { // 8 * 3 paths = 24 From 209f335c6d5a04ef348bd382fa4ba812eeb763bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Thu, 25 Jun 2026 16:33:20 +0200 Subject: [PATCH 19/41] Support static LBs --- pkg/controller/ingress/spec/worktree.go | 10 +++++++++- pkg/controller/ingress/spec/worktree_test.go | 17 +++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/pkg/controller/ingress/spec/worktree.go b/pkg/controller/ingress/spec/worktree.go index 98269ea..30223c2 100644 --- a/pkg/controller/ingress/spec/worktree.go +++ b/pkg/controller/ingress/spec/worktree.go @@ -36,6 +36,7 @@ type WorkTreeALB struct { 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. @@ -129,6 +130,7 @@ func BuildTree( 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{}, @@ -546,6 +548,11 @@ func (t WorkTreeALB) ToCreatePayload( return cmp.Compare(*a.TargetPort, *b.TargetPort) }) + 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)), @@ -560,8 +567,9 @@ func (t WorkTreeALB) ToCreatePayload( Role: new("ROLE_LISTENERS_AND_TARGETS"), }, }, + ExternalAddress: externalAddress, Options: &albsdk.LoadBalancerOptions{ - EphemeralAddress: new(true), + EphemeralAddress: new(t.externalIP == ""), AccessControl: t.accessControl, PrivateNetworkOnly: new(t.internalLB), // TODO: diff --git a/pkg/controller/ingress/spec/worktree_test.go b/pkg/controller/ingress/spec/worktree_test.go index 3821c22..bf9e7b8 100644 --- a/pkg/controller/ingress/spec/worktree_test.go +++ b/pkg/controller/ingress/spec/worktree_test.go @@ -325,6 +325,23 @@ var _ = Describe("WorkTreeALB", func() { 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 From dab4dd0f2ae983a31fe00743ab1e49e34e2390cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Thu, 25 Jun 2026 16:41:28 +0200 Subject: [PATCH 20/41] Use index for paths --- pkg/controller/ingress/spec/worktree.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pkg/controller/ingress/spec/worktree.go b/pkg/controller/ingress/spec/worktree.go index 30223c2..1b1b882 100644 --- a/pkg/controller/ingress/spec/worktree.go +++ b/pkg/controller/ingress/spec/worktree.go @@ -8,7 +8,6 @@ import ( "fmt" "maps" "slices" - "strconv" "strings" corev1 "k8s.io/api/core/v1" @@ -268,7 +267,7 @@ func addPathToTree(tree *WorkTreeALB, ingressClass *networkingv1.IngressClass, i if exists && albPath.ingressPathReference == ingressPathReference { errors = append(errors, ErrorEvent{ Ingress: ingress, - FieldPath: field.NewPath("spec", "rules", strconv.Itoa(ruleIndex), "path", strconv.Itoa(pathIndex)), + FieldPath: field.NewPath("spec", "rules").Index(ruleIndex).Child("path").Index(pathIndex), Description: "Path already exists", }) return false, errors From 49aebafa14d190b5ce6ee713ba65917209c9d4a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Thu, 25 Jun 2026 17:14:33 +0200 Subject: [PATCH 21/41] Support TLS bridging --- pkg/controller/ingress/spec/worktree.go | 13 ++++- pkg/controller/ingress/spec/worktree_test.go | 59 +++++++++++++++++--- pkg/testutil/service/service.go | 11 +++- 3 files changed, 70 insertions(+), 13 deletions(-) diff --git a/pkg/controller/ingress/spec/worktree.go b/pkg/controller/ingress/spec/worktree.go index 1b1b882..b4736eb 100644 --- a/pkg/controller/ingress/spec/worktree.go +++ b/pkg/controller/ingress/spec/worktree.go @@ -193,7 +193,7 @@ func BuildTree( httpPort := GetAnnotation(AnnotationHTTPPort, 80, &ingress, ingressClass) httpsPort := GetAnnotation(AnnotationHTTPSPort, 443, &ingress, ingressClass) - targetPool, e := buildTargetPool(tree, targets, ingress, rule, ruleIndex, path, pathIndex, servicesMap) + targetPool, e := buildTargetPool(tree, ingressClass, targets, ingress, rule, ruleIndex, path, pathIndex, servicesMap) errors = append(errors, e...) if targetPool == nil { continue // If the target pool is invalid we do not add any rules. @@ -293,7 +293,7 @@ func addPathToTree(tree *WorkTreeALB, ingressClass *networkingv1.IngressClass, i // // 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, targets []albsdk.Target, ingress networkingv1.Ingress, rule networkingv1.IngressRule, ruleIndex int, path networkingv1.HTTPIngressPath, pathIndex int, servicesMap map[types.NamespacedName]corev1.Service) (*albsdk.TargetPool, []ErrorEvent) { +func buildTargetPool(tree *WorkTreeALB, ingressClass *networkingv1.IngressClass, targets []albsdk.Target, ingress networkingv1.Ingress, rule networkingv1.IngressRule, 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} @@ -356,6 +356,13 @@ func buildTargetPool(tree *WorkTreeALB, targets []albsdk.Target, ingress network 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) + } // TODO: Use TCP health checks for eTP=Cluster if service.Spec.ExternalTrafficPolicy == corev1.ServiceExternalTrafficPolicyLocal { targetPool.ActiveHealthCheck = &albsdk.ActiveHealthCheck{ @@ -544,7 +551,7 @@ func (t WorkTreeALB) ToCreatePayload( targetPools = append(targetPools, *targetPool) } slices.SortFunc(targetPools, func(a, b albsdk.TargetPool) int { - return cmp.Compare(*a.TargetPort, *b.TargetPort) + return cmp.Compare(*a.Name, *b.Name) }) var externalAddress *string diff --git a/pkg/controller/ingress/spec/worktree_test.go b/pkg/controller/ingress/spec/worktree_test.go index bf9e7b8..e62de9d 100644 --- a/pkg/controller/ingress/spec/worktree_test.go +++ b/pkg/controller/ingress/spec/worktree_test.go @@ -54,38 +54,40 @@ var _ = Describe("WorkTreeALB", func() { It("should match rules against correct node ports", func() { const host = "my-host.local" - tree, _ := BuildTree(&networkingv1.IngressClass{}, []networkingv1.Ingress{ + tree, errs := BuildTree(&networkingv1.IngressClass{}, []networkingv1.Ingress{ Ingress( - "default", "ingress-to-node-port-5000", + "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", + "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", + "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", + "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", + 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", + 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 target ports. + // 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)))) @@ -381,6 +383,47 @@ var _ = Describe("WorkTreeALB", func() { }), )) }) + + 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())) + }) }) const ( diff --git a/pkg/testutil/service/service.go b/pkg/testutil/service/service.go index 40728bb..33b5bf5 100644 --- a/pkg/testutil/service/service.go +++ b/pkg/testutil/service/service.go @@ -8,8 +8,9 @@ import ( func Service(namespace, name string, opts ...ServiceOption) corev1.Service { service := corev1.Service{ ObjectMeta: metav1.ObjectMeta{ - Namespace: namespace, - Name: name, + Namespace: namespace, + Name: name, + Annotations: map[string]string{}, }, Spec: corev1.ServiceSpec{ Ports: []corev1.ServicePort{}, @@ -47,3 +48,9 @@ func WithServiceType(_type corev1.ServiceType) ServiceOption { service.Spec.Type = _type }) } + +func WithServiceAnnotation(key, value string) ServiceOption { + return serviceOptionFunc(func(service *corev1.Service) { + service.Annotations[key] = value + }) +} From 0a2244acb680a4868775723bb16bf8babb217258 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Thu, 25 Jun 2026 17:23:13 +0200 Subject: [PATCH 22/41] Refine health checks --- pkg/controller/ingress/spec/worktree.go | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/pkg/controller/ingress/spec/worktree.go b/pkg/controller/ingress/spec/worktree.go index b4736eb..d0513b2 100644 --- a/pkg/controller/ingress/spec/worktree.go +++ b/pkg/controller/ingress/spec/worktree.go @@ -363,7 +363,7 @@ func buildTargetPool(tree *WorkTreeALB, ingressClass *networkingv1.IngressClass, if ca := GetAnnotation(AnnotationTargetPoolTLSCustomCa, "", &service, &ingress, ingressClass); ca != "" { targetPool.TlsConfig.CustomCa = new(ca) } - // TODO: Use TCP health checks for eTP=Cluster + // 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, @@ -373,10 +373,9 @@ func buildTargetPool(tree *WorkTreeALB, ingressClass *networkingv1.IngressClass, }, HealthyThreshold: new(int32(1)), Interval: new("5s"), - IntervalJitter: new("1s"), - Timeout: new("1s"), - UnhealthyThreshold: new(int32(2)), - // TODO: Optimize interval etc. + IntervalJitter: new("0s"), + Timeout: new("3s"), + UnhealthyThreshold: new(int32(3)), } } From 6d897bde82539b6de242072eb67b213a977f9542 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Thu, 25 Jun 2026 17:24:17 +0200 Subject: [PATCH 23/41] Remove TODO --- pkg/controller/ingress/spec/worktree.go | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/controller/ingress/spec/worktree.go b/pkg/controller/ingress/spec/worktree.go index d0513b2..16f5760 100644 --- a/pkg/controller/ingress/spec/worktree.go +++ b/pkg/controller/ingress/spec/worktree.go @@ -564,7 +564,6 @@ func (t WorkTreeALB) ToCreatePayload( Labels: &map[string]string{ "ingress-class-uid": string(t.ingressClass.UID), }, - // TODO: Support static IP and promotion but not demotion Listeners: listeners, Networks: []albsdk.Network{ { From 34bef9f1fe1f27fa45eefa869b04994ef2dfbd28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Thu, 25 Jun 2026 17:25:54 +0200 Subject: [PATCH 24/41] Adjust TODO comments --- pkg/controller/ingress/spec/worktree.go | 3 +-- pkg/stackit/applicationloadbalancercertificates.go | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/pkg/controller/ingress/spec/worktree.go b/pkg/controller/ingress/spec/worktree.go index 16f5760..aeee255 100644 --- a/pkg/controller/ingress/spec/worktree.go +++ b/pkg/controller/ingress/spec/worktree.go @@ -559,7 +559,7 @@ func (t WorkTreeALB) ToCreatePayload( } return &albsdk.CreateLoadBalancerPayload{ - DisableTargetSecurityGroupAssignment: new(true), // TODO: Make this configurable via flag + 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), @@ -576,7 +576,6 @@ func (t WorkTreeALB) ToCreatePayload( EphemeralAddress: new(t.externalIP == ""), AccessControl: t.accessControl, PrivateNetworkOnly: new(t.internalLB), - // TODO: }, PlanId: &t.planID, Region: new(region), diff --git a/pkg/stackit/applicationloadbalancercertificates.go b/pkg/stackit/applicationloadbalancercertificates.go index 2a5b2b0..ce777a2 100644 --- a/pkg/stackit/applicationloadbalancercertificates.go +++ b/pkg/stackit/applicationloadbalancercertificates.go @@ -7,7 +7,7 @@ import ( ) type CertificatesClient interface { - // TODO: hard-code region and project into client + // 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) From 6cf173078d73626b7c915b1e4909d9318daacb17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Fri, 26 Jun 2026 08:48:17 +0200 Subject: [PATCH 25/41] Support out-of-band log configuration --- pkg/controller/ingress/spec/worktree.go | 8 +++++++- pkg/controller/ingress/spec/worktree_test.go | 21 ++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/pkg/controller/ingress/spec/worktree.go b/pkg/controller/ingress/spec/worktree.go index aeee255..99fb6ea 100644 --- a/pkg/controller/ingress/spec/worktree.go +++ b/pkg/controller/ingress/spec/worktree.go @@ -588,6 +588,8 @@ func (t WorkTreeALB) ToCreatePayload( // // 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, @@ -596,7 +598,11 @@ func (t WorkTreeALB) ToUpdatePayload( ) *albsdk.UpdateLoadBalancerPayload { create := t.ToCreatePayload(certificateIDMap, networkID, region) update := new(albsdk.UpdateLoadBalancerPayload(*create)) - // TODO: Take observability log config from existing LB. + 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 index e62de9d..398bd02 100644 --- a/pkg/controller/ingress/spec/worktree_test.go +++ b/pkg/controller/ingress/spec/worktree_test.go @@ -9,6 +9,7 @@ import ( "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" @@ -424,6 +425,26 @@ var _ = Describe("WorkTreeALB", func() { 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 balance", 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"))) + }) }) const ( From 7e56834d34c6a6d79ebf34460e451d571655888b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Fri, 26 Jun 2026 10:21:12 +0200 Subject: [PATCH 26/41] Define semantics for implementation-specific path --- docs/user.md | 46 ++++++++++++-------- pkg/controller/ingress/spec/worktree.go | 10 +++-- pkg/controller/ingress/spec/worktree_test.go | 19 ++++++++ 3 files changed, 53 insertions(+), 22 deletions(-) diff --git a/docs/user.md b/docs/user.md index e6091c2..c19d184 100644 --- a/docs/user.md +++ b/docs/user.md @@ -1,23 +1,13 @@ -# Application Load Balancer Controller Manager User Documentation - -The STACKIT Application Load Balancer Controller Manager (ALBCM) exposes HTTP/HTTPS applications by provisioning and configuring managed STACKIT ALBs based on native Kubernetes Ingress resources. - -### Enabling the ALB extension -The Application Load Balancer integration is disabled by default and can be activated for your cluster via the SKE-API by setting the enabled field to true inside the applicationLoadBalancer block under extensions: -```JSON -{ - "extensions": { - "applicationLoadBalancer": { - "enabled": true - } - } -} -``` +# 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* section). If no Ingress resources are currently linked to this class, the ALB acts as an empty listener that returns an HTTP 404 Not Found. @@ -36,6 +26,7 @@ spec: ``` #### The backend (Service) + Expose your application pods using a Kubernetes Service. ```YAML @@ -57,6 +48,7 @@ spec: ``` #### 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 @@ -80,21 +72,29 @@ spec: 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 ordering -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. +### 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. -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. +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 the top-to-bottom order of paths defined within a single Ingress YAML is non-deterministic. If your application requires strict execution ordering, you must split the rules into separate Ingress resources and assign explicit priority annotations to each. +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. @@ -131,12 +131,15 @@ spec: 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. @@ -169,17 +172,21 @@ spec: ``` ### 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 | @@ -202,5 +209,6 @@ Configure the STACKIT Application Load Balancer using the following annotations. ### Known Limitations #### defaultBackend support + 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. diff --git a/pkg/controller/ingress/spec/worktree.go b/pkg/controller/ingress/spec/worktree.go index 99fb6ea..baa5838 100644 --- a/pkg/controller/ingress/spec/worktree.go +++ b/pkg/controller/ingress/spec/worktree.go @@ -262,7 +262,6 @@ func addPathToTree(tree *WorkTreeALB, ingressClass *networkingv1.IngressClass, i } } - // TODO: Define a semantic for ImplementationSpecific path. According to spec it MUST be supported. albPath, exists := host.paths[_pathWithType] if exists && albPath.ingressPathReference == ingressPathReference { errors = append(errors, ErrorEvent{ @@ -457,8 +456,9 @@ func (t WorkTreeALB) ToCreatePayload( for hostname, host := range listener.hosts { paths := slices.Collect(maps.Values(host.paths)) typeRank := map[networkingv1.PathType]int{ - networkingv1.PathTypeExact: 1, - networkingv1.PathTypePrefix: 2, + 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 { @@ -481,6 +481,10 @@ func (t WorkTreeALB) ToCreatePayload( 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), diff --git a/pkg/controller/ingress/spec/worktree_test.go b/pkg/controller/ingress/spec/worktree_test.go index 398bd02..382c668 100644 --- a/pkg/controller/ingress/spec/worktree_test.go +++ b/pkg/controller/ingress/spec/worktree_test.go @@ -445,6 +445,25 @@ var _ = Describe("WorkTreeALB", func() { 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 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"))) + }) }) const ( From 9488817b6b57b7a61f692fa46fc74df86d6e3482 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Fri, 26 Jun 2026 10:30:39 +0200 Subject: [PATCH 27/41] Update test for rule ordering --- pkg/controller/ingress/spec/worktree_test.go | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/pkg/controller/ingress/spec/worktree_test.go b/pkg/controller/ingress/spec/worktree_test.go index 382c668..f77fdc6 100644 --- a/pkg/controller/ingress/spec/worktree_test.go +++ b/pkg/controller/ingress/spec/worktree_test.go @@ -38,19 +38,26 @@ var _ = Describe("WorkTreeALB", func() { 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(6)) + 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.Prefix).To(HaveValue(Equal("/prefix/a"))) - Expect(createPayload.Listeners[0].Http.Hosts[0].Rules[5].Path.Prefix).To(HaveValue(Equal("/prefix/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() { From b75661e434c0d7fd9a6d5b25ddf99ef59b97c75d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Fri, 26 Jun 2026 10:50:58 +0200 Subject: [PATCH 28/41] Add test for update version --- pkg/controller/ingress/spec/worktree_test.go | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/pkg/controller/ingress/spec/worktree_test.go b/pkg/controller/ingress/spec/worktree_test.go index f77fdc6..abf6551 100644 --- a/pkg/controller/ingress/spec/worktree_test.go +++ b/pkg/controller/ingress/spec/worktree_test.go @@ -433,7 +433,7 @@ var _ = Describe("WorkTreeALB", func() { Expect(create.TargetPools[4].TlsConfig.SkipCertificateValidation).To(HaveValue(BeTrue())) }) - It("should use the log configuration from the existing load balance", func() { + 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{ @@ -453,6 +453,18 @@ var _ = Describe("WorkTreeALB", func() { 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{}, From d68811b72b0f9e552cae244a20acb593b5e94d08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Fri, 26 Jun 2026 11:17:12 +0200 Subject: [PATCH 29/41] Fix linter issues --- .golangci.yaml | 1 + .../main.go | 2 +- .../ingress/ingressclass_controller.go | 10 +-- .../ingress/ingressclass_controller_test.go | 6 +- pkg/controller/ingress/setup.go | 7 +- pkg/controller/ingress/spec/worktree.go | 70 ++++++++++--------- pkg/controller/ingress/update.go | 8 ++- pkg/testutil/ingress/ingress.go | 2 + pkg/testutil/service/service.go | 2 + 9 files changed, 60 insertions(+), 48 deletions(-) diff --git a/.golangci.yaml b/.golangci.yaml index 90e8a70..a41069f 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -106,6 +106,7 @@ linters: - goconst - noctx - dupl + - funlen path: _test\.go - linters: - gocritic diff --git a/cmd/application-load-balancer-controller/main.go b/cmd/application-load-balancer-controller/main.go index 7666d42..f859743 100644 --- a/cmd/application-load-balancer-controller/main.go +++ b/cmd/application-load-balancer-controller/main.go @@ -40,7 +40,7 @@ type options struct { cloudConfig string } -// nolint:funlen +// nolint:funlen // This function isn't awfully complex. func main() { var opts options diff --git a/pkg/controller/ingress/ingressclass_controller.go b/pkg/controller/ingress/ingressclass_controller.go index d73e215..e94f969 100644 --- a/pkg/controller/ingress/ingressclass_controller.go +++ b/pkg/controller/ingress/ingressclass_controller.go @@ -110,7 +110,8 @@ func (r *IngressClassReconciler) updateStatus(ctx context.Context, ingressClass return ctrl.Result{}, fmt.Errorf("failed to get ingresses: %w", err) } - for _, ingress := range ingresses { + for i := range ingresses { + ingress := &ingresses[i] before := ingress.DeepCopy() ingress.Status.LoadBalancer.Ingress = []networkingv1.IngressLoadBalancerIngress{ @@ -123,7 +124,7 @@ func (r *IngressClassReconciler) updateStatus(ctx context.Context, ingressClass continue } patch := client.MergeFrom(before) - if err := r.Client.Status().Patch(ctx, &ingress, patch); err != nil { + if err := r.Client.Status().Patch(ctx, ingress, patch); err != nil { return ctrl.Result{}, fmt.Errorf("failed to patch ingress status object: %w", err) } } @@ -151,7 +152,8 @@ func (r *IngressClassReconciler) handleIngressClassDeletion( return err } - for _, ingress := range ingresses { + for i := range ingresses { + ingress := &ingresses[i] before := ingress.DeepCopy() ingress.Status.LoadBalancer.Ingress = []networkingv1.IngressLoadBalancerIngress{} @@ -160,7 +162,7 @@ func (r *IngressClassReconciler) handleIngressClassDeletion( continue } patch := client.MergeFrom(before) - if err := r.Client.Status().Patch(ctx, &ingress, patch); err != nil { + if err := r.Client.Status().Patch(ctx, ingress, patch); err != nil { return fmt.Errorf("failed to patch shoot object: %w", err) } } diff --git a/pkg/controller/ingress/ingressclass_controller_test.go b/pkg/controller/ingress/ingressclass_controller_test.go index 8629f90..ddb207b 100644 --- a/pkg/controller/ingress/ingressclass_controller_test.go +++ b/pkg/controller/ingress/ingressclass_controller_test.go @@ -150,7 +150,7 @@ var _ = Describe("IngressClassController", func() { }) It("should create an empty ALB for an ingress class matching the controller", func(ctx context.Context) { - var getLoadBalancerResponse *atomic.Pointer[albsdk.LoadBalancer] = &atomic.Pointer[albsdk.LoadBalancer]{} + getLoadBalancerResponse := &atomic.Pointer[albsdk.LoadBalancer]{} certClient.EXPECT().ListCertificate(gomock.Any(), gomock.Any(), gomock.Any()).Return(new(certsdk.ListCertificatesResponse{ Items: []certsdk.GetCertificateResponse{}, }), nil).AnyTimes() @@ -243,7 +243,7 @@ var _ = Describe("IngressClassController", func() { 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, projectID, region string, certificate *certsdk.CreateCertificatePayload) (*certsdk.GetCertificateResponse, error) { + 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) @@ -261,7 +261,7 @@ var _ = Describe("IngressClassController", func() { }) return &response, nil }).Times(1) - albClient.EXPECT().UpdateLoadBalancer(gomock.Any(), projectID, region, gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, projectID, region, name string, update *albsdk.UpdateLoadBalancerPayload) (*albsdk.LoadBalancer, error) { + 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") diff --git a/pkg/controller/ingress/setup.go b/pkg/controller/ingress/setup.go index 163a2f5..0d8bf76 100644 --- a/pkg/controller/ingress/setup.go +++ b/pkg/controller/ingress/setup.go @@ -82,7 +82,8 @@ func secretEventHandler(c client.Client) handler.EventHandler { } classNames := make(map[string]struct{}) - for _, ingress := range ingressList.Items { + for i := range ingressList.Items { + ingress := ingressList.Items[i] if ingress.Spec.IngressClassName == nil { continue } @@ -121,7 +122,7 @@ func serviceEventHandler(c client.Client) handler.EventHandler { } ingresses := &networkingv1.IngressList{} - err := c.List(context.Background(), ingresses, client.InNamespace(service.Namespace), client.MatchingFields{fieldIndexService: service.Name}) + err := c.List(ctx, ingresses, client.InNamespace(service.Namespace), client.MatchingFields{fieldIndexService: service.Name}) if err != nil { return nil } @@ -137,7 +138,7 @@ func serviceEventHandler(c client.Client) handler.EventHandler { reqs := []ctrl.Request{} for className := range classes { class := &networkingv1.IngressClass{} - if err := c.Get(context.Background(), types.NamespacedName{Name: className}, class); err != nil { + if err := c.Get(ctx, types.NamespacedName{Name: className}, class); err != nil { continue } if class.Spec.Controller == controllerName { diff --git a/pkg/controller/ingress/spec/worktree.go b/pkg/controller/ingress/spec/worktree.go index baa5838..d9d025d 100644 --- a/pkg/controller/ingress/spec/worktree.go +++ b/pkg/controller/ingress/spec/worktree.go @@ -76,7 +76,7 @@ type ingressPathReference struct { // 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 { +func (i *ingressPathReference) toTargetPoolName() string { return fmt.Sprintf("%s-%d-%d", i.uid, i.ruleIndex, i.pathIndex) } @@ -103,7 +103,7 @@ type WorkTreeCertificate struct { // I.e. all ingresses will be processed regardless of their ingress class reference. // // This function changes the order of the slice ingresses. -func BuildTree( +func BuildTree( //nolint:gocyclo,funlen // Breaking up this function won't make it much simpler. ingressClass *networkingv1.IngressClass, ingresses []networkingv1.Ingress, secrets []corev1.Secret, @@ -114,12 +114,12 @@ func BuildTree( errors := []ErrorEvent{} servicesMap := map[types.NamespacedName]corev1.Service{} - for _, s := range services { - servicesMap[client.ObjectKeyFromObject(&s)] = s + for i := range services { + servicesMap[client.ObjectKeyFromObject(&services[i])] = services[i] } secretsMap := map[types.NamespacedName]corev1.Secret{} - for _, s := range secrets { - secretsMap[client.ObjectKeyFromObject(&s)] = s + for i := range secrets { + secretsMap[client.ObjectKeyFromObject(&secrets[i])] = secrets[i] } targets := getTargetsOfNodes(nodes) @@ -137,7 +137,7 @@ func BuildTree( certificates: map[CertificateFingerprint]WorkTreeCertificate{}, } - errors = append(errors, addAccessControlToTree(tree, ingressClass)...) + addAccessControlToTree(tree, ingressClass) slices.SortFunc(ingresses, func(a, b networkingv1.Ingress) int { if diff := GetAnnotation(AnnotationPriority, 0, &b) - GetAnnotation(AnnotationPriority, 0, &a); diff != 0 { @@ -149,12 +149,13 @@ func BuildTree( return cmp.Compare(fmt.Sprintf("%s/%s", a.Namespace, a.Name), fmt.Sprintf("%s/%s", b.Namespace, b.Name)) }) - for _, ingress := range ingresses { + for i := range ingresses { + ingress := &ingresses[i] 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, + Ingress: ingress, FieldPath: field.NewPath("spec", "tls").Index(tlsIndex).Child("secretName"), Description: "TLS secret doesn't exist", }) @@ -162,7 +163,7 @@ func BuildTree( } if secret.Type != corev1.SecretTypeTLS { errors = append(errors, ErrorEvent{ - Ingress: &ingress, + Ingress: ingress, FieldPath: field.NewPath("spec", "tls").Index(tlsIndex).Child("secretName"), Description: "TLS secret isn't of type kubernetes.io/tls", }) @@ -172,7 +173,7 @@ func BuildTree( fingerprint, err := ValidateTLSCertAndFingerprint(secret.Data[corev1.TLSCertKey], secret.Data[corev1.TLSPrivateKeyKey]) if err != nil { errors = append(errors, ErrorEvent{ - Ingress: &ingress, + Ingress: ingress, FieldPath: field.NewPath("spec", "tls").Index(tlsIndex).Child("secretName"), Description: fmt.Sprintf("invalid certificate: %s", err.Error()), }) @@ -189,11 +190,11 @@ func BuildTree( for pathIndex, path := range rule.HTTP.Paths { ingressPathReference := ingressPathReference{namespace: ingress.Namespace, name: ingress.Name, uid: string(ingress.UID), ruleIndex: ruleIndex, pathIndex: pathIndex} - httpsOnly := GetAnnotation(AnnotationHTTPSOnly, false, &ingress, ingressClass) - httpPort := GetAnnotation(AnnotationHTTPPort, 80, &ingress, ingressClass) - httpsPort := GetAnnotation(AnnotationHTTPSPort, 443, &ingress, ingressClass) + httpsOnly := GetAnnotation(AnnotationHTTPSOnly, false, ingress, ingressClass) + httpPort := GetAnnotation(AnnotationHTTPPort, 80, ingress, ingressClass) + httpsPort := GetAnnotation(AnnotationHTTPSPort, 443, ingress, ingressClass) - targetPool, e := buildTargetPool(tree, ingressClass, targets, ingress, rule, ruleIndex, path, pathIndex, servicesMap) + 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. @@ -201,11 +202,11 @@ func BuildTree( var httpAdded, httpsAdded bool if !httpsOnly { - httpAdded, e = addPathToTree(tree, ingressClass, &ingress, rule, ruleIndex, path, pathIndex, int16(httpPort), protocolHTTP) + 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) + httpsAdded, e = addPathToTree(tree, ingressClass, ingress, rule, ruleIndex, path, pathIndex, int16(httpsPort), protocolHTTPS) errors = append(errors, e...) } @@ -220,16 +221,15 @@ func BuildTree( return tree, errors } -func addAccessControlToTree(tree *WorkTreeALB, ingressClass *networkingv1.IngressClass) []ErrorEvent { +func addAccessControlToTree(tree *WorkTreeALB, ingressClass *networkingv1.IngressClass) { annotation := GetAnnotation(AnnotationAllowedSourceRanges, "", ingressClass) if annotation == "" { - return nil + return } ranges := strings.Split(annotation, ",") tree.accessControl = &albsdk.LoadbalancerOptionAccessControl{ AllowedSourceRanges: ranges, } - return nil } // addPathToTree adds the given path to tree under the given port and protocol. @@ -292,7 +292,7 @@ func addPathToTree(tree *WorkTreeALB, ingressClass *networkingv1.IngressClass, i // // 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, rule networkingv1.IngressRule, ruleIndex int, path networkingv1.HTTPIngressPath, pathIndex int, servicesMap map[types.NamespacedName]corev1.Service) (*albsdk.TargetPool, []ErrorEvent) { +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} @@ -301,7 +301,7 @@ func buildTargetPool(tree *WorkTreeALB, ingressClass *networkingv1.IngressClass, if !exists { if len(tree.targetPools) >= LimitTargetPools { errors = append(errors, ErrorEvent{ - Ingress: &ingress, + Ingress: ingress, FieldPath: field.NewPath("spec", "rules").Index(ruleIndex).Child("paths").Index(pathIndex), Description: "Target pool limit reached. Path will be ignored.", }) @@ -314,7 +314,7 @@ func buildTargetPool(tree *WorkTreeALB, ingressClass *networkingv1.IngressClass, service, exists := servicesMap[types.NamespacedName{Namespace: ingress.Namespace, Name: path.Backend.Service.Name}] if !exists { errors = append(errors, ErrorEvent{ - Ingress: &ingress, + Ingress: ingress, FieldPath: field.NewPath("spec", "rules").Index(ruleIndex).Child("paths").Index(pathIndex).Child("backend", "service", "name"), Description: "Service doesn't exist", }) @@ -322,7 +322,7 @@ func buildTargetPool(tree *WorkTreeALB, ingressClass *networkingv1.IngressClass, } if service.Spec.Type != corev1.ServiceTypeNodePort && service.Spec.Type != corev1.ServiceTypeLoadBalancer { errors = append(errors, ErrorEvent{ - Ingress: &ingress, + 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", }) @@ -334,7 +334,7 @@ func buildTargetPool(tree *WorkTreeALB, ingressClass *networkingv1.IngressClass, port.Name == path.Backend.Service.Port.Name { if port.NodePort == 0 { errors = append(errors, ErrorEvent{ - Ingress: &ingress, + 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", }) @@ -345,7 +345,7 @@ func buildTargetPool(tree *WorkTreeALB, ingressClass *networkingv1.IngressClass, } if nodePort == 0 { errors = append(errors, ErrorEvent{ - Ingress: &ingress, + Ingress: ingress, FieldPath: field.NewPath("spec", "rules").Index(ruleIndex).Child("paths").Index(pathIndex).Child("backend", "service"), Description: "Port not found in service", }) @@ -356,10 +356,10 @@ func buildTargetPool(tree *WorkTreeALB, ingressClass *networkingv1.IngressClass, 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)), + Enabled: new(GetAnnotation(AnnotationTargetPoolTLSEnabled, false, &service, ingress, ingressClass)), + SkipCertificateValidation: new(GetAnnotation(AnnotationTargetPoolTLSSkipCertificateValidation, false, &service, ingress, ingressClass)), } - if ca := GetAnnotation(AnnotationTargetPoolTLSCustomCa, "", &service, &ingress, ingressClass); ca != "" { + 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. @@ -393,8 +393,10 @@ func ValidateTLSCertAndFingerprint(publicKey, privateKey []byte) (string, error) } func getTargetsOfNodes(nodes []corev1.Node) []albsdk.Target { + // TODO: remove nodes that are in deletion targets := []albsdk.Target{} - for _, node := range nodes { + for i := range nodes { + node := &nodes[i] for j := range node.Status.Addresses { address := node.Status.Addresses[j] if address.Type == corev1.NodeInternalIP { @@ -413,7 +415,7 @@ func getTargetsOfNodes(nodes []corev1.Node) []albsdk.Target { // 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 { +func (t *WorkTreeALB) GetMissingCertificates(existingCerts []certsdk.GetCertificateResponse) map[CertificateFingerprint]WorkTreeCertificate { missingCerts := map[CertificateFingerprint]WorkTreeCertificate{} existingCertsMap := map[CertificateFingerprint]any{} for _, cert := range existingCerts { @@ -433,7 +435,7 @@ func (t WorkTreeALB) GetMissingCertificates(existingCerts []certsdk.GetCertifica } // GetUnusedCertificates return all certificates in existingCerts that are not referenced in t. -func (t WorkTreeALB) GetUnusedCertificates(existingCerts map[CertificateFingerprint]string) map[CertificateFingerprint]string { +func (t *WorkTreeALB) GetUnusedCertificates(existingCerts map[CertificateFingerprint]string) map[CertificateFingerprint]string { unused := maps.Clone(existingCerts) for fingerprint := range t.certificates { delete(unused, fingerprint) @@ -445,7 +447,7 @@ func (t WorkTreeALB) GetUnusedCertificates(existingCerts map[CertificateFingerpr // // 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( +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, @@ -595,7 +597,7 @@ func (t WorkTreeALB) ToCreatePayload( // 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( +func (t *WorkTreeALB) ToUpdatePayload( certificateIDMap map[CertificateFingerprint]string, networkID string, region string, diff --git a/pkg/controller/ingress/update.go b/pkg/controller/ingress/update.go index fccbbda..22fe00c 100644 --- a/pkg/controller/ingress/update.go +++ b/pkg/controller/ingress/update.go @@ -19,7 +19,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" ) -func (r *IngressClassReconciler) reconcileALBResources(ctx context.Context, ingressClass *networkingv1.IngressClass) error { +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) @@ -145,7 +145,8 @@ func (r *IngressClassReconciler) reconcileALBResources(ctx context.Context, ingr 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 _, ingress := range ingresses { + 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 == "" { @@ -188,7 +189,8 @@ func (r *IngressClassReconciler) getServicesForIngresses(ctx context.Context, in func (r *IngressClassReconciler) getTLSSecretsFromIngresses(ctx context.Context, ingresses []networkingv1.Ingress) ([]corev1.Secret, error) { secrets := []corev1.Secret{} - for _, ingress := range ingresses { + 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) diff --git a/pkg/testutil/ingress/ingress.go b/pkg/testutil/ingress/ingress.go index 809890d..75743a6 100644 --- a/pkg/testutil/ingress/ingress.go +++ b/pkg/testutil/ingress/ingress.go @@ -1,3 +1,5 @@ +// revive:disable:exported // This file will be dot-imported. + package ingress import ( diff --git a/pkg/testutil/service/service.go b/pkg/testutil/service/service.go index 33b5bf5..c9a1d0c 100644 --- a/pkg/testutil/service/service.go +++ b/pkg/testutil/service/service.go @@ -1,3 +1,5 @@ +// revive:disable:exported // This file will be dot-imported. + package service import ( From c06a1e5a83a2dd5bb8f48c8f92e59d302e2b9550 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Fri, 26 Jun 2026 11:54:21 +0200 Subject: [PATCH 30/41] Improve controller tests --- .../ingress/ingressclass_controller_test.go | 82 ++----------------- pkg/testutil/testutil.go | 10 +++ 2 files changed, 19 insertions(+), 73 deletions(-) diff --git a/pkg/controller/ingress/ingressclass_controller_test.go b/pkg/controller/ingress/ingressclass_controller_test.go index ddb207b..73d1f8a 100644 --- a/pkg/controller/ingress/ingressclass_controller_test.go +++ b/pkg/controller/ingress/ingressclass_controller_test.go @@ -72,7 +72,7 @@ var _ = Describe("IngressClassController", func() { } Expect(k8sClient.Create(ctx, namespace)).To(Succeed()) DeferCleanup(func(ctx context.Context) { - // There is no namespace controller deployed. + // 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()) }) @@ -82,10 +82,7 @@ var _ = Describe("IngressClassController", func() { Addresses: []corev1.NodeAddress{{Type: corev1.NodeInternalIP, Address: "10.10.10.10"}}, }, } - Expect(k8sClient.Create(ctx, &node)).To(Succeed()) - DeferCleanup(func(ctx context.Context) { - Expect(k8sClient.Delete(ctx, &node)).To(Succeed()) - }) + testutil.CreateKubernetesResourceAndDeferDeletion(ctx, k8sClient, &node) mgr, err := ctrl.NewManager(cfg, ctrl.Options{ Scheme: scheme.Scheme, @@ -135,10 +132,7 @@ var _ = Describe("IngressClassController", func() { Controller: "some.other/controller", }, } - Expect(k8sClient.Create(ctx, ignoredIngressClass)).To(Succeed()) - DeferCleanup(func(ctx context.Context) { - testutil.DeleteAndWaitForKubernetesResource(ctx, k8sClient, ignoredIngressClass) - }) + testutil.CreateKubernetesResourceAndDeferDeletion(ctx, k8sClient, ignoredIngressClass) Consistently(func(g Gomega) { err := k8sClient.Get(ctx, client.ObjectKeyFromObject(ignoredIngressClass), ignoredIngressClass) @@ -233,7 +227,7 @@ var _ = Describe("IngressClassController", func() { } Expect(k8sClient.Create(ctx, ingressClass)).To(Succeed()) DeferCleanup(func(ctx context.Context) { - albClient.EXPECT().DeleteLoadBalancer(gomock.Any(), projectID, region, spec.LoadBalancerName(ingressClass)).Times(1) + albClient.EXPECT().DeleteLoadBalancer(gomock.Any(), projectID, region, spec.LoadBalancerName(ingressClass)).MinTimes(1) testutil.DeleteAndWaitForKubernetesResource(ctx, k8sClient, ingressClass) }) @@ -249,6 +243,7 @@ var _ = Describe("IngressClassController", func() { 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{ @@ -261,6 +256,7 @@ var _ = Describe("IngressClassController", func() { }) 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") @@ -280,13 +276,13 @@ var _ = Describe("IngressClassController", func() { corev1.TLSPrivateKeyKey: []byte(fixtureTLSPrivateKey), }, } - Expect(k8sClient.Create(ctx, &secret)).To(Succeed()) + testutil.CreateKubernetesResourceAndDeferDeletion(ctx, k8sClient, &secret) service := Service(corev1.NamespaceDefault, "my-service", WithServiceType(corev1.ServiceTypeNodePort), WithPort("http", 80, 30000, corev1.ProtocolTCP)) - Expect(k8sClient.Create(ctx, &service)).To(Succeed()) + 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})), ) - Expect(k8sClient.Create(ctx, &ingress)).To(Succeed()) + 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]( @@ -305,66 +301,6 @@ var _ = Describe("IngressClassController", func() { }, ConsistOf("random-certificate-id")), )) }) - - /* Context("When deleting an IngressClass", func() { - BeforeEach(func() { - // 1. Point our managed IngressClass definition to include the target testing labels - managedIngressClass = &networkingv1.IngressClass{ - ObjectMeta: metav1.ObjectMeta{ - GenerateName: "managed-ingressclass-", - UID: "envtest-ic-uid", - Labels: map[string]string{ - labels.LabelIngressClassUID: "target-cloud-alb-id", - }, - }, - Spec: networkingv1.IngressClassSpec{Controller: controllerName}, - } - - setupMocks = func(m *stackit.MockApplicationLoadBalancerClient) { - m.EXPECT(). - GetLoadBalancer(gomock.Any(), projectID, region, gomock.Any()). - Return(&albsdk.LoadBalancer{Status: new("READY")}, nil). - AnyTimes() - m.EXPECT(). - UpdateLoadBalancer(gomock.Any(), projectID, region, gomock.Any(), gomock.Any()). - Return(&albsdk.LoadBalancer{Status: new("READY")}, nil). - AnyTimes() // "allow background threads update safely without breaking my test" - - m.EXPECT(). - DeleteLoadBalancer(gomock.Any(), projectID, region, gomock.Any()). - Return(nil). - Times(1) // Asserts that the controller MUST call this exactly 1 time! - - } - - }) - - It("should read the UID label, delete associated ALB and certificate ", func(ctx context.Context) { - - // should delete the associated ALB and Certificate - certClient.EXPECT(). - DeleteCertificate(gomock.Any(), projectID, region, targetCertID). - Return(nil). - AnyTimes() - - // Publish the labeled IngressClass to the test cluster - Expect(k8sClient.Create(ctx, managedIngressClass)).To(Succeed()) - - // Wait for the controller background loop to notice it and attach the finalizer - WaitUntilFinalizerAttached(ctx, k8sClient, managedIngressClass) - - // Issue the Delete call to test the teardown pipeline - Expect(k8sClient.Delete(ctx, managedIngressClass)).To(Succeed()) - - // Verify the finalizer gets scrubbed and the object disappears from the API Server - Eventually(func(g Gomega) { - var ic networkingv1.IngressClass - err := k8sClient.Get(ctx, client.ObjectKeyFromObject(managedIngressClass), &ic) - - g.Expect(apierrors.IsNotFound(err)).To(BeTrue(), "The object must be deleted completely") - }, "5s", "200ms").Should(Succeed()) - }) - }) */ }) }) diff --git a/pkg/testutil/testutil.go b/pkg/testutil/testutil.go index 83ceacc..2b1536f 100644 --- a/pkg/testutil/testutil.go +++ b/pkg/testutil/testutil.go @@ -32,3 +32,13 @@ func HaveName(name string) types.GomegaMatcher { 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) + }) +} From a73247f46c1ecfe88fe53651439ca8d8bcd9bf0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Fri, 26 Jun 2026 16:21:31 +0200 Subject: [PATCH 31/41] Assign certificates per port --- docs/user.md | 8 +- .../ingress/ingressclass_controller_test.go | 92 +------ pkg/controller/ingress/spec/testdata/certs.go | 257 ++++++++++++++++++ pkg/controller/ingress/spec/worktree.go | 31 ++- pkg/controller/ingress/spec/worktree_test.go | 162 +++++------ 5 files changed, 356 insertions(+), 194 deletions(-) create mode 100644 pkg/controller/ingress/spec/testdata/certs.go diff --git a/docs/user.md b/docs/user.md index c19d184..0055bcc 100644 --- a/docs/user.md +++ b/docs/user.md @@ -208,7 +208,13 @@ Configure the STACKIT Application Load Balancer using the following annotations. ### Known Limitations -#### defaultBackend support +#### 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/pkg/controller/ingress/ingressclass_controller_test.go b/pkg/controller/ingress/ingressclass_controller_test.go index 73d1f8a..e040e14 100644 --- a/pkg/controller/ingress/ingressclass_controller_test.go +++ b/pkg/controller/ingress/ingressclass_controller_test.go @@ -11,6 +11,7 @@ import ( . "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" @@ -272,8 +273,8 @@ var _ = Describe("IngressClassController", func() { ObjectMeta: metav1.ObjectMeta{Namespace: corev1.NamespaceDefault, Name: "my-tls-cert"}, Type: corev1.SecretTypeTLS, Data: map[string][]byte{ - corev1.TLSCertKey: []byte(fixtureTLSPublicKey), - corev1.TLSPrivateKeyKey: []byte(fixtureTLSPrivateKey), + corev1.TLSCertKey: []byte(testdata.FixtureTLS1PublicKey), + corev1.TLSPrivateKeyKey: []byte(testdata.FixtureTLS1PrivateKey), }, } testutil.CreateKubernetesResourceAndDeferDeletion(ctx, k8sClient, &secret) @@ -316,90 +317,3 @@ func WaitUntilFinalizerAttached(ctx context.Context, cl client.Client, ic *netwo g.Expect(reconciledIngressClass.Finalizers).To(ContainElement(finalizerName)) }, "5s", "200ms").Should(Succeed()) } - -const ( - fixtureTLSPublicKey = `-----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-----` - fixtureTLSPrivateKey = `-----BEGIN PRIVATE KEY----- -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 -UoA+9Hd41MHTo2Frp1cML2BpdbK/ ------END PRIVATE KEY-----` -) 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 index d9d025d..72400f3 100644 --- a/pkg/controller/ingress/spec/worktree.go +++ b/pkg/controller/ingress/spec/worktree.go @@ -89,6 +89,8 @@ type workTreePath struct { 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. @@ -151,6 +153,10 @@ func BuildTree( //nolint:gocyclo,funlen // Breaking up this function won't make }) 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 { @@ -180,20 +186,21 @@ func BuildTree( //nolint:gocyclo,funlen // Breaking up this function won't make continue } - tree.certificates[CertificateFingerprint(fingerprint)] = WorkTreeCertificate{ - PublicKey: string(secret.Data[corev1.TLSCertKey]), - PrivateKey: string(secret.Data[corev1.TLSPrivateKeyKey]), + 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} - httpsOnly := GetAnnotation(AnnotationHTTPSOnly, false, ingress, ingressClass) - httpPort := GetAnnotation(AnnotationHTTPPort, 80, ingress, ingressClass) - httpsPort := GetAnnotation(AnnotationHTTPSPort, 443, ingress, ingressClass) - targetPool, e := buildTargetPool(tree, ingressClass, targets, ingress, ruleIndex, path, pathIndex, servicesMap) errors = append(errors, e...) if targetPool == nil { @@ -434,7 +441,7 @@ func (t *WorkTreeALB) GetMissingCertificates(existingCerts []certsdk.GetCertific return missingCerts } -// GetUnusedCertificates return all certificates in existingCerts that are not referenced in t. +// 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 { @@ -511,8 +518,10 @@ func (t *WorkTreeALB) ToCreatePayload( //nolint:gocyclo,funlen // Breaking up th CertificateIds: []string{}, }, } - // TODO: Only use the certificates used for this port. - for fingerprint := range t.certificates { + 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) } @@ -542,7 +551,7 @@ func (t *WorkTreeALB) ToCreatePayload( //nolint:gocyclo,funlen // Breaking up th 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("port-%d", 80)), + Name: new(fmt.Sprintf("dummy-port-%d", 80)), Protocol: new(string(protocolHTTP)), Port: new(int32(80)), Http: &albsdk.ProtocolOptionsHTTP{ diff --git a/pkg/controller/ingress/spec/worktree_test.go b/pkg/controller/ingress/spec/worktree_test.go index abf6551..c744935 100644 --- a/pkg/controller/ingress/spec/worktree_test.go +++ b/pkg/controller/ingress/spec/worktree_test.go @@ -6,6 +6,7 @@ import ( . "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" @@ -174,7 +175,7 @@ var _ = Describe("WorkTreeALB", func() { Type: corev1.SecretTypeTLS, Data: map[string][]byte{ corev1.TLSCertKey: []byte("invalid cert"), - corev1.TLSPrivateKeyKey: []byte(fixtureTLSPrivateKey), + corev1.TLSPrivateKeyKey: []byte(testdata.FixtureTLS1PrivateKey), }, }, }, nil, nil, nil, @@ -184,7 +185,7 @@ var _ = Describe("WorkTreeALB", func() { Expect(errs[0].Description).To(Equal("invalid certificate: tls: failed to find any PEM data in certificate input")) }) - It("should process TLS secret correctly", func() { + It("should process TLS secret correctly and return it as missing certificate", func() { tree, errs := BuildTree( &networkingv1.IngressClass{}, []networkingv1.Ingress{ @@ -195,8 +196,8 @@ var _ = Describe("WorkTreeALB", func() { ObjectMeta: metav1.ObjectMeta{Namespace: corev1.NamespaceDefault, Name: "my-tls"}, Type: corev1.SecretTypeTLS, Data: map[string][]byte{ - corev1.TLSCertKey: []byte(fixtureTLSPublicKey), - corev1.TLSPrivateKeyKey: []byte(fixtureTLSPrivateKey), + corev1.TLSCertKey: []byte(testdata.FixtureTLS1PublicKey), + corev1.TLSPrivateKeyKey: []byte(testdata.FixtureTLS1PrivateKey), }, }, }, nil, nil, nil, @@ -205,12 +206,74 @@ var _ = Describe("WorkTreeALB", func() { Expect(errs).To(BeEmpty()) Expect(tree.GetMissingCertificates(nil)).To(ConsistOf( WorkTreeCertificate{ - PublicKey: fixtureTLSPublicKey, - PrivateKey: fixtureTLSPrivateKey, + 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{ @@ -484,90 +547,3 @@ var _ = Describe("WorkTreeALB", func() { Expect(create.Listeners[0].Http.Hosts[0].Rules[0].Path.ExactMatch).To(HaveValue(Equal("/a"))) }) }) - -const ( - fixtureTLSPublicKey = `-----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-----` - fixtureTLSPrivateKey = `-----BEGIN PRIVATE KEY----- -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 -UoA+9Hd41MHTo2Frp1cML2BpdbK/ ------END PRIVATE KEY-----` -) From 2b534a6892daf791212de403eb55000d9f432eb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Fri, 26 Jun 2026 16:24:16 +0200 Subject: [PATCH 32/41] Link annotations --- docs/user.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user.md b/docs/user.md index 0055bcc..af2ce14 100644 --- a/docs/user.md +++ b/docs/user.md @@ -8,7 +8,7 @@ To expose an application, you need to deploy three core resources: an IngressCla #### 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* section). +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. From 0ab0e3a06eaa6a3f3bad66af28df97ef8dd258ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Fri, 26 Jun 2026 16:25:13 +0200 Subject: [PATCH 33/41] Use node port in docs --- docs/user.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user.md b/docs/user.md index af2ce14..2f7c7db 100644 --- a/docs/user.md +++ b/docs/user.md @@ -38,7 +38,7 @@ metadata: labels: app: service-a spec: - type: CLusterIP + type: NodePort ports: - port: 80 protocol: TCP From 8bcd2b43e9b2055888d2e23b208e9cdb69ae475f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Fri, 26 Jun 2026 16:53:15 +0200 Subject: [PATCH 34/41] Enable leader election by default --- cmd/application-load-balancer-controller/main.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/application-load-balancer-controller/main.go b/cmd/application-load-balancer-controller/main.go index f859743..ae13aba 100644 --- a/cmd/application-load-balancer-controller/main.go +++ b/cmd/application-load-balancer-controller/main.go @@ -47,12 +47,12 @@ func main() { 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", false, + 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", "d0fbe9c4.stackit.cloud", "The name of the resource that "+ + 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.") From 72be85ea4595658953b65b1cdabf98e714693e57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Fri, 26 Jun 2026 17:02:30 +0200 Subject: [PATCH 35/41] Index secret references on ingresses --- pkg/controller/ingress/setup.go | 58 ++++++++++++++++++--------------- 1 file changed, 31 insertions(+), 27 deletions(-) diff --git a/pkg/controller/ingress/setup.go b/pkg/controller/ingress/setup.go index 0d8bf76..46a95dd 100644 --- a/pkg/controller/ingress/setup.go +++ b/pkg/controller/ingress/setup.go @@ -19,8 +19,10 @@ import ( const ( // fieldIndexIngressClass indexes the ingress class on an ingress. fieldIndexIngressClass = ".spec.ingressClassName" - // fieldIndexService indexes a service reference on an ingress. + // 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. @@ -54,6 +56,16 @@ func (r *IngressClassReconciler) SetupWithManager(ctx context.Context, mgr ctrl. 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" } @@ -68,6 +80,7 @@ func (r *IngressClassReconciler) SetupWithManager(ctx context.Context, mgr ctrl. 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. @@ -76,44 +89,35 @@ func secretEventHandler(c client.Client) handler.EventHandler { return nil } - ingressList := &networkingv1.IngressList{} - if err := c.List(ctx, ingressList, client.InNamespace(secret.Namespace)); err != nil { + ingresses := &networkingv1.IngressList{} + err := c.List(ctx, ingresses, client.InNamespace(secret.Namespace), client.MatchingFields{fieldIndexSecret: secret.Name}) + if err != nil { return nil } - classNames := make(map[string]struct{}) - for i := range ingressList.Items { - ingress := ingressList.Items[i] - if ingress.Spec.IngressClassName == nil { - continue - } - - for _, tls := range ingress.Spec.TLS { - if tls.SecretName == secret.Name { - classNames[*ingress.Spec.IngressClassName] = struct{}{} - break - } + 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 } } - var requestList []ctrl.Request - for className := range classNames { - ingressClass := &networkingv1.IngressClass{} - err := c.Get(ctx, client.ObjectKey{Name: className}, ingressClass) - if err != nil || ingressClass.Spec.Controller != controllerName { + reqs := []ctrl.Request{} + for className := range classes { + class := &networkingv1.IngressClass{} + if err := c.Get(ctx, types.NamespacedName{Name: className}, class); err != nil { continue } - - requestList = append(requestList, ctrl.Request{ - NamespacedName: client.ObjectKeyFromObject(ingressClass), - }) + if class.Spec.Controller == controllerName { + reqs = append(reqs, reconcile.Request{NamespacedName: types.NamespacedName{Name: className}}) + } } - - return requestList + return reqs }) } -// serviceEventHandler returns all ingress classes that have at least one ingress that references the given secret. +// 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) From 0ee779bdc0292568f371ddac92a3540aa1b182b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Fri, 26 Jun 2026 17:04:21 +0200 Subject: [PATCH 36/41] Fix typo --- pkg/controller/ingress/ingressclass_controller.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/controller/ingress/ingressclass_controller.go b/pkg/controller/ingress/ingressclass_controller.go index e94f969..093790c 100644 --- a/pkg/controller/ingress/ingressclass_controller.go +++ b/pkg/controller/ingress/ingressclass_controller.go @@ -163,7 +163,7 @@ func (r *IngressClassReconciler) handleIngressClassDeletion( } patch := client.MergeFrom(before) if err := r.Client.Status().Patch(ctx, ingress, patch); err != nil { - return fmt.Errorf("failed to patch shoot object: %w", err) + return fmt.Errorf("failed to patch ingress %s: %w", client.ObjectKeyFromObject(ingress), err) } } From df49b6e2ea9b52be9115d0a2b43cfae71f78faf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Fri, 26 Jun 2026 17:05:02 +0200 Subject: [PATCH 37/41] Fix typo --- pkg/controller/ingress/update.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/controller/ingress/update.go b/pkg/controller/ingress/update.go index 22fe00c..b50fe44 100644 --- a/pkg/controller/ingress/update.go +++ b/pkg/controller/ingress/update.go @@ -89,7 +89,7 @@ func (r *IngressClassReconciler) reconcileALBResources(ctx context.Context, ingr } certIDMap := map[spec.CertificateFingerprint]string{} - // deplicateCerts contains all certificates that are duplicates of others (in certIDMap) by fingerprint. + // duplicateCerts contains all certificates that are duplicates of others (in certIDMap) by fingerprint. // Because they might still be used by the ALB the 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{} From 852678c51d32167b91bbf8e5761c63d1b3124078 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Fri, 26 Jun 2026 17:06:44 +0200 Subject: [PATCH 38/41] Fix typo --- pkg/controller/ingress/update.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/controller/ingress/update.go b/pkg/controller/ingress/update.go index b50fe44..8c5f5ee 100644 --- a/pkg/controller/ingress/update.go +++ b/pkg/controller/ingress/update.go @@ -90,7 +90,7 @@ func (r *IngressClassReconciler) reconcileALBResources(ctx context.Context, ingr 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 the must only be removed after the ALB was updated. + // 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 { From 93cb3d7b2ba631e111da0faaaa3bde7606f9f502 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Fri, 26 Jun 2026 17:14:41 +0200 Subject: [PATCH 39/41] Fix typo --- pkg/controller/ingress/spec/annotations.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/controller/ingress/spec/annotations.go b/pkg/controller/ingress/spec/annotations.go index aac542a..f5a9be4 100644 --- a/pkg/controller/ingress/spec/annotations.go +++ b/pkg/controller/ingress/spec/annotations.go @@ -61,14 +61,14 @@ const ( ) // GetAnnotation retrieves an annotation value from objects. -// If multiple objects contain the annotation, the first object in the slice containing the annotation takes precedence. +// 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 latest found value is parsed. +// 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 From 9e8cb895f115c9d0d59686b99d9cec305d22fb9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Fri, 26 Jun 2026 17:17:12 +0200 Subject: [PATCH 40/41] Fix typo --- pkg/controller/ingress/spec/worktree.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/controller/ingress/spec/worktree.go b/pkg/controller/ingress/spec/worktree.go index 72400f3..9fd4d34 100644 --- a/pkg/controller/ingress/spec/worktree.go +++ b/pkg/controller/ingress/spec/worktree.go @@ -99,7 +99,7 @@ type WorkTreeCertificate struct { // 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 to pick up fields that are already set, most notably the version for the update payload. +// 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. From 836aba1a3b59a6203f30856d7ca679cf092d21fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Fri, 26 Jun 2026 17:31:48 +0200 Subject: [PATCH 41/41] Fix path collision detection --- pkg/controller/ingress/spec/worktree.go | 14 +++++------ pkg/controller/ingress/spec/worktree_test.go | 26 ++++++++++++++++++++ 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/pkg/controller/ingress/spec/worktree.go b/pkg/controller/ingress/spec/worktree.go index 9fd4d34..c9fe517 100644 --- a/pkg/controller/ingress/spec/worktree.go +++ b/pkg/controller/ingress/spec/worktree.go @@ -270,21 +270,19 @@ func addPathToTree(tree *WorkTreeALB, ingressClass *networkingv1.IngressClass, i } albPath, exists := host.paths[_pathWithType] - if exists && albPath.ingressPathReference == ingressPathReference { + if exists { errors = append(errors, ErrorEvent{ Ingress: ingress, - FieldPath: field.NewPath("spec", "rules").Index(ruleIndex).Child("path").Index(pathIndex), + FieldPath: field.NewPath("spec", "rules").Index(ruleIndex).Child("paths").Index(pathIndex), Description: "Path already exists", }) return false, errors } - if !exists { - albPath = &workTreePath{ - path: _pathWithType, - ingressPathReference: ingressPathReference, - } + albPath = &workTreePath{ + path: _pathWithType, + ingressPathReference: ingressPathReference, + websocket: GetAnnotation(AnnotationWebSocket, false, ingress, ingressClass), } - albPath.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 diff --git a/pkg/controller/ingress/spec/worktree_test.go b/pkg/controller/ingress/spec/worktree_test.go index c744935..efd5018 100644 --- a/pkg/controller/ingress/spec/worktree_test.go +++ b/pkg/controller/ingress/spec/worktree_test.go @@ -546,4 +546,30 @@ var _ = Describe("WorkTreeALB", func() { 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)), + }), + )) + }) })