Repository: grafana/tanka
Pull request #592 (2021-08-06 01:21:48)

Add `tk lint` command
Like `fmt`, this takes a list of dirs/files as argument and evaluates them with the go-jsonnet lint utility
The linting results are directly printed out to stderr
If there are any linting errors, the command exit with status code 1
Closes https://github.com/grafana/tanka/issues/342

julienduchesne: There is still an issue with panics in go-jsonnet: https://github.com/google/go-jsonnet/issues/544 but this is ready to review as far as the tanka side goes. When it doesn't encounter the panic (in specific cases), it works well
@sbarzowski, this may interest you also
sbarzowski: It's nice to see `tk lint` coming along :-).

Regarding the panic, it's close to the top of my list now.
stale[bot]: This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

julienduchesne: Marked as keepalive and draft while we wait for go-jsonnet's next release

Patch:
diff --git a/.drone/drone.jsonnet b/.drone/drone.jsonnet
index 3270f80e..a7cc6954 100644
--- a/.drone/drone.jsonnet
+++ b/.drone/drone.jsonnet
@@ -1,6 +1,6 @@
 local vault = import 'vault.libsonnet';
 
-local golang = 'golang:1.17';
+local golang = 'golang:1.18';
 
 local volumes = [{ name: 'gopath', temp: {} }];
 local mounts = [{ name: 'gopath', path: '/go' }];
@@ -63,6 +63,7 @@ local docker(arch) = pipeline('docker-' + arch) {
       go('download', ['go mod download']),
       make('lint') { depends_on: ['download'] },
       make('test') { depends_on: ['download'] },
+      make('cross') { name: 'build', depends_on: ['download'] },
     ],
   } + constraints.always,
 
diff --git a/.drone/drone.yml b/.drone/drone.yml
index df7e4e38..2b60b66a 100644
--- a/.drone/drone.yml
+++ b/.drone/drone.yml
@@ -4,7 +4,7 @@ name: check
 steps:
 - commands:
   - go mod download
-  image: golang:1.17
+  image: golang:1.18
   name: download
   volumes:
   - name: gopath
@@ -13,7 +13,7 @@ steps:
   - make lint
   depends_on:
   - download
-  image: golang:1.17
+  image: golang:1.18
   name: lint
   volumes:
   - name: gopath
@@ -22,11 +22,20 @@ steps:
   - make test
   depends_on:
   - download
-  image: golang:1.17
+  image: golang:1.18
   name: test
   volumes:
   - name: gopath
     path: /go
+- commands:
+  - make cross
+  depends_on:
+  - download
+  image: golang:1.18
+  name: build
+  volumes:
+  - name: gopath
+    path: /go
 volumes:
 - name: gopath
   temp: {}
@@ -38,14 +47,14 @@ name: release
 steps:
 - commands:
   - git fetch origin --tags
-  image: golang:1.17
+  image: golang:1.18
   name: fetch-tags
   volumes:
   - name: gopath
     path: /go
 - commands:
   - make cross
-  image: golang:1.17
+  image: golang:1.18
   name: cross
   volumes:
   - name: gopath
@@ -96,14 +105,14 @@ platform:
 steps:
 - commands:
   - git fetch origin --tags
-  image: golang:1.17
+  image: golang:1.18
   name: fetch-tags
   volumes:
   - name: gopath
     path: /go
 - commands:
   - make static
-  image: golang:1.17
+  image: golang:1.18
   name: static
   volumes:
   - name: gopath
@@ -137,14 +146,14 @@ platform:
 steps:
 - commands:
   - git fetch origin --tags
-  image: golang:1.17
+  image: golang:1.18
   name: fetch-tags
   volumes:
   - name: gopath
     path: /go
 - commands:
   - make static
-  image: golang:1.17
+  image: golang:1.18
   name: static
   volumes:
   - name: gopath
@@ -212,6 +221,6 @@ kind: secret
 name: dockerhub_password
 ---
 kind: signature
-hmac: 63407a11388174089d6101453901ecc1aaafbe8a87a075237001f608d612c120
+hmac: ff2f11c8d385d1b0f6a4f0d3d1a25193119d8ac8c950dc7492dfb1aed156aa45
 
 ...
diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml
new file mode 100644
index 00000000..6a493a95
--- /dev/null
+++ b/.pre-commit-hooks.yaml
@@ -0,0 +1,8 @@
+- id: tanka-format
+  name: tkfmt
+  description: Automatically format jsonnet files.
+  entry: tk
+  args: [fmt]
+  language: golang
+  files: \.(jsonnet|libsonnet)$
+  minimum_pre_commit_version: 2.10.1
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 580ab68a..e94fe3ee 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,36 @@
 # Changelog
 
