Repository: GoogleContainerTools/kpt
Pull request #3159 (2022-05-13 18:34:37)

Update guide sidebar to contain all guides
This addresses a few issues with the kpt.dev site:
 * Remove the content about RemoteRootSync from the porch install guide. Content is moved to `docs/sync/prototyp-controller.md`
 * Fix missing space in numbered list in the namespace-provisioning-cli guide.
 * Add missing guides to the sidebar and the guides landing page
 * Use consistent capitalization on the guides landing page and the sidebar.
 * List the guides in the order in which users should (probably) read them. Open to input on the right order here.



Patch:
diff --git a/Makefile b/Makefile
index 70eb9e51..f3ef0381 100644
--- a/Makefile
+++ b/Makefile
@@ -86,10 +86,14 @@ test-fn-render: build
 test-fn-eval: build
 	PATH=$(GOBIN):$(PATH) go test -v --tags=docker --run=TestFnEval/testdata/fn-eval/$(T)  ./e2e/
 
-# target to run e2e tests for "kpt fn eval" command
+# target to run e2e tests for "kpt live apply" command
 test-live-apply: build
 	PATH=$(GOBIN):$(PATH) go test -v -timeout=20m --tags=kind -p 2 --run=TestLiveApply/testdata/live-apply/$(T)  ./e2e/
 
+# target to run e2e tests for "kpt live plan" command
+test-live-plan: build
+	PATH=$(GOBIN):$(PATH) go test -v -timeout=20m --tags=kind -p 2 --run=TestLivePlan/testdata/live-plan/$(T)  ./e2e/
+
 test-porch: build
 	PATH=$(GOBIN):$(PATH) go test -v --count=1 --tags=porch ./e2e/
 
diff --git a/commands/alphacmd.go b/commands/alphacmd.go
index ba4e9b4c..70142333 100644
--- a/commands/alphacmd.go
+++ b/commands/alphacmd.go
@@ -44,6 +44,7 @@ func GetAlphaCommand(ctx context.Context, name, version string) *cobra.Command {
 		NewRepoCommand(ctx, version),
 		NewRpkgCommand(ctx, version),
 		NewSyncCommand(ctx, version),
+		GetAlphaLiveCommand(ctx, "", version),
 	)
 
 	return alpha
