Repository: grafana/tanka
Pull request #760 (2022-09-20 12:52:07)

`tk export`: Expand merging capabilities with `--merge-strategy` flag
Documented in `exporting.md`. This option expands the existing `--merge` flag (now deprecated) to allow us to delete the previously exported manifests for an environment before exporting. This strategy has been in-use (in private code) for about 6 months here at Grafana Labs.

This is useful when exporting a single environment and merging the result with an existing GitOps repository, rather than re-exporting all environments.

I also added benchmark and a test for the export code.

github-actions[bot]: Benchstat (compared to main):

```
name              old time/op    new time/op    delta
pkg:github.com/grafana/tanka/pkg/jsonnet goos:linux goarch:amd64
GetSnippetHash-2    20.6ms ± 2%    20.5ms ± 1%   ~     (p=0.497 n=10+9)

name              old alloc/op   new alloc/op   delta
pkg:github.com/grafana/tanka/pkg/jsonnet goos:linux goarch:amd64
GetSnippetHash-2    3.86MB ± 0%    3.86MB ± 0%   ~     (p=0.280 n=10+10)

name              old allocs/op  new allocs/op  delta
pkg:github.com/grafana/tanka/pkg/jsonnet goos:linux goarch:amd64
GetSnippetHash-2     32.3k ± 0%     32.3k ± 0%   ~     (p=0.535 n=9+10)

```
julienduchesne: > Not a Tanka core team member, but changes LGTM. Left some minor suggestions.

All input is valued, regardless of status. Thanks!

