Files
monok8s/clitools/pkg/system/runner.go

347 lines
6.7 KiB
Go

package system
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"strings"
"sync"
"time"
)
type Logger interface {
Printf(format string, args ...any)
}
type RunnerConfig struct {
DefaultTimeout time.Duration
StreamOutput bool
Logger Logger
}
type Runner struct {
cfg RunnerConfig
}
func NewRunner(cfg RunnerConfig) *Runner {
if cfg.DefaultTimeout <= 0 {
cfg.DefaultTimeout = 30 * time.Second
}
return &Runner{cfg: cfg}
}
type Result struct {
Name string
Args []string
ExitCode int
Stdout string
Stderr string
Duration time.Duration
StartTime time.Time
EndTime time.Time
}
type RunOptions struct {
Dir string
Env []string
Timeout time.Duration
Stdin io.Reader
Stdout io.Writer
Stderr io.Writer
Quiet bool
RedactEnv []string
}
type RetryOptions struct {
Attempts int
Delay time.Duration
}
func (r *Runner) Run(ctx context.Context, name string, args ...string) (*Result, error) {
return r.RunWithOptions(ctx, name, args, RunOptions{})
}
func (r *Runner) RunWithOptions(ctx context.Context, name string, args []string, opt RunOptions) (*Result, error) {
if strings.TrimSpace(name) == "" {
return nil, errors.New("command name cannot be empty")
}
timeout := opt.Timeout
if timeout <= 0 {
timeout = r.cfg.DefaultTimeout
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
cmd := exec.CommandContext(ctx, name, args...)
cmd.Dir = opt.Dir
cmd.Env = mergeEnv(os.Environ(), opt.Env)
cmd.Stdin = opt.Stdin
var stdoutBuf bytes.Buffer
var stderrBuf bytes.Buffer
stdoutW := io.Writer(&stdoutBuf)
stderrW := io.Writer(&stderrBuf)
if opt.Stdout != nil {
stdoutW = io.MultiWriter(stdoutW, opt.Stdout)
} else if r.cfg.StreamOutput && !opt.Quiet {
stdoutW = io.MultiWriter(stdoutW, os.Stdout)
}
if opt.Stderr != nil {
stderrW = io.MultiWriter(stderrW, opt.Stderr)
} else if r.cfg.StreamOutput && !opt.Quiet {
stderrW = io.MultiWriter(stderrW, os.Stderr)
}
cmd.Stdout = stdoutW
cmd.Stderr = stderrW
start := time.Now()
if r.cfg.Logger != nil {
r.cfg.Logger.Printf("run: %s", formatCmd(name, args))
}
err := cmd.Run()
end := time.Now()
res := &Result{
Name: name,
Args: append([]string(nil), args...),
Stdout: stdoutBuf.String(),
Stderr: stderrBuf.String(),
Duration: end.Sub(start),
StartTime: start,
EndTime: end,
ExitCode: exitCode(err),
}
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
return res, fmt.Errorf("command timed out after %s: %s", timeout, formatCmd(name, args))
}
return res, fmt.Errorf("command failed (exit=%d): %s\nstderr:\n%s",
res.ExitCode, formatCmd(name, args), trimBlock(res.Stderr))
}
return res, nil
}
func (r *Runner) RunRetry(ctx context.Context, retry RetryOptions, name string, args ...string) (*Result, error) {
return r.RunRetryWithOptions(ctx, retry, name, args, RunOptions{})
}
func (r *Runner) RunRetryWithOptions(ctx context.Context, retry RetryOptions, name string, args []string, opt RunOptions) (*Result, error) {
if retry.Attempts <= 0 {
retry.Attempts = 1
}
if retry.Delay < 0 {
retry.Delay = 0
}
var lastRes *Result
var lastErr error
for attempt := 1; attempt <= retry.Attempts; attempt++ {
res, err := r.RunWithOptions(ctx, name, args, opt)
lastRes, lastErr = res, err
if err == nil {
return res, nil
}
if r.cfg.Logger != nil {
r.cfg.Logger.Printf("attempt %d/%d failed: %v", attempt, retry.Attempts, err)
}
if attempt == retry.Attempts {
break
}
select {
case <-ctx.Done():
return lastRes, ctx.Err()
case <-time.After(retry.Delay):
}
}
return lastRes, lastErr
}
type StepFunc func(ctx context.Context, r *Runner) error
type Step struct {
Name string
Description string
Retry RetryOptions
Run StepFunc
}
type StepEvent struct {
Name string
StartTime time.Time
EndTime time.Time
Duration time.Duration
Err error
}
type StepReporter interface {
StepStarted(event StepEvent)
StepFinished(event StepEvent)
}
type Phase struct {
Name string
Steps []Step
}
func (r *Runner) RunPhase(ctx context.Context, phase Phase, reporter StepReporter) error {
for _, step := range phase.Steps {
start := time.Now()
if reporter != nil {
reporter.StepStarted(StepEvent{
Name: step.Name,
StartTime: start,
})
}
var err error
if step.Retry.Attempts > 0 {
err = runStepWithRetry(ctx, r, step)
} else {
err = step.Run(ctx, r)
}
end := time.Now()
if reporter != nil {
reporter.StepFinished(StepEvent{
Name: step.Name,
StartTime: start,
EndTime: end,
Duration: end.Sub(start),
Err: err,
})
}
if err != nil {
return fmt.Errorf("phase %q step %q failed: %w", phase.Name, step.Name, err)
}
}
return nil
}
func runStepWithRetry(ctx context.Context, r *Runner, step Step) error {
attempts := step.Retry.Attempts
if attempts <= 0 {
attempts = 1
}
var lastErr error
for i := 1; i <= attempts; i++ {
lastErr = step.Run(ctx, r)
if lastErr == nil {
return nil
}
if i == attempts {
break
}
if r.cfg.Logger != nil {
r.cfg.Logger.Printf("step %q attempt %d/%d failed: %v", step.Name, i, attempts, lastErr)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(step.Retry.Delay):
}
}
return lastErr
}
func CheckCommandExists(name string) error {
_, err := exec.LookPath(name)
if err != nil {
return fmt.Errorf("required command not found in PATH: %s", name)
}
return nil
}
func mergeEnv(base []string, extra []string) []string {
if len(extra) == 0 {
return base
}
m := map[string]string{}
for _, kv := range base {
k, v, ok := strings.Cut(kv, "=")
if ok {
m[k] = v
}
}
for _, kv := range extra {
k, v, ok := strings.Cut(kv, "=")
if ok {
m[k] = v
}
}
out := make([]string, 0, len(m))
for k, v := range m {
out = append(out, k+"="+v)
}
return out
}
func formatCmd(name string, args []string) string {
parts := make([]string, 0, len(args)+1)
parts = append(parts, shellQuote(name))
for _, a := range args {
parts = append(parts, shellQuote(a))
}
return strings.Join(parts, " ")
}
func shellQuote(s string) string {
if s == "" {
return "''"
}
if !strings.ContainsAny(s, " \t\n'\"\\$`!&|;<>()[]{}*?~") {
return s
}
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
func trimBlock(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return "(empty)"
}
return s
}
func exitCode(err error) int {
if err == nil {
return 0
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return exitErr.ExitCode()
}
return -1
}
type StdLogger struct {
mu sync.Mutex
}
func (l *StdLogger) Printf(format string, args ...any) {
l.mu.Lock()
defer l.mu.Unlock()
fmt.Fprintf(os.Stderr, format+"\n", args...)
}