Skip to content

Commit b04a856

Browse files
Implement Kpt Function Conditional Rendering (Nephio #1084)
1 parent b6f3d8e commit b04a856

7 files changed

Lines changed: 157 additions & 7 deletions

File tree

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// Copyright 2026 The kpt and Nephio Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package fnruntime
16+
17+
import (
18+
"context"
19+
"io"
20+
"testing"
21+
22+
"github.com/kptdev/kpt/internal/types"
23+
fnresult "github.com/kptdev/kpt/pkg/api/fnresult/v1"
24+
kptfile "github.com/kptdev/kpt/pkg/api/kptfile/v1"
25+
"github.com/kptdev/kpt/pkg/lib/runneroptions"
26+
"github.com/stretchr/testify/assert"
27+
"github.com/stretchr/testify/require"
28+
"sigs.k8s.io/kustomize/kyaml/filesys"
29+
"sigs.k8s.io/kustomize/kyaml/yaml"
30+
)
31+
32+
func TestFunctionRunner_Conditions(t *testing.T) {
33+
ctx := context.Background()
34+
fsys := filesys.MakeFsInMemory()
35+
celEnv, err := runneroptions.NewCELEnvironment()
36+
require.NoError(t, err)
37+
38+
inputNodes := []*yaml.RNode{
39+
yaml.MustParse("apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: app-config"),
40+
}
41+
42+
testCases := []struct {
43+
name string
44+
fn *kptfile.Function
45+
condition string
46+
expectRun bool
47+
}{
48+
{
49+
name: "builtin runtime - condition met",
50+
fn: &kptfile.Function{
51+
Image: runneroptions.FuncGenPkgContext,
52+
},
53+
condition: "resources.exists(r, r.kind == 'ConfigMap')",
54+
expectRun: true,
55+
},
56+
{
57+
name: "builtin runtime - condition not met",
58+
fn: &kptfile.Function{
59+
Image: runneroptions.FuncGenPkgContext,
60+
},
61+
condition: "resources.exists(r, r.kind == 'Deployment')",
62+
expectRun: false,
63+
},
64+
{
65+
name: "executable runtime - condition met",
66+
fn: &kptfile.Function{
67+
Exec: "my-exec",
68+
},
69+
condition: "resources.size() > 0",
70+
expectRun: true,
71+
},
72+
{
73+
name: "executable runtime - condition not met",
74+
fn: &kptfile.Function{
75+
Exec: "my-exec",
76+
},
77+
condition: "resources.size() == 0",
78+
expectRun: false,
79+
},
80+
}
81+
82+
for _, tc := range testCases {
83+
t.Run(tc.name, func(t *testing.T) {
84+
tc.fn.Condition = tc.condition
85+
results := fnresult.NewResultList()
86+
87+
// Mock runner options
88+
opts := runneroptions.RunnerOptions{
89+
CELEnvironment: celEnv,
90+
ResolveToImage: func(image string) string { return image },
91+
}
92+
93+
// We use a mock runner to avoid actual execution
94+
runner, err := NewRunner(ctx, fsys, tc.fn, types.UniquePath("pkg"), results, opts, nil)
95+
require.NoError(t, err)
96+
97+
// Override the Run function to track if it's called
98+
wasRun := false
99+
runner.filter.Run = func(r io.Reader, w io.Writer) error {
100+
wasRun = true
101+
return nil
102+
}
103+
104+
_, err = runner.Filter(inputNodes)
105+
require.NoError(t, err)
106+
107+
assert.Equal(t, tc.expectRun, wasRun, "Run state mismatch for: %s", tc.name)
108+
assert.Equal(t, !tc.expectRun, runner.WasSkipped(), "Skip state mismatch for: %s", tc.name)
109+
110+
if !tc.expectRun {
111+
require.NotEmpty(t, results.Items)
112+
assert.True(t, results.Items[0].Skipped)
113+
assert.Equal(t, 0, results.Items[0].ExitCode)
114+
}
115+
})
116+
}
117+
}

internal/fnruntime/runner.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,16 @@ type FunctionRunner struct {
212212
opts runneroptions.RunnerOptions
213213
condition string // CEL condition expression
214214
celEnv *runneroptions.CELEnvironment // shared CEL environment for condition evaluation
215+
skipped bool // true if function execution was skipped due to condition
216+
}
217+
218+
func (fr *FunctionRunner) SetCondition(condition string, celEnv *runneroptions.CELEnvironment) {
219+
fr.condition = condition
220+
fr.celEnv = celEnv
221+
}
222+
223+
func (fr *FunctionRunner) WasSkipped() bool {
224+
return fr.skipped
215225
}
216226

217227
func (fr *FunctionRunner) Filter(input []*yaml.RNode) (output []*yaml.RNode, err error) {
@@ -230,8 +240,10 @@ func (fr *FunctionRunner) Filter(input []*yaml.RNode) (output []*yaml.RNode, err
230240
}
231241
// Append a skipped result so consumers get one result per pipeline step
232242
fr.fnResult.ExitCode = 0
243+
fr.fnResult.Skipped = true
233244
fr.fnResults.Items = append(fr.fnResults.Items, *fr.fnResult)
234245
// Return input unchanged - function is skipped
246+
fr.skipped = true
235247
return input, nil
236248
}
237249
}

