Repository: GoogleContainerTools/kpt
Pull request #3472 (2022-08-19 01:21:19)

Provide the option of creating the package branch if it doesn't exist
This adds a `CreateBranch` property on the `GitRepository` object of the `Repository` resource. It allows clients to specify whether porch should create the package branch if it doesn't already exist in the repository.

There will be a separate PR to add a flag for this to the `kpt alpha repo register` command.


Patch:
diff --git a/internal/fnruntime/jsglue.go b/internal/fnruntime/jsglue.go
index 272d370a..00ab772f 100644
--- a/internal/fnruntime/jsglue.go
+++ b/internal/fnruntime/jsglue.go
@@ -14,6 +14,9 @@
 
 package fnruntime
 
+// The glue code below has been tested with node.js v18.6.0.
+// It may need some small changes to work with v14 and v16.
+
 var glueCode = `// The code below run the wasm function.
 const go = new Go();
 // The path to the wasm artifact is provided by env var to enable the reusibility of this script.
diff --git a/internal/fnruntime/nodejs.go b/internal/fnruntime/nodejs.go
index 8555dc6f..f67ffedf 100644
--- a/internal/fnruntime/nodejs.go
+++ b/internal/fnruntime/nodejs.go
@@ -34,6 +34,10 @@ type WasmNodejsFn struct {
 
 func NewNodejsFn(loader WasmLoader) (*WasmNodejsFn, error) {
 	cacheDir := filepath.Join(os.TempDir(), "kpt-wasm-fn")
+	err := os.MkdirAll(cacheDir, 0755)
+	if err != nil {
+		return nil, fmt.Errorf("unable to create cache dir: %w", err)
+	}
 	tempDir, err := os.MkdirTemp(cacheDir, "nodejs-")
 	if err != nil {
 		return nil, fmt.Errorf("unable to create temp dir: %w", err)
diff --git a/main.go b/main.go
index 732cf8a9..2329180b 100644
--- a/main.go
+++ b/main.go
@@ -25,7 +25,6 @@ package main
 
 import (
 	"context"
-	"flag"
 	"fmt"
 	"os"
 
@@ -49,22 +48,15 @@ func main() {
 // runMain does the initial setup in order to run kpt. The return value from
 // this function will be the exit code when kpt terminates.
 func runMain() int {
-	var logFlags flag.FlagSet
 	var err error
 
 	ctx := context.Background()
 
-	cmd := run.GetMain(ctx)
-
 	// Enable commandline flags for klog.
 	// logging will help in collecting debugging information from users
-	// Note(droot): There are too many flags exposed that makes the command
-	// usage verbose but couldn't find a way to make it less verbose.
-	klog.InitFlags(&logFlags)
-	// By default klog v1 logs to stderr, switch that off
-	_ = logFlags.Set("logtostderr", "false")
-	_ = logFlags.Set("alsologtostderr", "true")
-	cmd.Flags().AddGoFlagSet(&logFlags)
+	klog.InitFlags(nil)
+
+	cmd := run.GetMain(ctx)
 
 	err = cli.RunNoErrOutput(cmd)
 	if err != nil {
diff --git a/porch/Makefile b/porch/Makefile
index ec23b981..3ad8ea7f 100644
--- a/porch/Makefile
+++ b/porch/Makefile
@@ -12,6 +12,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+MYGOBIN := $(shell go env GOPATH)/bin
 KUBECONFIG=$(CURDIR)/deployments/local/kubeconfig
 BUILDDIR=$(CURDIR)/.build
 CACHEDIR=$(CURDIR)/.cache
@@ -45,6 +46,9 @@ PORCH_CONTROLLERS_IMAGE ?= porch-controllers
 PORCH_WRAPPER_SERVER_IMAGE ?= porch-wrapper-server
 TEST_GIT_SERVER_IMAGE ?= test-git-server
 
+"$(MYGOBIN)/stringer":
+	go install golang.org/x/tools/cmd/stringer@v0.1.12
+
 .PHONY: all
 all: stop network start-etcd start-kube-apiserver start-function-runner run-local
 
@@ -109,7 +113,7 @@ start-function-runner:
 	  -disable-runtimes pod
 
 .PHONY: generate
-generate:
+generate: "$(MYGOBIN)/stringer"
 	@for f in $(MODULES); do (cd $$f; echo "Generating $$f"; go generate -v ./...) || exit 1; done
 
 .PHONY: tidy
diff --git a/porch/api/generated/clientset/versioned/clientset.go b/porch/api/generated/clientset/versioned/clientset.go
index 2820ec42..b0756b46 100644
--- a/porch/api/generated/clientset/versioned/clientset.go
+++ b/porch/api/generated/clientset/versioned/clientset.go
@@ -59,10 +59,6 @@ func (c *Clientset) Discovery() discovery.DiscoveryInterface {
 func NewForConfig(c *rest.Config) (*Clientset, error) {
 	configShallowCopy := *c
 
-	if configShallowCopy.UserAgent == "" {
-		configShallowCopy.UserAgent = rest.DefaultKubernetesUserAgent()
-	}
-
 	// share the transport between all clients
 	httpClient, err := rest.HTTPClientFor(&configShallowCopy)
 	if err != nil {
diff --git a/porch/api/generated/clientset/versioned/fake/register.go b/porch/api/generated/clientset/versioned/fake/register.go
index 2b1d2df7..36b9da3c 100644
--- a/porch/api/generated/clientset/versioned/fake/register.go
+++ b/porch/api/generated/clientset/versioned/fake/register.go
@@ -35,14 +35,14 @@ var localSchemeBuilder = runtime.SchemeBuilder{
 // AddToScheme adds all types of this clientset into the given scheme. This allows composition
 // of clientsets, like in:
 //
-//	import (
-//	  "k8s.io/client-go/kubernetes"
-//	  clientsetscheme "k8s.io/client-go/kubernetes/scheme"
-//	  aggregatorclientsetscheme "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset/scheme"
-//	)
+//   import (
+//     "k8s.io/client-go/kubernetes"
+//     clientsetscheme "k8s.io/client-go/kubernetes/scheme"
+//     aggregatorclientsetscheme "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset/scheme"
+//   )
 //
-//	kclientset, _ := kubernetes.NewForConfig(c)
-//	_ = aggregatorclientsetscheme.AddToScheme(clientsetscheme.Scheme)
+//   kclientset, _ := kubernetes.NewForConfig(c)
+//   _ = aggregatorclientsetscheme.AddToScheme(clientsetscheme.Scheme)
 //
 // After this, RawExtensions in Kubernetes types will serialize kube-aggregator types
 // correctly.
diff --git a/porch/api/generated/clientset/versioned/scheme/register.go b/porch/api/generated/clientset/versioned/scheme/register.go
index 90fc1da2..cee00d25 100644
--- a/porch/api/generated/clientset/versioned/scheme/register.go
+++ b/porch/api/generated/clientset/versioned/scheme/register.go
@@ -35,14 +35,14 @@ var localSchemeBuilder = runtime.SchemeBuilder{
 // AddToScheme adds all types of this clientset into the given scheme. This allows composition
 // of clientsets, like in:
 //
-//	import (
-//	  "k8s.io/client-go/kubernetes"
-//	  clientsetscheme "k8s.io/client-go/kubernetes/scheme"
-//	  aggregatorclientsetscheme "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset/scheme"
-//	)
+//   import (
+//     "k8s.io/client-go/kubernetes"
+//     clientsetscheme "k8s.io/client-go/kubernetes/scheme"
+//     aggregatorclientsetscheme "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset/scheme"
+//   )
 //
-//	kclientset, _ := kubernetes.NewForConfig(c)
-//	_ = aggregatorclientsetscheme.AddToScheme(clientsetscheme.Scheme)
+//   kclientset, _ := kubernetes.NewForConfig(c)
+//   _ = aggregatorclientsetscheme.AddToScheme(clientsetscheme.Scheme)
 //
 // After this, RawExtensions in Kubernetes types will serialize kube-aggregator types
 // correctly.
diff --git a/porch/api/generated/openapi/zz_generated.openapi.go b/porch/api/generated/openapi/zz_generated.openapi.go
index 6d266578..861aee2f 100644
--- a/porch/api/generated/openapi/zz_generated.openapi.go
+++ b/porch/api/generated/openapi/zz_generated.openapi.go
@@ -577,7 +577,7 @@ func schema_porch_api_porch_v1alpha1_PackageCloneTaskSpec(ref common.ReferenceCa
 					},
 					"strategy": {
 						SchemaProps: spec.SchemaProps{
-							Description: "\tDefines which strategy should be used to update the package. It defaults to 'resource-merge'.\n * resource-merge: Perform a structural comparison of the original /\n   updated resources, and merge the changes into the local package.\n * fast-forward: Fail without updating if the local package was modified\n   since it was fetched.\n * force-delete-replace: Wipe all the local changes to the package and replace\n   it with the remote version.",
+							Description: "\n\tDefines which strategy should be used to update the package. It defaults to 'resource-merge'.\n * resource-merge: Perform a structural comparison of the original /\n   updated resources, and merge the changes into the local package.\n * fast-forward: Fail without updating if the local package was modified\n   since it was fetched.\n * force-delete-replace: Wipe all the local changes to the package and replace\n   it with the remote version.",
 							Type:        []string{"string"},
 							Format:      "",
 						},
@@ -1808,7 +1808,7 @@ func schema_pkg_apis_meta_v1_CreateOptions(ref common.ReferenceCallback) common.
 					},
 					"fieldValidation": {
 						SchemaProps: spec.SchemaProps{
-							Description: "fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields, provided that the `ServerSideFieldValidation` feature gate is also enabled. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23 and is the default behavior when the `ServerSideFieldValidation` feature gate is disabled. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default when the `ServerSideFieldValidation` feature gate is enabled. - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered.",
+							Description: "fieldValidation determines how the server should respond to unknown/duplicate fields in the object in the request. Introduced as alpha in 1.23, older servers or servers with the `ServerSideFieldValidation` feature disabled will discard valid values specified in  this param and not perform any server side field validation. Valid values are: - Ignore: ignores unknown/duplicate fields. - Warn: responds with a warning for each unknown/duplicate field, but successfully serves the request. - Strict: fails the request on unknown/duplicate fields.",
 							Type:        []string{"string"},
 							Format:      "",
 						},
@@ -2321,7 +2321,7 @@ func schema_pkg_apis_meta_v1_ListMeta(ref common.ReferenceCallback) common.OpenA
 				Properties: map[string]spec.Schema{
 					"selfLink": {
 						SchemaProps: spec.SchemaProps{
-							Description: "Deprecated: selfLink is a legacy read-only field that is no longer populated by the system.",
+							Description: "selfLink is a URL representing this object. Populated by the system. Read-only.\n\nDEPRECATED Kubernetes will stop propagating this field in 1.20 release and the field is planned to be removed in 1.21 release.",
 							Type:        []string{"string"},
 							Format:      "",
 						},
@@ -2473,7 +2473,7 @@ func schema_pkg_apis_meta_v1_ManagedFieldsEntry(ref common.ReferenceCallback) co
 					},
 					"time": {
 						SchemaProps: spec.SchemaProps{
-							Description: "Time is the timestamp of when the ManagedFields entry was added. The timestamp will also be updated if a field is added, the manager changes any of the owned fields value or removes a field. The timestamp does not update when a field is removed from the entry because another manager took it over.",
+							Description: "Time is timestamp of when these fields were set. It should always be empty if Operation is 'Apply'",
 							Ref:         ref("k8s.io/apimachinery/pkg/apis/meta/v1.Time"),
 						},
 					},
@@ -2533,7 +2533,7 @@ func schema_pkg_apis_meta_v1_ObjectMeta(ref common.ReferenceCallback) common.Ope
 					},
 					"generateName": {
 						SchemaProps: spec.SchemaProps{
-							Description: "GenerateName is an optional prefix, used by the server, to generate a unique name ONLY IF the Name field has not been provided. If this field is used, the name returned to the client will be different than the name passed. This value will also be combined with a unique suffix. The provided value has the same validation rules as the Name field, and may be truncated by the length of the suffix required to make the value unique on the server.\n\nIf this field is specified and the generated name exists, the server will return a 409.\n\nApplied only if Name is not specified. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#idempotency",
+							Description: "GenerateName is an optional prefix, used by the server, to generate a unique name ONLY IF the Name field has not been provided. If this field is used, the name returned to the client will be different than the name passed. This value will also be combined with a unique suffix. The provided value has the same validation rules as the Name field, and may be truncated by the length of the suffix required to make the value unique on the server.\n\nIf this field is specified and the generated name exists, the server will NOT return a 409 - instead, it will either return 201 Created or 500 with Reason ServerTimeout indicating a unique name could not be found in the time allotted, and the client should retry (optionally after the time indicated in the Retry-After header).\n\nApplied only if Name is not specified. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#idempotency",
 							Type:        []string{"string"},
 							Format:      "",
 						},
@@ -2547,7 +2547,7 @@ func schema_pkg_apis_meta_v1_ObjectMeta(ref common.ReferenceCallback) common.Ope
 					},
 					"selfLink": {
 						SchemaProps: spec.SchemaProps{
-							Description: "Deprecated: selfLink is a legacy read-only field that is no longer populated by the system.",
+							Description: "SelfLink is a URL representing this object. Populated by the system. Read-only.\n\nDEPRECATED Kubernetes will stop propagating this field in 1.20 release and the field is planned to be removed in 1.21 release.",
 							Type:        []string{"string"},
 							Format:      "",
 						},
@@ -2667,7 +2667,7 @@ func schema_pkg_apis_meta_v1_ObjectMeta(ref common.ReferenceCallback) common.Ope
 					},
 					"clusterName": {
 						SchemaProps: spec.SchemaProps{
-							Description: "Deprecated: ClusterName is a legacy field that was always cleared by the system and never used; it will be removed completely in 1.25.\n\nThe name in the go struct is changed to help clients detect accidental use.",
+							Description: "The name of the cluster which the object belongs to. This is used to distinguish resources with same name and namespace in different clusters. This field is not set anywhere right now and apiserver is going to ignore it if set in create or update request.",
 							Type:        []string{"string"},
 							Format:      "",
 						},
@@ -2742,7 +2742,7 @@ func schema_pkg_apis_meta_v1_OwnerReference(ref common.ReferenceCallback) common
 					},
 					"blockOwnerDeletion": {
 						SchemaProps: spec.SchemaProps{
-							Description: "If true, AND if the owner has the \"foregroundDeletion\" finalizer, then the owner cannot be deleted from the key-value store until this reference is removed. See https://kubernetes.io/docs/concepts/architecture/garbage-collection/#foreground-deletion for how the garbage collector interacts with this field and enforces the foreground deletion. Defaults to false. To set this field, a user needs \"delete\" permission of the owner, otherwise 422 (Unprocessable Entity) will be returned.",
+							Description: "If true, AND if the owner has the \"foregroundDeletion\" finalizer, then the owner cannot be deleted from the key-value store until this reference is removed. Defaults to false. To set this field, a user needs \"delete\" permission of the owner, otherwise 422 (Unprocessable Entity) will be returned.",
 							Type:        []string{"boolean"},
 							Format:      "",
 						},
@@ -2909,7 +2909,7 @@ func schema_pkg_apis_meta_v1_PatchOptions(ref common.ReferenceCallback) common.O
 					},
 					"fieldValidation": {
 						SchemaProps: spec.SchemaProps{
-							Description: "fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields, provided that the `ServerSideFieldValidation` feature gate is also enabled. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23 and is the default behavior when the `ServerSideFieldValidation` feature gate is disabled. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default when the `ServerSideFieldValidation` feature gate is enabled. - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered.",
+							Description: "fieldValidation determines how the server should respond to unknown/duplicate fields in the object in the request. Introduced as alpha in 1.23, older servers or servers with the `ServerSideFieldValidation` feature disabled will discard valid values specified in  this param and not perform any server side field validation. Valid values are: - Ignore: ignores unknown/duplicate fields. - Warn: responds with a warning for each unknown/duplicate field, but successfully serves the request. - Strict: fails the request on unknown/duplicate fields.",
 							Type:        []string{"string"},
 							Format:      "",
 						},
@@ -3536,7 +3536,7 @@ func schema_pkg_apis_meta_v1_UpdateOptions(ref common.ReferenceCallback) common.
 					},
 					"fieldValidation": {
 						SchemaProps: spec.SchemaProps{
-							Description: "fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields, provided that the `ServerSideFieldValidation` feature gate is also enabled. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23 and is the default behavior when the `ServerSideFieldValidation` feature gate is disabled. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default when the `ServerSideFieldValidation` feature gate is enabled. - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered.",
+							Description: "fieldValidation determines how the server should respond to unknown/duplicate fields in the object in the request. Introduced as alpha in 1.23, older servers or servers with the `ServerSideFieldValidation` feature disabled will discard valid values specified in  this param and not perform any server side field validation. Valid values are: - Ignore: ignores unknown/duplicate fields. - Warn: responds with a warning for each unknown/duplicate field, but successfully serves the request. - Strict: fails the request on unknown/duplicate fields.",
 							Type:        []string{"string"},
 							Format:      "",
 						},
diff --git a/porch/api/porchconfig/v1alpha1/config.porch.kpt.dev_repositories.yaml b/porch/api/porchconfig/v1alpha1/config.porch.kpt.dev_repositories.yaml
index 1100c2c1..a38433af 100644
--- a/porch/api/porchconfig/v1alpha1/config.porch.kpt.dev_repositories.yaml
+++ b/porch/api/porchconfig/v1alpha1/config.porch.kpt.dev_repositories.yaml
@@ -64,7 +64,7 @@ spec:
             type: object
           spec:
             description: "RepositorySpec defines the desired state of Repository \n
-              Notes: * deployment repository - in KRM API ConfigSync would be configured
+              Notes: - deployment repository - in KRM API ConfigSync would be configured
               directly? (or via this API)"
             properties:
               content:
@@ -84,10 +84,14 @@ spec:
                   Ignored if `type` is not `git`.
                 properties:
                   branch:
-                    description: Name of the branch containig the packages. Finalized
+                    description: Name of the branch containing the packages. Finalized
                       packages will be committed to this branch (if the repository
                       allows write access). If unspecified, defaults to "main".
                     type: string
+                  createBranch:
+                    description: CreateBranch specifies if Porch should create the
+                      package branch if it doesn't exist.
+                    type: boolean
                   directory:
                     description: Directory within the Git repository where the packages
                       are stored. A subdirectory of this directory containing a Kptfile
@@ -177,10 +181,14 @@ spec:
                       Must be unspecified if `type` is not `git`.
                     properties:
                       branch:
-                        description: Name of the branch containig the packages. Finalized
+                        description: Name of the branch containing the packages. Finalized
                           packages will be committed to this branch (if the repository
                           allows write access). If unspecified, defaults to "main".
                         type: string
+                      createBranch:
+                        description: CreateBranch specifies if Porch should create
+                          the package branch if it doesn't exist.
+                        type: boolean
                       directory:
                         description: Directory within the Git repository where the
                           packages are stored. A subdirectory of this directory containing
diff --git a/porch/api/porchconfig/v1alpha1/types.go b/porch/api/porchconfig/v1alpha1/types.go
index 1baeb143..ff560ebd 100644
--- a/porch/api/porchconfig/v1alpha1/types.go
+++ b/porch/api/porchconfig/v1alpha1/types.go
@@ -92,8 +92,10 @@ type GitRepository struct {
 	// Address of the Git repository, for example:
 	//   `https://github.com/GoogleCloudPlatform/blueprints.git`
 	Repo string `json:"repo"`
