diff --git a/cmd/application-load-balancer-controller-manager/main.go b/cmd/application-load-balancer-controller-manager/main.go index 735ed522..0e09b84e 100644 --- a/cmd/application-load-balancer-controller-manager/main.go +++ b/cmd/application-load-balancer-controller-manager/main.go @@ -19,6 +19,8 @@ import ( "sigs.k8s.io/controller-runtime/pkg/healthz" "sigs.k8s.io/controller-runtime/pkg/log/zap" metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + "sigs.k8s.io/controller-runtime/pkg/webhook" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" ) var ( @@ -133,6 +135,13 @@ func main() { os.Exit(1) } + mgr.GetWebhookServer().Register("/validate-ingress", &webhook.Admission{ + Handler: &ingress.IngressValidator{ + Client: mgr.GetClient(), + Decoder: admission.NewDecoder(mgr.GetScheme()), + }, + }) + setupLog.Info("starting manager") if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { setupLog.Error(err, "problem running manager") diff --git a/deploy/application-load-balancer-controller-manager/deployment.yaml b/deploy/application-load-balancer-controller-manager/deployment.yaml index 6e7de544..393a0127 100644 --- a/deploy/application-load-balancer-controller-manager/deployment.yaml +++ b/deploy/application-load-balancer-controller-manager/deployment.yaml @@ -43,6 +43,9 @@ spec: hostPort: 8081 name: probe protocol: TCP + - containerPort: 9443 + name: validating-webhook + protocol: TCP resources: limits: cpu: "0.5" @@ -53,7 +56,14 @@ spec: volumeMounts: - mountPath: /etc/serviceaccount name: cloud-secret + - mountPath: /tmp/k8s-webhook-server/serving-certs + name: validating-webhook-cert + readOnly: true volumes: - name: cloud-secret secret: secretName: stackit-cloud-secret + - name: validating-webhook-cert + secret: + secretName: stackit-application-load-balancer-contoller-manager-webhook-cert + \ No newline at end of file diff --git a/deploy/application-load-balancer-controller-manager/kustomization.yaml b/deploy/application-load-balancer-controller-manager/kustomization.yaml index 857fb567..36aa808e 100644 --- a/deploy/application-load-balancer-controller-manager/kustomization.yaml +++ b/deploy/application-load-balancer-controller-manager/kustomization.yaml @@ -4,4 +4,5 @@ kind: Kustomization resources: - deployment.yaml - rbac.yaml - +- validating-webhook.yaml +- validating-webhook-issuer.yaml \ No newline at end of file diff --git a/deploy/application-load-balancer-controller-manager/service.yaml b/deploy/application-load-balancer-controller-manager/service.yaml index 28222103..99ff3118 100644 --- a/deploy/application-load-balancer-controller-manager/service.yaml +++ b/deploy/application-load-balancer-controller-manager/service.yaml @@ -17,4 +17,8 @@ spec: port: 8080 targetPort: metrics protocol: TCP + - name: validating-webhook + port: 443 + targetPort: 9443 + protocol: TCP type: ClusterIP diff --git a/deploy/application-load-balancer-controller-manager/validating-webhook-issuer.yaml b/deploy/application-load-balancer-controller-manager/validating-webhook-issuer.yaml new file mode 100644 index 00000000..60e919a4 --- /dev/null +++ b/deploy/application-load-balancer-controller-manager/validating-webhook-issuer.yaml @@ -0,0 +1,21 @@ +apiVersion: cert-manager.io/v1 +kind: Issuer +metadata: + name: stackit-application-load-balancer-contoller-manager + namespace: kube-system +spec: + selfSigned: {} +--- +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: stackit-application-load-balancer-contoller-manager-webhook-cert + namespace: kube-system +spec: + dnsNames: + - stackit-application-load-balancer-contoller-manager.kube-system.svc + - stackit-application-load-balancer-contoller-manager.kube-system.svc.cluster.local + issuerRef: + kind: Issuer + name: stackit-application-load-balancer-contoller-manager + secretName: stackit-application-load-balancer-contoller-manager-webhook-cert # cert-manager will create this secret \ No newline at end of file diff --git a/deploy/application-load-balancer-controller-manager/validating-webhook.yaml b/deploy/application-load-balancer-controller-manager/validating-webhook.yaml new file mode 100644 index 00000000..37b794bb --- /dev/null +++ b/deploy/application-load-balancer-controller-manager/validating-webhook.yaml @@ -0,0 +1,22 @@ +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingWebhookConfiguration +metadata: + name: stackit-application-load-balancer-contoller-manager + annotations: + cert-manager.io/inject-ca-from: kube-system/stackit-application-load-balancer-contoller-manager-webhook-cert +webhooks: + - name: validate-ingress.stackit.cloud + rules: + - apiGroups: ["networking.k8s.io"] + apiVersions: ["v1"] + operations: ["CREATE", "UPDATE"] + resources: ["ingresses"] + scope: "Namespaced" + clientConfig: + service: + namespace: kube-system + name: stackit-application-load-balancer-contoller-manager + path: "/validate-ingress" + admissionReviewVersions: ["v1"] + sideEffects: None + timeoutSeconds: 5 \ No newline at end of file diff --git a/go.mod b/go.mod index 4d78bb08..24679b58 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,8 @@ require ( 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.6.2 github.com/stackitcloud/stackit-sdk-go/services/iaas v1.11.1 github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v1.12.2 go.uber.org/mock v0.6.0 @@ -30,6 +32,7 @@ require ( k8s.io/klog/v2 v2.140.0 k8s.io/mount-utils v0.36.0 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 @@ -121,12 +126,14 @@ 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 diff --git a/go.sum b/go.sum index 375834af..a241864e 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= @@ -159,6 +165,8 @@ 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/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -186,6 +194,10 @@ 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.6.2 h1:ERtEiDYvT1BYCHzqMk2RUdD7o/9dkpa/60s1QVol3yI= +github.com/stackitcloud/stackit-sdk-go/services/certificates v1.6.2/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= @@ -326,6 +338,8 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= +gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= gonum.org/v1/gonum v0.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= @@ -352,6 +366,8 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= k8s.io/api v0.36.0 h1:SgqDhZzHdOtMk40xVSvCXkP9ME0H05hPM3p9AB1kL80= k8s.io/api v0.36.0/go.mod h1:m1LVrGPNYax5NBHdO+QuAedXyuzTt4RryI/qnmNvs34= +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.0 h1:jZyPzhd5Z+3h9vJLt0z9XdzW9VzNzWAUw+P1xZ9PXtQ= k8s.io/apimachinery v0.36.0/go.mod h1:FklypaRJt6n5wUIwWXIP6GJlIpUizTgfo1T/As+Tyxc= k8s.io/apiserver v0.36.0 h1:Jg5OFAENUACByUCg15CmhZAYrr5ZyJ+jodyA1mHl3YE= @@ -378,6 +394,8 @@ k8s.io/utils v0.0.0-20260507154919-ff6756f316d2 h1:wU4tMEhLGgIbLvXQb1cfN+EcM0wf7 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/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/pkg/alb/ingress/annotations.go b/pkg/alb/ingress/annotations.go index f2f890fe..c92c4f22 100644 --- a/pkg/alb/ingress/annotations.go +++ b/pkg/alb/ingress/annotations.go @@ -20,6 +20,10 @@ const ( // AnnotationPlanID sets the plan for the ALB. // Can be set on IngressClass. AnnotationPlanID = "alb.stackit.cloud/plan-id" + // AnnotationNetworkMode specifies the network routing mode. + // It currently validates the presence of "NodePort" to ensure backward compatibility for future direct-to-pod routing. + // Can be set on Ingress. + AnnotationNetworkMode = "alb.stackit.cloud/network-mode" // AnnotationTargetPoolTLSEnabled If true, the application load balancer enables TLS bridging. // It uses the trusted CAs from the operating system for validation. diff --git a/pkg/alb/ingress/webhook_test.go b/pkg/alb/ingress/webhook_test.go new file mode 100644 index 00000000..cecbfb39 --- /dev/null +++ b/pkg/alb/ingress/webhook_test.go @@ -0,0 +1,164 @@ +package ingress + +import ( + "context" + "encoding/json" + "testing" + + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + admissionv1 "k8s.io/api/admission/v1" +) + +func TestIngressValidator_Handle(t *testing.T) { + s := scheme.Scheme + _ = networkingv1.AddToScheme(s) + + managedIngressClassName := "stackit-alb" + managedIngressClass := &networkingv1.IngressClass{ + ObjectMeta: metav1.ObjectMeta{Name: managedIngressClassName}, + Spec: networkingv1.IngressClassSpec{ + Controller: controllerName, + }, + } + + unmanagedIngressClassName := "nginx" + unmanagedIngressClass := &networkingv1.IngressClass{ + ObjectMeta: metav1.ObjectMeta{Name: unmanagedIngressClassName}, + Spec: networkingv1.IngressClassSpec{ + Controller: "k8s.io/ingress-nginx", + }, + } + + fakeClient := fake.NewClientBuilder().WithScheme(s).WithObjects(managedIngressClass, unmanagedIngressClass).Build() + decoder := admission.NewDecoder(s) + + validator := &IngressValidator{ + Client: fakeClient, + } + _ = validator.InjectDecoder(decoder) + + tests := []struct { + name string + className *string + annotations map[string]string + expectAllowed bool + }{ + { + name: "Valid Ingress", + className: &managedIngressClassName, + annotations: map[string]string{ + AnnotationNetworkMode: "NodePort", + }, + expectAllowed: true, + }, + { + name: "No IngressClass - Should Ignore and Allow", + className: nil, + annotations: map[string]string{}, + expectAllowed: true, + }, + { + name: "Unmanaged IngressClass - Should Ignore and Allow", + className: &unmanagedIngressClassName, + annotations: map[string]string{ + // These are completely invalid for STACKIT ALB, + // but the webhook shouldn't check them because it's unmanaged. + AnnotationNetworkMode: "LoadBalancer", + AnnotationHTTPPort: "potato", + }, + expectAllowed: true, + }, + { + name: "Missing Network Mode", + className: &managedIngressClassName, + annotations: map[string]string{ + AnnotationHTTPPort: "80", + }, + expectAllowed: false, + }, + { + name: "Invalid Network Mode Value - Must be NodePort", + className: &managedIngressClassName, + annotations: map[string]string{ + AnnotationNetworkMode: "LoadBalancer", + }, + expectAllowed: false, + }, + { + name: "Invalid Boolean", + className: &managedIngressClassName, + annotations: map[string]string{ + AnnotationNetworkMode: "NodePort", + AnnotationInternal: "not-a-bool", + }, + expectAllowed: false, + }, + { + name: "Invalid Port Number - Out of Range", + className: &managedIngressClassName, + annotations: map[string]string{ + AnnotationNetworkMode: "NodePort", + AnnotationHTTPPort: "99999", + }, + expectAllowed: false, + }, + { + name: "Invalid IP Address", + className: &managedIngressClassName, + annotations: map[string]string{ + AnnotationNetworkMode: "NodePort", + AnnotationExternalIP: "300.0.0.1", + }, + expectAllowed: false, + }, + { + name: "Negative TTL", + className: &managedIngressClassName, + annotations: map[string]string{ + AnnotationNetworkMode: "NodePort", + AnnotationCookiePersistenceTTLSeconds: "-50", + }, + expectAllowed: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ingress := &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ingress", + Namespace: "default", + Annotations: tt.annotations, + }, + Spec: networkingv1.IngressSpec{ + IngressClassName: tt.className, + }, + } + + // Marshal it into JSON to simulate the API server payload + rawIngress, err := json.Marshal(ingress) + if err != nil { + t.Fatalf("Failed to marshal ingress: %v", err) + } + + req := admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Object: runtime.RawExtension{Raw: rawIngress}, + }, + } + + // Execute the webhook + res := validator.Handle(context.TODO(), req) + + if res.Allowed != tt.expectAllowed { + t.Errorf("Expected Allowed=%v, got Allowed=%v. Result Message: %s", + tt.expectAllowed, res.Allowed, res.Result.Message) + } + }) + } +} \ No newline at end of file diff --git a/pkg/alb/ingress/webook.go b/pkg/alb/ingress/webook.go new file mode 100644 index 00000000..79daf888 --- /dev/null +++ b/pkg/alb/ingress/webook.go @@ -0,0 +1,103 @@ +package ingress + +import ( + "fmt" + "context" + "net" + "net/http" + "strconv" + + networkingv1 "k8s.io/api/networking/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +type IngressValidator struct { + Client client.Client + Decoder admission.Decoder +} + +func (v *IngressValidator) InjectDecoder(d admission.Decoder) error { + v.Decoder = d + return nil +} + +func (v *IngressValidator) Handle(ctx context.Context, req admission.Request) admission.Response { + ingress := &networkingv1.Ingress{} + if err := v.Decoder.Decode(req, ingress); err != nil { + return admission.Errored(http.StatusBadRequest, err) + } + + if ingress.Spec.IngressClassName == nil { + return admission.Allowed("No ingress class specified; ignoring.") + } + + ingressClass := &networkingv1.IngressClass{} + if err := v.Client.Get(ctx, client.ObjectKey{Name: *ingress.Spec.IngressClassName}, ingressClass); err != nil { + return admission.Errored(http.StatusInternalServerError, err) + } + + if ingressClass.Spec.Controller != controllerName { + return admission.Allowed("Ingress managed by a different controller; allowing.") + } + + // 1. Network Mode Check. + mode, exists := ingress.Annotations[AnnotationNetworkMode] + if !exists { + return admission.Denied("The annotation '" + AnnotationNetworkMode + "' is mandatory for STACKIT ALB Ingresses.") + } + if mode != "NodePort" { + return admission.Denied(fmt.Sprintf("The annotation '%s' currently only supports the value 'NodePort'.", AnnotationNetworkMode)) + } + + // 2. Validate IP Addresses. + if val, ok := ingress.Annotations[AnnotationExternalIP]; ok { + if net.ParseIP(val) == nil { + return admission.Denied(fmt.Sprintf("Annotation '%s' must be a valid IP address.", AnnotationExternalIP)) + } + } + + // 3. Validate Booleans. + boolAnnotations := []string{ + AnnotationInternal, + AnnotationTargetPoolTLSEnabled, + AnnotationTargetPoolTLSSkipCertificateValidation, + AnnotationHTTPSOnly, + AnnotationWebSocket, + } + for _, ann := range boolAnnotations { + if val, ok := ingress.Annotations[ann]; ok { + if _, err := strconv.ParseBool(val); err != nil { + return admission.Denied(fmt.Sprintf("Annotation '%s' must be a valid boolean (true or false).", ann)) + } + } + } + + // 4. Validate Ports (Must be between 1 and 65535). + portAnnotations := []string{AnnotationHTTPPort, AnnotationHTTPSPort} + for _, ann := range portAnnotations { + if val, ok := ingress.Annotations[ann]; ok { + port, err := strconv.Atoi(val) + if err != nil || port < 1 || port > 65535 { + return admission.Denied(fmt.Sprintf("Annotation '%s' must be a valid port number between 1 and 65535.", ann)) + } + } + } + + // 5. Validate TTL and Priority (Must be valid integers. TTL must be non-negative). + intAnnotations := []string{AnnotationCookiePersistenceTTLSeconds, AnnotationPriority} + for _, ann := range intAnnotations { + if val, ok := ingress.Annotations[ann]; ok { + num, err := strconv.Atoi(val) + if err != nil { + return admission.Denied(fmt.Sprintf("Annotation '%s' must be a valid integer.", ann)) + } + // Optional: Enforce TTL to be non-negative + if ann == AnnotationCookiePersistenceTTLSeconds && num < 0 { + return admission.Denied(fmt.Sprintf("Annotation '%s' must be greater than or equal to 0.", ann)) + } + } + } + + return admission.Allowed("Validation passed.") +} \ No newline at end of file