Repository: GoogleContainerTools/kpt
Pull request #2049 (2021-05-19 17:36:28)

Remove preview and diff
This removes the `kpt pkg diff` and `kpt pkg preview` commands. The `preview` functionality is available in the `kpt live apply` and `kpt live destroy` commands using the `--dry-run` flag.

Unlike kubectl, the `--dry-run` flag here is just a boolean. We choose between server-side and client-side dry-run based on the `--server-side` flag. 

frankfarzan: How about the doc changes to the book and the references?
mortent: > How about the doc changes to the book and the references?

Planning to do that in a follow-up PR. I forgot to mention that in the PR description.

Patch:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index c980eaf2..eb40cccc 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -26,6 +26,13 @@ information on using pull requests.
 This project follows [Google's Open Source Community Guidelines] and a [Code of
 Conduct].
 
+## Style Guides
+
+Contributions are required to follow these style guides:
+
+- [Error Message Style Guide]
+- [Documentation Style Guide]
+
 ## Building the Source
 
 1. Clone the project
@@ -47,15 +54,21 @@ Conduct].
    make all
    ```
 
-## Contributing to docs
+## Contribution to the Functions Catalog
+
+Refer to the
+[documentation in the kpt-functions-catalog](https://github.com/GoogleContainerTools/kpt-functions-catalog/blob/master/CONTRIBUTING.md)
+repo.
+
+## Contributing to Documentation
 
-If you are updating the documentation, please do it in separate PRs from
-code changes and the commit message should start with `Docs:`.
+If you are updating the documentation, please do it in separate PRs from code
+changes and PR description should start with `Docs:`.
 
 ### Run the docs locally
 
-Make docs changes and test them by running the site in a docker container
-with `make site-run-server`.
+Make docs changes and test them by running the site in a docker container with
+`make site-run-server`.
 
 It's usually a good idea to test locally for the following:
 
@@ -67,25 +80,6 @@ It's usually a good idea to test locally for the following:
 Docs are under [site/] and use [docsify] to present the source markdown files.
 The sidebar is automatically updated for the site at deployment time.
 
-### Adding or updating catalog functions
-
-<!-- TODO: Update this once the catalog is live. -->
-The config functions catalog is auto-generated using [this node package]. Update
-the catalog.json file with the appropriate information for your function like
-its type (source, sink, validator, transformer, or generator). Regenerate the
-catalog page with:
-
-`make gencatalog`
-
-### Adding or updating asciinema
-
-- asciinema casts are under `site/static/casts`
-- add or modify a `*.sh*` script which will run the commands that will be
-  recorded
-- run `make-gif.sh` with the script name (without extension) as the argument
-- add the updated cast to git
-- Reference the cast using the `asciinema` shortcode
-
 ### Docs Hygiene
 
 The kpt website uses markdownlint to lint docs for formatting and style. Use
@@ -99,6 +93,8 @@ This includes:
 - Run the kpt website through the [W3 Link Checker] in recursive mode and fix
   warnings and errors.
 
+[error message style guide]: docs/style-guides/errors.md
+[documentation style guide]: docs/style-guides/docs.md
 [github help]: https://help.github.com/articles/about-pull-requests/
 [google's open source community guidelines]:
   https://opensource.google.com/conduct/
diff --git a/Makefile b/Makefile
index 5ea83c49..5e11bd51 100644
--- a/Makefile
+++ b/Makefile
@@ -65,16 +65,16 @@ test:
 # This target is used to run Go tests that require docker runtime.
 # Some tests, like pipeline tests, need to have docker available to run.
 test-docker: build
-	KPT_E2E_BIN=$(GOBIN)/kpt go test -cover --tags=docker ./...
+	PATH=$(GOBIN):$(PATH) go test -cover --tags=docker ./...
 
 # KPT_E2E_UPDATE_EXPECTED=true (if expected output to be updated)
 # target to run e2e tests for "kpt fn render" command
 test-fn-render: build
-	KPT_E2E_BIN=$(GOBIN)/kpt go test -v --tags=docker --run=TestFnRender ./e2e/
+	PATH=$(GOBIN):$(PATH) go test -v --tags=docker --run=TestFnRender ./e2e/
 
 # target to run e2e tests for "kpt fn eval" command
 test-fn-eval: build
-	KPT_E2E_BIN=$(GOBIN)/kpt go test -v --tags=docker --run=TestFnEval ./e2e/
+	PATH=$(GOBIN):$(PATH) go test -v --tags=docker --run=TestFnEval ./e2e/
 
 # target to flush kpt-fn cache
 flush-fn-cache:
diff --git a/commands/livecmd.go b/commands/livecmd.go
index 5123bd9c..407cb179 100644
--- a/commands/livecmd.go
+++ b/commands/livecmd.go
@@ -27,8 +27,6 @@ import (
 	"github.com/GoogleContainerTools/kpt/internal/docs/generated/livedocs"
 	"github.com/GoogleContainerTools/kpt/internal/util/cfgflags"
 	"github.com/GoogleContainerTools/kpt/pkg/live"
-	"github.com/GoogleContainerTools/kpt/thirdparty/cli-utils/diff"
-	"github.com/GoogleContainerTools/kpt/thirdparty/cli-utils/preview"
 	"github.com/GoogleContainerTools/kpt/thirdparty/cli-utils/status"
 	"github.com/spf13/cobra"
 	"k8s.io/cli-runtime/pkg/genericclioptions"
@@ -79,19 +77,7 @@ func GetLiveCommand(ctx context.Context, _, version string) *cobra.Command {
 	// Init command which updates a Kptfile for the ResourceGroup inventory object.
 	klog.V(2).Infoln("init command updates Kptfile for ResourceGroup inventory")
 	initCmd := cmdliveinit.NewCommand(ctx, f, ioStreams)
-
 	applyCmd := cmdapply.NewCommand(ctx, rgProvider, rgLoader, ioStreams)
-
-	previewCmd := preview.GetPreviewRunner(dp, dl, ioStreams).Command
-	previewCmd.Short = livedocs.PreviewShort
-	previewCmd.Long = livedocs.PreviewShort + "\n" + livedocs.PreviewLong
-	previewCmd.Example = livedocs.PreviewExamples
-
-	diffCmd := diff.NewCmdDiff(f, ioStreams)
-	diffCmd.Short = livedocs.DiffShort
-	diffCmd.Long = livedocs.DiffShort + "\n" + livedocs.DiffLong
-	diffCmd.Example = livedocs.DiffExamples
-
 	destroyCmd := cmddestroy.NewCommand(ctx, rgProvider, rgLoader, ioStreams)
 
 	statusCmd := status.GetStatusRunner(dp, dl).Command
@@ -99,8 +85,7 @@ func GetLiveCommand(ctx context.Context, _, version string) *cobra.Command {
 	statusCmd.Long = livedocs.StatusLong
 	statusCmd.Example = livedocs.StatusExamples
 
-	liveCmd.AddCommand(initCmd, applyCmd, previewCmd, diffCmd, destroyCmd,
-		statusCmd)
+	liveCmd.AddCommand(initCmd, applyCmd, destroyCmd, statusCmd)
 
 	// Add the migrate command to change from ConfigMap to ResourceGroup inventory
 	// object. Also add the install-resource-group command.
diff --git a/docs/style-guides/docs.md b/docs/style-guides/docs.md
new file mode 100644
index 00000000..cb69bd26
--- /dev/null
+++ b/docs/style-guides/docs.md
@@ -0,0 +1,42 @@
+# kpt Docs Style Guide
+
+1. CLI commands name should appear with inline quotes everywhere including in
+   page titles:
+   - Correct: `diff`
+   - Incorrect: `Diff`
+   - Incorrect: `The Diff tool`
+2. Shell commands should be in `shell` code blocks and start with `$` to
+   distinguish the command from its output:
+
+   ```shell
+   $ kpt <command>
+   <output>
+   ```
+
+3. Links to other kpt doc pages should be absolute:
+   - Correct: `[pkg]: /reference/pkg/get/`
+   - Incorrect: `[pkg]: ../../../reference/pkg/get`
+4. Flags must appear after positional args:
+
+   - Correct:
+
+   ```shell
+   $ kpt fn eval my-package --image gcr.io/kpt-fn/search-replace
+   ```
+
+   - Incorrect:
+
+   ```shell
+   $ kpt fn eval --image gcr.io/kpt-fn/search-replace my-package
+   ```
+
+5. The name of the tool should always appear as small caps (even at start of
+   sentences) and not in block quotes:
+   - Correct: kpt
+   - Incorrect: `kpt`
+   - Incorrect: Kpt
+   - Incorrect: KPT
+6. References to a particular KRM group, version, kind, field should appear with
+   inline quotes:
+   - Correct: `ConfigMap`
+   - Incorrect: ConfigMap
diff --git a/docs/style-guides/errors.md b/docs/style-guides/errors.md
new file mode 100644
index 00000000..8696156f
--- /dev/null
+++ b/docs/style-guides/errors.md
@@ -0,0 +1,38 @@
+# kpt Error Messages Style Guide
+
+Validation error messages and documentation are an important part of the UX in
+kpt. In fact, validation error messages are most likely the first thing users
+experience. The general philosophy here is to have **precise**, **actionable**,
+and **consistent** error messages.
+
+## Errors vs. Documentation
+
+In most cases, the error message should provide enough information to resolve
+the issue. Prefer short descriptions that explain what caused the specific error
+and what to do to fix it. More extensive explanations and general rules (if they
+take more than a sentence to describe) are for documentation (which is
+automatically linked in error messages).
+
+Generally, documentation should expand on what the error message says and help
+the reader understand how to prevent future mistakes.
+
+## Error Message Rules
+
+In the table below, origin and scope of rules are denoted with a prefix
+
+- **K** is inherited from
+  [Kubernetes conventions](https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#validation);
+  rules with prefix
+- **R** are specific to Kpt.
+
+<!-- prettier-ignore -->
+|  Rule  | Description  | Examples  |
+| --- | --- | --- |
+| K1  | Where possible, tell users what they CAN instead of what they CANNOT do.  |   |
+| K2  | When asserting a requirement in the positive, use "must". Avoid words like "should" as they imply that the assertion is optional.  | a) "must be greater than 0"  b) "must match regex '[a-z]+'"  |
+| K3  | When asserting a formatting requirement in the negative, use "must not". Avoid words like "should not" as they imply that the assertion is optional.  | "must not contain '..'"  |
+| K5  | When referencing a user-provided string value, indicate the literal in quotes.  N Use quotes (%q format specifier in Go) around user-provided values. This includes file paths.  | "must not contain '..'"  |
+| K6  | When referencing a field name, indicate the name in back-quotes.  N Where unclear from the message, reference the full field path.  | "must be greater than `spec.request.size`"  |
+| K7  | When specifying inequalities, use words rather than symbols. Do not use words like "larger than", "bigger than", "more than", "higher than", etc.  | a) "must be less than 256"  b) "must be greater than or equal to 0".   |
+| K8  | When specifying numeric ranges, use inclusive ranges when possible.  |   |
+| R1  | If wrapping a runtime error, such as the result of a failed API Server call, use %w formatting verb in fmt.Errorf()(err) to include the root cause error. Refer to [Go error enhancements in 1.13](https://blog.golang.org/go1.13-errors).  |   |
diff --git a/internal/cmdapply/cmdapply.go b/internal/cmdapply/cmdapply.go
index 45c8f81e..b7c2e246 100644
--- a/internal/cmdapply/cmdapply.go
+++ b/internal/cmdapply/cmdapply.go
@@ -71,6 +71,8 @@ func NewRunner(ctx context.Context, provider provider.Provider, loader manifestr
 			fmt.Sprintf("%q and %q.", flagutils.InventoryPolicyStrict, flagutils.InventoryPolicyAdopt))
 	c.Flags().BoolVar(&r.installCRD, "install-resource-group", false,
 		"If true, install the inventory ResourceGroup CRD before applying.")
+	c.Flags().BoolVar(&r.dryRun, "dry-run", false,
+		"dry-run apply for the resources in the package.")
 	return r
 }
 
@@ -97,6 +99,7 @@ type Runner struct {
 	prunePropagationPolicyString string
 	pruneTimeout                 time.Duration
 	inventoryPolicyString        string
+	dryRun                       bool
 
 	inventoryPolicy inventory.InventoryPolicy
 	prunePropPolicy v1.DeletionPropagation
@@ -159,9 +162,18 @@ func (r *Runner) runE(c *cobra.Command, args []string) error {
 		return err
 	}
 
+	dryRunStrategy := common.DryRunNone
+	if r.dryRun {
+		if r.serverSideOptions.ServerSideApply {
+			dryRunStrategy = common.DryRunServer
+		} else {
+			dryRunStrategy = common.DryRunClient
+		}
+	}
+
 	// TODO(mortent): Figure out if we can do this differently.
 	if r.PreProcess != nil {
-		r.inventoryPolicy, err = r.PreProcess(inv, common.DryRunNone)
+		r.inventoryPolicy, err = r.PreProcess(inv, dryRunStrategy)
 		if err != nil {
 			return err
 		}
@@ -179,7 +191,7 @@ func (r *Runner) runE(c *cobra.Command, args []string) error {
 		// If we are not waiting for status, tell the applier to not
 		// emit the events.
 		EmitStatusEvents:       r.reconcileTimeout != time.Duration(0) || r.pruneTimeout != time.Duration(0),
-		DryRunStrategy:         common.DryRunNone,
+		DryRunStrategy:         dryRunStrategy,
 		PrunePropagationPolicy: r.prunePropPolicy,
 		PruneTimeout:           r.pruneTimeout,
 		InventoryPolicy:        r.inventoryPolicy,
@@ -188,5 +200,5 @@ func (r *Runner) runE(c *cobra.Command, args []string) error {
 	// The printer will print updates from the channel. It will block
 	// until the channel is closed.
 	printer := printers.GetPrinter(r.output, r.ioStreams)
-	return printer.Print(ch, common.DryRunNone)
+	return printer.Print(ch, dryRunStrategy)
 }
diff --git a/internal/cmddestroy/cmddestroy.go b/internal/cmddestroy/cmddestroy.go
index 579b6c5a..41481e31 100644
--- a/internal/cmddestroy/cmddestroy.go
+++ b/internal/cmddestroy/cmddestroy.go
@@ -58,6 +58,8 @@ func NewRunner(ctx context.Context, provider provider.Provider, loader manifestr
 	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.dryRun, "dry-run", false,
+		"dry-run apply for the resources in the package.")
 	return r
 }
 
@@ -80,12 +82,13 @@ type Runner struct {
 
 	output                string
 	inventoryPolicyString string
+	dryRun                bool
 
 	inventoryPolicy inventory.InventoryPolicy
 
 	// TODO(mortent): This is needed for now since we don't have a good way to
 	// stub out the Destroyer with an interface for testing purposes.
-	destroyRunner func(r *Runner, inv inventory.InventoryInfo) error
+	destroyRunner func(r *Runner, inv inventory.InventoryInfo, strategy common.DryRunStrategy) error
 }
 
 // preRunE validates the inventoryPolicy and the output type.
@@ -130,18 +133,25 @@ func (r *Runner) runE(c *cobra.Command, args []string) error {
 		return err
 	}
 
+	dryRunStrategy := common.DryRunNone
+	if r.dryRun {
+		dryRunStrategy = common.DryRunClient
+	}
+	// TODO: This is terrible. Fix it.
+	r.Destroyer.DryRunStrategy = dryRunStrategy
+
 	// TODO(mortent): Figure out if we can do this differently.
 	if r.PreProcess != nil {
-		r.inventoryPolicy, err = r.PreProcess(inv, r.Destroyer.DryRunStrategy)
+		r.inventoryPolicy, err = r.PreProcess(inv, dryRunStrategy)
 		if err != nil {
 			return err
 		}
 	}
 
-	return r.destroyRunner(r, inv)
+	return r.destroyRunner(r, inv, dryRunStrategy)
 }
 
-func runDestroy(r *Runner, inv inventory.InventoryInfo) error {
+func runDestroy(r *Runner, inv inventory.InventoryInfo, dryRunStrategy common.DryRunStrategy) error {
 	// Run the destroyer. It will return a channel where we can receive updates
 	// to keep track of progress and any issues.
 	err := r.Destroyer.Initialize()
@@ -156,5 +166,5 @@ func runDestroy(r *Runner, inv inventory.InventoryInfo) error {
 	// The printer will print updates from the channel. It will block
 	// until the channel is closed.
 	printer := printers.GetPrinter(r.output, r.ioStreams)
-	return printer.Print(ch, r.Destroyer.DryRunStrategy)
+	return printer.Print(ch, dryRunStrategy)
 }
diff --git a/internal/cmdrender/executor.go b/internal/cmdrender/executor.go
index 474dd644..25c2240d 100644
--- a/internal/cmdrender/executor.go
+++ b/internal/cmdrender/executor.go
@@ -445,9 +445,6 @@ func fnChain(ctx context.Context, hctx *hydrationContext, pkgPath types.UniquePa
 
 // trackInputFiles records file paths of input resources in the hydration context.
 func trackInputFiles(hctx *hydrationContext, input []*yaml.RNode) error {
-	if err := detectPathConflicts(input); err != nil {
-		return err
-	}
 	if hctx.inputFiles == nil {
 		hctx.inputFiles = sets.String{}
 	}
@@ -464,9 +461,6 @@ func trackInputFiles(hctx *hydrationContext, input []*yaml.RNode) error {
 // trackOutputfiles records the file paths of output resources in the hydration
 // context. It should be invoked post hydration.
 func trackOutputFiles(hctx *hydrationContext) error {
-	if err := detectPathConflicts(hctx.root.resources); err != nil {
-		return err
-	}
 	outputSet := sets.String{}
 
 	for _, r := range hctx.root.resources {
@@ -480,28 +474,6 @@ func trackOutputFiles(hctx *hydrationContext) error {
 	return nil
 }
 
-// detectPathConflicts returns an error if the same index/path is on multiple resources
-func detectPathConflicts(nodes []*yaml.RNode) error {
-	// map has structure path -> index -> bool
-	// to keep track of paths and indexes found
-	pathIndexes := make(map[string]map[string]bool)
-	for _, node := range nodes {
-		fp, index, err := kioutil.GetFileAnnotations(node)
-		if err != nil {
-			return err
-		}
-		fp = path.Clean(fp)
-		if pathIndexes[fp] == nil {
-			pathIndexes[fp] = make(map[string]bool)
-		}
-		if _, ok := pathIndexes[fp][index]; ok {
-			return fmt.Errorf("resource at path %q and index %q already exists", fp, index)
-		}
-		pathIndexes[fp][index] = true
-	}
-	return nil
-}
-
 // pruneResources compares the input and output of the hydration and prunes
 // resources that are no longer present in the output of the hydration.
 func pruneResources(hctx *hydrationContext) error {
diff --git a/internal/fnruntime/runner.go b/internal/fnruntime/runner.go
index ff14e1d2..86623d78 100644
--- a/internal/fnruntime/runner.go
+++ b/internal/fnruntime/runner.go
@@ -20,6 +20,7 @@ import (
 	"fmt"
 	"io/ioutil"
 	"os"
+	"path"
 	"path/filepath"
 	"strings"
 
@@ -31,6 +32,7 @@ import (
 	"sigs.k8s.io/kustomize/kyaml/fn/framework"
 	"sigs.k8s.io/kustomize/kyaml/fn/runtime/runtimeutil"
 	"sigs.k8s.io/kustomize/kyaml/kio"
+	"sigs.k8s.io/kustomize/kyaml/kio/kioutil"
 	"sigs.k8s.io/kustomize/kyaml/yaml"
 )
 
@@ -120,6 +122,9 @@ func (fr *FunctionRunner) do(input []*yaml.RNode) (output []*yaml.RNode, err err
 	fnResult := fr.fnResult
 
 	output, err = fr.filter.Filter(input)
+	if pathErr := enforcePathInvariants(output); pathErr != nil {
+		return output, pathErr
+	}
 
 	// parse the results irrespective of the success/failure of fn exec
 	resultErr := parseStructuredResult(fr.filter.Results, fnResult)
@@ -208,6 +213,33 @@ func printFnExecErr(ctx context.Context, fnErr *ExecError) {
 	pr.OptPrintf(printOpt.Stderr(), "  Exit code: %d\n\n", fnErr.ExitCode)
 }
 
+// path (location) of a KRM resources is tracked in a special key in
+// metadata.annotation field. enforcePathInvariants throws an error if there is a path
+// to a file outside the package, or if the same index/path is on multiple resources
+func enforcePathInvariants(nodes []*yaml.RNode) error {
+	// map has structure path -> index -> bool
+	// to keep track of paths and indexes found
+	pathIndexes := make(map[string]map[string]bool)
+	for _, node := range nodes {
+		currPath, index, err := kioutil.GetFileAnnotations(node)
+		if err != nil {
+			return err
+		}
+		fp := path.Clean(currPath)
+		if strings.HasPrefix(fp, "../") {
+			return fmt.Errorf("function must not modify resources outside of package: resource has path %s", currPath)
+		}
+		if pathIndexes[fp] == nil {
+			pathIndexes[fp] = make(map[string]bool)
+		}
+		if _, ok := pathIndexes[fp][index]; ok {
+			return fmt.Errorf("resource at path %q and index %q already exists", fp, index)
+		}
+		pathIndexes[fp][index] = true
+	}
+	return nil
+}
+
 // multiLineFormatter knows how to format multiple lines in pretty format
 // that can be displayed to an end user.
 type multiLineFormatter struct {
diff --git a/internal/util/pkgutil/pkgutil.go b/internal/util/pkgutil/pkgutil.go
index 09a1d34c..ed4f4e4a 100644
--- a/internal/util/pkgutil/pkgutil.go
+++ b/internal/util/pkgutil/pkgutil.go
@@ -24,6 +24,7 @@ import (
 
 	"github.com/GoogleContainerTools/kpt/internal/pkg"
 	kptfilev1alpha2 "github.com/GoogleContainerTools/kpt/pkg/api/kptfile/v1alpha2"
+	"github.com/GoogleContainerTools/kpt/pkg/kptfile/kptfileutil"
 	"sigs.k8s.io/kustomize/kyaml/copyutil"
 	"sigs.k8s.io/kustomize/kyaml/kio"
 	"sigs.k8s.io/kustomize/kyaml/kio/filters"
@@ -293,6 +294,43 @@ func FormatPackage(pkgPath string) {
 		// do not throw error if formatting fails
 		return
 	}
+	err = RoundTripKptfilesInPkg(pkgPath)
+	if err != nil {
+		// do not throw error if formatting fails
+		return
+	}
+}
+
+// RoundTripKptfilesInPkg reads and writes all Kptfiles in the package including
+// subpackages. This is used to format Kptfiles in the order of go structures
+// TODO: phanimarupaka remove this method after addressing https://github.com/GoogleContainerTools/kpt/issues/2052
+func RoundTripKptfilesInPkg(pkgPath string) error {
+	paths, err := pkg.Subpackages(pkgPath, pkg.All, true)
+	if err != nil {
+		return err
+	}
+
+	var pkgsPaths []string
+	for _, path := range paths {
+		// join pkgPath as the paths are relative to pkgPath
+		pkgsPaths = append(pkgsPaths, filepath.Join(pkgPath, path))
+	}
+	// include root package as well
+	pkgsPaths = append(pkgsPaths, pkgPath)
+
+	for _, pkgPath := range pkgsPaths {
+		kf, err := kptfileutil.ReadFile(pkgPath)
+		if err != nil {
+			// do not throw error if formatting fails
+			return err
+		}
+		err = kptfileutil.WriteFile(pkgPath, kf)
+		if err != nil {
+			// do not throw error if formatting fails
+			return err
+		}
+	}
+	return nil
 }
 
 // Exists returns true if a file or directory exists on the provided path,
diff --git a/scripts/generate_site_sidebar/sidebar_template.md b/scripts/generate_site_sidebar/sidebar_template.md
index 1999e43a..e7bd67bc 100644
--- a/scripts/generate_site_sidebar/sidebar_template.md
+++ b/scripts/generate_site_sidebar/sidebar_template.md
@@ -16,10 +16,8 @@
     - [live](reference/live/)
         - [apply](reference/live/apply/)
         - [destroy](reference/live/destroy/)
-        - [diff](reference/live/diff/)
         - [init](reference/live/init/)
         - [migrate](reference/live/migrate/)
-        - [preview](reference/live/preview/)
         - [status](reference/live/status/)
 - [Functions Catalog](https://catalog.kpt.dev/ ':target=_self')
 - [FAQ](faq/)
diff --git a/site/book/01-getting-started/02-quickstart.md b/site/book/01-getting-started/02-quickstart.md
index 5b400ed9..453c3281 100644
--- a/site/book/01-getting-started/02-quickstart.md
+++ b/site/book/01-getting-started/02-quickstart.md
@@ -105,10 +105,10 @@ This adds some metadata to the `Kptfile` required to keep track of changes made
 cluster. For example, if a resource is deleted from the package in the future, it will be pruned
 from the cluster.
 
-You can preview the changes that will be made to the cluster:
+You can validate the resources and verify that the expected changes will be made to the cluster:
 
 ```shell
