// Package process runs child commands for ReleaseKit's internal adapters. package process import ( "context" "errors" "fmt" "io" "os" "os/exec" "sort" "strings" "sync" ) // Invocation describes one child process. UnsetEnv removes inherited values // before Env overlays values for this child only. type Invocation struct { Args []string Dir string Env map[string]string UnsetEnv []string Stdin io.Reader Stdout io.Writer Stderr io.Writer } // Run starts the command in the requested directory and waits for it to exit. // Errors preserve the underlying exec error, including *exec.ExitError. func Run(ctx context.Context, invocation Invocation) error { command, err := newCommand(ctx, invocation) if err != nil { return err } command.Stdin = invocation.Stdin command.Stdout = invocation.Stdout command.Stderr = invocation.Stderr if err := command.Run(); err != nil { if contextErr := ctx.Err(); contextErr != nil { return fmt.Errorf("run %q: %w", invocation.Args[0], contextErr) } return fmt.Errorf("run %q: %w", invocation.Args[0], err) } return nil } // PipedProcess owns the command started by StartPiped. Callers close Stdin, // read Stdout to EOF, and call Wait once after a normal exchange. On an abort, // close active pipes, call Cancel, and then Wait to reap the child. type PipedProcess struct { command *exec.Cmd stdin io.WriteCloser stdout io.ReadCloser parent context.Context cancel context.CancelFunc waitOnce sync.Once waitErr error } // Stdin returns the child process input pipe. The caller owns its closure. func (child *PipedProcess) Stdin() io.WriteCloser { return child.stdin } // Stdout returns the child process output pipe. The caller must consume it // before Wait on a normal exit and owns its closure on an abort. func (child *PipedProcess) Stdout() io.ReadCloser { return child.stdout } // Cancel interrupts the child without cancelling its parent context. func (child *PipedProcess) Cancel() { child.cancel() } // Wait waits for and reaps the child. It preserves the parent's cancellation // cause and otherwise returns the command's wrapped exit error. func (child *PipedProcess) Wait() error { child.waitOnce.Do(func() { err := child.command.Wait() child.cancel() if err == nil { return } if contextErr := child.parent.Err(); contextErr != nil { child.waitErr = fmt.Errorf("run %q: %w", child.command.Args[0], contextErr) return } child.waitErr = fmt.Errorf("run %q: %w", child.command.Args[0], err) }) return child.waitErr } // StartPiped starts a child with stdin and stdout connected to pipes. Command // settings come from invocation; its Stdin and Stdout fields are ignored, while // Stderr remains routed as requested. func StartPiped(ctx context.Context, invocation Invocation) (*PipedProcess, error) { childContext, cancel := context.WithCancel(ctx) command, err := newCommand(childContext, invocation) if err != nil { cancel() return nil, err } command.Stderr = invocation.Stderr stdin, err := command.StdinPipe() if err != nil { cancel() return nil, fmt.Errorf("pipe stdin for %q: %w", invocation.Args[0], err) } stdout, err := command.StdoutPipe() if err != nil { _ = stdin.Close() cancel() return nil, fmt.Errorf("pipe stdout for %q: %w", invocation.Args[0], err) } if err := command.Start(); err != nil { _ = stdin.Close() _ = stdout.Close() cancel() return nil, fmt.Errorf("start %q: %w", invocation.Args[0], err) } return &PipedProcess{ command: command, stdin: stdin, stdout: stdout, parent: ctx, cancel: cancel, }, nil } func newCommand(ctx context.Context, invocation Invocation) (*exec.Cmd, error) { if len(invocation.Args) == 0 || invocation.Args[0] == "" { return nil, errors.New("process invocation requires a command") } command := exec.CommandContext(ctx, invocation.Args[0], invocation.Args[1:]...) command.Dir = invocation.Dir if len(invocation.Env) != 0 || len(invocation.UnsetEnv) != 0 { command.Env = environmentEntries(invocation.Env, invocation.UnsetEnv) } return command, nil } func environmentEntries(overlay map[string]string, unset []string) []string { environment := make(map[string]string, len(os.Environ())+len(overlay)) for _, entry := range os.Environ() { key, value, ok := strings.Cut(entry, "=") if ok { environment[key] = value } } for _, key := range unset { delete(environment, key) } for key, value := range overlay { environment[key] = value } keys := make([]string, 0, len(environment)) for key := range environment { keys = append(keys, key) } sort.Strings(keys) entries := make([]string, 0, len(keys)) for _, key := range keys { entries = append(entries, key+"="+environment[key]) } return entries }