Repository: grafana/tanka
Pull request #427 (2020-11-23 16:09:16)

feat(cmd): use jpath tool to export JSONNET_PATH 
This allows us to export the JSONNET_PATH for use with other tools like
`jsonnet-lint` or `jsonnet-deps`.

Example:

```
eval $(tk tool jpath environment/prod/main.jsonnet)
jsonnet-lint environment/prod/main.jsonnet
```

sh0rez: We also have `tk tool jpath`, which is closely related. Also it is not incredibly useful at the moment. wdyt about adding your functionality as `tk tool jpath -e` for starters? Also `tool jpath` should accept an argument instead of using the current working dir

Patch:
diff --git a/.gitignore b/.gitignore
index 1521c8b7..517ab3db 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,1 +1,2 @@
 dist
+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/Dockerfile b/Dockerfile
index 7f93157c..d1ca901e 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -13,11 +13,15 @@ RUN git clone https://github.com/jsonnet-bundler/jsonnet-bundler &&\
     make static &&\
     mv _output/jb /usr/local/bin/jb
 
+FROM alpine as helm
+RUN apk add --no-cache -X http://dl-cdn.alpinelinux.org/alpine/edge/testing helm
+
 # 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 /usr/bin/helm /usr/local/bin/helm
 WORKDIR /app
 ENTRYPOINT ["/usr/local/bin/tk"]
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..f3f220ae 100644
--- a/cmd/tk/main.go
+++ b/cmd/tk/main.go
@@ -60,7 +60,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 +77,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.ParseEnv(baseDir, tanka.ParseOpts{Evaluator: tanka.EnvsOnlyEvaluator})
+			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/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/docs/docs/helm.mdx b/docs/docs/helm.mdx
index fcc36277..377f7b2e 100644
--- a/docs/docs/helm.mdx
+++ b/docs/docs/helm.mdx
@@ -142,7 +142,7 @@ to add it:
 version: 1
 repositories:
   - name: stable
-    url: https://kubernetes-charts.storage.googleapis.com
+    url: https://charts.helm.sh/stable
 + - name: grafana
 +   url: https://grafana.github.io/helm-charts
 ```
diff --git a/pkg/helm/charts.go b/pkg/helm/charts.go
index 37eb1d1a..341e467f 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},
 		})
@@ -162,7 +188,7 @@ func InitChartfile(path string) (*Charts, error) {
 		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/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/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/evaluators.go b/pkg/tanka/evaluators.go
new file mode 100644
index 00000000..33f0bc2f
--- /dev/null
+++ b/pkg/tanka/evaluators.go
@@ -0,0 +1,84 @@
+package tanka
+
+import (
+	"fmt"
+
+	"github.com/pkg/errors"
+
+	"github.com/grafana/tanka/pkg/jsonnet"
+	"github.com/grafana/tanka/pkg/jsonnet/jpath"
+)
+
+// Evaluator signature for implementing arbitrary Jsonnet evaluators
+type Evaluator func(path string, opts jsonnet.Opts) (string, error)
+
+// DefaultEvaluator evaluates the jsonnet environment at the given file system path
+func DefaultEvaluator(path string, opts jsonnet.Opts) (string, error) {
+	entrypoint, err := jpath.Entrypoint(path)
+	if err != nil {
+		return "", err
+	}
+
+	// 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
+}
+
+// EnvsOnlyEvaluator finds the Environment object (without its .data object) at
+// the given file system path intended for use by the `tk env` command
+func EnvsOnlyEvaluator(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
+}
diff --git a/pkg/tanka/parse.go b/pkg/tanka/parse.go
index f408d9ab..c79a85ea 100644
--- a/pkg/tanka/parse.go
+++ b/pkg/tanka/parse.go
@@ -9,7 +9,6 @@ import (
 	"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"
@@ -26,6 +25,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 +33,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 +64,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 := ParseEnv(path, ParseOpts{JsonnetOpts: 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 +92,9 @@ 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")
-	}
-
-	return raw, env, 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 +111,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 +121,81 @@ 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)
+// ParseEnv evaluates the jsonnet environment at the given file system path and
+// optionally also returns and Environment object
+func ParseEnv(path string, opts ParseOpts) (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.JsonnetOpts.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)
+	if opts.Evaluator == nil {
+		opts.Evaluator = DefaultEvaluator
+	}
+
+	raw, err := opts.Evaluator(path, opts.JsonnetOpts)
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 
-	if opts.EvalPattern != "" {
-		evalScript := fmt.Sprintf("(import '%s').%s", entrypoint, opts.EvalPattern)
-		raw, err = jsonnet.Evaluate(entrypoint, evalScript, opts)
+	var data interface{}
+	if err := json.Unmarshal([]byte(raw), &data); err != nil {
+		return nil, nil, errors.Wrap(err, "unmarshalling data")
+	}
+
+	if opts.JsonnetOpts.EvalPattern != "" {
+		// 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
 		}
-	}
-	// parse result
-	var data interface{}
-	if err := json.Unmarshal([]byte(raw), &data); err != nil {
-		return nil, err
-	}
-	return data, nil
+		return data, env, nil
+	} else if specEnv != nil {
+		// if no environments found, fallback to original behavior
+		specEnv.Data = data
+		return data, specEnv, nil
+	}
+	// if no environments or spec found, behave as jsonnet interpreter
+	return data, nil, ErrNoEnv{path}
 }
 
 func checkVersion(constraint string) error {
@@ -189,3 +222,25 @@ func checkVersion(constraint string) error {
 
 	return nil
 }
+
+// extractEnvironments filters out any Environment manifests
+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
+}
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/tanka.go b/pkg/tanka/tanka.go
index a5bd6cd3..4bdd1afa 100644
--- a/pkg/tanka/tanka.go
+++ b/pkg/tanka/tanka.go
@@ -9,6 +9,8 @@ import (
 	"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 +19,7 @@ type Opts struct {
 	Filters process.Matchers
 }
 
-type JsonnetOpts = jsonnet.Opts
+type ParseOpts struct {
+	JsonnetOpts
+	Evaluator Evaluator
+}
diff --git a/pkg/tanka/workflow.go b/pkg/tanka/workflow.go
index bbe8f51e..ecfa5f53 100644
--- a/pkg/tanka/workflow.go
+++ b/pkg/tanka/workflow.go
@@ -184,9 +184,13 @@ 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 {
+	r, _, err := ParseEnv(dir, ParseOpts{JsonnetOpts: opts.JsonnetOpts})
+	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/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/parse_test.go b/pkg/tanka/parse_test.go
index 2715e0e0..520a484d 100644
--- a/pkg/tanka/parse_test.go
+++ b/pkg/tanka/parse_test.go
@@ -3,7 +3,6 @@ package tanka
 import (
 	"testing"
 
-	"github.com/grafana/tanka/pkg/jsonnet"
 	"github.com/grafana/tanka/pkg/spec/v1alpha1"
 	"github.com/stretchr/testify/assert"
 )
@@ -12,6 +11,7 @@ func TestEvalJsonnet(t *testing.T) {
 	cases := []struct {
 		baseDir  string
 		expected interface{}
+		env      *v1alpha1.Environment
 	}{
 		{
 			baseDir: "./testdata/cases/array/",
@@ -25,18 +25,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 := ParseEnv(test.baseDir, ParseOpts{})
+		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/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)
+			}
+		})
+	}
+}