-$ kpt live preview
+$ kpt live apply --dry-run
 ```
 
 Apply the resources to the cluster:
diff --git a/site/book/05-developing-functions/00.md b/site/book/05-developing-functions/00.md
index 1333ed77..87b840b0 100644
--- a/site/book/05-developing-functions/00.md
+++ b/site/book/05-developing-functions/00.md
@@ -1,1 +1,12 @@
-TODO
+[Chapter 2][ch2] provided a high-level conceptual explanation of functions. We
+discussed how this architecture enables us to develop functions in different
+toolchains, languages and runtimes. In this chapter, we are going to look at
+several different approaches to developing functions.
+
+We are also going to take a closer look at the standardized API between the
+orchestrator (i.e. kpt CLI) and the function containers. Understanding this API
+enables you to have a deeper understanding of how things work under the hood. It
+also enables you to create your own toolchain for function development if you so
+desire.
+
+[ch2]: /book/02-concepts/03-functions
diff --git a/site/book/05-developing-functions/03-developing-in-Go.md b/site/book/05-developing-functions/03-developing-in-Go.md
index 1333ed77..3429ac21 100644
--- a/site/book/05-developing-functions/03-developing-in-Go.md
+++ b/site/book/05-developing-functions/03-developing-in-Go.md
@@ -1,1 +1,123 @@
-TODO
+You can use the `kyaml` library to develop functions in Go. Doing so provides the
+following features:
+
+- **General-purpose language:** Compared to Domain Specific Languages (DSL), Go
+  is a general-purpose programming language that provides:
+  - Proper abstractions and language features
+  - An extensive ecosystem of tooling (e.g. IDE support)
+  - A comprehensive catalog of well-supported libraries
+  - Robust community support and detailed documentation
+- **YAML-centric**: As opposed to other frameworks discussed in this chapter,
+  the `kyaml` library exposes the YAML Abstract Syntax Tree (AST) to the user.
+  This enables you to control every aspect of the YAML file including
+  manipulating comments; however, it comes at the cost of complexity compared to
+  representing resources as idiomatic data structures.
+
+## Quickstart
+
+### Create the go module
+
+```shell
+$ go mod init github.com/user/repo
+$ go get sigs.k8s.io/kustomize/kyaml@v0.10.6
+```
+
+### Create the `main.go`
+
+This is a simple function that adds the annotation `myannotation` with the
+provided value:
+
+```go
+// main.go
+package main
+
+import (
+	"os"
+
+	"sigs.k8s.io/kustomize/kyaml/fn/framework"
+	"sigs.k8s.io/kustomize/kyaml/yaml"
+)
+
+var value string
+
+func main() {
+	resourceList := &framework.ResourceList{}
+	cmd := framework.Command(resourceList, func() error {
+		// cmd.Execute() will parse the ResourceList.functionConfig into
+		// cmd.Flags from the ResourceList.functionConfig.data field.
+		for i := range resourceList.Items {
+			// modify the resources using the kyaml/yaml library:
+			if err := resourceList.Items[i].PipeE(yaml.SetAnnotation("myannotation", value)); err != nil {
+				return err
+			}
+		}
+		return nil
+	})
+	cmd.Flags().StringVar(&value, "value", "", "annotation value")
+	if err := cmd.Execute(); err != nil {
+		os.Exit(1)
+	}
+}
+```
+
+### Build and test the function
+
+Build the go binary:
+
+```shell
+$ go build -o my-fn .
+```
+
+Test it by running imperatively:
+
+```shell
+$ kpt pkg get https://github.com/GoogleContainerTools/kpt.git/package-examples/wordpress@v0.3
+# run the my-fn function against the configuration in wordpress
+$ kpt fn eval wordpress --exec-path ./my-fn -- value=foo
+```
+
+During iterative development, `--exec-path` flag can be used to execute the
+function binary directly instead of requiring the function to be containerized
+first. Once you have a function binary that works, you can then proceed to
+creating the container image.
+
+### Publish the function
+
+Build the function into a container image:
+
+```shell
+# optional: generate a Dockerfile to contain the function
+$ go run ./main.go gen ./
+```
+
+```shell
+# build the function into an image
+$ docker build . -t gcr.io/project/fn-name:tag
+# optional: push the image to a container registry
+$ docker push gcr.io/project/fn-name:tag
+```
+
+Run the function imperatively as a container function:
+
+```shell
+$ kpt fn eval wordpress --image gcr.io/project/fn-name:tag -- value=foo
+```
+
+## Next Steps
+
+- Read the package documentation:
+
+| Package                                    | Purpose               |
+| ------------------------------------------ | --------------------- |
+| [sigs.k8s.io/kustomize/kyaml/fn/framework] | Functions Framework   |
+| [sigs.k8s.io/kustomize/kyaml/yaml]         | Modify YAML resources |
+
+- Take a look at the source code for [functions in the catalog] to better
+  understand how to develop functions in Go
+
+[sigs.k8s.io/kustomize/kyaml/fn/framework]:
+  https://pkg.go.dev/sigs.k8s.io/kustomize/kyaml@v0.10.16/fn/framework#pkg-index
+[sigs.k8s.io/kustomize/kyaml/yaml]:
+  https://pkg.go.dev/sigs.k8s.io/kustomize/kyaml@v0.10.16/yaml
+[functions in the catalog]:
+  https://github.com/GoogleContainerTools/kpt-functions-catalog/tree/master/functions/go
diff --git a/site/book/05-developing-functions/04-developing-in-Typescript.md b/site/book/05-developing-functions/04-developing-in-Typescript.md
index 1333ed77..5c62c270 100644
--- a/site/book/05-developing-functions/04-developing-in-Typescript.md
+++ b/site/book/05-developing-functions/04-developing-in-Typescript.md
@@ -1,1 +1,241 @@
-TODO
+kpt project provides an opinionated Typescript SDK for implementing functions.
+It provides the following features:
+
+- **General-purpose language:** Similar to Go, Typescript is a general-purpose
+  programming language that provides:
+  - Proper abstractions and language features
+  - An extensive ecosystem of tooling (e.g. IDE support)
+  - A comprehensive catalog of well-supported libraries
+  - Robust community support and detailed documentation
+- **Idiomatic:** The TS SDK provides a different level of abstraction compared to
+  the Go library we saw previously. Instead of exposing the low-level YAML AST,
+  resources are marshalled into native Typescript objects. As a result, you get
+  a more idiomatic and high-level abstraction. Ideally, you should work with
+  native data structure/object in each language and not think about YAML which
+  is just a file format. Even though resources in configuration files are
+  round-tripped to Typescript objects, the kpt orchestrator ensures that
+  YAML-specific constructs such as comments are preserved. The obvious
+  limitation of this high-level abstraction is that you cannot develop functions
+  for manipulating YAML-specific constructs like comments.
+- **Type-safety:** Kubernetes configuration is typed, and its schema is defined
+  using the OpenAPI spec. Typescript has a sophisticated type system that
+  accommodates the complexity of Kubernetes resources. The TS SDK enables
+  generating Typescript classes for core and CRD types.
+- **Batteries-included:** The TS SDK provides a simple, powerful API for
+  querying and manipulating resources inspired by [document-oriented databases].
+  It provides the scaffolding required to develop, build, test, and publish
+  functions, allowing you to focus on implementing your business-logic.
+
+## Quickstart
+
+This quickstart will get you started developing a kpt function with the TS SDK.
+
+### System Requirements
+
+Currently supported platforms: amd64 Linux/Mac
+
+- Install [kpt][download-kpt] and its dependencies
+- Install [node][download-node]
+  - The SDK requires `npm` version 6 or higher.
+  - If installing node from binaries (i.e. without a package manager), follow
+    these [installation instructions][install-node].
+
+### Explore the Demo Functions Package
+
+1. Get the `demo-functions` package:
+
+   ```shell
+   $ git clone --depth 1 https://github.com/GoogleContainerTools/kpt-functions-sdk.git
+   ```
+
+   All subsequent commands are run from the `demo-functions` directory:
+
+   ```shell
+   $ cd kpt-functions-sdk/ts/demo-functions
+   ```
+
+1. Install all dependencies:
+
+   ```shell
+   $ npm install
+   ```
+
+1. Run the following in a separate terminal to continuously build your function
+   as you make changes:
+
+   ```shell
+   $ npm run watch
+   ```
+
+1. Explore the [`label_namespace`][label-namespace] transformer function. This
+   function takes a given `label_name` and `label_value` to add the appropriate
+   label to `Namespace` objects using the SDK's `addLabel` function.
+
+   ```typescript
+   import { addLabel, Configs } from "kpt-functions";
+   import { isNamespace } from "./gen/io.k8s.api.core.v1";
+
+   export const LABEL_NAME = "label_name";
+   export const LABEL_VALUE = "label_value";
+
+   export async function labelNamespace(configs: Configs) {
+     const labelName = configs.getFunctionConfigValueOrThrow(LABEL_NAME);
+     const labelValue = configs.getFunctionConfigValueOrThrow(LABEL_VALUE);
+     configs
+       .get(isNamespace)
+       .forEach((n) => addLabel(n, labelName, labelValue));
+   }
+   ```
+
+1. Run the `label_namespace` function on the `example-configs` directory:
+
+   ```shell
+   $ export CONFIGS=../../example-configs
+
+   $ kpt fn source $CONFIGS \
+     | node dist/label_namespace_run.js -d label_name=color -d label_value=orange \
+     | kpt fn sink $CONFIGS
+   ```
+
+   As the name suggests, this function added the given label to all `Namespace`
+   objects in the `example-configs` directory:
+
+   ```shell
+   $ git diff $CONFIGS
+   ```
+
+1. Try modifying the function in `src/label_namespace.ts` to perform other
+   operations and rerun the function on `example-configs` to see the changes.
+
+1. Explore validation functions like [validate-rolebinding]. Instead of
+   transforming configuration, this function searches RoleBindings and returns a
+   results field containing details about invalid subject names.
+
+   ```typescript
+   import { Configs, kubernetesObjectResult } from "kpt-functions";
+   import { isRoleBinding } from "./gen/io.k8s.api.rbac.v1";
+
+   const SUBJECT_NAME = "subject_name";
+
+   export async function validateRolebinding(configs: Configs) {
+     // Get the subject name parameter.
+     const subjectName = configs.getFunctionConfigValueOrThrow(SUBJECT_NAME);
+
+     // Iterate over all RoleBinding objects in the input
+     // Filter those that have a subject we're looking for.
+     const results = configs
+       .get(isRoleBinding)
+       .filter(
+         (rb) =>
+           rb && rb.subjects && rb.subjects.find((s) => s.name === subjectName)
+       )
+       .map((rb) =>
+         kubernetesObjectResult(`Found banned subject '${subjectName}'`, rb)
+       );
+
+     configs.addResults(...results);
+   }
+   ```
+
+1. Run `validate-rolebinding` on `example-configs`.
+
+   ```shell
+   $ kpt fn source $CONFIGS \
+     | node dist/validate_rolebinding_run.js -d subject_name=alice@foo-corp.com \
+     | kpt fn sink $CONFIGS
+   $ git diff $CONFIGS
+   ```
+
+1. Explore generator functions like [expand-team-cr]. This function generates a
+   per-environment Namespace and RoleBinding object for each custom resource
+   (CR) of type Team.
+
+   ```typescript
+   import { Configs } from "kpt-functions";
+   import { isTeam, Team } from "./gen/dev.cft.anthos.v1alpha1";
+   import { Namespace } from "./gen/io.k8s.api.core.v1";
+   import { RoleBinding, Subject } from "./gen/io.k8s.api.rbac.v1";
+
+   const ENVIRONMENTS = ["dev", "prod"];
+
+   export async function expandTeamCr(configs: Configs) {
+     // For each 'Team' custom resource in the input:
+     // 1. Generate a per-enviroment Namespace.
+     // 2. Generate RoleBindings in each Namespace.
+     configs.get(isTeam).forEach((team) => {
+       const name = team.metadata.name;
+
+       ENVIRONMENTS.forEach((suffix) => {
+         const ns = `${name}-${suffix}`;
+         configs.insert(Namespace.named(ns));
+         configs.insert(...createRoleBindings(team, ns));
+       });
+     });
+   }
+
+   function createRoleBindings(team: Team, namespace: string): RoleBinding[] {
+     return (team.spec.roles || []).map((item) => {
+       return new RoleBinding({
+         metadata: {
+           name: item.role,
+           namespace,
+         },
+         subjects: roleSubjects(item),
+         roleRef: {
+           kind: "ClusterRole",
+           name: item.role,
+           apiGroup: "rbac.authorization.k8s.io",
+         },
+       });
+     });
+   }
+
+   function roleSubjects(item: Team.Spec.Item): Subject[] {
+     const userSubjects: Subject[] = (item.users || []).map(
+       (user) =>
+         new Subject({
+           kind: "User",
+           name: user,
+         })
+     );
+     const groupSubjects: Subject[] = (item.groups || []).map(
+       (group) =>
+         new Subject({
+           kind: "Group",
+           name: group,
+         })
+     );
+     return userSubjects.concat(groupSubjects);
+   }
+   ```
+
+1. Run `expand-team-cr` on `example-configs`.
+
+   ```shell
+   $ kpt fn source $CONFIGS \
+    | node dist/expand_team_cr_run.js \
+    | kpt fn sink $CONFIGS
+   $ git diff $CONFIGS
+   ```
+
+## Next Steps
+
+- Read the complete [Typescript SDK Developer Guide].
+- Take a look at these [example functions] to better understand how to use the
+  TS SDK.
+
+[download-kpt]: /book/01-getting-started/01-system-requirements
+[download-node]: https://nodejs.org/en/download/
+[install-node]: https://github.com/nodejs/help/wiki/Installation/
+[ts sdk api]: https://googlecontainertools.github.io/kpt-functions-sdk/api/
+[label-namespace]:
+  https://github.com/GoogleContainerTools/kpt-functions-sdk/blob/master/ts/demo-functions/src/label_namespace.ts
+[validate-rolebinding]:
+  https://github.com/GoogleContainerTools/kpt-functions-sdk/blob/master/ts/demo-functions/src/validate_rolebinding.ts
+[expand-team-cr]:
+  https://github.com/GoogleContainerTools/kpt-functions-sdk/blob/master/ts/demo-functions/src/expand_team_cr.ts
+[example functions]:
+  https://github.com/GoogleContainerTools/kpt-functions-sdk/tree/master/ts/demo-functions/src/
+[document-oriented databases]:
+  https://en.wikipedia.org/wiki/Document-oriented_database
+[typescript sdk developer guide]: /sdk/ts-guide
diff --git a/site/installation/README.md b/site/installation/README.md
index 58762bdf..4c248ed4 100644
--- a/site/installation/README.md
+++ b/site/installation/README.md
@@ -9,7 +9,6 @@ Download pre-compiled binaries.
 | ------------------------
 | [Linux (x64)][linux]
 | [macOS (x64)][darwin]
-| Windows (x64) (coming later)
 
 ```shell
 # For linux/mac
