Repository: GoogleContainerTools/kpt
Pull request #2447 (2021-08-13 10:47:22)

Implement function target selectors, set infrastructure and handle pruning
This Pr implements the GVKNN resource selector. The resources can be targeted by functions by specifying GVKNN target selector. This is the first PR to implement selectors, so it involves setting up the infrastructure for selectors so that the next selectors is easy to add. This PR also handles prune logic.

More tests in progress.

https://github.com/GoogleContainerTools/kpt/issues/2439


Patch:
diff --git a/internal/cmdrender/executor.go b/internal/cmdrender/executor.go
index 31d69f16..9d7caf8c 100644
--- a/internal/cmdrender/executor.go
+++ b/internal/cmdrender/executor.go
@@ -353,21 +353,50 @@ func (pn *pkgNode) runMutators(ctx context.Context, hctx *hydrationContext, inpu
 		return nil, err
 	}
 
-	output := &kio.PackageBuffer{}
-	// create a kio pipeline from kyaml library to execute the function chains
-	mutation := kio.Pipeline{
-		Inputs: []kio.Reader{
-			&kio.PackageBuffer{Nodes: input},
-		},
-		Filters: mutators,
-		Outputs: []kio.Writer{output},
-	}
-	err = mutation.Execute()
-	if err != nil {
-		return nil, err
+	for i, mutator := range mutators {
+		selectors := pl.Mutators[i].Selectors
+
+		if len(selectors) > 0 {
+			// set kpt-resource-id annotation on each resource before mutation
+			err = fnruntime.SetResourceIds(input)
+			if err != nil {
+				return nil, err
+			}
+		}
+
+		// select the resources on which function should be applied
+		selectedInput, err := fnruntime.SelectInput(input, selectors, &fnruntime.SelectionContext{RootPackagePath: hctx.root.pkg.UniquePath})
+		if err != nil {
+			return nil, err
+		}
+		output := &kio.PackageBuffer{}
+		// create a kio pipeline from kyaml library to execute the function chains
+		mutation := kio.Pipeline{
+			Inputs: []kio.Reader{
+				&kio.PackageBuffer{Nodes: selectedInput},
+			},
+			Filters: []kio.Filter{mutator},
+			Outputs: []kio.Writer{output},
+		}
+		err = mutation.Execute()
+		if err != nil {
+			return nil, err
+		}
+		hctx.executedFunctionCnt += 1
+
+		if len(selectors) > 0 {
+			// merge the output resources with input resources
+			input = fnruntime.MergeWithInput(output.Nodes, selectedInput, input)
+			// delete the kpt-resource-id annotation on each resource
+			err = fnruntime.DeleteResourceIds(input)
+			if err != nil {
+				return nil, err
+			}
+		} else {
+			input = output.Nodes
+		}
 	}
-	hctx.executedFunctionCnt += len(mutators)
-	return output.Nodes, nil
+	return input, nil
 }
 
 // runValidators runs a set of validator functions on input resources.
@@ -392,7 +421,11 @@ func (pn *pkgNode) runValidators(ctx context.Context, hctx *hydrationContext, in
 		}
 		// validators are run on a copy of mutated resources to ensure
 		// resources are not mutated.
