From 612a96971f49cfd306b0aea23f7045817466fb45 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Fri, 17 Apr 2026 13:36:56 +0200 Subject: [PATCH 1/9] support jobs as run target Signed-off-by: Nicolas De Loof --- cmd/compose/compose_test.go | 12 ++- cmd/compose/config.go | 8 +- cmd/compose/options_test.go | 52 +++++++------ cmd/compose/pullOptions_test.go | 22 ++++-- cmd/compose/run.go | 88 +++++++++++++++------- cmd/compose/run_test.go | 69 +++++++++++++++++ go.mod | 2 + go.sum | 4 +- pkg/api/api.go | 9 +++ pkg/compose/build_test.go | 26 ++++--- pkg/compose/compose.go | 8 +- pkg/compose/convergence.go | 40 +++++----- pkg/compose/convergence_test.go | 40 ++++++---- pkg/compose/create_test.go | 100 ++++++++++++++----------- pkg/compose/dependencies_test.go | 80 +++++++++++++------- pkg/compose/down_test.go | 8 +- pkg/compose/generate.go | 8 +- pkg/compose/hash_test.go | 6 +- pkg/compose/hook_test.go | 4 +- pkg/compose/publish.go | 4 +- pkg/compose/publish_test.go | 26 ++++--- pkg/compose/pull.go | 6 +- pkg/compose/run.go | 44 +++++++++-- pkg/compose/run_test.go | 125 +++++++++++++++++++++++++++++++ pkg/compose/viz_test.go | 98 +++++++++++++----------- 25 files changed, 629 insertions(+), 260 deletions(-) create mode 100644 cmd/compose/run_test.go create mode 100644 pkg/compose/run_test.go diff --git a/cmd/compose/compose_test.go b/cmd/compose/compose_test.go index 708929ff8cd..82a69b7a588 100644 --- a/cmd/compose/compose_test.go +++ b/cmd/compose/compose_test.go @@ -27,13 +27,17 @@ func TestFilterServices(t *testing.T) { p := &types.Project{ Services: types.Services{ "foo": { - Name: "foo", - Links: []string{"bar"}, + Name: "foo", + ContainerSpec: types.ContainerSpec{ + Links: []string{"bar"}, + }, }, "bar": { Name: "bar", - DependsOn: map[string]types.ServiceDependency{ - "zot": {}, + ContainerSpec: types.ContainerSpec{ + DependsOn: map[string]types.ServiceDependency{ + "zot": {}, + }, }, }, "zot": { diff --git a/cmd/compose/config.go b/cmd/compose/config.go index 646ecd81806..0d423af804b 100644 --- a/cmd/compose/config.go +++ b/cmd/compose/config.go @@ -257,7 +257,9 @@ func imagesOnly(project *types.Project) *types.Project { digests := types.Services{} for name, config := range project.Services { digests[name] = types.ServiceConfig{ - Image: config.Image, + ContainerSpec: types.ContainerSpec{ + Image: config.Image, + }, } } project = &types.Project{Services: digests} @@ -308,7 +310,9 @@ func resolveImageDigests(ctx context.Context, dockerCli command.Cli, model map[s service := s.(map[string]any) if image, ok := service["image"]; ok { p.Services[name] = types.ServiceConfig{ - Image: image.(string), + ContainerSpec: types.ContainerSpec{ + Image: image.(string), + }, } } } diff --git a/cmd/compose/options_test.go b/cmd/compose/options_test.go index b681c221378..75e11f16a73 100644 --- a/cmd/compose/options_test.go +++ b/cmd/compose/options_test.go @@ -38,17 +38,19 @@ func TestApplyPlatforms_InferFromRuntime(t *testing.T) { return &types.Project{ Services: types.Services{ "test": { - Name: "test", - Image: "foo", - Build: &types.BuildConfig{ - Context: ".", - Platforms: []string{ - "linux/amd64", - "linux/arm64", - "alice/32", + Name: "test", + ContainerSpec: types.ContainerSpec{ + Image: "foo", + Build: &types.BuildConfig{ + Context: ".", + Platforms: []string{ + "linux/amd64", + "linux/arm64", + "alice/32", + }, }, + Platform: "alice/32", }, - Platform: "alice/32", }, }, } @@ -75,13 +77,15 @@ func TestApplyPlatforms_DockerDefaultPlatform(t *testing.T) { }, Services: types.Services{ "test": { - Name: "test", - Image: "foo", - Build: &types.BuildConfig{ - Context: ".", - Platforms: []string{ - "linux/amd64", - "linux/arm64", + Name: "test", + ContainerSpec: types.ContainerSpec{ + Image: "foo", + Build: &types.BuildConfig{ + Context: ".", + Platforms: []string{ + "linux/amd64", + "linux/arm64", + }, }, }, }, @@ -110,13 +114,15 @@ func TestApplyPlatforms_UnsupportedPlatform(t *testing.T) { }, Services: types.Services{ "test": { - Name: "test", - Image: "foo", - Build: &types.BuildConfig{ - Context: ".", - Platforms: []string{ - "linux/amd64", - "linux/arm64", + Name: "test", + ContainerSpec: types.ContainerSpec{ + Image: "foo", + Build: &types.BuildConfig{ + Context: ".", + Platforms: []string{ + "linux/amd64", + "linux/arm64", + }, }, }, }, diff --git a/cmd/compose/pullOptions_test.go b/cmd/compose/pullOptions_test.go index 05dd868edf7..27b51af66a4 100644 --- a/cmd/compose/pullOptions_test.go +++ b/cmd/compose/pullOptions_test.go @@ -29,20 +29,26 @@ func TestApplyPullOptions(t *testing.T) { "must-build": { Name: "must-build", // No image, local build only - Build: &types.BuildConfig{ - Context: ".", + ContainerSpec: types.ContainerSpec{ + Build: &types.BuildConfig{ + Context: ".", + }, }, }, "has-build": { - Name: "has-build", - Image: "registry.example.com/myservice", - Build: &types.BuildConfig{ - Context: ".", + Name: "has-build", + ContainerSpec: types.ContainerSpec{ + Image: "registry.example.com/myservice", + Build: &types.BuildConfig{ + Context: ".", + }, }, }, "must-pull": { - Name: "must-pull", - Image: "registry.example.com/another-service", + Name: "must-pull", + ContainerSpec: types.ContainerSpec{ + Image: "registry.example.com/another-service", + }, }, }, } diff --git a/cmd/compose/run.go b/cmd/compose/run.go index 573c0e14d4f..ec1a44aa034 100644 --- a/cmd/compose/run.go +++ b/cmd/compose/run.go @@ -43,7 +43,7 @@ import ( type runOptions struct { *composeOptions - Service string + ServiceOrJob string Command []string environment []string envFiles []string @@ -70,16 +70,25 @@ type runOptions struct { quietPull bool } -func (options runOptions) apply(project *types.Project) (*types.Project, error) { +func (options runOptions) apply(project *types.Project, isJob bool) (*types.Project, error) { if options.noDeps { var err error - project, err = project.WithSelectedServices([]string{options.Service}, types.IgnoreDependencies) + if isJob { + project, err = project.WithSelectedJob(options.ServiceOrJob, types.IgnoreDependencies) + } else { + project, err = project.WithSelectedServices([]string{options.ServiceOrJob}, types.IgnoreDependencies) + } if err != nil { return nil, err } } - target, err := project.GetService(options.Service) + if isJob { + // Jobs are not in project.Services, nothing more to apply + return project, nil + } + + target, err := project.GetService(options.ServiceOrJob) if err != nil { return nil, err } @@ -111,7 +120,7 @@ func (options runOptions) apply(project *types.Project) (*types.Project, error) } for name := range project.Services { - if name == options.Service { + if name == options.ServiceOrJob { project.Services[name] = target break } @@ -159,11 +168,11 @@ func runCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *Backen var ttyFlag bool cmd := &cobra.Command{ - Use: "run [OPTIONS] SERVICE [COMMAND] [ARGS...]", - Short: "Run a one-off command on a service", + Use: "run [OPTIONS] SERVICE|JOB [COMMAND] [ARGS...]", + Short: "Run a one-off command on a service or job", Args: cobra.MinimumNArgs(1), PreRunE: AdaptCmd(func(ctx context.Context, cmd *cobra.Command, args []string) error { - options.Service = args[0] + options.ServiceOrJob = args[0] if len(args) > 1 { options.Command = args[1:] } @@ -177,18 +186,8 @@ func runCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *Backen } options.entrypointCmd = command } - if cmd.Flags().Changed("tty") { - if cmd.Flags().Changed("no-TTY") { - return fmt.Errorf("--tty and --no-TTY can't be used together") - } else { - options.noTty = !ttyFlag - } - } else if !cmd.Flags().Changed("no-TTY") && !cmd.Flags().Changed("interactive") && !dockerCli.In().IsTerminal() { - // while `docker run` requires explicit `-it` flags, Compose enables interactive mode and TTY by default - // but when compose is used from a script that has stdin piped from another command, we just can't - // Here, we detect we run "by default" (user didn't passed explicit flags) and disable TTY allocation if - // we don't have an actual terminal to attach to for interactive mode - options.noTty = true + if err := resolveTTYFlag(cmd, &options, ttyFlag, dockerCli.In().IsTerminal()); err != nil { + return err } if options.quiet { @@ -204,7 +203,17 @@ func runCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *Backen return err } - project, _, err := p.ToProject(ctx, dockerCli, backend, []string{options.Service}, composecli.WithoutEnvironmentResolution) + project, _, err := p.ToProject(ctx, dockerCli, backend, nil, composecli.WithoutEnvironmentResolution) + if err != nil { + return err + } + + isJob := isJobName(project, options.ServiceOrJob) + if isJob { + project, err = project.WithSelectedJob(options.ServiceOrJob) + } else { + project, err = project.WithSelectedServices([]string{options.ServiceOrJob}) + } if err != nil { return err } @@ -219,7 +228,7 @@ func runCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *Backen } options.ignoreOrphans = utils.StringToBool(project.Environment[ComposeIgnoreOrphans]) - return runRun(ctx, backend, project, options, createOpts, buildOpts, dockerCli) + return runRun(ctx, backend, project, options, createOpts, buildOpts, dockerCli, isJob) }), ValidArgsFunction: completeServiceNames(dockerCli, p), } @@ -257,7 +266,19 @@ func runCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *Backen return cmd } -func normalizeRunFlags(f *pflag.FlagSet, name string) pflag.NormalizedName { +func resolveTTYFlag(cmd *cobra.Command, options *runOptions, ttyFlag bool, isTerminal bool) error { + if cmd.Flags().Changed("tty") { + if cmd.Flags().Changed("no-TTY") { + return fmt.Errorf("--tty and --no-TTY can't be used together") + } + options.noTty = !ttyFlag + } else if !cmd.Flags().Changed("no-TTY") && !cmd.Flags().Changed("interactive") && !isTerminal { + options.noTty = true + } + return nil +} + +func normalizeRunFlags(_ *pflag.FlagSet, name string) pflag.NormalizedName { switch name { case "volumes": name = "volume" @@ -267,8 +288,8 @@ func normalizeRunFlags(f *pflag.FlagSet, name string) pflag.NormalizedName { return pflag.NormalizedName(name) } -func runRun(ctx context.Context, backend api.Compose, project *types.Project, options runOptions, createOpts createOptions, buildOpts buildOptions, dockerCli command.Cli) error { - project, err := options.apply(project) +func runRun(ctx context.Context, backend api.Compose, project *types.Project, options runOptions, createOpts createOptions, buildOpts buildOptions, dockerCli command.Cli, isJob bool) error { + project, err := options.apply(project, isJob) if err != nil { return err } @@ -314,7 +335,6 @@ func runRun(ctx context.Context, backend api.Compose, project *types.Project, op QuietPull: options.quietPull, }, Name: options.name, - Service: options.Service, Command: options.Command, Detach: options.Detach, AutoRemove: options.Remove, @@ -331,9 +351,14 @@ func runRun(ctx context.Context, backend api.Compose, project *types.Project, op NoDeps: options.noDeps, Index: 0, } + if isJob { + runOpts.Job = options.ServiceOrJob + } else { + runOpts.Service = options.ServiceOrJob + } for name, service := range project.Services { - if name == options.Service { + if name == options.ServiceOrJob { service.StdinOpen = options.interactive project.Services[name] = service } @@ -349,3 +374,12 @@ func runRun(ctx context.Context, backend api.Compose, project *types.Project, op } return err } + +// isJobName returns true if name refers to a job in the project (and not a service). +func isJobName(project *types.Project, name string) bool { + if _, ok := project.Services[name]; ok { + return false + } + _, ok := project.Jobs[name] + return ok +} diff --git a/cmd/compose/run_test.go b/cmd/compose/run_test.go new file mode 100644 index 00000000000..7a2c19a1e71 --- /dev/null +++ b/cmd/compose/run_test.go @@ -0,0 +1,69 @@ +/* + 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/v2/types" + "gotest.tools/v3/assert" +) + +func TestIsJobName_Service(t *testing.T) { + project := &types.Project{ + Services: types.Services{ + "web": {Name: "web"}, + }, + Jobs: types.Jobs{ + "migrate": {Name: "migrate"}, + }, + } + assert.Assert(t, !isJobName(project, "web")) +} + +func TestIsJobName_Job(t *testing.T) { + project := &types.Project{ + Services: types.Services{ + "web": {Name: "web"}, + }, + Jobs: types.Jobs{ + "migrate": {Name: "migrate"}, + }, + } + assert.Assert(t, isJobName(project, "migrate")) +} + +func TestIsJobName_Neither(t *testing.T) { + project := &types.Project{ + Services: types.Services{}, + Jobs: types.Jobs{}, + } + assert.Assert(t, !isJobName(project, "nonexistent")) +} + +func TestIsJobName_ServiceTakesPrecedence(t *testing.T) { + // If a name exists as both service and job, service wins + project := &types.Project{ + Services: types.Services{ + "ambiguous": {Name: "ambiguous"}, + }, + Jobs: types.Jobs{ + "ambiguous": {Name: "ambiguous"}, + }, + } + assert.Assert(t, !isJobName(project, "ambiguous")) +} diff --git a/go.mod b/go.mod index bdc2e214373..f87ac0e5015 100644 --- a/go.mod +++ b/go.mod @@ -147,6 +147,8 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect ) +replace github.com/compose-spec/compose-go/v2 => /Users/nicolas/go/src/github.com/compose-spec/compose-go + exclude ( // FIXME(thaJeztah): remove this once kubernetes updated their dependencies to no longer need this. // diff --git a/go.sum b/go.sum index 92dec19e0b4..1097cadc4fa 100644 --- a/go.sum +++ b/go.sum @@ -36,8 +36,6 @@ github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUoc7Ik9EfrFqcylYqgPZ9ANSbTAntnE= github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4= -github.com/compose-spec/compose-go/v2 v2.10.2 h1:USa1NUbDcl/cjb8T9iwnuFsnO79H+2ho2L5SjFKz3uI= -github.com/compose-spec/compose-go/v2 v2.10.2/go.mod h1:ZU6zlcweCZKyiB7BVfCizQT9XmkEIMFE+PRZydVcsZg= github.com/containerd/cgroups/v3 v3.1.3 h1:eUNflyMddm18+yrDmZPn3jI7C5hJ9ahABE5q6dyLYXQ= github.com/containerd/cgroups/v3 v3.1.3/go.mod h1:PKZ2AcWmSBsY/tJUVhtS/rluX0b1uq1GmPO1ElCmbOw= github.com/containerd/console v1.0.5 h1:R0ymNeydRqH2DmakFNdmjR2k0t7UPuiOV/N/27/qqsc= @@ -274,6 +272,8 @@ github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ= github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/ndeloof/compose-go/v2 v2.0.1-0.20260417075248-f37d49c3e808 h1:j9xyZ+njAa42UU2nW8d9tGAtg/Sh/v/t1nG7heRytWw= +github.com/ndeloof/compose-go/v2 v2.0.1-0.20260417075248-f37d49c3e808/go.mod h1:ZU6zlcweCZKyiB7BVfCizQT9XmkEIMFE+PRZydVcsZg= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= diff --git a/pkg/api/api.go b/pkg/api/api.go index 1e84cca2bf7..c25cdfdc3e9 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -419,6 +419,7 @@ type RunOptions struct { Project *types.Project Name string Service string + Job string Command []string Entrypoint []string Detach bool @@ -438,6 +439,14 @@ type RunOptions struct { Index int } +// TargetName returns the effective name of the run target (service or job). +func (o RunOptions) TargetName() string { + if o.Job != "" { + return o.Job + } + return o.Service +} + // AttachOptions group options of the Attach API type AttachOptions struct { Project *types.Project diff --git a/pkg/compose/build_test.go b/pkg/compose/build_test.go index 48f41464b1a..2be96e289a7 100644 --- a/pkg/compose/build_test.go +++ b/pkg/compose/build_test.go @@ -79,25 +79,33 @@ func Test_dockerFilePath(t *testing.T) { func Test_addBuildDependencies(t *testing.T) { project := &types.Project{Services: types.Services{ "test": types.ServiceConfig{ - Build: &types.BuildConfig{ - AdditionalContexts: map[string]string{ - "foo": "service:foo", - "bar": "service:bar", + ContainerSpec: types.ContainerSpec{ + Build: &types.BuildConfig{ + AdditionalContexts: map[string]string{ + "foo": "service:foo", + "bar": "service:bar", + }, }, }, }, "foo": types.ServiceConfig{ - Build: &types.BuildConfig{ - AdditionalContexts: map[string]string{ - "zot": "service:zot", + ContainerSpec: types.ContainerSpec{ + Build: &types.BuildConfig{ + AdditionalContexts: map[string]string{ + "zot": "service:zot", + }, }, }, }, "bar": types.ServiceConfig{ - Build: &types.BuildConfig{}, + ContainerSpec: types.ContainerSpec{ + Build: &types.BuildConfig{}, + }, }, "zot": types.ServiceConfig{ - Build: &types.BuildConfig{}, + ContainerSpec: types.ContainerSpec{ + Build: &types.BuildConfig{}, + }, }, }} diff --git a/pkg/compose/compose.go b/pkg/compose/compose.go index 33ed81af9b8..cad3a960174 100644 --- a/pkg/compose/compose.go +++ b/pkg/compose/compose.go @@ -373,9 +373,11 @@ func (s *composeService) projectFromName(containers Containers, projectName stri service, ok := set[serviceLabel] if !ok { service = types.ServiceConfig{ - Name: serviceLabel, - Image: c.Image, - Labels: c.Labels, + Name: serviceLabel, + ContainerSpec: types.ContainerSpec{ + Image: c.Image, + Labels: c.Labels, + }, } } service.Scale = increment(service.Scale) diff --git a/pkg/compose/convergence.go b/pkg/compose/convergence.go index 609f8039490..5b489b6bcd8 100644 --- a/pkg/compose/convergence.go +++ b/pkg/compose/convergence.go @@ -130,7 +130,7 @@ func (c *convergence) ensureService(ctx context.Context, project *types.Project, eg, ctx := errgroup.WithContext(ctx) - err = c.resolveServiceReferences(&service) + err = c.resolveContainerReferences(&service.ContainerSpec) if err != nil { return err } @@ -268,66 +268,66 @@ func getScale(config types.ServiceConfig) (int, error) { return scale, nil } -// resolveServiceReferences replaces reference to another service with reference to an actual container -func (c *convergence) resolveServiceReferences(service *types.ServiceConfig) error { - err := c.resolveVolumeFrom(service) +// resolveContainerReferences replaces reference to another service with reference to an actual container +func (c *convergence) resolveContainerReferences(spec *types.ContainerSpec) error { + err := c.resolveVolumeFrom(spec) if err != nil { return err } - err = c.resolveSharedNamespaces(service) + err = c.resolveSharedNamespaces(spec) if err != nil { return err } return nil } -func (c *convergence) resolveVolumeFrom(service *types.ServiceConfig) error { - for i, vol := range service.VolumesFrom { - spec := strings.Split(vol, ":") - if len(spec) == 0 { +func (c *convergence) resolveVolumeFrom(spec *types.ContainerSpec) error { + for i, vol := range spec.VolumesFrom { + parts := strings.Split(vol, ":") + if len(parts) == 0 { continue } - if spec[0] == "container" { - service.VolumesFrom[i] = spec[1] + if parts[0] == "container" { + spec.VolumesFrom[i] = parts[1] continue } - name := spec[0] + name := parts[0] dependencies := c.getObservedState(name) if len(dependencies) == 0 { return fmt.Errorf("cannot share volume with service %s: container missing", name) } - service.VolumesFrom[i] = dependencies.sorted()[0].ID + spec.VolumesFrom[i] = dependencies.sorted()[0].ID } return nil } -func (c *convergence) resolveSharedNamespaces(service *types.ServiceConfig) error { - str := service.NetworkMode +func (c *convergence) resolveSharedNamespaces(spec *types.ContainerSpec) error { + str := spec.NetworkMode if name := getDependentServiceFromMode(str); name != "" { dependencies := c.getObservedState(name) if len(dependencies) == 0 { return fmt.Errorf("cannot share network namespace with service %s: container missing", name) } - service.NetworkMode = types.ContainerPrefix + dependencies.sorted()[0].ID + spec.NetworkMode = types.ContainerPrefix + dependencies.sorted()[0].ID } - str = service.Ipc + str = spec.Ipc if name := getDependentServiceFromMode(str); name != "" { dependencies := c.getObservedState(name) if len(dependencies) == 0 { return fmt.Errorf("cannot share IPC namespace with service %s: container missing", name) } - service.Ipc = types.ContainerPrefix + dependencies.sorted()[0].ID + spec.Ipc = types.ContainerPrefix + dependencies.sorted()[0].ID } - str = service.Pid + str = spec.Pid if name := getDependentServiceFromMode(str); name != "" { dependencies := c.getObservedState(name) if len(dependencies) == 0 { return fmt.Errorf("cannot share PID namespace with service %s: container missing", name) } - service.Pid = types.ContainerPrefix + dependencies.sorted()[0].ID + spec.Pid = types.ContainerPrefix + dependencies.sorted()[0].ID } return nil diff --git a/pkg/compose/convergence_test.go b/pkg/compose/convergence_test.go index 2f7c31cf81c..0635e85a82e 100644 --- a/pkg/compose/convergence_test.go +++ b/pkg/compose/convergence_test.go @@ -38,10 +38,12 @@ import ( func TestContainerName(t *testing.T) { s := types.ServiceConfig{ - Name: "testservicename", - ContainerName: "testcontainername", - Scale: intPtr(1), - Deploy: &types.DeployConfig{}, + Name: "testservicename", + Scale: intPtr(1), + Deploy: &types.DeployConfig{}, + ContainerSpec: types.ContainerSpec{ + ContainerName: "testcontainername", + }, } ret, err := getScale(s) assert.NilError(t, err) @@ -418,12 +420,14 @@ func TestCreateMobyContainer(t *testing.T) { service := types.ServiceConfig{ Name: "test", - Networks: map[string]*types.ServiceNetworkConfig{ - "a": { - Priority: 10, - }, - "b": { - Priority: 100, + ContainerSpec: types.ContainerSpec{ + Networks: map[string]*types.ServiceNetworkConfig{ + "a": { + Priority: 10, + }, + "b": { + Priority: 100, + }, }, }, } @@ -517,9 +521,11 @@ func TestCreateMobyContainerLegacyAPI(t *testing.T) { service := types.ServiceConfig{ Name: "test", - Networks: map[string]*types.ServiceNetworkConfig{ - "a": {Priority: 10}, - "b": {Priority: 100}, + ContainerSpec: types.ContainerSpec{ + Networks: map[string]*types.ServiceNetworkConfig{ + "a": {Priority: 10}, + "b": {Priority: 100}, + }, }, } project := types.Project{ @@ -606,9 +612,11 @@ func TestCreateMobyContainerLegacyAPI_NetworkConnectFailure(t *testing.T) { service := types.ServiceConfig{ Name: "test", - Networks: map[string]*types.ServiceNetworkConfig{ - "a": {Priority: 10}, - "b": {Priority: 100}, + ContainerSpec: types.ContainerSpec{ + Networks: map[string]*types.ServiceNetworkConfig{ + "a": {Priority: 10}, + "b": {Priority: 100}, + }, }, } project := types.Project{ diff --git a/pkg/compose/create_test.go b/pkg/compose/create_test.go index e08e4227dae..fecbfd495c5 100644 --- a/pkg/compose/create_test.go +++ b/pkg/compose/create_test.go @@ -86,7 +86,7 @@ func TestBuildVolumeMount(t *testing.T) { } func TestServiceImageName(t *testing.T) { - assert.Equal(t, api.GetImageNameOrDefault(composetypes.ServiceConfig{Image: "myImage"}, "myProject"), "myImage") + assert.Equal(t, api.GetImageNameOrDefault(composetypes.ServiceConfig{ContainerSpec: composetypes.ContainerSpec{Image: "myImage"}}, "myProject"), "myImage") assert.Equal(t, api.GetImageNameOrDefault(composetypes.ServiceConfig{Name: "aService"}, "myProject"), "myProject-aService") } @@ -109,27 +109,29 @@ func TestBuildContainerMountOptions(t *testing.T) { Services: composetypes.Services{ "myService": { Name: "myService", - Volumes: []composetypes.ServiceVolumeConfig{ - { - Type: composetypes.VolumeTypeVolume, - Target: "/var/myvolume1", - }, - { - Type: composetypes.VolumeTypeVolume, - Target: "/var/myvolume2", - }, - { - Type: composetypes.VolumeTypeVolume, - Source: "myVolume3", - Target: "/var/myvolume3", - Volume: &composetypes.ServiceVolumeVolume{ - Subpath: "etc", + ContainerSpec: composetypes.ContainerSpec{ + Volumes: []composetypes.ServiceVolumeConfig{ + { + Type: composetypes.VolumeTypeVolume, + Target: "/var/myvolume1", + }, + { + Type: composetypes.VolumeTypeVolume, + Target: "/var/myvolume2", + }, + { + Type: composetypes.VolumeTypeVolume, + Source: "myVolume3", + Target: "/var/myvolume3", + Volume: &composetypes.ServiceVolumeVolume{ + Subpath: "etc", + }, + }, + { + Type: composetypes.VolumeTypeNamedPipe, + Source: "\\\\.\\pipe\\docker_engine_windows", + Target: "\\\\.\\pipe\\docker_engine", }, - }, - { - Type: composetypes.VolumeTypeNamedPipe, - Source: "\\\\.\\pipe\\docker_engine_windows", - Target: "\\\\.\\pipe\\docker_engine", }, }, }, @@ -195,12 +197,14 @@ func TestDefaultNetworkSettings(t *testing.T) { t.Run("returns the network with the highest priority as primary when service has multiple networks", func(t *testing.T) { service := composetypes.ServiceConfig{ Name: "myService", - Networks: map[string]*composetypes.ServiceNetworkConfig{ - "myNetwork1": { - Priority: 10, - }, - "myNetwork2": { - Priority: 1000, + ContainerSpec: composetypes.ContainerSpec{ + Networks: map[string]*composetypes.ServiceNetworkConfig{ + "myNetwork1": { + Priority: 10, + }, + "myNetwork2": { + Priority: 1000, + }, }, }, } @@ -276,9 +280,11 @@ func TestDefaultNetworkSettings(t *testing.T) { t.Run("returns only primary network in EndpointsConfig for API < 1.44", func(t *testing.T) { service := composetypes.ServiceConfig{ Name: "myService", - Networks: map[string]*composetypes.ServiceNetworkConfig{ - "myNetwork1": {Priority: 10}, - "myNetwork2": {Priority: 1000}, + ContainerSpec: composetypes.ContainerSpec{ + Networks: map[string]*composetypes.ServiceNetworkConfig{ + "myNetwork1": {Priority: 10}, + "myNetwork2": {Priority: 1000}, + }, }, } project := composetypes.Project{ @@ -299,8 +305,10 @@ func TestDefaultNetworkSettings(t *testing.T) { t.Run("returns defined network mode if explicitly set", func(t *testing.T) { service := composetypes.ServiceConfig{ - Name: "myService", - NetworkMode: "host", + Name: "myService", + ContainerSpec: composetypes.ContainerSpec{ + NetworkMode: "host", + }, } project := composetypes.Project{ Name: "myProject", @@ -323,19 +331,21 @@ func TestCreateEndpointSettings(t *testing.T) { eps, err := createEndpointSettings(&composetypes.Project{ Name: "projName", }, composetypes.ServiceConfig{ - Name: "serviceName", - ContainerName: "containerName", - Networks: map[string]*composetypes.ServiceNetworkConfig{ - "netName": { - Priority: 100, - Aliases: []string{"alias1", "alias2"}, - Ipv4Address: "10.16.17.18", - Ipv6Address: "fdb4:7a7f:373a:3f0c::42", - LinkLocalIPs: []string{"169.254.10.20"}, - MacAddress: "02:00:00:00:00:01", - DriverOpts: composetypes.Options{ - "driverOpt1": "optval1", - "driverOpt2": "optval2", + Name: "serviceName", + ContainerSpec: composetypes.ContainerSpec{ + ContainerName: "containerName", + Networks: map[string]*composetypes.ServiceNetworkConfig{ + "netName": { + Priority: 100, + Aliases: []string{"alias1", "alias2"}, + Ipv4Address: "10.16.17.18", + Ipv6Address: "fdb4:7a7f:373a:3f0c::42", + LinkLocalIPs: []string{"169.254.10.20"}, + MacAddress: "02:00:00:00:00:01", + DriverOpts: composetypes.Options{ + "driverOpt1": "optval1", + "driverOpt2": "optval2", + }, }, }, }, diff --git a/pkg/compose/dependencies_test.go b/pkg/compose/dependencies_test.go index 947a9bd4a3d..42e6d8d1070 100644 --- a/pkg/compose/dependencies_test.go +++ b/pkg/compose/dependencies_test.go @@ -35,14 +35,18 @@ func createTestProject() *types.Project { Services: types.Services{ "test1": { Name: "test1", - DependsOn: map[string]types.ServiceDependency{ - "test2": {}, + ContainerSpec: types.ContainerSpec{ + DependsOn: map[string]types.ServiceDependency{ + "test2": {}, + }, }, }, "test2": { Name: "test2", - DependsOn: map[string]types.ServiceDependency{ - "test3": {}, + ContainerSpec: types.ContainerSpec{ + DependsOn: map[string]types.ServiceDependency{ + "test3": {}, + }, }, }, "test3": { @@ -54,8 +58,10 @@ func createTestProject() *types.Project { func TestTraversalWithMultipleParents(t *testing.T) { dependent := types.ServiceConfig{ - Name: "dependent", - DependsOn: make(types.DependsOnConfig), + Name: "dependent", + ContainerSpec: types.ContainerSpec{ + DependsOn: make(types.DependsOnConfig), + }, } project := types.Project{ @@ -124,8 +130,10 @@ func TestBuildGraph(t *testing.T) { desc: "builds graph with single service", services: types.Services{ "test": { - Name: "test", - DependsOn: types.DependsOnConfig{}, + Name: "test", + ContainerSpec: types.ContainerSpec{ + DependsOn: types.DependsOnConfig{}, + }, }, }, expectedVertices: map[string]*Vertex{ @@ -142,12 +150,16 @@ func TestBuildGraph(t *testing.T) { desc: "builds graph with two separate services", services: types.Services{ "test": { - Name: "test", - DependsOn: types.DependsOnConfig{}, + Name: "test", + ContainerSpec: types.ContainerSpec{ + DependsOn: types.DependsOnConfig{}, + }, }, "another": { - Name: "another", - DependsOn: types.DependsOnConfig{}, + Name: "another", + ContainerSpec: types.ContainerSpec{ + DependsOn: types.DependsOnConfig{}, + }, }, }, expectedVertices: map[string]*Vertex{ @@ -172,13 +184,17 @@ func TestBuildGraph(t *testing.T) { services: types.Services{ "test": { Name: "test", - DependsOn: types.DependsOnConfig{ - "another": types.ServiceDependency{}, + ContainerSpec: types.ContainerSpec{ + DependsOn: types.DependsOnConfig{ + "another": types.ServiceDependency{}, + }, }, }, "another": { - Name: "another", - DependsOn: types.DependsOnConfig{}, + Name: "another", + ContainerSpec: types.ContainerSpec{ + DependsOn: types.DependsOnConfig{}, + }, }, }, expectedVertices: map[string]*Vertex{ @@ -207,19 +223,25 @@ func TestBuildGraph(t *testing.T) { services: types.Services{ "test": { Name: "test", - DependsOn: types.DependsOnConfig{ - "another": types.ServiceDependency{}, + ContainerSpec: types.ContainerSpec{ + DependsOn: types.DependsOnConfig{ + "another": types.ServiceDependency{}, + }, }, }, "another": { Name: "another", - DependsOn: types.DependsOnConfig{ - "another_dep": types.ServiceDependency{}, + ContainerSpec: types.ContainerSpec{ + DependsOn: types.DependsOnConfig{ + "another_dep": types.ServiceDependency{}, + }, }, }, "another_dep": { - Name: "another_dep", - DependsOn: types.DependsOnConfig{}, + Name: "another_dep", + ContainerSpec: types.ContainerSpec{ + DependsOn: types.DependsOnConfig{}, + }, }, }, expectedVertices: map[string]*Vertex{ @@ -284,12 +306,14 @@ func TestBuildGraphDependsOn(t *testing.T) { services: types.Services{ "test": { Name: "test", - DependsOn: types.DependsOnConfig{ - "test-removed-init-container": types.ServiceDependency{ - Condition: "service_completed_successfully", - Restart: false, - Extensions: types.Extensions(nil), - Required: false, + ContainerSpec: types.ContainerSpec{ + DependsOn: types.DependsOnConfig{ + "test-removed-init-container": types.ServiceDependency{ + Condition: "service_completed_successfully", + Restart: false, + Extensions: types.Extensions(nil), + Required: false, + }, }, }, }, diff --git a/pkg/compose/down_test.go b/pkg/compose/down_test.go index c52f736b1de..511d066b5d8 100644 --- a/pkg/compose/down_test.go +++ b/pkg/compose/down_test.go @@ -279,11 +279,11 @@ func TestDownRemoveImages(t *testing.T) { Name: strings.ToLower(testProject), Services: types.Services{ "local-anonymous": {Name: "local-anonymous"}, - "local-named": {Name: "local-named", Image: "local-named-image"}, - "remote": {Name: "remote", Image: "remote-image"}, - "remote-tagged": {Name: "remote-tagged", Image: "registry.example.com/remote-image-tagged:v1.0"}, + "local-named": {Name: "local-named", ContainerSpec: types.ContainerSpec{Image: "local-named-image"}}, + "remote": {Name: "remote", ContainerSpec: types.ContainerSpec{Image: "remote-image"}}, + "remote-tagged": {Name: "remote-tagged", ContainerSpec: types.ContainerSpec{Image: "registry.example.com/remote-image-tagged:v1.0"}}, "no-images-anonymous": {Name: "no-images-anonymous"}, - "no-images-named": {Name: "no-images-named", Image: "missing-named-image"}, + "no-images-named": {Name: "no-images-named", ContainerSpec: types.ContainerSpec{Image: "missing-named-image"}}, }, }, } diff --git a/pkg/compose/generate.go b/pkg/compose/generate.go index 5892a3bb729..9fed2da4dd3 100644 --- a/pkg/compose/generate.go +++ b/pkg/compose/generate.go @@ -85,9 +85,11 @@ func (s *composeService) createProjectFromContainers(containers []container.Summ service, ok := services[serviceLabel] if !ok { service = types.ServiceConfig{ - Name: serviceLabel, - Image: c.Image, - Labels: c.Labels, + Name: serviceLabel, + ContainerSpec: types.ContainerSpec{ + Image: c.Image, + Labels: c.Labels, + }, } } service.Scale = increment(service.Scale) diff --git a/pkg/compose/hash_test.go b/pkg/compose/hash_test.go index 73b7f387735..209a9ab0920 100644 --- a/pkg/compose/hash_test.go +++ b/pkg/compose/hash_test.go @@ -37,7 +37,9 @@ func serviceConfig(replicas int) types.ServiceConfig { Deploy: &types.DeployConfig{ Replicas: &replicas, }, - Name: "foo", - Image: "bar", + Name: "foo", + ContainerSpec: types.ContainerSpec{ + Image: "bar", + }, } } diff --git a/pkg/compose/hook_test.go b/pkg/compose/hook_test.go index 9c085082b56..09beaaea309 100644 --- a/pkg/compose/hook_test.go +++ b/pkg/compose/hook_test.go @@ -80,7 +80,9 @@ func TestRunHook_ConsoleSize(t *testing.T) { service := types.ServiceConfig{ Name: "test", - Tty: tc.tty, + ContainerSpec: types.ContainerSpec{ + Tty: tc.tty, + }, } hook := types.ServiceHook{Command: []string{"echo", "hello"}} ctr := container.Summary{ID: "container123"} diff --git a/pkg/compose/publish.go b/pkg/compose/publish.go index 30b6a6a49b7..bc0e0d07949 100644 --- a/pkg/compose/publish.go +++ b/pkg/compose/publish.go @@ -293,7 +293,9 @@ func (s *composeService) generateImageDigestsOverride(ctx context.Context, proje } for name, service := range project.Services { override.Services[name] = types.ServiceConfig{ - Image: service.Image, + ContainerSpec: types.ContainerSpec{ + Image: service.Image, + }, } } return override.MarshalYAML() diff --git a/pkg/compose/publish_test.go b/pkg/compose/publish_test.go index 3b0e5a4389c..9fafe26c31c 100644 --- a/pkg/compose/publish_test.go +++ b/pkg/compose/publish_test.go @@ -113,10 +113,12 @@ func Test_preChecks_sensitive_data_detected_decline(t *testing.T) { project := &types.Project{ Services: types.Services{ "web": { - Name: "web", - Image: "nginx", - EnvFiles: []types.EnvFile{ - {Path: envPath, Required: true}, + Name: "web", + ContainerSpec: types.ContainerSpec{ + Image: "nginx", + EnvFiles: []types.EnvFile{ + {Path: envPath, Required: true}, + }, }, }, }, @@ -138,13 +140,15 @@ func Test_publish_decline_returns_ErrCanceled(t *testing.T) { project := &types.Project{ Services: types.Services{ "web": { - Name: "web", - Image: "nginx", - Volumes: []types.ServiceVolumeConfig{ - { - Type: types.VolumeTypeBind, - Source: "/host/path", - Target: "/container/path", + Name: "web", + ContainerSpec: types.ContainerSpec{ + Image: "nginx", + Volumes: []types.ServiceVolumeConfig{ + { + Type: types.VolumeTypeBind, + Source: "/host/path", + Target: "/container/path", + }, }, }, }, diff --git a/pkg/compose/pull.go b/pkg/compose/pull.go index 8a02dc719b8..a16dcc01734 100644 --- a/pkg/compose/pull.go +++ b/pkg/compose/pull.go @@ -306,8 +306,10 @@ func (s *composeService) pullRequiredImages(ctx context.Context, project *types. // Hack: create a fake ServiceConfig so we pull missing volume image n := fmt.Sprintf("%s:volume %d", name, i) needPull[n] = types.ServiceConfig{ - Name: n, - Image: vol.Source, + Name: n, + ContainerSpec: types.ContainerSpec{ + Image: vol.Source, + }, } } } diff --git a/pkg/compose/run.go b/pkg/compose/run.go index 7ca080264da..88126e421c9 100644 --- a/pkg/compose/run.go +++ b/pkg/compose/run.go @@ -133,7 +133,7 @@ func (s *composeService) prepareRun(ctx context.Context, project *types.Project, return prepareRunResult{}, err } - service, err := project.GetService(opts.Service) + service, err := serviceConfigForRun(project, opts) if err != nil { return prepareRunResult{}, err } @@ -181,7 +181,7 @@ func (s *composeService) prepareRun(ctx context.Context, project *types.Project, Labels: mergeLabels(service.Labels, service.CustomLabels), } - err = newConvergence(project.ServiceNames(), observedState, nil, nil, s).resolveServiceReferences(&service) + err = newConvergence(project.ServiceNames(), observedState, nil, nil, s).resolveContainerReferences(&service.ContainerSpec) if err != nil { return prepareRunResult{}, err } @@ -214,13 +214,47 @@ func (s *composeService) prepareRun(ctx context.Context, project *types.Project, }, err } +// serviceConfigForRun resolves the run target and returns a ServiceConfig +// suitable for container creation. For jobs, a ServiceConfig is built from +// the job's Name and ContainerSpec. +func serviceConfigForRun(project *types.Project, opts api.RunOptions) (types.ServiceConfig, error) { + svc, job, err := resolveRunTarget(project, opts) + if err != nil { + return types.ServiceConfig{}, err + } + if svc != nil { + return *svc, nil + } + return types.ServiceConfig{ + Name: job.Name, + ContainerSpec: job.ContainerSpec, + }, nil +} + +// resolveRunTarget returns either a ServiceConfig or a JobConfig depending on +// which field is set in opts. Exactly one of the two returned pointers is non-nil. +func resolveRunTarget(project *types.Project, opts api.RunOptions) (*types.ServiceConfig, *types.JobConfig, error) { + if opts.Job != "" { + job, ok := project.Jobs[opts.Job] + if !ok { + return nil, nil, fmt.Errorf("no such job: %s", opts.Job) + } + return nil, &job, nil + } + service, err := project.GetService(opts.Service) + if err != nil { + return nil, nil, err + } + return &service, nil, nil +} + func prepareBuildOptions(opts api.RunOptions) *api.BuildOptions { if opts.Build == nil { return nil } - // Create a copy of build options and restrict to only the target service + // Create a copy of build options and restrict to only the target service/job buildOptsCopy := *opts.Build - buildOptsCopy.Services = []string{opts.Service} + buildOptsCopy.Services = []string{opts.TargetName()} return &buildOptsCopy } @@ -270,7 +304,7 @@ func applyRunOptions(project *types.Project, service *types.ServiceConfig, opts } func (s *composeService) startDependencies(ctx context.Context, project *types.Project, options api.RunOptions) error { - project = project.WithServicesDisabled(options.Service) + project = project.WithServicesDisabled(options.TargetName()) err := s.Create(ctx, project, api.CreateOptions{ Build: options.Build, diff --git a/pkg/compose/run_test.go b/pkg/compose/run_test.go new file mode 100644 index 00000000000..f33b8dcb943 --- /dev/null +++ b/pkg/compose/run_test.go @@ -0,0 +1,125 @@ +/* + 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/v2/types" + "gotest.tools/v3/assert" + + "github.com/docker/compose/v5/pkg/api" +) + +func TestResolveRunTarget_Service(t *testing.T) { + project := &types.Project{ + Services: types.Services{ + "web": { + Name: "web", + ContainerSpec: types.ContainerSpec{ + Image: "nginx", + }, + }, + }, + } + svc, job, err := resolveRunTarget(project, api.RunOptions{Service: "web"}) + assert.NilError(t, err) + assert.Assert(t, svc != nil) + assert.Assert(t, job == nil) + assert.Equal(t, svc.Name, "web") + assert.Equal(t, svc.Image, "nginx") +} + +func TestResolveRunTarget_Job(t *testing.T) { + project := &types.Project{ + Services: types.Services{}, + Jobs: types.Jobs{ + "migrate": { + Name: "migrate", + ContainerSpec: types.ContainerSpec{ + Image: "myapp", + Command: types.ShellCommand{"python", "manage.py", "migrate"}, + DependsOn: types.DependsOnConfig{ + "db": {Condition: "service_healthy"}, + }, + }, + }, + }, + } + svc, job, err := resolveRunTarget(project, api.RunOptions{Job: "migrate"}) + assert.NilError(t, err) + assert.Assert(t, svc == nil) + assert.Assert(t, job != nil) + assert.Equal(t, job.Name, "migrate") + assert.Equal(t, job.Image, "myapp") + assert.DeepEqual(t, []string(job.Command), []string{"python", "manage.py", "migrate"}) + assert.Equal(t, len(job.DependsOn), 1) +} + +func TestResolveRunTarget_JobNotFound(t *testing.T) { + project := &types.Project{ + Services: types.Services{}, + Jobs: types.Jobs{}, + } + _, _, err := resolveRunTarget(project, api.RunOptions{Job: "nonexistent"}) + assert.ErrorContains(t, err, "no such job: nonexistent") +} + +func TestResolveRunTarget_ServiceNotFound(t *testing.T) { + project := &types.Project{ + Services: types.Services{}, + } + _, _, err := resolveRunTarget(project, api.RunOptions{Service: "nonexistent"}) + assert.ErrorContains(t, err, "nonexistent") +} + +func TestResolveRunTarget_JobPreservesContainerSpec(t *testing.T) { + envVal := "db" + project := &types.Project{ + Services: types.Services{}, + Jobs: types.Jobs{ + "backup": { + Name: "backup", + ContainerSpec: types.ContainerSpec{ + Image: "postgres", + Command: types.ShellCommand{"pg_dump"}, + WorkingDir: "/data", + Environment: types.MappingWithEquals{ + "PGHOST": &envVal, + }, + Volumes: []types.ServiceVolumeConfig{ + { + Type: types.VolumeTypeBind, + Source: "/backups", + Target: "/output", + }, + }, + }, + Triggers: &types.TriggerConfig{ + Schedule: "0 2 * * *", + }, + }, + }, + } + _, job, err := resolveRunTarget(project, api.RunOptions{Job: "backup"}) + assert.NilError(t, err) + assert.Equal(t, job.Image, "postgres") + assert.Equal(t, job.WorkingDir, "/data") + assert.Equal(t, *job.Environment["PGHOST"], "db") + assert.Equal(t, len(job.Volumes), 1) + assert.Equal(t, job.Volumes[0].Source, "/backups") +} diff --git a/pkg/compose/viz_test.go b/pkg/compose/viz_test.go index 9a56bc4dda0..f6da1fb0132 100644 --- a/pkg/compose/viz_test.go +++ b/pkg/compose/viz_test.go @@ -35,64 +35,74 @@ func TestViz(t *testing.T) { WorkingDir: "/home", Services: types.Services{ "service1": { - Name: "service1", - Image: "image-for-service1", - Ports: []types.ServicePortConfig{ - { - Published: "80", - Target: 80, - Protocol: "tcp", + Name: "service1", + ContainerSpec: types.ContainerSpec{ + Image: "image-for-service1", + Ports: []types.ServicePortConfig{ + { + Published: "80", + Target: 80, + Protocol: "tcp", + }, + { + Published: "53", + Target: 533, + Protocol: "udp", + }, }, - { - Published: "53", - Target: 533, - Protocol: "udp", + Networks: map[string]*types.ServiceNetworkConfig{ + "internal": nil, }, }, - Networks: map[string]*types.ServiceNetworkConfig{ - "internal": nil, - }, }, "service2": { - Name: "service2", - Image: "image-for-service2", - Ports: []types.ServicePortConfig{}, + Name: "service2", + ContainerSpec: types.ContainerSpec{ + Image: "image-for-service2", + Ports: []types.ServicePortConfig{}, + }, }, "service3": { - Name: "service3", - Image: "some-image", - DependsOn: map[string]types.ServiceDependency{ - "service2": {}, - "service1": {}, + Name: "service3", + ContainerSpec: types.ContainerSpec{ + Image: "some-image", + DependsOn: map[string]types.ServiceDependency{ + "service2": {}, + "service1": {}, + }, }, }, "service4": { - Name: "service4", - Image: "another-image", - DependsOn: map[string]types.ServiceDependency{ - "service3": {}, - }, - Ports: []types.ServicePortConfig{ - { - Published: "8080", - Target: 80, + Name: "service4", + ContainerSpec: types.ContainerSpec{ + Image: "another-image", + DependsOn: map[string]types.ServiceDependency{ + "service3": {}, + }, + Ports: []types.ServicePortConfig{ + { + Published: "8080", + Target: 80, + }, + }, + Networks: map[string]*types.ServiceNetworkConfig{ + "external": nil, }, - }, - Networks: map[string]*types.ServiceNetworkConfig{ - "external": nil, }, }, "With host IP": { - Name: "With host IP", - Image: "user/image-name", - DependsOn: map[string]types.ServiceDependency{ - "service1": {}, - }, - Ports: []types.ServicePortConfig{ - { - Published: "8888", - Target: 8080, - HostIP: "127.0.0.1", + Name: "With host IP", + ContainerSpec: types.ContainerSpec{ + Image: "user/image-name", + DependsOn: map[string]types.ServiceDependency{ + "service1": {}, + }, + Ports: []types.ServicePortConfig{ + { + Published: "8888", + Target: 8080, + HostIP: "127.0.0.1", + }, }, }, }, From a5c5f814bba7a6ffbc39d4ab315255718bb93d91 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Fri, 17 Apr 2026 13:58:26 +0200 Subject: [PATCH 2/9] introduce runTarget to be used either by ServiceConfig|JobConfig Signed-off-by: Nicolas De Loof --- pkg/compose/run.go | 144 +++++++++++++++++++++------------------- pkg/compose/run_test.go | 50 ++++++++------ 2 files changed, 104 insertions(+), 90 deletions(-) diff --git a/pkg/compose/run.go b/pkg/compose/run.go index 88126e421c9..2044c6fa518 100644 --- a/pkg/compose/run.go +++ b/pkg/compose/run.go @@ -35,9 +35,28 @@ import ( "github.com/docker/compose/v5/pkg/api" ) +// runTarget is the internal representation of a run target (service or job). +// It carries only what's needed for one-off container execution: a name and +// the container specification. ServiceConfig-only fields (Deploy, Scale, Profiles) +// are intentionally excluded. +type runTarget struct { + Name string + types.ContainerSpec +} + +// toServiceConfig converts a runTarget into a ServiceConfig for functions +// in the container creation chain that still require it. This is the single +// bridge point; future refactoring will push ContainerSpec deeper. +func (t runTarget) toServiceConfig() types.ServiceConfig { + return types.ServiceConfig{ + Name: t.Name, + ContainerSpec: t.ContainerSpec, + } +} + type prepareRunResult struct { containerID string - service types.ServiceConfig + target runTarget created container.Summary } @@ -55,15 +74,15 @@ func (s *composeService) RunOneOffContainer(ctx context.Context, project *types. go cmd.ForwardAllSignals(ctx, s.apiClient(), result.containerID, sigc) defer signal.Stop(sigc) - // If the service has post_start hooks, set up a goroutine that waits for + // If the target has post_start hooks, set up a goroutine that waits for // the container to start and then executes them. This is needed because // cmd.RunStart both starts and attaches to the container in one call, // so we can't run hooks sequentially between start and attach. var hookErrCh chan error - if len(result.service.PostStart) > 0 { + if len(result.target.PostStart) > 0 { hookErrCh = make(chan error, 1) go func() { - hookErrCh <- s.runPostStartHooksOnEvent(ctx, result.containerID, result.service, result.created) + hookErrCh <- s.runPostStartHooksOnEvent(ctx, result.containerID, result.target, result.created) }() } @@ -90,7 +109,7 @@ func (s *composeService) RunOneOffContainer(ctx context.Context, project *types. // runPostStartHooksOnEvent listens for the container's start event and executes // post_start lifecycle hooks once the container is running. -func (s *composeService) runPostStartHooksOnEvent(ctx context.Context, containerID string, service types.ServiceConfig, ctr container.Summary) error { +func (s *composeService) runPostStartHooksOnEvent(ctx context.Context, containerID string, target runTarget, ctr container.Summary) error { evtCtx, cancel := context.WithCancel(ctx) defer cancel() @@ -111,6 +130,7 @@ func (s *composeService) runPostStartHooksOnEvent(ctx context.Context, container // Container started, run hooks } + service := target.toServiceConfig() for _, hook := range service.PostStart { if err := s.runHook(ctx, ctr, service, hook, nil); err != nil { return err @@ -133,34 +153,29 @@ func (s *composeService) prepareRun(ctx context.Context, project *types.Project, return prepareRunResult{}, err } - service, err := serviceConfigForRun(project, opts) + target, err := resolveRunTarget(project, opts) if err != nil { return prepareRunResult{}, err } - applyRunOptions(project, &service, opts) + applyRunOptions(project, &target, opts) - if err := s.stdin().CheckTty(opts.Interactive, service.Tty); err != nil { + if err := s.stdin().CheckTty(opts.Interactive, target.Tty); err != nil { return prepareRunResult{}, err } slug := stringid.GenerateRandomID() - if service.ContainerName == "" { - service.ContainerName = fmt.Sprintf("%[1]s%[4]s%[2]s%[4]srun%[4]s%[3]s", project.Name, service.Name, stringid.TruncateID(slug), api.Separator) - } - one := 1 - service.Scale = &one - service.Restart = "" - if service.Deploy != nil { - service.Deploy.RestartPolicy = nil + if target.ContainerName == "" { + target.ContainerName = fmt.Sprintf("%[1]s%[4]s%[2]s%[4]srun%[4]s%[3]s", project.Name, target.Name, stringid.TruncateID(slug), api.Separator) } - service.CustomLabels = service.CustomLabels. + target.Restart = "" + target.CustomLabels = target.CustomLabels. Add(api.SlugLabel, slug). Add(api.OneoffLabel, "True") - // Only ensure image exists for the target service, dependencies were already handled by startDependencies + // Only ensure image exists for the target, dependencies were already handled by startDependencies buildOpts := prepareBuildOptions(opts) - if err := s.ensureImagesExists(ctx, project, buildOpts, opts.QuietPull); err != nil { // all dependencies already checked, but might miss service img + if err := s.ensureImagesExists(ctx, project, buildOpts, opts.QuietPull); err != nil { return prepareRunResult{}, err } @@ -170,18 +185,12 @@ func (s *composeService) prepareRun(ctx context.Context, project *types.Project, } if !opts.NoDeps { - if err := s.waitDependencies(ctx, project, service.Name, service.DependsOn, observedState, 0); err != nil { + if err := s.waitDependencies(ctx, project, target.Name, target.DependsOn, observedState, 0); err != nil { return prepareRunResult{}, err } } - createOpts := createOptions{ - AutoRemove: opts.AutoRemove, - AttachStdin: opts.Interactive, - UseNetworkAliases: opts.UseNetworkAliases, - Labels: mergeLabels(service.Labels, service.CustomLabels), - } - err = newConvergence(project.ServiceNames(), observedState, nil, nil, s).resolveContainerReferences(&service.ContainerSpec) + err = newConvergence(project.ServiceNames(), observedState, nil, nil, s).resolveContainerReferences(&target.ContainerSpec) if err != nil { return prepareRunResult{}, err } @@ -191,6 +200,18 @@ func (s *composeService) prepareRun(ctx context.Context, project *types.Project, return prepareRunResult{}, err } + // Bridge to ServiceConfig for container creation layer + service := target.toServiceConfig() + one := 1 + service.Scale = &one + + createOpts := createOptions{ + AutoRemove: opts.AutoRemove, + AttachStdin: opts.Interactive, + UseNetworkAliases: opts.UseNetworkAliases, + Labels: mergeLabels(service.Labels, service.CustomLabels), + } + created, err := s.createContainer(ctx, project, service, service.ContainerName, -1, createOpts) if err != nil { return prepareRunResult{}, err @@ -209,43 +230,26 @@ func (s *composeService) prepareRun(ctx context.Context, project *types.Project, err = s.injectConfigs(ctx, project, service, inspect.Container.ID) return prepareRunResult{ containerID: created.ID, - service: service, + target: target, created: created, }, err } -// serviceConfigForRun resolves the run target and returns a ServiceConfig -// suitable for container creation. For jobs, a ServiceConfig is built from -// the job's Name and ContainerSpec. -func serviceConfigForRun(project *types.Project, opts api.RunOptions) (types.ServiceConfig, error) { - svc, job, err := resolveRunTarget(project, opts) - if err != nil { - return types.ServiceConfig{}, err - } - if svc != nil { - return *svc, nil - } - return types.ServiceConfig{ - Name: job.Name, - ContainerSpec: job.ContainerSpec, - }, nil -} - -// resolveRunTarget returns either a ServiceConfig or a JobConfig depending on -// which field is set in opts. Exactly one of the two returned pointers is non-nil. -func resolveRunTarget(project *types.Project, opts api.RunOptions) (*types.ServiceConfig, *types.JobConfig, error) { +// resolveRunTarget looks up the run target (service or job) in the project +// and returns a runTarget carrying only Name + ContainerSpec. +func resolveRunTarget(project *types.Project, opts api.RunOptions) (runTarget, error) { if opts.Job != "" { job, ok := project.Jobs[opts.Job] if !ok { - return nil, nil, fmt.Errorf("no such job: %s", opts.Job) + return runTarget{}, fmt.Errorf("no such job: %s", opts.Job) } - return nil, &job, nil + return runTarget{Name: job.Name, ContainerSpec: job.ContainerSpec}, nil } service, err := project.GetService(opts.Service) if err != nil { - return nil, nil, err + return runTarget{}, err } - return &service, nil, nil + return runTarget{Name: service.Name, ContainerSpec: service.ContainerSpec}, nil } func prepareBuildOptions(opts api.RunOptions) *api.BuildOptions { @@ -258,48 +262,48 @@ func prepareBuildOptions(opts api.RunOptions) *api.BuildOptions { return &buildOptsCopy } -func applyRunOptions(project *types.Project, service *types.ServiceConfig, opts api.RunOptions) { - service.Tty = opts.Tty - service.StdinOpen = opts.Interactive - service.ContainerName = opts.Name +func applyRunOptions(project *types.Project, target *runTarget, opts api.RunOptions) { + target.Tty = opts.Tty + target.StdinOpen = opts.Interactive + target.ContainerName = opts.Name if len(opts.Command) > 0 { - service.Command = opts.Command + target.Command = opts.Command } if opts.User != "" { - service.User = opts.User + target.User = opts.User } if len(opts.CapAdd) > 0 { - service.CapAdd = append(service.CapAdd, opts.CapAdd...) - service.CapDrop = slices.DeleteFunc(service.CapDrop, func(e string) bool { return slices.Contains(opts.CapAdd, e) }) + target.CapAdd = append(target.CapAdd, opts.CapAdd...) + target.CapDrop = slices.DeleteFunc(target.CapDrop, func(e string) bool { return slices.Contains(opts.CapAdd, e) }) } if len(opts.CapDrop) > 0 { - service.CapDrop = append(service.CapDrop, opts.CapDrop...) - service.CapAdd = slices.DeleteFunc(service.CapAdd, func(e string) bool { return slices.Contains(opts.CapDrop, e) }) + target.CapDrop = append(target.CapDrop, opts.CapDrop...) + target.CapAdd = slices.DeleteFunc(target.CapAdd, func(e string) bool { return slices.Contains(opts.CapDrop, e) }) } if opts.WorkingDir != "" { - service.WorkingDir = opts.WorkingDir + target.WorkingDir = opts.WorkingDir } if opts.Entrypoint != nil { - service.Entrypoint = opts.Entrypoint + target.Entrypoint = opts.Entrypoint if len(opts.Command) == 0 { - service.Command = []string{} + target.Command = []string{} } } if len(opts.Environment) > 0 { cmdEnv := types.NewMappingWithEquals(opts.Environment) - serviceOverrideEnv := cmdEnv.Resolve(func(s string) (string, bool) { + overrideEnv := cmdEnv.Resolve(func(s string) (string, bool) { v, ok := envResolver(project.Environment)(s) return v, ok }).RemoveEmpty() - if service.Environment == nil { - service.Environment = types.MappingWithEquals{} + if target.Environment == nil { + target.Environment = types.MappingWithEquals{} } - service.Environment.OverrideBy(serviceOverrideEnv) + target.Environment.OverrideBy(overrideEnv) } for k, v := range opts.Labels { - service.Labels = service.Labels.Add(k, v) + target.Labels = target.Labels.Add(k, v) } } diff --git a/pkg/compose/run_test.go b/pkg/compose/run_test.go index f33b8dcb943..e99184eaf9e 100644 --- a/pkg/compose/run_test.go +++ b/pkg/compose/run_test.go @@ -36,12 +36,10 @@ func TestResolveRunTarget_Service(t *testing.T) { }, }, } - svc, job, err := resolveRunTarget(project, api.RunOptions{Service: "web"}) + target, err := resolveRunTarget(project, api.RunOptions{Service: "web"}) assert.NilError(t, err) - assert.Assert(t, svc != nil) - assert.Assert(t, job == nil) - assert.Equal(t, svc.Name, "web") - assert.Equal(t, svc.Image, "nginx") + assert.Equal(t, target.Name, "web") + assert.Equal(t, target.Image, "nginx") } func TestResolveRunTarget_Job(t *testing.T) { @@ -60,14 +58,12 @@ func TestResolveRunTarget_Job(t *testing.T) { }, }, } - svc, job, err := resolveRunTarget(project, api.RunOptions{Job: "migrate"}) + target, err := resolveRunTarget(project, api.RunOptions{Job: "migrate"}) assert.NilError(t, err) - assert.Assert(t, svc == nil) - assert.Assert(t, job != nil) - assert.Equal(t, job.Name, "migrate") - assert.Equal(t, job.Image, "myapp") - assert.DeepEqual(t, []string(job.Command), []string{"python", "manage.py", "migrate"}) - assert.Equal(t, len(job.DependsOn), 1) + assert.Equal(t, target.Name, "migrate") + assert.Equal(t, target.Image, "myapp") + assert.DeepEqual(t, []string(target.Command), []string{"python", "manage.py", "migrate"}) + assert.Equal(t, len(target.DependsOn), 1) } func TestResolveRunTarget_JobNotFound(t *testing.T) { @@ -75,7 +71,7 @@ func TestResolveRunTarget_JobNotFound(t *testing.T) { Services: types.Services{}, Jobs: types.Jobs{}, } - _, _, err := resolveRunTarget(project, api.RunOptions{Job: "nonexistent"}) + _, err := resolveRunTarget(project, api.RunOptions{Job: "nonexistent"}) assert.ErrorContains(t, err, "no such job: nonexistent") } @@ -83,7 +79,7 @@ func TestResolveRunTarget_ServiceNotFound(t *testing.T) { project := &types.Project{ Services: types.Services{}, } - _, _, err := resolveRunTarget(project, api.RunOptions{Service: "nonexistent"}) + _, err := resolveRunTarget(project, api.RunOptions{Service: "nonexistent"}) assert.ErrorContains(t, err, "nonexistent") } @@ -115,11 +111,25 @@ func TestResolveRunTarget_JobPreservesContainerSpec(t *testing.T) { }, }, } - _, job, err := resolveRunTarget(project, api.RunOptions{Job: "backup"}) + target, err := resolveRunTarget(project, api.RunOptions{Job: "backup"}) assert.NilError(t, err) - assert.Equal(t, job.Image, "postgres") - assert.Equal(t, job.WorkingDir, "/data") - assert.Equal(t, *job.Environment["PGHOST"], "db") - assert.Equal(t, len(job.Volumes), 1) - assert.Equal(t, job.Volumes[0].Source, "/backups") + assert.Equal(t, target.Image, "postgres") + assert.Equal(t, target.WorkingDir, "/data") + assert.Equal(t, *target.Environment["PGHOST"], "db") + assert.Equal(t, len(target.Volumes), 1) + assert.Equal(t, target.Volumes[0].Source, "/backups") +} + +func TestRunTarget_ToServiceConfig(t *testing.T) { + target := runTarget{ + Name: "test", + ContainerSpec: types.ContainerSpec{ + Image: "myimage", + Command: types.ShellCommand{"echo", "hello"}, + }, + } + svc := target.toServiceConfig() + assert.Equal(t, svc.Name, "test") + assert.Equal(t, svc.Image, "myimage") + assert.DeepEqual(t, []string(svc.Command), []string{"echo", "hello"}) } From ba2e7810bf20d442870fb1d4e5ccb621d2600e32 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Fri, 17 Apr 2026 14:11:42 +0200 Subject: [PATCH 3/9] prefer ContainerSpec + Name so same function applies to ServiceConfig and JobConfig Signed-off-by: Nicolas De Loof --- internal/tracing/attributes_test.go | 14 +++++++------- pkg/compose/convergence.go | 6 +++--- pkg/compose/down.go | 2 +- pkg/compose/hook.go | 20 ++++++++++---------- pkg/compose/hook_test.go | 2 +- pkg/compose/restart.go | 4 ++-- pkg/compose/run.go | 9 ++++----- pkg/compose/secrets.go | 26 +++++++++++++------------- 8 files changed, 41 insertions(+), 42 deletions(-) diff --git a/internal/tracing/attributes_test.go b/internal/tracing/attributes_test.go index 8416f69728b..acbbe2f552a 100644 --- a/internal/tracing/attributes_test.go +++ b/internal/tracing/attributes_test.go @@ -28,27 +28,27 @@ func TestProjectHash(t *testing.T) { Name: "fake-proj", WorkingDir: "/tmp", Services: map[string]types.ServiceConfig{ - "foo": {Image: "fake-image"}, + "foo": {ContainerSpec: types.ContainerSpec{Image: "fake-image"}}, }, DisabledServices: map[string]types.ServiceConfig{ - "bar": {Image: "diff-image"}, + "bar": {ContainerSpec: types.ContainerSpec{Image: "diff-image"}}, }, } projB := &types.Project{ Name: "fake-proj", WorkingDir: "/tmp", Services: map[string]types.ServiceConfig{ - "foo": {Image: "fake-image"}, - "bar": {Image: "diff-image"}, + "foo": {ContainerSpec: types.ContainerSpec{Image: "fake-image"}}, + "bar": {ContainerSpec: types.ContainerSpec{Image: "diff-image"}}, }, } projC := &types.Project{ Name: "fake-proj", WorkingDir: "/tmp", Services: map[string]types.ServiceConfig{ - "foo": {Image: "fake-image"}, - "bar": {Image: "diff-image"}, - "baz": {Image: "yet-another-image"}, + "foo": {ContainerSpec: types.ContainerSpec{Image: "fake-image"}}, + "bar": {ContainerSpec: types.ContainerSpec{Image: "diff-image"}}, + "baz": {ContainerSpec: types.ContainerSpec{Image: "yet-another-image"}}, }, } diff --git a/pkg/compose/convergence.go b/pkg/compose/convergence.go index 5b489b6bcd8..de0527ec585 100644 --- a/pkg/compose/convergence.go +++ b/pkg/compose/convergence.go @@ -916,12 +916,12 @@ func (s *composeService) startService(ctx context.Context, continue } - err = s.injectSecrets(ctx, project, service, ctr.ID) + err = s.injectSecrets(ctx, project, service.Name, &service.ContainerSpec, ctr.ID) if err != nil { return err } - err = s.injectConfigs(ctx, project, service, ctr.ID) + err = s.injectConfigs(ctx, project, service.Name, &service.ContainerSpec, ctr.ID) if err != nil { return err } @@ -934,7 +934,7 @@ func (s *composeService) startService(ctx context.Context, } for _, hook := range service.PostStart { - err = s.runHook(ctx, ctr, service, hook, listener) + err = s.runHook(ctx, ctr, service.Name, service.Tty, hook, listener) if err != nil { return err } diff --git a/pkg/compose/down.go b/pkg/compose/down.go index 758b9868a28..31d5960c908 100644 --- a/pkg/compose/down.go +++ b/pkg/compose/down.go @@ -303,7 +303,7 @@ func (s *composeService) stopContainer(ctx context.Context, service *types.Servi if service != nil { for _, hook := range service.PreStop { - err := s.runHook(ctx, ctr, *service, hook, listener) + err := s.runHook(ctx, ctr, service.Name, service.Tty, hook, listener) if err != nil { // Ignore errors indicating that some containers were already stopped or removed. if errdefs.IsNotFound(err) || errdefs.IsConflict(err) { diff --git a/pkg/compose/hook.go b/pkg/compose/hook.go index 8acc24011f9..3ecc93e77e2 100644 --- a/pkg/compose/hook.go +++ b/pkg/compose/hook.go @@ -31,13 +31,13 @@ import ( "github.com/docker/compose/v5/pkg/utils" ) -func (s *composeService) runHook(ctx context.Context, ctr container.Summary, service types.ServiceConfig, hook types.ServiceHook, listener api.ContainerEventListener) error { +func (s *composeService) runHook(ctx context.Context, ctr container.Summary, name string, tty bool, hook types.ServiceHook, listener api.ContainerEventListener) error { wOut := utils.GetWriter(func(line string) { listener(api.ContainerEvent{ Type: api.HookEventLog, Source: getContainerNameWithoutProject(ctr) + " ->", ID: ctr.ID, - Service: service.Name, + Service: name, Line: line, }) }) @@ -58,13 +58,13 @@ func (s *composeService) runHook(ctx context.Context, ctr container.Summary, ser } if detached { - return s.runWaitExec(ctx, exec.ID, service, listener) + return s.runWaitExec(ctx, exec.ID, name, tty, listener) } attachOptions := client.ExecAttachOptions{ - TTY: service.Tty, + TTY: tty, } - if service.Tty { + if tty { height, width := s.stdout().GetTtySize() attachOptions.ConsoleSize = client.ConsoleSize{ Width: width, @@ -77,7 +77,7 @@ func (s *composeService) runHook(ctx context.Context, ctr container.Summary, ser } defer attach.Close() - if service.Tty { + if tty { _, err = io.Copy(wOut, attach.Reader) } else { _, err = stdcopy.StdCopy(wOut, wOut, attach.Reader) @@ -91,15 +91,15 @@ func (s *composeService) runHook(ctx context.Context, ctr container.Summary, ser return err } if inspected.ExitCode != 0 { - return fmt.Errorf("%s hook exited with status %d", service.Name, inspected.ExitCode) + return fmt.Errorf("%s hook exited with status %d", name, inspected.ExitCode) } return nil } -func (s *composeService) runWaitExec(ctx context.Context, execID string, service types.ServiceConfig, listener api.ContainerEventListener) error { +func (s *composeService) runWaitExec(ctx context.Context, execID string, name string, tty bool, listener api.ContainerEventListener) error { _, err := s.apiClient().ExecStart(ctx, execID, client.ExecStartOptions{ Detach: listener == nil, - TTY: service.Tty, + TTY: tty, }) if err != nil { return err @@ -118,7 +118,7 @@ func (s *composeService) runWaitExec(ctx context.Context, execID string, service } if !inspect.Running { if inspect.ExitCode != 0 { - return fmt.Errorf("%s hook exited with status %d", service.Name, inspect.ExitCode) + return fmt.Errorf("%s hook exited with status %d", name, inspect.ExitCode) } return nil } diff --git a/pkg/compose/hook_test.go b/pkg/compose/hook_test.go index 09beaaea309..f23768395ef 100644 --- a/pkg/compose/hook_test.go +++ b/pkg/compose/hook_test.go @@ -111,7 +111,7 @@ func TestRunHook_ConsoleSize(t *testing.T) { assert.NilError(t, err) noopListener := func(api.ContainerEvent) {} - err = s.(*composeService).runHook(t.Context(), ctr, service, hook, noopListener) + err = s.(*composeService).runHook(t.Context(), ctr, service.Name, service.Tty, hook, noopListener) assert.NilError(t, err) }) } diff --git a/pkg/compose/restart.go b/pkg/compose/restart.go index 9d83bff9a4b..b215cf8adae 100644 --- a/pkg/compose/restart.go +++ b/pkg/compose/restart.go @@ -87,7 +87,7 @@ func (s *composeService) restart(ctx context.Context, projectName string, option eg.Go(func() error { def := project.Services[service] for _, hook := range def.PreStop { - err = s.runHook(ctx, ctr, def, hook, nil) + err = s.runHook(ctx, ctr, def.Name, def.Tty, hook, nil) if err != nil { return err } @@ -102,7 +102,7 @@ func (s *composeService) restart(ctx context.Context, projectName string, option } s.events.On(startedEvent(eventName)) for _, hook := range def.PostStart { - err = s.runHook(ctx, ctr, def, hook, nil) + err = s.runHook(ctx, ctr, def.Name, def.Tty, hook, nil) if err != nil { return err } diff --git a/pkg/compose/run.go b/pkg/compose/run.go index 2044c6fa518..88f1c24d4cb 100644 --- a/pkg/compose/run.go +++ b/pkg/compose/run.go @@ -130,9 +130,8 @@ func (s *composeService) runPostStartHooksOnEvent(ctx context.Context, container // Container started, run hooks } - service := target.toServiceConfig() - for _, hook := range service.PostStart { - if err := s.runHook(ctx, ctr, service, hook, nil); err != nil { + for _, hook := range target.PostStart { + if err := s.runHook(ctx, ctr, target.Name, target.Tty, hook, nil); err != nil { return err } } @@ -222,12 +221,12 @@ func (s *composeService) prepareRun(ctx context.Context, project *types.Project, return prepareRunResult{}, err } - err = s.injectSecrets(ctx, project, service, inspect.Container.ID) + err = s.injectSecrets(ctx, project, target.Name, &target.ContainerSpec, inspect.Container.ID) if err != nil { return prepareRunResult{containerID: created.ID}, err } - err = s.injectConfigs(ctx, project, service, inspect.Container.ID) + err = s.injectConfigs(ctx, project, target.Name, &target.ContainerSpec, inspect.Container.ID) return prepareRunResult{ containerID: created.ID, target: target, diff --git a/pkg/compose/secrets.go b/pkg/compose/secrets.go index ac64684f8f3..1bf70525d4a 100644 --- a/pkg/compose/secrets.go +++ b/pkg/compose/secrets.go @@ -35,16 +35,16 @@ const ( configMount mountType = "config" ) -func (s *composeService) injectSecrets(ctx context.Context, project *types.Project, service types.ServiceConfig, id string) error { - return s.injectFileReferences(ctx, project, service, id, secretMount) +func (s *composeService) injectSecrets(ctx context.Context, project *types.Project, name string, spec *types.ContainerSpec, id string) error { + return s.injectFileReferences(ctx, project, name, spec, id, secretMount) } -func (s *composeService) injectConfigs(ctx context.Context, project *types.Project, service types.ServiceConfig, id string) error { - return s.injectFileReferences(ctx, project, service, id, configMount) +func (s *composeService) injectConfigs(ctx context.Context, project *types.Project, name string, spec *types.ContainerSpec, id string) error { + return s.injectFileReferences(ctx, project, name, spec, id, configMount) } -func (s *composeService) injectFileReferences(ctx context.Context, project *types.Project, service types.ServiceConfig, id string, mountType mountType) error { - mounts, sources := s.getFilesAndMap(project, service, mountType) +func (s *composeService) injectFileReferences(ctx context.Context, project *types.Project, name string, spec *types.ContainerSpec, id string, mountType mountType) error { + mounts, sources := getFilesAndMap(project, spec, mountType) for _, mount := range mounts { content, err := s.resolveFileContent(project, sources[mount.Source], mountType) @@ -55,8 +55,8 @@ func (s *composeService) injectFileReferences(ctx context.Context, project *type continue } - if service.ReadOnly { - return fmt.Errorf("cannot create %s %q in read-only service %s: `file` is the sole supported option", mountType, sources[mount.Source].Name, service.Name) + if spec.ReadOnly { + return fmt.Errorf("cannot create %s %q in read-only service %s: `file` is the sole supported option", mountType, sources[mount.Source].Name, name) } s.setDefaultTarget(&mount, mountType) @@ -68,14 +68,14 @@ func (s *composeService) injectFileReferences(ctx context.Context, project *type return nil } -func (s *composeService) getFilesAndMap(project *types.Project, service types.ServiceConfig, mountType mountType) ([]types.FileReferenceConfig, map[string]types.FileObjectConfig) { +func getFilesAndMap(project *types.Project, spec *types.ContainerSpec, mountType mountType) ([]types.FileReferenceConfig, map[string]types.FileObjectConfig) { var files []types.FileReferenceConfig var fileMap map[string]types.FileObjectConfig switch mountType { case secretMount: - files = make([]types.FileReferenceConfig, len(service.Secrets)) - for i, config := range service.Secrets { + files = make([]types.FileReferenceConfig, len(spec.Secrets)) + for i, config := range spec.Secrets { files[i] = types.FileReferenceConfig(config) } fileMap = make(map[string]types.FileObjectConfig) @@ -83,8 +83,8 @@ func (s *composeService) getFilesAndMap(project *types.Project, service types.Se fileMap[k] = types.FileObjectConfig(v) } case configMount: - files = make([]types.FileReferenceConfig, len(service.Configs)) - for i, config := range service.Configs { + files = make([]types.FileReferenceConfig, len(spec.Configs)) + for i, config := range spec.Configs { files[i] = types.FileReferenceConfig(config) } fileMap = make(map[string]types.FileObjectConfig) From 7a52dd35b99638836e833575fee3f753f9ae60bf Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Fri, 17 Apr 2026 14:54:24 +0200 Subject: [PATCH 4/9] run doesn't use types.ServiceConfig Signed-off-by: Nicolas De Loof --- pkg/api/api.go | 12 +- pkg/compose/convergence.go | 69 +++++--- pkg/compose/convergence_test.go | 25 +-- pkg/compose/create.go | 297 +++++++++++++++----------------- pkg/compose/create_test.go | 55 +++--- pkg/compose/run.go | 29 ++-- pkg/compose/run_test.go | 14 -- 7 files changed, 246 insertions(+), 255 deletions(-) diff --git a/pkg/api/api.go b/pkg/api/api.go index c25cdfdc3e9..6435df4b7aa 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -763,9 +763,13 @@ var Separator = "-" // GetImageNameOrDefault computes the default image name for a service, used to tag built images func GetImageNameOrDefault(service types.ServiceConfig, projectName string) string { - imageName := service.Image - if imageName == "" { - imageName = projectName + Separator + service.Name + return ImageNameOrDefault(service.Image, service.Name, projectName) +} + +// ImageNameOrDefault returns image if non-empty, otherwise builds a default from name and projectName. +func ImageNameOrDefault(image, name, projectName string) string { + if image != "" { + return image } - return imageName + return projectName + Separator + name } diff --git a/pkg/compose/convergence.go b/pkg/compose/convergence.go index de0527ec585..fdfee81b641 100644 --- a/pkg/compose/convergence.go +++ b/pkg/compose/convergence.go @@ -210,7 +210,7 @@ func (c *convergence) ensureService(ctx context.Context, project *types.Project, for i := 0; i < expected-actual; i++ { // Scale UP number := next + i - name := getContainerName(project.Name, service, number) + name := getContainerName(project.Name, service.Name, service.ContainerName, number) eventOpts := tracing.SpanOptions{trace.WithAttributes(attribute.String("container.name", name))} eg.Go(tracing.EventWrapFuncForErrGroup(ctx, "service/scale/up", eventOpts, func(ctx context.Context) error { opts := createOptions{ @@ -416,10 +416,10 @@ func checkExpectedVolumes(expected types.ServiceConfig, actual container.Summary return false } -func getContainerName(projectName string, service types.ServiceConfig, number int) string { - name := getDefaultContainerName(projectName, service.Name, strconv.Itoa(number)) - if service.ContainerName != "" { - name = service.ContainerName +func getContainerName(projectName string, serviceName string, containerName string, number int) string { + name := getDefaultContainerName(projectName, serviceName, strconv.Itoa(number)) + if containerName != "" { + name = containerName } return name } @@ -604,9 +604,16 @@ func nextContainerNumber(containers []container.Summary) int { func (s *composeService) createContainer(ctx context.Context, project *types.Project, service types.ServiceConfig, name string, number int, opts createOptions, ) (ctr container.Summary, err error) { + hash, err := ServiceHash(service) + if err != nil { + return ctr, err + } + opts.Labels[api.ConfigHashLabel] = hash + opts.Labels[api.DependenciesLabel] = formatDependencies(service.DependsOn) + eventName := "Container " + name s.events.On(creatingEvent(eventName)) - ctr, err = s.createMobyContainer(ctx, project, service, name, number, nil, opts) + ctr, err = s.createMobyContainer(ctx, project, service.Name, &service.ContainerSpec, service.Deploy, name, number, nil, opts) if err != nil { if ctx.Err() == nil { s.events.On(api.Resource{ @@ -650,15 +657,22 @@ func (s *composeService) recreateContainer(ctx context.Context, project *types.P if replacedContainerName == "" { replacedContainerName = service.Name + api.Separator + strconv.Itoa(number) } - name := getContainerName(project.Name, service, number) + name := getContainerName(project.Name, service.Name, service.ContainerName, number) tmpName := fmt.Sprintf("%s_%s", replaced.ID[:12], name) + hash, err := ServiceHash(service) + if err != nil { + return created, err + } opts := createOptions{ AutoRemove: false, AttachStdin: false, UseNetworkAliases: true, - Labels: mergeLabels(service.Labels, service.CustomLabels).Add(api.ContainerReplaceLabel, replacedContainerName), + Labels: mergeLabels(service.Labels, service.CustomLabels). + Add(api.ContainerReplaceLabel, replacedContainerName). + Add(api.ConfigHashLabel, hash). + Add(api.DependenciesLabel, formatDependencies(service.DependsOn)), } - created, err = s.createMobyContainer(ctx, project, service, tmpName, number, inherited, opts) + created, err = s.createMobyContainer(ctx, project, service.Name, &service.ContainerSpec, service.Deploy, tmpName, number, inherited, opts) if err != nil { return created, err } @@ -700,15 +714,16 @@ func (s *composeService) startContainer(ctx context.Context, ctr container.Summa return nil } -func (s *composeService) createMobyContainer(ctx context.Context, project *types.Project, service types.ServiceConfig, +func (s *composeService) createMobyContainer(ctx context.Context, project *types.Project, + serviceName string, spec *types.ContainerSpec, deploy *types.DeployConfig, name string, number int, inherit *container.Summary, opts createOptions, ) (container.Summary, error) { var created container.Summary - cfgs, err := s.getCreateConfigs(ctx, project, service, number, inherit, opts) + cfgs, err := s.getCreateConfigs(ctx, project, serviceName, spec, deploy, number, inherit, opts) if err != nil { return created, err } - platform := service.Platform + platform := spec.Platform if platform == "" { platform = project.Environment["DOCKER_DEFAULT_PLATFORM"] } @@ -734,7 +749,7 @@ func (s *composeService) createMobyContainer(ctx context.Context, project *types } for _, warning := range response.Warnings { s.events.On(api.Resource{ - ID: service.Name, + ID: serviceName, Status: api.Warning, Text: warning, }) @@ -748,14 +763,14 @@ func (s *composeService) createMobyContainer(ctx context.Context, project *types return created, err } if versions.LessThan(apiVersion, apiVersion144) { - serviceNetworks := service.NetworksByPriority() + serviceNetworks := spec.NetworksByPriority() for _, networkKey := range serviceNetworks { mobyNetworkName := project.Networks[networkKey].Name if string(cfgs.Host.NetworkMode) == mobyNetworkName { // primary network already configured as part of ContainerCreate continue } - epSettings, err := createEndpointSettings(project, service, number, networkKey, cfgs.Links, opts.UseNetworkAliases) + epSettings, err := createEndpointSettings(project, serviceName, spec, number, networkKey, cfgs.Links, opts.UseNetworkAliases) if err != nil { _, _ = s.apiClient().ContainerRemove(ctx, response.ID, client.ContainerRemoveOptions{Force: true}) return created, err @@ -787,16 +802,16 @@ func (s *composeService) createMobyContainer(ctx context.Context, project *types } // getLinks mimics V1 compose/service.py::Service::_get_links() -func (s *composeService) getLinks(ctx context.Context, projectName string, service types.ServiceConfig, number int) ([]string, error) { +func (s *composeService) getLinks(ctx context.Context, projectName string, serviceName string, spec *types.ContainerSpec, number int) ([]string, error) { var links []string format := func(k, v string) string { return fmt.Sprintf("%s:%s", k, v) } - getServiceContainers := func(serviceName string) (Containers, error) { - return s.getContainers(ctx, projectName, oneOffExclude, true, serviceName) + getServiceContainers := func(svcName string) (Containers, error) { + return s.getContainers(ctx, projectName, oneOffExclude, true, svcName) } - for _, rawLink := range service.Links { + for _, rawLink := range spec.Links { // linkName if informed like in: "serviceName[:linkName]" linkServiceName, linkName, ok := strings.Cut(rawLink, ":") if !ok { @@ -816,22 +831,22 @@ func (s *composeService) getLinks(ctx context.Context, projectName string, servi } } - if service.Labels[api.OneoffLabel] == "True" { - cnts, err := getServiceContainers(service.Name) + if spec.Labels[api.OneoffLabel] == "True" { + cnts, err := getServiceContainers(serviceName) if err != nil { return nil, err } for _, c := range cnts { containerName := getCanonicalContainerName(c) links = append(links, - format(containerName, service.Name), + format(containerName, serviceName), format(containerName, strings.TrimPrefix(containerName, projectName+api.Separator)), format(containerName, containerName), ) } } - for _, rawExtLink := range service.ExternalLinks { + for _, rawExtLink := range spec.ExternalLinks { externalLink, linkName, ok := strings.Cut(rawExtLink, ":") if !ok { linkName = externalLink @@ -945,6 +960,14 @@ func (s *composeService) startService(ctx context.Context, return nil } +func formatDependencies(dependsOn types.DependsOnConfig) string { + var dependencies []string + for s, d := range dependsOn { + dependencies = append(dependencies, fmt.Sprintf("%s:%s:%t", s, d.Condition, d.Restart)) + } + return strings.Join(dependencies, ",") +} + func mergeLabels(ls ...types.Labels) types.Labels { merged := types.Labels{} for _, l := range ls { diff --git a/pkg/compose/convergence_test.go b/pkg/compose/convergence_test.go index 0635e85a82e..365208440f1 100644 --- a/pkg/compose/convergence_test.go +++ b/pkg/compose/convergence_test.go @@ -97,7 +97,7 @@ func TestServiceLinks(t *testing.T) { Items: []container.Summary{c}, }, nil) - links, err := tested.(*composeService).getLinks(t.Context(), testProject, s, 1) + links, err := tested.(*composeService).getLinks(t.Context(), testProject, s.Name, &s.ContainerSpec, 1) assert.NilError(t, err) assert.Equal(t, len(links), 3) @@ -122,7 +122,7 @@ func TestServiceLinks(t *testing.T) { apiClient.EXPECT().ContainerList(gomock.Any(), containerListOptions).Return(client.ContainerListResult{ Items: []container.Summary{c}, }, nil) - links, err := tested.(*composeService).getLinks(t.Context(), testProject, s, 1) + links, err := tested.(*composeService).getLinks(t.Context(), testProject, s.Name, &s.ContainerSpec, 1) assert.NilError(t, err) assert.Equal(t, len(links), 3) @@ -147,7 +147,7 @@ func TestServiceLinks(t *testing.T) { Items: []container.Summary{c}, }, nil) - links, err := tested.(*composeService).getLinks(t.Context(), testProject, s, 1) + links, err := tested.(*composeService).getLinks(t.Context(), testProject, s.Name, &s.ContainerSpec, 1) assert.NilError(t, err) assert.Equal(t, len(links), 3) @@ -173,7 +173,7 @@ func TestServiceLinks(t *testing.T) { Items: []container.Summary{c}, }, nil) - links, err := tested.(*composeService).getLinks(t.Context(), testProject, s, 1) + links, err := tested.(*composeService).getLinks(t.Context(), testProject, s.Name, &s.ContainerSpec, 1) assert.NilError(t, err) assert.Equal(t, len(links), 4) @@ -211,7 +211,7 @@ func TestServiceLinks(t *testing.T) { Items: []container.Summary{c}, }, nil) - links, err := tested.(*composeService).getLinks(t.Context(), testProject, s, 1) + links, err := tested.(*composeService).getLinks(t.Context(), testProject, s.Name, &s.ContainerSpec, 1) assert.NilError(t, err) assert.Equal(t, len(links), 3) @@ -461,8 +461,13 @@ func TestCreateMobyContainer(t *testing.T) { }, }, nil) - _, err = tested.(*composeService).createMobyContainer(t.Context(), &project, service, "test", 0, nil, createOptions{ - Labels: make(types.Labels), + hash, err := ServiceHash(service) + assert.NilError(t, err) + _, err = tested.(*composeService).createMobyContainer(t.Context(), &project, service.Name, &service.ContainerSpec, service.Deploy, "test", 0, nil, createOptions{ + Labels: types.Labels{ + "com.docker.compose.config-hash": hash, + "com.docker.compose.depends_on": "", + }, }) var falseBool bool want := client.ContainerCreateOptions{ @@ -471,7 +476,7 @@ func TestCreateMobyContainer(t *testing.T) { AttachStderr: true, Image: "bork-test", Labels: map[string]string{ - "com.docker.compose.config-hash": "8dbce408396f8986266bc5deba0c09cfebac63c95c2238e405c7bee5f1bd84b8", + "com.docker.compose.config-hash": hash, "com.docker.compose.depends_on": "", }, }, @@ -576,7 +581,7 @@ func TestCreateMobyContainerLegacyAPI(t *testing.T) { }, }, nil) - _, err = tested.(*composeService).createMobyContainer(t.Context(), &project, service, "test", 0, nil, createOptions{ + _, err = tested.(*composeService).createMobyContainer(t.Context(), &project, service.Name, &service.ContainerSpec, service.Deploy, "test", 0, nil, createOptions{ Labels: make(types.Labels), UseNetworkAliases: true, }) @@ -645,7 +650,7 @@ func TestCreateMobyContainerLegacyAPI_NetworkConnectFailure(t *testing.T) { return client.ContainerRemoveResult{}, nil }) - _, err = tested.(*composeService).createMobyContainer(t.Context(), &project, service, "test", 0, nil, createOptions{ + _, err = tested.(*composeService).createMobyContainer(t.Context(), &project, service.Name, &service.ContainerSpec, service.Deploy, "test", 0, nil, createOptions{ Labels: make(types.Labels), UseNetworkAliases: true, }) diff --git a/pkg/compose/create.go b/pkg/compose/create.go index cc0b4afbce3..c324a24c139 100644 --- a/pkg/compose/create.go +++ b/pkg/compose/create.go @@ -168,57 +168,54 @@ func (s *composeService) ensureProjectVolumes(ctx context.Context, project *type //nolint:gocyclo func (s *composeService) getCreateConfigs(ctx context.Context, p *types.Project, - service types.ServiceConfig, + serviceName string, spec *types.ContainerSpec, deploy *types.DeployConfig, number int, inherit *container.Summary, opts createOptions, ) (createConfigs, error) { - labels, err := s.prepareLabels(opts.Labels, service, number) - if err != nil { - return createConfigs{}, err - } + labels := s.prepareLabels(opts.Labels, number) var runCmd, entrypoint []string - if service.Command != nil { - runCmd = service.Command + if spec.Command != nil { + runCmd = spec.Command } - if service.Entrypoint != nil { - entrypoint = service.Entrypoint + if spec.Entrypoint != nil { + entrypoint = spec.Entrypoint } var ( - tty = service.Tty - stdinOpen = service.StdinOpen + tty = spec.Tty + stdinOpen = spec.StdinOpen ) proxyConfig := types.MappingWithEquals(s.configFile().ParseProxyConfig(s.apiClient().DaemonHost(), nil)) - env := proxyConfig.OverrideBy(service.Environment) + env := proxyConfig.OverrideBy(spec.Environment) var mainNwName string var mainNw *types.ServiceNetworkConfig - if len(service.Networks) > 0 { - mainNwName = service.NetworksByPriority()[0] - mainNw = service.Networks[mainNwName] + if len(spec.Networks) > 0 { + mainNwName = spec.NetworksByPriority()[0] + mainNw = spec.Networks[mainNwName] } - if err := s.prepareContainerMACAddress(service, mainNw, mainNwName); err != nil { + if err := s.prepareContainerMACAddress(spec.MacAddress, mainNw, mainNwName); err != nil { return createConfigs{}, err } - healthcheck, err := s.ToMobyHealthCheck(ctx, service.HealthCheck) + healthcheck, err := s.ToMobyHealthCheck(ctx, spec.HealthCheck) if err != nil { return createConfigs{}, err } - exposedPorts, err := buildContainerPorts(service) + exposedPorts, err := buildContainerPorts(spec) if err != nil { return createConfigs{}, err } containerConfig := container.Config{ - Hostname: service.Hostname, - Domainname: service.DomainName, - User: service.User, + Hostname: spec.Hostname, + Domainname: spec.DomainName, + User: spec.User, ExposedPorts: exposedPorts, Tty: tty, OpenStdin: stdinOpen, @@ -227,28 +224,28 @@ func (s *composeService) getCreateConfigs(ctx context.Context, AttachStderr: true, AttachStdout: true, Cmd: runCmd, - Image: api.GetImageNameOrDefault(service, p.Name), - WorkingDir: service.WorkingDir, + Image: api.ImageNameOrDefault(spec.Image, serviceName, p.Name), + WorkingDir: spec.WorkingDir, Entrypoint: entrypoint, - NetworkDisabled: service.NetworkMode == "disabled", + NetworkDisabled: spec.NetworkMode == "disabled", Labels: labels, - StopSignal: service.StopSignal, + StopSignal: spec.StopSignal, Env: ToMobyEnv(env), Healthcheck: healthcheck, - StopTimeout: ToSeconds(service.StopGracePeriod), + StopTimeout: ToSeconds(spec.StopGracePeriod), } // VOLUMES/MOUNTS/FILESYSTEMS tmpfs := map[string]string{} - for _, t := range service.Tmpfs { + for _, t := range spec.Tmpfs { k, v, _ := strings.Cut(t, ":") tmpfs[k] = v } - binds, mounts, err := s.buildContainerVolumes(ctx, *p, service, inherit) + binds, mounts, err := s.buildContainerVolumes(ctx, *p, serviceName, spec, inherit) if err != nil { return createConfigs{}, err } // NETWORKING - links, err := s.getLinks(ctx, p.Name, service, number) + links, err := s.getLinks(ctx, p.Name, serviceName, spec, number) if err != nil { return createConfigs{}, err } @@ -256,31 +253,31 @@ func (s *composeService) getCreateConfigs(ctx context.Context, if err != nil { return createConfigs{}, err } - networkMode, networkingConfig, err := defaultNetworkSettings(p, service, number, links, opts.UseNetworkAliases, apiVersion) + networkMode, networkingConfig, err := defaultNetworkSettings(p, serviceName, spec, number, links, opts.UseNetworkAliases, apiVersion) if err != nil { return createConfigs{}, err } - portBindings, err := buildContainerPortBindingOptions(service) + portBindings, err := buildContainerPortBindingOptions(spec) if err != nil { return createConfigs{}, err } // MISC - resources := getDeployResources(service) + resources := getDeployResources(spec, deploy) var logConfig container.LogConfig - if service.Logging != nil { + if spec.Logging != nil { logConfig = container.LogConfig{ - Type: service.Logging.Driver, - Config: service.Logging.Options, + Type: spec.Logging.Driver, + Config: spec.Logging.Options, } } - securityOpts, unconfined, err := parseSecurityOpts(p, service.SecurityOpt) + securityOpts, unconfined, err := parseSecurityOpts(p, spec.SecurityOpt) if err != nil { return createConfigs{}, err } var dnsIPs []netip.Addr - for _, d := range service.DNS { + for _, d := range spec.DNS { dnsIP, err := netip.ParseAddr(d) if err != nil { return createConfigs{}, fmt.Errorf("invalid DNS address: %w", err) @@ -290,40 +287,40 @@ func (s *composeService) getCreateConfigs(ctx context.Context, hostConfig := container.HostConfig{ AutoRemove: opts.AutoRemove, - Annotations: service.Annotations, + Annotations: spec.Annotations, Binds: binds, Mounts: mounts, - CapAdd: service.CapAdd, - CapDrop: service.CapDrop, + CapAdd: spec.CapAdd, + CapDrop: spec.CapDrop, NetworkMode: networkMode, - Init: service.Init, - IpcMode: container.IpcMode(service.Ipc), - CgroupnsMode: container.CgroupnsMode(service.Cgroup), - ReadonlyRootfs: service.ReadOnly, - RestartPolicy: getRestartPolicy(service), - ShmSize: int64(service.ShmSize), - Sysctls: service.Sysctls, + Init: spec.Init, + IpcMode: container.IpcMode(spec.Ipc), + CgroupnsMode: container.CgroupnsMode(spec.Cgroup), + ReadonlyRootfs: spec.ReadOnly, + RestartPolicy: getRestartPolicy(spec.Restart, deploy), + ShmSize: int64(spec.ShmSize), + Sysctls: spec.Sysctls, PortBindings: portBindings, Resources: resources, - VolumeDriver: service.VolumeDriver, - VolumesFrom: service.VolumesFrom, + VolumeDriver: spec.VolumeDriver, + VolumesFrom: spec.VolumesFrom, DNS: dnsIPs, - DNSSearch: service.DNSSearch, - DNSOptions: service.DNSOpts, - ExtraHosts: service.ExtraHosts.AsList(":"), + DNSSearch: spec.DNSSearch, + DNSOptions: spec.DNSOpts, + ExtraHosts: spec.ExtraHosts.AsList(":"), SecurityOpt: securityOpts, - StorageOpt: service.StorageOpt, - UsernsMode: container.UsernsMode(service.UserNSMode), - UTSMode: container.UTSMode(service.Uts), - Privileged: service.Privileged, - PidMode: container.PidMode(service.Pid), + StorageOpt: spec.StorageOpt, + UsernsMode: container.UsernsMode(spec.UserNSMode), + UTSMode: container.UTSMode(spec.Uts), + Privileged: spec.Privileged, + PidMode: container.PidMode(spec.Pid), Tmpfs: tmpfs, - Isolation: container.Isolation(service.Isolation), - Runtime: service.Runtime, + Isolation: container.Isolation(spec.Isolation), + Runtime: spec.Runtime, LogConfig: logConfig, - GroupAdd: service.GroupAdd, + GroupAdd: spec.GroupAdd, Links: links, - OomScoreAdj: int(service.OomScoreAdj), + OomScoreAdj: int(spec.OomScoreAdj), } if unconfined { @@ -346,19 +343,7 @@ func (s *composeService) getCreateConfigs(ctx context.Context, // passed mainNw to provide backward-compatibility whenever possible. // // It returns the container-wide MAC address, but this value will be kept empty for newer API versions. -func (s *composeService) prepareContainerMACAddress(service types.ServiceConfig, mainNw *types.ServiceNetworkConfig, nwName string) error { - // Engine API 1.44 added support for endpoint-specific MAC address and now returns a warning when a MAC address is - // set in container.Config. Thus, we have to jump through a number of hoops: - // - // 1. Top-level mac_address and main endpoint's MAC address should be the same ; - // 2. If supported by the API, top-level mac_address should be migrated to the main endpoint and container.Config - // should be kept empty ; - // 3. Otherwise, the endpoint mac_address should be set in container.Config and no other endpoint-specific - // mac_address can be specified. If that's the case, use top-level mac_address ; - // - // After that, if an endpoint mac_address is set, it's either user-defined or migrated by the code below, so - // there's no need to check for API version in defaultNetworkSettings. - macAddress := service.MacAddress +func (s *composeService) prepareContainerMACAddress(macAddress string, mainNw *types.ServiceNetworkConfig, nwName string) error { if macAddress != "" && mainNw != nil && mainNw.MacAddress != "" && mainNw.MacAddress != macAddress { return fmt.Errorf("the service-level mac_address should have the same value as network %s", nwName) } @@ -368,10 +353,10 @@ func (s *composeService) prepareContainerMACAddress(service types.ServiceConfig, return nil } -func getAliases(project *types.Project, service types.ServiceConfig, serviceIndex int, cfg *types.ServiceNetworkConfig, useNetworkAliases bool) []string { - aliases := []string{getContainerName(project.Name, service, serviceIndex)} +func getAliases(project *types.Project, serviceName string, containerName string, serviceIndex int, cfg *types.ServiceNetworkConfig, useNetworkAliases bool) []string { + aliases := []string{getContainerName(project.Name, serviceName, containerName, serviceIndex)} if useNetworkAliases { - aliases = append(aliases, service.Name) + aliases = append(aliases, serviceName) if cfg != nil { aliases = append(aliases, cfg.Aliases...) } @@ -379,10 +364,13 @@ func getAliases(project *types.Project, service types.ServiceConfig, serviceInde return aliases } -func createEndpointSettings(p *types.Project, service types.ServiceConfig, serviceIndex int, networkKey string, links []string, useNetworkAliases bool) (*network.EndpointSettings, error) { +func createEndpointSettings( + p *types.Project, serviceName string, spec *types.ContainerSpec, + serviceIndex int, networkKey string, links []string, useNetworkAliases bool, +) (*network.EndpointSettings, error) { const ifname = "com.docker.network.endpoint.ifname" - config := service.Networks[networkKey] + config := spec.Networks[networkKey] var ipam *network.EndpointIPAMConfig var ( ipv4Address netip.Addr @@ -429,7 +417,7 @@ func createEndpointSettings(p *types.Project, service types.ServiceConfig, servi driverOpts = map[string]string{} } if name, ok := driverOpts[ifname]; ok && name != config.InterfaceName { - logrus.Warnf("ignoring services.%s.networks.%s.interface_name as %s driver_opts is already declared", service.Name, networkKey, ifname) + logrus.Warnf("ignoring services.%s.networks.%s.interface_name as %s driver_opts is already declared", serviceName, networkKey, ifname) } driverOpts[ifname] = config.InterfaceName } @@ -445,7 +433,7 @@ func createEndpointSettings(p *types.Project, service types.ServiceConfig, servi } return &network.EndpointSettings{ - Aliases: getAliases(p, service, serviceIndex, config, useNetworkAliases), + Aliases: getAliases(p, serviceName, spec.ContainerName, serviceIndex, config, useNetworkAliases), Links: links, IPAddress: ipv4Address, IPv6Gateway: ipv6Address, @@ -494,34 +482,23 @@ func parseSecurityOpts(p *types.Project, securityOpts []string) ([]string, bool, return parsed, unconfined, nil } -func (s *composeService) prepareLabels(labels types.Labels, service types.ServiceConfig, number int) (map[string]string, error) { - hash, err := ServiceHash(service) - if err != nil { - return nil, err - } - labels[api.ConfigHashLabel] = hash - +func (s *composeService) prepareLabels(labels types.Labels, number int) map[string]string { if number > 0 { // One-off containers are not indexed labels[api.ContainerNumberLabel] = strconv.Itoa(number) } - var dependencies []string - for s, d := range service.DependsOn { - dependencies = append(dependencies, fmt.Sprintf("%s:%s:%t", s, d.Condition, d.Restart)) - } - labels[api.DependenciesLabel] = strings.Join(dependencies, ",") - return labels, nil + return labels } // defaultNetworkSettings determines the container.NetworkMode and corresponding network.NetworkingConfig (nil if not applicable). func defaultNetworkSettings(project *types.Project, - service types.ServiceConfig, serviceIndex int, + serviceName string, spec *types.ContainerSpec, serviceIndex int, links []string, useNetworkAliases bool, version string, ) (container.NetworkMode, *network.NetworkingConfig, error) { - if service.NetworkMode != "" { - return container.NetworkMode(service.NetworkMode), nil, nil + if spec.NetworkMode != "" { + return container.NetworkMode(spec.NetworkMode), nil, nil } if len(project.Networks) == 0 { @@ -529,26 +506,26 @@ func defaultNetworkSettings(project *types.Project, } if versions.LessThan(version, apiVersion149) { - for _, config := range service.Networks { + for _, config := range spec.Networks { if config != nil && config.InterfaceName != "" { return "", nil, fmt.Errorf("interface_name requires Docker Engine %s or later", DockerEngineV28_1) } } } - serviceNetworks := service.NetworksByPriority() + serviceNetworks := spec.NetworksByPriority() primaryNetworkKey := "default" if len(serviceNetworks) > 0 { primaryNetworkKey = serviceNetworks[0] serviceNetworks = serviceNetworks[1:] } - primaryNetworkEndpoint, err := createEndpointSettings(project, service, serviceIndex, primaryNetworkKey, links, useNetworkAliases) + primaryNetworkEndpoint, err := createEndpointSettings(project, serviceName, spec, serviceIndex, primaryNetworkKey, links, useNetworkAliases) if err != nil { return "", nil, err } if primaryNetworkEndpoint.MacAddress.String() == "" { - primaryNetworkEndpoint.MacAddress, err = parseMACAddr(service.MacAddress) + primaryNetworkEndpoint.MacAddress, err = parseMACAddr(spec.MacAddress) if err != nil { return "", nil, err } @@ -567,7 +544,7 @@ func defaultNetworkSettings(project *types.Project, // container creation (see createMobyContainer in convergence.go). if !versions.LessThan(version, apiVersion144) { for _, networkKey := range serviceNetworks { - epSettings, err := createEndpointSettings(project, service, serviceIndex, networkKey, links, useNetworkAliases) + epSettings, err := createEndpointSettings(project, serviceName, spec, serviceIndex, networkKey, links, useNetworkAliases) if err != nil { return "", nil, err } @@ -586,31 +563,31 @@ func defaultNetworkSettings(project *types.Project, return container.NetworkMode(primaryNetworkMobyNetworkName), networkConfig, nil } -func getRestartPolicy(service types.ServiceConfig) container.RestartPolicy { - var restart container.RestartPolicy - if service.Restart != "" { - name, num, ok := strings.Cut(service.Restart, ":") +func getRestartPolicy(restart string, deploy *types.DeployConfig) container.RestartPolicy { + var restartPolicy container.RestartPolicy + if restart != "" { + name, num, ok := strings.Cut(restart, ":") var attempts int if ok { attempts, _ = strconv.Atoi(num) } - restart = container.RestartPolicy{ + restartPolicy = container.RestartPolicy{ Name: mapRestartPolicyCondition(name), MaximumRetryCount: attempts, } } - if service.Deploy != nil && service.Deploy.RestartPolicy != nil { - policy := *service.Deploy.RestartPolicy + if deploy != nil && deploy.RestartPolicy != nil { + policy := *deploy.RestartPolicy var attempts int if policy.MaxAttempts != nil { attempts = int(*policy.MaxAttempts) } - restart = container.RestartPolicy{ + restartPolicy = container.RestartPolicy{ Name: mapRestartPolicyCondition(policy.Condition), MaximumRetryCount: attempts, } } - return restart + return restartPolicy } func mapRestartPolicyCondition(condition string) container.RestartPolicyMode { @@ -629,44 +606,44 @@ func mapRestartPolicyCondition(condition string) container.RestartPolicyMode { } } -func getDeployResources(s types.ServiceConfig) container.Resources { +func getDeployResources(spec *types.ContainerSpec, deploy *types.DeployConfig) container.Resources { var swappiness *int64 - if s.MemSwappiness != 0 { - val := int64(s.MemSwappiness) + if spec.MemSwappiness != 0 { + val := int64(spec.MemSwappiness) swappiness = &val } resources := container.Resources{ - CgroupParent: s.CgroupParent, - Memory: int64(s.MemLimit), - MemorySwap: int64(s.MemSwapLimit), + CgroupParent: spec.CgroupParent, + Memory: int64(spec.MemLimit), + MemorySwap: int64(spec.MemSwapLimit), MemorySwappiness: swappiness, - MemoryReservation: int64(s.MemReservation), - OomKillDisable: &s.OomKillDisable, - CPUCount: s.CPUCount, - CPUPeriod: s.CPUPeriod, - CPUQuota: s.CPUQuota, - CPURealtimePeriod: s.CPURTPeriod, - CPURealtimeRuntime: s.CPURTRuntime, - CPUShares: s.CPUShares, - NanoCPUs: int64(s.CPUS * 1e9), - CPUPercent: int64(s.CPUPercent * 100), - CpusetCpus: s.CPUSet, - DeviceCgroupRules: s.DeviceCgroupRules, + MemoryReservation: int64(spec.MemReservation), + OomKillDisable: &spec.OomKillDisable, + CPUCount: spec.CPUCount, + CPUPeriod: spec.CPUPeriod, + CPUQuota: spec.CPUQuota, + CPURealtimePeriod: spec.CPURTPeriod, + CPURealtimeRuntime: spec.CPURTRuntime, + CPUShares: spec.CPUShares, + NanoCPUs: int64(spec.CPUS * 1e9), + CPUPercent: int64(spec.CPUPercent * 100), + CpusetCpus: spec.CPUSet, + DeviceCgroupRules: spec.DeviceCgroupRules, } - if s.PidsLimit != 0 { - resources.PidsLimit = &s.PidsLimit + if spec.PidsLimit != 0 { + resources.PidsLimit = &spec.PidsLimit } - setBlkio(s.BlkioConfig, &resources) + setBlkio(spec.BlkioConfig, &resources) - if s.Deploy != nil { - setLimits(s.Deploy.Resources.Limits, &resources) - setReservations(s.Deploy.Resources.Reservations, &resources) + if deploy != nil { + setLimits(deploy.Resources.Limits, &resources) + setReservations(deploy.Resources.Reservations, &resources) } var cdiDeviceNames []string - for _, device := range s.Devices { + for _, device := range spec.Devices { if device.Source == device.Target && cdi.IsQualifiedName(device.Source) { cdiDeviceNames = append(cdiDeviceNames, device.Source) @@ -687,7 +664,7 @@ func getDeployResources(s types.ServiceConfig) container.Resources { }) } - for _, gpus := range s.Gpus { + for _, gpus := range spec.Gpus { resources.DeviceRequests = append(resources.DeviceRequests, container.DeviceRequest{ Driver: gpus.Driver, Count: int(gpus.Count), @@ -697,7 +674,7 @@ func getDeployResources(s types.ServiceConfig) container.Resources { }) } - ulimits := toUlimits(s.Ulimits) + ulimits := toUlimits(spec.Ulimits) resources.Ulimits = ulimits return resources } @@ -795,10 +772,10 @@ func setBlkio(blkio *types.BlkioConfig, resources *container.Resources) { } } -func buildContainerPorts(s types.ServiceConfig) (network.PortSet, error) { +func buildContainerPorts(spec *types.ContainerSpec) (network.PortSet, error) { // Add published ports as exposed ports. exposedPorts := network.PortSet{} - for _, p := range s.Ports { + for _, p := range spec.Ports { np, err := network.ParsePort(fmt.Sprintf("%d/%s", p.Target, p.Protocol)) if err != nil { return nil, err @@ -807,7 +784,7 @@ func buildContainerPorts(s types.ServiceConfig) (network.PortSet, error) { } // Merge in exposed ports to the map of published ports - for _, e := range s.Expose { + for _, e := range spec.Expose { // support two formats for expose, original format /[] // or /[] pr, err := network.ParsePortRange(e) @@ -823,9 +800,9 @@ func buildContainerPorts(s types.ServiceConfig) (network.PortSet, error) { return exposedPorts, nil } -func buildContainerPortBindingOptions(s types.ServiceConfig) (network.PortMap, error) { +func buildContainerPortBindingOptions(spec *types.ContainerSpec) (network.PortMap, error) { bindings := network.PortMap{} - for _, port := range s.Ports { + for _, port := range spec.Ports { var err error p, err := network.ParsePort(fmt.Sprintf("%d/%s", port.Target, port.Protocol)) if err != nil { @@ -859,13 +836,13 @@ func getDependentServiceFromMode(mode string) string { func (s *composeService) buildContainerVolumes( ctx context.Context, p types.Project, - service types.ServiceConfig, + serviceName string, spec *types.ContainerSpec, inherit *container.Summary, ) ([]string, []mount.Mount, error) { var mounts []mount.Mount var binds []string - mountOptions, err := s.buildContainerMountOptions(ctx, p, service, inherit) + mountOptions, err := s.buildContainerMountOptions(ctx, p, serviceName, spec, inherit) if err != nil { return nil, nil, err } @@ -876,7 +853,7 @@ func (s *composeService) buildContainerVolumes( // `Mount` is preferred but does not offer option to created host path if missing // so `Bind` API is used here with raw volume string // see https://github.com/moby/moby/issues/43483 - v := findVolumeByTarget(service.Volumes, m.Target) + v := findVolumeByTarget(spec.Volumes, m.Target) if v != nil { if v.Type != types.VolumeTypeBind { v.Source = m.Source @@ -891,7 +868,7 @@ func (s *composeService) buildContainerVolumes( } } case mount.TypeVolume: - v := findVolumeByTarget(service.Volumes, m.Target) + v := findVolumeByTarget(spec.Volumes, m.Target) vol := findVolumeByName(p.Volumes, m.Source) if v != nil && vol != nil { // Prefer the bind API if no advanced option is used, to preserve backward compatibility @@ -986,7 +963,7 @@ func volumeRequiresMountAPI(vol *types.ServiceVolumeVolume) bool { } } -func (s *composeService) buildContainerMountOptions(ctx context.Context, p types.Project, service types.ServiceConfig, inherit *container.Summary) ([]mount.Mount, error) { +func (s *composeService) buildContainerMountOptions(ctx context.Context, p types.Project, serviceName string, spec *types.ContainerSpec, inherit *container.Summary) ([]mount.Mount, error) { mounts := map[string]mount.Mount{} if inherit != nil { for _, m := range inherit.Mounts { @@ -998,7 +975,7 @@ func (s *composeService) buildContainerMountOptions(ctx context.Context, p types src = m.Name } - img, err := s.apiClient().ImageInspect(ctx, api.GetImageNameOrDefault(service, p.Name)) + img, err := s.apiClient().ImageInspect(ctx, api.ImageNameOrDefault(spec.Image, serviceName, p.Name)) if err != nil { return nil, err } @@ -1015,7 +992,7 @@ func (s *composeService) buildContainerMountOptions(ctx context.Context, p types } } volumes := []types.ServiceVolumeConfig{} - for _, v := range service.Volumes { + for _, v := range spec.Volumes { if v.Target != m.Destination || v.Source != "" { volumes = append(volumes, v) continue @@ -1028,11 +1005,11 @@ func (s *composeService) buildContainerMountOptions(ctx context.Context, p types ReadOnly: !m.RW, } } - service.Volumes = volumes + spec.Volumes = volumes } } - mounts, err := fillBindMounts(p, service, mounts) + mounts, err := fillBindMounts(p, spec, mounts) if err != nil { return nil, err } @@ -1044,8 +1021,8 @@ func (s *composeService) buildContainerMountOptions(ctx context.Context, p types return values, nil } -func fillBindMounts(p types.Project, s types.ServiceConfig, m map[string]mount.Mount) (map[string]mount.Mount, error) { - for _, v := range s.Volumes { +func fillBindMounts(p types.Project, spec *types.ContainerSpec, m map[string]mount.Mount) (map[string]mount.Mount, error) { + for _, v := range spec.Volumes { bindMount, err := buildMount(p, v) if err != nil { return nil, err @@ -1053,7 +1030,7 @@ func fillBindMounts(p types.Project, s types.ServiceConfig, m map[string]mount.M m[bindMount.Target] = bindMount } - secrets, err := buildContainerSecretMounts(p, s) + secrets, err := buildContainerSecretMounts(p, spec) if err != nil { return nil, err } @@ -1064,7 +1041,7 @@ func fillBindMounts(p types.Project, s types.ServiceConfig, m map[string]mount.M m[s.Target] = s } - configs, err := buildContainerConfigMounts(p, s) + configs, err := buildContainerConfigMounts(p, spec) if err != nil { return nil, err } @@ -1077,11 +1054,11 @@ func fillBindMounts(p types.Project, s types.ServiceConfig, m map[string]mount.M return m, nil } -func buildContainerConfigMounts(p types.Project, s types.ServiceConfig) ([]mount.Mount, error) { +func buildContainerConfigMounts(p types.Project, spec *types.ContainerSpec) ([]mount.Mount, error) { mounts := map[string]mount.Mount{} configsBaseDir := "/" - for _, config := range s.Configs { + for _, config := range spec.Configs { target := config.Target if config.Target == "" { target = configsBaseDir + config.Source @@ -1127,11 +1104,11 @@ func buildContainerConfigMounts(p types.Project, s types.ServiceConfig) ([]mount return values, nil } -func buildContainerSecretMounts(p types.Project, s types.ServiceConfig) ([]mount.Mount, error) { +func buildContainerSecretMounts(p types.Project, spec *types.ContainerSpec) ([]mount.Mount, error) { mounts := map[string]mount.Mount{} secretsDir := "/run/secrets/" - for _, secret := range s.Secrets { + for _, secret := range spec.Secrets { target := secret.Target if secret.Target == "" { target = secretsDir + secret.Source diff --git a/pkg/compose/create_test.go b/pkg/compose/create_test.go index fecbfd495c5..58656f444a4 100644 --- a/pkg/compose/create_test.go +++ b/pkg/compose/create_test.go @@ -168,7 +168,8 @@ func TestBuildContainerMountOptions(t *testing.T) { } mock.EXPECT().ImageInspect(gomock.Any(), "myProject-myService").AnyTimes().Return(client.ImageInspectResult{}, nil) - mounts, err := s.buildContainerMountOptions(t.Context(), project, project.Services["myService"], inherit) + svc := project.Services["myService"] + mounts, err := s.buildContainerMountOptions(t.Context(), project, "myService", &svc.ContainerSpec, inherit) sort.Slice(mounts, func(i, j int) bool { return mounts[i].Target < mounts[j].Target }) @@ -180,7 +181,8 @@ func TestBuildContainerMountOptions(t *testing.T) { assert.Equal(t, mounts[2].VolumeOptions.Subpath, "etc") assert.Equal(t, mounts[3].Target, "\\\\.\\pipe\\docker_engine") - mounts, err = s.buildContainerMountOptions(t.Context(), project, project.Services["myService"], inherit) + svc2 := project.Services["myService"] + mounts, err = s.buildContainerMountOptions(t.Context(), project, "myService", &svc2.ContainerSpec, inherit) sort.Slice(mounts, func(i, j int) bool { return mounts[i].Target < mounts[j].Target }) @@ -223,7 +225,7 @@ func TestDefaultNetworkSettings(t *testing.T) { }), } - networkMode, networkConfig, err := defaultNetworkSettings(&project, service, 1, nil, true, "1.44") + networkMode, networkConfig, err := defaultNetworkSettings(&project, service.Name, &service.ContainerSpec, 1, nil, true, "1.44") assert.NilError(t, err) assert.Equal(t, string(networkMode), "myProject_myNetwork2") assert.Check(t, cmp.Len(networkConfig.EndpointsConfig, 2)) @@ -253,7 +255,7 @@ func TestDefaultNetworkSettings(t *testing.T) { }), } - networkMode, networkConfig, err := defaultNetworkSettings(&project, service, 1, nil, true, "1.44") + networkMode, networkConfig, err := defaultNetworkSettings(&project, service.Name, &service.ContainerSpec, 1, nil, true, "1.44") assert.NilError(t, err) assert.Equal(t, string(networkMode), "myProject_default") assert.Check(t, cmp.Len(networkConfig.EndpointsConfig, 1)) @@ -271,7 +273,7 @@ func TestDefaultNetworkSettings(t *testing.T) { }, } - networkMode, networkConfig, err := defaultNetworkSettings(&project, service, 1, nil, true, "1.44") + networkMode, networkConfig, err := defaultNetworkSettings(&project, service.Name, &service.ContainerSpec, 1, nil, true, "1.44") assert.NilError(t, err) assert.Equal(t, string(networkMode), "none") assert.Check(t, cmp.Nil(networkConfig)) @@ -296,7 +298,7 @@ func TestDefaultNetworkSettings(t *testing.T) { }), } - networkMode, networkConfig, err := defaultNetworkSettings(&project, service, 1, nil, true, "1.43") + networkMode, networkConfig, err := defaultNetworkSettings(&project, service.Name, &service.ContainerSpec, 1, nil, true, "1.43") assert.NilError(t, err) assert.Equal(t, string(networkMode), "myProject_myNetwork2") assert.Check(t, cmp.Len(networkConfig.EndpointsConfig, 1)) @@ -320,7 +322,7 @@ func TestDefaultNetworkSettings(t *testing.T) { }), } - networkMode, networkConfig, err := defaultNetworkSettings(&project, service, 1, nil, true, "1.44") + networkMode, networkConfig, err := defaultNetworkSettings(&project, service.Name, &service.ContainerSpec, 1, nil, true, "1.44") assert.NilError(t, err) assert.Equal(t, string(networkMode), "host") assert.Check(t, cmp.Nil(networkConfig)) @@ -328,28 +330,26 @@ func TestDefaultNetworkSettings(t *testing.T) { } func TestCreateEndpointSettings(t *testing.T) { - eps, err := createEndpointSettings(&composetypes.Project{ - Name: "projName", - }, composetypes.ServiceConfig{ - Name: "serviceName", - ContainerSpec: composetypes.ContainerSpec{ - ContainerName: "containerName", - Networks: map[string]*composetypes.ServiceNetworkConfig{ - "netName": { - Priority: 100, - Aliases: []string{"alias1", "alias2"}, - Ipv4Address: "10.16.17.18", - Ipv6Address: "fdb4:7a7f:373a:3f0c::42", - LinkLocalIPs: []string{"169.254.10.20"}, - MacAddress: "02:00:00:00:00:01", - DriverOpts: composetypes.Options{ - "driverOpt1": "optval1", - "driverOpt2": "optval2", - }, + spec := &composetypes.ContainerSpec{ + ContainerName: "containerName", + Networks: map[string]*composetypes.ServiceNetworkConfig{ + "netName": { + Priority: 100, + Aliases: []string{"alias1", "alias2"}, + Ipv4Address: "10.16.17.18", + Ipv6Address: "fdb4:7a7f:373a:3f0c::42", + LinkLocalIPs: []string{"169.254.10.20"}, + MacAddress: "02:00:00:00:00:01", + DriverOpts: composetypes.Options{ + "driverOpt1": "optval1", + "driverOpt2": "optval2", }, }, }, - }, 0, "netName", []string{"link1", "link2"}, true) + } + eps, err := createEndpointSettings(&composetypes.Project{ + Name: "projName", + }, "serviceName", spec, 0, "netName", []string{"link1", "link2"}, true) assert.NilError(t, err) macAddr, _ := net.ParseMAC("02:00:00:00:00:01") assert.Check(t, cmp.DeepEqual(eps, &network.EndpointSettings{ @@ -487,7 +487,8 @@ volumes: }) assert.NilError(t, err) s := &composeService{} - binds, mounts, err := s.buildContainerVolumes(t.Context(), *p, p.Services["test"], nil) + svc := p.Services["test"] + binds, mounts, err := s.buildContainerVolumes(t.Context(), *p, "test", &svc.ContainerSpec, nil) assert.NilError(t, err) assert.DeepEqual(t, tt.binds, binds) assert.DeepEqual(t, tt.mounts, mounts) diff --git a/pkg/compose/run.go b/pkg/compose/run.go index 88f1c24d4cb..55afcfa6588 100644 --- a/pkg/compose/run.go +++ b/pkg/compose/run.go @@ -44,16 +44,6 @@ type runTarget struct { types.ContainerSpec } -// toServiceConfig converts a runTarget into a ServiceConfig for functions -// in the container creation chain that still require it. This is the single -// bridge point; future refactoring will push ContainerSpec deeper. -func (t runTarget) toServiceConfig() types.ServiceConfig { - return types.ServiceConfig{ - Name: t.Name, - ContainerSpec: t.ContainerSpec, - } -} - type prepareRunResult struct { containerID string target runTarget @@ -199,22 +189,27 @@ func (s *composeService) prepareRun(ctx context.Context, project *types.Project, return prepareRunResult{}, err } - // Bridge to ServiceConfig for container creation layer - service := target.toServiceConfig() - one := 1 - service.Scale = &one - createOpts := createOptions{ AutoRemove: opts.AutoRemove, AttachStdin: opts.Interactive, UseNetworkAliases: opts.UseNetworkAliases, - Labels: mergeLabels(service.Labels, service.CustomLabels), + Labels: mergeLabels(target.Labels, target.CustomLabels), } - created, err := s.createContainer(ctx, project, service, service.ContainerName, -1, createOpts) + eventName := "Container " + target.ContainerName + s.events.On(creatingEvent(eventName)) + created, err := s.createMobyContainer(ctx, project, target.Name, &target.ContainerSpec, nil, target.ContainerName, -1, nil, createOpts) if err != nil { + if ctx.Err() == nil { + s.events.On(api.Resource{ + ID: eventName, + Status: api.Error, + Text: err.Error(), + }) + } return prepareRunResult{}, err } + s.events.On(createdEvent(eventName)) inspect, err := s.apiClient().ContainerInspect(ctx, created.ID, client.ContainerInspectOptions{}) if err != nil { diff --git a/pkg/compose/run_test.go b/pkg/compose/run_test.go index e99184eaf9e..4041d189b38 100644 --- a/pkg/compose/run_test.go +++ b/pkg/compose/run_test.go @@ -119,17 +119,3 @@ func TestResolveRunTarget_JobPreservesContainerSpec(t *testing.T) { assert.Equal(t, len(target.Volumes), 1) assert.Equal(t, target.Volumes[0].Source, "/backups") } - -func TestRunTarget_ToServiceConfig(t *testing.T) { - target := runTarget{ - Name: "test", - ContainerSpec: types.ContainerSpec{ - Image: "myimage", - Command: types.ShellCommand{"echo", "hello"}, - }, - } - svc := target.toServiceConfig() - assert.Equal(t, svc.Name, "test") - assert.Equal(t, svc.Image, "myimage") - assert.DeepEqual(t, []string(svc.Command), []string{"echo", "hello"}) -} From 32cef966aada3be6a17ca36062ed75646b7fecc0 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Fri, 17 Apr 2026 15:19:23 +0200 Subject: [PATCH 5/9] support run flags when target is a job Signed-off-by: Nicolas De Loof --- cmd/compose/run.go | 22 +++------------ pkg/api/api.go | 2 ++ pkg/compose/run.go | 18 ++++++++++++ pkg/e2e/compose_run_test.go | 43 +++++++++++++++++++++++++++++ pkg/e2e/fixtures/run-test/jobs.yaml | 20 ++++++++++++++ 5 files changed, 87 insertions(+), 18 deletions(-) create mode 100644 pkg/e2e/fixtures/run-test/jobs.yaml diff --git a/cmd/compose/run.go b/cmd/compose/run.go index ec1a44aa034..0ec87b8fc78 100644 --- a/cmd/compose/run.go +++ b/cmd/compose/run.go @@ -24,7 +24,6 @@ import ( composecli "github.com/compose-spec/compose-go/v2/cli" "github.com/compose-spec/compose-go/v2/dotenv" - "github.com/compose-spec/compose-go/v2/format" "github.com/compose-spec/compose-go/v2/types" "github.com/docker/cli/cli" "github.com/docker/cli/cli/command" @@ -84,7 +83,6 @@ func (options runOptions) apply(project *types.Project, isJob bool) (*types.Proj } if isJob { - // Jobs are not in project.Services, nothing more to apply return project, nil } @@ -96,27 +94,13 @@ func (options runOptions) apply(project *types.Project, isJob bool) (*types.Proj target.Tty = !options.noTty target.StdinOpen = options.interactive - // --service-ports and --publish are incompatible + // For services, ports are stripped unless --service-ports is set. + // Jobs always keep their declared ports (handled in applyRunOptions). if !options.servicePorts { if len(target.Ports) > 0 { logrus.Debug("Running service without ports exposed as --service-ports=false") } target.Ports = []types.ServicePortConfig{} - for _, p := range options.publish { - config, err := types.ParsePortConfig(p) - if err != nil { - return nil, err - } - target.Ports = append(target.Ports, config...) - } - } - - for _, v := range options.volumes { - volume, err := format.ParseVolume(v) - if err != nil { - return nil, err - } - target.Volumes = append(target.Volumes, volume) } for name := range project.Services { @@ -349,6 +333,8 @@ func runRun(ctx context.Context, backend api.Compose, project *types.Project, op Labels: labels, UseNetworkAliases: options.useAliases, NoDeps: options.noDeps, + Publish: options.publish, + Volumes: options.volumes, Index: 0, } if isJob { diff --git a/pkg/api/api.go b/pkg/api/api.go index 6435df4b7aa..3caf72382c0 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -435,6 +435,8 @@ type RunOptions struct { Privileged bool UseNetworkAliases bool NoDeps bool + Publish []string + Volumes []string // used by exec Index int } diff --git a/pkg/compose/run.go b/pkg/compose/run.go index 55afcfa6588..07019b647ce 100644 --- a/pkg/compose/run.go +++ b/pkg/compose/run.go @@ -24,6 +24,7 @@ import ( "os/signal" "slices" + "github.com/compose-spec/compose-go/v2/format" "github.com/compose-spec/compose-go/v2/types" "github.com/docker/cli/cli" cmd "github.com/docker/cli/cli/command/container" @@ -31,6 +32,7 @@ import ( "github.com/moby/moby/api/types/events" "github.com/moby/moby/client" "github.com/moby/moby/client/pkg/stringid" + "github.com/sirupsen/logrus" "github.com/docker/compose/v5/pkg/api" ) @@ -299,6 +301,22 @@ func applyRunOptions(project *types.Project, target *runTarget, opts api.RunOpti for k, v := range opts.Labels { target.Labels = target.Labels.Add(k, v) } + for _, p := range opts.Publish { + config, err := types.ParsePortConfig(p) + if err != nil { + logrus.Warnf("invalid publish value %q: %v", p, err) + continue + } + target.Ports = append(target.Ports, config...) + } + for _, v := range opts.Volumes { + volume, err := format.ParseVolume(v) + if err != nil { + logrus.Warnf("invalid volume value %q: %v", v, err) + continue + } + target.Volumes = append(target.Volumes, volume) + } } func (s *composeService) startDependencies(ctx context.Context, project *types.Project, options api.RunOptions) error { diff --git a/pkg/e2e/compose_run_test.go b/pkg/e2e/compose_run_test.go index 44c797cb14d..7d4303ae468 100644 --- a/pkg/e2e/compose_run_test.go +++ b/pkg/e2e/compose_run_test.go @@ -278,4 +278,47 @@ func TestLocalComposeRun(t *testing.T) { Err: "cannot attach stdin to a TTY-enabled container because stdin is not a terminal", }) }) + + t.Run("compose run job without dependencies", func(t *testing.T) { + res := c.RunDockerComposeCmd(t, "-f", "./fixtures/run-test/jobs.yaml", "run", "--rm", "simple") + lines := Lines(res.Stdout()) + assert.Equal(t, lines[len(lines)-1], "job executed", res.Stdout()) + + c.RunDockerComposeCmd(t, "-f", "./fixtures/run-test/jobs.yaml", "down", "--remove-orphans") + }) + + t.Run("compose run job starts dependencies", func(t *testing.T) { + res := c.RunDockerComposeCmd(t, "-f", "./fixtures/run-test/jobs.yaml", "run", "--rm", "with-deps") + lines := Lines(res.Stdout()) + assert.Equal(t, lines[len(lines)-1], "job with deps", res.Stdout()) + // db service should have been started as a dependency + assert.Assert(t, strings.Contains(res.Combined(), "db"), res.Combined()) + + c.RunDockerComposeCmd(t, "-f", "./fixtures/run-test/jobs.yaml", "down", "--remove-orphans") + }) + + t.Run("compose run job with command override", func(t *testing.T) { + res := c.RunDockerComposeCmd(t, "-f", "./fixtures/run-test/jobs.yaml", "run", "--rm", "with-command", "echo", "overridden") + lines := Lines(res.Stdout()) + assert.Equal(t, lines[len(lines)-1], "overridden", res.Stdout()) + + c.RunDockerComposeCmd(t, "-f", "./fixtures/run-test/jobs.yaml", "down", "--remove-orphans") + }) + + t.Run("compose run job with --no-deps skips dependencies", func(t *testing.T) { + res := c.RunDockerComposeCmd(t, "-f", "./fixtures/run-test/jobs.yaml", "run", "--rm", "--no-deps", "with-deps") + lines := Lines(res.Stdout()) + assert.Equal(t, lines[len(lines)-1], "job with deps", res.Stdout()) + // db service should NOT have been started + assert.Assert(t, !strings.Contains(res.Combined(), "db Started"), res.Combined()) + + c.RunDockerComposeCmd(t, "-f", "./fixtures/run-test/jobs.yaml", "down", "--remove-orphans") + }) + + t.Run("compose run unknown job or service", func(t *testing.T) { + res := c.RunDockerComposeCmdNoCheck(t, "-f", "./fixtures/run-test/jobs.yaml", "run", "nonexistent") + res.Assert(t, icmd.Expected{ + ExitCode: 1, + }) + }) } diff --git a/pkg/e2e/fixtures/run-test/jobs.yaml b/pkg/e2e/fixtures/run-test/jobs.yaml new file mode 100644 index 00000000000..31b5e3cb9f5 --- /dev/null +++ b/pkg/e2e/fixtures/run-test/jobs.yaml @@ -0,0 +1,20 @@ +services: + db: + image: alpine + command: sleep infinity + +jobs: + simple: + image: alpine + command: echo "job executed" + + with-deps: + image: alpine + command: echo "job with deps" + depends_on: + db: + condition: service_started + + with-command: + image: alpine + command: echo "default command" From 84bd30523a16ffaa4c230cc9c878aebe64f88bd2 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Fri, 17 Apr 2026 15:21:50 +0200 Subject: [PATCH 6/9] use compose-go PR to run tests on CI Signed-off-by: Nicolas De Loof --- cmd/compose/run.go | 10 +----- docs/reference/compose.md | 2 +- docs/reference/docker_compose_run.yaml | 4 +-- go.mod | 2 +- go.sum | 4 +-- pkg/compose/loader.go | 49 ++++++++++++++++++-------- 6 files changed, 41 insertions(+), 30 deletions(-) diff --git a/cmd/compose/run.go b/cmd/compose/run.go index 0ec87b8fc78..7099d8eab9f 100644 --- a/cmd/compose/run.go +++ b/cmd/compose/run.go @@ -187,20 +187,12 @@ func runCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *Backen return err } - project, _, err := p.ToProject(ctx, dockerCli, backend, nil, composecli.WithoutEnvironmentResolution) + project, _, err := p.ToProject(ctx, dockerCli, backend, []string{options.ServiceOrJob}, composecli.WithoutEnvironmentResolution) if err != nil { return err } isJob := isJobName(project, options.ServiceOrJob) - if isJob { - project, err = project.WithSelectedJob(options.ServiceOrJob) - } else { - project, err = project.WithSelectedServices([]string{options.ServiceOrJob}) - } - if err != nil { - return err - } project, err = project.WithServicesEnvironmentResolved(true) if err != nil { diff --git a/docs/reference/compose.md b/docs/reference/compose.md index d80bb86ec62..26cf438e433 100644 --- a/docs/reference/compose.md +++ b/docs/reference/compose.md @@ -35,7 +35,7 @@ Define and run multi-container applications with Docker | [`push`](compose_push.md) | Push service images | | [`restart`](compose_restart.md) | Restart service containers | | [`rm`](compose_rm.md) | Removes stopped service containers | -| [`run`](compose_run.md) | Run a one-off command on a service | +| [`run`](compose_run.md) | Run a one-off command on a service or job | | [`scale`](compose_scale.md) | Scale services | | [`start`](compose_start.md) | Start services | | [`stats`](compose_stats.md) | Display a live stream of container(s) resource usage statistics | diff --git a/docs/reference/docker_compose_run.yaml b/docs/reference/docker_compose_run.yaml index 61c7ca0e8cb..fc0f3d8d2f0 100644 --- a/docs/reference/docker_compose_run.yaml +++ b/docs/reference/docker_compose_run.yaml @@ -1,5 +1,5 @@ command: docker compose run -short: Run a one-off command on a service +short: Run a one-off command on a service or job long: |- Runs a one-time command against a service. @@ -54,7 +54,7 @@ long: |- This runs a database upgrade script, and removes the container when finished running, even if a restart policy is specified in the service configuration. -usage: docker compose run [OPTIONS] SERVICE [COMMAND] [ARGS...] +usage: docker compose run [OPTIONS] SERVICE|JOB [COMMAND] [ARGS...] pname: docker compose plink: docker_compose.yaml options: diff --git a/go.mod b/go.mod index f87ac0e5015..4192c622b82 100644 --- a/go.mod +++ b/go.mod @@ -147,7 +147,7 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect ) -replace github.com/compose-spec/compose-go/v2 => /Users/nicolas/go/src/github.com/compose-spec/compose-go +replace github.com/compose-spec/compose-go/v2 => github.com/ndeloof/compose-go/v2 v2.0.1-0.20260417132041-a0f7078b3027 exclude ( // FIXME(thaJeztah): remove this once kubernetes updated their dependencies to no longer need this. diff --git a/go.sum b/go.sum index 1097cadc4fa..cbd983c07b4 100644 --- a/go.sum +++ b/go.sum @@ -272,8 +272,8 @@ github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ= github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/ndeloof/compose-go/v2 v2.0.1-0.20260417075248-f37d49c3e808 h1:j9xyZ+njAa42UU2nW8d9tGAtg/Sh/v/t1nG7heRytWw= -github.com/ndeloof/compose-go/v2 v2.0.1-0.20260417075248-f37d49c3e808/go.mod h1:ZU6zlcweCZKyiB7BVfCizQT9XmkEIMFE+PRZydVcsZg= +github.com/ndeloof/compose-go/v2 v2.0.1-0.20260417132041-a0f7078b3027 h1:djnpaPpekm8X6oSswGFeFk/8jB4hgXZCP6KqZ8WZXe0= +github.com/ndeloof/compose-go/v2 v2.0.1-0.20260417132041-a0f7078b3027/go.mod h1:ZU6zlcweCZKyiB7BVfCizQT9XmkEIMFE+PRZydVcsZg= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= diff --git a/pkg/compose/loader.go b/pkg/compose/loader.go index 9a0699da7c6..739df27ae54 100644 --- a/pkg/compose/loader.go +++ b/pkg/compose/loader.go @@ -122,26 +122,28 @@ func (s *composeService) postProcessProject(project *types.Project, options api. return nil, errors.New("project name can't be empty. Use ProjectName option to set a valid name") } + // When the target is a job, skip service-oriented processing (profiles, service selection) + // and only select the job's service dependencies. + if len(options.Services) == 1 && project.Jobs != nil { + if _, ok := project.Jobs[options.Services[0]]; ok { + s.addCustomLabels(project, options) + project, err := project.WithSelectedJob(options.Services[0]) + if err != nil { + return nil, err + } + if !options.All { + project = project.WithoutUnnecessaryResources() + } + return project, nil + } + } + project, err := project.WithServicesEnabled(options.Services...) if err != nil { return nil, err } - // Add custom labels - for name, s := range project.Services { - s.CustomLabels = map[string]string{ - api.ProjectLabel: project.Name, - api.ServiceLabel: name, - api.VersionLabel: api.ComposeVersion, - api.WorkingDirLabel: project.WorkingDir, - api.ConfigFilesLabel: strings.Join(project.ComposeFiles, ","), - api.OneoffLabel: "False", - } - if len(options.EnvFiles) != 0 { - s.CustomLabels[api.EnvironmentFileLabel] = strings.Join(options.EnvFiles, ",") - } - project.Services[name] = s - } + s.addCustomLabels(project, options) project, err = project.WithSelectedServices(options.Services) if err != nil { @@ -155,3 +157,20 @@ func (s *composeService) postProcessProject(project *types.Project, options api. return project, nil } + +func (s *composeService) addCustomLabels(project *types.Project, options api.ProjectLoadOptions) { + for name, svc := range project.Services { + svc.CustomLabels = map[string]string{ + api.ProjectLabel: project.Name, + api.ServiceLabel: name, + api.VersionLabel: api.ComposeVersion, + api.WorkingDirLabel: project.WorkingDir, + api.ConfigFilesLabel: strings.Join(project.ComposeFiles, ","), + api.OneoffLabel: "False", + } + if len(options.EnvFiles) != 0 { + svc.CustomLabels[api.EnvironmentFileLabel] = strings.Join(options.EnvFiles, ",") + } + project.Services[name] = svc + } +} From 36078c597adce50f170adcd9f58906a44b9e824a Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Fri, 17 Apr 2026 17:04:06 +0200 Subject: [PATCH 7/9] fix support for building job images Signed-off-by: Nicolas De Loof --- pkg/compose/build.go | 43 +++++++++++++++++++++++++++++++++++++++---- pkg/compose/pull.go | 19 ++++++++++++++++++- pkg/compose/run.go | 1 - 3 files changed, 57 insertions(+), 6 deletions(-) diff --git a/pkg/compose/build.go b/pkg/compose/build.go index 7b9cc76f96c..bcf13dfd219 100644 --- a/pkg/compose/build.go +++ b/pkg/compose/build.go @@ -62,22 +62,25 @@ func (s *composeService) build(ctx context.Context, project *types.Project, opti options.Services = project.ServiceNames() } + // Separate job names from service names and collect buildable jobs + serviceNames := collectBuildableJobs(options.Services, project, localImages, serviceToBuild) + // also include services used as additional_contexts with service: prefix - options.Services = addBuildDependencies(options.Services, project) + serviceNames = addBuildDependencies(serviceNames, project) // Some build dependencies we just introduced may not be enabled var err error - project, err = project.WithServicesEnabled(options.Services...) + project, err = project.WithServicesEnabled(serviceNames...) if err != nil { return nil, err } - project, err = project.WithSelectedServices(options.Services) + project, err = project.WithSelectedServices(serviceNames) if err != nil { return nil, err } - err = project.ForEachService(options.Services, func(serviceName string, service *types.ServiceConfig) error { + err = project.ForEachService(serviceNames, func(serviceName string, service *types.ServiceConfig) error { if service.Build == nil { return nil } @@ -113,6 +116,11 @@ func (s *composeService) ensureImagesExists(ctx context.Context, project *types. return fmt.Errorf("invalid service %q. Must specify either image or build", name) } } + for name, job := range project.Jobs { + if job.Image == "" && job.Build == nil { + return fmt.Errorf("invalid job %q. Must specify either image or build", name) + } + } images, err := s.getLocalImagesDigests(ctx, project) if err != nil { @@ -166,6 +174,30 @@ func (s *composeService) ensureImagesExists(ctx context.Context, project *types. return nil } +// collectBuildableJobs separates job names from service names in the given list. +// Jobs that need building are added to serviceToBuild. Returns only the service names. +func collectBuildableJobs(names []string, project *types.Project, localImages map[string]api.ImageSummary, serviceToBuild map[string]types.ServiceConfig) []string { + var serviceNames []string + for _, name := range names { + job, ok := project.Jobs[name] + if !ok { + serviceNames = append(serviceNames, name) + continue + } + if job.Build == nil { + continue + } + image := api.ImageNameOrDefault(job.Image, name, project.Name) + if _, present := localImages[image]; !present || job.PullPolicy == types.PullPolicyBuild { + serviceToBuild[name] = types.ServiceConfig{ + Name: name, + ContainerSpec: job.ContainerSpec, + } + } + } + return serviceNames +} + func resolveImageVolumes(service *types.ServiceConfig, images map[string]api.ImageSummary, projectName string) { for i, vol := range service.Volumes { if vol.Type == types.VolumeTypeImage { @@ -199,6 +231,9 @@ func (s *composeService) getLocalImagesDigests(ctx context.Context, project *typ } } } + for _, job := range project.Jobs { + imageNames.Add(api.ImageNameOrDefault(job.Image, job.Name, project.Name)) + } imgs, err := s.getImageSummaries(ctx, imageNames.Elements()) if err != nil { return nil, err diff --git a/pkg/compose/pull.go b/pkg/compose/pull.go index a16dcc01734..a92daa0f06a 100644 --- a/pkg/compose/pull.go +++ b/pkg/compose/pull.go @@ -290,6 +290,23 @@ func encodedAuth(ref reference.Named, configFile authProvider) (string, error) { return base64.URLEncoding.EncodeToString(buf), nil } +// collectPullableJobs adds jobs that need pulling to the needPull map. +// Jobs with a build config are skipped (they'll be built instead). +func collectPullableJobs(project *types.Project, images map[string]api.ImageSummary, needPull map[string]types.ServiceConfig) { + for name, job := range project.Jobs { + if job.Image == "" || job.Build != nil { + continue + } + imgName := api.ImageNameOrDefault(job.Image, name, project.Name) + if _, ok := images[imgName]; !ok { + needPull[name] = types.ServiceConfig{ + Name: name, + ContainerSpec: job.ContainerSpec, + } + } + } +} + func (s *composeService) pullRequiredImages(ctx context.Context, project *types.Project, images map[string]api.ImageSummary, quietPull bool) error { needPull := map[string]types.ServiceConfig{} for name, service := range project.Services { @@ -314,8 +331,8 @@ func (s *composeService) pullRequiredImages(ctx context.Context, project *types. } } } - } + collectPullableJobs(project, images, needPull) if len(needPull) == 0 { return nil } diff --git a/pkg/compose/run.go b/pkg/compose/run.go index 07019b647ce..732bfefc436 100644 --- a/pkg/compose/run.go +++ b/pkg/compose/run.go @@ -164,7 +164,6 @@ func (s *composeService) prepareRun(ctx context.Context, project *types.Project, Add(api.SlugLabel, slug). Add(api.OneoffLabel, "True") - // Only ensure image exists for the target, dependencies were already handled by startDependencies buildOpts := prepareBuildOptions(opts) if err := s.ensureImagesExists(ctx, project, buildOpts, opts.QuietPull); err != nil { return prepareRunResult{}, err From 24b2a3ae795c8fdbab702f20b5c7cf721e35af29 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Fri, 17 Apr 2026 17:23:03 +0200 Subject: [PATCH 8/9] fix orphan detection Signed-off-by: Nicolas De Loof --- pkg/compose/hash.go | 13 +++++++++ pkg/compose/run.go | 58 +++++++++++++++++++++---------------- pkg/e2e/compose_run_test.go | 2 +- 3 files changed, 47 insertions(+), 26 deletions(-) diff --git a/pkg/compose/hash.go b/pkg/compose/hash.go index 24c6cce2e88..355398e3c5f 100644 --- a/pkg/compose/hash.go +++ b/pkg/compose/hash.go @@ -42,6 +42,19 @@ func ServiceHash(o types.ServiceConfig) (string, error) { return digest.SHA256.FromBytes(bytes).Encoded(), nil } +// ContainerSpecHash computes the configuration hash for a ContainerSpec (used by jobs and one-off containers). +func ContainerSpecHash(o types.ContainerSpec) (string, error) { + o.Build = nil + o.PullPolicy = "" + o.DependsOn = nil + + bytes, err := json.Marshal(o) + if err != nil { + return "", err + } + return digest.SHA256.FromBytes(bytes).Encoded(), nil +} + // NetworkHash computes the configuration hash for a network. func NetworkHash(o *types.NetworkConfig) (string, error) { bytes, err := json.Marshal(o) diff --git a/pkg/compose/run.go b/pkg/compose/run.go index 732bfefc436..336a8435992 100644 --- a/pkg/compose/run.go +++ b/pkg/compose/run.go @@ -190,39 +190,17 @@ func (s *composeService) prepareRun(ctx context.Context, project *types.Project, return prepareRunResult{}, err } - createOpts := createOptions{ - AutoRemove: opts.AutoRemove, - AttachStdin: opts.Interactive, - UseNetworkAliases: opts.UseNetworkAliases, - Labels: mergeLabels(target.Labels, target.CustomLabels), - } - - eventName := "Container " + target.ContainerName - s.events.On(creatingEvent(eventName)) - created, err := s.createMobyContainer(ctx, project, target.Name, &target.ContainerSpec, nil, target.ContainerName, -1, nil, createOpts) - if err != nil { - if ctx.Err() == nil { - s.events.On(api.Resource{ - ID: eventName, - Status: api.Error, - Text: err.Error(), - }) - } - return prepareRunResult{}, err - } - s.events.On(createdEvent(eventName)) - - inspect, err := s.apiClient().ContainerInspect(ctx, created.ID, client.ContainerInspectOptions{}) + created, err := s.createOneOffContainer(ctx, project, target, opts) if err != nil { return prepareRunResult{}, err } - err = s.injectSecrets(ctx, project, target.Name, &target.ContainerSpec, inspect.Container.ID) + err = s.injectSecrets(ctx, project, target.Name, &target.ContainerSpec, created.ID) if err != nil { return prepareRunResult{containerID: created.ID}, err } - err = s.injectConfigs(ctx, project, target.Name, &target.ContainerSpec, inspect.Container.ID) + err = s.injectConfigs(ctx, project, target.Name, &target.ContainerSpec, created.ID) return prepareRunResult{ containerID: created.ID, target: target, @@ -247,6 +225,36 @@ func resolveRunTarget(project *types.Project, opts api.RunOptions) (runTarget, e return runTarget{Name: service.Name, ContainerSpec: service.ContainerSpec}, nil } +func (s *composeService) createOneOffContainer(ctx context.Context, project *types.Project, target runTarget, opts api.RunOptions) (container.Summary, error) { + hash, err := ContainerSpecHash(target.ContainerSpec) + if err != nil { + return container.Summary{}, err + } + createOpts := createOptions{ + AutoRemove: opts.AutoRemove, + AttachStdin: opts.Interactive, + UseNetworkAliases: opts.UseNetworkAliases, + Labels: mergeLabels(target.Labels, target.CustomLabels). + Add(api.ConfigHashLabel, hash), + } + + eventName := "Container " + target.ContainerName + s.events.On(creatingEvent(eventName)) + created, err := s.createMobyContainer(ctx, project, target.Name, &target.ContainerSpec, nil, target.ContainerName, -1, nil, createOpts) + if err != nil { + if ctx.Err() == nil { + s.events.On(api.Resource{ + ID: eventName, + Status: api.Error, + Text: err.Error(), + }) + } + return container.Summary{}, err + } + s.events.On(createdEvent(eventName)) + return created, nil +} + func prepareBuildOptions(opts api.RunOptions) *api.BuildOptions { if opts.Build == nil { return nil diff --git a/pkg/e2e/compose_run_test.go b/pkg/e2e/compose_run_test.go index 7d4303ae468..0a2455fa41e 100644 --- a/pkg/e2e/compose_run_test.go +++ b/pkg/e2e/compose_run_test.go @@ -78,7 +78,7 @@ func TestLocalComposeRun(t *testing.T) { assert.Assert(t, strings.Contains(res.Stdout(), "run-test-back"), res.Stdout()) }) - t.Run("down", func(t *testing.T) { + t.Run("down --remove-orphans", func(t *testing.T) { c.RunDockerComposeCmd(t, "-f", "./fixtures/run-test/compose.yaml", "down", "--remove-orphans") res := c.RunDockerCmd(t, "ps", "--all") assert.Assert(t, !strings.Contains(res.Stdout(), "run-test"), res.Stdout()) From 21b093e10fbaa6ed3aa5376ef2b1861c246f0062 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Fri, 17 Apr 2026 17:59:03 +0200 Subject: [PATCH 9/9] build relies on types.ContainerSpec Signed-off-by: Nicolas De Loof --- pkg/compose/build.go | 31 ++++++------- pkg/compose/build_bake.go | 72 ++++++++++++++++++----------- pkg/compose/build_classic.go | 40 ++++++++-------- pkg/e2e/compose_run_test.go | 8 ++++ pkg/e2e/fixtures/run-test/jobs.yaml | 7 +++ 5 files changed, 94 insertions(+), 64 deletions(-) diff --git a/pkg/compose/build.go b/pkg/compose/build.go index bcf13dfd219..b39d5602be2 100644 --- a/pkg/compose/build.go +++ b/pkg/compose/build.go @@ -51,7 +51,7 @@ func (s *composeService) Build(ctx context.Context, project *types.Project, opti func (s *composeService) build(ctx context.Context, project *types.Project, options api.BuildOptions, localImages map[string]api.ImageSummary) (map[string]string, error) { imageIDs := map[string]string{} - serviceToBuild := types.Services{} + imagesToBuild := map[string]types.ContainerSpec{} var policy types.DependencyOption = types.IgnoreDependencies if options.Deps { @@ -63,7 +63,7 @@ func (s *composeService) build(ctx context.Context, project *types.Project, opti } // Separate job names from service names and collect buildable jobs - serviceNames := collectBuildableJobs(options.Services, project, localImages, serviceToBuild) + serviceNames := collectBuildableJobs(options.Services, project, localImages, imagesToBuild) // also include services used as additional_contexts with service: prefix serviceNames = addBuildDependencies(serviceNames, project) @@ -89,14 +89,14 @@ func (s *composeService) build(ctx context.Context, project *types.Project, opti if localImagePresent && service.PullPolicy != types.PullPolicyBuild { return nil } - serviceToBuild[serviceName] = *service + imagesToBuild[serviceName] = service.ContainerSpec return nil }, policy) if err != nil { return imageIDs, err } - if len(serviceToBuild) == 0 { + if len(imagesToBuild) == 0 { return imageIDs, nil } @@ -105,9 +105,9 @@ func (s *composeService) build(ctx context.Context, project *types.Project, opti return nil, err } if bake { - return s.doBuildBake(ctx, project, serviceToBuild, options) + return s.doBuildBake(ctx, project, imagesToBuild, options) } - return s.doBuildClassic(ctx, project, serviceToBuild, options) + return s.doBuildClassic(ctx, project, imagesToBuild, options) } func (s *composeService) ensureImagesExists(ctx context.Context, project *types.Project, buildOpts *api.BuildOptions, quietPull bool) error { @@ -176,7 +176,7 @@ func (s *composeService) ensureImagesExists(ctx context.Context, project *types. // collectBuildableJobs separates job names from service names in the given list. // Jobs that need building are added to serviceToBuild. Returns only the service names. -func collectBuildableJobs(names []string, project *types.Project, localImages map[string]api.ImageSummary, serviceToBuild map[string]types.ServiceConfig) []string { +func collectBuildableJobs(names []string, project *types.Project, localImages map[string]api.ImageSummary, imagesToBuild map[string]types.ContainerSpec) []string { var serviceNames []string for _, name := range names { job, ok := project.Jobs[name] @@ -189,10 +189,7 @@ func collectBuildableJobs(names []string, project *types.Project, localImages ma } image := api.ImageNameOrDefault(job.Image, name, project.Name) if _, present := localImages[image]; !present || job.PullPolicy == types.PullPolicyBuild { - serviceToBuild[name] = types.ServiceConfig{ - Name: name, - ContainerSpec: job.ContainerSpec, - } + imagesToBuild[name] = job.ContainerSpec } } return serviceNames @@ -284,9 +281,9 @@ func (s *composeService) getLocalImagesDigests(ctx context.Context, project *typ // // Finally, standard proxy variables based on the Docker client configuration are added, but will not overwrite // any values if already present. -func resolveAndMergeBuildArgs(proxyConfig map[string]string, project *types.Project, service types.ServiceConfig, opts api.BuildOptions) types.MappingWithEquals { +func resolveAndMergeBuildArgs(proxyConfig map[string]string, project *types.Project, spec types.ContainerSpec, opts api.BuildOptions) types.MappingWithEquals { result := make(types.MappingWithEquals). - OverrideBy(service.Build.Args). + OverrideBy(spec.Build.Args). OverrideBy(opts.Args). Resolve(envResolver(project.Environment)) @@ -301,17 +298,17 @@ func resolveAndMergeBuildArgs(proxyConfig map[string]string, project *types.Proj return result } -func getImageBuildLabels(project *types.Project, service types.ServiceConfig) types.Labels { +func getImageBuildLabels(project *types.Project, name string, spec types.ContainerSpec) types.Labels { ret := make(types.Labels) - if service.Build != nil { - for k, v := range service.Build.Labels { + if spec.Build != nil { + for k, v := range spec.Build.Labels { ret.Add(k, v) } } ret.Add(api.VersionLabel, api.ComposeVersion) ret.Add(api.ProjectLabel, project.Name) - ret.Add(api.ServiceLabel, service.Name) + ret.Add(api.ServiceLabel, name) return ret } diff --git a/pkg/compose/build_bake.go b/pkg/compose/build_bake.go index dc691cca9ff..c21ea5f3959 100644 --- a/pkg/compose/build_bake.go +++ b/pkg/compose/build_bake.go @@ -115,7 +115,7 @@ type buildStatus struct { Image string `json:"image.name"` } -func (s *composeService) doBuildBake(ctx context.Context, project *types.Project, serviceToBeBuild types.Services, options api.BuildOptions) (map[string]string, error) { //nolint:gocyclo +func (s *composeService) doBuildBake(ctx context.Context, project *types.Project, imagesToBuild map[string]types.ContainerSpec, options api.BuildOptions) (map[string]string, error) { //nolint:gocyclo eg := errgroup.Group{} ch := make(chan *client.SolveStatus) displayMode := progressui.DisplayMode(options.Progress) @@ -143,31 +143,39 @@ func (s *composeService) doBuildBake(ctx context.Context, project *types.Project group bakeGroup privileged bool read []string - expectedImages = make(map[string]string, len(serviceToBeBuild)) // service name -> expected image - targets = make(map[string]string, len(serviceToBeBuild)) // service name -> build target + expectedImages = make(map[string]string, len(imagesToBuild)) // service name -> expected image + targets = make(map[string]string, len(imagesToBuild)) // service name -> build target ) - // produce a unique ID for service used as bake target - for serviceName := range project.Services { - t := strings.ReplaceAll(serviceName, ".", "_") + // produce a unique ID for each build target (services + jobs) + assignTarget := func(name string) { + t := strings.ReplaceAll(name, ".", "_") for { - if _, ok := targets[serviceName]; !ok { - targets[serviceName] = t - break + if _, ok := targets[name]; !ok { + targets[name] = t + return } t += "_" } } + for serviceName := range project.Services { + assignTarget(serviceName) + } + for name := range imagesToBuild { + if _, ok := targets[name]; !ok { + assignTarget(name) + } + } var secretsEnv []string - for serviceName, service := range project.Services { - if service.Build == nil { - continue + addBakeTarget := func(name string, spec types.ContainerSpec) { + if spec.Build == nil { + return } - buildConfig := *service.Build - labels := getImageBuildLabels(project, service) + buildConfig := *spec.Build + labels := getImageBuildLabels(project, name, spec) - args := resolveAndMergeBuildArgs(s.getProxyConfig(), project, service, options).ToMapping() + args := resolveAndMergeBuildArgs(s.getProxyConfig(), project, spec, options).ToMapping() for k, v := range args { args[k] = strings.ReplaceAll(v, "${", "$${") } @@ -183,11 +191,11 @@ func (s *composeService) doBuildBake(ctx context.Context, project *types.Project var outputs []string var call string - push := options.Push && service.Image != "" + push := options.Push && spec.Image != "" switch { case options.Check: call = "lint" - case len(service.Build.Platforms) > 1: + case len(spec.Build.Platforms) > 1: outputs = []string{fmt.Sprintf("type=image,push=%t", push)} default: if push { @@ -205,15 +213,15 @@ func (s *composeService) doBuildBake(ctx context.Context, project *types.Project } } - image := api.GetImageNameOrDefault(service, project.Name) + image := api.ImageNameOrDefault(spec.Image, name, project.Name) s.events.On(buildingEvent(image)) - expectedImages[serviceName] = image + expectedImages[name] = image - pull := service.Build.Pull || options.Pull - noCache := service.Build.NoCache || options.NoCache + pull := spec.Build.Pull || options.Pull + noCache := spec.Build.NoCache || options.NoCache - target := targets[serviceName] + target := targets[name] secrets, env := toBakeSecrets(project, buildConfig.Secrets) secretsEnv = append(secretsEnv, env...) @@ -248,12 +256,22 @@ func (s *composeService) doBuildBake(ctx context.Context, project *types.Project } } - // create a bake group with targets for services to build - for serviceName, service := range serviceToBeBuild { - if service.Build == nil { + for serviceName, service := range project.Services { + addBakeTarget(serviceName, service.ContainerSpec) + } + for name, spec := range imagesToBuild { + if _, ok := project.Services[name]; ok { + continue // already processed as a service + } + addBakeTarget(name, spec) + } + + // create a bake group with targets to build + for name, spec := range imagesToBuild { + if spec.Build == nil { continue } - group.Targets = append(group.Targets, targets[serviceName]) + group.Targets = append(group.Targets, targets[name]) } cfg.Groups["default"] = group @@ -397,7 +415,7 @@ func (s *composeService) doBuildBake(ctx context.Context, project *types.Project } results := map[string]string{} - for name := range serviceToBeBuild { + for name := range imagesToBuild { image := expectedImages[name] target := targets[name] built, ok := md[target] diff --git a/pkg/compose/build_classic.go b/pkg/compose/build_classic.go index a5f750ae2dc..7789e901438 100644 --- a/pkg/compose/build_classic.go +++ b/pkg/compose/build_classic.go @@ -45,7 +45,7 @@ import ( "github.com/docker/compose/v5/pkg/api" ) -func (s *composeService) doBuildClassic(ctx context.Context, project *types.Project, serviceToBuild types.Services, options api.BuildOptions) (map[string]string, error) { +func (s *composeService) doBuildClassic(ctx context.Context, project *types.Project, imagesToBuild map[string]types.ContainerSpec, options api.BuildOptions) (map[string]string, error) { imageIDs := map[string]string{} // Not using bake, additional_context: service:xx is implemented by building images in dependency order @@ -82,14 +82,14 @@ func (s *composeService) doBuildClassic(ctx context.Context, project *types.Proj err = InDependencyOrder(ctx, project, func(ctx context.Context, name string) error { trace.SpanFromContext(ctx).SetAttributes(attribute.String("builder", "classic")) - service, ok := serviceToBuild[name] + spec, ok := imagesToBuild[name] if !ok { return nil } - image := api.GetImageNameOrDefault(service, project.Name) + image := api.ImageNameOrDefault(spec.Image, name, project.Name) s.events.On(buildingEvent(image)) - id, err := s.doBuildImage(ctx, project, service, options) + id, err := s.doBuildImage(ctx, project, name, spec, options) if err != nil { return err } @@ -118,7 +118,7 @@ func (s *composeService) doBuildClassic(ctx context.Context, project *types.Proj } //nolint:gocyclo -func (s *composeService) doBuildImage(ctx context.Context, project *types.Project, service types.ServiceConfig, options api.BuildOptions) (string, error) { +func (s *composeService) doBuildImage(ctx context.Context, project *types.Project, name string, spec types.ContainerSpec, options api.BuildOptions) (string, error) { var ( buildCtx io.ReadCloser dockerfileCtx io.ReadCloser @@ -126,29 +126,29 @@ func (s *composeService) doBuildImage(ctx context.Context, project *types.Projec relDockerfile string ) - if len(service.Build.Platforms) > 1 { + if len(spec.Build.Platforms) > 1 { return "", fmt.Errorf("the classic builder doesn't support multi-arch build, set DOCKER_BUILDKIT=1 to use BuildKit") } - if service.Build.Privileged { + if spec.Build.Privileged { return "", fmt.Errorf("the classic builder doesn't support privileged mode, set DOCKER_BUILDKIT=1 to use BuildKit") } - if len(service.Build.AdditionalContexts) > 0 { + if len(spec.Build.AdditionalContexts) > 0 { return "", fmt.Errorf("the classic builder doesn't support additional contexts, set DOCKER_BUILDKIT=1 to use BuildKit") } - if len(service.Build.SSH) > 0 { + if len(spec.Build.SSH) > 0 { return "", fmt.Errorf("the classic builder doesn't support SSH keys, set DOCKER_BUILDKIT=1 to use BuildKit") } - if len(service.Build.Secrets) > 0 { + if len(spec.Build.Secrets) > 0 { return "", fmt.Errorf("the classic builder doesn't support secrets, set DOCKER_BUILDKIT=1 to use BuildKit") } - if service.Build.Labels == nil { - service.Build.Labels = make(map[string]string) + if spec.Build.Labels == nil { + spec.Build.Labels = make(map[string]string) } - service.Build.Labels[api.ImageBuilderLabel] = "classic" + spec.Build.Labels[api.ImageBuilderLabel] = "classic" - dockerfileName := dockerFilePath(service.Build.Context, service.Build.Dockerfile) - specifiedContext := service.Build.Context + dockerfileName := dockerFilePath(spec.Build.Context, spec.Build.Dockerfile) + specifiedContext := spec.Build.Context progBuff := s.stdout() buildBuff := s.stdout() @@ -251,8 +251,8 @@ func (s *composeService) doBuildImage(ctx context.Context, project *types.Projec RegistryToken: authConfig.RegistryToken, } } - buildOpts := imageBuildOptions(s.getProxyConfig(), project, service, options) - imageName := api.GetImageNameOrDefault(service, project.Name) + buildOpts := imageBuildOptions(s.getProxyConfig(), project, spec, options) + imageName := api.ImageNameOrDefault(spec.Image, name, project.Name) buildOpts.Tags = append(buildOpts.Tags, imageName) buildOpts.Dockerfile = relDockerfile buildOpts.AuthConfigs = authConfigs @@ -293,15 +293,15 @@ func (s *composeService) doBuildImage(ctx context.Context, project *types.Projec return imageID, nil } -func imageBuildOptions(proxyConfigs map[string]string, project *types.Project, service types.ServiceConfig, options api.BuildOptions) client.ImageBuildOptions { - config := service.Build +func imageBuildOptions(proxyConfigs map[string]string, project *types.Project, spec types.ContainerSpec, options api.BuildOptions) client.ImageBuildOptions { + config := spec.Build return client.ImageBuildOptions{ Version: buildtypes.BuilderV1, Tags: config.Tags, NoCache: config.NoCache, Remove: true, PullParent: config.Pull, - BuildArgs: resolveAndMergeBuildArgs(proxyConfigs, project, service, options), + BuildArgs: resolveAndMergeBuildArgs(proxyConfigs, project, spec, options), Labels: config.Labels, NetworkMode: config.Network, ExtraHosts: config.ExtraHosts.AsList(":"), diff --git a/pkg/e2e/compose_run_test.go b/pkg/e2e/compose_run_test.go index 0a2455fa41e..7c99ef00838 100644 --- a/pkg/e2e/compose_run_test.go +++ b/pkg/e2e/compose_run_test.go @@ -315,6 +315,14 @@ func TestLocalComposeRun(t *testing.T) { c.RunDockerComposeCmd(t, "-f", "./fixtures/run-test/jobs.yaml", "down", "--remove-orphans") }) + t.Run("compose run job with build", func(t *testing.T) { + res := c.RunDockerComposeCmd(t, "-f", "./fixtures/run-test/jobs.yaml", "run", "--rm", "--build", "with-build") + lines := Lines(res.Stdout()) + assert.Equal(t, lines[len(lines)-1], "built-job-marker", res.Stdout()) + + c.RunDockerComposeCmd(t, "-f", "./fixtures/run-test/jobs.yaml", "down", "--remove-orphans", "--rmi=local") + }) + t.Run("compose run unknown job or service", func(t *testing.T) { res := c.RunDockerComposeCmdNoCheck(t, "-f", "./fixtures/run-test/jobs.yaml", "run", "nonexistent") res.Assert(t, icmd.Expected{ diff --git a/pkg/e2e/fixtures/run-test/jobs.yaml b/pkg/e2e/fixtures/run-test/jobs.yaml index 31b5e3cb9f5..27e6268b2dd 100644 --- a/pkg/e2e/fixtures/run-test/jobs.yaml +++ b/pkg/e2e/fixtures/run-test/jobs.yaml @@ -18,3 +18,10 @@ jobs: with-command: image: alpine command: echo "default command" + + with-build: + build: + dockerfile_inline: | + FROM alpine + RUN echo "built-job-marker" > /marker.txt + command: cat /marker.txt