Files
compose/pkg/compose/build_bake.go
T
Nicolas De LoofandClaude Fable 5 6d2e1d99dd build: split doBuildBake at its responsibility boundaries
doBuildBake mixed five concerns in a 300-line body (cognitive
complexity 88): progress display setup, translation of the project
into a bake file definition, temp metadata file allocation, bake
command construction, stderr rawjson streaming, and result
collection. Each now lives in its own function; the driver reads as
the sequence of those stages (cognitive complexity 17), and the
FIXME suppression is gone.

The bake variable was named to avoid shadowing the docker/cli
'build' package. No behavior change: prepareBakeBuild emits the same
config, and the stderr loop keeps the decoder-per-line semantics.

Co-Authored-By: Claude Fable 5 <[email protected]>
Signed-off-by: Nicolas De Loof <[email protected]>
2026-08-18 10:42:37 +02:00

652 lines
20 KiB
Go

/*
Copyright 2020 Docker Compose CLI authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package compose
import (
"bufio"
"bytes"
"context"
"crypto/sha1"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"github.com/compose-spec/compose-go/v2/types"
"github.com/containerd/console"
"github.com/containerd/errdefs"
"github.com/docker/cli/cli-plugins/manager"
"github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/command/image/build"
"github.com/docker/cli/cli/streams"
"github.com/google/uuid"
"github.com/moby/buildkit/client"
gitutil "github.com/moby/buildkit/frontend/dockerfile/dfgitutil"
"github.com/moby/buildkit/util/progress/progressui"
"github.com/moby/moby/client/pkg/versions"
"github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"golang.org/x/sync/errgroup"
"github.com/docker/compose/v5/pkg/api"
)
func buildWithBake(dockerCli command.Cli) (bool, error) {
enabled, err := dockerCli.BuildKitEnabled()
if err != nil {
return false, err
}
if !enabled {
return false, nil
}
_, err = manager.GetPlugin("buildx", dockerCli, &cobra.Command{})
if err != nil {
if errdefs.IsNotFound(err) {
logrus.Warnf("Docker Compose requires buildx plugin to be installed")
return false, nil
}
return false, err
}
return true, err
}
// We _could_ use bake.* types from github.com/docker/buildx but long term plan is to remove buildx as a dependency
type bakeConfig struct {
Groups map[string]bakeGroup `json:"group"`
Targets map[string]bakeTarget `json:"target"`
}
type bakeGroup struct {
Targets []string `json:"targets"`
}
type bakeTarget struct {
Context string `json:"context,omitempty"`
Contexts map[string]string `json:"contexts,omitempty"`
Dockerfile string `json:"dockerfile,omitempty"`
DockerfileInline string `json:"dockerfile-inline,omitempty"`
Args map[string]string `json:"args,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
Tags []string `json:"tags,omitempty"`
CacheFrom []string `json:"cache-from,omitempty"`
CacheTo []string `json:"cache-to,omitempty"`
Target string `json:"target,omitempty"`
Secrets []string `json:"secret,omitempty"`
SSH []string `json:"ssh,omitempty"`
Platforms []string `json:"platforms,omitempty"`
Pull bool `json:"pull,omitempty"`
NoCache bool `json:"no-cache,omitempty"`
NetworkMode string `json:"network,omitempty"`
NoCacheFilter []string `json:"no-cache-filter,omitempty"`
ShmSize types.UnitBytes `json:"shm-size,omitempty"`
Ulimits []string `json:"ulimits,omitempty"`
Call string `json:"call,omitempty"`
Entitlements []string `json:"entitlements,omitempty"`
ExtraHosts map[string]string `json:"extra-hosts,omitempty"`
Outputs []string `json:"output,omitempty"`
Attest []string `json:"attest,omitempty"`
}
type bakeMetadata map[string]buildStatus
type buildStatus struct {
Digest string `json:"containerimage.digest"`
Image string `json:"image.name"`
}
// bakeBuild is everything derived from the project that doBuildBake needs to
// drive `buildx bake`: the bake file definition, plus the settings that travel
// as command arguments or environment variables rather than in the file.
type bakeBuild struct {
cfg bakeConfig
targetNames map[string]string // service name -> bake target name
expectedImages map[string]string // service name -> expected image
localPaths []string // local build contexts bake needs `--allow fs.read` for
privileged bool
secretsEnv []string
}
func (s *composeService) doBuildBake(ctx context.Context, project *types.Project, serviceToBeBuild types.Services, options api.BuildOptions) (map[string]string, error) {
eg := errgroup.Group{}
ch := make(chan *client.SolveStatus)
displayMode := progressui.DisplayMode(options.Progress)
if p, ok := os.LookupEnv("BUILDKIT_PROGRESS"); ok && displayMode == progressui.AutoMode {
displayMode = progressui.DisplayMode(p)
}
out := options.Out
if out == nil {
out = s.stdout()
}
display, err := progressui.NewDisplay(makeConsole(out), displayMode)
if err != nil {
return nil, err
}
eg.Go(func() error {
_, err := display.UpdateFrom(ctx, ch)
return err
})
bake := s.prepareBakeBuild(project, serviceToBeBuild, options)
cfgJSON, err := json.MarshalIndent(bake.cfg, "", " ")
if err != nil {
return nil, err
}
if options.Print {
_, err = fmt.Fprintln(s.stdout(), string(cfgJSON))
return nil, err
}
logrus.Debugf("bake build config:\n%s", string(cfgJSON))
metadataFile, err := bakeMetadataPath()
if err != nil {
return nil, err
}
defer func() {
_ = os.Remove(metadataFile)
}()
buildx, err := s.getBuildxPlugin()
if err != nil {
return nil, err
}
args := bakeArgs(bake, metadataFile, options)
logrus.Debugf("Executing bake with args: %v", args)
if s.dryRun {
return s.dryRunBake(bake.cfg), nil
}
cmd := exec.CommandContext(ctx, buildx.Path, args...)
err = s.prepareShellOut(ctx, types.NewMapping(os.Environ()), cmd)
if err != nil {
return nil, err
}
endpoint, cleanup, err := s.propagateDockerEndpoint()
if err != nil {
return nil, err
}
defer cleanup()
cmd.Env = append(cmd.Env, endpoint...)
cmd.Env = append(cmd.Env, bake.secretsEnv...)
cmd.Stdout = s.stdout()
cmd.Stdin = bytes.NewBuffer(cfgJSON)
pipe, err := cmd.StderrPipe()
if err != nil {
return nil, err
}
err = cmd.Start()
if err != nil {
return nil, err
}
eg.Go(cmd.Wait)
errMessage, err := forwardBakeStatus(pipe, ch)
if err != nil {
return nil, err
}
close(ch) // stop build progress UI
err = eg.Wait()
if err != nil {
if len(errMessage) > 0 {
return nil, errors.New(strings.Join(errMessage, "\n"))
}
return nil, fmt.Errorf("failed to execute bake: %w", err)
}
return s.collectBakeResults(ctx, metadataFile, serviceToBeBuild, bake)
}
// prepareBakeBuild translates the project's build configuration into a bake
// file definition and the side-band settings bake takes on its command line.
func (s *composeService) prepareBakeBuild(project *types.Project, serviceToBeBuild types.Services, options api.BuildOptions) *bakeBuild {
bake := &bakeBuild{
cfg: bakeConfig{
Groups: map[string]bakeGroup{},
Targets: map[string]bakeTarget{},
},
targetNames: bakeTargetNames(project),
expectedImages: make(map[string]string, len(serviceToBeBuild)),
}
// project.Services lists every service (we still need their bake targets
// defined so additional_contexts: service:xxx references can resolve),
// but only emit "Building" progress and track expected images for
// services we actually plan to build.
for serviceName, service := range project.Services {
if service.Build == nil {
continue
}
buildConfig := *service.Build
args := resolveAndMergeBuildArgs(s.getProxyConfig(), project, service, options).ToMapping()
for k, v := range args {
args[k] = strings.ReplaceAll(v, "${", "$${")
}
entitlements, privileged := bakeEntitlements(buildConfig)
bake.privileged = bake.privileged || privileged
bake.localPaths = append(bake.localPaths, localBuildPaths(buildConfig)...)
image := api.GetImageNameOrDefault(service, project.Name)
if _, ok := serviceToBeBuild[serviceName]; ok {
s.events.On(buildingEvent(image))
bake.expectedImages[serviceName] = image
}
secrets, env := toBakeSecrets(project, buildConfig.Secrets)
bake.secretsEnv = append(bake.secretsEnv, env...)
outputs, call := bakeOutputs(service, options)
bake.cfg.Targets[bake.targetNames[serviceName]] = bakeTarget{
Context: buildConfig.Context,
Contexts: additionalContexts(buildConfig.AdditionalContexts, bake.targetNames),
Dockerfile: dockerFilePath(buildConfig.Context, buildConfig.Dockerfile),
DockerfileInline: strings.ReplaceAll(buildConfig.DockerfileInline, "${", "$${"),
Args: args,
Labels: getImageBuildLabels(project, service),
Tags: append(buildConfig.Tags, image),
CacheFrom: buildConfig.CacheFrom,
CacheTo: buildConfig.CacheTo,
NetworkMode: buildConfig.Network,
NoCacheFilter: buildConfig.NoCacheFilter,
Platforms: buildConfig.Platforms,
Target: buildConfig.Target,
Secrets: secrets,
SSH: toBakeSSH(append(buildConfig.SSH, options.SSHs...)),
Pull: buildConfig.Pull || options.Pull,
NoCache: buildConfig.NoCache || options.NoCache,
ShmSize: buildConfig.ShmSize,
Ulimits: toBakeUlimits(buildConfig.Ulimits),
Entitlements: entitlements,
ExtraHosts: toBakeExtraHosts(buildConfig.ExtraHosts),
Outputs: outputs,
Call: call,
Attest: toBakeAttest(buildConfig),
}
}
// create a bake group with targets for services to build
var group bakeGroup
for serviceName, service := range serviceToBeBuild {
if service.Build == nil {
continue
}
group.Targets = append(group.Targets, bake.targetNames[serviceName])
}
bake.cfg.Groups["default"] = group
return bake
}
// bakeTargetNames produces a unique ID for each service, used as bake target
func bakeTargetNames(project *types.Project) map[string]string {
targets := make(map[string]string, len(project.Services))
for serviceName := range project.Services {
t := strings.ReplaceAll(serviceName, ".", "_")
for {
if _, ok := targets[serviceName]; !ok {
targets[serviceName] = t
break
}
t += "_"
}
}
return targets
}
// bakeEntitlements returns the entitlements for a build target, and whether
// bake must be granted `security.insecure`.
func bakeEntitlements(buildConfig types.BuildConfig) ([]string, bool) {
entitlements := buildConfig.Entitlements
privileged := slices.Contains(buildConfig.Entitlements, "security.insecure")
if buildConfig.Privileged {
entitlements = append(entitlements, "security.insecure")
privileged = true
}
return entitlements, privileged
}
// bakeOutputs selects the bake output type — or the lint call for `--check` —
// for a service build.
func bakeOutputs(service types.ServiceConfig, options api.BuildOptions) (outputs []string, call string) {
push := options.Push && service.Image != ""
switch {
case options.Check:
return nil, "lint"
case len(service.Build.Platforms) > 1:
return []string{fmt.Sprintf("type=image,push=%t", push)}, ""
case push:
return []string{"type=registry"}, ""
default:
return []string{"type=docker"}, ""
}
}
// localBuildPaths returns the build context paths that live on the local
// filesystem — remote (git or URL) contexts need no fs.read entitlement.
func localBuildPaths(buildConfig types.BuildConfig) []string {
paths := []string{buildConfig.Context}
for _, path := range buildConfig.AdditionalContexts {
paths = append(paths, path)
}
var local []string
for _, path := range paths {
if _, _, err := gitutil.ParseGitRef(path); !strings.Contains(path, "://") && err != nil {
local = append(local, path)
}
}
return local
}
// bakeMetadataPath picks a fresh temporary path for bake's --metadata-file.
// We don't use os.CreateTemp here as we need a temporary file name, but don't
// want it actually created, as bake relies on atomicwriter and this creates
// conflict during rename.
func bakeMetadataPath() (string, error) {
tmpdir := os.TempDir()
for {
metadataFile := filepath.Join(tmpdir, fmt.Sprintf("compose-build-metadataFile-%s.json", uuid.New().String()))
_, err := os.Stat(metadataFile)
if os.IsNotExist(err) {
return metadataFile, nil
}
var pathError *fs.PathError
if errors.As(err, &pathError) {
return "", fmt.Errorf("can't access os.tempDir %s: %w", tmpdir, pathError.Err)
}
}
}
// bakeArgs assembles the buildx bake command line.
func bakeArgs(bake *bakeBuild, metadataFile string, options api.BuildOptions) []string {
args := []string{"bake", "--file", "-", "--progress", "rawjson", "--metadata-file", metadataFile}
// FIXME we should prompt user about this, but this is a breaking change in UX
for _, path := range bake.localPaths {
args = append(args, "--allow", "fs.read="+path)
}
if bake.privileged {
args = append(args, "--allow", "security.insecure")
}
if options.SBOM != "" {
args = append(args, "--sbom="+options.SBOM)
}
if options.Provenance != "" {
args = append(args, "--provenance="+options.Provenance)
}
if options.Builder != "" {
args = append(args, "--builder", options.Builder)
}
if options.Quiet {
args = append(args, "--progress=quiet")
}
return args
}
// forwardBakeStatus reads bake's rawjson stderr stream, forwarding solve
// statuses to the progress UI channel. Lines that are not solve statuses are
// collected as error messages, to be reported if bake exits non-zero.
func forwardBakeStatus(pipe io.Reader, ch chan<- *client.SolveStatus) ([]string, error) {
var errMessage []string
reader := bufio.NewReader(pipe)
for {
line, readErr := reader.ReadString('\n')
if readErr == io.EOF {
return errMessage, nil
}
if errors.Is(readErr, os.ErrClosed) {
logrus.Debugf("bake stopped")
return errMessage, nil
}
if readErr != nil {
return nil, fmt.Errorf("failed to execute bake: %w", readErr)
}
decoder := json.NewDecoder(strings.NewReader(line))
var status client.SolveStatus
if err := decoder.Decode(&status); err != nil {
errMessage = append(errMessage, strings.TrimPrefix(line, "ERROR: "))
continue
}
ch <- &status
}
}
// collectBakeResults reads bake's metadata file and maps each built image to
// its canonical digest.
func (s *composeService) collectBakeResults(ctx context.Context, metadataFile string, serviceToBeBuild types.Services, bake *bakeBuild) (map[string]string, error) {
raw, err := os.ReadFile(metadataFile)
if err != nil {
return nil, err
}
var md bakeMetadata
err = json.Unmarshal(raw, &md)
if err != nil {
return nil, err
}
// Bake reports the top-level attested image/index digest, which changes on
// every build when provenance attestations are enabled — even for a fully
// cached build (see https://github.com/docker/compose/issues/13636). For
// images loaded into the local engine (the common non-push case), resolve
// the canonical content digest — with the service's pinned platform when
// set — so unchanged rebuilds don't recreate containers.
results := map[string]string{}
for name, service := range serviceToBeBuild {
image := bake.expectedImages[name]
built, ok := md[bake.targetNames[name]]
if !ok {
return nil, fmt.Errorf("build result not found in Bake metadata for service %s", name)
}
results[image] = s.canonicalBuiltDigest(ctx, image, service.Platform, built.Digest)
s.events.On(builtEvent(image))
}
return results, nil
}
func (s *composeService) getBuildxPlugin() (*manager.Plugin, error) {
buildx, err := manager.GetPlugin("buildx", s.dockerCli, &cobra.Command{})
if err != nil {
return nil, err
}
if buildx.Err != nil {
return nil, buildx.Err
}
if buildx.Version == "" {
return nil, fmt.Errorf("failed to get version of buildx")
}
if versions.LessThan(buildx.Version[1:], buildxMinVersion) {
return nil, fmt.Errorf("compose build requires buildx %s or later", buildxMinVersion)
}
return buildx, nil
}
// makeConsole wraps the provided writer to match [containerd.File] interface if it is of type *streams.Out.
// buildkit's NewDisplay doesn't actually require a [io.Reader], it only uses the [containerd.Console] type to
// benefits from ANSI capabilities, but only does writes.
func makeConsole(out io.Writer) io.Writer {
if s, ok := out.(*streams.Out); ok {
return &_console{s}
}
return out
}
var _ console.File = &_console{}
type _console struct {
*streams.Out
}
func (c _console) Read([]byte) (n int, err error) {
return 0, errors.New("not implemented")
}
func (c _console) Close() error {
return nil
}
func (c _console) Fd() uintptr {
return c.FD()
}
func (c _console) Name() string {
return "compose"
}
func toBakeExtraHosts(hosts types.HostsList) map[string]string {
m := make(map[string]string)
for k, v := range hosts {
m[k] = strings.Join(v, ",")
}
return m
}
func additionalContexts(contexts types.Mapping, targets map[string]string) map[string]string {
ac := map[string]string{}
for k, v := range contexts {
if target, found := strings.CutPrefix(v, types.ServicePrefix); found {
v = "target:" + targets[target]
}
ac[k] = v
}
return ac
}
func toBakeUlimits(ulimits map[string]*types.UlimitsConfig) []string {
s := []string{}
for u, l := range ulimits {
if l.Single > 0 {
s = append(s, fmt.Sprintf("%s=%d", u, l.Single))
} else {
s = append(s, fmt.Sprintf("%s=%d:%d", u, l.Soft, l.Hard))
}
}
return s
}
func toBakeSSH(ssh types.SSHConfig) []string {
var s []string
for _, key := range ssh {
s = append(s, fmt.Sprintf("%s=%s", key.ID, key.Path))
}
return s
}
func toBakeSecrets(project *types.Project, secrets []types.ServiceSecretConfig) ([]string, []string) {
var s []string
var env []string
for _, ref := range secrets {
def := project.Secrets[ref.Source]
target := ref.Target
if target == "" {
target = ref.Source
}
switch {
case def.Environment != "":
env = append(env, fmt.Sprintf("%s=%s", def.Environment, project.Environment[def.Environment]))
s = append(s, fmt.Sprintf("id=%s,type=env,env=%s", target, def.Environment))
case def.File != "":
s = append(s, fmt.Sprintf("id=%s,type=file,src=%s", target, def.File))
}
}
return s, env
}
func toBakeAttest(buildConfig types.BuildConfig) []string {
var attests []string
// Handle per-service provenance configuration (only from build config, not global options)
if buildConfig.Provenance != "" {
if buildConfig.Provenance == "true" {
attests = append(attests, "type=provenance")
} else if buildConfig.Provenance != "false" {
attests = append(attests, fmt.Sprintf("type=provenance,%s", buildConfig.Provenance))
}
}
// Handle per-service SBOM configuration (only from build config, not global options)
if buildConfig.SBOM != "" {
if buildConfig.SBOM == "true" {
attests = append(attests, "type=sbom")
} else if buildConfig.SBOM != "false" {
attests = append(attests, fmt.Sprintf("type=sbom,%s", buildConfig.SBOM))
}
}
return attests
}
func dockerFilePath(ctxName string, dockerfile string) string {
if dockerfile == "" {
return ""
}
contextType, _ := build.DetectContextType(ctxName)
if contextType == build.ContextTypeGit || contextType == build.ContextTypeRemote {
return dockerfile
}
if strings.Contains(ctxName, "://") {
return dockerfile
}
if !filepath.IsAbs(dockerfile) {
dockerfile = filepath.Join(ctxName, dockerfile)
}
dir := filepath.Dir(dockerfile)
symlinks, err := filepath.EvalSymlinks(dir)
if err == nil {
return filepath.Join(symlinks, filepath.Base(dockerfile))
}
return dockerfile
}
func (s *composeService) dryRunBake(cfg bakeConfig) map[string]string {
bakeResponse := map[string]string{}
for name, target := range cfg.Targets {
dryRunUUID := fmt.Sprintf("dryRun-%x", sha1.Sum([]byte(name)))
s.events.On(api.Resource{
ID: name + " ==>",
Status: api.Done,
Text: fmt.Sprintf("==> writing image %s", dryRunUUID),
})
s.events.On(api.Resource{
ID: name + " ==> ==>",
Status: api.Done,
Text: fmt.Sprintf(`naming to %s`, target.Tags[0]),
})
bakeResponse[name] = dryRunUUID
}
for name := range bakeResponse {
s.events.On(builtEvent(name))
}
return bakeResponse
}