Repository: grafana/tanka (go)

Issue:
Move application logic out of main package
As I understand it, writing a Golang utility that includes Tanka isn't possible (in an elegant way) because it doesn't expose an API.

It would be very useful if the commands were separated out into separate packages available by import into other go code.

Pull request description:
refactor: api
Refactors all of the actual application logic out of the main package, into the `github.com/grafana/tanka/pkg/tanka` package, which effectively forms an API for Tanka, allowing it to be imported into other projects and used from there. 

The API is loosely based on the CLI, ie it follows the same scheme:
```go
tanka.Apply("environments/default`)
```

To change the behaviour of these calls, for example the diff strategy, modifiers can be used (https://dave.cheney.net/2014/10/17/functional-options-for-friendly-apis):

```go
tanka.Apply("environments/default", tanka.WithApplyForce(true))
```

In brief testing it seems as if it is still working the same way as it did before, however everyone reviewing this might want to keep a close look at the changes to see if I accidentally broke something without noticing. Thanks :)

Resolves #83 

Patch:
diff --git a/cmd/tk/env.go b/cmd/tk/env.go
index f7c97e2c..c81e05ba 100644
--- a/cmd/tk/env.go
+++ b/cmd/tk/env.go
@@ -8,12 +8,13 @@ import (
 	"path/filepath"
 	"text/tabwriter"
 
+	"github.com/pkg/errors"
 	"github.com/spf13/cobra"
 	"github.com/spf13/pflag"
 	"github.com/spf13/viper"
 
+	"github.com/grafana/tanka/pkg/cli"
 	"github.com/grafana/tanka/pkg/spec/v1alpha1"
-	"github.com/grafana/tanka/pkg/util"
 )
 
 func envCmd() *cobra.Command {
@@ -50,7 +51,7 @@ func envSetCmd() *cobra.Command {
 	envSettingsFlags(&tmp, cmd.Flags())
 
 	name := cmd.Flags().String("name", "", "")
-	cmd.Flags().MarkHidden("name")
+	_ = cmd.Flags().MarkHidden("name")
 
 	cmd.Run = func(cmd *cobra.Command, args []string) {
 		if *name != "" {
@@ -115,14 +116,16 @@ func addEnv(dir string, cfg *v1alpha1.Config) error {
 	if _, err := os.Stat(path); err != nil {
 		// folder does not exist
 		if os.IsNotExist(err) {
-			os.MkdirAll(path, os.ModePerm)
+			if err := os.MkdirAll(path, os.ModePerm); err != nil {
+				return errors.Wrap(err, "creating directory")
+			}
 		} else {
 			// it exists
 			if os.IsExist(err) {
-				return fmt.Errorf("Directory %s already exists.", path)
+				return fmt.Errorf("directory %s already exists", path)
 			}
 			// we have another error
-			return fmt.Errorf("Creating directory: %s", err)
+			return errors.Wrap(err, "creating directory")
 		}
 	}
 
@@ -156,7 +159,7 @@ func envRemoveCmd() *cobra.Command {
 				if err != nil {
 					log.Fatalln("parsing environments name:", err)
 				}
-				if err := util.Confirm(fmt.Sprintf("Permanently removing the environment located at '%s'.", path), "yes"); err != nil {
+				if err := cli.Confirm(fmt.Sprintf("Permanently removing the environment located at '%s'.", path), "yes"); err != nil {
 					log.Fatalln(err)
 				}
 				if err := os.RemoveAll(path); err != nil {
@@ -179,7 +182,7 @@ func envListCmd() *cobra.Command {
 	cmd.Run = func(cmd *cobra.Command, args []string) {
 		envs := []v1alpha1.Config{}
 		dirs := findBaseDirs()
-		useJson, err := cmd.Flags().GetBool("json")
+		useJSON, err := cmd.Flags().GetBool("json")
 		if err != nil {
 			// this err should never occur. Panic in case
 			panic(err)
@@ -189,7 +192,7 @@ func envListCmd() *cobra.Command {
 			envs = append(envs, *setupConfiguration(dir))
 		}
 
-		if useJson {
+		if useJSON {
 			j, err := json.Marshal(envs)
 			if err != nil {
 				log.Fatalln("Formatting as json:", j)
diff --git a/cmd/tk/init.go b/cmd/tk/init.go
index ca917e90..ca34e8e8 100644
--- a/cmd/tk/init.go
+++ b/cmd/tk/init.go
@@ -45,7 +45,6 @@ func initCmd() *cobra.Command {
 		}
 
 		fmt.Println("Directory structure set up! Remember to configure the API endpoint:\n`tk env set environments/default --server=127.0.0.1:6443`")
-
 	}
 	return cmd
 }
diff --git a/cmd/tk/jsonnet.go b/cmd/tk/jsonnet.go
index 0e2c9a7d..8e42c150 100644
--- a/cmd/tk/jsonnet.go
+++ b/cmd/tk/jsonnet.go
@@ -1,7 +1,6 @@
 package main
 
 import (
-	"encoding/json"
 	"log"
 	"path/filepath"
 
@@ -47,16 +46,3 @@ func eval(workdir string) (string, error) {
 	}
 	return json, nil
 }
-
-func evalDict(workdir string) (map[string]interface{}, error) {
-	var rawDict map[string]interface{}
-
-	raw, err := eval(workdir)
-	if err != nil {
-		return nil, err
-	}
-	if err := json.Unmarshal([]byte(raw), &rawDict); err != nil {
-		return nil, err
-	}
-	return rawDict, nil
-}
diff --git a/cmd/tk/main.go b/cmd/tk/main.go
index 1b6650c7..0c8379cb 100644
--- a/cmd/tk/main.go
+++ b/cmd/tk/main.go
@@ -11,8 +11,7 @@ import (
 	"github.com/spf13/viper"
 	"golang.org/x/crypto/ssh/terminal"
 
-	"github.com/grafana/tanka/pkg/cmp"
-	"github.com/grafana/tanka/pkg/kubernetes"
+	"github.com/grafana/tanka/pkg/cli/cmp"
 	"github.com/grafana/tanka/pkg/spec"
 	"github.com/grafana/tanka/pkg/spec/v1alpha1"
 )
@@ -21,26 +20,12 @@ import (
 // To be overwritten at build time
 var Version = "dev"
 
-// primary handlers
-var (
-	config = &v1alpha1.Config{}
-	kube   *kubernetes.Kubernetes
-)
-
 // describing variables
 var (
 	verbose     = false
 	interactive = terminal.IsTerminal(int(os.Stdout.Fd()))
 )
 
-// list of deprecated config keys and their alternatives
-// however, they still work and are aliased internally
-var deprecated = map[string]string{
-	"namespace": "spec.namespace",
-	"server":    "spec.apiServer",
-	"team":      "metadata.labels.team",
-}
-
 func main() {
 	log.SetFlags(0)
 	rootCmd := &cobra.Command{
@@ -48,20 +33,6 @@ func main() {
 		Short:            "tanka <3 jsonnet",
 		Version:          Version,
 		TraverseChildren: true,
-		// Configuration
-		PersistentPreRun: func(cmd *cobra.Command, args []string) {
-			if len(args) == 0 {
-				return
-			}
-			config = setupConfiguration(args[0])
-			if config == nil {
-				return
-			}
-
-			// Kubernetes
-			kube = kubernetes.New(config.Spec)
-
-		},
 	}
 	rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "")
 
diff --git a/cmd/tk/util.go b/cmd/tk/util.go
index 64afc15f..72a070c5 100644
--- a/cmd/tk/util.go
+++ b/cmd/tk/util.go
@@ -57,11 +57,11 @@ func highlight(lang, s string) string {
 func writeJSON(i interface{}, path string) error {
 	out, err := json.MarshalIndent(i, "", "  ")
 	if err != nil {
-		return fmt.Errorf("Marshalling: %s", err)
+		return fmt.Errorf("marshalling: %s", err)
 	}
 
 	if err := ioutil.WriteFile(path, append(out, '\n'), 0644); err != nil {
-		return fmt.Errorf("Writing %s: %s", path, err)
+		return fmt.Errorf("writing %s: %s", path, err)
 	}
 
 	return nil
diff --git a/cmd/tk/workflow.go b/cmd/tk/workflow.go
index 3d728709..67aa6273 100644
--- a/cmd/tk/workflow.go
+++ b/cmd/tk/workflow.go
@@ -11,8 +11,16 @@ import (
 	"github.com/spf13/cobra"
 	"github.com/spf13/pflag"
 
-	"github.com/grafana/tanka/pkg/cmp"
-	"github.com/grafana/tanka/pkg/kubernetes"
+	"github.com/grafana/tanka/pkg/cli/cmp"
+	"github.com/grafana/tanka/pkg/tanka"
+)
+
+// special exit codes for tk diff
+const (
+	// no changes
+	ExitStatusClean = 0
+	// differences between the local config and the cluster
+	ExitStatusDiff = 16
 )
 
 type workflowFlagVars struct {
@@ -38,29 +46,13 @@ func applyCmd() *cobra.Command {
 	force := cmd.Flags().Bool("force", false, "force applying (kubectl apply --force)")
 	autoApprove := cmd.Flags().Bool("dangerous-auto-approve", false, "skip interactive approval. Only for automation!")
 	cmd.Run = func(cmd *cobra.Command, args []string) {
-		if kube == nil {
-			log.Fatalln(kubernetes.ErrorMissingConfig{Verb: "apply"})
-		}
-
-		raw, err := evalDict(args[0])
+		err := tanka.Apply(args[0],
+			tanka.WithTargets(stringsToRegexps(vars.targets)...),
+			tanka.WithApplyForce(*force),
+			tanka.WithApplyAutoApprove(*autoApprove),
+		)
 		if err != nil {
-			log.Fatalln("Evaluating jsonnet:", err)
-		}
-
-		desired, err := kube.Reconcile(raw, stringsToRegexps(vars.targets)...)
-		if err != nil {
-			log.Fatalln("Reconciling:", err)
-		}
-
-		if !diff(desired, false, kubernetes.DiffOpts{}) {
-			log.Println("Warning: There are no differences. Your apply may not do anything at all.")
-		}
-
-		if err := kube.Apply(desired, kubernetes.ApplyOpts{
-			Force:       *force,
-			AutoApprove: *autoApprove,
-		}); err != nil {
-			log.Fatalln("Applying:", err)
+			log.Fatalln(err)
 		}
 	}
 	return cmd
@@ -88,58 +80,31 @@ func diffCmd() *cobra.Command {
 	)
 
 	cmd.Run = func(cmd *cobra.Command, args []string) {
-		if kube == nil {
-			log.Fatalln(kubernetes.ErrorMissingConfig{Verb: "diff"})
-		}
-
-		raw, err := evalDict(args[0])
-		if err != nil {
-			log.Fatalln("Evaluating jsonnet:", err)
-		}
-
-		if kube.Spec.DiffStrategy == "" {
-			kube.Spec.DiffStrategy = *diffStrategy
-		}
-
-		desired, err := kube.Reconcile(raw, stringsToRegexps(vars.targets)...)
+		changes, err := tanka.Diff(args[0],
+			tanka.WithTargets(stringsToRegexps(vars.targets)...),
+			tanka.WithDiffStrategy(*diffStrategy),
+			tanka.WithDiffSummarize(*summarize),
+		)
 		if err != nil {
-			log.Fatalln("Reconciling:", err)
+			log.Fatalln(err)
 		}
 
-		if diff(desired, interactive, kubernetes.DiffOpts{Summarize: *summarize}) {
-			os.Exit(16)
+		if changes == nil {
+			log.Println("No differences.")
+			os.Exit(ExitStatusClean)
 		}
-		log.Println("No differences.")
-	}
-
-	return cmd
-}
-
-// computes the diff, prints to screen.
-// set `pager` to false to disable the pager.
-// When non-interactive, no paging happens anyways.
-func diff(state []kubernetes.Manifest, pager bool, opts kubernetes.DiffOpts) (changed bool) {
-	changes, err := kube.Diff(state, opts)
-	if err != nil {
-		log.Fatalln("Diffing:", err)
-	}
-
-	if changes == nil {
-		return false
-	}
 
-	if interactive {
-		h := highlight("diff", *changes)
-		if pager {
+		if interactive {
+			h := highlight("diff", *changes)
 			pageln(h)
 		} else {
-			fmt.Println(h)
+			fmt.Println(*changes)
 		}
-	} else {
-		fmt.Println(*changes)
+
+		os.Exit(ExitStatusDiff)
 	}
 
-	return true
+	return cmd
 }
 
 func showCmd() *cobra.Command {
@@ -152,26 +117,18 @@ func showCmd() *cobra.Command {
 		},
 	}
 	vars := workflowFlags(cmd.Flags())
-	canRedirect := cmd.Flags().Bool("dangerous-allow-redirect", false, "allow redirecting output to a file or a pipe.")
+	allowRedirect := cmd.Flags().Bool("dangerous-allow-redirect", false, "allow redirecting output to a file or a pipe.")
 	cmd.Run = func(cmd *cobra.Command, args []string) {
-		if !interactive && !*canRedirect {
+		if !interactive && !*allowRedirect {
 			fmt.Fprintln(os.Stderr, "Redirection of the output of tk show is discouraged and disabled by default. Run tk show --dangerous-allow-redirect to enable.")
 			return
 		}
 
-		raw, err := evalDict(args[0])
-		if err != nil {
-			log.Fatalln("Evaluating jsonnet:", err)
-		}
-
-		state, err := kube.Reconcile(raw, stringsToRegexps(vars.targets)...)
-		if err != nil {
-			log.Fatalln("Reconciling:", err)
-		}
-
-		pretty, err := kube.Fmt(state)
+		pretty, err := tanka.Show(args[0],
+			tanka.WithTargets(stringsToRegexps(vars.targets)...),
+		)
 		if err != nil {
-			log.Fatalln("Pretty printing state:", err)
+			log.Fatalln(err)
 		}
 
 		pageln(pretty)
diff --git a/go.mod b/go.mod
index 434a1699..0f75d863 100644
--- a/go.mod
+++ b/go.mod
@@ -20,4 +20,5 @@ require (
 	golang.org/x/sys v0.0.0-20190616124812-15dcb6c0061f // indirect
 	gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
 	gopkg.in/yaml.v2 v2.2.2
+	gopkg.in/yaml.v3 v3.0.0-20191010095647-fc94e3f71652
 )
diff --git a/go.sum b/go.sum
index 75b17305..96c24451 100644
--- a/go.sum
+++ b/go.sum
@@ -124,3 +124,5 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v3 v3.0.0-20191010095647-fc94e3f71652 h1:VKvJ/mQ4BgCjZUDggYFxTe0qv9jPMHsZPD4Xt91Y5H4=
+gopkg.in/yaml.v3 v3.0.0-20191010095647-fc94e3f71652/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/pkg/util/alert.go b/pkg/cli/alert.go
similarity index 97%
rename from pkg/util/alert.go
rename to pkg/cli/alert.go
index 1968719a..7ea24655 100644
--- a/pkg/util/alert.go
+++ b/pkg/cli/alert.go
@@ -1,4 +1,4 @@
-package util
+package cli
 
 import (
 	"bufio"
diff --git a/pkg/cmp/complete.go b/pkg/cli/cmp/complete.go
similarity index 100%
rename from pkg/cmp/complete.go
rename to pkg/cli/cmp/complete.go
diff --git a/pkg/jsonnet/imports.go b/pkg/jsonnet/imports.go
index de8dcd58..adf50714 100644
--- a/pkg/jsonnet/imports.go
+++ b/pkg/jsonnet/imports.go
@@ -39,7 +39,7 @@ func TransitiveImports(filename string) ([]string, error) {
 		return nil, errors.Wrap(err, "creating Jsonnet AST")
 	}
 
-	imports := make([]string, 0, 0)
+	imports := make([]string, 0)
 	err = importRecursive(&imports, vm, node, "main.jsonnet")
 
 	return uniqueStringSlice(imports), err
diff --git a/pkg/kubernetes/errors.go b/pkg/kubernetes/errors.go
index 1a1072ae..3c7c26a9 100644
--- a/pkg/kubernetes/errors.go
+++ b/pkg/kubernetes/errors.go
@@ -1,6 +1,9 @@
 package kubernetes
 
-import "fmt"
+import (
+	"errors"
+	"fmt"
+)
 
 type ErrorNotFound struct {
 	name string
@@ -11,10 +14,6 @@ func (e ErrorNotFound) Error() string {
 	return fmt.Sprintf(`error from server (NotFound): %s "%s" not found`, e.kind, e.name)
 }
 
-type ErrorMissingConfig struct {
-	Verb string
-}
-
-func (e ErrorMissingConfig) Error() string {
-	return fmt.Sprintf("%s requires additional configuration. Refer to https://tanka.dev/environments for that.", e.Verb)
-}
+var (
+	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
index c0d005a4..8c74d5b1 100644
--- a/pkg/kubernetes/kubectl.go
+++ b/pkg/kubernetes/kubectl.go
@@ -14,7 +14,7 @@ import (
 	"github.com/stretchr/objx"
 	funk "github.com/thoas/go-funk"
 
-	"github.com/grafana/tanka/pkg/util"
+	"github.com/grafana/tanka/pkg/cli"
 )
 
 var (
@@ -143,7 +143,7 @@ func (k Kubectl) Apply(yaml, namespace string, opts ApplyOpts) error {
 	}
 
 	if !opts.AutoApprove {
-		if err := util.Confirm(
+		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()),
diff --git a/pkg/kubernetes/kubernetes.go b/pkg/kubernetes/kubernetes.go
index 3a0f9e5b..1a89a524 100644
--- a/pkg/kubernetes/kubernetes.go
+++ b/pkg/kubernetes/kubernetes.go
@@ -11,7 +11,7 @@ import (
 	"github.com/pkg/errors"
 	"github.com/stretchr/objx"
 	funk "github.com/thoas/go-funk"
-	yaml "gopkg.in/yaml.v2"
+	yaml "gopkg.in/yaml.v3"
 
 	"github.com/grafana/tanka/pkg/spec/v1alpha1"
 )
@@ -57,7 +57,7 @@ func (m Manifest) 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) {
+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))
 	if err != nil {
@@ -110,7 +110,7 @@ func (k *Kubernetes) Fmt(state []Manifest) (string, error) {
 // 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{"apply"}
+		return ErrorMissingConfig
 	}
 
 	yaml, err := k.Fmt(state)
@@ -122,29 +122,37 @@ func (k *Kubernetes) Apply(state []Manifest, opts ApplyOpts) error {
 
 // DiffOpts allow to specify additional parameters for diff operations
 type DiffOpts struct {
+	// Use `diffstat(1)` to create a histogram of the changes instead
 	Summarize bool
+
+	// Set the diff-strategy. If unset, the value set in the spec is used
+	Strategy string
 }
 
 // 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{"diff"}
+		return nil, ErrorMissingConfig
 	}
 	yaml, err := k.Fmt(state)
 	if err != nil {
 		return nil, err
 	}
 
-	if k.Spec.DiffStrategy == "" {
-		k.Spec.DiffStrategy = "native"
+	ds := 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")) {
-				k.Spec.DiffStrategy = "subset"
+				ds = "subset"
 			}
 		}
 	}
 
-	d, err := k.differs[k.Spec.DiffStrategy](yaml)
+	d, err := k.differs[ds](yaml)
 	switch {
 	case err != nil:
 		return nil, err
diff --git a/pkg/kubernetes/subsetdiff.go b/pkg/kubernetes/subsetdiff.go
index 14175c7a..a8687188 100644
--- a/pkg/kubernetes/subsetdiff.go
+++ b/pkg/kubernetes/subsetdiff.go
@@ -7,9 +7,7 @@ import (
 
 	"github.com/pkg/errors"
 	"github.com/stretchr/objx"
-	yaml "gopkg.in/yaml.v2"
-
-	"github.com/grafana/tanka/pkg/util"
+	yaml "gopkg.in/yaml.v3"
 )
 
 type difference struct {
@@ -27,7 +25,7 @@ func (k Kubectl) SubsetDiff(y string) (*string, error) {
 
 	for {
 		// jsonnet output -> desired state
-		var rawShould map[interface{}]interface{}
+		var rawShould map[string]interface{}
 		err := d.Decode(&rawShould)
 		if err == io.EOF {
 			break
@@ -39,7 +37,6 @@ func (k Kubectl) SubsetDiff(y string) (*string, error) {
 
 		routines++
 		go subsetDiff(k, rawShould, resultCh, errCh)
-
 	}
 
 	var lastErr error
@@ -74,9 +71,8 @@ func (k Kubectl) SubsetDiff(y string) (*string, error) {
 	return &diffs, nil
 }
 
-func subsetDiff(k Kubectl, rawShould map[interface{}]interface{}, r chan difference, e chan error) {
-	// filename
-	m := objx.New(util.CleanupInterfaceMap(rawShould))
+func subsetDiff(k Kubectl, rawShould map[string]interface{}, r chan difference, e chan error) {
+	m := objx.New(rawShould)
 	name := strings.Replace(fmt.Sprintf("%s.%s.%s.%s",
 		m.Get("apiVersion").MustStr(),
 		m.Get("kind").MustStr(),
diff --git a/pkg/tanka/parse.go b/pkg/tanka/parse.go
new file mode 100644
index 00000000..10ee8f0d
--- /dev/null
+++ b/pkg/tanka/parse.go
@@ -0,0 +1,84 @@
+package tanka
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+	"path/filepath"
+
+	"github.com/pkg/errors"
+	"github.com/spf13/viper"
+
+	"github.com/grafana/tanka/pkg/jsonnet"
+	"github.com/grafana/tanka/pkg/jsonnet/jpath"
+	"github.com/grafana/tanka/pkg/kubernetes"
+	"github.com/grafana/tanka/pkg/spec"
+)
+
+// 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)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	raw, err := eval(baseDir)
+	if err != nil {
+		return nil, nil, errors.Wrap(err, "evaluating jsonnet")
+	}
+
+	rec, err := kube.Reconcile(raw, opts.targets)
+	if err != nil {
+		return nil, nil, errors.Wrap(err, "reconciling")
+	}
+
+	return rec, kube, 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) {
+	config, err := spec.ParseDir(baseDir)
+	if err != nil {
+		switch err.(type) {
+		// config is missing
+		case viper.ConfigFileNotFoundError:
+			return nil, kubernetes.ErrorMissingConfig
+		// the config includes deprecated fields
+		case spec.ErrDeprecated:
+			if opts.wWarn == nil {
+				opts.wWarn = os.Stderr
+			}
+			fmt.Fprint(opts.wWarn, err)
+		// some other error
+		default:
+			return nil, errors.Wrap(err, "reading spec.json")
+		}
+	}
+
+	return kubernetes.New(config.Spec), nil
+}
+
+// eval evaluates the jsonnet environment at the given directory starting with
+// `main.jsonnet`
+func eval(path string) (map[string]interface{}, error) {
+	workdir, err := filepath.Abs(path)
+	if err != nil {
+		return nil, err
+	}
+	_, baseDir, _, err := jpath.Resolve(workdir)
+	if err != nil {
+		return nil, errors.Wrap(err, "resolving jpath")
+	}
+	raw, err := jsonnet.EvaluateFile(filepath.Join(baseDir, "main.jsonnet"))
+	if err != nil {
+		return nil, err
+	}
+
+	var dict map[string]interface{}
+	if err := json.Unmarshal([]byte(raw), &dict); err != nil {
+		return nil, err
+	}
+	return dict, nil
+}
diff --git a/pkg/tanka/tanka.go b/pkg/tanka/tanka.go
new file mode 100644
index 00000000..f57463a6
--- /dev/null
+++ b/pkg/tanka/tanka.go
@@ -0,0 +1,87 @@
+// The tanka package 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.
+package tanka
+
+import (
+	"io"
+	"regexp"
+
+	"github.com/grafana/tanka/pkg/kubernetes"
+)
+
+// parseModifiers parses all modifiers into an options struct
+func parseModifiers(mods []Modifier) *options {
+	o := &options{}
+	for _, mod := range mods {
+		mod(o)
+	}
+	return o
+}
+
+type options struct {
+	// io.Writer to write warnings and notices to
+	wWarn io.Writer
+
+	// target regular expressions to limit the working set
+	targets []*regexp.Regexp
+
+	// additional options for diff
+	diff kubernetes.DiffOpts
+	// additional options for apply
+	apply kubernetes.ApplyOpts
+}
+
+// Modifiers 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)
+
+// WithWarnWriter allows to provide a custom io.Writer that all warnings are
+// written to
+func WithWarnWriter(w io.Writer) Modifier {
+	return func(opts *options) {
+		opts.wWarn = w
+	}
+}
+
+// WithTargets allows to submit regular expressions to limit the working set of
+// objects (https://tanka.dev/targets/).
+func WithTargets(t ...*regexp.Regexp) Modifier {
+	return func(opts *options) {
+		opts.targets = t
+	}
+}
+
+// WithDiffStrategy allows to set the used diff strategy.
+// An empty string is ignored.
+func WithDiffStrategy(ds string) Modifier {
+	return func(opts *options) {
+		if ds != "" {
+			opts.diff.Strategy = ds
+		}
+	}
+}
+
+// WithDiffSummarize enables summary mode, which invokes `diffstat(1)` on the
+// set of changes to create an overview
+func WithDiffSummarize(b bool) Modifier {
+	return func(opts *options) {
+		opts.diff.Summarize = b
+	}
+}
+
+// WithApplyForce allows to invoke `kubectl apply` with the `--force` flag
+func WithApplyForce(b bool) Modifier {
+	return func(opts *options) {
+		opts.apply.Force = b
+	}
+}
+
+// WithApplyAutoApprove allows to skip the interactive approval
+func WithApplyAutoApprove(b bool) Modifier {
+	return func(opts *options) {
+		opts.apply.AutoApprove = b
+	}
+}
diff --git a/pkg/tanka/workflow.go b/pkg/tanka/workflow.go
new file mode 100644
index 00000000..dfcac13f
--- /dev/null
+++ b/pkg/tanka/workflow.go
@@ -0,0 +1,75 @@
+package tanka
+
+import (
+	"fmt"
+	"os"
+
+	"github.com/pkg/errors"
+
+	"github.com/grafana/tanka/pkg/kubernetes"
+)
+
+// Apply parses the environment at the given directory (a `baseDir`) and applies
+// the evaluated jsonnet to the Kubernetes cluster defined in the environments
+// `spec.json`.
+// NOTE: This function prints on screen in default configuration.
+// Use the `WithWarnWriter` modifier to change that. The `WithApply*` modifiers
+// may be used to further influence the behavior.
+func Apply(baseDir string, mods ...Modifier) error {
+	opts := parseModifiers(mods)
+
+	rec, kube, err := parse(baseDir, opts)
+	if err != nil {
+		return err
+	}
+
+	diff, err := kube.Diff(rec, kubernetes.DiffOpts{})
+	if err != nil {
+		return errors.Wrap(err, "diffing")
+	}
+	if diff == nil {
+		tmp := "Warning: There are no differences. Your apply may not do anything at all."
+		diff = &tmp
+	}
+
+	if opts.wWarn == nil {
+		opts.wWarn = os.Stderr
+	}
+	fmt.Fprintln(opts.wWarn, *diff)
+
+	return kube.Apply(rec, opts.apply)
+}
+
+// Diff parses the environment at the given directory (a `baseDir`) and returns
+// the differences from the live cluster state in `diff(1)` format. If the
+// `WithDiffSummarize` modifier is used, a histogram created using `diffstat(1)`
+// is returned instead.
+// The cluster information is retrieved from the environments `spec.json`.
+// NOTE: This function requires on `diff(1)`, `kubectl(1)` and perhaps `diffstat(1)`
+func Diff(baseDir string, mods ...Modifier) (*string, error) {
+	opts := parseModifiers(mods)
+
+	rec, kube, err := parse(baseDir, opts)
+	if err != nil {
+		return nil, err
+	}
+
+	return kube.Diff(rec, opts.diff)
+}
+
+// Show parses the environment at the given directory (a `baseDir`) and returns
+// the evaluated jsonnet in yaml form
+func Show(baseDir string, mods ...Modifier) (string, error) {
+	opts := parseModifiers(mods)
+
+	rec, kube, 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
+}
diff --git a/pkg/util/mapstr.go b/pkg/util/mapstr.go
deleted file mode 100644
index 76efd153..00000000
--- a/pkg/util/mapstr.go
+++ /dev/null
@@ -1,33 +0,0 @@
-package util
-
-import "fmt"
-
-// CleanupInterfaceMap converts a map[interface]interface to map[string]interface
-func CleanupInterfaceMap(in map[interface{}]interface{}) map[string]interface{} {
-	res := make(map[string]interface{})
-	for k, v := range in {
-		res[fmt.Sprintf("%v", k)] = cleanupMapValue(v)
-	}
-	return res
-}
-
-func cleanupInterfaceArray(in []interface{}) []interface{} {
-	res := make([]interface{}, len(in))
-	for i, v := range in {
-		res[i] = cleanupMapValue(v)
-	}
-	return res
-}
-
-func cleanupMapValue(v interface{}) interface{} {
-	switch v := v.(type) {
-	case []interface{}:
-		return cleanupInterfaceArray(v)
-	case map[interface{}]interface{}:
-		return CleanupInterfaceMap(v)
-	case string:
-		return v
-	default:
-		return fmt.Sprintf("%v", v)
-	}
-}


Test patch:
diff --git a/pkg/kubernetes/kubernetes_test.go b/pkg/kubernetes/kubernetes_test.go
index b9659746..c7e3e882 100644
--- a/pkg/kubernetes/kubernetes_test.go
+++ b/pkg/kubernetes/kubernetes_test.go
@@ -80,7 +80,7 @@ func TestReconcile(t *testing.T) {
 
 	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 := c.k.Reconcile(c.data.deep.(map[string]interface{}), c.targets)
 
 			require.Equal(t, c.err, err)
 
@@ -94,7 +94,7 @@ func TestReconcileOrder(t *testing.T) {
 	got := make([][]Manifest, 10)
 	k := &Kubernetes{}
 	for i := 0; i < 10; i++ {
-		r, err := k.Reconcile(testDataDeep().deep.(map[string]interface{}))
+		r, err := k.Reconcile(testDataDeep().deep.(map[string]interface{}), nil)
 		require.NoError(t, err)
 		got[i] = r
 	}