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 }