diff --git a/cmd/application-load-balancer-controller-manager/main.go b/cmd/application-load-balancer-controller-manager/main.go index 735ed522..950280f8 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,20 @@ func main() { os.Exit(1) } + decoder := admission.NewDecoder(mgr.GetScheme()) + mgr.GetWebhookServer().Register("/validate-ingress", &webhook.Admission{ + Handler: &ingress.IngressValidator{ + Client: mgr.GetAPIReader(), + Decoder: decoder, + }, + }) + mgr.GetWebhookServer().Register("/validate-ingressclass", &webhook.Admission{ + Handler: &ingress.IngressClassValidator{ + Client: mgr.GetAPIReader(), + Decoder: decoder, + }, + }) + 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 52e82f59..ca490539 100644 --- a/deploy/application-load-balancer-controller-manager/deployment.yaml +++ b/deploy/application-load-balancer-controller-manager/deployment.yaml @@ -12,6 +12,9 @@ spec: selector: matchLabels: app: stackit-application-load-balancer-controller-manager + networking.gardener.cloud/from-seed: allowed # Allow traffic from seed to shoot for webhook calls + networking.gardener.cloud/to-dns: allowed # Allow traffic to CoreDNS for webhook calls + networking.gardener.cloud/to-apiserver: allowed # Allow traffic to API server for webhook calls template: metadata: labels: @@ -43,6 +46,9 @@ spec: hostPort: 8081 name: probe protocol: TCP + - containerPort: 9443 + name: webhook + protocol: TCP resources: limits: cpu: "0.5" @@ -53,7 +59,14 @@ spec: volumeMounts: - mountPath: /etc/serviceaccount name: cloud-secret + - mountPath: /tmp/k8s-webhook-server/serving-certs + name: webhook-cert + readOnly: true volumes: - name: cloud-secret secret: secretName: stackit-cloud-secret + - name: webhook-cert + secret: + secretName: stackit-application-load-balancer-controller-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 5dff529b..bdc5769f 100644 --- a/deploy/application-load-balancer-controller-manager/kustomization.yaml +++ b/deploy/application-load-balancer-controller-manager/kustomization.yaml @@ -5,3 +5,5 @@ resources: - deployment.yaml - rbac.yaml - service.yaml +- validating-webhook.yaml +- validating-webhook-issuer.yaml diff --git a/deploy/application-load-balancer-controller-manager/service.yaml b/deploy/application-load-balancer-controller-manager/service.yaml index bd4f1d77..1fe65be8 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: 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..c5d43871 --- /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-controller-manager + namespace: kube-system +spec: + selfSigned: {} +--- +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: stackit-application-load-balancer-controller-manager-webhook-cert + namespace: kube-system +spec: + dnsNames: + - stackit-application-load-balancer-controller-manager.kube-system.svc + - stackit-application-load-balancer-controller-manager.kube-system.svc.cluster.local + issuerRef: + kind: Issuer + name: stackit-application-load-balancer-controller-manager + secretName: stackit-application-load-balancer-controller-manager-webhook-cert # cert-manager will create this secret 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..4f48d2b9 --- /dev/null +++ b/deploy/application-load-balancer-controller-manager/validating-webhook.yaml @@ -0,0 +1,37 @@ +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingWebhookConfiguration +metadata: + name: stackit-application-load-balancer-controller-manager + annotations: + cert-manager.io/inject-ca-from: kube-system/stackit-application-load-balancer-controller-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-controller-manager + path: "/validate-ingress" + admissionReviewVersions: ["v1"] + sideEffects: None + timeoutSeconds: 5 + - name: validate-ingressclass.stackit.cloud + rules: + - apiGroups: ["networking.k8s.io"] + apiVersions: ["v1"] + operations: ["CREATE", "UPDATE"] + resources: ["ingressclasses"] + scope: "Cluster" + clientConfig: + service: + namespace: kube-system + name: stackit-application-load-balancer-controller-manager + path: "/validate-ingressclass" + admissionReviewVersions: ["v1"] + sideEffects: None + timeoutSeconds: 5 \ No newline at end of file diff --git a/docs/albcm.md b/docs/albcm.md index 3e32d9ef..df31715e 100644 --- a/docs/albcm.md +++ b/docs/albcm.md @@ -35,6 +35,13 @@ If you need to explicitly prioritize certain rules over others, you can override 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. +### Network Routing Mode +The network routing mode determines how the ALB forwards external traffic to your applications within the cluster. Specifying this mode is mandatory. If omitted, your resource will be rejected by the validation webhook. + +You must set the `alb.stackit.cloud/network-mode` annotation on your IngressClass. + +Currently `NodePort` is the only supported mode. The ALB routes traffic to the cluster nodes, which then forward it to your pods. Future releases will introduce direct-to-pod routing as an additional mode. Setting `NodePort` now ensures your current configurations remain backwards compatible when new modes are added. + ### 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. @@ -69,6 +76,7 @@ metadata: | Annotation | Allowed On | Requirement | Description | | :--- | :--- | :--- | :--- | +| `alb.stackit.cloud/network-mode` | IngressClass | Mandatory | Routing mode (currently only `NodePort` supported). | | `alb.stackit.cloud/external-address` | IngressClass | Optional | Uses a specific STACKIT floating 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. | @@ -82,4 +90,4 @@ metadata: | `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 +| `alb.stackit.cloud/traget-pool-tls-skip-certificate-validation`| IngressClass, Ingress, Service | Optional | Enables TLS bridging but skips certificate validation. | diff --git a/pkg/alb/ingress/annotations.go b/pkg/alb/ingress/annotations.go index f2f890fe..4f16bae5 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 IngressClass. + 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/ingress_webhook_test.go b/pkg/alb/ingress/ingress_webhook_test.go new file mode 100644 index 00000000..0e91d630 --- /dev/null +++ b/pkg/alb/ingress/ingress_webhook_test.go @@ -0,0 +1,221 @@ +package ingress + +import ( + "context" + "encoding/json" + "testing" + + admissionv1 "k8s.io/api/admission/v1" + 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" +) + +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, + Decoder: decoder, + } + + tests := []struct { + name string + operation admissionv1.Operation + className *string + annotations map[string]string + expectAllowed bool + }{ + { + name: "Valid Ingress (Create)", + operation: admissionv1.Create, + className: &managedIngressClassName, + annotations: map[string]string{ + AnnotationHTTPSOnly: "true", + AnnotationPriority: "100", + }, + expectAllowed: true, + }, + { + name: "Valid Ingress (Update)", + operation: admissionv1.Update, + className: &managedIngressClassName, + annotations: map[string]string{ + AnnotationHTTPSOnly: "false", + AnnotationCookiePersistenceTTLSeconds: "3600", + }, + expectAllowed: true, + }, + { + name: "No IngressClass - Should Ignore and Allow", + operation: admissionv1.Create, + className: nil, + annotations: map[string]string{}, + expectAllowed: true, + }, + { + name: "Unmanaged IngressClass - Should Ignore and Allow", + operation: admissionv1.Create, + className: &unmanagedIngressClassName, + annotations: map[string]string{ + AnnotationHTTPSOnly: "not-a-bool", + }, + expectAllowed: true, + }, + { + name: "Denied - Invalid Boolean", + operation: admissionv1.Create, + className: &managedIngressClassName, + annotations: map[string]string{ + AnnotationHTTPSOnly: "not-a-bool", + }, + expectAllowed: false, + }, + { + name: "Denied - Invalid Integer", + operation: admissionv1.Create, + className: &managedIngressClassName, + annotations: map[string]string{ + AnnotationPriority: "high", + }, + expectAllowed: false, + }, + { + name: "Denied - Negative TTL", + operation: admissionv1.Create, + className: &managedIngressClassName, + annotations: map[string]string{ + AnnotationCookiePersistenceTTLSeconds: "-50", + }, + expectAllowed: false, + }, + { + name: "Valid WAF Name (Allowed)", + operation: admissionv1.Create, + className: &managedIngressClassName, + annotations: map[string]string{ + AnnotationWAFName: "my-valid-waf-123", + }, + expectAllowed: true, + }, + { + name: "Valid WAF Name Single Char (Allowed)", + operation: admissionv1.Create, + className: &managedIngressClassName, + annotations: map[string]string{ + AnnotationWAFName: "a", + }, + expectAllowed: true, + }, + { + name: "Denied - Invalid WAF Name (Uppercase)", + operation: admissionv1.Create, + className: &managedIngressClassName, + annotations: map[string]string{ + AnnotationWAFName: "My-Waf-Name", + }, + expectAllowed: false, + }, + { + name: "Denied - Invalid WAF Name (Starts with hyphen)", + operation: admissionv1.Create, + className: &managedIngressClassName, + annotations: map[string]string{ + AnnotationWAFName: "-my-waf", + }, + expectAllowed: false, + }, + { + name: "Denied - Invalid WAF Name (Ends with hyphen)", + operation: admissionv1.Create, + className: &managedIngressClassName, + annotations: map[string]string{ + AnnotationWAFName: "my-waf-", + }, + expectAllowed: false, + }, + { + name: "Denied - Invalid WAF Name (Invalid Character)", + operation: admissionv1.Create, + className: &managedIngressClassName, + annotations: map[string]string{ + AnnotationWAFName: "my_waf_name", + }, + expectAllowed: false, + }, + { + name: "Denied - Invalid WAF Name (Too Long - 64 chars)", + operation: admissionv1.Create, + className: &managedIngressClassName, + annotations: map[string]string{ + AnnotationWAFName: "a123456789012345678901234567890123456789012345678901234567890123", + }, + expectAllowed: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ingress := &networkingv1.Ingress{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "networking.k8s.io/v1", + Kind: "Ingress", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ingress", + Namespace: "default", + Annotations: tt.annotations, + }, + Spec: networkingv1.IngressSpec{ + IngressClassName: tt.className, + }, + } + + rawIngress, err := json.Marshal(ingress) + if err != nil { + t.Fatalf("Failed to marshal ingress: %v", err) + } + + req := admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Operation: tt.operation, + Object: runtime.RawExtension{Raw: rawIngress}, + }, + } + + if tt.operation == admissionv1.Update { + req.AdmissionRequest.OldObject = runtime.RawExtension{Raw: rawIngress} + } + + 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/ingress_webook.go b/pkg/alb/ingress/ingress_webook.go new file mode 100644 index 00000000..01eede5f --- /dev/null +++ b/pkg/alb/ingress/ingress_webook.go @@ -0,0 +1,123 @@ +package ingress + +import ( + "fmt" + "context" + "net/http" + "strconv" + "regexp" + + networkingv1 "k8s.io/api/networking/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + admissionv1 "k8s.io/api/admission/v1" +) + +type IngressValidator struct { + Client client.Reader + Decoder admission.Decoder +} + +// Handle routes the request based on the operation type. +func (v *IngressValidator) Handle(ctx context.Context, req admission.Request) admission.Response { + switch req.Operation { + case admissionv1.Create: + return v.handleCreate(ctx, req) + case admissionv1.Update: + return v.handleUpdate(ctx, req) + default: + return admission.Allowed("Unhandled operation allowed.") + } +} + +func (v *IngressValidator) handleCreate(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.") + } + + return v.validateBaseAnnotations(ctx, ingress) +} + +func (v *IngressValidator) handleUpdate(ctx context.Context, req admission.Request) admission.Response { + newIngress := &networkingv1.Ingress{} + if err := v.Decoder.Decode(req, newIngress); err != nil { + return admission.Errored(http.StatusBadRequest, err) + } + + oldIngress := &networkingv1.Ingress{} + if err := v.Decoder.DecodeRaw(req.OldObject, oldIngress); err != nil { + return admission.Errored(http.StatusBadRequest, err) + } + + if newIngress.Spec.IngressClassName == nil { + return admission.Allowed("No ingress class specified; ignoring.") + } + + ingressClass := &networkingv1.IngressClass{} + if err := v.Client.Get(ctx, client.ObjectKey{Name: *newIngress.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.") + } + + return v.validateBaseAnnotations(ctx, newIngress) +} + +// validateBaseAnnotations checks simple formatting, allowed values, and basic constraints for all relevant annotations. +func (v *IngressValidator) validateBaseAnnotations(ctx context.Context, ingress *networkingv1.Ingress) admission.Response { + // Validate WAF Name using the provided regex constraint + if val, ok := ingress.Annotations[AnnotationWAFName]; ok { + wafRegex := `^[0-9a-z](?:(?:[0-9a-z]|-){0,61}[0-9a-z])?$` + matched, _ := regexp.MatchString(wafRegex, val) + if !matched { + return admission.Denied(fmt.Sprintf("Annotation '%s' has an invalid value '%s'. It must match the pattern: %s", AnnotationWAFName, val, wafRegex)) + } + } + + // Validate Booleans + boolAnnotations := []string{ + 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)) + } + } + } + + // Validate Integers and TTL limits + 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)) + } + 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 diff --git a/pkg/alb/ingress/ingressclass_webhook.go b/pkg/alb/ingress/ingressclass_webhook.go new file mode 100644 index 00000000..895134de --- /dev/null +++ b/pkg/alb/ingress/ingressclass_webhook.go @@ -0,0 +1,184 @@ +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" + admissionv1 "k8s.io/api/admission/v1" +) + +type IngressClassValidator struct { + Client client.Reader + Decoder admission.Decoder +} + +func (v *IngressClassValidator) Handle(ctx context.Context, req admission.Request) admission.Response { + switch req.Operation { + case admissionv1.Create: + return v.handleCreate(ctx, req) + case admissionv1.Update: + return v.handleUpdate(ctx, req) + default: + return admission.Allowed("Unhandled operation allowed.") + } +} + +func (v *IngressClassValidator) handleCreate(ctx context.Context, req admission.Request) admission.Response { + newClass := &networkingv1.IngressClass{} + if err := v.Decoder.Decode(req, newClass); err != nil { + return admission.Errored(http.StatusBadRequest, err) + } + + if newClass.Spec.Controller != controllerName { + return admission.Allowed("Not a STACKIT ALB IngressClass.") + } + + if val, ok := newClass.Annotations[AnnotationExternalIP]; ok { + if net.ParseIP(val) == nil { + return admission.Denied(fmt.Sprintf("Annotation '%s' must be a valid IP address.", AnnotationExternalIP)) + } + } + + if resp := v.validateBaseAnnotations(newClass); !resp.Allowed { + return resp + } + + return admission.Allowed("IngressClass creation valid.") +} + +func (v *IngressClassValidator) handleUpdate(ctx context.Context, req admission.Request) admission.Response { + oldClass := &networkingv1.IngressClass{} + if err := v.Decoder.DecodeRaw(req.OldObject, oldClass); err != nil { + return admission.Errored(http.StatusBadRequest, err) + } + + newClass := &networkingv1.IngressClass{} + if err := v.Decoder.Decode(req, newClass); err != nil { + return admission.Errored(http.StatusBadRequest, err) + } + + if newClass.Spec.Controller != controllerName { + return admission.Allowed("Not a STACKIT ALB IngressClass.") + } + + if val, ok := newClass.Annotations[AnnotationExternalIP]; ok { + if net.ParseIP(val) == nil { + return admission.Denied(fmt.Sprintf("Annotation '%s' must be a valid IP address.", AnnotationExternalIP)) + } + } + + if resp := v.validateBaseAnnotations(newClass); !resp.Allowed { + return resp + } + + // Check immutability for AnnotationInternal + oldInternal := oldClass.Annotations[AnnotationInternal] + newInternal := newClass.Annotations[AnnotationInternal] + if oldInternal != newInternal { + return admission.Denied(fmt.Sprintf("The annotation '%s' is immutable and cannot be changed after creation.", AnnotationInternal)) + } + + if resp := v.validateIPUpdate(ctx, oldClass, newClass); !resp.Allowed { + return resp + } + + return admission.Allowed("IngressClass creation valid.") +} + +// validateBaseAnnotations checks simple formatting and allowed values for IngressClass annotations. +func (v *IngressClassValidator) validateBaseAnnotations(ingressClass *networkingv1.IngressClass) admission.Response { + if val, ok := ingressClass.Annotations[AnnotationExternalIP]; ok { + if net.ParseIP(val) == nil { + return admission.Denied(fmt.Sprintf("Annotation '%s' must be a valid IP address.", AnnotationExternalIP)) + } + } + + // Check if AnnotationInternal is a boolean. + if val, ok := ingressClass.Annotations[AnnotationInternal]; ok { + if _, err := strconv.ParseBool(val); err != nil { + return admission.Denied(fmt.Sprintf("Annotation '%s' must be a valid boolean (true or false).", AnnotationInternal)) + } + } + + // Network Mode Check. + mode, exists := ingressClass.Annotations[AnnotationNetworkMode] + if !exists { + return admission.Denied("The annotation '" + AnnotationNetworkMode + "' is mandatory for STACKIT ALB IngressClasses.") + } + if mode != "NodePort" { + return admission.Denied(fmt.Sprintf("The annotation '%s' currently only supports the value 'NodePort'.", AnnotationNetworkMode)) + } + + // Service Plan Check. + if val, ok := ingressClass.Annotations[AnnotationPlanID]; ok { + switch val { + case "p10", "p50", "p250", "p750": + default: + return admission.Denied(fmt.Sprintf("Annotation '%s' has an invalid value '%s'. Allowed values are: p10, p50, p250, p750.", AnnotationPlanID, val)) + } + } + + // Validate Listener Ports (Must be between 1 and 65535) + portAnnotations := []string{AnnotationHTTPPort, AnnotationHTTPSPort} + for _, ann := range portAnnotations { + if val, ok := ingressClass.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)) + } + } + } + + return admission.Allowed("Base annotations valid.") +} + +// validateIPUpdate enforces the strict update rules for the external IP annotation. +func (v *IngressClassValidator) validateIPUpdate(ctx context.Context, oldClass, newClass *networkingv1.IngressClass) admission.Response { + oldIP, oldHadIP := oldClass.Annotations[AnnotationExternalIP] + newIP, newHasIP := newClass.Annotations[AnnotationExternalIP] + + if oldHadIP && newHasIP && oldIP != newIP { + errMsg := fmt.Sprintf("Changing an existing static IP address is not allowed. The annotation '%s' cannot be updated from '%s' to '%s'.", + AnnotationExternalIP, oldIP, newIP) + return admission.Denied(errMsg) + } + + if !oldHadIP && newHasIP { + currentAssignedIP, err := v.getAssignedEphemeralIP(ctx, newClass.Name) + if err != nil { + return admission.Errored(http.StatusInternalServerError, err) + } + + if currentAssignedIP == "" || currentAssignedIP != newIP { + errMsg := fmt.Sprintf("The load balancer can only be promoted to a static IP address that matches its current ephemeral IP (currently assigned: '%s', requested: '%s').", + currentAssignedIP, newIP) + return admission.Denied(errMsg) + } + } + + return admission.Allowed("IngressClass IP update valid.") +} + +// getAssignedEphemeralIP scans the cluster for an Ingress using this class and returns its assigned IP. +func (v *IngressClassValidator) getAssignedEphemeralIP(ctx context.Context, className string) (string, error) { + ingressList := &networkingv1.IngressList{} + if err := v.Client.List(ctx, ingressList); err != nil { + return "", err + } + + for _, ing := range ingressList.Items { + if ing.Spec.IngressClassName != nil && *ing.Spec.IngressClassName == className { + if len(ing.Status.LoadBalancer.Ingress) > 0 { + return ing.Status.LoadBalancer.Ingress[0].IP, nil + } + } + } + + return "", nil +} \ No newline at end of file diff --git a/pkg/alb/ingress/ingressclass_webhook_test.go b/pkg/alb/ingress/ingressclass_webhook_test.go new file mode 100644 index 00000000..8557d9f9 --- /dev/null +++ b/pkg/alb/ingress/ingressclass_webhook_test.go @@ -0,0 +1,251 @@ +package ingress + +import ( + "context" + "encoding/json" + "testing" + + admissionv1 "k8s.io/api/admission/v1" + 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" +) + +func TestIngressClassValidator_Handle(t *testing.T) { + s := scheme.Scheme + _ = networkingv1.AddToScheme(s) + + decoder := admission.NewDecoder(s) + managedController := controllerName + unmanagedController := "k8s.io/ingress-nginx" + testClassName := "test-class" + + tests := []struct { + name string + operation admissionv1.Operation + controller string + oldAnnotations map[string]string + newAnnotations map[string]string + ephemeralIP string + expectAllowed bool + }{ + { + name: "Valid IngressClass Create", + operation: admissionv1.Create, + controller: managedController, + newAnnotations: map[string]string{ + AnnotationNetworkMode: "NodePort", + AnnotationPlanID: "p50", + AnnotationHTTPPort: "80", + }, + expectAllowed: true, + }, + { + name: "Unmanaged Controller - Should Ignore and Allow", + operation: admissionv1.Create, + controller: unmanagedController, + newAnnotations: map[string]string{ + AnnotationPlanID: "invalid-plan", + }, + expectAllowed: true, + }, + { + name: "Missing Network Mode (Denied)", + operation: admissionv1.Create, + controller: managedController, + newAnnotations: map[string]string{ + AnnotationPlanID: "p50", + }, + expectAllowed: false, + }, + { + name: "Invalid Network Mode Value (Denied)", + operation: admissionv1.Create, + controller: managedController, + newAnnotations: map[string]string{ + AnnotationNetworkMode: "LoadBalancer", + }, + expectAllowed: false, + }, + { + name: "Invalid IP Address Format (Denied)", + operation: admissionv1.Create, + controller: managedController, + newAnnotations: map[string]string{ + AnnotationNetworkMode: "NodePort", + AnnotationExternalIP: "not-an-ip-address", + }, + expectAllowed: false, + }, + { + name: "Invalid Plan ID (Denied)", + operation: admissionv1.Create, + controller: managedController, + newAnnotations: map[string]string{ + AnnotationNetworkMode: "NodePort", + AnnotationPlanID: "p100", + }, + expectAllowed: false, + }, + { + name: "Invalid Port (Denied)", + operation: admissionv1.Create, + controller: managedController, + newAnnotations: map[string]string{ + AnnotationNetworkMode: "NodePort", + AnnotationHTTPPort: "99999", + }, + expectAllowed: false, + }, + + { + name: "Update - Keep same Static IP (Allowed)", + operation: admissionv1.Update, + controller: managedController, + oldAnnotations: map[string]string{ + AnnotationNetworkMode: "NodePort", + AnnotationExternalIP: "1.2.3.4", + }, + newAnnotations: map[string]string{ + AnnotationNetworkMode: "NodePort", + AnnotationExternalIP: "1.2.3.4", + }, + expectAllowed: true, + }, + { + name: "Update - Change existing Static IP (Denied)", + operation: admissionv1.Update, + controller: managedController, + oldAnnotations: map[string]string{ + AnnotationNetworkMode: "NodePort", + AnnotationExternalIP: "1.2.3.4", + }, + newAnnotations: map[string]string{ + AnnotationNetworkMode: "NodePort", + AnnotationExternalIP: "5.6.7.8", + }, + expectAllowed: false, + }, + { + name: "Update - Promote Ephemeral to Static (Matches - Allowed)", + operation: admissionv1.Update, + controller: managedController, + oldAnnotations: map[string]string{ + AnnotationNetworkMode: "NodePort", + }, + newAnnotations: map[string]string{ + AnnotationNetworkMode: "NodePort", + AnnotationExternalIP: "9.9.9.9", + }, + ephemeralIP: "9.9.9.9", + expectAllowed: true, + }, + { + name: "Update - Promote Ephemeral to Static (Mismatch - Denied)", + operation: admissionv1.Update, + controller: managedController, + oldAnnotations: map[string]string{ + AnnotationNetworkMode: "NodePort", + }, + newAnnotations: map[string]string{ + AnnotationNetworkMode: "NodePort", + AnnotationExternalIP: "8.8.8.8", + }, + ephemeralIP: "9.9.9.9", + expectAllowed: false, + }, + { + name: "Update - Promote Ephemeral to Static (No IP Assigned Yet - Denied)", + operation: admissionv1.Update, + controller: managedController, + oldAnnotations: map[string]string{ + AnnotationNetworkMode: "NodePort", + }, + newAnnotations: map[string]string{ + AnnotationNetworkMode: "NodePort", + AnnotationExternalIP: "8.8.8.8", + }, + ephemeralIP: "", + expectAllowed: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + clientBuilder := fake.NewClientBuilder().WithScheme(s) + + if tt.ephemeralIP != "" { + mockIngress := &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "dummy-ingress", + Namespace: "default", + }, + Spec: networkingv1.IngressSpec{ + IngressClassName: &testClassName, + }, + Status: networkingv1.IngressStatus{ + LoadBalancer: networkingv1.IngressLoadBalancerStatus{ + Ingress: []networkingv1.IngressLoadBalancerIngress{ + {IP: tt.ephemeralIP}, + }, + }, + }, + } + clientBuilder.WithObjects(mockIngress) + } + + validator := &IngressClassValidator{ + Client: clientBuilder.Build(), + Decoder: decoder, + } + + newClass := &networkingv1.IngressClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: testClassName, + Annotations: tt.newAnnotations, + }, + Spec: networkingv1.IngressClassSpec{ + Controller: tt.controller, + }, + } + rawNew, err := json.Marshal(newClass) + if err != nil { + t.Fatalf("Failed to marshal new IngressClass: %v", err) + } + + req := admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Operation: tt.operation, + Object: runtime.RawExtension{Raw: rawNew}, + }, + } + + if tt.operation == admissionv1.Update { + oldClass := &networkingv1.IngressClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: testClassName, + Annotations: tt.oldAnnotations, + }, + Spec: networkingv1.IngressClassSpec{ + Controller: tt.controller, + }, + } + rawOld, err := json.Marshal(oldClass) + if err != nil { + t.Fatalf("Failed to marshal old IngressClass: %v", err) + } + req.AdmissionRequest.OldObject = runtime.RawExtension{Raw: rawOld} + } + + 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