Skip to content

Commit c497258

Browse files
author
SurbhiAgarwal1
committed
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 <surbhi.agarwal@example.com>
1 parent 929c632 commit c497258

44 files changed

Lines changed: 889 additions & 93 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

api/fnresult/v1/types.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ type Result struct {
4646
ExitCode int `yaml:"exitCode"`
4747
// Results is the list of results for the function
4848
Results []ResultItem `yaml:"results,omitempty"`
49+
// Skipped indicates if the function was skipped due to a condition
50+
Skipped bool `yaml:"skipped,omitempty" json:"skipped,omitempty"`
4951
}
5052

5153
const (

api/kptfile/v1/types.go

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,20 @@ type Function struct {
361361
// `Exclude` are used to specify resources on which the function should NOT be executed.
362362
// If not specified, all resources selected by `Selectors` are selected.
363363
Exclusions []Selector `yaml:"exclude,omitempty" json:"exclude,omitempty"`
364+
365+
// CelCondition is an optional CEL expression (exposed as 'when' in YAML) that determines whether this
366+
// function should be executed. The expression is evaluated against the list
367+
// of KRM resources passed to this function step (after `Selectors` and
368+
// `Exclude` have been applied) and should return a boolean value.
369+
// If omitted or evaluates to true, the function executes normally.
370+
// If evaluates to false, the function is skipped.
371+
//
372+
// Example: Check if a specific ConfigMap exists among the selected resources:
373+
// when: "resources.exists(r, r.kind == 'ConfigMap' && r.metadata.name == 'my-config')"
374+
//
375+
// Example: Check resource count among the selected resources:
376+
// when: "resources.filter(r, r.kind == 'Deployment').size() > 0"
377+
CelCondition string `yaml:"when,omitempty" json:"when,omitempty"`
364378
}
365379

366380
// Selector specifies the selection criteria
@@ -413,8 +427,11 @@ type Status struct {
413427
RenderStatus *RenderStatus `yaml:"renderStatus,omitempty" json:"renderStatus,omitempty"`
414428
}
415429

416-
// IsEmpty returns true if the Status has no meaningful content.
417-
func (s Status) IsEmpty() bool {
430+
// IsEmpty returns true if the status has no conditions and no render status.
431+
func (s *Status) IsEmpty() bool {
432+
if s == nil {
433+
return true
434+
}
418435
return len(s.Conditions) == 0 && s.RenderStatus == nil
419436
}
420437

@@ -435,9 +452,12 @@ type PipelineStepResult struct {
435452
ExecutionError string `yaml:"executionError,omitempty" json:"executionError,omitempty"`
436453
Stderr string `yaml:"stderr,omitempty" json:"stderr,omitempty"`
437454
ExitCode int `yaml:"exitCode" json:"exitCode"`
438-
439-
Results []fnresultv1.ResultItem `yaml:"results,omitempty" json:"results,omitempty"`
440-
ErrorResults []fnresultv1.ResultItem `yaml:"errorResults,omitempty" json:"errorResults,omitempty"`
455+
Results []fnresultv1.ResultItem `yaml:"results,omitempty" json:"results,omitempty"`
456+
ErrorResults []fnresultv1.ResultItem `yaml:"errorResults,omitempty" json:"errorResults,omitempty"`
457+
// When is the CEL condition expression that was evaluated
458+
When string `yaml:"when,omitempty" json:"when,omitempty"`
459+
// Skipped indicates if the function was skipped due to a condition
460+
Skipped bool `yaml:"skipped,omitempty" json:"skipped,omitempty"`
441461
}
442462

443463
type Condition struct {

commands/fn/render/cmdrender.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,12 @@ type Runner struct {
8888

8989
func (r *Runner) InitDefaults() {
9090
r.RunnerOptions.InitDefaults(runneroptions.GHCRImagePrefix)
91+
// Initialize CEL environment for condition evaluation
92+
// Fail early if initialization does not succeed because we might
93+
// need CEL to evaluate conditions
94+
if err := r.RunnerOptions.InitCELEnvironment(); err != nil {
95+
fmt.Fprintf(os.Stderr, "failed to initialize CEL environment: %v\n", err)
96+
}
9197
}
9298

9399
func (r *Runner) preRunE(_ *cobra.Command, args []string) error {

commands/fn/render/cmdrender_test.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ package render
1717
import (
1818
"os"
1919
"path/filepath"
20+
"runtime"
21+
"strings"
2022
"testing"
2123

2224
"github.com/kptdev/kpt/internal/testutil"
@@ -29,6 +31,10 @@ func TestCmd_flagAndArgParsing_Symlink(t *testing.T) {
2931
dir := t.TempDir()
3032
defer testutil.Chdir(t, dir)()
3133

34+
if runtime.GOOS == "windows" {
35+
t.Skip("skipping symlink render test on Windows")
36+
}
37+
3238
err := os.MkdirAll(filepath.Join(dir, "path", "to", "pkg", "dir"), 0700)
3339
assert.NoError(t, err)
3440
err = os.Symlink(filepath.Join("path", "to", "pkg", "dir"), "foo")
@@ -40,7 +46,7 @@ func TestCmd_flagAndArgParsing_Symlink(t *testing.T) {
4046
r.Command.SetArgs([]string{"foo"})
4147
err = r.Command.Execute()
4248
assert.NoError(t, err)
43-
assert.Equal(t, filepath.Join("path", "to", "pkg", "dir"), r.pkgPath)
49+
assert.Equal(t, strings.ToLower(filepath.Join("path", "to", "pkg", "dir")), strings.ToLower(r.pkgPath))
4450
}
4551

4652
// NoOpRunE is a noop function to replace the run function of a command. Useful for testing argument parsing.

commands/pkg/diff/cmddiff_test.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ package diff_test
1717
import (
1818
"os"
1919
"path/filepath"
20+
"runtime"
21+
"strings"
2022
"testing"
2123

2224
"github.com/kptdev/kpt/commands/pkg/diff"
@@ -75,6 +77,10 @@ func TestCmd_flagAndArgParsing_Symlink(t *testing.T) {
7577
dir := t.TempDir()
7678
defer testutil.Chdir(t, dir)()
7779

80+
if runtime.GOOS == "windows" {
81+
t.Skip("skipping symlink diff test on Windows")
82+
}
83+
7884
err := os.MkdirAll(filepath.Join(dir, "path", "to", "pkg", "dir"), 0700)
7985
assert.NoError(t, err)
8086
err = os.Symlink(filepath.Join("path", "to", "pkg", "dir"), "foo")
@@ -88,7 +94,8 @@ func TestCmd_flagAndArgParsing_Symlink(t *testing.T) {
8894
assert.NoError(t, err)
8995
cwd, err := os.Getwd()
9096
assert.NoError(t, err)
91-
assert.Equal(t, filepath.Join(cwd, "path", "to", "pkg", "dir"), r.Path)
97+
expected := filepath.Join(cwd, "path", "to", "pkg", "dir")
98+
assert.Equal(t, strings.ToLower(expected), strings.ToLower(r.Path))
9299
}
93100

94101
var NoOpRunE = func(_ *cobra.Command, _ []string) error { return nil }

commands/pkg/get/cmdget_test.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -400,7 +400,12 @@ func TestCmd_flagAndArgParsing_Symlink(t *testing.T) {
400400
err := os.MkdirAll(filepath.Join(dir, "path", "to", "pkg", "dir"), 0700)
401401
assert.NoError(t, err)
402402
err = os.Symlink(filepath.Join("path", "to", "pkg", "dir"), "link")
403-
assert.NoError(t, err)
403+
if err != nil {
404+
if runtime.GOOS == "windows" {
405+
t.Skipf("skipping symlink get test on Windows: %v", err)
406+
}
407+
t.Fatalf("failed to create symlink: %v", err)
408+
}
404409

405410
r := get.NewRunner(fake.CtxWithDefaultPrinter(), "kpt")
406411
r.Command.RunE = NoOpRunE

commands/pkg/update/cmdupdate_test.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -351,7 +351,12 @@ func TestCmd_flagAndArgParsing_Symlink(t *testing.T) {
351351
err := os.MkdirAll(filepath.Join(dir, "path", "to", "pkg", "dir"), 0700)
352352
assert.NoError(t, err)
353353
err = os.Symlink(filepath.Join("path", "to", "pkg", "dir"), "foo")
354-
assert.NoError(t, err)
354+
if err != nil {
355+
if runtime.GOOS == "windows" {
356+
t.Skipf("skipping symlink update test on Windows: %v", err)
357+
}
358+
t.Fatalf("failed to create symlink: %v", err)
359+
}
355360

356361
// verify the branch ref is set to the correct value
357362
r := update.NewRunner(fake.CtxWithDefaultPrinter(), "kpt")
@@ -363,7 +368,8 @@ func TestCmd_flagAndArgParsing_Symlink(t *testing.T) {
363368
assert.Equal(t, kptfilev1.ResourceMerge, r.Update.Strategy)
364369
cwd, err := os.Getwd()
365370
assert.NoError(t, err)
366-
assert.Equal(t, filepath.Join(cwd, "path", "to", "pkg", "dir"), r.Update.Pkg.UniquePath.String())
371+
expected := filepath.Join(cwd, "path", "to", "pkg", "dir")
372+
assert.Equal(t, strings.ToLower(expected), strings.ToLower(r.Update.Pkg.UniquePath.String()))
367373
}
368374

369375
// TestCmd_fail verifies that that command returns an error when it fails rather than exiting the process

documentation/content/en/book/01-getting-started/_index.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ documents for [`kpt fn render`](../../reference/cli/fn/render/) and [`kpt fn eva
4343

4444
### Kubernetes cluster
4545

46-
To deploy the examples, you need a Kubernetes cluster and a configured kubectl context.
46+
To deploy the examples, you need a Kubernetes cluster and a configured kubeconfig context.
4747

4848
For testing purposes, the [kind](https://kind.sigs.k8s.io/docs/user/quick-start/) tool is useful for running an ephemeral Kubernetes
4949
cluster on your local host.
@@ -106,7 +106,7 @@ vim deployment.yaml
106106
#### Automating one-time edits with functions
107107

108108
The [`kpt fn`](../../reference/cli/fn/) set of commands enables you to execute programs called _kpt functions_. These programs are
109-
packaged as containers and take YAML files as input, mutate or validate them, and then output YAML.
109+
packaged as containers and take in YAML files, mutate or validate them, and then output YAML.
110110

111111
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`.
112112

documentation/content/en/book/04-using-functions/_index.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,69 @@ pipeline:
346346

347347
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.
348348

349+
### Specifying `when`
350+
351+
The `when` field lets you skip a function based on the current state of the resources in the package.
352+
It takes a [CEL](https://cel.dev/) expression that is evaluated against the resource list. If the expression
353+
returns `true`, the function runs. If it returns `false`, the function is skipped.
354+
355+
The expression receives a variable called `resources`, which is a list of all KRM resources passed to
356+
this function step (after `selectors` and `exclude` have been applied). Each resource is a map with
357+
the standard fields `apiVersion`, `kind`, and `metadata`. Depending on the resource, fields such as
358+
`spec` and `status` may also be present.
359+
360+
For example, only run the `set-labels` function if a `ConfigMap` named `app-config` exists in the package:
361+
362+
```yaml
363+
# wordpress/Kptfile (Excerpt)
364+
apiVersion: kpt.dev/v1
365+
kind: Kptfile
366+
metadata:
367+
name: wordpress
368+
pipeline:
369+
mutators:
370+
- image: ghcr.io/kptdev/krm-functions-catalog/set-labels:latest
371+
configMap:
372+
app: wordpress
373+
when: resources.exists(r, r.kind == 'ConfigMap' && r.metadata.name == 'app-config')
374+
```
375+
376+
When you render the package, kpt shows whether the function ran or was skipped:
377+
378+
```shell
379+
$ kpt fn render wordpress
380+
Package "wordpress":
381+
382+
[RUNNING] "ghcr.io/kptdev/krm-functions-catalog/set-labels:latest"
383+
[PASS] "ghcr.io/kptdev/krm-functions-catalog/set-labels:latest"
384+
385+
Successfully executed 1 function(s) in 1 package(s).
386+
```
387+
388+
If the condition is not met:
389+
390+
```shell
391+
$ kpt fn render wordpress
392+
Package "wordpress":
393+
394+
[SKIPPED] "ghcr.io/kptdev/krm-functions-catalog/set-labels:latest" (condition not met)
395+
396+
Successfully executed 0 function(s) in 1 package(s).
397+
```
398+
399+
Some useful CEL expression patterns:
400+
401+
- Check if a resource of a specific kind exists:
402+
`resources.exists(r, r.kind == 'Deployment')`
403+
- Check if a specific resource exists by name:
404+
`resources.exists(r, r.kind == 'ConfigMap' && r.metadata.name == 'my-config')`
405+
- Check the count of resources:
406+
`resources.filter(r, r.kind == 'Deployment').size() > 0`
407+
408+
The `when` field can be combined with `selectors` and `exclude`. The condition is evaluated
409+
after selectors and exclusions are applied, so `resources` only contains the resources that
410+
passed the selection criteria.
411+
349412
### Specifying `selectors`
350413

351414
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:

documentation/content/en/reference/schema/kptfile/kptfile.yaml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,16 @@ definitions:
7171
this is primarily used for merging function declaration with upstream counterparts
7272
type: string
7373
x-go-name: Name
74+
when:
75+
description: |-
76+
`CelCondition` is an optional CEL expression that determines whether this
77+
function should be executed. The expression is evaluated against the list
78+
of KRM resources passed to this function step (after `Selectors` and
79+
`Exclude` have been applied) and should return a boolean value.
80+
If omitted or evaluates to true, the function executes normally.
81+
If evaluates to false, the function is skipped.
82+
type: string
83+
x-go-name: CelCondition
7484
selectors:
7585
description: |-
7686
`Selectors` are used to specify resources on which the function should be executed

0 commit comments

Comments
 (0)