-	// Name of the branch containig the packages. Finalized packages will be committed to this branch (if the repository allows write access). If unspecified, defaults to "main".
+	// Name of the branch containing the packages. Finalized packages will be committed to this branch (if the repository allows write access). If unspecified, defaults to "main".
 	Branch string `json:"branch,omitempty"`
+	// CreateBranch specifies if Porch should create the package branch if it doesn't exist.
+	CreateBranch bool `json:"createBranch,omitempty"`
 	// Directory within the Git repository where the packages are stored. A subdirectory of this directory containing a Kptfile is considered a package. If unspecified, defaults to root directory.
 	Directory string `json:"directory,omitempty"`
 	// Reference to secret containing authentication credentials.
diff --git a/porch/controllers/config/crd/bases/config.cloud.google.com_remoterootsyncsets.yaml b/porch/controllers/config/crd/bases/config.cloud.google.com_remoterootsyncsets.yaml
index d766ba73..86a6d2f3 100644
--- a/porch/controllers/config/crd/bases/config.cloud.google.com_remoterootsyncsets.yaml
+++ b/porch/controllers/config/crd/bases/config.cloud.google.com_remoterootsyncsets.yaml
@@ -12,6 +12,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+---
 apiVersion: apiextensions.k8s.io/v1
 kind: CustomResourceDefinition
 metadata:
