diff --git a/cmd/compose/tracing.go b/cmd/compose/tracing.go new file mode 100644 index 00000000..99ff58d8 --- /dev/null +++ b/cmd/compose/tracing.go @@ -0,0 +1,35 @@ +/* + 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 ( + "github.com/moby/buildkit/util/tracing/detect" + "go.opentelemetry.io/otel" + + _ "github.com/moby/buildkit/util/tracing/detect/delegated" //nolint:blank-imports + _ "github.com/moby/buildkit/util/tracing/env" //nolint:blank-imports +) + +func init() { + detect.ServiceName = "compose" + // do not log tracing errors to stdio + otel.SetErrorHandler(skipErrors{}) +} + +type skipErrors struct{} + +func (skipErrors) Handle(err error) {} diff --git a/go.mod b/go.mod index bc8c3daf..a61fb926 100644 --- a/go.mod +++ b/go.mod @@ -101,7 +101,7 @@ require ( go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.29.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.29.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.29.0 // indirect - go.opentelemetry.io/otel v1.4.1 // indirect + go.opentelemetry.io/otel v1.4.1 go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.4.1 // indirect go.opentelemetry.io/otel/internal/metric v0.27.0 // indirect go.opentelemetry.io/otel/metric v0.27.0 // indirect @@ -122,7 +122,7 @@ require ( gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/apimachinery v0.24.1 // indirect; see replace for the actual version used - k8s.io/client-go v0.24.1 // indirect; see replace for the actual version used + k8s.io/client-go v0.24.1 // see replace for the actual version used k8s.io/klog/v2 v2.60.1 // indirect k8s.io/utils v0.0.0-20220210201930-3a6ce19ff2f9 // indirect sigs.k8s.io/structured-merge-diff/v4 v4.2.1 // indirect @@ -130,9 +130,17 @@ require ( ) require ( + github.com/cenkalti/backoff/v4 v4.1.2 // indirect github.com/cyberphone/json-canonicalization v0.0.0-20210303052042-6bc126869bf4 // indirect + github.com/googleapis/gnostic v0.5.5 // indirect + github.com/moby/spdystream v0.2.0 // indirect + github.com/serialx/hashring v0.0.0-20190422032157-8b2912629002 // indirect github.com/zmap/zcrypto v0.0.0-20220605182715-4dfcec6e9a8c // indirect github.com/zmap/zlint v1.1.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/internal/retry v1.4.1 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.4.1 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.4.1 // indirect + k8s.io/api v0.24.1 // indirect ) replace ( diff --git a/go.sum b/go.sum index 016dddc2..d6416883 100644 --- a/go.sum +++ b/go.sum @@ -246,6 +246,7 @@ github.com/campoy/unique v0.0.0-20180121183637-88950e537e7e/go.mod h1:9IOqJGCPMS github.com/cavaliercoder/go-cpio v0.0.0-20180626203310-925f9528c45e/go.mod h1:oDpT4efm8tSYHXV5tHSdRvBet/b/QzxZ+XyyPehvm3A= github.com/cenkalti/backoff v2.1.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= github.com/cenkalti/backoff/v4 v4.1.1/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= +github.com/cenkalti/backoff/v4 v4.1.2 h1:6Yo7N8UP2K6LWZnW94DLVSSrbobcWdVzAYOisuDPIFo= github.com/cenkalti/backoff/v4 v4.1.2/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= github.com/census-instrumentation/opencensus-proto v0.2.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= @@ -498,6 +499,7 @@ github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5m github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU= github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= github.com/elazarl/goproxy v0.0.0-20180725130230-947c36da3153/go.mod h1:/Zj4wYkgs4iZTTu3o/KG3Itv/qCCa8VVMlb3i9OVuzc= +github.com/elazarl/goproxy v0.0.0-20191011121108-aa519ddbe484 h1:pEtiCjIXx3RvGjlUJuCNxNOw0MNblyR9Wi+vJGBFh+8= github.com/elazarl/goproxy v0.0.0-20191011121108-aa519ddbe484/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM= github.com/elazarl/goproxy/ext v0.0.0-20190711103511-473e67f1d7d2/go.mod h1:gNh8nYJoAm43RfaxurUnxr+N1PwuFV3ZMl/efxlIlY8= github.com/emicklei/go-restful v0.0.0-20170410110728-ff4f55a20633/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs= @@ -761,6 +763,7 @@ github.com/googleapis/gnostic v0.0.0-20170729233727-0c5108395e2d/go.mod h1:sJBsC github.com/googleapis/gnostic v0.2.2/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY= github.com/googleapis/gnostic v0.4.1/go.mod h1:LRhVm6pbyptWbWbuZ38d1eyptfvIytN3ir6b65WBswg= github.com/googleapis/gnostic v0.5.1/go.mod h1:6U4PtQXGIEt/Z3h5MAT7FNofLnw9vXk2cUuW7uA/OeU= +github.com/googleapis/gnostic v0.5.5 h1:9fHAtK0uDfpveeqqo1hkEZJcFvYXAiCN3UutL8F9xHw= github.com/googleapis/gnostic v0.5.5/go.mod h1:7+EbHbldMins07ALC74bsA81Ovc97DwqyJO1AENw9kA= github.com/gookit/color v1.2.4/go.mod h1:AhIE+pS6D4Ql0SQWbBeXPHw7gY0/sjHoA4s/n1KB7xg= github.com/gophercloud/gophercloud v0.1.0/go.mod h1:vxM41WHh5uqHVBMZHzuwNOHh8XEoIEcSTewFxm1c5g8= @@ -1022,6 +1025,7 @@ github.com/moby/buildkit v0.10.4 h1:FvC+buO8isGpUFZ1abdSLdGHZVqg9sqI4BbFL8tlzP4= github.com/moby/buildkit v0.10.4/go.mod h1:Yajz9vt1Zw5q9Pp4pdb3TCSUXJBIroIQGQ3TTs/sLug= github.com/moby/locker v1.0.1 h1:fOXqR41zeveg4fFODix+1Ch4mj/gT0NE1XJbp/epuBg= github.com/moby/locker v1.0.1/go.mod h1:S7SDdo5zpBK84bzzVlKr2V0hz+7x9hWbYC/kq7oQppc= +github.com/moby/spdystream v0.2.0 h1:cjW1zVyyoiM0T7b6UoySUFqzXMoqRckQtXwGPiBhOM8= github.com/moby/spdystream v0.2.0/go.mod h1:f7i0iNDQJ059oMTcWxx8MA/zKFIuD/lY+0GqbN2Wy8c= github.com/moby/sys/mount v0.1.0/go.mod h1:FVQFLDRWwyBjDTBNQXDlWnSFREqOo3OKX9aqhmeoo74= github.com/moby/sys/mount v0.1.1/go.mod h1:FVQFLDRWwyBjDTBNQXDlWnSFREqOo3OKX9aqhmeoo74= @@ -1235,6 +1239,7 @@ github.com/securego/gosec v0.0.0-20200401082031-e946c8c39989/go.mod h1:i9l/TNj+y github.com/securego/gosec/v2 v2.3.0/go.mod h1:UzeVyUXbxukhLeHKV3VVqo7HdoQR9MrRfFmZYotn8ME= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= +github.com/serialx/hashring v0.0.0-20190422032157-8b2912629002 h1:ka9QPuQg2u4LGipiZGsgkg3rJCo4iIUCy75FddM0GRQ= github.com/serialx/hashring v0.0.0-20190422032157-8b2912629002/go.mod h1:/yeG0My1xr/u+HZrFQ1tOQQQQrOawfyMUH13ai5brBc= github.com/shirou/gopsutil v0.0.0-20190901111213-e4ec7b275ada/go.mod h1:WWnYX4lzhCH5h/3YBfyVA3VbLYjlMZZAQcW9ojMexNc= github.com/shirou/w32 v0.0.0-20160930032740-bb4de0191aa4/go.mod h1:qsXQc7+bwAM3Q1u/4XEfrquwF8Lw7D7y5cD8CuHnfIc= @@ -1465,13 +1470,16 @@ go.opentelemetry.io/otel v1.4.1/go.mod h1:StM6F/0fSwpd8dKWDCdRr7uRvEPYdW0hBSlbdT go.opentelemetry.io/otel/exporters/jaeger v1.4.1/go.mod h1:ZW7vkOu9nC1CxsD8bHNHCia5JUbwP39vxgd1q4Z5rCI= go.opentelemetry.io/otel/exporters/otlp v0.20.0/go.mod h1:YIieizyaN77rtLJra0buKiNBOm9XQfkPEKBeuhoMwAM= go.opentelemetry.io/otel/exporters/otlp/internal/retry v1.3.0/go.mod h1:VpP4/RMn8bv8gNo9uK7/IMY4mtWLELsS+JIP0inH0h4= +go.opentelemetry.io/otel/exporters/otlp/internal/retry v1.4.1 h1:imIM3vRDMyZK1ypQlQlO+brE22I9lRhJsBDXpDWjlz8= go.opentelemetry.io/otel/exporters/otlp/internal/retry v1.4.1/go.mod h1:VpP4/RMn8bv8gNo9uK7/IMY4mtWLELsS+JIP0inH0h4= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.3.0/go.mod h1:hO1KLR7jcKaDDKDkvI9dP/FIhpmna5lkqPUQdEjFAM8= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.4.1 h1:WPpPsAAs8I2rA47v5u0558meKmmwm1Dj99ZbqCV8sZ8= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.4.1/go.mod h1:o5RW5o2pKpJLD5dNTCmjF1DorYwMeFJmb/rKr5sLaa8= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.3.0/go.mod h1:keUU7UfnwWTWpJ+FWnyqmogPa82nuU5VUANFq49hlMY= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.4.1 h1:AxqDiGk8CorEXStMDZF5Hz9vo9Z7ZZ+I5m8JRl/ko40= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.4.1/go.mod h1:c6E4V3/U+miqjs/8l950wggHGL1qzlp0Ypj9xoGrPqo= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.3.0/go.mod h1:QNX1aly8ehqqX1LEa6YniTU7VY9I6R3X/oPxhGdTceE= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.4.1 h1:8qOago/OqoFclMUUj/184tZyRdDZFpcejSjbk5Jrl6Y= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.4.1/go.mod h1:VwYo0Hak6Efuy0TXsZs8o1hnV3dHDPNtDbycG0hI8+M= go.opentelemetry.io/otel/internal/metric v0.27.0 h1:9dAVGAfFiiEq5NVB9FUJ5et+btbDQAUIJehJ+ikyryk= go.opentelemetry.io/otel/internal/metric v0.27.0/go.mod h1:n1CVxRqKqYZtqyTh9U/onvKapPGv7y/rpyOTI+LFNzw= @@ -1498,6 +1506,7 @@ go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= +go.uber.org/goleak v1.1.12 h1:gZAh5/EyT/HQwlpkCy6wTpqfH9H8Lz8zbm3dZh+OyzA= go.uber.org/goleak v1.1.12/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= diff --git a/pkg/compose/build.go b/pkg/compose/build.go index 5ab257dd..dcb4c8e7 100644 --- a/pkg/compose/build.go +++ b/pkg/compose/build.go @@ -81,6 +81,12 @@ func (s *composeService) build(ctx context.Context, project *types.Project, opti Attrs: map[string]string{"ref": image}, }) } + if len(buildOptions.Platforms) > 1 { + buildOptions.Exports = []bclient.ExportEntry{{ + Type: "image", + Attrs: map[string]string{}, + }} + } opts[imageName] = buildOptions } @@ -162,6 +168,15 @@ func (s *composeService) getBuildOptions(project *types.Project, images map[stri if err != nil { return nil, err } + opt.Exports = []bclient.ExportEntry{{ + Type: "docker", + Attrs: map[string]string{ + "load": "true", + }, + }} + if opt.Platforms, err = useDockerDefaultPlatform(project, service.Build.Platforms); err != nil { + opt.Platforms = []specs.Platform{} + } opts[imageName] = opt continue } @@ -206,7 +221,7 @@ func (s *composeService) doBuild(ctx context.Context, project *types.Project, op if buildkitEnabled, err := s.dockerCli.BuildKitEnabled(); err != nil || !buildkitEnabled { return s.doBuildClassic(ctx, project, opts) } - return s.doBuildBuildkit(ctx, project, opts, mode) + return s.doBuildBuildkit(ctx, opts, mode) } func (s *composeService) toBuildOptions(project *types.Project, service types.ServiceConfig, imageTag string, sshKeys []types.SSHKey) (build.Options, error) { @@ -215,20 +230,9 @@ func (s *composeService) toBuildOptions(project *types.Project, service types.Se buildArgs := flatten(service.Build.Args.Resolve(envResolver(project.Environment))) - var plats []specs.Platform - if platform, ok := project.Environment["DOCKER_DEFAULT_PLATFORM"]; ok { - p, err := platforms.Parse(platform) - if err != nil { - return build.Options{}, err - } - plats = append(plats, p) - } - if service.Platform != "" { - p, err := platforms.Parse(service.Platform) - if err != nil { - return build.Options{}, err - } - plats = append(plats, p) + plats, err := addPlatforms(project, service) + if err != nil { + return build.Options{}, err } cacheFrom, err := buildflags.ParseCacheEntry(service.Build.CacheFrom) @@ -352,3 +356,40 @@ func addSecretsConfig(project *types.Project, service types.ServiceConfig) (sess } return secretsprovider.NewSecretProvider(store), nil } + +func addPlatforms(project *types.Project, service types.ServiceConfig) ([]specs.Platform, error) { + plats, err := useDockerDefaultPlatform(project, service.Build.Platforms) + if err != nil { + return nil, err + } + + if service.Platform != "" && !utils.StringContains(service.Build.Platforms, service.Platform) { + return nil, fmt.Errorf("service.platform should be part of the service.build.platforms: %q", service.Platform) + } + + for _, buildPlatform := range service.Build.Platforms { + p, err := platforms.Parse(buildPlatform) + if err != nil { + return nil, err + } + if !utils.Contains(plats, p) { + plats = append(plats, p) + } + } + return plats, nil +} + +func useDockerDefaultPlatform(project *types.Project, platformList types.StringList) ([]specs.Platform, error) { + var plats []specs.Platform + if platform, ok := project.Environment["DOCKER_DEFAULT_PLATFORM"]; ok { + if !utils.StringContains(platformList, platform) { + return nil, fmt.Errorf("the DOCKER_DEFAULT_PLATFORM value should be part of the service.build.platforms: %q", platform) + } + p, err := platforms.Parse(platform) + if err != nil { + return nil, err + } + plats = append(plats, p) + } + return plats, nil +} diff --git a/pkg/compose/build_buildkit.go b/pkg/compose/build_buildkit.go index d4120ced..912c520c 100644 --- a/pkg/compose/build_buildkit.go +++ b/pkg/compose/build_buildkit.go @@ -18,27 +18,36 @@ package compose import ( "context" + "fmt" + "net/url" "os" "path/filepath" + "strings" + + ctxkube "github.com/docker/buildx/driver/kubernetes/context" + "github.com/docker/buildx/store" + "github.com/docker/buildx/store/storeutil" + "github.com/docker/cli/cli/command" + "github.com/docker/cli/cli/context/docker" + ctxstore "github.com/docker/cli/cli/context/store" + dockerclient "github.com/docker/docker/client" + "github.com/sirupsen/logrus" + "golang.org/x/sync/errgroup" + "k8s.io/client-go/tools/clientcmd" - "github.com/compose-spec/compose-go/types" "github.com/docker/buildx/build" "github.com/docker/buildx/driver" + _ "github.com/docker/buildx/driver/docker" //nolint:blank-imports + _ "github.com/docker/buildx/driver/docker-container" //nolint:blank-imports + _ "github.com/docker/buildx/driver/kubernetes" //nolint:blank-imports xprogress "github.com/docker/buildx/util/progress" ) -func (s *composeService) doBuildBuildkit(ctx context.Context, project *types.Project, opts map[string]build.Options, mode string) (map[string]string, error) { - const drivername = "default" - d, err := driver.GetDriver(ctx, drivername, nil, s.apiClient(), s.configFile(), nil, nil, nil, nil, nil, project.WorkingDir) +func (s *composeService) doBuildBuildkit(ctx context.Context, opts map[string]build.Options, mode string) (map[string]string, error) { + dis, err := s.getDrivers(ctx) if err != nil { return nil, err } - driverInfo := []build.DriverInfo{ - { - Name: drivername, - Driver: d, - }, - } // Progress needs its own context that lives longer than the // build one otherwise it won't read all the messages from @@ -47,8 +56,7 @@ func (s *composeService) doBuildBuildkit(ctx context.Context, project *types.Pro defer cancel() w := xprogress.NewPrinter(progressCtx, s.stdout(), os.Stdout, mode) - // We rely on buildx "docker" builder integrated in docker engine, so don't need a DockerAPI here - response, err := build.Build(ctx, driverInfo, opts, nil, filepath.Dir(s.configFile().Filename), w) + response, err := build.Build(ctx, dis, opts, &internalAPI{dockerCli: s.dockerCli}, filepath.Dir(s.configFile().Filename), w) errW := w.Wait() if err == nil { err = errW @@ -71,3 +79,187 @@ func (s *composeService) doBuildBuildkit(ctx context.Context, project *types.Pro return imagesBuilt, err } + +func (s *composeService) getDrivers(ctx context.Context) ([]build.DriverInfo, error) { //nolint:gocyclo + txn, release, err := storeutil.GetStore(s.dockerCli) + if err != nil { + return nil, err + } + defer release() + + ng, err := storeutil.GetCurrentInstance(txn, s.dockerCli) + if err != nil { + return nil, err + } + + dis := make([]build.DriverInfo, len(ng.Nodes)) + var f driver.Factory + if ng.Driver != "" { + factories := driver.GetFactories() + for _, fac := range factories { + if fac.Name() == ng.Driver { + f = fac + continue + } + } + if f == nil { + if f = driver.GetFactory(ng.Driver, true); f == nil { + return nil, fmt.Errorf("failed to find buildx driver %q", ng.Driver) + } + } + } else { + ep := ng.Nodes[0].Endpoint + dockerapi, err := clientForEndpoint(s.dockerCli, ep) + if err != nil { + return nil, err + } + f, err = driver.GetDefaultFactory(ctx, dockerapi, false) + if err != nil { + return nil, err + } + ng.Driver = f.Name() + } + + imageopt, err := storeutil.GetImageConfig(s.dockerCli, ng) + if err != nil { + return nil, err + } + + eg, _ := errgroup.WithContext(ctx) + for i, n := range ng.Nodes { + func(i int, n store.Node) { + eg.Go(func() error { + di := build.DriverInfo{ + Name: n.Name, + Platform: n.Platforms, + ProxyConfig: storeutil.GetProxyConfig(s.dockerCli), + } + defer func() { + dis[i] = di + }() + + dockerapi, err := clientForEndpoint(s.dockerCli, n.Endpoint) + if err != nil { + di.Err = err + return nil + } + // TODO: replace the following line with dockerclient.WithAPIVersionNegotiation option in clientForEndpoint + dockerapi.NegotiateAPIVersion(ctx) + + contextStore := s.dockerCli.ContextStore() + + var kcc driver.KubeClientConfig + kcc, err = configFromContext(n.Endpoint, contextStore) + if err != nil { + // err is returned if n.Endpoint is non-context name like "unix:///var/run/docker.sock". + // try again with name="default". + // FIXME: n should retain real context name. + kcc, err = configFromContext("default", contextStore) + if err != nil { + logrus.Error(err) + } + } + + tryToUseKubeConfigInCluster := false + if kcc == nil { + tryToUseKubeConfigInCluster = true + } else { + if _, err := kcc.ClientConfig(); err != nil { + tryToUseKubeConfigInCluster = true + } + } + if tryToUseKubeConfigInCluster { + kccInCluster := driver.KubeClientConfigInCluster{} + if _, err := kccInCluster.ClientConfig(); err == nil { + logrus.Debug("using kube config in cluster") + kcc = kccInCluster + } + } + + d, err := driver.GetDriver(ctx, "buildx_buildkit_"+n.Name, f, dockerapi, imageopt.Auth, kcc, n.Flags, n.Files, n.DriverOpts, n.Platforms, "") + if err != nil { + di.Err = err + return nil + } + di.Driver = d + di.ImageOpt = imageopt + return nil + }) + }(i, n) + } + + if err := eg.Wait(); err != nil { + return nil, err + } + + return dis, nil +} + +func clientForEndpoint(dockerCli command.Cli, name string) (dockerclient.APIClient, error) { + list, err := dockerCli.ContextStore().List() + if err != nil { + return nil, err + } + for _, l := range list { + if l.Name != name { + continue + } + dep, ok := l.Endpoints["docker"] + if !ok { + return nil, fmt.Errorf("context %q does not have a Docker endpoint", name) + } + epm, ok := dep.(docker.EndpointMeta) + if !ok { + return nil, fmt.Errorf("endpoint %q is not of type EndpointMeta, %T", dep, dep) + } + ep, err := docker.WithTLSData(dockerCli.ContextStore(), name, epm) + if err != nil { + return nil, err + } + clientOpts, err := ep.ClientOpts() + if err != nil { + return nil, err + } + return dockerclient.NewClientWithOpts(clientOpts...) + } + + ep := docker.Endpoint{ + EndpointMeta: docker.EndpointMeta{ + Host: name, + }, + } + + clientOpts, err := ep.ClientOpts() + if err != nil { + return nil, err + } + + return dockerclient.NewClientWithOpts(clientOpts...) +} + +func configFromContext(endpointName string, s ctxstore.Reader) (clientcmd.ClientConfig, error) { + if strings.HasPrefix(endpointName, "kubernetes://") { + u, _ := url.Parse(endpointName) + if kubeconfig := u.Query().Get("kubeconfig"); kubeconfig != "" { + _ = os.Setenv(clientcmd.RecommendedConfigPathEnvVar, kubeconfig) + } + rules := clientcmd.NewDefaultClientConfigLoadingRules() + apiConfig, err := rules.Load() + if err != nil { + return nil, err + } + return clientcmd.NewDefaultClientConfig(*apiConfig, &clientcmd.ConfigOverrides{}), nil + } + return ctxkube.ConfigFromContext(endpointName, s) +} + +type internalAPI struct { + dockerCli command.Cli +} + +func (a *internalAPI) DockerAPI(name string) (dockerclient.APIClient, error) { + if name == "" { + name = a.dockerCli.CurrentContext() + } + return clientForEndpoint(a.dockerCli, name) +} diff --git a/pkg/compose/build_classic.go b/pkg/compose/build_classic.go index e362ae24..3a41e618 100644 --- a/pkg/compose/build_classic.go +++ b/pkg/compose/build_classic.go @@ -89,6 +89,10 @@ func (s *composeService) doBuildClassicSimpleImage(ctx context.Context, options } } + if len(options.Platforms) > 1 { + return "", errors.Errorf("this builder doesn't support multi-arch build, set DOCKER_BUILDKIT=1 to use multi-arch builder") + } + switch { case isLocalDir(specifiedContext): contextDir, relDockerfile, err = build.GetContextFromLocalDir(specifiedContext, dockerfileName) diff --git a/pkg/e2e/build_test.go b/pkg/e2e/build_test.go index 178ed7f9..7eeebf94 100644 --- a/pkg/e2e/build_test.go +++ b/pkg/e2e/build_test.go @@ -243,3 +243,105 @@ func TestBuildImageDependencies(t *testing.T) { t.Skip("See https://github.com/docker/compose/issues/9232") }) } + +func TestBuildPlatformsWithCorrectBuildxConfig(t *testing.T) { + c := NewParallelCLI(t) + + // declare builder + result := c.RunDockerCmd(t, "buildx", "create", "--name", "build-platform", "--use", "--bootstrap") + assert.NilError(t, result.Error) + + t.Cleanup(func() { + c.RunDockerComposeCmd(t, "--project-directory", "fixtures/build-test/platforms", "down") + _ = c.RunDockerCmd(t, "buildx", "rm", "-f", "build-platform") + }) + + t.Run("platform not supported by builder", func(t *testing.T) { + res := c.RunDockerComposeCmdNoCheck(t, "--project-directory", "fixtures/build-test/platforms", + "-f", "fixtures/build-test/platforms/compose-unsupported-platform.yml", "build") + res.Assert(t, icmd.Expected{ + ExitCode: 17, + Err: "failed to solve: alpine: no match for platform in", + }) + }) + + t.Run("multi-arch build ok", func(t *testing.T) { + res := c.RunDockerComposeCmdNoCheck(t, "--project-directory", "fixtures/build-test/platforms", "build") + assert.NilError(t, res.Error, res.Stderr()) + res.Assert(t, icmd.Expected{Out: "I am building for linux/arm64"}) + res.Assert(t, icmd.Expected{Out: "I am building for linux/amd64"}) + + }) + + t.Run("multi-arch multi service builds ok", func(t *testing.T) { + res := c.RunDockerComposeCmdNoCheck(t, "--project-directory", "fixtures/build-test/platforms", + "-f", "fixtures/build-test/platforms/compose-multiple-platform-builds.yaml", "build") + assert.NilError(t, res.Error, res.Stderr()) + res.Assert(t, icmd.Expected{Out: "I'm Service A and I am building for linux/arm64"}) + res.Assert(t, icmd.Expected{Out: "I'm Service A and I am building for linux/amd64"}) + res.Assert(t, icmd.Expected{Out: "I'm Service B and I am building for linux/arm64"}) + res.Assert(t, icmd.Expected{Out: "I'm Service B and I am building for linux/amd64"}) + res.Assert(t, icmd.Expected{Out: "I'm Service C and I am building for linux/arm64"}) + res.Assert(t, icmd.Expected{Out: "I'm Service C and I am building for linux/amd64"}) + }) + + t.Run("multi-arch up --build", func(t *testing.T) { + res := c.RunDockerComposeCmdNoCheck(t, "--project-directory", "fixtures/build-test/platforms", "up", "--build") + assert.NilError(t, res.Error, res.Stderr()) + res.Assert(t, icmd.Expected{Out: "platforms-platforms-1 exited with code 0"}) + }) + + t.Run("use DOCKER_DEFAULT_PLATFORM value when up --build", func(t *testing.T) { + cmd := c.NewDockerComposeCmd(t, "--project-directory", "fixtures/build-test/platforms", "up", "--build") + res := icmd.RunCmd(cmd, func(cmd *icmd.Cmd) { + cmd.Env = append(cmd.Env, "DOCKER_DEFAULT_PLATFORM=linux/amd64") + }) + assert.NilError(t, res.Error, res.Stderr()) + res.Assert(t, icmd.Expected{Out: "I am building for linux/amd64"}) + assert.Assert(t, !strings.Contains(res.Stdout(), "I am building for linux/arm64")) + }) +} + +func TestBuildPlatformsStandardErrors(t *testing.T) { + c := NewParallelCLI(t) + + t.Run("no platform support with Classic Builder", func(t *testing.T) { + cmd := c.NewDockerComposeCmd(t, "--project-directory", "fixtures/build-test/platforms", "build") + + res := icmd.RunCmd(cmd, func(cmd *icmd.Cmd) { + cmd.Env = append(cmd.Env, "DOCKER_BUILDKIT=0") + }) + res.Assert(t, icmd.Expected{ + ExitCode: 1, + Err: "this builder doesn't support multi-arch build, set DOCKER_BUILDKIT=1 to use multi-arch builder", + }) + }) + + t.Run("builder does not support multi-arch", func(t *testing.T) { + res := c.RunDockerComposeCmdNoCheck(t, "--project-directory", "fixtures/build-test/platforms", "build") + res.Assert(t, icmd.Expected{ + ExitCode: 17, + Err: `multiple platforms feature is currently not supported for docker driver. Please switch to a different driver (eg. "docker buildx create --use")`, + }) + }) + + t.Run("service platform not defined in platforms build section", func(t *testing.T) { + res := c.RunDockerComposeCmdNoCheck(t, "--project-directory", "fixtures/build-test/platforms", + "-f", "fixtures/build-test/platforms/compose-service-platform-not-in-build-platforms.yaml", "build") + res.Assert(t, icmd.Expected{ + ExitCode: 1, + Err: `service.platform should be part of the service.build.platforms: "linux/riscv64"`, + }) + }) + + t.Run("DOCKER_DEFAULT_PLATFORM value not defined in platforms build section", func(t *testing.T) { + cmd := c.NewDockerComposeCmd(t, "--project-directory", "fixtures/build-test/platforms", "build") + res := icmd.RunCmd(cmd, func(cmd *icmd.Cmd) { + cmd.Env = append(cmd.Env, "DOCKER_DEFAULT_PLATFORM=windows/amd64") + }) + res.Assert(t, icmd.Expected{ + ExitCode: 1, + Err: `DOCKER_DEFAULT_PLATFORM value should be part of the service.build.platforms: "windows/amd64"`, + }) + }) +} diff --git a/pkg/e2e/fixtures/build-test/platforms/Dockerfile b/pkg/e2e/fixtures/build-test/platforms/Dockerfile new file mode 100644 index 00000000..ef22c17f --- /dev/null +++ b/pkg/e2e/fixtures/build-test/platforms/Dockerfile @@ -0,0 +1,22 @@ +# 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. + +FROM --platform=$BUILDPLATFORM golang:alpine AS build + +ARG TARGETPLATFORM +ARG BUILDPLATFORM +RUN echo "I am building for $TARGETPLATFORM, running on $BUILDPLATFORM" > /log + +FROM alpine +COPY --from=build /log /log diff --git a/pkg/e2e/fixtures/build-test/platforms/compose-multiple-platform-builds.yaml b/pkg/e2e/fixtures/build-test/platforms/compose-multiple-platform-builds.yaml new file mode 100644 index 00000000..aac3a3db --- /dev/null +++ b/pkg/e2e/fixtures/build-test/platforms/compose-multiple-platform-builds.yaml @@ -0,0 +1,23 @@ +services: + serviceA: + image: build-test-platform-a:test + build: + context: ./contextServiceA + platforms: + - linux/amd64 + - linux/arm64 + serviceB: + image: build-test-platform-b:test + build: + context: ./contextServiceB + platforms: + - linux/amd64 + - linux/arm64 + serviceC: + image: build-test-platform-c:test + build: + context: ./contextServiceC + platforms: + - linux/amd64 + - linux/arm64 + diff --git a/pkg/e2e/fixtures/build-test/platforms/compose-service-platform-not-in-build-platforms.yaml b/pkg/e2e/fixtures/build-test/platforms/compose-service-platform-not-in-build-platforms.yaml new file mode 100644 index 00000000..bed88fa5 --- /dev/null +++ b/pkg/e2e/fixtures/build-test/platforms/compose-service-platform-not-in-build-platforms.yaml @@ -0,0 +1,9 @@ +services: + platforms: + image: build-test-platform:test + platform: linux/riscv64 + build: + context: . + platforms: + - linux/amd64 + - linux/arm64 diff --git a/pkg/e2e/fixtures/build-test/platforms/compose-unsupported-platform.yml b/pkg/e2e/fixtures/build-test/platforms/compose-unsupported-platform.yml new file mode 100644 index 00000000..e3342829 --- /dev/null +++ b/pkg/e2e/fixtures/build-test/platforms/compose-unsupported-platform.yml @@ -0,0 +1,8 @@ +services: + platforms: + image: build-test-platform:test + build: + context: . + platforms: + - unsupported/unsupported + - linux/amd64 diff --git a/pkg/e2e/fixtures/build-test/platforms/compose.yaml b/pkg/e2e/fixtures/build-test/platforms/compose.yaml new file mode 100644 index 00000000..73421f47 --- /dev/null +++ b/pkg/e2e/fixtures/build-test/platforms/compose.yaml @@ -0,0 +1,9 @@ +services: + platforms: + image: build-test-platform:test + build: + context: . + platforms: + - linux/amd64 + - linux/arm64 + diff --git a/pkg/e2e/fixtures/build-test/platforms/contextServiceA/Dockerfile b/pkg/e2e/fixtures/build-test/platforms/contextServiceA/Dockerfile new file mode 100644 index 00000000..468b2b10 --- /dev/null +++ b/pkg/e2e/fixtures/build-test/platforms/contextServiceA/Dockerfile @@ -0,0 +1,22 @@ +# 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. + +FROM --platform=$BUILDPLATFORM golang:alpine AS build + +ARG TARGETPLATFORM +ARG BUILDPLATFORM +RUN echo "I'm Service A and I am building for $TARGETPLATFORM, running on $BUILDPLATFORM" > /log + +FROM alpine +COPY --from=build /log /log diff --git a/pkg/e2e/fixtures/build-test/platforms/contextServiceB/Dockerfile b/pkg/e2e/fixtures/build-test/platforms/contextServiceB/Dockerfile new file mode 100644 index 00000000..cfa2ae34 --- /dev/null +++ b/pkg/e2e/fixtures/build-test/platforms/contextServiceB/Dockerfile @@ -0,0 +1,22 @@ +# 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. + +FROM --platform=$BUILDPLATFORM golang:alpine AS build + +ARG TARGETPLATFORM +ARG BUILDPLATFORM +RUN echo "I'm Service B and I am building for $TARGETPLATFORM, running on $BUILDPLATFORM" > /log + +FROM alpine +COPY --from=build /log /log diff --git a/pkg/e2e/fixtures/build-test/platforms/contextServiceC/Dockerfile b/pkg/e2e/fixtures/build-test/platforms/contextServiceC/Dockerfile new file mode 100644 index 00000000..3216f618 --- /dev/null +++ b/pkg/e2e/fixtures/build-test/platforms/contextServiceC/Dockerfile @@ -0,0 +1,22 @@ +# 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. + +FROM --platform=$BUILDPLATFORM golang:alpine AS build + +ARG TARGETPLATFORM +ARG BUILDPLATFORM +RUN echo "I'm Service C and I am building for $TARGETPLATFORM, running on $BUILDPLATFORM" > /log + +FROM alpine +COPY --from=build /log /log diff --git a/pkg/utils/slices.go b/pkg/utils/slices.go new file mode 100644 index 00000000..3b635c25 --- /dev/null +++ b/pkg/utils/slices.go @@ -0,0 +1,30 @@ +/* + 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 utils + +import "reflect" + +// Contains helps to detect if a non-comparable struct is part of an array +// only use this method if you can't rely on existing golang Contains function of slices (https://pkg.go.dev/golang.org/x/exp/slices#Contains) +func Contains[T any](origin []T, element T) bool { + for _, v := range origin { + if reflect.DeepEqual(v, element) { + return true + } + } + return false +} diff --git a/pkg/utils/slices_test.go b/pkg/utils/slices_test.go new file mode 100644 index 00000000..d9468afe --- /dev/null +++ b/pkg/utils/slices_test.go @@ -0,0 +1,95 @@ +/* + 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 utils + +import ( + "testing" + + specs "github.com/opencontainers/image-spec/specs-go/v1" +) + +func TestContains(t *testing.T) { + source := []specs.Platform{ + { + Architecture: "linux/amd64", + OS: "darwin", + OSVersion: "", + OSFeatures: nil, + Variant: "", + }, + { + Architecture: "linux/arm64", + OS: "linux", + OSVersion: "12", + OSFeatures: nil, + Variant: "v8", + }, + { + Architecture: "", + OS: "", + OSVersion: "", + OSFeatures: nil, + Variant: "", + }, + } + + type args struct { + origin []specs.Platform + element specs.Platform + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "element found", + args: args{ + origin: source, + element: specs.Platform{ + Architecture: "linux/arm64", + OS: "linux", + OSVersion: "12", + OSFeatures: nil, + Variant: "v8", + }, + }, + want: true, + }, + { + name: "element not found", + args: args{ + origin: source, + element: specs.Platform{ + Architecture: "linux/arm64", + OS: "darwin", + OSVersion: "12", + OSFeatures: nil, + Variant: "v8", + }, + }, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := Contains(tt.args.origin, tt.args.element); got != tt.want { + t.Errorf("Contains() = %v, want %v", got, tt.want) + } + }) + } +}