Repository: grafana/tanka
Pull request #456 (2020-12-30 18:36:49)

fix(jpath): support nested calling again 
Originally, Tanka was able to find the environments entrypoint, even when it was actually called on a directory that is nested underneath that environment.

This was modeled after the ability to use `git` commands anywhere in the project.

This functionality was lost however in a recent PR (https://github.com/grafana/tanka/pull/389)

While restoring it, I took the chance to do a long overdue refactoring of the `jpath` package.



Patch:
diff --git a/cmd/tk/jsonnet.go b/cmd/tk/jsonnet.go
index cb578835..16fb5f08 100644
--- a/cmd/tk/jsonnet.go
+++ b/cmd/tk/jsonnet.go
@@ -27,7 +27,7 @@ func evalCmd() *cli.Command {
 		jsonnetOpts := tanka.Opts{
 			JsonnetOpts: getJsonnetOpts(),
 		}
-		jsonnetOpts.EvalPattern = *evalPattern
+		jsonnetOpts.EvalScript = "(import '%s')." + *evalPattern
 		raw, err := tanka.Eval(args[0], jsonnetOpts)
 
 		if raw == nil && err != nil {
diff --git a/cmd/tk/main.go b/cmd/tk/main.go
index f3f220ae..02be8147 100644
--- a/cmd/tk/main.go
+++ b/cmd/tk/main.go
@@ -8,6 +8,7 @@ import (
 
 	"golang.org/x/crypto/ssh/terminal"
 
+	"github.com/fatih/color"
 	"github.com/go-clix/cli"
 
 	"github.com/grafana/tanka/pkg/jsonnet/jpath"
@@ -56,7 +57,7 @@ func main() {
 
 	// Run!
 	if err := rootCmd.Execute(); err != nil {
-		log.Fatalln(err)
+		log.Fatalln(color.RedString("Error:"), err)
 	}
 }
 
@@ -79,7 +80,7 @@ func setupConfiguration(baseDir string) *v1alpha1.Environment {
 			}
 		// no spec.json is found, try parsing main.jsonnet
 		case spec.ErrNoSpec:
-			_, config, err := tanka.ParseEnv(baseDir, tanka.ParseOpts{Evaluator: tanka.EnvsOnlyEvaluator})
+			_, config, err := tanka.ParseEnv(baseDir, tanka.JsonnetOpts{EvalScript: tanka.EnvsOnlyEvalScript})
 			if err != nil {
 				switch err.(type) {
 				case tanka.ErrNoEnv:
diff --git a/pkg/jsonnet/eval.go b/pkg/jsonnet/eval.go
index 2741c1a0..812475f5 100644
--- a/pkg/jsonnet/eval.go
+++ b/pkg/jsonnet/eval.go
@@ -29,7 +29,7 @@ type Opts struct {
 	ExtCode     InjectedCode
 	TLACode     InjectedCode
 	ImportPaths []string
-	EvalPattern string
+	EvalScript  string
 }
 
 // MakeVM returns a Jsonnet VM with some extensions of Tanka, including:
diff --git a/pkg/jsonnet/jpath/dirs.go b/pkg/jsonnet/jpath/dirs.go
new file mode 100644
index 00000000..43ab9c49
--- /dev/null
+++ b/pkg/jsonnet/jpath/dirs.go
@@ -0,0 +1,110 @@
+package jpath
+
+import (
+	"io/ioutil"
+	"os"
+	"path/filepath"
+	"runtime"
+)
+
+// FindRoot returns the absolute path of the project root, being the directory
+// that directly holds `tkrc.yaml` if it exists, otherwise the directory that
+// directly holds `jsonnetfile.json`
+func FindRoot(path string) (dir string, err error) {
+	start, err := FsDir(path)
+	if err != nil {
+		return "", err
+	}
+
+	// root path based on os
+	stop := "/"
+	if runtime.GOOS == "windows" {
+		stop = filepath.VolumeName(start) + "\\"
+	}
+
+	// try tkrc.yaml first
+	root, err := FindParentFile("tkrc.yaml", start, stop)
+	if err == nil {
+		return root, nil
+	}
+
+	// otherwise use jsonnetfile.json
+	root, err = FindParentFile("jsonnetfile.json", start, stop)
+	if _, ok := err.(ErrorFileNotFound); ok {
+		return "", ErrorNoRoot
+	} else if err != nil {
+		return "", err
+	}
+
+	return root, nil
+}
+
+// FindBase returns the absolute path of the environments base directory, the
+// one which directly holds the entrypoint file.
+func FindBase(path string, root string) (string, error) {
+	dir, err := FsDir(path)
+	if err != nil {
+		return "", err
+	}
+
+	filename, err := Filename(path)
+	if err != nil {
+		return "", err
+	}
+
+	base, err := FindParentFile(filename, dir, root)
+
+	if _, ok := err.(ErrorFileNotFound); ok {
+		return "", ErrorNoBase{filename: filename}
+	} else if err != nil {
+		return "", err
+	}
+
+	return base, nil
+}
+
+// FindParentFile traverses the parent directory tree for the given `file`,
+// starting from `start` and ending in `stop`. If the file is not found an error is returned.
+func FindParentFile(file, start, stop string) (string, error) {
+	files, err := ioutil.ReadDir(start)
+	if err != nil {
+		return "", err
+	}
+
+	if dirContainsFile(files, file) {
+		return start, nil
+	} else if start == stop {
+		return "", ErrorFileNotFound{file}
+	}
+	return FindParentFile(file, filepath.Dir(start), stop)
+}
+
+// dirContainsFile returns whether a file is included in a directory.
+func dirContainsFile(files []os.FileInfo, filename string) bool {
+	for _, f := range files {
+		if f.Name() == filename {
+			return true
+		}
+	}
+	return false
+}
+
+// FsDir returns the most inner directory of path, as reported by the local
+// filesystem
+func FsDir(path string) (string, error) {
+	path, err := filepath.Abs(path)
+	if err != nil {
+		return "", err
+	}
+
+	fi, err := os.Stat(path)
+	if err != nil {
+		return "", err
+	}
+
+	if fi.IsDir() {
+		return path, nil
+	}
+
+	return filepath.Dir(path), nil
+}
diff --git a/pkg/jsonnet/jpath/errors.go b/pkg/jsonnet/jpath/errors.go
new file mode 100644
index 00000000..a75ee7b5
--- /dev/null
+++ b/pkg/jsonnet/jpath/errors.go
@@ -0,0 +1,31 @@
+package jpath
+
+import (
+	"errors"
+	"fmt"
+)
+
+// ErrorNoRoot means no rootDir was found in the parent directories
+var ErrorNoRoot = errors.New(`Unable to identify the project root.
+Tried to find 'tkrc.yaml' or 'jsonnetfile.json' in the parent directories.
+Please refer to https://tanka.dev/directory-structure for more information`)
+
+// ErrorNoBase means no baseDir was found in the parent directories
+type ErrorNoBase struct {
+	filename string
+}
+
+func (e ErrorNoBase) Error() string {
+	return fmt.Sprintf(`Unable to identify the environments base directory.
+Tried to find '%s' in the parent directories.
+Please refer to https://tanka.dev/directory-structure for more information`, e.filename)
+}
+
+// ErrorFileNotFound means that the searched file was not found
+type ErrorFileNotFound struct {
+	filename string
+}
+
+func (e ErrorFileNotFound) Error() string {
+	return e.filename + " not found"
+}
diff --git a/pkg/jsonnet/jpath/jpath.go b/pkg/jsonnet/jpath/jpath.go
index eb606995..d0372dc2 100644
--- a/pkg/jsonnet/jpath/jpath.go
+++ b/pkg/jsonnet/jpath/jpath.go
@@ -1,32 +1,12 @@
 package jpath
 
 import (
-	"errors"
-	"io/ioutil"
 	"os"
 	"path/filepath"
-	"runtime"
 )
 
 const DEFAULT_ENTRYPOINT = "main.jsonnet"
 
-var (
-	// ErrorNoRoot means no rootDir was found in the parents
-	ErrorNoRoot = errors.New("could not locate a tkrc.yaml or jsonnetfile.json in the parent directories, which is required to identify the project root.\nRefer to https://tanka.dev/directory-structure for more information")
-
-	// ErrorNoBase means no baseDir was found in the parents
-	ErrorNoBase = errors.New("could not locate entrypoint (usually main.jsonnet) in the parent directories, which is required as the entrypoint for the evaluation.\nRefer to https://tanka.dev/directory-structure for more information")
-)
-
-// ErrorFileNotFound means that the searched file was not found
-type ErrorFileNotFound struct {
-	filename string
-}
-
-func (e ErrorFileNotFound) Error() string {
-	return e.filename + " not found"
-}
-
 // Resolve the given path and resolves the jPath around it. This means it:
 // - figures out the project root (the one with .jsonnetfile, vendor/ and lib/)
 // - figures out the environments base directory (usually the main.jsonnet)
@@ -35,24 +15,16 @@ func (e ErrorFileNotFound) Error() string {
 // This results in predictable imports, as it doesn't matter whether the user called
 // called the command further down tree or not. A little bit like git.
 func Resolve(path string) (jpath []string, base, root string, err error) {
-	entrypoint, err := Entrypoint(path)
+	root, err = FindRoot(path)
 	if err != nil {
 		return nil, "", "", err
 	}
 
-	root, err = FindRoot(filepath.Dir(entrypoint))
+	base, err = FindBase(path, root)
 	if err != nil {
 		return nil, "", "", err
 	}
 
-	base, err = FindParentFile(filepath.Base(entrypoint), filepath.Dir(entrypoint), root)
-	if err != nil {
-		if _, ok := err.(ErrorFileNotFound); ok {
-			return nil, "", "", ErrorNoBase
-		}
-		return nil, "", "", err
-	}
-
 	// The importer iterates through this list in reverse order
 	return []string{
 		filepath.Join(root, "vendor"),
@@ -62,75 +34,40 @@ func Resolve(path string) (jpath []string, base, root string, err error) {
 	}, base, root, nil
 }
 
-// FindRoot searches for a rootDir by the following criteria:
-// - tkrc.yaml is considered first, for a jb-independent way of marking the root
-// - if it is not present (default), jsonnetfile.json is used.
-func FindRoot(start string) (dir string, err error) {
-	// root path based on os
-	stop := "/"
-	if runtime.GOOS == "windows" {
-		stop = filepath.VolumeName(start) + "\\"
-	}
-
-	// try tkrc.yaml first
-	root, err := FindParentFile("tkrc.yaml", start, stop)
-	if err == nil {
-		return root, nil
-	}
-
-	// otherwise use jsonnetfile.json
-	root, err = FindParentFile("jsonnetfile.json", start, stop)
+// Filename returns the name of the entrypoint file.
+// It DOES NOT return an absolute path, only a plain name like "main.jsonnet"
+// To obtain an absolute path, use Entrypoint() instead.
+func Filename(path string) (string, error) {
+	fi, err := os.Stat(path)
 	if err != nil {
-		if _, ok := err.(ErrorFileNotFound); ok {
-			return "", ErrorNoRoot
-		}
 		return "", err
 	}
 
-	return root, nil
-}
-
-// FindParentFile traverses the parent directory tree for the given `file`,
-// starting from `start` and ending in `stop`. If the file is not found an error is returned.
-func FindParentFile(file, start, stop string) (string, error) {
-	files, err := ioutil.ReadDir(start)
-	if err != nil {
-		return "", err
+	if fi.IsDir() {
+		return DEFAULT_ENTRYPOINT, nil
 	}
 
-	if dirContainsFile(files, file) {
-		return start, nil
-	} else if start == stop {
-		return "", ErrorFileNotFound{file}
-	}
-	return FindParentFile(file, filepath.Dir(start), stop)
-}
+	return filepath.Base(fi.Name()), nil
 
-// dirContainsFile returns whether a file is included in a directory.
-func dirContainsFile(files []os.FileInfo, filename string) bool {
-	for _, f := range files {
-		if f.Name() == filename {
-			return true
-		}
-	}
-	return false
 }
 
+// Entrypoint returns the absolute path of the environments entrypoint file (the
+// one passed to jsonnet.EvaluateFile)
 func Entrypoint(path string) (string, error) {
-	filename := DEFAULT_ENTRYPOINT
-
-	entrypoint, err := filepath.Abs(path)
+	root, err := FindRoot(path)
 	if err != nil {
 		return "", err
 	}
 
-	stat, err := os.Stat(entrypoint)
+	base, err := FindBase(path, root)
 	if err != nil {
 		return "", err
 	}
-	if !stat.IsDir() {
-		return entrypoint, nil
+
+	filename, err := Filename(path)
+	if err != nil {
+		return "", err
 	}
 
-	return filepath.Join(entrypoint, filename), nil
+	return filepath.Join(base, filename), nil
 }
diff --git a/pkg/tanka/evaluators.go b/pkg/tanka/evaluators.go
index 33f0bc2f..a6e8a766 100644
--- a/pkg/tanka/evaluators.go
+++ b/pkg/tanka/evaluators.go
@@ -9,20 +9,16 @@ import (
 	"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) {
+// EvalJsonnet evaluates the jsonnet environment at the given file system path
+func EvalJsonnet(path string, opts jsonnet.Opts) (raw string, err error) {
 	entrypoint, err := jpath.Entrypoint(path)
 	if err != nil {
 		return "", err
 	}
 
 	// evaluate Jsonnet
-	var raw string
-	if opts.EvalPattern != "" {
-		evalScript := fmt.Sprintf("(import '%s').%s", entrypoint, opts.EvalPattern)
+	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")
@@ -36,49 +32,33 @@ func DefaultEvaluator(path string, opts jsonnet.Opts) (string, error) {
 	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 := `
+// EnvsOnlyEvalScript finds the Environment object (without its .data object)
+const EnvsOnlyEvalScript = `
 local noDataEnv(object) =
-  if std.isObject(object)
-  then
-    if std.objectHas(object, 'apiVersion')
-       && std.objectHas(object, 'kind')
+  std.prune(
+    if std.isObject(object)
     then
-      if object.kind == 'Environment'
-      then object { data:: {} }
-      else {}
-    else
-      std.mapWithKey(
-        function(key, obj)
+      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 if std.isArray(object)
-  then
-    std.map(
-      function(obj)
-        noDataEnv(obj),
-      object
-    )
-  else {};
+    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 30808003..fa3e97ef 100644
--- a/pkg/tanka/parse.go
+++ b/pkg/tanka/parse.go
@@ -64,7 +64,7 @@ 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) {
-	_, env, err := ParseEnv(path, ParseOpts{JsonnetOpts: opts.JsonnetOpts})
+	_, env, err := ParseEnv(path, opts.JsonnetOpts)
 	if err != nil {
 		return nil, err
 	}
@@ -97,7 +97,7 @@ func load(path string, opts Opts) (*loaded, error) {
 func parseSpec(path string) (*v1alpha1.Environment, error) {
 	_, baseDir, rootDir, err := jpath.Resolve(path)
 	if err != nil {
-		return nil, errors.Wrap(err, "resolving jpath")
+		return nil, err
 	}
 
 	// name of the environment: relative path from rootDir
@@ -114,7 +114,7 @@ func parseSpec(path string) (*v1alpha1.Environment, error) {
 			return config, err
 		// some other error
 		default:
-			return nil, errors.Wrap(err, "reading spec.json")
+			return nil, err
 		}
 	}
 
@@ -123,14 +123,14 @@ func parseSpec(path string) (*v1alpha1.Environment, error) {
 
 // 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) {
+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, errors.Wrap(err, "reading spec.json")
+			return nil, nil, err
 		}
 	} else {
 		// original behavior, if env has spec.json
@@ -139,14 +139,10 @@ func ParseEnv(path string, opts ParseOpts) (interface{}, *v1alpha1.Environment,
 		if err != nil {
 			return nil, nil, errors.Wrap(err, "marshalling environment config")
 		}
-		opts.JsonnetOpts.ExtCode.Set(spec.APIGroup+"/environment", string(jsonEnv))
-	}
-
-	if opts.Evaluator == nil {
-		opts.Evaluator = DefaultEvaluator
+		opts.ExtCode.Set(spec.APIGroup+"/environment", string(jsonEnv))
 	}
 
-	raw, err := opts.Evaluator(path, opts.JsonnetOpts)
+	raw, err := EvalJsonnet(path, opts)
 	if err != nil {
 		return nil, nil, err
 	}
@@ -156,8 +152,8 @@ func ParseEnv(path string, opts ParseOpts) (interface{}, *v1alpha1.Environment,
 		return nil, nil, errors.Wrap(err, "unmarshalling data")
 	}
 
-	if opts.JsonnetOpts.EvalPattern != "" {
-		// EvalPattern has no affinity with an environment, behave as jsonnet interpreter
+	if opts.EvalScript != "" {
+		// EvalScript has no affinity with an environment, behave as jsonnet interpreter
 		return data, nil, nil
 	}
 
diff --git a/pkg/tanka/tanka.go b/pkg/tanka/tanka.go
index 4bdd1afa..5564a5d4 100644
--- a/pkg/tanka/tanka.go
+++ b/pkg/tanka/tanka.go
@@ -18,8 +18,3 @@ type Opts struct {
 	// Filters are used to optionally select a subset of the resources
 	Filters process.Matchers
 }
-
-type ParseOpts struct {
-	JsonnetOpts
-	Evaluator Evaluator
-}
diff --git a/pkg/tanka/workflow.go b/pkg/tanka/workflow.go
index ecfa5f53..1a3f5d2c 100644
--- a/pkg/tanka/workflow.go
+++ b/pkg/tanka/workflow.go
@@ -184,7 +184,7 @@ 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, ParseOpts{JsonnetOpts: opts.JsonnetOpts})
+	r, _, err := ParseEnv(dir, opts.JsonnetOpts)
 	switch err.(type) {
 	case ErrNoEnv, ErrMultipleEnvs:
 		return r, err


Test patch:
diff --git a/pkg/jsonnet/jpath/dirs_test.go b/pkg/jsonnet/jpath/dirs_test.go
index 5de11e00..f9075f89 100644
--- a/pkg/jsonnet/jpath/dirs_test.go
+++ b/pkg/jsonnet/jpath/dirs_test.go
@@ -11,6 +11,125 @@ import (
 	"github.com/stretchr/testify/require"
 )
 
+func TestDirs(t *testing.T) {
+	origDir, err := os.Getwd()
+	require.NoError(t, err)
+
+	cases := []struct {
+		name    string
+		data    string
+		workdir string
+		env     string
+
+		wantBase    string
+		wantRoot    string
+		wantRootErr error
+		wantBaseErr error
+	}{
+		// noBase
+		{
+			name: "noBase/empty",
+			data: "./testdata/noBase",
+			env:  "environments/empty",
+
+			wantRoot:    "/",
+			wantBaseErr: ErrorNoBase{"main.jsonnet"},
+		},
+		{
+			name: "noBase/filename",
+			data: "./testdata/noBase",
+			env:  "environments/filename",
+
+			wantRoot:    "/",
+			wantBaseErr: ErrorNoBase{"main.jsonnet"},
+		},
+		{
+			name: "noBase/noMain",
+			data: "./testdata/noBase",
+			env:  "environments/noMain",
+
+			wantRoot:    "/",
+			wantBaseErr: ErrorNoBase{"main.jsonnet"},
+		},
+
+		// noRoot
+		{
+			name: "noRoot",
+			data: "./testdata/noRoot",
+			env:  "environments/default",
+
+			wantRootErr: ErrorNoRoot,
+		},
+
+		// valid
+		{
+			name: "valid:relative",
+			data: "./testdata/valid",
+			env:  "environments/default",
+
+			wantRoot: "/",
+			wantBase: "environments/default",
+		},
+		{
+			name:    "valid:currentDir",
+			data:    "./testdata/valid",
+			env:     ".",
+			workdir: "environments/default",
+
+			wantRoot: "/",
+			wantBase: "environments/default",
+		},
+		{
+			name:    "valid:nested",
+			data:    "./testdata/valid",
+			env:     ".",
+			workdir: "environments/default/nestedDir",
+
+			wantRoot: "/",
+			wantBase: "environments/default",
+		},
+		{
+			name:    "valid:nested2",
+			data:    "./testdata/valid",
+			env:     "..",
+			workdir: "environments/default/nestedDir",
+
+			wantRoot: "/",
+			wantBase: "environments/default",
+		},
+	}
+
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			defer os.Chdir(origDir)
+
+			// go to testdata
+			require.NoError(t, os.Chdir(c.data))
+
+			tdRoot, err := os.Getwd()
+			require.NoError(t, err)
+
+			if c.workdir != "" {
+				require.NoError(t, os.Chdir(c.workdir))
+			}
+
+			root, err := FindRoot(c.env)
+			assert.Equal(t, c.wantRootErr, err)
+			if err == nil {
+				assert.Equal(t, filepath.Join(tdRoot, c.wantRoot), root)
+			} else {
+				return
+			}
+
+			base, err := FindBase(c.env, root)
+			assert.Equal(t, c.wantBaseErr, err)
+			if err == nil {
+				assert.Equal(t, filepath.Join(tdRoot, c.wantBase), base)
+			}
+		})
+	}
+}
+
 type scenario struct {
 	name        string
 	testdata    []string
@@ -37,7 +156,7 @@ func TestFindRoot(t *testing.T) {
 			name:        "missing-basePointer",
 			environment: "environments/default",
 			testdata:    []string{"jsonnetfile.json", "environments/default/"},
-			err:         ErrorNoBase,
+			err:         ErrorNoBase{filename: "main.jsonnet"},
 		},
 		// Scenario: Missing root pointerfile. We expect an ErrorNoRoot.
 		{
diff --git a/pkg/jsonnet/jpath/testdata/noBase/environments/empty/.gitkeep b/pkg/jsonnet/jpath/testdata/noBase/environments/empty/.gitkeep
new file mode 100644
index 00000000..e69de29b
diff --git a/pkg/jsonnet/jpath/testdata/noBase/environments/filename/custom.jsonnet b/pkg/jsonnet/jpath/testdata/noBase/environments/filename/custom.jsonnet
new file mode 100644
index 00000000..0967ef42
--- /dev/null
+++ b/pkg/jsonnet/jpath/testdata/noBase/environments/filename/custom.jsonnet
@@ -0,0 +1,1 @@
+{}
diff --git a/pkg/jsonnet/jpath/testdata/noBase/environments/filename/spec.json b/pkg/jsonnet/jpath/testdata/noBase/environments/filename/spec.json
new file mode 100644
index 00000000..c08726f1
--- /dev/null
+++ b/pkg/jsonnet/jpath/testdata/noBase/environments/filename/spec.json
@@ -0,0 +1,13 @@
+{
+  "apiVersion": "tanka.dev/v1alpha1",
+  "kind": "Environment",
+  "metadata": {
+    "name": "environments/filename"
+  },
+  "spec": {
+    "apiServer": "",
+    "namespace": "default",
+    "resourceDefaults": {},
+    "expectVersions": {}
+  }
+}
diff --git a/pkg/jsonnet/jpath/testdata/noBase/environments/noMain/spec.json b/pkg/jsonnet/jpath/testdata/noBase/environments/noMain/spec.json
new file mode 100644
index 00000000..44dfe5d1
--- /dev/null
+++ b/pkg/jsonnet/jpath/testdata/noBase/environments/noMain/spec.json
@@ -0,0 +1,13 @@
+{
+  "apiVersion": "tanka.dev/v1alpha1",
+  "kind": "Environment",
+  "metadata": {
+    "name": "environments/noMain"
+  },
+  "spec": {
+    "apiServer": "",
+    "namespace": "default",
+    "resourceDefaults": {},
+    "expectVersions": {}
+  }
+}
diff --git a/pkg/jsonnet/jpath/testdata/noBase/jsonnetfile.json b/pkg/jsonnet/jpath/testdata/noBase/jsonnetfile.json
new file mode 100644
index 00000000..9e26dfee
--- /dev/null
+++ b/pkg/jsonnet/jpath/testdata/noBase/jsonnetfile.json
@@ -0,0 +1,1 @@
+{}
\ No newline at end of file
diff --git a/pkg/jsonnet/jpath/testdata/noRoot/environments/default/main.jsonnet b/pkg/jsonnet/jpath/testdata/noRoot/environments/default/main.jsonnet
new file mode 100644
index 00000000..0967ef42
--- /dev/null
+++ b/pkg/jsonnet/jpath/testdata/noRoot/environments/default/main.jsonnet
@@ -0,0 +1,1 @@
+{}
diff --git a/pkg/jsonnet/jpath/testdata/noRoot/environments/default/spec.json b/pkg/jsonnet/jpath/testdata/noRoot/environments/default/spec.json
new file mode 100644
index 00000000..70a3ffb1
--- /dev/null
+++ b/pkg/jsonnet/jpath/testdata/noRoot/environments/default/spec.json
@@ -0,0 +1,13 @@
+{
+  "apiVersion": "tanka.dev/v1alpha1",
+  "kind": "Environment",
+  "metadata": {
+    "name": "environments/default"
+  },
+  "spec": {
+    "apiServer": "",
+    "namespace": "default",
+    "resourceDefaults": {},
+    "expectVersions": {}
+  }
+}
diff --git a/pkg/jsonnet/jpath/testdata/valid/environments/default/main.jsonnet b/pkg/jsonnet/jpath/testdata/valid/environments/default/main.jsonnet
new file mode 100644
index 00000000..3d36287a
--- /dev/null
+++ b/pkg/jsonnet/jpath/testdata/valid/environments/default/main.jsonnet
@@ -0,0 +1,6 @@
+{
+  apiVersion: 'v1',
+  kind: 'ConfigMap',
+  metadata: { name: 'myConfig' },
+  data: (import "nestedDir/file.jsonnet"),
+}
diff --git a/pkg/jsonnet/jpath/testdata/valid/environments/default/nestedDir/file.jsonnet b/pkg/jsonnet/jpath/testdata/valid/environments/default/nestedDir/file.jsonnet
new file mode 100644
index 00000000..f626ab03
--- /dev/null
+++ b/pkg/jsonnet/jpath/testdata/valid/environments/default/nestedDir/file.jsonnet
@@ -0,0 +1,3 @@
+{
+  "foo.yml": "foo: bar\nbar: baz"
+}
diff --git a/pkg/jsonnet/jpath/testdata/valid/environments/default/spec.json b/pkg/jsonnet/jpath/testdata/valid/environments/default/spec.json
new file mode 100644
index 00000000..70a3ffb1
--- /dev/null
+++ b/pkg/jsonnet/jpath/testdata/valid/environments/default/spec.json
@@ -0,0 +1,13 @@
+{
+  "apiVersion": "tanka.dev/v1alpha1",
+  "kind": "Environment",
+  "metadata": {
+    "name": "environments/default"
+  },
+  "spec": {
+    "apiServer": "",
+    "namespace": "default",
+    "resourceDefaults": {},
+    "expectVersions": {}
+  }
+}
diff --git a/pkg/jsonnet/jpath/testdata/valid/jsonnetfile.json b/pkg/jsonnet/jpath/testdata/valid/jsonnetfile.json
new file mode 100644
index 00000000..9e26dfee
--- /dev/null
+++ b/pkg/jsonnet/jpath/testdata/valid/jsonnetfile.json
@@ -0,0 +1,1 @@
+{}
\ No newline at end of file
diff --git a/pkg/tanka/parse_test.go b/pkg/tanka/parse_test.go
index 4145d298..ba960c8c 100644
--- a/pkg/tanka/parse_test.go
+++ b/pkg/tanka/parse_test.go
@@ -114,7 +114,7 @@ func TestEvalJsonnet(t *testing.T) {
 	}
 
 	for _, test := range cases {
-		data, env, e := ParseEnv(test.baseDir, ParseOpts{})
+		data, env, e := ParseEnv(test.baseDir, JsonnetOpts{})
 		if data == nil {
 			assert.NoError(t, e)
 		} else if e != nil {