Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions cmd/application-load-balancer-controller/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"os"

"github.com/stackitcloud/application-load-balancer-controller/pkg/controller/ingress"
ingresswebhook "github.com/stackitcloud/application-load-balancer-controller/pkg/controller/ingress/webhook"
albclient "github.com/stackitcloud/application-load-balancer-controller/pkg/stackit"
stackitconfig "github.com/stackitcloud/application-load-balancer-controller/pkg/stackit/config"
sdkconfig "github.com/stackitcloud/stackit-sdk-go/core/config"
Expand All @@ -16,6 +17,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 (
Expand Down Expand Up @@ -124,6 +127,22 @@ func main() {
os.Exit(1)
}

// The API reader is used instead of the cached client so that the IngressClass
// lookups always reflect the current API server state.
decoder := admission.NewDecoder(mgr.GetScheme())
mgr.GetWebhookServer().Register("/validate-ingress", &webhook.Admission{
Handler: &ingresswebhook.IngressValidator{
Client: mgr.GetAPIReader(),
Decoder: decoder,
},
})
mgr.GetWebhookServer().Register("/validate-ingressclass", &webhook.Admission{
Handler: &ingresswebhook.IngressClassValidator{
Client: mgr.GetAPIReader(),
Decoder: decoder,
},
})

if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
setupLog.Error(err, "unable to set up health check")
os.Exit(1)
Expand Down
27 changes: 27 additions & 0 deletions deploy/components/cert-manager/issuer.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
name: application-load-balancer-controller
namespace: kube-system
labels:
app.kubernetes.io/name: application-load-balancer-controller
app.kubernetes.io/part-of: stackit-alb
spec:
selfSigned: {}
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: application-load-balancer-controller-webhook-cert
namespace: kube-system
labels:
app.kubernetes.io/name: application-load-balancer-controller
app.kubernetes.io/part-of: stackit-alb
spec:
secretName: application-load-balancer-controller-webhook-cert
dnsNames:
- application-load-balancer-controller.kube-system.svc
- application-load-balancer-controller.kube-system.svc.cluster.local
issuerRef:
kind: Issuer
name: application-load-balancer-controller
14 changes: 14 additions & 0 deletions deploy/components/cert-manager/kustomization.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
apiVersion: kustomize.config.k8s.io/v1alpha1
kind: Component

# Optional cert-manager component. When included from a Kustomization via the
# `components:` field, it contributes a self-signed cert-manager Issuer and a
# Certificate that produces the TLS secret consumed by the webhook server.
#
# Requires cert-manager to be installed in the target cluster. If cert-manager
# is not available, omit this component and provide the secret
# `application-load-balancer-controller-webhook-cert` in the kube-system
# namespace out-of-band, and inject the CA bundle into the
# ValidatingWebhookConfiguration manually.
resources:
- issuer.yaml
11 changes: 11 additions & 0 deletions deploy/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ spec:
- name: probes
containerPort: 8081
protocol: TCP
- name: webhook
containerPort: 9443
protocol: TCP
livenessProbe:
httpGet:
path: /healthz
Expand Down Expand Up @@ -77,11 +80,19 @@ spec:
name: cloud-config
- mountPath: /etc/serviceaccount
name: cloud-secret
- mountPath: /tmp/k8s-webhook-server/serving-certs
name: webhook-cert
readOnly: true
volumes:
- name: cloud-config
configMap:
name: stackit-cloud-config
- name: cloud-secret
secret:
secretName: stackit-cloud-secret
- name: webhook-cert
secret:
# The secret is created by the cert-manager overlay (deploy/cert-manager)
# or must be provided out-of-band by the cluster operator.
secretName: application-load-balancer-controller-webhook-cert
terminationGracePeriodSeconds: 30
2 changes: 2 additions & 0 deletions deploy/kustomization.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ resources:
- serviceaccount.yaml
- rbac.yaml
- deployment.yaml
- service.yaml
- validating-webhook.yaml
21 changes: 21 additions & 0 deletions deploy/service.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
apiVersion: v1
kind: Service
metadata:
name: application-load-balancer-controller
namespace: kube-system
labels:
app.kubernetes.io/name: application-load-balancer-controller
app.kubernetes.io/part-of: stackit-alb
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: application-load-balancer-controller
ports:
- name: webhook
port: 443
targetPort: webhook
protocol: TCP
- name: metrics
port: 8080
targetPort: metrics
protocol: TCP
47 changes: 47 additions & 0 deletions deploy/validating-webhook.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingWebhookConfiguration
metadata:
name: application-load-balancer-controller
labels:
app.kubernetes.io/name: application-load-balancer-controller
app.kubernetes.io/part-of: stackit-alb
annotations:
# cert-manager injects the CA bundle from the certificate referenced below.
# When applied without the cert-manager overlay, operators must set
# webhooks[*].clientConfig.caBundle out-of-band.
cert-manager.io/inject-ca-from: kube-system/application-load-balancer-controller-webhook-cert
webhooks:
- name: validate-ingress.alb.stackit.cloud
rules:
- apiGroups: ["networking.k8s.io"]
apiVersions: ["v1"]
operations: ["CREATE", "UPDATE"]
resources: ["ingresses"]
scope: "Namespaced"
clientConfig:
service:
namespace: kube-system
name: application-load-balancer-controller
path: /validate-ingress
port: 443
admissionReviewVersions: ["v1"]
sideEffects: None
timeoutSeconds: 5
failurePolicy: Fail
- name: validate-ingressclass.alb.stackit.cloud
rules:
- apiGroups: ["networking.k8s.io"]
apiVersions: ["v1"]
operations: ["CREATE", "UPDATE"]
resources: ["ingressclasses"]
scope: "Cluster"
clientConfig:
service:
namespace: kube-system
name: application-load-balancer-controller
path: /validate-ingressclass
port: 443
admissionReviewVersions: ["v1"]
sideEffects: None
timeoutSeconds: 5
failurePolicy: Fail
16 changes: 16 additions & 0 deletions docs/user.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,22 @@ If the ingress class results in zero listeners, a dummy listener on port 80 is a
This listener always returns the HTTP status code 404.
Common scenarios where this can happen is when there are zero ingresses or an HTTPS-only load balancer does not have any certificates yet.

### Validating Admission Webhook

The controller ships two Kubernetes validating admission webhooks that reject
Ingress and IngressClass objects with invalid annotations at admission time.
This lets you catch mistakes (invalid IP addresses, unsupported plan IDs,
missing mandatory annotations, immutable field changes, etc.) before they are
persisted, instead of only observing them via reconciliation events later.

The webhooks only validate resources managed by the STACKIT ALB controller:

- For an `Ingress`, validation is skipped when `spec.ingressClassName` is unset
or references an `IngressClass` whose `spec.controller` is not
`stackit.cloud/alb-ingress`.
- For an `IngressClass`, validation is skipped when `spec.controller` is not
`stackit.cloud/alb-ingress`.

### Troubleshooting

The controller emits events on both ingresses and ingress classes.
Expand Down
6 changes: 3 additions & 3 deletions pkg/controller/ingress/ingressclass_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ import (
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"
// ControllerName is the name of the ALB controller that the IngressClass should point to for reconciliation
ControllerName = "stackit.cloud/alb-ingress"

// readyRequeueInterval defines how often the controller should check for the ALB to become ready.
readyRequeueInterval = 10 * time.Second
Expand All @@ -53,7 +53,7 @@ func (r *IngressClassReconciler) Reconcile(ctx context.Context, req ctrl.Request
}

// Check if the IngressClass points to the ALB controller
if ingressClass.Spec.Controller != controllerName {
if ingressClass.Spec.Controller != ControllerName {
// If this IngressClass doesn't point to the ALB controller, ignore this IngressClass
return ctrl.Result{}, nil
}
Expand Down
8 changes: 4 additions & 4 deletions pkg/controller/ingress/ingressclass_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ var _ = Describe("IngressClassController", func() {
},
},
Spec: networkingv1.IngressClassSpec{
Controller: controllerName,
Controller: ControllerName,
},
}
Expect(k8sClient.Create(ctx, ingressClass)).To(Succeed())
Expand Down Expand Up @@ -185,7 +185,7 @@ var _ = Describe("IngressClassController", func() {
},
},
Spec: networkingv1.IngressClassSpec{
Controller: controllerName,
Controller: ControllerName,
},
}
Expect(k8sClient.Create(ctx, ingressClass)).To(Succeed())
Expand Down Expand Up @@ -418,7 +418,7 @@ var _ = Describe("IngressClassController", func() {
},
},
Spec: networkingv1.IngressClassSpec{
Controller: controllerName,
Controller: ControllerName,
},
}
testutil.CreateKubernetesResourceAndDeferDeletion(ctx, k8sClient, ingressClass)
Expand Down Expand Up @@ -468,7 +468,7 @@ var _ = Describe("IngressClassController", func() {
},
},
Spec: networkingv1.IngressClassSpec{
Controller: controllerName,
Controller: ControllerName,
},
}
testutil.CreateKubernetesResourceAndDeferDeletion(ctx, k8sClient, ignoredIngressClass)
Expand Down
8 changes: 4 additions & 4 deletions pkg/controller/ingress/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ func ingressClassRequestsForReferencingIngresses(ctx context.Context, c client.C
if err := c.Get(ctx, types.NamespacedName{Name: className}, class); err != nil {
continue
}
if class.Spec.Controller == controllerName {
if class.Spec.Controller == ControllerName {
reqs = append(reqs, reconcile.Request{NamespacedName: types.NamespacedName{Name: className}})
}
}
Expand All @@ -151,7 +151,7 @@ func nodeEventHandler(c client.Client) handler.EventHandler {
}
requestList := []ctrl.Request{}
for i := range ingressClassList.Items {
if ingressClassList.Items[i].Spec.Controller != controllerName {
if ingressClassList.Items[i].Spec.Controller != ControllerName {
continue
}
requestList = append(requestList, ctrl.Request{
Expand All @@ -175,7 +175,7 @@ func ingressEventHandler(c client.Client) handler.EventHandler {
return nil
}

if ingressClass.Spec.Controller != controllerName {
if ingressClass.Spec.Controller != ControllerName {
return nil
}

Expand Down Expand Up @@ -221,6 +221,6 @@ func ingressClassPredicate() predicate.Predicate {
if !ok {
return false
}
return ingressClass.Spec.Controller == controllerName
return ingressClass.Spec.Controller == ControllerName
})
}
75 changes: 75 additions & 0 deletions pkg/controller/ingress/spec/validation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package spec

import (
"fmt"
"net"
"net/netip"
"slices"
"strings"

networkingv1 "k8s.io/api/networking/v1"
)

// ServicePlans lists all supported ALB service plans.
var ServicePlans = []string{
"p10",
}

// DefaultServicePlan is the plan used when no plan is explicitly requested.
const DefaultServicePlan = "p10"

// ValidateNetworkMode validates that the network mode annotation is set to a supported value.
// Currently only NetworkModeNodePort is supported and the annotation is mandatory.
func ValidateNetworkMode(ingressClass *networkingv1.IngressClass) error {
networkMode := ingressClass.Annotations[AnnotationNetworkMode]
if networkMode != NetworkModeNodePort {
return fmt.Errorf("annotation %s must be set to %s", AnnotationNetworkMode, NetworkModeNodePort)
}
return nil
}

// ValidateExternalIP validates that the provided external IP annotation value is a valid IPv4 address.
// An empty value is considered valid.
func ValidateExternalIP(value string) error {
if value == "" {
return nil
}
addr, err := netip.ParseAddr(value)
if err != nil {
return fmt.Errorf("failed to parse external IP annotation: %w", err)
}
if !addr.Is4() {
return fmt.Errorf("external IP annotation is not an IPv4 address")
}
return nil
}

// ValidatePlanID validates that the provided plan ID is one of the supported service plans.
// An empty value is considered valid (defaults to DefaultServicePlan at reconcile time).
func ValidatePlanID(value string) error {
if value == "" {
return nil
}
if !slices.Contains(ServicePlans, value) {
return fmt.Errorf("invalid plan id %q", value)
}
return nil
}

// ValidateAllowedSourceRanges validates a comma-separated list of CIDR ranges.
// An empty value is considered valid.
func ValidateAllowedSourceRanges(value string) ([]string, error) {
if value == "" {
return nil, nil
}
ranges := strings.Split(value, ",")
for i, r := range ranges {
if k := slices.Index(ranges, r); k < i {
return nil, fmt.Errorf("duplicate range in annotation %s", AnnotationAllowedSourceRanges)
}
if _, _, err := net.ParseCIDR(r); err != nil {
return nil, fmt.Errorf("IP range %d is invalid: %w", i, err)
}
}
return ranges, nil
}
Loading