Skip to content

Commit 3e58652

Browse files
Kamil PrzybylKamil Przybyl
authored andcommitted
feat: add ingressclass webhook
1 parent b70ee73 commit 3e58652

9 files changed

Lines changed: 541 additions & 93 deletions

File tree

cmd/application-load-balancer-controller-manager/main.go

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -135,12 +135,19 @@ func main() {
135135
os.Exit(1)
136136
}
137137

138+
decoder := admission.NewDecoder(mgr.GetScheme())
138139
mgr.GetWebhookServer().Register("/validate-ingress", &webhook.Admission{
139-
Handler: &ingress.IngressValidator{
140-
Client: mgr.GetClient(),
141-
Decoder: admission.NewDecoder(mgr.GetScheme()),
142-
},
143-
})
140+
Handler: &ingress.IngressValidator{
141+
Client: mgr.GetClient(),
142+
Decoder: decoder,
143+
},
144+
})
145+
mgr.GetWebhookServer().Register("/validate-ingressclass", &webhook.Admission{
146+
Handler: &ingress.IngressClassValidator{
147+
Client: mgr.GetClient(),
148+
Decoder: decoder,
149+
},
150+
})
144151

145152
setupLog.Info("starting manager")
146153
if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil {

deploy/application-load-balancer-controller-manager/deployment.yaml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ spec:
4444
name: probe
4545
protocol: TCP
4646
- containerPort: 9443
47-
name: validating-webhook
47+
name: webhook
4848
protocol: TCP
4949
resources:
5050
limits:
@@ -57,13 +57,13 @@ spec:
5757
- mountPath: /etc/serviceaccount
5858
name: cloud-secret
5959
- mountPath: /tmp/k8s-webhook-server/serving-certs
60-
name: validating-webhook-cert
60+
name: webhook-cert
6161
readOnly: true
6262
volumes:
6363
- name: cloud-secret
6464
secret:
6565
secretName: stackit-cloud-secret
66-
- name: validating-webhook-cert
66+
- name: webhook-cert
6767
secret:
6868
secretName: stackit-application-load-balancer-contoller-manager-webhook-cert
6969

deploy/application-load-balancer-controller-manager/service.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ spec:
1717
port: 8080
1818
targetPort: metrics
1919
protocol: TCP
20-
- name: validating-webhook
20+
- name: webhook
2121
port: 443
2222
targetPort: 9443
2323
protocol: TCP

deploy/application-load-balancer-controller-manager/validating-webhook-issuer.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,5 @@ spec:
1818
issuerRef:
1919
kind: Issuer
2020
name: stackit-application-load-balancer-contoller-manager
21-
secretName: stackit-application-load-balancer-contoller-manager-webhook-cert # cert-manager will create this secret
21+
secretName: stackit-application-load-balancer-contoller-manager-webhook-cert # cert-manager will create this secret
22+

deploy/application-load-balancer-controller-manager/validating-webhook.yaml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,19 @@ webhooks:
1919
path: "/validate-ingress"
2020
admissionReviewVersions: ["v1"]
2121
sideEffects: None
22+
timeoutSeconds: 5
23+
- name: validate-ingressclass.stackit.cloud
24+
rules:
25+
- apiGroups: ["networking.k8s.io"]
26+
apiVersions: ["v1"]
27+
operations: ["CREATE", "UPDATE"]
28+
resources: ["ingressclasses"]
29+
scope: "Cluster"
30+
clientConfig:
31+
service:
32+
namespace: kube-system
33+
name: stackit-application-load-balancer-contoller-manager
34+
path: "/validate-ingressclass"
35+
admissionReviewVersions: ["v1"]
36+
sideEffects: None
2237
timeoutSeconds: 5
Lines changed: 45 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,13 @@ import (
55
"encoding/json"
66
"testing"
77

8+
admissionv1 "k8s.io/api/admission/v1"
89
networkingv1 "k8s.io/api/networking/v1"
910
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1011
"k8s.io/apimachinery/pkg/runtime"
1112
"k8s.io/client-go/kubernetes/scheme"
1213
"sigs.k8s.io/controller-runtime/pkg/client/fake"
1314
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
14-
admissionv1 "k8s.io/api/admission/v1"
1515
)
1616

1717
func TestIngressValidator_Handle(t *testing.T) {
@@ -38,89 +38,76 @@ func TestIngressValidator_Handle(t *testing.T) {
3838
decoder := admission.NewDecoder(s)
3939

4040
validator := &IngressValidator{
41-
Client: fakeClient,
41+
Client: fakeClient,
42+
Decoder: decoder,
4243
}
43-
_ = validator.InjectDecoder(decoder)
4444

4545
tests := []struct {
46-
name string
47-
className *string
48-
annotations map[string]string
49-
expectAllowed bool
46+
name string
47+
operation admissionv1.Operation
48+
className *string
49+
annotations map[string]string
50+
expectAllowed bool
5051
}{
5152
{
52-
name: "Valid Ingress",
53+
name: "Valid Ingress (Create)",
54+
operation: admissionv1.Create,
55+
className: &managedIngressClassName,
56+
annotations: map[string]string{
57+
AnnotationHTTPSOnly: "true",
58+
AnnotationPriority: "100",
59+
},
60+
expectAllowed: true,
61+
},
62+
{
63+
name: "Valid Ingress (Update)",
64+
operation: admissionv1.Update,
5365
className: &managedIngressClassName,
5466
annotations: map[string]string{
55-
AnnotationNetworkMode: "NodePort",
67+
AnnotationHTTPSOnly: "false",
68+
AnnotationCookiePersistenceTTLSeconds: "3600",
5669
},
5770
expectAllowed: true,
5871
},
5972
{
6073
name: "No IngressClass - Should Ignore and Allow",
74+
operation: admissionv1.Create,
6175
className: nil,
6276
annotations: map[string]string{},
6377
expectAllowed: true,
6478
},
6579
{
6680
name: "Unmanaged IngressClass - Should Ignore and Allow",
81+
operation: admissionv1.Create,
6782
className: &unmanagedIngressClassName,
6883
annotations: map[string]string{
69-
// These are completely invalid for STACKIT ALB,
70-
// but the webhook shouldn't check them because it's unmanaged.
71-
AnnotationNetworkMode: "LoadBalancer",
72-
AnnotationHTTPPort: "potato",
84+
AnnotationHTTPSOnly: "not-a-bool",
7385
},
7486
expectAllowed: true,
7587
},
7688
{
77-
name: "Missing Network Mode",
78-
className: &managedIngressClassName,
79-
annotations: map[string]string{
80-
AnnotationHTTPPort: "80",
81-
},
82-
expectAllowed: false,
83-
},
84-
{
85-
name: "Invalid Network Mode Value - Must be NodePort",
86-
className: &managedIngressClassName,
87-
annotations: map[string]string{
88-
AnnotationNetworkMode: "LoadBalancer",
89-
},
90-
expectAllowed: false,
91-
},
92-
{
93-
name: "Invalid Boolean",
89+
name: "Denied - Invalid Boolean",
90+
operation: admissionv1.Create,
9491
className: &managedIngressClassName,
9592
annotations: map[string]string{
96-
AnnotationNetworkMode: "NodePort",
97-
AnnotationInternal: "not-a-bool",
93+
AnnotationHTTPSOnly: "not-a-bool",
9894
},
9995
expectAllowed: false,
10096
},
10197
{
102-
name: "Invalid Port Number - Out of Range",
98+
name: "Denied - Invalid Integer",
99+
operation: admissionv1.Create,
103100
className: &managedIngressClassName,
104101
annotations: map[string]string{
105-
AnnotationNetworkMode: "NodePort",
106-
AnnotationHTTPPort: "99999",
102+
AnnotationPriority: "high",
107103
},
108104
expectAllowed: false,
109105
},
110106
{
111-
name: "Invalid IP Address",
107+
name: "Denied - Negative TTL",
108+
operation: admissionv1.Create,
112109
className: &managedIngressClassName,
113110
annotations: map[string]string{
114-
AnnotationNetworkMode: "NodePort",
115-
AnnotationExternalIP: "300.0.0.1",
116-
},
117-
expectAllowed: false,
118-
},
119-
{
120-
name: "Negative TTL",
121-
className: &managedIngressClassName,
122-
annotations: map[string]string{
123-
AnnotationNetworkMode: "NodePort",
124111
AnnotationCookiePersistenceTTLSeconds: "-50",
125112
},
126113
expectAllowed: false,
@@ -130,6 +117,10 @@ func TestIngressValidator_Handle(t *testing.T) {
130117
for _, tt := range tests {
131118
t.Run(tt.name, func(t *testing.T) {
132119
ingress := &networkingv1.Ingress{
120+
TypeMeta: metav1.TypeMeta{
121+
APIVersion: "networking.k8s.io/v1",
122+
Kind: "Ingress",
123+
},
133124
ObjectMeta: metav1.ObjectMeta{
134125
Name: "test-ingress",
135126
Namespace: "default",
@@ -139,24 +130,27 @@ func TestIngressValidator_Handle(t *testing.T) {
139130
IngressClassName: tt.className,
140131
},
141132
}
142-
143-
// Marshal it into JSON to simulate the API server payload
133+
144134
rawIngress, err := json.Marshal(ingress)
145135
if err != nil {
146136
t.Fatalf("Failed to marshal ingress: %v", err)
147137
}
148138

149139
req := admission.Request{
150140
AdmissionRequest: admissionv1.AdmissionRequest{
151-
Object: runtime.RawExtension{Raw: rawIngress},
141+
Operation: tt.operation,
142+
Object: runtime.RawExtension{Raw: rawIngress},
152143
},
153144
}
154145

155-
// Execute the webhook
146+
if tt.operation == admissionv1.Update {
147+
req.AdmissionRequest.OldObject = runtime.RawExtension{Raw: rawIngress}
148+
}
149+
156150
res := validator.Handle(context.TODO(), req)
157151

158152
if res.Allowed != tt.expectAllowed {
159-
t.Errorf("Expected Allowed=%v, got Allowed=%v. Result Message: %s",
153+
t.Errorf("Expected Allowed=%v, got Allowed=%v. Result Message: %s",
160154
tt.expectAllowed, res.Allowed, res.Result.Message)
161155
}
162156
})
Lines changed: 42 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -3,26 +3,33 @@ package ingress
33
import (
44
"fmt"
55
"context"
6-
"net"
76
"net/http"
87
"strconv"
98

109
networkingv1 "k8s.io/api/networking/v1"
1110
"sigs.k8s.io/controller-runtime/pkg/client"
1211
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
12+
admissionv1 "k8s.io/api/admission/v1"
1313
)
1414

1515
type IngressValidator struct {
1616
Client client.Client
1717
Decoder admission.Decoder
1818
}
1919

20-
func (v *IngressValidator) InjectDecoder(d admission.Decoder) error {
21-
v.Decoder = d
22-
return nil
20+
// Handle routes the request based on the operation type.
21+
func (v *IngressValidator) Handle(ctx context.Context, req admission.Request) admission.Response {
22+
switch req.Operation {
23+
case admissionv1.Create:
24+
return v.handleCreate(ctx, req)
25+
case admissionv1.Update:
26+
return v.handleUpdate(ctx, req)
27+
default:
28+
return admission.Allowed("Unhandled operation allowed.")
29+
}
2330
}
2431

25-
func (v *IngressValidator) Handle(ctx context.Context, req admission.Request) admission.Response {
32+
func (v *IngressValidator) handleCreate(ctx context.Context, req admission.Request) admission.Response {
2633
ingress := &networkingv1.Ingress{}
2734
if err := v.Decoder.Decode(req, ingress); err != nil {
2835
return admission.Errored(http.StatusBadRequest, err)
@@ -36,30 +43,45 @@ func (v *IngressValidator) Handle(ctx context.Context, req admission.Request) ad
3643
if err := v.Client.Get(ctx, client.ObjectKey{Name: *ingress.Spec.IngressClassName}, ingressClass); err != nil {
3744
return admission.Errored(http.StatusInternalServerError, err)
3845
}
39-
46+
4047
if ingressClass.Spec.Controller != controllerName {
4148
return admission.Allowed("Ingress managed by a different controller; allowing.")
4249
}
4350

44-
// 1. Network Mode Check.
45-
mode, exists := ingress.Annotations[AnnotationNetworkMode]
46-
if !exists {
47-
return admission.Denied("The annotation '" + AnnotationNetworkMode + "' is mandatory for STACKIT ALB Ingresses.")
51+
return v.validateBaseAnnotations(ctx, ingress)
52+
}
53+
54+
func (v *IngressValidator) handleUpdate(ctx context.Context, req admission.Request) admission.Response {
55+
newIngress := &networkingv1.Ingress{}
56+
if err := v.Decoder.Decode(req, newIngress); err != nil {
57+
return admission.Errored(http.StatusBadRequest, err)
4858
}
49-
if mode != "NodePort" {
50-
return admission.Denied(fmt.Sprintf("The annotation '%s' currently only supports the value 'NodePort'.", AnnotationNetworkMode))
59+
60+
oldIngress := &networkingv1.Ingress{}
61+
if err := v.Decoder.DecodeRaw(req.OldObject, oldIngress); err != nil {
62+
return admission.Errored(http.StatusBadRequest, err)
5163
}
5264

53-
// 2. Validate IP Addresses.
54-
if val, ok := ingress.Annotations[AnnotationExternalIP]; ok {
55-
if net.ParseIP(val) == nil {
56-
return admission.Denied(fmt.Sprintf("Annotation '%s' must be a valid IP address.", AnnotationExternalIP))
57-
}
65+
if newIngress.Spec.IngressClassName == nil {
66+
return admission.Allowed("No ingress class specified; ignoring.")
67+
}
68+
69+
ingressClass := &networkingv1.IngressClass{}
70+
if err := v.Client.Get(ctx, client.ObjectKey{Name: *newIngress.Spec.IngressClassName}, ingressClass); err != nil {
71+
return admission.Errored(http.StatusInternalServerError, err)
5872
}
5973

60-
// 3. Validate Booleans.
74+
if ingressClass.Spec.Controller != controllerName {
75+
return admission.Allowed("Ingress managed by a different controller; allowing.")
76+
}
77+
78+
return v.validateBaseAnnotations(ctx, newIngress)
79+
}
80+
81+
// validateBaseAnnotations checks simple formatting, allowed values, and basic constraints for all relevant annotations.
82+
func (v *IngressValidator) validateBaseAnnotations(ctx context.Context, ingress *networkingv1.Ingress) admission.Response {
83+
// Validate Booleans
6184
boolAnnotations := []string{
62-
AnnotationInternal,
6385
AnnotationTargetPoolTLSEnabled,
6486
AnnotationTargetPoolTLSSkipCertificateValidation,
6587
AnnotationHTTPSOnly,
@@ -73,26 +95,14 @@ func (v *IngressValidator) Handle(ctx context.Context, req admission.Request) ad
7395
}
7496
}
7597

76-
// 4. Validate Ports (Must be between 1 and 65535).
77-
portAnnotations := []string{AnnotationHTTPPort, AnnotationHTTPSPort}
78-
for _, ann := range portAnnotations {
79-
if val, ok := ingress.Annotations[ann]; ok {
80-
port, err := strconv.Atoi(val)
81-
if err != nil || port < 1 || port > 65535 {
82-
return admission.Denied(fmt.Sprintf("Annotation '%s' must be a valid port number between 1 and 65535.", ann))
83-
}
84-
}
85-
}
86-
87-
// 5. Validate TTL and Priority (Must be valid integers. TTL must be non-negative).
98+
// Validate Integers and TTL limits
8899
intAnnotations := []string{AnnotationCookiePersistenceTTLSeconds, AnnotationPriority}
89100
for _, ann := range intAnnotations {
90101
if val, ok := ingress.Annotations[ann]; ok {
91102
num, err := strconv.Atoi(val)
92103
if err != nil {
93104
return admission.Denied(fmt.Sprintf("Annotation '%s' must be a valid integer.", ann))
94105
}
95-
// Optional: Enforce TTL to be non-negative
96106
if ann == AnnotationCookiePersistenceTTLSeconds && num < 0 {
97107
return admission.Denied(fmt.Sprintf("Annotation '%s' must be greater than or equal to 0.", ann))
98108
}

0 commit comments

Comments
 (0)