Repository: GoogleContainerTools/kpt
Pull request #3575 (2022-09-22 23:08:17)

klippy: a controller that auto-instantiates packages
 This controller will automatically create draft package instances where the bindings are satisfied.  If the bindings are sufficiently constraining, this can act as a guided workflow to help users do the next right thing.

johnbelamaric: This is quite similar to what I am looking for with #3448. The idea is that a local-config resource will describe the "my package requires", and another one in other packages will describe the "my package provides". I want a dependency resolution controller that sees these in Draft package revisions and resolves them. Dependencies themselves are also surfaced as conditions, so that we can identify when all dependencies have been satisfied.
justinsb: > This is quite similar to what I am looking for with https://github.com/GoogleContainerTools/kpt/issues/3448. The idea is that a local-config resource will describe the "my package requires", and another one in other packages will describe the "my package provides". I want a dependency resolution controller that sees these in Draft package revisions and resolves them. Dependencies themselves are also surfaced as conditions, so that we can identify when all dependencies have been satisfied.

That's great news, thank @johnbelamaric .  I do expect we'll have multiple controllers making proposals.  I think it would be great if @yuwenma (or @natasha41575 ) tackled updating-the-container-image next.  I don't know whether your needs are close enough that we should extend this controller, or whether we should build a second controller - generally I think we'll evolve our understanding as we build out this functionality (better to have many specialized controllers, or fewer more generic ones?)
justinsb: Thanks @mortent - I added a README and was able to remove the hack.

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