+## 0.21.0 (2022-04-28)
+
+### Features
+
+- **cli**: Add Apple Silicon binary
+  **[#685](https://github.com/grafana/tanka/pull/685)** (**BeyondEvil**)
+- **tanka/cli**: Add server-side apply mode
+  **[#651](https://github.com/grafana/tanka/pull/651)** (**smuth4**)
+
+### Bug Fixes/Enhancements
+
+- **cli**: Remove backticks from -inject-labels flag desc
+  **[#688](https://github.com/grafana/tanka/pull/688)** (**colega**)
+- **tanka**: Fix target must be a non-nil pointer
+  **[#684](https://github.com/grafana/tanka/pull/684)** (**maoueh**)
+- **tanka**: Upgrade to Go 1.18 + Upgrade dependencies
+  **[#697](https://github.com/grafana/tanka/pull/697)**
+
+## 0.20.0 (2022-02-01)
+
+### Features
+
+- **jsonnet**: Update `go-jsonnet` to version 0.18.0
+  **[#660](https://github.com/grafana/tanka/pull/660)**
+- **cli**: Add `--dry-run` kubectl option
+  **[#667](https://github.com/grafana/tanka/pull/667)**
+- **helm**: Add option to pass `--skip-tests`
+  **[#654](https://github.com/grafana/tanka/pull/654)** (**jouve**)
+- **export**: Introduce a configurable memory ballast
+  **[#669](https://github.com/grafana/tanka/pull/669)**
+
 ## 0.19.0 (2021-11-22)
 
 ### Notice
diff --git a/GOVERNANCE.md b/GOVERNANCE.md
index 06ea806a..0110830e 100644
--- a/GOVERNANCE.md
+++ b/GOVERNANCE.md
@@ -149,7 +149,7 @@ The ex-member is
 If needed, we reserve the right to publicly announce removal.
 
 [announce]: https://groups.google.com/forum/#!forum/tanka-announce
-[coc]: https://github.com/grafana/grafana/blob/master/CODE_OF_CONDUCT.md
+[coc]: https://github.com/grafana/grafana/blob/main/CODE_OF_CONDUCT.md
 [devs]: https://groups.google.com/forum/#!forum/tanka-developers
 [maintainers]: https://github.com/grafana/tanka/blob/main/MAINTAINERS.md
 [rough]: https://tools.ietf.org/html/rfc7282
diff --git a/Makefile b/Makefile
index 4a903d76..6fc24e7d 100644
--- a/Makefile
+++ b/Makefile
@@ -26,8 +26,10 @@ uninstall:
 
 $(GOX):
 	go get -u github.com/mitchellh/gox
+	go install github.com/mitchellh/gox
+
 cross: $(GOX)
-	CGO_ENABLED=0 gox -output="dist/{{.Dir}}-{{.OS}}-{{.Arch}}" -ldflags=${LDFLAGS} -arch="amd64 arm64 arm" -os="linux" -osarch="darwin/amd64" -osarch="windows/amd64" ./cmd/tk
+	CGO_ENABLED=0 $(BIN_DIR)/gox -output="dist/{{.Dir}}-{{.OS}}-{{.Arch}}" -ldflags=${LDFLAGS} -arch="amd64 arm64 arm" -os="linux" -osarch="darwin/amd64" -osarch="darwin/arm64" -osarch="windows/amd64" ./cmd/tk
 
 # Docker container
 container: static
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 00000000..b8697c16
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,20 @@
+# Reporting security issues
+
+If you think you have found a security vulnerability, please send a report to [security@grafana.com](mailto:security@grafana.com). This address can be used for all of Grafana Labs's open source and commercial products (including but not limited to Grafana, Grafana Cloud, Grafana Enterprise, and grafana.com). We can accept only vulnerability reports at this address.
+
+Please encrypt your message to us; please use our PGP key. The key fingerprint is:
+
+F988 7BEA 027A 049F AE8E 5CAA D125 8932 BE24 C5CA
+
+The key is available from [keyserver.ubuntu.com](https://keyserver.ubuntu.com/pks/lookup?search=0xF9887BEA027A049FAE8E5CAAD1258932BE24C5CA&fingerprint=on&op=index).
+
+Grafana Labs will send you a response indicating the next steps in handling your report. After the initial reply to your report, the security team will keep you informed of the progress towards a fix and full announcement, and may ask for additional information or guidance.
+
+**Important:** We ask you to not disclose the vulnerability before it have been fixed and announced, unless you received a response from the Grafana Labs security team that you can do so.
+
+## Security announcements
+
+We maintain a category on the community site called [Security Announcements](https://community.grafana.com/c/support/security-announcements),
+where we will post a summary, remediation, and mitigation details for any patch containing security fixes.
+
+You can also subscribe to email updates to this category if you have a grafana.com account and sign on to the community site or track updates via an [RSS feed](https://community.grafana.com/c/support/security-announcements.rss).
diff --git a/cmd/tk/export.go b/cmd/tk/export.go
index 1b7460aa..68ece5d8 100644
--- a/cmd/tk/export.go
+++ b/cmd/tk/export.go
@@ -2,7 +2,9 @@ package main
 
 import (
 	"fmt"
+	"log"
 	"regexp"
+	"runtime"
 
 	"github.com/go-clix/cli"
 
@@ -38,6 +40,11 @@ func exportCmd() *cli.Command {
 	cachePath := cmd.Flags().StringP("cache-path", "c", "", "Local file path where cached evaluations should be stored")
 	cacheEnvs := cmd.Flags().StringArrayP("cache-envs", "e", nil, "Regexes which define which environment should be cached (if caching is enabled)")
 
+	ballastBytes := cmd.Flags().Int("mem-ballast-size-bytes", 0, "(Experimental) Size of memory ballast to allocate.")
+	if err := cmd.Flags().MarkHidden("mem-ballast-size-bytes"); err != nil {
+		log.Fatalf("Could not mark flag as hidden: %s", err)
+	}
+
 	vars := workflowFlags(cmd.Flags())
 	getJsonnetOpts := jsonnetFlags(cmd.Flags())
 	getLabelSelector := labelSelectorFlag(cmd.Flags())
@@ -45,6 +52,10 @@ func exportCmd() *cli.Command {
 	recursive := cmd.Flags().BoolP("recursive", "r", false, "Look recursively for Tanka environments")
 
 	cmd.Run = func(cmd *cli.Command, args []string) error {
+		// Allocate a block of memory to alter GC behaviour. See https://github.com/golang/go/issues/23044
+		ballast := make([]byte, *ballastBytes)
+		defer runtime.KeepAlive(ballast)
+
 		filters, err := process.StrExps(vars.targets...)
 		if err != nil {
 			return err
diff --git a/cmd/tk/flags.go b/cmd/tk/flags.go
index 40c26e55..8b4e4b91 100644
--- a/cmd/tk/flags.go
+++ b/cmd/tk/flags.go
@@ -92,5 +92,5 @@ func envSettingsFlags(env *v1alpha1.Environment, fs *pflag.FlagSet) {
 	fs.StringVar(&env.Spec.APIServer, "server-from-context", env.Spec.APIServer, "set the server to a known one from $KUBECONFIG")
 	fs.StringVar(&env.Spec.Namespace, "namespace", env.Spec.Namespace, "namespace to create objects in")
 	fs.StringVar(&env.Spec.DiffStrategy, "diff-strategy", env.Spec.DiffStrategy, "specify diff-strategy. Automatically detected otherwise.")
-	fs.BoolVar(&env.Spec.InjectLabels, "inject-labels", env.Spec.InjectLabels, "add tanka environment label to each created resource. Required for `tk prune`.")
+	fs.BoolVar(&env.Spec.InjectLabels, "inject-labels", env.Spec.InjectLabels, "add tanka environment label to each created resource. Required for 'tk prune'.")
 }
diff --git a/cmd/tk/lint.go b/cmd/tk/lint.go
new file mode 100644
index 00000000..3c435f04
--- /dev/null
+++ b/cmd/tk/lint.go
@@ -0,0 +1,46 @@
+package main
+
+import (
+	"errors"
+
+	"github.com/go-clix/cli"
+	"github.com/gobwas/glob"
+	"github.com/posener/complete"
+
+	"github.com/grafana/tanka/pkg/jsonnet"
+)
+
+func lintCmd() *cli.Command {
+	cmd := &cli.Command{
+		Use:   "lint <FILES|DIRECTORIES>",
+		Short: "lint Jsonnet code",
+		Args: cli.Args{
+			Validator: cli.ValidateFunc(func(args []string) error {
+				if len(args) == 0 {
+					return errors.New("at least one file or directory is required")
+				}
+				return nil
+			}),
+			Predictor: complete.PredictFiles("*.*sonnet"),
+		},
+	}
+
+	exclude := cmd.Flags().StringSliceP("exclude", "e", []string{"**/.*", ".*", "**/vendor/**", "vendor/**"}, "globs to exclude")
+	parallelism := cmd.Flags().IntP("parallelism", "n", 4, "amount of workers")
+	verbose := cmd.Flags().BoolP("verbose", "v", false, "print each checked file")
+
+	cmd.Run = func(cmd *cli.Command, args []string) error {
+		globs := make([]glob.Glob, len(*exclude))
+		for i, e := range *exclude {
+			g, err := glob.Compile(e)
+			if err != nil {
+				return err
+			}
+			globs[i] = g
+		}
+
+		return jsonnet.Lint(args, &jsonnet.LintOpts{Excludes: globs, PrintNames: *verbose, Parallelism: *parallelism})
+	}
+
+	return cmd
+}
diff --git a/cmd/tk/main.go b/cmd/tk/main.go
index 52cf28ad..c8ff31e7 100644
--- a/cmd/tk/main.go
+++ b/cmd/tk/main.go
@@ -44,6 +44,7 @@ func main() {
 	// jsonnet commands
 	rootCmd.AddCommand(
 		fmtCmd(),
+		lintCmd(),
 		evalCmd(),
 		initCmd(),
 		toolCmd(),
diff --git a/cmd/tk/tool.go b/cmd/tk/tool.go
index a4043906..a5ab070f 100644
--- a/cmd/tk/tool.go
+++ b/cmd/tk/tool.go
@@ -46,7 +46,7 @@ func jpathCmd() *cli.Command {
 			return fmt.Errorf("Resolving JPATH: %s", err)
 		}
 
-		jsonnetpath, base, root, err := jpath.Resolve(entrypoint)
+		jsonnetpath, base, root, err := jpath.Resolve(entrypoint, false)
 		if err != nil {
 			return fmt.Errorf("Resolving JPATH: %s", err)
 		}
diff --git a/cmd/tk/workflow.go b/cmd/tk/workflow.go
index f36546c4..07216492 100644
--- a/cmd/tk/workflow.go
+++ b/cmd/tk/workflow.go
@@ -21,22 +21,42 @@ const (
 	ExitStatusDiff = 16
 )
 
+func validateDryRun(dryRunStr string) error {
+	switch dryRunStr {
+	case "", "none", "client", "server":
+		return nil
+	}
+	return fmt.Errorf(`--dry-run must be either: "", "none", "server" or "client"`)
+}
+
 func applyCmd() *cli.Command {
 	cmd := &cli.Command{
 		Use:   "apply <path>",
 		Short: "apply the configuration to the cluster",
 		Args:  workflowArgs,
+		Predictors: complete.Flags{
+			"diff-strategy":  cli.PredictSet("native", "subset", "validate", "server"),
+			"apply-strategy": cli.PredictSet("client", "server"),
+		},
 	}
 
 	var opts tanka.ApplyOpts
 	cmd.Flags().BoolVar(&opts.Force, "force", false, "force applying (kubectl apply --force)")
 	cmd.Flags().BoolVar(&opts.Validate, "validate", true, "validation of resources (kubectl --validate=false)")
 	cmd.Flags().BoolVar(&opts.AutoApprove, "dangerous-auto-approve", false, "skip interactive approval. Only for automation!")
+	cmd.Flags().StringVar(&opts.DryRun, "dry-run", "", `--dry-run parameter to pass down to kubectl, must be "none", "server", or "client"`)
+	cmd.Flags().StringVar(&opts.ApplyStrategy, "apply-strategy", "", "force the apply strategy to use. Automatically chosen if not set.")
+	cmd.Flags().StringVar(&opts.DiffStrategy, "diff-strategy", "", "force the diff strategy to use. Automatically chosen if not set.")
 
 	vars := workflowFlags(cmd.Flags())
 	getJsonnetOpts := jsonnetFlags(cmd.Flags())
 
 	cmd.Run = func(cmd *cli.Command, args []string) error {
+		err := validateDryRun(opts.DryRun)
+		if err != nil {
+			return err
+		}
+
 		filters, err := process.StrExps(vars.targets...)
 		if err != nil {
 			return err
@@ -58,12 +78,18 @@ func pruneCmd() *cli.Command {
 	}
 
 	var opts tanka.PruneOpts
+	cmd.Flags().StringVar(&opts.DryRun, "dry-run", "", `--dry-run parameter to pass down to kubectl, must be "none", "server", or "client"`)
 	cmd.Flags().BoolVar(&opts.Force, "force", false, "force deleting (kubectl delete --force)")
 	cmd.Flags().BoolVar(&opts.AutoApprove, "dangerous-auto-approve", false, "skip interactive approval. Only for automation!")
 	cmd.Flags().StringVar(&opts.Name, "name", "", "string that only a single inline environment contains in its name")
 	getJsonnetOpts := jsonnetFlags(cmd.Flags())
 
 	cmd.Run = func(cmd *cli.Command, args []string) error {
+		err := validateDryRun(opts.DryRun)
+		if err != nil {
+			return err
+		}
+
 		opts.JsonnetOpts = getJsonnetOpts()
 
 		return tanka.Prune(args[0], opts)
@@ -80,6 +106,7 @@ func deleteCmd() *cli.Command {
 	}
 
 	var opts tanka.DeleteOpts
+	cmd.Flags().StringVar(&opts.DryRun, "dry-run", "", `--dry-run parameter to pass down to kubectl, must be "none", "server", or "client"`)
 	cmd.Flags().BoolVar(&opts.Force, "force", false, "force deleting (kubectl delete --force)")
 	cmd.Flags().BoolVar(&opts.Validate, "validate", true, "validation of resources (kubectl --validate=false)")
 	cmd.Flags().BoolVar(&opts.AutoApprove, "dangerous-auto-approve", false, "skip interactive approval. Only for automation!")
@@ -88,6 +115,11 @@ func deleteCmd() *cli.Command {
 	getJsonnetOpts := jsonnetFlags(cmd.Flags())
 
 	cmd.Run = func(cmd *cli.Command, args []string) error {
+		err := validateDryRun(opts.DryRun)
+		if err != nil {
+			return err
+		}
+
 		filters, err := process.StrExps(vars.targets...)
 		if err != nil {
 			return err
@@ -107,7 +139,7 @@ func diffCmd() *cli.Command {
 		Short: "differences between the configuration and the cluster",
 		Args:  workflowArgs,
 		Predictors: complete.Flags{
-			"diff-strategy": cli.PredictSet("native", "subset", "validate"),
+			"diff-strategy": cli.PredictSet("native", "subset", "validate", "server"),
 		},
 	}
 
diff --git a/docs/docs/config.md b/docs/docs/config.md
index 86f33f35..0efaf25d 100644
--- a/docs/docs/config.md
+++ b/docs/docs/config.md
@@ -33,6 +33,10 @@ Tanka's behavior can be customized per Environment using a file called `spec.jso
     // Must be the full URL, e.g. https://cluster.fqdn:6443
     "apiServer": "<url>",
 
+    // The Kubernetes context name(s) to use.
+    // This field supports regular expressions and is mutually exclusive with apiServer field.
+    "contextNames": ["<string>"],
+    
     // Default namespace for objects that don't explicitely specify one
     "namespace": "<string>" | default = "default",
 
diff --git a/docs/docs/diff-strategy.md b/docs/docs/diff-strategy.md
index f2bcfd70..2c07e7df 100644
--- a/docs/docs/diff-strategy.md
+++ b/docs/docs/diff-strategy.md
@@ -24,6 +24,9 @@ tk diff --diff-strategy=native .
 # validate: Like native but with a server-side validation
 tk diff --diff-strategy=validate .
 
+# server-side
+tk diff --diff-strategy=server .
+
 # subset
 tk diff --diff-strategy=subset .
 ```
@@ -45,6 +48,19 @@ There is a
 [known issue](known-issues.md#unexpected-diff-if-the-same-port-number-is-used-for-udp-and-tcp)
 with `kubectl diff`, which affects ports configured to use both TCP and UDP.
 
+### Server-side diffs
+
+There are two additional modes which extend `native`: `validate` and `server`.
+While all `kubectl diff` commands are sent to the API server, these two
+methods take advantage of an additional server-side diff mode (which uses the
+`kubectl diff --server-side` flag, complementing the
+[server-side apply](/server-side-apply) mode).
+
+Since a plain `server` diff often produces cruft, and wouldn't be representative
+of a client-side apply, the `validate` method allows the server-side diff to
+check that all models are valid server-side, but still displays the `native`
+diff output to the user.
+
 ## Subset
 
 If native diffing is not supported by your cluster, Tanka provides subset diff
diff --git a/docs/docs/jsonnet/native-functions.md b/docs/docs/jsonnet/native-functions.md
index 143f3c38..2f75fc5e 100644
--- a/docs/docs/jsonnet/native-functions.md
+++ b/docs/docs/jsonnet/native-functions.md
@@ -23,10 +23,10 @@ To use them in your code, you need to access them using `std.native` from the st
 ### Signature
 
 ```ts
-parseJSON(string json) Object
+parseJson(string json) Object
 ```
 
-`parseJSON` parses a json string and returns the respective Jsonnet type (`Object`, `Array`,  etc).
+`parseJson` parses a json string and returns the respective Jsonnet type (`Object`, `Array`,  etc).
 
 ### Examples
 
@@ -53,10 +53,10 @@ Evaluating with Tanka results in the JSON:
 ### Signature
 
 ```ts
-parseYAML(string yaml) []Object
+parseYaml(string yaml) []Object
 ```
 
-`parseYAML` wraps `yaml.Unmarshal` to convert a string of yaml document(s) into
+`parseYaml` wraps `yaml.Unmarshal` to convert a string of yaml document(s) into
 a set of dicts. If `yaml` only contains a single document, a single value array
 will be returned.
 
@@ -93,10 +93,10 @@ Evaluating with Tanka results in the JSON:
 ### Signature
 
 ```ts
-manifestJSONFromJSON(string json, int indent) string
+manifestJsonFromJson(string json, int indent) string
 ```
 
-`manifestJSONFromJSON` reserializes JSON and allows to change the indentation.
+`manifestJsonFromJson` reserializes JSON and allows to change the indentation.
 
 ### Examples
 
@@ -119,10 +119,10 @@ Evaluating with Tanka results in the JSON:
 ### Signature
 
 ```ts
-manifestYAMLFromJSON(string json) string
+manifestYamlFromJson(string json) string
 ```
 
-`manifestYamlFromJSON` serializes a JSON string as a YAML document.
+`manifestYamlFromJson` serializes a JSON string as a YAML document.
 
 ### Examples
 
diff --git a/docs/docs/namespaces.md b/docs/docs/namespaces.md
index e642f076..43b1db75 100644
--- a/docs/docs/namespaces.md
+++ b/docs/docs/namespaces.md
@@ -33,7 +33,7 @@ legit cases where it's handy to have them span multiple namespaces, for example:
 Some resources in Kubernetes are cluster-wide, meaning they don't belong to a single namespace at all.
 
 Tanka will make an attempt to not add namespaces to _known_ cluster-wide types.
-It does this with a short list of types in [the source code](https://github.com/grafana/tanka/blob/master/pkg/process/namespace.go).
+It does this with a short list of types in [the source code](https://github.com/grafana/tanka/blob/main/pkg/process/namespace.go).
 
 Tanka cannot feasibly maintain this list for all known custom resource types. In those cases, resources will have namespaces added to their manifests,
 and kubectl should happily apply them as non-namespaced resources.
diff --git a/docs/docs/server-side-apply.md b/docs/docs/server-side-apply.md
new file mode 100644
index 00000000..44662991
--- /dev/null
+++ b/docs/docs/server-side-apply.md
@@ -0,0 +1,40 @@
+---
+name: "Server-Side Apply"
+route: "/server-side-apply"
+menu: Advanced features
+---
+
+# Server-Side Apply
+
+Tanka supports
+[server-side apply](https://kubernetes.io/docs/reference/using-api/server-side-apply/),
+which requires at least Kubernetes 1.16+, and was promoted to stable status in 1.22.
+
+To enable server-side diff in tanka, add the following field to `spec.json`:
+
+```diff
+{
+  "spec": {
++    "applyStrategy": "server",
+  }
+}
+```
+
+This also has the effect of changing the default [diff strategy](/diff-strategy)
+to `server`, but this can be overridden via command line flags or `spec.json`.
+
+While server-side apply doesn't have any effect on the resources being applied
+and is intended to be a general in-place upgrade to client-side apply, there are
+differences in how fields are managed that can make converting existing cluster
+resources a non-trival change.
+
+Identifying and fixing these changes are beyond the scope of this guide, but
+many can be found before an apply by using the `validate` or `server`
+[diff strategy](/diff-strategy).
+
+## Field conflicts
+
+As part of the changes, you may encounter error messages which
+recommend the use of the `--force-conflicts` flag. Using `tk apply --force`
+in server-side mode will enable that flag for kubectl instead of
+`kubectl --force`, which no longer has any effect in server-side mode.
diff --git a/docs/doczrc.js b/docs/doczrc.js
index 37d36426..4ca3936d 100644
--- a/docs/doczrc.js
+++ b/docs/doczrc.js
@@ -4,6 +4,7 @@ export default {
 
   public: "/static",
   ignore: ["design/**", ".vscodeext/**"],
+  editBranch: "main",
 
   themeConfig: {
     showDarkModeSwitch: false,
diff --git a/go.mod b/go.mod
index d1e1ed2e..3afef4c0 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,6 @@
 module github.com/grafana/tanka
 
-go 1.17
+go 1.18
 
 require (
 	github.com/Masterminds/semver v1.5.0
@@ -9,19 +9,19 @@ require (
 	github.com/fatih/structs v1.1.0
 	github.com/go-clix/cli v0.2.0
 	github.com/gobwas/glob v0.2.3
-	github.com/google/go-cmp v0.5.6
+	github.com/google/go-cmp v0.5.8
 	github.com/google/go-jsonnet v0.18.0
 	github.com/karrick/godirwalk v1.16.1
 	github.com/pkg/errors v0.9.1
 	github.com/posener/complete v1.2.3
 	github.com/spf13/pflag v1.0.5
 	github.com/stretchr/objx v0.3.0
-	github.com/stretchr/testify v1.7.0
-	github.com/thoas/go-funk v0.9.1
-	golang.org/x/crypto v0.0.0-20210921155107-089bfa567519
+	github.com/stretchr/testify v1.7.1
+	github.com/thoas/go-funk v0.9.2
+	golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4
 	gopkg.in/yaml.v2 v2.4.0
 	gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b
-	k8s.io/apimachinery v0.22.2
+	k8s.io/apimachinery v0.23.6
 	sigs.k8s.io/yaml v1.3.0
 )
 
@@ -45,4 +45,5 @@ require (
 	golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e // indirect
 	golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect
 	k8s.io/klog/v2 v2.40.1 // indirect
+	k8s.io/utils v0.0.0-20211116205334-6203023598ed // indirect
 )
diff --git a/go.sum b/go.sum
index f444cec8..c7920c07 100644
--- a/go.sum
+++ b/go.sum
@@ -23,7 +23,7 @@ github.com/elazarl/goproxy v0.0.0-20180725130230-947c36da3153/go.mod h1:/Zj4wYkg
 github.com/emicklei/go-restful v0.0.0-20170410110728-ff4f55a20633/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs=
 github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
 github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
-github.com/evanphx/json-patch v4.11.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
+github.com/evanphx/json-patch v4.12.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
 github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM=
 github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
 github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
@@ -31,14 +31,17 @@ github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
 github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
 github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
 github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
+github.com/getkin/kin-openapi v0.76.0/go.mod h1:660oXbgy5JFMKreazJaQTw7o+X00qeSyhcnluiMv+Xg=
+github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
 github.com/go-clix/cli v0.2.0 h1:rqpcyS/cvshOhXkwii0V+7nWetDVC8cp4pKI7JiCIS8=
 github.com/go-clix/cli v0.2.0/go.mod h1:yWI9abpv187r47lDjz8Z9TWev93aUTWaW2seSb5JmPQ=
 github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas=
-github.com/go-logr/logr v0.4.0/go.mod h1:z6/tIYblkpsD+a4lm/fGIIU9mZ+XfAiaFtq7xTgseGU=
+github.com/go-logr/logr v0.2.0/go.mod h1:z6/tIYblkpsD+a4lm/fGIIU9mZ+XfAiaFtq7xTgseGU=
 github.com/go-logr/logr v1.2.0/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
 github.com/go-logr/logr v1.2.2 h1:ahHml/yUpnlb96Rp8HCvtYVPY8ZYpxq3g7UYchIYwbs=
 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
 github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
+github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
 github.com/go-openapi/jsonreference v0.19.3/go.mod h1:rjx6GuL8TTa9VaixXglHmQmIL98+wF9xc8zWvFonSJ8=
 github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
 github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
@@ -62,8 +65,8 @@ github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw
 github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
 github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ=
-github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
+github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 github.com/google/go-jsonnet v0.18.0 h1:/6pTy6g+Jh1a1I2UMoAODkqELFiVIdOxbNwv0DDzoOg=
 github.com/google/go-jsonnet v0.18.0/go.mod h1:C3fTzyVJDslXdiTqw/bTFk7vSGyCtH3MGRbDfvEwGd0=
 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -74,6 +77,7 @@ github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
 github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/googleapis/gnostic v0.5.1/go.mod h1:6U4PtQXGIEt/Z3h5MAT7FNofLnw9vXk2cUuW7uA/OeU=
 github.com/googleapis/gnostic v0.5.5/go.mod h1:7+EbHbldMins07ALC74bsA81Ovc97DwqyJO1AENw9kA=
+github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
 github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
 github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
 github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
@@ -89,7 +93,7 @@ github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH
 github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU=
 github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
 github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
-github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
+github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
 github.com/karrick/godirwalk v1.16.1 h1:DynhcF+bztK8gooS0+NDJFrdNZjJ3gzVzC545UNA9iw=
 github.com/karrick/godirwalk v1.16.1/go.mod h1:j4mkqPuvaLI8mp1DroR3P6ad7cyYd4c1qeJ3RV7ULlk=
 github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
@@ -119,8 +123,8 @@ github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx
 github.com/moby/spdystream v0.2.0/go.mod h1:f7i0iNDQJ059oMTcWxx8MA/zKFIuD/lY+0GqbN2Wy8c=
 github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
-github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
 github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
+github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
 github.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
 github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw=
 github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
@@ -145,10 +149,10 @@ github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNX
 github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
 github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8=
 github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
+github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk=
 github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
 github.com/spf13/cast v1.4.1 h1:s0hze+J0196ZfEMTs80N7UlFt0BDuQ7Q+JDnHiMWKdA=
 github.com/spf13/cast v1.4.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
-github.com/spf13/pflag v0.0.0-20170130214245-9ff6c6923cff/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
 github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
 github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
 github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8=
@@ -159,24 +163,27 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf
 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
 github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
-github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/thoas/go-funk v0.9.1 h1:O549iLZqPpTUQ10ykd26sZhzD+rmR5pWhuElrhbC20M=
-github.com/thoas/go-funk v0.9.1/go.mod h1:+IWnUfUmFO1+WVYQWQtIJHeRRdaIyyYglZN7xzUPe4Q=
+github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/thoas/go-funk v0.9.2 h1:oKlNYv0AY5nyf9g+/GhMgS/UO2ces0QRdPKwkhY3VCk=
+github.com/thoas/go-funk v0.9.2/go.mod h1:+IWnUfUmFO1+WVYQWQtIJHeRRdaIyyYglZN7xzUPe4Q=
 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
 golang.org/x/crypto v0.0.0-20200414173820-0848c9571904/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
-golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 h1:7I4JAnoQBe7ZtJcBaYHi5UtiO8tQHbUSXxL+pnGRANg=
-golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4 h1:kUhD7nTDoI3fVd9G4ORWrbV5NY0liEs/Jg2pv5f+bBA=
+golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
 golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
 golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -186,17 +193,17 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn
 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
 golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
 golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
-golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
-golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
+golang.org/x/net v0.0.0-20211209124913-491a49abca63/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -210,10 +217,11 @@ golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e h1:fLOSk5Q00efkSvAm+4xcoXD+RRmLmmulPn5I3Y9F2EM=
 golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -223,19 +231,22 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
 golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
 golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20200505023115-26f46d2f7ef8/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
 golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
 golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
 google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@@ -257,6 +268,7 @@ google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpAD
 google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
 google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
+google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -279,16 +291,21 @@ gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v
 gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
-k8s.io/apimachinery v0.22.2 h1:ejz6y/zNma8clPVfNDLnPbleBo6MpoFy/HBiBqCouVk=
-k8s.io/apimachinery v0.22.2/go.mod h1:O3oNtNadZdeOMxHFVxOreoznohCpy0z6mocxbZr7oJ0=
-k8s.io/gengo v0.0.0-20200413195148-3a45101e95ac/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8IAqLxYwwyPxAX1Pzy0ii0=
+k8s.io/apimachinery v0.23.6 h1:RH1UweWJkWNTlFx0D8uxOpaU1tjIOvVVWV/bu5b3/NQ=
+k8s.io/apimachinery v0.23.6/go.mod h1:BEuFMMBaIbcOqVIJqNZJXGFTP4W6AycEpb5+m/97hrM=
+k8s.io/gengo v0.0.0-20210813121822-485abfe95c7c/go.mod h1:FiNAH4ZV3gBg2Kwh89tzAEV2be7d5xI0vBa/VySYy3E=
 k8s.io/klog/v2 v2.0.0/go.mod h1:PBfzABfn139FHAV07az/IF9Wp1bkk3vpT2XSJ76fSDE=
-k8s.io/klog/v2 v2.9.0/go.mod h1:hy9LJ/NvuK+iVyP4Ehqva4HxZG/oXyIS3n3Jmire4Ec=
+k8s.io/klog/v2 v2.2.0/go.mod h1:Od+F08eJP+W3HUb4pSrPpgp9DGU4GzlpG/TmITuYh/Y=
+k8s.io/klog/v2 v2.30.0/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0=
 k8s.io/klog/v2 v2.40.1 h1:P4RRucWk/lFOlDdkAr3mc7iWFkgKrZY9qZMAgek06S4=
 k8s.io/klog/v2 v2.40.1/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0=
-k8s.io/kube-openapi v0.0.0-20210421082810-95288971da7e/go.mod h1:vHXdDvt9+2spS2Rx9ql3I8tycm3H9FDfdUoIuKCefvw=
+k8s.io/kube-openapi v0.0.0-20211115234752-e816edb12b65/go.mod h1:sX9MT8g7NVZM5lVL/j8QyCCJe8YSMW30QvGZWaCIDIk=
+k8s.io/utils v0.0.0-20210802155522-efc7438f0176/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA=
+k8s.io/utils v0.0.0-20211116205334-6203023598ed h1:ck1fRPWPJWsMd8ZRFsWc6mh/zHp5fZ/shhbrgPUxDAE=
+k8s.io/utils v0.0.0-20211116205334-6203023598ed/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA=
+sigs.k8s.io/json v0.0.0-20211020170558-c049b76a60c6/go.mod h1:p4QtZmO4uMYipTQNzagwnNoseA6OxSUutVw05NhYDRs=
 sigs.k8s.io/structured-merge-diff/v4 v4.0.2/go.mod h1:bJZC9H9iH24zzfZ/41RGcq60oK1F7G282QMXDPYydCw=
-sigs.k8s.io/structured-merge-diff/v4 v4.1.2/go.mod h1:j/nl6xW8vLS49O8YvXW1ocPhZawJtm+Yrr7PPRQ0Vg4=
+sigs.k8s.io/structured-merge-diff/v4 v4.2.1/go.mod h1:j/nl6xW8vLS49O8YvXW1ocPhZawJtm+Yrr7PPRQ0Vg4=
 sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o=
 sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc=
 sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo=
diff --git a/pkg/jsonnet/eval.go b/pkg/jsonnet/eval.go
index 33e2eb0f..f532711d 100644
--- a/pkg/jsonnet/eval.go
+++ b/pkg/jsonnet/eval.go
@@ -132,7 +132,7 @@ func evaluateSnippet(evalFunc evalFunc, path, data string, opts Opts) (string, e
 	}
 
 	// Create VM
-	jpath, _, _, err := jpath.Resolve(path)
+	jpath, _, _, err := jpath.Resolve(path, false)
 	if err != nil {
 		return "", errors.Wrap(err, "resolving import paths")
 	}
diff --git a/pkg/jsonnet/files.go b/pkg/jsonnet/files.go
new file mode 100644
index 00000000..9b08cbb6
--- /dev/null
+++ b/pkg/jsonnet/files.go
@@ -0,0 +1,55 @@
+package jsonnet
+
+import (
+	"os"
+	"path/filepath"
+
+	"github.com/gobwas/glob"
+	"github.com/karrick/godirwalk"
+)
+
+// FindFiles takes a file / directory and finds all Jsonnet files
+func FindFiles(target string, excludes []glob.Glob) ([]string, error) {
+	// if it's a file, don't try to find children
+	fi, err := os.Stat(target)
+	if err != nil {
+		return nil, err
+	}
+	if fi.Mode().IsRegular() {
+		return []string{target}, nil
+	}
+
+	var files []string
+
+	// godirwalk is faster than filepath.Walk, 'cause no os.Stat required
+	err = godirwalk.Walk(target, &godirwalk.Options{
+		Callback: func(rawPath string, de *godirwalk.Dirent) error {
+			// Normalize slashes for Windows
+			path := filepath.ToSlash(rawPath)
+
+			if de.IsDir() {
+				return nil
+			}
+
+			// excluded?
+			for _, g := range excludes {
+				if g.Match(path) {
+					return nil
+				}
+			}
+
+			// only .jsonnet or .libsonnet
+			if ext := filepath.Ext(path); ext == ".jsonnet" || ext == ".libsonnet" {
+				files = append(files, path)
+			}
+			return nil
+		},
+		// faster, no sort required
+		Unsorted: true,
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	return files, nil
+}
diff --git a/pkg/jsonnet/imports.go b/pkg/jsonnet/imports.go
index 543fb95c..ecc975b8 100644
--- a/pkg/jsonnet/imports.go
+++ b/pkg/jsonnet/imports.go
@@ -40,7 +40,7 @@ func TransitiveImports(dir string) ([]string, error) {
 		return nil, errors.Wrap(err, "opening file")
 	}
 
-	jpath, _, rootDir, err := jpath.Resolve(dir)
+	jpath, _, rootDir, err := jpath.Resolve(dir, false)
 	if err != nil {
 		return nil, errors.Wrap(err, "resolving JPATH")
 	}
diff --git a/pkg/jsonnet/jpath/jpath.go b/pkg/jsonnet/jpath/jpath.go
index d0372dc2..96ca678b 100644
--- a/pkg/jsonnet/jpath/jpath.go
+++ b/pkg/jsonnet/jpath/jpath.go
@@ -14,14 +14,19 @@ const DEFAULT_ENTRYPOINT = "main.jsonnet"
 // It then constructs a jPath with the base directory, vendor/ and lib/.
 // This results in predictable imports, as it doesn't matter whether the user called
 // called the command further down tree or not. A little bit like git.
-func Resolve(path string) (jpath []string, base, root string, err error) {
+func Resolve(path string, allowMissingBase bool) (jpath []string, base, root string, err error) {
 	root, err = FindRoot(path)
 	if err != nil {
 		return nil, "", "", err
 	}
 
 	base, err = FindBase(path, root)
-	if err != nil {
+	if err != nil && allowMissingBase {
+		base, err = FsDir(path)
+		if err != nil {
+			return nil, "", "", err
+		}
+	} else if err != nil {
 		return nil, "", "", err
 	}
 
diff --git a/pkg/jsonnet/lint.go b/pkg/jsonnet/lint.go
new file mode 100644
index 00000000..bd8e63fa
--- /dev/null
+++ b/pkg/jsonnet/lint.go
@@ -0,0 +1,100 @@
+package jsonnet
+
+import (
+	"bytes"
+	"fmt"
+	"io/ioutil"
+	"path/filepath"
+
+	"github.com/gobwas/glob"
+	"github.com/google/go-jsonnet/linter"
+	"github.com/grafana/tanka/pkg/jsonnet/jpath"
+	"github.com/pkg/errors"
+)
+
+// LintOpts modifies the behaviour of Lint
+type LintOpts struct {
+	// Excludes are a list of globs to exclude files while searching for Jsonnet
+	// files
+	Excludes []glob.Glob
+
+	// PrintNames causes all filenames to be printed
+	PrintNames bool
+
+	// Parallelism determines the number of workers that will process files
+	Parallelism int
+}
+
+// Lint takes a list of files and directories, processes them and prints
+// out to stderr if there are linting warnings
+func Lint(fds []string, opts *LintOpts) error {
+
+	var paths []string
+	for _, f := range fds {
+		fs, err := FindFiles(f, opts.Excludes)
+		if err != nil {
+			return errors.Wrap(err, "finding Jsonnet files")
+		}
+		paths = append(paths, fs...)
+	}
+
+	type result struct {
+		failed bool
+		output string
+	}
+	fileCh := make(chan string, len(paths))
+	resultCh := make(chan result, len(paths))
+	lintWorker := func(fileCh <-chan string, resultCh chan result) {
+		for file := range fileCh {
+			buf := &bytes.Buffer{}
+			var err error
+			file, err = filepath.Abs(file)
+			if err != nil {
+				fmt.Fprintf(buf, "got an error getting the absolute path for %s: %v\n\n", file, err)
+				resultCh <- result{failed: true, output: buf.String()}
+				continue
+			}
+
+			if opts.PrintNames {
+				fmt.Printf("Linting %s...\n", file)
+			}
+
+			vm := MakeVM(Opts{})
+			jpaths, _, _, err := jpath.Resolve(file, true)
+			if err != nil {
+				fmt.Fprintf(buf, "got an error getting JPATH for %s: %v\n\n", file, err)
+				resultCh <- result{failed: true, output: buf.String()}
+				continue
+			}
+
+			vm.Importer(NewExtendedImporter(jpaths))
+
+			content, _ := ioutil.ReadFile(file)
+			failed := linter.LintSnippet(vm, buf, []linter.Snippet{{FileName: file, Code: string(content)}})
+			resultCh <- result{failed: failed, output: buf.String()}
+		}
+	}
+
+	for i := 0; i < opts.Parallelism; i++ {
+		go lintWorker(fileCh, resultCh)
+	}
+
+	for _, file := range paths {
+		fileCh <- file
+	}
+	close(fileCh)
+
+	lintingFailed := false
+	for i := 0; i < len(paths); i++ {
+		result := <-resultCh
+		lintingFailed = lintingFailed || result.failed
+		if result.output != "" {
+			fmt.Print(result.output)
+		}
+	}
+
+	if lintingFailed {
+		return errors.New("Linting has failed for at least one file")
+	}
+	return nil
+}
diff --git a/pkg/kubernetes/apply.go b/pkg/kubernetes/apply.go
index 3ff7cf99..dc57f4b7 100644
--- a/pkg/kubernetes/apply.go
+++ b/pkg/kubernetes/apply.go
@@ -75,8 +75,8 @@ See https://tanka.dev/garbage-collection for more details.`)
 			continue
 		}
 
-		// skip objects not created explicitely
-		if _, ok := m.Metadata().Annotations()[AnnotationLastApplied]; !ok {
+		// skip objects not created explicitly (e.g. pods created from deployments)
+		if !k.isKubectlCreated(m) {
 			continue
 		}
 
@@ -88,6 +88,21 @@ See https://tanka.dev/garbage-collection for more details.`)
 	return orphaned, nil
 }
 
+func (k *Kubernetes) isKubectlCreated(manifest manifest.Manifest) bool {
+	// Check if created by client-side apply
+	if _, ok := manifest.Metadata().Annotations()[AnnotationLastApplied]; ok {
+		return true
+	}
+	// Check if created by server-side apply
+	for _, manager := range manifest.Metadata().ManagedFields() {
+		manager_name := manager.(map[string]interface{})["manager"]
+		if manager_name == "tanka" || manager_name == "kubectl-client-side-apply" {
+			return true
+		}
+	}
+	return false
+}
+
 func (k *Kubernetes) uids(state manifest.List) (map[string]bool, error) {
 	uids := make(map[string]bool)
 
diff --git a/pkg/kubernetes/client/apply.go b/pkg/kubernetes/client/apply.go
index a166d84c..40825efd 100644
--- a/pkg/kubernetes/client/apply.go
+++ b/pkg/kubernetes/client/apply.go
@@ -1,24 +1,50 @@
 package client
 
 import (
+	"fmt"
 	"os"
+	"os/exec"
 	"strings"
 
+	"github.com/Masterminds/semver"
+
 	"github.com/grafana/tanka/pkg/kubernetes/manifest"
 )
 
-// Apply applies the given yaml to the cluster
-func (k Kubectl) Apply(data manifest.List, opts ApplyOpts) error {
+// Test-ability: isolate applyCtl to build and return exec.Cmd from ApplyOpts
+func (k Kubectl) applyCtl(data manifest.List, opts ApplyOpts) *exec.Cmd {
 	argv := []string{"-f", "-"}
+	serverSide := (opts.ApplyStrategy == "server")
+	if serverSide {
+		argv = append(argv, "--server-side")
+		if k.info.ClientVersion.GreaterThan(semver.MustParse("1.19.0")) {
+			argv = append(argv, "--field-manager=tanka")
+		}
+	}
 	if opts.Force {
-		argv = append(argv, "--force")
+		if serverSide {
+			argv = append(argv, "--force-conflicts")
+		} else {
+			argv = append(argv, "--force")
+		}
 	}
 
 	if !opts.Validate {
 		argv = append(argv, "--validate=false")
 	}
 
-	cmd := k.ctl("apply", argv...)
+	if opts.DryRun != "" {
+		dryRun := fmt.Sprintf("--dry-run=%s", opts.DryRun)
+		argv = append(argv, dryRun)
+	}
+
+	return k.ctl("apply", argv...)
+}
+
+// Apply applies the given yaml to the cluster
+func (k Kubectl) Apply(data manifest.List, opts ApplyOpts) error {
+	cmd := k.applyCtl(data, opts)
+
 	cmd.Stdout = os.Stdout
 	cmd.Stderr = os.Stderr
 
diff --git a/pkg/kubernetes/client/client.go b/pkg/kubernetes/client/client.go
index 28bec6ec..d8cb64d4 100644
--- a/pkg/kubernetes/client/client.go
+++ b/pkg/kubernetes/client/client.go
@@ -50,6 +50,12 @@ type ApplyOpts struct {
 
 	// autoApprove allows to skip the interactive approval
 	AutoApprove bool
+
+	// DryRun string passed to kubectl as --dry-run=<DryRun>
+	DryRun string
+
+	// ApplyStrategy to pick a final method for deploying generated objects
+	ApplyStrategy string
 }
 
 // DeleteOpts allow to specify additional parameters for delete operations
diff --git a/pkg/kubernetes/client/context.go b/pkg/kubernetes/client/context.go
index b8c8d178..b965c0b9 100644
--- a/pkg/kubernetes/client/context.go
+++ b/pkg/kubernetes/client/context.go
@@ -6,15 +6,16 @@ import (
 	"errors"
 	"fmt"
 	"os"
+	"regexp"
 	"strings"
 
 	"github.com/stretchr/objx"
 	funk "github.com/thoas/go-funk"
 )
 
-// findContext returns a valid context from $KUBECONFIG that uses the given
+// findContextFromEndpoint returns a valid context from $KUBECONFIG that uses the given
 // apiServer endpoint.
-func findContext(endpoint string) (Config, error) {
+func findContextFromEndpoint(endpoint string) (Config, error) {
 	cluster, context, err := ContextFromIP(endpoint)
 	if err != nil {
 		return Config{}, err
@@ -26,6 +27,59 @@ func findContext(endpoint string) (Config, error) {
 	}, nil
 }
 
+// findContextFromNames will try to match a context name from names
+func findContextFromNames(names []string) (Config, error) {
+	for _, name := range names {
+		cluster, context, err := ContextFromName(name)
+
+		if _, ok := err.(ErrorNoContext); ok {
+			continue
+		} else if err != nil {
+			return Config{}, err
+		}
+		return Config{
+			Context: *context,
+			Cluster: *cluster,
+		}, nil
+	}
+	return Config{}, ErrorNoContext(fmt.Sprintf("%v", names))
+}
+
+func ContextFromName(contextName string) (*Cluster, *Context, error) {
+	cfg, err := Kubeconfig()
+	if err != nil {
+		return nil, nil, err
+	}
+
+	// find the context by name
+	var context Context
+	contexts, err := tryMSISlice(cfg.Get("contexts"), "contexts")
+	if err != nil {
+		return nil, nil, err
+	}
+
+	err = find(contexts, "name", contextName, &context)
+	if err == ErrorNoMatch {
+		return nil, nil, ErrorNoContext(contextName)
+	} else if err != nil {
+		return nil, nil, err
+	}
+	var cluster Cluster
+	clusters, err := tryMSISlice(cfg.Get("clusters"), "clusters")
+	if err != nil {
+		return nil, nil, err
+	}
+
+	err = find(clusters, "name", context.Context.Cluster, &cluster)
+	if err == ErrorNoMatch {
+		return nil, nil, ErrorNoCluster(contextName)
+	} else if err != nil {
+		return nil, nil, err
+	}
+
+	return &cluster, &context, nil
+}
+
 // Kubeconfig returns the merged $KUBECONFIG of the host
 func Kubeconfig() (objx.Map, error) {
 	cmd := kubectlCmd("config", "view", "-o", "json")
@@ -161,8 +215,7 @@ func find(list []map[string]interface{}, prop string, expected string, ptr inter
 			findErr = fmt.Errorf("testing whether `%s` is `%s`: unable to parse `%v` as string", prop, expected, got)
 			return false
 		}
-
-		return str == expected
+		return regexp.MustCompile(expected).MatchString(str)
 	})
 	if findErr != nil {
 		return findErr
diff --git a/pkg/kubernetes/client/delete.go b/pkg/kubernetes/client/delete.go
index 844f49b0..7a827be3 100644
--- a/pkg/kubernetes/client/delete.go
+++ b/pkg/kubernetes/client/delete.go
@@ -2,11 +2,14 @@ package client
 
 import (
 	"bytes"
+	"fmt"
 	"os"
+	"os/exec"
 	"strings"
 )
 
-func (k Kubectl) Delete(namespace, kind, name string, opts DeleteOpts) error {
+// Test-ability: isolate deleteCtl to build and return exec.Cmd from DeleteOpts
+func (k Kubectl) deleteCtl(namespace, kind, name string, opts DeleteOpts) *exec.Cmd {
 	argv := []string{
 		"-n", namespace,
 		kind, name,
@@ -15,7 +18,18 @@ func (k Kubectl) Delete(namespace, kind, name string, opts DeleteOpts) error {
 		argv = append(argv, "--force")
 	}
 
-	cmd := k.ctl("delete", argv...)
+	if opts.DryRun != "" {
+		dryRun := fmt.Sprintf("--dry-run=%s", opts.DryRun)
+		argv = append(argv, dryRun)
+	}
+
+	return k.ctl("delete", argv...)
+}
+
+// Delete deletes the given Kubernetes resource from the cluster
+func (k Kubectl) Delete(namespace, kind, name string, opts DeleteOpts) error {
+
+	cmd := k.deleteCtl(namespace, kind, name, opts)
 
 	var stdout bytes.Buffer
 	var stderr bytes.Buffer
@@ -31,6 +45,9 @@ func (k Kubectl) Delete(namespace, kind, name string, opts DeleteOpts) error {
 		}
 		return err
 	}
+	if opts.DryRun != "" {
+		print(stdout.String())
+	}
 
 	return nil
 }
diff --git a/pkg/kubernetes/client/diff.go b/pkg/kubernetes/client/diff.go
index b4958b1c..edfb3d78 100644
--- a/pkg/kubernetes/client/diff.go
+++ b/pkg/kubernetes/client/diff.go
@@ -11,49 +11,47 @@ import (
 	"github.com/grafana/tanka/pkg/kubernetes/manifest"
 )
 
-// DiffServerSide takes the desired state and computes the differences, returning them in `diff(1)` format
-// It also validates that manifests are valid server-side
+// DiffServerSide takes the desired state and computes the differences server-side, returning them in `diff(1)` format
 func (k Kubectl) DiffServerSide(data manifest.List) (*string, error) {
 	return k.diff(data, true)
 }
 
+// ValidateServerSide takes the desired state and computes the differences, returning them in `diff(1)` format
+// It also validates that manifests are valid server-side, but still returns the client-side diff
+func (k Kubectl) ValidateServerSide(data manifest.List) (*string, error) {
+	if _, diffErr := k.diff(data, true); diffErr != nil {
+		return nil, diffErr
+	}
+	return k.diff(data, false)
+}
+
 // DiffClientSide takes the desired state and computes the differences, returning them in `diff(1)` format
 func (k Kubectl) DiffClientSide(data manifest.List) (*string, error) {
 	return k.diff(data, false)
 }
 
-func (k Kubectl) diff(data manifest.List, validate bool) (*string, error) {
+func (k Kubectl) diff(data manifest.List, serverSide bool) (*string, error) {
 	fw := FilterWriter{filters: []*regexp.Regexp{regexp.MustCompile(`exit status \d`)}}
-	diffCmd := func(serverSide bool) (string, error) {
-		args := []string{"-f", "-"}
-		if serverSide {
-			args = append(args, "--server-side")
-		}
-		cmd := k.ctl("diff", args...)
-
-		raw := bytes.Buffer{}
-		cmd.Stdout = &raw
-		cmd.Stderr = &fw
-		cmd.Stdin = strings.NewReader(data.String())
-		err := cmd.Run()
-		return raw.String(), err
-	}
 
-	if validate {
-		// Running the diff server-side, this checks that the resource definitions are valid
-		// However, it also diffs with server-side kubernetes elements, so it adds in a lot of elements that we shouldn't consider
-		_, err := diffCmd(true)
-		if diffErr := parseDiffErr(err, fw.buf, k.Info().ClientVersion); diffErr != nil {
-			return nil, diffErr
+	args := []string{"-f", "-"}
+	if serverSide {
+		args = append(args, "--server-side", "--force-conflicts")
+		if k.info.ClientVersion.GreaterThan(semver.MustParse("1.19.0")) {
+			args = append(args, "--field-manager=tanka")
 		}
 	}
+	cmd := k.ctl("diff", args...)
 
-	// Running the actual diff without considering server-side elements
-	s, err := diffCmd(false)
+	raw := bytes.Buffer{}
+	cmd.Stdout = &raw
+	cmd.Stderr = &fw
+	cmd.Stdin = strings.NewReader(data.String())
+	err := cmd.Run()
 	if diffErr := parseDiffErr(err, fw.buf, k.Info().ClientVersion); diffErr != nil {
 		return nil, diffErr
 	}
 
+	s := raw.String()
 	if s == "" {
 		return nil, nil
 	}
diff --git a/pkg/kubernetes/client/get.go b/pkg/kubernetes/client/get.go
index a82c035d..8cc12474 100644
--- a/pkg/kubernetes/client/get.go
+++ b/pkg/kubernetes/client/get.go
@@ -7,6 +7,8 @@ import (
 	"fmt"
 	"strings"
 
+	"github.com/Masterminds/semver"
+
 	"github.com/grafana/tanka/pkg/kubernetes/manifest"
 )
 
@@ -22,7 +24,10 @@ func (k Kubectl) GetByLabels(namespace, kind string, labels map[string]string) (
 	for k, v := range labels {
 		lArgs = append(lArgs, fmt.Sprintf("-l=%s=%s", k, v))
 	}
-
+	// Needed to properly filter for resources that should be pruned
+	if k.info.ClientVersion.GreaterThan(semver.MustParse("1.21.0")) {
+		lArgs = append(lArgs, "--show-managed-fields")
+	}
 	var opts getOpts
 	if namespace == "" {
 		opts.allNamespaces = true
diff --git a/pkg/kubernetes/client/kubectl.go b/pkg/kubernetes/client/kubectl.go
index e13eb1e2..6af4d877 100644
--- a/pkg/kubernetes/client/kubectl.go
+++ b/pkg/kubernetes/client/kubectl.go
@@ -23,7 +23,7 @@ func New(endpoint string) (*Kubectl, error) {
 
 	// discover context
 	var err error
-	k.info.Kubeconfig, err = findContext(endpoint)
+	k.info.Kubeconfig, err = findContextFromEndpoint(endpoint)
 	if err != nil {
 		return nil, errors.Wrap(err, "finding usable context")
 	}
@@ -37,6 +37,25 @@ func New(endpoint string) (*Kubectl, error) {
 	return &k, nil
 }
 
+func NewFromNames(names []string) (*Kubectl, error) {
+	k := Kubectl{}
+
+	var err error
+	k.info.Kubeconfig, err = findContextFromNames(names)
+	if err != nil {
+		return nil, errors.Wrap(err, "finding usable context")
+	}
+
+	// query versions (requires context)
+	k.info.ClientVersion, k.info.ServerVersion, err = k.version()
+	if err != nil {
+		return nil, errors.Wrap(err, "obtaining versions")
+	}
+
+	return &k, nil
+
+}
+
 // Info returns known informational data about the client and its environment
 func (k Kubectl) Info() Info {
 	return k.info
diff --git a/pkg/kubernetes/diff.go b/pkg/kubernetes/diff.go
index acbfdb9e..d57759b8 100644
--- a/pkg/kubernetes/diff.go
+++ b/pkg/kubernetes/diff.go
@@ -28,7 +28,7 @@ Please upgrade kubectl to at least version 1.18.1.`)
 		for _, namespace := range resourceNamespaces {
 			_, err = k.ctl.Namespace(namespace)
 			if err != nil {
-				if errors.As(err, client.ErrNamespaceNotFound{}) {
+				if errors.As(err, &client.ErrNamespaceNotFound{}) {
 					continue
 				}
 				return nil, errors.Wrap(err, "retrieving namespaces")
diff --git a/pkg/kubernetes/kubernetes.go b/pkg/kubernetes/kubernetes.go
index 22f42ed5..3cf1e89a 100644
--- a/pkg/kubernetes/kubernetes.go
+++ b/pkg/kubernetes/kubernetes.go
@@ -28,14 +28,24 @@ type Differ func(manifest.List) (*string, error)
 // New creates a new Kubernetes with an initialized client
 func New(env v1alpha1.Environment) (*Kubernetes, error) {
 	// setup client
-	ctl, err := client.New(env.Spec.APIServer)
+	var ctl *client.Kubectl
+	var err error
+	if len(env.Spec.ContextNames) < 1 {
+		ctl, err = client.New(env.Spec.APIServer)
+	} else {
+		ctl, err = client.NewFromNames(env.Spec.ContextNames)
+	}
 	if err != nil {
 		return nil, err
 	}
 
 	// setup diffing
 	if env.Spec.DiffStrategy == "" {
-		env.Spec.DiffStrategy = "native"
+		if env.Spec.ApplyStrategy == "server" {
+			env.Spec.DiffStrategy = "server"
+		} else {
+			env.Spec.DiffStrategy = "native"
+		}
 
 		if ctl.Info().ServerVersion.LessThan(semver.MustParse("1.13.0")) {
 			env.Spec.DiffStrategy = "subset"
@@ -47,7 +57,8 @@ func New(env v1alpha1.Environment) (*Kubernetes, error) {
 		ctl: ctl,
 		differs: map[string]Differ{
 			"native":   ctl.DiffClientSide,
-			"validate": ctl.DiffServerSide,
+			"validate": ctl.ValidateServerSide,
+			"server":   ctl.DiffServerSide,
 			"subset":   SubsetDiffer(ctl),
 		},
 	}
diff --git a/pkg/kubernetes/manifest/manifest.go b/pkg/kubernetes/manifest/manifest.go
index 51f577d6..ea3a5306 100644
--- a/pkg/kubernetes/manifest/manifest.go
+++ b/pkg/kubernetes/manifest/manifest.go
@@ -234,6 +234,19 @@ func (m Metadata) Annotations() map[string]interface{} {
 	return safeMSI(m, "annotations")
 }
 
+// Managed fields of the manifest
+func (m Metadata) ManagedFields() []interface{} {
+	items, ok := m["managedFields"]
+	if !ok {
+		return make([]interface{}, 0)
+	}
+	list, ok := items.([]interface{})
+	if !ok {
+		return make([]interface{}, 0)
+	}
+	return list
+}
+
 func safeMSI(m map[string]interface{}, key string) map[string]interface{} {
 	switch t := m[key].(type) {
 	case map[string]interface{}:
diff --git a/pkg/spec/spec.go b/pkg/spec/spec.go
index 86f5515c..706981c8 100644
--- a/pkg/spec/spec.go
+++ b/pkg/spec/spec.go
@@ -74,7 +74,7 @@ func Parse(data []byte, namespace string) (*v1alpha1.Environment, error) {
 	}
 
 	// default apiServer URL to https
-	if !regexp.MustCompile("^.+://").MatchString(config.Spec.APIServer) {
+	if config.Spec.APIServer != "" && !regexp.MustCompile("^.+://").MatchString(config.Spec.APIServer) {
 		config.Spec.APIServer = "https://" + config.Spec.APIServer
 	}
 
diff --git a/pkg/spec/v1alpha1/environment.go b/pkg/spec/v1alpha1/environment.go
index 59bc6d1c..55f0713e 100644
--- a/pkg/spec/v1alpha1/environment.go
+++ b/pkg/spec/v1alpha1/environment.go
@@ -58,8 +58,10 @@ func (m Metadata) NameLabel() string {
 // Spec defines Kubernetes properties
 type Spec struct {
 	APIServer        string           `json:"apiServer"`
+	ContextNames     []string         `json:"contextNames"`
 	Namespace        string           `json:"namespace"`
 	DiffStrategy     string           `json:"diffStrategy,omitempty"`
+	ApplyStrategy    string           `json:"applyStrategy,omitempty"`
 	InjectLabels     bool             `json:"injectLabels,omitempty"`
 	ResourceDefaults ResourceDefaults `json:"resourceDefaults"`
 	ExpectVersions   ExpectVersions   `json:"expectVersions"`
diff --git a/pkg/tanka/format.go b/pkg/tanka/format.go
index 941c25aa..f71e8be6 100644
--- a/pkg/tanka/format.go
+++ b/pkg/tanka/format.go
@@ -3,11 +3,10 @@ package tanka
 import (
 	"fmt"
 	"os"
-	"path/filepath"
 
 	"github.com/gobwas/glob"
 	"github.com/google/go-jsonnet/formatter"
-	"github.com/karrick/godirwalk"
+	"github.com/grafana/tanka/pkg/jsonnet"
 	"github.com/pkg/errors"
 )
 
@@ -34,7 +33,7 @@ type OutFn func(name, content string) error
 func FormatFiles(fds []string, opts *FormatOpts) ([]string, error) {
 	var paths []string
 	for _, f := range fds {
-		fs, err := findFiles(f, opts.Excludes)
+		fs, err := jsonnet.FindFiles(f, opts.Excludes)
 		if err != nil {
 			return nil, errors.Wrap(err, "finding Jsonnet files")
 		}
@@ -87,49 +86,3 @@ func FormatFiles(fds []string, opts *FormatOpts) ([]string, error) {
 func Format(filename string, content string) (string, error) {
 	return formatter.Format(filename, content, formatter.DefaultOptions())
 }
-
-// findFiles takes a file / directory and finds all Jsonnet files
-func findFiles(target string, excludes []glob.Glob) ([]string, error) {
-	// if it's a file, don't try to find children
-	fi, err := os.Stat(target)
-	if err != nil {
-		return nil, err
-	}
-	if fi.Mode().IsRegular() {
-		return []string{target}, nil
-	}
-
-	var files []string
-
-	// godirwalk is faster than filepath.Walk, 'cause no os.Stat required
-	err = godirwalk.Walk(target, &godirwalk.Options{
-		Callback: func(rawPath string, de *godirwalk.Dirent) error {
-			// Normalize slashes for Windows
-			path := filepath.ToSlash(rawPath)
-
-			if de.IsDir() {
-				return nil
-			}
-
-			// excluded?
-			for _, g := range excludes {
-				if g.Match(path) {
-					return nil
-				}
-			}
-
-			// only .jsonnet or .libsonnet
-			if ext := filepath.Ext(path); ext == ".jsonnet" || ext == ".libsonnet" {
-				files = append(files, path)
-			}
-			return nil
-		},
-		// faster, no sort required
-		Unsorted: true,
-	})
-	if err != nil {
-		return nil, err
-	}
-
-	return files, nil
-}
diff --git a/pkg/tanka/load.go b/pkg/tanka/load.go
index 7cf928a8..b254a21e 100644
--- a/pkg/tanka/load.go
+++ b/pkg/tanka/load.go
@@ -150,8 +150,10 @@ func (l LoadResult) Connect() (*kubernetes.Kubernetes, error) {
 
 	// check env is complete
 	s := ""
-	if env.Spec.APIServer == "" {
-		s += "  * spec.apiServer: No Kubernetes cluster endpoint specified"
+	if env.Spec.APIServer == "" && len(env.Spec.ContextNames) < 1 {
+		s += "  * spec.apiServer|spec.contextNames: No Kubernetes cluster endpoint or context names specified. Please specify only one."
+	} else if env.Spec.APIServer != "" && len(env.Spec.ContextNames) > 0 {
+		s += "  * spec.apiServer|spec.contextNames: These fields are mutually exclusive, please only specify one."
 	}
 	if env.Spec.Namespace == "" {
 		s += "  * spec.namespace: Default namespace missing"
diff --git a/pkg/tanka/prune.go b/pkg/tanka/prune.go
index 78b8a34c..be45b7b2 100644
--- a/pkg/tanka/prune.go
+++ b/pkg/tanka/prune.go
@@ -18,6 +18,8 @@ type PruneOpts struct {
 	AutoApprove bool
 	// Force ignores any warnings kubectl might have
 	Force bool
+	// DryRun string passed to kubectl as --dry-run=<DryRun>
+	DryRun string
 }
 
 // Prune deletes all resources from the cluster, that are no longer present in
diff --git a/pkg/tanka/workflow.go b/pkg/tanka/workflow.go
index e0e305d6..a31c0040 100644
--- a/pkg/tanka/workflow.go
+++ b/pkg/tanka/workflow.go
@@ -20,11 +20,27 @@ type ApplyOpts struct {
 	AutoApprove bool
 	// DiffStrategy to use for printing the diff before approval
 	DiffStrategy string
-
+	// ApplyStrategy decides how kubectl will apply the manifest
+	ApplyStrategy string
 	// Force ignores any warnings kubectl might have
 	Force bool
 	// Validate set to false ignores invalid Kubernetes schemas
 	Validate bool
+	// DryRun string passed to kubectl as --dry-run=<DryRun>
+	DryRun string
+	// ServerSide bool passed to kubectl as --server-side
+	ServerSide bool
+}
+
+// ErrorApplyStrategyUnknown occurs when an apply-strategy is requested that does
+// not exist. Unlike ErrorDiffStrategyUnknown, this needs to be used before things
+// reach the `kube.Apply` function.
+type ErrorApplyStrategyUnknown struct {
+	Requested string
+}
+
+func (e ErrorApplyStrategyUnknown) Error() string {
+	return fmt.Sprintf("apply strategy `%s` does not exist. Pick one of: [server, client].", e.Requested)
 }
 
 // Apply parses the environment at the given directory (a `baseDir`) and applies
@@ -35,6 +51,24 @@ func Apply(baseDir string, opts ApplyOpts) error {
 	if err != nil {
 		return err
 	}
+
+	// If the apply strategy was not set on the command-line, draw from spec or use default
+	if opts.ApplyStrategy == "" {
+		if l.Env.Spec.ApplyStrategy != "" {
+			opts.ApplyStrategy = l.Env.Spec.ApplyStrategy
+		} else {
+			opts.ApplyStrategy = "client"
+		}
+	}
+	if opts.ApplyStrategy != "client" && opts.ApplyStrategy != "server" {
+		return ErrorApplyStrategyUnknown{Requested: opts.ApplyStrategy}
+	}
+
+	// Default to `server` diff in server apply mode
+	if opts.ApplyStrategy == "server" && opts.DiffStrategy == "" && l.Env.Spec.DiffStrategy == "" {
+		l.Env.Spec.DiffStrategy = "server"
+	}
+
 	kube, err := l.Connect()
 	if err != nil {
 		return err
@@ -59,14 +93,16 @@ func Apply(baseDir string, opts ApplyOpts) error {
 	}
 
 	// prompt for confirmation
-	if opts.AutoApprove {
+	if opts.AutoApprove || opts.DryRun != "" {
 	} else if err := confirmPrompt("Applying to", l.Env.Spec.Namespace, kube.Info()); err != nil {
 		return err
 	}
 
 	return kube.Apply(l.Resources, kubernetes.ApplyOpts{
-		Force:    opts.Force,
-		Validate: opts.Validate,
+		Force:         opts.Force,
+		Validate:      opts.Validate,
+		DryRun:        opts.DryRun,
+		ApplyStrategy: opts.ApplyStrategy,
 	})
 }
 
@@ -89,7 +125,7 @@ func confirmPrompt(action, namespace string, info client.Info) error {
 type DiffOpts struct {
 	Opts
 
-	// Strategy must be one of "native", "validate", or "subset"
+	// Strategy must be one of "native", "validate", "subset" or "server"
 	Strategy string
 	// Summarize prints a summary, instead of the actual diff
 	Summarize bool
@@ -134,6 +170,8 @@ type DeleteOpts struct {
 	Force bool
 	// Validate set to false ignores invalid Kubernetes schemas
 	Validate bool
+	// DryRun string passed to kubectl as --dry-run=<DryRun>
+	DryRun string
 }
 
 // Delete parses the environment at the given directory (a `baseDir`) and deletes
@@ -150,22 +188,24 @@ func Delete(baseDir string, opts DeleteOpts) error {
 	}
 	defer kube.Close()
 
-	// show diff
-	// static differ will never fail and always return something if input is not nil
-	diff, err := kubernetes.StaticDiffer(false)(l.Resources)
+	if opts.DryRun == "" {
+		// show diff
+		// static differ will never fail and always return something if input is not nil
+		diff, err := kubernetes.StaticDiffer(false)(l.Resources)
 
-	if err != nil {
-		fmt.Println("Error diffing:", err)
-	}
+		if err != nil {
+			fmt.Println("Error diffing:", err)
+		}
 
-	// in case of non-fatal error diff may be nil
-	if diff != nil {
-		b := term.Colordiff(*diff)
-		fmt.Print(b.String())
+		// in case of non-fatal error diff may be nil
+		if diff != nil {
+			b := term.Colordiff(*diff)
+			fmt.Print(b.String())
+		}
 	}
 
 	// prompt for confirmation
-	if opts.AutoApprove {
+	if opts.AutoApprove || opts.DryRun != "" {
 	} else if err := confirmPrompt("Deleting from", l.Env.Spec.Namespace, kube.Info()); err != nil {
 		return err
 	}
@@ -173,6 +213,7 @@ func Delete(baseDir string, opts DeleteOpts) error {
 	return kube.Delete(l.Resources, kubernetes.DeleteOpts{
 		Force:    opts.Force,
 		Validate: opts.Validate,
+		DryRun:   opts.DryRun,
 	})
 }
 


Test patch:
diff --git a/pkg/jsonnet/jpath/dirs_test.go b/pkg/jsonnet/jpath/dirs_test.go
index 5270d499..635e0c00 100644
--- a/pkg/jsonnet/jpath/dirs_test.go
+++ b/pkg/jsonnet/jpath/dirs_test.go
@@ -188,7 +188,7 @@ func TestFindRoot(t *testing.T) {
 			dir := makeTestdata(t, s.testdata)
 			defer os.RemoveAll(dir)
 
-			_, base, root, err := Resolve(filepath.Join(dir, s.environment))
+			_, base, root, err := Resolve(filepath.Join(dir, s.environment), false)
 			assert.Equal(t, s.err, err)
 
 			if err == nil {
diff --git a/pkg/kubernetes/client/apply_test.go b/pkg/kubernetes/client/apply_test.go
new file mode 100644
index 00000000..193075b7
--- /dev/null
+++ b/pkg/kubernetes/client/apply_test.go
@@ -0,0 +1,79 @@
+package client
+
+import (
+	"testing"
+
+	"k8s.io/apimachinery/pkg/util/sets"
+
+	"github.com/grafana/tanka/pkg/kubernetes/manifest"
+)
+
+func TestKubectl_applyCtl(t *testing.T) {
+	info := Info{
+		Kubeconfig: Config{
+			Context: Context{
+				Name: "foo-context",
+			},
+		},
+	}
+
+	type args struct {
+		data manifest.List
+		opts ApplyOpts
+	}
+	tests := []struct {
+		name           string
+		args           args
+		expectedArgs   []string
+		unExpectedArgs []string
+	}{
+		{
+			name: "test default",
+			args: args{
+				opts: ApplyOpts{Validate: true},
+			},
+			expectedArgs:   []string{"--context", info.Kubeconfig.Context.Name},
+			unExpectedArgs: []string{"--force", "--dry-run=server", "--validate=false"},
+		},
+		{
+			name: "test force",
+			args: args{
+				opts: ApplyOpts{Validate: true, Force: true},
+			},
+			expectedArgs:   []string{"--force"},
+			unExpectedArgs: []string{"--validate=false"},
+		},
+		{
+			name: "test validate",
+			args: args{
+				opts: ApplyOpts{Validate: false},
+			},
+			expectedArgs: []string{"--validate=false"},
+		},
+		{
+			name: "test dry-run",
+			args: args{
+				opts: ApplyOpts{Validate: true, DryRun: "server"},
+			},
+			expectedArgs:   []string{"--dry-run=server"},
+			unExpectedArgs: []string{"--validate=false"},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			k := Kubectl{
+				info: info,
+			}
+			got := k.applyCtl(tt.args.data, tt.args.opts)
+			gotSet := sets.NewString(got.Args...)
+			if !gotSet.HasAll(tt.expectedArgs...) {
+				t.Errorf("Kubectl.applyCtl() = %v doesn't have (all) expectedArgs='%v'", got.Args, tt.expectedArgs)
+			}
+			if gotSet.HasAny(tt.unExpectedArgs...) {
+				t.Errorf("Kubectl.applyCtl() = %v has (any) unExpectedArgs='%v'", got.Args, tt.unExpectedArgs)
+			}
+
+		})
+	}
+}
diff --git a/pkg/kubernetes/client/delete_test.go b/pkg/kubernetes/client/delete_test.go
new file mode 100644
index 00000000..ddb9e5f7
--- /dev/null
+++ b/pkg/kubernetes/client/delete_test.go
@@ -0,0 +1,74 @@
+package client
+
+import (
+	"testing"
+
+	"k8s.io/apimachinery/pkg/util/sets"
+)
+
+func TestKubectl_deleteCtl(t *testing.T) {
+	info := Info{
+		Kubeconfig: Config{
+			Context: Context{
+				Name: "foo-context",
+			},
+		},
+	}
+
+	type args struct {
+		ns   string
+		kind string
+		name string
+		opts DeleteOpts
+	}
+
+	tests := []struct {
+		name           string
+		args           args
+		expectedArgs   []string
+		unExpectedArgs []string
+	}{
+		{
+			name: "test default",
+			args: args{
+				ns:   "foo-ns",
+				kind: "deploy",
+				name: "foo-deploy",
+				opts: DeleteOpts{},
+			},
+			expectedArgs:   []string{"--context", info.Kubeconfig.Context.Name, "-n", "foo-ns", "deploy", "foo-deploy"},
+			unExpectedArgs: []string{"--force", "--dry-run=server"},
+		},
+		{
+			name: "test dry-run",
+			args: args{
+				opts: DeleteOpts{DryRun: "server"},
+			},
+			expectedArgs: []string{"--dry-run=server"},
+		},
+		{
+			name: "test force",
+			args: args{
+				opts: DeleteOpts{Force: true},
+			},
+			expectedArgs: []string{"--force"},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			k := Kubectl{
+				info: info,
+			}
+			got := k.deleteCtl(tt.args.ns, tt.args.kind, tt.args.name, tt.args.opts)
+			gotSet := sets.NewString(got.Args...)
+			if !gotSet.HasAll(tt.expectedArgs...) {
+				t.Errorf("Kubectl.applyCtl() = %v doesn't have (all) expectedArgs='%v'", got.Args, tt.expectedArgs)
+			}
+			if gotSet.HasAny(tt.unExpectedArgs...) {
+				t.Errorf("Kubectl.applyCtl() = %v has (any) unExpectedArgs='%v'", got.Args, tt.unExpectedArgs)
+			}
+
+		})
+	}
+}