feat: Add CEL-based conditional function execution (#4388)#4391
feat: Add CEL-based conditional function execution (#4388)#4391SurbhiAgarwal1 wants to merge 9 commits intokptdev:mainfrom
Conversation
✅ Deploy Preview for kptdocs ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
32fe3c0 to
3d2bde5
Compare
.agent/github_comment_4382.md
Outdated
There was a problem hiding this comment.
Did you mean to post these files on the issue rather than on the PR? It looks like AI generated content.
nagygergo
left a comment
There was a problem hiding this comment.
Hey, good to see a new contributor.
Some general comments:
- Clean up AI instructions.
- Add e2e tests.
- Add documentation.
Some specific comments are inline.
internal/fnruntime/celeval.go
Outdated
|
|
||
| // NewCELEvaluator creates a new CEL evaluator with the standard environment | ||
| func NewCELEvaluator() (*CELEvaluator, error) { | ||
| env, err := cel.NewEnv( |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
This is still unresolved. Please read up on how cost estimation works in cel environments. The goal is to protect the kpt and porch runtime from exploits using arbitrary code execution allowed here
There was a problem hiding this comment.
Hi @nagygergo! I've researched the CEL cost estimation API and found that env.EstimateCost() requires runtime size information about variables (like the resources list) which isn't available at compile time. When I tried using it with a nil estimator, it panics during size computation.
I see two approaches:
Option 1: Runtime Cost Tracking - Use cel.CostTracking() in the Program to enforce limits during evaluation (similar to Kubernetes ValidatingAdmissionPolicy)
Option 2: Custom Estimator - Implement checker.CostEstimator interface with estimated sizes for the resources variable
Which approach would you prefer? I'm happy to implement either one properly. All tests are currently passing with cel-go v0.26.0.
internal/fnruntime/runner.go
Outdated
| return NewFunctionRunner(ctx, fltr, pkgPath, fnResult, fnResults, opts) | ||
|
|
||
| // Initialize CEL evaluator if a condition is specified | ||
| var evaluator *CELEvaluator |
There was a problem hiding this comment.
Why do you need to create a new CEL env for each function evaluation? The env can be the same.
internal/fnruntime/runner.go
Outdated
| pr := printer.FromContextOrDie(fr.ctx) | ||
|
|
||
| // Check condition before executing function | ||
| if fr.condition != "" && fr.evaluator != nil { |
There was a problem hiding this comment.
Why check if fr.evaluator exists or not. If the function runner was created with a condition appearing for it's Runner, then must have an evaluator. It's ok to run to a panic if it doesn't exist at this point.
| "github.com/stretchr/testify/require" | ||
| "sigs.k8s.io/kustomize/kyaml/yaml" | ||
| ) | ||
|
|
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Hi @nagygergo! Done! I've added TestEvaluateCondition_Immutability that verifies CEL functions can't mutate the input resource list.
The test creates a ConfigMap, stores the original YAML, evaluates a CEL condition, then compares before/after to ensure no mutation. Even though the function signature accepts *yaml.RNode list (which could be mutated), CEL only receives converted map[string]interface{} copies, so the original RNodes remain unchanged.
|
|
||
| // Create function runner with condition | ||
| fnResult := &fnresult.Result{} | ||
| fnResults := &fnresult.ResultList{} |
There was a problem hiding this comment.
Are these needed for testware when initialising it?
internal/fnruntime/celeval.go
Outdated
| // NewCELEvaluator creates a new CEL evaluator with the standard environment | ||
| func NewCELEvaluator() (*CELEvaluator, error) { | ||
| env, err := cel.NewEnv( | ||
| cel.Variable("resources", cel.ListType(cel.DynType)), |
There was a problem hiding this comment.
Probably advanced strings libraries would be good to include. https://pkg.go.dev/github.com/google/cel-go/ext#Strings
internal/fnruntime/celeval.go
Outdated
| } | ||
|
|
||
| // Evaluate the expression | ||
| out, _, err := prg.Eval(map[string]interface{}{ |
There was a problem hiding this comment.
There should be a context passed to this to protect against long-hanging operations.
internal/fnruntime/celeval.go
Outdated
| } | ||
|
|
||
| // Convert resources to a format suitable for CEL | ||
| resourceList, err := e.resourcesToList(resources) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Hi @nagygergo! I've fixed the RNode serialization issue you mentioned.
Changed from resource.Map() (which serializes to YAML and back) to using resource.YNode().Decode() directly. This avoids the intermediate serialization step and reduces memory overhead.
The new implementation:
- Gets the underlying yaml.Node directly via
YNode() - Decodes it to
map[string]interface{}without serialization - Maintains the same CEL evaluation behavior
All tests are passing with this change.
5f1dce7 to
98ac05d
Compare
|
Thanks @nagygergo for the detailed review! I've addressed all the feedback: Changes Made:1. Cleaned up AI-generated files
2. Added CEL protection
3. Reuse CEL environment
4. Added context parameter
5. Removed unnecessary nil check
6. Added immutability test
7. Updated all tests
8. Added documentation
9. Resource serialization
10. fnResult/fnResults in tests
All review comments have been addressed. Let me know if you need any other changes! |
cce25d3 to
e0c5faa
Compare
|
@SurbhiAgarwal1 please fix the build issues. I'll get back to reviewing it after the build and tests are passing. |
155590a to
fc9326d
Compare
nagygergo
left a comment
There was a problem hiding this comment.
Hey, added some comments.
One thing that's missing as an exact recommendation is how to reuse the same environment across multiple executions. Will try to look for the right object to bind it to. It's important becasue in porch's case, it makes sense to reuse the interpreter (env) multiple times, instead of spawning multiple.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 7 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
internal/fnruntime/celeval.go
Outdated
| "github.com/google/cel-go/cel" | ||
| "github.com/google/cel-go/common/types" | ||
| "github.com/google/cel-go/ext" | ||
| "k8s.io/apiserver/pkg/cel/library" | ||
| k8scellib "k8s.io/apiserver/pkg/cel/library" | ||
| "sigs.k8s.io/kustomize/kyaml/yaml" |
internal/fnruntime/celeval.go
Outdated
| ) | ||
| const checkFrequency = 100 | ||
|
|
||
| // This gives about .1 seconds of CPU time for the evaultation to run |
| // of conditional function execution | ||
| func TestFunctionRunner_ConditionalExecution_E2E(t *testing.T) { | ||
| ctx := printer.WithContext(context.Background(), printer.New(nil, nil)) | ||
| _ = filesys.MakeFsInMemory() // Reserved for future use |
There was a problem hiding this comment.
Copilot reviewed 6 out of 7 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
internal/fnruntime/celeval.go
Outdated
| return evaluator, nil | ||
| } | ||
|
|
||
|
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 7 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
internal/fnruntime/celeval.go
Outdated
|
|
||
| const checkFrequency = 100 | ||
| // This gives about .1 seconds of CPU time for the evaluation to run | ||
| // This gives about .1 seconds of CPU time for the evaluation to run |
internal/fnruntime/celeval.go
Outdated
| k8scellib "k8s.io/apiserver/pkg/cel/library" | ||
| "sigs.k8s.io/kustomize/kyaml/yaml" | ||
| ) | ||
|
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 7 changed files in this pull request and generated 1 comment.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // | ||
| // Example: Check if a specific ConfigMap exists: | ||
| // condition: "resources.exists(r, r.kind == 'ConfigMap' && r.metadata.name == 'my-config')" | ||
| // | ||
| // Example: Check resource count: | ||
| // condition: "resources.filter(r, r.kind == 'Deployment').size() > 0" |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 7 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // If evaluates to false, the function is skipped. | ||
| // | ||
| // Example: Check if a specific ConfigMap exists: | ||
| // condition: "resources.exists(r, r.kind == 'ConfigMap' && r.metadata.name == 'my-config')" |
| // resources in the package and should return a boolean value. | ||
| // If omitted or evaluates to true, the function executes normally. | ||
| // If evaluates to false, the function is skipped. | ||
| // |
There was a problem hiding this comment.
Copilot reviewed 6 out of 7 changed files in this pull request and generated no new comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 7 changed files in this pull request and generated no new comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Instead of this test, please add e2e testcases to e2e/testdata/fn-render
|
Great progress so far.
Looked a bit into the porch codebase as well. I'd say that the best way to initialise a CEL environment would be that This would allow for porch to be more memory efficient. Also, please move e2e tests to Then we can get to writing the documentation. I'm not overly familiar on how to extend it, so I'll need to do a bit of reading on it. I'll come back on it on Monday/Tuesday if that's ok. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 16 out of 17 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 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 with wrong 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") | ||
| } |
| 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") |
| 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.
Pull request overview
Copilot reviewed 16 out of 17 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
internal/fnruntime/celeval_test.go
Outdated
| "github.com/stretchr/testify/require" | ||
| "sigs.k8s.io/kustomize/kyaml/yaml" | ||
|
|
||
| "github.com/GoogleContainerTools/kpt/internal/fnruntime/runneroptions" |
internal/fnruntime/celeval_test.go
Outdated
| assert.NotNil(t, env.Env) | ||
| } | ||
|
|
||
| func TestNewCELEvaluator_EmptyCondition(t *testing.T) { | ||
| env, err := runneroptions.NewCELEnvironment() | ||
| require.NoError(t, err) | ||
| assert.NotNil(t, env) | ||
| assert.NotNil(t, env.Env) | ||
| } | ||
|
|
||
| func TestEvaluateCondition_EmptyCondition(t *testing.T) { |
internal/fnruntime/celeval_test.go
Outdated
| // 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 with wrong 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) |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 15 out of 16 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| } | ||
|
|
||
| // Set condition; the shared CEL environment from opts is used at evaluation time. | ||
| if f.Condition != "" { |
| func (opts *RunnerOptions) InitDefaults(defaultImagePrefix string) { | ||
| opts.ImagePullPolicy = IfNotPresentPull | ||
| opts.ResolveToImage = opts.ResolveToImageForCLIFunc(defaultImagePrefix) | ||
| celEnv, err := NewCELEnvironment() | ||
| if err != nil { | ||
| // CEL environment creation should never fail with the standard config; | ||
| // panic here surfaces misconfiguration immediately rather than silently skipping conditions. | ||
| panic(fmt.Sprintf("failed to initialise CEL environment: %v", err)) | ||
| } | ||
| opts.CELEnvironment = celEnv | ||
| } | ||
|
|
| // CELEvaluator is an alias for runneroptions.CELEnvironment so that runner.go | ||
| // can reference it within the fnruntime package without an import cycle. |
| // `Condition` is an optional CEL expression that determines whether this | ||
| // function should be executed. The expression is evaluated against the KRM | ||
| // resources in the package and should return a boolean value. | ||
| // If omitted or evaluates to true, the function executes normally. | ||
| // If evaluates to false, the function is skipped. | ||
| // | ||
| // Example: Check if a specific ConfigMap exists: | ||
| // condition: "resources.exists(r, r.kind == 'ConfigMap' && r.metadata.name == 'my-config')" | ||
| // | ||
| // Example: Check resource count: | ||
| // condition: "resources.filter(r, r.kind == 'Deployment').size() > 0" | ||
| Condition string `yaml:"condition,omitempty" json:"condition,omitempty"` |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 18 out of 19 changed files in this pull request and generated no new comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Adds support for CEL expressions in Kptfile pipeline functions via a new 'condition' field. Functions with a condition are only executed if the CEL expression evaluates to true against the current resource list. - Add CELEvaluator in internal/fnruntime/celeval.go with k8s CEL extensions - Integrate condition check in FunctionRunner.Filter (runner.go) - Append skipped result to fnResults when condition is not met - Add 'condition' field to kptfile/v1 Function type - Update executor and runneroptions to support condition passing - Add e2e and unit tests for conditional execution - Add k8s.io/apiserver dependency for CEL library extensions Signed-off-by: Surbhi <agarwalsurbhi1807@gmail.com>
Replace k8s apiserver CEL library functions with cel-go built-in ext package equivalents. The k8s-specific functions (IP, CIDR, Quantity, SemVer, etc.) are not needed for basic KRM resource filtering and the heavy dependency was causing CI build failures. Signed-off-by: Surbhi <agarwalsurbhi1807@gmail.com>
…valuateCondition call - Move CELEvaluator/CELEnvironment to pkg/lib/runneroptions to avoid import cycle - Rename NewCELEvaluator(condition) -> NewCELEnvironment() (no condition param) - Remove pre-compiled prg field; compile program inside EvaluateCondition per call - Add CELEnvironment field to RunnerOptions, populated in InitDefaults - celeval.go now just type-aliases runneroptions.CELEnvironment - Update runner.go to use opts.CELEnvironment and pass condition string at eval time - Update unit tests to use new API - Add e2e testdata under e2e/testdata/fn-render/condition/ Signed-off-by: Surbhi <agarwalsurbhi1807@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Surbhi <agarwalsurbhi1807@gmail.com>
Remove internal/fnruntime/conditional_e2e_test.go and replace with testdata-driven e2e tests under e2e/testdata/fn-render/condition/: - condition-met: function executes when CEL condition is true - condition-not-met: function is skipped when CEL condition is false Signed-off-by: Surbhi <agarwalsurbhi1807@gmail.com>
- Run go mod tidy to drop k8s.io/apiserver (was causing docker/podman CI failure) - Fix celeval_test.go: correct import path to github.com/kptdev/kpt/pkg/lib/runneroptions - Update tests to use new EvaluateCondition(ctx, condition, resources) API Signed-off-by: Surbhi <agarwalsurbhi1807@gmail.com>
RunnerOptions.CELEnvironment is now populated by InitDefaults, so nil it out before struct comparison just like ResolveToImage. Signed-off-by: Surbhi <agarwalsurbhi1807@gmail.com>
Fix 1: ensure kind/apiVersion/metadata always present in CEL resource map - resourceToMap now guarantees these keys exist so CEL expressions like r.kind == 'Deployment' return false instead of 'no such key' error Fix 2: add .krmignore to condition test dirs - .expected/ was being picked up by kpt fn render as a KRM resource - .krmignore with '.expected' excludes it, matching all other test cases Signed-off-by: Surbhi <agarwalsurbhi1807@gmail.com>
Signed-off-by: Surbhi <agarwalsurbhi1807@gmail.com>
|
Hi @nagygergo, I've addressed all your feedback — CELEnvironment in RunnerOptions, InitDefaults creates it, program compilation moved to EvaluateCondition, e2e tests moved to e2e/testdata/fn-render/condition/, and k8s.io/apiserver removed from go.mod. The CI is still showing some test failures. I verified locally that TestCmd_flagAndArgParsing_Symlink and TestFunctionConfig/file_config also fail on upstream/main directly, so they appear to be pre-existing. Could you confirm whether the remaining CI failures are blocking from your side, or if they're known flaky/pre-existing tests? |
Implements #4388
Changes
conditionfield to Function schema for CEL expressionsExample Usage