package tfconfig import ( "fmt" "io/ioutil" "path/filepath" "strings" "github.com/hashicorp/hcl/v2" ) // LoadModule reads the directory at the given path and attempts to interpret // it as a Terraform module. func LoadModule(dir string) (*Module, Diagnostics) { // For broad compatibility here we actually have two separate loader // codepaths. The main one uses the new HCL parser and API and is intended // for configurations from Terraform 0.12 onwards (though will work for // many older configurations too), but we'll also fall back on one that // uses the _old_ HCL implementation so we can deal with some edge-cases // that are not valid in new HCL. module, diags := loadModule(dir) if diags.HasErrors() { // Try using the legacy HCL parser and see if we fare better. legacyModule, legacyDiags := loadModuleLegacyHCL(dir) if !legacyDiags.HasErrors() { legacyModule.init(legacyDiags) return legacyModule, legacyDiags } } module.init(diags) return module, diags } // IsModuleDir checks if the given path contains terraform configuration files. // This allows the caller to decide how to handle directories that do not have tf files. func IsModuleDir(dir string) bool { primaryPaths, _ := dirFiles(dir) if len(primaryPaths) == 0 { return false } return true } func (m *Module) init(diags Diagnostics) { // Fill in any additional provider requirements that are implied by // resource configurations, to avoid the caller from needing to apply // this logic itself. Implied requirements don't have version constraints, // but we'll make sure the requirement value is still non-nil in this // case so callers can easily recognize it. for _, r := range m.ManagedResources { if _, exists := m.RequiredProviders[r.Provider.Name]; !exists { m.RequiredProviders[r.Provider.Name] = &ProviderRequirement{} } } for _, r := range m.DataResources { if _, exists := m.RequiredProviders[r.Provider.Name]; !exists { m.RequiredProviders[r.Provider.Name] = &ProviderRequirement{} } } // We redundantly also reference the diagnostics from inside the module // object, primarily so that we can easily included in JSON-serialized // versions of the module object. m.Diagnostics = diags } func dirFiles(dir string) (primary []string, diags hcl.Diagnostics) { infos, err := ioutil.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 } var override []string for _, info := range infos { if info.IsDir() { // We only care about files continue } name := info.Name() ext := fileExt(name) if ext == "" || isIgnoredFile(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) } } // We are assuming that any _override files will be logically named, // and processing the files in alphabetical order. Primaries first, then overrides. primary = append(primary, override...) return } // 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 { 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 }