Skip to content

Commit

Permalink
add condition for script to execute
Browse files Browse the repository at this point in the history
  • Loading branch information
umputun committed May 14, 2023
1 parent 685652d commit bd9ec2f
Show file tree
Hide file tree
Showing 6 changed files with 154 additions and 36 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,7 @@ Each command type supports the following options:
- `local`: if set to `true` the command will be executed on the local host (the one running the `spot` command) instead of the remote host(s).
- `sudo`: if set to `true` the command will be executed with `sudo` privileges.
- `only_on`: optional, allows to set a list of host names or addresses where the command will be executed. If not set, the command will be executed on all hosts. For example, `only_on: [host1, host2]` will execute command on `host1` and `host2` only. This option also supports reversed condition, so if user wants to execute command on all hosts except some, `!` prefix can be used. For example, `only_on: [!host1, !host2]` will execute command on all hosts except `host1` and `host2`.
- `cond`: defines a condition for the command to be executed. The condition is a valid shell command that will be executed on the remote host(s) and if it returns 0, the primary command will be executed. For example, `cond: "test -f /tmp/foo"` will execute the primary script command only if the file `/tmp/foo` exists. `cond` option supported for `script` command type only.

example setting `ignore_errors`, `no_auto` and `only_on` options:

Expand Down
19 changes: 18 additions & 1 deletion pkg/config/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,9 @@ type Cmd struct {
Script string `yaml:"script" toml:"script,multiline"`
Environment map[string]string `yaml:"env" toml:"env"`
Options CmdOptions `yaml:"options" toml:"options,omitempty"`
Condition string `yaml:"cond" toml:"cond,omitempty"`

Secrets map[string]string `yaml:"-" toml:"-"` // loaded Secrets, filled by playbook
Secrets map[string]string `yaml:"-" toml:"-"` // loaded secrets, filled by playbook
}

// CmdOptions defines options for a command
Expand Down Expand Up @@ -98,6 +99,22 @@ func (cmd *Cmd) GetWait() (command string, rdr io.Reader) {
return cmd.scriptCommand(cmd.Wait.Command), nil
}

// GetCondition returns a condition command as a string and an io.Reader based on whether the command is a single line or multiline
func (cmd *Cmd) GetCondition() (command string, rdr io.Reader) {
if cmd.Condition == "" {
return "", nil
}

elems := strings.Split(cmd.Condition, "\n")
if len(elems) > 1 {
log.Printf("[DEBUG] condition %q is multiline, using script file", cmd.Name)
return "", cmd.scriptFile(cmd.Condition)
}

log.Printf("[DEBUG] condition %q is single line, using condition string", cmd.Name)
return cmd.scriptCommand(cmd.Condition), nil
}

// scriptCommand concatenates all script lines in commands into one a string to be executed by shell.
// Empty string is returned if no script is defined.
func (cmd *Cmd) scriptCommand(inp string) string {
Expand Down
45 changes: 45 additions & 0 deletions pkg/config/command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -483,3 +483,48 @@ echo 'Goodbye, World!'
})
}
}

