Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ require (
github.com/moby/moby/api v1.54.2
github.com/moby/moby/client v0.4.1
github.com/tidwall/jsonc v0.3.3
google.golang.org/protobuf v1.34.2
gopkg.in/yaml.v3 v3.0.1
)

Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
Expand Down
25 changes: 14 additions & 11 deletions runtime/docker/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,29 +107,29 @@ func (r *Runtime) BuildImage(ctx context.Context, spec runtime.BuildSpec, events
//
// - Classic-builder-style records with `stream` (log lines) and
// `status` (layer/pull progress) fields. Pre-BuildKit format.
// Kept as a fallback — modern dockerd never emits these under
// BuildKit, but harmless if a future daemon falls back.
//
// - BuildKit records of the form `{"id":"moby.buildkit.trace",
// "aux":"<base64-protobuf>"}` for per-step progress and
// `{"id":"moby.image.id","aux":{"ID":"sha256:..."}}` for the
// final image. The aux protobuf is buildkit's `SolveStatus` —
// decoding requires the buildkit module. We intentionally don't
// pull that dep in: per-step progress events are silently dropped
// under BuildKit; BuildStart / BuildCompleted (emitted by the
// caller and at the end of BuildImage) still fire correctly, and
// errors still propagate via `errorDetail` / `error` fields.
// A future PR can revisit if vertex-level progress is needed.
// "aux":"<base64-protobuf>"}` for per-step progress. The aux
// payload is decoded by buildkitTraceDecoder via protowire — no
// dependency on github.com/moby/buildkit. See buildkit_trace.go
// for the subset of the StatusResponse schema we parse.
func streamBuildOutput(ctx context.Context, body io.ReadCloser, events chan<- runtime.BuildEvent) error {
defer body.Close()

type buildMsg struct {
Stream string `json:"stream,omitempty"`
Status string `json:"status,omitempty"`
Stream string `json:"stream,omitempty"`
Status string `json:"status,omitempty"`
ID string `json:"id,omitempty"`
Aux json.RawMessage `json:"aux,omitempty"`
ErrorDetail *struct {
Message string `json:"message"`
} `json:"errorDetail,omitempty"`
Error string `json:"error,omitempty"`
}

trace := newBuildkitTraceDecoder()
dec := json.NewDecoder(body)
for {
select {
Expand Down Expand Up @@ -162,6 +162,9 @@ func streamBuildOutput(ctx context.Context, body io.ReadCloser, events chan<- ru
Message: msg.Status,
})
}
if msg.ID == "moby.buildkit.trace" && len(msg.Aux) > 0 {
trace.handleAux(msg.Aux, events)
}
}
}

Expand Down
230 changes: 230 additions & 0 deletions runtime/docker/buildkit_trace.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
package docker

import (
"encoding/base64"
"encoding/json"
"fmt"
"strings"

"google.golang.org/protobuf/encoding/protowire"

"github.com/crunchloop/devcontainer/runtime"
)

// buildkitTraceDecoder decodes the `moby.buildkit.trace` aux records
// dockerd emits when building under BuildKit. The aux payload is a
// base64-encoded `controlapi.StatusResponse` protobuf:
//
// message StatusResponse {
// repeated Vertex vertexes = 1;
// repeated VertexStatus statuses = 2;
// repeated VertexLog logs = 3;
// }
// message Vertex {
// string digest = 1; repeated string inputs = 2; string name = 3;
// bool cached = 4; Timestamp started = 5; Timestamp completed = 6;
// string error = 7; ...
// }
// message VertexLog {
// string vertex = 1; Timestamp timestamp = 2; int64 stream = 3;
// bytes msg = 4;
// }
//
// We parse the wire format directly via protowire — buildkit's own Go
// types live in github.com/moby/buildkit, which would pull ~250
// transitive modules (containerd, k8s, sigstore, AWS/Azure SDKs). The
// fields we care about (name, cached, started, completed, error, log
// msg) are stable and a hand-roll is ~150 LOC.
//
// State is tracked across StatusResponse updates: BuildKit re-sends the
// same vertex digest with incremental field updates. We dedupe by
// emitting BuildLayerEvent only on start- and complete-transitions per
// digest; VertexLog records always emit BuildLogEvent.
type buildkitTraceDecoder struct {
seenStart map[string]bool
seenComplete map[string]bool
}

func newBuildkitTraceDecoder() *buildkitTraceDecoder {
return &buildkitTraceDecoder{
seenStart: map[string]bool{},
seenComplete: map[string]bool{},
}
}

