Repository: GoogleContainerTools/kpt
Pull request #3707 (2022-12-29 21:31:14)

Add PackageVariantSet controller
This PR adds the UpstreamPolicy (renamed to PackageVariantSet) as described in [this doc](https://docs.google.com/document/d/1OxNon_1ri4YOqNtEQivBgeRzIPuX9sOyu-nYukjwN1Q/edit?usp=sharing&resourcekey=0-2nDYYH5Kw58IwCatA4uDQw) and discussed in https://github.com/GoogleContainerTools/kpt/issues/3488. 

There are some TODOs in this PR that I am working on in a follow-up:
- Implement the option to specify "tag" rather than a "revision" for the upstream package revision
- Using "Conditions" rather than a top-level field in the status and other returned errors
- Implementing the "object set" option for downstream package targets.

This PR also adds a finalizer to the PackageVariant controller to make sure that it cleans up properly before being deleted. (I noticed some issues with its cleanup while trying out the PackageVariantSet controller.)

Relevant issue: https://github.com/GoogleContainerTools/kpt/issues/3488

natasha41575: cc @johnbelamaric 

Patch:
diff --git a/commands/alpha/rpkg/proposedelete/command.go b/commands/alpha/rpkg/proposedelete/command.go
new file mode 100644
index 00000000..b685bb58
--- /dev/null
+++ b/commands/alpha/rpkg/proposedelete/command.go
@@ -0,0 +1,117 @@
+// 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 proposedelete
+
+import (
+	"context"
+	"fmt"
+	"strings"
+
+	"github.com/GoogleContainerTools/kpt/internal/errors"
+	"github.com/GoogleContainerTools/kpt/internal/util/porch"
+	"github.com/GoogleContainerTools/kpt/porch/api/porch/v1alpha1"
+	"github.com/spf13/cobra"
+	"k8s.io/cli-runtime/pkg/genericclioptions"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+)
+
+const (
+	command = "cmdrpkgpropose-delete"
+)
+
+func newRunner(ctx context.Context, rcg *genericclioptions.ConfigFlags) *runner {
+	r := &runner{
+		ctx: ctx,
+		cfg: rcg,
+	}
+	c := &cobra.Command{
+		Use:        "propose-delete PACKAGE",
+		Aliases:    []string{"propose-del"},
+		SuggestFor: []string{},
+		PreRunE:    r.preRunE,
+		RunE:       r.runE,
+		Hidden:     porch.HidePorchCommands,
+	}
+	r.Command = c
+
+	// Create flags
+
+	return r
+}
+
+func NewCommand(ctx context.Context, rcg *genericclioptions.ConfigFlags) *cobra.Command {
+	return newRunner(ctx, rcg).Command
+}
+
+type runner struct {
+	ctx     context.Context
+	cfg     *genericclioptions.ConfigFlags
+	client  client.Client
+	Command *cobra.Command
+}
+
+func (r *runner) preRunE(cmd *cobra.Command, args []string) error {
+	const op errors.Op = command + ".preRunE"
+
+	client, err := porch.CreateClientWithFlags(r.cfg)
+	if err != nil {
+		return errors.E(op, err)
+	}
+	r.client = client
+	return nil
+}
+
+func (r *runner) runE(_ *cobra.Command, args []string) error {
+	const op errors.Op = command + ".runE"
+	var messages []string
+	namespace := *r.cfg.Namespace
+
+	for _, name := range args {
+		pr := &v1alpha1.PackageRevision{}
+		if err := r.client.Get(r.ctx, client.ObjectKey{
+			Namespace: namespace,
+			Name:      name,
+		}, pr); err != nil {
+			return errors.E(op, err)
+		}
+
+		switch pr.Spec.Lifecycle {
+		case v1alpha1.PackageRevisionLifecyclePublished:
+			// ok
+		case v1alpha1.PackageRevisionLifecycleDeletionProposed:
+			fmt.Fprintf(r.Command.OutOrStderr(), "%s is already proposed for deletion\n", name)
+			continue
+		default:
+			msg := fmt.Sprintf("can only propose published packages for deletion; package %s is not published", name)
+			messages = append(messages, msg)
+			fmt.Fprintln(r.Command.ErrOrStderr(), msg)
+			continue
+		}
+
+		pr.Spec.Lifecycle = v1alpha1.PackageRevisionLifecycleDeletionProposed
+		if err := r.client.Update(r.ctx, pr); err != nil {
+			messages = append(messages, err.Error())
+			fmt.Fprintf(r.Command.ErrOrStderr(), "%s failed (%s)\n", name, err)
+		} else {
+			fmt.Fprintf(r.Command.OutOrStderr(), "%s proposed for deletion\n", name)
+		}
+	}
+
+	if len(messages) > 0 {
+		return errors.E(op, fmt.Errorf("errors:\n  %s", strings.Join(messages, "\n  ")))
+	}
+
+	return nil
+}
diff --git a/commands/alpha/rpkg/rpkgcmd.go b/commands/alpha/rpkg/rpkgcmd.go
index 72006bec..c379448d 100644
--- a/commands/alpha/rpkg/rpkgcmd.go
+++ b/commands/alpha/rpkg/rpkgcmd.go
@@ -26,6 +26,7 @@ import (
 	"github.com/GoogleContainerTools/kpt/commands/alpha/rpkg/get"
 	initialization "github.com/GoogleContainerTools/kpt/commands/alpha/rpkg/init"
 	"github.com/GoogleContainerTools/kpt/commands/alpha/rpkg/propose"
+	"github.com/GoogleContainerTools/kpt/commands/alpha/rpkg/proposedelete"
 	"github.com/GoogleContainerTools/kpt/commands/alpha/rpkg/pull"
 	"github.com/GoogleContainerTools/kpt/commands/alpha/rpkg/push"
 	"github.com/GoogleContainerTools/kpt/commands/alpha/rpkg/reject"
@@ -80,6 +81,7 @@ func NewCommand(ctx context.Context, version string) *cobra.Command {
 		del.NewCommand(ctx, kubeflags),
 		copy.NewCommand(ctx, kubeflags),
 		update.NewCommand(ctx, kubeflags),
+		proposedelete.NewCommand(ctx, kubeflags),
 	)
 
 	return repo
diff --git a/commands/alpha/rpkg/update/command.go b/commands/alpha/rpkg/update/command.go
index 4d67d488..8f4e0899 100644
--- a/commands/alpha/rpkg/update/command.go
+++ b/commands/alpha/rpkg/update/command.go
@@ -18,6 +18,7 @@ import (
 	"context"
 	"fmt"
 
+	"github.com/GoogleContainerTools/kpt/internal/docs/generated/rpkgdocs"
 	"github.com/GoogleContainerTools/kpt/internal/errors"
 	"github.com/GoogleContainerTools/kpt/internal/util/porch"
 	porchapi "github.com/GoogleContainerTools/kpt/porch/api/porch/v1alpha1"
@@ -46,6 +47,9 @@ func newRunner(ctx context.Context, rcg *genericclioptions.ConfigFlags) *runner
 		Use:     "update SOURCE_PACKAGE",
 		PreRunE: r.preRunE,
 		RunE:    r.runE,
+		Short:   rpkgdocs.UpdateShort,
+		Long:    rpkgdocs.UpdateShort + "\n" + rpkgdocs.UpdateLong,
+		Example: rpkgdocs.UpdateExamples,
 		Hidden:  porch.HidePorchCommands,
 	}
 	r.Command.Flags().StringVar(&r.revision, "revision", "", "Revision of the upstream package to update to.")
diff --git a/commands/alpha/rpkg/update/discover.go b/commands/alpha/rpkg/update/discover.go
index c8cc4577..1c3519f5 100644
--- a/commands/alpha/rpkg/update/discover.go
+++ b/commands/alpha/rpkg/update/discover.go
@@ -163,7 +163,7 @@ func (r *runner) getRepositories() (*configapi.RepositoryList, error) {
 func (r *runner) getUpstreamRevisions(repo configapi.Repository, upstreamPackageName string) []porchapi.PackageRevision {
 	var result []porchapi.PackageRevision
 	for _, pkgRev := range r.prs {
-		if pkgRev.Spec.Lifecycle != porchapi.PackageRevisionLifecyclePublished {
+		if !porchapi.LifecycleIsPublished(pkgRev.Spec.Lifecycle) {
 			// only consider published packages
 			continue
 		}
diff --git a/go.mod b/go.mod
index a0de9fef..1180a028 100644
--- a/go.mod
+++ b/go.mod
@@ -3,7 +3,7 @@ module github.com/GoogleContainerTools/kpt
 go 1.18
 
 require (
-	github.com/GoogleContainerTools/kpt/porch/api v0.0.0-20221028161857-aa271f292cc0
+	github.com/GoogleContainerTools/kpt/porch/api v0.0.0-20230121152246-dc44dbd18a33
 	github.com/bytecodealliance/wasmtime-go v0.39.0
 	github.com/cpuguy83/go-md2man/v2 v2.0.2
 	github.com/go-errors/errors v1.4.2
diff --git a/go.sum b/go.sum
index d9f8d74e..ba8bfc72 100644
--- a/go.sum
+++ b/go.sum
@@ -87,8 +87,8 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03
 github.com/BurntSushi/toml v0.4.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
 github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
 github.com/Djarvur/go-err113 v0.0.0-20210108212216-aea10b59be24/go.mod h1:4UJr5HIiMZrwgkSPdsjy2uOQExX/WEILpIrO9UPGuXs=
-github.com/GoogleContainerTools/kpt/porch/api v0.0.0-20221028161857-aa271f292cc0 h1:Nay/s1StXHUKyIxerpXb8o0hZUkRjrbteLO6ardI26Y=
-github.com/GoogleContainerTools/kpt/porch/api v0.0.0-20221028161857-aa271f292cc0/go.mod h1:ASrhnLAL4ahTuiUJyepqcpVRXIoRMJyDs8/eSxwhgZM=
+github.com/GoogleContainerTools/kpt/porch/api v0.0.0-20230121152246-dc44dbd18a33 h1:9M1bvq7hU/JTY4VVcqhCQT0eAa5HznrFaLAm2ldfe70=
+github.com/GoogleContainerTools/kpt/porch/api v0.0.0-20230121152246-dc44dbd18a33/go.mod h1:ASrhnLAL4ahTuiUJyepqcpVRXIoRMJyDs8/eSxwhgZM=
 github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
 github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
 github.com/Masterminds/goutils v1.1.0/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
diff --git a/internal/docs/generated/rpkgdocs/docs.go b/internal/docs/generated/rpkgdocs/docs.go
index bbb9eaa0..4b3c23e0 100644
--- a/internal/docs/generated/rpkgdocs/docs.go
+++ b/internal/docs/generated/rpkgdocs/docs.go
@@ -241,3 +241,43 @@ var RejectExamples = `
   # reject the proposal for package revision blueprint-8f9a0c7bf29eb2cbac9476319cd1ad2e897be4f9
   $ kpt alpha rpkg reject blueprint-8f9a0c7bf29eb2cbac9476319cd1ad2e897be4f9 --namespace=default
 `
+
+var UpdateShort = `Update a downstream package revision to a more recent revision of its upstream package.`
+var UpdateLong = `
+  kpt alpha rpkg update PACKAGE_REV_NAME [flags]
+
+Args:
+
+  PACKAGE_REV_NAME:
+  The target downstream package revision to be updated.
+  
+
+Flags:
+
+  --revision
+  The revision number of the upstream kpt package that the target
+  downstream package (PACKAGE_REV_NAME) should be updated to. With
+  this flag, you can only specify one target downstream package.
+  
+  --discover
+  If set, list packages revisions that need updates rather than
+  performing an update. Must be one of 'upstream' or 'downstream'. If
+  set to 'upstream', this will list downstream package revisions that
+  have upstream updates available. If set to 'downstream', this will list
+  upstream package revisions whose downstream package revisions need
+  to be updated. You can optionally pass in package revision names as arguments
+  in order to just list updates for those package revisions, or you can
+  pass in no arguments in order to list available updates for all package
+  revisions.
+  
+`
+var UpdateExamples = `
+  # update deployment-e982b2196b35a4f5e81e92f49a430fe463aa9f1a package to v3 of its upstream
+  $ kpt alpha rpkg update deployment-e982b2196b35a4f5e81e92f49a430fe463aa9f1a --revision=v3
+
+  # see available upstream updates for all your downstream packages
+  $ kpt alpha rpkg update --discover=upstream
+
+  # see available updates for any downstream packages that were created from the upstream blueprints-e982b2196b35a4f5e81e92f49a430fe463aa9f1a package
+  $ kpt alpha rpkg update --discover=downstream blueprints-e982b2196b35a4f5e81e92f49a430fe463aa9f1a
+`
diff --git a/internal/fnruntime/utils.go b/internal/fnruntime/utils.go
index b41a3624..793ceea7 100644
--- a/internal/fnruntime/utils.go
+++ b/internal/fnruntime/utils.go
@@ -147,7 +147,7 @@ func SelectInput(input []*yaml.RNode, selectors, exclusions []kptfilev1.Selector
 	} else {
 		for _, node := range input {
 			for _, selector := range selectors {
-				if isMatch(node, selector) {
+				if IsMatch(node, selector) {
 					selectedInput = append(selectedInput, node)
 				}
 			}
@@ -160,7 +160,7 @@ func SelectInput(input []*yaml.RNode, selectors, exclusions []kptfilev1.Selector
 	for _, node := range selectedInput {
 		matchesExclusion := false
 		for _, exclusion := range exclusions {
-			if !exclusion.IsEmpty() && isMatch(node, exclusion) {
+			if !exclusion.IsEmpty() && IsMatch(node, exclusion) {
 				matchesExclusion = true
 				break
 			}
@@ -172,8 +172,8 @@ func SelectInput(input []*yaml.RNode, selectors, exclusions []kptfilev1.Selector
 	return filteredInput, nil
 }
 
-// isMatch returns true if the resource matches input selection criteria
-func isMatch(node *yaml.RNode, selector kptfilev1.Selector) bool {
+// IsMatch returns true if the resource matches input selection criteria
+func IsMatch(node *yaml.RNode, selector kptfilev1.Selector) bool {
 	// keep expanding with new selectors
 	return nameMatch(node, selector) && namespaceMatch(node, selector) &&
 		kindMatch(node, selector) && apiVersionMatch(node, selector) &&
diff --git a/porch/Makefile b/porch/Makefile
index 527dddbe..a07d32cd 100644
--- a/porch/Makefile
+++ b/porch/Makefile
@@ -49,7 +49,7 @@ 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=* to enable all known reconcilers.
-ALL_RECONCILERS="rootsyncsets,remoterootsyncsets,workloadidentitybindings,rootsyncdeployments,functiondiscovery,packagevariants,rootsyncrollouts"
+ALL_RECONCILERS="rootsyncsets,remoterootsyncsets,workloadidentitybindings,rootsyncdeployments,functiondiscovery,packagevariants,packagevariantsets,rootsyncrollouts"
 ifndef RECONCILERS
   ENABLED_RECONCILERS="rootsyncsets,remoterootsyncsets,workloadidentitybindings,functiondiscovery"
 else
diff --git a/porch/controllers/config/crd/bases/config.porch.kpt.dev_packagevariantsets.yaml b/porch/controllers/config/crd/bases/config.porch.kpt.dev_packagevariantsets.yaml
new file mode 100644
index 00000000..3d3601c9
--- /dev/null
+++ b/porch/controllers/config/crd/bases/config.porch.kpt.dev_packagevariantsets.yaml
@@ -0,0 +1,256 @@
+# Copyright 2023 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: packagevariantsets.config.porch.kpt.dev
+spec:
+  group: config.porch.kpt.dev
+  names:
+    kind: PackageVariantSet
+    listKind: PackageVariantSetList
+    plural: packagevariantsets
+    singular: packagevariantset
+  scope: Namespaced
+  versions:
+  - name: v1alpha1
+    schema:
+      openAPIV3Schema:
+        description: PackageVariantSet represents an upstream package revision and
+          a way to target specific downstream repositories where a variant of the
+          upstream package should be created.
+        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: PackageVariantSetSpec defines the desired state of PackageVariantSet
+            properties:
+              adoptionPolicy:
+                type: string
+              deletionPolicy:
+                type: string
+              targets:
+                items:
+                  properties:
+                    objects:
+                      description: 'option 3: a selector against a set of arbitrary
+                        objects'
+                      properties:
+                        repoName:
+                          properties:
+                            fromField:
+                              type: string
+                            value:
+                              type: string
+                          type: object
+                        selectors:
+                          items:
+                            properties:
+                              annotations:
+                                additionalProperties:
+                                  type: string
+                                description: Annotations on the target resources
+                                type: object
+                              apiVersion:
+                                description: APIVersion of the target resources
+                                type: string
+                              kind:
+                                description: Kind of the target resources
+                                type: string
+                              labelSelector:
+                                description: Labels on the target resources
+                                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
+                              name:
+                                description: Name of the target resources
+                                type: string
+                              namespace:
+                                description: Namespace of the target resources
+                                type: string
+                            type: object
+                          type: array
+                      type: object
+                    package:
+                      description: 'option 1: an explicit repo/package name pair'
+                      properties:
+                        name:
+                          type: string
+                        repo:
+                          type: string
+                      type: object
+                    packageName:
+                      description: For options 2 and 3, PackageName specifies how
+                        to create the name of the package variant
+                      properties:
+                        baseName:
+                          properties:
+                            fromField:
+                              type: string
+                            value:
+                              type: string
+                          type: object
+                        namePrefix:
+                          properties:
+                            fromField:
+                              type: string
+                            value:
+                              type: string
+                          type: object
+                        nameSuffix:
+                          properties:
+                            fromField:
+                              type: string
+                            value:
+                              type: string
+                          type: object
+                      type: object
+                    repositories:
+                      description: 'option 2: a label selector against a set of repositories'
+                      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: array
+              upstream:
+                properties:
+                  package:
+                    properties:
+                      name:
+                        type: string
+                      repo:
+                        type: string
+                    type: object
+                  ref:
+                    type: string
+                  revision:
+                    type: string
+                type: object
+            type: object
+          status:
+            description: PackageVariantSetStatus defines the observed state of PackageVariantSet
+            properties:
+              validationErrors:
+                description: 'TODO: Move this to conditions.'
+                items:
+                  type: string
+                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 ef4bcc25..0e12112d 100644
--- a/porch/controllers/main.go
+++ b/porch/controllers/main.go
@@ -42,6 +42,7 @@ import (
 	"github.com/GoogleContainerTools/kpt/porch/controllers/functiondiscovery"
 	"github.com/GoogleContainerTools/kpt/porch/controllers/klippy/pkg/controllers/klippy"
 	"github.com/GoogleContainerTools/kpt/porch/controllers/packagevariants/pkg/controllers/packagevariant"
+	"github.com/GoogleContainerTools/kpt/porch/controllers/packagevariantsets/pkg/controllers/packagevariantset"
 	"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/rootsyncrollouts/pkg/controllers/rootsyncrollout"
@@ -54,6 +55,7 @@ import (
 var (
 	reconcilers = map[string]Reconciler{
 		"packagevariants":          &packagevariant.PackageVariantReconciler{},
+		"packagevariantsets":       &packagevariantset.PackageVariantSetReconciler{},
 		"rootsyncsets":             &rootsyncset.RootSyncSetReconciler{},
 		"remoterootsyncsets":       &remoterootsyncset.RemoteRootSyncSetReconciler{},
 		"workloadidentitybindings": &workloadidentitybinding.WorkloadIdentityBindingReconciler{},
diff --git a/porch/controllers/packagevariants/api/v1alpha1/packagevariant_types.go b/porch/controllers/packagevariants/api/v1alpha1/packagevariant_types.go
index 85dddd04..2ea73fbe 100644
--- a/porch/controllers/packagevariants/api/v1alpha1/packagevariant_types.go
+++ b/porch/controllers/packagevariants/api/v1alpha1/packagevariant_types.go
@@ -48,6 +48,8 @@ const (
 
 	DeletionPolicyDelete DeletionPolicy = "delete"
 	DeletionPolicyOrphan DeletionPolicy = "orphan"
+
+	Finalizer = "config.porch.kpt.dev/packagevariants"
 )
 
 // PackageVariantSpec defines the desired state of PackageVariant
@@ -72,6 +74,7 @@ type Downstream struct {
 
 // PackageVariantStatus defines the observed state of PackageVariant
 type PackageVariantStatus struct {
+	// TODO: Move this to conditions.
 	ValidationErrors []string `json:"validationErrors,omitempty"`
 }
 
diff --git a/porch/controllers/packagevariants/pkg/controllers/packagevariant/packagevariant_controller.go b/porch/controllers/packagevariants/pkg/controllers/packagevariant/packagevariant_controller.go
index 6d5b3c94..b2129928 100644
--- a/porch/controllers/packagevariants/pkg/controllers/packagevariant/packagevariant_controller.go
+++ b/porch/controllers/packagevariants/pkg/controllers/packagevariant/packagevariant_controller.go
@@ -32,6 +32,7 @@ import (
 	"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"
@@ -69,6 +70,37 @@ func (r *PackageVariantReconciler) Reconcile(ctx context.Context, req ctrl.Reque
 		return ctrl.Result{}, nil
 	}
 
+	if !pv.ObjectMeta.DeletionTimestamp.IsZero() {
+		// This object is being deleted, so we need to make sure the packagerevisions owned by this object
+		// are deleted. Normally, garbage collection can handle this, but we have a special case here because
+		// (a) we cannot delete published packagerevisions and instead have to propose deletion of them
+		// (b) we may want to orphan packagerevisions instead of deleting them.
+		for _, pr := range prList.Items {
+			if r.hasOurOwnerReference(pv, pr.OwnerReferences) {
+				r.deleteOrOrphan(ctx, &pr, pv)
+				if pr.Spec.Lifecycle == porchapi.PackageRevisionLifecycleDeletionProposed {
+					// We need to orphan this package revision; otherwise it will automatically
+					// get deleted after its parent PackageVariant object is deleted.
+					r.orphanPackageRevision(ctx, &pr, pv)
+				}
+			}
+		}
+		// Remove our finalizer from the list and update it.
+		controllerutil.RemoveFinalizer(pv, api.Finalizer)
+		if err := r.Update(ctx, pv); err != nil {
+			return ctrl.Result{}, fmt.Errorf("failed to update %s after delete finalizer: %w", req.Name, err)
+		}
+		return ctrl.Result{}, nil
+	}
+
+	// the object is not being deleted, so let's ensure that our finalizer is here
+	if !controllerutil.ContainsFinalizer(pv, api.Finalizer) {
+		controllerutil.AddFinalizer(pv, api.Finalizer)
+		if err := r.Update(ctx, pv); err != nil {
+			return ctrl.Result{}, fmt.Errorf("failed to update %s after add finalizer: %w", req.Name, err)
+		}
+	}
+
 	if errs := validatePackageVariant(pv); len(errs) > 0 {
 		pv.Status.ValidationErrors = nil
 		for _, validationErr := range errs {
@@ -351,25 +383,31 @@ func (r *PackageVariantReconciler) deleteOrOrphan(ctx context.Context,
 		r.deletePackageRevision(ctx, pr)
 	case api.DeletionPolicyOrphan:
 		klog.Infoln(fmt.Sprintf("package variant %q is orphaning package revision %q", pv.Name, pr.Name))
-		pr.ObjectMeta.OwnerReferences = r.orphanPackageRevision(pr.OwnerReferences, pv.UID)
-		if err := r.Client.Update(ctx, pr); err != nil {
-			klog.Errorf("error orphaning package revision: %v", err)
-		}
+		r.orphanPackageRevision(ctx, pr, pv)
 	default:
 		// this should never happen, because the pv should already be validated beforehand
 		klog.Errorf("invalid deletion policy %s", pv.Spec.DeletionPolicy)
 	}
 }
 
-func (r *PackageVariantReconciler) orphanPackageRevision(currentOwners []metav1.OwnerReference,
-	pvUID types.UID) []metav1.OwnerReference {
-	var newOwners []metav1.OwnerReference
-	for _, owner := range currentOwners {
-		if owner.UID != pvUID {
-			newOwners = append(newOwners, owner)
+func (r *PackageVariantReconciler) orphanPackageRevision(ctx context.Context,
+	pr *porchapi.PackageRevision,
+	pv *api.PackageVariant) {
+	pr.ObjectMeta.OwnerReferences = removeOwnerRefByUID(pr.OwnerReferences, pv.UID)
+	if err := r.Client.Update(ctx, pr); err != nil {
+		klog.Errorf("error orphaning package revision: %v", err)
+	}
+}
+
+func removeOwnerRefByUID(ownerRefs []metav1.OwnerReference,
+	ownerToRemove types.UID) []metav1.OwnerReference {
+	var result []metav1.OwnerReference
+	for _, owner := range ownerRefs {
+		if owner.UID != ownerToRemove {
+			result = append(result, owner)
 		}
 	}
-	return newOwners
+	return result
 }
 
 func (r *PackageVariantReconciler) deletePackageRevision(ctx context.Context, pr *porchapi.PackageRevision) {
diff --git a/porch/controllers/packagevariantsets/api/v1alpha1/groupversion_info.go b/porch/controllers/packagevariantsets/api/v1alpha1/groupversion_info.go
new file mode 100644
index 00000000..aa52eab1
--- /dev/null
+++ b/porch/controllers/packagevariantsets/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/packagevariantsets/api/v1alpha1/packagevariantset_types.go b/porch/controllers/packagevariantsets/api/v1alpha1/packagevariantset_types.go
new file mode 100644
index 00000000..17f3b47d
--- /dev/null
+++ b/porch/controllers/packagevariantsets/api/v1alpha1/packagevariantset_types.go
@@ -0,0 +1,131 @@
+// 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 (
+	pkgvarapi "github.com/GoogleContainerTools/kpt/porch/controllers/packagevariants/api/v1alpha1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+//+kubebuilder:object:root=true
+//+kubebuilder:subresource:status
+
+// PackageVariantSet represents an upstream package revision and a way to
+// target specific downstream repositories where a variant of the upstream
+// package should be created.
+type PackageVariantSet struct {
+	metav1.TypeMeta   `json:",inline"`
+	metav1.ObjectMeta `json:"metadata,omitempty"`
+
+	Spec   PackageVariantSetSpec   `json:"spec,omitempty"`
+	Status PackageVariantSetStatus `json:"status,omitempty"`
+}
+
+func (o *PackageVariantSet) GetSpec() *PackageVariantSetSpec {
+	if o == nil {
+		return nil
+	}
+	return &o.Spec
+}
+
+// PackageVariantSetSpec defines the desired state of PackageVariantSet
+type PackageVariantSetSpec struct {
+	Upstream *Upstream `json:"upstream,omitempty"`
+	Targets  []Target  `json:"targets,omitempty"`
+
+	AdoptionPolicy pkgvarapi.AdoptionPolicy `json:"adoptionPolicy,omitempty"`
+	DeletionPolicy pkgvarapi.DeletionPolicy `json:"deletionPolicy,omitempty"`
+}
+
+type Upstream struct {
+	Package *Package `json:"package,omitempty"`
+
+	Revision string `json:"revision,omitempty"`
+
+	Tag string `json:"ref,omitempty"`
+}
+
+type Target struct {
+	// option 1: an explicit repo/package name pair
+	Package *Package `json:"package,omitempty"`
+
+	// option 2: a label selector against a set of repositories
+	Repositories *metav1.LabelSelector `json:"repositories,omitempty"`
+
+	// option 3: a selector against a set of arbitrary objects
+	Objects *ObjectSelector `json:"objects,omitempty"`
+
+	// For options 2 and 3, PackageName specifies how to create the name of the
+	// package variant
+	PackageName *PackageName `json:"packageName,omitempty"`
+}
+
+type Package struct {
+	Repo string `json:"repo,omitempty"`
+	Name string `json:"name,omitempty"`
+}
+
+type ObjectSelector struct {
+	Selectors []Selector `json:"selectors,omitempty"`
+
+	RepoName *ValueOrFromField `json:"repoName,omitempty"`
+}
+
+type Selector struct {
+	// APIVersion of the target resources
+	APIVersion string `yaml:"apiVersion,omitempty" json:"apiVersion,omitempty"`
+	// Kind of the target resources
+	Kind string `yaml:"kind,omitempty" json:"kind,omitempty"`
+	// Name of the target resources
+	Name string `yaml:"name,omitempty" json:"name,omitempty"`
+	// Namespace of the target resources
+	Namespace string `yaml:"namespace,omitempty" json:"namespace,omitempty"`
+	// Labels on the target resources
+	Labels *metav1.LabelSelector `yaml:"labelSelector,omitempty" json:"labelSelector,omitempty"`
+	// Annotations on the target resources
+	Annotations map[string]string `yaml:"annotations,omitempty" json:"annotations,omitempty"`
+}
+
+type PackageName struct {
+	Name *ValueOrFromField `json:"baseName,omitempty"`
+
+	NameSuffix *ValueOrFromField `json:"nameSuffix,omitempty"`
+
+	NamePrefix *ValueOrFromField `json:"namePrefix,omitempty"`
+}
+
+type ValueOrFromField struct {
+	Value     string `json:"value,omitempty"`
+	FromField string `json:"fromField,omitempty"`
+}
+
+// PackageVariantSetStatus defines the observed state of PackageVariantSet
+type PackageVariantSetStatus struct {
+	// TODO: Move this to conditions.
+	ValidationErrors []string `json:"validationErrors,omitempty"`
+}
+
+//+kubebuilder:object:root=true
+
+// PackageVariantSetList contains a list of PackageVariantSet
+type PackageVariantSetList struct {
+	metav1.TypeMeta `json:",inline"`
+	metav1.ListMeta `json:"metadata,omitempty"`
+	Items           []PackageVariantSet `json:"items"`
+}
+
+func init() {
+	SchemeBuilder.Register(&PackageVariantSet{}, &PackageVariantSetList{})
+}
diff --git a/porch/controllers/packagevariantsets/api/v1alpha1/zz_generated.deepcopy.go b/porch/controllers/packagevariantsets/api/v1alpha1/zz_generated.deepcopy.go
new file mode 100644
index 00000000..6da09a81
--- /dev/null
+++ b/porch/controllers/packagevariantsets/api/v1alpha1/zz_generated.deepcopy.go
@@ -0,0 +1,300 @@
+//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 *ObjectSelector) DeepCopyInto(out *ObjectSelector) {
+	*out = *in
+	if in.Selectors != nil {
+		in, out := &in.Selectors, &out.Selectors
+		*out = make([]Selector, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+	if in.RepoName != nil {
+		in, out := &in.RepoName, &out.RepoName
+		*out = new(ValueOrFromField)
+		**out = **in
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ObjectSelector.
+func (in *ObjectSelector) DeepCopy() *ObjectSelector {
+	if in == nil {
+		return nil
+	}
+	out := new(ObjectSelector)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *Package) DeepCopyInto(out *Package) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Package.
+func (in *Package) DeepCopy() *Package {
+	if in == nil {
+		return nil
+	}
+	out := new(Package)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *PackageName) DeepCopyInto(out *PackageName) {
+	*out = *in
+	if in.Name != nil {
+		in, out := &in.Name, &out.Name
+		*out = new(ValueOrFromField)
+		**out = **in
+	}
+	if in.NameSuffix != nil {
+		in, out := &in.NameSuffix, &out.NameSuffix
+		*out = new(ValueOrFromField)
+		**out = **in
+	}
+	if in.NamePrefix != nil {
+		in, out := &in.NamePrefix, &out.NamePrefix
+		*out = new(ValueOrFromField)
+		**out = **in
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PackageName.
+func (in *PackageName) DeepCopy() *PackageName {
+	if in == nil {
+		return nil
+	}
+	out := new(PackageName)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *PackageVariantSet) DeepCopyInto(out *PackageVariantSet) {
+	*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 PackageVariantSet.
+func (in *PackageVariantSet) DeepCopy() *PackageVariantSet {
+	if in == nil {
+		return nil
+	}
+	out := new(PackageVariantSet)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *PackageVariantSet) 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 *PackageVariantSetList) DeepCopyInto(out *PackageVariantSetList) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ListMeta.DeepCopyInto(&out.ListMeta)
+	if in.Items != nil {
+		in, out := &in.Items, &out.Items
+		*out = make([]PackageVariantSet, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PackageVariantSetList.
+func (in *PackageVariantSetList) DeepCopy() *PackageVariantSetList {
+	if in == nil {
+		return nil
+	}
+	out := new(PackageVariantSetList)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *PackageVariantSetList) 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 *PackageVariantSetSpec) DeepCopyInto(out *PackageVariantSetSpec) {
+	*out = *in
+	if in.Upstream != nil {
+		in, out := &in.Upstream, &out.Upstream
+		*out = new(Upstream)
+		(*in).DeepCopyInto(*out)
+	}
+	if in.Targets != nil {
+		in, out := &in.Targets, &out.Targets
+		*out = make([]Target, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PackageVariantSetSpec.
+func (in *PackageVariantSetSpec) DeepCopy() *PackageVariantSetSpec {
+	if in == nil {
+		return nil
+	}
+	out := new(PackageVariantSetSpec)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *PackageVariantSetStatus) DeepCopyInto(out *PackageVariantSetStatus) {
+	*out = *in
+	if in.ValidationErrors != nil {
+		in, out := &in.ValidationErrors, &out.ValidationErrors
+		*out = make([]string, len(*in))
+		copy(*out, *in)
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PackageVariantSetStatus.
+func (in *PackageVariantSetStatus) DeepCopy() *PackageVariantSetStatus {
+	if in == nil {
+		return nil
+	}
+	out := new(PackageVariantSetStatus)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *Selector) DeepCopyInto(out *Selector) {
+	*out = *in
+	if in.Labels != nil {
+		in, out := &in.Labels, &out.Labels
+		*out = new(v1.LabelSelector)
+		(*in).DeepCopyInto(*out)
+	}
+	if in.Annotations != nil {
+		in, out := &in.Annotations, &out.Annotations
+		*out = make(map[string]string, len(*in))
+		for key, val := range *in {
+			(*out)[key] = val
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Selector.
+func (in *Selector) DeepCopy() *Selector {
+	if in == nil {
+		return nil
+	}
+	out := new(Selector)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *Target) DeepCopyInto(out *Target) {
+	*out = *in
+	if in.Package != nil {
+		in, out := &in.Package, &out.Package
+		*out = new(Package)
+		**out = **in
+	}
+	if in.Repositories != nil {
+		in, out := &in.Repositories, &out.Repositories
+		*out = new(v1.LabelSelector)
+		(*in).DeepCopyInto(*out)
+	}
+	if in.Objects != nil {
+		in, out := &in.Objects, &out.Objects
+		*out = new(ObjectSelector)
+		(*in).DeepCopyInto(*out)
+	}
+	if in.PackageName != nil {
+		in, out := &in.PackageName, &out.PackageName
+		*out = new(PackageName)
+		(*in).DeepCopyInto(*out)
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Target.
+func (in *Target) DeepCopy() *Target {
+	if in == nil {
+		return nil
+	}
+	out := new(Target)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *Upstream) DeepCopyInto(out *Upstream) {
+	*out = *in
+	if in.Package != nil {
+		in, out := &in.Package, &out.Package
+		*out = new(Package)
+		**out = **in
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Upstream.
+func (in *Upstream) DeepCopy() *Upstream {
+	if in == nil {
+		return nil
+	}
+	out := new(Upstream)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ValueOrFromField) DeepCopyInto(out *ValueOrFromField) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ValueOrFromField.
+func (in *ValueOrFromField) DeepCopy() *ValueOrFromField {
+	if in == nil {
+		return nil
+	}
+	out := new(ValueOrFromField)
+	in.DeepCopyInto(out)
+	return out
+}
diff --git a/porch/controllers/packagevariantsets/config/rbac/role.yaml b/porch/controllers/packagevariantsets/config/rbac/role.yaml
new file mode 100644
index 00000000..dad1da23
--- /dev/null
+++ b/porch/controllers/packagevariantsets/config/rbac/role.yaml
@@ -0,0 +1,48 @@
+# 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-packagevariantsets
+rules:
+- apiGroups:
+  - config.porch.kpt.dev
+  resources:
+  - packagevariantsets
+  - packagevariants
+  verbs:
+  - create
+  - delete
+  - get
+  - list
+  - patch
+  - update
+  - watch
+- apiGroups:
+  - config.porch.kpt.dev
+  resources:
+  - packagevariantsets/finalizers
+  verbs:
+  - update
+- apiGroups:
+  - config.porch.kpt.dev
+  resources:
+  - packagevariantsets/status
+  verbs:
+  - get
+  - patch
+  - update
diff --git a/porch/controllers/packagevariantsets/config/rbac/rolebinding.yaml b/porch/controllers/packagevariantsets/config/rbac/rolebinding.yaml
new file mode 100644
index 00000000..a8cf15ba
--- /dev/null
+++ b/porch/controllers/packagevariantsets/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-packagevariantsets
+roleRef:
+  apiGroup: rbac.authorization.k8s.io
+  kind: ClusterRole
+  name: porch-controllers-packagevariantsets
+subjects:
+- kind: ServiceAccount
+  name: porch-controllers
+  namespace: porch-system
\ No newline at end of file
diff --git a/porch/controllers/packagevariantsets/config/samples/pvs.yaml b/porch/controllers/packagevariantsets/config/samples/pvs.yaml
new file mode 100644
index 00000000..ecf87785
--- /dev/null
+++ b/porch/controllers/packagevariantsets/config/samples/pvs.yaml
@@ -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.
+
+apiVersion: config.porch.kpt.dev/v1alpha1
+kind: PackageVariantSet
+metadata:
+  name: my-pvs
+  namespace: default
+spec:
+  upstream:
+    package:
+      repo: blueprints
+      name: foo
+    revision: v3
+  targets:
+    - package:
+        repo: blueprints
+        name: alpha
+    - repositories:
+        matchLabels:
+          abc: def
+      packageName:
+        baseName:
+          value: beta
+
diff --git a/porch/controllers/packagevariantsets/pkg/controllers/packagevariantset/packagevariantset_controller.go b/porch/controllers/packagevariantsets/pkg/controllers/packagevariantset/packagevariantset_controller.go
new file mode 100644
index 00000000..a25924b6
--- /dev/null
+++ b/porch/controllers/packagevariantsets/pkg/controllers/packagevariantset/packagevariantset_controller.go
@@ -0,0 +1,527 @@
+// 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 packagevariantset
+
+import (
+	"bytes"
+	"context"
+	"crypto/sha1"
+	"encoding/hex"
+	"flag"
+	"fmt"
+	"strconv"
+	"strings"
+
+	porchapi "github.com/GoogleContainerTools/kpt/porch/api/porch/v1alpha1"
+	configapi "github.com/GoogleContainerTools/kpt/porch/api/porchconfig/v1alpha1"
+	pkgvarapi "github.com/GoogleContainerTools/kpt/porch/controllers/packagevariants/api/v1alpha1"
+	api "github.com/GoogleContainerTools/kpt/porch/controllers/packagevariantsets/api/v1alpha1"
+
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/runtime/serializer/json"
+	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/apimachinery/pkg/util/validation/field"
+	"k8s.io/client-go/kubernetes/scheme"
+	ctrl "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/handler"
+	"sigs.k8s.io/controller-runtime/pkg/reconcile"
+	"sigs.k8s.io/controller-runtime/pkg/source"
+	kyamlutils "sigs.k8s.io/kustomize/kyaml/utils"
+	"sigs.k8s.io/kustomize/kyaml/yaml"
+)
+
+type Options struct{}
+
+func (o *Options) InitDefaults()                       {}
+func (o *Options) BindFlags(_ string, _ *flag.FlagSet) {}
+
+// PackageVariantSetReconciler reconciles a PackageVariantSet object
+type PackageVariantSetReconciler struct {
+	client.Client
+	Options
+
+	serializer *json.Serializer
+}
+
+const PackageVariantSetOwnerLabel = "config.porch.kpt.dev/packagevariantset"
+
+//go:generate go run sigs.k8s.io/controller-tools/cmd/controller-gen@v0.8.0 rbac:roleName=porch-controllers-packagevariantsets webhook paths="." output:rbac:artifacts:config=../../../config/rbac
+
+//+kubebuilder:rbac:groups=config.porch.kpt.dev,resources=packagevariantsets,verbs=get;list;watch;create;update;patch;delete
+//+kubebuilder:rbac:groups=config.porch.kpt.dev,resources=packagevariantsets/status,verbs=get;update;patch
+//+kubebuilder:rbac:groups=config.porch.kpt.dev,resources=packagevariantsets/finalizers,verbs=update
+
+// Reconcile implements the main kubernetes reconciliation loop.
+func (r *PackageVariantSetReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
+	pvs, err := r.init(ctx, req)
+	if err != nil {
+		return ctrl.Result{}, err
+	}
+	if pvs == nil {
+		// maybe the pvs was deleted
+		return ctrl.Result{}, nil
+	}
+
+	if errs := validatePackageVariantSet(pvs); len(errs) > 0 {
+		pvs.Status.ValidationErrors = nil
+		for _, validationErr := range errs {
+			if validationErr.Error() != "" {
+				pvs.Status.ValidationErrors = append(pvs.Status.ValidationErrors, validationErr.Error())
+			}
+		}
+		statusUpdateErr := r.Client.Status().Update(ctx, pvs)
+		return ctrl.Result{}, statusUpdateErr
+	}
+
+	upstream, err := r.getUpstreamPR(pvs.Spec.Upstream)
+	if err != nil {
+		return ctrl.Result{}, err
+	}
+	if upstream == nil {
+		return ctrl.Result{}, fmt.Errorf("could not find specified upstream")
+	}
+
+	downstreams, err := r.unrollDownstreamTargets(ctx, pvs,
+		upstream.Package)
+	if err != nil {
+		return ctrl.Result{}, err
+	}
+
+	if err := r.ensurePackageVariants(ctx, upstream, downstreams, pvs); err != nil {
+		return ctrl.Result{}, err
+	}
+
+	return ctrl.Result{}, nil
+}
+
+func (r *PackageVariantSetReconciler) init(ctx context.Context, req ctrl.Request) (*api.PackageVariantSet, error) {
+	var pvs api.PackageVariantSet
+	if err := r.Client.Get(ctx, req.NamespacedName, &pvs); err != nil {
+		return nil, client.IgnoreNotFound(err)
+	}
+	return &pvs, nil
+}
+
+func validatePackageVariantSet(pvs *api.PackageVariantSet) []error {
+	var allErrs []error
+	if pvs.Spec.Upstream == nil {
+		allErrs = append(allErrs, field.Invalid(field.NewPath("spec", "upstream"), "{}", "missing required field"))
+	} else {
+		if pvs.Spec.Upstream.Package == nil {
+			allErrs = append(allErrs, field.Invalid(field.NewPath("spec", "upstream", "package"), "{}", "missing required field"))
+		} else {
+			if pvs.Spec.Upstream.Package.Name == "" {
+				allErrs = append(allErrs, field.Invalid(field.NewPath("spec", "upstream", "package", "name"), "", "missing required field"))
+			}
+			if pvs.Spec.Upstream.Package.Repo == "" {
+				allErrs = append(allErrs, field.Invalid(field.NewPath("spec", "upstream", "package", "repo"), "", "missing required field"))
+			}
+		}
+		if (pvs.Spec.Upstream.Tag == "" && pvs.Spec.Upstream.Revision == "") ||
+			(pvs.Spec.Upstream.Tag != "" && pvs.Spec.Upstream.Revision != "") {
+			allErrs = append(allErrs, fmt.Errorf("must have one of spec.upstream.revision and spec.upstream.tag"))
+		}
+	}
+
+	if len(pvs.Spec.Targets) == 0 {
+		allErrs = append(allErrs, fmt.Errorf("must specify at least one item in spec.targets"))
+	}
+	for i, target := range pvs.Spec.Targets {
+		count := 0
+		if target.Package != nil {
+			if target.PackageName != nil {
+				allErrs = append(allErrs, fmt.Errorf("spec.targets[%d] cannot specify both fields `packageName` and `package`", i))
+			}
+			if target.Package.Repo == "" {
+				allErrs = append(allErrs, fmt.Errorf("spec.targets[%d].package.repo cannot be empty when using `package`", i))
+			}
+			count++
+		}
+		if target.Repositories != nil {
+			count++
+		}
+		if target.Objects != nil {
+			if target.Objects.Selectors == nil {
+				allErrs = append(allErrs, fmt.Errorf("spec.targets[%d].objects must have at least one selector", i))
+			}
+			if target.Objects.RepoName == nil {
+				allErrs = append(allErrs, fmt.Errorf("spec.targets[%d].objects must specify `repoName` field", i))
+			}
+			count++
+		}
+		if count != 1 {
+			allErrs = append(allErrs, fmt.Errorf("spec.targets[%d] must specify one of `package`, `repositories`, or `objects`", i))
+		}
+	}
+
+	if pvs.Spec.AdoptionPolicy == "" {
+		pvs.Spec.AdoptionPolicy = pkgvarapi.AdoptionPolicyAdoptNone
+	}
+	if pvs.Spec.DeletionPolicy == "" {
+		pvs.Spec.DeletionPolicy = pkgvarapi.DeletionPolicyDelete
+	}
+	if pvs.Spec.AdoptionPolicy != pkgvarapi.AdoptionPolicyAdoptNone && pvs.Spec.AdoptionPolicy != pkgvarapi.AdoptionPolicyAdoptExisting {
+		allErrs = append(allErrs, field.Invalid(
+			field.NewPath("spec", "adoptionPolicy"), pvs.Spec.AdoptionPolicy,
+			fmt.Sprintf("field can only be %q or %q",
+				pkgvarapi.AdoptionPolicyAdoptNone, pkgvarapi.AdoptionPolicyAdoptExisting)))
+	}
+	if pvs.Spec.DeletionPolicy != pkgvarapi.DeletionPolicyOrphan && pvs.Spec.DeletionPolicy != pkgvarapi.DeletionPolicyDelete {
+		allErrs = append(allErrs, field.Invalid(
+			field.NewPath("spec", "deletionPolicy"), pvs.Spec.DeletionPolicy,
+			fmt.Sprintf("field can only be %q or %q",
+				pkgvarapi.DeletionPolicyOrphan, pkgvarapi.DeletionPolicyDelete)))
+	}
+	return allErrs
+}
+
+func (r *PackageVariantSetReconciler) getUpstreamPR(
+	upstream *api.Upstream) (*pkgvarapi.Upstream, error) {
+
+	// one of upstream.Tag or upstream.Revision must have been specified,
+	// because this pvs has already been validated
+	if upstream.Tag != "" {
+		// TODO: Implement this.
+		//   We need to figure out which published revision this refers to,
+		//   so the controller needs to reach out to the Github repo and
+		//   look at the commits on this tag.
+		//   We will need to find this tag's most recent commit that refers
+		//   to the relevant package, and then find the package revision
+		//   that is on the same commit.
+		return nil, fmt.Errorf("specifying the upstream tag is not yet supported")
+
+	}
+
+	// upstream.Revision is specified
+	return &pkgvarapi.Upstream{
+		Repo:     upstream.Package.Repo,
+		Package:  upstream.Package.Name,
+		Revision: upstream.Revision,
+	}, nil
+
+}
+
+func (r *PackageVariantSetReconciler) unrollDownstreamTargets(ctx context.Context,
+	pvs *api.PackageVariantSet,
+	upstreamPackageName string) ([]*pkgvarapi.Downstream, error) {
+	var result []*pkgvarapi.Downstream
+	for _, target := range pvs.Spec.Targets {
+		switch {
+		case target.Package != nil:
+			// an explicit repo/package name pair
+			result = append(result, r.repoPackagePair(&target, upstreamPackageName))
+
+		case target.Repositories != nil:
+			// a label selector against a set of repositories
+			selector, err := metav1.LabelSelectorAsSelector(target.Repositories)
+			if err != nil {
+				return nil, err
+			}
+			var repoList configapi.RepositoryList
+			if err := r.Client.List(ctx, &repoList,
+				client.InNamespace(pvs.Namespace),
+				client.MatchingLabelsSelector{Selector: selector}); err != nil {
+				return nil, err
+			}
+			pkgs, err := r.repositorySet(&target, upstreamPackageName, &repoList)
+			if err != nil {
+				return nil, fmt.Errorf("error when selecting repository set: %v", err)
+			}
+			result = append(result, pkgs...)
+
+		case target.Objects != nil:
+			// a selector against a set of arbitrary objects
+			pkgs, err := r.objectSet(ctx, &target, upstreamPackageName)
+			if err != nil {
+				return nil, fmt.Errorf("error when selecting object set: %v", err)
+			}
+			result = append(result, pkgs...)
+		}
+	}
+	return result, nil
+}
+
+func (r *PackageVariantSetReconciler) repoPackagePair(target *api.Target,
+	upstreamPackageName string) *pkgvarapi.Downstream {
+	downstreamPackageName := target.Package.Name
+	if downstreamPackageName == "" {
+		downstreamPackageName = upstreamPackageName
+	}
+	return &pkgvarapi.Downstream{
+		Repo:    target.Package.Repo,
+		Package: downstreamPackageName,
+	}
+}
+
+func (r *PackageVariantSetReconciler) repositorySet(
+	target *api.Target,
+	upstreamPackageName string,
+	repoList *configapi.RepositoryList) ([]*pkgvarapi.Downstream, error) {
+
+	var result []*pkgvarapi.Downstream
+	for _, repo := range repoList.Items {
+		repoAsRNode, err := r.convertObjectToRNode(&repo)
+		if err != nil {
+			return nil, fmt.Errorf("error converting repo to RNode: %v", err)
+		}
+		downstreamPackageName, err := r.getDownstreamPackageName(target.PackageName, upstreamPackageName, repoAsRNode)
+		if err != nil {
+			return nil, err
+		}
+		result = append(result, &pkgvarapi.Downstream{
+			Repo:    repo.Name,
+			Package: downstreamPackageName,
+		})
+
+	}
+	return result, nil
+}
+
+func (r *PackageVariantSetReconciler) objectSet(ctx context.Context,
+	target *api.Target,
+	upstreamPackageName string) ([]*pkgvarapi.Downstream, error) {
+	// TODO: Implement this
+	return nil, fmt.Errorf("specifying a set of objects in the target is not yet supported")
+}
+
+func (r *PackageVariantSetReconciler) getDownstreamPackageName(targetName *api.PackageName,
+	upstreamPackageName string,
+	obj *yaml.RNode) (string, error) {
+
+	if targetName == nil {
+		return upstreamPackageName, nil
+	}
+
+	packageName, err := r.fetchValue(targetName.Name, obj)
+	if err != nil {
+		return "", err
+	}
+	if packageName == "" {
+		packageName = upstreamPackageName
+	}
+
+	suffix, err := r.fetchValue(targetName.NameSuffix, obj)
+	if err != nil {
+		return "", err
+	}
+
+	prefix, err := r.fetchValue(targetName.NamePrefix, obj)
+	if err != nil {
+		return "", err
+	}
+
+	return prefix + packageName + suffix, nil
+}
+
+func (r *PackageVariantSetReconciler) convertObjectToRNode(obj runtime.Object) (*yaml.RNode, error) {
+	var buffer bytes.Buffer
+	if err := r.serializer.Encode(obj, &buffer); err != nil {
+		return nil, err
+	}
+	return yaml.Parse(buffer.String())
+}
+
+func (r *PackageVariantSetReconciler) fetchValue(value *api.ValueOrFromField,
+	obj *yaml.RNode) (string, error) {
+	if value == nil {
+		return "", nil
+	}
+	if value.Value != "" {
+		return value.Value, nil
+	}
+	if value.FromField == "" {
+		return "", nil
+	}
+
+	// The SmarterPathSplitter below splits on '.', and the yaml.Lookup filter expects
+	// a list of path elements to parse through, e.g. ["metadata", "ownerRefs", "1"],
+	// so we have to do a bit of a hack to support JSON path syntax.
+	// Adding a '.' before each '[' ensures that we split our path correctly, then
+	// we have to parse through and remove the [] around numbers;
+	// E.g. 'metadata.ownerRefs[1]' splits into 'metadata', 'ownerRefs', '1' before
+	// we call yaml.Lookup, so we first change it to 'metadata.ownerRefs.1 before calling the splitter.
+	// See TestFetchValue for examples of what this supports.
+	fromField := strings.ReplaceAll(value.FromField, "[", ".[")
+	fieldPath := kyamlutils.SmarterPathSplitter(fromField, ".")
+	for i := range fieldPath {
+		trimmed := strings.Trim(fieldPath[i], "[]")
+		if _, err := strconv.Atoi(trimmed); err == nil {
+			fieldPath[i] = trimmed
+		}
+	}
+	rn, err := obj.Pipe(yaml.Lookup(fieldPath...))
+	if err != nil {
+		return "", err
+	}
+	if rn.IsNilOrEmpty() || rn.YNode().Value == "" {
+		return "", fmt.Errorf("value not found")
+	}
+
+	return rn.YNode().Value, nil
+}
+
+func (r *PackageVariantSetReconciler) ensurePackageVariants(ctx context.Context,
+	upstream *pkgvarapi.Upstream, downstreams []*pkgvarapi.Downstream,
+	pvs *api.PackageVariantSet) error {
+
+	var pvList pkgvarapi.PackageVariantList
+	if err := r.Client.List(ctx, &pvList,
+		client.InNamespace(pvs.Namespace),
+		client.MatchingLabels{
+			PackageVariantSetOwnerLabel: string(pvs.UID),
+		}); err != nil {
+		return fmt.Errorf("error listing package variants %v", err)
+	}
+
+	// existingPackageVariantMap holds the PackageVariant objects that currently exist.
+	existingPackageVariantMap := make(map[string]*pkgvarapi.PackageVariant, len(pvList.Items))
+	// desiredPackageVariantMap holds the PackageVariant objects that we want to exist.
+	desiredPackageVariantMap := make(map[string]*pkgvarapi.PackageVariant, len(downstreams))
+
+	for _, pv := range pvList.Items {
+		hash, err := hashFromPackageVariantSpec(&pv.Spec)
+		if err != nil {
+			return err
+		}
+		existingPackageVariantMap[hash] = &pv
+	}
+
+	tr := true
+	for _, downstream := range downstreams {
+		pvSpec := pkgvarapi.PackageVariantSpec{
+			Upstream:       upstream,
+			Downstream:     downstream,
+			AdoptionPolicy: pvs.Spec.AdoptionPolicy,
+			DeletionPolicy: pvs.Spec.DeletionPolicy,
+		}
+		hash, err := hashFromPackageVariantSpec(&pvSpec)
+		if err != nil {
+			return err
+		}
+		pv := pkgvarapi.PackageVariant{
+			TypeMeta: metav1.TypeMeta{
+				Kind:       "PackageVariant",
+				APIVersion: "config.porch.kpt.dev",
+			},
+			ObjectMeta: metav1.ObjectMeta{
+				Name:       fmt.Sprintf("%s-%s", pvs.Name, hash),
+				Namespace:  pvs.Namespace,
+				Finalizers: []string{pkgvarapi.Finalizer},
+				Labels:     map[string]string{PackageVariantSetOwnerLabel: string(pvs.UID)},
+				OwnerReferences: []metav1.OwnerReference{{
+					APIVersion:         pvs.APIVersion,
+					Kind:               pvs.Kind,
+					Name:               pvs.Name,
+					UID:                pvs.UID,
+					Controller:         &tr,
+					BlockOwnerDeletion: nil,
+				}},
+			},
+			Spec: pvSpec,
+		}
+		desiredPackageVariantMap[hash] = &pv
+	}
+
+	for existingPvHash, existingPV := range existingPackageVariantMap {
+		if _, found := desiredPackageVariantMap[existingPvHash]; found {
+			// this PackageVariant exists in both the desired PackageVariant set and the
+			// existing PackageVariant set, so we don't need to do anything.
+		} else {
+			// this PackageVariant exists in the existing PackageVariant set, but not
+			// the desired PackageVariant set, so we need to delete it.
+			err := r.Client.Delete(ctx, existingPV)
+			if err != nil {
+				return err
+			}
+		}
+	}
+
+	for desiredPvHash, desiredPv := range desiredPackageVariantMap {
+		if _, found := existingPackageVariantMap[desiredPvHash]; found {
+			// this PackageVariant exists in both the desired PackageVariant set and the
+			// existing PackageVariant set, so we don't need to do anything.
+		} else {
+			// this PackageVariant exists in the desired PackageVariant set, but not
+			// the existing PackageVariant set, so we need to create it.
+			err := r.Client.Create(ctx, desiredPv)
+			if err != nil {
+				return err
+			}
+		}
+	}
+
+	return nil
+}
+
+func hashFromPackageVariantSpec(spec *pkgvarapi.PackageVariantSpec) (string, error) {
+	b, err := yaml.Marshal(spec)
+	if err != nil {
+		return "", err
+	}
+	hash := sha1.Sum(b)
+	return hex.EncodeToString(hash[:]), nil
+}
+
+// SetupWithManager sets up the controller with the Manager.
+func (r *PackageVariantSetReconciler) SetupWithManager(mgr ctrl.Manager) error {
+	if err := api.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
+	}
+	if err := pkgvarapi.AddToScheme(mgr.GetScheme()); err != nil {
+		return err
+	}
+	if err := scheme.AddToScheme(mgr.GetScheme()); err != nil {
+		return err
+	}
+
+	r.Client = mgr.GetClient()
+	r.serializer = json.NewSerializerWithOptions(json.DefaultMetaFactory, nil, nil, json.SerializerOptions{Yaml: true})
+
+	return ctrl.NewControllerManagedBy(mgr).
+		For(&api.PackageVariantSet{}).
+		Watches(&source.Kind{Type: &pkgvarapi.PackageVariant{}},
+			handler.EnqueueRequestsFromMapFunc(r.mapObjectsToRequests)).
+		Watches(&source.Kind{Type: &porchapi.PackageRevision{}},
+			handler.EnqueueRequestsFromMapFunc(r.mapObjectsToRequests)).
+		Complete(r)
+}
+
+func (r *PackageVariantSetReconciler) mapObjectsToRequests(obj client.Object) []reconcile.Request {
+	attachedPackageVariants := &api.PackageVariantSetList{}
+	err := r.List(context.TODO(), attachedPackageVariants, &client.ListOptions{
+		Namespace: obj.GetNamespace(),
+	})
+	if err != nil {
+		return []reconcile.Request{}
+	}
+	requests := make([]reconcile.Request, len(attachedPackageVariants.Items))
+	for i, item := range attachedPackageVariants.Items {
+		requests[i] = reconcile.Request{
+			NamespacedName: types.NamespacedName{
+				Name:      item.GetName(),
+				Namespace: item.GetNamespace(),
+			},
+		}
+	}
+	return requests
+}
diff --git a/porch/controllers/remoterootsyncsets/pkg/applyset/applyset.go b/porch/controllers/remoterootsyncsets/pkg/applyset/applyset.go
index 7c733ee7..775e04bf 100644
--- a/porch/controllers/remoterootsyncsets/pkg/applyset/applyset.go
+++ b/porch/controllers/remoterootsyncsets/pkg/applyset/applyset.go
@@ -26,6 +26,7 @@ import (
 	"k8s.io/apimachinery/pkg/types"
 	"k8s.io/client-go/dynamic"
 	"k8s.io/klog/v2"
+	"sigs.k8s.io/cli-utils/pkg/kstatus/status"
 )
 
 // ApplySet is a set of objects that we want to apply to the cluster.
@@ -210,7 +211,24 @@ func (a *ApplySet) ApplyOnce(ctx context.Context) (*ApplyResults, error) {
 		tracker.lastApplied = applied
 		results.applySuccess(gvk, nn)
 
-		tracker.isHealthy = isHealthy(applied)
+		health, err := computeHealth(applied)
+		if err != nil {
+			klog.Warningf("error computing health: %v", err)
+			tracker.isHealthy = false
+		} else {
+			switch health.Status {
+			case status.CurrentStatus:
+				tracker.isHealthy = true
+			case status.InProgressStatus:
+				// TODO: Do we want a different status here?
+				tracker.isHealthy = false
+			case status.FailedStatus:
+				tracker.isHealthy = false
+			default:
+				klog.Warningf("unexpected health status %v", health)
+				tracker.isHealthy = false
+			}
+		}
 		results.reportHealth(gvk, nn, tracker.isHealthy)
 	}
 	return results, nil
diff --git a/porch/controllers/remoterootsyncsets/pkg/applyset/health.go b/porch/controllers/remoterootsyncsets/pkg/applyset/health.go
index adee9093..13c626d1 100644
--- a/porch/controllers/remoterootsyncsets/pkg/applyset/health.go
+++ b/porch/controllers/remoterootsyncsets/pkg/applyset/health.go
@@ -15,74 +15,11 @@
 package applyset
 
 import (
-	"encoding/json"
-
 	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
-	"k8s.io/klog/v2"
-)
-
-// isHealthy reports whether the object should be considered "healthy"
-// TODO: Replace with kstatus library
-func isHealthy(u *unstructured.Unstructured) bool {
-	ready := true
-
-	statusConditions, found, err := unstructured.NestedFieldNoCopy(u.Object, "status", "conditions")
-	if err != nil || !found {
-		klog.Infof("status conditions not found for %s", u.GroupVersionKind())
-		return true
-	}
-
-	statusConditionsList, ok := statusConditions.([]interface{})
-	if !ok {
-		klog.Warningf("expected status.conditions to be list, got %T", statusConditions)
-		return true
-	}
-	for i := range statusConditionsList {
-		condition := statusConditionsList[i]
-		conditionMap, ok := condition.(map[string]interface{})
-		if !ok {
-			klog.Warningf("expected status.conditions[%d] to be map, got %T", i, condition)
-			return true
-		}
-
-		conditionType := ""
-		conditionStatus := ""
-		for k, v := range conditionMap {
-			switch k {
-			case "type":
-				s, ok := v.(string)
-				if !ok {
-					klog.Warningf("expected status.conditions[].type to be string, got %T", v)
-				} else {
-					conditionType = s
-				}
-			case "status":
-				s, ok := v.(string)
-				if !ok {
-					klog.Warningf("expected status.conditions[].status to be string, got %T", v)
-				} else {
-					conditionStatus = s
-				}
-			}
-		}
 
-		// TODO: Check conditionType?
-
-		switch conditionStatus {
-		case "True":
-			// ready
-
-		case "False":
-			j, _ := json.Marshal(condition)
-			klog.Infof("status.conditions indicates object is not ready: %v", string(j))
-			ready = false
-
-		case "":
-			klog.Warningf("ignoring status.conditions[] type %q with unknown status %q", conditionType, conditionStatus)
-
-		}
-	}
+	"sigs.k8s.io/cli-utils/pkg/kstatus/status"
+)
 
-	klog.Infof("isHealthy %s => %v", u.GroupVersionKind(), ready)
-	return ready
+func computeHealth(u *unstructured.Unstructured) (*status.Result, error) {
+	return status.Compute(u)
 }
diff --git a/porch/controllers/remoterootsyncsets/pkg/controllers/remoterootsyncset/remoterootsync_controller.go b/porch/controllers/remoterootsyncsets/pkg/controllers/remoterootsyncset/remoterootsync_controller.go
index c7df7dec..e2a7cea8 100644
--- a/porch/controllers/remoterootsyncsets/pkg/controllers/remoterootsyncset/remoterootsync_controller.go
+++ b/porch/controllers/remoterootsyncsets/pkg/controllers/remoterootsyncset/remoterootsync_controller.go
@@ -126,33 +126,52 @@ func (r *RemoteRootSyncSetReconciler) Reconcile(ctx context.Context, req ctrl.Re
 
 	var result ctrl.Result
 
-	var patchErrs []error
+	var applyErrors []error
 	for _, clusterRef := range subject.Spec.ClusterRefs {
 		results, err := r.applyToClusterRef(ctx, &subject, clusterRef)
 		if err != nil {
-			patchErrs = append(patchErrs, err)
-		}
-		if updateTargetStatus(&subject, clusterRef, results, err) {
-			if err := r.client.Status().Update(ctx, &subject); err != nil {
-				patchErrs = append(patchErrs, err)
-			}
+			klog.Errorf("error applying to ref %v: %v", clusterRef, err)
+			applyErrors = append(applyErrors, err)
 		}
 
+		updateTargetStatus(&subject, clusterRef, results, err)
+
+		// TODO: Do we ever want to do a partial flush of results?  Should we exit the loop and re-reconcile?
+
 		if results != nil && !(results.AllApplied() && results.AllHealthy()) {
 			result.Requeue = true
 		}
 	}
 
-	if len(patchErrs) != 0 {
-		for _, patchErr := range patchErrs {
-			klog.Errorf("%v", patchErr)
+	specTargets := make(map[api.ClusterRef]bool)
+	for _, ref := range subject.Spec.ClusterRefs {
+		specTargets[*ref] = true
+	}
+
+	// Remove any old target statuses
+	var keepTargets []api.TargetStatus
+	for i := range subject.Status.Targets {
+		target := &subject.Status.Targets[i]
+		if specTargets[target.Ref] {
+			keepTargets = append(keepTargets, *target)
 		}
-		return ctrl.Result{}, patchErrs[0]
+	}
+	subject.Status.Targets = keepTargets
+
+	updateAggregateStatus(&subject)
+
+	// TODO: Do this in a lazy way?
+	if err := r.client.Status().Update(ctx, &subject); err != nil {
+		return result, fmt.Errorf("error updating status: %w", err)
+	}
+
+	if len(applyErrors) != 0 {
+		return result, applyErrors[0]
 	}
 	return result, nil
 }
 
-func updateTargetStatus(subject *api.RemoteRootSyncSet, ref *api.ClusterRef, applyResults *applyset.ApplyResults, err error) bool {
+func updateTargetStatus(subject *api.RemoteRootSyncSet, ref *api.ClusterRef, applyResults *applyset.ApplyResults, err error) {
 	var found *api.TargetStatus
 	for i := range subject.Status.Targets {
 		target := &subject.Status.Targets[i]
@@ -186,9 +205,6 @@ func updateTargetStatus(subject *api.RemoteRootSyncSet, ref *api.ClusterRef, app
 			meta.SetStatusCondition(&found.Conditions, metav1.Condition{Type: "Ready", Status: metav1.ConditionTrue, Reason: "Ready"})
 		}
 	}
-	// TODO: SetStatusCondition should return an indiciation if anything has changes
-
-	return updateAggregateStatus(subject)
 }
 
 func updateAggregateStatus(subject *api.RemoteRootSyncSet) bool {
diff --git a/porch/go.mod b/porch/go.mod
index fa7f85e4..e89f34b9 100644
--- a/porch/go.mod
+++ b/porch/go.mod
@@ -17,7 +17,7 @@ require (
 	github.com/GoogleContainerTools/kpt-functions-catalog/functions/go/set-namespace v0.4.1
 	github.com/GoogleContainerTools/kpt-functions-catalog/functions/go/starlark v0.4.3
 	github.com/GoogleContainerTools/kpt-functions-sdk/go/fn v0.0.0-20220506190241-f85503febd54
-	github.com/GoogleContainerTools/kpt/porch/api v0.0.0-20221028161857-aa271f292cc0
+	github.com/GoogleContainerTools/kpt/porch/api v0.0.0-20230121152246-dc44dbd18a33
 	github.com/bluekeyes/go-gitdiff v0.6.1
 	github.com/go-git/go-billy/v5 v5.3.1
 	github.com/go-git/go-git/v5 v5.4.3-0.20220408232334-4f916225cb2f
@@ -53,6 +53,7 @@ require (
 	k8s.io/klog/v2 v2.70.1
 	k8s.io/kube-aggregator v0.24.0-beta.0
 	k8s.io/utils v0.0.0-20220823124924-e9cbc92d1a73
+	sigs.k8s.io/cli-utils v0.34.0
 	sigs.k8s.io/controller-runtime v0.13.0
 	sigs.k8s.io/kustomize/kyaml v0.13.9
 	sigs.k8s.io/yaml v1.3.0
@@ -187,7 +188,6 @@ require (
 	k8s.io/kube-openapi v0.0.0-20220803162953-67bda5d908f1 // indirect
 	k8s.io/kubectl v0.25.3 // indirect
 	sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.0.33 // indirect
-	sigs.k8s.io/cli-utils v0.34.0 // indirect
 	sigs.k8s.io/json v0.0.0-20220713155537-f223a00ba0e2 // indirect
 	sigs.k8s.io/kustomize/api v0.12.1 // indirect
 	sigs.k8s.io/structured-merge-diff/v4 v4.2.3 // indirect
diff --git a/site/reference/cli/alpha/rpkg/update/README.md b/site/reference/cli/alpha/rpkg/update/README.md
new file mode 100644
index 00000000..ebcf4e5d
--- /dev/null
+++ b/site/reference/cli/alpha/rpkg/update/README.md
@@ -0,0 +1,73 @@
+---
+title: "`clone`"
+linkTitle: "clone"
+type: docs
+description: >
+Update a downstream package revision to a more recent revision of its upstream package.
+---
+
+<!--mdtogo:Short
+    Update a downstream package revision to a more recent revision of its upstream package.
+-->
+
+`update` performs a kpt pkg update on an existing downstream package revision.
+
+### Synopsis
+
+<!--mdtogo:Long-->
+
+```
+kpt alpha rpkg update PACKAGE_REV_NAME [flags]
+```
+
+#### Args
+
+```
+PACKAGE_REV_NAME:
+The target downstream package revision to be updated.
+
+```
+
+#### Flags
+
+```
+--revision
+The revision number of the upstream kpt package that the target
+downstream package (PACKAGE_REV_NAME) should be updated to. With
+this flag, you can only specify one target downstream package.
+
+--discover
+If set, list packages revisions that need updates rather than
+performing an update. Must be one of 'upstream' or 'downstream'. If
+set to 'upstream', this will list downstream package revisions that
+have upstream updates available. If set to 'downstream', this will list
+upstream package revisions whose downstream package revisions need
+to be updated. You can optionally pass in package revision names as arguments
+in order to just list updates for those package revisions, or you can
+pass in no arguments in order to list available updates for all package
+revisions.
+
+```
+
+<!--mdtogo-->
+
+### Examples
+
+<!--mdtogo:Examples-->
+
+```shell
+# update deployment-e982b2196b35a4f5e81e92f49a430fe463aa9f1a package to v3 of its upstream
+$ kpt alpha rpkg update deployment-e982b2196b35a4f5e81e92f49a430fe463aa9f1a --revision=v3
+```
+
+```shell
+# see available upstream updates for all your downstream packages
+$ kpt alpha rpkg update --discover=upstream
+```
+
+```shell
+# see available updates for any downstream packages that were created from the upstream blueprints-e982b2196b35a4f5e81e92f49a430fe463aa9f1a package
+$ kpt alpha rpkg update --discover=downstream blueprints-e982b2196b35a4f5e81e92f49a430fe463aa9f1a
+```
+
+<!--mdtogo-->
\ No newline at end of file


Test patch:
diff --git a/e2e/testdata/porch/rpkg-lifecycle/config.yaml b/e2e/testdata/porch/rpkg-lifecycle/config.yaml
index f7f47796..46c18fd8 100644
--- a/e2e/testdata/porch/rpkg-lifecycle/config.yaml
+++ b/e2e/testdata/porch/rpkg-lifecycle/config.yaml
@@ -23,6 +23,17 @@ commands:
       - git-017a8366a5e0d9b35ae6dc489d4d3f68046d6034
     stderr: |
       git-017a8366a5e0d9b35ae6dc489d4d3f68046d6034 proposed
+  - args:
+      - alpha
+      - rpkg
+      - propose-delete
+      - git-017a8366a5e0d9b35ae6dc489d4d3f68046d6034
+      - --namespace=rpkg-lifecycle
+    exitCode: 1
+    stderr: |
+      can only propose published packages for deletion; package git-017a8366a5e0d9b35ae6dc489d4d3f68046d6034 is not published
+      Error: errors:
+        can only propose published packages for deletion; package git-017a8366a5e0d9b35ae6dc489d4d3f68046d6034 is not published 
   - args:
       - alpha
       - rpkg
@@ -50,6 +61,17 @@ commands:
     stdout: |
       NAME                                           PACKAGE             WORKSPACENAME   REVISION   LATEST   LIFECYCLE   REPOSITORY
       git-017a8366a5e0d9b35ae6dc489d4d3f68046d6034   lifecycle-package   lifecycle                  false    Draft       git
+  - args:
+      - alpha
+      - rpkg
+      - propose-delete
+      - git-017a8366a5e0d9b35ae6dc489d4d3f68046d6034
+      - --namespace=rpkg-lifecycle
+    exitCode: 1
+    stderr: |
+      can only propose published packages for deletion; package git-017a8366a5e0d9b35ae6dc489d4d3f68046d6034 is not published
+      Error: errors:
+        can only propose published packages for deletion; package git-017a8366a5e0d9b35ae6dc489d4d3f68046d6034 is not published 
   - args:
       - alpha
       - rpkg
@@ -95,3 +117,44 @@ commands:
       git-017a8366a5e0d9b35ae6dc489d4d3f68046d6034 failed (admission webhook "packagerevdeletion.google.com" denied the request: failed to delete package revision "git-017a8366a5e0d9b35ae6dc489d4d3f68046d6034": published PackageRevisions must be proposed for deletion by setting spec.lifecycle to 'DeletionProposed' prior to deletion)
       Error: errors:
         admission webhook "packagerevdeletion.google.com" denied the request: failed to delete package revision "git-017a8366a5e0d9b35ae6dc489d4d3f68046d6034": published PackageRevisions must be proposed for deletion by setting spec.lifecycle to 'DeletionProposed' prior to deletion 
+  - args:
+      - alpha
+      - rpkg
+      - propose-delete
+      - git-017a8366a5e0d9b35ae6dc489d4d3f68046d6034
+      - --namespace=rpkg-lifecycle
+    stderr: |
+      git-017a8366a5e0d9b35ae6dc489d4d3f68046d6034 proposed for deletion
+  - args:
+      - alpha
+      - rpkg
+      - propose-delete
+      - git-017a8366a5e0d9b35ae6dc489d4d3f68046d6034
+      - --namespace=rpkg-lifecycle
+    stderr: |
+      git-017a8366a5e0d9b35ae6dc489d4d3f68046d6034 is already proposed for deletion
+  - args:
+      - alpha
+      - rpkg
+      - get
+      - git-017a8366a5e0d9b35ae6dc489d4d3f68046d6034
+      - --namespace=rpkg-lifecycle
+    stdout: |
+      NAME                                           PACKAGE             WORKSPACENAME   REVISION   LATEST   LIFECYCLE          REPOSITORY
+      git-017a8366a5e0d9b35ae6dc489d4d3f68046d6034   lifecycle-package   lifecycle       v1         true     DeletionProposed   git
+  - args:
+      - alpha
+      - rpkg
+      - delete
+      - git-017a8366a5e0d9b35ae6dc489d4d3f68046d6034
+      - --namespace=rpkg-lifecycle
+    stderr: |
+      git-017a8366a5e0d9b35ae6dc489d4d3f68046d6034 deleted
+  - args:
+      - alpha
+      - rpkg
+      - get
+      - git-017a8366a5e0d9b35ae6dc489d4d3f68046d6034
+      - --namespace=rpkg-lifecycle
+    stderr: "Error: the server could not find the requested resource (get packagerevisions.porch.kpt.dev git-017a8366a5e0d9b35ae6dc489d4d3f68046d6034) \n"
+    exitCode: 1
diff --git a/internal/fnruntime/utils_test.go b/internal/fnruntime/utils_test.go
index b2f57776..26125fd4 100644
--- a/internal/fnruntime/utils_test.go
+++ b/internal/fnruntime/utils_test.go
@@ -135,7 +135,7 @@ spec:
 		t.Run(tc.name, func(t *testing.T) {
 			node, err := yaml.Parse(tc.input)
 			assert.NoError(t, err)
-			actual := isMatch(node, tc.selector)
+			actual := IsMatch(node, tc.selector)
 			assert.Equal(t, tc.expected, actual)
 		})
 	}
diff --git a/porch/controllers/packagevariantsets/pkg/controllers/packagevariantset/packagevariantset_controller_test.go b/porch/controllers/packagevariantsets/pkg/controllers/packagevariantset/packagevariantset_controller_test.go
new file mode 100644
index 00000000..53f8e45e
--- /dev/null
+++ b/porch/controllers/packagevariantsets/pkg/controllers/packagevariantset/packagevariantset_controller_test.go
@@ -0,0 +1,288 @@
+// Copyright 2023 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 packagevariantset
+
+import (
+	"testing"
+
+	configapi "github.com/GoogleContainerTools/kpt/porch/api/porchconfig/v1alpha1"
+	pkgvarapi "github.com/GoogleContainerTools/kpt/porch/controllers/packagevariants/api/v1alpha1"
+	api "github.com/GoogleContainerTools/kpt/porch/controllers/packagevariantsets/api/v1alpha1"
+	"github.com/stretchr/testify/require"
+	corev1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/runtime/serializer/json"
+	kyaml "sigs.k8s.io/kustomize/kyaml/yaml"
+	"sigs.k8s.io/yaml"
+)
+
+func TestValidatePackageVariantSet(t *testing.T) {
+	packageVariantHeader := `apiVersion: config.porch.kpt.dev
+kind: PackageVariantSet
+metadata:
+  name: my-pv`
+
+	testCases := map[string]struct {
+		packageVariant string
+		expectedErrs   []string
+	}{
+		"empty spec": {
+			packageVariant: packageVariantHeader,
+			expectedErrs: []string{"spec.upstream: Invalid value: \"{}\": missing required field",
+				"must specify at least one item in spec.targets",
+			},
+		},
+		"missing upstream package, but has both revision and tag": {
+			packageVariant: packageVariantHeader + `
+spec:
+  upstream:
+    revision: v1
+    tag: main`,
+			expectedErrs: []string{"spec.upstream.package: Invalid value: \"{}\": missing required field",
+				"must specify at least one item in spec.targets",
+			},
+		},
+		"missing upstream package repo, revision, and tag": {
+			packageVariant: packageVariantHeader + `
+spec:
+  upstream:
+    package:
+      name: foo`,
+			expectedErrs: []string{"spec.upstream.package.repo: Invalid value: \"\": missing required field",
+				"must have one of spec.upstream.revision and spec.upstream.tag",
+				"must specify at least one item in spec.targets",
+			},
+		},
+		"missing upstream package name, revision, and tag": {
+			packageVariant: packageVariantHeader + `
+spec:
+  upstream:
+    package:
+      repo: foo`,
+			expectedErrs: []string{"spec.upstream.package.name: Invalid value: \"\": missing required field",
+				"must have one of spec.upstream.revision and spec.upstream.tag",
+				"must specify at least one item in spec.targets",
+			},
+		},
+		"invalid targets": {
+			packageVariant: packageVariantHeader + `
+spec:
+  targets:
+  - package:
+      name: foo
+    packageName:
+      name: 
+        value: foo
+  - package:
+      repo: bar
+    repositories:
+      foo: bar
+  - package:
+      name: foo
+      repo: bar
+    objects:
+      repoName:
+        value: foo
+      `,
+			expectedErrs: []string{"spec.upstream: Invalid value: \"{}\": missing required field",
+				"spec.targets[0] cannot specify both fields `packageName` and `package`",
+				"spec.targets[0].package.repo cannot be empty when using `package`",
+				"spec.targets[1] must specify one of `package`, `repositories`, or `objects`",
+				"spec.targets[2].objects must have at least one selector",
+				"spec.targets[2] must specify one of `package`, `repositories`, or `objects`",
+			},
+		},
+		"invalid adoption and deletion policies": {
+			packageVariant: packageVariantHeader + `
+spec:
+  adoptionPolicy: invalid
+  deletionPolicy: invalid
+`,
+			expectedErrs: []string{"spec.upstream: Invalid value: \"{}\": missing required field",
+				"must specify at least one item in spec.targets",
+				"spec.adoptionPolicy: Invalid value: \"invalid\": field can only be \"adoptNone\" or \"adoptExisting\"",
+				"spec.deletionPolicy: Invalid value: \"invalid\": field can only be \"orphan\" or \"delete\"",
+			},
+		},
+		"valid adoption and deletion policies": {
+			packageVariant: packageVariantHeader + `
+spec:
+  adoptionPolicy: adoptExisting
+  deletionPolicy: orphan
+`,
+			expectedErrs: []string{"spec.upstream: Invalid value: \"{}\": missing required field",
+				"must specify at least one item in spec.targets",
+			},
+		},
+	}
+
+	for tn, tc := range testCases {
+		t.Run(tn, func(t *testing.T) {
+			var pvs api.PackageVariantSet
+			require.NoError(t, yaml.Unmarshal([]byte(tc.packageVariant), &pvs))
+			actualErrs := validatePackageVariantSet(&pvs)
+			require.Equal(t, len(tc.expectedErrs), len(actualErrs))
+			for i := range actualErrs {
+				require.EqualError(t, actualErrs[i], tc.expectedErrs[i])
+			}
+
+		})
+	}
+}
+
+func TestConvertObjectToRNode(t *testing.T) {
+	s := json.NewSerializerWithOptions(json.DefaultMetaFactory, nil, nil, json.SerializerOptions{Yaml: true})
+	r := PackageVariantSetReconciler{serializer: s}
+
+	t.Run("pod", func(t *testing.T) {
+		input := `apiVersion: v1
+kind: Pod
+metadata:
+  creationTimestamp: null
+  name: my-pod
+spec:
+  containers: null
+status: {}
+`
+		var pod corev1.Pod
+		require.NoError(t, yaml.Unmarshal([]byte(input), &pod))
+		n, err := r.convertObjectToRNode(&pod)
+		require.NoError(t, err)
+		require.Equal(t, input, n.MustString())
+	})
+
+	t.Run("repository", func(t *testing.T) {
+		input := `apiVersion: config.porch.kpt.dev/v1alpha1
+kind: Repository
+metadata:
+  creationTimestamp: null
+  name: my-repo
+spec: {}
+status: {}
+`
+		var repo configapi.Repository
+		require.NoError(t, yaml.Unmarshal([]byte(input), &repo))
+		n, err := r.convertObjectToRNode(&repo)
+		require.NoError(t, err)
+		require.Equal(t, input, n.MustString())
+	})
+}
+
+func TestFetchValue(t *testing.T) {
+	pod := kyaml.MustParse(`
+apiVersion: v1
+kind: Pod
+metadata:
+  name: nginx
+spec:
+  containers:
+  - name: nginx
+    image: nginx:1.14.2
+    ports:
+    - containerPort: 80
+  - name: foo
+    image: image:1.2.3
+    ports:
+    - containerPort: 8080`)
+
+	testCases := map[string]struct {
+		input    *api.ValueOrFromField
+		expected string
+	}{
+		"nil input": {
+			input:    nil,
+			expected: "",
+		},
+		"empty struct input": {
+			input:    &api.ValueOrFromField{},
+			expected: "",
+		},
+		"string literal value": {
+			input: &api.ValueOrFromField{
+				Value: "literal",
+			},
+			expected: "literal",
+		},
+		"value from field using key-value selector": {
+			input: &api.ValueOrFromField{
+				FromField: "spec.containers[name=foo].image",
+			},
+			expected: "image:1.2.3",
+		},
+		"value from field using integer selector": {
+			input: &api.ValueOrFromField{
+				FromField: "spec.containers[1].image",
+			},
+			expected: "image:1.2.3",
+		},
+	}
+
+	for tn, tc := range testCases {
+		r := &PackageVariantSetReconciler{}
+		t.Run(tn, func(t *testing.T) {
+			v, err := r.fetchValue(tc.input, pod)
+			require.NoError(t, err)
+			require.Equal(t, tc.expected, v)
+		})
+	}
+}
+
+func TestRepositorySet(t *testing.T) {
+	var repoList configapi.RepositoryList
+	require.NoError(t, yaml.Unmarshal([]byte(`
+apiVersion: config.porch.kpt.dev/v1alpha1
+kind: RepositoryList
+metadata:
+  name: my-repo-list
+items:
+- apiVersion: config.porch.kpt.dev/v1alpha1
+  kind: Repository
+  metadata:
+    name: my-repo-1
+    labels:
+      foo: bar
+      abc: def
+- apiVersion: config.porch.kpt.dev/v1alpha1
+  kind: Repository
+  metadata:
+    name: my-repo-2
+    labels:
+      foo: bar
+      abc: def
+      efg: hij
+`), &repoList))
+
+	var target api.Target
+	require.NoError(t, yaml.Unmarshal([]byte(`
+repositories:
+  foo: bar
+  abc: def
+packageName:
+  baseName:
+    value: dpn`), &target))
+
+	s := json.NewSerializerWithOptions(json.DefaultMetaFactory, nil, nil, json.SerializerOptions{Yaml: true})
+	r := PackageVariantSetReconciler{serializer: s}
+
+	result, err := r.repositorySet(&target, "upn", &repoList)
+	require.NoError(t, err)
+	require.Equal(t, []*pkgvarapi.Downstream{{
+		Repo:    "my-repo-1",
+		Package: "dpn",
+	}, {
+		Repo:    "my-repo-2",
+		Package: "dpn",
+	},
+	}, result)
+}
diff --git a/porch/test/e2e/e2e_test.go b/porch/test/e2e/e2e_test.go
index a45c52a8..8dbd1a04 100644
--- a/porch/test/e2e/e2e_test.go
+++ b/porch/test/e2e/e2e_test.go
@@ -2152,11 +2152,6 @@ func (t *PorchSuite) registerGitRepositoryF(ctx context.Context, repo, name, dir
 		},
 	})
 
-	// TODO: Replace with readiness check or similar, once we get to CRDs
-	// Sometimes we see "failed to list resources" here, I believe because we need to wait for the repository to be crawled.
-	t.Logf("HACK: sleeping for 5 seconds to allow for repository registration")
-	time.Sleep(5 * time.Second)
-
 	t.Cleanup(func() {
 		t.DeleteL(ctx, &configapi.Repository{
 			ObjectMeta: metav1.ObjectMeta{
@@ -2165,6 +2160,10 @@ func (t *PorchSuite) registerGitRepositoryF(ctx context.Context, repo, name, dir
 			},
 		})
 	})
+
+	// Make sure the repository is ready before we test to (hopefully)
+	// avoid flakiness.
+	t.waitUntilRepositoryReady(ctx, name, t.namespace)
 }
 
 type repositoryOption func(*configapi.Repository)
@@ -2310,6 +2309,9 @@ func (t *PorchSuite) mustNotExist(ctx context.Context, obj client.Object) {
 
 // waitUntilRepositoryReady waits for up to 10 seconds for the repository with the
 // provided name and namespace is ready, i.e. the Ready condition is true.
+// It also queries for Functions and PackageRevisions, to ensure these are also
+// ready - this is an artifact of the way we've implemented the aggregated apiserver,
+// where the first fetch can sometimes be synchronous.
 func (t *PorchSuite) waitUntilRepositoryReady(ctx context.Context, name, namespace string) {
 	nn := types.NamespacedName{
 		Name:      name,
@@ -2332,6 +2334,31 @@ func (t *PorchSuite) waitUntilRepositoryReady(ctx context.Context, name, namespa
 	if err != nil {
 		t.Errorf("Repository not ready after wait: %v", innerErr)
 	}
+
+	// While we're using an aggregated apiserver, make sure we can query the generated objects
+	if err := wait.PollImmediateWithContext(ctx, time.Second, 10*time.Second, func(ctx context.Context) (bool, error) {
+		var revisions porchapi.PackageRevisionList
+		if err := t.client.List(ctx, &revisions, client.InNamespace(nn.Namespace)); err != nil {
+			innerErr = err
+			return false, nil
+		}
+		return true, nil
+	}); err != nil {
+		t.Errorf("unable to query PackageRevisions after wait: %v", innerErr)
+	}
+
+	// Check for functions also (until we move them to CRDs)
+	if err := wait.PollImmediateWithContext(ctx, time.Second, 10*time.Second, func(ctx context.Context) (bool, error) {
+		var functions porchapi.FunctionList
+		if err := t.client.List(ctx, &functions, client.InNamespace(nn.Namespace)); err != nil {
+			innerErr = err
+			return false, nil
+		}
+		return true, nil
+	}); err != nil {
+		t.Errorf("unable to query Functions after wait: %v", innerErr)
+	}
+
 }
 
 func (t *PorchSuite) waitUntilRepositoryDeleted(ctx context.Context, name, namespace string) {