Repository: grafana/tanka
Pull request #312 (2020-07-03 20:27:59)

feat(process): Naive namespaces
Now that lists are always unwrapped, we assume all other objects use `ObjectMeta`, so they do accept a namespace, regardless of whether they actually use it.

This allows us to simplify our code, by always setting it. For yet unknown edge-cases, we allow using the `tanka.dev/namespaced` annotation to override this behaviour.

Removed the nsPatch injection method, as it is not needed anymore. Show and Apply will behave exactly the same now.

I decided against implementing a default list for cluster-wide resources, because this seems to make things only marginally more correct in theory, but has no effect on the outcome, but comes at the cost of maintaining yet another static list in our code. Quick analysis suggested there is no official type that won't use ObjectMeta, which makes me pretty confident here.

Fixes https://github.com/grafana/tanka/issues/269
Fixes https://github.com/grafana/tanka/issues/308

captncraig: Here's my concern about just adding namespaces to everything without exception:

If I wanted to export into a file tree like `{cluster}/{namespace}/{kind}-{name}.yaml`, I would find cluster roles under the namespace of their environment instead of under `default` or maybe `` like I would expect to find them. 

The kubernetes API won't complain, but things will not necessarily go into the directories I'd want them to.

Of course we could also work around this by having a set of overlays we put over the k8s jsonnet lib that add the annotation to those constructors as well.
hamishforbes: How would this affect an 'environment' with resources across namespaces?

I would ideally like to have a setup like
```
environments/cluster1/base/
environments/cluster1/myapp-dev1/
environments/cluster1/myapp-dev2/
environments/cluster2/base/
environments/cluster2/myapp-dev3/
lib/myapp/
lib/base/
```

Where the `myapp` lib is a collection of resources for an instance of our application and `base` is our cluster configuration, ingress, logging, metrics etc.

App environments are all under 1 namespace but the cluster baseline is spread across a few.
i don't really want to have to do `cluster/base-logging`, `cluster/base-metrics` etc as separate tanka environments, conceptually they're the same thing and i want to apply them all at once.
sh0rez: @hamishforbes this should behave exactly as it did before, at least during apply.

Tanka will only ever set the namespace defined in `spec.json`, if your resources that come from Jsonnet don't already have one. If they do, Tanka won't touch that (it never did).

Only two differences will be there:
- Cluster-wide resources will show a namespace in YAML, but this will always be discarded by kubectl afterwards. This makes our code much simpler however
- Namespaces will already be present during `show` or `export`, so they are actually passed to CD tools like Argo or Flux if needed
Duologic: I'd rather see Tanka or Kubectl return an error (or at least a warning), expecting the developer/jsonnet to set the namespace instead, depending on Tanka to resolve the namespace promotes negligence imo.

Patch:
diff --git a/cmd/tk/export.go b/cmd/tk/export.go
index 66389363..79b791e8 100644
--- a/cmd/tk/export.go
+++ b/cmd/tk/export.go
@@ -17,6 +17,11 @@ import (
 	"github.com/grafana/tanka/pkg/tanka"
 )
 
