| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574 |
- /*
- 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/docker/docker/api/types/versions"
- "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/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"`
- }
- func (s *composeService) doBuildBake(ctx context.Context, project *types.Project, serviceToBeBuild types.Services, options api.BuildOptions) (map[string]string, error) { //nolint:gocyclo
- eg := errgroup.Group{}
- ch := make(chan *client.SolveStatus)
- displayMode := progressui.DisplayMode(options.Progress)
- if displayMode == progressui.AutoMode {
- options.Progress = os.Getenv("BUILDKIT_PROGRESS")
- }
- 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
- })
- cfg := bakeConfig{
- Groups: map[string]bakeGroup{},
- Targets: map[string]bakeTarget{},
- }
- var (
- group bakeGroup
- privileged bool
- read []string
- expectedImages = make(map[string]string, len(serviceToBeBuild)) // service name -> expected image
- targets = make(map[string]string, len(serviceToBeBuild)) // service name -> build target
- )
- // produce a unique ID for service used as bake target
- for serviceName := range project.Services {
- t := strings.ReplaceAll(serviceName, ".", "_")
- for {
- if _, ok := targets[serviceName]; !ok {
- targets[serviceName] = t
- break
- }
- t += "_"
- }
- }
- var secretsEnv []string
- for serviceName, service := range project.Services {
- if service.Build == nil {
- continue
- }
- buildConfig := *service.Build
- labels := getImageBuildLabels(project, service)
- args := resolveAndMergeBuildArgs(s.getProxyConfig(), project, service, options).ToMapping()
- for k, v := range args {
- args[k] = strings.ReplaceAll(v, "${", "$${")
- }
- entitlements := buildConfig.Entitlements
- if slices.Contains(buildConfig.Entitlements, "security.insecure") {
- privileged = true
- }
- if buildConfig.Privileged {
- entitlements = append(entitlements, "security.insecure")
- privileged = true
- }
- var outputs []string
- var call string
- push := options.Push && service.Image != ""
- switch {
- case options.Check:
- call = "lint"
- case len(service.Build.Platforms) > 1:
- outputs = []string{fmt.Sprintf("type=image,push=%t", push)}
- default:
- if push {
- outputs = []string{"type=registry"}
- } else {
- outputs = []string{"type=docker"}
- }
- }
- read = append(read, buildConfig.Context)
- for _, path := range buildConfig.AdditionalContexts {
- _, _, err := gitutil.ParseGitRef(path)
- if !strings.Contains(path, "://") && err != nil {
- read = append(read, path)
- }
- }
- image := api.GetImageNameOrDefault(service, project.Name)
- s.events.On(buildingEvent(image))
- expectedImages[serviceName] = image
- pull := service.Build.Pull || options.Pull
- noCache := service.Build.NoCache || options.NoCache
- target := targets[serviceName]
- secrets, env := toBakeSecrets(project, buildConfig.Secrets)
- secretsEnv = append(secretsEnv, env...)
- cfg.Targets[target] = bakeTarget{
- Context: buildConfig.Context,
- Contexts: additionalContexts(buildConfig.AdditionalContexts, targets),
- Dockerfile: dockerFilePath(buildConfig.Context, buildConfig.Dockerfile),
- DockerfileInline: strings.ReplaceAll(buildConfig.DockerfileInline, "${", "$${"),
- Args: args,
- Labels: labels,
- 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: pull,
- NoCache: 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
- for serviceName, service := range serviceToBeBuild {
- if service.Build == nil {
- continue
- }
- group.Targets = append(group.Targets, targets[serviceName])
- }
- cfg.Groups["default"] = group
- b, err := json.MarshalIndent(cfg, "", " ")
- if err != nil {
- return nil, err
- }
- if options.Print {
- _, err = fmt.Fprintln(s.stdout(), string(b))
- return nil, err
- }
- logrus.Debugf("bake build config:\n%s", string(b))
- tmpdir := os.TempDir()
- var metadataFile string
- for {
- // 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
- metadataFile = filepath.Join(tmpdir, fmt.Sprintf("compose-build-metadataFile-%s.json", uuid.New().String()))
- if _, err = os.Stat(metadataFile); err != nil {
- if os.IsNotExist(err) {
- break
- }
- var pathError *fs.PathError
- if errors.As(err, &pathError) {
- return nil, fmt.Errorf("can't access os.tempDir %s: %w", tmpdir, pathError.Err)
- }
- }
- }
- defer func() {
- _ = os.Remove(metadataFile)
- }()
- buildx, err := manager.GetPlugin("buildx", s.dockerCli, &cobra.Command{})
- if err != nil {
- return nil, err
- }
- if versions.LessThan(buildx.Version[1:], "0.17.0") {
- return nil, fmt.Errorf("compose build requires buildx 0.17 or later")
- }
- 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 read {
- args = append(args, "--allow", "fs.read="+path)
- }
- if 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")
- }
- logrus.Debugf("Executing bake with args: %v", args)
- if s.dryRun {
- return s.dryRunBake(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
- }
- cmd.Env = append(cmd.Env, endpoint...)
- cmd.Env = append(cmd.Env, secretsEnv...)
- defer cleanup()
- cmd.Stdout = s.stdout()
- cmd.Stdin = bytes.NewBuffer(b)
- pipe, err := cmd.StderrPipe()
- if err != nil {
- return nil, err
- }
- var errMessage []string
- reader := bufio.NewReader(pipe)
- err = cmd.Start()
- if err != nil {
- return nil, err
- }
- eg.Go(cmd.Wait)
- for {
- line, readErr := reader.ReadString('\n')
- if readErr != nil {
- if readErr == io.EOF {
- break
- }
- if errors.Is(readErr, os.ErrClosed) {
- logrus.Debugf("bake stopped")
- break
- }
- return nil, fmt.Errorf("failed to execute bake: %w", readErr)
- }
- decoder := json.NewDecoder(strings.NewReader(line))
- var status client.SolveStatus
- err := decoder.Decode(&status)
- if err != nil {
- if strings.HasPrefix(line, "ERROR: ") {
- errMessage = append(errMessage, line[7:])
- } else {
- errMessage = append(errMessage, line)
- }
- continue
- }
- ch <- &status
- }
- 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)
- }
- b, err = os.ReadFile(metadataFile)
- if err != nil {
- return nil, err
- }
- var md bakeMetadata
- err = json.Unmarshal(b, &md)
- if err != nil {
- return nil, err
- }
- results := map[string]string{}
- for name := range serviceToBeBuild {
- image := expectedImages[name]
- target := targets[name]
- built, ok := md[target]
- if !ok {
- return nil, fmt.Errorf("build result not found in Bake metadata for service %s", name)
- }
- results[image] = built.Digest
- s.events.On(builtEvent(image))
- }
- return results, 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(p []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 ""
- }
- if contextType, _ := build.DetectContextType(ctxName); contextType == build.ContextTypeGit {
- 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.displayDryRunBuildEvent(name, dryRunUUID, target.Tags[0])
- bakeResponse[name] = dryRunUUID
- }
- for name := range bakeResponse {
- s.events.On(builtEvent(name))
- }
- return bakeResponse
- }
- func (s composeService) displayDryRunBuildEvent(name, dryRunUUID, tag string) {
- 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`, tag),
- })
- }
|