From f50608be7d7b43b316a0bd611201a286953d7bf1 Mon Sep 17 00:00:00 2001 From: Chris Randles Date: Sun, 3 May 2026 17:49:26 -0400 Subject: [PATCH] feat: [cel] add decoder, feature, wait helpers and live-cluster example Signed-off-by: Chris Randles --- cel/decoder/decoder.go | 76 +++++++++++++++ cel/decoder/decoder_test.go | 154 +++++++++++++++++++++++++++++++ cel/feature/feature.go | 100 ++++++++++++++++++++ cel/feature/feature_test.go | 97 +++++++++++++++++++ cel/feature/fetch.go | 67 ++++++++++++++ cel/wait/wait.go | 92 ++++++++++++++++++ cel/wait/wait_test.go | 55 +++++++++++ docs/cel-assertions.md | 179 ++++++++++++++++++++++++++++++++++++ examples/cel/cel_test.go | 165 ++++++++++++++++++++------------- examples/cel/main_test.go | 45 +++++++++ 10 files changed, 968 insertions(+), 62 deletions(-) create mode 100644 cel/decoder/decoder.go create mode 100644 cel/decoder/decoder_test.go create mode 100644 cel/feature/feature.go create mode 100644 cel/feature/feature_test.go create mode 100644 cel/feature/fetch.go create mode 100644 cel/wait/wait.go create mode 100644 cel/wait/wait_test.go create mode 100644 examples/cel/main_test.go 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/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 index 29fa1eb8..6b8872ac 100644 --- a/docs/cel-assertions.md +++ b/docs/cel-assertions.md @@ -13,9 +13,12 @@ This document proposes the design for a set of CEL ([Common Expression Language] * [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 @@ -173,6 +176,46 @@ 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: @@ -274,3 +317,139 @@ 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 index 3699d341..38951c50 100644 --- a/examples/cel/cel_test.go +++ b/examples/cel/cel_test.go @@ -14,105 +14,146 @@ See the License for the specific language governing permissions and limitations under the License. */ -// Package cel demonstrates the cel and cel/policy packages without a live -// cluster: every assertion is evaluated offline against a fixture object, -// the same way a unit test would exercise a ValidatingAdmissionPolicy -// without standing up an API server. package cel import ( + "context" "strings" "testing" + "time" - admissionregistrationv1 "k8s.io/api/admissionregistration/v1" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - celpkg "sigs.k8s.io/e2e-framework/cel" + 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" - "sigs.k8s.io/e2e-framework/klient/decoder" + 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" ) -// vapYAML is the same shape an operator would ship as a -// ValidatingAdmissionPolicy. policy.FromVAP turns it into a Policy that -// can be run offline against fixture objects. -const vapYAML = ` -apiVersion: admissionregistration.k8s.io/v1 -kind: ValidatingAdmissionPolicy +// 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: deployment-replicas -spec: - matchConstraints: - resourceRules: - - apiGroups: ["apps"] - apiVersions: ["v1"] - operations: ["CREATE","UPDATE"] - resources: ["deployments"] - validations: - - expression: "object.spec.replicas >= 1" - message: "replicas must be at least 1" - - expression: "object.spec.replicas <= 100" - message: "replicas must not exceed 100" + name: cel-cfg + namespace: cel-ns +data: + greeting: hello +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: cel-sa + namespace: cel-ns ` -func TestEvaluatorAssert(t *testing.T) { - ev, err := celpkg.NewEvaluator() +// 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) } - dep := newDeployment("cel-demo", 2) - if err := ev.Assert("object.spec.replicas >= 1", celpkg.ObjectBinding(dep)); err != nil { - t.Fatal(err) - } -} -func TestPolicyCheck(t *testing.T) { - ev, err := celpkg.NewEvaluator() - if err != nil { - t.Fatal(err) - } - pol := policy.Policy{ + // 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"}, }, } - if res := pol.Check(ev, newDeployment("cel-demo", 2)); !res.Passed() { - t.Fatal(res.Err()) - } - if res := pol.Check(ev, newDeployment("cel-demo", 0)); res.Passed() { - t.Fatal("expected validation failure for replicas=0") - } -} -func TestPolicyFromVAP(t *testing.T) { - ev, err := celpkg.NewEvaluator() - if err != nil { - t.Fatal(err) - } - var vap admissionregistrationv1.ValidatingAdmissionPolicy - if err := decoder.Decode(strings.NewReader(vapYAML), &vap); err != nil { - t.Fatal(err) - } - pol := policy.FromVAP(&vap) - if res := pol.Check(ev, newDeployment("cel-demo", 2)); !res.Passed() { - t.Fatal(res.Err()) - } + 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(name string, replicas int32) *appsv1.Deployment { +func newDeployment(namespace, name string, replicas int32) *appsv1.Deployment { labels := map[string]string{"app": "cel-example"} return &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{Name: name, Labels: labels}, + 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"}}, + 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)) +}