From ae98179d88edc8819f0ba7ff7323b90f2b743ffe Mon Sep 17 00:00:00 2001 From: Kamil Przybyl Date: Tue, 17 Mar 2026 10:26:41 +0100 Subject: [PATCH 01/23] add application load balancer controller manager --- .../main.go | 316 ++++++++++++ go.mod | 13 +- go.sum | 26 +- pkg/alb/ingress/alb_spec.go | 488 ++++++++++++++++++ pkg/alb/ingress/alb_spec_test.go | 419 +++++++++++++++ pkg/alb/ingress/controller_test.go | 462 +++++++++++++++++ pkg/alb/ingress/ingressclass_controller.go | 467 +++++++++++++++++ .../ingress/ingressclass_controller_test.go | 238 +++++++++ pkg/alb/ingress/suite_test.go | 111 ++++ pkg/stackit/applicationloadbalancer.go | 127 +++++ .../applicationloadbalancercertificates.go | 65 +++ samples/ingress/deployment.yaml | 49 ++ samples/ingress/ingress-class.yaml | 10 + samples/ingress/ingress.yaml | 37 ++ samples/ingress/issuer.yaml | 15 + samples/ingress/service.yaml | 33 ++ samples/{ => service}/echo-deploy.yaml | 0 samples/{ => service}/echo-svc.yaml | 0 samples/{ => service}/http-deploy.yaml | 0 samples/{ => service}/http-svc.yaml | 0 20 files changed, 2869 insertions(+), 7 deletions(-) create mode 100644 cmd/application-load-balancer-controller-manager/main.go create mode 100644 pkg/alb/ingress/alb_spec.go create mode 100644 pkg/alb/ingress/alb_spec_test.go create mode 100644 pkg/alb/ingress/controller_test.go create mode 100644 pkg/alb/ingress/ingressclass_controller.go create mode 100644 pkg/alb/ingress/ingressclass_controller_test.go create mode 100644 pkg/alb/ingress/suite_test.go create mode 100644 pkg/stackit/applicationloadbalancer.go create mode 100644 pkg/stackit/applicationloadbalancercertificates.go create mode 100644 samples/ingress/deployment.yaml create mode 100644 samples/ingress/ingress-class.yaml create mode 100644 samples/ingress/ingress.yaml create mode 100644 samples/ingress/issuer.yaml create mode 100644 samples/ingress/service.yaml rename samples/{ => service}/echo-deploy.yaml (100%) rename samples/{ => service}/echo-svc.yaml (100%) rename samples/{ => service}/http-deploy.yaml (100%) rename samples/{ => service}/http-svc.yaml (100%) diff --git a/cmd/application-load-balancer-controller-manager/main.go b/cmd/application-load-balancer-controller-manager/main.go new file mode 100644 index 00000000..128aea99 --- /dev/null +++ b/cmd/application-load-balancer-controller-manager/main.go @@ -0,0 +1,316 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "crypto/tls" + "flag" + "fmt" + "os" + "path/filepath" + + // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) + // to ensure that exec-entrypoint and run can make use of them. + _ "k8s.io/client-go/plugin/pkg/client/auth" + + sdkconfig "github.com/stackitcloud/stackit-sdk-go/core/config" + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/certwatcher" + "sigs.k8s.io/controller-runtime/pkg/healthz" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + "sigs.k8s.io/controller-runtime/pkg/metrics/filters" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + "sigs.k8s.io/controller-runtime/pkg/webhook" + + albclient "github.com/stackitcloud/cloud-provider-stackit/pkg/stackit" + albsdk "github.com/stackitcloud/stackit-sdk-go/services/alb" + certsdk "github.com/stackitcloud/stackit-sdk-go/services/certificates" + // +kubebuilder:scaffold:imports +) + +var ( + scheme = runtime.NewScheme() + setupLog = ctrl.Log.WithName("setup") +) + +func init() { + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + + // +kubebuilder:scaffold:scheme +} + +// nolint:gocyclo +func main() { + var metricsAddr string + var metricsCertPath, metricsCertName, metricsCertKey string + var webhookCertPath, webhookCertName, webhookCertKey string + var enableLeaderElection bool + var leaderElectionNamespace string + var leaderElectionID string + var probeAddr string + var secureMetrics bool + var enableHTTP2 bool + var tlsOpts []func(*tls.Config) + flag.StringVar(&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(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") + flag.BoolVar(&enableLeaderElection, "leader-elect", false, + "Enable leader election for controller manager. "+ + "Enabling this will ensure there is only one active controller manager.") + flag.StringVar(&leaderElectionNamespace, "leader-election-namespace", "default", "The namespace in which the leader "+ + "election resource will be created.") + flag.StringVar(&leaderElectionID, "leader-election-id", "d0fbe9c4.stackit.cloud", "The name of the resource that "+ + "leader election will use for holding the leader lock.") + flag.BoolVar(&secureMetrics, "metrics-secure", true, + "If set, the metrics endpoint is served securely via HTTPS. Use --metrics-secure=false to use HTTP instead.") + flag.StringVar(&webhookCertPath, "webhook-cert-path", "", "The directory that contains the webhook certificate.") + flag.StringVar(&webhookCertName, "webhook-cert-name", "tls.crt", "The name of the webhook certificate file.") + flag.StringVar(&webhookCertKey, "webhook-cert-key", "tls.key", "The name of the webhook key file.") + flag.StringVar(&metricsCertPath, "metrics-cert-path", "", + "The directory that contains the metrics server certificate.") + flag.StringVar(&metricsCertName, "metrics-cert-name", "tls.crt", "The name of the metrics server certificate file.") + flag.StringVar(&metricsCertKey, "metrics-cert-key", "tls.key", "The name of the metrics server key file.") + flag.BoolVar(&enableHTTP2, "enable-http2", false, + "If set, HTTP/2 will be enabled for the metrics and webhook servers") + opts := zap.Options{ + Development: true, + } + opts.BindFlags(flag.CommandLine) + flag.Parse() + + ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) + + // if the enable-http2 flag is false (the default), http/2 should be disabled + // due to its vulnerabilities. More specifically, disabling http/2 will + // prevent from being vulnerable to the HTTP/2 Stream Cancellation and + // Rapid Reset CVEs. For more information see: + // - https://github.com/advisories/GHSA-qppj-fm5r-hxr3 + // - https://github.com/advisories/GHSA-4374-p667-p6c8 + disableHTTP2 := func(c *tls.Config) { + setupLog.Info("disabling http/2") + c.NextProtos = []string{"http/1.1"} + } + + if !enableHTTP2 { + tlsOpts = append(tlsOpts, disableHTTP2) + } + + // Create watchers for metrics and webhooks certificates + var metricsCertWatcher, webhookCertWatcher *certwatcher.CertWatcher + + // Initial webhook TLS options + webhookTLSOpts := tlsOpts + + if len(webhookCertPath) > 0 { + setupLog.Info("Initializing webhook certificate watcher using provided certificates", + "webhook-cert-path", webhookCertPath, "webhook-cert-name", webhookCertName, "webhook-cert-key", webhookCertKey) + + var err error + webhookCertWatcher, err = certwatcher.New( + filepath.Join(webhookCertPath, webhookCertName), + filepath.Join(webhookCertPath, webhookCertKey), + ) + if err != nil { + setupLog.Error(err, "Failed to initialize webhook certificate watcher") + os.Exit(1) + } + + webhookTLSOpts = append(webhookTLSOpts, func(config *tls.Config) { + config.GetCertificate = webhookCertWatcher.GetCertificate + }) + } + + webhookServer := webhook.NewServer(webhook.Options{ + TLSOpts: webhookTLSOpts, + }) + + // Metrics endpoint is enabled in 'config/default/kustomization.yaml'. The Metrics options configure the server. + // More info: + // - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.19.4/pkg/metrics/server + // - https://book.kubebuilder.io/reference/metrics.html + metricsServerOptions := metricsserver.Options{ + BindAddress: metricsAddr, + SecureServing: secureMetrics, + TLSOpts: tlsOpts, + } + + if secureMetrics { + // FilterProvider is used to protect the metrics endpoint with authn/authz. + // These configurations ensure that only authorized users and service accounts + // can access the metrics endpoint. The RBAC are configured in 'config/rbac/kustomization.yaml'. More info: + // https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.19.4/pkg/metrics/filters#WithAuthenticationAndAuthorization + metricsServerOptions.FilterProvider = filters.WithAuthenticationAndAuthorization + } + + // If the certificate is not specified, controller-runtime will automatically + // generate self-signed certificates for the metrics server. While convenient for development and testing, + // this setup is not recommended for production. + // + // TODO(user): If you enable certManager, uncomment the following lines: + // - [METRICS-WITH-CERTS] at config/default/kustomization.yaml to generate and use certificates + // managed by cert-manager for the metrics server. + // - [PROMETHEUS-WITH-CERTS] at config/prometheus/kustomization.yaml for TLS certification. + if len(metricsCertPath) > 0 { + setupLog.Info("Initializing metrics certificate watcher using provided certificates", + "metrics-cert-path", metricsCertPath, "metrics-cert-name", metricsCertName, "metrics-cert-key", metricsCertKey) + + var err error + metricsCertWatcher, err = certwatcher.New( + filepath.Join(metricsCertPath, metricsCertName), + filepath.Join(metricsCertPath, metricsCertKey), + ) + if err != nil { + setupLog.Error(err, "to initialize metrics certificate watcher", "error", err) + os.Exit(1) + } + + metricsServerOptions.TLSOpts = append(metricsServerOptions.TLSOpts, func(config *tls.Config) { + config.GetCertificate = metricsCertWatcher.GetCertificate + }) + } + + mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ + Scheme: scheme, + Metrics: metricsServerOptions, + WebhookServer: webhookServer, + HealthProbeBindAddress: probeAddr, + LeaderElection: enableLeaderElection, + LeaderElectionID: leaderElectionID, + LeaderElectionNamespace: leaderElectionNamespace, + // LeaderElectionReleaseOnCancel defines if the leader should step down voluntarily + // when the Manager ends. This requires the binary to immediately end when the + // Manager is stopped, otherwise, this setting is unsafe. Setting this significantly + // speeds up voluntary leader transitions as the new leader don't have to wait + // LeaseDuration time first. + // + // In the default scaffold provided, the program ends immediately after + // the manager stops, so would be fine to enable this option. However, + // if you are doing or is intended to do any operation such as perform cleanups + // after the manager stops then its usage might be unsafe. + // LeaderElectionReleaseOnCancel: true, + }) + if err != nil { + setupLog.Error(err, "unable to start manager") + os.Exit(1) + } + + albURL, _ := os.LookupEnv("STACKIT_LOAD_BALANCER_API_ALB_URL") + + certURL, _ := os.LookupEnv("STACKIT_LOAD_BALANCER_API_CERT_URL") + + region, set := os.LookupEnv("STACKIT_REGION") + if !set { + setupLog.Error(err, "STACKIT_REGION not set", "controller", "IngressClass") + os.Exit(1) + } + projectID, set := os.LookupEnv("PROJECT_ID") + if !set { + setupLog.Error(err, "PROJECT_ID not set", "controller", "IngressClass") + os.Exit(1) + } + networkID, set := os.LookupEnv("NETWORK_ID") + if !set { + setupLog.Error(err, "NETWORK_ID not set", "controller", "IngressClass") + os.Exit(1) + } + + // Create an ALB SDK client + albOpts := []sdkconfig.ConfigurationOption{} + if albURL != "" { + albOpts = append(albOpts, sdkconfig.WithEndpoint(albURL)) + } + + certOpts := []sdkconfig.ConfigurationOption{} + if certURL != "" { + certOpts = append(certOpts, sdkconfig.WithEndpoint(certURL)) + } + + fmt.Printf("Create ALB SDK client\n") + sdkClient, err := albsdk.NewAPIClient(albOpts...) + if err != nil { + setupLog.Error(err, "unable to create ALB SDK client", "controller", "IngressClass") + os.Exit(1) + } + // Create an ALB client + fmt.Printf("Create ALB client\n") + albClient, err := albclient.NewClient(sdkClient) + if err != nil { + setupLog.Error(err, "unable to create ALB client", "controller", "IngressClass") + os.Exit(1) + } + + // Create an Certificates SDK client + certificateAPI, err := certsdk.NewAPIClient(certOpts...) + if err != nil { + setupLog.Error(err, "unable to create certificate SDK client", "controller", "IngressClass") + os.Exit(1) + } + // Create an Certificates API client + certificateClient, err := certificateclient.NewCertClient(certificateAPI) + if err != nil { + setupLog.Error(err, "unable to create Certificates client", "controller", "IngressClass") + os.Exit(1) + } + + if err = (&controller.IngressClassReconciler{ + Client: mgr.GetClient(), + ALBClient: albClient, + CertificateClient: certificateClient, + Scheme: mgr.GetScheme(), + ProjectID: projectID, + NetworkID: networkID, + Region: region, + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "IngressClass") + os.Exit(1) + } + // +kubebuilder:scaffold:builder + + if metricsCertWatcher != nil { + setupLog.Info("Adding metrics certificate watcher to manager") + if err := mgr.Add(metricsCertWatcher); err != nil { + setupLog.Error(err, "unable to add metrics certificate watcher to manager") + os.Exit(1) + } + } + + if webhookCertWatcher != nil { + setupLog.Info("Adding webhook certificate watcher to manager") + if err := mgr.Add(webhookCertWatcher); err != nil { + setupLog.Error(err, "unable to add webhook certificate watcher to manager") + 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(ctrl.SetupSignalHandler()); err != nil { + setupLog.Error(err, "problem running manager") + os.Exit(1) + } +} diff --git a/go.mod b/go.mod index 4b0a5f38..fd120fe3 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ toolchain go1.26.1 require ( github.com/container-storage-interface/spec v1.12.0 github.com/go-viper/mapstructure/v2 v2.5.0 + github.com/google/go-cmp v0.7.0 github.com/google/uuid v1.6.0 github.com/kubernetes-csi/csi-lib-utils v0.23.2 github.com/kubernetes-csi/csi-test/v5 v5.4.0 @@ -15,7 +16,9 @@ require ( github.com/prometheus/client_golang v1.23.2 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 - github.com/stackitcloud/stackit-sdk-go/core v0.22.0 + github.com/stackitcloud/stackit-sdk-go/core v0.23.0 + github.com/stackitcloud/stackit-sdk-go/services/alb v0.12.1 + github.com/stackitcloud/stackit-sdk-go/services/certificates v1.4.1 github.com/stackitcloud/stackit-sdk-go/services/iaas v1.3.5 github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v1.8.0 go.uber.org/mock v0.6.0 @@ -32,6 +35,7 @@ require ( k8s.io/klog/v2 v2.140.0 k8s.io/mount-utils v0.35.2 k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 + sigs.k8s.io/controller-runtime v0.23.3 ) replace k8s.io/cloud-provider => github.com/stackitcloud/cloud-provider v0.35.1-ske-1 @@ -50,11 +54,13 @@ require ( github.com/coreos/go-systemd/v22 v22.6.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/felixge/httpsnoop v1.0.4 // 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/stdr v1.2.2 // indirect + github.com/go-logr/zapr v1.3.0 // indirect github.com/go-openapi/jsonpointer v0.22.1 // indirect github.com/go-openapi/jsonreference v0.21.2 // indirect github.com/go-openapi/swag v0.25.1 // indirect @@ -77,7 +83,6 @@ require ( github.com/google/btree v1.1.3 // indirect github.com/google/cel-go v0.26.0 // 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-20260115054156-294ebfa9ad83 // indirect github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect @@ -123,12 +128,14 @@ require ( golang.org/x/text v0.33.0 // indirect golang.org/x/time v0.10.0 // indirect golang.org/x/tools v0.41.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect + k8s.io/apiextensions-apiserver v0.35.0 // indirect k8s.io/apiserver v0.35.1 // indirect k8s.io/component-helpers v0.35.1 // indirect k8s.io/controller-manager v0.35.1 // indirect @@ -137,6 +144,6 @@ require ( sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 // 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.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/go.sum b/go.sum index 97b5d821..2efd21fc 100644 --- a/go.sum +++ b/go.sum @@ -33,6 +33,10 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= github.com/emicklei/go-restful/v3 v3.12.2/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/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= @@ -105,6 +109,8 @@ github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7O 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-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc= github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -161,6 +167,8 @@ github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= +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= @@ -186,8 +194,12 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stackitcloud/cloud-provider v0.35.1-ske-1 h1:Oo71mALP7hh50wAeGOogng2sAsi7AUre4177WS2n9NE= github.com/stackitcloud/cloud-provider v0.35.1-ske-1/go.mod h1:zGF/i9YuBODKxj7szGMMIz4DRnjsDy5mg2JU+XbbULA= -github.com/stackitcloud/stackit-sdk-go/core v0.22.0 h1:6rViz7GnNwXSh51Lur5xuDzO8EWSZfN9J0HvEkBKq6c= -github.com/stackitcloud/stackit-sdk-go/core v0.22.0/go.mod h1:osMglDby4csGZ5sIfhNyYq1bS1TxIdPY88+skE/kkmI= +github.com/stackitcloud/stackit-sdk-go/core v0.23.0 h1:zPrOhf3Xe47rKRs1fg/AqKYUiJJRYjdcv+3qsS50mEs= +github.com/stackitcloud/stackit-sdk-go/core v0.23.0/go.mod h1:osMglDby4csGZ5sIfhNyYq1bS1TxIdPY88+skE/kkmI= +github.com/stackitcloud/stackit-sdk-go/services/alb v0.12.1 h1:RKaxAymxlyxxE0Gta3yRuQWf07LnlcX+mfGnVB96NHA= +github.com/stackitcloud/stackit-sdk-go/services/alb v0.12.1/go.mod h1:FHkV5L9vCQha+5MX+NdMdYjQIHXcLr95+bu1FN91QOM= +github.com/stackitcloud/stackit-sdk-go/services/certificates v1.4.1 h1:RBY/mNR4H8Vd/7z0nky+AQNvoaZ16hvrGSuYi1YLLao= +github.com/stackitcloud/stackit-sdk-go/services/certificates v1.4.1/go.mod h1:3R/RwYdBc1s6WZNhToWs0rBDropbNRM7okOAdjY3rpU= github.com/stackitcloud/stackit-sdk-go/services/iaas v1.3.5 h1:W57+XRa8wTLsi5CV9Tqa7mGgt/PvlRM//RurXSmvII8= github.com/stackitcloud/stackit-sdk-go/services/iaas v1.3.5/go.mod h1:lTWjW57eAq1bwfM6nsNinhoBr3MHFW/GaFasdAsYfDM= github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v1.8.0 h1:DxrN85V738CRLynu6MULQHO+OXyYnkhVPgoZKULfFIs= @@ -328,6 +340,8 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= +gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= @@ -354,6 +368,8 @@ 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.35.2 h1:tW7mWc2RpxW7HS4CoRXhtYHSzme1PN1UjGHJ1bdrtdw= k8s.io/api v0.35.2/go.mod h1:7AJfqGoAZcwSFhOjcGM7WV05QxMMgUaChNfLTXDRE60= +k8s.io/apiextensions-apiserver v0.35.0 h1:3xHk2rTOdWXXJM+RDQZJvdx0yEOgC0FgQ1PlJatA5T4= +k8s.io/apiextensions-apiserver v0.35.0/go.mod h1:E1Ahk9SADaLQ4qtzYFkwUqusXTcaV2uw3l14aqpL2LU= k8s.io/apimachinery v0.35.2 h1:NqsM/mmZA7sHW02JZ9RTtk3wInRgbVxL8MPfzSANAK8= k8s.io/apimachinery v0.35.2/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= k8s.io/apiserver v0.35.1 h1:potxdhhTL4i6AYAa2QCwtlhtB1eCdWQFvJV6fXgJzxs= @@ -378,11 +394,13 @@ k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0x k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 h1:jpcvIRr3GLoUoEKRkHKSmGjxb6lWwrBlJsXc+eUYQHM= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= +sigs.k8s.io/controller-runtime v0.23.3 h1:VjB/vhoPoA9l1kEKZHBMnQF33tdCLQKJtydy4iqwZ80= +sigs.k8s.io/controller-runtime v0.23.3/go.mod h1:B6COOxKptp+YaUT5q4l6LqUJTRpizbgf9KSRNdQGns0= 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.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 h1:2WOzJpHUBVrrkDjU4KBT8n5LDcj824eX0I5UKcgeRUs= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482/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/alb/ingress/alb_spec.go b/pkg/alb/ingress/alb_spec.go new file mode 100644 index 00000000..7a0b74d7 --- /dev/null +++ b/pkg/alb/ingress/alb_spec.go @@ -0,0 +1,488 @@ +package ingress + +import ( + "context" + "crypto/x509" + "encoding/pem" + "fmt" + "log" + "net/netip" + "sort" + "strconv" + "strings" + + v1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + + albsdk "github.com/stackitcloud/stackit-sdk-go/services/alb/v2api" + certsdk "github.com/stackitcloud/stackit-sdk-go/services/certificates/v2api" +) + +const ( + // externalIPAnnotation references an OpenStack floating 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 floating 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. + externalIPAnnotation = "alb.stackit.cloud/external-address" + // If true, the application load balancer is not exposed via a floating IP. + internalIPAnnotation = "alb.stackit.cloud/internal-alb" + // If true, the application load balancer enables TLS bridging. + // It uses the trusted CAs from the operating system for validation. + tlsBridgingTrustedCaAnnotation = "alb.stackit.cloud/tls-bridging-trusted-ca" + // If set, the application load balancer enables TLS bridging with a custom CA provided as value. + tlsBridgingCustomCaAnnotation = "alb.stackit.cloud/tls-bridging-custom-ca" + // If true, the application load balancer enables TLS bridging but skips validation. + tlsBridgingSkipValidationAnnotation = "alb.stackit.cloud/tls-bridging-no-validation" + // priorityAnnotation is used to set the priority of the Ingress. + priorityAnnotation = "alb.stackit.cloud/priority" +) + +const ( + // minPriority and maxPriority are the minimum and maximum values for the priority annotation. + minPriority = 1 + maxPriority = 25 + // defaultPriority is the default priority for Ingress resources that do not have a priority annotation. + defaultPriority = 0 +) + +type ruleMetadata struct { + path string + host string + priority int + pathLength int + pathTypeVal int + ingressName string + ingressNamespace string + ruleOrder int + targetPool string +} + +// albSpecFromIngress generates a complete ALB specification for a given set of Ingress resources that reference the same IngressClass. +// It merges and sorts all routing rules across the ingresses based on host, priority, path specificity, path type, and ingress origin. +// The resulting ALB payload includes targets derived from cluster nodes, target pools per backend service, HTTP(S) listeners, +// and optional TLS certificate bindings. This spec is later used to create or update the actual ALB instance. +func (r *IngressClassReconciler) albSpecFromIngress( + ctx context.Context, + ingresses []*networkingv1.Ingress, + ingressClass *networkingv1.IngressClass, + networkID *string, + nodes []v1.Node, + services map[string]v1.Service, +) (*albsdk.CreateLoadBalancerPayload, error) { + targetPools := []albsdk.TargetPool{} + targetPoolSeen := map[string]bool{} + allCertificateIDs := []string{} + ruleMetadataList := []ruleMetadata{} + + alb := &albsdk.CreateLoadBalancerPayload{ + Options: &albsdk.LoadBalancerOptions{}, + Networks: &[]albsdk.Network{ + { + NetworkId: networkID, + Role: albsdk.NETWORKROLE_LISTENERS_AND_TARGETS.Ptr(), + }, + }, + } + + // Create targets for each node in the cluster + targets := []albsdk.Target{} + for _, node := range nodes { + for j := range node.Status.Addresses { + address := node.Status.Addresses[j] + if address.Type == v1.NodeInternalIP { + targets = append(targets, albsdk.Target{ + DisplayName: &node.Name, + Ip: &address.Address, + }) + break + } + } + } + + // For each Ingress, add its rules to the combined rule list + for _, ingress := range ingresses { + priority := getIngressPriority(ingress) + + for _, rule := range ingress.Spec.Rules { + for j, path := range rule.HTTP.Paths { + nodePort, err := getNodePort(services, path) + if err != nil { + return nil, err + } + + targetPoolName := fmt.Sprintf("pool-%d", nodePort) + if !targetPoolSeen[targetPoolName] { + addTargetPool(ctx, ingress, targetPoolName, &targetPools, nodePort, targets) + targetPoolSeen[targetPoolName] = true + } + + pathTypeVal := 1 + if path.PathType != nil && *path.PathType == networkingv1.PathTypeExact { + pathTypeVal = 0 + } + + ruleMetadataList = append(ruleMetadataList, ruleMetadata{ + path: path.Path, + host: rule.Host, + priority: priority, + pathLength: len(path.Path), + pathTypeVal: pathTypeVal, + ingressName: ingress.Name, + ingressNamespace: ingress.Namespace, + ruleOrder: j, + targetPool: targetPoolName, + }) + } + } + + // Apend certificates from the current Ingress to the combined certificates + certificateIDs, err := r.loadCerts(ctx, ingressClass, ingress) + if err != nil { + log.Printf("failed to load tls certificates: %v", err) + // return nil, fmt.Errorf("failed to load tls certificates: %w", err) + } + allCertificateIDs = append(allCertificateIDs, certificateIDs...) + } + + // Sort all collected rules + sort.SliceStable(ruleMetadataList, func(i, j int) bool { + a, b := ruleMetadataList[i], ruleMetadataList[j] + // 1. Host name (lexicographically) + if a.host != b.host { + return a.host < b.host + } + // 2. Priority annotation (higher priority wins) + if a.priority != b.priority { + return a.priority > b.priority + } + // 3. Path specificity (longer paths first) + if a.pathLength != b.pathLength { + return a.pathLength > b.pathLength + } + // 4. Path type precedence (Exact < Prefix) + if a.pathTypeVal != b.pathTypeVal { + return a.pathTypeVal < b.pathTypeVal + } + // 5. Ingress name tie-breaker + if a.ingressName != b.ingressName { + return a.ingressName < b.ingressName + } + // 6. Ingress Namespace tie-breaker + if a.ingressNamespace != b.ingressNamespace { + return a.ingressNamespace < b.ingressNamespace + } + return a.ruleOrder < b.ruleOrder + }) + + // Group rules by host + hostToRules := map[string][]albsdk.Rule{} + for _, meta := range ruleMetadataList { + rule := albsdk.Rule{ + TargetPool: ptr.To(meta.targetPool), + } + if meta.pathTypeVal == 0 { // Exact path + rule.Path = &albsdk.Path{ + ExactMatch: ptr.To(meta.path), + } + } else { // Prefix path + rule.Path = &albsdk.Path{ + Prefix: ptr.To(meta.path), + } + } + hostToRules[meta.host] = append(hostToRules[meta.host], rule) + } + + // Build Host configs + httpHosts := []albsdk.HostConfig{} + hostnames := make([]string, 0, len(hostToRules)) + for host := range hostToRules { + hostnames = append(hostnames, host) + } + sort.Strings(hostnames) + + for _, host := range hostnames { + rulesCopy := hostToRules[host] + httpHosts = append(httpHosts, albsdk.HostConfig{ + Host: ptr.To(host), + Rules: &rulesCopy, + }) + } + + // Build Listeners + // Create a default HTTP rule for the ALB Always create an HTTP listener - neecessary step for acme challenge + // Add TLS listener if any Ingress has TLS configured + listeners := []albsdk.Listener{ + { + Name: ptr.To("http"), + Port: ptr.To(int64(80)), + Protocol: albsdk.LISTENERPROTOCOL_HTTP.Ptr(), + Http: &albsdk.ProtocolOptionsHTTP{ + Hosts: &httpHosts, + }, + }, + } + if len(allCertificateIDs) > 0 { + listeners = append(listeners, albsdk.Listener{ + Name: ptr.To("https"), + Port: ptr.To(int64(443)), + Protocol: albsdk.LISTENERPROTOCOL_HTTPS.Ptr(), + Http: &albsdk.ProtocolOptionsHTTP{ + Hosts: &httpHosts, + }, + Https: &albsdk.ProtocolOptionsHTTPS{ + CertificateConfig: &albsdk.CertificateConfig{ + CertificateIds: &allCertificateIDs, + }, + }, + }) + } + + // Set the IP address of the ALB + err := setIpAddresses(ingressClass, alb) + if err != nil { + return nil, fmt.Errorf("failed to set IP address: %w", err) + } + + alb.Name = ptr.To(getAlbName(ingressClass)) + alb.Listeners = &listeners + alb.TargetPools = &targetPools + + return alb, nil +} + +// laodCerts loads the tls certificates from Ingress to the Certificates API +func (r *IngressClassReconciler) loadCerts( + ctx context.Context, + ingressClass *networkingv1.IngressClass, + ingress *networkingv1.Ingress, +) ([]string, error) { + certificateIDs := []string{} + + for _, tls := range ingress.Spec.TLS { + if len(tls.SecretName) == 0 { + continue + } + + secret := &v1.Secret{} + if err := r.Client.Get(ctx, types.NamespacedName{Namespace: ingress.Namespace, Name: tls.SecretName}, secret); err != nil { + return nil, fmt.Errorf("failed to get TLS secret: %w", err) + } + + // The tls.crt should contain both the leaf certificate and the intermediate CA certificates. + // If it contains only the leaf certificate, the ACME challenge likely hasn't finished. + // Therefore the incomplete certificate shouldn't be loaded as the updates upon them are impossible. + complete, err := isCertValid(*secret) + if err != nil { + return nil, fmt.Errorf("failed to validate certificate: %w", err) + } + if !complete { + // TODO: Requeue, instead of returning error - the ACME challenge hasn't finished yet + // return ctrl.Result{RequeueAfter: 10 * time.Second}, nil + return nil, fmt.Errorf("certificate is not complete: %w", err) + } + + createCertificatePayload := &certsdk.CreateCertificatePayload{ + Name: ptr.To(getCertName(ingressClass, ingress, secret)), + ProjectId: &r.ProjectID, + PrivateKey: ptr.To(string(secret.Data["tls.key"])), + PublicKey: ptr.To(string(secret.Data["tls.crt"])), + } + res, err := r.CertificateClient.CreateCertificate(ctx, r.ProjectID, r.Region, createCertificatePayload) + if err != nil { + return nil, fmt.Errorf("failed to create certificate: %w", err) + } + + certificateIDs = append(certificateIDs, *res.Id) + } + return certificateIDs, nil +} + +// cleanupCerts deletes the certificates from the Certificates API that are no longer associated with any Ingress in the IngressClass +func (r *IngressClassReconciler) cleanupCerts(ctx context.Context, ingressClass *networkingv1.IngressClass, ingresses []*networkingv1.Ingress) error { + // Prepare a map of secret names that are currently being used by the ingresses + usedSecrets := map[string]bool{} + for _, ingress := range ingresses { + for _, tls := range ingress.Spec.TLS { + if tls.SecretName != "" { + // Retrieve the TLS Secret + tlsSecret := &v1.Secret{} + err := r.Client.Get(ctx, types.NamespacedName{Namespace: ingress.Namespace, Name: tls.SecretName}, tlsSecret) + if err != nil { + log.Printf("failed to get TLS secret %s: %v", tls.SecretName, err) + continue + } + certName := getCertName(ingressClass, ingress, tlsSecret) + usedSecrets[certName] = true + } + } + } + + certificatesList, err := r.CertificateClient.ListCertificate(ctx, r.ProjectID, r.Region) + if err != nil { + return fmt.Errorf("failed to list certificates: %w", err) + } + + if certificatesList == nil || certificatesList.Items == nil { + return nil // No certificates to clean up + } + for _, cert := range *certificatesList.Items { + certID := *cert.Id + certName := *cert.Name + + // The certificatesList contains all certificates in the project, so we need to filter them by the ALB IngressClass UID. + if !strings.HasPrefix(certName, generateShortUID(ingressClass.UID)) { + continue + } + + // If the tls secret is no longer in referenced, delete the certificate + if _, inUse := usedSecrets[certName]; !inUse { + err := r.CertificateClient.DeleteCertificate(ctx, r.ProjectID, r.Region, certID) + if err != nil { + return fmt.Errorf("failed to delete certificate %s: %v", certName, err) + } + } + } + return nil +} + +// isCertValid checks if the certificate chain is complete. It is used for checking if +// the cert-manager's ACME challenge is completed, or if it's sill ongoing. +func isCertValid(secret v1.Secret) (bool, error) { + tlsCert := secret.Data["tls.crt"] + if tlsCert == nil { + return false, fmt.Errorf("tls.crt not found in secret") + } + + // Split the certificates in the tls.crt by PEM boundary + blocks := []*pem.Block{} + for len(tlsCert) > 0 { + var block *pem.Block + block, tlsCert = pem.Decode(tlsCert) + if block == nil { + return false, fmt.Errorf("failed to decode certificate") + } + blocks = append(blocks, block) + } + + // Parse the certificates using x509 + certs := []*x509.Certificate{} + for _, block := range blocks { + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return false, fmt.Errorf("failed to parse certificate: %v", err) + } + certs = append(certs, cert) + } + + // If there are multiple certificates, it means the chain is likely complete + return len(certs) > 1, nil +} + +func addTargetPool( + _ context.Context, + ingress *networkingv1.Ingress, + targetPoolName string, + targetPools *[]albsdk.TargetPool, + nodePort int32, + targets []albsdk.Target, +) { + tlsConfig := &albsdk.TlsConfig{} + if val, ok := ingress.Annotations[tlsBridgingTrustedCaAnnotation]; ok && val == "true" { + tlsConfig.Enabled = ptr.To(true) + } + if val, ok := ingress.Annotations[tlsBridgingCustomCaAnnotation]; ok && val != "" { + tlsConfig.Enabled = ptr.To(true) + tlsConfig.CustomCa = ptr.To(val) + } + if val, ok := ingress.Annotations[tlsBridgingSkipValidationAnnotation]; ok && val == "true" { + tlsConfig.Enabled = ptr.To(true) + tlsConfig.SkipCertificateValidation = ptr.To(true) + } + if tlsConfig.Enabled == nil { + tlsConfig = nil + } + *targetPools = append(*targetPools, albsdk.TargetPool{ + Name: ptr.To(targetPoolName), + TargetPort: ptr.To(int64(nodePort)), + TlsConfig: tlsConfig, + Targets: &targets, + }) +} + +func setIpAddresses(ingressClass *networkingv1.IngressClass, alb *albsdk.CreateLoadBalancerPayload) error { + isInternalIP, found := ingressClass.Annotations[internalIPAnnotation] + if found && isInternalIP == "true" { + alb.Options = &albsdk.LoadBalancerOptions{ + PrivateNetworkOnly: ptr.To(true), + } + return nil + } + externalAddress, found := ingressClass.Annotations[externalIPAnnotation] + if !found { + alb.Options = &albsdk.LoadBalancerOptions{ + EphemeralAddress: ptr.To(true), + } + return nil + } + err := validateIPAddress(externalAddress) + if err != nil { + return fmt.Errorf("failed to validate external address: %w", err) + } + alb.ExternalAddress = ptr.To(externalAddress) + return nil +} + +func validateIPAddress(ipAddr string) error { + ip, err := netip.ParseAddr(ipAddr) + if err != nil { + return fmt.Errorf("invalid format for external IP: %w", err) + } + if ip.Is6() { + return fmt.Errorf("external IP must be an IPv4 address") + } + return nil +} + +// getNodePort gets the NodePort of the Service +func getNodePort(services map[string]v1.Service, path networkingv1.HTTPIngressPath) (int32, error) { + service, found := services[path.Backend.Service.Name] + if !found { + return 0, fmt.Errorf("service not found: %s", path.Backend.Service.Name) + } + + if path.Backend.Service.Port.Name != "" { + for _, servicePort := range service.Spec.Ports { + if servicePort.Name == path.Backend.Service.Port.Name { + if servicePort.NodePort == 0 { + return 0, fmt.Errorf("port %q of service %q has no node port", servicePort.Name, path.Backend.Service.Name) + } + return servicePort.NodePort, nil + } + } + } else { + for _, servicePort := range service.Spec.Ports { + if servicePort.Port == path.Backend.Service.Port.Number { + if servicePort.NodePort == 0 { + return 0, fmt.Errorf("port %d of service %q has no node port", servicePort.Port, path.Backend.Service.Name) + } + return servicePort.NodePort, nil + } + } + } + return 0, fmt.Errorf("no matching port found for service %q", path.Backend.Service.Name) +} + +// getIngressPriority retrieves the priority of the Ingress from its annotations. +func getIngressPriority(ingress *networkingv1.Ingress) int { + if val, ok := ingress.Annotations[priorityAnnotation]; ok { + if priority, err := strconv.Atoi(val); err == nil { + if priority >= minPriority && priority <= maxPriority { + return priority + } + } + } + return defaultPriority +} diff --git a/pkg/alb/ingress/alb_spec_test.go b/pkg/alb/ingress/alb_spec_test.go new file mode 100644 index 00000000..cd873229 --- /dev/null +++ b/pkg/alb/ingress/alb_spec_test.go @@ -0,0 +1,419 @@ +package ingress + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "google.golang.org/protobuf/testing/protocmp" + + v1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" + + albsdk "github.com/stackitcloud/stackit-sdk-go/services/alb/v2api" +) + +const ( + testController = "test-controller" + testIngressClassName = "test-ingressclass" + testIngressName = "test-ingress" + testNetworkID = "test-network" + testHost = "example.com" + testPath = "/" + testNodeName = "node-0" + testNodeIP = "1.1.1.1" + testServiceName = "test-service" + testServicePort = 80 + testNodePort = 30080 + testTLSName = "test-tls-secret" +) + +func ingressPrefixPath(path, serviceName string) networkingv1.HTTPIngressPath { + return networkingv1.HTTPIngressPath{ + Path: path, + PathType: ptr.To(networkingv1.PathTypePrefix), + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: serviceName, + Port: networkingv1.ServiceBackendPort{Number: testServicePort}, + }, + }, + } +} + +func ingressExactPath(path, serviceName string) networkingv1.HTTPIngressPath { + return networkingv1.HTTPIngressPath{ + Path: path, + PathType: ptr.To(networkingv1.PathTypeExact), + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: serviceName, + Port: networkingv1.ServiceBackendPort{Number: testServicePort}, + }, + }, + } +} + +func ingressRule(host string, paths ...networkingv1.HTTPIngressPath) networkingv1.IngressRule { + return networkingv1.IngressRule{ + Host: host, + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{Paths: paths}, + }, + } +} + +func fixtureIngressWithParams(name, namespace string, annotations map[string]string, rules ...networkingv1.IngressRule) *networkingv1.Ingress { + return &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Annotations: annotations, + }, + Spec: networkingv1.IngressSpec{ + IngressClassName: ptr.To(testIngressClassName), + Rules: rules, + }, + } +} + +func fixtureServiceWithParams(port, nodePort int32) *v1.Service { + return &v1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: testServiceName}, + Spec: v1.ServiceSpec{ + Ports: []v1.ServicePort{ + { + Port: port, + NodePort: nodePort, + }, + }, + }, + } +} + +func fixtureNode(mods ...func(*v1.Node)) *v1.Node { + node := &v1.Node{ + ObjectMeta: metav1.ObjectMeta{Name: testNodeName}, + Status: v1.NodeStatus{ + Addresses: []v1.NodeAddress{{Type: v1.NodeInternalIP, Address: testNodeIP}}, + }, + } + for _, mod := range mods { + mod(node) + } + return node +} + +func fixtureIngress(mods ...func(*networkingv1.Ingress)) *networkingv1.Ingress { + ingress := &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{Name: testIngressName}, + Spec: networkingv1.IngressSpec{ + IngressClassName: ptr.To(testIngressClassName), + Rules: []networkingv1.IngressRule{ + { + Host: testHost, + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: testPath, + PathType: ptr.To(networkingv1.PathTypePrefix), + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: testServiceName, + Port: networkingv1.ServiceBackendPort{Number: testServicePort}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + for _, mod := range mods { + mod(ingress) + } + return ingress +} + +func fixtureIngressClass(mods ...func(*networkingv1.IngressClass)) *networkingv1.IngressClass { + ingressClass := &networkingv1.IngressClass{ + ObjectMeta: metav1.ObjectMeta{Name: testIngressClassName}, + Spec: networkingv1.IngressClassSpec{Controller: testController}, + } + for _, mod := range mods { + mod(ingressClass) + } + return ingressClass +} + +func fixtureAlbPayload(mods ...func(*albsdk.CreateLoadBalancerPayload)) *albsdk.CreateLoadBalancerPayload { + payload := &albsdk.CreateLoadBalancerPayload{ + Name: ptr.To("k8s-ingress-" + testIngressClassName), + Listeners: &[]albsdk.Listener{ + { + Port: ptr.To(int64(80)), + Protocol: albsdk.LISTENERPROTOCOL_HTTP.Ptr(), + Http: &albsdk.ProtocolOptionsHTTP{ + Hosts: &[]albsdk.HostConfig{ + { + Host: ptr.To(testHost), + Rules: &[]albsdk.Rule{ + { + Path: &albsdk.Path{ + Prefix: ptr.To(testPath), + }, + TargetPool: ptr.To("pool-30080"), + }, + }, + }, + }, + }, + }, + }, + Networks: &[]albsdk.Network{{NetworkId: ptr.To(testNetworkID), Role: albsdk.NETWORKROLE_LISTENERS_AND_TARGETS.Ptr()}}, + Options: &albsdk.LoadBalancerOptions{EphemeralAddress: ptr.To(true)}, + TargetPools: &[]albsdk.TargetPool{ + {Name: ptr.To("pool-30080"), TargetPort: ptr.To(int64(30080)), Targets: &[]albsdk.Target{{DisplayName: ptr.To(testNodeName), Ip: ptr.To(testNodeIP)}}}, + }, + } + for _, mod := range mods { + mod(payload) + } + return payload +} + +func Test_albSpecFromIngress(t *testing.T) { + r := &IngressClassReconciler{} + nodes := []v1.Node{*fixtureNode()} + + tests := []struct { + name string + ingresses []*networkingv1.Ingress + ingressClass *networkingv1.IngressClass + services map[string]v1.Service + want *albsdk.CreateLoadBalancerPayload + wantErr bool + }{ + { + name: "valid ingress with HTTP listener", + ingresses: []*networkingv1.Ingress{fixtureIngress()}, + ingressClass: fixtureIngressClass(), + services: map[string]v1.Service{testServiceName: *fixtureServiceWithParams(testServicePort, testNodePort)}, + want: fixtureAlbPayload(), + }, + { + name: "valid ingress with HTTP listener with external ip address", + ingresses: []*networkingv1.Ingress{fixtureIngress()}, + ingressClass: fixtureIngressClass( + func(ing *networkingv1.IngressClass) { + ing.Annotations = map[string]string{externalIPAnnotation: "2.2.2.2"} + }, + ), + services: map[string]v1.Service{testServiceName: *fixtureServiceWithParams(testServicePort, testNodePort)}, + want: fixtureAlbPayload(func(payload *albsdk.CreateLoadBalancerPayload) { + payload.ExternalAddress = ptr.To("2.2.2.2") + payload.Options = &albsdk.LoadBalancerOptions{EphemeralAddress: nil} + }), + }, + { + name: "valid ingress with HTTP listener with internal ip address", + ingresses: []*networkingv1.Ingress{fixtureIngress()}, + ingressClass: fixtureIngressClass( + func(ing *networkingv1.IngressClass) { + ing.Annotations = map[string]string{internalIPAnnotation: "true"} + }, + ), + services: map[string]v1.Service{testServiceName: *fixtureServiceWithParams(testServicePort, testNodePort)}, + want: fixtureAlbPayload(func(payload *albsdk.CreateLoadBalancerPayload) { + payload.Options = &albsdk.LoadBalancerOptions{PrivateNetworkOnly: ptr.To(true)} + }), + }, + { + name: "host ordering", + ingressClass: fixtureIngressClass(), + ingresses: []*networkingv1.Ingress{ + fixtureIngressWithParams("ingress", "ns", nil, + ingressRule("z-host.com", ingressPrefixPath("/a", "svc1")), + ingressRule("a-host.com", ingressPrefixPath("/a", "svc2")), + ), + }, + services: map[string]v1.Service{ + "svc1": *fixtureServiceWithParams(testServicePort, 30001), + "svc2": *fixtureServiceWithParams(testServicePort, 30002), + }, + want: fixtureAlbPayload(func(p *albsdk.CreateLoadBalancerPayload) { + (*p.Listeners)[0].Http.Hosts = &[]albsdk.HostConfig{ + { + Host: ptr.To("a-host.com"), + Rules: &[]albsdk.Rule{ + {Path: &albsdk.Path{Prefix: ptr.To("/a")}, TargetPool: ptr.To("pool-30002")}, + }, + }, + { + Host: ptr.To("z-host.com"), + Rules: &[]albsdk.Rule{ + {Path: &albsdk.Path{Prefix: ptr.To("/a")}, TargetPool: ptr.To("pool-30001")}, + }, + }, + } + p.TargetPools = &[]albsdk.TargetPool{ + {Name: ptr.To("pool-30001"), TargetPort: ptr.To(int64(30001)), Targets: &[]albsdk.Target{{DisplayName: ptr.To(testNodeName), Ip: ptr.To(testNodeIP)}}}, + {Name: ptr.To("pool-30002"), TargetPort: ptr.To(int64(30002)), Targets: &[]albsdk.Target{{DisplayName: ptr.To(testNodeName), Ip: ptr.To(testNodeIP)}}}, + } + }), + }, + { + name: "priority annotation ordering", + ingressClass: fixtureIngressClass(), + ingresses: []*networkingv1.Ingress{ + fixtureIngressWithParams("low", "ns", nil, + ingressRule("host.com", ingressPrefixPath("/x", "svc1")), + ), + fixtureIngressWithParams("high", "ns", map[string]string{priorityAnnotation: "5"}, + ingressRule("host.com", ingressPrefixPath("/x", "svc2")), + ), + }, + services: map[string]v1.Service{ + "svc1": *fixtureServiceWithParams(testServicePort, 30003), + "svc2": *fixtureServiceWithParams(testServicePort, 30004), + }, + want: fixtureAlbPayload(func(p *albsdk.CreateLoadBalancerPayload) { + (*(*p.Listeners)[0].Http.Hosts)[0].Host = ptr.To("host.com") + (*(*p.Listeners)[0].Http.Hosts)[0].Rules = &[]albsdk.Rule{ + {Path: &albsdk.Path{Prefix: ptr.To("/x")}, TargetPool: ptr.To("pool-30004")}, + {Path: &albsdk.Path{Prefix: ptr.To("/x")}, TargetPool: ptr.To("pool-30003")}, + } + p.TargetPools = &[]albsdk.TargetPool{ + {Name: ptr.To("pool-30003"), TargetPort: ptr.To(int64(30003)), Targets: &[]albsdk.Target{{DisplayName: ptr.To(testNodeName), Ip: ptr.To(testNodeIP)}}}, + {Name: ptr.To("pool-30004"), TargetPort: ptr.To(int64(30004)), Targets: &[]albsdk.Target{{DisplayName: ptr.To(testNodeName), Ip: ptr.To(testNodeIP)}}}, + } + }), + }, + { + name: "path specificity ordering", + ingressClass: fixtureIngressClass(), + ingresses: []*networkingv1.Ingress{ + fixtureIngressWithParams("ingress", "ns", nil, + ingressRule("host.com", + ingressPrefixPath("/short", "svc1"), + ingressPrefixPath("/very/very/long/specific", "svc2"), + ), + ), + }, + services: map[string]v1.Service{ + "svc1": *fixtureServiceWithParams(testServicePort, 30005), + "svc2": *fixtureServiceWithParams(testServicePort, 30006), + }, + want: fixtureAlbPayload(func(p *albsdk.CreateLoadBalancerPayload) { + (*(*p.Listeners)[0].Http.Hosts)[0].Host = ptr.To("host.com") + (*(*p.Listeners)[0].Http.Hosts)[0].Rules = &[]albsdk.Rule{ + {Path: &albsdk.Path{Prefix: ptr.To("/very/very/long/specific")}, TargetPool: ptr.To("pool-30006")}, + {Path: &albsdk.Path{Prefix: ptr.To("/short")}, TargetPool: ptr.To("pool-30005")}, + } + p.TargetPools = &[]albsdk.TargetPool{ + {Name: ptr.To("pool-30005"), TargetPort: ptr.To(int64(30005)), Targets: &[]albsdk.Target{{DisplayName: ptr.To(testNodeName), Ip: ptr.To(testNodeIP)}}}, + {Name: ptr.To("pool-30006"), TargetPort: ptr.To(int64(30006)), Targets: &[]albsdk.Target{{DisplayName: ptr.To(testNodeName), Ip: ptr.To(testNodeIP)}}}, + } + }), + }, + { + name: "path type ordering (Exact before Prefix)", + ingressClass: fixtureIngressClass(), + ingresses: []*networkingv1.Ingress{ + fixtureIngressWithParams("ingress", "ns", nil, + ingressRule("host.com", + ingressExactPath("/same", "svc-exact"), + ingressPrefixPath("/same", "svc-prefix"), + ), + ), + }, + services: map[string]v1.Service{ + "svc-exact": *fixtureServiceWithParams(testServicePort, 30100), + "svc-prefix": *fixtureServiceWithParams(testServicePort, 30101), + }, + want: fixtureAlbPayload(func(p *albsdk.CreateLoadBalancerPayload) { + (*(*p.Listeners)[0].Http.Hosts)[0].Host = ptr.To("host.com") + (*(*p.Listeners)[0].Http.Hosts)[0].Rules = &[]albsdk.Rule{ + {Path: &albsdk.Path{ExactMatch: ptr.To("/same")}, TargetPool: ptr.To("pool-30100")}, + {Path: &albsdk.Path{Prefix: ptr.To("/same")}, TargetPool: ptr.To("pool-30101")}, + } + p.TargetPools = &[]albsdk.TargetPool{ + {Name: ptr.To("pool-30100"), TargetPort: ptr.To(int64(30100)), Targets: &[]albsdk.Target{{DisplayName: ptr.To(testNodeName), Ip: ptr.To(testNodeIP)}}}, + {Name: ptr.To("pool-30101"), TargetPort: ptr.To(int64(30101)), Targets: &[]albsdk.Target{{DisplayName: ptr.To(testNodeName), Ip: ptr.To(testNodeIP)}}}, + } + }), + }, + { + name: "ingress name ordering", + ingressClass: fixtureIngressClass(), + ingresses: []*networkingv1.Ingress{ + fixtureIngressWithParams("b-ingress", "ns", nil, + ingressRule("host.com", ingressPrefixPath("/x", "svc1")), + ), + fixtureIngressWithParams("a-ingress", "ns", nil, + ingressRule("host.com", ingressPrefixPath("/x", "svc2")), + ), + }, + services: map[string]v1.Service{ + "svc1": *fixtureServiceWithParams(testServicePort, 30007), + "svc2": *fixtureServiceWithParams(testServicePort, 30008), + }, + want: fixtureAlbPayload(func(p *albsdk.CreateLoadBalancerPayload) { + (*(*p.Listeners)[0].Http.Hosts)[0].Host = ptr.To("host.com") + (*(*p.Listeners)[0].Http.Hosts)[0].Rules = &[]albsdk.Rule{ + {Path: &albsdk.Path{Prefix: ptr.To("/x")}, TargetPool: ptr.To("pool-30008")}, + {Path: &albsdk.Path{Prefix: ptr.To("/x")}, TargetPool: ptr.To("pool-30007")}, + } + p.TargetPools = &[]albsdk.TargetPool{ + {Name: ptr.To("pool-30007"), TargetPort: ptr.To(int64(30007)), Targets: &[]albsdk.Target{{DisplayName: ptr.To(testNodeName), Ip: ptr.To(testNodeIP)}}}, + {Name: ptr.To("pool-30008"), TargetPort: ptr.To(int64(30008)), Targets: &[]albsdk.Target{{DisplayName: ptr.To(testNodeName), Ip: ptr.To(testNodeIP)}}}, + } + }), + }, + { + name: "namespace ordering", + ingressClass: fixtureIngressClass(), + ingresses: []*networkingv1.Ingress{ + fixtureIngressWithParams("ingress", "ns-b", nil, + ingressRule("host.com", ingressPrefixPath("/x", "svc1")), + ), + fixtureIngressWithParams("ingress", "ns-a", nil, + ingressRule("host.com", ingressPrefixPath("/x", "svc2")), + ), + }, + services: map[string]v1.Service{ + "svc1": *fixtureServiceWithParams(testServicePort, 30009), + "svc2": *fixtureServiceWithParams(testServicePort, 30010), + }, + want: fixtureAlbPayload(func(p *albsdk.CreateLoadBalancerPayload) { + (*(*p.Listeners)[0].Http.Hosts)[0].Host = ptr.To("host.com") + (*(*p.Listeners)[0].Http.Hosts)[0].Rules = &[]albsdk.Rule{ + {Path: &albsdk.Path{Prefix: ptr.To("/x")}, TargetPool: ptr.To("pool-30010")}, + {Path: &albsdk.Path{Prefix: ptr.To("/x")}, TargetPool: ptr.To("pool-30009")}, + } + p.TargetPools = &[]albsdk.TargetPool{ + {Name: ptr.To("pool-30009"), TargetPort: ptr.To(int64(30009)), Targets: &[]albsdk.Target{{DisplayName: ptr.To(testNodeName), Ip: ptr.To(testNodeIP)}}}, + {Name: ptr.To("pool-30010"), TargetPort: ptr.To(int64(30010)), Targets: &[]albsdk.Target{{DisplayName: ptr.To(testNodeName), Ip: ptr.To(testNodeIP)}}}, + } + }), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := r.albSpecFromIngress(context.TODO(), tt.ingresses, tt.ingressClass, ptr.To(testNetworkID), nodes, tt.services) + if (err != nil) != tt.wantErr { + t.Errorf("got error = %v, wantErr %v", err, tt.wantErr) + return + } + if diff := cmp.Diff(tt.want, got, protocmp.Transform()); diff != "" { + t.Errorf("got %v, want %v, diff=%s", got, tt.want, diff) + } + }) + } +} diff --git a/pkg/alb/ingress/controller_test.go b/pkg/alb/ingress/controller_test.go new file mode 100644 index 00000000..feb81865 --- /dev/null +++ b/pkg/alb/ingress/controller_test.go @@ -0,0 +1,462 @@ +package ingress_test + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/utils/ptr" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/cache" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/config" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/metrics/server" + + albclient "github.com/stackitcloud/cloud-provider-stackit/pkg/stackit" + + "go.uber.org/mock/gomock" +) + +const ( + finalizerName = "stackit.cloud/alb-ingress" + projectID = "dummy-project-id" + region = "eu01" +) + +var _ = Describe("IngressClassReconciler", func() { + var ( + k8sClient client.Client + namespace *corev1.Namespace + mockCtrl *gomock.Controller + albClient *albclient.MockClient + certClient *certificateclient.MockClient + ctx context.Context + cancel context.CancelFunc + ) + + BeforeEach(func() { + ctx, cancel = context.WithCancel(context.Background()) + DeferCleanup(cancel) + + mockCtrl = gomock.NewController(GinkgoT()) + albClient = albclient.NewMockClient(mockCtrl) + certClient = certificateclient.NewMockClient(mockCtrl) + + var err error + k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) + Expect(err).NotTo(HaveOccurred()) + + namespace = &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "stackit-alb-ingress-test-", + }, + } + Expect(k8sClient.Create(ctx, namespace)).To(Succeed()) + DeferCleanup(func() { + _ = k8sClient.Delete(context.Background(), namespace) + }) + + mgr, err := ctrl.NewManager(cfg, ctrl.Options{ + Metrics: server.Options{BindAddress: "0"}, + Cache: cache.Options{ + DefaultNamespaces: map[string]cache.Config{ + namespace.Name: {}, + }, + }, + Controller: config.Controller{SkipNameValidation: ptr.To(true)}, + }) + Expect(err).NotTo(HaveOccurred()) + + reconciler := &controller.IngressClassReconciler{ + Client: mgr.GetClient(), + Scheme: scheme.Scheme, + ALBClient: albClient, + CertificateClient: certClient, + ProjectID: projectID, + Region: region, + NetworkID: "dummy-network", + } + Expect(reconciler.SetupWithManager(mgr)).To(Succeed()) + + go func() { + defer GinkgoRecover() + Expect(mgr.Start(ctx)).To(Succeed()) + }() + + Eventually(func() bool { + return mgr.GetCache().WaitForCacheSync(ctx) + }, "2s", "50ms").Should(BeTrue()) + }) + + Context("when the IngressClass does NOT point to our controller", func() { + It("should ignore the IngressClass", func() { + ingressClass := &networkingv1.IngressClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ignored-ingressclass", + Namespace: namespace.Name, + }, + Spec: networkingv1.IngressClassSpec{ + Controller: "some.other/controller", + }, + } + Expect(k8sClient.Create(ctx, ingressClass)).To(Succeed()) + + Consistently(func() error { + return k8sClient.Get(ctx, client.ObjectKeyFromObject(ingressClass), ingressClass) + }).Should(Succeed()) + + Expect(ingressClass.Finalizers).To(BeEmpty()) + }) + }) + + Context("when the IngressClass points to our controller", func() { + var ingressClass *networkingv1.IngressClass + + BeforeEach(func() { + ingressClass = &networkingv1.IngressClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: "managed-ingressclass", + Namespace: namespace.Name, + }, + Spec: networkingv1.IngressClassSpec{ + Controller: "stackit.cloud/alb-ingress", + }, + } + Expect(k8sClient.Create(ctx, ingressClass)).To(Succeed()) + }) + + AfterEach(func() { + var ic networkingv1.IngressClass + err := k8sClient.Get(ctx, client.ObjectKeyFromObject(ingressClass), &ic) + if k8serrors.IsNotFound(err) { + // nothing to clean up, it’s already deleted + return + } + Expect(err).NotTo(HaveOccurred()) + + if controllerutil.ContainsFinalizer(&ic, finalizerName) { + controllerutil.RemoveFinalizer(&ic, finalizerName) + Expect(k8sClient.Update(ctx, &ic)).To(Succeed()) + } + + // delete the patched object (ic), not the old ingressClass pointer + err = k8sClient.Delete(ctx, &ic) + if err != nil && !k8serrors.IsNotFound(err) { + Expect(err).NotTo(HaveOccurred()) + } + + Eventually(func() bool { + return k8serrors.IsNotFound( + k8sClient.Get(ctx, client.ObjectKeyFromObject(ingressClass), &networkingv1.IngressClass{}), + ) + }).Should(BeTrue(), "IngressClass should be fully deleted") + }) + + Context("and it is being deleted", func() { + BeforeEach(func() { + Expect(controllerutil.AddFinalizer(ingressClass, finalizerName)).To(BeTrue()) + Expect(k8sClient.Update(ctx, ingressClass)).To(Succeed()) + + // Stub ALB deletion in case controller proceeds to cleanup + albClient.EXPECT(). + DeleteLoadBalancer(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + AnyTimes(). + Return(nil) + + // Stub certificate deletion in case controller proceeds to cleanup + certClient.EXPECT(). + ListCertificate(gomock.Any(), gomock.Any(), gomock.Any()). + AnyTimes(). + Return(nil, nil) + }) + + Context("and NO referencing Ingresses exist", func() { + It("should remove finalizer and delete ALB", func() { + Expect(k8sClient.Delete(ctx, ingressClass)).To(Succeed()) + Eventually(func(g Gomega) { + var ic networkingv1.IngressClass + err := k8sClient.Get(ctx, client.ObjectKeyFromObject(ingressClass), &ic) + if k8serrors.IsNotFound(err) { + // IngressClass is gone — controller must have removed the finalizer + return + } + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(controllerutil.ContainsFinalizer(&ic, finalizerName)).To(BeFalse()) + }).Should(Succeed()) + }) + }) + + Context("and referencing Ingresses DO exist", func() { + It("should NOT remove finalizer and NOT delete ALB", func() { + ing := &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "referencing-ingress", + Namespace: namespace.Name, + }, + Spec: networkingv1.IngressSpec{ + IngressClassName: ptr.To("managed-ingressclass"), + Rules: []networkingv1.IngressRule{ + { + Host: "example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/", + PathType: ptr.To(networkingv1.PathTypePrefix), + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "dummy-svc", + Port: networkingv1.ServiceBackendPort{Number: 80}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, ing)).To(Succeed()) + DeferCleanup(func() { + _ = k8sClient.Delete(ctx, ing) + }) + + // Wait until the controller sees the Ingress and processes it + Eventually(func(g Gomega) { + var ic networkingv1.IngressClass + err := k8sClient.Get(ctx, client.ObjectKeyFromObject(ingressClass), &ic) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(ic.Finalizers).To(ContainElement(finalizerName)) + }).Should(Succeed()) + + Expect(k8sClient.Delete(ctx, ingressClass)).To(Succeed()) + + // Expect finalizer to still be there + Consistently(func(g Gomega) { + var ic networkingv1.IngressClass + err := k8sClient.Get(ctx, client.ObjectKeyFromObject(ingressClass), &ic) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(ic.Finalizers).To(ContainElement(finalizerName)) + }, "1s", "100ms").Should(Succeed()) + }) + }) + }) + + Context("and it is NOT being deleted", func() { + Context("and it does NOT have the finalizer", func() { + It("should add the finalizer", func() { + // Stub ALB deletion in case controller proceeds to cleanup + albClient.EXPECT(). + DeleteLoadBalancer(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + AnyTimes(). + Return(nil) + + // Stub certificate deletion in case controller proceeds to cleanup + certClient.EXPECT(). + ListCertificate(gomock.Any(), gomock.Any(), gomock.Any()). + AnyTimes(). + Return(nil, nil) + + Eventually(func(g Gomega) { + var updated networkingv1.IngressClass + err := k8sClient.Get(ctx, client.ObjectKeyFromObject(ingressClass), &updated) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(controllerutil.ContainsFinalizer(&updated, finalizerName)).To(BeTrue()) + }).Should(Succeed()) + }) + }) + + Context("and it ALREADY has the finalizer", func() { + BeforeEach(func() { + Expect(controllerutil.AddFinalizer(ingressClass, finalizerName)).To(BeTrue()) + Expect(k8sClient.Update(ctx, ingressClass)).To(Succeed()) + }) + + Context("and NO referencing Ingresses exist", func() { + It("should clean up ALB and certs, but retain the IngressClass and finalizer", func() { + albClient.EXPECT(). + DeleteLoadBalancer(gomock.Any(), projectID, region, "k8s-ingress-managed-ingressclass"). + Return(nil). + AnyTimes() + + certClient.EXPECT(). + ListCertificate(gomock.Any(), projectID, region). + Return(nil, nil). + AnyTimes() + + certClient.EXPECT(). + DeleteCertificate(gomock.Any(), projectID, region, gomock.Any()). + Return(nil). + AnyTimes() + + Consistently(func(g Gomega) { + var ic networkingv1.IngressClass + err := k8sClient.Get(ctx, client.ObjectKeyFromObject(ingressClass), &ic) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(controllerutil.ContainsFinalizer(&ic, finalizerName)).To(BeTrue()) + }, "5s", "100ms").Should(Succeed()) + }) + }) + + Context("and referencing Ingresses DO exist", func() { + BeforeEach(func() { + ing := &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "referencing-ingress", + Namespace: namespace.Name, + }, + Spec: networkingv1.IngressSpec{ + IngressClassName: ptr.To("managed-ingressclass"), + Rules: []networkingv1.IngressRule{ + { + Host: "example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/", + PathType: ptr.To(networkingv1.PathTypePrefix), + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "dummy-svc", + Port: networkingv1.ServiceBackendPort{Number: 80}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, ing)).To(Succeed()) + DeferCleanup(func() { + _ = k8sClient.Delete(ctx, ing) + }) + }) + + // Context("and ALB does NOT exist", func() { + // FIt("should create the ALB", func() { + // Eventually(func(g Gomega) { + // var ic networkingv1.IngressClass + // err := k8sClient.Get(ctx, client.ObjectKeyFromObject(ingressClass), &ic) + // g.Expect(err).NotTo(HaveOccurred()) + // g.Expect(ic.DeletionTimestamp.IsZero()).To(BeTrue(), "IngressClass should not be marked for deletion") + // g.Expect(ic.Finalizers).To(ContainElement(finalizerName), "Finalizer should still be present") + // }).Should(Succeed()) + // }) + // }) + + // Context("and ALB already exists", func() { + // BeforeEach(func() { + // albClient.EXPECT(). + // GetLoadBalancer(gomock.Any(), projectID, region, "k8s-ingress-managed-ingressclass"). + // Return(&albsdk.LoadBalancer{ + // Listeners: &[]albsdk.Listener{}, + // TargetPools: &[]albsdk.TargetPool{}, + // Status: albsdk.LOADBALANCERSTATUS_READY.Ptr(), + // ExternalAddress: ptr.To("1.2.3.4"), + // Version: albsdk.PtrString("1"), + // }, nil) + // }) + + // Context("and ALB config has changed", func() { + // It("should update the ALB", func() { + // albClient.EXPECT(). + // UpdateLoadBalancer(gomock.Any(), projectID, region, "k8s-ingress-managed-ingressclass", gomock.Any()). + // Return(nil, nil) + + // Eventually(func(g Gomega) { + // var ic networkingv1.IngressClass + // err := k8sClient.Get(ctx, client.ObjectKeyFromObject(ingressClass), &ic) + // g.Expect(err).NotTo(HaveOccurred()) + // }).Should(Succeed()) + // }) + // }) + + // Context("and ALB config has NOT changed", func() { + // It("should not update the ALB", func() { + // // No update call expected + // Eventually(func(g Gomega) { + // var ic networkingv1.IngressClass + // err := k8sClient.Get(ctx, client.ObjectKeyFromObject(ingressClass), &ic) + // g.Expect(err).NotTo(HaveOccurred()) + // }).Should(Succeed()) + // }) + // }) + + // Context("and ALB is ready and has an IP", func() { + // It("should update Ingress status", func() { + // Eventually(func(g Gomega) { + // var updated networkingv1.Ingress + // err := k8sClient.Get(ctx, client.ObjectKeyFromObject(ingress), &updated) + // g.Expect(err).NotTo(HaveOccurred()) + // g.Expect(updated.Status.LoadBalancer.Ingress).ToNot(BeEmpty()) + // g.Expect(updated.Status.LoadBalancer.Ingress[0].IP).To(Equal("1.2.3.4")) + // }).Should(Succeed()) + // }) + // }) + + // Context("and ALB is ready but has NO IP", func() { + // BeforeEach(func() { + // albClient.EXPECT(). + // GetLoadBalancer(gomock.Any(), projectID, region, "k8s-ingress-managed-ingressclass"). + // Return(&albsdk.LoadBalancer{ + // Listeners: &[]albsdk.Listener{}, + // TargetPools: &[]albsdk.TargetPool{}, + // Status: ptr.To(albclient.LBStatusReady), + // ExternalAddress: nil, + // PrivateAddress: nil, + // Version: 1, + // }, nil) + // }) + + // It("should requeue for later", func() { + // // This can be indirectly asserted by ensuring status is not updated yet + // Consistently(func(g Gomega) { + // var updated networkingv1.Ingress + // err := k8sClient.Get(ctx, client.ObjectKeyFromObject(ingress), &updated) + // g.Expect(err).NotTo(HaveOccurred()) + // g.Expect(updated.Status.LoadBalancer.Ingress).To(BeEmpty()) + // }, "1s", "100ms").Should(Succeed()) + // }) + // }) + + // Context("and ALB is NOT ready", func() { + // BeforeEach(func() { + // albClient.EXPECT(). + // GetLoadBalancer(gomock.Any(), projectID, region, "k8s-ingress-managed-ingressclass"). + // Return(&albsdk.LoadBalancer{ + // Listeners: &[]albsdk.Listener{}, + // TargetPools: &[]albsdk.TargetPool{}, + // Status: ptr.To("PENDING"), + // ExternalAddress: nil, + // PrivateAddress: nil, + // Version: 1, + // }, nil) + // }) + + // It("should requeue for later", func() { + // Consistently(func(g Gomega) { + // var updated networkingv1.Ingress + // err := k8sClient.Get(ctx, client.ObjectKeyFromObject(ingress), &updated) + // g.Expect(err).NotTo(HaveOccurred()) + // g.Expect(updated.Status.LoadBalancer.Ingress).To(BeEmpty()) + // }, "1s", "100ms").Should(Succeed()) + // }) + // }) + // }) + }) + }) + }) + }) +}) diff --git a/pkg/alb/ingress/ingressclass_controller.go b/pkg/alb/ingress/ingressclass_controller.go new file mode 100644 index 00000000..dddec288 --- /dev/null +++ b/pkg/alb/ingress/ingressclass_controller.go @@ -0,0 +1,467 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package ingress + +import ( + "context" + "crypto/md5" + "encoding/hex" + "errors" + "fmt" + "time" + + v1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/handler" + + albclient "github.com/stackitcloud/cloud-provider-stackit/pkg/stackit" + albsdk "github.com/stackitcloud/stackit-sdk-go/services/alb/v2api" +) + +const ( + // finalizerName is the name of the finalizer that is added to the 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 { + client.Client + ALBClient albclient.Client + CertificateClient certificateclient.Client + Scheme *runtime.Scheme + ProjectID string + NetworkID string + Region string +} + +// +kubebuilder:rbac:groups=networking.k8s.io.stackit.cloud,resources=ingressclasses,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=networking.k8s.io.stackit.cloud,resources=ingressclasses/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=networking.k8s.io.stackit.cloud,resources=ingressclasses/finalizers,verbs=update + +// Reconcile is part of the main kubernetes reconciliation loop which aims to +// move the current state of the cluster closer to the desired state. +// TODO(user): Modify the Reconcile function to compare the state specified by +// the IngressClass object against the actual cluster state, and then +// perform operations to make the cluster state reflect the state specified by +// the user. +// +// For more details, check Reconcile and its Result here: +// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.19.4/pkg/reconcile +func (r *IngressClassReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + // _ = log.FromContext(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 + } + + albIngressList, err := r.getAlbIngressList(ctx, ingressClass) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to get the list of Ingresses %s: %w", ingressClass.Name, err) + } + + if !ingressClass.DeletionTimestamp.IsZero() { + err := r.handleIngressClassDeletion(ctx, albIngressList, 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 len(albIngressList) < 1 { + err := r.handleIngressClassWithoutIngresses(ctx, albIngressList, ingressClass) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to reconcile %s IngressClass with no Ingresses: %w", getAlbName(ingressClass), err) + } + return ctrl.Result{}, nil + } + _, err = r.handleIngressClassWithIngresses(ctx, albIngressList, ingressClass) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to reconcile %s IngressClass with Ingresses: %w", getAlbName(ingressClass), err) + } + + return ctrl.Result{}, nil +} + +// SetupWithManager sets up the controller with the Manager. +func (r *IngressClassReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + // Uncomment the following line adding a pointer to an instance of the controlled resource as an argument + For(&networkingv1.IngressClass{}). + Watches(&v1.Node{}, handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, o client.Object) []ctrl.Request { + // TODO: Add predicates - watch only for specific changes on nodes + ingressClassList := &networkingv1.IngressClassList{} + err := r.Client.List(ctx, ingressClassList) + if err != nil { + panic(err) + } + requestList := []ctrl.Request{} + for _, ingressClass := range ingressClassList.Items { + requestList = append(requestList, ctrl.Request{ + NamespacedName: client.ObjectKeyFromObject(&ingressClass), + }) + } + return requestList + })). + Watches(&networkingv1.Ingress{}, handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, o client.Object) []ctrl.Request { + ingress, ok := o.(*networkingv1.Ingress) + if !ok || ingress.Spec.IngressClassName == nil { + return nil + } + + return []ctrl.Request{ + { + NamespacedName: types.NamespacedName{ + Name: *ingress.Spec.IngressClassName, + }, + }, + } + })). + Named("ingressclass"). + Complete(r) +} + +// handleIngressClassWithIngresses handles the state of IngressClass when at least one Ingress resource is referencing it. +// It ensures that the ALB is created when it is the first ever Ingress +// referencing the specified IngressClass, and performs updates otherwise. +func (r *IngressClassReconciler) handleIngressClassWithIngresses( + ctx context.Context, + ingresses []*networkingv1.Ingress, + ingressClass *networkingv1.IngressClass, +) (ctrl.Result, error) { + // Get all nodes and services + nodes := &v1.NodeList{} + err := r.Client.List(ctx, nodes) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to get nodes: %w", err) + } + serviceList := &v1.ServiceList{} + err = r.Client.List(ctx, serviceList) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to get services: %w", err) + } + services := map[string]v1.Service{} + for _, service := range serviceList.Items { + services[service.Name] = service + } + + // Create ALB payload from Ingresses + albPayload, err := r.albSpecFromIngress(ctx, ingresses, ingressClass, &r.NetworkID, nodes.Items, services) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to create alb payload: %w", err) + } + + // Create ALB if it doesn't exist + alb, err := r.ALBClient.GetLoadBalancer(ctx, r.ProjectID, r.Region, getAlbName(ingressClass)) + if errors.Is(err, albclient.ErrorNotFound) { + _, err := r.ALBClient.CreateLoadBalancer(ctx, r.ProjectID, r.Region, albPayload) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to create load balancer: %w", err) + } + return ctrl.Result{}, nil + } + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to get load balancer: %w", err) + } + + // Update ALB if it exists and the configuration has changed + if detectChange(alb, albPayload) { + updatePayload := &albsdk.UpdateLoadBalancerPayload{ + Name: albPayload.Name, + ExternalAddress: albPayload.ExternalAddress, + Listeners: albPayload.Listeners, + Networks: albPayload.Networks, + Options: albPayload.Options, + TargetPools: albPayload.TargetPools, + Version: alb.Version, + } + + if _, err := r.ALBClient.UpdateLoadBalancer(ctx, r.ProjectID, r.Region, getAlbName(ingressClass), updatePayload); err != nil { + return ctrl.Result{}, fmt.Errorf("failed to update load balancer: %w", err) + } + } + + requeue, err := r.updateStatus(ctx, ingresses, ingressClass) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to update ingress status: %w", err) + } + return requeue, nil +} + +// updateStatus updates the status of the Ingresses with the ALB IP address +func (r *IngressClassReconciler) updateStatus(ctx context.Context, ingresses []*networkingv1.Ingress, ingressClass *networkingv1.IngressClass) (ctrl.Result, error) { + alb, err := r.ALBClient.GetLoadBalancer(ctx, r.ProjectID, r.Region, getAlbName(ingressClass)) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to get load balancer: %w", err) + } + + if *alb.Status != albclient.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 == "" { + // ALB ready, but IP not available yet, requeue + return ctrl.Result{RequeueAfter: 10 * time.Second}, nil + } + + for _, ingress := range ingresses { + // Fetch the latest Ingress object to check its current status + currentIngress := &networkingv1.Ingress{} + if err := r.Client.Get(ctx, client.ObjectKeyFromObject(ingress), currentIngress); err != nil { + return ctrl.Result{}, fmt.Errorf("failed to get latest ingress %s/%s: %v", ingress.Namespace, ingress.Name, err) + } + + // Check if the IP in the current Ingress status is different + shouldUpdate := false + if len(currentIngress.Status.LoadBalancer.Ingress) == 0 { + shouldUpdate = true + } else if currentIngress.Status.LoadBalancer.Ingress[0].IP != albIP { + shouldUpdate = true + } + + if shouldUpdate { + currentIngress.Status.LoadBalancer.Ingress = []networkingv1.IngressLoadBalancerIngress{ + {IP: albIP}, + } + if err := r.Client.Status().Update(ctx, currentIngress); err != nil { + return ctrl.Result{}, fmt.Errorf("failed to update ingress status %s/%s: %v", currentIngress.Namespace, currentIngress.Name, err) + } + } + } + + return ctrl.Result{}, nil +} + +// handleIngressClassWithoutIngresses handles the state of the IngressClass that is not referenced by any Ingress +func (r *IngressClassReconciler) handleIngressClassWithoutIngresses( + ctx context.Context, + ingresses []*networkingv1.Ingress, + ingressClass *networkingv1.IngressClass, +) error { + err := r.ALBClient.DeleteLoadBalancer(ctx, r.ProjectID, r.Region, getAlbName(ingressClass)) + if err != nil { + return fmt.Errorf("failed to delete load balancer: %w", err) + } + err = r.cleanupCerts(ctx, ingressClass, ingresses) + if err != nil { + return fmt.Errorf("failed to clean up certificates: %w", err) + } + + return nil +} + +// handleIngressClassDeletion handles the deletion of IngressClass resource. +// It ensures that the ALB is deleted only when no other Ingresses +// are referencing the the same IngressClass. +func (r *IngressClassReconciler) handleIngressClassDeletion( + ctx context.Context, + ingresses []*networkingv1.Ingress, + ingressClass *networkingv1.IngressClass, +) error { + // Before deleting ALB, ensure no other Ingresses with the same IngressClassName exist + if len(ingresses) < 1 { + err := r.ALBClient.DeleteLoadBalancer(ctx, r.ProjectID, r.Region, getAlbName(ingressClass)) + if err != nil { + return fmt.Errorf("failed to delete load balancer: %w", err) + } + // Remove finalizer from the IngressClass + 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) + } + } + } + + // TODO: Throw en error saying other ingresses are still referencing this ingress class + return nil +} + +// detectChange checks if there is any difference between the current and desired ALB configuration. +func detectChange(alb *albsdk.LoadBalancer, albPayload *albsdk.CreateLoadBalancerPayload) bool { + if len(*alb.Listeners) != len(*albPayload.Listeners) { + return true + } + + for i := range *alb.Listeners { + albListener := (*alb.Listeners)[i] + payloadListener := (*albPayload.Listeners)[i] + + if ptr.Deref(albListener.Protocol, "") != ptr.Deref(payloadListener.Protocol, "") || + ptr.Deref(albListener.Port, 0) != ptr.Deref(payloadListener.Port, 0) { + return true + } + + // HTTP rules comparison (via Hosts) + if albListener.Http != nil && payloadListener.Http != nil { + albHosts := albListener.Http.Hosts + payloadHosts := payloadListener.Http.Hosts + + if len(ptr.Deref(albHosts, nil)) != len(ptr.Deref(payloadHosts, nil)) { + return true + } + + for j := range *albHosts { + albHost := (*albHosts)[j] + payloadHost := (*payloadHosts)[j] + + if ptr.Deref(albHost.Host, "") != ptr.Deref(payloadHost.Host, "") { + return true + } + + if len(ptr.Deref(albHost.Rules, nil)) != len(ptr.Deref(payloadHost.Rules, nil)) { + return true + } + + for k := range *albHost.Rules { + albRule := (*albHost.Rules)[k] + payloadRule := (*payloadHost.Rules)[k] + + if albRule.Path != nil || payloadRule.Path != nil { + if albRule.Path == nil || payloadRule.Path == nil { + return true + } + if ptr.Deref(albRule.Path.Prefix, "") != ptr.Deref(payloadRule.Path.Prefix, "") { + return true + } + } + if ptr.Deref(albRule.TargetPool, "") != ptr.Deref(payloadRule.TargetPool, "") { + return true + } + } + } + } else if albListener.Http != nil || payloadListener.Http != nil { + // One is nil, one isn't + return true + } + + // HTTPS certificate comparison + if albListener.Https != nil && payloadListener.Https != nil { + a := albListener.Https.CertificateConfig + b := payloadListener.Https.CertificateConfig + if len(ptr.Deref(a.CertificateIds, nil)) != len(ptr.Deref(b.CertificateIds, nil)) { + return true + } + } else if albListener.Https != nil || payloadListener.Https != nil { + // One is nil, one isn't + return true + } + } + + // TargetPools comparison + if len(*alb.TargetPools) != len(*albPayload.TargetPools) { + return true + } + for i := range *alb.TargetPools { + a := (*alb.TargetPools)[i] + b := (*albPayload.TargetPools)[i] + + if ptr.Deref(a.Name, "") != ptr.Deref(b.Name, "") || + ptr.Deref(a.TargetPort, 0) != ptr.Deref(b.TargetPort, 0) { + return true + } + + if len(ptr.Deref(a.Targets, nil)) != len(ptr.Deref(b.Targets, nil)) { + return true + } + + if (a.TlsConfig == nil) != (b.TlsConfig == nil) { + return true + } + if a.TlsConfig != nil && b.TlsConfig != nil { + if ptr.Deref(a.TlsConfig.SkipCertificateValidation, false) != ptr.Deref(b.TlsConfig.SkipCertificateValidation, false) || + ptr.Deref(a.TlsConfig.CustomCa, "") != ptr.Deref(b.TlsConfig.CustomCa, "") { + return true + } + } + } + + return false +} + +// getAlbIngressList lists all Ingresses that reference specified IngressClass +func (r *IngressClassReconciler) getAlbIngressList( + ctx context.Context, + ingressClass *networkingv1.IngressClass, +) ([]*networkingv1.Ingress, error) { + ingressList := &networkingv1.IngressList{} + err := r.Client.List(ctx, ingressList) + if err != nil { + return nil, fmt.Errorf("failed to list all Ingresses: %w", err) + } + + ingresses := []*networkingv1.Ingress{} + for _, ingress := range ingressList.Items { + if ingress.Spec.IngressClassName != nil && *ingress.Spec.IngressClassName == ingressClass.Name { + ingresses = append(ingresses, &ingress) + } + } + + return ingresses, nil +} + +// getAlbName returns the name for the ALB by retrieving the name of the IngressClass +func getAlbName(ingressClass *networkingv1.IngressClass) string { + return fmt.Sprintf("k8s-ingress-%s", ingressClass.Name) +} + +// getCertName generates a unique name for the Certificate using the IngressClass UID, Ingress UID, +// and TLS Secret UID, ensuring it fits within the Kubernetes 63-character limit. +func getCertName(ingressClass *networkingv1.IngressClass, ingress *networkingv1.Ingress, tlsSecret *v1.Secret) string { + ingressClassShortUID := generateShortUID(ingressClass.UID) + ingressShortUID := generateShortUID(ingress.UID) + tlsSecretShortUID := generateShortUID(tlsSecret.UID) + + return fmt.Sprintf("%s-%s-%s", ingressClassShortUID, ingressShortUID, tlsSecretShortUID) +} + +// generateShortUID generates a shortened version of a UID by hashing it. +func generateShortUID(uid types.UID) string { + hash := md5.Sum([]byte(uid)) + return hex.EncodeToString(hash[:4]) +} diff --git a/pkg/alb/ingress/ingressclass_controller_test.go b/pkg/alb/ingress/ingressclass_controller_test.go new file mode 100644 index 00000000..68e46c9d --- /dev/null +++ b/pkg/alb/ingress/ingressclass_controller_test.go @@ -0,0 +1,238 @@ +package ingress + +import ( + "context" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + gomock "go.uber.org/mock/gomock" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + albclient "github.com/stackitcloud/cloud-provider-stackit/pkg/stackit" + albsdk "github.com/stackitcloud/stackit-sdk-go/services/alb/v2api" +) + +const ( + testProjectID = "test-project" + testRegion = "test-region" + testALBName = "k8s-ingress-test-ingressclass" + testNamespace = "test-namespace" + testPublicIP = "1.2.3.4" + testPrivateIP = "10.0.0.1" +) + +func TestIngressClassReconciler_updateStatus(t *testing.T) { + testIngressClass := &networkingv1.IngressClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: testIngressClassName, + }, + } + + tests := []struct { + name string + ingresses []*networkingv1.Ingress + mockK8sClient func(client.Client) error + mockALBClient func(*mock_albclient.MockClient) + wantResult reconcile.Result + wantErr bool + }{ + { + name: "ALB not ready (Terminating), should requeue", + mockK8sClient: func(c client.Client) error { + return c.Create(context.Background(), &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testIngressName}, + }) + }, + mockALBClient: func(m *mock_albclient.MockClient) { + m.EXPECT(). + GetLoadBalancer(gomock.Any(), testProjectID, testRegion, testALBName). + Return(&albsdk.LoadBalancer{ + Status: albsdk.LOADBALANCERSTATUS_TERMINATING.Ptr(), + }, nil) + }, + wantResult: reconcile.Result{RequeueAfter: 10 * time.Second}, + wantErr: false, + }, + // This case only checks the reconcile result, not whether the ingress status was actually updated. + // The actual update logic will be verified in integration tests. + { + name: "ALB ready, public IP available, ingress status needs update", + ingresses: []*networkingv1.Ingress{ + {ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testIngressName}}, + }, + mockK8sClient: func(c client.Client) error { + return c.Create(context.Background(), &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testIngressName}, + }) + }, + mockALBClient: func(m *mock_albclient.MockClient) { + m.EXPECT(). + GetLoadBalancer(gomock.Any(), testProjectID, testRegion, testALBName). + Return(&albsdk.LoadBalancer{ + Status: albsdk.LOADBALANCERSTATUS_READY.Ptr(), + ExternalAddress: ptr.To(testPublicIP), + }, nil) + }, + wantResult: reconcile.Result{}, + wantErr: false, + }, + // This case only checks the reconcile result, not whether the ingress status was actually updated. + // The actual update logic will be verified in integration tests. + { + name: "ALB ready, private IP available, ingress status needs update", + ingresses: []*networkingv1.Ingress{ + {ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testIngressName}}, + }, + mockK8sClient: func(c client.Client) error { + return c.Create(context.Background(), &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testIngressName}, + }) + }, + mockALBClient: func(m *mock_albclient.MockClient) { + m.EXPECT(). + GetLoadBalancer(gomock.Any(), testProjectID, testRegion, testALBName). + Return(&albsdk.LoadBalancer{ + Status: albsdk.LOADBALANCERSTATUS_READY.Ptr(), + PrivateAddress: ptr.To(testPrivateIP), + }, nil) + }, + wantResult: reconcile.Result{}, + wantErr: false, + }, + // This case only checks the reconcile result, not whether the ingress status was actually updated. + // The actual update logic will be verified in integration tests. + { + name: "ALB ready, IP already correct, no update", + ingresses: []*networkingv1.Ingress{ + { + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testIngressName}, + Status: networkingv1.IngressStatus{ + LoadBalancer: networkingv1.IngressLoadBalancerStatus{ + Ingress: []networkingv1.IngressLoadBalancerIngress{{IP: testPublicIP}}, + }, + }, + }, + }, + mockK8sClient: func(c client.Client) error { + return c.Create(context.Background(), &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testIngressName}, + Status: networkingv1.IngressStatus{ + LoadBalancer: networkingv1.IngressLoadBalancerStatus{ + Ingress: []networkingv1.IngressLoadBalancerIngress{{IP: testPublicIP}}, + }, + }, + }) + }, + mockALBClient: func(m *mock_albclient.MockClient) { + m.EXPECT(). + GetLoadBalancer(gomock.Any(), testProjectID, testRegion, testALBName). + Return(&albsdk.LoadBalancer{ + Status: albsdk.LOADBALANCERSTATUS_READY.Ptr(), + PrivateAddress: ptr.To(testPublicIP), + }, nil) + }, + wantResult: reconcile.Result{}, + wantErr: false, + }, + { + name: "failed to get load balancer", + ingresses: []*networkingv1.Ingress{ + {ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testIngressName}}, + }, + mockALBClient: func(m *mock_albclient.MockClient) { + m.EXPECT().GetLoadBalancer(gomock.Any(), testProjectID, testRegion, testALBName).Return(nil, albclient.ErrorNotFound) + }, + wantResult: reconcile.Result{}, + wantErr: true, + }, + { + name: "failed to get latest ingress", + ingresses: []*networkingv1.Ingress{ + {ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testIngressName}}, + }, + mockALBClient: func(m *mock_albclient.MockClient) { + m.EXPECT(). + GetLoadBalancer(gomock.Any(), testProjectID, testRegion, testALBName). + Return(&albsdk.LoadBalancer{ + Status: albsdk.LOADBALANCERSTATUS_READY.Ptr(), + PrivateAddress: ptr.To(testPublicIP), + }, nil) + }, + wantResult: reconcile.Result{}, + wantErr: true, + }, + // This case only checks the reconcile result, not whether the ingress status was actually updated. + // The actual update logic will be verified in integration tests. + { + name: "failed to update ingress status", + ingresses: []*networkingv1.Ingress{ + {ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testIngressName}}, + }, + mockALBClient: func(m *mock_albclient.MockClient) { + m.EXPECT(). + GetLoadBalancer(gomock.Any(), testProjectID, testRegion, testALBName). + Return(&albsdk.LoadBalancer{ + Status: albsdk.LOADBALANCERSTATUS_READY.Ptr(), + PrivateAddress: ptr.To(testPublicIP), + }, nil) + }, + wantResult: reconcile.Result{}, + wantErr: true, + }, + { + name: "ALB ready, no public or private IP, should requeue", + ingresses: []*networkingv1.Ingress{ + {ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testIngressName}}, + }, + mockALBClient: func(m *mock_albclient.MockClient) { + m.EXPECT(). + GetLoadBalancer(gomock.Any(), testProjectID, testRegion, testALBName). + Return(&albsdk.LoadBalancer{ + Status: albsdk.LOADBALANCERSTATUS_READY.Ptr(), + }, nil) + }, + wantResult: reconcile.Result{RequeueAfter: 10 * time.Second}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + + mockAlbClient := mock_albclient.NewMockClient(ctrl) + fakeClient := fake.NewClientBuilder().WithScheme(scheme.Scheme).Build() + r := &IngressClassReconciler{ + Client: fakeClient, + ALBClient: mockAlbClient, + ProjectID: testProjectID, + Region: testRegion, + } + + if tt.mockK8sClient != nil { + if err := tt.mockK8sClient(fakeClient); err != nil { + t.Fatalf("mockK8sClient failed: %v", err) + } + } + + if tt.mockALBClient != nil { + tt.mockALBClient(mockAlbClient) + } + + got, err := r.updateStatus(context.Background(), tt.ingresses, testIngressClass) + if (err != nil) != tt.wantErr { + t.Fatalf("expected error %v, got %v", tt.wantErr, err) + } + if diff := cmp.Diff(tt.wantResult, got); diff != "" { + t.Fatalf("unexpected result (-want +got):\n%s", diff) + } + }) + } +} diff --git a/pkg/alb/ingress/suite_test.go b/pkg/alb/ingress/suite_test.go new file mode 100644 index 00000000..3a05f910 --- /dev/null +++ b/pkg/alb/ingress/suite_test.go @@ -0,0 +1,111 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package ingress_test + +import ( + "context" + "os" + "path/filepath" + "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" + // +kubebuilder:scaffold:imports +) + +// These tests use Ginkgo (BDD-style Go testing framework). Refer to +// http://onsi.github.io/ginkgo/ to learn more about Ginkgo. + +var ( + ctx context.Context + cancel context.CancelFunc + 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))) + + ctx, cancel = context.WithCancel(context.TODO()) + + var err error + // +kubebuilder:scaffold:scheme + + By("bootstrapping test environment") + testEnv = &envtest.Environment{ + CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")}, + ErrorIfCRDPathMissing: false, + } + + // Retrieve the first found binary directory to allow running tests from IDEs + if getFirstFoundEnvTestBinaryDir() != "" { + testEnv.BinaryAssetsDirectory = getFirstFoundEnvTestBinaryDir() + } + + // 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") + cancel() + err := testEnv.Stop() + Expect(err).NotTo(HaveOccurred()) +}) + +// getFirstFoundEnvTestBinaryDir locates the first binary in the specified path. +// ENVTEST-based tests depend on specific binaries, usually located in paths set by +// controller-runtime. When running tests directly (e.g., via an IDE) without using +// Makefile targets, the 'BinaryAssetsDirectory' must be explicitly configured. +// +// This function streamlines the process by finding the required binaries, similar to +// setting the 'KUBEBUILDER_ASSETS' environment variable. To ensure the binaries are +// properly set up, run 'make setup-envtest' beforehand. +func getFirstFoundEnvTestBinaryDir() string { + basePath := filepath.Join("..", "..", "bin", "k8s") + entries, err := os.ReadDir(basePath) + if err != nil { + logf.Log.Error(err, "Failed to read directory", "path", basePath) + return "" + } + for _, entry := range entries { + if entry.IsDir() { + return filepath.Join(basePath, entry.Name()) + } + } + return "" +} diff --git a/pkg/stackit/applicationloadbalancer.go b/pkg/stackit/applicationloadbalancer.go new file mode 100644 index 00000000..b1a09944 --- /dev/null +++ b/pkg/stackit/applicationloadbalancer.go @@ -0,0 +1,127 @@ +package stackit + +import ( + "context" + "errors" + "net/http" + + "github.com/google/uuid" + + oapiError "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + 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 Client 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 client struct { + client *albsdk.APIClient +} + +var _ Client = (*client)(nil) + +func NewClient(cl *albsdk.APIClient) (Client, error) { + return &client{client: cl}, nil +} + +func (cl client) GetLoadBalancer(ctx context.Context, projectID, region, name string) (*albsdk.LoadBalancer, error) { + lb, err := cl.client.GetLoadBalancerExecute(ctx, projectID, region, name) + if isOpenAPINotFound(err) { + return lb, ErrorNotFound + } + return lb, err +} + +// DeleteLoadBalancer returns no error if the load balancer doesn't exist. +func (cl client) DeleteLoadBalancer(ctx context.Context, projectID, region, name string) error { + _, err := cl.client.DeleteLoadBalancerExecute(ctx, projectID, region, name) + return err +} + +// CreateLoadBalancer returns ErrorNotFound if the project is not enabled. +func (cl client) CreateLoadBalancer(ctx context.Context, projectID, region string, create *albsdk.CreateLoadBalancerPayload) (*albsdk.LoadBalancer, error) { + lb, err := cl.client.CreateLoadBalancer(ctx, projectID, region).CreateLoadBalancerPayload(*create).XRequestID(uuid.NewString()).Execute() + if isOpenAPINotFound(err) { + return lb, ErrorNotFound + } + return lb, err +} + +func (cl client) UpdateLoadBalancer(ctx context.Context, projectID, region, name string, update *albsdk.UpdateLoadBalancerPayload) ( + *albsdk.LoadBalancer, error, +) { + return cl.client.UpdateLoadBalancer(ctx, projectID, region, name).UpdateLoadBalancerPayload(*update).Execute() +} + +func (cl client) UpdateTargetPool(ctx context.Context, projectID, region, name, targetPoolName string, payload albsdk.UpdateTargetPoolPayload) error { + _, err := cl.client.UpdateTargetPool(ctx, projectID, region, name, targetPoolName).UpdateTargetPoolPayload(payload).Execute() + return err +} + +func (cl client) CreateCredentials( + ctx context.Context, + projectID string, + region string, + payload albsdk.CreateCredentialsPayload, +) (*albsdk.CreateCredentialsResponse, error) { + return cl.client.CreateCredentials(ctx, projectID, region).CreateCredentialsPayload(payload).XRequestID(uuid.NewString()).Execute() +} + +func (cl client) ListCredentials(ctx context.Context, projectID, region string) (*albsdk.ListCredentialsResponse, error) { + return cl.client.ListCredentialsExecute(ctx, projectID, region) +} + +func (cl client) GetCredentials(ctx context.Context, projectID, region, credentialsRef string) (*albsdk.GetCredentialsResponse, error) { + return cl.client.GetCredentialsExecute(ctx, projectID, region, credentialsRef) +} + +func (cl client) UpdateCredentials(ctx context.Context, projectID, region, credentialsRef string, payload albsdk.UpdateCredentialsPayload) error { + _, err := cl.client.UpdateCredentials(ctx, projectID, region, credentialsRef).UpdateCredentialsPayload(payload).Execute() + if err != nil { + return err + } + return nil +} + +func (cl client) DeleteCredentials(ctx context.Context, projectID, region, credentialsRef string) error { + _, err := cl.client.DeleteCredentials(ctx, projectID, region, credentialsRef).Execute() + if err != nil { + return err + } + return nil +} + +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/applicationloadbalancercertificates.go b/pkg/stackit/applicationloadbalancercertificates.go new file mode 100644 index 00000000..0028d38f --- /dev/null +++ b/pkg/stackit/applicationloadbalancercertificates.go @@ -0,0 +1,65 @@ +package stackit + +import ( + "context" + "errors" + "net/http" + + oapiError "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + certsdk "github.com/stackitcloud/stackit-sdk-go/services/certificates/v2api" +) + +type CertificatesClient interface { + 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) (Client, error) { + return &certClient{client: cl}, nil +} + +func (cl certClient) GetCertificate(ctx context.Context, projectID, region, name string) (*certsdk.GetCertificateResponse, error) { + cert, err := cl.client.GetCertificateExecute(ctx, projectID, region, name) + if isOpenAPINotFound(err) { + return cert, ErrorNotFound + } + return cert, err +} + +func (cl certClient) DeleteCertificate(ctx context.Context, projectID, region, name string) error { + _, err := cl.client.DeleteCertificateExecute(ctx, projectID, region, name) + return err +} + +func (cl certClient) CreateCertificate(ctx context.Context, projectID, region string, certificate *certsdk.CreateCertificatePayload) (*certsdk.GetCertificateResponse, error) { + cert, err := cl.client.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.ListCertificates(ctx, projectID, region).Execute() + return certs, err +} + +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/samples/ingress/deployment.yaml b/samples/ingress/deployment.yaml new file mode 100644 index 00000000..6bb034fc --- /dev/null +++ b/samples/ingress/deployment.yaml @@ -0,0 +1,49 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: service-a + namespace: default +spec: + replicas: 1 + selector: + matchLabels: + app: service-a + template: + metadata: + labels: + app: service-a + spec: + containers: + - name: service-a + image: python:3 + command: + - "sh" + - "-c" + - "mkdir -p /data/service-a && echo '

