diff --git a/.gitignore b/.gitignore index 41e3565d..ed603ce0 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ quotas* hmac cloud.yaml +cloud-config.yaml __debug_* test/e2e/inventory* diff --git a/Makefile b/Makefile index 96ded47b..289fae31 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 @@ -139,6 +139,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..c4092320 --- /dev/null +++ b/cmd/application-load-balancer-controller-manager/main.go @@ -0,0 +1,147 @@ +package main + +import ( + "flag" + "os" + + "github.com/stackitcloud/cloud-provider-stackit/pkg/alb/ingress" + albclient "github.com/stackitcloud/cloud-provider-stackit/pkg/stackit" + stackitconfig "github.com/stackitcloud/cloud-provider-stackit/pkg/stackit/config" + sdkconfig "github.com/stackitcloud/stackit-sdk-go/core/config" + albsdk "github.com/stackitcloud/stackit-sdk-go/services/alb/v2api" + certsdk "github.com/stackitcloud/stackit-sdk-go/services/certificates/v2api" + + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + _ "k8s.io/client-go/plugin/pkg/client/auth" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/healthz" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" +) + +var ( + scheme = runtime.NewScheme() + setupLog = ctrl.Log.WithName("setup") +) + +func init() { + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) +} + +// options holds the command-line options used to initialize the controller manager. +type options struct { + metricsAddr string + enableLeaderElection bool + leaderElectionNamespace string + leaderElectionID string + probeAddr string + cloudConfig string +} + +// nolint:funlen // TODO: Refactor into smaller functions. +func main() { + var opts options + + flag.StringVar(&opts.metricsAddr, "metrics-bind-address", "0", "The address the metrics endpoint binds to. "+ + "Use :8443 for HTTPS or :8080 for HTTP, or leave as 0 to disable the metrics service.") + flag.StringVar(&opts.probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") + flag.BoolVar(&opts.enableLeaderElection, "leader-elect", false, + "Enable leader election for controller manager. "+ + "Enabling this will ensure there is only one active controller manager.") + flag.StringVar(&opts.leaderElectionNamespace, "leader-election-namespace", "default", "The namespace in which the leader "+ + "election resource will be created.") + flag.StringVar(&opts.leaderElectionID, "leader-election-id", "d0fbe9c4.stackit.cloud", "The name of the resource that "+ + "leader election will use for holding the leader lock.") + flag.StringVar(&opts.cloudConfig, "cloud-config", "cloud.yaml", "The path to the cloud config file.") + + zapOpts := zap.Options{ + Development: true, + } + zapOpts.BindFlags(flag.CommandLine) + flag.Parse() + ctrl.SetLogger(zap.New(zap.UseFlagOptions(&zapOpts))) + + config, err := stackitconfig.ReadALBConfigFromFile(opts.cloudConfig) + if err != nil { + setupLog.Error(err, "Failed to read cloud config") + os.Exit(1) + } + + mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ + Scheme: scheme, + Metrics: metricsserver.Options{ + BindAddress: opts.metricsAddr, + }, + HealthProbeBindAddress: opts.probeAddr, + LeaderElection: opts.enableLeaderElection, + LeaderElectionID: opts.leaderElectionID, + LeaderElectionNamespace: opts.leaderElectionNamespace, + LeaderElectionReleaseOnCancel: true, + }) + if err != nil { + setupLog.Error(err, "unable to start manager") + os.Exit(1) + } + albOpts := []sdkconfig.ConfigurationOption{} + if config.Global.APIEndpoints.ApplicationLoadBalancerAPI != "" { + albOpts = append(albOpts, sdkconfig.WithEndpoint(config.Global.APIEndpoints.ApplicationLoadBalancerAPI)) + } + + certOpts := []sdkconfig.ConfigurationOption{} + if config.Global.APIEndpoints.ApplicationLoadBalancerCertificateAPI != "" { + certOpts = append(certOpts, sdkconfig.WithEndpoint(config.Global.APIEndpoints.ApplicationLoadBalancerCertificateAPI)) + } + + // Setup ALB API client + sdkClient, err := albsdk.NewAPIClient(albOpts...) + if err != nil { + setupLog.Error(err, "unable to create ALB SDK client", "controller", "IngressClass") + os.Exit(1) + } + albClient, err := albclient.NewApplicationLoadBalancerClient(sdkClient) + if err != nil { + setupLog.Error(err, "unable to create ALB client", "controller", "IngressClass") + os.Exit(1) + } + + // Setup Certificates API client + certificateAPI, err := certsdk.NewAPIClient(certOpts...) + if err != nil { + setupLog.Error(err, "unable to create certificate SDK client", "controller", "IngressClass") + os.Exit(1) + } + certificateClient, err := albclient.NewCertClient(certificateAPI) + if err != nil { + setupLog.Error(err, "unable to create Certificates client", "controller", "IngressClass") + os.Exit(1) + } + + if err = (&ingress.IngressClassReconciler{ + Client: mgr.GetClient(), + Recorder: mgr.GetEventRecorderFor("ingressclass-controller"), + ALBClient: albClient, + CertificateClient: certificateClient, + Scheme: mgr.GetScheme(), + ALBConfig: config, + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "IngressClass") + os.Exit(1) + } + + if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { + setupLog.Error(err, "unable to set up health check") + os.Exit(1) + } + if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { + setupLog.Error(err, "unable to set up ready check") + os.Exit(1) + } + + setupLog.Info("starting manager") + if err := mgr.Start(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..52e82f59 --- /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-controller-manager + labels: + app: stackit-application-load-balancer-controller-manager +spec: + replicas: 2 + strategy: + type: RollingUpdate + selector: + matchLabels: + app: stackit-application-load-balancer-controller-manager + template: + metadata: + labels: + app: stackit-application-load-balancer-controller-manager + spec: + serviceAccountName: stackit-application-load-balancer-controller-manager + terminationGracePeriodSeconds: 30 + containers: + - name: stackit-application-load-balancer-controller-manager + # TODO(jamand): Adapt image tag + image: ghcr.io/stackitcloud/cloud-provider-stackit/stackit-application-load-balancer-controller-manager:XXX + args: + - "--authorization-always-allow-paths=/metrics" + - "--leader-elect=true" + - "--leader-elect-resource-name=stackit-application-load-balancer-controller-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..5dff529b --- /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 +- service.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..9ecc212d --- /dev/null +++ b/deploy/application-load-balancer-controller-manager/rbac.yaml @@ -0,0 +1,71 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + namespace: kube-system + name: stackit-application-load-balancer-controller-manager +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: stackit-application-load-balancer-controller-manager +rules: + # TODO(jamand): Go through rules again +- apiGroups: + - "" + resources: + - events + verbs: + - create + - patch + - update +- apiGroups: + - "" + resources: + - nodes + verbs: + - list + - watch +- apiGroups: + - "" + resources: + - services + verbs: + - list + - watch +- apiGroups: + - "networking.k8s.io" + resources: + - ingresses + verbs: + - get + - list + - watch +- apiGroups: + - "networking.k8s.io" + resources: + - ingresses/status + verbs: + - patch +- apiGroups: + - "networking.k8s.io" + resources: + - ingressclasses + verbs: + - get + - list + - patch + - update + - watch +--- +kind: ClusterRoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: stackit-application-load-balancer-controller-manager +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: stackit-application-load-balancer-controller-manager +subjects: +- kind: ServiceAccount + name: stackit-application-load-balancer-controller-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..bd4f1d77 --- /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-controller-manager + namespace: kube-system + name: stackit-application-load-balancer-controller-manager +spec: + selector: + app: stackit-application-load-balancer-controller-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..b56b6a39 --- /dev/null +++ b/docs/albcm.md @@ -0,0 +1,85 @@ +# 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 +``` + +### Expose your deployment via Ingress +Check out our sample manifests to quickly deploy and expose your applications: +1. **[Deployment](../samples/ingress/deployment.yaml)**: A sample web server. +2. **[Service](../samples/ingress/service.yaml)**: Exposes the pods (must be of type `NodePort`). +3. **[IngressClass](../samples/ingress/ingress-class.yaml)**: Specifies the `stackit.cloud/alb-ingress` controller. +4. **[Ingress](../samples/ingress/ingress.yaml)**: Routes external traffic to your service. + +### Ingress to ALB Mapping +All Ingress resources that reference the same IngressClass are grouped together and provisioned on a single, shared Application Load Balancer (ALB). If you need separate ALBs (for instance, if you want to assign different static IP addresses or need one public and one internal ALB) then you must create a distinct IngressClass for each one. + +### Ingress Rule Evaluation Order +When multiple Ingress resources share the same ALB, the controller must sort them to determine the order in which routing rules are evaluated. By default, resources are sorted by their CreationTimestamp, meaning older Ingresses are evaluated first. + +If you need to explicitly prioritize certain rules over others, you can override this default behavior using the `alb.stackit.cloud/priority` annotation on your Ingress resource. Ingresses with a higher priority value are evaluated first. If multiple Ingresses share the same priority score, the controller falls back to sorting them by their creation timestamp. + +Note that this sorting only applies across different Ingress resources. The top-to-bottom sequence of rules and paths defined within a single Ingress YAML is not preserved and is processed non-deterministically. If you need to preserve the exact top-to-bottom order specified in your YAML, you must separate them into distinct Ingress resources and use the priority annotation. + +### WebSockets Support +You can enable WebSocket support for your applications by adding a specific annotation to your Ingress resource. Note that in this initial release, enabling this annotation applies globally to all routing rules defined within that specific Ingress. + +To enable it, add the alb.stackit.cloud/websocket annotation to your Ingress metadata: +``` +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: alb-ingress + namespace: default + annotations: + alb.stackit.cloud/websocket: "true" +spec: + # ... rest of your ingress spec +``` + +### Web Application Firewall (WAF) +You can secure your applications by attaching a WAF configuration using the `alb.stackit.cloud/web-application-firewall-name` annotation: + +IngressClass Level (Global): Applies the WAF configuration to all listeners created by any Ingress using this class. Note: This takes precedence and overwrites any WAF configuration specified on individual Ingress resources. + +Ingress Level (Specific): Applies the WAF configuration only to the listeners created by that individual Ingress, provided the IngressClass does not enforce a global WAF. + +Example: +``` +metadata: + annotations: + alb.stackit.cloud/web-application-firewall-name: "my-waf-config" +``` + +### Supported Annotations + +| Annotation | Allowed On | Requirement | Description | +| :--- | :--- | :--- | :--- | +| `alb.stackit.cloud/external-address` | IngressClass | Optional | Uses a specific STACKIT public IP instead of an ephemeral one. | +| `alb.stackit.cloud/internal` | IngressClass | Optional | If `true`, the ALB is not exposed via a public IP. | +| `alb.stackit.cloud/plan-id` | IngressClass | Optional | Sets the service plan for the ALB. | +| `alb.stackit.cloud/http-port` | IngressClass, Ingress | Optional | Specifies the custom HTTP port. | +| `alb.stackit.cloud/https-port` | IngressClass, Ingress | Optional | Specifies the custom HTTPS port. | +| `alb.stackit.cloud/https-only` | IngressClass, Ingress | Optional | If `true`, the Ingress will not be reachable via HTTP. | +| `alb.stackit.cloud/websocket` | IngressClass, Ingress | Optional | Enables global WebSocket support. | +| `alb.stackit.cloud/web-application-firewall-name`| IngressClass, Ingress | Optional | Attaches a specific WAF configuration. | +| `alb.stackit.cloud/cookie-persistence-name` | IngressClass, Ingress | Optional | Sets the name for session cookie persistence. | +| `alb.stackit.cloud/cookie-persistence-ttl-seconds`| IngressClass, Ingress | Optional | Sets the TTL (in seconds) for cookie persistence. | +| `alb.stackit.cloud/priority` | Ingress | Optional | Defines the evaluation priority of the Ingress. | +| `alb.stackit.cloud/traget-pool-tls-enabled` | IngressClass, Ingress, Service | Optional | Enables TLS bridging using OS trusted CAs. | +| `alb.stackit.cloud/traget-pool-tls-custom-ca` | IngressClass, Ingress, Service | Optional | Enables TLS bridging with a custom CA. | +| `alb.stackit.cloud/traget-pool-tls-skip-certificate-validation`| IngressClass, Ingress, Service | Optional | Enables TLS bridging but skips certificate validation. | \ No newline at end of file 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 4d78bb08..95dee2ac 100644 --- a/go.mod +++ b/go.mod @@ -8,28 +8,31 @@ require ( 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 - github.com/onsi/ginkgo/v2 v2.28.3 - github.com/onsi/gomega v1.40.0 + github.com/onsi/ginkgo/v2 v2.29.0 + github.com/onsi/gomega v1.41.0 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.26.0 + github.com/stackitcloud/stackit-sdk-go/services/alb v0.14.2 + github.com/stackitcloud/stackit-sdk-go/services/certificates v1.8.0 github.com/stackitcloud/stackit-sdk-go/services/iaas v1.11.1 - github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v1.12.2 + github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v1.13.0 go.uber.org/mock v0.6.0 golang.org/x/sync v0.20.0 golang.org/x/sys v0.44.0 - google.golang.org/grpc v1.81.0 + google.golang.org/grpc v1.81.1 google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af gopkg.in/yaml.v3 v3.0.1 - k8s.io/api v0.36.0 - k8s.io/apimachinery v0.36.0 - k8s.io/client-go v0.36.0 - k8s.io/cloud-provider v0.36.0 - k8s.io/component-base v0.36.0 + k8s.io/api v0.36.1 + k8s.io/apimachinery v0.36.1 + k8s.io/client-go v0.36.1 + k8s.io/cloud-provider v0.36.1 + k8s.io/component-base v0.36.1 k8s.io/klog/v2 v2.140.0 - k8s.io/mount-utils v0.36.0 + k8s.io/mount-utils v0.36.1 k8s.io/utils v0.0.0-20260507154919-ff6756f316d2 + sigs.k8s.io/controller-runtime v0.24.1 ) replace k8s.io/cloud-provider => github.com/stackitcloud/cloud-provider v0.36.0-ske-1 @@ -48,11 +51,13 @@ require ( github.com/coreos/go-systemd/v22 v22.7.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/emicklei/go-restful/v3 v3.13.0 // indirect + github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/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 @@ -91,6 +96,7 @@ require ( github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/procfs v0.19.2 // indirect + github.com/spf13/afero v1.12.0 // indirect github.com/stackitcloud/stackit-sdk-go/services/resourcemanager v0.23.0 // indirect github.com/stoewer/go-strcase v1.3.1 // indirect github.com/stretchr/objx v0.5.3 // indirect @@ -121,19 +127,22 @@ require ( golang.org/x/text v0.36.0 // indirect golang.org/x/time v0.14.0 // indirect golang.org/x/tools v0.44.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // 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.36.0 // indirect k8s.io/apiserver v0.36.0 // indirect k8s.io/component-helpers v0.36.0 // indirect k8s.io/controller-manager v0.36.0 // indirect k8s.io/kms v0.36.0 // indirect k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect - k8s.io/streaming v0.36.0 // indirect + k8s.io/streaming v0.36.1 // indirect sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.34.0 // indirect + sigs.k8s.io/controller-runtime/tools/setup-envtest v0.24.1 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect diff --git a/go.sum b/go.sum index 375834af..fdf8434e 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.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= +github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= +github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= +github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= github.com/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-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg= github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -155,10 +161,12 @@ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFd github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/onsi/ginkgo/v2 v2.28.3 h1:4JvMdwtFU0imd8fHx25OJXoDMRexnf8v5NHKYSTTji4= -github.com/onsi/ginkgo/v2 v2.28.3/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= -github.com/onsi/gomega v1.40.0 h1:Vtol0e1MghCD2ZVIilPDIg44XSL9l2QAn8ZNaljWcJc= -github.com/onsi/gomega v1.40.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A= +github.com/onsi/ginkgo/v2 v2.29.0 h1:rfh+ZFjgJhYWRoIqVf3Uwx/W20yLrcrE2h2GmYVRaag= +github.com/onsi/ginkgo/v2 v2.29.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= +github.com/onsi/gomega v1.41.0 h1:OwKp4pXNgVxf6sCplzYo794OFNuoL2q2SBMU5NSWOjA= +github.com/onsi/gomega v1.41.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A= +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= @@ -177,6 +185,8 @@ github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/soheilhy/cmux v0.1.5 h1:jjzc5WVemNEDTLwv9tlmemhC73tI08BNOIGwBOo10Js= github.com/soheilhy/cmux v0.1.5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE1GqG0= +github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= +github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= @@ -186,10 +196,16 @@ github.com/stackitcloud/cloud-provider v0.36.0-ske-1 h1:CZaL+8FH1rOjPnlPkhmvfKUk github.com/stackitcloud/cloud-provider v0.36.0-ske-1/go.mod h1:y/3sksoC0taJZR0PcAAYUqVyD6Jzu2X0lD4yCEPXPuI= github.com/stackitcloud/stackit-sdk-go/core v0.26.0 h1:jQEb9gkehfp6VCP6TcYk7BI10cz4l0KM2L6hqYBH2QA= github.com/stackitcloud/stackit-sdk-go/core v0.26.0/go.mod h1:WU1hhxnjXw2EV7CYa1nlEvNpMiRY6CvmIOaHuL3pOaA= +github.com/stackitcloud/stackit-sdk-go/services/alb v0.14.2 h1:hGzfOJjlCRoFpri5eYIiwhE27qu02pKZLprKvbsTC/w= +github.com/stackitcloud/stackit-sdk-go/services/alb v0.14.2/go.mod h1:eK6oRB5Tmpt6KbXQ4UYBGg2LgW5bPtVoncL9E8JSRww= +github.com/stackitcloud/stackit-sdk-go/services/certificates v1.7.0 h1:J7BVVHjRTS5YUyGf6DZEIE1uD9f/S4v9dDbpZWVEd3U= +github.com/stackitcloud/stackit-sdk-go/services/certificates v1.7.0/go.mod h1:eJpB3/pukz+KzVPVHQ4g3DVtQkxGga18VbFBhq9ugdY= +github.com/stackitcloud/stackit-sdk-go/services/certificates v1.8.0 h1:bINitVHAyfFfRhkt8/eXDXEjpuH72n9HykZhthGkEg4= +github.com/stackitcloud/stackit-sdk-go/services/certificates v1.8.0/go.mod h1:eJpB3/pukz+KzVPVHQ4g3DVtQkxGga18VbFBhq9ugdY= github.com/stackitcloud/stackit-sdk-go/services/iaas v1.11.1 h1:HcKqjwIjv4OAW1aWI0U/JWjnzTwzSvdr6DLasH940EU= github.com/stackitcloud/stackit-sdk-go/services/iaas v1.11.1/go.mod h1:Ts06id0KejUlQWbpR+/rm+tKng6QkTuFV1VQTPJ4dA4= -github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v1.12.2 h1:3Xnt5lnMmqVWChvH8lYJwpRoRatoqXfHlZ12wgNwUD4= -github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v1.12.2/go.mod h1:+Ld3dn648I+YKcBV3fEkYpDSr3fel421+LurJGywSBs= +github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v1.13.0 h1:UuLNwFHjJCpL11y4F7B9oBKtZkxpu01VkNPILNkpex4= +github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v1.13.0/go.mod h1:+Ld3dn648I+YKcBV3fEkYpDSr3fel421+LurJGywSBs= github.com/stackitcloud/stackit-sdk-go/services/resourcemanager v0.23.0 h1:u2C3oHNcc41Ba5cUqSPuqviDrYSRhpaC5+ELbuHHdwM= github.com/stackitcloud/stackit-sdk-go/services/resourcemanager v0.23.0/go.mod h1:NEz3f+GV5G++BE9/MmZCsXJyCih7jtg0pZuSyG2sLEs= github.com/stoewer/go-strcase v1.3.1 h1:iS0MdW+kVTxgMoE1LAZyMiYJFKlOzLooE4MxjirtkAs= @@ -326,14 +342,16 @@ 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.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 h1:tu/dtnW1o3wfaxCOjSLn5IRX4YDcJrtlpzYkhHhGaC4= google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng= google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw= -google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -350,16 +368,18 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/api v0.36.0 h1:SgqDhZzHdOtMk40xVSvCXkP9ME0H05hPM3p9AB1kL80= -k8s.io/api v0.36.0/go.mod h1:m1LVrGPNYax5NBHdO+QuAedXyuzTt4RryI/qnmNvs34= -k8s.io/apimachinery v0.36.0 h1:jZyPzhd5Z+3h9vJLt0z9XdzW9VzNzWAUw+P1xZ9PXtQ= -k8s.io/apimachinery v0.36.0/go.mod h1:FklypaRJt6n5wUIwWXIP6GJlIpUizTgfo1T/As+Tyxc= +k8s.io/api v0.36.1 h1:XbL/EMj8K2aJpJtePmqUyQMsM0D4QI2pvl7YKJ20FTY= +k8s.io/api v0.36.1/go.mod h1:KOWo4ey3TINlXjeHVuwB3i+tXXnu+UcwFBHlI/9dvEo= +k8s.io/apiextensions-apiserver v0.36.0 h1:Wt7E8J+VBCbj4FjiBfDTK/neXDDjyJVJc7xfuOHImZ0= +k8s.io/apiextensions-apiserver v0.36.0/go.mod h1:kGDjH0msuiIB3tgsYRV0kS9GqpMYMUsQ3GHv7TApyug= +k8s.io/apimachinery v0.36.1 h1:G63Gjx2W+q0YD+72Vo8oY0nDnePVwnuzTmmy5ENrVSA= +k8s.io/apimachinery v0.36.1/go.mod h1:ibYOR00vW/I1kzvi5SF0dRuJ52BvKtfvRdOn35GPQ+8= k8s.io/apiserver v0.36.0 h1:Jg5OFAENUACByUCg15CmhZAYrr5ZyJ+jodyA1mHl3YE= k8s.io/apiserver v0.36.0/go.mod h1:mHvwdHf+qKEm+1/hYm756SV+oREOKSPnsjagOpx6Vho= -k8s.io/client-go v0.36.0 h1:pOYi7C4RHChYjMiHpZSpSbIM6ZxVbRXBy7CuiIwqA3c= -k8s.io/client-go v0.36.0/go.mod h1:ZKKcpwF0aLYfkHFCjillCKaTK/yBkEDHTDXCFY6AS9Y= -k8s.io/component-base v0.36.0 h1:hFjEktssxiJhrK1zfybkH4kJOi8iZuF+mIDCqS5+jRo= -k8s.io/component-base v0.36.0/go.mod h1:JZvIfcNHk+uck+8LhJzhSBtydWXaZNQwX2OdL+Mnwsk= +k8s.io/client-go v0.36.1 h1:FN/K8QIT2CEDt+2WB2HnWrUANZ50AP5GII43/SP2JR0= +k8s.io/client-go v0.36.1/go.mod h1:s6rAnCtTGYDQnpNjEhSaISV+2O8jwruZ6m3QOYBFbtU= +k8s.io/component-base v0.36.1 h1:iG6GsELftXqTNG9HG6kiVjatSgAw1sf5pJ6R5a6N0kA= +k8s.io/component-base v0.36.1/go.mod h1:nf9XPlntRdqO6WMeEWAA5F93Y4ICZQdeT9GeqLDB3JI= k8s.io/component-helpers v0.36.0 h1:KznLAOD7oPxjaeheW4SOQijz9UtMO8Nvp89+lR8FYks= k8s.io/component-helpers v0.36.0/go.mod h1:BqZG+01Z97KR8GN9Stb8SiRmtn/EpZogriuQtpMCsLg= k8s.io/controller-manager v0.36.0 h1:SQoi2QplC2mI7v+rRRVeHtlQcGJVdz8qE86AN+uIT34= @@ -370,14 +390,18 @@ k8s.io/kms v0.36.0 h1:DPy0VDWi6hCgFMgzV5cNuSDrIROMRcJpTZ1GnB+D368= k8s.io/kms v0.36.0/go.mod h1:g91diTD9h0oJCCHkTb00krlF+Qm5HTnkWLi9Q/TpRoc= k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hkbPJgdATINPMAxaynU2Ovg= k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= -k8s.io/mount-utils v0.36.0 h1:ufsqGyCoPDh7p+6OIa1wv6oH9GqkQQ8XIfEOVfCV3g0= -k8s.io/mount-utils v0.36.0/go.mod h1:+I47UOG6FiUGVSy7VanjU/mQXLShMo3M7xBpGLzCub8= -k8s.io/streaming v0.36.0 h1:agnTxU+NFulUrtYzXUGKO3ndEa8jKwht1Kwn9nu9x+4= -k8s.io/streaming v0.36.0/go.mod h1:z6fV3D+NVkoeqRMtWwlUZK6U17SY/LqNzOxWL6GyR/s= +k8s.io/mount-utils v0.36.1 h1:NWDFsdv+jfqPfa/LisnbEn1QyPNYjMNkmfEORXhyvZA= +k8s.io/mount-utils v0.36.1/go.mod h1:+I47UOG6FiUGVSy7VanjU/mQXLShMo3M7xBpGLzCub8= +k8s.io/streaming v0.36.1 h1:L+K68n4Gg940BGNNYtUBvL1WTLL0YnKT3s+P1MNAmR4= +k8s.io/streaming v0.36.1/go.mod h1:z6fV3D+NVkoeqRMtWwlUZK6U17SY/LqNzOxWL6GyR/s= k8s.io/utils v0.0.0-20260507154919-ff6756f316d2 h1:wU4tMEhLGgIbLvXQb1cfN+EcM0wf7zC6CPF+C79jroc= k8s.io/utils v0.0.0-20260507154919-ff6756f316d2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.34.0 h1:hSfpvjjTQXQY2Fol2CS0QHMNs/WI1MOSGzCm1KhM5ec= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.34.0/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= +sigs.k8s.io/controller-runtime v0.24.1 h1:miPEwrmirImAvgME1L9qebGHrOnGJoVmVdtOU9fRfo4= +sigs.k8s.io/controller-runtime v0.24.1/go.mod h1:vFkfY5fGt5xAC/sKb8IBFKgWPNKG9OUG29dR8Y2wImw= +sigs.k8s.io/controller-runtime/tools/setup-envtest v0.24.1 h1:l2AjyGE/PWub6EB165ij4/bpCK0TlY5NlhzIHfmGvmg= +sigs.k8s.io/controller-runtime/tools/setup-envtest v0.24.1/go.mod h1:wpkYufRHTSw9ABET21/PkEL7kdGnmiZJ6o72t9p/1I8= 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= 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 03ee57b6..d33b4034 100644 --- a/hack/tools.mk +++ b/hack/tools.mk @@ -11,9 +11,10 @@ GOLANGCI_LINT_VERSION ?= v2.12.2 # renovate: datasource=github-releases depName=uber-go/mock MOCKGEN_VERSION ?= v0.6.0 # renovate: datasource=github-releases depName=chainguard-dev/apko -APKO_VERSION ?= v1.2.12 +APKO_VERSION ?= v1.2.13 # 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/add.go b/pkg/alb/ingress/add.go new file mode 100644 index 00000000..eb38e122 --- /dev/null +++ b/pkg/alb/ingress/add.go @@ -0,0 +1,170 @@ +package ingress + +import ( + "context" + "reflect" + + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/predicate" +) + +const ( + // fieldIndexIngressClass indexes the ingress class on an ingress. + fieldIndexIngressClass = ".spec.ingressClassName" +) + +// SetupWithManager sets up the controller with the Manager. +func (r *IngressClassReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manager, ctrlName string) error { + mgr.GetCache().IndexField(ctx, &networkingv1.Ingress{}, fieldIndexIngressClass, func(o client.Object) []string { + ingress := o.(*networkingv1.Ingress) + if ingress.Spec.IngressClassName == nil { + return nil + } + return []string{*ingress.Spec.IngressClassName} + }) + + if ctrlName == "" { + ctrlName = "ingressclass" + } + + return ctrl.NewControllerManagedBy(mgr). + For(&networkingv1.IngressClass{}, builder.WithPredicates(ingressClassPredicate())). + Watches(&corev1.Node{}, nodeEventHandler(r.Client), builder.WithPredicates(nodePredicate())). + Watches(&networkingv1.Ingress{}, ingressEventHandler(r.Client)). + Watches(&corev1.Secret{}, secretEventHandler(r.Client)). + // TODO: Services are missing + Named(ctrlName). + Complete(r) +} + +func secretEventHandler(c client.Client) handler.EventHandler { + return handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, o client.Object) []ctrl.Request { + // Filter out non-TLS Secrets. + secret, ok := o.(*corev1.Secret) + if !ok || secret.Type != corev1.SecretTypeTLS { + return nil + } + + ingressList := &networkingv1.IngressList{} + if err := c.List(ctx, ingressList, client.InNamespace(secret.Namespace)); err != nil { + return nil + } + + classNames := make(map[string]struct{}) + for _, ingress := range ingressList.Items { + if ingress.Spec.IngressClassName == nil { + continue + } + + for _, tls := range ingress.Spec.TLS { + if tls.SecretName == secret.Name { + classNames[*ingress.Spec.IngressClassName] = struct{}{} + break + } + } + } + + var requestList []ctrl.Request + for className := range classNames { + ingressClass := &networkingv1.IngressClass{} + err := c.Get(ctx, client.ObjectKey{Name: className}, ingressClass) + if err != nil || ingressClass.Spec.Controller != controllerName { + continue + } + + requestList = append(requestList, ctrl.Request{ + NamespacedName: client.ObjectKeyFromObject(ingressClass), + }) + } + + return requestList + }) +} + +func nodeEventHandler(c client.Client) handler.EventHandler { + return handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, _ client.Object) []ctrl.Request { + ingressClassList := &networkingv1.IngressClassList{} + err := c.List(ctx, ingressClassList) + if err != nil { + return nil + } + requestList := []ctrl.Request{} + for i := range ingressClassList.Items { + if ingressClassList.Items[i].Spec.Controller != controllerName { + continue + } + requestList = append(requestList, ctrl.Request{ + NamespacedName: client.ObjectKeyFromObject(new(ingressClassList.Items[i])), + }) + } + return requestList + }) +} + +func ingressEventHandler(c client.Client) handler.EventHandler { + return handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, o client.Object) []ctrl.Request { + ingress, ok := o.(*networkingv1.Ingress) + if !ok || ingress.Spec.IngressClassName == nil { + return nil + } + + ingressClass := &networkingv1.IngressClass{} + err := c.Get(ctx, client.ObjectKey{Name: *ingress.Spec.IngressClassName}, ingressClass) + if err != nil { + return nil + } + + if ingressClass.Spec.Controller != controllerName { + return nil + } + + return []ctrl.Request{ + { + NamespacedName: client.ObjectKeyFromObject(ingressClass), + }, + } + }) +} + +func nodePredicate() predicate.Predicate { + return predicate.Funcs{ + CreateFunc: func(_ event.CreateEvent) bool { + return true + }, + UpdateFunc: func(e event.UpdateEvent) bool { + oldNode, ok := e.ObjectOld.(*corev1.Node) + if !ok { + return false + } + newNode, ok := e.ObjectNew.(*corev1.Node) + if !ok { + return false + } + + // TODO: include more updates such as annotations + return !reflect.DeepEqual(oldNode.Status.Addresses, newNode.Status.Addresses) + }, + DeleteFunc: func(_ event.DeleteEvent) bool { + return true + }, + GenericFunc: func(_ event.GenericEvent) bool { + return true + }, + } +} + +func ingressClassPredicate() predicate.Predicate { + return predicate.NewPredicateFuncs(func(object client.Object) bool { + ingressClass, ok := object.(*networkingv1.IngressClass) + if !ok { + return false + } + return ingressClass.Spec.Controller == controllerName + }) +} diff --git a/pkg/alb/ingress/certificate.go b/pkg/alb/ingress/certificate.go new file mode 100644 index 00000000..b565e51d --- /dev/null +++ b/pkg/alb/ingress/certificate.go @@ -0,0 +1,61 @@ +package ingress + +import ( + "context" + "fmt" + "strings" + + "github.com/stackitcloud/cloud-provider-stackit/pkg/labels" + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" +) + +func (r *IngressClassReconciler) cleanupUnusedCertificates(ctx context.Context, class *networkingv1.IngressClass, activeCertIDs map[string]string) error { + certificatesList, err := r.CertificateClient.ListCertificate(ctx, r.ALBConfig.Global.ProjectID, r.ALBConfig.Global.Region) + if err != nil { + return fmt.Errorf("failed to list certificates: %w", err) + } + + if certificatesList == nil || certificatesList.Items == nil { + return nil // No certificates to clean up + } + + // using labels for certificates + targetUID := string(class.UID) + + // Create a fast lookup set of the active Certificate IDs + activeIDsSet := make(map[string]struct{}) + for _, id := range activeCertIDs { + activeIDsSet[id] = struct{}{} + } + + for _, cert := range certificatesList.Items { + if cert.Labels == nil { + continue + } + if val, ok := (*cert.Labels)[labels.LabelIngressClassUID]; ok && val == targetUID { + // Skip deletion if this certificate is actively used + if _, isActive := activeIDsSet[*cert.Id]; isActive { + continue + } + err := r.CertificateClient.DeleteCertificate(ctx, r.ALBConfig.Global.ProjectID, r.ALBConfig.Global.Region, *cert.Id) + if err != nil { + return fmt.Errorf("failed to delete unused certificate %s: %v", *cert.Name, err) + } + } + } + return nil +} + +// 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, tlsSecret *corev1.Secret) string { + classShortUID := shortUUID(string(ingressClass.UID)) + tlsSecretShortUID := shortUUID(string(tlsSecret.UID))[:25] + + return fmt.Sprintf("%s-%s", classShortUID, tlsSecretShortUID) +} + +func shortUUID(s string) string { + return strings.ReplaceAll(string(s), "-", "") +} diff --git a/pkg/alb/ingress/ingressclass_controller.go b/pkg/alb/ingress/ingressclass_controller.go new file mode 100644 index 00000000..9e6e408b --- /dev/null +++ b/pkg/alb/ingress/ingressclass_controller.go @@ -0,0 +1,195 @@ +package ingress + +import ( + "context" + "fmt" + "time" + + "github.com/stackitcloud/cloud-provider-stackit/pkg/stackit" + stackitconfig "github.com/stackitcloud/cloud-provider-stackit/pkg/stackit/config" + networkingv1 "k8s.io/api/networking/v1" + apiequality "k8s.io/apimachinery/pkg/api/equality" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" +) + +const ( + // finalizerName is the name of the finalizer that is added to Ingress and IngressClass + finalizerName = "stackit.cloud/alb-ingress" + // controllerName is the name of the ALB controller that the IngressClass should point to for reconciliation + controllerName = "stackit.cloud/alb-ingress" +) + +// IngressClassReconciler reconciles a IngressClass object +type IngressClassReconciler struct { //nolint:revive // Naming this ClassReconciler would be confusing. + Client client.Client + Recorder record.EventRecorder + ALBClient stackit.ApplicationLoadBalancerClient + CertificateClient stackit.CertificatesClient + Scheme *runtime.Scheme + ALBConfig stackitconfig.ALBConfig +} + +func (r *IngressClassReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + log := ctrl.LoggerFrom(ctx) + ingressClass := &networkingv1.IngressClass{} + err := r.Client.Get(ctx, req.NamespacedName, ingressClass) + if err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + // Check if the IngressClass points to the ALB controller + if ingressClass.Spec.Controller != controllerName { + // If this IngressClass doesn't point to the ALB controller, ignore this IngressClass + return ctrl.Result{}, nil + } + + // TODO: Use proper verbosity levels + log.V(10).Info("Reconciling IngressClass") + + if !ingressClass.DeletionTimestamp.IsZero() { + err := r.handleIngressClassDeletion(ctx, ingressClass) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to handle IngressClass deletion: %w", err) + } + return ctrl.Result{}, nil + } + + // Add finalizer to the IngressClass if not already added + if controllerutil.AddFinalizer(ingressClass, finalizerName) { + err := r.Client.Update(ctx, ingressClass) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to add finalizer to IngressClass: %w", err) + } + return ctrl.Result{}, nil + } + + if err := r.applyALB(ctx, ingressClass); err != nil { + return ctrl.Result{}, fmt.Errorf("failed to apply ALB: %w", err) + } + + // for _, errItem := range errorList { + // var evtErr *errorEvent + // if errors.As(errItem, &evtErr) { + // log.Info(evtErr.Error(), "ingressRef", evtErr.ingressRef) + // evtErr.RecordEvent(ingressClass, r.Recorder) + // } else { + // log.Info(errItem.Error()) + // } + // } + + requeue, err := r.updateStatus(ctx, ingressClass) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to update ingress status: %w", err) + } + + log.Info("Successfully reconciled IngressClass", "Name", ingressClass.Name) + + return requeue, nil +} + +// updateStatus updates the status of the Ingresses with the ALB IP address +func (r *IngressClassReconciler) updateStatus(ctx context.Context, ingressClass *networkingv1.IngressClass) (ctrl.Result, error) { + alb, err := r.ALBClient.GetLoadBalancer(ctx, r.ALBConfig.Global.ProjectID, r.ALBConfig.Global.Region, string(ingressClass.UID)) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to get load balancer: %w", err) + } + + if *alb.Status != stackit.LBStatusReady { + // ALB is not yet ready, requeue + return ctrl.Result{RequeueAfter: 10 * time.Second}, nil + } + + var albIP string + if alb.ExternalAddress != nil && *alb.ExternalAddress != "" { + albIP = *alb.ExternalAddress + } else if alb.PrivateAddress != nil && *alb.PrivateAddress != "" { + albIP = *alb.PrivateAddress + } + + if albIP == "" { + return ctrl.Result{}, fmt.Errorf("alb is ready but has no IPs %v", alb.Name) + } + + ingresses, err := r.getIngressesForIngressClass(ctx, ingressClass) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to get ingresses: %w", err) + } + + for _, ingress := range ingresses { + before := ingress.DeepCopy() + + ingress.Status.LoadBalancer.Ingress = []networkingv1.IngressLoadBalancerIngress{ + { + IP: albIP, + }, + } + + if apiequality.Semantic.DeepEqual(before, ingress) { + continue + } + patch := client.MergeFrom(before) + if err := r.Client.Status().Patch(ctx, &ingress, patch); err != nil { + return ctrl.Result{}, fmt.Errorf("failed to patch ingress status object: %w", err) + } + } + + return ctrl.Result{}, nil +} + +func (r *IngressClassReconciler) getIngressesForIngressClass(ctx context.Context, ingressClass *networkingv1.IngressClass) ([]networkingv1.Ingress, error) { + ingresses := networkingv1.IngressList{} + if err := r.Client.List(ctx, &ingresses, client.MatchingFields{fieldIndexIngressClass: ingressClass.Name}); err != nil { + return nil, err + } + return ingresses.Items, nil +} + +// handleIngressClassDeletion handles the deletion of IngressClass resource. +// It does not wait until all ingresses are deleted. It just removes the status from the ingresses and removes the ALB. +// If this blocked the IngressClass would be there forever as there is no ownerReference in the ingresses. +func (r *IngressClassReconciler) handleIngressClassDeletion( + ctx context.Context, + ingressClass *networkingv1.IngressClass, +) error { + ingresses, err := r.getIngressesForIngressClass(ctx, ingressClass) + if err != nil { + return err + } + + for _, ingress := range ingresses { + before := ingress.DeepCopy() + + ingress.Status.LoadBalancer.Ingress = []networkingv1.IngressLoadBalancerIngress{} + + if apiequality.Semantic.DeepEqual(before, ingress) { + continue + } + patch := client.MergeFrom(before) + if err := r.Client.Status().Patch(ctx, &ingress, patch); err != nil { + return fmt.Errorf("failed to patch shoot object: %w", err) + } + } + + err = r.ALBClient.DeleteLoadBalancer(ctx, r.ALBConfig.Global.ProjectID, r.ALBConfig.Global.Region, string(ingressClass.UID)) + if err != nil { + return fmt.Errorf("failed to delete load balancer: %w", err) + } + + err = r.cleanupUnusedCertificates(ctx, ingressClass, nil) + if err != nil { + return err + } + + if controllerutil.RemoveFinalizer(ingressClass, finalizerName) { + err = r.Client.Update(ctx, ingressClass) + if err != nil { + return fmt.Errorf("failed to remove finalizer from IngressClass: %w", err) + } + } + + return nil +} diff --git a/pkg/alb/ingress/ingressclass_controller_test.go b/pkg/alb/ingress/ingressclass_controller_test.go new file mode 100644 index 00000000..93198f2e --- /dev/null +++ b/pkg/alb/ingress/ingressclass_controller_test.go @@ -0,0 +1,473 @@ +package ingress_test + +import ( + "context" + "fmt" + "sync" + "sync/atomic" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/stackitcloud/cloud-provider-stackit/pkg/alb/ingress" + "github.com/stackitcloud/cloud-provider-stackit/pkg/alb/ingress/spec" + "github.com/stackitcloud/cloud-provider-stackit/pkg/alb/ingress/testutil" + . "github.com/stackitcloud/cloud-provider-stackit/pkg/alb/ingress/testutil/ingress" + . "github.com/stackitcloud/cloud-provider-stackit/pkg/alb/ingress/testutil/service" + "github.com/stackitcloud/cloud-provider-stackit/pkg/stackit" + stackitconfig "github.com/stackitcloud/cloud-provider-stackit/pkg/stackit/config" + albsdk "github.com/stackitcloud/stackit-sdk-go/services/alb/v2api" + certsdk "github.com/stackitcloud/stackit-sdk-go/services/certificates/v2api" + "go.uber.org/mock/gomock" + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + projectID = "dummy-project-id" + region = "eu01" + networkID = "my-network" + controllerName = "stackit.cloud/alb-ingress" + finalizerName = "stackit.cloud/alb-ingress" + targetCertID = "real-certificate-uuid-abc-123" +) + +var _ = FDescribe("IngressClassController", func() { + var ( + recorder *record.FakeRecorder + + // namespace is the namespace in which all namespaced resources of the test case should go. + // It is cleaned up automatically when the test ends and all resource deletions will be finalized before the test case completes. + namespace *corev1.Namespace + + mockCtrl *gomock.Controller + albClient *stackit.MockApplicationLoadBalancerClient + certClient *stackit.MockCertificatesClient + + node corev1.Node + + mgrContext context.Context + mgrCancel context.CancelFunc + managerTerminated sync.WaitGroup + ) + + BeforeEach(func(ctx context.Context) { + + mockCtrl = gomock.NewController(GinkgoT()) + recorder = record.NewFakeRecorder(10) + + albClient = stackit.NewMockApplicationLoadBalancerClient(mockCtrl) + certClient = stackit.NewMockCertificatesClient(mockCtrl) + mgrContext, mgrCancel = context.WithCancel(context.Background()) + + namespace = &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "stackit-alb-ingress-test-", + }, + } + Expect(k8sClient.Create(ctx, namespace)).To(Succeed()) + DeferCleanup(func(ctx context.Context) { + // There is no namespace controller deployed. + Expect(k8sClient.Delete(ctx, namespace)).To(Succeed()) + }) + + node = corev1.Node{ + ObjectMeta: metav1.ObjectMeta{Name: "test-node"}, + Status: corev1.NodeStatus{ + Addresses: []corev1.NodeAddress{{Type: corev1.NodeInternalIP, Address: "10.10.10.10"}}, + }, + } + Expect(k8sClient.Create(ctx, &node)).To(Succeed()) + DeferCleanup(func(ctx context.Context) { + Expect(k8sClient.Delete(ctx, &node)).To(Succeed()) + }) + + mgr, err := ctrl.NewManager(cfg, ctrl.Options{ + Scheme: scheme.Scheme, + }) + Expect(err).NotTo(HaveOccurred()) + + reconciler := ingress.IngressClassReconciler{ + Recorder: recorder, + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + ALBClient: albClient, + CertificateClient: certClient, + ALBConfig: stackitconfig.ALBConfig{ + Global: stackitconfig.GlobalOpts{ + ProjectID: projectID, + Region: region, + }, + ApplicationLoadBalancer: stackitconfig.ApplicationLoadBalancerOpts{NetworkID: networkID}}, + } + + Expect(reconciler.SetupWithManager(ctx, mgr, namespace.Name)).To(Succeed()) + + managerTerminated.Add(1) + go func() { + defer GinkgoRecover() + err = mgr.Start(mgrContext) + managerTerminated.Done() + Expect(err).NotTo(HaveOccurred()) + }() + DeferCleanup(func() { + mgrCancel() + // Canceling the context doesn't cause the manager to stop immediately. + // We have to wait for manager.Start() to return to ensure that the manager doesn't "spill" into the next test case. + managerTerminated.Wait() + mockCtrl.Finish() + }) + + }) + + Context("when the IngressClass does not match controller", func() { + It("should ignore the IngressClass and not append finalizers", func(ctx context.Context) { + ignoredIngressClass := &networkingv1.IngressClass{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "ignored-ingressclass-", + }, + Spec: networkingv1.IngressClassSpec{ + Controller: "some.other/controller", + }, + } + Expect(k8sClient.Create(ctx, ignoredIngressClass)).To(Succeed()) + DeferCleanup(func(ctx context.Context) { + testutil.DeleteAndWaitForKubernetesResource(ctx, k8sClient, ignoredIngressClass) + }) + + Consistently(func(g Gomega) { + err := k8sClient.Get(ctx, client.ObjectKeyFromObject(ignoredIngressClass), ignoredIngressClass) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(ignoredIngressClass.Finalizers).To(BeEmpty()) + }, "2s", "200ms").Should(Succeed()) + + }) + }) + + It("should create an empty ALB for an ingress class matching the controller", func(ctx context.Context) { + certClient.EXPECT().ListCertificate(gomock.Any(), gomock.Any(), gomock.Any()).Return(new(certsdk.ListCertificatesResponse{ + Items: []certsdk.GetCertificateResponse{}, + }), nil).AnyTimes() + albClient.EXPECT().GetLoadBalancer(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, stackit.ErrorNotFound).AnyTimes() + done := make(chan any) + albClient.EXPECT().CreateLoadBalancer(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, _, _ string, _ *albsdk.CreateLoadBalancerPayload) (*albsdk.LoadBalancer, error) { + // TODO: verify arguments + close(done) + return new(albsdk.LoadBalancer{}), nil + }).MinTimes(1) // TODO: Change to exactly once. + + ingressClass := &networkingv1.IngressClass{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "managed-ingressclass-", + }, + Spec: networkingv1.IngressClassSpec{ + Controller: controllerName, + }, + } + Expect(k8sClient.Create(ctx, ingressClass)).To(Succeed()) + DeferCleanup(func() { + testutil.DeleteAndWaitForKubernetesResource(ctx, k8sClient, ingressClass) + }) + + WaitUntilFinalizerAttached(ctx, k8sClient, ingressClass) + + Eventually(done).WithTimeout(5 * time.Second).Should(BeClosed()) + }) + + // The ALB is already created when BeforeEach completes. + Context("with IngressClass matching the controller", func() { + var ( + ingressClass *networkingv1.IngressClass + + getLoadBalancerResponse *atomic.Pointer[albsdk.LoadBalancer] + listCertificatesResponse *atomic.Pointer[certsdk.ListCertificatesResponse] + ) + + BeforeEach(func(ctx context.Context) { + getLoadBalancerResponse = &atomic.Pointer[albsdk.LoadBalancer]{} + listCertificatesResponse = &atomic.Pointer[certsdk.ListCertificatesResponse]{} + listCertificatesResponse.Store(&certsdk.ListCertificatesResponse{Items: []certsdk.GetCertificateResponse{}}) + + certClient.EXPECT().ListCertificate(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(ctx context.Context, projectID, region string) (*certsdk.ListCertificatesResponse, error) { + return listCertificatesResponse.Load(), nil + }).AnyTimes() + + albClient.EXPECT().GetLoadBalancer(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, projectID, region, name string) (*albsdk.LoadBalancer, error) { + lb := getLoadBalancerResponse.Load() + if lb == nil { + return nil, stackit.ErrorNotFound + } + return lb, nil + }).AnyTimes() + albClient.EXPECT().CreateLoadBalancer(gomock.Any(), projectID, region, gomock.Any()).DoAndReturn(func(_ context.Context, _, _ string, create *albsdk.CreateLoadBalancerPayload) (*albsdk.LoadBalancer, error) { + // TODO: check name + response := albsdk.LoadBalancer(*create) + response.Version = new("version-after-create") + response.ExternalAddress = new("127.0.0.1") + response.Status = new(stackit.LBStatusReady) + getLoadBalancerResponse.Store(&response) + return &response, nil + }).Times(1) + + ingressClass = &networkingv1.IngressClass{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "ingressclass-", + }, + Spec: networkingv1.IngressClassSpec{ + Controller: controllerName, + }, + } + Expect(k8sClient.Create(ctx, ingressClass)).To(Succeed()) + // Wait for CreateLoadBalancer to be called, i.e. getLoadBalancerResponse to not be nil. + Eventually(getLoadBalancerResponse).Should(testutil.HaveAtomicValue[albsdk.LoadBalancer](Not(BeNil()))) + }) + + It("should create certificate and referenced in ALB", func(ctx context.Context) { + updateRequest := &atomic.Pointer[albsdk.UpdateLoadBalancerPayload]{} + certClient.EXPECT().CreateCertificate(gomock.Any(), projectID, region, gomock.Any()).DoAndReturn(func(ctx context.Context, projectID, region string, certificate *certsdk.CreateCertificatePayload) (*certsdk.GetCertificateResponse, error) { + fingerprint, err := spec.ValidateTLSCertAndFingerprint([]byte(*certificate.PublicKey), []byte(*certificate.PrivateKey)) + if err != nil { + return nil, fmt.Errorf("invalid certificate: %w", err) + } + response := certsdk.GetCertificateResponse{ + Id: new("random-certificate-id"), + Labels: certificate.Labels, + Data: &certsdk.Data{ + FingerprintSha256: new(fingerprint), + }, + PublicKey: certificate.PublicKey, + } + listCertificatesResponse.Store(&certsdk.ListCertificatesResponse{ + Items: []certsdk.GetCertificateResponse{response}, + }) + return &response, nil + }).Times(1) + albClient.EXPECT().UpdateLoadBalancer(gomock.Any(), projectID, region, gomock.Any(), gomock.Any()).DoAndReturn(func(ctx context.Context, projectID, region, name string, update *albsdk.UpdateLoadBalancerPayload) (*albsdk.LoadBalancer, error) { + response := albsdk.LoadBalancer(*update) + response.Version = new("version-after-update") + response.ExternalAddress = new("127.0.0.1") + response.Status = new(stackit.LBStatusReady) + getLoadBalancerResponse.Store(&response) + + updateRequest.Store(update) + return (*albsdk.LoadBalancer)(update), nil + }).Times(1) + + secret := corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Namespace: corev1.NamespaceDefault, Name: "my-tls-cert"}, + Type: corev1.SecretTypeTLS, + Data: map[string][]byte{ + corev1.TLSCertKey: []byte(fixtureTLSPublicKey), + corev1.TLSPrivateKeyKey: []byte(fixtureTLSPrivateKey), + }, + } + Expect(k8sClient.Create(ctx, &secret)).To(Succeed()) + service := Service(corev1.NamespaceDefault, "my-service", WithServiceType(corev1.ServiceTypeNodePort), WithPort("http", 80, 30000, corev1.ProtocolTCP)) + Expect(k8sClient.Create(ctx, &service)).To(Succeed()) + ingress := Ingress(corev1.NamespaceDefault, "my-ingress", WithIngressClass(ingressClass.Name), WithTLSSecret(secret.Name), + WithRule("my-host.local", WithPath("/", new(networkingv1.PathTypePrefix), service.Name, networkingv1.ServiceBackendPort{Number: 80})), + ) + Expect(k8sClient.Create(ctx, &ingress)).To(Succeed()) + + Eventually(updateRequest).Should(testutil.HaveAtomicValue[albsdk.UpdateLoadBalancerPayload](Not(BeNil()))) + update := updateRequest.Load() + Expect(update.Version).To(HaveValue(Equal("version-after-create"))) + Expect(update.Listeners[1].Https.CertificateConfig.CertificateIds).To(ConsistOf("random-certificate-id")) + }) + + /* Context("When deleting an IngressClass", func() { + BeforeEach(func() { + // 1. Point our managed IngressClass definition to include the target testing labels + managedIngressClass = &networkingv1.IngressClass{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "managed-ingressclass-", + UID: "envtest-ic-uid", + Labels: map[string]string{ + labels.LabelIngressClassUID: "target-cloud-alb-id", + }, + }, + Spec: networkingv1.IngressClassSpec{Controller: controllerName}, + } + + setupMocks = func(m *stackit.MockApplicationLoadBalancerClient) { + m.EXPECT(). + GetLoadBalancer(gomock.Any(), projectID, region, gomock.Any()). + Return(&albsdk.LoadBalancer{Status: new("READY")}, nil). + AnyTimes() + m.EXPECT(). + UpdateLoadBalancer(gomock.Any(), projectID, region, gomock.Any(), gomock.Any()). + Return(&albsdk.LoadBalancer{Status: new("READY")}, nil). + AnyTimes() // "allow background threads update safely without breaking my test" + + m.EXPECT(). + DeleteLoadBalancer(gomock.Any(), projectID, region, gomock.Any()). + Return(nil). + Times(1) // Asserts that the controller MUST call this exactly 1 time! + + } + + }) + + It("should read the UID label, delete associated ALB and certificate ", func(ctx context.Context) { + + // should delete the associated ALB and Certificate + certClient.EXPECT(). + DeleteCertificate(gomock.Any(), projectID, region, targetCertID). + Return(nil). + AnyTimes() + + // Publish the labeled IngressClass to the test cluster + Expect(k8sClient.Create(ctx, managedIngressClass)).To(Succeed()) + + // Wait for the controller background loop to notice it and attach the finalizer + WaitUntilFinalizerAttached(ctx, k8sClient, managedIngressClass) + + // Issue the Delete call to test the teardown pipeline + Expect(k8sClient.Delete(ctx, managedIngressClass)).To(Succeed()) + + // Verify the finalizer gets scrubbed and the object disappears from the API Server + Eventually(func(g Gomega) { + var ic networkingv1.IngressClass + err := k8sClient.Get(ctx, client.ObjectKeyFromObject(managedIngressClass), &ic) + + g.Expect(apierrors.IsNotFound(err)).To(BeTrue(), "The object must be deleted completely") + }, "5s", "200ms").Should(Succeed()) + }) + }) */ + }) + +}) + +func testIngress(class *networkingv1.IngressClass, service *corev1.Service) *networkingv1.Ingress { + return &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{Name: "test-ingress", Namespace: service.Namespace}, + Spec: networkingv1.IngressSpec{ + IngressClassName: new(class.Name), + Rules: []networkingv1.IngressRule{ + { + Host: "example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/", + PathType: new(networkingv1.PathTypePrefix), + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: service.Name, + Port: networkingv1.ServiceBackendPort{Number: service.Spec.Ports[0].Port}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } +} + +// WaitUntilFinalizerAttached blocks until the controller successfully injects our tracking string +func WaitUntilFinalizerAttached(ctx context.Context, cl client.Client, ic *networkingv1.IngressClass) { + GinkgoHelper() // Tells Ginkgo to report failures on the line that calls this function, not here! + + reconciledIngressClass := &networkingv1.IngressClass{} + Eventually(func(g Gomega) { + err := cl.Get(ctx, client.ObjectKeyFromObject(ic), reconciledIngressClass) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(reconciledIngressClass.Finalizers).To(ContainElement(finalizerName)) + }, "5s", "200ms").Should(Succeed()) +} + +const ( + fixtureTLSPublicKey = `-----BEGIN CERTIFICATE----- +MIIFmzCCA4OgAwIBAgIUbhg0VsnIT3fREtGHtyj1YYY1mkUwDQYJKoZIhvcNAQEL +BQAwXTELMAkGA1UEBhMCREUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM +GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDEWMBQGA1UEAwwNbXktaG9zdC5sb2Nh +bDAeFw0yNjA2MTYwODU4MzVaFw0yNzA2MTYwODU4MzVaMF0xCzAJBgNVBAYTAkRF +MRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRz +IFB0eSBMdGQxFjAUBgNVBAMMDW15LWhvc3QubG9jYWwwggIiMA0GCSqGSIb3DQEB +AQUAA4ICDwAwggIKAoICAQDBwBCu7Bc77uMgUOslDJUObgG5FZUYWzdo6owK6Qmo +aNfvjmwwkbMHLqu8t6ZNi9UoRTJ1G9GeM8JtPL+bikKu1ZjN2MbO6VHI3xy0Az85 +r2/FKta1faFcrV7Vul/zJqAljf4qeTK31mFmZq1is86Q0wYcEf3qnNDafN5ThGT/ +F7akDlKTDG1RmyXHw+/90TINZ6q8Rqf5kI3EV63zlrG6iRJ38Dphge8Hk+ZGjURm +qx7Jz2iJkRGbIB53ZDEBk+KWM6K7iUbswmJv4qyat8P7Bv2Iisob9LVhU//852f+ +vdmdxoebUn6dGjsNv9lX0qKiEzcE1Lm2SPNIB3bfY5xNnKNjCT7qZ4NXKoeTTwLK +S+gN8zcY3Sdb8kyCKmhIGA4TXsQEyhzG/YwYGE/VgOEgv324VDGB5FcT+VcjZiHD +6nzDfqKH3NkaJ70PsCa4t3scHogkWQLnGMJd2/T+t/L3tVPZaJearexh6RUZJlIW +gCCAMqJPoALKzGrfSHhiy5L+ghpEgSnh4ZiWYxNbPcbGOXygxOZVnNc4y1PNb+vX +hXGU16wSoWQf8cZA0WDKiXLFz7qM6tAS49PJsHalWryE3qO741D/fOgl7Nzsi6MR +0lMsR9pCptIPPmiY/5f6pFxgS08IJFhaxAybCEuroLLBdXBMcD2SmSP7Scm2az09 +1QIDAQABo1MwUTAdBgNVHQ4EFgQUjdu/uxlLXaaafQIdx6gZZ45cxgswHwYDVR0j +BBgwFoAUjdu/uxlLXaaafQIdx6gZZ45cxgswDwYDVR0TAQH/BAUwAwEB/zANBgkq +hkiG9w0BAQsFAAOCAgEAX+/DmcP+iAqOo0WaOOvM7V4Iz8EAXSRdgMgi+xPRH8Dt +gYe1xc0eb3UJkkeOusrQKfEXbC47X905aAGACPNqLs+Mm40h8bctAqKExgFM7noM +8OK/y1I3RjDtbCMHJ5uCanuuqgVpXuuSWOafwY21n2mPi15+wjYJlk9YOVPAXkIl +wHpWwGv+4uuD0ppTHwF2bLFpypeVSsVLQdQ/F6H2K6QFIaHXhMZm2m1wLdD8AuiU +1AagiwOQwnGcSzKSjptO1DjWlJOPffcAzO2zXq3HT4Y3debbiKIY5uhXJfU7u82D +Q45dms99DN6FzFONf92NfHI48PAmHXFD8xoKOYejcsV/Fe0coccCbbj/wlReVabt +PE0skr0z12hPkQ6+BQri2nxKqbQPCyLKQNJ4p1ku2v73TX0zd2fU+P3mV0UoFovF +/8vOqc6J+MyrDSzvqdunEPL8pG6ziGnhC2fT2e41LYKWQqkBjFIQnEeTcr0pVdiG +R4dGu19QV3PBoX2IbLexndiYGCJuBsKpjIu5C4Z5BibXXZdngPwpWdaoG2DZQZ2s +okmiQzkHzZ3ADR/UVqTDICjr8gEzjZRfgwEt+jIkgEV7i5S9GS9miyzUKPi6pEuL +JGVFbYQdFntS/izqlEV0L+3te0WKQIEX6Sq8wdxg0twpRdzaMepJiLTYi/YxJa8= +-----END CERTIFICATE-----` + fixtureTLSPrivateKey = `-----BEGIN PRIVATE KEY----- +MIIJQQIBADANBgkqhkiG9w0BAQEFAASCCSswggknAgEAAoICAQDBwBCu7Bc77uMg +UOslDJUObgG5FZUYWzdo6owK6QmoaNfvjmwwkbMHLqu8t6ZNi9UoRTJ1G9GeM8Jt +PL+bikKu1ZjN2MbO6VHI3xy0Az85r2/FKta1faFcrV7Vul/zJqAljf4qeTK31mFm +Zq1is86Q0wYcEf3qnNDafN5ThGT/F7akDlKTDG1RmyXHw+/90TINZ6q8Rqf5kI3E +V63zlrG6iRJ38Dphge8Hk+ZGjURmqx7Jz2iJkRGbIB53ZDEBk+KWM6K7iUbswmJv +4qyat8P7Bv2Iisob9LVhU//852f+vdmdxoebUn6dGjsNv9lX0qKiEzcE1Lm2SPNI +B3bfY5xNnKNjCT7qZ4NXKoeTTwLKS+gN8zcY3Sdb8kyCKmhIGA4TXsQEyhzG/YwY +GE/VgOEgv324VDGB5FcT+VcjZiHD6nzDfqKH3NkaJ70PsCa4t3scHogkWQLnGMJd +2/T+t/L3tVPZaJearexh6RUZJlIWgCCAMqJPoALKzGrfSHhiy5L+ghpEgSnh4ZiW +YxNbPcbGOXygxOZVnNc4y1PNb+vXhXGU16wSoWQf8cZA0WDKiXLFz7qM6tAS49PJ +sHalWryE3qO741D/fOgl7Nzsi6MR0lMsR9pCptIPPmiY/5f6pFxgS08IJFhaxAyb +CEuroLLBdXBMcD2SmSP7Scm2az091QIDAQABAoICABd8+kjKdFKetkgvpyIZsWRL +b8gJVsbaIBCHBq037STOeQcgo/sLXsHLJaS+OtoBzriQEvrhgXsFWVe22p+3ljft +yxWBZzCkVnbcnXUxQ5PxscIcXGUqMsqydeHBM2qdzyJeYWayxLRGuA4a+oARvkQO +YRo8ECVGF4e1RZqoXToTnN+soNQU2JfhECZ0mX6SwtefLrKeejSmEpmv63WxWiB8 +B5IkvF8fymOHyY3aCGXN7vCWRV0QCitdLHRa4BoJ3JlK7zp+/Oss8ZQQzc3/4zFm +eov4D2JuOyLudQUq5I+cYmpfLAdna9QN3wTesjGUZoTxgWUDiPQRSfT8eqvAPq1v +yS9nQWC2bYwjngsauwtYBjY/Z0mParwLCRJLhOtsqZ6h9YqMAgwzAbfGazzTYDoH +gROUER+wCj1A41z5x5dADbtZkHqdJf6oVBbunH7rTz5KwvzH9DeCh6/+zhLOL27f +9UvVOoowQ4GPB07wrkpf+W1XvAO9jWV3bBReYO2OTd5D5HOChGlD0YYhr8aTKBlu +ql8qHqBB+8HBUfxYulXuN7qnq+o6f5T9exwaIGGgAHshbTuTO5aNOgQeL834D2wq +U2T3FG8xDRTfaxr9LbwyykQCkQX5rYzbua3hUepd9zQdJSr1CBJd85EqGWphDJ4z +7gFxwCInifd8UjJlJ6ntAoIBAQD0cI/zZglqemBeeB2dQNtabrKHhrR6EVPZgbHP +jAbsh8KuQ21jOQM+yncbvvcaKNOIbiw4fFmu538khmlF1YrkSxkd3z6blFRXefG8 +2Cx4Zt/xVxX4VWSayUpiYA0wWv3Vr9n5KdYVtHxhPjbFL8w+0X8/l5fuB8bUhR7m +YyqkC/dVyeuHURuJN4p/6nuXg2h8Bbjs/tw/eBFnED6lZinyaQSeW9w7/0IODbII +/SU6Bhj+BNaYAl+U2Vfq7IddtvogOvJJOlTOxkls7f4a0Ms8ehympEyv/Y/5eVMB +OF9/ToNLGnBTQUBWBy4aEngXMybY+zcXmNJ05KYH9i5gaDBDAoIBAQDK6c3yqxfV +8SJStVAZYI66QrudQr5TrLeEqoyrsn9Oe80svi7CzG34PgLOhVuYJWQBHlWVtTq7 +F9UscCGd+cRUTK+3mvimEfcy3kFW24g5mJ0pxGNAQ1MqtMggCTYWtsck8Y/NkWx5 +niQm69yMNOmMvt3a3TzZONDWsRN3uefZ0+Pl84Ef/+YTdswtuSc3NMA3diNGuIPh +rDx2SLlVLn9iEVTsYddDywaE00hnQgv0py9iPm2VoC2o26lpY3JAg1wYWpGFa/LG +vZ9kQXhGdX9wfPp3MV4tnze6hqFwN/vQKg33Xh+PQsAk8eVBqJNhk3n8PscvSOPa +hUkA8T+xk6QHAoIBADEnrZr5qu0RnO2CZBoqX7IIzrf4O7TMZTs5HIOrGf1Ys6qN +fqLUZTWsS1V2CoTlLtyhoxzczMAiZ2v155eWgK6192ANc66fnnJU4GrkYdT4gxIq +PA3LRkbmMaIkxKIzuhXNnhy/8AA/Yj+/3g27Nexv/pHQL0o7oB0+g986k+mXSm6j +A00b31ixpZVhlub6EvnVwMFP4wSUZZN/LcnfCJJp0fbybBBYnXTsBiBOn7zSWxZB +7NF2sLfjGQ3x8KrEz/nJQM2/ACzwrPVNyqqj0CriN36/TXiamehGII3/Qxz7seVZ +dLsZRRHHsdqmWiX4MFiz8/k3zyKYlFbHh731VbcCggEACCiMYkRkyfJPCfpGRS7v +rid+uZz0YBLisg/VZhXgLnylzDW9VZG4njGIFVuhSiW+tpjMoh9ORDV6GbZMc7iW +HzmSGxS9CJhSUxZClEZxXLd5IjPGNdA/KMlp/nfAV/tzWFXqDT7amK02EOaM0IpU +FZea/fDFQIqbQvaNrNOpscVmNVmsCGhWjNPK88+s9vhE/jXexzol+03chHj6EqWy +83N08aghapVgJrkEATrTljuemRmfeFOfYlmqnxUjg9qEOmpxzWaAtWLsZLCJMHQK +8q/jtiUi/zyWlgZRuVxW4JDATQDYzf7GEPY03IX1nwe58N1pTspkduXDAKmygOZJ +wwKCAQBTVZRSmQ/jzidcr5XBrU+qCIvfEvBLazc92GvoxYbBiXkMMlJIa/HzeYZR +C4urK9s7saMV3dIuo9laXnmjCx3T3ql7PvCUu250TKshM4w+6SVr+LlMLvmiH2vr +5ExTtdU7j6O5uq5+/tOsuBvC5UPmPYJfrWLSuF0OlhjtUPnQE7qUhIpGsq/uZLBJ +2KEUTroXmqKytomC4fHDKZdPexPS+tOKZ63HFxDYWM6LkcTBoXAmFejlJUzV5h2r +0kSRgTzjA/YZ67+MLsu+zz+7Q/triFveizJKLjHc6/Eo/c2XWk9h1XgYG19BBWqb +UoA+9Hd41MHTo2Frp1cML2BpdbK/ +-----END PRIVATE KEY-----` +) diff --git a/pkg/alb/ingress/ingressclass_controller_unit_test.go b/pkg/alb/ingress/ingressclass_controller_unit_test.go new file mode 100644 index 00000000..1dfd4631 --- /dev/null +++ b/pkg/alb/ingress/ingressclass_controller_unit_test.go @@ -0,0 +1,480 @@ +package ingress + +/* import ( + "context" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + stackitconfig "github.com/stackitcloud/cloud-provider-stackit/pkg/stackit/config" + gomock "go.uber.org/mock/gomock" + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/scheme" + "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" + testNamespace = "test-namespace" + testPublicIP = "1.2.3.4" + testPrivateIP = "10.0.0.1" + testIngressName = "test-ingress" + testIngressClassName = "k8s-ingress-test-ingress-class" + testIngressClassUID = "11111111-2222-3333-4444-555555555555" + testALBName = testIngressClassUID +) + +//nolint:funlen // Just many test cases. +func TestIngressClassReconciler_updateStatus(t *testing.T) { + testIngressClass := &networkingv1.IngressClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: testIngressClassName, + UID: testIngressClassUID, + }, + } + + testService := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-service", + Namespace: testNamespace, + }, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + {Port: 8080}, + }, + }, + } + + 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(), new(testIngress(testIngressClass, testService))) + + }, + mockALBClient: func(m *stackit.MockApplicationLoadBalancerClient) { + m.EXPECT(). + GetLoadBalancer(gomock.Any(), testProjectID, testRegion, testALBName). + Return(&albsdk.LoadBalancer{ + Status: new("STATUS_TERMINATING"), + }, nil) + }, + wantResult: reconcile.Result{RequeueAfter: 10 * time.Second}, + wantErr: false, + }, + { + name: "ALB ready, public IP available, ingress status needs update", + ingresses: []*networkingv1.Ingress{ + new(testIngress(testIngressClass, testService)), + }, + mockK8sClient: func(c client.Client) error { + return c.Create(context.Background(), new(testIngress(testIngressClass, testService))) + }, + mockALBClient: func(m *stackit.MockApplicationLoadBalancerClient) { + m.EXPECT(). + GetLoadBalancer(gomock.Any(), testProjectID, testRegion, testALBName). + Return(&albsdk.LoadBalancer{ + Status: new("STATUS_READY"), + ExternalAddress: new(testPublicIP), + }, nil) + }, + wantResult: reconcile.Result{}, + wantErr: false, + }, + { + name: "ALB ready, private IP available, ingress status needs update", + ingresses: []*networkingv1.Ingress{ + new(testIngress(testIngressClass, testService)), + }, + mockK8sClient: func(c client.Client) error { + return c.Create(context.Background(), new(testIngress(testIngressClass, testService))) + }, + mockALBClient: func(m *stackit.MockApplicationLoadBalancerClient) { + m.EXPECT(). + GetLoadBalancer(gomock.Any(), testProjectID, testRegion, testALBName). + Return(&albsdk.LoadBalancer{ + Status: new("STATUS_READY"), + PrivateAddress: new(testPrivateIP), + }, nil) + }, + wantResult: reconcile.Result{}, + wantErr: false, + }, + { + name: "ALB ready, IP already correct, no status update", + ingresses: []*networkingv1.Ingress{ + new(func() networkingv1.Ingress { + ing := testIngress(testIngressClass, testService) + ing.Spec.IngressClassName = new(testIngressClassName) + ing.Status = networkingv1.IngressStatus{ + LoadBalancer: networkingv1.IngressLoadBalancerStatus{ + Ingress: []networkingv1.IngressLoadBalancerIngress{{IP: testPublicIP}}, + }, + } + return ing + }()), + }, + mockK8sClient: func(c client.Client) error { + ing := testIngress(testIngressClass, testService) + ing.Spec.IngressClassName = new(testIngressClassName) + if err := c.Create(context.Background(), &ing); err != nil { + return err + } + + ing.Status = networkingv1.IngressStatus{ + LoadBalancer: networkingv1.IngressLoadBalancerStatus{ + Ingress: []networkingv1.IngressLoadBalancerIngress{{IP: testPublicIP}}, + }, + } + return c.Status().Update(context.Background(), &ing) + }, + mockALBClient: func(m *stackit.MockApplicationLoadBalancerClient) { + m.EXPECT(). + GetLoadBalancer(gomock.Any(), testProjectID, testRegion, testALBName). + Return(&albsdk.LoadBalancer{ + Status: new("STATUS_READY"), + ExternalAddress: new(testPublicIP), + }, nil) + }, + wantResult: reconcile.Result{}, + wantErr: false, + }, + { + name: "failed to get load balancer", + ingresses: []*networkingv1.Ingress{ + new(testIngress(testIngressClass, testService)), + }, + mockALBClient: func(m *stackit.MockApplicationLoadBalancerClient) { + m.EXPECT().GetLoadBalancer(gomock.Any(), testProjectID, testRegion, testALBName).Return(nil, stackit.ErrorNotFound) + }, + wantResult: reconcile.Result{}, + wantErr: true, + }, + { + name: "ALB ready, no public or private IP, return error", + ingresses: []*networkingv1.Ingress{ + new(testIngress(testIngressClass, testService)), + }, + mockALBClient: func(m *stackit.MockApplicationLoadBalancerClient) { + m.EXPECT(). + GetLoadBalancer(gomock.Any(), testProjectID, testRegion, testALBName). + Return(&albsdk.LoadBalancer{ + Status: new("STATUS_READY"), + }, nil) + }, + wantResult: reconcile.Result{}, + wantErr: true, + }, + } + + 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, + ALBConfig: stackitconfig.ALBConfig{ + Global: stackitconfig.GlobalOpts{ + 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) + } + + expectedIngress := testIngress(testIngressClass, testService) + + got, err := r.updateStatus(context.Background(), 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) + } + + if tt.name == "ALB ready, public IP available, ingress status needs update" { + latestIngress := &networkingv1.Ingress{} + if err := fakeClient.Get(context.Background(), client.ObjectKey{Namespace: expectedIngress.Namespace, Name: expectedIngress.Name}, latestIngress); err != nil { + t.Fatalf("failed to fetch ingress: %v", err) + } + if len(latestIngress.Status.LoadBalancer.Ingress) == 0 || latestIngress.Status.LoadBalancer.Ingress[0].IP != testPublicIP { + t.Errorf("Ingress status was not updated with the expected IP!") + } + } + + }) + } +} + +func TestIngressClassReconciler_Reconcile(t *testing.T) { + + testIngressClass := &networkingv1.IngressClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: testIngressClassName, + UID: testIngressClassUID, + Finalizers: []string{finalizerName}, + }, + Spec: networkingv1.IngressClassSpec{ + Controller: controllerName, + }, + } + + tests := []struct { + name string + ingressClass *networkingv1.IngressClass + mockALB func(*stackit.MockApplicationLoadBalancerClient) + mockCerts func(*stackit.MockCertificatesClient) + wantResult reconcile.Result + wantErr bool + checkFinalizer bool + }{ + { + name: "existing ingress class, happy", + ingressClass: testIngressClass, + mockALB: func(m *stackit.MockApplicationLoadBalancerClient) { + m.EXPECT(). + GetLoadBalancer(gomock.Any(), testProjectID, testRegion, testALBName). + Return(&albsdk.LoadBalancer{ + Status: new("STATUS_READY"), + ExternalAddress: new(testPublicIP), + }, nil).Times(2) + }, + wantResult: reconcile.Result{}, + wantErr: false, + }, + { + name: "ingress class doesn't match the controller, should ignore and exit cleanly", + ingressClass: &networkingv1.IngressClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: testIngressClassName, + UID: testIngressClassUID, + Finalizers: []string{finalizerName}, + }, + Spec: networkingv1.IngressClassSpec{ + Controller: "unknown-controller", + }, + }, + mockALB: nil, + wantResult: reconcile.Result{}, + wantErr: false, + }, + { + name: "ingress class has emtpy/mismatched controller specs, should ignore and exit cleanly", + ingressClass: &networkingv1.IngressClass{}, + mockALB: nil, + wantResult: reconcile.Result{}, + wantErr: false, + }, + { + name: "ingress class not found, should ignore and exit cleanly", + ingressClass: nil, + mockALB: nil, + wantResult: reconcile.Result{}, + wantErr: false, + }, + { + name: "missing finalizer, should add finalizer", + ingressClass: &networkingv1.IngressClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: testIngressClassName, + UID: testIngressClassUID, + Finalizers: []string{}, + }, + Spec: networkingv1.IngressClassSpec{ + Controller: controllerName, + }, + }, + mockALB: func(m *stackit.MockApplicationLoadBalancerClient) {}, + wantResult: reconcile.Result{}, + wantErr: false, + checkFinalizer: true, + }, + { + name: "ALB status not ready, should requeue", + ingressClass: &networkingv1.IngressClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: testIngressClassName, + UID: testIngressClassUID, + Finalizers: []string{finalizerName}, + }, + Spec: networkingv1.IngressClassSpec{ + Controller: controllerName, + }, + }, + + mockALB: func(m *stackit.MockApplicationLoadBalancerClient) { + m.EXPECT(). + GetLoadBalancer(gomock.Any(), testProjectID, testRegion, testALBName). + Return(&albsdk.LoadBalancer{ + Status: new("STATUS_NOT_READY"), + ExternalAddress: new(testPublicIP), + }, nil).Times(2) + }, + wantResult: reconcile.Result{RequeueAfter: 10 * time.Second}, + wantErr: false, + checkFinalizer: true, + }, + { + name: "ALB does not exist yet, should create a new one successfully", + ingressClass: testIngressClass, + mockALB: func(m *stackit.MockApplicationLoadBalancerClient) { + m.EXPECT(). + GetLoadBalancer(gomock.Any(), testProjectID, testRegion, testALBName). + Return(nil, stackit.ErrorNotFound) + m.EXPECT(). + CreateLoadBalancer(gomock.Any(), testProjectID, testRegion, gomock.Any()). + Return(&albsdk.LoadBalancer{}, nil) + m.EXPECT(). + GetLoadBalancer(gomock.Any(), testProjectID, testRegion, testALBName). + Return(&albsdk.LoadBalancer{ + Status: new("STATUS_READY"), + ExternalAddress: new(testPublicIP), + }, nil) + }, + wantResult: reconcile.Result{}, + wantErr: false, + }, + { + name: "ingress class has deletion timestamp, should run handleIngressClassDeletion", + ingressClass: &networkingv1.IngressClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: testIngressClassName, + UID: testIngressClassUID, + DeletionTimestamp: &metav1.Time{Time: time.Now()}, + Finalizers: []string{finalizerName}, + }, + Spec: networkingv1.IngressClassSpec{Controller: controllerName}, + }, + mockALB: func(m *stackit.MockApplicationLoadBalancerClient) { + m.EXPECT(). + DeleteLoadBalancer(gomock.Any(), testProjectID, testRegion, testALBName). + Return(nil) + }, + mockCerts: func(m *stackit.MockCertificatesClient) { + m.EXPECT(). + ListCertificate(gomock.Any(), testProjectID, testRegion). + Return(nil, nil) + }, + wantResult: reconcile.Result{}, + wantErr: false, + }, + { + name: "ALB configuration has changed, should issue an update call", + ingressClass: testIngressClass, + mockALB: func(m *stackit.MockApplicationLoadBalancerClient) { + m.EXPECT(). + GetLoadBalancer(gomock.Any(), testProjectID, testRegion, testALBName). + Return(&albsdk.LoadBalancer{ + Status: new("STATUS_READY"), + Version: new("lb-v1"), + Listeners: []albsdk.Listener{{Port: new(int32(80))}}, // add a listener to empty config + }, nil) + + m.EXPECT(). + UpdateLoadBalancer(gomock.Any(), testProjectID, testRegion, testALBName, gomock.Any()). + Return(&albsdk.LoadBalancer{}, nil) + + m.EXPECT(). + GetLoadBalancer(gomock.Any(), testProjectID, testRegion, testALBName). + Return(&albsdk.LoadBalancer{ + Status: new("STATUS_READY"), + ExternalAddress: new(testPublicIP), + }, nil) + }, + wantResult: reconcile.Result{}, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + mockAlbClient := stackit.NewMockApplicationLoadBalancerClient(ctrl) + mockCertsClient := stackit.NewMockCertificatesClient(ctrl) + + clientBuilder := fake.NewClientBuilder().WithScheme(scheme.Scheme) + if tt.ingressClass != nil { + clientBuilder.WithRuntimeObjects(tt.ingressClass) + } + fakeClient := clientBuilder.Build() + + r := &IngressClassReconciler{ + Client: fakeClient, + ALBClient: mockAlbClient, + CertificateClient: mockCertsClient, + ALBConfig: stackitconfig.ALBConfig{ + Global: stackitconfig.GlobalOpts{ + ProjectID: testProjectID, + Region: testRegion, + }, + }, + } + + if tt.mockALB != nil { + tt.mockALB(mockAlbClient) + } + if tt.mockCerts != nil { + tt.mockCerts(mockCertsClient) + } + + req := reconcile.Request{ + NamespacedName: client.ObjectKey{ + Name: testIngressClassName, + }, + } + + got, err := r.Reconcile(context.Background(), req) + if (err != nil) != tt.wantErr { + t.Fatalf("Reconcile() - expected error %v, got %v", tt.wantErr, err) + } + if diff := cmp.Diff(tt.wantResult, got); diff != "" { + t.Fatalf("Reconcile() - unexpected result (-want +got):\n%s", diff) + } + + if tt.checkFinalizer { + // fetching the absolute latest state of the object directly from the fake K8s API server + latestClass := &networkingv1.IngressClass{} + key := client.ObjectKey{Name: testIngressClassName} + + if fetchErr := fakeClient.Get(context.Background(), key, latestClass); fetchErr != nil { + t.Fatalf("Failed to fetch latest IngressClass state from fake client: %v", fetchErr) + } + + // assertion: the finalizer string list is no longer empty and contains finalizerName + if len(latestClass.Finalizers) == 0 { + t.Errorf("Verification failed: expected IngressClass to have finalizers, but the list is empty") + } else if latestClass.Finalizers[0] != finalizerName { + t.Errorf("Verification failed: expected finalizer %q, but found %q", finalizerName, latestClass.Finalizers[0]) + } + } + }) + } +} +*/ diff --git a/pkg/alb/ingress/spec/annotations.go b/pkg/alb/ingress/spec/annotations.go new file mode 100644 index 00000000..7faf25cf --- /dev/null +++ b/pkg/alb/ingress/spec/annotations.go @@ -0,0 +1,106 @@ +package spec + +import ( + "strconv" + + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + // AnnotationExternalIP references a STACKIT public IP that should be used by the application load balancer. + // If set it will be used instead of an ephemeral IP. The IP must be created by the customer. When the service is deleted, + // the public IP will not be deleted. The IP is ignored if the alb.stackit.cloud/internal-alb is set. + // If the annotation is set after the creation it must match the ephemeral IP. + // This will promote the ephemeral IP to a static IP. + // Can be set on IngressClass. + AnnotationExternalIP = "alb.stackit.cloud/external-address" + // AnnotationInternal If true, the application load balancer is not exposed via a public IP. + // Can be set on IngressClass. + AnnotationInternal = "alb.stackit.cloud/internal-alb" + // AnnotationPlanID sets the plan for the ALB. + // Can be set on IngressClass. + AnnotationPlanID = "alb.stackit.cloud/plan-id" + + // AnnotationTargetPoolTLSEnabled If true, the application load balancer enables TLS bridging. + // It uses the trusted CAs from the operating system for validation. + // Can be set on IngressClass, Ingress and Service. + AnnotationTargetPoolTLSEnabled = "alb.stackit.cloud/target-pool-tls-enabled" + // AnnotationTargetPoolTLSCustomCa If set, the application load balancer enables TLS bridging with a custom CA provided as value. Is this an inlined field? What is its format? Can this be set to an empty string to reset it? + // Can be set on IngressClass, Ingress and Service + AnnotationTargetPoolTLSCustomCa = "alb.stackit.cloud/target-pool-tls-custom-ca" + // AnnotationTargetPoolTLSSkipCertificateValidation If true, the application load balancer enables TLS bridging but skips validation. + // Can be set on IngressClass, Ingress and Service. + AnnotationTargetPoolTLSSkipCertificateValidation = "alb.stackit.cloud/target-pool-tls-skip-certificate-validation" + + // AnnotationHTTPPort Specifies the HTTP port. + // Can be set on IngressClass and Ingress. + AnnotationHTTPPort = "alb.stackit.cloud/http-port" + // AnnotationHTTPSPort Specifies the HTTPS port. + // Can be set on IngressClass and Ingress. + AnnotationHTTPSPort = "alb.stackit.cloud/https-port" + // AnnotationHTTPSOnly if true, the ingress will not be reachable via HTTP. Does that mean all ingresses are always available via HTTPS? + // Can be set on IngressClass and Ingress. + AnnotationHTTPSOnly = "alb.stackit.cloud/https-only" + + // AnnotationWebSocket TODO + // Can be set on IngressClass and Ingress. + AnnotationWebSocket = "alb.stackit.cloud/websocket" + + // AnnotationWAFName TODO + // Can be set on IngressClass and Ingress. + AnnotationWAFName = "alb.stackit.cloud/web-application-firewall-name" + + // AnnotationPriority is used to set the priority of the Ingress. Can be only set on ingress objects. + // Can be set on IngressClass and Ingress. + AnnotationPriority = "alb.stackit.cloud/priority" +) + +// GetAnnotation retrieves an annotation value from objects. +// If multiple objects contain the annotation, the later object in the slice takes precedence. +// If no object contains the annotation then defaultValue is returned. +// +// GetAnnotation parses the value of the annotation and return type T. +// If T is string then the value is returned raw. +// For int and bool Atoi and ParseBool are called respectively. +// If parsing fails or T is any other type, defaultValue is returned. +// Only the latest found value is parsed. +// +// TODO: Return parser errors?! +// TODO: Allow unsetting a value by setting the annotation to an empty string?! +func GetAnnotation[T any](annotation string, defaultValue T, objects ...client.Object) T { + var rawVal string + var found bool + + // Iterate through sources (e.g., Ingress, then IngressClass) + for _, object := range objects { + if val, exists := object.GetAnnotations()[annotation]; exists { + rawVal = val + found = true + break + } + } + + if !found { + return defaultValue + } + + var result any + var err error + + switch any(defaultValue).(type) { + case string: + return any(rawVal).(T) + case int: + result, err = strconv.Atoi(rawVal) + case bool: + result, err = strconv.ParseBool(rawVal) + default: + return defaultValue + } + + if err != nil { + return defaultValue + } + + return result.(T) +} diff --git a/pkg/alb/ingress/spec/events.go b/pkg/alb/ingress/spec/events.go new file mode 100644 index 00000000..434d6925 --- /dev/null +++ b/pkg/alb/ingress/spec/events.go @@ -0,0 +1,34 @@ +package spec + +import ( + "fmt" + + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/client-go/tools/record" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +type errorEvent struct { + ingress client.Object + description string + fieldPath *field.Path +} + +func (e *errorEvent) Error() string { + if e.fieldPath != nil { + return fmt.Sprintf("%s: %s", e.fieldPath.String(), e.description) + } + return e.description +} + +// TODO: rethink this function +func (e *errorEvent) RecordEvent(class *networkingv1.IngressClass, recorder record.EventRecorder) { + if e.ingress.GetName() == "" { + return + } + + recorder.Eventf(class, corev1.EventTypeWarning, "ALB", "Error in %s in Namespace %s: %s", e.ingress.GetName(), e.ingress.GetNamespace(), e.Error()) + recorder.Event(e.ingress, corev1.EventTypeWarning, "ALB", e.Error()) +} diff --git a/pkg/alb/ingress/spec/suite_test.go b/pkg/alb/ingress/spec/suite_test.go new file mode 100644 index 00000000..64a94bc4 --- /dev/null +++ b/pkg/alb/ingress/spec/suite_test.go @@ -0,0 +1,13 @@ +package spec + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestStackit(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "ALB Spec") +} diff --git a/pkg/alb/ingress/spec/workingtree.go b/pkg/alb/ingress/spec/workingtree.go new file mode 100644 index 00000000..12656867 --- /dev/null +++ b/pkg/alb/ingress/spec/workingtree.go @@ -0,0 +1,432 @@ +package spec + +import ( + "cmp" + "crypto/sha256" + cryptotls "crypto/tls" + "encoding/hex" + "fmt" + "slices" + "strconv" + + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + + albsdk "github.com/stackitcloud/stackit-sdk-go/services/alb/v2api" + certsdk "github.com/stackitcloud/stackit-sdk-go/services/certificates/v2api" +) + +type CertificateFingerprint string + +// WorkingTreeALB +// +// The zero value is invalid. Use BuildTree to create a working tree. +// +// Look at the methods how a working tree can be used. +type WorkingTreeALB struct { + ingressClass *networkingv1.IngressClass + planId string + + listeners map[int16]*workingTreeListener + // We can already create the real type because there is nothing to merge or track. + targetPools map[ingressPathReference]*albsdk.TargetPool + // We maintain certificates on ALB-level although we + certificates map[CertificateFingerprint]WorkingTreeCertificate + + existingALB *albsdk.LoadBalancer +} + +type workingTreeListener struct { + hosts map[string]*workingTreeHost +} + +type pathWithType struct { + pathType networkingv1.PathType + path string +} + +type workingTreeHost struct { + paths map[pathWithType]*workingTreePath +} + +type ingressPathReference struct { + namespace string + name string + uid string + ruleIndex int + pathIndex int +} + +func (i ingressPathReference) toTargetPoolName() string { + return fmt.Sprintf("%s-%d-%d", i.uid, i.ruleIndex, i.pathIndex) +} + +type workingTreePath struct { + ingressPathReference ingressPathReference + websocket bool +} + +type WorkingTreeCertificate struct { + PublicKey string + PrivateKey string +} + +// BuildTree creates a new working tree. +// +// It tries to fit as much ingresses into the working tree as possible, bound by the limits of the application load balancer. +// +// Every ingress rule translates into 1 or 2 rules in the ALB. +// +// If existingALB is nil it is assumed that no load balancer exists yet. +// +// It must return all sorts of errors. +// +// The arguments must only contain data related to the ingress class. +// +// This function might change the of ingresses in the provided slice. +func BuildTree( + ingressClass *networkingv1.IngressClass, + ingresses []networkingv1.Ingress, + secrets []corev1.Secret, + services []corev1.Service, + nodes []corev1.Node, + existingALB *albsdk.LoadBalancer, +) (*WorkingTreeALB, []errorEvent) { + errors := []errorEvent{} + + servicesMap := map[types.NamespacedName]corev1.Service{} + for _, s := range services { + servicesMap[client.ObjectKeyFromObject(&s)] = s + } + secretsMap := map[types.NamespacedName]corev1.Secret{} + for _, s := range secrets { + secretsMap[client.ObjectKeyFromObject(&s)] = s + } + + targets := getTargetsOfNodes(nodes) + + tree := &WorkingTreeALB{ + ingressClass: ingressClass, + planId: GetAnnotation(AnnotationPlanID, "", ingressClass), + + listeners: map[int16]*workingTreeListener{ + 80: new(workingTreeListener{ + hosts: map[string]*workingTreeHost{}, + }), + 443: new(workingTreeListener{ + hosts: map[string]*workingTreeHost{}, + }), + }, + targetPools: map[ingressPathReference]*albsdk.TargetPool{}, + existingALB: existingALB, + certificates: map[CertificateFingerprint]WorkingTreeCertificate{}, + } + + // TODO: Explain sorting + slices.SortFunc(ingresses, func(a, b networkingv1.Ingress) int { + if diff := GetAnnotation(AnnotationPriority, 0, &a) - GetAnnotation(AnnotationPriority, 0, &b); diff != 0 { + return diff + } + if diff := a.CreationTimestamp.Compare(b.CreationTimestamp.Time); diff != 0 { + return diff + } + return cmp.Compare(fmt.Sprintf("%s/%s", a.Namespace, a.Name), + fmt.Sprintf("%s/%s", b.Namespace, b.Name)) + }) + for _, ingress := range ingresses { + for tlsIndex, tls := range ingress.Spec.TLS { + // TODO: document that the host field is completely ignored + secret, exists := secretsMap[types.NamespacedName{Namespace: ingress.Namespace, Name: tls.SecretName}] + if !exists { + errors = append(errors, errorEvent{ + ingress: &ingress, + fieldPath: field.NewPath("spec", "tls").Index(tlsIndex).Child("secretName"), + description: "TLS secret doesn't exist", + }) + continue + } + if secret.Type != corev1.SecretTypeTLS { + errors = append(errors, errorEvent{ + ingress: &ingress, + fieldPath: field.NewPath("spec", "tls").Index(tlsIndex).Child("secretName"), + description: "TLS secret isn't of type kubernetes.io/tls", + }) + continue + } + + fingerprint, err := ValidateTLSCertAndFingerprint(secret.Data[corev1.TLSCertKey], secret.Data[corev1.TLSPrivateKeyKey]) + if err != nil { + errors = append(errors, errorEvent{ + ingress: &ingress, + fieldPath: field.NewPath("spec", "tls").Index(tlsIndex).Child("secretName"), + description: fmt.Sprintf("invalid certificate: %s", err.Error()), + }) + continue + } + + tree.certificates[CertificateFingerprint(fingerprint)] = WorkingTreeCertificate{ + PublicKey: string(secret.Data[corev1.TLSCertKey]), + PrivateKey: string(secret.Data[corev1.TLSPrivateKeyKey]), + } + } + for ruleIndex, rule := range ingress.Spec.Rules { + // TODO: support rules that don't have a path + for pathIndex, path := range rule.HTTP.Paths { + _pathWithType := pathWithType{pathType: ptr.Deref(path.PathType, networkingv1.PathTypeExact), path: path.Path} + ingressPathReference := ingressPathReference{namespace: ingress.Namespace, name: ingress.Name, uid: string(ingress.UID), ruleIndex: ruleIndex, pathIndex: pathIndex} + + // TODO: What is the default port? + host, exists := tree.listeners[80].hosts[rule.Host] + if !exists { + host = &workingTreeHost{ + paths: map[pathWithType]*workingTreePath{}, + } + tree.listeners[80].hosts[rule.Host] = host + } + // TODO: Define a semantic for ImplementationSpecific path. According to spec it MUST be supported. + albPath, exists := host.paths[_pathWithType] + if exists && albPath.ingressPathReference == ingressPathReference { + errors = append(errors, errorEvent{ + + fieldPath: field.NewPath("spec", "rules", strconv.Itoa(ruleIndex), "path", strconv.Itoa(pathIndex)), + description: "Path already exists", + }) + continue + } + if !exists { + albPath = &workingTreePath{ + ingressPathReference: ingressPathReference, + } + // TODO: check limits + } + albPath.websocket = GetAnnotation(AnnotationWebSocket, false, ingressClass, &ingress) + + targetPool, exists := tree.targetPools[ingressPathReference] + if !exists { + targetPool = &albsdk.TargetPool{} + // TODO: check limits. If the limit is breached here, we shouldn't have added the paths either. So adding the path to the tree must be delayed until all checks are completed. + } + + // TODO: Support other backends than services. + + service, exists := servicesMap[types.NamespacedName{Namespace: ingress.Namespace, Name: path.Backend.Service.Name}] + if !exists { + errors = append(errors, errorEvent{ + ingress: &ingress, + fieldPath: field.NewPath("spec", "rules").Index(ruleIndex).Child("paths").Index(pathIndex).Child("backend", "service", "name"), + description: "Service doesn't exist", + }) + continue + } + if service.Spec.Type != corev1.ServiceTypeNodePort { + errors = append(errors, errorEvent{ + ingress: &ingress, + fieldPath: field.NewPath("spec", "rules").Index(ruleIndex).Child("paths").Index(pathIndex).Child("backend", "service", "name"), + description: "Service is not of type NodePort", + }) + continue + } + nodePort := int32(0) + for _, port := range service.Spec.Ports { + if port.Port == path.Backend.Service.Port.Number || + port.Name == path.Backend.Service.Port.Name { + if port.NodePort == 0 { + errors = append(errors, errorEvent{ + ingress: &ingress, + fieldPath: field.NewPath("spec", "rules").Index(ruleIndex).Child("paths").Index(pathIndex).Child("backend", "service", "name"), + description: "Service port doesn't have a node port", + }) + continue + } + nodePort = port.NodePort + } + } + + targetPool.Name = new(ingressPathReference.toTargetPoolName()) + targetPool.TargetPort = new(nodePort) + targetPool.Targets = targets + // TODO: Use TCP health checks for eTP=Cluster + if service.Spec.ExternalTrafficPolicy == corev1.ServiceExternalTrafficPolicyLocal { + targetPool.ActiveHealthCheck = &albsdk.ActiveHealthCheck{ + AltPort: &service.Spec.HealthCheckNodePort, + HttpHealthChecks: &albsdk.HttpHealthChecks{ + Path: new("/healthz"), + }, + // TODO: Optimize interval etc. + } + } + // TODO: Recommend the use of eTP=Local. + + // We are committing to adding this here. + tree.listeners[80].hosts[rule.Host].paths[_pathWithType] = albPath + tree.targetPools[ingressPathReference] = targetPool + } + } + } + + return tree, errors +} + +func ValidateTLSCertAndFingerprint(publicKey, privateKey []byte) (string, error) { + cert, err := cryptotls.X509KeyPair(publicKey, privateKey) + if err != nil { + return "", err + } + sha256Hash := sha256.Sum256(cert.Leaf.Raw) + return hex.EncodeToString(sha256Hash[:]), nil +} + +func getTargetsOfNodes(nodes []corev1.Node) []albsdk.Target { + targets := []albsdk.Target{} + for _, node := range nodes { + for j := range node.Status.Addresses { + address := node.Status.Addresses[j] + if address.Type == corev1.NodeInternalIP { + targets = append(targets, albsdk.Target{ + DisplayName: &node.Name, + Ip: &address.Address, + }) + break + } + } + } + return targets +} + +// GetMissingCertificates returns all certificates that are required by t except those that it finds in existingCert. +// It can be used to create all remaining certificates required to create the ALB. +// +// This function uses the SHA256 fingerprint from the response to match existing certificates. +func (t WorkingTreeALB) GetMissingCertificates(existingCerts []certsdk.GetCertificateResponse) map[CertificateFingerprint]WorkingTreeCertificate { + missingCerts := map[CertificateFingerprint]WorkingTreeCertificate{} + existingCertsMap := map[CertificateFingerprint]any{} + for _, cert := range existingCerts { + if cert.Data == nil || cert.Data.FingerprintSha256 == nil { + continue + } + existingCertsMap[CertificateFingerprint(*cert.Data.FingerprintSha256)] = nil + } + + for fingerprint, cert := range t.certificates { + if _, exists := existingCertsMap[fingerprint]; exists { + continue + } + missingCerts[fingerprint] = cert + } + return missingCerts +} + +// ToCreatePayload +// Doesn't include certificates that are missing in certificateIDMap. +func (t WorkingTreeALB) ToCreatePayload( + certificateIDMap map[CertificateFingerprint]string, + networkID string, + region string, +) *albsdk.CreateLoadBalancerPayload { + listeners := []albsdk.Listener{} + for port, listener := range t.listeners { + hosts := []albsdk.HostConfig{} + for hostname, host := range listener.hosts { + rules := []albsdk.Rule{} + for path, pathDetails := range host.paths { + rule := albsdk.Rule{ + TargetPool: new(pathDetails.ingressPathReference.toTargetPoolName()), + WebSocket: &pathDetails.websocket, + } + + switch path.pathType { + case networkingv1.PathTypeExact: + rule.Path = new(albsdk.Path{ + ExactMatch: new(path.path), + }) + default: + rule.Path = new(albsdk.Path{ + Prefix: new(path.path), + }) + } + + rules = append(rules, rule) + } + + hosts = append(hosts, albsdk.HostConfig{ + Host: &hostname, + Rules: rules, + }) + } + + var https *albsdk.ProtocolOptionsHTTPS + if port == 443 { + https = &albsdk.ProtocolOptionsHTTPS{ + CertificateConfig: &albsdk.CertificateConfig{ + CertificateIds: []string{}, + }, + } + for fingerprint := range t.certificates { + if id, exists := certificateIDMap[fingerprint]; exists { + https.CertificateConfig.CertificateIds = append(https.CertificateConfig.CertificateIds, id) + } + } + } + + listeners = append(listeners, albsdk.Listener{ + Port: new(int32(port)), + Http: &albsdk.ProtocolOptionsHTTP{ + Hosts: hosts, + }, + Https: https, + }) + } + + targetPools := []albsdk.TargetPool{} + for _, targetPool := range t.targetPools { + targetPools = append(targetPools, *targetPool) + } + slices.SortFunc(targetPools, func(a, b albsdk.TargetPool) int { + return cmp.Compare(*a.TargetPort, *b.TargetPort) + }) + + return &albsdk.CreateLoadBalancerPayload{ + DisableTargetSecurityGroupAssignment: new(true), // TODO: Make this configurable via flag + Name: new(fmt.Sprintf("k8s-ingress-%s", t.ingressClass.UID)), + Labels: &map[string]string{ + "ingress-class-uid": string(t.ingressClass.UID), + }, + // TODO: Support static IP and promotion but not demotion + Listeners: listeners, + Networks: []albsdk.Network{ + { + NetworkId: new(networkID), + Role: new("ROLE_LISTENERS_AND_TARGETS"), + }, + }, + Options: &albsdk.LoadBalancerOptions{ + EphemeralAddress: new(true), + // TODO: + }, + PlanId: &t.planId, + Region: new(region), + TargetPools: targetPools, + } +} + +// ToUpdatePayload creates the payload to update a load balancer from the working tree. +// It requires that existingALB was not nil when BuildTree was called. +// certificateIDMap must contain all certificates that exist in the API for this ALB. +// However, not all secrets must exist. +// +// The output is deterministic. +func (t WorkingTreeALB) ToUpdatePayload( + certificateIDMap map[CertificateFingerprint]string, + networkID string, + region string, +) *albsdk.UpdateLoadBalancerPayload { + create := t.ToCreatePayload(certificateIDMap, networkID, region) + update := new(albsdk.UpdateLoadBalancerPayload(*create)) + // TODO: Take observability log config from existing LB. + update.Version = t.existingALB.Version + return update +} diff --git a/pkg/alb/ingress/spec/workingtree_test.go b/pkg/alb/ingress/spec/workingtree_test.go new file mode 100644 index 00000000..2310d414 --- /dev/null +++ b/pkg/alb/ingress/spec/workingtree_test.go @@ -0,0 +1,282 @@ +package spec + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/onsi/gomega/types" + . "github.com/stackitcloud/cloud-provider-stackit/pkg/alb/ingress/testutil/ingress" + . "github.com/stackitcloud/cloud-provider-stackit/pkg/alb/ingress/testutil/service" + "github.com/stackitcloud/stackit-sdk-go/services/alb/v2api" + + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type testCase struct { + class *networkingv1.IngressClass + ingresses []networkingv1.Ingress + secrets []corev1.Secret + services []corev1.Service + nodes []corev1.Node + existingALB *v2api.LoadBalancer + + matcher types.GomegaMatcher +} + +var _ = Describe("WorkingTreeALB.ToCreatePayload", func() { + It("should sort rules from most to least-specific even if their priority is inversed", func() { + tree, _ := BuildTree(&networkingv1.IngressClass{}, []networkingv1.Ingress{ + Ingress( + "default", "ingress-with-higher-priority", + WithAnnotation(AnnotationPriority, "5"), + WithRule("my-host.local", WithPath("/", new(networkingv1.PathTypePrefix), "my-service", networkingv1.ServiceBackendPort{Number: 1337})), + ), + Ingress( + "default", "ingress-with-more-specific-path", + WithAnnotation(AnnotationPriority, "4"), + WithRule("my-host.local", WithPath("/more-specific", new(networkingv1.PathTypePrefix), "other-service", networkingv1.ServiceBackendPort{Number: 1337})), + ), + }, nil, nil, nil, nil) + createPayload := tree.ToCreatePayload(nil, "", "") + Expect(createPayload.Listeners[0].Http.Hosts[0].Host).To(HaveValue(Equal("my-host.local"))) + Expect(createPayload.Listeners[0].Http.Hosts[0].Rules[0].Path.Prefix).To(HaveValue(Equal("/more-specific"))) + Expect(createPayload.Listeners[0].Http.Hosts[0].Rules[1].Path.Prefix).To(HaveValue(Equal("/"))) + }) + + It("should match rules against correct node ports", func() { + const host = "my-host.local" + tree, _ := BuildTree(&networkingv1.IngressClass{}, []networkingv1.Ingress{ + Ingress( + "default", "ingress-to-node-port-5000", + WithRule(host, WithPath("/5000", new(networkingv1.PathTypeExact), "service-a", networkingv1.ServiceBackendPort{Number: 1337})), + ), + Ingress( + "default", "ingress-to-node-port-5001", + WithRule(host, WithPath("/5001", new(networkingv1.PathTypeExact), "service-a", networkingv1.ServiceBackendPort{Name: "1338"})), + ), + Ingress( + "default", "ingress-to-node-port-5002", + WithRule(host, WithPath("/5002", new(networkingv1.PathTypeExact), "service-a", networkingv1.ServiceBackendPort{Number: 1339})), + ), + Ingress( + "default", "ingress-to-node-port-5003", + WithRule(host, WithPath("/5003", new(networkingv1.PathTypeExact), "service-b", networkingv1.ServiceBackendPort{Number: 1337})), + ), + }, nil, []corev1.Service{ + Service("default", "service-a", + WithPort("1337", 1337, 5000, corev1.ProtocolTCP), + WithPort("1338", 1338, 5001, corev1.ProtocolTCP), + WithPort("1339", 1339, 5002, corev1.ProtocolTCP), + ), + Service("default", "service-b", + WithPort("1337", 1337, 5003, corev1.ProtocolTCP), + ), + }, nil, nil) + createPayload := tree.ToCreatePayload(nil, "", "") + + Expect(createPayload.Listeners[0].Http.Hosts[0].Host).To(HaveValue(Equal(host))) + + // The following assertions require that target pool are sorted by target ports. + Expect(createPayload.Listeners[0].Http.Hosts[0].Rules[0].Path.ExactMatch).To(HaveValue(Equal("/5000"))) + Expect(createPayload.TargetPools[0].Name).To(Equal(createPayload.Listeners[0].Http.Hosts[0].Rules[0].TargetPool)) + Expect(createPayload.TargetPools[0].TargetPort).To(HaveValue(Equal(int32(5000)))) + + Expect(createPayload.Listeners[0].Http.Hosts[0].Rules[1].Path.ExactMatch).To(HaveValue(Equal("/5001"))) + Expect(createPayload.TargetPools[1].Name).To(Equal(createPayload.Listeners[0].Http.Hosts[0].Rules[1].TargetPool)) + Expect(createPayload.TargetPools[1].TargetPort).To(HaveValue(Equal(int32(5001)))) + + Expect(createPayload.Listeners[0].Http.Hosts[0].Rules[2].Path.ExactMatch).To(HaveValue(Equal("/5002"))) + Expect(createPayload.TargetPools[2].Name).To(Equal(createPayload.Listeners[0].Http.Hosts[0].Rules[2].TargetPool)) + Expect(createPayload.TargetPools[2].TargetPort).To(HaveValue(Equal(int32(5002)))) + + Expect(createPayload.Listeners[0].Http.Hosts[0].Rules[3].Path.ExactMatch).To(HaveValue(Equal("/5003"))) + Expect(createPayload.TargetPools[3].Name).To(Equal(createPayload.Listeners[0].Http.Hosts[0].Rules[3].TargetPool)) + Expect(createPayload.TargetPools[3].TargetPort).To(HaveValue(Equal(int32(5003)))) + }) + + It("should return an error when the TLS secret doesn't exist", func() { + _, errs := BuildTree( + &networkingv1.IngressClass{}, + []networkingv1.Ingress{ + Ingress(corev1.NamespaceDefault, "ingress-with-tls-secret-reference", WithTLSSecret("doesnt-exist")), + }, + nil, nil, nil, nil, + ) + + Expect(errs).To(HaveLen(1)) + Expect(errs[0].description).To(Equal("TLS secret doesn't exist")) + }) + + It("should return an error when the TLS secret isn't of type TLS", func() { + _, errs := BuildTree( + &networkingv1.IngressClass{}, + []networkingv1.Ingress{ + Ingress(corev1.NamespaceDefault, "ingress-with-tls-secret-reference", WithTLSSecret("non-tls")), + }, + []corev1.Secret{ + { + ObjectMeta: v1.ObjectMeta{Namespace: corev1.NamespaceDefault, Name: "non-tls"}, + Type: corev1.SecretTypeDockerConfigJson, // Not TLS + }, + }, nil, nil, nil, + ) + + Expect(errs).To(HaveLen(1)) + Expect(errs[0].description).To(Equal("TLS secret isn't of type kubernetes.io/tls")) + }) + + It("should return an error when the TLS secret isn't of type TLS", func() { + _, errs := BuildTree( + &networkingv1.IngressClass{}, + []networkingv1.Ingress{ + Ingress(corev1.NamespaceDefault, "ingress-with-tls-secret-reference", WithTLSSecret("non-tls")), + }, + []corev1.Secret{ + { + ObjectMeta: v1.ObjectMeta{Namespace: corev1.NamespaceDefault, Name: "non-tls"}, + Type: corev1.SecretTypeDockerConfigJson, // Not TLS + }, + }, nil, nil, nil, + ) + + Expect(errs).To(HaveLen(1)) + Expect(errs[0].description).To(Equal("TLS secret isn't of type kubernetes.io/tls")) + }) + + It("should return an error when TLS secret parsing fails", func() { + _, errs := BuildTree( + &networkingv1.IngressClass{}, + []networkingv1.Ingress{ + Ingress(corev1.NamespaceDefault, "ingress-with-tls-secret-reference", WithTLSSecret("invalid-tls")), + }, + []corev1.Secret{ + { + ObjectMeta: v1.ObjectMeta{Namespace: corev1.NamespaceDefault, Name: "invalid-tls"}, + Type: corev1.SecretTypeTLS, + Data: map[string][]byte{ + corev1.TLSCertKey: []byte("invalid cert"), + corev1.TLSPrivateKeyKey: []byte(fixtureTLSPrivateKey), + }, + }, + }, nil, nil, nil, + ) + + Expect(errs).To(HaveLen(1)) + Expect(errs[0].description).To(Equal("invalid certificate: tls: failed to find any PEM data in certificate input")) + }) + + It("should process TLS secret correctly", func() { + tree, errs := BuildTree( + &networkingv1.IngressClass{}, + []networkingv1.Ingress{ + Ingress(corev1.NamespaceDefault, "ingress-with-tls-secret-reference", WithTLSSecret("my-tls")), + }, + []corev1.Secret{ + { + ObjectMeta: v1.ObjectMeta{Namespace: corev1.NamespaceDefault, Name: "my-tls"}, + Type: corev1.SecretTypeTLS, + Data: map[string][]byte{ + corev1.TLSCertKey: []byte(fixtureTLSPublicKey), + corev1.TLSPrivateKeyKey: []byte(fixtureTLSPrivateKey), + }, + }, + }, nil, nil, nil, + ) + + Expect(errs).To(HaveLen(0)) + Expect(tree.GetMissingCertificates(nil)).To(ConsistOf( + WorkingTreeCertificate{ + PublicKey: fixtureTLSPublicKey, + PrivateKey: fixtureTLSPrivateKey, + }, + )) + }) +}) + +const ( + fixtureTLSPublicKey = `-----BEGIN CERTIFICATE----- +MIIFmzCCA4OgAwIBAgIUbhg0VsnIT3fREtGHtyj1YYY1mkUwDQYJKoZIhvcNAQEL +BQAwXTELMAkGA1UEBhMCREUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM +GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDEWMBQGA1UEAwwNbXktaG9zdC5sb2Nh +bDAeFw0yNjA2MTYwODU4MzVaFw0yNzA2MTYwODU4MzVaMF0xCzAJBgNVBAYTAkRF +MRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRz +IFB0eSBMdGQxFjAUBgNVBAMMDW15LWhvc3QubG9jYWwwggIiMA0GCSqGSIb3DQEB +AQUAA4ICDwAwggIKAoICAQDBwBCu7Bc77uMgUOslDJUObgG5FZUYWzdo6owK6Qmo +aNfvjmwwkbMHLqu8t6ZNi9UoRTJ1G9GeM8JtPL+bikKu1ZjN2MbO6VHI3xy0Az85 +r2/FKta1faFcrV7Vul/zJqAljf4qeTK31mFmZq1is86Q0wYcEf3qnNDafN5ThGT/ +F7akDlKTDG1RmyXHw+/90TINZ6q8Rqf5kI3EV63zlrG6iRJ38Dphge8Hk+ZGjURm +qx7Jz2iJkRGbIB53ZDEBk+KWM6K7iUbswmJv4qyat8P7Bv2Iisob9LVhU//852f+ +vdmdxoebUn6dGjsNv9lX0qKiEzcE1Lm2SPNIB3bfY5xNnKNjCT7qZ4NXKoeTTwLK +S+gN8zcY3Sdb8kyCKmhIGA4TXsQEyhzG/YwYGE/VgOEgv324VDGB5FcT+VcjZiHD +6nzDfqKH3NkaJ70PsCa4t3scHogkWQLnGMJd2/T+t/L3tVPZaJearexh6RUZJlIW +gCCAMqJPoALKzGrfSHhiy5L+ghpEgSnh4ZiWYxNbPcbGOXygxOZVnNc4y1PNb+vX +hXGU16wSoWQf8cZA0WDKiXLFz7qM6tAS49PJsHalWryE3qO741D/fOgl7Nzsi6MR +0lMsR9pCptIPPmiY/5f6pFxgS08IJFhaxAybCEuroLLBdXBMcD2SmSP7Scm2az09 +1QIDAQABo1MwUTAdBgNVHQ4EFgQUjdu/uxlLXaaafQIdx6gZZ45cxgswHwYDVR0j +BBgwFoAUjdu/uxlLXaaafQIdx6gZZ45cxgswDwYDVR0TAQH/BAUwAwEB/zANBgkq +hkiG9w0BAQsFAAOCAgEAX+/DmcP+iAqOo0WaOOvM7V4Iz8EAXSRdgMgi+xPRH8Dt +gYe1xc0eb3UJkkeOusrQKfEXbC47X905aAGACPNqLs+Mm40h8bctAqKExgFM7noM +8OK/y1I3RjDtbCMHJ5uCanuuqgVpXuuSWOafwY21n2mPi15+wjYJlk9YOVPAXkIl +wHpWwGv+4uuD0ppTHwF2bLFpypeVSsVLQdQ/F6H2K6QFIaHXhMZm2m1wLdD8AuiU +1AagiwOQwnGcSzKSjptO1DjWlJOPffcAzO2zXq3HT4Y3debbiKIY5uhXJfU7u82D +Q45dms99DN6FzFONf92NfHI48PAmHXFD8xoKOYejcsV/Fe0coccCbbj/wlReVabt +PE0skr0z12hPkQ6+BQri2nxKqbQPCyLKQNJ4p1ku2v73TX0zd2fU+P3mV0UoFovF +/8vOqc6J+MyrDSzvqdunEPL8pG6ziGnhC2fT2e41LYKWQqkBjFIQnEeTcr0pVdiG +R4dGu19QV3PBoX2IbLexndiYGCJuBsKpjIu5C4Z5BibXXZdngPwpWdaoG2DZQZ2s +okmiQzkHzZ3ADR/UVqTDICjr8gEzjZRfgwEt+jIkgEV7i5S9GS9miyzUKPi6pEuL +JGVFbYQdFntS/izqlEV0L+3te0WKQIEX6Sq8wdxg0twpRdzaMepJiLTYi/YxJa8= +-----END CERTIFICATE-----` + fixtureTLSPrivateKey = `-----BEGIN PRIVATE KEY----- +MIIJQQIBADANBgkqhkiG9w0BAQEFAASCCSswggknAgEAAoICAQDBwBCu7Bc77uMg +UOslDJUObgG5FZUYWzdo6owK6QmoaNfvjmwwkbMHLqu8t6ZNi9UoRTJ1G9GeM8Jt +PL+bikKu1ZjN2MbO6VHI3xy0Az85r2/FKta1faFcrV7Vul/zJqAljf4qeTK31mFm +Zq1is86Q0wYcEf3qnNDafN5ThGT/F7akDlKTDG1RmyXHw+/90TINZ6q8Rqf5kI3E +V63zlrG6iRJ38Dphge8Hk+ZGjURmqx7Jz2iJkRGbIB53ZDEBk+KWM6K7iUbswmJv +4qyat8P7Bv2Iisob9LVhU//852f+vdmdxoebUn6dGjsNv9lX0qKiEzcE1Lm2SPNI +B3bfY5xNnKNjCT7qZ4NXKoeTTwLKS+gN8zcY3Sdb8kyCKmhIGA4TXsQEyhzG/YwY +GE/VgOEgv324VDGB5FcT+VcjZiHD6nzDfqKH3NkaJ70PsCa4t3scHogkWQLnGMJd +2/T+t/L3tVPZaJearexh6RUZJlIWgCCAMqJPoALKzGrfSHhiy5L+ghpEgSnh4ZiW +YxNbPcbGOXygxOZVnNc4y1PNb+vXhXGU16wSoWQf8cZA0WDKiXLFz7qM6tAS49PJ +sHalWryE3qO741D/fOgl7Nzsi6MR0lMsR9pCptIPPmiY/5f6pFxgS08IJFhaxAyb +CEuroLLBdXBMcD2SmSP7Scm2az091QIDAQABAoICABd8+kjKdFKetkgvpyIZsWRL +b8gJVsbaIBCHBq037STOeQcgo/sLXsHLJaS+OtoBzriQEvrhgXsFWVe22p+3ljft +yxWBZzCkVnbcnXUxQ5PxscIcXGUqMsqydeHBM2qdzyJeYWayxLRGuA4a+oARvkQO +YRo8ECVGF4e1RZqoXToTnN+soNQU2JfhECZ0mX6SwtefLrKeejSmEpmv63WxWiB8 +B5IkvF8fymOHyY3aCGXN7vCWRV0QCitdLHRa4BoJ3JlK7zp+/Oss8ZQQzc3/4zFm +eov4D2JuOyLudQUq5I+cYmpfLAdna9QN3wTesjGUZoTxgWUDiPQRSfT8eqvAPq1v +yS9nQWC2bYwjngsauwtYBjY/Z0mParwLCRJLhOtsqZ6h9YqMAgwzAbfGazzTYDoH +gROUER+wCj1A41z5x5dADbtZkHqdJf6oVBbunH7rTz5KwvzH9DeCh6/+zhLOL27f +9UvVOoowQ4GPB07wrkpf+W1XvAO9jWV3bBReYO2OTd5D5HOChGlD0YYhr8aTKBlu +ql8qHqBB+8HBUfxYulXuN7qnq+o6f5T9exwaIGGgAHshbTuTO5aNOgQeL834D2wq +U2T3FG8xDRTfaxr9LbwyykQCkQX5rYzbua3hUepd9zQdJSr1CBJd85EqGWphDJ4z +7gFxwCInifd8UjJlJ6ntAoIBAQD0cI/zZglqemBeeB2dQNtabrKHhrR6EVPZgbHP +jAbsh8KuQ21jOQM+yncbvvcaKNOIbiw4fFmu538khmlF1YrkSxkd3z6blFRXefG8 +2Cx4Zt/xVxX4VWSayUpiYA0wWv3Vr9n5KdYVtHxhPjbFL8w+0X8/l5fuB8bUhR7m +YyqkC/dVyeuHURuJN4p/6nuXg2h8Bbjs/tw/eBFnED6lZinyaQSeW9w7/0IODbII +/SU6Bhj+BNaYAl+U2Vfq7IddtvogOvJJOlTOxkls7f4a0Ms8ehympEyv/Y/5eVMB +OF9/ToNLGnBTQUBWBy4aEngXMybY+zcXmNJ05KYH9i5gaDBDAoIBAQDK6c3yqxfV +8SJStVAZYI66QrudQr5TrLeEqoyrsn9Oe80svi7CzG34PgLOhVuYJWQBHlWVtTq7 +F9UscCGd+cRUTK+3mvimEfcy3kFW24g5mJ0pxGNAQ1MqtMggCTYWtsck8Y/NkWx5 +niQm69yMNOmMvt3a3TzZONDWsRN3uefZ0+Pl84Ef/+YTdswtuSc3NMA3diNGuIPh +rDx2SLlVLn9iEVTsYddDywaE00hnQgv0py9iPm2VoC2o26lpY3JAg1wYWpGFa/LG +vZ9kQXhGdX9wfPp3MV4tnze6hqFwN/vQKg33Xh+PQsAk8eVBqJNhk3n8PscvSOPa +hUkA8T+xk6QHAoIBADEnrZr5qu0RnO2CZBoqX7IIzrf4O7TMZTs5HIOrGf1Ys6qN +fqLUZTWsS1V2CoTlLtyhoxzczMAiZ2v155eWgK6192ANc66fnnJU4GrkYdT4gxIq +PA3LRkbmMaIkxKIzuhXNnhy/8AA/Yj+/3g27Nexv/pHQL0o7oB0+g986k+mXSm6j +A00b31ixpZVhlub6EvnVwMFP4wSUZZN/LcnfCJJp0fbybBBYnXTsBiBOn7zSWxZB +7NF2sLfjGQ3x8KrEz/nJQM2/ACzwrPVNyqqj0CriN36/TXiamehGII3/Qxz7seVZ +dLsZRRHHsdqmWiX4MFiz8/k3zyKYlFbHh731VbcCggEACCiMYkRkyfJPCfpGRS7v +rid+uZz0YBLisg/VZhXgLnylzDW9VZG4njGIFVuhSiW+tpjMoh9ORDV6GbZMc7iW +HzmSGxS9CJhSUxZClEZxXLd5IjPGNdA/KMlp/nfAV/tzWFXqDT7amK02EOaM0IpU +FZea/fDFQIqbQvaNrNOpscVmNVmsCGhWjNPK88+s9vhE/jXexzol+03chHj6EqWy +83N08aghapVgJrkEATrTljuemRmfeFOfYlmqnxUjg9qEOmpxzWaAtWLsZLCJMHQK +8q/jtiUi/zyWlgZRuVxW4JDATQDYzf7GEPY03IX1nwe58N1pTspkduXDAKmygOZJ +wwKCAQBTVZRSmQ/jzidcr5XBrU+qCIvfEvBLazc92GvoxYbBiXkMMlJIa/HzeYZR +C4urK9s7saMV3dIuo9laXnmjCx3T3ql7PvCUu250TKshM4w+6SVr+LlMLvmiH2vr +5ExTtdU7j6O5uq5+/tOsuBvC5UPmPYJfrWLSuF0OlhjtUPnQE7qUhIpGsq/uZLBJ +2KEUTroXmqKytomC4fHDKZdPexPS+tOKZ63HFxDYWM6LkcTBoXAmFejlJUzV5h2r +0kSRgTzjA/YZ67+MLsu+zz+7Q/triFveizJKLjHc6/Eo/c2XWk9h1XgYG19BBWqb +UoA+9Hd41MHTo2Frp1cML2BpdbK/ +-----END PRIVATE KEY-----` +) diff --git a/pkg/alb/ingress/suite_test.go b/pkg/alb/ingress/suite_test.go new file mode 100644 index 00000000..1ec0baef --- /dev/null +++ b/pkg/alb/ingress/suite_test.go @@ -0,0 +1,51 @@ +package ingress_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" +) + +var ( + testEnv *envtest.Environment + cfg *rest.Config + k8sClient client.Client +) + +func TestControllers(t *testing.T) { + RegisterFailHandler(Fail) + + RunSpecs(t, "Controller Suite") +} + +var _ = BeforeSuite(func() { + logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) + + var err error + + By("bootstrapping test environment") + testEnv = &envtest.Environment{} + + // cfg is defined in this file globally. + cfg, err = testEnv.Start() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg).NotTo(BeNil()) + + k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) + Expect(err).NotTo(HaveOccurred()) + Expect(k8sClient).NotTo(BeNil()) +}) + +var _ = AfterSuite(func() { + By("tearing down the test environment") + err := testEnv.Stop() + Expect(err).NotTo(HaveOccurred()) +}) diff --git a/pkg/alb/ingress/testutil/ingress/ingress.go b/pkg/alb/ingress/testutil/ingress/ingress.go new file mode 100644 index 00000000..c47a82d8 --- /dev/null +++ b/pkg/alb/ingress/testutil/ingress/ingress.go @@ -0,0 +1,101 @@ +package ingress + +import ( + networkingv1 "k8s.io/api/networking/v1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +// Ingress constructs an ingress for testing purposes. +func Ingress(namespace, name string, opts ...IngressOption) networkingv1.Ingress { + i := networkingv1.Ingress{ + ObjectMeta: v1.ObjectMeta{ + Namespace: namespace, + Name: name, + Annotations: map[string]string{}, + }, + } + for _, o := range opts { + o.applyToIngress(&i) + } + return i +} + +type IngressOption interface { + applyToIngress(ingress *networkingv1.Ingress) +} + +type ingressOptionFunc func(ingress *networkingv1.Ingress) + +func (f ingressOptionFunc) applyToIngress(ingress *networkingv1.Ingress) { + f(ingress) +} + +func WithUID(uid string) IngressOption { + return ingressOptionFunc(func(ingress *networkingv1.Ingress) { + ingress.UID = types.UID(uid) + }) +} + +func WithIngressClass(ingressClass string) IngressOption { + return ingressOptionFunc(func(ingress *networkingv1.Ingress) { + ingress.Spec.IngressClassName = new(ingressClass) + }) +} + +func WithAnnotation(key, value string) IngressOption { + return ingressOptionFunc(func(ingress *networkingv1.Ingress) { + ingress.Annotations[key] = value + }) +} + +func WithTLSSecret(secretName string) ingressOptionFunc { + return ingressOptionFunc(func(ingress *networkingv1.Ingress) { + ingress.Spec.TLS = append(ingress.Spec.TLS, networkingv1.IngressTLS{ + SecretName: secretName, + }) + }) +} + +func WithRule(host string, opts ...RuleOptions) IngressOption { + return ingressOptionFunc(func(ingress *networkingv1.Ingress) { + rule := networkingv1.IngressRule{ + Host: host, + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{}, + }, + } + for _, o := range opts { + o.applyToRule(&rule) + } + ingress.Spec.Rules = append(ingress.Spec.Rules, rule) + }) +} + +type RuleOptions interface { + applyToRule(rule *networkingv1.IngressRule) +} + +type ruleOptionsFunc func(rule *networkingv1.IngressRule) + +func (f ruleOptionsFunc) applyToRule(rule *networkingv1.IngressRule) { + f(rule) +} + +func WithPath(path string, _type *networkingv1.PathType, serviceName string, serviceBackendPort networkingv1.ServiceBackendPort) RuleOptions { + return ruleOptionsFunc(func(rule *networkingv1.IngressRule) { + if rule.HTTP.Paths == nil { + rule.HTTP.Paths = []networkingv1.HTTPIngressPath{} + } + rule.HTTP.Paths = append(rule.HTTP.Paths, networkingv1.HTTPIngressPath{ + PathType: _type, + Path: path, + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: serviceName, + Port: serviceBackendPort, + }, + }, + }) + }) +} diff --git a/pkg/alb/ingress/testutil/service/service.go b/pkg/alb/ingress/testutil/service/service.go new file mode 100644 index 00000000..3b6ddad2 --- /dev/null +++ b/pkg/alb/ingress/testutil/service/service.go @@ -0,0 +1,49 @@ +package service + +import ( + corev1 "k8s.io/api/core/v1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func Service(namespace, name string, opts ...ServiceOption) corev1.Service { + service := corev1.Service{ + ObjectMeta: v1.ObjectMeta{ + Namespace: namespace, + Name: name, + }, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{}, + }, + } + for _, o := range opts { + o.ApplyToService(&service) + } + return service +} + +type ServiceOption interface { + ApplyToService(service *corev1.Service) +} + +type serviceOptionFunc func(service *corev1.Service) + +func (f serviceOptionFunc) ApplyToService(service *corev1.Service) { + f(service) +} + +func WithPort(name string, port, nodePort int32, protocol corev1.Protocol) ServiceOption { + return serviceOptionFunc(func(service *corev1.Service) { + service.Spec.Ports = append(service.Spec.Ports, corev1.ServicePort{ + Name: name, + Port: port, + NodePort: nodePort, + Protocol: protocol, + }) + }) +} + +func WithServiceType(_type corev1.ServiceType) ServiceOption { + return serviceOptionFunc(func(service *corev1.Service) { + service.Spec.Type = _type + }) +} diff --git a/pkg/alb/ingress/testutil/testutil.go b/pkg/alb/ingress/testutil/testutil.go new file mode 100644 index 00000000..4ef481e1 --- /dev/null +++ b/pkg/alb/ingress/testutil/testutil.go @@ -0,0 +1,28 @@ +package testutil + +import ( + "context" + "sync/atomic" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/onsi/gomega/types" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func DeleteAndWaitForKubernetesResource(ctx context.Context, cl client.Client, obj client.Object) { + GinkgoHelper() + Expect(cl.Delete(ctx, obj)).To(Succeed()) + Eventually(func(g Gomega, ctx context.Context) { + g.Expect(cl.Get(ctx, client.ObjectKeyFromObject(obj), obj)).Should(WithTransform(apierrors.IsNotFound, BeTrue()), "Expected resource %s to eventually be deleted", client.ObjectKeyFromObject(obj)) + + }).WithContext(ctx).Should(Succeed()) +} + +func HaveAtomicValue[T any](matcher types.GomegaMatcher) types.GomegaMatcher { + return WithTransform(func(a *atomic.Pointer[T]) *T { + t := a.Load() + return t + }, matcher) +} diff --git a/pkg/alb/ingress/update.go b/pkg/alb/ingress/update.go new file mode 100644 index 00000000..ab84cc45 --- /dev/null +++ b/pkg/alb/ingress/update.go @@ -0,0 +1,295 @@ +package ingress + +import ( + "context" + "errors" + "fmt" + + "github.com/stackitcloud/cloud-provider-stackit/pkg/alb/ingress/spec" + "github.com/stackitcloud/cloud-provider-stackit/pkg/labels" + "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" + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func (r *IngressClassReconciler) applyALB(ctx context.Context, ingressClass *networkingv1.IngressClass) error { + ingresses, err := r.getIngressesForIngressClass(ctx, ingressClass) + if err != nil { + return fmt.Errorf("failed to get ingresses for class: %w", err) + } + + secrets, err := r.getTLSSecretsFromIngresses(ctx, ingresses) + if err != nil { + return fmt.Errorf("failed to get secrets for ingresses: %w", err) + } + + services, err := r.getServicesForIngresses(ctx, ingresses) + if err != nil { + return fmt.Errorf("failed to get services for ingresses: %w", err) + } + + nodes := corev1.NodeList{} + if err := r.Client.List(ctx, &nodes); err != nil { + return fmt.Errorf("failed to get nodes: %w", err) + } + + existingALB, err := r.ALBClient.GetLoadBalancer(ctx, r.ALBConfig.Global.ProjectID, r.ALBConfig.Global.Region, "my-alb") // TODO: Set real name + if err != nil && !errors.Is(err, stackit.ErrorNotFound) { + return fmt.Errorf("failed to get load balancer: %w", err) + } + if errors.Is(err, stackit.ErrorNotFound) { + existingALB = nil + } + + tree, _ := spec.BuildTree( // TODO: deal with errors + ingressClass, + ingresses, + secrets, + services, + nodes.Items, + existingALB, + ) + + // Create certificates that are needed and get an ID mapping + // TODO: Deal with paging. + projectCertificates, err := r.CertificateClient.ListCertificate(ctx, r.ALBConfig.Global.ProjectID, r.ALBConfig.Global.Region) + if err != nil { + return fmt.Errorf("failed to list certificates: %w", err) + } + ingressClassCertificates := []certsdk.GetCertificateResponse{} + for _, cert := range projectCertificates.Items { + if cert.Labels != nil && (*cert.Labels)[labels.LabelIngressClassUID] == string(ingressClass.UID) { + // TODO: Check for nil-ness + ingressClassCertificates = append(ingressClassCertificates, cert) + } + } + // Optional: Delete any that are already no longer used and will not be used with the update. + + missingCertificates := tree.GetMissingCertificates(ingressClassCertificates) + for fingerprint, c := range missingCertificates { + createCertificatePayload := &certsdk.CreateCertificatePayload{ + Name: new(string(fingerprint)), // TODO: Add some identifying prefix and shorten it to 63 characters + ProjectId: &r.ALBConfig.Global.ProjectID, + PrivateKey: new(string(c.PrivateKey)), + PublicKey: new(string(c.PublicKey)), + Labels: &map[string]string{ + labels.LabelIngressClassUID: string(ingressClass.UID), + }, + } + response, err := r.CertificateClient.CreateCertificate(ctx, r.ALBConfig.Global.ProjectID, r.ALBConfig.Global.Region, createCertificatePayload) + if err != nil { + return fmt.Errorf("failed to create certificate: %w", err) + } + // TODO: Check for nil-ness + ctrl.LoggerFrom(ctx).Info("Created certificate", "id", response.Id, "fingerprint", fingerprint) + ingressClassCertificates = append(ingressClassCertificates, *response) + } + + certIDMap := map[spec.CertificateFingerprint]string{} + for _, cert := range ingressClassCertificates { + certIDMap[spec.CertificateFingerprint(*cert.Data.FingerprintSha256)] = *cert.Id + } + + if existingALB == nil { + alb := tree.ToCreatePayload(certIDMap, r.ALBConfig.ApplicationLoadBalancer.NetworkID, r.ALBConfig.Global.Region) + _, err := r.ALBClient.CreateLoadBalancer(ctx, r.ALBConfig.Global.ProjectID, r.ALBConfig.Global.Region, alb) + if err != nil { + return fmt.Errorf("failed to create load balancer: %w", err) + } + ctrl.LoggerFrom(ctx).Info("Created application load balancer", "name", alb.Name) + return nil + } + + alb := tree.ToUpdatePayload(certIDMap, r.ALBConfig.ApplicationLoadBalancer.NetworkID, r.ALBConfig.Global.Region) + if !updateNeeded(existingALB, alb) { + return nil + } + + _, err = r.ALBClient.UpdateLoadBalancer(ctx, r.ALBConfig.Global.ProjectID, r.ALBConfig.Global.Region, *alb.Name, alb) + if err != nil { + return fmt.Errorf("failed to update load balancer: %w", err) + } + ctrl.LoggerFrom(ctx).Info("Updated application load balancer", "name", alb.Name) + + // TODO: + // Clean up orphaned certificates now that the ALB is successfully detached from them + // if cleanupErr := r.cleanupUnusedCertificates(ctx, ingressClass, activeCertIDs); cleanupErr != nil { + // log.Error(cleanupErr, "failed to cleanup unused certificates") + // } + + return nil +} + +// getServicesForIngresses returns all services that are referenced anywhere in any of the ingresses. +// It ignores services that are not found. +// TODO: Support resource backends (that reference services). +func (r *IngressClassReconciler) getServicesForIngresses(ctx context.Context, ingresses []networkingv1.Ingress) ([]corev1.Service, error) { + // TODO: This and the next function can be generalized with a NamespacedReferenceList function. Possibly with a callback function for the indexes. Should return a map indexed with types.NamespacedName. + services := []corev1.Service{} + for _, ingress := range ingresses { + for ruleIndex, rule := range ingress.Spec.Rules { + for pathIndex, path := range rule.HTTP.Paths { + if path.Backend.Service.Name == "" { + continue + } + service := corev1.Service{} + err := r.Client.Get(ctx, types.NamespacedName{Namespace: ingress.Namespace, Name: path.Backend.Service.Name}, &service) + if client.IgnoreNotFound(err) != nil { + return nil, fmt.Errorf( + "failed to get service %s referenced in ingress %s at rule %d and path %d (zero-indexed): %w", + types.NamespacedName{Namespace: ingress.Namespace, Name: path.Backend.Service.Name}, + client.ObjectKeyFromObject(&ingress), + ruleIndex, pathIndex, err, + ) + } + if !apierrors.IsNotFound(err) { + services = append(services, service) + } + } + } + if ingress.Spec.DefaultBackend == nil || ingress.Spec.DefaultBackend.Service == nil || ingress.Spec.DefaultBackend.Service.Name == "" { + continue + } + service := corev1.Service{} + err := r.Client.Get(ctx, types.NamespacedName{Namespace: ingress.Namespace, Name: ingress.Spec.DefaultBackend.Service.Name}, &service) + if client.IgnoreNotFound(err) != nil { + return nil, fmt.Errorf( + "failed to get service %s referenced in the default backend of ingress %s: %w", + types.NamespacedName{Namespace: ingress.Namespace, Name: ingress.Spec.DefaultBackend.Service.Name}, + client.ObjectKeyFromObject(&ingress), + err, + ) + } + if !apierrors.IsNotFound(err) { + services = append(services, service) + } + } + return services, nil +} + +func (r *IngressClassReconciler) getTLSSecretsFromIngresses(ctx context.Context, ingresses []networkingv1.Ingress) ([]corev1.Secret, error) { + secrets := []corev1.Secret{} + for _, ingress := range ingresses { + for tlsIndex, tls := range ingress.Spec.TLS { + secret := corev1.Secret{} + err := r.Client.Get(ctx, types.NamespacedName{Namespace: ingress.Namespace, Name: tls.SecretName}, &secret) + if client.IgnoreNotFound(err) != nil { + return nil, fmt.Errorf( + "failed to get secret %s referenced in the ingress %s at position %d: %w", + types.NamespacedName{Namespace: ingress.Namespace, Name: tls.SecretName}, + client.ObjectKeyFromObject(&ingress), + tlsIndex, err, + ) + } + if !apierrors.IsNotFound(err) { + secrets = append(secrets, secret) + } + } + } + return secrets, nil +} + +func updateNeeded(alb *albsdk.LoadBalancer, albPayload *albsdk.UpdateLoadBalancerPayload) bool { + return listenersChanged(alb.Listeners, albPayload.Listeners) || targetPoolsChanged(alb.TargetPools, albPayload.TargetPools) +} + +func listenersChanged(current, desired []albsdk.Listener) bool { + if len(current) != len(desired) { + return true + } + for i := range current { + c, d := current[i], desired[i] + + if ptr.Deref(c.Protocol, "") != ptr.Deref(d.Protocol, "") || + ptr.Deref(c.Port, 0) != ptr.Deref(d.Port, 0) || + ptr.Deref(c.WafConfigName, "") != ptr.Deref(d.WafConfigName, "") { + return true + } + + if httpOptionsChanged(c.Http, d.Http) || httpsOptionsChanged(c.Https, d.Https) { + return true + } + } + return false +} + +func httpOptionsChanged(c, d *albsdk.ProtocolOptionsHTTP) bool { + if c == nil && d == nil { + return false + } + if c == nil || d == nil || len(c.Hosts) != len(d.Hosts) { + return true + } + + for i := range c.Hosts { + ch, dh := c.Hosts[i], d.Hosts[i] + if ptr.Deref(ch.Host, "") != ptr.Deref(dh.Host, "") || len(ch.Rules) != len(dh.Rules) { + return true + } + + for j := range ch.Rules { + cr, dr := ch.Rules[j], dh.Rules[j] + if pathChanged(cr.Path, dr.Path) { + return true + } + if ptr.Deref(cr.WebSocket, false) != ptr.Deref(dr.WebSocket, false) || + ptr.Deref(cr.TargetPool, "") != ptr.Deref(dr.TargetPool, "") { + return true + } + } + } + return false +} + +func pathChanged(c, d *albsdk.Path) bool { + if c == nil && d == nil { + return false + } + if c == nil || d == nil { + return true + } + return ptr.Deref(c.Prefix, "") != ptr.Deref(d.Prefix, "") || ptr.Deref(c.ExactMatch, "") != ptr.Deref(d.ExactMatch, "") +} + +func httpsOptionsChanged(c, d *albsdk.ProtocolOptionsHTTPS) bool { + if c == nil && d == nil { + return false + } + if c == nil || d == nil { + return true + } + return len(c.CertificateConfig.CertificateIds) != len(d.CertificateConfig.CertificateIds) +} + +func targetPoolsChanged(current, desired []albsdk.TargetPool) bool { + if len(current) != len(desired) { + return true + } + for i := range current { + c, d := current[i], desired[i] + + if ptr.Deref(c.Name, "") != ptr.Deref(d.Name, "") || + ptr.Deref(c.TargetPort, 0) != ptr.Deref(d.TargetPort, 0) || + len(c.Targets) != len(d.Targets) { + return true + } + + if (c.TlsConfig == nil) != (d.TlsConfig == nil) { + return true + } + if c.TlsConfig != nil && d.TlsConfig != nil { + if ptr.Deref(c.TlsConfig.SkipCertificateValidation, false) != ptr.Deref(d.TlsConfig.SkipCertificateValidation, false) || + ptr.Deref(c.TlsConfig.CustomCa, "") != ptr.Deref(d.TlsConfig.CustomCa, "") { + return true + } + } + } + return false +} diff --git a/pkg/alb/ingress/update_test.go b/pkg/alb/ingress/update_test.go new file mode 100644 index 00000000..cfc1c7d7 --- /dev/null +++ b/pkg/alb/ingress/update_test.go @@ -0,0 +1,232 @@ +package ingress + +import ( + "testing" + + albsdk "github.com/stackitcloud/stackit-sdk-go/services/alb/v2api" + "k8s.io/utils/ptr" +) + +func Test_updateNeeded(t *testing.T) { + tests := []struct { + name string + current *albsdk.LoadBalancer + desired *albsdk.UpdateLoadBalancerPayload + expected bool + }{ + { + name: "no changes", + current: &albsdk.LoadBalancer{ + Listeners: []albsdk.Listener{ + {Port: ptr.To[int32](80)}, + }, + }, + desired: &albsdk.UpdateLoadBalancerPayload{ + Listeners: []albsdk.Listener{ + {Port: ptr.To[int32](80)}, + }, + }, + expected: false, + }, + { + name: "port changed", + current: &albsdk.LoadBalancer{ + Listeners: []albsdk.Listener{ + {Port: ptr.To[int32](80)}, + }, + }, + desired: &albsdk.UpdateLoadBalancerPayload{ + Listeners: []albsdk.Listener{ + {Port: ptr.To[int32](443)}, + }, + }, + expected: true, + }, + { + name: "waf config changed", + current: &albsdk.LoadBalancer{ + Listeners: []albsdk.Listener{ + {WafConfigName: ptr.To("waf-1")}, + }, + }, + desired: &albsdk.UpdateLoadBalancerPayload{ + Listeners: []albsdk.Listener{ + {WafConfigName: ptr.To("waf-2")}, + }, + }, + expected: true, + }, + { + name: "path prefix changed", + current: &albsdk.LoadBalancer{ + Listeners: []albsdk.Listener{ + { + Http: &albsdk.ProtocolOptionsHTTP{ + Hosts: []albsdk.HostConfig{ + { + Rules: []albsdk.Rule{ + {Path: &albsdk.Path{Prefix: ptr.To("/api")}}, + }, + }, + }, + }, + }, + }, + }, + desired: &albsdk.UpdateLoadBalancerPayload{ + Listeners: []albsdk.Listener{ + { + Http: &albsdk.ProtocolOptionsHTTP{ + Hosts: []albsdk.HostConfig{ + { + Rules: []albsdk.Rule{ + {Path: &albsdk.Path{Prefix: ptr.To("/v2")}}, + }, + }, + }, + }, + }, + }, + }, + expected: true, + }, + { + name: "path exact match changed", + current: &albsdk.LoadBalancer{ + Listeners: []albsdk.Listener{ + { + Http: &albsdk.ProtocolOptionsHTTP{ + Hosts: []albsdk.HostConfig{ + { + Rules: []albsdk.Rule{ + {Path: &albsdk.Path{ExactMatch: ptr.To("/api")}}, + }, + }, + }, + }, + }, + }, + }, + desired: &albsdk.UpdateLoadBalancerPayload{ + Listeners: []albsdk.Listener{ + { + Http: &albsdk.ProtocolOptionsHTTP{ + Hosts: []albsdk.HostConfig{ + { + Rules: []albsdk.Rule{ + {Path: &albsdk.Path{ExactMatch: ptr.To("/v2")}}, + }, + }, + }, + }, + }, + }, + }, + expected: true, + }, + { + name: "websocket changed", + current: &albsdk.LoadBalancer{ + Listeners: []albsdk.Listener{ + { + Http: &albsdk.ProtocolOptionsHTTP{ + Hosts: []albsdk.HostConfig{ + { + Rules: []albsdk.Rule{ + {WebSocket: ptr.To(false)}, + }, + }, + }, + }, + }, + }, + }, + desired: &albsdk.UpdateLoadBalancerPayload{ + Listeners: []albsdk.Listener{ + { + Http: &albsdk.ProtocolOptionsHTTP{ + Hosts: []albsdk.HostConfig{ + { + Rules: []albsdk.Rule{ + {WebSocket: ptr.To(true)}, + }, + }, + }, + }, + }, + }, + }, + expected: true, + }, + { + name: "https certificates changed", + current: &albsdk.LoadBalancer{ + Listeners: []albsdk.Listener{ + { + Https: &albsdk.ProtocolOptionsHTTPS{ + CertificateConfig: &albsdk.CertificateConfig{ + CertificateIds: []string{"cert1"}, + }, + }, + }, + }, + }, + desired: &albsdk.UpdateLoadBalancerPayload{ + Listeners: []albsdk.Listener{ + { + Https: &albsdk.ProtocolOptionsHTTPS{ + CertificateConfig: &albsdk.CertificateConfig{ + CertificateIds: []string{"cert1", "cert2"}, + }, + }, + }, + }, + }, + expected: true, + }, + { + name: "target pool port changed", + current: &albsdk.LoadBalancer{ + TargetPools: []albsdk.TargetPool{ + {TargetPort: ptr.To[int32](80)}, + }, + }, + desired: &albsdk.UpdateLoadBalancerPayload{ + TargetPools: []albsdk.TargetPool{ + {TargetPort: ptr.To[int32](443)}, + }, + }, + expected: true, + }, + { + name: "target pool tls validation changed", + current: &albsdk.LoadBalancer{ + TargetPools: []albsdk.TargetPool{ + { + TlsConfig: &albsdk.TlsConfig{ + SkipCertificateValidation: ptr.To(false), + }, + }, + }, + }, + desired: &albsdk.UpdateLoadBalancerPayload{ + TargetPools: []albsdk.TargetPool{ + { + TlsConfig: &albsdk.TlsConfig{ + SkipCertificateValidation: ptr.To(true), + }, + }, + }, + }, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := updateNeeded(tt.current, tt.desired); got != tt.expected { + t.Errorf("updateNeeded() = %v, want %v", got, tt.expected) + } + }) + } +} diff --git a/pkg/labels/labels.go b/pkg/labels/labels.go index 2c0cbc62..14bc0ef1 100644 --- a/pkg/labels/labels.go +++ b/pkg/labels/labels.go @@ -5,6 +5,15 @@ import ( "strings" ) +const ( + + // prefixALBIngressController is the prefix for all labels associated with ingress controllers + prefixALBIngressController = "alb-ingress-controller-" + // LabelIngressClassUID is the unique key that identifies resources + // owned by a specific IngressClass. + LabelIngressClassUID = prefixALBIngressController + "ingress-class-uid" +) + // Replace non-alphanumeric characters (except '-', '_', '.') with '-' var reg = regexp.MustCompile(`[^-a-zA-Z0-9_.]+`) 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..2a5b2b00 --- /dev/null +++ b/pkg/stackit/applicationloadbalancercertificates.go @@ -0,0 +1,51 @@ +package stackit + +import ( + "context" + + certsdk "github.com/stackitcloud/stackit-sdk-go/services/certificates/v2api" +) + +type CertificatesClient interface { + // TODO: hard-code region and project into client + GetCertificate(ctx context.Context, projectID, region, name string) (*certsdk.GetCertificateResponse, error) + DeleteCertificate(ctx context.Context, projectID, region, name string) error + CreateCertificate(ctx context.Context, projectID, region string, certificate *certsdk.CreateCertificatePayload) (*certsdk.GetCertificateResponse, error) + ListCertificate(ctx context.Context, projectID, region string) (*certsdk.ListCertificatesResponse, error) +} + +type certClient struct { + client *certsdk.APIClient +} + +var _ CertificatesClient = (*certClient)(nil) + +func NewCertClient(cl *certsdk.APIClient) (CertificatesClient, error) { + return &certClient{client: cl}, nil +} + +func (cl certClient) GetCertificate(ctx context.Context, projectID, region, name string) (*certsdk.GetCertificateResponse, error) { + cert, err := cl.client.DefaultAPI.GetCertificate(ctx, projectID, region, name).Execute() + if isOpenAPINotFound(err) { + return cert, ErrorNotFound + } + return cert, err +} + +func (cl certClient) DeleteCertificate(ctx context.Context, projectID, region, name string) error { + _, err := cl.client.DefaultAPI.DeleteCertificate(ctx, projectID, region, name).Execute() + return err +} + +func (cl certClient) CreateCertificate(ctx context.Context, projectID, region string, certificate *certsdk.CreateCertificatePayload) (*certsdk.GetCertificateResponse, error) { + cert, err := cl.client.DefaultAPI.CreateCertificate(ctx, projectID, region).CreateCertificatePayload(*certificate).Execute() + if isOpenAPINotFound(err) { + return cert, ErrorNotFound + } + return cert, err +} + +func (cl certClient) ListCertificate(ctx context.Context, projectID, region string) (*certsdk.ListCertificatesResponse, error) { + certs, err := cl.client.DefaultAPI.ListCertificates(ctx, projectID, region).Execute() + return certs, err +} diff --git a/pkg/stackit/applicationloadbalancercertificates_mock.go b/pkg/stackit/applicationloadbalancercertificates_mock.go new file mode 100644 index 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/pkg/stackit/config/config.go b/pkg/stackit/config/config.go index cad4a805..db543fab 100644 --- a/pkg/stackit/config/config.go +++ b/pkg/stackit/config/config.go @@ -1,7 +1,12 @@ package config import ( + "errors" + "io" + "os" + "github.com/stackitcloud/cloud-provider-stackit/pkg/stackit/metadata" + "gopkg.in/yaml.v3" ) type GlobalOpts struct { @@ -11,8 +16,10 @@ type GlobalOpts struct { } type APIEndpoints struct { - IaasAPI string `yaml:"iaasApi"` - LoadBalancerAPI string `yaml:"loadBalancerApi"` + IaasAPI string `yaml:"iaasApi"` + LoadBalancerAPI string `yaml:"loadBalancerApi"` + ApplicationLoadBalancerAPI string `yaml:"applicationLoadBalancerApi"` + ApplicationLoadBalancerCertificateAPI string `yaml:"applicationLoadBalancerCertificateApi"` } type CCMConfig struct { @@ -35,3 +42,46 @@ type CSIConfig struct { type BlockStorageOpts struct { RescanOnResize bool `yaml:"rescanOnResize"` } + +type ALBConfig struct { + Global GlobalOpts `yaml:"global"` + Metadata metadata.Opts `yaml:"metadata"` + ApplicationLoadBalancer ApplicationLoadBalancerOpts `yaml:"applicationLoadBalancer"` +} +type ApplicationLoadBalancerOpts struct { + NetworkID string `yaml:"networkId"` +} + +func readFile(path string) ([]byte, error) { + file, err := os.Open(path) + if err != nil { + return []byte{}, err + } + defer file.Close() + + return io.ReadAll(file) +} + +func ReadALBConfigFromFile(path string) (ALBConfig, error) { + content, err := readFile(path) + if err != nil { + return ALBConfig{}, err + } + + config := ALBConfig{} + err = yaml.Unmarshal(content, &config) + if err != nil { + return ALBConfig{}, err + } + + if config.Global.ProjectID == "" { + return ALBConfig{}, errors.New("project ID must be set") + } + if config.Global.Region == "" { + return ALBConfig{}, errors.New("region must be set") + } + if config.ApplicationLoadBalancer.NetworkID == "" { + return ALBConfig{}, errors.New("network ID must be set") + } + return config, nil +} diff --git a/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 '