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 <milas.bowman@docker.com>
This commit is contained in:
Milas Bowman 2023-08-30 08:47:09 -04:00 committed by Nicolas De loof
parent 407a0d5b53
commit 1fdbcb6255
13 changed files with 341 additions and 243 deletions

View File

@ -35,7 +35,6 @@ import (
type buildOptions struct { type buildOptions struct {
*ProjectOptions *ProjectOptions
composeOptions
quiet bool quiet bool
pull bool pull bool
push bool push bool
@ -73,7 +72,7 @@ func (opts buildOptions) toAPIBuildOptions(services []string) (api.BuildOptions,
}, nil }, nil
} }
func buildCommand(p *ProjectOptions, progress *string, backend api.Service) *cobra.Command { func buildCommand(p *ProjectOptions, backend api.Service) *cobra.Command {
opts := buildOptions{ opts := buildOptions{
ProjectOptions: p, 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().Bool("no-rm", false, "Do not remove intermediate containers after a successful build. DEPRECATED")
cmd.Flags().MarkHidden("no-rm") //nolint:errcheck 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().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 cmd.Flags().MarkHidden("progress") //nolint:errcheck
return cmd return cmd
@ -130,6 +129,10 @@ func runBuild(ctx context.Context, backend api.Service, opts buildOptions, servi
return err return err
} }
if err := applyPlatforms(project, false); err != nil {
return err
}
apiBuildOptions, err := opts.toAPIBuildOptions(services) apiBuildOptions, err := opts.toAPIBuildOptions(services)
if err != nil { if err != nil {
return err return err

View File

@ -26,8 +26,9 @@ import (
"strings" "strings"
"syscall" "syscall"
"github.com/compose-spec/compose-go/dotenv"
buildx "github.com/docker/buildx/util/progress" buildx "github.com/docker/buildx/util/progress"
"github.com/compose-spec/compose-go/dotenv"
"github.com/docker/cli/cli/command" "github.com/docker/cli/cli/command"
"github.com/docker/compose/v2/pkg/remote" "github.com/docker/compose/v2/pkg/remote"
@ -117,6 +118,7 @@ type ProjectOptions struct {
ProjectDir string ProjectDir string
EnvFiles []string EnvFiles []string
Compatibility bool Compatibility bool
Progress string
} }
// ProjectFunc does stuff within a types.Project // 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.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.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.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") _ = f.MarkHidden("workdir")
} }
@ -294,7 +297,6 @@ func RootCommand(dockerCli command.Cli, backend api.Service) *cobra.Command { //
version bool version bool
parallel int parallel int
dryRun bool dryRun bool
progress string
) )
c := &cobra.Command{ c := &cobra.Command{
Short: "Docker Compose", Short: "Docker Compose",
@ -359,7 +361,7 @@ func RootCommand(dockerCli command.Cli, backend api.Service) *cobra.Command { //
ui.Mode = ui.ModeTTY ui.Mode = ui.ModeTTY
} }
switch progress { switch opts.Progress {
case ui.ModeAuto: case ui.ModeAuto:
ui.Mode = ui.ModeAuto ui.Mode = ui.ModeAuto
case ui.ModeTTY: case ui.ModeTTY:
@ -375,7 +377,7 @@ func RootCommand(dockerCli command.Cli, backend api.Service) *cobra.Command { //
case ui.ModeQuiet, "none": case ui.ModeQuiet, "none":
ui.Mode = ui.ModeQuiet ui.Mode = ui.ModeQuiet
default: default:
return fmt.Errorf("unsupported --progress value %q", progress) return fmt.Errorf("unsupported --progress value %q", opts.Progress)
} }
if opts.WorkDir != "" { if opts.WorkDir != "" {
@ -446,7 +448,7 @@ func RootCommand(dockerCli command.Cli, backend api.Service) *cobra.Command { //
portCommand(&opts, dockerCli, backend), portCommand(&opts, dockerCli, backend),
imagesCommand(&opts, dockerCli, backend), imagesCommand(&opts, dockerCli, backend),
versionCommand(dockerCli), versionCommand(dockerCli),
buildCommand(&opts, &progress, backend), buildCommand(&opts, backend),
pushCommand(&opts, backend), pushCommand(&opts, backend),
pullCommand(&opts, backend), pullCommand(&opts, backend),
createCommand(&opts, backend), createCommand(&opts, backend),
@ -478,8 +480,6 @@ func RootCommand(dockerCli command.Cli, backend api.Service) *cobra.Command { //
completeProfileNames(&opts), 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().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().IntVar(&parallel, "parallel", -1, `Control max parallelism, -1 for unlimited`)
c.Flags().BoolVarP(&version, "version", "v", false, "Show the Docker Compose version information") c.Flags().BoolVarP(&version, "version", "v", false, "Show the Docker Compose version information")

View File

@ -123,6 +123,9 @@ func (opts createOptions) Apply(project *types.Project) error {
project.Services[i] = service 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 { if opts.Build {
for i, service := range project.Services { for i, service := range project.Services {
if service.Build == nil { if service.Build == nil {
@ -132,6 +135,7 @@ func (opts createOptions) Apply(project *types.Project) error {
project.Services[i] = service project.Services[i] = service
} }
} }
// opts.noBuild, however, means do not perform ANY builds
if opts.noBuild { if opts.noBuild {
for i, service := range project.Services { for i, service := range project.Services {
service.Build = nil service.Build = nil
@ -141,6 +145,11 @@ func (opts createOptions) Apply(project *types.Project) error {
project.Services[i] = service project.Services[i] = service
} }
} }
if err := applyPlatforms(project, true); err != nil {
return err
}
for _, scale := range opts.scale { for _, scale := range opts.scale {
split := strings.Split(scale, "=") split := strings.Split(scale, "=")
if len(split) != 2 { if len(split) != 2 {

76
cmd/compose/options.go Normal file
View File

@ -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
cmd/compose/options_test.go Normal file
View File

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

View File

@ -21,6 +21,8 @@ import (
"fmt" "fmt"
"strings" "strings"
xprogress "github.com/docker/buildx/util/progress"
cgo "github.com/compose-spec/compose-go/cli" cgo "github.com/compose-spec/compose-go/cli"
"github.com/compose-spec/compose-go/loader" "github.com/compose-spec/compose-go/loader"
"github.com/compose-spec/compose-go/types" "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), capDrop: opts.NewListOpts(nil),
} }
createOpts := createOptions{} createOpts := createOptions{}
buildOpts := buildOptions{
ProjectOptions: p,
}
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "run [OPTIONS] SERVICE [COMMAND] [ARGS...]", Use: "run [OPTIONS] SERVICE [COMMAND] [ARGS...]",
Short: "Run a one-off command on a service.", 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 return err
} }
if createOpts.quietPull {
buildOpts.Progress = xprogress.PrinterModeQuiet
}
options.ignoreOrphans = utils.StringToBool(project.Environment[ComposeIgnoreOrphans]) 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), ValidArgsFunction: completeServiceNames(p),
} }
@ -197,7 +206,7 @@ func normalizeRunFlags(f *pflag.FlagSet, name string) pflag.NormalizedName {
return pflag.NormalizedName(name) 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) err := options.apply(project)
if err != nil { if err != nil {
return err 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 { 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()) }, streams.Err())
if err != nil { if err != nil {
return err return err
@ -224,8 +242,20 @@ func runRun(ctx context.Context, backend api.Service, project *types.Project, op
labels[parts[0]] = parts[1] 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 // start container and attach to container streams
runOpts := api.RunOptions{ runOpts := api.RunOptions{
Build: buildForRun,
Name: options.name, Name: options.name,
Service: options.Service, Service: options.Service,
Command: options.Command, Command: options.Command,
@ -264,7 +294,7 @@ func runRun(ctx context.Context, backend api.Service, project *types.Project, op
return err 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{} dependencies := types.Services{}
var requestedService types.ServiceConfig var requestedService types.ServiceConfig
for _, service := range project.Services { for _, service := range project.Services {
@ -278,6 +308,7 @@ func startDependencies(ctx context.Context, backend api.Service, project types.P
project.Services = dependencies project.Services = dependencies
project.DisabledServices = append(project.DisabledServices, requestedService) project.DisabledServices = append(project.DisabledServices, requestedService)
err := backend.Create(ctx, &project, api.CreateOptions{ err := backend.Create(ctx, &project, api.CreateOptions{
Build: buildOpts,
IgnoreOrphans: ignoreOrphans, IgnoreOrphans: ignoreOrphans,
}) })
if err != nil { if err != nil {

View File

@ -23,6 +23,8 @@ import (
"strings" "strings"
"time" "time"
xprogress "github.com/docker/buildx/util/progress"
"github.com/compose-spec/compose-go/types" "github.com/compose-spec/compose-go/types"
"github.com/docker/compose/v2/cmd/formatter" "github.com/docker/compose/v2/cmd/formatter"
"github.com/spf13/cobra" "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 { func upCommand(p *ProjectOptions, streams api.Streams, backend api.Service) *cobra.Command {
up := upOptions{} up := upOptions{}
create := createOptions{} create := createOptions{}
build := buildOptions{ProjectOptions: p}
upCmd := &cobra.Command{ upCmd := &cobra.Command{
Use: "up [OPTIONS] [SERVICE...]", Use: "up [OPTIONS] [SERVICE...]",
Short: "Create and start containers", 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 { if len(up.attach) != 0 && up.attachDependencies {
return errors.New("cannot combine --attach and --attach-dependencies") 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), ValidArgsFunction: completeServiceNames(p),
} }
@ -148,7 +151,16 @@ func validateFlags(up *upOptions, create *createOptions) error {
return nil 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 { if len(project.Services) == 0 {
return fmt.Errorf("no service selected") return fmt.Errorf("no service selected")
} }
@ -163,7 +175,26 @@ func runUp(ctx context.Context, streams api.Streams, backend api.Service, create
return err 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{ create := api.CreateOptions{
Build: build,
Services: services, Services: services,
RemoveOrphans: createOptions.removeOrphans, RemoveOrphans: createOptions.removeOrphans,
IgnoreOrphans: createOptions.ignoreOrphans, IgnoreOrphans: createOptions.ignoreOrphans,

View File

@ -167,6 +167,7 @@ func (o BuildOptions) Apply(project *types.Project) error {
// CreateOptions group options of the Create API // CreateOptions group options of the Create API
type CreateOptions struct { type CreateOptions struct {
Build *BuildOptions
// Services defines the services user interacts with // Services defines the services user interacts with
Services []string Services []string
// Remove legacy containers for services that are not defined in the project // 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 // RunOptions group options of the Run API
type RunOptions struct { 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 is the compose project used to define this app. Might be nil if user ran command just with project name
Project *types.Project Project *types.Project
Name string Name string

View File

@ -18,6 +18,7 @@ package compose
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
@ -43,7 +44,6 @@ import (
"github.com/moby/buildkit/session/sshforward/sshprovider" "github.com/moby/buildkit/session/sshforward/sshprovider"
"github.com/moby/buildkit/util/entitlements" "github.com/moby/buildkit/util/entitlements"
specs "github.com/opencontainers/image-spec/specs-go/v1" specs "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/pkg/errors"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
// required to get default driver registered // required to get default driver registered
@ -56,13 +56,13 @@ func (s *composeService) Build(ctx context.Context, project *types.Project, opti
return err return err
} }
return progress.RunWithTitle(ctx, func(ctx context.Context) error { return progress.RunWithTitle(ctx, func(ctx context.Context) error {
_, err := s.build(ctx, project, options) _, err := s.build(ctx, project, options, nil)
return err return err
}, s.stdinfo(), "Building") }, s.stdinfo(), "Building")
} }
//nolint:gocyclo //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() buildkitEnabled, err := s.dockerCli.BuildKitEnabled()
if err != nil { if err != nil {
return nil, err return nil, err
@ -117,6 +117,12 @@ func (s *composeService) build(ctx context.Context, project *types.Project, opti
return nil return nil
} }
image := api.GetImageNameOrDefault(service, project.Name)
_, localImagePresent := localImages[image]
if localImagePresent && service.PullPolicy != types.PullPolicyBuild {
return nil
}
if !buildkitEnabled { if !buildkitEnabled {
id, err := s.doBuildClassic(ctx, project, service, options) id, err := s.doBuildClassic(ctx, project, service, options)
if err != nil { if err != nil {
@ -183,7 +189,7 @@ func getServiceIndex(project *types.Project, name string) (types.ServiceConfig,
return service, idx 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 { for _, service := range project.Services {
if service.Image == "" && service.Build == nil { if service.Image == "" && service.Build == nil {
return fmt.Errorf("invalid service %q. Must specify either image or build", service.Name) 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 return err
} }
mode := xprogress.PrinterModeAuto if buildOpts != nil {
if quietPull {
mode = xprogress.PrinterModeQuiet
}
buildRequired, err := s.prepareProjectForBuild(project, images)
if err != nil {
return err
}
if buildRequired {
err = tracing.SpanWrapFunc("project/build", tracing.ProjectOptions(project), err = tracing.SpanWrapFunc("project/build", tracing.ProjectOptions(project),
func(ctx context.Context) error { func(ctx context.Context) error {
builtImages, err := s.build(ctx, project, api.BuildOptions{ builtImages, err := s.build(ctx, project, *buildOpts, images)
Progress: mode,
})
if err != nil { if err != nil {
return err return err
} }
@ -249,37 +243,6 @@ func (s *composeService) ensureImagesExists(ctx context.Context, project *types.
return nil 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) { func (s *composeService) getLocalImagesDigests(ctx context.Context, project *types.Project) (map[string]string, error) {
var imageNames []string var imageNames []string
for _, s := range project.Services { for _, s := range project.Services {
@ -318,8 +281,10 @@ func (s *composeService) getLocalImagesDigests(ctx context.Context, project *typ
Variant: inspect.Variant, Variant: inspect.Variant,
} }
if !platforms.NewMatcher(platform).Match(actual) { 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", // there is a local image, but it's for the wrong platform, so
imgName, platforms.Format(platform), platforms.Format(actual)) // 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) { 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 { if err != nil {
return build.Options{}, err return build.Options{}, err
} }
@ -515,24 +480,6 @@ func addSecretsConfig(project *types.Project, service types.ServiceConfig) (sess
return secretsprovider.NewSecretProvider(store), nil 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 { func getImageBuildLabels(project *types.Project, service types.ServiceConfig) types.Labels {
ret := make(types.Labels) ret := make(types.Labels)
if service.Build != nil { if service.Build != nil {
@ -555,37 +502,25 @@ func toBuildContexts(additionalContexts types.Mapping) map[string]build.NamedCon
return namedContexts return namedContexts
} }
func useDockerDefaultPlatform(project *types.Project, platformList types.StringList) ([]specs.Platform, error) { func parsePlatforms(service types.ServiceConfig) ([]specs.Platform, error) {
var plats []specs.Platform if service.Build == nil || len(service.Build.Platforms) == 0 {
if platform, ok := project.Environment["DOCKER_DEFAULT_PLATFORM"]; ok { return nil, nil
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)
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 { if err != nil {
errs = append(errs, err)
} else {
ret[i] = p
}
}
if err := errors.Join(errs...); err != nil {
return nil, err return nil, err
} }
plats = append(plats, p)
}
return plats, nil
}
func useDockerDefaultOrServicePlatform(project *types.Project, service types.ServiceConfig, useOnePlatform bool) ([]specs.Platform, error) { return ret, nil
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
}
return plats, nil
} }

View File

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

View File

@ -62,9 +62,9 @@ type createConfigs struct {
Links []string 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 progress.RunWithTitle(ctx, func(ctx context.Context) error {
return s.create(ctx, project, options) return s.create(ctx, project, createOpts)
}, s.stdinfo(), "Creating") }, s.stdinfo(), "Creating")
} }
@ -79,7 +79,7 @@ func (s *composeService) create(ctx context.Context, project *types.Project, opt
return err return err
} }
err = s.ensureImagesExists(ctx, project, options.QuietPull) err = s.ensureImagesExists(ctx, project, options.Build, options.QuietPull)
if err != nil { if err != nil {
return err return err
} }

View File

@ -85,7 +85,7 @@ func (s *composeService) prepareRun(ctx context.Context, project *types.Project,
Add(api.SlugLabel, slug). Add(api.SlugLabel, slug).
Add(api.OneoffLabel, "True") 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 return "", err
} }

View File

@ -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 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 { if err := project.ForServices(services); err != nil {
return err return err
} }
@ -458,6 +454,12 @@ func (s *composeService) handleWatchBatch(
) )
err := s.Up(ctx, project, api.UpOptions{ err := s.Up(ctx, project, api.UpOptions{
Create: api.CreateOptions{ 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}, Services: []string{serviceName},
Inherit: true, Inherit: true,
}, },