385 lines
9.3 KiB
Go
385 lines
9.3 KiB
Go
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()
|
|
|
|
logs, err := openLogWriters(req.StdoutLogPath, req.StderrLogPath)
|
|
if err != nil {
|
|
return RunResult{}, err
|
|
}
|
|
defer logs.Close()
|
|
|
|
cmd := exec.CommandContext(runCtx, req.Executable, req.Args...)
|
|
cmd.Dir = req.WorkingDir
|
|
cmd.Env = mergeEnv(os.Environ(), req.EnvOverrides)
|
|
cmd.Stdout = logs.Stdout
|
|
cmd.Stderr = logs.Stderr
|
|
|
|
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
|
|
}
|
|
|
|
stderrTail := readRedactedTail(req.StderrLogPath, req.EnvOverrides, 2048)
|
|
diagnostics := buildDiagnostics(req, result, stderrTail)
|
|
|
|
if result.TimedOut {
|
|
return result, fmt.Errorf("command timed out after %s (%s)", req.Timeout, diagnostics)
|
|
}
|
|
if result.Canceled {
|
|
return result, fmt.Errorf("command canceled (%s)", diagnostics)
|
|
}
|
|
if exitErr, ok := waitErr.(*exec.ExitError); ok {
|
|
return result, fmt.Errorf("command failed with exit code %d (%s): %w", exitErr.ExitCode(), diagnostics, waitErr)
|
|
}
|
|
|
|
return result, fmt.Errorf("command failed to run (%s): %w", diagnostics, 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
|
|
}
|
|
|
|
type logWriters struct {
|
|
files []*os.File
|
|
Stdout io.Writer
|
|
Stderr io.Writer
|
|
}
|
|
|
|
func (l *logWriters) Close() {
|
|
for _, f := range l.files {
|
|
_ = f.Close()
|
|
}
|
|
}
|
|
|
|
func openLogWriters(stdoutPath, stderrPath string) (*logWriters, error) {
|
|
cleanStdout := cleanLogPath(stdoutPath)
|
|
cleanStderr := cleanLogPath(stderrPath)
|
|
|
|
// Keep stdout/stderr on the same file descriptor when both paths target
|
|
// the same file to avoid descriptor aliasing surprises across runtimes.
|
|
if cleanStdout != "" && cleanStdout == cleanStderr {
|
|
f, err := openLogFile(cleanStdout)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open shared stdout/stderr log %q: %w", cleanStdout, err)
|
|
}
|
|
return &logWriters{
|
|
files: []*os.File{f},
|
|
Stdout: f,
|
|
Stderr: f,
|
|
}, nil
|
|
}
|
|
|
|
stdoutFile, stdoutWriter, err := logWriter(cleanStdout)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open stdout log: %w", err)
|
|
}
|
|
stderrFile, stderrWriter, err := logWriter(cleanStderr)
|
|
if err != nil {
|
|
closeFile(stdoutFile)
|
|
return nil, fmt.Errorf("open stderr log: %w", err)
|
|
}
|
|
|
|
files := make([]*os.File, 0, 2)
|
|
if stdoutFile != nil {
|
|
files = append(files, stdoutFile)
|
|
}
|
|
if stderrFile != nil {
|
|
files = append(files, stderrFile)
|
|
}
|
|
return &logWriters{
|
|
files: files,
|
|
Stdout: stdoutWriter,
|
|
Stderr: stderrWriter,
|
|
}, nil
|
|
}
|
|
|
|
func cleanLogPath(path string) string {
|
|
trimmed := strings.TrimSpace(path)
|
|
if trimmed == "" {
|
|
return ""
|
|
}
|
|
return filepath.Clean(trimmed)
|
|
}
|
|
|
|
func logWriter(path string) (*os.File, io.Writer, error) {
|
|
if strings.TrimSpace(path) == "" {
|
|
return nil, io.Discard, nil
|
|
}
|
|
f, err := openLogFile(path)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return f, f, nil
|
|
}
|
|
|
|
func openLogFile(path string) (*os.File, error) {
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
return nil, fmt.Errorf("create log directory for %q: %w", path, err)
|
|
}
|
|
f, err := os.Create(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open log file %q: %w", path, err)
|
|
}
|
|
return f, nil
|
|
}
|
|
|
|
func closeFile(f *os.File) {
|
|
if f != nil {
|
|
_ = f.Close()
|
|
}
|
|
}
|
|
|
|
func buildDiagnostics(req RunRequest, result RunResult, stderrTail string) string {
|
|
details := fmt.Sprintf(
|
|
"executable=%q args=%v cwd=%q timeout=%s exit_code=%d timed_out=%t canceled=%t stdout_log=%q stderr_log=%q",
|
|
req.Executable,
|
|
req.Args,
|
|
req.WorkingDir,
|
|
req.Timeout,
|
|
result.ExitCode,
|
|
result.TimedOut,
|
|
result.Canceled,
|
|
req.StdoutLogPath,
|
|
req.StderrLogPath,
|
|
)
|
|
if strings.TrimSpace(stderrTail) == "" {
|
|
if hint := fdDiagnosticsHint(result.ExitCode, ""); hint != "" {
|
|
return details + fmt.Sprintf(" hint=%q", hint)
|
|
}
|
|
return details
|
|
}
|
|
details = details + fmt.Sprintf(" stderr_tail=%q", stderrTail)
|
|
if hint := fdDiagnosticsHint(result.ExitCode, stderrTail); hint != "" {
|
|
details = details + fmt.Sprintf(" hint=%q", hint)
|
|
}
|
|
return details
|
|
}
|
|
|
|
func fdDiagnosticsHint(exitCode int, stderrTail string) string {
|
|
lowerTail := strings.ToLower(stderrTail)
|
|
if strings.Contains(lowerTail, "bad file descriptor") || strings.Contains(lowerTail, "errno 9") {
|
|
return "stderr stream appears invalid in child process (fd 2); check wrapper/subprocess environment differences versus direct shell execution"
|
|
}
|
|
if exitCode == 120 {
|
|
return "exit code 120 may indicate interpreter shutdown stream-flush failures (commonly invalid stdout/stderr descriptors in Python processes)"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func readRedactedTail(path string, envOverrides map[string]string, maxBytes int64) string {
|
|
if strings.TrimSpace(path) == "" || maxBytes <= 0 {
|
|
return ""
|
|
}
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
defer f.Close()
|
|
|
|
info, err := f.Stat()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
size := info.Size()
|
|
start := int64(0)
|
|
if size > maxBytes {
|
|
start = size - maxBytes
|
|
}
|
|
if _, err := f.Seek(start, io.SeekStart); err != nil {
|
|
return ""
|
|
}
|
|
data, err := io.ReadAll(f)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
tail := strings.TrimSpace(string(data))
|
|
if tail == "" {
|
|
return ""
|
|
}
|
|
return redactSensitiveTail(tail, envOverrides)
|
|
}
|
|
|
|
func redactSensitiveTail(tail string, envOverrides map[string]string) string {
|
|
out := tail
|
|
for k, v := range envOverrides {
|
|
if strings.TrimSpace(v) == "" {
|
|
continue
|
|
}
|
|
if looksSensitiveEnvKey(k) {
|
|
out = strings.ReplaceAll(out, v, "<redacted>")
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func looksSensitiveEnvKey(key string) bool {
|
|
k := strings.ToUpper(strings.TrimSpace(key))
|
|
return strings.Contains(k, "KEY") ||
|
|
strings.Contains(k, "TOKEN") ||
|
|
strings.Contains(k, "SECRET") ||
|
|
strings.Contains(k, "PASSWORD")
|
|
}
|
|
|
|
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
|
|
}
|