Repository: grafana/tanka
Pull request #403 (2020-10-11 15:47:01)

feat: inline Environment
Building further on #389, with implementing the environment inline there is no more need to encapsulate a Tanka Environment in a directory.

This proposes a solution for #390.

Might also help for #394 as the "spec" is now evaluated in Jsonnet.

This PR will allow us to use the Environment inline, like this:

```
{
  apiVersion: 'tanka.dev/v1alpha1',
  kind: 'Environment',
  metadata: {
    name: 'myenv',
  },
  spec: {
    apiServer: 'https://localhost',
    namespace: 'myns',
  },
  data: {
    // put here what used to be in main.jsonnet
    // or just (import 'main.jsonnet') and call this file with tanka directly
  },
}
```

It is fully backwards compatible but has a few drawbacks:

* The tanka.dev/Environment extCode is based on the premise that the env is availabe before runtime, it is now generated at runtime, it doesn't make much sense to read env from tk.env now as the env is now available inside your jsonnet anyway.
* ~`tk env` is build upon the spec.json, it will be hard to support inline environments with this~ see below
* `tk complete` might need some adjustments

On `tk env`:

* `tk env add` and `tk env remove` will be fairly easy to support, just need to decide on what the default layout is for `add`.
* `tk env list` ~can become significantly slower as the jsonnet has to be evaluated for an Environment object to exist, we can improve on speed with go routines and only evaluate if spec.json doesn't exists~ I've fixed this by wrapping a snippet around the jsonnet removing the `data:` object in 2ee1696d3b875a5785a16ae419598329d62c4f40
* `tk env set` would be really hard to port over, you'd be modifying someone's jsonnet code.

Duologic: rebased.
Duologic: rebased.
jdbaldry: > Also, I think the current main.jsonnet/spec.json approach is actually very beginner friendly
I agree, I don't think we should change the defaults.

My first thought is that option `1.` is closer to existing behavior which I like but I'm also keen on how `2.` looks aesthetically. `2.` adjusts the relationship between the environment and data (environment contains data rather than being composed with it), and I'm not sure if there is enough value to justify confusing that relationship.

That being said I'm not sure which of the two relationships is objectively better out of:
- environment contains data
- environment and data are composed together
malcolmholmes: > That being said I'm not sure which of the two relationships is objectively better out of:
>
> *  environment contains data
> *  environment and data are composed together
To really get a sense of where this is going, it helps to look at the next step that @Duologic has in mind, where you can have multiple environments within one setup. That I think will drive exactly how this is expressed.
Duologic: I'm wondering whether we should do it in one go but I'm worried this PR becomes to big.
captncraig: I really like the possibilities that this brings. A few thoughts:

1. I feel it is fair to assume there will be *either* a `spec.json` or a `main.jsonnet` that evaluates to a single environment having `spec` and `data` fields. I like enforced structure and prefer checked assumptions to supporting any desired style.
2. If we don't detect a spec.json, we can assume we are in the "new" mode and do a first pass that evaluates `.spec` to get all of the metadata we need. That will let us support `tk env` and friends with less overhead, as well as keeping the extCodes working and things.
3. I am still not sold on #414, since running multiple environments is a much larger paradigm shift in the workflows around tanka. It is a lot more involved than slightly refactoring the input format.
sh0rez: I'm a bit out here and surely didn't attend all discussions, so I might miss obvious things.

I'm generally on the lines of @captncraig, where I am in favor of a slight refactor / workflow change (this PR), but #414 seems more far-reaching to me. I do not fully understand the use-case for #414, but I am sure @Duologic can explain that to me.

Some random thoughts I had while reading along:

> If we don't detect a spec.json, we can assume we are in the "new" mode

Existing behavior explicitly supports not having a `spec.json`, resulting in `namespace=default` and an empty apiServer url. We need to take care not to break users in unexpected ways.

> either a spec.json or a main.jsonnet

