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
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ require (
github.com/bytecodealliance/wasmtime-go v1.0.0
github.com/cpuguy83/go-md2man/v2 v2.0.7
github.com/go-errors/errors v1.5.1
github.com/google/cel-go v0.22.1
github.com/google/go-cmp v0.7.0
github.com/google/go-containerregistry v0.20.6
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
Expand Down
162 changes: 162 additions & 0 deletions internal/fnruntime/celeval.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
// Copyright 2026 The kpt and Nephio 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 fnruntime

import (
"context"
"fmt"

"github.com/google/cel-go/cel"
"github.com/google/cel-go/common/types"
"github.com/google/cel-go/common/types/ref"
"sigs.k8s.io/kustomize/kyaml/yaml"
)

// CELEvaluator evaluates CEL expressions against KRM resources
type CELEvaluator struct {
env *cel.Env
prg cel.Program // Pre-compiled program for the condition
}

// NewCELEvaluator creates a new CEL evaluator with the standard environment
// The environment is created once and reused for all evaluations
func NewCELEvaluator(condition string) (*CELEvaluator, error) {
env, err := cel.NewEnv(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a totally unprotected cel executor. There should be limitations on the number of CPU cycles it can consume, the amount of characters it can output, the max complexity of the ast.

cel.Variable("resources", cel.ListType(cel.DynType)),
Copy link
Contributor

@nagygergo nagygergo Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably advanced strings libraries would be good to include. https://pkg.go.dev/github.com/google/cel-go/ext#Strings

)
if err != nil {
return nil, fmt.Errorf("failed to create CEL environment: %w", err)
}

evaluator := &CELEvaluator{
env: env,
}

// Pre-compile the condition if provided
if condition != "" {
ast, issues := env.Compile(condition)
if issues != nil && issues.Err() != nil {
return nil, fmt.Errorf("failed to compile CEL expression: %w", issues.Err())
}

// Check AST complexity
if ast.SourceInfo().LineOffsets()[len(ast.SourceInfo().LineOffsets())-1] > 10000 {
return nil, fmt.Errorf("CEL expression too complex: exceeds maximum character limit")
}

// Create the program
prg, err := env.Program(ast)
if err != nil {
return nil, fmt.Errorf("failed to create CEL program: %w", err)
}

evaluator.prg = prg
}

return evaluator, nil
}

// EvaluateCondition evaluates a CEL condition expression against a list of resources
// Returns true if the condition is met, false otherwise
// The program is pre-compiled, so this just evaluates it with the given resources
func (e *CELEvaluator) EvaluateCondition(ctx context.Context, resources []*yaml.RNode) (bool, error) {
if e.prg == nil {
return true, nil
}

// Convert resources to a format suitable for CEL
resourceList, err := e.resourcesToList(resources)
Copy link
Contributor

@nagygergo nagygergo Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is serialising all the yaml.RNode actually needed? As it's a map[string]any type anyways (with no strange subtypes), probably the CEL interpreter can deal with it directly. Serialising the whole package for the cel execution, then not reusing it can cause a significant memory footprint bloat.

if err != nil {
return false, fmt.Errorf("failed to convert resources: %w", err)
}

// Evaluate the expression
out, _, err := e.prg.Eval(map[string]interface{}{
"resources": resourceList,
})
if err != nil {
return false, fmt.Errorf("failed to evaluate CEL expression: %w", err)
}

// Extract the boolean result
result, ok := out.(types.Bool)
if !ok {
return false, fmt.Errorf("CEL expression must return a boolean, got %T", out)
}

return bool(result), nil
}

// resourcesToList converts RNodes to a list of maps for CEL evaluation
func (e *CELEvaluator) resourcesToList(resources []*yaml.RNode) ([]interface{}, error) {
result := make([]interface{}, 0, len(resources))

for _, resource := range resources {
// Convert each resource to a map
resourceMap, err := e.resourceToMap(resource)
if err != nil {
return nil, err
}
result = append(result, resourceMap)
}

return result, nil
}

// resourceToMap converts a single RNode to a map for CEL evaluation
// RNode internally uses map[string]interface{}, so we can access it directly
func (e *CELEvaluator) resourceToMap(resource *yaml.RNode) (map[string]interface{}, error) {
// RNode.YNode() returns the underlying yaml.Node which contains the data
// We can work with it directly without serialization
var result map[string]interface{}

// Get the YAML string representation only if needed
yamlStr, err := resource.String()
if err != nil {
return nil, fmt.Errorf("failed to convert resource to string: %w", err)
}

// Parse into a generic map
err = yaml.Unmarshal([]byte(yamlStr), &result)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal resource: %w", err)
}

return result, nil
}

// Helper functions for common CEL operations

// ResourceExists checks if any resource matches the given predicate
// This is exposed as a CEL macro/function
func ResourceExists(resources []interface{}, predicate func(interface{}) bool) bool {
for _, r := range resources {
if predicate(r) {
return true
}
}
return false
}

// FilterResources filters resources based on a predicate
func FilterResources(resources []interface{}, predicate func(interface{}) bool) []interface{} {
result := make([]interface{}, 0)
for _, r := range resources {
if predicate(r) {
result = append(result, r)
}
}
return result
}
200 changes: 200 additions & 0 deletions internal/fnruntime/celeval_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
// Copyright 2026 The kpt and Nephio 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 fnruntime

import (
"context"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sigs.k8s.io/kustomize/kyaml/yaml"
)

Copy link
Contributor

