github.com/opentofu/opentofu@v1.7.1/internal/command/cliconfig/cliconfig.go (about)

     1  // Copyright (c) The OpenTofu Authors
     2  // SPDX-License-Identifier: MPL-2.0
     3  // Copyright (c) 2023 HashiCorp, Inc.
     4  // SPDX-License-Identifier: MPL-2.0
     5  
     6  // Package cliconfig has the types representing and the logic to load CLI-level
     7  // configuration settings.
     8  //
     9  // The CLI config is a small collection of settings that a user can override via
    10  // some files in their home directory or, in some cases, via environment
    11  // variables. The CLI config is not the same thing as a OpenTofu configuration
    12  // written in the Terraform language; the logic for those lives in the top-level
    13  // directory "configs".
    14  package cliconfig
    15  
    16  import (
    17  	"errors"
    18  	"fmt"
    19  	"io/fs"
    20  	"log"
    21  	"os"
    22  	"path/filepath"
    23  	"strings"
    24  
    25  	"github.com/hashicorp/hcl"
    26  
    27  	svchost "github.com/hashicorp/terraform-svchost"
    28  
    29  	"github.com/opentofu/opentofu/internal/tfdiags"
    30  )
    31  
    32  const pluginCacheDirEnvVar = "TF_PLUGIN_CACHE_DIR"
    33  const pluginCacheMayBreakLockFileEnvVar = "TF_PLUGIN_CACHE_MAY_BREAK_DEPENDENCY_LOCK_FILE"
    34  
    35  // Config is the structure of the configuration for the OpenTofu CLI.
    36  //
    37  // This is not the configuration for OpenTofu itself. That is in the
    38  // "config" package.
    39  type Config struct {
    40  	Providers    map[string]string
    41  	Provisioners map[string]string
    42  
    43  	// If set, enables local caching of plugins in this directory to
    44  	// avoid repeatedly re-downloading over the Internet.
    45  	PluginCacheDir string `hcl:"plugin_cache_dir"`
    46  
    47  	// PluginCacheMayBreakDependencyLockFile is an interim accommodation for
    48  	// those who wish to use the Plugin Cache Dir even in cases where doing so
    49  	// will cause the dependency lock file to be incomplete.
    50  	//
    51  	// This is likely to become a silent no-op in future OpenTofu versions but
    52  	// is here in recognition of the fact that the dependency lock file is not
    53  	// yet a good fit for all OpenTofu workflows and folks in that category
    54  	// would prefer to have the plugin cache dir's behavior to take priority
    55  	// over the requirements of the dependency lock file.
    56  	PluginCacheMayBreakDependencyLockFile bool `hcl:"plugin_cache_may_break_dependency_lock_file"`
    57  
    58  	Hosts map[string]*ConfigHost `hcl:"host"`
    59  
    60  	Credentials        map[string]map[string]interface{}   `hcl:"credentials"`
    61  	CredentialsHelpers map[string]*ConfigCredentialsHelper `hcl:"credentials_helper"`
    62  
    63  	// ProviderInstallation represents any provider_installation blocks
    64  	// in the configuration. Only one of these is allowed across the whole
    65  	// configuration, but we decode into a slice here so that we can handle
    66  	// that validation at validation time rather than initial decode time.
    67  	ProviderInstallation []*ProviderInstallation
    68  }
    69  
    70  // ConfigHost is the structure of the "host" nested block within the CLI
    71  // configuration, which can be used to override the default service host
    72  // discovery behavior for a particular hostname.
    73  type ConfigHost struct {
    74  	Services map[string]interface{} `hcl:"services"`
    75  }
    76  
    77  // ConfigCredentialsHelper is the structure of the "credentials_helper"
    78  // nested block within the CLI configuration.
    79  type ConfigCredentialsHelper struct {
    80  	Args []string `hcl:"args"`
    81  }
    82  
    83  // BuiltinConfig is the built-in defaults for the configuration. These
    84  // can be overridden by user configurations.
    85  var BuiltinConfig Config
    86  
    87  // ConfigFile returns the default path to the configuration file.
    88  //
    89  // On Unix-like systems this is the ".tofurc" file in the home directory.
    90  // On Windows, this is the "tofu.rc" file in the application data
    91  // directory.
    92  func ConfigFile() (string, error) {
    93  	return configFile()
    94  }
    95  
    96  // ConfigDir returns the configuration directory for OpenTofu.
    97  func ConfigDir() (string, error) {
    98  	return configDir()
    99  }
   100  
   101  // DataDirs returns the data directories for OpenTofu.
   102  func DataDirs() ([]string, error) {
   103  	return dataDirs()
   104  }
   105  
   106  // LoadConfig reads the CLI configuration from the various filesystem locations
   107  // and from the environment, returning a merged configuration along with any
   108  // diagnostics (errors and warnings) encountered along the way.
   109  func LoadConfig() (*Config, tfdiags.Diagnostics) {
   110  	var diags tfdiags.Diagnostics
   111  	configVal := BuiltinConfig // copy
   112  	config := &configVal
   113  
   114  	if mainFilename, mainFileDiags := cliConfigFile(); len(mainFileDiags) == 0 {
   115  		if _, err := os.Stat(mainFilename); err == nil {
   116  			mainConfig, mainDiags := loadConfigFile(mainFilename)
   117  			diags = diags.Append(mainDiags)
   118  			config = config.Merge(mainConfig)
   119  		}
   120  	} else {
   121  		diags = diags.Append(mainFileDiags)
   122  	}
   123  
   124  	// Unless the user has specifically overridden the configuration file
   125  	// location using an environment variable, we'll also load what we find
   126  	// in the config directory. We skip the config directory when source
   127  	// file override is set because we interpret the environment variable
   128  	// being set as an intention to ignore the default set of CLI config
   129  	// files because we're doing something special, like running OpenTofu
   130  	// in automation with a locally-customized configuration.
   131  	if cliConfigFileOverride() == "" {
   132  		if configDir, err := ConfigDir(); err == nil {
   133  			if info, err := os.Stat(configDir); err == nil && info.IsDir() {
   134  				dirConfig, dirDiags := loadConfigDir(configDir)
   135  				diags = diags.Append(dirDiags)
   136  				config = config.Merge(dirConfig)
   137  			}
   138  		}
   139  	} else {
   140  		log.Printf("[DEBUG] Not reading CLI config directory because config location is overridden by environment variable")
   141  	}
   142  
   143  	if envConfig := EnvConfig(); envConfig != nil {
   144  		// envConfig takes precedence
   145  		config = envConfig.Merge(config)
   146  	}
   147  
   148  	diags = diags.Append(config.Validate())
   149  
   150  	return config, diags
   151  }
   152  
   153  // loadConfigFile loads the CLI configuration from ".tofurc" files.
   154  func loadConfigFile(path string) (*Config, tfdiags.Diagnostics) {
   155  	var diags tfdiags.Diagnostics
   156  	result := &Config{}
   157  
   158  	log.Printf("Loading CLI configuration from %s", path)
   159  
   160  	// Read the HCL file and prepare for parsing
   161  	d, err := os.ReadFile(path)
   162  	if err != nil {
   163  		diags = diags.Append(fmt.Errorf("Error reading %s: %w", path, err))
   164  		return result, diags
   165  	}
   166  
   167  	// Parse it
   168  	obj, err := hcl.Parse(string(d))
   169  	if err != nil {
   170  		diags = diags.Append(fmt.Errorf("Error parsing %s: %w", path, err))
   171  		return result, diags
   172  	}
   173  
   174  	// Build up the result
   175  	if err := hcl.DecodeObject(&result, obj); err != nil {
   176  		diags = diags.Append(fmt.Errorf("Error parsing %s: %w", path, err))
   177  		return result, diags
   178  	}
   179  
   180  	// Deal with the provider_installation block, which is not handled using
   181  	// DecodeObject because its structure is not compatible with the
   182  	// limitations of that function.
   183  	providerInstBlocks, moreDiags := decodeProviderInstallationFromConfig(obj)
   184  	diags = diags.Append(moreDiags)
   185  	result.ProviderInstallation = providerInstBlocks
   186  
   187  	// Replace all env vars
   188  	for k, v := range result.Providers {
   189  		result.Providers[k] = os.ExpandEnv(v)
   190  	}
   191  	for k, v := range result.Provisioners {
   192  		result.Provisioners[k] = os.ExpandEnv(v)
   193  	}
   194  
   195  	if result.PluginCacheDir != "" {
   196  		result.PluginCacheDir = os.ExpandEnv(result.PluginCacheDir)
   197  	}
   198  
   199  	return result, diags
   200  }
   201  
   202  func loadConfigDir(path string) (*Config, tfdiags.Diagnostics) {
   203  	var diags tfdiags.Diagnostics
   204  	result := &Config{}
   205  
   206  	entries, err := os.ReadDir(path)
   207  	if err != nil {
   208  		diags = diags.Append(fmt.Errorf("Error reading %s: %w", path, err))
   209  		return result, diags
   210  	}
   211  
   212  	for _, entry := range entries {
   213  		name := entry.Name()
   214  		// Ignoring errors here because it is used only to indicate pattern
   215  		// syntax errors, and our patterns are hard-coded here.
   216  		hclMatched, _ := filepath.Match("*.tfrc", name)
   217  		jsonMatched, _ := filepath.Match("*.tfrc.json", name)
   218  		if !(hclMatched || jsonMatched) {
   219  			continue
   220  		}
   221  
   222  		filePath := filepath.Join(path, name)
   223  		fileConfig, fileDiags := loadConfigFile(filePath)
   224  		diags = diags.Append(fileDiags)
   225  		result = result.Merge(fileConfig)
   226  	}
   227  
   228  	return result, diags
   229  }
   230  
   231  // EnvConfig returns a Config populated from environment variables.
   232  //
   233  // Any values specified in this config should override those set in the
   234  // configuration file.
   235  func EnvConfig() *Config {
   236  	env := makeEnvMap(os.Environ())
   237  	return envConfig(env)
   238  }
   239  
   240  func envConfig(env map[string]string) *Config {
   241  	config := &Config{}
   242  
   243  	if envPluginCacheDir := env[pluginCacheDirEnvVar]; envPluginCacheDir != "" {
   244  		// No Expandenv here, because expanding environment variables inside
   245  		// an environment variable would be strange and seems unnecessary.
   246  		// (User can expand variables into the value while setting it using
   247  		// standard shell features.)
   248  		config.PluginCacheDir = envPluginCacheDir
   249  	}
   250  
   251  	if envMayBreak := env[pluginCacheMayBreakLockFileEnvVar]; envMayBreak != "" && envMayBreak != "0" {
   252  		// This is an environment variable analog to the
   253  		// plugin_cache_may_break_dependency_lock_file setting. If either this
   254  		// or the config file setting are enabled then it's enabled; there is
   255  		// no way to override back to false if either location sets this to
   256  		// true.
   257  		config.PluginCacheMayBreakDependencyLockFile = true
   258  	}
   259  
   260  	return config
   261  }
   262  
   263  func makeEnvMap(environ []string) map[string]string {
   264  	if len(environ) == 0 {
   265  		return nil
   266  	}
   267  
   268  	ret := make(map[string]string, len(environ))
   269  	for _, entry := range environ {
   270  		eq := strings.IndexByte(entry, '=')
   271  		if eq == -1 {
   272  			continue
   273  		}
   274  		ret[entry[:eq]] = entry[eq+1:]
   275  	}
   276  	return ret
   277  }
   278  
   279  // Validate checks for errors in the configuration that cannot be detected
   280  // just by HCL decoding, returning any problems as diagnostics.
   281  //
   282  // On success, the returned diagnostics will return false from the HasErrors
   283  // method. A non-nil diagnostics is not necessarily an error, since it may
   284  // contain just warnings.
   285  func (c *Config) Validate() tfdiags.Diagnostics {
   286  	var diags tfdiags.Diagnostics
   287  
   288  	if c == nil {
   289  		return diags
   290  	}
   291  
   292  	// FIXME: Right now our config parsing doesn't retain enough information
   293  	// to give proper source references to any errors. We should improve
   294  	// on this when we change the CLI config parser to use HCL2.
   295  
   296  	// Check that all "host" blocks have valid hostnames.
   297  	for givenHost := range c.Hosts {
   298  		_, err := svchost.ForComparison(givenHost)
   299  		if err != nil {
   300  			diags = diags.Append(
   301  				fmt.Errorf("The host %q block has an invalid hostname: %w", givenHost, err),
   302  			)
   303  		}
   304  	}
   305  
   306  	// Check that all "credentials" blocks have valid hostnames.
   307  	for givenHost := range c.Credentials {
   308  		_, err := svchost.ForComparison(givenHost)
   309  		if err != nil {
   310  			diags = diags.Append(
   311  				fmt.Errorf("The credentials %q block has an invalid hostname: %w", givenHost, err),
   312  			)
   313  		}
   314  	}
   315  
   316  	// Should have zero or one "credentials_helper" blocks
   317  	if len(c.CredentialsHelpers) > 1 {
   318  		diags = diags.Append(
   319  			fmt.Errorf("No more than one credentials_helper block may be specified"),
   320  		)
   321  	}
   322  
   323  	// Should have zero or one "provider_installation" blocks
   324  	if len(c.ProviderInstallation) > 1 {
   325  		diags = diags.Append(
   326  			fmt.Errorf("No more than one provider_installation block may be specified"),
   327  		)
   328  	}
   329  
   330  	if c.PluginCacheDir != "" {
   331  		_, err := os.Stat(c.PluginCacheDir)
   332  		if err != nil {
   333  			diags = diags.Append(
   334  				fmt.Errorf("The specified plugin cache dir %s cannot be opened: %w", c.PluginCacheDir, err),
   335  			)
   336  		}
   337  	}
   338  
   339  	return diags
   340  }
   341  
   342  // Merge merges two configurations and returns a third entirely
   343  // new configuration with the two merged.
   344  func (c *Config) Merge(c2 *Config) *Config {
   345  	var result Config
   346  	result.Providers = make(map[string]string)
   347  	result.Provisioners = make(map[string]string)
   348  	for k, v := range c.Providers {
   349  		result.Providers[k] = v
   350  	}
   351  	for k, v := range c2.Providers {
   352  		if v1, ok := c.Providers[k]; ok {
   353  			log.Printf("[INFO] Local %s provider configuration '%s' overrides '%s'", k, v, v1)
   354  		}
   355  		result.Providers[k] = v
   356  	}
   357  	for k, v := range c.Provisioners {
   358  		result.Provisioners[k] = v
   359  	}
   360  	for k, v := range c2.Provisioners {
   361  		if v1, ok := c.Provisioners[k]; ok {
   362  			log.Printf("[INFO] Local %s provisioner configuration '%s' overrides '%s'", k, v, v1)
   363  		}
   364  		result.Provisioners[k] = v
   365  	}
   366  
   367  	result.PluginCacheDir = c.PluginCacheDir
   368  	if result.PluginCacheDir == "" {
   369  		result.PluginCacheDir = c2.PluginCacheDir
   370  	}
   371  
   372  	if c.PluginCacheMayBreakDependencyLockFile || c2.PluginCacheMayBreakDependencyLockFile {
   373  		// This setting saturates to "on"; once either configuration sets it,
   374  		// there is no way to override it back to off again.
   375  		result.PluginCacheMayBreakDependencyLockFile = true
   376  	}
   377  
   378  	if (len(c.Hosts) + len(c2.Hosts)) > 0 {
   379  		result.Hosts = make(map[string]*ConfigHost)
   380  		for name, host := range c.Hosts {
   381  			result.Hosts[name] = host
   382  		}
   383  		for name, host := range c2.Hosts {
   384  			result.Hosts[name] = host
   385  		}
   386  	}
   387  
   388  	if (len(c.Credentials) + len(c2.Credentials)) > 0 {
   389  		result.Credentials = make(map[string]map[string]interface{})
   390  		for host, creds := range c.Credentials {
   391  			result.Credentials[host] = creds
   392  		}
   393  		for host, creds := range c2.Credentials {
   394  			// We just clobber an entry from the other file right now. Will
   395  			// improve on this later using the more-robust merging behavior
   396  			// built in to HCL2.
   397  			result.Credentials[host] = creds
   398  		}
   399  	}
   400  
   401  	if (len(c.CredentialsHelpers) + len(c2.CredentialsHelpers)) > 0 {
   402  		result.CredentialsHelpers = make(map[string]*ConfigCredentialsHelper)
   403  		for name, helper := range c.CredentialsHelpers {
   404  			result.CredentialsHelpers[name] = helper
   405  		}
   406  		for name, helper := range c2.CredentialsHelpers {
   407  			result.CredentialsHelpers[name] = helper
   408  		}
   409  	}
   410  
   411  	if (len(c.ProviderInstallation) + len(c2.ProviderInstallation)) > 0 {
   412  		result.ProviderInstallation = append(result.ProviderInstallation, c.ProviderInstallation...)
   413  		result.ProviderInstallation = append(result.ProviderInstallation, c2.ProviderInstallation...)
   414  	}
   415  
   416  	return &result
   417  }
   418  
   419  func cliConfigFile() (string, tfdiags.Diagnostics) {
   420  	var diags tfdiags.Diagnostics
   421  	mustExist := true
   422  
   423  	configFilePath := cliConfigFileOverride()
   424  	if configFilePath == "" {
   425  		var err error
   426  		configFilePath, err = ConfigFile()
   427  		mustExist = false
   428  
   429  		if err != nil {
   430  			log.Printf(
   431  				"[ERROR] Error detecting default CLI config file path: %s",
   432  				err)
   433  		}
   434  	}
   435  
   436  	log.Printf("[DEBUG] Attempting to open CLI config file: %s", configFilePath)
   437  	f, err := os.Open(configFilePath)
   438  	if err == nil {
   439  		f.Close()
   440  		return configFilePath, diags
   441  	}
   442  
   443  	if mustExist || !errors.Is(err, fs.ErrNotExist) {
   444  		diags = append(diags, tfdiags.Sourceless(
   445  			tfdiags.Warning,
   446  			"Unable to open CLI configuration file",
   447  			fmt.Sprintf("The CLI configuration file at %q does not exist.", configFilePath),
   448  		))
   449  	}
   450  
   451  	log.Println("[DEBUG] File doesn't exist, but doesn't need to. Ignoring.")
   452  	return "", diags
   453  }
   454  
   455  func cliConfigFileOverride() string {
   456  	configFilePath := os.Getenv("TF_CLI_CONFIG_FILE")
   457  	if configFilePath == "" {
   458  		configFilePath = os.Getenv("TERRAFORM_CONFIG")
   459  	}
   460  	return configFilePath
   461  }