Repository: grafana/tanka
Pull request #450 (2020-12-20 11:42:30)

feat(cli): bulk export
This ports the functionality for bulk exporting Tanka environments back from our internal CI/CD tooling.

The gist:

* moved generalized logic from `cmd/tk` to `pkg/tanka`
* add `ParseParallel` to evaluate envs in parallel
* `tk env list` uses `ParseParallel`
* `tk export --format` now takes `{{env.<...>}}` and fills it with the tanka.dev/Environment object
* `tk export` can export multiple environments and find them recursively with `--recursive`
* `tk export` creates a `manifest.json` file to map files back to an environment
* `tk export` can use `-l <labelFilters>`, similar to `tk env list` and `kubectl`

Examples:
```bash
# Format based on environment {{env.<...>}}
$ tk export exportDir environments/dev/ --format '{{env.metadata.labels.cluster}}/{{env.spec.namespace}}//{{.kind}}-{{.metadata.name}}'
# Export multiple environments
$ tk export exportDir environments/dev/ environments/qa/
# Recursive export
$ tk export exportDir environments/ --recursive
# Recursive export with labelSelector
$ tk export exportDir environments/ -r -l team=infra
```

Note: this changes `tk export`, `<exportDir>` now comes before the `<environment>`.

malcolmholmes: I think the syntax should be to expect a dir to be a single environment, or if -r is specified, recursively look for environments. This gives a clearer expectation of what the command will do - means people must explicitly request the recursive behaviour.
Duologic: Rebased.

Patch:
diff --git a/.gitignore b/.gitignore
index 517ab3db..18e9056f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,3 @@
 dist
