Skip to content

Commit 7531a53

Browse files
authored
Refactor shell and add function to return output struct (#51)
* Refactor shell and add function to return output struct * Split out the stdlib monkeypatches to a different file * Add stream version of returning output struct * Port terratest version of output streaming for more reliability * Make sure newlines are included properly * Add sleep to ensure stdout is read before stderr * We need to Println in streammode * Add comments about the relation to terratest * Remove unused imports
1 parent ca9f6c2 commit 7531a53

File tree

4 files changed

+238
-104
lines changed

4 files changed

+238
-104
lines changed

shell/cmd.go

Lines changed: 32 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
package shell
22

33
import (
4-
"bufio"
54
"fmt"
6-
"io"
75
"os"
86
"os/exec"
97
"strings"
@@ -27,74 +25,47 @@ func RunShellCommand(options *ShellOptions, command string, args ...string) erro
2725
return errors.WithStackTrace(cmd.Run())
2826
}
2927

28+
// Run the specified shell command with the specified arguments. Return its stdout, stderr, and interleaved output as
29+
// separate strings in a struct.
30+
func RunShellCommandAndGetOutputStruct(options *ShellOptions, command string, args ...string) (*Output, error) {
31+
return runShellCommand(options, false, command, args...)
32+
}
33+
3034
// Run the specified shell command with the specified arguments. Return its stdout and stderr as a string
3135
func RunShellCommandAndGetOutput(options *ShellOptions, command string, args ...string) (string, error) {
32-
logCommand(options, command, args...)
33-
cmd := exec.Command(command, args...)
34-
35-
cmd.Stdin = os.Stdin
36-
37-
setCommandOptions(options, cmd)
38-
39-
out, err := cmd.CombinedOutput()
40-
return string(out), errors.WithStackTrace(err)
36+
out, err := runShellCommand(options, false, command, args...)
37+
return out.Combined(), err
4138
}
4239

43-
// Run the specified shell command with the specified arguments. Return its stdout and stderr as a string and also
44-
// stream stdout and stderr to the OS stdout/stderr
40+
// Run the specified shell command with the specified arguments. Return its interleaved stdout and stderr as a string
41+
// and also stream stdout and stderr to the OS stdout/stderr
4542
func RunShellCommandAndGetAndStreamOutput(options *ShellOptions, command string, args ...string) (string, error) {
46-
logCommand(options, command, args...)
47-
cmd := exec.Command(command, args...)
48-
49-
setCommandOptions(options, cmd)
50-
51-
cmd.Stdin = os.Stdin
52-
53-
stdout, err := cmd.StdoutPipe()
54-
if err != nil {
55-
return "", errors.WithStackTrace(err)
56-
}
57-
58-
stderr, err := cmd.StderrPipe()
59-
if err != nil {
60-
return "", errors.WithStackTrace(err)
61-
}
62-
63-
if err := cmd.Start(); err != nil {
64-
return "", errors.WithStackTrace(err)
65-
}
66-
67-
output, err := readStdoutAndStderr(
68-
stdout,
69-
true,
70-
stderr,
71-
true,
72-
options,
73-
)
74-
if err != nil {
75-
return output, err
76-
}
77-
78-
err = cmd.Wait()
79-
return output, errors.WithStackTrace(err)
43+
out, err := runShellCommand(options, true, command, args...)
44+
return out.Combined(), err
8045
}
8146

8247
// Run the specified shell command with the specified arguments. Return its stdout as a string
8348
func RunShellCommandAndGetStdout(options *ShellOptions, command string, args ...string) (string, error) {
84-
logCommand(options, command, args...)
85-
cmd := exec.Command(command, args...)
86-
87-
cmd.Stdin = os.Stdin
88-
89-
setCommandOptions(options, cmd)
90-
91-
out, err := cmd.Output()
92-
return string(out), errors.WithStackTrace(err)
49+
out, err := runShellCommand(options, false, command, args...)
50+
return out.Stdout(), err
9351
}
9452

9553
// Run the specified shell command with the specified arguments. Return its stdout as a string and also stream stdout
9654
// and stderr to the OS stdout/stderr
9755
func RunShellCommandAndGetStdoutAndStreamOutput(options *ShellOptions, command string, args ...string) (string, error) {
56+
out, err := runShellCommand(options, true, command, args...)
57+
return out.Stdout(), err
58+
}
59+
60+
// Run the specified shell command with the specified arguments. Return its stdout, stderr, and interleaved output as a
61+
// struct and also stream stdout and stderr to the OS stdout/stderr
62+
func RunShellCommandAndGetOutputStructAndStreamOutput(options *ShellOptions, command string, args ...string) (*Output, error) {
63+
return runShellCommand(options, true, command, args...)
64+
}
65+
66+
// Run the specified shell command with the specified arguments. Return its stdout and stderr as a string and also
67+
// stream stdout and stderr to the OS stdout/stderr
68+
func runShellCommand(options *ShellOptions, streamOutput bool, command string, args ...string) (*Output, error) {
9869
logCommand(options, command, args...)
9970
cmd := exec.Command(command, args...)
10071

@@ -104,24 +75,23 @@ func RunShellCommandAndGetStdoutAndStreamOutput(options *ShellOptions, command s
10475

10576
stdout, err := cmd.StdoutPipe()
10677
if err != nil {
107-
return "", errors.WithStackTrace(err)
78+
return nil, errors.WithStackTrace(err)
10879
}
10980

11081
stderr, err := cmd.StderrPipe()
11182
if err != nil {
112-
return "", errors.WithStackTrace(err)
83+
return nil, errors.WithStackTrace(err)
11384
}
11485

11586
if err := cmd.Start(); err != nil {
116-
return "", errors.WithStackTrace(err)
87+
return nil, errors.WithStackTrace(err)
11788
}
11889

11990
output, err := readStdoutAndStderr(
91+
options.Logger,
92+
streamOutput,
12093
stdout,
121-
true,
12294
stderr,
123-
false,
124-
options,
12595
)
12696
if err != nil {
12797
return output, err
@@ -131,48 +101,6 @@ func RunShellCommandAndGetStdoutAndStreamOutput(options *ShellOptions, command s
131101
return output, errors.WithStackTrace(err)
132102
}
133103

134-
// This function captures stdout and stderr while still printing it to the stdout and stderr of this Go program
135-
func readStdoutAndStderr(
136-
stdout io.ReadCloser,
137-
includeStdout bool,
138-
stderr io.ReadCloser,
139-
includeStderr bool,
140-
options *ShellOptions,
141-
) (string, error) {
142-
allOutput := []string{}
143-
144-
stdoutScanner := bufio.NewScanner(stdout)
145-
stderrScanner := bufio.NewScanner(stderr)
146-
147-
for {
148-
if stdoutScanner.Scan() {
149-
text := stdoutScanner.Text()
150-
options.Logger.Println(text)
151-
if includeStdout {
152-
allOutput = append(allOutput, text)
153-
}
154-
} else if stderrScanner.Scan() {
155-
text := stderrScanner.Text()
156-
options.Logger.Println(text)
157-
if includeStderr {
158-
allOutput = append(allOutput, text)
159-
}
160-
} else {
161-
break
162-
}
163-
}
164-
165-
if err := stdoutScanner.Err(); err != nil {
166-
return "", errors.WithStackTrace(err)
167-
}
168-
169-
if err := stderrScanner.Err(); err != nil {
170-
return "", errors.WithStackTrace(err)
171-
}
172-
173-
return strings.Join(allOutput, "\n"), nil
174-
}
175-
176104
func logCommand(options *ShellOptions, command string, args ...string) {
177105
if options.SensitiveArgs {
178106
options.Logger.Infof("Running command: %s (args redacted)", command)

shell/cmd_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@ func TestRunShellCommandAndGetOutput(t *testing.T) {
3030
assert.Equal(t, "hi\n", out)
3131
}
3232

33+
func TestRunShellCommandAndGetOutputNoTerminatingNewLine(t *testing.T) {
34+
t.Parallel()
35+
36+
out, err := RunShellCommandAndGetOutput(NewShellOptions(), "echo", "-n", "hi")
37+
assert.NoError(t, err)
38+
assert.Equal(t, "hi", out)
39+
}
40+
3341
func TestRunShellCommandAndGetStdoutReturnsStdout(t *testing.T) {
3442
t.Parallel()
3543

@@ -46,6 +54,32 @@ func TestRunShellCommandAndGetStdoutDoesNotReturnStderr(t *testing.T) {
4654
assert.Equal(t, "", out)
4755
}
4856

57+
func TestRunShellCommandAndGetStdoutAndStreamOutputDoesNotReturnStderr(t *testing.T) {
58+
t.Parallel()
59+
60+
out, err := RunShellCommandAndGetStdoutAndStreamOutput(NewShellOptions(), filepath.Join("test-fixture", "echo_hi_stderr.sh"))
61+
assert.NoError(t, err)
62+
assert.Equal(t, "", out)
63+
}
64+
65+
func TestRunShellCommandAndGetAndStreamOutput(t *testing.T) {
66+
t.Parallel()
67+
68+
out, err := RunShellCommandAndGetAndStreamOutput(NewShellOptions(), filepath.Join("test-fixture", "echo_stdoutstderr.sh"))
69+
assert.NoError(t, err)
70+
assert.Equal(t, "hello\nworld\n", out)
71+
}
72+
73+
func TestRunShellCommandAndGetOutputStruct(t *testing.T) {
74+
t.Parallel()
75+
76+
out, err := RunShellCommandAndGetOutputStruct(NewShellOptions(), filepath.Join("test-fixture", "echo_stdoutstderr.sh"))
77+
assert.NoError(t, err)
78+
assert.Equal(t, "hello\nworld\n", out.Combined())
79+
assert.Equal(t, "hello\n", out.Stdout())
80+
assert.Equal(t, "world\n", out.Stderr())
81+
}
82+
4983
func TestRunShellCommandWithEnv(t *testing.T) {
5084
t.Parallel()
5185

0 commit comments

Comments
 (0)