Repository: GoogleContainerTools/kpt
Pull request #3135 (2022-05-11 23:33:55)

update rpkg copy docs
Doc update for rpkg copy. This should be merged after the release that includes https://github.com/GoogleContainerTools/kpt/pull/3130.

natasha41575: @yuwenma PTAL, this is the doc update to go with https://github.com/GoogleContainerTools/kpt/pull/3130
natasha41575: FYI @mortent 

Patch:
diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml
index f41b56ab..63d27875 100644
--- a/.github/workflows/go.yml
+++ b/.github/workflows/go.yml
@@ -29,8 +29,11 @@ on:
 
 jobs:
   build:
-    name: Build
+    name: Build-test-kpt-CLI
     runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        runtime: [docker, podman]
     steps:
       - name: Set up Go 1.17
         uses: actions/setup-go@v1
@@ -45,3 +48,5 @@ jobs:
           git config --global user.name Your Name
           make all
           make test-docker
+        env:
+          KPT_FN_RUNTIME: ${{ matrix.runtime }}
diff --git a/CODEOWNERS b/CODEOWNERS
index d298b84e..391c78d8 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -2,17 +2,17 @@
 # Some people have asked for only minimal permissions to avoid notification spam: @bgrant0607
 
 # Default, unless something takes precedence below
-* @droot @justinsb @mengqiy @martinmaly @mortent @natasha41575 @yuwenma
+* @droot @justinsb @mengqiy @mortent @natasha41575 @yuwenma
 
 # Docs
-/site @bgrant0607 @droot @justinsb @mengqiy @martinmaly @mortent @natasha41575 @yuwenma
+/site @bgrant0607 @droot @justinsb @mengqiy @mortent @natasha41575 @yuwenma
 # These packages are referenced in docs.
-/package-examples/nginx/ @droot @justinsb @mengqiy @martinmaly @mortent @natasha41575 @yuwenma
-/package-examples/wordpress/ @droot @justinsb @mengqiy @martinmaly @mortent @natasha41575 @yuwenma
+/package-examples/nginx/ @droot @justinsb @mengqiy @mortent @natasha41575 @yuwenma
+/package-examples/wordpress/ @droot @justinsb @mengqiy @mortent @natasha41575 @yuwenma
 
 # Schemas changes additional scrutiny.
-/pkg/api @droot @justinsb @mengqiy @martinmaly @mortent @natasha41575 @yuwenma
+/pkg/api @droot @justinsb @mengqiy @mortent @natasha41575 @yuwenma
 
 # These tests enforce the behavior for fn
-kpt/e2e/testdata/fn-render @droot @justinsb @mengqiy @martinmaly @mortent @natasha41575 @yuwenma
-kpt/e2e/testdata/fn-eval @droot @justinsb @mengqiy @martinmaly @mortent @natasha41575 @yuwenma
+kpt/e2e/testdata/fn-render @droot @justinsb @mengqiy @mortent @natasha41575 @yuwenma
+kpt/e2e/testdata/fn-eval @droot @justinsb @mengqiy @mortent @natasha41575 @yuwenma
diff --git a/Makefile b/Makefile
index a9aa9ede..4c4b8172 100644
--- a/Makefile
+++ b/Makefile
@@ -95,15 +95,21 @@ test:
 
 # This target is used to run Go tests that require docker runtime.
 # Some tests, like pipeline tests, need to have docker available to run.
+# KPT_FN_RUNTIME can be set to select the desired function runtime.
+# If unspecified, the default function runtime will be used.
 test-docker: build
 	PATH=$(GOBIN):$(PATH) go test -cover --tags=docker ./...
 
 # KPT_E2E_UPDATE_EXPECTED=true (if expected output to be updated)
 # target to run e2e tests for "kpt fn render" command
+# KPT_FN_RUNTIME can be set to select the desired function runtime.
+# If unspecified, the default function runtime will be used.
 test-fn-render: build
 	PATH=$(GOBIN):$(PATH) go test -v --tags=docker --run=TestFnRender/testdata/fn-render/$(T) ./e2e/
 
 # target to run e2e tests for "kpt fn eval" command
+# KPT_FN_RUNTIME can be set to select the desired function runtime.
+# If unspecified, the default function runtime will be used.
 test-fn-eval: build
 	PATH=$(GOBIN):$(PATH) go test -v --tags=docker --run=TestFnEval/testdata/fn-eval/$(T)  ./e2e/
 
diff --git a/internal/docs/generated/fndocs/docs.go b/internal/docs/generated/fndocs/docs.go
index 12e2de0a..5f419978 100644
--- a/internal/docs/generated/fndocs/docs.go
+++ b/internal/docs/generated/fndocs/docs.go
@@ -58,18 +58,6 @@ Flags:
     By default, container function is executed as ` + "`" + `nobody` + "`" + ` user. You may want to use
     this flag to run higher privilege operations such as mounting the local filesystem.
   
-  --match-api-version:
-    Select resources matching the given apiVersion.
-  
-  --match-kind
-    Select resources matching the given kind.
-  
-  --match-name:
-    Select resources matching the given name.
-    
-  --match-namespace:
-    Select resources matching the given namespace.
-  
   --env, e:
     List of local environment variables to be exported to the container function.
     By default, none of local environment variables are made available to the
@@ -106,6 +94,18 @@ Flags:
     (DEPRECATED) include-meta-resources is no longer necessary because meta
     resources are included by default with kpt version v1.0.0-beta.15+.
   
+  --match-api-version:
+    Select resources matching the given apiVersion.
+  
+  --match-kind
+    Select resources matching the given kind.
+  
+  --match-name:
+    Select resources matching the given name.
+    
+  --match-namespace:
+    Select resources matching the given namespace.
+  
   --mount:
     List of storage options to enable reading from the local filesytem. By default,
     container functions can not access the local filesystem. It accepts the same options
@@ -125,11 +125,26 @@ Flags:
     3. OUT_DIR_PATH: output resources are written to provided directory.
        The provided directory must not already exist.
   
+  --type, t;
+    Specify the function type. Accept value ` + "`" + `mutator` + "`" + ` (default), ` + "`" + `validator` + "`" + `. 
+    If used with ` + "`" + `--save` + "`" + `, this flag will save the evaluated function to the corresponding
+     Kptfile section: ` + "`" + `.pipeline.mutators` + "`" + ` if type is ` + "`" + `mutator` + "`" + `; ` + "`" + `.pipeline.validators` + "`" + ` if type
+      is ` + "`" + `validator` + "`" + `.
+  
   --results-dir:
     Path to a directory to write structured results. Directory will be created if
     it doesn't exist. Structured results emitted by the functions are aggregated and saved
     to ` + "`" + `results.yaml` + "`" + ` file in the specified directory.
     If not specified, no result files are written to the local filesystem.
+    
+  --save, s:
+    Save the function image and fn-config to Kptfile. Require ` + "`" + ` + "` + "`" + `" + ` + "`" + `--image` + "`" + ` + "` + "`" + `" + ` + "`" + `.
+    
+
+Environment Variables:
+
+  KPT_FN_RUNTIME:
+    The runtime to run kpt functions. It must be one of "docker" or "podman".
 `
 var EvalExamples = `
   # execute container my-fn on the resources in DIR directory and
@@ -143,6 +158,12 @@ var EvalExamples = `
   # execute container my-fn with an input ConfigMap containing ` + "`" + `data: {foo: bar}` + "`" + `
   $ kpt fn eval DIR -i gcr.io/example.com/my-fn:v1.0.0 -- foo=bar
 
+  # execute container my-fn and save it to Kptfile ` + "`" + `pipeline.mutators` + "`" + ` (Default) list.
+  $ kpt fn eval DIR -s -i gcr.io/example.com/my-fn:v1.0.0 -- foo=bar
+
+  # execute container my-fn and save it to Kptfile ` + "`" + `pipeline.validators` + "`" + ` list.
+  $ kpt fn eval DIR -s -t validator -i gcr.io/example.com/my-fn:v1.0.0 -- foo=bar
+
   # execute executable my-fn on the resources in DIR directory and
   # write output back to DIR
   $ kpt fn eval DIR --exec ./my-fn
@@ -193,6 +214,10 @@ var EvalExamples = `
   # execute container 'set-namespace' on the resources with 'name' foo and 'kind' Deployment
   # in current directory
   kpt fn eval -i set-namespace:v0.1 --by-kind Deployment --by-name foo -- namespace=staging
+
+  # execute container my-fn with podman on the resources in DIR directory and
+  # write output back to DIR
+  $ KPT_FN_RUNTIME=podman kpt fn eval DIR -i gcr.io/example.com/my-fn
 `
 
 var ExportShort = `Auto-generating function pipelines for different workflow orchestrators`
@@ -263,6 +288,11 @@ Flags:
     it doesn't exist. Structured results emitted by the functions are aggregated and saved
     to ` + "`" + `results.yaml` + "`" + ` file in the specified directory.
     If not specified, no result files are written to the local filesystem.
+
+Environment Variables:
+
+  KPT_FN_RUNTIME:
+    The runtime to run kpt functions. It must be one of "docker" or "podman".
 `
 var RenderExamples = `
   # Render the package in current directory
@@ -286,6 +316,9 @@ var RenderExamples = `
   # the transformed resources are written to another directory
   $ kpt fn render -o stdout \
   | kpt fn eval - -i gcr.io/kpt-fn/set-annotations:v0.1.3 -o path/to/dir  -- foo=bar
+
+  # Render my-package-dir with podman as runtime for functions
+  $ KPT_FN_RUNTIME=podman kpt fn render my-package-dir --runtime podman
 `
 
 var SinkShort = `Write resources to a local directory`
diff --git a/internal/docs/generated/rpkgdocs/docs.go b/internal/docs/generated/rpkgdocs/docs.go
index 2991cf45..0a1cab12 100644
--- a/internal/docs/generated/rpkgdocs/docs.go
+++ b/internal/docs/generated/rpkgdocs/docs.go
@@ -79,28 +79,24 @@ var CloneExamples = `
 
 var CopyShort = `Create a new package revision from an existing one.`
 var CopyLong = `
-  kpt alpha rpkg copy SOURCE_PACKAGE_REV_NAME TARGET_PACKAGE_NAME [flags]
+  kpt alpha rpkg copy SOURCE_PACKAGE_REV_NAME [flags]
 
 Args:
 
   SOURCE_PACKAGE_REV_NAME:
     The name of the package revision that will be used as the source
     for creating a new package revision.
-  
-  TARGET_PACKAGE_NAME:
-    The name of the new package.
 
 Flags:
 
-  --repository
-    Repository in which the new package revision will be created.
-  
   --revision
-    Revision for the new package.
+    Revision for the new package. If this is not specified, the default
+    revision will be ` + "`" + `latest + 1` + "`" + `. The default can only be used if the
+    latest package revision is of the format ` + "`" + `^v[0-9]+$` + "`" + `.
 `
 var CopyExamples = `
-  # create a new package foo from package blueprint-b47eadc99f3c525571d3834cc61b974453bc6be2 
-  $ kpt alpha rpkg copy blueprint-b47eadc99f3c525571d3834cc61b974453bc6be2 foo --repository=blueprint --revision=v10 --namespace=default
+  # create a new package from package blueprint-b47eadc99f3c525571d3834cc61b974453bc6be2
+  $ kpt alpha rpkg copy blueprint-b47eadc99f3c525571d3834cc61b974453bc6be2 --revision=v10 --namespace=default
 `
 
 var DelShort = `Delete a package revision.`
diff --git a/internal/fnruntime/container.go b/internal/fnruntime/container.go
index a42a2f28..ef2281a6 100644
--- a/internal/fnruntime/container.go
+++ b/internal/fnruntime/container.go
@@ -21,33 +21,52 @@ import (
 	goerrors "errors"
 	"fmt"
 	"io"
+	"os"
 	"os/exec"
+	"regexp"
 	"strings"
+	"sync"
 	"time"
 
 	"github.com/GoogleContainerTools/kpt/internal/printer"
 	"github.com/GoogleContainerTools/kpt/internal/types"
 	"github.com/GoogleContainerTools/kpt/internal/util/porch"
 	fnresult "github.com/GoogleContainerTools/kpt/pkg/api/fnresult/v1"
+	"golang.org/x/mod/semver"
 	"sigs.k8s.io/kustomize/kyaml/fn/runtime/runtimeutil"
 )
 
+// We may create multiple instance of ContainerFn, but we only want to check
+// if container runtime is available once.
+var checkContainerRuntimeOnce sync.Once
+
 // containerNetworkName is a type for network name used in container
 type containerNetworkName string
 
 const (
-	networkNameNone    containerNetworkName = "none"
-	networkNameHost    containerNetworkName = "host"
-	defaultLongTimeout time.Duration        = 5 * time.Minute
-	dockerBin          string               = "docker"
+	networkNameNone           containerNetworkName = "none"
+	networkNameHost           containerNetworkName = "host"
+	defaultLongTimeout        time.Duration        = 5 * time.Minute
+	dockerVersionTimeout      time.Duration        = 5 * time.Second
+	minSupportedDockerVersion string               = "v20.10.0"
+
+	dockerBin string = "docker"
+	podmanBin string = "podman"
 
 	AlwaysPull       ImagePullPolicy = "Always"
 	IfNotPresentPull ImagePullPolicy = "IfNotPresent"
 	NeverPull        ImagePullPolicy = "Never"
+
+	ContainerRuntimeEnv = "KPT_FN_RUNTIME"
+
+	Docker ContainerRuntime = "docker"
+	Podman ContainerRuntime = "podman"
 )
 
 type ImagePullPolicy string
 
