Repository: grafana/tanka
Pull request #412 (2020-10-25 14:40:35)

feat(cli): run commands with tk- prefix on PATH
In analogy to the git- prefixed commands, this finds all executable on
PATH that look like `tk-<command>` and run them as `tk <command>`.

Implementation example:
```
$ cat /usr/bin/tk-jq
  #!/usr/bin/env sh
  ${EXECUTABLE} eval ${@:2} | jq $1

$ echo "{ tanka: 'cool' }" > main.jsonnet
$ tk jq .tanka main.jsonnet
  "cool"

```

This concept intends to foster extensions that work well with Tanka, for example encrypt/decrypt secrets during runtime, alternative diff tools, integration with third party API clients, ...

malcolmholmes: So the idea is that the sub-command receives already evaluated json, and doesn’t need jsonnet support. That is useful.

How about when we want to extend e.g. the diff or apply commands themselves?
Duologic: > So the idea is that the sub-command receives already evaluated json, and doesn’t need jsonnet support. That is useful.

Nope, it doesn't pass anything, it just executes your script, you call (or not) `$EXECUTABLE` which refers to the `tk` that calls the script.
 
> How about when we want to extend e.g. the diff or apply commands themselves?

Example: https://github.com/grafana/deployment_tools/blob/master/scripts/ksonnet/tk-apply-multi (if we remove the changedir command, then this can be dropped in your PATH and be used as `tk apply-multi`)

There is no tight integration, tanka simply executes it.




Patch:
diff --git a/.gitignore b/.gitignore
index 1521c8b7..18e9056f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,1 +1,3 @@
 dist
+/tk
+/cmd/tk/tk
diff --git a/ADOPTERS.md b/ADOPTERS.md
new file mode 100644
index 00000000..ed715632
--- /dev/null
+++ b/ADOPTERS.md
@@ -0,0 +1,3 @@
+* Everquote
+* GitLab
+* Grafana Labs
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e05f7c0c..30499e40 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,91 @@
 # Changelog
 
