penguin/monok8s

k8s image for Mono Gateway Dev Kit

clitools/pkg/system/runner.go

raw ยท 6891 bytes

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...)
}