func TestCmd_GetCondition(t *testing.T) {
testCases := []struct {
name string
cmd *Cmd
expectedCmd string
expectedReader io.Reader
}{
{
name: "single-line wait command",
cmd: &Cmd{Condition: "echo Hello, World!"},
expectedCmd: `sh -c 'echo Hello, World!'`,
},
{
name: "multi-line wait command",
cmd: &Cmd{Condition: `echo 'Hello, World!'
echo 'Goodbye, World!'`,
},
expectedReader: strings.NewReader(`#!/bin/sh
set -e
echo 'Hello, World!'
echo 'Goodbye, World!'
`),
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
cmd, reader := tc.cmd.GetCondition()
assert.Equal(t, tc.expectedCmd, cmd)

if tc.expectedReader != nil {
expectedBytes, err := io.ReadAll(tc.expectedReader)
assert.NoError(t, err)

actualBytes, err := io.ReadAll(reader)
assert.NoError(t, err)

assert.Equal(t, string(expectedBytes), string(actualBytes))
} else {
assert.Nil(t, reader)
}
})
}
}
64 changes: 51 additions & 13 deletions pkg/runner/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,19 @@ type execCmd struct {

const tmpRemoteDir = "/tmp/.spot" // this is a directory on remote host to store temporary files

// script executes a script command on a target host. It can be a single line or multiline script,
// Script executes a script command on a target host. It can be a single line or multiline script,
// this part is translated by the prepScript function.
// If sudo option is set, it will execute the script with sudo. If output contains variables as "setvar foo=bar",
// it will return the variables as map.
func (ec *execCmd) script(ctx context.Context) (details string, vars map[string]string, err error) {
func (ec *execCmd) Script(ctx context.Context) (details string, vars map[string]string, err error) {
cond, err := ec.checkCondition(ctx)
if err != nil {
return "", nil, err
}
if !cond {
return fmt.Sprintf(" {skip: %s}", ec.cmd.Name), nil, nil
}

single, multiRdr := ec.cmd.GetScript()
c, teardown, err := ec.prepScript(ctx, single, multiRdr)
if err != nil {
Expand Down Expand Up @@ -74,10 +82,10 @@ func (ec *execCmd) script(ctx context.Context) (details string, vars map[string]
return details, vars, nil
}

// copy uploads a single file or multiple files (if wildcard is used) to a target host.
// Copy uploads a single file or multiple files (if wildcard is used) to a target host.
// if sudo option is set, it will make a temporary directory and upload the files there,
// then move it to the final destination with sudo script execution.
func (ec *execCmd) copy(ctx context.Context) (details string, vars map[string]string, err error) {
func (ec *execCmd) Copy(ctx context.Context) (details string, vars map[string]string, err error) {
tmpl := templater{hostAddr: ec.hostAddr, hostName: ec.hostName, task: ec.tsk, command: ec.cmd.Name, env: ec.cmd.Environment}

src := tmpl.apply(ec.cmd.Copy.Source)
Expand Down Expand Up @@ -124,8 +132,8 @@ func (ec *execCmd) copy(ctx context.Context) (details string, vars map[string]st
return details, nil, nil
}

// mcopy uploads multiple files to a target host. It calls copy function for each file.
func (ec *execCmd) mcopy(ctx context.Context) (details string, vars map[string]string, err error) {
// Mcopy uploads multiple files to a target host. It calls copy function for each file.
func (ec *execCmd) Mcopy(ctx context.Context) (details string, vars map[string]string, err error) {
msgs := []string{}
tmpl := templater{hostAddr: ec.hostAddr, hostName: ec.hostName, task: ec.tsk, command: ec.cmd.Name, env: ec.cmd.Environment}
for _, c := range ec.cmd.MCopy {
Expand All @@ -134,16 +142,16 @@ func (ec *execCmd) mcopy(ctx context.Context) (details string, vars map[string]s
msgs = append(msgs, fmt.Sprintf("%s -> %s", src, dst))
ecSingle := ec
ecSingle.cmd.Copy = config.CopyInternal{Source: src, Dest: dst, Mkdir: c.Mkdir}
if _, _, err := ecSingle.copy(ctx); err != nil {
if _, _, err := ecSingle.Copy(ctx); err != nil {
return details, nil, fmt.Errorf("can't copy file to %s: %w", ec.hostAddr, err)
}
}
details = fmt.Sprintf(" {copy: %s}", strings.Join(msgs, ", "))
return details, nil, nil
}

// sync synchronizes files from a source to a destination on a target host.
func (ec *execCmd) sync(ctx context.Context) (details string, vars map[string]string, err error) {
// Sync synchronizes files from a source to a destination on a target host.
func (ec *execCmd) Sync(ctx context.Context) (details string, vars map[string]string, err error) {
tmpl := templater{hostAddr: ec.hostAddr, hostName: ec.hostName, task: ec.tsk, command: ec.cmd.Name, env: ec.cmd.Environment}
src := tmpl.apply(ec.cmd.Sync.Source)
dst := tmpl.apply(ec.cmd.Sync.Dest)
Expand All @@ -154,8 +162,8 @@ func (ec *execCmd) sync(ctx context.Context) (details string, vars map[string]st
return details, nil, nil
}

// delete deletes files on a target host. If sudo option is set, it will execute a sudo rm commands.
func (ec *execCmd) delete(ctx context.Context) (details string, vars map[string]string, err error) {
// Delete deletes files on a target host. If sudo option is set, it will execute a sudo rm commands.
func (ec *execCmd) Delete(ctx context.Context) (details string, vars map[string]string, err error) {
tmpl := templater{hostAddr: ec.hostAddr, hostName: ec.hostName, task: ec.tsk, command: ec.cmd.Name, env: ec.cmd.Environment}
loc := tmpl.apply(ec.cmd.Delete.Location)

Expand All @@ -182,9 +190,9 @@ func (ec *execCmd) delete(ctx context.Context) (details string, vars map[string]
return details, nil, nil
}

// wait waits for a command to complete on a target hostAddr. It runs the command in a loop with a check duration
// Wait waits for a command to complete on a target hostAddr. It runs the command in a loop with a check duration
// until the command succeeds or the timeout is exceeded.
func (ec *execCmd) wait(ctx context.Context) (details string, vars map[string]string, err error) {
func (ec *execCmd) Wait(ctx context.Context) (details string, vars map[string]string, err error) {
single, multiRdr := ec.cmd.GetWait()
c, teardown, err := ec.prepScript(ctx, single, multiRdr)
if err != nil {
Expand Down Expand Up @@ -236,6 +244,36 @@ func (ec *execCmd) wait(ctx context.Context) (details string, vars map[string]st
}
}

func (ec *execCmd) checkCondition(ctx context.Context) (bool, error) {
if ec.cmd.Condition == "" {
return true, nil // no condition, always allow
}
single, multiRdr := ec.cmd.GetCondition()
c, teardown, err := ec.prepScript(ctx, single, multiRdr)
if err != nil {
return false, fmt.Errorf("can't prepare condition script on %s: %w", ec.hostAddr, err)
}
defer func() {
if teardown == nil {
return
}
if err = teardown(); err != nil {
log.Printf("[WARN] can't teardown coindition script on %s: %v", ec.hostAddr, err)
}
}()

if ec.cmd.Options.Sudo { // command's sudo also applies to condition script
c = fmt.Sprintf("sudo sh -c %q", c)
}

// run the condition command
if _, err := ec.exec.Run(ctx, c, ec.verbose); err != nil {
log.Printf("[DEBUG] condition not passed on %s: %v", ec.hostAddr, err)
return false, nil
}
return true, nil
}

// prepScript prepares a script for execution. Script can be either a single command or a multiline script.
// In case of a single command, it just applies templates to it. In case of a multiline script, it creates
// a temporary file with the script chmod as +x and uploads to remote host to /tmp.
Expand Down
49 changes: 33 additions & 16 deletions pkg/runner/commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,20 +127,19 @@ func Test_templaterApply(t *testing.T) {
}

func Test_execCmd(t *testing.T) {

testingHostAndPort, teardown := startTestContainer(t)
defer teardown()

ctx := context.Background()
connector, err := executor.NewConnector("testdata/test_ssh_key", time.Second*10)
require.NoError(t, err)
sess, err := connector.Connect(ctx, testingHostAndPort, "my-hostAddr", "test")
require.NoError(t, err)
connector, connErr := executor.NewConnector("testdata/test_ssh_key", time.Second*10)
require.NoError(t, connErr)
sess, errSess := connector.Connect(ctx, testingHostAndPort, "my-hostAddr", "test")
require.NoError(t, errSess)

t.Run("copy a single file", func(t *testing.T) {
ec := execCmd{exec: sess, tsk: &config.Task{Name: "test"}, cmd: config.Cmd{
Copy: config.CopyInternal{Source: "testdata/inventory.yml", Dest: "/tmp/inventory.txt"}}}
details, _, err := ec.copy(ctx)
details, _, err := ec.Copy(ctx)
require.NoError(t, err)
assert.Equal(t, " {copy: testdata/inventory.yml -> /tmp/inventory.txt}", details)
})
Expand All @@ -151,7 +150,7 @@ func Test_execCmd(t *testing.T) {
})
ec := execCmd{exec: sess, tsk: &config.Task{Name: "test"}, cmd: config.Cmd{Wait: config.WaitInternal{
Command: "cat /tmp/wait.done", Timeout: 2 * time.Second, CheckDuration: time.Millisecond * 100}}}
details, _, err := ec.wait(ctx)
details, _, err := ec.Wait(ctx)
require.NoError(t, err)
t.Log(details)
})
Expand All @@ -162,7 +161,7 @@ func Test_execCmd(t *testing.T) {
})
ec := execCmd{exec: sess, tsk: &config.Task{Name: "test"}, cmd: config.Cmd{Wait: config.WaitInternal{
Command: "echo this is wait\ncat /tmp/wait.done", Timeout: 2 * time.Second, CheckDuration: time.Millisecond * 100}}}
details, _, err := ec.wait(ctx)
details, _, err := ec.Wait(ctx)
require.NoError(t, err)
t.Log(details)
})
Expand All @@ -174,23 +173,23 @@ func Test_execCmd(t *testing.T) {
ec := execCmd{exec: sess, tsk: &config.Task{Name: "test"}, cmd: config.Cmd{Wait: config.WaitInternal{
Command: "cat /srv/wait.done", Timeout: 2 * time.Second, CheckDuration: time.Millisecond * 100},
Options: config.CmdOptions{Sudo: true}}}
details, _, err := ec.wait(ctx)
details, _, err := ec.Wait(ctx)
require.NoError(t, err)
t.Log(details)
})

t.Run("wait failed", func(t *testing.T) {
ec := execCmd{exec: sess, tsk: &config.Task{Name: "test"}, cmd: config.Cmd{Wait: config.WaitInternal{
Command: "cat /tmp/wait.never-done", Timeout: 1 * time.Second, CheckDuration: time.Millisecond * 100}}}
_, _, err := ec.wait(ctx)
_, _, err := ec.Wait(ctx)
require.EqualError(t, err, "timeout exceeded")
})

t.Run("wait failed with sudo", func(t *testing.T) {
ec := execCmd{exec: sess, tsk: &config.Task{Name: "test"}, cmd: config.Cmd{Wait: config.WaitInternal{
Command: "cat /srv/wait.never-done", Timeout: 1 * time.Second, CheckDuration: time.Millisecond * 100},
Options: config.CmdOptions{Sudo: true}}}
_, _, err := ec.wait(ctx)
_, _, err := ec.Wait(ctx)
require.EqualError(t, err, "timeout exceeded")
})

Expand All @@ -199,7 +198,7 @@ func Test_execCmd(t *testing.T) {
require.NoError(t, err)
ec := execCmd{exec: sess, tsk: &config.Task{Name: "test"}, cmd: config.Cmd{Delete: config.DeleteInternal{
Location: "/tmp/delete.me"}}}
_, _, err = ec.delete(ctx)
_, _, err = ec.Delete(ctx)
require.NoError(t, err)
})

Expand All @@ -215,7 +214,7 @@ func Test_execCmd(t *testing.T) {
ec := execCmd{exec: sess, tsk: &config.Task{Name: "test"}, cmd: config.Cmd{Delete: config.DeleteInternal{
Location: "/tmp/delete-recursive", Recursive: true}}}

_, _, err = ec.delete(ctx)
_, _, err = ec.Delete(ctx)
require.NoError(t, err)

_, err = sess.Run(ctx, "ls /tmp/delete-recursive", true)
Expand All @@ -228,13 +227,13 @@ func Test_execCmd(t *testing.T) {
ec := execCmd{exec: sess, tsk: &config.Task{Name: "test"}, cmd: config.Cmd{Delete: config.DeleteInternal{
Location: "/srv/delete.me"}, Options: config.CmdOptions{Sudo: false}}}

_, _, err = ec.delete(ctx)
_, _, err = ec.Delete(ctx)
require.Error(t, err, "should fail because of missing sudo")

ec = execCmd{exec: sess, tsk: &config.Task{Name: "test"}, cmd: config.Cmd{Delete: config.DeleteInternal{
Location: "/srv/delete.me"}, Options: config.CmdOptions{Sudo: true}}}

_, _, err = ec.delete(ctx)
_, _, err = ec.Delete(ctx)
require.NoError(t, err, "should fail pass with sudo")
})

Expand All @@ -250,10 +249,28 @@ func Test_execCmd(t *testing.T) {
ec := execCmd{exec: sess, tsk: &config.Task{Name: "test"}, cmd: config.Cmd{Delete: config.DeleteInternal{
Location: "/srv/delete-recursive", Recursive: true}, Options: config.CmdOptions{Sudo: true}}}

_, _, err = ec.delete(ctx)
_, _, err = ec.Delete(ctx)
require.NoError(t, err)

_, err = sess.Run(ctx, "ls /srv/delete-recursive", true)
require.Error(t, err, "should not exist")
})

t.Run("condition false", func(t *testing.T) {
ec := execCmd{exec: sess, tsk: &config.Task{Name: "test"}, cmd: config.Cmd{Condition: "ls /srv/test.condition",
Script: "echo 'condition false'", Name: "test"}}
details, _, err := ec.Script(ctx)
require.NoError(t, err)
assert.Equal(t, " {skip: test}", details)
})

t.Run("condition true", func(t *testing.T) {
_, err := sess.Run(ctx, "sudo touch /srv/test.condition", true)
require.NoError(t, err)
ec := execCmd{exec: sess, tsk: &config.Task{Name: "test"}, cmd: config.Cmd{Condition: "ls -la /srv/test.condition",
Script: "echo condition true", Name: "test"}}
details, _, err := ec.Script(ctx)
require.NoError(t, err)
assert.Equal(t, " {script: sh -c 'echo condition true'}", details)
})
}
12 changes: 6 additions & 6 deletions pkg/runner/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,22 +176,22 @@ func (p *Process) execCommand(ctx context.Context, ec execCmd) (details string,
switch {
case ec.cmd.Script != "":
log.Printf("[DEBUG] execute script %q on %s", ec.cmd.Name, ec.hostAddr)
return ec.script(ctx)
return ec.Script(ctx)
case ec.cmd.Copy.Source != "" && ec.cmd.Copy.Dest != "":
log.Printf("[DEBUG] copy file to %s", ec.hostAddr)
return ec.copy(ctx)
return ec.Copy(ctx)
case len(ec.cmd.MCopy) > 0:
log.Printf("[DEBUG] copy multiple files to %s", ec.hostAddr)
return ec.mcopy(ctx)
return ec.Mcopy(ctx)
case ec.cmd.Sync.Source != "" && ec.cmd.Sync.Dest != "":
log.Printf("[DEBUG] sync files on %s", ec.hostAddr)
return ec.sync(ctx)
return ec.Sync(ctx)
case ec.cmd.Delete.Location != "":
log.Printf("[DEBUG] delete files on %s", ec.hostAddr)
return ec.delete(ctx)
return ec.Delete(ctx)
case ec.cmd.Wait.Command != "":
log.Printf("[DEBUG] wait for command on %s", ec.hostAddr)
return ec.wait(ctx)
return ec.Wait(ctx)
default:
return "", nil, fmt.Errorf("unknown command %q", ec.cmd.Name)
}
Expand Down

0 comments on commit bd9ec2f

Please sign in to comment.