// handleAux base64-decodes and parses a moby.buildkit.trace aux
// payload. Best-effort: malformed records are silently ignored — the
// build's authoritative success/failure is reported via the outer
// JSON-line stream's `error`/`errorDetail` fields, not via aux.
func (d *buildkitTraceDecoder) handleAux(aux json.RawMessage, events chan<- runtime.BuildEvent) {
var b64 string
if err := json.Unmarshal(aux, &b64); err != nil {
return
}
raw, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
return
}
d.decodeStatus(raw, events)
}

func (d *buildkitTraceDecoder) decodeStatus(buf []byte, events chan<- runtime.BuildEvent) {
for len(buf) > 0 {
num, typ, n := protowire.ConsumeTag(buf)
if n < 0 {
return
}
buf = buf[n:]
switch {
case num == 1 && typ == protowire.BytesType: // Vertex
v, m := protowire.ConsumeBytes(buf)
if m < 0 {
return
}
d.decodeVertex(v, events)
buf = buf[m:]
case num == 3 && typ == protowire.BytesType: // VertexLog
v, m := protowire.ConsumeBytes(buf)
if m < 0 {
return
}
d.decodeLog(v, events)
buf = buf[m:]
default:
m := protowire.ConsumeFieldValue(num, typ, buf)
if m < 0 {
return
}
buf = buf[m:]
}
}
}

func (d *buildkitTraceDecoder) decodeVertex(buf []byte, events chan<- runtime.BuildEvent) {
var (
digest, name, vErr string
cached bool
hasStarted, hasCompleted bool
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
for len(buf) > 0 {
num, typ, n := protowire.ConsumeTag(buf)
if n < 0 {
return
}
buf = buf[n:]
switch {
case num == 1 && typ == protowire.BytesType:
s, m := protowire.ConsumeBytes(buf)
if m < 0 {
return
}
digest = string(s)
buf = buf[m:]
case num == 3 && typ == protowire.BytesType:
s, m := protowire.ConsumeBytes(buf)
if m < 0 {
return
}
name = string(s)
buf = buf[m:]
case num == 4 && typ == protowire.VarintType:
v, m := protowire.ConsumeVarint(buf)
if m < 0 {
return
}
cached = v != 0
buf = buf[m:]
case num == 5 && typ == protowire.BytesType:
_, m := protowire.ConsumeBytes(buf)
if m < 0 {
return
}
hasStarted = true
buf = buf[m:]
case num == 6 && typ == protowire.BytesType:
_, m := protowire.ConsumeBytes(buf)
if m < 0 {
return
}
hasCompleted = true
buf = buf[m:]
case num == 7 && typ == protowire.BytesType:
s, m := protowire.ConsumeBytes(buf)
if m < 0 {
return
}
vErr = string(s)
buf = buf[m:]
default:
m := protowire.ConsumeFieldValue(num, typ, buf)
if m < 0 {
return
}
buf = buf[m:]
}
}
if digest == "" || name == "" {
return
}
if hasStarted && !d.seenStart[digest] {
d.seenStart[digest] = true
emitBuildEvent(events, runtime.BuildEvent{
Kind: runtime.BuildEventLayer,
Message: fmt.Sprintf("START %s", name),
LayerID: digest,
})
}
if hasCompleted && !d.seenComplete[digest] {
d.seenComplete[digest] = true
status := "DONE"
switch {
case vErr != "":
status = "ERROR: " + vErr
case cached:
status = "CACHED"
}
emitBuildEvent(events, runtime.BuildEvent{
Kind: runtime.BuildEventLayer,
Message: fmt.Sprintf("%s %s", status, name),
LayerID: digest,
})
}
}

func (d *buildkitTraceDecoder) decodeLog(buf []byte, events chan<- runtime.BuildEvent) {
var msg []byte
for len(buf) > 0 {
num, typ, n := protowire.ConsumeTag(buf)
if n < 0 {
return
}
buf = buf[n:]
switch {
case num == 4 && typ == protowire.BytesType:
s, m := protowire.ConsumeBytes(buf)
if m < 0 {
return
}
msg = s
buf = buf[m:]
default:
m := protowire.ConsumeFieldValue(num, typ, buf)
if m < 0 {
return
}
buf = buf[m:]
}
}
if len(msg) == 0 {
return
}
for _, line := range strings.Split(strings.TrimRight(string(msg), "\n"), "\n") {
if line == "" {
continue
}
emitBuildEvent(events, runtime.BuildEvent{
Kind: runtime.BuildEventLog,
Message: line,
})
}
}
Loading