diff --git a/Makefile b/Makefile index a3a4286b..76a09268 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 @@ -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 @@ -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 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..ffa751b6 --- /dev/null +++ b/cmd/application-load-balancer-controller-manager/main.go @@ -0,0 +1,326 @@ +package main + +import ( + "crypto/tls" + "errors" + "flag" + "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. + 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" + "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 +) + +var ( + scheme = runtime.NewScheme() + setupLog = ctrl.Log.WithName("setup") +) + +func init() { + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + + // +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) { + configFile, err := os.Open(cloudConfig) + if err != nil { + return Config{}, err + } + defer configFile.Close() + + var config Config + content, err := io.ReadAll(configFile) + if err != nil { + return Config{}, err + } + + err = yaml.Unmarshal(content, &config) + if err != nil { + return Config{}, err + } + + if config.ProjectID == "" { + return Config{}, errors.New("project ID must be set") + } + if config.Region == "" { + return Config{}, errors.New("region must be set") + } + if config.NetworkID == "" { + return Config{}, errors.New("network ID must be set") + } + return config, nil +} + +// nolint:gocyclo,funlen // TODO: Refactor into smaller functions. +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 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.") + 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") + flag.StringVar(&cloudConfig, "cloud-config", "cloud.yaml", "The path to the cloud config file.") + opts := zap.Options{ + Development: true, + } + opts.BindFlags(flag.CommandLine) + flag.Parse() + + ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) + + 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 + // 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 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, + }) + + // 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, + 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") + + albOpts := []sdkconfig.ConfigurationOption{} + if albURL != "" { + albOpts = append(albOpts, sdkconfig.WithEndpoint(albURL)) + } + + certOpts := []sdkconfig.ConfigurationOption{} + if certURL != "" { + certOpts = append(certOpts, sdkconfig.WithEndpoint(certURL)) + } + + // Setup ALB API client + sdkClient, err := albsdk.NewAPIClient(albOpts...) + if err != nil { + setupLog.Error(err, "unable to create ALB SDK client", "controller", "IngressClass") + os.Exit(1) + } + albClient, err := albclient.NewApplicationLoadBalancerClient(sdkClient) + if err != nil { + setupLog.Error(err, "unable to create ALB client", "controller", "IngressClass") + os.Exit(1) + } + + // Setup Certificates API client + certificateAPI, err := certsdk.NewAPIClient(certOpts...) + if err != nil { + setupLog.Error(err, "unable to create certificate SDK client", "controller", "IngressClass") + os.Exit(1) + } + certificateClient, err := albclient.NewCertClient(certificateAPI) + if err != nil { + setupLog.Error(err, "unable to create Certificates client", "controller", "IngressClass") + os.Exit(1) + } + + if err = (&ingress.IngressClassReconciler{ + Client: mgr.GetClient(), + ALBClient: albClient, + CertificateClient: certificateClient, + Scheme: mgr.GetScheme(), + 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) + } + // +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/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 diff --git a/docs/albcm.md b/docs/albcm.md new file mode 100644 index 00000000..818d042b --- /dev/null +++ b/docs/albcm.md @@ -0,0 +1,106 @@ +# 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_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 +``` + +### 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 +``` 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: diff --git a/go.mod b/go.mod index 4b0a5f38..fd120fe3 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ toolchain go1.26.1 require ( github.com/container-storage-interface/spec v1.12.0 github.com/go-viper/mapstructure/v2 v2.5.0 + github.com/google/go-cmp v0.7.0 github.com/google/uuid v1.6.0 github.com/kubernetes-csi/csi-lib-utils v0.23.2 github.com/kubernetes-csi/csi-test/v5 v5.4.0 @@ -15,7 +16,9 @@ require ( github.com/prometheus/client_golang v1.23.2 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 - github.com/stackitcloud/stackit-sdk-go/core v0.22.0 + github.com/stackitcloud/stackit-sdk-go/core v0.23.0 + github.com/stackitcloud/stackit-sdk-go/services/alb v0.12.1 + github.com/stackitcloud/stackit-sdk-go/services/certificates v1.4.1 github.com/stackitcloud/stackit-sdk-go/services/iaas v1.3.5 github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v1.8.0 go.uber.org/mock v0.6.0 @@ -32,6 +35,7 @@ require ( k8s.io/klog/v2 v2.140.0 k8s.io/mount-utils v0.35.2 k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 + sigs.k8s.io/controller-runtime v0.23.3 ) replace k8s.io/cloud-provider => github.com/stackitcloud/cloud-provider v0.35.1-ske-1 @@ -50,11 +54,13 @@ require ( github.com/coreos/go-systemd/v22 v22.6.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-logr/zapr v1.3.0 // indirect github.com/go-openapi/jsonpointer v0.22.1 // indirect github.com/go-openapi/jsonreference v0.21.2 // indirect github.com/go-openapi/swag v0.25.1 // indirect @@ -77,7 +83,6 @@ require ( github.com/google/btree v1.1.3 // indirect github.com/google/cel-go v0.26.0 // indirect github.com/google/gnostic-models v0.7.0 // indirect - github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect @@ -123,12 +128,14 @@ require ( golang.org/x/text v0.33.0 // indirect golang.org/x/time v0.10.0 // indirect golang.org/x/tools v0.41.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect + k8s.io/apiextensions-apiserver v0.35.0 // indirect k8s.io/apiserver v0.35.1 // indirect k8s.io/component-helpers v0.35.1 // indirect k8s.io/controller-manager v0.35.1 // indirect @@ -137,6 +144,6 @@ require ( sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/go.sum b/go.sum index 97b5d821..2efd21fc 100644 --- a/go.sum +++ b/go.sum @@ -33,6 +33,10 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= +github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= +github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= +github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= @@ -105,6 +109,8 @@ github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7O github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc= github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -161,6 +167,8 @@ github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -186,8 +194,12 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stackitcloud/cloud-provider v0.35.1-ske-1 h1:Oo71mALP7hh50wAeGOogng2sAsi7AUre4177WS2n9NE= github.com/stackitcloud/cloud-provider v0.35.1-ske-1/go.mod h1:zGF/i9YuBODKxj7szGMMIz4DRnjsDy5mg2JU+XbbULA= -github.com/stackitcloud/stackit-sdk-go/core v0.22.0 h1:6rViz7GnNwXSh51Lur5xuDzO8EWSZfN9J0HvEkBKq6c= -github.com/stackitcloud/stackit-sdk-go/core v0.22.0/go.mod h1:osMglDby4csGZ5sIfhNyYq1bS1TxIdPY88+skE/kkmI= +github.com/stackitcloud/stackit-sdk-go/core v0.23.0 h1:zPrOhf3Xe47rKRs1fg/AqKYUiJJRYjdcv+3qsS50mEs= +github.com/stackitcloud/stackit-sdk-go/core v0.23.0/go.mod h1:osMglDby4csGZ5sIfhNyYq1bS1TxIdPY88+skE/kkmI= +github.com/stackitcloud/stackit-sdk-go/services/alb v0.12.1 h1:RKaxAymxlyxxE0Gta3yRuQWf07LnlcX+mfGnVB96NHA= +github.com/stackitcloud/stackit-sdk-go/services/alb v0.12.1/go.mod h1:FHkV5L9vCQha+5MX+NdMdYjQIHXcLr95+bu1FN91QOM= +github.com/stackitcloud/stackit-sdk-go/services/certificates v1.4.1 h1:RBY/mNR4H8Vd/7z0nky+AQNvoaZ16hvrGSuYi1YLLao= +github.com/stackitcloud/stackit-sdk-go/services/certificates v1.4.1/go.mod h1:3R/RwYdBc1s6WZNhToWs0rBDropbNRM7okOAdjY3rpU= github.com/stackitcloud/stackit-sdk-go/services/iaas v1.3.5 h1:W57+XRa8wTLsi5CV9Tqa7mGgt/PvlRM//RurXSmvII8= github.com/stackitcloud/stackit-sdk-go/services/iaas v1.3.5/go.mod h1:lTWjW57eAq1bwfM6nsNinhoBr3MHFW/GaFasdAsYfDM= github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v1.8.0 h1:DxrN85V738CRLynu6MULQHO+OXyYnkhVPgoZKULfFIs= @@ -328,6 +340,8 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= +gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= @@ -354,6 +368,8 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= k8s.io/api v0.35.2 h1:tW7mWc2RpxW7HS4CoRXhtYHSzme1PN1UjGHJ1bdrtdw= k8s.io/api v0.35.2/go.mod h1:7AJfqGoAZcwSFhOjcGM7WV05QxMMgUaChNfLTXDRE60= +k8s.io/apiextensions-apiserver v0.35.0 h1:3xHk2rTOdWXXJM+RDQZJvdx0yEOgC0FgQ1PlJatA5T4= +k8s.io/apiextensions-apiserver v0.35.0/go.mod h1:E1Ahk9SADaLQ4qtzYFkwUqusXTcaV2uw3l14aqpL2LU= k8s.io/apimachinery v0.35.2 h1:NqsM/mmZA7sHW02JZ9RTtk3wInRgbVxL8MPfzSANAK8= k8s.io/apimachinery v0.35.2/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= k8s.io/apiserver v0.35.1 h1:potxdhhTL4i6AYAa2QCwtlhtB1eCdWQFvJV6fXgJzxs= @@ -378,11 +394,13 @@ k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0x k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 h1:jpcvIRr3GLoUoEKRkHKSmGjxb6lWwrBlJsXc+eUYQHM= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= +sigs.k8s.io/controller-runtime v0.23.3 h1:VjB/vhoPoA9l1kEKZHBMnQF33tdCLQKJtydy4iqwZ80= +sigs.k8s.io/controller-runtime v0.23.3/go.mod h1:B6COOxKptp+YaUT5q4l6LqUJTRpizbgf9KSRNdQGns0= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 h1:2WOzJpHUBVrrkDjU4KBT8n5LDcj824eX0I5UKcgeRUs= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/hack/test.sh b/hack/test.sh index 10c78774..8db6ce76 100755 --- a/hack/test.sh +++ b/hack/test.sh @@ -24,4 +24,5 @@ else timeout_flag="-timeout=2m" fi +export KUBEBUILDER_ASSETS="$(pwd)/$(hack/tools/bin/setup-envtest use 1.35.0 --bin-dir hack/tools/bin -p path)" go test ${timeout_flag:+"$timeout_flag"} "$@" "${test_flags[@]}" diff --git a/hack/tools.mk b/hack/tools.mk index 9afb0ec8..ca8feb4a 100644 --- a/hack/tools.mk +++ b/hack/tools.mk @@ -14,6 +14,7 @@ MOCKGEN_VERSION ?= v0.6.0 APKO_VERSION ?= v1.1.15 # renovate: datasource=github-releases depName=ko-build/ko KO_VERSION ?= v0.18.1 +ENVTEST_VERSION ?= v0.0.0-20260317052337-b8d2b5b862fa KUBERNETES_TEST_VERSION ?= v1.33.5 @@ -53,6 +54,10 @@ KO := $(TOOLS_BIN_DIR)/ko $(KO): $(call tool_version_file,$(KO),$(KO_VERSION)) GOBIN=$(abspath $(TOOLS_BIN_DIR)) go install github.com/google/ko@$(KO_VERSION) +ENVTEST := $(TOOLS_BIN_DIR)/setup-envtest +$(ENVTEST): $(call tool_version_file,$(ENVTEST),$(ENVTEST_VERSION)) + GOBIN=$(abspath $(TOOLS_BIN_DIR)) go install sigs.k8s.io/controller-runtime/tools/setup-envtest@$(ENVTEST_VERSION) + KUBERNETES_TEST := $(TOOLS_BIN_DIR)/e2e.test KUBERNETES_TEST_GINKGO := $(TOOLS_BIN_DIR)/ginkgo $(KUBERNETES_TEST): $(call tool_version_file,$(KUBERNETES_TEST),$(KUBERNETES_TEST_VERSION)) diff --git a/pkg/alb/ingress/alb_spec.go b/pkg/alb/ingress/alb_spec.go new file mode 100644 index 00000000..7e788e79 --- /dev/null +++ b/pkg/alb/ingress/alb_spec.go @@ -0,0 +1,498 @@ +package ingress + +import ( + "context" + "crypto/x509" + "encoding/pem" + "fmt" + "log" + "net/netip" + "sort" + "strconv" + "strings" + + 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" +) + +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( //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 []corev1.Node, + services map[string]corev1.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: ptr.To("ROLE_LISTENERS_AND_TARGETS"), + }, + }, + } + + // Create targets for each node in the cluster + targets := []albsdk.Target{} + for i := range nodes { + node := nodes[i] + for j := range node.Status.Addresses { + address := node.Status.Addresses[j] + if address.Type == corev1.NodeInternalIP { + targets = append(targets, albsdk.Target{ + DisplayName: &node.Name, + Ip: &address.Address, + }) + break + } + } + } + + // 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) + //nolint:gocritic // TODO: Rework error handling. + // 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(int32(80)), + Protocol: ptr.To("PROTOCOL_HTTP"), + Http: &albsdk.ProtocolOptionsHTTP{ + Hosts: httpHosts, + }, + }, + } + if len(allCertificateIDs) > 0 { + listeners = append(listeners, albsdk.Listener{ + Name: ptr.To("https"), + Port: ptr.To(int32(443)), + Protocol: ptr.To("PROTOCOL_HTTPS"), + 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 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) + } + + // 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) + } + 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 == "" { + 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 +} + +// 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") + } + + // 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) + } + + // A valid, trusted chain must contain at least 2 certificates: + // the leaf (domain) and at least one intermediate CA. + 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(nodePort), + TlsConfig: tlsConfig, + Targets: targets, + }) +} + +// 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" { + 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]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) + } + + 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..c5fcb35a --- /dev/null +++ b/pkg/alb/ingress/alb_spec_test.go @@ -0,0 +1,421 @@ +package ingress + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "google.golang.org/protobuf/testing/protocmp" + + 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" +) + +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) *corev1.Service { //nolint:unparam // We might need it later. + return &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: testServiceName}, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Port: port, + NodePort: nodePort, + }, + }, + }, + } +} + +func fixtureNode(mods ...func(*corev1.Node)) *corev1.Node { + node := &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{Name: testNodeName}, + Status: corev1.NodeStatus{ + Addresses: []corev1.NodeAddress{{Type: corev1.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{ + { + Name: ptr.To("http"), + Port: ptr.To(int32(80)), + Protocol: ptr.To("PROTOCOL_HTTP"), + 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: ptr.To("ROLE_LISTENERS_AND_TARGETS")}}, + Options: &albsdk.LoadBalancerOptions{EphemeralAddress: ptr.To(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)}}}, + }, + } + for _, mod := range mods { + mod(payload) + } + return payload +} + +//nolint:funlen // Just many test cases. +func Test_albSpecFromIngress(t *testing.T) { + r := &IngressClassReconciler{} + nodes := []corev1.Node{*fixtureNode()} + + tests := []struct { + name string + ingresses []*networkingv1.Ingress + ingressClass *networkingv1.IngressClass + services map[string]corev1.Service + want *albsdk.CreateLoadBalancerPayload + wantErr bool + }{ + { + name: "valid ingress with HTTP listener", + ingresses: []*networkingv1.Ingress{fixtureIngress()}, + ingressClass: fixtureIngressClass(), + services: map[string]corev1.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]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} + }), + }, + { + 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]corev1.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]corev1.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(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: "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]corev1.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(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: "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]corev1.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(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: "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]corev1.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(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: "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]corev1.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(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: "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]corev1.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(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)}}}, + } + }), + }, + } + + 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..0c2573d9 --- /dev/null +++ b/pkg/alb/ingress/controller_test.go @@ -0,0 +1,463 @@ +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" + 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" + "sigs.k8s.io/controller-runtime/pkg/config" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/metrics/server" + + "github.com/stackitcloud/cloud-provider-stackit/pkg/alb/ingress" + "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 *stackit.MockApplicationLoadBalancerClient + certClient *stackit.MockCertificatesClient + ctx context.Context + cancel context.CancelFunc + ) + + BeforeEach(func() { + ctx, cancel = context.WithCancel(context.Background()) + DeferCleanup(cancel) + + mockCtrl = gomock.NewController(GinkgoT()) + albClient = stackit.NewMockApplicationLoadBalancerClient(mockCtrl) + certClient = stackit.NewMockCertificatesClient(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 := &ingress.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 apierrors.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 && !apierrors.IsNotFound(err) { + Expect(err).NotTo(HaveOccurred()) + } + + Eventually(func() bool { + return apierrors.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 apierrors.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..8dd06eb3 --- /dev/null +++ b/pkg/alb/ingress/ingressclass_controller.go @@ -0,0 +1,477 @@ +/* +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" + + corev1 "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" + + "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 { //nolint:revive // Naming this ClassReconciler would be confusing. + Client client.Client + ALBClient stackit.ApplicationLoadBalancerClient + CertificateClient stackit.CertificatesClient + 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) { + 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(&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. +func (r *IngressClassReconciler) handleIngressClassWithIngresses( + ctx context.Context, + ingresses []*networkingv1.Ingress, + ingressClass *networkingv1.IngressClass, +) (ctrl.Result, error) { + // Get all nodes and services + nodes := &corev1.NodeList{} + err := r.Client.List(ctx, nodes) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to get nodes: %w", err) + } + 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]corev1.Service{} + for i := range serviceList.Items { + service := serviceList.Items[i] + 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, 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) + } + 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 != stackit.LBStatusReady { + // ALB is not yet ready, requeue + return ctrl.Result{RequeueAfter: 10 * time.Second}, nil + } + + var albIP string + if alb.ExternalAddress != nil && *alb.ExternalAddress != "" { + albIP = *alb.ExternalAddress + } else if alb.PrivateAddress != nil && *alb.PrivateAddress != "" { + albIP = *alb.PrivateAddress + } + + if albIP == "" { + // 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 { //nolint:gocyclo,funlen // We check a lot of fields. Not much complexity. + 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 + } + + // 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 + payloadHosts := payloadListener.Http.Hosts + + if len(albHosts) != len(payloadHosts) { + 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(albHost.Rules) != len(payloadHost.Rules) { + 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.Path.ExactMatch, "") != ptr.Deref(payloadRule.Path.ExactMatch, "") { + 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(a.CertificateIds) != len(b.CertificateIds) { + 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(a.Targets) != len(b.Targets) { + 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 i := range ingressList.Items { + ingress := ingressList.Items[i] + 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 *corev1.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..fc6d8bf8 --- /dev/null +++ b/pkg/alb/ingress/ingressclass_controller_test.go @@ -0,0 +1,239 @@ +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" + + "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" +) + +//nolint:funlen // Just many test cases. +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(*stackit.MockApplicationLoadBalancerClient) + 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 *stackit.MockApplicationLoadBalancerClient) { + m.EXPECT(). + GetLoadBalancer(gomock.Any(), testProjectID, testRegion, testALBName). + Return(&albsdk.LoadBalancer{ + Status: ptr.To("STATUS_TERMINATING"), + }, 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 *stackit.MockApplicationLoadBalancerClient) { + m.EXPECT(). + GetLoadBalancer(gomock.Any(), testProjectID, testRegion, testALBName). + Return(&albsdk.LoadBalancer{ + Status: ptr.To("STATUS_READY"), + 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 *stackit.MockApplicationLoadBalancerClient) { + m.EXPECT(). + GetLoadBalancer(gomock.Any(), testProjectID, testRegion, testALBName). + Return(&albsdk.LoadBalancer{ + Status: ptr.To("STATUS_READY"), + 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 *stackit.MockApplicationLoadBalancerClient) { + m.EXPECT(). + GetLoadBalancer(gomock.Any(), testProjectID, testRegion, testALBName). + Return(&albsdk.LoadBalancer{ + Status: ptr.To("STATUS_READY"), + 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 *stackit.MockApplicationLoadBalancerClient) { + m.EXPECT().GetLoadBalancer(gomock.Any(), testProjectID, testRegion, testALBName).Return(nil, stackit.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 *stackit.MockApplicationLoadBalancerClient) { + m.EXPECT(). + GetLoadBalancer(gomock.Any(), testProjectID, testRegion, testALBName). + Return(&albsdk.LoadBalancer{ + Status: ptr.To("STATUS_READY"), + 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 *stackit.MockApplicationLoadBalancerClient) { + m.EXPECT(). + GetLoadBalancer(gomock.Any(), testProjectID, testRegion, testALBName). + Return(&albsdk.LoadBalancer{ + Status: ptr.To("STATUS_READY"), + 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 *stackit.MockApplicationLoadBalancerClient) { + m.EXPECT(). + GetLoadBalancer(gomock.Any(), testProjectID, testRegion, testALBName). + Return(&albsdk.LoadBalancer{ + Status: ptr.To("STATUS_READY"), + }, 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 := stackit.NewMockApplicationLoadBalancerClient(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..3e10f50a --- /dev/null +++ b/pkg/stackit/applicationloadbalancer.go @@ -0,0 +1,112 @@ +package stackit + +import ( + "context" + + "github.com/google/uuid" + + albsdk "github.com/stackitcloud/stackit-sdk-go/services/alb/v2api" +) + +type ProjectStatus string + +const ( + LBStatusReady = "STATUS_READY" + LBStatusTerminating = "STATUS_TERMINATING" + LBStatusError = "STATUS_ERROR" + + ProtocolHTTP = "PROTOCOL_HTTP" + ProtocolHTTPS = "PROTOCOL_HTTPS" + + ProjectStatusDisabled ProjectStatus = "STATUS_DISABLED" +) + +type ApplicationLoadBalancerClient interface { + GetLoadBalancer(ctx context.Context, projectID, region, name string) (*albsdk.LoadBalancer, error) + DeleteLoadBalancer(ctx context.Context, projectID, region, name string) error + CreateLoadBalancer(ctx context.Context, projectID, region string, albsdk *albsdk.CreateLoadBalancerPayload) (*albsdk.LoadBalancer, error) + UpdateLoadBalancer(ctx context.Context, projectID, region, name string, update *albsdk.UpdateLoadBalancerPayload) (*albsdk.LoadBalancer, error) + UpdateTargetPool(ctx context.Context, projectID, region, name string, targetPoolName string, payload albsdk.UpdateTargetPoolPayload) error + CreateCredentials(ctx context.Context, projectID, region string, payload albsdk.CreateCredentialsPayload) (*albsdk.CreateCredentialsResponse, error) + ListCredentials(ctx context.Context, projectID, region string) (*albsdk.ListCredentialsResponse, error) + GetCredentials(ctx context.Context, projectID, region, credentialRef string) (*albsdk.GetCredentialsResponse, error) + UpdateCredentials(ctx context.Context, projectID, region, credentialRef string, payload albsdk.UpdateCredentialsPayload) error + DeleteCredentials(ctx context.Context, projectID, region, credentialRef string) error +} + +type applicationLoadBalancerClient struct { + client *albsdk.APIClient +} + +var _ ApplicationLoadBalancerClient = (*applicationLoadBalancerClient)(nil) + +func NewApplicationLoadBalancerClient(cl *albsdk.APIClient) (ApplicationLoadBalancerClient, error) { + return &applicationLoadBalancerClient{client: cl}, nil +} + +func (cl applicationLoadBalancerClient) GetLoadBalancer(ctx context.Context, projectID, region, name string) (*albsdk.LoadBalancer, error) { + lb, err := cl.client.DefaultAPI.GetLoadBalancer(ctx, projectID, region, name).Execute() + if isOpenAPINotFound(err) { + return lb, ErrorNotFound + } + return lb, err +} + +// DeleteLoadBalancer returns no error if the load balancer doesn't exist. +func (cl applicationLoadBalancerClient) DeleteLoadBalancer(ctx context.Context, projectID, region, name string) error { + _, err := cl.client.DefaultAPI.DeleteLoadBalancer(ctx, projectID, region, name).Execute() + return err +} + +// CreateLoadBalancer returns ErrorNotFound if the project is not enabled. +func (cl applicationLoadBalancerClient) CreateLoadBalancer(ctx context.Context, projectID, region string, create *albsdk.CreateLoadBalancerPayload) (*albsdk.LoadBalancer, error) { + lb, err := cl.client.DefaultAPI.CreateLoadBalancer(ctx, projectID, region).CreateLoadBalancerPayload(*create).XRequestID(uuid.NewString()).Execute() + if isOpenAPINotFound(err) { + return lb, ErrorNotFound + } + return lb, err +} + +func (cl applicationLoadBalancerClient) UpdateLoadBalancer(ctx context.Context, projectID, region, name string, update *albsdk.UpdateLoadBalancerPayload) ( + *albsdk.LoadBalancer, error, +) { + return cl.client.DefaultAPI.UpdateLoadBalancer(ctx, projectID, region, name).UpdateLoadBalancerPayload(*update).Execute() +} + +func (cl applicationLoadBalancerClient) UpdateTargetPool(ctx context.Context, projectID, region, name, targetPoolName string, payload albsdk.UpdateTargetPoolPayload) error { + _, err := cl.client.DefaultAPI.UpdateTargetPool(ctx, projectID, region, name, targetPoolName).UpdateTargetPoolPayload(payload).Execute() + return err +} + +func (cl applicationLoadBalancerClient) CreateCredentials( + ctx context.Context, + projectID string, + region string, + payload albsdk.CreateCredentialsPayload, +) (*albsdk.CreateCredentialsResponse, error) { + return cl.client.DefaultAPI.CreateCredentials(ctx, projectID, region).CreateCredentialsPayload(payload).XRequestID(uuid.NewString()).Execute() +} + +func (cl applicationLoadBalancerClient) ListCredentials(ctx context.Context, projectID, region string) (*albsdk.ListCredentialsResponse, error) { + return cl.client.DefaultAPI.ListCredentials(ctx, projectID, region).Execute() +} + +func (cl applicationLoadBalancerClient) GetCredentials(ctx context.Context, projectID, region, credentialsRef string) (*albsdk.GetCredentialsResponse, error) { + return cl.client.DefaultAPI.GetCredentials(ctx, projectID, region, credentialsRef).Execute() +} + +func (cl applicationLoadBalancerClient) UpdateCredentials(ctx context.Context, projectID, region, credentialsRef string, payload albsdk.UpdateCredentialsPayload) error { + _, err := cl.client.DefaultAPI.UpdateCredentials(ctx, projectID, region, credentialsRef).UpdateCredentialsPayload(payload).Execute() + if err != nil { + return err + } + return nil +} + +func (cl applicationLoadBalancerClient) DeleteCredentials(ctx context.Context, projectID, region, credentialsRef string) error { + _, err := cl.client.DefaultAPI.DeleteCredentials(ctx, projectID, region, credentialsRef).Execute() + if err != nil { + return err + } + return nil +} diff --git a/pkg/stackit/applicationloadbalancer_mock.go b/pkg/stackit/applicationloadbalancer_mock.go new file mode 100644 index 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.go b/pkg/stackit/applicationloadbalancercertificates.go new file mode 100644 index 00000000..86485ec4 --- /dev/null +++ b/pkg/stackit/applicationloadbalancercertificates.go @@ -0,0 +1,50 @@ +package stackit + +import ( + "context" + + 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) (CertificatesClient, error) { + return &certClient{client: cl}, nil +} + +func (cl certClient) GetCertificate(ctx context.Context, projectID, region, name string) (*certsdk.GetCertificateResponse, error) { + cert, err := cl.client.DefaultAPI.GetCertificate(ctx, projectID, region, name).Execute() + if isOpenAPINotFound(err) { + return cert, ErrorNotFound + } + return cert, err +} + +func (cl certClient) DeleteCertificate(ctx context.Context, projectID, region, name string) error { + _, err := cl.client.DefaultAPI.DeleteCertificate(ctx, projectID, region, name).Execute() + return err +} + +func (cl certClient) CreateCertificate(ctx context.Context, projectID, region string, certificate *certsdk.CreateCertificatePayload) (*certsdk.GetCertificateResponse, error) { + cert, err := cl.client.DefaultAPI.CreateCertificate(ctx, projectID, region).CreateCertificatePayload(*certificate).Execute() + if isOpenAPINotFound(err) { + return cert, ErrorNotFound + } + return cert, err +} + +func (cl certClient) ListCertificate(ctx context.Context, projectID, region string) (*certsdk.ListCertificatesResponse, error) { + certs, err := cl.client.DefaultAPI.ListCertificates(ctx, projectID, region).Execute() + return certs, err +} diff --git a/pkg/stackit/applicationloadbalancercertificates_mock.go b/pkg/stackit/applicationloadbalancercertificates_mock.go new file mode 100644 index 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) +} 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..ccfa4d08 --- /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 # Use this for testing to avoid hitting letsencrypt rate limits. + email: mail@example.com + 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