Repository: grafana/tanka
Pull request #459 (2020-12-31 00:10:42)

refactor(api): Loader interface
I took a chance at refactoring the code in `pkg/tanka/parse.go`, which was modified a lot recently. These modifications revealed problems with my original design, which led to poor readability and a tangled control flow.

To address this, this PR strictly separates `inline` from `static` (spec.json) parsing. IMO this is far more readable.

It is quite likely I missed some edge-cases, @Duologic is probably able to point at these.

Duologic: I don't mind seeing a refactoring here, but I have quite an extensive PR that will get affected if this gets merged first. Can we review and merge mine first and then come up with this API rewrite?
sh0rez: > Can we review and merge mine first and then come up with this API rewrite?

Depends on your PR :stuck_out_tongue:. One of us is going to re-do some work, which sucks, so we should consider both PR's. This is a long overdue topic, and I bet whatever you wish to add becomes simpler when the refactoring happens beforehand ;)
Duologic: It is not very nice to put the burden on me.
malcolmholmes: @Duologic can you share your PR here? Isnt it best to review which side will require less work and go that way?
Duologic: #450 
Duologic: That PR has been through reviews already, I figured it was the lack of time on the other side that was holding it up, but apparently a rewrite of the API was going on.
sh0rez: > It is not very nice to put the burden on me.

I think I made it pretty clear I merely asked you to show me the other PR, so we can review which side requires less rebasing. You gave me zero information on your PR, so this was all I could do at the time.

To not escalate this further, we will go ahead with yours first.
sh0rez: Rebased. @Duologic @jvrplmlmn PTAL

Patch:
diff --git a/cmd/tk/env.go b/cmd/tk/env.go
index 5cf5f818..10485301 100644
--- a/cmd/tk/env.go
+++ b/cmd/tk/env.go
@@ -14,7 +14,6 @@ import (
 
 	"github.com/grafana/tanka/pkg/jsonnet/jpath"
 	"github.com/grafana/tanka/pkg/kubernetes/client"
-	"github.com/grafana/tanka/pkg/spec"
 	"github.com/grafana/tanka/pkg/spec/v1alpha1"
 	"github.com/grafana/tanka/pkg/tanka"
 	"github.com/grafana/tanka/pkg/term"
@@ -43,32 +42,6 @@ var kubectlContexts = cli.PredictFunc(
 	},
 )
 
