Repository: grafana/tanka
Pull request #420 (2020-11-06 15:19:12)

Vendor helm chart versions based on chartfile.yaml
When calling `tk tool charts vendor`, and a directory already exists at
the expected chart path, recursively delete that directory unless its
Chart.yaml matches the version of the chart specified in chartfile.yaml.

This allows vendored charts whose versions have changed to be downloaded
by tanka users without first removing the charts directory.

Return errors when Chart.yaml doesn't exist, or is malformed.

---

Implements https://github.com/grafana/tanka/issues/419

craigfurman: @sh0rez would you mind reviewing this implementation of https://github.com/grafana/tanka/issues/419, if you have time?
craigfurman: Thanks for reviewing @sh0rez! I've implemented some of the suggestions and replied to another. Let me know what you think 👍 

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..6d60f6a6 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -13,11 +13,18 @@ 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 curl bash openssl
+RUN curl -fsSL -o get_helm.sh https://raw.githubusercontent.com/helm/helm/master/scripts/get-helm-3 &&\
+    chmod 700 get_helm.sh &&\
+    ./get_helm.sh
+
 # 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/local/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/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
 }


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"
+  }
+}