// Package compose executes Docker Compose through a fixed executable and argument arrays. package compose import ( "bytes" "context" "errors" "fmt" "io" "sync" "os" "os/exec" ) // Result is the captured output and process exit code for one Docker invocation. var ErrOutputLimit = errors.New("compose output limit exceeded") // CaptureLimits bounds each stream independently. Overflow is never retained. type CaptureLimits struct { StdoutBytes int64; StderrBytes int64 } type Result struct { Stdout string Stderr string ExitCode int } // Runner executes the Docker CLI. It never invokes a shell. type Runner struct { binary string } // NewRunner returns a runner for binary. An empty binary selects docker from PATH. func NewRunner(binary string) Runner { if binary == "" { binary = "docker" } return Runner{binary: binary} } // Run invokes Docker with the supplied argument array and optional standard input. func (r Runner) Run(ctx context.Context, args []string, stdin io.Reader) (Result, error) { command := exec.CommandContext(ctx, r.binary, args...) command.Stdin = stdin var stdout, stderr bytes.Buffer command.Stdout = &stdout command.Stderr = &stderr err := command.Run() result := Result{Stdout: stdout.String(), Stderr: stderr.String()} if err == nil { return result, nil } var exitError *exec.ExitError if errors.As(err, &exitError) { result.ExitCode = exitError.ExitCode() return result, err } if errors.Is(err, exec.ErrNotFound) || errors.Is(err, os.ErrNotExist) { result.ExitCode = 127 return result, fmt.Errorf("%w: %w", exec.ErrNotFound, err) } return result, err } // RunBounded executes one owned process while retaining at most the configured bytes per stream. // The complete process group is terminated as soon as either stream exceeds its limit. func (r Runner) RunBounded(ctx context.Context, args []string, stdin io.Reader, limits CaptureLimits) (Result, error) { if limits.StdoutBytes < 0 || limits.StderrBytes < 0 { return Result{}, ErrOutputLimit } command := exec.Command(r.binary, args...) command.Stdin = stdin stdoutPipe, err := command.StdoutPipe(); if err != nil { return Result{}, err } stderrPipe, err := command.StderrPipe(); if err != nil { return Result{}, err } configureOwnedProcess(command) if err := command.Start(); err != nil { return Result{ExitCode: 127}, err } type stream struct { b []byte; overflow bool } var out, er stream overflow := make(chan struct{}, 1) var wg sync.WaitGroup; wg.Add(2) read := func(rd io.Reader, max int64, target *stream) { defer wg.Done() buf := make([]byte, 32*1024) for { n, readErr := rd.Read(buf) if n > 0 { remain := max - int64(len(target.b)) if remain > 0 { take := int64(n); if take > remain { take = remain }; target.b = append(target.b, buf[:take]...) } if int64(n) > remain { target.overflow = true select { case overflow <- struct{}{}: default: } return } } if readErr != nil { return } } } go read(stdoutPipe, limits.StdoutBytes, &out); go read(stderrPipe, limits.StderrBytes, &er) finished := make(chan error, 1); go func() { finished <- command.Wait() }() var waitErr error select { case waitErr = <-finished: // The parent may exit while a descendant inherited stdout/stderr; tear down the owned group. terminateOwnedProcess(command) case <-overflow: terminateOwnedProcess(command); waitErr = <-finished case <-ctx.Done(): terminateOwnedProcess(command); waitErr = <-finished } wg.Wait() result := Result{Stdout: string(out.b), Stderr: string(er.b)} if out.overflow || er.overflow { return result, ErrOutputLimit } if waitErr == nil { return result, nil } var exitErr *exec.ExitError if errors.As(waitErr, &exitErr) { result.ExitCode = exitErr.ExitCode(); return result, waitErr } if errors.Is(waitErr, exec.ErrNotFound) || errors.Is(waitErr, os.ErrNotExist) { result.ExitCode = 127; return result, fmt.Errorf("%w: %w", exec.ErrNotFound, waitErr) } if ctx.Err() != nil { return result, ctx.Err() } return result, waitErr }