| // Copyright IBM Corp. 2014, 2026 | |
| // SPDX-License-Identifier: BUSL-1.1 | |
| package configs | |
| import ( | |
| "fmt" | |
| "os" | |
| "path/filepath" | |
| "strings" | |
| "sync" | |
| "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 | |
| mu sync.Mutex | |
| } | |
| // 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) { | |
| p.mu.Lock() | |
| defer p.mu.Unlock() | |
| 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 | |
| } | |
| // We are in a locked method, it's safe to call p.allowExperiments directly | |
| 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.mu.Lock() | |
| defer p.mu.Unlock() | |
| p.allowExperiments = allowed | |
| } | |
| func (p *SourceBundleParser) AllowsLanguageExperiments() bool { | |
| p.mu.Lock() | |
| defer p.mu.Unlock() | |
| return p.allowExperiments | |
| } |