We introduce more than one way to do the same thing here, mostly for compatibility reasons.

I'd really like to avoid a "basic" and "advanced" mode. Could we instead identify what makes the Jsonnet mode harder and make it much simpler so we can ship it as the one and only way?
Duologic: > I'm generally on the lines of @captncraig, where I am in favor of a slight refactor / workflow change (this PR), but #414 seems more far-reaching to me. I do not fully understand the use-case for #414, but I am sure @Duologic can explain that to me.

As mentioned in #414, it proposes an answer to the question "What if the jsonnet has multiple environments?" This can also be answered with an error message saying `Tanka won't handle multiple environments at once`,  that seems a bit dense to me as it is perfectly possible to handle multiple environments at once.

> I'd really like to avoid a "basic" and "advanced" mode. Could we instead identify what makes the Jsonnet mode harder and make it much simpler so we can ship it as the one and only way?

The "advanced mode" does not necessarily reflect on Tanka, the way of working (basic/advanced) becomes a matter of applying conventions to your jsonnet code base instead of imposing restrictions by the runtime (Tanka). With saying basic, I mean defining the current approach as the simplest convention possible on how to use Tanka. If we want to bring out a different convention as the basic use case, then we can, for both the single environment and multiple environments pull requests.

There are some implementation details I've put into place on #414 (like finding the environments), I'd like to backport to this PR in case #414 doesn't get accepted. This would allow us to simply pull all Environments out of the jsonnet code, and if there is more than 1, we can return aforementioned error instead of trying to apply them.
Duologic: re `data:`:

As I said before, the argument for making `data` part of the Environment object is to ensure the jsonnet is
deterministic about what it represents.

Any other the Jsonnet objects can exist as siblings to the Environment object, removing the assumption that all visible
objects are Kubernetes objects. This brings opportunities here for other tools to jump less hoops for the same jsonnet
codebase. (grizzly hacks in making `$.grafanaDashboards` visible at runtime, for example)

re `spec.json` behavior:

The code currently looks for an Environment object, if that doesn't exist it assumes the old spec.json behavior. This is
required to keep backwards compatibility. We can deprecate that way of working over time, but that doesn't need to be
decided here.


Patch:
diff --git a/.gitignore b/.gitignore
index 1521c8b7..517ab3db 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,1 +1,2 @@
 dist
+tk
diff --git a/Dockerfile b/Dockerfile
index 7f93157c..bf58b8cc 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -15,7 +15,7 @@ RUN git clone https://github.com/jsonnet-bundler/jsonnet-bundler &&\
 
 # 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
diff --git a/cmd/tk/env.go b/cmd/tk/env.go
index a799dda8..690b8bdb 100644
--- a/cmd/tk/env.go
+++ b/cmd/tk/env.go
@@ -36,7 +36,7 @@ func envCmd() *cli.Command {
 	return cmd
 }
 
