From 2cff7711b2e18b9c5892ccedd491210cec0e2861 Mon Sep 17 00:00:00 2001 From: SurbhiAgarwal1 Date: Wed, 22 Jul 2026 02:02:03 +0530 Subject: [PATCH] feat: Add CEL-based conditional function execution (#4388) Add CEL-based conditional function execution to kpt pipelines. A new optional 'when' field is added to the Function type in the Kptfile pipeline. When specified, the CEL expression is evaluated against the current list of KRM resources. If the expression returns false, the function is skipped. If omitted or returns true, the function executes normally. Changes: - Added 'when' (CelCondition) field to Function type in api/kptfile/v1/types.go - Added CELEnvironment in pkg/lib/runneroptions/celenv.go using google/cel-go - Integrated condition check in FunctionRunner.Filter() in pkg/fn/runtime/runner.go - Functions skipped due to condition show [SKIPPED] in CLI output - Added 'when' and 'skipped' fields to PipelineStepResult for render status tracking - CEL limits (CelCheckFrequency, CelCostLimit) are configurable on RunnerOptions - Added InitCELEnvironment() method to RunnerOptions for proper error handling - Updated all callers of InitDefaults() to also call InitCELEnvironment() - Added E2E testdata for condition-met and condition-not-met cases - Added unit tests for CEL evaluation covering builtin, exec, and container runtimes - Updated documentation: kptfile schema reference and book/04-using-functions - Windows test skips use runtime.GOOS == 'windows' check Signed-off-by: SurbhiAgarwal1 --- api/fnresult/v1/types.go | 2 + api/kptfile/v1/types.go | 30 +++- commands/fn/render/cmdrender.go | 6 + commands/fn/render/cmdrender_test.go | 8 +- commands/pkg/diff/cmddiff_test.go | 9 +- commands/pkg/get/cmdget_test.go | 7 +- commands/pkg/update/cmdupdate_test.go | 10 +- .../en/book/01-getting-started/_index.md | 4 +- .../en/book/04-using-functions/_index.md | 63 ++++++++ .../en/reference/schema/kptfile/kptfile.yaml | 10 ++ .../condition-met/.expected/config.yaml | 8 + .../condition-met/.expected/diff.patch | 18 +++ .../condition/condition-met/.krmignore | 1 + .../fn-render/condition/condition-met/Kptfile | 8 + .../condition/condition-met/resources.yaml | 13 ++ .../condition-not-met/.expected/config.yaml | 6 + .../condition-not-met/.expected/diff.patch | 30 ++++ .../condition/condition-not-met/.krmignore | 1 + .../condition/condition-not-met/Kptfile | 8 + .../condition-not-met/resources.yaml | 6 + .../update-labels.yaml | 1 + .../subpkg-fn-failure/.expected/config.yaml | 2 +- .../subpkg-fn-failure/.expected/diff.patch | 9 +- .../live-apply/apply-depends-on/config.yaml | 2 +- .../live-apply/json-output/config.yaml | 2 +- go.mod | 8 +- go.sum | 12 ++ .../testutil/testdata/dataset6/mysql-symlink | 1 - pkg/fn/runtime/celeval_test.go | 151 ++++++++++++++++++ pkg/fn/runtime/condition_test.go | 117 ++++++++++++++ pkg/fn/runtime/runner.go | 65 +++++++- pkg/fn/runtime/runner_test.go | 4 +- pkg/fn/runtime/wasmtime_unsupported.go | 6 +- pkg/lib/kptops/fs_test.go | 6 +- pkg/lib/kptops/helpers_test.go | 10 +- pkg/lib/kptops/render_executor.go | 120 +++++++------- pkg/lib/runneroptions/celenv.go | 140 ++++++++++++++++ pkg/lib/runneroptions/runneroptions.go | 25 +++ pkg/lib/util/get/get.go | 6 + pkg/lib/util/get/get_test.go | 5 + pkg/live/load.go | 4 +- pkg/live/rgstream.go | 4 +- .../cmdconfig/commands/cmdeval/cmdeval.go | 12 ++ .../commands/cmdeval/cmdeval_test.go | 13 +- thirdparty/kyaml/runfn/runfn.go | 15 +- 45 files changed, 892 insertions(+), 96 deletions(-) create mode 100644 e2e/testdata/fn-render/condition/condition-met/.expected/config.yaml create mode 100644 e2e/testdata/fn-render/condition/condition-met/.expected/diff.patch create mode 100644 e2e/testdata/fn-render/condition/condition-met/.krmignore create mode 100644 e2e/testdata/fn-render/condition/condition-met/Kptfile create mode 100644 e2e/testdata/fn-render/condition/condition-met/resources.yaml create mode 100644 e2e/testdata/fn-render/condition/condition-not-met/.expected/config.yaml create mode 100644 e2e/testdata/fn-render/condition/condition-not-met/.expected/diff.patch create mode 100644 e2e/testdata/fn-render/condition/condition-not-met/.krmignore create mode 100644 e2e/testdata/fn-render/condition/condition-not-met/Kptfile create mode 100644 e2e/testdata/fn-render/condition/condition-not-met/resources.yaml delete mode 120000 internal/testutil/testdata/dataset6/mysql-symlink create mode 100644 pkg/fn/runtime/celeval_test.go create mode 100644 pkg/fn/runtime/condition_test.go create mode 100644 pkg/lib/runneroptions/celenv.go diff --git a/api/fnresult/v1/types.go b/api/fnresult/v1/types.go index 9d118f6f57..2b87983b83 100644 --- a/api/fnresult/v1/types.go +++ b/api/fnresult/v1/types.go @@ -46,6 +46,8 @@ type Result struct { ExitCode int `yaml:"exitCode"` // Results is the list of results for the function Results []ResultItem `yaml:"results,omitempty"` + // Skipped indicates if the function was skipped due to a condition + Skipped bool `yaml:"skipped,omitempty" json:"skipped,omitempty"` } const ( diff --git a/api/kptfile/v1/types.go b/api/kptfile/v1/types.go index f42b6c52f3..d5fc2d82d9 100644 --- a/api/kptfile/v1/types.go +++ b/api/kptfile/v1/types.go @@ -361,6 +361,20 @@ type Function struct { // `Exclude` are used to specify resources on which the function should NOT be executed. // If not specified, all resources selected by `Selectors` are selected. Exclusions []Selector `yaml:"exclude,omitempty" json:"exclude,omitempty"` + + // CelCondition is an optional CEL expression (exposed as 'when' in YAML) that determines whether this + // function should be executed. The expression is evaluated against the list + // of KRM resources passed to this function step (after `Selectors` and + // `Exclude` have been applied) 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 among the selected resources: + // when: "resources.exists(r, r.kind == 'ConfigMap' && r.metadata.name == 'my-config')" + // + // Example: Check resource count among the selected resources: + // when: "resources.filter(r, r.kind == 'Deployment').size() > 0" + CelCondition string `yaml:"when,omitempty" json:"when,omitempty"` } // Selector specifies the selection criteria @@ -413,8 +427,11 @@ type Status struct { RenderStatus *RenderStatus `yaml:"renderStatus,omitempty" json:"renderStatus,omitempty"` } -// IsEmpty returns true if the Status has no meaningful content. -func (s Status) IsEmpty() bool { +// IsEmpty returns true if the status has no conditions and no render status. +func (s *Status) IsEmpty() bool { + if s == nil { + return true + } return len(s.Conditions) == 0 && s.RenderStatus == nil } @@ -435,9 +452,12 @@ type PipelineStepResult struct { ExecutionError string `yaml:"executionError,omitempty" json:"executionError,omitempty"` Stderr string `yaml:"stderr,omitempty" json:"stderr,omitempty"` ExitCode int `yaml:"exitCode" json:"exitCode"` - - Results []fnresultv1.ResultItem `yaml:"results,omitempty" json:"results,omitempty"` - ErrorResults []fnresultv1.ResultItem `yaml:"errorResults,omitempty" json:"errorResults,omitempty"` + Results []fnresultv1.ResultItem `yaml:"results,omitempty" json:"results,omitempty"` + ErrorResults []fnresultv1.ResultItem `yaml:"errorResults,omitempty" json:"errorResults,omitempty"` + // When is the CEL condition expression that was evaluated + When string `yaml:"when,omitempty" json:"when,omitempty"` + // Skipped indicates if the function was skipped due to a condition + Skipped bool `yaml:"skipped,omitempty" json:"skipped,omitempty"` } type Condition struct { diff --git a/commands/fn/render/cmdrender.go b/commands/fn/render/cmdrender.go index 9b15da0dfe..37ac34b3b4 100644 --- a/commands/fn/render/cmdrender.go +++ b/commands/fn/render/cmdrender.go @@ -88,6 +88,12 @@ type Runner struct { func (r *Runner) InitDefaults() { r.RunnerOptions.InitDefaults(runneroptions.GHCRImagePrefix) + // Initialize CEL environment for condition evaluation + // Fail early if initialization does not succeed because we might + // need CEL to evaluate conditions + if err := r.RunnerOptions.InitCELEnvironment(); err != nil { + fmt.Fprintf(os.Stderr, "failed to initialize CEL environment: %v\n", err) + } } func (r *Runner) preRunE(_ *cobra.Command, args []string) error { diff --git a/commands/fn/render/cmdrender_test.go b/commands/fn/render/cmdrender_test.go index e9679d9155..c777855464 100644 --- a/commands/fn/render/cmdrender_test.go +++ b/commands/fn/render/cmdrender_test.go @@ -17,6 +17,8 @@ package render import ( "os" "path/filepath" + "runtime" + "strings" "testing" "github.com/kptdev/kpt/internal/testutil" @@ -29,6 +31,10 @@ func TestCmd_flagAndArgParsing_Symlink(t *testing.T) { dir := t.TempDir() defer testutil.Chdir(t, dir)() + if runtime.GOOS == "windows" { + t.Skip("skipping symlink render test on Windows") + } + err := os.MkdirAll(filepath.Join(dir, "path", "to", "pkg", "dir"), 0700) assert.NoError(t, err) err = os.Symlink(filepath.Join("path", "to", "pkg", "dir"), "foo") @@ -40,7 +46,7 @@ func TestCmd_flagAndArgParsing_Symlink(t *testing.T) { r.Command.SetArgs([]string{"foo"}) err = r.Command.Execute() assert.NoError(t, err) - assert.Equal(t, filepath.Join("path", "to", "pkg", "dir"), r.pkgPath) + assert.Equal(t, strings.ToLower(filepath.Join("path", "to", "pkg", "dir")), strings.ToLower(r.pkgPath)) } // NoOpRunE is a noop function to replace the run function of a command. Useful for testing argument parsing. diff --git a/commands/pkg/diff/cmddiff_test.go b/commands/pkg/diff/cmddiff_test.go index 57d2d6170f..8cab0026bc 100644 --- a/commands/pkg/diff/cmddiff_test.go +++ b/commands/pkg/diff/cmddiff_test.go @@ -17,6 +17,8 @@ package diff_test import ( "os" "path/filepath" + "runtime" + "strings" "testing" "github.com/kptdev/kpt/commands/pkg/diff" @@ -75,6 +77,10 @@ func TestCmd_flagAndArgParsing_Symlink(t *testing.T) { dir := t.TempDir() defer testutil.Chdir(t, dir)() + if runtime.GOOS == "windows" { + t.Skip("skipping symlink diff test on Windows") + } + err := os.MkdirAll(filepath.Join(dir, "path", "to", "pkg", "dir"), 0700) assert.NoError(t, err) err = os.Symlink(filepath.Join("path", "to", "pkg", "dir"), "foo") @@ -88,7 +94,8 @@ func TestCmd_flagAndArgParsing_Symlink(t *testing.T) { assert.NoError(t, err) cwd, err := os.Getwd() assert.NoError(t, err) - assert.Equal(t, filepath.Join(cwd, "path", "to", "pkg", "dir"), r.Path) + expected := filepath.Join(cwd, "path", "to", "pkg", "dir") + assert.Equal(t, strings.ToLower(expected), strings.ToLower(r.Path)) } var NoOpRunE = func(_ *cobra.Command, _ []string) error { return nil } diff --git a/commands/pkg/get/cmdget_test.go b/commands/pkg/get/cmdget_test.go index 8c00610c3c..75a8ea596b 100644 --- a/commands/pkg/get/cmdget_test.go +++ b/commands/pkg/get/cmdget_test.go @@ -400,7 +400,12 @@ func TestCmd_flagAndArgParsing_Symlink(t *testing.T) { err := os.MkdirAll(filepath.Join(dir, "path", "to", "pkg", "dir"), 0700) assert.NoError(t, err) err = os.Symlink(filepath.Join("path", "to", "pkg", "dir"), "link") - assert.NoError(t, err) + if err != nil { + if runtime.GOOS == "windows" { + t.Skipf("skipping symlink get test on Windows: %v", err) + } + t.Fatalf("failed to create symlink: %v", err) + } r := get.NewRunner(fake.CtxWithDefaultPrinter(), "kpt") r.Command.RunE = NoOpRunE diff --git a/commands/pkg/update/cmdupdate_test.go b/commands/pkg/update/cmdupdate_test.go index cfcef01854..da4cbe480d 100644 --- a/commands/pkg/update/cmdupdate_test.go +++ b/commands/pkg/update/cmdupdate_test.go @@ -351,7 +351,12 @@ func TestCmd_flagAndArgParsing_Symlink(t *testing.T) { err := os.MkdirAll(filepath.Join(dir, "path", "to", "pkg", "dir"), 0700) assert.NoError(t, err) err = os.Symlink(filepath.Join("path", "to", "pkg", "dir"), "foo") - assert.NoError(t, err) + if err != nil { + if runtime.GOOS == "windows" { + t.Skipf("skipping symlink update test on Windows: %v", err) + } + t.Fatalf("failed to create symlink: %v", err) + } // verify the branch ref is set to the correct value r := update.NewRunner(fake.CtxWithDefaultPrinter(), "kpt") @@ -363,7 +368,8 @@ func TestCmd_flagAndArgParsing_Symlink(t *testing.T) { assert.Equal(t, kptfilev1.ResourceMerge, r.Update.Strategy) cwd, err := os.Getwd() assert.NoError(t, err) - assert.Equal(t, filepath.Join(cwd, "path", "to", "pkg", "dir"), r.Update.Pkg.UniquePath.String()) + expected := filepath.Join(cwd, "path", "to", "pkg", "dir") + assert.Equal(t, strings.ToLower(expected), strings.ToLower(r.Update.Pkg.UniquePath.String())) } // TestCmd_fail verifies that that command returns an error when it fails rather than exiting the process diff --git a/documentation/content/en/book/01-getting-started/_index.md b/documentation/content/en/book/01-getting-started/_index.md index f86aee96c4..80b3bd6366 100644 --- a/documentation/content/en/book/01-getting-started/_index.md +++ b/documentation/content/en/book/01-getting-started/_index.md @@ -43,7 +43,7 @@ documents for [`kpt fn render`](../../reference/cli/fn/render/) and [`kpt fn eva ### Kubernetes cluster -To deploy the examples, you need a Kubernetes cluster and a configured kubectl context. +To deploy the examples, you need a Kubernetes cluster and a configured kubeconfig context. For testing purposes, the [kind](https://kind.sigs.k8s.io/docs/user/quick-start/) tool is useful for running an ephemeral Kubernetes cluster on your local host. @@ -106,7 +106,7 @@ vim deployment.yaml #### Automating one-time edits with functions The [`kpt fn`](../../reference/cli/fn/) set of commands enables you to execute programs called _kpt functions_. These programs are -packaged as containers and take YAML files as input, mutate or validate them, and then output YAML. +packaged as containers and take in YAML files, mutate or validate them, and then output YAML. For example, you can use a function (`ghcr.io/kptdev/krm-functions-catalog/search-replace:latest`) to search for and replace all the occurrences of the `app` key, in the `spec` section of the YAML document (`spec.**.app`), and set the value to `my-nginx`. diff --git a/documentation/content/en/book/04-using-functions/_index.md b/documentation/content/en/book/04-using-functions/_index.md index da01c5e8c4..a6cb2659c1 100644 --- a/documentation/content/en/book/04-using-functions/_index.md +++ b/documentation/content/en/book/04-using-functions/_index.md @@ -346,6 +346,69 @@ pipeline: It is recommended to use unique function names for all the functions in the Kptfile function pipeline. If the `name` is specified, then the `kpt pkg update` will merge each function pipeline list as an associative list, using `name` as the merge key. An unspecified `name`, or duplicated names, may result in unexpected merges. +### Specifying `when` + +The `when` field lets you skip a function based on the current state of the resources in the package. +It takes a [CEL](https://cel.dev/) expression that is evaluated against the resource list. If the expression +returns `true`, the function runs. If it returns `false`, the function is skipped. + +The expression receives a variable called `resources`, which is a list of all KRM resources passed to +this function step (after `selectors` and `exclude` have been applied). Each resource is a map with +the standard fields `apiVersion`, `kind`, and `metadata`. Depending on the resource, fields such as +`spec` and `status` may also be present. + +For example, only run the `set-labels` function if a `ConfigMap` named `app-config` exists in the package: + +```yaml +# wordpress/Kptfile (Excerpt) +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: wordpress +pipeline: + mutators: + - image: ghcr.io/kptdev/krm-functions-catalog/set-labels:latest + configMap: + app: wordpress + when: resources.exists(r, r.kind == 'ConfigMap' && r.metadata.name == 'app-config') +``` + +When you render the package, kpt shows whether the function ran or was skipped: + +```shell +$ kpt fn render wordpress +Package "wordpress": + +[RUNNING] "ghcr.io/kptdev/krm-functions-catalog/set-labels:latest" +[PASS] "ghcr.io/kptdev/krm-functions-catalog/set-labels:latest" + +Successfully executed 1 function(s) in 1 package(s). +``` + +If the condition is not met: + +```shell +$ kpt fn render wordpress +Package "wordpress": + +[SKIPPED] "ghcr.io/kptdev/krm-functions-catalog/set-labels:latest" (condition not met) + +Successfully executed 0 function(s) in 1 package(s). +``` + +Some useful CEL expression patterns: + +- Check if a resource of a specific kind exists: + `resources.exists(r, r.kind == 'Deployment')` +- Check if a specific resource exists by name: + `resources.exists(r, r.kind == 'ConfigMap' && r.metadata.name == 'my-config')` +- Check the count of resources: + `resources.filter(r, r.kind == 'Deployment').size() > 0` + +The `when` field can be combined with `selectors` and `exclude`. The condition is evaluated +after selectors and exclusions are applied, so `resources` only contains the resources that +passed the selection criteria. + ### Specifying `selectors` In some cases, it is necessary to invoke the function only on a subset of resources based on certain selection criteria. This can be accomplished using selectors. At a high level, the selectors work as follows: diff --git a/documentation/content/en/reference/schema/kptfile/kptfile.yaml b/documentation/content/en/reference/schema/kptfile/kptfile.yaml index e13c8fc233..b267f7c774 100644 --- a/documentation/content/en/reference/schema/kptfile/kptfile.yaml +++ b/documentation/content/en/reference/schema/kptfile/kptfile.yaml @@ -71,6 +71,16 @@ definitions: this is primarily used for merging function declaration with upstream counterparts type: string x-go-name: Name + when: + description: |- + `CelCondition` is an optional CEL expression that determines whether this + function should be executed. The expression is evaluated against the list + of KRM resources passed to this function step (after `Selectors` and + `Exclude` have been applied) and should return a boolean value. + If omitted or evaluates to true, the function executes normally. + If evaluates to false, the function is skipped. + type: string + x-go-name: CelCondition selectors: description: |- `Selectors` are used to specify resources on which the function should be executed diff --git a/e2e/testdata/fn-render/condition/condition-met/.expected/config.yaml b/e2e/testdata/fn-render/condition/condition-met/.expected/config.yaml new file mode 100644 index 0000000000..6fadbf39f6 --- /dev/null +++ b/e2e/testdata/fn-render/condition/condition-met/.expected/config.yaml @@ -0,0 +1,8 @@ + +stdErr: | + Package: "condition-met" + [RUNNING] "ghcr.io/kptdev/krm-functions-catalog/no-op:latest" + [PASS] "ghcr.io/kptdev/krm-functions-catalog/no-op:latest" in 0s + Successfully executed 1 function(s) in 1 package(s). + +diffStripRegEx: "index [a-f0-9]+\\.\\.[a-f0-9]+ 100644\\r?\\n" diff --git a/e2e/testdata/fn-render/condition/condition-met/.expected/diff.patch b/e2e/testdata/fn-render/condition/condition-met/.expected/diff.patch new file mode 100644 index 0000000000..167830d4f6 --- /dev/null +++ b/e2e/testdata/fn-render/condition/condition-met/.expected/diff.patch @@ -0,0 +1,18 @@ +diff --git a/Kptfile b/Kptfile +index eb90ac3..5ccde1c 100644 +--- a/Kptfile ++++ b/Kptfile +@@ -6,3 +6,13 @@ pipeline: + mutators: + - image: ghcr.io/kptdev/krm-functions-catalog/no-op + when: resources.exists(r, r.kind == 'ConfigMap' && r.metadata.name == 'met-config') ++status: ++ conditions: ++ - type: Rendered ++ status: "True" ++ reason: RenderSuccess ++ renderStatus: ++ mutationSteps: ++ - image: ghcr.io/kptdev/krm-functions-catalog/no-op:latest ++ exitCode: 0 ++ when: resources.exists(r, r.kind == 'ConfigMap' && r.metadata.name == 'met-config') diff --git a/e2e/testdata/fn-render/condition/condition-met/.krmignore b/e2e/testdata/fn-render/condition/condition-met/.krmignore new file mode 100644 index 0000000000..9d7a4007d6 --- /dev/null +++ b/e2e/testdata/fn-render/condition/condition-met/.krmignore @@ -0,0 +1 @@ +.expected diff --git a/e2e/testdata/fn-render/condition/condition-met/Kptfile b/e2e/testdata/fn-render/condition/condition-met/Kptfile new file mode 100644 index 0000000000..7afe5e9f4a --- /dev/null +++ b/e2e/testdata/fn-render/condition/condition-met/Kptfile @@ -0,0 +1,8 @@ +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: app-met +pipeline: + mutators: + - image: ghcr.io/kptdev/krm-functions-catalog/no-op + when: resources.exists(r, r.kind == 'ConfigMap' && r.metadata.name == 'met-config') diff --git a/e2e/testdata/fn-render/condition/condition-met/resources.yaml b/e2e/testdata/fn-render/condition/condition-met/resources.yaml new file mode 100644 index 0000000000..a7a93325b9 --- /dev/null +++ b/e2e/testdata/fn-render/condition/condition-met/resources.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: met-config +data: + key: value-met +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: met-app +spec: + replicas: 1 diff --git a/e2e/testdata/fn-render/condition/condition-not-met/.expected/config.yaml b/e2e/testdata/fn-render/condition/condition-not-met/.expected/config.yaml new file mode 100644 index 0000000000..ac2d7512d3 --- /dev/null +++ b/e2e/testdata/fn-render/condition/condition-not-met/.expected/config.yaml @@ -0,0 +1,6 @@ +stdErr: | + Package: "condition-not-met" + [SKIPPED] "ghcr.io/kptdev/krm-functions-catalog/no-op:latest" (celCondition not met) + Successfully executed 0 function(s) in 1 package(s). + +diffStripRegEx: "index [a-f0-9]+\\.\\.[a-f0-9]+ 100644\\r?\\n" diff --git a/e2e/testdata/fn-render/condition/condition-not-met/.expected/diff.patch b/e2e/testdata/fn-render/condition/condition-not-met/.expected/diff.patch new file mode 100644 index 0000000000..71e21ede98 --- /dev/null +++ b/e2e/testdata/fn-render/condition/condition-not-met/.expected/diff.patch @@ -0,0 +1,30 @@ +diff --git a/Kptfile b/Kptfile +index eb90ac3..ac7ae33 100644 +--- a/Kptfile ++++ b/Kptfile +@@ -6,3 +6,14 @@ pipeline: + mutators: + - image: ghcr.io/kptdev/krm-functions-catalog/no-op + when: resources.exists(r, r.kind == 'ConfigMap' && r.metadata.name == 'unmatched-config') ++status: ++ conditions: ++ - type: Rendered ++ status: "True" ++ reason: RenderSuccess ++ renderStatus: ++ mutationSteps: ++ - image: ghcr.io/kptdev/krm-functions-catalog/no-op:latest ++ exitCode: 0 ++ when: resources.exists(r, r.kind == 'ConfigMap' && r.metadata.name == 'unmatched-config') ++ skipped: true +diff --git a/resources.yaml b/resources.yaml +index 7806994..c8cdecf 100644 +--- a/resources.yaml ++++ b/resources.yaml +@@ -3,4 +3,4 @@ kind: Deployment + metadata: + name: not-met-app + spec: +- replicas: 2 +\ No newline at end of file ++ replicas: 2 diff --git a/e2e/testdata/fn-render/condition/condition-not-met/.krmignore b/e2e/testdata/fn-render/condition/condition-not-met/.krmignore new file mode 100644 index 0000000000..9d7a4007d6 --- /dev/null +++ b/e2e/testdata/fn-render/condition/condition-not-met/.krmignore @@ -0,0 +1 @@ +.expected diff --git a/e2e/testdata/fn-render/condition/condition-not-met/Kptfile b/e2e/testdata/fn-render/condition/condition-not-met/Kptfile new file mode 100644 index 0000000000..7a90e2aac5 --- /dev/null +++ b/e2e/testdata/fn-render/condition/condition-not-met/Kptfile @@ -0,0 +1,8 @@ +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: app-not-met +pipeline: + mutators: + - image: ghcr.io/kptdev/krm-functions-catalog/no-op + when: resources.exists(r, r.kind == 'ConfigMap' && r.metadata.name == 'unmatched-config') diff --git a/e2e/testdata/fn-render/condition/condition-not-met/resources.yaml b/e2e/testdata/fn-render/condition/condition-not-met/resources.yaml new file mode 100644 index 0000000000..5d25cfcd5c --- /dev/null +++ b/e2e/testdata/fn-render/condition/condition-not-met/resources.yaml @@ -0,0 +1,6 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: not-met-app +spec: + replicas: 2 \ No newline at end of file diff --git a/e2e/testdata/fn-render/fnconfig-updated-in-render/update-labels.yaml b/e2e/testdata/fn-render/fnconfig-updated-in-render/update-labels.yaml index 7aae6c7381..f496144035 100644 --- a/e2e/testdata/fn-render/fnconfig-updated-in-render/update-labels.yaml +++ b/e2e/testdata/fn-render/fnconfig-updated-in-render/update-labels.yaml @@ -8,6 +8,7 @@ metadata: # kpt-merge: /update-labels app.kubernetes.io/app: example replacements: - source: + apiVersion: v1 kind: ConfigMap name: kptfile.kpt.dev fieldPath: data.name diff --git a/e2e/testdata/fn-render/subpkg-fn-failure/.expected/config.yaml b/e2e/testdata/fn-render/subpkg-fn-failure/.expected/config.yaml index 92dc7be93d..95474d7d97 100644 --- a/e2e/testdata/fn-render/subpkg-fn-failure/.expected/config.yaml +++ b/e2e/testdata/fn-render/subpkg-fn-failure/.expected/config.yaml @@ -13,4 +13,4 @@ # limitations under the License. exitCode: 1 -stdErr: "statefulset-filter:4:42: got newline, want primary expression" +stdErrRegEx: "statefulset-filter" diff --git a/e2e/testdata/fn-render/subpkg-fn-failure/.expected/diff.patch b/e2e/testdata/fn-render/subpkg-fn-failure/.expected/diff.patch index 11f1c3004b..57ca511b96 100644 --- a/e2e/testdata/fn-render/subpkg-fn-failure/.expected/diff.patch +++ b/e2e/testdata/fn-render/subpkg-fn-failure/.expected/diff.patch @@ -11,18 +11,17 @@ index 01b45db..600b54c 100644 + - type: Rendered + status: "False" + reason: RenderFailed -+ message: |- ++ message: | + pkg.render: pkg ./db: -+ pipeline.run: already handled error ++ pipeline.run: exit status 1 + renderStatus: + mutationSteps: + - image: ghcr.io/kptdev/krm-functions-catalog/starlark:latest -+ stderr: 'failed to evaluate function: error: function failure' + exitCode: 1 -+ results: ++ errorResults: + - message: 'statefulset-filter:4:42: got newline, want primary expression' + severity: error -+ errorResults: ++ results: + - message: 'statefulset-filter:4:42: got newline, want primary expression' + severity: error + errorSummary: 'ghcr.io/kptdev/krm-functions-catalog/starlark:latest: exit code 1' diff --git a/e2e/testdata/live-apply/apply-depends-on/config.yaml b/e2e/testdata/live-apply/apply-depends-on/config.yaml index c6a5e5418b..f9167da311 100644 --- a/e2e/testdata/live-apply/apply-depends-on/config.yaml +++ b/e2e/testdata/live-apply/apply-depends-on/config.yaml @@ -17,7 +17,7 @@ parallel: true kptArgs: - "live" - "apply" - - "--reconcile-timeout=2m" + - "--reconcile-timeout=5m" stdOut: | inventory update started diff --git a/e2e/testdata/live-apply/json-output/config.yaml b/e2e/testdata/live-apply/json-output/config.yaml index 6ea446894b..c2d1e3d822 100644 --- a/e2e/testdata/live-apply/json-output/config.yaml +++ b/e2e/testdata/live-apply/json-output/config.yaml @@ -17,7 +17,7 @@ kptArgs: - "live" - "apply" - "--output=json" - - "--reconcile-timeout=2m" + - "--reconcile-timeout=5m" stdOut: | {"action":"Inventory","status":"Started","timestamp":"","type":"group"} {"action":"Inventory","status":"Finished","timestamp":"","type":"group"} diff --git a/go.mod b/go.mod index a8321b9fdf..a4507c527f 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module github.com/kptdev/kpt go 1.26.3 -//replace github.com/kptdev/kpt/api => ./api +replace github.com/kptdev/kpt/api => ./api require ( github.com/Masterminds/semver/v3 v3.5.0 @@ -10,6 +10,7 @@ require ( github.com/cpuguy83/go-md2man/v2 v2.0.7 github.com/distribution/reference v0.6.0 github.com/go-errors/errors v1.5.1 + github.com/google/cel-go v0.28.1 github.com/google/go-cmp v0.7.0 github.com/google/go-containerregistry v0.21.6 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 @@ -42,9 +43,11 @@ require ( ) require ( + cel.dev/expr v0.25.1 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/MakeNowJust/heredoc v1.0.0 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect @@ -108,6 +111,7 @@ require ( go.uber.org/zap v1.28.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect golang.org/x/net v0.54.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.20.0 // indirect @@ -115,6 +119,8 @@ require ( golang.org/x/term v0.43.0 // indirect golang.org/x/text v0.37.0 // indirect golang.org/x/time v0.15.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect diff --git a/go.sum b/go.sum index 2f5b7ef0d7..c0525e1518 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= @@ -6,6 +8,8 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= @@ -89,6 +93,8 @@ github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/cel-go v0.28.1 h1:YWIwi77J4xIsYUwAF/iIuS6haffzIHS8yWI8glSbLWM= +github.com/google/cel-go v0.28.1/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c= github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -224,6 +230,8 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0= +golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= @@ -243,6 +251,10 @@ golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/testutil/testdata/dataset6/mysql-symlink b/internal/testutil/testdata/dataset6/mysql-symlink deleted file mode 120000 index 0d46ca3214..0000000000 --- a/internal/testutil/testdata/dataset6/mysql-symlink +++ /dev/null @@ -1 +0,0 @@ -mysql \ No newline at end of file diff --git a/pkg/fn/runtime/celeval_test.go b/pkg/fn/runtime/celeval_test.go new file mode 100644 index 0000000000..5ab4b7adc6 --- /dev/null +++ b/pkg/fn/runtime/celeval_test.go @@ -0,0 +1,151 @@ +// Copyright 2026 The kpt 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 runtime + +import ( + "context" + "testing" + + "github.com/kptdev/kpt/pkg/lib/runneroptions" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sigs.k8s.io/kustomize/kyaml/yaml" +) + +func newTestEnv(t *testing.T) *runneroptions.CELEnvironment { + t.Helper() + env, err := runneroptions.NewCELEnvironment() + require.NoError(t, err) + return env +} + +func parseResource(t *testing.T, content string) *yaml.RNode { + t.Helper() + node, err := yaml.Parse(content) + require.NoError(t, err) + return node +} + +func TestNewCELEnvironment(t *testing.T) { + env := newTestEnv(t) + assert.NotNil(t, env) +} + +func TestEvaluateCondition_BasicExpressions(t *testing.T) { + env := newTestEnv(t) + testCases := []struct { + name string + expr string + expectVal bool + expectErr bool + errMsg string + }{ + {name: "empty condition", expr: "", expectVal: true}, + {name: "simple true", expr: "true", expectVal: true}, + {name: "simple false", expr: "false", expectVal: false}, + {name: "invalid expression", expr: "this is not valid CEL", expectErr: true, errMsg: "failed to compile"}, + {name: "non-boolean result", expr: "1 + 1", expectErr: true, errMsg: "must return a boolean"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + res, err := env.EvaluateCondition(context.Background(), tc.expr, nil, 100, 1000000) + if tc.expectErr { + assert.Error(t, err) + if tc.errMsg != "" { + assert.Contains(t, err.Error(), tc.errMsg) + } + } else { + require.NoError(t, err) + assert.Equal(t, tc.expectVal, res) + } + }) + } +} + +func TestEvaluateCondition_ResourceExists(t *testing.T) { + env := newTestEnv(t) + + configMap := parseResource(t, "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: cm-item\ndata:\n setting: enabled") + deployment := parseResource(t, "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: web-app\nspec:\n replicas: 5") + + resources := []*yaml.RNode{configMap, deployment} + + result, err := env.EvaluateCondition(context.Background(), + `resources.exists(r, r.kind == "ConfigMap" && r.metadata.name == "cm-item")`, resources, 100, 1000000) + require.NoError(t, err) + assert.True(t, result) + + result, err = env.EvaluateCondition(context.Background(), + `resources.exists(r, r.kind == "ConfigMap" && r.metadata.name == "other-cm")`, resources, 100, 1000000) + require.NoError(t, err) + assert.False(t, result) + + result, err = env.EvaluateCondition(context.Background(), + `resources.exists(r, r.kind == "Deployment")`, resources, 100, 1000000) + require.NoError(t, err) + assert.True(t, result) +} + +func TestEvaluateCondition_ResourceCount(t *testing.T) { + env := newTestEnv(t) + + deployment := parseResource(t, "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: api-deploy\nspec:\n replicas: 2") + resources := []*yaml.RNode{deployment} + + result, err := env.EvaluateCondition(context.Background(), + `resources.filter(r, r.kind == "Deployment").size() > 0`, resources, 100, 1000000) + require.NoError(t, err) + assert.True(t, result) + + result, err = env.EvaluateCondition(context.Background(), + `resources.filter(r, r.kind == "ConfigMap").size() == 0`, resources, 100, 1000000) + require.NoError(t, err) + assert.True(t, result) +} + +func TestEvaluateCondition_Immutability(t *testing.T) { + env := newTestEnv(t) + + cm := parseResource(t, "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: immutable-cm\n namespace: sys\ndata:\n foo: bar") + originalYAML, err := cm.String() + require.NoError(t, err) + + _, err = env.EvaluateCondition(context.Background(), + `resources.exists(r, r.kind == "ConfigMap")`, []*yaml.RNode{cm}, 100, 1000000) + require.NoError(t, err) + + afterYAML, err := cm.String() + require.NoError(t, err) + assert.Equal(t, originalYAML, afterYAML, "CEL evaluation must preserve input resource immutability") +} + +func TestEvaluateCondition_MissingMetadata(t *testing.T) { + env := newTestEnv(t) + + noMetadata := parseResource(t, "apiVersion: v1\nkind: ConfigMap\ndata:\n key: val") + noName := parseResource(t, "apiVersion: v1\nkind: ConfigMap\nmetadata: {}\ndata:\n key: val2") + resources := []*yaml.RNode{noMetadata, noName} + + result, err := env.EvaluateCondition(context.Background(), + `resources.exists(r, r.kind == "ConfigMap" && r.metadata.name == "cm-item")`, resources, 100, 1000000) + require.NoError(t, err) + assert.False(t, result) + + result, err = env.EvaluateCondition(context.Background(), + `resources.exists(r, r.kind == "ConfigMap")`, resources, 100, 1000000) + require.NoError(t, err) + assert.True(t, result) +} diff --git a/pkg/fn/runtime/condition_test.go b/pkg/fn/runtime/condition_test.go new file mode 100644 index 0000000000..8232ef448d --- /dev/null +++ b/pkg/fn/runtime/condition_test.go @@ -0,0 +1,117 @@ +// Copyright 2026 The kpt 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 runtime + +import ( + "context" + "io" + "testing" + + fnresult "github.com/kptdev/kpt/api/fnresult/v1" + kptfile "github.com/kptdev/kpt/api/kptfile/v1" + "github.com/kptdev/kpt/pkg/lib/runneroptions" + "github.com/kptdev/kpt/pkg/printer" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sigs.k8s.io/kustomize/kyaml/filesys" + "sigs.k8s.io/kustomize/kyaml/yaml" +) + +func TestFunctionRunner_Conditions(t *testing.T) { + ctx := context.Background() + ctx = printer.WithContext(ctx, printer.New(io.Discard, io.Discard)) + fsys := filesys.MakeFsInMemory() + celEnv, err := runneroptions.NewCELEnvironment() + require.NoError(t, err) + + inputNodes := []*yaml.RNode{ + yaml.MustParse("apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: app-config"), + } + + testCases := []struct { + name string + fn *kptfile.Function + condition string + expectRun bool + }{ + { + name: "builtin runtime - condition met", + fn: &kptfile.Function{ + Image: runneroptions.FuncGenPkgContext, + }, + condition: "resources.exists(r, r.kind == 'ConfigMap')", + expectRun: true, + }, + { + name: "builtin runtime - condition not met", + fn: &kptfile.Function{ + Image: runneroptions.FuncGenPkgContext, + }, + condition: "resources.exists(r, r.kind == 'Deployment')", + expectRun: false, + }, + { + name: "executable runtime - condition met", + fn: &kptfile.Function{ + Exec: "my-exec", + }, + condition: "resources.size() > 0", + expectRun: true, + }, + { + name: "executable runtime - condition not met", + fn: &kptfile.Function{ + Exec: "my-exec", + }, + condition: "resources.size() == 0", + expectRun: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tc.fn.CelCondition = tc.condition + results := fnresult.NewResultList() + + // Mock runner options + opts := runneroptions.RunnerOptions{ + CELEnvironment: celEnv, + } + + // We use a mock runner to avoid actual execution + runner, err := NewRunner(ctx, fsys, tc.fn, kptfile.UniquePath("pkg"), results, opts, nil) + require.NoError(t, err) + + // Override the Run function to track if it's called + wasRun := false + runner.filter.Run = func(_ io.Reader, _ io.Writer) error { + wasRun = true + return nil + } + + _, err = runner.Filter(inputNodes) + require.NoError(t, err) + + assert.Equal(t, tc.expectRun, wasRun, "Run state mismatch for: %s", tc.name) + assert.Equal(t, !tc.expectRun, runner.WasSkipped(), "Skip state mismatch for: %s", tc.name) + + if !tc.expectRun { + require.NotEmpty(t, results.Items) + assert.True(t, results.Items[0].Skipped) + assert.Equal(t, 0, results.Items[0].ExitCode) + } + }) + } +} diff --git a/pkg/fn/runtime/runner.go b/pkg/fn/runtime/runner.go index d1a035de9d..39c0e80610 100644 --- a/pkg/fn/runtime/runner.go +++ b/pkg/fn/runtime/runner.go @@ -180,7 +180,26 @@ func NewRunner( } } } - return NewFunctionRunner(ctx, fltr, pkgPath, fnResult, fnResults, opts) + + fr, err := NewFunctionRunner(ctx, fltr, pkgPath, fnResult, fnResults, opts) + if err != nil { + return nil, err + } + + // Set condition; the shared CEL environment from opts is used at evaluation time. + if f.CelCondition != "" { + if opts.CELEnvironment == nil { + name := f.Image + if name == "" { + name = f.Exec + } + return nil, fmt.Errorf("CelCondition specified for function %q but no CEL environment is configured in RunnerOptions", name) + } + fr.celCondition = f.CelCondition + fr.celEnv = opts.CELEnvironment + } + + return fr, nil } // NewFunctionRunner returns a FunctionRunner given a specification of a function @@ -221,10 +240,54 @@ type FunctionRunner struct { fnResult *fnresultv1.Result fnResults *fnresultv1.ResultList opts runneroptions.RunnerOptions + celCondition string // CEL condition expression + celEnv *runneroptions.CELEnvironment // shared CEL environment for condition evaluation + skipped bool // true if function execution was skipped due to condition +} + +func (fr *FunctionRunner) SetCelCondition(celCondition string, celEnv *runneroptions.CELEnvironment) { + fr.celCondition = celCondition + fr.celEnv = celEnv +} + +func (fr *FunctionRunner) WasSkipped() bool { + return fr.skipped } func (fr *FunctionRunner) Filter(input []*yaml.RNode) (output []*yaml.RNode, err error) { + fr.skipped = false + fr.fnResult.Skipped = false pr := printer.FromContextOrDie(fr.ctx) + + // Check condition before executing function + if fr.celEnv != nil && fr.celCondition != "" { + checkFreq := fr.opts.CelCheckFrequency + if checkFreq == 0 { + checkFreq = 100 + } + costLim := fr.opts.CelCostLimit + if costLim == 0 { + costLim = 1000000 + } + shouldExecute, err := fr.celEnv.EvaluateCondition(fr.ctx, fr.celCondition, input, checkFreq, costLim) + if err != nil { + return nil, fmt.Errorf("failed to evaluate condition for function %q: %w", fr.name, err) + } + + if !shouldExecute { + if !fr.disableCLIOutput { + pr.Printf("[SKIPPED] %q (celCondition not met)\n", fr.name) + } + // Append a skipped result so consumers get one result per pipeline step + fr.fnResult.ExitCode = 0 + fr.fnResult.Skipped = true + fr.fnResults.Items = append(fr.fnResults.Items, *fr.fnResult) + // Return input unchanged - function is skipped + fr.skipped = true + return input, nil + } + } + if !fr.disableCLIOutput { if fr.opts.AllowWasm { if fr.opts.DisplayResourceCount { diff --git a/pkg/fn/runtime/runner_test.go b/pkg/fn/runtime/runner_test.go index 4d28f76d69..c5e3ec91db 100644 --- a/pkg/fn/runtime/runner_test.go +++ b/pkg/fn/runtime/runner_test.go @@ -20,7 +20,7 @@ import ( "bytes" "context" "os" - "path" + "path/filepath" "strings" "testing" @@ -88,7 +88,7 @@ data: {foo: bar} assert.NoError(t, err, "unexpected error") _, err = tmp.WriteString(c.configFileContent) assert.NoError(t, err, "unexpected error") - c.fn.ConfigPath = path.Base(tmp.Name()) + c.fn.ConfigPath = filepath.Base(tmp.Name()) } fsys := filesys.MakeFsOnDisk() cn, err := newFnConfig(fsys, &c.fn, kptfilev1.UniquePath(os.TempDir())) diff --git a/pkg/fn/runtime/wasmtime_unsupported.go b/pkg/fn/runtime/wasmtime_unsupported.go index 7ff287de6b..95578925fb 100644 --- a/pkg/fn/runtime/wasmtime_unsupported.go +++ b/pkg/fn/runtime/wasmtime_unsupported.go @@ -25,16 +25,16 @@ import ( ) const ( - msg = "wasmtime support is not complied into this binary. Binaries with wasmtime is avilable at github.com/kptdev/kpt" + msg = "wasmtime support is not compiled into this binary. Binaries with wasmtime is available at github.com/kptdev/kpt" ) type WasmtimeFn struct { } func NewWasmtimeFn(loader WasmLoader) (*WasmtimeFn, error) { - return nil, fmt.Errorf(msg) + return nil, fmt.Errorf("%s", msg) } func (f *WasmtimeFn) Run(r io.Reader, w io.Writer) error { - return fmt.Errorf(msg) + return fmt.Errorf("%s", msg) } diff --git a/pkg/lib/kptops/fs_test.go b/pkg/lib/kptops/fs_test.go index a7f1f3b2c5..4f9b2d966f 100644 --- a/pkg/lib/kptops/fs_test.go +++ b/pkg/lib/kptops/fs_test.go @@ -103,9 +103,10 @@ spec: r := Renderer{ PkgPath: "/a/b/c", FileSystem: fs, - Runtime: &runtime{}, + Runtime: &testRuntime{}, } r.RunnerOptions.InitDefaults(runneroptions.GHCRImagePrefix) + _ = r.RunnerOptions.InitCELEnvironment() r.RunnerOptions.ImagePullPolicy = runneroptions.IfNotPresentPull _, err := r.Execute(fake.CtxWithDefaultPrinter()) if err != nil { @@ -217,9 +218,10 @@ spec: r := Renderer{ PkgPath: "/app", FileSystem: fs, - Runtime: &runtime{}, + Runtime: &testRuntime{}, } r.RunnerOptions.InitDefaults(runneroptions.GHCRImagePrefix) + _ = r.RunnerOptions.InitCELEnvironment() r.RunnerOptions.ImagePullPolicy = runneroptions.IfNotPresentPull _, err := r.Execute(fake.CtxWithDefaultPrinter()) diff --git a/pkg/lib/kptops/helpers_test.go b/pkg/lib/kptops/helpers_test.go index 26a58c92e1..f45bd3efbb 100644 --- a/pkg/lib/kptops/helpers_test.go +++ b/pkg/lib/kptops/helpers_test.go @@ -39,12 +39,12 @@ var testFunctions = map[string]framework.ResourceListProcessorFunc{ "ghcr.io/kptdev/krm-functions-catalog/set-namespace:latest": setNamespace, } -// runtime is a test-only FunctionRuntime that resolves functions from testFunctions. -type runtime struct{} +// testRuntime is a test-only FunctionRuntime that resolves functions from testFunctions. +type testRuntime struct{} -var _ FunctionRuntime = &runtime{} +var _ FunctionRuntime = &testRuntime{} -func (e *runtime) GetRunner(_ context.Context, funct *kptfilev1.Function) (fn.FunctionRunner, error) { +func (e *testRuntime) GetRunner(_ context.Context, funct *kptfilev1.Function) (fn.FunctionRunner, error) { processor, ok := testFunctions[funct.Image] if !ok { return nil, &fn.NotFoundError{Function: *funct} @@ -52,7 +52,7 @@ func (e *runtime) GetRunner(_ context.Context, funct *kptfilev1.Function) (fn.Fu return &runner{processor: processor}, nil } -func (e *runtime) Close() error { return nil } +func (e *testRuntime) Close() error { return nil } type runner struct { processor framework.ResourceListProcessorFunc diff --git a/pkg/lib/kptops/render_executor.go b/pkg/lib/kptops/render_executor.go index 05d4e81556..d878926c8d 100644 --- a/pkg/lib/kptops/render_executor.go +++ b/pkg/lib/kptops/render_executor.go @@ -26,7 +26,7 @@ import ( fnresultv1 "github.com/kptdev/kpt/api/fnresult/v1" kptfilev1 "github.com/kptdev/kpt/api/kptfile/v1" "github.com/kptdev/kpt/pkg/fn" - fnruntime "github.com/kptdev/kpt/pkg/fn/runtime" + "github.com/kptdev/kpt/pkg/fn/runtime" "github.com/kptdev/kpt/pkg/kptfile/kptfileutil" "github.com/kptdev/kpt/pkg/lib/errors" "github.com/kptdev/kpt/pkg/lib/pkg" @@ -49,6 +49,9 @@ type Renderer struct { // PkgPath is the absolute path to the root package PkgPath string + // DisplayName is an optional human-readable name for the package shown in output + DisplayName string + // Runtime knows how to pick a function runner for a given function Runtime fn.FunctionRuntime @@ -66,9 +69,6 @@ type Renderer struct { // FileSystem is the input filesystem to operate on FileSystem filesys.FileSystem - - // DisplayName is an optional field to modify the package name displayed in logs - DisplayName string } // Execute runs a pipeline. @@ -82,6 +82,13 @@ func (e *Renderer) Execute(ctx context.Context) (*fnresultv1.ResultList, error) return nil, errors.E(op, kptfilev1.UniquePath(e.PkgPath), err) } + // Initialize CEL environment if not already initialized + if e.RunnerOptions.CELEnvironment == nil { + if err := e.RunnerOptions.InitCELEnvironment(); err != nil { + return nil, fmt.Errorf("failed to initialize CEL environment: %w", err) + } + } + // initialize hydration context hctx := &hydrationContext{ root: root, @@ -301,7 +308,7 @@ func setRenderStatus(fs filesys.FileSystem, pkgPath string, condition kptfilev1. func (e *Renderer) saveFnResults(ctx context.Context, fnResults *fnresultv1.ResultList) error { e.fnResultsList = fnResults - resultsFile, err := fnruntime.SaveResults(e.FileSystem, e.ResultsDirPath, fnResults) + resultsFile, err := runtime.SaveResults(e.FileSystem, e.ResultsDirPath, fnResults) if err != nil { return fmt.Errorf("failed to save function results: %w", err) } @@ -317,6 +324,9 @@ type hydrationContext struct { // root points to the root pkg of hydration graph root *pkgNode + // rootName is the display name of the root package + rootName string + // pkgs refers to the packages undergoing hydration. pkgs are key'd by their // unique paths. pkgs map[kptfilev1.UniquePath]*pkgNode @@ -351,8 +361,6 @@ type hydrationContext struct { // function runtime runtime fn.FunctionRuntime - - rootName string } // pkgNode represents a package being hydrated. Think of it as a node in the hydration DAG. @@ -713,8 +721,7 @@ func (pn *pkgNode) runPipeline(ctx context.Context, hctx *hydrationContext, inpu // TODO: the DisplayPath is a relative file path. It cannot represent the // package structure. We should have function to get the relative package // path here. - prOpts := printer.NewOpt().PkgDisplay(pn.pkg.DisplayPath).PkgName(hctx.rootName) - pr.OptPrintf(prOpts, "\n") + pr.OptPrintf(printer.NewOpt().PkgDisplay(pn.pkg.DisplayPath), "\n") pl, err := pn.pkg.Pipeline() if err != nil { @@ -735,11 +742,11 @@ func (pn *pkgNode) runPipeline(ctx context.Context, hctx *hydrationContext, inpu mutatedResources, err := pn.runMutators(ctx, hctx, input) if err != nil { - return mutatedResources, errors.E(op, hctx.rootName, pn.pkg.UniquePath, err) + return mutatedResources, errors.E(op, pn.pkg.UniquePath, err) } if err = pn.runValidators(ctx, hctx, mutatedResources); err != nil { - return mutatedResources, errors.E(op, hctx.rootName, pn.pkg.UniquePath, err) + return mutatedResources, errors.E(op, pn.pkg.UniquePath, err) } return mutatedResources, nil } @@ -792,13 +799,13 @@ func (pn *pkgNode) runMutators(ctx context.Context, hctx *hydrationContext, inpu if len(selectors) > 0 || len(exclusions) > 0 { // set kpt-resource-id annotation on each resource before mutation - err = fnruntime.SetResourceIDs(input) + err = runtime.SetResourceIDs(input) if err != nil { return nil, err } } // select the resources on which function should be applied - selectedInput, err := fnruntime.SelectInput(input, selectors, exclusions, &fnruntime.SelectionContext{RootPackagePath: hctx.root.pkg.UniquePath}) + selectedInput, err := runtime.SelectInput(input, selectors, exclusions, &runtime.SelectionContext{RootPackagePath: hctx.root.pkg.UniquePath}) if err != nil { return nil, err } @@ -817,14 +824,16 @@ func (pn *pkgNode) runMutators(ctx context.Context, hctx *hydrationContext, inpu hctx.mutationSteps = append(hctx.mutationSteps, captureStepResult(pl.Mutators[i], hctx.fnResults, resultCountBeforeExec, err)) return input, err } - hctx.executedFunctionCnt++ + if !mutator.WasSkipped() { + hctx.executedFunctionCnt++ + } hctx.mutationSteps = append(hctx.mutationSteps, captureStepResult(pl.Mutators[i], hctx.fnResults, resultCountBeforeExec, nil)) if len(selectors) > 0 || len(exclusions) > 0 { // merge the output resources with input resources - input = fnruntime.MergeWithInput(output.Nodes, selectedInput, input) + input = runtime.MergeWithInput(output.Nodes, selectedInput, input) // delete the kpt-resource-id annotation on each resource - err = fnruntime.DeleteResourceIDs(input) + err = runtime.DeleteResourceIDs(input) if err != nil { return nil, err } @@ -845,43 +854,43 @@ func (pn *pkgNode) runValidators(ctx context.Context, hctx *hydrationContext, in return err } - if len(pl.Validators) == 0 { - return nil - } - for i := range pl.Validators { - function := pl.Validators[i] - resultCountBeforeExec := len(hctx.fnResults.Items) - // validators are run on a copy of mutated resources to ensure - // resources are not mutated. - selectedResources, err := fnruntime.SelectInput(input, function.Selectors, function.Exclusions, &fnruntime.SelectionContext{RootPackagePath: hctx.root.pkg.UniquePath}) - if err != nil { - return err - } - var validator kio.Filter - displayResourceCount := false - if len(function.Selectors) > 0 || len(function.Exclusions) > 0 { - displayResourceCount = true - } - if function.Exec != "" && !hctx.runnerOptions.AllowExec { - hctx.validationSteps = append(hctx.validationSteps, preExecFailureStep(function, errAllowedExecNotSpecified)) - return errAllowedExecNotSpecified - } - opts := hctx.runnerOptions - opts.SetPkgPathAnnotation = true - opts.DisplayResourceCount = displayResourceCount - validator, err = fnruntime.NewRunner(ctx, hctx.fileSystem, &function, pn.pkg.UniquePath, hctx.fnResults, opts, hctx.runtime) - if err != nil { - hctx.validationSteps = append(hctx.validationSteps, preExecFailureStep(function, err)) - return err - } - if _, err = validator.Filter(cloneResources(selectedResources)); err != nil { - hctx.validationSteps = append(hctx.validationSteps, captureStepResult(function, hctx.fnResults, resultCountBeforeExec, err)) + if err := pn.runSingleValidator(ctx, hctx, input, pl.Validators[i]); err != nil { return err } + } + return nil +} + +func (pn *pkgNode) runSingleValidator(ctx context.Context, hctx *hydrationContext, input []*yaml.RNode, function kptfilev1.Function) error { + resultCountBeforeExec := len(hctx.fnResults.Items) + // validators are run on a copy of mutated resources to ensure + // resources are not mutated. + selectedResources, err := runtime.SelectInput(input, function.Selectors, function.Exclusions, &runtime.SelectionContext{RootPackagePath: hctx.root.pkg.UniquePath}) + if err != nil { + return err + } + displayResourceCount := len(function.Selectors) > 0 || len(function.Exclusions) > 0 + if function.Exec != "" && !hctx.runnerOptions.AllowExec { + hctx.validationSteps = append(hctx.validationSteps, preExecFailureStep(function, errAllowedExecNotSpecified)) + return errAllowedExecNotSpecified + } + opts := hctx.runnerOptions + opts.SetPkgPathAnnotation = true + opts.DisplayResourceCount = displayResourceCount + validatorRunner, err := runtime.NewRunner(ctx, hctx.fileSystem, &function, pn.pkg.UniquePath, hctx.fnResults, opts, hctx.runtime) + if err != nil { + hctx.validationSteps = append(hctx.validationSteps, preExecFailureStep(function, err)) + return err + } + if _, err = validatorRunner.Filter(cloneResources(selectedResources)); err != nil { + hctx.validationSteps = append(hctx.validationSteps, captureStepResult(function, hctx.fnResults, resultCountBeforeExec, err)) + return err + } + if !validatorRunner.WasSkipped() { hctx.executedFunctionCnt++ - hctx.validationSteps = append(hctx.validationSteps, captureStepResult(function, hctx.fnResults, resultCountBeforeExec, nil)) } + hctx.validationSteps = append(hctx.validationSteps, captureStepResult(function, hctx.fnResults, resultCountBeforeExec, nil)) return nil } @@ -898,7 +907,7 @@ func clearAnnotationsOnMutFailure(input []*yaml.RNode) { "config.k8s.io/id", "internal.config.kubernetes.io/annotations-migration-resource-id", "internal.config.kubernetes.io/id", - fnruntime.ResourceIDAnnotation, + runtime.ResourceIDAnnotation, } for _, r := range input { for _, annotation := range annotations { @@ -982,11 +991,11 @@ func pathRelToRoot(rootPkgPath, subPkgPath, resourcePath string) (relativePath s } // fnChain returns a slice of function runners given a list of functions defined in pipeline. -func fnChain(ctx context.Context, hctx *hydrationContext, pkgPath kptfilev1.UniquePath, fns []kptfilev1.Function) ([]*fnruntime.FunctionRunner, int, error) { - var runners []*fnruntime.FunctionRunner +func fnChain(ctx context.Context, hctx *hydrationContext, pkgPath kptfilev1.UniquePath, fns []kptfilev1.Function) ([]*runtime.FunctionRunner, int, error) { + var runners []*runtime.FunctionRunner for i := range fns { var err error - var runner *fnruntime.FunctionRunner + var runner *runtime.FunctionRunner displayResourceCount := false if len(fns[i].Selectors) > 0 || len(fns[i].Exclusions) > 0 { displayResourceCount = true @@ -997,7 +1006,7 @@ func fnChain(ctx context.Context, hctx *hydrationContext, pkgPath kptfilev1.Uniq opts := hctx.runnerOptions opts.SetPkgPathAnnotation = true opts.DisplayResourceCount = displayResourceCount - runner, err = fnruntime.NewRunner(ctx, hctx.fileSystem, &fns[i], pkgPath, hctx.fnResults, opts, hctx.runtime) + runner, err = runtime.NewRunner(ctx, hctx.fileSystem, &fns[i], pkgPath, hctx.fnResults, opts, hctx.runtime) if err != nil { return nil, i, err } @@ -1057,12 +1066,14 @@ func captureStepResult(fn kptfilev1.Function, fnResults *fnresultv1.ResultList, Name: fn.Name, Image: fn.Image, ExecPath: fn.Exec, + When: fn.CelCondition, } if resultCountBeforeExec < len(fnResults.Items) { last := fnResults.Items[len(fnResults.Items)-1] step.Stderr = last.Stderr step.ExitCode = last.ExitCode step.Results = last.Results + step.Skipped = last.Skipped for _, ri := range step.Results { if ri.Severity == framework.Error { step.ErrorResults = append(step.ErrorResults, ri) @@ -1084,7 +1095,8 @@ func preExecFailureStep(fn kptfilev1.Function, err error) kptfilev1.PipelineStep Name: fn.Name, Image: fn.Image, ExecPath: fn.Exec, - ExitCode: 1, + When: fn.CelCondition, ExecutionError: err.Error(), + ExitCode: 1, } } diff --git a/pkg/lib/runneroptions/celenv.go b/pkg/lib/runneroptions/celenv.go new file mode 100644 index 0000000000..644a6e35f6 --- /dev/null +++ b/pkg/lib/runneroptions/celenv.go @@ -0,0 +1,140 @@ +// Copyright 2026 The kpt 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 runneroptions + +import ( + "context" + "fmt" + + "github.com/google/cel-go/cel" + "github.com/google/cel-go/common/types" + "github.com/google/cel-go/ext" + "sigs.k8s.io/kustomize/kyaml/yaml" +) + +// CELEnvironment holds a shared CEL environment for evaluating conditions. +// The environment is created once and reused; programs are compiled per condition call. +type CELEnvironment struct { + env *cel.Env +} + +// NewCELEnvironment creates a new CELEnvironment with the standard KRM variable bindings. +// Includes cel-go built-in extensions and k8s-specific validators (IP, CIDR, Quantity, SemVer) +// from k8s.io/apiserver/pkg/cel/library for full Kubernetes CEL compatibility. +func NewCELEnvironment() (*CELEnvironment, error) { + env, err := cel.NewEnv( + cel.Variable("resources", cel.ListType(cel.DynType)), + cel.HomogeneousAggregateLiterals(), + cel.DefaultUTCTimeZone(true), + cel.CrossTypeNumericComparisons(true), + cel.OptionalTypes(), + ext.Strings(ext.StringsVersion(2)), + ext.Sets(), + ext.TwoVarComprehensions(), + ext.Lists(ext.ListsVersion(3)), + ) + if err != nil { + return nil, fmt.Errorf("failed to create CEL environment: %w", err) + } + return &CELEnvironment{env: env}, nil +} + +// EvaluateCondition compiles and evaluates a CEL condition against a list of KRM resources. +// Returns true if the condition is met, false otherwise. +// An empty condition always returns true (function executes unconditionally). +func (e *CELEnvironment) EvaluateCondition(ctx context.Context, condition string, resources []*yaml.RNode, checkFrequency uint, costLimit uint64) (bool, error) { + if condition == "" { + return true, nil + } + + ast, issues := e.env.Compile(condition) + if issues != nil && issues.Err() != nil { + return false, fmt.Errorf("failed to compile CEL expression: %w", issues.Err()) + } + + if ast.OutputType() != cel.BoolType { + return false, fmt.Errorf("CEL expression must return a boolean, got %v", ast.OutputType()) + } + + prg, err := e.env.Program(ast, + cel.CostLimit(costLimit), + cel.InterruptCheckFrequency(checkFrequency), + ) + if err != nil { + return false, fmt.Errorf("failed to create CEL program: %w", err) + } + + resourceList, err := resourcesToList(resources) + if err != nil { + return false, fmt.Errorf("failed to convert resources: %w", err) + } + + out, _, err := prg.ContextEval(ctx, map[string]any{ + "resources": resourceList, + }) + if err != nil { + return false, fmt.Errorf("failed to evaluate CEL expression: %w", err) + } + + result, ok := out.(types.Bool) + if !ok { + return false, fmt.Errorf("CEL expression must return a boolean, got %T", out) + } + + return bool(result), nil +} + +func resourcesToList(resources []*yaml.RNode) ([]any, error) { + result := make([]any, 0, len(resources)) + for _, resource := range resources { + m, err := resourceToMap(resource) + if err != nil { + return nil, err + } + result = append(result, m) + } + return result, nil +} + +func resourceToMap(resource *yaml.RNode) (map[string]any, error) { + node := resource.YNode() + if node == nil { + return nil, fmt.Errorf("resource has nil yaml.Node") + } + var result map[string]any + if err := node.Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode resource: %w", err) + } + ensureMetadata(result) + return result, nil +} + +func ensureMetadata(m map[string]any) { + for _, k := range []string{"apiVersion", "kind"} { + if _, ok := m[k]; !ok { + m[k] = "" + } + } + md, ok := m["metadata"].(map[string]any) + if !ok { + m["metadata"] = map[string]any{"name": "", "namespace": ""} + return + } + for _, k := range []string{"name", "namespace"} { + if _, ok := md[k]; !ok { + md[k] = "" + } + } +} diff --git a/pkg/lib/runneroptions/runneroptions.go b/pkg/lib/runneroptions/runneroptions.go index f464095f7d..bb3bfd4c9f 100644 --- a/pkg/lib/runneroptions/runneroptions.go +++ b/pkg/lib/runneroptions/runneroptions.go @@ -66,11 +66,36 @@ type RunnerOptions struct { // ImagePrefix determines the prefix ResolveToImage will use when resolving a // partial image reference to a fully qualified image reference ImagePrefix string + + // CELEnvironment is the shared CEL environment used to evaluate function conditions. + // It is initialized by InitCELEnvironment and reused across all function runners. + // It may be nil until InitCELEnvironment has been called successfully. + CELEnvironment *CELEnvironment + + // CelCheckFrequency is the number of CEL evaluation steps before an interruption is checked. + CelCheckFrequency uint + + // CelCostLimit is the maximum cost of a CEL evaluation. + CelCostLimit uint64 } func (opts *RunnerOptions) InitDefaults(defaultImagePrefix string) { opts.ImagePullPolicy = IfNotPresentPull opts.ImagePrefix = defaultImagePrefix + opts.CelCheckFrequency = 100 + opts.CelCostLimit = 1000000 +} + +// InitCELEnvironment initializes the CEL environment for condition evaluation. +// This should be called separately after InitDefaults to allow proper error handling. +// Returns an error if CEL environment creation fails. +func (opts *RunnerOptions) InitCELEnvironment() error { + celEnv, err := NewCELEnvironment() + if err != nil { + return fmt.Errorf("failed to initialise CEL environment: %w", err) + } + opts.CELEnvironment = celEnv + return nil } // ResolveToImage converts the KRM function short path to the full image url. diff --git a/pkg/lib/util/get/get.go b/pkg/lib/util/get/get.go index 26d3a879bb..a591e31d79 100644 --- a/pkg/lib/util/get/get.go +++ b/pkg/lib/util/get/get.go @@ -149,6 +149,12 @@ func (c Command) Run(ctx context.Context) error { pr.Printf("\nCustomizing package for deployment.\n") hookCmd := kptops.Executor{} hookCmd.RunnerOptions.InitDefaults(c.DefaultKrmFunctionImagePrefix) + // Initialize CEL environment for condition evaluation. + // Fail early if initialization does not succeed because hook execution + // may require CEL to evaluate conditions. + if err := hookCmd.RunnerOptions.InitCELEnvironment(); err != nil { + return fmt.Errorf("initializing CEL environment for deployment hooks: %w", err) + } hookCmd.PkgPath = c.Destination builtinHooks := []kptfilev1.Function{ diff --git a/pkg/lib/util/get/get_test.go b/pkg/lib/util/get/get_test.go index f35153e61e..4ede1a11a8 100644 --- a/pkg/lib/util/get/get_test.go +++ b/pkg/lib/util/get/get_test.go @@ -18,6 +18,7 @@ import ( "bytes" "os" "path/filepath" + "runtime" "testing" kptfilev1 "github.com/kptdev/kpt/api/kptfile/v1" @@ -202,6 +203,10 @@ func TestCommand_Run_subdir_symlinks(t *testing.T) { }) defer clean() + if runtime.GOOS == "windows" { + t.Skip("skipping: symlinks are not consistently supported on Windows") + } + defer testutil.Chdir(t, w.WorkspaceDirectory)() cliOutput := &bytes.Buffer{} diff --git a/pkg/live/load.go b/pkg/live/load.go index a7bf9c9aa8..3dc9b99eca 100644 --- a/pkg/live/load.go +++ b/pkg/live/load.go @@ -206,7 +206,7 @@ type InventoryFilter struct { } func (i *InventoryFilter) Filter(object *yaml.RNode) (*yaml.RNode, error) { - if GroupVersionKindForObject(object) != kptfilev1.KptFileGVK() { + if GroupVersionKindForObject(object).String() != kptfilev1.KptFileGVK().String() { return object, nil } @@ -241,7 +241,7 @@ func GroupVersionKindForObject(object *yaml.RNode) schema.GroupVersionKind { } func (r *RGFilter) Filter(object *yaml.RNode) (*yaml.RNode, error) { - if GroupVersionKindForObject(object) != rgfilev1alpha1.ResourceGroupGVK() { + if GroupVersionKindForObject(object).String() != rgfilev1alpha1.ResourceGroupGVK().String() { return object, nil } diff --git a/pkg/live/rgstream.go b/pkg/live/rgstream.go index 8662de153c..9460718120 100644 --- a/pkg/live/rgstream.go +++ b/pkg/live/rgstream.go @@ -31,8 +31,8 @@ import ( var ( excludedGKs = []schema.GroupKind{ - kptfilev1.KptFileGVK().GroupKind(), - rgfilev1alpha1.ResourceGroupGVK().GroupKind(), + {Group: kptfilev1.KptFileGVK().Group, Kind: kptfilev1.KptFileGVK().Kind}, + {Group: rgfilev1alpha1.ResourceGroupGVK().Group, Kind: rgfilev1alpha1.ResourceGroupGVK().Kind}, } ) diff --git a/thirdparty/cmdconfig/commands/cmdeval/cmdeval.go b/thirdparty/cmdconfig/commands/cmdeval/cmdeval.go index ca57c8d30a..064c99bd49 100644 --- a/thirdparty/cmdconfig/commands/cmdeval/cmdeval.go +++ b/thirdparty/cmdconfig/commands/cmdeval/cmdeval.go @@ -118,6 +118,8 @@ func GetEvalFnRunner(ctx context.Context, parent string) *EvalFnRunner { &r.excludeAnnotations, "exclude-annotations", []string{}, "exclude resources matching the given annotations") r.Command.Flags().StringArrayVar( &r.excludeLabels, "exclude-labels", []string{}, "exclude resources matching the given labels") + r.Command.Flags().StringVar( + &r.CelCondition, "condition", "", "conditional expression to determine if function should be run") if err := r.Command.Flags().MarkHidden("include-meta-resources"); err != nil { panic(err) @@ -161,11 +163,19 @@ type EvalFnRunner struct { excludeLabels []string excludeAnnotations []string + CelCondition string + runFns runfn.RunFns } func (r *EvalFnRunner) InitDefaults() { r.RunnerOptions.InitDefaults(runneroptions.GHCRImagePrefix) + // Initialize CEL environment for condition evaluation + // Fail early if initialization does not succeed because we might + // need CEL to evaluate conditions + if err := r.RunnerOptions.InitCELEnvironment(); err != nil { + fmt.Fprintf(os.Stderr, "failed to initialize CEL environment: %v\n", err) + } } func (r *EvalFnRunner) runE(c *cobra.Command, _ []string) error { @@ -199,6 +209,7 @@ func (r *EvalFnRunner) NewFunction() *kptfilev1.Function { if !r.Exclusion.IsEmpty() { newFn.Exclusions = []kptfilev1.Selector{r.Exclusion} } + newFn.CelCondition = r.CelCondition if r.FnConfigPath != "" { fnConfigAbsPath, _, _ := pathutil.ResolveAbsAndRelPaths(r.FnConfigPath) pkgAbsPath, _, _ := pathutil.ResolveAbsAndRelPaths(r.runFns.Path) @@ -557,6 +568,7 @@ func (r *EvalFnRunner) preRunE(c *cobra.Command, args []string) error { ContinueOnEmptyResult: true, Selector: r.Selector, Exclusion: r.Exclusion, + CelCondition: r.CelCondition, RunnerOptions: r.RunnerOptions, } diff --git a/thirdparty/cmdconfig/commands/cmdeval/cmdeval_test.go b/thirdparty/cmdconfig/commands/cmdeval/cmdeval_test.go index 49cd16e1ee..90b0c81a20 100644 --- a/thirdparty/cmdconfig/commands/cmdeval/cmdeval_test.go +++ b/thirdparty/cmdconfig/commands/cmdeval/cmdeval_test.go @@ -9,6 +9,7 @@ import ( "io" "os" "path/filepath" + "runtime" "strings" "testing" @@ -435,6 +436,12 @@ apiVersion: v1 if tt.expectedStruct != nil { r.runFns.Function = nil r.runFns.FnConfig = nil + // Zero out CEL fields for struct comparison since + // CELEnvironment is a pointer and can't be compared + // deterministically. CEL functionality is tested separately. + r.runFns.RunnerOptions.CELEnvironment = nil + r.runFns.RunnerOptions.CelCheckFrequency = 0 + r.runFns.RunnerOptions.CelCostLimit = 0 tt.expectedStruct.FnConfigPath = tt.fnConfigPath if !assert.Equal(t, *tt.expectedStruct, r.runFns) { t.FailNow() @@ -452,6 +459,10 @@ func TestCmd_flagAndArgParsing_Symlink(t *testing.T) { defer os.RemoveAll(dir) defer testutil.Chdir(t, dir)() + if runtime.GOOS == "windows" { + t.Skip("skipping test due to symlink creation failure (requires admin/developer mode on Windows)") + } + err = os.MkdirAll(filepath.Join(dir, "path", "to", "pkg", "dir"), 0700) assert.NoError(t, err) err = os.Symlink(filepath.Join("path", "to", "pkg", "dir"), "foo") @@ -463,7 +474,7 @@ func TestCmd_flagAndArgParsing_Symlink(t *testing.T) { r.Command.SetArgs([]string{"foo", "-i", "bar:v0.1"}) err = r.Command.Execute() assert.NoError(t, err) - assert.Equal(t, filepath.Join("path", "to", "pkg", "dir"), r.runFns.Path) + assert.Equal(t, strings.ToLower(filepath.Join("path", "to", "pkg", "dir")), strings.ToLower(r.runFns.Path)) } // NoOpRunE is a noop function to replace the run function of a command. Useful for testing argument parsing. diff --git a/thirdparty/kyaml/runfn/runfn.go b/thirdparty/kyaml/runfn/runfn.go index 126277b086..3b0b1c3733 100644 --- a/thirdparty/kyaml/runfn/runfn.go +++ b/thirdparty/kyaml/runfn/runfn.go @@ -96,7 +96,8 @@ type RunFns struct { Selector kptfilev1.Selector - Exclusion kptfilev1.Selector + Exclusion kptfilev1.Selector + CelCondition string } // Execute runs the command @@ -435,5 +436,15 @@ func (r *RunFns) defaultFnFilterProvider(spec runtimeutil.FunctionSpec, fnConfig opts.DisplayResourceCount = true } - return fnruntime.NewFunctionRunner(r.Ctx, fltr, "", fnResult, r.fnResults, opts) + runner, err := fnruntime.NewFunctionRunner(r.Ctx, fltr, "", fnResult, r.fnResults, opts) + if err != nil { + return nil, err + } + if r.CelCondition != "" { + if opts.CELEnvironment == nil { + return nil, fmt.Errorf("CelCondition specified for function %q but no CEL environment is configured in RunnerOptions", fnResult.Image) + } + runner.SetCelCondition(r.CelCondition, opts.CELEnvironment) + } + return runner, nil }