From 095f65cb42a626c68228787cbf1096de3effaa4d Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Fri, 9 Feb 2024 11:40:29 +0100 Subject: [PATCH] delegate build to buildx bake Signed-off-by: Nicolas De Loof --- go.mod | 2 +- go.sum | 5 +- pkg/compose/build.go | 56 +++---- pkg/compose/build_bake.go | 305 ++++++++++++++++++++++++++++++++++++++ pkg/compose/watch_test.go | 1 - 5 files changed, 329 insertions(+), 40 deletions(-) create mode 100644 pkg/compose/build_bake.go diff --git a/go.mod b/go.mod index 72d05e010..750a7d2b4 100644 --- a/go.mod +++ b/go.mod @@ -136,7 +136,7 @@ require ( github.com/moby/sys/mountinfo v0.7.2 // indirect github.com/moby/sys/sequential v0.6.0 // indirect github.com/moby/sys/signal v0.7.1 // indirect - github.com/moby/sys/symlink v0.3.0 // indirect + github.com/moby/sys/symlink v0.2.0 // indirect github.com/moby/sys/user v0.3.0 // indirect github.com/moby/sys/userns v0.1.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect diff --git a/go.sum b/go.sum index 862ee708d..1b898b28e 100644 --- a/go.sum +++ b/go.sum @@ -337,8 +337,8 @@ github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7z github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= github.com/moby/sys/signal v0.7.1 h1:PrQxdvxcGijdo6UXXo/lU/TvHUWyPhj7UOpSo8tuvk0= github.com/moby/sys/signal v0.7.1/go.mod h1:Se1VGehYokAkrSQwL4tDzHvETwUZlnY7S5XtQ50mQp8= -github.com/moby/sys/symlink v0.3.0 h1:GZX89mEZ9u53f97npBy4Rc3vJKj7JBDj/PN2I22GrNU= -github.com/moby/sys/symlink v0.3.0/go.mod h1:3eNdhduHmYPcgsJtZXW1W4XUJdZGBIkttZ8xKqPUJq0= +github.com/moby/sys/symlink v0.2.0 h1:tk1rOM+Ljp0nFmfOIBtlV3rTDlWOwFRhjEeAhZB0nZc= +github.com/moby/sys/symlink v0.2.0/go.mod h1:7uZVF2dqJjG/NsClqul95CqKOBRQyYSNnJ6BMgR/gFs= github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo= github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= @@ -582,6 +582,7 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210331175145-43e1dd70ce54/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/pkg/compose/build.go b/pkg/compose/build.go index fafeb9c2f..4390f5c54 100644 --- a/pkg/compose/build.go +++ b/pkg/compose/build.go @@ -21,7 +21,6 @@ import ( "errors" "fmt" "os" - "path/filepath" "github.com/compose-spec/compose-go/v2/types" "github.com/containerd/platforms" @@ -38,7 +37,6 @@ import ( "github.com/docker/compose/v2/pkg/progress" "github.com/docker/compose/v2/pkg/utils" "github.com/docker/docker/api/types/container" - "github.com/docker/docker/builder/remotecontext/urlutil" bclient "github.com/moby/buildkit/client" "github.com/moby/buildkit/session" "github.com/moby/buildkit/session/auth/authprovider" @@ -64,26 +62,16 @@ func (s *composeService) Build(ctx context.Context, project *types.Project, opti }, s.stdinfo(), "Building") } -type serviceToBuild struct { - name string - service types.ServiceConfig -} - //nolint:gocyclo func (s *composeService) build(ctx context.Context, project *types.Project, options api.BuildOptions, localImages map[string]string) (map[string]string, error) { - buildkitEnabled, err := s.dockerCli.BuildKitEnabled() - if err != nil { - return nil, err - } - imageIDs := map[string]string{} - serviceToBeBuild := map[string]serviceToBuild{} + serviceToBuild := types.Services{} var policy types.DependencyOption = types.IgnoreDependencies if options.Deps { policy = types.IncludeDependencies } - err = project.ForEachService(options.Services, func(serviceName string, service *types.ServiceConfig) error { + err := project.ForEachService(options.Services, func(serviceName string, service *types.ServiceConfig) error { if service.Build == nil { return nil } @@ -92,14 +80,26 @@ func (s *composeService) build(ctx context.Context, project *types.Project, opti if localImagePresent && service.PullPolicy != types.PullPolicyBuild { return nil } - serviceToBeBuild[serviceName] = serviceToBuild{name: serviceName, service: *service} + serviceToBuild[serviceName] = *service return nil }, policy) - if err != nil || len(serviceToBeBuild) == 0 { + if err != nil || len(serviceToBuild) == 0 { return imageIDs, err } + bake, err := buildWithBake(s.dockerCli) + if err != nil { + return nil, err + } + if bake { + return s.doBuildBake(ctx, project, serviceToBuild, options) + } + // Initialize buildkit nodes + buildkitEnabled, err := s.dockerCli.BuildKitEnabled() + if err != nil { + return nil, err + } var ( b *builder.Builder nodes []builder.Node @@ -152,12 +152,10 @@ func (s *composeService) build(ctx context.Context, project *types.Project, opti return -1 } err = InDependencyOrder(ctx, project, func(ctx context.Context, name string) error { - serviceToBuild, ok := serviceToBeBuild[name] + service, ok := serviceToBuild[name] if !ok { return nil } - service := serviceToBuild.service - cw := progress.ContextWriter(ctx) serviceName := fmt.Sprintf("Service %s", name) @@ -211,7 +209,8 @@ func (s *composeService) build(ctx context.Context, project *types.Project, opti for i, imageDigest := range builtDigests { if imageDigest != "" { - imageRef := api.GetImageNameOrDefault(project.Services[names[i]], project.Name) + service := project.Services[names[i]] + imageRef := api.GetImageNameOrDefault(service, project.Name) imageIDs[imageRef] = imageDigest } } @@ -334,12 +333,7 @@ func (s *composeService) getLocalImagesDigests(ctx context.Context, project *typ // // Finally, standard proxy variables based on the Docker client configuration are added, but will not overwrite // any values if already present. -func resolveAndMergeBuildArgs( - dockerCli command.Cli, - project *types.Project, - service types.ServiceConfig, - opts api.BuildOptions, -) types.MappingWithEquals { +func resolveAndMergeBuildArgs(dockerCli command.Cli, project *types.Project, service types.ServiceConfig, opts api.BuildOptions) types.MappingWithEquals { result := make(types.MappingWithEquals). OverrideBy(service.Build.Args). OverrideBy(opts.Args). @@ -479,16 +473,6 @@ func flatten(in types.MappingWithEquals) types.Mapping { return out } -func dockerFilePath(ctxName string, dockerfile string) string { - if dockerfile == "" { - return "" - } - if urlutil.IsGitURL(ctxName) || filepath.IsAbs(dockerfile) { - return dockerfile - } - return filepath.Join(ctxName, dockerfile) -} - func sshAgentProvider(sshKeys types.SSHConfig) (session.Attachable, error) { sshConfig := make([]sshprovider.AgentConfig, 0, len(sshKeys)) for _, sshKey := range sshKeys { diff --git a/pkg/compose/build_bake.go b/pkg/compose/build_bake.go new file mode 100644 index 000000000..c1211b062 --- /dev/null +++ b/pkg/compose/build_bake.go @@ -0,0 +1,305 @@ +/* + 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 ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + + "github.com/compose-spec/compose-go/v2/types" + "github.com/docker/cli/cli-plugins/manager" + "github.com/docker/cli/cli-plugins/socket" + "github.com/docker/cli/cli/command" + "github.com/docker/compose/v2/pkg/api" + "github.com/docker/compose/v2/pkg/progress" + "github.com/docker/docker/builder/remotecontext/urlutil" + "github.com/moby/buildkit/client" + "github.com/moby/buildkit/util/progress/progressui" + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/propagation" + "golang.org/x/sync/errgroup" +) + +func buildWithBake(dockerCli command.Cli) (bool, error) { + b, ok := os.LookupEnv("COMPOSE_BAKE") + if !ok { + if dockerCli.ConfigFile().Plugins["compose"]["build"] == "bake" { + b, ok = "true", true + } + } + if !ok { + return false, nil + } + bake, err := strconv.ParseBool(b) + if err != nil { + return false, err + } + if !bake { + return false, nil + } + + enabled, err := dockerCli.BuildKitEnabled() + if err != nil { + return false, err + } + if !enabled { + logrus.Warnf("Docker Compose is configured to build using Bake, but buildkit isn't enabled") + } + + _, err = manager.GetPlugin("buildx", dockerCli, &cobra.Command{}) + if err != nil { + if manager.IsNotFound(err) { + logrus.Warnf("Docker Compose is configured to build using Bake, but buildx isn't 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"` + Dockerfile string `json:"dockerfile,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"` + Secrets []string `json:"secret,omitempty"` + SSH []string `json:"ssh,omitempty"` + Platforms []string `json:"platforms,omitempty"` + Target string `json:"target,omitempty"` + Pull bool `json:"pull,omitempty"` + NoCache bool `json:"no-cache,omitempty"` +} + +type bakeMetadata map[string]buildStatus + +type buildStatus struct { + Digest string `json:"containerimage.digest"` +} + +func (s *composeService) doBuildBake(ctx context.Context, project *types.Project, serviceToBeBuild types.Services, options api.BuildOptions) (map[string]string, error) { //nolint:gocyclo + cw := progress.ContextWriter(ctx) + for name := range serviceToBeBuild { + cw.Event(progress.BuildingEvent(name)) + } + + eg := errgroup.Group{} + ch := make(chan *client.SolveStatus) + display, err := progressui.NewDisplay(os.Stdout, progressui.DisplayMode(options.Progress)) + 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 + + for name, service := range serviceToBeBuild { + if service.Build == nil { + continue + } + build := *service.Build + + args := types.Mapping{} + for k, v := range resolveAndMergeBuildArgs(s.dockerCli, project, service, options) { + if v == nil { + continue + } + args[k] = *v + } + + cfg.Targets[name] = bakeTarget{ + Context: build.Context, + Dockerfile: dockerFilePath(build.Context, build.Dockerfile), + Args: args, + Labels: build.Labels, + Tags: build.Tags, + + CacheFrom: build.CacheFrom, + // CacheTo: TODO + Platforms: build.Platforms, + Target: build.Target, + Secrets: toBakeSecrets(project, build.Secrets), + SSH: toBakeSSH(build.SSH), + Pull: options.Pull, + NoCache: options.NoCache, + } + group.Targets = append(group.Targets, name) + } + + cfg.Groups["default"] = group + + b, err := json.Marshal(cfg) + if err != nil { + return nil, err + } + + metadata, err := os.CreateTemp(os.TempDir(), "compose") + if err != nil { + return nil, err + } + + buildx, err := manager.GetPlugin("buildx", s.dockerCli, &cobra.Command{}) + if err != nil { + return nil, err + } + cmd := exec.CommandContext(ctx, buildx.Path, "bake", "--file", "-", "--progress", "rawjson", "--metadata-file", metadata.Name()) + // Remove DOCKER_CLI_PLUGIN... variable so buildx can detect it run standalone + cmd.Env = filter(os.Environ(), manager.ReexecEnvvar) + + // Use docker/cli mechanism to propagate termination signal to child process + server, err := socket.NewPluginServer(nil) + if err != nil { + defer server.Close() //nolint:errcheck + cmd.Cancel = server.Close + cmd.Env = replace(cmd.Env, socket.EnvKey, server.Addr().String()) + } + + cmd.Env = append(cmd.Env, fmt.Sprintf("DOCKER_CONTEXT=%s", s.dockerCli.CurrentContext())) + + // propagate opentelemetry context to child process, see https://github.com/open-telemetry/oteps/blob/main/text/0258-env-context-baggage-carriers.md + carrier := propagation.MapCarrier{} + otel.GetTextMapPropagator().Inject(ctx, &carrier) + cmd.Env = append(cmd.Env, types.Mapping(carrier).Values()...) + + cmd.Stdout = s.stdout() + cmd.Stdin = bytes.NewBuffer(b) + pipe, err := cmd.StderrPipe() + if err != nil { + return nil, err + } + + err = cmd.Start() + if err != nil { + return nil, err + } + eg.Go(cmd.Wait) + for { + decoder := json.NewDecoder(pipe) + var s client.SolveStatus + err := decoder.Decode(&s) + if err != nil { + if errors.Is(err, io.EOF) { + break + } + // bake displays build details at the end of a build, which isn't a json SolveStatus + continue + } + ch <- &s + } + close(ch) // stop build progress UI + + err = eg.Wait() + if err != nil { + return nil, err + } + + b, err = os.ReadFile(metadata.Name()) + 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, m := range md { + results[name] = m.Digest + cw.Event(progress.BuiltEvent(name)) + } + return results, nil +} + +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 { + var s []string + for _, ref := range secrets { + def := project.Secrets[ref.Source] + switch { + case def.Environment != "": + s = append(s, fmt.Sprintf("id=%s,type=env,env=%s", ref.Source, def.Environment)) + case def.File != "": + s = append(s, fmt.Sprintf("id=%s,type=file,src=%s", ref.Source, def.File)) + } + } + return s +} + +func filter(environ []string, variable string) []string { + prefix := variable + "=" + filtered := make([]string, 0, len(environ)) + for _, val := range environ { + if !strings.HasPrefix(val, prefix) { + filtered = append(filtered, val) + } + } + return filtered +} + +func replace(environ []string, variable, value string) []string { + filtered := filter(environ, variable) + return append(filtered, fmt.Sprintf("%s=%s", variable, value)) +} + +func dockerFilePath(ctxName string, dockerfile string) string { + if dockerfile == "" { + return "" + } + if urlutil.IsGitURL(ctxName) || filepath.IsAbs(dockerfile) { + return dockerfile + } + return filepath.Join(ctxName, dockerfile) +} diff --git a/pkg/compose/watch_test.go b/pkg/compose/watch_test.go index 40a303998..31fd6545d 100644 --- a/pkg/compose/watch_test.go +++ b/pkg/compose/watch_test.go @@ -117,7 +117,6 @@ func TestWatch_Sync(t *testing.T) { mockCtrl := gomock.NewController(t) cli := mocks.NewMockCli(mockCtrl) cli.EXPECT().Err().Return(streams.NewOut(os.Stderr)).AnyTimes() - cli.EXPECT().BuildKitEnabled().Return(true, nil) apiClient := mocks.NewMockAPIClient(mockCtrl) apiClient.EXPECT().ContainerList(gomock.Any(), gomock.Any()).Return([]moby.Container{ testContainer("test", "123", false),