+## 0.13 (2020-12-11)
+
+#### :building_construction: Inline environments
+
+One of the most debated features of the past months has landed: defining Tanka
+Environments inline. It is now possible to leverage all powerful Jsonnet
+concepts to modify your Tanka Environments. See
+https://tanka.dev/inline-environments for more details.
+
+#### :wheel_of_dharma: Kustomize support
+
+In 0.12 we brought in [Helm support](#wheel_of_dharma-helm-support), similarly
+Tanka now also comes with Kustomize support.
+
+* We have refactored `helm-util` into
+  [`tanka-util`](https://github.com/grafana/jsonnet-libs/blob/master/tanka-util)
+  to support both `helm.template()` and `kustomize.build()` use cases from a
+  common base.
+* Jsonnet-native overwriting of Kustomizations
+
+Have a look at https://tanka.dev/kustomize on how to use all this goodness.
+Also https://tanka.dev/helm has been updated to match the library changes.
+
+> This feature is currently experimental. We believe it is feature complete, but
+> further usage in the field may lead to adjustments
+
+#### :sparkles: Export `JSONNET_PATH`
+
+Tanka :heart: Jsonnet, and so do you. With this release, you can now access the
+`JSONNET_PATH` that Tanka uses to find all libraries. Try something this in your
+environment:
+
+```console
+$ JSONNET_PATH=$(tk tool jpath environments/prometheus) jsonnet-lint environments/prometheus/main.jsonnet
+```
+
+#### :speech_balloon: Github Discussions
+
+The Tanka project is trying out GitHub Discussions as the primary support channel:
+
+- :mag: It is searchable, so information never gets lost in Slack again
+- :busts_in_silhouette: No longer sign-up for two platforms
+- :mega: Reach both, the team and other community members
+
+Head over to https://github.com/grafana/tanka/discussions and start the discussion!
+
+### Features
+
+- **jsonnet**: Allow alternative entrypoints
+  **([#389](https://github.com/grafana/tanka/pull/389))**
+- **jsonnet** :sparkles:: Support for inline environment
+  **([#403](https://github.com/grafana/tanka/pull/403))**
+- **jsonnet, cli** :sparkles:: `tk tool jpath` can be used to export `JSONNET_PATH`
+  **([#427](https://github.com/grafana/tanka/pull/427))**
+- **jsonnet, docker**: Add `openssh-client` to Docker image
+  **([#429](https://github.com/grafana/tanka/pull/429))** (**@xvzf**)
+
+* **k8s** :sparkles:: Render Kustomize into Jsonnet
+  **([#422](https://github.com/grafana/tanka/pull/422))**
+* **k8s**: Add `metadata.Namespace` directive, always the path relative to the project root
+  **([#435](https://github.com/grafana/tanka/pull/435))**
+
+- **helm**: Chart tool: Check chart versions and update accordingly
+  **([#420](https://github.com/grafana/tanka/pull/420))** (**@craigfurman**)
+- **helm,docker**: Add `helm` client to Docker image
+  **([#430](https://github.com/grafana/tanka/pull/430))** (**@ducharmemp**)
+
+* **api**: Introduce the concept of Evaluators
+  **([#431](https://github.com/grafana/tanka/pull/431))**
+
+
+### Bug Fixes
+
+- **helm**: Chart tool: Detect already pulled charts
+  **([#402](https://github.com/grafana/tanka/pull/402))** (**justinwalz**)
+- **helm**: Chart tool: Use new URL for stable helm repo
+  **([#425](https://github.com/grafana/tanka/pull/425))**
+
+* **cli**: Environment path as name relative to the project root
+  **([#404](https://github.com/grafana/tanka/pull/404))** (**mwasilew2**)
+* **cli**: Normalize `tk fmt` paths on Windows
+  **([#411](https://github.com/grafana/tanka/pull/411))** (**nlowe**)
+* **cli**: Confirmation prompts on Windows
+  **([#413](https://github.com/grafana/tanka/pull/413))** (**nlowe**)
+
+
 ## 0.12 (2020-10-05)
 
 Like good wine, some things need time. After 3 months of intense development we
@@ -18,7 +104,7 @@ Just by upgrading to 0.12, you have access to every single Helm chart on the
 planet, right inside of Tanka! Read more on https://tanka.dev/helm
 
 > This feature is currently experimental. We believe it is feature complete, but
-> further usage in the filed may lead to adjustments
+> further usage in the field may lead to adjustments
 
 #### :house: Top Level Arguments
 
@@ -341,7 +427,7 @@ more partly failed applies!
   **([#235](https://github.com/grafana/tanka/pull/235))**
 
 ## 0.8.0 (2020-02-13)
-
+ (**@xvzf**)
 The next big one is here! Feature packed with environment overriding and `tk export`. Furthermore lots of bugs were fixed, so using Tanka should be much
 smoother now!
 
diff --git a/Dockerfile b/Dockerfile
index 7f93157c..77b32852 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,23 +1,36 @@
 # download kubectl
-FROM alpine as kubectl
+FROM golang:alpine as kubectl
 RUN apk add --no-cache curl
 RUN export VERSION=$(curl -s https://storage.googleapis.com/kubernetes-release/release/stable.txt) &&\
-    curl -o /usr/local/bin/kubectl -L https://storage.googleapis.com/kubernetes-release/release/${VERSION}/bin/linux/amd64/kubectl &&\
+    export OS=$(go env GOOS) && \
+    export ARCH=$(go env GOARCH) &&\
+    curl -o /usr/local/bin/kubectl -L https://storage.googleapis.com/kubernetes-release/release/${VERSION}/bin/${OS}/${ARCH}/kubectl &&\
     chmod +x /usr/local/bin/kubectl
 
 # build jsonnet-bundler
-FROM golang as jb
+FROM golang:alpine as jb
 WORKDIR /tmp
-RUN git clone https://github.com/jsonnet-bundler/jsonnet-bundler &&\
+RUN apk add --no-cache git make bash &&\
+    git clone https://github.com/jsonnet-bundler/jsonnet-bundler &&\
     cd jsonnet-bundler &&\
     make static &&\
     mv _output/jb /usr/local/bin/jb
 
+FROM golang:alpine as helm
+WORKDIR /tmp/helm
+RUN apk add --no-cache jq curl
+RUN export TAG=$(curl --silent "https://api.github.com/repos/helm/helm/releases/latest" | jq -r .tag_name) &&\
+    export OS=$(go env GOOS) &&\
+    export ARCH=$(go env GOARCH) &&\
+    curl -SL "https://get.helm.sh/helm-${TAG}-${OS}-${ARCH}.tar.gz" > helm.tgz && \
+    tar -xvf helm.tgz --strip-components=1
+
 # assemble final container
 FROM alpine
-RUN apk add --no-cache coreutils diffutils less git
+RUN apk add --no-cache coreutils diffutils less git openssh-client
 COPY tk /usr/local/bin/tk
 COPY --from=kubectl /usr/local/bin/kubectl /usr/local/bin/kubectl
 COPY --from=jb /usr/local/bin/jb /usr/local/bin/jb
+COPY --from=helm /tmp/helm/helm /usr/local/bin/helm
 WORKDIR /app
 ENTRYPOINT ["/usr/local/bin/tk"]
diff --git a/README.md b/README.md
index c684cebb..f1f7af17 100644
--- a/README.md
+++ b/README.md
@@ -61,6 +61,7 @@ on track quickly.
 
 There are several places to connect with the Tanka community:
 
+- [GitHub Discussions](https://github.com/grafana/tanka/discussions/442): Primary support channel
 - `#tanka` on [Grafana Slack](https://grafana.slack.com)
 - Mailing lists
   - [`tanka-announce`](https://groups.google.com/forum/#!forum/tanka-announce):
diff --git a/cmd/tk/args.go b/cmd/tk/args.go
index 24f39b47..46f47760 100644
--- a/cmd/tk/args.go
+++ b/cmd/tk/args.go
@@ -1,15 +1,38 @@
 package main
 
 import (
+	"os"
+	"path/filepath"
+
 	"github.com/go-clix/cli"
 	"github.com/posener/complete"
+
+	"github.com/grafana/tanka/pkg/tanka"
 )
 
 var workflowArgs = cli.Args{
 	Validator: cli.ValidateExact(1),
 	Predictor: cli.PredictFunc(func(args complete.Args) []string {
-		if dirs := findBaseDirs(); len(dirs) != 0 {
-			return dirs
+		pwd, err := os.Getwd()
+		if err != nil {
+			return nil
+		}
+
+		dirs, err := tanka.FindBaseDirs(pwd)
+		if err != nil {
+			return nil
+		}
+
+		var reldirs []string
+		for _, dir := range dirs {
+			reldir, err := filepath.Rel(pwd, dir)
+			if err == nil {
+				reldirs = append(reldirs, reldir)
+			}
+		}
+
+		if len(reldirs) != 0 {
+			return reldirs
 		}
 
 		return complete.PredictDirs("*").Predict(args)
diff --git a/cmd/tk/env.go b/cmd/tk/env.go
index a799dda8..10485301 100644
--- a/cmd/tk/env.go
+++ b/cmd/tk/env.go
@@ -11,12 +11,11 @@ import (
 	"github.com/go-clix/cli"
 	"github.com/pkg/errors"
 	"github.com/posener/complete"
-	"github.com/spf13/pflag"
-	"k8s.io/apimachinery/pkg/labels"
 
 	"github.com/grafana/tanka/pkg/jsonnet/jpath"
 	"github.com/grafana/tanka/pkg/kubernetes/client"
 	"github.com/grafana/tanka/pkg/spec/v1alpha1"
+	"github.com/grafana/tanka/pkg/tanka"
 	"github.com/grafana/tanka/pkg/term"
 )
 
@@ -36,13 +35,6 @@ func envCmd() *cli.Command {
 	return cmd
 }
 
-func envSettingsFlags(env *v1alpha1.Config, fs *pflag.FlagSet) {
-	fs.StringVar(&env.Spec.APIServer, "server", env.Spec.APIServer, "endpoint of the Kubernetes API")
-	fs.StringVar(&env.Spec.APIServer, "server-from-context", env.Spec.APIServer, "set the server to a known one from $KUBECONFIG")
-	fs.StringVar(&env.Spec.Namespace, "namespace", env.Spec.Namespace, "namespace to create objects in")
-	fs.StringVar(&env.Spec.DiffStrategy, "diff-strategy", env.Spec.DiffStrategy, "specify diff-strategy. Automatically detected otherwise.")
-}
-
 var kubectlContexts = cli.PredictFunc(
 	func(complete.Args) []string {
 		c, _ := client.Contexts()
@@ -61,7 +53,7 @@ func envSetCmd() *cli.Command {
 	}
 
 	// flags
-	tmp := v1alpha1.Config{}
+	tmp := v1alpha1.Environment{}
 	envSettingsFlags(&tmp, cmd.Flags())
 
 	// removed name flag
@@ -117,6 +109,8 @@ func envAddCmd() *cli.Command {
 	}
 	cfg := v1alpha1.New()
 	envSettingsFlags(cfg, cmd.Flags())
+	inline := cmd.Flags().BoolP("inline", "i", false, "create an inline environment")
+
 	cmd.Run = func(cmd *cli.Command, args []string) error {
 		if cmd.Flags().Changed("server-from-context") {
 			server, err := client.IPFromContext(cfg.Spec.APIServer)
@@ -126,7 +120,7 @@ func envAddCmd() *cli.Command {
 			cfg.Spec.APIServer = server
 		}
 
-		if err := addEnv(args[0], cfg); err != nil {
+		if err := addEnv(args[0], cfg, *inline); err != nil {
 			return err
 		}
 
@@ -136,7 +130,7 @@ func envAddCmd() *cli.Command {
 }
 
 // used by initCmd() as well
-func addEnv(dir string, cfg *v1alpha1.Config) error {
+func addEnv(dir string, cfg *v1alpha1.Environment, inline bool) error {
 	path, err := filepath.Abs(dir)
 	if err != nil {
 		return err
@@ -164,14 +158,22 @@ func addEnv(dir string, cfg *v1alpha1.Config) error {
 	// the other properties are already set by v1alpha1.New() and pflag.Parse()
 	cfg.Metadata.Name, _ = filepath.Rel(rootDir, path)
 
-	// write spec.json
-	if err := writeJSON(cfg, filepath.Join(path, "spec.json")); err != nil {
-		return err
-	}
+	if inline {
+		cfg.Data = struct{}{}
+		// write main.jsonnet with inline tanka.dev/Environment
+		if err := writeJsonnet(cfg, filepath.Join(path, "main.jsonnet")); err != nil {
+			return err
+		}
+	} else {
+		// write spec.json
+		if err := writeJSON(cfg, filepath.Join(path, "spec.json")); err != nil {
+			return err
+		}
 
-	// write main.jsonnet
-	if err := writeJSON(struct{}{}, filepath.Join(path, "main.jsonnet")); err != nil {
-		return err
+		// write main.jsonnet
+		if err := writeJsonnet(struct{}{}, filepath.Join(path, "main.jsonnet")); err != nil {
+			return err
+		}
 	}
 
 	return nil
@@ -203,40 +205,49 @@ func envRemoveCmd() *cli.Command {
 }
 
 func envListCmd() *cli.Command {
+	args := workflowArgs
+	args.Validator = cli.ValidateFunc(func(args []string) error {
+		if len(args) > 1 {
+			return fmt.Errorf("expects at most 1 arg, received %v", len(args))
+		}
+		return nil
+	})
+
 	cmd := &cli.Command{
-		Use:     "list",
+		Use:     "list [<path>]",
 		Aliases: []string{"ls"},
-		Short:   "list environments",
-		Args:    cli.ArgsNone(),
+		Short:   "list environments relative to current dir or <path>",
+		Args:    args,
 	}
 
 	useJSON := cmd.Flags().Bool("json", false, "json output")
-	labelSelector := cmd.Flags().StringP("selector", "l", "", "Label selector. Uses the same syntax as kubectl does")
+	getLabelSelector := labelSelectorFlag(cmd.Flags())
 
 	useNames := cmd.Flags().Bool("names", false, "plain names output")
 
 	cmd.Run = func(cmd *cli.Command, args []string) error {
-		envs := []v1alpha1.Config{}
-		dirs := findBaseDirs()
-		var selector labels.Selector
+		var dir string
 		var err error
-
-		if *labelSelector != "" {
-			selector, err = labels.Parse(*labelSelector)
+		if len(args) == 1 {
+			dir = args[0]
+		} else {
+			dir, err = os.Getwd()
 			if err != nil {
-				return err
+				return nil
 			}
 		}
 
-		for _, dir := range dirs {
-			env := setupConfiguration(dir)
-			if env == nil {
-				log.Printf("Could not setup configuration from %q", dir)
-				continue
-			}
-			if selector == nil || selector.Empty() || selector.Matches(env.Metadata) {
-				envs = append(envs, *env)
-			}
+		stat, err := os.Stat(dir)
+		if err != nil {
+			return err
+		}
+		if !stat.IsDir() {
+			return fmt.Errorf("Not a directory: %s", dir)
+		}
+
+		envs, err := tanka.FindEnvironments(dir, getLabelSelector())
+		if err != nil {
+			return err
 		}
 
 		if *useJSON {
@@ -265,3 +276,14 @@ func envListCmd() *cli.Command {
 	}
 	return cmd
 }
+
+func setupConfiguration(baseDir string) *v1alpha1.Environment {
+	env, err := tanka.Load(baseDir, tanka.Opts{
+		JsonnetOpts: tanka.JsonnetOpts{EvalScript: tanka.EnvsOnlyEvalScript},
+	})
+	if err != nil {
+		log.Fatalln(err)
+	}
+
+	return env.Env
+}
diff --git a/cmd/tk/export.go b/cmd/tk/export.go
index 8ce4a6ac..cb92a398 100644
--- a/cmd/tk/export.go
+++ b/cmd/tk/export.go
@@ -1,137 +1,91 @@
 package main
 
 import (
-	"bytes"
 	"fmt"
-	"io"
-	"io/ioutil"
-	"log"
-	"os"
 	"path/filepath"
-	"strings"
 
-	"text/template"
-
-	"github.com/Masterminds/sprig/v3"
 	"github.com/go-clix/cli"
+	"github.com/pkg/errors"
 
+	"github.com/grafana/tanka/pkg/jsonnet/jpath"
 	"github.com/grafana/tanka/pkg/tanka"
 )
 
-// BelRune is a string of the Ascii character BEL which made computers ring in ancient times
-// We use it as "magic" char for the subfolder creation as it is a non printable character and thereby will never be
-// in a valid filepath by accident. Only when we include it.
-const BelRune = string(rune(7))
-
 func exportCmd() *cli.Command {
 	args := workflowArgs
-	args.Validator = cli.ValidateExact(2)
+	args.Validator = cli.ValidateFunc(func(args []string) error {
+		if len(args) < 2 {
+			return fmt.Errorf("expects at least 2 args, received %v", len(args))
+		}
+		return nil
+	})
 
 	cmd := &cli.Command{
-		Use:   "export <environment> <outputDir>",
-		Short: "write each resources as a YAML file",
+		Use:   "export <outputDir> <path> [<path>...]",
+		Short: "export environments found in path(s)",
 		Args:  args,
 	}
 
-	format := cmd.Flags().String("format", "{{.apiVersion}}.{{.kind}}-{{.metadata.name}}", "https://tanka.dev/exporting#filenames")
+	format := cmd.Flags().String(
+		"format",
+		"{{.apiVersion}}.{{.kind}}-{{.metadata.name}}",
+		"https://tanka.dev/exporting#filenames",
+	)
+
 	extension := cmd.Flags().String("extension", "yaml", "File extension")
 	merge := cmd.Flags().Bool("merge", false, "Allow merging with existing directory")
 
 	vars := workflowFlags(cmd.Flags())
 	getJsonnetOpts := jsonnetFlags(cmd.Flags())
+	getLabelSelector := labelSelectorFlag(cmd.Flags())
 
-	cmd.Run = func(cmd *cli.Command, args []string) error {
-		// dir must be empty
-		to := args[1]
-		empty, err := dirEmpty(to)
-		if err != nil {
-			return fmt.Errorf("Checking target dir: %s", err)
-		}
-		if !empty && !*merge {
-			return fmt.Errorf("Output dir `%s` not empty. Pass --merge to ignore this", to)
-		}
-
-		// exit early if the template is bad
+	recursive := cmd.Flags().BoolP("recursive", "r", false, "Look recursively for Tanka environments")
 
-		// Replace all os.path separators in string with BelRune for creating subfolders
-		replacedFormat := strings.Replace(*format, string(os.PathSeparator), BelRune, -1)
-
-		tmpl, err := template.New("").
-			Funcs(sprig.TxtFuncMap()). // register Masterminds/sprig
-			Parse(replacedFormat)      // parse template
-		if err != nil {
-			return fmt.Errorf("Parsing name format: %s", err)
-		}
-
-		// get the manifests
-		res, err := tanka.Show(args[0], tanka.Opts{
-			JsonnetOpts: getJsonnetOpts(),
-			Filters:     stringsToRegexps(vars.targets),
-		})
-		if err != nil {
-			return err
+	cmd.Run = func(cmd *cli.Command, args []string) error {
+		opts := tanka.ExportEnvOpts{
+			Format:    *format,
+			Extension: *extension,
+			Merge:     *merge,
+			Targets:   vars.targets,
+			ParseParallelOpts: tanka.ParseParallelOpts{
+				JsonnetOpts: getJsonnetOpts(),
+				Selector:    getLabelSelector(),
+			},
 		}
 
-		// write each to a file
-		for _, m := range res {
-			buf := bytes.Buffer{}
-			if err := tmpl.Execute(&buf, m); err != nil {
-				log.Fatalln("executing name template:", err)
+		var paths []string
+		for _, path := range args[1:] {
+			// find possible environments
+			if *recursive {
+				rootDir, err := jpath.FindRoot(path)
+				if err != nil {
+					return errors.Wrap(err, "resolving jpath")
+				}
+
+				// get absolute path to Environment
+				envs, err := tanka.FindEnvironments(path, opts.ParseParallelOpts.Selector)
+				if err != nil {
+					return err
+				}
+
+				for _, env := range envs {
+					paths = append(paths, filepath.Join(rootDir, env.Metadata.Namespace))
+				}
+				continue
 			}
 
-			// Replace all os.path separators in string in order to not accidentally create subfolders
-			name := strings.Replace(buf.String(), string(os.PathSeparator), "-", -1)
-			// Replace the BEL character inserted with a path separator again in order to create a subfolder
-			name = strings.Replace(name, BelRune, string(os.PathSeparator), -1)
-
-			// Create all subfolders in path
-			path := filepath.Join(to, name+"."+*extension)
-
-			// Abort if already exists
-			if exists, err := fileExists(path); err != nil {
+			// validate environment
+			jsonnetOpts := opts.ParseParallelOpts.JsonnetOpts
+			jsonnetOpts.EvalScript = tanka.EnvsOnlyEvalScript
+			_, err := tanka.Load(path, tanka.Opts{JsonnetOpts: jsonnetOpts})
+			if err != nil {
 				return err
-			} else if exists {
-				return fmt.Errorf("File '%s' already exists. Aborting", path)
-			}
-
-			// Write file
-			if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
-				return fmt.Errorf("creating filepath '%s': %s", filepath.Dir(path), err)
-			}
-			data := m.String()
-			if err := ioutil.WriteFile(path, []byte(data), 0644); err != nil {
-				return fmt.Errorf("writing manifest: %s", err)
 			}
+			paths = append(paths, path)
 		}
 
-		return nil
+		// export them
+		return tanka.ExportEnvironments(paths, args[0], &opts)
 	}
 	return cmd
 }
-
-func fileExists(name string) (bool, error) {
-	_, err := os.Stat(name)
-	if os.IsNotExist(err) {
-		return false, nil
-	}
-	if err != nil {
-		return false, err
-	}
-	return true, nil
-}
-
-func dirEmpty(dir string) (bool, error) {
-	f, err := os.Open(dir)
-	if os.IsNotExist(err) {
-		return true, os.MkdirAll(dir, os.ModePerm)
-	} else if err != nil {
-		return false, err
-	}
-	defer f.Close()
-
-	_, err = f.Readdirnames(1)
-	if err == io.EOF {
-		return true, nil
-	}
-	return false, err
-}
diff --git a/cmd/tk/flags.go b/cmd/tk/flags.go
new file mode 100644
index 00000000..aab5af38
--- /dev/null
+++ b/cmd/tk/flags.go
@@ -0,0 +1,91 @@
+package main
+
+import (
+	"fmt"
+	"log"
+	"strings"
+
+	"github.com/spf13/pflag"
+	"k8s.io/apimachinery/pkg/labels"
+
+	"github.com/grafana/tanka/pkg/spec/v1alpha1"
+	"github.com/grafana/tanka/pkg/tanka"
+)
+
+type workflowFlagVars struct {
+	targets []string
+}
+
+func workflowFlags(fs *pflag.FlagSet) *workflowFlagVars {
+	v := workflowFlagVars{}
+	fs.StringSliceVarP(&v.targets, "target", "t", nil, "only use the specified objects (Format: <type>/<name>)")
+	return &v
+}
+
+func labelSelectorFlag(fs *pflag.FlagSet) func() labels.Selector {
+	labelSelector := fs.StringP("selector", "l", "", "Label selector. Uses the same syntax as kubectl does")
+
+	return func() labels.Selector {
+		if *labelSelector != "" {
+			selector, err := labels.Parse(*labelSelector)
+			if err != nil {
+				log.Fatalf("Could not parse selector (-l) %s", *labelSelector)
+			}
+			return selector
+		}
+		return nil
+	}
+}
+
+func jsonnetFlags(fs *pflag.FlagSet) func() tanka.JsonnetOpts {
+	getExtCode, getTLACode := cliCodeParser(fs)
+
+	return func() tanka.JsonnetOpts {
+		return tanka.JsonnetOpts{
+			ExtCode: getExtCode(),
+			TLACode: getTLACode(),
+		}
+	}
+}
+
+func cliCodeParser(fs *pflag.FlagSet) (func() map[string]string, func() map[string]string) {
+	// need to use StringArray instead of StringSlice, because pflag attempts to
+	// parse StringSlice using the csv parser, which breaks when passing objects
+	extCode := fs.StringArray("ext-code", nil, "Set code value of extVar (Format: key=<code>)")
+	extStr := fs.StringArrayP("ext-str", "V", nil, "Set string value of extVar (Format: key=value)")
+
+	tlaCode := fs.StringArray("tla-code", nil, "Set code value of top level function (Format: key=<code>)")
+	tlaStr := fs.StringArrayP("tla-str", "A", nil, "Set string value of top level function (Format: key=value)")
+
+	newParser := func(kind string, code, str *[]string) func() map[string]string {
+		return func() map[string]string {
+			m := make(map[string]string)
+			for _, s := range *code {
+				split := strings.SplitN(s, "=", 2)
+				if len(split) != 2 {
+					log.Fatalf(kind+"-code argument has wrong format: `%s`. Expected `key=<code>`", s)
+				}
+				m[split[0]] = split[1]
+			}
+
+			for _, s := range *str {
+				split := strings.SplitN(s, "=", 2)
+				if len(split) != 2 {
+					log.Fatalf(kind+"-str argument has wrong format: `%s`. Expected `key=<value>`", s)
+				}
+				m[split[0]] = fmt.Sprintf(`"%s"`, split[1])
+			}
+			return m
+		}
+	}
+
+	return newParser("ext", extCode, extStr),
+		newParser("tla", tlaCode, tlaStr)
+}
+
+func envSettingsFlags(env *v1alpha1.Environment, fs *pflag.FlagSet) {
+	fs.StringVar(&env.Spec.APIServer, "server", env.Spec.APIServer, "endpoint of the Kubernetes API")
+	fs.StringVar(&env.Spec.APIServer, "server-from-context", env.Spec.APIServer, "set the server to a known one from $KUBECONFIG")
+	fs.StringVar(&env.Spec.Namespace, "namespace", env.Spec.Namespace, "namespace to create objects in")
+	fs.StringVar(&env.Spec.DiffStrategy, "diff-strategy", env.Spec.DiffStrategy, "specify diff-strategy. Automatically detected otherwise.")
+}
diff --git a/cmd/tk/init.go b/cmd/tk/init.go
index dec59869..5c21a9a3 100644
--- a/cmd/tk/init.go
+++ b/cmd/tk/init.go
@@ -23,6 +23,7 @@ func initCmd() *cli.Command {
 
 	force := cmd.Flags().BoolP("force", "f", false, "ignore the working directory not being empty")
 	installK8sLibFlag := cmd.Flags().Bool("k8s", true, "set to false to skip installation of k.libsonnet")
+	inline := cmd.Flags().BoolP("inline", "i", false, "create an inline environment")
 
 	cmd.Run = func(cmd *cli.Command, args []string) error {
 		failed := false
@@ -48,7 +49,7 @@ func initCmd() *cli.Command {
 		}
 
 		cfg := v1alpha1.New()
-		if err := addEnv("environments/default", cfg); err != nil {
+		if err := addEnv("environments/default", cfg, *inline); err != nil {
 			return err
 		}
 
diff --git a/cmd/tk/jsonnet.go b/cmd/tk/jsonnet.go
index 8f173c56..5079416d 100644
--- a/cmd/tk/jsonnet.go
+++ b/cmd/tk/jsonnet.go
@@ -3,11 +3,8 @@ package main
 import (
 	"encoding/json"
 	"fmt"
-	"log"
-	"strings"
 
 	"github.com/go-clix/cli"
-	"github.com/spf13/pflag"
 
 	"github.com/grafana/tanka/pkg/tanka"
 )
@@ -27,10 +24,12 @@ func evalCmd() *cli.Command {
 		jsonnetOpts := tanka.Opts{
 			JsonnetOpts: getJsonnetOpts(),
 		}
-		jsonnetOpts.EvalPattern = *evalPattern
+		if *evalPattern != "" {
+			jsonnetOpts.EvalScript = fmt.Sprintf(tanka.PatternEvalScript, *evalPattern)
+		}
 		raw, err := tanka.Eval(args[0], jsonnetOpts)
 
-		if err != nil {
+		if raw == nil && err != nil {
 			return err
 		}
 
@@ -48,49 +47,3 @@ func evalCmd() *cli.Command {
 
 	return cmd
 }
-
-func jsonnetFlags(fs *pflag.FlagSet) func() tanka.JsonnetOpts {
-	getExtCode, getTLACode := cliCodeParser(fs)
-
-	return func() tanka.JsonnetOpts {
-		return tanka.JsonnetOpts{
-			ExtCode: getExtCode(),
-			TLACode: getTLACode(),
-		}
-	}
-}
-
-func cliCodeParser(fs *pflag.FlagSet) (func() map[string]string, func() map[string]string) {
-	// need to use StringArray instead of StringSlice, because pflag attempts to
-	// parse StringSlice using the csv parser, which breaks when passing objects
-	extCode := fs.StringArray("ext-code", nil, "Set code value of extVar (Format: key=<code>)")
-	extStr := fs.StringArrayP("ext-str", "V", nil, "Set string value of extVar (Format: key=value)")
-
-	tlaCode := fs.StringArray("tla-code", nil, "Set code value of top level function (Format: key=<code>)")
-	tlaStr := fs.StringArrayP("tla-str", "A", nil, "Set string value of top level function (Format: key=value)")
-
-	newParser := func(kind string, code, str *[]string) func() map[string]string {
-		return func() map[string]string {
-			m := make(map[string]string)
-			for _, s := range *code {
-				split := strings.SplitN(s, "=", 2)
-				if len(split) != 2 {
-					log.Fatalf(kind+"-code argument has wrong format: `%s`. Expected `key=<code>`", s)
-				}
-				m[split[0]] = split[1]
-			}
-
-			for _, s := range *str {
-				split := strings.SplitN(s, "=", 2)
-				if len(split) != 2 {
-					log.Fatalf(kind+"-str argument has wrong format: `%s`. Expected `key=<value>`", s)
-				}
-				m[split[0]] = fmt.Sprintf(`"%s"`, split[1])
-			}
-			return m
-		}
-	}
-
-	return newParser("ext", extCode, extStr),
-		newParser("tla", tlaCode, tlaStr)
-}
diff --git a/cmd/tk/main.go b/cmd/tk/main.go
index 15ddd3a7..52cf28ad 100644
--- a/cmd/tk/main.go
+++ b/cmd/tk/main.go
@@ -1,18 +1,13 @@
 package main
 
 import (
-	"fmt"
 	"log"
 	"os"
-	"path/filepath"
-
-	"golang.org/x/crypto/ssh/terminal"
 
+	"github.com/fatih/color"
 	"github.com/go-clix/cli"
+	"golang.org/x/crypto/ssh/terminal"
 
-	"github.com/grafana/tanka/pkg/jsonnet/jpath"
-	"github.com/grafana/tanka/pkg/spec"
-	"github.com/grafana/tanka/pkg/spec/v1alpha1"
 	"github.com/grafana/tanka/pkg/tanka"
 )
 
@@ -54,34 +49,13 @@ func main() {
 		toolCmd(),
 	)
 
+	// external commands prefixed with "tk-"
+	rootCmd.AddCommand(
+		prefixCommands("tk-")...,
+	)
+
 	// Run!
 	if err := rootCmd.Execute(); err != nil {
-		log.Fatalln(err)
+		log.Fatalln(color.RedString("Error:"), err)
 	}
 }
-
-func setupConfiguration(baseDir string) *v1alpha1.Config {
-	_, baseDir, rootDir, err := jpath.Resolve(baseDir)
-	if err != nil {
-		log.Fatalln("Resolving jpath:", err)
-	}
-
-	// name of the environment: relative path from rootDir
-	name, _ := filepath.Rel(rootDir, baseDir)
-
-	config, err := spec.ParseDir(baseDir, name)
-	if err != nil {
-		switch err.(type) {
-		// the config includes deprecated fields
-		case spec.ErrDeprecated:
-			if verbose {
-				fmt.Print(err)
-			}
-		// some other error
-		default:
-			log.Fatalf("Reading spec.json: %s", err)
-		}
-	}
-
-	return config
-}
diff --git a/cmd/tk/other.go b/cmd/tk/other.go
deleted file mode 100644
index 40dc694f..00000000
--- a/cmd/tk/other.go
+++ /dev/null
@@ -1,36 +0,0 @@
-package main
-
-import (
-	"log"
-	"os"
-	"path/filepath"
-
-	"github.com/grafana/tanka/pkg/jsonnet/jpath"
-)
-
-// findBaseDirs searches for possible environments
-func findBaseDirs() (dirs []string) {
-	pwd, err := os.Getwd()
-	if err != nil {
-		return
-	}
-	_, _, _, err = jpath.Resolve(pwd)
-	if err == jpath.ErrorNoRoot {
-		return
-	}
-
-	if err := filepath.Walk(".", func(path string, info os.FileInfo, err error) error {
-		requiredFiles := []string{"main.jsonnet", "spec.json"}
-		for _, name := range requiredFiles {
-			if _, err := os.Stat(filepath.Join(path, name)); err != nil {
-				// missing file, not a valid environment directory
-				return nil
-			}
-		}
-		dirs = append(dirs, path)
-		return nil
-	}); err != nil {
-		log.Fatalln(err)
-	}
-	return dirs
-}
diff --git a/cmd/tk/prefix.go b/cmd/tk/prefix.go
new file mode 100644
index 00000000..0f040e2c
--- /dev/null
+++ b/cmd/tk/prefix.go
@@ -0,0 +1,80 @@
+package main
+
+import (
+	"fmt"
+	"io/ioutil"
+	"os"
+	"os/exec"
+	"strings"
+
+	"github.com/go-clix/cli"
+)
+
+func prefixCommands(prefix string) (cmds []*cli.Command) {
+	externalCommands, err := executablesOnPath(prefix)
+	if err != nil {
+		// soft fail if no commands found
+		return nil
+	}
+
+	for file, path := range externalCommands {
+		cmd := &cli.Command{
+			Use:   fmt.Sprintf("%s --", strings.TrimPrefix(file, prefix)),
+			Short: fmt.Sprintf("external command %s", path),
+			Args:  cli.ArgsAny(),
+		}
+
+		ext_command := exec.Command(path)
+		if ex, err := os.Executable(); err == nil {
+			ext_command.Env = append(os.Environ(), fmt.Sprintf("EXECUTABLE=%s", ex))
+		}
+		ext_command.Stdout = os.Stdout
+		ext_command.Stderr = os.Stderr
+
+		cmd.Run = func(cmd *cli.Command, args []string) error {
+			ext_command.Args = append(ext_command.Args, args...)
+			return ext_command.Run()
+		}
+		cmds = append(cmds, cmd)
+	}
+	if len(cmds) > 0 {
+		return cmds
+	}
+	return nil
+}
+
+func executablesOnPath(prefix string) (executables map[string]string, err error) {
+	path, ok := os.LookupEnv("PATH")
+	if !ok {
+		// if PATH not set, soft fail
+		return nil, fmt.Errorf("PATH not set")
+	}
+
+	paths := strings.Split(path, ":")
+	for _, p := range paths {
+		s, err := os.Stat(p)
+		if err != nil && os.IsNotExist(err) {
+			continue
+		}
+		if err != nil {
+			return nil, err
+		}
+		if !s.IsDir() {
+			continue
+		}
+
+		files, err := ioutil.ReadDir(p)
+		if err != nil {
+			return nil, err
+		}
+		for _, file := range files {
+			if !strings.HasPrefix(file.Name(), prefix) {
+				continue
+			}
+			if file.Mode().IsRegular() && file.Mode().Perm()&0111 != 0 {
+				executables[file.Name()] = fmt.Sprintf("%s/%s", p, file.Name())
+			}
+		}
+	}
+	return executables, nil
+}
diff --git a/cmd/tk/tool.go b/cmd/tk/tool.go
index 6bf1bc44..84b1fc3f 100644
--- a/cmd/tk/tool.go
+++ b/cmd/tk/tool.go
@@ -4,6 +4,7 @@ import (
 	"bytes"
 	"encoding/json"
 	"fmt"
+	"log"
 	"os"
 	"os/exec"
 	"path/filepath"
@@ -30,29 +31,40 @@ func toolCmd() *cli.Command {
 
 func jpathCmd() *cli.Command {
 	cmd := &cli.Command{
-		Short: "print information about the jpath",
-		Use:   "jpath",
-		Run: func(cmd *cli.Command, args []string) error {
-			pwd, err := os.Getwd()
-			if err != nil {
-				return err
-			}
-			path, base, root, err := jpath.Resolve(pwd)
-			if err != nil {
-				return fmt.Errorf("Resolving JPATH: %s", err)
-			}
-			entrypoint, err := jpath.Entrypoint(base)
-			if err != nil {
-				return fmt.Errorf("Resolving JPATH: %s", err)
-			}
-			fmt.Println("main:", entrypoint)
-			fmt.Println("rootDir:", root)
-			fmt.Println("baseDir:", base)
-			fmt.Println("jpath:", path)
+		Short: "export JSONNET_PATH for use with other jsonnet tools",
+		Use:   "jpath [<file/dir>]",
+		Args:  workflowArgs,
+	}
+
+	debug := cmd.Flags().BoolP("debug", "d", false, "show debug info")
+
+	cmd.Run = func(cmd *cli.Command, args []string) error {
+		path := args[0]
 
-			return nil
-		},
+		entrypoint, err := jpath.Entrypoint(path)
+		if err != nil {
+			return fmt.Errorf("Resolving JPATH: %s", err)
+		}
+
+		jsonnetpath, base, root, err := jpath.Resolve(entrypoint)
+		if err != nil {
+			return fmt.Errorf("Resolving JPATH: %s", err)
+		}
+
+		if *debug {
+			// log to debug info to stderr
+			log.Println("main:", entrypoint)
+			log.Println("rootDir:", root)
+			log.Println("baseDir:", base)
+			log.Println("jpath:", jsonnetpath)
+		}
+
+		// print export JSONNET_PATH to stdout
+		fmt.Printf("%s", strings.Join(jsonnetpath, ":"))
+
+		return nil
 	}
+
 	return cmd
 }
 
diff --git a/cmd/tk/toolCharts.go b/cmd/tk/toolCharts.go
index 217234c8..f8fc7d08 100644
--- a/cmd/tk/toolCharts.go
+++ b/cmd/tk/toolCharts.go
@@ -20,6 +20,7 @@ func chartsCmd() *cli.Command {
 	cmd.AddCommand(
 		chartsInitCmd(),
 		chartsAddCmd(),
+		chartsAddRepoCmd(),
 		chartsVendorCmd(),
 		chartsConfigCmd(),
 	)
@@ -63,6 +64,28 @@ func chartsAddCmd() *cli.Command {
 	return cmd
 }
 
+func chartsAddRepoCmd() *cli.Command {
+	cmd := &cli.Command{
+		Use:   "add-repo [NAME] [URL]",
+		Short: "Adds a repository to the chartfile",
+		Args:  cli.ArgsExact(2),
+	}
+
+	cmd.Run = func(cmd *cli.Command, args []string) error {
+		c, err := loadChartfile()
+		if err != nil {
+			return err
+		}
+
+		return c.AddRepos(helm.Repo{
+			Name: args[0],
+			URL:  args[1],
+		})
+	}
+
+	return cmd
+}
+
 func chartsConfigCmd() *cli.Command {
 	cmd := &cli.Command{
 		Use:   "config",
diff --git a/cmd/tk/util.go b/cmd/tk/util.go
index 3c9b4b44..ca07847e 100644
--- a/cmd/tk/util.go
+++ b/cmd/tk/util.go
@@ -8,6 +8,8 @@ import (
 	"os"
 	"os/exec"
 	"strings"
+
+	"github.com/google/go-jsonnet/formatter"
 )
 
 func pageln(i ...interface{}) error {
@@ -56,3 +58,22 @@ func writeJSON(i interface{}, path string) error {
 
 	return nil
 }
+
+// writeJsonnet writes the given object to the path as a formatted Jsonnet file
+func writeJsonnet(i interface{}, path string) error {
+	out, err := json.MarshalIndent(i, "", "  ")
+	if err != nil {
+		return fmt.Errorf("marshalling: %s", err)
+	}
+
+	main, err := formatter.Format(path, string(out), formatter.DefaultOptions())
+	if err != nil {
+		return fmt.Errorf("formatting %s: %s", path, err)
+	}
+
+	if err := ioutil.WriteFile(path, []byte(main), 0644); err != nil {
+		return fmt.Errorf("writing %s: %s", path, err)
+	}
+
+	return nil
+}
diff --git a/cmd/tk/workflow.go b/cmd/tk/workflow.go
index 4f1d46a6..c66c31ab 100644
--- a/cmd/tk/workflow.go
+++ b/cmd/tk/workflow.go
@@ -5,10 +5,8 @@ import (
 	"log"
 	"os"
 
-	"github.com/posener/complete"
-	"github.com/spf13/pflag"
-
 	"github.com/go-clix/cli"
+	"github.com/posener/complete"
 
 	"github.com/grafana/tanka/pkg/process"
 	"github.com/grafana/tanka/pkg/tanka"
@@ -23,16 +21,6 @@ const (
 	ExitStatusDiff = 16
 )
 
-type workflowFlagVars struct {
-	targets []string
-}
-
-func workflowFlags(fs *pflag.FlagSet) *workflowFlagVars {
-	v := workflowFlagVars{}
-	fs.StringSliceVarP(&v.targets, "target", "t", nil, "only use the specified objects (Format: <type>/<name>)")
-	return &v
-}
-
 func applyCmd() *cli.Command {
 	cmd := &cli.Command{
 		Use:   "apply <path>",
@@ -49,7 +37,11 @@ func applyCmd() *cli.Command {
 	getJsonnetOpts := jsonnetFlags(cmd.Flags())
 
 	cmd.Run = func(cmd *cli.Command, args []string) error {
-		opts.Filters = stringsToRegexps(vars.targets)
+		filters, err := process.StrExps(vars.targets...)
+		if err != nil {
+			return err
+		}
+		opts.Filters = filters
 		opts.JsonnetOpts = getJsonnetOpts()
 
 		return tanka.Apply(args[0], opts)
@@ -94,7 +86,11 @@ func deleteCmd() *cli.Command {
 	getJsonnetOpts := jsonnetFlags(cmd.Flags())
 
 	cmd.Run = func(cmd *cli.Command, args []string) error {
-		opts.Filters = stringsToRegexps(vars.targets)
+		filters, err := process.StrExps(vars.targets...)
+		if err != nil {
+			return err
+		}
+		opts.Filters = filters
 		opts.JsonnetOpts = getJsonnetOpts()
 
 		return tanka.Delete(args[0], opts)
@@ -120,7 +116,11 @@ func diffCmd() *cli.Command {
 	getJsonnetOpts := jsonnetFlags(cmd.Flags())
 
 	cmd.Run = func(cmd *cli.Command, args []string) error {
-		opts.Filters = stringsToRegexps(vars.targets)
+		filters, err := process.StrExps(vars.targets...)
+		if err != nil {
+			return err
+		}
+		opts.Filters = filters
 		opts.JsonnetOpts = getJsonnetOpts()
 
 		changes, err := tanka.Diff(args[0], opts)
@@ -165,9 +165,14 @@ Otherwise run tk show --dangerous-allow-redirect to bypass this check.`)
 			return nil
 		}
 
+		filters, err := process.StrExps(vars.targets...)
+		if err != nil {
+			return err
+		}
+
 		pretty, err := tanka.Show(args[0], tanka.Opts{
 			JsonnetOpts: getJsonnetOpts(),
-			Filters:     stringsToRegexps(vars.targets),
+			Filters:     filters,
 		})
 
 		if err != nil {
@@ -178,11 +183,3 @@ Otherwise run tk show --dangerous-allow-redirect to bypass this check.`)
 	}
 	return cmd
 }
-
-func stringsToRegexps(exps []string) process.Matchers {
-	regexs, err := process.StrExps(exps...)
-	if err != nil {
-		log.Fatalln(err)
-	}
-	return regexs
-}
diff --git a/docs/docs/env-vars.md b/docs/docs/env-vars.md
index 7999d543..27545ded 100644
--- a/docs/docs/env-vars.md
+++ b/docs/docs/env-vars.md
@@ -25,3 +25,8 @@ menu: "References"
 
 **Description**: Path to the `helm` executable  
 **Default**: `$PATH/helm`
+
+### TANKA_KUSTOMIZE_PATH
+
+**Description**: Path to the `kustomize` executable  
+**Default**: `$PATH/kustomize`
diff --git a/docs/docs/formatting.md b/docs/docs/formatting.md
new file mode 100644
index 00000000..7400a014
--- /dev/null
+++ b/docs/docs/formatting.md
@@ -0,0 +1,25 @@
+---
+name: "Formatting"
+route: "/formatting"
+menu: "References"
+---
+
+# File Formatting
+
+Tanka supports formatting for all `jsonnet` and `libsonnet` files using the `tk fmt` command.
+
+By default, the command excludes all `vendor` directories.
+
+```bash
+# Run for current and child directories. Run this in the root of the project to format all your files.
+tk fmt .
+
+# Format a single file (myFile.jsonnet)
+tk fmt myFile.jsonnet
+
+# Use the `-t` tag to test (Dry run).
+tk fmt -t myFile.jsonnet
+
+# Format using verbose mode.
+tk fmt -v .
+```
\ No newline at end of file
diff --git a/docs/docs/helm.mdx b/docs/docs/helm.mdx
index 5c90d3fb..916b8687 100644
--- a/docs/docs/helm.mdx
+++ b/docs/docs/helm.mdx
@@ -17,17 +17,19 @@ resource definition, you can still consume Helm resources, as described below.
 ## Consuming Helm Charts from Jsonnet
 
 Helm support is provided using the
-[`github.com/grafana/jsonnet-libs/helm-util`](https://github.com/grafana/jsonnet-libs/tree/master/helm-util)
-library.  Install it with:
+[`github.com/grafana/jsonnet-libs/tanka-util`](https://github.com/grafana/jsonnet-libs/tree/master/tanka-util)
+library. Install it with:
+
 ```bash
-jb install github.com/grafana/jsonnet-libs/helm-util
+jb install github.com/grafana/jsonnet-libs/tanka-util
 ```
 
 The following example shows how to extract the individual resources of the
 [`grafana`](https://hub.helm.sh/charts/grafana/grafana) Helm Chart:
 
 ```jsonnet
-local helm = (import "github.com/grafana/jsonnet-libs/helm-util/helm.libsonnet").new(std.thisFile);
+local tanka = import "github.com/grafana/jsonnet-libs/tanka-util/main.libsonnet";
+local helm = tanka.helm.new(std.thisFile);
 
 {
   grafana: helm.template("grafana", "./charts/grafana", {
@@ -135,16 +137,22 @@ This will also call `tk tool charts vendor`, so that the `charts/` directory is
 
 **Adding Repositories:**
 By default, the `stable` repository is automatically set up for you. If you wish
-to add another repository, you currently need modify `chartfile.yaml` directly
-to add it:
+to add another repository, you can use the `add-repo` command:
+
+```bash
+# Add the official Grafana repository
+$ tk tool charts add-repo grafana https://grafana.github.io/helm-charts
+```
+
+Another way is to modify `chartfile.yaml` directly:
 
 ```diff
 version: 1
 repositories:
   - name: stable
-    url: https://kubernetes-charts.storage.googleapis.com
+    url: https://charts.helm.sh/stable
 + - name: grafana
-+   url: https://https://grafana.github.io/helm-charts
++   url: https://grafana.github.io/helm-charts
 ```
 
 ## Troubleshooting
@@ -165,11 +173,12 @@ using [`TANKA_HELM_PATH`](/env-vars#tanka_helm_path)
 ### opts.calledFrom unset
 
 This occurs, when Tanka was not told where it `helm.template()` was invoked
-from. This most likely means you didn't call `new(std.thisFile)` when importing `helm-util`:
+from. This most likely means you didn't call `new(std.thisFile)` when importing `tanka-util`:
 
 ```jsonnet
-local helm = (import "github.com/grafana/jsonnet-libs/helm-util/helm.libsonnet").new(std.thisFile);
-                                                                                 ↑ This is important
+local tanka = import "github.com/grafana/jsonnet-libs/tanka-util/main.libsonnet";
+local helm = tanka.helm.new(std.thisFile);
+                       ↑ This is important
 ```
 
 ### Failed to find Chart
diff --git a/docs/docs/inline-environments.mdx b/docs/docs/inline-environments.mdx
new file mode 100644
index 00000000..130a8261
--- /dev/null
+++ b/docs/docs/inline-environments.mdx
@@ -0,0 +1,138 @@
+---
+name: "Inline environments"
+route: "/inline-environments"
+menu: Advanced features
+---
+
+# Inline environments
+
+Inline environments is the practice of defining the environment's config inline
+for evaluation at runtime as opposed to configuring it statically in
+`spec.json`.
+
+The general take away is:
+
+* `spec.json` will no longer be used
+* `main.jsonnet` is expected to render a `tanka.dev/Environment` object
+* this object is expected to hold Kubernetes objects at `.data`
+
+## Converting to an inline environment
+
+Converting a traditional `spec.json` environment into an inline environment is quite
+straight forward. Based on the example from [Using Jsonnet](tutorial/jsonnet):
+
+The directory structure:
+
+```sh
+├── environments
+│   └── default # default environment
+│       ├── main.jsonnet # main file
+│       └── spec.json # environment's config
+├── jsonnetfile.json
+├── lib # libraries
+└── vendor # external libraries
+```
+
+The original files look like this:
+
+```jsonnet
+// main.jsonnet
+{
+  some_deployment: {/* ... */ },
+  some_service: {/* ... */ },
+}
+```
+
+
+```json
+// spec.json
+{
+  "apiVersion": "tanka.dev/v1alpha1",
+  "kind": "Environment",
+  "metadata": {
+    "name": "default"
+  },
+  "spec": {
+    "apiServer": "https://127.0.0.1:6443",
+    "namespace": "monitoring"
+  }
+}
+```
+
+Converting is a simple as bringing in the `spec.json` into `main.jsonnet` and
+moving the original `main.jsonnet` scope into the `data:` element.
+
+```jsonnet
+// main.jsonnet
+{
+  apiVersion: 'tanka.dev/v1alpha1',
+  kind: 'Environment',
+  metadata: {
+    name: 'default',
+  },
+  spec: {
+    apiServer: 'https://127.0.0.1:6443',
+    namespace: 'monitoring',
+  },
+  data: { // original main.jsonnet data
+    some_deployment: {/* ... */ },
+    some_service: {/* ... */ },
+  },
+}
+```
+
+
+## Use case: variable apiServer
+
+Even though the `apiServer` directive is originally meant to prevent that the
+manifests don't get accidentally applied to the wrong Kubernetes cluster, there
+is a valid use case for making the `apiServer` variable: Local test clusters.
+
+Instead of modifying `spec.json` each time, with inline environments it is
+possible to leverage powerful jsonnet concepts, for example with top level
+arguments:
+
+```jsonnet
+// environments/minikube-test-setup/main.jsonnet
+function(apiServer) {
+  apiVersion: 'tanka.dev/v1alpha1',
+  kind: 'Environment',
+  metadata: {
+    name: 'minikube-test-setup',
+  },
+  spec: {
+    apiServer: apiServer,
+    namespace: 'monitoring',
+  },
+  data: { /* ... */ },
+}
+```
+
+Applying this to a local Kubernetes cluster can be done like this:
+
+```bash
+$ tk apply --tla-str apiServer=https://127.0.0.1:4758 environments/minikube-test-setup
+```
+
+Similarly this can be used to configure any part of the Environment object, like
+`namespace:`, `metadata.labels`, ...
+
+## Caveats
+
+### `import "tk"`
+
+Inline environments cannot use [`import "tk"`](config#jsonnet-access) anymore as
+this information was populated before jsonnet evaluation by the existence of
+`spec.json`.
+
+### `tk env`
+
+The different `tk env` subcommands are heavily based on the `spec.json`
+approach. `tk env list` will continue to work as expected, `tk env
+(add|remove|set)` will only work for `spec.json` based environments.
+
+### Multiple inline environments
+
+Having multiple inline environments is currently unsupported during Kubernetes
+operations, with an exception for `tk eval`, which will happily render it to
+json.
diff --git a/docs/docs/kustomize.mdx b/docs/docs/kustomize.mdx
new file mode 100644
index 00000000..92295ea5
--- /dev/null
+++ b/docs/docs/kustomize.mdx
@@ -0,0 +1,126 @@
+---
+name: "Kustomize support"
+route: "/kustomize"
+menu: Advanced features
+---
+
+# Kustomize Support
+
+[Kustomize](https://kustomize.io) provides a solution for customizing Kubernetes
+manifests in YAML.
+
+Even though Grafana Tanka uses the [Jsonnet language](/jsonnet/overview) for
+resource definition, you can still consume kustomizations, as described below.
+
+> **Warning:** Keep in mind this feature is considered EXPERIMENTAL
+
+## Consuming a Kustomization from Jsonnet
+
+Kustomize support is provided using the
+[`github.com/grafana/jsonnet-libs/tanka-util`](https://github.com/grafana/jsonnet-libs/tree/master/tanka-util)
+library.  Install it with:
+
+```bash
+jb install github.com/grafana/jsonnet-libs/tanka-util
+```
+
+The following example shows how to extract the individual resources of the
+[`flux2/source-controller`](https://github.com/fluxcd/flux2/tree/main/manifests/bases/source-controller)
+kustomization:
+
+```jsonnet
+local tanka = import 'github.com/grafana/jsonnet-libs/tanka-util/main.libsonnet';
+local kustomize = tanka.kustomize.new(std.thisFile);
+
+{
+  source_controller: kustomize.build(path='flux2')
+}
+```
+
+Kustomize takes a kustomization manifest as input. Go on an create this file
+`flux2/kustomization.yaml` relative to above jsonnet:
+
+```yaml
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+resources:
+- https://github.com/fluxcd/flux2/archive/v0.4.3.zip//flux2-0.4.3/manifests/bases/source-controller
+```
+
+> **Important:** You MUST include the `.new(std.thisFile)` part in the import.
+> This is what tells Tanka where you actually call `kustomize.build()` from, so
+> it can find your kustomization manifest.
+
+<br />
+
+Once invoked, the `$.source_controller` key holds the individual resources of
+the kustomization as a regular Jsonnet object that looks roughly like so:
+
+```jsonnet
+{
+  'custom_resource_definition_buckets.source.toolkit.fluxcd.io': {/* ... */ },
+  'custom_resource_definition_gitrepositories.source.toolkit.fluxcd.io': {/* ... */ },
+  'custom_resource_definition_helmcharts.source.toolkit.fluxcd.io': {/* ... */ },
+  'custom_resource_definition_helmrepositories.source.toolkit.fluxcd.io': {/* ... */ },
+  deployment_source_controller: {/* ... */ },
+  service_source_controller: {/* ... */ },
+}
+```
+
+Above can be [manipulated](/tutorial/environments#patching) in the same way as
+any other Jsonnet data.
+
+## Working with Kustomize
+
+Tanka, like Jsonnet, is hermetic. It **always yields the same resources** when
+the project is strictly self-contained.
+
+Kustomize however has the ability to pull
+[resources](https://kubectl.docs.kubernetes.io/references/kustomize/resource/)
+from different sources at runtime, which violates above requirement. This is
+also apparent in the example above.
+
+> **Important:** Due to the nature of Kustomize, it is not feasible to ensure
+> hermetic and reproducible kustomize builds from within Tanka. Beware of that
+> when using Kustomize.
+
+## Troubleshooting
+
+### Kustomize executable missing
+
+Kustomize support in Tanka requires the `kustomize` binary installed on your
+system and available on the `$PATH`. If Kustomize is not installed, you will see
+this error message:
+
+```
+evaluating jsonnet: RUNTIME ERROR: Expanding Kustomize: exec: "kustomize": executable file not found in $PATH
+```
+
+To solve this, you need to 
+[install Kustomize](https://kubectl.docs.kubernetes.io/installation/kustomize/).
+If you cannot install it system-wide, you can point Tanka at your executable 
+using [`TANKA_KUSTOMIZE_PATH`](/env-vars#tanka_kustomize_path)
+
+### opts.calledFrom unset
+
+This occurs, when Tanka was not told where it `kustomize.build()` was invoked
+from. This most likely means you didn't call `new(std.thisFile)` when importing `tanka-util`:
+
+```jsonnet
+local tanka = import "github.com/grafana/jsonnet-libs/tanka-util/main.libsonnet";
+local helm = tanka.helm.new(std.thisFile);
+                       ↑ This is important
+```
+
+### Failed to find kustomization
+
+```
+helmTemplate: Failed to find a kustomization at 'flux2': No such file or directory.
+helmTemplate: Failed to find a kustomization at '/home/user/stuff/tanka/environments/default/flux2': No such file or directory.
+```
+
+Tanka failed to locate your kustomization on the filesystem. It looked at the
+relative path you provided in `kustomize.build()`, starting from the directory
+of the file you called `kustomize.build()` from.
+
+Please check there is actually a valid kustomization at this place.
diff --git a/docs/doczrc.js b/docs/doczrc.js
index 8014b222..37d36426 100644
--- a/docs/doczrc.js
+++ b/docs/doczrc.js
@@ -48,6 +48,7 @@ export default {
       menu: [
         "Garbage collection",
         "Helm support",
+        "Kustomize support",
         "Output filtering",
         "Exporting as YAML",
       ],
diff --git a/go.mod b/go.mod
index 407b376b..adf896b4 100644
--- a/go.mod
+++ b/go.mod
@@ -10,7 +10,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.16.1-0.20200908152747-b70cbd441a39
+	github.com/google/go-jsonnet v0.17.0
 	github.com/huandu/xstrings v1.3.2 // indirect
 	github.com/imdario/mergo v0.3.9 // indirect
 	github.com/karrick/godirwalk v1.15.5
diff --git a/go.sum b/go.sum
index dc83709e..23c5f25f 100644
--- a/go.sum
+++ b/go.sum
@@ -63,8 +63,8 @@ github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw
 github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 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-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/go-jsonnet v0.17.0 h1:/9NIEfhK1NQRKl3sP2536b2+x5HnZMdql7x3yK/l8JY=
+github.com/google/go-jsonnet v0.17.0/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=
diff --git a/pkg/helm/charts.go b/pkg/helm/charts.go
index 37eb1d1a..c9d340e0 100644
--- a/pkg/helm/charts.go
+++ b/pkg/helm/charts.go
@@ -68,6 +68,11 @@ type Charts struct {
 	Helm Helm
 }
 
+// chartManifest represents a Helm chart's Chart.yaml
+type chartManifest struct {
+	Version string `yaml:"version"`
+}
+
 // ChartDir returns the directory pulled charts are saved in
 func (c Charts) ChartDir() string {
 	return filepath.Join(c.projectRoot, c.Manifest.Directory)
@@ -95,12 +100,33 @@ func (c Charts) Vendor() error {
 	for _, r := range c.Manifest.Requires {
 		chartName := parseReqName(r.Chart)
 		chartPath := filepath.Join(dir, chartName)
-		if _, err := os.Stat(chartPath); err == nil {
-			log.Printf(" %s@%s exists", r.Chart, r.Version.String())
-			continue
+
+		_, err := os.Stat(chartPath)
+		if err == nil {
+			chartManifestPath := filepath.Join(chartPath, "Chart.yaml")
+			chartManifestBytes, err := ioutil.ReadFile(chartManifestPath)
+			if err != nil {
+				return fmt.Errorf("reading chart manifest: %w", err)
+			}
+			var chartYAML chartManifest
+			if err := yaml.Unmarshal(chartManifestBytes, &chartYAML); err != nil {
+				return fmt.Errorf("unmarshalling chart manifest: %w", err)
+			}
+
+			if chartYAML.Version == r.Version.String() {
+				log.Printf(" %s@%s exists", r.Chart, r.Version.String())
+				continue
+			} else {
+				log.Printf("Removing %s@%s", r.Chart, r.Version.String())
+				if err := os.RemoveAll(chartPath); err != nil {
+					return err
+				}
+			}
+		} else if !os.IsNotExist(err) {
+			return err
 		}
 
-		err := c.Helm.Pull(r.Chart, r.Version.String(), PullOpts{
+		err = c.Helm.Pull(r.Chart, r.Version.String(), PullOpts{
 			Destination: dir,
 			Opts:        Opts{Repositories: c.Manifest.Repositories},
 		})
@@ -116,7 +142,7 @@ func (c Charts) Vendor() error {
 
 // Add adds every Chart in reqs to the Manifest after validation, and runs
 // Vendor afterwards
-func (c Charts) Add(reqs []string) error {
+func (c *Charts) Add(reqs []string) error {
 	log.Printf("Adding %v Charts ...", len(reqs))
 
 	skip := func(s string, err error) {
@@ -157,12 +183,35 @@ func (c Charts) Add(reqs []string) error {
 	return c.Vendor()
 }
 
+func (c *Charts) AddRepos(repos ...Repo) error {
+	added := 0
+	for _, r := range repos {
+		if c.Manifest.Repositories.Has(r) {
+			log.Printf("Skipping %s: Already exists", r.Name)
+			continue
+		}
+
+		c.Manifest.Repositories = append(c.Manifest.Repositories, r)
+	}
+
+	// write out
+	if err := write(c.Manifest, c.ManifestFile()); err != nil {
+		return err
+	}
+
+	if added != len(repos) {
+		return fmt.Errorf("%v Chart(s) were skipped. Please check above logs for details", len(repos)-added)
+	}
+
+	return nil
+}
+
 func InitChartfile(path string) (*Charts, error) {
 	c := Chartfile{
 		Version: Version,
 		Repositories: []Repo{{
 			Name: "stable",
-			URL:  "https://kubernetes-charts.storage.googleapis.com",
+			URL:  "https://charts.helm.sh/stable",
 		}},
 		Requires: make(Requirements, 0),
 	}
diff --git a/pkg/helm/jsonnet.go b/pkg/helm/jsonnet.go
index ced9a8cf..3a03596e 100644
--- a/pkg/helm/jsonnet.go
+++ b/pkg/helm/jsonnet.go
@@ -1,14 +1,11 @@
 package helm
 
 import (
-	"bytes"
 	"encoding/json"
 	"fmt"
 	"os"
 	"path/filepath"
-	"text/template"
 
-	"github.com/Masterminds/sprig/v3"
 	"github.com/google/go-jsonnet"
 	"github.com/google/go-jsonnet/ast"
 	"github.com/grafana/tanka/pkg/kubernetes/manifest"
@@ -71,7 +68,7 @@ func NativeFunc(h Helm) *jsonnet.NativeFunction {
 			}
 
 			// convert list to map
-			out, err := listAsMap(list, opts.NameFormat)
+			out, err := manifest.ListAsMap(list, opts.NameFormat)
 			if err != nil {
 				return nil, err
 			}
@@ -98,43 +95,3 @@ func parseOpts(data interface{}) (*JsonnetOpts, error) {
 
 	return &opts, nil
 }
-
-func listAsMap(list manifest.List, nameFormat string) (map[string]interface{}, error) {
-	if nameFormat == "" {
-		nameFormat = DefaultNameFormat
-	}
-
-	tmpl, err := template.New("").
-		Funcs(sprig.TxtFuncMap()).
-		Parse(nameFormat)
-	if err != nil {
-		return nil, fmt.Errorf("Parsing name format: %w", err)
-	}
-
-	out := make(map[string]interface{})
-	for _, m := range list {
-		var buf bytes.Buffer
-		if err := tmpl.Execute(&buf, m); err != nil {
-			return nil, err
-		}
-		name := buf.String()
-
-		if _, ok := out[name]; ok {
-			return nil, ErrorDuplicateName{name: name, format: nameFormat}
-		}
-		out[name] = map[string]interface{}(m)
-	}
-
-	return out, nil
-}
-
-// ErrorDuplicateName means two resources share the same name using the given
-// nameFormat.
-type ErrorDuplicateName struct {
-	name   string
-	format string
-}
-
-func (e ErrorDuplicateName) Error() string {
-	return fmt.Sprintf("Two resources share the same name '%s'. Please adapt the name template '%s'. See https://tanka.dev/helm#two-resources-share-the-same-name", e.name, e.format)
-}
diff --git a/pkg/helm/spec.go b/pkg/helm/spec.go
index 23b79923..ab1feabc 100644
--- a/pkg/helm/spec.go
+++ b/pkg/helm/spec.go
@@ -23,7 +23,7 @@ type Chartfile struct {
 	Version uint `json:"version"`
 
 	// Repositories to source from
-	Repositories []Repo `json:"repositories"`
+	Repositories Repos `json:"repositories"`
 
 	// Requires lists Charts expected to be present in the charts folder
 	Requires Requirements `json:"requires"`
@@ -43,6 +43,19 @@ type Repo struct {
 	Password string `json:"password,omitempty"`
 }
 
+type Repos []Repo
+
+// Has reports whether 'repo' is already part of the repositories
+func (r Repos) Has(repo Repo) bool {
+	for _, x := range r {
+		if x == repo {
+			return true
+		}
+	}
+
+	return false
+}
+
 // Requirement describes a single required Helm Chart.
 // Both, Chart and Version are required
 type Requirement struct {
diff --git a/pkg/jsonnet/eval.go b/pkg/jsonnet/eval.go
index 2741c1a0..f3a0290a 100644
--- a/pkg/jsonnet/eval.go
+++ b/pkg/jsonnet/eval.go
@@ -29,7 +29,7 @@ type Opts struct {
 	ExtCode     InjectedCode
 	TLACode     InjectedCode
 	ImportPaths []string
-	EvalPattern string
+	EvalScript  string
 }
 
 // MakeVM returns a Jsonnet VM with some extensions of Tanka, including:
@@ -69,6 +69,7 @@ func EvaluateFile(jsonnetFile string, opts Opts) (string, error) {
 }
 
 // Evaluate renders the given jsonnet into a string
+// TODO: don't resolve jpath, this is ANONYMOUS AFTER ALL
 func Evaluate(path, data string, opts Opts) (string, error) {
 	jpath, _, _, err := jpath.Resolve(path)
 	if err != nil {
diff --git a/pkg/jsonnet/imports.go b/pkg/jsonnet/imports.go
index 0af133dd..2cadcca2 100644
--- a/pkg/jsonnet/imports.go
+++ b/pkg/jsonnet/imports.go
@@ -72,6 +72,9 @@ func TransitiveImports(dir string) ([]string, error) {
 
 	for i := range paths {
 		paths[i], _ = filepath.Rel(rootDir, paths[i])
+
+		// Normalize path separators for windows
+		paths[i] = filepath.ToSlash(paths[i])
 	}
 	sort.Strings(paths)
 
diff --git a/pkg/jsonnet/jpath/dirs.go b/pkg/jsonnet/jpath/dirs.go
new file mode 100644
index 00000000..decab66d
--- /dev/null
+++ b/pkg/jsonnet/jpath/dirs.go
@@ -0,0 +1,125 @@
+package jpath
+
+import (
+	"io/ioutil"
+	"os"
+	"path/filepath"
+	"runtime"
+)
+
+// Dirs returns the project-root (root) and environment directory (base)
+func Dirs(path string) (root string, base string, err error) {
+	root, err = FindRoot(path)
+	if err != nil {
+		return "", "", err
+	}
+
+	base, err = FindBase(path, root)
+	if err != nil {
+		return root, "", err
+	}
+
+	return root, base, err
+}
+
+// FindRoot returns the absolute path of the project root, being the directory
+// that directly holds `tkrc.yaml` if it exists, otherwise the directory that
+// directly holds `jsonnetfile.json`
+func FindRoot(path string) (dir string, err error) {
+	start, err := FsDir(path)
+	if err != nil {
+		return "", err
+	}
+
+	// root path based on os
+	stop := "/"
+	if runtime.GOOS == "windows" {
+		stop = filepath.VolumeName(start) + "\\"
+	}
+
+	// try tkrc.yaml first
+	root, err := FindParentFile("tkrc.yaml", start, stop)
+	if err == nil {
+		return root, nil
+	}
+
+	// otherwise use jsonnetfile.json
+	root, err = FindParentFile("jsonnetfile.json", start, stop)
+	if _, ok := err.(ErrorFileNotFound); ok {
+		return "", ErrorNoRoot
+	} else if err != nil {
+		return "", err
+	}
+
+	return root, nil
+}
+
+// FindBase returns the absolute path of the environments base directory, the
+// one which directly holds the entrypoint file.
+func FindBase(path string, root string) (string, error) {
+	dir, err := FsDir(path)
+	if err != nil {
+		return "", err
+	}
+
+	filename, err := Filename(path)
+	if err != nil {
+		return "", err
+	}
+
+	base, err := FindParentFile(filename, dir, root)
+
+	if _, ok := err.(ErrorFileNotFound); ok {
+		return "", ErrorNoBase{filename: filename}
+	} else if err != nil {
+		return "", err
+	}
+
+	return base, nil
+}
+
+// FindParentFile traverses the parent directory tree for the given `file`,
+// starting from `start` and ending in `stop`. If the file is not found an error is returned.
+func FindParentFile(file, start, stop string) (string, error) {
+	files, err := ioutil.ReadDir(start)
+	if err != nil {
+		return "", err
+	}
+
+	if dirContainsFile(files, file) {
+		return start, nil
+	} else if start == stop {
+		return "", ErrorFileNotFound{file}
+	}
+	return FindParentFile(file, filepath.Dir(start), stop)
+}
+
+// dirContainsFile returns whether a file is included in a directory.
+func dirContainsFile(files []os.FileInfo, filename string) bool {
+	for _, f := range files {
+		if f.Name() == filename {
+			return true
+		}
+	}
+	return false
+}
+
+// FsDir returns the most inner directory of path, as reported by the local
+// filesystem
+func FsDir(path string) (string, error) {
+	path, err := filepath.Abs(path)
+	if err != nil {
+		return "", err
+	}
+
+	fi, err := os.Stat(path)
+	if err != nil {
+		return "", err
+	}
+
+	if fi.IsDir() {
+		return path, nil
+	}
+
+	return filepath.Dir(path), nil
+}
diff --git a/pkg/jsonnet/jpath/errors.go b/pkg/jsonnet/jpath/errors.go
new file mode 100644
index 00000000..a75ee7b5
--- /dev/null
+++ b/pkg/jsonnet/jpath/errors.go
@@ -0,0 +1,31 @@
+package jpath
+
+import (
+	"errors"
+	"fmt"
+)
+
+// ErrorNoRoot means no rootDir was found in the parent directories
+var ErrorNoRoot = errors.New(`Unable to identify the project root.
+Tried to find 'tkrc.yaml' or 'jsonnetfile.json' in the parent directories.
+Please refer to https://tanka.dev/directory-structure for more information`)
+
+// ErrorNoBase means no baseDir was found in the parent directories
+type ErrorNoBase struct {
+	filename string
+}
+
+func (e ErrorNoBase) Error() string {
+	return fmt.Sprintf(`Unable to identify the environments base directory.
+Tried to find '%s' in the parent directories.
+Please refer to https://tanka.dev/directory-structure for more information`, e.filename)
+}
+
+// ErrorFileNotFound means that the searched file was not found
+type ErrorFileNotFound struct {
+	filename string
+}
+
+func (e ErrorFileNotFound) Error() string {
+	return e.filename + " not found"
+}
diff --git a/pkg/jsonnet/jpath/jpath.go b/pkg/jsonnet/jpath/jpath.go
index eb606995..d0372dc2 100644
--- a/pkg/jsonnet/jpath/jpath.go
+++ b/pkg/jsonnet/jpath/jpath.go
@@ -1,32 +1,12 @@
 package jpath
 
 import (
-	"errors"
-	"io/ioutil"
 	"os"
 	"path/filepath"
-	"runtime"
 )
 
 const DEFAULT_ENTRYPOINT = "main.jsonnet"
 
-var (
-	// ErrorNoRoot means no rootDir was found in the parents
-	ErrorNoRoot = errors.New("could not locate a tkrc.yaml or jsonnetfile.json in the parent directories, which is required to identify the project root.\nRefer to https://tanka.dev/directory-structure for more information")
-
-	// ErrorNoBase means no baseDir was found in the parents
-	ErrorNoBase = errors.New("could not locate entrypoint (usually main.jsonnet) in the parent directories, which is required as the entrypoint for the evaluation.\nRefer to https://tanka.dev/directory-structure for more information")
-)
-
-// ErrorFileNotFound means that the searched file was not found
-type ErrorFileNotFound struct {
-	filename string
-}
-
-func (e ErrorFileNotFound) Error() string {
-	return e.filename + " not found"
-}
-
 // Resolve the given path and resolves the jPath around it. This means it:
 // - figures out the project root (the one with .jsonnetfile, vendor/ and lib/)
 // - figures out the environments base directory (usually the main.jsonnet)
@@ -35,24 +15,16 @@ func (e ErrorFileNotFound) Error() string {
 // This results in predictable imports, as it doesn't matter whether the user called
 // called the command further down tree or not. A little bit like git.
 func Resolve(path string) (jpath []string, base, root string, err error) {
-	entrypoint, err := Entrypoint(path)
+	root, err = FindRoot(path)
 	if err != nil {
 		return nil, "", "", err
 	}
 
-	root, err = FindRoot(filepath.Dir(entrypoint))
+	base, err = FindBase(path, root)
 	if err != nil {
 		return nil, "", "", err
 	}
 
-	base, err = FindParentFile(filepath.Base(entrypoint), filepath.Dir(entrypoint), root)
-	if err != nil {
-		if _, ok := err.(ErrorFileNotFound); ok {
-			return nil, "", "", ErrorNoBase
-		}
-		return nil, "", "", err
-	}
-
 	// The importer iterates through this list in reverse order
 	return []string{
 		filepath.Join(root, "vendor"),
@@ -62,75 +34,40 @@ func Resolve(path string) (jpath []string, base, root string, err error) {
 	}, base, root, nil
 }
 
-// FindRoot searches for a rootDir by the following criteria:
-// - tkrc.yaml is considered first, for a jb-independent way of marking the root
-// - if it is not present (default), jsonnetfile.json is used.
-func FindRoot(start string) (dir string, err error) {
-	// root path based on os
-	stop := "/"
-	if runtime.GOOS == "windows" {
-		stop = filepath.VolumeName(start) + "\\"
-	}
-
-	// try tkrc.yaml first
-	root, err := FindParentFile("tkrc.yaml", start, stop)
-	if err == nil {
-		return root, nil
-	}
-
-	// otherwise use jsonnetfile.json
-	root, err = FindParentFile("jsonnetfile.json", start, stop)
+// Filename returns the name of the entrypoint file.
+// It DOES NOT return an absolute path, only a plain name like "main.jsonnet"
+// To obtain an absolute path, use Entrypoint() instead.
+func Filename(path string) (string, error) {
+	fi, err := os.Stat(path)
 	if err != nil {
-		if _, ok := err.(ErrorFileNotFound); ok {
-			return "", ErrorNoRoot
-		}
 		return "", err
 	}
 
-	return root, nil
-}
-
-// FindParentFile traverses the parent directory tree for the given `file`,
-// starting from `start` and ending in `stop`. If the file is not found an error is returned.
-func FindParentFile(file, start, stop string) (string, error) {
-	files, err := ioutil.ReadDir(start)
-	if err != nil {
-		return "", err
+	if fi.IsDir() {
+		return DEFAULT_ENTRYPOINT, nil
 	}
 
-	if dirContainsFile(files, file) {
-		return start, nil
-	} else if start == stop {
-		return "", ErrorFileNotFound{file}
-	}
-	return FindParentFile(file, filepath.Dir(start), stop)
-}
+	return filepath.Base(fi.Name()), nil
 
-// dirContainsFile returns whether a file is included in a directory.
-func dirContainsFile(files []os.FileInfo, filename string) bool {
-	for _, f := range files {
-		if f.Name() == filename {
-			return true
-		}
-	}
-	return false
 }
 
+// Entrypoint returns the absolute path of the environments entrypoint file (the
+// one passed to jsonnet.EvaluateFile)
 func Entrypoint(path string) (string, error) {
-	filename := DEFAULT_ENTRYPOINT
-
-	entrypoint, err := filepath.Abs(path)
+	root, err := FindRoot(path)
 	if err != nil {
 		return "", err
 	}
 
-	stat, err := os.Stat(entrypoint)
+	base, err := FindBase(path, root)
 	if err != nil {
 		return "", err
 	}
-	if !stat.IsDir() {
-		return entrypoint, nil
+
+	filename, err := Filename(path)
+	if err != nil {
+		return "", err
 	}
 
-	return filepath.Join(entrypoint, filename), nil
+	return filepath.Join(base, filename), nil
 }
diff --git a/pkg/jsonnet/native/funcs.go b/pkg/jsonnet/native/funcs.go
index 336cef54..e6ef0f06 100644
--- a/pkg/jsonnet/native/funcs.go
+++ b/pkg/jsonnet/native/funcs.go
@@ -10,6 +10,7 @@ import (
 	jsonnet "github.com/google/go-jsonnet"
 	"github.com/google/go-jsonnet/ast"
 	"github.com/grafana/tanka/pkg/helm"
+	"github.com/grafana/tanka/pkg/kustomize"
 	"github.com/pkg/errors"
 	yaml "gopkg.in/yaml.v3"
 )
@@ -32,6 +33,7 @@ func Funcs() []*jsonnet.NativeFunction {
 		regexSubst(),
 
 		helm.NativeFunc(helm.ExecHelm{}),
+		kustomize.NativeFunc(kustomize.ExecKustomize{}),
 	}
 }
 
diff --git a/pkg/kubernetes/kubernetes.go b/pkg/kubernetes/kubernetes.go
index 2e4f07c5..783ec047 100644
--- a/pkg/kubernetes/kubernetes.go
+++ b/pkg/kubernetes/kubernetes.go
@@ -12,7 +12,7 @@ import (
 
 // Kubernetes exposes methods to work with the Kubernetes orchestrator
 type Kubernetes struct {
-	Env v1alpha1.Config
+	Env v1alpha1.Environment
 
 	// Client (kubectl)
 	ctl client.Client
@@ -26,7 +26,7 @@ type Kubernetes struct {
 type Differ func(manifest.List) (*string, error)
 
 // New creates a new Kubernetes with an initialized client
-func New(env v1alpha1.Config) (*Kubernetes, error) {
+func New(env v1alpha1.Environment) (*Kubernetes, error) {
 	// setup client
 	ctl, err := client.New(env.Spec.APIServer)
 	if err != nil {
diff --git a/pkg/kubernetes/manifest/errors.go b/pkg/kubernetes/manifest/errors.go
index 3afcf339..e1b6dbad 100644
--- a/pkg/kubernetes/manifest/errors.go
+++ b/pkg/kubernetes/manifest/errors.go
@@ -66,3 +66,14 @@ func (s SampleString) Indent(n int) string {
 	lines := strings.Split(s.String(), "\n")
 	return indent + strings.Join(lines, "\n"+indent)
 }
+
+// ErrorDuplicateName means two resources share the same name using the given
+// nameFormat.
+type ErrorDuplicateName struct {
+	name   string
+	format string
+}
+
+func (e ErrorDuplicateName) Error() string {
+	return fmt.Sprintf("Two resources share the same name '%s'. Please adapt the name template '%s'.", e.name, e.format)
+}
diff --git a/pkg/kubernetes/manifest/manifest.go b/pkg/kubernetes/manifest/manifest.go
index a62634c6..70a36aa4 100644
--- a/pkg/kubernetes/manifest/manifest.go
+++ b/pkg/kubernetes/manifest/manifest.go
@@ -4,7 +4,9 @@ import (
 	"bytes"
 	"encoding/json"
 	"fmt"
+	"text/template"
 
+	"github.com/Masterminds/sprig/v3"
 	"github.com/pkg/errors"
 	"github.com/stretchr/objx"
 	yaml "gopkg.in/yaml.v2"
@@ -270,3 +272,35 @@ func m2o(m interface{}) objx.Map {
 	}
 	return nil
 }
+
+// DefaultNameFormat to use when no nameFormat is supplied
+const DefaultNameFormat = `{{ print .kind "_" .metadata.name | snakecase }}`
+
+func ListAsMap(list List, nameFormat string) (map[string]interface{}, error) {
+	if nameFormat == "" {
+		nameFormat = DefaultNameFormat
+	}
+
+	tmpl, err := template.New("").
+		Funcs(sprig.TxtFuncMap()).
+		Parse(nameFormat)
+	if err != nil {
+		return nil, fmt.Errorf("Parsing name format: %w", err)
+	}
+
+	out := make(map[string]interface{})
+	for _, m := range list {
+		var buf bytes.Buffer
+		if err := tmpl.Execute(&buf, m); err != nil {
+			return nil, err
+		}
+		name := buf.String()
+
+		if _, ok := out[name]; ok {
+			return nil, ErrorDuplicateName{name: name, format: nameFormat}
+		}
+		out[name] = map[string]interface{}(m)
+	}
+
+	return out, nil
+}
diff --git a/pkg/kustomize/build.go b/pkg/kustomize/build.go
new file mode 100644
index 00000000..ca53aea1
--- /dev/null
+++ b/pkg/kustomize/build.go
@@ -0,0 +1,40 @@
+package kustomize
+
+import (
+	"bytes"
+	"io"
+	"os"
+
+	"github.com/grafana/tanka/pkg/kubernetes/manifest"
+	"github.com/pkg/errors"
+	yaml "gopkg.in/yaml.v3"
+)
+
+// Build expands a Kustomize into a regular manifest.List using the `kustomize
+// build` command
+func (k ExecKustomize) Build(path string) (manifest.List, error) {
+	cmd := k.cmd("build", path)
+	var buf bytes.Buffer
+	cmd.Stdout = &buf
+	cmd.Stderr = os.Stderr
+
+	if err := cmd.Run(); err != nil {
+		return nil, errors.Wrap(err, "Expanding Kustomize")
+	}
+
+	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 Kustomize output")
+		}
+
+		list = append(list, m)
+	}
+
+	return list, nil
+}
diff --git a/pkg/kustomize/jsonnet.go b/pkg/kustomize/jsonnet.go
new file mode 100644
index 00000000..c857ec0b
--- /dev/null
+++ b/pkg/kustomize/jsonnet.go
@@ -0,0 +1,86 @@
+package kustomize
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+	"path/filepath"
+
+	"github.com/google/go-jsonnet"
+	"github.com/google/go-jsonnet/ast"
+	"github.com/grafana/tanka/pkg/kubernetes/manifest"
+)
+
+// JsonnetOpts are additional properties the consumer of the native func might
+// pass.
+type JsonnetOpts struct {
+	// CalledFrom is the file that calls kustomizeBuild. This is used to find the
+	// vendored Kustomize relative to this file
+	CalledFrom string `json:"calledFrom"`
+	// NameBuild is used to create the keys in the resulting map
+	NameFormat string `json:"nameFormat"`
+}
+
+// NativeFunc returns a jsonnet native function that provides the same
+// functionality as `Kustomize.Build` of this package. Kustomize yamls are required to be
+// present on the local filesystem, at a relative location to the file that
+// calls `kustomize.build()` / `std.native('kustomizeBuild')`. This guarantees
+// hermeticity
+func NativeFunc(k Kustomize) *jsonnet.NativeFunction {
+	return &jsonnet.NativeFunction{
+		Name: "kustomizeBuild",
+		// Similar to `kustomize build {path}` where {path} is a local path
+		Params: ast.Identifiers{"path", "opts"},
+		Func: func(data []interface{}) (interface{}, error) {
+			path, ok := data[0].(string)
+			if !ok {
+				return nil, fmt.Errorf("Argument 'path' must be of 'string' type, got '%T' instead", data[0])
+			}
+
+			// TODO: validate data[1] actually follows the struct scheme
+			opts, err := parseOpts(data[1])
+			if err != nil {
+				return "", err
+			}
+
+			// resolve the Kustomize path relative to the caller
+			callerDir := filepath.Dir(opts.CalledFrom)
+			actual_path := filepath.Join(callerDir, path)
+			if _, err := os.Stat(actual_path); err != nil {
+				return nil, fmt.Errorf("kustomizeBuild: Failed to find kustomization at '%s': %s. See https://tanka.dev/kustomize#failed-to-find-kustomization", actual_path, err)
+			}
+
+			// render resources
+			list, err := k.Build(actual_path)
+			if err != nil {
+				return nil, err
+			}
+
+			// convert list to map
+			out, err := manifest.ListAsMap(list, opts.NameFormat)
+			if err != nil {
+				return nil, err
+			}
+
+			return out, nil
+		},
+	}
+}
+
+func parseOpts(data interface{}) (*JsonnetOpts, error) {
+	c, err := json.Marshal(data)
+	if err != nil {
+		return nil, err
+	}
+	var opts JsonnetOpts
+	if err := json.Unmarshal(c, &opts); err != nil {
+		return nil, err
+	}
+
+	// Kustomize paths are only allowed at relative paths. Use conf.CalledFrom to find the callers directory
+	if opts.CalledFrom == "" {
+		return nil, fmt.Errorf("kustomizeBuild: 'opts.calledFrom' is unset or empty.\nTanka needs this to find your Kustomize. See https://tanka.dev/kustomize#optscalledfrom-unset\n")
+	}
+
+	return &opts, nil
+}
diff --git a/pkg/kustomize/kustomize.go b/pkg/kustomize/kustomize.go
new file mode 100644
index 00000000..1d4c5bd5
--- /dev/null
+++ b/pkg/kustomize/kustomize.go
@@ -0,0 +1,39 @@
+package kustomize
+
+import (
+	"os"
+	"os/exec"
+
+	"github.com/grafana/tanka/pkg/kubernetes/manifest"
+)
+
+// Kustomize provides high level access to some Kustomize operations
+type Kustomize interface {
+	// Build returns the individual resources of a Kustomize
+	Build(path string) (manifest.List, error)
+}
+
+// ExecKustomize is a Kustomize implementation powered by the `kustomize`
+// command line utility
+type ExecKustomize struct{}
+
+// cmd returns a prepared exec.Cmd to use the `kustomize` binary
+func (e ExecKustomize) cmd(action string, args ...string) *exec.Cmd {
+	argv := []string{action}
+	argv = append(argv, args...)
+
+	cmd := kustomizeCmd(argv...)
+	cmd.Stderr = os.Stderr
+
+	return cmd
+}
+
+// kustomizeCmd returns a bare exec.Cmd pointed at the local kustomize binary
+func kustomizeCmd(args ...string) *exec.Cmd {
+	bin := "kustomize"
+	if env := os.Getenv("TANKA_KUSTOMIZE_PATH"); env != "" {
+		bin = env
+	}
+
+	return exec.Command(bin, args...)
+}
diff --git a/pkg/process/process.go b/pkg/process/process.go
index c14ae8b1..e65e473c 100644
--- a/pkg/process/process.go
+++ b/pkg/process/process.go
@@ -18,7 +18,13 @@ const (
 // - tanka.dev/** labels
 // - filtering
 // - best-effort sorting
-func Process(raw interface{}, cfg v1alpha1.Config, exprs Matchers) (manifest.List, error) {
+func Process(cfg v1alpha1.Environment, exprs Matchers) (manifest.List, error) {
+	raw := cfg.Data
+
+	if raw == nil {
+		return manifest.List{}, nil
+	}
+
 	// Scan for everything that looks like a Kubernetes object
 	extracted, err := Extract(raw)
 	if err != nil {
@@ -56,7 +62,7 @@ func Process(raw interface{}, cfg v1alpha1.Config, exprs Matchers) (manifest.Lis
 }
 
 // Label conditionally adds tanka.dev/** labels to each manifest in the List
-func Label(list manifest.List, cfg v1alpha1.Config) manifest.List {
+func Label(list manifest.List, cfg v1alpha1.Environment) manifest.List {
 	for i, m := range list {
 		// inject tanka.dev/environment label
 		if cfg.Spec.InjectLabels {
@@ -68,7 +74,7 @@ func Label(list manifest.List, cfg v1alpha1.Config) manifest.List {
 	return list
 }
 
-func ResourceDefaults(list manifest.List, cfg v1alpha1.Config) manifest.List {
+func ResourceDefaults(list manifest.List, cfg v1alpha1.Environment) manifest.List {
 	for i, m := range list {
 		for k, v := range cfg.Spec.ResourceDefaults.Annotations {
 			annotations := m.Metadata().Annotations()
diff --git a/pkg/spec/spec.go b/pkg/spec/spec.go
index 989c4895..42ae491f 100644
--- a/pkg/spec/spec.go
+++ b/pkg/spec/spec.go
@@ -18,9 +18,9 @@ const APIGroup = "tanka.dev"
 // Specfile is the filename for the environment config
 const Specfile = "spec.json"
 
-// ParseDir parses the given environments `spec.json` into a `v1alpha1.Config`
+// ParseDir parses the given environments `spec.json` into a `v1alpha1.Environment`
 // object with the name set to the directories name
-func ParseDir(baseDir, name string) (*v1alpha1.Config, error) {
+func ParseDir(baseDir, path string) (*v1alpha1.Environment, error) {
 	fi, err := os.Stat(baseDir)
 	if err != nil {
 		return nil, err
@@ -33,26 +33,29 @@ func ParseDir(baseDir, name string) (*v1alpha1.Config, error) {
 	if err != nil {
 		if os.IsNotExist(err) {
 			c := v1alpha1.New()
-			c.Metadata.Name = name
-			return c, ErrNoSpec{name}
+			c.Metadata.Name = path // legacy behavior
+			c.Metadata.Namespace = path
+			return c, ErrNoSpec{path}
 		}
 		return nil, err
 	}
 
-	return Parse(data, name)
+	c, err := Parse(data, path)
+	if c != nil {
+		// set the name field
+		c.Metadata.Name = path // legacy behavior
+	}
+
+	return c, err
 }
 
-// Parse parses the json `data` into a `v1alpha1.Config` object.
-// `name` is the name of the environment
-func Parse(data []byte, name string) (*v1alpha1.Config, error) {
+// Parse parses the json `data` into a `v1alpha1.Environment` object.
+func Parse(data []byte, namespace string) (*v1alpha1.Environment, error) {
 	config := v1alpha1.New()
 	if err := json.Unmarshal(data, config); err != nil {
 		return nil, errors.Wrap(err, "parsing spec.json")
 	}
 
-	// set the name field
-	config.Metadata.Name = name
-
 	if err := handleDeprecated(config, data); err != nil {
 		return config, err
 	}
@@ -62,10 +65,12 @@ func Parse(data []byte, name string) (*v1alpha1.Config, error) {
 		config.Spec.APIServer = "https://" + config.Spec.APIServer
 	}
 
+	config.Metadata.Namespace = namespace
+
 	return config, nil
 }
 
-func handleDeprecated(c *v1alpha1.Config, data []byte) error {
+func handleDeprecated(c *v1alpha1.Environment, data []byte) error {
 	var errDepr ErrDeprecated
 
 	var msi map[string]interface{}
diff --git a/pkg/spec/v1alpha1/config.go b/pkg/spec/v1alpha1/environment.go
similarity index 71%
rename from pkg/spec/v1alpha1/config.go
rename to pkg/spec/v1alpha1/environment.go
index 77ce0296..413676ed 100644
--- a/pkg/spec/v1alpha1/config.go
+++ b/pkg/spec/v1alpha1/environment.go
@@ -2,9 +2,9 @@ package v1alpha1
 
 import "strings"
 
-// New creates a new Config object with internal values already set
-func New() *Config {
-	c := Config{}
+// New creates a new Environment object with internal values already set
+func New() *Environment {
+	c := Environment{}
 
 	// constants
 	c.APIVersion = "tanka.dev/v1alpha1"
@@ -18,19 +18,20 @@ func New() *Config {
 	return &c
 }
 
-// Config holds the configuration variables for config version v1alpha1
-// ApiVersion and Kind are currently unused, this may change in the future.
-type Config struct {
-	APIVersion string   `json:"apiVersion"`
-	Kind       string   `json:"kind"`
-	Metadata   Metadata `json:"metadata"`
-	Spec       Spec     `json:"spec"`
+// Environment represents a set of resources in relation to its Kubernetes cluster
+type Environment struct {
+	APIVersion string      `json:"apiVersion"`
+	Kind       string      `json:"kind"`
+	Metadata   Metadata    `json:"metadata"`
+	Spec       Spec        `json:"spec"`
+	Data       interface{} `json:"data,omitempty"`
 }
 
 // Metadata is meant for humans and not parsed
 type Metadata struct {
-	Name   string            `json:"name,omitempty"`
-	Labels map[string]string `json:"labels,omitempty"`
+	Name      string            `json:"name,omitempty"`
+	Namespace string            `json:"namespace,omitempty"`
+	Labels    map[string]string `json:"labels,omitempty"`
 }
 
 // Has and Get make Metadata a simple wrapper for labels.Labels to use our map in their querier
diff --git a/pkg/tanka/environments.go b/pkg/tanka/environments.go
new file mode 100644
index 00000000..a9c9c347
--- /dev/null
+++ b/pkg/tanka/environments.go
@@ -0,0 +1,148 @@
+package tanka
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"sync"
+
+	"k8s.io/apimachinery/pkg/labels"
+
+	"github.com/grafana/tanka/pkg/jsonnet/jpath"
+	"github.com/grafana/tanka/pkg/spec/v1alpha1"
+)
+
+const baseDirIndicator = "main.jsonnet"
+const parallel = 8
+
+// FindBaseDirs searches for possible environments
+func FindBaseDirs(workdir string) (dirs []string, err error) {
+	_, _, _, err = jpath.Resolve(workdir)
+	if err == jpath.ErrorNoRoot {
+		return nil, err
+	}
+
+	if err := filepath.Walk(workdir, func(path string, info os.FileInfo, err error) error {
+		if _, err := os.Stat(filepath.Join(path, baseDirIndicator)); err != nil {
+			// missing file, not a valid environment directory
+			return nil
+		}
+		dirs = append(dirs, path)
+		return nil
+	}); err != nil {
+		return nil, err
+	}
+	return dirs, nil
+}
+
+// FindEnvironments searches for actual environments
+// ignores directories if no environments are found
+func FindEnvironments(workdir string, selector labels.Selector) (envs []*v1alpha1.Environment, err error) {
+	dirs, err := FindBaseDirs(workdir)
+	if err != nil {
+		return nil, err
+	}
+	opts := ParseParallelOpts{
+		JsonnetOpts: JsonnetOpts{
+			EvalScript: EnvsOnlyEvalScript,
+		},
+		Selector: selector,
+	}
+	envs, err = ParseParallel(dirs, opts)
+
+	if err != nil {
+		switch err.(type) {
+		case ErrParseParallel:
+			// ignore ErrNoEnv errors
+			e := err.(ErrParseParallel)
+			var errors []error
+			for _, err := range e.errors {
+				switch err.(type) {
+				case ErrNoEnv:
+					continue
+				default:
+					errors = append(errors, err)
+				}
+			}
+			if len(errors) != 0 {
+				return nil, ErrParseParallel{errors: errors}
+			}
+		default:
+			return nil, err
+		}
+	}
+
+	return envs, nil
+}
+
+// ParseParallel evaluates multiple environments in parallel
+func ParseParallel(paths []string, opts ParseParallelOpts) (envs []*v1alpha1.Environment, err error) {
+	wg := sync.WaitGroup{}
+	envsChan := make(chan parseJob)
+	var allErrors []error
+
+	numParallel := parallel
+	if opts.Parallel > 0 {
+		numParallel = opts.Parallel
+	}
+	for i := 0; i < numParallel; i++ {
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			errs := parseWorker(envsChan)
+			if errs != nil {
+				allErrors = append(allErrors, errs...)
+			}
+		}()
+	}
+
+	results := make([]*v1alpha1.Environment, 0, len(paths))
+
+	for _, path := range paths {
+		env := &v1alpha1.Environment{}
+		results = append(results, env)
+		envsChan <- parseJob{
+			path: path,
+			opts: opts.JsonnetOpts,
+			env:  env,
+		}
+	}
+	close(envsChan)
+	wg.Wait()
+
+	for _, env := range results {
+		if env == nil {
+			continue
+		}
+		if opts.Selector == nil || opts.Selector.Empty() || opts.Selector.Matches(env.Metadata) {
+			envs = append(envs, env)
+		}
+	}
+
+	if len(allErrors) != 0 {
+		return envs, ErrParseParallel{errors: allErrors}
+	}
+
+	return envs, nil
+}
+
+type parseJob struct {
+	path string
+	opts JsonnetOpts
+	env  *v1alpha1.Environment
+}
+
+func parseWorker(envsChan <-chan parseJob) (errs []error) {
+	for req := range envsChan {
+		loaded, err := Load(req.path, Opts{JsonnetOpts: req.opts})
+		if err != nil {
+			errs = append(errs, fmt.Errorf("%s:\n %w", req.path, err))
+			continue
+		}
+		*req.env = *loaded.Env
+	}
+	if len(errs) != 0 {
+		return errs
+	}
+	return nil
+}
diff --git a/pkg/tanka/errors.go b/pkg/tanka/errors.go
new file mode 100644
index 00000000..5d0c72a4
--- /dev/null
+++ b/pkg/tanka/errors.go
@@ -0,0 +1,39 @@
+package tanka
+
+import (
+	"fmt"
+	"strings"
+)
+
+// ErrNoEnv means that the given jsonnet has no Environment object
+// This must not be fatal, some operations work without
+type ErrNoEnv struct {
+	path string
+}
+
+func (e ErrNoEnv) Error() string {
+	return fmt.Sprintf("unable to find an Environment in '%s'", e.path)
+}
+
+// ErrMultipleEnvs means that the given jsonnet has multiple Environment objects
+type ErrMultipleEnvs struct {
+	path  string
+	names []string
+}
+
+func (e ErrMultipleEnvs) Error() string {
+	return fmt.Sprintf("found multiple Environments (%s) in '%s'", strings.Join(e.names, ", "), e.path)
+}
+
+// ErrParseParallel is an array of errors collected while parsing environments in parallel
+type ErrParseParallel struct {
+	errors []error
+}
+
+func (e ErrParseParallel) Error() string {
+	returnErr := fmt.Sprintf("Unable to parse selected Environments:\n\n")
+	for _, err := range e.errors {
+		returnErr = fmt.Sprintf("%s- %s\n", returnErr, err.Error())
+	}
+	return returnErr
+}
diff --git a/pkg/tanka/evaluators.go b/pkg/tanka/evaluators.go
new file mode 100644
index 00000000..b8f14509
--- /dev/null
+++ b/pkg/tanka/evaluators.go
@@ -0,0 +1,85 @@
+package tanka
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/pkg/errors"
+
+	"github.com/grafana/tanka/pkg/jsonnet"
+	"github.com/grafana/tanka/pkg/jsonnet/jpath"
+)
+
+// EvalJsonnet evaluates the jsonnet environment at the given file system path
+func EvalJsonnet(path string, opts jsonnet.Opts) (raw string, err error) {
+	entrypoint, err := jpath.Entrypoint(path)
+	if err != nil {
+		return "", err
+	}
+
+	// evaluate Jsonnet
+	if opts.EvalScript != "" {
+		var tla []string
+		for k := range opts.TLACode {
+			tla = append(tla, k)
+		}
+		evalScript := fmt.Sprintf(`
+  local main = (import '%s');
+  %s
+`, entrypoint, opts.EvalScript)
+
+		if len(tla) != 0 {
+			tlaJoin := strings.Join(tla, ", ")
+			evalScript = fmt.Sprintf(`
+function(%s)
+  local main = (import '%s')(%s);
+  %s
+`, tlaJoin, entrypoint, tlaJoin, opts.EvalScript)
+		}
+
+		raw, err = jsonnet.Evaluate(path, evalScript, opts)
+		if err != nil {
+			return "", errors.Wrap(err, "evaluating jsonnet")
+		}
+		return raw, nil
+	}
+
+	raw, err = jsonnet.EvaluateFile(entrypoint, opts)
+	if err != nil {
+		return "", errors.Wrap(err, "evaluating jsonnet")
+	}
+	return raw, nil
+}
+
+const PatternEvalScript = "main.%s"
+
+// EnvsOnlyEvalScript finds the Environment object (without its .data object)
+const EnvsOnlyEvalScript = `
+local noDataEnv(object) =
+  std.prune(
+    if std.isObject(object)
+    then
+      if std.objectHas(object, 'apiVersion')
+         && std.objectHas(object, 'kind')
+      then
+        if object.kind == 'Environment'
+        then object { data:: {} }
+        else {}
+      else
+        std.mapWithKey(
+          function(key, obj)
+            noDataEnv(obj),
+          object
+        )
+    else if std.isArray(object)
+    then
+      std.map(
+        function(obj)
+          noDataEnv(obj),
+        object
+      )
+    else {}
+  );
+
+noDataEnv(main)
+`
diff --git a/pkg/tanka/export.go b/pkg/tanka/export.go
new file mode 100644
index 00000000..afecf8c9
--- /dev/null
+++ b/pkg/tanka/export.go
@@ -0,0 +1,199 @@
+package tanka
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"io"
+	"io/ioutil"
+	"os"
+	"path/filepath"
+	"strings"
+	"text/template"
+
+	"github.com/Masterminds/sprig/v3"
+
+	"github.com/grafana/tanka/pkg/kubernetes/manifest"
+	"github.com/grafana/tanka/pkg/process"
+)
+
+// BelRune is a string of the Ascii character BEL which made computers ring in ancient times
+// We use it as "magic" char for the subfolder creation as it is a non printable character and thereby will never be
+// in a valid filepath by accident. Only when we include it.
+const BelRune = string(rune(7))
+
+// When exporting manifests to files, it becomes increasingly hard to map manifests back to its environment, this file
+// can be used to map the files back to their environment. This is aimed to be used by CI/CD but can also be used for
+// debugging purposes.
+const manifestFile = "manifest.json"
+
+// ExportEnvOpts specify options on how to export environments
+type ExportEnvOpts struct {
+	// formatting the filename based on the exported Kubernetes manifest
+	Format string
+	// extension of the filename
+	Extension string
+	// merge export with existing directory
+	Merge bool
+	// optional: only export specified Kubernetes manifests
+	Targets []string
+	// optional: options for parsing Environments
+	ParseParallelOpts ParseParallelOpts
+}
+
+func ExportEnvironments(paths []string, to string, opts *ExportEnvOpts) error {
+	// Keep track of which file maps to which environment
+	fileToEnv := map[string]string{}
+
+	// dir must be empty
+	empty, err := dirEmpty(to)
+	if err != nil {
+		return fmt.Errorf("Checking target dir: %s", err)
+	}
+	if !empty && !opts.Merge {
+		return fmt.Errorf("Output dir `%s` not empty. Pass --merge to ignore this", to)
+	}
+
+	for _, path := range paths {
+		// select targets to export
+		filter, err := process.StrExps(opts.Targets...)
+		if err != nil {
+			return err
+		}
+
+		// get the manifests
+		loaded, err := Load(path, Opts{
+			Filters: filter,
+		})
+		if err != nil {
+			return err
+		}
+
+		env := loaded.Env
+		res := loaded.Resources
+
+		// create raw manifest version of env for templating
+		env.Data = nil
+		raw, err := json.Marshal(env)
+		if err != nil {
+			return err
+		}
+		var menv manifest.Manifest
+		if err := json.Unmarshal(raw, &menv); err != nil {
+			return err
+		}
+
+		// create template
+		manifestTemplate, err := createTemplate(opts.Format, menv)
+		if err != nil {
+			return fmt.Errorf("Parsing format: %s", err)
+		}
+
+		// write each to a file
+		for _, m := range res {
+			// apply template
+			name, err := applyTemplate(manifestTemplate, m)
+			if err != nil {
+				return fmt.Errorf("executing name template: %w", err)
+			}
+
+			// Create all subfolders in path
+			relpath := name + "." + opts.Extension
+			path := filepath.Join(to, relpath)
+
+			fileToEnv[relpath] = env.Metadata.Namespace
+
+			// Abort if already exists
+			if exists, err := fileExists(path); err != nil {
+				return err
+			} else if exists {
+				return fmt.Errorf("File '%s' already exists. Aborting", path)
+			}
+
+			// Write manifest
+			data := m.String()
+			if err := writeExportFile(path, []byte(data)); err != nil {
+				return err
+			}
+		}
+	}
+
+	// Write manifest file
+	if len(fileToEnv) != 0 {
+		data, err := json.MarshalIndent(fileToEnv, "", "    ")
+		if err != nil {
+			return err
+		}
+		path := filepath.Join(to, manifestFile)
+		if err := writeExportFile(path, data); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+func fileExists(name string) (bool, error) {
+	_, err := os.Stat(name)
+	if os.IsNotExist(err) {
+		return false, nil
+	}
+	if err != nil {
+		return false, err
+	}
+	return true, nil
+}
+
+func dirEmpty(dir string) (bool, error) {
+	f, err := os.Open(dir)
+	if os.IsNotExist(err) {
+		return true, os.MkdirAll(dir, os.ModePerm)
+	} else if err != nil {
+		return false, err
+	}
+	defer f.Close()
+
+	_, err = f.Readdirnames(1)
+	if err == io.EOF {
+		return true, nil
+	}
+	return false, err
+}
+
+func writeExportFile(path string, data []byte) error {
+	if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
+		return fmt.Errorf("creating filepath '%s': %s", filepath.Dir(path), err)
+	}
+
+	return ioutil.WriteFile(path, data, 0644)
+}
+
+func createTemplate(format string, env manifest.Manifest) (*template.Template, error) {
+	// Replace all os.path separators in string with BelRune for creating subfolders
+	replaceFormat := strings.Replace(format, string(os.PathSeparator), BelRune, -1)
+
+	envMap := template.FuncMap{"env": func() manifest.Manifest { return env }}
+
+	template, err := template.New("").
+		Funcs(sprig.TxtFuncMap()). // register Masterminds/sprig
+		Funcs(envMap).             // register environment mapping
+		Parse(replaceFormat)       // parse template
+	if err != nil {
+		return nil, err
+	}
+	return template, nil
+}
+
+func applyTemplate(template *template.Template, m manifest.Manifest) (path string, err error) {
+	buf := bytes.Buffer{}
+	if err := template.Execute(&buf, m); err != nil {
+		return "", err
+	}
+
+	// Replace all os.path separators in string in order to not accidentally create subfolders
+	path = strings.Replace(buf.String(), string(os.PathSeparator), "-", -1)
+	// Replace the BEL character inserted with a path separator again in order to create a subfolder
+	path = strings.Replace(path, BelRune, string(os.PathSeparator), -1)
+
+	return path, nil
+}
diff --git a/pkg/tanka/format.go b/pkg/tanka/format.go
index 62fbc8fa..5945ad32 100644
--- a/pkg/tanka/format.go
+++ b/pkg/tanka/format.go
@@ -104,7 +104,10 @@ func findFiles(target string, excludes []glob.Glob) ([]string, error) {
 
 	// godirwalk is faster than filepath.Walk, 'cause no os.Stat required
 	err = godirwalk.Walk(target, &godirwalk.Options{
-		Callback: func(path string, de *godirwalk.Dirent) error {
+		Callback: func(rawPath string, de *godirwalk.Dirent) error {
+			// Normalize slashes for Windows
+			path := filepath.ToSlash(rawPath)
+
 			if de.IsDir() {
 				return nil
 			}
diff --git a/pkg/tanka/inline.go b/pkg/tanka/inline.go
new file mode 100644
index 00000000..e6953fee
--- /dev/null
+++ b/pkg/tanka/inline.go
@@ -0,0 +1,92 @@
+package tanka
+
+import (
+	"encoding/json"
+	"fmt"
+	"path/filepath"
+
+	"github.com/grafana/tanka/pkg/jsonnet/jpath"
+	"github.com/grafana/tanka/pkg/kubernetes/manifest"
+	"github.com/grafana/tanka/pkg/process"
+	"github.com/grafana/tanka/pkg/spec"
+	"github.com/grafana/tanka/pkg/spec/v1alpha1"
+)
+
+// InlineLoader loads an environment that is specified inline from within
+// Jsonnet. The Jsonnet output is expected to hold a tanka.dev/Environment type,
+// Kubernetes resources are expected at the `data` key of this very type
+type InlineLoader struct{}
+
+func (i *InlineLoader) Load(path string, opts JsonnetOpts) (*v1alpha1.Environment, error) {
+	raw, err := EvalJsonnet(path, opts)
+	if err != nil {
+		return nil, err
+	}
+
+	var data interface{}
+	if err := json.Unmarshal([]byte(raw), &data); err != nil {
+		return nil, err
+	}
+
+	envs, err := extractEnvs(data)
+	if err != nil {
+		return nil, err
+	}
+
+	if len(envs) > 1 {
+		names := make([]string, 0, len(envs))
+		for _, e := range envs {
+			names = append(names, e.Metadata().Name())
+		}
+		return nil, ErrMultipleEnvs{path, names}
+	}
+
+	if len(envs) == 0 {
+		return nil, fmt.Errorf("Found no environments in '%s'", path)
+	}
+
+	root, base, err := jpath.Dirs(path)
+	if err != nil {
+		return nil, err
+	}
+
+	name, err := filepath.Rel(root, base)
+	if err != nil {
+		return nil, err
+	}
+
+	// TODO: Re-serializing the entire env here. This is horribly inefficient
+	envData, err := json.Marshal(envs[0])
+	if err != nil {
+		return nil, err
+	}
+
+	env, err := spec.Parse(envData, name)
+	if err != nil {
+		return nil, err
+	}
+
+	return env, nil
+}
+
+// extractEnvs filters out any Environment manifests
+func extractEnvs(data interface{}) (manifest.List, error) {
+	// Scan for everything that looks like a Kubernetes object
+	extracted, err := process.Extract(data)
+	if err != nil {
+		return nil, err
+	}
+
+	// Unwrap *List types
+	if err := process.Unwrap(extracted); err != nil {
+		return nil, err
+	}
+
+	out := make(manifest.List, 0, len(extracted))
+	for _, m := range extracted {
+		out = append(out, m)
+	}
+
+	// Extract only object of Kind: Environment
+	return process.Filter(out, process.MustStrExps("Environment/.*")), nil
+}
diff --git a/pkg/tanka/load.go b/pkg/tanka/load.go
new file mode 100644
index 00000000..3d0feebe
--- /dev/null
+++ b/pkg/tanka/load.go
@@ -0,0 +1,93 @@
+package tanka
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+
+	"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/process"
+	"github.com/grafana/tanka/pkg/spec"
+	"github.com/grafana/tanka/pkg/spec/v1alpha1"
+	"github.com/pkg/errors"
+)
+
+// Load loads the Environment at `path`. It automatically detects whether to
+// load inline or statically
+func Load(path string, opts Opts) (*LoadResult, error) {
+	_, base, err := jpath.Dirs(path)
+	if err != nil {
+		return nil, err
+	}
+
+	loader, err := detectLoader(base)
+	if err != nil {
+		return nil, err
+	}
+
+	env, err := loader.Load(path, opts.JsonnetOpts)
+	if err != nil {
+		return nil, err
+	}
+
+	if err := checkVersion(env.Spec.ExpectVersions.Tanka); err != nil {
+		return nil, err
+	}
+
+	processed, err := process.Process(*env, opts.Filters)
+	if err != nil {
+		return nil, err
+	}
+
+	return &LoadResult{Env: env, Resources: processed}, nil
+}
+
+// detectLoader detects whether the environment is inline or static and picks
+// the approriate loader
+func detectLoader(base string) (Loader, error) {
+	// check if spec.json exists
+	_, err := os.Stat(filepath.Join(base, spec.Specfile))
+	if os.IsNotExist(err) {
+		return &InlineLoader{}, nil
+	} else if err != nil {
+		return nil, err
+	}
+
+	return &StaticLoader{}, nil
+}
+
+// Loader is an abstraction over the process of loading Environments
+type Loader interface {
+	Load(path string, opts JsonnetOpts) (*v1alpha1.Environment, error)
+}
+
+type LoadResult struct {
+	Env       *v1alpha1.Environment
+	Resources manifest.List
+}
+
+func (l LoadResult) Connect() (*kubernetes.Kubernetes, error) {
+	env := *l.Env
+
+	// check env is complete
+	s := ""
+	if env.Spec.APIServer == "" {
+		s += "  * spec.apiServer: No Kubernetes cluster endpoint specified"
+	}
+	if env.Spec.Namespace == "" {
+		s += "  * spec.namespace: Default namespace missing"
+	}
+	if s != "" {
+		return nil, fmt.Errorf("Your Environment's spec.json seems incomplete:\n%s\n\nPlease see https://tanka.dev/config for reference", s)
+	}
+
+	// connect client
+	kube, err := kubernetes.New(env)
+	if err != nil {
+		return nil, errors.Wrap(err, "connecting to Kubernetes")
+	}
+
+	return kube, nil
+}
diff --git a/pkg/tanka/parse.go b/pkg/tanka/parse.go
deleted file mode 100644
index f408d9ab..00000000
--- a/pkg/tanka/parse.go
+++ /dev/null
@@ -1,191 +0,0 @@
-package tanka
-
-import (
-	"encoding/json"
-	"fmt"
-	"log"
-	"path/filepath"
-
-	"github.com/Masterminds/semver"
-	"github.com/pkg/errors"
-
-	"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/process"
-	"github.com/grafana/tanka/pkg/spec"
-	"github.com/grafana/tanka/pkg/spec/v1alpha1"
-)
-
-// DEFAULT_DEV_VERSION is the placeholder version used when no actual semver is
-// provided using ldflags
-const DEFAULT_DEV_VERSION = "dev"
-
-// CURRENT_VERSION is the current version of the running Tanka code
-var CURRENT_VERSION = DEFAULT_DEV_VERSION
-
-// loaded is the final result of all processing stages:
-// 1. jpath.Resolve: Consruct import paths
-// 2. parseSpec: load spec.json
-// 3. evalJsonnet: evaluate Jsonnet to JSON
-// 4. process.Process: post-processing
-//
-// Also connect() is provided to connect to the cluster for live operations
-type loaded struct {
-	Env       *v1alpha1.Config
-	Resources manifest.List
-}
-
-// connect opens a connection to the backing Kubernetes cluster.
-func (p *loaded) connect() (*kubernetes.Kubernetes, error) {
-	env := *p.Env
-
-	// check env is complete
-	s := ""
-	if env.Spec.APIServer == "" {
-		s += "  * spec.apiServer: No Kubernetes cluster endpoint specified"
-	}
-	if env.Spec.Namespace == "" {
-		s += "  * spec.namespace: Default namespace missing"
-	}
-	if s != "" {
-		return nil, fmt.Errorf("Your Environment's spec.json seems incomplete:\n%s\n\nPlease see https://tanka.dev/config for reference", s)
-	}
-
-	// connect client
-	kube, err := kubernetes.New(env)
-	if err != nil {
-		return nil, errors.Wrap(err, "connecting to Kubernetes")
-	}
-
-	return kube, nil
-}
-
-// load runs all processing stages described at the Processed type
-func load(path string, opts Opts) (*loaded, error) {
-	raw, env, err := eval(path, opts.JsonnetOpts)
-	if err != nil {
-		return nil, err
-	}
-
-	if err := checkVersion(env.Spec.ExpectVersions.Tanka); err != nil {
-		return nil, err
-	}
-
-	rec, err := process.Process(raw, *env, opts.Filters)
-	if err != nil {
-		return nil, err
-	}
-
-	return &loaded{
-		Resources: rec,
-		Env:       env,
-	}, nil
-}
-
-// eval runs all processing stages describe at the Processed type apart from
-// post-processing, thus returning the raw Jsonnet result.
-func eval(path string, opts jsonnet.Opts) (raw interface{}, env *v1alpha1.Config, err error) {
-	env, err = parseSpec(path)
-	if err != nil {
-		return nil, nil, err
-	}
-
-	raw, err = evalJsonnet(path, env, opts)
-	if err != nil {
-		return nil, nil, errors.Wrap(err, "evaluating jsonnet")
-	}
-
-	return raw, env, nil
-}
-
-// parseEnv parses the `spec.json` of the environment and returns a
-// *kubernetes.Kubernetes from it
-func parseSpec(path string) (*v1alpha1.Config, error) {
-	_, baseDir, rootDir, err := jpath.Resolve(path)
-	if err != nil {
-		return nil, errors.Wrap(err, "resolving jpath")
-	}
-
-	// name of the environment: relative path from rootDir
-	name, _ := filepath.Rel(rootDir, baseDir)
-
-	config, err := spec.ParseDir(baseDir, name)
-	if err != nil {
-		switch err.(type) {
-		// the config includes deprecated fields
-		case spec.ErrDeprecated:
-			log.Println(err)
-		// spec.json missing. we can still work with the default value
-		case spec.ErrNoSpec:
-			return config, nil
-		// some other error
-		default:
-			return nil, errors.Wrap(err, "reading spec.json")
-		}
-	}
-
-	return config, nil
-}
-
-// evalJsonnet evaluates the jsonnet environment at the given path
-func evalJsonnet(path 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))
-
-	// evaluate Jsonnet
-	var raw string
-	entrypoint, err := jpath.Entrypoint(path)
-	if err != nil {
-		return nil, err
-	}
-
-	if opts.EvalPattern != "" {
-		evalScript := fmt.Sprintf("(import '%s').%s", entrypoint, opts.EvalPattern)
-		raw, err = jsonnet.Evaluate(entrypoint, evalScript, opts)
-		if err != nil {
-			return nil, err
-		}
-	} else {
-		raw, err = jsonnet.EvaluateFile(entrypoint, opts)
-		if err != nil {
-			return nil, err
-		}
-	}
-	// parse result
-	var data interface{}
-	if err := json.Unmarshal([]byte(raw), &data); err != nil {
-		return nil, err
-	}
-	return data, nil
-}
-
-func checkVersion(constraint string) error {
-	if constraint == "" {
-		return nil
-	}
-	if CURRENT_VERSION == DEFAULT_DEV_VERSION {
-		return nil
-	}
-
-	c, err := semver.NewConstraint(constraint)
-	if err != nil {
-		return fmt.Errorf("Parsing version constraint: '%w'. Please check 'spec.expectVersions.tanka'", err)
-	}
-
-	v, err := semver.NewVersion(CURRENT_VERSION)
-	if err != nil {
-		return fmt.Errorf("'%s' is not a valid semantic version: '%w'.\nThis likely means your build of Tanka is broken, as this is a compile-time value. When in doubt, please raise an issue", CURRENT_VERSION, err)
-	}
-
-	if !c.Check(v) {
-		return fmt.Errorf("Current version '%s' does not satisfy the version required by the environment: '%s'. You likely need to use another version of Tanka", CURRENT_VERSION, constraint)
-	}
-
-	return nil
-}
diff --git a/pkg/tanka/prune.go b/pkg/tanka/prune.go
index c6178e7b..8bed2035 100644
--- a/pkg/tanka/prune.go
+++ b/pkg/tanka/prune.go
@@ -21,11 +21,11 @@ type PruneOpts struct {
 // Jsonnet. It uses the `tanka.dev/environment` label to identify those.
 func Prune(baseDir string, opts PruneOpts) error {
 	// parse jsonnet, init k8s client
-	p, err := load(baseDir, opts.Opts)
+	p, err := Load(baseDir, opts.Opts)
 	if err != nil {
 		return err
 	}
-	kube, err := p.connect()
+	kube, err := p.Connect()
 	if err != nil {
 		return err
 	}
diff --git a/pkg/tanka/static.go b/pkg/tanka/static.go
new file mode 100644
index 00000000..804a276c
--- /dev/null
+++ b/pkg/tanka/static.go
@@ -0,0 +1,65 @@
+package tanka
+
+import (
+	"encoding/json"
+	"log"
+	"path/filepath"
+
+	"github.com/grafana/tanka/pkg/jsonnet/jpath"
+	"github.com/grafana/tanka/pkg/spec"
+	"github.com/grafana/tanka/pkg/spec/v1alpha1"
+)
+
+// StaticLoader loads an environment from a static file called `spec.json`.
+// Jsonnet is evaluated as normal
+type StaticLoader struct{}
+
+func (s StaticLoader) Load(path string, opts JsonnetOpts) (*v1alpha1.Environment, error) {
+	root, base, err := jpath.Dirs(path)
+	if err != nil {
+		return nil, err
+	}
+
+	config, err := parseStaticSpec(root, base)
+	if err != nil {
+		return nil, err
+	}
+
+	data, err := EvalJsonnet(path, opts)
+	if err != nil {
+		return nil, err
+	}
+
+	if err := json.Unmarshal([]byte(data), &config.Data); err != nil {
+		return nil, err
+	}
+
+	return config, nil
+}
+
+// parseStaticSpec parses the `spec.json` of the environment and returns a
+// *kubernetes.Kubernetes from it
+func parseStaticSpec(root, base string) (*v1alpha1.Environment, error) {
+	// name of the environment: relative path from rootDir
+	name, err := filepath.Rel(root, base)
+	if err != nil {
+		return nil, err
+	}
+
+	env, err := spec.ParseDir(base, name)
+	if err != nil {
+		switch err.(type) {
+		// the config includes deprecated fields
+		case spec.ErrDeprecated:
+			log.Println(err)
+		// spec.json missing. we can still work with the default value
+		case spec.ErrNoSpec:
+			return env, nil
+		// some other error
+		default:
+			return nil, err
+		}
+	}
+
+	return env, nil
+}
diff --git a/pkg/tanka/status.go b/pkg/tanka/status.go
index c9e22d89..eb8cdb65 100644
--- a/pkg/tanka/status.go
+++ b/pkg/tanka/status.go
@@ -10,18 +10,18 @@ import (
 // the individual resources of the desired state and also the status of the
 // client.
 type Info struct {
-	Env       *v1alpha1.Config
+	Env       *v1alpha1.Environment
 	Resources manifest.List
 	Client    client.Info
 }
 
 // Status returns information about the particular environment
 func Status(baseDir string, opts Opts) (*Info, error) {
-	r, err := load(baseDir, opts)
+	r, err := Load(baseDir, opts)
 	if err != nil {
 		return nil, err
 	}
-	kube, err := r.connect()
+	kube, err := r.Connect()
 	if err != nil {
 		return nil, err
 	}
diff --git a/pkg/tanka/tanka.go b/pkg/tanka/tanka.go
index a5bd6cd3..ab014462 100644
--- a/pkg/tanka/tanka.go
+++ b/pkg/tanka/tanka.go
@@ -5,10 +5,17 @@
 package tanka
 
 import (
+	"fmt"
+
+	"github.com/Masterminds/semver"
+	"k8s.io/apimachinery/pkg/labels"
+
 	"github.com/grafana/tanka/pkg/jsonnet"
 	"github.com/grafana/tanka/pkg/process"
 )
 
+type JsonnetOpts = jsonnet.Opts
+
 // Opts specify general, optional properties that apply to all actions
 type Opts struct {
 	JsonnetOpts
@@ -17,4 +24,40 @@ type Opts struct {
 	Filters process.Matchers
 }
 
-type JsonnetOpts = jsonnet.Opts
+// DEFAULT_DEV_VERSION is the placeholder version used when no actual semver is
+// provided using ldflags
+const DEFAULT_DEV_VERSION = "dev"
+
+// CURRENT_VERSION is the current version of the running Tanka code
+var CURRENT_VERSION = DEFAULT_DEV_VERSION
+
+func checkVersion(constraint string) error {
+	if constraint == "" {
+		return nil
+	}
+	if CURRENT_VERSION == DEFAULT_DEV_VERSION {
+		return nil
+	}
+
+	c, err := semver.NewConstraint(constraint)
+	if err != nil {
+		return fmt.Errorf("Parsing version constraint: '%w'. Please check 'spec.expectVersions.tanka'", err)
+	}
+
+	v, err := semver.NewVersion(CURRENT_VERSION)
+	if err != nil {
+		return fmt.Errorf("'%s' is not a valid semantic version: '%w'.\nThis likely means your build of Tanka is broken, as this is a compile-time value. When in doubt, please raise an issue", CURRENT_VERSION, err)
+	}
+
+	if !c.Check(v) {
+		return fmt.Errorf("Current version '%s' does not satisfy the version required by the environment: '%s'. You likely need to use another version of Tanka", CURRENT_VERSION, constraint)
+	}
+
+	return nil
+}
+
+type ParseParallelOpts struct {
+	JsonnetOpts JsonnetOpts
+	Selector    labels.Selector
+	Parallel    int
+}
diff --git a/pkg/tanka/workflow.go b/pkg/tanka/workflow.go
index bbe8f51e..ff0f9e52 100644
--- a/pkg/tanka/workflow.go
+++ b/pkg/tanka/workflow.go
@@ -1,6 +1,7 @@
 package tanka
 
 import (
+	"encoding/json"
 	"fmt"
 
 	"github.com/fatih/color"
@@ -30,11 +31,11 @@ type ApplyOpts struct {
 // the evaluated jsonnet to the Kubernetes cluster defined in the environments
 // `spec.json`.
 func Apply(baseDir string, opts ApplyOpts) error {
-	l, err := load(baseDir, opts.Opts)
+	l, err := Load(baseDir, opts.Opts)
 	if err != nil {
 		return err
 	}
-	kube, err := l.connect()
+	kube, err := l.Connect()
 	if err != nil {
 		return err
 	}
@@ -101,11 +102,11 @@ type DiffOpts struct {
 // 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, opts DiffOpts) (*string, error) {
-	l, err := load(baseDir, opts.Opts)
+	l, err := Load(baseDir, opts.Opts)
 	if err != nil {
 		return nil, err
 	}
-	kube, err := l.connect()
+	kube, err := l.Connect()
 	if err != nil {
 		return nil, err
 	}
@@ -134,11 +135,11 @@ type DeleteOpts struct {
 // the generated objects from the Kubernetes cluster defined in the environment's
 // `spec.json`.
 func Delete(baseDir string, opts DeleteOpts) error {
-	l, err := load(baseDir, opts.Opts)
+	l, err := Load(baseDir, opts.Opts)
 	if err != nil {
 		return err
 	}
-	kube, err := l.connect()
+	kube, err := l.Connect()
 	if err != nil {
 		return err
 	}
@@ -174,7 +175,7 @@ func Delete(baseDir string, opts DeleteOpts) error {
 // the list of Kubernetes objects.
 // Tip: use the `String()` function on the returned list to get the familiar yaml stream
 func Show(baseDir string, opts Opts) (manifest.List, error) {
-	l, err := load(baseDir, opts)
+	l, err := Load(baseDir, opts)
 	if err != nil {
 		return nil, err
 	}
@@ -183,10 +184,16 @@ func Show(baseDir string, opts Opts) (manifest.List, error) {
 }
 
 // Eval returns the raw evaluated Jsonnet output (without any transformations)
-func Eval(dir string, opts Opts) (raw interface{}, err error) {
-	r, _, err := eval(dir, opts.JsonnetOpts)
+func Eval(dir string, opts Opts) (interface{}, error) {
+	raw, err := EvalJsonnet(dir, opts.JsonnetOpts)
 	if err != nil {
 		return nil, err
 	}
-	return r, nil
+
+	var data interface{}
+	if err := json.Unmarshal([]byte(raw), &data); err != nil {
+		return nil, err
+	}
+
+	return data, nil
 }
diff --git a/pkg/term/alert.go b/pkg/term/alert.go
index f17e0983..d566262f 100644
--- a/pkg/term/alert.go
+++ b/pkg/term/alert.go
@@ -3,22 +3,42 @@ package term
 import (
 	"bufio"
 	"fmt"
+	"io"
 	"os"
 
 	"github.com/pkg/errors"
 )
 
+var ErrConfirmationFailed = errors.New("aborted by user")
+
 // Confirm asks the user for confirmation
 func Confirm(msg, approval string) error {
-	reader := bufio.NewReader(os.Stdin)
-	fmt.Println(msg)
-	fmt.Printf("Please type '%s' to confirm: ", approval)
-	read, err := reader.ReadString('\n')
+	return confirmFrom(os.Stdin, os.Stdout, msg, approval)
+}
+
+func confirmFrom(r io.Reader, w io.Writer, msg, approval string) error {
+	reader := bufio.NewScanner(r)
+	_, err := fmt.Fprintln(w, msg)
 	if err != nil {
-		return errors.Wrap(err, "reading from stdin")
+		return errors.Wrap(err, "writing to stdout")
 	}
-	if read != approval+"\n" {
-		return errors.New("aborted by user")
+
+	_, err = fmt.Fprintf(w, "Please type '%s' to confirm: ", approval)
+	if err != nil {
+		return errors.Wrap(err, "writing to stdout")
 	}
+
+	if !reader.Scan() {
+		if err := reader.Err(); err != nil {
+			return errors.Wrap(err, "reading from stdin")
+		}
+
+		return ErrConfirmationFailed
+	}
+
+	if reader.Text() != approval {
+		return ErrConfirmationFailed
+	}
+
 	return nil
 }


Test patch:
diff --git a/pkg/helm/jsonnet_test.go b/pkg/helm/jsonnet_test.go
deleted file mode 100644
index 4f74c25d..00000000
--- a/pkg/helm/jsonnet_test.go
+++ /dev/null
@@ -1,97 +0,0 @@
-package helm
-
-import (
-	"testing"
-
-	"github.com/google/go-cmp/cmp"
-	"github.com/grafana/tanka/pkg/kubernetes/manifest"
-)
-
-func TestListAsMap(t *testing.T) {
-	cases := []struct {
-		name       string
-		list       manifest.List
-		result     map[string]interface{}
-		nameFormat string
-		err        error
-	}{
-		{
-			name:       "simple",
-			nameFormat: "", // test it properly defaults to DefaultNameFormat
-			list: manifest.List{
-				configMap("foo"),
-				deployment("bar"),
-			},
-			result: map[string]interface{}{
-				"config_map_foo": configMap("foo"),
-				"deployment_bar": deployment("bar"),
-			},
-		},
-		{
-			name: "duplicate-default",
-			list: manifest.List{
-				secret("foo", map[string]interface{}{"id": 1}),
-				secret("foo", map[string]interface{}{"id": 2}),
-			},
-			err:    ErrorDuplicateName{name: "secret_foo", format: DefaultNameFormat},
-			result: nil, // expect no result
-		},
-		{
-			name:       "duplicate-custom",
-			nameFormat: `{{ print .metadata.name "_" .data.id }}`,
-			list: manifest.List{
-				secret("foo", map[string]interface{}{"id": 1}),
-				secret("foo", map[string]interface{}{"id": 2}),
-			},
-			result: map[string]interface{}{
-				"foo_1": secret("foo", map[string]interface{}{"id": 1}),
-				"foo_2": secret("foo", map[string]interface{}{"id": 2}),
-			},
-			err: nil, // expect no error
-		},
-	}
-
-	for _, c := range cases {
-		t.Run(c.name, func(t *testing.T) {
-			result, err := listAsMap(c.list, c.nameFormat)
-			if err != c.err {
-				t.Fatalf("err mismatch: want '%s' but got '%s'", c.err, err)
-			}
-
-			if diff := cmp.Diff(c.result, result); diff != "" {
-				t.Fatal(diff)
-			}
-		})
-	}
-}
-
-func configMap(name string) map[string]interface{} {
-	return map[string]interface{}{
-		"apiVersion": "v1",
-		"kind":       "ConfigMap",
-		"metadata":   map[string]interface{}{"name": name},
-		"data":       map[string]interface{}{},
-	}
-}
-
-func deployment(name string) map[string]interface{} {
-	return map[string]interface{}{
-		"apiVersion": "apps/v1",
-		"kind":       "Deployment",
-		"metadata":   map[string]interface{}{"name": name},
-		"spec":       map[string]interface{}{},
-	}
-}
-
-func secret(name string, data map[string]interface{}) map[string]interface{} {
-	if data == nil {
-		data = map[string]interface{}{}
-	}
-
-	return map[string]interface{}{
-		"apiVersion": "v1",
-		"kind":       "Secret",
-		"metadata":   map[string]interface{}{"name": name},
-		"data":       data,
-	}
-}
diff --git a/pkg/jsonnet/jpath/dirs_test.go b/pkg/jsonnet/jpath/dirs_test.go
index 5de11e00..f9075f89 100644
--- a/pkg/jsonnet/jpath/dirs_test.go
+++ b/pkg/jsonnet/jpath/dirs_test.go
@@ -11,6 +11,125 @@ import (
 	"github.com/stretchr/testify/require"
 )
 
+func TestDirs(t *testing.T) {
+	origDir, err := os.Getwd()
+	require.NoError(t, err)
+
+	cases := []struct {
+		name    string
+		data    string
+		workdir string
+		env     string
+
+		wantBase    string
+		wantRoot    string
+		wantRootErr error
+		wantBaseErr error
+	}{
+		// noBase
+		{
+			name: "noBase/empty",
+			data: "./testdata/noBase",
+			env:  "environments/empty",
+
+			wantRoot:    "/",
+			wantBaseErr: ErrorNoBase{"main.jsonnet"},
+		},
+		{
+			name: "noBase/filename",
+			data: "./testdata/noBase",
+			env:  "environments/filename",
+
+			wantRoot:    "/",
+			wantBaseErr: ErrorNoBase{"main.jsonnet"},
+		},
+		{
+			name: "noBase/noMain",
+			data: "./testdata/noBase",
+			env:  "environments/noMain",
+
+			wantRoot:    "/",
+			wantBaseErr: ErrorNoBase{"main.jsonnet"},
+		},
+
+		// noRoot
+		{
+			name: "noRoot",
+			data: "./testdata/noRoot",
+			env:  "environments/default",
+
+			wantRootErr: ErrorNoRoot,
+		},
+
+		// valid
+		{
+			name: "valid:relative",
+			data: "./testdata/valid",
+			env:  "environments/default",
+
+			wantRoot: "/",
+			wantBase: "environments/default",
+		},
+		{
+			name:    "valid:currentDir",
+			data:    "./testdata/valid",
+			env:     ".",
+			workdir: "environments/default",
+
+			wantRoot: "/",
+			wantBase: "environments/default",
+		},
+		{
+			name:    "valid:nested",
+			data:    "./testdata/valid",
+			env:     ".",
+			workdir: "environments/default/nestedDir",
+
+			wantRoot: "/",
+			wantBase: "environments/default",
+		},
+		{
+			name:    "valid:nested2",
+			data:    "./testdata/valid",
+			env:     "..",
+			workdir: "environments/default/nestedDir",
+
+			wantRoot: "/",
+			wantBase: "environments/default",
+		},
+	}
+
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			defer os.Chdir(origDir)
+
+			// go to testdata
+			require.NoError(t, os.Chdir(c.data))
+
+			tdRoot, err := os.Getwd()
+			require.NoError(t, err)
+
+			if c.workdir != "" {
+				require.NoError(t, os.Chdir(c.workdir))
+			}
+
+			root, err := FindRoot(c.env)
+			assert.Equal(t, c.wantRootErr, err)
+			if err == nil {
+				assert.Equal(t, filepath.Join(tdRoot, c.wantRoot), root)
+			} else {
+				return
+			}
+
+			base, err := FindBase(c.env, root)
+			assert.Equal(t, c.wantBaseErr, err)
+			if err == nil {
+				assert.Equal(t, filepath.Join(tdRoot, c.wantBase), base)
+			}
+		})
+	}
+}
+
 type scenario struct {
 	name        string
 	testdata    []string
@@ -37,7 +156,7 @@ func TestFindRoot(t *testing.T) {
 			name:        "missing-basePointer",
 			environment: "environments/default",
 			testdata:    []string{"jsonnetfile.json", "environments/default/"},
-			err:         ErrorNoBase,
+			err:         ErrorNoBase{filename: "main.jsonnet"},
 		},
 		// Scenario: Missing root pointerfile. We expect an ErrorNoRoot.
 		{
diff --git a/pkg/jsonnet/jpath/testdata/noBase/environments/empty/.gitkeep b/pkg/jsonnet/jpath/testdata/noBase/environments/empty/.gitkeep
new file mode 100644
index 00000000..e69de29b
diff --git a/pkg/jsonnet/jpath/testdata/noBase/environments/filename/custom.jsonnet b/pkg/jsonnet/jpath/testdata/noBase/environments/filename/custom.jsonnet
new file mode 100644
index 00000000..0967ef42
--- /dev/null
+++ b/pkg/jsonnet/jpath/testdata/noBase/environments/filename/custom.jsonnet
@@ -0,0 +1,1 @@
+{}
diff --git a/pkg/jsonnet/jpath/testdata/noBase/environments/filename/spec.json b/pkg/jsonnet/jpath/testdata/noBase/environments/filename/spec.json
new file mode 100644
index 00000000..c08726f1
--- /dev/null
+++ b/pkg/jsonnet/jpath/testdata/noBase/environments/filename/spec.json
@@ -0,0 +1,13 @@
+{
+  "apiVersion": "tanka.dev/v1alpha1",
+  "kind": "Environment",
+  "metadata": {
+    "name": "environments/filename"
+  },
+  "spec": {
+    "apiServer": "",
+    "namespace": "default",
+    "resourceDefaults": {},
+    "expectVersions": {}
+  }
+}
diff --git a/pkg/jsonnet/jpath/testdata/noBase/environments/noMain/spec.json b/pkg/jsonnet/jpath/testdata/noBase/environments/noMain/spec.json
new file mode 100644
index 00000000..44dfe5d1
--- /dev/null
+++ b/pkg/jsonnet/jpath/testdata/noBase/environments/noMain/spec.json
@@ -0,0 +1,13 @@
+{
+  "apiVersion": "tanka.dev/v1alpha1",
+  "kind": "Environment",
+  "metadata": {
+    "name": "environments/noMain"
+  },
+  "spec": {
+    "apiServer": "",
+    "namespace": "default",
+    "resourceDefaults": {},
+    "expectVersions": {}
+  }
+}
diff --git a/pkg/jsonnet/jpath/testdata/noBase/jsonnetfile.json b/pkg/jsonnet/jpath/testdata/noBase/jsonnetfile.json
new file mode 100644
index 00000000..9e26dfee
--- /dev/null
+++ b/pkg/jsonnet/jpath/testdata/noBase/jsonnetfile.json
@@ -0,0 +1,1 @@
+{}
\ No newline at end of file
diff --git a/pkg/jsonnet/jpath/testdata/noRoot/environments/default/main.jsonnet b/pkg/jsonnet/jpath/testdata/noRoot/environments/default/main.jsonnet
new file mode 100644
index 00000000..0967ef42
--- /dev/null
+++ b/pkg/jsonnet/jpath/testdata/noRoot/environments/default/main.jsonnet
@@ -0,0 +1,1 @@
+{}
diff --git a/pkg/jsonnet/jpath/testdata/noRoot/environments/default/spec.json b/pkg/jsonnet/jpath/testdata/noRoot/environments/default/spec.json
new file mode 100644
index 00000000..70a3ffb1
--- /dev/null
+++ b/pkg/jsonnet/jpath/testdata/noRoot/environments/default/spec.json
@@ -0,0 +1,13 @@
+{
+  "apiVersion": "tanka.dev/v1alpha1",
+  "kind": "Environment",
+  "metadata": {
+    "name": "environments/default"
+  },
+  "spec": {
+    "apiServer": "",
+    "namespace": "default",
+    "resourceDefaults": {},
+    "expectVersions": {}
+  }
+}
diff --git a/pkg/jsonnet/jpath/testdata/valid/environments/default/main.jsonnet b/pkg/jsonnet/jpath/testdata/valid/environments/default/main.jsonnet
new file mode 100644
index 00000000..3d36287a
--- /dev/null
+++ b/pkg/jsonnet/jpath/testdata/valid/environments/default/main.jsonnet
@@ -0,0 +1,6 @@
+{
+  apiVersion: 'v1',
+  kind: 'ConfigMap',
+  metadata: { name: 'myConfig' },
+  data: (import "nestedDir/file.jsonnet"),
+}
diff --git a/pkg/jsonnet/jpath/testdata/valid/environments/default/nestedDir/file.jsonnet b/pkg/jsonnet/jpath/testdata/valid/environments/default/nestedDir/file.jsonnet
new file mode 100644
index 00000000..f626ab03
--- /dev/null
+++ b/pkg/jsonnet/jpath/testdata/valid/environments/default/nestedDir/file.jsonnet
@@ -0,0 +1,3 @@
+{
+  "foo.yml": "foo: bar\nbar: baz"
+}
diff --git a/pkg/jsonnet/jpath/testdata/valid/environments/default/spec.json b/pkg/jsonnet/jpath/testdata/valid/environments/default/spec.json
new file mode 100644
index 00000000..70a3ffb1
--- /dev/null
+++ b/pkg/jsonnet/jpath/testdata/valid/environments/default/spec.json
@@ -0,0 +1,13 @@
+{
+  "apiVersion": "tanka.dev/v1alpha1",
+  "kind": "Environment",
+  "metadata": {
+    "name": "environments/default"
+  },
+  "spec": {
+    "apiServer": "",
+    "namespace": "default",
+    "resourceDefaults": {},
+    "expectVersions": {}
+  }
+}
diff --git a/pkg/jsonnet/jpath/testdata/valid/jsonnetfile.json b/pkg/jsonnet/jpath/testdata/valid/jsonnetfile.json
new file mode 100644
index 00000000..9e26dfee
--- /dev/null
+++ b/pkg/jsonnet/jpath/testdata/valid/jsonnetfile.json
@@ -0,0 +1,1 @@
+{}
\ No newline at end of file
diff --git a/pkg/kubernetes/manifest/manifest_test.go b/pkg/kubernetes/manifest/manifest_test.go
index fabd524e..f821f3f5 100644
--- a/pkg/kubernetes/manifest/manifest_test.go
+++ b/pkg/kubernetes/manifest/manifest_test.go
@@ -90,3 +90,92 @@ spec:
 		t.Error(s)
 	}
 }
+
+func TestListAsMap(t *testing.T) {
+	cases := []struct {
+		name       string
+		list       List
+		result     map[string]interface{}
+		nameFormat string
+		err        error
+	}{
+		{
+			name:       "simple",
+			nameFormat: "", // test it properly defaults to DefaultNameFormat
+			list: List{
+				configMap("foo"),
+				deployment("bar"),
+			},
+			result: map[string]interface{}{
+				"config_map_foo": configMap("foo"),
+				"deployment_bar": deployment("bar"),
+			},
+		},
+		{
+			name: "duplicate-default",
+			list: List{
+				secret("foo", map[string]interface{}{"id": 1}),
+				secret("foo", map[string]interface{}{"id": 2}),
+			},
+			err:    ErrorDuplicateName{name: "secret_foo", format: DefaultNameFormat},
+			result: nil, // expect no result
+		},
+		{
+			name:       "duplicate-custom",
+			nameFormat: `{{ print .metadata.name "_" .data.id }}`,
+			list: List{
+				secret("foo", map[string]interface{}{"id": 1}),
+				secret("foo", map[string]interface{}{"id": 2}),
+			},
+			result: map[string]interface{}{
+				"foo_1": secret("foo", map[string]interface{}{"id": 1}),
+				"foo_2": secret("foo", map[string]interface{}{"id": 2}),
+			},
+			err: nil, // expect no error
+		},
+	}
+
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			result, err := ListAsMap(c.list, c.nameFormat)
+			if err != c.err {
+				t.Fatalf("err mismatch: want '%s' but got '%s'", c.err, err)
+			}
+
+			if diff := cmp.Diff(c.result, result); diff != "" {
+				t.Fatal(diff)
+			}
+		})
+	}
+}
+
+func configMap(name string) map[string]interface{} {
+	return map[string]interface{}{
+		"apiVersion": "v1",
+		"kind":       "ConfigMap",
+		"metadata":   map[string]interface{}{"name": name},
+		"data":       map[string]interface{}{},
+	}
+}
+
+func deployment(name string) map[string]interface{} {
+	return map[string]interface{}{
+		"apiVersion": "apps/v1",
+		"kind":       "Deployment",
+		"metadata":   map[string]interface{}{"name": name},
+		"spec":       map[string]interface{}{},
+	}
+}
+
+func secret(name string, data map[string]interface{}) map[string]interface{} {
+	if data == nil {
+		data = map[string]interface{}{}
+	}
+
+	return map[string]interface{}{
+		"apiVersion": "v1",
+		"kind":       "Secret",
+		"metadata":   map[string]interface{}{"name": name},
+		"data":       data,
+	}
+}
diff --git a/pkg/process/process_test.go b/pkg/process/process_test.go
index cf046159..5faec223 100644
--- a/pkg/process/process_test.go
+++ b/pkg/process/process_test.go
@@ -109,18 +109,19 @@ func TestProcess(t *testing.T) {
 
 	for _, c := range tests {
 		t.Run(c.name, func(t *testing.T) {
-			config := v1alpha1.New()
-			config.Metadata.Name = "testdata"
-			config.Spec = c.spec
+			env := v1alpha1.New()
+			env.Metadata.Name = "testdata"
+			env.Spec = c.spec
+			env.Data = c.deep.(map[string]interface{})
 
-			if config.Spec.InjectLabels {
+			if env.Spec.InjectLabels {
 				for i, m := range c.flat {
-					m.Metadata().Labels()[LabelEnvironment] = config.Metadata.NameLabel()
+					m.Metadata().Labels()[LabelEnvironment] = env.Metadata.NameLabel()
 					c.flat[i] = m
 				}
 			}
 
-			got, err := Process(c.deep.(map[string]interface{}), *config, c.targets)
+			got, err := Process(*env, c.targets)
 			require.Equal(t, c.err, err)
 
 			Sort(c.flat)
@@ -142,7 +143,10 @@ func mapToList(ms map[string]manifest.Manifest) manifest.List {
 func TestProcessOrder(t *testing.T) {
 	got := make([]manifest.List, 10)
 	for i := 0; i < 10; i++ {
-		r, err := Process(testDataDeep().Deep.(map[string]interface{}), *v1alpha1.New(), nil)
+		env := v1alpha1.New()
+		env.Data = testDataDeep().Deep.(map[string]interface{})
+
+		r, err := Process(*env, nil)
 		require.NoError(t, err)
 		got[i] = r
 	}
diff --git a/pkg/process/resourceDefaults_test.go b/pkg/process/resourceDefaults_test.go
index bed46b58..5b327296 100644
--- a/pkg/process/resourceDefaults_test.go
+++ b/pkg/process/resourceDefaults_test.go
@@ -55,7 +55,7 @@ func TestResourceDefaults(t *testing.T) {
 
 	for _, c := range cases {
 		t.Run(c.name, func(t *testing.T) {
-			cfg := v1alpha1.Config{
+			cfg := v1alpha1.Environment{
 				Spec: v1alpha1.Spec{
 					ResourceDefaults: v1alpha1.ResourceDefaults{
 						Annotations: c.specAnnotations,
diff --git a/pkg/spec/depreciations_test.go b/pkg/spec/depreciations_test.go
index 88621db4..374afb60 100644
--- a/pkg/spec/depreciations_test.go
+++ b/pkg/spec/depreciations_test.go
@@ -14,6 +14,9 @@ import (
 func TestDeprecated(t *testing.T) {
 	data := []byte(`
 {
+	"metadata": {
+		"name": "test"
+	},
 	"spec": {
 		"namespace": "new"
 	},
diff --git a/pkg/tanka/format_test.go b/pkg/tanka/format_test.go
new file mode 100644
index 00000000..20e3b48f
--- /dev/null
+++ b/pkg/tanka/format_test.go
@@ -0,0 +1,30 @@
+package tanka
+
+import (
+	"testing"
+
+	"github.com/gobwas/glob"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestFormatFiles_IgnoresVendor(t *testing.T) {
+	var files []string
+	_, err := FormatFiles([]string{"./testdata/cases/format/"}, &FormatOpts{
+		Excludes: []glob.Glob{glob.MustCompile("**/vendor/**")},
+		OutFn: func(f, _ string) error {
+			files = append(files, f)
+			return nil
+		},
+
+		PrintNames: false,
+	})
+
+	require.NoError(t, err)
+	assert.Contains(t, files, "testdata/cases/format/a.jsonnet")
+	assert.Contains(t, files, "testdata/cases/format/b.libsonnet")
+	assert.Contains(t, files, "testdata/cases/format/foo/a.jsonnet")
+	assert.Contains(t, files, "testdata/cases/format/foo/b.libsonnet")
+	assert.NotContains(t, files, "testdata/cases/format/vendor/a.jsonnet")
+	assert.NotContains(t, files, "testdata/cases/format/vendor/b.libsonnet")
+}
diff --git a/pkg/tanka/load_test.go b/pkg/tanka/load_test.go
new file mode 100644
index 00000000..36084d61
--- /dev/null
+++ b/pkg/tanka/load_test.go
@@ -0,0 +1,121 @@
+package tanka
+
+import (
+	"testing"
+
+	"github.com/grafana/tanka/pkg/kubernetes/manifest"
+	"github.com/grafana/tanka/pkg/spec/v1alpha1"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestLoad(t *testing.T) {
+	cases := []struct {
+		name     string
+		baseDir  string
+		expected interface{}
+		env      *v1alpha1.Environment
+	}{
+		{
+			name:    "static",
+			baseDir: "./testdata/cases/withspecjson/",
+			expected: manifest.List{{
+				"apiVersion": "v1",
+				"kind":       "ConfigMap",
+				"metadata": map[string]interface{}{
+					"name":      "config",
+					"namespace": "withspec",
+				},
+			}},
+			env: &v1alpha1.Environment{
+				APIVersion: v1alpha1.New().APIVersion,
+				Kind:       v1alpha1.New().Kind,
+				Metadata: v1alpha1.Metadata{
+					Name:      "cases/withspecjson",
+					Namespace: "cases/withspecjson",
+					Labels:    v1alpha1.New().Metadata.Labels,
+				},
+				Spec: v1alpha1.Spec{
+					APIServer: "https://localhost",
+					Namespace: "withspec",
+				},
+				Data: map[string]interface{}{
+					"apiVersion": "v1",
+					"kind":       "ConfigMap",
+					"metadata":   map[string]interface{}{"name": "config", "namespace": "withspec"},
+				},
+			},
+		},
+		{
+			name:    "static-filename",
+			baseDir: "./testdata/cases/withspecjson/main.jsonnet",
+			expected: manifest.List{{
+				"apiVersion": "v1",
+				"kind":       "ConfigMap",
+				"metadata": map[string]interface{}{
+					"name":      "config",
+					"namespace": "withspec",
+				},
+			}},
+			env: &v1alpha1.Environment{
+				APIVersion: v1alpha1.New().APIVersion,
+				Kind:       v1alpha1.New().Kind,
+				Metadata: v1alpha1.Metadata{
+					Name:      "cases/withspecjson",
+					Namespace: "cases/withspecjson",
+					Labels:    v1alpha1.New().Metadata.Labels,
+				},
+				Spec: v1alpha1.Spec{
+					APIServer: "https://localhost",
+					Namespace: "withspec",
+				},
+				Data: map[string]interface{}{
+					"apiVersion": "v1",
+					"kind":       "ConfigMap",
+					"metadata":   map[string]interface{}{"name": "config", "namespace": "withspec"},
+				},
+			},
+		},
+
+		{
+			name:    "inline",
+			baseDir: "./testdata/cases/withenv/main.jsonnet",
+			expected: manifest.List{{
+				"apiVersion": "v1",
+				"kind":       "ConfigMap",
+				"metadata": map[string]interface{}{
+					"name":      "config",
+					"namespace": "withenv",
+				},
+			}},
+			env: &v1alpha1.Environment{
+				APIVersion: v1alpha1.New().APIVersion,
+				Kind:       v1alpha1.New().Kind,
+				Metadata: v1alpha1.Metadata{
+					Name:      "withenv",
+					Namespace: "cases/withenv",
+					Labels:    v1alpha1.New().Metadata.Labels,
+				},
+				Spec: v1alpha1.Spec{
+					APIServer: "https://localhost",
+					Namespace: "withenv",
+				},
+				Data: map[string]interface{}{
+					"apiVersion": "v1",
+					"kind":       "ConfigMap",
+					"metadata":   map[string]interface{}{"name": "config", "namespace": "withenv"},
+				},
+			},
+		},
+	}
+
+	for _, test := range cases {
+		t.Run(test.name, func(t *testing.T) {
+			l, err := Load(test.baseDir, Opts{})
+			require.NoError(t, err)
+
+			assert.Equal(t, test.expected, l.Resources)
+			assert.Equal(t, test.env, l.Env)
+		})
+	}
+}
diff --git a/pkg/tanka/parse_test.go b/pkg/tanka/parse_test.go
deleted file mode 100644
index 2715e0e0..00000000
--- a/pkg/tanka/parse_test.go
+++ /dev/null
@@ -1,42 +0,0 @@
-package tanka
-
-import (
-	"testing"
-
-	"github.com/grafana/tanka/pkg/jsonnet"
-	"github.com/grafana/tanka/pkg/spec/v1alpha1"
-	"github.com/stretchr/testify/assert"
-)
-
-func TestEvalJsonnet(t *testing.T) {
-	cases := []struct {
-		baseDir  string
-		expected interface{}
-	}{
-		{
-			baseDir: "./testdata/cases/array/",
-			expected: []interface{}{
-				[]interface{}{
-					map[string]interface{}{"testCase": "nestedArray[0][0]"},
-					map[string]interface{}{"testCase": "nestedArray[0][1]"},
-				},
-				[]interface{}{
-					map[string]interface{}{"testCase": "nestedArray[1][0]"},
-					map[string]interface{}{"testCase": "nestedArray[1][1]"},
-				},
-			},
-		},
-		{
-			baseDir: "./testdata/cases/object/",
-			expected: map[string]interface{}{
-				"testCase": "object",
-			},
-		},
-	}
-
-	for _, test := range cases {
-		data, e := evalJsonnet(test.baseDir, v1alpha1.New(), jsonnet.Opts{})
-		assert.NoError(t, e)
-		assert.Equal(t, test.expected, data)
-	}
-}
diff --git a/pkg/tanka/testdata/cases/format/a.jsonnet b/pkg/tanka/testdata/cases/format/a.jsonnet
new file mode 100644
index 00000000..0967ef42
--- /dev/null
+++ b/pkg/tanka/testdata/cases/format/a.jsonnet
@@ -0,0 +1,1 @@
+{}
diff --git a/pkg/tanka/testdata/cases/format/b.libsonnet b/pkg/tanka/testdata/cases/format/b.libsonnet
new file mode 100644
index 00000000..0967ef42
--- /dev/null
+++ b/pkg/tanka/testdata/cases/format/b.libsonnet
@@ -0,0 +1,1 @@
+{}
diff --git a/pkg/tanka/testdata/cases/format/foo/a.jsonnet b/pkg/tanka/testdata/cases/format/foo/a.jsonnet
new file mode 100644
index 00000000..0967ef42
--- /dev/null
+++ b/pkg/tanka/testdata/cases/format/foo/a.jsonnet
@@ -0,0 +1,1 @@
+{}
diff --git a/pkg/tanka/testdata/cases/format/foo/b.libsonnet b/pkg/tanka/testdata/cases/format/foo/b.libsonnet
new file mode 100644
index 00000000..0967ef42
--- /dev/null
+++ b/pkg/tanka/testdata/cases/format/foo/b.libsonnet
@@ -0,0 +1,1 @@
+{}
diff --git a/pkg/tanka/testdata/cases/format/vendor/a.jsonnet b/pkg/tanka/testdata/cases/format/vendor/a.jsonnet
new file mode 100644
index 00000000..0967ef42
--- /dev/null
+++ b/pkg/tanka/testdata/cases/format/vendor/a.jsonnet
@@ -0,0 +1,1 @@
+{}
diff --git a/pkg/tanka/testdata/cases/format/vendor/b.libsonnet b/pkg/tanka/testdata/cases/format/vendor/b.libsonnet
new file mode 100644
index 00000000..0967ef42
--- /dev/null
+++ b/pkg/tanka/testdata/cases/format/vendor/b.libsonnet
@@ -0,0 +1,1 @@
+{}
diff --git a/pkg/tanka/testdata/cases/withenv/main.jsonnet b/pkg/tanka/testdata/cases/withenv/main.jsonnet
new file mode 100644
index 00000000..a3916a26
--- /dev/null
+++ b/pkg/tanka/testdata/cases/withenv/main.jsonnet
@@ -0,0 +1,16 @@
+{
+  apiVersion: 'tanka.dev/v1alpha1',
+  kind: 'Environment',
+  metadata: {
+    name: 'withenv',
+  },
+  spec: {
+    apiServer: 'https://localhost',
+    namespace: 'withenv',
+  },
+  data: {
+    apiVersion: 'v1',
+    kind: 'ConfigMap',
+    metadata: { name: 'config' },
+  },
+}
diff --git a/pkg/tanka/testdata/cases/withspecjson/main.jsonnet b/pkg/tanka/testdata/cases/withspecjson/main.jsonnet
new file mode 100644
index 00000000..1f7ef44c
--- /dev/null
+++ b/pkg/tanka/testdata/cases/withspecjson/main.jsonnet
@@ -0,0 +1,5 @@
+{
+  apiVersion: 'v1',
+  kind: 'ConfigMap',
+  metadata: { name: 'config' },
+}
diff --git a/pkg/tanka/testdata/cases/withspecjson/spec.json b/pkg/tanka/testdata/cases/withspecjson/spec.json
new file mode 100644
index 00000000..9c70d6b2
--- /dev/null
+++ b/pkg/tanka/testdata/cases/withspecjson/spec.json
@@ -0,0 +1,11 @@
+{
+  "apiVersion": "tanka.dev/v1alpha1",
+  "kind": "Environment",
+  "metadata": {
+    "name": "withspec"
+  },
+  "spec": {
+    "apiServer": "https://localhost",
+    "namespace": "withspec"
+  }
+}
diff --git a/pkg/term/alert_test.go b/pkg/term/alert_test.go
new file mode 100644
index 00000000..f1a1f13d
--- /dev/null
+++ b/pkg/term/alert_test.go
@@ -0,0 +1,38 @@
+package term
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestConfirm(t *testing.T) {
+	tests := []struct {
+		name     string
+		input    string
+		expected error
+	}{
+		{name: "linux yes", input: "yes\n", expected: nil},
+		{name: "windows yes", input: "yes\r\n", expected: nil},
+		{name: "linux no", input: "no\n", expected: ErrConfirmationFailed},
+		{name: "windows no", input: "no\r\n", expected: ErrConfirmationFailed},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			in := strings.NewReader(tt.input)
+			out := &strings.Builder{}
+
+			err := confirmFrom(in, out, "foo", "yes")
+
+			assert.Equal(t, "foo\nPlease type 'yes' to confirm: ", out.String())
+
+			if tt.expected != nil {
+				assert.EqualError(t, err, tt.expected.Error())
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}