feat(execx): add command-runner abstraction
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package execx provides a thin, testable abstraction over external command
|
||||
// execution.
|
||||
//
|
||||
// All privileged work in prx goes through a Runner so that the orchestration
|
||||
// logic can be unit tested without touching the real system.
|
||||
package execx
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Result is the outcome of running an external command.
|
||||
type Result struct {
|
||||
Stdout []byte
|
||||
Stderr []byte
|
||||
Code int
|
||||
}
|
||||
|
||||
// Runner abstracts external command execution.
|
||||
type Runner interface {
|
||||
// Run executes name with args. If stdin is non-nil it is fed to the
|
||||
// process standard input. A non-nil error is returned when the command
|
||||
// could not be started or exited with a non-zero status (*ExitError).
|
||||
Run(ctx context.Context, stdin []byte, name string, args ...string) (Result, error)
|
||||
// LookPath resolves the absolute path of an executable.
|
||||
LookPath(name string) (string, error)
|
||||
}
|
||||
|
||||
// ExitError indicates that a command started but exited with a non-zero code.
|
||||
type ExitError struct {
|
||||
Name string
|
||||
Args []string
|
||||
Code int
|
||||
Stderr []byte
|
||||
}
|
||||
|
||||
func (e *ExitError) Error() string {
|
||||
msg := strings.TrimSpace(string(e.Stderr))
|
||||
if msg == "" {
|
||||
msg = "no stderr"
|
||||
}
|
||||
return fmt.Sprintf("%s %s: exit status %d: %s", e.Name, strings.Join(e.Args, " "), e.Code, msg)
|
||||
}
|
||||
|
||||
// OSRunner runs commands on the host.
|
||||
type OSRunner struct{}
|
||||
|
||||
// Run implements Runner.
|
||||
func (OSRunner) Run(ctx context.Context, stdin []byte, name string, args ...string) (Result, error) {
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
if stdin != nil {
|
||||
cmd.Stdin = bytes.NewReader(stdin)
|
||||
}
|
||||
var out, errb bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &errb
|
||||
err := cmd.Run()
|
||||
res := Result{Stdout: out.Bytes(), Stderr: errb.Bytes()}
|
||||
if err != nil {
|
||||
var ee *exec.ExitError
|
||||
if errors.As(err, &ee) {
|
||||
res.Code = ee.ExitCode()
|
||||
return res, &ExitError{Name: name, Args: args, Code: res.Code, Stderr: res.Stderr}
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// LookPath implements Runner.
|
||||
func (OSRunner) LookPath(name string) (string, error) { return exec.LookPath(name) }
|
||||
|
||||
// Call records a single fake invocation.
|
||||
type Call struct {
|
||||
Name string
|
||||
Args []string
|
||||
Stdin []byte
|
||||
}
|
||||
|
||||
// Fake is an in-memory Runner for tests.
|
||||
type Fake struct {
|
||||
// Handler produces the result for a call. When nil an empty successful
|
||||
// result is returned.
|
||||
Handler func(name string, args []string, stdin []byte) (Result, error)
|
||||
// Calls records every invocation in order.
|
||||
Calls []Call
|
||||
}
|
||||
|
||||
// Run implements Runner.
|
||||
func (f *Fake) Run(_ context.Context, stdin []byte, name string, args ...string) (Result, error) {
|
||||
f.Calls = append(f.Calls, Call{Name: name, Args: append([]string(nil), args...), Stdin: append([]byte(nil), stdin...)})
|
||||
if f.Handler == nil {
|
||||
return Result{}, nil
|
||||
}
|
||||
return f.Handler(name, args, stdin)
|
||||
}
|
||||
|
||||
// LookPath implements Runner.
|
||||
func (f *Fake) LookPath(name string) (string, error) { return "/usr/bin/" + name, nil }
|
||||
Reference in New Issue
Block a user