diff --git a/porch/controllers/config/crd/bases/config.cloud.google.com_rootsyncsets.yaml b/porch/controllers/config/crd/bases/config.cloud.google.com_rootsyncsets.yaml
new file mode 100644
index 00000000..657232c1
--- /dev/null
+++ b/porch/controllers/config/crd/bases/config.cloud.google.com_rootsyncsets.yaml
@@ -0,0 +1,192 @@
+# 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.
+
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.8.0
+  creationTimestamp: null
+  name: rootsyncsets.config.cloud.google.com
+spec:
+  group: config.cloud.google.com
+  names:
+    kind: RootSyncSet
+    listKind: RootSyncSetList
+    plural: rootsyncsets
+    singular: rootsyncset
+  scope: Namespaced
+  versions:
+  - name: v1alpha1
+    schema:
+      openAPIV3Schema:
+        description: RootSyncSet is the Schema for the rootsyncsets API
+        properties:
+          apiVersion:
+            description: 'APIVersion defines the versioned schema of this representation
+              of an object. Servers should convert recognized schemas to the latest
+              internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
+            type: string
+          kind:
+            description: 'Kind is a string value representing the REST resource this
+              object represents. Servers may infer this from the endpoint the client
+              submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: RootSyncSetSpec defines the desired state of RootSyncSet
+            properties:
+              clusterRefs:
+                items:
+                  properties:
+                    apiVersion:
+                      type: string
+                    kind:
+                      type: string
+                    name:
+                      type: string
+                    namespace:
+                      type: string
+                  required:
+                  - namespace
+                  type: object
+                type: array
+              template:
+                properties:
+                  spec:
+                    properties:
+                      git:
+                        properties:
+                          auth:
+                            type: string
+                          branch:
+                            type: string
+                          dir:
+                            type: string
+                          gcpServiceAccountEmail:
+                            type: string
+                          noSSLVerify:
+                            type: boolean
+                          period:
+                            type: string
+                          proxy:
+                            type: string
+                          repo:
+                            type: string
+                          revision:
+                            type: string
+                          secretRef:
+                            description: SecretReference contains the reference to
+                              the secret used to connect to Git source of truth.
+                            properties:
+                              name:
+                                description: Name represents the secret name.
+                                type: string
+                            type: object
+                        required:
+                        - auth
+                        - repo
+                        type: object
+                      sourceFormat:
+                        type: string
+                    type: object
+                type: object
+            type: object
+          status:
+            description: RootSyncSetStatus defines the observed state of RootSyncSet
+            properties:
+              conditions:
+                description: Conditions describes the reconciliation state of the
+                  object.
+                items:
+                  description: "Condition contains details for one aspect of the current
+                    state of this API Resource. --- This struct is intended for direct
+                    use as an array at the field path .status.conditions.  For example,
+                    type FooStatus struct{ // Represents the observations of a foo's
+                    current state. // Known .status.conditions.type are: \"Available\",
+                    \"Progressing\", and \"Degraded\" // +patchMergeKey=type // +patchStrategy=merge
+                    // +listType=map // +listMapKey=type Conditions []metav1.Condition
+                    `json:\"conditions,omitempty\" patchStrategy:\"merge\" patchMergeKey:\"type\"
+                    protobuf:\"bytes,1,rep,name=conditions\"` \n // other fields }"
+                  properties:
+                    lastTransitionTime:
+                      description: lastTransitionTime is the last time the condition
+                        transitioned from one status to another. This should be when
+                        the underlying condition changed.  If that is not known, then
+                        using the time when the API field changed is acceptable.
+                      format: date-time
+                      type: string
+                    message:
+                      description: message is a human readable message indicating
+                        details about the transition. This may be an empty string.
+                      maxLength: 32768
+                      type: string
+                    observedGeneration:
+                      description: observedGeneration represents the .metadata.generation
+                        that the condition was set based upon. For instance, if .metadata.generation
+                        is currently 12, but the .status.conditions[x].observedGeneration
+                        is 9, the condition is out of date with respect to the current
+                        state of the instance.
+                      format: int64
+                      minimum: 0
+                      type: integer
+                    reason:
+                      description: reason contains a programmatic identifier indicating
+                        the reason for the condition's last transition. Producers
+                        of specific condition types may define expected values and
+                        meanings for this field, and whether the values are considered
+                        a guaranteed API. The value should be a CamelCase string.
+                        This field may not be empty.
+                      maxLength: 1024
+                      minLength: 1
+                      pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
+                      type: string
+                    status:
+                      description: status of the condition, one of True, False, Unknown.
+                      enum:
+                      - "True"
+                      - "False"
+                      - Unknown
+                      type: string
+                    type:
+                      description: type of condition in CamelCase or in foo.example.com/CamelCase.
+                        --- Many .condition.type values are consistent across resources
+                        like Available, but because arbitrary conditions can be useful
+                        (see .node.status.conditions), the ability to deconflict is
+                        important. The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt)
+                      maxLength: 316
+                      pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
+                      type: string
+                  required:
+                  - lastTransitionTime
+                  - message
+                  - reason
+                  - status
+                  - type
+                  type: object
+                type: array
+            type: object
+        type: object
+    served: true
+    storage: true
+    subresources:
+      status: {}
+status:
+  acceptedNames:
+    kind: ""
+    plural: ""
+  conditions: []
+  storedVersions: []
diff --git a/porch/controllers/config/crd/bases/porch.kpt.dev_workloadidentitybindings.yaml b/porch/controllers/config/crd/bases/porch.kpt.dev_workloadidentitybindings.yaml
index cbf0a0b1..0868e3eb 100644
--- a/porch/controllers/config/crd/bases/porch.kpt.dev_workloadidentitybindings.yaml
+++ b/porch/controllers/config/crd/bases/porch.kpt.dev_workloadidentitybindings.yaml
@@ -12,6 +12,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+---
 apiVersion: apiextensions.k8s.io/v1
 kind: CustomResourceDefinition
 metadata:
diff --git a/porch/controllers/config/rbac/role.yaml b/porch/controllers/config/rbac/role.yaml
index 6157d47a..f7f91cab 100644
--- a/porch/controllers/config/rbac/role.yaml
+++ b/porch/controllers/config/rbac/role.yaml
@@ -12,6 +12,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+---
 apiVersion: rbac.authorization.k8s.io/v1
 kind: ClusterRole
 metadata:
@@ -51,6 +52,32 @@ rules:
   - get
   - patch
   - update
+- apiGroups:
+  - config.cloud.google.com
+  resources:
+  - rootsyncsets
+  verbs:
+  - create
+  - delete
+  - get
+  - list
+  - patch
+  - update
+  - watch
+- apiGroups:
+  - config.cloud.google.com
+  resources:
+  - rootsyncsets/finalizers
+  verbs:
+  - update
+- apiGroups:
+  - config.cloud.google.com
+  resources:
+  - rootsyncsets/status
+  verbs:
+  - get
+  - patch
+  - update
 - apiGroups:
   - coordination.k8s.io
   resources:
