os/exec Orchestration
os/exec Orchestration
Overview
CLIs often wrap other tools: git, docker, kubectl, compilers. Package os/exec runs subprocesses with explicit argv (never shell strings with untrusted input).
Basics
cmd := exec.CommandContext(ctx, "git", "status", "--short")
cmd.Dir = repoDir
cmd.Env = append(os.Environ(), "LC_ALL=C")
out, err := cmd.Output() // stdout; stderr in ExitError.Stderr if failed| Method | Captures |
|---|---|
Run |
exit only; you wire Stdout/Stderr |
Output |
stdout bytes; err on non-zero |
CombinedOutput |
stdout+stderr merged |
Start/Wait |
async |
Stream to the user
cmd := exec.Command("go", "test", "./...")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
err := cmd.Run()Capture and inspect exit code
err := cmd.Run()
if err != nil {
var ee *exec.ExitError
if errors.As(err, &ee) {
return fmt.Errorf("git exit %d", ee.ExitCode())
}
return err // not found, killed, etc.
}Pipes between processes
func pipeline(ctx context.Context) error {
ps := exec.CommandContext(ctx, "ps", "aux")
grep := exec.CommandContext(ctx, "grep", "go")
r, w := io.Pipe()
ps.Stdout = w
grep.Stdin = r
var grepOut bytes.Buffer
grep.Stdout = &grepOut
if err := ps.Start(); err != nil {
return err
}
if err := grep.Start(); err != nil {
return err
}
errPs := ps.Wait()
_ = w.Close() // signal EOF to grep
errGrep := grep.Wait()
if errPs != nil {
return errPs
}
if errGrep != nil {
return errGrep
}
fmt.Print(grepOut.String())
return nil
}Prefer shell for one-off scripts; prefer argv pipelines in production wrappers for safety.
Never shell untrusted input
// BAD
exec.Command("sh", "-c", "echo "+userInput)
// GOOD
exec.Command("echo", userInput)If you must use a shell, allowlist and escape carefully—usually you should not.
LookPath
path, err := exec.LookPath("docker")
if err != nil {
return fmt.Errorf("docker not installed: %w", err)
}
_ = pathExample: gofmt-write wrapper
func run(paths []string) error {
args := append([]string{"-w"}, paths...)
cmd := exec.Command("gofmt", args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}Example: parallel commands with errgroup
eg, ctx := errgroup.WithContext(context.Background())
for _, pkg := range packages {
pkg := pkg
eg.Go(func() error {
cmd := exec.CommandContext(ctx, "go", "test", pkg)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%s: %s: %w", pkg, out, err)
}
return nil
})
}
return eg.Wait()Bound parallelism if the child tools are heavy.
Example: runjson — exec and parse JSON stdout
cmd := exec.CommandContext(ctx, "docker", "inspect", id)
out, err := cmd.Output()
if err != nil {
return err
}
var payload any
if err := json.Unmarshal(out, &payload); err != nil {
return fmt.Errorf("parse docker json: %w", err)
}Timeouts
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "make", "test")
err := cmd.Run()
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return fmt.Errorf("timed out")
}CommandContext sends kill when ctx cancels (details differ by OS for process groups).
Rules of thumb
| Do | Don’t |
|---|---|
| Argv slices | sh -c with user strings |
CommandContext |
Untimed long children |
| Surface exit codes | Swallow ExitError without code |
Set Dir/Env explicitly |
Rely on ambient cwd silently |
Try next
- Wrap
git rev-parse --short HEADand print version. - Run
go testwith timeout 5s; assert cancel path. - Build
whichallthat LookPaths a list of tools and exits 1 if any missing.