Repository: GoogleContainerTools/kpt
Pull request #3664 (2022-11-17 14:56:02)

Add stubs for compiling without wasmtime / without cgo
This allows us to compile kpt without cgo support; wasm support is in
alpha and we have to opt in to wasm support and the wasmtime runner
anyway.

We can likely add an exec-based runner here instead in future (though
we already have the nodejs one).


mortent: Change looks good, but just a few questions:
* This would mean that our regular published binaries will no longer include wasm support. Is the idea that users who want wasm support should build it from source?
* Should we also update the goreleaser spec for this to avoid enabling cgo?
justinsb: > This would mean that our regular published binaries will no longer include wasm support. Is the idea that users who want wasm support should build it from source?

So today if you compile with GCO_ENABLED=0, kpt fails to build.  This is ensuring it will build, and yes, built-in wasm support will not be available.  However, the nodejs support is still available.

I see this as a first step only; we fix a scenario in which the compile fails.  I would like to continue to support wasm functionality even with CGO_ENABLED=0, one way is to build a separate binary that wraps wasmtime.  But that _would_ have to be built with CGO_ENABLED, and we already have nodejs there, so I don't know whether that is a win.

> Should we also update the goreleaser spec for this to avoid enabling cgo?

We can do, particular if cgo is causing other problems, but we don't have to.  I think given that wasm is in alpha, and given that the nodejs support is available also, it's a reasonable thing to do, but I don't think this PR forces things either way (other than giving us the option to run with CGO_ENABLED=0!)

Patch:
diff --git a/commands/live/apply/cmdapply.go b/commands/live/apply/cmdapply.go
index 2f24cc9c..7cfbfd1f 100644
--- a/commands/live/apply/cmdapply.go
+++ b/commands/live/apply/cmdapply.go
@@ -175,6 +175,12 @@ func (r *Runner) runE(c *cobra.Command, args []string) error {
 		return err
 	}
 
+	// objs may contain kind List
+	objs, err = live.Flatten(objs)
+	if err != nil {
+		return err
+	}
+
 	invInfo, err := live.ToInventoryInfo(inv)
 	if err != nil {
 		return err
diff --git a/internal/fnruntime/wasmtime.go b/internal/fnruntime/wasmtime.go
index d522dbb3..473ace3c 100644
--- a/internal/fnruntime/wasmtime.go
+++ b/internal/fnruntime/wasmtime.go
@@ -1,3 +1,6 @@
+//go:build cgo
+// +build cgo
+
 // Copyright 2022 Google LLC
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
diff --git a/internal/fnruntime/wasmtime_unsupported.go b/internal/fnruntime/wasmtime_unsupported.go
new file mode 100644
index 00000000..93623e01
--- /dev/null
+++ b/internal/fnruntime/wasmtime_unsupported.go
@@ -0,0 +1,37 @@
+//go:build !cgo
+// +build !cgo
+
+// 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 fnruntime
+
+// Stub functions for running without wasmtime support compiled in.
+// wasmtime requires cgo, which is not always a viable option.
+
+import (
+	"fmt"
+	"io"
+)
+
+type WasmtimeFn struct {
+}
+
+func NewWasmtimeFn(loader WasmLoader) (*WasmtimeFn, error) {
+	return nil, fmt.Errorf("wasmtime support is not compiled into binary (check cgo settings)")
+}
+
+func (f *WasmtimeFn) Run(r io.Reader, w io.Writer) error {
+	return fmt.Errorf("wasmtime support is not compiled into binary (check cgo settings)")
+}
diff --git a/pkg/live/flatten.go b/pkg/live/flatten.go
new file mode 100644
index 00000000..a3fba6a0
--- /dev/null
+++ b/pkg/live/flatten.go
@@ -0,0 +1,42 @@
+// 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 live
+
+import (
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	"k8s.io/apimachinery/pkg/runtime"
+)
+
+// Flatten returns a list containing 'in' objects with objects of kind List
+// replaced by their members.
+func Flatten(in []*unstructured.Unstructured) ([]*unstructured.Unstructured, error) {
+	var out []*unstructured.Unstructured
+
+	for _, o := range in {
+		if o.IsList() {
+			err := o.EachListItem(func(item runtime.Object) error {
+				item2 := item.(*unstructured.Unstructured)
+				out = append(out, item2)
+				return nil
+			})
+			if err != nil {
+				return nil, err
+			}
+		} else {
+			out = append(out, o)
+		}
+	}
+	return out, nil
+}
diff --git a/porch/Makefile b/porch/Makefile
index 1099f359..9afa6cda 100644
--- a/porch/Makefile
+++ b/porch/Makefile
@@ -48,12 +48,12 @@ TEST_GIT_SERVER_IMAGE ?= test-git-server
 
 # Only enable a subset of reconcilers in porch controllers by default. Use the RECONCILERS
 # env variable to specify a specific list of reconcilers or use
-# RECONCILERS=all to enable all known reconcilers.
-ALL_RECONCILERS="rootsyncsets,remoterootsyncsets,workloadidentitybindings"
+# RECONCILERS=* to enable all known reconcilers.
+ALL_RECONCILERS="rootsyncsets,remoterootsyncsets,workloadidentitybindings,rootsyncdeployments"
 ifndef RECONCILERS
   ENABLED_RECONCILERS="rootsyncsets,remoterootsyncsets,workloadidentitybindings"
 else
-  ifeq ($(RECONCILERS),all)
+  ifeq ($(RECONCILERS),*)
     ENABLED_RECONCILERS=${ALL_RECONCILERS}
   else
     ENABLED_RECONCILERS=$(RECONCILERS)
diff --git a/porch/api/generated/clientset/versioned/fake/register.go b/porch/api/generated/clientset/versioned/fake/register.go
index 36b9da3c..2b1d2df7 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 cee00d25..90fc1da2 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 d4f9b63e..fdc8491e 100644
--- a/porch/api/generated/openapi/zz_generated.openapi.go
+++ b/porch/api/generated/openapi/zz_generated.openapi.go
@@ -767,7 +767,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:      "",
 						},
@@ -815,7 +815,7 @@ func schema_porch_api_porch_v1alpha1_PackageInitTaskSpec(ref common.ReferenceCal
 					},
 					"description": {
 						SchemaProps: spec.SchemaProps{
-							Description: "`WorkspaceName` is a short description of the package.",
+							Description: "`Description` is a short description of the package.",
 							Type:        []string{"string"},
 							Format:      "",
 						},
@@ -2476,7 +2476,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:      "",
 						},
@@ -2989,7 +2989,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:      "",
 						},
@@ -3141,7 +3141,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"),
 						},
 					},
@@ -3201,7 +3201,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:      "",
 						},
@@ -3215,7 +3215,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:      "",
 						},
@@ -3333,6 +3333,13 @@ func schema_pkg_apis_meta_v1_ObjectMeta(ref common.ReferenceCallback) common.Ope
 							},
 						},
 					},
