Skip to content

Commit dad1fdb

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. Generated-by: Cursor/Claude
1 parent 79d044a commit dad1fdb

File tree

13 files changed

+1037
-0
lines changed

13 files changed

+1037
-0
lines changed

pkg/webhook/parser.go

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,46 @@ type Config struct {
159159
// The URL configuration should be between quotes.
160160
// `url` cannot be specified when `path` is specified.
161161
URL string `marker:"url,optional"`
162+
163+
// NamespaceSelector decides whether to run the webhook on a request based on the namespace labels.
164+
// An object is selected if the namespace matches the selector.
165+
//
166+
// Examples:
167+
// // Match namespaces with a specific label
168+
// namespaceSelector=matchLabels~environment=production
169+
//
170+
// // Exclude the control-plane namespace (solves webhook bootstrap problem)
171+
// namespaceSelector=matchExpressions~key=control-plane.operator=DoesNotExist
172+
//
173+
// // Match multiple environments
174+
// namespaceSelector=matchExpressions~key=environment.operator=In.values=dev|staging|prod
175+
//
176+
// // Combine label match with expression
177+
// namespaceSelector=matchLabels~team=platform&matchExpressions~key=tier.operator=NotIn.values=system
178+
//
179+
// Operators: In, NotIn, Exists, DoesNotExist
180+
// Syntax: ~ separates selector type, . separates fields, & combines selectors, | separates values
181+
NamespaceSelector string `marker:"namespaceSelector,optional"`
182+
183+
// ObjectSelector decides whether to run the webhook on a request based on the object's labels.
184+
// An object is selected if it matches the selector.
185+
//
186+
// Examples:
187+
// // Only process objects managed by this operator
188+
// objectSelector=matchLabels~managed-by=my-operator
189+
//
190+
// // Only process production workloads
191+
// objectSelector=matchLabels~environment=production.tier=critical
192+
//
193+
// // Exclude objects with a specific label
194+
// objectSelector=matchExpressions~key=skip-validation.operator=DoesNotExist
195+
//
196+
// // Target specific application types
197+
// objectSelector=matchExpressions~key=app-type.operator=In.values=web|api|worker
198+
//
199+
// Operators: In, NotIn, Exists, DoesNotExist
200+
// Syntax: ~ separates selector type, . separates fields, & combines selectors, | separates values
201+
ObjectSelector string `marker:"objectSelector,optional"`
162202
}
163203

164204
// verbToAPIVariant converts a marker's verb to the proper value for the API.
@@ -180,6 +220,100 @@ func verbToAPIVariant(verbRaw string) admissionregv1.OperationType {
180220
}
181221
}
182222

