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