From 7b54bbc175d2aeab24b178b191f8f7f8480443dd Mon Sep 17 00:00:00 2001 From: Kamil Przybyl Date: Tue, 23 Jun 2026 13:49:46 +0200 Subject: [PATCH 01/77] 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 18e4ede4f5f5d289699007cec734b97049eef6cc 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/77] 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 ffb4d4629b66acf16522d8d066ab765a1d1b95e2 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/77] 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 6171e2f5e88804e7494bd4a88456fa1614d7379f 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/77] 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 92b4b4f749e6e7dcfbc237c628068991610ae878 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/77] 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 11da7efa0efc4a75c3f5ee86ca9e0179dca6d35c 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/77] 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 31e8b7093c72df5b2d2f35a2c955b6ac9160a69d 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/77] 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 55bf6febdac96d6be41d6715037ccd03ceba1752 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/77] 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 a050bf49d17293d3f1f7db052492e6a474d32ccf 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/77] 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 193cc788eb5f40f14535546f9a667a3cb9c7695c 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/77] 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 de0b106ae3408c3ee767215c08fc162cd3556612 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/77] 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 b18dfb942c4db5b5d9232d50fa4382f734f0c508 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/77] 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 7579ee386418bed5f3fd68c5145a93b3e027bd7a 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/77] 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 d9808df84f9684f3d1f32495f73647051a0cba0e 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/77] 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 215ed2272381d24e0d5dc000ac3167bb77ee0956 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/77] 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 876402508e167f1b1cc80f6069480cbf2bd935f5 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/77] 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 c8e20c7eaec1f08608cf8dc765fcce075f11bfcb 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/77] 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 66a4bc7ee8e5d63720a04870fff09a82491f304e 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/77] 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 9ac751be84e51b2efa666b6eefbfef6e1b825545 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/77] 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 d9371fda114ef77a766b974d6bc14b580d1819fd 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/77] 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 d0850f97813101c55c4a3678c2ced3c978ee4470 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/77] 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 f37078e4e8aeb9cf611858377951faefee9d7767 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/77] 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 735fddb807317b01c72c9cb19d03eede84e942e2 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/77] 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 97c107c4778796cbb9d960e86d26ed75a5666192 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/77] 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 eabd6882d19c7e274c0faa563e13f3c1a04d97de 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/77] 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 97d5d5e2b62d8d7056cf7fc312ae8165ba8d7019 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/77] 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 7462949e459d917716ab6c52866bc953832fb38e 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/77] 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 704b3ef8ce61ec4e6df2c550f8b05d32f9d7c57c 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/77] 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 390e7bc7b6b4cccf978d3c5dbda53b79a648da0c 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/77] 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 c47d4b09fe33404aafbf601af4674a0d224c17dd 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/77] 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 35fb8749986d6929065088a15860445a4a42af4b 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/77] 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 aa3dd3dcc1f116359c7fa80235171ca906de1393 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/77] 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 85a4237d0f0ffd4ef5143d18113f0ecc9cca1284 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/77] 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 cd7c861434113882e2d2717246b8b29f1f6addba 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/77] 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 c34ab88f969f6d482588ca81ebe04e388448c61c 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/77] 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 b8a2b05a65ad831266412171040d42ff85853ece 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/77] 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 7b1f4743ed015576a4c01e085517b96ad526b8e1 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/77] 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 79ab4b7cad3e8a8e1c1702825e25642280f72194 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/77] 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 35ba51b0a21a3164ab55c7fbd8af85ffc1695a04 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/77] 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 16d96b2022b175f4ea1724402c91645bd6cee454 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/77] 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 9597eabc1a570989f4dc382d83fd8e74502c73a4 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/77] 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)), + }), + )) + }) }) From 169508948811761ebcf53749a9414c62204a9417 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Mon, 29 Jun 2026 11:13:34 +0200 Subject: [PATCH 42/77] Implement AI feedback --- .../ingress/ingressclass_controller.go | 2 +- pkg/controller/ingress/setup.go | 27 ++++++++++++------- pkg/controller/ingress/spec/events.go | 4 --- pkg/controller/ingress/spec/worktree.go | 19 ++++++++----- pkg/controller/ingress/update.go | 9 ++++--- 5 files changed, 36 insertions(+), 25 deletions(-) diff --git a/pkg/controller/ingress/ingressclass_controller.go b/pkg/controller/ingress/ingressclass_controller.go index 093790c..62d0876 100644 --- a/pkg/controller/ingress/ingressclass_controller.go +++ b/pkg/controller/ingress/ingressclass_controller.go @@ -89,7 +89,7 @@ func (r *IngressClassReconciler) updateStatus(ctx context.Context, ingressClass return ctrl.Result{}, fmt.Errorf("failed to get load balancer: %w", err) } - if *alb.Status != stackit.LBStatusReady { + if alb.Status == nil || *alb.Status != stackit.LBStatusReady { // ALB is not yet ready, requeue return ctrl.Result{RequeueAfter: 10 * time.Second}, nil } diff --git a/pkg/controller/ingress/setup.go b/pkg/controller/ingress/setup.go index 46a95dd..b647e98 100644 --- a/pkg/controller/ingress/setup.go +++ b/pkg/controller/ingress/setup.go @@ -2,6 +2,7 @@ package ingress import ( "context" + "fmt" "reflect" corev1 "k8s.io/api/core/v1" @@ -27,18 +28,20 @@ const ( // 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 { + if err := 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} - }) + }); err != nil { + return fmt.Errorf("failed to index ingress class on ingresses: %w", err) + } - mgr.GetCache().IndexField(ctx, &networkingv1.Ingress{}, fieldIndexService, func(o client.Object) []string { + if err := 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 != "" { + if ingress.Spec.DefaultBackend != nil && ingress.Spec.DefaultBackend.Service != nil && ingress.Spec.DefaultBackend.Service.Name != "" { refs = append(refs, ingress.Spec.DefaultBackend.Service.Name) } for i := range ingress.Spec.Rules { @@ -54,9 +57,11 @@ func (r *IngressClassReconciler) SetupWithManager(ctx context.Context, mgr ctrl. } } return refs - }) + }); err != nil { + return fmt.Errorf("failed to index services on ingresses: %w", err) + } - mgr.GetCache().IndexField(ctx, &networkingv1.Ingress{}, fieldIndexSecret, func(o client.Object) []string { + if err := mgr.GetCache().IndexField(ctx, &networkingv1.Ingress{}, fieldIndexSecret, func(o client.Object) []string { ingress := o.(*networkingv1.Ingress) refs := []string{} for i := range ingress.Spec.TLS { @@ -64,7 +69,9 @@ func (r *IngressClassReconciler) SetupWithManager(ctx context.Context, mgr ctrl. } return refs - }) + }); err != nil { + return fmt.Errorf("failed to index secrets on ingresses: %w", err) + } if ctrlName == "" { ctrlName = "ingressclass" @@ -98,7 +105,7 @@ func secretEventHandler(c client.Client) handler.EventHandler { classes := map[string]any{} for i := range ingresses.Items { ingress := &ingresses.Items[i] - if ingress.Spec.IngressClassName != nil && *ingress.Spec.IngressClassName == "" { + if ingress.Spec.IngressClassName != nil && *ingress.Spec.IngressClassName != "" { classes[*ingress.Spec.IngressClassName] = nil } } @@ -134,7 +141,7 @@ func serviceEventHandler(c client.Client) handler.EventHandler { classes := map[string]any{} for i := range ingresses.Items { ingress := &ingresses.Items[i] - if ingress.Spec.IngressClassName != nil && *ingress.Spec.IngressClassName == "" { + if ingress.Spec.IngressClassName != nil && *ingress.Spec.IngressClassName != "" { classes[*ingress.Spec.IngressClassName] = nil } } @@ -166,7 +173,7 @@ func nodeEventHandler(c client.Client) handler.EventHandler { continue } requestList = append(requestList, ctrl.Request{ - NamespacedName: client.ObjectKeyFromObject(new(ingressClassList.Items[i])), + NamespacedName: client.ObjectKeyFromObject(&ingressClassList.Items[i]), }) } return requestList diff --git a/pkg/controller/ingress/spec/events.go b/pkg/controller/ingress/spec/events.go index 8b44385..1d9f608 100644 --- a/pkg/controller/ingress/spec/events.go +++ b/pkg/controller/ingress/spec/events.go @@ -24,10 +24,6 @@ func (e *ErrorEvent) Error() string { } 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/worktree.go b/pkg/controller/ingress/spec/worktree.go index c9fe517..be9bb90 100644 --- a/pkg/controller/ingress/spec/worktree.go +++ b/pkg/controller/ingress/spec/worktree.go @@ -198,6 +198,9 @@ func BuildTree( //nolint:gocyclo,funlen // Breaking up this function won't make for ruleIndex, rule := range ingress.Spec.Rules { // TODO: support rules that don't have a path + if rule.HTTP == nil { + continue + } for pathIndex, path := range rule.HTTP.Paths { ingressPathReference := ingressPathReference{namespace: ingress.Namespace, name: ingress.Name, uid: string(ingress.UID), ruleIndex: ruleIndex, pathIndex: pathIndex} @@ -310,12 +313,20 @@ func buildTargetPool(tree *WorkTreeALB, ingressClass *networkingv1.IngressClass, FieldPath: field.NewPath("spec", "rules").Index(ruleIndex).Child("paths").Index(pathIndex), Description: "Target pool limit reached. Path will be ignored.", }) + return nil, errors } } targetPool := &albsdk.TargetPool{} // TODO: Support other backends than services. - + if path.Backend.Service == nil { + errors = append(errors, ErrorEvent{ + Ingress: ingress, + FieldPath: field.NewPath("spec", "rules").Index(ruleIndex).Child("paths").Index(pathIndex).Child("backend"), + Description: "Backend of path isn't a service.", + }) + return nil, errors + } service, exists := servicesMap[types.NamespacedName{Namespace: ingress.Namespace, Name: path.Backend.Service.Name}] if !exists { errors = append(errors, ErrorEvent{ @@ -484,11 +495,7 @@ func (t *WorkTreeALB) ToCreatePayload( //nolint:gocyclo,funlen // Breaking up th } switch path.path.pathType { - case networkingv1.PathTypeExact: - rule.Path = new(albsdk.Path{ - ExactMatch: new(path.path.path), - }) - case networkingv1.PathTypeImplementationSpecific: + case networkingv1.PathTypeExact, networkingv1.PathTypeImplementationSpecific: rule.Path = new(albsdk.Path{ ExactMatch: new(path.path.path), }) diff --git a/pkg/controller/ingress/update.go b/pkg/controller/ingress/update.go index 8c5f5ee..d63e5d5 100644 --- a/pkg/controller/ingress/update.go +++ b/pkg/controller/ingress/update.go @@ -148,8 +148,11 @@ func (r *IngressClassReconciler) getServicesForIngresses(ctx context.Context, in for i := range ingresses { ingress := ingresses[i] for ruleIndex, rule := range ingress.Spec.Rules { + if rule.HTTP == nil { + continue + } for pathIndex, path := range rule.HTTP.Paths { - if path.Backend.Service.Name == "" { + if path.Backend.Service == nil || path.Backend.Service.Name == "" { continue } service := corev1.Service{} @@ -329,8 +332,6 @@ func targetPoolsChanged(current, desired []albsdk.TargetPool) bool { 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 - } + // The SDK considers nil and empty slices equal. slices.Equal does the same. return !slices.Equal(a.AllowedSourceRanges, b.AllowedSourceRanges) } From e08750af58b097b08365b5991dec8fedb63a32f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Mon, 29 Jun 2026 11:27:49 +0200 Subject: [PATCH 43/77] docs: document NodePort requirement for backend Services --- docs/user.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/user.md b/docs/user.md index 2f7c7db..17adb69 100644 --- a/docs/user.md +++ b/docs/user.md @@ -208,6 +208,10 @@ Configure the STACKIT Application Load Balancer using the following annotations. ### Known Limitations +#### Backend Services must be of type `NodePort` + +The controller currently only supports routing traffic to backend Services of `type: NodePort` (or `LoadBalancer`, which also allocates a NodePort). Services of type `ClusterIP` cannot be used as backends because the ALB needs a node-reachable port to forward traffic to. + #### 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. From 34680c5b464cb5c67d2a56d39d7accad4e11fd7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Mon, 29 Jun 2026 11:38:55 +0200 Subject: [PATCH 44/77] cmd: do not default zap logger to development mode --- cmd/application-load-balancer-controller/main.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/cmd/application-load-balancer-controller/main.go b/cmd/application-load-balancer-controller/main.go index ae13aba..18d554f 100644 --- a/cmd/application-load-balancer-controller/main.go +++ b/cmd/application-load-balancer-controller/main.go @@ -56,9 +56,7 @@ func main() { "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 := zap.Options{} zapOpts.BindFlags(flag.CommandLine) flag.Parse() ctrl.SetLogger(zap.New(zap.UseFlagOptions(&zapOpts))) From 486f84f1b7537a6a68ce4adce0d6308bcc7ae62d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Mon, 29 Jun 2026 11:47:09 +0200 Subject: [PATCH 45/77] controller/ingress: deduplicate ingress class request mapping for secrets and services --- pkg/controller/ingress/setup.go | 76 ++++++++++++--------------------- 1 file changed, 28 insertions(+), 48 deletions(-) diff --git a/pkg/controller/ingress/setup.go b/pkg/controller/ingress/setup.go index b647e98..f73f47c 100644 --- a/pkg/controller/ingress/setup.go +++ b/pkg/controller/ingress/setup.go @@ -95,32 +95,7 @@ func secretEventHandler(c client.Client) handler.EventHandler { if !ok || secret.Type != corev1.SecretTypeTLS { return nil } - - ingresses := &networkingv1.IngressList{} - err := c.List(ctx, ingresses, client.InNamespace(secret.Namespace), client.MatchingFields{fieldIndexSecret: secret.Name}) - if err != nil { - return nil - } - - classes := map[string]any{} - for i := range ingresses.Items { - ingress := &ingresses.Items[i] - if ingress.Spec.IngressClassName != nil && *ingress.Spec.IngressClassName != "" { - classes[*ingress.Spec.IngressClassName] = nil - } - } - - reqs := []ctrl.Request{} - for className := range classes { - class := &networkingv1.IngressClass{} - if err := c.Get(ctx, types.NamespacedName{Name: className}, class); err != nil { - continue - } - if class.Spec.Controller == controllerName { - reqs = append(reqs, reconcile.Request{NamespacedName: types.NamespacedName{Name: className}}) - } - } - return reqs + return ingressClassRequestsForReferencingIngresses(ctx, c, secret.Namespace, fieldIndexSecret, secret.Name) }) } @@ -131,33 +106,38 @@ func serviceEventHandler(c client.Client) handler.EventHandler { if !ok { return nil } + return ingressClassRequestsForReferencingIngresses(ctx, c, service.Namespace, fieldIndexService, service.Name) + }) +} - ingresses := &networkingv1.IngressList{} - err := c.List(ctx, ingresses, client.InNamespace(service.Namespace), client.MatchingFields{fieldIndexService: service.Name}) - if err != nil { - return nil - } +// ingressClassRequestsForReferencingIngresses lists all ingresses in the given namespace that reference an object +// (identified via the provided field index and value) and returns reconcile requests for all unique ALB-controlled +// ingress classes those ingresses belong to. +func ingressClassRequestsForReferencingIngresses(ctx context.Context, c client.Client, namespace, fieldIndex, value string) []ctrl.Request { + ingresses := &networkingv1.IngressList{} + if err := c.List(ctx, ingresses, client.InNamespace(namespace), client.MatchingFields{fieldIndex: value}); 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 - } + classes := map[string]any{} + for i := range ingresses.Items { + ingress := &ingresses.Items[i] + if ingress.Spec.IngressClassName != nil && *ingress.Spec.IngressClassName != "" { + classes[*ingress.Spec.IngressClassName] = nil } + } - reqs := []ctrl.Request{} - for className := range classes { - class := &networkingv1.IngressClass{} - if err := c.Get(ctx, types.NamespacedName{Name: className}, class); err != nil { - continue - } - if class.Spec.Controller == controllerName { - reqs = append(reqs, reconcile.Request{NamespacedName: types.NamespacedName{Name: className}}) - } + reqs := []ctrl.Request{} + for className := range classes { + class := &networkingv1.IngressClass{} + if err := c.Get(ctx, types.NamespacedName{Name: className}, class); err != nil { + continue } - return reqs - }) + if class.Spec.Controller == controllerName { + reqs = append(reqs, reconcile.Request{NamespacedName: types.NamespacedName{Name: className}}) + } + } + return reqs } func nodeEventHandler(c client.Client) handler.EventHandler { From 0fd72874600104784d86a08d3b9c2700c4a609b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Mon, 29 Jun 2026 14:04:13 +0200 Subject: [PATCH 46/77] Add samples to deploy controller --- .../ingress/ingressclass_controller.go | 8 ++ samples/deploy/deployment.yaml | 85 ++++++++++++ samples/deploy/kustomization.yaml | 9 ++ samples/deploy/namespace.yaml | 7 + samples/deploy/rbac.yaml | 128 ++++++++++++++++++ samples/deploy/secret.yaml | 35 +++++ samples/deploy/serviceaccount.yaml | 8 ++ 7 files changed, 280 insertions(+) create mode 100644 samples/deploy/deployment.yaml create mode 100644 samples/deploy/kustomization.yaml create mode 100644 samples/deploy/namespace.yaml create mode 100644 samples/deploy/rbac.yaml create mode 100644 samples/deploy/secret.yaml create mode 100644 samples/deploy/serviceaccount.yaml diff --git a/pkg/controller/ingress/ingressclass_controller.go b/pkg/controller/ingress/ingressclass_controller.go index 62d0876..6fb6acb 100644 --- a/pkg/controller/ingress/ingressclass_controller.go +++ b/pkg/controller/ingress/ingressclass_controller.go @@ -24,6 +24,14 @@ const ( controllerName = "stackit.cloud/alb-ingress" ) +// +kubebuilder:rbac:groups=core,resources=services,verbs=get;list;watch +// +kubebuilder:rbac:groups=core,resources=secrets,verbs=get;list;watch +// +kubebuilder:rbac:groups=core,resources=nodes,verbs=get;list;watch +// +kubebuilder:rbac:groups=core,resources=events,verbs=create;patch +// +kubebuilder:rbac:groups=networking.k8s.io,resources=ingressclasses,verbs=get;list;watch;update;patch +// +kubebuilder:rbac:groups=networking.k8s.io,resources=ingresses,verbs=get;list;watch +// +kubebuilder:rbac:groups=networking.k8s.io,resources=ingresses/status,verbs=get;update;patch + // IngressClassReconciler reconciles a IngressClass object type IngressClassReconciler struct { //nolint:revive // Naming this ClassReconciler would be confusing. Client client.Client diff --git a/samples/deploy/deployment.yaml b/samples/deploy/deployment.yaml new file mode 100644 index 0000000..b831435 --- /dev/null +++ b/samples/deploy/deployment.yaml @@ -0,0 +1,85 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: application-load-balancer-controller + namespace: stackit-alb-system + labels: + app.kubernetes.io/name: application-load-balancer-controller + app.kubernetes.io/part-of: stackit-alb +spec: + # Leader election is enabled, so it is safe to scale up for HA. + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: application-load-balancer-controller + template: + metadata: + labels: + app.kubernetes.io/name: application-load-balancer-controller + app.kubernetes.io/part-of: stackit-alb + spec: + serviceAccountName: application-load-balancer-controller + securityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + fsGroup: 65532 + seccompProfile: + type: RuntimeDefault + containers: + - name: manager + image: ghcr.io/stackitcloud/application-load-balancer-controller:v0.0.1-dev1 + imagePullPolicy: IfNotPresent + args: + - --cloud-config=/etc/stackit-alb/cloud-config.yaml + - --leader-elect=true + - --leader-election-namespace=stackit-alb-system + - --health-probe-bind-address=:8081 + - --metrics-bind-address=:8080 + - --zap-log-level=info + env: + # The STACKIT SDK picks up the service-account key from this path. + - name: STACKIT_SERVICE_ACCOUNT_KEY_PATH + value: /etc/stackit-alb/sa-key.json + ports: + - name: metrics + containerPort: 8080 + protocol: TCP + - name: probes + containerPort: 8081 + protocol: TCP + livenessProbe: + httpGet: + path: /healthz + port: probes + initialDelaySeconds: 15 + periodSeconds: 20 + readinessProbe: + httpGet: + path: /readyz + port: probes + initialDelaySeconds: 5 + periodSeconds: 10 + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + volumeMounts: + - name: config + mountPath: /etc/stackit-alb + readOnly: true + volumes: + - name: config + secret: + secretName: application-load-balancer-controller-config + defaultMode: 0o400 + terminationGracePeriodSeconds: 30 diff --git a/samples/deploy/kustomization.yaml b/samples/deploy/kustomization.yaml new file mode 100644 index 0000000..772e279 --- /dev/null +++ b/samples/deploy/kustomization.yaml @@ -0,0 +1,9 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - namespace.yaml + - serviceaccount.yaml + - rbac.yaml + - secret.yaml + - deployment.yaml diff --git a/samples/deploy/namespace.yaml b/samples/deploy/namespace.yaml new file mode 100644 index 0000000..25c59db --- /dev/null +++ b/samples/deploy/namespace.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: stackit-alb-system + labels: + app.kubernetes.io/name: application-load-balancer-controller + app.kubernetes.io/part-of: stackit-alb diff --git a/samples/deploy/rbac.yaml b/samples/deploy/rbac.yaml new file mode 100644 index 0000000..09f4c0f --- /dev/null +++ b/samples/deploy/rbac.yaml @@ -0,0 +1,128 @@ +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: application-load-balancer-controller + labels: + app.kubernetes.io/name: application-load-balancer-controller + app.kubernetes.io/part-of: stackit-alb +rules: + # IngressClasses are watched and a finalizer is added/removed. + - apiGroups: + - networking.k8s.io + resources: + - ingressclasses + verbs: + - get + - list + - watch + - update + - patch + # Ingresses are watched and their status is updated. + - apiGroups: + - networking.k8s.io + resources: + - ingresses + verbs: + - get + - list + - watch + - apiGroups: + - networking.k8s.io + resources: + - ingresses/status + verbs: + - get + - update + - patch + # Backend Services and TLS Secrets are read to build the ALB configuration. + - apiGroups: + - "" + resources: + - services + - secrets + verbs: + - get + - list + - watch + # Nodes are watched to derive ALB target IPs. + - apiGroups: + - "" + resources: + - nodes + verbs: + - get + - list + - watch + # Emit events for ingress/ingressclass reconciliations. + - apiGroups: + - "" + resources: + - events + verbs: + - create + - patch +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: application-load-balancer-controller + labels: + app.kubernetes.io/name: application-load-balancer-controller + app.kubernetes.io/part-of: stackit-alb +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: application-load-balancer-controller +subjects: + - kind: ServiceAccount + name: application-load-balancer-controller + namespace: stackit-alb-system +--- +# Permissions required by controller-runtime to perform leader election in the +# controller's own namespace. +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: application-load-balancer-controller-leader-election + namespace: stackit-alb-system + labels: + app.kubernetes.io/name: application-load-balancer-controller + app.kubernetes.io/part-of: stackit-alb +rules: + - apiGroups: + - coordination.k8s.io + resources: + - leases + verbs: + - get + - list + - watch + - create + - update + - patch + - delete + - apiGroups: + - "" + resources: + - events + verbs: + - create + - patch +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: application-load-balancer-controller-leader-election + namespace: stackit-alb-system + labels: + app.kubernetes.io/name: application-load-balancer-controller + app.kubernetes.io/part-of: stackit-alb +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: application-load-balancer-controller-leader-election +subjects: + - kind: ServiceAccount + name: application-load-balancer-controller + namespace: stackit-alb-system diff --git a/samples/deploy/secret.yaml b/samples/deploy/secret.yaml new file mode 100644 index 0000000..76b7466 --- /dev/null +++ b/samples/deploy/secret.yaml @@ -0,0 +1,35 @@ +--- +# Static Secret holding the cloud configuration and the STACKIT service-account +# key used by the controller to authenticate against the STACKIT APIs. +# +# Replace the placeholders below before applying: +# - projectId : the STACKIT project the ALBs are provisioned in. +# - region : the STACKIT region (e.g. eu01). +# - networkId : the network the ALBs are attached to. +# - sa-key.json : a service-account key JSON downloaded from the STACKIT +# portal. The key must have permissions to manage +# Application Load Balancers and Certificates in the project. +# +# Optional: override the API endpoints via the `apiEndpoints` block if you +# need to talk to non-default endpoints (e.g. for staging). +apiVersion: v1 +kind: Secret +type: Opaque +metadata: + name: application-load-balancer-controller-config + namespace: stackit-alb-system + labels: + app.kubernetes.io/name: application-load-balancer-controller + app.kubernetes.io/part-of: stackit-alb +stringData: + cloud-config.yaml: | + global: + projectId: REPLACE_WITH_PROJECT_ID + region: REPLACE_WITH_REGION + # apiEndpoints: + # applicationLoadBalancerApi: https://alb.api.eu01.stackit.cloud + # applicationLoadBalancerCertificateApi: https://certificates.api.eu01.stackit.cloud + applicationLoadBalancer: + networkId: REPLACE_WITH_NETWORK_ID + sa-key.json: | + REPLACE_WITH_STACKIT_SERVICE_ACCOUNT_KEY_JSON diff --git a/samples/deploy/serviceaccount.yaml b/samples/deploy/serviceaccount.yaml new file mode 100644 index 0000000..17ca782 --- /dev/null +++ b/samples/deploy/serviceaccount.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: application-load-balancer-controller + namespace: stackit-alb-system + labels: + app.kubernetes.io/name: application-load-balancer-controller + app.kubernetes.io/part-of: stackit-alb From 9e11ed8955b5d7fed48ec2589bf8b2cad745205f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Mon, 29 Jun 2026 14:05:02 +0200 Subject: [PATCH 47/77] Use positive jitter --- 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 be9bb90..93f21c5 100644 --- a/pkg/controller/ingress/spec/worktree.go +++ b/pkg/controller/ingress/spec/worktree.go @@ -388,7 +388,7 @@ func buildTargetPool(tree *WorkTreeALB, ingressClass *networkingv1.IngressClass, }, HealthyThreshold: new(int32(1)), Interval: new("5s"), - IntervalJitter: new("0s"), + IntervalJitter: new("1s"), Timeout: new("3s"), UnhealthyThreshold: new(int32(3)), } From 26a990e286484b6d2d6a6e930ceffb4a0f256a7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Mon, 29 Jun 2026 14:09:12 +0200 Subject: [PATCH 48/77] Use dev2 --- samples/deploy/deployment.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samples/deploy/deployment.yaml b/samples/deploy/deployment.yaml index b831435..08801e9 100644 --- a/samples/deploy/deployment.yaml +++ b/samples/deploy/deployment.yaml @@ -28,7 +28,7 @@ spec: type: RuntimeDefault containers: - name: manager - image: ghcr.io/stackitcloud/application-load-balancer-controller:v0.0.1-dev1 + image: ghcr.io/stackitcloud/application-load-balancer-controller:v0.0.1-dev2 imagePullPolicy: IfNotPresent args: - --cloud-config=/etc/stackit-alb/cloud-config.yaml From 36c71c92690fbd0ce44246e0324b927f7a9b4796 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Mon, 29 Jun 2026 14:55:20 +0200 Subject: [PATCH 49/77] Fix internal LBs --- pkg/controller/ingress/ingressclass_controller.go | 3 ++- pkg/controller/ingress/spec/worktree.go | 8 +++++++- pkg/controller/ingress/spec/worktree_test.go | 1 + 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/pkg/controller/ingress/ingressclass_controller.go b/pkg/controller/ingress/ingressclass_controller.go index 6fb6acb..dd894c8 100644 --- a/pkg/controller/ingress/ingressclass_controller.go +++ b/pkg/controller/ingress/ingressclass_controller.go @@ -105,7 +105,8 @@ func (r *IngressClassReconciler) updateStatus(ctx context.Context, ingressClass var albIP string if alb.ExternalAddress != nil && *alb.ExternalAddress != "" { albIP = *alb.ExternalAddress - } else if alb.PrivateAddress != nil && *alb.PrivateAddress != "" { + } else if alb.Options != nil && alb.Options.PrivateNetworkOnly != nil && + *alb.Options.PrivateNetworkOnly == true && alb.PrivateAddress != nil && *alb.PrivateAddress != "" { albIP = *alb.PrivateAddress } diff --git a/pkg/controller/ingress/spec/worktree.go b/pkg/controller/ingress/spec/worktree.go index 93f21c5..23139d6 100644 --- a/pkg/controller/ingress/spec/worktree.go +++ b/pkg/controller/ingress/spec/worktree.go @@ -577,6 +577,12 @@ func (t *WorkTreeALB) ToCreatePayload( //nolint:gocyclo,funlen // Breaking up th if t.externalIP != "" { externalAddress = new(t.externalIP) } + ephemeralAddress := new(false) + if t.externalIP == "" && !t.internalLB { + // Counter-intuitively an internal LB must set ephemeral address to false. + // So the only case where the values needs to be set to true is for public LBs without an existing IP. + ephemeralAddress = new(true) + } return &albsdk.CreateLoadBalancerPayload{ DisableTargetSecurityGroupAssignment: new(true), // TODO: Make this configurable via flag. @@ -593,7 +599,7 @@ func (t *WorkTreeALB) ToCreatePayload( //nolint:gocyclo,funlen // Breaking up th }, ExternalAddress: externalAddress, Options: &albsdk.LoadBalancerOptions{ - EphemeralAddress: new(t.externalIP == ""), + EphemeralAddress: ephemeralAddress, AccessControl: t.accessControl, PrivateNetworkOnly: new(t.internalLB), }, diff --git a/pkg/controller/ingress/spec/worktree_test.go b/pkg/controller/ingress/spec/worktree_test.go index efd5018..54af231 100644 --- a/pkg/controller/ingress/spec/worktree_test.go +++ b/pkg/controller/ingress/spec/worktree_test.go @@ -396,6 +396,7 @@ var _ = Describe("WorkTreeALB", func() { Expect(errs).To(BeEmpty()) create := tree.ToCreatePayload(nil, "network-id", "region") Expect(create.Options.PrivateNetworkOnly).To(HaveValue(BeTrue())) + Expect(create.Options.EphemeralAddress).To(HaveValue(BeFalse())) }) It("should set ALB to static if annotation contains IP", func() { From 919ab51ec7c7625b6d0f116274d09802e5ec7fe0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Mon, 29 Jun 2026 15:16:56 +0200 Subject: [PATCH 50/77] Ignore deploy folder --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index ed603ce..90fc3df 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,5 @@ __debug_* test/e2e/inventory* test/e2e/sa-key* test/e2e/kubeconfig* + +/deploy \ No newline at end of file From c715c8ae2750229c57f07e2f9971238433d115a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Mon, 29 Jun 2026 15:42:02 +0200 Subject: [PATCH 51/77] Filter out terminating nodes --- .../ingress/ingressclass_controller_test.go | 2 + pkg/controller/ingress/spec/worktree.go | 25 ++++++ pkg/controller/ingress/spec/worktree_test.go | 78 +++++++++++++++++++ 3 files changed, 105 insertions(+) diff --git a/pkg/controller/ingress/ingressclass_controller_test.go b/pkg/controller/ingress/ingressclass_controller_test.go index e040e14..a0a7248 100644 --- a/pkg/controller/ingress/ingressclass_controller_test.go +++ b/pkg/controller/ingress/ingressclass_controller_test.go @@ -302,6 +302,8 @@ var _ = Describe("IngressClassController", func() { }, ConsistOf("random-certificate-id")), )) }) + + // TODO: Test changes to nodes }) }) diff --git a/pkg/controller/ingress/spec/worktree.go b/pkg/controller/ingress/spec/worktree.go index 23139d6..c9d16be 100644 --- a/pkg/controller/ingress/spec/worktree.go +++ b/pkg/controller/ingress/spec/worktree.go @@ -413,6 +413,9 @@ func getTargetsOfNodes(nodes []corev1.Node) []albsdk.Target { targets := []albsdk.Target{} for i := range nodes { node := &nodes[i] + if isNodeTerminating(node) { + continue + } for j := range node.Status.Addresses { address := node.Status.Addresses[j] if address.Type == corev1.NodeInternalIP { @@ -632,3 +635,25 @@ func (t *WorkTreeALB) ToUpdatePayload( update.Version = t.existingALB.Version return update } + +const ( + // From https://github.com/kubernetes/cloud-provider/blob/81e4f58b4d1badd71d633d356faaaf69d971d874/controllers/service/controller.go#L64C2-L64C53 + TaintToBeDeleted = "ToBeDeletedByClusterAutoscaler" + // From https://github.com/gardener/machine-controller-manager/blob/fc341881a5e71d7c5f240ca73415f967084aa85b/pkg/util/provider/machineutils/utils.go#L61 + ConditionNodeTermination corev1.NodeConditionType = "Terminating" +) + +func isNodeTerminating(node *corev1.Node) bool { + for _, taint := range node.Spec.Taints { + if taint.Key == TaintToBeDeleted { + return true + } + } + + for _, condition := range node.Status.Conditions { + if condition.Type == ConditionNodeTermination && condition.Status == corev1.ConditionTrue { + return true + } + } + return false +} diff --git a/pkg/controller/ingress/spec/worktree_test.go b/pkg/controller/ingress/spec/worktree_test.go index 54af231..80d1476 100644 --- a/pkg/controller/ingress/spec/worktree_test.go +++ b/pkg/controller/ingress/spec/worktree_test.go @@ -573,4 +573,82 @@ var _ = Describe("WorkTreeALB", func() { }), )) }) + + It("should filter out nodes that don't meet the criteria to serve traffic", func() { + tree, 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}), + )), + }, + nil, + []corev1.Service{ + Service(corev1.NamespaceDefault, "my-service", WithServiceType(corev1.ServiceTypeNodePort), WithPort("my-port", 80, 30000, corev1.ProtocolTCP)), + }, []corev1.Node{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "node-that-meets-all-criteria", + }, + Status: corev1.NodeStatus{ + Addresses: []corev1.NodeAddress{ + { + Type: corev1.NodeInternalIP, + Address: "10.0.0.1", + }, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "node-with-termination-condition", + }, + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{ + { + Type: ConditionNodeTermination, + Status: corev1.ConditionTrue, + }, + }, + Addresses: []corev1.NodeAddress{ + { + Type: corev1.NodeInternalIP, + Address: "10.0.0.2", + }, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "node-with-tobedeleted-taint", + }, + Spec: corev1.NodeSpec{ + Taints: []corev1.Taint{ + { + Key: TaintToBeDeleted, + }, + }, + }, + Status: corev1.NodeStatus{ + Addresses: []corev1.NodeAddress{ + { + Type: corev1.NodeInternalIP, + Address: "10.0.0.3", + }, + }, + }, + }, + }, nil, + ) + + Expect(errs).To(BeEmpty()) + create := tree.ToCreatePayload(nil, "network-id", "region") + Expect(create.TargetPools).To(HaveLen(1)) + Expect(create.TargetPools[0].Targets).To(ConsistOf( + v2api.Target{ + DisplayName: new("node-that-meets-all-criteria"), + Ip: new("10.0.0.1"), + }, + )) + }) }) From b6ee361a5ccdfabc972ce54b822fc4a33529e231 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Mon, 29 Jun 2026 16:01:06 +0200 Subject: [PATCH 52/77] Enable lll linter --- .golangci.yaml | 1 + .../ingress/ingressclass_controller.go | 5 +- .../ingress/ingressclass_controller_test.go | 131 +++++++++--------- pkg/controller/ingress/setup.go | 1 - pkg/controller/ingress/spec/annotations.go | 3 +- pkg/controller/ingress/spec/worktree.go | 20 ++- pkg/controller/ingress/spec/worktree_test.go | 24 ++-- pkg/controller/ingress/update.go | 15 +- pkg/stackit/applicationloadbalancer.go | 12 +- .../applicationloadbalancercertificates.go | 4 +- pkg/testutil/testutil.go | 4 +- 11 files changed, 130 insertions(+), 90 deletions(-) diff --git a/.golangci.yaml b/.golangci.yaml index a41069f..0a547fd 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -19,6 +19,7 @@ linters: - govet - importas - ineffassign + - lll - misspell - modernize - nakedret diff --git a/pkg/controller/ingress/ingressclass_controller.go b/pkg/controller/ingress/ingressclass_controller.go index dd894c8..415ffde 100644 --- a/pkg/controller/ingress/ingressclass_controller.go +++ b/pkg/controller/ingress/ingressclass_controller.go @@ -91,7 +91,8 @@ func (r *IngressClassReconciler) Reconcile(ctx context.Context, req ctrl.Request } // 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) { +func (r *IngressClassReconciler) updateStatus( //nolint:gocyclo // TODO: Make this function smaller. + 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) @@ -106,7 +107,7 @@ func (r *IngressClassReconciler) updateStatus(ctx context.Context, ingressClass if alb.ExternalAddress != nil && *alb.ExternalAddress != "" { albIP = *alb.ExternalAddress } else if alb.Options != nil && alb.Options.PrivateNetworkOnly != nil && - *alb.Options.PrivateNetworkOnly == true && alb.PrivateAddress != nil && *alb.PrivateAddress != "" { + *alb.Options.PrivateNetworkOnly && alb.PrivateAddress != nil && *alb.PrivateAddress != "" { albIP = *alb.PrivateAddress } diff --git a/pkg/controller/ingress/ingressclass_controller_test.go b/pkg/controller/ingress/ingressclass_controller_test.go index a0a7248..c5cfcda 100644 --- a/pkg/controller/ingress/ingressclass_controller_test.go +++ b/pkg/controller/ingress/ingressclass_controller_test.go @@ -149,21 +149,23 @@ var _ = Describe("IngressClassController", func() { certClient.EXPECT().ListCertificate(gomock.Any(), gomock.Any(), gomock.Any()).Return(new(certsdk.ListCertificatesResponse{ Items: []certsdk.GetCertificateResponse{}, }), nil).AnyTimes() - albClient.EXPECT().GetLoadBalancer(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, _, _, _ string) (*albsdk.LoadBalancer, error) { - lb := getLoadBalancerResponse.Load() - if lb == nil { - return nil, stackit.ErrorNotFound - } - return lb, nil - }).AnyTimes() - albClient.EXPECT().CreateLoadBalancer(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, _, _ string, create *albsdk.CreateLoadBalancerPayload) (*albsdk.LoadBalancer, error) { - response := albsdk.LoadBalancer(*create) - response.Version = new("version-after-create") - response.ExternalAddress = new("127.0.0.1") - response.Status = new(stackit.LBStatusReady) - getLoadBalancerResponse.Store(&response) - return &response, nil - }).Times(1) + 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{ @@ -198,25 +200,28 @@ var _ = Describe("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(_ context.Context, _, _ string) (*certsdk.ListCertificatesResponse, error) { - return listCertificatesResponse.Load(), nil - }).AnyTimes() + certClient.EXPECT().ListCertificate(gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, _, _ string) (*certsdk.ListCertificatesResponse, error) { + return listCertificatesResponse.Load(), nil + }).AnyTimes() - albClient.EXPECT().GetLoadBalancer(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, _, _, _ string) (*albsdk.LoadBalancer, error) { - lb := getLoadBalancerResponse.Load() - if lb == nil { - return nil, stackit.ErrorNotFound - } - return lb, nil - }).AnyTimes() - albClient.EXPECT().CreateLoadBalancer(gomock.Any(), projectID, region, gomock.Any()).DoAndReturn(func(_ context.Context, _, _ string, create *albsdk.CreateLoadBalancerPayload) (*albsdk.LoadBalancer, error) { - response := albsdk.LoadBalancer(*create) - response.Version = new("version-after-create") - response.ExternalAddress = new("127.0.0.1") - response.Status = new(stackit.LBStatusReady) - getLoadBalancerResponse.Store(&response) - return &response, nil - }).Times(1) + albClient.EXPECT().GetLoadBalancer(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, _, _, _ string) (*albsdk.LoadBalancer, error) { + lb := getLoadBalancerResponse.Load() + if lb == nil { + return nil, stackit.ErrorNotFound + } + return lb, nil + }).AnyTimes() + albClient.EXPECT().CreateLoadBalancer(gomock.Any(), projectID, region, gomock.Any()). + DoAndReturn(func(_ context.Context, _, _ string, create *albsdk.CreateLoadBalancerPayload) (*albsdk.LoadBalancer, error) { + response := albsdk.LoadBalancer(*create) + response.Version = new("version-after-create") + response.ExternalAddress = new("127.0.0.1") + response.Status = new(stackit.LBStatusReady) + getLoadBalancerResponse.Store(&response) + return &response, nil + }).Times(1) ingressClass = &networkingv1.IngressClass{ ObjectMeta: metav1.ObjectMeta{ @@ -238,36 +243,38 @@ 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, _, _ string, certificate *certsdk.CreateCertificatePayload) (*certsdk.GetCertificateResponse, error) { - fingerprint, err := spec.ValidateTLSCertAndFingerprint([]byte(*certificate.PublicKey), []byte(*certificate.PrivateKey)) - if err != nil { - return nil, fmt.Errorf("invalid certificate: %w", err) - } - response := certsdk.GetCertificateResponse{ - Name: certificate.Name, - Id: new("random-certificate-id"), - Labels: certificate.Labels, - Data: &certsdk.Data{ - FingerprintSha256: new(fingerprint), - }, - PublicKey: certificate.PublicKey, - } - listCertificatesResponse.Store(&certsdk.ListCertificatesResponse{ - Items: []certsdk.GetCertificateResponse{response}, - }) - return &response, nil - }).Times(1) + certClient.EXPECT().CreateCertificate(gomock.Any(), projectID, region, gomock.Any()). + DoAndReturn(func(_ context.Context, _, _ string, certificate *certsdk.CreateCertificatePayload) (*certsdk.GetCertificateResponse, error) { + fingerprint, err := spec.ValidateTLSCertAndFingerprint([]byte(*certificate.PublicKey), []byte(*certificate.PrivateKey)) + if err != nil { + return nil, fmt.Errorf("invalid certificate: %w", err) + } + response := certsdk.GetCertificateResponse{ + Name: certificate.Name, + Id: new("random-certificate-id"), + Labels: certificate.Labels, + Data: &certsdk.Data{ + FingerprintSha256: new(fingerprint), + }, + PublicKey: certificate.PublicKey, + } + listCertificatesResponse.Store(&certsdk.ListCertificatesResponse{ + Items: []certsdk.GetCertificateResponse{response}, + }) + return &response, nil + }).Times(1) certClient.EXPECT().DeleteCertificate(gomock.Any(), projectID, region, "random-certificate-id").Return(nil).AnyTimes() - albClient.EXPECT().UpdateLoadBalancer(gomock.Any(), projectID, region, gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, _, _, _ string, update *albsdk.UpdateLoadBalancerPayload) (*albsdk.LoadBalancer, error) { - response := albsdk.LoadBalancer(*update) - response.Version = new("version-after-update") - response.ExternalAddress = new("127.0.0.1") - response.Status = new(stackit.LBStatusReady) - getLoadBalancerResponse.Store(&response) - - updateRequest.Store(update) - return (*albsdk.LoadBalancer)(update), nil - }).MinTimes(1) + albClient.EXPECT().UpdateLoadBalancer(gomock.Any(), projectID, region, gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, _, _, _ string, update *albsdk.UpdateLoadBalancerPayload) (*albsdk.LoadBalancer, error) { + response := albsdk.LoadBalancer(*update) + response.Version = new("version-after-update") + response.ExternalAddress = new("127.0.0.1") + response.Status = new(stackit.LBStatusReady) + getLoadBalancerResponse.Store(&response) + + updateRequest.Store(update) + return (*albsdk.LoadBalancer)(update), nil + }).MinTimes(1) secret := corev1.Secret{ ObjectMeta: metav1.ObjectMeta{Namespace: corev1.NamespaceDefault, Name: "my-tls-cert"}, diff --git a/pkg/controller/ingress/setup.go b/pkg/controller/ingress/setup.go index f73f47c..b5e96e8 100644 --- a/pkg/controller/ingress/setup.go +++ b/pkg/controller/ingress/setup.go @@ -66,7 +66,6 @@ func (r *IngressClassReconciler) SetupWithManager(ctx context.Context, mgr ctrl. refs := []string{} for i := range ingress.Spec.TLS { refs = append(refs, ingress.Spec.TLS[i].SecretName) - } return refs }); err != nil { diff --git a/pkg/controller/ingress/spec/annotations.go b/pkg/controller/ingress/spec/annotations.go index f5a9be4..6023fc1 100644 --- a/pkg/controller/ingress/spec/annotations.go +++ b/pkg/controller/ingress/spec/annotations.go @@ -25,7 +25,8 @@ const ( // 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? + // AnnotationTargetPoolTLSCustomCa If set, the application load balancer enables TLS bridging with a custom CA provided as value. + // TODO: 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. diff --git a/pkg/controller/ingress/spec/worktree.go b/pkg/controller/ingress/spec/worktree.go index c9d16be..0ba760b 100644 --- a/pkg/controller/ingress/spec/worktree.go +++ b/pkg/controller/ingress/spec/worktree.go @@ -202,7 +202,10 @@ func BuildTree( //nolint:gocyclo,funlen // Breaking up this function won't make continue } for pathIndex, path := range rule.HTTP.Paths { - ingressPathReference := ingressPathReference{namespace: ingress.Namespace, name: ingress.Name, uid: string(ingress.UID), ruleIndex: ruleIndex, pathIndex: pathIndex} + ingressPathReference := ingressPathReference{ + namespace: ingress.Namespace, name: ingress.Name, uid: string(ingress.UID), + ruleIndex: ruleIndex, pathIndex: pathIndex, + } targetPool, e := buildTargetPool(tree, ingressClass, targets, ingress, ruleIndex, path, pathIndex, servicesMap) errors = append(errors, e...) @@ -244,7 +247,11 @@ func addAccessControlToTree(tree *WorkTreeALB, ingressClass *networkingv1.Ingres // 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} @@ -272,7 +279,7 @@ func addPathToTree(tree *WorkTreeALB, ingressClass *networkingv1.IngressClass, i } } - albPath, exists := host.paths[_pathWithType] + _, exists = host.paths[_pathWithType] if exists { errors = append(errors, ErrorEvent{ Ingress: ingress, @@ -281,7 +288,7 @@ func addPathToTree(tree *WorkTreeALB, ingressClass *networkingv1.IngressClass, i }) return false, errors } - albPath = &workTreePath{ + albPath := &workTreePath{ path: _pathWithType, ingressPathReference: ingressPathReference, websocket: GetAnnotation(AnnotationWebSocket, false, ingress, ingressClass), @@ -300,7 +307,10 @@ 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, 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} diff --git a/pkg/controller/ingress/spec/worktree_test.go b/pkg/controller/ingress/spec/worktree_test.go index 80d1476..301d9d1 100644 --- a/pkg/controller/ingress/spec/worktree_test.go +++ b/pkg/controller/ingress/spec/worktree_test.go @@ -470,20 +470,24 @@ var _ = Describe("WorkTreeALB", func() { 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}), - )), + 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)), + Service(corev1.NamespaceDefault, "service-with-tls-disabled", WithServiceAnnotation(AnnotationTargetPoolTLSEnabled, "false"), + WithServiceType(corev1.ServiceTypeNodePort), WithPort("my-port", 80, 30001, corev1.ProtocolTCP)), }, nil, nil, ) diff --git a/pkg/controller/ingress/update.go b/pkg/controller/ingress/update.go index d63e5d5..79154ac 100644 --- a/pkg/controller/ingress/update.go +++ b/pkg/controller/ingress/update.go @@ -19,7 +19,9 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" ) -func (r *IngressClassReconciler) reconcileALBResources(ctx context.Context, ingressClass *networkingv1.IngressClass) error { //nolint:gocyclo,funlen // Breaking up this function won't make it much simpler. +func (r *IngressClassReconciler) reconcileALBResources( //nolint:gocyclo,funlen // TODO: Simplify this function. + 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) @@ -143,7 +145,8 @@ func (r *IngressClassReconciler) reconcileALBResources(ctx context.Context, ingr // 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. + // TODO: This and the next function can be generalized with a NamespacedReferenceList function. + // Possibly with a callback function for the indexes. Should return a map indexed with types.NamespacedName. services := []corev1.Service{} for i := range ingresses { ingress := ingresses[i] @@ -214,7 +217,9 @@ func (r *IngressClassReconciler) getTLSSecretsFromIngresses(ctx context.Context, } // getCertificatesForIngressClass returns all certificates matching the ingress class via label. -func (r *IngressClassReconciler) getCertificatesForIngressClass(ctx context.Context, ingressClass *networkingv1.IngressClass) ([]certsdk.GetCertificateResponse, error) { +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 { @@ -232,7 +237,9 @@ 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) || optionsChanged(alb.Options, albPayload.Options) + return listenersChanged(alb.Listeners, albPayload.Listeners) || + targetPoolsChanged(alb.TargetPools, albPayload.TargetPools) || + optionsChanged(alb.Options, albPayload.Options) } func listenersChanged(current, desired []albsdk.Listener) bool { diff --git a/pkg/stackit/applicationloadbalancer.go b/pkg/stackit/applicationloadbalancer.go index 3e10f50..3f0a372 100644 --- a/pkg/stackit/applicationloadbalancer.go +++ b/pkg/stackit/applicationloadbalancer.go @@ -59,7 +59,9 @@ func (cl applicationLoadBalancerClient) DeleteLoadBalancer(ctx context.Context, } // 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) { +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 @@ -73,7 +75,9 @@ func (cl applicationLoadBalancerClient) UpdateLoadBalancer(ctx context.Context, 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 { +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 } @@ -95,7 +99,9 @@ func (cl applicationLoadBalancerClient) GetCredentials(ctx context.Context, proj 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 { +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 diff --git a/pkg/stackit/applicationloadbalancercertificates.go b/pkg/stackit/applicationloadbalancercertificates.go index ce777a2..41d9674 100644 --- a/pkg/stackit/applicationloadbalancercertificates.go +++ b/pkg/stackit/applicationloadbalancercertificates.go @@ -37,7 +37,9 @@ func (cl certClient) DeleteCertificate(ctx context.Context, projectID, region, n return err } -func (cl certClient) CreateCertificate(ctx context.Context, projectID, region string, certificate *certsdk.CreateCertificatePayload) (*certsdk.GetCertificateResponse, error) { +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 diff --git a/pkg/testutil/testutil.go b/pkg/testutil/testutil.go index 2b1536f..2c29de8 100644 --- a/pkg/testutil/testutil.go +++ b/pkg/testutil/testutil.go @@ -15,7 +15,9 @@ func DeleteAndWaitForKubernetesResource(ctx context.Context, cl client.Client, o 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)) + 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 5767b95e41da3cddeb93bc695e01a9b2c5823a78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Mon, 29 Jun 2026 16:04:05 +0200 Subject: [PATCH 53/77] Merge two if's into one --- pkg/controller/ingress/spec/worktree.go | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/pkg/controller/ingress/spec/worktree.go b/pkg/controller/ingress/spec/worktree.go index 0ba760b..2f07da7 100644 --- a/pkg/controller/ingress/spec/worktree.go +++ b/pkg/controller/ingress/spec/worktree.go @@ -316,15 +316,13 @@ func buildTargetPool( ingressPathReference := ingressPathReference{namespace: ingress.Namespace, name: ingress.Name, uid: string(ingress.UID), ruleIndex: ruleIndex, pathIndex: pathIndex} _, exists := tree.targetPools[ingressPathReference] - if !exists { - if len(tree.targetPools) >= LimitTargetPools { - errors = append(errors, ErrorEvent{ - Ingress: ingress, - FieldPath: field.NewPath("spec", "rules").Index(ruleIndex).Child("paths").Index(pathIndex), - Description: "Target pool limit reached. Path will be ignored.", - }) - return nil, errors - } + if !exists && 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.", + }) + return nil, errors } targetPool := &albsdk.TargetPool{} From b744a237cb4d23c373609e5fdb840958f0705ca5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Mon, 29 Jun 2026 16:12:08 +0200 Subject: [PATCH 54/77] Use constants for protocol --- pkg/controller/ingress/spec/worktree.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/controller/ingress/spec/worktree.go b/pkg/controller/ingress/spec/worktree.go index 2f07da7..aeb0248 100644 --- a/pkg/controller/ingress/spec/worktree.go +++ b/pkg/controller/ingress/spec/worktree.go @@ -526,9 +526,9 @@ func (t *WorkTreeALB) ToCreatePayload( //nolint:gocyclo,funlen // Breaking up th } var https *albsdk.ProtocolOptionsHTTPS - protocol := "PROTOCOL_HTTP" + prot := protocolHTTP if listener.protocol == protocolHTTPS { - protocol = "PROTOCOL_HTTPS" + prot = protocolHTTPS https = &albsdk.ProtocolOptionsHTTPS{ CertificateConfig: &albsdk.CertificateConfig{ CertificateIds: []string{}, @@ -555,7 +555,7 @@ func (t *WorkTreeALB) ToCreatePayload( //nolint:gocyclo,funlen // Breaking up th listeners = append(listeners, albsdk.Listener{ Name: new(fmt.Sprintf("port-%d", port)), WafConfigName: waf, - Protocol: &protocol, + Protocol: new(string(prot)), Port: new(int32(port)), Http: &albsdk.ProtocolOptionsHTTP{ Hosts: hosts, From 683565027ea6bf52f9251681c5de5a0a0f2192a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Mon, 29 Jun 2026 16:16:18 +0200 Subject: [PATCH 55/77] Remove webhook section --- docs/user.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/docs/user.md b/docs/user.md index 17adb69..ec7a619 100644 --- a/docs/user.md +++ b/docs/user.md @@ -134,10 +134,6 @@ The field `Ingress.spec.tls.hosts` is ignored by the controller. The ALB takes t 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. From 8d0bc92eeaf8714f9f3529e43d073b34d9ea6994 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Mon, 29 Jun 2026 16:33:53 +0200 Subject: [PATCH 56/77] Add a topic sentence to README --- README.md | 6 ++++-- docs/development.md | 3 +++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index dad86cf..cfc86a4 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ -# Application load balancer (ALB) controller +# Application load balancer controller -[![Go Reference](https://pkg.go.dev/badge/github.com/stackitcloud/application-load-balancer-controller.svg)](https://pkg.go.dev/github.com/stackitcloud/application-load-balancer-controller) +The STACKIT Application Load Balancer Controller (ALBC) exposes HTTP/HTTPS applications by provisioning and configuring managed STACKIT Application Load Balancers based on Kubernetes resources. Currently, the controller supports the [Ingress API](https://kubernetes.io/docs/concepts/services-networking/ingress/). + +This project is currently in alpha stage. - [User docs](./docs/user.md) \ No newline at end of file diff --git a/docs/development.md b/docs/development.md index e69de29..2227714 100644 --- a/docs/development.md +++ b/docs/development.md @@ -0,0 +1,3 @@ +# Development + +[![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) \ No newline at end of file From b312eb3458d8dfcb8529bea996519e3977328ecd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Mon, 29 Jun 2026 16:55:14 +0200 Subject: [PATCH 57/77] Implement feedback for STACKIT package --- pkg/controller/ingress/update.go | 5 ++--- pkg/stackit/applicationloadbalancer.go | 2 -- pkg/stackit/config/config.go | 2 -- pkg/stackit/{client.go => error.go} | 0 pkg/stackit/suite_test.go | 13 ------------- 5 files changed, 2 insertions(+), 20 deletions(-) rename pkg/stackit/{client.go => error.go} (100%) delete mode 100644 pkg/stackit/suite_test.go diff --git a/pkg/controller/ingress/update.go b/pkg/controller/ingress/update.go index 79154ac..6bcad06 100644 --- a/pkg/controller/ingress/update.go +++ b/pkg/controller/ingress/update.go @@ -2,7 +2,6 @@ package ingress import ( "context" - "errors" "fmt" "slices" @@ -43,10 +42,10 @@ func (r *IngressClassReconciler) reconcileALBResources( //nolint:gocyclo,funlen } 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) { + if err != nil && !stackit.IsNotFound(err) { return fmt.Errorf("failed to get load balancer: %w", err) } - if errors.Is(err, stackit.ErrorNotFound) { + if stackit.IsNotFound(err) { existingALB = nil } diff --git a/pkg/stackit/applicationloadbalancer.go b/pkg/stackit/applicationloadbalancer.go index 3f0a372..815b355 100644 --- a/pkg/stackit/applicationloadbalancer.go +++ b/pkg/stackit/applicationloadbalancer.go @@ -17,8 +17,6 @@ const ( ProtocolHTTP = "PROTOCOL_HTTP" ProtocolHTTPS = "PROTOCOL_HTTPS" - - ProjectStatusDisabled ProjectStatus = "STATUS_DISABLED" ) type ApplicationLoadBalancerClient interface { diff --git a/pkg/stackit/config/config.go b/pkg/stackit/config/config.go index 6082d17..9c5a4a8 100644 --- a/pkg/stackit/config/config.go +++ b/pkg/stackit/config/config.go @@ -15,8 +15,6 @@ type GlobalOpts struct { } type APIEndpoints struct { - IaasAPI string `yaml:"iaasApi"` - LoadBalancerAPI string `yaml:"loadBalancerApi"` ApplicationLoadBalancerAPI string `yaml:"applicationLoadBalancerApi"` ApplicationLoadBalancerCertificateAPI string `yaml:"applicationLoadBalancerCertificateApi"` } diff --git a/pkg/stackit/client.go b/pkg/stackit/error.go similarity index 100% rename from pkg/stackit/client.go rename to pkg/stackit/error.go diff --git a/pkg/stackit/suite_test.go b/pkg/stackit/suite_test.go deleted file mode 100644 index 5450af3..0000000 --- a/pkg/stackit/suite_test.go +++ /dev/null @@ -1,13 +0,0 @@ -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") -} From cdd9e1852f45510d49829b570b32f61c8b4f9b43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Mon, 29 Jun 2026 16:57:50 +0200 Subject: [PATCH 58/77] Validate that HTTP and HTTPS port are in range --- pkg/controller/ingress/spec/worktree.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/pkg/controller/ingress/spec/worktree.go b/pkg/controller/ingress/spec/worktree.go index aeb0248..1dd115c 100644 --- a/pkg/controller/ingress/spec/worktree.go +++ b/pkg/controller/ingress/spec/worktree.go @@ -7,6 +7,7 @@ import ( "encoding/hex" "fmt" "maps" + "math" "slices" "strings" @@ -157,6 +158,21 @@ func BuildTree( //nolint:gocyclo,funlen // Breaking up this function won't make httpPort := GetAnnotation(AnnotationHTTPPort, 80, ingress, ingressClass) httpsPort := GetAnnotation(AnnotationHTTPSPort, 443, ingress, ingressClass) + if !httpsOnly && (httpPort <= 0 || httpPort > math.MaxUint16) { + errors = append(errors, ErrorEvent{ + Ingress: ingress, + Description: "HTTP port is out of range.", + }) + continue + } + if len(ingress.Spec.TLS) > 0 && (httpsPort <= 0 || httpsPort > math.MaxUint16) { + errors = append(errors, ErrorEvent{ + Ingress: ingress, + Description: "HTTPS port is out of range.", + }) + continue + } + for tlsIndex, tls := range ingress.Spec.TLS { secret, exists := secretsMap[types.NamespacedName{Namespace: ingress.Namespace, Name: tls.SecretName}] if !exists { From b24f972e707bccea68c8a4264b1ddfc84a91f77d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Mon, 29 Jun 2026 17:01:57 +0200 Subject: [PATCH 59/77] Change port to uint16 --- pkg/controller/ingress/spec/worktree.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/pkg/controller/ingress/spec/worktree.go b/pkg/controller/ingress/spec/worktree.go index 1dd115c..df45730 100644 --- a/pkg/controller/ingress/spec/worktree.go +++ b/pkg/controller/ingress/spec/worktree.go @@ -38,7 +38,7 @@ type WorkTreeALB struct { internalLB bool externalIP string - listeners map[int16]*workTreeListener + listeners map[uint16]*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 @@ -91,7 +91,7 @@ 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 + Ports map[uint16]any } // BuildTree creates a new work tree. @@ -134,7 +134,7 @@ func BuildTree( //nolint:gocyclo,funlen // Breaking up this function won't make internalLB: GetAnnotation(AnnotationInternal, false, ingressClass), externalIP: GetAnnotation(AnnotationExternalIP, "", ingressClass), - listeners: map[int16]*workTreeListener{}, + listeners: map[uint16]*workTreeListener{}, targetPools: map[ingressPathReference]*albsdk.TargetPool{}, existingALB: existingALB, certificates: map[CertificateFingerprint]WorkTreeCertificate{}, @@ -206,10 +206,10 @@ func BuildTree( //nolint:gocyclo,funlen // Breaking up this function won't make tree.certificates[CertificateFingerprint(fingerprint)] = WorkTreeCertificate{ PublicKey: string(secret.Data[corev1.TLSCertKey]), PrivateKey: string(secret.Data[corev1.TLSPrivateKeyKey]), - Ports: map[int16]any{}, + Ports: map[uint16]any{}, } } - tree.certificates[CertificateFingerprint(fingerprint)].Ports[int16(httpsPort)] = nil + tree.certificates[CertificateFingerprint(fingerprint)].Ports[uint16(httpsPort)] = nil } for ruleIndex, rule := range ingress.Spec.Rules { @@ -231,11 +231,11 @@ func BuildTree( //nolint:gocyclo,funlen // Breaking up this function won't make 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, uint16(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, uint16(httpsPort), protocolHTTPS) errors = append(errors, e...) } @@ -266,7 +266,7 @@ func addAccessControlToTree(tree *WorkTreeALB, ingressClass *networkingv1.Ingres func addPathToTree( tree *WorkTreeALB, ingressClass *networkingv1.IngressClass, ingress *networkingv1.Ingress, rule networkingv1.IngressRule, ruleIndex int, path networkingv1.HTTPIngressPath, pathIndex int, - port int16, protocol protocol, + port uint16, 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} From a695688da4fe72739eee2d4fcf52e626709f8b98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Mon, 29 Jun 2026 17:05:28 +0200 Subject: [PATCH 60/77] Remove custom scheme --- cmd/application-load-balancer-controller/main.go | 10 ---------- pkg/controller/ingress/ingressclass_controller.go | 2 -- pkg/controller/ingress/ingressclass_controller_test.go | 1 - 3 files changed, 13 deletions(-) diff --git a/cmd/application-load-balancer-controller/main.go b/cmd/application-load-balancer-controller/main.go index 18d554f..cf98149 100644 --- a/cmd/application-load-balancer-controller/main.go +++ b/cmd/application-load-balancer-controller/main.go @@ -11,9 +11,6 @@ import ( 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" @@ -22,14 +19,9 @@ import ( ) 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 @@ -68,7 +60,6 @@ func main() { } mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ - Scheme: scheme, Metrics: metricsserver.Options{ BindAddress: opts.metricsAddr, }, @@ -123,7 +114,6 @@ func main() { 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") diff --git a/pkg/controller/ingress/ingressclass_controller.go b/pkg/controller/ingress/ingressclass_controller.go index 415ffde..264e78a 100644 --- a/pkg/controller/ingress/ingressclass_controller.go +++ b/pkg/controller/ingress/ingressclass_controller.go @@ -10,7 +10,6 @@ import ( 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" @@ -38,7 +37,6 @@ type IngressClassReconciler struct { //nolint:revive // Naming this ClassReconci Recorder record.EventRecorder ALBClient stackit.ApplicationLoadBalancerClient CertificateClient stackit.CertificatesClient - Scheme *runtime.Scheme ALBConfig stackitconfig.ALBConfig } diff --git a/pkg/controller/ingress/ingressclass_controller_test.go b/pkg/controller/ingress/ingressclass_controller_test.go index c5cfcda..1c1faa8 100644 --- a/pkg/controller/ingress/ingressclass_controller_test.go +++ b/pkg/controller/ingress/ingressclass_controller_test.go @@ -93,7 +93,6 @@ var _ = Describe("IngressClassController", func() { reconciler := ingress.IngressClassReconciler{ Recorder: recorder, Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), ALBClient: albClient, CertificateClient: certClient, ALBConfig: stackitconfig.ALBConfig{ From 5b65b478bbf347386eb9adbc736b090697ef7513 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Mon, 29 Jun 2026 17:08:50 +0200 Subject: [PATCH 61/77] Consistently use LoadBalancerName --- 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 df45730..14ed2ab 100644 --- a/pkg/controller/ingress/spec/worktree.go +++ b/pkg/controller/ingress/spec/worktree.go @@ -613,7 +613,7 @@ func (t *WorkTreeALB) ToCreatePayload( //nolint:gocyclo,funlen // Breaking up th return &albsdk.CreateLoadBalancerPayload{ DisableTargetSecurityGroupAssignment: new(true), // TODO: Make this configurable via flag. - Name: new(fmt.Sprintf("k8s-ingress-%s", t.ingressClass.UID)), + Name: new(LoadBalancerName(t.ingressClass)), Labels: &map[string]string{ "ingress-class-uid": string(t.ingressClass.UID), }, From bcfcc5655dc3d1e6725fce14fe532e7340e23a2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Mon, 29 Jun 2026 17:16:01 +0200 Subject: [PATCH 62/77] Use newer DEV image --- samples/deploy/deployment.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samples/deploy/deployment.yaml b/samples/deploy/deployment.yaml index 08801e9..3a3a074 100644 --- a/samples/deploy/deployment.yaml +++ b/samples/deploy/deployment.yaml @@ -28,7 +28,7 @@ spec: type: RuntimeDefault containers: - name: manager - image: ghcr.io/stackitcloud/application-load-balancer-controller:v0.0.1-dev2 + image: ghcr.io/stackitcloud/application-load-balancer-controller-dev:12f902a imagePullPolicy: IfNotPresent args: - --cloud-config=/etc/stackit-alb/cloud-config.yaml From 891238802ae80426b8e3e1bea852b7d201b560ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Tue, 30 Jun 2026 08:59:01 +0200 Subject: [PATCH 63/77] Make tree receiver of addPath --- pkg/controller/ingress/spec/worktree.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/pkg/controller/ingress/spec/worktree.go b/pkg/controller/ingress/spec/worktree.go index 14ed2ab..62e502a 100644 --- a/pkg/controller/ingress/spec/worktree.go +++ b/pkg/controller/ingress/spec/worktree.go @@ -231,11 +231,11 @@ func BuildTree( //nolint:gocyclo,funlen // Breaking up this function won't make var httpAdded, httpsAdded bool if !httpsOnly { - httpAdded, e = addPathToTree(tree, ingressClass, ingress, rule, ruleIndex, path, pathIndex, uint16(httpPort), protocolHTTP) + httpAdded, e = tree.addPath(ingressClass, ingress, rule, ruleIndex, path, pathIndex, uint16(httpPort), protocolHTTP) errors = append(errors, e...) } if len(ingress.Spec.TLS) > 0 { - httpsAdded, e = addPathToTree(tree, ingressClass, ingress, rule, ruleIndex, path, pathIndex, uint16(httpsPort), protocolHTTPS) + httpsAdded, e = tree.addPath(ingressClass, ingress, rule, ruleIndex, path, pathIndex, uint16(httpsPort), protocolHTTPS) errors = append(errors, e...) } @@ -261,17 +261,17 @@ func addAccessControlToTree(tree *WorkTreeALB, ingressClass *networkingv1.Ingres } } -// addPathToTree adds the given path to tree under the given port and protocol. +// addPath 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, +func (t *WorkTreeALB) addPath( + ingressClass *networkingv1.IngressClass, ingress *networkingv1.Ingress, rule networkingv1.IngressRule, ruleIndex int, path networkingv1.HTTPIngressPath, pathIndex int, port uint16, 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] + listener, exists := t.listeners[port] if !exists { listener = &workTreeListener{ hosts: map[string]*workTreeHost{}, @@ -311,7 +311,7 @@ func addPathToTree( } // 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 + t.listeners[port] = listener listener.hosts[rule.Host] = host host.paths[_pathWithType] = albPath From 1df502f6d3b291c457549e6eaf306db374791e8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Tue, 30 Jun 2026 09:00:40 +0200 Subject: [PATCH 64/77] Fix test assertion --- pkg/controller/ingress/spec/labels_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/controller/ingress/spec/labels_test.go b/pkg/controller/ingress/spec/labels_test.go index d9ad4a0..9082794 100644 --- a/pkg/controller/ingress/spec/labels_test.go +++ b/pkg/controller/ingress/spec/labels_test.go @@ -19,7 +19,7 @@ var _ = Describe("SanitizeLabelValue", func() { 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(HaveLen(63)) Expect(result).To(Equal("this-is-a-very-long-label-that-should-be-truncated-to-63-charac")) }) From d247bde724a289d5664db523f6b3090bfea4eb5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Tue, 30 Jun 2026 09:05:32 +0200 Subject: [PATCH 65/77] Initialize target pool as late as possible --- pkg/controller/ingress/spec/worktree.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/pkg/controller/ingress/spec/worktree.go b/pkg/controller/ingress/spec/worktree.go index 62e502a..cd90374 100644 --- a/pkg/controller/ingress/spec/worktree.go +++ b/pkg/controller/ingress/spec/worktree.go @@ -340,7 +340,6 @@ func buildTargetPool( }) return nil, errors } - targetPool := &albsdk.TargetPool{} // TODO: Support other backends than services. if path.Backend.Service == nil { @@ -392,9 +391,11 @@ func buildTargetPool( return nil, errors } - targetPool.Name = new(ingressPathReference.toTargetPoolName()) - targetPool.TargetPort = new(nodePort) - targetPool.Targets = targets + targetPool := &albsdk.TargetPool{ + Name: new(ingressPathReference.toTargetPoolName()), + TargetPort: new(nodePort), + Targets: targets, + } targetPool.TlsConfig = &albsdk.TlsConfig{ Enabled: new(GetAnnotation(AnnotationTargetPoolTLSEnabled, false, &service, ingress, ingressClass)), SkipCertificateValidation: new(GetAnnotation(AnnotationTargetPoolTLSSkipCertificateValidation, false, &service, ingress, ingressClass)), From 245b06f9575255091efdc298cd69d487ee24993e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Tue, 30 Jun 2026 09:10:37 +0200 Subject: [PATCH 66/77] Add comment to clarify API behavior for health checks --- pkg/controller/ingress/spec/worktree.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/controller/ingress/spec/worktree.go b/pkg/controller/ingress/spec/worktree.go index cd90374..5e3f58e 100644 --- a/pkg/controller/ingress/spec/worktree.go +++ b/pkg/controller/ingress/spec/worktree.go @@ -411,6 +411,8 @@ func buildTargetPool( Path: new("/healthz"), OkStatuses: []string{"200"}, }, + // If ActiveHealthCheck is set then all fields in it have to be set. + // The fields below are not strictly needed for externalTrafficPolicy=Local. HealthyThreshold: new(int32(1)), Interval: new("5s"), IntervalJitter: new("1s"), From e9658d2ccec2eb42a686568e9f9050b5c28d1530 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Tue, 30 Jun 2026 09:31:16 +0200 Subject: [PATCH 67/77] Check that health check port is set --- pkg/controller/ingress/spec/worktree.go | 8 ++++ pkg/controller/ingress/spec/worktree_test.go | 29 ++++++++++++ pkg/testutil/ingress/ingress.go | 48 +++++++------------- pkg/testutil/service/service.go | 24 ++++------ 4 files changed, 61 insertions(+), 48 deletions(-) diff --git a/pkg/controller/ingress/spec/worktree.go b/pkg/controller/ingress/spec/worktree.go index 5e3f58e..5fc285c 100644 --- a/pkg/controller/ingress/spec/worktree.go +++ b/pkg/controller/ingress/spec/worktree.go @@ -405,6 +405,14 @@ func buildTargetPool( } // If externalTrafficPolicy=Cluster we use the default TCP health check on the node port itself. if service.Spec.ExternalTrafficPolicy == corev1.ServiceExternalTrafficPolicyLocal { + if service.Spec.HealthCheckNodePort == 0 { + errors = append(errors, ErrorEvent{ + Ingress: ingress, + FieldPath: field.NewPath("spec", "rules").Index(ruleIndex).Child("paths").Index(pathIndex).Child("backend", "service"), + Description: "Service has externalTrafficPolicy=Local but doesn't have a health check node port. The service must be of type LoadBalancer.", + }) + return nil, errors + } targetPool.ActiveHealthCheck = &albsdk.ActiveHealthCheck{ AltPort: &service.Spec.HealthCheckNodePort, HttpHealthChecks: &albsdk.HttpHealthChecks{ diff --git a/pkg/controller/ingress/spec/worktree_test.go b/pkg/controller/ingress/spec/worktree_test.go index 301d9d1..9760d4e 100644 --- a/pkg/controller/ingress/spec/worktree_test.go +++ b/pkg/controller/ingress/spec/worktree_test.go @@ -578,6 +578,35 @@ var _ = Describe("WorkTreeALB", func() { )) }) + It("should drop target pools with etp=Local and missing health check port", func() { + tree, 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}), + )), + }, + nil, + []corev1.Service{ + Service(corev1.NamespaceDefault, "my-service", + WithServiceType(corev1.ServiceTypeNodePort), WithPort("my-port", 80, 30000, corev1.ProtocolTCP), + func(service *corev1.Service) { + service.Spec.ExternalTrafficPolicy = corev1.ServiceExternalTrafficPolicyLocal + }, + ), + }, nil, nil, + ) + + Expect(errs).To(ConsistOf( + MatchAllFields(Fields{ + "Ingress": testutil.HaveName("ingress-1"), + "Description": Equal("Service has externalTrafficPolicy=Local but doesn't have a health check node port. The service must be of type LoadBalancer."), + "FieldPath": Equal(field.NewPath("spec", "rules").Index(0).Child("paths").Index(0).Child("backend", "service")), + }), + )) + Expect(tree.targetPools).To(HaveLen(0)) + }) + It("should filter out nodes that don't meet the criteria to serve traffic", func() { tree, errs := BuildTree( &networkingv1.IngressClass{}, diff --git a/pkg/testutil/ingress/ingress.go b/pkg/testutil/ingress/ingress.go index 75743a6..45b6cd4 100644 --- a/pkg/testutil/ingress/ingress.go +++ b/pkg/testutil/ingress/ingress.go @@ -18,49 +18,41 @@ func Ingress(namespace, name string, opts ...IngressOption) networkingv1.Ingress }, } for _, o := range opts { - o.applyToIngress(&i) + o(&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) -} +type IngressOption func(ingress *networkingv1.Ingress) func WithUID(uid string) IngressOption { - return ingressOptionFunc(func(ingress *networkingv1.Ingress) { + return func(ingress *networkingv1.Ingress) { ingress.UID = types.UID(uid) - }) + } } func WithIngressClass(ingressClass string) IngressOption { - return ingressOptionFunc(func(ingress *networkingv1.Ingress) { + return func(ingress *networkingv1.Ingress) { ingress.Spec.IngressClassName = new(ingressClass) - }) + } } func WithAnnotation(key, value string) IngressOption { - return ingressOptionFunc(func(ingress *networkingv1.Ingress) { + return func(ingress *networkingv1.Ingress) { ingress.Annotations[key] = value - }) + } } func WithTLSSecret(secretName string) IngressOption { - return ingressOptionFunc(func(ingress *networkingv1.Ingress) { + return 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) { + return func(ingress *networkingv1.Ingress) { rule := networkingv1.IngressRule{ Host: host, IngressRuleValue: networkingv1.IngressRuleValue{ @@ -68,24 +60,16 @@ func WithRule(host string, opts ...RuleOptions) IngressOption { }, } for _, o := range opts { - o.applyToRule(&rule) + o(&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) -} +type RuleOptions func(rule *networkingv1.IngressRule) func WithPath(path string, _type *networkingv1.PathType, serviceName string, serviceBackendPort networkingv1.ServiceBackendPort) RuleOptions { - return ruleOptionsFunc(func(rule *networkingv1.IngressRule) { + return func(rule *networkingv1.IngressRule) { if rule.HTTP.Paths == nil { rule.HTTP.Paths = []networkingv1.HTTPIngressPath{} } @@ -99,5 +83,5 @@ func WithPath(path string, _type *networkingv1.PathType, serviceName string, ser }, }, }) - }) + } } diff --git a/pkg/testutil/service/service.go b/pkg/testutil/service/service.go index c9a1d0c..c5e554f 100644 --- a/pkg/testutil/service/service.go +++ b/pkg/testutil/service/service.go @@ -19,40 +19,32 @@ func Service(namespace, name string, opts ...ServiceOption) corev1.Service { }, } for _, o := range opts { - o.ApplyToService(&service) + o(&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) -} +type ServiceOption func(service *corev1.Service) func WithPort(name string, port, nodePort int32, protocol corev1.Protocol) ServiceOption { - return serviceOptionFunc(func(service *corev1.Service) { + return 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) { + return func(service *corev1.Service) { service.Spec.Type = _type - }) + } } func WithServiceAnnotation(key, value string) ServiceOption { - return serviceOptionFunc(func(service *corev1.Service) { + return func(service *corev1.Service) { service.Annotations[key] = value - }) + } } From 3992a4faa51f1b1999099375277665f125312a13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Tue, 30 Jun 2026 10:00:22 +0200 Subject: [PATCH 68/77] Don't use the same name for variables and types Avoid the use of underscores at the beginning of variable names --- pkg/controller/ingress/spec/worktree.go | 23 +++++++++++------------ pkg/testutil/service/service.go | 4 ++-- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/pkg/controller/ingress/spec/worktree.go b/pkg/controller/ingress/spec/worktree.go index 5fc285c..4ab43cb 100644 --- a/pkg/controller/ingress/spec/worktree.go +++ b/pkg/controller/ingress/spec/worktree.go @@ -218,7 +218,7 @@ func BuildTree( //nolint:gocyclo,funlen // Breaking up this function won't make continue } for pathIndex, path := range rule.HTTP.Paths { - ingressPathReference := ingressPathReference{ + ingressPathRef := ingressPathReference{ namespace: ingress.Namespace, name: ingress.Name, uid: string(ingress.UID), ruleIndex: ruleIndex, pathIndex: pathIndex, } @@ -241,7 +241,7 @@ func BuildTree( //nolint:gocyclo,funlen // Breaking up this function won't make // 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 + tree.targetPools[ingressPathRef] = targetPool } } } @@ -268,8 +268,8 @@ func (t *WorkTreeALB) addPath( rule networkingv1.IngressRule, ruleIndex int, path networkingv1.HTTPIngressPath, pathIndex int, port uint16, 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} + pathAndType := pathWithType{pathType: ptr.Deref(path.PathType, networkingv1.PathTypeExact), path: path.Path} + ingressPathRef := ingressPathReference{namespace: ingress.Namespace, name: ingress.Name, uid: string(ingress.UID), ruleIndex: ruleIndex, pathIndex: pathIndex} listener, exists := t.listeners[port] if !exists { @@ -295,7 +295,7 @@ func (t *WorkTreeALB) addPath( } } - _, exists = host.paths[_pathWithType] + _, exists = host.paths[pathAndType] if exists { errors = append(errors, ErrorEvent{ Ingress: ingress, @@ -305,8 +305,8 @@ func (t *WorkTreeALB) addPath( return false, errors } albPath := &workTreePath{ - path: _pathWithType, - ingressPathReference: ingressPathReference, + path: pathAndType, + ingressPathReference: ingressPathRef, websocket: GetAnnotation(AnnotationWebSocket, false, ingress, ingressClass), } @@ -314,7 +314,7 @@ func (t *WorkTreeALB) addPath( t.listeners[port] = listener listener.hosts[rule.Host] = host - host.paths[_pathWithType] = albPath + host.paths[pathAndType] = albPath return true, errors } @@ -329,9 +329,9 @@ func buildTargetPool( ) (*albsdk.TargetPool, []ErrorEvent) { errors := []ErrorEvent{} - ingressPathReference := ingressPathReference{namespace: ingress.Namespace, name: ingress.Name, uid: string(ingress.UID), ruleIndex: ruleIndex, pathIndex: pathIndex} + ingressPathRef := ingressPathReference{namespace: ingress.Namespace, name: ingress.Name, uid: string(ingress.UID), ruleIndex: ruleIndex, pathIndex: pathIndex} - _, exists := tree.targetPools[ingressPathReference] + _, exists := tree.targetPools[ingressPathRef] if !exists && len(tree.targetPools) >= LimitTargetPools { errors = append(errors, ErrorEvent{ Ingress: ingress, @@ -392,7 +392,7 @@ func buildTargetPool( } targetPool := &albsdk.TargetPool{ - Name: new(ingressPathReference.toTargetPoolName()), + Name: new(ingressPathRef.toTargetPoolName()), TargetPort: new(nodePort), Targets: targets, } @@ -444,7 +444,6 @@ 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 i := range nodes { node := &nodes[i] diff --git a/pkg/testutil/service/service.go b/pkg/testutil/service/service.go index c5e554f..6b8abf5 100644 --- a/pkg/testutil/service/service.go +++ b/pkg/testutil/service/service.go @@ -37,9 +37,9 @@ func WithPort(name string, port, nodePort int32, protocol corev1.Protocol) Servi } } -func WithServiceType(_type corev1.ServiceType) ServiceOption { +func WithServiceType(t corev1.ServiceType) ServiceOption { return func(service *corev1.Service) { - service.Spec.Type = _type + service.Spec.Type = t } } From 632a531a5d87797208f2152cda0cdb0870d5db62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Tue, 30 Jun 2026 10:46:24 +0200 Subject: [PATCH 69/77] Remove early return after adding finalizer --- pkg/controller/ingress/ingressclass_controller.go | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/controller/ingress/ingressclass_controller.go b/pkg/controller/ingress/ingressclass_controller.go index 264e78a..be6cb38 100644 --- a/pkg/controller/ingress/ingressclass_controller.go +++ b/pkg/controller/ingress/ingressclass_controller.go @@ -71,7 +71,6 @@ func (r *IngressClassReconciler) Reconcile(ctx context.Context, req ctrl.Request return ctrl.Result{}, fmt.Errorf("failed to add finalizer to IngressClass: %w", err) } ctrl.LoggerFrom(ctx).Info("Added finalizer") - return ctrl.Result{}, nil } if err := r.reconcileALBResources(ctx, ingressClass); err != nil { From 820820529cc4886f60aac0fd0ae17e6cb724ece0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Tue, 30 Jun 2026 10:50:55 +0200 Subject: [PATCH 70/77] Set user agent for STACKIT APIs --- cmd/application-load-balancer-controller/main.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/cmd/application-load-balancer-controller/main.go b/cmd/application-load-balancer-controller/main.go index cf98149..66f1852 100644 --- a/cmd/application-load-balancer-controller/main.go +++ b/cmd/application-load-balancer-controller/main.go @@ -73,12 +73,16 @@ func main() { setupLog.Error(err, "unable to start manager") os.Exit(1) } - albOpts := []sdkconfig.ConfigurationOption{} + albOpts := []sdkconfig.ConfigurationOption{ + sdkconfig.WithUserAgent("application-load-balancer-controller"), + } if config.Global.APIEndpoints.ApplicationLoadBalancerAPI != "" { albOpts = append(albOpts, sdkconfig.WithEndpoint(config.Global.APIEndpoints.ApplicationLoadBalancerAPI)) } - certOpts := []sdkconfig.ConfigurationOption{} + certOpts := []sdkconfig.ConfigurationOption{ + sdkconfig.WithUserAgent("application-load-balancer-controller"), + } if config.Global.APIEndpoints.ApplicationLoadBalancerCertificateAPI != "" { certOpts = append(certOpts, sdkconfig.WithEndpoint(config.Global.APIEndpoints.ApplicationLoadBalancerCertificateAPI)) } From 88bb483c6f6db9e4e1da68967d6b04346dcf4c80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Tue, 30 Jun 2026 10:57:30 +0200 Subject: [PATCH 71/77] Wrap original error for not found --- pkg/stackit/applicationloadbalancer.go | 5 +++-- pkg/stackit/applicationloadbalancercertificates.go | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/pkg/stackit/applicationloadbalancer.go b/pkg/stackit/applicationloadbalancer.go index 815b355..615ebb9 100644 --- a/pkg/stackit/applicationloadbalancer.go +++ b/pkg/stackit/applicationloadbalancer.go @@ -2,6 +2,7 @@ package stackit import ( "context" + "fmt" "github.com/google/uuid" @@ -45,7 +46,7 @@ func NewApplicationLoadBalancerClient(cl *albsdk.APIClient) (ApplicationLoadBala 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, fmt.Errorf("%w: %w", ErrorNotFound, err) } return lb, err } @@ -62,7 +63,7 @@ func (cl applicationLoadBalancerClient) CreateLoadBalancer( ) (*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, fmt.Errorf("%w: %w", ErrorNotFound, err) } return lb, err } diff --git a/pkg/stackit/applicationloadbalancercertificates.go b/pkg/stackit/applicationloadbalancercertificates.go index 41d9674..c373b06 100644 --- a/pkg/stackit/applicationloadbalancercertificates.go +++ b/pkg/stackit/applicationloadbalancercertificates.go @@ -2,6 +2,7 @@ package stackit import ( "context" + "fmt" certsdk "github.com/stackitcloud/stackit-sdk-go/services/certificates/v2api" ) @@ -27,7 +28,7 @@ func NewCertClient(cl *certsdk.APIClient) (CertificatesClient, error) { 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, fmt.Errorf("%w: %w", ErrorNotFound, err) } return cert, err } @@ -42,7 +43,7 @@ func (cl certClient) CreateCertificate( ) (*certsdk.GetCertificateResponse, error) { cert, err := cl.client.DefaultAPI.CreateCertificate(ctx, projectID, region).CreateCertificatePayload(*certificate).Execute() if isOpenAPINotFound(err) { - return cert, ErrorNotFound + return cert, fmt.Errorf("%w: %w", ErrorNotFound, err) } return cert, err } From ed2f054760446318788ccc22d96fa02198546a40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Tue, 30 Jun 2026 10:58:50 +0200 Subject: [PATCH 72/77] Don't start variables with underscore --- pkg/testutil/ingress/ingress.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/testutil/ingress/ingress.go b/pkg/testutil/ingress/ingress.go index 45b6cd4..90898ab 100644 --- a/pkg/testutil/ingress/ingress.go +++ b/pkg/testutil/ingress/ingress.go @@ -68,13 +68,13 @@ func WithRule(host string, opts ...RuleOptions) IngressOption { type RuleOptions func(rule *networkingv1.IngressRule) -func WithPath(path string, _type *networkingv1.PathType, serviceName string, serviceBackendPort networkingv1.ServiceBackendPort) RuleOptions { +func WithPath(path string, pathType *networkingv1.PathType, serviceName string, serviceBackendPort networkingv1.ServiceBackendPort) RuleOptions { return 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, + PathType: pathType, Path: path, Backend: networkingv1.IngressBackend{ Service: &networkingv1.IngressServiceBackend{ From 207691bbc21258596400ac9f18540163b6f052f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Tue, 30 Jun 2026 11:40:35 +0200 Subject: [PATCH 73/77] Switch to the new event API --- cmd/application-load-balancer-controller/main.go | 2 +- pkg/controller/ingress/ingressclass_controller.go | 6 +++--- pkg/controller/ingress/ingressclass_controller_test.go | 6 +++--- pkg/controller/ingress/spec/events.go | 9 +++++---- pkg/controller/ingress/spec/worktree.go | 5 +++-- samples/deploy/rbac.yaml | 2 +- 6 files changed, 16 insertions(+), 14 deletions(-) diff --git a/cmd/application-load-balancer-controller/main.go b/cmd/application-load-balancer-controller/main.go index 66f1852..390f4e7 100644 --- a/cmd/application-load-balancer-controller/main.go +++ b/cmd/application-load-balancer-controller/main.go @@ -115,7 +115,7 @@ func main() { if err = (&ingress.IngressClassReconciler{ Client: mgr.GetClient(), - Recorder: mgr.GetEventRecorderFor("ingressclass-controller"), + Recorder: mgr.GetEventRecorder("ingressclass-controller"), ALBClient: albClient, CertificateClient: certificateClient, ALBConfig: config, diff --git a/pkg/controller/ingress/ingressclass_controller.go b/pkg/controller/ingress/ingressclass_controller.go index be6cb38..fc76f8e 100644 --- a/pkg/controller/ingress/ingressclass_controller.go +++ b/pkg/controller/ingress/ingressclass_controller.go @@ -10,7 +10,7 @@ import ( 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/client-go/tools/record" + "k8s.io/client-go/tools/events" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" @@ -26,7 +26,7 @@ const ( // +kubebuilder:rbac:groups=core,resources=services,verbs=get;list;watch // +kubebuilder:rbac:groups=core,resources=secrets,verbs=get;list;watch // +kubebuilder:rbac:groups=core,resources=nodes,verbs=get;list;watch -// +kubebuilder:rbac:groups=core,resources=events,verbs=create;patch +// +kubebuilder:rbac:groups=events.k8s.io,resources=events,verbs=create;patch // +kubebuilder:rbac:groups=networking.k8s.io,resources=ingressclasses,verbs=get;list;watch;update;patch // +kubebuilder:rbac:groups=networking.k8s.io,resources=ingresses,verbs=get;list;watch // +kubebuilder:rbac:groups=networking.k8s.io,resources=ingresses/status,verbs=get;update;patch @@ -34,7 +34,7 @@ const ( // IngressClassReconciler reconciles a IngressClass object type IngressClassReconciler struct { //nolint:revive // Naming this ClassReconciler would be confusing. Client client.Client - Recorder record.EventRecorder + Recorder events.EventRecorder ALBClient stackit.ApplicationLoadBalancerClient CertificateClient stackit.CertificatesClient ALBConfig stackitconfig.ALBConfig diff --git a/pkg/controller/ingress/ingressclass_controller_test.go b/pkg/controller/ingress/ingressclass_controller_test.go index 1c1faa8..d45d899 100644 --- a/pkg/controller/ingress/ingressclass_controller_test.go +++ b/pkg/controller/ingress/ingressclass_controller_test.go @@ -24,7 +24,7 @@ import ( 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" + "k8s.io/client-go/tools/events" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -40,7 +40,7 @@ const ( var _ = Describe("IngressClassController", func() { var ( - recorder *record.FakeRecorder + recorder *events.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. @@ -60,7 +60,7 @@ var _ = Describe("IngressClassController", func() { BeforeEach(func(ctx context.Context) { mockCtrl = gomock.NewController(GinkgoT()) - recorder = record.NewFakeRecorder(10) + recorder = events.NewFakeRecorder(10) albClient = stackit.NewMockApplicationLoadBalancerClient(mockCtrl) certClient = stackit.NewMockCertificatesClient(mockCtrl) diff --git a/pkg/controller/ingress/spec/events.go b/pkg/controller/ingress/spec/events.go index 1d9f608..cf4f318 100644 --- a/pkg/controller/ingress/spec/events.go +++ b/pkg/controller/ingress/spec/events.go @@ -6,7 +6,7 @@ import ( 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" + "k8s.io/client-go/tools/events" "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -23,7 +23,8 @@ func (e *ErrorEvent) Error() string { return e.Description } -func (e *ErrorEvent) RecordEvent(class *networkingv1.IngressClass, recorder record.EventRecorder) { - 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()) +func (e *ErrorEvent) RecordEvent(class *networkingv1.IngressClass, recorder events.EventRecorder) { + recorder.Eventf(class, e.Ingress, corev1.EventTypeWarning, "IngressWarning", "ReconcilingALB", + "Error in %s in Namespace %s: %s", e.Ingress.GetName(), e.Ingress.GetNamespace(), e.Error()) + recorder.Eventf(e.Ingress, class, corev1.EventTypeWarning, "IngressWarning", "ReconcilingALB", e.Error()) } diff --git a/pkg/controller/ingress/spec/worktree.go b/pkg/controller/ingress/spec/worktree.go index 4ab43cb..942b012 100644 --- a/pkg/controller/ingress/spec/worktree.go +++ b/pkg/controller/ingress/spec/worktree.go @@ -369,8 +369,9 @@ func buildTargetPool( } nodePort := int32(0) for _, port := range service.Spec.Ports { + // We must not match an empty port name against an empty port name. if port.Port == path.Backend.Service.Port.Number || - port.Name == path.Backend.Service.Port.Name { + (port.Name != "" && port.Name == path.Backend.Service.Port.Name) { if port.NodePort == 0 { errors = append(errors, ErrorEvent{ Ingress: ingress, @@ -386,7 +387,7 @@ func buildTargetPool( 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", + Description: "Port not found in service.", }) return nil, errors } diff --git a/samples/deploy/rbac.yaml b/samples/deploy/rbac.yaml index 09f4c0f..e02286c 100644 --- a/samples/deploy/rbac.yaml +++ b/samples/deploy/rbac.yaml @@ -56,7 +56,7 @@ rules: - watch # Emit events for ingress/ingressclass reconciliations. - apiGroups: - - "" + - "events.k8s.io" resources: - events verbs: From d57675e16217949071888a638add62982147d872 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Tue, 30 Jun 2026 13:32:49 +0200 Subject: [PATCH 74/77] Make controller tests run in CI --- Makefile | 4 ++-- hack/test.sh | 1 + hack/tools.mk | 5 +++++ pkg/controller/ingress/spec/worktree.go | 2 +- pkg/controller/ingress/spec/worktree_test.go | 3 ++- 5 files changed, 11 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index 28a24ca..720d03a 100644 --- a/Makefile +++ b/Makefile @@ -48,11 +48,11 @@ modules: ## Runs go mod to ensure modules are up to date. go mod tidy .PHONY: test -test: ## Run tests. +test: $(ENVTEST) ## Run tests. ./hack/test.sh ./cmd/... ./pkg/... .PHONY: test-cover -test-cover: ## Run tests with coverage. +test-cover: $(ENVTEST) ## Run tests with coverage. go test -coverprofile cover.out ./... go tool cover -html cover.out -o cover.html diff --git a/hack/test.sh b/hack/test.sh index 10c7877..8db6ce7 100755 --- a/hack/test.sh +++ b/hack/test.sh @@ -24,4 +24,5 @@ else timeout_flag="-timeout=2m" fi +export KUBEBUILDER_ASSETS="$(pwd)/$(hack/tools/bin/setup-envtest use 1.35.0 --bin-dir hack/tools/bin -p path)" go test ${timeout_flag:+"$timeout_flag"} "$@" "${test_flags[@]}" diff --git a/hack/tools.mk b/hack/tools.mk index b87ac0b..8d9a66f 100644 --- a/hack/tools.mk +++ b/hack/tools.mk @@ -10,6 +10,7 @@ GOIMPORTS_REVISER_VERSION ?= v3.12.6 GOLANGCI_LINT_VERSION ?= v2.12.2 # renovate: datasource=github-releases depName=ko-build/ko KO_VERSION ?= v0.18.1 +ENVTEST_VERSION ?= v0.0.0-20260317052337-b8d2b5b862fa # Tool targets should declare go.mod as a prerequisite, if the tool's version is managed via go modules. This causes @@ -39,3 +40,7 @@ $(GOLANGCI_LINT): $(call tool_version_file,$(GOLANGCI_LINT),$(GOLANGCI_LINT_VERS KO := $(TOOLS_BIN_DIR)/ko $(KO): $(call tool_version_file,$(KO),$(KO_VERSION)) GOBIN=$(abspath $(TOOLS_BIN_DIR)) go install github.com/google/ko@$(KO_VERSION) + +ENVTEST := $(TOOLS_BIN_DIR)/setup-envtest +$(ENVTEST): $(call tool_version_file,$(ENVTEST),$(ENVTEST_VERSION)) + GOBIN=$(abspath $(TOOLS_BIN_DIR)) go install sigs.k8s.io/controller-runtime/tools/setup-envtest@$(ENVTEST_VERSION) diff --git a/pkg/controller/ingress/spec/worktree.go b/pkg/controller/ingress/spec/worktree.go index 942b012..9200b5e 100644 --- a/pkg/controller/ingress/spec/worktree.go +++ b/pkg/controller/ingress/spec/worktree.go @@ -323,7 +323,7 @@ func (t *WorkTreeALB) addPath( // // 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( +func buildTargetPool( //nolint:gocyclo,funlen // TODO: Make function easier?! 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) { diff --git a/pkg/controller/ingress/spec/worktree_test.go b/pkg/controller/ingress/spec/worktree_test.go index 9760d4e..9d9aaec 100644 --- a/pkg/controller/ingress/spec/worktree_test.go +++ b/pkg/controller/ingress/spec/worktree_test.go @@ -208,6 +208,7 @@ var _ = Describe("WorkTreeALB", func() { WorkTreeCertificate{ PublicKey: testdata.FixtureTLS1PublicKey, PrivateKey: testdata.FixtureTLS1PrivateKey, + Ports: map[uint16]any{443: nil}, }, )) }) @@ -604,7 +605,7 @@ var _ = Describe("WorkTreeALB", func() { "FieldPath": Equal(field.NewPath("spec", "rules").Index(0).Child("paths").Index(0).Child("backend", "service")), }), )) - Expect(tree.targetPools).To(HaveLen(0)) + Expect(tree.targetPools).To(BeEmpty()) }) It("should filter out nodes that don't meet the criteria to serve traffic", func() { From 77f7939bb19a784a75f170e85cde124c5bf679ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Tue, 30 Jun 2026 13:45:24 +0200 Subject: [PATCH 75/77] Remove kubebuilder markers as RBAC rules are hand-written --- pkg/controller/ingress/ingressclass_controller.go | 8 -------- 1 file changed, 8 deletions(-) diff --git a/pkg/controller/ingress/ingressclass_controller.go b/pkg/controller/ingress/ingressclass_controller.go index fc76f8e..0a822a4 100644 --- a/pkg/controller/ingress/ingressclass_controller.go +++ b/pkg/controller/ingress/ingressclass_controller.go @@ -23,14 +23,6 @@ const ( controllerName = "stackit.cloud/alb-ingress" ) -// +kubebuilder:rbac:groups=core,resources=services,verbs=get;list;watch -// +kubebuilder:rbac:groups=core,resources=secrets,verbs=get;list;watch -// +kubebuilder:rbac:groups=core,resources=nodes,verbs=get;list;watch -// +kubebuilder:rbac:groups=events.k8s.io,resources=events,verbs=create;patch -// +kubebuilder:rbac:groups=networking.k8s.io,resources=ingressclasses,verbs=get;list;watch;update;patch -// +kubebuilder:rbac:groups=networking.k8s.io,resources=ingresses,verbs=get;list;watch -// +kubebuilder:rbac:groups=networking.k8s.io,resources=ingresses/status,verbs=get;update;patch - // IngressClassReconciler reconciles a IngressClass object type IngressClassReconciler struct { //nolint:revive // Naming this ClassReconciler would be confusing. Client client.Client From 18f90f79754f743bf785540b78e62a68f8a448e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Tue, 30 Jun 2026 15:32:26 +0200 Subject: [PATCH 76/77] Use envtest version from controller-runtime in go.mod --- hack/tools.mk | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/hack/tools.mk b/hack/tools.mk index 8d9a66f..dff1fbb 100644 --- a/hack/tools.mk +++ b/hack/tools.mk @@ -10,7 +10,8 @@ GOIMPORTS_REVISER_VERSION ?= v3.12.6 GOLANGCI_LINT_VERSION ?= v2.12.2 # renovate: datasource=github-releases depName=ko-build/ko KO_VERSION ?= v0.18.1 -ENVTEST_VERSION ?= v0.0.0-20260317052337-b8d2b5b862fa +# use controller-runtime version from go.mod +ENVTEST_VERSION ?= $(shell go list -m -f "{{ .Version }}" sigs.k8s.io/controller-runtime) # Tool targets should declare go.mod as a prerequisite, if the tool's version is managed via go modules. This causes From c9d522975bd83f751037941646989afbd9465fc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Tue, 30 Jun 2026 17:22:17 +0200 Subject: [PATCH 77/77] Add more tests to for work tree --- pkg/controller/ingress/spec/worktree_test.go | 87 +++++++++++++++++--- 1 file changed, 75 insertions(+), 12 deletions(-) diff --git a/pkg/controller/ingress/spec/worktree_test.go b/pkg/controller/ingress/spec/worktree_test.go index 9d9aaec..48bd06a 100644 --- a/pkg/controller/ingress/spec/worktree_test.go +++ b/pkg/controller/ingress/spec/worktree_test.go @@ -2,6 +2,7 @@ package spec import ( "fmt" + "time" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -114,6 +115,33 @@ var _ = Describe("WorkTreeALB", func() { Expect(createPayload.TargetPools[3].TargetPort).To(HaveValue(Equal(int32(5003)))) }) + It("should not expose ingress on HTTP if configured HTTPS-only", func() { + tree, errs := BuildTree( + &networkingv1.IngressClass{}, + []networkingv1.Ingress{ + Ingress(corev1.NamespaceDefault, "ingress", + WithAnnotation(AnnotationHTTPSOnly, "true"), WithTLSSecret("my-cert"), + WithRule("my-host.local", WithPath("/", new(networkingv1.PathTypePrefix), "my-service", networkingv1.ServiceBackendPort{Number: 80})), + ), + }, + []corev1.Secret{ + { + ObjectMeta: metav1.ObjectMeta{Namespace: corev1.NamespaceDefault, Name: "my-cert"}, + Type: corev1.SecretTypeTLS, + Data: map[string][]byte{ + corev1.TLSCertKey: []byte(testdata.FixtureTLS1PublicKey), + corev1.TLSPrivateKeyKey: []byte(testdata.FixtureTLS1PrivateKey), + }, + }, + }, []corev1.Service{ + Service(corev1.NamespaceDefault, "my-service", WithServiceType(corev1.ServiceTypeNodePort), WithPort("my-port", 80, 30000, corev1.ProtocolTCP)), + }, nil, nil, + ) + + Expect(errs).To(BeEmpty()) + Expect(tree.listeners).To(And(HaveLen(1), HaveKey(BeEquivalentTo(443)))) + }) + It("should return an error when the TLS secret doesn't exist", func() { _, errs := BuildTree( &networkingv1.IngressClass{}, @@ -213,6 +241,31 @@ var _ = Describe("WorkTreeALB", func() { )) }) + It("should return unused certificates that are no longer used by the ALB", func() { + tree, err := BuildTree(&networkingv1.IngressClass{}, []networkingv1.Ingress{ + Ingress(corev1.NamespaceDefault, "ingress-with-tls-secret-reference", WithTLSSecret("my-tls")), + }, []corev1.Secret{ + { + ObjectMeta: metav1.ObjectMeta{Namespace: corev1.NamespaceDefault, Name: "my-tls"}, + Type: corev1.SecretTypeTLS, + Data: map[string][]byte{ + corev1.TLSCertKey: []byte(testdata.FixtureTLS1PublicKey), + corev1.TLSPrivateKeyKey: []byte(testdata.FixtureTLS1PrivateKey), + }, + }, + }, []corev1.Service{}, nil, nil) + + Expect(err).To(BeEmpty()) + Expect(tree.GetUnusedCertificates(map[CertificateFingerprint]string{ + testdata.FixtureTLS1FingerprintSHA256: "id-1", + testdata.FixtureTLS2FingerprintSHA256: "id-2", + testdata.FixtureTLS3FingerprintSHA256: "id-3", + })).To(Equal(map[CertificateFingerprint]string{ + testdata.FixtureTLS2FingerprintSHA256: "id-2", + testdata.FixtureTLS3FingerprintSHA256: "id-3", + })) + }) + It("should use TLS certificates only on ports that reference it", func() { tree, errs := BuildTree( &networkingv1.IngressClass{ @@ -417,15 +470,25 @@ var _ = Describe("WorkTreeALB", func() { Expect(create.Options.EphemeralAddress).To(HaveValue(BeFalse())) }) - It("should return errors for paths that exceed the target pool limit", func() { + 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}), - ))) + // We create a matrix of resources based on all sorting criteria. + for prio := range 2 { // 2 * 2 * 2 * 3 paths = 24 + for age := range 2 { // Higher age means older + for alphabet := range 2 { + ingresses = append(ingresses, Ingress(corev1.NamespaceDefault, + fmt.Sprintf("ingress-prio-%d-age-%d-%s", prio, age, string(rune('a'+alphabet))), + func(ingress *networkingv1.Ingress) { + ingress.CreationTimestamp = metav1.NewTime(time.Unix(100000, 0).Add(-time.Duration(age) * time.Hour)) + }, + WithAnnotation(AnnotationPriority, fmt.Sprintf("%d", prio)), + WithRule("my-host.local", + WithPath(fmt.Sprintf("/prio-%d-age-%d-%d-0", prio, age, alphabet), new(networkingv1.PathTypeExact), "my-service", networkingv1.ServiceBackendPort{Number: 80}), + WithPath(fmt.Sprintf("/prio-%d-age-%d-%d-1", prio, age, alphabet), new(networkingv1.PathTypeExact), "my-service", networkingv1.ServiceBackendPort{Number: 80}), + WithPath(fmt.Sprintf("/prio-%d-age-%d-%d-2", prio, age, alphabet), new(networkingv1.PathTypeExact), "my-service", networkingv1.ServiceBackendPort{Number: 80}), + ))) + } + } } _, errs := BuildTree( &networkingv1.IngressClass{}, ingresses, nil, []corev1.Service{ @@ -435,22 +498,22 @@ var _ = Describe("WorkTreeALB", func() { Expect(errs).To(ConsistOf( MatchAllFields(Fields{ - "Ingress": testutil.HaveName("ingress-0"), + "Ingress": testutil.HaveName("ingress-prio-0-age-0-b"), "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"), + "Ingress": testutil.HaveName("ingress-prio-0-age-0-b"), "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"), + "Ingress": testutil.HaveName("ingress-prio-0-age-0-b"), "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"), + "Ingress": testutil.HaveName("ingress-prio-0-age-0-a"), "Description": Equal("Target pool limit reached. Path will be ignored."), "FieldPath": Equal(field.NewPath("spec", "rules").Index(0).Child("paths").Index(2)), }),