Repository: kptdev/kpt

Issue:
Package dependencies: expressing ("my package requires") and fulfilling ("my package provides")
### Describe your problem

As we have discussed a few times, packages often have dependencies. They may depend on particular resources existing in the cluster, or they may depend on availability of services running on an endpoint, or may have other types of dependencies. This issue is a spin off of #3351, to provide a single place to capture examples of these dependencies and discuss ways of managing them within `kpt`. It is not the same as `depends-on`, which is about `apply` ordering within a package's resources.

Some examples:
 * A package needs a namespace in which to be provisioned.
 * A package needs a particular CRD to be installed.
 * A package needs a Secret that must be provisioned out-of-band, OR satisfied via an ExternalSecret.
 * A package needs a value from the runtime instantiation of another package (e.g., a load balancer IP address).
 * A package needs a particular service running (e.g., a database), which may be provisioned in-cluster or out-of-cluster.
 * A package needs a particular shared resource to be available; for example, a shared Ingress Gateway.

Each of these may be satisfied in different ways, and some of them may need to be satisfied by a user with different privilege levels than the package deployer (e.g., deploying a namespace vs into a namespace, or deploying a CRD vs a CR).

We would like to be able to express the *requirement* a package has, without the package having an opinion about *how* the requirement is met. For example, if an application package needs a namespace, that could be provisioned manually, or via deployment of a namespace package, or could be added by the deployer to the application package itself. The application package needs to be able to simply say "I need a namespace", and then the Kpt suite of tools needs to identify whether or not that dependency has been satisfied, at the earliest possible time in the package lifecycle (see #3422).

To that end, packages also need a way to say "I satisfy some requirement". This allows the tooling to understand and match the "package requires" and "package provides". However, we need to figure out how that is expressed in each layer of the solution. For example, at runtime this model may be expressed using a "Claim" pattern; it's not clear that works at Config Time.

Another thought is how this relates to the idea of "mixins". One might consider something like Environment (#3280) as being expressed as a dependency - the package depends on some Environment package/mixin being deployed. The user would choose which (dev/stage/prod) one to use to resolve that dependency.


Discussion:
@johnbelamaric: @droot @yuwenma @bgrant0607 @justinsb @droot: Thanks for starting this thread @johnbelamaric 

> A package needs a value from the runtime instantiation of another package (e.g., a load balancer IP address).

Just a note: This one is different. This is more like a runtime dependency mixed with a value propagating from the runtime state (sounds like `apply-time` dependency). 


@johnbelamaric: > Just a note: This one is different. This is more like a runtime dependency mixed with a value propagating from the runtime state (sounds like `apply-time` dependency).

Yes, I'd like to capture various types and discuss strategies for each.@bgrant0607: Do we have an example of value propagation that's not an IP address? IP addresses can be reduced to the service case.@droot: Slightly different but a common scenario:

I have an application package that needs CloudSQL resource. And, CloudSQL resource is satisfied by another infra package that uses KCC resources (Config Connector). The unique thing about this scenario is that the cloudSQL (infra) package will be deployed in the KCC cluster (admin cluster is the common term) while the application package will be deployed in the application workload cluster. So this is a `cross-cluster` package dependency.
 @johnbelamaric: > Do we have an example of value propagation that's not an IP address? IP addresses can be reduced to the service case.

Service is indirection, and is a good solution to this value propagation when there is something to resolve the indirection (DNS in the case of Service).

There are examples of other things that have to be allocated out of a common pool. Some network-y things I can think of: ports (e.g., NodePort), VLAN IDs, maybe VRF names or BGP community strings or AS numbers. Some of these may be allocated on a per-cluster basis (NodePort) at runtime, but some may need to exist across clusters or in some other scope (organizational, regional, etc.). Those likely become inputs to multiple packages, and there is no simple protocol for serving them up via indirection.

Other ideas: I can imagine there are applications that (though not a great design) share a common database, for example. IP address or even IP:Port is not sufficient; they also need schema name. Those could be automatically generated or allocated.

One question: could we simplify this by requiring those allocations to be represented somehow by a package? So that all dependencies are just package dependencies? Probably not a good idea, it may cause too much package sprawl. Resource-level might be better. Or maybe representing the dependency itself as a resource.@johnbelamaric: > Those likely become inputs to multiple packages, and there is no simple protocol for serving them up via indirection.

While this is true, there actually *are* ways to deal with this. You can use DNS TXT records (or the Kube API server, for that matter) to make this an indirection. However, applications do not natively understand that - most applications can't even use DNS SRV records (IP and port), much less mapping a TXT into a database connection string of some sort. So, you then need an init-container or runtime controller (in the workload cluster) that can look up what you need via that mechanism and rewrite the config. This can work but is kludgy and requires workload changes to get it to work. I think we can do better.@johnbelamaric: @justinsb showed a concept of "binding" objects that may satisfy some of the dependency use cases. This is a resource within the package that "advertises" itself as a sort of placeholder. In the particular use case Justin discussed, this was a namespace annotated as a "local-config: binding". Since it was a local-config it wouldn't end up in the cluster, but it does serve to let consumers of the package know that they need to somehow provision a Namespace.

In [Nephio controller PoC demo](https://youtu.be/PwCT_fB-nR0?t=528), I used a ClusterScaleProfile CR that lived in the package to advertise that the package was able to accept this type of object from the context and have a function scale it based on the contents of that CR. This is similar to the binding concept, except that it is implicit in the type. It may be better to make it explicit with an annotation; although, in this case, the resource present in the package serves as default values in case the deployment context doesn't have that resource. So we may want a different annotation value, since it's not mandatory that a binding happens.

In that demo, I have a fan-out controller that injects the ClusterScaleProfile based on an association with the target cluster. It doesn't really need to be "cluster" - a more accurate way to describe this is "deployment context". In [a discussion](https://github.com/GoogleContainerTools/kpt/pull/3456#issuecomment-1219532855) on the WI operator, I asked about making that a function rather than a controller. A similar question can be asked of the way I inject ClusterScaleProfile in the Nephio demo - I do it with a controller, but perhaps it could be done automatically by Porch as part of function input gathering (#3396)?

For simplicity, let's assume a 1:1 ratio of deployment repositories:clusters. We could adjust this to handle 1:N or even N:1 or N:M, but I don't think the basic idea changes materially in that case. Here's how it could work in this case: 
 * A deployment context (or many different CRs representing different types of context) are attached (by name, labels, or reference; TBD) to a deployment repository.  For example, the GCP context object containing the project ID for the cluster reading from that deployment repository, or a ClusterScaleProfile as in the Nephio PoC.
 * The package contains binding objects of various types, some of which must be resolved ("project id"), and others which serve as defaults (a la ClusterScaleProfile). The annotation value probably needs to identify which are which.
 * Porch understands these are bindings, and so when cloning (or really, when *saving*) to a deployment repo - before running the function pipeline - it looks for associated objects of those types, and injects them, overwriting (or patching?) the ones in the package. In the Nephio PoC, I copied just the Spec object, leaving the metadata in place.
 * Those context objects serve as various function inputs, so now when the function pipeline runs, it will take appropriate actions: annotating the KSA in the case of the WI operator, and scaling deployments and configmaps in the case of the ClusterScaleProfile.
 
Notes & Issues:
 * Namespace - the original example for "bindings" - is not a repo or cluster level context, so this doesn't really apply to that type of dependency.
 * We probably want to tie this to Conditions still; I think these are complimentary concepts. A function (or kpt/Porch) can automatically calculate that required contextual inputs are a Condition, and decide if they Condition is met based on whether the deployment repo has an associated contextual input of the correct type.
 * We may find use cases where multiple contextual objects of the same type exist. I can't think of any offhand, but package composition is likely to create this sort of case. If these come up, then type is insufficient for targeting to the bindings, we may need labels or names or something.
 * One issue is what happens when the context objects change. For example, if we increase the capacity of the target cluster. In that case, Porch needs to be watching those objects and proposing changes. I guess that declarative case could be handled separately, by a controller, and not be the default behavior on clone.
@johnbelamaric: Since we are cataloging dependency-related thoughts here I figured I would include this from @BernardTsai-DT in a Nephio [discussion](https://docs.google.com/document/d/1xvuQTAO0n4ggvOaX6s8Y4vxqp_2h8xjB_JJetjlikN0/edit?hl=en&forcehl=1&disco=AAAAfGPXJwc). I thought this was an interesting set of categorizations to think about.

> Dependencies for that purpose need to be categorized, e.g.:
> - A  is hosted on B: a solution component can only be installed within the context of other solution components (for example a 
> network function in a k8s cluster)
> - A is managed by B: a solution component is managed by another solution component (for example the data and control plane of network function)
> - A is served by B: a solution component makes use of services provided by another solution component (for example a web API server and a database backend)
> - A is clustered  with B, C. ...: here several soution components provide the same service but for availability reasons are clustered (for example a database cluster)
>
> These are the bread-and-butter dependencies which would probably address 80% of the typical dependencies to be considered. For more complex dependencies I would then recommend to make use of special controllers which would reflect more complex constraints, e.g. reconfiguration of a 5G network slice which would implicitely require the introduction of a different solution architecture (for example customer upgrades his network slice from standard shared setup to a setup with dedicated edge UPF).
@yuwenma: Some examples from porch dogfooding (it uses Porch UI  to create CNRM GCP project, cluster, etc and and install porch to the new cluster in the other project):
1. deployment-tolerant requester
 - "environment" package(s) is the "provider" 
 - "gkeCluster" package is the "requester"
 - The "gkeCluster" does not require "environment" package to be deployed first. This is unlike the "namespace" provisioning where "namespace" has to be deployed first. 
2. server-side "live" provider  
 - "gkeCluster" package is the "provider"
 - "Porch" package is the "requester" 
 - The porch package requests the  the cluster to be deployed and have some field data updated by the server, specifically the CA Certificate (see dogfooding user guide step 5.8). This basically requests a "live" resource. We may want to discuss whether porch should support it or not.
3.  "passive" requester
 - higher privilege team provide the "IAM" role package(s)
 - lower privilege team needs the "IAM" to be preset.
 - "IAM" role package(s) are set by people with higher privilege. So it normally goes with the "provider" package and it is out of the "requester" package control, or even the "requester" does not know what IAM role they need. This is a passive "require-provide" scenario. One big challenge is that how we can validate the IAM role on the downstream "requester" side (via KRM function?) since all key data is hold by the upstream "provider".@johnbelamaric: Just as an FYI, we are looking towards doing *something* with respect to this in the Nephio R1 timeframe. 

Quoting what I write in https://docs.google.com/document/d/14PYu1Y6h1IXRwuhSY3CKCiuprsduOvMp_Bb4fVGhFD0/edit#:

We can deliver this in the Porch or in Nephio, it’s up to us - but I suspect if we build it in Nephio we may want to eventually upstream it to Porch, it is quite general purpose in its utility.

This is a set of CRD to represent some basic dependencies, and a controller that can propose additional packages to fulfill those dependencies.

Some of those dependencies could be explicit, and some implicit. Explicit dependencies must be declared by the package author; implicit dependencies may be discovered by the system by examining the package contents.

Some implicit examples:
* If a package contains namespaced resources, the namespace must exist.
* If a package contains a CR, the CRD must be loaded in the cluster.

Some explicit examples:
* A package may need some other service (e.g., a database), but does not embed that service within itself.
* A package may need a Secret that is provisioned out-of-band.

Each dependency may be resolved in many different ways. For example, a namespace resource could already exist in the destination cluster, or we could add the resource directly in the package, or we could propose a separate package to be deployed that will provision the namespace. We’ll need to figure out how the controller decides (i.e., how we specify policies) which of these mechanisms to use to resolve a given dependency.

The same conditions mechanism we used for IPAM can be used for dependency management, with each dependency representing a condition that must be resolved before we can approve the package for deployment.@liamfallon: Duplicated to Porch in the Nephio project: https://github.com/nephio-project/nephio/issues/653

Solution patch:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index cbdd1bda..7c93a44f 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -71,7 +71,8 @@ jobs:
       actions: read # To read the workflow path.
       id-token: write # To sign the provenance.
       contents: write # To add assets to a release.
-    uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v1.2.0
+    uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v1.2.1
     with:
+      compile-generator: true # Workaround for https://github.com/slsa-framework/slsa-github-generator/issues/1163
       base64-subjects: "${{ needs.build.outputs.hashes }}"
       upload-assets: true # upload to a new release
diff --git a/Formula/kpt.rb b/Formula/kpt.rb
index 817166d3..2bd485c4 100644
--- a/Formula/kpt.rb
+++ b/Formula/kpt.rb
@@ -15,8 +15,8 @@
 class Kpt < Formula
   desc "Toolkit to manage,and apply Kubernetes Resource config data files"
   homepage "https://googlecontainertools.github.io/kpt"
-  url "https://github.com/GoogleContainerTools/kpt/archive/v1.0.0-beta.21.tar.gz"
-  sha256 "ceb5f4523bd1f7d5574652d4d97f92f6028d8f59302cd69ba6b3b1fa62e56295"
+  url "https://github.com/GoogleContainerTools/kpt/archive/v1.0.0-beta.23.tar.gz"
+  sha256 "45c34e7f6fb8246dbe1b35769d988fe18c64d668b2fba747d45fd1e22eacf99b"
 
   depends_on "go" => :build
 
diff --git a/commands/alpha/rpkg/push/command.go b/commands/alpha/rpkg/push/command.go
index c7be0abe..6d514a70 100644
--- a/commands/alpha/rpkg/push/command.go
+++ b/commands/alpha/rpkg/push/command.go
@@ -23,9 +23,11 @@ import (
 	"os"
 	"path"
 	"path/filepath"
+	"strings"
 
 	"github.com/GoogleContainerTools/kpt/internal/docs/generated/rpkgdocs"
 	"github.com/GoogleContainerTools/kpt/internal/errors"
+	"github.com/GoogleContainerTools/kpt/internal/fnruntime"
 	"github.com/GoogleContainerTools/kpt/internal/printer"
 	"github.com/GoogleContainerTools/kpt/internal/util/porch"
 	porchapi "github.com/GoogleContainerTools/kpt/porch/api/porch/v1alpha1"
@@ -117,7 +119,7 @@ func (r *runner) runE(cmd *cobra.Command, args []string) error {
 		return errors.E(op, err)
 	}
 
-	if err := r.client.Update(r.ctx, &porchapi.PackageRevisionResources{
+	pkgResources := porchapi.PackageRevisionResources{
 		TypeMeta: metav1.TypeMeta{
 			Kind:       "PackageRevisionResources",
 			APIVersion: porchapi.SchemeGroupVersion.Identifier(),
@@ -129,12 +131,86 @@ func (r *runner) runE(cmd *cobra.Command, args []string) error {
 		Spec: porchapi.PackageRevisionResourcesSpec{
 			Resources: resources,
 		},
-	}); err != nil {
+	}
+	if err := r.client.Update(r.ctx, &pkgResources); err != nil {
 		return errors.E(op, err)
 	}
+	rs := pkgResources.Status.RenderStatus
+	if rs.Err != "" {
+		r.printer.Printf("Package is updated, but failed to render the package.\n")
+		r.printer.Printf("Error: %s\n", rs.Err)
+	}
+	if len(rs.Result.Items) > 0 {
+		for _, result := range rs.Result.Items {
+			r.printer.Printf("[RUNNING] %q \n", result.Image)
+			printOpt := printer.NewOpt()
+			if result.ExitCode != 0 {
+				r.printer.OptPrintf(printOpt, "[FAIL] %q\n", result.Image)
+			} else {
+				r.printer.OptPrintf(printOpt, "[PASS] %q\n", result.Image)
+			}
+			r.printFnResult(result, printOpt)
+		}
+	}
 	return nil
 }
 
+// printFnResult prints given function result in a user friendly
+// format on kpt CLI.
+func (r *runner) printFnResult(fnResult *porchapi.Result, opt *printer.Options) {
+	if len(fnResult.Results) > 0 {
+		// function returned structured results
+		var lines []string
+		for _, item := range fnResult.Results {
+			lines = append(lines, str(item))
+		}
+		ri := &fnruntime.MultiLineFormatter{
+			Title:          "Results",
+			Lines:          lines,
+			TruncateOutput: printer.TruncateOutput,
+		}
+		r.printer.OptPrintf(opt, "%s", ri.String())
+	}
+}
+
+// String provides a human-readable message for the result item
+func str(i porchapi.ResultItem) string {
+	identifier := i.ResourceRef
+	var idStringList []string
+	if identifier != nil {
+		if identifier.APIVersion != "" {
+			idStringList = append(idStringList, identifier.APIVersion)
+		}
+		if identifier.Kind != "" {
+			idStringList = append(idStringList, identifier.Kind)
+		}
+		if identifier.Namespace != "" {
+			idStringList = append(idStringList, identifier.Namespace)
+		}
+		if identifier.Name != "" {
+			idStringList = append(idStringList, identifier.Name)
+		}
+	}
+	formatString := "[%s]"
+	severity := i.Severity
+	// We default Severity to Info when converting a result to a message.
+	if i.Severity == "" {
+		severity = "info"
+	}
+	list := []interface{}{severity}
+	if len(idStringList) > 0 {
+		formatString += " %s"
+		list = append(list, strings.Join(idStringList, "/"))
+	}
+	if i.Field != nil {
+		formatString += " %s"
+		list = append(list, i.Field.Path)
+	}
+	formatString += ": %s"
+	list = append(list, i.Message)
+	return fmt.Sprintf(formatString, list...)
+}
+
 func readFromDir(dir string) (map[string]string, error) {
 	resources := map[string]string{}
 	if err := filepath.Walk(dir, func(path string, info fs.FileInfo, err error) error {
diff --git a/go.mod b/go.mod
index c5e57afa..dfbea9e8 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-20221027171429-622c0141f3d7
+	github.com/GoogleContainerTools/kpt/porch/api v0.0.0-20221028161857-aa271f292cc0
 	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 c3c07a1a..a509372d 100644
--- a/go.sum
+++ b/go.sum
@@ -87,6 +87,8 @@ github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym
 github.com/Djarvur/go-err113 v0.0.0-20210108212216-aea10b59be24/go.mod h1:4UJr5HIiMZrwgkSPdsjy2uOQExX/WEILpIrO9UPGuXs=
 github.com/GoogleContainerTools/kpt/porch/api v0.0.0-20221027171429-622c0141f3d7 h1:WKfyM9TDvHaGJ0n98rO/U7qkPL8wAWPvLKQYNT7TNvY=
 github.com/GoogleContainerTools/kpt/porch/api v0.0.0-20221027171429-622c0141f3d7/go.mod h1:ASrhnLAL4ahTuiUJyepqcpVRXIoRMJyDs8/eSxwhgZM=
+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/MakeNowJust/heredoc v0.0.0-20170808103936-bb23615498cd h1:sjQovDkwrZp8u+gxLtPgKGjk5hCxuy2hrRejBTA9xFU=
 github.com/MakeNowJust/heredoc v0.0.0-20170808103936-bb23615498cd/go.mod h1:64YHyfSL2R96J44Nlwm39UHepQbyR5q10x7iYa1ks2E=
 github.com/Masterminds/goutils v1.1.0/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
diff --git a/internal/fnruntime/fnerrors.go b/internal/fnruntime/fnerrors.go
index 988c6d36..3cf6a341 100644
--- a/internal/fnruntime/fnerrors.go
+++ b/internal/fnruntime/fnerrors.go
@@ -45,7 +45,7 @@ type ExecError struct {
 func (fe *ExecError) String() string {
 	var b strings.Builder
 
-	errLines := &multiLineFormatter{
+	errLines := &MultiLineFormatter{
 		Title:          "Stderr",
 		Lines:          strings.Split(fe.Stderr, "\n"),
 		UseQuote:       true,
diff --git a/internal/fnruntime/runner.go b/internal/fnruntime/runner.go
index a3a9490a..a83db137 100644
--- a/internal/fnruntime/runner.go
+++ b/internal/fnruntime/runner.go
@@ -295,10 +295,15 @@ func (fr *FunctionRunner) do(input []*yaml.RNode) (output []*yaml.RNode, err err
 	}
 	if err != nil {
 		var execErr *ExecError
+		// set exitCode to non-zero by default in case of an error.
+		// It will be overridden with appropriate exitCode if the function runtime returns execError.
+		// builtinruntime and podEvaluator function runtime do not return execError so having
+		// a default is important.
+		fnResult.ExitCode = 1
+		fr.fnResults.ExitCode = 1
 		if goerrors.As(err, &execErr) {
 			fnResult.ExitCode = execErr.ExitCode
 			fnResult.Stderr = execErr.Stderr
-			fr.fnResults.ExitCode = 1
 		}
 		// accumulate the results
 		fr.fnResults.Items = append(fr.fnResults.Items, *fnResult)
@@ -427,7 +432,7 @@ func printFnResult(ctx context.Context, fnResult *fnresult.Result, opt *printer.
 		for _, item := range fnResult.Results {
 			lines = append(lines, item.String())
 		}
-		ri := &multiLineFormatter{
+		ri := &MultiLineFormatter{
 			Title:          "Results",
 			Lines:          lines,
 			TruncateOutput: printer.TruncateOutput,
@@ -448,7 +453,7 @@ func printFnExecErr(ctx context.Context, fnErr *ExecError) {
 func printFnStderr(ctx context.Context, stdErr string) {
 	pr := printer.FromContextOrDie(ctx)
 	if len(stdErr) > 0 {
-		errLines := &multiLineFormatter{
+		errLines := &MultiLineFormatter{
 			Title:          "Stderr",
 			Lines:          strings.Split(stdErr, "\n"),
 			UseQuote:       true,
@@ -492,9 +497,9 @@ func enforcePathInvariants(nodes []*yaml.RNode) error {
 	return nil
 }
 
-// multiLineFormatter knows how to format multiple lines in pretty format
+// MultiLineFormatter knows how to format multiple lines in pretty format
 // that can be displayed to an end user.
-type multiLineFormatter struct {
+type MultiLineFormatter struct {
 	// Title under which lines need to be printed
 	Title string
 
@@ -512,7 +517,7 @@ type multiLineFormatter struct {
 }
 
 // String returns multiline string.
-func (ri *multiLineFormatter) String() string {
+func (ri *MultiLineFormatter) String() string {
 	if ri.MaxLines == 0 {
 		ri.MaxLines = FnExecErrorTruncateLines
 	}
diff --git a/porch/api/generated/clientset/versioned/fake/register.go b/porch/api/generated/clientset/versioned/fake/register.go
index 2b1d2df7..36b9da3c 100644
--- a/porch/api/generated/clientset/versioned/fake/register.go
+++ b/porch/api/generated/clientset/versioned/fake/register.go
@@ -35,14 +35,14 @@ var localSchemeBuilder = runtime.SchemeBuilder{
 // AddToScheme adds all types of this clientset into the given scheme. This allows composition
 // of clientsets, like in:
 //
-//	import (
-//	  "k8s.io/client-go/kubernetes"
-//	  clientsetscheme "k8s.io/client-go/kubernetes/scheme"
-//	  aggregatorclientsetscheme "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset/scheme"
-//	)
+//   import (
+//     "k8s.io/client-go/kubernetes"
+//     clientsetscheme "k8s.io/client-go/kubernetes/scheme"
+//     aggregatorclientsetscheme "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset/scheme"
+//   )
 //
-//	kclientset, _ := kubernetes.NewForConfig(c)
-//	_ = aggregatorclientsetscheme.AddToScheme(clientsetscheme.Scheme)
+//   kclientset, _ := kubernetes.NewForConfig(c)
+//   _ = aggregatorclientsetscheme.AddToScheme(clientsetscheme.Scheme)
 //
 // After this, RawExtensions in Kubernetes types will serialize kube-aggregator types
 // correctly.
diff --git a/porch/api/generated/clientset/versioned/scheme/register.go b/porch/api/generated/clientset/versioned/scheme/register.go
index 90fc1da2..cee00d25 100644
--- a/porch/api/generated/clientset/versioned/scheme/register.go
+++ b/porch/api/generated/clientset/versioned/scheme/register.go
@@ -35,14 +35,14 @@ var localSchemeBuilder = runtime.SchemeBuilder{
 // AddToScheme adds all types of this clientset into the given scheme. This allows composition
 // of clientsets, like in:
 //
-//	import (
-//	  "k8s.io/client-go/kubernetes"
-//	  clientsetscheme "k8s.io/client-go/kubernetes/scheme"
-//	  aggregatorclientsetscheme "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset/scheme"
-//	)
+//   import (
+//     "k8s.io/client-go/kubernetes"
+//     clientsetscheme "k8s.io/client-go/kubernetes/scheme"
+//     aggregatorclientsetscheme "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset/scheme"
+//   )
 //
-//	kclientset, _ := kubernetes.NewForConfig(c)
-//	_ = aggregatorclientsetscheme.AddToScheme(clientsetscheme.Scheme)
+//   kclientset, _ := kubernetes.NewForConfig(c)
+//   _ = aggregatorclientsetscheme.AddToScheme(clientsetscheme.Scheme)
 //
 // After this, RawExtensions in Kubernetes types will serialize kube-aggregator types
 // correctly.
diff --git a/porch/api/porch/types_packagerevisions.go b/porch/api/porch/types_packagerevisions.go
index a5eab8ac..3c65483d 100644
--- a/porch/api/porch/types_packagerevisions.go
+++ b/porch/api/porch/types_packagerevisions.go
@@ -347,27 +347,6 @@ const (
 	ConditionUnknown ConditionStatus = "Unknown"
 )
 
-// Result contains the structured result from an individual function
-type Result struct {
-	// Image is the full name of the image that generates this result
-	// Image and Exec are mutually exclusive
-	Image string `json:"image,omitempty"`
-	// ExecPath is the the absolute os-specific path to the executable file
-	// If user provides an executable file with commands, ExecPath should
-	// contain the entire input string.
-	ExecPath string `json:"exec,omitempty"`
-	// TODO(droot): This is required for making structured results subpackage aware.
-	// Enable this once test harness supports filepath based assertions.
-	// Pkg is OS specific Absolute path to the package.
-	// Pkg string `yaml:"pkg,omitempty"`
-	// Stderr is the content in function stderr
-	Stderr string `json:"stderr,omitempty"`
-	// ExitCode is the exit code from running the function
-	ExitCode int `json:"exitCode"`
-	// Results is the list of results for the function
-	Results []ResultItem `json:"results,omitempty"`
-}
-
 const (
 	// Deprecated: prefer ResultListGVK
 	ResultListKind = "FunctionResultList"
@@ -380,7 +359,6 @@ const (
 )
 
 // ResultList contains aggregated results from multiple functions
-// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
 type ResultList struct {
 	metav1.TypeMeta   `json:",inline"`
 	metav1.ObjectMeta `json:"metadata,omitempty"`
@@ -390,6 +368,27 @@ type ResultList struct {
 	Items []*Result `json:"items,omitempty"`
 }
 
+// Result contains the structured result from an individual function
+type Result struct {
+	// Image is the full name of the image that generates this result
+	// Image and Exec are mutually exclusive
+	Image string `json:"image,omitempty"`
+	// ExecPath is the the absolute os-specific path to the executable file
+	// If user provides an executable file with commands, ExecPath should
+	// contain the entire input string.
+	ExecPath string `json:"exec,omitempty"`
+	// TODO(droot): This is required for making structured results subpackage aware.
+	// Enable this once test harness supports filepath based assertions.
+	// Pkg is OS specific Absolute path to the package.
+	// Pkg string `yaml:"pkg,omitempty"`
+	// Stderr is the content in function stderr
+	Stderr string `json:"stderr,omitempty"`
+	// ExitCode is the exit code from running the function
+	ExitCode int `json:"exitCode"`
+	// Results is the list of results for the function
+	Results []ResultItem `json:"results,omitempty"`
+}
+
 // ResultItem defines a validation result
 type ResultItem struct {
 	// Message is a human readable message. This field is required.
diff --git a/porch/api/porch/v1alpha1/types_packagerevisions.go b/porch/api/porch/v1alpha1/types_packagerevisions.go
index 4a7b3593..1f018d71 100644
--- a/porch/api/porch/v1alpha1/types_packagerevisions.go
+++ b/porch/api/porch/v1alpha1/types_packagerevisions.go
@@ -358,27 +358,6 @@ const (
 	ConditionUnknown ConditionStatus = "Unknown"
 )
 
-// Result contains the structured result from an individual function
-type Result struct {
-	// Image is the full name of the image that generates this result
-	// Image and Exec are mutually exclusive
-	Image string `json:"image,omitempty"`
-	// ExecPath is the the absolute os-specific path to the executable file
-	// If user provides an executable file with commands, ExecPath should
-	// contain the entire input string.
-	ExecPath string `json:"exec,omitempty"`
-	// TODO(droot): This is required for making structured results subpackage aware.
-	// Enable this once test harness supports filepath based assertions.
-	// Pkg is OS specific Absolute path to the package.
-	// Pkg string `yaml:"pkg,omitempty"`
-	// Stderr is the content in function stderr
-	Stderr string `json:"stderr,omitempty"`
-	// ExitCode is the exit code from running the function
-	ExitCode int `json:"exitCode"`
-	// Results is the list of results for the function
-	Results []ResultItem `json:"results,omitempty"`
-}
-
 const (
 	// Deprecated: prefer ResultListGVK
 	ResultListKind = "FunctionResultList"
@@ -391,7 +370,6 @@ const (
 )
 
 // ResultList contains aggregated results from multiple functions
-// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
 type ResultList struct {
 	metav1.TypeMeta   `json:",inline"`
 	metav1.ObjectMeta `json:"metadata,omitempty"`
@@ -401,6 +379,27 @@ type ResultList struct {
 	Items []*Result `json:"items,omitempty"`
 }
 
+// Result contains the structured result from an individual function
+type Result struct {
+	// Image is the full name of the image that generates this result
+	// Image and Exec are mutually exclusive
+	Image string `json:"image,omitempty"`
+	// ExecPath is the the absolute os-specific path to the executable file
+	// If user provides an executable file with commands, ExecPath should
+	// contain the entire input string.
+	ExecPath string `json:"exec,omitempty"`
+	// TODO(droot): This is required for making structured results subpackage aware.
+	// Enable this once test harness supports filepath based assertions.
+	// Pkg is OS specific Absolute path to the package.
+	// Pkg string `yaml:"pkg,omitempty"`
+	// Stderr is the content in function stderr
+	Stderr string `json:"stderr,omitempty"`
+	// ExitCode is the exit code from running the function
+	ExitCode int `json:"exitCode"`
+	// Results is the list of results for the function
+	Results []ResultItem `json:"results,omitempty"`
+}
+
 // ResultItem defines a validation result
 type ResultItem struct {
 	// Message is a human readable message. This field is required.
diff --git a/porch/api/porch/v1alpha1/zz_generated.deepcopy.go b/porch/api/porch/v1alpha1/zz_generated.deepcopy.go
index a2ad63a8..c8eecf26 100644
--- a/porch/api/porch/v1alpha1/zz_generated.deepcopy.go
+++ b/porch/api/porch/v1alpha1/zz_generated.deepcopy.go
@@ -932,14 +932,6 @@ func (in *ResultList) DeepCopy() *ResultList {
 	return out
 }
 
-// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
-func (in *ResultList) 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 *SecretRef) DeepCopyInto(out *SecretRef) {
 	*out = *in
diff --git a/porch/api/porch/zz_generated.deepcopy.go b/porch/api/porch/zz_generated.deepcopy.go
index f8967902..9d41fa35 100644
--- a/porch/api/porch/zz_generated.deepcopy.go
+++ b/porch/api/porch/zz_generated.deepcopy.go
@@ -932,14 +932,6 @@ func (in *ResultList) DeepCopy() *ResultList {
 	return out
 }
 
-// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
-func (in *ResultList) 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 *SecretRef) DeepCopyInto(out *SecretRef) {
 	*out = *in
diff --git a/porch/controllers/klippy/README.md b/porch/controllers/klippy/README.md
new file mode 100644
index 00000000..b33536bb
--- /dev/null
+++ b/porch/controllers/klippy/README.md
@@ -0,0 +1,44 @@
+# Overview
+
+klippy is a controller that proposes creating packages as a child of a
+parent package, where all the bindings can be satisfied.
+
+The idea is that packages in this "proposed by robot" state can
+provide a guided authoring experience, and we can communicate these
+paths through the existing API.
+
+The controller and the idea of package proposals should be considered
+exploration / experimental.
+
+# Bindings
+
+The idea of bindings is that a blueprint author can include some
+objects that are specially marked with the
+`config.kubernetes.io/local-config` = `binding` annotation.  Those are
+binding objects.
+
+Because those binding objects are `local-config`, they will not be
+applied to the cluster as part of the package.  Instead, those objects
+normally come from a parent package.
+
+The value of a binding object comes when we replace the placeholder
+values with the actual values from the parent.  We do a semantically
+aware rename, so - for example - if a binding objects is a namespace,
+all the objects in the binding placeholder namespace would be changed
+to be in the newly bound namespace.
+
+If the object is something like a ConfigMap, we would update all the
+references to that ConfigMap, for example in pod volumes.
+
+# klippy: auto-binding
+
+The idea of the klippy controller therefore is to eagerly look for
+places where we can instantiate a child package under a parent
+package, where all the bindings can be satisfied.
+
+We match bindings based on the Group/Version/Kind.  Additionally, if
+the binding object has labels, we'll look for those labels on the
+parent package object (this was needed because otherwise we were
+over-proposing on common objects like Namespaces, in practice we need
+some sort of "intent" label on Namespaces or GCP Projects/Folders,
+that indicates what we expect them to contain)
diff --git a/porch/controllers/klippy/config/rbac/role.yaml b/porch/controllers/klippy/config/rbac/role.yaml
new file mode 100644
index 00000000..b243eb7e
--- /dev/null
+++ b/porch/controllers/klippy/config/rbac/role.yaml
@@ -0,0 +1,61 @@
+# 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-klippy
+rules:
+- apiGroups:
+  - porch.kpt.dev
+  resources:
+  - packagerevisionresources
+  verbs:
+  - create
+  - delete
+  - get
+  - list
+  - patch
+  - update
+  - watch
+- apiGroups:
+  - porch.kpt.dev
+  resources:
+  - packagerevisionresources/status
+  verbs:
+  - get
+  - patch
+  - update
+- apiGroups:
+  - porch.kpt.dev
+  resources:
+  - packagerevisions
+  verbs:
+  - create
+  - delete
+  - get
+  - list
+  - patch
+  - update
+  - watch
+- apiGroups:
+  - porch.kpt.dev
+  resources:
+  - packagerevisions/status
+  verbs:
+  - get
+  - patch
+  - update
diff --git a/porch/controllers/klippy/config/rbac/rolebinding.yaml b/porch/controllers/klippy/config/rbac/rolebinding.yaml
new file mode 100644
index 00000000..8e41a8c7
--- /dev/null
+++ b/porch/controllers/klippy/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-klippy
+roleRef:
+  apiGroup: rbac.authorization.k8s.io
+  kind: ClusterRole
+  name: porch-controllers-klippy
+subjects:
+- kind: ServiceAccount
+  name: porch-controllers
+  namespace: porch-system
\ No newline at end of file
diff --git a/porch/controllers/klippy/doc.go b/porch/controllers/klippy/doc.go
new file mode 100644
index 00000000..481427c6
--- /dev/null
+++ b/porch/controllers/klippy/doc.go
@@ -0,0 +1,17 @@
+// 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 klippy
+
+//go:generate go run sigs.k8s.io/controller-tools/cmd/controller-gen@v0.8.0 rbac:roleName=porch-controllers-klippy webhook paths="./..." output:rbac:artifacts:config=config/rbac
diff --git a/porch/controllers/klippy/pkg/controllers/klippy/klippy_controller.go b/porch/controllers/klippy/pkg/controllers/klippy/klippy_controller.go
new file mode 100644
index 00000000..73503a00
--- /dev/null
+++ b/porch/controllers/klippy/pkg/controllers/klippy/klippy_controller.go
@@ -0,0 +1,443 @@
+// 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 klippy
+
+import (
+	"context"
+	"flag"
+	"fmt"
+	"path/filepath"
+	"strings"
+	"unicode"
+
+	api "github.com/GoogleContainerTools/kpt/porch/api/porch/v1alpha1"
+	"github.com/GoogleContainerTools/kpt/porch/controllers/remoterootsyncsets/pkg/applyset"
+	"k8s.io/apimachinery/pkg/api/meta"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/client-go/dynamic"
+	ctrl "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/log"
+	"sigs.k8s.io/yaml"
+)
+
+type Options struct {
+	// BindFunction is the image name for the "bind" function
+	BindFunction string
+}
+
+func (o *Options) InitDefaults() {
+	o.BindFunction = "bind"
+}
+
+func (o *Options) BindFlags(prefix string, flagset *flag.FlagSet) {
+	flagset.StringVar(&o.BindFunction, prefix+"bindFunction", o.BindFunction, "image name for the bind function")
+}
+
+// KlippyReconciler reconciles Klippy objects
+type KlippyReconciler struct {
+	Options
+
+	client client.Client
+
+	dynamicClient dynamic.Interface
+	restMapper    meta.RESTMapper
+}
+
+//+kubebuilder:rbac:groups=porch.kpt.dev,resources=packagerevisionresources,verbs=get;list;watch;create;update;patch;delete
+//+kubebuilder:rbac:groups=porch.kpt.dev,resources=packagerevisionresources/status,verbs=get;update;patch
+
+//+kubebuilder:rbac:groups=porch.kpt.dev,resources=packagerevisions,verbs=get;list;watch;create;update;patch;delete
+//+kubebuilder:rbac:groups=porch.kpt.dev,resources=packagerevisions/status,verbs=get;update;patch
+
+// Reconcile implements the main kubernetes reconciliation loop.
+func (r *KlippyReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
+	log := log.FromContext(ctx)
+
+	log.Info("reconciling object", "id", req.NamespacedName)
+
+	var parent api.PackageRevision
+	if err := r.client.Get(ctx, req.NamespacedName, &parent); err != nil {
+		return ctrl.Result{}, client.IgnoreNotFound(err)
+	}
+
+	// TODO: Can we filter the watch?
+	if !parent.Status.Deployment {
+		log.V(2).Info("ignoring package not in deployments repository", "package", parent.Spec.PackageName)
+		return ctrl.Result{}, nil
+	}
+
+	var parentResources api.PackageRevisionResources
+	if err := r.client.Get(ctx, req.NamespacedName, &parentResources); err != nil {
+		// Not found here is unexpected
+		return ctrl.Result{}, err
+	}
+
+	if err := r.reconcile(ctx, &parent, &parentResources); err != nil {
+		// TODO: raise event?
+		log.Error(err, "error reconciling", "id", req.NamespacedName)
+		return ctrl.Result{}, err
+	}
+
+	return ctrl.Result{}, nil
+}
+
+func isWhitespace(s string) bool {
+	for _, r := range s {
+		if !unicode.IsSpace(r) {
+			return false
+		}
+	}
+	return true
+}
+
+func (r *KlippyReconciler) reconcile(ctx context.Context, parent *api.PackageRevision, parentResources *api.PackageRevisionResources) error {
+	log := log.FromContext(ctx)
+
+	var blueprintPackageRevisions api.PackageRevisionList
+	if err := r.client.List(ctx, &blueprintPackageRevisions); err != nil {
+		return fmt.Errorf("error listing blueprints: %w", err)
+	}
+
+	// TODO: We should be able to cache this
+	blueprints, err := r.parseBlueprints(ctx, &blueprintPackageRevisions)
+	if err != nil {
+		return err
+	}
+
+	parentObjects, err := parseObjects(ctx, parentResources)
+	if err != nil {
+		return err
+	}
+
+	var allProposals []*api.PackageRevision
+	for _, blueprint := range blueprints {
+		proposals, err := r.buildProposals(ctx, parent, parentObjects, blueprint)
+		if err != nil {
+			return err
+		}
+		allProposals = append(allProposals, proposals...)
+	}
+
+	log.Info("built proposals", "proposal", allProposals)
+	if err := r.storeProposals(ctx, parent, allProposals); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func (r *KlippyReconciler) parseBlueprints(ctx context.Context, packageRevisions *api.PackageRevisionList) ([]*blueprint, error) {
+	var blueprints []*blueprint
+
+	for i := range packageRevisions.Items {
+		packageRevision := &packageRevisions.Items[i]
+
+		// Only match blueprint packages
+		// TODO: Push-down into a field selector?
+		if packageRevision.Status.Deployment {
+			continue
+		}
+
+		// TODO: Cache
+
+		var packageRevisionResources api.PackageRevisionResources
+		id := types.NamespacedName{
+			Namespace: packageRevision.Namespace,
+			Name:      packageRevision.Name,
+		}
+		if err := r.client.Get(ctx, id, &packageRevisionResources); err != nil {
+			return nil, fmt.Errorf("error fetching PackageRevisionResources %v: %w", id, err)
+		}
+
+		objects, err := parseObjects(ctx, &packageRevisionResources)
+		if err != nil {
+			return nil, err
+		}
+		blueprint := &blueprint{
+			Objects:     objects,
+			ID:          id,
+			PackageName: packageRevision.Spec.PackageName,
+		}
+		blueprints = append(blueprints, blueprint)
+	}
+	return blueprints, nil
+}
+
+func parseObjects(ctx context.Context, subject *api.PackageRevisionResources) ([]*unstructured.Unstructured, error) {
+	log := log.FromContext(ctx)
+
+	// TODO: Does this function exist somewhere?
+	var objects []*unstructured.Unstructured
+
+	for path, contents := range subject.Spec.Resources {
+		ext := filepath.Ext(path)
+		ext = strings.ToLower(ext)
+
+		switch ext {
+		case ".yaml", ".yml":
+			// TODO: Use https://github.com/kubernetes-sigs/kustomize/blob/a5b61016bb40c30dd1b0a78290b28b2330a0383e/kyaml/kio/byteio_reader.go#L170 or similar?
+			for _, s := range strings.Split(contents, "\n---\n") {
+				if isWhitespace(s) {
+					continue
+				}
+
+				o := &unstructured.Unstructured{}
+				if err := yaml.Unmarshal([]byte(s), o); err != nil {
+					return nil, fmt.Errorf("error parsing package as kubernetes object: %w", err)
+				}
+
+				// TODO: sync with kpt logic; skip objects marked with the local-only annotation
+				objects = append(objects, o)
+			}
+
+		default:
+			log.V(2).Info("skipping file with unhandled extension", "path", path)
+		}
+	}
+
+	return objects, nil
+}
+
+type bindingSlotRequirements struct {
+	MatchLabels map[string]string
+}
+
+type blueprint struct {
+	ID          types.NamespacedName
+	PackageName string
+	Objects     []*unstructured.Unstructured
+}
+
+func (r *KlippyReconciler) buildProposals(ctx context.Context, parent *api.PackageRevision, parentObjects []*unstructured.Unstructured, blueprint *blueprint) ([]*api.PackageRevision, error) {
+	log := log.FromContext(ctx)
+
+	var proposals []*api.PackageRevision
+
+	slots := make(map[schema.GroupKind]*bindingSlotRequirements)
+	for _, obj := range blueprint.Objects {
+		annotations := obj.GetAnnotations()
+		if annotations["config.kubernetes.io/local-config"] != "binding" {
+			continue
+		}
+
+		gk := obj.GroupVersionKind().GroupKind()
+
+		requirements := &bindingSlotRequirements{}
+		requirements.MatchLabels = obj.GetLabels()
+
+		slots[gk] = requirements
+	}
+
+	if len(slots) == 0 {
+		// Though technically this is "a match", it tends to be incorrect and leads to over-proposing
+		return nil, nil
+	}
+
+	isMatch := false
+	bindings := make(map[schema.GroupKind]*unstructured.Unstructured)
+	{
+		for _, obj := range parentObjects {
+			annotations := obj.GetAnnotations()
+			if annotations["config.kubernetes.io/local-config"] == "binding" {
+				// Ignore binding objects in the "parent"; they don't satisfy bindings
+				continue
+			}
+
+			gk := obj.GroupVersionKind().GroupKind()
+
+			requirements, found := slots[gk]
+			if !found {
+				continue
+			}
+
+			matchesLabels := true
+			for k, v := range requirements.MatchLabels {
+				if obj.GetLabels()[k] != v {
+					matchesLabels = false
+				}
+			}
+			if !matchesLabels {
+				continue
+			}
+			bindings[gk] = obj
+		}
+
+		isMatch = len(bindings) == len(slots)
+		if !isMatch {
+			log.Info("package does not match", "parent", parent.Spec.PackageName, "child", blueprint.PackageName)
+		}
+	}
+
+	if isMatch {
+		log.Info("matched package", "parent", parent.Spec.PackageName, "child", blueprint.PackageName)
+		// TODO: How to avoid collisions
+		name := "packagename-" + strings.ReplaceAll(blueprint.PackageName, "/", "-")
+		packageName := blueprint.PackageName
+
+		clone := &api.PackageCloneTaskSpec{}
+		clone.Upstream = api.UpstreamPackage{
+			UpstreamRef: &api.PackageRevisionRef{
+				Name: blueprint.ID.Name,
+			},
+		}
+
+		parentName := parent.Spec.PackageName
+		objectName := packageName + "-" + parentName + "-" + name
+
+		// TODO: How to sanitize?
+		// packageName can be something like dir/name, and that isn't allowed in names
+		objectName = strings.ReplaceAll(objectName, "/", "-")
+
+		proposal := &api.PackageRevision{
+			TypeMeta: metav1.TypeMeta{
+				Kind:       "PackageRevision",
+				APIVersion: api.SchemeGroupVersion.Identifier(),
+			},
+			ObjectMeta: metav1.ObjectMeta{
+				Namespace: parent.GetNamespace(),
+				Name:      objectName,
+			},
+			Spec: api.PackageRevisionSpec{
+				PackageName:    name,
+				Revision:       "v1",
+				RepositoryName: parent.Spec.RepositoryName,
+				// TODO: Speculative: true,
+				Tasks: []api.Task{
+					{
+						Type:  api.TaskTypeClone,
+						Clone: clone,
+					},
+				},
+			},
+		}
+		proposal.Labels = map[string]string{
+			"alpha.kpt.dev/proposal": "",
+		}
+		proposal.Spec.Parent = &api.ParentReference{
+			Name: parent.Name,
+		}
+
+		// TODO: Share the workspace?  Create a new one?
+		proposal.Spec.WorkspaceName = parent.Spec.WorkspaceName
+
+		for _, bindingObj := range bindings {
+			bindingCore := &unstructured.Unstructured{}
+			bindingCore.SetAPIVersion(bindingObj.GetAPIVersion())
+			bindingCore.SetKind(bindingObj.GetKind())
+			bindingCore.SetName(bindingObj.GetName())
+			if bindingObj.GetNamespace() != "" {
+				bindingCore.SetNamespace(bindingObj.GetNamespace())
+			}
+
+			var bindingConfig runtime.RawExtension
+			bindingConfig.Object = bindingCore
+
+			image := r.Options.BindFunction
+
+			proposal.Spec.Tasks = append(proposal.Spec.Tasks, api.Task{
+				Type: api.TaskTypeEval,
+				Eval: &api.FunctionEvalTaskSpec{
+					Image:  image,
+					Config: bindingConfig,
+				},
+			})
+
+		}
+
+		proposals = append(proposals, proposal)
+	}
+
+	return proposals, nil
+}
+
+func (r *KlippyReconciler) storeProposals(ctx context.Context, parent *api.PackageRevision, children []*api.PackageRevision) error {
+	log := log.FromContext(ctx)
+
+	// TODO: Cache applyset
+
+	// TODO: Should the fieldmanager just be klippy?  These objects should be owned
+	patchOptions := metav1.PatchOptions{
+		FieldManager: "klippy-" + parent.GetNamespace() + "-" + parent.GetName(),
+	}
+
+	// We force to overcome errors like: Apply failed with 1 conflict: conflict with "kubectl-client-side-apply" using apps/v1: .spec.template.spec.containers[name="porch-server"].image
+	// TODO: How to handle this better
+	force := true
+	patchOptions.Force = &force
+
+	applier, err := applyset.New(applyset.Options{
+		RESTMapper:   r.restMapper,
+		Client:       r.dynamicClient,
+		PatchOptions: patchOptions,
+	})
+	if err != nil {
+		return err
+	}
+
+	// TODO: Set owner refs
+
+	var applyableObjects []applyset.ApplyableObject
+	for _, o := range children {
+		applyableObjects = append(applyableObjects, o)
+	}
+	if err := applier.ReplaceAllObjects(applyableObjects); err != nil {
+		return err
+	}
+
+	results, err := applier.ApplyOnce(ctx)
+	if err != nil {
+		return fmt.Errorf("failed to apply proposals: %w", err)
+	}
+
+	// TODO: Signal that we don't care about health?
+
+	log.Info("applied objects", "results", results)
+
+	// TODO: Implement pruning
+
+	return nil
+}
+
+// SetupWithManager sets up the controller with the Manager.
+func (r *KlippyReconciler) SetupWithManager(mgr ctrl.Manager) error {
+	if err := api.AddToScheme(mgr.GetScheme()); err != nil {
+		return err
+	}
+
+	r.client = mgr.GetClient()
+
+	r.restMapper = mgr.GetRESTMapper()
+
+	restConfig := mgr.GetConfig()
+
+	client, err := dynamic.NewForConfig(restConfig)
+	if err != nil {
+		return fmt.Errorf("failed to create a new dynamic client: %w", err)
+	}
+	r.dynamicClient = client
+
+	if err := ctrl.NewControllerManagedBy(mgr).
+		For(&api.PackageRevisionResources{}).
+		Complete(r); err != nil {
+		return err
+	}
+
+	return nil
+}
diff --git a/porch/controllers/main.go b/porch/controllers/main.go
index ac84080c..90371989 100644
--- a/porch/controllers/main.go
+++ b/porch/controllers/main.go
@@ -39,6 +39,7 @@ import (
 	"sigs.k8s.io/controller-runtime/pkg/healthz"
 	"sigs.k8s.io/controller-runtime/pkg/reconcile"
 
+	"github.com/GoogleContainerTools/kpt/porch/controllers/klippy/pkg/controllers/klippy"
 	"github.com/GoogleContainerTools/kpt/porch/controllers/remoterootsyncsets/pkg/controllers/remoterootsyncset"
 	"github.com/GoogleContainerTools/kpt/porch/controllers/rootsyncsets/pkg/controllers/rootsyncset"
 	"github.com/GoogleContainerTools/kpt/porch/controllers/workloadidentitybindings/pkg/controllers/workloadidentitybinding"
@@ -47,26 +48,28 @@ import (
 )
 
 var (
-	reconcilers = map[string]newReconciler{
-		"rootsyncsets": func() Reconciler {
-			return rootsyncset.NewRootSyncSetReconciler()
-		},
-		"remoterootsyncsets": func() Reconciler {
-			return &remoterootsyncset.RemoteRootSyncSetReconciler{}
-		},
-		"workloadidentitybindings": func() Reconciler {
-			return &workloadidentitybinding.WorkloadIdentityBindingReconciler{}
-		},
+	reconcilers = map[string]Reconciler{
+		"rootsyncsets":             &rootsyncset.RootSyncSetReconciler{},
+		"remoterootsyncsets":       &remoterootsyncset.RemoteRootSyncSetReconciler{},
+		"workloadidentitybindings": &workloadidentitybinding.WorkloadIdentityBindingReconciler{},
+		"klippy":                   &klippy.KlippyReconciler{},
 	}
 )
 
+// Reconciler is the interface implemented by (our) reconcilers, which includes some configuration and initialization.
 type Reconciler interface {
 	reconcile.Reconciler
+
+	// InitDefaults populates default values into our options
+	InitDefaults()
+
+	// BindFlags binds options to flags
+	BindFlags(prefix string, flags *flag.FlagSet)
+
+	// SetupWithManager registers the reconciler to run under the specified manager
 	SetupWithManager(ctrl.Manager) error
 }
 
-type newReconciler func() Reconciler
-
 // We include our lease / events permissions in the main RBAC role
 
 //+kubebuilder:rbac:groups=coordination.k8s.io,resources=leases,verbs=get;list;watch;create;update;patch;delete
@@ -86,6 +89,10 @@ func run(ctx context.Context) error {
 	// var probeAddr string
 	var enabledReconcilersString string
 
+	for _, reconciler := range reconcilers {
+		reconciler.InitDefaults()
+	}
+
 	klog.InitFlags(nil)
 
 	// flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.")
@@ -95,8 +102,16 @@ func run(ctx context.Context) error {
 	// 		"Enabling this will ensure there is only one active controller manager.")
 	flag.StringVar(&enabledReconcilersString, "reconcilers", "*", "reconcilers that should be enabled; use * to mean 'enable all'")
 
+	for name, reconciler := range reconcilers {
+		reconciler.BindFlags(name+".", flag.CommandLine)
+	}
+
 	flag.Parse()
 
+	if len(flag.Args()) != 0 {
+		return fmt.Errorf("unexpected additional (non-flag) arguments: %v", flag.Args())
+	}
+
 	scheme := runtime.NewScheme()
 	if err := clientgoscheme.AddToScheme(scheme); err != nil {
 		return fmt.Errorf("error initializing scheme: %w", err)
@@ -121,12 +136,12 @@ func run(ctx context.Context) error {
 	}
 
 	enabledReconcilers := parseReconcilers(enabledReconcilersString)
-	for r, f := range reconcilers {
-		if !reconcilerIsEnabled(enabledReconcilers, r) {
+	for name, reconciler := range reconcilers {
+		if !reconcilerIsEnabled(enabledReconcilers, name) {
 			continue
 		}
-		if err = f().SetupWithManager(mgr); err != nil {
-			return fmt.Errorf("error creating %s reconciler: %w", r, err)
+		if err = reconciler.SetupWithManager(mgr); err != nil {
+			return fmt.Errorf("error creating %s reconciler: %w", name, err)
 		}
 	}
 
diff --git a/porch/controllers/remoterootsyncsets/pkg/controllers/remoterootsyncset/remoterootsync_controller.go b/porch/controllers/remoterootsyncsets/pkg/controllers/remoterootsyncset/remoterootsync_controller.go
index 414e13fa..9a1179b7 100644
--- a/porch/controllers/remoterootsyncsets/pkg/controllers/remoterootsyncset/remoterootsync_controller.go
+++ b/porch/controllers/remoterootsyncsets/pkg/controllers/remoterootsyncset/remoterootsync_controller.go
@@ -16,6 +16,7 @@ package remoterootsyncset
 
 import (
 	"context"
+	"flag"
 	"fmt"
 	"os"
 	"path"
@@ -49,8 +50,19 @@ var (
 	RootSyncKind       = "RootSync"
 )
 
+type Options struct {
+}
+
+func (o *Options) InitDefaults() {
+}
+
+func (o *Options) BindFlags(prefix string, flags *flag.FlagSet) {
+}
+
 // RemoteRootSyncSetReconciler reconciles RemoteRootSyncSet objects
 type RemoteRootSyncSetReconciler struct {
+	Options
+
 	client.Client
 
 	ociStorage *kptoci.Storage
diff --git a/porch/controllers/rootsyncsets/pkg/controllers/rootsyncset/controller.go b/porch/controllers/rootsyncsets/pkg/controllers/rootsyncset/controller.go
index b57d9234..d69e0fbd 100644
--- a/porch/controllers/rootsyncsets/pkg/controllers/rootsyncset/controller.go
+++ b/porch/controllers/rootsyncsets/pkg/controllers/rootsyncset/controller.go
@@ -18,6 +18,7 @@ import (
 	"context"
 	"encoding/base64"
 	"encoding/json"
+	"flag"
 	"fmt"
 	"strings"
 	"sync"
@@ -69,15 +70,19 @@ var (
 	configControllerApiVersion = "configcontroller.cnrm.cloud.google.com/v1beta1"
 )
 
-func NewRootSyncSetReconciler() *RootSyncSetReconciler {
-	return &RootSyncSetReconciler{
-		channel:  make(chan event.GenericEvent, 10),
-		watchers: make(map[v1alpha1.ClusterRef]*watcher),
-	}
+type Options struct {
+}
+
+func (o *Options) InitDefaults() {
+}
+
+func (o *Options) BindFlags(prefix string, flags *flag.FlagSet) {
 }
 
 // RootSyncSetReconciler reconciles a RootSyncSet object
 type RootSyncSetReconciler struct {
+	Options
+
 	client.Client
 
 	WorkloadIdentityHelper
@@ -392,6 +397,9 @@ func BuildObjectsToApply(rootsyncset *v1alpha1.RootSyncSet) (*unstructured.Unstr
 
 // SetupWithManager sets up the controller with the Manager.
 func (r *RootSyncSetReconciler) SetupWithManager(mgr ctrl.Manager) error {
+	r.channel = make(chan event.GenericEvent, 10)
+	r.watchers = make(map[v1alpha1.ClusterRef]*watcher)
+
 	if err := v1alpha1.AddToScheme(mgr.GetScheme()); err != nil {
 		return err
 	}
diff --git a/porch/controllers/workloadidentitybindings/pkg/controllers/workloadidentitybinding/controller.go b/porch/controllers/workloadidentitybindings/pkg/controllers/workloadidentitybinding/controller.go
index 962aa7e2..3eee920e 100644
--- a/porch/controllers/workloadidentitybindings/pkg/controllers/workloadidentitybinding/controller.go
+++ b/porch/controllers/workloadidentitybindings/pkg/controllers/workloadidentitybinding/controller.go
@@ -17,6 +17,7 @@ package workloadidentitybinding
 import (
 	"context"
 	"encoding/json"
+	"flag"
 	"fmt"
 
 	"github.com/GoogleContainerTools/kpt/porch/controllers/remoterootsyncsets/pkg/applyset"
@@ -37,10 +38,20 @@ const (
 	finalizerName = "config.porch.kpt.dev/workloadidentitybindings"
 )
 
+type Options struct {
+}
+
+func (o *Options) InitDefaults() {
+}
+
+func (o *Options) BindFlags(prefix string, flags *flag.FlagSet) {
+}
+
 // WorkloadIdentityBindingReconciler reconciles WorkloadIdentityBinding objects
 type WorkloadIdentityBindingReconciler struct {
+	Options
+
 	client.Client
-	// Scheme *runtime.Scheme
 
 	dynamicClient dynamic.Interface
 	restMapper    meta.RESTMapper
diff --git a/porch/go.mod b/porch/go.mod
index 8867470b..a59d2a62 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-20221027171429-622c0141f3d7
+	github.com/GoogleContainerTools/kpt/porch/api v0.0.0-20221028161857-aa271f292cc0
 	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/porch/pkg/apiserver/apiserver.go b/porch/pkg/apiserver/apiserver.go
index 0f332011..0f70ba3d 100644
--- a/porch/pkg/apiserver/apiserver.go
+++ b/porch/pkg/apiserver/apiserver.go
@@ -216,10 +216,13 @@ func (c completedConfig) New() (*PorchServer, error) {
 	referenceResolver := porch.NewReferenceResolver(coreClient)
 	userInfoProvider := &porch.ApiserverUserInfoProvider{}
 
+	watcherMgr := engine.NewWatcherManager()
+
 	cache := cache.NewCache(c.ExtraConfig.CacheDirectory, cache.CacheOptions{
 		CredentialResolver: credentialResolver,
 		UserInfoProvider:   userInfoProvider,
 		MetadataStore:      metadataStore,
+		ObjectNotifier:     watcherMgr,
 	})
 
 	runnerOptionsResolver := func(namespace string) fnruntime.RunnerOptions {
@@ -247,6 +250,7 @@ func (c completedConfig) New() (*PorchServer, error) {
 		engine.WithReferenceResolver(referenceResolver),
 		engine.WithUserInfoProvider(userInfoProvider),
 		engine.WithMetadataStore(metadataStore),
+		engine.WithWatcherManager(watcherMgr),
 	)
 	if err != nil {
 		return nil, err
diff --git a/porch/pkg/cache/cache.go b/porch/pkg/cache/cache.go
index 0ed4db6c..67d341c4 100644
--- a/porch/pkg/cache/cache.go
+++ b/porch/pkg/cache/cache.go
@@ -27,6 +27,7 @@ import (
 	"github.com/GoogleContainerTools/kpt/porch/pkg/oci"
 	"github.com/GoogleContainerTools/kpt/porch/pkg/repository"
 	"go.opentelemetry.io/otel/trace"
+	"k8s.io/apimachinery/pkg/watch"
 )
 
 // Cache allows us to keep state for repositories, rather than querying them every time.
@@ -47,33 +48,31 @@ type Cache struct {
 	userInfoProvider   repository.UserInfoProvider
 	metadataStore      meta.MetadataStore
 
-	objectCache *objectCache
+	objectNotifier objectNotifier
+}
+
+type objectNotifier interface {
+	NotifyPackageRevisionChange(eventType watch.EventType, obj repository.PackageRevision, objMeta meta.PackageRevisionMeta)
 }
 
 type CacheOptions struct {
 	CredentialResolver repository.CredentialResolver
 	UserInfoProvider   repository.UserInfoProvider
 	MetadataStore      meta.MetadataStore
+	ObjectNotifier     objectNotifier
 }
 
 func NewCache(cacheDir string, opts CacheOptions) *Cache {
-	objectCache := &objectCache{}
-
 	return &Cache{
 		repositories:       make(map[string]*cachedRepository),
 		cacheDir:           cacheDir,
 		credentialResolver: opts.CredentialResolver,
 		userInfoProvider:   opts.UserInfoProvider,
 		metadataStore:      opts.MetadataStore,
-		objectCache:        objectCache,
+		objectNotifier:     opts.ObjectNotifier,
 	}
 }
 
-// ObjectCache() is a cache of all our objects.
-func (c *Cache) ObjectCache() ObjectCache {
-	return c.objectCache
-}
-
 func (c *Cache) OpenRepository(ctx context.Context, repositorySpec *configapi.Repository) (*cachedRepository, error) {
 	ctx, span := tracer.Start(ctx, "Cache::OpenRepository", trace.WithAttributes())
 	defer span.End()
@@ -95,7 +94,7 @@ func (c *Cache) OpenRepository(ctx context.Context, repositorySpec *configapi.Re
 			if err != nil {
 				return nil, err
 			}
-			cr = newRepository(key, repositorySpec, r, c.objectCache, c.metadataStore)
+			cr = newRepository(key, repositorySpec, r, c.objectNotifier, c.metadataStore)
 			c.repositories[key] = cr
 		}
 		return cr, nil
@@ -131,7 +130,7 @@ func (c *Cache) OpenRepository(ctx context.Context, repositorySpec *configapi.Re
 			}); err != nil {
 				return nil, err
 			} else {
-				cr = newRepository(key, repositorySpec, r, c.objectCache, c.metadataStore)
+				cr = newRepository(key, repositorySpec, r, c.objectNotifier, c.metadataStore)
 				c.repositories[key] = cr
 			}
 		} else {
diff --git a/porch/pkg/cache/fake/objectnotifier.go b/porch/pkg/cache/fake/objectnotifier.go
new file mode 100644
index 00000000..5c667355
--- /dev/null
+++ b/porch/pkg/cache/fake/objectnotifier.go
@@ -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.
+
+package fake
+
+import (
+	"github.com/GoogleContainerTools/kpt/porch/pkg/meta"
+	"github.com/GoogleContainerTools/kpt/porch/pkg/repository"
+	"k8s.io/apimachinery/pkg/watch"
+)
+
+type ObjectNotifier struct{}
+
+func (o *ObjectNotifier) NotifyPackageRevisionChange(watch.EventType, repository.PackageRevision, meta.PackageRevisionMeta) {
+}
diff --git a/porch/pkg/cache/repository.go b/porch/pkg/cache/repository.go
index 7bd68719..2ef7e964 100644
--- a/porch/pkg/cache/repository.go
+++ b/porch/pkg/cache/repository.go
@@ -62,20 +62,20 @@ type cachedRepository struct {
 	refreshRevisionsError error
 	refreshPkgsError      error
 
-	objectCache *objectCache
+	objectNotifier objectNotifier
 
 	metadataStore meta.MetadataStore
 }
 
-func newRepository(id string, repoSpec *configapi.Repository, repo repository.Repository, objectCache *objectCache, metadataStore meta.MetadataStore) *cachedRepository {
+func newRepository(id string, repoSpec *configapi.Repository, repo repository.Repository, objectNotifier objectNotifier, metadataStore meta.MetadataStore) *cachedRepository {
 	ctx, cancel := context.WithCancel(context.Background())
 	r := &cachedRepository{
-		id:            id,
-		repoSpec:      repoSpec,
-		repo:          repo,
-		cancel:        cancel,
-		objectCache:   objectCache,
-		metadataStore: metadataStore,
+		id:             id,
+		repoSpec:       repoSpec,
+		repo:           repo,
+		cancel:         cancel,
+		objectNotifier: objectNotifier,
+		metadataStore:  metadataStore,
 	}
 
 	// TODO: Should we fetch the packages here?
@@ -357,9 +357,10 @@ func (r *cachedRepository) refreshAllCachedPackages(ctx context.Context) (map[re
 		return nil, nil, err
 	}
 	// Create a map so we can quickly check if a specific PackageRevisionMeta exists.
-	existingPkgRevCRsMap := make(map[string]bool)
-	for _, pr := range existingPkgRevCRs {
-		existingPkgRevCRsMap[pr.Name] = true
+	existingPkgRevCRsMap := make(map[string]meta.PackageRevisionMeta)
+	for i := range existingPkgRevCRs {
+		pr := existingPkgRevCRs[i]
+		existingPkgRevCRsMap[pr.Name] = pr
 	}
 
 	// TODO: Can we avoid holding the lock for the ListPackageRevisions / identifyLatestRevisions section?
@@ -440,18 +441,26 @@ func (r *cachedRepository) refreshAllCachedPackages(ctx context.Context) (map[re
 	// Send notification for packages that changed.
 	for k, newPackage := range r.cachedPackageRevisions {
 		oldPackage := oldPackageRevisions[k]
+		metaPackage, found := existingPkgRevCRsMap[newPackage.KubeObjectName()]
+		if !found {
+			klog.Warningf("no PackageRev CR found for PackageRevision %s", newPackage.KubeObjectName())
+		}
 		if oldPackage == nil {
-			r.objectCache.notifyPackageRevisionChange(watch.Added, newPackage)
+			r.objectNotifier.NotifyPackageRevisionChange(watch.Added, newPackage, metaPackage)
 		} else {
 			// TODO: only if changed
 			klog.Warningf("over-notifying of package updates (even on unchanged packages)")
-			r.objectCache.notifyPackageRevisionChange(watch.Modified, newPackage)
+			r.objectNotifier.NotifyPackageRevisionChange(watch.Modified, newPackage, metaPackage)
 		}
 	}
 
 	for k, oldPackage := range oldPackageRevisions {
+		metaPackage, found := existingPkgRevCRsMap[oldPackage.KubeObjectName()]
+		if !found {
+			klog.Warningf("no PackageRev CR found for PackageRevision %s", oldPackage.KubeObjectName())
+		}
 		if newPackageRevisionMap[k] == nil {
-			r.objectCache.notifyPackageRevisionChange(watch.Deleted, oldPackage)
+			r.objectNotifier.NotifyPackageRevisionChange(watch.Deleted, oldPackage, metaPackage)
 		}
 	}
 
diff --git a/porch/pkg/engine/engine.go b/porch/pkg/engine/engine.go
index c374d666..659be9c1 100644
--- a/porch/pkg/engine/engine.go
+++ b/porch/pkg/engine/engine.go
@@ -44,6 +44,7 @@ import (
 	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/apimachinery/pkg/runtime/schema"
 	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/apimachinery/pkg/watch"
 	"k8s.io/klog/v2"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 	"sigs.k8s.io/kustomize/kyaml/comments"
@@ -55,7 +56,7 @@ var tracer = otel.Tracer("engine")
 
 type CaDEngine interface {
 	// ObjectCache() is a cache of all our objects.
-	ObjectCache() cache.ObjectCache
+	ObjectCache() WatcherManager
 
 	UpdatePackageResources(ctx context.Context, repositoryObj *configapi.Repository, oldPackage *PackageRevision, old, new *api.PackageRevisionResources) (*PackageRevision, *api.RenderStatus, error)
 	ListFunctions(ctx context.Context, repositoryObj *configapi.Repository) ([]*Function, error)
@@ -83,6 +84,16 @@ func (p *Package) KubeObjectName() string {
 	return p.repoPackage.KubeObjectName()
 }
 
+// TODO: This is a bit awkward, and we should see if there is a way to avoid
+// having to expose this function. Any functionality that requires creating new
+// engine.PackageRevision resources should be in the engine package.
+func ToPackageRevision(pkgRev repository.PackageRevision, pkgRevMeta meta.PackageRevisionMeta) *PackageRevision {
+	return &PackageRevision{
+		repoPackageRevision: pkgRev,
+		packageRevisionMeta: pkgRevMeta,
+	}
+}
+
 type PackageRevision struct {
 	repoPackageRevision repository.PackageRevision
 	packageRevisionMeta meta.PackageRevisionMeta
@@ -149,6 +160,7 @@ type cadEngine struct {
 	referenceResolver  ReferenceResolver
 	userInfoProvider   repository.UserInfoProvider
 	metadataStore      meta.MetadataStore
+	watcherManager     *watcherManager
 }
 
 var _ CaDEngine = &cadEngine{}
@@ -158,8 +170,8 @@ type mutation interface {
 }
 
 // ObjectCache is a cache of all our objects.
-func (cad *cadEngine) ObjectCache() cache.ObjectCache {
-	return cad.cache.ObjectCache()
+func (cad *cadEngine) ObjectCache() WatcherManager {
+	return cad.watcherManager
 }
 
 func (cad *cadEngine) OpenRepository(ctx context.Context, repositorySpec *configapi.Repository) (repository.Repository, error) {
@@ -327,6 +339,7 @@ func (cad *cadEngine) CreatePackageRevision(ctx context.Context, repositoryObj *
 	if err != nil {
 		return nil, err
 	}
+	cad.watcherManager.NotifyPackageRevisionChange(watch.Added, repoPkgRev, pkgRevMeta)
 	return &PackageRevision{
 		repoPackageRevision: repoPkgRev,
 		packageRevisionMeta: pkgRevMeta,
@@ -539,6 +552,7 @@ func (cad *cadEngine) UpdatePackageRevision(ctx context.Context, repositoryObj *
 		}
 		cad.metadataStore.Update(ctx, pkgRevMeta)
 
+		cad.watcherManager.NotifyPackageRevisionChange(watch.Modified, repoPkgRev, pkgRevMeta)
 		return &PackageRevision{
 			repoPackageRevision: repoPkgRev,
 			packageRevisionMeta: pkgRevMeta,
@@ -560,8 +574,19 @@ func (cad *cadEngine) UpdatePackageRevision(ctx context.Context, repositoryObj *
 		if err != nil {
 			return nil, err
 		}
+
+		pkgRevMeta := meta.PackageRevisionMeta{
+			Name:        repoPkgRev.KubeObjectName(),
+			Namespace:   repoPkgRev.KubeObjectNamespace(),
+			Labels:      newObj.Labels,
+			Annotations: newObj.Annotations,
+		}
+		cad.metadataStore.Update(ctx, pkgRevMeta)
+
+		cad.watcherManager.NotifyPackageRevisionChange(watch.Modified, repoPkgRev, pkgRevMeta)
 		return &PackageRevision{
 			repoPackageRevision: repoPkgRev,
+			packageRevisionMeta: pkgRevMeta,
 		}, nil
 	}
 
@@ -664,6 +689,7 @@ func (cad *cadEngine) UpdatePackageRevision(ctx context.Context, repositoryObj *
 	}
 	cad.metadataStore.Update(ctx, pkgRevMeta)
 
+	cad.watcherManager.NotifyPackageRevisionChange(watch.Modified, repoPkgRev, pkgRevMeta)
 	return &PackageRevision{
 		repoPackageRevision: repoPkgRev,
 		packageRevisionMeta: pkgRevMeta,
@@ -800,6 +826,7 @@ func (cad *cadEngine) DeletePackageRevision(ctx context.Context, repositoryObj *
 		return err
 	}
 
+	cad.watcherManager.NotifyPackageRevisionChange(watch.Deleted, oldPackage.repoPackageRevision, oldPackage.packageRevisionMeta)
 	return nil
 }
 
diff --git a/porch/pkg/engine/options.go b/porch/pkg/engine/options.go
index 6a87e0ad..7e2ca8a7 100644
--- a/porch/pkg/engine/options.go
+++ b/porch/pkg/engine/options.go
@@ -127,3 +127,10 @@ func WithMetadataStore(metadataStore meta.MetadataStore) EngineOption {
 		return nil
 	})
 }
+
+func WithWatcherManager(watcherManager *watcherManager) EngineOption {
+	return EngineOptionFunc(func(engine *cadEngine) error {
+		engine.watcherManager = watcherManager
+		return nil
+	})
+}
diff --git a/porch/pkg/cache/objectcache.go b/porch/pkg/engine/watchermanager.go
similarity index 79%
rename from porch/pkg/cache/objectcache.go
rename to porch/pkg/engine/watchermanager.go
index 4a9cd79a..7560fdb9 100644
--- a/porch/pkg/cache/objectcache.go
+++ b/porch/pkg/engine/watchermanager.go
@@ -12,29 +12,34 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package cache
+package engine
 
 import (
 	"context"
 	"sync"
 
+	"github.com/GoogleContainerTools/kpt/porch/pkg/meta"
 	"github.com/GoogleContainerTools/kpt/porch/pkg/repository"
 	"k8s.io/apimachinery/pkg/watch"
 	"k8s.io/klog/v2"
 )
 
 // ObjectCache caches objects across repositories, and allows for watching.
-type ObjectCache interface {
+type WatcherManager interface {
 	WatchPackageRevisions(ctx context.Context, filter repository.ListPackageRevisionFilter, callback ObjectWatcher) error
 }
 
-// ObjectWatcher is the callback interface for watchers.
+// PackageRevisionWatcher is the callback interface for watchers.
 type ObjectWatcher interface {
-	OnPackageRevisionChange(eventType watch.EventType, obj repository.PackageRevision) bool
+	OnPackageRevisionChange(eventType watch.EventType, obj repository.PackageRevision, objMeta meta.PackageRevisionMeta) bool
 }
 
-// objectCache implements ObjectCache
-type objectCache struct {
+func NewWatcherManager() *watcherManager {
+	return &watcherManager{}
+}
+
+// watcherManager implements WatcherManager
+type watcherManager struct {
 	mutex sync.Mutex
 
 	// watchers is a list of all the change-listeners.
@@ -56,7 +61,7 @@ type watcher struct {
 }
 
 // WatchPackageRevision adds a change-listener that will be called for all changes.
-func (r *objectCache) WatchPackageRevisions(ctx context.Context, filter repository.ListPackageRevisionFilter, callback ObjectWatcher) error {
+func (r *watcherManager) WatchPackageRevisions(ctx context.Context, filter repository.ListPackageRevisionFilter, callback ObjectWatcher) error {
 	r.mutex.Lock()
 	defer r.mutex.Unlock()
 
@@ -84,7 +89,7 @@ func (r *objectCache) WatchPackageRevisions(ctx context.Context, filter reposito
 }
 
 // notifyPackageRevisionChange is called to send a change notification to all interested listeners.
-func (r *objectCache) notifyPackageRevisionChange(eventType watch.EventType, obj repository.PackageRevision) {
+func (r *watcherManager) NotifyPackageRevisionChange(eventType watch.EventType, obj repository.PackageRevision, objMeta meta.PackageRevisionMeta) {
 	r.mutex.Lock()
 	defer r.mutex.Unlock()
 
@@ -97,7 +102,7 @@ func (r *objectCache) notifyPackageRevisionChange(eventType watch.EventType, obj
 			r.watchers[i] = nil
 			continue
 		}
-		if keepGoing := watcher.callback.OnPackageRevisionChange(eventType, obj); !keepGoing {
+		if keepGoing := watcher.callback.OnPackageRevisionChange(eventType, obj, objMeta); !keepGoing {
 			klog.Infof("stopping watcher in response to !keepGoing")
 			r.watchers[i] = nil
 		}
diff --git a/porch/pkg/registry/porch/packagecommon.go b/porch/pkg/registry/porch/packagecommon.go
index a06433e2..3217ccab 100644
--- a/porch/pkg/registry/porch/packagecommon.go
+++ b/porch/pkg/registry/porch/packagecommon.go
@@ -21,7 +21,6 @@ import (
 	unversionedapi "github.com/GoogleContainerTools/kpt/porch/api/porch"
 	api "github.com/GoogleContainerTools/kpt/porch/api/porch/v1alpha1"
 	configapi "github.com/GoogleContainerTools/kpt/porch/api/porchconfig/v1alpha1"
-	"github.com/GoogleContainerTools/kpt/porch/pkg/cache"
 	"github.com/GoogleContainerTools/kpt/porch/pkg/engine"
 	"github.com/GoogleContainerTools/kpt/porch/pkg/repository"
 	apierrors "k8s.io/apimachinery/pkg/api/errors"
@@ -129,7 +128,7 @@ func (r *packageCommon) listPackages(ctx context.Context, filter packageFilter,
 	return nil
 }
 
-func (r *packageCommon) watchPackages(ctx context.Context, filter packageRevisionFilter, callback cache.ObjectWatcher) error {
+func (r *packageCommon) watchPackages(ctx context.Context, filter packageRevisionFilter, callback engine.ObjectWatcher) error {
 	if err := r.cad.ObjectCache().WatchPackageRevisions(ctx, filter.ListPackageRevisionFilter, callback); err != nil {
 		return err
 	}
diff --git a/porch/pkg/registry/porch/packagerevision.go b/porch/pkg/registry/porch/packagerevision.go
index eb9dc7d9..a97f9f97 100644
--- a/porch/pkg/registry/porch/packagerevision.go
+++ b/porch/pkg/registry/porch/packagerevision.go
@@ -46,6 +46,7 @@ var _ rest.Scoper = &packageRevisions{}
 var _ rest.Creater = &packageRevisions{}
 var _ rest.Updater = &packageRevisions{}
 var _ rest.GracefulDeleter = &packageRevisions{}
+var _ rest.Watcher = &packageRevisions{}
 
 func (r *packageRevisions) New() runtime.Object {
 	return &api.PackageRevision{}
diff --git a/porch/pkg/registry/porch/packagerevisionresources.go b/porch/pkg/registry/porch/packagerevisionresources.go
index df161105..825002c7 100644
--- a/porch/pkg/registry/porch/packagerevisionresources.go
+++ b/porch/pkg/registry/porch/packagerevisionresources.go
@@ -45,7 +45,6 @@ var _ rest.Lister = &packageRevisionResources{}
 var _ rest.Getter = &packageRevisionResources{}
 var _ rest.Scoper = &packageRevisionResources{}
 var _ rest.Updater = &packageRevisionResources{}
-var _ rest.Watcher = &packageRevisionResources{}
 
 func (r *packageRevisionResources) New() runtime.Object {
 	return &api.PackageRevisionResources{}
diff --git a/porch/pkg/registry/porch/watch.go b/porch/pkg/registry/porch/watch.go
index a03a2072..a993635d 100644
--- a/porch/pkg/registry/porch/watch.go
+++ b/porch/pkg/registry/porch/watch.go
@@ -20,6 +20,7 @@ import (
 	"sync"
 
 	"github.com/GoogleContainerTools/kpt/porch/pkg/engine"
+	"github.com/GoogleContainerTools/kpt/porch/pkg/meta"
 	"github.com/GoogleContainerTools/kpt/porch/pkg/repository"
 	"go.opentelemetry.io/otel/trace"
 	metainternalversion "k8s.io/apimachinery/pkg/apis/meta/internalversion"
@@ -30,16 +31,16 @@ import (
 )
 
 // Watch supports watching for changes.
-func (r *packageRevisionResources) Watch(ctx context.Context, options *metainternalversion.ListOptions) (watch.Interface, error) {
+func (r *packageRevisions) Watch(ctx context.Context, options *metainternalversion.ListOptions) (watch.Interface, error) {
 	// 'label' selects on labels; 'field' selects on the object's fields. Not all fields
 	// are supported; an error should be returned if 'field' tries to select on a field that
 	// isn't supported. 'resourceVersion' allows for continuing/starting a watch at a
 	// particular version.
 
-	ctx, span := tracer.Start(ctx, "packageRevisionResources::Watch", trace.WithAttributes())
+	ctx, span := tracer.Start(ctx, "packageRevisions::Watch", trace.WithAttributes())
 	defer span.End()
 
-	filter, err := parsePackageRevisionResourcesFieldSelector(options.FieldSelector)
+	filter, err := parsePackageRevisionFieldSelector(options.FieldSelector)
 	if err != nil {
 		return nil, err
 	}
@@ -70,7 +71,7 @@ type watcher struct {
 	resultChan chan watch.Event
 
 	mutex         sync.Mutex
-	eventCallback func(eventType watch.EventType, pr repository.PackageRevision) bool
+	eventCallback func(eventType watch.EventType, pr engine.PackageRevision) bool
 }
 
 var _ watch.Interface = &watcher{}
@@ -91,7 +92,7 @@ func (w *watcher) ResultChan() <-chan watch.Event {
 // listAndWatch implements watch by doing a list, then sending any observed changes.
 // This is not a compliant implementation of watch, but it is a good-enough start for most controllers.
 // One trick is that we start the watch _before_ we perform the list, so we don't miss changes that happen immediately after the list.
-func (w *watcher) listAndWatch(ctx context.Context, r *packageRevisionResources, filter packageRevisionFilter, selector labels.Selector) {
+func (w *watcher) listAndWatch(ctx context.Context, r *packageRevisions, filter packageRevisionFilter, selector labels.Selector) {
 	if err := w.listAndWatchInner(ctx, r, filter, selector); err != nil {
 		// TODO: We need to populate the object on this error
 		klog.Warningf("sending error to watch stream")
@@ -104,16 +105,16 @@ func (w *watcher) listAndWatch(ctx context.Context, r *packageRevisionResources,
 	close(w.resultChan)
 }
 
-func (w *watcher) listAndWatchInner(ctx context.Context, r *packageRevisionResources, filter packageRevisionFilter, selector labels.Selector) error {
+func (w *watcher) listAndWatchInner(ctx context.Context, r *packageRevisions, filter packageRevisionFilter, selector labels.Selector) error {
 	errorResult := make(chan error, 4)
 	done := false
 
 	var backlog []watch.Event
-	w.eventCallback = func(eventType watch.EventType, pr repository.PackageRevision) bool {
+	w.eventCallback = func(eventType watch.EventType, pr engine.PackageRevision) bool {
 		if done {
 			return false
 		}
-		obj, err := pr.GetResources(ctx)
+		obj, err := pr.GetPackageRevision(ctx)
 		if err != nil {
 			done = true
 			errorResult <- err
@@ -134,7 +135,7 @@ func (w *watcher) listAndWatchInner(ctx context.Context, r *packageRevisionResou
 
 	// TODO: Only if rv == 0?
 	if err := r.packageCommon.listPackageRevisions(ctx, filter, selector, func(p *engine.PackageRevision) error {
-		obj, err := p.GetResources(ctx)
+		obj, err := p.GetPackageRevision(ctx)
 		if err != nil {
 			done = true
 			return err
@@ -181,11 +182,11 @@ func (w *watcher) listAndWatchInner(ctx context.Context, r *packageRevisionResou
 	}
 
 	klog.Infof("moving watch into streaming mode")
-	w.eventCallback = func(eventType watch.EventType, pr repository.PackageRevision) bool {
+	w.eventCallback = func(eventType watch.EventType, pr engine.PackageRevision) bool {
 		if done {
 			return false
 		}
-		obj, err := pr.GetResources(ctx)
+		obj, err := pr.GetPackageRevision(ctx)
 		if err != nil {
 			done = true
 			errorResult <- err
@@ -220,9 +221,9 @@ func (w *watcher) sendWatchEvent(ev watch.Event) {
 }
 
 // OnPackageRevisionChange is the callback called when a PackageRevision changes.
-func (w *watcher) OnPackageRevisionChange(eventType watch.EventType, pr repository.PackageRevision) bool {
+func (w *watcher) OnPackageRevisionChange(eventType watch.EventType, pr repository.PackageRevision, objMeta meta.PackageRevisionMeta) bool {
 	w.mutex.Lock()
 	defer w.mutex.Unlock()
 
-	return w.eventCallback(eventType, pr)
+	return w.eventCallback(eventType, *engine.ToPackageRevision(pr, objMeta))
 }
diff --git a/site/installation/kpt-cli.md b/site/installation/kpt-cli.md
index 6cfe7bcd..9d5251a4 100644
--- a/site/installation/kpt-cli.md
+++ b/site/installation/kpt-cli.md
@@ -103,7 +103,7 @@ Use one of the kpt docker images.
 ### `kpt`
 
 ```shell
-$ docker run gcr.io/kpt-dev/kpt:v1.0.0-beta.21 version
+$ docker run gcr.io/kpt-dev/kpt:v1.0.0-beta.23 version
 ```
 
 ### `kpt-gcloud`
@@ -111,7 +111,7 @@ $ docker run gcr.io/kpt-dev/kpt:v1.0.0-beta.21 version
 An image which includes kpt based upon the Google [cloud-sdk] alpine image.
 
 ```shell
-$ docker run gcr.io/kpt-dev/kpt-gcloud:v1.0.0-beta.21 version
+$ docker run gcr.io/kpt-dev/kpt-gcloud:v1.0.0-beta.23 version
 ```
 
 ## Source
@@ -134,12 +134,12 @@ $ kpt version
   https://console.cloud.google.com/gcr/images/kpt-dev/GLOBAL/kpt-gcloud?gcrImageListsize=30
 [cloud-sdk]: https://github.com/GoogleCloudPlatform/cloud-sdk-docker
 [linux-amd64]:
-  https://github.com/GoogleContainerTools/kpt/releases/download/v1.0.0-beta.21/kpt_linux_amd64
+  https://github.com/GoogleContainerTools/kpt/releases/download/v1.0.0-beta.23/kpt_linux_amd64
 [linux-arm64]:
-  https://github.com/GoogleContainerTools/kpt/releases/download/v1.0.0-beta.21/kpt_linux_arm64
+  https://github.com/GoogleContainerTools/kpt/releases/download/v1.0.0-beta.23/kpt_linux_arm64
 [darwin-amd64]:
-  https://github.com/GoogleContainerTools/kpt/releases/download/v1.0.0-beta.21/kpt_darwin_amd64
+  https://github.com/GoogleContainerTools/kpt/releases/download/v1.0.0-beta.23/kpt_darwin_amd64
 [darwin-arm64]:
-  https://github.com/GoogleContainerTools/kpt/releases/download/v1.0.0-beta.21/kpt_darwin_arm64
+  https://github.com/GoogleContainerTools/kpt/releases/download/v1.0.0-beta.23/kpt_darwin_arm64
 [migration guide]: /installation/migration
 [bash-completion]: https://github.com/scop/bash-completion#installation



Test patch:
diff --git a/internal/fnruntime/runner_test.go b/internal/fnruntime/runner_test.go
index 05bad6d6..8f8099cc 100644
--- a/internal/fnruntime/runner_test.go
+++ b/internal/fnruntime/runner_test.go
@@ -101,13 +101,13 @@ data: {foo: bar}
 
 func TestMultilineFormatter(t *testing.T) {
 	type testcase struct {
-		ml       *multiLineFormatter
+		ml       *MultiLineFormatter
 		expected string
 	}
 
 	testcases := map[string]testcase{
 		"multiline should format lines and truncate": {
-			ml: &multiLineFormatter{
+			ml: &MultiLineFormatter{
 				Title: "Results",
 				Lines: []string{
 					"line-1",
@@ -127,7 +127,7 @@ func TestMultilineFormatter(t *testing.T) {
 `,
 		},
 		"multiline should format without truncate": {
-			ml: &multiLineFormatter{
+			ml: &MultiLineFormatter{
 				Title: "Results",
 				Lines: []string{
 					"line-1",
diff --git a/porch/pkg/cache/cache_test.go b/porch/pkg/cache/cache_test.go
index f8408e51..f2f2f21a 100644
--- a/porch/pkg/cache/cache_test.go
+++ b/porch/pkg/cache/cache_test.go
@@ -23,9 +23,10 @@ import (
 
 	api "github.com/GoogleContainerTools/kpt/porch/api/porch/v1alpha1"
 	"github.com/GoogleContainerTools/kpt/porch/api/porchconfig/v1alpha1"
+	fakecache "github.com/GoogleContainerTools/kpt/porch/pkg/cache/fake"
 	"github.com/GoogleContainerTools/kpt/porch/pkg/git"
 	"github.com/GoogleContainerTools/kpt/porch/pkg/meta"
-	"github.com/GoogleContainerTools/kpt/porch/pkg/meta/fake"
+	fakemeta "github.com/GoogleContainerTools/kpt/porch/pkg/meta/fake"
 	"github.com/GoogleContainerTools/kpt/porch/pkg/repository"
 	gogit "github.com/go-git/go-git/v5"
 	"github.com/google/go-cmp/cmp"
@@ -134,7 +135,8 @@ func openRepositoryFromArchive(t *testing.T, ctx context.Context, testPath, name
 	metadataStore := createMetadataStoreFromArchive(t, "", "")
 
 	cache := NewCache(t.TempDir(), CacheOptions{
-		MetadataStore: metadataStore,
+		MetadataStore:  metadataStore,
+		ObjectNotifier: &fakecache.ObjectNotifier{},
 	})
 	cachedGit, err := cache.OpenRepository(ctx, &v1alpha1.Repository{
 		TypeMeta: metav1.TypeMeta{
@@ -170,7 +172,7 @@ func createMetadataStoreFromArchive(t *testing.T, testPath, name string) meta.Me
 		t.Fatalf("Error reading metadata file found for repository %s", name)
 	}
 	if os.IsNotExist(err) {
-		return &fake.MemoryMetadataStore{
+		return &fakemeta.MemoryMetadataStore{
 			Metas: []meta.PackageRevisionMeta{},
 		}
 	}
@@ -180,7 +182,7 @@ func createMetadataStoreFromArchive(t *testing.T, testPath, name string) meta.Me
 		t.Fatalf("Error unmarshalling metadata file for repository %s", name)
 	}
 
-	return &fake.MemoryMetadataStore{
+	return &fakemeta.MemoryMetadataStore{
 		Metas: metas,
 	}
 }
diff --git a/porch/test/e2e/e2e_test.go b/porch/test/e2e/e2e_test.go
index 9aaca03b..97639be8 100644
--- a/porch/test/e2e/e2e_test.go
+++ b/porch/test/e2e/e2e_test.go
@@ -32,9 +32,12 @@ import (
 	"github.com/google/go-cmp/cmp"
 	"github.com/stretchr/testify/assert"
 	coreapi "k8s.io/api/core/v1"
+	corev1 "k8s.io/api/core/v1"
 	apierrors "k8s.io/apimachinery/pkg/api/errors"
 	"k8s.io/apimachinery/pkg/api/meta"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	"k8s.io/apimachinery/pkg/runtime/schema"
 	"k8s.io/apimachinery/pkg/types"
 	"k8s.io/apimachinery/pkg/util/wait"
 	_ "k8s.io/client-go/plugin/pkg/client/auth/gcp"
@@ -1684,6 +1687,82 @@ func (t *PorchSuite) TestRegisteredPackageRevisionLabels(ctx context.Context) {
 	)
 }
 
+func (t *PorchSuite) TestPackageRevisionGarbageCollector(ctx context.Context) {
+	const (
+		repository  = "pkgrevgc"
+		workspace   = "test-workspace"
+		description = "empty-package description"
+		cmName      = "foo"
+	)
+
+	var (
+		packageRevisionGVK = porchapi.SchemeGroupVersion.WithKind("PackageRevision")
+		configMapGVK       = corev1.SchemeGroupVersion.WithKind("ConfigMap")
+	)
+
+	t.registerMainGitRepositoryF(ctx, repository)
+
+	// Create a new package (via init)
+	pr := &porchapi.PackageRevision{
+		TypeMeta: metav1.TypeMeta{
+			Kind:       packageRevisionGVK.Kind,
+			APIVersion: packageRevisionGVK.GroupVersion().String(),
+		},
+		ObjectMeta: metav1.ObjectMeta{
+			Namespace: t.namespace,
+		},
+		Spec: porchapi.PackageRevisionSpec{
+			PackageName:    "empty-package",
+			WorkspaceName:  workspace,
+			RepositoryName: repository,
+		},
+	}
+	t.CreateF(ctx, pr)
+
+	cm := &corev1.ConfigMap{
+		TypeMeta: metav1.TypeMeta{
+			Kind:       configMapGVK.Kind,
+			APIVersion: configMapGVK.GroupVersion().String(),
+		},
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      cmName,
+			Namespace: t.namespace,
+			OwnerReferences: []metav1.OwnerReference{
+				{
+					APIVersion: porchapi.SchemeGroupVersion.String(),
+					Kind:       "PackageRevision",
+					Name:       pr.Name,
+					UID:        pr.UID,
+				},
+			},
+		},
+		Data: map[string]string{
+			"foo": "bar",
+		},
+	}
+	t.CreateF(ctx, cm)
+
+	t.DeleteF(ctx, pr)
+	t.waitUntilObjectDeleted(
+		ctx,
+		packageRevisionGVK,
+		types.NamespacedName{
+			Name:      pr.Name,
+			Namespace: pr.Namespace,
+		},
+		10*time.Second,
+	)
+	t.waitUntilObjectDeleted(
+		ctx,
+		configMapGVK,
+		types.NamespacedName{
+			Name:      cm.Name,
+			Namespace: cm.Namespace,
+		},
+		10*time.Second,
+	)
+}
+
 func (t *PorchSuite) validateLabelsAndAnnos(ctx context.Context, name string, labels, annos map[string]string) {
 	var pr porchapi.PackageRevision
 	t.GetF(ctx, client.ObjectKey{
@@ -1957,3 +2036,22 @@ func (t *PorchSuite) waitUntilAllPackagesDeleted(ctx context.Context, repoName s
 		t.Fatalf("Packages from repo %s still remains", repoName)
 	}
 }
+
+func (t *PorchSuite) waitUntilObjectDeleted(ctx context.Context, gvk schema.GroupVersionKind, namespacedName types.NamespacedName, d time.Duration) {
+	var innerErr error
+	err := wait.PollImmediateWithContext(ctx, time.Second, d, func(ctx context.Context) (bool, error) {
+		var u unstructured.Unstructured
+		u.SetGroupVersionKind(gvk)
+		if err := t.client.Get(ctx, namespacedName, &u); err != nil {
+			if apierrors.IsNotFound(err) {
+				return true, nil
+			}
+			innerErr = err
+			return false, err
+		}
+		return false, nil
+	})
+	if err != nil {
+		t.Errorf("Object %s not deleted after %s: %v", namespacedName.String(), d.String(), innerErr)
+	}
+}