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 }