+// BelRune is a string of the Ascii character BEL which made computers ring in ancient times
+// We use it as "magic" char for the subfolder creation as it is a non printable character and thereby will never be
+// in a valid filepath by accident. Only when we include it.
+const BelRune = string(rune(7))
+
 func exportCmd() *cli.Command {
 	args := workflowArgs
 	args.Validator = cli.ValidateExact(2)
@@ -31,6 +36,7 @@ func exportCmd() *cli.Command {
 	getExtCode := extCodeParser(cmd.Flags())
 	format := cmd.Flags().String("format", "{{.apiVersion}}.{{.kind}}-{{.metadata.name}}", "https://tanka.dev/exporting#filenames")
 	extension := cmd.Flags().String("extension", "yaml", "File extension")
+	merge := cmd.Flags().Bool("merge", false, "Allow merging with existing directory")
 
 	templateFuncMap := template.FuncMap{
 		"lower": func(s string) string {
@@ -45,12 +51,16 @@ func exportCmd() *cli.Command {
 		if err != nil {
 			return fmt.Errorf("Checking target dir: %s", err)
 		}
-		if !empty {
-			return fmt.Errorf("Target dir `%s` not empty. Aborting.", to)
+		if !empty && !*merge {
+			return fmt.Errorf("Output dir `%s` not empty. Pass --merge to ignore this", to)
 		}
 
 		// exit early if the template is bad
-		tmpl, err := template.New("").Funcs(templateFuncMap).Parse(*format)
+
+		// Replace all os.path separators in string with BelRune for creating subfolders
+		replacedFormat := strings.Replace(*format, string(os.PathSeparator), BelRune, -1)
+
+		tmpl, err := template.New("").Funcs(templateFuncMap).Parse(replacedFormat)
 		if err != nil {
 			return fmt.Errorf("Parsing name format: %s", err)
 		}
@@ -70,11 +80,29 @@ func exportCmd() *cli.Command {
 			if err := tmpl.Execute(&buf, m); err != nil {
 				log.Fatalln("executing name template:", err)
 			}
-			name := strings.Replace(buf.String(), "/", "-", -1)
 
+			// Replace all os.path separators in string in order to not accidentally create subfolders
+			name := strings.Replace(buf.String(), string(os.PathSeparator), "-", -1)
+			// Replace the BEL character inserted with a path separator again in order to create a subfolder
+			name = strings.Replace(name, BelRune, string(os.PathSeparator), -1)
+
+			// Create all subfolders in path
+			path := filepath.Join(to, name+"."+*extension)
+
+			// Abort if already exists
+			if ok, err := fileExists(path); err != nil {
+				return err
+			} else if !ok {
+				return fmt.Errorf("File '%s' already exists. Aborting", path)
+			}
+
+			// Write file
+			if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
+				return fmt.Errorf("creating filepath '%s': %s", filepath.Dir(path), err)
+			}
 			data := m.String()
-			if err := ioutil.WriteFile(filepath.Join(to, name+"."+*extension), []byte(data), 0644); err != nil {
-				return fmt.Errorf("Writing manifest: %s", err)
+			if err := ioutil.WriteFile(path, []byte(data), 0644); err != nil {
+				return fmt.Errorf("writing manifest: %s", err)
 			}
 		}
 
@@ -83,6 +111,14 @@ func exportCmd() *cli.Command {
 	return cmd
 }
 
+func fileExists(name string) (bool, error) {
+	_, err := os.Stat(name)
+	if os.IsNotExist(err) {
+		return false, nil
+	}
+	return err != nil, err
+}
+
 func dirEmpty(dir string) (bool, error) {
 	f, err := os.Open(dir)
 	if os.IsNotExist(err) {
diff --git a/cmd/tk/main.go b/cmd/tk/main.go
index c3c6d49e..b293a06f 100644
--- a/cmd/tk/main.go
+++ b/cmd/tk/main.go
@@ -40,6 +40,7 @@ func main() {
 		showCmd(),
 		diffCmd(),
 		pruneCmd(),
+		deleteCmd(),
 	)
 
 	rootCmd.AddCommand(
diff --git a/cmd/tk/workflow.go b/cmd/tk/workflow.go
index 36a1c604..4c8f7a78 100644
--- a/cmd/tk/workflow.go
+++ b/cmd/tk/workflow.go
@@ -84,6 +84,35 @@ func pruneCmd() *cli.Command {
 	return cmd
 }
 
+func deleteCmd() *cli.Command {
+	cmd := &cli.Command{
+		Use:   "delete <path>",
+		Short: "delete the environment from cluster",
+		Args:  workflowArgs,
+	}
+
+	vars := workflowFlags(cmd.Flags())
+	force := cmd.Flags().Bool("force", false, "force deleting (kubectl delete --force)")
+	validate := cmd.Flags().Bool("validate", true, "validation of resources (kubectl --validate=false)")
+	autoApprove := cmd.Flags().Bool("dangerous-auto-approve", false, "skip interactive approval. Only for automation!")
+	getExtCode := extCodeParser(cmd.Flags())
+
+	cmd.Run = func(cmd *cli.Command, args []string) error {
+		err := tanka.Delete(args[0],
+			tanka.WithTargets(stringsToRegexps(vars.targets)),
+			tanka.WithExtCode(getExtCode()),
+			tanka.WithApplyForce(*force),
+			tanka.WithApplyValidate(*validate),
+			tanka.WithApplyAutoApprove(*autoApprove),
+		)
+		if err != nil {
+			return err
+		}
+		return nil
+	}
+	return cmd
+}
+
 func diffCmd() *cli.Command {
 	cmd := &cli.Command{
 		Use:   "diff <path>",
diff --git a/docs/docs/namespaces.md b/docs/docs/namespaces.md
new file mode 100644
index 00000000..d4a436c4
--- /dev/null
+++ b/docs/docs/namespaces.md
@@ -0,0 +1,46 @@
+---
+name: Namespaces
+route: /namespaces
+---
+
+# Namespaces
+
+When using Tanka, namespaces are handled slightly different compared to
+`kubectl`, because environments offer more granular control than contexts used
+by `kubectl`.
+
+## Default namespaces
+
+In the [`spec.json`](/config/#file-format) of each environment, you can set the
+`spec.namespace` field, which is the default namespace. The default namespace is
+set for every resource that **does not** have a namespace **set from Jsonnet**.
+
+|     | Scenario                                                                           | Action                                                                          |
+| --- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
+| 1.  | Your resource **lacks namespace** information (`metadata.namespace`) unset or `""` | Tanka sets `metadata.namespace` to the value of `spec.namespace` in `spec.json` |
+| 2.  | Your resource **already has** namespace information                                | Tanka does nothing, accepting the explicit namespace                            |
+
+While we recommend keeping environments limited to a single namespace, there are
+legit cases where it's handy to have them span multiple namespaces, for example:
+
+- Some other piece of software (Operators, etc) require resources to be in a specific namespace
+- A rarely changing "base" environment holding resources deployed for many clusters in the same way
+- etc.
+
+## Cluster-wide resources
+
+Some resources in Kubernetes are cluster-wide, meaning they don't belong to a single namespace at all.
+
+To Tanka these appear as _Scenario 1 (see above)_, so it will set the default
+namespace. In reality however, this is **not a problem**, because `kubectl`
+discards this information silently. We made this design-choice, because it
+simplifies our code a lot.
+
+In case this ever becomes a problem, you can **override this** behavior
+per-resource, by setting the `tanka.dev/namespaced` annotation to `"false"`
+(must be of `string` type):
+
+```jsonnet
+thing: clusterRole.new("myClusterRole")
+       + clusterRole.mixin.metadata.withAnnotationsMixin({ "tanka.dev/namespaced": "false" })
+```
diff --git a/docs/doczrc.js b/docs/doczrc.js
index bef46755..51ff90a3 100644
--- a/docs/doczrc.js
+++ b/docs/doczrc.js
@@ -50,6 +50,7 @@ export default {
     "Garbage collection",
     "Command-line completion",
     "Diff strategies",
+    "Namespaces",
 
     // reference
     "Configuration Reference",
diff --git a/pkg/kubernetes/client/context.go b/pkg/kubernetes/client/context.go
index 42d5f70b..b8c8d178 100644
--- a/pkg/kubernetes/client/context.go
+++ b/pkg/kubernetes/client/context.go
@@ -5,7 +5,6 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
-	"io/ioutil"
 	"os"
 	"strings"
 
@@ -27,33 +26,6 @@ func findContext(endpoint string) (Config, error) {
 	}, nil
 }
 
-// writeNamespacePatch writes a temporary file that includes only the previously
-// discovered context with the `context.namespace` field set to the default
-// namespace from `spec.json`. Adding this file to `$KUBECONFIG` results in
-// `kubectl` picking this up, effectively setting the default namespace.
-func writeNamespacePatch(context Context, defaultNamespace string) (string, error) {
-	context.Context.Namespace = defaultNamespace
-
-	patch := map[string]interface{}{
-		"contexts": []interface{}{context},
-	}
-	out, err := json.Marshal(patch)
-	if err != nil {
-		return "", err
-	}
-
-	f, err := ioutil.TempFile("", "tk-kubectx-namespace-*.yaml")
-	if err != nil {
-		return "", err
-	}
-
-	if err = ioutil.WriteFile(f.Name(), out, 0644); err != nil {
-		return "", err
-	}
-
-	return f.Name(), nil
-}
-
 // Kubeconfig returns the merged $KUBECONFIG of the host
 func Kubeconfig() (objx.Map, error) {
 	cmd := kubectlCmd("config", "view", "-o", "json")
diff --git a/pkg/kubernetes/client/exec.go b/pkg/kubernetes/client/exec.go
index 35e69bd0..1edc3d02 100644
--- a/pkg/kubernetes/client/exec.go
+++ b/pkg/kubernetes/client/exec.go
@@ -4,9 +4,6 @@ import (
 	"fmt"
 	"os"
 	"os/exec"
-	"path/filepath"
-	"sort"
-	"strings"
 )
 
 // kubectlCmd returns command a object that will launch kubectl at an appropriate path.
@@ -30,7 +27,6 @@ func (k Kubectl) ctl(action string, args ...string) *exec.Cmd {
 
 	// prepare the cmd
 	cmd := kubectlCmd(argv...)
-	cmd.Env = patchKubeconfig(k.nsPatch, os.Environ())
 
 	if os.Getenv("TANKA_KUBECTL_TRACE") != "" {
 		fmt.Println(cmd.String())
@@ -38,43 +34,3 @@ func (k Kubectl) ctl(action string, args ...string) *exec.Cmd {
 
 	return cmd
 }
-
-func patchKubeconfig(file string, e []string) []string {
-	// prepend namespace patch to $KUBECONFIG
-	env := newEnv(e)
-	if _, ok := env["KUBECONFIG"]; !ok {
-		env["KUBECONFIG"] = filepath.Join(homeDir(), ".kube", "config") // kubectl default
-	}
-	env["KUBECONFIG"] = fmt.Sprintf("%s%s%s", file, string(os.PathListSeparator), env["KUBECONFIG"])
-	return env.render()
-}
-
-// environment is a helper type for manipulating os.Environ() more easily
-type environment map[string]string
-
-func newEnv(e []string) environment {
-	env := make(environment)
-	for _, s := range e {
-		kv := strings.SplitN(s, "=", 2)
-		env[kv[0]] = kv[1]
-	}
-	return env
-}
-
-func (e environment) render() []string {
-	s := make([]string, 0, len(e))
-	for k, v := range e {
-		s = append(s, fmt.Sprintf("%s=%s", k, v))
-	}
-	sort.Strings(s)
-	return s
-}
-
-func homeDir() string {
-	home, err := os.UserHomeDir()
-	// unable to find homedir. Should never happen on the supported os/arch
-	if err != nil {
-		panic("Unable to find your $HOME directory. This should not have ever happened. Please open an issue on https://github.com/grafana/tanka/issues with your OS and ARCH.")
-	}
-	return home
-}
diff --git a/pkg/kubernetes/client/kubectl.go b/pkg/kubernetes/client/kubectl.go
index bc7118eb..5fde8957 100644
--- a/pkg/kubernetes/client/kubectl.go
+++ b/pkg/kubernetes/client/kubectl.go
@@ -14,13 +14,10 @@ import (
 // Kubectl uses the `kubectl` command to operate on a Kubernetes cluster
 type Kubectl struct {
 	info Info
-
-	// internal fields
-	nsPatch string
 }
 
 // New returns a instance of Kubectl with a correct context already discovered.
-func New(endpoint, defaultNamespace string) (*Kubectl, error) {
+func New(endpoint string) (*Kubectl, error) {
 	k := Kubectl{}
 
 	// discover context
@@ -30,13 +27,6 @@ func New(endpoint, defaultNamespace string) (*Kubectl, error) {
 		return nil, errors.Wrap(err, "finding usable context")
 	}
 
-	// set the default namespace by injecting it into the context
-	nsPatch, err := writeNamespacePatch(k.info.Kubeconfig.Context, defaultNamespace)
-	if err != nil {
-		return nil, errors.Wrap(err, "creating $KUBECONFIG patch for default namespace")
-	}
-	k.nsPatch = nsPatch
-
 	// query versions (requires context)
 	k.info.ClientVersion, k.info.ServerVersion, err = k.version()
 	if err != nil {
@@ -52,9 +42,8 @@ func (k Kubectl) Info() Info {
 }
 
 // Close runs final cleanup:
-// - remove the nsPatch file
 func (k Kubectl) Close() error {
-	return os.RemoveAll(k.nsPatch)
+	return nil
 }
 
 // Namespaces of the cluster
diff --git a/pkg/kubernetes/delete.go b/pkg/kubernetes/delete.go
index 4330eb83..e0a234fe 100644
--- a/pkg/kubernetes/delete.go
+++ b/pkg/kubernetes/delete.go
@@ -3,11 +3,20 @@ package kubernetes
 import (
 	"github.com/grafana/tanka/pkg/kubernetes/client"
 	"github.com/grafana/tanka/pkg/kubernetes/manifest"
+	"github.com/grafana/tanka/pkg/process"
 )
 
 type DeleteOpts client.DeleteOpts
 
-func (k *Kubernetes) Delete(state manifest.List, opts DeleteOpts) error {
+func (k *Kubernetes) Delete(state manifest.List, opts ApplyOpts) error {
+	// Sort and reverse the manifests to avoid cascading deletions
+	process.Sort(state)
+	for i := 0; i < len(state)/2; i++ {
+		t := state[i]
+		state[i] = state[len(state)-1-i]
+		state[len(state)-1-i] = t
+	}
+
 	for _, m := range state {
 		if err := k.ctl.Delete(m.Metadata().Namespace(), m.Kind(), m.Metadata().Name(), client.DeleteOpts(opts)); err != nil {
 			return err
diff --git a/pkg/kubernetes/kubernetes.go b/pkg/kubernetes/kubernetes.go
index 3c725c79..2e4f07c5 100644
--- a/pkg/kubernetes/kubernetes.go
+++ b/pkg/kubernetes/kubernetes.go
@@ -28,7 +28,7 @@ type Differ func(manifest.List) (*string, error)
 // New creates a new Kubernetes with an initialized client
 func New(env v1alpha1.Config) (*Kubernetes, error) {
 	// setup client
-	ctl, err := client.New(env.Spec.APIServer, env.Spec.Namespace)
+	ctl, err := client.New(env.Spec.APIServer)
 	if err != nil {
 		return nil, err
 	}
diff --git a/pkg/kubernetes/manifest/errors.go b/pkg/kubernetes/manifest/errors.go
index f1196b90..3afcf339 100644
--- a/pkg/kubernetes/manifest/errors.go
+++ b/pkg/kubernetes/manifest/errors.go
@@ -3,33 +3,42 @@ package manifest
 import (
 	"fmt"
 	"strings"
+
+	"github.com/fatih/color"
 )
 
 // SchemaError means that some expected fields were missing
 type SchemaError struct {
-	Fields   map[string]bool
+	Fields   map[string]error
 	Name     string
 	Manifest Manifest
 }
 
+var (
+	redf    = color.New(color.FgRed, color.Bold, color.Underline).Sprintf
+	yellowf = color.New(color.FgYellow).Sprintf
+	bluef   = color.New(color.FgBlue, color.Bold).Sprintf
+)
+
 // Error returns the fields the manifest at the path is missing
 func (s *SchemaError) Error() string {
-	fields := make([]string, 0, len(s.Fields))
-	for k, missing := range s.Fields {
-		if !missing {
-			continue
-		}
-		fields = append(fields, k)
-	}
-
 	if s.Name == "" {
 		s.Name = "Resource"
 	}
 
-	msg := fmt.Sprintf("%s has missing or invalid fields: %s", s.Name, strings.Join(fields, ", "))
+	msg := fmt.Sprintf("%s has missing or invalid fields:\n", redf(s.Name))
+
+	for k, err := range s.Fields {
+		if err == nil {
+			continue
+		}
+
+		msg += fmt.Sprintf("  - %s: %s\n", yellowf(k), err)
+	}
 
 	if s.Manifest != nil {
-		msg += fmt.Sprintf(":\n\n%s\n\nPlease check above object.", SampleString(s.Manifest.String()).Indent(2))
+		msg += bluef("\nPlease check below object:\n")
+		msg += SampleString(s.Manifest.String()).Indent(2)
 	}
 
 	return msg
diff --git a/pkg/kubernetes/manifest/manifest.go b/pkg/kubernetes/manifest/manifest.go
index b673ecb8..f2c1695f 100644
--- a/pkg/kubernetes/manifest/manifest.go
+++ b/pkg/kubernetes/manifest/manifest.go
@@ -38,25 +38,37 @@ func (m Manifest) String() string {
 	return string(y)
 }
 
+var (
+	ErrInvalidStr = fmt.Errorf("Missing or not of string type")
+	ErrInvalidMap = fmt.Errorf("Missing or not an object")
+)
+
 // Verify checks whether the manifest is correctly structured
 func (m Manifest) Verify() error {
 	o := m2o(m)
-	fields := make(map[string]bool)
+	fields := make(map[string]error)
 
 	if !o.Get("kind").IsStr() {
-		fields["kind"] = true
+		fields["kind"] = ErrInvalidStr
 	}
 	if !o.Get("apiVersion").IsStr() {
-		fields["apiVersion"] = true
+		fields["apiVersion"] = ErrInvalidStr
 	}
 
 	// Lists don't have `metadata`
 	if !m.IsList() {
 		if !o.Get("metadata").IsMSI() {
-			fields["metadata"] = true
+			fields["metadata"] = ErrInvalidMap
 		}
 		if !o.Get("metadata.name").IsStr() {
-			fields["metadata.name"] = true
+			fields["metadata.name"] = ErrInvalidStr
+		}
+
+		if err := verifyMSS(o.Get("metadata.labels").Data()); err != nil {
+			fields["metadata.labels"] = err
+		}
+		if err := verifyMSS(o.Get("metadata.annotations").Data()); err != nil {
+			fields["metadata.annotations"] = err
 		}
 	}
 
@@ -70,6 +82,27 @@ func (m Manifest) Verify() error {
 	}
 }
 
+// verifyMSS checks that ptr is either nil or a string map
+func verifyMSS(ptr interface{}) error {
+	if ptr == nil {
+		return nil
+	}
+
+	switch t := ptr.(type) {
+	case map[string]string:
+		return nil
+	case map[string]interface{}:
+		for k, v := range t {
+			if _, ok := v.(string); !ok {
+				return fmt.Errorf("Contains non-string field '%s' of type '%T'", k, v)
+			}
+		}
+		return nil
+	default:
+		return fmt.Errorf("Must be object, but got '%T' instead", ptr)
+	}
+}
+
 // IsList returns whether the manifest is a List type, containing other
 // manifests as children. Code based on
 // https://github.com/kubernetes/apimachinery/blob/61490fe38e784592212b24b9878306b09be45ab0/pkg/apis/meta/v1/unstructured/unstructured.go#L54
diff --git a/pkg/process/namespace.go b/pkg/process/namespace.go
new file mode 100644
index 00000000..e29176a7
--- /dev/null
+++ b/pkg/process/namespace.go
@@ -0,0 +1,42 @@
+package process
+
+import (
+	"github.com/grafana/tanka/pkg/kubernetes/manifest"
+)
+
+const (
+	// AnnotationNamespaced can be set on any resource to override the decision
+	// whether 'metadata.namespace' is set by Tanka
+	AnnotationNamespaced = MetadataPrefix + "/namespaced"
+)
+
+// Namespace injects the default namespace of the environment into each
+// resources, that does not already define one. AnnotationNamespaced can be used
+// to disable this per resource
+func Namespace(list manifest.List, def string) manifest.List {
+	if def == "" {
+		return list
+	}
+
+	for i, m := range list {
+		namespaced := true
+
+		// check for annotation override
+		if s, ok := m.Metadata().Annotations()[AnnotationNamespaced]; ok {
+			namespaced = s == "true"
+		}
+
+		if namespaced && !m.Metadata().HasNamespace() {
+			m.Metadata()["namespace"] = def
+		}
+
+		// remove annotations if empty (we always create those by accessing them)
+		if len(m.Metadata().Annotations()) == 0 {
+			delete(m.Metadata(), "annotations")
+		}
+
+		list[i] = m
+	}
+
+	return list
+}
diff --git a/pkg/process/process.go b/pkg/process/process.go
index efc0b5b8..9c6e68ad 100644
--- a/pkg/process/process.go
+++ b/pkg/process/process.go
@@ -35,6 +35,9 @@ func Process(raw map[string]interface{}, cfg v1alpha1.Config, exprs Matchers) (m
 		out = append(out, m)
 	}
 
+	// set default namespace
+	out = Namespace(out, cfg.Spec.Namespace)
+
 	// tanka.dev/** labels
 	out = Label(out, cfg)
 
diff --git a/pkg/tanka/prune.go b/pkg/tanka/prune.go
index 1d2fb2a9..92a68ceb 100644
--- a/pkg/tanka/prune.go
+++ b/pkg/tanka/prune.go
@@ -50,5 +50,5 @@ func Prune(baseDir string, mods ...Modifier) error {
 	}
 
 	// delete resources
-	return kube.Delete(orphaned, kubernetes.DeleteOpts(opts.apply))
+	return kube.Delete(orphaned, opts.apply)
 }
diff --git a/pkg/tanka/tanka.go b/pkg/tanka/tanka.go
index 7b6989ff..fe4f58fa 100644
--- a/pkg/tanka/tanka.go
+++ b/pkg/tanka/tanka.go
@@ -27,6 +27,7 @@ type options struct {
 
 	// additional options for diff
 	diff kubernetes.DiffOpts
+
 	// additional options for apply
 	apply kubernetes.ApplyOpts
 }
diff --git a/pkg/tanka/workflow.go b/pkg/tanka/workflow.go
index e0359f2d..b3c7f510 100644
--- a/pkg/tanka/workflow.go
+++ b/pkg/tanka/workflow.go
@@ -90,6 +90,45 @@ func Diff(baseDir string, mods ...Modifier) (*string, error) {
 	return kube.Diff(l.Resources, opts.diff)
 }
 
+// Delete parses the environment at the given directory (a `baseDir`) and deletes
+// the generated objects from the Kubernetes cluster defined in the environment's
+// `spec.json`.
+func Delete(baseDir string, mods ...Modifier) error {
+	opts := parseModifiers(mods)
+
+	l, err := load(baseDir, opts)
+	if err != nil {
+		return err
+	}
+	kube, err := l.connect()
+	if err != nil {
+		return err
+	}
+	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 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())
+	}
+
+	// prompt for confirmation
+	if opts.apply.AutoApprove {
+	} else if err := confirmPrompt("Deleting from", l.Env.Spec.Namespace, kube.Info()); err != nil {
+		return err
+	}
+
+	return kube.Delete(l.Resources, opts.apply)
+}
+
 // Show parses the environment at the given directory (a `baseDir`) and returns
 // the list of Kubernetes objects.
 // Tip: use the `String()` function on the returned list to get the familiar yaml stream


Test patch:
diff --git a/pkg/kubernetes/client/exec_test.go b/pkg/kubernetes/client/exec_test.go
deleted file mode 100644
index 9f2a91f7..00000000
--- a/pkg/kubernetes/client/exec_test.go
+++ /dev/null
@@ -1,39 +0,0 @@
-package client
-
-import (
-	"fmt"
-	"path/filepath"
-	"testing"
-
-	"github.com/stretchr/testify/assert"
-)
-
-const patchFile = "/tmp/tk-nsPatch.yaml"
-
-func TestPatchKubeconfig(t *testing.T) {
-	cases := []struct {
-		name string
-		env  []string
-		want []string
-	}{
-		{
-			name: "none",
-			env:  []string{},
-			want: []string{
-				fmt.Sprintf("KUBECONFIG=%s:%s", patchFile, filepath.Join(homeDir(), ".kube", "config")),
-			},
-		},
-		{
-			name: "custom",
-			env:  []string{"KUBECONFIG=/home/user/.config/kube"},
-			want: []string{"KUBECONFIG=" + patchFile + ":/home/user/.config/kube"},
-		},
-	}
-
-	for _, c := range cases {
-		t.Run(c.name, func(t *testing.T) {
-			got := patchKubeconfig(patchFile, c.env)
-			assert.Equal(t, c.want, got)
-		})
-	}
-}
diff --git a/pkg/process/namespace_test.go b/pkg/process/namespace_test.go
new file mode 100644
index 00000000..ea17fdd8
--- /dev/null
+++ b/pkg/process/namespace_test.go
@@ -0,0 +1,69 @@
+package process
+
+import (
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+	"github.com/grafana/tanka/pkg/kubernetes/manifest"
+)
+
+func TestNamespace(t *testing.T) {
+	cases := []struct {
+		name          string
+		namespace     string
+		before, after manifest.Manifest
+	}{
+		// resource without a namespace: set it
+		{
+			name:      "simple/namespaced",
+			namespace: "testing",
+			before: manifest.Manifest{
+				"kind": "Deployment",
+			},
+			after: manifest.Manifest{
+				"kind": "Deployment",
+				"metadata": map[string]interface{}{
+					"namespace": "testing",
+				},
+			},
+		},
+
+		// resource with a namespace: ignore it
+		{
+			name:      "already-present",
+			namespace: "ignored",
+			before: manifest.Manifest{
+				"kind":     "Deployment",
+				"metadata": map[string]interface{}{"namespace": "mycoolnamespace"},
+			},
+			after: manifest.Manifest{
+				"kind":     "Deployment",
+				"metadata": map[string]interface{}{"namespace": "mycoolnamespace"},
+			},
+		},
+
+		// empty default ns: do nothing
+		{
+			name:      "no-default",
+			namespace: "",
+			before: manifest.Manifest{
+				"kind":     "Deployment",
+				"metadata": map[string]interface{}{},
+			},
+			after: manifest.Manifest{
+				"kind":     "Deployment",
+				"metadata": map[string]interface{}{},
+			},
+		},
+	}
+
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			result := Namespace(manifest.List{c.before}, c.namespace)
+
+			if diff := cmp.Diff(manifest.List{c.after}, result); diff != "" {
+				t.Error(diff)
+			}
+		})
+	}
+}
diff --git a/pkg/process/process_test.go b/pkg/process/process_test.go
index 32395125..5014b380 100644
--- a/pkg/process/process_test.go
+++ b/pkg/process/process_test.go
@@ -72,6 +72,30 @@ func TestProcess(t *testing.T) {
 				loadFixture("list").Flat["foo.items[1]"],
 			},
 		},
+		{
+			name: "force-namespace",
+			spec: v1alpha1.Spec{Namespace: "tanka"},
+			deep: testDataFlat().Deep,
+			flat: func() manifest.List {
+				f := testDataFlat().Flat["."]
+				f.Metadata()["namespace"] = "tanka"
+				return manifest.List{f}
+			}(),
+		},
+		{
+			name: "custom-namespace",
+			spec: v1alpha1.Spec{Namespace: "tanka"},
+			deep: func() map[string]interface{} {
+				d := testDataFlat().Deep.(map[string]interface{})
+				d["metadata"].(map[string]interface{})["namespace"] = "custom"
+				return d
+			}(),
+			flat: func() manifest.List {
+				f := testDataFlat().Flat["."]
+				f.Metadata()["namespace"] = "custom"
+				return manifest.List{f}
+			}(),
+		},
 	}
 
 	for _, c := range tests {