Repository: grafana/tanka
Pull request #366 (2020-08-31 17:43:09)

Add default labels and annotations from spec.json
Implements #365 

This allows you to have in your spec.json:

```
"spec": {
  "resourceDefaults": {
    "labels": {"foo":"bar"},
    "annotations": {"whatever/foo": "anything_at_all"},
  }
}
```

and those will be applied to EVERY resource in the environment.

It will not overwrite any labels or annotations explicitly defined in your manifests.


Patch:
diff --git a/.gitignore b/.gitignore
index a5546f77..1521c8b7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,1 @@
 dist
-tk
-
diff --git a/cmd/tk/tool.go b/cmd/tk/tool.go
index 72f77e51..11d5fb3d 100644
--- a/cmd/tk/tool.go
+++ b/cmd/tk/tool.go
@@ -20,8 +20,11 @@ func toolCmd() *cli.Command {
 		Short: "handy utilities for working with jsonnet",
 		Use:   "tool [command]",
 	}
-	cmd.AddCommand(jpathCmd())
-	cmd.AddCommand(importsCmd())
+	cmd.AddCommand(
+		jpathCmd(),
+		importsCmd(),
+		chartsCmd(),
+	)
 	return cmd
 }
 
diff --git a/cmd/tk/toolCharts.go b/cmd/tk/toolCharts.go
new file mode 100644
index 00000000..217234c8
--- /dev/null
+++ b/cmd/tk/toolCharts.go
@@ -0,0 +1,126 @@
+package main
+
+import (
+	"fmt"
+	"log"
+	"os"
+	"path/filepath"
+
+	"github.com/go-clix/cli"
+	"github.com/grafana/tanka/pkg/helm"
+	"gopkg.in/yaml.v2"
+)
+
+func chartsCmd() *cli.Command {
+	cmd := &cli.Command{
+		Use:   "charts",
+		Short: "Declarative vendoring of Helm Charts",
+	}
+
+	cmd.AddCommand(
+		chartsInitCmd(),
+		chartsAddCmd(),
+		chartsVendorCmd(),
+		chartsConfigCmd(),
+	)
+
+	return cmd
+}
+
+func chartsVendorCmd() *cli.Command {
+	cmd := &cli.Command{
+		Use:   "vendor",
+		Short: "Download Charts to a local folder",
+	}
+
+	cmd.Run = func(cmd *cli.Command, args []string) error {
+		c, err := loadChartfile()
+		if err != nil {
+			return err
+		}
+
+		return c.Vendor()
+	}
+
+	return cmd
+}
+
+func chartsAddCmd() *cli.Command {
+	cmd := &cli.Command{
+		Use:   "add [chart@version] [...]",
+		Short: "Adds Charts to the chartfile",
+	}
+
+	cmd.Run = func(cmd *cli.Command, args []string) error {
+		c, err := loadChartfile()
+		if err != nil {
+			return err
+		}
+
+		return c.Add(args)
+	}
+
+	return cmd
+}
+
+func chartsConfigCmd() *cli.Command {
+	cmd := &cli.Command{
+		Use:   "config",
+		Short: "Displays the current manifest",
+	}
+
+	cmd.Run = func(cmd *cli.Command, args []string) error {
+		c, err := loadChartfile()
+		if err != nil {
+			return err
+		}
+
+		data, err := yaml.Marshal(c.Manifest)
+		if err != nil {
+			return err
+		}
+
+		fmt.Print(string(data))
+
+		return nil
+	}
+
+	return cmd
+}
+
+func chartsInitCmd() *cli.Command {
+	cmd := &cli.Command{
+		Use:   "init",
+		Short: "Create a new Chartfile",
+	}
+
+	cmd.Run = func(cmd *cli.Command, args []string) error {
+		wd, err := os.Getwd()
+		if err != nil {
+			return err
+		}
+
+		path := filepath.Join(wd, helm.Filename)
+		if _, err := os.Stat(path); err == nil {
+			return fmt.Errorf("Chartfile at '%s' already exists. Aborting", path)
+		}
+
+		if _, err := helm.InitChartfile(path); err != nil {
+			return err
+		}
+
+		log.Printf("Success! New Chartfile created at '%s'", path)
+		return nil
+	}
+
+	return cmd
+}
+
+func loadChartfile() (*helm.Charts, error) {
+	wd, err := os.Getwd()
+	if err != nil {
+		return nil, err
+	}
+
+	return helm.LoadChartfile(wd)
+}
diff --git a/go.mod b/go.mod
index 9db35c4c..020d88ae 100644
--- a/go.mod
+++ b/go.mod
@@ -9,7 +9,7 @@ require (
 	github.com/go-clix/cli v0.1.1
 	github.com/gobwas/glob v0.2.3
 	github.com/google/go-cmp v0.5.2-0.20200818193711-d2fcc899bdc2
-	github.com/google/go-jsonnet v0.15.1-0.20200331184325-4f4aa80dd785
+	github.com/google/go-jsonnet v0.16.1-0.20200908152747-b70cbd441a39
 	github.com/karrick/godirwalk v1.15.5
 	github.com/pkg/errors v0.8.1
 	github.com/posener/complete v1.2.3
@@ -21,4 +21,5 @@ require (
 	gopkg.in/yaml.v2 v2.2.8
 	gopkg.in/yaml.v3 v3.0.0-20191010095647-fc94e3f71652
 	k8s.io/apimachinery v0.18.3
+	sigs.k8s.io/yaml v1.2.0
 )
diff --git a/go.sum b/go.sum
index ae1d2b21..17b5936c 100644
--- a/go.sum
+++ b/go.sum
@@ -35,8 +35,11 @@ github.com/google/go-cmp v0.3.0 h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY=
 github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
 github.com/google/go-cmp v0.5.2-0.20200818193711-d2fcc899bdc2 h1:CZtx9gNen+kr3PuC/JQff3n1pJbgpy7Wr3hzjnupqdw=
 github.com/google/go-cmp v0.5.2-0.20200818193711-d2fcc899bdc2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM=
 github.com/google/go-jsonnet v0.15.1-0.20200331184325-4f4aa80dd785 h1:+dlQ7fPoeAqO0U9V+94golo/rW1/V2Pn+v8aPp3ljRM=
 github.com/google/go-jsonnet v0.15.1-0.20200331184325-4f4aa80dd785/go.mod h1:sOcuej3UW1vpPTZOr8L7RQimqai1a57bt5j22LzGZCw=
+github.com/google/go-jsonnet v0.16.1-0.20200908152747-b70cbd441a39 h1:noLRnY1ESguFGDPxXvIcESe2rG63f+ZSbSGYfVa6iHo=
+github.com/google/go-jsonnet v0.16.1-0.20200908152747-b70cbd441a39/go.mod h1:sOcuej3UW1vpPTZOr8L7RQimqai1a57bt5j22LzGZCw=
 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
 github.com/google/gofuzz v1.1.0 h1:Hsa8mG0dQ46ij8Sl2AYJDUv1oA9/d6Vk+3LG99Oe02g=
 github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -151,4 +154,5 @@ sigs.k8s.io/structured-merge-diff/v3 v3.0.0-20200116222232-67a7b8c61874/go.mod h
 sigs.k8s.io/structured-merge-diff/v3 v3.0.0 h1:dOmIZBMfhcHS09XZkMyUgkq5trg3/jRyJYFZUiaOp8E=
 sigs.k8s.io/structured-merge-diff/v3 v3.0.0/go.mod h1:PlARxl6Hbt/+BC80dRLi1qAmnMqwqDg62YvvVkZjemw=
 sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o=
+sigs.k8s.io/yaml v1.2.0 h1:kr/MCeFWJWTwyaHoR9c8EjH9OumOmoF9YGiZd7lFm/Q=
 sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc=
diff --git a/pkg/helm/charts.go b/pkg/helm/charts.go
new file mode 100644
index 00000000..7a66c1e5
--- /dev/null
+++ b/pkg/helm/charts.go
@@ -0,0 +1,201 @@
+package helm
+
+import (
+	"errors"
+	"fmt"
+	"io/ioutil"
+	"log"
+	"os"
+	"path/filepath"
+	"regexp"
+	"strings"
+
+	"github.com/Masterminds/semver"
+	"sigs.k8s.io/yaml"
+)
+
+// LoadChartfile opens a Chartfile tree
+func LoadChartfile(projectRoot string) (*Charts, error) {
+	// make sure project root is valid
+	abs, err := filepath.Abs(projectRoot)
+	if err != nil {
+		return nil, err
+	}
+
+	// open chartfile
+	chartfile := filepath.Join(abs, Filename)
+	data, err := ioutil.ReadFile(chartfile)
+	if err != nil {
+		return nil, err
+	}
+
+	// parse it
+	c := Chartfile{
+		Version:   Version,
+		Directory: DefaultDir,
+	}
+	if err := yaml.UnmarshalStrict(data, &c); err != nil {
+		return nil, err
+	}
+
+	for i, r := range c.Requires {
+		if r.Chart == "" {
+			return nil, fmt.Errorf("requirements[%v]: 'chart' must be set", i)
+		}
+	}
+
+	// return Charts handle
+	charts := &Charts{
+		Manifest:    c,
+		projectRoot: abs,
+
+		// default to ExecHelm, but allow injecting from the outside
+		Helm: ExecHelm{},
+	}
+	return charts, nil
+}
+
+// Charts exposes the central Chartfile management functions
+type Charts struct {
+	// Manifest are the chartfile.yaml contents. It holds data about the developers intentions
+	Manifest Chartfile
+
+	// projectRoot is the enclosing directory of chartfile.yaml
+	projectRoot string
+
+	// Helm is the helm implementation underneath. ExecHelm is the default, but
+	// any implementation of the Helm interface may be used
+	Helm Helm
+}
+
+// ChartDir returns the directory pulled charts are saved in
+func (c Charts) ChartDir() string {
+	return filepath.Join(c.projectRoot, c.Manifest.Directory)
+}
+
+// ManifestFile returns the full path to the chartfile.yaml
+func (c Charts) ManifestFile() string {
+	return filepath.Join(c.projectRoot, Filename)
+}
+
+// Vendor pulls all Charts specified in the manifest into the local charts
+// directory. It fetches the repository index before doing so.
+func (c Charts) Vendor() error {
+	dir := c.ChartDir()
+	if err := os.MkdirAll(dir, os.ModePerm); err != nil {
+		return err
+	}
+
+	log.Println("Syncing Repositories ...")
+	if err := c.Helm.RepoUpdate(Opts{Repositories: c.Manifest.Repositories}); err != nil {
+		return err
+	}
+
+	log.Println("Pulling Charts ...")
+	for _, r := range c.Manifest.Requires {
+		err := c.Helm.Pull(r.Chart, r.Version.String(), PullOpts{
+			Destination: dir,
+			Opts:        Opts{Repositories: c.Manifest.Repositories},
+		})
+		if err != nil {
+			return err
+		}
+
+		log.Printf(" %s@%s", r.Chart, r.Version.String())
+	}
+
+	return nil
+}
+
+// Add adds every Chart in reqs to the Manifest after validation, and runs
+// Vendor afterwards
+func (c Charts) Add(reqs []string) error {
+	log.Printf("Adding %v Charts ...", len(reqs))
+
+	skip := func(s string, err error) {
+		log.Printf(" Skipping %s: %s.", s, err)
+	}
+
+	// parse new charts, append in memory
+	added := 0
+	for _, s := range reqs {
+		r, err := parseReq(s)
+		if err != nil {
+			skip(s, err)
+			continue
+		}
+
+		if c.Manifest.Requires.Has(*r) {
+			skip(s, fmt.Errorf("already exists"))
+			continue
+		}
+
+		c.Manifest.Requires = append(c.Manifest.Requires, *r)
+		added++
+		log.Println(" OK:", s)
+	}
+
+	// write out
+	if err := write(c.Manifest, c.ManifestFile()); err != nil {
+		return err
+	}
+
+	// skipped some? fail then
+	if added != len(reqs) {
+		return fmt.Errorf("%v Charts were skipped. Please check above logs for details", len(reqs)-added)
+	}
+
+	// worked fine? vendor it
+	log.Printf("Added %v Charts to helmfile.yaml. Vendoring ...", added)
+	return c.Vendor()
+}
+
+func InitChartfile(path string) (*Charts, error) {
+	c := Chartfile{
+		Version: Version,
+		Repositories: []Repo{{
+			Name: "stable",
+			URL:  "https://kubernetes-charts.storage.googleapis.com",
+		}},
+		Requires: make(Requirements, 0),
+	}
+
+	if err := write(c, path); err != nil {
+		return nil, err
+	}
+
+	return LoadChartfile(filepath.Dir(path))
+}
+
+// write saves a Chartfile to dest
+func write(c Chartfile, dest string) error {
+	data, err := yaml.Marshal(c)
+	if err != nil {
+		return err
+	}
+
+	return ioutil.WriteFile(dest, data, 0644)
+}
+
+var chartExp = regexp.MustCompile(`\w+\/\w+@.+`)
+
+// parseReq parses a requirement from a string of the format `repo/name@version`
+func parseReq(s string) (*Requirement, error) {
+	if !chartExp.MatchString(s) {
+		return nil, fmt.Errorf("not of form 'repo/chart@version'")
+	}
+
+	elems := strings.Split(s, "@")
+	chart := elems[0]
+	ver, err := semver.NewVersion(elems[1])
+	if errors.Is(err, semver.ErrInvalidSemVer) {
+		return nil, fmt.Errorf("version is invalid")
+	} else if err != nil {
+		return nil, fmt.Errorf("version is invalid: %s", err)
+	}
+
+	return &Requirement{
+		Chart:   chart,
+		Version: *ver,
+	}, nil
+}
diff --git a/pkg/helm/helm.go b/pkg/helm/helm.go
new file mode 100644
index 00000000..6752d0ae
--- /dev/null
+++ b/pkg/helm/helm.go
@@ -0,0 +1,115 @@
+package helm
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"io/ioutil"
+	"os"
+	"os/exec"
+)
+
+// Helm provides high level access to some Helm operations
+type Helm interface {
+	// Pull downloads a Helm Chart from a remote
+	Pull(chart, version string, opts PullOpts) error
+
+	// RepoUpdate fetches the latest remote index
+	RepoUpdate(opts Opts) error
+}
+
+// PullOpts are additional, non-required options for Helm.Pull
+type PullOpts struct {
+	Opts
+
+	// Directory to put the resulting .tgz into
+	Destination string
+}
+
+// Opts are additional, non-required options that all Helm operations accept
+type Opts struct {
+	Repositories []Repo
+}
+
+// ExecHelm is a Helm implementation powered by the `helm` command line utility
+type ExecHelm struct{}
+
+// Pull implements Helm.Pull
+func (e ExecHelm) Pull(chart, version string, opts PullOpts) error {
+	repoFile, err := writeRepoTmpFile(opts.Repositories)
+	if err != nil {
+		return err
+	}
+	defer os.Remove(repoFile)
+
+	cmd := e.cmd("pull", chart,
+		"--version", version,
+		"--destination", opts.Destination,
+		"--repository-config", repoFile,
+		"--untar",
+	)
+
+	return cmd.Run()
+}
+
+// RepoUpdate implements Helm.RepoUpdate
+func (e ExecHelm) RepoUpdate(opts Opts) error {
+	repoFile, err := writeRepoTmpFile(opts.Repositories)
+	if err != nil {
+		return err
+	}
+	defer os.Remove(repoFile)
+
+	cmd := e.cmd("repo", "update",
+		"--repository-config", repoFile,
+	)
+	var errBuf bytes.Buffer
+	cmd.Stderr = &errBuf
+
+	if err := cmd.Run(); err != nil {
+		return fmt.Errorf("%s\n%s", errBuf.String(), err)
+	}
+
+	return nil
+}
+
+// cmd returns a prepared exec.Cmd to use the `helm` binary
+func (e ExecHelm) cmd(action string, args ...string) *exec.Cmd {
+	argv := []string{action}
+	argv = append(argv, args...)
+
+	cmd := helmCmd(argv...)
+	cmd.Stderr = os.Stderr
+
+	return cmd
+}
+
+// helmCmd returns a bare exec.Cmd pointed at the local helm binary
+func helmCmd(args ...string) *exec.Cmd {
+	bin := "helm"
+	if env := os.Getenv("TANKA_HELM_PATH"); env != "" {
+		bin = env
+	}
+
+	return exec.Command(bin, args...)
+}
+
+// writeRepoTmpFile creates a temporary repositories.yaml from the passed Repo
+// slice to be used by the helm binary
+func writeRepoTmpFile(r []Repo) (string, error) {
+	m := map[string]interface{}{
+		"repositories": r,
+	}
+
+	f, err := ioutil.TempFile("", "charts-repos")
+	if err != nil {
+		return "", err
+	}
+
+	enc := json.NewEncoder(f)
+	if err := enc.Encode(m); err != nil {
+		return "", err
+	}
+
+	return f.Name(), nil
+}
diff --git a/pkg/helm/spec.go b/pkg/helm/spec.go
new file mode 100644
index 00000000..23b79923
--- /dev/null
+++ b/pkg/helm/spec.go
@@ -0,0 +1,65 @@
+package helm
+
+import (
+	"github.com/Masterminds/semver"
+)
+
+const (
+	// Version of the current Chartfile implementation
+	Version = 1
+
+	// Filename of the Chartfile
+	Filename = "chartfile.yaml"
+
+	// DefaultDir is the directory used for storing Charts if not specified
+	// otherwise
+	DefaultDir = "charts"
+)
+
+// Chartfile is the schema used to declaratively define locally required Helm
+// Charts
+type Chartfile struct {
+	// Version of the Chartfile schema (for future use)
+	Version uint `json:"version"`
+
+	// Repositories to source from
+	Repositories []Repo `json:"repositories"`
+
+	// Requires lists Charts expected to be present in the charts folder
+	Requires Requirements `json:"requires"`
+
+	// Folder to use for storing Charts. Defaults to 'charts'
+	Directory string `json:"directory,omitempty"`
+}
+
+// Repo describes a single Helm repository
+type Repo struct {
+	Name     string `json:"name,omitempty"`
+	URL      string `json:"url,omitempty"`
+	CAFile   string `json:"caFile,omitempty"`
+	CertFile string `json:"certFile,omitempty"`
+	KeyFile  string `json:"keyFile,omitempty"`
+	Username string `json:"username,omitempty"`
+	Password string `json:"password,omitempty"`
+}
+
+// Requirement describes a single required Helm Chart.
+// Both, Chart and Version are required
+type Requirement struct {
+	Chart   string         `json:"chart"`
+	Version semver.Version `json:"version"`
+}
+
+// Requirements is an aggregate of all required Charts
+type Requirements []Requirement
+
+// Has reports whether 'req' is already part of the requirements
+func (r Requirements) Has(req Requirement) bool {
+	for _, x := range r {
+		if x == req {
+			return true
+		}
+	}
+
+	return false
+}
diff --git a/pkg/helm/template.go b/pkg/helm/template.go
new file mode 100644
index 00000000..10843a12
--- /dev/null
+++ b/pkg/helm/template.go
@@ -0,0 +1,179 @@
+package helm
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"io"
+	"io/ioutil"
+	"os"
+	"path/filepath"
+	"strings"
+
+	jsonnet "github.com/google/go-jsonnet"
+	"github.com/google/go-jsonnet/ast"
+	"github.com/grafana/tanka/pkg/kubernetes/manifest"
+	"github.com/pkg/errors"
+	yaml "gopkg.in/yaml.v3"
+)
+
+// TemplateOpts defines additional parameters that can be passed to the
+// Helm.Template action
+// TODO: Isolate between Helm.Template and NativeFunc
+type TemplateOpts struct {
+	// general
+	Values map[string]interface{} `json:"values"`
+	Flags  []string               `json:"flags"`
+
+	// native func related
+	CalledFrom string `json:"calledFrom"`
+}
+
+func confToArgs(conf TemplateOpts) ([]string, []string, error) {
+	var args []string
+	var tempFiles []string
+
+	// create file and append to args
+	if len(conf.Values) != 0 {
+		valuesYaml, err := yaml.Marshal(conf.Values)
+		if err != nil {
+			return nil, nil, err
+		}
+		tmpFile, err := ioutil.TempFile(os.TempDir(), "tanka-")
+		if err != nil {
+			return nil, nil, errors.Wrap(err, "cannot create temporary values.yaml")
+		}
+		tempFiles = append(tempFiles, tmpFile.Name())
+		if _, err = tmpFile.Write(valuesYaml); err != nil {
+			return nil, tempFiles, errors.Wrap(err, "failed to write to temporary values.yaml")
+		}
+		if err := tmpFile.Close(); err != nil {
+			return nil, tempFiles, err
+		}
+		args = append(args, fmt.Sprintf("--values=%s", tmpFile.Name()))
+	}
+
+	// append custom flags to args
+	args = append(args, conf.Flags...)
+
+	return args, tempFiles, nil
+}
+
+// Template expands a Helm Chart into a regular manifest.List using the `helm
+// template` command
+func (h ExecHelm) Template(name, chart string, opts TemplateOpts) (manifest.List, error) {
+	confArgs, tmpFiles, err := confToArgs(opts)
+	if err != nil {
+		return nil, err
+	}
+	for _, f := range tmpFiles {
+		defer os.Remove(f)
+	}
+
+	args := []string{name, chart}
+	args = append(args, confArgs...)
+
+	cmd := h.cmd("template", args...)
+	var buf bytes.Buffer
+	cmd.Stdout = &buf
+	cmd.Stderr = os.Stderr
+
+	if err := cmd.Run(); err != nil {
+		return nil, errors.Wrap(err, "Expanding Helm Chart")
+	}
+
+	var list manifest.List
+	d := yaml.NewDecoder(&buf)
+	for {
+		var m manifest.Manifest
+		if err := d.Decode(&m); err != nil {
+			if err == io.EOF {
+				break
+			}
+			return nil, errors.Wrap(err, "Parsing Helm output")
+		}
+
+		// Helm might return "empty" elements in the YAML stream that consist
+		// only of comments. Ignore these
+		if len(m) == 0 {
+			continue
+		}
+
+		list = append(list, m)
+	}
+
+	return list, nil
+}
+
+// NativeFunc returns a jsonnet native function that provides the same
+// functionality as `Helm.Template` of this package. Charts are required to be
+// present on the local filesystem, at a relative location to the file that
+// calls `helm.template()` / `std.native('helmTemplate')`. This guarantees
+// hermeticity
+func NativeFunc() *jsonnet.NativeFunction {
+	return &jsonnet.NativeFunction{
+		Name: "helmTemplate",
+		// Similar to `helm template [NAME] [CHART] [flags]` except 'conf' is a
+		// bit more elaborate and chart is a local path
+		Params: ast.Identifiers{"name", "chart", "conf"},
+		Func: func(data []interface{}) (interface{}, error) {
+			name, ok := data[0].(string)
+			if !ok {
+				return nil, fmt.Errorf("First argument 'name' must be of 'string' type, got '%T' instead", data[0])
+			}
+
+			chartpath, ok := data[1].(string)
+			if !ok {
+				return nil, fmt.Errorf("Second argument 'chart' must be of 'string' type, got '%T' instead", data[1])
+			}
+
+			// TODO: validate data[2] actually follows the struct scheme
+			c, err := json.Marshal(data[2])
+			if err != nil {
+				return "", err
+			}
+			var conf TemplateOpts
+			if err := json.Unmarshal(c, &conf); err != nil {
+				return "", err
+			}
+
+			// Charts are only allowed at relative paths. Use conf.CalledFrom to find the callers directory
+			if conf.CalledFrom == "" {
+				// TODO: rephrase and move lengthy explanation to website
+				return nil, fmt.Errorf("helmTemplate: 'conf.calledFrom' is unset or empty.\nHowever, Tanka must know where helmTemplate was called from, to resolve the Helm Chart relative to that.\n")
+			}
+			callerDir := filepath.Dir(conf.CalledFrom)
+
+			// resolve the Chart relative to the caller
+			chart := filepath.Join(callerDir, chartpath)
+			if _, err := os.Stat(chart); err != nil {
+				// TODO: add website link for explanation
+				return nil, fmt.Errorf("helmTemplate: Failed to find a Chart at '%s': %s", chart, err)
+			}
+
+			// TODO: Define Template on the Helm interface instead
+			var h ExecHelm
+			list, err := h.Template(name, chart, conf)
+			if err != nil {
+				return nil, err
+			}
+
+			out := make(map[string]interface{})
+			for _, m := range list {
+				name := fmt.Sprintf("%s_%s", m.Metadata().Name(), m.Kind())
+				name = normalizeName(name)
+
+				out[name] = map[string]interface{}(m)
+			}
+
+			return out, nil
+		},
+	}
+}
+
+func normalizeName(s string) string {
+	s = strings.ReplaceAll(s, "-", "_")
+	s = strings.ReplaceAll(s, ":", "_")
+	s = strings.ToLower(s)
+	return s
+}
diff --git a/pkg/helmraiser/helm.go b/pkg/helmraiser/helm.go
deleted file mode 100644
index 32569147..00000000
--- a/pkg/helmraiser/helm.go
+++ /dev/null
@@ -1,161 +0,0 @@
-package helmraiser
-
-import (
-	"bytes"
-	"encoding/json"
-	"fmt"
-	"io"
-	"io/ioutil"
-	"os"
-	"os/exec"
-	"strings"
-
-	jsonnet "github.com/google/go-jsonnet"
-	"github.com/google/go-jsonnet/ast"
-	"github.com/pkg/errors"
-	yaml "gopkg.in/yaml.v3"
-)
-
-type HelmConf struct {
-	Values map[string]interface{}
-	Flags  []string
-}
-
-func confToArgs(conf HelmConf) ([]string, []string, error) {
-	var args []string
-	var tempFiles []string
-
-	// create file and append to args
-	if len(conf.Values) != 0 {
-		valuesYaml, err := yaml.Marshal(conf.Values)
-		if err != nil {
-			return nil, nil, err
-		}
-		tmpFile, err := ioutil.TempFile(os.TempDir(), "tanka-")
-		if err != nil {
-			return nil, nil, errors.Wrap(err, "cannot create temporary values.yaml")
-		}
-		tempFiles = append(tempFiles, tmpFile.Name())
-		if _, err = tmpFile.Write(valuesYaml); err != nil {
-			return nil, tempFiles, errors.Wrap(err, "failed to write to temporary values.yaml")
-		}
-		if err := tmpFile.Close(); err != nil {
-			return nil, tempFiles, err
-		}
-		args = append(args, fmt.Sprintf("--values=%s", tmpFile.Name()))
-	}
-
-	// append custom flags to args
-	args = append(args, conf.Flags...)
-
-	if len(args) == 0 {
-		args = nil
-	}
-
-	return args, tempFiles, nil
-}
-
-func parseYamlToMap(yamlFile []byte) (map[string]interface{}, error) {
-	files := make(map[string]interface{})
-	d := yaml.NewDecoder(bytes.NewReader(yamlFile))
-	for {
-		var doc, jsonDoc interface{}
-		if err := d.Decode(&doc); err != nil {
-			if err == io.EOF {
-				break
-			}
-			return nil, errors.Wrap(err, "parsing manifests")
-		}
-
-		jsonRaw, err := json.Marshal(doc)
-		if err != nil {
-			return nil, errors.Wrap(err, "marshaling mainfests")
-		}
-
-		if err := json.Unmarshal(jsonRaw, &jsonDoc); err != nil {
-			return nil, errors.Wrap(err, "unmarshaling manifests")
-		}
-
-		// Unmarshal name and kind
-		kindName := struct {
-			Kind     string `json:"kind"`
-			Metadata struct {
-				Name string `json:"name"`
-			} `json:"metadata"`
-		}{}
-		if err := json.Unmarshal(jsonRaw, &kindName); err != nil {
-			return nil, errors.Wrap(err, "subtracting kind/name through unmarshaling")
-		}
-
-		// snake_case string
-		normalizeName := func(s string) string {
-			s = strings.ReplaceAll(s, "-", "_")
-			s = strings.ReplaceAll(s, ":", "_")
-			s = strings.ToLower(s)
-			return s
-		}
-
-		// create a map of resources for ease of use in jsonnet
-		name := normalizeName(fmt.Sprintf("%s_%s", kindName.Metadata.Name, kindName.Kind))
-		if jsonDoc != nil {
-			files[name] = jsonDoc
-		}
-	}
-	return files, nil
-}
-
-// helmTemplate wraps and runs `helm template`
-// returns the generated manifests in a map
-func HelmTemplate() *jsonnet.NativeFunction {
-	return &jsonnet.NativeFunction{
-		Name: "helmTemplate",
-		// Lines up with `helm template [NAME] [CHART] [flags]` except 'conf' is a bit more elaborate
-		Params: ast.Identifiers{"name", "chart", "conf"},
-		Func: func(data []interface{}) (interface{}, error) {
-			name, chart := data[0].(string), data[1].(string)
-
-			c, err := json.Marshal(data[2])
-			if err != nil {
-				return "", err
-			}
-			var conf HelmConf
-			if err := json.Unmarshal(c, &conf); err != nil {
-				return "", err
-			}
-
-			// the basic arguments to make this work
-			args := []string{
-				"template",
-				name,
-				chart,
-			}
-
-			confArgs, tempFiles, err := confToArgs(conf)
-			if err != nil {
-				return "", nil
-			}
-			for _, file := range tempFiles {
-				defer os.Remove(file)
-			}
-			if confArgs != nil {
-				args = append(args, confArgs...)
-			}
-
-			helmBinary := "helm"
-			if hc := os.Getenv("TANKA_HELM_PATH"); hc != "" {
-				helmBinary = hc
-			}
-
-			// convert the values map into a yaml file
-			cmd := exec.Command(helmBinary, args...)
-			buf := bytes.Buffer{}
-			cmd.Stdout = &buf
-			cmd.Stderr = os.Stderr
-			if err := cmd.Run(); err != nil {
-				return nil, fmt.Errorf("running 'helm %s': %w", strings.Join(args, " "), err)
-			}
-
-			return parseYamlToMap(buf.Bytes())
-		},
-	}
-}
diff --git a/pkg/jsonnet/eval.go b/pkg/jsonnet/eval.go
index b2ae10fc..ea996616 100644
--- a/pkg/jsonnet/eval.go
+++ b/pkg/jsonnet/eval.go
@@ -1,7 +1,6 @@
 package jsonnet
 
 import (
-	"io/ioutil"
 	"path/filepath"
 
 	jsonnet "github.com/google/go-jsonnet"
@@ -15,51 +14,63 @@ import (
 // See jsonnet.With* for this.
 type Modifier func(vm *jsonnet.VM) error
 
-// EvaluateFile opens the file, reads it into memory and evaluates it afterwards (`Evaluate()`)
-func EvaluateFile(jsonnetFile string, mods ...Modifier) (string, error) {
-	bytes, err := ioutil.ReadFile(jsonnetFile)
-	if err != nil {
-		return "", err
-	}
+// InjectedCode holds data that is "late-bound" into the VM
+type InjectedCode map[string]string
 
-	jpath, _, _, err := jpath.Resolve(filepath.Dir(jsonnetFile))
-	if err != nil {
-		return "", errors.Wrap(err, "resolving jpath")
+// Set allows to set values on an InjectedCode, even when it is nil
+func (i *InjectedCode) Set(key, value string) {
+	if *i == nil {
+		*i = make(InjectedCode)
 	}
-	return Evaluate(string(bytes), jpath, mods...)
+
+	(*i)[key] = value
 }
 
-// Evaluate renders the given jsonnet into a string
-func Evaluate(sonnet string, jpath []string, mods ...Modifier) (string, error) {
+// Opts are additional properties for the Jsonnet VM
+type Opts struct {
+	ExtCode     InjectedCode
+	TLACode     InjectedCode
+	ImportPaths []string
+}
+
+// MakeVM returns a Jsonnet VM with some extensions of Tanka, including:
+// - extended importer
+// - extCode and tlaCode applied
+// - native functions registered
+func MakeVM(opts Opts) *jsonnet.VM {
 	vm := jsonnet.MakeVM()
-	vm.Importer(NewExtendedImporter(jpath))
+	vm.Importer(NewExtendedImporter(opts.ImportPaths))
 
-	for _, mod := range mods {
-		if err := mod(vm); err != nil {
-			return "", err
-		}
+	for k, v := range opts.ExtCode {
+		vm.ExtCode(k, v)
+	}
+	for k, v := range opts.TLACode {
+		vm.TLACode(k, v)
 	}
 
 	for _, nf := range native.Funcs() {
 		vm.NativeFunction(nf)
 	}
 
-	return vm.EvaluateSnippet("main.jsonnet", sonnet)
+	return vm
 }
 
-// WithExtCode allows to make the supplied snippet available to Jsonnet as an
-// ext var
-func WithExtCode(key, code string) Modifier {
-	return func(vm *jsonnet.VM) error {
-		vm.ExtCode(key, code)
-		return nil
+// EvaluateFile evaluates the Jsonnet code in the given file and returns the
+// result in JSON form. It disregards opts.ImportPaths in favor of automatically
+// resolving these according to the specified file.
+func EvaluateFile(jsonnetFile string, opts Opts) (string, error) {
+	jpath, _, _, err := jpath.Resolve(filepath.Dir(jsonnetFile))
+	if err != nil {
+		return "", errors.Wrap(err, "resolving import paths")
 	}
+	opts.ImportPaths = jpath
+
+	vm := MakeVM(opts)
+	return vm.EvaluateFile(jsonnetFile)
 }
 
-// WithTLA allows to set the given code as a top level argument
-func WithTLA(key, code string) Modifier {
-	return func(vm *jsonnet.VM) error {
-		vm.TLACode(key, code)
-		return nil
-	}
+// Evaluate renders the given jsonnet into a string
+func Evaluate(filename, data string, opts Opts) (string, error) {
+	vm := jsonnet.MakeVM()
+	return vm.EvaluateAnonymousSnippet(filename, data)
 }
diff --git a/pkg/jsonnet/native/funcs.go b/pkg/jsonnet/native/funcs.go
index a22bffce..e5b250d8 100644
--- a/pkg/jsonnet/native/funcs.go
+++ b/pkg/jsonnet/native/funcs.go
@@ -9,7 +9,7 @@ import (
 
 	jsonnet "github.com/google/go-jsonnet"
 	"github.com/google/go-jsonnet/ast"
-	"github.com/grafana/tanka/pkg/helmraiser"
+	"github.com/grafana/tanka/pkg/helm"
 	"github.com/pkg/errors"
 	yaml "gopkg.in/yaml.v3"
 )
@@ -31,7 +31,7 @@ func Funcs() []*jsonnet.NativeFunction {
 		regexMatch(),
 		regexSubst(),
 
-		helmraiser.HelmTemplate(),
+		helm.NativeFunc(),
 	}
 }
 
diff --git a/pkg/kubernetes/diff.go b/pkg/kubernetes/diff.go
index 0ff73196..ab0b84e0 100644
--- a/pkg/kubernetes/diff.go
+++ b/pkg/kubernetes/diff.go
@@ -17,7 +17,7 @@ func (k *Kubernetes) Diff(state manifest.List, opts DiffOpts) (*string, error) {
 	if k.ctl.Info().ClientVersion.Equal(semver.MustParse("1.18.0")) {
 		return nil, fmt.Errorf(`You seem to be using kubectl 1.18.0, which contains an unfixed issue
 that makes 'kubectl diff' modify resources in your cluster.
-Please downgrade kubectl until https://github.com/kubernetes/kubernetes/issues/89762 is fixed.`)
+Please upgrade kubectl to at least version 1.18.1.`)
 	}
 
 	// required for separating
diff --git a/pkg/process/process.go b/pkg/process/process.go
index 3af81ac5..c14ae8b1 100644
--- a/pkg/process/process.go
+++ b/pkg/process/process.go
@@ -41,6 +41,9 @@ func Process(raw interface{}, cfg v1alpha1.Config, exprs Matchers) (manifest.Lis
 	// tanka.dev/** labels
 	out = Label(out, cfg)
 
+	// arbitrary labels and annotations from spec
+	out = ResourceDefaults(out, cfg)
+
 	// Perhaps filter for kind/name expressions
 	if len(exprs) > 0 {
 		out = Filter(out, exprs)
@@ -65,6 +68,27 @@ func Label(list manifest.List, cfg v1alpha1.Config) manifest.List {
 	return list
 }
 
+func ResourceDefaults(list manifest.List, cfg v1alpha1.Config) manifest.List {
+	for i, m := range list {
+		for k, v := range cfg.Spec.ResourceDefaults.Annotations {
+			annotations := m.Metadata().Annotations()
+			if _, ok := annotations[k]; !ok {
+				annotations[k] = v
+			}
+		}
+
+		for k, v := range cfg.Spec.ResourceDefaults.Labels {
+			labels := m.Metadata().Labels()
+			if _, ok := labels[k]; !ok {
+				labels[k] = v
+			}
+		}
+
+		list[i] = m
+	}
+	return list
+}
+
 // Unwrap returns all Kubernetes objects in the manifest. If m is not a List
 // type, a one item List is returned
 func Unwrap(manifests map[string]manifest.Manifest) error {
diff --git a/pkg/spec/v1alpha1/config.go b/pkg/spec/v1alpha1/config.go
index 8a623d63..97f9e1ff 100644
--- a/pkg/spec/v1alpha1/config.go
+++ b/pkg/spec/v1alpha1/config.go
@@ -50,8 +50,15 @@ func (m Metadata) NameLabel() string {
 
 // Spec defines Kubernetes properties
 type Spec struct {
-	APIServer    string `json:"apiServer"`
-	Namespace    string `json:"namespace"`
-	DiffStrategy string `json:"diffStrategy,omitempty"`
-	InjectLabels bool   `json:"injectLabels,omitempty"`
+	APIServer        string           `json:"apiServer"`
+	Namespace        string           `json:"namespace"`
+	DiffStrategy     string           `json:"diffStrategy,omitempty"`
+	InjectLabels     bool             `json:"injectLabels,omitempty"`
+	ResourceDefaults ResourceDefaults `json:"resourceDefaults,omitempty"`
+}
+
+// ResourceDefaults will be inserted in any manifests that tanka processes.
+type ResourceDefaults struct {
+	Annotations map[string]string `json:"annotations,omitempty"`
+	Labels      map[string]string `json:"labels,omitempty"`
 }
diff --git a/pkg/tanka/parse.go b/pkg/tanka/parse.go
index 9340b84a..cb6b4915 100644
--- a/pkg/tanka/parse.go
+++ b/pkg/tanka/parse.go
@@ -56,7 +56,7 @@ func (p *loaded) connect() (*kubernetes.Kubernetes, error) {
 
 // load runs all processing stages described at the Processed type
 func load(dir string, opts *options) (*loaded, error) {
-	raw, env, err := eval(dir, opts.extCode)
+	raw, env, err := eval(dir, opts.jsonnet)
 	if err != nil {
 		return nil, err
 	}
@@ -74,7 +74,7 @@ func load(dir string, opts *options) (*loaded, error) {
 
 // eval runs all processing stages describe at the Processed type apart from
 // post-processing, thus returning the raw Jsonnet result.
-func eval(dir string, extCode map[string]string) (raw interface{}, env *v1alpha1.Config, err error) {
+func eval(dir string, opts jsonnet.Opts) (raw interface{}, env *v1alpha1.Config, err error) {
 	_, baseDir, rootDir, err := jpath.Resolve(dir)
 	if err != nil {
 		return nil, nil, errors.Wrap(err, "resolving jpath")
@@ -85,7 +85,7 @@ func eval(dir string, extCode map[string]string) (raw interface{}, env *v1alpha1
 		return nil, nil, err
 	}
 
-	raw, err = evalJsonnet(baseDir, env, extCode)
+	raw, err = evalJsonnet(baseDir, env, opts)
 	if err != nil {
 		return nil, nil, errors.Wrap(err, "evaluating jsonnet")
 	}
@@ -119,27 +119,22 @@ func parseSpec(baseDir, rootDir string) (*v1alpha1.Config, error) {
 
 // evalJsonnet evaluates the jsonnet environment at the given directory starting with
 // `main.jsonnet`
-func evalJsonnet(baseDir string, env *v1alpha1.Config, extCode map[string]string) (interface{}, error) {
+func evalJsonnet(baseDir string, env *v1alpha1.Config, opts jsonnet.Opts) (interface{}, error) {
+	// make env spec accessible from Jsonnet
 	jsonEnv, err := json.Marshal(env)
 	if err != nil {
 		return nil, errors.Wrap(err, "marshalling environment config")
 	}
+	opts.ExtCode.Set(spec.APIGroup+"/environment", string(jsonEnv))
 
-	ext := []jsonnet.Modifier{
-		jsonnet.WithExtCode(spec.APIGroup+"/environment", string(jsonEnv)),
-	}
-	for k, v := range extCode {
-		ext = append(ext, jsonnet.WithExtCode(k, v))
-	}
-
-	raw, err := jsonnet.EvaluateFile(
-		filepath.Join(baseDir, "main.jsonnet"),
-		ext...,
-	)
+	// evaluate Jsonnet
+	mainFile := filepath.Join(baseDir, "main.jsonnet")
+	raw, err := jsonnet.EvaluateFile(mainFile, opts)
 	if err != nil {
 		return nil, err
 	}
 
+	// parse result
 	var data interface{}
 	if err := json.Unmarshal([]byte(raw), &data); err != nil {
 		return nil, err
diff --git a/pkg/tanka/tanka.go b/pkg/tanka/tanka.go
index bc58e93e..16dd87b3 100644
--- a/pkg/tanka/tanka.go
+++ b/pkg/tanka/tanka.go
@@ -5,6 +5,7 @@
 package tanka
 
 import (
+	"github.com/grafana/tanka/pkg/jsonnet"
 	"github.com/grafana/tanka/pkg/kubernetes"
 	"github.com/grafana/tanka/pkg/process"
 )
@@ -20,8 +21,7 @@ func parseModifiers(mods []Modifier) *options {
 
 type options struct {
 	// `std.extVar`
-	extCode map[string]string
-	tlaCode map[string]string
+	jsonnet jsonnet.Opts
 
 	// target regular expressions to limit the working set
 	targets process.Matchers
@@ -41,13 +41,13 @@ type Modifier func(*options)
 // WithExtCode allows to pass external variables (jsonnet code) to the VM
 func WithExtCode(code map[string]string) Modifier {
 	return func(opts *options) {
-		opts.extCode = code
+		opts.jsonnet.ExtCode = code
 	}
 }
 
 func WithTLACode(code map[string]string) Modifier {
 	return func(opts *options) {
-		opts.extCode = code
+		opts.jsonnet.TLACode = code
 	}
 }
 
diff --git a/pkg/tanka/workflow.go b/pkg/tanka/workflow.go
index b0e95ce2..61cd6897 100644
--- a/pkg/tanka/workflow.go
+++ b/pkg/tanka/workflow.go
@@ -147,7 +147,7 @@ func Show(baseDir string, mods ...Modifier) (manifest.List, error) {
 func Eval(dir string, mods ...Modifier) (raw interface{}, err error) {
 	opts := parseModifiers(mods)
 
-	r, _, err := eval(dir, opts.extCode)
+	r, _, err := eval(dir, opts.jsonnet)
 	if err != nil {
 		return nil, err
 	}


Test patch:
diff --git a/pkg/helm/template_test.go b/pkg/helm/template_test.go
new file mode 100644
index 00000000..056e69db
--- /dev/null
+++ b/pkg/helm/template_test.go
@@ -0,0 +1,96 @@
+package helm
+
+import (
+	"fmt"
+	"os"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestConfToArgs_noconf(t *testing.T) {
+	conf := TemplateOpts{}
+	args, tempFiles, err := confToArgs(conf)
+	for _, file := range tempFiles {
+		defer os.Remove(file)
+	}
+
+	assert.Equal(t, []string(nil), args)
+	assert.Nil(t, err)
+}
+
+func TestConfToArgs_emptyconf(t *testing.T) {
+	conf := TemplateOpts{
+		Values: map[string]interface{}{},
+		Flags:  []string{},
+	}
+
+	args, tempFiles, err := confToArgs(conf)
+	for _, file := range tempFiles {
+		defer os.Remove(file)
+	}
+
+	assert.Equal(t, []string(nil), args)
+	assert.Nil(t, err)
+}
+
+func TestConfToArgs_flags(t *testing.T) {
+	conf := TemplateOpts{
+		Flags: []string{
+			"--version=v0.1",
+			"--random=arg",
+		},
+	}
+
+	args, tempFiles, err := confToArgs(conf)
+	for _, file := range tempFiles {
+		defer os.Remove(file)
+	}
+
+	assert.Equal(t, []string{
+		"--version=v0.1",
+		"--random=arg",
+	}, args)
+	assert.Nil(t, err)
+}
+
+func TestConfToArgs_values(t *testing.T) {
+	conf := TemplateOpts{
+		Values: map[string]interface{}{
+			"hasValues": "yes",
+		},
+	}
+
+	args, tempFiles, err := confToArgs(conf)
+	for _, file := range tempFiles {
+		defer os.Remove(file)
+	}
+
+	assert.FileExists(t, tempFiles[0])
+	assert.Equal(t, []string{fmt.Sprintf("--values=%s", tempFiles[0])}, args)
+	assert.Nil(t, err)
+}
+
+func TestConfToArgs_flagsvalues(t *testing.T) {
+	conf := TemplateOpts{
+		Values: map[string]interface{}{
+			"hasValues": "yes",
+		},
+		Flags: []string{
+			"--version=v0.1",
+			"--random=arg",
+		},
+	}
+
+	args, tempFiles, err := confToArgs(conf)
+	for _, file := range tempFiles {
+		defer os.Remove(file)
+	}
+
+	assert.Equal(t, []string{
+		fmt.Sprintf("--values=%s", tempFiles[0]),
+		"--version=v0.1",
+		"--random=arg",
+	}, args)
+	assert.Nil(t, err)
+}
diff --git a/pkg/helmraiser/helm_test.go b/pkg/helmraiser/helm_test.go
deleted file mode 100644
index 3b17cff8..00000000
--- a/pkg/helmraiser/helm_test.go
+++ /dev/null
@@ -1,191 +0,0 @@
-package helmraiser
-
-import (
-	"fmt"
-	"os"
-	"testing"
-
-	"github.com/stretchr/testify/assert"
-)
-
-func TestConfToArgs_noconf(t *testing.T) {
-	conf := HelmConf{}
-	args, tempFiles, err := confToArgs(conf)
-	for _, file := range tempFiles {
-		defer os.Remove(file)
-	}
-
-	assert.Equal(t, []string(nil), args)
-	assert.Nil(t, err)
-}
-
-func TestConfToArgs_emptyconf(t *testing.T) {
-	conf := HelmConf{
-		Values: map[string]interface{}{},
-		Flags:  []string{},
-	}
-
-	args, tempFiles, err := confToArgs(conf)
-	for _, file := range tempFiles {
-		defer os.Remove(file)
-	}
-
-	assert.Equal(t, []string(nil), args)
-	assert.Nil(t, err)
-}
-
-func TestConfToArgs_flags(t *testing.T) {
-	conf := HelmConf{
-		Flags: []string{
-			"--version=v0.1",
-			"--random=arg",
-		},
-	}
-
-	args, tempFiles, err := confToArgs(conf)
-	for _, file := range tempFiles {
-		defer os.Remove(file)
-	}
-
-	assert.Equal(t, []string{
-		"--version=v0.1",
-		"--random=arg",
-	}, args)
-	assert.Nil(t, err)
-}
-
-func TestConfToArgs_values(t *testing.T) {
-	conf := HelmConf{
-		Values: map[string]interface{}{
-			"hasValues": "yes",
-		},
-	}
-
-	args, tempFiles, err := confToArgs(conf)
-	for _, file := range tempFiles {
-		defer os.Remove(file)
-	}
-
-	assert.FileExists(t, tempFiles[0])
-	assert.Equal(t, []string{fmt.Sprintf("--values=%s", tempFiles[0])}, args)
-	assert.Nil(t, err)
-}
-
-func TestConfToArgs_flagsvalues(t *testing.T) {
-	conf := HelmConf{
-		Values: map[string]interface{}{
-			"hasValues": "yes",
-		},
-		Flags: []string{
-			"--version=v0.1",
-			"--random=arg",
-		},
-	}
-
-	args, tempFiles, err := confToArgs(conf)
-	for _, file := range tempFiles {
-		defer os.Remove(file)
-	}
-
-	assert.Equal(t, []string{
-		fmt.Sprintf("--values=%s", tempFiles[0]),
-		"--version=v0.1",
-		"--random=arg",
-	}, args)
-	assert.Nil(t, err)
-}
-
-func TestParseYamlToMap_basic(t *testing.T) {
-	yamlFile := []byte(`---
-kind: testKind
-metadata:
-  name: testName`)
-	actual, err := parseYamlToMap(yamlFile)
-
-	expected := map[string]interface{}{
-		"testname_testkind": map[string]interface{}{
-			"kind": "testKind",
-			"metadata": map[string]interface{}{
-				"name": "testName",
-			},
-		},
-	}
-	assert.Equal(t, expected, actual)
-	assert.Nil(t, err)
-}
-
-func TestParseYamlToMap_dash(t *testing.T) {
-	yamlFile := []byte(`---
-kind: testKind
-metadata:
-  name: test-Name`)
-	actual, err := parseYamlToMap(yamlFile)
-
-	expected := map[string]interface{}{
-		"test_name_testkind": map[string]interface{}{
-			"kind": "testKind",
-			"metadata": map[string]interface{}{
-				"name": "test-Name",
-			},
-		},
-	}
-	assert.Equal(t, expected, actual)
-	assert.Nil(t, err)
-}
-
-func TestParseYamlToMap_colon(t *testing.T) {
-	yamlFile := []byte(`---
-kind: testKind
-metadata:
-  name: test:Name`)
-	actual, err := parseYamlToMap(yamlFile)
-
-	expected := map[string]interface{}{
-		"test_name_testkind": map[string]interface{}{
-			"kind": "testKind",
-			"metadata": map[string]interface{}{
-				"name": "test:Name",
-			},
-		},
-	}
-	assert.Equal(t, expected, actual)
-	assert.Nil(t, err)
-}
-
-func TestParseYamlToMap_empty(t *testing.T) {
-	yamlFile := []byte(`---`)
-	actual, err := parseYamlToMap(yamlFile)
-
-	expected := map[string]interface{}{}
-	assert.Equal(t, expected, actual)
-	assert.Nil(t, err)
-}
-
-func TestParseYamlToMap_multiple_files(t *testing.T) {
-	yamlFile := []byte(`---
-kind: testKind
-metadata:
-  name: testName
----
-kind: testKind
-metadata:
-  name: testName2`)
-	actual, err := parseYamlToMap(yamlFile)
-
-	expected := map[string]interface{}{
-		"testname_testkind": map[string]interface{}{
-			"kind": "testKind",
-			"metadata": map[string]interface{}{
-				"name": "testName",
-			},
-		},
-		"testname2_testkind": map[string]interface{}{
-			"kind": "testKind",
-			"metadata": map[string]interface{}{
-				"name": "testName2",
-			},
-		},
-	}
-	assert.Equal(t, expected, actual)
-	assert.Nil(t, err)
-}
diff --git a/pkg/jsonnet/jpath/jpath_test.go b/pkg/jsonnet/jpath/jpath_test.go
index 490e1ac8..bb872f70 100644
--- a/pkg/jsonnet/jpath/jpath_test.go
+++ b/pkg/jsonnet/jpath/jpath_test.go
@@ -12,7 +12,7 @@ import (
 )
 
 func TestResolvePrecedence(t *testing.T) {
-	s, err := jsonnet.EvaluateFile(filepath.Join("./testdata/precedence/environments/default/main.jsonnet"))
+	s, err := jsonnet.EvaluateFile(filepath.Join("./testdata/precedence/environments/default/main.jsonnet"), jsonnet.Opts{})
 	require.NoError(t, err)
 
 	want := map[string]string{
diff --git a/pkg/kubernetes/data_test.go b/pkg/kubernetes/data_test.go
deleted file mode 100644
index 9cd65f3d..00000000
--- a/pkg/kubernetes/data_test.go
+++ /dev/null
@@ -1,64 +0,0 @@
-package kubernetes
-
-import (
-	"encoding/json"
-	"fmt"
-	"io/ioutil"
-	"strings"
-
-	"github.com/grafana/tanka/pkg/jsonnet"
-	"github.com/grafana/tanka/pkg/kubernetes/manifest"
-)
-
-// testData holds data for tests
-type testData struct {
-	Deep interface{}                  `json:"deep"`
-	Flat map[string]manifest.Manifest `json:"flat"`
-}
-
-func loadFixture(name string) testData {
-	raw, err := ioutil.ReadFile("./testdata/td" + strings.Title(name) + ".jsonnet")
-	if err != nil {
-		panic(fmt.Sprint("loading fixture:", err))
-	}
-
-	data, err := jsonnet.Evaluate(string(raw), []string{"./testdata"})
-	if err != nil {
-		panic(fmt.Sprint("loading fixture:", err))
-	}
-
-	var d testData
-	if err := json.Unmarshal([]byte(data), &d); err != nil {
-		panic(fmt.Sprint("loading fixture:", err))
-	}
-
-	return d
-}
-
-// testDataRegular is a regular output of jsonnet without special things, but it
-// is nested.
-func testDataRegular() testData {
-	return loadFixture("regular")
-}
-
-// testDataFlat is a flat manifest that does not need reconciliation
-func testDataFlat() testData {
-	return loadFixture("flat")
-}
-
-// testDataPrimitive is an invalid manifest, because it ends with a primitive
-// without including required fields
-func testDataPrimitive() testData {
-	return loadFixture("invalidPrimitive")
-}
-
-// testDataDeep is super deeply nested on multiple levels
-func testDataDeep() testData {
-	return loadFixture("deep")
-}
-
-// testDataArray is an array of (deeply nested) dicts that should be fully
-// flattened
-func testDataArray() testData {
-	return loadFixture("array")
-}
diff --git a/pkg/process/data_test.go b/pkg/process/data_test.go
index c38c9ec5..5fd63bf5 100644
--- a/pkg/process/data_test.go
+++ b/pkg/process/data_test.go
@@ -3,7 +3,6 @@ package process
 import (
 	"encoding/json"
 	"fmt"
-	"io/ioutil"
 	"strings"
 
 	"github.com/grafana/tanka/pkg/jsonnet"
@@ -17,12 +16,13 @@ type testData struct {
 }
 
 func loadFixture(name string) testData {
-	raw, err := ioutil.ReadFile("./testdata/td" + strings.Title(name) + ".jsonnet")
-	if err != nil {
-		panic(fmt.Sprint("loading fixture:", err))
-	}
+	filename := "./testdata/td" + strings.Title(name) + ".jsonnet"
+
+	vm := jsonnet.MakeVM(jsonnet.Opts{
+		ImportPaths: []string{"./testdata"},
+	})
 
-	data, err := jsonnet.Evaluate(string(raw), []string{"./testdata"})
+	data, err := vm.EvaluateFile(filename)
 	if err != nil {
 		panic(fmt.Sprint("loading fixture:", err))
 	}
diff --git a/pkg/process/resourceDefaults_test.go b/pkg/process/resourceDefaults_test.go
new file mode 100644
index 00000000..bed46b58
--- /dev/null
+++ b/pkg/process/resourceDefaults_test.go
@@ -0,0 +1,92 @@
+package process
+
+import (
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+
+	"github.com/grafana/tanka/pkg/kubernetes/manifest"
+	"github.com/grafana/tanka/pkg/spec/v1alpha1"
+)
+
+func TestResourceDefaults(t *testing.T) {
+	cases := []struct {
+		name                string
+		beforeAnnotations   map[string]string
+		beforeLabels        map[string]string
+		specAnnotations     map[string]string
+		specLabels          map[string]string
+		expectedAnnotations map[string]string
+		expectedLabels      map[string]string
+	}{
+		// resource without a namespace: set it
+		{
+			name: "No change",
+		},
+		{
+			name:                "Add annotation",
+			specAnnotations:     map[string]string{"a": "b"},
+			expectedAnnotations: map[string]string{"a": "b"},
+		},
+		{
+			name:           "Add Label",
+			specLabels:     map[string]string{"a": "b"},
+			expectedLabels: map[string]string{"a": "b"},
+		},
+		{
+			name:                "Add leaves existing",
+			beforeAnnotations:   map[string]string{"1": "2"},
+			beforeLabels:        map[string]string{"1": "2"},
+			specAnnotations:     map[string]string{"a": "b"},
+			specLabels:          map[string]string{"a": "b"},
+			expectedAnnotations: map[string]string{"a": "b", "1": "2"},
+			expectedLabels:      map[string]string{"a": "b", "1": "2"},
+		},
+		{
+			name:                "Existing overrides spec",
+			beforeAnnotations:   map[string]string{"a": "c", "1": "2"},
+			beforeLabels:        map[string]string{"a": "c", "1": "2"},
+			specAnnotations:     map[string]string{"a": "b"},
+			specLabels:          map[string]string{"a": "b"},
+			expectedAnnotations: map[string]string{"a": "c", "1": "2"},
+			expectedLabels:      map[string]string{"a": "c", "1": "2"},
+		},
+	}
+
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			cfg := v1alpha1.Config{
+				Spec: v1alpha1.Spec{
+					ResourceDefaults: v1alpha1.ResourceDefaults{
+						Annotations: c.specAnnotations,
+						Labels:      c.specLabels,
+					},
+				},
+			}
+
+			before := manifest.Manifest{
+				"kind": "Deployment",
+			}
+			for k, v := range c.beforeAnnotations {
+				before.Metadata().Annotations()[k] = v
+			}
+			for k, v := range c.beforeLabels {
+				before.Metadata().Labels()[k] = v
+			}
+
+			expected := manifest.Metadata{}
+			for k, v := range c.expectedAnnotations {
+				expected.Annotations()[k] = v
+			}
+			for k, v := range c.expectedLabels {
+				expected.Labels()[k] = v
+			}
+
+			result := ResourceDefaults(manifest.List{before}, cfg)
+			actual := result[0]
+			if diff := cmp.Diff(expected, actual.Metadata()); diff != "" {
+				t.Error(diff)
+			}
+		})
+	}
+}
diff --git a/pkg/tanka/parse_test.go b/pkg/tanka/parse_test.go
index fe6014d1..2715e0e0 100644
--- a/pkg/tanka/parse_test.go
+++ b/pkg/tanka/parse_test.go
@@ -1,9 +1,11 @@
 package tanka
 
 import (
+	"testing"
+
+	"github.com/grafana/tanka/pkg/jsonnet"
 	"github.com/grafana/tanka/pkg/spec/v1alpha1"
 	"github.com/stretchr/testify/assert"
-	"testing"
 )
 
 func TestEvalJsonnet(t *testing.T) {
@@ -33,8 +35,7 @@ func TestEvalJsonnet(t *testing.T) {
 	}
 
 	for _, test := range cases {
-		m := make(map[string]string)
-		data, e := evalJsonnet(test.baseDir, v1alpha1.New(), m)
+		data, e := evalJsonnet(test.baseDir, v1alpha1.New(), jsonnet.Opts{})
 		assert.NoError(t, e)
 		assert.Equal(t, test.expected, data)
 	}