Repository: runatlantis/atlantis (language: go)

Issue:
toggle to silence `multienv` output
<!--- Please keep this note for the community --->

### Community Note

- Please vote on this issue by adding a 👍 [reaction](https://blog.github.com/2016-03-10-add-reactions-to-pull-requests-issues-and-comments/) to the original issue to help the community and maintainers prioritize this request. Searching for pre-existing feature requests helps us consolidate datapoints for identical requirements into a single place, thank you!
- Please do not leave "+1" or other comments that do not add relevant new information or questions, they generate extra noise for issue followers and do not help prioritize the request.
- If you are interested in working on this issue or have submitted a pull request, please leave a comment.

<!--- Thank you for keeping this note for the community --->

---

- [x] I'd be willing to implement this feature ([contributing guide](https://github.com/runatlantis/atlantis/blob/main/CONTRIBUTING.md))

**Describe the user story**
_As an end-user of Atlantis, when [`multienv`](https://www.runatlantis.io/docs/custom-workflows.html#multiple-environment-variables-multienv-command) is used, I don't want to see this output._

**Describe the solution you'd like**
Environment variable to disable `multienv` output, e.g. `ATLANTIS_MULTIENV_OUTPUT`.

This would suppress the following output when Atlantis runs a plan/apply:

```
Dynamic environment variables added:
FOO_BAR_ENV
FOO_BAZ_ENV
...
...
```

`multienv` is powerful for configuration within workflows, but when used has the disadvantage of reducing visibility that Atlantis provides by outputting redundant information. 

**Describe the drawbacks of your solution**
I can't think of any drawbacks, as this would be opt-in and someone using it should be aware of the loss of contextual information from [`multienv`](https://www.runatlantis.io/docs/custom-workflows.html#multiple-environment-variables-multienv-command). 

**Describe alternatives you've considered**
- Using a standard `env` instead with output disabled, however that defeats the flexibility of the `multienv` directive.
- Implementing a toggle like `output: hide`, which is used in `run` directives, instead of an environment variable toggle.


Discussion / hints:

Fix patch:
diff --git a/runatlantis.io/docs/custom-workflows.md b/runatlantis.io/docs/custom-workflows.md
index 85c1330a7a..f1c3a043b2 100644
--- a/runatlantis.io/docs/custom-workflows.md
+++ b/runatlantis.io/docs/custom-workflows.md
@@ -625,18 +625,30 @@ as the environment variable value.
 The `multienv` command allows you to set dynamic number of multiple environment variables that will be available
 to all steps defined **below** the `multienv` step.
 
+Compact:
 ```yaml
 - multienv: custom-command
 ```
+| Key      | Type   | Default | Required | Description                                                |
+|----------|--------|---------|----------|------------------------------------------------------------|
+| multienv | string | none    | no       | Run a custom command and add printed environment variables |
 
-| Key      | Type   | Default | Required | Description                                                                    |
-|----------|--------|---------|----------|--------------------------------------------------------------------------------|
-| multienv | string | none    | no       | Run a custom command and add set environment variables according to the result |
+Full:
+```yaml
+- multienv:
+    command: custom-command
+    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 |
 
-The result of the executed command must have a fixed format:
-EnvVar1Name=value1,EnvVar2Name=value2,EnvVar3Name=value3
+The output of the command execution must have the following format:
+`EnvVar1Name=value1,EnvVar2Name=value2,EnvVar3Name=value3`
 
-The name-value pairs in the result are added as environment variables if success is true otherwise the workflow execution stops with error and the errorMessage is getting displayed.
+The name-value pairs in the output are added as environment variables if command execution is successful, otherwise the workflow execution is interrupted with an error and the errorMessage is returned.
 
 ::: tip Notes
 
diff --git a/server/core/config/raw/step.go b/server/core/config/raw/step.go
index 68f8899717..581be49c64 100644
--- a/server/core/config/raw/step.go
+++ b/server/core/config/raw/step.go
@@ -40,6 +40,9 @@ const (
 //     name: test
 //     command: echo 312
 //     value: value
+//   - multienv:
+//     command: envs.sh
+//     outpiut: hide
 //   - run:
 //     command: my custom command
 //     output: hide
@@ -57,8 +60,8 @@ 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
-	// EnvOrRun will be set in case #2 above.
-	EnvOrRun map[string]map[string]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.
@@ -146,7 +149,7 @@ func (s Step) Validate() error {
 		return nil
 	}
 
-	envOrRunStep := func(value interface{}) error {
+	envOrRunOrMultiEnvStep := func(value interface{}) error {
 		elem := value.(map[string]map[string]string)
 		var keys []string
 		for k := range elem {
@@ -192,19 +195,21 @@ func (s Step) Validate() error {
 				return fmt.Errorf("env steps only support one of the %q or %q keys, found both",
 					ValueArgKey, CommandArgKey)
 			}
-		case RunStepName:
+		case RunStepName, MultiEnvStepName:
 			argsCopy := make(map[string]string)
 			for k, v := range args {
 				argsCopy[k] = v
 			}
 			args = argsCopy
 			if _, ok := args[CommandArgKey]; !ok {
-				return fmt.Errorf("run step must have a %q key set", CommandArgKey)
+				return fmt.Errorf("%q step must have a %q key set", stepName, CommandArgKey)
 			}
 			delete(args, CommandArgKey)
 			if v, ok := args[OutputArgKey]; ok {
-				if !(v == valid.PostProcessRunOutputShow || v == valid.PostProcessRunOutputHide || v == valid.PostProcessRunOutputStripRefreshing) {
+				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)
@@ -215,7 +220,7 @@ func (s Step) Validate() error {
 				}
 				// Sort so tests can be deterministic.
 				sort.Strings(argKeys)
-				return fmt.Errorf("run steps only support keys %q, %q and %q, found extra keys %q", RunStepName, CommandArgKey, OutputArgKey, strings.Join(argKeys, ","))
+				return fmt.Errorf("%q steps only support keys %q and %q, found extra keys %q", stepName, CommandArgKey, OutputArgKey, strings.Join(argKeys, ","))
 			}
 		default:
 			return fmt.Errorf("%q is not a valid step type", stepName)
@@ -224,7 +229,7 @@ func (s Step) Validate() error {
 		return nil
 	}
 
-	runStep := func(value interface{}) error {
+	runOrMultiEnvStep := func(value interface{}) error {
 		elem := value.(map[string]string)
 		var keys []string
 		for k := range elem {
@@ -238,7 +243,7 @@ func (s Step) Validate() error {
 				len(keys), strings.Join(keys, ","))
 		}
 		for stepName := range elem {
-			if stepName != RunStepName && stepName != MultiEnvStepName {
+			if !(stepName == RunStepName || stepName == MultiEnvStepName) {
 				return fmt.Errorf("%q is not a valid step type", stepName)
 			}
 		}
@@ -251,11 +256,11 @@ func (s Step) Validate() error {
 	if len(s.Map) > 0 {
 		return validation.Validate(s.Map, validation.By(extraArgs))
 	}
-	if len(s.EnvOrRun) > 0 {
-		return validation.Validate(s.EnvOrRun, validation.By(envOrRunStep))
+	if len(s.CommandMap) > 0 {
+		return validation.Validate(s.CommandMap, validation.By(envOrRunOrMultiEnvStep))
 	}
 	if len(s.StringVal) > 0 {
-		return validation.Validate(s.StringVal, validation.By(runStep))
+		return validation.Validate(s.StringVal, validation.By(runOrMultiEnvStep))
 	}
 	return errors.New("step element is empty")
 }
@@ -269,10 +274,10 @@ func (s Step) ToValid() valid.Step {
 	}
 
 	// This will trigger in case #2 (see Step docs).
-	if len(s.EnvOrRun) > 0 {
+	if len(s.CommandMap) > 0 {
 		// 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.EnvOrRun {
+		for stepName, stepArgs := range s.CommandMap {
 			step := valid.Step{
 				StepName:    stepName,
 				EnvVarName:  stepArgs[NameArgKey],
@@ -356,7 +361,7 @@ func (s *Step) unmarshalGeneric(unmarshal func(interface{}) error) error {
 	var envStep map[string]map[string]string
 	err = unmarshal(&envStep)
 	if err == nil {
-		s.EnvOrRun = envStep
+		s.CommandMap = envStep
 		return nil
 	}
 
@@ -379,8 +384,8 @@ func (s Step) marshalGeneric() (interface{}, error) {
 		return s.StringVal, nil
 	} else if len(s.Map) != 0 {
 		return s.Map, nil
-	} else if len(s.EnvOrRun) != 0 {
-		return s.EnvOrRun, nil
+	} else if len(s.CommandMap) != 0 {
+		return s.CommandMap, nil
 	} else if s.Key != nil {
 		return s.Key, nil
 	}
diff --git a/server/core/runtime/multienv_step_runner.go b/server/core/runtime/multienv_step_runner.go
index 515eb66896..17e2ae1963 100644
--- a/server/core/runtime/multienv_step_runner.go
+++ b/server/core/runtime/multienv_step_runner.go
@@ -16,32 +16,39 @@ 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) (string, error) {
-	res, err := r.RunStepRunner.Run(ctx, command, path, envs, false, valid.PostProcessRunOutputShow)
+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)
 	if err != nil {
 		return "", err
 	}
 
+	var sb strings.Builder
 	if len(res) == 0 {
-		return "No dynamic environment variable added", nil
-	}
+		sb.WriteString("No dynamic environment variable added")
+	} else {
+		sb.WriteString("Dynamic environment variables added:\n")
 
-	var sb strings.Builder
-	sb.WriteString("Dynamic environment variables added:\n")
+		vars, err := parseMultienvLine(res)
+		if err != nil {
+			return "", fmt.Errorf("Invalid environment variable definition: %s (%w)", res, err)
+		}
 
-	vars, err := parseMultienvLine(res)
-	if err != nil {
-		return "", fmt.Errorf("Invalid environment variable definition: %s (%w)", res, err)
+		for i := 0; i < len(vars); i += 2 {
+			key := vars[i]
+			envs[key] = vars[i+1]
+			sb.WriteString(key)
+			sb.WriteRune('\n')
+		}
 	}
 
-	for i := 0; i < len(vars); i += 2 {
-		key := vars[i]
-		envs[key] = vars[i+1]
-		sb.WriteString(key)
-		sb.WriteRune('\n')
+	switch postProcessOutput {
+	case valid.PostProcessRunOutputHide:
+		return "", nil
+	case valid.PostProcessRunOutputShow:
+		return sb.String(), nil
+	default:
+		return sb.String(), nil
 	}
-
-	return sb.String(), nil
 }
 
 func parseMultienvLine(in string) ([]string, error) {
diff --git a/server/events/project_command_runner.go b/server/events/project_command_runner.go
index cd1b2e0d15..4d5c0dcf76 100644
--- a/server/events/project_command_runner.go
+++ b/server/events/project_command_runner.go
@@ -78,7 +78,7 @@ type EnvStepRunner interface {
 // MultiEnvStepRunner runs multienv steps.
 type MultiEnvStepRunner interface {
 	// Run cmd in path.
-	Run(ctx command.ProjectContext, cmd string, path string, envs map[string]string) (string, error)
+	Run(ctx command.ProjectContext, 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
@@ -795,7 +795,7 @@ func (p *DefaultProjectCommandRunner) runSteps(steps []valid.Step, ctx command.P
 			// 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)
+			out, err = p.MultiEnvStepRunner.Run(ctx, step.RunCommand, absPath, envs, step.Output)
 		}
 
 		if out != "" {


Test patch:
diff --git a/server/core/config/raw/step_test.go b/server/core/config/raw/step_test.go
index 72003e2c01..f47c497e6f 100644
--- a/server/core/config/raw/step_test.go
+++ b/server/core/config/raw/step_test.go
@@ -81,7 +81,7 @@ env:
   value: direct_value
   name: test`,
 			exp: raw.Step{
-				EnvOrRun: EnvOrRunType{
+				CommandMap: EnvType{
 					"env": {
 						"value": "direct_value",
 						"name":  "test",
@@ -96,7 +96,7 @@ env:
   command: echo 123
   name: test`,
 			exp: raw.Step{
-				EnvOrRun: EnvOrRunType{
+				CommandMap: EnvType{
 					"env": {
 						"command": "echo 123",
 						"name":    "test",
@@ -134,10 +134,10 @@ key: value`,
 			description: "empty",
 			input:       "",
 			exp: raw.Step{
-				Key:       nil,
-				Map:       nil,
-				StringVal: nil,
-				EnvOrRun:  nil,
+				Key:        nil,
+				Map:        nil,
+				StringVal:  nil,
+				CommandMap: nil,
 			},
 		},
 
@@ -227,7 +227,7 @@ func TestStep_Validate(t *testing.T) {
 		{
 			description: "env",
 			input: raw.Step{
-				EnvOrRun: EnvOrRunType{
+				CommandMap: EnvType{
 					"env": {
 						"name":    "test",
 						"command": "echo 123",
@@ -283,7 +283,7 @@ func TestStep_Validate(t *testing.T) {
 		{
 			description: "multiple keys in env",
 			input: raw.Step{
-				EnvOrRun: EnvOrRunType{
+				CommandMap: EnvType{
 					"key1": nil,
 					"key2": nil,
 				},
@@ -312,7 +312,7 @@ func TestStep_Validate(t *testing.T) {
 		{
 			description: "invalid key in env",
 			input: raw.Step{
-				EnvOrRun: EnvOrRunType{
+				CommandMap: EnvType{
 					"invalid": nil,
 				},
 			},
@@ -353,7 +353,7 @@ func TestStep_Validate(t *testing.T) {
 		{
 			description: "env step with no name key set",
 			input: raw.Step{
-				EnvOrRun: EnvOrRunType{
+				CommandMap: EnvType{
 					"env": {
 						"value": "value",
 					},
@@ -364,7 +364,7 @@ func TestStep_Validate(t *testing.T) {
 		{
 			description: "env step with invalid key",
 			input: raw.Step{
-				EnvOrRun: EnvOrRunType{
+				CommandMap: EnvType{
 					"env": {
 						"abc":      "",
 						"invalid2": "",
@@ -376,7 +376,7 @@ func TestStep_Validate(t *testing.T) {
 		{
 			description: "env step with both command and value set",
 			input: raw.Step{
-				EnvOrRun: EnvOrRunType{
+				CommandMap: EnvType{
 					"env": {
 						"name":    "name",
 						"command": "command",
@@ -454,7 +454,7 @@ func TestStep_ToValid(t *testing.T) {
 		{
 			description: "env step",
 			input: raw.Step{
-				EnvOrRun: EnvOrRunType{
+				CommandMap: EnvType{
 					"env": {
 						"name":    "test",
 						"command": "echo 123",
@@ -561,7 +561,7 @@ func TestStep_ToValid(t *testing.T) {
 		{
 			description: "run step with output",
 			input: raw.Step{
-				EnvOrRun: EnvOrRunType{
+				CommandMap: RunType{
 					"run": {
 						"command": "my 'run command'",
 						"output":  "hide",
@@ -574,6 +574,34 @@ func TestStep_ToValid(t *testing.T) {
 				Output:     "hide",
 			},
 		},
+		{
+			description: "multienv step",
+			input: raw.Step{
+				StringVal: map[string]string{
+					"multienv": "envs.sh",
+				},
+			},
+			exp: valid.Step{
+				StepName:   "multienv",
+				RunCommand: "envs.sh",
+			},
+		},
+		{
+			description: "multienv step with output",
+			input: raw.Step{
+				CommandMap: MultiEnvType{
+					"multienv": {
+						"command": "envs.sh",
+						"output":  "hide",
+					},
+				},
+			},
+			exp: valid.Step{
+				StepName:   "multienv",
+				RunCommand: "envs.sh",
+				Output:     "hide",
+			},
+		},
 	}
 	for _, c := range cases {
 		t.Run(c.description, func(t *testing.T) {
@@ -583,4 +611,6 @@ func TestStep_ToValid(t *testing.T) {
 }
 
 type MapType map[string]map[string][]string
-type EnvOrRunType 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
diff --git a/server/core/runtime/multienv_step_runner_test.go b/server/core/runtime/multienv_step_runner_test.go
index f7d6b1132f..adf51a8b60 100644
--- a/server/core/runtime/multienv_step_runner_test.go
+++ b/server/core/runtime/multienv_step_runner_test.go
@@ -5,6 +5,7 @@ import (
 
 	version "github.com/hashicorp/go-version"
 	. "github.com/petergtz/pegomock/v4"
+	"github.com/runatlantis/atlantis/server/core/config/valid"
 	"github.com/runatlantis/atlantis/server/core/runtime"
 	"github.com/runatlantis/atlantis/server/core/terraform/mocks"
 	"github.com/runatlantis/atlantis/server/events/command"
@@ -84,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)
+			value, err := multiEnvStepRunner.Run(ctx, c.Command, tmpDir, envMap, valid.PostProcessRunOutputShow)
 			if c.ExpErr != "" {
 				ErrContains(t, c.ExpErr, err)
 				return