diff --git a/commands/alphalivecmd.go b/commands/alphalivecmd.go
new file mode 100644
index 00000000..b5794f2f
--- /dev/null
+++ b/commands/alphalivecmd.go
@@ -0,0 +1,46 @@
+// Copyright 2020 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 commands
+
+import (
+	"context"
+	"os"
+
+	"github.com/GoogleContainerTools/kpt/internal/cmdplan"
+	"github.com/GoogleContainerTools/kpt/internal/docs/generated/livedocs"
+	"github.com/spf13/cobra"
+	"k8s.io/cli-runtime/pkg/genericclioptions"
+)
+
+func GetAlphaLiveCommand(ctx context.Context, _, version string) *cobra.Command {
+	liveCmd := &cobra.Command{
+		Use:   "live",
+		Short: "[Alpha] " + livedocs.LiveShort,
+		Long:  "[Alpha] " + livedocs.LiveShort + "\n" + livedocs.LiveLong,
+	}
+
+	ioStreams := genericclioptions.IOStreams{
+		In:     os.Stdin,
+		Out:    os.Stdout,
+		ErrOut: os.Stderr,
+	}
+
+	f := newFactory(liveCmd, version)
+
+	planCmd := cmdplan.NewCommand(ctx, f, ioStreams)
+	liveCmd.AddCommand(planCmd)
+
+	return liveCmd
+}
diff --git a/commands/factory.go b/commands/factory.go
new file mode 100644
index 00000000..7d548d96
--- /dev/null
+++ b/commands/factory.go
@@ -0,0 +1,37 @@
+// Copyright 2020 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 commands
+
+import (
+	"flag"
+	"fmt"
+
+	"github.com/GoogleContainerTools/kpt/internal/util/cfgflags"
+	"github.com/spf13/cobra"
+	"k8s.io/cli-runtime/pkg/genericclioptions"
+	cluster "k8s.io/kubectl/pkg/cmd/util"
+)
+
+func newFactory(cmd *cobra.Command, version string) cluster.Factory {
+	flags := cmd.PersistentFlags()
+	kubeConfigFlags := genericclioptions.NewConfigFlags(true).WithDeprecatedPasswordFlag()
+	kubeConfigFlags.AddFlags(flags)
+	userAgentKubeConfigFlags := &cfgflags.UserAgentKubeConfigFlags{
+		Delegate:  kubeConfigFlags,
+		UserAgent: fmt.Sprintf("kpt/%s", version),
+	}
+	cmd.PersistentFlags().AddGoFlagSet(flag.CommandLine)
+	return cluster.NewFactory(userAgentKubeConfigFlags)
+}
diff --git a/commands/livecmd.go b/commands/livecmd.go
index cadaace9..880effa7 100644
--- a/commands/livecmd.go
+++ b/commands/livecmd.go
@@ -16,8 +16,6 @@ package commands
 
 import (
 	"context"
-	"flag"
-	"fmt"
 	"os"
 
 	"github.com/GoogleContainerTools/kpt/internal/cmdapply"
@@ -26,12 +24,10 @@ import (
 	"github.com/GoogleContainerTools/kpt/internal/cmdliveinit"
 	"github.com/GoogleContainerTools/kpt/internal/cmdmigrate"
 	"github.com/GoogleContainerTools/kpt/internal/docs/generated/livedocs"
-	"github.com/GoogleContainerTools/kpt/internal/util/cfgflags"
 	"github.com/GoogleContainerTools/kpt/thirdparty/cli-utils/status"
 	"github.com/spf13/cobra"
 	"k8s.io/cli-runtime/pkg/genericclioptions"
 	"k8s.io/klog/v2"
-	cluster "k8s.io/kubectl/pkg/cmd/util"
 	"sigs.k8s.io/cli-utils/pkg/manifestreader"
 )
 
@@ -71,15 +67,3 @@ func GetLiveCommand(ctx context.Context, _, version string) *cobra.Command {
 
 	return liveCmd
 }
-
-func newFactory(cmd *cobra.Command, version string) cluster.Factory {
-	flags := cmd.PersistentFlags()
-	kubeConfigFlags := genericclioptions.NewConfigFlags(true).WithDeprecatedPasswordFlag()
-	kubeConfigFlags.AddFlags(flags)
-	userAgentKubeConfigFlags := &cfgflags.UserAgentKubeConfigFlags{
-		Delegate:  kubeConfigFlags,
-		UserAgent: fmt.Sprintf("kpt/%s", version),
-	}
-	cmd.PersistentFlags().AddGoFlagSet(flag.CommandLine)
-	return cluster.NewFactory(userAgentKubeConfigFlags)
-}
diff --git a/docs/sync/prototype-controller.md b/docs/sync/prototype-controller.md
new file mode 100644
index 00000000..f44fbbf1
--- /dev/null
+++ b/docs/sync/prototype-controller.md
@@ -0,0 +1,23 @@
+## RemoteRootSync controller
+
+Create some example repositories / packages:
+
+```sh
+# Create artifact-registry repos etc
+make apply-dev-config
+# Push a sample hello-world app
+make -C config/samples/apps/hello-server push-image
+# Create a package for the sample hello-world app
+./scripts/create-deployment-package.sh
+```
+
+To test out remoterootsync self-applying:
+
+```sh
+# Grant more RBAC permissions than are normally needed (equivalent to admin permissions)
+kubectl apply -f controllers/remoterootsync/config/samples/hack-self-apply-rbac.yaml
+
+# Apply the RemoteRootSyncSet
+cat controllers/remoterootsync/config/samples/hack-self-apply.yaml \
+  | sed -e s/example-google-project-id/${GCP_PROJECT_ID}/g | kubectl apply -f -
+```
diff --git a/go.mod b/go.mod
index 1949a278..bb63ab7f 100644
--- a/go.mod
+++ b/go.mod
@@ -86,7 +86,7 @@ require (
 	github.com/pmezard/go-difflib v1.0.0 // indirect
 	github.com/russross/blackfriday v1.5.2 // indirect
 	github.com/russross/blackfriday/v2 v2.1.0 // indirect
-	github.com/sergi/go-diff v1.1.0 // indirect
+	github.com/sergi/go-diff v1.2.0 // indirect
 	github.com/spf13/pflag v1.0.5 // indirect
 	github.com/spyzhov/ajson v0.4.2 // indirect
 	go.starlark.net v0.0.0-20200306205701-8dd3e2ee1dd5 // indirect
diff --git a/go.sum b/go.sum
index 7bb5633f..e1c2e83f 100644
--- a/go.sum
+++ b/go.sum
@@ -582,8 +582,9 @@ github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD
 github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
 github.com/sagikazarmark/crypt v0.3.0/go.mod h1:uD/D+6UF4SrIR1uGEv7bBNkNqLGqUr43MRiaGWX1Nig=
 github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
-github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
 github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
+github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
+github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
 github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
 github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
 github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
diff --git a/internal/cmdplan/command.go b/internal/cmdplan/command.go
new file mode 100644
index 00000000..cb8db7cc
--- /dev/null
+++ b/internal/cmdplan/command.go
@@ -0,0 +1,288 @@
+// 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 cmdplan
+
+import (
+	"bufio"
+	"context"
+	"fmt"
+	"os"
+	"strings"
+
+	"github.com/GoogleContainerTools/kpt/internal/util/argutil"
+	"github.com/GoogleContainerTools/kpt/pkg/api/plan/v1alpha1"
+	"github.com/GoogleContainerTools/kpt/pkg/live"
+	kptplanner "github.com/GoogleContainerTools/kpt/pkg/live/planner"
+	"github.com/spf13/cobra"
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	"k8s.io/cli-runtime/pkg/genericclioptions"
+	"k8s.io/kubectl/pkg/cmd/util"
+	"sigs.k8s.io/cli-utils/cmd/flagutils"
+	"sigs.k8s.io/cli-utils/pkg/common"
+	print "sigs.k8s.io/cli-utils/pkg/print/common"
+	"sigs.k8s.io/kustomize/kyaml/kio"
+	"sigs.k8s.io/kustomize/kyaml/yaml"
+)
+
+const (
+	TextOutput = "text"
+	KRMOutput  = "krm"
+
+	EntryPrefix   = "\t"
+	ContentPrefix = "\t\t"
+)
+
+func NewRunner(ctx context.Context, factory util.Factory, ioStreams genericclioptions.IOStreams) *Runner {
+	r := &Runner{
+		ctx:       ctx,
+		factory:   factory,
+		ioStreams: ioStreams,
+		serverSideOptions: common.ServerSideOptions{
+			ServerSideApply: true,
+		},
+	}
+	c := &cobra.Command{
+		Use:     "plan [PKG_PATH | -]",
+		PreRunE: r.PreRunE,
+		RunE:    r.RunE,
+	}
+	c.Flags().StringVar(&r.inventoryPolicyString, flagutils.InventoryPolicyFlag, flagutils.InventoryPolicyStrict,
+		"It determines the behavior when the resources don't belong to current inventory. Available options "+
+			fmt.Sprintf("%q and %q.", flagutils.InventoryPolicyStrict, flagutils.InventoryPolicyAdopt))
+	c.Flags().BoolVar(&r.serverSideOptions.ForceConflicts, "force-conflicts", false,
+		"If true, overwrite applied fields on server if field manager conflict.")
+	c.Flags().StringVar(&r.serverSideOptions.FieldManager, "field-manager", common.DefaultFieldManager,
+		"The client owner of the fields being applied on the server-side.")
+	c.Flags().StringVar(&r.output, "output", "text",
+		"The output format for the plan. Must be either 'text' or 'krm'. Default is 'text'")
+	r.Command = c
+
+	return r
+}
+
+func NewCommand(ctx context.Context, factory util.Factory, ioStreams genericclioptions.IOStreams) *cobra.Command {
+	return NewRunner(ctx, factory, ioStreams).Command
+}
+
+type Runner struct {
+	ctx       context.Context
+	Command   *cobra.Command
+	factory   util.Factory
+	ioStreams genericclioptions.IOStreams
+
+	inventoryPolicyString string
+	serverSideOptions     common.ServerSideOptions
+	output                string
+}
+
+func (r *Runner) PreRunE(c *cobra.Command, args []string) error {
+	return r.validateOutputFormat()
+}
+
+func (r *Runner) validateOutputFormat() error {
+	if !(r.output == "text" || r.output == "krm") {
+		return fmt.Errorf("unknown output format %q. Must be either 'text' or 'krm'", r.output)
+	}
+	return nil
+}
+
+func (r *Runner) RunE(c *cobra.Command, args []string) error {
+	// default to the current working directory if the user didn't
+	// provide a target package.
+	if len(args) == 0 {
+		cwd, err := os.Getwd()
+		if err != nil {
+			return err
+		}
+		args = append(args, cwd)
+	}
+
+	// Handle symlinks.
+	path := args[0]
+	var err error
+	if args[0] != "-" {
+		path, err = argutil.ResolveSymlink(r.ctx, path)
+		if err != nil {
+			return err
+		}
+	}
+
+	// Load the resources from disk or stdin and extract the
+	// inventory information.
+	objs, inv, err := live.Load(r.factory, path, "", c.InOrStdin())
+	if err != nil {
+		return err
+	}
+
+	// Convert the inventory data input to the format required by
+	// the actuation code.
+	invInfo, err := live.ToInventoryInfo(inv)
+	if err != nil {
+		return err
+	}
+
+	// Create and execute the planner.
+	planner, err := kptplanner.NewClusterPlanner(r.factory)
+	if err != nil {
+		return err
+	}
+	plan, err := planner.BuildPlan(r.ctx, invInfo, objs, kptplanner.Options{
+		ServerSideOptions: r.serverSideOptions,
+	})
+	if err != nil {
+		return err
+	}
+
+	switch r.output {
+	case "text":
+		return printText(plan, objs, r.ioStreams)
+	case "krm":
+		return printKRM(plan, r.ioStreams)
+	}
+	return fmt.Errorf("unknown output format %s", r.output)
+}
+
+func printText(plan *v1alpha1.Plan, objs []*unstructured.Unstructured, ioStreams genericclioptions.IOStreams) error {
+	if !hasChanges(plan) {
+		fmt.Fprint(ioStreams.Out, "no changes found\n")
+		return nil
+	}
+
+	fmt.Fprintf(ioStreams.Out, "kpt will perform the following actions:\n")
+	for i := range plan.Spec.Actions {
+		action := plan.Spec.Actions[i]
+		switch action.Type {
+		case v1alpha1.Create:
+			printEntryWithColor("+", print.GREEN, action, ioStreams)
+			u, ok := findResource(objs, action.Group, action.Kind, action.Namespace, action.Name)
+			if !ok {
+				panic("can't find resource")
+			}
+			printKRMWithPrefix(u, ContentPrefix, ioStreams)
+		case v1alpha1.Unchanged:
+			// Do nothing.
+		case v1alpha1.Delete:
+			printEntryWithColor("-", print.RED, action, ioStreams)
+		case v1alpha1.Update:
+			printEntry(" ", action, ioStreams)
+			findAndPrintDiff(action.Before, action.After, ContentPrefix, ioStreams)
+		case v1alpha1.Skip:
+			// TODO: provide more information about why the resource was skipped.
+			printEntryWithColor("=", print.YELLOW, action, ioStreams)
+		case v1alpha1.Error:
+			printEntry("!", action, ioStreams)
+			printWithPrefix(action.Error, ContentPrefix, ioStreams)
+		}
+		fmt.Fprintf(ioStreams.Out, "\n")
+	}
+	return nil
+}
+
+func hasChanges(plan *v1alpha1.Plan) bool {
+	for _, a := range plan.Spec.Actions {
+		if a.Type != v1alpha1.Unchanged {
+			return true
+		}
+	}
+	return false
+}
+
+func printEntryWithColor(prefix string, color print.Color, action v1alpha1.Action, ioStreams genericclioptions.IOStreams) {
+	txt := print.SprintfWithColor(color, "%s%s %s/%s %s/%s\n", EntryPrefix, prefix, action.Group, action.Kind, action.Namespace, action.Name)
+	fmt.Fprint(ioStreams.Out, txt)
+}
+
+func printEntry(prefix string, action v1alpha1.Action, ioStreams genericclioptions.IOStreams) {
+	fmt.Fprintf(ioStreams.Out, "%s%s %s/%s %s/%s\n", EntryPrefix, prefix, action.Group, action.Kind, action.Namespace, action.Name)
+}
+
+func findAndPrintDiff(before, after *unstructured.Unstructured, prefix string, ioStreams genericclioptions.IOStreams) {
+	diff, err := diffObjects(before, after)
+	if err != nil {
+		panic(err)
+	}
+	for _, d := range diff {
+		if d.Path == ".metadata.generation" || d.Path == ".metadata.managedFields.0.time" {
+			continue
+		}
+
+		switch d.Type {
+		case "LeftAdd":
+			txt := print.SprintfWithColor(print.RED, "%s-%s: %s\n", prefix, d.Path, strings.TrimSpace(fmt.Sprintf("%v", d.Left)))
+			fmt.Fprint(ioStreams.Out, txt)
+		case "RightAdd":
+			txt := print.SprintfWithColor(print.GREEN, "%s+%s: %s\n", prefix, d.Path, strings.TrimSpace(fmt.Sprintf("%v", d.Right)))
+			fmt.Fprint(ioStreams.Out, txt)
+		case "Change":
+			txt1 := print.SprintfWithColor(print.RED, "%s-%s: %s\n", prefix, d.Path, strings.TrimSpace(fmt.Sprintf("%v", d.Left)))
+			fmt.Fprint(ioStreams.Out, txt1)
+			txt2 := print.SprintfWithColor(print.GREEN, "%s+%s: %s\n", prefix, d.Path, strings.TrimSpace(fmt.Sprintf("%v", d.Right)))
+			fmt.Fprint(ioStreams.Out, txt2)
+		}
+	}
+}
+
+func findResource(objs []*unstructured.Unstructured, group, kind, namespace, name string) (*unstructured.Unstructured, bool) {
+	for i := range objs {
+		o := objs[i]
+		gvk := o.GroupVersionKind()
+		if gvk.Group == group && gvk.Kind == kind && o.GetName() == name && o.GetNamespace() == namespace {
+			return o, true
+		}
+	}
+	return nil, false
+}
+
+func printKRMWithPrefix(u *unstructured.Unstructured, prefix string, ioStreams genericclioptions.IOStreams) {
+	b, err := yaml.Marshal(u.Object)
+	if err != nil {
+		panic(fmt.Errorf("unable to marshal resource: %v", err))
+	}
+	printWithPrefix(string(b), prefix, ioStreams)
+}
+
+func printWithPrefix(text, prefix string, ioStreams genericclioptions.IOStreams) {
+	scanner := bufio.NewScanner(strings.NewReader(text))
+	for scanner.Scan() {
+		fmt.Fprintf(ioStreams.Out, "%s%s\n", prefix, scanner.Text())
+	}
+	if err := scanner.Err(); err != nil {
+		panic(fmt.Errorf("error reading text: %v", err))
+	}
+}
+
+// printKRM outputs the plan inside a ResourceList so the output format
+// follows the KRM function wire format.
+func printKRM(plan *v1alpha1.Plan, ioStreams genericclioptions.IOStreams) error {
+	b, err := yaml.Marshal(plan)
+	if err != nil {
+		return fmt.Errorf("failed to marshall plan into yaml: %w", err)
+	}
+	rn, err := yaml.Parse(string(b))
+	if err != nil {
+		return fmt.Errorf("failed to parse plan: %w", err)
+	}
+	writer := &kio.ByteWriter{
+		Writer:                ioStreams.Out,
+		KeepReaderAnnotations: true,
+		WrappingAPIVersion:    kio.ResourceListAPIVersion,
+		WrappingKind:          kio.ResourceListKind,
+	}
+	err = writer.Write([]*yaml.RNode{rn})
+	if err != nil {
+		return fmt.Errorf("failed to write resources: %w", err)
+	}
+	return nil
+}
diff --git a/internal/cmdplan/diff.go b/internal/cmdplan/diff.go
new file mode 100644
index 00000000..f4ddc2a5
--- /dev/null
+++ b/internal/cmdplan/diff.go
@@ -0,0 +1,130 @@
+// 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 cmdplan
+
+import (
+	"reflect"
+	"strconv"
+
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+)
+
+type Diff struct {
+	Type  string
+	Left  interface{}
+	Right interface{}
+	Path  string
+}
+
+func diffObjects(b, a *unstructured.Unstructured) ([]Diff, error) {
+	diffs, err := diffMaps("", b.Object, a.Object)
+	if err != nil {
+		return nil, err
+	}
+	return diffs, nil
+}
+
+func diffMaps(prefix string, l, r map[string]interface{}) ([]Diff, error) {
+	var diffs []Diff
+	for k, lv := range l {
+		childPrefix := prefix + "." + k
+
+		rv, ok := r[k]
+		if !ok {
+			diffs = append(diffs, Diff{Type: "LeftAdd", Path: childPrefix, Left: lv, Right: rv})
+			continue
+		}
+		childDiffs, err := diffValue(childPrefix, lv, rv)
+		if err != nil {
+			return nil, err
+		}
+		diffs = append(diffs, childDiffs...)
+	}
+
+	for k, rv := range r {
+		childPrefix := prefix + "." + k
+
+		lv, ok := l[k]
+		if !ok {
+			diffs = append(diffs, Diff{Type: "RightAdd", Path: childPrefix, Left: lv, Right: rv})
+			continue
+		}
+	}
+
+	return diffs, nil
+}
+
+func diffSlices(prefix string, l, r []interface{}) ([]Diff, error) {
+	var diffs []Diff
+	for i, lv := range l {
+		childPrefix := prefix + "." + strconv.Itoa(i)
+
+		if len(r) <= i {
+			diffs = append(diffs, Diff{Type: "LeftAdd", Path: childPrefix, Left: l[i], Right: nil})
+			continue
+		}
+		rv := r[i]
+		childDiffs, err := diffValue(childPrefix, lv, rv)
+		if err != nil {
+			return nil, err
+		}
+		diffs = append(diffs, childDiffs...)
+	}
+
+	for i, rv := range r {
+		childPrefix := prefix + "." + strconv.Itoa(i)
+
+		if len(l) <= i {
+			diffs = append(diffs, Diff{Type: "RightAdd", Path: childPrefix, Left: nil, Right: rv})
+			continue
+		}
+	}
+
+	return diffs, nil
+}
+
+func diffValue(path string, lv, rv interface{}) ([]Diff, error) {
+	switch lv := lv.(type) {
+	// case string:
+	// 	rvString, ok := rv.(string)
+	// 	if !ok || lv != rvString {
+	// 		diffs = append(diffs, Diff{Type: "Change", Path: childPrefix, Left: lv, Right: rv})
+	// 	}
+	// case int64:
+	// 	rvInt64, ok := rv.(int64)
+	// 	if !ok || lv != rvInt64 {
+	// 		diffs = append(diffs, Diff{Type: "Change", Path: childPrefix, Left: lv, Right: rv})
+	// 	}
+	case map[string]interface{}:
+		rvMap, ok := rv.(map[string]interface{})
+		if !ok {
+			return []Diff{{Type: "Change", Path: path, Left: lv, Right: rv}}, nil
+		}
+		return diffMaps(path, lv, rvMap)
+
+	case []interface{}:
+		rvSlice, ok := rv.([]interface{})
+		if !ok {
+			return []Diff{{Type: "Change", Path: path, Left: lv, Right: rv}}, nil
+		}
+		return diffSlices(path, lv, rvSlice)
+
+	default:
+		if !reflect.DeepEqual(lv, rv) {
+			return []Diff{{Type: "Change", Path: path, Left: lv, Right: rv}}, nil
+		}
+		return nil, nil
+	}
+}
diff --git a/pkg/api/plan/v1alpha1/types.go b/pkg/api/plan/v1alpha1/types.go
new file mode 100644
index 00000000..bd5e17a4
--- /dev/null
+++ b/pkg/api/plan/v1alpha1/types.go
@@ -0,0 +1,67 @@
+// Copyright 2022 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package v1alpha1
+
+import (
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	"sigs.k8s.io/kustomize/kyaml/yaml"
+)
+
+var ResourceMeta = yaml.ResourceMeta{
+	TypeMeta: yaml.TypeMeta{
+		APIVersion: "config.google.com/v1alpha1",
+		Kind:       "Plan",
+	},
+	ObjectMeta: yaml.ObjectMeta{
+		NameMeta: yaml.NameMeta{
+			Name: "plan",
+		},
+		Annotations: map[string]string{
+			"config.kubernetes.io/local-config": "true",
+		},
+	},
+}
+
+type Plan struct {
+	yaml.ResourceMeta `yaml:",inline" json:",inline"`
+
+	Spec PlanSpec `json:"spec,omitempty" yaml:"spec,omitempty"`
+}
+
+type PlanSpec struct {
+	Actions []Action `json:"actions,omitempty"`
+}
+
+type ActionType string
+
+const (
+	Create    ActionType = "Create"
+	Unchanged ActionType = "Unchanged"
+	Delete    ActionType = "Delete"
+	Update    ActionType = "Update"
+	Skip      ActionType = "Skip"
+	Error     ActionType = "Error"
+)
+
+type Action struct {
+	Type      ActionType                 `json:"action,omitempty" yaml:"action,omitempty"`
+	Group     string                     `json:"apiVersion,omitempty" yaml:"apiVersion,omitempty"`
+	Kind      string                     `json:"kind,omitempty" yaml:"kind,omitempty"`
+	Name      string                     `json:"name,omitempty" yaml:"name,omitempty"`
+	Namespace string                     `json:"namespace,omitempty" yaml:"namespace,omitempty"`
+	Before    *unstructured.Unstructured `json:"before,omitempty" yaml:"before,omitempty"`
+	After     *unstructured.Unstructured `json:"after,omitempty" yaml:"after,omitempty"`
+	Error     string                     `json:"error,omitempty" yaml:"error,omitempty"`
+}
diff --git a/pkg/live/planner/cluster.go b/pkg/live/planner/cluster.go
new file mode 100644
index 00000000..de6f3800
--- /dev/null
+++ b/pkg/live/planner/cluster.go
@@ -0,0 +1,297 @@
+// 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 planner
+
+import (
+	"context"
+	"fmt"
+	"reflect"
+
+	planv1alpha1 "github.com/GoogleContainerTools/kpt/pkg/api/plan/v1alpha1"
+	"github.com/GoogleContainerTools/kpt/pkg/live"
+	"github.com/GoogleContainerTools/kpt/pkg/status"
+	"github.com/GoogleContainerTools/kpt/thirdparty/cli-utils/apply"
+	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/client-go/dynamic"
+	"k8s.io/kubectl/pkg/cmd/util"
+	"sigs.k8s.io/cli-utils/pkg/apply/event"
+	"sigs.k8s.io/cli-utils/pkg/common"
+	"sigs.k8s.io/cli-utils/pkg/inventory"
+	"sigs.k8s.io/cli-utils/pkg/object"
+)
+
+func NewClusterPlanner(f util.Factory) (*ClusterPlanner, error) {
+	fetcher, err := NewResourceFetcher(f)
+	if err != nil {
+		return nil, err
+	}
+
+	invClient, err := inventory.NewClient(f, live.WrapInventoryObj, live.InvToUnstructuredFunc, inventory.StatusPolicyNone)
+	if err != nil {
+		return nil, err
+	}
+
+	statusPoller, err := status.NewStatusPoller(f)
+	if err != nil {
+		return nil, err
+	}
+
+	applier, err := apply.NewApplierBuilder().
+		WithFactory(f).
+		WithInventoryClient(invClient).
+		WithStatusPoller(statusPoller).
+		Build()
+	if err != nil {
+		return nil, err
+	}
+
+	return &ClusterPlanner{
+		applier:         applier,
+		resourceFetcher: fetcher,
+	}, nil
+}
+
+type Applier interface {
+	Run(ctx context.Context, invInfo inventory.Info, objects object.UnstructuredSet, options apply.ApplierOptions) <-chan event.Event
+}
+
+type ResourceFetcher interface {
+	FetchResource(ctx context.Context, id object.ObjMetadata) (*unstructured.Unstructured, bool, error)
+}
+
+type ClusterPlanner struct {
+	applier         Applier
+	resourceFetcher ResourceFetcher
+}
+
+type Options struct {
+	ServerSideOptions common.ServerSideOptions
+}
+
+func (r *ClusterPlanner) BuildPlan(ctx context.Context, inv inventory.Info, objects []*unstructured.Unstructured, o Options) (*planv1alpha1.Plan, error) {
+	actions, err := r.dryRunForPlan(ctx, inv, objects, o)
+	if err != nil {
+		return nil, err
+	}
+	return &planv1alpha1.Plan{
+		ResourceMeta: planv1alpha1.ResourceMeta,
+		Spec: planv1alpha1.PlanSpec{
+			Actions: actions,
+		},
+	}, nil
+}
+
+func (r *ClusterPlanner) dryRunForPlan(ctx context.Context, inv inventory.Info,
+	objects []*unstructured.Unstructured, o Options) ([]planv1alpha1.Action, error) {
+
+	eventCh := r.applier.Run(ctx, inv, objects, apply.ApplierOptions{
+		DryRunStrategy:    common.DryRunServer,
+		ServerSideOptions: o.ServerSideOptions,
+	})
+
+	var actions []planv1alpha1.Action
+	var err error
+	for e := range eventCh {
+		if e.Type == event.InitType {
+			// This event includes all resources that will be applied, pruned or deleted, so
+			// we make sure we fetch all the resources from the cluster.
+			// TODO: See if we can update the actuation library to provide the pre-actuation
+			// versions of the resources as part of the regular run. This solution is not great
+			// as fetching all resources will take time.
+			a, err := r.fetchResources(ctx, e)
+			if err != nil {
+				return nil, err
+			}
+			actions = a
+		}
+		if e.Type == event.ErrorType {
+			// Update the err variable here, but wait for the channel to close
+			// before we return from the function.
+			// Since ErrorEvents are considered fatal, there should only be sent
+			// and it will be followed by the channel being closed.
+			err = e.ErrorEvent.Err
+		}
+		// For the Apply, Prune and Delete event types, we just capture the result
+		// of the dry-run operation for the specific resource.
+		switch e.Type {
+		case event.ApplyType:
+			id := e.ApplyEvent.Identifier
+			index := indexForIdentifier(id, actions)
+			a := actions[index]
+			actions[index] = handleApplyEvent(e, a)
+		case event.PruneType:
+			id := e.PruneEvent.Identifier
+			index := indexForIdentifier(id, actions)
+			a := actions[index]
+			actions[index] = handlePruneEvent(e, a)
+		// Prune and Delete are essentially the same thing, but the actuation
+		// library return Prune events when resources are deleted by omission
+		// during apply, and Delete events from the destroyer. Supporting both
+		// here for completeness.
+		case event.DeleteType:
+			id := e.DeleteEvent.Identifier
+			index := indexForIdentifier(id, actions)
+			a := actions[index]
+			actions[index] = handleDeleteEvent(e, a)
+		}
+	}
+	return actions, err
+}
+
+func handleApplyEvent(e event.Event, a planv1alpha1.Action) planv1alpha1.Action {
+	if e.ApplyEvent.Error != nil {
+		a.Type = planv1alpha1.Error
+		a.Error = e.ApplyEvent.Error.Error()
+	} else {
+		switch e.ApplyEvent.Operation {
+		case event.Unchanged:
+			a.Type = planv1alpha1.Skip
+		case event.ServersideApplied:
+			a.After = e.ApplyEvent.Resource
+			if a.Before != nil {
+				// TODO: Unclear if we should diff the full resources here. It doesn't work
+				// well with client-side apply as the managedFields property shows up as
+				// changes. It also means there is a race with controllers that might change
+				// the status of resources.
+				if reflect.DeepEqual(a.Before, a.After) {
+					a.Type = planv1alpha1.Unchanged
+				} else {
+					a.Type = planv1alpha1.Update
+				}
+			} else {
+				a.Type = planv1alpha1.Create
+			}
+		}
+	}
+	return a
+}
+
+func handlePruneEvent(e event.Event, a planv1alpha1.Action) planv1alpha1.Action {
+	if e.PruneEvent.Error != nil {
+		a.Type = planv1alpha1.Error
+		a.Error = e.PruneEvent.Error.Error()
+	} else {
+		switch e.PruneEvent.Operation {
+		case event.Pruned:
+			a.Type = planv1alpha1.Delete
+		// Lifecycle directives can cause resources to remain in the
+		// live state even if they would normally be pruned.
+		// TODO: Handle reason for skipped resources that has recently
+		// been added to the actuation library.
+		case event.PruneSkipped:
+			a.Type = planv1alpha1.Skip
+		}
+	}
+	return a
+}
+
+func handleDeleteEvent(e event.Event, a planv1alpha1.Action) planv1alpha1.Action {
+	if e.DeleteEvent.Error != nil {
+		a.Type = planv1alpha1.Error
+		a.Error = e.DeleteEvent.Error.Error()
+	} else {
+		switch e.DeleteEvent.Operation {
+		case event.Deleted:
+			a.Type = planv1alpha1.Delete
+		case event.DeleteSkipped:
+			a.Type = planv1alpha1.Skip
+		}
+	}
+	return a
+}
+
+func (r *ClusterPlanner) fetchResources(ctx context.Context, e event.Event) ([]planv1alpha1.Action, error) {
+	var actions []planv1alpha1.Action
+	for _, ag := range e.InitEvent.ActionGroups {
+		// We only care about the Apply, Prune and Delete actions.
+		if !(ag.Action == event.ApplyAction || ag.Action == event.PruneAction || ag.Action == event.DeleteAction) {
+			continue
+		}
+		for _, id := range ag.Identifiers {
+			u, _, err := r.resourceFetcher.FetchResource(ctx, id)
+			// If the type doesn't exist in the cluster, then the resource itself doesn't exist.
+			if err != nil && !meta.IsNoMatchError(err) {
+				return nil, err
+			}
+			actions = append(actions, planv1alpha1.Action{
+				Group:     id.GroupKind.Group,
+				Kind:      id.GroupKind.Kind,
+				Name:      id.Name,
+				Namespace: id.Namespace,
+				Before:    u,
+			})
+		}
+	}
+	return actions, nil
+}
+
+type resourceFetcher struct {
+	dynamicClient dynamic.Interface
+	mapper        meta.RESTMapper
+}
+
+func NewResourceFetcher(f util.Factory) (ResourceFetcher, error) {
+	dc, err := f.DynamicClient()
+	if err != nil {
+		return nil, err
+	}
+
+	mapper, err := f.ToRESTMapper()
+	if err != nil {
+		return nil, err
+	}
+	return &resourceFetcher{
+		dynamicClient: dc,
+		mapper:        mapper,
+	}, nil
+}
+
+func (rf *resourceFetcher) FetchResource(ctx context.Context, id object.ObjMetadata) (*unstructured.Unstructured, bool, error) {
+	mapping, err := rf.mapper.RESTMapping(id.GroupKind)
+	if err != nil {
+		return nil, false, err
+	}
+	var r dynamic.ResourceInterface
+	if mapping.Scope == meta.RESTScopeRoot {
+		r = rf.dynamicClient.Resource(mapping.Resource)
+	} else {
+		r = rf.dynamicClient.Resource(mapping.Resource).Namespace(id.Namespace)
+	}
+	u, err := r.Get(ctx, id.Name, metav1.GetOptions{})
+	if err != nil && !apierrors.IsNotFound(err) {
+		return nil, false, err
+	}
+
+	if apierrors.IsNotFound(err) {
+		return nil, false, nil
+	}
+	return u, true, nil
+}
+
+func indexForIdentifier(id object.ObjMetadata, actions []planv1alpha1.Action) int {
+	for i := range actions {
+		a := actions[i]
+		if a.Group == id.GroupKind.Group &&
+			a.Kind == id.GroupKind.Kind &&
+			a.Name == id.Name &&
+			a.Namespace == id.Namespace {
+			return i
+		}
+	}
+	panic(fmt.Errorf("unknown identifier %s", id.String()))
+}
diff --git a/porch/go.mod b/porch/go.mod
index c370edfb..fa228920 100644
--- a/porch/go.mod
+++ b/porch/go.mod
@@ -142,7 +142,7 @@ require (
 	github.com/qri-io/starlib v0.5.0 // indirect
 	github.com/russross/blackfriday v1.5.2 // indirect
 	github.com/russross/blackfriday/v2 v2.1.0 // indirect
-	github.com/sergi/go-diff v1.1.0 // indirect
+	github.com/sergi/go-diff v1.2.0 // indirect
 	github.com/sirupsen/logrus v1.8.1 // indirect
 	github.com/stretchr/testify v1.7.1 // indirect
 	github.com/vbatts/tar-split v0.11.2 // indirect
diff --git a/porch/go.sum b/porch/go.sum
index dd16a645..623f593c 100644
--- a/porch/go.sum
+++ b/porch/go.sum
@@ -934,8 +934,9 @@ github.com/sagikazarmark/crypt v0.3.0/go.mod h1:uD/D+6UF4SrIR1uGEv7bBNkNqLGqUr43
 github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
 github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
 github.com/seccomp/libseccomp-golang v0.9.1/go.mod h1:GbW5+tmTXfcxTToHLXlScSlAvWlF4P2Ca7zGrPiEpWo=
-github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
 github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
+github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
+github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
 github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
 github.com/sirupsen/logrus v1.0.4-0.20170822132746-89742aefa4b2/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc=
 github.com/sirupsen/logrus v1.0.6/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc=
diff --git a/scripts/generate_site_sidebar/sidebar_template.md.tmpl b/scripts/generate_site_sidebar/sidebar_template.md.tmpl
index ea92ab77..6bc59b6b 100644
--- a/scripts/generate_site_sidebar/sidebar_template.md.tmpl
+++ b/scripts/generate_site_sidebar/sidebar_template.md.tmpl
@@ -57,11 +57,12 @@
 - [GitOps](gitops/)
   - [Config Sync](gitops/configsync/)
 - [Guides](guides/)
-  - [The Rationale behind kpt](guides/rationale.md)
-  - [Namespace provisioning CLI](guides/namespace-provisioning-cli.md)
-  - [Namespace provisioning UI](guides/namespace-provisioning-ui.md)
+  - [The Rationale Behind kpt](guides/rationale.md)
   - [Porch Installation Guide](guides/porch-installation.md)
+  - [Porch UI Installation Guide](guides/porch-ui-installation.md)
   - [Porch User Guide](guides/porch-user-guide.md)
+  - [Namespace Provisioning CLI](guides/namespace-provisioning-cli.md)
+  - [Namespace Provisioning UI](guides/namespace-provisioning-ui.md)
   - [Variant Constructor Pattern](guides/variant-constructor-pattern.md)
   - [Value Propagation Pattern](guides/value-propagation.md)
 - [FAQ](faq/)
diff --git a/site/coverpage.md b/site/coverpage.md
index d24ac3fe..e4eb6baf 100644
--- a/site/coverpage.md
+++ b/site/coverpage.md
@@ -6,8 +6,8 @@
 > by manipulating declarative Configuration as Data, 
 > separated from the code that transforms it.
 
-<iframe id="ytplayer" type="text/html" width="550" height="360"
-    src="https://www.youtube.com/embed/L_x7z4CXHDw" allowFullScreen="allowFullScreen"> </iframe>
+<iframe id="ytplayer" type="text/html" width="330" height="216"
+    src="https://www.youtube.com/embed/L_x7z4CXHDw" align="left" allowFullScreen="allowFullScreen"> </iframe>
 
 
 [Why kpt](guides/rationale.md)
diff --git a/site/guides/README.md b/site/guides/README.md
index 573e2ffd..dcd63cb1 100644
--- a/site/guides/README.md
+++ b/site/guides/README.md
@@ -1,10 +1,10 @@
 # Reference
 
-- [The Rationale behind kpt](guides/rationale.md)
-- [Namespace provisioning CLI](guides/namespace-provisioning-cli.md)
-- [Namespace provisioning UI](guides/namespace-provisioning-ui.md)
+- [The Rationale Behind kpt](guides/rationale.md)
 - [Porch Installation Guide](guides/porch-installation.md)
+- [Porch UI Installation Guide](guides/porch-ui-installation.md)
 - [Porch User Guide](guides/porch-user-guide.md)
-- [Tenant Onboarding Guide](guides/tenant-onboarding.md)
+- [Namespace Provisioning CLI](guides/namespace-provisioning-cli.md)
+- [Namespace Provisioning UI](guides/namespace-provisioning-ui.md)
 - [Variant Constructor Pattern](guides/variant-constructor-pattern.md)
-- [Value Propagation Pattern](guides/value-propagation.md)
+- [Value Propagation Pattern](guides/value-propagation.md)
\ No newline at end of file
diff --git a/site/guides/namespace-provisioning-cli.md b/site/guides/namespace-provisioning-cli.md
index 77665cbf..add44611 100644
--- a/site/guides/namespace-provisioning-cli.md
+++ b/site/guides/namespace-provisioning-cli.md
@@ -16,8 +16,8 @@ In this guide, we will create a package that will be used for provisioning a nam
 ### Repositories
 
 Platform teams will have to setup two repos:
-1.Blueprint repo where reusable kpt packages will live.
-2.Deployment repo where instances of packages that will be deployed to a
+1. Blueprint repo where reusable kpt packages will live.
+2. Deployment repo where instances of packages that will be deployed to a
 kubernetes cluster will live. Users will create deployable packages from the
 packages in the blueprint repo.
 
diff --git a/site/guides/porch-installation.md b/site/guides/porch-installation.md
index 2d99bbb8..8f07a95e 100644
--- a/site/guides/porch-installation.md
+++ b/site/guides/porch-installation.md
@@ -192,30 +192,3 @@ kubectl api-resources | grep porch
 
 And start [using Porch](guides/porch-user-guide.md) if the Porch resources are
 available.
-
-## Deploying Configuration
-
-**Note:** Rather than using Config Sync, this section uses a prototype sync
-controller.
-
-Create some example repositories / packages:
-
-```sh
-# Create artifact-registry repos etc
-make apply-dev-config
-# Push a sample hello-world app
-make -C config/samples/apps/hello-server push-image
-# Create a package for the sample hello-world app
-./scripts/create-deployment-package.sh
-```
-
-To test out remoterootsync self-applying:
-
-```sh
-# Grant more RBAC permissions than are normally needed (equivalent to admin permissions)
-kubectl apply -f controllers/remoterootsync/config/samples/hack-self-apply-rbac.yaml
-
-# Apply the RemoteRootSyncSet
-cat controllers/remoterootsync/config/samples/hack-self-apply.yaml \
-  | sed -e s/example-google-project-id/${GCP_PROJECT_ID}/g | kubectl apply -f -
-```
diff --git a/site/guides/value-propagation.md b/site/guides/value-propagation.md
index 4a8ed2a4..ffe133f6 100644
--- a/site/guides/value-propagation.md
+++ b/site/guides/value-propagation.md
@@ -1,7 +1,7 @@
 [starlark]: https://catalog.kpt.dev/starlark/v0.4/
 [apply-replacements]: https://catalog.kpt.dev/apply-replacements/v0.1/
 
-# Value propagation
+# Value propagation pattern
 
 Generating a string value and propagating that value to another place
 (or many other places) in your configuration is a very common pattern. 
diff --git a/site/sidebar.md b/site/sidebar.md
index 009f2683..cd7e4d0b 100644
--- a/site/sidebar.md
+++ b/site/sidebar.md
@@ -96,11 +96,12 @@
 - [GitOps](gitops/)
   - [Config Sync](gitops/configsync/)
 - [Guides](guides/)
-  - [The Rationale behind kpt](guides/rationale.md)
-  - [Namespace provisioning CLI](guides/namespace-provisioning-cli.md)
-  - [Namespace provisioning UI](guides/namespace-provisioning-ui.md)
+  - [The Rationale Behind kpt](guides/rationale.md)
   - [Porch Installation Guide](guides/porch-installation.md)
+  - [Porch UI Installation Guide](guides/porch-ui-installation.md)
   - [Porch User Guide](guides/porch-user-guide.md)
+  - [Namespace Provisioning CLI](guides/namespace-provisioning-cli.md)
+  - [Namespace Provisioning UI](guides/namespace-provisioning-ui.md)
   - [Variant Constructor Pattern](guides/variant-constructor-pattern.md)
   - [Value Propagation Pattern](guides/value-propagation.md)
 - [FAQ](faq/)


Test patch:
diff --git a/e2e/live_test.go b/e2e/live_test.go
index 159c1e14..c7944046 100644
--- a/e2e/live_test.go
+++ b/e2e/live_test.go
@@ -1,3 +1,4 @@
+//go:build kind
 // +build kind
 
 // Copyright 2021 Google LLC
@@ -29,6 +30,10 @@ func TestLiveApply(t *testing.T) {
 	runTests(t, filepath.Join(".", "testdata", "live-apply"))
 }
 
+func TestLivePlan(t *testing.T) {
+	runTests(t, filepath.Join(".", "testdata", "live-plan"))
+}
+
 func runTests(t *testing.T, path string) {
 	testCases := scanTestCases(t, path)
 
@@ -62,8 +67,8 @@ func runTests(t *testing.T, path string) {
 			defer livetest.RemoveNamespace(t, ns)
 
 			(&livetest.Runner{
-				Config:    c,
-				Path:      p,
+				Config: c,
+				Path:   p,
 			}).Run(t)
 		})
 	}
diff --git a/e2e/testdata/live-apply/apply-depends-on/config.yaml b/e2e/testdata/live-apply/apply-depends-on/config.yaml
index b9549880..e2e0e020 100644
--- a/e2e/testdata/live-apply/apply-depends-on/config.yaml
+++ b/e2e/testdata/live-apply/apply-depends-on/config.yaml
@@ -15,6 +15,8 @@
 parallel: true
 
 kptArgs:
+  - "live"
+  - "apply"
   - "--reconcile-timeout=2m"
 
 stdOut: |
diff --git a/e2e/testdata/live-apply/crd-and-cr/config.yaml b/e2e/testdata/live-apply/crd-and-cr/config.yaml
index a0e5ef4a..38e5c621 100644
--- a/e2e/testdata/live-apply/crd-and-cr/config.yaml
+++ b/e2e/testdata/live-apply/crd-and-cr/config.yaml
@@ -15,6 +15,8 @@
 parallel: true
 
 kptArgs:
+  - "live"
+  - "apply"
   - "--reconcile-timeout=1m"
 
 stdOut: |
diff --git a/e2e/testdata/live-apply/dry-run-with-install-rg/config.yaml b/e2e/testdata/live-apply/dry-run-with-install-rg/config.yaml
index add5fb5e..a79fb851 100644
--- a/e2e/testdata/live-apply/dry-run-with-install-rg/config.yaml
+++ b/e2e/testdata/live-apply/dry-run-with-install-rg/config.yaml
@@ -15,6 +15,8 @@
 parallel: false
 noResourceGroup: true
 kptArgs:
+  - "live"
+  - "apply"
   - "--dry-run"
   - "--install-resource-group"
 stdOut: |
diff --git a/e2e/testdata/live-apply/dry-run-without-rg/config.yaml b/e2e/testdata/live-apply/dry-run-without-rg/config.yaml
index 608a7e83..a45ee3c6 100644
--- a/e2e/testdata/live-apply/dry-run-without-rg/config.yaml
+++ b/e2e/testdata/live-apply/dry-run-without-rg/config.yaml
@@ -15,6 +15,8 @@
 parallel: false
 noResourceGroup: true
 kptArgs:
+  - "live"
+  - "apply"
   - "--dry-run"
 stdErr: |
   Error: The ResourceGroup CRD was not found in the cluster. Please install it either by using the '--install-resource-group' flag or the 'kpt live install-resource-group' command.
diff --git a/e2e/testdata/live-apply/install-rg-on-apply/config.yaml b/e2e/testdata/live-apply/install-rg-on-apply/config.yaml
index b0019b96..22479834 100644
--- a/e2e/testdata/live-apply/install-rg-on-apply/config.yaml
+++ b/e2e/testdata/live-apply/install-rg-on-apply/config.yaml
@@ -15,6 +15,8 @@
 parallel: false
 noResourceGroup: true
 kptArgs:
+  - "live"
+  - "apply"
   - "--reconcile-timeout=1m"
 stdOut: |
   deployment.apps/nginx-deployment created
diff --git a/e2e/testdata/live-apply/json-output/config.yaml b/e2e/testdata/live-apply/json-output/config.yaml
index 2a5526d4..45c2b3f4 100644
--- a/e2e/testdata/live-apply/json-output/config.yaml
+++ b/e2e/testdata/live-apply/json-output/config.yaml
@@ -14,6 +14,8 @@
 
 parallel: true
 kptArgs:
+  - "live"
+  - "apply"
   - "--output=json"
   - "--reconcile-timeout=2m"
 stdOut: |
diff --git a/e2e/testdata/live-apply/prune-depends-on/config.yaml b/e2e/testdata/live-apply/prune-depends-on/config.yaml
index 2f85051b..6b46a4fa 100644
--- a/e2e/testdata/live-apply/prune-depends-on/config.yaml
+++ b/e2e/testdata/live-apply/prune-depends-on/config.yaml
@@ -15,6 +15,8 @@
 parallel: true
 
 kptArgs:
+  - "live"
+  - "apply"
   - "--reconcile-timeout=1m"
 
 stdOut: |
diff --git a/e2e/testdata/live-plan/create/config.yaml b/e2e/testdata/live-plan/create/config.yaml
new file mode 100644
index 00000000..c0b38824
--- /dev/null
+++ b/e2e/testdata/live-plan/create/config.yaml
@@ -0,0 +1,61 @@
+# Copyright 2021 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.
+
+parallel: true
+
+kptArgs:
+  - "live"
+  - "plan"
+
+stdOut: |
+  apiVersion: config.kubernetes.io/v1
+  kind: ResourceList
+  items:
+  - apiVersion: config.google.com/v1alpha1
+    kind: Plan
+    metadata:
+      name: plan
+    spec:
+      actions:
+      - action: ApplyCreate
+        kind: ConfigMap
+        name: foo
+        namespace: create
+        after:
+          object:
+            apiVersion: v1
+            data:
+              key: value
+            kind: ConfigMap
+            metadata:
+              annotations:
+                config.k8s.io/owning-inventory: create
+              creationTimestamp: "<TIMESTAMP>"
+              managedFields:
+              - apiVersion: v1
+                fieldsType: FieldsV1
+                fieldsV1:
+                  f:data:
+                    f:key: {}
+                  f:metadata:
+                    f:annotations:
+                      f:config.k8s.io/owning-inventory: {}
+                manager: kubectl
+                operation: Apply
+                time: "<TIMESTAMP>"
+              name: foo
+              namespace: create
+              uid: <UID>
+
+
diff --git a/e2e/testdata/live-plan/create/resources/Kptfile b/e2e/testdata/live-plan/create/resources/Kptfile
new file mode 100644
index 00000000..946835b4
--- /dev/null
+++ b/e2e/testdata/live-plan/create/resources/Kptfile
@@ -0,0 +1,8 @@
+apiVersion: kpt.dev/v1
+kind: Kptfile
+metadata:
+  name: create
+inventory:
+  namespace: create
+  name: create
+  inventoryID: create
diff --git a/e2e/testdata/live-plan/create/resources/cm.yaml b/e2e/testdata/live-plan/create/resources/cm.yaml
new file mode 100644
index 00000000..dd896ead
--- /dev/null
+++ b/e2e/testdata/live-plan/create/resources/cm.yaml
@@ -0,0 +1,21 @@
+# Copyright 2021 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: v1
+kind: ConfigMap
+metadata:
+  name: foo
+  namespace: create
+data:
+  key: value
\ No newline at end of file
diff --git a/e2e/testdata/live-plan/prune/config.yaml b/e2e/testdata/live-plan/prune/config.yaml
new file mode 100644
index 00000000..f3a2fc06
--- /dev/null
+++ b/e2e/testdata/live-plan/prune/config.yaml
@@ -0,0 +1,210 @@
+# Copyright 2021 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.
+
+parallel: true
+
+kptArgs:
+  - "live"
+  - "plan"
+
+stdOut: |
+  apiVersion: config.kubernetes.io/v1
+  kind: ResourceList
+  items:
+  - apiVersion: config.google.com/v1alpha1
+    kind: Plan
+    metadata:
+      name: plan
+    spec:
+      actions:
+      - action: ApplyChanges
+        kind: ConfigMap
+        name: cm
+        namespace: prune
+        before:
+          object:
+            apiVersion: v1
+            data:
+              key: value
+            kind: ConfigMap
+            metadata:
+              annotations:
+                config.k8s.io/owning-inventory: prune
+                kubectl.kubernetes.io/last-applied-configuration: |
+                  {"apiVersion":"v1","data":{"key":"value"},"kind":"ConfigMap","metadata":{"annotations":{"config.k8s.io/owning-inventory":"prune"},"name":"cm","namespace":"prune"}}
+              creationTimestamp: "<TIMESTAMP>"
+              managedFields:
+              - apiVersion: v1
+                fieldsType: FieldsV1
+                fieldsV1:
+                  f:data:
+                    .: {}
+                    f:key: {}
+                  f:metadata:
+                    f:annotations:
+                      .: {}
+                      f:config.k8s.io/owning-inventory: {}
+                      f:kubectl.kubernetes.io/last-applied-configuration: {}
+                manager: kubectl-client-side-apply
+                operation: Update
+                time: "<TIMESTAMP>"
+              name: cm
+              namespace: prune
+              resourceVersion: "<RV>"
+              uid: <UID>
+        after:
+          object:
+            apiVersion: v1
+            data:
+              key: value
+            kind: ConfigMap
+            metadata:
+              annotations:
+                config.k8s.io/owning-inventory: prune
+                kubectl.kubernetes.io/last-applied-configuration: |
+                  {"apiVersion":"v1","data":{"key":"value"},"kind":"ConfigMap","metadata":{"annotations":{"config.k8s.io/owning-inventory":"prune"},"name":"cm","namespace":"prune"}}
+              creationTimestamp: "<TIMESTAMP>"
+              managedFields:
+              - apiVersion: v1
+                fieldsType: FieldsV1
+                fieldsV1:
+                  f:data:
+                    f:key: {}
+                  f:metadata:
+                    f:annotations:
+                      f:config.k8s.io/owning-inventory: {}
+                manager: kubectl
+                operation: Apply
+                time: "<TIMESTAMP>"
+              - apiVersion: v1
+                fieldsType: FieldsV1
+                fieldsV1:
+                  f:data:
+                    .: {}
+                    f:key: {}
+                  f:metadata:
+                    f:annotations:
+                      .: {}
+                      f:config.k8s.io/owning-inventory: {}
+                      f:kubectl.kubernetes.io/last-applied-configuration: {}
+                manager: kubectl-client-side-apply
+                operation: Update
+                time: "<TIMESTAMP>"
+              name: cm
+              namespace: prune
+              resourceVersion: "<RV>"
+              uid: <UID>
+      - action: PrunePruned
+        apiVersion: apps
+        kind: Deployment
+        name: dep
+        namespace: prune
+        before:
+          object:
+            apiVersion: apps/v1
+            kind: Deployment
+            metadata:
+              annotations:
+                config.k8s.io/owning-inventory: prune
+                kubectl.kubernetes.io/last-applied-configuration: |
+                  {"apiVersion":"apps/v1","kind":"Deployment","metadata":{"annotations":{"config.k8s.io/owning-inventory":"prune"},"name":"dep","namespace":"prune"},"spec":{"replicas":1,"selector":{"matchLabels":{"app":"dep"}},"template":{"metadata":{"labels":{"app":"dep"}},"spec":{"containers":[{"image":"nginx:1.14.2","name":"nginx","ports":[{"containerPort":80}]}]}}}}
+              creationTimestamp: "<TIMESTAMP>"
+              generation: 1
+              managedFields:
+              - apiVersion: apps/v1
+                fieldsType: FieldsV1
+                fieldsV1:
+                  f:metadata:
+                    f:annotations:
+                      .: {}
+                      f:config.k8s.io/owning-inventory: {}
+                      f:kubectl.kubernetes.io/last-applied-configuration: {}
+                  f:spec:
+                    f:progressDeadlineSeconds: {}
+                    f:replicas: {}
+                    f:revisionHistoryLimit: {}
+                    f:selector: {}
+                    f:strategy:
+                      f:rollingUpdate:
+                        .: {}
+                        f:maxSurge: {}
+                        f:maxUnavailable: {}
+                      f:type: {}
+                    f:template:
+                      f:metadata:
+                        f:labels:
+                          .: {}
+                          f:app: {}
+                      f:spec:
+                        f:containers:
+                          k:{"name":"nginx"}:
+                            .: {}
+                            f:image: {}
+                            f:imagePullPolicy: {}
+                            f:name: {}
+                            f:ports:
+                              .: {}
+                              k:{"containerPort":80,"protocol":"TCP"}:
+                                .: {}
+                                f:containerPort: {}
+                                f:protocol: {}
+                            f:resources: {}
+                            f:terminationMessagePath: {}
+                            f:terminationMessagePolicy: {}
+                        f:dnsPolicy: {}
+                        f:restartPolicy: {}
+                        f:schedulerName: {}
+                        f:securityContext: {}
+                        f:terminationGracePeriodSeconds: {}
+                manager: kubectl-client-side-apply
+                operation: Update
+                time: "<TIMESTAMP>"
+              name: dep
+              namespace: prune
+              resourceVersion: "<RV>"
+              uid: <UID>
+            spec:
+              progressDeadlineSeconds: 600
+              replicas: 1
+              revisionHistoryLimit: 10
+              selector:
+                matchLabels:
+                  app: dep
+              strategy:
+                rollingUpdate:
+                  maxSurge: 25%
+                  maxUnavailable: 25%
+                type: RollingUpdate
+              template:
+                metadata:
+                  creationTimestamp: null
+                  labels:
+                    app: dep
+                spec:
+                  containers:
+                  - image: nginx:1.14.2
+                    imagePullPolicy: IfNotPresent
+                    name: nginx
+                    ports:
+                    - containerPort: 80
+                      protocol: TCP
+                    resources: {}
+                    terminationMessagePath: /dev/termination-log
+                    terminationMessagePolicy: File
+                  dnsPolicy: ClusterFirst
+                  restartPolicy: Always
+                  schedulerName: default-scheduler
+                  securityContext: {}
+                  terminationGracePeriodSeconds: 30
+            status: {}
diff --git a/e2e/testdata/live-plan/prune/pre-apply/cm.yaml b/e2e/testdata/live-plan/prune/pre-apply/cm.yaml
new file mode 100644
index 00000000..fcc5a09e
--- /dev/null
+++ b/e2e/testdata/live-plan/prune/pre-apply/cm.yaml
@@ -0,0 +1,23 @@
+# Copyright 2021 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: v1
+kind: ConfigMap
+metadata:
+  name: cm
+  namespace: prune
+  annotations:
+    config.k8s.io/owning-inventory: prune
+data:
+  key: value
\ No newline at end of file
diff --git a/e2e/testdata/live-plan/prune/pre-apply/dep.yaml b/e2e/testdata/live-plan/prune/pre-apply/dep.yaml
new file mode 100644
index 00000000..64d93c2d
--- /dev/null
+++ b/e2e/testdata/live-plan/prune/pre-apply/dep.yaml
@@ -0,0 +1,36 @@
+# Copyright 2021 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: apps/v1
+kind: Deployment
+metadata:
+  name: dep
+  namespace: prune
+  annotations:
+    config.k8s.io/owning-inventory: prune
+spec:
+  replicas: 1
+  selector:
+    matchLabels:
+      app: dep
+  template:
+    metadata:
+      labels:
+        app: dep
+    spec:
+      containers:
+        - name: nginx
+          image: nginx:1.14.2
+          ports:
+            - containerPort: 80
\ No newline at end of file
diff --git a/e2e/testdata/live-plan/prune/pre-apply/rg.yaml b/e2e/testdata/live-plan/prune/pre-apply/rg.yaml
new file mode 100644
index 00000000..40ae15e1
--- /dev/null
+++ b/e2e/testdata/live-plan/prune/pre-apply/rg.yaml
@@ -0,0 +1,31 @@
+# Copyright 2021 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: kpt.dev/v1alpha1
+kind: ResourceGroup
+metadata:
+  labels:
+    cli-utils.sigs.k8s.io/inventory-id: prune
+  name: prune
+  namespace: prune
+spec:
+  resources:
+    - group: ""
+      kind: ConfigMap
+      name: cm
+      namespace: prune
+    - group: apps
+      kind: Deployment
+      name: dep
+      namespace: prune
\ No newline at end of file
diff --git a/e2e/testdata/live-plan/prune/resources/Kptfile b/e2e/testdata/live-plan/prune/resources/Kptfile
new file mode 100644
index 00000000..b29cf046
--- /dev/null
+++ b/e2e/testdata/live-plan/prune/resources/Kptfile
@@ -0,0 +1,8 @@
+apiVersion: kpt.dev/v1
+kind: Kptfile
+metadata:
+  name: prune
+inventory:
+  namespace: prune
+  name: prune
+  inventoryID: prune
\ No newline at end of file
diff --git a/e2e/testdata/live-plan/prune/resources/cm.yaml b/e2e/testdata/live-plan/prune/resources/cm.yaml
new file mode 100644
index 00000000..608ce9d9
--- /dev/null
+++ b/e2e/testdata/live-plan/prune/resources/cm.yaml
@@ -0,0 +1,21 @@
+# Copyright 2021 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: v1
+kind: ConfigMap
+metadata:
+  name: cm
+  namespace: prune
+data:
+  key: value
\ No newline at end of file
diff --git a/pkg/live/planner/cluster_test.go b/pkg/live/planner/cluster_test.go
new file mode 100644
index 00000000..5b48564d
--- /dev/null
+++ b/pkg/live/planner/cluster_test.go
@@ -0,0 +1,172 @@
+// 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 planner
+
+import (
+	"context"
+	"testing"
+
+	planv1alpha1 "github.com/GoogleContainerTools/kpt/pkg/api/plan/v1alpha1"
+	"github.com/GoogleContainerTools/kpt/thirdparty/cli-utils/apply"
+	"github.com/google/go-cmp/cmp"
+	"github.com/stretchr/testify/require"
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	"sigs.k8s.io/cli-utils/pkg/apply/event"
+	"sigs.k8s.io/cli-utils/pkg/inventory"
+	"sigs.k8s.io/cli-utils/pkg/object"
+	"sigs.k8s.io/cli-utils/pkg/testutil"
+)
+
+var (
+	deploymentYAML = `
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: foo
+  namespace: default
+spec:
+  replicas: 1
+`
+)
+
+func TestClusterPlanner(t *testing.T) {
+	testCases := map[string]struct {
+		resources        []*unstructured.Unstructured
+		clusterResources []*unstructured.Unstructured
+		events           []event.Event
+
+		expectedPlan *planv1alpha1.Plan
+	}{
+		"single new resource": {
+			resources: []*unstructured.Unstructured{
+				testutil.Unstructured(t, deploymentYAML),
+			},
+			clusterResources: []*unstructured.Unstructured{},
+			events: []event.Event{
+				{
+					Type: event.InitType,
+					InitEvent: event.InitEvent{
+						ActionGroups: event.ActionGroupList{
+							{
+								Action: event.ApplyAction,
+								Name:   "apply-1",
+								Identifiers: []object.ObjMetadata{
+									testutil.ToIdentifier(t, deploymentYAML),
+								},
+							},
+						},
+					},
+				},
+				{
+					Type: event.ApplyType,
+					ApplyEvent: event.ApplyEvent{
+						GroupName:  "apply-1",
+						Identifier: testutil.ToIdentifier(t, deploymentYAML),
+						Operation:  event.ServersideApplied,
+						Resource:   testutil.Unstructured(t, deploymentYAML),
+					},
+				},
+			},
+			expectedPlan: &planv1alpha1.Plan{
+				ResourceMeta: planv1alpha1.ResourceMeta,
+				Spec: planv1alpha1.PlanSpec{
+					Actions: []planv1alpha1.Action{
+						{
+							Type:      planv1alpha1.Create,
+							Name:      "foo",
+							Namespace: "default",
+							Group:     "apps",
+							Kind:      "Deployment",
+							After:     testutil.Unstructured(t, deploymentYAML),
+						},
+					},
+				},
+			},
+		},
+	}
+
+	for tn := range testCases {
+		tc := testCases[tn]
+		t.Run(tn, func(t *testing.T) {
+			ctx := context.Background()
+
+			applier := &FakeApplier{
+				events: tc.events,
+			}
+
+			fakeResourceFetcher := &FakeResourceFetcher{
+				resources: tc.clusterResources,
+			}
+
+			plan, err := (&ClusterPlanner{
+				applier:         applier,
+				resourceFetcher: fakeResourceFetcher,
+			}).BuildPlan(ctx, &FakeInventoryInfo{}, []*unstructured.Unstructured{}, Options{})
+			require.NoError(t, err)
+
+			if diff := cmp.Diff(tc.expectedPlan, plan); diff != "" {
+				t.Errorf("plan mismatch (-want +got):\n%s", diff)
+			}
+		})
+	}
+}
+
+type FakeApplier struct {
+	events []event.Event
+}
+
+func (f *FakeApplier) Run(context.Context, inventory.Info, object.UnstructuredSet, apply.ApplierOptions) <-chan event.Event {
+	eventChannel := make(chan event.Event)
+	go func() {
+		defer close(eventChannel)
+		for i := range f.events {
+			eventChannel <- f.events[i]
+		}
+	}()
+	return eventChannel
+}
+
+type FakeResourceFetcher struct {
+	resources []*unstructured.Unstructured
+}
+
+func (frf *FakeResourceFetcher) FetchResource(_ context.Context, id object.ObjMetadata) (*unstructured.Unstructured, bool, error) {
+	for i := range frf.resources {
+		r := frf.resources[i]
+		rid := object.UnstructuredToObjMetadata(r)
+		if rid == id {
+			return r, true, nil
+		}
+	}
+	return nil, false, nil
+}
+
+type FakeInventoryInfo struct{}
+
+func (fii *FakeInventoryInfo) Namespace() string {
+	return ""
+}
+
+func (fii *FakeInventoryInfo) Name() string {
+	return ""
+}
+
+func (fii *FakeInventoryInfo) ID() string {
+	return ""
+}
+
+func (fii *FakeInventoryInfo) Strategy() inventory.Strategy {
+	return inventory.NameStrategy
+}
diff --git a/pkg/test/live/runner.go b/pkg/test/live/runner.go
index 26ddcf2c..ef0a8c1b 100644
--- a/pkg/test/live/runner.go
+++ b/pkg/test/live/runner.go
@@ -72,9 +72,8 @@ func (r *Runner) RunPreApply(t *testing.T) {
 }
 
 func (r *Runner) RunApply(t *testing.T) (string, string, error) {
-	args := append([]string{"live", "apply"}, r.Config.KptArgs...)
-	t.Logf("Running command: kpt %s", strings.Join(args, " "))
-	cmd := exec.Command("kpt", args...)
+	t.Logf("Running command: kpt %s", strings.Join(r.Config.KptArgs, " "))
+	cmd := exec.Command("kpt", r.Config.KptArgs...)
 	cmd.Dir = filepath.Join(r.Path, "resources")
 
 	var outBuf bytes.Buffer
@@ -106,7 +105,11 @@ func (r *Runner) VerifyStderr(t *testing.T, stderr string) {
 }
 
 func prepOutput(t *testing.T, s string) string {
-	return strings.TrimSpace(substituteTimestamps(removeStatusEvents(t, s)))
+	txt := removeStatusEvents(t, s)
+	txt = substituteTimestamps(txt)
+	txt = substituteUIDs(txt)
+	txt = substituteResourceVersion(txt)
+	return strings.TrimSpace(txt)
 }
 
 func (r *Runner) VerifyInventory(t *testing.T, name, namespace string) {
@@ -179,6 +182,18 @@ func substituteTimestamps(text string) string {
 	return timestampRegexp.ReplaceAllString(text, "<TIMESTAMP>")
 }
 
+var uidRegexp = regexp.MustCompile(`[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`)
+
+func substituteUIDs(text string) string {
+	return uidRegexp.ReplaceAllLiteralString(text, "<UID>")
+}
+
+var resourceVersionRegexp = regexp.MustCompile(`resourceVersion: "[0-9]+"`)
+
+func substituteResourceVersion(text string) string {
+	return resourceVersionRegexp.ReplaceAllLiteralString(text, "resourceVersion: \"<RV>\"")
+}
+
 var statuses = []status.Status{
 	status.InProgressStatus,
 	status.CurrentStatus,