// 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" "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 configFile() } // ConfigDir returns the configuration directory for OpenTofu. func ConfigDir() (string, error) { return configDir() } // DataDirs returns the data directories for OpenTofu. func DataDirs() ([]string, error) { return 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(_ context.Context) (*Config, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics configVal := BuiltinConfig // copy config := &configVal if mainFilename, mainFileDiags := cliConfigFile(); len(mainFileDiags) == 0 { if _, err := os.Stat(mainFilename); err == nil { mainConfig, mainDiags := 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 := ConfigDir(); err == nil { if info, err := os.Stat(configDir); err == nil && info.IsDir() { dirConfig, dirDiags := 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) { var diags tfdiags.Diagnostics result := &Config{} log.Printf("Loading CLI configuration from %s", path) // Read the HCL file and prepare for parsing d, err := os.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 loadConfigDir(path string) (*Config, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics result := &Config{} entries, err := os.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 cliConfigFile() (string, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics mustExist := true configFilePath := cliConfigFileOverride() if configFilePath == "" { var err error configFilePath, err = 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 := os.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 }