-
Notifications
You must be signed in to change notification settings - Fork 253
feat: Add CEL-based conditional function execution (#4388) #4391
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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( | ||
| cel.Variable("resources", cel.ListType(cel.DynType)), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
| } | ||
| 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" | ||
| ) | ||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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") | ||
| } | ||
There was a problem hiding this comment.
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.