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  }