-tk
+/tk
+/cmd/tk/tk
diff --git a/cmd/tk/args.go b/cmd/tk/args.go
index 24f39b47..46f47760 100644
--- a/cmd/tk/args.go
+++ b/cmd/tk/args.go
@@ -1,15 +1,38 @@
 package main
 
 import (
+	"os"
+	"path/filepath"
+
 	"github.com/go-clix/cli"
 	"github.com/posener/complete"
+
+	"github.com/grafana/tanka/pkg/tanka"
 )
 
 var workflowArgs = cli.Args{
 	Validator: cli.ValidateExact(1),
 	Predictor: cli.PredictFunc(func(args complete.Args) []string {
-		if dirs := findBaseDirs(); len(dirs) != 0 {
-			return dirs
+		pwd, err := os.Getwd()
+		if err != nil {
+			return nil
+		}
+
+		dirs, err := tanka.FindBaseDirs(pwd)
+		if err != nil {
+			return nil
+		}
+
+		var reldirs []string
+		for _, dir := range dirs {
+			reldir, err := filepath.Rel(pwd, dir)
+			if err == nil {
+				reldirs = append(reldirs, reldir)
+			}
+		}
+
+		if len(reldirs) != 0 {
+			return reldirs
 		}
 
 		return complete.PredictDirs("*").Predict(args)
diff --git a/cmd/tk/env.go b/cmd/tk/env.go
index b21f648b..5cf5f818 100644
--- a/cmd/tk/env.go
+++ b/cmd/tk/env.go
@@ -11,12 +11,12 @@ import (
 	"github.com/go-clix/cli"
 	"github.com/pkg/errors"
 	"github.com/posener/complete"
-	"github.com/spf13/pflag"
-	"k8s.io/apimachinery/pkg/labels"
 
 	"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"
 )
 
@@ -36,13 +36,6 @@ func envCmd() *cli.Command {
 	return cmd
 }
 
-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")
-	fs.StringVar(&env.Spec.DiffStrategy, "diff-strategy", env.Spec.DiffStrategy, "specify diff-strategy. Automatically detected otherwise.")
-}
-
 var kubectlContexts = cli.PredictFunc(
 	func(complete.Args) []string {
 		c, _ := client.Contexts()
@@ -50,6 +43,32 @@ 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",
@@ -213,40 +232,49 @@ func envRemoveCmd() *cli.Command {
 }
 
 func envListCmd() *cli.Command {
+	args := workflowArgs
+	args.Validator = cli.ValidateFunc(func(args []string) error {
+		if len(args) > 1 {
+			return fmt.Errorf("expects at most 1 arg, received %v", len(args))
+		}
+		return nil
+	})
+
 	cmd := &cli.Command{
-		Use:     "list",
+		Use:     "list [<path>]",
 		Aliases: []string{"ls"},
-		Short:   "list environments",
-		Args:    cli.ArgsNone(),
+		Short:   "list environments relative to current dir or <path>",
+		Args:    args,
 	}
 
 	useJSON := cmd.Flags().Bool("json", false, "json output")
-	labelSelector := cmd.Flags().StringP("selector", "l", "", "Label selector. Uses the same syntax as kubectl does")
+	getLabelSelector := labelSelectorFlag(cmd.Flags())
 
 	useNames := cmd.Flags().Bool("names", false, "plain names output")
 
 	cmd.Run = func(cmd *cli.Command, args []string) error {
-		envs := []v1alpha1.Environment{}
-		dirs := findBaseDirs()
-		var selector labels.Selector
+		var dir string
 		var err error
-
-		if *labelSelector != "" {
-			selector, err = labels.Parse(*labelSelector)
+		if len(args) == 1 {
+			dir = args[0]
+		} else {
+			dir, err = os.Getwd()
 			if err != nil {
-				return err
+				return nil
 			}
 		}
 
-		for _, dir := range dirs {
-			env := setupConfiguration(dir)
-			if env == nil {
-				log.Printf("Could not setup configuration from %q", dir)
-				continue
-			}
-			if selector == nil || selector.Empty() || selector.Matches(env.Metadata) {
-				envs = append(envs, *env)
-			}
+		stat, err := os.Stat(dir)
+		if err != nil {
+			return err
+		}
+		if !stat.IsDir() {
+			return fmt.Errorf("Not a directory: %s", dir)
+		}
+
+		envs, err := tanka.FindEnvironments(dir, getLabelSelector())
+		if err != nil {
+			return err
 		}
 
 		if *useJSON {
diff --git a/cmd/tk/export.go b/cmd/tk/export.go
index 8ce4a6ac..19eef18a 100644
--- a/cmd/tk/export.go
+++ b/cmd/tk/export.go
@@ -1,137 +1,88 @@
 package main
 
 import (
-	"bytes"
 	"fmt"
-	"io"
-	"io/ioutil"
-	"log"
-	"os"
 	"path/filepath"
-	"strings"
 
-	"text/template"
-
-	"github.com/Masterminds/sprig/v3"
 	"github.com/go-clix/cli"
+	"github.com/pkg/errors"
 
+	"github.com/grafana/tanka/pkg/jsonnet/jpath"
 	"github.com/grafana/tanka/pkg/tanka"
 )
 
-// BelRune is a string of the Ascii character BEL which made computers ring in ancient times
-// We use it as "magic" char for the subfolder creation as it is a non printable character and thereby will never be
-// in a valid filepath by accident. Only when we include it.
-const BelRune = string(rune(7))
-
 func exportCmd() *cli.Command {
 	args := workflowArgs
-	args.Validator = cli.ValidateExact(2)
+	args.Validator = cli.ValidateFunc(func(args []string) error {
+		if len(args) < 2 {
+			return fmt.Errorf("expects at least 2 args, received %v", len(args))
+		}
+		return nil
+	})
 
 	cmd := &cli.Command{
-		Use:   "export <environment> <outputDir>",
-		Short: "write each resources as a YAML file",
+		Use:   "export <outputDir> <path> [<path>...]",
+		Short: "export environments found in path(s)",
 		Args:  args,
 	}
 
-	format := cmd.Flags().String("format", "{{.apiVersion}}.{{.kind}}-{{.metadata.name}}", "https://tanka.dev/exporting#filenames")
+	format := cmd.Flags().String(
+		"format",
+		"{{.apiVersion}}.{{.kind}}-{{.metadata.name}}",
+		"https://tanka.dev/exporting#filenames",
+	)
+
 	extension := cmd.Flags().String("extension", "yaml", "File extension")
 	merge := cmd.Flags().Bool("merge", false, "Allow merging with existing directory")
 
 	vars := workflowFlags(cmd.Flags())
 	getJsonnetOpts := jsonnetFlags(cmd.Flags())
+	getLabelSelector := labelSelectorFlag(cmd.Flags())
 
-	cmd.Run = func(cmd *cli.Command, args []string) error {
-		// dir must be empty
-		to := args[1]
-		empty, err := dirEmpty(to)
-		if err != nil {
-			return fmt.Errorf("Checking target dir: %s", err)
-		}
-		if !empty && !*merge {
-			return fmt.Errorf("Output dir `%s` not empty. Pass --merge to ignore this", to)
-		}
-
-		// exit early if the template is bad
+	recursive := cmd.Flags().BoolP("recursive", "r", false, "Look recursively for Tanka environments")
 
-		// Replace all os.path separators in string with BelRune for creating subfolders
-		replacedFormat := strings.Replace(*format, string(os.PathSeparator), BelRune, -1)
-
-		tmpl, err := template.New("").
-			Funcs(sprig.TxtFuncMap()). // register Masterminds/sprig
-			Parse(replacedFormat)      // parse template
-		if err != nil {
-			return fmt.Errorf("Parsing name format: %s", err)
-		}
-
-		// get the manifests
-		res, err := tanka.Show(args[0], tanka.Opts{
-			JsonnetOpts: getJsonnetOpts(),
-			Filters:     stringsToRegexps(vars.targets),
-		})
-		if err != nil {
-			return err
+	cmd.Run = func(cmd *cli.Command, args []string) error {
+		opts := tanka.ExportEnvOpts{
+			Format:    *format,
+			Extension: *extension,
+			Merge:     *merge,
+			Targets:   vars.targets,
+			ParseParallelOpts: tanka.ParseParallelOpts{
+				JsonnetOpts: getJsonnetOpts(),
+				Selector:    getLabelSelector(),
+			},
 		}
 
-		// write each to a file
-		for _, m := range res {
-			buf := bytes.Buffer{}
-			if err := tmpl.Execute(&buf, m); err != nil {
-				log.Fatalln("executing name template:", err)
+		var paths []string
+		for _, path := range args[1:] {
+			if *recursive {
+				rootDir, err := jpath.FindRoot(path)
+				if err != nil {
+					return errors.Wrap(err, "resolving jpath")
+				}
+
+				// get absolute path to Environment
+				envs, err := tanka.FindEnvironments(path, opts.ParseParallelOpts.Selector)
+				if err != nil {
+					return err
+				}
+
+				for _, env := range envs {
+					paths = append(paths, filepath.Join(rootDir, env.Metadata.Namespace))
+				}
+				continue
 			}
 
-			// Replace all os.path separators in string in order to not accidentally create subfolders
-			name := strings.Replace(buf.String(), string(os.PathSeparator), "-", -1)
-			// Replace the BEL character inserted with a path separator again in order to create a subfolder
-			name = strings.Replace(name, BelRune, string(os.PathSeparator), -1)
-
-			// Create all subfolders in path
-			path := filepath.Join(to, name+"."+*extension)
-
-			// Abort if already exists
-			if exists, err := fileExists(path); err != nil {
+			jsonnetOpts := opts.ParseParallelOpts.JsonnetOpts
+			jsonnetOpts.EvalScript = tanka.EnvsOnlyEvalScript
+			_, _, err := tanka.ParseEnv(path, jsonnetOpts)
+			if err != nil {
 				return err
-			} else if exists {
-				return fmt.Errorf("File '%s' already exists. Aborting", path)
-			}
-
-			// Write file
-			if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
-				return fmt.Errorf("creating filepath '%s': %s", filepath.Dir(path), err)
-			}
-			data := m.String()
-			if err := ioutil.WriteFile(path, []byte(data), 0644); err != nil {
-				return fmt.Errorf("writing manifest: %s", err)
 			}
+			paths = append(paths, path)
 		}
 
-		return nil
+		return tanka.ExportEnvironments(paths, args[0], &opts)
 	}
 	return cmd
 }
-
-func fileExists(name string) (bool, error) {
-	_, err := os.Stat(name)
-	if os.IsNotExist(err) {
-		return false, nil
-	}
-	if err != nil {
-		return false, err
-	}
-	return true, nil
-}
-
-func dirEmpty(dir string) (bool, error) {
-	f, err := os.Open(dir)
-	if os.IsNotExist(err) {
-		return true, os.MkdirAll(dir, os.ModePerm)
-	} else if err != nil {
-		return false, err
-	}
-	defer f.Close()
-
-	_, err = f.Readdirnames(1)
-	if err == io.EOF {
-		return true, nil
-	}
-	return false, err
-}
diff --git a/cmd/tk/flags.go b/cmd/tk/flags.go
new file mode 100644
index 00000000..aab5af38
--- /dev/null
+++ b/cmd/tk/flags.go
@@ -0,0 +1,91 @@
+package main
+
+import (
+	"fmt"
+	"log"
+	"strings"
+
+	"github.com/spf13/pflag"
+	"k8s.io/apimachinery/pkg/labels"
+
+	"github.com/grafana/tanka/pkg/spec/v1alpha1"
+	"github.com/grafana/tanka/pkg/tanka"
+)
+
+type workflowFlagVars struct {
+	targets []string
+}
+
+func workflowFlags(fs *pflag.FlagSet) *workflowFlagVars {
+	v := workflowFlagVars{}
+	fs.StringSliceVarP(&v.targets, "target", "t", nil, "only use the specified objects (Format: <type>/<name>)")
+	return &v
+}
+
+func labelSelectorFlag(fs *pflag.FlagSet) func() labels.Selector {
+	labelSelector := fs.StringP("selector", "l", "", "Label selector. Uses the same syntax as kubectl does")
+
+	return func() labels.Selector {
+		if *labelSelector != "" {
+			selector, err := labels.Parse(*labelSelector)
+			if err != nil {
+				log.Fatalf("Could not parse selector (-l) %s", *labelSelector)
+			}
+			return selector
+		}
+		return nil
+	}
+}
+
+func jsonnetFlags(fs *pflag.FlagSet) func() tanka.JsonnetOpts {
+	getExtCode, getTLACode := cliCodeParser(fs)
+
+	return func() tanka.JsonnetOpts {
+		return tanka.JsonnetOpts{
+			ExtCode: getExtCode(),
+			TLACode: getTLACode(),
+		}
+	}
+}
+
+func cliCodeParser(fs *pflag.FlagSet) (func() map[string]string, func() map[string]string) {
+	// need to use StringArray instead of StringSlice, because pflag attempts to
+	// parse StringSlice using the csv parser, which breaks when passing objects
+	extCode := fs.StringArray("ext-code", nil, "Set code value of extVar (Format: key=<code>)")
+	extStr := fs.StringArrayP("ext-str", "V", nil, "Set string value of extVar (Format: key=value)")
+
+	tlaCode := fs.StringArray("tla-code", nil, "Set code value of top level function (Format: key=<code>)")
+	tlaStr := fs.StringArrayP("tla-str", "A", nil, "Set string value of top level function (Format: key=value)")
+
+	newParser := func(kind string, code, str *[]string) func() map[string]string {
+		return func() map[string]string {
+			m := make(map[string]string)
+			for _, s := range *code {
+				split := strings.SplitN(s, "=", 2)
+				if len(split) != 2 {
+					log.Fatalf(kind+"-code argument has wrong format: `%s`. Expected `key=<code>`", s)
+				}
+				m[split[0]] = split[1]
+			}
+
+			for _, s := range *str {
+				split := strings.SplitN(s, "=", 2)
+				if len(split) != 2 {
+					log.Fatalf(kind+"-str argument has wrong format: `%s`. Expected `key=<value>`", s)
+				}
+				m[split[0]] = fmt.Sprintf(`"%s"`, split[1])
+			}
+			return m
+		}
+	}
+
+	return newParser("ext", extCode, extStr),
+		newParser("tla", tlaCode, tlaStr)
+}
+
+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")
+	fs.StringVar(&env.Spec.DiffStrategy, "diff-strategy", env.Spec.DiffStrategy, "specify diff-strategy. Automatically detected otherwise.")
+}
diff --git a/cmd/tk/jsonnet.go b/cmd/tk/jsonnet.go
index 16fb5f08..95c7382e 100644
--- a/cmd/tk/jsonnet.go
+++ b/cmd/tk/jsonnet.go
@@ -2,12 +2,8 @@ package main
 
 import (
 	"encoding/json"
-	"fmt"
-	"log"
-	"strings"
 
 	"github.com/go-clix/cli"
-	"github.com/spf13/pflag"
 
 	"github.com/grafana/tanka/pkg/tanka"
 )
@@ -27,7 +23,9 @@ func evalCmd() *cli.Command {
 		jsonnetOpts := tanka.Opts{
 			JsonnetOpts: getJsonnetOpts(),
 		}
-		jsonnetOpts.EvalScript = "(import '%s')." + *evalPattern
+		if *evalPattern != "" {
+			jsonnetOpts.EvalScript = "(import '%s')." + *evalPattern
+		}
 		raw, err := tanka.Eval(args[0], jsonnetOpts)
 
 		if raw == nil && err != nil {
@@ -48,49 +46,3 @@ func evalCmd() *cli.Command {
 
 	return cmd
 }
-
-func jsonnetFlags(fs *pflag.FlagSet) func() tanka.JsonnetOpts {
-	getExtCode, getTLACode := cliCodeParser(fs)
-
-	return func() tanka.JsonnetOpts {
-		return tanka.JsonnetOpts{
-			ExtCode: getExtCode(),
-			TLACode: getTLACode(),
-		}
-	}
-}
-
-func cliCodeParser(fs *pflag.FlagSet) (func() map[string]string, func() map[string]string) {
-	// need to use StringArray instead of StringSlice, because pflag attempts to
-	// parse StringSlice using the csv parser, which breaks when passing objects
-	extCode := fs.StringArray("ext-code", nil, "Set code value of extVar (Format: key=<code>)")
-	extStr := fs.StringArrayP("ext-str", "V", nil, "Set string value of extVar (Format: key=value)")
-
-	tlaCode := fs.StringArray("tla-code", nil, "Set code value of top level function (Format: key=<code>)")
-	tlaStr := fs.StringArrayP("tla-str", "A", nil, "Set string value of top level function (Format: key=value)")
-
-	newParser := func(kind string, code, str *[]string) func() map[string]string {
-		return func() map[string]string {
-			m := make(map[string]string)
-			for _, s := range *code {
-				split := strings.SplitN(s, "=", 2)
-				if len(split) != 2 {
-					log.Fatalf(kind+"-code argument has wrong format: `%s`. Expected `key=<code>`", s)
-				}
-				m[split[0]] = split[1]
-			}
-
-			for _, s := range *str {
-				split := strings.SplitN(s, "=", 2)
-				if len(split) != 2 {
-					log.Fatalf(kind+"-str argument has wrong format: `%s`. Expected `key=<value>`", s)
-				}
-				m[split[0]] = fmt.Sprintf(`"%s"`, split[1])
-			}
-			return m
-		}
-	}
-
-	return newParser("ext", extCode, extStr),
-		newParser("tla", tlaCode, tlaStr)
-}
diff --git a/cmd/tk/main.go b/cmd/tk/main.go
index f0de6499..23178db9 100644
--- a/cmd/tk/main.go
+++ b/cmd/tk/main.go
@@ -1,18 +1,13 @@
 package main
 
 import (
-	"fmt"
 	"log"
 	"os"
-	"path/filepath"
-
-	"golang.org/x/crypto/ssh/terminal"
 
+	"github.com/fatih/color"
 	"github.com/go-clix/cli"
+	"golang.org/x/crypto/ssh/terminal"
 
-	"github.com/grafana/tanka/pkg/jsonnet/jpath"
-	"github.com/grafana/tanka/pkg/spec"
-	"github.com/grafana/tanka/pkg/spec/v1alpha1"
 	"github.com/grafana/tanka/pkg/tanka"
 )
 
@@ -56,44 +51,6 @@ func main() {
 
 	// Run!
 	if err := rootCmd.Execute(); err != nil {
-		log.Fatalln(err)
-	}
-}
-
-func setupConfiguration(baseDir string) *v1alpha1.Environment {
-	_, baseDir, rootDir, err := jpath.Resolve(baseDir)
-	if err != nil {
-		log.Fatalln("Resolving jpath:", err)
+		log.Fatalln(color.RedString("Error:"), 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)
-			}
-		// no spec.json is found, try parsing main.jsonnet
-		case spec.ErrNoSpec:
-			_, config, err := tanka.ParseEnv(baseDir, tanka.JsonnetOpts{EvalScript: tanka.EnvsOnlyEvalScript})
-			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)
-		}
-	}
-
-	return config
 }
diff --git a/cmd/tk/other.go b/cmd/tk/other.go
deleted file mode 100644
index 97558abe..00000000
--- a/cmd/tk/other.go
+++ /dev/null
@@ -1,36 +0,0 @@
-package main
-
-import (
-	"log"
-	"os"
-	"path/filepath"
-
-	"github.com/grafana/tanka/pkg/jsonnet/jpath"
-)
-
-// findBaseDirs searches for possible environments
-func findBaseDirs() (dirs []string) {
-	pwd, err := os.Getwd()
-	if err != nil {
-		return
-	}
-	_, _, _, err = jpath.Resolve(pwd)
-	if err == jpath.ErrorNoRoot {
-		return
-	}
-
-	if err := filepath.Walk(".", func(path string, info os.FileInfo, err error) error {
-		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
-				return nil
-			}
-		}
-		dirs = append(dirs, path)
-		return nil
-	}); err != nil {
-		log.Fatalln(err)
-	}
-	return dirs
-}
diff --git a/cmd/tk/workflow.go b/cmd/tk/workflow.go
index 4f1d46a6..c66c31ab 100644
--- a/cmd/tk/workflow.go
+++ b/cmd/tk/workflow.go
@@ -5,10 +5,8 @@ import (
 	"log"
 	"os"
 
-	"github.com/posener/complete"
-	"github.com/spf13/pflag"
-
 	"github.com/go-clix/cli"
+	"github.com/posener/complete"
 
 	"github.com/grafana/tanka/pkg/process"
 	"github.com/grafana/tanka/pkg/tanka"
@@ -23,16 +21,6 @@ const (
 	ExitStatusDiff = 16
 )
 
-type workflowFlagVars struct {
-	targets []string
-}
-
-func workflowFlags(fs *pflag.FlagSet) *workflowFlagVars {
-	v := workflowFlagVars{}
-	fs.StringSliceVarP(&v.targets, "target", "t", nil, "only use the specified objects (Format: <type>/<name>)")
-	return &v
-}
-
 func applyCmd() *cli.Command {
 	cmd := &cli.Command{
 		Use:   "apply <path>",
@@ -49,7 +37,11 @@ func applyCmd() *cli.Command {
 	getJsonnetOpts := jsonnetFlags(cmd.Flags())
 
 	cmd.Run = func(cmd *cli.Command, args []string) error {
-		opts.Filters = stringsToRegexps(vars.targets)
+		filters, err := process.StrExps(vars.targets...)
+		if err != nil {
+			return err
+		}
+		opts.Filters = filters
 		opts.JsonnetOpts = getJsonnetOpts()
 
 		return tanka.Apply(args[0], opts)
@@ -94,7 +86,11 @@ func deleteCmd() *cli.Command {
 	getJsonnetOpts := jsonnetFlags(cmd.Flags())
 
 	cmd.Run = func(cmd *cli.Command, args []string) error {
-		opts.Filters = stringsToRegexps(vars.targets)
+		filters, err := process.StrExps(vars.targets...)
+		if err != nil {
+			return err
+		}
+		opts.Filters = filters
 		opts.JsonnetOpts = getJsonnetOpts()
 
 		return tanka.Delete(args[0], opts)
@@ -120,7 +116,11 @@ func diffCmd() *cli.Command {
 	getJsonnetOpts := jsonnetFlags(cmd.Flags())
 
 	cmd.Run = func(cmd *cli.Command, args []string) error {
-		opts.Filters = stringsToRegexps(vars.targets)
+		filters, err := process.StrExps(vars.targets...)
+		if err != nil {
+			return err
+		}
+		opts.Filters = filters
 		opts.JsonnetOpts = getJsonnetOpts()
 
 		changes, err := tanka.Diff(args[0], opts)
@@ -165,9 +165,14 @@ Otherwise run tk show --dangerous-allow-redirect to bypass this check.`)
 			return nil
 		}
 
+		filters, err := process.StrExps(vars.targets...)
+		if err != nil {
+			return err
+		}
+
 		pretty, err := tanka.Show(args[0], tanka.Opts{
 			JsonnetOpts: getJsonnetOpts(),
-			Filters:     stringsToRegexps(vars.targets),
+			Filters:     filters,
 		})
 
 		if err != nil {
@@ -178,11 +183,3 @@ Otherwise run tk show --dangerous-allow-redirect to bypass this check.`)
 	}
 	return cmd
 }
-
-func stringsToRegexps(exps []string) process.Matchers {
-	regexs, err := process.StrExps(exps...)
-	if err != nil {
-		log.Fatalln(err)
-	}
-	return regexs
-}
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/environments.go b/pkg/tanka/environments.go
new file mode 100644
index 00000000..459df6dc
--- /dev/null
+++ b/pkg/tanka/environments.go
@@ -0,0 +1,148 @@
+package tanka
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"sync"
+
+	"k8s.io/apimachinery/pkg/labels"
+
+	"github.com/grafana/tanka/pkg/jsonnet/jpath"
+	"github.com/grafana/tanka/pkg/spec/v1alpha1"
+)
+
+const baseDirIndicator = "main.jsonnet"
+const parallel = 8
+
+// FindBaseDirs searches for possible environments
+func FindBaseDirs(workdir string) (dirs []string, err error) {
+	_, _, _, err = jpath.Resolve(workdir)
+	if err == jpath.ErrorNoRoot {
+		return nil, err
+	}
+
+	if err := filepath.Walk(workdir, func(path string, info os.FileInfo, err error) error {
+		if _, err := os.Stat(filepath.Join(path, baseDirIndicator)); err != nil {
+			// missing file, not a valid environment directory
+			return nil
+		}
+		dirs = append(dirs, path)
+		return nil
+	}); err != nil {
+		return nil, err
+	}
+	return dirs, nil
+}
+
+// FindEnvironments searches for actual environments
+// ignores directories if no environments are found
+func FindEnvironments(workdir string, selector labels.Selector) (envs []*v1alpha1.Environment, err error) {
+	dirs, err := FindBaseDirs(workdir)
+	if err != nil {
+		return nil, err
+	}
+	opts := ParseParallelOpts{
+		JsonnetOpts: JsonnetOpts{
+			EvalScript: EnvsOnlyEvalScript,
+		},
+		Selector: selector,
+	}
+	envs, err = ParseParallel(dirs, opts)
+
+	if err != nil {
+		switch err.(type) {
+		case ErrParseParallel:
+			// ignore ErrNoEnv errors
+			e := err.(ErrParseParallel)
+			var errors []error
+			for _, err := range e.errors {
+				switch err.(type) {
+				case ErrNoEnv:
+					continue
+				default:
+					errors = append(errors, err)
+				}
+			}
+			if len(errors) != 0 {
+				return nil, ErrParseParallel{errors: errors}
+			}
+		default:
+			return nil, err
+		}
+	}
+
+	return envs, nil
+}
+
+// ParseParallel evaluates multiple environments in parallel
+func ParseParallel(paths []string, opts ParseParallelOpts) (envs []*v1alpha1.Environment, err error) {
+	wg := sync.WaitGroup{}
+	envsChan := make(chan parseJob)
+	var allErrors []error
+
+	numParallel := parallel
+	if opts.Parallel > 0 {
+		numParallel = opts.Parallel
+	}
+	for i := 0; i < numParallel; i++ {
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			errs := parseWorker(envsChan)
+			if errs != nil {
+				allErrors = append(allErrors, errs...)
+			}
+		}()
+	}
+
+	results := make([]*v1alpha1.Environment, 0, len(paths))
+
+	for _, path := range paths {
+		env := &v1alpha1.Environment{}
+		results = append(results, env)
+		envsChan <- parseJob{
+			path: path,
+			opts: opts.JsonnetOpts,
+			env:  env,
+		}
+	}
+	close(envsChan)
+	wg.Wait()
+
+	for _, env := range results {
+		if env == nil {
+			continue
+		}
+		if opts.Selector == nil || opts.Selector.Empty() || opts.Selector.Matches(env.Metadata) {
+			envs = append(envs, env)
+		}
+	}
+
+	if len(allErrors) != 0 {
+		return envs, ErrParseParallel{errors: allErrors}
+	}
+
+	return envs, nil
+}
+
+type parseJob struct {
+	path string
+	opts JsonnetOpts
+	env  *v1alpha1.Environment
+}
+
+func parseWorker(envsChan <-chan parseJob) (errs []error) {
+	for req := range envsChan {
+		_, env, err := ParseEnv(req.path, req.opts)
+		if err != nil {
+			errs = append(errs, fmt.Errorf("%s:\n %w", req.path, err))
+			continue
+		}
+		*req.env = *env
+	}
+	if len(errs) != 0 {
+		return errs
+	}
+	return nil
+}
diff --git a/pkg/tanka/errors.go b/pkg/tanka/errors.go
index 546285b0..5d0c72a4 100644
--- a/pkg/tanka/errors.go
+++ b/pkg/tanka/errors.go
@@ -24,3 +24,16 @@ type ErrMultipleEnvs struct {
 func (e ErrMultipleEnvs) Error() string {
 	return fmt.Sprintf("found multiple Environments (%s) in '%s'", strings.Join(e.names, ", "), e.path)
 }
+
+// ErrParseParallel is an array of errors collected while parsing environments in parallel
+type ErrParseParallel struct {
+	errors []error
+}
+
+func (e ErrParseParallel) Error() string {
+	returnErr := fmt.Sprintf("Unable to parse selected Environments:\n\n")
+	for _, err := range e.errors {
+		returnErr = fmt.Sprintf("%s- %s\n", returnErr, err.Error())
+	}
+	return returnErr
+}
diff --git a/pkg/tanka/export.go b/pkg/tanka/export.go
new file mode 100644
index 00000000..f6f2cbe1
--- /dev/null
+++ b/pkg/tanka/export.go
@@ -0,0 +1,199 @@
+package tanka
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"io"
+	"io/ioutil"
+	"os"
+	"path/filepath"
+	"strings"
+	"text/template"
+
+	"github.com/Masterminds/sprig/v3"
+
+	"github.com/grafana/tanka/pkg/kubernetes/manifest"
+	"github.com/grafana/tanka/pkg/process"
+)
+
+// BelRune is a string of the Ascii character BEL which made computers ring in ancient times
+// We use it as "magic" char for the subfolder creation as it is a non printable character and thereby will never be
+// in a valid filepath by accident. Only when we include it.
+const BelRune = string(rune(7))
+
+// When exporting manifests to files, it becomes increasingly hard to map manifests back to its environment, this file
+// can be used to map the files back to their environment. This is aimed to be used by CI/CD but can also be used for
+// debugging purposes.
+const manifestFile = "manifest.json"
+
+// ExportEnvOpts specify options on how to export environments
+type ExportEnvOpts struct {
+	// formatting the filename based on the exported Kubernetes manifest
+	Format string
+	// extension of the filename
+	Extension string
+	// merge export with existing directory
+	Merge bool
+	// optional: only export specified Kubernetes manifests
+	Targets []string
+	// optional: options for parsing Environments
+	ParseParallelOpts ParseParallelOpts
+}
+
+func ExportEnvironments(paths []string, to string, opts *ExportEnvOpts) error {
+	// Keep track of which file maps to which environment
+	fileToEnv := map[string]string{}
+
+	// dir must be empty
+	empty, err := dirEmpty(to)
+	if err != nil {
+		return fmt.Errorf("Checking target dir: %s", err)
+	}
+	if !empty && !opts.Merge {
+		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 {
+		// select targets to export
+		filter, err := process.StrExps(opts.Targets...)
+		if err != nil {
+			return err
+		}
+
+		// get the manifests
+		res, err := LoadManifests(env, filter)
+		if err != nil {
+			return err
+		}
+
+		// create raw manifest version of env for templating
+		env.Data = nil
+		raw, err := json.Marshal(env)
+		if err != nil {
+			return err
+		}
+		var menv manifest.Manifest
+		if err := json.Unmarshal(raw, &menv); err != nil {
+			return err
+		}
+
+		// create template
+		manifestTemplate, err := createTemplate(opts.Format, menv)
+		if err != nil {
+			return fmt.Errorf("Parsing format: %s", err)
+		}
+
+		// write each to a file
+		for _, m := range res {
+			// apply template
+			name, err := applyTemplate(manifestTemplate, m)
+			if err != nil {
+				return fmt.Errorf("executing name template: %w", err)
+			}
+
+			// Create all subfolders in path
+			path := filepath.Join(to, name+"."+opts.Extension)
+
+			fileToEnv[path] = env.Metadata.Namespace
+
+			// Abort if already exists
+			if exists, err := fileExists(path); err != nil {
+				return err
+			} else if exists {
+				return fmt.Errorf("File '%s' already exists. Aborting", path)
+			}
+
+			// Write manifest
+			data := m.String()
+			if err := writeExportFile(path, []byte(data)); err != nil {
+				return err
+			}
+		}
+	}
+
+	// Write manifest file
+	if len(fileToEnv) != 0 {
+		data, err := json.MarshalIndent(fileToEnv, "", "    ")
+		if err != nil {
+			return err
+		}
+		path := filepath.Join(to, manifestFile)
+		if err := writeExportFile(path, data); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+func fileExists(name string) (bool, error) {
+	_, err := os.Stat(name)
+	if os.IsNotExist(err) {
+		return false, nil
+	}
+	if err != nil {
+		return false, err
+	}
+	return true, nil
+}
+
+func dirEmpty(dir string) (bool, error) {
+	f, err := os.Open(dir)
+	if os.IsNotExist(err) {
+		return true, os.MkdirAll(dir, os.ModePerm)
+	} else if err != nil {
+		return false, err
+	}
+	defer f.Close()
+
+	_, err = f.Readdirnames(1)
+	if err == io.EOF {
+		return true, nil
+	}
+	return false, err
+}
+
+func writeExportFile(path string, data []byte) error {
+	if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
+		return fmt.Errorf("creating filepath '%s': %s", filepath.Dir(path), err)
+	}
+
+	return ioutil.WriteFile(path, data, 0644)
+}
+
+func createTemplate(format string, env manifest.Manifest) (*template.Template, error) {
+	// Replace all os.path separators in string with BelRune for creating subfolders
+	replaceFormat := strings.Replace(format, string(os.PathSeparator), BelRune, -1)
+
+	envMap := template.FuncMap{"env": func() manifest.Manifest { return env }}
+
+	template, err := template.New("").
+		Funcs(sprig.TxtFuncMap()). // register Masterminds/sprig
+		Funcs(envMap).             // register environment mapping
+		Parse(replaceFormat)       // parse template
+	if err != nil {
+		return nil, err
+	}
+	return template, nil
+}
+
+func applyTemplate(template *template.Template, m manifest.Manifest) (path string, err error) {
+	buf := bytes.Buffer{}
+	if err := template.Execute(&buf, m); err != nil {
+		return "", err
+	}
+
+	// Replace all os.path separators in string in order to not accidentally create subfolders
+	path = strings.Replace(buf.String(), string(os.PathSeparator), "-", -1)
+	// Replace the BEL character inserted with a path separator again in order to create a subfolder
+	path = strings.Replace(path, BelRune, string(os.PathSeparator), -1)
+
+	return path, nil
+}
diff --git a/pkg/tanka/parse.go b/pkg/tanka/parse.go
index 5f6adc13..3d877e50 100644
--- a/pkg/tanka/parse.go
+++ b/pkg/tanka/parse.go
@@ -9,6 +9,7 @@ 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"
@@ -73,23 +74,32 @@ func load(path string, opts Opts) (*loaded, error) {
 		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", path)
+		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
 	}
 
-	rec, err := process.Process(env.Data, *env, opts.Filters)
+	resources, err := process.Process(env.Data, *env, filters)
 	if err != nil {
 		return nil, err
 	}
 
-	return &loaded{
-		Resources: rec,
-		Env:       env,
-	}, nil
+	return resources, nil
 }
 
 // parseSpec parses the `spec.json` of the environment and returns a
@@ -97,7 +107,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 +124,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
 		}
 	}
 
@@ -130,7 +140,7 @@ func ParseEnv(path string, opts JsonnetOpts) (interface{}, *v1alpha1.Environment
 		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,7 +149,13 @@ func ParseEnv(path string, opts JsonnetOpts) (interface{}, *v1alpha1.Environment
 		if err != nil {
 			return nil, nil, errors.Wrap(err, "marshalling environment config")
 		}
-		opts.ExtCode.Set(spec.APIGroup+"/environment", string(jsonEnv))
+		// 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)
@@ -152,11 +168,6 @@ func ParseEnv(path string, opts JsonnetOpts) (interface{}, *v1alpha1.Environment
 		return nil, nil, errors.Wrap(err, "unmarshalling data")
 	}
 
-	if opts.EvalScript != "" {
-		// EvalScript 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 {
diff --git a/pkg/tanka/tanka.go b/pkg/tanka/tanka.go
index 5564a5d4..71e909f2 100644
--- a/pkg/tanka/tanka.go
+++ b/pkg/tanka/tanka.go
@@ -5,6 +5,8 @@
 package tanka
 
 import (
+	"k8s.io/apimachinery/pkg/labels"
+
 	"github.com/grafana/tanka/pkg/jsonnet"
 	"github.com/grafana/tanka/pkg/process"
 )
@@ -18,3 +20,9 @@ type Opts struct {
 	// Filters are used to optionally select a subset of the resources
 	Filters process.Matchers
 }
+
+type ParseParallelOpts struct {
+	JsonnetOpts JsonnetOpts
+	Selector    labels.Selector
+	Parallel    int
+}


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