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 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/decoder/decoder.go b/cel/decoder/decoder.go new file mode 100644 index 00000000..40f87b97 --- /dev/null +++ b/cel/decoder/decoder.go @@ -0,0 +1,76 @@ +/* +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 decoder bridges klient/decoder with cel so CEL assertions +// can run over YAML or JSON manifests decoded through the framework's +// decoder. Two shapes are exposed: +// +// - HandlerFunc factories (AssertHandler, PolicyHandler) that plug into +// decoder.DecodeEach, DecodeEachFile, DecodeURL. +// - One-shot helpers (AssertYAML, AssertYAMLAll) for single-document and +// multi-document inputs when a caller doesn't need streaming. +package decoder + +import ( + "context" + "fmt" + "io" + + "sigs.k8s.io/e2e-framework/cel" + "sigs.k8s.io/e2e-framework/cel/policy" + kdecoder "sigs.k8s.io/e2e-framework/klient/decoder" + "sigs.k8s.io/e2e-framework/klient/k8s" +) + +// AssertHandler returns a decoder.HandlerFunc that asserts expr against +// every decoded object, binding the object to the `object` CEL variable. +// The first assertion failure halts decoding and surfaces as the return +// error of the DecodeEach / DecodeEachFile / DecodeURL call. +func AssertHandler(ev *cel.Evaluator, expr string) kdecoder.HandlerFunc { + return func(_ context.Context, obj k8s.Object) error { + return ev.Assert(expr, cel.ObjectBinding(obj)) + } +} + +// PolicyHandler returns a decoder.HandlerFunc that runs pol against every +// decoded object. All validations in pol are evaluated (not short-circuit) +// per object; a per-object failure halts decoding with a joined error. +func PolicyHandler(ev *cel.Evaluator, pol policy.Policy) kdecoder.HandlerFunc { + return func(_ context.Context, obj k8s.Object) error { + if res := pol.Check(ev, obj); !res.Passed() { + return res.Err() + } + return nil + } +} + +// AssertYAML decodes a single-document YAML or JSON manifest from manifest +// and asserts expr against the resulting object. +func AssertYAML(ev *cel.Evaluator, expr string, manifest io.Reader) error { + obj, err := kdecoder.DecodeAny(manifest) + if err != nil { + return fmt.Errorf("cel: decode: %w", err) + } + return ev.Assert(expr, cel.ObjectBinding(obj)) +} + +// AssertYAMLAll decodes a multi-document YAML or JSON stream and asserts +// expr against every object it contains. The first failure returns its +// error; callers wanting all failures at once should use PolicyHandler +// with a single-validation Policy and DecodeEach directly. +func AssertYAMLAll(ctx context.Context, ev *cel.Evaluator, expr string, manifest io.Reader) error { + return kdecoder.DecodeEach(ctx, manifest, AssertHandler(ev, expr)) +} diff --git a/cel/decoder/decoder_test.go b/cel/decoder/decoder_test.go new file mode 100644 index 00000000..4755732c --- /dev/null +++ b/cel/decoder/decoder_test.go @@ -0,0 +1,154 @@ +/* +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 decoder + +import ( + "context" + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "sigs.k8s.io/e2e-framework/cel" + "sigs.k8s.io/e2e-framework/cel/policy" + kdecoder "sigs.k8s.io/e2e-framework/klient/decoder" +) + +const singleDocYAML = ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: app-config + namespace: demo +data: + greeting: hello +` + +const multiDocYAML = ` +apiVersion: v1 +kind: ConfigMap +metadata: {name: a, namespace: demo} +data: {key: value-a} +--- +apiVersion: v1 +kind: ConfigMap +metadata: {name: b, namespace: demo} +data: {key: value-b} +--- +apiVersion: v1 +kind: ConfigMap +metadata: {name: c, namespace: demo} +data: {key: value-c} +` + +func TestAssertYAML_pass(t *testing.T) { + ev := newEv(t) + err := AssertYAML(ev, + `object.kind == "ConfigMap" && object.metadata.namespace == "demo"`, + strings.NewReader(singleDocYAML)) + if err != nil { + t.Fatalf("AssertYAML: %v", err) + } +} + +func TestAssertYAML_fail(t *testing.T) { + ev := newEv(t) + err := AssertYAML(ev, + `object.metadata.namespace == "prod"`, + strings.NewReader(singleDocYAML)) + if err == nil || !strings.Contains(err.Error(), "assertion failed") { + t.Fatalf("expected assertion failure, got %v", err) + } +} + +func TestAssertYAMLAll_allPass(t *testing.T) { + ev := newEv(t) + err := AssertYAMLAll(context.Background(), ev, + `object.kind == "ConfigMap"`, + strings.NewReader(multiDocYAML)) + if err != nil { + t.Fatalf("AssertYAMLAll: %v", err) + } +} + +func TestAssertYAMLAll_haltsOnFirstFailure(t *testing.T) { + // b/key is "value-b"; assertion requires it to equal "value-a". + ev := newEv(t) + err := AssertYAMLAll(context.Background(), ev, + `object.data.key == "value-a"`, + strings.NewReader(multiDocYAML)) + if err == nil { + t.Fatal("expected failure on second document") + } + if !strings.Contains(err.Error(), "assertion failed") { + t.Fatalf("want assertion failure, got %v", err) + } +} + +func TestPolicyHandler_perObject(t *testing.T) { + ev := newEv(t) + pol := policy.Policy{ + Name: "config-shape", + Validations: []policy.Validation{ + {Expression: `has(object.data) && size(object.data) > 0`, Message: "must have data"}, + {Expression: `object.metadata.namespace != ""`, Message: "must have namespace"}, + }, + } + err := kdecoder.DecodeEach(context.Background(), + strings.NewReader(multiDocYAML), + PolicyHandler(ev, pol)) + if err != nil { + t.Fatalf("PolicyHandler: %v", err) + } +} + +func TestAssertHandler_appliesPerObject(t *testing.T) { + // Use DecodeEach directly with AssertHandler — the standard streaming + // pattern a test author would write. + ev := newEv(t) + err := kdecoder.DecodeEach(context.Background(), + strings.NewReader(multiDocYAML), + AssertHandler(ev, `object.metadata.namespace == "demo"`)) + if err != nil { + t.Fatalf("DecodeEach + AssertHandler: %v", err) + } +} + +// TestDirect_AssertAgainstTypedObject sanity-checks that the same decoded +// object we evaluate via YAML also evaluates via ObjectBinding on a typed +// struct, so both paths stay consistent. +func TestDirect_AssertAgainstTypedObject(t *testing.T) { + cm := &corev1.ConfigMap{ + TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "ConfigMap"}, + ObjectMeta: metav1.ObjectMeta{Name: "app-config", Namespace: "demo"}, + Data: map[string]string{"greeting": "hello"}, + } + ev := newEv(t) + if err := ev.Assert(`object.data.greeting == "hello"`, cel.ObjectBinding(cm)); err != nil { + t.Fatalf("typed ObjectBinding: %v", err) + } +} + +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/feature/feature.go b/cel/feature/feature.go new file mode 100644 index 00000000..57230f61 --- /dev/null +++ b/cel/feature/feature.go @@ -0,0 +1,100 @@ +/* +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 feature adapts the cel primitives into features.Func values +// so CEL assertions read as a one-line Assess in a test. +package feature + +import ( + "context" + "testing" + + "sigs.k8s.io/e2e-framework/cel" + "sigs.k8s.io/e2e-framework/cel/policy" + "sigs.k8s.io/e2e-framework/klient/k8s" + "sigs.k8s.io/e2e-framework/pkg/envconf" + "sigs.k8s.io/e2e-framework/pkg/features" +) + +// BinderFunc produces a Bindings map for an assertion. The test environment +// is supplied so binders can fetch live objects from the cluster. +type BinderFunc func(context.Context, *envconf.Config) (cel.Bindings, error) + +// FetcherFunc returns a Kubernetes object to be bound as `object`. It is +// the common simpler case of BinderFunc for policy assertions that only +// look at one object. +type FetcherFunc func(context.Context, *envconf.Config) (k8s.Object, error) + +// Assert returns a features.Func that evaluates expr against the bindings +// produced by binder and fails the assessment via t.Fatal if the expression +// does not evaluate to true. +func Assert(ev *cel.Evaluator, expr string, binder BinderFunc) features.Func { + return func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + t.Helper() + bindings, err := binder(ctx, cfg) + if err != nil { + t.Fatalf("cel: bind: %v", err) + } + if err := ev.Assert(expr, bindings); err != nil { + t.Fatal(err) + } + return ctx + } +} + +// AssertPolicy fetches an object and runs every Validation in pol against +// it. Any failure ends the assessment; t.Fatal reports every failure so the +// test output shows the full admission story. +func AssertPolicy(ev *cel.Evaluator, pol policy.Policy, fetcher FetcherFunc) features.Func { + return func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + t.Helper() + obj, err := fetcher(ctx, cfg) + if err != nil { + t.Fatalf("cel: fetch: %v", err) + } + if res := pol.Check(ev, obj); !res.Passed() { + t.Fatal(res.Err()) + } + return ctx + } +} + +// AssertObject is a one-line shortcut for the common case: fetch target by +// name in the test's namespace (cfg.Namespace()), bind it to `object`, and +// assert expr against it. For a specific namespace use AssertObjectIn. +func AssertObject(ev *cel.Evaluator, expr string, target k8s.Object, name string) features.Func { + return Assert(ev, expr, AsBinder(Fetch(target, name, ""))) +} + +// AssertObjectIn is like AssertObject but fetches from the given namespace +// rather than cfg.Namespace(). Use for cluster-scoped resources (pass "") +// or to cross-reference a resource in another namespace. +func AssertObjectIn(ev *cel.Evaluator, expr string, target k8s.Object, name, namespace string) features.Func { + return Assert(ev, expr, AsBinder(Fetch(target, name, namespace))) +} + +// AssertPolicyOnObject is a shortcut for running pol against target fetched +// by name in cfg.Namespace(). For a specific namespace use +// AssertPolicyOnObjectIn. +func AssertPolicyOnObject(ev *cel.Evaluator, pol policy.Policy, target k8s.Object, name string) features.Func { + return AssertPolicy(ev, pol, Fetch(target, name, "")) +} + +// AssertPolicyOnObjectIn is like AssertPolicyOnObject but fetches from the +// given namespace. +func AssertPolicyOnObjectIn(ev *cel.Evaluator, pol policy.Policy, target k8s.Object, name, namespace string) features.Func { + return AssertPolicy(ev, pol, Fetch(target, name, namespace)) +} diff --git a/cel/feature/feature_test.go b/cel/feature/feature_test.go new file mode 100644 index 00000000..a90e6d64 --- /dev/null +++ b/cel/feature/feature_test.go @@ -0,0 +1,97 @@ +/* +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 feature + +import ( + "context" + "testing" + + appsv1 "k8s.io/api/apps/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "sigs.k8s.io/e2e-framework/cel" + "sigs.k8s.io/e2e-framework/cel/policy" + "sigs.k8s.io/e2e-framework/klient/k8s" + "sigs.k8s.io/e2e-framework/pkg/envconf" +) + +// These tests exercise the happy path of each adapter. Failure behavior is +// a thin t.Fatal call and is covered by the primitive tests in ../cel and +// ../cel/policy. The Go testing framework does not give us a practical way +// to capture t.Fatal without failing the parent test, which is itself a +// motivator for the pluggable-T work in #527. + +func dep(replicas, ready 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}, + Status: appsv1.DeploymentStatus{ReadyReplicas: ready}, + } +} + +func TestAssert_happyPath(t *testing.T) { + ev := newEv(t) + binder := func(_ context.Context, _ *envconf.Config) (cel.Bindings, error) { + return cel.ObjectBinding(dep(3, 3)), nil + } + fn := Assert(ev, "object.status.readyReplicas == object.spec.replicas", binder) + fn(context.Background(), t, &envconf.Config{}) +} + +func TestAssertPolicy_happyPath(t *testing.T) { + ev := newEv(t) + pol := policy.Policy{ + Name: "replicas", + Validations: []policy.Validation{ + {Expression: "object.spec.replicas >= 1"}, + {Expression: "object.spec.replicas <= 100"}, + }, + } + fetcher := func(_ context.Context, _ *envconf.Config) (k8s.Object, error) { + return dep(3, 3), nil + } + fn := AssertPolicy(ev, pol, fetcher) + fn(context.Background(), t, &envconf.Config{}) +} + +// TestAdapters_compileAsFeaturesFunc pins down that each adapter returns a +// features.Func value — i.e. the type is exactly what features.Builder.Assess +// accepts. If this compiles, the adapter surface matches the intended shape. +func TestAdapters_compileAsFeaturesFunc(t *testing.T) { + ev := newEv(t) + + binder := func(_ context.Context, _ *envconf.Config) (cel.Bindings, error) { + return cel.ObjectBinding(dep(1, 1)), nil + } + fetcher := func(_ context.Context, _ *envconf.Config) (k8s.Object, error) { + return dep(1, 1), nil + } + + // Assign to concrete function type to catch signature drift at compile time. + _ = Assert(ev, "true", binder) + _ = AssertPolicy(ev, policy.Policy{}, fetcher) +} + +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/feature/fetch.go b/cel/feature/fetch.go new file mode 100644 index 00000000..c7b242ea --- /dev/null +++ b/cel/feature/fetch.go @@ -0,0 +1,67 @@ +/* +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 feature + +import ( + "context" + "fmt" + + "sigs.k8s.io/e2e-framework/cel" + "sigs.k8s.io/e2e-framework/klient/k8s" + "sigs.k8s.io/e2e-framework/pkg/envconf" +) + +// Fetch returns a FetcherFunc that retrieves the named resource into target +// on every call. target is a non-nil pointer to a k8s.Object of the expected +// Kind; its fields are populated in place and the same pointer is returned. +// +// If namespace is empty, cfg.Namespace() is used. For cluster-scoped +// resources, pass an empty namespace explicitly and use cfg.Namespace() +// only when your test wants that default. +func Fetch(target k8s.Object, name, namespace string) FetcherFunc { + return func(ctx context.Context, cfg *envconf.Config) (k8s.Object, error) { + if target == nil { + return nil, fmt.Errorf("cel: fetch: target is nil") + } + ns := namespace + if ns == "" { + ns = cfg.Namespace() + } + client, err := cfg.NewClient() + if err != nil { + return nil, fmt.Errorf("cel: fetch: new client: %w", err) + } + if err := client.Resources().Get(ctx, name, ns, target); err != nil { + return nil, fmt.Errorf("cel: fetch %s/%s: %w", ns, name, err) + } + return target, nil + } +} + +// AsBinder adapts a FetcherFunc into a BinderFunc that binds the fetched +// object to the `object` CEL variable. Use when a caller wants to compose +// several bindings (for example, object plus request) — construct each +// binding with the helper of its choice and merge them with cel.Bind. +func AsBinder(f FetcherFunc) BinderFunc { + return func(ctx context.Context, cfg *envconf.Config) (cel.Bindings, error) { + obj, err := f(ctx, cfg) + if err != nil { + return nil, err + } + return cel.ObjectBinding(obj), nil + } +} 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/cel/wait/wait.go b/cel/wait/wait.go new file mode 100644 index 00000000..190e6237 --- /dev/null +++ b/cel/wait/wait.go @@ -0,0 +1,92 @@ +/* +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 wait provides CEL-based conditions for use with klient/wait.For. +// Each condition refetches the target on every poll and evaluates a CEL +// expression against it, succeeding when the expression returns true. +package wait + +import ( + "context" + "strings" + + apimachinerywait "k8s.io/apimachinery/pkg/util/wait" + + "sigs.k8s.io/e2e-framework/cel" + "sigs.k8s.io/e2e-framework/klient/k8s" + "sigs.k8s.io/e2e-framework/klient/k8s/resources" +) + +// Match returns a ConditionWithContextFunc that refetches target from r on +// every poll and evaluates expr against it. The condition is satisfied when +// expr evaluates to true. +// +// On each poll target is Get-ted by name in namespace and its fields are +// populated in place. A Get error (including NotFound) is treated as "not +// yet" rather than a terminal failure so the poll keeps retrying until the +// overall wait.For timeout elapses. A CEL compile or type error IS +// terminal and halts the poll immediately. +func Match(r *resources.Resources, ev *cel.Evaluator, target k8s.Object, name, namespace, expr string) apimachinerywait.ConditionWithContextFunc { + return func(ctx context.Context) (bool, error) { + if err := r.Get(ctx, name, namespace, target); err != nil { + // Treat fetch errors as transient: the object may not exist + // yet. wait.For will stop polling on timeout. + return false, nil + } + if err := ev.Assert(expr, cel.ObjectBinding(target)); err != nil { + if isTerminal(err) { + return false, err + } + return false, nil + } + return true, nil + } +} + +// MatchAny is like Match but accepts caller-supplied bindings in addition +// to `object`. Use when the expression references more than the fetched +// target (for example a request binding or a second object). +// +// target is still fetched on every poll and bound to `object`; the extra +// bindings are merged in afterwards via cel.Bind (so they can override +// `object` if a caller really wants to). +func MatchAny(r *resources.Resources, ev *cel.Evaluator, target k8s.Object, name, namespace, expr string, extra cel.Bindings) apimachinerywait.ConditionWithContextFunc { + return func(ctx context.Context) (bool, error) { + if err := r.Get(ctx, name, namespace, target); err != nil { + return false, nil + } + bindings := cel.Bind(cel.ObjectBinding(target), extra) + if err := ev.Assert(expr, bindings); err != nil { + if isTerminal(err) { + return false, err + } + return false, nil + } + return true, nil + } +} + +// isTerminal reports whether a CEL error should stop polling outright +// rather than being retried. Compile errors and type errors cannot be +// fixed by retrying, so they halt immediately; a simple "assertion failed" +// just means the expression returned false and we should poll again. +func isTerminal(err error) bool { + if err == nil { + return false + } + msg := err.Error() + return strings.Contains(msg, "compile ") || strings.Contains(msg, "want bool") +} diff --git a/cel/wait/wait_test.go b/cel/wait/wait_test.go new file mode 100644 index 00000000..7aee08f5 --- /dev/null +++ b/cel/wait/wait_test.go @@ -0,0 +1,55 @@ +/* +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 wait + +import "testing" + +// The Match/MatchAny functions rely on *resources.Resources to fetch +// from a live API server, which is covered by the integration test in +// examples/cel. These unit tests exercise only the isTerminal helper — +// the logic that decides whether a CEL error halts polling. + +func TestIsTerminal_compileErrorTerminates(t *testing.T) { + err := mockError("cel: compile \"x >\": syntax error") + if !isTerminal(err) { + t.Fatal("compile error should terminate polling") + } +} + +func TestIsTerminal_typeErrorTerminates(t *testing.T) { + err := mockError("cel: expression \"1+1\" returned types.Int, want bool") + if !isTerminal(err) { + t.Fatal("type error should terminate polling") + } +} + +func TestIsTerminal_assertionFailureRetries(t *testing.T) { + err := mockError("cel: assertion failed: object.status.ready == object.spec.replicas") + if isTerminal(err) { + t.Fatal("assertion failure should retry, not terminate") + } +} + +func TestIsTerminal_nil(t *testing.T) { + if isTerminal(nil) { + t.Fatal("nil error should not be terminal") + } +} + +type mockError string + +func (m mockError) Error() string { return string(m) } diff --git a/docs/cel-assertions.md b/docs/cel-assertions.md new file mode 100644 index 00000000..6b8872ac --- /dev/null +++ b/docs/cel-assertions.md @@ -0,0 +1,455 @@ +# 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) + * [Feature Helpers](#Feature-Helpers) +6. [CEL Proposal](#CEL-Proposal) + * [Pre-defined Evaluators and Bindings](#pre-defined-evaluators-and-bindings) + * [Pre-defined Helpers](#Pre-defined-Helpers) + * [Wait Integration](#Wait-Integration) + * [Decoder Integration](#Decoder-Integration) + +## 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. + +### **Feature Helpers** + +A thin `cel/feature` sub-package adapts primitives into `features.Func` values, so CEL primitives stay test-framework agnostic. + +```go +type BinderFunc func(context.Context, *envconf.Config) (Bindings, error) +type FetcherFunc func(context.Context, *envconf.Config) (k8s.Object, error) + +func Assert(ev *Evaluator, expr string, binder BinderFunc) features.Func +func AssertPolicy(ev *Evaluator, pol Policy, fetcher FetcherFunc) features.Func +``` + +The two primitives above take a `BinderFunc` or `FetcherFunc` that the +caller supplies. For the 80% case — fetch a single named resource in the +test namespace and assert on it — a pair of shortcut helpers collapses +fetch + bind + assert into one call: + +```go +// AssertObject fetches target by name in cfg.Namespace() and asserts expr. +func AssertObject(ev *Evaluator, expr string, target k8s.Object, name string) features.Func +// AssertObjectIn is like AssertObject but uses the given namespace. +func AssertObjectIn(ev *Evaluator, expr string, target k8s.Object, name, namespace string) features.Func + +// AssertPolicyOnObject runs pol against target fetched by name in cfg.Namespace(). +func AssertPolicyOnObject(ev *Evaluator, pol Policy, target k8s.Object, name string) features.Func +func AssertPolicyOnObjectIn(ev *Evaluator, pol Policy, target k8s.Object, name, namespace string) features.Func +``` + +The low-level fetch helpers stay exported for callers composing multi-binding assertions: + +```go +// Fetch returns a FetcherFunc that Gets target by name from namespace. +// If namespace is empty, cfg.Namespace() is used. +func Fetch(target k8s.Object, name, namespace string) FetcherFunc +// AsBinder adapts a FetcherFunc into a BinderFunc binding `object`. +func AsBinder(f FetcherFunc) BinderFunc +``` + +Each helper wraps the underlying CEL call in a `features.Func`, calls `t.Fatal` on failure, and returns the context unchanged. + +## 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()) +} +``` + +`features.Func` adapters: + +```go +// Assert evaluates expr against the bindings produced by binder. +func Assert(ev *Evaluator, expr string, binder BinderFunc) features.Func +// AssertPolicy fetches an object and runs every validation in pol against it. +func AssertPolicy(ev *Evaluator, pol Policy, fetcher FetcherFunc) features.Func +``` + +Usage in a feature: + +```go +ev, _ := cel.NewEvaluator() + +f := features.New("deployment is fully ready"). + Assess("replicas match", feature.Assert( + ev, + "object.status.readyReplicas == object.spec.replicas", + fetchDeployment("demo", "default"), + )). + Feature() +``` + +Chaining multiple invariants on the same feature: + +```go +f := features.New("baseline conformance"). + Assess("replicas match", + feature.AssertObject(ev, + "object.status.readyReplicas == object.spec.replicas", + &appsv1.Deployment{}, "demo")). + Assess("at least one replica", + feature.AssertObject(ev, + "object.spec.replicas >= 1", + &appsv1.Deployment{}, "demo")). + Feature() +``` + +### Wait Integration + +`klient/wait.For` drives polling against a `ConditionWithContextFunc`. A +companion `cel/wait` package supplies conditions whose predicate is +expressed in CEL, so the same polling machinery that powers `wait.For` can +terminate on any CEL invariant without a hand-written matcher. + +```go +// Match returns a ConditionWithContextFunc that refetches target on every +// poll and evaluates expr against it. Succeeds when expr returns true. +func Match(r *resources.Resources, ev *cel.Evaluator, target k8s.Object, + name, namespace, expr string) apimachinerywait.ConditionWithContextFunc + +// MatchAny is like Match but accepts additional bindings (for example a +// `request` binding) that are merged in alongside `object`. +func MatchAny(r *resources.Resources, ev *cel.Evaluator, target k8s.Object, + name, namespace, expr string, extra cel.Bindings) apimachinerywait.ConditionWithContextFunc +``` + +Fetch errors (including `NotFound`) are treated as transient so the poll +keeps retrying until the overall `wait.For` timeout elapses. CEL compile +and type errors are terminal and halt the poll immediately. + +Usage, waiting for a Deployment to roll out: + +```go +err := wait.For( + celwait.Match(client.Resources(), ev, &appsv1.Deployment{}, "cel-demo", cfg.Namespace(), + "object.status.readyReplicas == object.spec.replicas"), + wait.WithTimeout(2*time.Minute), +) +``` + +Waiting with an extra binding: + +```go +err := wait.For( + celwait.MatchAny(client.Resources(), ev, &corev1.Pod{}, "demo", cfg.Namespace(), + `object.status.phase == "Running" && request.dryRun == false`, + cel.RequestBinding(&cel.AdmissionRequest{DryRun: false}), + ), + wait.WithTimeout(time.Minute), +) +``` + +### Decoder Integration + +`klient/decoder` already reads YAML/JSON into `k8s.Object` values (single +or multi-document, file, string, or URL). A `cel/decoder` sub-package +layers CEL assertions on top, with two shapes: `HandlerFunc` factories that +plug into streaming decoders, and one-shot helpers for the common cases. + +```go +// AssertHandler returns a decoder.HandlerFunc that asserts expr against +// every decoded object. +func AssertHandler(ev *cel.Evaluator, expr string) decoder.HandlerFunc + +// PolicyHandler returns a decoder.HandlerFunc that runs pol against every +// decoded object. +func PolicyHandler(ev *cel.Evaluator, pol policy.Policy) decoder.HandlerFunc + +// AssertYAML decodes a single-document manifest and asserts expr. +func AssertYAML(ev *cel.Evaluator, expr string, manifest io.Reader) error + +// AssertYAMLAll decodes a multi-document stream and asserts expr against +// every object, halting on the first failure. +func AssertYAMLAll(ctx context.Context, ev *cel.Evaluator, expr string, manifest io.Reader) error +``` + +Usage, asserting every object in a multi-document manifest has a namespace: + +```go +err := celdecoder.AssertYAMLAll(ctx, ev, + `object.metadata.namespace != ""`, + strings.NewReader(manifestYAML)) +``` + +Usage with an existing decoder stream: + +```go +err := decoder.DecodeEachFile(ctx, os.DirFS("testdata"), "*.yaml", + celdecoder.AssertHandler(ev, `object.kind != "Pod"`)) +``` + +Usage running a full policy per decoded object: + +```go +pol := policy.Policy{ + Name: "shape", + Validations: []policy.Validation{ + {Expression: `has(object.metadata.namespace)`, Message: "must set namespace"}, + {Expression: `object.metadata.name != ""`, Message: "must set name"}, + }, +} +err := decoder.DecodeEach(ctx, strings.NewReader(manifest), + celdecoder.PolicyHandler(ev, pol)) +``` diff --git a/examples/cel/cel_test.go b/examples/cel/cel_test.go new file mode 100644 index 00000000..38951c50 --- /dev/null +++ b/examples/cel/cel_test.go @@ -0,0 +1,161 @@ +/* +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 ( + "context" + "strings" + "testing" + "time" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + klientcel "sigs.k8s.io/e2e-framework/cel" + celdecoder "sigs.k8s.io/e2e-framework/cel/decoder" + celfeature "sigs.k8s.io/e2e-framework/cel/feature" + "sigs.k8s.io/e2e-framework/cel/policy" + celwait "sigs.k8s.io/e2e-framework/cel/wait" + "sigs.k8s.io/e2e-framework/klient/wait" + "sigs.k8s.io/e2e-framework/pkg/envconf" + "sigs.k8s.io/e2e-framework/pkg/features" +) + +// Manifest used by the YAML-assertion step. Multi-document to exercise the +// streaming path that a typical operator author would apply to their +// rendered Helm or kustomize output. +const manifestYAML = ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: cel-cfg + namespace: cel-ns +data: + greeting: hello +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: cel-sa + namespace: cel-ns +` + +// TestCELAssertions demonstrates the four use cases the cel package +// is designed around: +// +// 1. One-line assertion against a live object (feature.AssertObject). +// 2. Offline ValidatingAdmissionPolicy evaluation (feature.AssertPolicyOnObject). +// 3. wait.For backed by a CEL condition (celwait.Match). +// 4. CEL assertions against decoded YAML manifests (celdecoder.AssertYAMLAll). +func TestCELAssertions(t *testing.T) { + ev, err := klientcel.NewEvaluator() + if err != nil { + t.Fatal(err) + } + + // A small policy with the shape a ValidatingAdmissionPolicy would ship. + replicasPolicy := 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"}, + }, + } + + f := features.New("deployment/cel-assertions"). + Setup(func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + client, err := cfg.NewClient() + if err != nil { + t.Fatal(err) + } + dep := newDeployment(cfg.Namespace(), "cel-demo", 2) + if err := client.Resources().Create(ctx, dep); err != nil { + t.Fatal(err) + } + // Use a CEL condition as our readiness gate — on each poll the + // Deployment is refetched and the expression is re-evaluated. + err = wait.For( + celwait.Match(client.Resources(), ev, &appsv1.Deployment{}, "cel-demo", cfg.Namespace(), + "object.status.readyReplicas == object.spec.replicas"), + wait.WithTimeout(2*time.Minute), + ) + if err != nil { + t.Fatal(err) + } + return ctx + }). + Assess("live object passes single CEL assertion", + celfeature.AssertObject(ev, + "object.status.readyReplicas == object.spec.replicas", + &appsv1.Deployment{}, "cel-demo"), + ). + Assess("live object passes VAP-shaped policy offline", + celfeature.AssertPolicyOnObject(ev, replicasPolicy, + &appsv1.Deployment{}, "cel-demo"), + ). + Assess("live object has at least one replica", + celfeature.AssertObject(ev, + "object.spec.replicas >= 1", + &appsv1.Deployment{}, "cel-demo"), + ). + Assess("every object in a YAML manifest has a namespace", + func(ctx context.Context, t *testing.T, _ *envconf.Config) context.Context { + if err := celdecoder.AssertYAMLAll(ctx, ev, + `object.metadata.namespace != ""`, + strings.NewReader(manifestYAML), + ); err != nil { + t.Fatal(err) + } + return ctx + }, + ). + Teardown(func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + client, err := cfg.NewClient() + if err != nil { + t.Fatal(err) + } + _ = client.Resources().Delete(ctx, newDeployment(cfg.Namespace(), "cel-demo", 2)) + return ctx + }). + Feature() + + testenv.Test(t, f) +} + +func newDeployment(namespace, name string, replicas int32) *appsv1.Deployment { + labels := map[string]string{"app": "cel-example"} + return &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + 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/cel/main_test.go b/examples/cel/main_test.go new file mode 100644 index 00000000..48b16725 --- /dev/null +++ b/examples/cel/main_test.go @@ -0,0 +1,45 @@ +/* +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 ( + "os" + "testing" + + "sigs.k8s.io/e2e-framework/pkg/env" + "sigs.k8s.io/e2e-framework/pkg/envconf" + "sigs.k8s.io/e2e-framework/pkg/envfuncs" + "sigs.k8s.io/e2e-framework/support/kind" +) + +var testenv env.Environment + +func TestMain(m *testing.M) { + testenv = env.New() + kindClusterName := envconf.RandomName("cel-example", 16) + namespace := envconf.RandomName("cel-ns", 16) + + testenv.Setup( + envfuncs.CreateCluster(kind.NewProvider(), kindClusterName), + envfuncs.CreateNamespace(namespace), + ) + testenv.Finish( + envfuncs.DeleteNamespace(namespace), + envfuncs.DestroyCluster(kindClusterName), + ) + os.Exit(testenv.Run(m)) +} diff --git a/examples/go.mod b/examples/go.mod index 5e0ea73f..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/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 + 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 75b90235..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,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.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= 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=