diff --git a/main.go b/main.go index bcf6a3f588..e7750afa99 100644 --- a/main.go +++ b/main.go @@ -7,17 +7,24 @@ import ( "log" "os" "runtime" + "strings" "sync" "github.com/hashicorp/go-plugin" "github.com/hashicorp/terraform/helper/logging" "github.com/hashicorp/terraform/terraform" "github.com/mattn/go-colorable" + "github.com/mattn/go-shellwords" "github.com/mitchellh/cli" "github.com/mitchellh/panicwrap" "github.com/mitchellh/prefixedio" ) +const ( + // EnvCLI is the environment variable name to set additional CLI args. + EnvCLI = "TF_CLI_ARGS" +) + func main() { // Override global prefix set by go-dynect during init() log.SetPrefix("") @@ -129,9 +136,35 @@ func wrappedMain() int { // Make sure we clean up any managed plugins at the end of this defer plugin.CleanupClients() - // Get the command line args. We shortcut "--version" and "-v" to - // just show the version. + // Get the command line args. args := os.Args[1:] + + // 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) @@ -142,7 +175,9 @@ func wrappedMain() int { } } - cli := &cli.CLI{ + // Rebuild the CLI with any modified args. + log.Printf("[INFO] CLI command args: %#v", args) + cliRunner = &cli.CLI{ Args: args, Commands: Commands, HelpFunc: helpFunc, @@ -153,7 +188,7 @@ func wrappedMain() int { ContextOpts.Providers = config.ProviderFactories() ContextOpts.Provisioners = config.ProvisionerFactories() - exitCode, err := cli.Run() + exitCode, err := cliRunner.Run() if err != nil { Ui.Error(fmt.Sprintf("Error executing CLI: %s", err.Error())) return 1 @@ -241,3 +276,47 @@ func copyOutput(r io.Reader, doneCh chan<- struct{}) { wg.Wait() } + +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 +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000000..e5500e8ef6 --- /dev/null +++ b/main_test.go @@ -0,0 +1,257 @@ +package main + +import ( + "fmt" + "os" + "reflect" + "testing" + + "github.com/mitchellh/cli" +) + +func TestMain_cliArgsFromEnv(t *testing.T) { + // Setup the state. This test really messes with the environment and + // global state so we set things up to be restored. + + // Restore original CLI args + oldArgs := os.Args + defer func() { os.Args = oldArgs }() + + // Setup test command and restore that + testCommandName := "unit-test-cli-args" + testCommand := &testCommandCLI{} + defer func() { delete(Commands, testCommandName) }() + Commands[testCommandName] = func() (cli.Command, error) { + return testCommand, nil + } + + cases := []struct { + Name string + Args []string + Value string + Expected []string + Err bool + }{ + { + "no env", + []string{testCommandName, "foo", "bar"}, + "", + []string{"foo", "bar"}, + false, + }, + + { + "both env var and CLI", + []string{testCommandName, "foo", "bar"}, + "-foo bar", + []string{"-foo", "bar", "foo", "bar"}, + false, + }, + + { + "only env var", + []string{testCommandName}, + "-foo bar", + []string{"-foo", "bar"}, + false, + }, + + { + "cli string has blank values", + []string{testCommandName, "bar", "", "baz"}, + "-foo bar", + []string{"-foo", "bar", "bar", "", "baz"}, + false, + }, + + { + "cli string has blank values before the command", + []string{"", testCommandName, "bar"}, + "-foo bar", + []string{"-foo", "bar", "bar"}, + false, + }, + + { + // this should fail gracefully, this is just testing + // that we don't panic with our slice arithmetic + "no command", + []string{}, + "-foo bar", + nil, + true, + }, + + { + "single quoted strings", + []string{testCommandName, "foo"}, + "-foo 'bar baz'", + []string{"-foo", "bar baz", "foo"}, + false, + }, + + { + "double quoted strings", + []string{testCommandName, "foo"}, + `-foo "bar baz"`, + []string{"-foo", "bar baz", "foo"}, + false, + }, + + { + "double quoted single quoted strings", + []string{testCommandName, "foo"}, + `-foo "'bar baz'"`, + []string{"-foo", "'bar baz'", "foo"}, + false, + }, + } + + for i, tc := range cases { + t.Run(fmt.Sprintf("%d-%s", i, tc.Name), func(t *testing.T) { + os.Unsetenv(EnvCLI) + defer os.Unsetenv(EnvCLI) + + // Set the env var value + if tc.Value != "" { + if err := os.Setenv(EnvCLI, tc.Value); err != nil { + t.Fatalf("err: %s", err) + } + } + + // Setup the args + args := make([]string, len(tc.Args)+1) + args[0] = oldArgs[0] // process name + copy(args[1:], tc.Args) + + // Run it! + os.Args = args + testCommand.Args = nil + exit := wrappedMain() + if (exit != 0) != tc.Err { + t.Fatalf("bad: %d", exit) + } + if tc.Err { + return + } + + // Verify + if !reflect.DeepEqual(testCommand.Args, tc.Expected) { + t.Fatalf("bad: %#v", testCommand.Args) + } + }) + } +} + +// This test just has more options than the test above. Use this for +// more control over behavior at the expense of more complex test structures. +func TestMain_cliArgsFromEnvAdvanced(t *testing.T) { + // Restore original CLI args + oldArgs := os.Args + defer func() { os.Args = oldArgs }() + + cases := []struct { + Name string + Command string + EnvVar string + Args []string + Value string + Expected []string + Err bool + }{ + { + "targeted to another command", + "command", + EnvCLI + "_foo", + []string{"command", "foo", "bar"}, + "-flag", + []string{"foo", "bar"}, + false, + }, + + { + "targeted to this command", + "command", + EnvCLI + "_command", + []string{"command", "foo", "bar"}, + "-flag", + []string{"-flag", "foo", "bar"}, + false, + }, + + { + "targeted to a command with a hyphen", + "command-name", + EnvCLI + "_command_name", + []string{"command-name", "foo", "bar"}, + "-flag", + []string{"-flag", "foo", "bar"}, + false, + }, + + { + "targeted to a command with a space", + "command name", + EnvCLI + "_command_name", + []string{"command", "name", "foo", "bar"}, + "-flag", + []string{"-flag", "foo", "bar"}, + false, + }, + } + + for i, tc := range cases { + t.Run(fmt.Sprintf("%d-%s", i, tc.Name), func(t *testing.T) { + // Setup test command and restore that + testCommandName := tc.Command + testCommand := &testCommandCLI{} + defer func() { delete(Commands, testCommandName) }() + Commands[testCommandName] = func() (cli.Command, error) { + return testCommand, nil + } + + os.Unsetenv(tc.EnvVar) + defer os.Unsetenv(tc.EnvVar) + + // Set the env var value + if tc.Value != "" { + if err := os.Setenv(tc.EnvVar, tc.Value); err != nil { + t.Fatalf("err: %s", err) + } + } + + // Setup the args + args := make([]string, len(tc.Args)+1) + args[0] = oldArgs[0] // process name + copy(args[1:], tc.Args) + + // Run it! + os.Args = args + testCommand.Args = nil + exit := wrappedMain() + if (exit != 0) != tc.Err { + t.Fatalf("bad: %d", exit) + } + if tc.Err { + return + } + + // Verify + if !reflect.DeepEqual(testCommand.Args, tc.Expected) { + t.Fatalf("bad: %#v", testCommand.Args) + } + }) + } +} + +type testCommandCLI struct { + Args []string +} + +func (c *testCommandCLI) Run(args []string) int { + c.Args = args + return 0 +} + +func (c *testCommandCLI) Synopsis() string { return "" } +func (c *testCommandCLI) Help() string { return "" } diff --git a/vendor/github.com/mattn/go-shellwords/README.md b/vendor/github.com/mattn/go-shellwords/README.md new file mode 100644 index 0000000000..56f357fad7 --- /dev/null +++ b/vendor/github.com/mattn/go-shellwords/README.md @@ -0,0 +1,47 @@ +# go-shellwords + +[![Coverage Status](https://coveralls.io/repos/mattn/go-shellwords/badge.png?branch=master)](https://coveralls.io/r/mattn/go-shellwords?branch=master) +[![Build Status](https://travis-ci.org/mattn/go-shellwords.svg?branch=master)](https://travis-ci.org/mattn/go-shellwords) + +Parse line as shell words. + +## Usage + +```go +args, err := shellwords.Parse("./foo --bar=baz") +// args should be ["./foo", "--bar=baz"] +``` + +```go +os.Setenv("FOO", "bar") +p := shellwords.NewParser() +p.ParseEnv = true +args, err := p.Parse("./foo $FOO") +// args should be ["./foo", "bar"] +``` + +```go +p := shellwords.NewParser() +p.ParseBacktick = true +args, err := p.Parse("./foo `echo $SHELL`") +// args should be ["./foo", "/bin/bash"] +``` + +```go +shellwords.ParseBacktick = true +p := shellwords.NewParser() +args, err := p.Parse("./foo `echo $SHELL`") +// args should be ["./foo", "/bin/bash"] +``` + +# Thanks + +This is based on cpan module [Parse::CommandLine](https://metacpan.org/pod/Parse::CommandLine). + +# License + +under the MIT License: http://mattn.mit-license.org/2014 + +# Author + +Yasuhiro Matsumoto (a.k.a mattn) diff --git a/vendor/github.com/mattn/go-shellwords/shellwords.go b/vendor/github.com/mattn/go-shellwords/shellwords.go new file mode 100644 index 0000000000..355bdae681 --- /dev/null +++ b/vendor/github.com/mattn/go-shellwords/shellwords.go @@ -0,0 +1,142 @@ +package shellwords + +import ( + "errors" + "os" + "regexp" +) + +var ( + ParseEnv bool = false + ParseBacktick bool = false +) + +var envRe = regexp.MustCompile(`\$({[a-zA-Z0-9_]+}|[a-zA-Z0-9_]+)`) + +func isSpace(r rune) bool { + switch r { + case ' ', '\t', '\r', '\n': + return true + } + return false +} + +func replaceEnv(s string) string { + return envRe.ReplaceAllStringFunc(s, func(s string) string { + s = s[1:] + if s[0] == '{' { + s = s[1 : len(s)-1] + } + return os.Getenv(s) + }) +} + +type Parser struct { + ParseEnv bool + ParseBacktick bool + Position int +} + +func NewParser() *Parser { + return &Parser{ParseEnv, ParseBacktick, 0} +} + +func (p *Parser) Parse(line string) ([]string, error) { + args := []string{} + buf := "" + var escaped, doubleQuoted, singleQuoted, backQuote bool + backtick := "" + + pos := -1 + +loop: + for i, r := range line { + if escaped { + buf += string(r) + escaped = false + continue + } + + if r == '\\' { + if singleQuoted { + buf += string(r) + } else { + escaped = true + } + continue + } + + if isSpace(r) { + if singleQuoted || doubleQuoted || backQuote { + buf += string(r) + backtick += string(r) + } else if buf != "" { + if p.ParseEnv { + buf = replaceEnv(buf) + } + args = append(args, buf) + buf = "" + } + continue + } + + switch r { + case '`': + if !singleQuoted && !doubleQuoted { + if p.ParseBacktick { + if backQuote { + out, err := shellRun(backtick) + if err != nil { + return nil, err + } + buf = out + } + backtick = "" + backQuote = !backQuote + continue + } + backtick = "" + backQuote = !backQuote + } + case '"': + if !singleQuoted { + doubleQuoted = !doubleQuoted + continue + } + case '\'': + if !doubleQuoted { + singleQuoted = !singleQuoted + continue + } + case ';', '&', '|', '<', '>': + if !(escaped || singleQuoted || doubleQuoted || backQuote) { + pos = i + break loop + } + } + + buf += string(r) + if backQuote { + backtick += string(r) + } + } + + if buf != "" { + if p.ParseEnv { + buf = replaceEnv(buf) + } + args = append(args, buf) + } + + if escaped || singleQuoted || doubleQuoted || backQuote { + return nil, errors.New("invalid command line string") + } + + p.Position = pos + + return args, nil +} + +func Parse(line string) ([]string, error) { + return NewParser().Parse(line) +} diff --git a/vendor/github.com/mattn/go-shellwords/util_posix.go b/vendor/github.com/mattn/go-shellwords/util_posix.go new file mode 100644 index 0000000000..4f8ac55e47 --- /dev/null +++ b/vendor/github.com/mattn/go-shellwords/util_posix.go @@ -0,0 +1,19 @@ +// +build !windows + +package shellwords + +import ( + "errors" + "os" + "os/exec" + "strings" +) + +func shellRun(line string) (string, error) { + shell := os.Getenv("SHELL") + b, err := exec.Command(shell, "-c", line).Output() + if err != nil { + return "", errors.New(err.Error() + ":" + string(b)) + } + return strings.TrimSpace(string(b)), nil +} diff --git a/vendor/github.com/mattn/go-shellwords/util_windows.go b/vendor/github.com/mattn/go-shellwords/util_windows.go new file mode 100644 index 0000000000..7cad4cf06f --- /dev/null +++ b/vendor/github.com/mattn/go-shellwords/util_windows.go @@ -0,0 +1,17 @@ +package shellwords + +import ( + "errors" + "os" + "os/exec" + "strings" +) + +func shellRun(line string) (string, error) { + shell := os.Getenv("COMSPEC") + b, err := exec.Command(shell, "/c", line).Output() + if err != nil { + return "", errors.New(err.Error() + ":" + string(b)) + } + return strings.TrimSpace(string(b)), nil +} diff --git a/vendor/vendor.json b/vendor/vendor.json index 9546fb48ec..87636bfd6e 100644 --- a/vendor/vendor.json +++ b/vendor/vendor.json @@ -2107,6 +2107,12 @@ "revision": "30a891c33c7cde7b02a981314b4228ec99380cca", "revisionTime": "2016-11-23T14:36:37Z" }, + { + "checksumSHA1": "7OHq2KeND82oDITMEa+Mx4RmOaU=", + "path": "github.com/mattn/go-shellwords", + "revision": "753a2322a99f87c0eff284980e77f53041555bc6", + "revisionTime": "2017-01-23T01:43:24Z" + }, { "checksumSHA1": "A0PtnlAbuZA6kKfuVkM8GSx2SSI=", "comment": "v0.6.0", diff --git a/website/source/docs/configuration/environment-variables.html.md b/website/source/docs/configuration/environment-variables.html.md index 30e3b421a5..e339545202 100644 --- a/website/source/docs/configuration/environment-variables.html.md +++ b/website/source/docs/configuration/environment-variables.html.md @@ -65,6 +65,29 @@ export TF_VAR_amap='{ foo = "bar", baz = "qux" }' For more on how to use `TF_VAR_name` in context, check out the section on [Variable Configuration](/docs/configuration/variables.html). +## TF_CLI_ARGS and TF_CLI_ARGS_name + +The value of `TF_CLI_ARGS` will specify additional arguments to the +command-line. This allows easier automation in CI environments as well as +modifying default behavior of Terraform on your own system. + +These arguments are inserted directly _after_ the subcommand +(such as `plan`) and _before_ any flags specified directly on the command-line. +This behavior ensures that flags on the command-line take precedence over +environment variables. + +For example, the following command: `TF_CLI_ARGS="-input=false" terraform apply -force` +is the equivalent to manually typing: `terraform apply -input=false -force`. + +The flag `TF_CLI_ARGS` affects all Terraform commands. If you specify a +named command in the form of `TF_CLI_ARGS_name` then it will only affect +that command. As an example, to specify that only plans never refresh, +you can set `TF_CLI_ARGS_plan="-refresh=false"`. + +The value of the flag is parsed as if you typed it directly to the shell. +Double and single quotes are allowed to capture strings and arguments will +be separated by spaces otherwise. + ## TF_SKIP_REMOTE_TESTS This can be set prior to running the unit tests to opt-out of any tests