Skip to content

Commit 482f624

Browse files
Add namespaceSelector and objectSelector to webhook markers
Enables filtering webhooks by namespace and object labels to solve the webhook bootstrap problem and support namespace-scoped operators. Assisted-by: Cursor/Claude
1 parent fbbff17 commit 482f624

10 files changed

Lines changed: 539 additions & 0 deletions

File tree

pkg/webhook/parser.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ limitations under the License.
2323
package webhook
2424

2525
import (
26+
"encoding/json"
2627
"fmt"
2728
"slices"
2829
"strings"
@@ -200,6 +201,26 @@ type Config struct {
200201
// The URL configuration should be between quotes.
201202
// `url` cannot be specified when `path` is specified.
202203
URL string `marker:"url,optional"`
204+
205+
// NamespaceSelector limits which namespaces trigger this webhook. The webhook runs only for
206+
// requests in namespaces that match the selector. Value is a JSON object with the same shape
207+
// as the Kubernetes LabelSelector (matchLabels and/or matchExpressions).
208+
//
209+
// Example:
210+
//
211+
// // +kubebuilder:webhook:...,namespaceSelector=`{"matchLabels":{"webhook-enabled":"true"}}`
212+
// // +kubebuilder:webhook:...,namespaceSelector=`{"matchExpressions":[{"key":"environment","operator":"In","values":["dev","staging","prod"]}]}`
213+
NamespaceSelector string `marker:"namespaceSelector,optional"`
214+
215+
// ObjectSelector limits which objects trigger this webhook. The webhook runs only for requests
216+
// whose object matches the selector. Value is a JSON object with the same shape as the
217+
// Kubernetes LabelSelector (matchLabels and/or matchExpressions).
218+
//
219+
// Example:
220+
//
221+
// // +kubebuilder:webhook:...,objectSelector=`{"matchLabels":{"managed-by":"my-operator"}}`
222+
// // +kubebuilder:webhook:...,objectSelector=`{"matchExpressions":[{"key":"app-type","operator":"In","values":["web","api","worker"]}]}`
223+
ObjectSelector string `marker:"objectSelector,optional"`
203224
}
204225

205226
// verbToAPIVariant converts a marker's verb to the proper value for the API.
@@ -221,6 +242,26 @@ func verbToAPIVariant(verbRaw string) admissionregv1.OperationType {
221242
}
222243
}
223244

245+
// parseLabelSelector parses a JSON string into a LabelSelector. The JSON must match the
246+
// Kubernetes LabelSelector type (matchLabels and/or matchExpressions). It returns nil for empty input.
247+
func parseLabelSelector(selectorStr string) (*metav1.LabelSelector, error) {
248+
selectorStr = strings.TrimSpace(selectorStr)
249+
if selectorStr == "" {
250+
return nil, nil
251+
}
252+
253+
var selector metav1.LabelSelector
254+
if err := json.Unmarshal([]byte(selectorStr), &selector); err != nil {
255+
return nil, fmt.Errorf("label selector must be valid JSON (e.g. {\"matchLabels\":{\"key\":\"value\"}}): %w", err)
256+
}
257+
258+
if selector.MatchLabels == nil && len(selector.MatchExpressions) == 0 {
259+
return nil, fmt.Errorf("label selector must specify at least one of matchLabels or matchExpressions")
260+
}
261+
262+
return &selector, nil
263+
}
264+
224265
// ToMutatingWebhookConfiguration converts this WebhookConfig to its Kubernetes API form.
225266
func (c WebhookConfig) ToMutatingWebhookConfiguration() (admissionregv1.MutatingWebhookConfiguration, error) {
226267
if !c.Mutating {
@@ -263,6 +304,16 @@ func (c Config) ToMutatingWebhook() (admissionregv1.MutatingWebhook, error) {
263304
return admissionregv1.MutatingWebhook{}, err
264305
}
265306

307+
namespaceSelector, err := c.namespaceSelector()
308+
if err != nil {
309+
return admissionregv1.MutatingWebhook{}, fmt.Errorf("invalid namespaceSelector: %w", err)
310+
}
311+
312+
objectSelector, err := c.objectSelector()
313+
if err != nil {
314+
return admissionregv1.MutatingWebhook{}, fmt.Errorf("invalid objectSelector: %w", err)
315+
}
316+
266317
return admissionregv1.MutatingWebhook{
267318
Name: c.Name,
268319
Rules: c.rules(),
@@ -273,6 +324,8 @@ func (c Config) ToMutatingWebhook() (admissionregv1.MutatingWebhook, error) {
273324
TimeoutSeconds: c.timeoutSeconds(),
274325
AdmissionReviewVersions: c.AdmissionReviewVersions,
275326
ReinvocationPolicy: c.reinvocationPolicy(),
327+
NamespaceSelector: namespaceSelector,
328+
ObjectSelector: objectSelector,
276329
}, nil
277330
}
278331

@@ -292,6 +345,16 @@ func (c Config) ToValidatingWebhook() (admissionregv1.ValidatingWebhook, error)
292345
return admissionregv1.ValidatingWebhook{}, err
293346
}
294347

348+
namespaceSelector, err := c.namespaceSelector()
349+
if err != nil {
350+
return admissionregv1.ValidatingWebhook{}, fmt.Errorf("invalid namespaceSelector: %w", err)
351+
}
352+
353+
objectSelector, err := c.objectSelector()
354+
if err != nil {
355+
return admissionregv1.ValidatingWebhook{}, fmt.Errorf("invalid objectSelector: %w", err)
356+
}
357+
295358
return admissionregv1.ValidatingWebhook{
296359
Name: c.Name,
297360
Rules: c.rules(),
@@ -301,6 +364,8 @@ func (c Config) ToValidatingWebhook() (admissionregv1.ValidatingWebhook, error)
301364
SideEffects: c.sideEffects(),
302365
TimeoutSeconds: c.timeoutSeconds(),
303366
AdmissionReviewVersions: c.AdmissionReviewVersions,
367+
NamespaceSelector: namespaceSelector,
368+
ObjectSelector: objectSelector,
304369
}, nil
305370
}
306371