diff --git a/site/reference/live/apply/README.md b/site/reference/live/apply/README.md
index 940518a9..5be64d45 100644
--- a/site/reference/live/apply/README.md
+++ b/site/reference/live/apply/README.md
@@ -32,6 +32,14 @@ PKG_PATH | -:
 
 #### Flags
 ```
+--dry-run:
+  It true, kpt will validate the resources in the package and print which
+  resources will be applied and which resources will be pruned, but no resources
+  will be changed.
+  If the --server-side flag is true, kpt will do a server-side dry-run, otherwise
+  it will be a client-side dry-run. Note that the output will differ somewhat
+  between the two alternatives. 
+
 --field-manager:
   Identifier for the **owner** of the fields being applied. Only usable
   when --server-side flag is specified. Default value is kubectl.
diff --git a/site/reference/live/destroy/README.md b/site/reference/live/destroy/README.md
index 5713e7f7..67d1603a 100644
--- a/site/reference/live/destroy/README.md
+++ b/site/reference/live/destroy/README.md
@@ -29,6 +29,10 @@ PKG_PATH | -:
 
 #### Flags
 ```
+--dry-run:
+  It true, kpt will print the resources that will be removed from the cluster, 
+  but no resources will be deleted.
+
 --inventory-policy:
   Determines how to handle overlaps between the package being currently applied
   and existing resources in the cluster. The available options are:
