Skip to content

Commit 3abcd99

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 89265b3 commit 3abcd99

File tree

16 files changed

+1178
-1
lines changed

16 files changed

+1178
-1
lines changed

Makefile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,10 +97,11 @@ test-all:
9797
modules: ## Runs go mod to ensure modules are up to date.
9898
go mod tidy
9999
cd pkg/applyconfiguration/testdata/cronjob; go mod tidy
100+
cd pkg/webhook/testdata; go mod tidy
100101

101102
.PHONY: verify-modules
102103
verify-modules: modules ## Verify go modules are up to date
103-
@if !(git diff --quiet HEAD -- go.sum go.mod); then \
104+
@if !(git diff --quiet HEAD -- go.sum go.mod pkg/applyconfiguration/testdata/cronjob/go.sum pkg/applyconfiguration/testdata/cronjob/go.mod pkg/webhook/testdata/go.sum pkg/webhook/testdata/go.mod); then \
104105
git diff; \
105106
echo "go module files are out of date, please run 'make modules'"; exit 1; \
106107
fi

pkg/webhook/parser.go

Lines changed: 73 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"
@@ -159,6 +160,24 @@ type Config struct {
159160
// The URL configuration should be between quotes.
160161
// `url` cannot be specified when `path` is specified.
161162
URL string `marker:"url,optional"`
163+
164+
// NamespaceSelector limits which namespaces trigger this webhook. The webhook runs only for
165+
// requests in namespaces that match the selector. The value is a JSON object with the same
166+
// shape as the Kubernetes LabelSelector (matchLabels and/or matchExpressions). Use backticks
167+
// around the JSON so you do not need to escape quotes (e.g. namespaceSelector=`{"matchLabels":{"key":"value"}}`).
168+
//
169+
// Example: namespaceSelector=`{"matchLabels":{"webhook-enabled":"true"}}`
170+
// Example: namespaceSelector=`{"matchExpressions":[{"key":"environment","operator":"In","values":["dev","staging","prod"]}]}`
171+
NamespaceSelector string `marker:"namespaceSelector,optional"`
172+
173+
// ObjectSelector limits which objects trigger this webhook. The webhook runs only for requests
174+
// whose object matches the selector. The value is a JSON object with the same shape as the
175+
// Kubernetes LabelSelector (matchLabels and/or matchExpressions). Use backticks around the JSON
176+
// so you do not need to escape quotes (e.g. objectSelector=`{"matchLabels":{"key":"value"}}`).
177+
//
178+
// Example: objectSelector=`{"matchLabels":{"managed-by":"my-operator"}}`
179+
// Example: objectSelector=`{"matchExpressions":[{"key":"app-type","operator":"In","values":["web","api","worker"]}]}`
180+
ObjectSelector string `marker:"objectSelector,optional"`
162181
}
163182

164183
// verbToAPIVariant converts a marker's verb to the proper value for the API.
@@ -180,6 +199,26 @@ func verbToAPIVariant(verbRaw string) admissionregv1.OperationType {
180199
}
181200
}
182201