-func setupConfiguration(baseDir string) *v1alpha1.Environment {
-	_, baseDir, rootDir, err := jpath.Resolve(baseDir)
-	if err != nil {
-		log.Fatalln("Resolving jpath:", err)
-	}
-
-	// name of the environment: relative path from rootDir
-	name, _ := filepath.Rel(rootDir, baseDir)
-
-	config, err := spec.ParseDir(baseDir, name)
-	if err != nil {
-		switch err.(type) {
-		// the config includes deprecated fields
-		case spec.ErrDeprecated:
-			if verbose {
-				fmt.Print(err)
-			}
-		// some other error
-		default:
-			log.Fatalf("Reading spec.json: %s", err)
-		}
-	}
-
-	return config
-}
-
 func envSetCmd() *cli.Command {
 	cmd := &cli.Command{
 		Use:   "set",
@@ -303,3 +276,14 @@ func envListCmd() *cli.Command {
 	}
 	return cmd
 }
+
+func setupConfiguration(baseDir string) *v1alpha1.Environment {
+	env, err := tanka.Load(baseDir, tanka.Opts{
+		JsonnetOpts: tanka.JsonnetOpts{EvalScript: tanka.EnvsOnlyEvalScript},
+	})
+	if err != nil {
+		log.Fatalln(err)
+	}
+
+	return env.Env
+}
diff --git a/cmd/tk/export.go b/cmd/tk/export.go
index 19eef18a..cb92a398 100644
--- a/cmd/tk/export.go
+++ b/cmd/tk/export.go
@@ -55,6 +55,7 @@ func exportCmd() *cli.Command {
 
 		var paths []string
 		for _, path := range args[1:] {
+			// find possible environments
 			if *recursive {
 				rootDir, err := jpath.FindRoot(path)
 				if err != nil {
@@ -73,15 +74,17 @@ func exportCmd() *cli.Command {
 				continue
 			}
 
+			// validate environment
 			jsonnetOpts := opts.ParseParallelOpts.JsonnetOpts
 			jsonnetOpts.EvalScript = tanka.EnvsOnlyEvalScript
-			_, _, err := tanka.ParseEnv(path, jsonnetOpts)
+			_, err := tanka.Load(path, tanka.Opts{JsonnetOpts: jsonnetOpts})
 			if err != nil {
 				return err
 			}
 			paths = append(paths, path)
 		}
 
+		// export them
 		return tanka.ExportEnvironments(paths, args[0], &opts)
 	}
 	return cmd
diff --git a/pkg/jsonnet/eval.go b/pkg/jsonnet/eval.go
index 812475f5..f3a0290a 100644
--- a/pkg/jsonnet/eval.go
+++ b/pkg/jsonnet/eval.go
@@ -69,6 +69,7 @@ func EvaluateFile(jsonnetFile string, opts Opts) (string, error) {
 }
 
 // Evaluate renders the given jsonnet into a string
+// TODO: don't resolve jpath, this is ANONYMOUS AFTER ALL
 func Evaluate(path, data string, opts Opts) (string, error) {
 	jpath, _, _, err := jpath.Resolve(path)
 	if err != nil {
diff --git a/pkg/jsonnet/jpath/dirs.go b/pkg/jsonnet/jpath/dirs.go
index 43ab9c49..decab66d 100644
--- a/pkg/jsonnet/jpath/dirs.go
+++ b/pkg/jsonnet/jpath/dirs.go
@@ -7,6 +7,21 @@ import (
 	"runtime"
 )
 
+// Dirs returns the project-root (root) and environment directory (base)
+func Dirs(path string) (root string, base string, err error) {
+	root, err = FindRoot(path)
+	if err != nil {
+		return "", "", err
+	}
+
+	base, err = FindBase(path, root)
+	if err != nil {
+		return root, "", err
+	}
+
+	return root, base, err
+}
+
 // FindRoot returns the absolute path of the project root, being the directory
 // that directly holds `tkrc.yaml` if it exists, otherwise the directory that
 // directly holds `jsonnetfile.json`
diff --git a/pkg/process/process.go b/pkg/process/process.go
index a72f9aec..e65e473c 100644
--- a/pkg/process/process.go
+++ b/pkg/process/process.go
@@ -18,7 +18,13 @@ const (
 // - tanka.dev/** labels
 // - filtering
 // - best-effort sorting
-func Process(raw interface{}, cfg v1alpha1.Environment, exprs Matchers) (manifest.List, error) {
+func Process(cfg v1alpha1.Environment, exprs Matchers) (manifest.List, error) {
+	raw := cfg.Data
+
+	if raw == nil {
+		return manifest.List{}, nil
+	}
+
 	// Scan for everything that looks like a Kubernetes object
 	extracted, err := Extract(raw)
 	if err != nil {
diff --git a/pkg/spec/spec.go b/pkg/spec/spec.go
index 2d0185f8..42ae491f 100644
--- a/pkg/spec/spec.go
+++ b/pkg/spec/spec.go
@@ -50,7 +50,7 @@ func ParseDir(baseDir, path string) (*v1alpha1.Environment, error) {
 }
 
 // Parse parses the json `data` into a `v1alpha1.Environment` object.
-func Parse(data []byte, path string) (*v1alpha1.Environment, error) {
+func Parse(data []byte, namespace string) (*v1alpha1.Environment, error) {
 	config := v1alpha1.New()
 	if err := json.Unmarshal(data, config); err != nil {
 		return nil, errors.Wrap(err, "parsing spec.json")
@@ -65,7 +65,7 @@ func Parse(data []byte, path string) (*v1alpha1.Environment, error) {
 		config.Spec.APIServer = "https://" + config.Spec.APIServer
 	}
 
-	config.Metadata.Namespace = path
+	config.Metadata.Namespace = namespace
 
 	return config, nil
 }
diff --git a/pkg/tanka/environments.go b/pkg/tanka/environments.go
index 459df6dc..a9c9c347 100644
--- a/pkg/tanka/environments.go
+++ b/pkg/tanka/environments.go
@@ -134,12 +134,12 @@ type parseJob struct {
 
 func parseWorker(envsChan <-chan parseJob) (errs []error) {
 	for req := range envsChan {
-		_, env, err := ParseEnv(req.path, req.opts)
+		loaded, err := Load(req.path, Opts{JsonnetOpts: req.opts})
 		if err != nil {
 			errs = append(errs, fmt.Errorf("%s:\n %w", req.path, err))
 			continue
 		}
-		*req.env = *env
+		*req.env = *loaded.Env
 	}
 	if len(errs) != 0 {
 		return errs
diff --git a/pkg/tanka/evaluators.go b/pkg/tanka/evaluators.go
index a6e8a766..e016a5f1 100644
--- a/pkg/tanka/evaluators.go
+++ b/pkg/tanka/evaluators.go
@@ -19,15 +19,16 @@ func EvalJsonnet(path string, opts jsonnet.Opts) (raw string, err error) {
 	// evaluate Jsonnet
 	if opts.EvalScript != "" {
 		evalScript := fmt.Sprintf(opts.EvalScript, entrypoint)
-		raw, err = jsonnet.Evaluate(entrypoint, evalScript, opts)
-		if err != nil {
-			return "", errors.Wrap(err, "evaluating jsonnet")
-		}
-	} else {
-		raw, err = jsonnet.EvaluateFile(entrypoint, opts)
+		raw, err = jsonnet.Evaluate(path, evalScript, opts)
 		if err != nil {
 			return "", errors.Wrap(err, "evaluating jsonnet")
 		}
+		return raw, nil
+	}
+
+	raw, err = jsonnet.EvaluateFile(entrypoint, opts)
+	if err != nil {
+		return "", errors.Wrap(err, "evaluating jsonnet")
 	}
 	return raw, nil
 }
diff --git a/pkg/tanka/export.go b/pkg/tanka/export.go
index f6f2cbe1..afecf8c9 100644
--- a/pkg/tanka/export.go
+++ b/pkg/tanka/export.go
@@ -54,13 +54,7 @@ func ExportEnvironments(paths []string, to string, opts *ExportEnvOpts) error {
 		return fmt.Errorf("Output dir `%s` not empty. Pass --merge to ignore this", to)
 	}
 
-	// get all environments for paths
-	envs, err := ParseParallel(paths, opts.ParseParallelOpts)
-	if err != nil {
-		return err
-	}
-
-	for _, env := range envs {
+	for _, path := range paths {
 		// select targets to export
 		filter, err := process.StrExps(opts.Targets...)
 		if err != nil {
@@ -68,11 +62,16 @@ func ExportEnvironments(paths []string, to string, opts *ExportEnvOpts) error {
 		}
 
 		// get the manifests
-		res, err := LoadManifests(env, filter)
+		loaded, err := Load(path, Opts{
+			Filters: filter,
+		})
 		if err != nil {
 			return err
 		}
 
+		env := loaded.Env
+		res := loaded.Resources
+
 		// create raw manifest version of env for templating
 		env.Data = nil
 		raw, err := json.Marshal(env)
@@ -99,9 +98,10 @@ func ExportEnvironments(paths []string, to string, opts *ExportEnvOpts) error {
 			}
 
 			// Create all subfolders in path
-			path := filepath.Join(to, name+"."+opts.Extension)
+			relpath := name + "." + opts.Extension
+			path := filepath.Join(to, relpath)
 
-			fileToEnv[path] = env.Metadata.Namespace
+			fileToEnv[relpath] = env.Metadata.Namespace
 
 			// Abort if already exists
 			if exists, err := fileExists(path); err != nil {
diff --git a/pkg/tanka/inline.go b/pkg/tanka/inline.go
new file mode 100644
index 00000000..e6953fee
--- /dev/null
+++ b/pkg/tanka/inline.go
@@ -0,0 +1,92 @@
+package tanka
+
+import (
+	"encoding/json"
+	"fmt"
+	"path/filepath"
+
+	"github.com/grafana/tanka/pkg/jsonnet/jpath"
+	"github.com/grafana/tanka/pkg/kubernetes/manifest"
+	"github.com/grafana/tanka/pkg/process"
+	"github.com/grafana/tanka/pkg/spec"
+	"github.com/grafana/tanka/pkg/spec/v1alpha1"
+)
+
+// InlineLoader loads an environment that is specified inline from within
+// Jsonnet. The Jsonnet output is expected to hold a tanka.dev/Environment type,
+// Kubernetes resources are expected at the `data` key of this very type
+type InlineLoader struct{}
+
+func (i *InlineLoader) Load(path string, opts JsonnetOpts) (*v1alpha1.Environment, error) {
+	raw, err := EvalJsonnet(path, opts)
+	if err != nil {
+		return nil, err
+	}
+
+	var data interface{}
+	if err := json.Unmarshal([]byte(raw), &data); err != nil {
+		return nil, err
+	}
+
+	envs, err := extractEnvs(data)
+	if err != nil {
+		return nil, err
+	}
+
+	if len(envs) > 1 {
+		names := make([]string, 0, len(envs))
+		for _, e := range envs {
+			names = append(names, e.Metadata().Name())
+		}
+		return nil, ErrMultipleEnvs{path, names}
+	}
+
+	if len(envs) == 0 {
+		return nil, fmt.Errorf("Found no environments in '%s'", path)
+	}
+
+	root, base, err := jpath.Dirs(path)
+	if err != nil {
+		return nil, err
+	}
+
+	name, err := filepath.Rel(root, base)
+	if err != nil {
+		return nil, err
+	}
+
+	// TODO: Re-serializing the entire env here. This is horribly inefficient
+	envData, err := json.Marshal(envs[0])
+	if err != nil {
+		return nil, err
+	}
+
+	env, err := spec.Parse(envData, name)
+	if err != nil {
+		return nil, err
+	}
+
+	return env, nil
+}
+
+// extractEnvs filters out any Environment manifests
+func extractEnvs(data interface{}) (manifest.List, error) {
+	// Scan for everything that looks like a Kubernetes object
+	extracted, err := process.Extract(data)
+	if err != nil {
+		return nil, err
+	}
+
+	// Unwrap *List types
+	if err := process.Unwrap(extracted); err != nil {
+		return nil, err
+	}
+
+	out := make(manifest.List, 0, len(extracted))
+	for _, m := range extracted {
+		out = append(out, m)
+	}
+
+	// Extract only object of Kind: Environment
+	return process.Filter(out, process.MustStrExps("Environment/.*")), nil
+}
diff --git a/pkg/tanka/load.go b/pkg/tanka/load.go
new file mode 100644
index 00000000..3d0feebe
--- /dev/null
+++ b/pkg/tanka/load.go
@@ -0,0 +1,93 @@
+package tanka
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+
+	"github.com/grafana/tanka/pkg/jsonnet/jpath"
+	"github.com/grafana/tanka/pkg/kubernetes"
+	"github.com/grafana/tanka/pkg/kubernetes/manifest"
+	"github.com/grafana/tanka/pkg/process"
+	"github.com/grafana/tanka/pkg/spec"
+	"github.com/grafana/tanka/pkg/spec/v1alpha1"
+	"github.com/pkg/errors"
+)
+
+// Load loads the Environment at `path`. It automatically detects whether to
+// load inline or statically
+func Load(path string, opts Opts) (*LoadResult, error) {
+	_, base, err := jpath.Dirs(path)
+	if err != nil {
+		return nil, err
+	}
+
+	loader, err := detectLoader(base)
+	if err != nil {
+		return nil, err
+	}
+
+	env, err := loader.Load(path, opts.JsonnetOpts)
+	if err != nil {
+		return nil, err
+	}
+
+	if err := checkVersion(env.Spec.ExpectVersions.Tanka); err != nil {
+		return nil, err
+	}
+
+	processed, err := process.Process(*env, opts.Filters)
+	if err != nil {
+		return nil, err
+	}
+
+	return &LoadResult{Env: env, Resources: processed}, nil
+}
+
+// detectLoader detects whether the environment is inline or static and picks
+// the approriate loader
+func detectLoader(base string) (Loader, error) {
+	// check if spec.json exists
+	_, err := os.Stat(filepath.Join(base, spec.Specfile))
+	if os.IsNotExist(err) {
+		return &InlineLoader{}, nil
+	} else if err != nil {
+		return nil, err
+	}
+
+	return &StaticLoader{}, nil
+}
+
+// Loader is an abstraction over the process of loading Environments
+type Loader interface {
+	Load(path string, opts JsonnetOpts) (*v1alpha1.Environment, error)
+}
+
+type LoadResult struct {
+	Env       *v1alpha1.Environment
+	Resources manifest.List
+}
+
+func (l LoadResult) Connect() (*kubernetes.Kubernetes, error) {
+	env := *l.Env
+
+	// check env is complete
+	s := ""
+	if env.Spec.APIServer == "" {
+		s += "  * spec.apiServer: No Kubernetes cluster endpoint specified"
+	}
+	if env.Spec.Namespace == "" {
+		s += "  * spec.namespace: Default namespace missing"
+	}
+	if s != "" {
+		return nil, fmt.Errorf("Your Environment's spec.json seems incomplete:\n%s\n\nPlease see https://tanka.dev/config for reference", s)
+	}
+
+	// connect client
+	kube, err := kubernetes.New(env)
+	if err != nil {
+		return nil, errors.Wrap(err, "connecting to Kubernetes")
+	}
+
+	return kube, nil
+}
diff --git a/pkg/tanka/parse.go b/pkg/tanka/parse.go
deleted file mode 100644
index 3d877e50..00000000
--- a/pkg/tanka/parse.go
+++ /dev/null
@@ -1,270 +0,0 @@
-package tanka
-
-import (
-	"encoding/json"
-	"fmt"
-	"log"
-	"path/filepath"
-
-	"github.com/Masterminds/semver"
-	"github.com/pkg/errors"
-
-	"github.com/grafana/tanka/pkg/jsonnet"
-	"github.com/grafana/tanka/pkg/jsonnet/jpath"
-	"github.com/grafana/tanka/pkg/kubernetes"
-	"github.com/grafana/tanka/pkg/kubernetes/manifest"
-	"github.com/grafana/tanka/pkg/process"
-	"github.com/grafana/tanka/pkg/spec"
-	"github.com/grafana/tanka/pkg/spec/v1alpha1"
-)
-
-// DEFAULT_DEV_VERSION is the placeholder version used when no actual semver is
-// provided using ldflags
-const DEFAULT_DEV_VERSION = "dev"
-
-// CURRENT_VERSION is the current version of the running Tanka code
-var CURRENT_VERSION = DEFAULT_DEV_VERSION
-
-// loaded is the final result of all processing stages:
-// TODO: remove or update this summary
-// 1. jpath.Resolve: Consruct import paths
-// 2. parseSpec: load spec.json
-// 3. evalJsonnet: evaluate Jsonnet to JSON
-// 4. process.Process: post-processing
-//
-// Also connect() is provided to connect to the cluster for live operations
-type loaded struct {
-	Env       *v1alpha1.Environment
-	Resources manifest.List
-}
-
-// connect opens a connection to the backing Kubernetes cluster.
-func (p *loaded) connect() (*kubernetes.Kubernetes, error) {
-	env := *p.Env
-
-	// check env is complete
-	s := ""
-	if env.Spec.APIServer == "" {
-		s += "  * spec.apiServer: No Kubernetes cluster endpoint specified"
-	}
-	if env.Spec.Namespace == "" {
-		s += "  * spec.namespace: Default namespace missing"
-	}
-	if s != "" {
-		return nil, fmt.Errorf("Your Environment's spec.json seems incomplete:\n%s\n\nPlease see https://tanka.dev/config for reference", s)
-	}
-
-	// connect client
-	kube, err := kubernetes.New(env)
-	if err != nil {
-		return nil, errors.Wrap(err, "connecting to Kubernetes")
-	}
-
-	return kube, nil
-}
-
-// load runs all processing stages described at the Processed type
-func load(path string, opts Opts) (*loaded, error) {
-	_, env, err := ParseEnv(path, opts.JsonnetOpts)
-	if err != nil {
-		return nil, err
-	}
-
-	if env == nil {
-		return nil, fmt.Errorf("no Tanka environment found")
-	}
-
-	rec, err := LoadManifests(env, opts.Filters)
-	if err != nil {
-		return nil, err
-	}
-
-	return &loaded{
-		Resources: rec,
-		Env:       env,
-	}, nil
-}
-
-func LoadManifests(env *v1alpha1.Environment, filters process.Matchers) (manifest.List, error) {
-	if env.Metadata.Name == "" {
-		return nil, fmt.Errorf("Environment has no metadata.name set in %s", env.Metadata.Namespace)
-	}
-
-	if err := checkVersion(env.Spec.ExpectVersions.Tanka); err != nil {
-		return nil, err
-	}
-
-	resources, err := process.Process(env.Data, *env, filters)
-	if err != nil {
-		return nil, err
-	}
-
-	return resources, nil
-}
-
-// parseSpec parses the `spec.json` of the environment and returns a
-// *kubernetes.Kubernetes from it
-func parseSpec(path string) (*v1alpha1.Environment, error) {
-	_, baseDir, rootDir, err := jpath.Resolve(path)
-	if err != nil {
-		return nil, err
-	}
-
-	// name of the environment: relative path from rootDir
-	name, _ := filepath.Rel(rootDir, baseDir)
-
-	config, err := spec.ParseDir(baseDir, name)
-	if err != nil {
-		switch err.(type) {
-		// the config includes deprecated fields
-		case spec.ErrDeprecated:
-			log.Println(err)
-		// spec.json missing. we can still work with the default value
-		case spec.ErrNoSpec:
-			return config, err
-		// some other error
-		default:
-			return nil, err
-		}
-	}
-
-	return config, nil
-}
-
-// ParseEnv evaluates the jsonnet environment at the given file system path and
-// optionally also returns and Environment object
-func ParseEnv(path string, opts JsonnetOpts) (interface{}, *v1alpha1.Environment, error) {
-	specEnv, err := parseSpec(path)
-	if err != nil {
-		switch err.(type) {
-		case spec.ErrNoSpec:
-			specEnv = nil
-		default:
-			return nil, nil, err
-		}
-	} 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")
-		}
-		// make a copy to prevent race condition in parallel execution
-		injectCode := jsonnet.InjectedCode{}
-		for k, v := range opts.ExtCode {
-			injectCode.Set(k, v)
-		}
-		injectCode.Set(spec.APIGroup+"/environment", string(jsonEnv))
-		opts.ExtCode = injectCode
-	}
-
-	raw, err := EvalJsonnet(path, opts)
-	if err != nil {
-		return nil, nil, err
-	}
-
-	var data interface{}
-	if err := json.Unmarshal([]byte(raw), &data); err != nil {
-		return nil, nil, errors.Wrap(err, "unmarshalling data")
-	}
-
-	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, nil, err
-		}
-		relpath, err := relativeEntrypoint(path)
-		if err != nil {
-			return nil, nil, err
-		}
-		env, err = spec.Parse(marshalled, relpath)
-		if err != nil {
-			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
-	}
-	// if no environments or spec found, behave as jsonnet interpreter
-	return data, nil, ErrNoEnv{path}
-}
-
-func checkVersion(constraint string) error {
-	if constraint == "" {
-		return nil
-	}
-	if CURRENT_VERSION == DEFAULT_DEV_VERSION {
-		return nil
-	}
-
-	c, err := semver.NewConstraint(constraint)
-	if err != nil {
-		return fmt.Errorf("Parsing version constraint: '%w'. Please check 'spec.expectVersions.tanka'", err)
-	}
-
-	v, err := semver.NewVersion(CURRENT_VERSION)
-	if err != nil {
-		return fmt.Errorf("'%s' is not a valid semantic version: '%w'.\nThis likely means your build of Tanka is broken, as this is a compile-time value. When in doubt, please raise an issue", CURRENT_VERSION, err)
-	}
-
-	if !c.Check(v) {
-		return fmt.Errorf("Current version '%s' does not satisfy the version required by the environment: '%s'. You likely need to use another version of Tanka", CURRENT_VERSION, constraint)
-	}
-
-	return nil
-}
-
-// 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
-}
-
-func relativeEntrypoint(path string) (string, error) {
-	entrypoint, err := jpath.Entrypoint(path)
-	if err != nil {
-		return "", err
-	}
-	_, _, rootDir, err := jpath.Resolve(entrypoint)
-	if err != nil {
-		return "", errors.Wrap(err, "resolving jpath")
-	}
-
-	return filepath.Rel(rootDir, entrypoint)
-}
diff --git a/pkg/tanka/prune.go b/pkg/tanka/prune.go
index c6178e7b..8bed2035 100644
--- a/pkg/tanka/prune.go
+++ b/pkg/tanka/prune.go
@@ -21,11 +21,11 @@ type PruneOpts struct {
 // Jsonnet. It uses the `tanka.dev/environment` label to identify those.
 func Prune(baseDir string, opts PruneOpts) error {
 	// parse jsonnet, init k8s client
-	p, err := load(baseDir, opts.Opts)
+	p, err := Load(baseDir, opts.Opts)
 	if err != nil {
 		return err
 	}
-	kube, err := p.connect()
+	kube, err := p.Connect()
 	if err != nil {
 		return err
 	}
diff --git a/pkg/tanka/static.go b/pkg/tanka/static.go
new file mode 100644
index 00000000..804a276c
--- /dev/null
+++ b/pkg/tanka/static.go
@@ -0,0 +1,65 @@
+package tanka
+
+import (
+	"encoding/json"
+	"log"
+	"path/filepath"
+
+	"github.com/grafana/tanka/pkg/jsonnet/jpath"
+	"github.com/grafana/tanka/pkg/spec"
+	"github.com/grafana/tanka/pkg/spec/v1alpha1"
+)
+
+// StaticLoader loads an environment from a static file called `spec.json`.
+// Jsonnet is evaluated as normal
+type StaticLoader struct{}
+
+func (s StaticLoader) Load(path string, opts JsonnetOpts) (*v1alpha1.Environment, error) {
+	root, base, err := jpath.Dirs(path)
+	if err != nil {
+		return nil, err
+	}
+
+	config, err := parseStaticSpec(root, base)
+	if err != nil {
+		return nil, err
+	}
+
+	data, err := EvalJsonnet(path, opts)
+	if err != nil {
+		return nil, err
+	}
+
+	if err := json.Unmarshal([]byte(data), &config.Data); err != nil {
+		return nil, err
+	}
+
+	return config, nil
+}
+
+// parseStaticSpec parses the `spec.json` of the environment and returns a
+// *kubernetes.Kubernetes from it
+func parseStaticSpec(root, base string) (*v1alpha1.Environment, error) {
+	// name of the environment: relative path from rootDir
+	name, err := filepath.Rel(root, base)
+	if err != nil {
+		return nil, err
+	}
+
+	env, err := spec.ParseDir(base, name)
+	if err != nil {
+		switch err.(type) {
+		// the config includes deprecated fields
+		case spec.ErrDeprecated:
+			log.Println(err)
+		// spec.json missing. we can still work with the default value
+		case spec.ErrNoSpec:
+			return env, nil
+		// some other error
+		default:
+			return nil, err
+		}
+	}
+
+	return env, nil
+}
diff --git a/pkg/tanka/status.go b/pkg/tanka/status.go
index ceccc269..eb8cdb65 100644
--- a/pkg/tanka/status.go
+++ b/pkg/tanka/status.go
@@ -17,11 +17,11 @@ type Info struct {
 
 // Status returns information about the particular environment
 func Status(baseDir string, opts Opts) (*Info, error) {
-	r, err := load(baseDir, opts)
+	r, err := Load(baseDir, opts)
 	if err != nil {
 		return nil, err
 	}
-	kube, err := r.connect()
+	kube, err := r.Connect()
 	if err != nil {
 		return nil, err
 	}
diff --git a/pkg/tanka/tanka.go b/pkg/tanka/tanka.go
index 71e909f2..ab014462 100644
--- a/pkg/tanka/tanka.go
+++ b/pkg/tanka/tanka.go
@@ -5,6 +5,9 @@
 package tanka
 
 import (
+	"fmt"
+
+	"github.com/Masterminds/semver"
 	"k8s.io/apimachinery/pkg/labels"
 
 	"github.com/grafana/tanka/pkg/jsonnet"
@@ -21,6 +24,38 @@ type Opts struct {
 	Filters process.Matchers
 }
 
+// DEFAULT_DEV_VERSION is the placeholder version used when no actual semver is
+// provided using ldflags
+const DEFAULT_DEV_VERSION = "dev"
+
+// CURRENT_VERSION is the current version of the running Tanka code
+var CURRENT_VERSION = DEFAULT_DEV_VERSION
+
+func checkVersion(constraint string) error {
+	if constraint == "" {
+		return nil
+	}
+	if CURRENT_VERSION == DEFAULT_DEV_VERSION {
+		return nil
+	}
+
+	c, err := semver.NewConstraint(constraint)
+	if err != nil {
+		return fmt.Errorf("Parsing version constraint: '%w'. Please check 'spec.expectVersions.tanka'", err)
+	}
+
+	v, err := semver.NewVersion(CURRENT_VERSION)
+	if err != nil {
+		return fmt.Errorf("'%s' is not a valid semantic version: '%w'.\nThis likely means your build of Tanka is broken, as this is a compile-time value. When in doubt, please raise an issue", CURRENT_VERSION, err)
+	}
+
+	if !c.Check(v) {
+		return fmt.Errorf("Current version '%s' does not satisfy the version required by the environment: '%s'. You likely need to use another version of Tanka", CURRENT_VERSION, constraint)
+	}
+
+	return nil
+}
+
 type ParseParallelOpts struct {
 	JsonnetOpts JsonnetOpts
 	Selector    labels.Selector
diff --git a/pkg/tanka/workflow.go b/pkg/tanka/workflow.go
index 1a3f5d2c..ff0f9e52 100644
--- a/pkg/tanka/workflow.go
+++ b/pkg/tanka/workflow.go
@@ -1,6 +1,7 @@
 package tanka
 
 import (
+	"encoding/json"
 	"fmt"
 
 	"github.com/fatih/color"
@@ -30,11 +31,11 @@ type ApplyOpts struct {
 // the evaluated jsonnet to the Kubernetes cluster defined in the environments
 // `spec.json`.
 func Apply(baseDir string, opts ApplyOpts) error {
-	l, err := load(baseDir, opts.Opts)
+	l, err := Load(baseDir, opts.Opts)
 	if err != nil {
 		return err
 	}
-	kube, err := l.connect()
+	kube, err := l.Connect()
 	if err != nil {
 		return err
 	}
@@ -101,11 +102,11 @@ type DiffOpts struct {
 // The cluster information is retrieved from the environments `spec.json`.
 // NOTE: This function requires on `diff(1)`, `kubectl(1)` and perhaps `diffstat(1)`
 func Diff(baseDir string, opts DiffOpts) (*string, error) {
-	l, err := load(baseDir, opts.Opts)
+	l, err := Load(baseDir, opts.Opts)
 	if err != nil {
 		return nil, err
 	}
-	kube, err := l.connect()
+	kube, err := l.Connect()
 	if err != nil {
 		return nil, err
 	}
@@ -134,11 +135,11 @@ type DeleteOpts struct {
 // the generated objects from the Kubernetes cluster defined in the environment's
 // `spec.json`.
 func Delete(baseDir string, opts DeleteOpts) error {
-	l, err := load(baseDir, opts.Opts)
+	l, err := Load(baseDir, opts.Opts)
 	if err != nil {
 		return err
 	}
-	kube, err := l.connect()
+	kube, err := l.Connect()
 	if err != nil {
 		return err
 	}
@@ -174,7 +175,7 @@ func Delete(baseDir string, opts DeleteOpts) error {
 // the list of Kubernetes objects.
 // Tip: use the `String()` function on the returned list to get the familiar yaml stream
 func Show(baseDir string, opts Opts) (manifest.List, error) {
-	l, err := load(baseDir, opts)
+	l, err := Load(baseDir, opts)
 	if err != nil {
 		return nil, err
 	}
@@ -183,14 +184,16 @@ func Show(baseDir string, opts Opts) (manifest.List, error) {
 }
 
 // Eval returns the raw evaluated Jsonnet output (without any transformations)
-func Eval(dir string, opts Opts) (raw interface{}, err error) {
-	r, _, err := ParseEnv(dir, opts.JsonnetOpts)
-	switch err.(type) {
-	case ErrNoEnv, ErrMultipleEnvs:
-		return r, err
-	case nil:
-		return r, nil
-	default:
+func Eval(dir string, opts Opts) (interface{}, error) {
+	raw, err := EvalJsonnet(dir, opts.JsonnetOpts)
+	if err != nil {
 		return nil, err
 	}
+
+	var data interface{}
+	if err := json.Unmarshal([]byte(raw), &data); err != nil {
+		return nil, err
+	}
+
+	return data, nil
 }


Test patch:
diff --git a/pkg/process/process_test.go b/pkg/process/process_test.go
index cf046159..5faec223 100644
--- a/pkg/process/process_test.go
+++ b/pkg/process/process_test.go
@@ -109,18 +109,19 @@ func TestProcess(t *testing.T) {
 
 	for _, c := range tests {
 		t.Run(c.name, func(t *testing.T) {
-			config := v1alpha1.New()
-			config.Metadata.Name = "testdata"
-			config.Spec = c.spec
+			env := v1alpha1.New()
+			env.Metadata.Name = "testdata"
+			env.Spec = c.spec
+			env.Data = c.deep.(map[string]interface{})
 
-			if config.Spec.InjectLabels {
+			if env.Spec.InjectLabels {
 				for i, m := range c.flat {
-					m.Metadata().Labels()[LabelEnvironment] = config.Metadata.NameLabel()
+					m.Metadata().Labels()[LabelEnvironment] = env.Metadata.NameLabel()
 					c.flat[i] = m
 				}
 			}
 
-			got, err := Process(c.deep.(map[string]interface{}), *config, c.targets)
+			got, err := Process(*env, c.targets)
 			require.Equal(t, c.err, err)
 
 			Sort(c.flat)
@@ -142,7 +143,10 @@ func mapToList(ms map[string]manifest.Manifest) manifest.List {
 func TestProcessOrder(t *testing.T) {
 	got := make([]manifest.List, 10)
 	for i := 0; i < 10; i++ {
-		r, err := Process(testDataDeep().Deep.(map[string]interface{}), *v1alpha1.New(), nil)
+		env := v1alpha1.New()
+		env.Data = testDataDeep().Deep.(map[string]interface{})
+
+		r, err := Process(*env, nil)
 		require.NoError(t, err)
 		got[i] = r
 	}
diff --git a/pkg/tanka/parse_test.go b/pkg/tanka/load_test.go
similarity index 55%
rename from pkg/tanka/parse_test.go
rename to pkg/tanka/load_test.go
index ba960c8c..36084d61 100644
--- a/pkg/tanka/parse_test.go
+++ b/pkg/tanka/load_test.go
@@ -3,42 +3,30 @@ package tanka
 import (
 	"testing"
 
+	"github.com/grafana/tanka/pkg/kubernetes/manifest"
 	"github.com/grafana/tanka/pkg/spec/v1alpha1"
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 )
 
-func TestEvalJsonnet(t *testing.T) {
+func TestLoad(t *testing.T) {
 	cases := []struct {
+		name     string
 		baseDir  string
 		expected interface{}
 		env      *v1alpha1.Environment
 	}{
 		{
-			baseDir: "./testdata/cases/array/",
-			expected: []interface{}{
-				[]interface{}{
-					map[string]interface{}{"testCase": "nestedArray[0][0]"},
-					map[string]interface{}{"testCase": "nestedArray[0][1]"},
-				},
-				[]interface{}{
-					map[string]interface{}{"testCase": "nestedArray[1][0]"},
-					map[string]interface{}{"testCase": "nestedArray[1][1]"},
-				},
-			},
-			env: nil,
-		},
-		{
-			baseDir: "./testdata/cases/object/",
-			expected: map[string]interface{}{
-				"testCase": "object",
-			},
-			env: nil,
-		},
-		{
+			name:    "static",
 			baseDir: "./testdata/cases/withspecjson/",
-			expected: map[string]interface{}{
-				"testCase": "object",
-			},
+			expected: manifest.List{{
+				"apiVersion": "v1",
+				"kind":       "ConfigMap",
+				"metadata": map[string]interface{}{
+					"name":      "config",
+					"namespace": "withspec",
+				},
+			}},
 			env: &v1alpha1.Environment{
 				APIVersion: v1alpha1.New().APIVersion,
 				Kind:       v1alpha1.New().Kind,
@@ -52,15 +40,23 @@ func TestEvalJsonnet(t *testing.T) {
 					Namespace: "withspec",
 				},
 				Data: map[string]interface{}{
-					"testCase": "object",
+					"apiVersion": "v1",
+					"kind":       "ConfigMap",
+					"metadata":   map[string]interface{}{"name": "config", "namespace": "withspec"},
 				},
 			},
 		},
 		{
+			name:    "static-filename",
 			baseDir: "./testdata/cases/withspecjson/main.jsonnet",
-			expected: map[string]interface{}{
-				"testCase": "object",
-			},
+			expected: manifest.List{{
+				"apiVersion": "v1",
+				"kind":       "ConfigMap",
+				"metadata": map[string]interface{}{
+					"name":      "config",
+					"namespace": "withspec",
+				},
+			}},
 			env: &v1alpha1.Environment{
 				APIVersion: v1alpha1.New().APIVersion,
 				Kind:       v1alpha1.New().Kind,
@@ -74,32 +70,30 @@ func TestEvalJsonnet(t *testing.T) {
 					Namespace: "withspec",
 				},
 				Data: map[string]interface{}{
-					"testCase": "object",
+					"apiVersion": "v1",
+					"kind":       "ConfigMap",
+					"metadata":   map[string]interface{}{"name": "config", "namespace": "withspec"},
 				},
 			},
 		},
+
 		{
+			name:    "inline",
 			baseDir: "./testdata/cases/withenv/main.jsonnet",
-			expected: map[string]interface{}{
-				"apiVersion": v1alpha1.New().APIVersion,
-				"kind":       v1alpha1.New().Kind,
+			expected: manifest.List{{
+				"apiVersion": "v1",
+				"kind":       "ConfigMap",
 				"metadata": map[string]interface{}{
-					"name": "withenv",
-				},
-				"spec": map[string]interface{}{
-					"apiServer": "https://localhost",
+					"name":      "config",
 					"namespace": "withenv",
 				},
-				"data": map[string]interface{}{
-					"testCase": "object",
-				},
-			},
+			}},
 			env: &v1alpha1.Environment{
 				APIVersion: v1alpha1.New().APIVersion,
 				Kind:       v1alpha1.New().Kind,
 				Metadata: v1alpha1.Metadata{
 					Name:      "withenv",
-					Namespace: "cases/withenv/main.jsonnet",
+					Namespace: "cases/withenv",
 					Labels:    v1alpha1.New().Metadata.Labels,
 				},
 				Spec: v1alpha1.Spec{
@@ -107,20 +101,21 @@ func TestEvalJsonnet(t *testing.T) {
 					Namespace: "withenv",
 				},
 				Data: map[string]interface{}{
-					"testCase": "object",
+					"apiVersion": "v1",
+					"kind":       "ConfigMap",
+					"metadata":   map[string]interface{}{"name": "config", "namespace": "withenv"},
 				},
 			},
 		},
 	}
 
 	for _, test := range cases {
-		data, env, e := ParseEnv(test.baseDir, JsonnetOpts{})
-		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)
+		t.Run(test.name, func(t *testing.T) {
+			l, err := Load(test.baseDir, Opts{})
+			require.NoError(t, err)
+
+			assert.Equal(t, test.expected, l.Resources)
+			assert.Equal(t, test.env, l.Env)
+		})
 	}
 }
diff --git a/pkg/tanka/testdata/cases/withenv/main.jsonnet b/pkg/tanka/testdata/cases/withenv/main.jsonnet
index d6f4b7c2..a3916a26 100644
--- a/pkg/tanka/testdata/cases/withenv/main.jsonnet
+++ b/pkg/tanka/testdata/cases/withenv/main.jsonnet
@@ -9,6 +9,8 @@
     namespace: 'withenv',
   },
   data: {
-    testCase: 'object',
+    apiVersion: 'v1',
+    kind: 'ConfigMap',
+    metadata: { name: 'config' },
   },
 }
diff --git a/pkg/tanka/testdata/cases/withspecjson/main.jsonnet b/pkg/tanka/testdata/cases/withspecjson/main.jsonnet
index 157b04a4..1f7ef44c 100644
--- a/pkg/tanka/testdata/cases/withspecjson/main.jsonnet
+++ b/pkg/tanka/testdata/cases/withspecjson/main.jsonnet
@@ -1,3 +1,5 @@
 {
-  testCase: 'object',
+  apiVersion: 'v1',
+  kind: 'ConfigMap',
+  metadata: { name: 'config' },
 }