Skip to content

Commit

Permalink
Move Exec helpers to cross-platform build.
Browse files Browse the repository at this point in the history
These are not using Windows-specific dependencies, and as such should theoretically be viable on any platform. The only change at present is to allow file extensions other than .bat/.exe.

PiperOrigin-RevId: 648830301
  • Loading branch information
ItsMattL authored and copybara-github committed Jul 2, 2024
1 parent 35e21f9 commit ecffa77
Show file tree
Hide file tree
Showing 5 changed files with 297 additions and 302 deletions.
185 changes: 184 additions & 1 deletion go/helpers/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,19 @@
package helpers

import (
"bytes"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"syscall"
"time"

"github.com/google/deck"
)

// ExecResult holds the output from a subprocess execution.
Expand Down Expand Up @@ -78,6 +84,7 @@ var (
PsPath = os.ExpandEnv("${windir}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe")

// Test helpers
fnExec = execute
fnSleep = time.Sleep
)

Expand All @@ -104,13 +111,189 @@ type ExecVerifier struct {
StdErrMatch *regexp.Regexp
}

// NewExecVerifier applys the default values to an ExecVerifier.
// NewExecVerifier applies the default values to an ExecVerifier.
func NewExecVerifier() *ExecVerifier {
return &ExecVerifier{
SuccessCodes: []int{0},
}
}

// Exec executes a subprocess and returns the results.
//
// If Exec is called without a configuration, a default configuration is used. The default
// configuration will use a simple exit code verifier and no timeout. Behaviors can be disabled
// by supplying a config but leaving individual members as nil.
func Exec(path string, args []string, conf *ExecConfig) (ExecResult, error) {
var err error
var res ExecResult

// Default config if unspecified.
if conf == nil {
conf = &ExecConfig{
Verifier: NewExecVerifier(),
}
}
// Default retry if unspecified.
if conf.RetryInterval == nil {
defInt := 1 * time.Minute
conf.RetryInterval = &defInt
}

for attempt := 0; attempt <= conf.RetryCount; attempt++ {
if res, err = fnExec(path, args, conf); err == nil {
break
}
deck.Warningf("%s did not complete successfully: %v", path, err)
if attempt == conf.RetryCount {
break
}
deck.Infof("retrying in %v", conf.RetryInterval)
fnSleep(*conf.RetryInterval)
}
return res, err
}

// ExecWithAttr executes a subprocess with custom process attributes and returns the results.
//
// See also https://github.com/golang/go/issues/17149.
func ExecWithAttr(path string, timeout *time.Duration, spattr *syscall.SysProcAttr) (ExecResult, error) {
conf := &ExecConfig{
Timeout: timeout,
SpAttr: spattr,
}
return fnExec(path, []string{}, conf)
}

// ExecWithVerify executes a subprocess and performs additional verification on the results.
//
// Exec can return failures in multiple ways: explicit errors, invalid exit codes, error messages in outputs, etc.
// Without additional verification, these have to be checked individually by the caller. ExecWithVerify provides
// a wrapper that will perform most of these checks and will populate err if *any* of them are present, saving the caller
// the extra effort.
func ExecWithVerify(path string, args []string, timeout *time.Duration, verifier *ExecVerifier) (ExecResult, error) {
if verifier == nil {
verifier = NewExecVerifier()
}
conf := &ExecConfig{
Timeout: timeout,
Verifier: verifier,
}
return fnExec(path, args, conf)
}

func verify(path string, res ExecResult, verifier ExecVerifier) (ExecResult, error) {
if res.ExitErr != nil {
if exiterr, ok := res.ExitErr.(*exec.ExitError); ok {
// if the exitcode was 0.. but ExitErr contains additional information, return the error.
if exiterr.ExitCode() == 0 {
return res, res.ExitErr
}
} else {
return res, res.ExitErr
}
}
codeOk := false
for _, c := range verifier.SuccessCodes {
if c == res.ExitCode {
codeOk = true
break
}
}
if !codeOk {
return res, fmt.Errorf("%q %w: %d", path, ErrExitCode, res.ExitCode)
}

if verifier.StdErrMatch != nil && verifier.StdErrMatch.Match(res.Stderr) {
return res, fmt.Errorf("%w from %q", ErrStdErr, path)
}
if verifier.StdOutMatch != nil && verifier.StdOutMatch.Match(res.Stdout) {
return res, fmt.Errorf("%w from %q", ErrStdOut, path)
}
return res, nil
}

func execute(path string, args []string, conf *ExecConfig) (ExecResult, error) {
var cmd *exec.Cmd
result := ExecResult{}
if conf == nil {
return result, errors.New("conf cannot be nil")
}

switch strings.ToLower(filepath.Ext(path)) {
case ".ps1":
// Escape spaces in PowerShell paths.
args = append([]string{"-NoProfile", "-NoLogo", "-Command", strings.ReplaceAll(path, " ", "` ")}, args...)
// Append $LASTEXITCODE so exitcode can be inferred.
// ref: https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_powershell_exe
args = append(args, ";", "exit", "$LASTEXITCODE")
path = PsPath
default:
// path and args unmodified
}

if conf.SpAttr != nil {
cmd = exec.Command(path)
cmd.SysProcAttr = conf.SpAttr
} else {
cmd = exec.Command(path, args...)
}

// create our own buffer to hold a copy of the output and err
var errbuf, outbuf bytes.Buffer

// add our buffers to any supplied by the user and pass to cmd
if conf.WriteStdOut != nil {
cmd.Stdout = io.MultiWriter(&outbuf, conf.WriteStdOut)
} else {
cmd.Stdout = &outbuf
}
if conf.WriteStdErr != nil {
cmd.Stderr = io.MultiWriter(&errbuf, conf.WriteStdErr)
} else {
cmd.Stderr = &errbuf
}

start := time.Now()
// Start command asynchronously
deck.InfofA("Executing: %v \n", cmd.Args).With(deck.V(2)).Go()
if err := cmd.Start(); err != nil {
return result, fmt.Errorf("cmd.Start: %w", err)
}

var timer *time.Timer
// Create a timer that will kill the process
if conf.Timeout != nil {
timer = time.AfterFunc(*conf.Timeout, func() {
cmd.Process.Kill()
})
}

// Wait for execution
result.ExitErr = cmd.Wait()

// Populate the result object
result.Stdout = outbuf.Bytes()
result.Stderr = errbuf.Bytes()

// when the execution times out return a timeout error
if conf.Timeout != nil && !timer.Stop() {
return result, ExecError{
errmsg: ErrTimeout.Error(),
procresult: result,
wraps: ErrTimeout,
}
}

result.ExitCode = cmd.ProcessState.ExitCode()
result.ProcessTimer = time.Since(start)

if conf.Verifier != nil {
return verify(path, result, *conf.Verifier)
}

return result, nil
}