diff --git a/porch/controllers/rootsyncset/api/v1alpha1/groupversion_info.go b/porch/controllers/rootsyncset/api/v1alpha1/groupversion_info.go
new file mode 100644
index 00000000..31b116ac
--- /dev/null
+++ b/porch/controllers/rootsyncset/api/v1alpha1/groupversion_info.go
@@ -0,0 +1,36 @@
+// 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 v1alpha1 contains API Schema definitions for the config.cloud.google.com v1alpha1 API group
+//+kubebuilder:object:generate=true
+//+groupName=config.cloud.google.com
+package v1alpha1
+
+import (
+	"k8s.io/apimachinery/pkg/runtime/schema"
+	"sigs.k8s.io/controller-runtime/pkg/scheme"
+)
+
+//go:generate go run sigs.k8s.io/controller-tools/cmd/controller-gen@v0.8.0 object object:headerFile="../../../../scripts/boilerplate.go.txt" paths="./..."
+
+var (
+	// GroupVersion is group version used to register these objects
+	GroupVersion = schema.GroupVersion{Group: "config.cloud.google.com", Version: "v1alpha1"}
+
+	// SchemeBuilder is used to add go types to the GroupVersionKind scheme
+	SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion}
+
+	// AddToScheme adds the types in this group-version to the given scheme.
+	AddToScheme = SchemeBuilder.AddToScheme
+)
diff --git a/porch/controllers/rootsyncset/api/v1alpha1/rootsyncset_types.go b/porch/controllers/rootsyncset/api/v1alpha1/rootsyncset_types.go
new file mode 100644
index 00000000..a67cadcc
--- /dev/null
+++ b/porch/controllers/rootsyncset/api/v1alpha1/rootsyncset_types.go
@@ -0,0 +1,93 @@
+// 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 v1alpha1
+
+import (
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+// RootSyncSetSpec defines the desired state of RootSyncSet
+type RootSyncSetSpec struct {
+	ClusterRefs []*ClusterRef `json:"clusterRefs,omitempty"`
+	Template    *RootSyncInfo `json:"template,omitempty"`
+}
+
+type ClusterRef struct {
+	ApiVersion string `json:"apiVersion,omitempty"`
+	Kind       string `json:"kind,omitempty"`
+	Name       string `json:"name,omitempty"`
+	Namespace  string `json:"namespace,'omitempty'"`
+}
+
+type RootSyncInfo struct {
+	Spec *RootSyncSpec `json:"spec,omitempty"`
+}
+
+type RootSyncSpec struct {
+	SourceFormat string   `json:"sourceFormat,omitempty"`
+	Git          *GitInfo `json:"git,omitempty"`
+}
+
+type GitInfo struct {
+	Repo                   string          `json:"repo"`
+	Branch                 string          `json:"branch,omitempty"`
+	Revision               string          `json:"revision,omitempty"`
+	Dir                    string          `json:"dir,omitempty"`
+	Period                 metav1.Duration `json:"period,omitempty"`
+	Auth                   string          `json:"auth"`
+	GCPServiceAccountEmail string          `json:"gcpServiceAccountEmail,omitempty"`
+	Proxy                  string          `json:"proxy,omitempty"`
+	SecretRef              SecretReference `json:"secretRef,omitempty"`
+	NoSSLVerify            bool            `json:"noSSLVerify,omitempty"`
+}
+
+// SecretReference contains the reference to the secret used to connect to
+// Git source of truth.
+type SecretReference struct {
+	// Name represents the secret name.
+	// +optional
+	Name string `json:"name,omitempty"`
+}
+
+// RootSyncSetStatus defines the observed state of RootSyncSet
+type RootSyncSetStatus struct {
+	// Conditions describes the reconciliation state of the object.
+	Conditions []metav1.Condition `json:"conditions,omitempty"`
+}
+
+//+kubebuilder:object:root=true
+//+kubebuilder:subresource:status
+
+// RootSyncSet is the Schema for the rootsyncsets API
+type RootSyncSet struct {
+	metav1.TypeMeta   `json:",inline"`
+	metav1.ObjectMeta `json:"metadata,omitempty"`
+
+	Spec   RootSyncSetSpec   `json:"spec,omitempty"`
+	Status RootSyncSetStatus `json:"status,omitempty"`
+}
+
+//+kubebuilder:object:root=true
+
+// RootSyncSetList contains a list of RootSyncSet
+type RootSyncSetList struct {
+	metav1.TypeMeta `json:",inline"`
+	metav1.ListMeta `json:"metadata,omitempty"`
+	Items           []RootSyncSet `json:"items"`
+}
+
+func init() {
+	SchemeBuilder.Register(&RootSyncSet{}, &RootSyncSetList{})
+}
diff --git a/porch/controllers/rootsyncset/api/v1alpha1/zz_generated.deepcopy.go b/porch/controllers/rootsyncset/api/v1alpha1/zz_generated.deepcopy.go
new file mode 100644
index 00000000..064f3b42
--- /dev/null
+++ b/porch/controllers/rootsyncset/api/v1alpha1/zz_generated.deepcopy.go
@@ -0,0 +1,224 @@
+//go:build !ignore_autogenerated
+// +build !ignore_autogenerated
+
+// 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.
+
+// Code generated by controller-gen. DO NOT EDIT.
+
+package v1alpha1
+
+import (
+	"k8s.io/apimachinery/pkg/apis/meta/v1"
+	runtime "k8s.io/apimachinery/pkg/runtime"
+)
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ClusterRef) DeepCopyInto(out *ClusterRef) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterRef.
+func (in *ClusterRef) DeepCopy() *ClusterRef {
+	if in == nil {
+		return nil
+	}
+	out := new(ClusterRef)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *GitInfo) DeepCopyInto(out *GitInfo) {
+	*out = *in
+	out.Period = in.Period
+	out.SecretRef = in.SecretRef
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GitInfo.
+func (in *GitInfo) DeepCopy() *GitInfo {
+	if in == nil {
+		return nil
+	}
+	out := new(GitInfo)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *RootSyncInfo) DeepCopyInto(out *RootSyncInfo) {
+	*out = *in
+	if in.Spec != nil {
+		in, out := &in.Spec, &out.Spec
+		*out = new(RootSyncSpec)
+		(*in).DeepCopyInto(*out)
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RootSyncInfo.
+func (in *RootSyncInfo) DeepCopy() *RootSyncInfo {
+	if in == nil {
+		return nil
+	}
+	out := new(RootSyncInfo)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *RootSyncSet) DeepCopyInto(out *RootSyncSet) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+	in.Spec.DeepCopyInto(&out.Spec)
+	in.Status.DeepCopyInto(&out.Status)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RootSyncSet.
+func (in *RootSyncSet) DeepCopy() *RootSyncSet {
+	if in == nil {
+		return nil
+	}
+	out := new(RootSyncSet)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *RootSyncSet) DeepCopyObject() runtime.Object {
+	if c := in.DeepCopy(); c != nil {
+		return c
+	}
+	return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *RootSyncSetList) DeepCopyInto(out *RootSyncSetList) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ListMeta.DeepCopyInto(&out.ListMeta)
+	if in.Items != nil {
+		in, out := &in.Items, &out.Items
+		*out = make([]RootSyncSet, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RootSyncSetList.
+func (in *RootSyncSetList) DeepCopy() *RootSyncSetList {
+	if in == nil {
+		return nil
+	}
+	out := new(RootSyncSetList)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *RootSyncSetList) DeepCopyObject() runtime.Object {
+	if c := in.DeepCopy(); c != nil {
+		return c
+	}
+	return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *RootSyncSetSpec) DeepCopyInto(out *RootSyncSetSpec) {
+	*out = *in
+	if in.ClusterRefs != nil {
+		in, out := &in.ClusterRefs, &out.ClusterRefs
+		*out = make([]*ClusterRef, len(*in))
+		for i := range *in {
+			if (*in)[i] != nil {
+				in, out := &(*in)[i], &(*out)[i]
+				*out = new(ClusterRef)
+				**out = **in
+			}
+		}
+	}
+	if in.Template != nil {
+		in, out := &in.Template, &out.Template
+		*out = new(RootSyncInfo)
+		(*in).DeepCopyInto(*out)
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RootSyncSetSpec.
+func (in *RootSyncSetSpec) DeepCopy() *RootSyncSetSpec {
+	if in == nil {
+		return nil
+	}
+	out := new(RootSyncSetSpec)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *RootSyncSetStatus) DeepCopyInto(out *RootSyncSetStatus) {
+	*out = *in
+	if in.Conditions != nil {
+		in, out := &in.Conditions, &out.Conditions
+		*out = make([]v1.Condition, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RootSyncSetStatus.
+func (in *RootSyncSetStatus) DeepCopy() *RootSyncSetStatus {
+	if in == nil {
+		return nil
+	}
+	out := new(RootSyncSetStatus)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *RootSyncSpec) DeepCopyInto(out *RootSyncSpec) {
+	*out = *in
+	if in.Git != nil {
+		in, out := &in.Git, &out.Git
+		*out = new(GitInfo)
+		**out = **in
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RootSyncSpec.
+func (in *RootSyncSpec) DeepCopy() *RootSyncSpec {
+	if in == nil {
+		return nil
+	}
+	out := new(RootSyncSpec)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *SecretReference) DeepCopyInto(out *SecretReference) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretReference.
+func (in *SecretReference) DeepCopy() *SecretReference {
+	if in == nil {
+		return nil
+	}
+	out := new(SecretReference)
+	in.DeepCopyInto(out)
+	return out
+}
diff --git a/porch/controllers/rootsyncset/pkg/controllers/rootsyncset/controller.go b/porch/controllers/rootsyncset/pkg/controllers/rootsyncset/controller.go
new file mode 100644
index 00000000..572ac49d
--- /dev/null
+++ b/porch/controllers/rootsyncset/pkg/controllers/rootsyncset/controller.go
@@ -0,0 +1,360 @@
+// 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 rootsyncset
+
+import (
+	"context"
+	"encoding/base64"
+	"encoding/json"
+	"fmt"
+
+	container "cloud.google.com/go/container/apiv1"
+	"github.com/GoogleContainerTools/kpt/porch/controllers/rootsyncset/api/v1alpha1"
+	"github.com/GoogleContainerTools/kpt/porch/pkg/googleurl"
+	"golang.org/x/oauth2"
+	"google.golang.org/api/option"
+	containerpb "google.golang.org/genproto/googleapis/container/v1"
+	apierrors "k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/client-go/dynamic"
+	"k8s.io/client-go/rest"
+	"k8s.io/klog/v2"
+	ctrl "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
+)
+
+var (
+	rootSyncNamespace  = "config-management-system"
+	rootSyncApiVersion = "configsync.gke.io/v1beta1"
+	rootSyncName       = "root-sync"
+	rootSyncKind       = "RootSync"
+)
+
+// RootSyncSetReconciler reconciles a RootSyncSet object
+type RootSyncSetReconciler struct {
+	client.Client
+	Scheme *runtime.Scheme
+
+	WorkloadIdentityHelper
+}
+
+//+kubebuilder:rbac:groups=config.cloud.google.com,resources=rootsyncsets,verbs=get;list;watch;create;update;patch;delete
+//+kubebuilder:rbac:groups=config.cloud.google.com,resources=rootsyncsets/status,verbs=get;update;patch
+//+kubebuilder:rbac:groups=config.cloud.google.com,resources=rootsyncsets/finalizers,verbs=update
+
+// Reconcile is part of the main kubernetes reconciliation loop which aims to
+// move the current state of the cluster closer to the desired state.
+// Reconcile function compares the state specified by
+// the RootSyncSet object against the actual cluster state, and then
+// perform operations to make the cluster state reflect the state specified by
+// the user.
+//
+
+// For more details, check Reconcile and its Result here:
+// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.10.0/pkg/reconcile
+func (r *RootSyncSetReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
+	var rootsyncset v1alpha1.RootSyncSet
+	if err := r.Get(ctx, req.NamespacedName, &rootsyncset); err != nil {
+		return ctrl.Result{}, client.IgnoreNotFound(err)
+	}
+	myFinalizerName := "config.cloud.google.com/finalizer"
+	if rootsyncset.ObjectMeta.DeletionTimestamp.IsZero() {
+		// The object is not being deleted, so if it does not have our finalizer,
+		// then lets add the finalizer and update the object. This is equivalent
+		// registering our finalizer.
+		if !controllerutil.ContainsFinalizer(&rootsyncset, myFinalizerName) {
+			controllerutil.AddFinalizer(&rootsyncset, myFinalizerName)
+			if err := r.Update(ctx, &rootsyncset); err != nil {
+				return ctrl.Result{}, fmt.Errorf("error adding finalizer: %w", err)
+			}
+		}
+	} else {
+		// The object is being deleted
+		if controllerutil.ContainsFinalizer(&rootsyncset, myFinalizerName) {
+			// our finalizer is present, so lets handle any external dependency
+			if err := r.deleteExternalResources(ctx, &rootsyncset); err != nil {
+				// if fail to delete the external dependency here, return with error
+				// so that it can be retried
+				return ctrl.Result{}, fmt.Errorf("have problem to delete external resource: %w", err)
+			}
+			// remove our finalizer from the list and update it.
+			controllerutil.RemoveFinalizer(&rootsyncset, myFinalizerName)
+			if err := r.Update(ctx, &rootsyncset); err != nil {
+				return ctrl.Result{}, fmt.Errorf("failed to update %s after delete finalizer: %w", req.Name, err)
+			}
+		}
+		// Stop reconciliation as the item is being deleted
+		return ctrl.Result{}, nil
+	}
+	var patchErrs []error
+	for _, clusterRef := range rootsyncset.Spec.ClusterRefs {
+		clusterRefName := clusterRef.Kind + ":" + clusterRef.Name
+		client, err := r.GetClient(ctx, clusterRef, rootsyncset.Namespace)
+		if err != nil {
+			patchErrs = append(patchErrs, err)
+			continue
+		}
+		rootSyncRes, newRootSync, err := BuildObjectsToApply(&rootsyncset)
+		if err != nil {
+			patchErrs = append(patchErrs, err)
+			continue
+		}
+		data, err := json.Marshal(newRootSync)
+		if err != nil {
+			patchErrs = append(patchErrs, fmt.Errorf("failed to encode root sync to JSON: %w", err))
+			continue
+		}
+		rs, err := client.Resource(rootSyncRes).Namespace(rootSyncNamespace).Patch(ctx, rootSyncName, types.ApplyPatchType, data, metav1.PatchOptions{FieldManager: req.Name})
+		if err != nil {
+			patchErrs = append(patchErrs, fmt.Errorf("failed to patch RootSync %s in cluster %s: %w", rootSyncNamespace+"/"+rootSyncName, clusterRefName, err))
+		} else {
+			klog.Infof("Create/Update resource %s as %v", rootSyncName, rs)
+		}
+	}
+	if len(patchErrs) != 0 {
+		for _, patchErr := range patchErrs {
+			klog.Errorf("%v", patchErr)
+		}
+		return ctrl.Result{}, patchErrs[0]
+	}
+	return ctrl.Result{}, nil
+}
+
+//BuildObjectsToApply config root sync
+func BuildObjectsToApply(rootsyncset *v1alpha1.RootSyncSet) (schema.GroupVersionResource, *unstructured.Unstructured, error) {
+	gv, err := schema.ParseGroupVersion(rootSyncApiVersion)
+	if err != nil {
+		return schema.GroupVersionResource{}, nil, fmt.Errorf("failed to parse group version when building object: %w", err)
+	}
+	rootSyncRes := schema.GroupVersionResource{Group: gv.Group, Version: gv.Version, Resource: "rootsyncs"}
+	newRootSync, err := runtime.DefaultUnstructuredConverter.ToUnstructured(rootsyncset.Spec.Template)
+	newRootSync["apiVersion"] = rootSyncApiVersion
+	newRootSync["kind"] = rootSyncKind
+	newRootSync["metadata"] = map[string]string{"name": rootSyncName,
+		"namespace": rootSyncNamespace}
+	fmt.Printf("rootsync looks like %v", newRootSync)
+	if err != nil {
+		return schema.GroupVersionResource{}, nil, fmt.Errorf("failed to convert to unstructured type: %w", err)
+	}
+	u := unstructured.Unstructured{Object: newRootSync}
+
+	return rootSyncRes, &u, nil
+}
+
+// SetupWithManager sets up the controller with the Manager.
+func (r *RootSyncSetReconciler) SetupWithManager(mgr ctrl.Manager) error {
+	if err := r.WorkloadIdentityHelper.Init(mgr.GetConfig()); err != nil {
+		return err
+	}
+	return ctrl.NewControllerManagedBy(mgr).
+		For(&v1alpha1.RootSyncSet{}).
+		Complete(r)
+}
+
+func (r *RootSyncSetReconciler) deleteExternalResources(ctx context.Context, rootsyncset *v1alpha1.RootSyncSet) error {
+	var deleteErrs []error
+	for _, clusterRef := range rootsyncset.Spec.ClusterRefs {
+		myClient, err := r.GetClient(ctx, clusterRef, rootsyncset.Namespace)
+		if err != nil {
+			deleteErrs = append(deleteErrs, fmt.Errorf("failed to get client when delete resource: %w", err))
+			continue
+		}
+		klog.Infof("deleting external resource %s ...", rootSyncName)
+		gv, err := schema.ParseGroupVersion(rootSyncApiVersion)
+		if err != nil {
+			deleteErrs = append(deleteErrs, fmt.Errorf("failed to parse group version when deleting external resrouces: %w", err))
+			continue
+		}
+		rootSyncRes := schema.GroupVersionResource{Group: gv.Group, Version: gv.Version, Resource: "rootsyncs"}
+		err = myClient.Resource(rootSyncRes).Namespace("config-management-system").Delete(ctx, rootSyncName, metav1.DeleteOptions{})
+		if err != nil && !apierrors.IsNotFound(err) {
+			deleteErrs = append(deleteErrs, fmt.Errorf("failed to delete external resource : %w", err))
+		}
+	}
+	if len(deleteErrs) != 0 {
+		for _, deleteErr := range deleteErrs {
+			klog.Errorf("%v", deleteErr)
+		}
+		return deleteErrs[0]
+	}
+	klog.Infof("external resource %s delete Done!", rootSyncName)
+	return nil
+}
+
+// GetCCRESTConfig builds a rest.Config for accessing the config controller cluster,
+// this is a tmp workaround.
+func (r *RootSyncSetReconciler) GetCCRESTConfig(ctx context.Context, cluster *unstructured.Unstructured) (*rest.Config, error) {
+	gkeResourceLink, _, err := unstructured.NestedString(cluster.Object, "status", "gkeResourceLink")
+	if err != nil {
+		return nil, fmt.Errorf("failed to get status.gkeResourceLink field: %w", err)
+	}
+	if gkeResourceLink == "" {
+		return nil, fmt.Errorf("status.gkeResourceLink not set in object")
+	}
+
+	googleURL, err := googleurl.ParseUnversioned(gkeResourceLink)
+	if err != nil {
+		return nil, fmt.Errorf("error parsing gkeResourceLink %q: %w", gkeResourceLink, err)
+	}
+	projectID := googleURL.Project
+	location := googleURL.Location
+	clusterName := googleURL.Extra["clusters"]
+
+	tokenSource, err := r.GetConfigConnectorContextTokenSource(ctx, cluster.GetNamespace())
+	if err != nil {
+		return nil, err
+	}
+
+	c, err := container.NewClusterManagerClient(ctx, option.WithTokenSource(tokenSource), option.WithQuotaProject(projectID))
+	if err != nil {
+		return nil, fmt.Errorf("failed to create new cluster manager client: %w", err)
+	}
+	defer c.Close()
+
+	clusterSelfLink := "projects/" + projectID + "/locations/" + location + "/clusters/" + clusterName
+	klog.Infof("cluster path is %s", clusterSelfLink)
+	req := &containerpb.GetClusterRequest{
+		Name: clusterSelfLink,
+	}
+	resp, err := c.GetCluster(ctx, req)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get cluster info for cluster %q: %w", clusterSelfLink, err)
+	}
+	restConfig := &rest.Config{}
+	caData, err := base64.StdEncoding.DecodeString(resp.MasterAuth.ClusterCaCertificate)
+	if err != nil {
+		return nil, fmt.Errorf("error decoding ca certificate from gke cluster %q: %w", clusterSelfLink, err)
+	}
+	restConfig.CAData = caData
+
+	restConfig.Host = "https://" + resp.Endpoint
+	klog.Infof("Host endpoint is %s", restConfig.Host)
+
+	token, err := tokenSource.Token()
+	if err != nil {
+		return nil, fmt.Errorf("error getting token: %w", err)
+	}
+
+	restConfig.BearerToken = token.AccessToken
+	return restConfig, nil
+}
+
+func (r *RootSyncSetReconciler) GetClient(ctx context.Context, ref *v1alpha1.ClusterRef, ns string) (dynamic.Interface, error) {
+	key := types.NamespacedName{Namespace: ref.Namespace, Name: ref.Name}
+	if key.Namespace == "" {
+		key.Namespace = ns
+	}
+	u := &unstructured.Unstructured{}
+	var config *rest.Config
+	gv, err := schema.ParseGroupVersion(ref.ApiVersion)
+	if err != nil {
+		return nil, fmt.Errorf("failed to parse group version when building object: %w", err)
+	}
+
+	u.SetGroupVersionKind(schema.GroupVersionKind{
+		Group:   gv.Group,
+		Version: gv.Version,
+		Kind:    ref.Kind,
+	})
+	if err := r.Get(ctx, key, u); err != nil {
+		return nil, fmt.Errorf("failed to get cluster: %w", err)
+	}
+	if ref.Kind == "ContainerCluster" {
+		config, err = r.GetGKERESTConfig(ctx, u)
+	} else if ref.Kind == "ConfigControllerInstance" {
+		config, err = r.GetCCRESTConfig(ctx, u) //TODO: tmp workaround, update after ACP add new fields
+	} else {
+		return nil, fmt.Errorf("failed to find target cluster, cluster kind has to be ContainerCluster or ConfigControllerInstance")
+	}
+	if err != nil {
+		return nil, err
+	}
+	myClient, err := dynamic.NewForConfig(config)
+	if err != nil {
+		return nil, fmt.Errorf("failed to create a new dynamic client: %w", err)
+	}
+	return myClient, nil
+}
+
+// GetGKERESTConfig builds a rest.Config for accessing the specified cluster,
+// without assuming that kubeconfig is correctly configured / mapped.
+func (r *RootSyncSetReconciler) GetGKERESTConfig(ctx context.Context, cluster *unstructured.Unstructured) (*rest.Config, error) {
+	restConfig := &rest.Config{}
+	clusterCaCertificate, exist, err := unstructured.NestedString(cluster.Object, "spec", "masterAuth", "clusterCaCertificate")
+	if err != nil {
+		return nil, fmt.Errorf("failed to get rest config: %w", err)
+	}
+	if !exist {
+		return nil, fmt.Errorf("clusterCaCertificate field does not exist")
+	}
+	caData, err := base64.StdEncoding.DecodeString(clusterCaCertificate)
+	if err != nil {
+		return nil, fmt.Errorf("error decoding ca certificate: %w", err)
+	}
+	restConfig.CAData = caData
+	endpoint, exist, err := unstructured.NestedString(cluster.Object, "status", "endpoint")
+	if err != nil {
+		return nil, fmt.Errorf("failed to get rest config: %w", err)
+	}
+	if !exist {
+		return nil, fmt.Errorf("endpoint field does not exist")
+	}
+	restConfig.Host = "https://" + endpoint
+	klog.Infof("Host endpoint is %s", restConfig.Host)
+	tokenSource, err := r.GetConfigConnectorContextTokenSource(ctx, cluster.GetNamespace())
+	if err != nil {
+		return nil, err
+	}
+	token, err := tokenSource.Token()
+	if err != nil {
+		return nil, fmt.Errorf("error getting token: %w", err)
+	}
+	restConfig.BearerToken = token.AccessToken
+	return restConfig, nil
+}
+
+// GetConfigConnectorContextTokenSource gets and returns the ConfigConnectorContext for the given namespace.
+func (r *RootSyncSetReconciler) GetConfigConnectorContextTokenSource(ctx context.Context, ns string) (oauth2.TokenSource, error) {
+	gvr := schema.GroupVersionResource{
+		Group:    "core.cnrm.cloud.google.com",
+		Version:  "v1beta1",
+		Resource: "configconnectorcontexts",
+	}
+
+	cr, err := r.dynamicClient.Resource(gvr).Namespace(ns).Get(ctx, "configconnectorcontext.core.cnrm.cloud.google.com", metav1.GetOptions{})
+	if err != nil {
+		return nil, err
+	}
+
+	googleServiceAccount, _, err := unstructured.NestedString(cr.Object, "spec", "googleServiceAccount")
+	if err != nil {
+		return nil, fmt.Errorf("error reading spec.googleServiceAccount from ConfigConnectorContext in %q: %w", ns, err)
+	}
+
+	if googleServiceAccount == "" {
+		return nil, fmt.Errorf("could not find spec.googleServiceAccount from ConfigConnectorContext in %q: %w", ns, err)
+	}
+
+	kubeServiceAccount := types.NamespacedName{
+		Namespace: "cnrm-system",
+		Name:      "cnrm-controller-manager-" + ns,
+	}
+	return r.WorkloadIdentityHelper.GetGcloudAccessTokenSource(ctx, kubeServiceAccount, googleServiceAccount)
+}
diff --git a/porch/controllers/rootsyncset/pkg/controllers/rootsyncset/tokens.go b/porch/controllers/rootsyncset/pkg/controllers/rootsyncset/tokens.go
new file mode 100644
index 00000000..52b4487d
--- /dev/null
+++ b/porch/controllers/rootsyncset/pkg/controllers/rootsyncset/tokens.go
@@ -0,0 +1,203 @@
+// 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 rootsyncset
+
+import (
+	"context"
+	"fmt"
+	"io/ioutil"
+	"os"
+	"strings"
+	"sync"
+
+	"github.com/GoogleContainerTools/kpt/porch/pkg/tokenexchange/gcptokensource"
+	"github.com/GoogleContainerTools/kpt/porch/pkg/tokenexchange/ksaimpersonationtokensource"
+	"github.com/GoogleContainerTools/kpt/porch/pkg/tokenexchange/ksatokensource"
+	"github.com/GoogleContainerTools/kpt/porch/pkg/tokenexchange/membership"
+	"golang.org/x/oauth2"
+	"google.golang.org/api/option"
+	"google.golang.org/api/sts/v1"
+	stsv1 "google.golang.org/api/sts/v1"
+	apierrors "k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/client-go/dynamic"
+	corev1client "k8s.io/client-go/kubernetes/typed/core/v1"
+	"k8s.io/client-go/rest"
+	"k8s.io/klog/v2"
+)
+
+// WorkloadIdentityHelper is a helper class that does the exchanges needed for workload identity.
+type WorkloadIdentityHelper struct {
+	// stsClient holds a client for querying STS
+	stsClient *stsv1.Service
+
+	// corev1Client is used for kubernetes impersonation
+	corev1Client corev1client.CoreV1Interface
+
+	// dynamicClient is used to check for the membership resource
+	dynamicClient dynamic.Interface
+
+	restConfig *rest.Config
+
+	mutex      sync.Mutex
+	tokenCache map[tokenCacheKey]oauth2.TokenSource
+}
+
+type tokenCacheKey struct {
+	kubeServiceAccount types.NamespacedName
+	gcpServiceAccount  string
+}
+
+// Init should be called before using a WorkloadIdentityHelper
+func (r *WorkloadIdentityHelper) Init(restConfig *rest.Config) error {
+	r.restConfig = restConfig
+
+	// If we want to debug RBAC/Token Exchange locally...
+	// restConfigImpersonate := *restConfig
+	// restConfigImpersonate.Impersonate.UserName = "system:serviceaccount:configcontroller-system:rootsyncset-impersonate"
+	// restConfig = &restConfigImpersonate
+
+	corev1Client, err := corev1client.NewForConfig(restConfig)
+	if err != nil {
+		return fmt.Errorf("error building corev1 client: %w", err)
+	}
+	r.corev1Client = corev1Client
+
+	dynamicClient, err := dynamic.NewForConfig(restConfig)
+	if err != nil {
+		return fmt.Errorf("error building dynamic client: %w", err)
+	}
+	r.dynamicClient = dynamicClient
+
+	// option.WithoutAuthentication because we don't want to use our credentials for the exchange
+	// STS actually gives an error: googleapi: "Error 400: Request contains an invalid argument., badRequest"
+	stsClient, err := sts.NewService(context.Background(), option.WithoutAuthentication())
+	if err != nil {
+		return fmt.Errorf("error building sts client: %w", err)
+	}
+	r.stsClient = stsClient
+
+	r.tokenCache = make(map[tokenCacheKey]oauth2.TokenSource)
+
+	return nil
+}
+
+// GetGcloudAccessTokenSource does the exchange to get a token for the specified GCP ServiceAccount.
+func (r *WorkloadIdentityHelper) GetGcloudAccessTokenSource(ctx context.Context, kubeServiceAccount types.NamespacedName, gcpServiceAccount string) (oauth2.TokenSource, error) {
+	key := tokenCacheKey{
+		kubeServiceAccount: kubeServiceAccount,
+		gcpServiceAccount:  gcpServiceAccount,
+	}
+
+	r.mutex.Lock()
+	cachedTokenSource := r.tokenCache[key]
+	r.mutex.Unlock()
+
+	if cachedTokenSource != nil {
+		return cachedTokenSource, nil
+	}
+
+	var workloadIdentityPool string
+	var identityProvider string
+
+	membershipConfig, err := membership.Get(ctx, r.dynamicClient)
+	if err != nil {
+		if apierrors.IsNotFound(err) {
+			// TODO: Cache this?
+			workloadIdentityPool, identityProvider, err = r.findWorkloadIdentityPool(ctx, kubeServiceAccount)
+			if err != nil {
+				return nil, err
+			}
+		} else {
+			return nil, fmt.Errorf("error fetching membership: %w", err)
+		}
+	} else {
+		workloadIdentityPool = membershipConfig.Spec.WorkloadIdentityPool
+		identityProvider = membershipConfig.Spec.IdentityProvider
+	}
+
+	impersonated := ksaimpersonationtokensource.New(r.corev1Client, kubeServiceAccount, []string{workloadIdentityPool})
+
+	ksaToken := ksatokensource.New(r.stsClient, impersonated, workloadIdentityPool, identityProvider)
+
+	var scopes []string
+	gcpTokenSource := gcptokensource.New(gcpServiceAccount, scopes, ksaToken)
+
+	tokenSource := oauth2.ReuseTokenSource(nil, gcpTokenSource)
+
+	r.mutex.Lock()
+	r.tokenCache[key] = tokenSource
+	r.mutex.Unlock()
+
+	return tokenSource, nil
+}
+
+func (r *WorkloadIdentityHelper) findWorkloadIdentityPool(ctx context.Context, kubeServiceAccount types.NamespacedName) (string, string, error) {
+	accessToken := ""
+
+	// First, see if we have a valid token mounted locally in our pod
+	{
+		const tokenFilePath = "/var/run/secrets/kubernetes.io/serviceaccount/token"
+
+		tokenBytes, err := ioutil.ReadFile(tokenFilePath)
+		if err != nil {
+			if os.IsNotExist(err) {
+				klog.V(2).Infof("token file not found at %q", tokenFilePath)
+			} else {
+				klog.Warningf("error reading token file from %q: %v", tokenFilePath, err)
+			}
+		} else {
+			klog.Infof("found token at %q", tokenFilePath)
+			accessToken = string(tokenBytes)
+		}
+	}
+
+	// We could also query the kube apiserver at /.well-known/openid-configuration
+	// kubectl get --raw /.well-known/openid-configuration
+	// {"issuer":"https://container.googleapis.com/v1/projects/example-project-id/locations/us-central1/clusters/krmapihost-control","jwks_uri":"https://172.16.0.130:443/openid/v1/jwks","response_types_supported":["id_token"],"subject_types_supported":["public"],"id_token_signing_alg_values_supported":["RS256"]}
+
+	if accessToken == "" {
+		// We get a token for our own service account, so we can extract the issuer
+		klog.Infof("token not found at well-known path, requesting token from apiserver")
+		impersonated := ksaimpersonationtokensource.New(r.corev1Client, kubeServiceAccount, nil /* unspecified/default audience */)
+
+		token, err := impersonated.Token()
+		if err != nil {
+			return "", "", fmt.Errorf("failed to get kube token for %s: %w", kubeServiceAccount, err)
+		} else {
+			accessToken = token.AccessToken
+		}
+	}
+
+	issuer, err := ksatokensource.ExtractIssuer(accessToken)
+	if err != nil {
+		return "", "", err
+	}
+
+	if strings.HasPrefix(issuer, "https://container.googleapis.com/") {
+		path := strings.TrimPrefix(issuer, "https://container.googleapis.com/")
+		tokens := strings.Split(path, "/")
+		for i := 0; i+1 < len(tokens); i++ {
+			if tokens[i] == "projects" {
+				workloadIdentityPool := tokens[i+1] + ".svc.id.goog"
+				klog.Infof("inferred workloadIdentityPool as %q", workloadIdentityPool)
+				return workloadIdentityPool, issuer, nil
+			}
+		}
+		return "", "", fmt.Errorf("could not extract project from issue %q", issuer)
+	} else {
+		return "", "", fmt.Errorf("unknown issuer %q", issuer)
+	}
+}
diff --git a/porch/func/healthchecker/healthchecker.go b/porch/func/healthchecker/healthchecker.go
index 4ee0fe5f..7639a98b 100644
--- a/porch/func/healthchecker/healthchecker.go
+++ b/porch/func/healthchecker/healthchecker.go
@@ -21,6 +21,8 @@ import (
 	"k8s.io/klog/v2"
 )
 
+// HealthChecker implements the GRPC Health Checking Protocol:
+// https://github.com/grpc/grpc/blob/master/doc/health-checking.md
 type HealthChecker struct{}
 
 func NewHealthChecker() *HealthChecker {
diff --git a/porch/func/internal/podevaluator.go b/porch/func/internal/podevaluator.go
index a8d06d12..0cfcb2ac 100644
--- a/porch/func/internal/podevaluator.go
+++ b/porch/func/internal/podevaluator.go
@@ -19,7 +19,7 @@ import (
 	"fmt"
 	"net"
 	"os"
-	"path"
+	"path/filepath"
 	"strconv"
 	"strings"
 	"sync"
@@ -124,7 +124,7 @@ func (pe *podEvaluator) EvaluateFunction(ctx context.Context, req *evaluator.Eva
 		grpcClientCh: ccChan,
 	}
 
-	// Waiting for the client from the channel.
+	// Waiting for the client from the channel. This step is blocking.
 	cc := <-ccChan
 	if cc.err != nil {
 		return nil, fmt.Errorf("unable to get the grpc client to the pod for %v: %w", req.Image, cc.err)
@@ -141,6 +141,13 @@ func (pe *podEvaluator) EvaluateFunction(ctx context.Context, req *evaluator.Eva
 	return resp, nil
 }
 
+// podCacheManager manages the cache of the pods and the corresponding GRPC clients.
+// It also does the garbage collection after pods' TTL.
+// It has 2 receive-only channels: requestCh and podReadyCh.
+// It listens to the requestCh channel and receives clientConnRequest from the
+// GRPC request handlers and add them in the waitlists.
+// It also listens to the podReadyCh channel. If a pod is ready, it notifies the
+// goroutines by sending back the GRPC client by lookup the waitlists mapping.
 type podCacheManager struct {
 	gcScanInternal time.Duration
 	podTTL         time.Duration
@@ -150,7 +157,10 @@ type podCacheManager struct {
 	// podReadyCh is a channel to receive the information when a pod is ready.
 	podReadyCh <-chan *imagePodAndGRPCClient
 
-	cache     map[string]*podAndGRPCClient
+	// cache is a mapping from image name to <pod + grpc client>.
+	cache map[string]*podAndGRPCClient
+	// waitlists is a mapping from image name to a list of channels that are
+	// waiting for the GPRC client connections.
 	waitlists map[string][]chan<- *clientConnAndError
 
 	podManager *podManager
@@ -159,9 +169,6 @@ type podCacheManager struct {
 type clientConnRequest struct {
 	image string
 
-	unavavilable     bool
-	currClientTarget string
-
 	// grpcConn is a channel that a grpc client should be sent back.
 	grpcClientCh chan<- *clientConnAndError
 }
@@ -182,6 +189,7 @@ type imagePodAndGRPCClient struct {
 	err error
 }
 
+// warmupCache creates the pods and warms up the cache.
 func (pcm *podCacheManager) warmupCache(podTTLConfig string) error {
 	start := time.Now()
 	defer func() {
@@ -197,6 +205,7 @@ func (pcm *podCacheManager) warmupCache(podTTLConfig string) error {
 		return err
 	}
 
+	// We create the pods concurrently to speed it up.
 	var wg sync.WaitGroup
 	for fnImage, ttlStr := range podsTTL {
 		wg.Add(1)
@@ -216,11 +225,15 @@ func (pcm *podCacheManager) warmupCache(podTTLConfig string) error {
 			klog.Infof("preloaded pod cache for function %v", img)
 		}(fnImage, ttlStr)
 	}
-	// Wait for the cache warming to finish before returning.
+	// Wait for the cache warming up to finish before returning.
 	wg.Wait()
 	return nil
 }
 
+// podCacheManager responds to the requestCh and the podReadyCh and does the
+// garbage collection synchronously.
+// We must run this method in one single goroutine. Doing it this way simplify
+// design around concurrency.
 func (pcm *podCacheManager) podCacheManager() {
 	tick := time.Tick(pcm.gcScanInternal)
 	for {
@@ -267,6 +280,7 @@ func (pcm *podCacheManager) podCacheManager() {
 			} else {
 				pcm.cache[resp.image] = resp.podAndGRPCClient
 			}
+			// notify all the goroutines that are waiting for the GRPC client.
 			channels := pcm.waitlists[resp.image]
 			delete(pcm.waitlists, resp.image)
 			for i := range channels {
@@ -343,6 +357,11 @@ func (pcm *podCacheManager) garbageCollector() {
 	}
 }
 
+// podManager is responsible for:
+// - creating a pod
+// - retrieving an existing pod
+// - waiting for the pod to be running and ready
+// - caching the metadata (e.g. entrypoint) for the image.
 type podManager struct {
 	// kubeClient is the kubernetes client
 	kubeClient client.Client
@@ -367,6 +386,11 @@ type digestAndEntrypoint struct {
 	entrypoint []string
 }
 
+// getFuncEvalPodClient ensures there is a pod running and ready for the image.
+// It will send it to the podReadyCh channel when the pod is ready. ttl is the
+// time-to-live period for the pod. If useGenerateName is false, it will try to
+// create a pod with a fixed name. Otherwise, it will create a pod and let the
+// apiserver to generate the name from a template.
 func (pm *podManager) getFuncEvalPodClient(ctx context.Context, image string, ttl time.Duration, useGenerateName bool) {
 	c, err := func() (*podAndGRPCClient, error) {
 		podKey, err := pm.retrieveOrCreatePod(ctx, image, ttl, useGenerateName)
@@ -436,6 +460,7 @@ func (pm *podManager) imageDigestAndEntrypoint(image string) (*digestAndEntrypoi
 	return de, nil
 }
 
+// retrieveOrCreatePod retrieves or creates a pod for an image.
 func (pm *podManager) retrieveOrCreatePod(ctx context.Context, image string, ttl time.Duration, useGenerateName bool) (client.ObjectKey, error) {
 	var de *digestAndEntrypoint
 	var err error
@@ -474,7 +499,7 @@ func (pm *podManager) retrieveOrCreatePod(ctx context.Context, image string, ttl
 	}
 
 	cmd := append([]string{
-		path.Join(volumeMountPath, wrapperServerBin),
+		filepath.Join(volumeMountPath, wrapperServerBin),
 		"--port", defaultWrapperServerPort, "--",
 	}, de.entrypoint...)
 
@@ -500,6 +525,7 @@ func (pm *podManager) retrieveOrCreatePod(ctx context.Context, image string, ttl
 			},
 		},
 		Spec: corev1.PodSpec{
+			// We use initContainer to copy the wrapper server binary into the KRM function image.
 			InitContainers: []corev1.Container{
 				{
 					Name:  "copy-wrapper-server",
@@ -525,9 +551,11 @@ func (pm *podManager) retrieveOrCreatePod(ctx context.Context, image string, ttl
 					Command: cmd,
 					ReadinessProbe: &corev1.Probe{
 						ProbeHandler: corev1.ProbeHandler{
+							// TODO: use the k8s native GRPC prober when it has been rolled out in GKE.
+							// https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/#define-a-grpc-liveness-probe
 							Exec: &corev1.ExecAction{
 								Command: []string{
-									path.Join(volumeMountPath, gRPCProbeBin),
+									filepath.Join(volumeMountPath, gRPCProbeBin),
 									"-addr", net.JoinHostPort("localhost", defaultWrapperServerPort),
 								},
 							},
@@ -551,6 +579,8 @@ func (pm *podManager) retrieveOrCreatePod(ctx context.Context, image string, ttl
 			},
 		},
 	}
+	// Server-side apply doesn't support name generation. We have to use Create
+	// if we need to use name generation.
 	if useGenerateName {
 		pod.GenerateName = podId + "-"
 		err = pm.kubeClient.Create(ctx, pod, client.FieldOwner(fieldManagerName))
@@ -593,6 +623,7 @@ func (pm *podManager) podIpIfRunningAndReady(ctx context.Context, podKey client.
 	return pod.Status.PodIP, nil
 }
 
+// patchPodWithUnixTimeAnnotation patches the pod with the new updated TTL annotation.
 func patchPodWithUnixTimeAnnotation(cl client.Client, podKey client.ObjectKey, ttl time.Duration) {
 	patch := []byte(fmt.Sprintf(`{"metadata":{"annotations":{"%v": "%v"}}}`, reclaimAfterAnnotation, time.Now().Add(ttl).Unix()))
 	pod := &corev1.Pod{}
diff --git a/porch/pkg/cache/cache.go b/porch/pkg/cache/cache.go
index bd5aa651..bf257d19 100644
--- a/porch/pkg/cache/cache.go
+++ b/porch/pkg/cache/cache.go
@@ -104,9 +104,16 @@ func (c *Cache) OpenRepository(ctx context.Context, repositorySpec *configapi.Re
 
 		cr := c.repositories[key]
 		if cr == nil {
+			var mbs git.MainBranchStrategy
+			if gitSpec.CreateBranch {
+				mbs = git.CreateIfMissing
+			} else {
+				mbs = git.ErrorIfMissing
+			}
 			if r, err := git.OpenRepository(ctx, repositorySpec.Name, repositorySpec.Namespace, gitSpec, filepath.Join(c.cacheDir, "git"), git.GitRepositoryOptions{
 				CredentialResolver: c.credentialResolver,
 				UserInfoProvider:   c.userInfoProvider,
+				MainBranchStrategy: mbs,
 			}); err != nil {
 				return nil, err
 			} else {
diff --git a/porch/pkg/engine/clone.go b/porch/pkg/engine/clone.go
index 1fa581b9..866b3e53 100644
--- a/porch/pkg/engine/clone.go
+++ b/porch/pkg/engine/clone.go
@@ -151,8 +151,8 @@ func (m *clonePackageMutation) cloneFromGit(ctx context.Context, gitPackage *api
 	defer os.RemoveAll(dir)
 
 	r, err := git.OpenRepository(ctx, "", "", &spec, dir, git.GitRepositoryOptions{
-		CredentialResolver:         m.credentialResolver,
-		SkipMainBranchVerification: true, // We are only reading so we don't need the main branch to exist.
+		CredentialResolver: m.credentialResolver,
+		MainBranchStrategy: git.SkipVerification, // We are only reading so we don't need the main branch to exist.
 	})
 	if err != nil {
 		return repository.PackageResources{}, fmt.Errorf("cannot clone Git repository: %w", err)
diff --git a/porch/pkg/git/commit.go b/porch/pkg/git/commit.go
index 4bd0eab6..22e8cae8 100644
--- a/porch/pkg/git/commit.go
+++ b/porch/pkg/git/commit.go
@@ -206,7 +206,7 @@ func removeTreeEntry(tree *object.Tree, name string) {
 
 // storeFile writes a blob with contents at the specified path
 func (h *commitHelper) storeFile(path, contents string) error {
-	hash, err := h.storeBlob(contents)
+	hash, err := storeBlob(h.storer, contents)
 	if err != nil {
 		return err
 	}
@@ -296,9 +296,9 @@ func (h *commitHelper) commit(ctx context.Context, message string, pkgPath strin
 }
 
 // storeBlob is a helper method to write a blob to the git store.
-func (h *commitHelper) storeBlob(value string) (plumbing.Hash, error) {
+func storeBlob(storer storage.Storer, value string) (plumbing.Hash, error) {
 	data := []byte(value)
-	eo := h.storer.NewEncodedObject()
+	eo := storer.NewEncodedObject()
 	eo.SetType(plumbing.BlobObject)
 	eo.SetSize(int64(len(data)))
 
@@ -316,7 +316,7 @@ func (h *commitHelper) storeBlob(value string) (plumbing.Hash, error) {
 		return plumbing.Hash{}, err
 	}
 
-	return h.storer.SetEncodedObject(eo)
+	return storer.SetEncodedObject(eo)
 }
 
 // split returns the full directory path and file name
@@ -456,13 +456,17 @@ func (h *commitHelper) storeCommit(parent plumbing.Hash, tree plumbing.Hash, use
 		TreeHash: tree,
 	}
 
+	return storeCommit(h.storer, parent, commit)
+}
+
+func storeCommit(storer storage.Storer, parent plumbing.Hash, commit *object.Commit) (plumbing.Hash, error) {
 	if !parent.IsZero() {
 		commit.ParentHashes = []plumbing.Hash{parent}
 	}
 
-	eo := h.storer.NewEncodedObject()
+	eo := storer.NewEncodedObject()
 	if err := commit.Encode(eo); err != nil {
 		return plumbing.Hash{}, err
 	}
-	return h.storer.SetEncodedObject(eo)
+	return storer.SetEncodedObject(eo)
 }
diff --git a/porch/pkg/git/git.go b/porch/pkg/git/git.go
index ce67fdd6..e545a3b4 100644
--- a/porch/pkg/git/git.go
+++ b/porch/pkg/git/git.go
@@ -32,6 +32,7 @@ import (
 	"github.com/go-git/go-git/v5"
 	"github.com/go-git/go-git/v5/config"
 	"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/object"
 	"github.com/go-git/go-git/v5/plumbing/transport"
 	"go.opentelemetry.io/otel"
@@ -51,10 +52,19 @@ type GitRepository interface {
 	GetPackage(ctx context.Context, ref, path string) (repository.PackageRevision, kptfilev1.GitLock, error)
 }
 
+//go:generate stringer -type=MainBranchStrategy -linecomment
+type MainBranchStrategy int
+
+const (
+	ErrorIfMissing   MainBranchStrategy = iota // ErrorIsMissing
+	CreateIfMissing                            // CreateIfMissing
+	SkipVerification                           // SkipVerification
+)
+
 type GitRepositoryOptions struct {
-	CredentialResolver         repository.CredentialResolver
-	UserInfoProvider           repository.UserInfoProvider
-	SkipMainBranchVerification bool
+	CredentialResolver repository.CredentialResolver
+	UserInfoProvider   repository.UserInfoProvider
+	MainBranchStrategy MainBranchStrategy
 }
 
 func OpenRepository(ctx context.Context, name, namespace string, spec *configapi.GitRepository, root string, opts GitRepositoryOptions) (GitRepository, error) {
@@ -124,7 +134,7 @@ func OpenRepository(ctx context.Context, name, namespace string, spec *configapi
 		return nil, err
 	}
 
-	if err := repository.verifyRepository(&opts); err != nil {
+	if err := repository.verifyRepository(ctx, &opts); err != nil {
 		return nil, err
 	}
 
@@ -676,21 +686,86 @@ func (r *gitRepository) fetchRemoteRepository(ctx context.Context) error {
 }
 
 // Verifies repository. Repository must be fetched already.
-func (r *gitRepository) verifyRepository(opts *GitRepositoryOptions) error {
+func (r *gitRepository) verifyRepository(ctx context.Context, opts *GitRepositoryOptions) error {
 	// When opening a temporary repository, such as for cloning a package
 	// from unregistered upstream, we won't be pushing into the remote so
 	// we don't need to verify presence of the main branch.
-	if !opts.SkipMainBranchVerification {
-		// Check existence of main branch. If it doesn't exist, fail.
-		// If the main branch doesn't exist, we could create draft or proposal branch
-		// which could become the default branch and those are hard to delete.
-		if _, err := r.repo.Reference(r.branch.RefInLocal(), false); err != nil {
+	if opts.MainBranchStrategy == SkipVerification {
+		return nil
+	}
+
+	if _, err := r.repo.Reference(r.branch.RefInLocal(), false); err != nil {
+		switch opts.MainBranchStrategy {
+		case ErrorIfMissing:
 			return fmt.Errorf("branch %q doesn't exist: %v", r.branch, err)
+		case CreateIfMissing:
+			klog.Info("Creating branch %s in repository %s", r.branch, r.name)
+			if err := r.createBranch(ctx, r.branch); err != nil {
+				return fmt.Errorf("error creating main branch %q: %v", r.branch, err)
+			}
+		default:
+			return fmt.Errorf("unknown main branch strategy %q", opts.MainBranchStrategy.String())
 		}
 	}
 	return nil
 }
 
+const (
+	fileContent   = "Created by porch"
+	fileName      = "README.md"
+	commitMessage = "Initial commit for main branch by porch"
+)
+
+// createBranch creates the provided branch by creating a commit containing
+// a README.md file on the root of the repo and then pushing it to the branch.
+func (r *gitRepository) createBranch(ctx context.Context, branch BranchName) error {
+	fileHash, err := storeBlob(r.repo.Storer, fileContent)
+	if err != nil {
+		return err
+	}
+
+	tree := &object.Tree{}
+	tree.Entries = append(tree.Entries, object.TreeEntry{
+		Name: fileName,
+		Mode: filemode.Regular,
+		Hash: fileHash,
+	})
+
+	treeEo := r.repo.Storer.NewEncodedObject()
+	if err := tree.Encode(treeEo); err != nil {
+		return err
+	}
+
+	treeHash, err := r.repo.Storer.SetEncodedObject(treeEo)
+	if err != nil {
+		return err
+	}
+
+	now := time.Now()
+	commit := &object.Commit{
+		Author: object.Signature{
+			Name:  porchSignatureName,
+			Email: porchSignatureEmail,
+			When:  now,
+		},
+		Committer: object.Signature{
+			Name:  porchSignatureName,
+			Email: porchSignatureEmail,
+			When:  now,
+		},
+		Message:  commitMessage,
+		TreeHash: treeHash,
+	}
+	commitHash, err := storeCommit(r.repo.Storer, plumbing.ZeroHash, commit)
+	if err != nil {
+		return err
+	}
+
+	refSpecs := newPushRefSpecBuilder()
+	refSpecs.AddRefToPush(commitHash, branch.RefInLocal())
+	return r.pushAndCleanup(ctx, refSpecs)
+}
+
 // Creates a commit which deletes the package from the branch, and returns its commit hash.
 // If the branch doesn't exist, will return zero hash and no error.
 func (r *gitRepository) createPackageDeleteCommit(ctx context.Context, branch plumbing.ReferenceName, pkg *gitPackageRevision) (plumbing.Hash, error) {
diff --git a/porch/pkg/git/mainbranchstrategy_string.go b/porch/pkg/git/mainbranchstrategy_string.go
new file mode 100644
index 00000000..7707bcc9
--- /dev/null
+++ b/porch/pkg/git/mainbranchstrategy_string.go
@@ -0,0 +1,25 @@
+// Code generated by "stringer -type=MainBranchStrategy -linecomment"; DO NOT EDIT.
+
+package git
+
+import "strconv"
+
+func _() {
+	// An "invalid array index" compiler error signifies that the constant values have changed.
+	// Re-run the stringer command to generate them again.
+	var x [1]struct{}
+	_ = x[ErrorIfMissing-0]
+	_ = x[CreateIfMissing-1]
+	_ = x[SkipVerification-2]
+}
+
+const _MainBranchStrategy_name = "ErrorIsMissingCreateIfMissingSkipVerification"
+
+var _MainBranchStrategy_index = [...]uint8{0, 14, 29, 45}
+
+func (i MainBranchStrategy) String() string {
+	if i < 0 || i >= MainBranchStrategy(len(_MainBranchStrategy_index)-1) {
+		return "MainBranchStrategy(" + strconv.FormatInt(int64(i), 10) + ")"
+	}
+	return _MainBranchStrategy_name[_MainBranchStrategy_index[i]:_MainBranchStrategy_index[i+1]]
+}
diff --git a/porch/pkg/googleurl/parser.go b/porch/pkg/googleurl/parser.go
new file mode 100644
index 00000000..fda6d6b6
--- /dev/null
+++ b/porch/pkg/googleurl/parser.go
@@ -0,0 +1,106 @@
+// 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 googleurl
+
+import (
+	"fmt"
+	"net/url"
+	"strings"
+)
+
+// Link holds the results of parsing a google selfLink URL.
+type Link struct {
+	// Service is the service name e.g. container.googleapis.com
+	Service string
+	// Version is the version in the link
+	Version string
+
+	// Project is the GCP project ID in the link
+	Project string
+
+	// Zone is the GCP zone found in the link
+	Zone string
+
+	// Location is the GCP location (region or zone) found in the link
+	Location string
+
+	// Extra holds any additional named fields found in the link
+	Extra map[string]string
+}
+
+// Parse parses the selfLink into fields in a Link object.
+// A selfLink is expected to have a version as the first path token, otherwise use ParseUnversioned.
+func Parse(selfLink string) (*Link, error) {
+	u, err := url.Parse(selfLink)
+	if err != nil {
+		return nil, fmt.Errorf("selfLink %q was not a valid URL", selfLink)
+	}
+
+	link := &Link{
+		Service: u.Host,
+	}
+
+	tokens := strings.Split(strings.TrimPrefix(u.Path, "/"), "/")
+
+	link.Version = tokens[0]
+	tokens = tokens[1:]
+
+	if len(tokens)%2 != 0 {
+		return nil, fmt.Errorf("unexpected url %q (unexpected tokens)", selfLink)
+	}
+
+	return parseTokens(link, tokens)
+}
+
+func parseTokens(link *Link, tokens []string) (*Link, error) {
+	for i := 0; i < len(tokens); i += 2 {
+		switch tokens[i] {
+		case "projects":
+			link.Project = tokens[i+1]
+		case "zones":
+			link.Zone = tokens[i+1]
+		case "locations":
+			link.Location = tokens[i+1]
+		default:
+			if link.Extra == nil {
+				link.Extra = make(map[string]string)
+			}
+			link.Extra[tokens[i]] = tokens[i+1]
+		}
+	}
+	return link, nil
+}
+
+// ParseUnversioned parses the unversioned selfLink into fields in a Link object.
+// An unversioned reference differs from a normal selfLink in that it doesn't have a version,
+// for example "//container.googleapis.com/projects/example-project/locations/us-central1/clusters/example-cluster"
+func ParseUnversioned(selfLink string) (*Link, error) {
+	u, err := url.Parse(selfLink)
+	if err != nil {
+		return nil, fmt.Errorf("selfLink %q was not a valid URL", selfLink)
+	}
+
+	link := &Link{
+		Service: u.Host,
+	}
+
+	tokens := strings.Split(strings.TrimPrefix(u.Path, "/"), "/")
+
+	if len(tokens)%2 != 0 {
+		return nil, fmt.Errorf("unexpected url %q (unexpected tokens)", selfLink)
+	}
+
+	return parseTokens(link, tokens)
+}
diff --git a/porch/pkg/registry/porch/wi/wi.go b/porch/pkg/registry/porch/wi/wi.go
index 90ca0593..5f1519ca 100644
--- a/porch/pkg/registry/porch/wi/wi.go
+++ b/porch/pkg/registry/porch/wi/wi.go
@@ -20,9 +20,9 @@ import (
 	"os"
 	"strings"
 
-	"github.com/GoogleContainerTools/kpt/porch/pkg/registry/porch/wi/gcptokensource"
-	"github.com/GoogleContainerTools/kpt/porch/pkg/registry/porch/wi/ksaimpersonationtokensource"
-	"github.com/GoogleContainerTools/kpt/porch/pkg/registry/porch/wi/ksatokensource"
+	"github.com/GoogleContainerTools/kpt/porch/pkg/tokenexchange/gcptokensource"
+	"github.com/GoogleContainerTools/kpt/porch/pkg/tokenexchange/ksaimpersonationtokensource"
+	"github.com/GoogleContainerTools/kpt/porch/pkg/tokenexchange/ksatokensource"
 	"golang.org/x/oauth2"
 	stsv1 "google.golang.org/api/sts/v1"
 	"k8s.io/apimachinery/pkg/types"
diff --git a/porch/pkg/registry/porch/wi/gcptokensource/gcptokensource.go b/porch/pkg/tokenexchange/gcptokensource/gcptokensource.go
similarity index 100%
rename from porch/pkg/registry/porch/wi/gcptokensource/gcptokensource.go
rename to porch/pkg/tokenexchange/gcptokensource/gcptokensource.go
diff --git a/porch/pkg/registry/porch/wi/ksaimpersonationtokensource/ksaimpersonation.go b/porch/pkg/tokenexchange/ksaimpersonationtokensource/ksaimpersonation.go
similarity index 100%
rename from porch/pkg/registry/porch/wi/ksaimpersonationtokensource/ksaimpersonation.go
rename to porch/pkg/tokenexchange/ksaimpersonationtokensource/ksaimpersonation.go
diff --git a/porch/pkg/registry/porch/wi/ksatokensource/ksatokensource.go b/porch/pkg/tokenexchange/ksatokensource/ksatokensource.go
similarity index 100%
rename from porch/pkg/registry/porch/wi/ksatokensource/ksatokensource.go
rename to porch/pkg/tokenexchange/ksatokensource/ksatokensource.go
diff --git a/porch/pkg/tokenexchange/membership/membership.go b/porch/pkg/tokenexchange/membership/membership.go
new file mode 100644
index 00000000..7c919b65
--- /dev/null
+++ b/porch/pkg/tokenexchange/membership/membership.go
@@ -0,0 +1,67 @@
+// 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 membership
+
+import (
+	"context"
+	"encoding/json"
+
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+	"k8s.io/client-go/dynamic"
+)
+
+var (
+	gvr = schema.GroupVersionResource{
+		Group:    "hub.gke.io",
+		Version:  "v1",
+		Resource: "memberships",
+	}
+)
+
+// Membership is the object created by hub when a cluster is registered to hub.
+type Membership struct {
+	metav1.TypeMeta   `json:",inline"`
+	metav1.ObjectMeta `json:"metadata,omitempty"`
+	Spec              MembershipSpec `json:"spec,omitempty"`
+}
+
+type MembershipSpec struct {
+	Owner                MembershipOwner `json:"owner,omitempty"`
+	WorkloadIdentityPool string          `json:"workload_identity_pool,omitempty"`
+	IdentityProvider     string          `json:"identity_provider,omitempty"`
+}
+
+type MembershipOwner struct {
+	ID string `json:"id,omitempty"`
+}
+
+// Get gets and returns the Membership named "membership" from the cluster.
+func Get(ctx context.Context, client dynamic.Interface) (*Membership, error) {
+	cr, err := client.Resource(gvr).Get(ctx, "membership", metav1.GetOptions{})
+	if err != nil {
+		return nil, err
+	}
+	// round-trip through JSON is a convenient way to get at structured content
+	b, err := cr.MarshalJSON()
+	if err != nil {
+		return nil, err
+	}
+	membership := &Membership{}
+	if err := json.Unmarshal(b, membership); err != nil {
+		return nil, err
+	}
+	return membership, nil
+}
diff --git a/run/run.go b/run/run.go
index 36ab5896..1ef17a9a 100644
--- a/run/run.go
+++ b/run/run.go
@@ -17,6 +17,7 @@ package run
 import (
 	"bytes"
 	"context"
+	"flag"
 	"fmt"
 	"os"
 	"os/exec"
@@ -55,6 +56,8 @@ func GetMain(ctx context.Context) *cobra.Command {
 		},
 	}
 
+	cmd.PersistentFlags().AddGoFlagSet(flag.CommandLine)
+
 	cmd.PersistentFlags().BoolVar(&printer.TruncateOutput, "truncate-output", true,
 		"Enable the truncation for output")
 	// wire the global printer
@@ -171,8 +174,12 @@ func hideFlags(cmd *cobra.Command) {
 		"log_dir",
 		"log_file",
 		"log_file_max_size",
+		"logtostderr",
+		"one_output",
 		"skip_headers",
 		"skip_log_headers",
+		"stack-trace",
+		"stderrthreshold",
 		"vmodule",
 
 		// Flags related to apiserver


Test patch:
diff --git a/e2e/testdata/fn-render/basicpipeline-wasm/Kptfile b/e2e/testdata/fn-render/basicpipeline-wasm/Kptfile
index 5e7b3a64..7dd8cd1e 100644
--- a/e2e/testdata/fn-render/basicpipeline-wasm/Kptfile
+++ b/e2e/testdata/fn-render/basicpipeline-wasm/Kptfile
@@ -6,6 +6,7 @@ metadata:
     tier: backend
 pipeline:
   mutators:
+    # The following 2 images are built from https://github.com/GoogleContainerTools/kpt-functions-catalog/pull/898.
     - image: gcr.io/kpt-fn-demo/set-namespace:v0.5.0 # TODO: switch to use the kpt-fn GCR when it is built there.
       configMap:
         namespace: staging
diff --git a/porch/pkg/git/git_test.go b/porch/pkg/git/git_test.go
index bdd7c522..3339a1f3 100644
--- a/porch/pkg/git/git_test.go
+++ b/porch/pkg/git/git_test.go
@@ -78,7 +78,7 @@ type GitSuite struct {
 func (g GitSuite) TestOpenEmptyRepository(t *testing.T) {
 	tempdir := t.TempDir()
 	tarfile := filepath.Join("testdata", "empty-repository.tar")
-	_, address := ServeGitRepositoryWithBranch(t, tarfile, tempdir, g.branch)
+	repo, address := ServeGitRepositoryWithBranch(t, tarfile, tempdir, g.branch)
 
 	ctx := context.Background()
 	const (
@@ -96,9 +96,17 @@ func (g GitSuite) TestOpenEmptyRepository(t *testing.T) {
 		t.Errorf("Unexpectedly succeeded opening empty repository with main branch validation enabled.")
 	}
 
-	if _, err := OpenRepository(ctx, name, namespace, repository, tempdir, GitRepositoryOptions{SkipMainBranchVerification: true}); err != nil {
+	if _, err := OpenRepository(ctx, name, namespace, repository, tempdir, GitRepositoryOptions{MainBranchStrategy: SkipVerification}); err != nil {
 		t.Errorf("Failed to open empty git repository with main branch validation disabled: %v", err)
 	}
+
+	if _, err := OpenRepository(ctx, name, namespace, repository, tempdir, GitRepositoryOptions{MainBranchStrategy: CreateIfMissing}); err != nil {
+		t.Errorf("Failed to create new main branch: %v", err)
+	}
+	_, err := repo.Reference(BranchName(g.branch).RefInRemote(), false)
+	if err != nil {
+		t.Errorf("Couldn't find branch %q after opening repository with CreateIfMissing strategy: %v", g.branch, err)
+	}
 }
 
 // TestGitPackageRoundTrip creates a package in git and verifies we can read the contents back.
diff --git a/porch/pkg/googleurl/parser_test.go b/porch/pkg/googleurl/parser_test.go
new file mode 100644
index 00000000..332bd9b3
--- /dev/null
+++ b/porch/pkg/googleurl/parser_test.go
@@ -0,0 +1,97 @@
+// 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 googleurl
+
+import (
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+)
+
+func TestParse(t *testing.T) {
+	grid := []struct {
+		SelfLink string
+		Want     Link
+	}{
+		{
+			SelfLink: "https://container.googleapis.com/v1/projects/justinsb-acp-dev/locations/us-central1/clusters/auto-gke/nodePools/default-pool",
+			Want: Link{
+				Service:  "container.googleapis.com",
+				Version:  "v1",
+				Project:  "justinsb-acp-dev",
+				Location: "us-central1",
+				Extra: map[string]string{
+					"clusters":  "auto-gke",
+					"nodePools": "default-pool",
+				},
+			},
+		},
+		{
+			SelfLink: "//gkehub.googleapis.com/v1/projects/justinsb-acp-dev/locations/global/memberships/example-cluster-1",
+			Want: Link{
+				Service:  "gkehub.googleapis.com",
+				Version:  "v1",
+				Project:  "justinsb-acp-dev",
+				Location: "global",
+				Extra: map[string]string{
+					"memberships": "example-cluster-1",
+				},
+			},
+		},
+	}
+
+	for _, g := range grid {
+		t.Run(g.SelfLink, func(t *testing.T) {
+			got, err := Parse(g.SelfLink)
+			if err != nil {
+				t.Fatalf("unexpected error: %v", err)
+			}
+			if diff := cmp.Diff(g.Want, *got); diff != "" {
+				t.Errorf("unexpected diff (+got,-want): %s", diff)
+			}
+		})
+	}
+}
+
+func TestParseUnversioned(t *testing.T) {
+	grid := []struct {
+		SelfLink string
+		Want     Link
+	}{
+		{
+			SelfLink: "//container.googleapis.com/projects/example-project/locations/us-central1/clusters/example-cluster",
+			Want: Link{
+				Service:  "container.googleapis.com",
+				Project:  "example-project",
+				Location: "us-central1",
+				Extra: map[string]string{
+					"clusters": "example-cluster",
+				},
+			},
+		},
+	}
+
+	for _, g := range grid {
+		t.Run(g.SelfLink, func(t *testing.T) {
+			got, err := ParseUnversioned(g.SelfLink)
+			if err != nil {
+				t.Fatalf("unexpected error: %v", err)
+			}
+			if diff := cmp.Diff(g.Want, *got); diff != "" {
+				t.Errorf("unexpected diff (+got,-want): %s", diff)
+			}
+		})
+	}
+}