Repository: GoogleContainerTools/kpt
Pull request #3759 (2023-01-27 00:10:13)

packagevariant controller should unpropose deletion when needed
Ran into this scenario when playing around with the packagevariant controller:

- I create a PackageVariant with a target downstream package `foo`. The PackageVariant controller creates the target draft package revision, and I publish it, resulting in `foo v1`. 
- I update the PackageVariant's target downstream package name to something else, like `bar`. The PackageVariant controller creates a new target draft package revision `bar`. It also sees that it owns a package revision `foo v1` that no longer matches its target, so proposes `foo v1` for deletion. This is working great so far.
- I update the PackageVariant's target downstream package name back to `foo`. The PackageVariant controller deletes its package revision `bar`. The controller also sees that there is already a package revision `foo v1` that it owns that matches its target, so it does nothing -- meaning that `foo v1` stays proposed for deletion. This PR makes it so that in such a case, the target package revision's lifecycle gets changed back from DeletionProposed to Published.


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/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/livedocs/docs.go b/internal/docs/generated/livedocs/docs.go
index 297cce23..81eed549 100644
--- a/internal/docs/generated/livedocs/docs.go
+++ b/internal/docs/generated/livedocs/docs.go
@@ -65,10 +65,6 @@ Flags:
   
     The default value is ‘events’.
   
-  --poll-period:
-    The frequency with which the cluster will be polled to determine
-    the status of the applied resources. The default value is 2 seconds.
-  
   --prune-propagation-policy:
     The propagation policy that should be used when pruning resources. The
     default value here is 'Background'. The other options are 'Foreground' and 'Orphan'.
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..d4629daa 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 {
@@ -237,6 +269,14 @@ func (r *PackageVariantReconciler) findAndUpdateExistingRevisions(ctx context.Co
 	var err error
 	for i, downstream := range downstreams {
 		if r.isUpToDate(pv, downstream) {
+			if downstream.Spec.Lifecycle == porchapi.PackageRevisionLifecycleDeletionProposed {
+				// We proposed this package revision for deletion in the past, but now it
+				// matches our target, so we no longer want it to be deleted.
+				downstream.Spec.Lifecycle = porchapi.PackageRevisionLifecyclePublished
+				if err := r.Client.Update(ctx, downstream); err != nil {
+					klog.Errorf("error updating package revision lifecycle: %v", err)
+				}
+			}
 			continue
 		}
 		if porchapi.LifecycleIsPublished(downstream.Spec.Lifecycle) {
@@ -351,25 +391,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/go.mod b/porch/go.mod
index 435c0b5e..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
diff --git a/site/reference/cli/live/apply/README.md b/site/reference/cli/live/apply/README.md
index fe6aac09..5cf38317 100644
--- a/site/reference/cli/live/apply/README.md
+++ b/site/reference/cli/live/apply/README.md
@@ -78,10 +78,6 @@ PKG_PATH | -:
 
   The default value is ‘events’.
 
---poll-period:
-  The frequency with which the cluster will be polled to determine
-  the status of the applied resources. The default value is 2 seconds.
-
 --prune-propagation-policy:
   The propagation policy that should be used when pruning resources. The
   default value here is 'Background'. The other options are 'Foreground' and 'Orphan'.


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)
+}