Repository: GoogleContainerTools/kpt
Pull request #3537 (2022-09-06 13:59:55)

rpkg get: clean up namespace handling
If we don't specify a namespace, we should only get the current namespace.

If we specify --all-namespaces or -A, we should print the namespace.



Patch:
diff --git a/internal/cmdfndoc/cmdfndoc.go b/internal/cmdfndoc/cmdfndoc.go
index c1611302..7f98c0d4 100644
--- a/internal/cmdfndoc/cmdfndoc.go
+++ b/internal/cmdfndoc/cmdfndoc.go
@@ -65,12 +65,16 @@ func (r *Runner) runE(c *cobra.Command, _ []string) error {
 	if r.Image == "" {
 		return errors.New("image must be specified")
 	}
-	r.Image = fnruntime.AddDefaultImagePathPrefix(c.Context(), r.Image)
+	// TODO: We probably should be going through the runner
+	image, err := fnruntime.ResolveToImageForCLI(c.Context(), r.Image)
+	if err != nil {
+		return err
+	}
 	var out, errout bytes.Buffer
 	dockerRunArgs := []string{
 		"run",
 		"--rm", // delete the container afterward
-		r.Image,
+		image,
 		"--help",
 	}
 	// If the env var is empty, stringToContainerRuntime defaults it to docker.
diff --git a/internal/cmdrender/cmdrender.go b/internal/cmdrender/cmdrender.go
index a7822fbe..2627741a 100644
--- a/internal/cmdrender/cmdrender.go
+++ b/internal/cmdrender/cmdrender.go
@@ -51,15 +51,15 @@ func NewRunner(ctx context.Context, parent string) *Runner {
 	c.Flags().StringVarP(&r.dest, "output", "o", "",
 		fmt.Sprintf("output resources are written to provided location. Allowed values: %s|%s|<OUT_DIR_PATH>", cmdutil.Stdout, cmdutil.Unwrap))
 
-	c.Flags().Var(&r.imagePullPolicy, "image-pull-policy",
-		"pull image before running the container "+r.imagePullPolicy.HelpAllowedValues())
+	c.Flags().Var(&r.RunnerOptions.ImagePullPolicy, "image-pull-policy",
+		"pull image before running the container "+r.RunnerOptions.ImagePullPolicy.HelpAllowedValues())
 	_ = c.RegisterFlagCompletionFunc("image-pull-policy", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
-		return r.imagePullPolicy.AllStrings(), cobra.ShellCompDirectiveDefault
+		return r.RunnerOptions.ImagePullPolicy.AllStrings(), cobra.ShellCompDirectiveDefault
 	})
 
-	c.Flags().BoolVar(&r.allowExec, "allow-exec", false,
+	c.Flags().BoolVar(&r.RunnerOptions.AllowExec, "allow-exec", r.RunnerOptions.AllowExec,
 		"allow binary executable to be run during pipeline execution.")
-	c.Flags().BoolVar(&r.allowWasm, "allow-alpha-wasm", false,
+	c.Flags().BoolVar(&r.RunnerOptions.AllowWasm, "allow-alpha-wasm", r.RunnerOptions.AllowWasm,
 		"allow wasm to be used during pipeline execution.")
 	cmdutil.FixDocs("kpt", parent, c)
 	r.Command = c
@@ -72,18 +72,17 @@ func NewCommand(ctx context.Context, parent string) *cobra.Command {
 
 // Runner contains the run function pipeline run command
 type Runner struct {
-	pkgPath         string
-	resultsDirPath  string
-	imagePullPolicy fnruntime.ImagePullPolicy
-	allowExec       bool
-	allowWasm       bool
-	dest            string
-	Command         *cobra.Command
-	ctx             context.Context
+	pkgPath        string
+	resultsDirPath string
+	dest           string
+	Command        *cobra.Command
+	ctx            context.Context
+
+	RunnerOptions fnruntime.RunnerOptions
 }
 
 func (r *Runner) InitDefaults() {
-	r.imagePullPolicy = fnruntime.IfNotPresentPull
+	r.RunnerOptions.InitDefaults()
 }
 
 func (r *Runner) preRunE(c *cobra.Command, args []string) error {
@@ -130,13 +129,11 @@ func (r *Runner) runE(c *cobra.Command, _ []string) error {
 		return err
 	}
 	executor := render.Renderer{
-		PkgPath:         absPkgPath,
-		ResultsDirPath:  r.resultsDirPath,
-		Output:          output,
-		ImagePullPolicy: r.imagePullPolicy,
-		AllowExec:       r.allowExec,
-		AllowWasm:       r.allowWasm,
-		FileSystem:      filesys.FileSystemOrOnDisk{},
+		PkgPath:        absPkgPath,
+		ResultsDirPath: r.resultsDirPath,
+		Output:         output,
+		RunnerOptions:  r.RunnerOptions,
+		FileSystem:     filesys.FileSystemOrOnDisk{},
 	}
 	if err := executor.Execute(r.ctx); err != nil {
 		return err
diff --git a/internal/cmdrpkgget/command.go b/internal/cmdrpkgget/command.go
index 0503873b..73c57d00 100644
--- a/internal/cmdrpkgget/command.go
+++ b/internal/cmdrpkgget/command.go
@@ -22,15 +22,17 @@ import (
 	"github.com/GoogleContainerTools/kpt/internal/docs/generated/rpkgdocs"
 	"github.com/GoogleContainerTools/kpt/internal/errors"
 	"github.com/GoogleContainerTools/kpt/internal/util/porch"
-	api "github.com/GoogleContainerTools/kpt/porch/api/porch/v1alpha1"
 	"github.com/spf13/cobra"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	"k8s.io/apimachinery/pkg/fields"
 	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/runtime/schema"
 	"k8s.io/cli-runtime/pkg/genericclioptions"
 	"k8s.io/cli-runtime/pkg/printers"
 	"k8s.io/cli-runtime/pkg/resource"
 	"k8s.io/client-go/rest"
+	"k8s.io/klog/v2"
 	"k8s.io/kubectl/pkg/cmd/get"
 )
 
@@ -44,7 +46,7 @@ func newRunner(ctx context.Context, rcg *genericclioptions.ConfigFlags) *runner
 		cfg:        rcg,
 		printFlags: get.NewGetPrintFlags(),
 	}
-	c := &cobra.Command{
+	cmd := &cobra.Command{
 		Use:        "get",
 		Aliases:    []string{"list"},
 		SuggestFor: []string{},
@@ -55,12 +57,16 @@ func newRunner(ctx context.Context, rcg *genericclioptions.ConfigFlags) *runner
 		RunE:       r.runE,
 		Hidden:     porch.HidePorchCommands,
 	}
-	r.Command = c
+	r.Command = cmd
 
 	// Create flags
-	c.Flags().StringVar(&r.name, "name", "", "Name of the packages to get. Any package whose name contains this value will be included in the results.")
-	c.Flags().StringVar(&r.revision, "revision", "", "Revision of the packages to get. Any package whose revision matches this value will be included in the results.")
-	r.printFlags.AddFlags(c)
+	cmd.Flags().StringVar(&r.packageName, "name", "", "Name of the packages to get. Any package whose name contains this value will be included in the results.")
+	cmd.Flags().StringVar(&r.revision, "revision", "", "Revision of the packages to get. Any package whose revision matches this value will be included in the results.")
+
+	cmd.Flags().BoolVarP(&r.AllNamespaces, "all-namespaces", "A", r.AllNamespaces,
+		"If present, list the requested object(s) across all namespaces. Namespace in current context is ignored even if specified with --namespace.")
+
+	r.printFlags.AddFlags(cmd)
 	return r
 }
 
@@ -74,14 +80,29 @@ type runner struct {
 	Command *cobra.Command
 
 	// Flags
-	name       string
-	revision   string
-	printFlags *get.PrintFlags
+	packageName   string
+	revision      string
+	printFlags    *get.PrintFlags
+	AllNamespaces bool
 
 	requestTable bool
 }
 
 func (r *runner) preRunE(cmd *cobra.Command, args []string) error {
+	if *r.cfg.Namespace == "" {
+		// Get the namespace from kubeconfig
+		namespace, _, err := r.cfg.ToRawKubeConfigLoader().Namespace()
+		if err != nil {
+			return fmt.Errorf("error getting namespace: %w", err)
+		}
+		r.cfg.Namespace = &namespace
+	}
+
+	// Print the namespace if we're spanning namespaces
+	if r.AllNamespaces {
+		r.printFlags.HumanReadableFlags.WithNamespace = true
+	}
+
 	outputOption := cmd.Flags().Lookup("output").Value.String()
 	if strings.Contains(outputOption, "custom-columns") || outputOption == "yaml" || strings.Contains(outputOption, "json") {
 		r.requestTable = false
@@ -96,14 +117,43 @@ func (r *runner) runE(cmd *cobra.Command, args []string) error {
 
 	var objs []runtime.Object
 	b := resource.NewBuilder(r.cfg).
-		Unstructured().
-		NamespaceParam(*r.cfg.Namespace).DefaultNamespace()
+		NamespaceParam(*r.cfg.Namespace).AllNamespaces(r.AllNamespaces)
+
+	if r.requestTable {
+		scheme := runtime.NewScheme()
+		// Accept PartialObjectMetadata and Table
+		if err := metav1.AddMetaToScheme(scheme); err != nil {
+			return fmt.Errorf("error building runtime.Scheme: %w", err)
+		}
+		b = b.WithScheme(scheme, schema.GroupVersion{Version: "v1"})
+	} else {
+		// We want to print the server version, not whatever version we happen to have compiled in
+		b = b.Unstructured()
+	}
 
+	useSelectors := true
 	if len(args) > 0 {
 		b = b.ResourceNames("packagerevisions", args...)
+		// We can't pass selectors here, get an error "Error: selectors and the all flag cannot be used when passing resource/name arguments"
+		// TODO: cli-utils bug?  I think there is a metadata.name field selector (used for single object watch)
+		useSelectors = false
 	} else {
-		b = b.SelectAllParam(true).
-			ResourceTypes("packagerevisions")
+		b = b.ResourceTypes("packagerevisions")
+	}
+
+	if useSelectors {
+		fieldSelector := fields.Everything()
+		if r.revision != "" {
+			fieldSelector = fields.OneTermEqualSelector("spec.revision", r.revision)
+		}
+		if r.packageName != "" {
+			fieldSelector = fields.OneTermEqualSelector("spec.packageName", r.packageName)
+		}
+		if s := fieldSelector.String(); s != "" {
+			b = b.FieldSelectorParam(s)
+		} else {
+			b = b.SelectAllParam(true)
+		}
 	}
 
 	b = b.ContinueOnError().
@@ -129,16 +179,39 @@ func (r *runner) runE(cmd *cobra.Command, args []string) error {
 		return errors.E(op, err)
 	}
 
+	// Decode json objects in tables (likely PartialObjectMetadata)
+	for _, i := range infos {
+		if table, ok := i.Object.(*metav1.Table); ok {
+			for i := range table.Rows {
+				row := &table.Rows[i]
+				if row.Object.Object == nil && row.Object.Raw != nil {
+					u := &unstructured.Unstructured{}
+					if err := u.UnmarshalJSON(row.Object.Raw); err != nil {
+						klog.Warningf("error parsing raw object: %v", err)
+					}
+					row.Object.Object = u
+				}
+			}
+		}
+	}
+
+	// Apply any filters we couldn't pass down as field selectors
 	for _, i := range infos {
 		switch obj := i.Object.(type) {
 		case *unstructured.Unstructured:
-			o, err := r.tranformAndMatch(obj)
+			match, err := r.packageRevisionMatches(obj)
 			if err != nil {
 				return errors.E(op, err)
 			}
-			if o != nil {
-				objs = append(objs, o)
+			if match {
+				objs = append(objs, obj)
+			}
+		case *metav1.Table:
+			// Technically we should have applied this as a field-selector, so this might not be necessary
+			if err := r.filterTableRows(obj); err != nil {
+				return err
 			}
+			objs = append(objs, obj)
 		default:
 			return errors.E(op, fmt.Sprintf("Unrecognized response %T", obj))
 		}
@@ -149,10 +222,6 @@ func (r *runner) runE(cmd *cobra.Command, args []string) error {
 		return errors.E(op, err)
 	}
 
-	if r.requestTable {
-		printer = &get.TablePrinter{Delegate: printer}
-	}
-
 	w := printers.GetNewTabWriter(cmd.OutOrStdout())
 	for _, obj := range objs {
 		if err := printer.PrintObj(obj, w); err != nil {
@@ -166,37 +235,22 @@ func (r *runner) runE(cmd *cobra.Command, args []string) error {
 	return nil
 }
 
-var tableGVK = metav1.SchemeGroupVersion.WithKind("Table")
-var packageRevisionGVK = api.SchemeGroupVersion.WithKind("PackageRevision")
-
-func (r *runner) tranformAndMatch(obj *unstructured.Unstructured) (runtime.Object, error) {
-	switch gvk := obj.GroupVersionKind(); gvk {
-	case tableGVK:
-		return r.matchTable(obj)
-	case packageRevisionGVK:
-		return r.matchPackageRevision(obj)
-	default:
-		fmt.Fprintf(r.Command.OutOrStderr(), "Unrecognized response type %s", gvk)
-		return obj, nil // unrecognized, attempt to print
-	}
-}
-
-func (r *runner) matchPackageRevision(o *unstructured.Unstructured) (runtime.Object, error) {
+func (r *runner) packageRevisionMatches(o *unstructured.Unstructured) (bool, error) {
 	packageName, _, err := unstructured.NestedString(o.Object, "spec", "packageName")
 	if err != nil {
-		return nil, err
+		return false, err
 	}
 	revision, _, err := unstructured.NestedString(o.Object, "spec", "revision")
 	if err != nil {
-		return nil, err
+		return false, err
 	}
-	if !strings.Contains(packageName, r.name) {
-		return nil, nil
+	if r.packageName != "" && r.packageName != packageName {
+		return false, nil
 	}
 	if r.revision != "" && r.revision != revision {
-		return nil, nil
+		return false, nil
 	}
-	return o, nil
+	return true, nil
 }
 
 func findColumn(cols []metav1.TableColumnDefinition, name string) int {
@@ -216,31 +270,22 @@ func getStringCell(cells []interface{}, col int) (string, bool) {
 	return s, ok
 }
 
-func (r *runner) matchTable(u *unstructured.Unstructured) (runtime.Object, error) {
-	table := &metav1.Table{}
-	if err := runtime.DefaultUnstructuredConverter.FromUnstructured(u.Object, table); err != nil {
-		return nil, err
-	}
-
+func (r *runner) filterTableRows(table *metav1.Table) error {
 	filtered := make([]metav1.TableRow, 0, len(table.Rows))
-	nameCol := findColumn(table.ColumnDefinitions, "Package")
+	packageNameCol := findColumn(table.ColumnDefinitions, "Package")
 	revisionCol := findColumn(table.ColumnDefinitions, "Revision")
 
 	for i := range table.Rows {
 		row := &table.Rows[i]
-		if row.Object.Object != nil {
-			fmt.Fprintf(r.Command.OutOrStderr(), "Found non-nil Object in table: %s",
-				row.Object.Object.GetObjectKind().GroupVersionKind())
-		} else {
-			if name, ok := getStringCell(row.Cells, nameCol); ok {
-				if !strings.Contains(name, r.name) {
-					continue
-				}
+
+		if packageName, ok := getStringCell(row.Cells, packageNameCol); ok {
+			if r.packageName != "" && r.packageName != packageName {
+				continue
 			}
-			if revision, ok := getStringCell(row.Cells, revisionCol); ok {
-				if r.revision != "" && r.revision != revision {
-					continue
-				}
+		}
+		if revision, ok := getStringCell(row.Cells, revisionCol); ok {
+			if r.revision != "" && r.revision != revision {
+				continue
 			}
 		}
 
@@ -248,5 +293,5 @@ func (r *runner) matchTable(u *unstructured.Unstructured) (runtime.Object, error
 		filtered = append(filtered, *row)
 	}
 	table.Rows = filtered
-	return table, nil
+	return nil
 }
diff --git a/internal/fnruntime/container.go b/internal/fnruntime/container.go
index 0d2ab9a7..b0772040 100644
--- a/internal/fnruntime/container.go
+++ b/internal/fnruntime/container.go
@@ -29,7 +29,6 @@ import (
 	"time"
 
 	"github.com/GoogleContainerTools/kpt/internal/printer"
-	"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"
@@ -225,25 +224,13 @@ func NewContainerEnvFromStringSlice(envStr []string) *runtimeutil.ContainerEnv {
 	return ce
 }
 
-// AddDefaultImagePathPrefix converts the function short path to the full image url.
+// ResolveToImageForCLI converts the function short path to the full image url.
 // If the function is Catalog function, it adds "gcr.io/kpt-fn/".e.g. set-namespace:v0.1 --> gcr.io/kpt-fn/set-namespace:v0.1
-// If the function is porch function, it queries porch to get the function image by name and namespace.
-// e.g. default:set-namespace:v0.1 --> us-west1-docker.pkg.dev/cpa-kit-dev/packages/set-namespace:v0.1
-func AddDefaultImagePathPrefix(ctx context.Context, image string) string {
-	segments := strings.Split(image, ":")
-	if len(segments) == 4 {
-		// Porch function
-		functionName := strings.Join(segments[1:], ":")
-		function, err := porch.FunctionGetter{}.Get(ctx, functionName, segments[0])
-		if err != nil {
-			return image
-		}
-		return function.Spec.Image
-	}
+func ResolveToImageForCLI(ctx context.Context, image string) (string, error) {
 	if !strings.Contains(image, "/") {
-		return fmt.Sprintf("gcr.io/kpt-fn/%s", image)
+		return fmt.Sprintf("gcr.io/kpt-fn/%s", image), nil
 	}
-	return image
+	return image, nil
 }
 
 // ContainerImageError is an error type which will be returned when
diff --git a/internal/fnruntime/runner.go b/internal/fnruntime/runner.go
index 8356e2a2..a3a9490a 100644
--- a/internal/fnruntime/runner.go
+++ b/internal/fnruntime/runner.go
@@ -45,6 +45,39 @@ const (
 	FuncGenPkgContext = "builtins/gen-pkg-context"
 )
 
+type RunnerOptions struct {
+	// ImagePullPolicy controls the image pulling behavior before running the container.
+	ImagePullPolicy ImagePullPolicy
+
+	// when set to true, function runner will set the package path annotation
+	// on resources that do not have it set. The resources generated by
+	// functions do not have this annotation set.
+	SetPkgPathAnnotation bool
+
+	DisplayResourceCount bool
+
+	// allowExec determines if function binary executable are allowed
+	// to be run during pipeline execution. Running function binaries is a
+	// privileged operation, so explicit permission is required.
+	AllowExec bool
+
+	// allowWasm determines if function wasm are allowed to be run during pipeline
+	// execution. Running wasm function is an alpha feature, so it needs to be
+	// enabled explicitly.
+	AllowWasm bool
+
+	// ResolveToImage will resolve a partial image to a fully-qualified one
+	ResolveToImage ImageResolveFunc
+}
+
+// ImageResolveFunc is the type for a function that can resolve a partial image to a (more) fully-qualified name
+type ImageResolveFunc func(ctx context.Context, image string) (string, error)
+
+func (o *RunnerOptions) InitDefaults() {
+	o.ImagePullPolicy = IfNotPresentPull
+	o.ResolveToImage = ResolveToImageForCLI
+}
+
 // NewRunner returns a FunctionRunner given a specification of a function
 // and it's config.
 func NewRunner(
@@ -53,8 +86,7 @@ func NewRunner(
 	f *kptfilev1.Function,
 	pkgPath types.UniquePath,
 	fnResults *fnresult.ResultList,
-	imagePullPolicy ImagePullPolicy,
-	setPkgPathAnnotation, displayResourceCount, allowWasm bool,
+	opts RunnerOptions,
 	runtime fn.FunctionRuntime,
 ) (*FunctionRunner, error) {
 	config, err := newFnConfig(fsys, f, pkgPath)
@@ -62,7 +94,11 @@ func NewRunner(
 		return nil, err
 	}
 	if f.Image != "" {
-		f.Image = AddDefaultImagePathPrefix(ctx, f.Image)
+		img, err := opts.ResolveToImage(ctx, f.Image)
+		if err != nil {
+			return nil, err
+		}
+		f.Image = img
 	}
 
 	fnResult := &fnresult.Result{
@@ -97,7 +133,7 @@ func NewRunner(
 			switch {
 			case f.Image != "":
 				// If allowWasm is true, we will use wasm runtime for image field.
-				if allowWasm {
+				if opts.AllowWasm {
 					wFn, err := NewWasmFn(NewOciLoader(filepath.Join(os.TempDir(), "kpt-fn-wasm"), f.Image))
 					if err != nil {
 						return nil, err
@@ -106,15 +142,15 @@ func NewRunner(
 				} else {
 					cfn := &ContainerFn{
 						Image:           f.Image,
-						ImagePullPolicy: imagePullPolicy,
+						ImagePullPolicy: opts.ImagePullPolicy,
 						Ctx:             ctx,
 						FnResult:        fnResult,
 					}
 					fltr.Run = cfn.Run
 				}
 			case f.Exec != "":
-				// If allowWasm is true, we will use wasm runtime for exec field.
-				if allowWasm {
+				// If AllowWasm is true, we will use wasm runtime for exec field.
+				if opts.AllowWasm {
 					wFn, err := NewWasmFn(&FsLoader{Filename: f.Exec})
 					if err != nil {
 						return nil, err
@@ -146,7 +182,7 @@ func NewRunner(
 			}
 		}
 	}
-	return NewFunctionRunner(ctx, fltr, pkgPath, fnResult, fnResults, setPkgPathAnnotation, displayResourceCount, allowWasm)
+	return NewFunctionRunner(ctx, fltr, pkgPath, fnResult, fnResults, opts)
 }
 
 // NewFunctionRunner returns a FunctionRunner given a specification of a function
@@ -156,9 +192,7 @@ func NewFunctionRunner(ctx context.Context,
 	pkgPath types.UniquePath,
 	fnResult *fnresult.Result,
 	fnResults *fnresult.ResultList,
-	setPkgPathAnnotation bool,
-	displayResourceCount bool,
-	wasm bool) (*FunctionRunner, error) {
+	opts RunnerOptions) (*FunctionRunner, error) {
 	name := fnResult.Image
 	if name == "" {
 		name = fnResult.ExecPath
@@ -169,15 +203,13 @@ func NewFunctionRunner(ctx context.Context,
 	// See https://github.com/GoogleContainerTools/kpt/issues/3230 for more details.
 	fltr.GlobalScope = true
 	return &FunctionRunner{
-		ctx:                  ctx,
-		name:                 name,
-		pkgPath:              pkgPath,
-		filter:               fltr,
-		fnResult:             fnResult,
-		fnResults:            fnResults,
-		setPkgPathAnnotation: setPkgPathAnnotation,
-		displayResourceCount: displayResourceCount,
-		wasm:                 wasm,
+		ctx:       ctx,
+		name:      name,
+		pkgPath:   pkgPath,
+		filter:    fltr,
+		fnResult:  fnResult,
+		fnResults: fnResults,
+		opts:      opts,
 	}, nil
 }
 
@@ -190,23 +222,18 @@ type FunctionRunner struct {
 	filter           *runtimeutil.FunctionFilter
 	fnResult         *fnresult.Result
 	fnResults        *fnresult.ResultList
-	// when set to true, function runner will set the package path annotation
-	// on resources that do not have it set. The resources generated by
-	// functions do not have this annotation set.
-	setPkgPathAnnotation bool
-	displayResourceCount bool
-	wasm                 bool
+	opts             RunnerOptions
 }
 
 func (fr *FunctionRunner) Filter(input []*yaml.RNode) (output []*yaml.RNode, err error) {
 	pr := printer.FromContextOrDie(fr.ctx)
 	if !fr.disableCLIOutput {
-		if fr.wasm {
+		if fr.opts.AllowWasm {
 			pr.Printf("[RUNNING] WASM %q", fr.name)
 		} else {
 			pr.Printf("[RUNNING] %q", fr.name)
 		}
-		if fr.displayResourceCount {
+		if fr.opts.DisplayResourceCount {
 			pr.Printf(" on %d resource(s)", len(input))
 		}
 		pr.Printf("\n")
@@ -247,7 +274,7 @@ func (fr *FunctionRunner) do(input []*yaml.RNode) (output []*yaml.RNode, err err
 	fnResult := fr.fnResult
 	output, err = fr.filter.Filter(input)
 
-	if fr.setPkgPathAnnotation {
+	if fr.opts.SetPkgPathAnnotation {
 		if pkgPathErr := setPkgPathAnnotationIfNotExist(output, fr.pkgPath); pkgPathErr != nil {
 			return output, pkgPathErr
 		}
diff --git a/internal/hook/executor.go b/internal/hook/executor.go
index f13c71c7..e509820d 100644
--- a/internal/hook/executor.go
+++ b/internal/hook/executor.go
@@ -35,11 +35,11 @@ var ErrAllowedExecNotSpecified = fmt.Errorf("must run with `--allow-exec` option
 
 // Executor executes a hook.
 type Executor struct {
-	PkgPath         string
-	ResultsDirPath  string
-	Output          io.Writer
-	ImagePullPolicy fnruntime.ImagePullPolicy
-	AllowExec       bool
+	PkgPath        string
+	ResultsDirPath string
+	Output         io.Writer
+
+	RunnerOptions fnruntime.RunnerOptions
 
 	FileSystem filesys.FileSystem
 
@@ -83,18 +83,16 @@ func (e *Executor) fnChain(ctx context.Context, fns []kptfilev1.Function) ([]kio
 		var err error
 		var runner kio.Filter
 		fn := fns[i]
-		if fn.Exec != "" && !e.AllowExec {
+		if fn.Exec != "" && !e.RunnerOptions.AllowExec {
 			return nil, ErrAllowedExecNotSpecified
 		}
+		opts := e.RunnerOptions
 		runner, err = fnruntime.NewRunner(ctx,
 			e.FileSystem,
 			&fn,
 			types.UniquePath(e.PkgPath),
 			e.fnResults,
-			e.ImagePullPolicy,
-			false, /* do not set pkg annotations */
-			false, /* do not display resource */
-			true,  /* allow wasm functions to run */
+			opts,
 			e.Runtime)
 		if err != nil {
 			return nil, err
diff --git a/internal/util/get/get.go b/internal/util/get/get.go
index 414b68a9..605acf2a 100644
--- a/internal/util/get/get.go
+++ b/internal/util/get/get.go
@@ -126,9 +126,10 @@ func (c Command) Run(ctx context.Context) error {
 	if c.IsDeploymentInstance {
 		pr := printer.FromContextOrDie(ctx)
 		pr.Printf("\nCustomizing package for deployment.\n")
-		hookCmd := hook.Executor{
-			PkgPath: c.Destination,
-		}
+		hookCmd := hook.Executor{}
+		hookCmd.RunnerOptions.InitDefaults()
+		hookCmd.PkgPath = c.Destination
+
 		builtinHooks := []kptfilev1.Function{
 			{
 				Image: fnruntime.FuncGenPkgContext,
diff --git a/internal/util/render/executor.go b/internal/util/render/executor.go
index 66256c2e..7dc03b5e 100644
--- a/internal/util/render/executor.go
+++ b/internal/util/render/executor.go
@@ -58,15 +58,8 @@ type Renderer struct {
 	// Output is the writer to which the output resources are written
 	Output io.Writer
 
-	// ImagePullPolicy pull image before running the container.
-	// It must be one of fnruntime.AlwaysPull, fnruntime.IfNotPresentPull, fnruntime.NeverPull
-	ImagePullPolicy fnruntime.ImagePullPolicy
-
-	// AllowExec allow binary executable to be run during pipeline execution
-	AllowExec bool
-
-	// AllowWasm allow wasm to be run during pipeline execution
-	AllowWasm bool
+	// RunnerOptions contains options controlling function execution.
+	RunnerOptions fnruntime.RunnerOptions
 
 	// FileSystem is the input filesystem to operate on
 	FileSystem filesys.FileSystem
@@ -85,14 +78,12 @@ func (e *Renderer) Execute(ctx context.Context) error {
 
 	// initialize hydration context
 	hctx := &hydrationContext{
-		root:            root,
-		pkgs:            map[types.UniquePath]*pkgNode{},
-		fnResults:       fnresult.NewResultList(),
-		imagePullPolicy: e.ImagePullPolicy,
-		allowExec:       e.AllowExec,
-		allowWasm:       e.AllowWasm,
-		fileSystem:      e.FileSystem,
-		runtime:         e.Runtime,
+		root:          root,
+		pkgs:          map[types.UniquePath]*pkgNode{},
+		fnResults:     fnresult.NewResultList(),
+		runnerOptions: e.RunnerOptions,
+		fileSystem:    e.FileSystem,
+		runtime:       e.Runtime,
 	}
 
 	if _, err = hydrate(ctx, root, hctx); err != nil {
@@ -194,18 +185,7 @@ type hydrationContext struct {
 	// during pipeline execution.
 	fnResults *fnresult.ResultList
 
-	// imagePullPolicy controls the image pulling behavior.
-	imagePullPolicy fnruntime.ImagePullPolicy
-
-	// allowExec determines if function binary executable are allowed
-	// to be run during pipeline execution. Running function binaries is a
-	// privileged operation, so explicit permission is required.
-	allowExec bool
-
-	// allowWasm determines if function wasm are allowed to be run during pipeline
-	// execution. Running wasm function is an alpha feature, so it needs to be
-	// enabled explicitly.
-	allowWasm bool
+	runnerOptions fnruntime.RunnerOptions
 
 	fileSystem filesys.FileSystem
 
@@ -502,11 +482,13 @@ func (pn *pkgNode) runValidators(ctx context.Context, hctx *hydrationContext, in
 		if len(function.Selectors) > 0 || len(function.Exclusions) > 0 {
 			displayResourceCount = true
 		}
-		if function.Exec != "" && !hctx.allowExec {
+		if function.Exec != "" && !hctx.runnerOptions.AllowExec {
 			return errAllowedExecNotSpecified
 		}
-		validator, err = fnruntime.NewRunner(ctx, hctx.fileSystem, &function, pn.pkg.UniquePath, hctx.fnResults, hctx.imagePullPolicy,
-			true, displayResourceCount, hctx.allowWasm, hctx.runtime)
+		opts := hctx.runnerOptions
+		opts.SetPkgPathAnnotation = true
+		opts.DisplayResourceCount = displayResourceCount
+		validator, err = fnruntime.NewRunner(ctx, hctx.fileSystem, &function, pn.pkg.UniquePath, hctx.fnResults, opts, hctx.runtime)
 		if err != nil {
 			return err
 		}
@@ -610,11 +592,13 @@ func fnChain(ctx context.Context, hctx *hydrationContext, pkgPath types.UniquePa
 		if len(function.Selectors) > 0 || len(function.Exclusions) > 0 {
 			displayResourceCount = true
 		}
-		if function.Exec != "" && !hctx.allowExec {
+		if function.Exec != "" && !hctx.runnerOptions.AllowExec {
 			return nil, errAllowedExecNotSpecified
 		}
-		runner, err = fnruntime.NewRunner(ctx, hctx.fileSystem, &function, pkgPath, hctx.fnResults, hctx.imagePullPolicy,
-			true, displayResourceCount, hctx.allowWasm, hctx.runtime)
+		opts := hctx.runnerOptions
+		opts.SetPkgPathAnnotation = true
+		opts.DisplayResourceCount = displayResourceCount
+		runner, err = fnruntime.NewRunner(ctx, hctx.fileSystem, &function, pkgPath, hctx.fnResults, opts, hctx.runtime)
 		if err != nil {
 			return nil, err
 		}
diff --git a/porch/pkg/apiserver/apiserver.go b/porch/pkg/apiserver/apiserver.go
index 63f8d035..379c5359 100644
--- a/porch/pkg/apiserver/apiserver.go
+++ b/porch/pkg/apiserver/apiserver.go
@@ -18,6 +18,7 @@ import (
 	"context"
 	"fmt"
 
+	"github.com/GoogleContainerTools/kpt/internal/fnruntime"
 	"github.com/GoogleContainerTools/kpt/porch/api/porch/install"
 	configapi "github.com/GoogleContainerTools/kpt/porch/api/porchconfig/v1alpha1"
 	"github.com/GoogleContainerTools/kpt/porch/pkg/cache"
@@ -202,7 +203,12 @@ func (c completedConfig) New() (*PorchServer, error) {
 	referenceResolver := porch.NewReferenceResolver(coreClient)
 	userInfoProvider := &porch.ApiserverUserInfoProvider{}
 
-	renderer := kpt.NewRenderer()
+	runnerOptions := fnruntime.RunnerOptions{}
+	runnerOptions.ResolveToImage = resolveToImagePorch
+
+	runnerOptions.InitDefaults()
+
+	renderer := kpt.NewRenderer(runnerOptions)
 
 	cache := cache.NewCache(c.ExtraConfig.CacheDirectory, cache.CacheOptions{
 		CredentialResolver: credentialResolver,
diff --git a/porch/pkg/apiserver/resolvetoimage.go b/porch/pkg/apiserver/resolvetoimage.go
new file mode 100644
index 00000000..40750a67
--- /dev/null
+++ b/porch/pkg/apiserver/resolvetoimage.go
@@ -0,0 +1,44 @@
+// Copyright 2022 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.
+
+package apiserver
+
+import (
+	"context"
+	"fmt"
+	"strings"
+
+	"github.com/GoogleContainerTools/kpt/internal/util/porch"
+)
+
+// resolveToImagePorch converts the function short path to the full image url.
+// If the function is Catalog function, it adds "gcr.io/kpt-fn/".e.g. set-namespace:v0.1 --> gcr.io/kpt-fn/set-namespace:v0.1
+// If the function is porch function, it queries porch to get the function image by name and namespace.
+// e.g. default:set-namespace:v0.1 --> us-west1-docker.pkg.dev/cpa-kit-dev/packages/set-namespace:v0.1
+func resolveToImagePorch(ctx context.Context, image string) (string, error) {
+	segments := strings.Split(image, ":")
+	if len(segments) == 4 {
+		// Porch function
+		functionName := strings.Join(segments[1:], ":")
+		function, err := porch.FunctionGetter{}.Get(ctx, functionName, segments[0])
+		if err != nil {
+			return "", fmt.Errorf("failed to resolve image: %w", err)
+		}
+		return function.Spec.Image, nil
+	}
+	if !strings.Contains(image, "/") {
+		return fmt.Sprintf("gcr.io/kpt-fn/%s", image), nil
+	}
+	return image, nil
+}
diff --git a/porch/pkg/git/repo.go b/porch/pkg/git/repo.go
new file mode 100644
index 00000000..2aa1d3bb
--- /dev/null
+++ b/porch/pkg/git/repo.go
@@ -0,0 +1,60 @@
+// Copyright 2022 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.
+
+package git
+
+import gogit "github.com/go-git/go-git/v5"
+
+// Repo manages a single git repository
+type Repo struct {
+	gogit *gogit.Repository
+
+	// Basic auth
+	username string
+	password string
+}
+
+// NewRepo constructs an instance of Repo
+func NewRepo(gogit *gogit.Repository, options ...GitRepoOption) (*Repo, error) {
+	r := &Repo{
+		gogit: gogit,
+	}
+	for _, option := range options {
+		if err := option.apply(r); err != nil {
+			return nil, err
+		}
+	}
+	return r, nil
+}
+
+// GitRepoOption is implemented by configuration settings for git repository.
+type GitRepoOption interface {
+	apply(*Repo) error
+}
+
+type optionBasicAuth struct {
+	username, password string
+}
+
+func (o *optionBasicAuth) apply(s *Repo) error {
+	s.username, s.password = o.username, o.password
+	return nil
+}
+
+func WithBasicAuth(username, password string) GitRepoOption {
+	return &optionBasicAuth{
+		username: username,
+		password: password,
+	}
+}
diff --git a/porch/pkg/git/repos.go b/porch/pkg/git/repos.go
new file mode 100644
index 00000000..ca8923a0
--- /dev/null
+++ b/porch/pkg/git/repos.go
@@ -0,0 +1,215 @@
+// Copyright 2022 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.
+
+package git
+
+import (
+	"context"
+	"fmt"
+	"path/filepath"
+	"sync"
+	"time"
+
+	gogit "github.com/go-git/go-git/v5"
+	"github.com/go-git/go-git/v5/plumbing"
+	"github.com/go-git/go-git/v5/plumbing/object"
+)
+
+type Repos interface {
+	FindRepo(ctx context.Context, id string) (*Repo, error)
+}
+
+// StaticRepos holds multiple registered git repositories
+type StaticRepos struct {
+	mutex sync.Mutex
+	repos map[string]*Repo
+}
+
+// NewRepos constructs an instance of Repos
+func NewStaticRepos() *StaticRepos {
+	return &StaticRepos{
+		repos: make(map[string]*Repo),
+	}
+}
+
+// FindRepo returns a repo registered under the specified id, or nil if none is registered.
+func (r *StaticRepos) FindRepo(ctx context.Context, id string) (*Repo, error) {
+	r.mutex.Lock()
+	defer r.mutex.Unlock()
+
+	return r.repos[id], nil
+}
+
+// Add registers a git repository under the specified id
+func (r *StaticRepos) Add(id string, repo *Repo) error {
+	if !isRepoIDAllowed(id) {
+		return fmt.Errorf("invalid name %q", id)
+	}
+
+	r.mutex.Lock()
+	defer r.mutex.Unlock()
+	if _, found := r.repos[id]; found {
+		return fmt.Errorf("repo %q already exists", id)
+	}
+	r.repos[id] = repo
+	return nil
+}
+
+func NewDynamicRepos(baseDir string, gitRepoOptions []GitRepoOption) *DynamicRepos {
+	return &DynamicRepos{
+		baseDir:        baseDir,
+		repos:          make(map[string]*dynamicRepo),
+		gitRepoOptions: gitRepoOptions,
+	}
+}
+
+type DynamicRepos struct {
+	mutex          sync.Mutex
+	repos          map[string]*dynamicRepo
+	baseDir        string
+	gitRepoOptions []GitRepoOption
+}
+
+type dynamicRepo struct {
+	mutex          sync.Mutex
+	repo           *Repo
+	dir            string
+	gitRepoOptions []GitRepoOption
+}
+
+func isRepoIDAllowed(s string) bool {
+	if len(s) == 0 {
+		return false
+	}
+	for _, r := range s {
+		if r >= 'a' && r <= 'z' {
+			// OK
+		} else if r >= '0' && r <= '9' {
+			// OK
+		} else {
+			switch r {
+			case '-':
+				// OK
+			default:
+				return false
+			}
+		}
+	}
+	return true
+}
+
+func (r *DynamicRepos) FindRepo(ctx context.Context, id string) (*Repo, error) {
+	dir := filepath.Join(r.baseDir, id)
+	if !isRepoIDAllowed(id) {
+		return nil, fmt.Errorf("invalid name %q", id)
+	}
+
+	r.mutex.Lock()
+	repo := r.repos[id]
+	if repo == nil {
+		repo = &dynamicRepo{
+			dir:            dir,
+			gitRepoOptions: r.gitRepoOptions,
+		}
+		r.repos[id] = repo
+	}
+	r.mutex.Unlock()
+
+	return repo.open()
+}
+
+func (r *dynamicRepo) open() (*Repo, error) {
+	r.mutex.Lock()
+	defer r.mutex.Unlock()
+
+	if r.repo == nil {
+		var gogitRepo *gogit.Repository
+		var err error
+
+		if gogitRepo, err = gogit.PlainOpen(r.dir); err != nil {
+			if err != gogit.ErrRepositoryNotExists {
+				return nil, fmt.Errorf("failed to open git repository %q: %w", r.dir, err)
+			}
+			isBare := true
+			gogitRepo, err = gogit.PlainInit(r.dir, isBare)
+			if err != nil {
+				return nil, fmt.Errorf("failed to initialize git repository %q: %w", r.dir, err)
+			}
+			if err := CreateEmptyCommit(gogitRepo); err != nil {
+				return nil, err
+			}
+
+			// Delete go-git default branch
+			_ = gogitRepo.Storer.RemoveReference(plumbing.Master)
+		}
+		repo, err := NewRepo(gogitRepo, r.gitRepoOptions...)
+		if err != nil {
+			return nil, err
+		}
+		r.repo = repo
+	}
+
+	return r.repo, nil
+}
+
+func CreateEmptyCommit(repo *gogit.Repository) error {
+	store := repo.Storer
+	// Create first commit using empty tree.
+	emptyTree := object.Tree{}
+	encodedTree := store.NewEncodedObject()
+	if err := emptyTree.Encode(encodedTree); err != nil {
+		return fmt.Errorf("failed to encode initial empty commit tree: %w", err)
+	}
+
+	treeHash, err := store.SetEncodedObject(encodedTree)
+	if err != nil {
+		return fmt.Errorf("failed to create initial empty commit tree: %w", err)
+	}
+
+	sig := object.Signature{
+		Name:  "Git Server",
+		Email: "git-server@kpt.dev",
+		When:  time.Now(),
+	}
+
+	commit := object.Commit{
+		Author:       sig,
+		Committer:    sig,
+		Message:      "Empty Commit",
+		TreeHash:     treeHash,
+		ParentHashes: []plumbing.Hash{}, // No parents
+	}
+
+	encodedCommit := store.NewEncodedObject()
+	if err := commit.Encode(encodedCommit); err != nil {
+		return fmt.Errorf("failed to encode initial empty commit: %w", err)
+	}
+
+	commitHash, err := store.SetEncodedObject(encodedCommit)
+	if err != nil {
+		return fmt.Errorf("failed to create initial empty commit: %w", err)
+	}
+
+	main := plumbing.NewHashReference(plumbing.ReferenceName("refs/heads/main"), commitHash)
+	if err := repo.Storer.SetReference(main); err != nil {
+		return fmt.Errorf("failed to set refs/heads/main to commit sha %s: %w", commitHash, err)
+	}
+
+	head := plumbing.NewSymbolicReference(plumbing.HEAD, "refs/heads/main")
+	if err := repo.Storer.SetReference(head); err != nil {
+		return fmt.Errorf("failed to set HEAD to refs/heads/main: %w", err)
+	}
+
+	return nil
+}
diff --git a/porch/pkg/kpt/render.go b/porch/pkg/kpt/render.go
index 7185275f..49bd8e98 100644
--- a/porch/pkg/kpt/render.go
+++ b/porch/pkg/kpt/render.go
@@ -20,6 +20,7 @@ import (
 	"io"
 	"os"
 
+	"github.com/GoogleContainerTools/kpt/internal/fnruntime"
 	"github.com/GoogleContainerTools/kpt/internal/pkg"
 	"github.com/GoogleContainerTools/kpt/internal/printer"
 	"github.com/GoogleContainerTools/kpt/internal/util/render"
@@ -28,22 +29,23 @@ import (
 	"sigs.k8s.io/kustomize/kyaml/filesys"
 )
 
-func NewRenderer() fn.Renderer {
-	return &renderer{}
+func NewRenderer(runnerOptions fnruntime.RunnerOptions) fn.Renderer {
+	return &renderer{runnerOptions: runnerOptions}
 }
 
 type renderer struct {
+	runnerOptions fnruntime.RunnerOptions
 }
 
 var _ fn.Renderer = &renderer{}
 
 func (r *renderer) Render(ctx context.Context, pkg filesys.FileSystem, opts fn.RenderOptions) error {
 	rr := render.Renderer{
-		PkgPath:    opts.PkgPath,
-		Runtime:    opts.Runtime,
-		FileSystem: pkg,
+		PkgPath:       opts.PkgPath,
+		Runtime:       opts.Runtime,
+		FileSystem:    pkg,
+		RunnerOptions: r.runnerOptions,
 	}
-
 	return rr.Execute(printer.WithContext(ctx, &packagePrinter{}))
 }
 
diff --git a/thirdparty/cmdconfig/commands/cmdeval/cmdeval.go b/thirdparty/cmdconfig/commands/cmdeval/cmdeval.go
index b89d1b6b..ba6ecd5a 100644
--- a/thirdparty/cmdconfig/commands/cmdeval/cmdeval.go
+++ b/thirdparty/cmdconfig/commands/cmdeval/cmdeval.go
@@ -86,14 +86,14 @@ func GetEvalFnRunner(ctx context.Context, parent string) *EvalFnRunner {
 	r.Command.Flags().BoolVar(
 		&r.AsCurrentUser, "as-current-user", false, "use the uid and gid that kpt is running with to run the function in the container")
 
-	r.Command.Flags().Var(&r.ImagePullPolicy, "image-pull-policy",
-		"pull image before running the container "+r.ImagePullPolicy.HelpAllowedValues())
-	r.Command.RegisterFlagCompletionFunc("image-pull-policy", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
-		return r.ImagePullPolicy.AllStrings(), cobra.ShellCompDirectiveDefault
+	r.Command.Flags().Var(&r.RunnerOptions.ImagePullPolicy, "image-pull-policy",
+		"pull image before running the container "+r.RunnerOptions.ImagePullPolicy.HelpAllowedValues())
+	_ = r.Command.RegisterFlagCompletionFunc("image-pull-policy", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
+		return r.RunnerOptions.ImagePullPolicy.AllStrings(), cobra.ShellCompDirectiveDefault
 	})
 
 	r.Command.Flags().BoolVar(
-		&r.AllowWasm, "allow-alpha-wasm", false, "allow alpha wasm functions to be run. If true, you can specify a wasm image with --image flag or a path to a wasm file (must have the .wasm file extension) with --exec flag.")
+		&r.RunnerOptions.AllowWasm, "allow-alpha-wasm", false, "allow alpha wasm functions to be run. If true, you can specify a wasm image with --image flag or a path to a wasm file (must have the .wasm file extension) with --exec flag.")
 
 	// selector flags
 	r.Command.Flags().StringVar(
@@ -146,33 +146,34 @@ type EvalFnRunner struct {
 	FnType               string
 	Exec                 string
 	FnConfigPath         string
-	RunFns               runfn.RunFns
 	ResultsDir           string
-	ImagePullPolicy      fnruntime.ImagePullPolicy
 	Network              bool
 	Mounts               []string
 	Env                  []string
 	AsCurrentUser        bool
 	IncludeMetaResources bool
-	AllowWasm            bool
 	Ctx                  context.Context
 	Selector             kptfile.Selector
 	Exclusion            kptfile.Selector
 	dataItems            []string
 
+	RunnerOptions fnruntime.RunnerOptions
+
 	// we will need to parse these values into Selector and Exclusion
 	selectorLabels      []string
 	selectorAnnotations []string
 	excludeLabels       []string
 	excludeAnnotations  []string
+
+	runFns runfn.RunFns
 }
 
 func (r *EvalFnRunner) InitDefaults() {
-	r.ImagePullPolicy = fnruntime.IfNotPresentPull
+	r.RunnerOptions.InitDefaults()
 }
 
 func (r *EvalFnRunner) runE(c *cobra.Command, _ []string) error {
-	err := runner.HandleError(r.Ctx, r.RunFns.Execute())
+	err := runner.HandleError(r.Ctx, r.runFns.Execute())
 	if err != nil {
 		return err
 	}
@@ -203,7 +204,7 @@ func (r *EvalFnRunner) NewFunction() *kptfile.Function {
 	}
 	if r.FnConfigPath != "" {
 		fnConfigAbsPath, _, _ := pathutil.ResolveAbsAndRelPaths(r.FnConfigPath)
-		pkgAbsPath, _, _ := pathutil.ResolveAbsAndRelPaths(r.RunFns.Path)
+		pkgAbsPath, _, _ := pathutil.ResolveAbsAndRelPaths(r.runFns.Path)
 		newFn.ConfigPath, _ = filepath.Rel(pkgAbsPath, fnConfigAbsPath)
 	} else {
 		data := map[string]string{}
@@ -256,7 +257,7 @@ func (r *EvalFnRunner) updateFnList(oldFNs []kptfile.Function) ([]kptfile.Functi
 // SaveFnToKptfile adds the evaluated function and its arguments to Kptfile `pipeline.mutators` or `pipeline.validators` .
 func (r *EvalFnRunner) SaveFnToKptfile() {
 	pr := printer.FromContextOrDie(r.Ctx)
-	kf, err := pkg.ReadKptfile(filesys.FileSystemOrOnDisk{}, r.RunFns.Path)
+	kf, err := pkg.ReadKptfile(filesys.FileSystemOrOnDisk{}, r.runFns.Path)
 	if err != nil {
 		pr.Printf("function not added: Kptfile not exists\n")
 		return
@@ -281,7 +282,7 @@ func (r *EvalFnRunner) SaveFnToKptfile() {
 	// When saving function to Kptfile, the functionConfig should be the relative path
 	// to the kpt package, not the relative path to the current working dir.
 	// error handling are ignored since they have been validated in preRunE.
-	if err := kptfileutil.WriteFile(r.RunFns.Path, mutatedKfAsYNode); err != nil {
+	if err := kptfileutil.WriteFile(r.runFns.Path, mutatedKfAsYNode); err != nil {
 		pr.Printf("function is not added to Kptfile: %v\n", err)
 		return
 	}
@@ -291,7 +292,7 @@ func (r *EvalFnRunner) SaveFnToKptfile() {
 // preserveCommentsAndFieldOrder syncs the mutated Kptfile with the original to preserve
 // comments and field order, and returns the result as a yaml Node
 func (r *EvalFnRunner) preserveCommentsAndFieldOrder(kf *kptfile.KptFile) (*yaml.Node, error) {
-	kfAsRNode, err := yaml.ReadFile(filepath.Join(r.RunFns.Path, kptfile.KptFileName))
+	kfAsRNode, err := yaml.ReadFile(filepath.Join(r.runFns.Path, kptfile.KptFileName))
 	if err != nil {
 		return nil, fmt.Errorf("could not read Kptfile: %v", err)
 	}
@@ -315,11 +316,20 @@ func (r *EvalFnRunner) preserveCommentsAndFieldOrder(kf *kptfile.KptFile) (*yaml
 
 // getCLIFunctionConfig parses the commandline flags and arguments into explicit
 // function config
-func (r *EvalFnRunner) getCLIFunctionConfig(dataItems []string) (*yaml.RNode, error) {
+func (r *EvalFnRunner) getCLIFunctionConfig(ctx context.Context, dataItems []string) (*yaml.RNode, error) {
 	if r.Image == "" && r.Exec == "" {
 		return nil, nil
 	}
 
+	// TODO: This probably doesn't belong here, but moving it changes the test output
+	if r.Image != "" {
+		img, err := r.RunnerOptions.ResolveToImage(ctx, r.Image)
+		if err != nil {
+			return nil, err
+		}
+		r.Image = img
+	}
+
 	var err error
 
 	// create the function config
@@ -457,9 +467,6 @@ func (r *EvalFnRunner) preRunE(c *cobra.Command, args []string) error {
 	if r.Image == "" && r.Exec == "" {
 		return errors.Errorf("must specify --image or --exec")
 	}
-	if r.Image != "" {
-		r.Image = fnruntime.AddDefaultImagePathPrefix(c.Context(), r.Image)
-	}
 	var dataItems []string
 	if c.ArgsLenAtDash() >= 0 {
 		dataItems = append(dataItems, args[c.ArgsLenAtDash():]...)
@@ -475,7 +482,7 @@ func (r *EvalFnRunner) preRunE(c *cobra.Command, args []string) error {
 	if len(dataItems) > 0 && r.FnConfigPath != "" {
 		return fmt.Errorf("function arguments can only be specified without function config file")
 	}
-	fnConfig, err := r.getCLIFunctionConfig(dataItems)
+	fnConfig, err := r.getCLIFunctionConfig(c.Context(), dataItems)
 	if err != nil {
 		return err
 	}
@@ -532,28 +539,27 @@ func (r *EvalFnRunner) preRunE(c *cobra.Command, args []string) error {
 		}
 	}
 	r.parseSelectors()
-	r.RunFns = runfn.RunFns{
-		Ctx:             r.Ctx,
-		Function:        fnSpec,
-		ExecArgs:        execArgs,
-		OriginalExec:    r.Exec,
-		Output:          output,
-		Input:           input,
-		Path:            path,
-		Network:         r.Network,
-		StorageMounts:   storageMounts,
-		ResultsDir:      r.ResultsDir,
-		Env:             r.Env,
-		AsCurrentUser:   r.AsCurrentUser,
-		FnConfig:        fnConfig,
-		FnConfigPath:    r.FnConfigPath,
-		ImagePullPolicy: r.ImagePullPolicy,
+	r.runFns = runfn.RunFns{
+		Ctx:           r.Ctx,
+		Function:      fnSpec,
+		ExecArgs:      execArgs,
+		OriginalExec:  r.Exec,
+		Output:        output,
+		Input:         input,
+		Path:          path,
+		Network:       r.Network,
+		StorageMounts: storageMounts,
+		ResultsDir:    r.ResultsDir,
+		Env:           r.Env,
+		AsCurrentUser: r.AsCurrentUser,
+		FnConfig:      fnConfig,
+		FnConfigPath:  r.FnConfigPath,
 		// fn eval should remove all files when all resources
 		// are deleted.
 		ContinueOnEmptyResult: true,
-		AllowWasm:             r.AllowWasm,
 		Selector:              r.Selector,
 		Exclusion:             r.Exclusion,
+		RunnerOptions:         r.RunnerOptions,
 	}
 
 	return nil
diff --git a/thirdparty/kyaml/runfn/runfn.go b/thirdparty/kyaml/runfn/runfn.go
index bbb27b0f..87e4dd4b 100644
--- a/thirdparty/kyaml/runfn/runfn.go
+++ b/thirdparty/kyaml/runfn/runfn.go
@@ -85,8 +85,7 @@ type RunFns struct {
 	// function in the list.
 	ContinueOnEmptyResult bool
 
-	// AllowWasm controls if wasm functions are allowed to be run.
-	AllowWasm bool
+	RunnerOptions fnruntime.RunnerOptions
 
 	// ExecArgs are the arguments for exec commands
 	ExecArgs []string
@@ -94,8 +93,6 @@ type RunFns struct {
 	// OriginalExec is the original exec commands
 	OriginalExec string
 
-	ImagePullPolicy fnruntime.ImagePullPolicy
-
 	Selector kptfile.Selector
 
 	Exclusion kptfile.Selector
@@ -357,11 +354,15 @@ func (r *RunFns) defaultFnFilterProvider(spec runtimeutil.FunctionSpec, fnConfig
 	if spec.Container.Image != "" {
 		fnResult.Image = spec.Container.Image
 
+		resolvedImage, err := r.RunnerOptions.ResolveToImage(context.TODO(), spec.Container.Image)
+		if err != nil {
+			return nil, err
+		}
 		// If AllowWasm is true, we try to use the image field as a wasm image.
 		// TODO: we can be smarter here. If the image doesn't support wasm/js platform,
 		// it should fallback to run it as container fn.
-		if r.AllowWasm {
-			wFn, err := fnruntime.NewWasmFn(fnruntime.NewOciLoader(filepath.Join(os.TempDir(), "kpt-fn-wasm"), spec.Container.Image))
+		if r.RunnerOptions.AllowWasm {
+			wFn, err := fnruntime.NewWasmFn(fnruntime.NewOciLoader(filepath.Join(os.TempDir(), "kpt-fn-wasm"), resolvedImage))
 			if err != nil {
 				return nil, err
 			}
@@ -373,8 +374,8 @@ func (r *RunFns) defaultFnFilterProvider(spec runtimeutil.FunctionSpec, fnConfig
 				return nil, err
 			}
 			c := &fnruntime.ContainerFn{
-				Image:           spec.Container.Image,
-				ImagePullPolicy: r.ImagePullPolicy,
+				Image:           resolvedImage,
+				ImagePullPolicy: r.RunnerOptions.ImagePullPolicy,
 				UIDGID:          uidgid,
 				StorageMounts:   r.StorageMounts,
 				Env:             spec.Container.Env,
@@ -393,7 +394,7 @@ func (r *RunFns) defaultFnFilterProvider(spec runtimeutil.FunctionSpec, fnConfig
 	if spec.Exec.Path != "" {
 		fnResult.ExecPath = r.OriginalExec
 
-		if r.AllowWasm && strings.HasSuffix(spec.Exec.Path, ".wasm") {
+		if r.RunnerOptions.AllowWasm && strings.HasSuffix(spec.Exec.Path, ".wasm") {
 			wFn, err := fnruntime.NewWasmFn(&fnruntime.FsLoader{Filename: spec.Exec.Path})
 			if err != nil {
 				return nil, err
@@ -409,9 +410,10 @@ func (r *RunFns) defaultFnFilterProvider(spec runtimeutil.FunctionSpec, fnConfig
 		}
 	}
 
-	displayResourceCount := false
+	opts := r.RunnerOptions
 	if !r.Selector.IsEmpty() || !r.Exclusion.IsEmpty() {
-		displayResourceCount = true
+		opts.DisplayResourceCount = true
 	}
-	return fnruntime.NewFunctionRunner(r.Ctx, fltr, "", fnResult, r.fnResults, false, displayResourceCount, r.AllowWasm)
+
+	return fnruntime.NewFunctionRunner(r.Ctx, fltr, "", fnResult, r.fnResults, opts)
 }


Test patch:
diff --git a/e2e/porch_test.go b/e2e/porch_test.go
index 73311fb2..373daa64 100644
--- a/e2e/porch_test.go
+++ b/e2e/porch_test.go
@@ -33,9 +33,8 @@ import (
 )
 
 const (
-	updateGoldenFiles    = "UPDATE_GOLDEN_FILES"
-	testGitNamespace     = "test-git-namespace"
-	gitRepositoryAddress = "http://git-server." + testGitNamespace + ".svc.cluster.local:8080"
+	updateGoldenFiles = "UPDATE_GOLDEN_FILES"
+	testGitNamespace  = "test-git-namespace"
 )
 
 func TestPorch(t *testing.T) {
@@ -47,7 +46,7 @@ func TestPorch(t *testing.T) {
 }
 
 func runTests(t *testing.T, path string) {
-	git := startGitServer(t, path)
+	gitServerURL := startGitServer(t, path)
 	testCases := scanTestCases(t, path)
 
 	for _, tc := range testCases {
@@ -55,19 +54,20 @@ func runTests(t *testing.T, path string) {
 			if tc.Skip != "" {
 				t.Skipf("Skipping test: %s", tc.Skip)
 			}
-			runTestCase(t, git, tc)
+			repoURL := gitServerURL + "/" + strings.ReplaceAll(tc.TestCase, "/", "-")
+			runTestCase(t, repoURL, tc)
 		})
 	}
 }
 
-func runTestCase(t *testing.T, git string, tc porch.TestCaseConfig) {
+func runTestCase(t *testing.T, repoURL string, tc porch.TestCaseConfig) {
 	porch.KubectlCreateNamespace(t, tc.TestCase)
 	t.Cleanup(func() {
 		porch.KubectlDeleteNamespace(t, tc.TestCase)
 	})
 
 	if tc.Repository != "" {
-		porch.RegisterRepository(t, git, tc.TestCase, tc.Repository)
+		porch.RegisterRepository(t, repoURL, tc.TestCase, tc.Repository)
 	}
 
 	for i := range tc.Commands {
@@ -82,6 +82,7 @@ func runTestCase(t *testing.T, git string, tc porch.TestCaseConfig) {
 		cmd.Stdout = &stdout
 		cmd.Stderr = &stderr
 
+		t.Logf("running command %v", strings.Join(cmd.Args, " "))
 		err := cmd.Run()
 
 		if command.Yaml {
@@ -128,6 +129,8 @@ func reorderYamlStdout(t *testing.T, buf *bytes.Buffer) {
 }
 
 func startGitServer(t *testing.T, path string) string {
+	gitServerURL := "http://git-server." + testGitNamespace + ".svc.cluster.local:8080"
+
 	gitServerImage := porch.GetGitServerImageName(t)
 	t.Logf("Git Image: %s", gitServerImage)
 
@@ -146,9 +149,9 @@ func startGitServer(t *testing.T, path string) string {
 	porch.KubectlApply(t, config)
 	porch.KubectlWaitForDeployment(t, testGitNamespace, "git-server")
 	porch.KubectlWaitForService(t, testGitNamespace, "git-server")
-	porch.KubectlWaitForGitDNS(t, gitRepositoryAddress)
+	porch.KubectlWaitForGitDNS(t, gitServerURL)
 
-	return gitRepositoryAddress
+	return gitServerURL
 }
 
 func scanTestCases(t *testing.T, root string) []porch.TestCaseConfig {
diff --git a/e2e/testdata/porch/rpkg-clone/config.yaml b/e2e/testdata/porch/rpkg-clone/config.yaml
index 5594e47b..0aeb8e12 100644
--- a/e2e/testdata/porch/rpkg-clone/config.yaml
+++ b/e2e/testdata/porch/rpkg-clone/config.yaml
@@ -5,7 +5,7 @@ commands:
       - register
       - --namespace=rpkg-clone
       - --name=git
-      - http://git-server.test-git-namespace.svc.cluster.local:8080
+      - http://git-server.test-git-namespace.svc.cluster.local:8080/rpkg-clone
   - args:
       - alpha
       - rpkg
diff --git a/e2e/testdata/porch/rpkg-copy/config.yaml b/e2e/testdata/porch/rpkg-copy/config.yaml
index b6af4dec..9dc3ec18 100644
--- a/e2e/testdata/porch/rpkg-copy/config.yaml
+++ b/e2e/testdata/porch/rpkg-copy/config.yaml
@@ -5,7 +5,7 @@ commands:
       - register
       - --namespace=rpkg-copy
       - --name=git
-      - http://git-server.test-git-namespace.svc.cluster.local:8080
+      - http://git-server.test-git-namespace.svc.cluster.local:8080/rpkg-copy
   - args:
       - alpha
       - rpkg
diff --git a/e2e/testdata/porch/rpkg-init-deploy/config.yaml b/e2e/testdata/porch/rpkg-init-deploy/config.yaml
index f7fa49a1..12b4952a 100644
--- a/e2e/testdata/porch/rpkg-init-deploy/config.yaml
+++ b/e2e/testdata/porch/rpkg-init-deploy/config.yaml
@@ -6,7 +6,7 @@ commands:
       - --namespace=rpkg-init-deploy
       - --name=git
       - --deployment
-      - http://git-server.test-git-namespace.svc.cluster.local:8080
+      - http://git-server.test-git-namespace.svc.cluster.local:8080/rpkg-init-deploy
   - args:
       - alpha
       - repo
@@ -76,4 +76,4 @@ commands:
       - --namespace=rpkg-init-deploy
     stdout: |
       NAME   TYPE   CONTENT   DEPLOYMENT   READY   ADDRESS
-      git    git    Package   true         True    http://git-server.test-git-namespace.svc.cluster.local:8080
+      git    git    Package   true         True    http://git-server.test-git-namespace.svc.cluster.local:8080/rpkg-init-deploy
diff --git a/e2e/testdata/porch/rpkg-init/config.yaml b/e2e/testdata/porch/rpkg-init/config.yaml
index 3dd5b01d..452b6c46 100644
--- a/e2e/testdata/porch/rpkg-init/config.yaml
+++ b/e2e/testdata/porch/rpkg-init/config.yaml
@@ -5,7 +5,7 @@ commands:
       - register
       - --namespace=rpkg-init
       - --name=git
-      - http://git-server.test-git-namespace.svc.cluster.local:8080
+      - http://git-server.test-git-namespace.svc.cluster.local:8080/rpkg-init
   - args:
       - alpha
       - repo
@@ -13,8 +13,8 @@ commands:
       - --namespace=rpkg-init
       - --output=custom-columns=NAME:.metadata.name,ADDRESS:.spec.git.repo,BRANCH:.spec.git.branch,DIR:.spec.git.directory
     stdout: |
-      NAME   ADDRESS                                                       BRANCH   DIR
-      git    http://git-server.test-git-namespace.svc.cluster.local:8080   main     /
+      NAME   ADDRESS                                                                 BRANCH   DIR
+      git    http://git-server.test-git-namespace.svc.cluster.local:8080/rpkg-init   main     /
   - args:
       - alpha
       - rpkg
diff --git a/e2e/testdata/porch/rpkg-lifecycle/config.yaml b/e2e/testdata/porch/rpkg-lifecycle/config.yaml
index 1426c4fe..a4b97b0c 100644
--- a/e2e/testdata/porch/rpkg-lifecycle/config.yaml
+++ b/e2e/testdata/porch/rpkg-lifecycle/config.yaml
@@ -5,7 +5,7 @@ commands:
       - register
       - --namespace=rpkg-lifecycle
       - --name=git
-      - http://git-server.test-git-namespace.svc.cluster.local:8080
+      - http://git-server.test-git-namespace.svc.cluster.local:8080/rpkg-lifecycle
   - args:
       - alpha
       - rpkg
diff --git a/e2e/testdata/porch/rpkg-push/config.yaml b/e2e/testdata/porch/rpkg-push/config.yaml
index a0d593fd..cb823ae9 100644
--- a/e2e/testdata/porch/rpkg-push/config.yaml
+++ b/e2e/testdata/porch/rpkg-push/config.yaml
@@ -5,7 +5,7 @@ commands:
       - register
       - --namespace=rpkg-push
       - --name=git
-      - http://git-server.test-git-namespace.svc.cluster.local:8080
+      - http://git-server.test-git-namespace.svc.cluster.local:8080/rpkg-push
   - args:
       - alpha
       - rpkg
diff --git a/e2e/testdata/porch/rpkg-update/config.yaml b/e2e/testdata/porch/rpkg-update/config.yaml
index 12487445..b5a39ff5 100644
--- a/e2e/testdata/porch/rpkg-update/config.yaml
+++ b/e2e/testdata/porch/rpkg-update/config.yaml
@@ -5,7 +5,7 @@ commands:
       - register
       - --namespace=rpkg-update
       - --name=git
-      - http://git-server.test-git-namespace.svc.cluster.local:8080
+      - http://git-server.test-git-namespace.svc.cluster.local:8080/rpkg-update
   - args:
       - alpha
       - rpkg
diff --git a/pkg/test/porch/cluster.go b/pkg/test/porch/cluster.go
index 6c664df7..4b2db8c7 100644
--- a/pkg/test/porch/cluster.go
+++ b/pkg/test/porch/cluster.go
@@ -115,13 +115,17 @@ func KubectlWaitForService(t *testing.T, namespace, name string) {
 
 // Kubernetes DNS needs time to propagate the updated address
 // Wait until we can register the repository and list its contents.
-func KubectlWaitForGitDNS(t *testing.T, address string) {
+func KubectlWaitForGitDNS(t *testing.T, gitServerURL string) {
 	const name = "test-git-dns-resolve"
 
 	KubectlCreateNamespace(t, name)
 	defer KubectlDeleteNamespace(t, name)
 
-	cmd := exec.Command("kpt", "alpha", "repo", "register", "--namespace", name, "--name", name, address)
+	// We expect repos to automatically be created (albeit empty)
+	repoURL := gitServerURL + "/" + name
+
+	cmd := exec.Command("kpt", "alpha", "repo", "register", "--namespace", name, "--name", name, repoURL)
+	t.Logf("running command %v", strings.Join(cmd.Args, " "))
 	out, err := cmd.CombinedOutput()
 	if err != nil {
 		t.Fatalf("Failed to register probe repository: %v\n%s", err, string(out))
@@ -134,8 +138,9 @@ func KubectlWaitForGitDNS(t *testing.T, address string) {
 	giveUp := time.Now().Add(1 * time.Minute)
 	for {
 		cmd := exec.Command("kpt", "alpha", "rpkg", "get", "--namespace", name)
+		t.Logf("running command %v", strings.Join(cmd.Args, " "))
 		out, err := cmd.CombinedOutput()
-		t.Log(string(out))
+		t.Logf("output: %v", string(out))
 
 		if err == nil {
 			break
@@ -151,27 +156,30 @@ func KubectlWaitForGitDNS(t *testing.T, address string) {
 
 func KubectlCreateNamespace(t *testing.T, name string) {
 	cmd := exec.Command("kubectl", "create", "namespace", name)
+	t.Logf("running command %v", strings.Join(cmd.Args, " "))
 	out, err := cmd.CombinedOutput()
 	if err != nil {
 		t.Fatalf("Failed to create namespace %q: %v\n%s", name, err, string(out))
 	}
-	t.Log(string(out))
+	t.Logf("output: %v", string(out))
 }
 
 func KubectlDeleteNamespace(t *testing.T, name string) {
 	cmd := exec.Command("kubectl", "delete", "namespace", name)
+	t.Logf("running command %v", strings.Join(cmd.Args, " "))
 	out, err := cmd.CombinedOutput()
 	if err != nil {
 		t.Logf("Failed to delete namespace %q: %v\n%s", name, err, string(out))
 	}
-	t.Log(string(out))
+	t.Logf("output: %v", string(out))
 }
 
-func RegisterRepository(t *testing.T, address, namespace, name string) {
-	cmd := exec.Command("kpt", "alpha", "repo", "register", "--namespace", namespace, "--name", name, address)
+func RegisterRepository(t *testing.T, repoURL, namespace, name string) {
+	cmd := exec.Command("kpt", "alpha", "repo", "register", "--namespace", namespace, "--name", name, repoURL)
+	t.Logf("running command %v", strings.Join(cmd.Args, " "))
 	out, err := cmd.CombinedOutput()
 	if err != nil {
-		t.Fatalf("Failed to register repository %q: %v\n%s", address, err, string(out))
+		t.Fatalf("Failed to register repository %q: %v\n%s", repoURL, err, string(out))
 	}
-	t.Log(string(out))
+	t.Logf("output: %v", string(out))
 }
diff --git a/porch/pkg/engine/clone_test.go b/porch/pkg/engine/clone_test.go
index db5678c2..b0a62d8d 100644
--- a/porch/pkg/engine/clone_test.go
+++ b/porch/pkg/engine/clone_test.go
@@ -115,8 +115,14 @@ func createRepoWithContents(t *testing.T, contentDir string) *gogit.Repository {
 	return repo
 }
 
-func startGitServer(t *testing.T, repo *gogit.Repository, opts ...git.GitServerOption) string {
-	server, err := git.NewGitServer(repo, opts...)
+func startGitServer(t *testing.T, repo *git.Repo, opts ...git.GitServerOption) string {
+	key := "default"
+	repos := git.NewStaticRepos()
+	if err := repos.Add(key, repo); err != nil {
+		t.Fatalf("repos.Add failed: %v", err)
+	}
+
+	server, err := git.NewGitServer(repos)
 	if err != nil {
 		t.Fatalf("Failed to create git server: %v", err)
 	}
@@ -150,7 +156,7 @@ func startGitServer(t *testing.T, repo *gogit.Repository, opts ...git.GitServerO
 		return ""
 	}
 
-	return fmt.Sprintf("http://%s", address)
+	return fmt.Sprintf("http://%s/%s", address, key)
 }
 
 // TODO(mortent): See if we can restruture the packages to
@@ -205,8 +211,14 @@ func TestCloneGitBasicAuth(t *testing.T) {
 	}
 
 	auth := randomCredentials()
-	repo := createRepoWithContents(t, testdata)
-	addr := startGitServer(t, repo, git.WithBasicAuth(auth.username, auth.password))
+	gogitRepo := createRepoWithContents(t, testdata)
+
+	repo, err := git.NewRepo(gogitRepo, git.WithBasicAuth(auth.username, auth.password))
+	if err != nil {
+		t.Fatalf("NewRepo failed: %v", err)
+	}
+
+	addr := startGitServer(t, repo)
 
 	cpm := clonePackageMutation{
 		task: &v1alpha1.Task{
diff --git a/porch/pkg/engine/render_test.go b/porch/pkg/engine/render_test.go
index d891de34..7a62f009 100644
--- a/porch/pkg/engine/render_test.go
+++ b/porch/pkg/engine/render_test.go
@@ -20,6 +20,7 @@ import (
 	"path/filepath"
 	"testing"
 
+	"github.com/GoogleContainerTools/kpt/internal/fnruntime"
 	v1 "github.com/GoogleContainerTools/kpt/pkg/api/kptfile/v1"
 	"github.com/GoogleContainerTools/kpt/porch/pkg/kpt"
 	"github.com/GoogleContainerTools/kpt/porch/pkg/repository"
@@ -29,8 +30,11 @@ import (
 )
 
 func TestRender(t *testing.T) {
+	runnerOptions := fnruntime.RunnerOptions{}
+	runnerOptions.InitDefaults()
+
 	render := &renderPackageMutation{
-		renderer: kpt.NewRenderer(),
+		renderer: kpt.NewRenderer(runnerOptions),
 		runtime:  kpt.NewSimpleFunctionRuntime(),
 	}
 
diff --git a/porch/pkg/git/testing.go b/porch/pkg/git/testing.go
index 73338bb0..0e0b1ab2 100644
--- a/porch/pkg/git/testing.go
+++ b/porch/pkg/git/testing.go
@@ -27,7 +27,6 @@ import (
 	"strings"
 	"time"
 
-	gogit "github.com/go-git/go-git/v5"
 	"github.com/go-git/go-git/v5/plumbing"
 	"github.com/go-git/go-git/v5/plumbing/filemode"
 	"github.com/go-git/go-git/v5/plumbing/format/packfile"
@@ -40,21 +39,19 @@ import (
 
 // GitServer is a mock git server implementing "just enough" of the git protocol
 type GitServer struct {
-	repo *gogit.Repository
-
-	// Basic auth
-	username string
-	password string
+	repos Repos
 }
 
 // NewGitServer constructs a GitServer backed by the specified repo.
-func NewGitServer(repo *gogit.Repository, opts ...GitServerOption) (*GitServer, error) {
+func NewGitServer(repos Repos, opts ...GitServerOption) (*GitServer, error) {
 	gs := &GitServer{
-		repo: repo,
+		repos: repos,
 	}
 
 	for _, opt := range opts {
-		opt.apply(gs)
+		if err := opt.apply(gs); err != nil {
+			return nil, err
+		}
 	}
 
 	return gs, nil
@@ -106,23 +103,40 @@ func (s *GitServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 
 // serveRequest is the main dispatcher for http requests.
 func (s *GitServer) serveRequest(w http.ResponseWriter, r *http.Request) error {
-	if s.username != "" || s.password != "" {
-		username, password, ok := r.BasicAuth()
-		if !ok || username != s.username || password != s.password {
-			http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+	pathTokens := strings.Split(strings.TrimPrefix(r.URL.Path, "/"), "/")
+	if len(pathTokens) > 1 {
+		repoID := pathTokens[0]
+		repo, err := s.repos.FindRepo(r.Context(), repoID)
+		if err != nil {
+			klog.Warningf("500 for %s %s: %v", r.Method, r.URL, err)
+			http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+			return nil
+
+		}
+		if repo == nil {
+			// TODO: Should we send something consistent with auth failure?
+			klog.Warningf("404 for %s %s (repo not found)", r.Method, r.URL)
+			http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
 			return nil
 		}
-	}
 
-	path := r.URL.Path
-	if path == "/info/refs" {
-		return s.serveGitInfoRefs(w, r)
-	}
-	if path == "/git-upload-pack" {
-		return s.serveGitUploadPack(w, r)
-	}
-	if path == "/git-receive-pack" {
-		return s.serveGitReceivePack(w, r)
+		if repo.username != "" || repo.password != "" {
+			username, password, ok := r.BasicAuth()
+			if !ok || username != repo.username || password != repo.password {
+				http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+				return nil
+			}
+		}
+		gitPath := strings.Join(pathTokens[1:], "/")
+		if gitPath == "info/refs" {
+			return s.serveGitInfoRefs(w, r, repo)
+		}
+		if gitPath == "git-upload-pack" {
+			return s.serveGitUploadPack(w, r, repo)
+		}
+		if gitPath == "git-receive-pack" {
+			return s.serveGitReceivePack(w, r, repo)
+		}
 	}
 
 	klog.Warningf("404 for %s %s", r.Method, r.URL)
@@ -131,7 +145,7 @@ func (s *GitServer) serveRequest(w http.ResponseWriter, r *http.Request) error {
 }
 
 // serveGitInfoRefs serves the info/refs (discovery) endpoint
-func (s *GitServer) serveGitInfoRefs(w http.ResponseWriter, r *http.Request) error {
+func (s *GitServer) serveGitInfoRefs(w http.ResponseWriter, r *http.Request, repo *Repo) error {
 	query := r.URL.Query()
 	serviceName := query.Get("service")
 
@@ -150,7 +164,7 @@ func (s *GitServer) serveGitInfoRefs(w http.ResponseWriter, r *http.Request) err
 	}
 
 	// We send an advertisement for each of our references
-	it, err := s.repo.References()
+	it, err := repo.gogit.References()
 	if err != nil {
 		return fmt.Errorf("failed to get git references: %w", err)
 	}
@@ -163,7 +177,7 @@ func (s *GitServer) serveGitInfoRefs(w http.ResponseWriter, r *http.Request) err
 		// Resolve symbolic references.
 		switch ref.Type() {
 		case plumbing.SymbolicReference:
-			if r, err := s.repo.Reference(ref.Name(), true); err != nil {
+			if r, err := repo.gogit.Reference(ref.Name(), true); err != nil {
 				klog.Warningf("Skipping unresolvable symbolic reference %q: %v", ref.Name(), err)
 				return nil
 			} else {
@@ -255,7 +269,7 @@ func writeRefs(gw *PacketLineWriter, sorted []*plumbing.Reference, capabilities
 }
 
 // serveGitUploadPack serves the git-upload-pack endpoint
-func (s *GitServer) serveGitUploadPack(w http.ResponseWriter, r *http.Request) error {
+func (s *GitServer) serveGitUploadPack(w http.ResponseWriter, r *http.Request, repo *Repo) error {
 	// See https://git-scm.com/docs/pack-protocol/2.2.3#_packfile_negotiation
 
 	// The client sends a line for each sha it wants and each sha it has
@@ -276,7 +290,7 @@ func (s *GitServer) serveGitUploadPack(w http.ResponseWriter, r *http.Request) e
 	// This works, and is correct on the "clean pull" scenario, but is not efficient in the real world.
 
 	// Gather all the objects
-	walker := newObjectWalker(s.repo.Storer)
+	walker := newObjectWalker(repo.gogit.Storer)
 	if err := walker.walkAllRefs(); err != nil {
 		return fmt.Errorf("error walking refs: %w", err)
 	}
@@ -298,7 +312,7 @@ func (s *GitServer) serveGitUploadPack(w http.ResponseWriter, r *http.Request) e
 	klog.Infof("sending %d objects in packfile", len(objects))
 
 	useRefDeltas := false
-	storer := s.repo.Storer
+	storer := repo.gogit.Storer
 
 	// TODO: Buffer on disk first?
 	packFileEncoder := packfile.NewEncoder(w, storer, useRefDeltas)
@@ -328,7 +342,7 @@ type RefUpdate struct {
 	Ref  string
 }
 
-func (s *GitServer) serveGitReceivePack(w http.ResponseWriter, r *http.Request) error {
+func (s *GitServer) serveGitReceivePack(w http.ResponseWriter, r *http.Request, repo *Repo) error {
 	var refUpdates []RefUpdate
 
 	body := r.Body
@@ -413,7 +427,7 @@ func (s *GitServer) serveGitReceivePack(w http.ResponseWriter, r *http.Request)
 
 	gitWriter := NewPacketLineWriter(w)
 
-	switch err := packfile.UpdateObjectStorage(s.repo.Storer, body); err {
+	switch err := packfile.UpdateObjectStorage(repo.gogit.Storer, body); err {
 	case nil, packfile.ErrEmptyPackfile:
 		// ok
 	default:
@@ -439,11 +453,11 @@ func (s *GitServer) serveGitReceivePack(w http.ResponseWriter, r *http.Request)
 		switch {
 		case refUpdate.To.IsZero():
 			klog.Infof("Deleting reference %s", refUpdate.Ref)
-			s.repo.Storer.RemoveReference(plumbing.ReferenceName(refUpdate.Ref))
+			repo.gogit.Storer.RemoveReference(plumbing.ReferenceName(refUpdate.Ref))
 
 		default:
 			ref := plumbing.NewHashReference(plumbing.ReferenceName(refUpdate.Ref), refUpdate.To)
-			if err := s.repo.Storer.SetReference(ref); err != nil {
+			if err := repo.gogit.Storer.SetReference(ref); err != nil {
 				klog.Warningf("failed to update reference %v: %v", refUpdate, err)
 			} else {
 				klog.Warningf("updated reference %v -> %v", refUpdate.Ref, refUpdate.To)
@@ -630,20 +644,5 @@ func (w *PacketLineWriter) WriteZeroPacketLine() {
 // Options
 
 type GitServerOption interface {
-	apply(*GitServer)
-}
-
-type optionBasicAuth struct {
-	username, password string
-}
-
-func (o *optionBasicAuth) apply(s *GitServer) {
-	s.username, s.password = o.username, o.password
-}
-
-func WithBasicAuth(username, password string) GitServerOption {
-	return &optionBasicAuth{
-		username: username,
-		password: password,
-	}
+	apply(*GitServer) error
 }
diff --git a/porch/pkg/git/testing_repo.go b/porch/pkg/git/testing_repo.go
index bf2338ff..20341438 100644
--- a/porch/pkg/git/testing_repo.go
+++ b/porch/pkg/git/testing_repo.go
@@ -124,7 +124,19 @@ func ServeGitRepository(t *testing.T, tarfile, tempdir string) (*gogit.Repositor
 func ServeExistingRepository(t *testing.T, git *gogit.Repository) string {
 	t.Helper()
 
-	server, err := NewGitServer(git)
+	repo, err := NewRepo(git)
+	if err != nil {
+		t.Fatalf("NewRepo failed: %v", err)
+	}
+
+	key := "default"
+
+	repos := NewStaticRepos()
+	if err := repos.Add(key, repo); err != nil {
+		t.Fatalf("repos.Add failed: %v", err)
+	}
+
+	server, err := NewGitServer(repos)
 	if err != nil {
 		t.Fatalf("NewGitServer() failed: %v", err)
 	}
@@ -152,7 +164,7 @@ func ServeExistingRepository(t *testing.T, git *gogit.Repository) string {
 	if !ok {
 		t.Fatalf("Git Server failed to start")
 	}
-	return "http://" + address.String()
+	return "http://" + address.String() + "/" + key
 }
 
 func extractTar(t *testing.T, tarfile string, dir string) {
diff --git a/porch/pkg/kpt/fs_test.go b/porch/pkg/kpt/fs_test.go
index e921ff3f..e5760619 100644
--- a/porch/pkg/kpt/fs_test.go
+++ b/porch/pkg/kpt/fs_test.go
@@ -102,11 +102,12 @@ spec:
 		t.Errorf("Failed to write file: %v", err)
 	}
 	r := render.Renderer{
-		PkgPath:         "/a/b/c",
-		ImagePullPolicy: fnruntime.IfNotPresentPull,
-		FileSystem:      fs,
-		Runtime:         &runtime{},
+		PkgPath:    "/a/b/c",
+		FileSystem: fs,
+		Runtime:    &runtime{},
 	}
+	r.RunnerOptions.InitDefaults()
+	r.RunnerOptions.ImagePullPolicy = fnruntime.IfNotPresentPull
 	err := r.Execute(fake.CtxWithDefaultPrinter())
 	if err != nil {
 		t.Errorf("Failed to render: %v", err)
@@ -215,11 +216,13 @@ spec:
 	}
 
 	r := render.Renderer{
-		PkgPath:         "/app",
-		ImagePullPolicy: fnruntime.IfNotPresentPull,
-		FileSystem:      fs,
-		Runtime:         &runtime{},
+		PkgPath:    "/app",
+		FileSystem: fs,
+		Runtime:    &runtime{},
 	}
+	r.RunnerOptions.InitDefaults()
+	r.RunnerOptions.ImagePullPolicy = fnruntime.IfNotPresentPull
+
 	err := r.Execute(fake.CtxWithDefaultPrinter())
 	if err != nil {
 		t.Errorf("Failed to render: %v", err)
diff --git a/porch/pkg/kpt/render_test.go b/porch/pkg/kpt/render_test.go
index 2178b529..273a45f7 100644
--- a/porch/pkg/kpt/render_test.go
+++ b/porch/pkg/kpt/render_test.go
@@ -65,6 +65,7 @@ func TestRender(t *testing.T) {
 				FileSystem: filesys.FileSystemOrOnDisk{},
 				Output:     &output,
 			}
+			r.RunnerOptions.InitDefaults()
 
 			if err := r.Execute(fake.CtxWithDefaultPrinter()); err != nil {
 				t.Errorf("Render failed: %v", err)
diff --git a/porch/test/e2e/e2e_test.go b/porch/test/e2e/e2e_test.go
index bb82b6ba..83173a64 100644
--- a/porch/test/e2e/e2e_test.go
+++ b/porch/test/e2e/e2e_test.go
@@ -74,14 +74,21 @@ func Run(suite interface{}, t *testing.T) {
 
 type PorchSuite struct {
 	TestSuite
-	config GitConfig
+
+	gitConfig GitConfig
 }
 
 var _ Initializer = &PorchSuite{}
 
 func (p *PorchSuite) Initialize(ctx context.Context) {
 	p.TestSuite.Initialize(ctx)
-	p.config = p.CreateGitRepo()
+	p.gitConfig = p.CreateGitRepo()
+}
+
+func (p *PorchSuite) GitConfig(repoID string) GitConfig {
+	config := p.gitConfig
+	config.Repo = config.Repo + "/" + repoID
+	return config
 }
 
 func (t *PorchSuite) TestGitRepository(ctx context.Context) {
@@ -1497,7 +1504,8 @@ func (t *PorchSuite) registerGitRepositoryF(ctx context.Context, repo, name, dir
 type repositoryOption func(*configapi.Repository)
 
 func (t *PorchSuite) registerMainGitRepositoryF(ctx context.Context, name string, opts ...repositoryOption) {
-	config := t.config
+	repoID := t.namespace + "-" + name
+	config := t.GitConfig(repoID)
 
 	var secret string
 	// Create auth secret if necessary
diff --git a/porch/test/e2e/suite.go b/porch/test/e2e/suite.go
index ee46498b..fd2efeca 100644
--- a/porch/test/e2e/suite.go
+++ b/porch/test/e2e/suite.go
@@ -176,7 +176,7 @@ func (t *TestSuite) CreateGitRepo() GitConfig {
 	} else {
 		// Deploy Git server via k8s client.
 		t.Logf("not using dev porch; creating git server in cluster")
-		return t.createInClusterGitServer()
+		return t.createInClusterGitServer(context.TODO())
 	}
 }
 
@@ -317,16 +317,10 @@ func createLocalGitServer(t *testing.T) GitConfig {
 		}
 	})
 
-	isBare := true
-	repo, err := gogit.PlainInit(tmp, isBare)
-	if err != nil {
-		t.Fatalf("Failed to initialize Git repository in %q: %v", tmp, err)
-		return GitConfig{}
-	}
-
-	createInitialCommit(t, repo)
+	var gitRepoOptions []git.GitRepoOption
+	repos := git.NewDynamicRepos(tmp, gitRepoOptions)
 
-	server, err := git.NewGitServer(repo)
+	server, err := git.NewGitServer(repos)
 	if err != nil {
 		t.Fatalf("Failed to start git server: %v", err)
 		return GitConfig{}
@@ -412,9 +406,7 @@ func createInitialCommit(t *testing.T, repo *gogit.Repository) {
 	}
 }
 
-func (t *TestSuite) createInClusterGitServer() GitConfig {
-	ctx := context.TODO()
-
+func (t *TestSuite) createInClusterGitServer(ctx context.Context) GitConfig {
 	// Determine git-server image name. Use the same container registry and tag as the Porch server,
 	// replacing base image name with `git-server`. TODO: Make configurable?
 
@@ -528,22 +520,51 @@ func (t *TestSuite) createInClusterGitServer() GitConfig {
 	for {
 		time.Sleep(5 * time.Second)
 
-		var server appsv1.Deployment
+		var deployment appsv1.Deployment
 		t.GetF(ctx, client.ObjectKey{
 			Namespace: t.namespace,
 			Name:      "git-server",
-		}, &server)
-		if server.Status.AvailableReplicas > 0 {
-			t.Logf("git server is up")
+		}, &deployment)
+
+		ready := true
+		if ready && deployment.Generation != deployment.Status.ObservedGeneration {
+			t.Logf("waiting for ObservedGeneration %v to match Generation %v", deployment.Status.ObservedGeneration, deployment.Generation)
+			ready = false
+		}
+		if ready && deployment.Status.UpdatedReplicas < deployment.Status.Replicas {
+			t.Logf("waiting for UpdatedReplicas %d to match Replicas %d", deployment.Status.UpdatedReplicas, deployment.Status.Replicas)
+			ready = false
+		}
+		if ready && deployment.Status.AvailableReplicas < deployment.Status.Replicas {
+			t.Logf("waiting for AvailableReplicas %d to match Replicas %d", deployment.Status.AvailableReplicas, deployment.Status.Replicas)
+			ready = false
+		}
+
+		if ready {
+			ready = false // Until we've seen Available condition
+			for _, condition := range deployment.Status.Conditions {
+				if condition.Type == "Available" {
+					ready = true
+					if condition.Status != "True" {
+						t.Logf("waiting for status.condition %v", condition)
+						ready = false
+					}
+				}
+			}
+		}
+
+		if ready {
 			break
 		}
 
 		if time.Now().After(giveUp) {
-			t.Fatalf("git server failed to start: %s", &server)
+			t.Fatalf("git server failed to start: %s", &deployment)
 			return GitConfig{}
 		}
 	}
 
+	t.Logf("git server is up")
+
 	t.Logf("Waiting for git-server-service to be ready ...")
 
 	// Check the Endpoint resource for readiness
diff --git a/porch/test/git/main.go b/porch/test/git/main.go
index 0234ab42..4de87183 100644
--- a/porch/test/git/main.go
+++ b/porch/test/git/main.go
@@ -22,12 +22,8 @@ import (
 	"net/http"
 	"os"
 	"os/signal"
-	"time"
 
 	"github.com/GoogleContainerTools/kpt/porch/pkg/git"
-	gogit "github.com/go-git/go-git/v5"
-	"github.com/go-git/go-git/v5/plumbing"
-	"github.com/go-git/go-git/v5/plumbing/object"
 	"k8s.io/klog/v2"
 )
 
@@ -46,44 +42,27 @@ func main() {
 }
 
 func run(dirs []string) error {
-	var dir string
+	var baseDir string
 
 	switch len(dirs) {
 	case 0:
 		var err error
-		dir, err = os.MkdirTemp("", "repo-*")
+		baseDir, err = os.MkdirTemp("", "repo-*")
 		if err != nil {
 			return fmt.Errorf("failed to create temporary directory for git repository: %w", err)
 		}
 
 	case 1:
-		dir = dirs[0]
+		baseDir = dirs[0]
 
 	default:
-		return fmt.Errorf("can server only one git repository, not %d", len(dirs))
+		return fmt.Errorf("can serve only one git repository, not %d", len(dirs))
 	}
 
-	var repo *gogit.Repository
-	var err error
+	var gitRepoOptions []git.GitRepoOption
+	repos := git.NewDynamicRepos(baseDir, gitRepoOptions)
 
-	if repo, err = gogit.PlainOpen(dir); err != nil {
-		if err != gogit.ErrRepositoryNotExists {
-			return fmt.Errorf("failed to open git repository %q: %w", dir, err)
-		}
-		isBare := true
-		repo, err = gogit.PlainInit(dir, isBare)
-		if err != nil {
-			return fmt.Errorf("failed to initialize git repository %q: %w", dir, err)
-		}
-		if err := createEmptyCommit(repo); err != nil {
-			return err
-		}
-
-		// Delete go-git default branch
-		_ = repo.Storer.RemoveReference(plumbing.Master)
-	}
-
-	server, err := git.NewGitServer(repo)
+	server, err := git.NewGitServer(repos)
 	if err != nil {
 		return fmt.Errorf("filed to initialize git server: %w", err)
 	}
@@ -109,54 +88,3 @@ func run(dirs []string) error {
 
 	return nil
 }
-
-func createEmptyCommit(repo *gogit.Repository) error {
-	store := repo.Storer
-	// Create first commit using empty tree.
-	emptyTree := object.Tree{}
-	encodedTree := store.NewEncodedObject()
-	if err := emptyTree.Encode(encodedTree); err != nil {
-		return fmt.Errorf("failed to encode initial empty commit tree: %w", err)
-	}
-
-	treeHash, err := store.SetEncodedObject(encodedTree)
-	if err != nil {
-		return fmt.Errorf("failed to create initial empty commit tree: %w", err)
-	}
-
-	sig := object.Signature{
-		Name:  "Git Server",
-		Email: "git-server@kpt.dev",
-		When:  time.Now(),
-	}
-
-	commit := object.Commit{
-		Author:       sig,
-		Committer:    sig,
-		Message:      "Empty Commit",
-		TreeHash:     treeHash,
-		ParentHashes: []plumbing.Hash{}, // No parents
-	}
-
-	encodedCommit := store.NewEncodedObject()
-	if err := commit.Encode(encodedCommit); err != nil {
-		return fmt.Errorf("failed to encode initial empty commit: %w", err)
-	}
-
-	commitHash, err := store.SetEncodedObject(encodedCommit)
-	if err != nil {
-		return fmt.Errorf("failed to create initial empty commit: %w", err)
-	}
-
-	main := plumbing.NewHashReference(plumbing.ReferenceName("refs/heads/main"), commitHash)
-	if err := repo.Storer.SetReference(main); err != nil {
-		return fmt.Errorf("failed to set refs/heads/main to commit sha %s: %w", commitHash, err)
-	}
-
-	head := plumbing.NewSymbolicReference(plumbing.HEAD, "refs/heads/main")
-	if err := repo.Storer.SetReference(head); err != nil {
-		return fmt.Errorf("failed to set HEAD to refs/heads/main: %w", err)
-	}
-
-	return nil
-}
diff --git a/thirdparty/cmdconfig/commands/cmdeval/cmdeval_test.go b/thirdparty/cmdconfig/commands/cmdeval/cmdeval_test.go
index 385420b8..7b87b402 100644
--- a/thirdparty/cmdconfig/commands/cmdeval/cmdeval_test.go
+++ b/thirdparty/cmdconfig/commands/cmdeval/cmdeval_test.go
@@ -216,9 +216,11 @@ apiVersion: v1
 			args: []string{"eval", dir, "--results-dir", "foo/", "--image", "foo:bar", "--", "a=b", "c=d", "e=f"},
 			path: dir,
 			expectedStruct: &runfn.RunFns{
-				Path:                  dir,
-				ResultsDir:            "foo/",
-				ImagePullPolicy:       fnruntime.IfNotPresentPull,
+				Path:       dir,
+				ResultsDir: "foo/",
+				RunnerOptions: fnruntime.RunnerOptions{
+					ImagePullPolicy: fnruntime.IfNotPresentPull,
+				},
 				Env:                   []string{},
 				ContinueOnEmptyResult: true,
 				Ctx:                   context.TODO(),
@@ -256,8 +258,10 @@ apiVersion: v1
 			args: []string{"eval", dir, "--env", "FOO=BAR", "-e", "BAR", "--image", "foo:bar"},
 			path: dir,
 			expectedStruct: &runfn.RunFns{
-				Path:                  dir,
-				ImagePullPolicy:       fnruntime.IfNotPresentPull,
+				Path: dir,
+				RunnerOptions: fnruntime.RunnerOptions{
+					ImagePullPolicy: fnruntime.IfNotPresentPull,
+				},
 				Env:                   []string{"FOO=BAR", "BAR"},
 				ContinueOnEmptyResult: true,
 				Ctx:                   context.TODO(),
@@ -280,9 +284,11 @@ apiVersion: v1
 			args: []string{"eval", dir, "--as-current-user", "--image", "foo:bar"},
 			path: dir,
 			expectedStruct: &runfn.RunFns{
-				Path:                  dir,
-				AsCurrentUser:         true,
-				ImagePullPolicy:       fnruntime.IfNotPresentPull,
+				Path:          dir,
+				AsCurrentUser: true,
+				RunnerOptions: fnruntime.RunnerOptions{
+					ImagePullPolicy: fnruntime.IfNotPresentPull,
+				},
 				Env:                   []string{},
 				ContinueOnEmptyResult: true,
 				Ctx:                   context.TODO(),
@@ -364,46 +370,42 @@ apiVersion: v1
 			}
 
 			// check if Input was set
-			if !assert.Equal(t, tt.input, r.RunFns.Input) {
+			if !assert.Equal(t, tt.input, r.runFns.Input) {
 				t.FailNow()
 			}
 
 			// check if Output was set
-			if !assert.Equal(t, tt.output, r.RunFns.Output) {
+			if !assert.Equal(t, tt.output, r.runFns.Output) {
 				t.FailNow()
 			}
 
 			// check if Path was set
-			if !assert.Equal(t, tt.path, r.RunFns.Path) {
+			if !assert.Equal(t, tt.path, r.runFns.Path) {
 				t.FailNow()
 			}
 
 			// check if Network was set
 			if tt.network {
-				if !assert.Equal(t, tt.network, r.RunFns.Network) {
+				if !assert.Equal(t, tt.network, r.runFns.Network) {
 					t.FailNow()
 				}
 			} else {
-				if !assert.Equal(t, false, r.RunFns.Network) {
+				if !assert.Equal(t, false, r.runFns.Network) {
 					t.FailNow()
 				}
 			}
 
-			if !assert.Equal(t, tt.fnConfigPath, r.RunFns.FnConfigPath) {
-				t.FailNow()
-			}
-
-			if !assert.Equal(t, r.RunFns, r.RunFns) {
+			if !assert.Equal(t, tt.fnConfigPath, r.runFns.FnConfigPath) {
 				t.FailNow()
 			}
 
-			if !assert.Equal(t, toStorageMounts(tt.mount), r.RunFns.StorageMounts) {
+			if !assert.Equal(t, toStorageMounts(tt.mount), r.runFns.StorageMounts) {
 				t.FailNow()
 			}
 
 			// check if function config was set
 			if tt.expectedFnConfig != "" {
-				actual := strings.TrimSpace(r.RunFns.FnConfig.MustString())
+				actual := strings.TrimSpace(r.runFns.FnConfig.MustString())
 				if !assert.Equal(t, strings.TrimSpace(tt.expectedFnConfig), actual) {
 					t.FailNow()
 				}
@@ -411,26 +413,27 @@ apiVersion: v1
 
 			// check if function was set
 			if tt.expectedFn != nil {
-				if !assert.NotNil(t, r.RunFns.Function) {
+				if !assert.NotNil(t, r.runFns.Function) {
 					t.FailNow()
 				}
-				if !assert.EqualValues(t, tt.expectedFn, r.RunFns.Function) {
+				if !assert.EqualValues(t, tt.expectedFn, r.runFns.Function) {
 					t.FailNow()
 				}
 			}
 
 			// check if exec arguments were set
 			if len(tt.expectedExecArgs) != 0 {
-				if !assert.EqualValues(t, tt.expectedExecArgs, r.RunFns.ExecArgs) {
+				if !assert.EqualValues(t, tt.expectedExecArgs, r.runFns.ExecArgs) {
 					t.FailNow()
 				}
 			}
 
 			if tt.expectedStruct != nil {
-				r.RunFns.Function = nil
-				r.RunFns.FnConfig = nil
+				r.runFns.Function = nil
+				r.runFns.FnConfig = nil
+				r.runFns.RunnerOptions.ResolveToImage = nil
 				tt.expectedStruct.FnConfigPath = tt.fnConfigPath
-				if !assert.Equal(t, *tt.expectedStruct, r.RunFns) {
+				if !assert.Equal(t, *tt.expectedStruct, r.runFns) {
 					t.FailNow()
 				}
 			}
@@ -457,7 +460,7 @@ func TestCmd_flagAndArgParsing_Symlink(t *testing.T) {
 	r.Command.SetArgs([]string{"foo", "-i", "bar:v0.1"})
 	err = r.Command.Execute()
 	assert.NoError(t, err)
-	assert.Equal(t, filepath.Join("path", "to", "pkg", "dir"), r.RunFns.Path)
+	assert.Equal(t, filepath.Join("path", "to", "pkg", "dir"), r.runFns.Path)
 }
 
 // NoOpRunE is a noop function to replace the run function of a command.  Useful for testing argument parsing.