github.com/terramate-io/tf@v0.0.0-20230830114523-fce866b4dfcd/configs/parser_config_dir.go (about) 1 // Copyright (c) HashiCorp, Inc. 2 // SPDX-License-Identifier: MPL-2.0 3 4 package configs 5 6 import ( 7 "fmt" 8 "os" 9 "path" 10 "path/filepath" 11 "strings" 12 13 "github.com/hashicorp/hcl/v2" 14 ) 15 16 const ( 17 DefaultTestDirectory = "tests" 18 ) 19 20 // LoadConfigDir reads the .tf and .tf.json files in the given directory 21 // as config files (using LoadConfigFile) and then combines these files into 22 // a single Module. 23 // 24 // If this method returns nil, that indicates that the given directory does not 25 // exist at all or could not be opened for some reason. Callers may wish to 26 // detect this case and ignore the returned diagnostics so that they can 27 // produce a more context-aware error message in that case. 28 // 29 // If this method returns a non-nil module while error diagnostics are returned 30 // then the module may be incomplete but can be used carefully for static 31 // analysis. 32 // 33 // This file does not consider a directory with no files to be an error, and 34 // will simply return an empty module in that case. Callers should first call 35 // Parser.IsConfigDir if they wish to recognize that situation. 36 // 37 // .tf files are parsed using the HCL native syntax while .tf.json files are 38 // parsed using the HCL JSON syntax. 39 func (p *Parser) LoadConfigDir(path string) (*Module, hcl.Diagnostics) { 40 primaryPaths, overridePaths, _, diags := p.dirFiles(path, "") 41 if diags.HasErrors() { 42 return nil, diags 43 } 44 45 primary, fDiags := p.loadFiles(primaryPaths, false) 46 diags = append(diags, fDiags...) 47 override, fDiags := p.loadFiles(overridePaths, true) 48 diags = append(diags, fDiags...) 49 50 mod, modDiags := NewModule(primary, override) 51 diags = append(diags, modDiags...) 52 53 mod.SourceDir = path 54 55 return mod, diags 56 } 57 58 // LoadConfigDirWithTests matches LoadConfigDir, but the return Module also 59 // contains any relevant .tftest.hcl files. 60 func (p *Parser) LoadConfigDirWithTests(path string, testDirectory string) (*Module, hcl.Diagnostics) { 61 primaryPaths, overridePaths, testPaths, diags := p.dirFiles(path, testDirectory) 62 if diags.HasErrors() { 63 return nil, diags 64 } 65 66 primary, fDiags := p.loadFiles(primaryPaths, false) 67 diags = append(diags, fDiags...) 68 override, fDiags := p.loadFiles(overridePaths, true) 69 diags = append(diags, fDiags...) 70 tests, fDiags := p.loadTestFiles(path, testPaths) 71 diags = append(diags, fDiags...) 72 73 mod, modDiags := NewModuleWithTests(primary, override, tests) 74 diags = append(diags, modDiags...) 75 76 mod.SourceDir = path 77 78 return mod, diags 79 } 80 81 // ConfigDirFiles returns lists of the primary and override files configuration 82 // files in the given directory. 83 // 84 // If the given directory does not exist or cannot be read, error diagnostics 85 // are returned. If errors are returned, the resulting lists may be incomplete. 86 func (p Parser) ConfigDirFiles(dir string) (primary, override []string, diags hcl.Diagnostics) { 87 primary, override, _, diags = p.dirFiles(dir, "") 88 return primary, override, diags 89 } 90 91 // ConfigDirFilesWithTests matches ConfigDirFiles except it also returns the 92 // paths to any test files within the module. 93 func (p Parser) ConfigDirFilesWithTests(dir string, testDirectory string) (primary, override, tests []string, diags hcl.Diagnostics) { 94 return p.dirFiles(dir, testDirectory) 95 } 96 97 // IsConfigDir determines whether the given path refers to a directory that 98 // exists and contains at least one Terraform config file (with a .tf or 99 // .tf.json extension.). Note, we explicitely exclude checking for tests here 100 // as tests must live alongside actual .tf config files. 101 func (p *Parser) IsConfigDir(path string) bool { 102 primaryPaths, overridePaths, _, _ := p.dirFiles(path, "") 103 return (len(primaryPaths) + len(overridePaths)) > 0 104 } 105 106 func (p *Parser) loadFiles(paths []string, override bool) ([]*File, hcl.Diagnostics) { 107 var files []*File 108 var diags hcl.Diagnostics 109 110 for _, path := range paths { 111 var f *File 112 var fDiags hcl.Diagnostics 113 if override { 114 f, fDiags = p.LoadConfigFileOverride(path) 115 } else { 116 f, fDiags = p.LoadConfigFile(path) 117 } 118 diags = append(diags, fDiags...) 119 if f != nil { 120 files = append(files, f) 121 } 122 } 123 124 return files, diags 125 } 126 127 // dirFiles finds Terraform configuration files within dir, splitting them into 128 // primary and override files based on the filename. 129 // 130 // If testsDir is not empty, dirFiles will also retrieve Terraform testing files 131 // both directly within dir and within testsDir as a subdirectory of dir. In 132 // this way, testsDir acts both as a direction to retrieve test files within the 133 // main direction and as the location for additional test files. 134 func (p *Parser) dirFiles(dir string, testsDir string) (primary, override, tests []string, diags hcl.Diagnostics) { 135 includeTests := len(testsDir) > 0 136 137 if includeTests { 138 testPath := path.Join(dir, testsDir) 139 140 infos, err := p.fs.ReadDir(testPath) 141 if err != nil { 142 // Then we couldn't read from the testing directory for some reason. 143 144 if os.IsNotExist(err) { 145 // Then this means the testing directory did not exist. 146 // We won't actually stop loading the rest of the configuration 147 // for this, we will add a warning to explain to the user why 148 // test files weren't processed but leave it at that. 149 if testsDir != DefaultTestDirectory { 150 // We'll only add the warning if a directory other than the 151 // default has been requested. If the user is just loading 152 // the default directory then we have no expectation that 153 // it should actually exist. 154 diags = append(diags, &hcl.Diagnostic{ 155 Severity: hcl.DiagWarning, 156 Summary: "Test directory does not exist", 157 Detail: fmt.Sprintf("Requested test directory %s does not exist.", testPath), 158 }) 159 } 160 } else { 161 // Then there is some other reason we couldn't load. We will 162 // treat this as a full error. 163 diags = append(diags, &hcl.Diagnostic{ 164 Severity: hcl.DiagError, 165 Summary: "Failed to read test directory", 166 Detail: fmt.Sprintf("Test directory %s could not be read: %v.", testPath, err), 167 }) 168 169 // We'll also stop loading the rest of the config for this. 170 return 171 } 172 173 } else { 174 for _, testInfo := range infos { 175 if testInfo.IsDir() || IsIgnoredFile(testInfo.Name()) { 176 continue 177 } 178 179 if strings.HasSuffix(testInfo.Name(), ".tftest.hcl") || strings.HasSuffix(testInfo.Name(), ".tftest.json") { 180 tests = append(tests, filepath.Join(testPath, testInfo.Name())) 181 } 182 } 183 } 184 185 } 186 187 infos, err := p.fs.ReadDir(dir) 188 if err != nil { 189 diags = append(diags, &hcl.Diagnostic{ 190 Severity: hcl.DiagError, 191 Summary: "Failed to read module directory", 192 Detail: fmt.Sprintf("Module directory %s does not exist or cannot be read.", dir), 193 }) 194 return 195 } 196 197 for _, info := range infos { 198 if info.IsDir() { 199 // We only care about terraform configuration files. 200 continue 201 } 202 203 name := info.Name() 204 ext := fileExt(name) 205 if ext == "" || IsIgnoredFile(name) { 206 continue 207 } 208 209 if ext == ".tftest.hcl" || ext == ".tftest.json" { 210 if includeTests { 211 tests = append(tests, filepath.Join(dir, name)) 212 } 213 continue 214 } 215 216 baseName := name[:len(name)-len(ext)] // strip extension 217 isOverride := baseName == "override" || strings.HasSuffix(baseName, "_override") 218 219 fullPath := filepath.Join(dir, name) 220 if isOverride { 221 override = append(override, fullPath) 222 } else { 223 primary = append(primary, fullPath) 224 } 225 } 226 227 return 228 } 229 230 func (p *Parser) loadTestFiles(basePath string, paths []string) (map[string]*TestFile, hcl.Diagnostics) { 231 var diags hcl.Diagnostics 232 233 tfs := make(map[string]*TestFile) 234 for _, path := range paths { 235 tf, fDiags := p.LoadTestFile(path) 236 diags = append(diags, fDiags...) 237 if tf != nil { 238 // We index test files relative to the module they are testing, so 239 // the key is the relative path between basePath and path. 240 relPath, err := filepath.Rel(basePath, path) 241 if err != nil { 242 diags = append(diags, &hcl.Diagnostic{ 243 Severity: hcl.DiagWarning, 244 Summary: "Failed to calculate relative path", 245 Detail: fmt.Sprintf("Terraform could not calculate the relative path for test file %s and it has been skipped: %s", path, err), 246 }) 247 continue 248 } 249 tfs[relPath] = tf 250 } 251 } 252 253 return tfs, diags 254 } 255 256 // fileExt returns the Terraform configuration extension of the given 257 // path, or a blank string if it is not a recognized extension. 258 func fileExt(path string) string { 259 if strings.HasSuffix(path, ".tf") { 260 return ".tf" 261 } else if strings.HasSuffix(path, ".tf.json") { 262 return ".tf.json" 263 } else if strings.HasSuffix(path, ".tftest.hcl") { 264 return ".tftest.hcl" 265 } else if strings.HasSuffix(path, ".tftest.json") { 266 return ".tftest.json" 267 } else { 268 return "" 269 } 270 } 271 272 // IsIgnoredFile returns true if the given filename (which must not have a 273 // directory path ahead of it) should be ignored as e.g. an editor swap file. 274 func IsIgnoredFile(name string) bool { 275 return strings.HasPrefix(name, ".") || // Unix-like hidden files 276 strings.HasSuffix(name, "~") || // vim 277 strings.HasPrefix(name, "#") && strings.HasSuffix(name, "#") // emacs 278 } 279 280 // IsEmptyDir returns true if the given filesystem path contains no Terraform 281 // configuration files. 282 // 283 // Unlike the methods of the Parser type, this function always consults the 284 // real filesystem, and thus it isn't appropriate to use when working with 285 // configuration loaded from a plan file. 286 func IsEmptyDir(path string) (bool, error) { 287 if _, err := os.Stat(path); err != nil && os.IsNotExist(err) { 288 return true, nil 289 } 290 291 p := NewParser(nil) 292 fs, os, _, diags := p.dirFiles(path, "") 293 if diags.HasErrors() { 294 return false, diags 295 } 296 297 return len(fs) == 0 && len(os) == 0, nil 298 }