Add subprocess runner and generated config helpers
This commit is contained in:
226
internal/adapters/subprocess/run.go
Normal file
226
internal/adapters/subprocess/run.go
Normal file
@@ -0,0 +1,226 @@
|
||||
package subprocess
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// RunRequest defines a subprocess invocation.
|
||||
type RunRequest struct {
|
||||
Executable string
|
||||
Args []string
|
||||
WorkingDir string
|
||||
EnvOverrides map[string]string
|
||||
Timeout time.Duration
|
||||
StdoutLogPath string
|
||||
StderrLogPath string
|
||||
}
|
||||
|
||||
// RunResult captures subprocess execution details.
|
||||
type RunResult struct {
|
||||
ExitCode int
|
||||
StartedAt time.Time
|
||||
CompletedAt time.Time
|
||||
Duration time.Duration
|
||||
StdoutLogPath string
|
||||
StderrLogPath string
|
||||
TimedOut bool
|
||||
Canceled bool
|
||||
}
|
||||
|
||||
// Run executes a subprocess with context cancellation and optional timeout.
|
||||
func Run(ctx context.Context, req RunRequest) (RunResult, error) {
|
||||
if strings.TrimSpace(req.Executable) == "" {
|
||||
return RunResult{}, fmt.Errorf("subprocess executable is required")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
runCtx := ctx
|
||||
cancel := func() {}
|
||||
if req.Timeout > 0 {
|
||||
runCtx, cancel = context.WithTimeout(ctx, req.Timeout)
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
stdoutFile, stdoutWriter, err := logWriter(req.StdoutLogPath)
|
||||
if err != nil {
|
||||
return RunResult{}, fmt.Errorf("open stdout log: %w", err)
|
||||
}
|
||||
defer closeFile(stdoutFile)
|
||||
|
||||
stderrFile, stderrWriter, err := logWriter(req.StderrLogPath)
|
||||
if err != nil {
|
||||
return RunResult{}, fmt.Errorf("open stderr log: %w", err)
|
||||
}
|
||||
defer closeFile(stderrFile)
|
||||
|
||||
cmd := exec.CommandContext(runCtx, req.Executable, req.Args...)
|
||||
cmd.Dir = req.WorkingDir
|
||||
cmd.Env = mergeEnv(os.Environ(), req.EnvOverrides)
|
||||
cmd.Stdout = stdoutWriter
|
||||
cmd.Stderr = stderrWriter
|
||||
|
||||
started := time.Now().UTC()
|
||||
result := RunResult{
|
||||
ExitCode: -1,
|
||||
StartedAt: started,
|
||||
StdoutLogPath: req.StdoutLogPath,
|
||||
StderrLogPath: req.StderrLogPath,
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
result.CompletedAt = time.Now().UTC()
|
||||
result.Duration = result.CompletedAt.Sub(result.StartedAt)
|
||||
return result, fmt.Errorf("start command %q with args %v: %w", req.Executable, req.Args, err)
|
||||
}
|
||||
|
||||
waitErr := cmd.Wait()
|
||||
result.CompletedAt = time.Now().UTC()
|
||||
result.Duration = result.CompletedAt.Sub(result.StartedAt)
|
||||
if cmd.ProcessState != nil {
|
||||
result.ExitCode = cmd.ProcessState.ExitCode()
|
||||
}
|
||||
|
||||
ctxErr := runCtx.Err()
|
||||
if errors.Is(ctxErr, context.DeadlineExceeded) {
|
||||
result.TimedOut = true
|
||||
}
|
||||
if errors.Is(ctxErr, context.Canceled) && !result.TimedOut {
|
||||
result.Canceled = true
|
||||
}
|
||||
|
||||
if waitErr == nil {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
if result.TimedOut {
|
||||
return result, fmt.Errorf("command %q timed out after %s (args=%v)", req.Executable, req.Timeout, req.Args)
|
||||
}
|
||||
if result.Canceled {
|
||||
return result, fmt.Errorf("command %q canceled (args=%v)", req.Executable, req.Args)
|
||||
}
|
||||
if exitErr, ok := waitErr.(*exec.ExitError); ok {
|
||||
return result, fmt.Errorf("command %q failed with exit code %d (args=%v): %w", req.Executable, exitErr.ExitCode(), req.Args, waitErr)
|
||||
}
|
||||
|
||||
return result, fmt.Errorf("command %q failed to run (args=%v): %w", req.Executable, req.Args, waitErr)
|
||||
}
|
||||
|
||||
// WriteYAMLAtomic marshals value as YAML and atomically writes it to path.
|
||||
func WriteYAMLAtomic(path string, value any, perm os.FileMode) error {
|
||||
data, err := yaml.Marshal(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal yaml for %q: %w", path, err)
|
||||
}
|
||||
if err := WriteFileAtomic(path, data, perm); err != nil {
|
||||
return fmt.Errorf("write yaml %q: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WriteFileAtomic writes bytes via same-directory temp file + atomic rename.
|
||||
func WriteFileAtomic(path string, data []byte, perm os.FileMode) error {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return fmt.Errorf("write file: path is required")
|
||||
}
|
||||
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("create parent directory %q: %w", dir, err)
|
||||
}
|
||||
|
||||
base := filepath.Base(path)
|
||||
tmp, err := os.CreateTemp(dir, "."+base+".tmp-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temp file: %w", err)
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
removeTmp := true
|
||||
defer func() {
|
||||
if removeTmp {
|
||||
_ = os.Remove(tmpPath)
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("write temp file: %w", err)
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("sync temp file: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("close temp file: %w", err)
|
||||
}
|
||||
if err := os.Chmod(tmpPath, perm); err != nil {
|
||||
return fmt.Errorf("chmod temp file: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpPath, path); err != nil {
|
||||
return fmt.Errorf("rename temp file: %w", err)
|
||||
}
|
||||
removeTmp = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func logWriter(path string) (*os.File, io.Writer, error) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return nil, io.Discard, nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return nil, nil, fmt.Errorf("create log directory for %q: %w", path, err)
|
||||
}
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("open log file %q: %w", path, err)
|
||||
}
|
||||
return f, f, nil
|
||||
}
|
||||
|
||||
func closeFile(f *os.File) {
|
||||
if f != nil {
|
||||
_ = f.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func mergeEnv(base []string, overrides map[string]string) []string {
|
||||
if len(overrides) == 0 {
|
||||
return base
|
||||
}
|
||||
|
||||
kv := make(map[string]string, len(base)+len(overrides))
|
||||
for _, item := range base {
|
||||
k, v, ok := strings.Cut(item, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
kv[k] = v
|
||||
}
|
||||
for k, v := range overrides {
|
||||
kv[k] = v
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(kv))
|
||||
for k := range kv {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
out := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
out = append(out, k+"="+kv[k])
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user