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  }