still working
This commit is contained in:
27
internal/infra/osexec/fakerunner.go
Normal file
27
internal/infra/osexec/fakerunner.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package osexec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FakeRunner is a test-friendly Runner that returns pre-determined values, supports delays, and can simulate timeouts.
|
||||
type FakeRunner struct {
|
||||
Stdout string
|
||||
Stderr string
|
||||
ExitCode int
|
||||
Delay time.Duration
|
||||
Err error
|
||||
}
|
||||
|
||||
func (f *FakeRunner) Run(ctx context.Context, name string, args ...string) (string, string, int, error) {
|
||||
if f.Delay > 0 {
|
||||
select {
|
||||
case <-time.After(f.Delay):
|
||||
case <-ctx.Done():
|
||||
// Pass through context error
|
||||
return "", "", -1, ctx.Err()
|
||||
}
|
||||
}
|
||||
return f.Stdout, f.Stderr, f.ExitCode, f.Err
|
||||
}
|
||||
142
internal/infra/osexec/osexec.go
Normal file
142
internal/infra/osexec/osexec.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package osexec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Typed errors
|
||||
var (
|
||||
ErrTimeout = errors.New("command timeout")
|
||||
)
|
||||
|
||||
// ErrExitNonZero is returned when a command returned a non-zero exit status.
|
||||
type ErrExitNonZero struct {
|
||||
ExitCode int
|
||||
Stdout string
|
||||
Stderr string
|
||||
}
|
||||
|
||||
func (e ErrExitNonZero) Error() string {
|
||||
return fmt.Sprintf("command exit status %d", e.ExitCode)
|
||||
}
|
||||
|
||||
// Runner defines the behavior for executing a command. Implementations can be injected for tests.
|
||||
type Runner interface {
|
||||
Run(ctx context.Context, name string, args ...string) (stdout string, stderr string, exitCode int, err error)
|
||||
}
|
||||
|
||||
// DefaultMaxOutputBytes limits stdout/stderr returned by Exec.
|
||||
const DefaultMaxOutputBytes = 64 * 1024
|
||||
|
||||
// DefaultRunner executes real OS commands.
|
||||
type DefaultRunner struct {
|
||||
RedactPatterns []*regexp.Regexp
|
||||
MaxOutputBytes int
|
||||
}
|
||||
|
||||
func NewDefaultRunner(patterns []string) *DefaultRunner {
|
||||
var compiled []*regexp.Regexp
|
||||
for _, p := range patterns {
|
||||
r, err := regexp.Compile(p)
|
||||
if err == nil {
|
||||
compiled = append(compiled, r)
|
||||
}
|
||||
}
|
||||
return &DefaultRunner{RedactPatterns: compiled, MaxOutputBytes: DefaultMaxOutputBytes}
|
||||
}
|
||||
|
||||
func (r *DefaultRunner) redact(s string) string {
|
||||
if len(r.RedactPatterns) == 0 {
|
||||
return s
|
||||
}
|
||||
out := s
|
||||
for _, re := range r.RedactPatterns {
|
||||
out = re.ReplaceAllString(out, "[REDACTED]")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (r *DefaultRunner) truncate(s string) (string, bool) {
|
||||
max := r.MaxOutputBytes
|
||||
if max <= 0 || len(s) <= max {
|
||||
return s, false
|
||||
}
|
||||
return s[:max] + "... (truncated)", true
|
||||
}
|
||||
|
||||
// Run executes a command using os/exec and returns captured output and exit code.
|
||||
func (r *DefaultRunner) Run(ctx context.Context, name string, args ...string) (string, string, int, error) {
|
||||
start := time.Now()
|
||||
// use exec.CommandContext to respect context deadlines
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
outBytes, err := cmd.Output() // only captures stdout; we rely on combined output for simplicity
|
||||
var stderr []byte
|
||||
if err != nil {
|
||||
if ee, ok := err.(*exec.ExitError); ok {
|
||||
stderr = ee.Stderr
|
||||
}
|
||||
}
|
||||
// If context timed out
|
||||
if ctx.Err() == context.DeadlineExceeded || ctx.Err() == context.Canceled {
|
||||
return string(outBytes), string(stderr), -1, ErrTimeout
|
||||
}
|
||||
|
||||
stdout := string(outBytes)
|
||||
sErrStr := string(stderr)
|
||||
// Truncate outputs if needed
|
||||
stdout, _ = r.truncate(stdout)
|
||||
sErrStr, _ = r.truncate(sErrStr)
|
||||
|
||||
// If command failed with non-zero exit
|
||||
if err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
code := exitErr.ExitCode()
|
||||
return stdout, sErrStr, code, ErrExitNonZero{ExitCode: code, Stdout: stdout, Stderr: sErrStr}
|
||||
}
|
||||
return stdout, sErrStr, -1, err
|
||||
}
|
||||
|
||||
_ = time.Since(start) // measure duration; can be logged by caller
|
||||
return stdout, sErrStr, 0, nil
|
||||
}
|
||||
|
||||
// Exec runs using DefaultRunner; default patterns are warmed up empty
|
||||
var Default = NewDefaultRunner(nil)
|
||||
|
||||
// Exec is a convenience function using the Default runner.
|
||||
func Exec(ctx context.Context, name string, args ...string) (string, string, int, error) {
|
||||
return Default.Run(ctx, name, args...)
|
||||
}
|
||||
|
||||
// ExecWithRunner uses the provided Runner to execute and normalizes errors.
|
||||
func ExecWithRunner(r Runner, ctx context.Context, name string, args ...string) (string, string, int, error) {
|
||||
out, errOut, code, err := r.Run(ctx, name, args...)
|
||||
// timeout
|
||||
if ctx.Err() == context.DeadlineExceeded || ctx.Err() == context.Canceled {
|
||||
return out, errOut, -1, ErrTimeout
|
||||
}
|
||||
// runner returned an error
|
||||
if err != nil {
|
||||
// If error implements ErrExitNonZero (already constructed by runner), return as is
|
||||
var exitErr ErrExitNonZero
|
||||
if errors.As(err, &exitErr) {
|
||||
return exitErr.Stdout, exitErr.Stderr, exitErr.ExitCode, exitErr
|
||||
}
|
||||
// If it's an exec.ExitError, wrap in ErrExitNonZero
|
||||
var execExit *exec.ExitError
|
||||
if errors.As(err, &execExit) {
|
||||
return out, errOut, execExit.ExitCode(), ErrExitNonZero{ExitCode: execExit.ExitCode(), Stdout: out, Stderr: errOut}
|
||||
}
|
||||
return out, errOut, code, err
|
||||
}
|
||||
// no error, but a non-zero exit code
|
||||
if code != 0 {
|
||||
return out, errOut, code, ErrExitNonZero{ExitCode: code, Stdout: out, Stderr: errOut}
|
||||
}
|
||||
return out, errOut, code, nil
|
||||
}
|
||||
71
internal/infra/osexec/osexec_test.go
Normal file
71
internal/infra/osexec/osexec_test.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package osexec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFakeRunnerSuccess(t *testing.T) {
|
||||
fr := &FakeRunner{Stdout: "ok"}
|
||||
ctx := context.Background()
|
||||
out, errOut, code, err := fr.Run(ctx, "echo", "ok")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if out != "ok" || errOut != "" || code != 0 {
|
||||
t.Fatalf("expected ok, got out=%q err=%q code=%d", out, errOut, code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecWithFakeRunnerNonZero(t *testing.T) {
|
||||
fr := &FakeRunner{Stdout: "", Stderr: "fail", ExitCode: 2}
|
||||
ctx := context.Background()
|
||||
out, errOut, code, err := ExecWithRunner(fr, ctx, "false")
|
||||
if err == nil {
|
||||
t.Fatalf("expected ErrExitNonZero, got no error")
|
||||
}
|
||||
if e, ok := err.(ErrExitNonZero); !ok {
|
||||
t.Fatalf("expected ErrExitNonZero, got %T %v", err, err)
|
||||
} else if e.ExitCode != 2 {
|
||||
t.Fatalf("expected exit 2, got %d", e.ExitCode)
|
||||
}
|
||||
if code != 2 || out != "" || errOut != "fail" {
|
||||
t.Fatalf("mismatch output: code=%d out=%q err=%q", code, out, errOut)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecWithFakeRunnerTimeout(t *testing.T) {
|
||||
fr := &FakeRunner{Stdout: "", Stderr: "", ExitCode: 0, Delay: 200 * time.Millisecond}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
_, _, _, err := ExecWithRunner(fr, ctx, "sleep")
|
||||
if !errors.Is(err, ErrTimeout) && err != context.DeadlineExceeded && err != context.Canceled {
|
||||
t.Fatalf("expected timeout error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedact(t *testing.T) {
|
||||
r := NewDefaultRunner([]string{"s3_secret_[a-z0-9]+"})
|
||||
// Redact function is unexported; replicate behavior
|
||||
out := "User: s3_secret_abc123 is set"
|
||||
redacted := r.redact(out)
|
||||
if strings.Contains(redacted, "s3_secret_") {
|
||||
t.Fatalf("expected redaction in %q", redacted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncate(t *testing.T) {
|
||||
r := NewDefaultRunner(nil)
|
||||
r.MaxOutputBytes = 10
|
||||
long := strings.Repeat("x", 50)
|
||||
truncated, truncatedFlag := r.truncate(long)
|
||||
if !truncatedFlag {
|
||||
t.Fatalf("expected truncated flag true")
|
||||
}
|
||||
if len(truncated) > r.MaxOutputBytes+20 { // includes '... (truncated)'
|
||||
t.Fatalf("unexpected truncated length")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user