| // Copyright (c) HashiCorp, Inc. |
| // SPDX-License-Identifier: BUSL-1.1 |
| |
| package configs |
| |
| import ( |
| "fmt" |
| "os" |
| "path" |
| "path/filepath" |
| "strings" |
| |
| "github.com/hashicorp/hcl/v2" |
| ) |
| |
| const ( |
| DefaultTestDirectory = "tests" |
| ) |
| |
| // LoadConfigDir reads the .tf and .tf.json files in the given directory |
| // as config files (using LoadConfigFile) and then combines these files into |
| // a single Module. |
| // |
| // If this method returns nil, that indicates that the given directory does not |
| // exist at all or could not be opened for some reason. Callers may wish to |
| // detect this case and ignore the returned diagnostics so that they can |
| // produce a more context-aware error message in that case. |
| // |
| // If this method returns a non-nil module while error diagnostics are returned |
| // then the module may be incomplete but can be used carefully for static |
| // analysis. |
| // |
| // This file does not consider a directory with no files to be an error, and |
| // will simply return an empty module in that case. Callers should first call |
| // Parser.IsConfigDir if they wish to recognize that situation. |
| // |
| // .tf files are parsed using the HCL native syntax while .tf.json files are |
| // parsed using the HCL JSON syntax. |
| func (p *Parser) LoadConfigDir(path string) (*Module, hcl.Diagnostics) { |
| primaryPaths, overridePaths, _, diags := p.dirFiles(path, "") |
| if diags.HasErrors() { |
| return nil, diags |
| } |
| |
| primary, fDiags := p.loadFiles(primaryPaths, false) |
| diags = append(diags, fDiags...) |
| override, fDiags := p.loadFiles(overridePaths, true) |
| diags = append(diags, fDiags...) |
| |
| mod, modDiags := NewModule(primary, override) |
| diags = append(diags, modDiags...) |
| |
| mod.SourceDir = path |
| |
| return mod, diags |
| } |
| |
| // LoadConfigDirWithTests matches LoadConfigDir, but the return Module also |
| // contains any relevant .tftest.hcl files. |
| func (p *Parser) LoadConfigDirWithTests(path string, testDirectory string) (*Module, hcl.Diagnostics) { |
| primaryPaths, overridePaths, testPaths, diags := p.dirFiles(path, testDirectory) |
| if diags.HasErrors() { |
| return nil, diags |
| } |
| |
| primary, fDiags := p.loadFiles(primaryPaths, false) |
| diags = append(diags, fDiags...) |
| override, fDiags := p.loadFiles(overridePaths, true) |
| diags = append(diags, fDiags...) |
| tests, fDiags := p.loadTestFiles(path, testPaths) |
| diags = append(diags, fDiags...) |
| |
| mod, modDiags := NewModuleWithTests(primary, override, tests) |
| diags = append(diags, modDiags...) |
| |
| mod.SourceDir = path |
| |
| return mod, diags |
| } |
| |
| func (p *Parser) LoadMockDataDir(dir string, useForPlanDefault bool, source hcl.Range) (*MockData, hcl.Diagnostics) { |
| var diags hcl.Diagnostics |
| |
| infos, err := p.fs.ReadDir(dir) |
| if err != nil { |
| diags = append(diags, &hcl.Diagnostic{ |
| Severity: hcl.DiagError, |
| Summary: "Failed to read mock data directory", |
| Detail: fmt.Sprintf("Mock data directory %s does not exist or cannot be read.", dir), |
| Subject: source.Ptr(), |
| }) |
| return nil, diags |
| } |
| |
| var files []string |
| for _, info := range infos { |
| if info.IsDir() { |
| // We only care about terraform configuration files. |
| continue |
| } |
| |
| name := info.Name() |
| if !(strings.HasSuffix(name, ".tfmock.hcl") || strings.HasSuffix(name, ".tfmock.json")) { |
| continue |
| } |
| |
| if IsIgnoredFile(name) { |
| continue |
| } |
| |
| files = append(files, filepath.Join(dir, name)) |
| } |
| |
| var data *MockData |
| for _, file := range files { |
| current, currentDiags := p.LoadMockDataFile(file, useForPlanDefault) |
| diags = append(diags, currentDiags...) |
| if data != nil { |
| diags = append(diags, data.Merge(current, false)...) |
| continue |
| } |
| data = current |
| } |
| return data, diags |
| } |
| |
| // ConfigDirFiles returns lists of the primary and override files configuration |
| // files in the given directory. |
| // |
| // If the given directory does not exist or cannot be read, error diagnostics |
| // are returned. If errors are returned, the resulting lists may be incomplete. |
| func (p Parser) ConfigDirFiles(dir string) (primary, override []string, diags hcl.Diagnostics) { |
| primary, override, _, diags = p.dirFiles(dir, "") |
| return primary, override, diags |
| } |
| |
| // ConfigDirFilesWithTests matches ConfigDirFiles except it also returns the |
| // paths to any test files within the module. |
| func (p Parser) ConfigDirFilesWithTests(dir string, testDirectory string) (primary, override, tests []string, diags hcl.Diagnostics) { |
| return p.dirFiles(dir, testDirectory) |
| } |
| |
| // IsConfigDir determines whether the given path refers to a directory that |
| // exists and contains at least one Terraform config file (with a .tf or |
| // .tf.json extension.). Note, we explicitely exclude checking for tests here |
| // as tests must live alongside actual .tf config files. |
| func (p *Parser) IsConfigDir(path string) bool { |
| primaryPaths, overridePaths, _, _ := p.dirFiles(path, "") |
| return (len(primaryPaths) + len(overridePaths)) > 0 |
| } |
| |
| func (p *Parser) loadFiles(paths []string, override bool) ([]*File, hcl.Diagnostics) { |
| var files []*File |
| var diags hcl.Diagnostics |
| |
| for _, path := range paths { |
| var f *File |
| var fDiags hcl.Diagnostics |
| if override { |
| f, fDiags = p.LoadConfigFileOverride(path) |
| } else { |
| f, fDiags = p.LoadConfigFile(path) |
| } |
| diags = append(diags, fDiags...) |
| if f != nil { |
| files = append(files, f) |
| } |
| } |
| |
| return files, diags |
| } |
| |
| // dirFiles finds Terraform configuration files within dir, splitting them into |
| // primary and override files based on the filename. |
| // |
| // If testsDir is not empty, dirFiles will also retrieve Terraform testing files |
| // both directly within dir and within testsDir as a subdirectory of dir. In |
| // this way, testsDir acts both as a direction to retrieve test files within the |
| // main direction and as the location for additional test files. |
| func (p *Parser) dirFiles(dir string, testsDir string) (primary, override, tests []string, diags hcl.Diagnostics) { |
| includeTests := len(testsDir) > 0 |
| |
| if includeTests { |
| testPath := path.Join(dir, testsDir) |
| |
| infos, err := p.fs.ReadDir(testPath) |
| if err != nil { |
| // Then we couldn't read from the testing directory for some reason. |
| |
| if os.IsNotExist(err) { |
| // Then this means the testing directory did not exist. |
| // We won't actually stop loading the rest of the configuration |
| // for this, we will add a warning to explain to the user why |
| // test files weren't processed but leave it at that. |
| if testsDir != DefaultTestDirectory { |
| // We'll only add the warning if a directory other than the |
| // default has been requested. If the user is just loading |
| // the default directory then we have no expectation that |
| // it should actually exist. |
| diags = append(diags, &hcl.Diagnostic{ |
| Severity: hcl.DiagWarning, |
| Summary: "Test directory does not exist", |
| Detail: fmt.Sprintf("Requested test directory %s does not exist.", testPath), |
| }) |
| } |
| } else { |
| // Then there is some other reason we couldn't load. We will |
| // treat this as a full error. |
| diags = append(diags, &hcl.Diagnostic{ |
| Severity: hcl.DiagError, |
| Summary: "Failed to read test directory", |
| Detail: fmt.Sprintf("Test directory %s could not be read: %v.", testPath, err), |
| }) |
| |
| // We'll also stop loading the rest of the config for this. |
| return |
| } |
| |
| } else { |
| for _, testInfo := range infos { |
| if testInfo.IsDir() || IsIgnoredFile(testInfo.Name()) { |
| continue |
| } |
| |
| if strings.HasSuffix(testInfo.Name(), ".tftest.hcl") || strings.HasSuffix(testInfo.Name(), ".tftest.json") { |
| tests = append(tests, filepath.Join(testPath, testInfo.Name())) |
| } |
| } |
| } |
| |
| } |
| |
| infos, err := p.fs.ReadDir(dir) |
| if err != nil { |
| diags = append(diags, &hcl.Diagnostic{ |
| Severity: hcl.DiagError, |
| Summary: "Failed to read module directory", |
| Detail: fmt.Sprintf("Module directory %s does not exist or cannot be read.", dir), |
| }) |
| return |
| } |
| |
| for _, info := range infos { |
| if info.IsDir() { |
| // We only care about terraform configuration files. |
| continue |
| } |
| |
| name := info.Name() |
| ext := fileExt(name) |
| if ext == "" || IsIgnoredFile(name) { |
| continue |
| } |
| |
| if ext == ".tftest.hcl" || ext == ".tftest.json" { |
| if includeTests { |
| tests = append(tests, filepath.Join(dir, name)) |
| } |
| continue |
| } |
| |
| baseName := name[:len(name)-len(ext)] // strip extension |
| isOverride := baseName == "override" || strings.HasSuffix(baseName, "_override") |
| |
| fullPath := filepath.Join(dir, name) |
| if isOverride { |
| override = append(override, fullPath) |
| } else { |
| primary = append(primary, fullPath) |
| } |
| } |
| |
| return |
| } |
| |
| func (p *Parser) loadTestFiles(basePath string, paths []string) (map[string]*TestFile, hcl.Diagnostics) { |
| var diags hcl.Diagnostics |
| |
| tfs := make(map[string]*TestFile) |
| for _, path := range paths { |
| tf, fDiags := p.LoadTestFile(path) |
| diags = append(diags, fDiags...) |
| if tf != nil { |
| // We index test files relative to the module they are testing, so |
| // the key is the relative path between basePath and path. |
| relPath, err := filepath.Rel(basePath, path) |
| if err != nil { |
| diags = append(diags, &hcl.Diagnostic{ |
| Severity: hcl.DiagWarning, |
| Summary: "Failed to calculate relative path", |
| Detail: fmt.Sprintf("Terraform could not calculate the relative path for test file %s and it has been skipped: %s", path, err), |
| }) |
| continue |
| } |
| tfs[relPath] = tf |
| } |
| } |
| |
| return tfs, diags |
| } |
| |
| // fileExt returns the Terraform configuration extension of the given |
| // path, or a blank string if it is not a recognized extension. |
| func fileExt(path string) string { |
| if strings.HasSuffix(path, ".tf") { |
| return ".tf" |
| } else if strings.HasSuffix(path, ".tf.json") { |
| return ".tf.json" |
| } else if strings.HasSuffix(path, ".tftest.hcl") { |
| return ".tftest.hcl" |
| } else if strings.HasSuffix(path, ".tftest.json") { |
| return ".tftest.json" |
| } else { |
| return "" |
| } |
| } |
| |
| // IsIgnoredFile returns true if the given filename (which must not have a |
| // directory path ahead of it) should be ignored as e.g. an editor swap file. |
| func IsIgnoredFile(name string) bool { |
| return strings.HasPrefix(name, ".") || // Unix-like hidden files |
| strings.HasSuffix(name, "~") || // vim |
| strings.HasPrefix(name, "#") && strings.HasSuffix(name, "#") // emacs |
| } |
| |
| // IsEmptyDir returns true if the given filesystem path contains no Terraform |
| // configuration or test files. |
| // |
| // Unlike the methods of the Parser type, this function always consults the |
| // real filesystem, and thus it isn't appropriate to use when working with |
| // configuration loaded from a plan file. |
| func IsEmptyDir(path, testDir string) (bool, error) { |
| if _, err := os.Stat(path); err != nil && os.IsNotExist(err) { |
| return true, nil |
| } |
| |
| p := NewParser(nil) |
| fs, os, tests, diags := p.dirFiles(path, testDir) |
| if diags.HasErrors() { |
| return false, diags |
| } |
| |
| return len(fs) == 0 && len(os) == 0 && len(tests) == 0, nil |
| } |