Files
opentf/internal/command/cliconfig/cliconfig.go
2025-10-29 12:03:55 -05:00

545 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"
"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]interface{} `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]interface{} `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 {
eq := strings.IndexByte(entry, '=')
if eq == -1 {
continue
}
ret[entry[:eq]] = entry[eq+1:]
}
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)
for name, host := range c.Hosts {
result.Hosts[name] = host
}
for name, host := range c2.Hosts {
result.Hosts[name] = host
}
}
if (len(c.Credentials) + len(c2.Credentials)) > 0 {
result.Credentials = make(map[string]map[string]interface{})
for host, creds := range c.Credentials {
result.Credentials[host] = creds
}
for host, creds := range c2.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.
result.Credentials[host] = creds
}
}
if (len(c.CredentialsHelpers) + len(c2.CredentialsHelpers)) > 0 {
result.CredentialsHelpers = make(map[string]*ConfigCredentialsHelper)
for name, helper := range c.CredentialsHelpers {
result.CredentialsHelpers[name] = helper
}
for name, helper := range c2.CredentialsHelpers {
result.CredentialsHelpers[name] = helper
}
}
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
}