+					"clusterName": {
+						SchemaProps: spec.SchemaProps{
+							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:      "",
+						},
+					},
 					"managedFields": {
 						SchemaProps: spec.SchemaProps{
 							Description: "ManagedFields maps workflow-id and version to the set of fields that are managed by that workflow. This is mostly for internal housekeeping, and users typically shouldn't need to set or understand this field. A workflow can be the user's name, a controller's name, or the name of a specific apply path like \"ci-cd\". The set of fields is always in the version that the workflow used when modifying the object.",
@@ -3403,7 +3410,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:      "",
 						},
@@ -3570,7 +3577,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:      "",
 						},
@@ -4197,7 +4204,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:      "",
 						},
@@ -4242,7 +4249,7 @@ func schema_k8sio_apimachinery_pkg_runtime_RawExtension(ref common.ReferenceCall
 	return common.OpenAPIDefinition{
 		Schema: spec.Schema{
 			SchemaProps: spec.SchemaProps{
-				Description: "RawExtension is used to hold extensions in external versions.\n\nTo use this, make a field which has RawExtension as its type in your external, versioned struct, and Object in your internal struct. You also need to register your various plugin types.\n\n// Internal package:\n\n\ttype MyAPIObject struct {\n\t\truntime.TypeMeta `json:\",inline\"`\n\t\tMyPlugin runtime.Object `json:\"myPlugin\"`\n\t}\n\n\ttype PluginA struct {\n\t\tAOption string `json:\"aOption\"`\n\t}\n\n// External package:\n\n\ttype MyAPIObject struct {\n\t\truntime.TypeMeta `json:\",inline\"`\n\t\tMyPlugin runtime.RawExtension `json:\"myPlugin\"`\n\t}\n\n\ttype PluginA struct {\n\t\tAOption string `json:\"aOption\"`\n\t}\n\n// On the wire, the JSON will look something like this:\n\n\t{\n\t\t\"kind\":\"MyAPIObject\",\n\t\t\"apiVersion\":\"v1\",\n\t\t\"myPlugin\": {\n\t\t\t\"kind\":\"PluginA\",\n\t\t\t\"aOption\":\"foo\",\n\t\t},\n\t}\n\nSo what happens? Decode first uses json or yaml to unmarshal the serialized data into your external MyAPIObject. That causes the raw JSON to be stored, but not unpacked. The next step is to copy (using pkg/conversion) into the internal struct. The runtime package's DefaultScheme has conversion functions installed which will unpack the JSON stored in RawExtension, turning it into the correct object type, and storing it in the Object. (TODO: In the case where the object is of an unknown type, a runtime.Unknown object will be created and stored.)",
+				Description: "RawExtension is used to hold extensions in external versions.\n\nTo use this, make a field which has RawExtension as its type in your external, versioned struct, and Object in your internal struct. You also need to register your various plugin types.\n\n// Internal package: type MyAPIObject struct {\n\truntime.TypeMeta `json:\",inline\"`\n\tMyPlugin runtime.Object `json:\"myPlugin\"`\n} type PluginA struct {\n\tAOption string `json:\"aOption\"`\n}\n\n// External package: type MyAPIObject struct {\n\truntime.TypeMeta `json:\",inline\"`\n\tMyPlugin runtime.RawExtension `json:\"myPlugin\"`\n} type PluginA struct {\n\tAOption string `json:\"aOption\"`\n}\n\n// On the wire, the JSON will look something like this: {\n\t\"kind\":\"MyAPIObject\",\n\t\"apiVersion\":\"v1\",\n\t\"myPlugin\": {\n\t\t\"kind\":\"PluginA\",\n\t\t\"aOption\":\"foo\",\n\t},\n}\n\nSo what happens? Decode first uses json or yaml to unmarshal the serialized data into your external MyAPIObject. That causes the raw JSON to be stored, but not unpacked. The next step is to copy (using pkg/conversion) into the internal struct. The runtime package's DefaultScheme has conversion functions installed which will unpack the JSON stored in RawExtension, turning it into the correct object type, and storing it in the Object. (TODO: In the case where the object is of an unknown type, a runtime.Unknown object will be created and stored.)",
 				Type:        []string{"object"},
 			},
 		},
@@ -4253,7 +4260,7 @@ func schema_k8sio_apimachinery_pkg_runtime_TypeMeta(ref common.ReferenceCallback
 	return common.OpenAPIDefinition{
 		Schema: spec.Schema{
 			SchemaProps: spec.SchemaProps{
-				Description: "TypeMeta is shared by all top level objects. The proper way to use it is to inline it in your type, like this:\n\n\ttype MyAwesomeAPIObject struct {\n\t     runtime.TypeMeta    `json:\",inline\"`\n\t     ... // other fields\n\t}\n\nfunc (obj *MyAwesomeAPIObject) SetGroupVersionKind(gvk *metav1.GroupVersionKind) { metav1.UpdateTypeMeta(obj,gvk) }; GroupVersionKind() *GroupVersionKind\n\nTypeMeta is provided here for convenience. You may use it directly from this package or define your own with the same fields.",
+				Description: "TypeMeta is shared by all top level objects. The proper way to use it is to inline it in your type, like this: type MyAwesomeAPIObject struct {\n     runtime.TypeMeta    `json:\",inline\"`\n     ... // other fields\n} func (obj *MyAwesomeAPIObject) SetGroupVersionKind(gvk *metav1.GroupVersionKind) { metav1.UpdateTypeMeta(obj,gvk) }; GroupVersionKind() *GroupVersionKind\n\nTypeMeta is provided here for convenience. You may use it directly from this package or define your own with the same fields.",
 				Type:        []string{"object"},
 				Properties: map[string]spec.Schema{
 					"apiVersion": {
diff --git a/porch/controllers/config/crd/bases/config.porch.kpt.dev_rootsyncdeployments.yaml b/porch/controllers/config/crd/bases/config.porch.kpt.dev_rootsyncdeployments.yaml
new file mode 100644
index 00000000..132d4d36
--- /dev/null
+++ b/porch/controllers/config/crd/bases/config.porch.kpt.dev_rootsyncdeployments.yaml
@@ -0,0 +1,214 @@
+# 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: rootsyncdeployments.config.porch.kpt.dev
+spec:
+  group: config.porch.kpt.dev
+  names:
+    kind: RootSyncDeployment
+    listKind: RootSyncDeploymentList
+    plural: rootsyncdeployments
+    singular: rootsyncdeployment
+  scope: Namespaced
+  versions:
+  - name: v1alpha1
+    schema:
+      openAPIV3Schema:
+        description: RootSyncDeployment is the Schema for the rootsyncdeployments
+          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: RootSyncDeploymentSpec defines the desired state of RootSyncDeployment
+            properties:
+              packageRevision:
+                properties:
+                  name:
+                    type: string
+                  namespace:
+                    type: string
+                type: object
+              targets:
+                properties:
+                  selector:
+                    description: A label selector is a label query over a set of resources.
+                      The result of matchLabels and matchExpressions are ANDed. An
+                      empty label selector matches all objects. A null label selector
+                      matches no objects.
+                    properties:
+                      matchExpressions:
+                        description: matchExpressions is a list of label selector
+                          requirements. The requirements are ANDed.
+                        items:
+                          description: A label selector requirement is a selector
+                            that contains values, a key, and an operator that relates
+                            the key and values.
+                          properties:
+                            key:
+                              description: key is the label key that the selector
+                                applies to.
+                              type: string
+                            operator:
+                              description: operator represents a key's relationship
+                                to a set of values. Valid operators are In, NotIn,
+                                Exists and DoesNotExist.
+                              type: string
+                            values:
+                              description: values is an array of string values. If
+                                the operator is In or NotIn, the values array must
+                                be non-empty. If the operator is Exists or DoesNotExist,
+                                the values array must be empty. This array is replaced
+                                during a strategic merge patch.
+                              items:
+                                type: string
+                              type: array
+                          required:
+                          - key
+                          - operator
+                          type: object
+                        type: array
+                      matchLabels:
+                        additionalProperties:
+                          type: string
+                        description: matchLabels is a map of {key,value} pairs. A
+                          single {key,value} in the matchLabels map is equivalent
+                          to an element of matchExpressions, whose key field is "key",
+                          the operator is "In", and the values array contains only
+                          "value". The requirements are ANDed.
+                        type: object
+                    type: object
+                type: object
+            type: object
+          status:
+            description: RootSyncDeploymentStatus defines the observed state of RootSyncDeployment
+            properties:
+              clusterRefStatuses:
+                items:
+                  properties:
+                    apiVersion:
+                      type: string
+                    kind:
+                      type: string
+                    name:
+                      type: string
+                    namespace:
+                      type: string
+                    revision:
+                      type: string
+                    synced:
+                      type: boolean
+                  required:
+                  - synced
+                  type: object
+                type: array
+              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/main.go b/porch/controllers/main.go
index 90371989..d8959bf9 100644
--- a/porch/controllers/main.go
+++ b/porch/controllers/main.go
@@ -41,6 +41,7 @@ import (
 
 	"github.com/GoogleContainerTools/kpt/porch/controllers/klippy/pkg/controllers/klippy"
 	"github.com/GoogleContainerTools/kpt/porch/controllers/remoterootsyncsets/pkg/controllers/remoterootsyncset"
+	"github.com/GoogleContainerTools/kpt/porch/controllers/rootsyncdeployments/pkg/controllers/rootsyncdeployment"
 	"github.com/GoogleContainerTools/kpt/porch/controllers/rootsyncsets/pkg/controllers/rootsyncset"
 	"github.com/GoogleContainerTools/kpt/porch/controllers/workloadidentitybindings/pkg/controllers/workloadidentitybinding"
 	"github.com/GoogleContainerTools/kpt/porch/pkg/controllerrestmapper"
@@ -53,6 +54,7 @@ var (
 		"remoterootsyncsets":       &remoterootsyncset.RemoteRootSyncSetReconciler{},
 		"workloadidentitybindings": &workloadidentitybinding.WorkloadIdentityBindingReconciler{},
 		"klippy":                   &klippy.KlippyReconciler{},
+		"rootsyncdeployments":      rootsyncdeployment.NewRootSyncDeploymentReconciler(),
 	}
 )
 
@@ -100,7 +102,7 @@ func run(ctx context.Context) error {
 	// flag.BoolVar(&enableLeaderElection, "leader-elect", false,
 	// 	"Enable leader election for controller manager. "+
 	// 		"Enabling this will ensure there is only one active controller manager.")
-	flag.StringVar(&enabledReconcilersString, "reconcilers", "*", "reconcilers that should be enabled; use * to mean 'enable all'")
+	flag.StringVar(&enabledReconcilersString, "reconcilers", "", "reconcilers that should be enabled; use * to mean 'enable all'")
 
 	for name, reconciler := range reconcilers {
 		reconciler.BindFlags(name+".", flag.CommandLine)
diff --git a/porch/controllers/rootsyncdeployments/api/v1alpha1/groupversion_info.go b/porch/controllers/rootsyncdeployments/api/v1alpha1/groupversion_info.go
new file mode 100644
index 00000000..6866e810
--- /dev/null
+++ b/porch/controllers/rootsyncdeployments/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.porch.kpt.dev v1alpha1 API group
+//+kubebuilder:object:generate=true
+//+groupName=config.porch.kpt.dev
+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.porch.kpt.dev", 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/rootsyncdeployments/api/v1alpha1/rootsyncdeployments_types.go b/porch/controllers/rootsyncdeployments/api/v1alpha1/rootsyncdeployments_types.go
new file mode 100644
index 00000000..d8c03f19
--- /dev/null
+++ b/porch/controllers/rootsyncdeployments/api/v1alpha1/rootsyncdeployments_types.go
@@ -0,0 +1,76 @@
+// 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"
+)
+
+// RootSyncDeploymentSpec defines the desired state of RootSyncDeployment
+type RootSyncDeploymentSpec struct {
+	Targets         ClusterTargetSelector `json:"targets,omitempty"`
+	PackageRevision PackageRevisionRef    `json:"packageRevision,omitempty"`
+}
+
+type ClusterTargetSelector struct {
+	Selector *metav1.LabelSelector `json:"selector,omitempty"`
+}
+
+type PackageRevisionRef struct {
+	Name      string `json:"name,omitempty"`
+	Namespace string `json:"namespace,omitempty"`
+}
+
+// RootSyncDeploymentStatus defines the observed state of RootSyncDeployment
+type RootSyncDeploymentStatus struct {
+	// Conditions describes the reconciliation state of the object.
+	Conditions []metav1.Condition `json:"conditions,omitempty"`
+
+	ClusterRefStatuses []ClusterRefStatus `json:"clusterRefStatuses,omitempty"`
+}
+
+type ClusterRefStatus struct {
+	ApiVersion string `json:"apiVersion,omitempty"`
+	Kind       string `json:"kind,omitempty"`
+	Name       string `json:"name,omitempty"`
+	Namespace  string `json:"namespace,omitempty"`
+	Revision   string `json:"revision,omitempty"`
+	Synced     bool   `json:"synced"`
+}
+
+//+kubebuilder:object:root=true
+//+kubebuilder:subresource:status
+
+// RootSyncDeployment is the Schema for the rootsyncdeployments API
+type RootSyncDeployment struct {
+	metav1.TypeMeta   `json:",inline"`
+	metav1.ObjectMeta `json:"metadata,omitempty"`
+
+	Spec   RootSyncDeploymentSpec   `json:"spec,omitempty"`
+	Status RootSyncDeploymentStatus `json:"status,omitempty"`
+}
+
+//+kubebuilder:object:root=true
+
+// RootSyncDeploymentList contains a list of RootSyncDeployment
+type RootSyncDeploymentList struct {
+	metav1.TypeMeta `json:",inline"`
+	metav1.ListMeta `json:"metadata,omitempty"`
+	Items           []RootSyncDeployment `json:"items"`
+}
+
+func init() {
+	SchemeBuilder.Register(&RootSyncDeployment{}, &RootSyncDeploymentList{})
+}
diff --git a/porch/controllers/rootsyncdeployments/api/v1alpha1/zz_generated.deepcopy.go b/porch/controllers/rootsyncdeployments/api/v1alpha1/zz_generated.deepcopy.go
new file mode 100644
index 00000000..cde5cc78
--- /dev/null
+++ b/porch/controllers/rootsyncdeployments/api/v1alpha1/zz_generated.deepcopy.go
@@ -0,0 +1,178 @@
+//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 *ClusterRefStatus) DeepCopyInto(out *ClusterRefStatus) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterRefStatus.
+func (in *ClusterRefStatus) DeepCopy() *ClusterRefStatus {
+	if in == nil {
+		return nil
+	}
+	out := new(ClusterRefStatus)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ClusterTargetSelector) DeepCopyInto(out *ClusterTargetSelector) {
+	*out = *in
+	if in.Selector != nil {
+		in, out := &in.Selector, &out.Selector
+		*out = new(v1.LabelSelector)
+		(*in).DeepCopyInto(*out)
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterTargetSelector.
+func (in *ClusterTargetSelector) DeepCopy() *ClusterTargetSelector {
+	if in == nil {
+		return nil
+	}
+	out := new(ClusterTargetSelector)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *PackageRevisionRef) DeepCopyInto(out *PackageRevisionRef) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PackageRevisionRef.
+func (in *PackageRevisionRef) DeepCopy() *PackageRevisionRef {
+	if in == nil {
+		return nil
+	}
+	out := new(PackageRevisionRef)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *RootSyncDeployment) DeepCopyInto(out *RootSyncDeployment) {
+	*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 RootSyncDeployment.
+func (in *RootSyncDeployment) DeepCopy() *RootSyncDeployment {
+	if in == nil {
+		return nil
+	}
+	out := new(RootSyncDeployment)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *RootSyncDeployment) 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 *RootSyncDeploymentList) DeepCopyInto(out *RootSyncDeploymentList) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ListMeta.DeepCopyInto(&out.ListMeta)
+	if in.Items != nil {
+		in, out := &in.Items, &out.Items
+		*out = make([]RootSyncDeployment, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RootSyncDeploymentList.
+func (in *RootSyncDeploymentList) DeepCopy() *RootSyncDeploymentList {
+	if in == nil {
+		return nil
+	}
+	out := new(RootSyncDeploymentList)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *RootSyncDeploymentList) 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 *RootSyncDeploymentSpec) DeepCopyInto(out *RootSyncDeploymentSpec) {
+	*out = *in
+	in.Targets.DeepCopyInto(&out.Targets)
+	out.PackageRevision = in.PackageRevision
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RootSyncDeploymentSpec.
+func (in *RootSyncDeploymentSpec) DeepCopy() *RootSyncDeploymentSpec {
+	if in == nil {
+		return nil
+	}
+	out := new(RootSyncDeploymentSpec)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *RootSyncDeploymentStatus) DeepCopyInto(out *RootSyncDeploymentStatus) {
+	*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])
+		}
+	}
+	if in.ClusterRefStatuses != nil {
+		in, out := &in.ClusterRefStatuses, &out.ClusterRefStatuses
+		*out = make([]ClusterRefStatus, len(*in))
+		copy(*out, *in)
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RootSyncDeploymentStatus.
+func (in *RootSyncDeploymentStatus) DeepCopy() *RootSyncDeploymentStatus {
+	if in == nil {
+		return nil
+	}
+	out := new(RootSyncDeploymentStatus)
+	in.DeepCopyInto(out)
+	return out
+}
diff --git a/porch/controllers/rootsyncdeployments/config/rbac/role.yaml b/porch/controllers/rootsyncdeployments/config/rbac/role.yaml
new file mode 100644
index 00000000..0a6010d8
--- /dev/null
+++ b/porch/controllers/rootsyncdeployments/config/rbac/role.yaml
@@ -0,0 +1,79 @@
+# 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: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+  creationTimestamp: null
+  name: porch-controllers-rootsyncdeployments
+rules:
+- apiGroups:
+  - config.porch.kpt.dev
+  resources:
+  - repositories
+  verbs:
+  - get
+  - list
+  - watch
+- apiGroups:
+  - config.porch.kpt.dev
+  resources:
+  - rootsyncdeployments
+  verbs:
+  - create
+  - delete
+  - get
+  - list
+  - patch
+  - update
+  - watch
+- apiGroups:
+  - config.porch.kpt.dev
+  resources:
+  - rootsyncdeployments/finalizers
+  verbs:
+  - update
+- apiGroups:
+  - config.porch.kpt.dev
+  resources:
+  - rootsyncdeployments/status
+  verbs:
+  - get
+  - patch
+  - update
+- apiGroups:
+  - configcontroller.cnrm.cloud.google.com
+  resources:
+  - configcontrollerinstances
+  verbs:
+  - get
+  - list
+  - watch
+- apiGroups:
+  - container.cnrm.cloud.google.com
+  resources:
+  - containerclusters
+  verbs:
+  - get
+  - list
+  - watch
+- apiGroups:
+  - porch.kpt.dev
+  resources:
+  - packagerevisions
+  verbs:
+  - get
+  - list
+  - watch
diff --git a/porch/controllers/rootsyncdeployments/config/rbac/rolebinding.yaml b/porch/controllers/rootsyncdeployments/config/rbac/rolebinding.yaml
new file mode 100644
index 00000000..935c8904
--- /dev/null
+++ b/porch/controllers/rootsyncdeployments/config/rbac/rolebinding.yaml
@@ -0,0 +1,26 @@
+# 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: rbac.authorization.k8s.io/v1
+kind: ClusterRoleBinding
+metadata:
+  name: porch-system:porch-controllers-rootsyncdeployments
+roleRef:
+  apiGroup: rbac.authorization.k8s.io
+  kind: ClusterRole
+  name: porch-controllers-rootsyncdeployments
+subjects:
+- kind: ServiceAccount
+  name: porch-controllers
+  namespace: porch-system
\ No newline at end of file
diff --git a/porch/controllers/rootsyncdeployments/config/samples/README.md b/porch/controllers/rootsyncdeployments/config/samples/README.md
new file mode 100644
index 00000000..9b2d1f1c
--- /dev/null
+++ b/porch/controllers/rootsyncdeployments/config/samples/README.md
@@ -0,0 +1,118 @@
+# Simple example
+
+This example adds a RootSyncDeployment with name `simple` that will deploy the referenced
+PackageRevision to all GKE or Config Controller clusters with the `foo: bar` label. When the
+version of the PackageRevision is changed, the clusters will be updated progressively.
+
+## Setup
+
+### Clusters
+Create clusters with Config Connector using the following two manifests:
+```
+apiVersion: container.cnrm.cloud.google.com/v1beta1
+kind: ContainerCluster
+metadata:
+  name: gke-one
+  namespace: config-control
+spec:
+  location: us-central1
+  initialNodeCount: 1
+  workloadIdentityConfig:
+    workloadPool: ${PROJECT-ID}.svc.id.goog
+```
+
+```
+apiVersion: container.cnrm.cloud.google.com/v1beta1
+kind: ContainerCluster
+metadata:
+  name: gke-two
+  namespace: config-control
+spec:
+  location: us-central1
+  initialNodeCount: 1
+  workloadIdentityConfig:
+    workloadPool: ${PROJECT-ID}.svc.id.goog
+```
+
+Install Config Management through the Google Cloud console to make sure
+Config Sync is available in the cluster.
+
+### Porch package
+Make sure you have a repository registered with Porch (this example assumes the repository is named `blueprint`). Create a new package `foo`, add a ConfigMap to the package, and publish it:
+```
+kpt alpha rpkg init foo --repository=blueprint -n default --workspace=foo
+kpt alpha rpkg pull blueprint-16f93511a8fd4774c928e09a7e135f9852161f74 -n default ./pull
+cat <<EOF >>./pull/cm.yaml
+apiVersion: v1
+kind: ConfigMap
+metadata:
+  name: foo
+  namespace: default
+data:
+  cm: foo
+EOF
+kpt alpha rpkg push blueprint-16f93511a8fd4774c928e09a7e135f9852161f74 -n default ./pull
+kpt alpha rpkg propose blueprint-16f93511a8fd4774c928e09a7e135f9852161f74 -n default
+kpt alpha rpkg approve blueprint-16f93511a8fd4774c928e09a7e135f9852161f74 -n default
+rm -fr ./pull
+```
+
+Then create a new revision of the package and publish it.
+```
+kpt alpha rpkg edit blueprint-16f93511a8fd4774c928e09a7e135f9852161f74 -n default --workspace foo2
+kpt alpha rpkg pull blueprint-93dabbfa9de63b507d13d366185a70ee2ed087f7 -n default ./pull
+cat <<EOF >./pull/cm.yaml
+apiVersion: v1
+kind: ConfigMap
+metadata:
+  name: foo2
+  namespace: default
+data:
+  cm: foo2
+EOF
+kpt alpha rpkg push blueprint-93dabbfa9de63b507d13d366185a70ee2ed087f7 -n default ./pull
+kpt alpha rpkg propose blueprint-93dabbfa9de63b507d13d366185a70ee2ed087f7 -n default
+kpt alpha rpkg approve blueprint-93dabbfa9de63b507d13d366185a70ee2ed087f7 -n default
+rm -fr ./pull
+```
+
+## Running the example
+Update the name of the PackageRevision in the `simple.yaml` file to point to the PackageRevision
+you want to deploy (if you ended up with different names than the example)
+
+Label the first GKE cluster with the required labels:
+```
+kubectl -n config-control label containerclusters.container.cnrm.cloud.google.com gke-one foo=bar
+```
+
+Apply the RootSyncDeployment to install the PackageRevision in the gke-one cluster:
+```
+kubectl apply -f simple.yaml
+```
+
+Watch the progress:
+```
+k get rootsyncdeployments.config.porch.kpt.dev simple -oyaml -w
+```
+
+Label the second cluster as well:
+```
+kubectl -n config-control label containerclusters.container.cnrm.cloud.google.com gke-two foo=bar
+```
+
+Update the RootSyncDeployment spec to point to the next revision of the Package:
+```
+spec:
+  packageRevision:
+    name: blueprint-93dabbfa9de63b507d13d366185a70ee2ed087f7
+```
+
+Apply it again:
+```
+kubectl apply -f simple.yaml
+```
+
+Watch the progress:
+```
+k get rootsyncdeployments.config.porch.kpt.dev simple -oyaml -w
+```
diff --git a/porch/controllers/rootsyncdeployments/config/samples/simple.yaml b/porch/controllers/rootsyncdeployments/config/samples/simple.yaml
new file mode 100644
index 00000000..cc752067
--- /dev/null
+++ b/porch/controllers/rootsyncdeployments/config/samples/simple.yaml
@@ -0,0 +1,27 @@
+# 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: config.porch.kpt.dev/v1alpha1
+kind: RootSyncDeployment
+metadata:
+  name: simple
+  namespace: default
+spec:
+  targets:
+    selector:
+      matchLabels:
+        foo: bar
+  packageRevision:
+    name: blueprint-16f93511a8fd4774c928e09a7e135f9852161f74
+    namespace: default
\ No newline at end of file
diff --git a/porch/controllers/rootsyncdeployments/pkg/controllers/rootsyncdeployment/controller.go b/porch/controllers/rootsyncdeployments/pkg/controllers/rootsyncdeployment/controller.go
new file mode 100644
index 00000000..0ba6c2ec
--- /dev/null
+++ b/porch/controllers/rootsyncdeployments/pkg/controllers/rootsyncdeployment/controller.go
@@ -0,0 +1,517 @@
+// 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 rootsyncdeployment
+
+import (
+	"context"
+	"flag"
+	"fmt"
+	"sync"
+
+	porchapi "github.com/GoogleContainerTools/kpt/porch/api/porch/v1alpha1"
+	configapi "github.com/GoogleContainerTools/kpt/porch/api/porchconfig/v1alpha1"
+	"github.com/GoogleContainerTools/kpt/porch/controllers/rootsyncdeployments/api/v1alpha1"
+	rssapi "github.com/GoogleContainerTools/kpt/porch/controllers/rootsyncsets/api/v1alpha1"
+	"k8s.io/apimachinery/pkg/api/equality"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	"k8s.io/apimachinery/pkg/labels"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+	"k8s.io/apimachinery/pkg/types"
+	"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"
+	"sigs.k8s.io/controller-runtime/pkg/handler"
+	"sigs.k8s.io/controller-runtime/pkg/reconcile"
+	"sigs.k8s.io/controller-runtime/pkg/source"
+)
+
+const (
+	rootSyncSetLabel = "config.porch.kpt.dev/rootsyncdeployment"
+)
+
+var (
+	configConnectorContainerClusterGVK = schema.GroupVersionKind{
+		Group:   "container.cnrm.cloud.google.com",
+		Version: "v1beta1",
+		Kind:    "ContainerCluster",
+	}
+	configConnectorConfigControllerClusterGVK = schema.GroupVersionKind{
+		Group:   "configcontroller.cnrm.cloud.google.com",
+		Version: "v1beta1",
+		Kind:    "ConfigControllerInstance",
+	}
+	rootSyncSetGVK = schema.GroupVersionKind{
+		Group:   "config.porch.kpt.dev",
+		Version: "v1alpha1",
+		Kind:    "RootSyncSet",
+	}
+)
+
+type Options struct {
+}
+
+func (o *Options) InitDefaults() {
+}
+
+func (o *Options) BindFlags(prefix string, flags *flag.FlagSet) {
+}
+
+func NewRootSyncDeploymentReconciler() *RootSyncDeploymentReconciler {
+	return &RootSyncDeploymentReconciler{
+		clusterTargetCache: make(map[types.NamespacedName]labels.Selector),
+	}
+}
+
+// RootSyncDeploymentReconciler reconciles a RootSyncDeployment object
+type RootSyncDeploymentReconciler struct {
+	Options
+
+	client.Client
+
+	mutex              sync.Mutex
+	clusterTargetCache map[types.NamespacedName]labels.Selector
+}
+
+//go:generate go run sigs.k8s.io/controller-tools/cmd/controller-gen@v0.8.0 rbac:roleName=porch-controllers-rootsyncdeployments webhook paths="." output:rbac:artifacts:config=../../../config/rbac
+
+//+kubebuilder:rbac:groups=config.porch.kpt.dev,resources=rootsyncdeployments,verbs=get;list;watch;create;update;patch;delete
+//+kubebuilder:rbac:groups=config.porch.kpt.dev,resources=rootsyncdeployments/status,verbs=get;update;patch
+//+kubebuilder:rbac:groups=config.porch.kpt.dev,resources=rootsyncdeployments/finalizers,verbs=update
+//+kubebuilder:rbac:groups=porch.kpt.dev,resources=packagerevisions,verbs=get;list;watch
+//+kubebuilder:rbac:groups=config.porch.kpt.dev,resources=repositories,verbs=get;list;watch
+//+kubebuilder:rbac:groups=configcontroller.cnrm.cloud.google.com,resources=configcontrollerinstances,verbs=get;list;watch
+//+kubebuilder:rbac:groups=container.cnrm.cloud.google.com,resources=containerclusters,verbs=get;list;watch
+
+func (r *RootSyncDeploymentReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
+	var rootsyncdeployment v1alpha1.RootSyncDeployment
+	if err := r.Get(ctx, req.NamespacedName, &rootsyncdeployment); err != nil {
+		return ctrl.Result{}, client.IgnoreNotFound(err)
+	}
+
+	myFinalizerName := "config.porch.kpt.dev/rootsyncdeployments"
+	if rootsyncdeployment.ObjectMeta.DeletionTimestamp.IsZero() {
+		// Update the cache with mapping from rollouts to ClusterTargets. It allows the controller
+		// to determine which Rollouts needs to be reconciled based on an event about a cluster.
+		selector, err := metav1.LabelSelectorAsSelector(rootsyncdeployment.Spec.Targets.Selector)
+		if err != nil {
+			return ctrl.Result{}, fmt.Errorf("error converting targets selector to labels.Selector: %v", err)
+		}
+		func() {
+			r.mutex.Lock()
+			defer r.mutex.Unlock()
+			r.clusterTargetCache[req.NamespacedName] = selector
+		}()
+
+		// 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(&rootsyncdeployment, myFinalizerName) {
+			controllerutil.AddFinalizer(&rootsyncdeployment, myFinalizerName)
+			if err := r.Update(ctx, &rootsyncdeployment); err != nil {
+				return ctrl.Result{}, fmt.Errorf("error adding finalizer: %w", err)
+			}
+		}
+	} else {
+		// Clean up the cache.
+		func() {
+			r.mutex.Lock()
+			defer r.mutex.Unlock()
+			delete(r.clusterTargetCache, req.NamespacedName)
+		}()
+
+		// The object is being deleted
+		if controllerutil.ContainsFinalizer(&rootsyncdeployment, myFinalizerName) {
+			// remove our finalizer from the list and update it.
+			controllerutil.RemoveFinalizer(&rootsyncdeployment, myFinalizerName)
+			if err := r.Update(ctx, &rootsyncdeployment); 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
+	}
+
+	res, err := r.syncRootSyncDeployment(ctx, &rootsyncdeployment)
+	if err != nil {
+		return ctrl.Result{}, err
+	}
+
+	if err := r.updateStatus(ctx, &rootsyncdeployment, res); err != nil {
+		return ctrl.Result{}, err
+	}
+
+	return ctrl.Result{}, nil
+}
+
+func (r *RootSyncDeploymentReconciler) updateStatus(ctx context.Context, rds *v1alpha1.RootSyncDeployment, clusterRefStatuses []v1alpha1.ClusterRefStatus) error {
+	if equality.Semantic.DeepEqual(rds.Status.ClusterRefStatuses, clusterRefStatuses) {
+		klog.Infof("Status has not changed, update not needed.")
+		return nil
+	}
+	rds.Status.ClusterRefStatuses = clusterRefStatuses
+	return r.Status().Update(ctx, rds)
+}
+
+func (r *RootSyncDeploymentReconciler) syncRootSyncDeployment(ctx context.Context, rsd *v1alpha1.RootSyncDeployment) ([]v1alpha1.ClusterRefStatus, error) {
+	var clusterRefStatuses []v1alpha1.ClusterRefStatus
+	namespace := rsd.Spec.PackageRevision.Namespace
+	if namespace == "" {
+		namespace = rsd.Namespace
+	}
+	nn := types.NamespacedName{
+		Name:      rsd.Spec.PackageRevision.Name,
+		Namespace: namespace,
+	}
+
+	pkgRev, err := r.getPackageRevision(ctx, nn)
+	if err != nil {
+		return clusterRefStatuses, err
+	}
+
+	repo, err := r.getRepository(ctx, types.NamespacedName{
+		Name:      pkgRev.Spec.RepositoryName,
+		Namespace: pkgRev.Namespace,
+	})
+	if err != nil {
+		return clusterRefStatuses, err
+	}
+
+	rootSyncSetSpec := toRootSyncSpec(pkgRev, repo)
+
+	clusters, err := r.getClusters(ctx, rsd.Spec.Targets.Selector)
+	if err != nil {
+		return clusterRefStatuses, err
+	}
+	klog.Infof("Found clusters: %s", toClusterNames(clusters))
+
+	rootsyncsets, err := r.listRootSyncSets(ctx, rsd.Name, rsd.Namespace)
+	if err != nil {
+		return clusterRefStatuses, err
+	}
+	klog.Infof("Found RootSyncSets: %s", toRootSyncSetNames(rootsyncsets))
+
+	var clustersMissingRootSync []rssapi.ClusterRef
+	var clustersToDeleteRootSync []*rssapi.RootSyncSet
+	var clustersNeedingUpdate []*rssapi.RootSyncSet
+	var clustersBeingUpdated []*rssapi.RootSyncSet
+	var upToDateClusters []*rssapi.RootSyncSet
+
+	for i := range rootsyncsets {
+		rss := rootsyncsets[i]
+
+		// TODO: See if we can remove this constraint. But it make things easier for now.
+		// First remove RootSyncSets for clusters where it should no longer exist.
+		if l := len(rss.Spec.ClusterRefs); l != 1 {
+			return clusterRefStatuses,
+				fmt.Errorf("RootSyncSet %s contains %d ClusterRefs, but it should be 1", rss.Name, l)
+		}
+
+		// If the RootSyncSet references a cluster that is no longer matches by the target selector,
+		// we need to delete the RootSyncSet.
+		clusterRef := rss.Spec.ClusterRefs[0]
+		if !contains(clusters, clusterRef) {
+			clustersToDeleteRootSync = append(clustersToDeleteRootSync, rss)
+			continue
+		}
+
+		// If the RootSync template in the RootSyncSet doesn't match the spec from the
+		// PackageRevision, it needs to be updated.
+		if !equality.Semantic.DeepEqual(rootSyncSetSpec, rss.Spec.Template.Spec) {
+			clustersNeedingUpdate = append(clustersNeedingUpdate, rss)
+			continue
+		}
+
+		// If the RootSyncSet is not synced, we treat them as being the process of being updated.
+		// This might not be correct in all situations.
+		if !isRssSynced(rss) {
+			clustersBeingUpdated = append(clustersBeingUpdated, rss)
+			continue
+		}
+		upToDateClusters = append(upToDateClusters, rss)
+	}
+
+	// Find any clusters that are targeted by the label selector, but
+	// where we don't have a RootSyncSet referencing the cluster.
+	for i := range clusters {
+		cluster := clusters[i]
+		ref := rssapi.ClusterRef{
+			Name:       cluster.GetName(),
+			Namespace:  cluster.GetNamespace(),
+			ApiVersion: cluster.GetAPIVersion(),
+			Kind:       cluster.GetKind(),
+		}
+		var found bool
+		for _, rss := range rootsyncsets {
+			rssClusterRef := *rss.Spec.ClusterRefs[0]
+			if equality.Semantic.DeepEqual(rssClusterRef, ref) {
+				found = true
+			}
+		}
+		if !found {
+			clustersMissingRootSync = append(clustersMissingRootSync, ref)
+		}
+	}
+
+	// TODO: We should keep track of the clusters and status until we know they have been removed
+	// from the cluster.
+	for _, rss := range clustersToDeleteRootSync {
+		if err := r.Delete(ctx, rss); err != nil {
+			klog.Warningf("Error removing RootSyncSet %s: %v", rss.Name, err)
+		}
+	}
+
+	// If we have clusters that doesn't already have the package installed, we just
+	// update them all. The limit to the number of clusters being updated
+	// concurrently (currently that is 1) does not apply here.
+	if len(clustersMissingRootSync) > 0 {
+		for _, cr := range clustersMissingRootSync {
+			rss := newRootSyncSet(rsd, cr, rootSyncSetSpec, pkgRev.Spec.PackageName)
+			if err := r.Create(ctx, rss); err != nil {
+				klog.Warningf("Error creating RootSyncSet %s: %v", rss.Name, err)
+			}
+			clustersBeingUpdated = append(clustersBeingUpdated, rss)
+		}
+	}
+
+	// If no clusters are in the process of being updated and we have clusters
+	// that needs to be updated, just update the corresponding RootSyncSet.
+	if len(clustersBeingUpdated) == 0 && len(clustersNeedingUpdate) > 0 {
+		rss := clustersNeedingUpdate[0]
+		rss.Spec.Template.Spec = rootSyncSetSpec
+		if err := r.Update(ctx, rss); err != nil {
+			klog.Warningf("Error updating RootSyncSet %s: %v", rss.Name, err)
+		}
+		clustersNeedingUpdate = clustersNeedingUpdate[1:]
+		clustersBeingUpdated = append(clustersBeingUpdated, rss)
+	}
+
+	for _, c := range upToDateClusters {
+		clusterRefStatuses = append(clusterRefStatuses, newClusterRefStatus(c, true))
+	}
+	for _, c := range clustersBeingUpdated {
+		clusterRefStatuses = append(clusterRefStatuses, newClusterRefStatus(c, false))
+	}
+	for _, c := range clustersNeedingUpdate {
+		clusterRefStatuses = append(clusterRefStatuses, newClusterRefStatus(c, isRssSynced(c)))
+	}
+
+	return clusterRefStatuses, nil
+}
+
+func newClusterRefStatus(rss *rssapi.RootSyncSet, synced bool) v1alpha1.ClusterRefStatus {
+	clusterRef := rss.Spec.ClusterRefs[0]
+	return v1alpha1.ClusterRefStatus{
+		ApiVersion: clusterRef.ApiVersion,
+		Kind:       clusterRef.Kind,
+		Name:       clusterRef.Name,
+		Namespace:  clusterRef.Namespace,
+		Revision:   rss.Spec.Template.Spec.Git.Revision,
+		Synced:     synced,
+	}
+}
+
+func (r *RootSyncDeploymentReconciler) getPackageRevision(ctx context.Context, nn types.NamespacedName) (*porchapi.PackageRevision, error) {
+	var packageRevision porchapi.PackageRevision
+	if err := r.Get(ctx, nn, &packageRevision); err != nil {
+		return nil, err
+	}
+	return &packageRevision, nil
+}
+
+func (r *RootSyncDeploymentReconciler) getRepository(ctx context.Context, nn types.NamespacedName) (*configapi.Repository, error) {
+	var repository configapi.Repository
+	if err := r.Get(ctx, nn, &repository); err != nil {
+		return nil, err
+	}
+	return &repository, nil
+}
+
+func newRootSyncSet(rsd *v1alpha1.RootSyncDeployment, clusterRef rssapi.ClusterRef, rssSpec *rssapi.RootSyncSpec, pkgName string) *rssapi.RootSyncSet {
+	t := true
+	rootsyncset := &rssapi.RootSyncSet{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      fmt.Sprintf("%s-%s", pkgName, clusterRef.Name),
+			Namespace: rsd.Namespace,
+			Labels: map[string]string{
+				rootSyncSetLabel: rsd.Name,
+			},
+			OwnerReferences: []metav1.OwnerReference{
+				{
+					APIVersion: rsd.APIVersion,
+					Kind:       rsd.Kind,
+					Name:       rsd.Name,
+					UID:        rsd.UID,
+					Controller: &t,
+				},
+			},
+		},
+		Spec: rssapi.RootSyncSetSpec{
+			ClusterRefs: []*rssapi.ClusterRef{
+				&clusterRef,
+			},
+			Template: &rssapi.RootSyncInfo{
+				Spec: rssSpec,
+			},
+		},
+	}
+	return rootsyncset
+}
+
+func toRootSyncSpec(pkgRev *porchapi.PackageRevision, repo *configapi.Repository) *rssapi.RootSyncSpec {
+	return &rssapi.RootSyncSpec{
+		SourceFormat: "unstructured",
+		Git: &rssapi.GitInfo{
+			Repo:     repo.Spec.Git.Repo,
+			Revision: fmt.Sprintf("%s/%s", pkgRev.Spec.PackageName, pkgRev.Spec.Revision),
+			Dir:      pkgRev.Spec.PackageName,
+			Branch:   repo.Spec.Git.Branch,
+			Auth:     "none",
+		},
+	}
+}
+
+func isRssSynced(rss *rssapi.RootSyncSet) bool {
+	if rss.Generation != rss.Status.ObservedGeneration {
+		return false
+	}
+
+	for _, crs := range rss.Status.ClusterRefStatuses {
+		if crs.SyncStatus == "Synced" {
+			return true
+		}
+	}
+	return false
+}
+
+func contains(clusters []*unstructured.Unstructured, clusterRef *rssapi.ClusterRef) bool {
+	for _, cr := range clusters {
+		if clusterRef.ApiVersion == cr.GetAPIVersion() &&
+			clusterRef.Kind == cr.GetKind() &&
+			clusterRef.Name == cr.GetName() &&
+			clusterRef.Namespace == cr.GetNamespace() {
+			return true
+		}
+	}
+	return false
+}
+
+func (r *RootSyncDeploymentReconciler) listRootSyncSets(ctx context.Context, rsdName, rsdNamespace string) ([]*rssapi.RootSyncSet, error) {
+	var list rssapi.RootSyncSetList
+	if err := r.List(ctx, &list, client.MatchingLabels{rootSyncSetLabel: rsdName}, client.InNamespace(rsdNamespace)); err != nil {
+		return nil, err
+	}
+	var rootsyncsets []*rssapi.RootSyncSet
+	for i := range list.Items {
+		item := &list.Items[i]
+		rootsyncsets = append(rootsyncsets, item)
+	}
+	return rootsyncsets, nil
+}
+
+func (r *RootSyncDeploymentReconciler) getClusters(ctx context.Context, sel *metav1.LabelSelector) ([]*unstructured.Unstructured, error) {
+	var clusters []*unstructured.Unstructured
+	for _, gvk := range []schema.GroupVersionKind{configConnectorConfigControllerClusterGVK, configConnectorContainerClusterGVK} {
+		c, err := r.getClustersByGVK(ctx, gvk, sel)
+		if err != nil {
+			return nil, err
+		}
+		clusters = append(clusters, c...)
+	}
+	return clusters, nil
+}
+
+func (r *RootSyncDeploymentReconciler) getClustersByGVK(ctx context.Context, gvk schema.GroupVersionKind, sel *metav1.LabelSelector) ([]*unstructured.Unstructured, error) {
+	selector, err := metav1.LabelSelectorAsSelector(sel)
+	if err != nil {
+		return nil, err
+	}
+	var list unstructured.UnstructuredList
+	list.SetGroupVersionKind(gvk)
+	if err := r.List(ctx, &list, client.MatchingLabelsSelector{Selector: selector}); err != nil {
+		return nil, err
+	}
+
+	var clusters []*unstructured.Unstructured
+	for i := range list.Items {
+		c := list.Items[i]
+		clusters = append(clusters, &c)
+	}
+	return clusters, nil
+}
+
+func toRootSyncSetNames(rsss []*rssapi.RootSyncSet) []string {
+	var names []string
+	for _, rss := range rsss {
+		names = append(names, rss.Name)
+	}
+	return names
+}
+
+func toClusterNames(clusters []*unstructured.Unstructured) []string {
+	var names []string
+	for _, c := range clusters {
+		names = append(names, c.GetName())
+	}
+	return names
+}
+
+// SetupWithManager sets up the controller with the Manager.
+func (r *RootSyncDeploymentReconciler) SetupWithManager(mgr ctrl.Manager) error {
+	if err := v1alpha1.AddToScheme(mgr.GetScheme()); err != nil {
+		return err
+	}
+	if err := porchapi.AddToScheme(mgr.GetScheme()); err != nil {
+		return err
+	}
+	if err := configapi.AddToScheme(mgr.GetScheme()); err != nil {
+		return err
+	}
+
+	r.Client = mgr.GetClient()
+
+	var containerCluster unstructured.Unstructured
+	containerCluster.SetGroupVersionKind(configConnectorContainerClusterGVK)
+	var configController unstructured.Unstructured
+	configController.SetGroupVersionKind(configConnectorConfigControllerClusterGVK)
+
+	return ctrl.NewControllerManagedBy(mgr).
+		For(&v1alpha1.RootSyncDeployment{}).
+		Owns(&rssapi.RootSyncSet{}).
+		Watches(
+			&source.Kind{Type: &containerCluster},
+			handler.EnqueueRequestsFromMapFunc(r.requestsFromMapFunc),
+		).
+		Watches(
+			&source.Kind{Type: &configController},
+			handler.EnqueueRequestsFromMapFunc(r.requestsFromMapFunc),
+		).
+		Complete(r)
+}
+
+func (r *RootSyncDeploymentReconciler) requestsFromMapFunc(cluster client.Object) []reconcile.Request {
+	r.mutex.Lock()
+	defer r.mutex.Unlock()
+
+	var requests []reconcile.Request
+	l := cluster.GetLabels()
+	for nn, clusterTargetSelector := range r.clusterTargetCache {
+		if clusterTargetSelector.Matches(labels.Set(l)) {
+			requests = append(requests, reconcile.Request{NamespacedName: nn})
+		}
+	}
+	return requests
+}


Test patch:
diff --git a/pkg/live/flatten_test.go b/pkg/live/flatten_test.go
new file mode 100644
index 00000000..93304eed
--- /dev/null
+++ b/pkg/live/flatten_test.go
@@ -0,0 +1,78 @@
+// 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 live
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+)
+
+func Test_Flatten(t *testing.T) {
+	tests := []struct {
+		name string
+		in   []*unstructured.Unstructured
+		want []*unstructured.Unstructured
+	}{
+		{
+			name: "simple list",
+			in:   simpleList,
+			want: simpleList,
+		},
+		{
+			name: "list with list",
+			in: []*unstructured.Unstructured{
+				{
+					Object: map[string]interface{}{
+						"kind":  "List",
+						"items": anySlice(simpleList),
+					},
+				},
+			},
+			want: simpleList,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got, err := Flatten(tt.in)
+			if assert.NoError(t, err) {
+				assert.Equal(t, tt.want, got)
+			}
+		})
+	}
+}
+
+func anySlice(in []*unstructured.Unstructured) (out []interface{}) {
+	for _, o := range in {
+		out = append(out, o.Object)
+	}
+	return
+}
+
+var simpleList = []*unstructured.Unstructured{
+	{
+		Object: map[string]interface{}{
+			"kind":    "ConfigMap",
+			"version": "v1",
+		},
+	},
+	{
+		Object: map[string]interface{}{
+			"kind":    "Pod",
+			"version": "v1",
+		},
+	},
+}