diff --git a/cmd/compose/build.go b/cmd/compose/build.go index d8993a26c..00b3ba06b 100644 --- a/cmd/compose/build.go +++ b/cmd/compose/build.go @@ -35,7 +35,6 @@ import ( type buildOptions struct { *ProjectOptions - composeOptions quiet bool pull bool push bool @@ -73,7 +72,7 @@ func (opts buildOptions) toAPIBuildOptions(services []string) (api.BuildOptions, }, nil } -func buildCommand(p *ProjectOptions, progress *string, backend api.Service) *cobra.Command { +func buildCommand(p *ProjectOptions, backend api.Service) *cobra.Command { opts := buildOptions{ ProjectOptions: p, } @@ -118,7 +117,7 @@ func buildCommand(p *ProjectOptions, progress *string, backend api.Service) *cob cmd.Flags().Bool("no-rm", false, "Do not remove intermediate containers after a successful build. DEPRECATED") cmd.Flags().MarkHidden("no-rm") //nolint:errcheck cmd.Flags().VarP(&opts.memory, "memory", "m", "Set memory limit for the build container. Not supported by BuildKit.") - cmd.Flags().StringVar(progress, "progress", buildx.PrinterModeAuto, fmt.Sprintf(`Set type of ui output (%s)`, strings.Join(printerModes, ", "))) + cmd.Flags().StringVar(&p.Progress, "progress", buildx.PrinterModeAuto, fmt.Sprintf(`Set type of ui output (%s)`, strings.Join(printerModes, ", "))) cmd.Flags().MarkHidden("progress") //nolint:errcheck return cmd @@ -130,6 +129,10 @@ func runBuild(ctx context.Context, backend api.Service, opts buildOptions, servi return err } + if err := applyPlatforms(project, false); err != nil { + return err + } + apiBuildOptions, err := opts.toAPIBuildOptions(services) if err != nil { return err diff --git a/cmd/compose/compose.go b/cmd/compose/compose.go index e0e125faf..8df84df21 100644 --- a/cmd/compose/compose.go +++ b/cmd/compose/compose.go @@ -26,8 +26,9 @@ import ( "strings" "syscall" - "github.com/compose-spec/compose-go/dotenv" buildx "github.com/docker/buildx/util/progress" + + "github.com/compose-spec/compose-go/dotenv" "github.com/docker/cli/cli/command" "github.com/docker/compose/v2/pkg/remote" @@ -117,6 +118,7 @@ type ProjectOptions struct { ProjectDir string EnvFiles []string Compatibility bool + Progress string } // ProjectFunc does stuff within a types.Project @@ -170,6 +172,7 @@ func (o *ProjectOptions) addProjectFlags(f *pflag.FlagSet) { f.StringVar(&o.ProjectDir, "project-directory", "", "Specify an alternate working directory\n(default: the path of the, first specified, Compose file)") f.StringVar(&o.WorkDir, "workdir", "", "DEPRECATED! USE --project-directory INSTEAD.\nSpecify an alternate working directory\n(default: the path of the, first specified, Compose file)") f.BoolVar(&o.Compatibility, "compatibility", false, "Run compose in backward compatibility mode") + f.StringVar(&o.Progress, "progress", buildx.PrinterModeAuto, fmt.Sprintf(`Set type of progress output (%s)`, strings.Join(printerModes, ", "))) _ = f.MarkHidden("workdir") } @@ -294,7 +297,6 @@ func RootCommand(dockerCli command.Cli, backend api.Service) *cobra.Command { // version bool parallel int dryRun bool - progress string ) c := &cobra.Command{ Short: "Docker Compose", @@ -359,7 +361,7 @@ func RootCommand(dockerCli command.Cli, backend api.Service) *cobra.Command { // ui.Mode = ui.ModeTTY } - switch progress { + switch opts.Progress { case ui.ModeAuto: ui.Mode = ui.ModeAuto case ui.ModeTTY: @@ -375,7 +377,7 @@ func RootCommand(dockerCli command.Cli, backend api.Service) *cobra.Command { // case ui.ModeQuiet, "none": ui.Mode = ui.ModeQuiet default: - return fmt.Errorf("unsupported --progress value %q", progress) + return fmt.Errorf("unsupported --progress value %q", opts.Progress) } if opts.WorkDir != "" { @@ -446,7 +448,7 @@ func RootCommand(dockerCli command.Cli, backend api.Service) *cobra.Command { // portCommand(&opts, dockerCli, backend), imagesCommand(&opts, dockerCli, backend), versionCommand(dockerCli), - buildCommand(&opts, &progress, backend), + buildCommand(&opts, backend), pushCommand(&opts, backend), pullCommand(&opts, backend), createCommand(&opts, backend), @@ -478,8 +480,6 @@ func RootCommand(dockerCli command.Cli, backend api.Service) *cobra.Command { // completeProfileNames(&opts), ) - c.Flags().StringVar(&progress, "progress", buildx.PrinterModeAuto, fmt.Sprintf(`Set type of progress output (%s)`, strings.Join(printerModes, ", "))) - c.Flags().StringVar(&ansi, "ansi", "auto", `Control when to print ANSI control characters ("never"|"always"|"auto")`) c.Flags().IntVar(¶llel, "parallel", -1, `Control max parallelism, -1 for unlimited`) c.Flags().BoolVarP(&version, "version", "v", false, "Show the Docker Compose version information") diff --git a/cmd/compose/create.go b/cmd/compose/create.go index b513c6d93..0efa7989d 100644 --- a/cmd/compose/create.go +++ b/cmd/compose/create.go @@ -123,6 +123,9 @@ func (opts createOptions) Apply(project *types.Project) error { project.Services[i] = service } } + // N.B. opts.Build means "force build all", but images can still be built + // when this is false + // e.g. if a service has pull_policy: build or its local image is missing if opts.Build { for i, service := range project.Services { if service.Build == nil { @@ -132,6 +135,7 @@ func (opts createOptions) Apply(project *types.Project) error { project.Services[i] = service } } + // opts.noBuild, however, means do not perform ANY builds if opts.noBuild { for i, service := range project.Services { service.Build = nil @@ -141,6 +145,11 @@ func (opts createOptions) Apply(project *types.Project) error { project.Services[i] = service } } + + if err := applyPlatforms(project, true); err != nil { + return err + } + for _, scale := range opts.scale { split := strings.Split(scale, "=") if len(split) != 2 { diff --git a/cmd/compose/options.go b/cmd/compose/options.go new file mode 100644 index 000000000..88135df20 --- /dev/null +++ b/cmd/compose/options.go @@ -0,0 +1,76 @@ +/* + Copyright 2023 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 ( + "fmt" + + "github.com/compose-spec/compose-go/types" + "github.com/docker/compose/v2/pkg/utils" +) + +func applyPlatforms(project *types.Project, buildForSinglePlatform bool) error { + defaultPlatform := project.Environment["DOCKER_DEFAULT_PLATFORM"] + for i := range project.Services { + // mutable reference so platform fields can be updated + service := &project.Services[i] + + if service.Build == nil { + continue + } + + // default platform only applies if the service doesn't specify + if defaultPlatform != "" && service.Platform == "" { + if len(service.Build.Platforms) > 0 && !utils.StringContains(service.Build.Platforms, defaultPlatform) { + return fmt.Errorf("service %q build.platforms does not support value set by DOCKER_DEFAULT_PLATFORM: %s", service.Name, defaultPlatform) + } + service.Platform = defaultPlatform + } + + if service.Platform != "" { + if len(service.Build.Platforms) > 0 { + if !utils.StringContains(service.Build.Platforms, service.Platform) { + return fmt.Errorf("service %q build configuration does not support platform: %s", service.Name, service.Platform) + } + } + + if buildForSinglePlatform || len(service.Build.Platforms) == 0 { + // if we're building for a single platform, we want to build for the platform we'll use to run the image + // similarly, if no build platforms were explicitly specified, it makes sense to build for the platform + // the image is designed for rather than allowing the builder to infer the platform + service.Build.Platforms = []string{service.Platform} + } + } + + // services can specify that they should be built for multiple platforms, which can be used + // with `docker compose build` to produce a multi-arch image + // other cases, such as `up` and `run`, need a single architecture to actually run + // if there is only a single platform present (which might have been inferred + // from service.Platform above), it will be used, even if it requires emulation. + // if there's more than one platform, then the list is cleared so that the builder + // can decide. + // TODO(milas): there's no validation that the platform the builder will pick is actually one + // of the supported platforms from the build definition + // e.g. `build.platforms: [linux/arm64, linux/amd64]` on a `linux/ppc64` machine would build + // for `linux/ppc64` instead of returning an error that it's not a valid platform for the service. + if buildForSinglePlatform && len(service.Build.Platforms) > 1 { + // empty indicates that the builder gets to decide + service.Build.Platforms = nil + } + } + return nil +} diff --git a/cmd/compose/options_test.go b/cmd/compose/options_test.go new file mode 100644 index 000000000..7ee5f4b9c --- /dev/null +++ b/cmd/compose/options_test.go @@ -0,0 +1,130 @@ +/* + Copyright 2023 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 ( + "testing" + + "github.com/compose-spec/compose-go/types" + "github.com/stretchr/testify/require" +) + +func TestApplyPlatforms_InferFromRuntime(t *testing.T) { + makeProject := func() *types.Project { + return &types.Project{ + Services: []types.ServiceConfig{ + { + Name: "test", + Image: "foo", + Build: &types.BuildConfig{ + Context: ".", + Platforms: []string{ + "linux/amd64", + "linux/arm64", + "alice/32", + }, + }, + Platform: "alice/32", + }, + }, + } + } + + t.Run("SinglePlatform", func(t *testing.T) { + project := makeProject() + require.NoError(t, applyPlatforms(project, true)) + require.EqualValues(t, []string{"alice/32"}, project.Services[0].Build.Platforms) + }) + + t.Run("MultiPlatform", func(t *testing.T) { + project := makeProject() + require.NoError(t, applyPlatforms(project, false)) + require.EqualValues(t, []string{"linux/amd64", "linux/arm64", "alice/32"}, + project.Services[0].Build.Platforms) + }) +} + +func TestApplyPlatforms_DockerDefaultPlatform(t *testing.T) { + makeProject := func() *types.Project { + return &types.Project{ + Environment: map[string]string{ + "DOCKER_DEFAULT_PLATFORM": "linux/amd64", + }, + Services: []types.ServiceConfig{ + { + Name: "test", + Image: "foo", + Build: &types.BuildConfig{ + Context: ".", + Platforms: []string{ + "linux/amd64", + "linux/arm64", + }, + }, + }, + }, + } + } + + t.Run("SinglePlatform", func(t *testing.T) { + project := makeProject() + require.NoError(t, applyPlatforms(project, true)) + require.EqualValues(t, []string{"linux/amd64"}, project.Services[0].Build.Platforms) + }) + + t.Run("MultiPlatform", func(t *testing.T) { + project := makeProject() + require.NoError(t, applyPlatforms(project, false)) + require.EqualValues(t, []string{"linux/amd64", "linux/arm64"}, + project.Services[0].Build.Platforms) + }) +} + +func TestApplyPlatforms_UnsupportedPlatform(t *testing.T) { + makeProject := func() *types.Project { + return &types.Project{ + Environment: map[string]string{ + "DOCKER_DEFAULT_PLATFORM": "commodore/64", + }, + Services: []types.ServiceConfig{ + { + Name: "test", + Image: "foo", + Build: &types.BuildConfig{ + Context: ".", + Platforms: []string{ + "linux/amd64", + "linux/arm64", + }, + }, + }, + }, + } + } + + t.Run("SinglePlatform", func(t *testing.T) { + project := makeProject() + require.EqualError(t, applyPlatforms(project, true), + `service "test" build.platforms does not support value set by DOCKER_DEFAULT_PLATFORM: commodore/64`) + }) + + t.Run("MultiPlatform", func(t *testing.T) { + project := makeProject() + require.EqualError(t, applyPlatforms(project, false), + `service "test" build.platforms does not support value set by DOCKER_DEFAULT_PLATFORM: commodore/64`) + }) +} diff --git a/cmd/compose/run.go b/cmd/compose/run.go index ada590ccd..796de54c6 100644 --- a/cmd/compose/run.go +++ b/cmd/compose/run.go @@ -21,6 +21,8 @@ import ( "fmt" "strings" + xprogress "github.com/docker/buildx/util/progress" + cgo "github.com/compose-spec/compose-go/cli" "github.com/compose-spec/compose-go/loader" "github.com/compose-spec/compose-go/types" @@ -118,6 +120,9 @@ func runCommand(p *ProjectOptions, streams api.Streams, backend api.Service) *co capDrop: opts.NewListOpts(nil), } createOpts := createOptions{} + buildOpts := buildOptions{ + ProjectOptions: p, + } cmd := &cobra.Command{ Use: "run [OPTIONS] SERVICE [COMMAND] [ARGS...]", Short: "Run a one-off command on a service.", @@ -152,8 +157,12 @@ func runCommand(p *ProjectOptions, streams api.Streams, backend api.Service) *co return err } + if createOpts.quietPull { + buildOpts.Progress = xprogress.PrinterModeQuiet + } + options.ignoreOrphans = utils.StringToBool(project.Environment[ComposeIgnoreOrphans]) - return runRun(ctx, backend, project, options, createOpts, streams) + return runRun(ctx, backend, project, options, createOpts, buildOpts, streams) }), ValidArgsFunction: completeServiceNames(p), } @@ -197,7 +206,7 @@ func normalizeRunFlags(f *pflag.FlagSet, name string) pflag.NormalizedName { return pflag.NormalizedName(name) } -func runRun(ctx context.Context, backend api.Service, project *types.Project, options runOptions, createOpts createOptions, streams api.Streams) error { +func runRun(ctx context.Context, backend api.Service, project *types.Project, options runOptions, createOpts createOptions, buildOpts buildOptions, streams api.Streams) error { err := options.apply(project) if err != nil { return err @@ -209,7 +218,16 @@ func runRun(ctx context.Context, backend api.Service, project *types.Project, op } err = progress.Run(ctx, func(ctx context.Context) error { - return startDependencies(ctx, backend, *project, options.Service, options.ignoreOrphans) + var buildForDeps *api.BuildOptions + if !createOpts.noBuild { + // allow dependencies needing build to be implicitly selected + bo, err := buildOpts.toAPIBuildOptions(nil) + if err != nil { + return err + } + buildForDeps = &bo + } + return startDependencies(ctx, backend, *project, buildForDeps, options.Service, options.ignoreOrphans) }, streams.Err()) if err != nil { return err @@ -224,8 +242,20 @@ func runRun(ctx context.Context, backend api.Service, project *types.Project, op labels[parts[0]] = parts[1] } + var buildForRun *api.BuildOptions + if !createOpts.noBuild { + // dependencies have already been started above, so only the service + // being run might need to be built at this point + bo, err := buildOpts.toAPIBuildOptions([]string{options.Service}) + if err != nil { + return err + } + buildForRun = &bo + } + // start container and attach to container streams runOpts := api.RunOptions{ + Build: buildForRun, Name: options.name, Service: options.Service, Command: options.Command, @@ -264,7 +294,7 @@ func runRun(ctx context.Context, backend api.Service, project *types.Project, op return err } -func startDependencies(ctx context.Context, backend api.Service, project types.Project, requestedServiceName string, ignoreOrphans bool) error { +func startDependencies(ctx context.Context, backend api.Service, project types.Project, buildOpts *api.BuildOptions, requestedServiceName string, ignoreOrphans bool) error { dependencies := types.Services{} var requestedService types.ServiceConfig for _, service := range project.Services { @@ -278,6 +308,7 @@ func startDependencies(ctx context.Context, backend api.Service, project types.P project.Services = dependencies project.DisabledServices = append(project.DisabledServices, requestedService) err := backend.Create(ctx, &project, api.CreateOptions{ + Build: buildOpts, IgnoreOrphans: ignoreOrphans, }) if err != nil { diff --git a/cmd/compose/up.go b/cmd/compose/up.go index 0f49fa7f7..7daf5b3be 100644 --- a/cmd/compose/up.go +++ b/cmd/compose/up.go @@ -23,6 +23,8 @@ import ( "strings" "time" + xprogress "github.com/docker/buildx/util/progress" + "github.com/compose-spec/compose-go/types" "github.com/docker/compose/v2/cmd/formatter" "github.com/spf13/cobra" @@ -74,6 +76,7 @@ func (opts upOptions) apply(project *types.Project, services []string) error { func upCommand(p *ProjectOptions, streams api.Streams, backend api.Service) *cobra.Command { up := upOptions{} create := createOptions{} + build := buildOptions{ProjectOptions: p} upCmd := &cobra.Command{ Use: "up [OPTIONS] [SERVICE...]", Short: "Create and start containers", @@ -90,7 +93,7 @@ func upCommand(p *ProjectOptions, streams api.Streams, backend api.Service) *cob if len(up.attach) != 0 && up.attachDependencies { return errors.New("cannot combine --attach and --attach-dependencies") } - return runUp(ctx, streams, backend, create, up, project, services) + return runUp(ctx, streams, backend, create, up, build, project, services) }), ValidArgsFunction: completeServiceNames(p), } @@ -148,7 +151,16 @@ func validateFlags(up *upOptions, create *createOptions) error { return nil } -func runUp(ctx context.Context, streams api.Streams, backend api.Service, createOptions createOptions, upOptions upOptions, project *types.Project, services []string) error { +func runUp( + ctx context.Context, + streams api.Streams, + backend api.Service, + createOptions createOptions, + upOptions upOptions, + buildOptions buildOptions, + project *types.Project, + services []string, +) error { if len(project.Services) == 0 { return fmt.Errorf("no service selected") } @@ -163,7 +175,26 @@ func runUp(ctx context.Context, streams api.Streams, backend api.Service, create return err } + var build *api.BuildOptions + // this check is technically redundant as createOptions::apply() + // already removed all the build sections + if !createOptions.noBuild { + if createOptions.quietPull { + buildOptions.Progress = xprogress.PrinterModeQuiet + } + // BuildOptions here is nested inside CreateOptions, so + // no service list is passed, it will implicitly pick all + // services being created, which includes any explicitly + // specified via "services" arg here as well as deps + bo, err := buildOptions.toAPIBuildOptions(nil) + if err != nil { + return err + } + build = &bo + } + create := api.CreateOptions{ + Build: build, Services: services, RemoveOrphans: createOptions.removeOrphans, IgnoreOrphans: createOptions.ignoreOrphans, diff --git a/pkg/api/api.go b/pkg/api/api.go index dfe4628c9..b1442eb5e 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -167,6 +167,7 @@ func (o BuildOptions) Apply(project *types.Project) error { // CreateOptions group options of the Create API type CreateOptions struct { + Build *BuildOptions // Services defines the services user interacts with Services []string // Remove legacy containers for services that are not defined in the project @@ -302,6 +303,7 @@ type RemoveOptions struct { // RunOptions group options of the Run API type RunOptions struct { + Build *BuildOptions // Project is the compose project used to define this app. Might be nil if user ran command just with project name Project *types.Project Name string diff --git a/pkg/compose/build.go b/pkg/compose/build.go index 406bb6d9c..7ad944f6e 100644 --- a/pkg/compose/build.go +++ b/pkg/compose/build.go @@ -18,6 +18,7 @@ package compose import ( "context" + "errors" "fmt" "os" "path/filepath" @@ -43,7 +44,6 @@ import ( "github.com/moby/buildkit/session/sshforward/sshprovider" "github.com/moby/buildkit/util/entitlements" specs "github.com/opencontainers/image-spec/specs-go/v1" - "github.com/pkg/errors" "github.com/sirupsen/logrus" // required to get default driver registered @@ -56,13 +56,13 @@ func (s *composeService) Build(ctx context.Context, project *types.Project, opti return err } return progress.RunWithTitle(ctx, func(ctx context.Context) error { - _, err := s.build(ctx, project, options) + _, err := s.build(ctx, project, options, nil) return err }, s.stdinfo(), "Building") } //nolint:gocyclo -func (s *composeService) build(ctx context.Context, project *types.Project, options api.BuildOptions) (map[string]string, error) { +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 @@ -117,6 +117,12 @@ func (s *composeService) build(ctx context.Context, project *types.Project, opti return nil } + image := api.GetImageNameOrDefault(service, project.Name) + _, localImagePresent := localImages[image] + if localImagePresent && service.PullPolicy != types.PullPolicyBuild { + return nil + } + if !buildkitEnabled { id, err := s.doBuildClassic(ctx, project, service, options) if err != nil { @@ -183,7 +189,7 @@ func getServiceIndex(project *types.Project, name string) (types.ServiceConfig, return service, idx } -func (s *composeService) ensureImagesExists(ctx context.Context, project *types.Project, quietPull bool) error { +func (s *composeService) ensureImagesExists(ctx context.Context, project *types.Project, buildOpts *api.BuildOptions, quietPull bool) error { for _, service := range project.Services { if service.Image == "" && service.Build == nil { return fmt.Errorf("invalid service %q. Must specify either image or build", service.Name) @@ -204,22 +210,10 @@ func (s *composeService) ensureImagesExists(ctx context.Context, project *types. return err } - mode := xprogress.PrinterModeAuto - if quietPull { - mode = xprogress.PrinterModeQuiet - } - - buildRequired, err := s.prepareProjectForBuild(project, images) - if err != nil { - return err - } - - if buildRequired { + if buildOpts != nil { err = tracing.SpanWrapFunc("project/build", tracing.ProjectOptions(project), func(ctx context.Context) error { - builtImages, err := s.build(ctx, project, api.BuildOptions{ - Progress: mode, - }) + builtImages, err := s.build(ctx, project, *buildOpts, images) if err != nil { return err } @@ -249,37 +243,6 @@ func (s *composeService) ensureImagesExists(ctx context.Context, project *types. return nil } -func (s *composeService) prepareProjectForBuild(project *types.Project, images map[string]string) (bool, error) { - buildRequired := false - err := api.BuildOptions{}.Apply(project) - if err != nil { - return false, err - } - for i, service := range project.Services { - if service.Build == nil { - continue - } - - image := api.GetImageNameOrDefault(service, project.Name) - _, localImagePresent := images[image] - if localImagePresent && service.PullPolicy != types.PullPolicyBuild { - service.Build = nil - project.Services[i] = service - continue - } - - if service.Platform == "" { - // let builder to build for default platform - service.Build.Platforms = nil - } else { - service.Build.Platforms = []string{service.Platform} - } - project.Services[i] = service - buildRequired = true - } - return buildRequired, nil -} - func (s *composeService) getLocalImagesDigests(ctx context.Context, project *types.Project) (map[string]string, error) { var imageNames []string for _, s := range project.Services { @@ -318,8 +281,10 @@ func (s *composeService) getLocalImagesDigests(ctx context.Context, project *typ Variant: inspect.Variant, } if !platforms.NewMatcher(platform).Match(actual) { - return nil, errors.Errorf("image with reference %s was found but does not match the specified platform: wanted %s, actual: %s", - imgName, platforms.Format(platform), platforms.Format(actual)) + // there is a local image, but it's for the wrong platform, so + // pretend it doesn't exist so that we can pull/build an image + // for the correct platform instead + delete(images, imgName) } } @@ -362,7 +327,7 @@ func resolveAndMergeBuildArgs( } func (s *composeService) toBuildOptions(project *types.Project, service types.ServiceConfig, options api.BuildOptions) (build.Options, error) { - plats, err := addPlatforms(project, service) + plats, err := parsePlatforms(service) if err != nil { return build.Options{}, err } @@ -515,24 +480,6 @@ 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 := useDockerDefaultOrServicePlatform(project, service, false) - if err != nil { - return nil, err - } - - 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 getImageBuildLabels(project *types.Project, service types.ServiceConfig) types.Labels { ret := make(types.Labels) if service.Build != nil { @@ -555,37 +502,25 @@ func toBuildContexts(additionalContexts types.Mapping) map[string]build.NamedCon return namedContexts } -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 len(platformList) > 0 && !utils.StringContains(platformList, platform) { - return nil, fmt.Errorf("the DOCKER_DEFAULT_PLATFORM %q value should be part of the service.build.platforms: %q", platform, platformList) - } - p, err := platforms.Parse(platform) +func parsePlatforms(service types.ServiceConfig) ([]specs.Platform, error) { + if service.Build == nil || len(service.Build.Platforms) == 0 { + return nil, nil + } + + var errs []error + ret := make([]specs.Platform, len(service.Build.Platforms)) + for i := range service.Build.Platforms { + p, err := platforms.Parse(service.Build.Platforms[i]) if err != nil { - return nil, err + errs = append(errs, err) + } else { + ret[i] = p } - plats = append(plats, p) - } - return plats, nil -} - -func useDockerDefaultOrServicePlatform(project *types.Project, service types.ServiceConfig, useOnePlatform bool) ([]specs.Platform, error) { - plats, err := useDockerDefaultPlatform(project, service.Build.Platforms) - if (len(plats) > 0 && useOnePlatform) || err != nil { - return plats, err } - if service.Platform != "" { - if len(service.Build.Platforms) > 0 && !utils.StringContains(service.Build.Platforms, service.Platform) { - return nil, fmt.Errorf("service.platform %q should be part of the service.build.platforms: %q", service.Platform, service.Build.Platforms) - } - // User defined a service platform and no build platforms, so we should keep the one define on the service level - p, err := platforms.Parse(service.Platform) - if !utils.Contains(plats, p) { - plats = append(plats, p) - } - return plats, err + if err := errors.Join(errs...); err != nil { + return nil, err } - return plats, nil + + return ret, nil } diff --git a/pkg/compose/build_test.go b/pkg/compose/build_test.go deleted file mode 100644 index d71c710f0..000000000 --- a/pkg/compose/build_test.go +++ /dev/null @@ -1,121 +0,0 @@ -/* - 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 ( - "testing" - - "github.com/compose-spec/compose-go/types" - "gotest.tools/v3/assert" -) - -func TestPrepareProjectForBuild(t *testing.T) { - t.Run("build service platform", func(t *testing.T) { - project := types.Project{ - Services: []types.ServiceConfig{ - { - Name: "test", - Image: "foo", - Build: &types.BuildConfig{ - Context: ".", - Platforms: []string{ - "linux/amd64", - "linux/arm64", - "alice/32", - }, - }, - Platform: "alice/32", - }, - }, - } - - s := &composeService{} - _, err := s.prepareProjectForBuild(&project, nil) - assert.NilError(t, err) - assert.DeepEqual(t, project.Services[0].Build.Platforms, types.StringList{"alice/32"}) - }) - - t.Run("build DOCKER_DEFAULT_PLATFORM", func(t *testing.T) { - project := types.Project{ - Environment: map[string]string{ - "DOCKER_DEFAULT_PLATFORM": "linux/amd64", - }, - Services: []types.ServiceConfig{ - { - Name: "test", - Image: "foo", - Build: &types.BuildConfig{ - Context: ".", - Platforms: []string{ - "linux/amd64", - "linux/arm64", - }, - }, - }, - }, - } - - s := &composeService{} - _, err := s.prepareProjectForBuild(&project, nil) - assert.NilError(t, err) - assert.DeepEqual(t, project.Services[0].Build.Platforms, types.StringList{"linux/amd64"}) - }) - - t.Run("skip existing image", func(t *testing.T) { - project := types.Project{ - Services: []types.ServiceConfig{ - { - Name: "test", - Image: "foo", - Build: &types.BuildConfig{ - Context: ".", - }, - }, - }, - } - - s := &composeService{} - _, err := s.prepareProjectForBuild(&project, map[string]string{"foo": "exists"}) - assert.NilError(t, err) - assert.Check(t, project.Services[0].Build == nil) - }) - - t.Run("unsupported build platform", func(t *testing.T) { - project := types.Project{ - Environment: map[string]string{ - "DOCKER_DEFAULT_PLATFORM": "commodore/64", - }, - Services: []types.ServiceConfig{ - { - Name: "test", - Image: "foo", - Build: &types.BuildConfig{ - Context: ".", - Platforms: []string{ - "linux/amd64", - "linux/arm64", - }, - }, - }, - }, - } - - s := &composeService{} - _, err := s.prepareProjectForBuild(&project, nil) - assert.Check(t, err != nil) - }) -} diff --git a/pkg/compose/create.go b/pkg/compose/create.go index 4d35bb704..d905c14b3 100644 --- a/pkg/compose/create.go +++ b/pkg/compose/create.go @@ -62,9 +62,9 @@ type createConfigs struct { Links []string } -func (s *composeService) Create(ctx context.Context, project *types.Project, options api.CreateOptions) error { +func (s *composeService) Create(ctx context.Context, project *types.Project, createOpts api.CreateOptions) error { return progress.RunWithTitle(ctx, func(ctx context.Context) error { - return s.create(ctx, project, options) + return s.create(ctx, project, createOpts) }, s.stdinfo(), "Creating") } @@ -79,7 +79,7 @@ func (s *composeService) create(ctx context.Context, project *types.Project, opt return err } - err = s.ensureImagesExists(ctx, project, options.QuietPull) + err = s.ensureImagesExists(ctx, project, options.Build, options.QuietPull) if err != nil { return err } diff --git a/pkg/compose/run.go b/pkg/compose/run.go index 6924c2061..471af3b01 100644 --- a/pkg/compose/run.go +++ b/pkg/compose/run.go @@ -85,7 +85,7 @@ func (s *composeService) prepareRun(ctx context.Context, project *types.Project, Add(api.SlugLabel, slug). Add(api.OneoffLabel, "True") - if err := s.ensureImagesExists(ctx, project, opts.QuietPull); err != nil { // all dependencies already checked, but might miss service img + if err := s.ensureImagesExists(ctx, project, opts.Build, opts.QuietPull); err != nil { // all dependencies already checked, but might miss service img return "", err } diff --git a/pkg/compose/watch.go b/pkg/compose/watch.go index 7d7ba4c58..f24e7fc12 100644 --- a/pkg/compose/watch.go +++ b/pkg/compose/watch.go @@ -85,10 +85,6 @@ func (s *composeService) getSyncImplementation(project *types.Project) sync.Sync } func (s *composeService) Watch(ctx context.Context, project *types.Project, services []string, _ api.WatchOptions) error { //nolint: gocyclo - _, err := s.prepareProjectForBuild(project, nil) - if err != nil { - return err - } if err := project.ForServices(services); err != nil { return err } @@ -458,6 +454,12 @@ func (s *composeService) handleWatchBatch( ) err := s.Up(ctx, project, api.UpOptions{ Create: api.CreateOptions{ + Build: &api.BuildOptions{ + Pull: false, + Push: false, + // restrict the build to ONLY this service, not any of its dependencies + Services: []string{serviceName}, + }, Services: []string{serviceName}, Inherit: true, },