diff --git a/site/reference/live/diff/README.md b/site/reference/live/diff/README.md
deleted file mode 100644
index aa2b614b..00000000
--- a/site/reference/live/diff/README.md
+++ /dev/null
@@ -1,59 +0,0 @@
----
-title: "`diff`"
-linkTitle: "diff"
-type: docs
-description: >
-   Display the diff between the local package and the live cluster resources.
----
-<!--mdtogo:Short
-    Display the diff between the local package and the live cluster resources.
--->
-
-`diff` compares the live cluster state of each package resource against the 
-local package config.
-
-### Synopsis
-<!--mdtogo:Long-->
-```
-kpt live diff [PKG_PATH | -]
-```
-
-#### Args
-```
-PKG_PATH | -:
-  Path to the local package which should be diffed against the cluster. It must
-  contain a Kptfile with inventory information. Defaults to the current working
-  directory.
-  Using '-' as the package path will cause kpt to read resources from stdin.
-```
-
-#### Environment Variables
-```
-KUBECTL_EXTERNAL_DIFF:
-  Commandline diffing tool ('diff; by default) that will be used to show
-  changes.
-  
-  # Use meld to show changes
-  KPT_EXTERNAL_DIFF=meld kpt live diff
-```
-
-#### Exit statuses
-```
-The following exit values are returned:
-
-  * 0: No differences were found.
-  * 1: Differences were found.
-  * >1 kpt live or diff failed with an error.
-```
-<!--mdtogo-->
-
-### Examples
-<!--mdtogo:Examples-->
-```shell
-# diff the config in the current directory against the live cluster resources.
-kpt live diff
-
-# specify the local diff program to use.
-export KUBECTL_EXTERNAL_DIFF=meld; kpt live diff my-dir
-```
-<!--mdtogo-->
diff --git a/site/reference/live/preview/README.md b/site/reference/live/preview/README.md
deleted file mode 100644
index 63290f99..00000000
--- a/site/reference/live/preview/README.md
+++ /dev/null
@@ -1,91 +0,0 @@
----
-title: "`preview`"
-linkTitle: "preview"
-type: docs
-description: >
-   Preview the changes apply would make to the cluster
----
-<!--mdtogo:Short
-    Preview the changes apply would make to the cluster
--->
-
-`preview` shows the operations that will be done when running `apply` or 
-`destroy` against a cluster.
-
-### Synopsis
-<!--mdtogo:Long-->
-```
-kpt live preview [PKG_PATH | -] [flags]
-```
-
-#### Args
-
-```
-PKG_PATH | -:
-  Path to the local package for which a preview of the operations of apply
-  or destroy should be displayed. It must contain a Kptfile with inventory
-  information. Defaults to the current working directory.
-  Using '-' as the package path will cause kpt to read resources from stdin.
-```
-
-#### Flags
-
-```
---destroy:
-  If true, preview deletion of all resources.
-
---field-manager:
-  Identifier for the **owner** of the fields being applied. Only usable
-  when --server-side flag is specified. Default value is kubectl.
-
---force-conflicts:
-  Force overwrite of field conflicts during apply due to different field
-  managers. Only usable when --server-side flag is specified.
-  Default value is false (error and failure when field managers conflict).
-
---install-resource-group:
-  Install the ResourceGroup CRD into the cluster if it isn't already
-  available. Default is false.
-
---inventory-policy:
-  Determines how to handle overlaps between the package being currently applied
-  and existing resources in the cluster. The available options are:
-  
-    * strict: If any of the resources already exist in the cluster, but doesn't
-      belong to the current package, it is considered an error.
-    * adopt: If a resource already exist in the cluster, but belongs to a 
-      different package, it is considered an error. Resources that doesn't belong
-      to other packages are adopted into the current package.
-      
-  The default value is `strict`.
-
---output:
-  Determines the output format for the status information. Must be one of the following:
-  
-    * events: The output will be a list of the status events as they become available.
-    * json: The output will be a list of the status events as they become available,
-      each formatted as a json object.
-    * table: The output will be presented as a table that will be updated inline
-      as the status of resources become available.
-
-  The default value is ‘events’.
-
---server-side:
-  Perform the apply operation server-side rather than client-side.
-  Default value is false (client-side).
-```
-<!--mdtogo-->
-
-### Examples
-
-<!--mdtogo:Examples-->
-```shell
-# preview apply for the package in the current directory. 
-kpt live preview
-```
-
-```shell
-# preview destroy for a package in the my-dir directory.
-kpt live preview --destroy my-dir
-```
-<!--mdtogo-->
diff --git a/site/sdk/ts-guide.md b/site/sdk/ts-guide.md
new file mode 100644
index 00000000..b2fe11a2
--- /dev/null
+++ b/site/sdk/ts-guide.md
@@ -0,0 +1,245 @@
+# Typescript SDK Developer Guide
+
+This guide will walk you through developing a kpt function using the Typescript SDK.
+
+## Setup
+
+### System Requirements
+
+Currently supported platforms: amd64 Linux/Mac
+
+### Setting Up Your Local Environment
+
+Currently supported platforms: amd64 Linux/Mac
+
+- Install [kpt][download-kpt] and its dependencies
+- Install [node][download-node]
+  - The SDK requires `npm` version 6 or higher.
+  - If installing node from binaries (i.e. without a package manager), follow
+    these [installation instructions][install-node].
+
+### Your Kubernetes Cluster
+
+For the type generation functionality to work, you need a Kubernetes cluster
+with the [CRD OpenAPI Publishing][crd-openapi] feature which is GA with
+Kubernetes 1.16.
+
+Alternatively, you can use an existing NPM package with pre-generated types
+such as the `hello-world` package discussed in the [Quickstart] and skip to
+[implementing the function](#implement-the-function).
+
+### Working with CRDs
+
+The SDK uses the k8s server to generate the Typescript classes. If your
+function uses a Custom Resource Definition, make sure you apply it to the
+cluster used for type generation:
+
+```shell
+$ kubectl apply -f /path/to/my/crd.yaml
+```
+
+## Create the NPM Package
+
+To initialize a new NPM package, first create a package directory:
+
+```shell
+$ mkdir my-package
+$ cd my-package
+```
+
+> **Note:** All subsequent commands are run from the `my-package/` directory.
+
+Run the interactive initializer:
+
+```shell
+$ npm init kpt-functions
+```
+
+Follow the instructions and respond to all prompts.
+
+This process will create the following:
+
+1. `package.json`: The `kpt-functions` framework library is the only item
+   declared in `dependencies`. Everything required to compile and test your
+   config function is declared as `devDependencies`, including the
+   `create-kpt-functions` CLI discussed later in the `README`.
+1. `src/`: Directory containing the source files for all your functions, e.g.:
+   - `my_func.ts`: Implement your function's interface here.
+   - `my_func_test.ts`: Unit tests for your function.
+   - `my_func_run.ts`: The entry point from which your function is run.
+1. `src/gen/`: Contains Kubernetes core and CRD types generated from the
+   OpenAPI spec published by the cluster you selected.
+1. `build/`: Contains Dockerfile for each function, e.g.:
+   - `my_func.Dockerfile`
+
+Next, install all package dependencies:
+
+```shell
+$ npm install
+```
+
+In addition to installation, `install` compiles your function into the `dist/`
+directory.
+
+You can run your function directly:
+
+```shell
+$ node dist/my_func_run.js --help
+```
+
+Currently, it simply passes through the input configuration data. Let's remedy
+this.
+
+## Implement the Function
+
+You can now start implementing the function using your favorite IDE, e.g.
+[VSCode]:
+
+```shell
+$ code .
+```
+
+In `src/my_func.ts`, implement the `KptFunc` interface from the [TS SDK API].
+
+Take a look at these [demo functions] to better understand how
+to use the typescript library.
+
+Once you've written some code, build the package with:
+
+```shell
+$ npm run build
+```
+
+Alternatively, run the following in a separate terminal. It will continuously
+build your function as you make changes:
+
+```shell
+$ npm run watch
+```
+
+To run the tests, use:
+
+```shell
+$ npm test
+```
+
+## Debug and Test the Function
+
+You may want to run a function developed with one of the config function SDKs
+using the exec runtime in order to avoid the overhead associated with running
+a container. To run your function in the exec runtime, you will first need to
+package your function as an executable.
+
+The below example shows how to run a typescript function using the kpt exec
+runtime.
+
+### Prerequisites
+
+- Install the pkg CLI.
+
+  ```shell
+  $ npm install -g pkg
+  ```
+
+- Install your kpt-functions package module to create your function's
+  distributable file.
+
+  ```shell
+  $ npm i
+  ```
+
+### Steps
+
+1. Use the pkg CLI to create an executable from your function's distributable
+   file. For a `my_fn` function built using the typescript SDK, this is
+   `dist/my_fn_run.js`.
+
+   ```shell
+   $ npx pkg dist/my_func_run.js
+   ```
+
+1. Pass the path to the appropriate executable for your OS when running kpt
+   using the exec runtime.
+
+   ```shell
+   $ kpt fn eval DIR --exec-path ./my_func_run-macos
+   ```
+
+## Build and push container images
+
+With your working function in-hand, it's time to package your function into an
+executable container image.
+
+To build the docker image:
+
+```shell
+$ npm run kpt:docker-build
+```
+
+You can now run the function container, e.g.:
+
+```shell
+$ kpt fn eval DIR --image gcr.io/kpt-functions-demo/my-func:dev
+```
+
+To push the image to your container registry of choice:
+
+```shell
+$ npm run kpt:docker-push
+```
+
+You'll need proper authentication/authorization to push to your registry.
+
+`kpt:docker-push` pushes to the registry specified in the
+`kpt.docker_repo_base` field in `package.json`. You can manually edit this
+field at any time.
+
+The default value for the container image tag is `dev`. This can be overridden
+using`--tag` flag:
+
+```shell
+$ npm run kpt:docker-build -- --tag=latest
+$ npm run kpt:docker-push -- --tag=latest
+```
+
+## Use the SDK CLI
+
+The `create-kpt-functions` package (installed as `devDependencies`), provides
+a CLI for managing the NPM package you created above. The CLI sub-commands can
+be invoked via `npm run`. For example, to add a new function to the package:
+
+```shell
+$ npm run kpt:function-create
+```
+
+These sub-commands are available:
+
+```
+kpt:docker-create       Generate Dockerfiles for all functions. Overwrite
+                        files if they exist.
+kpt:docker-build        Build container images for all functions.
+kpt:docker-push         Push container images to the registry for all
+                        functions.
+kpt:function-create     Generate stubs for a new function. Overwrites files
+                        if they exist.
+kpt:type-create         Generate classes for core and CRD types. Overwrite
+                        files if they exist.
+```
+
+Flags are passed to the CLI after the `--` separator. For example, to pass a
+tag when building a container image:
+
+```shell
+$ npm run kpt:docker-build -- --tag=latest
+```
+
+[download-kpt]: /book/01-getting-started/01-system-requirements
+[download-node]: https://nodejs.org/en/download/
+[install-node]: https://github.com/nodejs/help/wiki/Installation/
+[install-node]: https://github.com/nodejs/help/wiki/Installation/
+[install-docker]: https://docs.docker.com/engine/installation/
+[crd-openapi]: https://github.com/kubernetes/kubernetes/blob/master/CHANGELOG/CHANGELOG-1.15.md#customresourcedefinition-openapi-publishing
+[quickstart]: ../quickstart/
+[vscode]: https://code.visualstudio.com/
+[ts sdk api]: https://googlecontainertools.github.io/kpt-functions-sdk/api/
+[demo functions]: https://github.com/GoogleContainerTools/kpt-functions-sdk/tree/master/ts/demo-functions/src/
diff --git a/site/sidebar.md b/site/sidebar.md
index a9d95bf9..8093976d 100644
--- a/site/sidebar.md
+++ b/site/sidebar.md
@@ -45,10 +45,8 @@
     - [live](reference/live/)
         - [apply](reference/live/apply/)
         - [destroy](reference/live/destroy/)
-        - [diff](reference/live/diff/)
         - [init](reference/live/init/)
         - [migrate](reference/live/migrate/)
-        - [preview](reference/live/preview/)
         - [status](reference/live/status/)
 - [Functions Catalog](https://catalog.kpt.dev/ ':target=_self')
 - [FAQ](faq/)
diff --git a/thirdparty/cli-utils/diff/cmddiff.go b/thirdparty/cli-utils/diff/cmddiff.go
deleted file mode 100644
index c2d9940b..00000000
--- a/thirdparty/cli-utils/diff/cmddiff.go
+++ /dev/null
@@ -1,121 +0,0 @@
-// Copyright 2020 The Kubernetes Authors.
-// SPDX-License-Identifier: Apache-2.0
-
-package diff
-
-import (
-	"io/ioutil"
-	"os"
-
-	"github.com/spf13/cobra"
-	"k8s.io/cli-runtime/pkg/genericclioptions"
-	"k8s.io/cli-runtime/pkg/resource"
-	"k8s.io/klog"
-	"k8s.io/kubectl/pkg/cmd/diff"
-	"k8s.io/kubectl/pkg/cmd/util"
-	"k8s.io/kubectl/pkg/util/i18n"
-	"sigs.k8s.io/cli-utils/pkg/common"
-)
-
-const tmpDirPrefix = "diff-cmd"
-
-// NewCmdDiff returns cobra command to implement client-side diff of package
-// directory. For each local config file, get the resource in the cluster
-// and diff the local config resource against the resource in the cluster.
-func NewCmdDiff(f util.Factory, ioStreams genericclioptions.IOStreams) *cobra.Command {
-	options := diff.NewDiffOptions(ioStreams)
-	cmd := &cobra.Command{
-		Use:                   "diff [PKG_PATH | -]",
-		DisableFlagsInUseLine: true,
-		Short:                 i18n.T("Diff local config against cluster applied version"),
-		Args:                  cobra.MaximumNArgs(1),
-		Run: func(cmd *cobra.Command, args []string) {
-			if len(args) == 0 {
-				// default to the current working directory
-				args = append(args, ".")
-			}
-			cleanupFunc, err := Initialize(options, f, args)
-			defer cleanupFunc()
-			util.CheckErr(err)
-			util.CheckErr(options.Run())
-		},
-	}
-
-	return cmd
-}
-
-// Initialize fills in the DiffOptions in preparation for DiffOptions.Run().
-// Returns a cleanup function for removing temp files after expanding stdin, or
-// error if there is an error filling in the options or if there
-// is not one argument that is a directory.
-func Initialize(o *diff.DiffOptions, f util.Factory, args []string) (func(), error) {
-	cleanupFunc := func() {}
-	// Validate the only argument is a (package) directory path.
-	filenameFlags, err := common.DemandOneDirectory(args)
-	if err != nil {
-		return cleanupFunc, err
-	}
-	// Process input from stdin
-	if len(args) == 0 {
-		tmpDir, err := createTempDir()
-		if err != nil {
-			return cleanupFunc, err
-		}
-		cleanupFunc = func() {
-			os.RemoveAll(tmpDir)
-		}
-		filenameFlags.Filenames = &[]string{tmpDir}
-		klog.V(6).Infof("stdin diff command temp dir: %s", tmpDir)
-		if err := common.FilterInputFile(os.Stdin, tmpDir); err != nil {
-			return cleanupFunc, err
-		}
-	} else {
-		// We do not want to diff the inventory object. So we expand
-		// the config file paths, excluding the inventory object.
-		filenameFlags, err = common.ExpandPackageDir(filenameFlags)
-		if err != nil {
-			return cleanupFunc, err
-		}
-	}
-	o.FilenameOptions = filenameFlags.ToOptions()
-
-	o.OpenAPISchema, err = f.OpenAPISchema()
-	if err != nil {
-		return cleanupFunc, err
-	}
-
-	o.DiscoveryClient, err = f.ToDiscoveryClient()
-	if err != nil {
-		return cleanupFunc, err
-	}
-
-	o.DynamicClient, err = f.DynamicClient()
-	if err != nil {
-		return cleanupFunc, err
-	}
-
-	o.DryRunVerifier = resource.NewDryRunVerifier(o.DynamicClient, o.DiscoveryClient)
-
-	o.CmdNamespace, o.EnforceNamespace, err = f.ToRawKubeConfigLoader().Namespace()
-	if err != nil {
-		return cleanupFunc, err
-	}
-
-	o.Builder = f.NewBuilder()
-
-	// We don't support server-side apply diffing yet.
-	o.ServerSideApply = false
-	o.ForceConflicts = false
-
-	return cleanupFunc, nil
-}
-
-func createTempDir() (string, error) {
-	// Create a temporary file with the passed prefix in
-	// the default temporary directory.
-	tmpDir, err := ioutil.TempDir("", tmpDirPrefix)
-	if err != nil {
-		return "", err
-	}
-	return tmpDir, nil
-}
diff --git a/thirdparty/cli-utils/preview/cmdpreview.go b/thirdparty/cli-utils/preview/cmdpreview.go
deleted file mode 100644
index 88b08c8d..00000000
--- a/thirdparty/cli-utils/preview/cmdpreview.go
+++ /dev/null
@@ -1,195 +0,0 @@
-// Copyright 2020 The Kubernetes Authors.
-// SPDX-License-Identifier: Apache-2.0
-
-package preview
-
-import (
-	"context"
-	"fmt"
-	"strings"
-
-	"github.com/spf13/cobra"
-	"k8s.io/cli-runtime/pkg/genericclioptions"
-	cmdutil "k8s.io/kubectl/pkg/cmd/util"
-	"k8s.io/kubectl/pkg/util/i18n"
-
-	"github.com/GoogleContainerTools/kpt/pkg/live"
-	"github.com/GoogleContainerTools/kpt/thirdparty/cli-utils/flagutils"
-	"github.com/GoogleContainerTools/kpt/thirdparty/cli-utils/printers"
-	"sigs.k8s.io/cli-utils/pkg/apply"
-	"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/manifestreader"
-	"sigs.k8s.io/cli-utils/pkg/provider"
-)
-
-var (
-	noPrune        = false
-	previewDestroy = false
-)
-
-// GetPreviewRunner creates and returns the PreviewRunner which stores the cobra command.
-func GetPreviewRunner(provider provider.Provider, loader manifestreader.ManifestLoader, ioStreams genericclioptions.IOStreams) *PreviewRunner {
-	r := &PreviewRunner{
-		Applier:   apply.NewApplier(provider),
-		Destroyer: apply.NewDestroyer(provider),
-		ioStreams: ioStreams,
-		provider:  provider,
-		loader:    loader,
-	}
-	cmd := &cobra.Command{
-		Use:                   "preview [PKG_PATH | -]",
-		DisableFlagsInUseLine: true,
-		Short:                 i18n.T("Preview the apply of a configuration"),
-		Args:                  cobra.MaximumNArgs(1),
-		RunE:                  r.RunE,
-	}
-
-	cmd.Flags().BoolVar(&noPrune, "no-prune", noPrune, "If true, do not prune previously applied objects.")
-	cmd.Flags().BoolVar(&r.serverSideOptions.ServerSideApply, "server-side", false,
-		"If true, preview runs in the server instead of the client.")
-	cmd.Flags().BoolVar(&r.serverSideOptions.ForceConflicts, "force-conflicts", false,
-		"If true during server-side preview, do not report field conflicts.")
-	cmd.Flags().StringVar(&r.serverSideOptions.FieldManager, "field-manager", common.DefaultFieldManager,
-		"If true during server-side preview, sets field owner.")
-	cmd.Flags().BoolVar(&previewDestroy, "destroy", previewDestroy, "If true, preview of destroy operations will be displayed.")
-	cmd.Flags().StringVar(&r.output, "output", printers.DefaultPrinter(),
-		fmt.Sprintf("Output format, must be one of %s", strings.Join(printers.SupportedPrinters(), ",")))
-	cmd.Flags().StringVar(&r.inventoryPolicy, 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))
-	cmd.Flags().BoolVar(&r.installCRD, "install-resource-group", false,
-		"If true, install the inventory ResourceGroup CRD before previewing.")
-
-	r.Command = cmd
-	return r
-}
-
-// PreviewCommand creates the PreviewRunner, returning the cobra command associated with it.
-func PreviewCommand(f cmdutil.Factory, ioStreams genericclioptions.IOStreams) *cobra.Command {
-	provider := provider.NewProvider(f)
-	loader := manifestreader.NewManifestLoader(f)
-	return GetPreviewRunner(provider, loader, ioStreams).Command
-}
-
-// PreviewRunner encapsulates data necessary to run the preview command.
-type PreviewRunner struct {
-	Command    *cobra.Command
-	PreProcess func(info inventory.InventoryInfo, strategy common.DryRunStrategy) (inventory.InventoryPolicy, error)
-	ioStreams  genericclioptions.IOStreams
-	Applier    *apply.Applier
-	Destroyer  *apply.Destroyer
-	provider   provider.Provider
-	loader     manifestreader.ManifestLoader
-
-	serverSideOptions common.ServerSideOptions
-	output            string
-	inventoryPolicy   string
-	installCRD        bool // Install the ResourceGroup CRD before previewing
-}
-
-// RunE is the function run from the cobra command.
-func (r *PreviewRunner) RunE(cmd *cobra.Command, args []string) error {
-	// Install the inventory ResourceGroup CRD prior to applying if
-	// the flag/option is present. Otherwise, check if the CRD exists
-	// and report a failure.
-	if r.installCRD {
-		fmt.Fprint(r.ioStreams.Out, "installing inventory ResourceGroup CRD...")
-		err := live.InstallResourceGroupCRD(r.provider.Factory())
-		if err == nil {
-			fmt.Fprintln(r.ioStreams.Out, "success")
-		} else {
-			fmt.Fprintln(r.ioStreams.Out, "failed")
-			fmt.Fprintln(r.ioStreams.Out, "run 'kpt live install-resource-group' to try again")
-			return err
-		}
-	} else if !live.ResourceGroupCRDApplied(r.provider.Factory()) {
-		// Otherwise, report the inventory ResourceGroup if missing.
-		fmt.Fprintln(r.ioStreams.Out, "inventory ResourceGroup CRD is missing")
-		fmt.Fprintln(r.ioStreams.Out, "run 'kpt live install-resource-group' to remedy")
-		// Do NOT return here, since it breaks legacy ConfigMap applies.
-	}
-
-	if len(args) == 0 {
-		// default to the current working directory
-		args = append(args, ".")
-	}
-	var ch <-chan event.Event
-
-	drs := common.DryRunClient
-	if r.serverSideOptions.ServerSideApply {
-		drs = common.DryRunServer
-	}
-
-	if previewDestroy {
-		r.Destroyer.DryRunStrategy = drs
-	}
-
-	inventoryPolicy, err := flagutils.ConvertInventoryPolicy(r.inventoryPolicy)
-	if err != nil {
-		return err
-	}
-
-	reader, err := r.loader.ManifestReader(cmd.InOrStdin(), flagutils.PathFromArgs(args))
-	if err != nil {
-		return err
-	}
-	objs, err := reader.Read()
-	if err != nil {
-		return err
-	}
-
-	inv, objs, err := r.loader.InventoryInfo(objs)
-	if err != nil {
-		return err
-	}
-
-	if r.PreProcess != nil {
-		inventoryPolicy, err = r.PreProcess(inv, drs)
-		if err != nil {
-			return err
-		}
-	}
-
-	// if destroy flag is set in preview, transmit it to destroyer DryRunStrategy flag
-	// and pivot execution to destroy with dry-run
-	if !r.Destroyer.DryRunStrategy.ClientOrServerDryRun() {
-		err = r.Applier.Initialize()
-		if err != nil {
-			return err
-		}
-
-		// Create a context
-		ctx := context.Background()
-
-		_, err := common.DemandOneDirectory(args)
-		if err != nil {
-			return err
-		}
-
-		// Run the applier. It will return a channel where we can receive updates
-		// to keep track of progress and any issues.
-		ch = r.Applier.Run(ctx, inv, objs, apply.Options{
-			EmitStatusEvents:  false,
-			NoPrune:           noPrune,
-			DryRunStrategy:    drs,
-			ServerSideOptions: r.serverSideOptions,
-			InventoryPolicy:   inventoryPolicy,
-		})
-	} else {
-		err = r.Destroyer.Initialize()
-		if err != nil {
-			return err
-		}
-		option := &apply.DestroyerOption{
-			InventoryPolicy: inventoryPolicy,
-		}
-		ch = r.Destroyer.Run(inv, option)
-	}
-
-	// The printer will print updates from the channel. It will block
-	// until the channel is closed.
-	printer := printers.GetPrinter(r.output, r.ioStreams)
-	return printer.Print(ch, drs)
-}
diff --git a/thirdparty/cli-utils/printers/events/formatter.go b/thirdparty/cli-utils/printers/events/formatter.go
index f24fc60e..736c5518 100644
--- a/thirdparty/cli-utils/printers/events/formatter.go
+++ b/thirdparty/cli-utils/printers/events/formatter.go
@@ -19,9 +19,9 @@ import (
 )
 
 func NewFormatter(ioStreams genericclioptions.IOStreams,
-	previewStrategy common.DryRunStrategy) list.Formatter {
+	dryRunStrategy common.DryRunStrategy) list.Formatter {
 	return &formatter{
-		print: getPrintFunc(ioStreams.Out, previewStrategy),
+		print: getPrintFunc(ioStreams.Out, dryRunStrategy),
 	}
 }
 
@@ -130,12 +130,12 @@ func resourceIDToString(gk schema.GroupKind, name string) string {
 
 type printFunc func(format string, a ...interface{})
 
-func getPrintFunc(w io.Writer, previewStrategy common.DryRunStrategy) printFunc {
+func getPrintFunc(w io.Writer, dryRunStrategy common.DryRunStrategy) printFunc {
 	return func(format string, a ...interface{}) {
-		if previewStrategy.ClientDryRun() {
-			format += " (preview)"
-		} else if previewStrategy.ServerDryRun() {
-			format += " (preview-server)"
+		if dryRunStrategy.ClientDryRun() {
+			format += " (dry-run)"
+		} else if dryRunStrategy.ServerDryRun() {
+			format += " (dry-run-server)"
 		}
 		fmt.Fprintf(w, format+"\n", a...)
 	}
diff --git a/thirdparty/cli-utils/printers/json/formatter.go b/thirdparty/cli-utils/printers/json/formatter.go
index d527af83..c402654c 100644
--- a/thirdparty/cli-utils/printers/json/formatter.go
+++ b/thirdparty/cli-utils/printers/json/formatter.go
@@ -16,16 +16,16 @@ import (
 )
 
 func NewFormatter(ioStreams genericclioptions.IOStreams,
-	previewStrategy common.DryRunStrategy) list.Formatter {
+	dryRunStrategy common.DryRunStrategy) list.Formatter {
 	return &formatter{
-		ioStreams:       ioStreams,
-		previewStrategy: previewStrategy,
+		ioStreams:      ioStreams,
+		dryRunStrategy: dryRunStrategy,
 	}
 }
 
 type formatter struct {
-	previewStrategy common.DryRunStrategy
-	ioStreams       genericclioptions.IOStreams
+	dryRunStrategy common.DryRunStrategy
+	ioStreams      genericclioptions.IOStreams
 }
 
 func (jf *formatter) FormatApplyEvent(ae event.ApplyEvent, as *list.ApplyStats, c list.Collector) error {
diff --git a/thirdparty/cli-utils/printers/printer/printer.go b/thirdparty/cli-utils/printers/printer/printer.go
index 0310fd62..21cecf37 100644
--- a/thirdparty/cli-utils/printers/printer/printer.go
+++ b/thirdparty/cli-utils/printers/printer/printer.go
@@ -9,5 +9,5 @@ import (
 )
 
 type Printer interface {
-	Print(ch <-chan event.Event, previewStrategy common.DryRunStrategy) error
+	Print(ch <-chan event.Event, dryRunStrategy common.DryRunStrategy) error
 }


Test patch:
diff --git a/e2e/live/end-to-end-test.sh b/e2e/live/end-to-end-test.sh
index 0c1b2422..68bc70ec 100755
--- a/e2e/live/end-to-end-test.sh
+++ b/e2e/live/end-to-end-test.sh
@@ -421,14 +421,13 @@ createTestSuite
 waitForDefaultServiceAccount
 
 # Basic init as setup for follow-on tests
-# Test: Preview without ResourceGroup CRD installation fails
-echo "[ResourceGroup] Testing initial preview without ResourceGroup inventory CRD"
+# Test: Apply dry-run without ResourceGroup CRD installation fails
+echo "[ResourceGroup] Testing initial apply dry-run without ResourceGroup inventory CRD"
 cp -f e2e/live/testdata/Kptfile e2e/live/testdata/rg-test-case-1a
 ${BIN_DIR}/kpt live init --quiet e2e/live/testdata/rg-test-case-1a
-echo "kpt live preview e2e/live/testdata/rg-test-case-1a"
-${BIN_DIR}/kpt live preview e2e/live/testdata/rg-test-case-1a > $OUTPUT_DIR/status 2>&1
-assertContains "inventory ResourceGroup CRD is missing"
-assertContains "run 'kpt live install-resource-group' to remedy"
+echo "kpt live apply --dry-run e2e/live/testdata/rg-test-case-1a"
+${BIN_DIR}/kpt live apply --dry-run e2e/live/testdata/rg-test-case-1a > $OUTPUT_DIR/status 2>&1
+assertContains "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."
 printResult
 
 # Test: Apply without ResourceGroup CRD installation fails
@@ -518,15 +517,15 @@ ${BIN_DIR}/kpt live init --quiet e2e/live/testdata/rg-test-case-1a > $OUTPUT_DIR
 assertNotContains "initializing Kptfile inventory info"
 printResult
 
-# Test: Basic kpt live preview
-# Preview run for "rg-test-case-1a" directory
-echo "[ResourceGroup] Testing initial preview"
-echo "kpt live preview e2e/live/testdata/rg-test-case-1a"
-${BIN_DIR}/kpt live preview e2e/live/testdata/rg-test-case-1a > $OUTPUT_DIR/status
-assertContains "namespace/rg-test-namespace created (preview)"
-assertContains "pod/pod-a created (preview)"
-assertContains "pod/pod-b created (preview)"
-assertContains "pod/pod-c created (preview)"
+# Test: Basic kpt live apply dry-run
+# Apply run-run for "rg-test-case-1a" directory
+echo "[ResourceGroup] Testing initial apply dry-run"
+echo "kpt live apply --dry-run e2e/live/testdata/rg-test-case-1a"
+${BIN_DIR}/kpt live apply --dry-run e2e/live/testdata/rg-test-case-1a > $OUTPUT_DIR/status
+assertContains "namespace/rg-test-namespace created (dry-run)"
+assertContains "pod/pod-a created (dry-run)"
+assertContains "pod/pod-b created (dry-run)"
+assertContains "pod/pod-c created (dry-run)"
 assertContains "4 resource(s) applied. 4 created, 0 unchanged, 0 configured, 0 failed"
 assertContains "0 resource(s) pruned, 0 skipped, 0 failed"
 printResult
@@ -548,19 +547,19 @@ wait 2
 assertRGInventory "rg-test-namespace" "4"
 printResult
 
-# Test: kpt live preview of apply/prune
+# Test: kpt live apply dry-run of with prune
 # "rg-test-case-1b" directory is "rg-test-case-1a" directory with "pod-a" removed and "pod-d" added.
-echo "[ResourceGroup] Testing basic preview"
-echo "kpt live preview e2e/live/testdata/rg-test-case-1b"
+echo "[ResourceGroup] Testing basic apply dry-run"
+echo "kpt live apply --dry-run e2e/live/testdata/rg-test-case-1b"
 cp -f e2e/live/testdata/rg-test-case-1a/Kptfile e2e/live/testdata/rg-test-case-1b
-${BIN_DIR}/kpt live preview e2e/live/testdata/rg-test-case-1b > $OUTPUT_DIR/status
-assertContains "namespace/rg-test-namespace configured (preview)"
-assertContains "pod/pod-b configured (preview)"
-assertContains "pod/pod-c configured (preview)"
-assertContains "pod/pod-d created (preview)"
-assertContains "4 resource(s) applied. 1 created, 0 unchanged, 3 configured, 0 failed (preview)"
-assertContains "pod/pod-a pruned (preview)"
-assertContains "1 resource(s) pruned, 0 skipped, 0 failed (preview)"
+${BIN_DIR}/kpt live apply --dry-run e2e/live/testdata/rg-test-case-1b > $OUTPUT_DIR/status
+assertContains "namespace/rg-test-namespace configured (dry-run)"
+assertContains "pod/pod-b configured (dry-run)"
+assertContains "pod/pod-c configured (dry-run)"
+assertContains "pod/pod-d created (dry-run)"
+assertContains "4 resource(s) applied. 1 created, 0 unchanged, 3 configured, 0 failed (dry-run)"
+assertContains "pod/pod-a pruned (dry-run)"
+assertContains "1 resource(s) pruned, 0 skipped, 0 failed (dry-run)"
 wait 2
 # Validate resources in the cluster
 # ConfigMap inventory with four inventory items.
@@ -592,15 +591,15 @@ assertPodExists "pod-d" "rg-test-namespace"
 assertPodNotExists "pod-a" "rg-test-namespace"
 printResult
 
-# Basic kpt live preview --destroy
-echo "[ResourceGroup] Testing basic preview destroy"
-echo "kpt live preview --destroy e2e/live/testdata/rg-test-case-1b"
-${BIN_DIR}/kpt live preview --destroy e2e/live/testdata/rg-test-case-1b > $OUTPUT_DIR/status
-assertContains "pod/pod-d deleted (preview)"
-assertContains "pod/pod-c deleted (preview)"
-assertContains "pod/pod-b deleted (preview)"
-assertContains "namespace/rg-test-namespace deleted (preview)"
-assertContains "4 resource(s) deleted, 0 skipped (preview)"
+# Basic kpt live destroy --dry-run
+echo "[ResourceGroup] Testing basic destroy dry-run"
+echo "kpt live destroy --dry-run e2e/live/testdata/rg-test-case-1b"
+${BIN_DIR}/kpt live destroy --dry-run e2e/live/testdata/rg-test-case-1b > $OUTPUT_DIR/status
+assertContains "pod/pod-d deleted (dry-run)"
+assertContains "pod/pod-c deleted (dry-run)"
+assertContains "pod/pod-b deleted (dry-run)"
+assertContains "namespace/rg-test-namespace deleted (dry-run)"
+assertContains "4 resource(s) deleted, 0 skipped (dry-run)"
 # Validate resources NOT DESTROYED in the cluster
 assertPodExists "pod-b" "rg-test-namespace"
 assertPodExists "pod-c" "rg-test-namespace"
diff --git a/e2e/testdata/fn-eval/consistent-formatting/.expected/diff.patch b/e2e/testdata/fn-eval/consistent-formatting/.expected/diff.patch
index a8d96bdb..5b210d26 100644
--- a/e2e/testdata/fn-eval/consistent-formatting/.expected/diff.patch
+++ b/e2e/testdata/fn-eval/consistent-formatting/.expected/diff.patch
@@ -1,75 +1,42 @@
 diff --git a/Kptfile b/Kptfile
-index 74670e7..9d4cd4a 100644
+index 74670e7..154c558 100644
 --- a/Kptfile
 +++ b/Kptfile
-@@ -2,30 +2,25 @@ apiVersion: kpt.dev/v1alpha2
+@@ -2,16 +2,7 @@ apiVersion: kpt.dev/v1alpha2
  kind: Kptfile
  metadata:
    name: nginx
-+  namespace: staging
- info:
-   description: describe this package
+-info:
+-  description: describe this package
 -  site: https://github.com/GoogleContainerTools/kpt
-   emails:
-     - foo@gmail.com
+-  emails:
+-    - foo@gmail.com
 -  license: license text
-   keywords:
-     - tag1
-     - tag2
-+  license: license text
-   man: nginx man text
--upstream:
--  type: git
--  git:
--    repo: https://github.com/GoogleContainerTools/kpt
--    directory: package-examples/nginx
--    ref: v0.2
--  updateStrategy: resource-merge
--upstreamLock:
--  type: git
--  git:
--    repo: https://github.com/GoogleContainerTools/kpt
--    directory: package-examples/nginx
--    ref: package-examples/nginx/v0.2
--    commit: 4d2aa98b45ddee4b5fa45fbca16f2ff887de9efb
+-  keywords:
+-    - tag1
+-    - tag2
+-  man: nginx man text
++  namespace: staging
+ upstream:
+   type: git
+   git:
+@@ -26,6 +17,16 @@ upstreamLock:
+     directory: package-examples/nginx
+     ref: package-examples/nginx/v0.2
+     commit: 4d2aa98b45ddee4b5fa45fbca16f2ff887de9efb
++info:
 +  site: https://github.com/GoogleContainerTools/kpt
-+inventory:
-+  name: inventory-00933591
-+  namespace: some-space
-+  labels:
-+    foo: bar
-+  annotations:
-+    abc: def
-+  inventoryID: 92c234b7e9267815b0c3e17c9e4d7139a16c104f-1620493522822890000
++  emails:
++    - foo@gmail.com
++  license: license text
++  description: describe this package
++  keywords:
++    - tag1
++    - tag2
++  man: nginx man text
  pipeline:
    mutators:
      - image: gcr.io/kpt-fn/set-namespace:v0.1
-@@ -39,11 +34,17 @@ pipeline:
-       configPath: ./fn-config.yaml
-     - image: gcr.io/kpt-fn/starlark:v0.1
-       configPath: path/to/foo-star.yaml
--inventory:
--  namespace: some-space
--  name: inventory-00933591
--  inventoryID: 92c234b7e9267815b0c3e17c9e4d7139a16c104f-1620493522822890000
--  labels:
--    foo: bar
--  annotations:
--    abc: def
-+upstream:
-+  type: git
-+  updateStrategy: resource-merge
-+  git:
-+    directory: package-examples/nginx
-+    ref: v0.2
-+    repo: https://github.com/GoogleContainerTools/kpt
-+upstreamLock:
-+  type: git
-+  git:
-+    commit: 4d2aa98b45ddee4b5fa45fbca16f2ff887de9efb
-+    directory: package-examples/nginx
-+    ref: package-examples/nginx/v0.2
-+    repo: https://github.com/GoogleContainerTools/kpt
 diff --git a/resources.yaml b/resources.yaml
 index c0f48b0..9e31a48 100644
 --- a/resources.yaml
diff --git a/e2e/testdata/fn-eval/function-chain/.expected/diff.patch b/e2e/testdata/fn-eval/function-chain/.expected/diff.patch
new file mode 100644
index 00000000..696e79d4
--- /dev/null
+++ b/e2e/testdata/fn-eval/function-chain/.expected/diff.patch
@@ -0,0 +1,30 @@
+diff --git a/resources.yaml b/resources.yaml
+index 7a494c9..0298ad0 100644
+--- a/resources.yaml
++++ b/resources.yaml
+@@ -15,12 +15,25 @@ apiVersion: apps/v1
+ kind: Deployment
+ metadata:
+   name: nginx-deployment
++  namespace: staging
++  labels:
++    foo: bar
+ spec:
+   replicas: 3
++  selector:
++    matchLabels:
++      foo: bar
++  template:
++    metadata:
++      labels:
++        foo: bar
+ ---
+ apiVersion: custom.io/v1
+ kind: Custom
+ metadata:
+   name: custom
++  namespace: staging
++  labels:
++    foo: bar
+ spec:
+   image: nginx:1.2.3
diff --git a/e2e/testdata/fn-eval/function-chain/.expected/exec.sh b/e2e/testdata/fn-eval/function-chain/.expected/exec.sh
new file mode 100644
index 00000000..1c7b0301
--- /dev/null
+++ b/e2e/testdata/fn-eval/function-chain/.expected/exec.sh
@@ -0,0 +1,6 @@
+#! /bin/bash
+
+kpt fn source |\
+kpt fn eval - --image gcr.io/kpt-fn/set-namespace:v0.1 -- namespace=staging |\
+kpt fn eval - --image gcr.io/kpt-fn/set-label:v0.1 -- foo=bar |\
+kpt fn sink .
diff --git a/e2e/testdata/fn-eval/function-chain/.krmignore b/e2e/testdata/fn-eval/function-chain/.krmignore
new file mode 100644
index 00000000..9d7a4007
--- /dev/null
+++ b/e2e/testdata/fn-eval/function-chain/.krmignore
@@ -0,0 +1,1 @@
+.expected
diff --git a/e2e/testdata/fn-eval/function-chain/resources.yaml b/e2e/testdata/fn-eval/function-chain/resources.yaml
new file mode 100644
index 00000000..7a494c9c
--- /dev/null
+++ b/e2e/testdata/fn-eval/function-chain/resources.yaml
@@ -0,0 +1,26 @@
+# 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: nginx-deployment
+spec:
+  replicas: 3
+---
+apiVersion: custom.io/v1
+kind: Custom
+metadata:
+  name: custom
+spec:
+  image: nginx:1.2.3
diff --git a/e2e/testdata/fn-render/path-index-ancestor/.expected/diff.patch b/e2e/testdata/fn-render/generator-absolute-path/.expected/diff.patch
similarity index 69%
rename from e2e/testdata/fn-render/path-index-ancestor/.expected/diff.patch
rename to e2e/testdata/fn-render/generator-absolute-path/.expected/diff.patch
index 10d977a8..18f40825 100644
--- a/e2e/testdata/fn-render/path-index-ancestor/.expected/diff.patch
+++ b/e2e/testdata/fn-render/generator-absolute-path/.expected/diff.patch
@@ -1,8 +1,8 @@
-diff --git a/deployment_httpbin.yaml b/deployment_httpbin.yaml
+diff --git a/another/file/out.yaml b/another/file/out.yaml
 new file mode 100644
 index 0000000..fa83ef1
 --- /dev/null
-+++ b/deployment_httpbin.yaml
++++ b/another/file/out.yaml
 @@ -0,0 +1,13 @@
 +apiVersion: apps/v1
 +kind: Deployment
@@ -16,4 +16,4 @@ index 0000000..fa83ef1
 +        - name: httpbin
 +          image: kennethreitz/httpbin
 +          ports:
-+            - containerPort: 9876
++            - containerPort: 9876
\ No newline at end of file
diff --git a/e2e/testdata/fn-render/generator-absolute-path/.krmignore b/e2e/testdata/fn-render/generator-absolute-path/.krmignore
new file mode 100644
index 00000000..9d7a4007
--- /dev/null
+++ b/e2e/testdata/fn-render/generator-absolute-path/.krmignore
@@ -0,0 +1,1 @@
+.expected
diff --git a/e2e/testdata/fn-render/path-index-current/db/Kptfile b/e2e/testdata/fn-render/generator-absolute-path/Kptfile
similarity index 74%
rename from e2e/testdata/fn-render/path-index-current/db/Kptfile
rename to e2e/testdata/fn-render/generator-absolute-path/Kptfile
index 142ea1fd..30810450 100644
--- a/e2e/testdata/fn-render/path-index-current/db/Kptfile
+++ b/e2e/testdata/fn-render/generator-absolute-path/Kptfile
@@ -5,4 +5,4 @@ metadata:
 pipeline:
   mutators:
     - image: gcr.io/kpt-fn/starlark:unstable
-      configPath: starlark-httpbin-gen.yaml
+      configPath: starlark-httpbin.yaml
diff --git a/e2e/testdata/fn-render/path-index-current/db/resources.yaml b/e2e/testdata/fn-render/generator-absolute-path/resources.yaml
similarity index 100%
rename from e2e/testdata/fn-render/path-index-current/db/resources.yaml
rename to e2e/testdata/fn-render/generator-absolute-path/resources.yaml
diff --git a/e2e/testdata/fn-render/generator-absolute-path/starlark-httpbin.yaml b/e2e/testdata/fn-render/generator-absolute-path/starlark-httpbin.yaml
new file mode 100644
index 00000000..196d240a
--- /dev/null
+++ b/e2e/testdata/fn-render/generator-absolute-path/starlark-httpbin.yaml
@@ -0,0 +1,60 @@
+# 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: fn.kpt.dev/v1alpha1
+kind: StarlarkRun
+metadata:
+  name: httpbin-gen
+source: |-
+  httpbin_deployment = {
+     "apiVersion": "apps/v1",
+     "kind": "Deployment",
+     "metadata": {
+        "name": "httpbin",
+        "annotations": {
+          "config.kubernetes.io/index": 0,
+          # generates resource in parent directory
+          "config.kubernetes.io/path": "/another/file/out.yaml"
+        }
+     },
+     "spec": {
+        "replicas": 4,
+        "template": {
+           "spec": {
+              "containers": [
+                {
+                  "name": "httpbin",
+                  "image": "kennethreitz/httpbin",
+                  "ports": [
+                    {
+                       "containerPort": 9876
+                    }
+                  ]
+                }
+              ]
+           }
+        }
+    }
+  }
+  # filter to return if resource is HTTPBin resource
+  def is_httpbin(r):
+    return r["apiVersion"] == "apps/v1" and r["kind"] == "Deployment" and r["metadata"]["name"] == "httpbin"
+
+  def ensure_httpbin(resources):
+    for r in resources:
+      if is_httpbin(r):
+        return
+
+    resources.append(httpbin_deployment)
+
+  ensure_httpbin(ctx.resource_list["items"])
diff --git a/e2e/testdata/fn-render/inline-fnconfig/Kptfile b/e2e/testdata/fn-render/inline-fnconfig/Kptfile
index 5a2a6ef8..0f9559d2 100644
--- a/e2e/testdata/fn-render/inline-fnconfig/Kptfile
+++ b/e2e/testdata/fn-render/inline-fnconfig/Kptfile
@@ -8,7 +8,7 @@ pipeline:
       configMap:
         namespace: staging
     - image: gcr.io/kpt-fn/set-label:unstable
-      config: # inline config
+      config:
         apiVersion: v1
         kind: ConfigMap
         data:
diff --git a/e2e/testdata/fn-render/path-index-ancestor/.expected/config.yaml b/e2e/testdata/fn-render/path-index-ancestor/.expected/config.yaml
index bd487dc9..77383b91 100644
--- a/e2e/testdata/fn-render/path-index-ancestor/.expected/config.yaml
+++ b/e2e/testdata/fn-render/path-index-ancestor/.expected/config.yaml
@@ -12,8 +12,5 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-# This package is nonIdempotent because functions in the subpackage (db)
-# do not have knowledge of the resources in the parent package. Therefore
-# when it runs twice, it tries to recreate the same resource in the parent,
-# resulting in a filepath conflict and throwing an error.
-nonIdempotent: true
\ No newline at end of file
+exitCode: 1
+stdErr: "Error: function must not modify resources outside of package: resource has path ../deployment_httpbin.yaml"
\ No newline at end of file
diff --git a/e2e/testdata/fn-render/path-index-current/.expected/diff.patch b/e2e/testdata/fn-render/path-index-current/.expected/diff.patch
index 6212e438..2b504552 100644
--- a/e2e/testdata/fn-render/path-index-current/.expected/diff.patch
+++ b/e2e/testdata/fn-render/path-index-current/.expected/diff.patch
@@ -1,8 +1,8 @@
-diff --git a/db/deployment_httpbin.yaml b/db/deployment_httpbin.yaml
+diff --git a/deployment_httpbin.yaml b/deployment_httpbin.yaml
 new file mode 100644
 index 0000000..fa83ef1
 --- /dev/null
-+++ b/db/deployment_httpbin.yaml
++++ b/deployment_httpbin.yaml
 @@ -0,0 +1,13 @@
 +apiVersion: apps/v1
 +kind: Deployment
diff --git a/e2e/testdata/fn-render/path-index-current/Kptfile b/e2e/testdata/fn-render/path-index-current/Kptfile
index 7b3bc56d..375117ba 100644
--- a/e2e/testdata/fn-render/path-index-current/Kptfile
+++ b/e2e/testdata/fn-render/path-index-current/Kptfile
@@ -2,3 +2,7 @@ apiVersion: kpt.dev/v1alpha2
 kind: Kptfile
 metadata:
   name: app-with-generator
+pipeline:
+  mutators:
+    - image: gcr.io/kpt-fn/starlark:unstable
+      configPath: starlark-httpbin-gen.yaml
diff --git a/e2e/testdata/fn-render/path-index-current/db/starlark-httpbin-gen.yaml b/e2e/testdata/fn-render/path-index-current/starlark-httpbin-gen.yaml
similarity index 100%
rename from e2e/testdata/fn-render/path-index-current/db/starlark-httpbin-gen.yaml
rename to e2e/testdata/fn-render/path-index-current/starlark-httpbin-gen.yaml
diff --git a/e2e/testdata/fn-render/path-index-duplicate/.expected/config.yaml b/e2e/testdata/fn-render/path-index-duplicate/.expected/config.yaml
index 08c0556e..78c04966 100644
--- a/e2e/testdata/fn-render/path-index-duplicate/.expected/config.yaml
+++ b/e2e/testdata/fn-render/path-index-duplicate/.expected/config.yaml
@@ -18,4 +18,4 @@ exitCode: 1
 stdErr: 'resource at path "resources.yaml" and index "0" already exists'
 stdOut: |
   [RUNNING] "gcr.io/kpt-fn/starlark:unstable"
-  [PASS] "gcr.io/kpt-fn/starlark:unstable"
+  [FAIL] "gcr.io/kpt-fn/starlark:unstable"
diff --git a/e2e/testdata/fn-render/path-index-outofpackage/.expected/config.yaml b/e2e/testdata/fn-render/path-index-outofpackage/.expected/config.yaml
index 54fb5f42..a2e30ce7 100644
--- a/e2e/testdata/fn-render/path-index-outofpackage/.expected/config.yaml
+++ b/e2e/testdata/fn-render/path-index-outofpackage/.expected/config.yaml
@@ -12,8 +12,5 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-# This package is nonIdempotent because functions in the subpackage (db)
-# do not have knowledge of the resources outside of this package. Therefore
-# when it runs twice, it tries to recreate the same resource in the directory,
-# resulting in a filepath conflict and throwing an error.
-nonIdempotent: true
+exitCode: 1
+stdErr: "Error: function must not modify resources outside of package: resource has path ../notpkg/deployment_httpbin.yaml"
\ No newline at end of file
diff --git a/e2e/testdata/fn-render/path-index-outofpackage/.expected/diff.patch b/e2e/testdata/fn-render/path-index-outofpackage/.expected/diff.patch
deleted file mode 100644
index be184eb6..00000000
--- a/e2e/testdata/fn-render/path-index-outofpackage/.expected/diff.patch
+++ /dev/null
@@ -1,19 +0,0 @@
-diff --git a/notpkg/deployment_httpbin.yaml b/notpkg/deployment_httpbin.yaml
-new file mode 100644
-index 0000000..fa83ef1
---- /dev/null
-+++ b/notpkg/deployment_httpbin.yaml
-@@ -0,0 +1,13 @@
-+apiVersion: apps/v1
-+kind: Deployment
-+metadata:
-+  name: httpbin
-+spec:
-+  replicas: 4
-+  template:
-+    spec:
-+      containers:
-+        - name: httpbin
-+          image: kennethreitz/httpbin
-+          ports:
-+            - containerPort: 9876
diff --git a/e2e/testdata/fn-render/validate-generated-resource/Kptfile b/e2e/testdata/fn-render/validate-generated-resource/Kptfile
index 15001326..1590d625 100644
--- a/e2e/testdata/fn-render/validate-generated-resource/Kptfile
+++ b/e2e/testdata/fn-render/validate-generated-resource/Kptfile
@@ -4,8 +4,8 @@ metadata:
   name: db
 pipeline:
   mutators:
-    - image: gcr.io/kpt-fn/starlark:unstable # generates httpbin deployment
+    - image: gcr.io/kpt-fn/starlark:unstable
       configPath: starlark-httpbin-gen.yaml
   validators:
-    - image: gcr.io/kpt-fn/starlark:unstable # validates httpbin deployment exists
+    - image: gcr.io/kpt-fn/starlark:unstable
       configPath: starlark-httpbin-val.yaml
diff --git a/internal/cmddestroy/cmddestroy_test.go b/internal/cmddestroy/cmddestroy_test.go
index e0e6a915..de194df3 100644
--- a/internal/cmddestroy/cmddestroy_test.go
+++ b/internal/cmddestroy/cmddestroy_test.go
@@ -26,6 +26,7 @@ import (
 	"github.com/stretchr/testify/assert"
 	"k8s.io/cli-runtime/pkg/genericclioptions"
 	cmdtesting "k8s.io/kubectl/pkg/cmd/testing"
+	"sigs.k8s.io/cli-utils/pkg/common"
 	"sigs.k8s.io/cli-utils/pkg/inventory"
 )
 
@@ -96,7 +97,7 @@ func TestCmd(t *testing.T) {
 
 			runner := NewRunner(fake.CtxWithNilPrinter(), rgProvider, rgLoader, ioStreams)
 			runner.Command.SetArgs(tc.args)
-			runner.destroyRunner = func(_ *Runner, inv inventory.InventoryInfo) error {
+			runner.destroyRunner = func(_ *Runner, inv inventory.InventoryInfo, _ common.DryRunStrategy) error {
 				tc.destroyCallbackFunc(t, inv)
 				return nil
 			}
diff --git a/internal/cmdrender/executor_test.go b/internal/cmdrender/executor_test.go
deleted file mode 100644
index f4fba4e7..00000000
--- a/internal/cmdrender/executor_test.go
+++ /dev/null
@@ -1,266 +0,0 @@
-// 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.
-
-package cmdrender
-
-import (
-	"bytes"
-	"fmt"
-	"strings"
-	"testing"
-
-	"sigs.k8s.io/kustomize/kyaml/kio"
-)
-
-func TestDetectPathConflicts(t *testing.T) {
-	tests := map[string]struct {
-		input       string // input
-		expectedErr string // expected result
-	}{
-		"duplicate": {
-			input: `apiVersion: v1
-kind: Custom
-metadata:
-  name: a
-  annotations:
-    config.kubernetes.io/path: 'my/path/custom.yaml'
-    config.kubernetes.io/index: '0'
----
-apiVersion: v1
-kind: Custom
-metadata:
-  name: b
-  annotations:
-    config.kubernetes.io/path: 'my/path/custom.yaml'
-    config.kubernetes.io/index: '0'
-`,
-			expectedErr: `resource at path "my/path/custom.yaml" and index "0" already exists`,
-		},
-		"duplicate with `./` prefix": {
-			input: `apiVersion: v1
-kind: Custom
-metadata:
-  name: a
-  annotations:
-    config.kubernetes.io/path: 'my/path/custom.yaml'
-    config.kubernetes.io/index: '0'
----
-apiVersion: v1
-kind: Custom
-metadata:
-  name: b
-  annotations:
-    config.kubernetes.io/path: './my/path/custom.yaml'
-    config.kubernetes.io/index: '0'
-`,
-			expectedErr: `resource at path "my/path/custom.yaml" and index "0" already exists`,
-		},
-		"duplicate path, not index": {
-			input: `apiVersion: v1
-kind: Custom
-metadata:
-  name: a
-  annotations:
-    config.kubernetes.io/path: 'my/path/custom.yaml'
-    config.kubernetes.io/index: '0'
----
-apiVersion: v1
-kind: Custom
-metadata:
-  name: b
-  annotations:
-    config.kubernetes.io/path: 'my/path/custom.yaml'
-    config.kubernetes.io/index: '1'
-`,
-		},
-		"duplicate index, not path": {
-			input: `apiVersion: v1
-kind: Custom
-metadata:
-  name: a
-  annotations:
-    config.kubernetes.io/path: 'my/path/a.yaml'
-    config.kubernetes.io/index: '0'
----
-apiVersion: v1
-kind: Custom
-metadata:
-  name: b
-  annotations:
-    config.kubernetes.io/path: 'my/path/b.yaml'
-    config.kubernetes.io/index: '0'
-`,
-		},
-		"larger number of resources with duplicate": {
-			input: `apiVersion: v1
-kind: Custom
-metadata:
-  name: a
-  annotations:
-    config.kubernetes.io/path: 'my/path/a.yaml'
-    config.kubernetes.io/index: '0'
----
-apiVersion: v1
-kind: Custom
-metadata:
-  name: b
-  annotations:
-    config.kubernetes.io/path: 'my/path/a.yaml'
-    config.kubernetes.io/index: '1'
----
-apiVersion: v1
-kind: Custom
-metadata:
-  name: b
-  annotations:
-    config.kubernetes.io/path: 'my/path/b.yaml'
-    config.kubernetes.io/index: '0'
----
-apiVersion: v1
-kind: Custom
-metadata:
-  name: b
-  annotations:
-    config.kubernetes.io/path: 'my/path/b.yaml'
-    config.kubernetes.io/index: '1'
----
-apiVersion: v1
-kind: Custom
-metadata:
-  name: b
-  annotations:
-    config.kubernetes.io/path: 'my/path/b.yaml'
-    config.kubernetes.io/index: '2'
----
-apiVersion: v1
-kind: Custom
-metadata:
-  name: b
-  annotations:
-    config.kubernetes.io/path: 'my/path/c.yaml'
-    config.kubernetes.io/index: '0'
----
-apiVersion: v1
-kind: Custom
-metadata:
-  name: b
-  annotations:
-    config.kubernetes.io/path: 'my/path/c.yaml'
-    config.kubernetes.io/index: '1'
----
-apiVersion: v1
-kind: Custom
-metadata:
-  name: b
-  annotations:
-    config.kubernetes.io/path: 'my/path/b.yaml'
-    config.kubernetes.io/index: '1'
-`,
-			expectedErr: `resource at path "my/path/b.yaml" and index "1" already exists`,
-		},
-		"larger number of resources without duplicates": {
-			input: `apiVersion: v1
-kind: Custom
-metadata:
-  name: a
-  annotations:
-    config.kubernetes.io/path: 'my/path/a.yaml'
-    config.kubernetes.io/index: '0'
----
-apiVersion: v1
-kind: Custom
-metadata:
-  name: b
-  annotations:
-    config.kubernetes.io/path: 'my/path/a.yaml'
-    config.kubernetes.io/index: '1'
----
-apiVersion: v1
-kind: Custom
-metadata:
-  name: b
-  annotations:
-    config.kubernetes.io/path: 'my/path/b.yaml'
-    config.kubernetes.io/index: '0'
----
-apiVersion: v1
-kind: Custom
-metadata:
-  name: b
-  annotations:
-    config.kubernetes.io/path: 'my/path/b.yaml'
-    config.kubernetes.io/index: '1'
----
-apiVersion: v1
-kind: Custom
-metadata:
-  name: b
-  annotations:
-    config.kubernetes.io/path: 'my/path/b.yaml'
-    config.kubernetes.io/index: '2'
----
-apiVersion: v1
-kind: Custom
-metadata:
-  name: b
-  annotations:
-    config.kubernetes.io/path: 'my/path/c.yaml'
-    config.kubernetes.io/index: '0'
----
-apiVersion: v1
-kind: Custom
-metadata:
-  name: b
-  annotations:
-    config.kubernetes.io/path: 'my/path/c.yaml'
-    config.kubernetes.io/index: '1'
----
-apiVersion: v1
-kind: Custom
-metadata:
-  name: b
-  annotations:
-    config.kubernetes.io/path: 'my/path/b.yaml'
-    config.kubernetes.io/index: '3'
-`,
-		},
-	}
-	for _, tc := range tests {
-		out := &bytes.Buffer{}
-		r := kio.ByteReadWriter{
-			Reader:                bytes.NewBufferString(tc.input),
-			Writer:                out,
-			KeepReaderAnnotations: true,
-			OmitReaderAnnotations: true,
-		}
-		n, err := r.Read()
-		if err != nil {
-			fmt.Println("1")
-			t.FailNow()
-		}
-		err = detectPathConflicts(n)
-		if err != nil && tc.expectedErr == "" {
-			t.Errorf("unexpected error %s", err.Error())
-			t.FailNow()
-		}
-		if tc.expectedErr != "" && err == nil {
-			t.Errorf("expected error %s", tc.expectedErr)
-			t.FailNow()
-		}
-		if tc.expectedErr != "" && !strings.Contains(err.Error(), tc.expectedErr) {
-			t.Errorf("wanted error %s, got %s", tc.expectedErr, err.Error())
-			t.FailNow()
-		}
-	}
-}
diff --git a/internal/fnruntime/runner_test.go b/internal/fnruntime/runner_test.go
index 0fdc69e9..3ec3dd6f 100644
--- a/internal/fnruntime/runner_test.go
+++ b/internal/fnruntime/runner_test.go
@@ -17,14 +17,17 @@
 package fnruntime
 
 import (
+	"bytes"
 	"io/ioutil"
 	"os"
 	"path"
+	"strings"
 	"testing"
 
 	"github.com/GoogleContainerTools/kpt/internal/types"
 	kptfilev1alpha2 "github.com/GoogleContainerTools/kpt/pkg/api/kptfile/v1alpha2"
 	"github.com/stretchr/testify/assert"
+	"sigs.k8s.io/kustomize/kyaml/kio"
 	"sigs.k8s.io/kustomize/kyaml/yaml"
 )
 
@@ -163,3 +166,284 @@ func TestMultilineFormatter(t *testing.T) {
 		})
 	}
 }
+
+func TestEnforcePathInvariants(t *testing.T) {
+	tests := map[string]struct {
+		input       string // input
+		expectedErr string // expected result
+	}{
+		"duplicate": {
+			input: `apiVersion: v1
+kind: Custom
+metadata:
+  name: a
+  annotations:
+    config.kubernetes.io/path: 'my/path/custom.yaml'
+    config.kubernetes.io/index: '0'
+---
+apiVersion: v1
+kind: Custom
+metadata:
+  name: b
+  annotations:
+    config.kubernetes.io/path: 'my/path/custom.yaml'
+    config.kubernetes.io/index: '0'
+`,
+			expectedErr: `resource at path "my/path/custom.yaml" and index "0" already exists`,
+		},
+		"duplicate with `./` prefix": {
+			input: `apiVersion: v1
+kind: Custom
+metadata:
+  name: a
+  annotations:
+    config.kubernetes.io/path: 'my/path/custom.yaml'
+    config.kubernetes.io/index: '0'
+---
+apiVersion: v1
+kind: Custom
+metadata:
+  name: b
+  annotations:
+    config.kubernetes.io/path: './my/path/custom.yaml'
+    config.kubernetes.io/index: '0'
+`,
+			expectedErr: `resource at path "my/path/custom.yaml" and index "0" already exists`,
+		},
+		"duplicate path, not index": {
+			input: `apiVersion: v1
+kind: Custom
+metadata:
+  name: a
+  annotations:
+    config.kubernetes.io/path: 'my/path/custom.yaml'
+    config.kubernetes.io/index: '0'
+---
+apiVersion: v1
+kind: Custom
+metadata:
+  name: b
+  annotations:
+    config.kubernetes.io/path: 'my/path/custom.yaml'
+    config.kubernetes.io/index: '1'
+`,
+		},
+		"duplicate index, not path": {
+			input: `apiVersion: v1
+kind: Custom
+metadata:
+  name: a
+  annotations:
+    config.kubernetes.io/path: 'my/path/a.yaml'
+    config.kubernetes.io/index: '0'
+---
+apiVersion: v1
+kind: Custom
+metadata:
+  name: b
+  annotations:
+    config.kubernetes.io/path: 'my/path/b.yaml'
+    config.kubernetes.io/index: '0'
+`,
+		},
+		"larger number of resources with duplicate": {
+			input: `apiVersion: v1
+kind: Custom
+metadata:
+  name: a
+  annotations:
+    config.kubernetes.io/path: 'my/path/a.yaml'
+    config.kubernetes.io/index: '0'
+---
+apiVersion: v1
+kind: Custom
+metadata:
+  name: b
+  annotations:
+    config.kubernetes.io/path: 'my/path/a.yaml'
+    config.kubernetes.io/index: '1'
+---
+apiVersion: v1
+kind: Custom
+metadata:
+  name: b
+  annotations:
+    config.kubernetes.io/path: 'my/path/b.yaml'
+    config.kubernetes.io/index: '0'
+---
+apiVersion: v1
+kind: Custom
+metadata:
+  name: b
+  annotations:
+    config.kubernetes.io/path: 'my/path/b.yaml'
+    config.kubernetes.io/index: '1'
+---
+apiVersion: v1
+kind: Custom
+metadata:
+  name: b
+  annotations:
+    config.kubernetes.io/path: 'my/path/b.yaml'
+    config.kubernetes.io/index: '2'
+---
+apiVersion: v1
+kind: Custom
+metadata:
+  name: b
+  annotations:
+    config.kubernetes.io/path: 'my/path/c.yaml'
+    config.kubernetes.io/index: '0'
+---
+apiVersion: v1
+kind: Custom
+metadata:
+  name: b
+  annotations:
+    config.kubernetes.io/path: 'my/path/c.yaml'
+    config.kubernetes.io/index: '1'
+---
+apiVersion: v1
+kind: Custom
+metadata:
+  name: b
+  annotations:
+    config.kubernetes.io/path: 'my/path/b.yaml'
+    config.kubernetes.io/index: '1'
+`,
+			expectedErr: `resource at path "my/path/b.yaml" and index "1" already exists`,
+		},
+		"larger number of resources without duplicates": {
+			input: `apiVersion: v1
+kind: Custom
+metadata:
+  name: a
+  annotations:
+    config.kubernetes.io/path: 'my/path/a.yaml'
+    config.kubernetes.io/index: '0'
+---
+apiVersion: v1
+kind: Custom
+metadata:
+  name: b
+  annotations:
+    config.kubernetes.io/path: 'my/path/a.yaml'
+    config.kubernetes.io/index: '1'
+---
+apiVersion: v1
+kind: Custom
+metadata:
+  name: b
+  annotations:
+    config.kubernetes.io/path: 'my/path/b.yaml'
+    config.kubernetes.io/index: '0'
+---
+apiVersion: v1
+kind: Custom
+metadata:
+  name: b
+  annotations:
+    config.kubernetes.io/path: 'my/path/b.yaml'
+    config.kubernetes.io/index: '1'
+---
+apiVersion: v1
+kind: Custom
+metadata:
+  name: b
+  annotations:
+    config.kubernetes.io/path: 'my/path/b.yaml'
+    config.kubernetes.io/index: '2'
+---
+apiVersion: v1
+kind: Custom
+metadata:
+  name: b
+  annotations:
+    config.kubernetes.io/path: 'my/path/c.yaml'
+    config.kubernetes.io/index: '0'
+---
+apiVersion: v1
+kind: Custom
+metadata:
+  name: b
+  annotations:
+    config.kubernetes.io/path: 'my/path/c.yaml'
+    config.kubernetes.io/index: '1'
+---
+apiVersion: v1
+kind: Custom
+metadata:
+  name: b
+  annotations:
+    config.kubernetes.io/path: 'my/path/b.yaml'
+    config.kubernetes.io/index: '3'
+`,
+		},
+
+		"no error": {
+			input: `
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: my-stateful-set
+  annotations:
+    config.kubernetes.io/path: my-stateful-set.yaml
+spec:
+  replicas: 3
+`,
+		},
+		"with ../ prefix": {
+			input: `
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: my-stateful-set
+  annotations:
+    config.kubernetes.io/path: ../my-stateful-set.yaml
+spec:
+  replicas: 3
+
+`,
+			expectedErr: "function must not modify resources outside of package: resource has path ../my-stateful-set.yaml",
+		},
+		"with nested ../ in path": {
+			input: `
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: my-stateful-set
+  annotations:
+    config.kubernetes.io/path: a/b/../../../my-stateful-set.yaml
+spec:
+  replicas: 3
+`,
+			expectedErr: "function must not modify resources outside of package: resource has path a/b/../../../my-stateful-set.yaml",
+		},
+	}
+	for _, tc := range tests {
+		out := &bytes.Buffer{}
+		r := kio.ByteReadWriter{
+			Reader:                bytes.NewBufferString(tc.input),
+			Writer:                out,
+			KeepReaderAnnotations: true,
+			OmitReaderAnnotations: true,
+		}
+		n, err := r.Read()
+		if err != nil {
+			t.FailNow()
+		}
+		err = enforcePathInvariants(n)
+		if err != nil && tc.expectedErr == "" {
+			t.Errorf("unexpected error %s", err.Error())
+			t.FailNow()
+		}
+		if tc.expectedErr != "" && err == nil {
+			t.Errorf("expected error %s", tc.expectedErr)
+			t.FailNow()
+		}
+		if tc.expectedErr != "" && !strings.Contains(err.Error(), tc.expectedErr) {
+			t.Errorf("wanted error %s, got %s", tc.expectedErr, err.Error())
+			t.FailNow()
+		}
+	}
+}
diff --git a/pkg/test/runner/README.md b/pkg/test/runner/README.md
new file mode 100644
index 00000000..ed071ca1
--- /dev/null
+++ b/pkg/test/runner/README.md
@@ -0,0 +1,68 @@
+This package is an end-to-end test runner for kpt fn commands. It
+can also be used to test kpt KRM functions.
+
+# How Does It Work
+
+After importing this package, you can use `runner.ScanTestCases` to
+recursively scan the test cases in a given directory. For each test
+case, call `runner.NewRunner` to create a test runner and then call
+`Run` to run the test. For example please see `e2e/fn_test.go`
+
+All test cases should be **independent** and should be able to run in
+fully parallel. They should be **self-contained** so the runner doesn't
+need any manual configuration.
+
+The configurations for a test case is located in the `.expected` directory
+in the test case directory. The runner will implicitly use following files
+in `.expected`:
+
+- `config.yaml`: This is the file which contains the configurations for the
+  test case. It can have following fields:
+  - `testType`: `eval` or `render`. It controls which `kpt fn` command will be
+    used to run the test. Default: `eval`.
+  - `exitCode`: The expected exit code for the command. Default: 0.
+  - `skip`: Runner will skip the test if `skip` is set to true. Default: false.
+  - `notIdempotent`: The functions and commands should be idempotent, but in
+    some cases it's not doable. The runner will not run the command twice to
+    check idempotent if this is set to true. Default: false.
+  - `debug`: Debug means will the debug behavior be enabled. Default: false.
+    Debug behaviors:
+    - Keep the temporary directory used to run the test cases after test.
+  - `stdOut`: The expected standard output from running the command. Default: "".
+  - `stdErr`: The expected standard error from running the command. Default: "".
+  - `disableOutputTruncate`: Control should error output be truncated. Default:
+    false.
+  - Configurations only apply to `eval` tests:
+    - `execPath`: A path to the executable file that will be run as function.
+      Mutually exclusive with Image. The path should be separated by slash '/'
+    - `image`: The image name for the function. Either `image` or `execPath`
+      must be provided.
+    - `args`: A map contains the arguments that will be passed into function.
+      Args will be passed as 'key=value' format after the '--' in command.
+    - `network`: Control the accessibility of network from the function
+      container. Default: false.
+    - `includeMetaResources`: Enable including meta resources, like Kptfile,
+      in the function input. Default: false.
+    - `fnConfig`: The path to the function config file. The path should be
+      separated by slash '/'.
+- `diff.patch`: The expected `git diff` output after running the command.
+  Default: "".
+- `results.yaml`: The expected result file after running the command.
+  Default: "".
+- `setup.sh`: A **bash** script which will be run before the command if it exists.
+- `exec.sh`: A **bash** script which will be run if it exists and will replace the
+  command (`kpt fn eval` or `kpt fn render`) that will be run according to
+  `testType` in configurations. All configurations that used to control command
+  behavior, like `disableOutputTruncate` and `args`, will be ignored.
+- `teardown.sh`: A **bash** script which will be run after the command and
+  result comparison if it exists.
+
+# Expected Output
+
+The runner will compare the actual values of:
+
+- `stdout`
+- `stderr`
+- Exit Code
+- Diff
+- Results file
diff --git a/pkg/test/runner/runner.go b/pkg/test/runner/runner.go
index 59e7308d..03d760ef 100644
--- a/pkg/test/runner/runner.go
+++ b/pkg/test/runner/runner.go
@@ -35,10 +35,11 @@ type Runner struct {
 }
 
 func getKptBin() (string, error) {
-	if p := os.Getenv(kptBinEnv); p != "" {
-		return p, nil
+	p, err := exec.Command("which", "kpt").CombinedOutput()
+	if err != nil {
+		return "", fmt.Errorf("cannot find command 'kpt' in $PATH: %w", err)
 	}
-	return "", fmt.Errorf("must specify env '%s' for kpt binary path", kptBinEnv)
+	return strings.TrimSpace(string(p)), nil
 }
 
 const (
@@ -46,12 +47,14 @@ const (
 	// expected diff and results if they already exist. If will not change
 	// config.yaml.
 	updateExpectedEnv string = "KPT_E2E_UPDATE_EXPECTED"
-	kptBinEnv         string = "KPT_E2E_BIN"
 
 	expectedDir         string = ".expected"
 	expectedResultsFile string = "results.yaml"
 	expectedDiffFile    string = "diff.patch"
 	expectedConfigFile  string = "config.yaml"
+	setupScript         string = "setup.sh"
+	teardownScript      string = "teardown.sh"
+	execScript          string = "exec.sh"
 	CommandFnEval       string = "eval"
 	CommandFnRender     string = "render"
 )
@@ -91,6 +94,40 @@ func (r *Runner) Run() error {
 	}
 }
 
+// runSetupScript runs the setup script if the test has it
+func (r *Runner) runSetupScript(pkgPath string) error {
+	p, err := filepath.Abs(filepath.Join(r.testCase.Path, expectedDir, setupScript))
+	if err != nil {
+		return err
+	}
+	if _, err := os.Stat(p); os.IsNotExist(err) {
+		return nil
+	}
+	cmd := getCommand(pkgPath, "bash", []string{p})
+	r.t.Logf("running setup script: %q", cmd.String())
+	if output, err := cmd.CombinedOutput(); err != nil {
+		return fmt.Errorf("failed to run setup script %q.\nOutput: %q\n: %w", p, string(output), err)
+	}
+	return nil
+}
+
+// runTearDownScript runs the teardown script if the test has it
+func (r *Runner) runTearDownScript(pkgPath string) error {
+	p, err := filepath.Abs(filepath.Join(r.testCase.Path, expectedDir, teardownScript))
+	if err != nil {
+		return err
+	}
+	if _, err := os.Stat(p); os.IsNotExist(err) {
+		return nil
+	}
+	cmd := getCommand(pkgPath, "bash", []string{p})
+	r.t.Logf("running teardown script: %q", cmd.String())
+	if output, err := cmd.CombinedOutput(); err != nil {
+		return fmt.Errorf("failed to run teardown script %q.\nOutput: %q\n: %w", p, string(output), err)
+	}
+	return nil
+}
+
 func (r *Runner) runFnEval() error {
 	r.t.Logf("Running test against package %s\n", r.pkgName)
 	tmpDir, err := ioutil.TempDir("", "kpt-fn-e2e-*")
@@ -124,34 +161,51 @@ func (r *Runner) runFnEval() error {
 	}
 
 	// run function
-	kptArgs := []string{"fn", "eval", pkgPath}
+	for i := 0; i < r.testCase.Config.RunCount(); i++ {
+		err = r.runSetupScript(pkgPath)
+		if err != nil {
+			return err
+		}
 
-	if resultsDir != "" {
-		kptArgs = append(kptArgs, "--results-dir", resultsDir)
-	}
-	if r.testCase.Config.EvalConfig.Network {
-		kptArgs = append(kptArgs, "--network")
-	}
-	if r.testCase.Config.EvalConfig.Image != "" {
-		kptArgs = append(kptArgs, "--image", r.testCase.Config.EvalConfig.Image)
-	} else if !r.testCase.Config.EvalConfig.execUniquePath.Empty() {
-		kptArgs = append(kptArgs, "--exec-path", string(r.testCase.Config.EvalConfig.execUniquePath))
-	}
-	if !r.testCase.Config.EvalConfig.fnConfigUniquePath.Empty() {
-		kptArgs = append(kptArgs, "--fn-config", string(r.testCase.Config.EvalConfig.fnConfigUniquePath))
-	}
-	if r.testCase.Config.EvalConfig.IncludeMetaResources {
-		kptArgs = append(kptArgs, "--include-meta-resources")
-	}
-	// args must be appended last
-	if len(r.testCase.Config.EvalConfig.Args) > 0 {
-		kptArgs = append(kptArgs, "--")
-		for k, v := range r.testCase.Config.EvalConfig.Args {
-			kptArgs = append(kptArgs, fmt.Sprintf("%s=%s", k, v))
+		var cmd *exec.Cmd
+		execScriptPath, err := filepath.Abs(filepath.Join(r.testCase.Path, expectedDir, execScript))
+		if err != nil {
+			return err
 		}
-	}
-	for i := 0; i < r.testCase.Config.RunCount(); i++ {
-		stdout, stderr, fnErr := runCommand("", r.kptBin, kptArgs)
+
+		if _, err := os.Stat(execScriptPath); err == nil {
+			cmd = getCommand(pkgPath, "bash", []string{execScriptPath})
+		} else {
+			kptArgs := []string{"fn", "eval", pkgPath}
+
+			if resultsDir != "" {
+				kptArgs = append(kptArgs, "--results-dir", resultsDir)
+			}
+			if r.testCase.Config.EvalConfig.Network {
+				kptArgs = append(kptArgs, "--network")
+			}
+			if r.testCase.Config.EvalConfig.Image != "" {
+				kptArgs = append(kptArgs, "--image", r.testCase.Config.EvalConfig.Image)
+			} else if !r.testCase.Config.EvalConfig.execUniquePath.Empty() {
+				kptArgs = append(kptArgs, "--exec-path", string(r.testCase.Config.EvalConfig.execUniquePath))
+			}
+			if !r.testCase.Config.EvalConfig.fnConfigUniquePath.Empty() {
+				kptArgs = append(kptArgs, "--fn-config", string(r.testCase.Config.EvalConfig.fnConfigUniquePath))
+			}
+			if r.testCase.Config.EvalConfig.IncludeMetaResources {
+				kptArgs = append(kptArgs, "--include-meta-resources")
+			}
+			// args must be appended last
+			if len(r.testCase.Config.EvalConfig.Args) > 0 {
+				kptArgs = append(kptArgs, "--")
+				for k, v := range r.testCase.Config.EvalConfig.Args {
+					kptArgs = append(kptArgs, fmt.Sprintf("%s=%s", k, v))
+				}
+			}
+			cmd = getCommand("", r.kptBin, kptArgs)
+		}
+		r.t.Logf("running command %q", cmd.String())
+		stdout, stderr, fnErr := runCommand(cmd)
 		if fnErr != nil {
 			r.t.Logf("kpt error, stdout: %s; stderr: %s", stdout, stderr)
 		}
@@ -170,6 +224,11 @@ func (r *Runner) runFnEval() error {
 		if fnErr != nil {
 			break
 		}
+
+		err = r.runTearDownScript(pkgPath)
+		if err != nil {
+			return err
+		}
 	}
 
 	return nil
@@ -230,17 +289,35 @@ func (r *Runner) runFnRender() error {
 	}
 
 	// run function
-	kptArgs := []string{"fn", "render", pkgPath}
+	for i := 0; i < r.testCase.Config.RunCount(); i++ {
+		err = r.runSetupScript(pkgPath)
+		if err != nil {
+			return err
+		}
 
-	if resultsDir != "" {
-		kptArgs = append(kptArgs, "--results-dir", resultsDir)
-	}
+		var cmd *exec.Cmd
 
-	if r.testCase.Config.DisableOutputTruncate {
-		kptArgs = append(kptArgs, "--truncate-output=false")
-	}
-	for i := 0; i < r.testCase.Config.RunCount(); i++ {
-		stdout, stderr, fnErr := runCommand("", r.kptBin, kptArgs)
+		execScriptPath, err := filepath.Abs(filepath.Join(r.testCase.Path, expectedDir, execScript))
+		if err != nil {
+			return err
+		}
+
+		if _, err := os.Stat(execScriptPath); err == nil {
+			cmd = getCommand(pkgPath, "bash", []string{execScriptPath})
+		} else {
+			kptArgs := []string{"fn", "render", pkgPath}
+
+			if resultsDir != "" {
+				kptArgs = append(kptArgs, "--results-dir", resultsDir)
+			}
+
+			if r.testCase.Config.DisableOutputTruncate {
+				kptArgs = append(kptArgs, "--truncate-output=false")
+			}
+			cmd = getCommand("", r.kptBin, kptArgs)
+		}
+		r.t.Logf("running command %q", cmd.String())
+		stdout, stderr, fnErr := runCommand(cmd)
 		// Update the diff file or results file if updateExpectedEnv is set.
 		if strings.ToLower(os.Getenv(updateExpectedEnv)) == "true" {
 			return r.updateExpected(pkgPath, resultsDir, filepath.Join(r.testCase.Path, expectedDir))
@@ -259,6 +336,11 @@ func (r *Runner) runFnRender() error {
 		if fnErr != nil {
 			break
 		}
+
+		err = r.runTearDownScript(pkgPath)
+		if err != nil {
+			return err
+		}
 	}
 	return nil
 }
diff --git a/pkg/test/runner/util.go b/pkg/test/runner/util.go
index 288f98bb..2e94d29e 100644
--- a/pkg/test/runner/util.go
+++ b/pkg/test/runner/util.go
@@ -23,9 +23,7 @@ import (
 	"path/filepath"
 )
 
-func runCommand(pwd, name string, arg []string) (string, string, error) {
-	cmd := exec.Command(name, arg...)
-	cmd.Dir = pwd
+func runCommand(cmd *exec.Cmd) (string, string, error) {
 	var stdout bytes.Buffer
 	var stderr bytes.Buffer
 	cmd.Stdout = &stdout
@@ -34,13 +32,19 @@ func runCommand(pwd, name string, arg []string) (string, string, error) {
 	return stdout.String(), stderr.String(), err
 }
 
+func getCommand(pwd, name string, arg []string) *exec.Cmd {
+	cmd := exec.Command(name, arg...)
+	cmd.Dir = pwd
+	return cmd
+}
+
 func copyDir(src, dst string) error {
-	_, _, err := runCommand("", "cp", []string{"-r", src, dst})
+	_, _, err := runCommand(getCommand("", "cp", []string{"-r", src, dst}))
 	return err
 }
 
 func gitInit(d string) error {
-	stdout, stderr, err := runCommand(d, "git", []string{"init"})
+	stdout, stderr, err := runCommand(getCommand(d, "git", []string{"init"}))
 	if err != nil {
 		return fmt.Errorf("git init error: %w, output: %s, stderr: %s", err, stdout, stderr)
 	}
@@ -48,7 +52,7 @@ func gitInit(d string) error {
 }
 
 func gitAddAll(d string) error {
-	stdout, stderr, err := runCommand(d, "git", []string{"add", "--all"})
+	stdout, stderr, err := runCommand(getCommand(d, "git", []string{"add", "--all"}))
 	if err != nil {
 		return fmt.Errorf("git commit error: %w, output: %s, stderr: %s", err, stdout, stderr)
 	}
@@ -56,15 +60,15 @@ func gitAddAll(d string) error {
 }
 
 func gitCommit(d, msg string) error {
-	stdout, stderr, err := runCommand(d, "git", []string{"config", "user.name", "none"})
+	stdout, stderr, err := runCommand(getCommand(d, "git", []string{"config", "user.name", "none"}))
 	if err != nil {
 		return fmt.Errorf("git config error: %w, output: %s, stderr: %s", err, stdout, stderr)
 	}
-	stdout, stderr, err = runCommand(d, "git", []string{"config", "user.email", "none"})
+	stdout, stderr, err = runCommand(getCommand(d, "git", []string{"config", "user.email", "none"}))
 	if err != nil {
 		return fmt.Errorf("git config error: %w, output: %s, stderr: %s", err, stdout, stderr)
 	}
-	stdout, stderr, err = runCommand(d, "git", []string{"commit", "-m", msg, "--allow-empty"})
+	stdout, stderr, err = runCommand(getCommand(d, "git", []string{"commit", "-m", msg, "--allow-empty"}))
 	if err != nil {
 		return fmt.Errorf("git commit error: %w, output: %s, stderr: %s", err, stdout, stderr)
 	}
@@ -72,7 +76,7 @@ func gitCommit(d, msg string) error {
 }
 
 func gitDiff(d, commit1, commit2 string) (string, error) {
-	stdout, stderr, err := runCommand(d, "git", []string{"diff", commit1, commit2})
+	stdout, stderr, err := runCommand(getCommand(d, "git", []string{"diff", commit1, commit2}))
 	if err != nil {
 		return "", fmt.Errorf("git diff error: %w, output: %s, stderr: %s", err, stdout, stderr)
 	}
@@ -80,7 +84,7 @@ func gitDiff(d, commit1, commit2 string) (string, error) {
 }
 
 func getCommitHash(d string) (string, error) {
-	stdout, stderr, err := runCommand(d, "git", []string{"log", "-n", "1", "--pretty=format:%h"})
+	stdout, stderr, err := runCommand(getCommand(d, "git", []string{"log", "-n", "1", "--pretty=format:%h"}))
 	if err != nil {
 		return "", fmt.Errorf("git log error: %w, output: %s, stderr: %s", err, stdout, stderr)
 	}
@@ -102,6 +106,6 @@ func diffStrings(actual, expected string) (string, error) {
 		return "", fmt.Errorf("failed to write file %s", expectedPath)
 	}
 	// diff is expected to exit with 1 so we ignore the error here
-	output, _, _ := runCommand(tmpDir, "diff", []string{"-u", expectedPath, actualPath})
+	output, _, _ := runCommand(getCommand(tmpDir, "diff", []string{"-u", expectedPath, actualPath}))
 	return output, nil
 }
diff --git a/thirdparty/cli-utils/printers/events/formatter_test.go b/thirdparty/cli-utils/printers/events/formatter_test.go
index a3bfa862..d946eb0a 100644
--- a/thirdparty/cli-utils/printers/events/formatter_test.go
+++ b/thirdparty/cli-utils/printers/events/formatter_test.go
@@ -22,14 +22,14 @@ import (
 
 func TestFormatter_FormatApplyEvent(t *testing.T) {
 	testCases := map[string]struct {
-		previewStrategy common.DryRunStrategy
+		dryRunStrategy  common.DryRunStrategy
 		event           event.ApplyEvent
 		applyStats      *list.ApplyStats
 		statusCollector list.Collector
 		expected        string
 	}{
 		"resource created without no dryrun": {
-			previewStrategy: common.DryRunNone,
+			dryRunStrategy: common.DryRunNone,
 			event: event.ApplyEvent{
 				Operation:  event.Created,
 				Type:       event.ApplyEventResourceUpdate,
@@ -38,35 +38,35 @@ func TestFormatter_FormatApplyEvent(t *testing.T) {
 			expected: "deployment.apps/my-dep created",
 		},
 		"resource updated with client dryrun": {
-			previewStrategy: common.DryRunClient,
+			dryRunStrategy: common.DryRunClient,
 			event: event.ApplyEvent{
 				Operation:  event.Configured,
 				Type:       event.ApplyEventResourceUpdate,
 				Identifier: createIdentifier("apps", "Deployment", "", "my-dep"),
 			},
-			expected: "deployment.apps/my-dep configured (preview)",
+			expected: "deployment.apps/my-dep configured (dry-run)",
 		},
 		"resource updated with server dryrun": {
-			previewStrategy: common.DryRunServer,
+			dryRunStrategy: common.DryRunServer,
 			event: event.ApplyEvent{
 				Operation:  event.Configured,
 				Type:       event.ApplyEventResourceUpdate,
 				Identifier: createIdentifier("batch", "CronJob", "foo", "my-cron"),
 			},
-			expected: "cronjob.batch/my-cron configured (preview-server)",
+			expected: "cronjob.batch/my-cron configured (dry-run-server)",
 		},
 		"apply event with error should display the error": {
-			previewStrategy: common.DryRunServer,
+			dryRunStrategy: common.DryRunServer,
 			event: event.ApplyEvent{
 				Operation:  event.Failed,
 				Type:       event.ApplyEventResourceUpdate,
 				Identifier: createIdentifier("apps", "Deployment", "", "my-dep"),
 				Error:      fmt.Errorf("this is a test error"),
 			},
-			expected: "deployment.apps/my-dep failed: this is a test error (preview-server)",
+			expected: "deployment.apps/my-dep failed: this is a test error (dry-run-server)",
 		},
 		"completed event": {
-			previewStrategy: common.DryRunNone,
+			dryRunStrategy: common.DryRunNone,
 			event: event.ApplyEvent{
 				Type: event.ApplyEventCompleted,
 			},
@@ -100,7 +100,7 @@ deployment.apps/my-dep is Current: Resource is Current
 	for tn, tc := range testCases {
 		t.Run(tn, func(t *testing.T) {
 			ioStreams, _, out, _ := genericclioptions.NewTestIOStreams() //nolint:dogsled
-			formatter := NewFormatter(ioStreams, tc.previewStrategy)
+			formatter := NewFormatter(ioStreams, tc.dryRunStrategy)
 			err := formatter.FormatApplyEvent(tc.event, tc.applyStats, tc.statusCollector)
 			assert.NoError(t, err)
 
@@ -111,13 +111,13 @@ deployment.apps/my-dep is Current: Resource is Current
 
 func TestFormatter_FormatStatusEvent(t *testing.T) {
 	testCases := map[string]struct {
-		previewStrategy common.DryRunStrategy
+		dryRunStrategy  common.DryRunStrategy
 		event           event.StatusEvent
 		statusCollector list.Collector
 		expected        string
 	}{
 		"resource update with Current status": {
-			previewStrategy: common.DryRunNone,
+			dryRunStrategy: common.DryRunNone,
 			event: event.StatusEvent{
 				Type: event.StatusEventResourceUpdate,
 				Resource: &pollevent.ResourceStatus{
@@ -140,7 +140,7 @@ func TestFormatter_FormatStatusEvent(t *testing.T) {
 	for tn, tc := range testCases {
 		t.Run(tn, func(t *testing.T) {
 			ioStreams, _, out, _ := genericclioptions.NewTestIOStreams() //nolint:dogsled
-			formatter := NewFormatter(ioStreams, tc.previewStrategy)
+			formatter := NewFormatter(ioStreams, tc.dryRunStrategy)
 			err := formatter.FormatStatusEvent(tc.event, tc.statusCollector)
 			assert.NoError(t, err)
 
@@ -151,13 +151,13 @@ func TestFormatter_FormatStatusEvent(t *testing.T) {
 
 func TestFormatter_FormatPruneEvent(t *testing.T) {
 	testCases := map[string]struct {
-		previewStrategy common.DryRunStrategy
-		event           event.PruneEvent
-		pruneStats      *list.PruneStats
-		expected        string
+		dryRunStrategy common.DryRunStrategy
+		event          event.PruneEvent
+		pruneStats     *list.PruneStats
+		expected       string
 	}{
 		"resource pruned without no dryrun": {
-			previewStrategy: common.DryRunNone,
+			dryRunStrategy: common.DryRunNone,
 			event: event.PruneEvent{
 				Operation:  event.Pruned,
 				Type:       event.PruneEventResourceUpdate,
@@ -166,16 +166,16 @@ func TestFormatter_FormatPruneEvent(t *testing.T) {
 			expected: "deployment.apps/my-dep pruned",
 		},
 		"resource skipped with client dryrun": {
-			previewStrategy: common.DryRunClient,
+			dryRunStrategy: common.DryRunClient,
 			event: event.PruneEvent{
 				Operation:  event.PruneSkipped,
 				Type:       event.PruneEventResourceUpdate,
 				Identifier: createIdentifier("apps", "Deployment", "", "my-dep"),
 			},
-			expected: "deployment.apps/my-dep prune skipped (preview)",
+			expected: "deployment.apps/my-dep prune skipped (dry-run)",
 		},
 		"resource with prune error": {
-			previewStrategy: common.DryRunNone,
+			dryRunStrategy: common.DryRunNone,
 			event: event.PruneEvent{
 				Type:       event.PruneEventFailed,
 				Identifier: createIdentifier("apps", "Deployment", "", "my-dep"),
@@ -184,7 +184,7 @@ func TestFormatter_FormatPruneEvent(t *testing.T) {
 			expected: "deployment.apps/my-dep prune failed: this is a test",
 		},
 		"prune event with completed status": {
-			previewStrategy: common.DryRunNone,
+			dryRunStrategy: common.DryRunNone,
 			event: event.PruneEvent{
 				Type: event.PruneEventCompleted,
 			},
@@ -199,7 +199,7 @@ func TestFormatter_FormatPruneEvent(t *testing.T) {
 	for tn, tc := range testCases {
 		t.Run(tn, func(t *testing.T) {
 			ioStreams, _, out, _ := genericclioptions.NewTestIOStreams() //nolint:dogsled
-			formatter := NewFormatter(ioStreams, tc.previewStrategy)
+			formatter := NewFormatter(ioStreams, tc.dryRunStrategy)
 			err := formatter.FormatPruneEvent(tc.event, tc.pruneStats)
 			assert.NoError(t, err)
 
@@ -210,14 +210,14 @@ func TestFormatter_FormatPruneEvent(t *testing.T) {
 
 func TestFormatter_FormatDeleteEvent(t *testing.T) {
 	testCases := map[string]struct {
-		previewStrategy common.DryRunStrategy
+		dryRunStrategy  common.DryRunStrategy
 		event           event.DeleteEvent
 		deleteStats     *list.DeleteStats
 		statusCollector list.Collector
 		expected        string
 	}{
 		"resource deleted without no dryrun": {
-			previewStrategy: common.DryRunNone,
+			dryRunStrategy: common.DryRunNone,
 			event: event.DeleteEvent{
 				Operation: event.Deleted,
 				Type:      event.DeleteEventResourceUpdate,
@@ -226,26 +226,26 @@ func TestFormatter_FormatDeleteEvent(t *testing.T) {
 			expected: "deployment.apps/my-dep deleted",
 		},
 		"resource skipped with client dryrun": {
-			previewStrategy: common.DryRunClient,
+			dryRunStrategy: common.DryRunClient,
 			event: event.DeleteEvent{
 				Operation: event.DeleteSkipped,
 				Type:      event.DeleteEventResourceUpdate,
 				Object:    createObject("apps", "Deployment", "", "my-dep"),
 			},
-			expected: "deployment.apps/my-dep delete skipped (preview)",
+			expected: "deployment.apps/my-dep delete skipped (dry-run)",
 		},
 		"resource with delete error": {
-			previewStrategy: common.DryRunServer,
+			dryRunStrategy: common.DryRunServer,
 			event: event.DeleteEvent{
 				Type:       event.DeleteEventFailed,
 				Object:     createObject("apps", "Deployment", "", "my-dep"),
 				Identifier: createIdentifier("apps", "Deployment", "", "my-dep"),
 				Error:      fmt.Errorf("this is a test"),
 			},
-			expected: "deployment.apps/my-dep deletion failed: this is a test (preview-server)",
+			expected: "deployment.apps/my-dep deletion failed: this is a test (dry-run-server)",
 		},
 		"delete event with completed status": {
-			previewStrategy: common.DryRunNone,
+			dryRunStrategy: common.DryRunNone,
 			event: event.DeleteEvent{
 				Type: event.DeleteEventCompleted,
 			},
@@ -260,7 +260,7 @@ func TestFormatter_FormatDeleteEvent(t *testing.T) {
 	for tn, tc := range testCases {
 		t.Run(tn, func(t *testing.T) {
 			ioStreams, _, out, _ := genericclioptions.NewTestIOStreams() //nolint:dogsled
-			formatter := NewFormatter(ioStreams, tc.previewStrategy)
+			formatter := NewFormatter(ioStreams, tc.dryRunStrategy)
 			err := formatter.FormatDeleteEvent(tc.event, tc.deleteStats)
 			assert.NoError(t, err)
 
diff --git a/thirdparty/cli-utils/printers/json/formatter_test.go b/thirdparty/cli-utils/printers/json/formatter_test.go
index fa3ce14d..269bb00d 100644
--- a/thirdparty/cli-utils/printers/json/formatter_test.go
+++ b/thirdparty/cli-utils/printers/json/formatter_test.go
@@ -21,14 +21,14 @@ import (
 
 func TestFormatter_FormatApplyEvent(t *testing.T) {
 	testCases := map[string]struct {
-		previewStrategy common.DryRunStrategy
+		dryRunStrategy  common.DryRunStrategy
 		event           event.ApplyEvent
 		applyStats      *list.ApplyStats
 		statusCollector list.Collector
 		expected        []map[string]interface{}
 	}{
 		"resource created without dryrun": {
-			previewStrategy: common.DryRunNone,
+			dryRunStrategy: common.DryRunNone,
 			event: event.ApplyEvent{
 				Operation:  event.Created,
 				Type:       event.ApplyEventResourceUpdate,
@@ -48,7 +48,7 @@ func TestFormatter_FormatApplyEvent(t *testing.T) {
 			},
 		},
 		"resource updated with client dryrun": {
-			previewStrategy: common.DryRunClient,
+			dryRunStrategy: common.DryRunClient,
 			event: event.ApplyEvent{
 				Operation:  event.Configured,
 				Type:       event.ApplyEventResourceUpdate,
@@ -68,7 +68,7 @@ func TestFormatter_FormatApplyEvent(t *testing.T) {
 			},
 		},
 		"resource updated with server dryrun": {
-			previewStrategy: common.DryRunServer,
+			dryRunStrategy: common.DryRunServer,
 			event: event.ApplyEvent{
 				Operation:  event.Configured,
 				Type:       event.ApplyEventResourceUpdate,
@@ -88,7 +88,7 @@ func TestFormatter_FormatApplyEvent(t *testing.T) {
 			},
 		},
 		"completed event": {
-			previewStrategy: common.DryRunNone,
+			dryRunStrategy: common.DryRunNone,
 			event: event.ApplyEvent{
 				Type: event.ApplyEventCompleted,
 			},
@@ -142,7 +142,7 @@ func TestFormatter_FormatApplyEvent(t *testing.T) {
 	for tn, tc := range testCases {
 		t.Run(tn, func(t *testing.T) {
 			ioStreams, _, out, _ := genericclioptions.NewTestIOStreams() //nolint:dogsled
-			formatter := NewFormatter(ioStreams, tc.previewStrategy)
+			formatter := NewFormatter(ioStreams, tc.dryRunStrategy)
 			err := formatter.FormatApplyEvent(tc.event, tc.applyStats, tc.statusCollector)
 			assert.NoError(t, err)
 
@@ -160,13 +160,13 @@ func TestFormatter_FormatApplyEvent(t *testing.T) {
 
 func TestFormatter_FormatStatusEvent(t *testing.T) {
 	testCases := map[string]struct {
-		previewStrategy common.DryRunStrategy
+		dryRunStrategy  common.DryRunStrategy
 		event           event.StatusEvent
 		statusCollector list.Collector
 		expected        map[string]interface{}
 	}{
 		"resource update with Current status": {
-			previewStrategy: common.DryRunNone,
+			dryRunStrategy: common.DryRunNone,
 			event: event.StatusEvent{
 				Type: event.StatusEventResourceUpdate,
 				Resource: &pollevent.ResourceStatus{
@@ -199,7 +199,7 @@ func TestFormatter_FormatStatusEvent(t *testing.T) {
 	for tn, tc := range testCases {
 		t.Run(tn, func(t *testing.T) {
 			ioStreams, _, out, _ := genericclioptions.NewTestIOStreams() //nolint:dogsled
-			formatter := NewFormatter(ioStreams, tc.previewStrategy)
+			formatter := NewFormatter(ioStreams, tc.dryRunStrategy)
 			err := formatter.FormatStatusEvent(tc.event, tc.statusCollector)
 			assert.NoError(t, err)
 
@@ -210,13 +210,13 @@ func TestFormatter_FormatStatusEvent(t *testing.T) {
 
 func TestFormatter_FormatPruneEvent(t *testing.T) {
 	testCases := map[string]struct {
-		previewStrategy common.DryRunStrategy
-		event           event.PruneEvent
-		pruneStats      *list.PruneStats
-		expected        map[string]interface{}
+		dryRunStrategy common.DryRunStrategy
+		event          event.PruneEvent
+		pruneStats     *list.PruneStats
+		expected       map[string]interface{}
 	}{
 		"resource pruned without dryrun": {
-			previewStrategy: common.DryRunNone,
+			dryRunStrategy: common.DryRunNone,
 			event: event.PruneEvent{
 				Operation:  event.Pruned,
 				Type:       event.PruneEventResourceUpdate,
@@ -234,7 +234,7 @@ func TestFormatter_FormatPruneEvent(t *testing.T) {
 			},
 		},
 		"resource skipped with client dryrun": {
-			previewStrategy: common.DryRunClient,
+			dryRunStrategy: common.DryRunClient,
 			event: event.PruneEvent{
 				Operation:  event.PruneSkipped,
 				Type:       event.PruneEventResourceUpdate,
@@ -252,7 +252,7 @@ func TestFormatter_FormatPruneEvent(t *testing.T) {
 			},
 		},
 		"prune event with completed status": {
-			previewStrategy: common.DryRunNone,
+			dryRunStrategy: common.DryRunNone,
 			event: event.PruneEvent{
 				Type: event.PruneEventCompleted,
 			},
@@ -273,7 +273,7 @@ func TestFormatter_FormatPruneEvent(t *testing.T) {
 	for tn, tc := range testCases {
 		t.Run(tn, func(t *testing.T) {
 			ioStreams, _, out, _ := genericclioptions.NewTestIOStreams() //nolint:dogsled
-			formatter := NewFormatter(ioStreams, tc.previewStrategy)
+			formatter := NewFormatter(ioStreams, tc.dryRunStrategy)
 			err := formatter.FormatPruneEvent(tc.event, tc.pruneStats)
 			assert.NoError(t, err)
 
@@ -284,14 +284,14 @@ func TestFormatter_FormatPruneEvent(t *testing.T) {
 
 func TestFormatter_FormatDeleteEvent(t *testing.T) {
 	testCases := map[string]struct {
-		previewStrategy common.DryRunStrategy
+		dryRunStrategy  common.DryRunStrategy
 		event           event.DeleteEvent
 		deleteStats     *list.DeleteStats
 		statusCollector list.Collector
 		expected        map[string]interface{}
 	}{
 		"resource deleted without no dryrun": {
-			previewStrategy: common.DryRunNone,
+			dryRunStrategy: common.DryRunNone,
 			event: event.DeleteEvent{
 				Operation:  event.Deleted,
 				Type:       event.DeleteEventResourceUpdate,
@@ -309,7 +309,7 @@ func TestFormatter_FormatDeleteEvent(t *testing.T) {
 			},
 		},
 		"resource skipped with client dryrun": {
-			previewStrategy: common.DryRunClient,
+			dryRunStrategy: common.DryRunClient,
 			event: event.DeleteEvent{
 				Operation:  event.DeleteSkipped,
 				Type:       event.DeleteEventResourceUpdate,
@@ -327,7 +327,7 @@ func TestFormatter_FormatDeleteEvent(t *testing.T) {
 			},
 		},
 		"delete event with completed status": {
-			previewStrategy: common.DryRunNone,
+			dryRunStrategy: common.DryRunNone,
 			event: event.DeleteEvent{
 				Type: event.DeleteEventCompleted,
 			},
@@ -348,7 +348,7 @@ func TestFormatter_FormatDeleteEvent(t *testing.T) {
 	for tn, tc := range testCases {
 		t.Run(tn, func(t *testing.T) {
 			ioStreams, _, out, _ := genericclioptions.NewTestIOStreams() //nolint:dogsled
-			formatter := NewFormatter(ioStreams, tc.previewStrategy)
+			formatter := NewFormatter(ioStreams, tc.dryRunStrategy)
 			err := formatter.FormatDeleteEvent(tc.event, tc.deleteStats)
 			assert.NoError(t, err)