Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions cel/decoder/decoder.go
Original file line number Diff line number Diff line change
@@ -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))
}
154 changes: 154 additions & 0 deletions cel/decoder/decoder_test.go
Original file line number Diff line number Diff line change
@@ -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
}
100 changes: 100 additions & 0 deletions cel/feature/feature.go
Original file line number Diff line number Diff line change
@@ -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))
}
Loading