mirror of
https://github.com/opentofu/opentofu.git
synced 2024-12-26 00:41:27 -06:00
7bee77bdd3
Several times over the years we've considered adding tracing instrumentation to Terraform, since even when running in isolation as a CLI program it has a "distributed system-like" structure, with lots of concurrent internal work and also some work delegated to provider plugins that are essentially temporarily-running microservices. However, it's always felt a bit overwhelming to do it because much of Terraform predates the Go context.Context idiom and so it's tough to get a clean chain of context.Context values all the way down the stack without disturbing a lot of existing APIs. This commit aims to just get that process started by establishing how a context can propagate from "package main" into the command package, focusing initially on "terraform init" and some other commands that share some underlying functions with that command. OpenTelemetry has emerged as a de-facto industry standard and so this uses its API directly, without any attempt to hide it behind an abstraction. The OpenTelemetry API is itself already an adapter layer, so we should be able to swap in any backend that uses comparable concepts. For now we just discard the tracing reports by default, and allow users to opt in to delivering traces over OTLP by setting an environment variable when running Terraform (the environment variable was established in an earlier commit, so this commit builds on that.) When tracing collection is enabled, every Terraform CLI run will generate at least one overall span representing the command that was run. Some commands might also create child spans, but most currently do not.
497 lines
16 KiB
Go
497 lines
16 KiB
Go
// Copyright (c) HashiCorp, Inc.
|
|
// SPDX-License-Identifier: MPL-2.0
|
|
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
|
|
"github.com/apparentlymart/go-shquot/shquot"
|
|
"github.com/hashicorp/go-plugin"
|
|
"github.com/hashicorp/terraform-svchost/disco"
|
|
"github.com/hashicorp/terraform/internal/addrs"
|
|
"github.com/hashicorp/terraform/internal/command/cliconfig"
|
|
"github.com/hashicorp/terraform/internal/command/format"
|
|
"github.com/hashicorp/terraform/internal/didyoumean"
|
|
"github.com/hashicorp/terraform/internal/httpclient"
|
|
"github.com/hashicorp/terraform/internal/logging"
|
|
"github.com/hashicorp/terraform/internal/terminal"
|
|
"github.com/hashicorp/terraform/version"
|
|
"github.com/mattn/go-shellwords"
|
|
"github.com/mitchellh/cli"
|
|
"github.com/mitchellh/colorstring"
|
|
"go.opentelemetry.io/otel/trace"
|
|
|
|
backendInit "github.com/hashicorp/terraform/internal/backend/init"
|
|
)
|
|
|
|
const (
|
|
// EnvCLI is the environment variable name to set additional CLI args.
|
|
EnvCLI = "TF_CLI_ARGS"
|
|
|
|
// The parent process will create a file to collect crash logs
|
|
envTmpLogPath = "TF_TEMP_LOG_PATH"
|
|
)
|
|
|
|
// ui wraps the primary output cli.Ui, and redirects Warn calls to Output
|
|
// calls. This ensures that warnings are sent to stdout, and are properly
|
|
// serialized within the stdout stream.
|
|
type ui struct {
|
|
cli.Ui
|
|
}
|
|
|
|
func (u *ui) Warn(msg string) {
|
|
u.Ui.Output(msg)
|
|
}
|
|
|
|
func init() {
|
|
Ui = &ui{&cli.BasicUi{
|
|
Writer: os.Stdout,
|
|
ErrorWriter: os.Stderr,
|
|
Reader: os.Stdin,
|
|
}}
|
|
}
|
|
|
|
func main() {
|
|
os.Exit(realMain())
|
|
}
|
|
|
|
func realMain() int {
|
|
defer logging.PanicHandler()
|
|
|
|
var err error
|
|
|
|
err = openTelemetryInit()
|
|
if err != nil {
|
|
// openTelemetryInit can only fail if Terraform was run with an
|
|
// explicit environment variable to enable telemetry collection,
|
|
// so in typical use we cannot get here.
|
|
Ui.Error(fmt.Sprintf("Could not initialize telemetry: %s", err))
|
|
Ui.Error(fmt.Sprintf("Unset environment variable %s if you don't intend to collect telemetry from Terraform.", openTelemetryExporterEnvVar))
|
|
return 1
|
|
}
|
|
var ctx context.Context
|
|
var otelSpan trace.Span
|
|
{
|
|
// At minimum we emit a span covering the entire command execution.
|
|
_, displayArgs := shquot.POSIXShellSplit(os.Args)
|
|
ctx, otelSpan = tracer.Start(context.Background(), fmt.Sprintf("terraform %s", displayArgs))
|
|
defer otelSpan.End()
|
|
}
|
|
|
|
tmpLogPath := os.Getenv(envTmpLogPath)
|
|
if tmpLogPath != "" {
|
|
f, err := os.OpenFile(tmpLogPath, os.O_RDWR|os.O_APPEND, 0666)
|
|
if err == nil {
|
|
defer f.Close()
|
|
|
|
log.Printf("[DEBUG] Adding temp file log sink: %s", f.Name())
|
|
logging.RegisterSink(f)
|
|
} else {
|
|
log.Printf("[ERROR] Could not open temp log file: %v", err)
|
|
}
|
|
}
|
|
|
|
log.Printf(
|
|
"[INFO] Terraform version: %s %s",
|
|
Version, VersionPrerelease)
|
|
for _, depMod := range version.InterestingDependencies() {
|
|
log.Printf("[DEBUG] using %s %s", depMod.Path, depMod.Version)
|
|
}
|
|
log.Printf("[INFO] Go runtime version: %s", runtime.Version())
|
|
log.Printf("[INFO] CLI args: %#v", os.Args)
|
|
if ExperimentsAllowed() {
|
|
log.Printf("[INFO] This build of Terraform allows using experimental features")
|
|
}
|
|
|
|
streams, err := terminal.Init()
|
|
if err != nil {
|
|
Ui.Error(fmt.Sprintf("Failed to configure the terminal: %s", err))
|
|
return 1
|
|
}
|
|
if streams.Stdout.IsTerminal() {
|
|
log.Printf("[TRACE] Stdout is a terminal of width %d", streams.Stdout.Columns())
|
|
} else {
|
|
log.Printf("[TRACE] Stdout is not a terminal")
|
|
}
|
|
if streams.Stderr.IsTerminal() {
|
|
log.Printf("[TRACE] Stderr is a terminal of width %d", streams.Stderr.Columns())
|
|
} else {
|
|
log.Printf("[TRACE] Stderr is not a terminal")
|
|
}
|
|
if streams.Stdin.IsTerminal() {
|
|
log.Printf("[TRACE] Stdin is a terminal")
|
|
} else {
|
|
log.Printf("[TRACE] Stdin is not a terminal")
|
|
}
|
|
|
|
// NOTE: We're intentionally calling LoadConfig _before_ handling a possible
|
|
// -chdir=... option on the command line, so that a possible relative
|
|
// path in the TERRAFORM_CONFIG_FILE environment variable (though probably
|
|
// ill-advised) will be resolved relative to the true working directory,
|
|
// not the overridden one.
|
|
config, diags := cliconfig.LoadConfig()
|
|
|
|
if len(diags) > 0 {
|
|
// Since we haven't instantiated a command.Meta yet, we need to do
|
|
// some things manually here and use some "safe" defaults for things
|
|
// that command.Meta could otherwise figure out in smarter ways.
|
|
Ui.Error("There are some problems with the CLI configuration:")
|
|
for _, diag := range diags {
|
|
earlyColor := &colorstring.Colorize{
|
|
Colors: colorstring.DefaultColors,
|
|
Disable: true, // Disable color to be conservative until we know better
|
|
Reset: true,
|
|
}
|
|
// We don't currently have access to the source code cache for
|
|
// the parser used to load the CLI config, so we can't show
|
|
// source code snippets in early diagnostics.
|
|
Ui.Error(format.Diagnostic(diag, nil, earlyColor, 78))
|
|
}
|
|
if diags.HasErrors() {
|
|
Ui.Error("As a result of the above problems, Terraform may not behave as intended.\n\n")
|
|
// We continue to run anyway, since Terraform has reasonable defaults.
|
|
}
|
|
}
|
|
|
|
// Get any configured credentials from the config and initialize
|
|
// a service discovery object. The slightly awkward predeclaration of
|
|
// disco is required to allow us to pass untyped nil as the creds source
|
|
// when creating the source fails. Otherwise we pass a typed nil which
|
|
// breaks the nil checks in the disco object
|
|
var services *disco.Disco
|
|
credsSrc, err := credentialsSource(config)
|
|
if err == nil {
|
|
services = disco.NewWithCredentialsSource(credsSrc)
|
|
} else {
|
|
// Most commands don't actually need credentials, and most situations
|
|
// that would get us here would already have been reported by the config
|
|
// loading above, so we'll just log this one as an aid to debugging
|
|
// in the unlikely event that it _does_ arise.
|
|
log.Printf("[WARN] Cannot initialize remote host credentials manager: %s", err)
|
|
// passing (untyped) nil as the creds source is okay because the disco
|
|
// object checks that and just acts as though no credentials are present.
|
|
services = disco.NewWithCredentialsSource(nil)
|
|
}
|
|
services.SetUserAgent(httpclient.TerraformUserAgent(version.String()))
|
|
|
|
providerSrc, diags := providerSource(config.ProviderInstallation, services)
|
|
if len(diags) > 0 {
|
|
Ui.Error("There are some problems with the provider_installation configuration:")
|
|
for _, diag := range diags {
|
|
earlyColor := &colorstring.Colorize{
|
|
Colors: colorstring.DefaultColors,
|
|
Disable: true, // Disable color to be conservative until we know better
|
|
Reset: true,
|
|
}
|
|
Ui.Error(format.Diagnostic(diag, nil, earlyColor, 78))
|
|
}
|
|
if diags.HasErrors() {
|
|
Ui.Error("As a result of the above problems, Terraform's provider installer may not behave as intended.\n\n")
|
|
// We continue to run anyway, because most commands don't do provider installation.
|
|
}
|
|
}
|
|
providerDevOverrides := providerDevOverrides(config.ProviderInstallation)
|
|
|
|
// The user can declare that certain providers are being managed on
|
|
// Terraform's behalf using this environment variable. This is used
|
|
// primarily by the SDK's acceptance testing framework.
|
|
unmanagedProviders, err := parseReattachProviders(os.Getenv("TF_REATTACH_PROVIDERS"))
|
|
if err != nil {
|
|
Ui.Error(err.Error())
|
|
return 1
|
|
}
|
|
|
|
// Initialize the backends.
|
|
backendInit.Init(services)
|
|
|
|
// Get the command line args.
|
|
binName := filepath.Base(os.Args[0])
|
|
args := os.Args[1:]
|
|
|
|
originalWd, err := os.Getwd()
|
|
if err != nil {
|
|
// It would be very strange to end up here
|
|
Ui.Error(fmt.Sprintf("Failed to determine current working directory: %s", err))
|
|
return 1
|
|
}
|
|
|
|
// The arguments can begin with a -chdir option to ask Terraform to switch
|
|
// to a different working directory for the rest of its work. If that
|
|
// option is present then extractChdirOption returns a trimmed args with that option removed.
|
|
overrideWd, args, err := extractChdirOption(args)
|
|
if err != nil {
|
|
Ui.Error(fmt.Sprintf("Invalid -chdir option: %s", err))
|
|
return 1
|
|
}
|
|
if overrideWd != "" {
|
|
err := os.Chdir(overrideWd)
|
|
if err != nil {
|
|
Ui.Error(fmt.Sprintf("Error handling -chdir option: %s", err))
|
|
return 1
|
|
}
|
|
}
|
|
|
|
// In tests, Commands may already be set to provide mock commands
|
|
if Commands == nil {
|
|
// Commands get to hold on to the original working directory here,
|
|
// in case they need to refer back to it for any special reason, though
|
|
// they should primarily be working with the override working directory
|
|
// that we've now switched to above.
|
|
initCommands(ctx, originalWd, streams, config, services, providerSrc, providerDevOverrides, unmanagedProviders)
|
|
}
|
|
|
|
// Run checkpoint
|
|
go runCheckpoint(ctx, config)
|
|
|
|
// Make sure we clean up any managed plugins at the end of this
|
|
defer plugin.CleanupClients()
|
|
|
|
// Build the CLI so far, we do this so we can query the subcommand.
|
|
cliRunner := &cli.CLI{
|
|
Args: args,
|
|
Commands: Commands,
|
|
HelpFunc: helpFunc,
|
|
HelpWriter: os.Stdout,
|
|
}
|
|
|
|
// Prefix the args with any args from the EnvCLI
|
|
args, err = mergeEnvArgs(EnvCLI, cliRunner.Subcommand(), args)
|
|
if err != nil {
|
|
Ui.Error(err.Error())
|
|
return 1
|
|
}
|
|
|
|
// Prefix the args with any args from the EnvCLI targeting this command
|
|
suffix := strings.Replace(strings.Replace(
|
|
cliRunner.Subcommand(), "-", "_", -1), " ", "_", -1)
|
|
args, err = mergeEnvArgs(
|
|
fmt.Sprintf("%s_%s", EnvCLI, suffix), cliRunner.Subcommand(), args)
|
|
if err != nil {
|
|
Ui.Error(err.Error())
|
|
return 1
|
|
}
|
|
|
|
// We shortcut "--version" and "-v" to just show the version
|
|
for _, arg := range args {
|
|
if arg == "-v" || arg == "-version" || arg == "--version" {
|
|
newArgs := make([]string, len(args)+1)
|
|
newArgs[0] = "version"
|
|
copy(newArgs[1:], args)
|
|
args = newArgs
|
|
break
|
|
}
|
|
}
|
|
|
|
// Rebuild the CLI with any modified args.
|
|
log.Printf("[INFO] CLI command args: %#v", args)
|
|
cliRunner = &cli.CLI{
|
|
Name: binName,
|
|
Args: args,
|
|
Commands: Commands,
|
|
HelpFunc: helpFunc,
|
|
HelpWriter: os.Stdout,
|
|
|
|
Autocomplete: true,
|
|
AutocompleteInstall: "install-autocomplete",
|
|
AutocompleteUninstall: "uninstall-autocomplete",
|
|
}
|
|
|
|
// Before we continue we'll check whether the requested command is
|
|
// actually known. If not, we might be able to suggest an alternative
|
|
// if it seems like the user made a typo.
|
|
// (This bypasses the built-in help handling in cli.CLI for the situation
|
|
// where a command isn't found, because it's likely more helpful to
|
|
// mention what specifically went wrong, rather than just printing out
|
|
// a big block of usage information.)
|
|
|
|
// Check if this is being run via shell auto-complete, which uses the
|
|
// binary name as the first argument and won't be listed as a subcommand.
|
|
autoComplete := os.Getenv("COMP_LINE") != ""
|
|
|
|
if cmd := cliRunner.Subcommand(); cmd != "" && !autoComplete {
|
|
// Due to the design of cli.CLI, this special error message only works
|
|
// for typos of top-level commands. For a subcommand typo, like
|
|
// "terraform state posh", cmd would be "state" here and thus would
|
|
// be considered to exist, and it would print out its own usage message.
|
|
if _, exists := Commands[cmd]; !exists {
|
|
suggestions := make([]string, 0, len(Commands))
|
|
for name := range Commands {
|
|
suggestions = append(suggestions, name)
|
|
}
|
|
suggestion := didyoumean.NameSuggestion(cmd, suggestions)
|
|
if suggestion != "" {
|
|
suggestion = fmt.Sprintf(" Did you mean %q?", suggestion)
|
|
}
|
|
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)
|
|
return 1
|
|
}
|
|
}
|
|
|
|
exitCode, err := cliRunner.Run()
|
|
if err != nil {
|
|
Ui.Error(fmt.Sprintf("Error executing CLI: %s", err.Error()))
|
|
return 1
|
|
}
|
|
|
|
// if we are exiting with a non-zero code, check if it was caused by any
|
|
// plugins crashing
|
|
if exitCode != 0 {
|
|
for _, panicLog := range logging.PluginPanics() {
|
|
Ui.Error(panicLog)
|
|
}
|
|
}
|
|
|
|
return exitCode
|
|
}
|
|
|
|
func mergeEnvArgs(envName string, cmd string, args []string) ([]string, error) {
|
|
v := os.Getenv(envName)
|
|
if v == "" {
|
|
return args, nil
|
|
}
|
|
|
|
log.Printf("[INFO] %s value: %q", envName, v)
|
|
extra, err := shellwords.Parse(v)
|
|
if err != nil {
|
|
return nil, fmt.Errorf(
|
|
"Error parsing extra CLI args from %s: %s",
|
|
envName, err)
|
|
}
|
|
|
|
// Find the command to look for in the args. If there is a space,
|
|
// we need to find the last part.
|
|
search := cmd
|
|
if idx := strings.LastIndex(search, " "); idx >= 0 {
|
|
search = cmd[idx+1:]
|
|
}
|
|
|
|
// Find the index to place the flags. We put them exactly
|
|
// after the first non-flag arg.
|
|
idx := -1
|
|
for i, v := range args {
|
|
if v == search {
|
|
idx = i
|
|
break
|
|
}
|
|
}
|
|
|
|
// idx points to the exact arg that isn't a flag. We increment
|
|
// by one so that all the copying below expects idx to be the
|
|
// insertion point.
|
|
idx++
|
|
|
|
// Copy the args
|
|
newArgs := make([]string, len(args)+len(extra))
|
|
copy(newArgs, args[:idx])
|
|
copy(newArgs[idx:], extra)
|
|
copy(newArgs[len(extra)+idx:], args[idx:])
|
|
return newArgs, nil
|
|
}
|
|
|
|
// parse information on reattaching to unmanaged providers out of a
|
|
// JSON-encoded environment variable.
|
|
func parseReattachProviders(in string) (map[addrs.Provider]*plugin.ReattachConfig, error) {
|
|
unmanagedProviders := map[addrs.Provider]*plugin.ReattachConfig{}
|
|
if in != "" {
|
|
type reattachConfig struct {
|
|
Protocol string
|
|
ProtocolVersion int
|
|
Addr struct {
|
|
Network string
|
|
String string
|
|
}
|
|
Pid int
|
|
Test bool
|
|
}
|
|
var m map[string]reattachConfig
|
|
err := json.Unmarshal([]byte(in), &m)
|
|
if err != nil {
|
|
return unmanagedProviders, fmt.Errorf("Invalid format for TF_REATTACH_PROVIDERS: %w", err)
|
|
}
|
|
for p, c := range m {
|
|
a, diags := addrs.ParseProviderSourceString(p)
|
|
if diags.HasErrors() {
|
|
return unmanagedProviders, fmt.Errorf("Error parsing %q as a provider address: %w", a, diags.Err())
|
|
}
|
|
var addr net.Addr
|
|
switch c.Addr.Network {
|
|
case "unix":
|
|
addr, err = net.ResolveUnixAddr("unix", c.Addr.String)
|
|
if err != nil {
|
|
return unmanagedProviders, fmt.Errorf("Invalid unix socket path %q for %q: %w", c.Addr.String, p, err)
|
|
}
|
|
case "tcp":
|
|
addr, err = net.ResolveTCPAddr("tcp", c.Addr.String)
|
|
if err != nil {
|
|
return unmanagedProviders, fmt.Errorf("Invalid TCP address %q for %q: %w", c.Addr.String, p, err)
|
|
}
|
|
default:
|
|
return unmanagedProviders, fmt.Errorf("Unknown address type %q for %q", c.Addr.Network, p)
|
|
}
|
|
unmanagedProviders[a] = &plugin.ReattachConfig{
|
|
Protocol: plugin.Protocol(c.Protocol),
|
|
ProtocolVersion: c.ProtocolVersion,
|
|
Pid: c.Pid,
|
|
Test: c.Test,
|
|
Addr: addr,
|
|
}
|
|
}
|
|
}
|
|
return unmanagedProviders, nil
|
|
}
|
|
|
|
func extractChdirOption(args []string) (string, []string, error) {
|
|
if len(args) == 0 {
|
|
return "", args, nil
|
|
}
|
|
|
|
const argName = "-chdir"
|
|
const argPrefix = argName + "="
|
|
var argValue string
|
|
var argPos int
|
|
|
|
for i, arg := range args {
|
|
if !strings.HasPrefix(arg, "-") {
|
|
// Because the chdir option is a subcommand-agnostic one, we require
|
|
// it to appear before any subcommand argument, so if we find a
|
|
// non-option before we find -chdir then we are finished.
|
|
break
|
|
}
|
|
if arg == argName || arg == argPrefix {
|
|
return "", args, fmt.Errorf("must include an equals sign followed by a directory path, like -chdir=example")
|
|
}
|
|
if strings.HasPrefix(arg, argPrefix) {
|
|
argPos = i
|
|
argValue = arg[len(argPrefix):]
|
|
}
|
|
}
|
|
|
|
// When we fall out here, we'll have populated argValue with a non-empty
|
|
// string if the -chdir=... option was present and valid, or left it
|
|
// empty if it wasn't present.
|
|
if argValue == "" {
|
|
return "", args, nil
|
|
}
|
|
|
|
// If we did find the option then we'll need to produce a new args that
|
|
// doesn't include it anymore.
|
|
if argPos == 0 {
|
|
// Easy case: we can just slice off the front
|
|
return argValue, args[1:], nil
|
|
}
|
|
// Otherwise we need to construct a new array and copy to it.
|
|
newArgs := make([]string, len(args)-1)
|
|
copy(newArgs, args[:argPos])
|
|
copy(newArgs[argPos:], args[argPos+1:])
|
|
return argValue, newArgs, nil
|
|
}
|