Repository: grafana/tanka
Pull request #111 (2019-11-14 20:10:08)

refactor(kubernetes): client interface; feat(cli): status
In an attempt to add a simple `tk status` command, I ended up doing the long overdue refactor of the Kubernetes package, which where kubectl code was mixed with logic.

The client (`kubectl`) is now an interface to make this core component of Tanka possibly testable. All kubectl related code is located in the `client` package, while the `kubernetes` package is just assuming that it has a client that is *kubectl compatible*. The goal of this is *not* to be client agnostic, but rather allow mocks.

Everybody please review thoroughly, as we might have broken a lot of things here, due to the lack of tests :/ (Will work on that next).

PS: Sorry for the big commit, wasn't able to split it into logical pieces


Patch:
diff --git a/.drone/drone.jsonnet b/.drone/drone.jsonnet
index 7a8da082..57a8ecd8 100644
--- a/.drone/drone.jsonnet
+++ b/.drone/drone.jsonnet
@@ -54,7 +54,7 @@ local docker(arch) = pipeline('docker-' + arch) {
   ],
 };
 
-local drone = [
+[
   pipeline('check') {
     steps: [
       go('download', ['go mod download']),
@@ -102,8 +102,4 @@ local drone = [
       'docker-arm64',
     ],
   } + constraints.onlyTagOrMaster,
-];
-
-{
-  drone: std.manifestYamlStream(drone),
-}
+]
diff --git a/.drone/drone.yml b/.drone/drone.yml
index 93f2dd5d..ce504245 100644
--- a/.drone/drone.yml
+++ b/.drone/drone.yml
@@ -1,116 +1,100 @@
+---
 kind: pipeline
 name: check
+
+platform:
+  os: linux
+  arch: amd64
+
 steps:
-- commands:
-  - go mod download
+- name: download
   image: golang:1.12
-  name: download
+  commands:
+  - go mod download
   volumes:
   - name: gopath
     path: /go
-- commands:
-  - make lint
-  depends_on:
-  - download
+
+- name: lint
   image: golang:1.12
-  name: lint
+  commands:
+  - make lint
   volumes:
   - name: gopath
     path: /go
-- commands:
-  - make test
   depends_on:
   - download
+
+- name: test
   image: golang:1.12
-  name: test
+  commands:
+  - make test
   volumes:
   - name: gopath
     path: /go
+  depends_on:
+  - download
+
 volumes:
 - name: gopath
   temp: {}
+
 ---
-depends_on:
-- check
 kind: pipeline
 name: release
+
+platform:
+  os: linux
+  arch: amd64
+
 steps:
-- commands:
-  - make cross
+- name: cross
   image: golang:1.12
-  name: cross
+  commands:
+  - make cross
   volumes:
   - name: gopath
     path: /go
-- image: plugins/github-release
-  name: publish
+
+- name: publish
+  image: plugins/github-release
   settings:
     api_key:
       from_secret: GITHUB_TOKEN
     files: dist/*
-    note: 'This is release ${DRONE_TAG} of Tanka (`tk`). Check out the [CHANGELOG](CHANGELOG.md)
-      for detailed release notes.
-
-      ## Install instructions
-
-
-      #### Binary:
-
-      ```bash
-
-      # download the binary (adapt os and arch as needed)
-
-      $ curl -fSL -o "/usr/local/bin/tk" "https://github.com/sh0rez/tanka/releases/download/${DRONE_TAG}/tk-linux-amd64"
-
-
-      # make it executable
-
-      $ chmod a+x "/usr/local/bin/tk"
-
-
-      # have fun :)
-
-      $ tk --help
-
-      ```
-
-
-      #### Docker container:
-
-      https://hub.docker.com/r/grafana/tanka
-
-      ```bash
-
-      $ docker pull grafana/tanka:${DRONE_TAG}
+    note: "This is release ${DRONE_TAG} of Tanka (`tk`). Check out the [CHANGELOG](CHANGELOG.md) for detailed release notes.\n## Install instructions\n\n#### Binary:\n```bash\n# download the binary (adapt os and arch as needed)\n$ curl -fSL -o \"/usr/local/bin/tk\" \"https://github.com/sh0rez/tanka/releases/download/${DRONE_TAG}/tk-linux-amd64\"\n\n# make it executable\n$ chmod a+x \"/usr/local/bin/tk\"\n\n# have fun :)\n$ tk --help\n```\n\n#### Docker container:\nhttps://hub.docker.com/r/grafana/tanka\n```bash\n$ docker pull grafana/tanka:${DRONE_TAG}\n```\n"
+    title: ${DRONE_TAG}
 
-      ```
+volumes:
+- name: gopath
+  temp: {}
 
-      '
-    title: ${DRONE_TAG}
 trigger:
   event:
   - tag
-volumes:
-- name: gopath
-  temp: {}
----
+
 depends_on:
 - check
+
+---
 kind: pipeline
 name: docker-amd64
+
 platform:
-  arch: amd64
   os: linux
+  arch: amd64
+
 steps:
-- commands:
-  - make static
+- name: static
   image: golang:1.12
-  name: static
+  commands:
+  - make static
   volumes:
   - name: gopath
     path: /go
-- image: plugins/docker
-  name: container
+
+- name: container
+  image: plugins/docker
   settings:
     auto_tag: true
     auto_tag_suffix: amd64
@@ -119,32 +103,39 @@ steps:
     repo: grafana/tanka
     username:
       from_secret: docker_username
+
+volumes:
+- name: gopath
+  temp: {}
+
 trigger:
   ref:
   - refs/heads/master
   - refs/heads/docker
   - refs/tags/v*
-volumes:
-- name: gopath
-  temp: {}
----
+
 depends_on:
 - check
+
+---
 kind: pipeline
 name: docker-arm
+
 platform:
-  arch: arm
   os: linux
+  arch: arm
+
 steps:
-- commands:
-  - make static
+- name: static
   image: golang:1.12
-  name: static
+  commands:
+  - make static
   volumes:
   - name: gopath
     path: /go
-- image: plugins/docker
-  name: container
+
+- name: container
+  image: plugins/docker
   settings:
     auto_tag: true
     auto_tag_suffix: arm
@@ -153,32 +144,39 @@ steps:
     repo: grafana/tanka
     username:
       from_secret: docker_username
+
+volumes:
+- name: gopath
+  temp: {}
+
 trigger:
   ref:
   - refs/heads/master
   - refs/heads/docker
   - refs/tags/v*
-volumes:
-- name: gopath
-  temp: {}
----
+
 depends_on:
 - check
+
+---
 kind: pipeline
 name: docker-arm64
+
 platform:
-  arch: arm64
   os: linux
+  arch: arm64
+
 steps:
-- commands:
-  - make static
+- name: static
   image: golang:1.12
-  name: static
+  commands:
+  - make static
   volumes:
   - name: gopath
     path: /go
-- image: plugins/docker
-  name: container
+
+- name: container
+  image: plugins/docker
   settings:
     auto_tag: true
     auto_tag_suffix: arm64
@@ -187,24 +185,31 @@ steps:
     repo: grafana/tanka
     username:
       from_secret: docker_username
+
+volumes:
+- name: gopath
+  temp: {}
+
 trigger:
   ref:
   - refs/heads/master
   - refs/heads/docker
   - refs/tags/v*
-volumes:
-- name: gopath
-  temp: {}
----
+
 depends_on:
-- docker-amd64
-- docker-arm
-- docker-arm64
+- check
+
+---
 kind: pipeline
 name: manifest
+
+platform:
+  os: linux
+  arch: amd64
+
 steps:
-- image: plugins/manifest
-  name: manifest
+- name: manifest
+  image: plugins/manifest
   settings:
     auto_tag: true
     ignore_missing: true
@@ -213,11 +218,20 @@ steps:
     spec: .drone/docker-manifest.tmpl
     username:
       from_secret: docker_username
+
+volumes:
+- name: gopath
+  temp: {}
+
 trigger:
   ref:
   - refs/heads/master
   - refs/heads/docker
   - refs/tags/v*
-volumes:
-- name: gopath
-  temp: {}
+
+depends_on:
+- docker-amd64
+- docker-arm
+- docker-arm64
+
+...
diff --git a/Makefile b/Makefile
index ca76047f..918a1c2a 100644
--- a/Makefile
+++ b/Makefile
@@ -35,4 +35,4 @@ container: static
 
 # CI
 drone:
-	jsonnet .drone/drone.jsonnet  | jq .drone -r | yq -y . > .drone/drone.yml
+	drone jsonnet --source .drone/drone.jsonnet --target .drone/drone.yml --stream --format
diff --git a/cmd/tk/init.go b/cmd/tk/init.go
index ca34e8e8..90c692a0 100644
--- a/cmd/tk/init.go
+++ b/cmd/tk/init.go
@@ -6,8 +6,9 @@ import (
 	"log"
 	"os"
 
-	"github.com/grafana/tanka/pkg/spec/v1alpha1"
 	"github.com/spf13/cobra"
+
+	"github.com/grafana/tanka/pkg/spec/v1alpha1"
 )
 
 // initCmd creates a new application
diff --git a/cmd/tk/jsonnet.go b/cmd/tk/jsonnet.go
index 8e42c150..64cba68c 100644
--- a/cmd/tk/jsonnet.go
+++ b/cmd/tk/jsonnet.go
@@ -4,10 +4,11 @@ import (
 	"log"
 	"path/filepath"
 
-	"github.com/grafana/tanka/pkg/jsonnet"
-	"github.com/grafana/tanka/pkg/jsonnet/jpath"
 	"github.com/pkg/errors"
 	"github.com/spf13/cobra"
+
+	"github.com/grafana/tanka/pkg/jsonnet"
+	"github.com/grafana/tanka/pkg/jsonnet/jpath"
 )
 
 func evalCmd() *cobra.Command {
diff --git a/cmd/tk/main.go b/cmd/tk/main.go
index 0c8379cb..fa1a06dc 100644
--- a/cmd/tk/main.go
+++ b/cmd/tk/main.go
@@ -48,6 +48,7 @@ func main() {
 
 	rootCmd.AddCommand(
 		envCmd(),
+		statusCmd(),
 	)
 
 	// jsonnet commands
diff --git a/cmd/tk/status.go b/cmd/tk/status.go
new file mode 100644
index 00000000..9828192b
--- /dev/null
+++ b/cmd/tk/status.go
@@ -0,0 +1,47 @@
+package main
+
+import (
+	"fmt"
+	"log"
+	"os"
+	"text/tabwriter"
+
+	"github.com/fatih/structs"
+	"github.com/spf13/cobra"
+
+	"github.com/grafana/tanka/pkg/tanka"
+)
+
+func statusCmd() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "status <path>",
+		Short: "display an overview of the environment, including contents and metadata.",
+		Args:  cobra.ExactArgs(1),
+		Annotations: map[string]string{
+			"args": "baseDir",
+		},
+	}
+	cmd.Run = func(cmd *cobra.Command, args []string) {
+		status, err := tanka.Status(args[0])
+		if err != nil {
+			log.Fatalln(err)
+		}
+
+		fmt.Println("Context:", status.Client.Context.Get("name"))
+		fmt.Println("Cluster:", status.Client.Context.Get("context").MustObjxMap().Get("cluster"))
+		fmt.Println("Environment:")
+		for k, v := range structs.Map(status.Env.Spec) {
+			fmt.Printf("  %s: %s\n", k, v)
+		}
+
+		fmt.Println("Resources:")
+		f := "  %s\t%s/%s\n"
+		w := tabwriter.NewWriter(os.Stdout, 0, 0, 4, ' ', 0)
+		fmt.Fprintln(w, "  NAMESPACE\tOBJECTSPEC")
+		for _, r := range status.Resources {
+			fmt.Fprintf(w, f, r.Metadata().Namespace(), r.Kind(), r.Metadata().Name())
+		}
+		w.Flush()
+	}
+	return cmd
+}
diff --git a/go.mod b/go.mod
index 0f75d863..179a8641 100644
--- a/go.mod
+++ b/go.mod
@@ -6,6 +6,7 @@ require (
 	github.com/Masterminds/semver v1.4.2
 	github.com/alecthomas/chroma v0.6.6
 	github.com/fatih/color v1.7.0
+	github.com/fatih/structs v1.1.0
 	github.com/google/go-jsonnet v0.14.1-0.20191006203837-42cb19ef24fb
 	github.com/kr/pretty v0.1.0 // indirect
 	github.com/pkg/errors v0.8.1
diff --git a/go.sum b/go.sum
index 96c24451..ad16a4f8 100644
--- a/go.sum
+++ b/go.sum
@@ -32,6 +32,8 @@ github.com/dlclark/regexp2 v1.1.6 h1:CqB4MjHw0MFCDj+PHHjiESmHX+N7t0tJzKvC6M97BRg
 github.com/dlclark/regexp2 v1.1.6/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
 github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys=
 github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
+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 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
 github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
 github.com/google/go-jsonnet v0.14.1-0.20191006203837-42cb19ef24fb h1:6GyG7yy0z1foZBWyfRa9CZYdcLbSPHR19PW70fJBedI=
diff --git a/pkg/jsonnet/importer.go b/pkg/jsonnet/importer.go
index 59285ffc..9bb401e9 100644
--- a/pkg/jsonnet/importer.go
+++ b/pkg/jsonnet/importer.go
@@ -25,6 +25,7 @@ func NewExtendedImporter(jpath []string) *ExtendedImporter {
 	}
 }
 
+// Import implements the functionality offered by the ExtendedImporter
 func (i *ExtendedImporter) Import(importedFrom, importedPath string) (contents jsonnet.Contents, foundAt string, err error) {
 	// regularly import
 	contents, foundAt, err = i.fi.Import(importedFrom, importedPath)
diff --git a/pkg/kubernetes/client/apply.go b/pkg/kubernetes/client/apply.go
new file mode 100644
index 00000000..ccdddc4e
--- /dev/null
+++ b/pkg/kubernetes/client/apply.go
@@ -0,0 +1,28 @@
+package client
+
+import (
+	"os"
+	"os/exec"
+	"strings"
+
+	"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 {
+	argv := []string{"apply",
+		"--context", k.context.Get("name").MustStr(),
+		"-f", "-",
+	}
+	if opts.Force {
+		argv = append(argv, "--force")
+	}
+
+	cmd := exec.Command("kubectl", argv...)
+	cmd.Stdout = os.Stdout
+	cmd.Stderr = os.Stderr
+
+	cmd.Stdin = strings.NewReader(data.String())
+
+	return cmd.Run()
+}
diff --git a/pkg/kubernetes/client/client.go b/pkg/kubernetes/client/client.go
new file mode 100644
index 00000000..8f57a3d9
--- /dev/null
+++ b/pkg/kubernetes/client/client.go
@@ -0,0 +1,57 @@
+package client
+
+import (
+	"github.com/Masterminds/semver"
+	"github.com/stretchr/objx"
+
+	"github.com/grafana/tanka/pkg/kubernetes/manifest"
+)
+
+// Client for working with Kubernetes
+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)
+
+	// Apply the configuration to the cluster. `data` must contain a plaintext
+	// format that is `kubectl-apply(1)` compatible
+	Apply(data manifest.List, opts ApplyOpts) error
+
+	// DiffServerSide runs the diff operation on the server and returns the
+	// result in `diff(1)` format
+	DiffServerSide(data manifest.List) (*string, error)
+
+	// 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
+
+	// Info returns known informational data about the client. Best effort based,
+	// fields of `Info` that cannot be stocked with valuable data, e.g.
+	// due to an error, shall be left nil.
+	Info() (*Info, error)
+}
+
+// Info contains metadata about the client and its environment
+type Info struct {
+	// version of `kubectl`
+	ClientVersion *semver.Version
+
+	// version of the API server
+	ServerVersion *semver.Version
+
+	// used context and cluster from KUBECONFIG
+	Context, Cluster objx.Map
+}
+
+// ApplyOpts allow to specify additional parameter for apply operations
+type ApplyOpts struct {
+	// force allows to ignore checks and force the operation
+	Force bool
+
+	// autoApprove allows to skip the interactive approval
+	AutoApprove bool
+}
+
+// DeleteOpts allow to specify additional parameters for delete operations
+// Currently not different from ApplyOpts, but may be required in the future
+type DeleteOpts ApplyOpts
diff --git a/pkg/kubernetes/client/context.go b/pkg/kubernetes/client/context.go
new file mode 100644
index 00000000..3b3d1d84
--- /dev/null
+++ b/pkg/kubernetes/client/context.go
@@ -0,0 +1,63 @@
+package client
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"os"
+	"os/exec"
+
+	"github.com/stretchr/objx"
+	funk "github.com/thoas/go-funk"
+)
+
+// setupContext uses `kubectl config view` to obtain the KUBECONFIG and extracts the correct context from it
+func (k *Kubectl) setupContext() error {
+	if k.context != nil {
+		return nil
+	}
+
+	cmd := exec.Command("kubectl", "config", "view", "-o", "json")
+	cfgJSON := bytes.Buffer{}
+	cmd.Stdout = &cfgJSON
+	cmd.Stderr = os.Stderr
+	if err := cmd.Run(); err != nil {
+		return err
+	}
+	var cfg map[string]interface{}
+	if err := json.Unmarshal(cfgJSON.Bytes(), &cfg); err != nil {
+		return err
+	}
+
+	var err error
+	k.cluster, k.context, err = contextFromKubeconfig(cfg, k.APIServer)
+	if err != nil {
+		return err
+	}
+	return nil
+}
+
+// contextFromKubeconfig searches a kubeconfig for a context of a cluster that matches the apiServer
+func contextFromKubeconfig(kubeconfig map[string]interface{}, apiServer string) (cluster, context objx.Map, err error) {
+	cfg := objx.New(kubeconfig)
+
+	// find the correct cluster
+	cluster = objx.New(funk.Find(cfg.Get("clusters").MustMSISlice(), func(x map[string]interface{}) bool {
+		host := objx.New(x).Get("cluster.server").MustStr()
+		return host == apiServer
+	}))
+	if len(cluster) == 0 { // empty map means no result
+		return nil, nil, fmt.Errorf("no cluster that matches the apiServer `%s` was found. Please check your $KUBECONFIG", apiServer)
+	}
+
+	// find a context that uses the cluster
+	context = objx.New(funk.Find(cfg.Get("contexts").MustMSISlice(), func(x map[string]interface{}) bool {
+		c := objx.New(x)
+		return c.Get("context.cluster").MustStr() == cluster.Get("name").MustStr()
+	}))
+	if !(len(context) > 0) {
+		return nil, nil, fmt.Errorf("no context that matches the cluster `%s` was found. Please check your $KUBECONFIG", cluster.Get("name").MustStr())
+	}
+
+	return cluster, context, nil
+}
diff --git a/pkg/kubernetes/client/delete.go b/pkg/kubernetes/client/delete.go
new file mode 100644
index 00000000..ace845eb
--- /dev/null
+++ b/pkg/kubernetes/client/delete.go
@@ -0,0 +1,45 @@
+package client
+
+import (
+	"fmt"
+	"os"
+	"os/exec"
+)
+
+// 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))
+	}
+
+	return k.delete(namespace, lArgs, opts)
+}
+
+func (k Kubectl) delete(namespace string, sel []string, opts DeleteOpts) error {
+	argv := append([]string{"delete",
+		"-n", namespace,
+		"--context", k.context.Get("name").MustStr(),
+	}, sel...)
+
+	if opts.Force {
+		argv = append(argv, "--force")
+	}
+
+	cmd := exec.Command("kubectl", argv...)
+
+	cmd.Stdout = os.Stdout
+	cmd.Stderr = os.Stderr
+	cmd.Stdin = os.Stdin
+
+	if err := cmd.Run(); err != nil {
+		return err
+	}
+
+	return nil
+}
diff --git a/pkg/kubernetes/client/errors.go b/pkg/kubernetes/client/errors.go
new file mode 100644
index 00000000..cf077db0
--- /dev/null
+++ b/pkg/kubernetes/client/errors.go
@@ -0,0 +1,20 @@
+package client
+
+// ErrorNotFound means that the requested object is not found on the server
+type ErrorNotFound struct {
+	errOut string
+}
+
+func (e ErrorNotFound) Error() string {
+	return e.errOut
+}
+
+// ErrorUnknownResource means that the requested resource type is unknown to the
+// server
+type ErrorUnknownResource struct {
+	errOut string
+}
+
+func (e ErrorUnknownResource) Error() string {
+	return e.errOut
+}
diff --git a/pkg/kubernetes/client/get.go b/pkg/kubernetes/client/get.go
new file mode 100644
index 00000000..0368e426
--- /dev/null
+++ b/pkg/kubernetes/client/get.go
@@ -0,0 +1,73 @@
+package client
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"os/exec"
+	"strings"
+
+	"github.com/grafana/tanka/pkg/kubernetes/manifest"
+)
+
+// 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})
+}
+
+// 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) {
+	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)
+	if err != nil {
+		return nil, err
+	}
+
+	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
+}
+
+func (k Kubectl) get(namespace string, sel []string) (manifest.Manifest, error) {
+	argv := append([]string{"get",
+		"-o", "json",
+		"-n", namespace,
+		"--context", k.context.Get("name").MustStr(),
+	}, sel...)
+	cmd := exec.Command("kubectl", argv...)
+
+	var sout, serr bytes.Buffer
+	cmd.Stdout = &sout
+	cmd.Stderr = &serr
+
+	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
+	}
+
+	var m manifest.Manifest
+	if err := json.Unmarshal(sout.Bytes(), &m); err != nil {
+		return nil, err
+	}
+
+	return m, nil
+}
diff --git a/pkg/kubernetes/client/kubectl.go b/pkg/kubernetes/client/kubectl.go
new file mode 100644
index 00000000..138b40e3
--- /dev/null
+++ b/pkg/kubernetes/client/kubectl.go
@@ -0,0 +1,112 @@
+package client
+
+import (
+	"bytes"
+	"os"
+	"os/exec"
+	"regexp"
+	"strings"
+
+	"github.com/Masterminds/semver"
+	"github.com/pkg/errors"
+	"github.com/stretchr/objx"
+
+	"github.com/grafana/tanka/pkg/kubernetes/manifest"
+)
+
+// Kubectl uses the `kubectl` command to operate on a Kubernetes cluster
+type Kubectl struct {
+	context   objx.Map
+	cluster   objx.Map
+	APIServer string
+}
+
+// New returns a instance of Kubectl with a correct context already discovered.
+func New(endpoint string) (*Kubectl, error) {
+	k := Kubectl{
+		APIServer: endpoint,
+	}
+	if err := k.setupContext(); err != nil {
+		return nil, errors.Wrap(err, "finding usable context")
+	}
+	return &k, nil
+}
+
+// Info returns known informational data about the client and its environment
+func (k Kubectl) Info() (*Info, error) {
+	client, server, err := k.version()
+	if err != nil {
+		return nil, errors.Wrap(err, "obtaining versions")
+	}
+
+	return &Info{
+		ClientVersion: client,
+		ServerVersion: server,
+
+		Context: k.context,
+		Cluster: k.cluster,
+	}, nil
+}
+
+// Version returns the version of kubectl and the Kubernetes api server
+func (k Kubectl) version() (client, server *semver.Version, err error) {
+	cmd := exec.Command("kubectl", "version",
+		"-o", "json",
+		"--context", k.context.Get("name").MustStr(),
+	)
+	var buf bytes.Buffer
+	cmd.Stdout = &buf
+	cmd.Stderr = os.Stderr
+	if err := cmd.Run(); err != nil {
+		return nil, nil, err
+	}
+	vs := objx.MustFromJSON(buf.String())
+	client = semver.MustParse(vs.Get("clientVersion.gitVersion").MustStr())
+	server = semver.MustParse(vs.Get("serverVersion.gitVersion").MustStr())
+	return client, server, nil
+}
+
+// DiffServerSide takes the desired state and computes the differences on the
+// server, returning them in `diff(1)` format
+func (k Kubectl) DiffServerSide(data manifest.List) (*string, error) {
+	argv := []string{"diff",
+		"--context", k.context.Get("name").MustStr(),
+		"-f", "-",
+	}
+	cmd := exec.Command("kubectl", argv...)
+
+	raw := bytes.Buffer{}
+	cmd.Stdout = &raw
+	cmd.Stderr = FilterWriter{regexp.MustCompile(`exit status \d`)}
+
+	cmd.Stdin = strings.NewReader(data.String())
+
+	err := cmd.Run()
+
+	// kubectl uses exit status 1 to tell us that there is a diff
+	if exitError, ok := err.(*exec.ExitError); ok && exitError.ExitCode() == 1 {
+		s := raw.String()
+		return &s, nil
+	}
+	// another error
+	if err != nil {
+		return nil, err
+	}
+
+	// no diff -> nil
+	return nil, nil
+}
+
+// FilterWriter is an io.Writer that discards every message that matches at
+// least one of the regular expressions.
+type FilterWriter []*regexp.Regexp
+
+func (r FilterWriter) Write(p []byte) (n int, err error) {
+	for _, re := range r {
+		if re.Match(p) {
+			// silently discard
+			return len(p), nil
+		}
+	}
+	return os.Stderr.Write(p)
+}
diff --git a/pkg/kubernetes/errors.go b/pkg/kubernetes/errors.go
index 3c7c26a9..ef7ed701 100644
--- a/pkg/kubernetes/errors.go
+++ b/pkg/kubernetes/errors.go
@@ -2,18 +2,9 @@ package kubernetes
 
 import (
 	"errors"
-	"fmt"
 )
 
-type ErrorNotFound struct {
-	name string
-	kind string
-}
-
-func (e ErrorNotFound) Error() string {
-	return fmt.Sprintf(`error from server (NotFound): %s "%s" not found`, e.kind, e.name)
-}
-
 var (
+	// ErrorMissingConfig means that the `spec.json` is absent
 	ErrorMissingConfig = errors.New("This operation requires additional configuration. Refer to https://tanka.dev/environments for instructions")
 )
diff --git a/pkg/kubernetes/kubectl.go b/pkg/kubernetes/kubectl.go
deleted file mode 100644
index 8c74d5b1..00000000
--- a/pkg/kubernetes/kubectl.go
+++ /dev/null
@@ -1,223 +0,0 @@
-package kubernetes
-
-import (
-	"bytes"
-	"encoding/json"
-	"fmt"
-	"os"
-	"os/exec"
-	"regexp"
-	"strings"
-
-	"github.com/Masterminds/semver"
-	"github.com/fatih/color"
-	"github.com/stretchr/objx"
-	funk "github.com/thoas/go-funk"
-
-	"github.com/grafana/tanka/pkg/cli"
-)
-
-var (
-	alert = color.New(color.FgRed, color.Bold).SprintFunc()
-)
-
-// Kubectl uses the `kubectl` command to operate on a Kubernetes cluster
-type Kubectl struct {
-	context   objx.Map
-	cluster   objx.Map
-	APIServer string
-}
-
-// Version returns the version of kubectl and the Kubernetes api server
-func (k Kubectl) Version() (client, server semver.Version, err error) {
-	zero := *semver.MustParse("0.0.0")
-	if err := k.setupContext(); err != nil {
-		return zero, zero, err
-	}
-	cmd := exec.Command("kubectl", "version",
-		"-o", "json",
-		"--context", k.context.Get("name").MustStr(),
-	)
-	var buf bytes.Buffer
-	cmd.Stdout = &buf
-	cmd.Stderr = os.Stderr
-	if err := cmd.Run(); err != nil {
-		return zero, zero, err
-	}
-	vs := objx.MustFromJSON(buf.String())
-	client = *semver.MustParse(vs.Get("clientVersion.gitVersion").MustStr())
-	server = *semver.MustParse(vs.Get("serverVersion.gitVersion").MustStr())
-	return client, server, nil
-}
-
-// setupContext uses `kubectl config view` to obtain the KUBECONFIG and extracts the correct context from it
-func (k *Kubectl) setupContext() error {
-	if k.context != nil {
-		return nil
-	}
-
-	cmd := exec.Command("kubectl", "config", "view", "-o", "json")
-	cfgJSON := bytes.Buffer{}
-	cmd.Stdout = &cfgJSON
-	cmd.Stderr = os.Stderr
-	if err := cmd.Run(); err != nil {
-		return err
-	}
-	var cfg map[string]interface{}
-	if err := json.Unmarshal(cfgJSON.Bytes(), &cfg); err != nil {
-		return err
-	}
-
-	var err error
-	k.cluster, k.context, err = contextFromKubeconfig(cfg, k.APIServer)
-	if err != nil {
-		return err
-	}
-	return nil
-}
-
-// contextFromKubeconfig searches a kubeconfig for a context of a cluster that matches the apiServer
-func contextFromKubeconfig(kubeconfig map[string]interface{}, apiServer string) (cluster, context objx.Map, err error) {
-	cfg := objx.New(kubeconfig)
-
-	// find the correct cluster
-	cluster = objx.New(funk.Find(cfg.Get("clusters").MustMSISlice(), func(x map[string]interface{}) bool {
-		host := objx.New(x).Get("cluster.server").MustStr()
-		return host == apiServer
-	}))
-	if !(len(cluster) > 0) { // empty map means no result
-		return nil, nil, fmt.Errorf("no cluster that matches the apiServer `%s` was found. Please check your $KUBECONFIG", apiServer)
-	}
-
-	// find a context that uses the cluster
-	context = objx.New(funk.Find(cfg.Get("contexts").MustMSISlice(), func(x map[string]interface{}) bool {
-		c := objx.New(x)
-		return c.Get("context.cluster").MustStr() == cluster.Get("name").MustStr()
-	}))
-	if !(len(context) > 0) {
-		return nil, nil, fmt.Errorf("no context that matches the cluster `%s` was found. Please check your $KUBECONFIG", cluster.Get("name").MustStr())
-	}
-
-	return cluster, context, nil
-}
-
-// Get retrieves an Kubernetes object from the API
-func (k Kubectl) Get(namespace, kind, name string) (map[string]interface{}, error) {
-	if err := k.setupContext(); err != nil {
-		return nil, err
-	}
-	argv := []string{"get",
-		"-o", "json",
-		"-n", namespace,
-		"--context", k.context.Get("name").MustStr(),
-		kind, name,
-	}
-	cmd := exec.Command("kubectl", argv...)
-	var sout, serr bytes.Buffer
-	cmd.Stdout = &sout
-	cmd.Stderr = &serr
-	if err := cmd.Run(); err != nil {
-		if strings.HasPrefix(serr.String(), "Error from server (NotFound)") {
-			return nil, ErrorNotFound{kind, name}
-		}
-		fmt.Print(serr.String())
-		return nil, err
-	}
-	var obj map[string]interface{}
-	if err := json.Unmarshal(sout.Bytes(), &obj); err != nil {
-		return nil, err
-	}
-	return obj, nil
-}
-
-// ApplyOpts allow to specify additional parameter for apply operations
-type ApplyOpts struct {
-	Force       bool
-	AutoApprove bool
-}
-
-// Apply applies the given yaml to the cluster
-func (k Kubectl) Apply(yaml, namespace string, opts ApplyOpts) error {
-	if err := k.setupContext(); err != nil {
-		return err
-	}
-
-	if !opts.AutoApprove {
-		if err := cli.Confirm(
-			fmt.Sprintf(`Applying to namespace '%s' of cluster '%s' at '%s' using context '%s'.`,
-				alert(namespace),
-				alert(k.cluster.Get("name").MustStr()),
-				alert(k.cluster.Get("cluster.server").MustStr()),
-				alert(k.context.Get("name").MustStr()),
-			),
-			"yes",
-		); err != nil {
-			return err
-		}
-	}
-
-	argv := []string{"apply",
-		"--context", k.context.Get("name").MustStr(),
-		"-f", "-",
-	}
-	if !opts.Force {
-		argv = append(argv, "--force")
-	}
-
-	cmd := exec.Command("kubectl", argv...)
-	cmd.Stdout = os.Stdout
-	cmd.Stderr = os.Stderr
-
-	stdin, err := cmd.StdinPipe()
-	if err != nil {
-		return err
-	}
-
-	go func() {
-		fmt.Fprintln(stdin, yaml)
-		stdin.Close()
-	}()
-
-	return cmd.Run()
-}
-
-// Diff takes a desired state as yaml and returns the differences
-// to the system in common diff format
-func (k Kubectl) Diff(yaml string) (*string, error) {
-	if err := k.setupContext(); err != nil {
-		return nil, err
-	}
-
-	argv := []string{"diff",
-		"--context", k.context.Get("name").MustStr(),
-		"-f", "-",
-	}
-	cmd := exec.Command("kubectl", argv...)
-	raw := bytes.Buffer{}
-	cmd.Stdout = &raw
-	cmd.Stderr = FilteredErr{regexp.MustCompile(`exit status \d`)}
-
-	stdin, err := cmd.StdinPipe()
-	if err != nil {
-		return nil, err
-	}
-
-	go func() {
-		fmt.Fprintln(stdin, yaml)
-		stdin.Close()
-	}()
-
-	if err := cmd.Run(); err != nil {
-		if exitError, ok := err.(*exec.ExitError); ok {
-			// kubectl uses this to tell us that there is a diff
-			if exitError.ExitCode() == 1 {
-				s := raw.String()
-				return &s, nil
-			}
-		}
-		return nil, err
-	}
-
-	// no diff -> nil
-	return nil, nil
-}
diff --git a/pkg/kubernetes/kubernetes.go b/pkg/kubernetes/kubernetes.go
index 1a89a524..bf9fb14f 100644
--- a/pkg/kubernetes/kubernetes.go
+++ b/pkg/kubernetes/kubernetes.go
@@ -1,123 +1,95 @@
 package kubernetes
 
 import (
-	"bytes"
 	"fmt"
-	"regexp"
-	"sort"
-	"strings"
 
 	"github.com/Masterminds/semver"
+	"github.com/fatih/color"
 	"github.com/pkg/errors"
-	"github.com/stretchr/objx"
-	funk "github.com/thoas/go-funk"
-	yaml "gopkg.in/yaml.v3"
 
+	"github.com/grafana/tanka/pkg/cli"
+	"github.com/grafana/tanka/pkg/kubernetes/client"
+	"github.com/grafana/tanka/pkg/kubernetes/manifest"
 	"github.com/grafana/tanka/pkg/spec/v1alpha1"
 )
 
-// Kubernetes bridges tanka to the Kubernetse orchestrator.
+// Kubernetes exposes methods to work with the Kubernetes orchestrator
 type Kubernetes struct {
-	client Kubectl
-	Spec   v1alpha1.Spec
+	Spec v1alpha1.Spec
+
+	// Client (kubectl)
+	ctl  client.Client
+	info client.Info
 
 	// Diffing
 	differs map[string]Differ // List of diff strategies
 }
 
-type Differ func(yaml string) (*string, error)
+// Differ is responsible for comparing the given manifests to the cluster and
+// returning differences (if any) in `diff(1)` format.
+type Differ func(manifest.List) (*string, error)
 
-// New creates a new Kubernetes
-func New(s v1alpha1.Spec) *Kubernetes {
-	k := Kubernetes{
-		Spec: s,
-	}
-	k.client.APIServer = k.Spec.APIServer
-	k.differs = map[string]Differ{
-		"native": k.client.Diff,
-		"subset": k.client.SubsetDiff,
+// New creates a new Kubernetes with an initialized client
+func New(s v1alpha1.Spec) (*Kubernetes, error) {
+	// setup client
+	ctl, err := client.New(s.APIServer)
+	if err != nil {
+		return nil, errors.Wrap(err, "creating client")
 	}
-	return &k
-}
-
-// Manifest describes a single Kubernetes manifest
-type Manifest map[string]interface{}
 
-func (m Manifest) Kind() string {
-	return m["kind"].(string)
-}
-
-func (m Manifest) Name() string {
-	return m["metadata"].(map[string]interface{})["name"].(string)
-}
-
-func (m Manifest) Namespace() string {
-	return m["metadata"].(map[string]interface{})["namespace"].(string)
-}
-
-// Reconcile receives the raw evaluated jsonnet as a marshaled json dict and
-// shall return it reconciled as a state object of the target system
-func (k *Kubernetes) Reconcile(raw map[string]interface{}, objectspecs []*regexp.Regexp) (state []Manifest, err error) {
-	docs, err := walkJSON(raw, "")
-	out := make([]Manifest, 0, len(docs))
+	// obtain information about the client (including versions)
+	info, err := ctl.Info()
 	if err != nil {
-		return nil, errors.Wrap(err, "flattening manifests")
-	}
-	for _, d := range docs {
-		m := objx.New(d)
-		if k != nil && !m.Has("metadata.namespace") {
-			m.Set("metadata.namespace", k.Spec.Namespace)
-		}
-		out = append(out, Manifest(m))
+		return nil, err
 	}
 
-	if len(objectspecs) > 0 {
-		out = funk.Filter(out, func(i interface{}) bool {
-			p := objectspec(i.(Manifest))
-			for _, o := range objectspecs {
-				if o.MatchString(strings.ToLower(p)) {
-					return true
-				}
-			}
-			return false
-		}).([]Manifest)
-	}
+	// setup diffing
+	if s.DiffStrategy == "" {
+		s.DiffStrategy = "native"
 
-	sort.SliceStable(out, func(i int, j int) bool {
-		if out[i].Kind() != out[j].Kind() {
-			return out[i].Kind() < out[j].Kind()
+		if info.ServerVersion.LessThan(semver.MustParse("1.13.0")) {
+			s.DiffStrategy = "subset"
 		}
-		return out[i].Name() < out[j].Name()
-	})
-
-	return out, nil
-}
-
-// Fmt receives the state and reformats it to YAML Documents
-func (k *Kubernetes) Fmt(state []Manifest) (string, error) {
-	buf := bytes.Buffer{}
-	enc := yaml.NewEncoder(&buf)
+	}
 
-	for _, d := range state {
-		if err := enc.Encode(d); err != nil {
-			return "", err
-		}
+	k := Kubernetes{
+		Spec: s,
+		ctl:  ctl,
+		info: *info,
+		differs: map[string]Differ{
+			"native": ctl.DiffServerSide,
+			"subset": SubsetDiffer(ctl),
+		},
 	}
 
-	return buf.String(), nil
+	return &k, nil
 }
 
-// Apply receives a state object generated using `Reconcile()` and may apply it to the target system
-func (k *Kubernetes) Apply(state []Manifest, opts ApplyOpts) error {
-	if k == nil {
-		return ErrorMissingConfig
-	}
+// ApplyOpts allow set additional parameters for the apply operation
+type ApplyOpts client.ApplyOpts
 
-	yaml, err := k.Fmt(state)
+// 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 {
+	info, err := k.ctl.Info()
 	if err != nil {
 		return err
 	}
-	return k.client.Apply(yaml, k.Spec.Namespace, opts)
+	alert := color.New(color.FgRed, color.Bold).SprintFunc()
+
+	if !opts.AutoApprove {
+		if err := cli.Confirm(
+			fmt.Sprintf(`Applying to namespace '%s' of cluster '%s' at '%s' using context '%s'.`,
+				alert(k.Spec.Namespace),
+				alert(info.Cluster.Get("name").MustStr()),
+				alert(info.Cluster.Get("cluster.server").MustStr()),
+				alert(info.Context.Get("name").MustStr()),
+			),
+			"yes",
+		); err != nil {
+			return err
+		}
+	}
+	return k.ctl.Apply(state, client.ApplyOpts(opts))
 }
 
 // DiffOpts allow to specify additional parameters for diff operations
@@ -130,29 +102,13 @@ type DiffOpts struct {
 }
 
 // Diff takes the desired state and returns the differences from the cluster
-func (k *Kubernetes) Diff(state []Manifest, opts DiffOpts) (*string, error) {
-	if k == nil {
-		return nil, ErrorMissingConfig
-	}
-	yaml, err := k.Fmt(state)
-	if err != nil {
-		return nil, err
-	}
-
-	ds := k.Spec.DiffStrategy
+func (k *Kubernetes) Diff(state manifest.List, opts DiffOpts) (*string, error) {
+	strategy := k.Spec.DiffStrategy
 	if opts.Strategy != "" {
-		ds = opts.Strategy
-	}
-	if ds == "" {
-		ds = "native"
-		if _, server, err := k.client.Version(); err == nil {
-			if server.LessThan(semver.MustParse("1.13.0")) {
-				ds = "subset"
-			}
-		}
+		strategy = opts.Strategy
 	}
 
-	d, err := k.differs[ds](yaml)
+	d, err := k.differs[strategy](state)
 	switch {
 	case err != nil:
 		return nil, err
@@ -167,9 +123,14 @@ func (k *Kubernetes) Diff(state []Manifest, opts DiffOpts) (*string, error) {
 	return d, nil
 }
 
-func objectspec(m Manifest) string {
+// Info about the client, etc.
+func (k *Kubernetes) Info() client.Info {
+	return k.info
+}
+
+func objectspec(m manifest.Manifest) string {
 	return fmt.Sprintf("%s/%s",
 		m.Kind(),
-		m.Name(),
+		m.Metadata().Name(),
 	)
 }
diff --git a/pkg/kubernetes/manifest/errors.go b/pkg/kubernetes/manifest/errors.go
new file mode 100644
index 00000000..f133eb3f
--- /dev/null
+++ b/pkg/kubernetes/manifest/errors.go
@@ -0,0 +1,43 @@
+package manifest
+
+import (
+	"fmt"
+	"strings"
+)
+
+// SchemaError means that some expected fields were missing
+type SchemaError struct {
+	fields map[string]bool
+	name   string
+}
+
+// Error returns the fields the manifest at the path is missing
+func (s *SchemaError) Error() string {
+	e := ""
+	for k, missing := range s.fields {
+		if !missing {
+			continue
+		}
+		e += ", " + k
+	}
+	e = strings.TrimPrefix(e, ", ")
+	return fmt.Sprintf("%s missing or invalid fields: %s", s.name, e)
+}
+
+func (s *SchemaError) add(field string) {
+	if s.fields == nil {
+		s.fields = make(map[string]bool)
+	}
+	s.fields[field] = true
+}
+
+// Missing returns whether a field is missing
+func (s *SchemaError) Missing(field string) bool {
+	return s.fields[field]
+}
+
+// WithName inserts a path into the error message
+func (s *SchemaError) WithName(name string) *SchemaError {
+	s.name = name
+	return s
+}
diff --git a/pkg/kubernetes/manifest/manifest.go b/pkg/kubernetes/manifest/manifest.go
new file mode 100644
index 00000000..00405732
--- /dev/null
+++ b/pkg/kubernetes/manifest/manifest.go
@@ -0,0 +1,169 @@
+package manifest
+
+import (
+	"bytes"
+	"encoding/json"
+
+	"github.com/pkg/errors"
+	"github.com/stretchr/objx"
+	yaml "gopkg.in/yaml.v2"
+)
+
+// Manifest represents a Kubernetes API object. The fields `apiVersion` and
+// `kind` are required, `metadata.name` should be present as well
+type Manifest map[string]interface{}
+
+// New creates a new Manifest
+func New(raw map[string]interface{}) (Manifest, *SchemaError) {
+	m := Manifest(raw)
+	if err := m.Verify(); err != nil {
+		return nil, err
+	}
+	return m, nil
+}
+
+// NewFromObj creates a new Manifest from an objx.Map
+func NewFromObj(raw objx.Map) (Manifest, *SchemaError) {
+	return New(map[string]interface{}(raw))
+}
+
+// String returns the Manifest in yaml representation
+func (m Manifest) String() string {
+	y, err := yaml.Marshal(m)
+	if err != nil {
+		// this should never go wrong in normal operations
+		panic(errors.Wrap(err, "formatting manifest"))
+	}
+	return string(y)
+}
+
+// Verify checks whether the manifest is correctly structured
+func (m Manifest) Verify() *SchemaError {
+	o := m2o(m)
+	var err SchemaError
+
+	if !o.Get("kind").IsStr() {
+		err.add("kind")
+	}
+	if !o.Get("apiVersion").IsStr() {
+		err.add("apiVersion")
+	}
+	if !o.Get("metadata").IsMSI() {
+		err.add("metadata")
+	}
+	if !o.Get("metadata.name").IsStr() && m.Kind() != "List" {
+		err.add("metadata.name")
+	}
+
+	if len(err.fields) == 0 {
+		return nil
+	}
+	return &err
+}
+
+// Kind returns the kind of the API object
+func (m Manifest) Kind() string {
+	return m["kind"].(string)
+}
+
+// APIVersion returns the version of the API this object uses
+func (m Manifest) APIVersion() string {
+	return m["apiVersion"].(string)
+}
+
+// Metadata returns the metadata of this object
+func (m Manifest) Metadata() Metadata {
+	return Metadata(m["metadata"].(map[string]interface{}))
+}
+
+// UnmarshalJSON validates the Manifest during json parsing
+func (m Manifest) UnmarshalJSON(data []byte) error {
+	if err := json.Unmarshal(data, &m); err != nil {
+		return err
+	}
+	return m.Verify()
+}
+
+// UnmarshalYAML validates the Manifest during yaml parsing
+func (m Manifest) UnmarshalYAML(unmarshal func(interface{}) error) error {
+	if err := unmarshal(&m); err != nil {
+		return err
+	}
+	return m.Verify()
+}
+
+// Metadata is the metadata object from the Manifest
+type Metadata map[string]interface{}
+
+// Name of the manifest
+func (m Metadata) Name() string {
+	return m["name"].(string)
+}
+
+// HasNamespace returns whether the manifest has a namespace set
+func (m Metadata) HasNamespace() bool {
+	return m2o(m).Get("namespace").IsStr()
+}
+
+// Namespace of the manifest
+func (m Metadata) Namespace() string {
+	if !m.HasNamespace() {
+		return ""
+	}
+	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{})
+	}
+	return m["labels"].(map[string]interface{})
+}
+
+// HasAnnotations returns whether the manifest has annotations
+func (m Metadata) HasAnnotations() bool {
+	return m2o(m).Get("annotations").IsMSI()
+}
+
+// Annotations of the manifest
+func (m Metadata) Annotations() map[string]interface{} {
+	if !m.HasAnnotations() {
+		return make(map[string]interface{})
+	}
+	return m["annotations"].(map[string]interface{})
+}
+
+// List of individual Manifests
+type List []Manifest
+
+// String returns the List as a yaml stream. In case of an error, it is
+// returned as a string instead.
+func (m List) String() string {
+	buf := bytes.Buffer{}
+	enc := yaml.NewEncoder(&buf)
+
+	for _, d := range m {
+		if err := enc.Encode(d); err != nil {
+			// This should never happen in normal operations
+			panic(errors.Wrap(err, "formatting manifests"))
+		}
+	}
+
+	return buf.String()
+}
+
+func m2o(m interface{}) objx.Map {
+	switch mm := m.(type) {
+	case Metadata:
+		return objx.New(map[string]interface{}(mm))
+	case Manifest:
+		return objx.New(map[string]interface{}(mm))
+	}
+	return nil
+}
diff --git a/pkg/kubernetes/reconcile.go b/pkg/kubernetes/reconcile.go
index 3b99b9aa..75338b9f 100644
--- a/pkg/kubernetes/reconcile.go
+++ b/pkg/kubernetes/reconcile.go
@@ -2,72 +2,150 @@ package kubernetes
 
 import (
 	"fmt"
+	"regexp"
+	"sort"
+	"strings"
 
+	"github.com/pkg/errors"
 	"github.com/stretchr/objx"
+	funk "github.com/thoas/go-funk"
+
+	"github.com/grafana/tanka/pkg/kubernetes/manifest"
+	"github.com/grafana/tanka/pkg/spec/v1alpha1"
 )
 
-// ErrorPrimitiveReached occurs when walkJSON reaches the end of nested dicts without finding a valid Kubernetes manifest
-type ErrorPrimitiveReached struct {
-	path, key string
-	primitive interface{}
+// Reconcile extracts all valid Kubernetes objects from the raw output of the
+// Jsonnet compiler. A valid object is identified by the presence of `kind` and
+// `apiVersion`.
+// TODO: Check on `metadata.name` as well and assert that they are
+// not only set but also strings.
+func Reconcile(raw map[string]interface{}, spec v1alpha1.Spec, targets []*regexp.Regexp) (state manifest.List, err error) {
+	extracted, err := extract(raw)
+	if err != nil {
+		return nil, errors.Wrap(err, "flattening manifests")
+	}
+
+	out := make(manifest.List, 0, len(extracted))
+	for _, m := range extracted {
+		if spec.Namespace != "" && !m.Metadata().HasNamespace() {
+			m.Metadata()["namespace"] = spec.Namespace
+		}
+
+		out = append(out, m)
+	}
+
+	// optionally filter the working set of objects
+	if len(targets) > 0 {
+		tmp := funk.Filter(out, func(i interface{}) bool {
+			p := objectspec(i.(manifest.Manifest))
+			for _, t := range targets {
+				if t.MatchString(strings.ToLower(p)) {
+					return true
+				}
+			}
+			return false
+		}).([]manifest.Manifest)
+		out = manifest.List(tmp)
+	}
+
+	// Stable output order
+	sort.SliceStable(out, func(i int, j int) bool {
+		if out[i].Kind() != out[j].Kind() {
+			return out[i].Kind() < out[j].Kind()
+		}
+		return out[i].Metadata().Name() < out[j].Metadata().Name()
+	})
+
+	return out, nil
 }
 
-func (e ErrorPrimitiveReached) Error() string {
-	return fmt.Sprintf("recursion did not resolve in a valid Kubernetes object, "+
-		"because one of `kind` or `apiVersion` is missing in path `.%s`."+
-		" Found non-dict value `%s` of type `%T` instead.",
-		e.path, e.key, e.primitive)
+func extract(deep interface{}) (map[string]manifest.Manifest, error) {
+	extracted := make(map[string]manifest.Manifest)
+	if err := walkJSON(deep, extracted, nil); err != nil {
+		return nil, err
+	}
+	return extracted, nil
 }
 
 // walkJSON traverses deeply nested kubernetes manifest and extracts them into a flat []dict.
-func walkJSON(rawDeep interface{}, path string) ([]map[string]interface{}, error) {
-	flat := make([]map[string]interface{}, 0)
-
+func walkJSON(deep interface{}, extracted map[string]manifest.Manifest, path trace) error {
 	// array: walkJSON for each
-	if d, ok := rawDeep.([]map[string]interface{}); ok {
+	if d, ok := deep.([]map[string]interface{}); ok {
 		for i, j := range d {
-			out, err := walkJSON(j, fmt.Sprintf("%s[%v]", path, i))
-			if err != nil {
-				return nil, err
+			path := append(path, fmt.Sprintf("[%v]", i))
+			if err := walkJSON(j, extracted, path); err != nil {
+				return err
 			}
-			flat = append(flat, out...)
 		}
-		return flat, nil
+		return nil
 	}
 
 	// assert for map[string]interface{} (also aliased objx.Map)
-	if m, ok := rawDeep.(objx.Map); ok {
-		rawDeep = map[string]interface{}(m)
+	if m, ok := deep.(objx.Map); ok {
+		deep = map[string]interface{}(m)
 	}
-	deep, ok := rawDeep.(map[string]interface{})
+	deep, ok := deep.(map[string]interface{})
 	if !ok {
-		return nil, fmt.Errorf("deep has unexpected type %T @ %s", deep, path)
+		return fmt.Errorf("deep has unexpected type %T @ %s", deep, path)
 	}
 
 	// already flat?
 	r := objx.New(deep)
+
 	if r.Has("apiVersion") && r.Has("kind") {
-		return []map[string]interface{}{deep}, nil
+		extracted[path.Full()] = deep.(map[string]interface{})
+		return nil
 	}
 
 	// walk it
-	for n, d := range deep {
-		if n == "__ksonnet" {
+	for key, d := range deep.(map[string]interface{}) {
+		if key == "__ksonnet" {
 			continue
 		}
+		path := append(path, key)
+
 		if _, ok := d.(map[string]interface{}); !ok {
-			return nil, ErrorPrimitiveReached{path, n, d}
+			return ErrorPrimitiveReached{path.Base(), key, d}
 		}
+
 		m := objx.New(d)
 		if m.Has("apiVersion") && m.Has("kind") {
-			flat = append(flat, m)
-		} else {
-			f, err := walkJSON(m, path+"."+n)
+			mf, err := manifest.NewFromObj(m)
 			if err != nil {
-				return nil, err
+				return err.WithName(path.Full())
+			}
+			extracted[path.Full()] = mf
+		} else {
+			if err := walkJSON(m, extracted, path); err != nil {
+				return err
 			}
-			flat = append(flat, f...)
 		}
 	}
-	return flat, nil
+	return nil
+}
+
+type trace []string
+
+func (t trace) Full() string {
+	return "." + strings.Join(t, ".")
+}
+
+func (t trace) Base() string {
+	if len(t) > 0 {
+		t = t[:len(t)-1]
+	}
+	return "." + strings.Join(t, ".")
+}
+
+// ErrorPrimitiveReached occurs when walkJSON reaches the end of nested dicts without finding a valid Kubernetes manifest
+type ErrorPrimitiveReached struct {
+	path, key string
+	primitive interface{}
+}
+
+func (e ErrorPrimitiveReached) Error() string {
+	return fmt.Sprintf("recursion did not resolve in a valid Kubernetes object, "+
+		"because one of `kind` or `apiVersion` is missing in path `%s`."+
+		" Found non-dict value `%s` of type `%T` instead.",
+		e.path, e.key, e.primitive)
 }
diff --git a/pkg/kubernetes/subsetdiff.go b/pkg/kubernetes/subsetdiff.go
index a8687188..534e8549 100644
--- a/pkg/kubernetes/subsetdiff.go
+++ b/pkg/kubernetes/subsetdiff.go
@@ -2,12 +2,14 @@ package kubernetes
 
 import (
 	"fmt"
-	"io"
 	"strings"
 
 	"github.com/pkg/errors"
 	"github.com/stretchr/objx"
 	yaml "gopkg.in/yaml.v3"
+
+	"github.com/grafana/tanka/pkg/kubernetes/client"
+	"github.com/grafana/tanka/pkg/kubernetes/manifest"
 )
 
 type difference struct {
@@ -15,63 +17,68 @@ type difference struct {
 	live, merged string
 }
 
-func (k Kubectl) SubsetDiff(y string) (*string, error) {
-	docs := []difference{}
-	d := yaml.NewDecoder(strings.NewReader(y))
+// SubsetDiffer returns a implementation of Differ that computes the diff by
+// comparing only the fields present in the desired state. This algorithm might
+// miss information, but is all that's possible on cluster versions lower than
+// 1.13.
+func SubsetDiffer(c client.Client) Differ {
+	return func(state manifest.List) (*string, error) {
+		docs := []difference{}
 
-	routines := 0
-	errCh := make(chan error)
-	resultCh := make(chan difference)
+		errCh := make(chan error)
+		resultCh := make(chan difference)
 
-	for {
-		// jsonnet output -> desired state
-		var rawShould map[string]interface{}
-		err := d.Decode(&rawShould)
-		if err == io.EOF {
-			break
+		for _, rawShould := range state {
+			go parallelSubsetDiff(c, rawShould, resultCh, errCh)
 		}
 
-		if err != nil {
-			return nil, errors.Wrap(err, "decoding yaml")
+		var lastErr error
+		for i := 0; i < len(state); i++ {
+			select {
+			case d := <-resultCh:
+				docs = append(docs, d)
+			case err := <-errCh:
+				lastErr = err
+			}
 		}
+		close(resultCh)
+		close(errCh)
 
-		routines++
-		go subsetDiff(k, rawShould, resultCh, errCh)
-	}
-
-	var lastErr error
-	for i := 0; i < routines; i++ {
-		select {
-		case d := <-resultCh:
-			docs = append(docs, d)
-		case err := <-errCh:
-			lastErr = err
+		if lastErr != nil {
+			return nil, errors.Wrap(lastErr, "calculating subset")
 		}
-	}
-	close(resultCh)
-	close(errCh)
 
-	if lastErr != nil {
-		return nil, errors.Wrap(lastErr, "calculating subset")
-	}
-
-	var diffs string
-	for _, d := range docs {
-		diffStr, err := diff(d.name, d.live, d.merged)
-		if err != nil {
-			return nil, errors.Wrap(err, "invoking diff")
+		var diffs string
+		for _, d := range docs {
+			diffStr, err := diff(d.name, d.live, d.merged)
+			if err != nil {
+				return nil, errors.Wrap(err, "invoking diff")
+			}
+			if diffStr != "" {
+				diffStr += "\n"
+			}
+			diffs += diffStr
 		}
-		if diffStr != "" {
-			diffStr += "\n"
+		diffs = strings.TrimSuffix(diffs, "\n")
+
+		if diffs == "" {
+			return nil, nil
 		}
-		diffs += diffStr
+
+		return &diffs, nil
 	}
-	diffs = strings.TrimSuffix(diffs, "\n")
+}
 
-	return &diffs, nil
+func parallelSubsetDiff(c client.Client, rawShould map[string]interface{}, r chan difference, e chan error) {
+	diff, err := subsetDiff(c, rawShould)
+	if err != nil {
+		e <- err
+		return
+	}
+	r <- *diff
 }
 
-func subsetDiff(k Kubectl, rawShould map[string]interface{}, r chan difference, e chan error) {
+func subsetDiff(c client.Client, rawShould map[string]interface{}) (*difference, error) {
 	m := objx.New(rawShould)
 	name := strings.Replace(fmt.Sprintf("%s.%s.%s.%s",
 		m.Get("apiVersion").MustStr(),
@@ -81,40 +88,37 @@ func subsetDiff(k Kubectl, rawShould map[string]interface{}, r chan difference,
 	), "/", "-", -1)
 
 	// kubectl output -> current state
-	rawIs, err := k.Get(
+	rawIs, err := c.Get(
 		m.Get("metadata.namespace").MustStr(),
 		m.Get("kind").MustStr(),
 		m.Get("metadata.name").MustStr(),
 	)
 	if err != nil {
-		if _, ok := err.(ErrorNotFound); ok {
+		if _, ok := err.(client.ErrorNotFound); ok {
 			rawIs = map[string]interface{}{}
 		} else {
-			e <- errors.Wrap(err, "getting state from cluster")
-			return
+			return nil, errors.Wrap(err, "getting state from cluster")
 		}
 	}
 
 	should, err := yaml.Marshal(rawShould)
 	if err != nil {
-		e <- err
-		return
+		return nil, err
 	}
 
 	is, err := yaml.Marshal(subset(m, rawIs))
 	if err != nil {
-		e <- err
-		return
+		return nil, err
 	}
 	if string(is) == "{}\n" {
 		is = []byte("")
 	}
 
-	r <- difference{
+	return &difference{
 		name:   name,
 		live:   string(is),
 		merged: string(should),
-	}
+	}, nil
 }
 
 // subset removes all keys from is, that are not present in should.
diff --git a/pkg/spec/spec.go b/pkg/spec/spec.go
index 45b30631..18cb0e6a 100644
--- a/pkg/spec/spec.go
+++ b/pkg/spec/spec.go
@@ -5,9 +5,10 @@ import (
 	"os"
 	"path/filepath"
 
-	"github.com/grafana/tanka/pkg/spec/v1alpha1"
 	"github.com/pkg/errors"
 	"github.com/spf13/viper"
+
+	"github.com/grafana/tanka/pkg/spec/v1alpha1"
 )
 
 // list of deprecated config keys and their alternatives
diff --git a/pkg/tanka/parse.go b/pkg/tanka/parse.go
index 10ee8f0d..23735023 100644
--- a/pkg/tanka/parse.go
+++ b/pkg/tanka/parse.go
@@ -12,33 +12,53 @@ import (
 	"github.com/grafana/tanka/pkg/jsonnet"
 	"github.com/grafana/tanka/pkg/jsonnet/jpath"
 	"github.com/grafana/tanka/pkg/kubernetes"
+	"github.com/grafana/tanka/pkg/kubernetes/manifest"
 	"github.com/grafana/tanka/pkg/spec"
+	"github.com/grafana/tanka/pkg/spec/v1alpha1"
 )
 
+// ParseResult contains the environments config and the manifests of this
+// particular env
+type ParseResult struct {
+	Env       *v1alpha1.Config
+	Resources manifest.List
+}
+
+func (p *ParseResult) newKube() (*kubernetes.Kubernetes, error) {
+	kube, err := kubernetes.New(p.Env.Spec)
+	if err != nil {
+		return nil, errors.Wrap(err, "connecting to Kubernetes")
+	}
+	return kube, nil
+}
+
 // parse loads the `spec.json`, evaluates the jsonnet and returns both, the
 // kubernetes object and the reconciled manifests
-func parse(baseDir string, opts *options) ([]kubernetes.Manifest, *kubernetes.Kubernetes, error) {
-	kube, err := parseEnv(baseDir, opts)
+func parse(baseDir string, opts *options) (*ParseResult, error) {
+	env, err := parseEnv(baseDir, opts)
 	if err != nil {
-		return nil, nil, err
+		return nil, err
 	}
 
 	raw, err := eval(baseDir)
 	if err != nil {
-		return nil, nil, errors.Wrap(err, "evaluating jsonnet")
+		return nil, errors.Wrap(err, "evaluating jsonnet")
 	}
 
-	rec, err := kube.Reconcile(raw, opts.targets)
+	rec, err := kubernetes.Reconcile(raw, env.Spec, opts.targets)
 	if err != nil {
-		return nil, nil, errors.Wrap(err, "reconciling")
+		return nil, errors.Wrap(err, "reconciling")
 	}
 
-	return rec, kube, nil
+	return &ParseResult{
+		Resources: rec,
+		Env:       env,
+	}, nil
 }
 
 // parseEnv parses the `spec.json` of the environment and returns a
 // *kubernetes.Kubernetes from it
-func parseEnv(baseDir string, opts *options) (*kubernetes.Kubernetes, error) {
+func parseEnv(baseDir string, opts *options) (*v1alpha1.Config, error) {
 	config, err := spec.ParseDir(baseDir)
 	if err != nil {
 		switch err.(type) {
@@ -57,7 +77,7 @@ func parseEnv(baseDir string, opts *options) (*kubernetes.Kubernetes, error) {
 		}
 	}
 
-	return kubernetes.New(config.Spec), nil
+	return config, nil
 }
 
 // eval evaluates the jsonnet environment at the given directory starting with
diff --git a/pkg/tanka/status.go b/pkg/tanka/status.go
new file mode 100644
index 00000000..89bb3055
--- /dev/null
+++ b/pkg/tanka/status.go
@@ -0,0 +1,38 @@
+package tanka
+
+import (
+	"github.com/grafana/tanka/pkg/kubernetes/client"
+	"github.com/grafana/tanka/pkg/kubernetes/manifest"
+	"github.com/grafana/tanka/pkg/spec/v1alpha1"
+)
+
+// Info holds information about a particular environment, including its Config,
+// the individual resources of the desired state and also the status of the
+// client.
+type Info struct {
+	Env       *v1alpha1.Config
+	Resources manifest.List
+	Client    client.Info
+}
+
+// Status returns information about the particular environment
+func Status(baseDir string, mods ...Modifier) (*Info, error) {
+	opts := parseModifiers(mods)
+
+	r, err := parse(baseDir, opts)
+	if err != nil {
+		return nil, err
+	}
+	kube, err := r.newKube()
+	if err != nil {
+		return nil, err
+	}
+
+	r.Env.Spec.DiffStrategy = kube.Spec.DiffStrategy
+
+	return &Info{
+		Env:       r.Env,
+		Resources: r.Resources,
+		Client:    kube.Info(),
+	}, nil
+}
diff --git a/pkg/tanka/tanka.go b/pkg/tanka/tanka.go
index f57463a6..59a8a254 100644
--- a/pkg/tanka/tanka.go
+++ b/pkg/tanka/tanka.go
@@ -1,4 +1,4 @@
-// The tanka package allows to use most of Tanka's features available on the
+// Package tanka allows to use most of Tanka's features available on the
 // command line programmatically as a Golang library. Keep in mind that the API
 // is still experimental and may change without and signs of warnings while
 // Tanka is still in alpha. Nevertheless, we try to avoid breaking changes.
@@ -33,7 +33,7 @@ type options struct {
 	apply kubernetes.ApplyOpts
 }
 
-// Modifiers allow to influence the behavior of certain `tanka.*` actions. They
+// Modifier allow to influence the behavior of certain `tanka.*` actions. They
 // are roughly equivalent to flags on the command line. See the `tanka.With*`
 // functions for available options.
 type Modifier func(*options)
diff --git a/pkg/tanka/workflow.go b/pkg/tanka/workflow.go
index dfcac13f..6f541ac8 100644
--- a/pkg/tanka/workflow.go
+++ b/pkg/tanka/workflow.go
@@ -18,12 +18,16 @@ import (
 func Apply(baseDir string, mods ...Modifier) error {
 	opts := parseModifiers(mods)
 
-	rec, kube, err := parse(baseDir, opts)
+	p, err := parse(baseDir, opts)
+	if err != nil {
+		return err
+	}
+	kube, err := p.newKube()
 	if err != nil {
 		return err
 	}
 
-	diff, err := kube.Diff(rec, kubernetes.DiffOpts{})
+	diff, err := kube.Diff(p.Resources, kubernetes.DiffOpts{})
 	if err != nil {
 		return errors.Wrap(err, "diffing")
 	}
@@ -37,7 +41,7 @@ func Apply(baseDir string, mods ...Modifier) error {
 	}
 	fmt.Fprintln(opts.wWarn, *diff)
 
-	return kube.Apply(rec, opts.apply)
+	return kube.Apply(p.Resources, opts.apply)
 }
 
 // Diff parses the environment at the given directory (a `baseDir`) and returns
@@ -49,12 +53,16 @@ func Apply(baseDir string, mods ...Modifier) error {
 func Diff(baseDir string, mods ...Modifier) (*string, error) {
 	opts := parseModifiers(mods)
 
-	rec, kube, err := parse(baseDir, opts)
+	p, err := parse(baseDir, opts)
+	if err != nil {
+		return nil, err
+	}
+	kube, err := p.newKube()
 	if err != nil {
 		return nil, err
 	}
 
-	return kube.Diff(rec, opts.diff)
+	return kube.Diff(p.Resources, opts.diff)
 }
 
 // Show parses the environment at the given directory (a `baseDir`) and returns
@@ -62,14 +70,10 @@ func Diff(baseDir string, mods ...Modifier) (*string, error) {
 func Show(baseDir string, mods ...Modifier) (string, error) {
 	opts := parseModifiers(mods)
 
-	rec, kube, err := parse(baseDir, opts)
+	p, err := parse(baseDir, opts)
 	if err != nil {
 		return "", err
 	}
 
-	s, err := kube.Fmt(rec)
-	if err != nil {
-		return "", errors.Wrap(err, "pretty printing state")
-	}
-	return s, nil
+	return p.Resources.String(), nil
 }


Test patch:
diff --git a/pkg/kubernetes/data_test.go b/pkg/kubernetes/data_test.go
index 44da9d77..9026606f 100644
--- a/pkg/kubernetes/data_test.go
+++ b/pkg/kubernetes/data_test.go
@@ -1,10 +1,13 @@
 package kubernetes
 
+import "github.com/grafana/tanka/pkg/kubernetes/manifest"
+
 // This file  contains data for testing
 
 // testData holds data for tests
 type testData struct {
-	deep, flat interface{}
+	deep interface{}
+	flat map[string]manifest.Manifest
 }
 
 // testDataRegular is a regular output of jsonnet without special things, but it
@@ -40,8 +43,8 @@ func testDataRegular() testData {
 				},
 			},
 		},
-		flat: []map[string]interface{}{
-			{
+		flat: map[string]manifest.Manifest{
+			".deployment": {
 				"apiVersion": "apps/v1",
 				"kind":       "Deployment",
 				"metadata": map[string]interface{}{
@@ -56,7 +59,7 @@ func testDataRegular() testData {
 					},
 				},
 			},
-			{
+			".service": {
 				"apiVersion": "v1",
 				"kind":       "Service",
 				"metadata": map[string]interface{}{
@@ -90,8 +93,8 @@ func testDataFlat() testData {
 				},
 			},
 		},
-		flat: []map[string]interface{}{
-			{
+		flat: map[string]manifest.Manifest{
+			".": {
 				"apiVersion": "apps/v1",
 				"kind":       "Deployment",
 				"metadata": map[string]interface{}{
@@ -128,7 +131,6 @@ func testDataPrimitive() testData {
 				},
 			},
 		},
-		flat: []map[string]interface{}(nil),
 	}
 }
 
@@ -181,29 +183,29 @@ func testDataDeep() testData {
 				},
 			},
 		},
-		flat: []map[string]interface{}{
-			{
+		flat: map[string]manifest.Manifest{
+			".app.web.backend.server.nginx.deployment": {
 				"kind":       "Deployment",
 				"apiVersion": "apps/v1",
 				"metadata": map[string]interface{}{
 					"name": "nginx",
 				},
 			},
-			{
+			".app.web.frontend.nodejs.express.service": {
 				"kind":       "Service",
 				"apiVersion": "v1",
 				"metadata": map[string]interface{}{
 					"name": "frontend",
 				},
 			},
-			{
+			".app.web.frontend.nodejs.express.deployment": {
 				"kind":       "Deployment",
 				"apiVersion": "apps/v1",
 				"metadata": map[string]interface{}{
 					"name": "frontend",
 				},
 			},
-			{
+			".app.namespace": {
 				"kind":       "Namespace",
 				"apiVersion": "v1",
 				"metadata": map[string]interface{}{
@@ -218,10 +220,17 @@ func testDataDeep() testData {
 // flattened
 func testDataArray() testData {
 	return testData{
-		deep: append([]map[string]interface{}{
+		deep: append(make([]map[string]interface{}, 0),
 			testDataDeep().deep.(map[string]interface{}),
-		}, testDataFlat().deep.(map[string]interface{})),
+			testDataFlat().deep.(map[string]interface{}),
+		),
 
-		flat: append(testDataDeep().flat.([]map[string]interface{}), testDataFlat().flat.([]map[string]interface{})...),
+		flat: map[string]manifest.Manifest{
+			".[0].app.web.backend.server.nginx.deployment":    testDataDeep().flat[".app.web.backend.server.nginx.deployment"],
+			".[0].app.web.frontend.nodejs.express.service":    testDataDeep().flat[".app.web.frontend.nodejs.express.service"],
+			".[0].app.web.frontend.nodejs.express.deployment": testDataDeep().flat[".app.web.frontend.nodejs.express.deployment"],
+			".[0].app.namespace":                              testDataDeep().flat[".app.namespace"],
+			".[1]":                                            testDataFlat().flat["."],
+		},
 	}
 }
diff --git a/pkg/kubernetes/kubernetes_test.go b/pkg/kubernetes/kubernetes_test.go
index c7e3e882..0e621d16 100644
--- a/pkg/kubernetes/kubernetes_test.go
+++ b/pkg/kubernetes/kubernetes_test.go
@@ -4,33 +4,36 @@ import (
 	"regexp"
 	"testing"
 
-	"github.com/grafana/tanka/pkg/spec/v1alpha1"
 	"github.com/stretchr/objx"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
+
+	"github.com/grafana/tanka/pkg/kubernetes/manifest"
+	"github.com/grafana/tanka/pkg/spec/v1alpha1"
 )
 
 func TestReconcile(t *testing.T) {
 	tests := []struct {
 		name string
-		k    *Kubernetes
+		spec v1alpha1.Spec
+
+		deep interface{}
+		flat manifest.List
 
-		data    testData
 		targets []*regexp.Regexp
 		err     error
 	}{
 		{
 			name: "regular",
-			data: testDataRegular(),
+			deep: testDataRegular().deep,
+			flat: mapToList(testDataRegular().flat),
 		},
 		{
 			name: "targets",
-			data: testData{
-				deep: testDataDeep().deep,
-				flat: []map[string]interface{}{
-					testDataDeep().flat.([]map[string]interface{})[0], // deployment/nginx
-					testDataDeep().flat.([]map[string]interface{})[1], // service/frontend
-				},
+			deep: testDataDeep().deep,
+			flat: manifest.List{
+				testDataDeep().flat[".app.web.backend.server.nginx.deployment"],
+				testDataDeep().flat[".app.web.frontend.nodejs.express.service"],
 			},
 			targets: []*regexp.Regexp{
 				regexp.MustCompile("deployment/nginx"),
@@ -39,62 +42,53 @@ func TestReconcile(t *testing.T) {
 		},
 		{
 			name: "targets-regex",
-			data: testData{
-				deep: testDataDeep().deep,
-				flat: []map[string]interface{}{
-					testDataDeep().flat.([]map[string]interface{})[2], // deployment/frontend
-					testDataDeep().flat.([]map[string]interface{})[0], // deployment/nginx
-				},
+			deep: testDataDeep().deep,
+			flat: manifest.List{
+				testDataDeep().flat[".app.web.backend.server.nginx.deployment"],
+				testDataDeep().flat[".app.web.frontend.nodejs.express.deployment"],
 			},
 			targets: []*regexp.Regexp{regexp.MustCompile("deployment/.*")},
 		},
 		{
 			name: "force-namespace",
-			k:    &Kubernetes{Spec: v1alpha1.Spec{Namespace: "tanka"}},
-			data: testData{
-				deep: testDataFlat().deep,
-				flat: func() []map[string]interface{} {
-					f := objx.New(testDataFlat().flat.([]map[string]interface{})[0])
-					f.Set("metadata.namespace", "tanka")
-					return []map[string]interface{}{f}
-				}(),
-			},
+			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",
-			k:    &Kubernetes{Spec: v1alpha1.Spec{Namespace: "tanka"}},
-			data: testData{
-				deep: func() map[string]interface{} {
-					d := objx.New(testDataFlat().deep)
-					d.Set("metadata.namespace", "custom")
-					return d
-				}(),
-				flat: func() []map[string]interface{} {
-					f := objx.New(testDataFlat().flat.([]map[string]interface{})[0])
-					f.Set("metadata.namespace", "custom")
-					return []map[string]interface{}{f}
-				}(),
-			},
+			spec: v1alpha1.Spec{Namespace: "tanka"},
+			deep: func() map[string]interface{} {
+				d := objx.New(testDataFlat().deep)
+				d.Set("metadata.namespace", "custom")
+				return d
+			}(),
+			flat: func() manifest.List {
+				f := testDataFlat().flat["."]
+				f.Metadata()["namespace"] = "custom"
+				return manifest.List{f}
+			}(),
 		},
 	}
 
 	for _, c := range tests {
 		t.Run(c.name, func(t *testing.T) {
-			got, err := c.k.Reconcile(c.data.deep.(map[string]interface{}), c.targets)
+			got, err := Reconcile(c.deep.(map[string]interface{}), c.spec, c.targets)
 
 			require.Equal(t, c.err, err)
-
-			flat := c.data.flat.([]map[string]interface{})
-			assert.Equal(t, msisToManifests(flat), got)
+			assert.ElementsMatch(t, c.flat, got)
 		})
 	}
 }
 
 func TestReconcileOrder(t *testing.T) {
-	got := make([][]Manifest, 10)
-	k := &Kubernetes{}
+	got := make([]manifest.List, 10)
 	for i := 0; i < 10; i++ {
-		r, err := k.Reconcile(testDataDeep().deep.(map[string]interface{}), nil)
+		r, err := Reconcile(testDataDeep().deep.(map[string]interface{}), v1alpha1.Spec{}, nil)
 		require.NoError(t, err)
 		got[i] = r
 	}
@@ -104,10 +98,10 @@ func TestReconcileOrder(t *testing.T) {
 	}
 }
 
-func msisToManifests(msis []map[string]interface{}) []Manifest {
-	ms := make([]Manifest, len(msis))
-	for i, msi := range msis {
-		ms[i] = Manifest(msi)
+func mapToList(ms map[string]manifest.Manifest) manifest.List {
+	l := make(manifest.List, 0, len(ms))
+	for _, m := range ms {
+		l = append(l, m)
 	}
-	return ms
+	return l
 }
diff --git a/pkg/kubernetes/reconcile_test.go b/pkg/kubernetes/reconcile_test.go
index 8bdfb388..fdecf160 100644
--- a/pkg/kubernetes/reconcile_test.go
+++ b/pkg/kubernetes/reconcile_test.go
@@ -7,7 +7,7 @@ import (
 	"github.com/stretchr/testify/require"
 )
 
-func TestWalkJSON(t *testing.T) {
+func TestExtract(t *testing.T) {
 	tests := []struct {
 		name string
 		data testData
@@ -38,9 +38,10 @@ func TestWalkJSON(t *testing.T) {
 
 	for _, c := range tests {
 		t.Run(c.name, func(t *testing.T) {
-			got, err := walkJSON(c.data.deep, "")
+			extracted, err := extract(c.data.deep)
+
 			require.Equal(t, c.err, err)
-			assert.ElementsMatch(t, c.data.flat, got)
+			assert.EqualValues(t, c.data.flat, extracted)
 		})
 	}
 }
diff --git a/pkg/spec/depreciations_test.go b/pkg/spec/depreciations_test.go
index e934c109..8e9e6d24 100644
--- a/pkg/spec/depreciations_test.go
+++ b/pkg/spec/depreciations_test.go
@@ -3,9 +3,10 @@ package spec
 import (
 	"testing"
 
-	"github.com/grafana/tanka/pkg/spec/v1alpha1"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
+
+	"github.com/grafana/tanka/pkg/spec/v1alpha1"
 )
 
 // TestDeprecated checks that deprecated fields are still respected, but can be