Skip to content

Commit

Permalink
Dry run mode (#37)
Browse files Browse the repository at this point in the history
* add dry run mode with --dry

* lint: minor warns
  • Loading branch information
umputun committed May 2, 2023
1 parent e1f7b45 commit 0aa555f
Show file tree
Hide file tree
Showing 7 changed files with 262 additions and 2 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ Spot supports the following command-line options:
- `-s`, `--skip=`: Skips the specified commands during the task execution. Providing the `-s` flag multiple times with different command names skips multiple commands.
- `-o`, `--only=`: Runs only the specified commands during the task execution. Providing the `-o` flag multiple times with different command names runs only multiple commands.
- `-e`, `--env=`: Sets the environment variables to be used during the task execution. Providing the `-e` flag multiple times with different environment variables sets multiple environment variables, e.g., `-e VAR1=VALUE1 -e VAR2=VALUE2`.
- `--dry`: Enables dry-run mode, which prints out the commands to be executed without actually executing them.
- `-v`, `--verbose`: Enables verbose mode, providing more detailed output and error messages during the task execution.
- `--dbg`: Enables debug mode, providing even more detailed output and error messages during the task execution as well as diagnostic messages.
- `-h` `--help`: Displays the help message, listing all available command-line options.
Expand Down
85 changes: 85 additions & 0 deletions app/executor/dry.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package executor

import (
"bufio"
"bytes"
"context"
"io"
"log"
"os"
"strings"
)

// Dry is an executor for dry run, just prints commands and files to be copied, synced, deleted.
// Useful for debugging and testing, doesn't actually execute anything.
type Dry struct {
hostAddr string
hostName string
}

// NewDry creates new executor for dry run
func NewDry(hostAddr, hostName string) *Dry {
return &Dry{hostAddr: hostAddr, hostName: hostName}
}

// Run shows the command content, doesn't execute it
func (ex *Dry) Run(_ context.Context, cmd string, verbose bool) (out []string, err error) {
log.Printf("[DEBUG] run %s", cmd)
outLog, _ := MakeOutAndErrWriters(ex.hostAddr, ex.hostName, verbose)
var stdoutBuf bytes.Buffer
mwr := io.MultiWriter(outLog, &stdoutBuf)
mwr.Write([]byte(cmd)) //nolint
for _, line := range strings.Split(stdoutBuf.String(), "\n") {
if line != "" {
out = append(out, line)
}
}
return out, nil
}

// Upload doesn't actually upload, just prints the command
func (ex *Dry) Upload(_ context.Context, local, remote string, mkdir bool) (err error) {
log.Printf("[DEBUG] upload %s to %s, mkdir: %v", local, remote, mkdir)
if strings.Contains(remote, "spot-script") {
outLog, outErr := MakeOutAndErrWriters(ex.hostAddr, ex.hostName, true)
outErr.Write([]byte("command script " + remote)) //nolint
// read local file and write it to outLog
f, err := os.Open(local) //nolint
if err != nil {
return err
}
defer f.Close() //nolint ro file

scanner := bufio.NewScanner(f)
for scanner.Scan() {
outLog.Write([]byte(scanner.Text())) //nolint
}
if err := scanner.Err(); err != nil {
return err
}
}
return nil
}

// Download file from remote server with scp
func (ex *Dry) Download(_ context.Context, remote, local string, mkdir bool) (err error) {
log.Printf("[DEBUG] download %s to %s, mkdir: %v", local, remote, mkdir)
return nil
}

// Sync doesn't sync anything, just prints the command
func (ex *Dry) Sync(_ context.Context, localDir, remoteDir string, del bool) ([]string, error) {
log.Printf("[DEBUG] sync %s to %s, delite: %v", localDir, remoteDir, del)
return nil, nil
}

// Delete doesn't delete anything, just prints the command
func (ex *Dry) Delete(_ context.Context, remoteFile string, recursive bool) (err error) {
log.Printf("[DEBUG] delete %s, recursive: %v", remoteFile, recursive)
return nil
}

// Close doesn't do anything
func (ex *Dry) Close() error {
return nil
}
131 changes: 131 additions & 0 deletions app/executor/dry_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package executor

import (
"bytes"
"context"
"io"
"log"
"os"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestDry_Run(t *testing.T) {
ctx := context.Background()
dry := NewDry("hostAddr", "hostName")
res, err := dry.Run(ctx, "ls -la /srv", true)
require.NoError(t, err)
require.Len(t, res, 1)
require.Equal(t, "ls -la /srv", res[0])
}

func TestDryUpload(t *testing.T) {
tempFile, err := os.CreateTemp("", "spot-script")
require.NoError(t, err)
defer os.Remove(tempFile.Name())

content := "line1\nline2\nline3\n"
_, err = tempFile.WriteString(content)
require.NoError(t, err)
tempFile.Close()

dry := &Dry{
hostAddr: "host1.example.com",
hostName: "host1",
}

stdout := captureOutput(func() {
err = dry.Upload(context.Background(), tempFile.Name(), "remote/path/spot-script", true)
})

require.NoError(t, err)

// check for logs with the "command script" and file content in the output
assert.Contains(t, stdout, "command script remote/path/spot-script",
"expected log entry containing 'command script' not found")
require.Contains(t, stdout, "line1", "expected log entry containing 'line1' not found")
require.Contains(t, stdout, "line2", "expected log entry containing 'line2' not found")
require.Contains(t, stdout, "line3", "expected log entry containing 'line3' not found")
}

func TestDryUpload_FileOpenError(t *testing.T) {
nonExistentFile := "non_existent_file"

dry := &Dry{
hostAddr: "host1.example.com",
hostName: "host1",
}

err := dry.Upload(context.Background(), nonExistentFile, "remote/path/spot-script", true)
require.Error(t, err)
assert.Contains(t, err.Error(), "open non_existent_file", "expected error message containing 'open non_existent_file' not found")
}

func TestDryOperations(t *testing.T) {
dry := &Dry{
hostAddr: "host1.example.com",
hostName: "host1",
}

testCases := []struct {
name string
operation func() error
expectedLog string
}{
{
name: "download",
operation: func() error {
return dry.Download(context.Background(), "remote/path", "local/path", true)
},
expectedLog: "[DEBUG] download local/path to remote/path, mkdir: true",
},
{
name: "sync",
operation: func() error {
_, err := dry.Sync(context.Background(), "local/dir", "remote/dir", true)
return err
},
expectedLog: "[DEBUG] sync local/dir to remote/dir, delite: true",
},
{
name: "delete",
operation: func() error {
return dry.Delete(context.Background(), "remote/file", true)
},
expectedLog: "[DEBUG] delete remote/file, recursive: true",
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
buff := bytes.NewBuffer(nil)
log.SetOutput(buff)
err := tc.operation()
require.NoError(t, err)
stdout := buff.String()
// check for logs with the expected log entry in the output
assert.Contains(t, stdout, tc.expectedLog, "expected log entry not found")
})
}
}

func captureOutput(f func()) (stdout string) {
// redirect stdout
oldStdout := os.Stdout
rout, wout, _ := os.Pipe()
os.Stdout = wout

// execute the function
f()

// stop capturing
wout.Close()
os.Stdout = oldStdout

// read the captured output
stdoutBuf, _ := io.ReadAll(rout)

return string(stdoutBuf)
}
2 changes: 1 addition & 1 deletion app/executor/remote.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ func (ex *Remote) Download(ctx context.Context, remote, local string, mkdir bool
if ex.client == nil {
return fmt.Errorf("client is not connected")
}
log.Printf("[DEBUG] upload %s to %s", local, remote)
log.Printf("[DEBUG] download %s to %s", local, remote)

host, port, err := net.SplitHostPort(ex.hostAddr)
if err != nil {
Expand Down
13 changes: 12 additions & 1 deletion app/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ type options struct {
Skip []string `long:"skip" description:"skip commands"`
Only []string `long:"only" description:"run only commands"`

Dry bool `long:"dry" description:"dry run"`
Verbose bool `short:"v" long:"verbose" description:"verbose mode"`
Dbg bool `long:"dbg" description:"debug mode"`
Help bool `short:"h" long:"help" description:"show help"`
Expand All @@ -52,7 +53,7 @@ type options struct {
var revision = "latest"

func main() {
fmt.Printf("simplotask %s\n", revision)
fmt.Printf("spot %s\n", revision)

var opts options
p := flags.NewParser(&opts, flags.PrintErrors|flags.PassDoubleDash)
Expand All @@ -67,6 +68,11 @@ func main() {

setupLog(opts.Dbg)

if opts.Dry {
msg := color.New(color.FgHiRed).SprintfFunc()("dry run - no changes will be made and no commands will be executed\n")
fmt.Print(msg)
}

if err := run(opts); err != nil {
if opts.Dbg {
log.Panicf("[ERROR] %v", err)
Expand All @@ -77,6 +83,10 @@ func main() {
}

func run(opts options) error {
if opts.Dry {
log.Printf("[WARN] dry run, no changes will be made and no commands will be executed")
}

st := time.Now()
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
Expand Down Expand Up @@ -111,6 +121,7 @@ func run(opts options) error {
Skip: opts.Skip,
ColorWriter: executor.NewColorizedWriter(os.Stdout, "", "", ""),
Verbose: opts.Verbose,
Dry: opts.Dry,
}

errs := new(multierror.Error)
Expand Down
9 changes: 9 additions & 0 deletions app/runner/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ type Process struct {
Config *config.PlayBook
ColorWriter *executor.ColorizedWriter
Verbose bool
Dry bool

Skip []string
Only []string
Expand Down Expand Up @@ -136,6 +137,14 @@ func (p *Process) runTaskOnHost(ctx context.Context, tsk *config.Task, hostAddr,
params.exec = &executor.Local{}
params.hostAddr = "localhost"
}

if p.Dry {
params.exec = executor.NewDry(hostAddr, hostName)
if cmd.Options.Local {
params.hostAddr = "localhost"
}
}

details, err := p.execCommand(ctx, params)
if err != nil {
if !cmd.Options.IgnoreErrors {
Expand Down
23 changes: 23 additions & 0 deletions app/runner/runner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,29 @@ func TestProcess_Run(t *testing.T) {
assert.Equal(t, 1, res.Hosts)
}

func TestProcess_RunDry(t *testing.T) {
ctx := context.Background()
hostAndPort, teardown := startTestContainer(t)
defer teardown()

connector, err := executor.NewConnector("testdata/test_ssh_key", time.Second*10)
require.NoError(t, err)
conf, err := config.New("testdata/conf.yml", nil)
require.NoError(t, err)

p := Process{
Concurrency: 1,
Connector: connector,
Config: conf,
ColorWriter: executor.NewColorizedWriter(os.Stdout, "", "", ""),
Dry: true,
}
res, err := p.Run(ctx, "task1", hostAndPort)
require.NoError(t, err)
assert.Equal(t, 6, res.Commands)
assert.Equal(t, 1, res.Hosts)
}

func TestProcess_RunOnly(t *testing.T) {
ctx := context.Background()
hostAndPort, teardown := startTestContainer(t)
Expand Down

0 comments on commit 0aa555f

Please sign in to comment.