Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow Custom CLI commands to be any (complex) shell commands #260

Merged
merged 8 commits into from Nov 30, 2022
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
28 changes: 14 additions & 14 deletions atmos.yaml
Expand Up @@ -170,20 +170,20 @@ commands:
# Steps support using Go templates and can access all configuration settings (e.g. {{ .ComponentConfig.xxx.yyy.zzz }})
# Steps also have access to the ENV vars defined in the 'env' section of the 'command'
steps:
- 'echo Atmos component from argument: {{ .Arguments.component }}'
- 'echo ATMOS_COMPONENT: $ATMOS_COMPONENT'
- 'echo Atmos stack: {{ .Flags.stack }}'
- 'echo Terraform component: {{ .ComponentConfig.component }}'
- 'echo Backend S3 bucket: {{ .ComponentConfig.backend.bucket }}'
- 'echo Terraform workspace: {{ .ComponentConfig.workspace }}'
- 'echo Namespace: {{ .ComponentConfig.vars.namespace }}'
- 'echo Tenant: {{ .ComponentConfig.vars.tenant }}'
- 'echo Environment: {{ .ComponentConfig.vars.environment }}'
- 'echo Stage: {{ .ComponentConfig.vars.stage }}'
- 'echo settings.spacelift.workspace_enabled: {{ .ComponentConfig.settings.spacelift.workspace_enabled }}'
- 'echo Dependencies: {{ .ComponentConfig.deps }}'
- 'echo settings.config.is_prod: {{ .ComponentConfig.settings.config.is_prod }}'
- 'echo ATMOS_IS_PROD: $ATMOS_IS_PROD'
- 'echo Atmos component from argument: "{{ .Arguments.component }}"'
- 'echo ATMOS_COMPONENT: "$ATMOS_COMPONENT"'
- 'echo Atmos stack: "{{ .Flags.stack }}"'
- 'echo Terraform component: "{{ .ComponentConfig.component }}"'
- 'echo Backend S3 bucket: "{{ .ComponentConfig.backend.bucket }}"'
- 'echo Terraform workspace: "{{ .ComponentConfig.workspace }}"'
- 'echo Namespace: "{{ .ComponentConfig.vars.namespace }}"'
- 'echo Tenant: "{{ .ComponentConfig.vars.tenant }}"'
- 'echo Environment: "{{ .ComponentConfig.vars.environment }}"'
- 'echo Stage: "{{ .ComponentConfig.vars.stage }}"'
- 'echo settings.spacelift.workspace_enabled: "{{ .ComponentConfig.settings.spacelift.workspace_enabled }}"'
- 'echo Dependencies: "{{ .ComponentConfig.deps }}"'
- 'echo settings.config.is_prod: "{{ .ComponentConfig.settings.config.is_prod }}"'
- 'echo ATMOS_IS_PROD: "$ATMOS_IS_PROD"'