This is service A!

' > /data/service-a/index.html && cd /data && python -m http.server 80" + ports: + - containerPort: 80 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: service-b + namespace: default +spec: + replicas: 1 + selector: + matchLabels: + app: service-b + template: + metadata: + labels: + app: service-b + spec: + containers: + - name: service-b + image: python:3 + command: + - "sh" + - "-c" + - "mkdir -p /data/service-b && echo '

This is service B!

' > /data/service-b/index.html && cd /data && python -m http.server 80" + ports: + - containerPort: 80 diff --git a/samples/ingress/ingress-class.yaml b/samples/ingress/ingress-class.yaml new file mode 100644 index 00000000..6a4d73ff --- /dev/null +++ b/samples/ingress/ingress-class.yaml @@ -0,0 +1,10 @@ +apiVersion: networking.k8s.io/v1 +kind: IngressClass +metadata: + annotations: + # alb.stackit.cloud/internal-alb: "false" + alb.stackit.cloud/external-address: "192.214.175.149" # Make sure to replace this with your external IP + name: sample-alb-ingress +spec: + controller: stackit.cloud/alb-ingress + diff --git a/samples/ingress/ingress.yaml b/samples/ingress/ingress.yaml new file mode 100644 index 00000000..aac285b0 --- /dev/null +++ b/samples/ingress/ingress.yaml @@ -0,0 +1,37 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: service-a + namespace: default +spec: + ingressClassName: sample-alb-ingress + rules: + - host: app.example.com + http: + paths: + - path: /service-a + pathType: Prefix + backend: + service: + name: service-a + port: + number: 80 +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: service-b + namespace: default +spec: + ingressClassName: sample-alb-ingress + rules: + - host: app.example.com + http: + paths: + - path: /service-b + pathType: Prefix + backend: + service: + name: service-b + port: + number: 80 diff --git a/samples/ingress/issuer.yaml b/samples/ingress/issuer.yaml new file mode 100644 index 00000000..79b903c6 --- /dev/null +++ b/samples/ingress/issuer.yaml @@ -0,0 +1,15 @@ +apiVersion: cert-manager.io/v1 +kind: ClusterIssuer +metadata: + name: letsencrypt +spec: + acme: + server: https://acme-v02.api.letsencrypt.org/directory + # server: https://acme-staging-v02.api.letsencrypt.org/directory + email: kamil.przybyl@stackit.cloud + privateKeySecretRef: + name: letsencrypt + solvers: + - http01: + ingress: + class: sample-ingress-class diff --git a/samples/ingress/service.yaml b/samples/ingress/service.yaml new file mode 100644 index 00000000..ceb74686 --- /dev/null +++ b/samples/ingress/service.yaml @@ -0,0 +1,33 @@ +apiVersion: v1 +kind: Service +metadata: + labels: + app: service-a + name: service-a + namespace: default +spec: + ports: + - port: 80 + protocol: TCP + targetPort: 80 + nodePort: 30000 + selector: + app: service-a + type: NodePort +--- +apiVersion: v1 +kind: Service +metadata: + labels: + app: service-b + name: service-b + namespace: default +spec: + ports: + - port: 80 + protocol: TCP + targetPort: 80 + nodePort: 30001 + selector: + app: service-b + type: NodePort diff --git a/samples/echo-deploy.yaml b/samples/service/echo-deploy.yaml similarity index 100% rename from samples/echo-deploy.yaml rename to samples/service/echo-deploy.yaml diff --git a/samples/echo-svc.yaml b/samples/service/echo-svc.yaml similarity index 100% rename from samples/echo-svc.yaml rename to samples/service/echo-svc.yaml diff --git a/samples/http-deploy.yaml b/samples/service/http-deploy.yaml similarity index 100% rename from samples/http-deploy.yaml rename to samples/service/http-deploy.yaml diff --git a/samples/http-svc.yaml b/samples/service/http-svc.yaml similarity index 100% rename from samples/http-svc.yaml rename to samples/service/http-svc.yaml From 86bdf1df74ef02a33d2fdfb0567aee9cc2857e49 Mon Sep 17 00:00:00 2001 From: Kamil Przybyl Date: Tue, 17 Mar 2026 10:35:34 +0100 Subject: [PATCH 02/23] chore: add alb ingress controller docs run-it-locally how-to --- docs/ingress-controller.md | 103 +++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 docs/ingress-controller.md diff --git a/docs/ingress-controller.md b/docs/ingress-controller.md new file mode 100644 index 00000000..2de655b2 --- /dev/null +++ b/docs/ingress-controller.md @@ -0,0 +1,103 @@ +### Run the ALB Ingress controller locally +To run the controller on your local machine, ensure you have a valid kubeconfig pointing to the target Kubernetes cluster where the ALB resources should be managed. + +##### Environment Variables +The controller requires specific configuration and credentials to interact with the STACKIT APIs and your network infrastructure. Set the following variables: + - STACKIT_SERVICE_ACCOUNT_TOKEN: Your authentication token for performing CRUD operations via the ALB and Certificates SDK. + - STACKIT_REGION: The STACKIT region where the infrastructure resides (e.g., eu01). + - PROJECT_ID: The unique identifier of your STACKIT project where the ALB will be provisioned. + - NETWORK_ID: The ID of the STACKIT network where the ALB will be provisioned. +``` +export STACKIT_SERVICE_ACCOUNT_TOKEN= +export STACKIT_REGION= +export PROJECT_ID= +export NETWORK_ID= +``` +Kubernetes Context +The controller uses the default Kubernetes client. Ensure your KUBECONFIG environment variable is set or your current context is correctly configured: +``` +export KUBECONFIG=~/.kube/config +``` +#### Run +Use the provided Makefile in the root of repository to start the controller: +``` +make run +``` + +### Create your deployment and expose it via Ingress +1. Create your k8s deployment, here’s an example of a simple http web server: +``` +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: httpbin-deployment + name: httpbin-deployment + namespace: default +spec: + replicas: 2 + selector: + matchLabels: + app: httpbin-deployment + template: + metadata: + labels: + app: httpbin-deployment + spec: + containers: + - image: kennethreitz/httpbin + name: httpbin + ports: + - containerPort: 80 +``` +2. Now, create a k8s service so that the traffic can be routed to the pods: +``` +apiVersion: v1 +kind: Service +metadata: + labels: + app: httpbin-deployment + name: httpbin + namespace: default +spec: + ports: + - port: 80 + protocol: TCP + targetPort: 80 + nodePort: 30000 + selector: + app: httpbin-deployment + type: NodePort +``` +>NOTE: The service has to be of type NodePort to enable access to the nodes from the outside of the cluster. +3. Create an IngressClass that specifies the ALB Ingress controller: +``` +apiVersion: networking.k8s.io/v1 +kind: IngressClass +metadata: + namespace: default + name: alb-01 +spec: + controller: stackit.cloud/alb-ingress +``` +4. Lastly, create an ingress resource that references the previously created IngressClass: +``` +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: alb-ingress + namespace: default +spec: + ingressClassName: alb-01 + rules: + - host: example.gg + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: httpbin + port: + number: 80 +``` From 9de21442a5931c29c7f6dcad2383ba4b1df75fce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Tue, 17 Mar 2026 10:47:02 +0100 Subject: [PATCH 03/23] Fix errors in stackit package --- pkg/stackit/applicationloadbalancer.go | 35 ++++++------------- .../applicationloadbalancercertificates.go | 25 +++---------- 2 files changed, 15 insertions(+), 45 deletions(-) diff --git a/pkg/stackit/applicationloadbalancer.go b/pkg/stackit/applicationloadbalancer.go index b1a09944..21847a61 100644 --- a/pkg/stackit/applicationloadbalancer.go +++ b/pkg/stackit/applicationloadbalancer.go @@ -2,12 +2,9 @@ package stackit import ( "context" - "errors" - "net/http" "github.com/google/uuid" - oapiError "github.com/stackitcloud/stackit-sdk-go/core/oapierror" albsdk "github.com/stackitcloud/stackit-sdk-go/services/alb/v2api" ) @@ -48,7 +45,7 @@ func NewClient(cl *albsdk.APIClient) (Client, error) { } func (cl client) GetLoadBalancer(ctx context.Context, projectID, region, name string) (*albsdk.LoadBalancer, error) { - lb, err := cl.client.GetLoadBalancerExecute(ctx, projectID, region, name) + lb, err := cl.client.DefaultAPI.GetLoadBalancer(ctx, projectID, region, name).Execute() if isOpenAPINotFound(err) { return lb, ErrorNotFound } @@ -57,13 +54,13 @@ func (cl client) GetLoadBalancer(ctx context.Context, projectID, region, name st // DeleteLoadBalancer returns no error if the load balancer doesn't exist. func (cl client) DeleteLoadBalancer(ctx context.Context, projectID, region, name string) error { - _, err := cl.client.DeleteLoadBalancerExecute(ctx, projectID, region, name) + _, err := cl.client.DefaultAPI.DeleteLoadBalancer(ctx, projectID, region, name).Execute() return err } // CreateLoadBalancer returns ErrorNotFound if the project is not enabled. func (cl client) CreateLoadBalancer(ctx context.Context, projectID, region string, create *albsdk.CreateLoadBalancerPayload) (*albsdk.LoadBalancer, error) { - lb, err := cl.client.CreateLoadBalancer(ctx, projectID, region).CreateLoadBalancerPayload(*create).XRequestID(uuid.NewString()).Execute() + lb, err := cl.client.DefaultAPI.CreateLoadBalancer(ctx, projectID, region).CreateLoadBalancerPayload(*create).XRequestID(uuid.NewString()).Execute() if isOpenAPINotFound(err) { return lb, ErrorNotFound } @@ -73,11 +70,11 @@ func (cl client) CreateLoadBalancer(ctx context.Context, projectID, region strin func (cl client) UpdateLoadBalancer(ctx context.Context, projectID, region, name string, update *albsdk.UpdateLoadBalancerPayload) ( *albsdk.LoadBalancer, error, ) { - return cl.client.UpdateLoadBalancer(ctx, projectID, region, name).UpdateLoadBalancerPayload(*update).Execute() + return cl.client.DefaultAPI.UpdateLoadBalancer(ctx, projectID, region, name).UpdateLoadBalancerPayload(*update).Execute() } func (cl client) UpdateTargetPool(ctx context.Context, projectID, region, name, targetPoolName string, payload albsdk.UpdateTargetPoolPayload) error { - _, err := cl.client.UpdateTargetPool(ctx, projectID, region, name, targetPoolName).UpdateTargetPoolPayload(payload).Execute() + _, err := cl.client.DefaultAPI.UpdateTargetPool(ctx, projectID, region, name, targetPoolName).UpdateTargetPoolPayload(payload).Execute() return err } @@ -87,19 +84,19 @@ func (cl client) CreateCredentials( region string, payload albsdk.CreateCredentialsPayload, ) (*albsdk.CreateCredentialsResponse, error) { - return cl.client.CreateCredentials(ctx, projectID, region).CreateCredentialsPayload(payload).XRequestID(uuid.NewString()).Execute() + return cl.client.DefaultAPI.CreateCredentials(ctx, projectID, region).CreateCredentialsPayload(payload).XRequestID(uuid.NewString()).Execute() } func (cl client) ListCredentials(ctx context.Context, projectID, region string) (*albsdk.ListCredentialsResponse, error) { - return cl.client.ListCredentialsExecute(ctx, projectID, region) + return cl.client.DefaultAPI.ListCredentials(ctx, projectID, region).Execute() } func (cl client) GetCredentials(ctx context.Context, projectID, region, credentialsRef string) (*albsdk.GetCredentialsResponse, error) { - return cl.client.GetCredentialsExecute(ctx, projectID, region, credentialsRef) + return cl.client.DefaultAPI.GetCredentials(ctx, projectID, region, credentialsRef).Execute() } func (cl client) UpdateCredentials(ctx context.Context, projectID, region, credentialsRef string, payload albsdk.UpdateCredentialsPayload) error { - _, err := cl.client.UpdateCredentials(ctx, projectID, region, credentialsRef).UpdateCredentialsPayload(payload).Execute() + _, err := cl.client.DefaultAPI.UpdateCredentials(ctx, projectID, region, credentialsRef).UpdateCredentialsPayload(payload).Execute() if err != nil { return err } @@ -107,21 +104,9 @@ func (cl client) UpdateCredentials(ctx context.Context, projectID, region, crede } func (cl client) DeleteCredentials(ctx context.Context, projectID, region, credentialsRef string) error { - _, err := cl.client.DeleteCredentials(ctx, projectID, region, credentialsRef).Execute() + _, err := cl.client.DefaultAPI.DeleteCredentials(ctx, projectID, region, credentialsRef).Execute() if err != nil { return err } return nil } - -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/applicationloadbalancercertificates.go b/pkg/stackit/applicationloadbalancercertificates.go index 0028d38f..86485ec4 100644 --- a/pkg/stackit/applicationloadbalancercertificates.go +++ b/pkg/stackit/applicationloadbalancercertificates.go @@ -2,10 +2,7 @@ package stackit import ( "context" - "errors" - "net/http" - oapiError "github.com/stackitcloud/stackit-sdk-go/core/oapierror" certsdk "github.com/stackitcloud/stackit-sdk-go/services/certificates/v2api" ) @@ -22,12 +19,12 @@ type certClient struct { var _ CertificatesClient = (*certClient)(nil) -func NewCertClient(cl *certsdk.APIClient) (Client, error) { +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.GetCertificateExecute(ctx, projectID, region, name) + cert, err := cl.client.DefaultAPI.GetCertificate(ctx, projectID, region, name).Execute() if isOpenAPINotFound(err) { return cert, ErrorNotFound } @@ -35,12 +32,12 @@ func (cl certClient) GetCertificate(ctx context.Context, projectID, region, name } func (cl certClient) DeleteCertificate(ctx context.Context, projectID, region, name string) error { - _, err := cl.client.DeleteCertificateExecute(ctx, projectID, region, name) + _, 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.CreateCertificate(ctx, projectID, region).CreateCertificatePayload(*certificate).Execute() + cert, err := cl.client.DefaultAPI.CreateCertificate(ctx, projectID, region).CreateCertificatePayload(*certificate).Execute() if isOpenAPINotFound(err) { return cert, ErrorNotFound } @@ -48,18 +45,6 @@ func (cl certClient) CreateCertificate(ctx context.Context, projectID, region st } func (cl certClient) ListCertificate(ctx context.Context, projectID, region string) (*certsdk.ListCertificatesResponse, error) { - certs, err := cl.client.ListCertificates(ctx, projectID, region).Execute() + certs, err := cl.client.DefaultAPI.ListCertificates(ctx, projectID, region).Execute() return certs, err } - -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) -} From 77b29bfde84451a3a1dce94b0e023fd4aa2308ee Mon Sep 17 00:00:00 2001 From: Kamil Przybyl Date: Tue, 17 Mar 2026 10:52:42 +0100 Subject: [PATCH 04/23] chore: add new Makefile build for alb ingress controller manager --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index a3a4286b..13fc7f54 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ # Options are set to exit when a recipe line exits non-zero or a piped command fails. SHELL = /usr/bin/env bash -o pipefail .SHELLFLAGS = -ec -BUILD_IMAGES ?= stackit-csi-plugin cloud-controller-manager +BUILD_IMAGES ?= stackit-csi-plugin cloud-controller-manager application-load-balancer-controller-manager SOURCES := Makefile go.mod go.sum $(shell find $(DEST) -name '*.go' 2>/dev/null) VERSION ?= $(shell git describe --dirty --tags --match='v*' 2>/dev/null || git rev-parse --short HEAD) REGISTRY ?= ghcr.io From fc6f95fae9b072cfc5f71fe9b898170a3f108a39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Tue, 17 Mar 2026 11:04:20 +0100 Subject: [PATCH 05/23] Fix errors in ingress package (only non-test files) --- .../main.go | 2 +- pkg/alb/ingress/alb_spec.go | 30 ++++++------ pkg/alb/ingress/ingressclass_controller.go | 46 +++++++++---------- pkg/stackit/applicationloadbalancer.go | 30 ++++++------ 4 files changed, 54 insertions(+), 54 deletions(-) diff --git a/cmd/application-load-balancer-controller-manager/main.go b/cmd/application-load-balancer-controller-manager/main.go index 128aea99..5d2cd4b7 100644 --- a/cmd/application-load-balancer-controller-manager/main.go +++ b/cmd/application-load-balancer-controller-manager/main.go @@ -250,7 +250,7 @@ func main() { } // Create an ALB client fmt.Printf("Create ALB client\n") - albClient, err := albclient.NewClient(sdkClient) + albClient, err := albclient.NewApplicationLoadBalancerClient(sdkClient) if err != nil { setupLog.Error(err, "unable to create ALB client", "controller", "IngressClass") os.Exit(1) diff --git a/pkg/alb/ingress/alb_spec.go b/pkg/alb/ingress/alb_spec.go index 7a0b74d7..d97c5450 100644 --- a/pkg/alb/ingress/alb_spec.go +++ b/pkg/alb/ingress/alb_spec.go @@ -79,10 +79,10 @@ func (r *IngressClassReconciler) albSpecFromIngress( alb := &albsdk.CreateLoadBalancerPayload{ Options: &albsdk.LoadBalancerOptions{}, - Networks: &[]albsdk.Network{ + Networks: []albsdk.Network{ { NetworkId: networkID, - Role: albsdk.NETWORKROLE_LISTENERS_AND_TARGETS.Ptr(), + Role: ptr.To("ROLE_LISTENERS_AND_TARGETS"), }, }, } @@ -207,7 +207,7 @@ func (r *IngressClassReconciler) albSpecFromIngress( rulesCopy := hostToRules[host] httpHosts = append(httpHosts, albsdk.HostConfig{ Host: ptr.To(host), - Rules: &rulesCopy, + Rules: rulesCopy, }) } @@ -217,24 +217,24 @@ func (r *IngressClassReconciler) albSpecFromIngress( listeners := []albsdk.Listener{ { Name: ptr.To("http"), - Port: ptr.To(int64(80)), - Protocol: albsdk.LISTENERPROTOCOL_HTTP.Ptr(), + Port: ptr.To(int32(80)), + Protocol: ptr.To("PROTOCOL_HTTP"), Http: &albsdk.ProtocolOptionsHTTP{ - Hosts: &httpHosts, + Hosts: httpHosts, }, }, } if len(allCertificateIDs) > 0 { listeners = append(listeners, albsdk.Listener{ Name: ptr.To("https"), - Port: ptr.To(int64(443)), - Protocol: albsdk.LISTENERPROTOCOL_HTTPS.Ptr(), + Port: ptr.To(int32(443)), + Protocol: ptr.To("PROTOCOL_HTTPS"), Http: &albsdk.ProtocolOptionsHTTP{ - Hosts: &httpHosts, + Hosts: httpHosts, }, Https: &albsdk.ProtocolOptionsHTTPS{ CertificateConfig: &albsdk.CertificateConfig{ - CertificateIds: &allCertificateIDs, + CertificateIds: allCertificateIDs, }, }, }) @@ -247,8 +247,8 @@ func (r *IngressClassReconciler) albSpecFromIngress( } alb.Name = ptr.To(getAlbName(ingressClass)) - alb.Listeners = &listeners - alb.TargetPools = &targetPools + alb.Listeners = listeners + alb.TargetPools = targetPools return alb, nil } @@ -328,7 +328,7 @@ func (r *IngressClassReconciler) cleanupCerts(ctx context.Context, ingressClass if certificatesList == nil || certificatesList.Items == nil { return nil // No certificates to clean up } - for _, cert := range *certificatesList.Items { + for _, cert := range certificatesList.Items { certID := *cert.Id certName := *cert.Name @@ -406,9 +406,9 @@ func addTargetPool( } *targetPools = append(*targetPools, albsdk.TargetPool{ Name: ptr.To(targetPoolName), - TargetPort: ptr.To(int64(nodePort)), + TargetPort: ptr.To(int32(nodePort)), TlsConfig: tlsConfig, - Targets: &targets, + Targets: targets, }) } diff --git a/pkg/alb/ingress/ingressclass_controller.go b/pkg/alb/ingress/ingressclass_controller.go index dddec288..c6ecbee8 100644 --- a/pkg/alb/ingress/ingressclass_controller.go +++ b/pkg/alb/ingress/ingressclass_controller.go @@ -34,7 +34,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/handler" - albclient "github.com/stackitcloud/cloud-provider-stackit/pkg/stackit" + "github.com/stackitcloud/cloud-provider-stackit/pkg/stackit" albsdk "github.com/stackitcloud/stackit-sdk-go/services/alb/v2api" ) @@ -48,8 +48,8 @@ const ( // IngressClassReconciler reconciles a IngressClass object type IngressClassReconciler struct { client.Client - ALBClient albclient.Client - CertificateClient certificateclient.Client + ALBClient stackit.ApplicationLoadBalancerClient + CertificateClient stackit.CertificatesClient Scheme *runtime.Scheme ProjectID string NetworkID string @@ -190,7 +190,7 @@ func (r *IngressClassReconciler) handleIngressClassWithIngresses( // Create ALB if it doesn't exist alb, err := r.ALBClient.GetLoadBalancer(ctx, r.ProjectID, r.Region, getAlbName(ingressClass)) - if errors.Is(err, albclient.ErrorNotFound) { + if errors.Is(err, stackit.ErrorNotFound) { _, err := r.ALBClient.CreateLoadBalancer(ctx, r.ProjectID, r.Region, albPayload) if err != nil { return ctrl.Result{}, fmt.Errorf("failed to create load balancer: %w", err) @@ -232,7 +232,7 @@ func (r *IngressClassReconciler) updateStatus(ctx context.Context, ingresses []* return ctrl.Result{}, fmt.Errorf("failed to get load balancer: %w", err) } - if *alb.Status != albclient.LBStatusReady { + if *alb.Status != stackit.LBStatusReady { // ALB is not yet ready, requeue return ctrl.Result{RequeueAfter: 10 * time.Second}, nil } @@ -324,13 +324,13 @@ func (r *IngressClassReconciler) handleIngressClassDeletion( // detectChange checks if there is any difference between the current and desired ALB configuration. func detectChange(alb *albsdk.LoadBalancer, albPayload *albsdk.CreateLoadBalancerPayload) bool { - if len(*alb.Listeners) != len(*albPayload.Listeners) { + if len(alb.Listeners) != len(albPayload.Listeners) { return true } - for i := range *alb.Listeners { - albListener := (*alb.Listeners)[i] - payloadListener := (*albPayload.Listeners)[i] + for i := range alb.Listeners { + albListener := (alb.Listeners)[i] + payloadListener := (albPayload.Listeners)[i] if ptr.Deref(albListener.Protocol, "") != ptr.Deref(payloadListener.Protocol, "") || ptr.Deref(albListener.Port, 0) != ptr.Deref(payloadListener.Port, 0) { @@ -342,25 +342,25 @@ func detectChange(alb *albsdk.LoadBalancer, albPayload *albsdk.CreateLoadBalance albHosts := albListener.Http.Hosts payloadHosts := payloadListener.Http.Hosts - if len(ptr.Deref(albHosts, nil)) != len(ptr.Deref(payloadHosts, nil)) { + if len(albHosts) != len(payloadHosts) { return true } - for j := range *albHosts { - albHost := (*albHosts)[j] - payloadHost := (*payloadHosts)[j] + for j := range albHosts { + albHost := albHosts[j] + payloadHost := payloadHosts[j] if ptr.Deref(albHost.Host, "") != ptr.Deref(payloadHost.Host, "") { return true } - if len(ptr.Deref(albHost.Rules, nil)) != len(ptr.Deref(payloadHost.Rules, nil)) { + if len(albHost.Rules) != len(payloadHost.Rules) { return true } - for k := range *albHost.Rules { - albRule := (*albHost.Rules)[k] - payloadRule := (*payloadHost.Rules)[k] + for k := range albHost.Rules { + albRule := albHost.Rules[k] + payloadRule := payloadHost.Rules[k] if albRule.Path != nil || payloadRule.Path != nil { if albRule.Path == nil || payloadRule.Path == nil { @@ -384,7 +384,7 @@ func detectChange(alb *albsdk.LoadBalancer, albPayload *albsdk.CreateLoadBalance if albListener.Https != nil && payloadListener.Https != nil { a := albListener.Https.CertificateConfig b := payloadListener.Https.CertificateConfig - if len(ptr.Deref(a.CertificateIds, nil)) != len(ptr.Deref(b.CertificateIds, nil)) { + if len(a.CertificateIds) != len(b.CertificateIds) { return true } } else if albListener.Https != nil || payloadListener.Https != nil { @@ -394,19 +394,19 @@ func detectChange(alb *albsdk.LoadBalancer, albPayload *albsdk.CreateLoadBalance } // TargetPools comparison - if len(*alb.TargetPools) != len(*albPayload.TargetPools) { + if len(alb.TargetPools) != len(albPayload.TargetPools) { return true } - for i := range *alb.TargetPools { - a := (*alb.TargetPools)[i] - b := (*albPayload.TargetPools)[i] + for i := range alb.TargetPools { + a := alb.TargetPools[i] + b := albPayload.TargetPools[i] if ptr.Deref(a.Name, "") != ptr.Deref(b.Name, "") || ptr.Deref(a.TargetPort, 0) != ptr.Deref(b.TargetPort, 0) { return true } - if len(ptr.Deref(a.Targets, nil)) != len(ptr.Deref(b.Targets, nil)) { + if len(a.Targets) != len(b.Targets) { return true } diff --git a/pkg/stackit/applicationloadbalancer.go b/pkg/stackit/applicationloadbalancer.go index 21847a61..3e10f50a 100644 --- a/pkg/stackit/applicationloadbalancer.go +++ b/pkg/stackit/applicationloadbalancer.go @@ -21,7 +21,7 @@ const ( ProjectStatusDisabled ProjectStatus = "STATUS_DISABLED" ) -type Client interface { +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) @@ -34,17 +34,17 @@ type Client interface { DeleteCredentials(ctx context.Context, projectID, region, credentialRef string) error } -type client struct { +type applicationLoadBalancerClient struct { client *albsdk.APIClient } -var _ Client = (*client)(nil) +var _ ApplicationLoadBalancerClient = (*applicationLoadBalancerClient)(nil) -func NewClient(cl *albsdk.APIClient) (Client, error) { - return &client{client: cl}, nil +func NewApplicationLoadBalancerClient(cl *albsdk.APIClient) (ApplicationLoadBalancerClient, error) { + return &applicationLoadBalancerClient{client: cl}, nil } -func (cl client) GetLoadBalancer(ctx context.Context, projectID, region, name string) (*albsdk.LoadBalancer, error) { +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 @@ -53,13 +53,13 @@ func (cl client) GetLoadBalancer(ctx context.Context, projectID, region, name st } // DeleteLoadBalancer returns no error if the load balancer doesn't exist. -func (cl client) DeleteLoadBalancer(ctx context.Context, projectID, region, name string) error { +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 client) 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 @@ -67,18 +67,18 @@ func (cl client) CreateLoadBalancer(ctx context.Context, projectID, region strin return lb, err } -func (cl client) UpdateLoadBalancer(ctx context.Context, projectID, region, name string, update *albsdk.UpdateLoadBalancerPayload) ( +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 client) 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 } -func (cl client) CreateCredentials( +func (cl applicationLoadBalancerClient) CreateCredentials( ctx context.Context, projectID string, region string, @@ -87,15 +87,15 @@ func (cl client) CreateCredentials( return cl.client.DefaultAPI.CreateCredentials(ctx, projectID, region).CreateCredentialsPayload(payload).XRequestID(uuid.NewString()).Execute() } -func (cl client) ListCredentials(ctx context.Context, projectID, region string) (*albsdk.ListCredentialsResponse, error) { +func (cl applicationLoadBalancerClient) ListCredentials(ctx context.Context, projectID, region string) (*albsdk.ListCredentialsResponse, error) { return cl.client.DefaultAPI.ListCredentials(ctx, projectID, region).Execute() } -func (cl client) GetCredentials(ctx context.Context, projectID, region, credentialsRef string) (*albsdk.GetCredentialsResponse, error) { +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 client) 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 @@ -103,7 +103,7 @@ func (cl client) UpdateCredentials(ctx context.Context, projectID, region, crede return nil } -func (cl client) DeleteCredentials(ctx context.Context, projectID, region, credentialsRef string) error { +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 From 088ee7881f9961df6e261dad7bfd90b8a34d8200 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Tue, 17 Mar 2026 11:17:25 +0100 Subject: [PATCH 06/23] Add mocks for ALB and certificates API --- pkg/stackit/applicationloadbalancer_mock.go | 188 ++++++++++++++++++ ...pplicationloadbalancercertificates_mock.go | 101 ++++++++++ 2 files changed, 289 insertions(+) create mode 100644 pkg/stackit/applicationloadbalancer_mock.go create mode 100644 pkg/stackit/applicationloadbalancercertificates_mock.go diff --git a/pkg/stackit/applicationloadbalancer_mock.go b/pkg/stackit/applicationloadbalancer_mock.go new file mode 100644 index 00000000..c77b237c --- /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_mock.go b/pkg/stackit/applicationloadbalancercertificates_mock.go new file mode 100644 index 00000000..a9a4e6b0 --- /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) +} From 714d80dce91f438497422ccd31b235efdf59c8fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Tue, 17 Mar 2026 11:39:34 +0100 Subject: [PATCH 07/23] Fix syntax errors in test in ingress package --- pkg/alb/ingress/alb_spec_test.go | 78 +++++++++---------- pkg/alb/ingress/controller_test.go | 13 ++-- .../ingress/ingressclass_controller_test.go | 38 ++++----- 3 files changed, 65 insertions(+), 64 deletions(-) diff --git a/pkg/alb/ingress/alb_spec_test.go b/pkg/alb/ingress/alb_spec_test.go index cd873229..0a215a42 100644 --- a/pkg/alb/ingress/alb_spec_test.go +++ b/pkg/alb/ingress/alb_spec_test.go @@ -154,15 +154,15 @@ func fixtureIngressClass(mods ...func(*networkingv1.IngressClass)) *networkingv1 func fixtureAlbPayload(mods ...func(*albsdk.CreateLoadBalancerPayload)) *albsdk.CreateLoadBalancerPayload { payload := &albsdk.CreateLoadBalancerPayload{ Name: ptr.To("k8s-ingress-" + testIngressClassName), - Listeners: &[]albsdk.Listener{ + Listeners: []albsdk.Listener{ { - Port: ptr.To(int64(80)), - Protocol: albsdk.LISTENERPROTOCOL_HTTP.Ptr(), + Port: ptr.To(int32(80)), + Protocol: ptr.To("PROTOCOL_HTTP"), Http: &albsdk.ProtocolOptionsHTTP{ - Hosts: &[]albsdk.HostConfig{ + Hosts: []albsdk.HostConfig{ { Host: ptr.To(testHost), - Rules: &[]albsdk.Rule{ + Rules: []albsdk.Rule{ { Path: &albsdk.Path{ Prefix: ptr.To(testPath), @@ -175,10 +175,10 @@ func fixtureAlbPayload(mods ...func(*albsdk.CreateLoadBalancerPayload)) *albsdk. }, }, }, - Networks: &[]albsdk.Network{{NetworkId: ptr.To(testNetworkID), Role: albsdk.NETWORKROLE_LISTENERS_AND_TARGETS.Ptr()}}, + Networks: []albsdk.Network{{NetworkId: ptr.To(testNetworkID), Role: ptr.To("ROLE_LISTENERS_AND_TARGETS")}}, Options: &albsdk.LoadBalancerOptions{EphemeralAddress: ptr.To(true)}, - TargetPools: &[]albsdk.TargetPool{ - {Name: ptr.To("pool-30080"), TargetPort: ptr.To(int64(30080)), Targets: &[]albsdk.Target{{DisplayName: ptr.To(testNodeName), Ip: ptr.To(testNodeIP)}}}, + TargetPools: []albsdk.TargetPool{ + {Name: ptr.To("pool-30080"), TargetPort: ptr.To(int32(30080)), Targets: []albsdk.Target{{DisplayName: ptr.To(testNodeName), Ip: ptr.To(testNodeIP)}}}, }, } for _, mod := range mods { @@ -247,23 +247,23 @@ func Test_albSpecFromIngress(t *testing.T) { "svc2": *fixtureServiceWithParams(testServicePort, 30002), }, want: fixtureAlbPayload(func(p *albsdk.CreateLoadBalancerPayload) { - (*p.Listeners)[0].Http.Hosts = &[]albsdk.HostConfig{ + p.Listeners[0].Http.Hosts = []albsdk.HostConfig{ { Host: ptr.To("a-host.com"), - Rules: &[]albsdk.Rule{ + Rules: []albsdk.Rule{ {Path: &albsdk.Path{Prefix: ptr.To("/a")}, TargetPool: ptr.To("pool-30002")}, }, }, { Host: ptr.To("z-host.com"), - Rules: &[]albsdk.Rule{ + Rules: []albsdk.Rule{ {Path: &albsdk.Path{Prefix: ptr.To("/a")}, TargetPool: ptr.To("pool-30001")}, }, }, } - p.TargetPools = &[]albsdk.TargetPool{ - {Name: ptr.To("pool-30001"), TargetPort: ptr.To(int64(30001)), Targets: &[]albsdk.Target{{DisplayName: ptr.To(testNodeName), Ip: ptr.To(testNodeIP)}}}, - {Name: ptr.To("pool-30002"), TargetPort: ptr.To(int64(30002)), Targets: &[]albsdk.Target{{DisplayName: ptr.To(testNodeName), Ip: ptr.To(testNodeIP)}}}, + p.TargetPools = []albsdk.TargetPool{ + {Name: ptr.To("pool-30001"), TargetPort: ptr.To(int32(30001)), Targets: []albsdk.Target{{DisplayName: ptr.To(testNodeName), Ip: ptr.To(testNodeIP)}}}, + {Name: ptr.To("pool-30002"), TargetPort: ptr.To(int32(30002)), Targets: []albsdk.Target{{DisplayName: ptr.To(testNodeName), Ip: ptr.To(testNodeIP)}}}, } }), }, @@ -283,14 +283,14 @@ func Test_albSpecFromIngress(t *testing.T) { "svc2": *fixtureServiceWithParams(testServicePort, 30004), }, want: fixtureAlbPayload(func(p *albsdk.CreateLoadBalancerPayload) { - (*(*p.Listeners)[0].Http.Hosts)[0].Host = ptr.To("host.com") - (*(*p.Listeners)[0].Http.Hosts)[0].Rules = &[]albsdk.Rule{ + p.Listeners[0].Http.Hosts[0].Host = ptr.To("host.com") + p.Listeners[0].Http.Hosts[0].Rules = []albsdk.Rule{ {Path: &albsdk.Path{Prefix: ptr.To("/x")}, TargetPool: ptr.To("pool-30004")}, {Path: &albsdk.Path{Prefix: ptr.To("/x")}, TargetPool: ptr.To("pool-30003")}, } - p.TargetPools = &[]albsdk.TargetPool{ - {Name: ptr.To("pool-30003"), TargetPort: ptr.To(int64(30003)), Targets: &[]albsdk.Target{{DisplayName: ptr.To(testNodeName), Ip: ptr.To(testNodeIP)}}}, - {Name: ptr.To("pool-30004"), TargetPort: ptr.To(int64(30004)), Targets: &[]albsdk.Target{{DisplayName: ptr.To(testNodeName), Ip: ptr.To(testNodeIP)}}}, + p.TargetPools = []albsdk.TargetPool{ + {Name: ptr.To("pool-30003"), TargetPort: ptr.To(int32(30003)), Targets: []albsdk.Target{{DisplayName: ptr.To(testNodeName), Ip: ptr.To(testNodeIP)}}}, + {Name: ptr.To("pool-30004"), TargetPort: ptr.To(int32(30004)), Targets: []albsdk.Target{{DisplayName: ptr.To(testNodeName), Ip: ptr.To(testNodeIP)}}}, } }), }, @@ -310,14 +310,14 @@ func Test_albSpecFromIngress(t *testing.T) { "svc2": *fixtureServiceWithParams(testServicePort, 30006), }, want: fixtureAlbPayload(func(p *albsdk.CreateLoadBalancerPayload) { - (*(*p.Listeners)[0].Http.Hosts)[0].Host = ptr.To("host.com") - (*(*p.Listeners)[0].Http.Hosts)[0].Rules = &[]albsdk.Rule{ + p.Listeners[0].Http.Hosts[0].Host = ptr.To("host.com") + p.Listeners[0].Http.Hosts[0].Rules = []albsdk.Rule{ {Path: &albsdk.Path{Prefix: ptr.To("/very/very/long/specific")}, TargetPool: ptr.To("pool-30006")}, {Path: &albsdk.Path{Prefix: ptr.To("/short")}, TargetPool: ptr.To("pool-30005")}, } - p.TargetPools = &[]albsdk.TargetPool{ - {Name: ptr.To("pool-30005"), TargetPort: ptr.To(int64(30005)), Targets: &[]albsdk.Target{{DisplayName: ptr.To(testNodeName), Ip: ptr.To(testNodeIP)}}}, - {Name: ptr.To("pool-30006"), TargetPort: ptr.To(int64(30006)), Targets: &[]albsdk.Target{{DisplayName: ptr.To(testNodeName), Ip: ptr.To(testNodeIP)}}}, + p.TargetPools = []albsdk.TargetPool{ + {Name: ptr.To("pool-30005"), TargetPort: ptr.To(int32(30005)), Targets: []albsdk.Target{{DisplayName: ptr.To(testNodeName), Ip: ptr.To(testNodeIP)}}}, + {Name: ptr.To("pool-30006"), TargetPort: ptr.To(int32(30006)), Targets: []albsdk.Target{{DisplayName: ptr.To(testNodeName), Ip: ptr.To(testNodeIP)}}}, } }), }, @@ -337,14 +337,14 @@ func Test_albSpecFromIngress(t *testing.T) { "svc-prefix": *fixtureServiceWithParams(testServicePort, 30101), }, want: fixtureAlbPayload(func(p *albsdk.CreateLoadBalancerPayload) { - (*(*p.Listeners)[0].Http.Hosts)[0].Host = ptr.To("host.com") - (*(*p.Listeners)[0].Http.Hosts)[0].Rules = &[]albsdk.Rule{ + p.Listeners[0].Http.Hosts[0].Host = ptr.To("host.com") + p.Listeners[0].Http.Hosts[0].Rules = []albsdk.Rule{ {Path: &albsdk.Path{ExactMatch: ptr.To("/same")}, TargetPool: ptr.To("pool-30100")}, {Path: &albsdk.Path{Prefix: ptr.To("/same")}, TargetPool: ptr.To("pool-30101")}, } - p.TargetPools = &[]albsdk.TargetPool{ - {Name: ptr.To("pool-30100"), TargetPort: ptr.To(int64(30100)), Targets: &[]albsdk.Target{{DisplayName: ptr.To(testNodeName), Ip: ptr.To(testNodeIP)}}}, - {Name: ptr.To("pool-30101"), TargetPort: ptr.To(int64(30101)), Targets: &[]albsdk.Target{{DisplayName: ptr.To(testNodeName), Ip: ptr.To(testNodeIP)}}}, + p.TargetPools = []albsdk.TargetPool{ + {Name: ptr.To("pool-30100"), TargetPort: ptr.To(int32(30100)), Targets: []albsdk.Target{{DisplayName: ptr.To(testNodeName), Ip: ptr.To(testNodeIP)}}}, + {Name: ptr.To("pool-30101"), TargetPort: ptr.To(int32(30101)), Targets: []albsdk.Target{{DisplayName: ptr.To(testNodeName), Ip: ptr.To(testNodeIP)}}}, } }), }, @@ -364,14 +364,14 @@ func Test_albSpecFromIngress(t *testing.T) { "svc2": *fixtureServiceWithParams(testServicePort, 30008), }, want: fixtureAlbPayload(func(p *albsdk.CreateLoadBalancerPayload) { - (*(*p.Listeners)[0].Http.Hosts)[0].Host = ptr.To("host.com") - (*(*p.Listeners)[0].Http.Hosts)[0].Rules = &[]albsdk.Rule{ + p.Listeners[0].Http.Hosts[0].Host = ptr.To("host.com") + p.Listeners[0].Http.Hosts[0].Rules = []albsdk.Rule{ {Path: &albsdk.Path{Prefix: ptr.To("/x")}, TargetPool: ptr.To("pool-30008")}, {Path: &albsdk.Path{Prefix: ptr.To("/x")}, TargetPool: ptr.To("pool-30007")}, } - p.TargetPools = &[]albsdk.TargetPool{ - {Name: ptr.To("pool-30007"), TargetPort: ptr.To(int64(30007)), Targets: &[]albsdk.Target{{DisplayName: ptr.To(testNodeName), Ip: ptr.To(testNodeIP)}}}, - {Name: ptr.To("pool-30008"), TargetPort: ptr.To(int64(30008)), Targets: &[]albsdk.Target{{DisplayName: ptr.To(testNodeName), Ip: ptr.To(testNodeIP)}}}, + p.TargetPools = []albsdk.TargetPool{ + {Name: ptr.To("pool-30007"), TargetPort: ptr.To(int32(30007)), Targets: []albsdk.Target{{DisplayName: ptr.To(testNodeName), Ip: ptr.To(testNodeIP)}}}, + {Name: ptr.To("pool-30008"), TargetPort: ptr.To(int32(30008)), Targets: []albsdk.Target{{DisplayName: ptr.To(testNodeName), Ip: ptr.To(testNodeIP)}}}, } }), }, @@ -391,14 +391,14 @@ func Test_albSpecFromIngress(t *testing.T) { "svc2": *fixtureServiceWithParams(testServicePort, 30010), }, want: fixtureAlbPayload(func(p *albsdk.CreateLoadBalancerPayload) { - (*(*p.Listeners)[0].Http.Hosts)[0].Host = ptr.To("host.com") - (*(*p.Listeners)[0].Http.Hosts)[0].Rules = &[]albsdk.Rule{ + p.Listeners[0].Http.Hosts[0].Host = ptr.To("host.com") + p.Listeners[0].Http.Hosts[0].Rules = []albsdk.Rule{ {Path: &albsdk.Path{Prefix: ptr.To("/x")}, TargetPool: ptr.To("pool-30010")}, {Path: &albsdk.Path{Prefix: ptr.To("/x")}, TargetPool: ptr.To("pool-30009")}, } - p.TargetPools = &[]albsdk.TargetPool{ - {Name: ptr.To("pool-30009"), TargetPort: ptr.To(int64(30009)), Targets: &[]albsdk.Target{{DisplayName: ptr.To(testNodeName), Ip: ptr.To(testNodeIP)}}}, - {Name: ptr.To("pool-30010"), TargetPort: ptr.To(int64(30010)), Targets: &[]albsdk.Target{{DisplayName: ptr.To(testNodeName), Ip: ptr.To(testNodeIP)}}}, + p.TargetPools = []albsdk.TargetPool{ + {Name: ptr.To("pool-30009"), TargetPort: ptr.To(int32(30009)), Targets: []albsdk.Target{{DisplayName: ptr.To(testNodeName), Ip: ptr.To(testNodeIP)}}}, + {Name: ptr.To("pool-30010"), TargetPort: ptr.To(int32(30010)), Targets: []albsdk.Target{{DisplayName: ptr.To(testNodeName), Ip: ptr.To(testNodeIP)}}}, } }), }, diff --git a/pkg/alb/ingress/controller_test.go b/pkg/alb/ingress/controller_test.go index feb81865..be18be83 100644 --- a/pkg/alb/ingress/controller_test.go +++ b/pkg/alb/ingress/controller_test.go @@ -18,7 +18,8 @@ import ( "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/metrics/server" - albclient "github.com/stackitcloud/cloud-provider-stackit/pkg/stackit" + "github.com/stackitcloud/cloud-provider-stackit/pkg/alb/ingress" + "github.com/stackitcloud/cloud-provider-stackit/pkg/stackit" "go.uber.org/mock/gomock" ) @@ -34,8 +35,8 @@ var _ = Describe("IngressClassReconciler", func() { k8sClient client.Client namespace *corev1.Namespace mockCtrl *gomock.Controller - albClient *albclient.MockClient - certClient *certificateclient.MockClient + albClient *stackit.MockApplicationLoadBalancerClient + certClient *stackit.MockCertificatesClient ctx context.Context cancel context.CancelFunc ) @@ -45,8 +46,8 @@ var _ = Describe("IngressClassReconciler", func() { DeferCleanup(cancel) mockCtrl = gomock.NewController(GinkgoT()) - albClient = albclient.NewMockClient(mockCtrl) - certClient = certificateclient.NewMockClient(mockCtrl) + albClient = stackit.NewMockApplicationLoadBalancerClient(mockCtrl) + certClient = stackit.NewMockCertificatesClient(mockCtrl) var err error k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) @@ -73,7 +74,7 @@ var _ = Describe("IngressClassReconciler", func() { }) Expect(err).NotTo(HaveOccurred()) - reconciler := &controller.IngressClassReconciler{ + reconciler := &ingress.IngressClassReconciler{ Client: mgr.GetClient(), Scheme: scheme.Scheme, ALBClient: albClient, diff --git a/pkg/alb/ingress/ingressclass_controller_test.go b/pkg/alb/ingress/ingressclass_controller_test.go index 68e46c9d..610f2a01 100644 --- a/pkg/alb/ingress/ingressclass_controller_test.go +++ b/pkg/alb/ingress/ingressclass_controller_test.go @@ -15,7 +15,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/reconcile" - albclient "github.com/stackitcloud/cloud-provider-stackit/pkg/stackit" + "github.com/stackitcloud/cloud-provider-stackit/pkg/stackit" albsdk "github.com/stackitcloud/stackit-sdk-go/services/alb/v2api" ) @@ -39,7 +39,7 @@ func TestIngressClassReconciler_updateStatus(t *testing.T) { name string ingresses []*networkingv1.Ingress mockK8sClient func(client.Client) error - mockALBClient func(*mock_albclient.MockClient) + mockALBClient func(*stackit.MockApplicationLoadBalancerClient) wantResult reconcile.Result wantErr bool }{ @@ -50,11 +50,11 @@ func TestIngressClassReconciler_updateStatus(t *testing.T) { ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testIngressName}, }) }, - mockALBClient: func(m *mock_albclient.MockClient) { + mockALBClient: func(m *stackit.MockApplicationLoadBalancerClient) { m.EXPECT(). GetLoadBalancer(gomock.Any(), testProjectID, testRegion, testALBName). Return(&albsdk.LoadBalancer{ - Status: albsdk.LOADBALANCERSTATUS_TERMINATING.Ptr(), + Status: ptr.To("STATUS_TERMINATING"), }, nil) }, wantResult: reconcile.Result{RequeueAfter: 10 * time.Second}, @@ -72,11 +72,11 @@ func TestIngressClassReconciler_updateStatus(t *testing.T) { ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testIngressName}, }) }, - mockALBClient: func(m *mock_albclient.MockClient) { + mockALBClient: func(m *stackit.MockApplicationLoadBalancerClient) { m.EXPECT(). GetLoadBalancer(gomock.Any(), testProjectID, testRegion, testALBName). Return(&albsdk.LoadBalancer{ - Status: albsdk.LOADBALANCERSTATUS_READY.Ptr(), + Status: ptr.To("STATUS_READY"), ExternalAddress: ptr.To(testPublicIP), }, nil) }, @@ -95,11 +95,11 @@ func TestIngressClassReconciler_updateStatus(t *testing.T) { ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testIngressName}, }) }, - mockALBClient: func(m *mock_albclient.MockClient) { + mockALBClient: func(m *stackit.MockApplicationLoadBalancerClient) { m.EXPECT(). GetLoadBalancer(gomock.Any(), testProjectID, testRegion, testALBName). Return(&albsdk.LoadBalancer{ - Status: albsdk.LOADBALANCERSTATUS_READY.Ptr(), + Status: ptr.To("STATUS_READY"), PrivateAddress: ptr.To(testPrivateIP), }, nil) }, @@ -130,11 +130,11 @@ func TestIngressClassReconciler_updateStatus(t *testing.T) { }, }) }, - mockALBClient: func(m *mock_albclient.MockClient) { + mockALBClient: func(m *stackit.MockApplicationLoadBalancerClient) { m.EXPECT(). GetLoadBalancer(gomock.Any(), testProjectID, testRegion, testALBName). Return(&albsdk.LoadBalancer{ - Status: albsdk.LOADBALANCERSTATUS_READY.Ptr(), + Status: ptr.To("STATUS_READY"), PrivateAddress: ptr.To(testPublicIP), }, nil) }, @@ -146,8 +146,8 @@ func TestIngressClassReconciler_updateStatus(t *testing.T) { ingresses: []*networkingv1.Ingress{ {ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testIngressName}}, }, - mockALBClient: func(m *mock_albclient.MockClient) { - m.EXPECT().GetLoadBalancer(gomock.Any(), testProjectID, testRegion, testALBName).Return(nil, albclient.ErrorNotFound) + mockALBClient: func(m *stackit.MockApplicationLoadBalancerClient) { + m.EXPECT().GetLoadBalancer(gomock.Any(), testProjectID, testRegion, testALBName).Return(nil, stackit.ErrorNotFound) }, wantResult: reconcile.Result{}, wantErr: true, @@ -157,11 +157,11 @@ func TestIngressClassReconciler_updateStatus(t *testing.T) { ingresses: []*networkingv1.Ingress{ {ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testIngressName}}, }, - mockALBClient: func(m *mock_albclient.MockClient) { + mockALBClient: func(m *stackit.MockApplicationLoadBalancerClient) { m.EXPECT(). GetLoadBalancer(gomock.Any(), testProjectID, testRegion, testALBName). Return(&albsdk.LoadBalancer{ - Status: albsdk.LOADBALANCERSTATUS_READY.Ptr(), + Status: ptr.To("STATUS_READY"), PrivateAddress: ptr.To(testPublicIP), }, nil) }, @@ -175,11 +175,11 @@ func TestIngressClassReconciler_updateStatus(t *testing.T) { ingresses: []*networkingv1.Ingress{ {ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testIngressName}}, }, - mockALBClient: func(m *mock_albclient.MockClient) { + mockALBClient: func(m *stackit.MockApplicationLoadBalancerClient) { m.EXPECT(). GetLoadBalancer(gomock.Any(), testProjectID, testRegion, testALBName). Return(&albsdk.LoadBalancer{ - Status: albsdk.LOADBALANCERSTATUS_READY.Ptr(), + Status: ptr.To("STATUS_READY"), PrivateAddress: ptr.To(testPublicIP), }, nil) }, @@ -191,11 +191,11 @@ func TestIngressClassReconciler_updateStatus(t *testing.T) { ingresses: []*networkingv1.Ingress{ {ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testIngressName}}, }, - mockALBClient: func(m *mock_albclient.MockClient) { + mockALBClient: func(m *stackit.MockApplicationLoadBalancerClient) { m.EXPECT(). GetLoadBalancer(gomock.Any(), testProjectID, testRegion, testALBName). Return(&albsdk.LoadBalancer{ - Status: albsdk.LOADBALANCERSTATUS_READY.Ptr(), + Status: ptr.To("STATUS_READY"), }, nil) }, wantResult: reconcile.Result{RequeueAfter: 10 * time.Second}, @@ -207,7 +207,7 @@ func TestIngressClassReconciler_updateStatus(t *testing.T) { t.Run(tt.name, func(t *testing.T) { ctrl := gomock.NewController(t) - mockAlbClient := mock_albclient.NewMockClient(ctrl) + mockAlbClient := stackit.NewMockApplicationLoadBalancerClient(ctrl) fakeClient := fake.NewClientBuilder().WithScheme(scheme.Scheme).Build() r := &IngressClassReconciler{ Client: fakeClient, From 7a770aa0723c0d8f8ff44c330faa402dfd0c46a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Amand?= Date: Tue, 17 Mar 2026 11:55:04 +0100 Subject: [PATCH 08/23] wip: Add alb-controller-manager deploy files --- .../deployment.yaml | 59 ++++++++++++++++++ .../kustomization.yaml | 7 +++ .../rbac.yaml | 60 +++++++++++++++++++ .../service.yaml | 20 +++++++ 4 files changed, 146 insertions(+) create mode 100644 deploy/application-load-balancer-controller-manager/deployment.yaml create mode 100644 deploy/application-load-balancer-controller-manager/kustomization.yaml create mode 100644 deploy/application-load-balancer-controller-manager/rbac.yaml create mode 100644 deploy/application-load-balancer-controller-manager/service.yaml diff --git a/deploy/application-load-balancer-controller-manager/deployment.yaml b/deploy/application-load-balancer-controller-manager/deployment.yaml new file mode 100644 index 00000000..6e7de544 --- /dev/null +++ b/deploy/application-load-balancer-controller-manager/deployment.yaml @@ -0,0 +1,59 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + namespace: kube-system + name: stackit-application-load-balancer-contoller-manager + labels: + app: stackit-application-load-balancer-contoller-manager +spec: + replicas: 2 + strategy: + type: RollingUpdate + selector: + matchLabels: + app: stackit-application-load-balancer-contoller-manager + template: + metadata: + labels: + app: stackit-application-load-balancer-contoller-manager + spec: + serviceAccountName: stackit-application-load-balancer-contoller-manager + terminationGracePeriodSeconds: 30 + containers: + - name: stackit-application-load-balancer-contoller-manager + # TODO(jamand): Adapt image tag + image: ghcr.io/stackitcloud/cloud-provider-stackit/stackit-application-load-balancer-contoller-manager:XXX + args: + - "--authorization-always-allow-paths=/metrics" + - "--leader-elect=true" + - "--leader-elect-resource-name=stackit-application-load-balancer-contoller-manager" + - "--enable-http2" + - "--metrics-bind-address=8080" + - "--secureMetrics=false" + # TODO(jamand): Check webhook cert + enableHTTP2 flag + env: + - name: STACKIT_SERVICE_ACCOUNT_KEY_PATH + value: /etc/serviceaccount/sa_key.json + ports: + - containerPort: 8080 + hostPort: 8080 + name: metrics + protocol: TCP + - containerPort: 8081 + hostPort: 8081 + name: probe + protocol: TCP + resources: + limits: + cpu: "0.5" + memory: 500Mi + requests: + cpu: "0.1" + memory: 100Mi + volumeMounts: + - mountPath: /etc/serviceaccount + name: cloud-secret + volumes: + - name: cloud-secret + secret: + secretName: stackit-cloud-secret diff --git a/deploy/application-load-balancer-controller-manager/kustomization.yaml b/deploy/application-load-balancer-controller-manager/kustomization.yaml new file mode 100644 index 00000000..857fb567 --- /dev/null +++ b/deploy/application-load-balancer-controller-manager/kustomization.yaml @@ -0,0 +1,7 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: +- deployment.yaml +- rbac.yaml + diff --git a/deploy/application-load-balancer-controller-manager/rbac.yaml b/deploy/application-load-balancer-controller-manager/rbac.yaml new file mode 100644 index 00000000..d8f6c540 --- /dev/null +++ b/deploy/application-load-balancer-controller-manager/rbac.yaml @@ -0,0 +1,60 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + namespace: kube-system + name: stackit-application-load-balancer-contoller-manager +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: stackit-application-load-balancer-contoller-manager +rules: + # TODO(jamand): Go through rules again +- apiGroups: + - "" + resources: + - events + verbs: + - create + - patch + - update +- apiGroups: + - "" + resources: + - nodes + verbs: + - list +- apiGroups: + - "networking.k8s.io" + resources: + - ingress + verbs: + - get +- apiGroups: + - "networking.k8s.io" + resources: + - ingress/status + verbs: + - patch +- apiGroups: + - "networking.k8s.io" + resources: + - ingressclass + verbs: + - list + - patch + - update + - watch +--- +kind: ClusterRoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: stackit-application-load-balancer-contoller-manager +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: stackit-application-load-balancer-contoller-manager +subjects: +- kind: ServiceAccount + name: stackit-application-load-balancer-contoller-manager + namespace: kube-system diff --git a/deploy/application-load-balancer-controller-manager/service.yaml b/deploy/application-load-balancer-controller-manager/service.yaml new file mode 100644 index 00000000..28222103 --- /dev/null +++ b/deploy/application-load-balancer-controller-manager/service.yaml @@ -0,0 +1,20 @@ +apiVersion: v1 +kind: Service +metadata: + labels: + app: stackit-application-load-balancer-contoller-manager + namespace: kube-system + name: stackit-application-load-balancer-contoller-manager +spec: + selector: + app: stackit-application-load-balancer-contoller-manager + ports: + - name: probe + port: 8081 + targetPort: probe + protocol: TCP + - name: metrics + port: 8080 + targetPort: metrics + protocol: TCP + type: ClusterIP From 7d6d1f5beec170df660bc45400aa487955fc2601 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Tue, 17 Mar 2026 12:05:39 +0100 Subject: [PATCH 09/23] Fix main.go --- .../main.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/cmd/application-load-balancer-controller-manager/main.go b/cmd/application-load-balancer-controller-manager/main.go index 5d2cd4b7..c68f6b22 100644 --- a/cmd/application-load-balancer-controller-manager/main.go +++ b/cmd/application-load-balancer-controller-manager/main.go @@ -39,9 +39,11 @@ import ( metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" "sigs.k8s.io/controller-runtime/pkg/webhook" + "github.com/stackitcloud/cloud-provider-stackit/pkg/alb/ingress" + "github.com/stackitcloud/cloud-provider-stackit/pkg/stackit" albclient "github.com/stackitcloud/cloud-provider-stackit/pkg/stackit" - albsdk "github.com/stackitcloud/stackit-sdk-go/services/alb" - certsdk "github.com/stackitcloud/stackit-sdk-go/services/certificates" + albsdk "github.com/stackitcloud/stackit-sdk-go/services/alb/v2api" + certsdk "github.com/stackitcloud/stackit-sdk-go/services/certificates/v2api" // +kubebuilder:scaffold:imports ) @@ -263,13 +265,13 @@ func main() { os.Exit(1) } // Create an Certificates API client - certificateClient, err := certificateclient.NewCertClient(certificateAPI) + certificateClient, err := stackit.NewCertClient(certificateAPI) if err != nil { setupLog.Error(err, "unable to create Certificates client", "controller", "IngressClass") os.Exit(1) } - if err = (&controller.IngressClassReconciler{ + if err = (&ingress.IngressClassReconciler{ Client: mgr.GetClient(), ALBClient: albClient, CertificateClient: certificateClient, From 45c973d9afc521cb375b2231838c130ffafcbd9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Tue, 17 Mar 2026 12:09:12 +0100 Subject: [PATCH 10/23] Add mock generation for ALB and certificates API --- Makefile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Makefile b/Makefile index 13fc7f54..2c372b5f 100644 --- a/Makefile +++ b/Makefile @@ -141,6 +141,9 @@ mocks: $(MOCKGEN) @$(MOCKGEN) -destination ./pkg/stackit/server_mock.go -package stackit ./pkg/stackit NodeClient @$(MOCKGEN) -destination ./pkg/stackit/metadata/metadata_mock.go -package metadata ./pkg/stackit/metadata IMetadata @$(MOCKGEN) -destination ./pkg/csi/util/mount/mount_mock.go -package mount ./pkg/csi/util/mount IMount + @$(MOCKGEN) -destination ./pkg/stackit/applicationloadbalancercertificates_mock.go -package stackit ./pkg/stackit CertificatesClient + @$(MOCKGEN) -destination ./pkg/stackit/applicationloadbalancer_mock.go -package stackit ./pkg/stackit ApplicationLoadBalancerClient + .PHONY: generate generate: mocks From 612f42fccb7bb947dda4bb3ecbc026b1abd5a0e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Tue, 17 Mar 2026 14:02:33 +0100 Subject: [PATCH 11/23] Fix linter issues --- .../main.go | 9 ++-- pkg/alb/ingress/alb_spec.go | 51 ++++++++++--------- pkg/alb/ingress/alb_spec_test.go | 41 +++++++-------- pkg/alb/ingress/controller_test.go | 10 ++-- pkg/alb/ingress/ingressclass_controller.go | 30 ++++++----- .../ingress/ingressclass_controller_test.go | 1 + 6 files changed, 74 insertions(+), 68 deletions(-) diff --git a/cmd/application-load-balancer-controller-manager/main.go b/cmd/application-load-balancer-controller-manager/main.go index c68f6b22..28b3f0c1 100644 --- a/cmd/application-load-balancer-controller-manager/main.go +++ b/cmd/application-load-balancer-controller-manager/main.go @@ -41,7 +41,6 @@ import ( "github.com/stackitcloud/cloud-provider-stackit/pkg/alb/ingress" "github.com/stackitcloud/cloud-provider-stackit/pkg/stackit" - albclient "github.com/stackitcloud/cloud-provider-stackit/pkg/stackit" albsdk "github.com/stackitcloud/stackit-sdk-go/services/alb/v2api" certsdk "github.com/stackitcloud/stackit-sdk-go/services/certificates/v2api" // +kubebuilder:scaffold:imports @@ -58,7 +57,7 @@ func init() { // +kubebuilder:scaffold:scheme } -// nolint:gocyclo +// nolint:gocyclo,funlen // TODO: Refactor into smaller functions. func main() { var metricsAddr string var metricsCertPath, metricsCertName, metricsCertKey string @@ -120,7 +119,7 @@ func main() { // Initial webhook TLS options webhookTLSOpts := tlsOpts - if len(webhookCertPath) > 0 { + if webhookCertPath != "" { setupLog.Info("Initializing webhook certificate watcher using provided certificates", "webhook-cert-path", webhookCertPath, "webhook-cert-name", webhookCertName, "webhook-cert-key", webhookCertKey) @@ -169,7 +168,7 @@ func main() { // - [METRICS-WITH-CERTS] at config/default/kustomization.yaml to generate and use certificates // managed by cert-manager for the metrics server. // - [PROMETHEUS-WITH-CERTS] at config/prometheus/kustomization.yaml for TLS certification. - if len(metricsCertPath) > 0 { + if metricsCertPath != "" { setupLog.Info("Initializing metrics certificate watcher using provided certificates", "metrics-cert-path", metricsCertPath, "metrics-cert-name", metricsCertName, "metrics-cert-key", metricsCertKey) @@ -252,7 +251,7 @@ func main() { } // Create an ALB client fmt.Printf("Create ALB client\n") - albClient, err := albclient.NewApplicationLoadBalancerClient(sdkClient) + albClient, err := stackit.NewApplicationLoadBalancerClient(sdkClient) if err != nil { setupLog.Error(err, "unable to create ALB client", "controller", "IngressClass") os.Exit(1) diff --git a/pkg/alb/ingress/alb_spec.go b/pkg/alb/ingress/alb_spec.go index d97c5450..89c458bc 100644 --- a/pkg/alb/ingress/alb_spec.go +++ b/pkg/alb/ingress/alb_spec.go @@ -11,7 +11,7 @@ import ( "strconv" "strings" - v1 "k8s.io/api/core/v1" + corev1 "k8s.io/api/core/v1" networkingv1 "k8s.io/api/networking/v1" "k8s.io/apimachinery/pkg/types" "k8s.io/utils/ptr" @@ -64,13 +64,13 @@ type ruleMetadata struct { // It merges and sorts all routing rules across the ingresses based on host, priority, path specificity, path type, and ingress origin. // The resulting ALB payload includes targets derived from cluster nodes, target pools per backend service, HTTP(S) listeners, // and optional TLS certificate bindings. This spec is later used to create or update the actual ALB instance. -func (r *IngressClassReconciler) albSpecFromIngress( +func (r *IngressClassReconciler) albSpecFromIngress( //nolint:funlen,gocyclo // We go through a lot of fields. Not much complexity. ctx context.Context, ingresses []*networkingv1.Ingress, ingressClass *networkingv1.IngressClass, networkID *string, - nodes []v1.Node, - services map[string]v1.Service, + nodes []corev1.Node, + services map[string]corev1.Service, ) (*albsdk.CreateLoadBalancerPayload, error) { targetPools := []albsdk.TargetPool{} targetPoolSeen := map[string]bool{} @@ -89,10 +89,11 @@ func (r *IngressClassReconciler) albSpecFromIngress( // Create targets for each node in the cluster 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 == v1.NodeInternalIP { + if address.Type == corev1.NodeInternalIP { targets = append(targets, albsdk.Target{ DisplayName: &node.Name, Ip: &address.Address, @@ -142,6 +143,7 @@ func (r *IngressClassReconciler) albSpecFromIngress( certificateIDs, err := r.loadCerts(ctx, ingressClass, ingress) if err != nil { log.Printf("failed to load tls certificates: %v", err) + //nolint:gocritic // TODO: Rework error handling. // return nil, fmt.Errorf("failed to load tls certificates: %w", err) } allCertificateIDs = append(allCertificateIDs, certificateIDs...) @@ -241,7 +243,7 @@ func (r *IngressClassReconciler) albSpecFromIngress( } // Set the IP address of the ALB - err := setIpAddresses(ingressClass, alb) + err := setIPAddresses(ingressClass, alb) if err != nil { return nil, fmt.Errorf("failed to set IP address: %w", err) } @@ -262,11 +264,11 @@ func (r *IngressClassReconciler) loadCerts( certificateIDs := []string{} for _, tls := range ingress.Spec.TLS { - if len(tls.SecretName) == 0 { + if tls.SecretName != "" { continue } - secret := &v1.Secret{} + secret := &corev1.Secret{} if err := r.Client.Get(ctx, types.NamespacedName{Namespace: ingress.Namespace, Name: tls.SecretName}, secret); err != nil { return nil, fmt.Errorf("failed to get TLS secret: %w", err) } @@ -274,7 +276,7 @@ func (r *IngressClassReconciler) loadCerts( // The tls.crt should contain both the leaf certificate and the intermediate CA certificates. // If it contains only the leaf certificate, the ACME challenge likely hasn't finished. // Therefore the incomplete certificate shouldn't be loaded as the updates upon them are impossible. - complete, err := isCertValid(*secret) + complete, err := isCertValid(secret) if err != nil { return nil, fmt.Errorf("failed to validate certificate: %w", err) } @@ -306,17 +308,18 @@ func (r *IngressClassReconciler) cleanupCerts(ctx context.Context, ingressClass usedSecrets := map[string]bool{} for _, ingress := range ingresses { for _, tls := range ingress.Spec.TLS { - if tls.SecretName != "" { - // Retrieve the TLS Secret - tlsSecret := &v1.Secret{} - err := r.Client.Get(ctx, types.NamespacedName{Namespace: ingress.Namespace, Name: tls.SecretName}, tlsSecret) - if err != nil { - log.Printf("failed to get TLS secret %s: %v", tls.SecretName, err) - continue - } - certName := getCertName(ingressClass, ingress, tlsSecret) - usedSecrets[certName] = true + if tls.SecretName == "" { + continue + } + // Retrieve the TLS Secret + tlsSecret := &corev1.Secret{} + err := r.Client.Get(ctx, types.NamespacedName{Namespace: ingress.Namespace, Name: tls.SecretName}, tlsSecret) + if err != nil { + log.Printf("failed to get TLS secret %s: %v", tls.SecretName, err) + continue } + certName := getCertName(ingressClass, ingress, tlsSecret) + usedSecrets[certName] = true } } @@ -350,7 +353,7 @@ func (r *IngressClassReconciler) cleanupCerts(ctx context.Context, ingressClass // isCertValid checks if the certificate chain is complete. It is used for checking if // the cert-manager's ACME challenge is completed, or if it's sill ongoing. -func isCertValid(secret v1.Secret) (bool, error) { +func isCertValid(secret *corev1.Secret) (bool, error) { tlsCert := secret.Data["tls.crt"] if tlsCert == nil { return false, fmt.Errorf("tls.crt not found in secret") @@ -406,13 +409,13 @@ func addTargetPool( } *targetPools = append(*targetPools, albsdk.TargetPool{ Name: ptr.To(targetPoolName), - TargetPort: ptr.To(int32(nodePort)), + TargetPort: ptr.To(nodePort), TlsConfig: tlsConfig, Targets: targets, }) } -func setIpAddresses(ingressClass *networkingv1.IngressClass, alb *albsdk.CreateLoadBalancerPayload) error { +func setIPAddresses(ingressClass *networkingv1.IngressClass, alb *albsdk.CreateLoadBalancerPayload) error { isInternalIP, found := ingressClass.Annotations[internalIPAnnotation] if found && isInternalIP == "true" { alb.Options = &albsdk.LoadBalancerOptions{ @@ -447,7 +450,7 @@ func validateIPAddress(ipAddr string) error { } // getNodePort gets the NodePort of the Service -func getNodePort(services map[string]v1.Service, path networkingv1.HTTPIngressPath) (int32, error) { +func getNodePort(services map[string]corev1.Service, path networkingv1.HTTPIngressPath) (int32, error) { service, found := services[path.Backend.Service.Name] if !found { return 0, fmt.Errorf("service not found: %s", path.Backend.Service.Name) diff --git a/pkg/alb/ingress/alb_spec_test.go b/pkg/alb/ingress/alb_spec_test.go index 0a215a42..6d7491ce 100644 --- a/pkg/alb/ingress/alb_spec_test.go +++ b/pkg/alb/ingress/alb_spec_test.go @@ -7,7 +7,7 @@ import ( "github.com/google/go-cmp/cmp" "google.golang.org/protobuf/testing/protocmp" - v1 "k8s.io/api/core/v1" + corev1 "k8s.io/api/core/v1" networkingv1 "k8s.io/api/networking/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/utils/ptr" @@ -79,11 +79,11 @@ func fixtureIngressWithParams(name, namespace string, annotations map[string]str } } -func fixtureServiceWithParams(port, nodePort int32) *v1.Service { - return &v1.Service{ +func fixtureServiceWithParams(port, nodePort int32) *corev1.Service { //nolint:unparam // We might need it later. + return &corev1.Service{ ObjectMeta: metav1.ObjectMeta{Name: testServiceName}, - Spec: v1.ServiceSpec{ - Ports: []v1.ServicePort{ + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ { Port: port, NodePort: nodePort, @@ -93,11 +93,11 @@ func fixtureServiceWithParams(port, nodePort int32) *v1.Service { } } -func fixtureNode(mods ...func(*v1.Node)) *v1.Node { - node := &v1.Node{ +func fixtureNode(mods ...func(*corev1.Node)) *corev1.Node { + node := &corev1.Node{ ObjectMeta: metav1.ObjectMeta{Name: testNodeName}, - Status: v1.NodeStatus{ - Addresses: []v1.NodeAddress{{Type: v1.NodeInternalIP, Address: testNodeIP}}, + Status: corev1.NodeStatus{ + Addresses: []corev1.NodeAddress{{Type: corev1.NodeInternalIP, Address: testNodeIP}}, }, } for _, mod := range mods { @@ -187,15 +187,16 @@ func fixtureAlbPayload(mods ...func(*albsdk.CreateLoadBalancerPayload)) *albsdk. return payload } +//nolint:funlen // Just many test cases. func Test_albSpecFromIngress(t *testing.T) { r := &IngressClassReconciler{} - nodes := []v1.Node{*fixtureNode()} + nodes := []corev1.Node{*fixtureNode()} tests := []struct { name string ingresses []*networkingv1.Ingress ingressClass *networkingv1.IngressClass - services map[string]v1.Service + services map[string]corev1.Service want *albsdk.CreateLoadBalancerPayload wantErr bool }{ @@ -203,7 +204,7 @@ func Test_albSpecFromIngress(t *testing.T) { name: "valid ingress with HTTP listener", ingresses: []*networkingv1.Ingress{fixtureIngress()}, ingressClass: fixtureIngressClass(), - services: map[string]v1.Service{testServiceName: *fixtureServiceWithParams(testServicePort, testNodePort)}, + services: map[string]corev1.Service{testServiceName: *fixtureServiceWithParams(testServicePort, testNodePort)}, want: fixtureAlbPayload(), }, { @@ -214,7 +215,7 @@ func Test_albSpecFromIngress(t *testing.T) { ing.Annotations = map[string]string{externalIPAnnotation: "2.2.2.2"} }, ), - services: map[string]v1.Service{testServiceName: *fixtureServiceWithParams(testServicePort, testNodePort)}, + services: map[string]corev1.Service{testServiceName: *fixtureServiceWithParams(testServicePort, testNodePort)}, want: fixtureAlbPayload(func(payload *albsdk.CreateLoadBalancerPayload) { payload.ExternalAddress = ptr.To("2.2.2.2") payload.Options = &albsdk.LoadBalancerOptions{EphemeralAddress: nil} @@ -228,7 +229,7 @@ func Test_albSpecFromIngress(t *testing.T) { ing.Annotations = map[string]string{internalIPAnnotation: "true"} }, ), - services: map[string]v1.Service{testServiceName: *fixtureServiceWithParams(testServicePort, testNodePort)}, + services: map[string]corev1.Service{testServiceName: *fixtureServiceWithParams(testServicePort, testNodePort)}, want: fixtureAlbPayload(func(payload *albsdk.CreateLoadBalancerPayload) { payload.Options = &albsdk.LoadBalancerOptions{PrivateNetworkOnly: ptr.To(true)} }), @@ -242,7 +243,7 @@ func Test_albSpecFromIngress(t *testing.T) { ingressRule("a-host.com", ingressPrefixPath("/a", "svc2")), ), }, - services: map[string]v1.Service{ + services: map[string]corev1.Service{ "svc1": *fixtureServiceWithParams(testServicePort, 30001), "svc2": *fixtureServiceWithParams(testServicePort, 30002), }, @@ -278,7 +279,7 @@ func Test_albSpecFromIngress(t *testing.T) { ingressRule("host.com", ingressPrefixPath("/x", "svc2")), ), }, - services: map[string]v1.Service{ + services: map[string]corev1.Service{ "svc1": *fixtureServiceWithParams(testServicePort, 30003), "svc2": *fixtureServiceWithParams(testServicePort, 30004), }, @@ -305,7 +306,7 @@ func Test_albSpecFromIngress(t *testing.T) { ), ), }, - services: map[string]v1.Service{ + services: map[string]corev1.Service{ "svc1": *fixtureServiceWithParams(testServicePort, 30005), "svc2": *fixtureServiceWithParams(testServicePort, 30006), }, @@ -332,7 +333,7 @@ func Test_albSpecFromIngress(t *testing.T) { ), ), }, - services: map[string]v1.Service{ + services: map[string]corev1.Service{ "svc-exact": *fixtureServiceWithParams(testServicePort, 30100), "svc-prefix": *fixtureServiceWithParams(testServicePort, 30101), }, @@ -359,7 +360,7 @@ func Test_albSpecFromIngress(t *testing.T) { ingressRule("host.com", ingressPrefixPath("/x", "svc2")), ), }, - services: map[string]v1.Service{ + services: map[string]corev1.Service{ "svc1": *fixtureServiceWithParams(testServicePort, 30007), "svc2": *fixtureServiceWithParams(testServicePort, 30008), }, @@ -386,7 +387,7 @@ func Test_albSpecFromIngress(t *testing.T) { ingressRule("host.com", ingressPrefixPath("/x", "svc2")), ), }, - services: map[string]v1.Service{ + services: map[string]corev1.Service{ "svc1": *fixtureServiceWithParams(testServicePort, 30009), "svc2": *fixtureServiceWithParams(testServicePort, 30010), }, diff --git a/pkg/alb/ingress/controller_test.go b/pkg/alb/ingress/controller_test.go index be18be83..0c2573d9 100644 --- a/pkg/alb/ingress/controller_test.go +++ b/pkg/alb/ingress/controller_test.go @@ -7,7 +7,7 @@ import ( . "github.com/onsi/gomega" corev1 "k8s.io/api/core/v1" networkingv1 "k8s.io/api/networking/v1" - k8serrors "k8s.io/apimachinery/pkg/api/errors" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes/scheme" "k8s.io/utils/ptr" @@ -135,7 +135,7 @@ var _ = Describe("IngressClassReconciler", func() { AfterEach(func() { var ic networkingv1.IngressClass err := k8sClient.Get(ctx, client.ObjectKeyFromObject(ingressClass), &ic) - if k8serrors.IsNotFound(err) { + if apierrors.IsNotFound(err) { // nothing to clean up, it’s already deleted return } @@ -148,12 +148,12 @@ var _ = Describe("IngressClassReconciler", func() { // delete the patched object (ic), not the old ingressClass pointer err = k8sClient.Delete(ctx, &ic) - if err != nil && !k8serrors.IsNotFound(err) { + if err != nil && !apierrors.IsNotFound(err) { Expect(err).NotTo(HaveOccurred()) } Eventually(func() bool { - return k8serrors.IsNotFound( + return apierrors.IsNotFound( k8sClient.Get(ctx, client.ObjectKeyFromObject(ingressClass), &networkingv1.IngressClass{}), ) }).Should(BeTrue(), "IngressClass should be fully deleted") @@ -183,7 +183,7 @@ var _ = Describe("IngressClassReconciler", func() { Eventually(func(g Gomega) { var ic networkingv1.IngressClass err := k8sClient.Get(ctx, client.ObjectKeyFromObject(ingressClass), &ic) - if k8serrors.IsNotFound(err) { + if apierrors.IsNotFound(err) { // IngressClass is gone — controller must have removed the finalizer return } diff --git a/pkg/alb/ingress/ingressclass_controller.go b/pkg/alb/ingress/ingressclass_controller.go index c6ecbee8..cf7abc1d 100644 --- a/pkg/alb/ingress/ingressclass_controller.go +++ b/pkg/alb/ingress/ingressclass_controller.go @@ -24,7 +24,7 @@ import ( "fmt" "time" - v1 "k8s.io/api/core/v1" + corev1 "k8s.io/api/core/v1" networkingv1 "k8s.io/api/networking/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" @@ -46,8 +46,8 @@ const ( ) // IngressClassReconciler reconciles a IngressClass object -type IngressClassReconciler struct { - client.Client +type IngressClassReconciler struct { //nolint:revive // Naming this ClassReconciler would be confusing. + Client client.Client ALBClient stackit.ApplicationLoadBalancerClient CertificateClient stackit.CertificatesClient Scheme *runtime.Scheme @@ -70,7 +70,6 @@ type IngressClassReconciler struct { // For more details, check Reconcile and its Result here: // - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.19.4/pkg/reconcile func (r *IngressClassReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - // _ = log.FromContext(ctx) ingressClass := &networkingv1.IngressClass{} err := r.Client.Get(ctx, req.NamespacedName, ingressClass) if err != nil { @@ -125,7 +124,7 @@ func (r *IngressClassReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). // Uncomment the following line adding a pointer to an instance of the controlled resource as an argument For(&networkingv1.IngressClass{}). - Watches(&v1.Node{}, handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, o client.Object) []ctrl.Request { + Watches(&corev1.Node{}, handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, _ client.Object) []ctrl.Request { // TODO: Add predicates - watch only for specific changes on nodes ingressClassList := &networkingv1.IngressClassList{} err := r.Client.List(ctx, ingressClassList) @@ -133,14 +132,15 @@ func (r *IngressClassReconciler) SetupWithManager(mgr ctrl.Manager) error { panic(err) } requestList := []ctrl.Request{} - for _, ingressClass := range ingressClassList.Items { + for i := range ingressClassList.Items { + ingressClass := ingressClassList.Items[i] requestList = append(requestList, ctrl.Request{ NamespacedName: client.ObjectKeyFromObject(&ingressClass), }) } return requestList })). - Watches(&networkingv1.Ingress{}, handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, o client.Object) []ctrl.Request { + Watches(&networkingv1.Ingress{}, handler.EnqueueRequestsFromMapFunc(func(_ context.Context, o client.Object) []ctrl.Request { ingress, ok := o.(*networkingv1.Ingress) if !ok || ingress.Spec.IngressClassName == nil { return nil @@ -167,18 +167,19 @@ func (r *IngressClassReconciler) handleIngressClassWithIngresses( ingressClass *networkingv1.IngressClass, ) (ctrl.Result, error) { // Get all nodes and services - nodes := &v1.NodeList{} + nodes := &corev1.NodeList{} err := r.Client.List(ctx, nodes) if err != nil { return ctrl.Result{}, fmt.Errorf("failed to get nodes: %w", err) } - serviceList := &v1.ServiceList{} + serviceList := &corev1.ServiceList{} err = r.Client.List(ctx, serviceList) if err != nil { return ctrl.Result{}, fmt.Errorf("failed to get services: %w", err) } - services := map[string]v1.Service{} - for _, service := range serviceList.Items { + services := map[string]corev1.Service{} + for i := range serviceList.Items { + service := serviceList.Items[i] services[service.Name] = service } @@ -323,7 +324,7 @@ func (r *IngressClassReconciler) handleIngressClassDeletion( } // detectChange checks if there is any difference between the current and desired ALB configuration. -func detectChange(alb *albsdk.LoadBalancer, albPayload *albsdk.CreateLoadBalancerPayload) bool { +func detectChange(alb *albsdk.LoadBalancer, albPayload *albsdk.CreateLoadBalancerPayload) bool { //nolint:gocyclo // We check a lot of fields. Not much complexity. if len(alb.Listeners) != len(albPayload.Listeners) { return true } @@ -436,7 +437,8 @@ func (r *IngressClassReconciler) getAlbIngressList( } ingresses := []*networkingv1.Ingress{} - for _, ingress := range ingressList.Items { + for i := range ingressList.Items { + ingress := ingressList.Items[i] if ingress.Spec.IngressClassName != nil && *ingress.Spec.IngressClassName == ingressClass.Name { ingresses = append(ingresses, &ingress) } @@ -452,7 +454,7 @@ func getAlbName(ingressClass *networkingv1.IngressClass) string { // getCertName generates a unique name for the Certificate using the IngressClass UID, Ingress UID, // and TLS Secret UID, ensuring it fits within the Kubernetes 63-character limit. -func getCertName(ingressClass *networkingv1.IngressClass, ingress *networkingv1.Ingress, tlsSecret *v1.Secret) string { +func getCertName(ingressClass *networkingv1.IngressClass, ingress *networkingv1.Ingress, tlsSecret *corev1.Secret) string { ingressClassShortUID := generateShortUID(ingressClass.UID) ingressShortUID := generateShortUID(ingress.UID) tlsSecretShortUID := generateShortUID(tlsSecret.UID) diff --git a/pkg/alb/ingress/ingressclass_controller_test.go b/pkg/alb/ingress/ingressclass_controller_test.go index 610f2a01..fc6d8bf8 100644 --- a/pkg/alb/ingress/ingressclass_controller_test.go +++ b/pkg/alb/ingress/ingressclass_controller_test.go @@ -28,6 +28,7 @@ const ( testPrivateIP = "10.0.0.1" ) +//nolint:funlen // Just many test cases. func TestIngressClassReconciler_updateStatus(t *testing.T) { testIngressClass := &networkingv1.IngressClass{ ObjectMeta: metav1.ObjectMeta{ From f619aa1343aee2ef61df73ff7dfc577de0c71b83 Mon Sep 17 00:00:00 2001 From: Menekse Ceylan Date: Tue, 17 Mar 2026 15:26:24 +0100 Subject: [PATCH 12/23] Added waf config to change detection Added ExactMatch -Path- to change detection --- pkg/alb/ingress/ingressclass_controller.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pkg/alb/ingress/ingressclass_controller.go b/pkg/alb/ingress/ingressclass_controller.go index cf7abc1d..7d7dfc6e 100644 --- a/pkg/alb/ingress/ingressclass_controller.go +++ b/pkg/alb/ingress/ingressclass_controller.go @@ -338,6 +338,11 @@ func detectChange(alb *albsdk.LoadBalancer, albPayload *albsdk.CreateLoadBalance return true } + // WAF config check + if ptr.Deref(albListener.WafConfigName, "") != ptr.Deref(payloadListener.WafConfigName, "") { + return true + } + // HTTP rules comparison (via Hosts) if albListener.Http != nil && payloadListener.Http != nil { albHosts := albListener.Http.Hosts @@ -370,6 +375,9 @@ func detectChange(alb *albsdk.LoadBalancer, albPayload *albsdk.CreateLoadBalance if ptr.Deref(albRule.Path.Prefix, "") != ptr.Deref(payloadRule.Path.Prefix, "") { return true } + if ptr.Deref(albRule.Path.ExactMatch, "") != ptr.Deref(payloadRule.Path.ExactMatch, "") { + return true + } } if ptr.Deref(albRule.TargetPool, "") != ptr.Deref(payloadRule.TargetPool, "") { return true From 32e60f205d9372435cdaab384a9d8e26d7b45828 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Wed, 18 Mar 2026 08:56:03 +0100 Subject: [PATCH 13/23] Update docs for ALBCM --- docs/{ingress-controller.md => albcm.md} | 41 +++++++++++++----------- docs/deployment.md | 8 +++-- 2 files changed, 28 insertions(+), 21 deletions(-) rename docs/{ingress-controller.md => albcm.md} (66%) diff --git a/docs/ingress-controller.md b/docs/albcm.md similarity index 66% rename from docs/ingress-controller.md rename to docs/albcm.md index 2de655b2..818d042b 100644 --- a/docs/ingress-controller.md +++ b/docs/albcm.md @@ -1,31 +1,27 @@ -### Run the ALB Ingress controller locally -To run the controller on your local machine, ensure you have a valid kubeconfig pointing to the target Kubernetes cluster where the ALB resources should be managed. +# Application Load Balancer Controller Manager + +The Application Load Balancer Controller Manager (ALBCM) manages ALBs from within a Kubernetes cluster. +Currently, the Ingress API is supported. +Support for Gateway API is planned. ##### Environment Variables + The controller requires specific configuration and credentials to interact with the STACKIT APIs and your network infrastructure. Set the following variables: - - STACKIT_SERVICE_ACCOUNT_TOKEN: Your authentication token for performing CRUD operations via the ALB and Certificates SDK. - - STACKIT_REGION: The STACKIT region where the infrastructure resides (e.g., eu01). - - PROJECT_ID: The unique identifier of your STACKIT project where the ALB will be provisioned. - - NETWORK_ID: The ID of the STACKIT network where the ALB will be provisioned. -``` -export STACKIT_SERVICE_ACCOUNT_TOKEN= -export STACKIT_REGION= -export PROJECT_ID= -export NETWORK_ID= -``` -Kubernetes Context + +- STACKIT_REGION: The STACKIT region where the infrastructure resides (e.g., eu01). +- PROJECT_ID: The unique identifier of your STACKIT project where the ALB will be provisioned. +- NETWORK_ID: The ID of the STACKIT network where the ALB will be provisioned. +- In addition, the ALBCM supports all environment variable support by the STACKIT SDK. This includes authentication. + The controller uses the default Kubernetes client. Ensure your KUBECONFIG environment variable is set or your current context is correctly configured: ``` export KUBECONFIG=~/.kube/config ``` -#### Run -Use the provided Makefile in the root of repository to start the controller: -``` -make run -``` ### Create your deployment and expose it via Ingress + 1. Create your k8s deployment, here’s an example of a simple http web server: + ``` apiVersion: apps/v1 kind: Deployment @@ -50,7 +46,9 @@ spec: ports: - containerPort: 80 ``` + 2. Now, create a k8s service so that the traffic can be routed to the pods: + ``` apiVersion: v1 kind: Service @@ -69,8 +67,11 @@ spec: app: httpbin-deployment type: NodePort ``` ->NOTE: The service has to be of type NodePort to enable access to the nodes from the outside of the cluster. + +> NOTE: The service has to be of type NodePort to enable access to the nodes from the outside of the cluster. + 3. Create an IngressClass that specifies the ALB Ingress controller: + ``` apiVersion: networking.k8s.io/v1 kind: IngressClass @@ -80,7 +81,9 @@ metadata: spec: controller: stackit.cloud/alb-ingress ``` + 4. Lastly, create an ingress resource that references the previously created IngressClass: + ``` apiVersion: networking.k8s.io/v1 kind: Ingress diff --git a/docs/deployment.md b/docs/deployment.md index 6a8a747d..9f140eac 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -17,14 +17,14 @@ ## Overview -The STACKIT Cloud Provider includes both the Cloud Controller Manager (CCM) for managing cloud resources and the CSI driver for persistent storage. This deployment provides a unified solution for cloud integration and storage provisioning. +The STACKIT Cloud Provider includes the Cloud Controller Manager (CCM) for managing cloud resources, the CSI driver for persistent storage and the Application Load Balancer Controller Manager (ALBCM) for managing STACKIT Application Load Balancer (ALB) via Ingress Resources. ## Deployment Components The deployment consists of the following components: 1. **ServiceAccount**: `stackit-cloud-controller-manager` with appropriate RBAC permissions -2. **Deployment**: Runs the cloud provider container with necessary configuration +2. **Deployment**: Runs the cloud provider containers with necessary configuration 3. **Service**: Exposes metrics and API endpoints ## Deployment Configuration @@ -50,6 +50,10 @@ The deployment can be customized using the following flags: - `--provide-controller-service`: Enable controller service (default: true) - `--provide-node-service`: Enable node service (default: true) +### Application Load Balancer Controller Manager + +- `--cloud-config`: Path to cloud configuration file + ## Deployment Steps Apply the deployment using kustomize: From e07f91b58d7476934766884d3764611c76d9629b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Wed, 18 Mar 2026 10:00:51 +0100 Subject: [PATCH 14/23] Fix ALB unit tests --- pkg/alb/ingress/alb_spec_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/alb/ingress/alb_spec_test.go b/pkg/alb/ingress/alb_spec_test.go index 6d7491ce..c5fcb35a 100644 --- a/pkg/alb/ingress/alb_spec_test.go +++ b/pkg/alb/ingress/alb_spec_test.go @@ -156,6 +156,7 @@ func fixtureAlbPayload(mods ...func(*albsdk.CreateLoadBalancerPayload)) *albsdk. Name: ptr.To("k8s-ingress-" + testIngressClassName), Listeners: []albsdk.Listener{ { + Name: ptr.To("http"), Port: ptr.To(int32(80)), Protocol: ptr.To("PROTOCOL_HTTP"), Http: &albsdk.ProtocolOptionsHTTP{ From 66e932c04d21b08b54709e7a30079fcf375f3527 Mon Sep 17 00:00:00 2001 From: Kamil Przybyl Date: Wed, 18 Mar 2026 10:24:07 +0100 Subject: [PATCH 15/23] feat: read configuration from cloud config --- .../main.go | 81 +++++++++++++------ 1 file changed, 57 insertions(+), 24 deletions(-) diff --git a/cmd/application-load-balancer-controller-manager/main.go b/cmd/application-load-balancer-controller-manager/main.go index 28b3f0c1..0b60b5f2 100644 --- a/cmd/application-load-balancer-controller-manager/main.go +++ b/cmd/application-load-balancer-controller-manager/main.go @@ -20,17 +20,18 @@ import ( "crypto/tls" "flag" "fmt" + "io" "os" "path/filepath" // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) // to ensure that exec-entrypoint and run can make use of them. - _ "k8s.io/client-go/plugin/pkg/client/auth" - sdkconfig "github.com/stackitcloud/stackit-sdk-go/core/config" + "gopkg.in/yaml.v3" "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/certwatcher" "sigs.k8s.io/controller-runtime/pkg/healthz" @@ -40,7 +41,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/webhook" "github.com/stackitcloud/cloud-provider-stackit/pkg/alb/ingress" - "github.com/stackitcloud/cloud-provider-stackit/pkg/stackit" + albclient "github.com/stackitcloud/cloud-provider-stackit/pkg/stackit" albsdk "github.com/stackitcloud/stackit-sdk-go/services/alb/v2api" certsdk "github.com/stackitcloud/stackit-sdk-go/services/certificates/v2api" // +kubebuilder:scaffold:imports @@ -57,6 +58,49 @@ func init() { // +kubebuilder:scaffold:scheme } +type Config struct { + NetworkID string `yaml:"networkID"` + ProjectID string `yaml:"projectID"` + Region string `yaml:"region"` +} + +// ReadConfig reads the ALB infrastructure configuration provided via the cloud-config flag. +func ReadConfig(cloudConfig string) Config { + configFile, err := os.Open(cloudConfig) + if err != nil { + setupLog.Error(err, "Failed to open the cloud config file") + os.Exit(1) + } + defer configFile.Close() + + var config Config + content, err := io.ReadAll(configFile) + if err != nil { + setupLog.Error(err, "Failed to read config content") + os.Exit(1) + } + + err = yaml.Unmarshal(content, &config) + if err != nil { + setupLog.Error(err, "Failed to parse config as YAML") + os.Exit(1) + } + + if config.ProjectID == "" { + setupLog.Error(err, "projectId must be set") + os.Exit(1) + } + if config.Region == "" { + setupLog.Error(err, "region must be set") + os.Exit(1) + } + if config.NetworkID == "" { + setupLog.Error(err, "networkId must be set") + os.Exit(1) + } + return config +} + // nolint:gocyclo,funlen // TODO: Refactor into smaller functions. func main() { var metricsAddr string @@ -66,6 +110,7 @@ func main() { var leaderElectionNamespace string var leaderElectionID string var probeAddr string + var cloudConfig string var secureMetrics bool var enableHTTP2 bool var tlsOpts []func(*tls.Config) @@ -90,6 +135,7 @@ func main() { flag.StringVar(&metricsCertKey, "metrics-cert-key", "tls.key", "The name of the metrics server key file.") flag.BoolVar(&enableHTTP2, "enable-http2", false, "If set, HTTP/2 will be enabled for the metrics and webhook servers") + flag.StringVar(&cloudConfig, "cloud-config", "cloud.yaml", "The path to the cloud config file.") opts := zap.Options{ Development: true, } @@ -98,6 +144,8 @@ func main() { ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) + config := ReadConfig(cloudConfig) + // if the enable-http2 flag is false (the default), http/2 should be disabled // due to its vulnerabilities. More specifically, disabling http/2 will // prevent from being vulnerable to the HTTP/2 Stream Cancellation and @@ -216,24 +264,9 @@ func main() { certURL, _ := os.LookupEnv("STACKIT_LOAD_BALANCER_API_CERT_URL") - region, set := os.LookupEnv("STACKIT_REGION") - if !set { - setupLog.Error(err, "STACKIT_REGION not set", "controller", "IngressClass") - os.Exit(1) - } - projectID, set := os.LookupEnv("PROJECT_ID") - if !set { - setupLog.Error(err, "PROJECT_ID not set", "controller", "IngressClass") - os.Exit(1) - } - networkID, set := os.LookupEnv("NETWORK_ID") - if !set { - setupLog.Error(err, "NETWORK_ID not set", "controller", "IngressClass") - os.Exit(1) - } - // Create an ALB SDK client albOpts := []sdkconfig.ConfigurationOption{} + if albURL != "" { albOpts = append(albOpts, sdkconfig.WithEndpoint(albURL)) } @@ -251,7 +284,7 @@ func main() { } // Create an ALB client fmt.Printf("Create ALB client\n") - albClient, err := stackit.NewApplicationLoadBalancerClient(sdkClient) + albClient, err := albclient.NewApplicationLoadBalancerClient(sdkClient) if err != nil { setupLog.Error(err, "unable to create ALB client", "controller", "IngressClass") os.Exit(1) @@ -264,7 +297,7 @@ func main() { os.Exit(1) } // Create an Certificates API client - certificateClient, err := stackit.NewCertClient(certificateAPI) + certificateClient, err := albclient.NewCertClient(certificateAPI) if err != nil { setupLog.Error(err, "unable to create Certificates client", "controller", "IngressClass") os.Exit(1) @@ -275,9 +308,9 @@ func main() { ALBClient: albClient, CertificateClient: certificateClient, Scheme: mgr.GetScheme(), - ProjectID: projectID, - NetworkID: networkID, - Region: region, + ProjectID: config.ProjectID, + NetworkID: config.NetworkID, + Region: config.Region, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "IngressClass") os.Exit(1) From ea81edb748f4b4c197399c3ccdc205b50321d160 Mon Sep 17 00:00:00 2001 From: Kamil Przybyl Date: Wed, 18 Mar 2026 10:44:56 +0100 Subject: [PATCH 16/23] chore: add a short description for setIPAddresses function --- pkg/alb/ingress/alb_spec.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/alb/ingress/alb_spec.go b/pkg/alb/ingress/alb_spec.go index 89c458bc..cd1e7c12 100644 --- a/pkg/alb/ingress/alb_spec.go +++ b/pkg/alb/ingress/alb_spec.go @@ -415,6 +415,8 @@ func addTargetPool( }) } +// setIPAddresses configures the Application Load Balancer IP address +// based on IngressClass annotations: internal, ephemeral, or static public IPs. func setIPAddresses(ingressClass *networkingv1.IngressClass, alb *albsdk.CreateLoadBalancerPayload) error { isInternalIP, found := ingressClass.Annotations[internalIPAnnotation] if found && isInternalIP == "true" { From b24cf41250d3eb8e8780f77ce5a59d9e49d3c941 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Wed, 18 Mar 2026 11:12:30 +0100 Subject: [PATCH 17/23] Include envtest for controller tests --- Makefile | 8 +++----- hack/test.sh | 1 + hack/tools.mk | 5 +++++ 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/Makefile b/Makefile index 2c372b5f..fe1b9fb0 100644 --- a/Makefile +++ b/Makefile @@ -60,7 +60,7 @@ 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 @@ -79,9 +79,7 @@ check: lint test ## Check everything (lint + test). .PHONY: verify-fmt verify-fmt: fmt ## Verify go code is formatted. - @if !(git diff --quiet HEAD); then \ - echo "unformatted files detected, please run 'make fmt'"; exit 1; \ - fi + exit 0 .PHONY: verify-modules verify-modules: modules ## Verify go module files are up to date. @@ -133,7 +131,7 @@ mocks: $(MOCKGEN) # generate mocks @go mod download @for service in $(MOCK_SERVICES); do \ - INTERFACES=`go doc -all github.com/stackitcloud/stackit-sdk-go/services/$$service | grep '^type Api.* interface' | sed -n 's/^type \(.*\) interface.*/\1/p' | paste -sd,`,DefaultApi; \ + INTERFACES=`go doc -all github.com/stackitcloud/stackit-sdk-go/services/$$service | grep '^type Api.* interface' | sed -n 's/^type \(.*\) interface.*/\1/p' | gpaste -sd,`,DefaultApi; \ $(MOCKGEN) -destination ./pkg/mock/$$service/$$service.go -package $$service github.com/stackitcloud/stackit-sdk-go/services/$$service $$INTERFACES; \ done @$(MOCKGEN) -destination ./pkg/stackit/iaas_mock.go -package stackit ./pkg/stackit IaasClient diff --git a/hack/test.sh b/hack/test.sh index 10c78774..8db6ce76 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 9afb0ec8..ca8feb4a 100644 --- a/hack/tools.mk +++ b/hack/tools.mk @@ -14,6 +14,7 @@ MOCKGEN_VERSION ?= v0.6.0 APKO_VERSION ?= v1.1.15 # renovate: datasource=github-releases depName=ko-build/ko KO_VERSION ?= v0.18.1 +ENVTEST_VERSION ?= v0.0.0-20260317052337-b8d2b5b862fa KUBERNETES_TEST_VERSION ?= v1.33.5 @@ -53,6 +54,10 @@ 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) + KUBERNETES_TEST := $(TOOLS_BIN_DIR)/e2e.test KUBERNETES_TEST_GINKGO := $(TOOLS_BIN_DIR)/ginkgo $(KUBERNETES_TEST): $(call tool_version_file,$(KUBERNETES_TEST),$(KUBERNETES_TEST_VERSION)) From 0bf5fc6957976732ade9b2c288858be953ca9289 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Wed, 18 Mar 2026 11:15:40 +0100 Subject: [PATCH 18/23] Undo temporary changes --- Makefile | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index fe1b9fb0..76a09268 100644 --- a/Makefile +++ b/Makefile @@ -79,7 +79,9 @@ check: lint test ## Check everything (lint + test). .PHONY: verify-fmt verify-fmt: fmt ## Verify go code is formatted. - exit 0 + @if !(git diff --quiet HEAD); then \ + echo "unformatted files detected, please run 'make fmt'"; exit 1; \ + fi .PHONY: verify-modules verify-modules: modules ## Verify go module files are up to date. @@ -131,7 +133,7 @@ mocks: $(MOCKGEN) # generate mocks @go mod download @for service in $(MOCK_SERVICES); do \ - INTERFACES=`go doc -all github.com/stackitcloud/stackit-sdk-go/services/$$service | grep '^type Api.* interface' | sed -n 's/^type \(.*\) interface.*/\1/p' | gpaste -sd,`,DefaultApi; \ + INTERFACES=`go doc -all github.com/stackitcloud/stackit-sdk-go/services/$$service | grep '^type Api.* interface' | sed -n 's/^type \(.*\) interface.*/\1/p' | paste -sd,`,DefaultApi; \ $(MOCKGEN) -destination ./pkg/mock/$$service/$$service.go -package $$service github.com/stackitcloud/stackit-sdk-go/services/$$service $$INTERFACES; \ done @$(MOCKGEN) -destination ./pkg/stackit/iaas_mock.go -package stackit ./pkg/stackit IaasClient From a68cbc767a1d8f87787fddb923fff0d0a0805f53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Wed, 18 Mar 2026 11:23:45 +0100 Subject: [PATCH 19/23] Fix linter issues --- .../main.go | 29 +++++++++---------- pkg/alb/ingress/ingressclass_controller.go | 2 +- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/cmd/application-load-balancer-controller-manager/main.go b/cmd/application-load-balancer-controller-manager/main.go index 0b60b5f2..591faabc 100644 --- a/cmd/application-load-balancer-controller-manager/main.go +++ b/cmd/application-load-balancer-controller-manager/main.go @@ -18,6 +18,7 @@ package main import ( "crypto/tls" + "errors" "flag" "fmt" "io" @@ -65,40 +66,34 @@ type Config struct { } // ReadConfig reads the ALB infrastructure configuration provided via the cloud-config flag. -func ReadConfig(cloudConfig string) Config { +func ReadConfig(cloudConfig string) (Config, error) { configFile, err := os.Open(cloudConfig) if err != nil { - setupLog.Error(err, "Failed to open the cloud config file") - os.Exit(1) + return Config{}, err } defer configFile.Close() var config Config content, err := io.ReadAll(configFile) if err != nil { - setupLog.Error(err, "Failed to read config content") - os.Exit(1) + return Config{}, err } err = yaml.Unmarshal(content, &config) if err != nil { - setupLog.Error(err, "Failed to parse config as YAML") - os.Exit(1) + return Config{}, err } if config.ProjectID == "" { - setupLog.Error(err, "projectId must be set") - os.Exit(1) + return Config{}, errors.New("project ID must be set") } if config.Region == "" { - setupLog.Error(err, "region must be set") - os.Exit(1) + return Config{}, errors.New("region must be set") } if config.NetworkID == "" { - setupLog.Error(err, "networkId must be set") - os.Exit(1) + return Config{}, errors.New("network ID must be set") } - return config + return config, nil } // nolint:gocyclo,funlen // TODO: Refactor into smaller functions. @@ -144,7 +139,11 @@ func main() { ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) - config := ReadConfig(cloudConfig) + config, err := ReadConfig(cloudConfig) + if err != nil { + setupLog.Error(err, "Failed to read cloud config") + os.Exit(1) + } // if the enable-http2 flag is false (the default), http/2 should be disabled // due to its vulnerabilities. More specifically, disabling http/2 will diff --git a/pkg/alb/ingress/ingressclass_controller.go b/pkg/alb/ingress/ingressclass_controller.go index 7d7dfc6e..8dd06eb3 100644 --- a/pkg/alb/ingress/ingressclass_controller.go +++ b/pkg/alb/ingress/ingressclass_controller.go @@ -324,7 +324,7 @@ func (r *IngressClassReconciler) handleIngressClassDeletion( } // detectChange checks if there is any difference between the current and desired ALB configuration. -func detectChange(alb *albsdk.LoadBalancer, albPayload *albsdk.CreateLoadBalancerPayload) bool { //nolint:gocyclo // We check a lot of fields. Not much complexity. +func detectChange(alb *albsdk.LoadBalancer, albPayload *albsdk.CreateLoadBalancerPayload) bool { //nolint:gocyclo,funlen // We check a lot of fields. Not much complexity. if len(alb.Listeners) != len(albPayload.Listeners) { return true } From 6d9842d799408e8b27672f63d06d1274e3c40478 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fischer?= Date: Wed, 18 Mar 2026 11:31:53 +0100 Subject: [PATCH 20/23] Remove license from code --- .../main.go | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/cmd/application-load-balancer-controller-manager/main.go b/cmd/application-load-balancer-controller-manager/main.go index 591faabc..adfe6d96 100644 --- a/cmd/application-load-balancer-controller-manager/main.go +++ b/cmd/application-load-balancer-controller-manager/main.go @@ -1,19 +1,3 @@ -/* -Copyright 2025. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - package main import ( From 4a8352ccb26fc7865785365f9f1085a5d0ab2ad3 Mon Sep 17 00:00:00 2001 From: Kamil Przybyl Date: Fri, 20 Mar 2026 16:11:24 +0100 Subject: [PATCH 21/23] chore: adjust issuer sample --- samples/ingress/issuer.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/samples/ingress/issuer.yaml b/samples/ingress/issuer.yaml index 79b903c6..ccfa4d08 100644 --- a/samples/ingress/issuer.yaml +++ b/samples/ingress/issuer.yaml @@ -5,8 +5,8 @@ metadata: spec: acme: server: https://acme-v02.api.letsencrypt.org/directory - # server: https://acme-staging-v02.api.letsencrypt.org/directory - email: kamil.przybyl@stackit.cloud + # server: https://acme-staging-v02.api.letsencrypt.org/directory # Use this for testing to avoid hitting letsencrypt rate limits. + email: mail@example.com privateKeySecretRef: name: letsencrypt solvers: From f903b257b365f4e12614d80a6f1e9a8250999e37 Mon Sep 17 00:00:00 2001 From: Kamil Przybyl Date: Fri, 20 Mar 2026 17:26:55 +0100 Subject: [PATCH 22/23] chore: clarify isCertValid --- pkg/alb/ingress/alb_spec.go | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/pkg/alb/ingress/alb_spec.go b/pkg/alb/ingress/alb_spec.go index cd1e7c12..7e788e79 100644 --- a/pkg/alb/ingress/alb_spec.go +++ b/pkg/alb/ingress/alb_spec.go @@ -276,9 +276,9 @@ func (r *IngressClassReconciler) loadCerts( // The tls.crt should contain both the leaf certificate and the intermediate CA certificates. // If it contains only the leaf certificate, the ACME challenge likely hasn't finished. // Therefore the incomplete certificate shouldn't be loaded as the updates upon them are impossible. - complete, err := isCertValid(secret) + complete, err := isCertReady(secret) if err != nil { - return nil, fmt.Errorf("failed to validate certificate: %w", err) + return nil, fmt.Errorf("failed to check if certificate is ready: %w", err) } if !complete { // TODO: Requeue, instead of returning error - the ACME challenge hasn't finished yet @@ -351,9 +351,13 @@ func (r *IngressClassReconciler) cleanupCerts(ctx context.Context, ingressClass return nil } -// isCertValid checks if the certificate chain is complete. It is used for checking if -// the cert-manager's ACME challenge is completed, or if it's sill ongoing. -func isCertValid(secret *corev1.Secret) (bool, error) { +// isCertReady checks if the certificate chain is complete (leaf + intermediates). +// This is required during ACME challenges (e.g., cert-manager), where a race condition +// can occur where the Secret may temporarily contain only the leaf certificate before the +// full chain is written. Because the STACKIT Application Load Balancer Certificates API +// only validates the cryptographic key match and is immutable (no update call), +// we must wait for the full chain to avoid locking the ALB with an incomplete certificate. +func isCertReady(secret *corev1.Secret) (bool, error) { tlsCert := secret.Data["tls.crt"] if tlsCert == nil { return false, fmt.Errorf("tls.crt not found in secret") @@ -380,7 +384,8 @@ func isCertValid(secret *corev1.Secret) (bool, error) { certs = append(certs, cert) } - // If there are multiple certificates, it means the chain is likely complete + // A valid, trusted chain must contain at least 2 certificates: + // the leaf (domain) and at least one intermediate CA. return len(certs) > 1, nil } From 3c1cc68dda5ce4c1b48721d12600c391e648c998 Mon Sep 17 00:00:00 2001 From: Kamil Przybyl Date: Fri, 20 Mar 2026 20:26:14 +0100 Subject: [PATCH 23/23] chore: remove debug messages --- .../main.go | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/cmd/application-load-balancer-controller-manager/main.go b/cmd/application-load-balancer-controller-manager/main.go index adfe6d96..ffa751b6 100644 --- a/cmd/application-load-balancer-controller-manager/main.go +++ b/cmd/application-load-balancer-controller-manager/main.go @@ -4,7 +4,6 @@ import ( "crypto/tls" "errors" "flag" - "fmt" "io" "os" "path/filepath" @@ -244,12 +243,9 @@ func main() { } albURL, _ := os.LookupEnv("STACKIT_LOAD_BALANCER_API_ALB_URL") - certURL, _ := os.LookupEnv("STACKIT_LOAD_BALANCER_API_CERT_URL") - // Create an ALB SDK client albOpts := []sdkconfig.ConfigurationOption{} - if albURL != "" { albOpts = append(albOpts, sdkconfig.WithEndpoint(albURL)) } @@ -259,27 +255,24 @@ func main() { certOpts = append(certOpts, sdkconfig.WithEndpoint(certURL)) } - fmt.Printf("Create ALB SDK client\n") + // 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) } - // Create an ALB client - fmt.Printf("Create ALB client\n") albClient, err := albclient.NewApplicationLoadBalancerClient(sdkClient) if err != nil { setupLog.Error(err, "unable to create ALB client", "controller", "IngressClass") os.Exit(1) } - // Create an Certificates SDK client + // 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) } - // Create an Certificates API client certificateClient, err := albclient.NewCertClient(certificateAPI) if err != nil { setupLog.Error(err, "unable to create Certificates client", "controller", "IngressClass")