223+
// parseLabelSelector parses a label selector string into a metav1.LabelSelector.
224+
// Format examples:
225+
// - matchLabels: "matchLabels~key1=value1.key2=value2"
226+
// - matchExpressions: "matchExpressions~key=env.operator=In.values=dev|prod"
227+
// - combined: "matchLabels~key1=value1&matchExpressions~key=env.operator=In.values=dev|prod"
228+
func parseLabelSelector(selectorStr string) (*metav1.LabelSelector, error) {
229+
if selectorStr == "" {
230+
return nil, nil
231+
}
232+
233+
selector := &metav1.LabelSelector{}
234+
235+
// Split by ampersand to handle matchLabels and matchExpressions (if combined)
236+
for part := range strings.SplitSeq(selectorStr, "&") {
237+
part = strings.TrimSpace(part)
238+
if part == "" {
239+
continue
240+
}
241+
242+
// Check if this is a matchLabels part
243+
if labelsStr, ok := strings.CutPrefix(part, "matchLabels~"); ok {
244+
if selector.MatchLabels == nil {
245+
selector.MatchLabels = make(map[string]string)
246+
}
247+
248+
// Parse key=value pairs separated by dots
249+
for pair := range strings.SplitSeq(labelsStr, ".") {
250+
pair = strings.TrimSpace(pair)
251+
if pair == "" {
252+
continue
253+
}
254+
kv := strings.SplitN(pair, "=", 2)
255+
if len(kv) != 2 {
256+
return nil, fmt.Errorf("invalid matchLabels format: %q, expected key=value", pair)
257+
}
258+
selector.MatchLabels[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1])
259+
}
260+
continue
261+
}
262+
263+
// Check if this is a matchExpressions part
264+
if exprStr, ok := strings.CutPrefix(part, "matchExpressions~"); ok {
265+
// Parse the match expression fields separated by dots
266+
expr := &metav1.LabelSelectorRequirement{}
267+
268+
for field := range strings.SplitSeq(exprStr, ".") {
269+
field = strings.TrimSpace(field)
270+
if field == "" {
271+
continue
272+
}
273+
274+
kv := strings.SplitN(field, "=", 2)
275+
if len(kv) != 2 {
276+
return nil, fmt.Errorf("invalid matchExpression field format: %q, expected key=value", field)
277+
}
278+
279+
key := strings.TrimSpace(kv[0])
280+
value := strings.TrimSpace(kv[1])
281+
282+
switch key {
283+
case "key":
284+
expr.Key = value
285+
case "operator":
286+
expr.Operator = metav1.LabelSelectorOperator(value)
287+
case "values":
288+
expr.Values = strings.Split(value, "|")
289+
default:
290+
return nil, fmt.Errorf("unknown matchExpression field: %q", key)
291+
}
292+
}
293+
294+
// Validate required fields
295+
if expr.Key == "" {
296+
return nil, fmt.Errorf("matchExpression missing required 'key' field")
297+
}
298+
if expr.Operator == "" {
299+
return nil, fmt.Errorf("matchExpression missing required 'operator' field")
300+
}
301+
302+
selector.MatchExpressions = append(selector.MatchExpressions, *expr)
303+
continue
304+
}
305+
306+
return nil, fmt.Errorf("unexpected selector format: %q, expected matchLabels~ or matchExpressions~", part)
307+
}
308+
309+
// Validate that we have at least one selector
310+
if selector.MatchLabels == nil && len(selector.MatchExpressions) == 0 {
311+
return nil, fmt.Errorf("label selector must have at least matchLabels or matchExpressions")
312+
}
313+
314+
return selector, nil
315+
}
316+
183317
// ToMutatingWebhookConfiguration converts this WebhookConfig to its Kubernetes API form.
184318
func (c WebhookConfig) ToMutatingWebhookConfiguration() (admissionregv1.MutatingWebhookConfiguration, error) {
185319
if !c.Mutating {
@@ -222,6 +356,16 @@ func (c Config) ToMutatingWebhook() (admissionregv1.MutatingWebhook, error) {
222356
return admissionregv1.MutatingWebhook{}, err
223357
}
224358

359+
namespaceSelector, err := c.namespaceSelector()
360+
if err != nil {
361+
return admissionregv1.MutatingWebhook{}, fmt.Errorf("invalid namespaceSelector: %w", err)
362+
}
363+
364+
objectSelector, err := c.objectSelector()
365+
if err != nil {
366+
return admissionregv1.MutatingWebhook{}, fmt.Errorf("invalid objectSelector: %w", err)
367+
}
368+
225369
return admissionregv1.MutatingWebhook{
226370
Name: c.Name,
227371
Rules: c.rules(),
@@ -232,6 +376,8 @@ func (c Config) ToMutatingWebhook() (admissionregv1.MutatingWebhook, error) {
232376
TimeoutSeconds: c.timeoutSeconds(),
233377
AdmissionReviewVersions: c.AdmissionReviewVersions,
234378
ReinvocationPolicy: c.reinvocationPolicy(),
379+
NamespaceSelector: namespaceSelector,
380+
ObjectSelector: objectSelector,
235381
}, nil
236382
}
237383

@@ -251,6 +397,16 @@ func (c Config) ToValidatingWebhook() (admissionregv1.ValidatingWebhook, error)
251397
return admissionregv1.ValidatingWebhook{}, err
252398
}
253399

400+
namespaceSelector, err := c.namespaceSelector()
401+
if err != nil {
402+
return admissionregv1.ValidatingWebhook{}, fmt.Errorf("invalid namespaceSelector: %w", err)
403+
}
404+
405+
objectSelector, err := c.objectSelector()
406+
if err != nil {
407+
return admissionregv1.ValidatingWebhook{}, fmt.Errorf("invalid objectSelector: %w", err)
408+
}
409+
254410
return admissionregv1.ValidatingWebhook{
255411
Name: c.Name,
256412
Rules: c.rules(),
@@ -260,6 +416,8 @@ func (c Config) ToValidatingWebhook() (admissionregv1.ValidatingWebhook, error)
260416
SideEffects: c.sideEffects(),
261417
TimeoutSeconds: c.timeoutSeconds(),
262418
AdmissionReviewVersions: c.AdmissionReviewVersions,
419+
NamespaceSelector: namespaceSelector,
420+
ObjectSelector: objectSelector,
263421
}, nil
264422
}
265423

@@ -402,6 +560,16 @@ func (c Config) reinvocationPolicy() *admissionregv1.ReinvocationPolicyType {
402560
return &reinvocationPolicy
403561
}
404562

563+
// namespaceSelector returns the parsed namespaceSelector config for a webhook.
564+
func (c Config) namespaceSelector() (*metav1.LabelSelector, error) {
565+
return parseLabelSelector(c.NamespaceSelector)
566+
}
567+
568+
// objectSelector returns the parsed objectSelector config for a webhook.
569+
func (c Config) objectSelector() (*metav1.LabelSelector, error) {
570+
return parseLabelSelector(c.ObjectSelector)
571+
}
572+
405573
// webhookVersions returns the target API versions of the {Mutating,Validating}WebhookConfiguration objects for a webhook.
406574
func (c Config) webhookVersions() ([]string, error) {
407575
// 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)