Browse Source

build: pass BuildOptions around explicitly & fix multi-platform issues

The big change here is to pass around an explicit `*BuildOptions` object
as part of Compose operations like `up` & `run` that may or may not do
builds. If the options object is `nil`, no builds whatsoever will be
attempted.

Motivation is to allow for partial rebuilds in the context of an `up`
for watch. This was broken and tricky to accomplish because various parts
of the Compose APIs mutate the `*Project` for convenience in ways that
make it unusable afterwards. (For example, it might set `service.Build = nil`
because it's not going to build that service right _then_. But we might
still want to build it later!)

NOTE: This commit does not actually touch the watch logic. This is all
      in preparation to make it possible.

As part of this, a bunch of code moved around and I eliminated a bunch
of partially redundant logic, mostly around multi-platform. Several
edge cases have been addressed as part of this:
 * `DOCKER_DEFAULT_PLATFORM` was _overriding_ explicitly set platforms
   in some cases, this is no longer true, and it behaves like the Docker
   CLI now
 * It was possible for Compose to build an image for one platform and
   then try to run it for a different platform (and fail)
 * Errors are no longer returned if a local image exists but for the
   wrong platform - the correct platform will be fetched/built (if
   possible).

Because there's a LOT of subtlety and tricky logic here, I've also tried
to add an excessive amount of explanatory comments.

Signed-off-by: Milas Bowman <[email protected]>
Milas Bowman 2 years ago
parent
commit
1fdbcb6255

+ 6 - 3
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

+ 7 - 7
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(&parallel, "parallel", -1, `Control max parallelism, -1 for unlimited`)
 	c.Flags().BoolVarP(&version, "version", "v", false, "Show the Docker Compose version information")

+ 9 - 0
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 {

+ 76 - 0
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
+}

+ 130 - 0
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`)
+	})
+}

+ 35 - 4
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 {

+ 33 - 2
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,

+ 2 - 0
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

+ 32 - 97
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 err := errors.Join(errs...); err != nil {
+		return nil, 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
-	}
-	return plats, nil
+	return ret, nil
 }

+ 0 - 121
pkg/compose/build_test.go

@@ -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)
-	})
-}

+ 3 - 3
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
 	}

+ 1 - 1
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
 	}
 

+ 6 - 4
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,
 				},