202+
// parseLabelSelector parses a JSON string into a LabelSelector. The JSON must match the
203+
// Kubernetes LabelSelector type (matchLabels and/or matchExpressions). Returns nil for empty input.
204+
func parseLabelSelector(selectorStr string) (*metav1.LabelSelector, error) {
205+
selectorStr = strings.TrimSpace(selectorStr)
206+
if selectorStr == "" {
207+
return nil, nil
208+
}
209+
210+
var selector metav1.LabelSelector
211+
if err := json.Unmarshal([]byte(selectorStr), &selector); err != nil {
212+
return nil, fmt.Errorf("label selector must be valid JSON (e.g. {\"matchLabels\":{\"key\":\"value\"}}): %w", err)
213+
}
214+
215+
if selector.MatchLabels == nil && len(selector.MatchExpressions) == 0 {
216+
return nil, fmt.Errorf("label selector must specify at least one of matchLabels or matchExpressions")
217+
}
218+
219+
return &selector, nil
220+
}
221+
183222
// ToMutatingWebhookConfiguration converts this WebhookConfig to its Kubernetes API form.
184223
func (c WebhookConfig) ToMutatingWebhookConfiguration() (admissionregv1.MutatingWebhookConfiguration, error) {
185224
if !c.Mutating {
@@ -222,6 +261,16 @@ func (c Config) ToMutatingWebhook() (admissionregv1.MutatingWebhook, error) {
222261
return admissionregv1.MutatingWebhook{}, err
223262
}
224263

264+
namespaceSelector, err := c.namespaceSelector()
265+
if err != nil {
266+
return admissionregv1.MutatingWebhook{}, fmt.Errorf("invalid namespaceSelector: %w", err)
267+
}
268+
269+
objectSelector, err := c.objectSelector()
270+
if err != nil {
271+
return admissionregv1.MutatingWebhook{}, fmt.Errorf("invalid objectSelector: %w", err)
272+
}
273+
225274
return admissionregv1.MutatingWebhook{
226275
Name: c.Name,
227276
Rules: c.rules(),
@@ -232,6 +281,8 @@ func (c Config) ToMutatingWebhook() (admissionregv1.MutatingWebhook, error) {
232281
TimeoutSeconds: c.timeoutSeconds(),
233282
AdmissionReviewVersions: c.AdmissionReviewVersions,
234283
ReinvocationPolicy: c.reinvocationPolicy(),
284+
NamespaceSelector: namespaceSelector,
285+
ObjectSelector: objectSelector,
235286
}, nil
236287
}
237288

@@ -251,6 +302,16 @@ func (c Config) ToValidatingWebhook() (admissionregv1.ValidatingWebhook, error)
251302
return admissionregv1.ValidatingWebhook{}, err
252303
}
253304

305+
namespaceSelector, err := c.namespaceSelector()
306+
if err != nil {
307+
return admissionregv1.ValidatingWebhook{}, fmt.Errorf("invalid namespaceSelector: %w", err)
308+
}
309+
310+
objectSelector, err := c.objectSelector()
311+
if err != nil {
312+
return admissionregv1.ValidatingWebhook{}, fmt.Errorf("invalid objectSelector: %w", err)
313+
}
314+
254315
return admissionregv1.ValidatingWebhook{
255316
Name: c.Name,
256317
Rules: c.rules(),
@@ -260,6 +321,8 @@ func (c Config) ToValidatingWebhook() (admissionregv1.ValidatingWebhook, error)
260321
SideEffects: c.sideEffects(),
261322
TimeoutSeconds: c.timeoutSeconds(),
262323
AdmissionReviewVersions: c.AdmissionReviewVersions,
324+
NamespaceSelector: namespaceSelector,
325+
ObjectSelector: objectSelector,
263326
}, nil
264327
}
265328

@@ -402,6 +465,16 @@ func (c Config) reinvocationPolicy() *admissionregv1.ReinvocationPolicyType {
402465
return &reinvocationPolicy
403466
}
404467

468+
// namespaceSelector returns the LabelSelector for the webhook's namespace filter, or nil if unset.
469+
func (c Config) namespaceSelector() (*metav1.LabelSelector, error) {
470+
return parseLabelSelector(c.NamespaceSelector)
471+
}
472+
473+
// objectSelector returns the LabelSelector for the webhook's object filter, or nil if unset.
474+
func (c Config) objectSelector() (*metav1.LabelSelector, error) {
475+
return parseLabelSelector(c.ObjectSelector)
476+
}
477+
405478
// webhookVersions returns the target API versions of the {Mutating,Validating}WebhookConfiguration objects for a webhook.
406479
func (c Config) webhookVersions() ([]string, error) {
407480
// 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)