diff --git a/build/build.go b/build/build.go index f0badc95982d..c8048fe1b004 100644 --- a/build/build.go +++ b/build/build.go @@ -433,9 +433,15 @@ func toRepoOnly(in string) (string, error) { } type ( + ExecutionMode string + Execution struct { + Mode ExecutionMode + Parallel int + } EvaluateFunc func(ctx context.Context, name string, c gateway.Client, res *gateway.Result, opt Options) error Handler struct { - Evaluate EvaluateFunc + Evaluate EvaluateFunc + Execution Execution } linkedTargetState struct { results *waitmap.Map @@ -444,6 +450,22 @@ type ( parents map[string][]string children map[string][]string } + linkedTargetHooks struct { + preEvaluate func() error + evaluate func() error + postEvaluate func() error + } + syncTargetState struct { + targets []string + results *waitmap.Map + evaluated *waitmap.Map + } +) + +const ( + ExecutionModeFailFast ExecutionMode = "fail-fast" + ExecutionModeSyncOutput ExecutionMode = "sync-output" + ExecutionModeDeferError ExecutionMode = "defer-error" ) func newLinkedTargetState(parents, children map[string][]string) *linkedTargetState { @@ -460,37 +482,108 @@ func (s *linkedTargetState) isLinked(key string) bool { return len(s.parents[key]) > 0 || len(s.children[key]) > 0 } -func (s *linkedTargetState) run(ctx context.Context, key string, result any, evaluate func() error) error { +func (s *linkedTargetState) hasLinks() bool { + return len(s.parents) > 0 || len(s.children) > 0 +} + +func (s *linkedTargetState) fail(key string, err error) { + s.results.Set(key, err) + s.evaluated.Set(key, err) + s.completed.Set(key, err) +} + +func (s *linkedTargetState) run(ctx context.Context, key string, result any, hooks linkedTargetHooks) error { // Registration flows from parents to children. Waiting for every direct child // here preserves external-cache lookup before evaluation begins. s.results.Set(key, result) children := s.children[key] - if _, err := s.results.Get(ctx, children...); err != nil { + if res, err := s.results.Get(ctx, children...); err != nil { + return err + } else if err := resultError(res); err != nil { return err } + if hooks.preEvaluate != nil { + if err := hooks.preEvaluate(); err != nil { + return err + } + } // Evaluation follows dependency order so the target's own session is attached // to shared solver vertices before a dependent can evaluate them. - if _, err := s.evaluated.Get(ctx, s.parents[key]...); err != nil { + if res, err := s.evaluated.Get(ctx, s.parents[key]...); err != nil { + return err + } else if err := resultError(res); err != nil { return err } - if err := evaluate(); err != nil { + if err := hooks.evaluate(); err != nil { + s.evaluated.Set(key, err) + s.completed.Set(key, err) return err } s.evaluated.Set(key, struct{}{}) + if hooks.postEvaluate != nil { + if err := hooks.postEvaluate(); err != nil { + s.completed.Set(key, err) + return err + } + } // Completion flows back from children to parents, retaining each parent job // and its session until every dependent has finished evaluating. - if _, err := s.completed.Get(ctx, children...); err != nil { + if res, err := s.completed.Get(ctx, children...); err != nil { + return err + } else if err := resultError(res); err != nil { return err } s.completed.Set(key, struct{}{}) return nil } -func Build(ctx context.Context, nodes []builder.Node, opts map[string]Options, docker *dockerutil.Client, cfg *confutil.Config, w progress.Writer) (resp map[string]*client.SolveResponse, err error) { - return BuildWithResultHandler(ctx, nodes, opts, docker, cfg, w, nil) +func resultError(results map[string]any) error { + for _, result := range results { + if err, ok := result.(error); ok { + return err + } + } + return nil } -func BuildWithResultHandler(ctx context.Context, nodes []builder.Node, opts map[string]Options, docker *dockerutil.Client, cfg *confutil.Config, w progress.Writer, bh *Handler) (resp map[string]*client.SolveResponse, err error) { +func newSyncTargetState(opts map[string]Options, drivers map[string][]*noderesolver.ResolvedNode) *syncTargetState { + targets := make([]string, 0, len(opts)) + for k := range opts { + for _, dp := range drivers[k] { + targets = append(targets, resultKey(dp, k)) + } + } + return &syncTargetState{ + targets: targets, + results: waitmap.New(), + evaluated: waitmap.New(), + } +} + +func (s *syncTargetState) fail(key string, err error) { + s.results.Set(key, err) + s.evaluated.Set(key, err) +} + +func (s *syncTargetState) waitResult(ctx context.Context, key string, result any) error { + s.results.Set(key, result) + results, err := s.results.Get(ctx, s.targets...) + if err != nil { + return err + } + return resultError(results) +} + +func (s *syncTargetState) waitEvaluated(ctx context.Context, key string, result any) error { + s.evaluated.Set(key, result) + results, err := s.evaluated.Get(ctx, s.targets...) + if err != nil { + return err + } + return resultError(results) +} + +func Build(ctx context.Context, nodes []builder.Node, opts map[string]Options, docker *dockerutil.Client, cfg *confutil.Config, w progress.Writer, bh *Handler) (resp map[string]*client.SolveResponse, err error) { if len(nodes) == 0 { return nil, errors.Errorf("driver required for build") } @@ -508,7 +601,12 @@ func BuildWithResultHandler(ctx context.Context, nodes []builder.Node, opts map[ return nil, err } - eg, ctx := errgroup.WithContext(ctx) + var eg *errgroup.Group + if bh != nil && bh.Execution.Mode == ExecutionModeDeferError { + eg = &errgroup.Group{} + } else { + eg, ctx = errgroup.WithContext(ctx) + } reqForNodes, release, err := newBuildRequests(ctx, docker, cfg, drivers, w, opts) if err != nil { return nil, err @@ -538,7 +636,31 @@ func BuildWithResultHandler(ctx context.Context, nodes []builder.Node, opts map[ // loop below, before any goroutines are spawned; no mutex needed. linkedClients := make(map[string]*client.Client) - for k, opt := range opts { + var syncState *syncTargetState + if bh != nil && bh.Execution.Mode == ExecutionModeSyncOutput { + if bh.Execution.Parallel > 0 && bh.Execution.Parallel < len(opts) { + return nil, errors.Errorf("sync-output execution requires parallelism to be unlimited or at least the number of targets") + } + // Sync waits for every solve result before any ref evaluation starts and + // every ref evaluation before exporters can run, so output is only written + // after all targets have reached the output boundary successfully. + syncState = newSyncTargetState(opts, drivers) + } + + var targetLimit chan struct{} + if bh != nil && bh.Execution.Parallel > 0 { + if bh.Execution.Parallel < len(opts) && linkedTargets.hasLinks() { + return nil, errors.Errorf("limited parallelism is not supported with linked targets") + } + targetLimit = make(chan struct{}, bh.Execution.Parallel) + } + + targets := slices.Collect(maps.Keys(opts)) + if targetLimit != nil { + slices.Sort(targets) + } + for _, k := range targets { + opt := opts[k] err := func(k string) (err error) { dps := drivers[k] multiDriver := len(drivers[k]) > 1 @@ -558,6 +680,20 @@ func BuildWithResultHandler(ctx context.Context, nodes []builder.Node, opts map[ res := make([]*client.SolveResponse, len(dps)) eg2, ctx := errgroup.WithContext(ctx) + var releaseTarget func() + if targetLimit != nil { + select { + case targetLimit <- struct{}{}: + releaseTarget = func() { <-targetLimit } + defer func() { + if err != nil { + releaseTarget() + } + }() + case <-ctx.Done(): + return context.Cause(ctx) + } + } var pushNames string var insecurePush bool @@ -638,7 +774,15 @@ func BuildWithResultHandler(ctx context.Context, nodes []builder.Node, opts map[ done = wg.Done } - eg2.Go(func() error { + eg2.Go(func() (err error) { + defer func() { + if err != nil { + if syncState != nil { + syncState.fail(rKey, err) + } + linkedTargets.fail(rKey, err) + } + }() if done != nil { defer done() } @@ -674,7 +818,7 @@ func BuildWithResultHandler(ctx context.Context, nodes []builder.Node, opts map[ callRes map[string][]byte frontendErr error ) - buildFunc := func(ctx context.Context, c gateway.Client) (_ *gateway.Result, retErr error) { + buildFunc := func(solveCtx context.Context, c gateway.Client) (_ *gateway.Result, retErr error) { // Capture the error from this build function. defer catchFrontendError(&retErr, &frontendErr) @@ -687,7 +831,7 @@ func BuildWithResultHandler(ctx context.Context, nodes []builder.Node, opts map[ req.FrontendOpt["requestid"] = "frontend." + opt.CallFunc.Name } - res, err := solve(ctx, c, req) + res, err := solve(solveCtx, c, req) if err != nil { return nil, err } @@ -696,17 +840,31 @@ func BuildWithResultHandler(ctx context.Context, nodes []builder.Node, opts map[ callRes = res.Metadata } - if err := linkedTargets.run(ctx, rKey, res, func() error { - // invoke custom evaluate handler if it is present - if bh != nil && bh.Evaluate != nil { - return bh.Evaluate(ctx, k, c, res, opt) + var preEvaluate, postEvaluate func() error + if syncState != nil { + preEvaluate = func() error { + return syncState.waitResult(ctx, rKey, res) } - if linkedTargets.isLinked(rKey) { - return eachRefParallel(ctx, res, func(ctx context.Context, ref gateway.Reference) error { - return ref.Evaluate(ctx) - }) + postEvaluate = func() error { + return syncState.waitEvaluated(ctx, rKey, struct{}{}) } - return nil + } + + if err := linkedTargets.run(ctx, rKey, res, linkedTargetHooks{ + preEvaluate: preEvaluate, + evaluate: func() error { + // invoke custom evaluate handler if it is present + if bh != nil && bh.Evaluate != nil { + return bh.Evaluate(solveCtx, k, c, res, opt) + } + if syncState != nil || linkedTargets.isLinked(rKey) { + return eachRefParallel(solveCtx, res, func(ctx context.Context, ref gateway.Reference) error { + return ref.Evaluate(ctx) + }) + } + return nil + }, + postEvaluate: postEvaluate, }); err != nil { return nil, err } @@ -799,6 +957,9 @@ func BuildWithResultHandler(ctx context.Context, nodes []builder.Node, opts map[ eg.Go(func() (err error) { ctx := baseCtx + if releaseTarget != nil { + defer releaseTarget() + } defer func() { if span != nil { tracing.FinishWithError(span, err) @@ -1243,9 +1404,12 @@ func waitContextDeps(ctx context.Context, node *noderesolver.ResolvedNode, resul if !ok { continue } + if err, ok := r.(error); ok { + return err + } rr, ok := r.(*gateway.Result) if !ok { - return errors.Errorf("invalid result type %T", rr) + return errors.Errorf("invalid result type %T", r) } if so.FrontendAttrs == nil { so.FrontendAttrs = map[string]string{} diff --git a/build/linked_targets_test.go b/build/linked_targets_test.go index 40460a09fb3e..910c6f9ec0c4 100644 --- a/build/linked_targets_test.go +++ b/build/linked_targets_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + "github.com/docker/buildx/util/waitmap" "github.com/pkg/errors" "github.com/stretchr/testify/require" ) @@ -39,16 +40,22 @@ func TestLinkedTargetStateChainRetainsParents(t *testing.T) { } go func() { - done["root"] <- state.run(t.Context(), "root", struct{}{}, func() error { return nil }) + done["root"] <- state.run(t.Context(), "root", struct{}{}, linkedTargetHooks{ + evaluate: func() error { return nil }, + }) }() go func() { - done["middle"] <- state.run(t.Context(), "middle", struct{}{}, func() error { return nil }) + done["middle"] <- state.run(t.Context(), "middle", struct{}{}, linkedTargetHooks{ + evaluate: func() error { return nil }, + }) }() go func() { - done["leaf"] <- state.run(t.Context(), "leaf", struct{}{}, func() error { - close(leafStarted) - <-releaseLeaf - return nil + done["leaf"] <- state.run(t.Context(), "leaf", struct{}{}, linkedTargetHooks{ + evaluate: func() error { + close(leafStarted) + <-releaseLeaf + return nil + }, }) }() @@ -83,7 +90,9 @@ func TestLinkedTargetStateDiamondEvaluatesBranchesInParallel(t *testing.T) { done := make(chan error, 4) run := func(key string, evaluate func() error) { go func() { - done <- state.run(t.Context(), key, struct{}{}, evaluate) + done <- state.run(t.Context(), key, struct{}{}, linkedTargetHooks{ + evaluate: evaluate, + }) }() } run("root", func() error { return nil }) @@ -126,12 +135,158 @@ func TestLinkedTargetStateCancellation(t *testing.T) { cause := errors.New("target failed") done := make(chan error, 1) go func() { - done <- state.run(ctx, "child", struct{}{}, func() error { return nil }) + done <- state.run(ctx, "child", struct{}{}, linkedTargetHooks{ + evaluate: func() error { return nil }, + }) + }() + cancel(cause) + require.ErrorIs(t, <-done, cause) +} + +func TestLinkedTargetStatePropagatesDependencyErrors(t *testing.T) { + state := newLinkedTargetState( + map[string][]string{"child": {"parent"}}, + map[string][]string{"parent": {"child"}}, + ) + cause := errors.New("parent failed") + state.fail("parent", cause) + + err := state.run(t.Context(), "child", struct{}{}, linkedTargetHooks{ + evaluate: func() error { return nil }, + }) + require.ErrorIs(t, err, cause) +} + +func TestSyncEvaluateWaitsForAllTargets(t *testing.T) { + targets := []string{"foo", "bar"} + results := waitmap.New() + + fooStarted := make(chan struct{}) + done := map[string]chan error{ + "foo": make(chan error, 1), + "bar": make(chan error, 1), + } + + go func() { + results.Set("foo", struct{}{}) + if _, err := results.Get(t.Context(), targets...); err != nil { + done["foo"] <- err + return + } + close(fooStarted) + done["foo"] <- nil + }() + + assertNotSignaled(t, fooStarted) + assertNotCompleted(t, done["foo"]) + + go func() { + results.Set("bar", struct{}{}) + if _, err := results.Get(t.Context(), targets...); err != nil { + done["bar"] <- err + return + } + done["bar"] <- nil + }() + + require.NoError(t, <-done["foo"]) + require.NoError(t, <-done["bar"]) +} + +func TestSyncEvaluateCancellation(t *testing.T) { + results := waitmap.New() + ctx, cancel := context.WithCancelCause(t.Context()) + cause := errors.New("target failed") + + done := make(chan error, 1) + go func() { + results.Set("foo", struct{}{}) + _, err := results.Get(ctx, "foo", "bar") + done <- err }() + cancel(cause) require.ErrorIs(t, <-done, cause) } +func TestSyncEvaluateDoesNotDeadlockLinkedTargets(t *testing.T) { + linked := newLinkedTargetState( + map[string][]string{"child": {"parent"}}, + map[string][]string{"parent": {"child"}}, + ) + results := waitmap.New() + evaluated := waitmap.New() + + done := map[string]chan error{ + "parent": make(chan error, 1), + "child": make(chan error, 1), + } + + for _, key := range []string{"parent", "child"} { + go func() { + done[key] <- linked.run(t.Context(), key, struct{}{}, linkedTargetHooks{ + preEvaluate: func() error { + results.Set(key, struct{}{}) + _, err := results.Get(t.Context(), "parent", "child") + return err + }, + evaluate: func() error { + return nil + }, + postEvaluate: func() error { + evaluated.Set(key, struct{}{}) + _, err := evaluated.Get(t.Context(), "parent", "child") + return err + }, + }) + }() + } + + require.NoError(t, <-done["parent"]) + require.NoError(t, <-done["child"]) +} + +func TestSyncEvaluatePropagatesEvaluationErrors(t *testing.T) { + linked := newLinkedTargetState(map[string][]string{}, map[string][]string{}) + evaluated := waitmap.New() + cause := errors.New("target failed") + done := map[string]chan error{ + "success": make(chan error, 1), + "failure": make(chan error, 1), + } + + go func() { + done["success"] <- linked.run(t.Context(), "success", struct{}{}, linkedTargetHooks{ + evaluate: func() error { + return nil + }, + postEvaluate: func() error { + evaluated.Set("success", struct{}{}) + results, err := evaluated.Get(t.Context(), "success", "failure") + if err != nil { + return err + } + return resultError(results) + }, + }) + }() + + assertNotCompleted(t, done["success"]) + + go func() { + err := linked.run(t.Context(), "failure", struct{}{}, linkedTargetHooks{ + evaluate: func() error { + return cause + }, + }) + evaluated.Set("failure", err) + done["failure"] <- err + }() + + require.ErrorIs(t, <-done["success"], cause) + require.ErrorIs(t, <-done["failure"], cause) +} + func assertNotCompleted(t *testing.T, ch <-chan error) { t.Helper() select { diff --git a/commands/bake.go b/commands/bake.go index 2924177f24d0..2d2ed933c19e 100644 --- a/commands/bake.go +++ b/commands/bake.go @@ -68,6 +68,7 @@ type bakeOptions struct { exportPush bool exportLoad bool callFunc string + execution string print bool list string @@ -360,8 +361,19 @@ func runBake(ctx context.Context, dockerCli command.Cli, targets []string, in ba return err } + execution, err := parseExecution(in.execution) + if err != nil { + return err + } + done := timeBuildCommand(mp, attributes) - resp, retErr := build.Build(ctx, nodes, bo, dockerutil.NewClient(dockerCli), confutil.NewConfig(dockerCli), printer) + var bh *build.Handler + if execution.Mode != build.ExecutionModeFailFast || execution.Parallel > 0 { + bh = &build.Handler{ + Execution: execution, + } + } + resp, retErr := build.Build(ctx, nodes, bo, dockerutil.NewClient(dockerCli), confutil.NewConfig(dockerCli), printer, bh) if err := printer.Wait(); retErr == nil { retErr = err } @@ -561,6 +573,7 @@ func bakeCmd(dockerCli command.Cli, rootOpts *rootOptions) *cobra.Command { flags.StringArrayVar(&options.vars, "var", nil, `Set a variable value (e.g., "name=value")`) flags.StringVar(&options.callFunc, "call", "build", `Set method for evaluating build ("check", "outline", "targets")`) flags.StringArrayVar(&options.allow, "allow", nil, "Allow build to access specified resources") + flags.StringVar(&options.execution, "execution", "fail-fast", `Set target execution behavior (format: "mode[,parallel=N]")`) flags.VarPF(callAlias(&options.callFunc, "check"), "check", "", `Shorthand for "--call=check"`) flags.Lookup("check").NoOptDefVal = "true" @@ -748,6 +761,52 @@ type listEntry struct { Format string } +func parseExecution(input string) (build.Execution, error) { + res := build.Execution{Mode: build.ExecutionModeFailFast} + if strings.TrimSpace(input) == "" { + return res, nil + } + fields, err := csvvalue.Fields(input, nil) + if err != nil { + return res, err + } + var modeSet bool + for _, field := range fields { + field = strings.TrimSpace(field) + if field == "" { + continue + } + key, value, ok := strings.Cut(field, "=") + if !ok { + if modeSet { + return res, errors.Errorf("multiple execution modes specified") + } + mode := build.ExecutionMode(field) + switch mode { + case build.ExecutionModeFailFast, build.ExecutionModeSyncOutput, build.ExecutionModeDeferError: + res.Mode = mode + modeSet = true + default: + return res, errors.Errorf("invalid execution mode %q", field) + } + continue + } + key = strings.TrimSpace(strings.ToLower(key)) + value = strings.TrimSpace(value) + switch key { + case "parallel": + parallel, err := strconv.Atoi(value) + if err != nil || parallel < 0 { + return res, errors.Errorf("invalid execution parallelism %q", value) + } + res.Parallel = parallel + default: + return res, errors.Errorf("unexpected key %q in execution option %q", key, field) + } + } + return res, nil +} + func parseList(input string) (listEntry, error) { res := listEntry{} diff --git a/commands/bake_test.go b/commands/bake_test.go new file mode 100644 index 000000000000..28f054af992f --- /dev/null +++ b/commands/bake_test.go @@ -0,0 +1,73 @@ +package commands + +import ( + "testing" + + "github.com/docker/buildx/build" + "github.com/stretchr/testify/require" +) + +func TestParseExecution(t *testing.T) { + tests := []struct { + name string + input string + expected build.Execution + }{ + { + name: "default", + input: "", + expected: build.Execution{ + Mode: build.ExecutionModeFailFast, + }, + }, + { + name: "sync output", + input: "sync-output", + expected: build.Execution{ + Mode: build.ExecutionModeSyncOutput, + }, + }, + { + name: "defer error with parallelism", + input: "defer-error,parallel=2", + expected: build.Execution{ + Mode: build.ExecutionModeDeferError, + Parallel: 2, + }, + }, + { + name: "parallelism with default mode", + input: "parallel=1", + expected: build.Execution{ + Mode: build.ExecutionModeFailFast, + Parallel: 1, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual, err := parseExecution(tt.input) + require.NoError(t, err) + require.Equal(t, tt.expected, actual) + }) + } +} + +func TestParseExecutionInvalid(t *testing.T) { + tests := []string{ + "unknown", + "mode=sync-output", + "parallel=-1", + "parallel=nope", + "unexpected=value", + "fail-fast,defer-error", + } + + for _, tt := range tests { + t.Run(tt, func(t *testing.T) { + _, err := parseExecution(tt) + require.Error(t, err) + }) + } +} diff --git a/commands/build.go b/commands/build.go index 5f267704052a..6b059f467fed 100644 --- a/commands/build.go +++ b/commands/build.go @@ -1203,7 +1203,7 @@ func RunBuild(ctx context.Context, dockerCli command.Cli, in *BuildOptions, inSt var inputs *build.Inputs buildOptions := map[string]build.Options{defaultTargetName: opts} - resp, err := build.BuildWithResultHandler(ctx, nodes, buildOptions, dockerutil.NewClient(dockerCli), confutil.NewConfig(dockerCli), progress, bh) + resp, err := build.Build(ctx, nodes, buildOptions, dockerutil.NewClient(dockerCli), confutil.NewConfig(dockerCli), progress, bh) err = wrapBuildError(err, false) if err != nil { return nil, nil, err diff --git a/docs/reference/buildx_bake.md b/docs/reference/buildx_bake.md index ca038d1bd6f0..c4278b647050 100644 --- a/docs/reference/buildx_bake.md +++ b/docs/reference/buildx_bake.md @@ -13,27 +13,28 @@ Build from a file ### Options -| Name | Type | Default | Description | -|:------------------------------------|:--------------|:--------|:----------------------------------------------------------------------------------------------------------------------| -| [`--allow`](#allow) | `stringArray` | | Allow build to access specified resources | -| [`--builder`](#builder) | `string` | | Override the configured builder instance | -| [`--call`](#call) | `string` | `build` | Set method for evaluating build (`check`, `outline`, `targets`) | -| [`--check`](#check) | `bool` | | Shorthand for `--call=check` | -| `-D`, `--debug` | `bool` | | Enable debug logging | -| [`-f`](#file), [`--file`](#file) | `stringArray` | | Build definition file | -| [`--list`](#list) | `string` | | List targets or variables | -| [`--load`](#load) | `bool` | | Shorthand for `--set=*.output=type=docker`. Conditional. | -| [`--metadata-file`](#metadata-file) | `string` | | Write build result metadata to a file | -| [`--no-cache`](#no-cache) | `bool` | | Do not use cache when building the image | -| `--policy` | `stringArray` | | Global policy evaluation options (format: `[disabled=true\|false][,strict=true\|false][,log-level=level]`) | -| [`--print`](#print) | `bool` | | Print the options without building | -| [`--progress`](#progress) | `string` | `auto` | Set type of progress output (`auto`, `none`, `plain`, `quiet`, `rawjson`, `tty`). Use plain to show container output | -| [`--provenance`](#provenance) | `string` | | Shorthand for `--set=*.attest=type=provenance` | -| [`--pull`](#pull) | `bool` | | Always attempt to pull all referenced images | -| [`--push`](#push) | `bool` | | Shorthand for `--set=*.output=type=registry`. Conditional. | -| [`--sbom`](#sbom) | `string` | | Shorthand for `--set=*.attest=type=sbom` | -| [`--set`](#set) | `stringArray` | | Override target value (e.g., `targetpattern.key=value`) | -| `--var` | `stringArray` | | Set a variable value (e.g., `name=value`) | +| Name | Type | Default | Description | +|:------------------------------------|:--------------|:------------|:----------------------------------------------------------------------------------------------------------------------| +| [`--allow`](#allow) | `stringArray` | | Allow build to access specified resources | +| [`--builder`](#builder) | `string` | | Override the configured builder instance | +| [`--call`](#call) | `string` | `build` | Set method for evaluating build (`check`, `outline`, `targets`) | +| [`--check`](#check) | `bool` | | Shorthand for `--call=check` | +| `-D`, `--debug` | `bool` | | Enable debug logging | +| [`--execution`](#execution) | `string` | `fail-fast` | Set target execution behavior (format: `mode[,parallel=N]`) | +| [`-f`](#file), [`--file`](#file) | `stringArray` | | Build definition file | +| [`--list`](#list) | `string` | | List targets or variables | +| [`--load`](#load) | `bool` | | Shorthand for `--set=*.output=type=docker`. Conditional. | +| [`--metadata-file`](#metadata-file) | `string` | | Write build result metadata to a file | +| [`--no-cache`](#no-cache) | `bool` | | Do not use cache when building the image | +| `--policy` | `stringArray` | | Global policy evaluation options (format: `[disabled=true\|false][,strict=true\|false][,log-level=level]`) | +| [`--print`](#print) | `bool` | | Print the options without building | +| [`--progress`](#progress) | `string` | `auto` | Set type of progress output (`auto`, `none`, `plain`, `quiet`, `rawjson`, `tty`). Use plain to show container output | +| [`--provenance`](#provenance) | `string` | | Shorthand for `--set=*.attest=type=provenance` | +| [`--pull`](#pull) | `bool` | | Always attempt to pull all referenced images | +| [`--push`](#push) | `bool` | | Shorthand for `--set=*.output=type=registry`. Conditional. | +| [`--sbom`](#sbom) | `string` | | Shorthand for `--set=*.attest=type=sbom` | +| [`--set`](#set) | `stringArray` | | Override target value (e.g., `targetpattern.key=value`) | +| `--var` | `stringArray` | | Set a variable value (e.g., `name=value`) | @@ -142,6 +143,40 @@ Same as [`build --call`](buildx_build.md#call). Same as [`build --check`](buildx_build.md#check). +### Configure target execution behavior (--execution) + +```text +--execution=MODE[,parallel=N] +``` + +The `--execution` flag controls how Bake schedules targets and handles target +failures. The default mode is `fail-fast`, which stops the build when a target +fails and cancels targets that are still running. + +The `sync-output` mode waits until every selected target has reached the output +boundary successfully before any target writes output. This is useful when +multiple targets export local outputs and you want to avoid writing partial +results if another target fails. + +The `defer-error` mode allows independent targets to keep running after another +target fails. Bake still returns an error after all possible targets complete, +but successful targets can finish and write their outputs. + +Use `parallel=N` to limit how many Bake targets run at the same time. This +limits target scheduling only; it doesn't change BuildKit's internal +parallelism for build steps inside a target. For example, the following command +runs at most two Bake targets at a time and reports any target errors after the +remaining independent targets complete: + +```console +$ docker buildx bake --execution=defer-error,parallel=2 +``` + +When `parallel` is omitted or set to `0`, Bake doesn't apply a target +parallelism limit. The `sync-output` mode requires all selected targets to be +able to reach the output boundary together, so it can't be combined with a +parallel limit that is smaller than the number of selected targets. + ### Specify a build definition file (-f, --file) Use the `-f` / `--file` option to specify the build definition file to use. diff --git a/tests/bake.go b/tests/bake.go index 279bc3c1143e..f38ea915f963 100644 --- a/tests/bake.go +++ b/tests/bake.go @@ -46,6 +46,10 @@ var bakeTests = []func(t *testing.T, sb integration.Sandbox){ testBakePrintRemoteContextSubdir, testBakeLocal, testBakeLocalMulti, + testBakeSyncOutput, + testBakeFailFast, + testBakeDeferError, + testBakeParallel, testBakeFileRelativePaths, testBakeLocalExportDeleteMode, testBakeRemote, @@ -724,6 +728,81 @@ services: require.FileExists(t, filepath.Join(dirDest2, "foo")) } +func testBakeSyncOutput(t *testing.T, sb integration.Sandbox) { + dir := bakeExecutionFailureDir(t, []byte(` +FROM scratch +COPY foo /foo +`)) + + out, err := bakeCmd(sb, withDir(dir), withArgs("--execution=sync-output")) + require.Error(t, err, out) + require.NoFileExists(t, filepath.Join(dir, "out", "foo")) +} + +func testBakeFailFast(t *testing.T, sb integration.Sandbox) { + dir := bakeExecutionFailureDir(t, []byte(` +FROM busybox +RUN sleep 2 +COPY foo /foo +`)) + + out, err := bakeCmd(sb, withDir(dir), withArgs("--execution=fail-fast")) + require.Error(t, err, out) + require.NoFileExists(t, filepath.Join(dir, "out", "foo")) +} + +func testBakeDeferError(t *testing.T, sb integration.Sandbox) { + dir := bakeExecutionFailureDir(t, []byte(` +FROM busybox +RUN sleep 2 +COPY foo /foo +`)) + + out, err := bakeCmd(sb, withDir(dir), withArgs("--execution=defer-error")) + require.Error(t, err, out) + require.FileExists(t, filepath.Join(dir, "out", "foo")) +} + +func testBakeParallel(t *testing.T, sb integration.Sandbox) { + dir := bakeExecutionFailureDir(t, []byte(` +FROM scratch +COPY foo /foo +`)) + + out, err := bakeCmd(sb, withDir(dir), withArgs("--execution=defer-error,parallel=1")) + require.Error(t, err, out) + require.FileExists(t, filepath.Join(dir, "out", "foo")) +} + +func bakeExecutionFailureDir(t *testing.T, dockerfile []byte) string { + failureDockerfile := []byte(` +FROM scratch +COPY missing /missing +`) + bakefile := []byte(` +group "default" { + targets = ["a-success", "b-failure"] +} + +target "a-success" { + dockerfile = "Dockerfile" + output = ["type=local,dest=out"] +} + +target "b-failure" { + dockerfile = "failure.Dockerfile" + output = ["type=cacheonly"] +} +`) + return tmpdir( + t, + fstest.CreateFile("docker-bake.hcl", bakefile, 0600), + fstest.CreateFile("Dockerfile", dockerfile, 0600), + fstest.CreateFile("failure.Dockerfile", failureDockerfile, 0600), + fstest.CreateFile("foo", []byte("foo"), 0600), + ) +} + func testBakeFileRelativePaths(t *testing.T, sb integration.Sandbox) { t.Run("compose context", func(t *testing.T) { dockerfile := []byte(`