Repository: grafana/tanka
Pull request #603 (2021-08-20 17:09:32)

Implements environment caching for `tk export`
In a context where the export runs in CI and generates all environments on pull requests, lots of time can be wasted on regenerating expensive environments unnecessarily.

This pull request introduces the means to cache these heavy environments under either the filesystem or Google Cloud Storage
This was tried on environments here at Grafana Labs and run time for an entire export went from over 1 minute to under 25 seconds (I got runs as low as 21 seconds) by caching a few large environments

Adds three CLI options for the `export` command:
- `--cache-path`: Where to store the cache. Currently supports file://path and gs://path
- `--cache-envs`: List of regexes that match environments to be cached. If caching to google storage, for example, it's not worth it to cache small environments
- `--warn-long-evals`: If enabled, will emit warnings when evaluations are long to indicate which envs should be cached

If caching isn't used or an environment is not included, behavior is as before

Request for opinion:
- The Google Storage support is currently implemented through the GCP Go SDK but that makes the binary go from 13M to 22M, it'd be better to interface using `gsutil` right? That way, there'd be no difference in binary size and it'd be completely optional

julienduchesne: @Duologic I removed the warning and I removed GCS caching. GCS caching can be supported through a FUSE mount

Patch:
diff --git a/.drone/drone.jsonnet b/.drone/drone.jsonnet
index 2c48b764..b1b69a20 100644
--- a/.drone/drone.jsonnet
+++ b/.drone/drone.jsonnet
@@ -1,6 +1,6 @@
 local vault = import 'vault.libsonnet';
 
-local golang = 'golang:1.15';
+local golang = 'golang:1.16';
 
 local volumes = [{ name: 'gopath', temp: {} }];
 local mounts = [{ name: 'gopath', path: '/go' }];
diff --git a/.drone/drone.yml b/.drone/drone.yml
index 531b388c..17119555 100644
--- a/.drone/drone.yml
+++ b/.drone/drone.yml
@@ -8,7 +8,7 @@ platform:
 
 steps:
 - name: download
-  image: golang:1.15
+  image: golang:1.16
   commands:
   - go mod download
   volumes:
@@ -16,7 +16,7 @@ steps:
     path: /go
 
 - name: lint
-  image: golang:1.15
+  image: golang:1.16
   commands:
   - make lint
   volumes:
@@ -26,7 +26,7 @@ steps:
   - download
 
 - name: test
-  image: golang:1.15
+  image: golang:1.16
   commands:
   - make test
   volumes:
@@ -49,7 +49,7 @@ platform:
 
 steps:
 - name: fetch-tags
-  image: golang:1.15
+  image: golang:1.16
   commands:
   - git fetch origin --tags
   volumes:
@@ -57,7 +57,7 @@ steps:
     path: /go
 
 - name: cross
-  image: golang:1.15
+  image: golang:1.16
   commands:
   - make cross
   volumes:
@@ -95,7 +95,7 @@ platform:
 
 steps:
 - name: fetch-tags
-  image: golang:1.15
+  image: golang:1.16
   commands:
   - git fetch origin --tags
   volumes:
@@ -103,7 +103,7 @@ steps:
     path: /go
 
 - name: static
-  image: golang:1.15
+  image: golang:1.16
   commands:
   - make static
   volumes:
@@ -144,7 +144,7 @@ platform:
 
 steps:
 - name: fetch-tags
-  image: golang:1.15
+  image: golang:1.16
   commands:
   - git fetch origin --tags
   volumes:
@@ -152,7 +152,7 @@ steps:
     path: /go
 
 - name: static
-  image: golang:1.15
+  image: golang:1.16
   commands:
   - make static
   volumes:
@@ -243,6 +243,6 @@ get:
 
 ---
 kind: signature
-hmac: c3ef3feca15523713fdd108f6d6708488514efb9958707b812db888b799b2244
+hmac: d23cf2eb1846155019d6159675b698388a196975b0d02a3814b031c847e64fb6
 
 ...
