Skip to content

Commit f1a1d1b

Browse files
aledbfclaude
andcommitted
feat(docker): stream build/compose progress live instead of on exit
The exec seam ran every docker subprocess with its stdout/stderr wired to bytes.Buffers and only surfaced the output after the process exited, so a `devcontainer build` (and the builds inside `up`) printed the whole BuildKit log in one dump at the end with no visible progress. Make streaming part of the Runner contract rather than a property of the concrete OSRunner: Run now takes a `stream io.Writer` that, when non-nil, receives the child's combined output live (OSRunner tees it to both the capture buffer and the stream). The client passes this writer to whichever runner is configured — real or fake — so there is no fake-vs-real branch and streaming is exercised through the seam in tests. docker.Client and ComposeClient gain a ProgressWriter; Build (and compose build/up) opt into streaming, while Run/Config/Stop/Down stay buffered (compose config's stdout is JSON the CLI parses and must not be streamed). The CLI sets ProgressWriter to the log destination in text mode only — under --log-format json a raw byte stream would corrupt the structured events, so that path keeps emitting the buffered output as a log event. Tests cover: OSRunner streams while still capturing; Build/compose-build stream through the seam; streaming is opt-in (no ProgressWriter → nil stream); and Run/compose-config never stream. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 3d232fa commit f1a1d1b

12 files changed

Lines changed: 246 additions & 71 deletions

File tree

