171 lines
4.7 KiB
Go
171 lines
4.7 KiB
Go
// 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
|
|
}
|