+type ContainerRuntime string
+
 // ContainerFnPermission contains the permission of container
 // function such as network access.
 type ContainerFnPermission struct {
@@ -86,8 +105,30 @@ type ContainerFn struct {
 // It reads the input from the given reader and writes the output
 // to the provided writer.
 func (f *ContainerFn) Run(reader io.Reader, writer io.Writer) error {
+	// If the env var is empty, stringToContainerRuntime defaults it to docker.
+	runtime, err := stringToContainerRuntime(os.Getenv(ContainerRuntimeEnv))
+	if err != nil {
+		return err
+	}
+
+	checkContainerRuntimeOnce.Do(func() {
+		err = containerRuntimeAvailable(runtime)
+	})
+	if err != nil {
+		return err
+	}
+
+	switch runtime {
+	case Podman:
+		return f.runCLI(reader, writer, podmanBin, filterPodmanCLIOutput)
+	default:
+		return f.runCLI(reader, writer, dockerBin, filterDockerCLIOutput)
+	}
+}
+
+func (f *ContainerFn) runCLI(reader io.Reader, writer io.Writer, bin string, filterCLIOutputFn func(io.Reader) string) error {
 	errSink := bytes.Buffer{}
-	cmd, cancel := f.getDockerCmd()
+	cmd, cancel := f.getCmd(bin)
 	defer cancel()
 	cmd.Stdin = reader
 	cmd.Stdout = writer
@@ -99,7 +140,7 @@ func (f *ContainerFn) Run(reader io.Reader, writer io.Writer) error {
 			return &ExecError{
 				OriginalErr:    exitErr,
 				ExitCode:       exitErr.ExitCode(),
-				Stderr:         filterDockerCLIOutput(&errSink),
+				Stderr:         filterCLIOutputFn(&errSink),
 				TruncateOutput: printer.TruncateOutput,
 			}
 		}
@@ -107,12 +148,14 @@ func (f *ContainerFn) Run(reader io.Reader, writer io.Writer) error {
 	}
 
 	if errSink.Len() > 0 {
-		f.FnResult.Stderr = filterDockerCLIOutput(&errSink)
+		f.FnResult.Stderr = filterCLIOutputFn(&errSink)
 	}
 	return nil
 }
 
-func (f *ContainerFn) getDockerCmd() (*exec.Cmd, context.CancelFunc) {
+// getCmd assembles a command for docker or podman. The input binName is expected
+// to be either "docker" or "podman".
+func (f *ContainerFn) getCmd(binName string) (*exec.Cmd, context.CancelFunc) {
 	network := networkNameNone
 	if f.Perm.AllowNetwork {
 		network = networkNameHost
@@ -134,7 +177,7 @@ func (f *ContainerFn) getDockerCmd() (*exec.Cmd, context.CancelFunc) {
 	case NeverPull:
 		args = append(args, "--pull", "never")
 	case AlwaysPull:
-		args = append(args, "--pull", "pull")
+		args = append(args, "--pull", "always")
 	case IfNotPresentPull:
 		args = append(args, "--pull", "missing")
 	default:
@@ -152,7 +195,7 @@ func (f *ContainerFn) getDockerCmd() (*exec.Cmd, context.CancelFunc) {
 		timeout = f.Timeout
 	}
 	ctx, cancel := context.WithTimeout(context.Background(), timeout)
-	return exec.CommandContext(ctx, dockerBin, args...), cancel
+	return exec.CommandContext(ctx, binName, args...), cancel
 }
 
 // NewContainerEnvFromStringSlice returns a new ContainerEnv pointer with parsing
@@ -254,3 +297,119 @@ func isdockerCLIoutput(s string) bool {
 	}
 	return false
 }
+
+// filterPodmanCLIOutput filters out podman CLI messages
+// from the given buffer.
+func filterPodmanCLIOutput(in io.Reader) string {
+	s := bufio.NewScanner(in)
+	var lines []string
+
+	for s.Scan() {
+		txt := s.Text()
+		if !isPodmanCLIoutput(txt) {
+			lines = append(lines, txt)
+		}
+	}
+	return strings.Join(lines, "\n")
+}
+
+var sha256Matcher = regexp.MustCompile(`^[A-Fa-f0-9]{64}$`)
+
+// isPodmanCLIoutput is helper method to determine if
+// the given string is a podman CLI output message.
+// Example podman output:
+//  "Trying to pull gcr.io/kpt-fn/starlark:v0.3..."
+//  "Getting image source signatures"
+//  "Copying blob sha256:aafbf7df3ddf625f4ababc8e55b4a09131651f9aac340b852b5f40b1a53deb65"
+//  "Copying config sha256:17ce4f65660717ba0afbd143578dfd1c5b9822bd3ad3945c10d6878e057265f1"
+//  "Writing manifest to image destination"
+//  "Storing signatures"
+//  "17ce4f65660717ba0afbd143578dfd1c5b9822bd3ad3945c10d6878e057265f1"
+//
+func isPodmanCLIoutput(s string) bool {
+	if strings.Contains(s, "Trying to pull") ||
+		strings.Contains(s, "Getting image source signatures") ||
+		strings.Contains(s, "Copying blob sha256:") ||
+		strings.Contains(s, "Copying config sha256:") ||
+		strings.Contains(s, "Writing manifest to image destination") ||
+		strings.Contains(s, "Storing signatures") ||
+		sha256Matcher.MatchString(s) {
+		return true
+	}
+	return false
+}
+
+func stringToContainerRuntime(v string) (ContainerRuntime, error) {
+	switch strings.ToLower(v) {
+	case string(Docker):
+		return Docker, nil
+	case string(Podman):
+		return Podman, nil
+	case "":
+		return Docker, nil
+	default:
+		return "", fmt.Errorf("unsupported runtime: %q the runtime must be either %s or %s", v, Docker, Podman)
+	}
+}
+
+func containerRuntimeAvailable(runtime ContainerRuntime) error {
+	switch runtime {
+	case Docker:
+		return dockerCmdAvailable()
+	case Podman:
+		return podmanCmdAvailable()
+	default:
+		return dockerCmdAvailable()
+	}
+}
+
+// dockerCmdAvailable runs `docker version` to check that the docker command is
+// available and is a supported version. Returns an error with installation
+// instructions if it is not
+func dockerCmdAvailable() error {
+	suggestedText := `docker must be running to use this command
+To install docker, follow the instructions at https://docs.docker.com/get-docker/.
+`
+	cmdOut := &bytes.Buffer{}
+
+	ctx, cancel := context.WithTimeout(context.Background(), dockerVersionTimeout)
+	defer cancel()
+	cmd := exec.CommandContext(ctx, "docker", "version", "--format", "{{.Client.Version}}")
+	cmd.Stdout = cmdOut
+	err := cmd.Run()
+	if err != nil || cmdOut.String() == "" {
+		return fmt.Errorf("%s", suggestedText)
+	}
+	return isSupportedDockerVersion(strings.TrimSuffix(cmdOut.String(), "\n"))
+}
+
+// isSupportedDockerVersion returns an error if a given docker version is invalid
+// or is less than minSupportedDockerVersion
+func isSupportedDockerVersion(v string) error {
+	suggestedText := fmt.Sprintf(`docker client version must be %s or greater`, minSupportedDockerVersion)
+	// docker version output does not have a leading v which is required by semver, so we prefix it
+	currentDockerVersion := fmt.Sprintf("v%s", v)
+	if !semver.IsValid(currentDockerVersion) {
+		return fmt.Errorf("%s: found invalid version %s", suggestedText, currentDockerVersion)
+	}
+	// if currentDockerVersion is less than minDockerClientVersion, compare returns +1
+	if semver.Compare(minSupportedDockerVersion, currentDockerVersion) > 0 {
+		return fmt.Errorf("%s: found %s", suggestedText, currentDockerVersion)
+	}
+	return nil
+}
+
+func podmanCmdAvailable() error {
+	suggestedText := `podman must be installed.
+To install podman, follow the instructions at https://podman.io/getting-started/installation.
+`
+
+	ctx, cancel := context.WithTimeout(context.Background(), dockerVersionTimeout)
+	defer cancel()
+	cmd := exec.CommandContext(ctx, "podman", "version")
+	err := cmd.Run()
+	if err != nil {
+		return fmt.Errorf("%s", suggestedText)
+	}
+	return nil
+}
diff --git a/internal/fnruntime/runner.go b/internal/fnruntime/runner.go
index 1e62b9a6..98c404b2 100644
--- a/internal/fnruntime/runner.go
+++ b/internal/fnruntime/runner.go
@@ -508,19 +508,7 @@ func newFnConfig(fsys filesys.FileSystem, f *kptfilev1.Function, pkgPath types.U
 		// directly use the config from file
 		return node, nil
 	case len(f.ConfigMap) != 0:
-		node = yaml.NewMapRNode(&f.ConfigMap)
-		if node == nil {
-			return nil, nil
-		}
-		// create a ConfigMap only for configMap config
-		configNode := yaml.MustParse(`
-apiVersion: v1
-kind: ConfigMap
-metadata:
-  name: function-input
-data: {}
-`)
-		err := configNode.PipeE(yaml.SetField("data", node))
+		configNode, err := NewConfigMap(f.ConfigMap)
 		if err != nil {
 			return nil, errors.E(op, fn, err)
 		}
diff --git a/internal/fnruntime/utils.go b/internal/fnruntime/utils.go
index acfe6d65..b41a3624 100644
--- a/internal/fnruntime/utils.go
+++ b/internal/fnruntime/utils.go
@@ -221,3 +221,30 @@ func annoMatch(node *yaml.RNode, selector kptfilev1.Selector) bool {
 	}
 	return true
 }
+
+func NewConfigMap(data map[string]string) (*yaml.RNode, error) {
+	node := yaml.NewMapRNode(&data)
+	if node == nil {
+		return nil, nil
+	}
+	// create a ConfigMap only for configMap config
+	configMap := yaml.MustParse(`
+apiVersion: v1
+kind: ConfigMap
+metadata:
+  name: function-input
+data: {}
+`)
+	if err := node.VisitFields(func(node *yaml.MapNode) error {
+		v := node.Value.YNode()
+		v.Tag = yaml.NodeTagString
+		node.Value.SetYNode(v)
+		return nil
+	}); err != nil {
+		return nil, err
+	}
+	if err := configMap.PipeE(yaml.SetField("data", node)); err != nil {
+		return nil, err
+	}
+	return configMap, nil
+}
diff --git a/internal/gitutil/gitutil.go b/internal/gitutil/gitutil.go
index 0a37e4cb..b3c7e34c 100644
--- a/internal/gitutil/gitutil.go
+++ b/internal/gitutil/gitutil.go
@@ -137,13 +137,26 @@ func (g *GitLocalRunner) run(ctx context.Context, verbose bool, command string,
 	}, nil
 }
 
+type NewGitUpstreamRepoOption func(*GitUpstreamRepo)
+
+func WithFetchedRefs(a map[string]bool) NewGitUpstreamRepoOption {
+	return func(g *GitUpstreamRepo) {
+		g.fetchedRefs = a
+	}
+}
+
 // NewGitUpstreamRepo returns a new GitUpstreamRepo for an upstream package.
-func NewGitUpstreamRepo(ctx context.Context, uri string) (*GitUpstreamRepo, error) {
+func NewGitUpstreamRepo(ctx context.Context, uri string, opts ...NewGitUpstreamRepoOption) (*GitUpstreamRepo, error) {
 	const op errors.Op = "gitutil.NewGitUpstreamRepo"
-
 	g := &GitUpstreamRepo{
 		URI: uri,
 	}
+	for _, opt := range opts {
+		opt(g)
+	}
+	if g.fetchedRefs == nil {
+		g.fetchedRefs = map[string]bool{}
+	}
 	if err := g.updateRefs(ctx); err != nil {
 		return nil, errors.E(op, errors.Repo(uri), err)
 	}
@@ -161,6 +174,17 @@ type GitUpstreamRepo struct {
 	// Tags contains all tag refs in the upstream repo as well as the
 	// each of the are referencing.
 	Tags map[string]string
+
+	// fetchedRefs keeps track of refs already fetched from remote
+	fetchedRefs map[string]bool
+}
+
+func (gur *GitUpstreamRepo) GetFetchedRefs() []string {
+	fetchedRefs := make([]string, 0, len(gur.fetchedRefs))
+	for ref := range gur.fetchedRefs {
+		fetchedRefs = append(fetchedRefs, ref)
+	}
+	return fetchedRefs
 }
 
 // updateRefs fetches all refs from the upstream git repo, parses the results
@@ -374,20 +398,26 @@ loop:
 		// commit sha.
 		validFullSha := s == strings.TrimSpace(rr.Stdout)
 		_, resolved := gur.ResolveRef(s)
-
+		// check if ref was previously fetched
+		// we use the ref s as the cache key
+		_, fetched := gur.fetchedRefs[s]
 		switch {
+		case fetched:
+			// skip refetching if previously fetched
+			break
 		case resolved || validFullSha:
 			// If the ref references a branch or a tag, or is a valid commit
-			// sha, we can fetch just a single commit.
+			// sha and has not already been fetched, we can fetch just a single commit.
 			if _, err := gitRunner.RunVerbose(ctx, "fetch", "origin", "--depth=1", s); err != nil {
 				AmendGitExecError(err, func(e *GitExecError) {
 					e.Repo = uri
-					e.Command = "origin"
+					e.Command = "fetch"
 					e.Ref = s
 				})
 				return "", errors.E(op, errors.Git, fmt.Errorf(
 					"error running `git fetch` for ref %q: %w", s, err))
 			}
+			gur.fetchedRefs[s] = true
 		default:
 			// In other situations (like a short commit sha), we have to do
 			// a full fetch from the remote.
@@ -407,6 +437,7 @@ loop:
 				return "", errors.E(op, errors.Git, fmt.Errorf(
 					"error verifying results from fetch: %w", err))
 			}
+			gur.fetchedRefs[s] = true
 			// If we did a full fetch, we already have all refs, so we can just
 			// exit the loop.
 			break loop
diff --git a/internal/hook/executor.go b/internal/hook/executor.go
index 5282d635..1e983ee2 100644
--- a/internal/hook/executor.go
+++ b/internal/hook/executor.go
@@ -22,7 +22,6 @@ import (
 	"github.com/GoogleContainerTools/kpt/internal/fnruntime"
 	"github.com/GoogleContainerTools/kpt/internal/pkg"
 	"github.com/GoogleContainerTools/kpt/internal/types"
-	"github.com/GoogleContainerTools/kpt/internal/util/cmdutil"
 	fnresult "github.com/GoogleContainerTools/kpt/pkg/api/fnresult/v1"
 	kptfilev1 "github.com/GoogleContainerTools/kpt/pkg/api/kptfile/v1"
 	"github.com/GoogleContainerTools/kpt/pkg/fn"
@@ -50,10 +49,6 @@ type Executor struct {
 	// fnResults stores function results gathered
 	// during pipeline execution.
 	fnResults *fnresult.ResultList
-
-	// bookkeeping to ensure docker command availability check is done once
-	// during rendering
-	dockerCheckDone bool
 }
 
 // Execute executes given hook.
@@ -91,13 +86,6 @@ func (e *Executor) fnChain(ctx context.Context, fns []kptfilev1.Function) ([]kio
 		if fn.Exec != "" && !e.AllowExec {
 			return nil, ErrAllowedExecNotSpecified
 		}
-		if fn.Image != "" && !e.dockerCheckDone {
-			err := cmdutil.DockerCmdAvailable()
-			if err != nil {
-				return nil, err
-			}
-			e.dockerCheckDone = true
-		}
 		runner, err = fnruntime.NewRunner(ctx,
 			e.FileSystem,
 			&fn,
diff --git a/internal/util/cmdutil/cmdutil.go b/internal/util/cmdutil/cmdutil.go
index d66bdef2..188ad24f 100644
--- a/internal/util/cmdutil/cmdutil.go
+++ b/internal/util/cmdutil/cmdutil.go
@@ -15,15 +15,11 @@
 package cmdutil
 
 import (
-	"bytes"
-	"context"
 	"encoding/json"
 	"fmt"
 	"io"
 	"os"
-	"os/exec"
 	"strings"
-	"time"
 
 	"github.com/GoogleContainerTools/kpt/internal/fnruntime"
 	"github.com/GoogleContainerTools/kpt/internal/util/function"
@@ -37,13 +33,11 @@ import (
 )
 
 const (
-	StackTraceOnErrors                      = "COBRA_STACK_TRACE_ON_ERRORS"
-	trueString                              = "true"
-	Stdout                                  = "stdout"
-	Unwrap                                  = "unwrap"
-	dockerVersionTimeout      time.Duration = 5 * time.Second
-	minSupportedDockerVersion string        = "v20.10.0"
-	FunctionsCatalogURL                     = "https://catalog.kpt.dev/catalog-v2.json"
+	StackTraceOnErrors  = "COBRA_STACK_TRACE_ON_ERRORS"
+	trueString          = "true"
+	Stdout              = "stdout"
+	Unwrap              = "unwrap"
+	FunctionsCatalogURL = "https://catalog.kpt.dev/catalog-v2.json"
 )
 
 // FixDocs replaces instances of old with new in the docs for c
@@ -65,42 +59,6 @@ func PrintErrorStacktrace() bool {
 // StackOnError if true, will print a stack trace on failure.
 var StackOnError bool
 
-// DockerCmdAvailable runs `docker version` to check that the docker command is
-// available and is a supported version. Returns an error with installation
-// instructions if it is not
-func DockerCmdAvailable() error {
-	suggestedText := `docker must be running to use this command
-To install docker, follow the instructions at https://docs.docker.com/get-docker/.
-`
-	cmdOut := &bytes.Buffer{}
-
-	ctx, cancel := context.WithTimeout(context.Background(), dockerVersionTimeout)
-	defer cancel()
-	cmd := exec.CommandContext(ctx, "docker", "version", "--format", "{{.Client.Version}}")
-	cmd.Stdout = cmdOut
-	err := cmd.Run()
-	if err != nil || cmdOut.String() == "" {
-		return fmt.Errorf("%s", suggestedText)
-	}
-	return isSupportedDockerVersion(strings.TrimSuffix(cmdOut.String(), "\n"))
-}
-
-// isSupportedDockerVersion returns an error if a given docker version is invalid
-// or is less than minSupportedDockerVersion
-func isSupportedDockerVersion(v string) error {
-	suggestedText := fmt.Sprintf(`docker client version must be %s or greater`, minSupportedDockerVersion)
-	// docker version output does not have a leading v which is required by semver, so we prefix it
-	currentDockerVersion := fmt.Sprintf("v%s", v)
-	if !semver.IsValid(currentDockerVersion) {
-		return fmt.Errorf("%s: found invalid version %s", suggestedText, currentDockerVersion)
-	}
-	// if currentDockerVersion is less than minDockerClientVersion, compare returns +1
-	if semver.Compare(minSupportedDockerVersion, currentDockerVersion) > 0 {
-		return fmt.Errorf("%s: found %s", suggestedText, currentDockerVersion)
-	}
-	return nil
-}
-
 func ValidateImagePullPolicyValue(v string) error {
 	v = strings.ToLower(v)
 	if v != strings.ToLower(string(fnruntime.AlwaysPull)) &&
diff --git a/internal/util/fetch/fetch.go b/internal/util/fetch/fetch.go
index 6b2040e0..156e58d2 100644
--- a/internal/util/fetch/fetch.go
+++ b/internal/util/fetch/fetch.go
@@ -62,7 +62,7 @@ func (c Command) Run(ctx context.Context) error {
 		Path:    g.Directory,
 		Ref:     g.Ref,
 	}
-	err = cloneAndCopy(ctx, repoSpec, c.Pkg.UniquePath.String())
+	err = NewCloner(repoSpec).cloneAndCopy(ctx, c.Pkg.UniquePath.String())
 	if err != nil {
 		return errors.E(op, c.Pkg.UniquePath, err)
 	}
@@ -94,21 +94,55 @@ func (c Command) validate(kf *kptfilev1.KptFile) error {
 	return nil
 }
 
+// Cloner clones an upstream repo defined by a repoSpec.
+// Optionally, previously cloned repos can be cached
+// rather than recloning them each time.
+type Cloner struct {
+	// repoSpec spec to clone
+	repoSpec *git.RepoSpec
+
+	// cachedRepos
+	cachedRepo map[string]*gitutil.GitUpstreamRepo
+}
+
+type NewClonerOption func(*Cloner)
+
+func WithCachedRepo(r map[string]*gitutil.GitUpstreamRepo) NewClonerOption {
+	return func(c *Cloner) {
+		c.cachedRepo = r
+	}
+}
+
+func NewCloner(r *git.RepoSpec, opts ...NewClonerOption) *Cloner {
+	c := &Cloner{
+		repoSpec: r,
+	}
+	for _, opt := range opts {
+		opt(c)
+	}
+	if c.cachedRepo == nil {
+		c.cachedRepo = make(map[string]*gitutil.GitUpstreamRepo)
+	}
+	return c
+}
+
 // cloneAndCopy fetches the provided repo and copies the content into the
 // directory specified by dest. The provided name is set as `metadata.name`
 // of the Kptfile of the package.
-func cloneAndCopy(ctx context.Context, r *git.RepoSpec, dest string) error {
+func (c *Cloner) cloneAndCopy(ctx context.Context, dest string) error {
 	const op errors.Op = "fetch.cloneAndCopy"
 	pr := printer.FromContextOrDie(ctx)
 
-	err := ClonerUsingGitExec(ctx, r)
+	err := c.ClonerUsingGitExec(ctx)
 	if err != nil {
 		return errors.E(op, errors.Git, types.UniquePath(dest), err)
 	}
-	defer os.RemoveAll(r.Dir)
+	defer os.RemoveAll(c.repoSpec.Dir)
+	// update cache before removing clone dir
+	defer delete(c.cachedRepo, c.repoSpec.CloneSpec())
 
-	sourcePath := filepath.Join(r.Dir, r.Path)
-	pr.Printf("Adding package %q.\n", strings.TrimPrefix(r.Path, "/"))
+	sourcePath := filepath.Join(c.repoSpec.Dir, c.repoSpec.Path)
+	pr.Printf("Adding package %q.\n", strings.TrimPrefix(c.repoSpec.Path, "/"))
 	if err := pkgutil.CopyPackage(sourcePath, dest, true, pkg.All); err != nil {
 		return errors.E(op, types.UniquePath(dest), err)
 	}
@@ -117,7 +151,7 @@ func cloneAndCopy(ctx context.Context, r *git.RepoSpec, dest string) error {
 		return errors.E(op, types.UniquePath(dest), err)
 	}
 
-	if err := kptfileutil.UpdateUpstreamLockFromGit(dest, r); err != nil {
+	if err := kptfileutil.UpdateUpstreamLockFromGit(dest, c.repoSpec); err != nil {
 		return errors.E(op, errors.Git, types.UniquePath(dest), err)
 	}
 	return nil
@@ -129,46 +163,51 @@ func cloneAndCopy(ctx context.Context, r *git.RepoSpec, dest string) error {
 // for versioning multiple kpt packages in a single repo independently. It
 // relies on the private clonerUsingGitExec function to try fetching different
 // refs.
-func ClonerUsingGitExec(ctx context.Context, repoSpec *git.RepoSpec) error {
+func (c *Cloner) ClonerUsingGitExec(ctx context.Context) error {
 	const op errors.Op = "fetch.ClonerUsingGitExec"
 
 	// Create a local representation of the upstream repo. This will initialize
 	// the cache for the specified repo uri if it isn't already there. It also
 	// fetches and caches all tag and branch refs from the upstream repo.
-	upstreamRepo, err := gitutil.NewGitUpstreamRepo(ctx, repoSpec.CloneSpec())
-	if err != nil {
-		return errors.E(op, errors.Git, errors.Repo(repoSpec.CloneSpec()), err)
+	upstreamRepo, exists := c.cachedRepo[c.repoSpec.CloneSpec()]
+	if !exists {
+		newUpstreamRemp, err := gitutil.NewGitUpstreamRepo(ctx, c.repoSpec.CloneSpec())
+		if err != nil {
+			return errors.E(op, errors.Git, errors.Repo(c.repoSpec.CloneSpec()), err)
+		}
+		upstreamRepo = newUpstreamRemp
+		c.cachedRepo[c.repoSpec.CloneSpec()] = upstreamRepo
 	}
 
 	// Check if we have a ref in the upstream that matches the package-specific
 	// reference. If we do, we use that reference.
-	ps := strings.Split(repoSpec.Path, "/")
+	ps := strings.Split(c.repoSpec.Path, "/")
 	for len(ps) != 0 {
 		p := path.Join(ps...)
-		packageRef := path.Join(strings.TrimLeft(p, "/"), repoSpec.Ref)
+		packageRef := path.Join(strings.TrimLeft(p, "/"), c.repoSpec.Ref)
 		if _, found := upstreamRepo.ResolveTag(packageRef); found {
-			repoSpec.Ref = packageRef
+			c.repoSpec.Ref = packageRef
 			break
 		}
 		ps = ps[:len(ps)-1]
 	}
 
 	// Pull the required ref into the repo git cache.
-	dir, err := upstreamRepo.GetRepo(ctx, []string{repoSpec.Ref})
+	dir, err := upstreamRepo.GetRepo(ctx, []string{c.repoSpec.Ref})
 	if err != nil {
-		return errors.E(op, errors.Git, errors.Repo(repoSpec.CloneSpec()), err)
+		return errors.E(op, errors.Git, errors.Repo(c.repoSpec.CloneSpec()), err)
 	}
 
 	gitRunner, err := gitutil.NewLocalGitRunner(dir)
 	if err != nil {
-		return errors.E(op, errors.Git, errors.Repo(repoSpec.CloneSpec()), err)
+		return errors.E(op, errors.Git, errors.Repo(c.repoSpec.CloneSpec()), err)
 	}
 
 	// Find the commit SHA for the ref that was just fetched. We need the SHA
 	// rather than the ref to be able to do a hard reset of the cache repo.
-	commit, found := upstreamRepo.ResolveRef(repoSpec.Ref)
+	commit, found := upstreamRepo.ResolveRef(c.repoSpec.Ref)
 	if !found {
-		commit = repoSpec.Ref
+		commit = c.repoSpec.Ref
 	}
 
 	// Reset the local repo to the commit we need. Doing a hard reset instead of
@@ -178,34 +217,34 @@ func ClonerUsingGitExec(ctx context.Context, repoSpec *git.RepoSpec) error {
 	_, err = gitRunner.Run(ctx, "reset", "--hard", commit)
 	if err != nil {
 		gitutil.AmendGitExecError(err, func(e *gitutil.GitExecError) {
-			e.Repo = repoSpec.CloneSpec()
+			e.Repo = c.repoSpec.CloneSpec()
 			e.Ref = commit
 		})
-		return errors.E(op, errors.Git, errors.Repo(repoSpec.CloneSpec()), err)
+		return errors.E(op, errors.Git, errors.Repo(c.repoSpec.CloneSpec()), err)
 	}
 
 	// We need to create a temp directory where we can copy the content of the repo.
 	// During update, we need to checkout multiple versions of the same repo, so
 	// we can't do merges directly from the cache.
-	repoSpec.Dir, err = ioutil.TempDir("", "kpt-get-")
+	c.repoSpec.Dir, err = ioutil.TempDir("", "kpt-get-")
 	if err != nil {
 		return errors.E(op, errors.Internal, fmt.Errorf("error creating temp directory: %w", err))
 	}
-	repoSpec.Commit = commit
+	c.repoSpec.Commit = commit
 
-	pkgPath := filepath.Join(dir, repoSpec.Path)
+	pkgPath := filepath.Join(dir, c.repoSpec.Path)
 	// Verify that the requested path exists in the repo.
 	_, err = os.Stat(pkgPath)
 	if os.IsNotExist(err) {
 		return errors.E(op,
 			errors.Internal,
 			err,
-			fmt.Errorf("path %q does not exist in repo %q", repoSpec.Path, repoSpec.OrgRepo))
+			fmt.Errorf("path %q does not exist in repo %q", c.repoSpec.Path, c.repoSpec.OrgRepo))
 	}
 
 	// Copy the content of the pkg into the temp directory.
 	// Note that we skip the content outside the package directory.
-	err = copyDir(ctx, pkgPath, repoSpec.AbsPath())
+	err = copyDir(ctx, pkgPath, c.repoSpec.AbsPath())
 	if err != nil {
 		return errors.E(op, errors.Internal, fmt.Errorf("error copying package: %w", err))
 	}
@@ -226,7 +265,7 @@ func ClonerUsingGitExec(ctx context.Context, repoSpec *git.RepoSpec) error {
 		var kfError *pkg.KptfileError
 		if errors.As(err, &kfError) {
 			return &pkg.RemoteKptfileError{
-				RepoSpec: repoSpec,
+				RepoSpec: c.repoSpec,
 				Err:      kfError.Err,
 			}
 		}
diff --git a/internal/util/render/executor.go b/internal/util/render/executor.go
index 1e130502..6a2c3b30 100644
--- a/internal/util/render/executor.go
+++ b/internal/util/render/executor.go
@@ -28,7 +28,6 @@ import (
 	"github.com/GoogleContainerTools/kpt/internal/printer"
 	"github.com/GoogleContainerTools/kpt/internal/types"
 	"github.com/GoogleContainerTools/kpt/internal/util/attribution"
-	"github.com/GoogleContainerTools/kpt/internal/util/cmdutil"
 	"github.com/GoogleContainerTools/kpt/internal/util/printerutil"
 	fnresult "github.com/GoogleContainerTools/kpt/pkg/api/fnresult/v1"
 	kptfilev1 "github.com/GoogleContainerTools/kpt/pkg/api/kptfile/v1"
@@ -199,10 +198,6 @@ type hydrationContext struct {
 	// privileged operation, so explicit permission is required.
 	allowExec bool
 
-	// bookkeeping to ensure docker command availability check is done once
-	// during rendering
-	dockerCheckDone bool
-
 	fileSystem filesys.FileSystem
 
 	// function runtime
@@ -480,16 +475,6 @@ func (pn *pkgNode) runValidators(ctx context.Context, hctx *hydrationContext, in
 		if function.Exec != "" && !hctx.allowExec {
 			return errAllowedExecNotSpecified
 		}
-		if function.Image != "" && !hctx.dockerCheckDone {
-			if hctx.runtime == nil {
-				// Check for Docker when using standard runner.
-				err := cmdutil.DockerCmdAvailable()
-				if err != nil {
-					return err
-				}
-			}
-			hctx.dockerCheckDone = true
-		}
 		validator, err = fnruntime.NewRunner(ctx, hctx.fileSystem, &function, pn.pkg.UniquePath, hctx.fnResults, hctx.imagePullPolicy, true, displayResourceCount, hctx.runtime)
 		if err != nil {
 			return err
@@ -597,16 +582,6 @@ func fnChain(ctx context.Context, hctx *hydrationContext, pkgPath types.UniquePa
 		if function.Exec != "" && !hctx.allowExec {
 			return nil, errAllowedExecNotSpecified
 		}
-		if function.Image != "" && !hctx.dockerCheckDone {
-			if hctx.runtime == nil {
-				// Check for Docker when using standard runner.
-				err := cmdutil.DockerCmdAvailable()
-				if err != nil {
-					return nil, err
-				}
-			}
-			hctx.dockerCheckDone = true
-		}
 		runner, err = fnruntime.NewRunner(ctx, hctx.fileSystem, &function, pkgPath, hctx.fnResults, hctx.imagePullPolicy, true, displayResourceCount, hctx.runtime)
 		if err != nil {
 			return nil, err
diff --git a/internal/util/update/update.go b/internal/util/update/update.go
index df0658c3..ef50a91b 100644
--- a/internal/util/update/update.go
+++ b/internal/util/update/update.go
@@ -25,6 +25,7 @@ import (
 	"strings"
 
 	"github.com/GoogleContainerTools/kpt/internal/errors"
+	"github.com/GoogleContainerTools/kpt/internal/gitutil"
 	"github.com/GoogleContainerTools/kpt/internal/pkg"
 	"github.com/GoogleContainerTools/kpt/internal/printer"
 	"github.com/GoogleContainerTools/kpt/internal/types"
@@ -102,10 +103,13 @@ type Command struct {
 
 	// Strategy is the update strategy to use
 	Strategy kptfilev1.UpdateStrategyType
+
+	// cachedUpstreamRepos is an upstream repo already fetched for a given repoSpec CloneRef
+	cachedUpstreamRepos map[string]*gitutil.GitUpstreamRepo
 }
 
 // Run runs the Command.
-func (u Command) Run(ctx context.Context) error {
+func (u *Command) Run(ctx context.Context) error {
 	const op errors.Op = "update.Run"
 	pr := printer.FromContextOrDie(ctx)
 
@@ -133,7 +137,9 @@ func (u Command) Run(ctx context.Context) error {
 	if err != nil {
 		return errors.E(op, u.Pkg.UniquePath, err)
 	}
-
+	if u.cachedUpstreamRepos == nil {
+		u.cachedUpstreamRepos = make(map[string]*gitutil.GitUpstreamRepo)
+	}
 	packageCount := 0
 
 	// Use stack to keep track of paths with a Kptfile that might contain
@@ -182,6 +188,11 @@ func (u Command) Run(ctx context.Context) error {
 	return nil
 }
 
+// GetCachedUpstreamRepos returns repos cached during update
+func (u Command) GetCachedUpstreamRepos() map[string]*gitutil.GitUpstreamRepo {
+	return u.cachedUpstreamRepos
+}
+
 // updateSubKf updates subpackage with given ref and update strategy
 func updateSubKf(subKf *kptfilev1.KptFile, ref string, strategy kptfilev1.UpdateStrategyType) {
 	// check if explicit ref provided
@@ -250,7 +261,8 @@ func (u Command) updateRootPackage(ctx context.Context, p *pkg.Pkg) error {
 	g := kf.Upstream.Git
 	updated := &git.RepoSpec{OrgRepo: g.Repo, Path: g.Directory, Ref: g.Ref}
 	pr.Printf("Fetching upstream from %s@%s\n", kf.Upstream.Git.Repo, kf.Upstream.Git.Ref)
-	if err := fetch.ClonerUsingGitExec(ctx, updated); err != nil {
+	cloner := fetch.NewCloner(updated, fetch.WithCachedRepo(u.cachedUpstreamRepos))
+	if err := cloner.ClonerUsingGitExec(ctx); err != nil {
 		return errors.E(op, p.UniquePath, err)
 	}
 	defer os.RemoveAll(updated.AbsPath())
@@ -260,7 +272,7 @@ func (u Command) updateRootPackage(ctx context.Context, p *pkg.Pkg) error {
 		gLock := kf.UpstreamLock.Git
 		originRepoSpec := &git.RepoSpec{OrgRepo: gLock.Repo, Path: gLock.Directory, Ref: gLock.Commit}
 		pr.Printf("Fetching origin from %s@%s\n", kf.Upstream.Git.Repo, kf.Upstream.Git.Ref)
-		if err := fetch.ClonerUsingGitExec(ctx, originRepoSpec); err != nil {
+		if err := fetch.NewCloner(originRepoSpec, fetch.WithCachedRepo(u.cachedUpstreamRepos)).ClonerUsingGitExec(ctx); err != nil {
 			return errors.E(op, p.UniquePath, err)
 		}
 		origin = originRepoSpec
diff --git a/porch/pkg/engine/eval.go b/porch/pkg/engine/eval.go
index 5ef7fe84..74274794 100644
--- a/porch/pkg/engine/eval.go
+++ b/porch/pkg/engine/eval.go
@@ -18,10 +18,10 @@ import (
 	"context"
 	"fmt"
 
+	"github.com/GoogleContainerTools/kpt/internal/fnruntime"
 	v1 "github.com/GoogleContainerTools/kpt/pkg/api/kptfile/v1"
 	"github.com/GoogleContainerTools/kpt/pkg/fn"
 	api "github.com/GoogleContainerTools/kpt/porch/api/porch/v1alpha1"
-	"github.com/GoogleContainerTools/kpt/porch/pkg/kpt"
 	"github.com/GoogleContainerTools/kpt/porch/pkg/repository"
 	"go.opentelemetry.io/otel/trace"
 	"sigs.k8s.io/kustomize/kyaml/fn/runtime/runtimeutil"
@@ -51,7 +51,7 @@ func (m *evalFunctionMutation) Apply(ctx context.Context, resources repository.P
 
 	var functionConfig *yaml.RNode
 	if m.task.Eval.ConfigMap != nil {
-		if cm, err := kpt.NewConfigMap(m.task.Eval.ConfigMap); err != nil {
+		if cm, err := fnruntime.NewConfigMap(m.task.Eval.ConfigMap); err != nil {
 			return repository.PackageResources{}, nil, fmt.Errorf("failed to create function config: %w", err)
 		} else {
 			functionConfig = cm
diff --git a/porch/pkg/kpt/eval.go b/porch/pkg/kpt/eval.go
index 26d2cfc8..549007ae 100644
--- a/porch/pkg/kpt/eval.go
+++ b/porch/pkg/kpt/eval.go
@@ -23,7 +23,6 @@ import (
 	"github.com/GoogleContainerTools/kpt/porch/pkg/kpt/internal"
 	"sigs.k8s.io/kustomize/kyaml/fn/framework"
 	"sigs.k8s.io/kustomize/kyaml/kio"
-	"sigs.k8s.io/kustomize/kyaml/yaml"
 )
 
 func NewSimpleFunctionRuntime() FunctionRuntime {
@@ -67,22 +66,3 @@ func (fr *runner) Run(r io.Reader, w io.Writer) error {
 
 	return framework.Execute(fr.processor, rw)
 }
-
-func NewConfigMap(data map[string]string) (*yaml.RNode, error) {
-	node := yaml.NewMapRNode(&data)
-	if node == nil {
-		return nil, nil
-	}
-	// create a ConfigMap only for configMap config
-	configMap := yaml.MustParse(`
-apiVersion: v1
-kind: ConfigMap
-metadata:
-  name: function-input
-data: {}
-`)
-	if err := configMap.PipeE(yaml.SetField("data", node)); err != nil {
-		return nil, err
-	}
-	return configMap, nil
-}
diff --git a/porch/pkg/kpt/pkgupdate.go b/porch/pkg/kpt/pkgupdate.go
index e6263845..e0b56a67 100644
--- a/porch/pkg/kpt/pkgupdate.go
+++ b/porch/pkg/kpt/pkgupdate.go
@@ -86,7 +86,7 @@ func PkgUpdate(ctx context.Context, ref string, packageDir string, opts PkgUpdat
 		// 	return errors.E(op, p.UniquePath, err)
 		// }
 		updated := *upstream
-		if err := fetch.ClonerUsingGitExec(ctx, &updated); err != nil {
+		if err := fetch.NewCloner(&updated).ClonerUsingGitExec(ctx); err != nil {
 			return err
 		}
 		defer os.RemoveAll(updated.AbsPath())
@@ -102,7 +102,7 @@ func PkgUpdate(ctx context.Context, ref string, packageDir string, opts PkgUpdat
 			// if err := fetch.ClonerUsingGitExec(ctx, originRepoSpec); err != nil {
 			// 	return errors.E(op, p.UniquePath, err)
 			// }
-			if err := fetch.ClonerUsingGitExec(ctx, originRepoSpec); err != nil {
+			if err := fetch.NewCloner(originRepoSpec).ClonerUsingGitExec(ctx); err != nil {
 				return err //errors.E(op, p.UniquePath, err)
 			}
 			originDir = originRepoSpec.AbsPath()
diff --git a/site/book/05-developing-functions/02-developing-in-Go.md b/site/book/05-developing-functions/02-developing-in-Go.md
index b95a8eaa..92d962ac 100644
--- a/site/book/05-developing-functions/02-developing-in-Go.md
+++ b/site/book/05-developing-functions/02-developing-in-Go.md
@@ -78,7 +78,7 @@ adds the annotation. After the iteration, it adds some user message to the `Reso
 ```go
 func Run(rl *fn.ResourceList) (bool, error) {
     for _, kubeObject := range rl.Items {
-        if kubeObject.IsGVK("v1", "Deployment") {
+        if kubeObject.IsGVK("apps/v1", "Deployment") {
             kubeObject.SetAnnotation("config.kubernetes.io/managed-by", "kpt")
         }
     }
diff --git a/site/reference/cli/alpha/rpkg/copy/README.md b/site/reference/cli/alpha/rpkg/copy/README.md
index d0e7133e..e15a991d 100644
--- a/site/reference/cli/alpha/rpkg/copy/README.md
+++ b/site/reference/cli/alpha/rpkg/copy/README.md
@@ -19,7 +19,7 @@ revision.
 <!--mdtogo:Long-->
 
 ```
-kpt alpha rpkg copy SOURCE_PACKAGE_REV_NAME TARGET_PACKAGE_NAME [flags]
+kpt alpha rpkg copy SOURCE_PACKAGE_REV_NAME [flags]
 ```
 
 #### Args
@@ -28,19 +28,15 @@ kpt alpha rpkg copy SOURCE_PACKAGE_REV_NAME TARGET_PACKAGE_NAME [flags]
 SOURCE_PACKAGE_REV_NAME:
   The name of the package revision that will be used as the source
   for creating a new package revision.
-
-TARGET_PACKAGE_NAME:
-  The name of the new package.
 ```
 
 #### Flags
 
 ```
---repository
-  Repository in which the new package revision will be created.
-
 --revision
-  Revision for the new package.
+  Revision for the new package. If this is not specified, the default
+  revision will be `latest + 1`. The default can only be used if the
+  latest package revision is of the format `^v[0-9]+$`.
 ```
 
 <!--mdtogo-->
@@ -50,8 +46,8 @@ TARGET_PACKAGE_NAME:
 <!--mdtogo:Examples-->
 
 ```shell
-# create a new package foo from package blueprint-b47eadc99f3c525571d3834cc61b974453bc6be2 
-$ kpt alpha rpkg copy blueprint-b47eadc99f3c525571d3834cc61b974453bc6be2 foo --repository=blueprint --revision=v10 --namespace=default
+# create a new package from package blueprint-b47eadc99f3c525571d3834cc61b974453bc6be2
+$ kpt alpha rpkg copy blueprint-b47eadc99f3c525571d3834cc61b974453bc6be2 --revision=v10 --namespace=default
 ```
 
 <!--mdtogo-->
\ No newline at end of file
diff --git a/site/reference/cli/fn/eval/README.md b/site/reference/cli/fn/eval/README.md
index 45ed8e90..233a06d6 100644
--- a/site/reference/cli/fn/eval/README.md
+++ b/site/reference/cli/fn/eval/README.md
@@ -58,18 +58,6 @@ fn-args:
   By default, container function is executed as `nobody` user. You may want to use
   this flag to run higher privilege operations such as mounting the local filesystem.
 
---match-api-version:
-  Select resources matching the given apiVersion.
-
---match-kind
-  Select resources matching the given kind.
-
---match-name:
-  Select resources matching the given name.
-  
---match-namespace:
-  Select resources matching the given namespace.
-
 --env, e:
   List of local environment variables to be exported to the container function.
   By default, none of local environment variables are made available to the
@@ -106,6 +94,18 @@ fn-args:
   (DEPRECATED) include-meta-resources is no longer necessary because meta
   resources are included by default with kpt version v1.0.0-beta.15+.
 
+--match-api-version:
+  Select resources matching the given apiVersion.
+
+--match-kind
+  Select resources matching the given kind.
+
+--match-name:
+  Select resources matching the given name.
+  
+--match-namespace:
+  Select resources matching the given namespace.
+
 --mount:
   List of storage options to enable reading from the local filesytem. By default,
   container functions can not access the local filesystem. It accepts the same options
@@ -125,11 +125,28 @@ fn-args:
   3. OUT_DIR_PATH: output resources are written to provided directory.
      The provided directory must not already exist.
 
+--type, t;
+  Specify the function type. Accept value `mutator` (default), `validator`. 
+  If used with `--save`, this flag will save the evaluated function to the corresponding
+   Kptfile section: `.pipeline.mutators` if type is `mutator`; `.pipeline.validators` if type
+    is `validator`.
+
 --results-dir:
   Path to a directory to write structured results. Directory will be created if
   it doesn't exist. Structured results emitted by the functions are aggregated and saved
   to `results.yaml` file in the specified directory.
   If not specified, no result files are written to the local filesystem.
+  
+--save, s:
+  Save the function image and fn-config to Kptfile. Require ` + "`" + `--image` + "`" + `.
+  
+```
+
+#### Environment Variables
+
+```
+KPT_FN_RUNTIME:
+  The runtime to run kpt functions. It must be one of "docker" or "podman".
 ```
 
 <!--mdtogo-->
@@ -155,6 +172,16 @@ $ kpt fn eval DIR -i gcr.io/example.com/my-fn --fn-config my-fn-config
 $ kpt fn eval DIR -i gcr.io/example.com/my-fn:v1.0.0 -- foo=bar
 ```
 
+```shell
+# execute container my-fn and save it to Kptfile `pipeline.mutators` (Default) list.
+$ kpt fn eval DIR -s -i gcr.io/example.com/my-fn:v1.0.0 -- foo=bar
+```
+
+```shell
+# execute container my-fn and save it to Kptfile `pipeline.validators` list.
+$ kpt fn eval DIR -s -t validator -i gcr.io/example.com/my-fn:v1.0.0 -- foo=bar
+```
+
 ```shell
 # execute executable my-fn on the resources in DIR directory and
 # write output back to DIR
@@ -228,6 +255,12 @@ $ kpt fn eval -i gcr.io/kpt-fn/set-namespace:v0.1 -o stdout -- namespace=staging
 kpt fn eval -i set-namespace:v0.1 --by-kind Deployment --by-name foo -- namespace=staging
 ```
 
+```shell
+# execute container my-fn with podman on the resources in DIR directory and
+# write output back to DIR
+$ KPT_FN_RUNTIME=podman kpt fn eval DIR -i gcr.io/example.com/my-fn
+```
+
 <!--mdtogo-->
 
 [docker volumes]: https://docs.docker.com/storage/volumes/
diff --git a/site/reference/cli/fn/render/README.md b/site/reference/cli/fn/render/README.md
index 17ec5c35..a18f5fc2 100644
--- a/site/reference/cli/fn/render/README.md
+++ b/site/reference/cli/fn/render/README.md
@@ -71,6 +71,13 @@ PKG_PATH:
   If not specified, no result files are written to the local filesystem.
 ```
 
+#### Environment Variables
+
+```
+KPT_FN_RUNTIME:
+  The runtime to run kpt functions. It must be one of "docker" or "podman".
+```
+
 <!--mdtogo-->
 
 ### Examples
@@ -111,6 +118,11 @@ $ kpt fn render -o stdout \
 | kpt fn eval - -i gcr.io/kpt-fn/set-annotations:v0.1.3 -o path/to/dir  -- foo=bar
 ```
 
+```shell
+# Render my-package-dir with podman as runtime for functions
+$ KPT_FN_RUNTIME=podman kpt fn render my-package-dir --runtime podman
+```
+
 <!--mdtogo-->
 
 [declarative functions execution]:
diff --git a/thirdparty/cmdconfig/commands/cmdeval/cmdeval.go b/thirdparty/cmdconfig/commands/cmdeval/cmdeval.go
index 5b601b19..12b1fb23 100644
--- a/thirdparty/cmdconfig/commands/cmdeval/cmdeval.go
+++ b/thirdparty/cmdconfig/commands/cmdeval/cmdeval.go
@@ -448,10 +448,6 @@ func (r *EvalFnRunner) preRunE(c *cobra.Command, args []string) error {
 	}
 	if r.Image != "" {
 		r.Image = fnruntime.AddDefaultImagePathPrefix(c.Context(), r.Image)
-		err := cmdutil.DockerCmdAvailable()
-		if err != nil {
-			return err
-		}
 	}
 	var dataItems []string
 	if c.ArgsLenAtDash() >= 0 {


Test patch:
diff --git a/e2e/fn_test.go b/e2e/fn_test.go
index 930b7eae..f2d6ad65 100644
--- a/e2e/fn_test.go
+++ b/e2e/fn_test.go
@@ -18,9 +18,12 @@
 package e2e_test
 
 import (
+	"os"
 	"path/filepath"
+	"strings"
 	"testing"
 
+	"github.com/GoogleContainerTools/kpt/internal/fnruntime"
 	"github.com/GoogleContainerTools/kpt/pkg/test/runner"
 )
 
@@ -56,6 +59,20 @@ func runTests(t *testing.T, cases *runner.TestCases, sequential bool) {
 		if c.Config.Sequential != sequential {
 			continue
 		}
+		// If the current function runtime doesn't match, we skip this test case.
+		currRuntime := strings.ToLower(os.Getenv(fnruntime.ContainerRuntimeEnv))
+		if len(c.Config.Runtimes) > 0 {
+			skip := true
+			for _, rt := range c.Config.Runtimes {
+				if currRuntime == strings.ToLower(rt) {
+					skip = false
+					break
+				}
+			}
+			if skip {
+				continue
+			}
+		}
 		t.Run(c.Path, func(t *testing.T) {
 			if !c.Config.Sequential {
 				t.Parallel()
diff --git a/e2e/testdata/fn-eval/default-runtime/.expected/diff.patch b/e2e/testdata/fn-eval/default-runtime/.expected/diff.patch
new file mode 100644
index 00000000..63ccc2b3
--- /dev/null
+++ b/e2e/testdata/fn-eval/default-runtime/.expected/diff.patch
@@ -0,0 +1,19 @@
+diff --git a/resources.yaml b/resources.yaml
+index 7a494c9..254b9cd 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
+ ---
+@@ -22,5 +23,6 @@ apiVersion: custom.io/v1
+ kind: Custom
+ metadata:
+   name: custom
++  namespace: staging
+ spec:
+   image: nginx:1.2.3
diff --git a/e2e/testdata/fn-eval/default-runtime/.expected/exec.sh b/e2e/testdata/fn-eval/default-runtime/.expected/exec.sh
new file mode 100644
index 00000000..e1ba303f
--- /dev/null
+++ b/e2e/testdata/fn-eval/default-runtime/.expected/exec.sh
@@ -0,0 +1,25 @@
+#! /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
+
+# clear environment variable KPT_FN_RUNTIME if it matches the default
+if [ "${KPT_FN_RUNTIME}" = "docker" ]; then
+   unset KPT_FN_RUNTIME
+fi
+
+echo "KPT_FN_RUNTIME is ${KPT_FN_RUNTIME}"
+# run eval with KPT_FN_RUNTIME unset.
+kpt fn eval -i gcr.io/kpt-fn/set-namespace:v0.1.3 -- namespace=staging
diff --git a/e2e/testdata/fn-eval/default-runtime/.krmignore b/e2e/testdata/fn-eval/default-runtime/.krmignore
new file mode 100644
index 00000000..9d7a4007
--- /dev/null
+++ b/e2e/testdata/fn-eval/default-runtime/.krmignore
@@ -0,0 +1,1 @@
+.expected
diff --git a/e2e/testdata/fn-eval/default-runtime/resources.yaml b/e2e/testdata/fn-eval/default-runtime/resources.yaml
new file mode 100644
index 00000000..7a494c9c
--- /dev/null
+++ b/e2e/testdata/fn-eval/default-runtime/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/error-in-pipe/.expected/config.yaml b/e2e/testdata/fn-eval/error-in-pipe/.expected/config.yaml
index 54b86c67..c84518fb 100644
--- a/e2e/testdata/fn-eval/error-in-pipe/.expected/config.yaml
+++ b/e2e/testdata/fn-eval/error-in-pipe/.expected/config.yaml
@@ -13,18 +13,4 @@
 # limitations under the License.
 
 exitCode: 1
-stdErr: |
-  [RUNNING] "gcr.io/kpt-fn/set-namespace:v0.1.3"
-  [FAIL] "gcr.io/kpt-fn/set-namespace:v0.1.3" in 0s
-    Results:
-      [error]: failed to configure function: input namespace cannot be empty
-    Stderr:
-      "[error] /// : failed to configure function: input namespace cannot be empty"
-    Exit code: 1
-  
-  [RUNNING] "gcr.io/kpt-fn/dne"
-  [FAIL] "gcr.io/kpt-fn/dne" in 0s
-    Stderr:
-      "docker: Error response from daemon: manifest for gcr.io/kpt-fn/dne:latest not found: manifest unknown: Failed to fetch \"latest\" from request \"/v2/kpt-fn/dne/manifests/latest\"."
-      "See 'docker run --help'."
-    Exit code: 125
+stdErr: Failed to fetch
diff --git a/e2e/testdata/fn-eval/image-pull-policy-always/.expected/setup.sh b/e2e/testdata/fn-eval/image-pull-policy-always/.expected/setup.sh
index 066ff92c..65d19043 100644
--- a/e2e/testdata/fn-eval/image-pull-policy-always/.expected/setup.sh
+++ b/e2e/testdata/fn-eval/image-pull-policy-always/.expected/setup.sh
@@ -15,9 +15,11 @@
 
 set -eo pipefail
 
+KPT_FN_RUNTIME="${KPT_FN_RUNTIME:=docker}"
+
 # Function gcr.io/kpt-fn-demo/foo:v0.1 prints "foo" to stderr and
 # function gcr.io/kpt-fn-demo/bar:v0.1 prints "bar" to stderr.
 # We intentionally tag a wrong image as gcr.io/kpt-fn-demo/foo:v0.1, since we
 # expect the correct image to be pulled and override the wrong image.
-docker pull gcr.io/kpt-fn-demo/bar:v0.1
-docker tag gcr.io/kpt-fn-demo/bar:v0.1 gcr.io/kpt-fn-demo/foo:v0.1
+${KPT_FN_RUNTIME} pull gcr.io/kpt-fn-demo/bar:v0.1
+${KPT_FN_RUNTIME} tag gcr.io/kpt-fn-demo/bar:v0.1 gcr.io/kpt-fn-demo/foo:v0.1
diff --git a/e2e/testdata/fn-eval/image-pull-policy-if-not-present/.expected/setup.sh b/e2e/testdata/fn-eval/image-pull-policy-if-not-present/.expected/setup.sh
index 1a03f012..ef576cad 100644
--- a/e2e/testdata/fn-eval/image-pull-policy-if-not-present/.expected/setup.sh
+++ b/e2e/testdata/fn-eval/image-pull-policy-if-not-present/.expected/setup.sh
@@ -15,8 +15,10 @@
 
 set -eo pipefail
 
+KPT_FN_RUNTIME="${KPT_FN_RUNTIME:=docker}"
+
 # Function gcr.io/kpt-fn-demo/foo:v0.1 prints "foo" to stderr and
 # function gcr.io/kpt-fn-demo/bar:v0.1 prints "bar" to stderr.
 # We intentionally tag a wrong image as pull gcr.io/kpt-fn-demo/bar:v0.1
-docker pull gcr.io/kpt-fn-demo/foo:v0.1
-docker tag gcr.io/kpt-fn-demo/foo:v0.1 gcr.io/kpt-fn-demo/bar:v0.1
+${KPT_FN_RUNTIME} pull gcr.io/kpt-fn-demo/foo:v0.1
+${KPT_FN_RUNTIME} tag gcr.io/kpt-fn-demo/foo:v0.1 gcr.io/kpt-fn-demo/bar:v0.1
diff --git a/e2e/testdata/fn-eval/image-pull-policy-never/.expected/config.yaml b/e2e/testdata/fn-eval/image-pull-policy-never/.expected/config.yaml
index 9269c10e..352eecda 100644
--- a/e2e/testdata/fn-eval/image-pull-policy-never/.expected/config.yaml
+++ b/e2e/testdata/fn-eval/image-pull-policy-never/.expected/config.yaml
@@ -16,4 +16,6 @@ testType: eval
 image: gcr.io/kpt-fn/not-exist:v0.1
 imagePullPolicy: Never
 exitCode: 1
-stdErr: "No such image"
+# The output in stderr is different depending on the runtime.
+# "No such image" is from docker and "image not known" is from podman.
+stdErrRegEx: No such image|image not known
diff --git a/e2e/testdata/fn-eval/missing-fn-image/.expected/config.yaml b/e2e/testdata/fn-eval/missing-fn-image/.expected/config.yaml
index fe98cda9..75ea383d 100644
--- a/e2e/testdata/fn-eval/missing-fn-image/.expected/config.yaml
+++ b/e2e/testdata/fn-eval/missing-fn-image/.expected/config.yaml
@@ -17,4 +17,4 @@ exitCode: 1
 image: gcr.io/kpt-fn/dne # non-existing image
 args:
   namespace: staging
-stdErr: 'gcr.io/kpt-fn/dne:latest not found'
+stdErr: "Failed to fetch"
diff --git a/e2e/testdata/fn-eval/no-image-pull/.expected/config.yaml b/e2e/testdata/fn-eval/no-image-pull/.expected/config.yaml
new file mode 100644
index 00000000..5adf2971
--- /dev/null
+++ b/e2e/testdata/fn-eval/no-image-pull/.expected/config.yaml
@@ -0,0 +1,1 @@
+sequential: true
diff --git a/e2e/testdata/fn-eval/no-image-pull/.expected/exec.sh b/e2e/testdata/fn-eval/no-image-pull/.expected/exec.sh
index 8a4e1568..c1510da6 100644
--- a/e2e/testdata/fn-eval/no-image-pull/.expected/exec.sh
+++ b/e2e/testdata/fn-eval/no-image-pull/.expected/exec.sh
@@ -15,9 +15,11 @@
 
 set -eo pipefail
 
-kpt fn eval - --image gcr.io/kpt-fn/search-replace:v0.1\
+KPT_FN_RUNTIME="${KPT_FN_RUNTIME:=docker}"
 
-docker image inspect gcr.io/kpt-fn/search-replace:v0.1
+kpt fn eval --image gcr.io/kpt-fn/search-replace:v0.1
+
+${KPT_FN_RUNTIME} image inspect gcr.io/kpt-fn/search-replace:v0.1
 if [[ $? != 0 ]]; then
     echo "ERR: Image could not be found locally and may not have been pulled"
     exit 1
diff --git a/e2e/testdata/fn-eval/no-image-pull/.expected/setup.sh b/e2e/testdata/fn-eval/no-image-pull/.expected/setup.sh
index 951fab3a..75f323d7 100644
--- a/e2e/testdata/fn-eval/no-image-pull/.expected/setup.sh
+++ b/e2e/testdata/fn-eval/no-image-pull/.expected/setup.sh
@@ -13,9 +13,10 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+KPT_FN_RUNTIME="${KPT_FN_RUNTIME:=docker}"
 
-docker image inspect gcr.io/kpt-fn/search-replace:v0.1
+${KPT_FN_RUNTIME} image inspect gcr.io/kpt-fn/search-replace:v0.1
 # if inspect exits with a 0 exit code the image was found locally, remove it
 if [[ $? == 0 ]]; then
-    docker image rm gcr.io/kpt-fn/search-replace:v0.1
+    ${KPT_FN_RUNTIME} image rm gcr.io/kpt-fn/search-replace:v0.1
 fi
diff --git a/e2e/testdata/fn-eval/privilege-options/mount/.expected/config.yaml b/e2e/testdata/fn-eval/privilege-options/mount/.expected/config.yaml
new file mode 100644
index 00000000..c68a282b
--- /dev/null
+++ b/e2e/testdata/fn-eval/privilege-options/mount/.expected/config.yaml
@@ -0,0 +1,2 @@
+runtimes:
+  - docker
diff --git a/e2e/testdata/fn-eval/save-fn/custom-pkg-path/.expected/diff.patch b/e2e/testdata/fn-eval/save-fn/custom-pkg-path/.expected/diff.patch
index d05d59be..595476de 100644
--- a/e2e/testdata/fn-eval/save-fn/custom-pkg-path/.expected/diff.patch
+++ b/e2e/testdata/fn-eval/save-fn/custom-pkg-path/.expected/diff.patch
@@ -12,18 +12,16 @@ index d9e2f05..93c547b 100644
 +    - image: gcr.io/kpt-fn/set-namespace:v0.1.3
 +      configPath: fn-config.yaml
 diff --git a/sub-pkg/fn-config.yaml b/sub-pkg/fn-config.yaml
-index 6db3003..a04940f 100644
+index 384a7ad..670db5a 100644
 --- a/sub-pkg/fn-config.yaml
 +++ b/sub-pkg/fn-config.yaml
-@@ -2,5 +2,6 @@ apiVersion: v1
+@@ -15,5 +15,6 @@ apiVersion: v1
  kind: ConfigMap
  metadata:
    name: fn-config
 +  namespace: staging
  data:
--  namespace: staging
-\ No newline at end of file
-+  namespace: staging
+   namespace: staging
 diff --git a/sub-pkg/resources.yaml b/sub-pkg/resources.yaml
 index ac634f3..0e09da8 100644
 --- a/sub-pkg/resources.yaml
@@ -42,4 +40,4 @@ index ac634f3..0e09da8 100644
    name: custom
 +  namespace: staging
  spec:
-   image: nginx:1.2.3
\ No newline at end of file
+   image: nginx:1.2.3
diff --git a/e2e/testdata/fn-eval/save-fn/custom-pkg-path/sub-pkg/fn-config.yaml b/e2e/testdata/fn-eval/save-fn/custom-pkg-path/sub-pkg/fn-config.yaml
index 6db30039..384a7ad4 100644
--- a/e2e/testdata/fn-eval/save-fn/custom-pkg-path/sub-pkg/fn-config.yaml
+++ b/e2e/testdata/fn-eval/save-fn/custom-pkg-path/sub-pkg/fn-config.yaml
@@ -1,6 +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: v1
 kind: ConfigMap
 metadata:
   name: fn-config
 data:
-  namespace: staging
\ No newline at end of file
+  namespace: staging
diff --git a/e2e/testdata/fn-eval/save-fn/exec/.expected/diff.patch b/e2e/testdata/fn-eval/save-fn/exec/.expected/diff.patch
index 4cdb1e77..53326473 100644
--- a/e2e/testdata/fn-eval/save-fn/exec/.expected/diff.patch
+++ b/e2e/testdata/fn-eval/save-fn/exec/.expected/diff.patch
@@ -30,4 +30,4 @@ index 9e9c98f..9adc2f7 100644
 -  namespace: foo
 +  namespace: bar
  spec:
-   image: nginx:1.2.3
\ No newline at end of file
+   image: nginx:1.2.3
diff --git a/e2e/testdata/fn-eval/save-fn/image/.expected/diff.patch b/e2e/testdata/fn-eval/save-fn/image/.expected/diff.patch
index a009824d..e0e2fc40 100644
--- a/e2e/testdata/fn-eval/save-fn/image/.expected/diff.patch
+++ b/e2e/testdata/fn-eval/save-fn/image/.expected/diff.patch
@@ -30,4 +30,4 @@ index ac634f3..0e09da8 100644
    name: custom
 +  namespace: staging
  spec:
-   image: nginx:1.2.3
\ No newline at end of file
+   image: nginx:1.2.3
diff --git a/e2e/testdata/fn-eval/save-fn/match-selector/.expected/diff.patch b/e2e/testdata/fn-eval/save-fn/match-selector/.expected/diff.patch
index 9f04bfd6..fc2e88ff 100644
--- a/e2e/testdata/fn-eval/save-fn/match-selector/.expected/diff.patch
+++ b/e2e/testdata/fn-eval/save-fn/match-selector/.expected/diff.patch
@@ -24,4 +24,4 @@ index 7a494c9..9d82bd6 100644
 +  namespace: staging
  spec:
    replicas: 3
- ---
\ No newline at end of file
+ ---
diff --git a/e2e/testdata/fn-eval/save-fn/override-fn/.expected/diff.patch b/e2e/testdata/fn-eval/save-fn/override-fn/.expected/diff.patch
index 90288ed5..cacc7417 100644
--- a/e2e/testdata/fn-eval/save-fn/override-fn/.expected/diff.patch
+++ b/e2e/testdata/fn-eval/save-fn/override-fn/.expected/diff.patch
@@ -34,4 +34,4 @@ index ac634f3..80e1b9e 100644
    name: custom
 +  namespace: newNs
  spec:
-   image: nginx:1.2.3
\ No newline at end of file
+   image: nginx:1.2.3
diff --git a/e2e/testdata/fn-eval/save-fn/validator-type/.expected/diff.patch b/e2e/testdata/fn-eval/save-fn/validator-type/.expected/diff.patch
index f90d3d8b..9d6f3ff4 100644
--- a/e2e/testdata/fn-eval/save-fn/validator-type/.expected/diff.patch
+++ b/e2e/testdata/fn-eval/save-fn/validator-type/.expected/diff.patch
@@ -30,4 +30,4 @@ index ac634f3..0e09da8 100644
    name: custom
 +  namespace: staging
  spec:
-   image: nginx:1.2.3
\ No newline at end of file
+   image: nginx:1.2.3
diff --git a/e2e/testdata/fn-eval/selectors/selectors-with-exclude/.expected/diff.patch b/e2e/testdata/fn-eval/selectors/selectors-with-exclude/.expected/diff.patch
index 0851ad22..56140c66 100644
--- a/e2e/testdata/fn-eval/selectors/selectors-with-exclude/.expected/diff.patch
+++ b/e2e/testdata/fn-eval/selectors/selectors-with-exclude/.expected/diff.patch
@@ -9,4 +9,4 @@ index 98394a0..163aba6 100644
 +  namespace: staging
  ---
  apiVersion: custom.io/v1
- kind: Custom
\ No newline at end of file
+ kind: Custom
diff --git a/e2e/testdata/fn-render/default-runtime/.expected/diff.patch b/e2e/testdata/fn-render/default-runtime/.expected/diff.patch
new file mode 100644
index 00000000..102eed98
--- /dev/null
+++ b/e2e/testdata/fn-render/default-runtime/.expected/diff.patch
@@ -0,0 +1,44 @@
+diff --git a/Kptfile b/Kptfile
+index 8e84241..c237afd 100644
+--- a/Kptfile
++++ b/Kptfile
+@@ -2,6 +2,9 @@ apiVersion: kpt.dev/v1
+ kind: Kptfile
+ metadata:
+   name: app
++  namespace: staging
++  labels:
++    tier: backend
+ pipeline:
+   mutators:
+     - image: gcr.io/kpt-fn/set-namespace:v0.1.3
+diff --git a/resources.yaml b/resources.yaml
+index 7a494c9..a9dd224 100644
+--- a/resources.yaml
++++ b/resources.yaml
+@@ -15,12 +15,25 @@ 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
++  namespace: staging
++  labels:
++    tier: backend
+ spec:
+   image: nginx:1.2.3
diff --git a/e2e/testdata/fn-render/default-runtime/.expected/exec.sh b/e2e/testdata/fn-render/default-runtime/.expected/exec.sh
new file mode 100644
index 00000000..43a6d630
--- /dev/null
+++ b/e2e/testdata/fn-render/default-runtime/.expected/exec.sh
@@ -0,0 +1,25 @@
+#! /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
+
+# clear environment variable KPT_FN_RUNTIME if it matches the default
+if [ "${KPT_FN_RUNTIME}" = "docker" ]; then
+   unset KPT_FN_RUNTIME
+fi
+
+echo "KPT_FN_RUNTIME is ${KPT_FN_RUNTIME}"
+# run render with KPT_FN_RUNTIME unset.
+kpt fn render
diff --git a/e2e/testdata/fn-render/default-runtime/.krmignore b/e2e/testdata/fn-render/default-runtime/.krmignore
new file mode 100644
index 00000000..9d7a4007
--- /dev/null
+++ b/e2e/testdata/fn-render/default-runtime/.krmignore
@@ -0,0 +1,1 @@
+.expected
diff --git a/e2e/testdata/fn-render/default-runtime/Kptfile b/e2e/testdata/fn-render/default-runtime/Kptfile
new file mode 100644
index 00000000..8e842410
--- /dev/null
+++ b/e2e/testdata/fn-render/default-runtime/Kptfile
@@ -0,0 +1,12 @@
+apiVersion: kpt.dev/v1
+kind: Kptfile
+metadata:
+  name: app
+pipeline:
+  mutators:
+    - image: gcr.io/kpt-fn/set-namespace:v0.1.3
+      configMap:
+        namespace: staging
+    - image: gcr.io/kpt-fn/set-labels:v0.1.4
+      configMap:
+        tier: backend
diff --git a/e2e/testdata/fn-render/default-runtime/resources.yaml b/e2e/testdata/fn-render/default-runtime/resources.yaml
new file mode 100644
index 00000000..7a494c9c
--- /dev/null
+++ b/e2e/testdata/fn-render/default-runtime/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/image-pull-policy-always/.expected/setup.sh b/e2e/testdata/fn-render/image-pull-policy-always/.expected/setup.sh
index 066ff92c..65d19043 100644
--- a/e2e/testdata/fn-render/image-pull-policy-always/.expected/setup.sh
+++ b/e2e/testdata/fn-render/image-pull-policy-always/.expected/setup.sh
@@ -15,9 +15,11 @@
 
 set -eo pipefail
 
+KPT_FN_RUNTIME="${KPT_FN_RUNTIME:=docker}"
+
 # Function gcr.io/kpt-fn-demo/foo:v0.1 prints "foo" to stderr and
 # function gcr.io/kpt-fn-demo/bar:v0.1 prints "bar" to stderr.
 # We intentionally tag a wrong image as gcr.io/kpt-fn-demo/foo:v0.1, since we
 # expect the correct image to be pulled and override the wrong image.
-docker pull gcr.io/kpt-fn-demo/bar:v0.1
-docker tag gcr.io/kpt-fn-demo/bar:v0.1 gcr.io/kpt-fn-demo/foo:v0.1
+${KPT_FN_RUNTIME} pull gcr.io/kpt-fn-demo/bar:v0.1
+${KPT_FN_RUNTIME} tag gcr.io/kpt-fn-demo/bar:v0.1 gcr.io/kpt-fn-demo/foo:v0.1
diff --git a/e2e/testdata/fn-render/image-pull-policy-if-not-present/.expected/setup.sh b/e2e/testdata/fn-render/image-pull-policy-if-not-present/.expected/setup.sh
index 1a03f012..ef576cad 100644
--- a/e2e/testdata/fn-render/image-pull-policy-if-not-present/.expected/setup.sh
+++ b/e2e/testdata/fn-render/image-pull-policy-if-not-present/.expected/setup.sh
@@ -15,8 +15,10 @@
 
 set -eo pipefail
 
+KPT_FN_RUNTIME="${KPT_FN_RUNTIME:=docker}"
+
 # Function gcr.io/kpt-fn-demo/foo:v0.1 prints "foo" to stderr and
 # function gcr.io/kpt-fn-demo/bar:v0.1 prints "bar" to stderr.
 # We intentionally tag a wrong image as pull gcr.io/kpt-fn-demo/bar:v0.1
-docker pull gcr.io/kpt-fn-demo/foo:v0.1
-docker tag gcr.io/kpt-fn-demo/foo:v0.1 gcr.io/kpt-fn-demo/bar:v0.1
+${KPT_FN_RUNTIME} pull gcr.io/kpt-fn-demo/foo:v0.1
+${KPT_FN_RUNTIME} tag gcr.io/kpt-fn-demo/foo:v0.1 gcr.io/kpt-fn-demo/bar:v0.1
diff --git a/e2e/testdata/fn-render/image-pull-policy-never/.expected/config.yaml b/e2e/testdata/fn-render/image-pull-policy-never/.expected/config.yaml
index 6918ac15..3142177e 100644
--- a/e2e/testdata/fn-render/image-pull-policy-never/.expected/config.yaml
+++ b/e2e/testdata/fn-render/image-pull-policy-never/.expected/config.yaml
@@ -14,4 +14,6 @@
 
 imagePullPolicy: Never
 exitCode: 1
-stdErr: "No such image"
+# The output in stderr is different depending on the runtime.
+# "No such image" is from docker and "image not known" is from podman.
+stdErrRegEx: No such image|image not known
diff --git a/e2e/testdata/fn-render/missing-fn-image/.expected/config.yaml b/e2e/testdata/fn-render/missing-fn-image/.expected/config.yaml
index 7bee7e35..86c07094 100644
--- a/e2e/testdata/fn-render/missing-fn-image/.expected/config.yaml
+++ b/e2e/testdata/fn-render/missing-fn-image/.expected/config.yaml
@@ -13,4 +13,4 @@
 # limitations under the License.
 
 exitCode: 1
-stdErr: 'gcr.io/kpt-fn/dne:latest not found'
+stdErr: "Failed to fetch"
diff --git a/internal/fnruntime/container_test.go b/internal/fnruntime/container_test.go
index f6ac50ac..5e51f49f 100644
--- a/internal/fnruntime/container_test.go
+++ b/internal/fnruntime/container_test.go
@@ -15,17 +15,17 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package fnruntime_test
+package fnruntime
 
 import (
 	"bytes"
 	"context"
 	"testing"
 
-	"github.com/GoogleContainerTools/kpt/internal/fnruntime"
 	"github.com/GoogleContainerTools/kpt/internal/printer"
 	fnresult "github.com/GoogleContainerTools/kpt/pkg/api/fnresult/v1"
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 )
 
 func TestContainerFn(t *testing.T) {
@@ -52,7 +52,7 @@ func TestContainerFn(t *testing.T) {
 		ctx := context.Background()
 		t.Run(tt.name, func(t *testing.T) {
 			errBuff := &bytes.Buffer{}
-			instance := fnruntime.ContainerFn{
+			instance := ContainerFn{
 				Ctx:   printer.WithContext(ctx, printer.New(nil, errBuff)),
 				Image: tt.image,
 				FnResult: &fnresult.Result{
@@ -74,3 +74,43 @@ func TestContainerFn(t *testing.T) {
 		})
 	}
 }
+
+func TestIsSupportedDockerVersion(t *testing.T) {
+	tests := []struct {
+		name   string
+		inputV string
+		errMsg string
+	}{
+		{
+			name:   "greater than min version",
+			inputV: "20.10.1",
+		},
+		{
+			name:   "equal to min version",
+			inputV: "20.10.0",
+		},
+		{
+			name:   "less than min version",
+			inputV: "20.9.1",
+			errMsg: "docker client version must be v20.10.0 or greater: found v20.9.1",
+		},
+		{
+			name:   "invalid semver",
+			inputV: "20..12.1",
+			errMsg: "docker client version must be v20.10.0 or greater: found invalid version v20..12.1",
+		},
+	}
+	for _, tt := range tests {
+		tt := tt
+		t.Run(tt.name, func(t *testing.T) {
+			require := require.New(t)
+			err := isSupportedDockerVersion(tt.inputV)
+			if tt.errMsg != "" {
+				require.NotNil(err)
+				require.Contains(err.Error(), tt.errMsg)
+			} else {
+				require.NoError(err)
+			}
+		})
+	}
+}
diff --git a/internal/fnruntime/utils_test.go b/internal/fnruntime/utils_test.go
index dafb230d..b2f57776 100644
--- a/internal/fnruntime/utils_test.go
+++ b/internal/fnruntime/utils_test.go
@@ -140,3 +140,19 @@ spec:
 		})
 	}
 }
+
+func TestNewConfigMap(t *testing.T) {
+	data := map[string]string{
+		"normal string": "abc",
+		"integer":       "8081",
+		"float":         "1.23",
+		"bool":          "true",
+	}
+	m, err := NewConfigMap(data)
+	assert.NoError(t, err)
+	mapAsString := m.MustString()
+	assert.Contains(t, mapAsString, `bool: "true"`)
+	assert.Contains(t, mapAsString, `normal string: abc`)
+	assert.Contains(t, mapAsString, `integer: "8081"`)
+	assert.Contains(t, mapAsString, `float: "1.23"`)
+}
diff --git a/internal/util/cmdutil/cmdutil_test.go b/internal/util/cmdutil/cmdutil_test.go
index a45ec333..f6110622 100644
--- a/internal/util/cmdutil/cmdutil_test.go
+++ b/internal/util/cmdutil/cmdutil_test.go
@@ -22,7 +22,6 @@ import (
 
 	"github.com/GoogleContainerTools/kpt/internal/util/function"
 	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/require"
 	"sigs.k8s.io/kustomize/kyaml/kio"
 )
 
@@ -349,43 +348,3 @@ func TestListImages(t *testing.T) {
 	sort.Strings(result)
 	assert.Equal(t, []string{"apply-setters:v0.1.1", "gatekeeper:v0.2.1"}, result)
 }
-
-func TestIsSupportedDockerVersion(t *testing.T) {
-	tests := []struct {
-		name   string
-		inputV string
-		errMsg string
-	}{
-		{
-			name:   "greater than min version",
-			inputV: "20.10.1",
-		},
-		{
-			name:   "equal to min version",
-			inputV: "20.10.0",
-		},
-		{
-			name:   "less than min version",
-			inputV: "20.9.1",
-			errMsg: "docker client version must be v20.10.0 or greater: found v20.9.1",
-		},
-		{
-			name:   "invalid semver",
-			inputV: "20..12.1",
-			errMsg: "docker client version must be v20.10.0 or greater: found invalid version v20..12.1",
-		},
-	}
-	for _, tt := range tests {
-		tt := tt
-		t.Run(tt.name, func(t *testing.T) {
-			require := require.New(t)
-			err := isSupportedDockerVersion(tt.inputV)
-			if tt.errMsg != "" {
-				require.NotNil(err)
-				require.Contains(err.Error(), tt.errMsg)
-			} else {
-				require.NoError(err)
-			}
-		})
-	}
-}
diff --git a/internal/util/update/update_test.go b/internal/util/update/update_test.go
index e1ea14e5..162a51a6 100644
--- a/internal/util/update/update_test.go
+++ b/internal/util/update/update_test.go
@@ -76,15 +76,25 @@ func TestCommand_Run_noRefChanges(t *testing.T) {
 				return
 			}
 			upstreamRepo := g.Repos[testutil.Upstream]
-
-			// Update the local package
-			if !assert.NoError(t, Command{
+			cmd := &Command{
 				Pkg:      pkgtest.CreatePkgOrFail(t, g.LocalWorkspace.FullPackagePath()),
 				Strategy: strategy,
-			}.Run(fake.CtxWithDefaultPrinter())) {
+			}
+			// Update the local package
+			if !assert.NoError(t, cmd.Run(fake.CtxWithDefaultPrinter())) {
 				return
 			}
 
+			// Expect upstream repo to be cached
+			cachedGURs := cmd.GetCachedUpstreamRepos()
+			assert.Equal(t, 1, len(cachedGURs))
+			for _, gur := range cachedGURs {
+				assert.Equal(t, upstreamRepo.RepoDirectory, gur.URI)
+				// Expect two fetched refs
+				// 1 for upstream ref (master) and 1 for previous upstream lock
+				assert.Equal(t, []string{masterBranch, upstreamRepo.Commits[0]}, gur.GetFetchedRefs())
+			}
+
 			// Expect the local package to have Dataset2
 			if !g.AssertLocalDataEquals(testutil.Dataset2, true) {
 				return
@@ -129,11 +139,11 @@ func TestCommand_Run_subDir(t *testing.T) {
 			upstreamRepo := g.Repos[testutil.Upstream]
 
 			// Update the local package
-			if !assert.NoError(t, Command{
+			if !assert.NoError(t, (&Command{
 				Pkg:      pkgtest.CreatePkgOrFail(t, g.LocalWorkspace.FullPackagePath()),
 				Ref:      "v1.2",
 				Strategy: strategy,
-			}.Run(fake.CtxWithDefaultPrinter())) {
+			}).Run(fake.CtxWithDefaultPrinter())) {
 				return
 			}
 
@@ -185,10 +195,10 @@ func TestCommand_Run_noChanges(t *testing.T) {
 			upstreamRepo := g.Repos[testutil.Upstream]
 
 			// Update the local package
-			err := Command{
+			err := (&Command{
 				Pkg:      pkgtest.CreatePkgOrFail(t, g.LocalWorkspace.FullPackagePath()),
 				Strategy: u.updater,
-			}.Run(fake.CtxWithDefaultPrinter())
+			}).Run(fake.CtxWithDefaultPrinter())
 			if u.err == "" {
 				if !assert.NoError(t, err) {
 					return
@@ -367,11 +377,11 @@ func TestCommand_Run_localPackageChanges(t *testing.T) {
 			}
 
 			// run the command
-			err = Command{
+			err = (&Command{
 				Pkg:      pkgtest.CreatePkgOrFail(t, g.LocalWorkspace.FullPackagePath()),
 				Ref:      masterBranch,
 				Strategy: tc.strategy,
-			}.Run(fake.CtxWithDefaultPrinter())
+			}).Run(fake.CtxWithDefaultPrinter())
 
 			// check the error response
 			if tc.expectedErr == "" {
@@ -437,11 +447,11 @@ func TestCommand_Run_toBranchRef(t *testing.T) {
 			upstreamRepo := g.Repos[testutil.Upstream]
 
 			// Update the local package
-			if !assert.NoError(t, Command{
+			if !assert.NoError(t, (&Command{
 				Pkg:      pkgtest.CreatePkgOrFail(t, g.LocalWorkspace.FullPackagePath()),
 				Strategy: strategy,
 				Ref:      "exp",
-			}.Run(fake.CtxWithDefaultPrinter())) {
+			}).Run(fake.CtxWithDefaultPrinter())) {
 				return
 			}
 
@@ -801,11 +811,11 @@ func TestCommand_Run_toBranchRefWithSubpkgs(t *testing.T) {
 			}
 
 			// Update the local package
-			if !assert.NoError(t, Command{
+			if !assert.NoError(t, (&Command{
 				Pkg:      pkgtest.CreatePkgOrFail(t, path.Join(g.LocalWorkspace.FullPackagePath(), tc.updateSubPkg)),
 				Strategy: tc.strategy,
 				Ref:      tc.updateRef,
-			}.Run(fake.CtxWithDefaultPrinter())) {
+			}).Run(fake.CtxWithDefaultPrinter())) {
 				return
 			}
 			expectedPath := tc.expectedLocal.ExpandPkgWithName(t,
@@ -847,11 +857,11 @@ func TestCommand_Run_toTagRef(t *testing.T) {
 			upstreamRepo := g.Repos[testutil.Upstream]
 
 			// Update the local package
-			if !assert.NoError(t, Command{
+			if !assert.NoError(t, (&Command{
 				Pkg:      pkgtest.CreatePkgOrFail(t, g.LocalWorkspace.FullPackagePath()),
 				Strategy: strategy,
 				Ref:      "v1.0",
-			}.Run(fake.CtxWithDefaultPrinter())) {
+			}).Run(fake.CtxWithDefaultPrinter())) {
 				return
 			}
 
@@ -904,11 +914,11 @@ func TestCommand_ResourceMerge_NonKRMUpdates(t *testing.T) {
 			upstreamRepo := g.Repos[testutil.Upstream]
 
 			// Update the local package
-			if !assert.NoError(t, Command{
+			if !assert.NoError(t, (&Command{
 				Pkg:      pkgtest.CreatePkgOrFail(t, g.LocalWorkspace.FullPackagePath()),
 				Strategy: strategy,
 				Ref:      "v1.0",
-			}.Run(fake.CtxWithDefaultPrinter())) {
+			}).Run(fake.CtxWithDefaultPrinter())) {
 				t.FailNow()
 			}
 
@@ -947,9 +957,9 @@ func TestCommand_Run_noUpstreamReference(t *testing.T) {
 	testutil.AddKptfileToWorkspace(t, w, kf)
 
 	// Update the local package
-	err := Command{
+	err := (&Command{
 		Pkg: pkgtest.CreatePkgOrFail(t, w.FullPackagePath()),
-	}.Run(fake.CtxWithDefaultPrinter())
+	}).Run(fake.CtxWithDefaultPrinter())
 
 	assert.Contains(t, err.Error(), "must have an upstream reference")
 }
@@ -960,10 +970,10 @@ func TestCommand_Run_failInvalidPath(t *testing.T) {
 		strategy := kptfilev1.UpdateStrategies[i]
 		t.Run(string(strategy), func(t *testing.T) {
 			path := filepath.Join("fake", "path")
-			err := Command{
+			err := (&Command{
 				Pkg:      pkgtest.CreatePkgOrFail(t, path),
 				Strategy: strategy,
-			}.Run(fake.CtxWithDefaultPrinter())
+			}).Run(fake.CtxWithDefaultPrinter())
 			if assert.Error(t, err) {
 				assert.Contains(t, err.Error(), "no such file or directory")
 			}
@@ -1025,9 +1035,9 @@ func TestCommand_Run_badUpstreamLock(t *testing.T) {
 			}
 
 			// Update the local package.
-			err = Command{
+			err = (&Command{
 				Pkg: pkgtest.CreatePkgOrFail(t, g.LocalWorkspace.FullPackagePath()),
-			}.Run(fake.CtxWithDefaultPrinter())
+			}).Run(fake.CtxWithDefaultPrinter())
 
 			if assert.Error(t, err) {
 				assert.Contains(t, err.Error(), tc.expectedErrorMsg)
@@ -1060,11 +1070,11 @@ func TestCommand_Run_failInvalidRef(t *testing.T) {
 				return
 			}
 
-			err := Command{
+			err := (&Command{
 				Pkg:      pkgtest.CreatePkgOrFail(t, g.LocalWorkspace.FullPackagePath()),
 				Ref:      "exp",
 				Strategy: strategy,
-			}.Run(fake.CtxWithDefaultPrinter())
+			}).Run(fake.CtxWithDefaultPrinter())
 			if !assert.Error(t, err) {
 				return
 			}
@@ -1118,9 +1128,9 @@ func TestCommand_Run_manualChange(t *testing.T) {
 		return
 	}
 
-	err := Command{
+	err := (&Command{
 		Pkg: pkgtest.CreatePkgOrFail(t, g.LocalWorkspace.FullPackagePath()),
-	}.Run(fake.CtxWithDefaultPrinter())
+	}).Run(fake.CtxWithDefaultPrinter())
 	if !assert.NoError(t, err) {
 		t.FailNow()
 	}
@@ -1172,9 +1182,9 @@ func TestCommand_Run_symlinks(t *testing.T) {
 	}
 	upstreamRepo := g.Repos[testutil.Upstream]
 
-	err := Command{
+	err := (&Command{
 		Pkg: pkgtest.CreatePkgOrFail(t, g.LocalWorkspace.FullPackagePath()),
-	}.Run(fake.CtxWithDefaultPrinter())
+	}).Run(fake.CtxWithDefaultPrinter())
 	if !assert.NoError(t, err) {
 		t.FailNow()
 	}
@@ -1220,10 +1230,10 @@ func TestCommand_Run_badStrategy(t *testing.T) {
 	}
 
 	// Update the local package
-	err := Command{
+	err := (&Command{
 		Pkg:      pkgtest.CreatePkgOrFail(t, g.LocalWorkspace.FullPackagePath()),
 		Strategy: strategy,
-	}.Run(fake.CtxWithDefaultPrinter())
+	}).Run(fake.CtxWithDefaultPrinter())
 	if !assert.Error(t, err, strategy) {
 		return
 	}
@@ -1999,10 +2009,10 @@ func TestCommand_Run_local_subpackages(t *testing.T) {
 					return
 				}
 
-				err := Command{
+				err := (&Command{
 					Pkg:      pkgtest.CreatePkgOrFail(t, g.LocalWorkspace.FullPackagePath()),
 					Strategy: strategy,
-				}.Run(fake.CtxWithDefaultPrinter())
+				}).Run(fake.CtxWithDefaultPrinter())
 
 				result := findExpectedResultForStrategy(test.expectedResults, strategy)
 
@@ -3539,9 +3549,9 @@ func TestRun_remote_subpackages(t *testing.T) {
 				return
 			}
 
-			err := Command{
+			err := (&Command{
 				Pkg: pkgtest.CreatePkgOrFail(t, g.LocalWorkspace.FullPackagePath()),
-			}.Run(fake.CtxWithDefaultPrinter())
+			}).Run(fake.CtxWithDefaultPrinter())
 
 			if tc.expectedErrMsg != "" {
 				if !assert.Error(t, err) {
@@ -3684,9 +3694,9 @@ func TestRootPackageIsUnfetched(t *testing.T) {
 			}
 			testutil.AddKptfileToWorkspace(t, w, kf)
 
-			err := Command{
+			err := (&Command{
 				Pkg: pkgtest.CreatePkgOrFail(t, w.FullPackagePath()),
-			}.Run(fake.CtxWithDefaultPrinter())
+			}).Run(fake.CtxWithDefaultPrinter())
 			if !assert.NoError(t, err) {
 				t.FailNow()
 			}
@@ -3697,6 +3707,97 @@ func TestRootPackageIsUnfetched(t *testing.T) {
 	}
 }
 
+// TestMultiUpdateCache verifies that multiple sub packages
+// with same upstream leverage the cache.
+func TestMultiUpdateCache(t *testing.T) {
+	numSubPkgs := 10
+	blueprintsUpstream := "blueprints"
+	// Generate multiple subpackages referencing blueprintsUpstream
+	subPkgs := make([]*pkgbuilder.SubPkg, 0, numSubPkgs)
+	for i := 1; i <= numSubPkgs; i++ {
+		sub := pkgbuilder.NewSubPkg(fmt.Sprintf("subpkg-%d", i)).
+			WithKptfile(
+				pkgbuilder.NewKptfile().
+					WithUpstreamRef(blueprintsUpstream, "/", masterBranch, "fast-forward").
+					WithUpstreamLockRef(blueprintsUpstream, "/", masterBranch, 0),
+			).
+			WithResource(pkgbuilder.DeploymentResource)
+		subPkgs = append(subPkgs, sub)
+	}
+
+	reposChanges := map[string][]testutil.Content{
+		testutil.Upstream: {
+			{
+				Pkg: pkgbuilder.NewRootPkg().
+					WithSubPackages(subPkgs...),
+				Branch: masterBranch,
+			},
+		},
+		blueprintsUpstream: {
+			{
+				Pkg: pkgbuilder.NewRootPkg().
+					WithResource(pkgbuilder.DeploymentResource),
+				Branch: masterBranch,
+			},
+		},
+	}
+
+	expectedLocal := pkgbuilder.NewRootPkg().
+		WithKptfile(
+			pkgbuilder.NewKptfile().
+				WithUpstreamRef(testutil.Upstream, "/", masterBranch, "resource-merge").
+				WithUpstreamLockRef(testutil.Upstream, "/", masterBranch, 0),
+		).
+		WithSubPackages(subPkgs...)
+
+	repos, w, clean := testutil.SetupReposAndWorkspace(t, reposChanges)
+	defer clean()
+
+	w.PackageDir = testPackageName
+	kf := kptfileutil.DefaultKptfile(testPackageName)
+	kf.Upstream = &kptfilev1.Upstream{
+		Type: kptfilev1.GitOrigin,
+		Git: &kptfilev1.Git{
+			Repo:      repos[testutil.Upstream].RepoDirectory,
+			Directory: "/",
+			Ref:       masterBranch,
+		},
+		UpdateStrategy: kptfilev1.ResourceMerge,
+	}
+	testutil.AddKptfileToWorkspace(t, w, kf)
+	cmd := Command{
+		Pkg: pkgtest.CreatePkgOrFail(t, w.FullPackagePath()),
+	}
+	err := cmd.Run(fake.CtxWithDefaultPrinter())
+	if !assert.NoError(t, err) {
+		t.FailNow()
+	}
+
+	// Expect 2 cached repo refs - testutil.Upstream and blueprintsUpstream
+	cachedGURs := cmd.GetCachedUpstreamRepos()
+	assert.Equal(t, 2, len(cachedGURs))
+
+	// Assert cached refs for each upstream repo
+	upstreamRepo := repos[testutil.Upstream].RepoDirectory
+	blueprintsRepo := repos[blueprintsUpstream].RepoDirectory
+	blueprintsRepoCommit := repos[blueprintsUpstream].Commits
+	for cr, gur := range cachedGURs {
+		switch cr {
+		case upstreamRepo:
+			// Expect only upstream ref master to be cached as there was no lock
+			assert.Equal(t, []string{masterBranch}, gur.GetFetchedRefs())
+		case blueprintsRepo:
+			// Expect both upstream ref master and upstream lock commit to be cached
+			assert.Equal(t, []string{masterBranch, blueprintsRepoCommit[0]}, gur.GetFetchedRefs())
+		default:
+			t.Fatalf("Unexpected upstream repo %s", cr)
+		}
+	}
+
+	expectedPath := expectedLocal.ExpandPkgWithName(t, testPackageName, testutil.ToReposInfo(repos))
+	testutil.KptfileAwarePkgEqual(t, expectedPath, w.FullPackagePath(), true)
+}
+
 type nonKRMTestCase struct {
 	name            string
 	updated         string
diff --git a/pkg/test/runner/README.md b/pkg/test/runner/README.md
index 8ade9004..537291c7 100644
--- a/pkg/test/runner/README.md
+++ b/pkg/test/runner/README.md
@@ -23,6 +23,9 @@ in `.expected`:
   - `exitCode`: The expected exit code for the command. Default: 0.
   - `skip`: Runner will skip the test if `skip` is set to true. Default: false.
   - `sequential`: This test case should be run sequentially. Default: false.
+  - `runtimes`: If the current runtime doesn't match any of the desired runtimes
+    here, the test case will be skipped. Valid values are `docker` and `podman`.
+    If unspecified, it will match any runtime.
   - `imagePullPolicy`: The image pull policy to be used. It can be set to one of
     `Always`, `IfNotPresent` and `Never`. Default value is inherited from the
     CLI flag.
@@ -34,6 +37,7 @@ in `.expected`:
     - Keep the temporary directory used to run the test cases after test.
   - `stdOut`: The expected standard output from running the command. Default: "".
   - `stdErr`: The expected standard error from running the command. Default: "".
+  - `StdErrRegEx`: A regular expression that is expected to match the standard error. Default: "".
   - `disableOutputTruncate`: Control should error output be truncated. Default:
     false.
   - Configurations only apply to `eval` tests:
diff --git a/pkg/test/runner/config.go b/pkg/test/runner/config.go
index 57b3c303..4266c0cd 100644
--- a/pkg/test/runner/config.go
+++ b/pkg/test/runner/config.go
@@ -58,6 +58,9 @@ type TestCaseConfig struct {
 	// StdErr is the expected standard error output and should be checked
 	// when a nonzero exit code is expected. Default: ""
 	StdErr string `json:"stdErr,omitempty" yaml:"stdErr,omitempty"`
+	// StdErrRegEx is the regular expression to match standard error output and should be checked
+	// when a nonzero exit code is expected. Default: ""
+	StdErrRegEx string `json:"stdErrRegEx,omitempty" yaml:"stdErrRegEx,omitempty"`
 
 	// StdOut is the expected standard output from running the command.
 	// Default: ""
@@ -71,6 +74,11 @@ type TestCaseConfig struct {
 	// be the same as the CLI flag.
 	ImagePullPolicy string `json:"imagePullPolicy,omitempty" yaml:"imagePullPolicy,omitempty"`
 
+	// Runtimes controls if a test case should be skipped. If the current runtime doesn't match
+	// any of the desired runtimes here, the test case will be skipped. Valid values are `docker`
+	// and `podman`. If unspecified, it will match any runtime.
+	Runtimes []string `json:"runtimes,omitempty" yaml:"runtimes,omitempty"`
+
 	// AllowExec determines if `fn render` needs to be invoked with `--allow-exec` flag
 	AllowExec bool `json:"allowExec,omitempty" yaml:"allowExec,omitempty"`
 
diff --git a/pkg/test/runner/runner.go b/pkg/test/runner/runner.go
index b9b74c00..c504f13f 100644
--- a/pkg/test/runner/runner.go
+++ b/pkg/test/runner/runner.go
@@ -20,9 +20,11 @@ import (
 	"os"
 	"os/exec"
 	"path/filepath"
+	"regexp"
 	"strings"
 	"testing"
 
+	"github.com/GoogleContainerTools/kpt/internal/fnruntime"
 	"github.com/google/go-cmp/cmp"
 )
 
@@ -220,7 +222,7 @@ func (r *Runner) runFnEval() error {
 			}
 			cmd = getCommand("", r.kptBin, kptArgs)
 		}
-		r.t.Logf("running command %q", cmd.String())
+		r.t.Logf("running command: %v=%v %v", fnruntime.ContainerRuntimeEnv, os.Getenv(fnruntime.ContainerRuntimeEnv), cmd.String())
 		stdout, stderr, fnErr := runCommand(cmd)
 		if fnErr != nil {
 			r.t.Logf("kpt error, stdout: %s; stderr: %s", stdout, stderr)
@@ -367,7 +369,7 @@ func (r *Runner) runFnRender() error {
 			}
 			cmd = getCommand("", r.kptBin, kptArgs)
 		}
-		r.t.Logf("running command %q", cmd.String())
+		r.t.Logf("running command: %v=%v %v", fnruntime.ContainerRuntimeEnv, os.Getenv(fnruntime.ContainerRuntimeEnv), cmd.String())
 		stdout, stderr, fnErr := runCommand(cmd)
 		// Update the diff file or results file if updateExpectedEnv is set.
 		if strings.ToLower(os.Getenv(updateExpectedEnv)) == "true" {
@@ -478,6 +480,16 @@ func (r *Runner) compareOutput(stdout string, stderr string) error {
 		r.t.Logf("stderr diff is %s", cmp.Diff(expectedStderr, stderr))
 		return fmt.Errorf("wanted stderr %q, got %q", expectedStderr, stderr)
 	}
+	stdErrRegEx := r.testCase.Config.StdErrRegEx
+	if stdErrRegEx != "" {
+		r, err := regexp.Compile(stdErrRegEx)
+		if err != nil {
+			return fmt.Errorf("unable to compile the regular expression %q: %w", stdErrRegEx, err)
+		}
+		if !r.MatchString(stderr) {
+			return fmt.Errorf("unable to match regular expression %q, got %v", stdErrRegEx, stderr)
+		}
+	}
 	expectedStdout := r.testCase.Config.StdOut
 	if !strings.Contains(stdout, expectedStdout) {
 		r.t.Logf("stdout diff is %s", cmp.Diff(expectedStdout, stdout))
diff --git a/porch/pkg/kpt/eval_test.go b/porch/pkg/kpt/eval_test.go
index 13ef5863..bafe1789 100644
--- a/porch/pkg/kpt/eval_test.go
+++ b/porch/pkg/kpt/eval_test.go
@@ -20,6 +20,7 @@ import (
 	"path"
 	"testing"
 
+	"github.com/GoogleContainerTools/kpt/internal/fnruntime"
 	v1 "github.com/GoogleContainerTools/kpt/pkg/api/kptfile/v1"
 	"sigs.k8s.io/kustomize/kyaml/kio"
 	"sigs.k8s.io/kustomize/kyaml/yaml"
@@ -56,7 +57,7 @@ func TestSetLabels(t *testing.T) {
 		t.Errorf("GetRunner failed: %v", err)
 	}
 
-	config, err := NewConfigMap(map[string]string{
+	config, err := fnruntime.NewConfigMap(map[string]string{
 		"label-key": "label-value",
 	})
 	if err != nil {