@@ -443,6 +508,16 @@ func (c Config) reinvocationPolicy() *admissionregv1.ReinvocationPolicyType {
443508
return &reinvocationPolicy
444509
}
445510

511+
// namespaceSelector returns the LabelSelector for the webhook's namespace filter, or nil if unset.
512+
func (c Config) namespaceSelector() (*metav1.LabelSelector, error) {
513+
return parseLabelSelector(c.NamespaceSelector)
514+
}
515+
516+
// objectSelector returns the LabelSelector for the webhook's object filter, or nil if unset.
517+
func (c Config) objectSelector() (*metav1.LabelSelector, error) {
518+
return parseLabelSelector(c.ObjectSelector)
519+
}
520+
446521
// webhookVersions returns the target API versions of the {Mutating,Validating}WebhookConfiguration objects for a webhook.
447522
func (c Config) webhookVersions() ([]string, error) {
448523
// If WebhookVersions is not specified, we default it to `v1`.

pkg/webhook/parser_integration_test.go

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -526,6 +526,146 @@ var _ = Describe("Webhook Generation From Parsing to CustomResourceDefinition",
526526
Expect(err).To(HaveOccurred())
527527
})
528528

529+
It("should properly generate webhook definition with namespaceSelector", func() {
530+
By("switching into testdata to appease go modules")
531+
cwd, err := os.Getwd()
532+
Expect(err).NotTo(HaveOccurred())
533+
Expect(os.Chdir("./testdata/valid-namespaceselector")).To(Succeed())
534+
defer func() { Expect(os.Chdir(cwd)).To(Succeed()) }()
535+
536+
By("loading the roots")
537+
pkgs, err := loader.LoadRoots(".")
538+
Expect(err).NotTo(HaveOccurred())
539+
Expect(pkgs).To(HaveLen(1))
540+
541+
By("setting up the parser")
542+
reg := &markers.Registry{}
543+
Expect(reg.Register(webhook.ConfigDefinition)).To(Succeed())
544+
Expect(reg.Register(webhook.WebhookConfigDefinition)).To(Succeed())
545+
546+
By("requesting that the manifest be generated")
547+
outputDir, err := os.MkdirTemp("", "webhook-integration-test")
548+
Expect(err).NotTo(HaveOccurred())
549+
defer os.RemoveAll(outputDir)
550+
genCtx := &genall.GenerationContext{
551+
Collector: &markers.Collector{Registry: reg},
552+
Roots: pkgs,
553+
OutputRule: genall.OutputToDirectory(outputDir),
554+
}
555+
Expect(webhook.Generator{}.Generate(genCtx)).To(Succeed())
556+
for _, r := range genCtx.Roots {
557+
Expect(r.Errors).To(HaveLen(0))
558+
}
559+
560+
By("loading the generated v1 YAML")
561+
actualFile, err := os.ReadFile(path.Join(outputDir, "manifests.yaml"))
562+
Expect(err).NotTo(HaveOccurred())
563+
actualManifest := &admissionregv1.ValidatingWebhookConfiguration{}
564+
Expect(yaml.UnmarshalStrict(actualFile, actualManifest)).To(Succeed())
565+
566+
By("loading the desired v1 YAML")
567+
expectedFile, err := os.ReadFile("manifests.yaml")
568+
Expect(err).NotTo(HaveOccurred())
569+
expectedManifest := &admissionregv1.ValidatingWebhookConfiguration{}
570+
Expect(yaml.UnmarshalStrict(expectedFile, expectedManifest)).To(Succeed())
571+
572+
By("comparing the two")
573+
assertSame(actualManifest, expectedManifest)
574+
})
575+
576+
It("should properly generate webhook definition with objectSelector", func() {
577+
By("switching into testdata to appease go modules")
578+
cwd, err := os.Getwd()
579+
Expect(err).NotTo(HaveOccurred())
580+
Expect(os.Chdir("./testdata/valid-objectselector")).To(Succeed())
581+
defer func() { Expect(os.Chdir(cwd)).To(Succeed()) }()
582+
583+
By("loading the roots")
584+
pkgs, err := loader.LoadRoots(".")
585+
Expect(err).NotTo(HaveOccurred())
586+
Expect(pkgs).To(HaveLen(1))
587+
588+
By("setting up the parser")
589+
reg := &markers.Registry{}
590+
Expect(reg.Register(webhook.ConfigDefinition)).To(Succeed())
591+
Expect(reg.Register(webhook.WebhookConfigDefinition)).To(Succeed())
592+
593+
By("requesting that the manifest be generated")
594+
outputDir, err := os.MkdirTemp("", "webhook-integration-test")
595+
Expect(err).NotTo(HaveOccurred())
596+
defer os.RemoveAll(outputDir)
597+
genCtx := &genall.GenerationContext{
598+
Collector: &markers.Collector{Registry: reg},
599+
Roots: pkgs,
600+
OutputRule: genall.OutputToDirectory(outputDir),
601+
}
602+
Expect(webhook.Generator{}.Generate(genCtx)).To(Succeed())
603+
for _, r := range genCtx.Roots {
604+
Expect(r.Errors).To(HaveLen(0))
605+
}
606+
607+
By("loading the generated v1 YAML")
608+
actualFile, err := os.ReadFile(path.Join(outputDir, "manifests.yaml"))
609+
Expect(err).NotTo(HaveOccurred())
610+
actualManifest := &admissionregv1.MutatingWebhookConfiguration{}
611+
Expect(yaml.UnmarshalStrict(actualFile, actualManifest)).To(Succeed())
612+
613+
By("loading the desired v1 YAML")
614+
expectedFile, err := os.ReadFile("manifests.yaml")
615+
Expect(err).NotTo(HaveOccurred())
616+
expectedManifest := &admissionregv1.MutatingWebhookConfiguration{}
617+
Expect(yaml.UnmarshalStrict(expectedFile, expectedManifest)).To(Succeed())
618+
619+
By("comparing the two")
620+
assertSame(actualManifest, expectedManifest)
621+
})
622+
623+
It("should properly generate webhook definition with matchExpressions in selectors", func() {
624+
By("switching into testdata to appease go modules")
625+
cwd, err := os.Getwd()
626+
Expect(err).NotTo(HaveOccurred())
627+
Expect(os.Chdir("./testdata/valid-selectors-matchexpressions")).To(Succeed())
628+
defer func() { Expect(os.Chdir(cwd)).To(Succeed()) }()
629+
630+
By("loading the roots")
631+
pkgs, err := loader.LoadRoots(".")
632+
Expect(err).NotTo(HaveOccurred())
633+
Expect(pkgs).To(HaveLen(1))
634+
635+
By("setting up the parser")
636+
reg := &markers.Registry{}
637+
Expect(reg.Register(webhook.ConfigDefinition)).To(Succeed())
638+
Expect(reg.Register(webhook.WebhookConfigDefinition)).To(Succeed())
639+
640+
By("requesting that the manifest be generated")
641+
outputDir, err := os.MkdirTemp("", "webhook-integration-test")
642+
Expect(err).NotTo(HaveOccurred())
643+
defer os.RemoveAll(outputDir)
644+
genCtx := &genall.GenerationContext{
645+
Collector: &markers.Collector{Registry: reg},
646+
Roots: pkgs,
647+
OutputRule: genall.OutputToDirectory(outputDir),
648+
}
649+
Expect(webhook.Generator{}.Generate(genCtx)).To(Succeed())
650+
for _, r := range genCtx.Roots {
651+
Expect(r.Errors).To(HaveLen(0))
652+
}
653+
654+
By("loading the generated v1 YAML")
655+
actualFile, err := os.ReadFile(path.Join(outputDir, "manifests.yaml"))
656+
Expect(err).NotTo(HaveOccurred())
657+
actualMutating, actualValidating := unmarshalBothV1(actualFile)
658+
659+
By("loading the desired v1 YAML")
660+
expectedFile, err := os.ReadFile("manifests.yaml")
661+
Expect(err).NotTo(HaveOccurred())
662+
expectedMutating, expectedValidating := unmarshalBothV1(expectedFile)
663+
664+
By("comparing the two")
665+
assertSame(actualMutating, expectedMutating)
666+
assertSame(actualValidating, expectedValidating)
667+
})
668+
529669
})
530670

531671
func unmarshalBothV1(in []byte) (mutating admissionregv1.MutatingWebhookConfiguration, validating admissionregv1.ValidatingWebhookConfiguration) {

0 commit comments

Comments
 (0)