# Integrations
integrations:
Expand Down
12 changes: 5 additions & 7 deletions cmd/cmd_utils.go
Expand Up @@ -7,7 +7,6 @@ import (
e "github.com/cloudposse/atmos/internal/exec"
"github.com/spf13/cobra"
"os"
"strings"
"text/template"

cfg "github.com/cloudposse/atmos/pkg/config"
Expand Down Expand Up @@ -192,8 +191,8 @@ func executeCustomCommand(cmd *cobra.Command, args []string, parentCommand *cobr

// If the command to get the value for the ENV var is provided, execute it
if valCommand != "" {
valCommandArgs := strings.Fields(valCommand)
res, err := e.ExecuteShellCommandAndReturnOutput(valCommandArgs[0], valCommandArgs[1:], ".", nil, false, commandConfig.Verbose)
valCommandName := fmt.Sprintf("env-var-%s-valcommand", key)
res, err := e.ExecuteShellAndReturnOutput(valCommand, valCommandName, ".", nil, false, commandConfig.Verbose)
if err != nil {
u.PrintErrorToStdErrorAndExit(err)
}
Expand Down Expand Up @@ -222,15 +221,14 @@ func executeCustomCommand(cmd *cobra.Command, args []string, parentCommand *cobr

// Process Go templates in the command's steps.
// Steps support Go templates and have access to {{ .ComponentConfig.xxx.yyy.zzz }} Go template variables
commandTmpl, err := processTmpl(fmt.Sprintf("step-%d", i), step, data)
commandToRun, err := processTmpl(fmt.Sprintf("step-%d", i), step, data)
if err != nil {
u.PrintErrorToStdErrorAndExit(err)
}
commandToRun := os.ExpandEnv(commandTmpl)

// Execute the command step
stepArgs := strings.Fields(commandToRun)
err = e.ExecuteShellCommand(stepArgs[0], stepArgs[1:], ".", envVarsList, false, commandConfig.Verbose)
commandName := fmt.Sprintf("%s-step-%d", commandConfig.Name, i)
err = e.ExecuteShell(commandToRun, commandName, ".", envVarsList, false, commandConfig.Verbose)
if err != nil {
u.PrintErrorToStdErrorAndExit(err)
}
Expand Down
2 changes: 2 additions & 0 deletions go.mod
Expand Up @@ -21,6 +21,7 @@ require (
github.com/stretchr/testify v1.8.1
github.com/zclconf/go-cty v1.12.1
gopkg.in/yaml.v2 v2.4.0
mvdan.cc/sh/v3 v3.5.1
)

require (
Expand Down Expand Up @@ -111,6 +112,7 @@ require (
golang.org/x/sys v0.0.0-20221010170243-090e33056c14 // indirect
golang.org/x/text v0.4.0 // indirect
golang.org/x/time v0.0.0-20220609170525-579cf78fd858 // indirect
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
google.golang.org/api v0.102.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
Expand Down
3 changes: 3 additions & 0 deletions go.sum
Expand Up @@ -745,6 +745,7 @@ golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20221010170243-090e33056c14 h1:k5II8e6QD8mITdi+okbbmR/cIyEbeXLBhy5Ha4nevyc=
golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
Expand Down Expand Up @@ -1039,6 +1040,8 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
mvdan.cc/sh/v3 v3.5.1 h1:hmP3UOw4f+EYexsJjFxvU38+kn+V/s2CclXHanIBkmQ=
mvdan.cc/sh/v3 v3.5.1/go.mod h1:1JcoyAKm1lZw/2bZje/iYKWicU/KMd0rsyJeKHnsK4E=
oras.land/oras-go v1.2.1 h1:/VcGS8FUy3eEXLl/1vC4QypLHwrfSmgW7ygsoklqKK8=
oras.land/oras-go v1.2.1/go.mod h1:3N11Z5E3c4ZzOjroCl1RtAdB4yNAYl7A27j2SVf913A=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
Expand Down
68 changes: 68 additions & 0 deletions internal/exec/shell_utils.go
@@ -1,13 +1,18 @@
package exec

import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"runtime"
"strings"

u "github.com/cloudposse/atmos/pkg/utils"
"mvdan.cc/sh/v3/expand"
stoned marked this conversation as resolved.
Show resolved Hide resolved
"mvdan.cc/sh/v3/interp"
"mvdan.cc/sh/v3/syntax"
)

// ExecuteShellCommand prints and executes the provided command with args and flags
Expand All @@ -31,6 +36,69 @@ func ExecuteShellCommand(command string, args []string, dir string, env []string
return cmd.Run()
}

// ExecuteShell uses mvdan.cc/sh/v3's parser and interpreter to run a shell script
func ExecuteShell(command string, name string, dir string, env []string, dryRun bool, verbose bool) error {
if verbose {
u.PrintInfo("\nExecuting command:")
fmt.Println(command)
}

parser, err := syntax.NewParser().Parse(strings.NewReader(command), name)
if err != nil {
return err
}

runner, err := interp.New(
interp.Dir(dir),
interp.Env(expand.ListEnviron(append(os.Environ(), env...)...)),
Copy link
Member

@aknysh aknysh Nov 28, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@stoned you removed

commandToRun := os.ExpandEnv(commandTmpl)

which is used to replace ${var} or $var in the command steps at runtime according to the values of the current environment variables.

Does interp.Env(expand do the same thing?

Also, we prob need to simplify

interp.Env(expand.ListEnviron(append(os.Environ(), env...)...)),

by using a local var b/c it's difficult to read

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exactly, mvdan.cc/sh/v3/interp.Env() will setup the environment to use: there is no need to have os.ExpandEnv() making its own substitutions.
And, yes, I will rewrite the call to interp.Env() to be, hopefully, simpler to read.

interp.StdIO(os.Stdin, os.Stdout, os.Stderr),
)
if err != nil {
return err
}


if dryRun {
return nil
}

return runner.Run(context.TODO(), parser)
}

// ExecuteShell uses mvdan.cc/sh/v3's parser and interpreter to run a shell script and capture its standard output
func ExecuteShellAndReturnOutput(command string, name string, dir string, env []string, dryRun bool, verbose bool) (string, error) {
var b bytes.Buffer
parser, err := syntax.NewParser().Parse(strings.NewReader(command), name)
if err != nil {
return "", err
}

runner, err := interp.New(
interp.Dir(dir),
interp.Env(expand.ListEnviron(append(os.Environ(), env...)...)),
interp.StdIO(os.Stdin, &b, os.Stderr),
)
if err != nil {
return "", err
}

if verbose {
aknysh marked this conversation as resolved.
Show resolved Hide resolved
u.PrintInfo("\nExecuting command:")
fmt.Println(command)
}

if dryRun {
return "", nil
}

err = runner.Run(context.TODO(), parser)
if err != nil {
return "", err
}

return b.String(), nil
}

// ExecuteShellCommandAndReturnOutput prints and executes the provided command with args and flags and returns the command output
func ExecuteShellCommandAndReturnOutput(command string, args []string, dir string, env []string, dryRun bool, verbose bool) (string, error) {
aknysh marked this conversation as resolved.
Show resolved Hide resolved
cmd := exec.Command(command, args...)
Expand Down