Permalink
543 lines (483 sloc)
17.6 KB
package main | |
import ( | |
"encoding/json" | |
"fmt" | |
"io/ioutil" | |
"log" | |
"net" | |
"os" | |
"path/filepath" | |
"runtime" | |
"strings" | |
"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" | |
"github.com/mitchellh/panicwrap" | |
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" | |
// Environment variable name used for smuggling true stderr terminal | |
// settings into a panicwrap child process. This is an implementation | |
// detail, subject to change in future, and should not ever be directly | |
// set by an end-user. | |
envTerminalPanicwrapWorkaround = "TF_PANICWRAP_STDERR" | |
) | |
// 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 main() { | |
os.Exit(realMain()) | |
} | |
func realMain() int { | |
var wrapConfig panicwrap.WrapConfig | |
// don't re-exec terraform as a child process for easier debugging | |
if os.Getenv("TF_FORK") == "0" { | |
return wrappedMain() | |
} | |
if !panicwrap.Wrapped(&wrapConfig) { | |
// We always send logs to a temporary file that we use in case | |
// there is a panic. Otherwise, we delete it. | |
logTempFile, err := ioutil.TempFile("", "terraform-log") | |
if err != nil { | |
fmt.Fprintf(os.Stderr, "Couldn't set up logging tempfile: %s", err) | |
return 1 | |
} | |
// Now that we have the file, close it and leave it for the wrapped | |
// process to write to. | |
logTempFile.Close() | |
defer os.Remove(logTempFile.Name()) | |
// store the path in the environment for the wrapped executable | |
os.Setenv(envTmpLogPath, logTempFile.Name()) | |
// We also need to do our terminal initialization before we fork, | |
// because the child process doesn't necessarily have access to | |
// the true stderr in order to initialize it. | |
streams, err := terminal.Init() | |
if err != nil { | |
fmt.Fprintf(os.Stderr, "Failed to initialize terminal: %s", err) | |
return 1 | |
} | |
// We need the child process to behave _as if_ connected to the real | |
// stderr, even though panicwrap is about to add a pipe in the way, | |
// so we'll smuggle the true stderr information in an environment | |
// varible. | |
streamState := streams.StateForAfterPanicWrap() | |
os.Setenv(envTerminalPanicwrapWorkaround, fmt.Sprintf("%t:%d", streamState.StderrIsTerminal, streamState.StderrWidth)) | |
// Create the configuration for panicwrap and wrap our executable | |
wrapConfig.Handler = logging.PanicHandler(logTempFile.Name()) | |
wrapConfig.IgnoreSignals = ignoreSignals | |
wrapConfig.ForwardSignals = forwardSignals | |
exitStatus, err := panicwrap.Wrap(&wrapConfig) | |
if err != nil { | |
fmt.Fprintf(os.Stderr, "Couldn't start Terraform: %s", err) | |
return 1 | |
} | |
return exitStatus | |
} | |
// Call the real main | |
return wrappedMain() | |
} | |
func init() { | |
Ui = &ui{&cli.BasicUi{ | |
Writer: os.Stdout, | |
ErrorWriter: os.Stderr, | |
Reader: os.Stdin, | |
}} | |
} | |
func wrappedMain() int { | |
var err error | |
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) | |
log.Printf("[INFO] Go runtime version: %s", runtime.Version()) | |
log.Printf("[INFO] CLI args: %#v", os.Args) | |
// This is the recieving end of our workaround to retain the metadata | |
// about the real stderr even though we're talking to it via the panicwrap | |
// pipe. See the call to StateForAfterPanicWrap above for the producer | |
// part of this. | |
var streamState *terminal.PrePanicwrapState | |
if raw := os.Getenv(envTerminalPanicwrapWorkaround); raw != "" { | |
streamState = &terminal.PrePanicwrapState{} | |
if _, err := fmt.Sscanf(raw, "%t:%d", &streamState.StderrIsTerminal, &streamState.StderrWidth); err != nil { | |
log.Printf("[WARN] %s is set but is incorrectly-formatted: %s", envTerminalPanicwrapWorkaround, err) | |
streamState = nil // leave it unset for a normal init, then | |
} | |
} | |
streams, err := terminal.ReinitInsidePanicwrap(streamState) | |
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(originalWd, streams, config, services, providerSrc, providerDevOverrides, unmanagedProviders) | |
} | |
// Run checkpoint | |
go runCheckpoint(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() { | |
// we don't write this to Error, or else panicwrap will think this | |
// process panicked | |
Ui.Info(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 | |
} |