-func envSettingsFlags(env *v1alpha1.Config, fs *pflag.FlagSet) {
+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")
@@ -61,7 +61,7 @@ func envSetCmd() *cli.Command {
 	}
 
 	// flags
-	tmp := v1alpha1.Config{}
+	tmp := v1alpha1.Environment{}
 	envSettingsFlags(&tmp, cmd.Flags())
 
 	// removed name flag
@@ -136,7 +136,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) error {
 	path, err := filepath.Abs(dir)
 	if err != nil {
 		return err
@@ -216,7 +216,7 @@ func envListCmd() *cli.Command {
 	useNames := cmd.Flags().Bool("names", false, "plain names output")
 
 	cmd.Run = func(cmd *cli.Command, args []string) error {
-		envs := []v1alpha1.Config{}
+		envs := []v1alpha1.Environment{}
 		dirs := findBaseDirs()
 		var selector labels.Selector
 		var err error
diff --git a/cmd/tk/jsonnet.go b/cmd/tk/jsonnet.go
index 8f173c56..cb578835 100644
--- a/cmd/tk/jsonnet.go
+++ b/cmd/tk/jsonnet.go
@@ -30,7 +30,7 @@ func evalCmd() *cli.Command {
 		jsonnetOpts.EvalPattern = *evalPattern
 		raw, err := tanka.Eval(args[0], jsonnetOpts)
 
-		if err != nil {
+		if raw == nil && err != nil {
 			return err
 		}
 
diff --git a/cmd/tk/main.go b/cmd/tk/main.go
index 15ddd3a7..a15afbfc 100644
--- a/cmd/tk/main.go
+++ b/cmd/tk/main.go
@@ -10,6 +10,7 @@ import (
 
 	"github.com/go-clix/cli"
 
+	"github.com/grafana/tanka/pkg/jsonnet"
 	"github.com/grafana/tanka/pkg/jsonnet/jpath"
 	"github.com/grafana/tanka/pkg/spec"
 	"github.com/grafana/tanka/pkg/spec/v1alpha1"
@@ -60,7 +61,7 @@ func main() {
 	}
 }
 
-func setupConfiguration(baseDir string) *v1alpha1.Config {
+func setupConfiguration(baseDir string) *v1alpha1.Environment {
 	_, baseDir, rootDir, err := jpath.Resolve(baseDir)
 	if err != nil {
 		log.Fatalln("Resolving jpath:", err)
@@ -77,6 +78,18 @@ func setupConfiguration(baseDir string) *v1alpha1.Config {
 			if verbose {
 				fmt.Print(err)
 			}
+		// no spec.json is found, try parsing main.jsonnet
+		case spec.ErrNoSpec:
+			config, err := tanka.EvalEnvs(baseDir, jsonnet.Opts{})
+			if err != nil {
+				switch err.(type) {
+				case tanka.ErrNoEnv:
+					return nil
+				default:
+					log.Fatalf("Reading main.jsonnet: %s", err)
+				}
+			}
+			return config
 		// some other error
 		default:
 			log.Fatalf("Reading spec.json: %s", err)
diff --git a/cmd/tk/other.go b/cmd/tk/other.go
index 40dc694f..97558abe 100644
--- a/cmd/tk/other.go
+++ b/cmd/tk/other.go
@@ -20,7 +20,7 @@ func findBaseDirs() (dirs []string) {
 	}
 
 	if err := filepath.Walk(".", func(path string, info os.FileInfo, err error) error {
-		requiredFiles := []string{"main.jsonnet", "spec.json"}
+		requiredFiles := []string{"main.jsonnet"}
 		for _, name := range requiredFiles {
 			if _, err := os.Stat(filepath.Join(path, name)); err != nil {
 				// missing file, not a valid environment directory
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/helm.mdx b/docs/docs/helm.mdx
index 5c90d3fb..fcc36277 100644
--- a/docs/docs/helm.mdx
+++ b/docs/docs/helm.mdx
@@ -144,7 +144,7 @@ repositories:
   - name: stable
     url: https://kubernetes-charts.storage.googleapis.com
 + - name: grafana
-+   url: https://https://grafana.github.io/helm-charts
++   url: https://grafana.github.io/helm-charts
 ```
 
 ## Troubleshooting
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/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/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..e2d8fa8f
--- /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 kustomize at '%s': %s.", 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.\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..a72f9aec 100644
--- a/pkg/process/process.go
+++ b/pkg/process/process.go
@@ -18,7 +18,7 @@ const (
 // - tanka.dev/** labels
 // - filtering
 // - best-effort sorting
-func Process(raw interface{}, cfg v1alpha1.Config, exprs Matchers) (manifest.List, error) {
+func Process(raw interface{}, cfg v1alpha1.Environment, exprs Matchers) (manifest.List, error) {
 	// Scan for everything that looks like a Kubernetes object
 	extracted, err := Extract(raw)
 	if err != nil {
@@ -56,7 +56,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 +68,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..93171e8b 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, name string) (*v1alpha1.Environment, error) {
 	fi, err := os.Stat(baseDir)
 	if err != nil {
 		return nil, err
@@ -39,20 +39,22 @@ func ParseDir(baseDir, name string) (*v1alpha1.Config, error) {
 		return nil, err
 	}
 
-	return Parse(data, name)
+	c, err := Parse(data)
+	if c != nil {
+		// set the name field
+		c.Metadata.Name = name
+	}
+
+	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) (*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
 	}
@@ -65,7 +67,7 @@ func Parse(data []byte, name string) (*v1alpha1.Config, error) {
 	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 78%
rename from pkg/spec/v1alpha1/config.go
rename to pkg/spec/v1alpha1/environment.go
index 77ce0296..e6031ed0 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,13 +18,13 @@ 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
diff --git a/pkg/tanka/errors.go b/pkg/tanka/errors.go
new file mode 100644
index 00000000..546285b0
--- /dev/null
+++ b/pkg/tanka/errors.go
@@ -0,0 +1,26 @@
+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)
+}
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/parse.go b/pkg/tanka/parse.go
index f408d9ab..2cc87e81 100644
--- a/pkg/tanka/parse.go
+++ b/pkg/tanka/parse.go
@@ -26,6 +26,7 @@ const DEFAULT_DEV_VERSION = "dev"
 var CURRENT_VERSION = DEFAULT_DEV_VERSION
 
 // loaded is the final result of all processing stages:
+// TODO: remove or update this summary
 // 1. jpath.Resolve: Consruct import paths
 // 2. parseSpec: load spec.json
 // 3. evalJsonnet: evaluate Jsonnet to JSON
@@ -33,7 +34,7 @@ var CURRENT_VERSION = DEFAULT_DEV_VERSION
 //
 // Also connect() is provided to connect to the cluster for live operations
 type loaded struct {
-	Env       *v1alpha1.Config
+	Env       *v1alpha1.Environment
 	Resources manifest.List
 }
 
@@ -64,16 +65,24 @@ func (p *loaded) connect() (*kubernetes.Kubernetes, error) {
 
 // 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)
+	_, env, err := eval(path, opts.JsonnetOpts)
 	if err != nil {
 		return nil, err
 	}
 
+	if env == nil {
+		return nil, fmt.Errorf("no Tanka environment found")
+	}
+
+	if env.Metadata.Name == "" {
+		return nil, fmt.Errorf("Environment has no metadata.name set in %s", path)
+	}
+
 	if err := checkVersion(env.Spec.ExpectVersions.Tanka); err != nil {
 		return nil, err
 	}
 
-	rec, err := process.Process(raw, *env, opts.Filters)
+	rec, err := process.Process(env.Data, *env, opts.Filters)
 	if err != nil {
 		return nil, err
 	}
@@ -84,25 +93,39 @@ func load(path string, opts Opts) (*loaded, error) {
 	}, 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")
-	}
+// eval evaluates the jsonnet environment at the given file system path
+func eval(path string, opts jsonnet.Opts) (interface{}, *v1alpha1.Environment, error) {
+	return parseEnv(
+		path,
+		opts,
+		func(path string, opts jsonnet.Opts) (string, error) {
+			entrypoint, err := jpath.Entrypoint(path)
+			if err != nil {
+				return "", err
+			}
 
-	return raw, env, nil
+			// evaluate Jsonnet
+			var raw string
+			if opts.EvalPattern != "" {
+				evalScript := fmt.Sprintf("(import '%s').%s", entrypoint, opts.EvalPattern)
+				raw, err = jsonnet.Evaluate(entrypoint, evalScript, opts)
+				if err != nil {
+					return "", errors.Wrap(err, "evaluating jsonnet")
+				}
+			} else {
+				raw, err = jsonnet.EvaluateFile(entrypoint, opts)
+				if err != nil {
+					return "", errors.Wrap(err, "evaluating jsonnet")
+				}
+			}
+			return raw, nil
+		},
+	)
 }
 
-// parseEnv parses the `spec.json` of the environment and returns a
+// parseSpec parses the `spec.json` of the environment and returns a
 // *kubernetes.Kubernetes from it
-func parseSpec(path string) (*v1alpha1.Config, error) {
+func parseSpec(path string) (*v1alpha1.Environment, error) {
 	_, baseDir, rootDir, err := jpath.Resolve(path)
 	if err != nil {
 		return nil, errors.Wrap(err, "resolving jpath")
@@ -119,7 +142,7 @@ func parseSpec(path string) (*v1alpha1.Config, error) {
 			log.Println(err)
 		// spec.json missing. we can still work with the default value
 		case spec.ErrNoSpec:
-			return config, nil
+			return config, err
 		// some other error
 		default:
 			return nil, errors.Wrap(err, "reading spec.json")
@@ -129,40 +152,78 @@ func parseSpec(path string) (*v1alpha1.Config, error) {
 	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)
+type evaluateFunc func(path string, opts jsonnet.Opts) (string, error)
+
+// parseEnv finds the Environment object at the given file system path
+func parseEnv(path string, opts jsonnet.Opts, evalFn evaluateFunc) (interface{}, *v1alpha1.Environment, error) {
+	specEnv, err := parseSpec(path)
 	if err != nil {
-		return nil, errors.Wrap(err, "marshalling environment config")
+		switch err.(type) {
+		case spec.ErrNoSpec:
+			specEnv = nil
+		default:
+			return nil, nil, errors.Wrap(err, "reading spec.json")
+		}
+	} else {
+		// original behavior, if env has spec.json
+		// then make env spec accessible through extCode
+		jsonEnv, err := json.Marshal(specEnv)
+		if err != nil {
+			return nil, nil, errors.Wrap(err, "marshalling environment config")
+		}
+		opts.ExtCode.Set(spec.APIGroup+"/environment", string(jsonEnv))
 	}
-	opts.ExtCode.Set(spec.APIGroup+"/environment", string(jsonEnv))
 
-	// evaluate Jsonnet
-	var raw string
-	entrypoint, err := jpath.Entrypoint(path)
+	raw, err := evalFn(path, opts)
 	if err != nil {
-		return nil, err
+		return nil, nil, err
+	}
+
+	var data interface{}
+	if err := json.Unmarshal([]byte(raw), &data); err != nil {
+		return nil, nil, errors.Wrap(err, "unmarshalling data")
 	}
 
 	if opts.EvalPattern != "" {
-		evalScript := fmt.Sprintf("(import '%s').%s", entrypoint, opts.EvalPattern)
-		raw, err = jsonnet.Evaluate(entrypoint, evalScript, opts)
+		// EvalPattern has no affinity with an environment, behave as jsonnet interpreter
+		return data, nil, nil
+	}
+
+	extractedEnvs, err := extractEnvironments(data)
+	if _, ok := err.(process.ErrorPrimitiveReached); ok {
+		if specEnv == nil {
+			// if no environments or spec found, behave as jsonnet interpreter
+			return data, nil, ErrNoEnv{path}
+		}
+	} else if err != nil {
+		return nil, nil, err
+	}
+
+	var env *v1alpha1.Environment
+
+	if len(extractedEnvs) > 1 {
+		names := make([]string, 0)
+		for _, exEnv := range extractedEnvs {
+			names = append(names, exEnv.Metadata().Name())
+		}
+		return data, nil, ErrMultipleEnvs{path, names}
+	} else if len(extractedEnvs) == 1 {
+		marshalled, err := json.Marshal(extractedEnvs[0])
 		if err != nil {
-			return nil, err
+			return nil, nil, err
 		}
-	} else {
-		raw, err = jsonnet.EvaluateFile(entrypoint, opts)
+		env, err = spec.Parse(marshalled)
 		if err != nil {
-			return nil, err
+			return nil, nil, err
 		}
+		return data, env, nil
+	} else if specEnv != nil {
+		// if no environments found, fallback to original behavior
+		specEnv.Data = data
+		return data, specEnv, nil
 	}
-	// parse result
-	var data interface{}
-	if err := json.Unmarshal([]byte(raw), &data); err != nil {
-		return nil, err
-	}
-	return data, nil
+	// if no environments or spec found, behave as jsonnet interpreter
+	return data, nil, ErrNoEnv{path}
 }
 
 func checkVersion(constraint string) error {
@@ -189,3 +250,78 @@ func checkVersion(constraint string) error {
 
 	return nil
 }
+
+func extractEnvironments(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
+}
+
+// EvalEnvs finds the Environment object (without its .data object) at the
+// given file system path intended for use by the `tk env` command
+func EvalEnvs(path string, opts jsonnet.Opts) (*v1alpha1.Environment, error) {
+	_, env, err := parseEnv(
+		path,
+		opts,
+		func(path string, opts jsonnet.Opts) (string, error) {
+			entrypoint, err := jpath.Entrypoint(path)
+			if err != nil {
+				return "", err
+			}
+
+			// Snippet to find all Environment objects and remove the .data object for faster evaluation
+			noData := `
+local noDataEnv(object) =
+  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(import '%s')
+`
+
+			// evaluate Jsonnet with noData snippet
+			var raw string
+			evalScript := fmt.Sprintf(noData, entrypoint)
+			raw, err = jsonnet.Evaluate(entrypoint, evalScript, opts)
+			if err != nil {
+				return "", errors.Wrap(err, "evaluating jsonnet")
+			}
+			return raw, nil
+		},
+	)
+	return env, err
+}
diff --git a/pkg/tanka/status.go b/pkg/tanka/status.go
index c9e22d89..ceccc269 100644
--- a/pkg/tanka/status.go
+++ b/pkg/tanka/status.go
@@ -10,7 +10,7 @@ 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
 }
diff --git a/pkg/tanka/workflow.go b/pkg/tanka/workflow.go
index bbe8f51e..ab301f64 100644
--- a/pkg/tanka/workflow.go
+++ b/pkg/tanka/workflow.go
@@ -185,8 +185,12 @@ 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)
-	if err != nil {
+	switch err.(type) {
+	case ErrNoEnv, ErrMultipleEnvs:
+		return r, err
+	case nil:
+		return r, nil
+	default:
 		return nil, err
 	}
-	return r, 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/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/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..2644a0ff 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"
 	},
@@ -23,7 +26,7 @@ func TestDeprecated(t *testing.T) {
 }
 `)
 
-	got, err := Parse(data, "test")
+	got, err := Parse(data)
 	require.Equal(t, ErrDeprecated{
 		{old: "server", new: "spec.apiServer"},
 		{old: "team", new: "metadata.labels.team"},
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/parse_test.go b/pkg/tanka/parse_test.go
index 2715e0e0..ad3d5ac5 100644
--- a/pkg/tanka/parse_test.go
+++ b/pkg/tanka/parse_test.go
@@ -12,6 +12,7 @@ func TestEvalJsonnet(t *testing.T) {
 	cases := []struct {
 		baseDir  string
 		expected interface{}
+		env      *v1alpha1.Environment
 	}{
 		{
 			baseDir: "./testdata/cases/array/",
@@ -25,18 +26,99 @@ func TestEvalJsonnet(t *testing.T) {
 					map[string]interface{}{"testCase": "nestedArray[1][1]"},
 				},
 			},
+			env: nil,
 		},
 		{
 			baseDir: "./testdata/cases/object/",
 			expected: map[string]interface{}{
 				"testCase": "object",
 			},
+			env: nil,
+		},
+		{
+			baseDir: "./testdata/cases/withspecjson/",
+			expected: map[string]interface{}{
+				"testCase": "object",
+			},
+			env: &v1alpha1.Environment{
+				APIVersion: v1alpha1.New().APIVersion,
+				Kind:       v1alpha1.New().Kind,
+				Metadata: v1alpha1.Metadata{
+					Name:   "cases/withspecjson",
+					Labels: v1alpha1.New().Metadata.Labels,
+				},
+				Spec: v1alpha1.Spec{
+					APIServer: "https://localhost",
+					Namespace: "withspec",
+				},
+				Data: map[string]interface{}{
+					"testCase": "object",
+				},
+			},
+		},
+		{
+			baseDir: "./testdata/cases/withspecjson/main.jsonnet",
+			expected: map[string]interface{}{
+				"testCase": "object",
+			},
+			env: &v1alpha1.Environment{
+				APIVersion: v1alpha1.New().APIVersion,
+				Kind:       v1alpha1.New().Kind,
+				Metadata: v1alpha1.Metadata{
+					Name:   "cases/withspecjson",
+					Labels: v1alpha1.New().Metadata.Labels,
+				},
+				Spec: v1alpha1.Spec{
+					APIServer: "https://localhost",
+					Namespace: "withspec",
+				},
+				Data: map[string]interface{}{
+					"testCase": "object",
+				},
+			},
+		},
+		{
+			baseDir: "./testdata/cases/withenv/main.jsonnet",
+			expected: map[string]interface{}{
+				"apiVersion": v1alpha1.New().APIVersion,
+				"kind":       v1alpha1.New().Kind,
+				"metadata": map[string]interface{}{
+					"name": "withenv",
+				},
+				"spec": map[string]interface{}{
+					"apiServer": "https://localhost",
+					"namespace": "withenv",
+				},
+				"data": map[string]interface{}{
+					"testCase": "object",
+				},
+			},
+			env: &v1alpha1.Environment{
+				APIVersion: v1alpha1.New().APIVersion,
+				Kind:       v1alpha1.New().Kind,
+				Metadata: v1alpha1.Metadata{
+					Name:   "withenv",
+					Labels: v1alpha1.New().Metadata.Labels,
+				},
+				Spec: v1alpha1.Spec{
+					APIServer: "https://localhost",
+					Namespace: "withenv",
+				},
+				Data: map[string]interface{}{
+					"testCase": "object",
+				},
+			},
 		},
 	}
 
 	for _, test := range cases {
-		data, e := evalJsonnet(test.baseDir, v1alpha1.New(), jsonnet.Opts{})
-		assert.NoError(t, e)
+		data, env, e := eval(test.baseDir, jsonnet.Opts{})
+		if data == nil {
+			assert.NoError(t, e)
+		} else if e != nil {
+			assert.IsType(t, ErrNoEnv{}, e)
+		}
 		assert.Equal(t, test.expected, data)
+		assert.Equal(t, test.env, env)
 	}
 }
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..d6f4b7c2
--- /dev/null
+++ b/pkg/tanka/testdata/cases/withenv/main.jsonnet
@@ -0,0 +1,14 @@
+{
+  apiVersion: 'tanka.dev/v1alpha1',
+  kind: 'Environment',
+  metadata: {
+    name: 'withenv',
+  },
+  spec: {
+    apiServer: 'https://localhost',
+    namespace: 'withenv',
+  },
+  data: {
+    testCase: 'object',
+  },
+}
diff --git a/pkg/tanka/testdata/cases/withspecjson/main.jsonnet b/pkg/tanka/testdata/cases/withspecjson/main.jsonnet
new file mode 100644
index 00000000..157b04a4
--- /dev/null
+++ b/pkg/tanka/testdata/cases/withspecjson/main.jsonnet
@@ -0,0 +1,3 @@
+{
+  testCase: 'object',
+}
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)
+			}
+		})
+	}
+}