‎internal/cli/build.go‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,9 @@ func runBuild(ctx context.Context, out Output, opts *buildOpts) error {
182182

183183
// Setup Docker clients
184184
dockerClient := docker.NewClient(opts.dockerPath, nil, logger)
185+
// Stream `docker build` progress live to the terminal (text mode only; under
186+
// --log-format json a raw byte stream would corrupt the structured events).
187+
dockerClient.ProgressWriter = progressWriter(opts.logFormat, os.Stderr)
185188

186189
engine, err := docker.NewEngineClient(logger)
187190
if err != nil {
@@ -429,7 +432,11 @@ func (r *buildRunner) buildDockerfile(ctx context.Context, cfg *config.DevContai
429432
return nil, fmt.Errorf(msgDockerBuildFailed, result.ExitCode, string(result.Stderr))
430433
}
431434

432-
logger.Write(string(result.Stderr), log.LevelInfo)
435+
// When streaming, the build output already reached the terminal live; only
436+
// emit the buffered copy when it did not (e.g. --log-format json).
437+
if dockerClient.ProgressWriter == nil {
438+
logger.Write(string(result.Stderr), log.LevelInfo)
439+
}
433440

434441
// If config has features, extend the built image with features
435442
if len(cfg.Features) > 0 {
@@ -598,7 +605,9 @@ func (r *buildRunner) buildImage(ctx context.Context, cfg *config.DevContainer,
598605
if result.ExitCode != 0 {
599606
return nil, fmt.Errorf(msgDockerBuildFailed, result.ExitCode, string(result.Stderr))
600607
}
601-
logger.Write(string(result.Stderr), log.LevelInfo)
608+
if dockerClient.ProgressWriter == nil {
609+
logger.Write(string(result.Stderr), log.LevelInfo)
610+
}
602611
return featureImageNames, nil
603612
}
604613

@@ -743,6 +752,7 @@ func (r *buildRunner) buildCompose(ctx context.Context, cfg *config.DevContainer
743752
if err != nil {
744753
return nil, fmt.Errorf("compose client: %w", err)
745754
}
755+
composeClient.ProgressWriter = progressWriter(opts.logFormat, os.Stderr)
746756

747757
composeConfig, err := composeClient.Config(ctx, composeFiles, "")
748758
if err != nil {

‎internal/cli/output.go‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,14 @@ func (c cmdOutput) Stderr() io.Writer { return c.cmd.ErrOrStderr() }
3636

3737
// outputFor returns an Output backed by the given cobra command.
3838
func outputFor(cmd *cobra.Command) Output { return cmdOutput{cmd: cmd} }
39+
40+
// progressWriter returns the writer that live subprocess progress (e.g. docker
41+
// build) should stream to: w in text mode, or nil under --log-format json where
42+
// a raw byte stream would corrupt the structured event stream (callers treat a
43+
// nil writer as "buffer and emit as a log event instead").
44+
func progressWriter(logFormat string, w io.Writer) io.Writer {
45+
if logFormat == "json" {
46+
return nil
47+
}
48+
return w
49+
}

‎internal/cli/up.go‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,9 @@ func runUp(ctx context.Context, out Output, opts *upOpts) error {
377377
}
378378

379379
dockerClient := docker.NewClient(opts.dockerPath, nil, logger)
380+
// Stream `docker build` progress live to the log destination (text mode only;
381+
// under --log-format json it would corrupt the structured event stream).
382+
dockerClient.ProgressWriter = progressWriter(opts.logFormat, logDst)
380383
run.docker = dockerClient
381384

382385
// Detect BuildKit
@@ -793,7 +796,11 @@ func (r *upRunner) fromDockerfile(ctx context.Context, cfg *config.DevContainer,
793796
return "", fmt.Errorf(msgDockerBuildFailed, buildResult.ExitCode, string(buildResult.Stderr))
794797
}
795798

796-
logger.Write(string(buildResult.Stderr), log.LevelInfo)
799+
// When streaming, the build output already reached the terminal live; only
800+
// emit the buffered copy when it did not (e.g. --log-format json).
801+
if dockerClient.ProgressWriter == nil {
802+
logger.Write(string(buildResult.Stderr), log.LevelInfo)
803+
}
797804

798805
// Extend with features if any
799806
if len(cfg.Features) > 0 {
@@ -1197,6 +1204,8 @@ func (r *upRunner) fromCompose(ctx context.Context, cfg *config.DevContainer, lo
11971204
if err != nil {
11981205
return "", fmt.Errorf("compose client: %w", err)
11991206
}
1207+
// Stream compose build/up progress to the same destination as docker builds.
1208+
composeClient.ProgressWriter = dockerClient.ProgressWriter
12001209

12011210
projectName := resolveComposeProjectName(ctx, cfg, env, composeFiles, composeClient)
12021211
logger.Write(fmt.Sprintf("Compose project: %s, service: %s", projectName, cfg.Service), log.LevelInfo)

‎internal/docker/argbuilders_test.go‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package docker
22

33
import (
44
"context"
5+
"io"
56
"reflect"
67
"testing"
78

@@ -28,11 +29,15 @@ type cannedResponse struct {
2829
code int
2930
}
3031

31-
func (r *recordingRunner) Run(_ context.Context, name string, args ...string) ([]byte, []byte, int, error) {
32+
func (r *recordingRunner) Run(_ context.Context, stream io.Writer, name string, args ...string) ([]byte, []byte, int, error) {
3233
r.calls = append(r.calls, recordedCall{name: name, args: append([]string(nil), args...)})
3334
if len(r.responses) > 0 {
3435
resp := r.responses[0]
3536
r.responses = r.responses[1:]
37+
if stream != nil {
38+
_, _ = stream.Write(resp.stdout)
39+
_, _ = stream.Write(resp.stderr)
40+
}
3641
return resp.stdout, resp.stderr, resp.code, nil
3742
}
3843
return nil, nil, 0, nil

‎internal/docker/client.go‎

Lines changed: 31 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ package docker
44
import (
55
"context"
66
"fmt"
7+
"io"
78
"regexp"
89
"strings"
910

@@ -20,6 +21,11 @@ type Client struct {
2021
// Runner is the seam over process execution. When nil, a default OS-backed
2122
// runner is used. Tests inject a fake to avoid shelling out.
2223
Runner exec.Runner
24+
// ProgressWriter, when set, receives `docker build` output live so the user
25+
// sees progress as it happens instead of one dump on completion. Leave nil to
26+
// keep output buffered (e.g. under --log-format json, where a raw byte stream
27+
// would corrupt the structured event stream).
28+
ProgressWriter io.Writer
2329
}
2430

2531
// NewClient creates a Docker CLI client.
@@ -42,59 +48,49 @@ type ExecResult struct {
4248
}
4349

4450
// runner returns the configured Runner, or a default OS-backed one carrying the
45-
// client's Env.
46-
func (c *Client) runner() exec.Runner {
51+
// client's Env plus extraEnv for this invocation.
52+
func (c *Client) runner(extraEnv []string) exec.Runner {
4753
if c.Runner != nil {
4854
return c.Runner
4955
}
50-
return exec.OSRunner{Env: c.Env}
56+
return exec.OSRunner{Env: append(append([]string{}, c.Env...), extraEnv...)}
5157
}
5258

53-
// Run executes a docker command and captures output. ctx cancels the subprocess
54-
// (e.g. on SIGINT), so a long-running `docker build` unwinds on Ctrl-C.
55-
func (c *Client) Run(ctx context.Context, args ...string) (*ExecResult, error) {
59+
// run executes a docker command and captures output. When stream is true and a
60+
// ProgressWriter is set, the child's output is also forwarded live through the
61+
// runner (a fake runner streams too, so the behavior is exercised in tests).
62+
// extraEnv is appended to the client's Env. ctx cancels the subprocess (e.g. on
63+
// SIGINT), so a long-running `docker build` unwinds on Ctrl-C.
64+
func (c *Client) run(ctx context.Context, stream bool, extraEnv []string, args ...string) (*ExecResult, error) {
5665
c.Log.Write(fmt.Sprintf("Run: %s %s", c.DockerPath, strings.Join(args, " ")), log.LevelTrace)
5766

58-
stdout, stderr, exitCode, err := c.runner().Run(ctx, c.DockerPath, args...)
67+
var live io.Writer
68+
if stream {
69+
live = c.ProgressWriter // nil unless the caller opted into live output
70+
}
71+
stdout, stderr, exitCode, err := c.runner(extraEnv).Run(ctx, live, c.DockerPath, args...)
5972
if err != nil {
6073
return nil, fmt.Errorf("exec docker: %w", err)
6174
}
75+
return &ExecResult{Stdout: stdout, Stderr: stderr, ExitCode: exitCode}, nil
76+
}
6277

63-
return &ExecResult{
64-
Stdout: stdout,
65-
Stderr: stderr,
66-
ExitCode: exitCode,
67-
}, nil
78+
// Run executes a docker command and captures output (no live streaming).
79+
func (c *Client) Run(ctx context.Context, args ...string) (*ExecResult, error) {
80+
return c.run(ctx, false, nil, args...)
6881
}
6982

70-
// Build runs `docker build` or `docker buildx build`. When opts.Env is set, those
71-
// entries are appended to the subprocess environment (used to point DOCKER_CONFIG
72-
// at a temporary credentials directory for private base-image pulls / --push /
73-
// --cache-to, without mutating the ambient environment).
83+
// Build runs `docker build` or `docker buildx build`, streaming progress to
84+
// ProgressWriter when set. When opts.Env is set, those entries are appended to
85+
// the subprocess environment (used to point DOCKER_CONFIG at a temporary
86+
// credentials directory for private base-image pulls / --push / --cache-to,
87+
// without mutating the ambient environment).
7488
func (c *Client) Build(ctx context.Context, opts BuildOptions) (*ExecResult, error) {
7589
args := c.buildArgs(opts)
7690
// Secret values reach BuildKit through the subprocess environment (referenced
7791
// by `--secret id=KEY,env=KEY`), never on the command line.
7892
extraEnv := append(append([]string{}, opts.Env...), opts.Secrets...)
79-
if len(extraEnv) > 0 {
80-
return c.runWithEnv(ctx, extraEnv, args...)
81-
}
82-
return c.Run(ctx, args...)
83-
}
84-
85-
// runWithEnv is Run with extra environment entries appended for this invocation.
86-
func (c *Client) runWithEnv(ctx context.Context, extraEnv []string, args ...string) (*ExecResult, error) {
87-
c.Log.Write(fmt.Sprintf("Run: %s %s", c.DockerPath, strings.Join(args, " ")), log.LevelTrace)
88-
89-
var r exec.Runner = exec.OSRunner{Env: append(append([]string{}, c.Env...), extraEnv...)}
90-
if c.Runner != nil {
91-
r = c.Runner // tests inject a fake; env is irrelevant there
92-
}
93-
stdout, stderr, exitCode, err := r.Run(ctx, c.DockerPath, args...)
94-
if err != nil {
95-
return nil, fmt.Errorf("exec docker: %w", err)
96-
}
97-
return &ExecResult{Stdout: stdout, Stderr: stderr, ExitCode: exitCode}, nil
93+
return c.run(ctx, true, extraEnv, args...)
9894
}
9995

10096
// BuildOptions configures a docker build.

‎internal/docker/compose.go‎

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"encoding/json"
66
"fmt"
7+
"io"
78
osexec "os/exec"
89
"strconv"
910
"strings"
@@ -22,6 +23,11 @@ type ComposeClient struct {
2223
// Runner is the seam over process execution. When nil, a default OS-backed
2324
// runner is used.
2425
Runner exec.Runner
26+
// ProgressWriter, when set, receives `docker compose build`/`up` output live
27+
// so the user sees progress as it happens. Leave nil to keep output buffered
28+
// (e.g. under --log-format json). `compose config` never streams — its stdout
29+
// is JSON that the CLI parses.
30+
ProgressWriter io.Writer
2531
}
2632

2733
// NewComposeClient detects Compose v2 (`docker compose`) and returns a client.
@@ -45,8 +51,15 @@ func NewComposeClient(dockerPath, composePath string, env []string, logger log.L
4551
return nil, fmt.Errorf("'docker compose' (v2) not found")
4652
}
4753

48-
// Run executes a compose command. ctx cancels the subprocess (e.g. on SIGINT).
54+
// Run executes a compose command and captures output (no live streaming).
4955
func (c *ComposeClient) Run(ctx context.Context, args ...string) (*ExecResult, error) {
56+
return c.run(ctx, false, args...)
57+
}
58+
59+
// run executes a compose command. When stream is true and ProgressWriter is set,
60+
// the child's output is also forwarded live through the runner. ctx cancels the
61+
// subprocess.
62+
func (c *ComposeClient) run(ctx context.Context, stream bool, args ...string) (*ExecResult, error) {
5063
fullArgs := append(c.Command[1:], args...)
5164

5265
// Emit a "Run:" start/stop pair around the subprocess, like the TS CLI wraps
@@ -56,11 +69,15 @@ func (c *ComposeClient) Run(ctx context.Context, args ...string) (*ExecResult, e
5669
runLine := fmt.Sprintf("Run: %s %s", c.Command[0], strings.Join(fullArgs, " "))
5770
startTS := c.Log.Start(runLine, log.LevelDebug)
5871

59-
runner := c.Runner
60-
if runner == nil {
61-
runner = exec.OSRunner{Env: c.Env}
72+
var runner exec.Runner = exec.OSRunner{Env: c.Env}
73+
if c.Runner != nil {
74+
runner = c.Runner
75+
}
76+
var live io.Writer
77+
if stream {
78+
live = c.ProgressWriter
6279
}
63-
stdout, stderr, exitCode, err := runner.Run(ctx, c.Command[0], fullArgs...)
80+
stdout, stderr, exitCode, err := runner.Run(ctx, live, c.Command[0], fullArgs...)
6481
c.Log.Stop(runLine, startTS, log.LevelDebug)
6582
if err != nil {
6683
return nil, fmt.Errorf("exec compose: %w", err)
@@ -103,7 +120,7 @@ func (c *ComposeClient) Build(ctx context.Context, composeFiles []string, envFil
103120
}
104121
args = append(args, services...)
105122

106-
res, err := c.Run(ctx, args...)
123+
res, err := c.run(ctx, true, args...)
107124
if err != nil {
108125
return err
109126
}
@@ -126,7 +143,7 @@ func (c *ComposeClient) Up(ctx context.Context, composeFiles []string, envFile s
126143
}
127144
args = append(args, services...)
128145

129-
res, err := c.Run(ctx, args...)
146+
res, err := c.run(ctx, true, args...)
130147
if err != nil {
131148
return err
132149
}

0 commit comments

Comments
 (0)