github.com/terramate-io/tf@v0.0.0-20230830114523-fce866b4dfcd/main.go (about) 1 // Copyright (c) HashiCorp, Inc. 2 // SPDX-License-Identifier: MPL-2.0 3 4 package main 5 6 import ( 7 "context" 8 "encoding/json" 9 "fmt" 10 "log" 11 "net" 12 "os" 13 "path/filepath" 14 "runtime" 15 "strings" 16 17 "github.com/apparentlymart/go-shquot/shquot" 18 "github.com/hashicorp/go-plugin" 19 "github.com/hashicorp/terraform-svchost/disco" 20 "github.com/mattn/go-shellwords" 21 "github.com/mitchellh/cli" 22 "github.com/mitchellh/colorstring" 23 "github.com/terramate-io/tf/addrs" 24 "github.com/terramate-io/tf/command/cliconfig" 25 "github.com/terramate-io/tf/command/format" 26 "github.com/terramate-io/tf/didyoumean" 27 "github.com/terramate-io/tf/httpclient" 28 "github.com/terramate-io/tf/logging" 29 "github.com/terramate-io/tf/terminal" 30 "github.com/terramate-io/tf/version" 31 "go.opentelemetry.io/otel/trace" 32 33 backendInit "github.com/terramate-io/tf/backend/init" 34 ) 35 36 const ( 37 // EnvCLI is the environment variable name to set additional CLI args. 38 EnvCLI = "TF_CLI_ARGS" 39 40 // The parent process will create a file to collect crash logs 41 envTmpLogPath = "TF_TEMP_LOG_PATH" 42 ) 43 44 // ui wraps the primary output cli.Ui, and redirects Warn calls to Output 45 // calls. This ensures that warnings are sent to stdout, and are properly 46 // serialized within the stdout stream. 47 type ui struct { 48 cli.Ui 49 } 50 51 func (u *ui) Warn(msg string) { 52 u.Ui.Output(msg) 53 } 54 55 func init() { 56 Ui = &ui{&cli.BasicUi{ 57 Writer: os.Stdout, 58 ErrorWriter: os.Stderr, 59 Reader: os.Stdin, 60 }} 61 } 62 63 func main() { 64 os.Exit(realMain()) 65 } 66 67 func realMain() int { 68 defer logging.PanicHandler() 69 70 var err error 71 72 err = openTelemetryInit() 73 if err != nil { 74 // openTelemetryInit can only fail if Terraform was run with an 75 // explicit environment variable to enable telemetry collection, 76 // so in typical use we cannot get here. 77 Ui.Error(fmt.Sprintf("Could not initialize telemetry: %s", err)) 78 Ui.Error(fmt.Sprintf("Unset environment variable %s if you don't intend to collect telemetry from Terraform.", openTelemetryExporterEnvVar)) 79 return 1 80 } 81 var ctx context.Context 82 var otelSpan trace.Span 83 { 84 // At minimum we emit a span covering the entire command execution. 85 _, displayArgs := shquot.POSIXShellSplit(os.Args) 86 ctx, otelSpan = tracer.Start(context.Background(), fmt.Sprintf("terraform %s", displayArgs)) 87 defer otelSpan.End() 88 } 89 90 tmpLogPath := os.Getenv(envTmpLogPath) 91 if tmpLogPath != "" { 92 f, err := os.OpenFile(tmpLogPath, os.O_RDWR|os.O_APPEND, 0666) 93 if err == nil { 94 defer f.Close() 95 96 log.Printf("[DEBUG] Adding temp file log sink: %s", f.Name()) 97 logging.RegisterSink(f) 98 } else { 99 log.Printf("[ERROR] Could not open temp log file: %v", err) 100 } 101 } 102 103 log.Printf( 104 "[INFO] Terraform version: %s %s", 105 Version, VersionPrerelease) 106 for _, depMod := range version.InterestingDependencies() { 107 log.Printf("[DEBUG] using %s %s", depMod.Path, depMod.Version) 108 } 109 log.Printf("[INFO] Go runtime version: %s", runtime.Version()) 110 log.Printf("[INFO] CLI args: %#v", os.Args) 111 if ExperimentsAllowed() { 112 log.Printf("[INFO] This build of Terraform allows using experimental features") 113 } 114 115 streams, err := terminal.Init() 116 if err != nil { 117 Ui.Error(fmt.Sprintf("Failed to configure the terminal: %s", err)) 118 return 1 119 } 120 if streams.Stdout.IsTerminal() { 121 log.Printf("[TRACE] Stdout is a terminal of width %d", streams.Stdout.Columns()) 122 } else { 123 log.Printf("[TRACE] Stdout is not a terminal") 124 } 125 if streams.Stderr.IsTerminal() { 126 log.Printf("[TRACE] Stderr is a terminal of width %d", streams.Stderr.Columns()) 127 } else { 128 log.Printf("[TRACE] Stderr is not a terminal") 129 } 130 if streams.Stdin.IsTerminal() { 131 log.Printf("[TRACE] Stdin is a terminal") 132 } else { 133 log.Printf("[TRACE] Stdin is not a terminal") 134 } 135 136 // NOTE: We're intentionally calling LoadConfig _before_ handling a possible 137 // -chdir=... option on the command line, so that a possible relative 138 // path in the TERRAFORM_CONFIG_FILE environment variable (though probably 139 // ill-advised) will be resolved relative to the true working directory, 140 // not the overridden one. 141 config, diags := cliconfig.LoadConfig() 142 143 if len(diags) > 0 { 144 // Since we haven't instantiated a command.Meta yet, we need to do 145 // some things manually here and use some "safe" defaults for things 146 // that command.Meta could otherwise figure out in smarter ways. 147 Ui.Error("There are some problems with the CLI configuration:") 148 for _, diag := range diags { 149 earlyColor := &colorstring.Colorize{ 150 Colors: colorstring.DefaultColors, 151 Disable: true, // Disable color to be conservative until we know better 152 Reset: true, 153 } 154 // We don't currently have access to the source code cache for 155 // the parser used to load the CLI config, so we can't show 156 // source code snippets in early diagnostics. 157 Ui.Error(format.Diagnostic(diag, nil, earlyColor, 78)) 158 } 159 if diags.HasErrors() { 160 Ui.Error("As a result of the above problems, Terraform may not behave as intended.\n\n") 161 // We continue to run anyway, since Terraform has reasonable defaults. 162 } 163 } 164 165 // Get any configured credentials from the config and initialize 166 // a service discovery object. The slightly awkward predeclaration of 167 // disco is required to allow us to pass untyped nil as the creds source 168 // when creating the source fails. Otherwise we pass a typed nil which 169 // breaks the nil checks in the disco object 170 var services *disco.Disco 171 credsSrc, err := credentialsSource(config) 172 if err == nil { 173 services = disco.NewWithCredentialsSource(credsSrc) 174 } else { 175 // Most commands don't actually need credentials, and most situations 176 // that would get us here would already have been reported by the config 177 // loading above, so we'll just log this one as an aid to debugging 178 // in the unlikely event that it _does_ arise. 179 log.Printf("[WARN] Cannot initialize remote host credentials manager: %s", err) 180 // passing (untyped) nil as the creds source is okay because the disco 181 // object checks that and just acts as though no credentials are present. 182 services = disco.NewWithCredentialsSource(nil) 183 } 184 services.SetUserAgent(httpclient.TerraformUserAgent(version.String())) 185 186 providerSrc, diags := providerSource(config.ProviderInstallation, services) 187 if len(diags) > 0 { 188 Ui.Error("There are some problems with the provider_installation configuration:") 189 for _, diag := range diags { 190 earlyColor := &colorstring.Colorize{ 191 Colors: colorstring.DefaultColors, 192 Disable: true, // Disable color to be conservative until we know better 193 Reset: true, 194 } 195 Ui.Error(format.Diagnostic(diag, nil, earlyColor, 78)) 196 } 197 if diags.HasErrors() { 198 Ui.Error("As a result of the above problems, Terraform's provider installer may not behave as intended.\n\n") 199 // We continue to run anyway, because most commands don't do provider installation. 200 } 201 } 202 providerDevOverrides := providerDevOverrides(config.ProviderInstallation) 203 204 // The user can declare that certain providers are being managed on 205 // Terraform's behalf using this environment variable. This is used 206 // primarily by the SDK's acceptance testing framework. 207 unmanagedProviders, err := parseReattachProviders(os.Getenv("TF_REATTACH_PROVIDERS")) 208 if err != nil { 209 Ui.Error(err.Error()) 210 return 1 211 } 212 213 // Initialize the backends. 214 backendInit.Init(services) 215 216 // Get the command line args. 217 binName := filepath.Base(os.Args[0]) 218 args := os.Args[1:] 219 220 originalWd, err := os.Getwd() 221 if err != nil { 222 // It would be very strange to end up here 223 Ui.Error(fmt.Sprintf("Failed to determine current working directory: %s", err)) 224 return 1 225 } 226 227 // The arguments can begin with a -chdir option to ask Terraform to switch 228 // to a different working directory for the rest of its work. If that 229 // option is present then extractChdirOption returns a trimmed args with that option removed. 230 overrideWd, args, err := extractChdirOption(args) 231 if err != nil { 232 Ui.Error(fmt.Sprintf("Invalid -chdir option: %s", err)) 233 return 1 234 } 235 if overrideWd != "" { 236 err := os.Chdir(overrideWd) 237 if err != nil { 238 Ui.Error(fmt.Sprintf("Error handling -chdir option: %s", err)) 239 return 1 240 } 241 } 242 243 // In tests, Commands may already be set to provide mock commands 244 if Commands == nil { 245 // Commands get to hold on to the original working directory here, 246 // in case they need to refer back to it for any special reason, though 247 // they should primarily be working with the override working directory 248 // that we've now switched to above. 249 initCommands(ctx, originalWd, streams, config, services, providerSrc, providerDevOverrides, unmanagedProviders) 250 } 251 252 // Run checkpoint 253 go runCheckpoint(ctx, config) 254 255 // Make sure we clean up any managed plugins at the end of this 256 defer plugin.CleanupClients() 257 258 // Build the CLI so far, we do this so we can query the subcommand. 259 cliRunner := &cli.CLI{ 260 Args: args, 261 Commands: Commands, 262 HelpFunc: helpFunc, 263 HelpWriter: os.Stdout, 264 } 265 266 // Prefix the args with any args from the EnvCLI 267 args, err = mergeEnvArgs(EnvCLI, cliRunner.Subcommand(), args) 268 if err != nil { 269 Ui.Error(err.Error()) 270 return 1 271 } 272 273 // Prefix the args with any args from the EnvCLI targeting this command 274 suffix := strings.Replace(strings.Replace( 275 cliRunner.Subcommand(), "-", "_", -1), " ", "_", -1) 276 args, err = mergeEnvArgs( 277 fmt.Sprintf("%s_%s", EnvCLI, suffix), cliRunner.Subcommand(), args) 278 if err != nil { 279 Ui.Error(err.Error()) 280 return 1 281 } 282 283 // We shortcut "--version" and "-v" to just show the version 284 for _, arg := range args { 285 if arg == "-v" || arg == "-version" || arg == "--version" { 286 newArgs := make([]string, len(args)+1) 287 newArgs[0] = "version" 288 copy(newArgs[1:], args) 289 args = newArgs 290 break 291 } 292 } 293 294 // Rebuild the CLI with any modified args. 295 log.Printf("[INFO] CLI command args: %#v", args) 296 cliRunner = &cli.CLI{ 297 Name: binName, 298 Args: args, 299 Commands: Commands, 300 HelpFunc: helpFunc, 301 HelpWriter: os.Stdout, 302 303 Autocomplete: true, 304 AutocompleteInstall: "install-autocomplete", 305 AutocompleteUninstall: "uninstall-autocomplete", 306 } 307 308 // Before we continue we'll check whether the requested command is 309 // actually known. If not, we might be able to suggest an alternative 310 // if it seems like the user made a typo. 311 // (This bypasses the built-in help handling in cli.CLI for the situation 312 // where a command isn't found, because it's likely more helpful to 313 // mention what specifically went wrong, rather than just printing out 314 // a big block of usage information.) 315 316 // Check if this is being run via shell auto-complete, which uses the 317 // binary name as the first argument and won't be listed as a subcommand. 318 autoComplete := os.Getenv("COMP_LINE") != "" 319 320 if cmd := cliRunner.Subcommand(); cmd != "" && !autoComplete { 321 // Due to the design of cli.CLI, this special error message only works 322 // for typos of top-level commands. For a subcommand typo, like 323 // "terraform state posh", cmd would be "state" here and thus would 324 // be considered to exist, and it would print out its own usage message. 325 if _, exists := Commands[cmd]; !exists { 326 suggestions := make([]string, 0, len(Commands)) 327 for name := range Commands { 328 suggestions = append(suggestions, name) 329 } 330 suggestion := didyoumean.NameSuggestion(cmd, suggestions) 331 if suggestion != "" { 332 suggestion = fmt.Sprintf(" Did you mean %q?", suggestion) 333 } 334 fmt.Fprintf(os.Stderr, "Terraform has no command named %q.%s\n\nTo see all of Terraform's top-level commands, run:\n terraform -help\n\n", cmd, suggestion) 335 return 1 336 } 337 } 338 339 exitCode, err := cliRunner.Run() 340 if err != nil { 341 Ui.Error(fmt.Sprintf("Error executing CLI: %s", err.Error())) 342 return 1 343 } 344 345 // if we are exiting with a non-zero code, check if it was caused by any 346 // plugins crashing 347 if exitCode != 0 { 348 for _, panicLog := range logging.PluginPanics() { 349 Ui.Error(panicLog) 350 } 351 } 352 353 return exitCode 354 } 355 356 func mergeEnvArgs(envName string, cmd string, args []string) ([]string, error) { 357 v := os.Getenv(envName) 358 if v == "" { 359 return args, nil 360 } 361 362 log.Printf("[INFO] %s value: %q", envName, v) 363 extra, err := shellwords.Parse(v) 364 if err != nil { 365 return nil, fmt.Errorf( 366 "Error parsing extra CLI args from %s: %s", 367 envName, err) 368 } 369 370 // Find the command to look for in the args. If there is a space, 371 // we need to find the last part. 372 search := cmd 373 if idx := strings.LastIndex(search, " "); idx >= 0 { 374 search = cmd[idx+1:] 375 } 376 377 // Find the index to place the flags. We put them exactly 378 // after the first non-flag arg. 379 idx := -1 380 for i, v := range args { 381 if v == search { 382 idx = i 383 break 384 } 385 } 386 387 // idx points to the exact arg that isn't a flag. We increment 388 // by one so that all the copying below expects idx to be the 389 // insertion point. 390 idx++ 391 392 // Copy the args 393 newArgs := make([]string, len(args)+len(extra)) 394 copy(newArgs, args[:idx]) 395 copy(newArgs[idx:], extra) 396 copy(newArgs[len(extra)+idx:], args[idx:]) 397 return newArgs, nil 398 } 399 400 // parse information on reattaching to unmanaged providers out of a 401 // JSON-encoded environment variable. 402 func parseReattachProviders(in string) (map[addrs.Provider]*plugin.ReattachConfig, error) { 403 unmanagedProviders := map[addrs.Provider]*plugin.ReattachConfig{} 404 if in != "" { 405 type reattachConfig struct { 406 Protocol string 407 ProtocolVersion int 408 Addr struct { 409 Network string 410 String string 411 } 412 Pid int 413 Test bool 414 } 415 var m map[string]reattachConfig 416 err := json.Unmarshal([]byte(in), &m) 417 if err != nil { 418 return unmanagedProviders, fmt.Errorf("Invalid format for TF_REATTACH_PROVIDERS: %w", err) 419 } 420 for p, c := range m { 421 a, diags := addrs.ParseProviderSourceString(p) 422 if diags.HasErrors() { 423 return unmanagedProviders, fmt.Errorf("Error parsing %q as a provider address: %w", a, diags.Err()) 424 } 425 var addr net.Addr 426 switch c.Addr.Network { 427 case "unix": 428 addr, err = net.ResolveUnixAddr("unix", c.Addr.String) 429 if err != nil { 430 return unmanagedProviders, fmt.Errorf("Invalid unix socket path %q for %q: %w", c.Addr.String, p, err) 431 } 432 case "tcp": 433 addr, err = net.ResolveTCPAddr("tcp", c.Addr.String) 434 if err != nil { 435 return unmanagedProviders, fmt.Errorf("Invalid TCP address %q for %q: %w", c.Addr.String, p, err) 436 } 437 default: 438 return unmanagedProviders, fmt.Errorf("Unknown address type %q for %q", c.Addr.Network, p) 439 } 440 unmanagedProviders[a] = &plugin.ReattachConfig{ 441 Protocol: plugin.Protocol(c.Protocol), 442 ProtocolVersion: c.ProtocolVersion, 443 Pid: c.Pid, 444 Test: c.Test, 445 Addr: addr, 446 } 447 } 448 } 449 return unmanagedProviders, nil 450 } 451 452 func extractChdirOption(args []string) (string, []string, error) { 453 if len(args) == 0 { 454 return "", args, nil 455 } 456 457 const argName = "-chdir" 458 const argPrefix = argName + "=" 459 var argValue string 460 var argPos int 461 462 for i, arg := range args { 463 if !strings.HasPrefix(arg, "-") { 464 // Because the chdir option is a subcommand-agnostic one, we require 465 // it to appear before any subcommand argument, so if we find a 466 // non-option before we find -chdir then we are finished. 467 break 468 } 469 if arg == argName || arg == argPrefix { 470 return "", args, fmt.Errorf("must include an equals sign followed by a directory path, like -chdir=example") 471 } 472 if strings.HasPrefix(arg, argPrefix) { 473 argPos = i 474 argValue = arg[len(argPrefix):] 475 } 476 } 477 478 // When we fall out here, we'll have populated argValue with a non-empty 479 // string if the -chdir=... option was present and valid, or left it 480 // empty if it wasn't present. 481 if argValue == "" { 482 return "", args, nil 483 } 484 485 // If we did find the option then we'll need to produce a new args that 486 // doesn't include it anymore. 487 if argPos == 0 { 488 // Easy case: we can just slice off the front 489 return argValue, args[1:], nil 490 } 491 // Otherwise we need to construct a new array and copy to it. 492 newArgs := make([]string, len(args)-1) 493 copy(newArgs, args[:argPos]) 494 copy(newArgs[argPos:], args[argPos+1:]) 495 return argValue, newArgs, nil 496 }