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  }