Patch:
diff --git a/cmd/tk/export.go b/cmd/tk/export.go
index 1900bd6a..951242e7 100644
--- a/cmd/tk/export.go
+++ b/cmd/tk/export.go
@@ -1,6 +1,7 @@
 package main
 
 import (
+	"errors"
 	"fmt"
 	"regexp"
 	"runtime"
@@ -34,12 +35,17 @@ 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", "", "Local file path where cached evaluations should be stored")
 	cacheEnvs := cmd.Flags().StringArrayP("cache-envs", "e", nil, "Regexes which define which environment should be cached (if caching is enabled)")
 	ballastBytes := cmd.Flags().Int("mem-ballast-size-bytes", 0, "Size of memory ballast to allocate. This may improve performance for large environments.")
 
+	merge := cmd.Flags().Bool("merge", false, "Allow merging with existing directory")
+	if err := cmd.Flags().MarkDeprecated("merge", "use --merge-strategy=fail-on-conflicts instead"); err != nil {
+		panic(err)
+	}
+	mergeStrategy := cmd.Flags().String("merge-strategy", "", "What to do when exporting to an existing directory. The default setting is to disallow exporting to an existing directory. Values: 'fail-on-conficts', 'replace-envs'")
+
 	vars := workflowFlags(cmd.Flags())
 	getJsonnetOpts := jsonnetFlags(cmd.Flags())
 	getLabelSelector := labelSelectorFlag(cmd.Flags())
@@ -59,7 +65,6 @@ func exportCmd() *cli.Command {
 		opts := tanka.ExportEnvOpts{
 			Format:    *format,
 			Extension: *extension,
-			Merge:     *merge,
 			Opts: tanka.Opts{
 				JsonnetOpts: getJsonnetOpts(),
 				Filters:     filters,
@@ -68,6 +73,11 @@ func exportCmd() *cli.Command {
 			Selector:    getLabelSelector(),
 			Parallelism: *parallel,
 		}
+
+		if opts.MergeStrategy, err = determineMergeStrategy(*merge, *mergeStrategy); err != nil {
+			return err
+		}
+
 		opts.Opts.CachePath = *cachePath
 		for _, expr := range *cacheEnvs {
 			regex, err := regexp.Compile(expr)
@@ -116,3 +126,20 @@ func exportCmd() *cli.Command {
 	}
 	return cmd
 }
+
+// `--merge` is deprecated in favor of `--merge-strategy`. However, merge has to keep working for now.
+func determineMergeStrategy(deprecatedMergeFlag bool, mergeStrategy string) (tanka.ExportMergeStrategy, error) {
+	if deprecatedMergeFlag && mergeStrategy != "" {
+		return "", errors.New("cannot use --merge and --merge-strategy at the same time")
+	}
+	if deprecatedMergeFlag {
+		return tanka.ExportMergeStrategyFailConflicts, nil
+	}
+
+	switch strategy := tanka.ExportMergeStrategy(mergeStrategy); strategy {
+	case tanka.ExportMergeStrategyFailConflicts, tanka.ExportMergeStrategyReplaceEnvs, tanka.ExportMergeStrategyNone:
+		return strategy, nil
+	}
+
+	return "", fmt.Errorf("invalid merge strategy: %q", mergeStrategy)
+}
diff --git a/docs/docs/exporting.md b/docs/docs/exporting.md
index a6afd0c6..0d90f304 100644
--- a/docs/docs/exporting.md
+++ b/docs/docs/exporting.md
@@ -99,7 +99,21 @@ $ tk export exportDir environments/ --recursive
 $ tk export exportDir environments/ -r -l team=infra
 ```
 
-## Using a memory ballast
+## Performance features
+
+When exporting a large amount of environments, jsonnet evaluation can become a bottleneck. To speed up the process, Tanka provides a few optional features.
+
+### Partial export (in a GitOps context)
+
+Given multiple environments, one may want to only export the environments that were modified since the last export. This is enabled by passing both the `--merge-strategy=replace-envs` flags.
+
+When these flags are passed, Tanka will:
+
+1. Delete the manifests that were previously exported by the environments that are being exported. This is done by looking at the `manifest.json` file that is generated by Tanka when exporting. The related entries are also removed from the `manifest.json` file.
+2. Generate the manifests for the targeted environments into the output directory.
+3. Add in the new manifests entries into the `manifest.json` file and re-export it.
+
+### Using a memory ballast
 
 _Read [this blog post](https://blog.twitch.tv/en/2019/04/10/go-memory-ballast-how-i-learnt-to-stop-worrying-and-love-the-heap/) for more information about memory ballasts._
 
@@ -107,7 +121,7 @@ For large environments that load lots of data into memory on evaluation, a memor
 
 Anecdotally (Grafana Labs), environments that took around a minute to load were able to load in around 45 secs with a ballast of 5GB (`--mem-ballast-size-bytes=5368709120`). Decreasing the ballast size resulted in negative impact on performance, and increasing it more did not result in any noticeable impact.
 
-## Caching
+### Caching
 
 Tanka can also cache the results of the export. This is useful if you often export the same files and want to avoid recomputing them. The cache key is calculated from the main file and all of its transitive imports, so any change to any file possibly used in an environment will invalidate the cache.
 
diff --git a/pkg/tanka/export.go b/pkg/tanka/export.go
index 153c34e4..99c350ea 100644
--- a/pkg/tanka/export.go
+++ b/pkg/tanka/export.go
@@ -5,12 +5,15 @@ import (
 	"encoding/json"
 	"fmt"
 	"io"
+	"io/fs"
+	"log"
 	"os"
 	"path/filepath"
 	"strings"
 	"text/template"
 
 	"github.com/Masterminds/sprig/v3"
+	"github.com/pkg/errors"
 	"k8s.io/apimachinery/pkg/labels"
 
 	"github.com/grafana/tanka/pkg/kubernetes/manifest"
@@ -27,20 +30,32 @@ const BelRune = string(rune(7))
 // debugging purposes.
 const manifestFile = "manifest.json"
 
+type ExportMergeStrategy string
+
+const (
+	ExportMergeStrategyNone          ExportMergeStrategy = ""
+	ExportMergeStrategyFailConflicts ExportMergeStrategy = "fail-on-conflicts"
+	ExportMergeStrategyReplaceEnvs   ExportMergeStrategy = "replace-envs"
+)
+
 // 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: options to parse Jsonnet
 	Opts Opts
 	// optional: filter environments based on labels
 	Selector labels.Selector
 	// optional: number of environments to process in parallel
 	Parallelism int
+
+	// What to do when exporting to an existing directory
+	// - none: fail when directory is not empty
+	// - fail-on-conflicts: fail when an exported file already exists
+	// - replace-envs: delete files previously exported by the targeted envs and re-export them
+	MergeStrategy ExportMergeStrategy
 }
 
 func ExportEnvironments(envs []*v1alpha1.Environment, to string, opts *ExportEnvOpts) error {
@@ -52,8 +67,15 @@ func ExportEnvironments(envs []*v1alpha1.Environment, to string, opts *ExportEnv
 	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)
+	if !empty && opts.MergeStrategy == ExportMergeStrategyNone {
+		return fmt.Errorf("output dir `%s` not empty. Pass a different --merge-strategy to ignore this", to)
+	}
+
+	// delete files previously exported by the targeted envs.
+	if opts.MergeStrategy == ExportMergeStrategyReplaceEnvs {
+		if err := deletePreviouslyExportedManifests(to, envs); err != nil {
+			return fmt.Errorf("deleting previously exported manifests: %w", err)
+		}
 	}
 
 	// get all environments for paths
@@ -122,19 +144,7 @@ func ExportEnvironments(envs []*v1alpha1.Environment, to string, opts *ExportEnv
 		}
 	}
 
-	// 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
+	return exportManifestFile(to, fileToEnv, nil)
 }
 
 func fileExists(name string) (bool, error) {
@@ -164,6 +174,75 @@ func dirEmpty(dir string) (bool, error) {
 	return false, err
 }
 
+func deletePreviouslyExportedManifests(path string, envs []*v1alpha1.Environment) error {
+	fileToEnvMap := make(map[string]string)
+
+	manifestFilePath := filepath.Join(path, manifestFile)
+	manifestContent, err := os.ReadFile(manifestFilePath)
+	if errors.Is(err, fs.ErrNotExist) {
+		log.Printf("Warning: No manifest file found at %s, skipping deletion of previously exported manifests\n", manifestFilePath)
+		return nil
+	} else if err != nil {
+		return err
+	}
+
+	if err := json.Unmarshal(manifestContent, &fileToEnvMap); err != nil {
+		return err
+	}
+
+	envNames := make(map[string]struct{})
+	for _, env := range envs {
+		envNames[env.Metadata.Namespace] = struct{}{}
+	}
+
+	var deletedManifestKeys []string
+	for exportedManifest, manifestEnv := range fileToEnvMap {
+		if _, ok := envNames[manifestEnv]; ok {
+			deletedManifestKeys = append(deletedManifestKeys, exportedManifest)
+			if err := os.Remove(filepath.Join(path, exportedManifest)); err != nil {
+				return err
+			}
+		}
+	}
+
+	return exportManifestFile(path, nil, deletedManifestKeys)
+}
+
+// exportManifestFile writes a manifest file that maps the exported files to their environment.
+// If the file already exists, the new entries will be merged with the existing ones.
+func exportManifestFile(path string, newFileToEnvMap map[string]string, deletedKeys []string) error {
+	if len(newFileToEnvMap) == 0 && len(deletedKeys) == 0 {
+		return nil
+	}
+
+	currentFileToEnvMap := make(map[string]string)
+	manifestFilePath := filepath.Join(path, manifestFile)
+	if manifestContent, err := os.ReadFile(manifestFilePath); err != nil && !errors.Is(err, fs.ErrNotExist) {
+		return fmt.Errorf("reading existing manifest file: %w", err)
+	} else if err == nil {
+		// Only read the manifest file if it exists.
+		// If it doesn't exist, currentFileToEnvMap will be empty, meaning that we're starting from a new export dir.
+		if err := json.Unmarshal(manifestContent, &currentFileToEnvMap); err != nil {
+			return fmt.Errorf("unmarshalling existing manifest file: %w", err)
+		}
+	}
+
+	for k, v := range newFileToEnvMap {
+		currentFileToEnvMap[k] = v
+	}
+	for _, k := range deletedKeys {
+		delete(currentFileToEnvMap, k)
+	}
+
+	// Write manifest file
+	data, err := json.MarshalIndent(currentFileToEnvMap, "", "    ")
+	if err != nil {
+		return fmt.Errorf("marshalling manifest file: %w", err)
+	}
+
+	return writeExportFile(manifestFilePath, data)
+}
+
 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)


Test patch:
diff --git a/cmd/tk/export_test.go b/cmd/tk/export_test.go
new file mode 100644
index 00000000..c5e6deea
--- /dev/null
+++ b/cmd/tk/export_test.go
@@ -0,0 +1,63 @@
+package main
+
+import (
+	"errors"
+	"testing"
+
+	"github.com/grafana/tanka/pkg/tanka"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestDetermineMergeStrategy(t *testing.T) {
+	cases := []struct {
+		name           string
+		deprecatedFlag bool
+		mergeStrategy  string
+		expected       tanka.ExportMergeStrategy
+		expectErr      error
+	}{
+		{
+			name:           "default",
+			deprecatedFlag: false,
+			mergeStrategy:  "",
+			expected:       tanka.ExportMergeStrategyNone,
+		},
+		{
+			name:           "deprecated flag set",
+			deprecatedFlag: true,
+			expected:       tanka.ExportMergeStrategyFailConflicts,
+		},
+		{
+			name:           "both values set",
+			deprecatedFlag: true,
+			mergeStrategy:  "fail-conflicts",
+			expectErr:      errors.New("cannot use --merge and --merge-strategy at the same time"),
+		},
+		{
+			name:          "fail-conflicts",
+			mergeStrategy: "fail-on-conflicts",
+			expected:      tanka.ExportMergeStrategyFailConflicts,
+		},
+		{
+			name:          "replace-envs",
+			mergeStrategy: "replace-envs",
+			expected:      tanka.ExportMergeStrategyReplaceEnvs,
+		},
+		{
+			name:          "bad value",
+			mergeStrategy: "blabla",
+			expectErr:     errors.New("invalid merge strategy: \"blabla\""),
+		},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			result, err := determineMergeStrategy(tc.deprecatedFlag, tc.mergeStrategy)
+			if tc.expectErr != nil {
+				assert.EqualError(t, err, tc.expectErr.Error())
+			} else {
+				assert.NoError(t, err)
+			}
+			assert.Equal(t, tc.expected, result)
+		})
+	}
+}
diff --git a/pkg/tanka/export_test.go b/pkg/tanka/export_test.go
index 9663c695..2c1acaaa 100644
--- a/pkg/tanka/export_test.go
+++ b/pkg/tanka/export_test.go
@@ -1,6 +1,18 @@
 package tanka
 
-import "testing"
+import (
+	"fmt"
+	"io"
+	"log"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/grafana/tanka/pkg/jsonnet"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"k8s.io/apimachinery/pkg/labels"
+)
 
 func Test_replaceTmplText(t *testing.T) {
 	type args struct {
@@ -31,3 +43,132 @@ func Test_replaceTmplText(t *testing.T) {
 		})
 	}
 }
+
+func TestExportEnvironments(t *testing.T) {
+	tempDir := t.TempDir()
+	require.NoError(t, os.Chdir("testdata"))
+	defer func() { require.NoError(t, os.Chdir("..")) }()
+
+	// Find envs
+	envs, err := FindEnvs("test-export-envs", FindOpts{Selector: labels.Everything()})
+	require.NoError(t, err)
+
+	// Export all envs
+	opts := &ExportEnvOpts{
+		Format:    "{{.metadata.namespace}}/{{.metadata.name}}",
+		Extension: "yaml",
+	}
+	opts.Opts.ExtCode = jsonnet.InjectedCode{
+		"deploymentName": "'initial-deployment'",
+		"serviceName":    "'initial-service'",
+	}
+	require.NoError(t, ExportEnvironments(envs, tempDir, opts))
+	checkFiles(t, tempDir, []string{
+		filepath.Join(tempDir, "inline-namespace1", "my-configmap.yaml"),
+		filepath.Join(tempDir, "inline-namespace1", "my-deployment.yaml"),
+		filepath.Join(tempDir, "inline-namespace1", "my-service.yaml"),
+		filepath.Join(tempDir, "inline-namespace2", "my-deployment.yaml"),
+		filepath.Join(tempDir, "inline-namespace2", "my-service.yaml"),
+		filepath.Join(tempDir, "static", "initial-deployment.yaml"),
+		filepath.Join(tempDir, "static", "initial-service.yaml"),
+		filepath.Join(tempDir, "manifest.json"),
+	})
+	manifestContent, err := os.ReadFile(filepath.Join(tempDir, "manifest.json"))
+	require.NoError(t, err)
+	assert.Equal(t, string(manifestContent), `{
+    "inline-namespace1/my-configmap.yaml": "test-export-envs/inline-envs/main.jsonnet",
+    "inline-namespace1/my-deployment.yaml": "test-export-envs/inline-envs/main.jsonnet",
+    "inline-namespace1/my-service.yaml": "test-export-envs/inline-envs/main.jsonnet",
+    "inline-namespace2/my-deployment.yaml": "test-export-envs/inline-envs/main.jsonnet",
+    "inline-namespace2/my-service.yaml": "test-export-envs/inline-envs/main.jsonnet",
+    "static/initial-deployment.yaml": "test-export-envs/static-env/main.jsonnet",
+    "static/initial-service.yaml": "test-export-envs/static-env/main.jsonnet"
+}`)
+
+	// Try to re-export
+	assert.EqualError(t, ExportEnvironments(envs, tempDir, opts), fmt.Sprintf("output dir `%s` not empty. Pass a different --merge-strategy to ignore this", tempDir))
+
+	// Try to re-export with the --merge-strategy=fail-on-conflicts flag. Will still fail because Tanka will not overwrite manifests silently
+	opts.MergeStrategy = ExportMergeStrategyFailConflicts
+	assert.ErrorContains(t, ExportEnvironments(envs, tempDir, opts), "already exists. Aborting")
+
+	// Re-export only one env with --merge-stategy=replace-envs flag
+	opts.Opts.ExtCode = jsonnet.InjectedCode{
+		"deploymentName": "'updated-deployment'",
+		"serviceName":    "'updated-service'",
+	}
+	opts.MergeStrategy = ExportMergeStrategyReplaceEnvs
+	staticEnv, err := FindEnvs("test-export-envs", FindOpts{Selector: labels.SelectorFromSet(labels.Set{"type": "static"})})
+	require.NoError(t, err)
+	require.NoError(t, ExportEnvironments(staticEnv, tempDir, opts))
+	checkFiles(t, tempDir, []string{
+		filepath.Join(tempDir, "inline-namespace1", "my-configmap.yaml"),
+		filepath.Join(tempDir, "inline-namespace1", "my-deployment.yaml"),
+		filepath.Join(tempDir, "inline-namespace1", "my-service.yaml"),
+		filepath.Join(tempDir, "inline-namespace2", "my-deployment.yaml"),
+		filepath.Join(tempDir, "inline-namespace2", "my-service.yaml"),
+		filepath.Join(tempDir, "static", "updated-deployment.yaml"),
+		filepath.Join(tempDir, "static", "updated-service.yaml"),
+		filepath.Join(tempDir, "manifest.json"),
+	})
+	manifestContent, err = os.ReadFile(filepath.Join(tempDir, "manifest.json"))
+	require.NoError(t, err)
+	assert.Equal(t, string(manifestContent), `{
+    "inline-namespace1/my-configmap.yaml": "test-export-envs/inline-envs/main.jsonnet",
+    "inline-namespace1/my-deployment.yaml": "test-export-envs/inline-envs/main.jsonnet",
+    "inline-namespace1/my-service.yaml": "test-export-envs/inline-envs/main.jsonnet",
+    "inline-namespace2/my-deployment.yaml": "test-export-envs/inline-envs/main.jsonnet",
+    "inline-namespace2/my-service.yaml": "test-export-envs/inline-envs/main.jsonnet",
+    "static/updated-deployment.yaml": "test-export-envs/static-env/main.jsonnet",
+    "static/updated-service.yaml": "test-export-envs/static-env/main.jsonnet"
+}`)
+}
+
+func BenchmarkExportEnvironmentsWithReplaceEnvs(b *testing.B) {
+	log.SetOutput(io.Discard)
+	tempDir := b.TempDir()
+	require.NoError(b, os.Chdir("testdata"))
+	defer func() { require.NoError(b, os.Chdir("..")) }()
+
+	// Find envs
+	envs, err := FindEnvs("test-export-envs", FindOpts{Selector: labels.Everything()})
+	require.NoError(b, err)
+
+	// Export all envs
+	opts := &ExportEnvOpts{
+		Format:        "{{.metadata.namespace}}/{{.metadata.name}}",
+		Extension:     "yaml",
+		MergeStrategy: ExportMergeStrategyReplaceEnvs,
+	}
+	opts.Opts.ExtCode = jsonnet.InjectedCode{
+		"deploymentName": "'initial-deployment'",
+		"serviceName":    "'initial-service'",
+	}
+	// Export a first time so that the benchmark loops are identical
+	require.NoError(b, ExportEnvironments(envs, tempDir, opts))
+
+	// On every loop, delete manifests from previous envs + reexport all envs
+	b.ResetTimer()
+	for i := 0; i < b.N; i++ {
+		require.NoError(b, ExportEnvironments(envs, tempDir, opts), "failed on iteration %d", i)
+	}
+}
+
+func checkFiles(t testing.TB, dir string, files []string) {
+	t.Helper()
+
+	var existingFiles []string
+	err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
+		if err != nil {
+			return err
+		}
+		if info.IsDir() {
+			return nil
+		}
+		existingFiles = append(existingFiles, path)
+		return nil
+	})
+	require.NoError(t, err)
+
+	assert.ElementsMatch(t, files, existingFiles)
+}
diff --git a/pkg/tanka/testdata/test-export-envs/inline-envs/main.jsonnet b/pkg/tanka/testdata/test-export-envs/inline-envs/main.jsonnet
new file mode 100644
index 00000000..e7c62883
--- /dev/null
+++ b/pkg/tanka/testdata/test-export-envs/inline-envs/main.jsonnet
@@ -0,0 +1,53 @@
+[
+  {
+    apiVersion: 'tanka.dev/v1alpha1',
+    kind: 'Environment',
+    metadata: {
+      name: env.namespace,
+      labels: {
+        type: 'inline',
+      },
+    },
+    spec: {
+      apiServer: 'https://localhost',
+      namespace: env.namespace,
+    },
+    data:
+      {
+        deployment: {
+          apiVersion: 'apps/v1',
+          kind: 'Deployment',
+          metadata: {
+            name: 'my-deployment',
+          },
+        },
+        service: {
+          apiVersion: 'v1',
+          kind: 'Service',
+          metadata: {
+            name: 'my-service',
+          },
+        },
+      } +
+      (if env.hasConfigMap then {
+         configMap: {
+           apiVersion: 'v1',
+           kind: 'ConfigMap',
+           metadata: {
+             name: 'my-configmap',
+           },
+         },
+       } else {}),
+  }
+
+  for env in [
+    {
+      namespace: 'inline-namespace1',
+      hasConfigMap: true,
+    },
+    {
+      namespace: 'inline-namespace2',
+      hasConfigMap: false,
+    },
+  ]
+]
diff --git a/pkg/tanka/testdata/test-export-envs/static-env/main.jsonnet b/pkg/tanka/testdata/test-export-envs/static-env/main.jsonnet
new file mode 100644
index 00000000..f44cefdd
--- /dev/null
+++ b/pkg/tanka/testdata/test-export-envs/static-env/main.jsonnet
@@ -0,0 +1,16 @@
+{
+  deployment: {
+    apiVersion: 'apps/v1',
+    kind: 'Deployment',
+    metadata: {
+      name: std.extVar('deploymentName'),
+    },
+  },
+  service: {
+    apiVersion: 'v1',
+    kind: 'Service',
+    metadata: {
+      name: std.extVar('serviceName'),
+    },
+  },
+}
diff --git a/pkg/tanka/testdata/test-export-envs/static-env/spec.json b/pkg/tanka/testdata/test-export-envs/static-env/spec.json
new file mode 100644
index 00000000..52f9f830
--- /dev/null
+++ b/pkg/tanka/testdata/test-export-envs/static-env/spec.json
@@ -0,0 +1,14 @@
+{
+  "apiVersion": "tanka.dev/v1alpha1",
+  "kind": "Environment",
+  "metadata": {
+    "name": "static",
+    "labels": {
+      "type": "static"
+    }
+  },
+  "spec": {
+    "apiServer": "https://localhost",
+    "namespace": "static"
+  }
+}