mirror of
https://github.com/opentffoundation/opentf.git
synced 2025-12-19 17:59:05 -05:00
These were previously settable only via environment variables. These are now handled as part of CLI Configuration and so also settable in a new "registry_protocols" block in a CLI configuration file, with the environment variables now treated as if they are an additional virtual configuration file containing the corresponding settings. This handles our settings in our modern style where package cliconfig is responsible for deciding the configuration and then package main reacts to that configuration without being aware of how it is decided. Signed-off-by: Martin Atkins <mart@degeneration.co.uk>
504 lines
17 KiB
Go
504 lines
17 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"
|
|
"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
|
|
}
|