internal/util/render/executor.go

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -812,7 +812,9 @@ func (pn *pkgNode) runMutators(ctx context.Context, hctx *hydrationContext, inpu
812812
hctx.mutationSteps = append(hctx.mutationSteps, captureStepResult(pl.Mutators[i], hctx.fnResults, resultCountBeforeExec, err))
813813
return input, err
814814
}
815-
hctx.executedFunctionCnt++
815+
if !mutator.WasSkipped() {
816+
hctx.executedFunctionCnt++
817+
}
816818
hctx.mutationSteps = append(hctx.mutationSteps, captureStepResult(pl.Mutators[i], hctx.fnResults, resultCountBeforeExec, nil))
817819

818820
if len(selectors) > 0 || len(exclusions) > 0 {
@@ -870,11 +872,14 @@ func (pn *pkgNode) runValidators(ctx context.Context, hctx *hydrationContext, in
870872
hctx.validationSteps = append(hctx.validationSteps, preExecFailureStep(function, err))
871873
return err
872874
}
873-
if _, err = validator.Filter(cloneResources(selectedResources)); err != nil {
875+
validatorRunner := validator.(*fnruntime.FunctionRunner)
876+
if _, err = validatorRunner.Filter(cloneResources(selectedResources)); err != nil {
874877
hctx.validationSteps = append(hctx.validationSteps, captureStepResult(function, hctx.fnResults, resultCountBeforeExec, err))
875878
return err
876879
}
877-
hctx.executedFunctionCnt++
880+
if !validatorRunner.WasSkipped() {
881+
hctx.executedFunctionCnt++
882+
}
878883
hctx.validationSteps = append(hctx.validationSteps, captureStepResult(function, hctx.fnResults, resultCountBeforeExec, nil))
879884
}
880885
return nil
@@ -1059,9 +1064,10 @@ func captureStepResult(fn kptfilev1.Function, fnResults *fnresult.ResultList, re
10591064
step.Stderr = last.Stderr
10601065
step.ExitCode = last.ExitCode
10611066
step.Results = frameworkResultsToItems(last.Results)
1062-
for _, ri := range step.Results {
1063-
if ri.Severity == string(framework.Error) {
1064-
step.ErrorResults = append(step.ErrorResults, ri)
1067+
step.Skipped = last.Skipped
1068+
for _, item := range step.Results {
1069+
if item.Severity == string(framework.Error) {
1070+
step.ErrorResults = append(step.ErrorResults, item)
10651071
}
10661072
}
10671073
} else if execErr != nil {

pkg/api/fnresult/v1/types.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ type Result struct {
3939
ExitCode int `yaml:"exitCode"`
4040
// Results is the list of results for the function
4141
Results framework.Results `yaml:"results,omitempty"`
42+
// Skipped indicates if the function was skipped due to a condition
43+
Skipped bool `yaml:"skipped,omitempty"`
4244
}
4345

4446
const (

pkg/api/kptfile/v1/types.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,8 @@ type PipelineStepResult struct {
447447
ExitCode int `yaml:"exitCode" json:"exitCode"`
448448
Results []ResultItem `yaml:"results,omitempty" json:"results,omitempty"`
449449
ErrorResults []ResultItem `yaml:"errorResults,omitempty" json:"errorResults,omitempty"`
450+
// Skipped indicates if the function was skipped due to a condition
451+
Skipped bool `yaml:"skipped,omitempty" json:"skipped,omitempty"`
450452
}
451453

452454
// ResultItem mirrors framework.Result with only the fields needed for Kptfile status.

thirdparty/cmdconfig/commands/cmdeval/cmdeval.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,8 @@ func GetEvalFnRunner(ctx context.Context, parent string) *EvalFnRunner {
117117
&r.excludeAnnotations, "exclude-annotations", []string{}, "exclude resources matching the given annotations")
118118
r.Command.Flags().StringArrayVar(
119119
&r.excludeLabels, "exclude-labels", []string{}, "exclude resources matching the given labels")
120+
r.Command.Flags().StringVar(
121+
&r.Condition, "condition", "", "conditional expression to determine if function should be run")
120122

121123
if err := r.Command.Flags().MarkHidden("include-meta-resources"); err != nil {
122124
panic(err)
@@ -161,6 +163,8 @@ type EvalFnRunner struct {
161163
excludeLabels []string
162164
excludeAnnotations []string
163165

166+
Condition string
167+
164168
runFns runfn.RunFns
165169
}
166170

@@ -202,6 +206,7 @@ func (r *EvalFnRunner) NewFunction() *kptfile.Function {
202206
if !r.Exclusion.IsEmpty() {
203207
newFn.Exclusions = []kptfile.Selector{r.Exclusion}
204208
}
209+
newFn.Condition = r.Condition
205210
if r.FnConfigPath != "" {
206211
fnConfigAbsPath, _, _ := pathutil.ResolveAbsAndRelPaths(r.FnConfigPath)
207212
pkgAbsPath, _, _ := pathutil.ResolveAbsAndRelPaths(r.runFns.Path)
@@ -556,6 +561,7 @@ func (r *EvalFnRunner) preRunE(c *cobra.Command, args []string) error {
556561
ContinueOnEmptyResult: true,
557562
Selector: r.Selector,
558563
Exclusion: r.Exclusion,
564+
Condition: r.Condition,
559565
RunnerOptions: r.RunnerOptions,
560566
}
561567

thirdparty/kyaml/runfn/runfn.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ type RunFns struct {
9797
Selector kptfile.Selector
9898

9999
Exclusion kptfile.Selector
100+
Condition string
100101
}
101102

102103
// Execute runs the command
@@ -413,5 +414,9 @@ func (r *RunFns) defaultFnFilterProvider(spec runtimeutil.FunctionSpec, fnConfig
413414
opts.DisplayResourceCount = true
414415
}
415416

416-
return fnruntime.NewFunctionRunner(r.Ctx, fltr, "", fnResult, r.fnResults, opts)
417+
runner, _ := fnruntime.NewFunctionRunner(r.Ctx, fltr, "", fnResult, r.fnResults, opts)
418+
if r.Condition != "" {
419+
runner.SetCondition(r.Condition, opts.CELEnvironment)
420+
}
421+
return runner, nil
417422
}

0 commit comments

Comments
 (0)