@nagygergo nagygergo Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a testcase that makes sure that the cel functions can't mutate the resourcelist that is the input. The function signature can allow for it, as it hands over the *yaml.RNode list.

func TestNewCELEvaluator(t *testing.T) {
eval, err := NewCELEvaluator("true")
require.NoError(t, err)
assert.NotNil(t, eval)
assert.NotNil(t, eval.env)
assert.NotNil(t, eval.prg)
}

func TestNewCELEvaluator_EmptyCondition(t *testing.T) {
eval, err := NewCELEvaluator("")
require.NoError(t, err)
assert.NotNil(t, eval)
assert.NotNil(t, eval.env)
assert.Nil(t, eval.prg)
}

func TestEvaluateCondition_EmptyCondition(t *testing.T) {
eval, err := NewCELEvaluator("")
require.NoError(t, err)

result, err := eval.EvaluateCondition(context.Background(), nil)
require.NoError(t, err)
assert.True(t, result, "empty condition should return true")
}

func TestEvaluateCondition_SimpleTrue(t *testing.T) {
eval, err := NewCELEvaluator("true")
require.NoError(t, err)

result, err := eval.EvaluateCondition(context.Background(), nil)
require.NoError(t, err)
assert.True(t, result)
}

func TestEvaluateCondition_SimpleFalse(t *testing.T) {
eval, err := NewCELEvaluator("false")
require.NoError(t, err)

result, err := eval.EvaluateCondition(context.Background(), nil)
require.NoError(t, err)
assert.False(t, result)
}

func TestEvaluateCondition_ResourceExists(t *testing.T) {
// Create test resources
configMapYAML := `
apiVersion: v1
kind: ConfigMap
metadata:
name: test-config
data:
key: value
`
deploymentYAML := `
apiVersion: apps/v1
kind: Deployment
metadata:
name: test-deployment
spec:
replicas: 3
`

configMap, err := yaml.Parse(configMapYAML)
require.NoError(t, err)
deployment, err := yaml.Parse(deploymentYAML)
require.NoError(t, err)

resources := []*yaml.RNode{configMap, deployment}

// Test: ConfigMap exists
condition := `resources.exists(r, r.kind == "ConfigMap" && r.metadata.name == "test-config")`
eval, err := NewCELEvaluator(condition)
require.NoError(t, err)
result, err := eval.EvaluateCondition(context.Background(), resources)
require.NoError(t, err)
assert.True(t, result, "should find the ConfigMap")

// Test: ConfigMap with wrong name doesn't exist
condition = `resources.exists(r, r.kind == "ConfigMap" && r.metadata.name == "wrong-name")`
eval, err = NewCELEvaluator(condition)
require.NoError(t, err)
result, err = eval.EvaluateCondition(context.Background(), resources)
require.NoError(t, err)
assert.False(t, result, "should not find ConfigMap withwrong name")

// Test: Deployment exists
condition = `resources.exists(r, r.kind == "Deployment")`
eval, err = NewCELEvaluator(condition)
require.NoError(t, err)
result, err = eval.EvaluateCondition(context.Background(), resources)
require.NoError(t, err)
assert.True(t, result, "should find the Deployment")
}

func TestEvaluateCondition_ResourceCount(t *testing.T) {
// Create test resources
deploymentYAML := `
apiVersion: apps/v1
kind: Deployment
metadata:
name: test-deployment
spec:
replicas: 3
`

deployment, err := yaml.Parse(deploymentYAML)
require.NoError(t, err)

resources := []*yaml.RNode{deployment}

// Test: Count of deployments is greater than 0
condition := `resources.filter(r, r.kind == "Deployment").size() > 0`
eval, err := NewCELEvaluator(condition)
require.NoError(t, err)
result, err := eval.EvaluateCondition(context.Background(), resources)
require.NoError(t, err)
assert.True(t, result, "should find deployments")

// Test: Count of ConfigMaps is 0
condition = `resources.filter(r, r.kind == "ConfigMap").size() == 0`
eval, err = NewCELEvaluator(condition)
require.NoError(t, err)
result, err = eval.EvaluateCondition(context.Background(), resources)
require.NoError(t, err)
assert.True(t, result, "should not find ConfigMaps")
}

func TestEvaluateCondition_InvalidExpression(t *testing.T) {
// Test invalid syntax
_, err := NewCELEvaluator("this is not valid CEL")
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to compile")
}

func TestEvaluateCondition_NonBooleanResult(t *testing.T) {
// Expression that returns a number, not a boolean
_, err := NewCELEvaluator("1 + 1")
assert.Error(t, err)
assert.Contains(t, err.Error(), "must return a boolean")
}

// TestEvaluateCondition_Immutability ensures CEL evaluation cannot mutate the input resources
func TestEvaluateCondition_Immutability(t *testing.T) {
configMapYAML := `
apiVersion: v1
kind: ConfigMap
metadata:
name: test-config
namespace: default
data:
key: original-value
`

configMap, err := yaml.Parse(configMapYAML)
require.NoError(t, err)

resources := []*yaml.RNode{configMap}

// Store original values
originalYAML, err := configMap.String()
require.NoError(t, err)

// Evaluate a condition that accesses the resources
condition := `resources.exists(r, r.kind == "ConfigMap")`
eval, err := NewCELEvaluator(condition)
require.NoError(t, err)

_, err = eval.EvaluateCondition(context.Background(), resources)
require.NoError(t, err)

// Verify resources haven't been mutated
afterYAML, err := configMap.String()
require.NoError(t, err)
assert.Equal(t, originalYAML, afterYAML, "CEL evaluation should not mutate input resources")
}
Loading