-		if _, err = validator.Filter(cloneResources(input)); err != nil {
+		selectedResources, err := fnruntime.SelectInput(input, fn.Selectors, &fnruntime.SelectionContext{RootPackagePath: hctx.root.pkg.UniquePath})
+		if err != nil {
+			return err
+		}
+		if _, err = validator.Filter(cloneResources(selectedResources)); err != nil {
 			return err
 		}
 		hctx.executedFunctionCnt++
@@ -507,7 +540,7 @@ func trackInputFiles(hctx *hydrationContext, relPath string, input []*yaml.RNode
 	return nil
 }
 
-// trackOutputfiles records the file paths of output resources in the hydration
+// trackOutputFiles records the file paths of output resources in the hydration
 // context. It should be invoked post hydration.
 func trackOutputFiles(hctx *hydrationContext) error {
 	outputSet := sets.String{}
diff --git a/internal/fnruntime/utils.go b/internal/fnruntime/utils.go
index ef373c87..cb6fe058 100644
--- a/internal/fnruntime/utils.go
+++ b/internal/fnruntime/utils.go
@@ -16,13 +16,22 @@ package fnruntime
 
 import (
 	"bytes"
+	"fmt"
 	"io/ioutil"
 	"path/filepath"
 
+	"github.com/GoogleContainerTools/kpt/internal/pkg"
+	"github.com/GoogleContainerTools/kpt/internal/types"
 	fnresult "github.com/GoogleContainerTools/kpt/pkg/api/fnresult/v1"
+	kptfilev1 "github.com/GoogleContainerTools/kpt/pkg/api/kptfile/v1"
 	"sigs.k8s.io/kustomize/kyaml/yaml"
 )
 
+// ResourceIDAnnotation is used to uniquely identify the resource during round trip
+// to and from a function execution. This annotation is meant to be consumed by
+// kpt during round trip and should be deleted after that
+const ResourceIDAnnotation = "internal.config.k8s.io/kpt-resource-id"
+
 // SaveResults saves results gathered from running the pipeline at specified dir.
 func SaveResults(resultsDir string, fnResults *fnresult.ResultList) (string, error) {
 	if resultsDir == "" {
@@ -45,3 +54,149 @@ func SaveResults(resultsDir string, fnResults *fnresult.ResultList) (string, err
 
 	return filePath, nil
 }
+
+// MergeWithInput merges the transformed output with input resources
+// input: all input resources, selectedInput: selected input resources
+// output: output resources as the result of function on selectedInput resources
+// for input: A,B,C,D; selectedInput: A,B; output: A,E(A transformed, B Deleted, E Added)
+// the result should be A,C,D,E
+// resources are identified by kpt-resource-id annotation
+func MergeWithInput(output, selectedInput, input []*yaml.RNode) []*yaml.RNode {
+	var result []*yaml.RNode
+	for i := range input {
+		presentInSelectedInput := presentIn(input[i], selectedInput)
+		presentInOutput := presentIn(input[i], output)
+		if !presentInSelectedInput {
+			// this resource is untouched
+			result = append(result, input[i])
+			continue
+		}
+
+		if presentInOutput {
+			// this resource modified by function, so replace it from the output
+			result = append(result, nodeWithResourceID(input[i].GetAnnotations()[ResourceIDAnnotation], output))
+		}
+		// if presentInSelectedInput and !presentInOutput the resource is deleted, so ignore it
+	}
+
+	// add function generated resources to the result
+	for i := range output {
+		if output[i].GetAnnotations()[ResourceIDAnnotation] == "" {
+			result = append(result, output[i])
+		}
+	}
+
+	return result
+}
+
+// nodeWithResourceID returns the node with the input resourceId
+func nodeWithResourceID(resourceID string, input []*yaml.RNode) *yaml.RNode {
+	for _, node := range input {
+		if node.GetAnnotations()[ResourceIDAnnotation] == resourceID {
+			return node
+		}
+	}
+	return nil
+}
+
+// presentIn returns true if the targetNode identified by kpt-resource-id annotation
+// is present in the input list of resources
+func presentIn(targetNode *yaml.RNode, input []*yaml.RNode) bool {
+	id := targetNode.GetAnnotations()[ResourceIDAnnotation]
+	for _, node := range input {
+		if node.GetAnnotations()[ResourceIDAnnotation] == id {
+			return true
+		}
+	}
+	return false
+}
+
+// SetResourceIds adds kpt-resource-id annotation to each input resource
+func SetResourceIds(input []*yaml.RNode) error {
+	id := 0
+	for i := range input {
+		idStr := fmt.Sprintf("%v", id)
+		err := input[i].PipeE(yaml.SetAnnotation(ResourceIDAnnotation, idStr))
+		if err != nil {
+			return err
+		}
+		id++
+	}
+	return nil
+}
+
+// DeleteResourceIds removes the kpt-resource-id annotation from all resources
+func DeleteResourceIds(input []*yaml.RNode) error {
+	for i := range input {
+		err := input[i].PipeE(yaml.ClearAnnotation(ResourceIDAnnotation))
+		if err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+type SelectionContext struct {
+	RootPackagePath types.UniquePath
+}
+
+// SelectInput returns the selected resources based on criteria in selectors
+func SelectInput(input []*yaml.RNode, selectors []kptfilev1.Selector, ctx *SelectionContext) ([]*yaml.RNode, error) {
+	if len(selectors) == 0 {
+		return input, nil
+	}
+	var filteredInput []*yaml.RNode
+	for _, node := range input {
+		for _, selector := range selectors {
+			match, err := isMatch(node, selector, ctx)
+			if err != nil {
+				return nil, err
+			}
+			if match {
+				filteredInput = append(filteredInput, node)
+			}
+		}
+	}
+	return filteredInput, nil
+}
+
+// isMatch returns true if the resource matches input selection criteria
+func isMatch(node *yaml.RNode, selector kptfilev1.Selector, ctx *SelectionContext) (bool, error) {
+	pkgPathMatch, err := packagePathMatch(node, selector, ctx.RootPackagePath.String())
+	if err != nil {
+		return false, err
+	}
+	// keep expanding with new selectors
+	return nameMatch(node, selector) && namespaceMatch(node, selector) &&
+		kindMatch(node, selector) && apiVersionMatch(node, selector) && pkgPathMatch, nil
+}
+
+// nameMatch returns true if the resource name matches input selection criteria
+func nameMatch(node *yaml.RNode, selector kptfilev1.Selector) bool {
+	return selector.Name == "" || selector.Name == node.GetName()
+}
+
+// namespaceMatch returns true if the resource namespace matches input selection criteria
+func namespaceMatch(node *yaml.RNode, selector kptfilev1.Selector) bool {
+	return selector.Namespace == "" || selector.Namespace == node.GetNamespace()
+}
+
+// kindMatch returns true if the resource kind matches input selection criteria
+func kindMatch(node *yaml.RNode, selector kptfilev1.Selector) bool {
+	return selector.Kind == "" || selector.Kind == node.GetKind()
+}
+
+// apiVersionMatch returns true if the resource apiVersion matches input selection criteria
+func apiVersionMatch(node *yaml.RNode, selector kptfilev1.Selector) bool {
+	return selector.APIVersion == "" || selector.APIVersion == node.GetApiVersion()
+}
+
+// packagePathMatch returns true if the package path of resource matches input selection criteria
+func packagePathMatch(node *yaml.RNode, selector kptfilev1.Selector, rootPackagePath string) (bool, error) {
+	resourcePkgPath, err := pkg.GetPkgPathAnnotation(node)
+	if err != nil {
+		return false, err
+	}
+	// TODO: pmarupaka Make this logic work for WINDOWS
+	return selector.PackagePath == "" || filepath.Join(rootPackagePath, selector.PackagePath) == resourcePkgPath, nil
+}
diff --git a/pkg/api/kptfile/v1/types.go b/pkg/api/kptfile/v1/types.go
index 934e6c1a..92c1bdc6 100644
--- a/pkg/api/kptfile/v1/types.go
+++ b/pkg/api/kptfile/v1/types.go
@@ -276,6 +276,34 @@ type Function struct {
 
 	// `ConfigMap` is a convenient way to specify a function config of kind ConfigMap.
 	ConfigMap map[string]string `yaml:"configMap,omitempty"`
+
+	// `Selectors` are used to specify resources on which the function should be executed
+	// if not specified, all resources are selected
+	Selectors []Selector `yaml:"selectors,omitempty"`
+}
+
+// Selector specifies the selection criteria
+// please update IsEmpty method if more properties are added
+type Selector struct {
+	// APIVersion of the target resources
+	APIVersion string `yaml:"apiVersion,omitempty"`
+	// Kind of the target resources
+	Kind string `yaml:"kind,omitempty"`
+	// Name of the target resources
+	Name string `yaml:"name,omitempty"`
+	// Namespace of the target resources
+	Namespace string `yaml:"namespace,omitempty"`
+	// PackagePath of the target resources relative to the root package directory
+	PackagePath string `yaml:"packagePath,omitempty"`
+}
+
+// IsEmpty returns true of none of the selection criteria is specified
+func (s Selector) IsEmpty() bool {
+	return s.APIVersion == "" &&
+		s.Namespace == "" &&
+		s.Name == "" &&
+		s.Kind == "" &&
+		s.PackagePath == ""
 }
 
 // Inventory encapsulates the parameters for the inventory resource applied to a cluster.
diff --git a/scripts/generate_site_sidebar/sidebar_template.md.tmpl b/scripts/generate_site_sidebar/sidebar_template.md.tmpl
index f014464d..2c1ae8d4 100644
--- a/scripts/generate_site_sidebar/sidebar_template.md.tmpl
+++ b/scripts/generate_site_sidebar/sidebar_template.md.tmpl
@@ -27,5 +27,7 @@
     - [ResourceList](reference/schema/resource-list/)
     - [CRD Status Convention](reference/schema/crd-status-convention/)
 - [Functions Catalog](https://catalog.kpt.dev/ ":target=_self")
+  - [Curated](https://catalog.kpt.dev/ ":target=_self")
+  - [Contrib](https://catalog.kpt.dev/contrib/ ":target=_self")
 - [FAQ](faq/)
 - [Contact](contact/)
diff --git a/site/index.html b/site/index.html
index c42728bd..ff38f97b 100644
--- a/site/index.html
+++ b/site/index.html
@@ -15,7 +15,7 @@
     />
     <link
       rel="stylesheet"
-      href="https://cdn.jsdelivr.net/npm/docsify-themeable@0/dist/css/theme-simple.css"
+      href="https://cdn.jsdelivr.net/npm/docsify-themeable@0.8.4/dist/css/theme-simple.css"
     />
     <link
       href="https://fonts.googleapis.com/icon?family=Material+Icons+Outlined"
diff --git a/site/sidebar.md b/site/sidebar.md
index 88533383..c872e55e 100644
--- a/site/sidebar.md
+++ b/site/sidebar.md
@@ -55,5 +55,7 @@
     - [ResourceList](reference/schema/resource-list/)
     - [CRD Status Convention](reference/schema/crd-status-convention/)
 - [Functions Catalog](https://catalog.kpt.dev/ ":target=_self")
+  - [Curated](https://catalog.kpt.dev/ ":target=_self")
+  - [Contrib](https://catalog.kpt.dev/contrib/ ":target=_self")
 - [FAQ](faq/)
 - [Contact](contact/)
diff --git a/thirdparty/cmdconfig/commands/cmdeval/cmdeval.go b/thirdparty/cmdconfig/commands/cmdeval/cmdeval.go
index 10ed86e1..c4714716 100644
--- a/thirdparty/cmdconfig/commands/cmdeval/cmdeval.go
+++ b/thirdparty/cmdconfig/commands/cmdeval/cmdeval.go
@@ -63,6 +63,15 @@ func GetEvalFnRunner(ctx context.Context, parent string) *EvalFnRunner {
 		&r.AsCurrentUser, "as-current-user", false, "use the uid and gid that kpt is running with to run the function in the container")
 	r.Command.Flags().StringVar(&r.ImagePullPolicy, "image-pull-policy", "always",
 		"pull image before running the container. It should be one of always, ifNotPresent and never.")
+	r.Command.Flags().StringVar(
+		&r.Selector.APIVersion, "api-version", "", "select the resources with apiVersion to run the function on")
+	r.Command.Flags().StringVar(
+		&r.Selector.Kind, "kind", "", "select the resources with kind to run the function on")
+	r.Command.Flags().StringVar(
+		&r.Selector.Name, "name", "", "select the resources with name to run the function on")
+	r.Command.Flags().StringVar(
+		&r.Selector.Namespace, "namespace", "", "select the resources with namespace to run the function on")
+
 	cmdutil.FixDocs("kpt", parent, c)
 	return r
 }
@@ -89,6 +98,7 @@ type EvalFnRunner struct {
 	AsCurrentUser        bool
 	IncludeMetaResources bool
 	Ctx                  context.Context
+	Selector             kptfile.Selector
 }
 
 func (r *EvalFnRunner) runE(c *cobra.Command, _ []string) error {
@@ -313,6 +323,7 @@ func (r *EvalFnRunner) preRunE(c *cobra.Command, args []string) error {
 		// fn eval should remove all files when all resources
 		// are deleted.
 		ContinueOnEmptyResult: true,
+		Selector:              r.Selector,
 	}
 
 	return nil
diff --git a/thirdparty/kyaml/runfn/runfn.go b/thirdparty/kyaml/runfn/runfn.go
index 049d4b47..d858b30d 100644
--- a/thirdparty/kyaml/runfn/runfn.go
+++ b/thirdparty/kyaml/runfn/runfn.go
@@ -94,6 +94,8 @@ type RunFns struct {
 	OriginalExec string
 
 	ImagePullPolicy fnruntime.ImagePullPolicy
+
+	Selector kptfile.Selector
 }
 
 // Execute runs the command
@@ -174,8 +176,7 @@ func (r RunFns) getFilters() ([]kio.Filter, error) {
 }
 
 // runFunctions runs the fltrs against the input and writes to either r.Output or output
-func (r RunFns) runFunctions(
-	input kio.Reader, output kio.Writer, fltrs []kio.Filter) error {
+func (r RunFns) runFunctions(input kio.Reader, output kio.Writer, fltrs []kio.Filter) error {
 	// use the previously read Resources as input
 	var outputs []kio.Writer
 	if r.Output == nil {
@@ -192,14 +193,53 @@ func (r RunFns) runFunctions(
 		})
 	}
 
-	var err error
+	inputResources, err := input.Read()
+	if err != nil {
+		return err
+	}
+
+	selectedInput := inputResources
+
+	if !r.Selector.IsEmpty() {
+		err = fnruntime.SetResourceIds(inputResources)
+		if err != nil {
+			return err
+		}
+
+		// select the resources on which function should be applied
+		selectedInput, err = fnruntime.SelectInput(
+			inputResources,
+			[]kptfile.Selector{r.Selector},
+			&fnruntime.SelectionContext{RootPackagePath: r.uniquePath})
+		if err != nil {
+			return err
+		}
+	}
+
+	pb := &kio.PackageBuffer{}
 	pipeline := kio.Pipeline{
-		Inputs:                []kio.Reader{input},
+		Inputs:                []kio.Reader{&kio.PackageBuffer{Nodes: selectedInput}},
 		Filters:               fltrs,
-		Outputs:               outputs,
+		Outputs:               []kio.Writer{pb},
 		ContinueOnEmptyResult: r.ContinueOnEmptyResult,
 	}
 	err = pipeline.Execute()
+	outputResources := pb.Nodes
+
+	if !r.Selector.IsEmpty() {
+		outputResources = fnruntime.MergeWithInput(pb.Nodes, selectedInput, inputResources)
+		deleteAnnoErr := fnruntime.DeleteResourceIds(outputResources)
+		if deleteAnnoErr != nil {
+			return deleteAnnoErr
+		}
+	}
+
+	if err == nil {
+		writeErr := outputs[0].Write(outputResources)
+		if writeErr != nil {
+			return writeErr
+		}
+	}
 	resultsFile, resultErr := fnruntime.SaveResults(r.ResultsDir, r.fnResults)
 	if err != nil {
 		// function fails


Test patch:
diff --git a/e2e/testdata/fn-eval/selectors/basicpipeline/.expected/diff.patch b/e2e/testdata/fn-eval/selectors/basicpipeline/.expected/diff.patch
new file mode 100644
index 00000000..dbb4ba49
--- /dev/null
+++ b/e2e/testdata/fn-eval/selectors/basicpipeline/.expected/diff.patch
@@ -0,0 +1,12 @@
+diff --git a/resources.yaml b/resources.yaml
+index 7a494c9..9d82bd6 100644
+--- a/resources.yaml
++++ b/resources.yaml
+@@ -15,6 +15,7 @@ apiVersion: apps/v1
+ kind: Deployment
+ metadata:
+   name: nginx-deployment
++  namespace: staging
+ spec:
+   replicas: 3
+ ---
diff --git a/e2e/testdata/fn-eval/selectors/basicpipeline/.expected/exec.sh b/e2e/testdata/fn-eval/selectors/basicpipeline/.expected/exec.sh
new file mode 100644
index 00000000..191fcf38
--- /dev/null
+++ b/e2e/testdata/fn-eval/selectors/basicpipeline/.expected/exec.sh
@@ -0,0 +1,18 @@
+#! /bin/bash
+# Copyright 2021 Google LLC
+#
+# 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.
+
+set -eo pipefail
+
+kpt fn eval -i set-namespace:v0.1.3 --name nginx-deployment --kind Deployment -- namespace=staging
diff --git a/e2e/testdata/fn-eval/selectors/basicpipeline/.krmignore b/e2e/testdata/fn-eval/selectors/basicpipeline/.krmignore
new file mode 100644
index 00000000..9d7a4007
--- /dev/null
+++ b/e2e/testdata/fn-eval/selectors/basicpipeline/.krmignore
@@ -0,0 +1,1 @@
+.expected
diff --git a/e2e/testdata/fn-eval/selectors/basicpipeline/resources.yaml b/e2e/testdata/fn-eval/selectors/basicpipeline/resources.yaml
new file mode 100644
index 00000000..7a494c9c
--- /dev/null
+++ b/e2e/testdata/fn-eval/selectors/basicpipeline/resources.yaml
@@ -0,0 +1,26 @@
+# Copyright 2021 Google LLC
+#
+# 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.
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: nginx-deployment
+spec:
+  replicas: 3
+---
+apiVersion: custom.io/v1
+kind: Custom
+metadata:
+  name: custom
+spec:
+  image: nginx:1.2.3
diff --git a/e2e/testdata/fn-eval/selectors/out-of-place-fnchain-unwrap/.expected/config.yaml b/e2e/testdata/fn-eval/selectors/out-of-place-fnchain-unwrap/.expected/config.yaml
new file mode 100644
index 00000000..cab123ed
--- /dev/null
+++ b/e2e/testdata/fn-eval/selectors/out-of-place-fnchain-unwrap/.expected/config.yaml
@@ -0,0 +1,49 @@
+# Copyright 2021 Google LLC
+#
+# 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.
+
+stdErr: |
+  [RUNNING] "gcr.io/kpt-fn/set-namespace:v0.1.3"
+  [PASS] "gcr.io/kpt-fn/set-namespace:v0.1.3" in 0s
+  [RUNNING] "gcr.io/kpt-fn/set-annotations:v0.1.3"
+  [PASS] "gcr.io/kpt-fn/set-annotations:v0.1.3" in 0s
+  [RUNNING] "gcr.io/kpt-fn/set-labels:v0.1.3"
+  [PASS] "gcr.io/kpt-fn/set-labels:v0.1.3" in 0s
+stdOut: |
+  apiVersion: apps/v1
+  kind: Deployment
+  metadata:
+    name: nginx-deployment
+    namespace: staging
+    labels:
+      tier: backend
+  spec:
+    replicas: 3
+    selector:
+      matchLabels:
+        tier: backend
+    template:
+      metadata:
+        labels:
+          tier: backend
+  ---
+  apiVersion: custom.io/v1
+  kind: Custom
+  metadata:
+    name: custom
+    annotations:
+      foo: bar
+    labels:
+      tier: backend
+  spec:
+    image: nginx:1.2.3
diff --git a/e2e/testdata/fn-eval/selectors/out-of-place-fnchain-unwrap/.expected/exec.sh b/e2e/testdata/fn-eval/selectors/out-of-place-fnchain-unwrap/.expected/exec.sh
new file mode 100644
index 00000000..9f200442
--- /dev/null
+++ b/e2e/testdata/fn-eval/selectors/out-of-place-fnchain-unwrap/.expected/exec.sh
@@ -0,0 +1,22 @@
+#! /bin/bash
+# Copyright 2021 Google LLC
+#
+# 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.
+
+
+set -eo pipefail
+
+kpt fn source \
+| kpt fn eval - --image gcr.io/kpt-fn/set-namespace:v0.1.3 --kind Deployment -o stdout -- namespace=staging \
+| kpt fn eval - --image gcr.io/kpt-fn/set-annotations:v0.1.3 --name custom -- foo=bar \
+| kpt fn eval - --image gcr.io/kpt-fn/set-labels:v0.1.3 -o unwrap -- tier=backend
diff --git a/e2e/testdata/fn-eval/selectors/out-of-place-fnchain-unwrap/.krmignore b/e2e/testdata/fn-eval/selectors/out-of-place-fnchain-unwrap/.krmignore
new file mode 100644
index 00000000..9d7a4007
--- /dev/null
+++ b/e2e/testdata/fn-eval/selectors/out-of-place-fnchain-unwrap/.krmignore
@@ -0,0 +1,1 @@
+.expected
diff --git a/e2e/testdata/fn-eval/selectors/out-of-place-fnchain-unwrap/resources.yaml b/e2e/testdata/fn-eval/selectors/out-of-place-fnchain-unwrap/resources.yaml
new file mode 100644
index 00000000..62f87440
--- /dev/null
+++ b/e2e/testdata/fn-eval/selectors/out-of-place-fnchain-unwrap/resources.yaml
@@ -0,0 +1,13 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: nginx-deployment
+spec:
+  replicas: 3
+---
+apiVersion: custom.io/v1
+kind: Custom
+metadata:
+  name: custom
+spec:
+  image: nginx:1.2.3
diff --git a/e2e/testdata/fn-render/selectors/basicpipeline/.expected/diff.patch b/e2e/testdata/fn-render/selectors/basicpipeline/.expected/diff.patch
new file mode 100644
index 00000000..0c359ecf
--- /dev/null
+++ b/e2e/testdata/fn-render/selectors/basicpipeline/.expected/diff.patch
@@ -0,0 +1,29 @@
+diff --git a/resources.yaml b/resources.yaml
+index 7a494c9..1d36135 100644
+--- a/resources.yaml
++++ b/resources.yaml
+@@ -15,12 +15,24 @@ apiVersion: apps/v1
+ kind: Deployment
+ metadata:
+   name: nginx-deployment
++  namespace: staging
++  labels:
++    tier: backend
+ spec:
+   replicas: 3
++  selector:
++    matchLabels:
++      tier: backend
++  template:
++    metadata:
++      labels:
++        tier: backend
+ ---
+ apiVersion: custom.io/v1
+ kind: Custom
+ metadata:
+   name: custom
++  labels:
++    tier: backend
+ spec:
+   image: nginx:1.2.3
diff --git a/e2e/testdata/fn-render/selectors/basicpipeline/.krmignore b/e2e/testdata/fn-render/selectors/basicpipeline/.krmignore
new file mode 100644
index 00000000..9d7a4007
--- /dev/null
+++ b/e2e/testdata/fn-render/selectors/basicpipeline/.krmignore
@@ -0,0 +1,1 @@
+.expected
diff --git a/e2e/testdata/fn-render/selectors/basicpipeline/Kptfile b/e2e/testdata/fn-render/selectors/basicpipeline/Kptfile
new file mode 100644
index 00000000..bd6b8791
--- /dev/null
+++ b/e2e/testdata/fn-render/selectors/basicpipeline/Kptfile
@@ -0,0 +1,15 @@
+apiVersion: kpt.dev/v1
+kind: Kptfile
+metadata:
+  name: app
+pipeline:
+  mutators:
+    - image: gcr.io/kpt-fn/set-namespace:v0.1.3
+      configMap:
+        namespace: staging
+      selectors:
+        - name: nginx-deployment
+          kind: Deployment
+    - image: gcr.io/kpt-fn/set-labels:v0.1.4
+      configMap:
+        tier: backend
diff --git a/e2e/testdata/fn-render/selectors/basicpipeline/resources.yaml b/e2e/testdata/fn-render/selectors/basicpipeline/resources.yaml
new file mode 100644
index 00000000..7a494c9c
--- /dev/null
+++ b/e2e/testdata/fn-render/selectors/basicpipeline/resources.yaml
@@ -0,0 +1,26 @@
+# Copyright 2021 Google LLC
+#
+# 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.
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: nginx-deployment
+spec:
+  replicas: 3
+---
+apiVersion: custom.io/v1
+kind: Custom
+metadata:
+  name: custom
+spec:
+  image: nginx:1.2.3
diff --git a/e2e/testdata/fn-render/selectors/generator/.expected/diff.patch b/e2e/testdata/fn-render/selectors/generator/.expected/diff.patch
new file mode 100644
index 00000000..dd1d2c47
--- /dev/null
+++ b/e2e/testdata/fn-render/selectors/generator/.expected/diff.patch
@@ -0,0 +1,31 @@
+diff --git a/db/deployment_httpbin.yaml b/db/deployment_httpbin.yaml
+new file mode 100644
+index 0000000..ffdf484
+--- /dev/null
++++ b/db/deployment_httpbin.yaml
+@@ -0,0 +1,25 @@
++apiVersion: apps/v1
++kind: Deployment
++metadata:
++  name: httpbin
++  namespace: staging
++  labels:
++    app: backend
++    tier: db
++spec:
++  replicas: 4
++  template:
++    spec:
++      containers:
++      - image: kennethreitz/httpbin
++        name: httpbin
++        ports:
++        - containerPort: 9876
++    metadata:
++      labels:
++        app: backend
++        tier: db
++  selector:
++    matchLabels:
++      app: backend
++      tier: db
diff --git a/e2e/testdata/fn-render/selectors/generator/.krmignore b/e2e/testdata/fn-render/selectors/generator/.krmignore
new file mode 100644
index 00000000..9d7a4007
--- /dev/null
+++ b/e2e/testdata/fn-render/selectors/generator/.krmignore
@@ -0,0 +1,1 @@
+.expected
diff --git a/e2e/testdata/fn-render/selectors/generator/Kptfile b/e2e/testdata/fn-render/selectors/generator/Kptfile
new file mode 100644
index 00000000..095150b2
--- /dev/null
+++ b/e2e/testdata/fn-render/selectors/generator/Kptfile
@@ -0,0 +1,16 @@
+apiVersion: kpt.dev/v1
+kind: Kptfile
+metadata:
+  name: app-with-generator
+pipeline:
+  mutators:
+    - image: gcr.io/kpt-fn/set-namespace:v0.1.3
+      configMap:
+        namespace: staging
+      selectors:
+        - name: httpbin
+    - image: gcr.io/kpt-fn/set-labels:v0.1.4
+      configMap:
+        tier: db
+      selectors:
+        - name: httpbin
diff --git a/e2e/testdata/fn-render/selectors/generator/db/Kptfile b/e2e/testdata/fn-render/selectors/generator/db/Kptfile
new file mode 100644
index 00000000..980690aa
--- /dev/null
+++ b/e2e/testdata/fn-render/selectors/generator/db/Kptfile
@@ -0,0 +1,18 @@
+apiVersion: kpt.dev/v1
+kind: Kptfile
+metadata:
+  name: db
+pipeline:
+  mutators:
+    - image: gcr.io/kpt-fn/starlark:v0.2.1
+      configPath: starlark-httpbin.yaml
+    - image: gcr.io/kpt-fn/set-namespace:v0.1.3
+      configMap:
+        namespace: db
+      selectors:
+        - name: httpbin
+    - image: gcr.io/kpt-fn/set-labels:v0.1.4
+      configMap:
+        app: backend
+      selectors:
+        - name: httpbin
diff --git a/e2e/testdata/fn-render/selectors/generator/db/resources.yaml b/e2e/testdata/fn-render/selectors/generator/db/resources.yaml
new file mode 100644
index 00000000..ac1fd966
--- /dev/null
+++ b/e2e/testdata/fn-render/selectors/generator/db/resources.yaml
@@ -0,0 +1,19 @@
+# Copyright 2021 Google LLC
+#
+# 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.
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: db
+spec:
+  replicas: 3
diff --git a/e2e/testdata/fn-render/selectors/generator/db/starlark-httpbin.yaml b/e2e/testdata/fn-render/selectors/generator/db/starlark-httpbin.yaml
new file mode 100644
index 00000000..5c7eea79
--- /dev/null
+++ b/e2e/testdata/fn-render/selectors/generator/db/starlark-httpbin.yaml
@@ -0,0 +1,55 @@
+# Copyright 2021 Google LLC
+#
+# 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.
+apiVersion: fn.kpt.dev/v1alpha1
+kind: StarlarkRun
+metadata:
+  name: httpbin-gen
+source: |-
+  httpbin_deployment = {
+     "apiVersion": "apps/v1",
+     "kind": "Deployment",
+     "metadata": {
+        "name": "httpbin",
+     },
+     "spec": {
+        "replicas": 4,
+        "template": {
+           "spec": {
+              "containers": [
+                {
+                  "name": "httpbin",
+                  "image": "kennethreitz/httpbin",
+                  "ports": [
+                    {
+                       "containerPort": 9876
+                    }
+                  ]
+                }
+              ]
+           }
+        }
+    }
+  }
+  # filter to return if resource is HTTPBin resource
+  def is_httpbin(r):
+    return r["apiVersion"] == "apps/v1" and r["kind"] == "Deployment" and r["metadata"]["name"] == "httpbin"
+
+  def ensure_httpbin(resources):
+    for r in resources:
+      if is_httpbin(r):
+        return
+
+    resources.append(httpbin_deployment)
+
+  ensure_httpbin(ctx.resource_list["items"])
diff --git a/e2e/testdata/fn-render/selectors/generator/resources.yaml b/e2e/testdata/fn-render/selectors/generator/resources.yaml
new file mode 100644
index 00000000..7a494c9c
--- /dev/null
+++ b/e2e/testdata/fn-render/selectors/generator/resources.yaml
@@ -0,0 +1,26 @@
+# Copyright 2021 Google LLC
+#
+# 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.
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: nginx-deployment
+spec:
+  replicas: 3
+---
+apiVersion: custom.io/v1
+kind: Custom
+metadata:
+  name: custom
+spec:
+  image: nginx:1.2.3
diff --git a/e2e/testdata/fn-render/selectors/subpkg/.expected/diff.patch b/e2e/testdata/fn-render/selectors/subpkg/.expected/diff.patch
new file mode 100644
index 00000000..76351c0d
--- /dev/null
+++ b/e2e/testdata/fn-render/selectors/subpkg/.expected/diff.patch
@@ -0,0 +1,51 @@
+diff --git a/db/resources.yaml b/db/resources.yaml
+index ac1fd96..49fa8df 100644
+--- a/db/resources.yaml
++++ b/db/resources.yaml
+@@ -15,5 +15,18 @@ apiVersion: apps/v1
+ kind: StatefulSet
+ metadata:
+   name: db
++  namespace: staging
++  labels:
++    app: backend
++    tier: db
+ spec:
+   replicas: 3
++  selector:
++    matchLabels:
++      app: backend
++      tier: db
++  template:
++    metadata:
++      labels:
++        app: backend
++        tier: db
+diff --git a/resources.yaml b/resources.yaml
+index 7a494c9..d22aec8 100644
+--- a/resources.yaml
++++ b/resources.yaml
+@@ -15,12 +15,23 @@ apiVersion: apps/v1
+ kind: Deployment
+ metadata:
+   name: nginx-deployment
++  labels:
++    tier: db
+ spec:
+   replicas: 3
++  selector:
++    matchLabels:
++      tier: db
++  template:
++    metadata:
++      labels:
++        tier: db
+ ---
+ apiVersion: custom.io/v1
+ kind: Custom
+ metadata:
+   name: custom
++  labels:
++    tier: db
+ spec:
+   image: nginx:1.2.3
diff --git a/e2e/testdata/fn-render/selectors/subpkg/.krmignore b/e2e/testdata/fn-render/selectors/subpkg/.krmignore
new file mode 100644
index 00000000..9d7a4007
--- /dev/null
+++ b/e2e/testdata/fn-render/selectors/subpkg/.krmignore
@@ -0,0 +1,1 @@
+.expected
diff --git a/e2e/testdata/fn-render/selectors/subpkg/Kptfile b/e2e/testdata/fn-render/selectors/subpkg/Kptfile
new file mode 100644
index 00000000..114f37da
--- /dev/null
+++ b/e2e/testdata/fn-render/selectors/subpkg/Kptfile
@@ -0,0 +1,14 @@
+apiVersion: kpt.dev/v1
+kind: Kptfile
+metadata:
+  name: app-with-db
+pipeline:
+  mutators:
+    - image: gcr.io/kpt-fn/set-namespace:v0.1.3
+      configMap:
+        namespace: staging
+      selectors:
+        - packagePath: ./db
+    - image: gcr.io/kpt-fn/set-labels:v0.1.4
+      configMap:
+        tier: db
diff --git a/e2e/testdata/fn-render/selectors/subpkg/db/Kptfile b/e2e/testdata/fn-render/selectors/subpkg/db/Kptfile
new file mode 100644
index 00000000..8c0fd517
--- /dev/null
+++ b/e2e/testdata/fn-render/selectors/subpkg/db/Kptfile
@@ -0,0 +1,12 @@
+apiVersion: kpt.dev/v1
+kind: Kptfile
+metadata:
+  name: db
+pipeline:
+  mutators:
+    - image: gcr.io/kpt-fn/set-namespace:v0.1.3
+      configMap:
+        namespace: db
+    - image: gcr.io/kpt-fn/set-labels:v0.1.4
+      configMap:
+        app: backend
diff --git a/e2e/testdata/fn-render/selectors/subpkg/db/resources.yaml b/e2e/testdata/fn-render/selectors/subpkg/db/resources.yaml
new file mode 100644
index 00000000..ac1fd966
--- /dev/null
+++ b/e2e/testdata/fn-render/selectors/subpkg/db/resources.yaml
@@ -0,0 +1,19 @@
+# Copyright 2021 Google LLC
+#
+# 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.
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: db
+spec:
+  replicas: 3
diff --git a/e2e/testdata/fn-render/selectors/subpkg/resources.yaml b/e2e/testdata/fn-render/selectors/subpkg/resources.yaml
new file mode 100644
index 00000000..7a494c9c
--- /dev/null
+++ b/e2e/testdata/fn-render/selectors/subpkg/resources.yaml
@@ -0,0 +1,26 @@
+# Copyright 2021 Google LLC
+#
+# 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.
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: nginx-deployment
+spec:
+  replicas: 3
+---
+apiVersion: custom.io/v1
+kind: Custom
+metadata:
+  name: custom
+spec:
+  image: nginx:1.2.3
diff --git a/e2e/testdata/fn-render/selectors/validate-subset/.krmignore b/e2e/testdata/fn-render/selectors/validate-subset/.krmignore
new file mode 100644
index 00000000..9d7a4007
--- /dev/null
+++ b/e2e/testdata/fn-render/selectors/validate-subset/.krmignore
@@ -0,0 +1,1 @@
+.expected
diff --git a/e2e/testdata/fn-render/selectors/validate-subset/Kptfile b/e2e/testdata/fn-render/selectors/validate-subset/Kptfile
new file mode 100644
index 00000000..80b8176e
--- /dev/null
+++ b/e2e/testdata/fn-render/selectors/validate-subset/Kptfile
@@ -0,0 +1,10 @@
+apiVersion: kpt.dev/v1
+kind: Kptfile
+metadata:
+  name: db
+pipeline:
+  validators:
+    - image: gcr.io/kpt-fn/starlark:v0.2.1 # validates httpbin deployment exists
+      configPath: starlark-httpbin-val.yaml
+      selectors:
+        - kind: Deployment
diff --git a/e2e/testdata/fn-render/selectors/validate-subset/resources.yaml b/e2e/testdata/fn-render/selectors/validate-subset/resources.yaml
new file mode 100644
index 00000000..9d82bd6f
--- /dev/null
+++ b/e2e/testdata/fn-render/selectors/validate-subset/resources.yaml
@@ -0,0 +1,27 @@
+# Copyright 2021 Google LLC
+#
+# 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.
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: nginx-deployment
+  namespace: staging
+spec:
+  replicas: 3
+---
+apiVersion: custom.io/v1
+kind: Custom
+metadata:
+  name: custom
+spec:
+  image: nginx:1.2.3
diff --git a/e2e/testdata/fn-render/selectors/validate-subset/starlark-httpbin-val.yaml b/e2e/testdata/fn-render/selectors/validate-subset/starlark-httpbin-val.yaml
new file mode 100644
index 00000000..6cd821fa
--- /dev/null
+++ b/e2e/testdata/fn-render/selectors/validate-subset/starlark-httpbin-val.yaml
@@ -0,0 +1,24 @@
+# Copyright 2021 Google LLC
+#
+# 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.
+apiVersion: fn.kpt.dev/v1alpha1
+kind: StarlarkRun
+metadata:
+  name: httpbin-val
+source: |-
+  def contains_httpbin_resource(resource_list):
+    for r in resource_list["items"]:
+      if r["metadata"]["namespace"] != "staging":
+        fail("resource has different namespace")
+        return
+  contains_httpbin_resource(ctx.resource_list)
diff --git a/internal/cmdrender/executor_test.go b/internal/cmdrender/executor_test.go
index 55528d89..9fdcfcbc 100644
--- a/internal/cmdrender/executor_test.go
+++ b/internal/cmdrender/executor_test.go
@@ -18,7 +18,9 @@ import (
 	"fmt"
 	"testing"
 
-	"gotest.tools/assert"
+	"github.com/GoogleContainerTools/kpt/internal/fnruntime"
+	"github.com/stretchr/testify/assert"
+	"sigs.k8s.io/kustomize/kyaml/kio"
 )
 
 func TestPathRelToRoot(t *testing.T) {
@@ -84,8 +86,144 @@ func TestPathRelToRoot(t *testing.T) {
 				tc.subPkgPath, tc.resourcePath)
 			assert.Equal(t, newPath, tc.expected)
 			if tc.errString != "" {
-				assert.ErrorContains(t, err, tc.errString)
+				assert.Contains(t, err.Error(), tc.errString)
 			}
 		})
 	}
 }
+
+func TestMergeWithInput(t *testing.T) {
+	tests := []struct {
+		name          string
+		input         string
+		selectedInput string
+		output        string
+		expected      string
+	}{
+		{
+			name: "simple input",
+			input: `apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: nginx-deployment
+  annotations:
+    internal.config.k8s.io/kpt-resource-id: "0"
+spec:
+  replicas: 3`,
+			selectedInput: `apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: nginx-deployment
+  annotations:
+    internal.config.k8s.io/kpt-resource-id: "0"
+spec:
+  replicas: 3`,
+			output: `apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: nginx-deployment
+  namespace: staging
+  annotations:
+    internal.config.k8s.io/kpt-resource-id: "0"
+spec:
+  replicas: 3`,
+			expected: `apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: nginx-deployment
+  namespace: staging
+  annotations:
+    internal.config.k8s.io/kpt-resource-id: "0"
+spec:
+  replicas: 3
+`,
+		},
+		{
+			name: "complex example with generation, transformation and deletion of resource",
+			input: `apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: nginx-deployment-0
+  annotations:
+    internal.config.k8s.io/kpt-resource-id: "0"
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: nginx-deployment-1
+  annotations:
+    internal.config.k8s.io/kpt-resource-id: "1"
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: nginx-deployment-2
+  annotations:
+    internal.config.k8s.io/kpt-resource-id: "2"
+`,
+			selectedInput: `apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: nginx-deployment-0
+  annotations:
+    internal.config.k8s.io/kpt-resource-id: "0"
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: nginx-deployment-1
+  annotations:
+    internal.config.k8s.io/kpt-resource-id: "1"
+`,
+			output: `apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: nginx-deployment-0
+  namespace: staging # transformed
+  annotations:
+    internal.config.k8s.io/kpt-resource-id: "0"
+---
+apiVersion: apps/v1 # generated resource
+kind: Deployment
+metadata:
+  name: nginx-deployment-3
+`,
+			expected: `apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: nginx-deployment-0
+  namespace: staging # transformed
+  annotations:
+    internal.config.k8s.io/kpt-resource-id: "0"
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: nginx-deployment-2
+  annotations:
+    internal.config.k8s.io/kpt-resource-id: "2"
+---
+apiVersion: apps/v1 # generated resource
+kind: Deployment
+metadata:
+  name: nginx-deployment-3
+`,
+		},
+	}
+
+	for i := range tests {
+		tc := tests[i]
+		t.Run(tc.name, func(t *testing.T) {
+			output, err := kio.ParseAll(tc.output)
+			assert.NoError(t, err)
+			selectedInput, err := kio.ParseAll(tc.selectedInput)
+			assert.NoError(t, err)
+			input, err := kio.ParseAll(tc.input)
+			assert.NoError(t, err)
+			result := fnruntime.MergeWithInput(output, selectedInput, input)
+			actual, err := kio.StringAll(result)
+			assert.NoError(t, err)
+			assert.Equal(t, tc.expected, actual)
+		})
+	}
+}
diff --git a/internal/fnruntime/utils_test.go b/internal/fnruntime/utils_test.go
new file mode 100644
index 00000000..b2644b4e
--- /dev/null
+++ b/internal/fnruntime/utils_test.go
@@ -0,0 +1,150 @@
+package fnruntime
+
+import (
+	"testing"
+
+	"github.com/GoogleContainerTools/kpt/internal/types"
+	kptfile "github.com/GoogleContainerTools/kpt/pkg/api/kptfile/v1"
+	"github.com/stretchr/testify/assert"
+	"sigs.k8s.io/kustomize/kyaml/yaml"
+)
+
+func TestIsMatch(t *testing.T) {
+	tests := []struct {
+		name     string
+		input    string
+		selector kptfile.Selector
+		expected bool
+	}{
+		{
+			name: "kind match",
+			input: `apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: nginx-deployment
+  annotations:
+    internal.config.k8s.io/kpt-resource-id: "0"
+spec:
+  replicas: 3`,
+			selector: kptfile.Selector{
+				Kind: "Deployment",
+			},
+			expected: true,
+		},
+		{
+			name: "name match",
+			input: `apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: nginx-deployment
+  annotations:
+    internal.config.k8s.io/kpt-resource-id: "0"
+spec:
+  replicas: 3`,
+			selector: kptfile.Selector{
+				Name: "nginx-deployment",
+			},
+			expected: true,
+		},
+		{
+			name: "namespace match",
+			input: `apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: nginx-deployment
+  namespace: staging
+  annotations:
+    internal.config.k8s.io/kpt-resource-id: "0"
+spec:
+  replicas: 3`,
+			selector: kptfile.Selector{
+				Namespace: "staging",
+			},
+			expected: true,
+		},
+		{
+			name: "apiVersion match",
+			input: `apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: nginx-deployment
+  annotations:
+    internal.config.k8s.io/kpt-resource-id: "0"
+spec:
+  replicas: 3`,
+			selector: kptfile.Selector{
+				APIVersion: "apps/v1",
+			},
+			expected: true,
+		},
+		{
+			name: "GVKNN match",
+			input: `apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: nginx-deployment
+  namespace: staging
+  annotations:
+    internal.config.k8s.io/kpt-resource-id: "0"
+spec:
+  replicas: 3`,
+			selector: kptfile.Selector{
+				Name:       "nginx-deployment",
+				Namespace:  "staging",
+				Kind:       "Deployment",
+				APIVersion: "apps/v1",
+			},
+			expected: true,
+		},
+		{
+			name: "namespace not matched but rest did",
+			input: `apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: nginx-deployment
+  namespace: staging
+  annotations:
+    internal.config.k8s.io/kpt-resource-id: "0"
+spec:
+  replicas: 3`,
+			selector: kptfile.Selector{
+				Name:       "nginx-deployment",
+				Namespace:  "prod",
+				Kind:       "Deployment",
+				APIVersion: "apps/v1",
+			},
+			expected: false,
+		},
+		{
+			name: "packagePath match",
+			input: `apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: nginx-deployment
+  annotations:
+    internal.config.k8s.io/kpt-resource-id: "0"
+    internal.config.kubernetes.io/package-path: "/path/to/root/pkg/db"
+spec:
+  replicas: 3`,
+			selector: kptfile.Selector{
+				PackagePath: "./db",
+			},
+			expected: true,
+		},
+	}
+
+	for i := range tests {
+		tc := tests[i]
+		t.Run(tc.name, func(t *testing.T) {
+			node, err := yaml.Parse(tc.input)
+			assert.NoError(t, err)
+			var rootPackagePath string
+			if tc.selector.PackagePath != "" {
+				rootPackagePath = "/path/to/root/pkg"
+			}
+			actual, err := isMatch(node, tc.selector, &SelectionContext{RootPackagePath: types.UniquePath(rootPackagePath)})
+			assert.NoError(t, err)
+			assert.Equal(t, tc.expected, actual)
+		})
+	}
+}