From 64b5f644152a53abe7ae8b49214c322115758f2d Mon Sep 17 00:00:00 2001 From: Kamil Przybyl Date: Tue, 17 Mar 2026 10:26:41 +0100 Subject: [PATCH 01/36] add application load balancer controller manager --- .../main.go | 316 ++++++++++++ go.mod | 11 +- go.sum | 22 +- 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, 2866 insertions(+), 4 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 6217d00c..6887d82c 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.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 @@ -14,6 +15,8 @@ require ( github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 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 @@ -30,6 +33,7 @@ require ( k8s.io/klog/v2 v2.140.0 k8s.io/mount-utils v0.35.3 k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 + sigs.k8s.io/controller-runtime v0.23.3 ) replace k8s.io/cloud-provider => github.com/stackitcloud/cloud-provider v0.35.1-ske-1 @@ -48,11 +52,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 @@ -75,7 +81,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 @@ -121,12 +126,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 @@ -135,6 +142,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 e4bfa5a3..d30df822 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= @@ -188,6 +196,10 @@ github.com/stackitcloud/cloud-provider v0.35.1-ske-1 h1:Oo71mALP7hh50wAeGOogng2s github.com/stackitcloud/cloud-provider v0.35.1-ske-1/go.mod h1:zGF/i9YuBODKxj7szGMMIz4DRnjsDy5mg2JU+XbbULA= 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.3 h1:pA2fiBc6+N9PDf7SAiluKGEBuScsTzd2uYBkA5RzNWQ= k8s.io/api v0.35.3/go.mod h1:9Y9tkBcFwKNq2sxwZTQh1Njh9qHl81D0As56tu42GA4= +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.3 h1:MeaUwQCV3tjKP4bcwWGgZ/cp/vpsRnQzqO6J6tJyoF8= k8s.io/apimachinery v0.35.3/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= k8s.io/apiserver v0.35.1 h1:potxdhhTL4i6AYAa2QCwtlhtB1eCdWQFvJV6fXgJzxs= @@ -378,11 +394,13 @@ k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 h1:kBawHLSnx/mYHmRnNUf9d4CpjREbe k8s.io/utils v0.0.0-20260319190234-28399d86e0b5/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 d623425565392442fcbe4618521b7166f2a88e27 Mon Sep 17 00:00:00 2001 From: Kamil Przybyl Date: Tue, 17 Mar 2026 10:35:34 +0100 Subject: [PATCH 02/36] 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 cbf5622d469e2e20d91f9f2a5f5c4b8277cc7bf0 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/36] 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 d7971dfd721d721417bdfe5e675b2690681dd8c6 Mon Sep 17 00:00:00 2001 From: Kamil Przybyl Date: Tue, 17 Mar 2026 10:52:42 +0100 Subject: [PATCH 04/36] 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 05be587006657db904d91a01569d650f1c6e776c 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/36] 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 9440cacafd7f3ac4e04da3ac9cacfa095246130d 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/36] 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 cd270f179fa2e4ff90944237ac408ca79c32247d 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/36] 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 cf3d7ac0eedce45d9c32856831d6e37f41ebf670 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/36] 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 bb40d219e4d1e6a3b79ef521969b3ba67016ca1b 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/36] 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 c18c3933f909412b54e7e567af3d21ee8fe67526 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/36] 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 6bd4ba5afed96768449ca8d240229043343d5fef 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/36] 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 bd2bf6c8cbb0d7c8ba97c1a7ddab92efe0a3f75f Mon Sep 17 00:00:00 2001 From: Menekse Ceylan Date: Tue, 17 Mar 2026 15:26:24 +0100 Subject: [PATCH 12/36] 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 4302bb9ace4bbea524211e8f1accbf03758c8c15 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/36] 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 76f737ef5822f2d9f31ea01f8bb4bf09e9c7c051 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/36] 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 5744c59af23e50b9bbd63fad32a71114acdfc0ac Mon Sep 17 00:00:00 2001 From: Kamil Przybyl Date: Wed, 18 Mar 2026 10:24:07 +0100 Subject: [PATCH 15/36] 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 a4c3eeeb9c44891e9155d527e4003f650908e5d1 Mon Sep 17 00:00:00 2001 From: Kamil Przybyl Date: Wed, 18 Mar 2026 10:44:56 +0100 Subject: [PATCH 16/36] 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 76e1f497d9093aa30185e45b70b1711231b90cd1 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/36] 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 3033973a..ec601d9d 100644 --- a/hack/tools.mk +++ b/hack/tools.mk @@ -14,6 +14,7 @@ MOCKGEN_VERSION ?= v0.6.0 APKO_VERSION ?= v1.1.16 # 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 1b1f8da075de67b4069e8d95d64bd13b9b51d301 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/36] 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 931b420972fbbb5afc8939371f6f7506bcacf8f0 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/36] 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 a19d91f4fc0dab5fef32d6879287b3ceb1c71b21 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/36] 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 261c70197bd9cf4ef0a01db7a26bd24184eb3cba Mon Sep 17 00:00:00 2001 From: Kamil Przybyl Date: Fri, 20 Mar 2026 16:11:24 +0100 Subject: [PATCH 21/36] 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 8eeb62cc3c1e8fc83cd70eb84ae69f1a8fe43941 Mon Sep 17 00:00:00 2001 From: Kamil Przybyl Date: Fri, 20 Mar 2026 17:26:55 +0100 Subject: [PATCH 22/36] 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 f7d1a7660d2e58774d5972e0e640fb66d09f6741 Mon Sep 17 00:00:00 2001 From: Kamil Przybyl Date: Fri, 20 Mar 2026 20:26:14 +0100 Subject: [PATCH 23/36] 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") From 1ff73e6b0e8bebcd20d7ef7cf60f076f8051486b Mon Sep 17 00:00:00 2001 From: Kamil Przybyl Date: Sat, 21 Mar 2026 13:53:25 +0100 Subject: [PATCH 24/36] fix: certificates not created because loadCerts skips all ingress tls references; adjusted requeuing logic for certificates --- pkg/alb/ingress/alb_spec.go | 41 ++++++++++------------ pkg/alb/ingress/alb_spec_test.go | 2 +- pkg/alb/ingress/ingressclass_controller.go | 5 ++- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/pkg/alb/ingress/alb_spec.go b/pkg/alb/ingress/alb_spec.go index 7e788e79..387d1f09 100644 --- a/pkg/alb/ingress/alb_spec.go +++ b/pkg/alb/ingress/alb_spec.go @@ -71,7 +71,7 @@ func (r *IngressClassReconciler) albSpecFromIngress( //nolint:funlen,gocyclo // networkID *string, nodes []corev1.Node, services map[string]corev1.Service, -) (*albsdk.CreateLoadBalancerPayload, error) { +) (bool, *albsdk.CreateLoadBalancerPayload, error) { targetPools := []albsdk.TargetPool{} targetPoolSeen := map[string]bool{} allCertificateIDs := []string{} @@ -111,7 +111,7 @@ func (r *IngressClassReconciler) albSpecFromIngress( //nolint:funlen,gocyclo // for j, path := range rule.HTTP.Paths { nodePort, err := getNodePort(services, path) if err != nil { - return nil, err + return false, nil, err } targetPoolName := fmt.Sprintf("pool-%d", nodePort) @@ -140,12 +140,13 @@ func (r *IngressClassReconciler) albSpecFromIngress( //nolint:funlen,gocyclo // } // 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) - //nolint:gocritic // TODO: Rework error handling. - // return nil, fmt.Errorf("failed to load tls certificates: %w", err) - } + requeueNeeded, certificateIDs, err := r.loadCerts(ctx, ingressClass, ingress) + if requeueNeeded { + return true, nil, nil + } + if err != nil { + return false, nil, fmt.Errorf("failed to load tls certificates: %w", err) + } allCertificateIDs = append(allCertificateIDs, certificateIDs...) } @@ -245,14 +246,14 @@ func (r *IngressClassReconciler) albSpecFromIngress( //nolint:funlen,gocyclo // // 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) + return false, 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 + return false, alb, nil } // laodCerts loads the tls certificates from Ingress to the Certificates API @@ -260,30 +261,26 @@ func (r *IngressClassReconciler) loadCerts( ctx context.Context, ingressClass *networkingv1.IngressClass, ingress *networkingv1.Ingress, -) ([]string, error) { +) (bool, []string, error) { certificateIDs := []string{} for _, tls := range ingress.Spec.TLS { - if tls.SecretName != "" { + if tls.SecretName == "" { continue } 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) + return false, 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 := isCertReady(secret) if err != nil { - return nil, fmt.Errorf("failed to check if certificate is ready: %w", err) + return false, 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 - // return ctrl.Result{RequeueAfter: 10 * time.Second}, nil - return nil, fmt.Errorf("certificate is not complete: %w", err) + // Requeue: The ACME challenge is still in progress and the certificate is not yet fully issued. + return true, nil, fmt.Errorf("certificate is not complete: %w", err) } createCertificatePayload := &certsdk.CreateCertificatePayload{ @@ -294,12 +291,12 @@ func (r *IngressClassReconciler) loadCerts( } res, err := r.CertificateClient.CreateCertificate(ctx, r.ProjectID, r.Region, createCertificatePayload) if err != nil { - return nil, fmt.Errorf("failed to create certificate: %w", err) + return false, nil, fmt.Errorf("failed to create certificate: %w", err) } certificateIDs = append(certificateIDs, *res.Id) } - return certificateIDs, nil + return false, certificateIDs, nil } // cleanupCerts deletes the certificates from the Certificates API that are no longer associated with any Ingress in the IngressClass diff --git a/pkg/alb/ingress/alb_spec_test.go b/pkg/alb/ingress/alb_spec_test.go index c5fcb35a..69a4f628 100644 --- a/pkg/alb/ingress/alb_spec_test.go +++ b/pkg/alb/ingress/alb_spec_test.go @@ -408,7 +408,7 @@ func Test_albSpecFromIngress(t *testing.T) { 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) + _, 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 diff --git a/pkg/alb/ingress/ingressclass_controller.go b/pkg/alb/ingress/ingressclass_controller.go index 8dd06eb3..0e9c23b8 100644 --- a/pkg/alb/ingress/ingressclass_controller.go +++ b/pkg/alb/ingress/ingressclass_controller.go @@ -184,7 +184,10 @@ func (r *IngressClassReconciler) handleIngressClassWithIngresses( } // Create ALB payload from Ingresses - albPayload, err := r.albSpecFromIngress(ctx, ingresses, ingressClass, &r.NetworkID, nodes.Items, services) + requeueNeeded, albPayload, err := r.albSpecFromIngress(ctx, ingresses, ingressClass, &r.NetworkID, nodes.Items, services) + if requeueNeeded { + return ctrl.Result{RequeueAfter: 10 * time.Second}, nil + } if err != nil { return ctrl.Result{}, fmt.Errorf("failed to create alb payload: %w", err) } From 838e4c5fa790f170c79ea8acc825545cc816254f Mon Sep 17 00:00:00 2001 From: Kamil Przybyl Date: Sun, 22 Mar 2026 10:50:16 +0100 Subject: [PATCH 25/36] fix: certificate deletion logic --- pkg/alb/ingress/alb_spec.go | 73 ++++++++-------------- pkg/alb/ingress/ingressclass_controller.go | 11 ++-- 2 files changed, 31 insertions(+), 53 deletions(-) diff --git a/pkg/alb/ingress/alb_spec.go b/pkg/alb/ingress/alb_spec.go index 387d1f09..4e7615fe 100644 --- a/pkg/alb/ingress/alb_spec.go +++ b/pkg/alb/ingress/alb_spec.go @@ -5,7 +5,6 @@ import ( "crypto/x509" "encoding/pem" "fmt" - "log" "net/netip" "sort" "strconv" @@ -299,53 +298,31 @@ func (r *IngressClassReconciler) loadCerts( return false, 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 == "" { - 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 - } - } - - 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 +// cleanupCerts deletes all certificates from the Certificates API that are associated with this IngressClass. +func (r *IngressClassReconciler) cleanupCerts(ctx context.Context, ingressClass *networkingv1.IngressClass) error { + // We use the IngressClass UID to identify certificates for this specific class. + // A shortened version is used because that is how the names were generated on creation. + // Note: While a UID collision between clusters is technically possible, it is almost impossible in practice. + classPrefix := generateShortUID(ingressClass.UID) + + 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 { + if strings.HasPrefix(*cert.Name, classPrefix) { + err := r.CertificateClient.DeleteCertificate(ctx, r.ProjectID, r.Region, *cert.Id) + if err != nil { + return fmt.Errorf("failed to delete orphaned certificate %s: %v", *cert.Name, err) + } + } + } + return nil } // isCertReady checks if the certificate chain is complete (leaf + intermediates). diff --git a/pkg/alb/ingress/ingressclass_controller.go b/pkg/alb/ingress/ingressclass_controller.go index 0e9c23b8..867160ed 100644 --- a/pkg/alb/ingress/ingressclass_controller.go +++ b/pkg/alb/ingress/ingressclass_controller.go @@ -39,7 +39,7 @@ import ( ) const ( - // finalizerName is the name of the finalizer that is added to the IngressClass + // finalizerName is the name of the finalizer that is added to Ingress and IngressClass finalizerName = "stackit.cloud/alb-ingress" // controllerName is the name of the ALB controller that the IngressClass should point to for reconciliation controllerName = "stackit.cloud/alb-ingress" @@ -105,7 +105,7 @@ func (r *IngressClassReconciler) Reconcile(ctx context.Context, req ctrl.Request } if len(albIngressList) < 1 { - err := r.handleIngressClassWithoutIngresses(ctx, albIngressList, ingressClass) + err := r.handleIngressClassWithoutIngresses(ctx, ingressClass) if err != nil { return ctrl.Result{}, fmt.Errorf("failed to reconcile %s IngressClass with no Ingresses: %w", getAlbName(ingressClass), err) } @@ -281,17 +281,18 @@ func (r *IngressClassReconciler) updateStatus(ctx context.Context, ingresses []* return ctrl.Result{}, nil } -// handleIngressClassWithoutIngresses handles the state of the IngressClass that is not referenced by any Ingress +// handleIngressClassWithoutIngresses handles the case where an IngressClass exists +// but is not referenced by any Ingresses. In this scenario, we delete the associated ALB +// and clean up certificates to avoid billing for unused resources. 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) + err = r.cleanupCerts(ctx, ingressClass) if err != nil { return fmt.Errorf("failed to clean up certificates: %w", err) } From 9d6f7670900a2fb8a91574328b3e157e30855ab4 Mon Sep 17 00:00:00 2001 From: Kamil Przybyl Date: Sun, 22 Mar 2026 11:43:42 +0100 Subject: [PATCH 26/36] chore: adjsut externalIPAnnotation comment --- pkg/alb/ingress/alb_spec.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/alb/ingress/alb_spec.go b/pkg/alb/ingress/alb_spec.go index 4e7615fe..9131e757 100644 --- a/pkg/alb/ingress/alb_spec.go +++ b/pkg/alb/ingress/alb_spec.go @@ -20,7 +20,7 @@ import ( ) const ( - // externalIPAnnotation references an OpenStack floating IP that should be used by the application load balancer. + // externalIPAnnotation references a STACKIT 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. From 98c71c0b9caa08ac9aa8eee5d35b37f0ab5e089e Mon Sep 17 00:00:00 2001 From: Maximilian Geberl Date: Thu, 26 Mar 2026 11:04:30 +0100 Subject: [PATCH 27/36] Adopt config to config structure --- .../main.go | 45 +++++-------- pkg/alb/ingress/alb_spec.go | 64 +++++++++---------- pkg/alb/ingress/controller_test.go | 13 +++- pkg/alb/ingress/ingressclass_controller.go | 21 +++--- .../ingress/ingressclass_controller_test.go | 9 ++- pkg/stackit/config/config.go | 15 ++++- 6 files changed, 89 insertions(+), 78 deletions(-) diff --git a/cmd/application-load-balancer-controller-manager/main.go b/cmd/application-load-balancer-controller-manager/main.go index ffa751b6..f96acbaf 100644 --- a/cmd/application-load-balancer-controller-manager/main.go +++ b/cmd/application-load-balancer-controller-manager/main.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" + stackitconfig "github.com/stackitcloud/cloud-provider-stackit/pkg/stackit/config" // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) // to ensure that exec-entrypoint and run can make use of them. sdkconfig "github.com/stackitcloud/stackit-sdk-go/core/config" @@ -42,39 +43,33 @@ 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, error) { +func ReadConfig(cloudConfig string) (stackitconfig.ALBConfig, error) { configFile, err := os.Open(cloudConfig) if err != nil { - return Config{}, err + return stackitconfig.ALBConfig{}, err } defer configFile.Close() - var config Config + var config stackitconfig.ALBConfig content, err := io.ReadAll(configFile) if err != nil { - return Config{}, err + return stackitconfig.ALBConfig{}, err } err = yaml.Unmarshal(content, &config) if err != nil { - return Config{}, err + return stackitconfig.ALBConfig{}, err } - if config.ProjectID == "" { - return Config{}, errors.New("project ID must be set") + if config.Global.ProjectID == "" { + return stackitconfig.ALBConfig{}, errors.New("project ID must be set") } - if config.Region == "" { - return Config{}, errors.New("region must be set") + if config.Global.Region == "" { + return stackitconfig.ALBConfig{}, errors.New("region must be set") } - if config.NetworkID == "" { - return Config{}, errors.New("network ID must be set") + if config.ApplicationLoadBalancer.NetworkID == "" { + return stackitconfig.ALBConfig{}, errors.New("network ID must be set") } return config, nil } @@ -241,18 +236,14 @@ func main() { 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") - albOpts := []sdkconfig.ConfigurationOption{} - if albURL != "" { - albOpts = append(albOpts, sdkconfig.WithEndpoint(albURL)) + if config.Global.APIEndpoints.ApplicationLoadBalancerAPI != "" { + albOpts = append(albOpts, sdkconfig.WithEndpoint(config.Global.APIEndpoints.ApplicationLoadBalancerAPI)) } certOpts := []sdkconfig.ConfigurationOption{} - if certURL != "" { - certOpts = append(certOpts, sdkconfig.WithEndpoint(certURL)) + if config.Global.APIEndpoints.ApplicationLoadBalancerCertificateAPI != "" { + certOpts = append(certOpts, sdkconfig.WithEndpoint(config.Global.APIEndpoints.ApplicationLoadBalancerCertificateAPI)) } // Setup ALB API client @@ -284,9 +275,7 @@ func main() { ALBClient: albClient, CertificateClient: certificateClient, Scheme: mgr.GetScheme(), - ProjectID: config.ProjectID, - NetworkID: config.NetworkID, - Region: config.Region, + ALBConfig: config, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "IngressClass") os.Exit(1) diff --git a/pkg/alb/ingress/alb_spec.go b/pkg/alb/ingress/alb_spec.go index 9131e757..d618cdb6 100644 --- a/pkg/alb/ingress/alb_spec.go +++ b/pkg/alb/ingress/alb_spec.go @@ -141,11 +141,11 @@ func (r *IngressClassReconciler) albSpecFromIngress( //nolint:funlen,gocyclo // // Apend certificates from the current Ingress to the combined certificates requeueNeeded, certificateIDs, err := r.loadCerts(ctx, ingressClass, ingress) if requeueNeeded { - return true, nil, nil - } - if err != nil { - return false, nil, fmt.Errorf("failed to load tls certificates: %w", err) - } + return true, nil, nil + } + if err != nil { + return false, nil, fmt.Errorf("failed to load tls certificates: %w", err) + } allCertificateIDs = append(allCertificateIDs, certificateIDs...) } @@ -284,11 +284,11 @@ func (r *IngressClassReconciler) loadCerts( createCertificatePayload := &certsdk.CreateCertificatePayload{ Name: ptr.To(getCertName(ingressClass, ingress, secret)), - ProjectId: &r.ProjectID, + ProjectId: &r.ALBConfig.Global.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) + res, err := r.CertificateClient.CreateCertificate(ctx, r.ALBConfig.Global.ProjectID, r.ALBConfig.Global.Region, createCertificatePayload) if err != nil { return false, nil, fmt.Errorf("failed to create certificate: %w", err) } @@ -300,34 +300,34 @@ func (r *IngressClassReconciler) loadCerts( // cleanupCerts deletes all certificates from the Certificates API that are associated with this IngressClass. func (r *IngressClassReconciler) cleanupCerts(ctx context.Context, ingressClass *networkingv1.IngressClass) error { - // We use the IngressClass UID to identify certificates for this specific class. + // We use the IngressClass UID to identify certificates for this specific class. // A shortened version is used because that is how the names were generated on creation. // Note: While a UID collision between clusters is technically possible, it is almost impossible in practice. - classPrefix := generateShortUID(ingressClass.UID) - - 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 { - if strings.HasPrefix(*cert.Name, classPrefix) { - err := r.CertificateClient.DeleteCertificate(ctx, r.ProjectID, r.Region, *cert.Id) - if err != nil { - return fmt.Errorf("failed to delete orphaned certificate %s: %v", *cert.Name, err) - } - } - } - return nil + classPrefix := generateShortUID(ingressClass.UID) + + certificatesList, err := r.CertificateClient.ListCertificate(ctx, r.ALBConfig.Global.ProjectID, r.ALBConfig.Global.Region) + if err != nil { + return fmt.Errorf("failed to list certificates: %w", err) + } + + if certificatesList == nil || certificatesList.Items == nil { + return nil // No certificates to clean up + } + + for _, cert := range certificatesList.Items { + if strings.HasPrefix(*cert.Name, classPrefix) { + err := r.CertificateClient.DeleteCertificate(ctx, r.ALBConfig.Global.ProjectID, r.ALBConfig.Global.Region, *cert.Id) + if err != nil { + return fmt.Errorf("failed to delete orphaned certificate %s: %v", *cert.Name, err) + } + } + } + return nil } // 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 +// 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. @@ -358,8 +358,8 @@ func isCertReady(secret *corev1.Secret) (bool, error) { certs = append(certs, cert) } - // A valid, trusted chain must contain at least 2 certificates: - // the leaf (domain) and at least one intermediate CA. + // A valid, trusted chain must contain at least 2 certificates: + // the leaf (domain) and at least one intermediate CA. return len(certs) > 1, nil } diff --git a/pkg/alb/ingress/controller_test.go b/pkg/alb/ingress/controller_test.go index 0c2573d9..c3fc9811 100644 --- a/pkg/alb/ingress/controller_test.go +++ b/pkg/alb/ingress/controller_test.go @@ -5,6 +5,7 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + stackitconfig "github.com/stackitcloud/cloud-provider-stackit/pkg/stackit/config" corev1 "k8s.io/api/core/v1" networkingv1 "k8s.io/api/networking/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -79,9 +80,15 @@ var _ = Describe("IngressClassReconciler", func() { Scheme: scheme.Scheme, ALBClient: albClient, CertificateClient: certClient, - ProjectID: projectID, - Region: region, - NetworkID: "dummy-network", + ALBConfig: stackitconfig.ALBConfig{ + Global: stackitconfig.GlobalOpts{ + ProjectID: projectID, + Region: region, + }, + ApplicationLoadBalancer: stackitconfig.ApplicationLoadBalancerOpts{ + NetworkID: "dummy-network", + }, + }, } Expect(reconciler.SetupWithManager(mgr)).To(Succeed()) diff --git a/pkg/alb/ingress/ingressclass_controller.go b/pkg/alb/ingress/ingressclass_controller.go index 867160ed..5aab7b75 100644 --- a/pkg/alb/ingress/ingressclass_controller.go +++ b/pkg/alb/ingress/ingressclass_controller.go @@ -24,6 +24,7 @@ import ( "fmt" "time" + stackitconfig "github.com/stackitcloud/cloud-provider-stackit/pkg/stackit/config" corev1 "k8s.io/api/core/v1" networkingv1 "k8s.io/api/networking/v1" "k8s.io/apimachinery/pkg/runtime" @@ -51,9 +52,7 @@ type IngressClassReconciler struct { //nolint:revive // Naming this ClassReconci ALBClient stackit.ApplicationLoadBalancerClient CertificateClient stackit.CertificatesClient Scheme *runtime.Scheme - ProjectID string - NetworkID string - Region string + ALBConfig stackitconfig.ALBConfig } // +kubebuilder:rbac:groups=networking.k8s.io.stackit.cloud,resources=ingressclasses,verbs=get;list;watch;create;update;patch;delete @@ -184,7 +183,7 @@ func (r *IngressClassReconciler) handleIngressClassWithIngresses( } // Create ALB payload from Ingresses - requeueNeeded, albPayload, err := r.albSpecFromIngress(ctx, ingresses, ingressClass, &r.NetworkID, nodes.Items, services) + requeueNeeded, albPayload, err := r.albSpecFromIngress(ctx, ingresses, ingressClass, &r.ALBConfig.ApplicationLoadBalancer.NetworkID, nodes.Items, services) if requeueNeeded { return ctrl.Result{RequeueAfter: 10 * time.Second}, nil } @@ -193,9 +192,9 @@ func (r *IngressClassReconciler) handleIngressClassWithIngresses( } // Create ALB if it doesn't exist - alb, err := r.ALBClient.GetLoadBalancer(ctx, r.ProjectID, r.Region, getAlbName(ingressClass)) + alb, err := r.ALBClient.GetLoadBalancer(ctx, r.ALBConfig.Global.ProjectID, r.ALBConfig.Global.Region, getAlbName(ingressClass)) if errors.Is(err, stackit.ErrorNotFound) { - _, err := r.ALBClient.CreateLoadBalancer(ctx, r.ProjectID, r.Region, albPayload) + _, err := r.ALBClient.CreateLoadBalancer(ctx, r.ALBConfig.Global.ProjectID, r.ALBConfig.Global.Region, albPayload) if err != nil { return ctrl.Result{}, fmt.Errorf("failed to create load balancer: %w", err) } @@ -217,7 +216,7 @@ func (r *IngressClassReconciler) handleIngressClassWithIngresses( Version: alb.Version, } - if _, err := r.ALBClient.UpdateLoadBalancer(ctx, r.ProjectID, r.Region, getAlbName(ingressClass), updatePayload); err != nil { + if _, err := r.ALBClient.UpdateLoadBalancer(ctx, r.ALBConfig.Global.ProjectID, r.ALBConfig.Global.Region, getAlbName(ingressClass), updatePayload); err != nil { return ctrl.Result{}, fmt.Errorf("failed to update load balancer: %w", err) } } @@ -231,7 +230,7 @@ func (r *IngressClassReconciler) handleIngressClassWithIngresses( // 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)) + alb, err := r.ALBClient.GetLoadBalancer(ctx, r.ALBConfig.Global.ProjectID, r.ALBConfig.Global.Region, getAlbName(ingressClass)) if err != nil { return ctrl.Result{}, fmt.Errorf("failed to get load balancer: %w", err) } @@ -281,14 +280,14 @@ func (r *IngressClassReconciler) updateStatus(ctx context.Context, ingresses []* return ctrl.Result{}, nil } -// handleIngressClassWithoutIngresses handles the case where an IngressClass exists +// handleIngressClassWithoutIngresses handles the case where an IngressClass exists // but is not referenced by any Ingresses. In this scenario, we delete the associated ALB // and clean up certificates to avoid billing for unused resources. func (r *IngressClassReconciler) handleIngressClassWithoutIngresses( ctx context.Context, ingressClass *networkingv1.IngressClass, ) error { - err := r.ALBClient.DeleteLoadBalancer(ctx, r.ProjectID, r.Region, getAlbName(ingressClass)) + err := r.ALBClient.DeleteLoadBalancer(ctx, r.ALBConfig.Global.ProjectID, r.ALBConfig.Global.Region, getAlbName(ingressClass)) if err != nil { return fmt.Errorf("failed to delete load balancer: %w", err) } @@ -310,7 +309,7 @@ func (r *IngressClassReconciler) handleIngressClassDeletion( ) 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)) + err := r.ALBClient.DeleteLoadBalancer(ctx, r.ALBConfig.Global.ProjectID, r.ALBConfig.Global.Region, getAlbName(ingressClass)) if err != nil { return fmt.Errorf("failed to delete load balancer: %w", err) } diff --git a/pkg/alb/ingress/ingressclass_controller_test.go b/pkg/alb/ingress/ingressclass_controller_test.go index fc6d8bf8..5c1e2cc1 100644 --- a/pkg/alb/ingress/ingressclass_controller_test.go +++ b/pkg/alb/ingress/ingressclass_controller_test.go @@ -6,6 +6,7 @@ import ( "time" "github.com/google/go-cmp/cmp" + stackitconfig "github.com/stackitcloud/cloud-provider-stackit/pkg/stackit/config" gomock "go.uber.org/mock/gomock" networkingv1 "k8s.io/api/networking/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -213,8 +214,12 @@ func TestIngressClassReconciler_updateStatus(t *testing.T) { r := &IngressClassReconciler{ Client: fakeClient, ALBClient: mockAlbClient, - ProjectID: testProjectID, - Region: testRegion, + ALBConfig: stackitconfig.ALBConfig{ + Global: stackitconfig.GlobalOpts{ + ProjectID: testProjectID, + Region: testRegion, + }, + }, } if tt.mockK8sClient != nil { diff --git a/pkg/stackit/config/config.go b/pkg/stackit/config/config.go index cad4a805..5a1aba80 100644 --- a/pkg/stackit/config/config.go +++ b/pkg/stackit/config/config.go @@ -11,8 +11,10 @@ type GlobalOpts struct { } type APIEndpoints struct { - IaasAPI string `yaml:"iaasApi"` - LoadBalancerAPI string `yaml:"loadBalancerApi"` + IaasAPI string `yaml:"iaasApi"` + LoadBalancerAPI string `yaml:"loadBalancerApi"` + ApplicationLoadBalancerAPI string `yaml:"applicationLoadBalancerApi"` + ApplicationLoadBalancerCertificateAPI string `yaml:"applicationLoadBalancerCertificateApi"` } type CCMConfig struct { @@ -35,3 +37,12 @@ type CSIConfig struct { type BlockStorageOpts struct { RescanOnResize bool `yaml:"rescanOnResize"` } + +type ALBConfig struct { + Global GlobalOpts `yaml:"global"` + Metadata metadata.Opts `yaml:"metadata"` + ApplicationLoadBalancer ApplicationLoadBalancerOpts `yaml:"applicationLoadBalancer"` +} +type ApplicationLoadBalancerOpts struct { + NetworkID string `yaml:"networkId"` +} From e8233474bd41e359fbfd2050ee10e96021cc9b6d Mon Sep 17 00:00:00 2001 From: Maximilian Geberl Date: Thu, 26 Mar 2026 11:20:27 +0100 Subject: [PATCH 28/36] Move ReadConfig to config package --- .../main.go | 36 +---------------- pkg/stackit/config/config.go | 39 +++++++++++++++++++ 2 files changed, 40 insertions(+), 35 deletions(-) diff --git a/cmd/application-load-balancer-controller-manager/main.go b/cmd/application-load-balancer-controller-manager/main.go index f96acbaf..06057a5c 100644 --- a/cmd/application-load-balancer-controller-manager/main.go +++ b/cmd/application-load-balancer-controller-manager/main.go @@ -2,9 +2,7 @@ package main import ( "crypto/tls" - "errors" "flag" - "io" "os" "path/filepath" @@ -12,7 +10,6 @@ import ( // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) // to ensure that exec-entrypoint and run can make use of them. 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" @@ -43,37 +40,6 @@ func init() { // +kubebuilder:scaffold:scheme } -// ReadConfig reads the ALB infrastructure configuration provided via the cloud-config flag. -func ReadConfig(cloudConfig string) (stackitconfig.ALBConfig, error) { - configFile, err := os.Open(cloudConfig) - if err != nil { - return stackitconfig.ALBConfig{}, err - } - defer configFile.Close() - - var config stackitconfig.ALBConfig - content, err := io.ReadAll(configFile) - if err != nil { - return stackitconfig.ALBConfig{}, err - } - - err = yaml.Unmarshal(content, &config) - if err != nil { - return stackitconfig.ALBConfig{}, err - } - - if config.Global.ProjectID == "" { - return stackitconfig.ALBConfig{}, errors.New("project ID must be set") - } - if config.Global.Region == "" { - return stackitconfig.ALBConfig{}, errors.New("region must be set") - } - if config.ApplicationLoadBalancer.NetworkID == "" { - return stackitconfig.ALBConfig{}, errors.New("network ID must be set") - } - return config, nil -} - // nolint:gocyclo,funlen // TODO: Refactor into smaller functions. func main() { var metricsAddr string @@ -117,7 +83,7 @@ func main() { ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) - config, err := ReadConfig(cloudConfig) + config, err := stackitconfig.ReadALBConfigFromFile(cloudConfig) if err != nil { setupLog.Error(err, "Failed to read cloud config") os.Exit(1) diff --git a/pkg/stackit/config/config.go b/pkg/stackit/config/config.go index 5a1aba80..db543fab 100644 --- a/pkg/stackit/config/config.go +++ b/pkg/stackit/config/config.go @@ -1,7 +1,12 @@ package config import ( + "errors" + "io" + "os" + "github.com/stackitcloud/cloud-provider-stackit/pkg/stackit/metadata" + "gopkg.in/yaml.v3" ) type GlobalOpts struct { @@ -46,3 +51,37 @@ type ALBConfig struct { type ApplicationLoadBalancerOpts struct { NetworkID string `yaml:"networkId"` } + +func readFile(path string) ([]byte, error) { + file, err := os.Open(path) + if err != nil { + return []byte{}, err + } + defer file.Close() + + return io.ReadAll(file) +} + +func ReadALBConfigFromFile(path string) (ALBConfig, error) { + content, err := readFile(path) + if err != nil { + return ALBConfig{}, err + } + + config := ALBConfig{} + err = yaml.Unmarshal(content, &config) + if err != nil { + return ALBConfig{}, err + } + + if config.Global.ProjectID == "" { + return ALBConfig{}, errors.New("project ID must be set") + } + if config.Global.Region == "" { + return ALBConfig{}, errors.New("region must be set") + } + if config.ApplicationLoadBalancer.NetworkID == "" { + return ALBConfig{}, errors.New("network ID must be set") + } + return config, nil +} From c66f7760898b145d6bc8d461f4ca456d05a53bc8 Mon Sep 17 00:00:00 2001 From: Maximilian Geberl Date: Thu, 26 Mar 2026 11:31:30 +0100 Subject: [PATCH 29/36] Remove unused webhook --- .../main.go | 51 ++----------------- 1 file changed, 5 insertions(+), 46 deletions(-) diff --git a/cmd/application-load-balancer-controller-manager/main.go b/cmd/application-load-balancer-controller-manager/main.go index 06057a5c..ebcbcc9d 100644 --- a/cmd/application-load-balancer-controller-manager/main.go +++ b/cmd/application-load-balancer-controller-manager/main.go @@ -6,10 +6,14 @@ import ( "os" "path/filepath" + "github.com/stackitcloud/cloud-provider-stackit/pkg/alb/ingress" + albclient "github.com/stackitcloud/cloud-provider-stackit/pkg/stackit" stackitconfig "github.com/stackitcloud/cloud-provider-stackit/pkg/stackit/config" // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) // to ensure that exec-entrypoint and run can make use of them. sdkconfig "github.com/stackitcloud/stackit-sdk-go/core/config" + albsdk "github.com/stackitcloud/stackit-sdk-go/services/alb/v2api" + certsdk "github.com/stackitcloud/stackit-sdk-go/services/certificates/v2api" "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" @@ -20,12 +24,6 @@ import ( "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" - - "github.com/stackitcloud/cloud-provider-stackit/pkg/alb/ingress" - 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 ) @@ -44,7 +42,6 @@ func init() { func main() { var metricsAddr string var metricsCertPath, metricsCertName, metricsCertKey string - var webhookCertPath, webhookCertName, webhookCertKey string var enableLeaderElection bool var leaderElectionNamespace string var leaderElectionID string @@ -65,9 +62,6 @@ func main() { "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.") @@ -105,33 +99,7 @@ func main() { } // Create watchers for metrics and webhooks certificates - var metricsCertWatcher, webhookCertWatcher *certwatcher.CertWatcher - - // Initial webhook TLS options - webhookTLSOpts := tlsOpts - - if webhookCertPath != "" { - 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, - }) + var metricsCertWatcher *certwatcher.CertWatcher // Metrics endpoint is enabled in 'config/default/kustomization.yaml'. The Metrics options configure the server. // More info: @@ -181,7 +149,6 @@ func main() { mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ Scheme: scheme, Metrics: metricsServerOptions, - WebhookServer: webhookServer, HealthProbeBindAddress: probeAddr, LeaderElection: enableLeaderElection, LeaderElectionID: leaderElectionID, @@ -256,14 +223,6 @@ func main() { } } - 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) From 3ec8b81b460a4edc431e3eeb7ffb0c9af1b1202b Mon Sep 17 00:00:00 2001 From: Maximilian Geberl Date: Thu, 26 Mar 2026 11:35:04 +0100 Subject: [PATCH 30/36] Remove secure metrics --- .../main.go | 93 +------------------ 1 file changed, 4 insertions(+), 89 deletions(-) diff --git a/cmd/application-load-balancer-controller-manager/main.go b/cmd/application-load-balancer-controller-manager/main.go index ebcbcc9d..be49132e 100644 --- a/cmd/application-load-balancer-controller-manager/main.go +++ b/cmd/application-load-balancer-controller-manager/main.go @@ -1,10 +1,8 @@ package main import ( - "crypto/tls" "flag" "os" - "path/filepath" "github.com/stackitcloud/cloud-provider-stackit/pkg/alb/ingress" albclient "github.com/stackitcloud/cloud-provider-stackit/pkg/stackit" @@ -19,10 +17,8 @@ import ( 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" "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" // +kubebuilder:scaffold:imports ) @@ -41,15 +37,11 @@ func init() { // nolint:gocyclo,funlen // TODO: Refactor into smaller functions. func main() { var metricsAddr string - var metricsCertPath, metricsCertName, metricsCertKey string var enableLeaderElection bool var leaderElectionNamespace string var leaderElectionID string var probeAddr string var cloudConfig 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.") @@ -60,14 +52,6 @@ func main() { "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(&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") flag.StringVar(&cloudConfig, "cloud-config", "cloud.yaml", "The path to the cloud config file.") opts := zap.Options{ Development: true, @@ -83,72 +67,11 @@ func main() { 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 - // 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 *certwatcher.CertWatcher - - // 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 metricsCertPath != "" { - 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, + Scheme: scheme, + Metrics: metricsserver.Options{ + BindAddress: metricsAddr, + }, HealthProbeBindAddress: probeAddr, LeaderElection: enableLeaderElection, LeaderElectionID: leaderElectionID, @@ -215,14 +138,6 @@ func main() { } // +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 err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { setupLog.Error(err, "unable to set up health check") os.Exit(1) From f9fdf11cd015e64fbd4e1677df1bbe86771c2afc Mon Sep 17 00:00:00 2001 From: Maximilian Geberl Date: Thu, 26 Mar 2026 11:35:22 +0100 Subject: [PATCH 31/36] Enable LeaderElectionReleaseOnCancel --- .../main.go | 20 +++++-------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/cmd/application-load-balancer-controller-manager/main.go b/cmd/application-load-balancer-controller-manager/main.go index be49132e..43243dd3 100644 --- a/cmd/application-load-balancer-controller-manager/main.go +++ b/cmd/application-load-balancer-controller-manager/main.go @@ -72,21 +72,11 @@ func main() { Metrics: metricsserver.Options{ BindAddress: metricsAddr, }, - 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, + HealthProbeBindAddress: probeAddr, + LeaderElection: enableLeaderElection, + LeaderElectionID: leaderElectionID, + LeaderElectionNamespace: leaderElectionNamespace, + LeaderElectionReleaseOnCancel: true, }) if err != nil { setupLog.Error(err, "unable to start manager") From d8cb33905fa8e75f3bff4d5e218211cbd2e3b239 Mon Sep 17 00:00:00 2001 From: Maximilian Geberl Date: Thu, 26 Mar 2026 11:42:42 +0100 Subject: [PATCH 32/36] Make linter happy --- .../main.go | 5 +- pkg/alb/ingress/alb_spec.go | 54 +++++----- pkg/alb/ingress/alb_spec_test.go | 99 +++++++++---------- pkg/alb/ingress/controller_test.go | 17 ++-- .../ingress/ingressclass_controller_test.go | 25 +++-- 5 files changed, 98 insertions(+), 102 deletions(-) diff --git a/cmd/application-load-balancer-controller-manager/main.go b/cmd/application-load-balancer-controller-manager/main.go index 43243dd3..6f2127a1 100644 --- a/cmd/application-load-balancer-controller-manager/main.go +++ b/cmd/application-load-balancer-controller-manager/main.go @@ -7,11 +7,10 @@ import ( "github.com/stackitcloud/cloud-provider-stackit/pkg/alb/ingress" albclient "github.com/stackitcloud/cloud-provider-stackit/pkg/stackit" stackitconfig "github.com/stackitcloud/cloud-provider-stackit/pkg/stackit/config" - // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) - // to ensure that exec-entrypoint and run can make use of them. sdkconfig "github.com/stackitcloud/stackit-sdk-go/core/config" albsdk "github.com/stackitcloud/stackit-sdk-go/services/alb/v2api" certsdk "github.com/stackitcloud/stackit-sdk-go/services/certificates/v2api" + "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" @@ -34,7 +33,7 @@ func init() { // +kubebuilder:scaffold:scheme } -// nolint:gocyclo,funlen // TODO: Refactor into smaller functions. +// nolint:funlen // TODO: Refactor into smaller functions. func main() { var metricsAddr string var enableLeaderElection bool diff --git a/pkg/alb/ingress/alb_spec.go b/pkg/alb/ingress/alb_spec.go index d618cdb6..6afc0547 100644 --- a/pkg/alb/ingress/alb_spec.go +++ b/pkg/alb/ingress/alb_spec.go @@ -13,7 +13,6 @@ import ( corev1 "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" @@ -81,7 +80,7 @@ func (r *IngressClassReconciler) albSpecFromIngress( //nolint:funlen,gocyclo // Networks: []albsdk.Network{ { NetworkId: networkID, - Role: ptr.To("ROLE_LISTENERS_AND_TARGETS"), + Role: new("ROLE_LISTENERS_AND_TARGETS"), }, }, } @@ -183,15 +182,15 @@ func (r *IngressClassReconciler) albSpecFromIngress( //nolint:funlen,gocyclo // hostToRules := map[string][]albsdk.Rule{} for _, meta := range ruleMetadataList { rule := albsdk.Rule{ - TargetPool: ptr.To(meta.targetPool), + TargetPool: new(meta.targetPool), } if meta.pathTypeVal == 0 { // Exact path rule.Path = &albsdk.Path{ - ExactMatch: ptr.To(meta.path), + ExactMatch: new(meta.path), } } else { // Prefix path rule.Path = &albsdk.Path{ - Prefix: ptr.To(meta.path), + Prefix: new(meta.path), } } hostToRules[meta.host] = append(hostToRules[meta.host], rule) @@ -208,7 +207,7 @@ func (r *IngressClassReconciler) albSpecFromIngress( //nolint:funlen,gocyclo // for _, host := range hostnames { rulesCopy := hostToRules[host] httpHosts = append(httpHosts, albsdk.HostConfig{ - Host: ptr.To(host), + Host: new(host), Rules: rulesCopy, }) } @@ -218,9 +217,9 @@ func (r *IngressClassReconciler) albSpecFromIngress( //nolint:funlen,gocyclo // // Add TLS listener if any Ingress has TLS configured listeners := []albsdk.Listener{ { - Name: ptr.To("http"), - Port: ptr.To(int32(80)), - Protocol: ptr.To("PROTOCOL_HTTP"), + Name: new("http"), + Port: new(int32(80)), + Protocol: new("PROTOCOL_HTTP"), Http: &albsdk.ProtocolOptionsHTTP{ Hosts: httpHosts, }, @@ -228,9 +227,9 @@ func (r *IngressClassReconciler) albSpecFromIngress( //nolint:funlen,gocyclo // } if len(allCertificateIDs) > 0 { listeners = append(listeners, albsdk.Listener{ - Name: ptr.To("https"), - Port: ptr.To(int32(443)), - Protocol: ptr.To("PROTOCOL_HTTPS"), + Name: new("https"), + Port: new(int32(443)), + Protocol: new("PROTOCOL_HTTPS"), Http: &albsdk.ProtocolOptionsHTTP{ Hosts: httpHosts, }, @@ -248,14 +247,15 @@ func (r *IngressClassReconciler) albSpecFromIngress( //nolint:funlen,gocyclo // return false, nil, fmt.Errorf("failed to set IP address: %w", err) } - alb.Name = ptr.To(getAlbName(ingressClass)) + alb.Name = new(getAlbName(ingressClass)) alb.Listeners = listeners alb.TargetPools = targetPools return false, alb, nil } -// laodCerts loads the tls certificates from Ingress to the Certificates API +// loadCerts loads the tls certificates from Ingress to the Certificates API +// nolint:gocritic // no named results func (r *IngressClassReconciler) loadCerts( ctx context.Context, ingressClass *networkingv1.IngressClass, @@ -283,10 +283,10 @@ func (r *IngressClassReconciler) loadCerts( } createCertificatePayload := &certsdk.CreateCertificatePayload{ - Name: ptr.To(getCertName(ingressClass, ingress, secret)), + Name: new(getCertName(ingressClass, ingress, secret)), ProjectId: &r.ALBConfig.Global.ProjectID, - PrivateKey: ptr.To(string(secret.Data["tls.key"])), - PublicKey: ptr.To(string(secret.Data["tls.crt"])), + PrivateKey: new(string(secret.Data["tls.key"])), + PublicKey: new(string(secret.Data["tls.crt"])), } res, err := r.CertificateClient.CreateCertificate(ctx, r.ALBConfig.Global.ProjectID, r.ALBConfig.Global.Region, createCertificatePayload) if err != nil { @@ -373,22 +373,22 @@ func addTargetPool( ) { tlsConfig := &albsdk.TlsConfig{} if val, ok := ingress.Annotations[tlsBridgingTrustedCaAnnotation]; ok && val == "true" { - tlsConfig.Enabled = ptr.To(true) + tlsConfig.Enabled = new(true) } if val, ok := ingress.Annotations[tlsBridgingCustomCaAnnotation]; ok && val != "" { - tlsConfig.Enabled = ptr.To(true) - tlsConfig.CustomCa = ptr.To(val) + tlsConfig.Enabled = new(true) + tlsConfig.CustomCa = new(val) } if val, ok := ingress.Annotations[tlsBridgingSkipValidationAnnotation]; ok && val == "true" { - tlsConfig.Enabled = ptr.To(true) - tlsConfig.SkipCertificateValidation = ptr.To(true) + tlsConfig.Enabled = new(true) + tlsConfig.SkipCertificateValidation = new(true) } if tlsConfig.Enabled == nil { tlsConfig = nil } *targetPools = append(*targetPools, albsdk.TargetPool{ - Name: ptr.To(targetPoolName), - TargetPort: ptr.To(nodePort), + Name: new(targetPoolName), + TargetPort: new(nodePort), TlsConfig: tlsConfig, Targets: targets, }) @@ -400,14 +400,14 @@ func setIPAddresses(ingressClass *networkingv1.IngressClass, alb *albsdk.CreateL isInternalIP, found := ingressClass.Annotations[internalIPAnnotation] if found && isInternalIP == "true" { alb.Options = &albsdk.LoadBalancerOptions{ - PrivateNetworkOnly: ptr.To(true), + PrivateNetworkOnly: new(true), } return nil } externalAddress, found := ingressClass.Annotations[externalIPAnnotation] if !found { alb.Options = &albsdk.LoadBalancerOptions{ - EphemeralAddress: ptr.To(true), + EphemeralAddress: new(true), } return nil } @@ -415,7 +415,7 @@ func setIPAddresses(ingressClass *networkingv1.IngressClass, alb *albsdk.CreateL if err != nil { return fmt.Errorf("failed to validate external address: %w", err) } - alb.ExternalAddress = ptr.To(externalAddress) + alb.ExternalAddress = new(externalAddress) return nil } diff --git a/pkg/alb/ingress/alb_spec_test.go b/pkg/alb/ingress/alb_spec_test.go index 69a4f628..c98067c4 100644 --- a/pkg/alb/ingress/alb_spec_test.go +++ b/pkg/alb/ingress/alb_spec_test.go @@ -10,7 +10,6 @@ import ( corev1 "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" ) @@ -33,7 +32,7 @@ const ( func ingressPrefixPath(path, serviceName string) networkingv1.HTTPIngressPath { return networkingv1.HTTPIngressPath{ Path: path, - PathType: ptr.To(networkingv1.PathTypePrefix), + PathType: new(networkingv1.PathTypePrefix), Backend: networkingv1.IngressBackend{ Service: &networkingv1.IngressServiceBackend{ Name: serviceName, @@ -46,7 +45,7 @@ func ingressPrefixPath(path, serviceName string) networkingv1.HTTPIngressPath { func ingressExactPath(path, serviceName string) networkingv1.HTTPIngressPath { return networkingv1.HTTPIngressPath{ Path: path, - PathType: ptr.To(networkingv1.PathTypeExact), + PathType: new(networkingv1.PathTypeExact), Backend: networkingv1.IngressBackend{ Service: &networkingv1.IngressServiceBackend{ Name: serviceName, @@ -73,7 +72,7 @@ func fixtureIngressWithParams(name, namespace string, annotations map[string]str Annotations: annotations, }, Spec: networkingv1.IngressSpec{ - IngressClassName: ptr.To(testIngressClassName), + IngressClassName: new(testIngressClassName), Rules: rules, }, } @@ -110,7 +109,7 @@ func fixtureIngress(mods ...func(*networkingv1.Ingress)) *networkingv1.Ingress { ingress := &networkingv1.Ingress{ ObjectMeta: metav1.ObjectMeta{Name: testIngressName}, Spec: networkingv1.IngressSpec{ - IngressClassName: ptr.To(testIngressClassName), + IngressClassName: new(testIngressClassName), Rules: []networkingv1.IngressRule{ { Host: testHost, @@ -119,7 +118,7 @@ func fixtureIngress(mods ...func(*networkingv1.Ingress)) *networkingv1.Ingress { Paths: []networkingv1.HTTPIngressPath{ { Path: testPath, - PathType: ptr.To(networkingv1.PathTypePrefix), + PathType: new(networkingv1.PathTypePrefix), Backend: networkingv1.IngressBackend{ Service: &networkingv1.IngressServiceBackend{ Name: testServiceName, @@ -153,22 +152,22 @@ func fixtureIngressClass(mods ...func(*networkingv1.IngressClass)) *networkingv1 func fixtureAlbPayload(mods ...func(*albsdk.CreateLoadBalancerPayload)) *albsdk.CreateLoadBalancerPayload { payload := &albsdk.CreateLoadBalancerPayload{ - Name: ptr.To("k8s-ingress-" + testIngressClassName), + Name: new("k8s-ingress-" + testIngressClassName), Listeners: []albsdk.Listener{ { - Name: ptr.To("http"), - Port: ptr.To(int32(80)), - Protocol: ptr.To("PROTOCOL_HTTP"), + Name: new("http"), + Port: new(int32(80)), + Protocol: new("PROTOCOL_HTTP"), Http: &albsdk.ProtocolOptionsHTTP{ Hosts: []albsdk.HostConfig{ { - Host: ptr.To(testHost), + Host: new(testHost), Rules: []albsdk.Rule{ { Path: &albsdk.Path{ - Prefix: ptr.To(testPath), + Prefix: new(testPath), }, - TargetPool: ptr.To("pool-30080"), + TargetPool: new("pool-30080"), }, }, }, @@ -176,10 +175,10 @@ func fixtureAlbPayload(mods ...func(*albsdk.CreateLoadBalancerPayload)) *albsdk. }, }, }, - Networks: []albsdk.Network{{NetworkId: ptr.To(testNetworkID), Role: ptr.To("ROLE_LISTENERS_AND_TARGETS")}}, - Options: &albsdk.LoadBalancerOptions{EphemeralAddress: ptr.To(true)}, + Networks: []albsdk.Network{{NetworkId: new(testNetworkID), Role: new("ROLE_LISTENERS_AND_TARGETS")}}, + Options: &albsdk.LoadBalancerOptions{EphemeralAddress: new(true)}, TargetPools: []albsdk.TargetPool{ - {Name: ptr.To("pool-30080"), TargetPort: ptr.To(int32(30080)), Targets: []albsdk.Target{{DisplayName: ptr.To(testNodeName), Ip: ptr.To(testNodeIP)}}}, + {Name: new("pool-30080"), TargetPort: new(int32(30080)), Targets: []albsdk.Target{{DisplayName: new(testNodeName), Ip: new(testNodeIP)}}}, }, } for _, mod := range mods { @@ -218,7 +217,7 @@ func Test_albSpecFromIngress(t *testing.T) { ), services: map[string]corev1.Service{testServiceName: *fixtureServiceWithParams(testServicePort, testNodePort)}, want: fixtureAlbPayload(func(payload *albsdk.CreateLoadBalancerPayload) { - payload.ExternalAddress = ptr.To("2.2.2.2") + payload.ExternalAddress = new("2.2.2.2") payload.Options = &albsdk.LoadBalancerOptions{EphemeralAddress: nil} }), }, @@ -232,7 +231,7 @@ func Test_albSpecFromIngress(t *testing.T) { ), services: map[string]corev1.Service{testServiceName: *fixtureServiceWithParams(testServicePort, testNodePort)}, want: fixtureAlbPayload(func(payload *albsdk.CreateLoadBalancerPayload) { - payload.Options = &albsdk.LoadBalancerOptions{PrivateNetworkOnly: ptr.To(true)} + payload.Options = &albsdk.LoadBalancerOptions{PrivateNetworkOnly: new(true)} }), }, { @@ -251,21 +250,21 @@ func Test_albSpecFromIngress(t *testing.T) { want: fixtureAlbPayload(func(p *albsdk.CreateLoadBalancerPayload) { p.Listeners[0].Http.Hosts = []albsdk.HostConfig{ { - Host: ptr.To("a-host.com"), + Host: new("a-host.com"), Rules: []albsdk.Rule{ - {Path: &albsdk.Path{Prefix: ptr.To("/a")}, TargetPool: ptr.To("pool-30002")}, + {Path: &albsdk.Path{Prefix: new("/a")}, TargetPool: new("pool-30002")}, }, }, { - Host: ptr.To("z-host.com"), + Host: new("z-host.com"), Rules: []albsdk.Rule{ - {Path: &albsdk.Path{Prefix: ptr.To("/a")}, TargetPool: ptr.To("pool-30001")}, + {Path: &albsdk.Path{Prefix: new("/a")}, TargetPool: new("pool-30001")}, }, }, } 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)}}}, + {Name: new("pool-30001"), TargetPort: new(int32(30001)), Targets: []albsdk.Target{{DisplayName: new(testNodeName), Ip: new(testNodeIP)}}}, + {Name: new("pool-30002"), TargetPort: new(int32(30002)), Targets: []albsdk.Target{{DisplayName: new(testNodeName), Ip: new(testNodeIP)}}}, } }), }, @@ -285,14 +284,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].Host = new("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")}, + {Path: &albsdk.Path{Prefix: new("/x")}, TargetPool: new("pool-30004")}, + {Path: &albsdk.Path{Prefix: new("/x")}, TargetPool: new("pool-30003")}, } 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)}}}, + {Name: new("pool-30003"), TargetPort: new(int32(30003)), Targets: []albsdk.Target{{DisplayName: new(testNodeName), Ip: new(testNodeIP)}}}, + {Name: new("pool-30004"), TargetPort: new(int32(30004)), Targets: []albsdk.Target{{DisplayName: new(testNodeName), Ip: new(testNodeIP)}}}, } }), }, @@ -312,14 +311,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].Host = new("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")}, + {Path: &albsdk.Path{Prefix: new("/very/very/long/specific")}, TargetPool: new("pool-30006")}, + {Path: &albsdk.Path{Prefix: new("/short")}, TargetPool: new("pool-30005")}, } 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)}}}, + {Name: new("pool-30005"), TargetPort: new(int32(30005)), Targets: []albsdk.Target{{DisplayName: new(testNodeName), Ip: new(testNodeIP)}}}, + {Name: new("pool-30006"), TargetPort: new(int32(30006)), Targets: []albsdk.Target{{DisplayName: new(testNodeName), Ip: new(testNodeIP)}}}, } }), }, @@ -339,14 +338,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].Host = new("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")}, + {Path: &albsdk.Path{ExactMatch: new("/same")}, TargetPool: new("pool-30100")}, + {Path: &albsdk.Path{Prefix: new("/same")}, TargetPool: new("pool-30101")}, } 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)}}}, + {Name: new("pool-30100"), TargetPort: new(int32(30100)), Targets: []albsdk.Target{{DisplayName: new(testNodeName), Ip: new(testNodeIP)}}}, + {Name: new("pool-30101"), TargetPort: new(int32(30101)), Targets: []albsdk.Target{{DisplayName: new(testNodeName), Ip: new(testNodeIP)}}}, } }), }, @@ -366,14 +365,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].Host = new("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")}, + {Path: &albsdk.Path{Prefix: new("/x")}, TargetPool: new("pool-30008")}, + {Path: &albsdk.Path{Prefix: new("/x")}, TargetPool: new("pool-30007")}, } 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)}}}, + {Name: new("pool-30007"), TargetPort: new(int32(30007)), Targets: []albsdk.Target{{DisplayName: new(testNodeName), Ip: new(testNodeIP)}}}, + {Name: new("pool-30008"), TargetPort: new(int32(30008)), Targets: []albsdk.Target{{DisplayName: new(testNodeName), Ip: new(testNodeIP)}}}, } }), }, @@ -393,14 +392,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].Host = new("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")}, + {Path: &albsdk.Path{Prefix: new("/x")}, TargetPool: new("pool-30010")}, + {Path: &albsdk.Path{Prefix: new("/x")}, TargetPool: new("pool-30009")}, } 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)}}}, + {Name: new("pool-30009"), TargetPort: new(int32(30009)), Targets: []albsdk.Target{{DisplayName: new(testNodeName), Ip: new(testNodeIP)}}}, + {Name: new("pool-30010"), TargetPort: new(int32(30010)), Targets: []albsdk.Target{{DisplayName: new(testNodeName), Ip: new(testNodeIP)}}}, } }), }, @@ -408,7 +407,7 @@ func Test_albSpecFromIngress(t *testing.T) { 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) + _, got, err := r.albSpecFromIngress(context.TODO(), tt.ingresses, tt.ingressClass, new(testNetworkID), nodes, tt.services) if (err != nil) != tt.wantErr { t.Errorf("got error = %v, wantErr %v", err, tt.wantErr) return diff --git a/pkg/alb/ingress/controller_test.go b/pkg/alb/ingress/controller_test.go index c3fc9811..2494f56a 100644 --- a/pkg/alb/ingress/controller_test.go +++ b/pkg/alb/ingress/controller_test.go @@ -11,7 +11,6 @@ import ( 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" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/cache" "sigs.k8s.io/controller-runtime/pkg/client" @@ -71,7 +70,7 @@ var _ = Describe("IngressClassReconciler", func() { namespace.Name: {}, }, }, - Controller: config.Controller{SkipNameValidation: ptr.To(true)}, + Controller: config.Controller{SkipNameValidation: new(true)}, }) Expect(err).NotTo(HaveOccurred()) @@ -208,7 +207,7 @@ var _ = Describe("IngressClassReconciler", func() { Namespace: namespace.Name, }, Spec: networkingv1.IngressSpec{ - IngressClassName: ptr.To("managed-ingressclass"), + IngressClassName: new("managed-ingressclass"), Rules: []networkingv1.IngressRule{ { Host: "example.com", @@ -217,7 +216,7 @@ var _ = Describe("IngressClassReconciler", func() { Paths: []networkingv1.HTTPIngressPath{ { Path: "/", - PathType: ptr.To(networkingv1.PathTypePrefix), + PathType: new(networkingv1.PathTypePrefix), Backend: networkingv1.IngressBackend{ Service: &networkingv1.IngressServiceBackend{ Name: "dummy-svc", @@ -322,7 +321,7 @@ var _ = Describe("IngressClassReconciler", func() { Namespace: namespace.Name, }, Spec: networkingv1.IngressSpec{ - IngressClassName: ptr.To("managed-ingressclass"), + IngressClassName: new("managed-ingressclass"), Rules: []networkingv1.IngressRule{ { Host: "example.com", @@ -331,7 +330,7 @@ var _ = Describe("IngressClassReconciler", func() { Paths: []networkingv1.HTTPIngressPath{ { Path: "/", - PathType: ptr.To(networkingv1.PathTypePrefix), + PathType: new(networkingv1.PathTypePrefix), Backend: networkingv1.IngressBackend{ Service: &networkingv1.IngressServiceBackend{ Name: "dummy-svc", @@ -372,7 +371,7 @@ var _ = Describe("IngressClassReconciler", func() { // Listeners: &[]albsdk.Listener{}, // TargetPools: &[]albsdk.TargetPool{}, // Status: albsdk.LOADBALANCERSTATUS_READY.Ptr(), - // ExternalAddress: ptr.To("1.2.3.4"), + // ExternalAddress: new("1.2.3.4"), // Version: albsdk.PtrString("1"), // }, nil) // }) @@ -421,7 +420,7 @@ var _ = Describe("IngressClassReconciler", func() { // Return(&albsdk.LoadBalancer{ // Listeners: &[]albsdk.Listener{}, // TargetPools: &[]albsdk.TargetPool{}, - // Status: ptr.To(albclient.LBStatusReady), + // Status: new(albclient.LBStatusReady), // ExternalAddress: nil, // PrivateAddress: nil, // Version: 1, @@ -446,7 +445,7 @@ var _ = Describe("IngressClassReconciler", func() { // Return(&albsdk.LoadBalancer{ // Listeners: &[]albsdk.Listener{}, // TargetPools: &[]albsdk.TargetPool{}, - // Status: ptr.To("PENDING"), + // Status: new("PENDING"), // ExternalAddress: nil, // PrivateAddress: nil, // Version: 1, diff --git a/pkg/alb/ingress/ingressclass_controller_test.go b/pkg/alb/ingress/ingressclass_controller_test.go index 5c1e2cc1..fcef4f60 100644 --- a/pkg/alb/ingress/ingressclass_controller_test.go +++ b/pkg/alb/ingress/ingressclass_controller_test.go @@ -11,7 +11,6 @@ import ( 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" @@ -56,7 +55,7 @@ func TestIngressClassReconciler_updateStatus(t *testing.T) { m.EXPECT(). GetLoadBalancer(gomock.Any(), testProjectID, testRegion, testALBName). Return(&albsdk.LoadBalancer{ - Status: ptr.To("STATUS_TERMINATING"), + Status: new("STATUS_TERMINATING"), }, nil) }, wantResult: reconcile.Result{RequeueAfter: 10 * time.Second}, @@ -78,8 +77,8 @@ func TestIngressClassReconciler_updateStatus(t *testing.T) { m.EXPECT(). GetLoadBalancer(gomock.Any(), testProjectID, testRegion, testALBName). Return(&albsdk.LoadBalancer{ - Status: ptr.To("STATUS_READY"), - ExternalAddress: ptr.To(testPublicIP), + Status: new("STATUS_READY"), + ExternalAddress: new(testPublicIP), }, nil) }, wantResult: reconcile.Result{}, @@ -101,8 +100,8 @@ func TestIngressClassReconciler_updateStatus(t *testing.T) { m.EXPECT(). GetLoadBalancer(gomock.Any(), testProjectID, testRegion, testALBName). Return(&albsdk.LoadBalancer{ - Status: ptr.To("STATUS_READY"), - PrivateAddress: ptr.To(testPrivateIP), + Status: new("STATUS_READY"), + PrivateAddress: new(testPrivateIP), }, nil) }, wantResult: reconcile.Result{}, @@ -136,8 +135,8 @@ func TestIngressClassReconciler_updateStatus(t *testing.T) { m.EXPECT(). GetLoadBalancer(gomock.Any(), testProjectID, testRegion, testALBName). Return(&albsdk.LoadBalancer{ - Status: ptr.To("STATUS_READY"), - PrivateAddress: ptr.To(testPublicIP), + Status: new("STATUS_READY"), + PrivateAddress: new(testPublicIP), }, nil) }, wantResult: reconcile.Result{}, @@ -163,8 +162,8 @@ func TestIngressClassReconciler_updateStatus(t *testing.T) { m.EXPECT(). GetLoadBalancer(gomock.Any(), testProjectID, testRegion, testALBName). Return(&albsdk.LoadBalancer{ - Status: ptr.To("STATUS_READY"), - PrivateAddress: ptr.To(testPublicIP), + Status: new("STATUS_READY"), + PrivateAddress: new(testPublicIP), }, nil) }, wantResult: reconcile.Result{}, @@ -181,8 +180,8 @@ func TestIngressClassReconciler_updateStatus(t *testing.T) { m.EXPECT(). GetLoadBalancer(gomock.Any(), testProjectID, testRegion, testALBName). Return(&albsdk.LoadBalancer{ - Status: ptr.To("STATUS_READY"), - PrivateAddress: ptr.To(testPublicIP), + Status: new("STATUS_READY"), + PrivateAddress: new(testPublicIP), }, nil) }, wantResult: reconcile.Result{}, @@ -197,7 +196,7 @@ func TestIngressClassReconciler_updateStatus(t *testing.T) { m.EXPECT(). GetLoadBalancer(gomock.Any(), testProjectID, testRegion, testALBName). Return(&albsdk.LoadBalancer{ - Status: ptr.To("STATUS_READY"), + Status: new("STATUS_READY"), }, nil) }, wantResult: reconcile.Result{RequeueAfter: 10 * time.Second}, From 693873afe06bc3c3ead7191bf85d95b9d720ce97 Mon Sep 17 00:00:00 2001 From: Maximilian Geberl Date: Thu, 26 Mar 2026 11:44:55 +0100 Subject: [PATCH 33/36] Remove kubebuilder scaffold comments --- cmd/application-load-balancer-controller-manager/main.go | 4 ---- pkg/alb/ingress/ingressclass_controller.go | 4 ---- pkg/alb/ingress/suite_test.go | 2 -- 3 files changed, 10 deletions(-) diff --git a/cmd/application-load-balancer-controller-manager/main.go b/cmd/application-load-balancer-controller-manager/main.go index 6f2127a1..0be343ce 100644 --- a/cmd/application-load-balancer-controller-manager/main.go +++ b/cmd/application-load-balancer-controller-manager/main.go @@ -19,7 +19,6 @@ import ( "sigs.k8s.io/controller-runtime/pkg/healthz" "sigs.k8s.io/controller-runtime/pkg/log/zap" metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" - // +kubebuilder:scaffold:imports ) var ( @@ -29,8 +28,6 @@ var ( func init() { utilruntime.Must(clientgoscheme.AddToScheme(scheme)) - - // +kubebuilder:scaffold:scheme } // nolint:funlen // TODO: Refactor into smaller functions. @@ -125,7 +122,6 @@ func main() { setupLog.Error(err, "unable to create controller", "controller", "IngressClass") os.Exit(1) } - // +kubebuilder:scaffold:builder if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { setupLog.Error(err, "unable to set up health check") diff --git a/pkg/alb/ingress/ingressclass_controller.go b/pkg/alb/ingress/ingressclass_controller.go index 5aab7b75..b6a27a3a 100644 --- a/pkg/alb/ingress/ingressclass_controller.go +++ b/pkg/alb/ingress/ingressclass_controller.go @@ -55,10 +55,6 @@ type IngressClassReconciler struct { //nolint:revive // Naming this ClassReconci ALBConfig stackitconfig.ALBConfig } -// +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 diff --git a/pkg/alb/ingress/suite_test.go b/pkg/alb/ingress/suite_test.go index 3a05f910..55f50ebd 100644 --- a/pkg/alb/ingress/suite_test.go +++ b/pkg/alb/ingress/suite_test.go @@ -31,7 +31,6 @@ import ( "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 @@ -57,7 +56,6 @@ var _ = BeforeSuite(func() { ctx, cancel = context.WithCancel(context.TODO()) var err error - // +kubebuilder:scaffold:scheme By("bootstrapping test environment") testEnv = &envtest.Environment{ From f4ec638a39f36d4ea1bfaf6f941c5515d24c125a Mon Sep 17 00:00:00 2001 From: Maximilian Geberl Date: Thu, 26 Mar 2026 11:47:28 +0100 Subject: [PATCH 34/36] Remove crd import in envtest; remove getFirstFoundEnvTestBinaryDir as this requires the makefile from kubebuilder --- pkg/alb/ingress/suite_test.go | 35 +---------------------------------- 1 file changed, 1 insertion(+), 34 deletions(-) diff --git a/pkg/alb/ingress/suite_test.go b/pkg/alb/ingress/suite_test.go index 55f50ebd..871df014 100644 --- a/pkg/alb/ingress/suite_test.go +++ b/pkg/alb/ingress/suite_test.go @@ -18,8 +18,6 @@ package ingress_test import ( "context" - "os" - "path/filepath" "testing" . "github.com/onsi/ginkgo/v2" @@ -58,15 +56,7 @@ var _ = BeforeSuite(func() { var err error 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() - } + testEnv = &envtest.Environment{} // cfg is defined in this file globally. cfg, err = testEnv.Start() @@ -84,26 +74,3 @@ var _ = AfterSuite(func() { 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 "" -} From 85e6a10ffbcad135a6c96bac5245ba3bf36ab2b0 Mon Sep 17 00:00:00 2001 From: Maximilian Geberl Date: Thu, 26 Mar 2026 11:48:31 +0100 Subject: [PATCH 35/36] Remove dummy comment --- pkg/alb/ingress/ingressclass_controller.go | 9 --------- 1 file changed, 9 deletions(-) diff --git a/pkg/alb/ingress/ingressclass_controller.go b/pkg/alb/ingress/ingressclass_controller.go index b6a27a3a..27c35d70 100644 --- a/pkg/alb/ingress/ingressclass_controller.go +++ b/pkg/alb/ingress/ingressclass_controller.go @@ -55,15 +55,6 @@ type IngressClassReconciler struct { //nolint:revive // Naming this ClassReconci ALBConfig stackitconfig.ALBConfig } -// 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) { ingressClass := &networkingv1.IngressClass{} err := r.Client.Get(ctx, req.NamespacedName, ingressClass) From 87dc2c6d34cce016470dd569e6b1bbe96637c3c6 Mon Sep 17 00:00:00 2001 From: Maximilian Geberl Date: Thu, 26 Mar 2026 14:35:39 +0100 Subject: [PATCH 36/36] Refactor SetupWithManager --- pkg/alb/ingress/add.go | 111 +++++++++++++++++++++ pkg/alb/ingress/ingressclass_controller.go | 45 +-------- 2 files changed, 113 insertions(+), 43 deletions(-) create mode 100644 pkg/alb/ingress/add.go diff --git a/pkg/alb/ingress/add.go b/pkg/alb/ingress/add.go new file mode 100644 index 00000000..89ef5bea --- /dev/null +++ b/pkg/alb/ingress/add.go @@ -0,0 +1,111 @@ +package ingress + +import ( + "context" + "reflect" + + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/predicate" +) + +// 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{}, builder.WithPredicates(ingressClassPredicate())). + Watches(&corev1.Node{}, nodeEventHandler(r.Client), builder.WithPredicates(nodePredicate())). + Watches(&networkingv1.Ingress{}, ingressEventHandler(r.Client)). + Named("ingressclass"). + Complete(r) +} + +func nodeEventHandler(c client.Client) handler.EventHandler { + return handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, _ client.Object) []ctrl.Request { + ingressClassList := &networkingv1.IngressClassList{} + err := c.List(ctx, ingressClassList) + if err != nil { + return nil + } + requestList := []ctrl.Request{} + for i := range ingressClassList.Items { + if ingressClassList.Items[i].Spec.Controller != controllerName { + continue + } + requestList = append(requestList, ctrl.Request{ + NamespacedName: client.ObjectKeyFromObject(new(ingressClassList.Items[i])), + }) + } + return requestList + }) +} + +func ingressEventHandler(c client.Client) handler.EventHandler { + return handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, o client.Object) []ctrl.Request { + ingress, ok := o.(*networkingv1.Ingress) + if !ok || ingress.Spec.IngressClassName == nil { + return nil + } + + ingressClass := &networkingv1.IngressClass{} + err := c.Get(ctx, client.ObjectKey{Name: *ingress.Spec.IngressClassName}, ingressClass) + if err != nil { + return nil + } + + if ingressClass.Spec.Controller != controllerName { + return []ctrl.Request{ + { + NamespacedName: client.ObjectKeyFromObject(ingressClass), + }, + } + } + + return nil + }) +} + +func nodePredicate() predicate.Predicate { + return predicate.Funcs{ + CreateFunc: func(_ event.CreateEvent) bool { + return true + }, + UpdateFunc: func(e event.UpdateEvent) bool { + oldNode, ok := e.ObjectOld.(*corev1.Node) + if !ok { + return false + } + newNode, ok := e.ObjectNew.(*corev1.Node) + if !ok { + return false + } + + if !reflect.DeepEqual(oldNode.Status.Addresses, newNode.Status.Addresses) { + return true + } + + return false + }, + DeleteFunc: func(_ event.DeleteEvent) bool { + return true + }, + GenericFunc: func(_ event.GenericEvent) bool { + return true + }, + } +} + +func ingressClassPredicate() predicate.Predicate { + return predicate.NewPredicateFuncs(func(object client.Object) bool { + ingressClass, ok := object.(*networkingv1.IngressClass) + if !ok { + return false + } + return ingressClass.Spec.Controller == controllerName + }) +} diff --git a/pkg/alb/ingress/ingressclass_controller.go b/pkg/alb/ingress/ingressclass_controller.go index 27c35d70..444473e6 100644 --- a/pkg/alb/ingress/ingressclass_controller.go +++ b/pkg/alb/ingress/ingressclass_controller.go @@ -24,7 +24,9 @@ import ( "fmt" "time" + "github.com/stackitcloud/cloud-provider-stackit/pkg/stackit" stackitconfig "github.com/stackitcloud/cloud-provider-stackit/pkg/stackit/config" + albsdk "github.com/stackitcloud/stackit-sdk-go/services/alb/v2api" corev1 "k8s.io/api/core/v1" networkingv1 "k8s.io/api/networking/v1" "k8s.io/apimachinery/pkg/runtime" @@ -33,10 +35,6 @@ import ( 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" - - "github.com/stackitcloud/cloud-provider-stackit/pkg/stackit" - albsdk "github.com/stackitcloud/stackit-sdk-go/services/alb/v2api" ) const ( @@ -105,45 +103,6 @@ func (r *IngressClassReconciler) Reconcile(ctx context.Context, req ctrl.Request 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(&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) - if err != nil { - panic(err) - } - requestList := []ctrl.Request{} - 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(_ 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.