From bfe661070f25387e980be6b0bd1b6bd7c1c69dd3 Mon Sep 17 00:00:00 2001 From: Damien Dassieu Date: Mon, 28 Oct 2024 13:39:24 +0100 Subject: [PATCH] Add custom path option for webhooks Review correction --- pkg/builder/webhook.go | 52 +++++++++++- pkg/builder/webhook_test.go | 159 ++++++++++++++++++++++++++++++++++++ 2 files changed, 207 insertions(+), 4 deletions(-) diff --git a/pkg/builder/webhook.go b/pkg/builder/webhook.go index cfb9f1a69d..543477da2e 100644 --- a/pkg/builder/webhook.go +++ b/pkg/builder/webhook.go @@ -20,6 +20,7 @@ import ( "errors" "net/http" "net/url" + "regexp" "strings" "github.com/go-logr/logr" @@ -39,6 +40,7 @@ type WebhookBuilder struct { apiType runtime.Object customDefaulter admission.CustomDefaulter customValidator admission.CustomValidator + customPath string gvk schema.GroupVersionKind mgr manager.Manager config *rest.Config @@ -90,6 +92,12 @@ func (blder *WebhookBuilder) RecoverPanic(recoverPanic bool) *WebhookBuilder { return blder } +// WithCustomPath overrides the webhook's default path by the customPath +func (blder *WebhookBuilder) WithCustomPath(customPath string) *WebhookBuilder { + blder.customPath = customPath + return blder +} + // Complete builds the webhook. func (blder *WebhookBuilder) Complete() error { // Set the Config @@ -140,8 +148,15 @@ func (blder *WebhookBuilder) registerWebhooks() error { } // Register webhook(s) for type - blder.registerDefaultingWebhook() - blder.registerValidatingWebhook() + err = blder.registerDefaultingWebhook() + if err != nil { + return err + } + + err = blder.registerValidatingWebhook() + if err != nil { + return err + } err = blder.registerConversionWebhook() if err != nil { @@ -151,11 +166,18 @@ func (blder *WebhookBuilder) registerWebhooks() error { } // registerDefaultingWebhook registers a defaulting webhook if necessary. -func (blder *WebhookBuilder) registerDefaultingWebhook() { +func (blder *WebhookBuilder) registerDefaultingWebhook() error { mwh := blder.getDefaultingWebhook() if mwh != nil { mwh.LogConstructor = blder.logConstructor path := generateMutatePath(blder.gvk) + if blder.customPath != "" { + generatedCustomPath, err := generateCustomPath(blder.customPath) + if err != nil { + return err + } + path = generatedCustomPath + } // Checking if the path is already registered. // If so, just skip it. @@ -166,6 +188,8 @@ func (blder *WebhookBuilder) registerDefaultingWebhook() { blder.mgr.GetWebhookServer().Register(path, mwh) } } + + return nil } func (blder *WebhookBuilder) getDefaultingWebhook() *admission.Webhook { @@ -180,11 +204,18 @@ func (blder *WebhookBuilder) getDefaultingWebhook() *admission.Webhook { } // registerValidatingWebhook registers a validating webhook if necessary. -func (blder *WebhookBuilder) registerValidatingWebhook() { +func (blder *WebhookBuilder) registerValidatingWebhook() error { vwh := blder.getValidatingWebhook() if vwh != nil { vwh.LogConstructor = blder.logConstructor path := generateValidatePath(blder.gvk) + if blder.customPath != "" { + generatedCustomPath, err := generateCustomPath(blder.customPath) + if err != nil { + return err + } + path = generatedCustomPath + } // Checking if the path is already registered. // If so, just skip it. @@ -195,6 +226,8 @@ func (blder *WebhookBuilder) registerValidatingWebhook() { blder.mgr.GetWebhookServer().Register(path, vwh) } } + + return nil } func (blder *WebhookBuilder) getValidatingWebhook() *admission.Webhook { @@ -251,3 +284,14 @@ func generateValidatePath(gvk schema.GroupVersionKind) string { return "/validate-" + strings.ReplaceAll(gvk.Group, ".", "-") + "-" + gvk.Version + "-" + strings.ToLower(gvk.Kind) } + +const webhookPathStringValidation = `^((/[a-zA-Z0-9-_]+)+|/)$` + +var validWebhookPathRegex = regexp.MustCompile(webhookPathStringValidation) + +func generateCustomPath(customPath string) (string, error) { + if !validWebhookPathRegex.MatchString(customPath) { + return "", errors.New("customPath \"" + customPath + "\" does not match this regex: " + webhookPathStringValidation) + } + return customPath, nil +} diff --git a/pkg/builder/webhook_test.go b/pkg/builder/webhook_test.go index 3ed422d3e9..85b97bf5d8 100644 --- a/pkg/builder/webhook_test.go +++ b/pkg/builder/webhook_test.go @@ -153,6 +153,85 @@ func runTests(admissionReviewVersion string) { ExpectWithOffset(1, w.Code).To(Equal(http.StatusNotFound)) }) + It("should scaffold a custom defaulting webhook with a custom path", func() { + By("creating a controller manager") + m, err := manager.New(cfg, manager.Options{}) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + + By("registering the type in the Scheme") + builder := scheme.Builder{GroupVersion: testDefaulterGVK.GroupVersion()} + builder.Register(&TestDefaulter{}, &TestDefaulterList{}) + err = builder.AddToScheme(m.GetScheme()) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + + customPath := "/custom-defaulting-path" + err = WebhookManagedBy(m). + For(&TestDefaulter{}). + WithDefaulter(&TestCustomDefaulter{}). + WithLogConstructor(func(base logr.Logger, req *admission.Request) logr.Logger { + return admission.DefaultLogConstructor(testingLogger, req) + }). + WithCustomPath(customPath). + Complete() + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + svr := m.GetWebhookServer() + ExpectWithOffset(1, svr).NotTo(BeNil()) + + reader := strings.NewReader(admissionReviewGV + admissionReviewVersion + `", + "request":{ + "uid":"07e52e8d-4513-11e9-a716-42010a800270", + "kind":{ + "group":"foo.test.org", + "version":"v1", + "kind":"TestDefaulter" + }, + "resource":{ + "group":"foo.test.org", + "version":"v1", + "resource":"testdefaulter" + }, + "namespace":"default", + "name":"foo", + "operation":"CREATE", + "object":{ + "replica":1 + }, + "oldObject":null + } +}`) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + err = svr.Start(ctx) + if err != nil && !os.IsNotExist(err) { + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + } + + By("sending a request to a mutating webhook path") + path, err := generateCustomPath(customPath) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + req := httptest.NewRequest("POST", svcBaseAddr+path, reader) + req.Header.Add("Content-Type", "application/json") + w := httptest.NewRecorder() + svr.WebhookMux().ServeHTTP(w, req) + ExpectWithOffset(1, w.Code).To(Equal(http.StatusOK)) + By("sanity checking the response contains reasonable fields") + ExpectWithOffset(1, w.Body).To(ContainSubstring(`"allowed":true`)) + ExpectWithOffset(1, w.Body).To(ContainSubstring(`"patch":`)) + ExpectWithOffset(1, w.Body).To(ContainSubstring(`"code":200`)) + EventuallyWithOffset(1, logBuffer).Should(gbytes.Say(`"msg":"Defaulting object","object":{"name":"foo","namespace":"default"},"namespace":"default","name":"foo","resource":{"group":"foo.test.org","version":"v1","resource":"testdefaulter"},"user":"","requestID":"07e52e8d-4513-11e9-a716-42010a800270"`)) + + By("sending a request to a mutating webhook path that have been overrided by the custom path") + path = generateMutatePath(testDefaulterGVK) + _, err = reader.Seek(0, 0) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + req = httptest.NewRequest("POST", svcBaseAddr+path, reader) + req.Header.Add("Content-Type", "application/json") + w = httptest.NewRecorder() + svr.WebhookMux().ServeHTTP(w, req) + ExpectWithOffset(1, w.Code).To(Equal(http.StatusNotFound)) + }) + It("should scaffold a custom defaulting webhook which recovers from panics", func() { By("creating a controller manager") m, err := manager.New(cfg, manager.Options{}) @@ -294,6 +373,86 @@ func runTests(admissionReviewVersion string) { EventuallyWithOffset(1, logBuffer).Should(gbytes.Say(`"msg":"Validating object","object":{"name":"foo","namespace":"default"},"namespace":"default","name":"foo","resource":{"group":"foo.test.org","version":"v1","resource":"testvalidator"},"user":"","requestID":"07e52e8d-4513-11e9-a716-42010a800270"`)) }) + It("should scaffold a custom validating webhook with a custom path", func() { + By("creating a controller manager") + m, err := manager.New(cfg, manager.Options{}) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + + By("registering the type in the Scheme") + builder := scheme.Builder{GroupVersion: testValidatorGVK.GroupVersion()} + builder.Register(&TestValidator{}, &TestValidatorList{}) + err = builder.AddToScheme(m.GetScheme()) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + + customPath := "/custom-validating-path" + err = WebhookManagedBy(m). + For(&TestValidator{}). + WithValidator(&TestCustomValidator{}). + WithLogConstructor(func(base logr.Logger, req *admission.Request) logr.Logger { + return admission.DefaultLogConstructor(testingLogger, req) + }). + WithCustomPath(customPath). + Complete() + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + svr := m.GetWebhookServer() + ExpectWithOffset(1, svr).NotTo(BeNil()) + + reader := strings.NewReader(admissionReviewGV + admissionReviewVersion + `", + "request":{ + "uid":"07e52e8d-4513-11e9-a716-42010a800270", + "kind":{ + "group":"foo.test.org", + "version":"v1", + "kind":"TestValidator" + }, + "resource":{ + "group":"foo.test.org", + "version":"v1", + "resource":"testvalidator" + }, + "namespace":"default", + "name":"foo", + "operation":"UPDATE", + "object":{ + "replica":1 + }, + "oldObject":{ + "replica":2 + } + } +}`) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + err = svr.Start(ctx) + if err != nil && !os.IsNotExist(err) { + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + } + + By("sending a request to a mutating webhook path that have been overrided by a custom path") + path := generateValidatePath(testValidatorGVK) + req := httptest.NewRequest("POST", svcBaseAddr+path, reader) + req.Header.Add("Content-Type", "application/json") + w := httptest.NewRecorder() + svr.WebhookMux().ServeHTTP(w, req) + ExpectWithOffset(1, w.Code).To(Equal(http.StatusNotFound)) + + By("sending a request to a validating webhook path") + path, err = generateCustomPath(customPath) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + _, err = reader.Seek(0, 0) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + req = httptest.NewRequest("POST", svcBaseAddr+path, reader) + req.Header.Add("Content-Type", "application/json") + w = httptest.NewRecorder() + svr.WebhookMux().ServeHTTP(w, req) + ExpectWithOffset(1, w.Code).To(Equal(http.StatusOK)) + By("sanity checking the response contains reasonable field") + ExpectWithOffset(1, w.Body).To(ContainSubstring(`"allowed":false`)) + ExpectWithOffset(1, w.Body).To(ContainSubstring(`"code":403`)) + EventuallyWithOffset(1, logBuffer).Should(gbytes.Say(`"msg":"Validating object","object":{"name":"foo","namespace":"default"},"namespace":"default","name":"foo","resource":{"group":"foo.test.org","version":"v1","resource":"testvalidator"},"user":"","requestID":"07e52e8d-4513-11e9-a716-42010a800270"`)) + }) + It("should scaffold a custom validating webhook which recovers from panics", func() { By("creating a controller manager") m, err := manager.New(cfg, manager.Options{})