From 8c7603508d49df580e8b769ec9a7dce48f315491 Mon Sep 17 00:00:00 2001 From: Aravindhan Ayyanathan Date: Thu, 30 Jul 2026 15:51:32 +0100 Subject: [PATCH 1/2] Exclude fn-config from owning function's input in fn render Signed-off-by: Aravindhan Ayyanathan --- api/kptfile/v1/types.go | 6 +- .../en/book/04-using-functions/_index.md | 2 + .../.expected/diff.patch | 10 +- .../fnconfig-in-subdir/.expected/diff.patch | 16 +- .../.expected/diff.patch | 16 +- .../fn-render/fnconfig/.expected/diff.patch | 10 +- .../.expected/diff.patch | 12 - pkg/lib/kptops/render_executor.go | 72 +++- pkg/lib/kptops/render_executor_test.go | 337 ++++++++++++++++++ 9 files changed, 411 insertions(+), 70 deletions(-) diff --git a/api/kptfile/v1/types.go b/api/kptfile/v1/types.go index f42b6c52f3..6be699de74 100644 --- a/api/kptfile/v1/types.go +++ b/api/kptfile/v1/types.go @@ -338,9 +338,9 @@ type Function struct { Exec string `yaml:"exec,omitempty" json:"exec,omitempty"` // `ConfigPath` specifies a slash-delimited relative path to a file in the current directory - // containing a KRM resource used as the function config. This resource is - // excluded when resolving 'sources', and as a result cannot be operated on - // by the pipeline. + // containing a KRM resource used as the function config. The function will + // not see this resource in its input items — it is passed as configuration + // only. Other functions in the pipeline can still see and modify the file. ConfigPath string `yaml:"configPath,omitempty" json:"configPath,omitempty"` // `ConfigMap` is a convenient way to specify a function config of kind ConfigMap. diff --git a/documentation/content/en/book/04-using-functions/_index.md b/documentation/content/en/book/04-using-functions/_index.md index aeeb03763d..9a4c2ed25a 100644 --- a/documentation/content/en/book/04-using-functions/_index.md +++ b/documentation/content/en/book/04-using-functions/_index.md @@ -281,6 +281,8 @@ The `functionConfig` field is an optional meta resource containing the arguments The general way to provide a `functionConfig` of the arbitrary kind (core or custom resources) is to declare the resource in a separate file, in the same directory as the `Kptfile`, and refer to it using the `configPath` field. +A function will not modify its own `configPath` resource. The resource is passed to the function as configuration only — it will not appear as one of the input resources that the function acts upon. Other functions in the pipeline can still see and modify the file. + For example: ```yaml diff --git a/e2e/testdata/fn-render/fnconfig-ancestorfn-not-mutate-subpkg-config/.expected/diff.patch b/e2e/testdata/fn-render/fnconfig-ancestorfn-not-mutate-subpkg-config/.expected/diff.patch index 2a7bb6281a..51537a5fe1 100644 --- a/e2e/testdata/fn-render/fnconfig-ancestorfn-not-mutate-subpkg-config/.expected/diff.patch +++ b/e2e/testdata/fn-render/fnconfig-ancestorfn-not-mutate-subpkg-config/.expected/diff.patch @@ -1,5 +1,5 @@ diff --git a/Kptfile b/Kptfile -index 8d8916e..67d2002 100644 +index 8d8916e..af76fa0 100644 --- a/Kptfile +++ b/Kptfile @@ -7,3 +7,22 @@ pipeline: @@ -16,7 +16,7 @@ index 8d8916e..67d2002 100644 + - image: ghcr.io/kptdev/krm-functions-catalog/set-labels:latest + exitCode: 0 + results: -+ - message: set 4 labels in total ++ - message: set 3 labels in total + severity: info + - image: ghcr.io/kptdev/krm-functions-catalog/set-namespace:latest + exitCode: 0 @@ -39,15 +39,13 @@ index f4f3c67..456cee5 100644 mutators: - image: ghcr.io/kptdev/krm-functions-catalog/set-labels:latest diff --git a/db/labelconfig.yaml b/db/labelconfig.yaml -index 22d2de2..f4d597f 100644 +index 22d2de2..3c63796 100644 --- a/db/labelconfig.yaml +++ b/db/labelconfig.yaml -@@ -15,5 +15,8 @@ apiVersion: v1 +@@ -15,5 +15,6 @@ apiVersion: v1 kind: ConfigMap metadata: name: label-config -+ labels: -+ tier: db + namespace: staging data: tier: db diff --git a/e2e/testdata/fn-render/fnconfig-in-subdir/.expected/diff.patch b/e2e/testdata/fn-render/fnconfig-in-subdir/.expected/diff.patch index 2080bd81b1..b3ede2b553 100644 --- a/e2e/testdata/fn-render/fnconfig-in-subdir/.expected/diff.patch +++ b/e2e/testdata/fn-render/fnconfig-in-subdir/.expected/diff.patch @@ -1,5 +1,5 @@ diff --git a/Kptfile b/Kptfile -index f244e01..9f79949 100644 +index f244e01..7de4df7 100644 --- a/Kptfile +++ b/Kptfile @@ -2,7 +2,21 @@ apiVersion: kpt.dev/v1 @@ -22,20 +22,8 @@ index f244e01..9f79949 100644 + - image: ghcr.io/kptdev/krm-functions-catalog/set-labels:latest + exitCode: 0 + results: -+ - message: set 5 labels in total ++ - message: set 4 labels in total + severity: info -diff --git a/db/labelconfig.yaml b/db/labelconfig.yaml -index 22d2de2..19e0746 100644 ---- a/db/labelconfig.yaml -+++ b/db/labelconfig.yaml -@@ -15,5 +15,7 @@ apiVersion: v1 - kind: ConfigMap - metadata: - name: label-config -+ labels: -+ tier: db - data: - tier: db diff --git a/resources.yaml b/resources.yaml index f2eec52..8ea86f0 100644 --- a/resources.yaml diff --git a/e2e/testdata/fn-render/fnconfig-pkgfn-refers-subdir/.expected/diff.patch b/e2e/testdata/fn-render/fnconfig-pkgfn-refers-subdir/.expected/diff.patch index 3d28083935..6afbc1cb35 100644 --- a/e2e/testdata/fn-render/fnconfig-pkgfn-refers-subdir/.expected/diff.patch +++ b/e2e/testdata/fn-render/fnconfig-pkgfn-refers-subdir/.expected/diff.patch @@ -1,5 +1,5 @@ diff --git a/Kptfile b/Kptfile -index 34f24da..f5bcf7c 100644 +index 34f24da..ba374a1 100644 --- a/Kptfile +++ b/Kptfile @@ -2,7 +2,21 @@ apiVersion: kpt.dev/v1 @@ -22,20 +22,8 @@ index 34f24da..f5bcf7c 100644 + - image: ghcr.io/kptdev/krm-functions-catalog/set-labels:latest + exitCode: 0 + results: -+ - message: set 5 labels in total ++ - message: set 4 labels in total + severity: info -diff --git a/confs/labelconfig.yaml b/confs/labelconfig.yaml -index 22d2de2..19e0746 100644 ---- a/confs/labelconfig.yaml -+++ b/confs/labelconfig.yaml -@@ -15,5 +15,7 @@ apiVersion: v1 - kind: ConfigMap - metadata: - name: label-config -+ labels: -+ tier: db - data: - tier: db diff --git a/resources.yaml b/resources.yaml index f2eec52..8ea86f0 100644 --- a/resources.yaml diff --git a/e2e/testdata/fn-render/fnconfig/.expected/diff.patch b/e2e/testdata/fn-render/fnconfig/.expected/diff.patch index e708fa2631..e4cb1c7601 100644 --- a/e2e/testdata/fn-render/fnconfig/.expected/diff.patch +++ b/e2e/testdata/fn-render/fnconfig/.expected/diff.patch @@ -1,5 +1,5 @@ diff --git a/Kptfile b/Kptfile -index cd1b1e5..a14e2ce 100644 +index cd1b1e5..fa0fdfa 100644 --- a/Kptfile +++ b/Kptfile @@ -2,6 +2,8 @@ apiVersion: kpt.dev/v1 @@ -44,7 +44,7 @@ index cd1b1e5..a14e2ce 100644 + - image: ghcr.io/kptdev/krm-functions-catalog/set-labels:latest + exitCode: 0 + results: -+ - message: set 8 labels in total ++ - message: set 7 labels in total + severity: info diff --git a/db/Kptfile b/db/Kptfile index a0e6f2d..5e0e30a 100644 @@ -79,16 +79,14 @@ index dabe43c..25f77c1 100644 + app: backend + tier: db diff --git a/labelconfig.yaml b/labelconfig.yaml -index 22d2de2..8712cbf 100644 +index 22d2de2..3c63796 100644 --- a/labelconfig.yaml +++ b/labelconfig.yaml -@@ -15,5 +15,8 @@ apiVersion: v1 +@@ -15,5 +15,6 @@ apiVersion: v1 kind: ConfigMap metadata: name: label-config + namespace: staging -+ labels: -+ tier: db data: tier: db diff --git a/resources.yaml b/resources.yaml diff --git a/e2e/testdata/fn-render/modify-path-annotation/.expected/diff.patch b/e2e/testdata/fn-render/modify-path-annotation/.expected/diff.patch index 8c70da6113..47f2a43559 100644 --- a/e2e/testdata/fn-render/modify-path-annotation/.expected/diff.patch +++ b/e2e/testdata/fn-render/modify-path-annotation/.expected/diff.patch @@ -23,15 +23,3 @@ diff --git a/deployment.yaml b/newfilename.yaml similarity index 100% rename from deployment.yaml rename to newfilename.yaml -diff --git a/starlark-fn.yaml b/starlark-fn.yaml -index 247c2ae..0ef906e 100644 ---- a/starlark-fn.yaml -+++ b/starlark-fn.yaml -@@ -11,7 +11,6 @@ - # 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. -- - apiVersion: fn.kpt.dev/v1alpha1 - kind: StarlarkRun - metadata: diff --git a/pkg/lib/kptops/render_executor.go b/pkg/lib/kptops/render_executor.go index 05d4e81556..87fce44de8 100644 --- a/pkg/lib/kptops/render_executor.go +++ b/pkg/lib/kptops/render_executor.go @@ -765,11 +765,13 @@ func (pn *pkgNode) runMutators(ctx context.Context, hctx *hydrationContext, inpu for i, mutator := range mutators { resultCountBeforeExec := len(hctx.fnResults.Items) + // Identify the fn-config resource for the current mutator. + // If found, refresh the FunctionRunner's config reference (it may + // have been mutated by a preceding function) and exclude it from + // the function's input items. A function should only receive its + // config via resourceList.functionConfig, not in resourceList.items. + var fnConfigNode *yaml.RNode if pl.Mutators[i].ConfigPath != "" { - // functionConfigs are included in the function inputs during `render` - // and as a result, they can be mutated during the `render`. - // So functionConfigs needs be updated in the FunctionRunner instance - // before every run. for _, r := range input { pkgPath, err := pkg.GetPkgPathAnnotation(r) if err != nil { @@ -779,14 +781,23 @@ func (pn *pkgNode) runMutators(ctx context.Context, hctx *hydrationContext, inpu if err != nil { return nil, err } - if pkgPath == pn.pkg.UniquePath.String() && // resource belong to current package + if pkgPath == pn.pkg.UniquePath.String() && // resource belongs to current package currPath == pl.Mutators[i].ConfigPath { // configPath matches mutator.SetFnConfig(r) - continue + fnConfigNode = r + break } } } + // Remove fn-config from input before passing to the function. + // We'll add it back unmodified after execution. + if fnConfigNode != nil { + input = slices.DeleteFunc(input, func(node *yaml.RNode) bool { + return node == fnConfigNode + }) + } + selectors := pl.Mutators[i].Selectors exclusions := pl.Mutators[i].Exclusions @@ -813,6 +824,9 @@ func (pn *pkgNode) runMutators(ctx context.Context, hctx *hydrationContext, inpu } err = mutation.Execute() if err != nil { + if fnConfigNode != nil { + input = append(input, fnConfigNode) + } clearAnnotationsOnMutFailure(input) hctx.mutationSteps = append(hctx.mutationSteps, captureStepResult(pl.Mutators[i], hctx.fnResults, resultCountBeforeExec, err)) return input, err @@ -831,6 +845,12 @@ func (pn *pkgNode) runMutators(ctx context.Context, hctx *hydrationContext, inpu } else { input = output.Nodes } + + // Add fn-config back unmodified so it remains in the resource list + // for subsequent functions and is not pruned from disk. + if fnConfigNode != nil { + input = append(input, fnConfigNode) + } } return input, nil } @@ -852,17 +872,14 @@ func (pn *pkgNode) runValidators(ctx context.Context, hctx *hydrationContext, in 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}) + + validatorInput := validatorInputWithoutOwnFnConfig(input, function.ConfigPath, pn.pkg.UniquePath.String()) + + selectedResources, err := fnruntime.SelectInput(validatorInput, 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 - } + 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 @@ -870,7 +887,7 @@ func (pn *pkgNode) runValidators(ctx context.Context, hctx *hydrationContext, in 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) + 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 @@ -885,6 +902,31 @@ func (pn *pkgNode) runValidators(ctx context.Context, hctx *hydrationContext, in return nil } +// validatorInputWithoutOwnFnConfig returns a copy of input with the fn-config resource removed +// if configPath is non-empty and a matching resource is found. If no match is found +// or configPath is empty, the original input is returned unmodified. +func validatorInputWithoutOwnFnConfig(input []*yaml.RNode, configPath string, pkgUniquePath string) []*yaml.RNode { + if configPath == "" { + return input + } + for _, r := range input { + pkgPath, err := pkg.GetPkgPathAnnotation(r) + if err != nil { + return input + } + currPath, _, err := kioutil.GetFileAnnotations(r) + if err != nil { + return input + } + if pkgPath == pkgUniquePath && currPath == configPath { + return slices.DeleteFunc(slices.Clone(input), func(node *yaml.RNode) bool { + return node == r + }) + } + } + return input +} + func cloneResources(input []*yaml.RNode) (output []*yaml.RNode) { for _, resource := range input { output = append(output, resource.Copy()) diff --git a/pkg/lib/kptops/render_executor_test.go b/pkg/lib/kptops/render_executor_test.go index 56e310d863..6e0470cc52 100644 --- a/pkg/lib/kptops/render_executor_test.go +++ b/pkg/lib/kptops/render_executor_test.go @@ -18,12 +18,14 @@ import ( "bytes" "context" "fmt" + "io" "path/filepath" "strings" "testing" 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/kptfile/kptfileutil" "github.com/kptdev/kpt/pkg/lib/pkg" @@ -1050,3 +1052,338 @@ metadata: }) } } + +// mockFnRuntime is a test FunctionRuntime that captures the items passed to the function. +type mockFnRuntime struct { + // capturedItems records the resource names received in items for each invocation + capturedItems [][]string + // failOnInvocation specifies which invocation (0-based) should return an error. + // Only used when shouldFail is true. + failOnInvocation int + // shouldFail enables failure on the specified invocation. + shouldFail bool +} + +func (m *mockFnRuntime) GetRunner(_ context.Context, _ *kptfilev1.Function) (fn.FunctionRunner, error) { + return &mockFnRunner{runtime: m}, nil +} + +// mockFnRunner captures input items and passes them through unmodified. +type mockFnRunner struct { + runtime *mockFnRuntime +} + +func (r *mockFnRunner) Run(reader io.Reader, writer io.Writer) error { + // Use ByteReadWriter to parse the ResourceList wire format + rw := &kio.ByteReadWriter{ + Reader: reader, + Writer: writer, + KeepReaderAnnotations: true, + WrappingAPIVersion: kio.ResourceListAPIVersion, + WrappingKind: kio.ResourceListKind, + } + nodes, err := rw.Read() + if err != nil { + return err + } + // Record the names of items received + var names []string + for _, node := range nodes { + names = append(names, node.GetName()) + } + r.runtime.capturedItems = append(r.runtime.capturedItems, names) + + // Fail if this is the designated failing invocation + invocation := len(r.runtime.capturedItems) - 1 + if r.runtime.shouldFail && r.runtime.failOnInvocation == invocation { + return fmt.Errorf("intentional failure on invocation %d", invocation) + } + + // Pass through unmodified + return rw.Write(nodes) +} + +// fnConfigTestResult holds the output of a fn-config exclusion test execution. +type fnConfigTestResult struct { + mock *mockFnRuntime + mockFS filesys.FileSystem + pkgPath string + err error +} + +// setupSinglePkgFnConfigTest creates a single package with the given Kptfile content, +// a fn-config ConfigMap named "fn-config", and a Deployment named "my-deployment". +func setupSinglePkgFnConfigTest(t *testing.T, kptfileContent string, mock *mockFnRuntime) fnConfigTestResult { + t.Helper() + mockFS := filesys.MakeFsInMemory() + pkgPath := "/test-pkg" + err := mockFS.Mkdir(pkgPath) + assert.NoError(t, err) + + err = mockFS.WriteFile(filepath.Join(pkgPath, "Kptfile"), []byte(kptfileContent)) + assert.NoError(t, err) + + err = mockFS.WriteFile(filepath.Join(pkgPath, "fn-config.yaml"), []byte(` +apiVersion: v1 +kind: ConfigMap +metadata: + name: fn-config +data: + key: value +`)) + assert.NoError(t, err) + + err = mockFS.WriteFile(filepath.Join(pkgPath, "deployment.yaml"), []byte(` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: my-deployment +spec: + replicas: 1 +`)) + assert.NoError(t, err) + + var outputBuffer bytes.Buffer + ctx := printer.WithContext(context.Background(), printer.New(&outputBuffer, &outputBuffer)) + + renderer := &Renderer{ + PkgPath: pkgPath, + Runtime: mock, + FileSystem: mockFS, + } + + _, execErr := renderer.Execute(ctx) + return fnConfigTestResult{mock: mock, mockFS: mockFS, pkgPath: pkgPath, err: execErr} +} + +// setupNestedPkgFnConfigTest creates a root package with a subpackage "sub". +// Both have a mutator with configPath pointing to their own config file. +// Root has root-config.yaml + root-deploy.yaml; sub has sub-config.yaml + sub-deploy.yaml. +func setupNestedPkgFnConfigTest(t *testing.T, rootKptfile string, mock *mockFnRuntime) fnConfigTestResult { + t.Helper() + mockFS := filesys.MakeFsInMemory() + rootPath := "/nested-pkg" + subPath := filepath.Join(rootPath, "sub") + err := mockFS.MkdirAll(subPath) + assert.NoError(t, err) + + err = mockFS.WriteFile(filepath.Join(rootPath, "Kptfile"), []byte(rootKptfile)) + assert.NoError(t, err) + + err = mockFS.WriteFile(filepath.Join(rootPath, "root-config.yaml"), []byte(` +apiVersion: v1 +kind: ConfigMap +metadata: + name: root-config +data: + key: root-value +`)) + assert.NoError(t, err) + + err = mockFS.WriteFile(filepath.Join(rootPath, "root-deploy.yaml"), []byte(` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: root-deploy +spec: + replicas: 1 +`)) + assert.NoError(t, err) + + err = mockFS.WriteFile(filepath.Join(subPath, "Kptfile"), []byte(` +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: sub-pkg +pipeline: + mutators: + - image: mock-sub-fn:latest + configPath: sub-config.yaml +`)) + assert.NoError(t, err) + + err = mockFS.WriteFile(filepath.Join(subPath, "sub-config.yaml"), []byte(` +apiVersion: v1 +kind: ConfigMap +metadata: + name: sub-config +data: + key: sub-value +`)) + assert.NoError(t, err) + + err = mockFS.WriteFile(filepath.Join(subPath, "sub-deploy.yaml"), []byte(` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: sub-deploy +spec: + replicas: 2 +`)) + assert.NoError(t, err) + + var outputBuffer bytes.Buffer + ctx := printer.WithContext(context.Background(), printer.New(&outputBuffer, &outputBuffer)) + + renderer := &Renderer{ + PkgPath: rootPath, + Runtime: mock, + FileSystem: mockFS, + } + + _, execErr := renderer.Execute(ctx) + return fnConfigTestResult{mock: mock, mockFS: mockFS, pkgPath: rootPath, err: execErr} +} + +func TestRunMutators_ExcludesFnConfigFromOwnFunction(t *testing.T) { + result := setupSinglePkgFnConfigTest(t, ` +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: test-pkg +pipeline: + mutators: + - image: mock-fn-1:latest + - image: mock-fn-2:latest + configPath: fn-config.yaml +`, &mockFnRuntime{}) + + assert.NoError(t, result.err) + assert.Equal(t, 2, len(result.mock.capturedItems), "expected 2 function invocations") + + // First function (mock-fn-1) should see all resources including fn-config + assert.Contains(t, result.mock.capturedItems[0], "fn-config", + "first function should see fn-config in its items") + assert.Contains(t, result.mock.capturedItems[0], "my-deployment", + "first function should see deployment in its items") + + // Second function (mock-fn-2) should NOT see fn-config in items (it's its own config) + assert.NotContains(t, result.mock.capturedItems[1], "fn-config", + "second function should NOT see its own fn-config in items") + assert.Contains(t, result.mock.capturedItems[1], "my-deployment", + "second function should still see deployment in its items") +} + +func TestRunMutators_FnConfigPreservedOnDisk(t *testing.T) { + result := setupSinglePkgFnConfigTest(t, ` +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: test-pkg +pipeline: + mutators: + - image: mock-fn:latest + configPath: fn-config.yaml +`, &mockFnRuntime{}) + + assert.NoError(t, result.err) + exists := result.mockFS.Exists(filepath.Join(result.pkgPath, "fn-config.yaml")) + assert.True(t, exists, "fn-config.yaml should still exist on disk after render") +} + +func TestRunValidators_ExcludesFnConfigFromOwnFunction(t *testing.T) { + result := setupSinglePkgFnConfigTest(t, ` +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: test-pkg +pipeline: + validators: + - image: mock-validator:latest + configPath: fn-config.yaml +`, &mockFnRuntime{}) + + assert.NoError(t, result.err) + assert.Equal(t, 1, len(result.mock.capturedItems), "expected 1 validator invocation") + + assert.NotContains(t, result.mock.capturedItems[0], "fn-config", + "validator should NOT see its own fn-config in items") + assert.Contains(t, result.mock.capturedItems[0], "my-deployment", + "validator should see deployment in its items") +} + +func TestRunMutators_NestedPkgFnConfigExclusion_DFS(t *testing.T) { + result := setupNestedPkgFnConfigTest(t, ` +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: root-pkg +pipeline: + mutators: + - image: mock-root-fn:latest + configPath: root-config.yaml +`, &mockFnRuntime{}) + + assert.NoError(t, result.err) + // DFS: subpackage runs first, then root + assert.Equal(t, 2, len(result.mock.capturedItems), "expected 2 function invocations (sub then root)") + + // First invocation (sub's function): should NOT see sub-config + assert.NotContains(t, result.mock.capturedItems[0], "sub-config", + "sub function should NOT see its own fn-config") + assert.Contains(t, result.mock.capturedItems[0], "sub-deploy", + "sub function should see sub-deploy") + + // Second invocation (root's function): should NOT see root-config (its own), + // but SHOULD see sub-config (regular resource from root's perspective) + assert.NotContains(t, result.mock.capturedItems[1], "root-config", + "root function should NOT see its own fn-config") + assert.Contains(t, result.mock.capturedItems[1], "sub-config", + "root function SHOULD see sub's fn-config as a regular resource") + assert.Contains(t, result.mock.capturedItems[1], "root-deploy", + "root function should see root-deploy") + assert.Contains(t, result.mock.capturedItems[1], "sub-deploy", + "root function should see sub-deploy (transitive)") +} + +func TestRunMutators_NestedPkgFnConfigExclusion_BFS(t *testing.T) { + result := setupNestedPkgFnConfigTest(t, ` +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: root-pkg + annotations: + kpt.dev/bfs-rendering: "true" +pipeline: + mutators: + - image: mock-root-fn:latest + configPath: root-config.yaml +`, &mockFnRuntime{}) + + assert.NoError(t, result.err) + // BFS: root runs first, then subpackage + assert.Equal(t, 2, len(result.mock.capturedItems), "expected 2 function invocations (root then sub)") + + // First invocation (root's function): should NOT see root-config (its own) + assert.NotContains(t, result.mock.capturedItems[0], "root-config", + "root function should NOT see its own fn-config") + assert.Contains(t, result.mock.capturedItems[0], "root-deploy", + "root function should see root-deploy") + assert.Contains(t, result.mock.capturedItems[0], "sub-config", + "root function SHOULD see sub's fn-config as a regular resource") + assert.Contains(t, result.mock.capturedItems[0], "sub-deploy", + "root function should see sub-deploy (descendant)") + + // Second invocation (sub's function): should NOT see sub-config + assert.NotContains(t, result.mock.capturedItems[1], "sub-config", + "sub function should NOT see its own fn-config") + assert.Contains(t, result.mock.capturedItems[1], "sub-deploy", + "sub function should see sub-deploy") +} + +func TestRunMutators_FnConfigRestoredOnError(t *testing.T) { + result := setupSinglePkgFnConfigTest(t, ` +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: test-pkg +pipeline: + mutators: + - image: mock-fn:latest + configPath: fn-config.yaml +`, &mockFnRuntime{shouldFail: true, failOnInvocation: 0}) + + assert.Error(t, result.err, "expected render to fail") + exists := result.mockFS.Exists(filepath.Join(result.pkgPath, "fn-config.yaml")) + assert.True(t, exists, "fn-config.yaml should still exist on disk after failed render") +} From 117023f7ecce31172473f8fb8212fb8f54721a91 Mon Sep 17 00:00:00 2001 From: Aravindhan Ayyanathan Date: Thu, 30 Jul 2026 17:09:32 +0100 Subject: [PATCH 2/2] Address review comments Signed-off-by: Aravindhan Ayyanathan --- .../content/en/reference/schema/kptfile/kptfile.json | 2 +- .../content/en/reference/schema/kptfile/kptfile.yaml | 6 +++--- pkg/lib/kptops/render_executor.go | 4 +++- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/documentation/content/en/reference/schema/kptfile/kptfile.json b/documentation/content/en/reference/schema/kptfile/kptfile.json index 4c97f45fb0..6bbe445391 100644 --- a/documentation/content/en/reference/schema/kptfile/kptfile.json +++ b/documentation/content/en/reference/schema/kptfile/kptfile.json @@ -49,7 +49,7 @@ "x-go-name": "ConfigMap" }, "configPath": { - "description": "`ConfigPath` specifies a slash-delimited relative path to a file in the current directory\ncontaining a KRM resource used as the function config. This resource is\nexcluded when resolving 'sources', and as a result cannot be operated on\nby the pipeline.", + "description": "`ConfigPath` specifies a slash-delimited relative path to a file in the current directory\ncontaining a KRM resource used as the function config. The function will\nnot see this resource in its input items — it is passed as configuration\nonly. Other functions in the pipeline can still see and modify the file.", "type": "string", "x-go-name": "ConfigPath" }, diff --git a/documentation/content/en/reference/schema/kptfile/kptfile.yaml b/documentation/content/en/reference/schema/kptfile/kptfile.yaml index e13c8fc233..8ea6c4e744 100644 --- a/documentation/content/en/reference/schema/kptfile/kptfile.yaml +++ b/documentation/content/en/reference/schema/kptfile/kptfile.yaml @@ -28,9 +28,9 @@ definitions: configPath: description: |- `ConfigPath` specifies a slash-delimited relative path to a file in the current directory - containing a KRM resource used as the function config. This resource is - excluded when resolving 'sources', and as a result cannot be operated on - by the pipeline. + containing a KRM resource used as the function config. The function will + not see this resource in its input items — it is passed as configuration + only. Other functions in the pipeline can still see and modify the file. type: string x-go-name: ConfigPath exclude: diff --git a/pkg/lib/kptops/render_executor.go b/pkg/lib/kptops/render_executor.go index 87fce44de8..5ce8b8e700 100644 --- a/pkg/lib/kptops/render_executor.go +++ b/pkg/lib/kptops/render_executor.go @@ -875,7 +875,9 @@ func (pn *pkgNode) runValidators(ctx context.Context, hctx *hydrationContext, in validatorInput := validatorInputWithoutOwnFnConfig(input, function.ConfigPath, pn.pkg.UniquePath.String()) - selectedResources, err := fnruntime.SelectInput(validatorInput, function.Selectors, function.Exclusions, &fnruntime.SelectionContext{RootPackagePath: hctx.root.pkg.UniquePath}) + selectedResources, err := fnruntime.SelectInput( + validatorInput, function.Selectors, function.Exclusions, + &fnruntime.SelectionContext{RootPackagePath: hctx.root.pkg.UniquePath}) if err != nil { return err }