diff --git a/cmd/tk/export.go b/cmd/tk/export.go
index f13048e9..ed76d6d5 100644
--- a/cmd/tk/export.go
+++ b/cmd/tk/export.go
@@ -2,6 +2,7 @@ package main
 
 import (
 	"fmt"
+	"regexp"
 
 	"github.com/go-clix/cli"
 
@@ -34,6 +35,8 @@ func exportCmd() *cli.Command {
 	extension := cmd.Flags().String("extension", "yaml", "File extension")
 	merge := cmd.Flags().Bool("merge", false, "Allow merging with existing directory")
 	parallel := cmd.Flags().IntP("parallel", "p", 8, "Number of environments to process in parallel")
+	cachePath := cmd.Flags().StringP("cache-path", "c", "", "URL where cached evaluations should be stored. Supports file:// and gs://")
+	cacheEnvs := cmd.Flags().StringArrayP("cache-envs", "e", nil, "Regexes which define which environment should be cached (if caching is enabled)")
 
 	vars := workflowFlags(cmd.Flags())
 	getJsonnetOpts := jsonnetFlags(cmd.Flags())
@@ -59,6 +62,14 @@ func exportCmd() *cli.Command {
 			Selector:    getLabelSelector(),
 			Parallelism: *parallel,
 		}
+		opts.Opts.CachePath = *cachePath
+		for _, expr := range *cacheEnvs {
+			regex, err := regexp.Compile(expr)
+			if err != nil {
+				return err
+			}
+			opts.Opts.CachePathRegexes = append(opts.Opts.CachePathRegexes, regex)
+		}
 
 		var exportEnvs []*v1alpha1.Environment
 		for _, path := range args[1:] {
diff --git a/cmd/tk/flags.go b/cmd/tk/flags.go
index 77aae844..1b807c75 100644
--- a/cmd/tk/flags.go
+++ b/cmd/tk/flags.go
@@ -19,7 +19,7 @@ type workflowFlagVars struct {
 
 func workflowFlags(fs *pflag.FlagSet) *workflowFlagVars {
 	v := workflowFlagVars{}
-	fs.StringVar(&v.name, "name", "", "Selects an environment from inline environments")
+	fs.StringVar(&v.name, "name", "", "string that only a single inline environment contains in its name")
 	fs.StringSliceVarP(&v.targets, "target", "t", nil, "Regex filter on '<kind>/<name>'. See https://tanka.dev/output-filtering")
 	return &v
 }
diff --git a/cmd/tk/prefix.go b/cmd/tk/prefix.go
index 4b28f7ce..76fa2a70 100644
--- a/cmd/tk/prefix.go
+++ b/cmd/tk/prefix.go
@@ -2,9 +2,9 @@ package main
 
 import (
 	"fmt"
-	"io/ioutil"
 	"os"
 	"os/exec"
+	"path/filepath"
 	"strings"
 
 	"github.com/go-clix/cli"
@@ -64,17 +64,27 @@ func executablesOnPath(prefix string) (map[string]string, error) {
 			continue
 		}
 
-		files, err := ioutil.ReadDir(p)
+		files, err := filepath.Glob(fmt.Sprintf("%s/%s*", p, prefix))
 		if err != nil {
 			return nil, err
 		}
 		for _, file := range files {
-			if !strings.HasPrefix(file.Name(), prefix) {
+			base := filepath.Base(file)
+			// guarding against a glob character in the prefix or path
+			if !strings.HasPrefix(base, prefix) {
 				continue
 			}
-			if file.Mode().IsRegular() && file.Mode().Perm()&0111 != 0 {
-				executables[file.Name()] = fmt.Sprintf("%s/%s", p, file.Name())
+			info, err := os.Stat(file)
+			if err != nil {
+				return nil, err
 			}
+			if !info.Mode().IsRegular() {
+				continue
+			}
+			if info.Mode().Perm()&0111 == 0 {
+				continue
+			}
+			executables[base] = file
 		}
 	}
 	return executables, nil
diff --git a/cmd/tk/workflow.go b/cmd/tk/workflow.go
index 66f20ea3..f36546c4 100644
--- a/cmd/tk/workflow.go
+++ b/cmd/tk/workflow.go
@@ -60,7 +60,7 @@ func pruneCmd() *cli.Command {
 	var opts tanka.PruneOpts
 	cmd.Flags().BoolVar(&opts.Force, "force", false, "force deleting (kubectl delete --force)")
 	cmd.Flags().BoolVar(&opts.AutoApprove, "dangerous-auto-approve", false, "skip interactive approval. Only for automation!")
-	cmd.Flags().StringVar(&opts.Name, "name", "", "Selects an environment from inline environments")
+	cmd.Flags().StringVar(&opts.Name, "name", "", "string that only a single inline environment contains in its name")
 	getJsonnetOpts := jsonnetFlags(cmd.Flags())
 
 	cmd.Run = func(cmd *cli.Command, args []string) error {
diff --git a/docs/docs/inline-environments.mdx b/docs/docs/inline-environments.mdx
index 3c40d7d8..957bd54c 100644
--- a/docs/docs/inline-environments.mdx
+++ b/docs/docs/inline-environments.mdx
@@ -160,6 +160,10 @@ to deploy:
 ```bash
 $ tk apply --name environment/us-central1 environments/monitoring-stack/main.jsonnet
 $ tk diff --name environment/europe-west2 environments/monitoring-stack/main.jsonnet
+
+# Partial matches also work (if they match a single environment)
+$ tk apply --name us-central1 environments/monitoring-stack/main.jsonnet
+$ tk diff --name west2 environments/monitoring-stack/main.jsonnet
 ```
 
 For export, it is possible to use the same `--name` selector or you can do a
diff --git a/go.mod b/go.mod
index adf896b4..a0d2d1fb 100644
--- a/go.mod
+++ b/go.mod
@@ -1,16 +1,17 @@
 module github.com/grafana/tanka
 
-go 1.15
+go 1.16
 
 require (
 	github.com/Masterminds/semver v1.4.2
 	github.com/Masterminds/sprig/v3 v3.1.0
 	github.com/fatih/color v1.9.0
 	github.com/fatih/structs v1.1.0
-	github.com/go-clix/cli v0.1.1
+	github.com/go-clix/cli v0.2.0
 	github.com/gobwas/glob v0.2.3
-	github.com/google/go-cmp v0.5.2-0.20200818193711-d2fcc899bdc2
+	github.com/google/go-cmp v0.5.6
 	github.com/google/go-jsonnet v0.17.0
+	github.com/google/uuid v1.1.2 // indirect
 	github.com/huandu/xstrings v1.3.2 // indirect
 	github.com/imdario/mergo v0.3.9 // indirect
 	github.com/karrick/godirwalk v1.15.5
@@ -18,11 +19,14 @@ require (
 	github.com/posener/complete v1.2.3
 	github.com/spf13/pflag v1.0.5
 	github.com/stretchr/objx v0.2.0
-	github.com/stretchr/testify v1.5.1
+	github.com/stretchr/testify v1.6.1
 	github.com/thoas/go-funk v0.4.0
-	golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9
+	golang.org/x/crypto v0.0.0-20201216223049-8b5274cf687f
+	golang.org/x/sys v0.0.0-20210616094352-59db8d763f22 // indirect
+	golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 // indirect
+	golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
 	gopkg.in/yaml.v2 v2.2.8
-	gopkg.in/yaml.v3 v3.0.0-20191010095647-fc94e3f71652
+	gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c
 	k8s.io/apimachinery v0.19.2
 	sigs.k8s.io/yaml v1.2.0
 )
diff --git a/go.sum b/go.sum
index 23c5f25f..d96e8434 100644
--- a/go.sum
+++ b/go.sum
@@ -32,6 +32,8 @@ github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4
 github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
 github.com/go-clix/cli v0.1.1 h1:T9N0AdMbmpFM9cLw42TcLL5sQ3YgyxTyHUhBK0GW1LI=
 github.com/go-clix/cli v0.1.1/go.mod h1:dYJevXraB9mXZFhz5clyQestG0qGcmT5rRC/P9etoRQ=
+github.com/go-clix/cli v0.2.0 h1:rqpcyS/cvshOhXkwii0V+7nWetDVC8cp4pKI7JiCIS8=
+github.com/go-clix/cli v0.2.0/go.mod h1:yWI9abpv187r47lDjz8Z9TWev93aUTWaW2seSb5JmPQ=
 github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas=
 github.com/go-logr/logr v0.2.0 h1:QvGt2nLcHH0WK9orKa+ppBPAxREcH364nPUedEpK0TY=
 github.com/go-logr/logr v0.2.0/go.mod h1:z6/tIYblkpsD+a4lm/fGIIU9mZ+XfAiaFtq7xTgseGU=
@@ -41,7 +43,6 @@ github.com/go-openapi/spec v0.0.0-20160808142527-6aced65f8501/go.mod h1:J8+jY1nA
 github.com/go-openapi/swag v0.0.0-20160704191624-1d0bd113de87/go.mod h1:DXUve3Dpr1UfpPtxFw+EFuQ41HhCWZfha5jSVRG7C7I=
 github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
 github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
-github.com/gogo/protobuf v1.3.1 h1:DqDEcV5aeaTmdFBePNpYsp3FlcVH/2ISVVM9Qf8PSls=
 github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o=
 github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
 github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@@ -57,19 +58,18 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq
 github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
 github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
 github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
-github.com/google/go-cmp v0.3.0 h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY=
 github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
 github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
 github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.2-0.20200818193711-d2fcc899bdc2 h1:CZtx9gNen+kr3PuC/JQff3n1pJbgpy7Wr3hzjnupqdw=
-github.com/google/go-cmp v0.5.2-0.20200818193711-d2fcc899bdc2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ=
+github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-jsonnet v0.17.0 h1:/9NIEfhK1NQRKl3sP2536b2+x5HnZMdql7x3yK/l8JY=
 github.com/google/go-jsonnet v0.17.0/go.mod h1:sOcuej3UW1vpPTZOr8L7RQimqai1a57bt5j22LzGZCw=
 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
-github.com/google/gofuzz v1.1.0 h1:Hsa8mG0dQ46ij8Sl2AYJDUv1oA9/d6Vk+3LG99Oe02g=
 github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
-github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY=
 github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y=
+github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/googleapis/gnostic v0.4.1/go.mod h1:LRhVm6pbyptWbWbuZ38d1eyptfvIytN3ir6b65WBswg=
 github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA=
 github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
@@ -89,7 +89,6 @@ github.com/karrick/godirwalk v1.15.5 h1:ErdAEFW/cKxQ5+9Gm/hopxB8ki21/di+vyNb9mHn
 github.com/karrick/godirwalk v1.15.5/go.mod h1:j4mkqPuvaLI8mp1DroR3P6ad7cyYd4c1qeJ3RV7ULlk=
 github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00=
 github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
-github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
 github.com/kr/pretty v0.2.0 h1:s5hAObm+yFO5uHYt5dYjxi2rXrsnmRpJx4OYvIWUaQs=
 github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
@@ -107,10 +106,8 @@ github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFW
 github.com/mitchellh/reflectwalk v1.0.0 h1:9D+8oIskB4VJBN5SFlmc27fSlIBZaov1Wpk/IfikLNY=
 github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
 github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
-github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
 github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
-github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI=
 github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
 github.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
 github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw=
@@ -138,17 +135,17 @@ github.com/stretchr/objx v0.2.0 h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48=
 github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
-github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
-github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
 github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
+github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
+github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/thoas/go-funk v0.4.0 h1:KBaa5NL7NMtsFlQaD8nQMbDt1wuM+OOaNQyYNYQFhVo=
 github.com/thoas/go-funk v0.4.0/go.mod h1:mlR+dHGb+4YgXkf13rkQTuzrneeHANxOm6+ZnEV9HsA=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
-golang.org/x/crypto v0.0.0-20200414173820-0848c9571904 h1:bXoxMPcSLOq08zI3/c5dEBT6lE4eh+jOh886GHrn6V8=
 golang.org/x/crypto v0.0.0-20200414173820-0848c9571904/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
-golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI=
 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.0.0-20201216223049-8b5274cf687f h1:aZp0e2vLN4MToVqnjNEYEtrEA8RH8U8FN1CU7JgqsPU=
+golang.org/x/crypto v0.0.0-20201216223049-8b5274cf687f/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
 golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
@@ -171,11 +168,15 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
 golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 h1:YyJpGZS1sBuBCzLAR1VEpK193GlqGZbnPFnPV/5Rsb4=
 golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200622214017-ed371f2e16b4 h1:5/PjkGUjvEU5Gl6BxmvKRPpqo2uNMv4rcHBMwzk/st8=
 golang.org/x/sys v0.0.0-20200622214017-ed371f2e16b4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210616094352-59db8d763f22 h1:RqytpXGR1iVNX7psjB3ff8y7sNFinVFvkx1c8SjBkio=
+golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -185,8 +186,9 @@ golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGm
 golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
 golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
 golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
-golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
 google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
 google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
@@ -208,7 +210,6 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8
 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
-gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
 gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
 gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
@@ -216,8 +217,8 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
 gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v3 v3.0.0-20191010095647-fc94e3f71652 h1:VKvJ/mQ4BgCjZUDggYFxTe0qv9jPMHsZPD4Xt91Y5H4=
-gopkg.in/yaml.v3 v3.0.0-20191010095647-fc94e3f71652/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 k8s.io/apimachinery v0.19.2 h1:5Gy9vQpAGTKHPVOh5c4plE274X8D/6cuEiTO2zve7tc=
diff --git a/pkg/jsonnet/eval.go b/pkg/jsonnet/eval.go
index 0c6ca08f..663f56f4 100644
--- a/pkg/jsonnet/eval.go
+++ b/pkg/jsonnet/eval.go
@@ -1,6 +1,9 @@
 package jsonnet
 
 import (
+	"io/ioutil"
+	"regexp"
+
 	jsonnet "github.com/google/go-jsonnet"
 	"github.com/pkg/errors"
 
@@ -30,6 +33,20 @@ type Opts struct {
 	TLACode     InjectedCode
 	ImportPaths []string
 	EvalScript  string
+	CachePath   string
+
+	CachePathRegexes []*regexp.Regexp
+}
+
+// PathIsCached determines if a given path is matched by any of the configured cached path regexes
+// If no path regexes are defined, all paths are matched
+func (opts Opts) PathIsCached(path string) bool {
+	for _, regex := range opts.CachePathRegexes {
+		if regex.MatchString(path) {
+			return true
+		}
+	}
+	return len(opts.CachePathRegexes) == 0
 }
 
 // Clone returns a deep copy of Opts
@@ -49,6 +66,9 @@ func (o Opts) Clone() Opts {
 		ExtCode:     extCode,
 		ImportPaths: append([]string{}, o.ImportPaths...),
 		EvalScript:  o.EvalScript,
+
+		CachePath:        o.CachePath,
+		CachePathRegexes: o.CachePathRegexes,
 	}
 }
 
@@ -78,24 +98,48 @@ func MakeVM(opts Opts) *jsonnet.VM {
 // result in JSON form. It disregards opts.ImportPaths in favor of automatically
 // resolving these according to the specified file.
 func EvaluateFile(jsonnetFile string, opts Opts) (string, error) {
-	jpath, _, _, err := jpath.Resolve(jsonnetFile)
-	if err != nil {
-		return "", errors.Wrap(err, "resolving import paths")
-	}
-	opts.ImportPaths = jpath
+	bytes, _ := ioutil.ReadFile(jsonnetFile)
+	return Evaluate(jsonnetFile, string(bytes), opts)
 
-	vm := MakeVM(opts)
-	return vm.EvaluateFile(jsonnetFile)
 }
 
 // Evaluate renders the given jsonnet into a string
-// TODO: don't resolve jpath, this is ANONYMOUS AFTER ALL
+// If cache options are given, a hash from the data will be computed and
+//  the resulting string will be cached for future retrieval
 func Evaluate(path, data string, opts Opts) (string, error) {
+	var cache *FileEvalCache
+	if opts.CachePath != "" && opts.PathIsCached(path) {
+		cache = NewFileEvalCache(opts.CachePath)
+	}
+
+	// Create VM
 	jpath, _, _, err := jpath.Resolve(path)
 	if err != nil {
 		return "", errors.Wrap(err, "resolving import paths")
 	}
 	opts.ImportPaths = jpath
 	vm := MakeVM(opts)
-	return vm.EvaluateAnonymousSnippet(path, data)
+
+	var hash string
+	if cache != nil {
+		if hash, err = getSnippetHash(vm, path, data); err != nil {
+			return "", err
+		}
+		if v, err := cache.Get(hash); err != nil {
+			return "", err
+		} else if v != "" {
+			return v, nil
+		}
+	}
+
+	content, err := vm.EvaluateAnonymousSnippet(path, data)
+	if err != nil {
+		return "", err
+	}
+
+	if cache != nil {
+		return content, cache.Store(hash, content)
+	}
+
+	return content, nil
 }
diff --git a/pkg/jsonnet/evalcache.go b/pkg/jsonnet/evalcache.go
new file mode 100644
index 00000000..f3ebc65b
--- /dev/null
+++ b/pkg/jsonnet/evalcache.go
@@ -0,0 +1,51 @@
+package jsonnet
+
+import (
+	"io/ioutil"
+	"os"
+	"path/filepath"
+)
+
+// FileEvalCache is an evaluation cache that stores its data on the local filesystem
+// Cache paths with the file:// scheme are routed to this cache
+type FileEvalCache struct {
+	Directory string
+}
+
+func NewFileEvalCache(cachePath string) *FileEvalCache {
+	return &FileEvalCache{
+		Directory: cachePath,
+	}
+}
+
+func (c *FileEvalCache) cachePath(hash string) (string, error) {
+	return filepath.Abs(filepath.Join(c.Directory, hash+".json"))
+}
+
+func (c *FileEvalCache) Get(hash string) (string, error) {
+	cachePath, err := c.cachePath(hash)
+	if err != nil {
+		return "", err
+	}
+
+	if _, err := os.Stat(cachePath); err == nil {
+		bytes, err := ioutil.ReadFile(cachePath)
+		return string(bytes), err
+	} else if !os.IsNotExist(err) {
+		return "", err
+	}
+	return "", nil
+}
+
+func (c *FileEvalCache) Store(hash, content string) error {
+	if err := os.MkdirAll(c.Directory, os.ModePerm); err != nil {
+		return err
+	}
+
+	cachePath, err := c.cachePath(hash)
+	if err != nil {
+		return err
+	}
+
+	return ioutil.WriteFile(cachePath, []byte(content), 0644)
+}
diff --git a/pkg/jsonnet/imports.go b/pkg/jsonnet/imports.go
index 9d11cc2a..87b495f1 100644
--- a/pkg/jsonnet/imports.go
+++ b/pkg/jsonnet/imports.go
@@ -1,10 +1,14 @@
 package jsonnet
 
 import (
+	"crypto/sha256"
+	"encoding/base64"
 	"fmt"
 	"io/ioutil"
+	"os"
 	"path/filepath"
 	"sort"
+	"sync"
 
 	jsonnet "github.com/google/go-jsonnet"
 	"github.com/google/go-jsonnet/ast"
@@ -134,6 +138,44 @@ func importRecursive(list map[string]bool, vm *jsonnet.VM, node ast.Node, curren
 	return nil
 }
 
+var fileHashes sync.Map
+
+// getSnippetHash takes a jsonnet snippet and calculates a hash from its content
+//   and the content of all of its dependencies.
+// File hashes are cached in-memory to optimize multiple executions of this function in a process
+func getSnippetHash(vm *jsonnet.VM, path, data string) (string, error) {
+	node, _ := jsonnet.SnippetToAST(path, data)
+	result := map[string]bool{}
+	if err := importRecursive(result, vm, node, path); err != nil {
+		return "", err
+	}
+	fileNames := []string{}
+	for file := range result {
+		fileNames = append(fileNames, file)
+	}
+	sort.Strings(fileNames)
+
+	fullHasher := sha256.New()
+	fullHasher.Write([]byte(data))
+	for _, file := range fileNames {
+		var fileHash []byte
+		if got, ok := fileHashes.Load(file); ok {
+			fileHash = got.([]byte)
+		} else {
+			bytes, err := os.ReadFile(file)
+			if err != nil {
+				return "", err
+			}
+			hash := sha256.New()
+			fileHash = hash.Sum(bytes)
+			fileHashes.Store(file, fileHash)
+		}
+		fullHasher.Write(fileHash)
+	}
+
+	return base64.URLEncoding.EncodeToString(fullHasher.Sum(nil)), nil
+}
+
 func uniqueStringSlice(s []string) []string {
 	seen := make(map[string]struct{}, len(s))
 	j := 0
diff --git a/pkg/tanka/evaluators.go b/pkg/tanka/evaluators.go
index 3babc8c2..8ceaa9fb 100644
--- a/pkg/tanka/evaluators.go
+++ b/pkg/tanka/evaluators.go
@@ -125,7 +125,7 @@ local singleEnv(object) =
        && std.objectHas(object, 'kind')
     then
       if object.kind == 'Environment'
-      && object.metadata.name == '%s'
+      && std.member(object.metadata.name, '%s')
       then object
       else {}
     else
diff --git a/pkg/tanka/inline.go b/pkg/tanka/inline.go
index 3a9dadfa..de173715 100644
--- a/pkg/tanka/inline.go
+++ b/pkg/tanka/inline.go
@@ -4,6 +4,7 @@ import (
 	"encoding/json"
 	"fmt"
 	"path/filepath"
+	"sort"
 
 	"github.com/grafana/tanka/pkg/jsonnet/jpath"
 	"github.com/grafana/tanka/pkg/kubernetes/manifest"
@@ -37,11 +38,12 @@ func (i *InlineLoader) Load(path string, opts LoaderOpts) (*v1alpha1.Environment
 		for _, e := range envs {
 			names = append(names, e.Metadata().Name())
 		}
+		sort.Strings(names)
 		return nil, ErrMultipleEnvs{path, names}
 	}
 
 	if len(envs) == 0 {
-		return nil, fmt.Errorf("Found no environments in '%s'", path)
+		return nil, fmt.Errorf("found no matching environments; run 'tk env list %s' to view available options", path)
 	}
 
 	// TODO: Re-serializing the entire env here. This is horribly inefficient
diff --git a/pkg/tanka/load.go b/pkg/tanka/load.go
index d4b925bd..7cf928a8 100644
--- a/pkg/tanka/load.go
+++ b/pkg/tanka/load.go
@@ -31,6 +31,12 @@ func Load(path string, opts Opts) (*LoadResult, error) {
 		return nil, err
 	}
 
+	// Check if there are still any inline environments in the manifests
+	// They are not real k8s resources, and cannot be applied
+	if envs := process.Filter(result.Resources, process.MustStrExps("Environment/.*")); len(envs) > 0 {
+		return nil, errors.New("found a tanka Environment resource. Check that you aren't using a spec.json and inline environments simultaneously")
+	}
+
 	return result, nil
 }
 


Test patch:
diff --git a/pkg/tanka/load_test.go b/pkg/tanka/load_test.go
index 0863a01d..2345ca74 100644
--- a/pkg/tanka/load_test.go
+++ b/pkg/tanka/load_test.go
@@ -150,7 +150,29 @@ func TestLoad(t *testing.T) {
 	}
 }
 
-func TestLoadFailsWhenDuplicateEnv(t *testing.T) {
-	_, err := Load("./testdata/cases/withduplicateenv", Opts{Name: "withenv"})
-	assert.NotNil(t, err)
+func TestLoadSelectEnvironment(t *testing.T) {
+	// No match
+	_, err := Load("./testdata/cases/multiple-inline-envs", Opts{Name: "no match"})
+	assert.EqualError(t, err, "found no matching environments; run 'tk env list ./testdata/cases/multiple-inline-envs' to view available options")
+
+	// Empty options, match all environments
+	_, err = Load("./testdata/cases/multiple-inline-envs", Opts{})
+	assert.EqualError(t, err, "found multiple Environments in './testdata/cases/multiple-inline-envs': \n - project1-env1\n - project1-env2\n - project2-env1")
+
+	// Partial match two environments
+	_, err = Load("./testdata/cases/multiple-inline-envs", Opts{Name: "env1"})
+	assert.EqualError(t, err, "found multiple Environments in './testdata/cases/multiple-inline-envs': \n - project1-env1\n - project2-env1")
+
+	// Partial match
+	_, err = Load("./testdata/cases/multiple-inline-envs", Opts{Name: "project2"})
+	assert.NoError(t, err)
+
+	// Full match
+	_, err = Load("./testdata/cases/multiple-inline-envs", Opts{Name: "project1-env1"})
+	assert.NoError(t, err)
+}
+
+func TestLoadFailsWhenBothSpecAndInline(t *testing.T) {
+	_, err := Load("./testdata/cases/static_and_inline", Opts{Name: "inline"})
+	assert.EqualError(t, err, "found a tanka Environment resource. Check that you aren't using a spec.json and inline environments simultaneously")
 }
diff --git a/pkg/tanka/testdata/cases/withduplicateenv/main.jsonnet b/pkg/tanka/testdata/cases/multiple-inline-envs/main.jsonnet
similarity index 50%
rename from pkg/tanka/testdata/cases/withduplicateenv/main.jsonnet
rename to pkg/tanka/testdata/cases/multiple-inline-envs/main.jsonnet
index fa93bb95..4e0407e3 100644
--- a/pkg/tanka/testdata/cases/withduplicateenv/main.jsonnet
+++ b/pkg/tanka/testdata/cases/multiple-inline-envs/main.jsonnet
@@ -1,13 +1,13 @@
 {
-  env1: {
+  project1_env1: {
     apiVersion: 'tanka.dev/v1alpha1',
     kind: 'Environment',
     metadata: {
-      name: 'withenv',
+      name: 'project1-env1',
     },
     spec: {
       apiServer: 'https://localhost',
-      namespace: 'withenv',
+      namespace: 'project1-env1',
     },
     data: {
       apiVersion: 'v1',
@@ -15,15 +15,31 @@
       metadata: { name: 'config' },
     },
   },
-  env2: {
+  project1_env2: {
     apiVersion: 'tanka.dev/v1alpha1',
     kind: 'Environment',
     metadata: {
-      name: 'withenv',
+      name: 'project1-env2',
     },
     spec: {
       apiServer: 'https://localhost',
-      namespace: 'withenv',
+      namespace: 'project1-env2',
+    },
+    data: {
+      apiVersion: 'v1',
+      kind: 'ConfigMap',
+      metadata: { name: 'config' },
+    },
+  },
+  project2_env1: {
+    apiVersion: 'tanka.dev/v1alpha1',
+    kind: 'Environment',
+    metadata: {
+      name: 'project2-env1',
+    },
+    spec: {
+      apiServer: 'https://localhost',
+      namespace: 'project2-env1',
     },
     data: {
       apiVersion: 'v1',
diff --git a/pkg/tanka/testdata/cases/static_and_inline/main.jsonnet b/pkg/tanka/testdata/cases/static_and_inline/main.jsonnet
new file mode 100644
index 00000000..d89f8a09
--- /dev/null
+++ b/pkg/tanka/testdata/cases/static_and_inline/main.jsonnet
@@ -0,0 +1,16 @@
+{
+  apiVersion: 'tanka.dev/v1alpha1',
+  kind: 'Environment',
+  metadata: {
+    name: 'inline',
+  },
+  spec: {
+    apiServer: 'https://localhost',
+    namespace: 'inline',
+  },
+  data: {
+    apiVersion: 'v1',
+    kind: 'ConfigMap',
+    metadata: { name: 'config' },
+  },
+}
diff --git a/pkg/tanka/testdata/cases/static_and_inline/spec.json b/pkg/tanka/testdata/cases/static_and_inline/spec.json
new file mode 100644
index 00000000..279417a2
--- /dev/null
+++ b/pkg/tanka/testdata/cases/static_and_inline/spec.json
@@ -0,0 +1,11 @@
+{
+  "apiVersion": "tanka.dev/v1alpha1",
+  "kind": "Environment",
+  "metadata": {
+    "name": "static"
+  },
+  "spec": {
+    "apiServer": "https://localhost",
+    "namespace": "static"
+  }
+}