mirror of
https://github.com/opentffoundation/opentf.git
synced 2026-05-25 10:02:16 -04:00
We don't typically just broadly run automatic rewriting tools like "go fix" across our codebase because that tends to cause annoying and unnecessary merge conflicts when we're backporting to earlier release branches. But all of the files in this commit were changed in some non-trivial way already during the OpenTofu v1.11 development period anyway, and so the likelyhood we'd be able to successfully backport from them is reduced and therefore this seems like a good opportunity to do some focused modernization using "go fix". My rules for what to include or not are admittedly quite "vibes-based", but the general idea was: - Focusing on files under the "command" directory only, because that's already been an area of intentional refactoring during this development period. - If the existing diff in a file is already significantly larger than the changes the fixer proposed to make, or if the fixer is proposing to change a line that was already changed in this development period. - More willing to include "_test.go" files than non-test files, even if they hadn't changed as much already, just because backports from test files for bug fixes tend to be entirely new test cases more than they are modifications to existing test cases, and so the risk of conflicts is lower there. Signed-off-by: Martin Atkins <mart@degeneration.co.uk>
534 lines
18 KiB
Go
534 lines
18 KiB
Go
// Copyright (c) The OpenTofu Authors
|
|
// SPDX-License-Identifier: MPL-2.0
|
|
// Copyright (c) 2023 HashiCorp, Inc.
|
|
// SPDX-License-Identifier: MPL-2.0
|
|
|
|
// Package cliconfig has the types representing and the logic to load CLI-level
|
|
// configuration settings.
|
|
//
|
|
// The CLI config is a small collection of settings that a user can override via
|
|
// some files in their home directory or, in some cases, via environment
|
|
// variables. The CLI config is not the same thing as a OpenTofu configuration
|
|
// written in the Terraform language; the logic for those lives in the top-level
|
|
// directory "configs".
|
|
package cliconfig
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"log"
|
|
"maps"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/hashicorp/hcl"
|
|
"github.com/opentofu/svchost"
|
|
|
|
"github.com/opentofu/opentofu/internal/tfdiags"
|
|
)
|
|
|
|
const pluginCacheDirEnvVar = "TF_PLUGIN_CACHE_DIR"
|
|
const pluginCacheMayBreakLockFileEnvVar = "TF_PLUGIN_CACHE_MAY_BREAK_DEPENDENCY_LOCK_FILE"
|
|
|
|
// Config is the structure of the configuration for the OpenTofu CLI.
|
|
//
|
|
// This is not the configuration for OpenTofu itself. That is in the
|
|
// "config" package.
|
|
type Config struct {
|
|
// If set, enables local caching of plugins in this directory to
|
|
// avoid repeatedly re-downloading over the Internet.
|
|
PluginCacheDir string `hcl:"plugin_cache_dir"`
|
|
|
|
// PluginCacheMayBreakDependencyLockFile is an interim accommodation for
|
|
// those who wish to use the Plugin Cache Dir even in cases where doing so
|
|
// will cause the dependency lock file to be incomplete.
|
|
//
|
|
// This is likely to become a silent no-op in future OpenTofu versions but
|
|
// is here in recognition of the fact that the dependency lock file is not
|
|
// yet a good fit for all OpenTofu workflows and folks in that category
|
|
// would prefer to have the plugin cache dir's behavior to take priority
|
|
// over the requirements of the dependency lock file.
|
|
PluginCacheMayBreakDependencyLockFile bool `hcl:"plugin_cache_may_break_dependency_lock_file"`
|
|
|
|
Hosts map[string]*ConfigHost `hcl:"host"`
|
|
|
|
Credentials map[string]map[string]any `hcl:"credentials"`
|
|
CredentialsHelpers map[string]*ConfigCredentialsHelper `hcl:"credentials_helper"`
|
|
|
|
// RegistryProtocols contains some settings for tailoring the request
|
|
// timeout and retry count for metadata requests made by our registry
|
|
// protocol clients.
|
|
//
|
|
// [LoadConfig] guarantees that this will always be present and contain
|
|
// values. If these settings are not configured either in files or the
|
|
// environment then we use some reasonable default settings.
|
|
RegistryProtocols *RegistryProtocolsConfig
|
|
|
|
// ProviderInstallation represents any provider_installation blocks
|
|
// in the configuration. Only one of these is allowed across the whole
|
|
// configuration, but we decode into a slice here so that we can handle
|
|
// that validation at validation time rather than initial decode time.
|
|
ProviderInstallation []*ProviderInstallation
|
|
|
|
// OCIDefaultCredentials and OCIRepositoryCredentials together represent
|
|
// the individual OCI-credentials-related blocks in the configuration.
|
|
//
|
|
// Only one OCIDefaultCredentials element is allowed, but we validate
|
|
// that after loading the configuration. Zero or more OCICredentials
|
|
// blocks are allowed, but they must each have a unique repository
|
|
// prefix.
|
|
OCIDefaultCredentials []*OCIDefaultCredentials
|
|
OCIRepositoryCredentials []*OCIRepositoryCredentials
|
|
}
|
|
|
|
// ConfigHost is the structure of the "host" nested block within the CLI
|
|
// configuration, which can be used to override the default service host
|
|
// discovery behavior for a particular hostname.
|
|
type ConfigHost struct {
|
|
Services map[string]any `hcl:"services"`
|
|
}
|
|
|
|
// ConfigCredentialsHelper is the structure of the "credentials_helper"
|
|
// nested block within the CLI configuration.
|
|
type ConfigCredentialsHelper struct {
|
|
Args []string `hcl:"args"`
|
|
}
|
|
|
|
// BuiltinConfig is the built-in defaults for the configuration. These
|
|
// can be overridden by user configurations.
|
|
var BuiltinConfig Config
|
|
|
|
// ConfigFile returns the default path to the configuration file.
|
|
//
|
|
// On Unix-like systems this is the ".tofurc" file in the home directory.
|
|
// On Windows, this is the "tofu.rc" file in the application data
|
|
// directory.
|
|
func ConfigFile() (string, error) {
|
|
return standardConfigLoader().configFile()
|
|
}
|
|
|
|
func (cl *ConfigLoader) ConfigFile() (string, error) {
|
|
return cl.configFile()
|
|
}
|
|
|
|
// ConfigDir returns the configuration directory for OpenTofu.
|
|
func ConfigDir() (string, error) {
|
|
return standardConfigLoader().configDir()
|
|
}
|
|
|
|
func (cl *ConfigLoader) ConfigDir() (string, error) {
|
|
return cl.configDir()
|
|
}
|
|
|
|
// DataDirs returns the data directories for OpenTofu.
|
|
func DataDirs() ([]string, error) {
|
|
return standardConfigLoader().dataDirs()
|
|
}
|
|
|
|
func (cl *ConfigLoader) DataDirs() ([]string, error) {
|
|
return cl.dataDirs()
|
|
}
|
|
|
|
// LoadConfig reads the CLI configuration from the various filesystem locations
|
|
// and from the environment, returning a merged configuration along with any
|
|
// diagnostics (errors and warnings) encountered along the way.
|
|
func LoadConfig(ctx context.Context) (*Config, tfdiags.Diagnostics) {
|
|
return standardConfigLoader().LoadConfig(ctx)
|
|
}
|
|
|
|
func (cl *ConfigLoader) LoadConfig(_ context.Context) (*Config, tfdiags.Diagnostics) {
|
|
var diags tfdiags.Diagnostics
|
|
configVal := BuiltinConfig // copy
|
|
config := &configVal
|
|
|
|
if mainFilename, mainFileDiags := cl.cliConfigFile(); len(mainFileDiags) == 0 {
|
|
if _, err := cl.Stat(mainFilename); err == nil {
|
|
mainConfig, mainDiags := cl.loadConfigFile(mainFilename)
|
|
diags = diags.Append(mainDiags)
|
|
// NOTE: The order of arguments to merge below seems confusing
|
|
// given that below we tend to do it the other way around, but
|
|
// this was a historical inconsistency that we only discovered
|
|
// after making BuiltinConfig not just be completely empty and
|
|
// so we've swapped these but left the others unswapped to avoid
|
|
// changing those behaviors. It was unfortunately never well
|
|
// specified what is overriding what here.
|
|
config = mainConfig.Merge(config)
|
|
}
|
|
} else {
|
|
diags = diags.Append(mainFileDiags)
|
|
}
|
|
|
|
// Unless the user has specifically overridden the configuration file
|
|
// location using an environment variable, we'll also load what we find
|
|
// in the config directory. We skip the config directory when source
|
|
// file override is set because we interpret the environment variable
|
|
// being set as an intention to ignore the default set of CLI config
|
|
// files because we're doing something special, like running OpenTofu
|
|
// in automation with a locally-customized configuration.
|
|
if cliConfigFileOverride() == "" {
|
|
if configDir, err := cl.ConfigDir(); err == nil {
|
|
if info, err := cl.Stat(configDir); err == nil && info.IsDir() {
|
|
dirConfig, dirDiags := cl.loadConfigDir(configDir)
|
|
diags = diags.Append(dirDiags)
|
|
config = config.Merge(dirConfig)
|
|
}
|
|
}
|
|
} else {
|
|
log.Printf("[DEBUG] Not reading CLI config directory because config location is overridden by environment variable")
|
|
}
|
|
|
|
if envConfig := EnvConfig(); envConfig != nil {
|
|
// envConfig takes precedence
|
|
config = envConfig.Merge(config)
|
|
}
|
|
|
|
diags = diags.Append(config.Validate())
|
|
|
|
return config, diags
|
|
}
|
|
|
|
// loadConfigFile loads the CLI configuration from ".tofurc" files.
|
|
func loadConfigFile(path string) (*Config, tfdiags.Diagnostics) {
|
|
return standardConfigLoader().loadConfigFile(path)
|
|
}
|
|
|
|
func (cl *ConfigLoader) loadConfigFile(path string) (*Config, tfdiags.Diagnostics) {
|
|
var diags tfdiags.Diagnostics
|
|
result := &Config{}
|
|
|
|
log.Printf("Loading CLI configuration from %s", path)
|
|
|
|
// Read the HCL file and prepare for parsing
|
|
d, err := cl.ReadFile(path)
|
|
if err != nil {
|
|
diags = diags.Append(fmt.Errorf("Error reading %s: %w", path, err))
|
|
return result, diags
|
|
}
|
|
|
|
// Parse it
|
|
obj, err := hcl.Parse(string(d))
|
|
if err != nil {
|
|
diags = diags.Append(fmt.Errorf("Error parsing %s: %w", path, err))
|
|
return result, diags
|
|
}
|
|
|
|
// Build up the result
|
|
if err := hcl.DecodeObject(&result, obj); err != nil {
|
|
diags = diags.Append(fmt.Errorf("Error parsing %s: %w", path, err))
|
|
return result, diags
|
|
}
|
|
|
|
// A few other blocks need some more special treatment because we are
|
|
// using a structure that is not compatible with HCL 1's DecodeObject,
|
|
// or HCL 1 would be too liberal in parsing and thus make it harder
|
|
// for us to potentially transition to using HCL 2 later.
|
|
registryProtocolsConfig, registryProtocolsDiags := decodeRegistryProtocolsConfigFromConfig(obj)
|
|
diags = diags.Append(registryProtocolsDiags)
|
|
result.RegistryProtocols = registryProtocolsConfig
|
|
providerInstBlocks, providerInstDiags := decodeProviderInstallationFromConfig(obj)
|
|
diags = diags.Append(providerInstDiags)
|
|
result.ProviderInstallation = providerInstBlocks
|
|
ociDefaultCredsBlocks, ociDefaultCredsDiags := decodeOCIDefaultCredentialsFromConfig(obj, path)
|
|
diags = diags.Append(ociDefaultCredsDiags)
|
|
result.OCIDefaultCredentials = ociDefaultCredsBlocks
|
|
ociCredsBlocks, ociCredsDiags := decodeOCIRepositoryCredentialsFromConfig(obj)
|
|
diags = diags.Append(ociCredsDiags)
|
|
result.OCIRepositoryCredentials = ociCredsBlocks
|
|
|
|
if result.PluginCacheDir != "" {
|
|
result.PluginCacheDir = os.ExpandEnv(result.PluginCacheDir)
|
|
}
|
|
|
|
return result, diags
|
|
}
|
|
|
|
func (cl *ConfigLoader) loadConfigDir(path string) (*Config, tfdiags.Diagnostics) {
|
|
var diags tfdiags.Diagnostics
|
|
result := &Config{}
|
|
|
|
entries, err := cl.ReadDir(path)
|
|
if err != nil {
|
|
diags = diags.Append(fmt.Errorf("Error reading %s: %w", path, err))
|
|
return result, diags
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
name := entry.Name()
|
|
// Ignoring errors here because it is used only to indicate pattern
|
|
// syntax errors, and our patterns are hard-coded here.
|
|
hclMatched, _ := filepath.Match("*.tfrc", name)
|
|
jsonMatched, _ := filepath.Match("*.tfrc.json", name)
|
|
if !hclMatched && !jsonMatched {
|
|
continue
|
|
}
|
|
|
|
filePath := filepath.Join(path, name)
|
|
fileConfig, fileDiags := loadConfigFile(filePath)
|
|
diags = diags.Append(fileDiags)
|
|
result = result.Merge(fileConfig)
|
|
}
|
|
|
|
return result, diags
|
|
}
|
|
|
|
// EnvConfig returns a Config populated from environment variables.
|
|
//
|
|
// Any values specified in this config should override those set in the
|
|
// configuration file.
|
|
func EnvConfig() *Config {
|
|
env := makeEnvMap(os.Environ())
|
|
return envConfig(env)
|
|
}
|
|
|
|
func envConfig(env map[string]string) *Config {
|
|
config := &Config{}
|
|
|
|
if envPluginCacheDir := env[pluginCacheDirEnvVar]; envPluginCacheDir != "" {
|
|
// No ExpandEnv here, because expanding environment variables inside
|
|
// an environment variable would be strange and seems unnecessary.
|
|
// (User can expand variables into the value while setting it using
|
|
// standard shell features.)
|
|
config.PluginCacheDir = envPluginCacheDir
|
|
}
|
|
|
|
if envMayBreak := env[pluginCacheMayBreakLockFileEnvVar]; envMayBreak != "" && envMayBreak != "0" {
|
|
// This is an environment variable analog to the
|
|
// plugin_cache_may_break_dependency_lock_file setting. If either this
|
|
// or the config file setting are enabled then it's enabled; there is
|
|
// no way to override back to false if either location sets this to
|
|
// true.
|
|
config.PluginCacheMayBreakDependencyLockFile = true
|
|
}
|
|
|
|
// The environment config _always_ has opinions about the registry
|
|
// protocols, because we include the default values in here if the
|
|
// relevant environment variables aren't set.
|
|
config.RegistryProtocols = decodeRegistryProtocolsConfigFromEnvironment()
|
|
|
|
return config
|
|
}
|
|
|
|
func makeEnvMap(environ []string) map[string]string {
|
|
if len(environ) == 0 {
|
|
return nil
|
|
}
|
|
|
|
ret := make(map[string]string, len(environ))
|
|
for _, entry := range environ {
|
|
before, after, ok := strings.Cut(entry, "=")
|
|
if !ok {
|
|
continue
|
|
}
|
|
ret[before] = after
|
|
}
|
|
return ret
|
|
}
|
|
|
|
// Validate checks for errors in the configuration that cannot be detected
|
|
// just by HCL decoding, returning any problems as diagnostics.
|
|
//
|
|
// On success, the returned diagnostics will return false from the HasErrors
|
|
// method. A non-nil diagnostics is not necessarily an error, since it may
|
|
// contain just warnings.
|
|
func (c *Config) Validate() tfdiags.Diagnostics {
|
|
var diags tfdiags.Diagnostics
|
|
|
|
if c == nil {
|
|
return diags
|
|
}
|
|
|
|
// FIXME: Right now our config parsing doesn't retain enough information
|
|
// to give proper source references to any errors. We should improve
|
|
// on this when we change the CLI config parser to use HCL2.
|
|
|
|
// Check that all "host" blocks have valid hostnames.
|
|
for givenHost := range c.Hosts {
|
|
_, err := svchost.ForComparison(givenHost)
|
|
if err != nil {
|
|
diags = diags.Append(
|
|
fmt.Errorf("The host %q block has an invalid hostname: %w", givenHost, err),
|
|
)
|
|
}
|
|
}
|
|
|
|
// Check that all "credentials" blocks have valid hostnames.
|
|
for givenHost := range c.Credentials {
|
|
_, err := svchost.ForComparison(givenHost)
|
|
if err != nil {
|
|
diags = diags.Append(
|
|
fmt.Errorf("The credentials %q block has an invalid hostname: %w", givenHost, err),
|
|
)
|
|
}
|
|
}
|
|
|
|
// Should have zero or one "credentials_helper" blocks
|
|
if len(c.CredentialsHelpers) > 1 {
|
|
diags = diags.Append(
|
|
fmt.Errorf("No more than one credentials_helper block may be specified"),
|
|
)
|
|
}
|
|
|
|
// Should have zero or one "provider_installation" blocks
|
|
if len(c.ProviderInstallation) > 1 {
|
|
diags = diags.Append(
|
|
fmt.Errorf("No more than one provider_installation block may be specified"),
|
|
)
|
|
}
|
|
|
|
// Should have zero or one "oci_default_credentials" blocks
|
|
if len(c.OCIDefaultCredentials) > 1 {
|
|
diags = diags.Append(
|
|
//nolint:stylecheck // Despite typical Go idiom, our existing precedent here is to return full sentences suitable for inclusion in diagnostics.
|
|
fmt.Errorf("No more than one oci_default_credentials block may be specified"),
|
|
)
|
|
}
|
|
if len(c.OCIRepositoryCredentials) != 0 {
|
|
seenOCICredentialsAddrs := make(map[string]struct{})
|
|
for _, creds := range c.OCIRepositoryCredentials {
|
|
if _, ok := seenOCICredentialsAddrs[creds.RepositoryPrefix]; ok {
|
|
diags = diags.Append(
|
|
//nolint:stylecheck // Despite typical Go idiom, our existing precedent here is to return full sentences suitable for inclusion in diagnostics.
|
|
fmt.Errorf("Duplicate oci_credentials block for %q", creds.RepositoryPrefix),
|
|
)
|
|
continue
|
|
}
|
|
seenOCICredentialsAddrs[creds.RepositoryPrefix] = struct{}{}
|
|
}
|
|
}
|
|
|
|
if c.PluginCacheDir != "" {
|
|
_, err := os.Stat(c.PluginCacheDir)
|
|
if err != nil {
|
|
diags = diags.Append(
|
|
fmt.Errorf("The specified plugin cache dir %s cannot be opened: %w", c.PluginCacheDir, err),
|
|
)
|
|
}
|
|
}
|
|
|
|
return diags
|
|
}
|
|
|
|
// Merge merges two configurations and returns a third entirely
|
|
// new configuration with the two merged.
|
|
func (c *Config) Merge(c2 *Config) *Config {
|
|
var result Config
|
|
|
|
result.PluginCacheDir = c.PluginCacheDir
|
|
if result.PluginCacheDir == "" {
|
|
result.PluginCacheDir = c2.PluginCacheDir
|
|
}
|
|
|
|
if c.PluginCacheMayBreakDependencyLockFile || c2.PluginCacheMayBreakDependencyLockFile {
|
|
// This setting saturates to "on"; once either configuration sets it,
|
|
// there is no way to override it back to off again.
|
|
result.PluginCacheMayBreakDependencyLockFile = true
|
|
}
|
|
|
|
if (len(c.Hosts) + len(c2.Hosts)) > 0 {
|
|
result.Hosts = make(map[string]*ConfigHost)
|
|
maps.Copy(result.Hosts, c.Hosts)
|
|
maps.Copy(result.Hosts, c2.Hosts)
|
|
}
|
|
|
|
if (len(c.Credentials) + len(c2.Credentials)) > 0 {
|
|
result.Credentials = make(map[string]map[string]any)
|
|
maps.Copy(result.Credentials, c.Credentials)
|
|
// We just clobber an entry from the other file right now. Will
|
|
// improve on this later using the more-robust merging behavior
|
|
// built in to HCL2.
|
|
maps.Copy(result.Credentials, c2.Credentials)
|
|
}
|
|
|
|
if (len(c.CredentialsHelpers) + len(c2.CredentialsHelpers)) > 0 {
|
|
result.CredentialsHelpers = make(map[string]*ConfigCredentialsHelper)
|
|
maps.Copy(result.CredentialsHelpers, c.CredentialsHelpers)
|
|
maps.Copy(result.CredentialsHelpers, c2.CredentialsHelpers)
|
|
}
|
|
|
|
result.RegistryProtocols = mergeRegistryProtocolConfigs(c2.RegistryProtocols, c.RegistryProtocols)
|
|
|
|
if (len(c.ProviderInstallation) + len(c2.ProviderInstallation)) > 0 {
|
|
result.ProviderInstallation = append(result.ProviderInstallation, c.ProviderInstallation...)
|
|
result.ProviderInstallation = append(result.ProviderInstallation, c2.ProviderInstallation...)
|
|
}
|
|
|
|
if (len(c.OCIDefaultCredentials) + len(c2.OCIDefaultCredentials)) > 0 {
|
|
result.OCIDefaultCredentials = append(result.OCIDefaultCredentials, c.OCIDefaultCredentials...)
|
|
result.OCIDefaultCredentials = append(result.OCIDefaultCredentials, c2.OCIDefaultCredentials...)
|
|
}
|
|
if (len(c.OCIRepositoryCredentials) + len(c2.OCIRepositoryCredentials)) > 0 {
|
|
result.OCIRepositoryCredentials = append(result.OCIRepositoryCredentials, c.OCIRepositoryCredentials...)
|
|
result.OCIRepositoryCredentials = append(result.OCIRepositoryCredentials, c2.OCIRepositoryCredentials...)
|
|
}
|
|
|
|
return &result
|
|
}
|
|
|
|
func (cl *ConfigLoader) cliConfigFile() (string, tfdiags.Diagnostics) {
|
|
var diags tfdiags.Diagnostics
|
|
mustExist := true
|
|
|
|
configFilePath := cliConfigFileOverride()
|
|
if configFilePath == "" {
|
|
var err error
|
|
configFilePath, err = cl.ConfigFile()
|
|
mustExist = false
|
|
|
|
if err != nil {
|
|
log.Printf(
|
|
"[ERROR] Error detecting default CLI config file path: %s",
|
|
err)
|
|
}
|
|
}
|
|
|
|
log.Printf("[DEBUG] Attempting to open CLI config file: %s", configFilePath)
|
|
f, err := cl.Open(configFilePath)
|
|
if err == nil {
|
|
f.Close()
|
|
return configFilePath, diags
|
|
}
|
|
|
|
if mustExist || !errors.Is(err, fs.ErrNotExist) {
|
|
diags = append(diags, tfdiags.Sourceless(
|
|
tfdiags.Warning,
|
|
"Unable to open CLI configuration file",
|
|
fmt.Sprintf("The CLI configuration file at %q does not exist.", configFilePath),
|
|
))
|
|
}
|
|
|
|
log.Println("[DEBUG] File doesn't exist, but doesn't need to. Ignoring.")
|
|
return "", diags
|
|
}
|
|
|
|
func cliConfigFileOverride() string {
|
|
configFilePath := os.Getenv("TF_CLI_CONFIG_FILE")
|
|
if configFilePath == "" {
|
|
configFilePath = os.Getenv("TERRAFORM_CONFIG")
|
|
}
|
|
return configFilePath
|
|
}
|
|
|
|
const (
|
|
// providerDownloadRetryCountEnvName is the environment variable name used to customize
|
|
// the HTTP retry count for module downloads.
|
|
providerDownloadRetryCountEnvName = "TF_PROVIDER_DOWNLOAD_RETRY"
|
|
|
|
providerDownloadDefaultRetry = 2
|
|
)
|
|
|
|
// ProviderDownloadRetries will attempt for requests with retryable errors, like 502 status codes
|
|
func ProviderDownloadRetries() int {
|
|
r := providerDownloadDefaultRetry
|
|
if v := os.Getenv(providerDownloadRetryCountEnvName); v != "" {
|
|
retry, err := strconv.Atoi(v)
|
|
if err == nil && retry > 0 {
|
|
r = retry
|
|
}
|
|
}
|
|
return r
|
|
}
|