Skip to content

history: make sure build record is finalized before exporting #3152

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
May 13, 2025
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
32 changes: 27 additions & 5 deletions commands/history/export.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,11 @@ import (
)

type exportOptions struct {
builder string
refs []string
output string
all bool
builder string
refs []string
output string
all bool
finalize bool
}

func runExport(ctx context.Context, dockerCli command.Cli, opts exportOptions) error {
Expand Down Expand Up @@ -62,6 +63,26 @@ func runExport(ctx context.Context, dockerCli command.Cli, opts exportOptions) e
return errors.Errorf("no record found for ref %q", ref)
}

if opts.finalize {
var finalized bool
for _, rec := range recs {
if rec.Trace == nil {
finalized = true
if err := finalizeRecord(ctx, rec.Ref, nodes); err != nil {
return err
}
}
}
if finalized {
recs, err = queryRecords(ctx, ref, nodes, &queryOptions{
CompletedOnly: true,
})
if err != nil {
return err
}
}
}

if ref == "" {
slices.SortFunc(recs, func(a, b historyRecord) int {
return b.CreatedAt.AsTime().Compare(a.CreatedAt.AsTime())
Expand Down Expand Up @@ -154,7 +175,8 @@ func exportCmd(dockerCli command.Cli, rootOpts RootOptions) *cobra.Command {

flags := cmd.Flags()
flags.StringVarP(&options.output, "output", "o", "", "Output file path")
flags.BoolVar(&options.all, "all", false, "Export all records for the builder")
flags.BoolVar(&options.all, "all", false, "Export all build records for the builder")
flags.BoolVar(&options.finalize, "finalize", false, "Ensure build records are finalized before exporting")

return cmd
}
10 changes: 1 addition & 9 deletions commands/history/trace.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import (
"github.com/docker/buildx/util/otelutil"
"github.com/docker/buildx/util/otelutil/jaeger"
"github.com/docker/cli/cli/command"
controlapi "github.com/moby/buildkit/api/services/control"
"github.com/opencontainers/go-digest"
ocispecs "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/pkg/browser"
Expand Down Expand Up @@ -57,14 +56,7 @@ func loadTrace(ctx context.Context, ref string, nodes []builder.Node) (string, [
// build is complete but no trace yet. try to finalize the trace
time.Sleep(1 * time.Second) // give some extra time for last parts of trace to be written

c, err := rec.node.Driver.Client(ctx)
if err != nil {
return "", nil, err
}
_, err = c.ControlClient().UpdateBuildHistory(ctx, &controlapi.UpdateBuildHistoryRequest{
Ref: rec.Ref,
Finalize: true,
})
err := finalizeRecord(ctx, rec.Ref, []builder.Node{*rec.node})
if err != nil {
return "", nil, err
}
Expand Down
22 changes: 22 additions & 0 deletions commands/history/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,28 @@ func queryRecords(ctx context.Context, ref string, nodes []builder.Node, opts *q
return out, nil
}

func finalizeRecord(ctx context.Context, ref string, nodes []builder.Node) error {
eg, ctx := errgroup.WithContext(ctx)
for _, node := range nodes {
node := node
eg.Go(func() error {
if node.Driver == nil {
return nil
}
c, err := node.Driver.Client(ctx)
if err != nil {
return err
}
_, err = c.ControlClient().UpdateBuildHistory(ctx, &controlapi.UpdateBuildHistoryRequest{
Ref: ref,
Finalize: true,
})
return err
})
}
Comment on lines +253 to +269
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tonistiigi For multi-node case I think this is safe even if the ref doesn't exist on unrelated node: https://github.com/moby/buildkit/blob/b46daef219fd318bd0f9c8f630472af771b2bb4a/solver/llbsolver/history.go#L676-L686

return eg.Wait()
}

func formatDuration(d time.Duration) string {
if d < time.Minute {
return fmt.Sprintf("%.1fs", d.Seconds())
Expand Down
23 changes: 17 additions & 6 deletions docs/reference/buildx_history_export.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ Export build records into Docker Desktop bundle

### Options

| Name | Type | Default | Description |
|:---------------------------------------|:---------|:--------|:-----------------------------------------|
| [`--all`](#all) | `bool` | | Export all records for the builder |
| [`--builder`](#builder) | `string` | | Override the configured builder instance |
| [`-D`](#debug), [`--debug`](#debug) | `bool` | | Enable debug logging |
| [`-o`](#output), [`--output`](#output) | `string` | | Output file path |
| Name | Type | Default | Description |
|:---------------------------------------|:---------|:--------|:----------------------------------------------------|
| [`--all`](#all) | `bool` | | Export all build records for the builder |
| [`--builder`](#builder) | `string` | | Override the configured builder instance |
| [`-D`](#debug), [`--debug`](#debug) | `bool` | | Enable debug logging |
| [`--finalize`](#finalize) | `bool` | | Ensure build records are finalized before exporting |
| [`-o`](#output), [`--output`](#output) | `string` | | Output file path |


<!---MARKER_GEN_END-->
Expand Down Expand Up @@ -49,6 +50,16 @@ docker buildx history export --builder builder0 ^1 -o builder0-build.dockerbuild
docker buildx history export --debug qu2gsuo8ejqrwdfii23xkkckt -o debug-build.dockerbuild
```

### <a name="finalize"></a> Ensure build records are finalized before exporting (--finalize)

Clients can report their own traces concurrently, and not all traces may be
saved yet by the time of the export. Use the `--finalize` flag to ensure all
traces are finalized before exporting.

```console
docker buildx history export --finalize qu2gsuo8ejqrwdfii23xkkckt -o finalized-build.dockerbuild
```

### <a name="output"></a> Export a single build to a custom file (--output)

```console
Expand Down
141 changes: 141 additions & 0 deletions tests/history.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package tests

import (
"encoding/json"
"os"
"path"
"path/filepath"
"strings"
"testing"
"time"

"github.com/moby/buildkit/util/testutil/integration"
"github.com/stretchr/testify/require"
)

var historyTests = []func(t *testing.T, sb integration.Sandbox){
testHistoryExport,
testHistoryExportFinalize,
testHistoryInspect,
testHistoryLs,
testHistoryRm,
}

func testHistoryExport(t *testing.T, sb integration.Sandbox) {
ref := buildTestProject(t, sb)
require.NotEmpty(t, ref.Ref)

outFile := path.Join(t.TempDir(), "export.dockerbuild")
cmd := buildxCmd(sb, withArgs("history", "export", ref.Ref, "--output", outFile))
out, err := cmd.Output()
require.NoError(t, err, string(out))
require.FileExists(t, outFile)
}

func testHistoryExportFinalize(t *testing.T, sb integration.Sandbox) {
ref := buildTestProject(t, sb)
require.NotEmpty(t, ref.Ref)

outFile := path.Join(t.TempDir(), "export.dockerbuild")
cmd := buildxCmd(sb, withArgs("history", "export", ref.Ref, "--finalize", "--output", outFile))
out, err := cmd.Output()
require.NoError(t, err, string(out))
require.FileExists(t, outFile)
}

func testHistoryInspect(t *testing.T, sb integration.Sandbox) {
ref := buildTestProject(t, sb)
require.NotEmpty(t, ref.Ref)

cmd := buildxCmd(sb, withArgs("history", "inspect", ref.Ref, "--format=json"))
out, err := cmd.Output()
require.NoError(t, err, string(out))

type recT struct {
Name string
Ref string
Context string
Dockerfile string
StartedAt *time.Time
CompletedAt *time.Time
Duration time.Duration
Status string
NumCompletedSteps int32
NumTotalSteps int32
NumCachedSteps int32
}
var rec recT
err = json.Unmarshal(out, &rec)
require.NoError(t, err)
require.Equal(t, ref.Ref, rec.Ref)
require.NotEmpty(t, rec.Name)
}

func testHistoryLs(t *testing.T, sb integration.Sandbox) {
ref := buildTestProject(t, sb)
require.NotEmpty(t, ref.Ref)

cmd := buildxCmd(sb, withArgs("history", "ls", "--filter=ref="+ref.Ref, "--format=json"))
out, err := cmd.Output()
require.NoError(t, err, string(out))

type recT struct {
Ref string `json:"ref"`
Name string `json:"name"`
Status string `json:"status"`
CreatedAt *time.Time `json:"created_at"`
CompletedAt *time.Time `json:"completed_at"`
TotalSteps int32 `json:"total_steps"`
CompletedSteps int32 `json:"completed_steps"`
CachedSteps int32 `json:"cached_steps"`
}
var rec recT
err = json.Unmarshal(out, &rec)
require.NoError(t, err)
require.Equal(t, ref.String(), rec.Ref)
require.NotEmpty(t, rec.Name)
}

func testHistoryRm(t *testing.T, sb integration.Sandbox) {
ref := buildTestProject(t, sb)
require.NotEmpty(t, ref.Ref)

cmd := buildxCmd(sb, withArgs("history", "rm", ref.Ref))
out, err := cmd.Output()
require.NoError(t, err, string(out))
}

type buildRef struct {
Builder string
Node string
Ref string
}

func (b buildRef) String() string {
return b.Builder + "/" + b.Node + "/" + b.Ref
}

func buildTestProject(t *testing.T, sb integration.Sandbox) buildRef {
dir := createTestProject(t)
out, err := buildCmd(sb, withArgs("--metadata-file", filepath.Join(dir, "md.json"), dir))
require.NoError(t, err, string(out))

dt, err := os.ReadFile(filepath.Join(dir, "md.json"))
require.NoError(t, err)

type mdT struct {
BuildRef string `json:"buildx.build.ref"`
}
var md mdT
err = json.Unmarshal(dt, &md)
require.NoError(t, err)

refParts := strings.Split(md.BuildRef, "/")
require.Len(t, refParts, 3)

return buildRef{
Builder: refParts[0],
Node: refParts[1],
Ref: refParts[2],
}
}
1 change: 1 addition & 0 deletions tests/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ func TestIntegration(t *testing.T) {
tests = append(tests, commonTests...)
tests = append(tests, buildTests...)
tests = append(tests, bakeTests...)
tests = append(tests, historyTests...)
tests = append(tests, inspectTests...)
tests = append(tests, lsTests...)
tests = append(tests, imagetoolsTests...)
Expand Down