Repository: grafana/tanka
Pull request #771 (2022-10-03 16:05:43)

Add new `tk tool importers` command
This command is powerful when used with the `--merge-strategy=replace-envs` flag added here: https://github.com/grafana/tanka/pull/760

* Given a repository with a large number of Tanka environments, exporting the whole set can be very time consuming if done on every commit. 
* What a user probably wants to do is to only export the environments that have changed. 
* To do this, we need to actually find out what files affect which environments, and this is what this new command does. 
* A user can now pass the list of files modified in a git commit, and run `tk export` on the set of environments affected by those files.

To be tested on our CI, but from tests on my machine, I can consistently get a result in under 1s, even for files that are heavily imported


Patch:
diff --git a/cmd/tk/tool.go b/cmd/tk/tool.go
index 7e19bf0c..137a7f62 100644
--- a/cmd/tk/tool.go
+++ b/cmd/tk/tool.go
@@ -10,6 +10,7 @@ import (
 	"strings"
 
 	"github.com/go-clix/cli"
+	"github.com/posener/complete"
 
 	"github.com/grafana/tanka/pkg/jsonnet"
 	"github.com/grafana/tanka/pkg/jsonnet/jpath"
@@ -23,6 +24,7 @@ func toolCmd() *cli.Command {
 	cmd.AddCommand(
 		jpathCmd(),
 		importsCmd(),
+		importersCmd(),
 		chartsCmd(),
 	)
 	return cmd
@@ -130,6 +132,42 @@ func importsCmd() *cli.Command {
 	return cmd
 }
 
+func importersCmd() *cli.Command {
+	cmd := &cli.Command{
+		Use:   "importers <file> <file...>",
+		Short: "list all environments that either directly or transitively import the given files",
+		Args: cli.Args{
+			Validator: cli.ArgsMin(1),
+			Predictor: complete.PredictFiles("*"),
+		},
+	}
+
+	root := cmd.Flags().String("root", ".", "root directory to search for environments")
+	cmd.Run = func(cmd *cli.Command, args []string) error {
+		root, err := filepath.Abs(*root)
+		if err != nil {
+			return fmt.Errorf("resolving root: %w", err)
+		}
+
+		for _, f := range args {
+			if _, err := os.Stat(f); os.IsNotExist(err) {
+				return fmt.Errorf("file %q does not exist", f)
+			}
+		}
+
+		envs, err := jsonnet.FindImporterForFiles(root, args)
+		if err != nil {
+			return fmt.Errorf("resolving imports: %s", err)
+		}
+
+		fmt.Println(strings.Join(envs, "\n"))
+
+		return nil
+	}
+
+	return cmd
+}
+
 func gitRoot() (string, error) {
 	s, err := git("rev-parse", "--show-toplevel")
 	return strings.TrimRight(s, "\n"), err
diff --git a/pkg/jsonnet/find_importers.go b/pkg/jsonnet/find_importers.go
new file mode 100644
index 00000000..e50aecb1
--- /dev/null
+++ b/pkg/jsonnet/find_importers.go
@@ -0,0 +1,270 @@
+package jsonnet
+
+import (
+	"os"
+	"path/filepath"
+	"sort"
+	"strings"
+
+	"github.com/grafana/tanka/pkg/jsonnet/jpath"
+)
+
+var (
+	jsonnetFilesMap = make(map[string]map[string]*cachedJsonnetFile)
+	symlinkCache    = make(map[string]string)
+)
+
+type cachedJsonnetFile struct {
+	Base       string
+	Imports    []string
+	Content    string
+	IsMainFile bool
+}
+
+// FindImporterForFiles finds the entrypoints (main.jsonnet files) that import the given files.
+// It looks through imports transitively, so if a file is imported through a chain, it will still be reported.
+// If the given file is a main.jsonnet file, it will be returned as well.
+func FindImporterForFiles(root string, files []string) ([]string, error) {
+	var err error
+	root, err = filepath.Abs(root)
+	if err != nil {
+		return nil, err
+	}
+
+	importers := map[string]struct{}{}
+
+	if files, err = expandSymlinksInFiles(root, files); err != nil {
+		return nil, err
+	}
+
+	// Loop through all given files and add their importers to the list
+	for _, file := range files {
+		if filepath.Base(file) == jpath.DefaultEntrypoint {
+			importers[file] = struct{}{}
+		}
+
+		newImporters, err := findImporters(root, file, map[string]struct{}{})
+		if err != nil {
+			return nil, err
+		}
+		for _, importer := range newImporters {
+			importers[importer] = struct{}{}
+		}
+	}
+
+	return mapToArray(importers), nil
+}
+
+// expandSymlinksInFiles takes an array of files and adds to it:
+// - all symlinks that point to the files
+// - all files that are pointed to by the symlinks
+func expandSymlinksInFiles(root string, files []string) ([]string, error) {
+	filesMap := map[string]struct{}{}
+
+	for _, file := range files {
+		file, err := filepath.Abs(file)
+		if err != nil {
+			return nil, err
+		}
+		filesMap[file] = struct{}{}
+
+		symlink, err := evalSymlinks(file)
+		if err != nil {
+			return nil, err
+		}
+		if symlink != file {
+			filesMap[symlink] = struct{}{}
+		}
+
+		symlinks, err := findSymlinks(root, file)
+		if err != nil {
+			return nil, err
+		}
+		for _, symlink := range symlinks {
+			filesMap[symlink] = struct{}{}
+		}
+	}
+
+	return mapToArray(filesMap), nil
+}
+
+// evalSymlinks returns the path after following all symlinks.
+// It caches the results to avoid unnecessary work.
+func evalSymlinks(path string) (string, error) {
+	var err error
+	eval, ok := symlinkCache[path]
+	if !ok {
+		eval, err = filepath.EvalSymlinks(path)
+		if err != nil {
+			return "", err
+		}
+		symlinkCache[path] = eval
+	}
+	return eval, nil
+}
+
+// findSymlinks finds all symlinks that point to the given file.
+// It's restricted to the given root directory.
+// It's used in the case where a user wants to find which entrypoints import a given file.
+// In that case, we also want to find the entrypoints that import a symlink to the file.
+func findSymlinks(root, file string) ([]string, error) {
+	var symlinks []string
+
+	err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
+		if err != nil {
+			return err
+		}
+
+		if info.Mode()&os.ModeSymlink == os.ModeSymlink {
+			eval, err := evalSymlinks(path)
+			if err != nil {
+				return err
+			}
+			if strings.Contains(file, eval) {
+				symlinks = append(symlinks, strings.Replace(file, eval, path, 1))
+			}
+		}
+
+		return nil
+	})
+
+	return symlinks, err
+}
+
+func findImporters(root string, searchForFile string, chain map[string]struct{}) ([]string, error) {
+	// If we've already looked through this file in the current execution, don't do it again
+	// Jsonnet supports cyclic imports (as long as the _attributes_ being used are not cyclic)
+	if _, ok := chain[searchForFile]; ok {
+		return nil, nil
+	}
+	chain[searchForFile] = struct{}{}
+
+	// If we've never fetched the map of all jsonnet files, do it now
+	// This is cached for performance
+	if _, ok := jsonnetFilesMap[root]; !ok {
+		jsonnetFilesMap[root] = make(map[string]*cachedJsonnetFile)
+
+		files, err := FindFiles(root, nil)
+		if err != nil {
+			return nil, err
+		}
+		for _, file := range files {
+			content, err := os.ReadFile(file)
+			if err != nil {
+				return nil, err
+			}
+			matches := importsRegexp.FindAllStringSubmatch(string(content), -1)
+
+			cachedObj := &cachedJsonnetFile{
+				Content:    string(content),
+				IsMainFile: strings.HasSuffix(file, jpath.DefaultEntrypoint),
+			}
+			for _, match := range matches {
+				cachedObj.Imports = append(cachedObj.Imports, match[2])
+			}
+			jsonnetFilesMap[root][file] = cachedObj
+		}
+	}
+	jsonnetFiles := jsonnetFilesMap[root]
+
+	var importers []string
+	var intermediateImporters []string
+
+	for jsonnetFilePath, jsonnetFileContent := range jsonnetFiles {
+		isImporter := false
+		// For all imports in all jsonnet files, check if they import the file we're looking for
+		for _, importPath := range jsonnetFileContent.Imports {
+			// If the filename is not the same as the file we are looking for, skip it
+			if filepath.Base(importPath) != filepath.Base(searchForFile) {
+				continue
+			}
+
+			// Match on relative imports with ..
+			// Jsonnet also matches all intermediary paths for some reason, so we look at them too
+			doubleDotCount := strings.Count(importPath, "..")
+			if doubleDotCount > 0 {
+				importPath = strings.ReplaceAll(importPath, "../", "")
+				for i := 0; i <= doubleDotCount; i++ {
+					dir := filepath.Dir(jsonnetFilePath)
+					for j := 0; j < i; j++ {
+						dir = filepath.Dir(dir)
+					}
+					testImportPath := filepath.Join(dir, importPath)
+					isImporter = pathMatches(searchForFile, testImportPath)
+				}
+			}
+
+			// Match on imports to lib/ or vendor/
+			if !isImporter {
+				importPath = strings.ReplaceAll(importPath, "./", "")
+				isImporter = pathMatches(searchForFile, filepath.Join(root, "vendor", importPath)) || pathMatches(searchForFile, filepath.Join(root, "lib", importPath))
+			}
+
+			// Match on imports to the base dir where the file is located (e.g. in the env dir)
+			if !isImporter {
+				if jsonnetFileContent.Base == "" {
+					base, err := jpath.FindBase(jsonnetFilePath, root)
+					if err != nil {
+						return nil, err
+					}
+					jsonnetFileContent.Base = base
+				}
+				isImporter = strings.HasPrefix(searchForFile, jsonnetFileContent.Base) && strings.HasSuffix(searchForFile, importPath)
+			}
+
+			// If the file we're looking in imports one of the files we're looking for, add it to the list
+			// It can either be an importer that we want to return (from a main file) or an intermediate importer
+			if isImporter {
+				if jsonnetFileContent.IsMainFile {
+					importers = append(importers, jsonnetFilePath)
+				} else {
+					intermediateImporters = append(intermediateImporters, jsonnetFilePath)
+				}
+				break
+			}
+		}
+	}
+
+	// Process intermediate importers recursively
+	// This will go on until we hit a main file, which will be returned
+	if len(intermediateImporters) > 0 {
+		for _, intermediateImporter := range intermediateImporters {
+			newImporters, err := findImporters(root, intermediateImporter, chain)
+			if err != nil {
+				return nil, err
+			}
+			importers = append(importers, newImporters...)
+		}
+	}
+
+	return importers, nil
+}
+
+func pathMatches(path1, path2 string) bool {
+	if path1 == path2 {
+		return true
+	}
+
+	var err error
+
+	evalPath1, err := evalSymlinks(path1)
+	if err != nil {
+		return false
+	}
+
+	evalPath2, err := evalSymlinks(path2)
+	if err != nil {
+		return false
+	}
+
+	return evalPath1 == evalPath2
+}
+
+func mapToArray(m map[string]struct{}) []string {
+	var arr []string
+	for k := range m {
+		arr = append(arr, k)
+	}
+	sort.Strings(arr)
+	return arr
+}
diff --git a/pkg/jsonnet/jpath/jpath.go b/pkg/jsonnet/jpath/jpath.go
index e99f0bc2..e5fd7590 100644
--- a/pkg/jsonnet/jpath/jpath.go
+++ b/pkg/jsonnet/jpath/jpath.go
@@ -5,7 +5,7 @@ import (
 	"path/filepath"
 )
 
-const defaultEntrypoint = "main.jsonnet"
+const DefaultEntrypoint = "main.jsonnet"
 
 // 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/)
@@ -49,7 +49,7 @@ func Filename(path string) (string, error) {
 	}
 
 	if fi.IsDir() {
-		return defaultEntrypoint, nil
+		return DefaultEntrypoint, nil
 	}
 
 	return filepath.Base(fi.Name()), nil


Test patch:
diff --git a/pkg/jsonnet/find_importers_test.go b/pkg/jsonnet/find_importers_test.go
new file mode 100644
index 00000000..b575d5dd
--- /dev/null
+++ b/pkg/jsonnet/find_importers_test.go
@@ -0,0 +1,114 @@
+package jsonnet
+
+import (
+	"fmt"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestFindImportersForFiles(t *testing.T) {
+	cases := []struct {
+		name              string
+		files             []string
+		expectedImporters []string
+		expectedErr       error
+	}{
+		{
+			name:  "no files",
+			files: []string{},
+		},
+		{
+			name:        "invalid file",
+			files:       []string{"testdata/findImporters/does-not-exist.jsonnet"},
+			expectedErr: fmt.Errorf("lstat %s: no such file or directory", absPath(t, "testdata/findImporters/does-not-exist.jsonnet")),
+		},
+		{
+			name:              "project with no imports",
+			files:             []string{"testdata/findImporters/environments/no-imports/main.jsonnet"},
+			expectedImporters: []string{absPath(t, "testdata/findImporters/environments/no-imports/main.jsonnet")}, // itself only
+		},
+		{
+			name:              "local import",
+			files:             []string{"testdata/findImporters/environments/imports-locals-and-vendored/local-file1.libsonnet"},
+			expectedImporters: []string{absPath(t, "testdata/findImporters/environments/imports-locals-and-vendored/main.jsonnet")},
+		},
+		{
+			name:              "local import with relative path",
+			files:             []string{"testdata/findImporters/environments/imports-locals-and-vendored/local-file2.libsonnet"},
+			expectedImporters: []string{absPath(t, "testdata/findImporters/environments/imports-locals-and-vendored/main.jsonnet")},
+		},
+		{
+			name:  "lib imported through chain",
+			files: []string{"testdata/findImporters/lib/lib1/main.libsonnet"},
+			expectedImporters: []string{
+				absPath(t, "testdata/findImporters/environments/imports-lib-and-vendored-through-chain/main.jsonnet"),
+			},
+		},
+		{
+			name:  "vendored lib imported through chain + directly",
+			files: []string{"testdata/findImporters/vendor/vendored/main.libsonnet"},
+			expectedImporters: []string{
+				absPath(t, "testdata/findImporters/environments/imports-lib-and-vendored-through-chain/main.jsonnet"),
+				absPath(t, "testdata/findImporters/environments/imports-locals-and-vendored/main.jsonnet"),
+				absPath(t, "testdata/findImporters/environments/imports-symlinked-vendor/main.jsonnet"),
+			},
+		},
+		{
+			name:  "vendored lib found through symlink", // expect same result as previous test
+			files: []string{"testdata/findImporters/vendor/vendor-symlinked/main.libsonnet"},
+			expectedImporters: []string{
+				absPath(t, "testdata/findImporters/environments/imports-lib-and-vendored-through-chain/main.jsonnet"),
+				absPath(t, "testdata/findImporters/environments/imports-locals-and-vendored/main.jsonnet"),
+				absPath(t, "testdata/findImporters/environments/imports-symlinked-vendor/main.jsonnet"),
+			},
+		},
+		{
+			name:  "text file",
+			files: []string{"testdata/findImporters/vendor/vendored/text-file.txt"},
+			expectedImporters: []string{
+				absPath(t, "testdata/findImporters/environments/imports-lib-and-vendored-through-chain/main.jsonnet"),
+				absPath(t, "testdata/findImporters/environments/imports-locals-and-vendored/main.jsonnet"),
+				absPath(t, "testdata/findImporters/environments/imports-symlinked-vendor/main.jsonnet"),
+			},
+		},
+	}
+
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			importers, err := FindImporterForFiles("testdata/findImporters", c.files)
+
+			if c.expectedErr != nil {
+				require.EqualError(t, err, c.expectedErr.Error())
+			} else {
+				require.NoError(t, err)
+				require.Equal(t, c.expectedImporters, importers)
+			}
+		})
+	}
+}
+
+func BenchmarkFindImporters(b *testing.B) {
+	// Create a very large and complex project
+	tempDir := b.TempDir()
+	generateTestProject(b, tempDir, 100, false)
+
+	// Run the benchmark
+	expectedImporters := []string{filepath.Join(tempDir, "main.jsonnet")}
+	b.ResetTimer()
+	for i := 0; i < b.N; i++ {
+		importers, err := FindImporterForFiles(tempDir, []string{filepath.Join(tempDir, "file10.libsonnet")})
+
+		require.NoError(b, err)
+		require.Equal(b, expectedImporters, importers)
+	}
+}
+
+func absPath(t *testing.T, path string) string {
+	t.Helper()
+
+	abs, err := filepath.Abs(path)
+	require.NoError(t, err)
+	return abs
+}
diff --git a/pkg/jsonnet/imports_test.go b/pkg/jsonnet/imports_test.go
index 7c3b5e73..7c7787d3 100644
--- a/pkg/jsonnet/imports_test.go
+++ b/pkg/jsonnet/imports_test.go
@@ -48,7 +48,7 @@ func BenchmarkGetSnippetHash(b *testing.B) {
 		b.Run(tc.name, func(b *testing.B) {
 			// Create a very large and complex project
 			tempDir := b.TempDir()
-			generateTestProject(b, tempDir, 10000, tc.importFromMain)
+			generateTestProject(b, tempDir, 1000, tc.importFromMain)
 
 			// Create a VM. It's important to reuse the same VM
 			// While there is a caching mechanism that normally shouldn't be shared in a benchmark iteration,
@@ -118,7 +118,7 @@ func generateTestProject(t testing.TB, dir string, depth int, importAllFromMain
 	var allFiles []string
 
 	var mainContentSplit []string
-	for i := 0; i < 1000; i++ {
+	for i := 0; i < depth; i++ {
 		mainContentSplit = append(mainContentSplit, fmt.Sprintf("(import 'file%d.libsonnet')", i))
 		filePath := filepath.Join(dir, fmt.Sprintf("file%d.libsonnet", i))
 		err := os.WriteFile(
@@ -134,8 +134,8 @@ func generateTestProject(t testing.TB, dir string, depth int, importAllFromMain
 	}
 	require.NoError(t, os.WriteFile(filepath.Join(dir, "main.jsonnet"), []byte(strings.Join(mainContentSplit, " + ")), 0644))
 	allFiles = append(allFiles, filepath.Join(dir, "main.jsonnet"))
-	require.NoError(t, os.WriteFile(filepath.Join(dir, "file1000.libsonnet"), []byte(`"a string"`), 0644))
-	allFiles = append(allFiles, filepath.Join(dir, "file1000.libsonnet"))
+	require.NoError(t, os.WriteFile(filepath.Join(dir, fmt.Sprintf("file%d.libsonnet", depth)), []byte(`"a string"`), 0644))
+	allFiles = append(allFiles, filepath.Join(dir, fmt.Sprintf("file%d.libsonnet", depth)))
 
 	return allFiles
 }
diff --git a/pkg/jsonnet/testdata/findImporters/environments/imports-lib-and-vendored-through-chain/chain1.libsonnet b/pkg/jsonnet/testdata/findImporters/environments/imports-lib-and-vendored-through-chain/chain1.libsonnet
new file mode 100644
index 00000000..5a030f0b
--- /dev/null
+++ b/pkg/jsonnet/testdata/findImporters/environments/imports-lib-and-vendored-through-chain/chain1.libsonnet
@@ -0,0 +1,3 @@
+{
+  chain: import 'chain2.libsonnet',
+}
diff --git a/pkg/jsonnet/testdata/findImporters/environments/imports-lib-and-vendored-through-chain/chain2.libsonnet b/pkg/jsonnet/testdata/findImporters/environments/imports-lib-and-vendored-through-chain/chain2.libsonnet
new file mode 100644
index 00000000..baf659c7
--- /dev/null
+++ b/pkg/jsonnet/testdata/findImporters/environments/imports-lib-and-vendored-through-chain/chain2.libsonnet
@@ -0,0 +1,3 @@
+{
+  chain: import 'lib1/main.libsonnet',
+}
diff --git a/pkg/jsonnet/testdata/findImporters/environments/imports-lib-and-vendored-through-chain/main.jsonnet b/pkg/jsonnet/testdata/findImporters/environments/imports-lib-and-vendored-through-chain/main.jsonnet
new file mode 100644
index 00000000..010b81ff
--- /dev/null
+++ b/pkg/jsonnet/testdata/findImporters/environments/imports-lib-and-vendored-through-chain/main.jsonnet
@@ -0,0 +1,4 @@
+{
+  chain: import 'chain1.libsonnet',
+
+}
diff --git a/pkg/jsonnet/testdata/findImporters/environments/imports-locals-and-vendored/local-file1.libsonnet b/pkg/jsonnet/testdata/findImporters/environments/imports-locals-and-vendored/local-file1.libsonnet
new file mode 100644
index 00000000..0967ef42
--- /dev/null
+++ b/pkg/jsonnet/testdata/findImporters/environments/imports-locals-and-vendored/local-file1.libsonnet
@@ -0,0 +1,1 @@
+{}
diff --git a/pkg/jsonnet/testdata/findImporters/environments/imports-locals-and-vendored/local-file2.libsonnet b/pkg/jsonnet/testdata/findImporters/environments/imports-locals-and-vendored/local-file2.libsonnet
new file mode 100644
index 00000000..0967ef42
--- /dev/null
+++ b/pkg/jsonnet/testdata/findImporters/environments/imports-locals-and-vendored/local-file2.libsonnet
@@ -0,0 +1,1 @@
+{}
diff --git a/pkg/jsonnet/testdata/findImporters/environments/imports-locals-and-vendored/main.jsonnet b/pkg/jsonnet/testdata/findImporters/environments/imports-locals-and-vendored/main.jsonnet
new file mode 100644
index 00000000..8faea36e
--- /dev/null
+++ b/pkg/jsonnet/testdata/findImporters/environments/imports-locals-and-vendored/main.jsonnet
@@ -0,0 +1,5 @@
+{
+  attr1: import 'local-file1.libsonnet',
+  attr2: import '././../imports-locals-and-vendored/./local-file2.libsonnet',
+  vendored1: import 'vendored/main.libsonnet',
+}
diff --git a/pkg/jsonnet/testdata/findImporters/environments/imports-symlinked-vendor/main.jsonnet b/pkg/jsonnet/testdata/findImporters/environments/imports-symlinked-vendor/main.jsonnet
new file mode 100644
index 00000000..da56ba9e
--- /dev/null
+++ b/pkg/jsonnet/testdata/findImporters/environments/imports-symlinked-vendor/main.jsonnet
@@ -0,0 +1,3 @@
+{
+  symlinked: import 'vendor-symlinked/main.libsonnet',
+}
diff --git a/pkg/jsonnet/testdata/findImporters/environments/no-imports/main.jsonnet b/pkg/jsonnet/testdata/findImporters/environments/no-imports/main.jsonnet
new file mode 100644
index 00000000..0967ef42
--- /dev/null
+++ b/pkg/jsonnet/testdata/findImporters/environments/no-imports/main.jsonnet
@@ -0,0 +1,1 @@
+{}
diff --git a/pkg/jsonnet/testdata/findImporters/lib/lib1/main.libsonnet b/pkg/jsonnet/testdata/findImporters/lib/lib1/main.libsonnet
new file mode 100644
index 00000000..635fab2c
--- /dev/null
+++ b/pkg/jsonnet/testdata/findImporters/lib/lib1/main.libsonnet
@@ -0,0 +1,3 @@
+{
+  vendored: import 'vendored/main.libsonnet',
+}
diff --git a/pkg/jsonnet/testdata/findImporters/lib/lib2/main.libsonnet b/pkg/jsonnet/testdata/findImporters/lib/lib2/main.libsonnet
new file mode 100644
index 00000000..0967ef42
--- /dev/null
+++ b/pkg/jsonnet/testdata/findImporters/lib/lib2/main.libsonnet
@@ -0,0 +1,1 @@
+{}
diff --git a/pkg/jsonnet/testdata/findImporters/lib/unimported-lib/main.libsonnet b/pkg/jsonnet/testdata/findImporters/lib/unimported-lib/main.libsonnet
new file mode 100644
index 00000000..0967ef42
--- /dev/null
+++ b/pkg/jsonnet/testdata/findImporters/lib/unimported-lib/main.libsonnet
@@ -0,0 +1,1 @@
+{}
diff --git a/pkg/jsonnet/testdata/findImporters/tkrc.yaml b/pkg/jsonnet/testdata/findImporters/tkrc.yaml
new file mode 100644
index 00000000..6cb4483b
--- /dev/null
+++ b/pkg/jsonnet/testdata/findImporters/tkrc.yaml
@@ -0,0 +1,1 @@
+# This is the root!
\ No newline at end of file
diff --git a/pkg/jsonnet/testdata/findImporters/vendor/unimported-vendor/main.libsonnet b/pkg/jsonnet/testdata/findImporters/vendor/unimported-vendor/main.libsonnet
new file mode 100644
index 00000000..0967ef42
--- /dev/null
+++ b/pkg/jsonnet/testdata/findImporters/vendor/unimported-vendor/main.libsonnet
@@ -0,0 +1,1 @@
+{}
diff --git a/pkg/jsonnet/testdata/findImporters/vendor/vendor-symlinked b/pkg/jsonnet/testdata/findImporters/vendor/vendor-symlinked
new file mode 120000
index 00000000..0bb55907
--- /dev/null
+++ b/pkg/jsonnet/testdata/findImporters/vendor/vendor-symlinked
@@ -0,0 +1,1 @@
+vendored
\ No newline at end of file
diff --git a/pkg/jsonnet/testdata/findImporters/vendor/vendored/main.libsonnet b/pkg/jsonnet/testdata/findImporters/vendor/vendored/main.libsonnet
new file mode 100644
index 00000000..fd71b56d
--- /dev/null
+++ b/pkg/jsonnet/testdata/findImporters/vendor/vendored/main.libsonnet
@@ -0,0 +1,3 @@
+{
+  text: importstr 'text-file.txt',
+}
diff --git a/pkg/jsonnet/testdata/findImporters/vendor/vendored/text-file.txt b/pkg/jsonnet/testdata/findImporters/vendor/vendored/text-file.txt
new file mode 100644
index 00000000..e87177c9
--- /dev/null
+++ b/pkg/jsonnet/testdata/findImporters/vendor/vendored/text-file.txt
@@ -0,0 +1,1 @@
+I am text
\ No newline at end of file