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 }