From 6a45780cd48a07b5772c2ed02c68ebcc4251777b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 06:09:00 +0000 Subject: [PATCH 1/4] build(deps): bump goreleaser/goreleaser-action in the all group Bumps the all group with 1 update: [goreleaser/goreleaser-action](https://github.com/goreleaser/goreleaser-action). Updates `goreleaser/goreleaser-action` from 7.1.0 to 7.2.1 - [Release notes](https://github.com/goreleaser/goreleaser-action/releases) - [Commits](https://github.com/goreleaser/goreleaser-action/compare/e24998b8b67b290c2fa8b7c14fcfa7de2c5c9b8c...1a80836c5c9d9e5755a25cb59ec6f45a3b5f41a8) --- updated-dependencies: - dependency-name: goreleaser/goreleaser-action dependency-version: 7.2.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all ... Signed-off-by: dependabot[bot] --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5082f391..5e46a570 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,7 +29,7 @@ jobs: cache: 'false' - name: Run GoReleaser - uses: goreleaser/goreleaser-action@e24998b8b67b290c2fa8b7c14fcfa7de2c5c9b8c # v7.1.0 + uses: goreleaser/goreleaser-action@1a80836c5c9d9e5755a25cb59ec6f45a3b5f41a8 # v7.2.1 with: distribution: goreleaser version: latest From e5fb857b3420afc08afb8a9902585e37843fe5e0 Mon Sep 17 00:00:00 2001 From: Chris Randles Date: Sun, 3 May 2026 13:06:33 -0400 Subject: [PATCH 2/4] feat: [cel] add CEL-based assertions and offline VAP evaluation Signed-off-by: Chris Randles --- cel/cel.go | 175 ++++++++++++++++++++++++ cel/cel_test.go | 257 +++++++++++++++++++++++++++++++++++ cel/library.go | 106 +++++++++++++++ cel/policy/policy.go | 122 +++++++++++++++++ cel/policy/policy_test.go | 153 +++++++++++++++++++++ cel/variables.go | 194 +++++++++++++++++++++++++++ docs/cel-assertions.md | 276 ++++++++++++++++++++++++++++++++++++++ examples/go.mod | 2 +- examples/go.sum | 4 +- go.mod | 9 +- go.sum | 18 ++- 11 files changed, 1310 insertions(+), 6 deletions(-) create mode 100644 cel/cel.go create mode 100644 cel/cel_test.go create mode 100644 cel/library.go create mode 100644 cel/policy/policy.go create mode 100644 cel/policy/policy_test.go create mode 100644 cel/variables.go create mode 100644 docs/cel-assertions.md diff --git a/cel/cel.go b/cel/cel.go new file mode 100644 index 00000000..1c2ab6b9 --- /dev/null +++ b/cel/cel.go @@ -0,0 +1,175 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package cel provides CEL-based assertion utilities for testing Kubernetes +// objects. It wraps cel-go with the variables and library set that the +// Kubernetes API server registers for admission CEL, so the same expressions +// that appear in a ValidatingAdmissionPolicy can appear in a test assertion. +package cel + +import ( + "fmt" + "sync" + + "github.com/google/cel-go/cel" + "github.com/google/cel-go/common/types" + "github.com/google/cel-go/common/types/ref" +) + +// Env selects which CEL variable shape the Evaluator exposes. +type Env int + +const ( + // EnvAdmission exposes object, oldObject, request, params, + // namespaceObject, and authorizer (matching ValidatingAdmissionPolicy). + EnvAdmission Env = iota + // EnvCRD exposes self and oldSelf (matching x-kubernetes-validations). + EnvCRD +) + +// DefaultCostLimit matches the API server's per-expression CEL cost limit. +const DefaultCostLimit uint64 = 10_000_000 + +// Evaluator compiles and evaluates CEL expressions against variable bindings. +// An Evaluator is safe for concurrent use; compiled programs are cached so +// the same expression is not re-compiled across assertions. +type Evaluator struct { + env *cel.Env + cache sync.Map // expression -> cel.Program + opts options +} + +type options struct { + env Env + libSet librarySet + costLimit uint64 + customVars []cel.EnvOption +} + +// Option configures an Evaluator. +type Option func(*options) + +// WithEnvironment selects the CEL variable shape. Defaults to EnvAdmission. +func WithEnvironment(env Env) Option { + return func(o *options) { o.env = env } +} + +// WithLibraries restricts the Kubernetes CEL libraries wired into the +// evaluator. By default every library registered for admission CEL is wired +// in; passing WithLibraries replaces that set with the provided subset. +func WithLibraries(libs ...Library) Option { + return func(o *options) { + o.libSet = newLibrarySet(libs) + } +} + +// WithCostLimit overrides the per-expression CEL cost limit. The default is +// DefaultCostLimit, matching the API server. Passing 0 disables the limit. +func WithCostLimit(limit uint64) Option { + return func(o *options) { o.costLimit = limit } +} + +// WithVariables is an escape hatch for advanced use: the supplied EnvOptions +// are appended after the default variables and libraries are registered. +func WithVariables(decls ...cel.EnvOption) Option { + return func(o *options) { o.customVars = append(o.customVars, decls...) } +} + +// NewEvaluator returns an Evaluator configured for the admission CEL +// environment with every standard Kubernetes CEL library wired in. +func NewEvaluator(opts ...Option) (*Evaluator, error) { + o := options{ + env: EnvAdmission, + libSet: allLibraries(), + costLimit: DefaultCostLimit, + } + for _, opt := range opts { + opt(&o) + } + + envOpts := make([]cel.EnvOption, 0, 16) + envOpts = append(envOpts, variableDecls(o.env)...) + envOpts = append(envOpts, o.libSet.envOptions()...) + envOpts = append(envOpts, o.customVars...) + + env, err := cel.NewEnv(envOpts...) + if err != nil { + return nil, fmt.Errorf("cel: build env: %w", err) + } + return &Evaluator{env: env, opts: o}, nil +} + +// Eval compiles expr and evaluates it against b, returning the raw result. +// Compiled programs are cached per-expression inside the Evaluator. +func (e *Evaluator) Eval(expr string, b Bindings) (ref.Val, error) { + prg, err := e.programFor(expr) + if err != nil { + return nil, err + } + out, _, err := prg.Eval(map[string]any(b)) + if err != nil { + return nil, fmt.Errorf("cel: eval %q: %w", expr, err) + } + return out, nil +} + +// Assert returns nil iff expr evaluates to boolean true. Non-boolean results +// and compile/evaluation errors are surfaced as errors. +func (e *Evaluator) Assert(expr string, b Bindings) error { + out, err := e.Eval(expr, b) + if err != nil { + return err + } + bv, ok := out.(types.Bool) + if !ok { + return fmt.Errorf("cel: expression %q returned %T, want bool", expr, out) + } + if !bool(bv) { + return fmt.Errorf("cel: assertion failed: %s", expr) + } + return nil +} + +// programFor returns a cached compiled program for expr, compiling on first +// use. Multiple goroutines racing on the same expression may each compile, +// but only one result is retained; cel.Program is concurrency-safe. +func (e *Evaluator) programFor(expr string) (cel.Program, error) { + if p, ok := e.cache.Load(expr); ok { + prg, ok := p.(cel.Program) + if !ok { + return nil, fmt.Errorf("cel: cached value for %q is %T, want cel.Program", expr, p) + } + return prg, nil + } + ast, iss := e.env.Compile(expr) + if iss != nil && iss.Err() != nil { + return nil, fmt.Errorf("cel: compile %q: %w", expr, iss.Err()) + } + progOpts := []cel.ProgramOption{cel.EvalOptions(cel.OptOptimize)} + if e.opts.costLimit > 0 { + progOpts = append(progOpts, cel.CostLimit(e.opts.costLimit)) + } + prg, err := e.env.Program(ast, progOpts...) + if err != nil { + return nil, fmt.Errorf("cel: program %q: %w", expr, err) + } + actual, _ := e.cache.LoadOrStore(expr, prg) + stored, ok := actual.(cel.Program) + if !ok { + return nil, fmt.Errorf("cel: cached value for %q is %T, want cel.Program", expr, actual) + } + return stored, nil +} diff --git a/cel/cel_test.go b/cel/cel_test.go new file mode 100644 index 00000000..511c2045 --- /dev/null +++ b/cel/cel_test.go @@ -0,0 +1,257 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cel + +import ( + "strings" + "sync" + "testing" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func deployment(replicas, ready int32) *appsv1.Deployment { + return &appsv1.Deployment{ + TypeMeta: metav1.TypeMeta{APIVersion: "apps/v1", Kind: "Deployment"}, + ObjectMeta: metav1.ObjectMeta{ + Name: "demo", + Namespace: "default", + Labels: map[string]string{"app": "demo"}, + }, + Spec: appsv1.DeploymentSpec{ + Replicas: &replicas, + }, + Status: appsv1.DeploymentStatus{ + ReadyReplicas: ready, + }, + } +} + +func TestNewEvaluator_defaults(t *testing.T) { + ev, err := NewEvaluator() + if err != nil { + t.Fatalf("NewEvaluator: %v", err) + } + if ev.opts.env != EnvAdmission { + t.Errorf("default env = %v, want EnvAdmission", ev.opts.env) + } + if ev.opts.costLimit != DefaultCostLimit { + t.Errorf("default costLimit = %d, want %d", ev.opts.costLimit, DefaultCostLimit) + } +} + +func TestAssert_pass(t *testing.T) { + ev := newEv(t) + if err := ev.Assert("object.spec.replicas == 3", + ObjectBinding(deployment(3, 3))); err != nil { + t.Fatalf("expected pass, got %v", err) + } +} + +func TestAssert_fail(t *testing.T) { + ev := newEv(t) + err := ev.Assert("object.status.readyReplicas == object.spec.replicas", + ObjectBinding(deployment(3, 1))) + if err == nil { + t.Fatal("expected failure, got nil") + } + if !strings.Contains(err.Error(), "assertion failed") { + t.Fatalf("want assertion failure, got %v", err) + } +} + +func TestAssert_nonBoolResultIsError(t *testing.T) { + ev := newEv(t) + err := ev.Assert("1 + 1", ObjectBinding(deployment(1, 1))) + if err == nil || !strings.Contains(err.Error(), "want bool") { + t.Fatalf("expected non-bool error, got %v", err) + } +} + +func TestAssert_compileError(t *testing.T) { + ev := newEv(t) + err := ev.Assert("object.spec.replicas >", ObjectBinding(deployment(1, 1))) + if err == nil || !strings.Contains(err.Error(), "compile") { + t.Fatalf("expected compile error, got %v", err) + } +} + +func TestAssert_unknownVariableIsCompileError(t *testing.T) { + ev := newEv(t) + // Admission env exposes `object` but not `self`. + err := ev.Assert("self.spec.replicas > 0", ObjectBinding(deployment(1, 1))) + if err == nil || !strings.Contains(err.Error(), "compile") { + t.Fatalf("expected compile error for unknown var, got %v", err) + } +} + +func TestEval_returnsRawValue(t *testing.T) { + ev := newEv(t) + out, err := ev.Eval("object.spec.replicas", ObjectBinding(deployment(5, 5))) + if err != nil { + t.Fatal(err) + } + got := out.Value() + // ref.Val returns int64 for integer fields under DynType. + if got != int64(5) { + t.Errorf("got %v (%T), want int64(5)", got, got) + } +} + +func TestProgramCache_reusedAcrossEval(t *testing.T) { + ev := newEv(t) + expr := "object.spec.replicas == 3" + dep := ObjectBinding(deployment(3, 3)) + + if err := ev.Assert(expr, dep); err != nil { + t.Fatal(err) + } + if _, cached := ev.cache.Load(expr); !cached { + t.Fatal("program not cached after first Assert") + } + // Second call must hit the cache without recompiling. + if err := ev.Assert(expr, dep); err != nil { + t.Fatal(err) + } +} + +func TestProgramCache_concurrent(t *testing.T) { + ev := newEv(t) + expr := "object.spec.replicas >= 1" + dep := ObjectBinding(deployment(3, 3)) + + var wg sync.WaitGroup + for i := 0; i < 50; i++ { + wg.Add(1) + go func() { + defer wg.Done() + if err := ev.Assert(expr, dep); err != nil { + t.Errorf("concurrent Assert: %v", err) + } + }() + } + wg.Wait() +} + +func TestWithEnvironment_CRD(t *testing.T) { + ev, err := NewEvaluator(WithEnvironment(EnvCRD)) + if err != nil { + t.Fatal(err) + } + if err := ev.Assert(`self.metadata.name == "demo"`, + SelfBinding(deployment(1, 1))); err != nil { + t.Fatalf("CRD-env Assert failed: %v", err) + } + // `object` must not resolve in the CRD environment. + err = ev.Assert("object.spec.replicas == 1", ObjectBinding(deployment(1, 1))) + if err == nil || !strings.Contains(err.Error(), "compile") { + t.Fatalf("expected compile error for admission var in CRD env, got %v", err) + } +} + +func TestWithLibraries_narrowsSet(t *testing.T) { + // Only wire Quantity — Regex must not be available. + ev, err := NewEvaluator(WithLibraries(LibQuantity)) + if err != nil { + t.Fatal(err) + } + // Quantity works. + if err := ev.Assert(`quantity("100Mi").isGreaterThan(quantity("50Mi"))`, nil); err != nil { + t.Fatalf("quantity assertion failed: %v", err) + } + // Regex-based call should compile-fail. + err = ev.Assert(`"abc".find("a.") != ""`, nil) + if err == nil || !strings.Contains(err.Error(), "compile") { + t.Fatalf("expected regex to be unavailable, got %v", err) + } +} + +func TestDefaultLibraries_wireStandardK8sSet(t *testing.T) { + ev := newEv(t) + cases := []string{ + `quantity("100Mi").isGreaterThan(quantity("50Mi"))`, + `ip("10.0.0.1").family() == 4`, + `cidr("10.0.0.0/8").containsIP("10.1.2.3")`, + `"deployment-demo".find("[a-z]+") == "deployment"`, + `semver("1.2.3").isLessThan(semver("2.0.0"))`, + } + for _, expr := range cases { + if err := ev.Assert(expr, nil); err != nil { + t.Errorf("expected library call %q to pass, got %v", expr, err) + } + } +} + +func TestBind_laterOverridesEarlier(t *testing.T) { + a := Bindings{"x": 1, "y": 2} + b := Bindings{"y": 3, "z": 4} + merged := Bind(a, b) + if merged["x"] != 1 || merged["y"] != 3 || merged["z"] != 4 { + t.Fatalf("Bind merge wrong: %v", merged) + } +} + +func TestObjectBinding_nilBecomesNil(t *testing.T) { + b := ObjectBinding(nil) + if b["object"] != nil { + t.Fatalf("nil object should bind as nil, got %v", b["object"]) + } +} + +func TestOldObjectBinding_boundKey(t *testing.T) { + b := OldObjectBinding(deployment(1, 1)) + if _, ok := b["oldObject"]; !ok { + t.Fatal("OldObjectBinding should populate oldObject") + } +} + +func TestRequestBinding_surfacesOperation(t *testing.T) { + ev := newEv(t) + b := Bind( + ObjectBinding(deployment(3, 3)), + RequestBinding(&AdmissionRequest{Operation: "CREATE", Namespace: "demo"}), + ) + if err := ev.Assert(`request.operation == "CREATE" && request.namespace == "demo"`, b); err != nil { + t.Fatalf("request binding: %v", err) + } +} + +func TestNamespaceBinding(t *testing.T) { + ev := newEv(t) + ns := &corev1.Namespace{ + TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Namespace"}, + ObjectMeta: metav1.ObjectMeta{Name: "demo", Labels: map[string]string{"tier": "gold"}}, + } + b := Bind( + ObjectBinding(deployment(1, 1)), + NamespaceBinding(ns), + ) + if err := ev.Assert(`namespaceObject.metadata.labels["tier"] == "gold"`, b); err != nil { + t.Fatalf("namespace binding: %v", err) + } +} + +func newEv(t *testing.T) *Evaluator { + t.Helper() + ev, err := NewEvaluator() + if err != nil { + t.Fatalf("NewEvaluator: %v", err) + } + return ev +} diff --git a/cel/library.go b/cel/library.go new file mode 100644 index 00000000..c8900a12 --- /dev/null +++ b/cel/library.go @@ -0,0 +1,106 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cel + +import ( + "github.com/google/cel-go/cel" + "k8s.io/apiserver/pkg/cel/library" +) + +// Library identifies a CEL library that the Kubernetes API server registers +// for admission CEL. NewEvaluator wires in every library by default; use +// WithLibraries to select a narrower set. +type Library int + +const ( + LibAuthz Library = iota + LibAuthzSelectors + LibQuantity + LibURLs + LibIP + LibCIDR + LibRegex + LibLists + LibFormat + LibSemver + LibJSONPatch +) + +// librarySet is the set of selected libraries, in a deterministic order. +type librarySet struct { + libs []Library +} + +func newLibrarySet(libs []Library) librarySet { + return librarySet{libs: libs} +} + +// allLibraries returns a librarySet containing every supported library. +func allLibraries() librarySet { + return librarySet{libs: []Library{ + LibAuthz, + LibAuthzSelectors, + LibQuantity, + LibURLs, + LibIP, + LibCIDR, + LibRegex, + LibLists, + LibFormat, + LibSemver, + LibJSONPatch, + }} +} + +// envOptions translates the selected libraries into cel.EnvOption values. +func (s librarySet) envOptions() []cel.EnvOption { + out := make([]cel.EnvOption, 0, len(s.libs)) + for _, l := range s.libs { + if opt := libraryEnvOption(l); opt != nil { + out = append(out, opt) + } + } + return out +} + +func libraryEnvOption(l Library) cel.EnvOption { + switch l { + case LibAuthz: + return library.Authz() + case LibAuthzSelectors: + return library.AuthzSelectors() + case LibQuantity: + return library.Quantity() + case LibURLs: + return library.URLs() + case LibIP: + return library.IP() + case LibCIDR: + return library.CIDR() + case LibRegex: + return library.Regex() + case LibLists: + return library.Lists() + case LibFormat: + return library.Format() + case LibSemver: + return library.SemverLib() + case LibJSONPatch: + return library.JSONPatch() + } + return nil +} diff --git a/cel/policy/policy.go b/cel/policy/policy.go new file mode 100644 index 00000000..4bda9170 --- /dev/null +++ b/cel/policy/policy.go @@ -0,0 +1,122 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package policy provides offline evaluation of ValidatingAdmissionPolicy- +// shaped CEL validations. It lets test authors unit-test the CEL rules in +// a policy against fixture objects without standing up a live API server. +package policy + +import ( + "errors" + "fmt" + + admissionregistrationv1 "k8s.io/api/admissionregistration/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "sigs.k8s.io/e2e-framework/cel" + "sigs.k8s.io/e2e-framework/klient/k8s" +) + +// Policy is a named collection of CEL validations, mirroring the shape of +// ValidatingAdmissionPolicy.spec.validations. +type Policy struct { + Name string `json:"name"` + Validations []Validation `json:"validations"` +} + +// Validation is one CEL rule within a Policy. +type Validation struct { + // Expression is the CEL expression evaluated against the target object. + // It must return a bool; a true result admits, false rejects. + Expression string `json:"expression"` + // Message is a human-readable failure explanation surfaced when the + // expression evaluates to false. Defaults to Expression when empty. + Message string `json:"message,omitempty"` + // Reason mirrors ValidatingAdmissionPolicy.spec.validations[*].reason. + Reason metav1.StatusReason `json:"reason,omitempty"` +} + +// Failure captures a single failed Validation within a Result. +type Failure struct { + Validation Validation `json:"validation"` + Err error `json:"error,omitempty"` +} + +// Result is the report of a Policy.Check. +type Result struct { + PolicyName string `json:"policyName"` + Failures []Failure `json:"failures,omitempty"` +} + +// Passed reports whether every validation admitted the object. +func (r Result) Passed() bool { return len(r.Failures) == 0 } + +// Err returns a joined error covering every failure, or nil if the result +// passed. +func (r Result) Err() error { + if r.Passed() { + return nil + } + errs := make([]error, 0, len(r.Failures)) + for _, f := range r.Failures { + msg := f.Validation.Message + if msg == "" { + msg = f.Validation.Expression + } + errs = append(errs, fmt.Errorf("%s: %s: %w", r.PolicyName, msg, f.Err)) + } + return errors.Join(errs...) +} + +// Check runs every Validation against obj and returns a Result. All +// validations are evaluated (no short-circuit on first failure), matching +// the admission path's accumulation of failures when failurePolicy is Fail. +func (p Policy) Check(ev *cel.Evaluator, obj k8s.Object) Result { + res := Result{PolicyName: p.Name} + bindings := cel.ObjectBinding(obj) + for _, v := range p.Validations { + if err := ev.Assert(v.Expression, bindings); err != nil { + res.Failures = append(res.Failures, Failure{Validation: v, Err: err}) + } + } + return res +} + +// FromVAP converts a ValidatingAdmissionPolicy manifest into a testable +// Policy. Only the Expression, Message, and Reason fields are carried over; +// paramKind, matchConstraints, and matchConditions are not evaluated (this +// is an offline validation of the CEL rules, not a full admission +// simulation). +func FromVAP(vap *admissionregistrationv1.ValidatingAdmissionPolicy) Policy { + if vap == nil { + return Policy{} + } + p := Policy{ + Name: vap.Name, + Validations: make([]Validation, 0, len(vap.Spec.Validations)), + } + for _, v := range vap.Spec.Validations { + conv := Validation{ + Expression: v.Expression, + Message: v.Message, + } + if v.Reason != nil { + conv.Reason = *v.Reason + } + p.Validations = append(p.Validations, conv) + } + return p +} diff --git a/cel/policy/policy_test.go b/cel/policy/policy_test.go new file mode 100644 index 00000000..31373912 --- /dev/null +++ b/cel/policy/policy_test.go @@ -0,0 +1,153 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package policy + +import ( + "strings" + "testing" + + admissionregistrationv1 "k8s.io/api/admissionregistration/v1" + appsv1 "k8s.io/api/apps/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "sigs.k8s.io/e2e-framework/cel" +) + +func dep(replicas int32) *appsv1.Deployment { + return &appsv1.Deployment{ + TypeMeta: metav1.TypeMeta{APIVersion: "apps/v1", Kind: "Deployment"}, + ObjectMeta: metav1.ObjectMeta{Name: "demo", Namespace: "default"}, + Spec: appsv1.DeploymentSpec{Replicas: &replicas}, + } +} + +var replicasPolicy = Policy{ + Name: "deployment-replicas", + Validations: []Validation{ + {Expression: "object.spec.replicas >= 1", Message: "replicas must be at least 1"}, + {Expression: "object.spec.replicas <= 100", Message: "replicas must not exceed 100"}, + }, +} + +func TestPolicy_admits(t *testing.T) { + ev := newEv(t) + res := replicasPolicy.Check(ev, dep(3)) + if !res.Passed() { + t.Fatalf("expected pass, got failures: %v", res.Err()) + } + if res.Err() != nil { + t.Fatalf("passed result should have nil Err, got %v", res.Err()) + } +} + +func TestPolicy_rejects_belowMin(t *testing.T) { + ev := newEv(t) + res := replicasPolicy.Check(ev, dep(0)) + if res.Passed() { + t.Fatal("expected failure") + } + if len(res.Failures) != 1 { + t.Fatalf("want 1 failure, got %d", len(res.Failures)) + } + if !strings.Contains(res.Err().Error(), "at least 1") { + t.Fatalf("want min-replicas message, got %v", res.Err()) + } +} + +func TestPolicy_accumulatesAllFailures(t *testing.T) { + multi := Policy{ + Name: "multi", + Validations: []Validation{ + {Expression: "object.spec.replicas < 0", Message: "must be negative"}, + {Expression: "object.spec.replicas > 1000", Message: "must exceed 1000"}, + }, + } + ev := newEv(t) + res := multi.Check(ev, dep(5)) + if len(res.Failures) != 2 { + t.Fatalf("want 2 failures, got %d", len(res.Failures)) + } +} + +func TestPolicy_emptyMessageFallsBackToExpression(t *testing.T) { + p := Policy{ + Name: "bare", + Validations: []Validation{ + {Expression: "object.spec.replicas < 0"}, + }, + } + ev := newEv(t) + res := p.Check(ev, dep(5)) + if !strings.Contains(res.Err().Error(), "object.spec.replicas < 0") { + t.Fatalf("want expression in message, got %v", res.Err()) + } +} + +func TestFromVAP_preservesValidations(t *testing.T) { + reasonInvalid := metav1.StatusReasonInvalid + vap := &admissionregistrationv1.ValidatingAdmissionPolicy{ + ObjectMeta: metav1.ObjectMeta{Name: "require-replicas"}, + Spec: admissionregistrationv1.ValidatingAdmissionPolicySpec{ + Validations: []admissionregistrationv1.Validation{ + { + Expression: "object.spec.replicas >= 1", + Message: "replicas >= 1", + Reason: &reasonInvalid, + }, + { + Expression: "object.spec.replicas <= 100", + Message: "replicas <= 100", + }, + }, + }, + } + p := FromVAP(vap) + if p.Name != "require-replicas" { + t.Errorf("Name = %q, want require-replicas", p.Name) + } + if len(p.Validations) != 2 { + t.Fatalf("want 2 validations, got %d", len(p.Validations)) + } + if p.Validations[0].Reason != metav1.StatusReasonInvalid { + t.Errorf("Reason not preserved: got %q", p.Validations[0].Reason) + } + + // Round-trip: run the converted Policy and confirm it admits a valid obj. + ev := newEv(t) + if !p.Check(ev, dep(3)).Passed() { + t.Fatal("converted policy should admit dep(3)") + } + if p.Check(ev, dep(0)).Passed() { + t.Fatal("converted policy should reject dep(0)") + } +} + +func TestFromVAP_nilReturnsEmpty(t *testing.T) { + p := FromVAP(nil) + if p.Name != "" || len(p.Validations) != 0 { + t.Fatalf("nil VAP should produce empty Policy, got %+v", p) + } +} + +func newEv(t *testing.T) *cel.Evaluator { + t.Helper() + ev, err := cel.NewEvaluator() + if err != nil { + t.Fatalf("NewEvaluator: %v", err) + } + return ev +} diff --git a/cel/variables.go b/cel/variables.go new file mode 100644 index 00000000..21991a96 --- /dev/null +++ b/cel/variables.go @@ -0,0 +1,194 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cel + +import ( + "github.com/google/cel-go/cel" + authenticationv1 "k8s.io/api/authentication/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + + "sigs.k8s.io/e2e-framework/klient/k8s" +) + +// Bindings is the variable map passed to CEL at evaluation time. The keys +// correspond to CEL variable names (e.g. "object", "oldObject", "self"). +type Bindings map[string]any + +// AdmissionRequest mirrors the subset of admissionv1.AdmissionRequest that +// admission CEL exposes under the `request` variable. Callers populate only +// the fields their assertions reference. +type AdmissionRequest struct { + Kind schema.GroupVersionKind + Resource schema.GroupVersionResource + SubResource string + RequestKind *schema.GroupVersionKind + RequestResource *schema.GroupVersionResource + RequestSubResource string + Name string + Namespace string + Operation string // CREATE, UPDATE, DELETE, CONNECT + UserInfo authenticationv1.UserInfo + DryRun bool +} + +// ObjectBinding binds obj to the `object` variable used by admission CEL. +func ObjectBinding(obj k8s.Object) Bindings { + return Bindings{"object": toUnstructured(obj)} +} + +// OldObjectBinding binds obj to the `oldObject` variable. +func OldObjectBinding(obj k8s.Object) Bindings { + return Bindings{"oldObject": toUnstructured(obj)} +} + +// ParamsBinding binds the param resource a ValidatingAdmissionPolicyBinding +// references to the `params` variable. +func ParamsBinding(params k8s.Object) Bindings { + return Bindings{"params": toUnstructured(params)} +} + +// NamespaceBinding binds the namespace of the object under admission to the +// `namespaceObject` variable. +func NamespaceBinding(ns *corev1.Namespace) Bindings { + return Bindings{"namespaceObject": toUnstructured(ns)} +} + +// RequestBinding binds a test-constructed AdmissionRequest to the `request` +// variable. Only the fields populated on req are surfaced to CEL. +// A nil req binds `request` to nil. +func RequestBinding(req *AdmissionRequest) Bindings { + if req == nil { + return Bindings{"request": nil} + } + return Bindings{"request": admissionRequestToMap(req)} +} + +// SelfBinding binds self to the `self` variable (CRD environment only). +// Using SelfBinding against an admission-environment Evaluator produces a +// CEL compile error at expression evaluation time. +func SelfBinding(self k8s.Object) Bindings { + return Bindings{"self": toUnstructured(self)} +} + +// OldSelfBinding binds oldSelf to the `oldSelf` variable (CRD environment). +func OldSelfBinding(oldSelf k8s.Object) Bindings { + return Bindings{"oldSelf": toUnstructured(oldSelf)} +} + +// Bind composes multiple Bindings. When keys collide, later values win. +func Bind(parts ...Bindings) Bindings { + out := Bindings{} + for _, p := range parts { + for k, v := range p { + out[k] = v + } + } + return out +} + +// variableDecls returns the cel.Variable declarations for a given Env. +func variableDecls(e Env) []cel.EnvOption { + switch e { + case EnvCRD: + return []cel.EnvOption{ + cel.Variable("self", cel.DynType), + cel.Variable("oldSelf", cel.DynType), + } + default: // EnvAdmission + return []cel.EnvOption{ + cel.Variable("object", cel.DynType), + cel.Variable("oldObject", cel.DynType), + cel.Variable("request", cel.DynType), + cel.Variable("params", cel.DynType), + cel.Variable("namespaceObject", cel.DynType), + cel.Variable("authorizer", cel.DynType), + cel.Variable("variables", cel.DynType), + } + } +} + +// toUnstructured converts a k8s.Object into the map[string]any shape CEL +// sees. A nil object becomes a nil binding, matching admission semantics +// where `object` is null on DELETE and `oldObject` is null on CREATE. +func toUnstructured(obj k8s.Object) any { + if obj == nil { + return nil + } + u, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj) + if err != nil { + // Surface the error at evaluation time rather than at binding time; + // returning a sentinel map lets CEL fail the assertion cleanly. + return map[string]any{"__binding_error__": err.Error()} + } + return u +} + +func admissionRequestToMap(req *AdmissionRequest) map[string]any { + m := map[string]any{ + "kind": gvkToMap(req.Kind), + "resource": gvrToMap(req.Resource), + "subResource": req.SubResource, + "name": req.Name, + "namespace": req.Namespace, + "operation": req.Operation, + "userInfo": userInfoToMap(req.UserInfo), + "dryRun": req.DryRun, + } + if req.RequestKind != nil { + m["requestKind"] = gvkToMap(*req.RequestKind) + } + if req.RequestResource != nil { + m["requestResource"] = gvrToMap(*req.RequestResource) + } + if req.RequestSubResource != "" { + m["requestSubResource"] = req.RequestSubResource + } + return m +} + +func gvkToMap(gvk schema.GroupVersionKind) map[string]any { + return map[string]any{"group": gvk.Group, "version": gvk.Version, "kind": gvk.Kind} +} + +func gvrToMap(gvr schema.GroupVersionResource) map[string]any { + return map[string]any{"group": gvr.Group, "version": gvr.Version, "resource": gvr.Resource} +} + +func userInfoToMap(u authenticationv1.UserInfo) map[string]any { + m := map[string]any{ + "username": u.Username, + "uid": u.UID, + "groups": stringSliceOrEmpty(u.Groups), + } + if len(u.Extra) > 0 { + extra := make(map[string]any, len(u.Extra)) + for k, v := range u.Extra { + extra[k] = []string(v) + } + m["extra"] = extra + } + return m +} + +func stringSliceOrEmpty(s []string) []string { + if s == nil { + return []string{} + } + return s +} diff --git a/docs/cel-assertions.md b/docs/cel-assertions.md new file mode 100644 index 00000000..29fa1eb8 --- /dev/null +++ b/docs/cel-assertions.md @@ -0,0 +1,276 @@ +# CEL Assertions + +This document proposes the design for a set of CEL ([Common Expression Language](https://github.com/google/cel-spec)) utilities in a new top-level package, `cel`, intended to let test developers write declarative assertions against Kubernetes objects in the same language the Kubernetes API server uses for admission and CRD validation. The goal of these utilities is to make test assertions terse, readable, and consistent with the CEL expressions a project already ships in its admission policies and CRDs. + +## Table of Contents + +1. [Motivation](#Motivation) +2. [Supported CEL environments](#supported-cel-environments) +3. [Goals](#Goals) +4. [Non-Goals](#Non-Goals) +5. [Design Components](#Design-Components) + * [Evaluator](#Evaluator) + * [Variable Bindings](#Variable-Bindings) + * [CEL Library Composition](#CEL-Library-Composition) + * [Policy](#Policy) +6. [CEL Proposal](#CEL-Proposal) + * [Pre-defined Evaluators and Bindings](#pre-defined-evaluators-and-bindings) + * [Pre-defined Helpers](#Pre-defined-Helpers) + +## Motivation + +When developing tests for Kubernetes components, it is common to fetch an object using the `klient` client and then assert some property of it — that a Deployment is fully rolled out, that a Service has the expected selector, that a ConfigMap contains a specific key. These assertions are typically written as Go field accessors, which gets verbose quickly and reads differently from the CRD validation rules or admission policies the same project ships. + +Kubernetes uses CEL for CRD `x-kubernetes-validations`, `ValidatingAdmissionPolicy`, and `MutatingAdmissionPolicy`. A test author wanting to exercise the same invariant a policy enforces has to translate the CEL expression into Go, which is easy to get wrong and adds drift between the test and the policy. + +A CEL utility in `cel` removes the translation step. The same expression that appears in a `ValidatingAdmissionPolicy` can appear in a test assertion, bound to the same variable names. Tests that need to check several invariants at once can group them into a reusable `Policy` value or chain multiple `Assess(...)` calls on a `features.Feature`, rather than inline every accessor. + +## Supported CEL environments + +- Admission (`object`, `oldObject`, `request`, `params`, `namespaceObject`, `authorizer`, `variables`) +- CRD validation (`self`, `oldSelf`) + +The admission environment is the default. It matches what a `ValidatingAdmissionPolicy` author sees, and is what most tests will want. The CRD environment is available via `WithEnvironment(EnvCRD)` for tests that assert on values scoped to a CRD field path. + +## Goals + +- Provide a simple way to assert that a Kubernetes object satisfies a CEL expression, returning a Go `error`. +- Support the variables and library functions the Kubernetes API server registers for admission CEL (`quantity`, `url`, `ip`, `cidr`, `regex`, `lists`, `format`, `semver`, `authz`, `jsonpatch`). +- Support unit-testing `ValidatingAdmissionPolicy` validations offline against fixture objects, without a live API server. +- Integrate with `pkg/features` so a CEL assertion reads as a one-line `Assess(...)`. +- Cache compiled CEL programs to avoid repeated compile cost across assertions. + +## Non-Goals + +- Replacing integration tests against a live admission webhook. The offline `Policy` check evaluates CEL against fixture objects; it does not install a policy on a cluster. +- Server-side-apply merge semantics for `MutatingAdmissionPolicy` `ApplyConfiguration` patches. +- A policy management layer. Applying or syncing policies to a cluster is the responsibility of `klient/decoder` and `klient/k8s/resources`. +- CEL authoring tooling (syntax highlighting, completion, lint). + +## Design Components + +### **Evaluator** + +```go +type Evaluator struct { + // unexported +} + +func NewEvaluator(opts ...Option) (*Evaluator, error) +``` + +The `Evaluator` owns a `*cel.Env`, a compiled-program cache, and a default set of variable bindings. It is the single primitive every other component uses. The zero-option constructor returns an evaluator configured for the admission environment with all standard Kubernetes CEL libraries wired in. + +An `Evaluator` is safe for concurrent use, so the same instance can be shared across parallel features. + +```go +type Option func(*options) +``` + +`Option` values are used to narrow or widen the default configuration. + +Example pre-defined Options: + +```go +// select the CEL environment (admission or CRD) +func WithEnvironment(env Env) Option +// restrict the wired-in Kubernetes CEL libraries +func WithLibraries(libs ...Library) Option +// bind the `authorizer` variable to a live *envconf.Config for SAR checks +func WithAuthorizer(cfg *envconf.Config) Option +// override the per-expression CEL cost limit (default matches API server) +func WithCostLimit(limit uint64) Option +``` + +### **Variable Bindings** + +```go +type Bindings map[string]any +``` + +`Bindings` is the variable map passed to CEL at evaluation time. Pre-defined binding helpers convert `k8s.Object` and related types into a `Bindings` value using the variable names the admission CEL environment expects. + +```go +func ObjectBinding(obj k8s.Object) Bindings +func OldObjectBinding(obj k8s.Object) Bindings +func ParamsBinding(params k8s.Object) Bindings +func NamespaceBinding(ns *corev1.Namespace) Bindings +func RequestBinding(req *AdmissionRequest) Bindings + +// CRD environment bindings — only valid when WithEnvironment(EnvCRD) is set +func SelfBinding(self k8s.Object) Bindings +func OldSelfBinding(oldSelf k8s.Object) Bindings + +// Compose multiple bindings; later keys override earlier ones. +func Bind(parts ...Bindings) Bindings +``` + +Binding helpers convert objects via `runtime.DefaultUnstructuredConverter` so CEL sees the same field shape the API server does. + +### **CEL Library Composition** + +```go +type Library int + +const ( + LibAuthz Library = iota + LibAuthzSelectors + LibQuantity + LibURLs + LibIP + LibCIDR + LibRegex + LibLists + LibFormat + LibSemver + LibJSONPatch +) +``` + +By default, `NewEvaluator` wires in every CEL library the Kubernetes API server registers for admission. Users can select a narrower set with `WithLibraries`. + +The `Authz` library is the one exception worth calling out: its function bodies issue `SubjectAccessReview` calls, so tests that use `authorizer.check(...)` need an `Evaluator` built with `WithAuthorizer(cfg)` pointing at a live `*envconf.Config`. + +### **Policy** + +```go +type Policy struct { + Name string + Validations []Validation +} + +type Validation struct { + Expression string + Message string + Reason metav1.StatusReason +} + +type Result struct { + PolicyName string + Failures []Failure +} + +type Failure struct { + Validation Validation + Err error +} +``` + +`Policy` is the offline analogue of `ValidatingAdmissionPolicy.spec.validations`. `Policy.Check` runs every validation against a candidate object and accumulates failures rather than short-circuiting, so tests can report the full admission story in one pass. + +```go +func (p Policy) Check(ev *Evaluator, obj k8s.Object) Result + +func (r Result) Passed() bool +func (r Result) Err() error +``` + +A companion helper converts a real `admissionregistrationv1.ValidatingAdmissionPolicy` into a testable `Policy`: + +```go +func FromVAP(vap *admissionregistrationv1.ValidatingAdmissionPolicy) Policy +``` + +Paired with `klient/decoder`, a test can decode the same `ValidatingAdmissionPolicy` manifest the operator ships and check its validations against a fixture object — no re-expressing the rules in Go. + +## CEL Proposal + +Proposal on the function signatures: + +```go +// NewEvaluator returns an Evaluator configured for the admission CEL +// environment with all standard Kubernetes CEL libraries wired in. +// Options narrow or widen the default. +func NewEvaluator(opts ...Option) (*Evaluator, error) + +// Eval compiles expr and evaluates it against b, returning the raw result. +// Compiled programs are cached per-expression inside the Evaluator. +func (e *Evaluator) Eval(expr string, b Bindings) (ref.Val, error) + +// Assert is the common case: returns nil iff expr evaluates to boolean true. +func (e *Evaluator) Assert(expr string, b Bindings) error +``` + +Usage: + +```go +ev, _ := cel.NewEvaluator() + +var dep appsv1.Deployment +_ = cfg.Client().Resources().Get(ctx, "demo", "default", &dep) + +err := ev.Assert( + "object.status.readyReplicas == object.spec.replicas", + cel.ObjectBinding(&dep), +) +``` + +With a narrower library set: + +```go +ev, _ := cel.NewEvaluator( + cel.WithLibraries(cel.LibQuantity, cel.LibRegex, cel.LibFormat), +) +``` + +With the CRD environment: + +```go +ev, _ := cel.NewEvaluator(cel.WithEnvironment(cel.EnvCRD)) +err := ev.Assert("self.size() > 0", cel.SelfBinding(&cr)) +``` + +### Pre-defined Evaluators and Bindings + +Building on the proposal, the following helpers would be included to reduce boilerplate for common setups. + +```go +// ObjectBinding wraps an object in a Bindings value using the admission +// variable name `object`. +func ObjectBinding(obj k8s.Object) Bindings +// OldObjectBinding binds `oldObject` — useful for UPDATE-path policy tests. +func OldObjectBinding(obj k8s.Object) Bindings +// ParamsBinding binds `params` — the param resource a VAP references. +func ParamsBinding(params k8s.Object) Bindings +// NamespaceBinding binds `namespaceObject`. +func NamespaceBinding(ns *corev1.Namespace) Bindings +// RequestBinding binds `request` — a test-constructed AdmissionRequest subset. +func RequestBinding(req *AdmissionRequest) Bindings + +// Bind composes multiple bindings. Later keys override earlier ones. +func Bind(parts ...Bindings) Bindings +``` + +Usage, composing several bindings for an UPDATE-path policy test: + +```go +b := cel.Bind( + cel.ObjectBinding(newDep), + cel.OldObjectBinding(oldDep), + cel.RequestBinding(&cel.AdmissionRequest{Operation: "UPDATE"}), +) +err := ev.Assert( + "object.spec.replicas >= oldObject.spec.replicas", + b, +) +``` + +### Pre-defined Helpers + +```go +// FromVAP converts a live ValidatingAdmissionPolicy into a testable Policy. +func FromVAP(vap *admissionregistrationv1.ValidatingAdmissionPolicy) Policy +``` + +Usage: + +```go +var vap admissionregistrationv1.ValidatingAdmissionPolicy +_ = decoder.Decode(strings.NewReader(policyYAML), &vap) + +pol := cel.FromVAP(&vap) +res := pol.Check(ev, &dep) +if !res.Passed() { + t.Fatal(res.Err()) +} +``` diff --git a/examples/go.mod b/examples/go.mod index 5e0ea73f..3ce104e9 100644 --- a/examples/go.mod +++ b/examples/go.mod @@ -51,7 +51,7 @@ require ( golang.org/x/term v0.37.0 // indirect golang.org/x/text v0.31.0 // indirect golang.org/x/time v0.9.0 // indirect - google.golang.org/protobuf v1.36.8 // indirect + google.golang.org/protobuf v1.36.10 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/examples/go.sum b/examples/go.sum index 75b90235..91a1f9fb 100644 --- a/examples/go.sum +++ b/examples/go.sum @@ -139,8 +139,8 @@ golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= -google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= -google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/go.mod b/go.mod index 0964f206..95c1b5be 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,11 @@ module sigs.k8s.io/e2e-framework go 1.25.7 require ( + github.com/google/cel-go v0.28.0 github.com/vladimirvivien/gexe v0.5.0 k8s.io/api v0.35.3 k8s.io/apimachinery v0.35.3 + k8s.io/apiserver v0.35.3 k8s.io/client-go v0.35.3 k8s.io/component-base v0.35.3 k8s.io/klog/v2 v2.140.0 @@ -14,6 +16,8 @@ require ( ) require ( + cel.dev/expr v0.25.1 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect @@ -47,13 +51,16 @@ require ( go.opentelemetry.io/otel/trace v1.36.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 // indirect golang.org/x/net v0.47.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/term v0.37.0 // indirect golang.org/x/text v0.31.0 // indirect golang.org/x/time v0.9.0 // indirect - google.golang.org/protobuf v1.36.8 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a // indirect + google.golang.org/protobuf v1.36.10 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index cb68770c..3d8afe87 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,9 @@ +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -33,6 +37,8 @@ github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+Gr github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/google/cel-go v0.28.0 h1:KjSWstCpz/MN5t4a8gnGJNIYUsJRpdi/r97xWDphIQc= +github.com/google/cel-go v0.28.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -121,6 +127,8 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 h1:kx6Ds3MlpiUHKj7syVnbp57++8WpuKPcR5yjLBjvLEA= +golang.org/x/exp v0.0.0-20240823005443-9b4947da3948/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ= golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= @@ -139,8 +147,12 @@ golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= -google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= -google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb h1:p31xT4yrYrSM/G4Sn2+TNUkVhFCbG9y8itM2S6Th950= +google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:jbe3Bkdp+Dh2IrslsFCklNhweNTBgSYanP1UXhJDhKg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a h1:v2PbRU4K3llS09c7zodFpNePeamkAwG3mPrAery9VeE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= @@ -157,6 +169,8 @@ k8s.io/apiextensions-apiserver v0.35.0 h1:3xHk2rTOdWXXJM+RDQZJvdx0yEOgC0FgQ1PlJa k8s.io/apiextensions-apiserver v0.35.0/go.mod h1:E1Ahk9SADaLQ4qtzYFkwUqusXTcaV2uw3l14aqpL2LU= k8s.io/apimachinery v0.35.3 h1:MeaUwQCV3tjKP4bcwWGgZ/cp/vpsRnQzqO6J6tJyoF8= k8s.io/apimachinery v0.35.3/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= +k8s.io/apiserver v0.35.3 h1:D2eIcfJ05hEAEewoSDg+05e0aSRwx8Y4Agvd/wiomUI= +k8s.io/apiserver v0.35.3/go.mod h1:JI0n9bHYzSgIxgIrfe21dbduJ9NHzKJ6RchcsmIKWKY= k8s.io/client-go v0.35.3 h1:s1lZbpN4uI6IxeTM2cpdtrwHcSOBML1ODNTCCfsP1pg= k8s.io/client-go v0.35.3/go.mod h1:RzoXkc0mzpWIDvBrRnD+VlfXP+lRzqQjCmKtiwZ8Q9c= k8s.io/component-base v0.35.3 h1:mbKbzoIMy7JDWS/wqZobYW1JDVRn/RKRaoMQHP9c4P0= From 73da333487db0f7988652fb74a7222ec0114bda5 Mon Sep 17 00:00:00 2001 From: Chris Randles Date: Tue, 21 Apr 2026 16:57:06 -0400 Subject: [PATCH 3/4] ci: add fork-only pull_request workflow for pre-submit signal Signed-off-by: Chris Randles --- .github/workflows/fork-ci.yml | 55 +++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 .github/workflows/fork-ci.yml diff --git a/.github/workflows/fork-ci.yml b/.github/workflows/fork-ci.yml new file mode 100644 index 00000000..c736544b --- /dev/null +++ b/.github/workflows/fork-ci.yml @@ -0,0 +1,55 @@ +name: fork-ci + +# Runs verify + unit tests on forks only. Upstream CI lives in Prow; +# this gives contributors deterministic pre-review signal on their branch. +# The job is gated to forks so it stays dormant in kubernetes-sigs/e2e-framework. +# +# Intended flow: open an intra-fork PR (feature-branch -> main on your fork) +# to get pre-submit signal, then open the upstream PR once it's green. +# Cross-fork PRs (fork -> kubernetes-sigs/e2e-framework) fire pull_request +# events in the upstream repo's context, where the repository guard skips +# this workflow by design. + +on: + pull_request: + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + if: github.repository != 'kubernetes-sigs/e2e-framework' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version: '1.26' + check-latest: 'true' + cache: 'true' + + - name: Verify + run: make verify + + test: + if: github.repository != 'kubernetes-sigs/e2e-framework' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version: '1.26' + check-latest: 'true' + cache: 'true' + + - name: Test + run: make test From 0754917991c2c5b9337569b89a89691eb7073689 Mon Sep 17 00:00:00 2001 From: Chris Randles Date: Sun, 3 May 2026 14:13:09 -0400 Subject: [PATCH 4/4] test: [examples/cel] add offline evaluator and VAP example Signed-off-by: Chris Randles --- examples/cel/cel_test.go | 120 +++++++++++++++++++++++++++++++++++++++ examples/go.mod | 7 +++ examples/go.sum | 14 +++++ 3 files changed, 141 insertions(+) create mode 100644 examples/cel/cel_test.go diff --git a/examples/cel/cel_test.go b/examples/cel/cel_test.go new file mode 100644 index 00000000..3699d341 --- /dev/null +++ b/examples/cel/cel_test.go @@ -0,0 +1,120 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package cel demonstrates the cel and cel/policy packages without a live +// cluster: every assertion is evaluated offline against a fixture object, +// the same way a unit test would exercise a ValidatingAdmissionPolicy +// without standing up an API server. +package cel + +import ( + "strings" + "testing" + + admissionregistrationv1 "k8s.io/api/admissionregistration/v1" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + celpkg "sigs.k8s.io/e2e-framework/cel" + "sigs.k8s.io/e2e-framework/cel/policy" + "sigs.k8s.io/e2e-framework/klient/decoder" +) + +// vapYAML is the same shape an operator would ship as a +// ValidatingAdmissionPolicy. policy.FromVAP turns it into a Policy that +// can be run offline against fixture objects. +const vapYAML = ` +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingAdmissionPolicy +metadata: + name: deployment-replicas +spec: + matchConstraints: + resourceRules: + - apiGroups: ["apps"] + apiVersions: ["v1"] + operations: ["CREATE","UPDATE"] + resources: ["deployments"] + validations: + - expression: "object.spec.replicas >= 1" + message: "replicas must be at least 1" + - expression: "object.spec.replicas <= 100" + message: "replicas must not exceed 100" +` + +func TestEvaluatorAssert(t *testing.T) { + ev, err := celpkg.NewEvaluator() + if err != nil { + t.Fatal(err) + } + dep := newDeployment("cel-demo", 2) + if err := ev.Assert("object.spec.replicas >= 1", celpkg.ObjectBinding(dep)); err != nil { + t.Fatal(err) + } +} + +func TestPolicyCheck(t *testing.T) { + ev, err := celpkg.NewEvaluator() + if err != nil { + t.Fatal(err) + } + pol := policy.Policy{ + Name: "deployment-replicas", + Validations: []policy.Validation{ + {Expression: "object.spec.replicas >= 1", Message: "replicas must be at least 1"}, + {Expression: "object.spec.replicas <= 100", Message: "replicas must not exceed 100"}, + }, + } + if res := pol.Check(ev, newDeployment("cel-demo", 2)); !res.Passed() { + t.Fatal(res.Err()) + } + if res := pol.Check(ev, newDeployment("cel-demo", 0)); res.Passed() { + t.Fatal("expected validation failure for replicas=0") + } +} + +func TestPolicyFromVAP(t *testing.T) { + ev, err := celpkg.NewEvaluator() + if err != nil { + t.Fatal(err) + } + var vap admissionregistrationv1.ValidatingAdmissionPolicy + if err := decoder.Decode(strings.NewReader(vapYAML), &vap); err != nil { + t.Fatal(err) + } + pol := policy.FromVAP(&vap) + if res := pol.Check(ev, newDeployment("cel-demo", 2)); !res.Passed() { + t.Fatal(res.Err()) + } +} + +func newDeployment(name string, replicas int32) *appsv1.Deployment { + labels := map[string]string{"app": "cel-example"} + return &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: name, Labels: labels}, + Spec: appsv1.DeploymentSpec{ + Replicas: &replicas, + Selector: &metav1.LabelSelector{MatchLabels: labels}, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{Labels: labels}, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "nginx", Image: "nginx"}}, + }, + }, + }, + } +} diff --git a/examples/go.mod b/examples/go.mod index 3ce104e9..4ae07365 100644 --- a/examples/go.mod +++ b/examples/go.mod @@ -11,6 +11,8 @@ require ( ) require ( + cel.dev/expr v0.25.1 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect @@ -22,6 +24,7 @@ require ( github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/swag v0.23.0 // indirect + github.com/google/cel-go v0.28.0 // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect @@ -45,16 +48,20 @@ require ( go.opentelemetry.io/otel/trace v1.36.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 // indirect golang.org/x/net v0.47.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/term v0.37.0 // indirect golang.org/x/text v0.31.0 // indirect golang.org/x/time v0.9.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a // indirect google.golang.org/protobuf v1.36.10 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/apiserver v0.35.3 // indirect k8s.io/client-go v0.35.3 // indirect k8s.io/component-base v0.35.3 // indirect k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect diff --git a/examples/go.sum b/examples/go.sum index 91a1f9fb..a07bfaa7 100644 --- a/examples/go.sum +++ b/examples/go.sum @@ -1,5 +1,9 @@ +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -33,6 +37,8 @@ github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+Gr github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/google/cel-go v0.28.0 h1:KjSWstCpz/MN5t4a8gnGJNIYUsJRpdi/r97xWDphIQc= +github.com/google/cel-go v0.28.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -121,6 +127,8 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 h1:kx6Ds3MlpiUHKj7syVnbp57++8WpuKPcR5yjLBjvLEA= +golang.org/x/exp v0.0.0-20240823005443-9b4947da3948/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ= golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= @@ -139,6 +147,10 @@ golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb h1:p31xT4yrYrSM/G4Sn2+TNUkVhFCbG9y8itM2S6Th950= +google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:jbe3Bkdp+Dh2IrslsFCklNhweNTBgSYanP1UXhJDhKg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a h1:v2PbRU4K3llS09c7zodFpNePeamkAwG3mPrAery9VeE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -157,6 +169,8 @@ k8s.io/apiextensions-apiserver v0.35.0 h1:3xHk2rTOdWXXJM+RDQZJvdx0yEOgC0FgQ1PlJa k8s.io/apiextensions-apiserver v0.35.0/go.mod h1:E1Ahk9SADaLQ4qtzYFkwUqusXTcaV2uw3l14aqpL2LU= k8s.io/apimachinery v0.35.4 h1:xtdom9RG7e+yDp71uoXoJDWEE2eOiHgeO4GdBzwWpds= k8s.io/apimachinery v0.35.4/go.mod h1:NNi1taPOpep0jOj+oRha3mBJPqvi0hGdaV8TCqGQ+cc= +k8s.io/apiserver v0.35.3 h1:D2eIcfJ05hEAEewoSDg+05e0aSRwx8Y4Agvd/wiomUI= +k8s.io/apiserver v0.35.3/go.mod h1:JI0n9bHYzSgIxgIrfe21dbduJ9NHzKJ6RchcsmIKWKY= k8s.io/client-go v0.35.3 h1:s1lZbpN4uI6IxeTM2cpdtrwHcSOBML1ODNTCCfsP1pg= k8s.io/client-go v0.35.3/go.mod h1:RzoXkc0mzpWIDvBrRnD+VlfXP+lRzqQjCmKtiwZ8Q9c= k8s.io/component-base v0.35.3 h1:mbKbzoIMy7JDWS/wqZobYW1JDVRn/RKRaoMQHP9c4P0=