Added some ctl boilerplate
This commit is contained in:
346
clitools/pkg/system/runner.go
Normal file
346
clitools/pkg/system/runner.go
Normal file
@@ -0,0 +1,346 @@
|
||||
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...)
|
||||
}
|
||||
Reference in New Issue
Block a user