diff --git a/runatlantis.io/docs/custom-workflows.md b/runatlantis.io/docs/custom-workflows.md index f2edd827ae..af655abf26 100644 --- a/runatlantis.io/docs/custom-workflows.md +++ b/runatlantis.io/docs/custom-workflows.md @@ -599,6 +599,10 @@ Full ```yaml - run: command: custom-command arg1 arg2 + shell: sh + shellArgs: + - "--debug" + - "-c" output: show ``` @@ -606,6 +610,8 @@ Full |-----|--------------------------------------------------------------|---------|----------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | run | map\[string -> string\] | none | no | Run a custom command | | run.command | string | none | yes | Shell command to run | +| run.shell | string | "sh" | no | Name of the shell to use for command execution | +| run.shellArgs | string or []string | "-c" | no | Command line arguments to be passed to the shell. Cannot be set without `shell` | | run.output | string | "show" | no | How to post-process the output of this command when posted in the PR comment. The options are
*`show` - preserve the full output
* `hide` - hide output from comment (still visible in the real-time streaming output)
* `strip_refreshing` - hide all output up until and including the last line containing "Refreshing...". This matches the behavior of the built-in `plan` command | #### Native Environment Variables @@ -664,6 +670,13 @@ as the environment variable value. - env: name: ENV_NAME_2 command: 'echo "dynamic-value-$(date)"' +- env: + name: ENV_NAME_3 + command: echo ${DIR%$REPO_REL_DIR} + shell: bash + shellArgs: + - "--verbose" + - "-c" ``` | Key | Type | Default | Required | Description | @@ -672,6 +685,8 @@ as the environment variable value. | env.name | string | none | yes | Name of the environment variable | | env.value | string | none | no | Set the value of the environment variable to a hard-coded string. Cannot be set at the same time as `command` | | env.command | string | none | no | Set the value of the environment variable to the output of a command. Cannot be set at the same time as `value` | +| env.shell | string | "sh" | no | Name of the shell to use for command execution. Cannot be set without `command` | +| env.shellArgs | string or []string | "-c" | no | Command line arguments to be passed to the shell. Cannot be set without `shell` | ::: tip Notes @@ -699,14 +714,20 @@ Full: ```yaml - multienv: command: custom-command + shell: bash + shellArgs: + - "--verbose" + - "-c" output: show ``` -| Key | Type | Default | Required | Description | -|------------------|-----------------------|---------|----------|-------------------------------------------------------------------------------------| -| multienv | map[string -> string] | none | no | Run a custom command and add printed environment variables | -| multienv.command | string | none | yes | Name of the custom script to run | -| multienv.output | string | "show" | no | Setting output to "hide" will supress the message obout added environment variables | +| Key | Type | Default | Required | Description | +|--------------------|-----------------------|---------|----------|-------------------------------------------------------------------------------------| +| multienv | map[string -> string] | none | no | Run a custom command and add printed environment variables | +| multienv.command | string | none | yes | Name of the custom script to run | +| multienv.shell | string | "sh" | no | Name of the shell to use for command execution | +| multienv.shellArgs | string or []string | "-c" | no | Command line arguments to be passed to the shell. Cannot be set without `shell` | +| multienv.output | string | "show" | no | Setting output to "hide" will supress the message obout added environment variables | The output of the command execution must have the following format: `EnvVar1Name=value1,EnvVar2Name=value2,EnvVar3Name=value3` diff --git a/server/core/config/raw/step.go b/server/core/config/raw/step.go index 581be49c64..6ada93488c 100644 --- a/server/core/config/raw/step.go +++ b/server/core/config/raw/step.go @@ -9,6 +9,7 @@ import ( validation "github.com/go-ozzo/ozzo-validation" "github.com/runatlantis/atlantis/server/core/config/valid" + "github.com/runatlantis/atlantis/server/utils" ) const ( @@ -27,45 +28,58 @@ const ( MultiEnvStepName = "multienv" ImportStepName = "import" StateRmStepName = "state_rm" + ShellArgKey = "shell" + ShellArgsArgKey = "shellArgs" ) -// Step represents a single action/command to perform. In YAML, it can be set as -// 1. A single string for a built-in command: -// - init -// - plan -// - policy_check -// -// 2. A map for an env step with name and command or value, or a run step with a command and output config -// - env: -// name: test -// command: echo 312 -// value: value -// - multienv: -// command: envs.sh -// outpiut: hide -// - run: -// command: my custom command -// output: hide -// -// 3. A map for a built-in command and extra_args: -// - plan: -// extra_args: [-var-file=staging.tfvars] -// -// 4. A map for a custom run command: -// - run: my custom command -// -// Here we parse step in the most generic fashion possible. See fields for more -// details. +/* +Step represents a single action/command to perform. In YAML, it can be set as +1. A single string for a built-in command: + - init + - plan + - policy_check + +2. A map for an env step with name and command or value, or a run step with a command and output config + - env: + name: test_command + command: echo 312 + - env: + name: test_value + value: value + - env: + name: test_bash_command + command: echo ${test_value::7} + shell: bash + shellArgs: ["--verbose", "-c"] + - multienv: + command: envs.sh + output: hide + shell: sh + shellArgs: -c + - run: + command: my custom command + output: hide + +3. A map for a built-in command and extra_args: + - plan: + extra_args: [-var-file=staging.tfvars] + +4. A map for a custom run command: + - run: my custom command + +Here we parse step in the most generic fashion possible. See fields for more +details. +*/ type Step struct { // Key will be set in case #1 and #3 above to the key. In case #2, there // could be multiple keys (since the element is a map) so we don't set Key. Key *string - // CommandMap will be set in case #2 above. - CommandMap map[string]map[string]string - // Map will be set in case #3 above. - Map map[string]map[string][]string // StringVal will be set in case #4 above. StringVal map[string]string + // Map will be set in case #3 above. + Map map[string]map[string][]string + // CommandMap will be set in case #2 above. + CommandMap map[string]map[string]interface{} } func (s *Step) UnmarshalYAML(unmarshal func(interface{}) error) error { @@ -142,7 +156,8 @@ func (s Step) Validate() error { } for k := range args { if k != ExtraArgsKey { - return fmt.Errorf("built-in steps only support a single %s key, found %q in step %s", ExtraArgsKey, k, stepName) + return fmt.Errorf("built-in steps only support a single %s key, found %q in step %s", + ExtraArgsKey, k, stepName) } } } @@ -150,7 +165,7 @@ func (s Step) Validate() error { } envOrRunOrMultiEnvStep := func(value interface{}) error { - elem := value.(map[string]map[string]string) + elem := value.(map[string]map[string]interface{}) var keys []string for k := range elem { keys = append(keys, k) @@ -169,63 +184,100 @@ func (s Step) Validate() error { stepName := keys[0] args := elem[keys[0]] - switch stepName { - case EnvStepName: - var argKeys []string - for k := range args { - argKeys = append(argKeys, k) + var argKeys []string + for k := range args { + argKeys = append(argKeys, k) + } + argMap := make(map[string]interface{}) + for k, v := range args { + argMap[k] = v + } + // Sort so tests can be deterministic. + sort.Strings(argKeys) + + // Validate keys common for all the steps. + if utils.SlicesContains(argKeys, ShellArgKey) && !utils.SlicesContains(argKeys, CommandArgKey) { + return fmt.Errorf("workflow steps only support %q key in combination with %q key", + ShellArgKey, CommandArgKey) + } + if utils.SlicesContains(argKeys, ShellArgsArgKey) && !utils.SlicesContains(argKeys, ShellArgKey) { + return fmt.Errorf("workflow steps only support %q key in combination with %q key", + ShellArgsArgKey, ShellArgKey) + } + + switch t := argMap[ShellArgsArgKey].(type) { + case nil: + case string: + case []interface{}: + for _, e := range t { + if _, ok := e.(string); !ok { + return fmt.Errorf("%q step %q option must contain only strings, found %v\n", + stepName, ShellArgsArgKey, e) + } } - // Sort so tests can be deterministic. - sort.Strings(argKeys) + default: + return fmt.Errorf("%q step %q option must be a string or a list of strings, found %v\n", + stepName, ShellArgsArgKey, t) + } + delete(argMap, ShellArgsArgKey) + delete(argMap, ShellArgKey) + // Validate keys per step type. + switch stepName { + case EnvStepName: foundNameKey := false for _, k := range argKeys { - if k != NameArgKey && k != CommandArgKey && k != ValueArgKey { - return fmt.Errorf("env steps only support keys %q, %q and %q, found key %q", NameArgKey, ValueArgKey, CommandArgKey, k) + if k != NameArgKey && k != CommandArgKey && k != ValueArgKey && k != ShellArgKey && k != ShellArgsArgKey { + return fmt.Errorf("env steps only support keys %q, %q, %q, %q and %q, found key %q", + NameArgKey, ValueArgKey, CommandArgKey, ShellArgKey, ShellArgsArgKey, k) } if k == NameArgKey { foundNameKey = true } } + delete(argMap, CommandArgKey) if !foundNameKey { return fmt.Errorf("env steps must have a %q key set", NameArgKey) } - // If we have 3 keys at this point then they've set both command and value. - if len(argKeys) != 2 { + delete(argMap, NameArgKey) + if utils.SlicesContains(argKeys, ValueArgKey) && utils.SlicesContains(argKeys, CommandArgKey) { return fmt.Errorf("env steps only support one of the %q or %q keys, found both", ValueArgKey, CommandArgKey) } + delete(argMap, ValueArgKey) case RunStepName, MultiEnvStepName: - argsCopy := make(map[string]string) - for k, v := range args { - argsCopy[k] = v - } - args = argsCopy - if _, ok := args[CommandArgKey]; !ok { + if _, ok := argMap[CommandArgKey].(string); !ok { return fmt.Errorf("%q step must have a %q key set", stepName, CommandArgKey) } - delete(args, CommandArgKey) - if v, ok := args[OutputArgKey]; ok { - if stepName == RunStepName && !(v == valid.PostProcessRunOutputShow || v == valid.PostProcessRunOutputHide || v == valid.PostProcessRunOutputStripRefreshing) { - return fmt.Errorf("run step %q option must be one of %q, %q, or %q", OutputArgKey, valid.PostProcessRunOutputShow, valid.PostProcessRunOutputHide, valid.PostProcessRunOutputStripRefreshing) - } else if stepName == MultiEnvStepName && !(v == valid.PostProcessRunOutputShow || v == valid.PostProcessRunOutputHide) { - return fmt.Errorf("multienv step %q option must be %q or %q", OutputArgKey, valid.PostProcessRunOutputShow, valid.PostProcessRunOutputHide) - } - } - delete(args, OutputArgKey) - if len(args) > 0 { - var argKeys []string - for k := range args { - argKeys = append(argKeys, k) + delete(argMap, CommandArgKey) + if v, ok := argMap[OutputArgKey].(string); ok { + if stepName == RunStepName && !(v == valid.PostProcessRunOutputShow || + v == valid.PostProcessRunOutputHide || v == valid.PostProcessRunOutputStripRefreshing) { + return fmt.Errorf("run step %q option must be one of %q, %q, or %q", + OutputArgKey, valid.PostProcessRunOutputShow, valid.PostProcessRunOutputHide, + valid.PostProcessRunOutputStripRefreshing) + } else if stepName == MultiEnvStepName && !(v == valid.PostProcessRunOutputShow || + v == valid.PostProcessRunOutputHide) { + return fmt.Errorf("multienv step %q option must be %q or %q", + OutputArgKey, valid.PostProcessRunOutputShow, valid.PostProcessRunOutputHide) } - // Sort so tests can be deterministic. - sort.Strings(argKeys) - return fmt.Errorf("%q steps only support keys %q and %q, found extra keys %q", stepName, CommandArgKey, OutputArgKey, strings.Join(argKeys, ",")) } + delete(argMap, OutputArgKey) default: return fmt.Errorf("%q is not a valid step type", stepName) } + if len(argMap) > 0 { + var argKeys []string + for k := range argMap { + argKeys = append(argKeys, k) + } + // Sort so tests can be deterministic. + sort.Strings(argKeys) + return fmt.Errorf("%q steps only support keys %q, %q, %q and %q, found extra keys %q", + stepName, CommandArgKey, OutputArgKey, ShellArgKey, ShellArgsArgKey, strings.Join(argKeys, ",")) + } + return nil } @@ -278,16 +330,40 @@ func (s Step) ToValid() valid.Step { // After validation we assume there's only one key and it's a valid // step name so we just use the first one. for stepName, stepArgs := range s.CommandMap { - step := valid.Step{ - StepName: stepName, - EnvVarName: stepArgs[NameArgKey], - RunCommand: stepArgs[CommandArgKey], - EnvVarValue: stepArgs[ValueArgKey], - Output: valid.PostProcessRunOutputOption(stepArgs[OutputArgKey]), + step := valid.Step{StepName: stepName} + if name, ok := stepArgs[NameArgKey].(string); ok { + step.EnvVarName = name + } + if command, ok := stepArgs[CommandArgKey].(string); ok { + step.RunCommand = command + } + if value, ok := stepArgs[ValueArgKey].(string); ok { + step.EnvVarValue = value + } + if output, ok := stepArgs[OutputArgKey].(string); ok { + step.Output = valid.PostProcessRunOutputOption(output) + } + if shell, ok := stepArgs[ShellArgKey].(string); ok { + step.RunShell = &valid.CommandShell{ + Shell: shell, + ShellArgs: []string{"-c"}, + } } if step.StepName == RunStepName && step.Output == "" { step.Output = valid.PostProcessRunOutputShow } + + switch t := stepArgs[ShellArgsArgKey].(type) { + case nil: + case string: + step.RunShell.ShellArgs = strings.Split(t, " ") + case []interface{}: + step.RunShell.ShellArgs = []string{} + for _, e := range t { + step.RunShell.ShellArgs = append(step.RunShell.ShellArgs, e.(string)) + } + } + return step } } @@ -341,6 +417,17 @@ func (s *Step) unmarshalGeneric(unmarshal func(interface{}) error) error { return nil } + // Try to unmarshal as a custom run step, ex. + // steps: + // - run: my command + // We validate if the key is run later. + var runStep map[string]string + err = unmarshal(&runStep) + if err == nil { + s.StringVal = runStep + return nil + } + // This represents a step with extra_args, ex: // init: // extra_args: [a, b] @@ -353,26 +440,20 @@ func (s *Step) unmarshalGeneric(unmarshal func(interface{}) error) error { return nil } - // This represents an env step, ex: - // env: - // name: k - // value: hi //optional - // command: exec - var envStep map[string]map[string]string - err = unmarshal(&envStep) - if err == nil { - s.CommandMap = envStep - return nil - } - - // Try to unmarshal as a custom run step, ex. + // This represents a command steps env, run, and multienv, ex: // steps: - // - run: my command - // We validate if the key is run later. - var runStep map[string]string - err = unmarshal(&runStep) + // - env: + // name: k + // command: exec + // - run: + // name: test_bash_command + // command: echo ${test_value::7} + // shell: bash + // shellArgs: ["--verbose", "-c"] + var commandStep map[string]map[string]interface{} + err = unmarshal(&commandStep) if err == nil { - s.StringVal = runStep + s.CommandMap = commandStep return nil } diff --git a/server/core/config/raw/step_test.go b/server/core/config/raw/step_test.go index f47c497e6f..f8b9ae8b11 100644 --- a/server/core/config/raw/step_test.go +++ b/server/core/config/raw/step_test.go @@ -143,12 +143,12 @@ key: value`, // Errors { - description: "extra args style no slice strings", + description: "extra args style no map strings", input: ` key: - value: - another: map`, - expErr: "yaml: unmarshal errors:\n line 3: cannot unmarshal !!map into string", + - value: + another: map`, + expErr: "yaml: unmarshal errors:\n line 3: cannot unmarshal !!seq into map[string]interface {}", }, } @@ -236,6 +236,47 @@ func TestStep_Validate(t *testing.T) { }, expErr: "", }, + { + description: "env shell", + input: raw.Step{ + CommandMap: EnvType{ + "env": { + "name": "test", + "command": "echo 123", + "shell": "bash", + }, + }, + }, + expErr: "", + }, + { + description: "env shellArgs string", + input: raw.Step{ + CommandMap: EnvType{ + "env": { + "name": "test", + "command": "echo 123", + "shell": "bash", + "shellArgs": "-c", + }, + }, + }, + expErr: "", + }, + { + description: "env shellArgs list of strings", + input: raw.Step{ + CommandMap: EnvType{ + "env": { + "name": "test", + "command": "echo 123", + "shell": "bash", + "shellArgs": []interface{}{"-c", "--debug"}, + }, + }, + }, + expErr: "", + }, { description: "apply extra_args", input: raw.Step{ @@ -371,7 +412,7 @@ func TestStep_Validate(t *testing.T) { }, }, }, - expErr: "env steps only support keys \"name\", \"value\" and \"command\", found key \"abc\"", + expErr: "env steps only support keys \"name\", \"value\", \"command\", \"shell\" and \"shellArgs\", found key \"abc\"", }, { description: "env step with both command and value set", @@ -386,6 +427,58 @@ func TestStep_Validate(t *testing.T) { }, expErr: "env steps only support one of the \"value\" or \"command\" keys, found both", }, + { + description: "env step with shell set but not command", + input: raw.Step{ + CommandMap: EnvType{ + "env": { + "name": "name", + "shell": "bash", + }, + }, + }, + expErr: "workflow steps only support \"shell\" key in combination with \"command\" key", + }, + { + description: "env step with shellArgs set but not shell", + input: raw.Step{ + CommandMap: EnvType{ + "env": { + "name": "name", + "shellArgs": "-c", + }, + }, + }, + expErr: "workflow steps only support \"shellArgs\" key in combination with \"shell\" key", + }, + { + description: "run step with shellArgs is not list of strings", + input: raw.Step{ + CommandMap: EnvType{ + "run": { + "name": "name", + "command": "echo", + "shell": "shell", + "shellArgs": []int{42, 42}, + }, + }, + }, + expErr: "\"run\" step \"shellArgs\" option must be a string or a list of strings, found [42 42]\n", + }, + { + description: "run step with shellArgs contain not strings", + input: raw.Step{ + CommandMap: EnvType{ + "run": { + "name": "name", + "command": "echo", + "shell": "shell", + "shellArgs": []interface{}{"-c", 42}, + }, + }, + }, + expErr: "\"run\" step \"shellArgs\" option must contain only strings, found 42\n", + }, { // For atlantis.yaml v2, this wouldn't parse, but now there should // be no error. @@ -611,6 +704,6 @@ func TestStep_ToValid(t *testing.T) { } type MapType map[string]map[string][]string -type EnvType map[string]map[string]string -type RunType map[string]map[string]string -type MultiEnvType map[string]map[string]string +type EnvType map[string]map[string]interface{} +type RunType map[string]map[string]interface{} +type MultiEnvType map[string]map[string]interface{} diff --git a/server/core/config/valid/repo_cfg.go b/server/core/config/valid/repo_cfg.go index e5a8378bd7..e42e60158b 100644 --- a/server/core/config/valid/repo_cfg.go +++ b/server/core/config/valid/repo_cfg.go @@ -189,6 +189,16 @@ type Stage struct { Steps []Step } +// CommandShell sets up the shell for command execution +type CommandShell struct { + Shell string + ShellArgs []string +} + +func (s CommandShell) String() string { + return fmt.Sprintf("%s %s", s.Shell, strings.Join(s.ShellArgs, " ")) +} + type Step struct { StepName string ExtraArgs []string @@ -202,6 +212,8 @@ type Step struct { EnvVarName string // EnvVarValue is the value to set EnvVarName to. EnvVarValue string + // The Shell to use for RunCommand execution. + RunShell *CommandShell } type Workflow struct { diff --git a/server/core/runtime/env_step_runner.go b/server/core/runtime/env_step_runner.go index eb6556c182..5fa865fefd 100644 --- a/server/core/runtime/env_step_runner.go +++ b/server/core/runtime/env_step_runner.go @@ -15,13 +15,20 @@ type EnvStepRunner struct { // Run runs the env step command. // value is the value for the environment variable. If set this is returned as // the value. Otherwise command is run and its output is the value returned. -func (r *EnvStepRunner) Run(ctx command.ProjectContext, command string, value string, path string, envs map[string]string) (string, error) { +func (r *EnvStepRunner) Run( + ctx command.ProjectContext, + shell *valid.CommandShell, + command string, + value string, + path string, + envs map[string]string, +) (string, error) { if value != "" { return value, nil } // Pass `false` for streamOutput because this isn't interesting to the user reading the build logs // in the web UI. - res, err := r.RunStepRunner.Run(ctx, command, path, envs, false, valid.PostProcessRunOutputShow) + res, err := r.RunStepRunner.Run(ctx, shell, command, path, envs, false, valid.PostProcessRunOutputShow) // Trim newline from res to support running `echo env_value` which has // a newline. We don't recommend users run echo -n env_value to remove the // newline because -n doesn't work in the sh shell which is what we use diff --git a/server/core/runtime/env_step_runner_test.go b/server/core/runtime/env_step_runner_test.go index a26b5c1a93..0fe86f77f0 100644 --- a/server/core/runtime/env_step_runner_test.go +++ b/server/core/runtime/env_step_runner_test.go @@ -77,7 +77,7 @@ func TestEnvStepRunner_Run(t *testing.T) { TerraformVersion: tfVersion, ProjectName: c.ProjectName, } - value, err := envRunner.Run(ctx, c.Command, c.Value, tmpDir, map[string]string(nil)) + value, err := envRunner.Run(ctx, nil, c.Command, c.Value, tmpDir, map[string]string(nil)) if c.ExpErr != "" { ErrContains(t, c.ExpErr, err) return diff --git a/server/core/runtime/models/shell_command_runner.go b/server/core/runtime/models/shell_command_runner.go index 3dcd56dd8a..7271f6789e 100644 --- a/server/core/runtime/models/shell_command_runner.go +++ b/server/core/runtime/models/shell_command_runner.go @@ -9,6 +9,7 @@ import ( "time" "github.com/pkg/errors" + "github.com/runatlantis/atlantis/server/core/config/valid" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/terraform/ansi" "github.com/runatlantis/atlantis/server/jobs" @@ -33,10 +34,27 @@ type ShellCommandRunner struct { outputHandler jobs.ProjectCommandOutputHandler streamOutput bool cmd *exec.Cmd + shell *valid.CommandShell } -func NewShellCommandRunner(command string, environ []string, workingDir string, streamOutput bool, outputHandler jobs.ProjectCommandOutputHandler) *ShellCommandRunner { - cmd := exec.Command("sh", "-c", command) // #nosec +func NewShellCommandRunner( + shell *valid.CommandShell, + command string, + environ []string, + workingDir string, + streamOutput bool, + outputHandler jobs.ProjectCommandOutputHandler, +) *ShellCommandRunner { + if shell == nil { + shell = &valid.CommandShell{ + Shell: "sh", + ShellArgs: []string{"-c"}, + } + } + var args []string + args = append(args, shell.ShellArgs...) + args = append(args, command) + cmd := exec.Command(shell.Shell, args...) // #nosec cmd.Env = environ cmd.Dir = workingDir @@ -46,6 +64,7 @@ func NewShellCommandRunner(command string, environ []string, workingDir string, outputHandler: outputHandler, streamOutput: streamOutput, cmd: cmd, + shell: shell, } } @@ -92,10 +111,10 @@ func (s *ShellCommandRunner) RunCommandAsync(ctx command.ProjectContext) (chan<- stderr, _ := s.cmd.StderrPipe() stdin, _ := s.cmd.StdinPipe() - ctx.Log.Debug("starting %q in %q", s.command, s.workingDir) + ctx.Log.Debug("starting '%s %q' in '%s'", s.shell.String(), s.command, s.workingDir) err := s.cmd.Start() if err != nil { - err = errors.Wrapf(err, "running %q in %q", s.command, s.workingDir) + err = errors.Wrapf(err, "running '%s %q' in '%s'", s.shell.String(), s.command, s.workingDir) ctx.Log.Err(err.Error()) outCh <- Line{Err: err} return @@ -154,11 +173,13 @@ func (s *ShellCommandRunner) RunCommandAsync(ctx command.ProjectContext) (chan<- // We're done now. Send an error if there was one. if err != nil { - err = errors.Wrapf(err, "running %q in %q", s.command, s.workingDir) + err = errors.Wrapf(err, "running '%s %q' in '%s'", + s.shell.String(), s.command, s.workingDir) log.Err(err.Error()) outCh <- Line{Err: err} } else { - log.Info("successfully ran %q in %q", s.command, s.workingDir) + log.Info("successfully ran '%s %q' in '%s'", + s.shell.String(), s.command, s.workingDir) } }() diff --git a/server/core/runtime/models/shell_command_runner_test.go b/server/core/runtime/models/shell_command_runner_test.go index 0555c7144c..e8edc32fb1 100644 --- a/server/core/runtime/models/shell_command_runner_test.go +++ b/server/core/runtime/models/shell_command_runner_test.go @@ -54,7 +54,7 @@ func TestShellCommandRunner_Run(t *testing.T) { expectedOutput := fmt.Sprintf("%s\n", strings.Join(c.ExpLines, "\n")) // Run once with streaming enabled - runner := models.NewShellCommandRunner(c.Command, environ, cwd, true, projectCmdOutputHandler) + runner := models.NewShellCommandRunner(nil, c.Command, environ, cwd, true, projectCmdOutputHandler) output, err := runner.Run(ctx) Ok(t, err) Equals(t, expectedOutput, output) @@ -68,7 +68,7 @@ func TestShellCommandRunner_Run(t *testing.T) { // command output handler should not have received anything projectCmdOutputHandler = mocks.NewMockProjectCommandOutputHandler() - runner = models.NewShellCommandRunner(c.Command, environ, cwd, false, projectCmdOutputHandler) + runner = models.NewShellCommandRunner(nil, c.Command, environ, cwd, false, projectCmdOutputHandler) output, err = runner.Run(ctx) Ok(t, err) Equals(t, expectedOutput, output) diff --git a/server/core/runtime/multienv_step_runner.go b/server/core/runtime/multienv_step_runner.go index 17e2ae1963..6e4434111f 100644 --- a/server/core/runtime/multienv_step_runner.go +++ b/server/core/runtime/multienv_step_runner.go @@ -16,8 +16,15 @@ type MultiEnvStepRunner struct { // Run runs the multienv step command. // The command must return a json string containing the array of name-value pairs that are being added as extra environment variables -func (r *MultiEnvStepRunner) Run(ctx command.ProjectContext, command string, path string, envs map[string]string, postProcessOutput valid.PostProcessRunOutputOption) (string, error) { - res, err := r.RunStepRunner.Run(ctx, command, path, envs, false, postProcessOutput) +func (r *MultiEnvStepRunner) Run( + ctx command.ProjectContext, + shell *valid.CommandShell, + command string, + path string, + envs map[string]string, + postProcessOutput valid.PostProcessRunOutputOption, +) (string, error) { + res, err := r.RunStepRunner.Run(ctx, shell, command, path, envs, false, postProcessOutput) if err != nil { return "", err } diff --git a/server/core/runtime/multienv_step_runner_test.go b/server/core/runtime/multienv_step_runner_test.go index adf51a8b60..360adce3f5 100644 --- a/server/core/runtime/multienv_step_runner_test.go +++ b/server/core/runtime/multienv_step_runner_test.go @@ -85,7 +85,7 @@ func TestMultiEnvStepRunner_Run(t *testing.T) { ProjectName: c.ProjectName, } envMap := make(map[string]string) - value, err := multiEnvStepRunner.Run(ctx, c.Command, tmpDir, envMap, valid.PostProcessRunOutputShow) + value, err := multiEnvStepRunner.Run(ctx, nil, c.Command, tmpDir, envMap, valid.PostProcessRunOutputShow) if c.ExpErr != "" { ErrContains(t, c.ExpErr, err) return diff --git a/server/core/runtime/run_step_runner.go b/server/core/runtime/run_step_runner.go index 1e3335762c..76629ba460 100644 --- a/server/core/runtime/run_step_runner.go +++ b/server/core/runtime/run_step_runner.go @@ -22,7 +22,15 @@ type RunStepRunner struct { ProjectCmdOutputHandler jobs.ProjectCommandOutputHandler } -func (r *RunStepRunner) Run(ctx command.ProjectContext, command string, path string, envs map[string]string, streamOutput bool, postProcessOutput valid.PostProcessRunOutputOption) (string, error) { +func (r *RunStepRunner) Run( + ctx command.ProjectContext, + shell *valid.CommandShell, + command string, + path string, + envs map[string]string, + streamOutput bool, + postProcessOutput valid.PostProcessRunOutputOption, +) (string, error) { tfVersion := r.DefaultTFVersion if ctx.TerraformVersion != nil { tfVersion = ctx.TerraformVersion @@ -68,7 +76,7 @@ func (r *RunStepRunner) Run(ctx command.ProjectContext, command string, path str finalEnvVars = append(finalEnvVars, fmt.Sprintf("%s=%s", key, val)) } - runner := models.NewShellCommandRunner(command, finalEnvVars, path, streamOutput, r.ProjectCmdOutputHandler) + runner := models.NewShellCommandRunner(shell, command, finalEnvVars, path, streamOutput, r.ProjectCmdOutputHandler) output, err := runner.Run(ctx) if postProcessOutput == valid.PostProcessRunOutputStripRefreshing { diff --git a/server/core/runtime/run_step_runner_test.go b/server/core/runtime/run_step_runner_test.go index d011254a09..4672fa2bb0 100644 --- a/server/core/runtime/run_step_runner_test.go +++ b/server/core/runtime/run_step_runner_test.go @@ -145,7 +145,7 @@ func TestRunStepRunner_Run(t *testing.T) { ProjectName: c.ProjectName, EscapedCommentArgs: []string{"-target=resource1", "-target=resource2"}, } - out, err := r.Run(ctx, c.Command, tmpDir, map[string]string{"test": "var"}, true, valid.PostProcessRunOutputShow) + out, err := r.Run(ctx, nil, c.Command, tmpDir, map[string]string{"test": "var"}, true, valid.PostProcessRunOutputShow) if c.ExpErr != "" { ErrContains(t, c.ExpErr, err) return diff --git a/server/core/terraform/terraform_client.go b/server/core/terraform/terraform_client.go index 09cf88f564..cd2a0d8ad7 100644 --- a/server/core/terraform/terraform_client.go +++ b/server/core/terraform/terraform_client.go @@ -466,7 +466,7 @@ func (c *DefaultClient) RunCommandAsync(ctx command.ProjectContext, path string, envVars = append(envVars, fmt.Sprintf("%s=%s", key, val)) } - runner := models.NewShellCommandRunner(cmd, envVars, path, true, c.projectCmdOutputHandler) + runner := models.NewShellCommandRunner(nil, cmd, envVars, path, true, c.projectCmdOutputHandler) inCh, outCh := runner.RunCommandAsync(ctx) return inCh, outCh } diff --git a/server/core/terraform/terraform_client_internal_test.go b/server/core/terraform/terraform_client_internal_test.go index 6dd4c89e85..8c6be3ee43 100644 --- a/server/core/terraform/terraform_client_internal_test.go +++ b/server/core/terraform/terraform_client_internal_test.go @@ -344,7 +344,7 @@ func TestDefaultClient_RunCommandAsync_ExitOne(t *testing.T) { _, outCh := client.RunCommandAsync(ctx, tmp, []string{"dying", "&&", "exit", "1"}, map[string]string{}, nil, "workspace") out, err := waitCh(outCh) - ErrEquals(t, fmt.Sprintf(`running "echo dying && exit 1" in %q: exit status 1`, tmp), err) + ErrEquals(t, fmt.Sprintf(`running 'sh -c "echo dying && exit 1"' in '%s': exit status 1`, tmp), err) // Test that we still get our output. Equals(t, "dying", out) diff --git a/server/events/mocks/mock_custom_step_runner.go b/server/events/mocks/mock_custom_step_runner.go index 8805706322..7662d22ba0 100644 --- a/server/events/mocks/mock_custom_step_runner.go +++ b/server/events/mocks/mock_custom_step_runner.go @@ -26,7 +26,7 @@ func NewMockCustomStepRunner(options ...pegomock.Option) *MockCustomStepRunner { func (mock *MockCustomStepRunner) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockCustomStepRunner) FailHandler() pegomock.FailHandler { return mock.fail } -func (mock *MockCustomStepRunner) Run(ctx command.ProjectContext, cmd string, path string, envs map[string]string, streamOutput bool, postProcessOutput valid.PostProcessRunOutputOption) (string, error) { +func (mock *MockCustomStepRunner) Run(ctx command.ProjectContext, shell *valid.CommandShell, cmd string, path string, envs map[string]string, streamOutput bool, postProcessOutput valid.PostProcessRunOutputOption) (string, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockCustomStepRunner().") } @@ -82,7 +82,7 @@ type VerifierMockCustomStepRunner struct { timeout time.Duration } -func (verifier *VerifierMockCustomStepRunner) Run(ctx command.ProjectContext, cmd string, path string, envs map[string]string, streamOutput bool, postProcessOutput valid.PostProcessRunOutputOption) *MockCustomStepRunner_Run_OngoingVerification { +func (verifier *VerifierMockCustomStepRunner) Run(ctx command.ProjectContext, shell *valid.CommandShell, cmd string, path string, envs map[string]string, streamOutput bool, postProcessOutput valid.PostProcessRunOutputOption) *MockCustomStepRunner_Run_OngoingVerification { params := []pegomock.Param{ctx, cmd, path, envs, streamOutput, postProcessOutput} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Run", params, verifier.timeout) return &MockCustomStepRunner_Run_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} diff --git a/server/events/mocks/mock_env_step_runner.go b/server/events/mocks/mock_env_step_runner.go index bfc7f97a57..0d99311987 100644 --- a/server/events/mocks/mock_env_step_runner.go +++ b/server/events/mocks/mock_env_step_runner.go @@ -4,10 +4,12 @@ package mocks import ( - pegomock "github.com/petergtz/pegomock/v4" - command "github.com/runatlantis/atlantis/server/events/command" "reflect" "time" + + pegomock "github.com/petergtz/pegomock/v4" + "github.com/runatlantis/atlantis/server/core/config/valid" + command "github.com/runatlantis/atlantis/server/events/command" ) type MockEnvStepRunner struct { @@ -25,7 +27,7 @@ func NewMockEnvStepRunner(options ...pegomock.Option) *MockEnvStepRunner { func (mock *MockEnvStepRunner) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockEnvStepRunner) FailHandler() pegomock.FailHandler { return mock.fail } -func (mock *MockEnvStepRunner) Run(ctx command.ProjectContext, cmd string, value string, path string, envs map[string]string) (string, error) { +func (mock *MockEnvStepRunner) Run(ctx command.ProjectContext, shell *valid.CommandShell, cmd string, value string, path string, envs map[string]string) (string, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockEnvStepRunner().") } @@ -81,7 +83,7 @@ type VerifierMockEnvStepRunner struct { timeout time.Duration } -func (verifier *VerifierMockEnvStepRunner) Run(ctx command.ProjectContext, cmd string, value string, path string, envs map[string]string) *MockEnvStepRunner_Run_OngoingVerification { +func (verifier *VerifierMockEnvStepRunner) Run(ctx command.ProjectContext, shell *valid.CommandShell, cmd string, value string, path string, envs map[string]string) *MockEnvStepRunner_Run_OngoingVerification { params := []pegomock.Param{ctx, cmd, value, path, envs} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Run", params, verifier.timeout) return &MockEnvStepRunner_Run_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} diff --git a/server/events/project_command_runner.go b/server/events/project_command_runner.go index 153269c7e2..26d4dc2cc2 100644 --- a/server/events/project_command_runner.go +++ b/server/events/project_command_runner.go @@ -65,20 +65,42 @@ type StepRunner interface { // CustomStepRunner runs custom run steps. type CustomStepRunner interface { // Run cmd in path. - Run(ctx command.ProjectContext, cmd string, path string, envs map[string]string, streamOutput bool, postProcessOutput valid.PostProcessRunOutputOption) (string, error) + Run( + ctx command.ProjectContext, + shell *valid.CommandShell, + cmd string, + path string, + envs map[string]string, + streamOutput bool, + postProcessOutput valid.PostProcessRunOutputOption, + ) (string, error) } //go:generate pegomock generate --package mocks -o mocks/mock_env_step_runner.go EnvStepRunner // EnvStepRunner runs env steps. type EnvStepRunner interface { - Run(ctx command.ProjectContext, cmd string, value string, path string, envs map[string]string) (string, error) + Run( + ctx command.ProjectContext, + shell *valid.CommandShell, + cmd string, + value string, + path string, + envs map[string]string, + ) (string, error) } // MultiEnvStepRunner runs multienv steps. type MultiEnvStepRunner interface { // Run cmd in path. - Run(ctx command.ProjectContext, cmd string, path string, envs map[string]string, postProcessOutput valid.PostProcessRunOutputOption) (string, error) + Run( + ctx command.ProjectContext, + shell *valid.CommandShell, + cmd string, + path string, + envs map[string]string, + postProcessOutput valid.PostProcessRunOutputOption, + ) (string, error) } //go:generate pegomock generate --package mocks -o mocks/mock_webhooks_sender.go WebhooksSender @@ -790,15 +812,15 @@ func (p *DefaultProjectCommandRunner) runSteps(steps []valid.Step, ctx command.P case "state_rm": out, err = p.StateRmStepRunner.Run(ctx, step.ExtraArgs, absPath, envs) case "run": - out, err = p.RunStepRunner.Run(ctx, step.RunCommand, absPath, envs, true, step.Output) + out, err = p.RunStepRunner.Run(ctx, step.RunShell, step.RunCommand, absPath, envs, true, step.Output) case "env": - out, err = p.EnvStepRunner.Run(ctx, step.RunCommand, step.EnvVarValue, absPath, envs) + out, err = p.EnvStepRunner.Run(ctx, step.RunShell, step.RunCommand, step.EnvVarValue, absPath, envs) envs[step.EnvVarName] = out // We reset out to the empty string because we don't want it to // be printed to the PR, it's solely to set the environment variable. out = "" case "multienv": - out, err = p.MultiEnvStepRunner.Run(ctx, step.RunCommand, absPath, envs, step.Output) + out, err = p.MultiEnvStepRunner.Run(ctx, step.RunShell, step.RunCommand, absPath, envs, step.Output) } if out != "" { diff --git a/server/events/project_command_runner_test.go b/server/events/project_command_runner_test.go index d241d44569..68548efdd0 100644 --- a/server/events/project_command_runner_test.go +++ b/server/events/project_command_runner_test.go @@ -99,7 +99,7 @@ func TestDefaultProjectCommandRunner_Plan(t *testing.T) { When(mockInit.Run(ctx, nil, repoDir, expEnvs)).ThenReturn("init", nil) When(mockPlan.Run(ctx, nil, repoDir, expEnvs)).ThenReturn("plan", nil) When(mockApply.Run(ctx, nil, repoDir, expEnvs)).ThenReturn("apply", nil) - When(mockRun.Run(ctx, "", repoDir, expEnvs, true, "")).ThenReturn("run", nil) + When(mockRun.Run(ctx, nil, "", repoDir, expEnvs, true, "")).ThenReturn("run", nil) res := runner.Plan(ctx) Assert(t, res.PlanSuccess != nil, "exp plan success") @@ -115,7 +115,7 @@ func TestDefaultProjectCommandRunner_Plan(t *testing.T) { case "apply": mockApply.VerifyWasCalledOnce().Run(ctx, nil, repoDir, expEnvs) case "run": - mockRun.VerifyWasCalledOnce().Run(ctx, "", repoDir, expEnvs, true, "") + mockRun.VerifyWasCalledOnce().Run(ctx, nil, "", repoDir, expEnvs, true, "") } } } @@ -455,8 +455,8 @@ func TestDefaultProjectCommandRunner_Apply(t *testing.T) { When(mockInit.Run(ctx, nil, repoDir, expEnvs)).ThenReturn("init", nil) When(mockPlan.Run(ctx, nil, repoDir, expEnvs)).ThenReturn("plan", nil) When(mockApply.Run(ctx, nil, repoDir, expEnvs)).ThenReturn("apply", nil) - When(mockRun.Run(ctx, "", repoDir, expEnvs, true, "")).ThenReturn("run", nil) - When(mockEnv.Run(ctx, "", "value", repoDir, make(map[string]string))).ThenReturn("value", nil) + When(mockRun.Run(ctx, nil, "", repoDir, expEnvs, true, "")).ThenReturn("run", nil) + When(mockEnv.Run(ctx, nil, "", "value", repoDir, make(map[string]string))).ThenReturn("value", nil) res := runner.Apply(ctx) Equals(t, c.expOut, res.ApplySuccess) @@ -471,9 +471,9 @@ func TestDefaultProjectCommandRunner_Apply(t *testing.T) { case "apply": mockApply.VerifyWasCalledOnce().Run(ctx, nil, repoDir, expEnvs) case "run": - mockRun.VerifyWasCalledOnce().Run(ctx, "", repoDir, expEnvs, true, "") + mockRun.VerifyWasCalledOnce().Run(ctx, nil, "", repoDir, expEnvs, true, "") case "env": - mockEnv.VerifyWasCalledOnce().Run(ctx, "", "value", repoDir, expEnvs) + mockEnv.VerifyWasCalledOnce().Run(ctx, nil, "", "value", repoDir, expEnvs) } } })