Repository: grafana/tanka
Pull request #265 (2020-04-20 12:47:19)

doc: recommend package managers
We now have AUR and `brew`, which is much better for users than
downloading binaries from the internet


Patch:
diff --git a/cmd/tk/main.go b/cmd/tk/main.go
index e3f7fae2..c3c6d49e 100644
--- a/cmd/tk/main.go
+++ b/cmd/tk/main.go
@@ -39,6 +39,7 @@ func main() {
 		applyCmd(),
 		showCmd(),
 		diffCmd(),
+		pruneCmd(),
 	)
 
 	rootCmd.AddCommand(
diff --git a/cmd/tk/workflow.go b/cmd/tk/workflow.go
index f38d3b05..b23a66fd 100644
--- a/cmd/tk/workflow.go
+++ b/cmd/tk/workflow.go
@@ -63,6 +63,28 @@ func applyCmd() *cli.Command {
 	return cmd
 }
 
+func pruneCmd() *cli.Command {
+	cmd := &cli.Command{
+		Use:   "prune <path>",
+		Short: "delete resources removed from Jsonnet",
+		Args:  workflowArgs,
+	}
+
+	getExtCode := extCodeParser(cmd.Flags())
+	autoApprove := cmd.Flags().Bool("dangerous-auto-approve", false, "skip interactive approval. Only for automation!")
+	force := cmd.Flags().Bool("force", false, "force deleting (kubectl delete --force)")
+
+	cmd.Run = func(cmd *cli.Command, args []string) error {
+		return tanka.Prune(args[0],
+			tanka.WithExtCode(getExtCode()),
+			tanka.WithApplyAutoApprove(*autoApprove),
+			tanka.WithApplyForce(*force),
+		)
+	}
+
+	return cmd
+}
+
 func diffCmd() *cli.Command {
 	cmd := &cli.Command{
 		Use:   "diff <path>",
diff --git a/docs/docs/config.md b/docs/docs/config.md
new file mode 100644
index 00000000..91c8430f
--- /dev/null
+++ b/docs/docs/config.md
@@ -0,0 +1,64 @@
+---
+name: "Configuration Reference"
+route: "/config"
+---
+
+# Configuration Reference
+
+Tanka's behavior can be customized per Environment using a file called `spec.json`
+
+## File format
+
+```json
+{
+  // Config format revision. Currently only "v1alpha1"
+  "apiVersion": "v1alpha1",
+  // Always "Environment". Reserved for future use
+  "kind": "Environment",
+
+  // Descriptive fields
+  "metadata": {
+    // Name of the Environment. Automatically set to the relative
+    // path from the project root
+    "name": "<string>",
+
+    // Arbitrary key:value string pairs. Not parsed by Tanka
+    "labels": { "<string>": "<string>" }
+  },
+
+  // Properties influencing Tanka's behavior
+  "spec": {
+    // The Kubernetes cluster to use.
+    // Must be the full URL, e.g. https://cluster.fqdn:6443
+    "apiServer": "<url>",
+
+    // Default namespace for objects that don't explicitely specify one
+    "namespace": "<string>" | default = "default",
+
+    // diffStrategy to use. Automatically chosen by default based on
+    // the availability of "kubectl diff".
+    // - native: uses "kubectl diff". Recommended
+    // - subset: fallback for k8s versions below 1.13.0
+    "diffStrategy": "[native, subset]" | default = "auto",
+
+    // Whether to add a "tanka.dev/environment" label to each created resource.
+    // Required for garbage collection ("tk prune").
+    "injectLabels": <boolean> | default = false
+  }
+}
+```
+
+## Jsonnet access
+
+It is possible to access above data from Jsonnet:
+
+```jsonnet
+local tk = import "tk";
+
+{
+  // The cluster IP
+  cluster: tk.env.spec.apiServer,
+  // The labels of your Environment
+  labels: tk.env.metadata.labels,
+}
+```
diff --git a/docs/docs/garbage-collection.md b/docs/docs/garbage-collection.md
new file mode 100644
index 00000000..59e76c73
--- /dev/null
+++ b/docs/docs/garbage-collection.md
@@ -0,0 +1,32 @@
+---
+name: "Garbage collection"
+route: "/garbage-collection"
+---
+
+# Garbage collection
+
+Tanka can automatically delete resources from your cluster once you remove them
+from Jsonnet.
+
+> **Note:** This feature is **experimental**. Please report problems at https://github.com/grafana/tanka/issues.
+
+To accomplish this, it appends the `tanka.dev/environment: <name>` label to each created
+resource. This is used to identify those which are missing from the local state in the
+future.
+
+Because the label causes a `diff` for every single object in your cluster and
+not everybody wants this, it needs to be explicitly enabled. To do so, add the
+following field to your `spec.json`:
+
+```diff
+{
+  "spec": {
++    "injectLabels": true,
+  }
+}
+```
+
+Once added, run a `tk apply`, make sure the label is actually added and confirm
+by typing `yes`.
+
+From now on, you can use `tk prune` to remove old resources from your cluster.
diff --git a/docs/docs/installation.md b/docs/docs/installation.md
index 87f5fb63..d41c6be8 100644
--- a/docs/docs/installation.md
+++ b/docs/docs/installation.md
@@ -17,9 +17,39 @@ prerequesites:
 - (recommended) `jb`: [#Jsonnet-bundler](#jsonnet-bundler), the Jsonnet package
   manager
 
-## Precompiled binaries (recommended)
+## Using a package manager (recommended)
 
-We provide pre-compiled binaries for Tanka at
+We maintain Tanka packages for some operation systems. Installing these is
+recommeded, as updates are automatically distributed.
+
+### macOS
+
+Tanka is in
+[`Homebrew/homebrew-core`](https://github.com/Homebrew/homebrew-core/blob/master/Formula/tanka.rb),
+so you can just install it using `brew`:
+
+```bash
+$ brew install tanka
+```
+
+### ArchLinux
+
+We maintain two AUR packages, one building [from
+source](https://aur.archlinux.org/packages/tanka/) and another one using a
+[pre-compiled binary](https://aur.archlinux.org/packages/tanka-bin/). These can
+be installed using any AUR helper, e.g. `yay`:
+
+```bash
+# from source:
+$ yay tanka
+
+# using pre-compiled binary:
+$ yay tanka-bin
+```
+
+## Precompiled binaries
+
+For all other operating systems, we provide pre-compiled binaries for Tanka at
 https://github.com/grafana/tanka/releases.
 
 Just grab the latest version from there, download it and put somewhere in your
diff --git a/docs/docs/libraries/overriding.md b/docs/docs/libraries/overriding.md
index e0b11a28..7231a6a9 100644
--- a/docs/docs/libraries/overriding.md
+++ b/docs/docs/libraries/overriding.md
@@ -28,7 +28,7 @@ For example, if `/vendor/foo/bar.libsonnet` contained an error, you could create
 >
 > ```jsonnet
 > // in /lib/foo/bar.libsonnet:
-> import "../vendor/foo/bar.libsonnet" + {
+> (import "../../vendor/foo/bar.libsonnet") + {
 >   foo+: {
 >     bar: "fixed"
 >   }
diff --git a/docs/doczrc.js b/docs/doczrc.js
index 130c32b3..bef46755 100644
--- a/docs/doczrc.js
+++ b/docs/doczrc.js
@@ -47,10 +47,12 @@ export default {
     // additional features
     "Output filtering",
     "Exporting as YAML",
+    "Garbage collection",
     "Command-line completion",
     "Diff strategies",
 
     // reference
+    "Configuration Reference",
     "Directory structure",
     "Environment variables",
 
diff --git a/examples/prom-grafana/jsonnetfile.json b/examples/prom-grafana/jsonnetfile.json
index 655fa8d7..0db30b86 100644
--- a/examples/prom-grafana/jsonnetfile.json
+++ b/examples/prom-grafana/jsonnetfile.json
@@ -1,5 +1,5 @@
 {
-  "goImportStyle": false,
+  "version": 1,
   "dependencies": [
     {
       "source": {
@@ -19,5 +19,6 @@
       },
       "version": "master"
     }
-  ]
+  ],
+  "legacyImports": true
 }
diff --git a/examples/prom-grafana/jsonnetfile.lock.json b/examples/prom-grafana/jsonnetfile.lock.json
index 73f9f087..22f79437 100644
--- a/examples/prom-grafana/jsonnetfile.lock.json
+++ b/examples/prom-grafana/jsonnetfile.lock.json
@@ -1,5 +1,5 @@
 {
-  "goImportStyle": false,
+  "version": 1,
   "dependencies": [
     {
       "source": {
@@ -8,8 +8,8 @@
           "subdir": "ksonnet-util"
         }
       },
-      "version": "f8a06bd7de550bd73921526a040f0db465133059",
-      "sum": "R5bR+TRNe7mWXLIDz84lJH58rFJU3+ieIb4xbhu+vyE="
+      "version": "b7d0399a4c8b9fe3ee381b3dc8752e7c778b3f1a",
+      "sum": "+NXJP8shQKw88Rs/X1firb7V2NzRSUvujBsMkKGh3e4="
     },
     {
       "source": {
@@ -21,5 +21,6 @@
       "version": "0d2f82676817bbf9e4acf6495b2090205f323b9f",
       "sum": "ur22hPQq0JAPBxm8hNMcwjumj4MkozDwOKiGZvVMYh4="
     }
-  ]
+  ],
+  "legacyImports": false
 }
diff --git a/pkg/kubernetes/apply.go b/pkg/kubernetes/apply.go
index c6cd35d0..49ad9917 100644
--- a/pkg/kubernetes/apply.go
+++ b/pkg/kubernetes/apply.go
@@ -2,12 +2,11 @@ package kubernetes
 
 import (
 	"fmt"
-
-	"github.com/fatih/color"
+	"strings"
+	"time"
 
 	"github.com/grafana/tanka/pkg/kubernetes/client"
 	"github.com/grafana/tanka/pkg/kubernetes/manifest"
-	"github.com/grafana/tanka/pkg/term"
 )
 
 // ApplyOpts allow set additional parameters for the apply operation
@@ -15,22 +14,90 @@ type ApplyOpts client.ApplyOpts
 
 // Apply receives a state object generated using `Reconcile()` and may apply it to the target system
 func (k *Kubernetes) Apply(state manifest.List, opts ApplyOpts) error {
-	alert := color.New(color.FgRed, color.Bold).SprintFunc()
-
-	cluster := k.ctl.Info().Kubeconfig.Cluster
-	context := k.ctl.Info().Kubeconfig.Context
-	if !opts.AutoApprove {
-		if err := term.Confirm(
-			fmt.Sprintf(`Applying to namespace '%s' of cluster '%s' at '%s' using context '%s'.`,
-				alert(k.Spec.Namespace),
-				alert(cluster.Name),
-				alert(cluster.Cluster.Server),
-				alert(context.Name),
-			),
-			"yes",
-		); err != nil {
-			return err
+	return k.ctl.Apply(state, client.ApplyOpts(opts))
+}
+
+// AnnoationLastApplied is the last-applied-configuration annotation used by kubectl
+const AnnotationLastApplied = "kubectl.kubernetes.io/last-applied-configuration"
+
+// Orphaned returns previously created resources that are missing from the
+// local state. It uses UIDs to safely identify objects.
+func (k *Kubernetes) Orphaned(state manifest.List) (manifest.List, error) {
+	if !k.Env.Spec.InjectLabels {
+		return nil, fmt.Errorf(`spec.injectLabels is set to false in your spec.json. Tanka needs to add
+a label to your resources to reliably detect which were removed from Jsonnet.
+See https://tanka.dev/garbage-collection for more details.`)
+	}
+
+	apiResources, err := k.ctl.Resources()
+	if err != nil {
+		return nil, err
+	}
+
+	start := time.Now()
+	fmt.Print("fetching UID's .. ")
+	uids, err := k.uids(state)
+	if err != nil {
+		return nil, err
+	}
+	fmt.Println("done", time.Since(start))
+
+	var orphaned manifest.List
+
+	// join all kinds that support LIST into a comma separated string for
+	// kubectl
+	kinds := ""
+	for _, r := range apiResources {
+		if !strings.Contains(r.Verbs, "list") {
+			continue
 		}
+
+		kinds += "," + r.FQN()
 	}
-	return k.ctl.Apply(state, client.ApplyOpts(opts))
+	kinds = strings.TrimPrefix(kinds, ",")
+
+	start = time.Now()
+	fmt.Print("fetching previously created resources .. ")
+	// get all resources matching our label
+	matched, err := k.ctl.GetByLabels("", kinds, map[string]string{
+		LabelEnvironment: k.Env.Metadata.NameLabel(),
+	})
+	if err != nil {
+		return nil, err
+	}
+	fmt.Println("done", time.Since(start))
+
+	// filter unknown
+	for _, m := range matched {
+		// ignore known ones
+		if uids[m.Metadata().UID()] {
+			continue
+		}
+
+		// skip objects not created explicitely
+		if _, ok := m.Metadata().Annotations()[AnnotationLastApplied]; !ok {
+			continue
+		}
+
+		// record and skip from now on
+		orphaned = append(orphaned, m)
+		uids[m.Metadata().UID()] = true
+	}
+
+	return orphaned, nil
+}
+
+func (k *Kubernetes) uids(state manifest.List) (map[string]bool, error) {
+	uids := make(map[string]bool)
+
+	live, err := k.ctl.GetByState(state)
+	if err != nil {
+		return nil, err
+	}
+
+	for _, m := range live {
+		uids[m.Metadata().UID()] = true
+	}
+
+	return uids, nil
 }
diff --git a/pkg/kubernetes/client/client.go b/pkg/kubernetes/client/client.go
index c4d7bdc0..4bc048d9 100644
--- a/pkg/kubernetes/client/client.go
+++ b/pkg/kubernetes/client/client.go
@@ -8,7 +8,8 @@ import (
 type Client interface {
 	// Get the specified object(s) from the cluster
 	Get(namespace, kind, name string) (manifest.Manifest, error)
-	GetByLabels(namespace string, labels map[string]interface{}) (manifest.List, error)
+	GetByLabels(namespace, kind string, labels map[string]string) (manifest.List, error)
+	GetByState(data manifest.List) (manifest.List, error)
 
 	// Apply the configuration to the cluster. `data` must contain a plaintext
 	// format that is `kubectl-apply(1)` compatible
@@ -20,7 +21,6 @@ type Client interface {
 
 	// Delete the specified object(s) from the cluster
 	Delete(namespace, kind, name string, opts DeleteOpts) error
-	DeleteByLabels(namespace string, labels map[string]interface{}, opts DeleteOpts) error
 
 	// Namespaces the cluster currently has
 	Namespaces() (map[string]bool, error)
diff --git a/pkg/kubernetes/client/delete.go b/pkg/kubernetes/client/delete.go
index 4b3a5c0d..55a2593c 100644
--- a/pkg/kubernetes/client/delete.go
+++ b/pkg/kubernetes/client/delete.go
@@ -1,27 +1,14 @@
 package client
 
 import (
-	"fmt"
 	"os"
 )
 
-// Delete removes the specified object from the cluster
 func (k Kubectl) Delete(namespace, kind, name string, opts DeleteOpts) error {
-	return k.delete(namespace, []string{kind, name}, opts)
-}
-
-// DeleteByLabels removes all objects matched by the given labels from the cluster
-func (k Kubectl) DeleteByLabels(namespace string, labels map[string]interface{}, opts DeleteOpts) error {
-	lArgs := make([]string, 0, len(labels))
-	for k, v := range labels {
-		lArgs = append(lArgs, fmt.Sprintf("-l=%s=%s", k, v))
+	argv := []string{
+		"-n", namespace,
+		kind, name,
 	}
-
-	return k.delete(namespace, lArgs, opts)
-}
-
-func (k Kubectl) delete(namespace string, sel []string, opts DeleteOpts) error {
-	argv := append([]string{"-n", namespace}, sel...)
 	if opts.Force {
 		argv = append(argv, "--force")
 	}
diff --git a/pkg/kubernetes/client/get.go b/pkg/kubernetes/client/get.go
index e5062f2e..44adb218 100644
--- a/pkg/kubernetes/client/get.go
+++ b/pkg/kubernetes/client/get.go
@@ -3,6 +3,7 @@ package client
 import (
 	"bytes"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"strings"
 
@@ -11,57 +12,80 @@ import (
 
 // Get retrieves a single Kubernetes object from the cluster
 func (k Kubectl) Get(namespace, kind, name string) (manifest.Manifest, error) {
-	return k.get(namespace, []string{kind, name})
+	return k.get(namespace, kind, []string{name}, getOpts{})
 }
 
-// GetByLabels retrieves all objects matched by the given labels from the cluster
-func (k Kubectl) GetByLabels(namespace string, labels map[string]interface{}) (manifest.List, error) {
+// GetByLabels retrieves all objects matched by the given labels from the cluster.
+// Set namespace to empty string for --all-namespaces
+func (k Kubectl) GetByLabels(namespace, kind string, labels map[string]string) (manifest.List, error) {
 	lArgs := make([]string, 0, len(labels))
 	for k, v := range labels {
 		lArgs = append(lArgs, fmt.Sprintf("-l=%s=%s", k, v))
 	}
 
-	list, err := k.get(namespace, lArgs)
+	var opts getOpts
+	if namespace == "" {
+		opts.allNamespaces = true
+	}
+	list, err := k.get(namespace, kind, lArgs, opts)
 	if err != nil {
 		return nil, err
 	}
 
-	if list.Kind() != "List" {
-		return nil, fmt.Errorf("expected kind `List` but got `%s` instead", list.Kind())
-	}
+	return unwrapList(list)
+}
 
-	items := list["items"].([]interface{})
-	ms := make(manifest.List, 0, len(items))
-	for _, i := range items {
-		ms = append(ms, manifest.Manifest(i.(map[string]interface{})))
+// GetByState returns the full object, including runtime fields for each
+// resource in the state
+func (k Kubectl) GetByState(data manifest.List) (manifest.List, error) {
+	list, err := k.get("", "", []string{"-f", "-"}, getOpts{
+		stdin: data.String(),
+	})
+	if err != nil {
+		return nil, err
 	}
 
-	return ms, nil
+	return unwrapList(list)
+}
+
+type getOpts struct {
+	allNamespaces bool
+	stdin         string
 }
 
-func (k Kubectl) get(namespace string, sel []string) (manifest.Manifest, error) {
-	argv := append([]string{
+func (k Kubectl) get(namespace, kind string, selector []string, opts getOpts) (manifest.Manifest, error) {
+	// build cli flags and args
+	argv := []string{
 		"-o", "json",
-		"-n", namespace,
-	}, sel...)
-	cmd := k.ctl("get", argv...)
+	}
+
+	if opts.allNamespaces {
+		argv = append(argv, "--all-namespaces")
+	} else if namespace != "" {
+		argv = append(argv, "-n", namespace)
+	}
+
+	if kind != "" {
+		argv = append(argv, kind)
+	}
+
+	argv = append(argv, selector...)
 
+	// setup command environment
+	cmd := k.ctl("get", argv...)
 	var sout, serr bytes.Buffer
 	cmd.Stdout = &sout
 	cmd.Stderr = &serr
+	if opts.stdin != "" {
+		cmd.Stdin = strings.NewReader(opts.stdin)
+	}
 
+	// run command
 	if err := cmd.Run(); err != nil {
-		if strings.HasPrefix(serr.String(), "Error from server (NotFound)") {
-			return nil, ErrorNotFound{serr.String()}
-		}
-		if strings.HasPrefix(serr.String(), "error: the server doesn't have a resource type") {
-			return nil, ErrorUnknownResource{serr.String()}
-		}
-
-		fmt.Print(serr.String())
-		return nil, err
+		return nil, parseGetErr(err, serr.String())
 	}
 
+	// parse result
 	var m manifest.Manifest
 	if err := json.Unmarshal(sout.Bytes(), &m); err != nil {
 		return nil, err
@@ -69,3 +93,28 @@ func (k Kubectl) get(namespace string, sel []string) (manifest.Manifest, error)
 
 	return m, nil
 }
+
+func parseGetErr(err error, stderr string) error {
+	if strings.HasPrefix(stderr, "Error from server (NotFound)") {
+		return ErrorNotFound{stderr}
+	}
+	if strings.HasPrefix(stderr, "error: the server doesn't have a resource type") {
+		return ErrorUnknownResource{stderr}
+	}
+
+	return errors.New(strings.TrimPrefix(fmt.Sprintf("%s\n%s", stderr, err), "\n"))
+}
+
+func unwrapList(list manifest.Manifest) (manifest.List, error) {
+	if list.Kind() != "List" {
+		return nil, fmt.Errorf("expected kind `List` but got `%s` instead", list.Kind())
+	}
+
+	items := list["items"].([]interface{})
+	ms := make(manifest.List, 0, len(items))
+	for _, i := range items {
+		ms = append(ms, manifest.Manifest(i.(map[string]interface{})))
+	}
+
+	return ms, nil
+}
diff --git a/pkg/kubernetes/client/resources.go b/pkg/kubernetes/client/resources.go
index 6a30c1a0..96a68092 100644
--- a/pkg/kubernetes/client/resources.go
+++ b/pkg/kubernetes/client/resources.go
@@ -34,11 +34,16 @@ type Resource struct {
 	Name       string `json:"NAME"`
 	Namespaced bool   `json:"NAMESPACED,string"`
 	Shortnames string `json:"SHORTNAMES"`
+	Verbs      string `json:"VERBS"`
+}
+
+func (r Resource) FQN() string {
+	return strings.TrimSuffix(r.Kind+"."+r.APIGroup, ".")
 }
 
 // Resources returns all API resources known to the server
 func (k Kubectl) Resources() (Resources, error) {
-	cmd := k.ctl("api-resources", "--cached")
+	cmd := k.ctl("api-resources", "--cached", "--output=wide")
 	var out bytes.Buffer
 	cmd.Stdout = &out
 	cmd.Stderr = os.Stderr
diff --git a/pkg/kubernetes/delete.go b/pkg/kubernetes/delete.go
new file mode 100644
index 00000000..4330eb83
--- /dev/null
+++ b/pkg/kubernetes/delete.go
@@ -0,0 +1,18 @@
+package kubernetes
+
+import (
+	"github.com/grafana/tanka/pkg/kubernetes/client"
+	"github.com/grafana/tanka/pkg/kubernetes/manifest"
+)
+
+type DeleteOpts client.DeleteOpts
+
+func (k *Kubernetes) Delete(state manifest.List, opts DeleteOpts) error {
+	for _, m := range state {
+		if err := k.ctl.Delete(m.Metadata().Namespace(), m.Kind(), m.Metadata().Name(), client.DeleteOpts(opts)); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
diff --git a/pkg/kubernetes/diff.go b/pkg/kubernetes/diff.go
index 87ea0083..0ff73196 100644
--- a/pkg/kubernetes/diff.go
+++ b/pkg/kubernetes/diff.go
@@ -37,7 +37,7 @@ Please downgrade kubectl until https://github.com/kubernetes/kubernetes/issues/8
 	// would cause an error
 	//
 	// live: all other resources
-	live, soon := separate(state, k.Spec.Namespace, separateOpts{
+	live, soon := separate(state, k.Env.Spec.Namespace, separateOpts{
 		namespaces: namespaces,
 		resources:  resources,
 	})
@@ -127,7 +127,7 @@ func (e ErrorDiffStrategyUnknown) Error() string {
 }
 
 func (k *Kubernetes) differ(override string) (Differ, error) {
-	strategy := k.Spec.DiffStrategy
+	strategy := k.Env.Spec.DiffStrategy
 	if override != "" {
 		strategy = override
 	}
diff --git a/pkg/kubernetes/kubernetes.go b/pkg/kubernetes/kubernetes.go
index ad0c01c7..3c725c79 100644
--- a/pkg/kubernetes/kubernetes.go
+++ b/pkg/kubernetes/kubernetes.go
@@ -12,7 +12,7 @@ import (
 
 // Kubernetes exposes methods to work with the Kubernetes orchestrator
 type Kubernetes struct {
-	Spec v1alpha1.Spec
+	Env v1alpha1.Config
 
 	// Client (kubectl)
 	ctl client.Client
@@ -26,25 +26,25 @@ type Kubernetes struct {
 type Differ func(manifest.List) (*string, error)
 
 // New creates a new Kubernetes with an initialized client
-func New(s v1alpha1.Spec) (*Kubernetes, error) {
+func New(env v1alpha1.Config) (*Kubernetes, error) {
 	// setup client
-	ctl, err := client.New(s.APIServer, s.Namespace)
+	ctl, err := client.New(env.Spec.APIServer, env.Spec.Namespace)
 	if err != nil {
 		return nil, err
 	}
 
 	// setup diffing
-	if s.DiffStrategy == "" {
-		s.DiffStrategy = "native"
+	if env.Spec.DiffStrategy == "" {
+		env.Spec.DiffStrategy = "native"
 
 		if ctl.Info().ServerVersion.LessThan(semver.MustParse("1.13.0")) {
-			s.DiffStrategy = "subset"
+			env.Spec.DiffStrategy = "subset"
 		}
 	}
 
 	k := Kubernetes{
-		Spec: s,
-		ctl:  ctl,
+		Env: env,
+		ctl: ctl,
 		differs: map[string]Differ{
 			"native": ctl.DiffServerSide,
 			"subset": SubsetDiffer(ctl),
diff --git a/pkg/kubernetes/manifest/manifest.go b/pkg/kubernetes/manifest/manifest.go
index 64194402..ad34110d 100644
--- a/pkg/kubernetes/manifest/manifest.go
+++ b/pkg/kubernetes/manifest/manifest.go
@@ -132,30 +132,47 @@ func (m Metadata) Namespace() string {
 	return m["namespace"].(string)
 }
 
-// HasLabels returns whether the manifest has labels
-func (m Metadata) HasLabels() bool {
-	return m2o(m).Get("labels").IsMSI()
-}
-
-// Labels of the manifest
-func (m Metadata) Labels() map[string]interface{} {
-	if !m.HasLabels() {
-		return make(map[string]interface{})
+func (m Metadata) UID() string {
+	uid, ok := m["uid"].(string)
+	if !ok {
+		return ""
 	}
-	return m["labels"].(map[string]interface{})
+	return uid
 }
 
-// HasAnnotations returns whether the manifest has annotations
-func (m Metadata) HasAnnotations() bool {
-	return m2o(m).Get("annotations").IsMSI()
+// Labels of the manifest
+func (m Metadata) Labels() map[string]string {
+	return safeStringMap(m, "labels")
 }
 
 // Annotations of the manifest
-func (m Metadata) Annotations() map[string]interface{} {
-	if !m.HasAnnotations() {
-		return make(map[string]interface{})
+func (m Metadata) Annotations() map[string]string {
+	return safeStringMap(m, "annotations")
+}
+
+// safeStringMap safely returns a string map:
+// - returns if map[string]string
+// - converts if map[string]interface{}
+// - zeroes if anything else
+func safeStringMap(m map[string]interface{}, key string) map[string]string {
+	switch t := m[key].(type) {
+	case map[string]string:
+		return t
+	case map[string]interface{}:
+		mss := make(map[string]string)
+		for k, v := range t {
+			s, ok := v.(string)
+			if !ok {
+				continue
+			}
+			mss[k] = s
+		}
+		m[key] = mss
+		return m[key].(map[string]string)
+	default:
+		m[key] = make(map[string]string)
+		return m[key].(map[string]string)
 	}
-	return m["annotations"].(map[string]interface{})
 }
 
 // List of individual Manifests
diff --git a/pkg/kubernetes/reconcile.go b/pkg/kubernetes/reconcile.go
index ae397c4c..1403ba87 100644
--- a/pkg/kubernetes/reconcile.go
+++ b/pkg/kubernetes/reconcile.go
@@ -52,6 +52,11 @@ var kindOrder = []string{
 	"APIService",
 }
 
+const (
+	MetadataPrefix   = "tanka.dev"
+	LabelEnvironment = MetadataPrefix + "/environment"
+)
+
 // Reconcile extracts kubernetes Manifests from raw evaluated jsonnet <kind>/<name>,
 // provided the manifests match the given regular expressions. It finds each manifest by
 // recursively walking the jsonnet structure.
@@ -59,7 +64,7 @@ var kindOrder = []string{
 // In addition, we sort the manifests to ensure the order is consistent in each
 // show/diff/apply cycle. This isn't necessary, but it does help users by producing
 // consistent diffs.
-func Reconcile(raw map[string]interface{}, spec v1alpha1.Spec, targets []*regexp.Regexp) (state manifest.List, err error) {
+func Reconcile(raw map[string]interface{}, cfg v1alpha1.Config, targets []*regexp.Regexp) (state manifest.List, err error) {
 	extracted, err := extract(raw)
 	if err != nil {
 		return nil, errors.Wrap(err, "flattening manifests")
@@ -67,6 +72,12 @@ func Reconcile(raw map[string]interface{}, spec v1alpha1.Spec, targets []*regexp
 
 	out := make(manifest.List, 0, len(extracted))
 	for _, m := range extracted {
+
+		// inject tanka.dev/environment label
+		if cfg.Spec.InjectLabels {
+			m.Metadata().Labels()[LabelEnvironment] = cfg.Metadata.NameLabel()
+		}
+
 		out = append(out, m)
 	}
 
diff --git a/pkg/spec/v1alpha1/config.go b/pkg/spec/v1alpha1/config.go
index ba527003..2e29220d 100644
--- a/pkg/spec/v1alpha1/config.go
+++ b/pkg/spec/v1alpha1/config.go
@@ -1,5 +1,7 @@
 package v1alpha1
 
+import "strings"
+
 // New creates a new Config object with internal values already set
 func New() *Config {
 	c := Config{}
@@ -31,9 +33,14 @@ type Metadata struct {
 	Labels map[string]string `json:"labels,omitempty"`
 }
 
+func (m Metadata) NameLabel() string {
+	return strings.Replace(m.Name, "/", ".", -1)
+}
+
 // Spec defines Kubernetes properties
 type Spec struct {
 	APIServer    string `json:"apiServer"`
 	Namespace    string `json:"namespace"`
 	DiffStrategy string `json:"diffStrategy,omitempty"`
+	InjectLabels bool   `json:"injectLabels,omitempty"`
 }
diff --git a/pkg/tanka/parse.go b/pkg/tanka/parse.go
index d141f140..95e7920e 100644
--- a/pkg/tanka/parse.go
+++ b/pkg/tanka/parse.go
@@ -23,7 +23,7 @@ type ParseResult struct {
 }
 
 func (p *ParseResult) newKube() (*kubernetes.Kubernetes, error) {
-	kube, err := kubernetes.New(p.Env.Spec)
+	kube, err := kubernetes.New(*p.Env)
 	if err != nil {
 		return nil, errors.Wrap(err, "connecting to Kubernetes")
 	}
@@ -38,7 +38,7 @@ func parse(dir string, opts *options) (*ParseResult, error) {
 		return nil, err
 	}
 
-	rec, err := kubernetes.Reconcile(raw, env.Spec, opts.targets)
+	rec, err := kubernetes.Reconcile(raw, *env, opts.targets)
 
 	if err != nil {
 		return nil, errors.Wrap(err, "reconciling")
diff --git a/pkg/tanka/prune.go b/pkg/tanka/prune.go
new file mode 100644
index 00000000..f95d16f5
--- /dev/null
+++ b/pkg/tanka/prune.go
@@ -0,0 +1,54 @@
+package tanka
+
+import (
+	"fmt"
+
+	"github.com/grafana/tanka/pkg/kubernetes"
+	"github.com/grafana/tanka/pkg/term"
+)
+
+// Prune deletes all resources from the cluster, that are no longer present in
+// Jsonnet. It uses the `tanka.dev/environment` label to identify those.
+func Prune(baseDir string, mods ...Modifier) error {
+	opts := parseModifiers(mods)
+
+	// parse jsonnet, init k8s client
+	p, err := parse(baseDir, opts)
+	if err != nil {
+		return err
+	}
+	kube, err := p.newKube()
+	if err != nil {
+		return err
+	}
+	defer kube.Close()
+
+	// find orphaned resources
+	orphaned, err := kube.Orphaned(p.Resources)
+	if err != nil {
+		return err
+	}
+
+	if len(orphaned) == 0 {
+		fmt.Println("Nothing found to prune.")
+		return nil
+	}
+
+	// print diff
+	diff, err := kubernetes.StaticDiffer(false)(orphaned)
+	if err != nil {
+		// static diff can't fail normally, so unlike in apply, this is fatal
+		// here
+		return err
+	}
+	fmt.Print(term.Colordiff(*diff).String())
+
+	// prompt for confirm
+	if opts.apply.AutoApprove {
+	} else if err := confirmPrompt("Pruning from", p.Env.Spec.Namespace, kube.Info()); err != nil {
+		return err
+	}
+
+	// delete resources
+	return kube.Delete(orphaned, kubernetes.DeleteOpts(opts.apply))
+}
diff --git a/pkg/tanka/status.go b/pkg/tanka/status.go
index 89bb3055..a56635dc 100644
--- a/pkg/tanka/status.go
+++ b/pkg/tanka/status.go
@@ -28,7 +28,7 @@ func Status(baseDir string, mods ...Modifier) (*Info, error) {
 		return nil, err
 	}
 
-	r.Env.Spec.DiffStrategy = kube.Spec.DiffStrategy
+	r.Env.Spec.DiffStrategy = kube.Env.Spec.DiffStrategy
 
 	return &Info{
 		Env:       r.Env,
diff --git a/pkg/tanka/workflow.go b/pkg/tanka/workflow.go
index bd41685e..960194f6 100644
--- a/pkg/tanka/workflow.go
+++ b/pkg/tanka/workflow.go
@@ -3,7 +3,10 @@ package tanka
 import (
 	"fmt"
 
+	"github.com/fatih/color"
+
 	"github.com/grafana/tanka/pkg/kubernetes"
+	"github.com/grafana/tanka/pkg/kubernetes/client"
 	"github.com/grafana/tanka/pkg/kubernetes/manifest"
 	"github.com/grafana/tanka/pkg/term"
 )
@@ -11,9 +14,6 @@ import (
 // Apply parses the environment at the given directory (a `baseDir`) and applies
 // the evaluated jsonnet to the Kubernetes cluster defined in the environments
 // `spec.json`.
-// NOTE: This function prints on screen in default configuration.
-// Use the `WithWarnWriter` modifier to change that. The `WithApply*` modifiers
-// may be used to further influence the behavior.
 func Apply(baseDir string, mods ...Modifier) error {
 	opts := parseModifiers(mods)
 
@@ -27,7 +27,8 @@ func Apply(baseDir string, mods ...Modifier) error {
 	}
 	defer kube.Close()
 
-	diff, err := kube.Diff(p.Resources, kubernetes.DiffOpts{})
+	// show diff
+	diff, err := kube.Diff(p.Resources, kubernetes.DiffOpts{Strategy: opts.diff.Strategy})
 	switch {
 	case err != nil:
 		// This is not fatal, the diff is not strictly required
@@ -43,9 +44,30 @@ func Apply(baseDir string, mods ...Modifier) error {
 		fmt.Print(b.String())
 	}
 
+	// prompt for confirmation
+	if opts.apply.AutoApprove {
+	} else if err := confirmPrompt("Applying to", p.Env.Spec.Namespace, kube.Info()); err != nil {
+		return err
+	}
+
 	return kube.Apply(p.Resources, opts.apply)
 }
 
+// confirmPrompt asks the user for confirmation before apply
+func confirmPrompt(action, namespace string, info client.Info) error {
+	alert := color.New(color.FgRed, color.Bold).SprintFunc()
+
+	return term.Confirm(
+		fmt.Sprintf(`%s namespace '%s' of cluster '%s' at '%s' using context '%s'.`, action,
+			alert(namespace),
+			alert(info.Kubeconfig.Cluster.Name),
+			alert(info.Kubeconfig.Cluster.Cluster.Server),
+			alert(info.Kubeconfig.Context.Name),
+		),
+		"yes",
+	)
+}
+
 // Diff parses the environment at the given directory (a `baseDir`) and returns
 // the differences from the live cluster state in `diff(1)` format. If the
 // `WithDiffSummarize` modifier is used, a histogram created using `diffstat(1)`


Test patch:
diff --git a/pkg/kubernetes/kubernetes_test.go b/pkg/kubernetes/kubernetes_test.go
index a8641317..1c0ba4cb 100644
--- a/pkg/kubernetes/kubernetes_test.go
+++ b/pkg/kubernetes/kubernetes_test.go
@@ -28,6 +28,14 @@ func TestReconcile(t *testing.T) {
 			deep: testDataRegular().Deep,
 			flat: mapToList(testDataRegular().Flat),
 		},
+		{
+			name: "injectLabels",
+			deep: testDataRegular().Deep,
+			flat: mapToList(testDataRegular().Flat),
+			spec: v1alpha1.Spec{
+				InjectLabels: true,
+			},
+		},
 		{
 			name: "targets",
 			deep: testDataDeep().Deep,
@@ -64,10 +72,19 @@ func TestReconcile(t *testing.T) {
 	for _, c := range tests {
 		t.Run(c.name, func(t *testing.T) {
 			config := v1alpha1.New()
+			config.Metadata.Name = "testdata"
 			config.Spec = c.spec
-			got, err := Reconcile(c.deep.(map[string]interface{}), config.Spec, c.targets)
 
+			if config.Spec.InjectLabels {
+				for i, m := range c.flat {
+					m.Metadata().Labels()[LabelEnvironment] = config.Metadata.NameLabel()
+					c.flat[i] = m
+				}
+			}
+
+			got, err := Reconcile(c.deep.(map[string]interface{}), *config, c.targets)
 			require.Equal(t, c.err, err)
+
 			assert.ElementsMatch(t, c.flat, got)
 		})
 	}
@@ -76,7 +93,7 @@ func TestReconcile(t *testing.T) {
 func TestReconcileOrder(t *testing.T) {
 	got := make([]manifest.List, 10)
 	for i := 0; i < 10; i++ {
-		r, err := Reconcile(testDataDeep().Deep.(map[string]interface{}), v1alpha1.New().Spec, nil)
+		r, err := Reconcile(testDataDeep().Deep.(map[string]interface{}), *v1alpha1.New(), nil)
 		require.NoError(t, err)
 		got[i] = r
 	}
diff --git a/pkg/kubernetes/reconcile_test.go b/pkg/kubernetes/reconcile_test.go
index cf89785a..187b295f 100644
--- a/pkg/kubernetes/reconcile_test.go
+++ b/pkg/kubernetes/reconcile_test.go
@@ -179,7 +179,7 @@ func TestReconcileSorting(t *testing.T) {
 	}
 
 	for _, test := range tests {
-		res, err := Reconcile(test.raw, v1alpha1.New().Spec, test.targets)
+		res, err := Reconcile(test.raw, *v1alpha1.New(), test.targets)
 
 		require.NoError(t, err)
 		require.Equal(t, test.state, res)