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 }