github.com/grafana/tanka@v0.26.1-0.20240506093700-c22cfc35c21a/pkg/jsonnet/find_importers.go (about)

     1  package jsonnet
     2  
     3  import (
     4  	"fmt"
     5  	"os"
     6  	"path/filepath"
     7  	"sort"
     8  	"strings"
     9  
    10  	"github.com/grafana/tanka/pkg/jsonnet/jpath"
    11  )
    12  
    13  var (
    14  	importersCache    = make(map[string][]string)
    15  	jsonnetFilesCache = make(map[string]map[string]*cachedJsonnetFile)
    16  	symlinkCache      = make(map[string]string)
    17  )
    18  
    19  type cachedJsonnetFile struct {
    20  	Base       string
    21  	Imports    []string
    22  	Content    string
    23  	IsMainFile bool
    24  }
    25  
    26  // FindImporterForFiles finds the entrypoints (main.jsonnet files) that import the given files.
    27  // It looks through imports transitively, so if a file is imported through a chain, it will still be reported.
    28  // If the given file is a main.jsonnet file, it will be returned as well.
    29  func FindImporterForFiles(root string, files []string) ([]string, error) {
    30  	var err error
    31  	root, err = filepath.Abs(root)
    32  	if err != nil {
    33  		return nil, err
    34  	}
    35  
    36  	importers := map[string]struct{}{}
    37  
    38  	// Handle files prefixed with `deleted:`. They need to be made absolute and we shouldn't try to find symlinks for them
    39  	var filesToCheck, existingFiles []string
    40  	for _, file := range files {
    41  		if strings.HasPrefix(file, "deleted:") {
    42  			deletedFile := strings.TrimPrefix(file, "deleted:")
    43  			// Try with both the absolute path and the path relative to the root
    44  			if !filepath.IsAbs(deletedFile) {
    45  				absFilePath, err := filepath.Abs(deletedFile)
    46  				if err != nil {
    47  					return nil, err
    48  				}
    49  				filesToCheck = append(filesToCheck, absFilePath)
    50  				filesToCheck = append(filesToCheck, filepath.Clean(filepath.Join(root, deletedFile)))
    51  			}
    52  			continue
    53  		}
    54  
    55  		existingFiles = append(existingFiles, file)
    56  	}
    57  
    58  	if existingFiles, err = expandSymlinksInFiles(root, existingFiles); err != nil {
    59  		return nil, err
    60  	}
    61  	filesToCheck = append(filesToCheck, existingFiles...)
    62  
    63  	// Loop through all given files and add their importers to the list
    64  	for _, file := range filesToCheck {
    65  		if filepath.Base(file) == jpath.DefaultEntrypoint {
    66  			importers[file] = struct{}{}
    67  		}
    68  
    69  		newImporters, err := findImporters(root, file, map[string]struct{}{})
    70  		if err != nil {
    71  			return nil, err
    72  		}
    73  		for _, importer := range newImporters {
    74  			importer, err = evalSymlinks(importer)
    75  			if err != nil {
    76  				return nil, err
    77  			}
    78  			importers[importer] = struct{}{}
    79  		}
    80  	}
    81  
    82  	return mapToArray(importers), nil
    83  }
    84  
    85  // expandSymlinksInFiles takes an array of files and adds to it:
    86  // - all symlinks that point to the files
    87  // - all files that are pointed to by the symlinks
    88  func expandSymlinksInFiles(root string, files []string) ([]string, error) {
    89  	filesMap := map[string]struct{}{}
    90  
    91  	for _, file := range files {
    92  		file, err := filepath.Abs(file)
    93  		if err != nil {
    94  			return nil, err
    95  		}
    96  		filesMap[file] = struct{}{}
    97  
    98  		symlink, err := evalSymlinks(file)
    99  		if err != nil {
   100  			return nil, err
   101  		}
   102  		if symlink != file {
   103  			filesMap[symlink] = struct{}{}
   104  		}
   105  
   106  		symlinks, err := findSymlinks(root, file)
   107  		if err != nil {
   108  			return nil, err
   109  		}
   110  		for _, symlink := range symlinks {
   111  			filesMap[symlink] = struct{}{}
   112  		}
   113  	}
   114  
   115  	return mapToArray(filesMap), nil
   116  }
   117  
   118  // evalSymlinks returns the path after following all symlinks.
   119  // It caches the results to avoid unnecessary work.
   120  func evalSymlinks(path string) (string, error) {
   121  	var err error
   122  	eval, ok := symlinkCache[path]
   123  	if !ok {
   124  		eval, err = filepath.EvalSymlinks(path)
   125  		if err != nil {
   126  			return "", err
   127  		}
   128  		symlinkCache[path] = eval
   129  	}
   130  	return eval, nil
   131  }
   132  
   133  // findSymlinks finds all symlinks that point to the given file.
   134  // It's restricted to the given root directory.
   135  // It's used in the case where a user wants to find which entrypoints import a given file.
   136  // In that case, we also want to find the entrypoints that import a symlink to the file.
   137  func findSymlinks(root, file string) ([]string, error) {
   138  	var symlinks []string
   139  
   140  	err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
   141  		if err != nil {
   142  			return err
   143  		}
   144  
   145  		if info.Mode()&os.ModeSymlink == os.ModeSymlink {
   146  			eval, err := evalSymlinks(path)
   147  			if err != nil {
   148  				return err
   149  			}
   150  			if strings.Contains(file, eval) {
   151  				symlinks = append(symlinks, strings.Replace(file, eval, path, 1))
   152  			}
   153  		}
   154  
   155  		return nil
   156  	})
   157  
   158  	return symlinks, err
   159  }
   160  
   161  func findImporters(root string, searchForFile string, chain map[string]struct{}) ([]string, error) {
   162  	// If we've already looked through this file in the current execution, don't do it again and return an empty list to end the recursion
   163  	// Jsonnet supports cyclic imports (as long as the _attributes_ being used are not cyclic)
   164  	if _, ok := chain[searchForFile]; ok {
   165  		return nil, nil
   166  	}
   167  	chain[searchForFile] = struct{}{}
   168  
   169  	// If we've already computed the importers for a file, return the cached result
   170  	key := root + ":" + searchForFile
   171  	if importers, ok := importersCache[key]; ok {
   172  		return importers, nil
   173  	}
   174  
   175  	jsonnetFiles, err := createJsonnetFileCache(root)
   176  	if err != nil {
   177  		return nil, err
   178  	}
   179  
   180  	var importers []string
   181  	var intermediateImporters []string
   182  
   183  	// If the file is not a vendored or a lib file, we assume:
   184  	// - it is used in a Tanka environment
   185  	// - it will not be imported by any lib or vendor files
   186  	// - the environment base (closest main file in parent dirs) will be considered an importer
   187  	// - if no base is found, all main files in child dirs will be considered importers
   188  	rootVendor := filepath.Join(root, "vendor")
   189  	rootLib := filepath.Join(root, "lib")
   190  	isFileLibOrVendored := func(file string) bool {
   191  		return strings.HasPrefix(file, rootVendor) || strings.HasPrefix(file, rootLib)
   192  	}
   193  	searchedFileIsLibOrVendored := isFileLibOrVendored(searchForFile)
   194  	if !searchedFileIsLibOrVendored {
   195  		searchedDir := filepath.Dir(searchForFile)
   196  		if entrypoint := findEntrypoint(searchedDir); entrypoint != "" {
   197  			// Found the main file for the searched file, add it as an importer
   198  			importers = append(importers, entrypoint)
   199  		} else if _, err := os.Stat(searchedDir); err == nil {
   200  			// No main file found, add all main files in child dirs as importers
   201  			files, err := FindFiles(searchedDir, nil)
   202  			if err != nil {
   203  				return nil, fmt.Errorf("failed to find files in %s: %w", searchedDir, err)
   204  			}
   205  			for _, file := range files {
   206  				if filepath.Base(file) == jpath.DefaultEntrypoint {
   207  					importers = append(importers, file)
   208  				}
   209  			}
   210  		}
   211  	}
   212  
   213  	for jsonnetFilePath, jsonnetFileContent := range jsonnetFiles {
   214  		if len(jsonnetFileContent.Imports) == 0 {
   215  			continue
   216  		}
   217  
   218  		if !searchedFileIsLibOrVendored && isFileLibOrVendored(jsonnetFilePath) {
   219  			// Skip the file if it's a vendored or lib file and the searched file is an environment file
   220  			// Libs and vendored files cannot import environment files
   221  			continue
   222  		}
   223  
   224  		isImporter := false
   225  		// For all imports in all jsonnet files, check if they import the file we're looking for
   226  		for _, importPath := range jsonnetFileContent.Imports {
   227  			// If the filename is not the same as the file we are looking for, skip it
   228  			if filepath.Base(importPath) != filepath.Base(searchForFile) {
   229  				continue
   230  			}
   231  
   232  			// Remove any `./` or `../` that can be removed just by looking at the given path
   233  			// ex: `./foo/bar.jsonnet` -> `foo/bar.jsonnet` or `/foo/../bar.jsonnet` -> `/bar.jsonnet`
   234  			importPath = filepath.Clean(importPath)
   235  
   236  			// Match on relative imports with ..
   237  			// Jsonnet also matches relative imports that are one level deeper than they should be
   238  			// Example: Given two envs (env1 and env2), the two following imports in `env1/main.jsonnet will work`: `../env2/main.jsonnet` and `../../env2/main.jsonnet`
   239  			// This can lead to false positives, but ruling them out would require much more complex logic
   240  			if strings.HasPrefix(importPath, "..") {
   241  				shallowImport := filepath.Clean(filepath.Join(filepath.Dir(jsonnetFilePath), strings.Replace(importPath, "../", "", 1)))
   242  				importPath = filepath.Clean(filepath.Join(filepath.Dir(jsonnetFilePath), importPath))
   243  
   244  				isImporter = pathMatches(searchForFile, importPath) || pathMatches(searchForFile, shallowImport)
   245  			}
   246  
   247  			// Match on imports to lib/ or vendor/
   248  			if !isImporter {
   249  				isImporter = pathMatches(searchForFile, filepath.Join(root, "vendor", importPath)) || pathMatches(searchForFile, filepath.Join(root, "lib", importPath))
   250  			}
   251  
   252  			// Match on imports to the base dir where the file is located (e.g. in the env dir)
   253  			if !isImporter {
   254  				if jsonnetFileContent.Base == "" {
   255  					base, err := jpath.FindBase(jsonnetFilePath, root)
   256  					if err != nil {
   257  						return nil, err
   258  					}
   259  					jsonnetFileContent.Base = base
   260  				}
   261  				isImporter = strings.HasPrefix(searchForFile, jsonnetFileContent.Base) && strings.HasSuffix(searchForFile, importPath)
   262  			}
   263  
   264  			// If the file we're looking in imports one of the files we're looking for, add it to the list
   265  			// It can either be an importer that we want to return (from a main file) or an intermediate importer
   266  			if isImporter {
   267  				if jsonnetFileContent.IsMainFile {
   268  					importers = append(importers, jsonnetFilePath)
   269  				}
   270  				intermediateImporters = append(intermediateImporters, jsonnetFilePath)
   271  				break
   272  			}
   273  		}
   274  	}
   275  
   276  	// Process intermediate importers recursively
   277  	// This will go on until we hit a main file, which will be returned
   278  	if len(intermediateImporters) > 0 {
   279  		for _, intermediateImporter := range intermediateImporters {
   280  			newImporters, err := findImporters(root, intermediateImporter, chain)
   281  			if err != nil {
   282  				return nil, err
   283  			}
   284  			importers = append(importers, newImporters...)
   285  		}
   286  	}
   287  
   288  	// If we've found a vendored file, check that it's not overridden by a vendored file in the environment root
   289  	// In that case, we only want to keep the environment vendored file
   290  	var filteredImporters []string
   291  	if strings.HasPrefix(searchForFile, rootVendor) {
   292  		for _, importer := range importers {
   293  			relativePath, err := filepath.Rel(rootVendor, searchForFile)
   294  			if err != nil {
   295  				return nil, err
   296  			}
   297  			vendoredFileInEnvironment := filepath.Join(filepath.Dir(importer), "vendor", relativePath)
   298  			if _, ok := jsonnetFilesCache[root][vendoredFileInEnvironment]; !ok {
   299  				filteredImporters = append(filteredImporters, importer)
   300  			}
   301  		}
   302  	} else {
   303  		filteredImporters = importers
   304  	}
   305  
   306  	importersCache[key] = filteredImporters
   307  	return filteredImporters, nil
   308  }
   309  
   310  func createJsonnetFileCache(root string) (map[string]*cachedJsonnetFile, error) {
   311  	if val, ok := jsonnetFilesCache[root]; ok {
   312  		return val, nil
   313  	}
   314  	jsonnetFilesCache[root] = make(map[string]*cachedJsonnetFile)
   315  
   316  	files, err := FindFiles(root, nil)
   317  	if err != nil {
   318  		return nil, err
   319  	}
   320  	for _, file := range files {
   321  		content, err := os.ReadFile(file)
   322  		if err != nil {
   323  			return nil, err
   324  		}
   325  		matches := importsRegexp.FindAllStringSubmatch(string(content), -1)
   326  
   327  		cachedObj := &cachedJsonnetFile{
   328  			Content:    string(content),
   329  			IsMainFile: strings.HasSuffix(file, jpath.DefaultEntrypoint),
   330  		}
   331  		for _, match := range matches {
   332  			cachedObj.Imports = append(cachedObj.Imports, match[2])
   333  		}
   334  		jsonnetFilesCache[root][file] = cachedObj
   335  	}
   336  
   337  	return jsonnetFilesCache[root], nil
   338  }
   339  
   340  // findEntrypoint finds the nearest main.jsonnet file in the given file's directory or parent directories
   341  func findEntrypoint(searchedDir string) string {
   342  	for {
   343  		if _, err := os.Stat(searchedDir); err == nil {
   344  			break
   345  		}
   346  		searchedDir = filepath.Dir(searchedDir)
   347  	}
   348  	searchedFileEntrypoint, err := jpath.Entrypoint(searchedDir)
   349  	if err != nil {
   350  		return ""
   351  	}
   352  	return searchedFileEntrypoint
   353  }
   354  
   355  func pathMatches(path1, path2 string) bool {
   356  	if path1 == path2 {
   357  		return true
   358  	}
   359  
   360  	var err error
   361  
   362  	evalPath1, err := evalSymlinks(path1)
   363  	if err != nil {
   364  		return false
   365  	}
   366  
   367  	evalPath2, err := evalSymlinks(path2)
   368  	if err != nil {
   369  		return false
   370  	}
   371  
   372  	return evalPath1 == evalPath2
   373  }
   374  
   375  func mapToArray(m map[string]struct{}) []string {
   376  	var arr []string
   377  	for k := range m {
   378  		arr = append(arr, k)
   379  	}
   380  	sort.Strings(arr)
   381  	return arr
   382  }