mirror of
https://github.com/hashicorp/terraform.git
synced 2026-03-22 02:20:07 -04:00
268 lines
8.9 KiB
Go
268 lines
8.9 KiB
Go
// Copyright IBM Corp. 2014, 2026
|
|
// SPDX-License-Identifier: BUSL-1.1
|
|
|
|
package configs
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/hashicorp/go-slug/sourceaddrs"
|
|
"github.com/hashicorp/go-slug/sourcebundle"
|
|
"github.com/hashicorp/hcl/v2"
|
|
"github.com/hashicorp/hcl/v2/hclparse"
|
|
)
|
|
|
|
// SourceBundleParser is the main interface to read configuration files and
|
|
// other related files from a source bundle. This is a subset of the
|
|
// functionality implemented by [Parser], specifically ignoring tftest files,
|
|
// which are not relevant for now.
|
|
type SourceBundleParser struct {
|
|
sources *sourcebundle.Bundle
|
|
p *hclparse.Parser
|
|
|
|
// allowExperiments controls whether we will allow modules to opt in to
|
|
// experimental language features. In main code this will be set only
|
|
// for alpha releases and some development builds. Test code must decide
|
|
// for itself whether to enable it so that tests can cover both the
|
|
// allowed and not-allowed situations.
|
|
allowExperiments bool
|
|
}
|
|
|
|
// NewSourceBundleParser creates a new [SourceBundleParser] for the given
|
|
// source bundle.
|
|
func NewSourceBundleParser(sources *sourcebundle.Bundle) *SourceBundleParser {
|
|
return &SourceBundleParser{
|
|
sources: sources,
|
|
p: hclparse.NewParser(),
|
|
}
|
|
}
|
|
|
|
// LoadConfigDir is the primary public entry point for [SourceBundleParser],
|
|
// and is similar to [Parser.LoadConfigDir]. It reads the .tf and .tf.json
|
|
// files at the given source address as config files, and combines these into a
|
|
// single [Module].
|
|
func (p *SourceBundleParser) LoadConfigDir(source sourceaddrs.FinalSource) (*Module, hcl.Diagnostics) {
|
|
primarySources, overrideSources, diags := p.dirSources(source)
|
|
if diags.HasErrors() {
|
|
return nil, diags
|
|
}
|
|
|
|
primary, fDiags := p.loadSources(primarySources, false)
|
|
diags = append(diags, fDiags...)
|
|
override, fDiags := p.loadSources(overrideSources, true)
|
|
diags = append(diags, fDiags...)
|
|
|
|
mod, modDiags := NewModule(primary, override)
|
|
diags = append(diags, modDiags...)
|
|
|
|
sourceDir, err := p.sources.LocalPathForSource(source)
|
|
if err != nil {
|
|
diags = diags.Append(&hcl.Diagnostic{
|
|
Severity: hcl.DiagError,
|
|
Summary: "Cannot find configuration source code",
|
|
Detail: fmt.Sprintf("Failed to load %s from the pre-installed source packages: %s. This is a bug in Terraform - please report it.", source, err),
|
|
})
|
|
return nil, diags
|
|
}
|
|
|
|
// The result of sources.LocalPathForSource can be an absolute path, but we
|
|
// don't actually want to pass an absolute path for a module's SourceDir;
|
|
// doing so will cause the value of `path.module` in Terraform configs to
|
|
// differ across plans and applies, since tfc-agent performs plans and
|
|
// applies in temporary directories. Instead, we try to resolve a relative
|
|
// path from Terraform's working directory, which should always be a
|
|
// reasonable SourceDir value.
|
|
var relativeSourceDir string
|
|
if filepath.IsAbs(sourceDir) {
|
|
workDir, err := os.Getwd()
|
|
if err != nil {
|
|
diags = diags.Append(&hcl.Diagnostic{
|
|
Severity: hcl.DiagError,
|
|
Summary: "Cannot resolve working directory",
|
|
Detail: fmt.Sprintf("Failed to resolve current working directory: %s. This is a bug in Terraform - please report it.", err),
|
|
})
|
|
}
|
|
|
|
relativeSourceDir, err = filepath.Rel(workDir, sourceDir)
|
|
if err != nil {
|
|
diags = diags.Append(&hcl.Diagnostic{
|
|
Severity: hcl.DiagError,
|
|
Summary: "Cannot resolve relative path",
|
|
Detail: fmt.Sprintf("Failed to resolve relative path to module directory: %s. This is a bug in Terraform - please report it.", err),
|
|
})
|
|
}
|
|
} else {
|
|
// sourceDir is already relative, use it as-is
|
|
relativeSourceDir = sourceDir
|
|
}
|
|
mod.SourceDir = relativeSourceDir
|
|
|
|
return mod, diags
|
|
}
|
|
|
|
// IsConfigDir is used to detect directories which have no config files, so
|
|
// that we can return useful early diagnostics when a given root module source
|
|
// address points at a directory which is not Terraform module.
|
|
func (p *SourceBundleParser) IsConfigDir(source sourceaddrs.FinalSource) bool {
|
|
primaryPaths, overridePaths, _ := p.dirSources(source)
|
|
return (len(primaryPaths) + len(overridePaths)) > 0
|
|
}
|
|
|
|
// Bundle returns the source bundle that this parser is reading from.
|
|
func (p *SourceBundleParser) Bundle() *sourcebundle.Bundle {
|
|
return p.sources
|
|
}
|
|
|
|
func (p *SourceBundleParser) dirSources(source sourceaddrs.FinalSource) (primary, override []sourceaddrs.FinalSource, diags hcl.Diagnostics) {
|
|
localDir, err := p.sources.LocalPathForSource(source)
|
|
if err != nil {
|
|
diags = diags.Append(&hcl.Diagnostic{
|
|
Severity: hcl.DiagError,
|
|
Summary: "Cannot find configuration source code",
|
|
Detail: fmt.Sprintf("Failed to load %s from the pre-installed source packages: %s.", source, err),
|
|
})
|
|
return
|
|
}
|
|
|
|
allEntries, err := os.ReadDir(localDir)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
diags = diags.Append(&hcl.Diagnostic{
|
|
Severity: hcl.DiagError,
|
|
Summary: "Missing Terraform configuration",
|
|
Detail: fmt.Sprintf("There is no Terraform configuration directory at %s.", source),
|
|
})
|
|
} else {
|
|
diags = diags.Append(&hcl.Diagnostic{
|
|
Severity: hcl.DiagError,
|
|
Summary: "Cannot read Terraform configuration",
|
|
// In this case the error message from the Go standard library
|
|
// is likely to disclose the real local directory name
|
|
// from the source bundle, but that's okay because it may
|
|
// sometimes help with debugging.
|
|
Detail: fmt.Sprintf("Error while reading the cached snapshot of %s: %s.", source, err),
|
|
})
|
|
}
|
|
return
|
|
}
|
|
|
|
for _, entry := range allEntries {
|
|
if entry.IsDir() {
|
|
continue
|
|
}
|
|
|
|
name := entry.Name()
|
|
ext := fileExt(name)
|
|
if ext == "" || IsIgnoredFile(name) {
|
|
continue
|
|
}
|
|
|
|
if ext == ".tftest.hcl" || ext == ".tftest.json" {
|
|
continue
|
|
}
|
|
|
|
baseName := name[:len(name)-len(ext)] // strip extension
|
|
isOverride := baseName == "override" || strings.HasSuffix(baseName, "_override")
|
|
|
|
asLocalSourcePath := "./" + filepath.Base(name)
|
|
relSource, err := sourceaddrs.ParseLocalSource(asLocalSourcePath)
|
|
if err != nil {
|
|
// If we get here then it's a bug in how we constructed the
|
|
// path above, not invalid user input.
|
|
panic(fmt.Sprintf("constructed invalid relative source path: %s", err))
|
|
}
|
|
fileSourceAddr, err := sourceaddrs.ResolveRelativeFinalSource(source, relSource)
|
|
if err != nil {
|
|
// If we get here then it's a bug in how we constructed the
|
|
// path above, not invalid user input.
|
|
panic(fmt.Sprintf("constructed invalid relative source path: %s", err))
|
|
}
|
|
|
|
if isOverride {
|
|
override = append(override, fileSourceAddr)
|
|
} else {
|
|
primary = append(primary, fileSourceAddr)
|
|
}
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
func (p *SourceBundleParser) loadSources(sources []sourceaddrs.FinalSource, override bool) ([]*File, hcl.Diagnostics) {
|
|
var files []*File
|
|
var diags hcl.Diagnostics
|
|
|
|
for _, path := range sources {
|
|
f, fDiags := p.loadConfigFile(path, override)
|
|
diags = append(diags, fDiags...)
|
|
if f != nil {
|
|
files = append(files, f)
|
|
}
|
|
}
|
|
|
|
return files, diags
|
|
}
|
|
|
|
func (p *SourceBundleParser) loadConfigFile(source sourceaddrs.FinalSource, override bool) (*File, hcl.Diagnostics) {
|
|
var diags hcl.Diagnostics
|
|
path, err := p.sources.LocalPathForSource(source)
|
|
if err != nil {
|
|
diags = diags.Append(&hcl.Diagnostic{
|
|
Severity: hcl.DiagError,
|
|
Summary: "Cannot find configuration source code",
|
|
Detail: fmt.Sprintf("Failed to load %s from the pre-installed source packages: %s.", source, err),
|
|
})
|
|
return nil, diags
|
|
}
|
|
|
|
src, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, hcl.Diagnostics{
|
|
{
|
|
Severity: hcl.DiagError,
|
|
Summary: "Failed to read file",
|
|
Detail: fmt.Sprintf("The file %q could not be read.", path),
|
|
},
|
|
}
|
|
}
|
|
|
|
// NOTE: this synthetic filename is intentionally a string rendering of the
|
|
// file's source address, which in many cases is _not_ a path name. We use
|
|
// the full source address in order to allow later consumers of diagnostics
|
|
// to look up the configuration file from the source bundle. We use this in
|
|
// the filename field of the diagnostic source to achieve this.
|
|
syntheticFilename := source.String()
|
|
|
|
var file *hcl.File
|
|
var fdiags hcl.Diagnostics
|
|
switch {
|
|
case strings.HasSuffix(path, ".json"):
|
|
file, fdiags = p.p.ParseJSON(src, syntheticFilename)
|
|
default:
|
|
file, fdiags = p.p.ParseHCL(src, syntheticFilename)
|
|
}
|
|
diags = append(diags, fdiags...)
|
|
|
|
body := hcl.EmptyBody()
|
|
if file != nil && file.Body != nil {
|
|
body = file.Body
|
|
}
|
|
|
|
return parseConfigFile(body, diags, override, p.allowExperiments)
|
|
}
|
|
|
|
// AllowLanguageExperiments specifies whether subsequent LoadConfigFile (and
|
|
// similar) calls will allow opting in to experimental language features.
|
|
//
|
|
// If this method is never called for a particular parser, the default behavior
|
|
// is to disallow language experiments.
|
|
//
|
|
// Main code should set this only for alpha or development builds. Test code
|
|
// is responsible for deciding for itself whether and how to call this
|
|
// method.
|
|
func (p *SourceBundleParser) AllowLanguageExperiments(allowed bool) {
|
|
p.allowExperiments = allowed
|
|
}
|