// ContainsString returns true if a string is in slice and false otherwise.
func ContainsString(a string, slice []string) bool {
for _, b := range slice {
Expand Down
6 changes: 0 additions & 6 deletions go/helpers/helpers_stub.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,3 @@

package helpers

// Exec executes a subprocess and returns the results.
//
// This call is unsupported on non-Windows platforms.
func Exec(path string, args []string, conf *ExecConfig) (ExecResult, error) {
return ExecResult{}, ErrUnsupported
}
113 changes: 113 additions & 0 deletions go/helpers/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@
package helpers

import (
"errors"
"fmt"
"regexp"
"testing"
"time"

"github.com/google/go-cmp/cmp"
)
Expand Down Expand Up @@ -157,3 +161,112 @@ func TestStringToMap(t *testing.T) {
}
}
}

func TestVerify(t *testing.T) {
err1 := errors.New("direct error")
def := NewExecVerifier()
tests := []struct {
ver ExecVerifier
res ExecResult
want error
}{
{*def, ExecResult{ExitErr: err1}, err1},
{*def, ExecResult{ExitCode: 1}, ErrExitCode},
{ExecVerifier{SuccessCodes: []int{2, 3, 4}}, ExecResult{ExitCode: 3}, nil},
{ExecVerifier{SuccessCodes: []int{2, 3, 4}}, ExecResult{ExitCode: 5}, ErrExitCode},
{*def, ExecResult{
Stdout: []byte("This is harmless output."),
Stderr: []byte("This too."),
}, nil},
{ExecVerifier{
SuccessCodes: []int{0},
StdOutMatch: regexp.MustCompile(".*harmful.*"),
StdErrMatch: regexp.MustCompile(".*harmful.*"),
}, ExecResult{
Stdout: []byte("This is harmless output."),
Stderr: []byte("This too."),
}, nil},
{ExecVerifier{
SuccessCodes: []int{0},
StdOutMatch: regexp.MustCompile(".*harmful.*"),
StdErrMatch: regexp.MustCompile(".*harmful.*"),
}, ExecResult{
Stdout: []byte("This is harmful output."),
Stderr: []byte("This isn't."),
}, ErrStdOut},
{ExecVerifier{
SuccessCodes: []int{0},
StdOutMatch: regexp.MustCompile(".*harmful.*"),
StdErrMatch: regexp.MustCompile(".*harmful.*"),
}, ExecResult{
Stderr: []byte("This is harmful output."),
Stdout: []byte("This isn't."),
}, ErrStdErr},
}
for i, tt := range tests {
testID := fmt.Sprintf("Test%d", i)
t.Run(testID, func(t *testing.T) {
_, got := verify(testID, tt.res, tt.ver)
if !errors.Is(got, tt.want) {
t.Errorf("got %v; want %v", got, tt.want)
}
})
}
}

func TestExecWithRetry(t *testing.T) {
tests := []struct {
inConf *ExecConfig
inErr []error
inRes []ExecResult
wantErr error
wantRes ExecResult
}{
// defaults used; success on first try
{nil, []error{nil},
[]ExecResult{
ExecResult{Stdout: []byte("Result 1")},
}, nil,
ExecResult{Stdout: []byte("Result 1")},
},
// defaults used; error on first try
{nil, []error{ErrStdErr},
[]ExecResult{
ExecResult{Stdout: []byte("Result 1")},
}, ErrStdErr,
ExecResult{},
},
// success on third try
{&ExecConfig{RetryCount: 3},
[]error{ErrStdErr, ErrStdErr, nil},
[]ExecResult{
ExecResult{Stdout: []byte("Result 1")},
ExecResult{Stdout: []byte("Result 2")},
ExecResult{Stdout: []byte("Result 3")},
}, nil,
ExecResult{Stdout: []byte("Result 3")},
},
}
fnSleep = func(time.Duration) {}
for i, tt := range tests {
testID := fmt.Sprintf("Test%d", i)
t.Run(testID, func(t *testing.T) {
ci := -1
fnExec = func(string, []string, *ExecConfig) (ExecResult, error) {
ci++
if ci >= len(tt.inErr) {
t.Fatalf("ran out of return values...")
}
return tt.inRes[ci], tt.inErr[ci]
}
got, err := Exec("test-process", []string{}, tt.inConf)
if !errors.Is(err, tt.wantErr) {
t.Errorf("Exec(%s): got %v; want %v", testID, err, tt.wantErr)
}
diff := cmp.Diff(got, tt.wantRes)
if err == nil && diff != "" {
t.Errorf("Exec(%s): returned unexpected differences (-want +got):\n%s", testID, diff)
}
})
}
}
Loading

0 comments on commit ecffa77

Please sign in to comment.