From 2b685ba27e5425e9dbcb56a2c21600b37fbef652 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Thu, 10 Sep 2026 17:49:06 +0200 Subject: [PATCH 01/16] build(deps): adopt compose-go jobs and the container-spec layering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit compose-spec/compose-go#866 is merged: jobs top-level element, container specification layered as ContainerSpec/WorkloadSpec, pre_start hooks carrying the full container specification resolved at load time. Bump to the merged head and adapt in the same movement — composite literals setting moved fields wrap them into the embedded ContainerSpec / WorkloadSpec (promoted field access was already source-compatible, so this is literal-only, no behavior change), and pre_start handling is typed against PreStartHook. Co-Authored-By: Claude Fable 5 Signed-off-by: Nicolas De Loof --- cmd/compose/compose_test.go | 5 +- cmd/compose/config.go | 4 +- cmd/compose/config_test.go | 29 +-- cmd/compose/options_test.go | 23 +-- cmd/compose/pullOptions_test.go | 15 +- cmd/compose/up_test.go | 3 +- go.mod | 2 +- go.sum | 4 +- internal/locker/pidfile_windows.go | 3 +- internal/tracing/attributes_test.go | 14 +- pkg/api/api_test.go | 45 ++-- pkg/bridge/convert_test.go | 7 +- pkg/compose/build_test.go | 65 +++--- pkg/compose/compose.go | 8 +- pkg/compose/create_test.go | 25 +-- pkg/compose/dependencies_test.go | 52 ++--- pkg/compose/down_test.go | 12 +- pkg/compose/generate.go | 8 +- pkg/compose/hash_test.go | 3 +- pkg/compose/hook_test.go | 7 +- pkg/compose/images_test.go | 11 +- pkg/compose/pre_start.go | 4 +- pkg/compose/pre_start_test.go | 272 +++++++++++++------------ pkg/compose/publish.go | 2 +- pkg/compose/publish_test.go | 64 +++--- pkg/compose/pull.go | 14 +- pkg/compose/pull_test.go | 22 +- pkg/compose/reconcile_test.go | 131 ++++++------ pkg/compose/restart_test.go | 10 +- pkg/compose/service_containers_test.go | 20 +- pkg/compose/start_test.go | 27 ++- pkg/compose/viz_test.go | 72 +++---- pkg/compose/watch_test.go | 15 +- pkg/e2e/compose_test.go | 4 +- 34 files changed, 507 insertions(+), 495 deletions(-) diff --git a/cmd/compose/compose_test.go b/cmd/compose/compose_test.go index 708929ff8cd..c2492dfa201 100644 --- a/cmd/compose/compose_test.go +++ b/cmd/compose/compose_test.go @@ -31,10 +31,9 @@ func TestFilterServices(t *testing.T) { Links: []string{"bar"}, }, "bar": { - Name: "bar", - DependsOn: map[string]types.ServiceDependency{ + Name: "bar", WorkloadSpec: types.WorkloadSpec{DependsOn: map[string]types.ServiceDependency{ "zot": {}, - }, + }}, }, "zot": { Name: "zot", diff --git a/cmd/compose/config.go b/cmd/compose/config.go index 8c4065f43f8..08fcd9fb4e8 100644 --- a/cmd/compose/config.go +++ b/cmd/compose/config.go @@ -262,7 +262,7 @@ func imagesOnly(project *types.Project) *types.Project { digests := types.Services{} for name, config := range project.Services { service := types.ServiceConfig{ - Image: config.Image, + ContainerSpec: types.ContainerSpec{Image: config.Image}, } for _, vol := range config.Volumes { if vol.Type == types.VolumeTypeImage { @@ -386,7 +386,7 @@ func resolveImageDigests(ctx context.Context, dockerCli command.Cli, model map[s } for _, hook := range preStartHooks(service) { image, _ := hook["image"].(string) - config.PreStart = append(config.PreStart, types.ServiceHook{Image: image}) + config.PreStart = append(config.PreStart, types.PreStartHook{ContainerSpec: types.ContainerSpec{Image: image}}) } for _, volume := range imageVolumes(service) { source, _ := volume["source"].(string) diff --git a/cmd/compose/config_test.go b/cmd/compose/config_test.go index 8807850a20d..db557cb42df 100644 --- a/cmd/compose/config_test.go +++ b/cmd/compose/config_test.go @@ -135,14 +135,17 @@ func TestImagesOnly(t *testing.T) { Name: "test", Services: types.Services{ "test": types.ServiceConfig{ - Name: "test", - Image: "docker.io/library/nginx@" + testDigest, - Command: types.ShellCommand{"echo", "hello"}, + Name: "test", + // hooks can't be overridden element-wise on merge, so the lock must not carry them - PreStart: []types.ServiceHook{{Image: "docker.io/library/hookimage@" + testDigest}}, - Volumes: []types.ServiceVolumeConfig{ - {Type: types.VolumeTypeImage, Source: "docker.io/library/someimage@" + testDigest, Target: "/data"}, - {Type: types.VolumeTypeBind, Source: "/host", Target: "/bind"}, + PreStart: []types.PreStartHook{{ContainerSpec: types.ContainerSpec{Image: "docker.io/library/hookimage@" + testDigest}}}, ContainerSpec: types.ContainerSpec{ + Image: "docker.io/library/nginx@" + testDigest, + Command: types.ShellCommand{"echo", "hello"}, + + Volumes: []types.ServiceVolumeConfig{ + {Type: types.VolumeTypeImage, Source: "docker.io/library/someimage@" + testDigest, Target: "/data"}, + {Type: types.VolumeTypeBind, Source: "/host", Target: "/bind"}, + }, }, }, }, @@ -154,9 +157,11 @@ func TestImagesOnly(t *testing.T) { assert.DeepEqual(t, locked, &types.Project{ Services: types.Services{ "test": types.ServiceConfig{ - Image: "docker.io/library/nginx@" + testDigest, - Volumes: []types.ServiceVolumeConfig{ - {Type: types.VolumeTypeImage, Source: "docker.io/library/someimage@" + testDigest, Target: "/data"}, + ContainerSpec: types.ContainerSpec{ + Image: "docker.io/library/nginx@" + testDigest, + Volumes: []types.ServiceVolumeConfig{ + {Type: types.VolumeTypeImage, Source: "docker.io/library/someimage@" + testDigest, Target: "/data"}, + }, }, }, }, @@ -167,8 +172,8 @@ func TestWarnHooksNotLockable(t *testing.T) { messages := captureWarnings(t, func() { warnHooksNotLockable(&types.Project{ Services: types.Services{ - "with-hook-image": types.ServiceConfig{PreStart: []types.ServiceHook{{Image: "alpine:latest"}}}, - "inline-hook": types.ServiceConfig{PreStart: []types.ServiceHook{{Command: types.ShellCommand{"echo"}}}}, + "with-hook-image": types.ServiceConfig{PreStart: []types.PreStartHook{{ContainerSpec: types.ContainerSpec{Image: "alpine:latest"}}}}, + "inline-hook": types.ServiceConfig{PreStart: []types.PreStartHook{{ContainerSpec: types.ContainerSpec{Command: types.ShellCommand{"echo"}}}}}, "without-hook": types.ServiceConfig{}, }, }) diff --git a/cmd/compose/options_test.go b/cmd/compose/options_test.go index c015a7a7238..07df9128545 100644 --- a/cmd/compose/options_test.go +++ b/cmd/compose/options_test.go @@ -38,17 +38,18 @@ func TestApplyPlatforms_InferFromRuntime(t *testing.T) { return &types.Project{ Services: types.Services{ "test": { - Name: "test", - Image: "foo", - Build: &types.BuildConfig{ + Name: "test", ContainerSpec: types.ContainerSpec{ + Image: "foo", + + Platform: "alice/32", + }, WorkloadSpec: types.WorkloadSpec{Build: &types.BuildConfig{ Context: ".", Platforms: []string{ "linux/amd64", "linux/arm64", "alice/32", }, - }, - Platform: "alice/32", + }}, }, }, } @@ -75,15 +76,13 @@ func TestApplyPlatforms_DockerDefaultPlatform(t *testing.T) { }, Services: types.Services{ "test": { - Name: "test", - Image: "foo", - Build: &types.BuildConfig{ + Name: "test", ContainerSpec: types.ContainerSpec{Image: "foo"}, WorkloadSpec: types.WorkloadSpec{Build: &types.BuildConfig{ Context: ".", Platforms: []string{ "linux/amd64", "linux/arm64", }, - }, + }}, }, }, } @@ -110,15 +109,13 @@ func TestApplyPlatforms_UnsupportedPlatform(t *testing.T) { }, Services: types.Services{ "test": { - Name: "test", - Image: "foo", - Build: &types.BuildConfig{ + Name: "test", ContainerSpec: types.ContainerSpec{Image: "foo"}, WorkloadSpec: types.WorkloadSpec{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..f13537923ec 100644 --- a/cmd/compose/pullOptions_test.go +++ b/cmd/compose/pullOptions_test.go @@ -27,22 +27,19 @@ func TestApplyPullOptions(t *testing.T) { project := &types.Project{ Services: types.Services{ "must-build": { - Name: "must-build", + Name: "must-build", WorkloadSpec: // No image, local build only - Build: &types.BuildConfig{ + types.WorkloadSpec{Build: &types.BuildConfig{ Context: ".", - }, + }}, }, "has-build": { - Name: "has-build", - Image: "registry.example.com/myservice", - Build: &types.BuildConfig{ + Name: "has-build", ContainerSpec: types.ContainerSpec{Image: "registry.example.com/myservice"}, WorkloadSpec: types.WorkloadSpec{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/up_test.go b/cmd/compose/up_test.go index e6e7fd2224f..ee159d08b7d 100644 --- a/cmd/compose/up_test.go +++ b/cmd/compose/up_test.go @@ -155,8 +155,7 @@ services: WorkingDir: dir, Services: types.Services{ "web": { - Name: "web", - Image: "nginx", + Name: "web", ContainerSpec: types.ContainerSpec{Image: "nginx"}, }, }, } diff --git a/go.mod b/go.mod index 39063352a87..58b25f1c611 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/Microsoft/go-winio v0.6.3-0.20251027160822-ad3df93bed29 github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d github.com/buger/goterm v1.0.4 - github.com/compose-spec/compose-go/v2 v2.15.1-0.20260908103050-cda18529aca7 + github.com/compose-spec/compose-go/v2 v2.15.1-0.20260910154416-11feead015fc github.com/containerd/console v1.0.5 github.com/containerd/containerd/v2 v2.3.5 github.com/containerd/errdefs v1.0.0 diff --git a/go.sum b/go.sum index 49835e4d1e4..bd7ac8241ca 100644 --- a/go.sum +++ b/go.sum @@ -28,8 +28,8 @@ 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.15.1-0.20260908103050-cda18529aca7 h1:D9jScaeUAk7DjyB71SD9zvOFPR6klw0FxPcR2bgu72c= -github.com/compose-spec/compose-go/v2 v2.15.1-0.20260908103050-cda18529aca7/go.mod h1:Q1+qtN4vhzEjGrnqRtzx1xa8raDZQlMUe3WJxndYNiQ= +github.com/compose-spec/compose-go/v2 v2.15.1-0.20260910154416-11feead015fc h1:rmi1Yz/JylJUNR068ay6XZI9mIOEyMFqWvWS0rWDgt4= +github.com/compose-spec/compose-go/v2 v2.15.1-0.20260910154416-11feead015fc/go.mod h1:Q1+qtN4vhzEjGrnqRtzx1xa8raDZQlMUe3WJxndYNiQ= 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= diff --git a/internal/locker/pidfile_windows.go b/internal/locker/pidfile_windows.go index 2dac9ff031b..f953f9dcee6 100644 --- a/internal/locker/pidfile_windows.go +++ b/internal/locker/pidfile_windows.go @@ -21,8 +21,9 @@ package locker import ( "os" - "github.com/docker/compose/v5/internal/pidfile" "github.com/mitchellh/go-ps" + + "github.com/docker/compose/v5/internal/pidfile" ) func (f *Pidfile) Lock() error { 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/api/api_test.go b/pkg/api/api_test.go index 98844a1c8ee..65f1fbbb85b 100644 --- a/pkg/api/api_test.go +++ b/pkg/api/api_test.go @@ -62,58 +62,57 @@ func TestGetDependentImages(t *testing.T) { }{ { name: "no hooks", - service: types.ServiceConfig{Image: "alpine:3.20"}, + service: types.ServiceConfig{ContainerSpec: types.ContainerSpec{Image: "alpine:3.20"}}, expected: nil, }, { name: "pre_start hook with explicit image", service: types.ServiceConfig{ - Image: "alpine:3.20", - PreStart: []types.ServiceHook{ - {Image: "alpine:3.19", Command: types.ShellCommand{"echo", "init"}}, - }, + PreStart: []types.PreStartHook{ + {ContainerSpec: types.ContainerSpec{Image: "alpine:3.19", Command: types.ShellCommand{"echo", "init"}}}, + }, ContainerSpec: types.ContainerSpec{Image: "alpine:3.20"}, }, expected: []string{"alpine:3.19"}, }, { name: "pre_start hook without image is ignored", service: types.ServiceConfig{ - Image: "alpine:3.20", - PreStart: []types.ServiceHook{ - {Image: "busybox", Command: types.ShellCommand{"echo", "a"}}, - {Command: types.ShellCommand{"echo", "b"}}, - }, + PreStart: []types.PreStartHook{ + {ContainerSpec: types.ContainerSpec{Image: "busybox", Command: types.ShellCommand{"echo", "a"}}}, + {ContainerSpec: types.ContainerSpec{Command: types.ShellCommand{"echo", "b"}}}, + }, ContainerSpec: types.ContainerSpec{Image: "alpine:3.20"}, }, expected: []string{"busybox"}, }, { name: "pre_start hook reusing the service image is ignored", service: types.ServiceConfig{ - Image: "alpine:3.20", - PreStart: []types.ServiceHook{ - {Image: "alpine:3.20", Command: types.ShellCommand{"echo", "same"}}, - {Image: "alpine:3.19", Command: types.ShellCommand{"echo", "other"}}, - }, + PreStart: []types.PreStartHook{ + {ContainerSpec: types.ContainerSpec{Image: "alpine:3.20", Command: types.ShellCommand{"echo", "same"}}}, + {ContainerSpec: types.ContainerSpec{Image: "alpine:3.19", Command: types.ShellCommand{"echo", "other"}}}, + }, ContainerSpec: types.ContainerSpec{Image: "alpine:3.20"}, }, expected: []string{"alpine:3.19"}, }, { name: "pre_start hook reusing the default (build) image name is ignored", service: types.ServiceConfig{ - Name: "web", - Build: &types.BuildConfig{Context: "."}, - PreStart: []types.ServiceHook{ - {Image: "demo-web", Command: types.ShellCommand{"echo", "same"}}, - }, + Name: "web", + + PreStart: []types.PreStartHook{ + {ContainerSpec: types.ContainerSpec{Image: "demo-web", Command: types.ShellCommand{"echo", "same"}}}, + }, WorkloadSpec: types.WorkloadSpec{Build: &types.BuildConfig{Context: "."}}, }, expected: nil, }, { + // exec hooks carry no image at all since the container spec + // layering: nothing to collect, by construction name: "post_start and pre_stop hooks are not collected", service: types.ServiceConfig{ - Image: "alpine:3.20", - PostStart: []types.ServiceHook{{Image: "ignored:post", Command: types.ShellCommand{"echo"}}}, - PreStop: []types.ServiceHook{{Image: "ignored:stop", Command: types.ShellCommand{"echo"}}}, + PostStart: []types.ServiceHook{{Command: types.ShellCommand{"echo"}}}, + PreStop: []types.ServiceHook{{Command: types.ShellCommand{"echo"}}}, + ContainerSpec: types.ContainerSpec{Image: "alpine:3.20"}, }, expected: nil, }, diff --git a/pkg/bridge/convert_test.go b/pkg/bridge/convert_test.go index bc36fa3c00a..de0e5cdaa38 100644 --- a/pkg/bridge/convert_test.go +++ b/pkg/bridge/convert_test.go @@ -42,9 +42,10 @@ func TestLoadAdditionalResources_BuildOnlySkipsPull(t *testing.T) { Name: "test", Services: types.Services{ "api": { - Name: "api", - Build: &types.BuildConfig{Context: "."}, - Expose: []string{"8080"}, + Name: "api", WorkloadSpec: types.WorkloadSpec{ + Build: &types.BuildConfig{Context: "."}, + Expose: []string{"8080"}, + }, }, }, } diff --git a/pkg/compose/build_test.go b/pkg/compose/build_test.go index a7fbf2e2c33..1dbba84de6b 100644 --- a/pkg/compose/build_test.go +++ b/pkg/compose/build_test.go @@ -84,26 +84,22 @@ func Test_dockerFilePath(t *testing.T) { func Test_addBuildDependencies(t *testing.T) { project := &types.Project{Services: types.Services{ "test": types.ServiceConfig{ - Build: &types.BuildConfig{ + WorkloadSpec: types.WorkloadSpec{Build: &types.BuildConfig{ AdditionalContexts: map[string]string{ "foo": "service:foo", "bar": "service:bar", }, - }, + }}, }, "foo": types.ServiceConfig{ - Build: &types.BuildConfig{ + WorkloadSpec: types.WorkloadSpec{Build: &types.BuildConfig{ AdditionalContexts: map[string]string{ "zot": "service:zot", }, - }, - }, - "bar": types.ServiceConfig{ - Build: &types.BuildConfig{}, - }, - "zot": types.ServiceConfig{ - Build: &types.BuildConfig{}, + }}, }, + "bar": types.ServiceConfig{WorkloadSpec: types.WorkloadSpec{Build: &types.BuildConfig{}}}, + "zot": types.ServiceConfig{WorkloadSpec: types.WorkloadSpec{Build: &types.BuildConfig{}}}, }} services := addBuildDependencies([]string{"test"}, project) @@ -122,11 +118,11 @@ func TestGetLocalImagesDigests_PreStartHook(t *testing.T) { Name: "demo", Services: types.Services{ "web": types.ServiceConfig{ - Name: "web", - Image: "alpine:3.20", - PreStart: []types.ServiceHook{ - {Image: "alpine:3.19", Command: types.ShellCommand{"echo", "init"}}, - }, + Name: "web", + + PreStart: []types.PreStartHook{ + {ContainerSpec: types.ContainerSpec{Image: "alpine:3.19", Command: types.ShellCommand{"echo", "init"}}}, + }, ContainerSpec: types.ContainerSpec{Image: "alpine:3.20"}, }, }, } @@ -155,9 +151,10 @@ func TestResolveImageVolumes(t *testing.T) { t.Run("source is an image name", func(t *testing.T) { service := types.ServiceConfig{ - Name: "web", - CustomLabels: types.Labels{}, - Volumes: []types.ServiceVolumeConfig{imageVolume("content:1", "/data")}, + Name: "web", ContainerSpec: types.ContainerSpec{ + CustomLabels: types.Labels{}, + Volumes: []types.ServiceVolumeConfig{imageVolume("content:1", "/data")}, + }, } resolveImageVolumes(&service, images, "p") assert.Equal(t, service.Volumes[0].Source, "content:1") @@ -166,9 +163,10 @@ func TestResolveImageVolumes(t *testing.T) { t.Run("source is another service resolves to its image name", func(t *testing.T) { service := types.ServiceConfig{ - Name: "web", - CustomLabels: types.Labels{}, - Volumes: []types.ServiceVolumeConfig{imageVolume("source", "/data")}, + Name: "web", ContainerSpec: types.ContainerSpec{ + CustomLabels: types.Labels{}, + Volumes: []types.ServiceVolumeConfig{imageVolume("source", "/data")}, + }, } resolveImageVolumes(&service, images, "p") // the mount Source must stay a daemon-resolvable name, never a digest @@ -178,9 +176,10 @@ func TestResolveImageVolumes(t *testing.T) { t.Run("unresolvable source is left untouched and unlabelled", func(t *testing.T) { service := types.ServiceConfig{ - Name: "web", - CustomLabels: types.Labels{}, - Volumes: []types.ServiceVolumeConfig{imageVolume("ghost:1", "/data")}, + Name: "web", ContainerSpec: types.ContainerSpec{ + CustomLabels: types.Labels{}, + Volumes: []types.ServiceVolumeConfig{imageVolume("ghost:1", "/data")}, + }, } resolveImageVolumes(&service, images, "p") assert.Equal(t, service.Volumes[0].Source, "ghost:1") @@ -190,11 +189,12 @@ func TestResolveImageVolumes(t *testing.T) { t.Run("several volumes produce a deterministic sorted label", func(t *testing.T) { service := types.ServiceConfig{ - Name: "web", - CustomLabels: types.Labels{}, - Volumes: []types.ServiceVolumeConfig{ - imageVolume("assets:2", "/b"), - imageVolume("content:1", "/a"), + Name: "web", ContainerSpec: types.ContainerSpec{ + CustomLabels: types.Labels{}, + Volumes: []types.ServiceVolumeConfig{ + imageVolume("assets:2", "/b"), + imageVolume("content:1", "/a"), + }, }, } resolveImageVolumes(&service, images, "p") @@ -203,9 +203,10 @@ func TestResolveImageVolumes(t *testing.T) { t.Run("no image volumes writes no label", func(t *testing.T) { service := types.ServiceConfig{ - Name: "web", - CustomLabels: types.Labels{}, - Volumes: []types.ServiceVolumeConfig{{Type: types.VolumeTypeVolume, Source: "vol", Target: "/data"}}, + Name: "web", ContainerSpec: types.ContainerSpec{ + CustomLabels: types.Labels{}, + Volumes: []types.ServiceVolumeConfig{{Type: types.VolumeTypeVolume, Source: "vol", Target: "/data"}}, + }, } resolveImageVolumes(&service, images, "p") _, labelled := service.CustomLabels[api.ImageVolumeDigestLabel] diff --git a/pkg/compose/compose.go b/pkg/compose/compose.go index ee797274f85..7f39461ed21 100644 --- a/pkg/compose/compose.go +++ b/pkg/compose/compose.go @@ -376,9 +376,11 @@ func (s *composeService) projectFromName(containers Containers, projectName stri service, ok := set[serviceLabel] if !ok { service = types.ServiceConfig{ - Name: serviceLabel, - Image: ctr.Image, - Labels: ctr.Labels, + Name: serviceLabel, + ContainerSpec: types.ContainerSpec{ + Image: ctr.Image, + Labels: ctr.Labels, + }, } } service.Scale = increment(service.Scale) diff --git a/pkg/compose/create_test.go b/pkg/compose/create_test.go index e08e4227dae..0600cf4edca 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") } @@ -108,8 +108,7 @@ func TestBuildContainerMountOptions(t *testing.T) { Name: "myProject", Services: composetypes.Services{ "myService": { - Name: "myService", - Volumes: []composetypes.ServiceVolumeConfig{ + Name: "myService", ContainerSpec: composetypes.ContainerSpec{Volumes: []composetypes.ServiceVolumeConfig{ { Type: composetypes.VolumeTypeVolume, Target: "/var/myvolume1", @@ -131,7 +130,7 @@ func TestBuildContainerMountOptions(t *testing.T) { Source: "\\\\.\\pipe\\docker_engine_windows", Target: "\\\\.\\pipe\\docker_engine", }, - }, + }}, }, }, Volumes: composetypes.Volumes(map[string]composetypes.VolumeConfig{ @@ -194,15 +193,14 @@ func TestBuildContainerMountOptions(t *testing.T) { 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{ + Name: "myService", ContainerSpec: composetypes.ContainerSpec{Networks: map[string]*composetypes.ServiceNetworkConfig{ "myNetwork1": { Priority: 10, }, "myNetwork2": { Priority: 1000, }, - }, + }}, } project := composetypes.Project{ Name: "myProject", @@ -275,11 +273,10 @@ 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{ + Name: "myService", ContainerSpec: composetypes.ContainerSpec{Networks: map[string]*composetypes.ServiceNetworkConfig{ "myNetwork1": {Priority: 10}, "myNetwork2": {Priority: 1000}, - }, + }}, } project := composetypes.Project{ Name: "myProject", @@ -299,8 +296,7 @@ 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", @@ -324,8 +320,7 @@ func TestCreateEndpointSettings(t *testing.T) { Name: "projName", }, composetypes.ServiceConfig{ Name: "serviceName", - ContainerName: "containerName", - Networks: map[string]*composetypes.ServiceNetworkConfig{ + ContainerName: "containerName", ContainerSpec: composetypes.ContainerSpec{Networks: map[string]*composetypes.ServiceNetworkConfig{ "netName": { Priority: 100, Aliases: []string{"alias1", "alias2"}, @@ -338,7 +333,7 @@ func TestCreateEndpointSettings(t *testing.T) { "driverOpt2": "optval2", }, }, - }, + }}, }, 0, "netName", []string{"link1", "link2"}, true) assert.NilError(t, err) macAddr, _ := net.ParseMAC("02:00:00:00:00:01") diff --git a/pkg/compose/dependencies_test.go b/pkg/compose/dependencies_test.go index 65dccbeaf58..af11f0c7a89 100644 --- a/pkg/compose/dependencies_test.go +++ b/pkg/compose/dependencies_test.go @@ -34,16 +34,14 @@ func createTestProject() *types.Project { return &types.Project{ Services: types.Services{ "test1": { - Name: "test1", - DependsOn: map[string]types.ServiceDependency{ + Name: "test1", WorkloadSpec: types.WorkloadSpec{DependsOn: map[string]types.ServiceDependency{ "test2": {}, - }, + }}, }, "test2": { - Name: "test2", - DependsOn: map[string]types.ServiceDependency{ + Name: "test2", WorkloadSpec: types.WorkloadSpec{DependsOn: map[string]types.ServiceDependency{ "test3": {}, - }, + }}, }, "test3": { Name: "test3", @@ -54,8 +52,7 @@ func createTestProject() *types.Project { func TestTraversalWithMultipleParents(t *testing.T) { dependent := types.ServiceConfig{ - Name: "dependent", - DependsOn: make(types.DependsOnConfig), + Name: "dependent", WorkloadSpec: types.WorkloadSpec{DependsOn: make(types.DependsOnConfig)}, } project := types.Project{ @@ -122,8 +119,7 @@ func TestBuildGraph(t *testing.T) { desc: "builds graph with single service", services: types.Services{ "test": { - Name: "test", - DependsOn: types.DependsOnConfig{}, + Name: "test", WorkloadSpec: types.WorkloadSpec{DependsOn: types.DependsOnConfig{}}, }, }, expectedVertices: map[string]*Vertex{ @@ -140,12 +136,10 @@ func TestBuildGraph(t *testing.T) { desc: "builds graph with two separate services", services: types.Services{ "test": { - Name: "test", - DependsOn: types.DependsOnConfig{}, + Name: "test", WorkloadSpec: types.WorkloadSpec{DependsOn: types.DependsOnConfig{}}, }, "another": { - Name: "another", - DependsOn: types.DependsOnConfig{}, + Name: "another", WorkloadSpec: types.WorkloadSpec{DependsOn: types.DependsOnConfig{}}, }, }, expectedVertices: map[string]*Vertex{ @@ -169,14 +163,12 @@ func TestBuildGraph(t *testing.T) { desc: "builds graph with a service and a dependency", services: types.Services{ "test": { - Name: "test", - DependsOn: types.DependsOnConfig{ + Name: "test", WorkloadSpec: types.WorkloadSpec{DependsOn: types.DependsOnConfig{ "another": types.ServiceDependency{}, - }, + }}, }, "another": { - Name: "another", - DependsOn: types.DependsOnConfig{}, + Name: "another", WorkloadSpec: types.WorkloadSpec{DependsOn: types.DependsOnConfig{}}, }, }, expectedVertices: map[string]*Vertex{ @@ -204,20 +196,17 @@ func TestBuildGraph(t *testing.T) { desc: "builds graph with multiple dependency levels", services: types.Services{ "test": { - Name: "test", - DependsOn: types.DependsOnConfig{ + Name: "test", WorkloadSpec: types.WorkloadSpec{DependsOn: types.DependsOnConfig{ "another": types.ServiceDependency{}, - }, + }}, }, "another": { - Name: "another", - DependsOn: types.DependsOnConfig{ + Name: "another", WorkloadSpec: types.WorkloadSpec{DependsOn: types.DependsOnConfig{ "another_dep": types.ServiceDependency{}, - }, + }}, }, "another_dep": { - Name: "another_dep", - DependsOn: types.DependsOnConfig{}, + Name: "another_dep", WorkloadSpec: types.WorkloadSpec{DependsOn: types.DependsOnConfig{}}, }, }, expectedVertices: map[string]*Vertex{ @@ -281,15 +270,14 @@ func TestBuildGraphDependsOn(t *testing.T) { desc: "service depends on init container which is already removed", services: types.Services{ "test": { - Name: "test", - DependsOn: types.DependsOnConfig{ + Name: "test", WorkloadSpec: types.WorkloadSpec{DependsOn: types.DependsOnConfig{ "test-removed-init-container": types.ServiceDependency{ Condition: "service_completed_successfully", Restart: false, Extensions: types.Extensions(nil), Required: false, }, - }, + }}, }, }, expectedVertices: map[string]*Vertex{ @@ -331,10 +319,10 @@ func TestNewGraphDoesNotMutateProject(t *testing.T) { Services: types.Services{ "app": { Name: "app", - DependsOn: types.DependsOnConfig{ + WorkloadSpec: types.WorkloadSpec{DependsOn: types.DependsOnConfig{ "db": {Condition: types.ServiceConditionStarted, Required: true}, "debug": {Condition: types.ServiceConditionStarted, Required: false}, - }, + }}, }, "db": {Name: "db"}, }, diff --git a/pkg/compose/down_test.go b/pkg/compose/down_test.go index d8fa9338f78..06817b8da15 100644 --- a/pkg/compose/down_test.go +++ b/pkg/compose/down_test.go @@ -295,11 +295,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"}}, }, }, } @@ -531,7 +531,7 @@ func TestEnsureImagesDown_SparesDanglingImagesOfOrphanedServices(t *testing.T) { project := &types.Project{ Name: "prj", Services: types.Services{ - "web": {Name: "web", Image: "web-image"}, + "web": {Name: "web", ContainerSpec: types.ContainerSpec{Image: "web-image"}}, }, } apiClient.EXPECT().ImageList(gomock.Any(), client.ImageListOptions{ @@ -570,7 +570,7 @@ func TestEnsureImagesDown_RemoveOrphansAlsoTakesDanglingImages(t *testing.T) { project := &types.Project{ Name: "prj", Services: types.Services{ - "web": {Name: "web", Image: "web-image"}, + "web": {Name: "web", ContainerSpec: types.ContainerSpec{Image: "web-image"}}, }, } apiClient.EXPECT().ImageList(gomock.Any(), client.ImageListOptions{ diff --git a/pkg/compose/generate.go b/pkg/compose/generate.go index fed2461a5c1..4ee63352e7b 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: ctr.Image, - Labels: ctr.Labels, + Name: serviceLabel, + ContainerSpec: types.ContainerSpec{ + Image: ctr.Image, + Labels: ctr.Labels, + }, } } service.Scale = increment(service.Scale) diff --git a/pkg/compose/hash_test.go b/pkg/compose/hash_test.go index 73b7f387735..b25ea9b887e 100644 --- a/pkg/compose/hash_test.go +++ b/pkg/compose/hash_test.go @@ -37,7 +37,6 @@ 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 3f5ef149051..1d768fc9e41 100644 --- a/pkg/compose/hook_test.go +++ b/pkg/compose/hook_test.go @@ -87,8 +87,7 @@ func TestRunHook_ConsoleSize(t *testing.T) { mockCli.EXPECT().Out().Return(streams.NewOut(tty)).AnyTimes() service := types.ServiceConfig{ - Name: "test", - Tty: tc.tty, + Name: "test", WorkloadSpec: types.WorkloadSpec{Tty: tc.tty}, } hook := types.ServiceHook{Command: []string{"echo", "hello"}} ctr := container.Summary{ID: "container123"} @@ -450,7 +449,7 @@ func TestRunHook_CopyError(t *testing.T) { mockCli.EXPECT().Out().Return(streams.NewOut(os.Stdout)).AnyTimes() ctr := container.Summary{ID: "ctr-1"} - service := types.ServiceConfig{Name: "svc", Tty: false} + service := types.ServiceConfig{Name: "svc", WorkloadSpec: types.WorkloadSpec{Tty: false}} hook := types.ServiceHook{Command: types.ShellCommand{"true"}} mockAPI.EXPECT(). @@ -572,7 +571,7 @@ func TestRunHook_ExecInspectError(t *testing.T) { mockCli.EXPECT().Out().Return(streams.NewOut(os.Stdout)).AnyTimes() ctr := container.Summary{ID: "ctr-1"} - service := types.ServiceConfig{Name: "svc", Tty: false} + service := types.ServiceConfig{Name: "svc", WorkloadSpec: types.WorkloadSpec{Tty: false}} hook := types.ServiceHook{Command: types.ShellCommand{"true"}} mockAPI.EXPECT(). diff --git a/pkg/compose/images_test.go b/pkg/compose/images_test.go index 9775fd14d5c..a1b53ad477d 100644 --- a/pkg/compose/images_test.go +++ b/pkg/compose/images_test.go @@ -389,10 +389,11 @@ func TestPlatformPinnedDigest(t *testing.T) { Name: "p", Services: types.Services{ "app": { - Name: "app", - Image: "foo:1", - Platform: "linux/s390x", - CustomLabels: types.Labels{}, + Name: "app", ContainerSpec: types.ContainerSpec{ + Image: "foo:1", + Platform: "linux/s390x", + CustomLabels: types.Labels{}, + }, }, }, } @@ -455,7 +456,7 @@ func TestPlatformPinnedDigest(t *testing.T) { // whichever service triggered it, so services sharing the image with another // pinned platform must re-resolve theirs. func TestServiceImageDigest(t *testing.T) { - pinnedService := types.ServiceConfig{Name: "app", Platform: "linux/s390x"} + pinnedService := types.ServiceConfig{Name: "app", ContainerSpec: types.ContainerSpec{Platform: "linux/s390x"}} t.Run("unpinned service uses the shared digest, no inspect", func(t *testing.T) { mockCtrl := gomock.NewController(t) diff --git a/pkg/compose/pre_start.go b/pkg/compose/pre_start.go index 4d70c54bbd8..1d350f6059a 100644 --- a/pkg/compose/pre_start.go +++ b/pkg/compose/pre_start.go @@ -88,7 +88,7 @@ func (s *composeService) runPreStart(ctx context.Context, project *types.Project func (s *composeService) runPreStartHook( ctx context.Context, project *types.Project, service types.ServiceConfig, - ctr container.Summary, index int, hook types.ServiceHook, listener api.ContainerEventListener, + ctr container.Summary, index int, hook types.PreStartHook, listener api.ContainerEventListener, ) error { created, err := s.createPreStartContainer(ctx, project, service, ctr, hook) if err != nil { @@ -164,7 +164,7 @@ func (s *composeService) runPreStartHook( func (s *composeService) createPreStartContainer( ctx context.Context, project *types.Project, service types.ServiceConfig, - ctr container.Summary, hook types.ServiceHook, + ctr container.Summary, hook types.PreStartHook, ) (client.ContainerCreateResult, error) { image := hook.Image if image == "" { diff --git a/pkg/compose/pre_start_test.go b/pkg/compose/pre_start_test.go index 635eef6739e..1f803f0360c 100644 --- a/pkg/compose/pre_start_test.go +++ b/pkg/compose/pre_start_test.go @@ -91,12 +91,12 @@ func TestPreStart_SuccessTwoHooksInOrder(t *testing.T) { project := &types.Project{Name: "demo"} service := types.ServiceConfig{ - Name: "web", - Image: "alpine", - PreStart: []types.ServiceHook{ - {Image: "alpine", Command: types.ShellCommand{"echo", "first"}}, - {Image: "alpine", Command: types.ShellCommand{"echo", "second"}}, - }, + Name: "web", + + PreStart: []types.PreStartHook{ + {ContainerSpec: types.ContainerSpec{Image: "alpine", Command: types.ShellCommand{"echo", "first"}}}, + {ContainerSpec: types.ContainerSpec{Image: "alpine", Command: types.ShellCommand{"echo", "second"}}}, + }, ContainerSpec: types.ContainerSpec{Image: "alpine"}, } ctr := container.Summary{ID: "service-ctr-id"} @@ -134,12 +134,12 @@ func TestPreStart_FirstHookFailsStopsExecution(t *testing.T) { project := &types.Project{Name: "demo"} service := types.ServiceConfig{ - Name: "web", - Image: "alpine", - PreStart: []types.ServiceHook{ - {Image: "alpine", Command: types.ShellCommand{"false"}}, - {Image: "alpine", Command: types.ShellCommand{"echo", "never"}}, - }, + Name: "web", + + PreStart: []types.PreStartHook{ + {ContainerSpec: types.ContainerSpec{Image: "alpine", Command: types.ShellCommand{"false"}}}, + {ContainerSpec: types.ContainerSpec{Image: "alpine", Command: types.ShellCommand{"echo", "never"}}}, + }, ContainerSpec: types.ContainerSpec{Image: "alpine"}, } ctr := container.Summary{ID: "service-ctr-id"} @@ -164,11 +164,11 @@ func TestPreStart_PerReplicaRejected(t *testing.T) { project := &types.Project{Name: "demo"} service := types.ServiceConfig{ - Name: "web", - Image: "alpine", - PreStart: []types.ServiceHook{ - {Image: "alpine", Command: types.ShellCommand{"true"}, PerReplica: true}, - }, + Name: "web", + + PreStart: []types.PreStartHook{ + {PerReplica: true, ContainerSpec: types.ContainerSpec{Image: "alpine", Command: types.ShellCommand{"true"}}}, + }, ContainerSpec: types.ContainerSpec{Image: "alpine"}, } ctr := container.Summary{ID: "service-ctr-id"} @@ -184,8 +184,8 @@ func TestPreStart_ImageFallsBackToBuiltImage(t *testing.T) { // Service with no explicit image (build-only); hook image also empty. service := types.ServiceConfig{ Name: "web", - PreStart: []types.ServiceHook{ - {Command: types.ShellCommand{"echo", "hi"}}, + PreStart: []types.PreStartHook{ + {ContainerSpec: types.ContainerSpec{Command: types.ShellCommand{"echo", "hi"}}}, }, } ctr := container.Summary{ID: "service-ctr-id"} @@ -215,11 +215,11 @@ func TestPreStart_ExplicitHookImageUsed(t *testing.T) { project := &types.Project{Name: "demo"} service := types.ServiceConfig{ - Name: "web", - Image: "service-image:latest", - PreStart: []types.ServiceHook{ - {Image: "custom-hook-image:1.2.3", Command: types.ShellCommand{"echo"}}, - }, + Name: "web", + + PreStart: []types.PreStartHook{ + {ContainerSpec: types.ContainerSpec{Image: "custom-hook-image:1.2.3", Command: types.ShellCommand{"echo"}}}, + }, ContainerSpec: types.ContainerSpec{Image: "service-image:latest"}, } ctr := container.Summary{ID: "service-ctr-id"} @@ -248,11 +248,11 @@ func TestPreStart_VolumesFromServiceContainer(t *testing.T) { project := &types.Project{Name: "demo"} service := types.ServiceConfig{ - Name: "web", - Image: "alpine", - PreStart: []types.ServiceHook{ - {Image: "alpine", Command: types.ShellCommand{"true"}}, - }, + Name: "web", + + PreStart: []types.PreStartHook{ + {ContainerSpec: types.ContainerSpec{Image: "alpine", Command: types.ShellCommand{"true"}}}, + }, ContainerSpec: types.ContainerSpec{Image: "alpine"}, } ctr := container.Summary{ID: "service-ctr-id"} @@ -290,12 +290,12 @@ func TestPreStart_ContainerCreateFailurePropagates(t *testing.T) { project := &types.Project{Name: "demo"} service := types.ServiceConfig{ - Name: "web", - Image: "alpine", - PreStart: []types.ServiceHook{ - {Image: "missing:latest", Command: types.ShellCommand{"true"}}, - {Image: "alpine", Command: types.ShellCommand{"never"}}, - }, + Name: "web", + + PreStart: []types.PreStartHook{ + {ContainerSpec: types.ContainerSpec{Image: "missing:latest", Command: types.ShellCommand{"true"}}}, + {ContainerSpec: types.ContainerSpec{Image: "alpine", Command: types.ShellCommand{"never"}}}, + }, ContainerSpec: types.ContainerSpec{Image: "alpine"}, } ctr := container.Summary{ID: "service-ctr-id"} @@ -312,11 +312,11 @@ func TestPreStart_ContainerStartFailurePropagates(t *testing.T) { project := &types.Project{Name: "demo"} service := types.ServiceConfig{ - Name: "web", - Image: "alpine", - PreStart: []types.ServiceHook{ - {Image: "alpine", Command: types.ShellCommand{"true"}}, - }, + Name: "web", + + PreStart: []types.PreStartHook{ + {ContainerSpec: types.ContainerSpec{Image: "alpine", Command: types.ShellCommand{"true"}}}, + }, ContainerSpec: types.ContainerSpec{Image: "alpine"}, } ctr := container.Summary{ID: "service-ctr-id"} @@ -350,11 +350,11 @@ func TestPreStart_WaitResultPreferredOverNilError(t *testing.T) { project := &types.Project{Name: "demo"} service := types.ServiceConfig{ - Name: "web", - Image: "alpine", - PreStart: []types.ServiceHook{ - {Image: "alpine", Command: types.ShellCommand{"true"}}, - }, + Name: "web", + + PreStart: []types.PreStartHook{ + {ContainerSpec: types.ContainerSpec{Image: "alpine", Command: types.ShellCommand{"true"}}}, + }, ContainerSpec: types.ContainerSpec{Image: "alpine"}, } ctr := container.Summary{ID: "service-ctr-id"} @@ -434,10 +434,10 @@ func TestPreStart_DetachedModeAttachesLogs(t *testing.T) { project := &types.Project{Name: "demo"} service := types.ServiceConfig{ - Name: "web", - Image: "alpine", - PreStart: []types.ServiceHook{ - {Image: "alpine", Command: types.ShellCommand{"true"}}, + Name: "web", + ContainerSpec: types.ContainerSpec{Image: "alpine"}, + PreStart: []types.PreStartHook{ + {ContainerSpec: types.ContainerSpec{Image: "alpine", Command: types.ShellCommand{"true"}}}, }, } ctr := container.Summary{ID: "service-ctr-id"} @@ -466,10 +466,10 @@ func TestPreStart_FailureIncludesTail(t *testing.T) { project := &types.Project{Name: "demo"} service := types.ServiceConfig{ - Name: "db", - Image: "postgres", - PreStart: []types.ServiceHook{ - {Image: "postgres", Command: types.ShellCommand{"migrate"}}, + Name: "db", + ContainerSpec: types.ContainerSpec{Image: "postgres"}, + PreStart: []types.PreStartHook{ + {ContainerSpec: types.ContainerSpec{Image: "postgres", Command: types.ShellCommand{"migrate"}}}, }, } ctr := container.Summary{ID: "service-ctr-id"} @@ -510,10 +510,10 @@ func TestPreStart_SuccessRemovesContainer(t *testing.T) { project := &types.Project{Name: "proj"} service := types.ServiceConfig{ - Name: "web", - Image: "alpine", - PreStart: []types.ServiceHook{ - {Image: "alpine", Command: types.ShellCommand{"true"}}, + Name: "web", + ContainerSpec: types.ContainerSpec{Image: "alpine"}, + PreStart: []types.PreStartHook{ + {ContainerSpec: types.ContainerSpec{Image: "alpine", Command: types.ShellCommand{"true"}}}, }, } ctr := container.Summary{ID: "svc-ctr"} @@ -549,10 +549,10 @@ func TestPreStart_FailureRetainsContainer(t *testing.T) { project := &types.Project{Name: "proj"} service := types.ServiceConfig{ - Name: "web", - Image: "alpine", - PreStart: []types.ServiceHook{ - {Image: "alpine", Command: types.ShellCommand{"migrate"}}, + Name: "web", + ContainerSpec: types.ContainerSpec{Image: "alpine"}, + PreStart: []types.PreStartHook{ + {ContainerSpec: types.ContainerSpec{Image: "alpine", Command: types.ShellCommand{"migrate"}}}, }, } ctr := container.Summary{ID: "svc-ctr"} @@ -588,10 +588,10 @@ func TestPreStart_CancellationRemovesContainer(t *testing.T) { project := &types.Project{Name: "proj"} service := types.ServiceConfig{ - Name: "web", - Image: "alpine", - PreStart: []types.ServiceHook{ - {Image: "alpine", Command: types.ShellCommand{"long-running-op"}}, + Name: "web", + ContainerSpec: types.ContainerSpec{Image: "alpine"}, + PreStart: []types.PreStartHook{ + {ContainerSpec: types.ContainerSpec{Image: "alpine", Command: types.ShellCommand{"long-running-op"}}}, }, } ctr := container.Summary{ID: "svc-ctr"} @@ -634,10 +634,10 @@ func TestPreStart_RemovesOrphanBeforeRun(t *testing.T) { project := &types.Project{Name: "proj"} service := types.ServiceConfig{ - Name: "web", - Image: "alpine", - PreStart: []types.ServiceHook{ - {Image: "alpine", Command: types.ShellCommand{"migrate"}}, + Name: "web", + ContainerSpec: types.ContainerSpec{Image: "alpine"}, + PreStart: []types.PreStartHook{ + {ContainerSpec: types.ContainerSpec{Image: "alpine", Command: types.ShellCommand{"migrate"}}}, }, } ctr := container.Summary{ID: "svc-ctr"} @@ -671,10 +671,10 @@ func TestPreStart_SuccessRemoveFailureIsNonFatal(t *testing.T) { project := &types.Project{Name: "proj"} service := types.ServiceConfig{ - Name: "web", - Image: "alpine", - PreStart: []types.ServiceHook{ - {Image: "alpine", Command: types.ShellCommand{"true"}}, + Name: "web", + ContainerSpec: types.ContainerSpec{Image: "alpine"}, + PreStart: []types.PreStartHook{ + {ContainerSpec: types.ContainerSpec{Image: "alpine", Command: types.ShellCommand{"true"}}}, }, } ctr := container.Summary{ID: "svc-ctr"} @@ -763,10 +763,10 @@ func TestPreStart_StreamLogsError_NilListener(t *testing.T) { project := &types.Project{Name: "proj"} service := types.ServiceConfig{ - Name: "web", - Image: "alpine", - PreStart: []types.ServiceHook{ - {Image: "alpine", Command: types.ShellCommand{"true"}}, + Name: "web", + ContainerSpec: types.ContainerSpec{Image: "alpine"}, + PreStart: []types.PreStartHook{ + {ContainerSpec: types.ContainerSpec{Image: "alpine", Command: types.ShellCommand{"true"}}}, }, } ctr := container.Summary{ID: "svc-ctr"} @@ -795,10 +795,10 @@ func TestPreStart_StreamLogsError_WithListener(t *testing.T) { project := &types.Project{Name: "proj"} service := types.ServiceConfig{ - Name: "web", - Image: "alpine", - PreStart: []types.ServiceHook{ - {Image: "alpine", Command: types.ShellCommand{"true"}}, + Name: "web", + ContainerSpec: types.ContainerSpec{Image: "alpine"}, + PreStart: []types.PreStartHook{ + {ContainerSpec: types.ContainerSpec{Image: "alpine", Command: types.ShellCommand{"true"}}}, }, } ctr := container.Summary{ID: "svc-ctr"} @@ -838,13 +838,15 @@ func TestPreStart_OldAPIVersion(t *testing.T) { }, } service := types.ServiceConfig{ - Name: "web", - Image: "alpine", - Networks: map[string]*types.ServiceNetworkConfig{ - "default": nil, + Name: "web", + ContainerSpec: types.ContainerSpec{ + Image: "alpine", + Networks: map[string]*types.ServiceNetworkConfig{ + "default": nil, + }, }, - PreStart: []types.ServiceHook{ - {Image: "alpine", Command: types.ShellCommand{"true"}}, + PreStart: []types.PreStartHook{ + {ContainerSpec: types.ContainerSpec{Image: "alpine", Command: types.ShellCommand{"true"}}}, }, } ctr := container.Summary{ID: "svc-ctr"} @@ -878,11 +880,13 @@ func TestPreStart_ConnectExtraNetworksSuccess(t *testing.T) { }, } service := types.ServiceConfig{ - Name: "web", - Image: "alpine", - Networks: map[string]*types.ServiceNetworkConfig{ - "default": nil, - "extra": nil, + Name: "web", + ContainerSpec: types.ContainerSpec{ + Image: "alpine", + Networks: map[string]*types.ServiceNetworkConfig{ + "default": nil, + "extra": nil, + }, }, } @@ -907,11 +911,13 @@ func TestPreStart_ConnectExtraNetworksFails(t *testing.T) { }, } service := types.ServiceConfig{ - Name: "web", - Image: "alpine", - Networks: map[string]*types.ServiceNetworkConfig{ - "default": nil, - "extra": nil, + Name: "web", + ContainerSpec: types.ContainerSpec{ + Image: "alpine", + Networks: map[string]*types.ServiceNetworkConfig{ + "default": nil, + "extra": nil, + }, }, } @@ -931,10 +937,10 @@ func TestPreStart_ContainerStartFailureAndRemoveFails(t *testing.T) { project := &types.Project{Name: "proj"} service := types.ServiceConfig{ - Name: "web", - Image: "alpine", - PreStart: []types.ServiceHook{ - {Image: "alpine", Command: types.ShellCommand{"true"}}, + Name: "web", + ContainerSpec: types.ContainerSpec{Image: "alpine"}, + PreStart: []types.PreStartHook{ + {ContainerSpec: types.ContainerSpec{Image: "alpine", Command: types.ShellCommand{"true"}}}, }, } ctr := container.Summary{ID: "svc-ctr"} @@ -965,10 +971,10 @@ func TestPreStart_OrphanScanFails(t *testing.T) { project := &types.Project{Name: "proj"} service := types.ServiceConfig{ - Name: "web", - Image: "alpine", - PreStart: []types.ServiceHook{ - {Image: "alpine", Command: types.ShellCommand{"true"}}, + Name: "web", + ContainerSpec: types.ContainerSpec{Image: "alpine"}, + PreStart: []types.PreStartHook{ + {ContainerSpec: types.ContainerSpec{Image: "alpine", Command: types.ShellCommand{"true"}}}, }, } ctr := container.Summary{ID: "svc-ctr"} @@ -998,10 +1004,10 @@ func TestPreStart_OrphanRemovalFails(t *testing.T) { project := &types.Project{Name: "proj"} service := types.ServiceConfig{ - Name: "web", - Image: "alpine", - PreStart: []types.ServiceHook{ - {Image: "alpine", Command: types.ShellCommand{"true"}}, + Name: "web", + ContainerSpec: types.ContainerSpec{Image: "alpine"}, + PreStart: []types.PreStartHook{ + {ContainerSpec: types.ContainerSpec{Image: "alpine", Command: types.ShellCommand{"true"}}}, }, } ctr := container.Summary{ID: "svc-ctr"} @@ -1042,14 +1048,16 @@ func TestPreStart_OldAPINetworkConnectFails(t *testing.T) { }, } service := types.ServiceConfig{ - Name: "web", - Image: "alpine", - Networks: map[string]*types.ServiceNetworkConfig{ - "default": nil, - "extra": nil, + Name: "web", + ContainerSpec: types.ContainerSpec{ + Image: "alpine", + Networks: map[string]*types.ServiceNetworkConfig{ + "default": nil, + "extra": nil, + }, }, - PreStart: []types.ServiceHook{ - {Image: "alpine", Command: types.ShellCommand{"true"}}, + PreStart: []types.PreStartHook{ + {ContainerSpec: types.ContainerSpec{Image: "alpine", Command: types.ShellCommand{"true"}}}, }, } ctr := container.Summary{ID: "svc-ctr"} @@ -1082,14 +1090,16 @@ func TestPreStart_OldAPINetworkConnectAndRemoveFails(t *testing.T) { }, } service := types.ServiceConfig{ - Name: "web", - Image: "alpine", - Networks: map[string]*types.ServiceNetworkConfig{ - "default": nil, - "extra": nil, + Name: "web", + ContainerSpec: types.ContainerSpec{ + Image: "alpine", + Networks: map[string]*types.ServiceNetworkConfig{ + "default": nil, + "extra": nil, + }, }, - PreStart: []types.ServiceHook{ - {Image: "alpine", Command: types.ShellCommand{"true"}}, + PreStart: []types.PreStartHook{ + {ContainerSpec: types.ContainerSpec{Image: "alpine", Command: types.ShellCommand{"true"}}}, }, } ctr := container.Summary{ID: "svc-ctr"} @@ -1125,10 +1135,10 @@ func TestPreStart_RuntimeAPIVersionError(t *testing.T) { project := &types.Project{Name: "proj"} service := types.ServiceConfig{ - Name: "web", - Image: "alpine", - PreStart: []types.ServiceHook{ - {Image: "alpine", Command: types.ShellCommand{"true"}}, + Name: "web", + ContainerSpec: types.ContainerSpec{Image: "alpine"}, + PreStart: []types.PreStartHook{ + {ContainerSpec: types.ContainerSpec{Image: "alpine", Command: types.ShellCommand{"true"}}}, }, } ctr := container.Summary{ID: "svc-ctr"} @@ -1153,10 +1163,10 @@ func TestPreStart_FailureStdoutOnlyTail(t *testing.T) { project := &types.Project{Name: "proj"} service := types.ServiceConfig{ - Name: "db", - Image: "postgres", - PreStart: []types.ServiceHook{ - {Image: "postgres", Command: types.ShellCommand{"migrate"}}, + Name: "db", + ContainerSpec: types.ContainerSpec{Image: "postgres"}, + PreStart: []types.PreStartHook{ + {ContainerSpec: types.ContainerSpec{Image: "postgres", Command: types.ShellCommand{"migrate"}}}, }, } ctr := container.Summary{ID: "svc-ctr"} diff --git a/pkg/compose/publish.go b/pkg/compose/publish.go index 6d4380fe938..ae27ca4e586 100644 --- a/pkg/compose/publish.go +++ b/pkg/compose/publish.go @@ -333,7 +333,7 @@ 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 c78808ac25d..4a8519c15bd 100644 --- a/pkg/compose/publish_test.go +++ b/pkg/compose/publish_test.go @@ -121,10 +121,11 @@ 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}, + }, }, }, }, @@ -214,10 +215,11 @@ func Test_checkForSensitiveData_optional_env_file_missing(t *testing.T) { project := &types.Project{ Services: types.Services{ "web": { - Name: "web", - Image: "nginx", - EnvFiles: []types.EnvFile{ - {Path: filepath.Join(dir, "missing.env"), Required: false}, + Name: "web", ContainerSpec: types.ContainerSpec{ + Image: "nginx", + EnvFiles: []types.EnvFile{ + {Path: filepath.Join(dir, "missing.env"), Required: false}, + }, }, }, }, @@ -237,10 +239,11 @@ func Test_checkForSensitiveData_optional_env_file_present(t *testing.T) { project := &types.Project{ Services: types.Services{ "web": { - Name: "web", - Image: "nginx", - EnvFiles: []types.EnvFile{ - {Path: envPath, Required: false}, + Name: "web", ContainerSpec: types.ContainerSpec{ + Image: "nginx", + EnvFiles: []types.EnvFile{ + {Path: envPath, Required: false}, + }, }, }, }, @@ -257,10 +260,11 @@ func Test_checkForSensitiveData_required_env_file_missing(t *testing.T) { project := &types.Project{ Services: types.Services{ "web": { - Name: "web", - Image: "nginx", - EnvFiles: []types.EnvFile{ - {Path: filepath.Join(dir, "missing.env"), Required: true}, + Name: "web", ContainerSpec: types.ContainerSpec{ + Image: "nginx", + EnvFiles: []types.EnvFile{ + {Path: filepath.Join(dir, "missing.env"), Required: true}, + }, }, }, }, @@ -703,13 +707,14 @@ 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", + }, }, }, }, @@ -747,11 +752,14 @@ func Test_generateImageDigestsOverride_resolvesDependentImages(t *testing.T) { Name: "test", Services: types.Services{ "app": types.ServiceConfig{ - Name: "app", - Image: "nginx:latest", - PreStart: []types.ServiceHook{{Image: "hookimage:latest"}}, - Volumes: []types.ServiceVolumeConfig{ - {Type: types.VolumeTypeImage, Source: "someimage:latest", Target: "/data"}, + Name: "app", + + PreStart: []types.PreStartHook{{ContainerSpec: types.ContainerSpec{Image: "hookimage:latest"}}}, ContainerSpec: types.ContainerSpec{ + Image: "nginx:latest", + + Volumes: []types.ServiceVolumeConfig{ + {Type: types.VolumeTypeImage, Source: "someimage:latest", Target: "/data"}, + }, }, }, }, diff --git a/pkg/compose/pull.go b/pkg/compose/pull.go index 4cfc3fcc3f0..b8a935fbf32 100644 --- a/pkg/compose/pull.go +++ b/pkg/compose/pull.go @@ -180,7 +180,7 @@ func (p *imagePuller) pullHookImages(ctx context.Context) error { hookPolicy = types.PullPolicyMissing } for _, img := range api.GetDependentImages(service, p.project.Name) { - pullRequired, skipReason, err := shouldPullImage(types.ServiceConfig{Name: name, Image: img, PullPolicy: hookPolicy}, p.images) + pullRequired, skipReason, err := shouldPullImage(types.ServiceConfig{Name: name, ContainerSpec: types.ContainerSpec{Image: img, PullPolicy: hookPolicy}}, p.images) if err != nil { return err } @@ -194,7 +194,7 @@ func (p *imagePuller) pullHookImages(ctx context.Context) error { continue } p.scheduled[img] = name - hookService := types.ServiceConfig{Name: name, Image: img} + hookService := types.ServiceConfig{Name: name, ContainerSpec: types.ContainerSpec{Image: img}} p.eg.Go(func() error { err := p.pullServiceImage(ctx, hookService, p.opts.Quiet, p.project.Environment["DOCKER_DEFAULT_PLATFORM"]) if err != nil && !p.opts.IgnoreFailures { @@ -410,8 +410,8 @@ 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}, } scheduled[vol.Source] = true } @@ -506,7 +506,7 @@ func addPreStartHookPulls(project *types.Project, images map[string]api.ImageSum hookPolicy = types.PullPolicyMissing } for i, img := range api.GetDependentImages(service, project.Name) { - pull, err := mustPull(types.ServiceConfig{Name: name, Image: img, PullPolicy: hookPolicy}, images) + pull, err := mustPull(types.ServiceConfig{Name: name, ContainerSpec: types.ContainerSpec{Image: img, PullPolicy: hookPolicy}}, images) if err != nil { return err } @@ -517,8 +517,8 @@ func addPreStartHookPulls(project *types.Project, images map[string]api.ImageSum // Hack: create a fake ServiceConfig so we pull missing pre_start hook image n := fmt.Sprintf("%s:pre_start %d", name, i) needPull[n] = types.ServiceConfig{ - Name: n, - Image: img, + Name: n, + ContainerSpec: types.ContainerSpec{Image: img}, } } } diff --git a/pkg/compose/pull_test.go b/pkg/compose/pull_test.go index 606ea175b5b..b0f62251326 100644 --- a/pkg/compose/pull_test.go +++ b/pkg/compose/pull_test.go @@ -64,10 +64,12 @@ func scheduledHookImages(t *testing.T, project *types.Project, present map[strin func serviceWithHook(name, img, policy string) types.ServiceConfig { return types.ServiceConfig{ - Name: name, - Image: img, - PullPolicy: policy, - PreStart: []types.ServiceHook{{Image: "init:latest"}}, + Name: name, + + PreStart: []types.PreStartHook{{ContainerSpec: types.ContainerSpec{Image: "init:latest"}}}, ContainerSpec: types.ContainerSpec{ + Image: img, + PullPolicy: policy, + }, } } @@ -166,8 +168,8 @@ func TestPullRequiredImagesUsesContentDigest(t *testing.T) { project := &types.Project{ Name: "demo", Services: types.Services{ - "web": {Name: "web", Image: ref}, - "pinned": {Name: "pinned", Image: ref, Platform: "linux/amd64"}, + "web": {Name: "web", ContainerSpec: types.ContainerSpec{Image: ref}}, + "pinned": {Name: "pinned", ContainerSpec: types.ContainerSpec{Image: ref, Platform: "linux/amd64"}}, }, } images := map[string]api.ImageSummary{} @@ -197,7 +199,7 @@ func TestShouldPullImage(t *testing.T) { "old:1": {LastTagTime: time.Now().Add(-48 * time.Hour)}, } svc := func(image, policy string) types.ServiceConfig { - return types.ServiceConfig{Name: "web", Image: image, PullPolicy: policy} + return types.ServiceConfig{Name: "web", ContainerSpec: types.ContainerSpec{Image: image, PullPolicy: policy}} } t.Run("no explicit policy always refreshes", func(t *testing.T) { @@ -286,9 +288,9 @@ func TestShouldPullImageProvider(t *testing.T) { images := map[string]api.ImageSummary{} pull, _, err := shouldPullImage(types.ServiceConfig{ - Name: "db", - Image: "db:1", - Provider: &types.ServiceProviderConfig{Type: "acme"}, + Name: "db", + + Provider: &types.ServiceProviderConfig{Type: "acme"}, ContainerSpec: types.ContainerSpec{Image: "db:1"}, }, images) assert.NilError(t, err) assert.Assert(t, pull, "provider service with a declared image must be pulled") diff --git a/pkg/compose/reconcile_test.go b/pkg/compose/reconcile_test.go index c0336ce162e..9d89275dec1 100644 --- a/pkg/compose/reconcile_test.go +++ b/pkg/compose/reconcile_test.go @@ -156,7 +156,7 @@ func networkAttachedContainer(t *testing.T, svc types.ServiceConfig, id string) // network is removed then recreated, and the same containers are reconnected — // not recreated. func TestReconcileNetworks_Diverged(t *testing.T) { - web := types.ServiceConfig{Name: "web", Scale: intPtr(1), Networks: map[string]*types.ServiceNetworkConfig{"frontend": {}}} + web := types.ServiceConfig{Name: "web", Scale: intPtr(1), ContainerSpec: types.ContainerSpec{Networks: map[string]*types.ServiceNetworkConfig{"frontend": {}}}} project := &types.Project{ Name: "myproject", Networks: types.Networks{"frontend": {Name: "myproject_frontend", Driver: "overlay"}}, @@ -189,7 +189,7 @@ func TestReconcileNetworks_Diverged(t *testing.T) { // recreated (by reconcileContainers), with the reconnect ordered before the old // container's removal so they don't race. func TestReconcileNetworks_DivergedAlsoRecreatesChangedContainer(t *testing.T) { - web := types.ServiceConfig{Name: "web", Scale: intPtr(1), Networks: map[string]*types.ServiceNetworkConfig{"frontend": {}}} + web := types.ServiceConfig{Name: "web", Scale: intPtr(1), ContainerSpec: types.ContainerSpec{Networks: map[string]*types.ServiceNetworkConfig{"frontend": {}}}} project := &types.Project{ Name: "myproject", Networks: types.Networks{"frontend": {Name: "myproject_frontend", Driver: "overlay"}}, @@ -232,14 +232,12 @@ func TestReconcileNetworks_DivergedMultipleServices(t *testing.T) { }, Services: types.Services{ "web": { - Name: "web", - Scale: intPtr(1), - Networks: map[string]*types.ServiceNetworkConfig{"frontend": {}}, + Name: "web", + Scale: intPtr(1), ContainerSpec: types.ContainerSpec{Networks: map[string]*types.ServiceNetworkConfig{"frontend": {}}}, }, "api": { - Name: "api", - Scale: intPtr(1), - Networks: map[string]*types.ServiceNetworkConfig{"frontend": {}}, + Name: "api", + Scale: intPtr(1), ContainerSpec: types.ContainerSpec{Networks: map[string]*types.ServiceNetworkConfig{"frontend": {}}}, }, }, } @@ -281,7 +279,7 @@ func TestReconcileNetworks_DivergedMultipleServices(t *testing.T) { // migration), so a network still in use by non-Compose containers cannot block a // rename. func TestReconcileNetworks_Renamed(t *testing.T) { - web := types.ServiceConfig{Name: "web", Scale: intPtr(1), Networks: map[string]*types.ServiceNetworkConfig{"frontend": {}}} + web := types.ServiceConfig{Name: "web", Scale: intPtr(1), ContainerSpec: types.ContainerSpec{Networks: map[string]*types.ServiceNetworkConfig{"frontend": {}}}} project := &types.Project{ Name: "myproject", Networks: types.Networks{"frontend": {Name: "myproject_frontend_v2", Driver: "overlay"}}, @@ -431,9 +429,8 @@ func divergedVolumeProject(t *testing.T, count, scale int) (*types.Project, *Obs for s := 0; s < count; s++ { name := fmt.Sprintf("db%d", s) svc := types.ServiceConfig{ - Name: name, - Scale: intPtr(scale), - Volumes: []types.ServiceVolumeConfig{{Source: "data", Type: "volume"}}, + Name: name, + Scale: intPtr(scale), ContainerSpec: types.ContainerSpec{Volumes: []types.ServiceVolumeConfig{{Source: "data", Type: "volume"}}}, } project.Services[name] = svc hash := mustServiceHash(t, svc) @@ -569,11 +566,10 @@ func TestReconcileVolumes_DivergedConfirmedSharedContainer(t *testing.T) { vol2 := types.VolumeConfig{Name: "myproject_data2", Driver: "local"} svc := types.ServiceConfig{ Name: "db", - Scale: intPtr(1), - Volumes: []types.ServiceVolumeConfig{ + Scale: intPtr(1), ContainerSpec: types.ContainerSpec{Volumes: []types.ServiceVolumeConfig{ {Source: "data1", Type: "volume"}, {Source: "data2", Type: "volume"}, - }, + }}, } project := &types.Project{ Name: "myproject", @@ -620,8 +616,8 @@ func TestReconcileVolumes_DivergedConfirmedSharedContainer(t *testing.T) { func TestReconcileVolumes_DivergedPartialConfirm(t *testing.T) { vol1 := types.VolumeConfig{Name: "myproject_data1", Driver: "local"} vol2 := types.VolumeConfig{Name: "myproject_data2", Driver: "local"} - svc1 := types.ServiceConfig{Name: "db1", Scale: intPtr(1), Volumes: []types.ServiceVolumeConfig{{Source: "data1", Type: "volume"}}} - svc2 := types.ServiceConfig{Name: "db2", Scale: intPtr(1), Volumes: []types.ServiceVolumeConfig{{Source: "data2", Type: "volume"}}} + svc1 := types.ServiceConfig{Name: "db1", Scale: intPtr(1), ContainerSpec: types.ContainerSpec{Volumes: []types.ServiceVolumeConfig{{Source: "data1", Type: "volume"}}}} + svc2 := types.ServiceConfig{Name: "db2", Scale: intPtr(1), ContainerSpec: types.ContainerSpec{Volumes: []types.ServiceVolumeConfig{{Source: "data2", Type: "volume"}}}} project := &types.Project{ Name: "myproject", Volumes: types.Volumes{"data1": vol1, "data2": vol2}, @@ -680,17 +676,22 @@ func TestReconcileVolumes_DivergedPartialConfirm(t *testing.T) { func TestReconcileVolumes_DivergedCascadesToDependent(t *testing.T) { vol := types.VolumeConfig{Name: "myproject_data", Driver: "local"} owner := types.ServiceConfig{ - Name: "owner", - Image: "alpine", - Scale: intPtr(1), - Volumes: []types.ServiceVolumeConfig{{Source: "data", Type: "volume"}}, + Name: "owner", + + Scale: intPtr(1), ContainerSpec: types.ContainerSpec{ + Image: "alpine", + + Volumes: []types.ServiceVolumeConfig{{Source: "data", Type: "volume"}}, + }, } dependent := types.ServiceConfig{ - Name: "dependent", - Image: "alpine", - Scale: intPtr(1), - VolumesFrom: []string{"owner"}, - DependsOn: types.DependsOnConfig{"owner": {Condition: types.ServiceConditionStarted, Restart: true, Required: true}}, + Name: "dependent", + + Scale: intPtr(1), ContainerSpec: types.ContainerSpec{ + Image: "alpine", + + VolumesFrom: []string{"owner"}, + }, WorkloadSpec: types.WorkloadSpec{DependsOn: types.DependsOnConfig{"owner": {Condition: types.ServiceConditionStarted, Restart: true, Required: true}}}, } project := &types.Project{ Name: "myproject", @@ -748,19 +749,24 @@ func TestReconcileVolumes_DivergedCascadesToDependent(t *testing.T) { func TestReconcileVolumes_DivergedVolumesFromRemovedBeforeVolume(t *testing.T) { vol := types.VolumeConfig{Name: "myproject_data", Driver: "local"} owner := types.ServiceConfig{ - Name: "owner", - Image: "alpine", - Scale: intPtr(1), - Volumes: []types.ServiceVolumeConfig{{Source: "data", Type: "volume"}}, + Name: "owner", + + Scale: intPtr(1), ContainerSpec: types.ContainerSpec{ + Image: "alpine", + + Volumes: []types.ServiceVolumeConfig{{Source: "data", Type: "volume"}}, + }, } // consumer inherits owner's mounts (including data) but never declares the // volume itself. consumer := types.ServiceConfig{ - Name: "consumer", - Image: "alpine", - Scale: intPtr(1), - VolumesFrom: []string{"owner"}, - DependsOn: types.DependsOnConfig{"owner": {Condition: types.ServiceConditionStarted, Required: true}}, + Name: "consumer", + + Scale: intPtr(1), ContainerSpec: types.ContainerSpec{ + Image: "alpine", + + VolumesFrom: []string{"owner"}, + }, WorkloadSpec: types.WorkloadSpec{DependsOn: types.DependsOnConfig{"owner": {Condition: types.ServiceConditionStarted, Required: true}}}, } project := &types.Project{ Name: "myproject", @@ -831,7 +837,7 @@ func TestReconcileVolumes_UnmanagedMatchReused(t *testing.T) { Name: "myproject", Volumes: types.Volumes{"data": {Name: "myproject_data", Driver: "local"}}, Services: types.Services{ - "db": {Name: "db", Scale: intPtr(1), Volumes: []types.ServiceVolumeConfig{{Source: "data", Type: "volume"}}}, + "db": {Name: "db", Scale: intPtr(1), ContainerSpec: types.ContainerSpec{Volumes: []types.ServiceVolumeConfig{{Source: "data", Type: "volume"}}}}, }, } dbHash := mustServiceHash(t, project.Services["db"]) @@ -894,7 +900,7 @@ func TestReconcileVolumes_RenamedMigratesContainers(t *testing.T) { Name: "myproject", Volumes: types.Volumes{"data": {Name: "myproject_data_v2", Driver: "local"}}, Services: types.Services{ - "db": {Name: "db", Scale: intPtr(1), Volumes: []types.ServiceVolumeConfig{{Source: "data", Type: "volume"}}}, + "db": {Name: "db", Scale: intPtr(1), ContainerSpec: types.ContainerSpec{Volumes: []types.ServiceVolumeConfig{{Source: "data", Type: "volume"}}}}, }, } dbHash := mustServiceHash(t, project.Services["db"]) @@ -1258,10 +1264,9 @@ func TestReconcileContainers_DependsOnChain(t *testing.T) { "db": {Name: "db", Scale: intPtr(1)}, "web": { Name: "web", - Scale: intPtr(1), - DependsOn: types.DependsOnConfig{ + Scale: intPtr(1), WorkloadSpec: types.WorkloadSpec{DependsOn: types.DependsOnConfig{ "db": {Condition: types.ServiceConditionStarted}, - }, + }}, }, }, } @@ -1296,10 +1301,9 @@ func TestReconcileContainers_DependsOnScaleDown(t *testing.T) { "db": svc, "web": { Name: "web", - Scale: intPtr(1), - DependsOn: types.DependsOnConfig{ + Scale: intPtr(1), WorkloadSpec: types.WorkloadSpec{DependsOn: types.DependsOnConfig{ "db": {Condition: types.ServiceConditionStarted}, - }, + }}, }, }, } @@ -1428,8 +1432,8 @@ func TestReconcileContainers_ServiceReference_NoRecreate(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - parent := types.ServiceConfig{Name: "parent", Image: "alpine", Scale: intPtr(1)} - dependent := types.ServiceConfig{Name: "dependent", Image: "alpine", Scale: intPtr(1)} + parent := types.ServiceConfig{Name: "parent", Scale: intPtr(1), ContainerSpec: types.ContainerSpec{Image: "alpine"}} + dependent := types.ServiceConfig{Name: "dependent", Scale: intPtr(1), ContainerSpec: types.ContainerSpec{Image: "alpine"}} tc.mutate(&dependent) project := &types.Project{ Name: "myproject", @@ -1450,10 +1454,11 @@ func TestReconcileContainers_ServiceReference_NoRecreate(t *testing.T) { // produces for namespace-sharing services, so planStopDependents fires too — // the test also asserts the resulting Stop is not duplicated. func TestReconcileContainers_NamespaceParentRecreated_CascadesToDependent(t *testing.T) { - parent := types.ServiceConfig{Name: "parent", Image: "alpine", Scale: intPtr(1)} + parent := types.ServiceConfig{Name: "parent", Scale: intPtr(1), ContainerSpec: types.ContainerSpec{Image: "alpine"}} dependent := types.ServiceConfig{ - Name: "dependent", Image: "alpine", Scale: intPtr(1), NetworkMode: "service:parent", - DependsOn: types.DependsOnConfig{"parent": {Condition: types.ServiceConditionStarted, Restart: true, Required: true}}, + Name: "dependent", Scale: intPtr(1), + ContainerSpec: types.ContainerSpec{Image: "alpine", NetworkMode: "service:parent"}, + WorkloadSpec: types.WorkloadSpec{DependsOn: types.DependsOnConfig{"parent": {Condition: types.ServiceConditionStarted, Restart: true, Required: true}}}, } project := &types.Project{ Name: "myproject", @@ -1479,17 +1484,20 @@ func TestReconcileContainers_NamespaceParentRecreated_CascadesToDependent(t *tes // non-matching parent: a dependent sharing namespace with two parents must // cascade-recreate when either parent is scheduled for recreation. func TestReconcileContainers_MultipleParents_EitherTriggersCascade(t *testing.T) { - netParent := types.ServiceConfig{Name: "netparent", Image: "alpine", Scale: intPtr(1)} - volParent := types.ServiceConfig{Name: "volparent", Image: "alpine", Scale: intPtr(1)} + netParent := types.ServiceConfig{Name: "netparent", Scale: intPtr(1), ContainerSpec: types.ContainerSpec{Image: "alpine"}} + volParent := types.ServiceConfig{Name: "volparent", Scale: intPtr(1), ContainerSpec: types.ContainerSpec{Image: "alpine"}} dependent := types.ServiceConfig{ - Name: "dependent", Image: "alpine", Scale: intPtr(1), - NetworkMode: "service:netparent", - VolumesFrom: []string{"volparent"}, - // Mirrors what compose-go's normalizer injects for namespace-sharing - // references, so the dependency graph orders parents before dependent. - DependsOn: types.DependsOnConfig{ - "netparent": {Condition: types.ServiceConditionStarted, Restart: true, Required: true}, - "volparent": {Condition: types.ServiceConditionStarted, Restart: true, Required: true}, + Name: "dependent", Scale: intPtr(1), ContainerSpec: types.ContainerSpec{ + Image: "alpine", + NetworkMode: "service:netparent", + VolumesFrom: []string{"volparent"}, + }, WorkloadSpec: types.WorkloadSpec{ + // Mirrors what compose-go's normalizer injects for namespace-sharing + // references, so the dependency graph orders parents before dependent. + DependsOn: types.DependsOnConfig{ + "netparent": {Condition: types.ServiceConditionStarted, Restart: true, Required: true}, + "volparent": {Condition: types.ServiceConditionStarted, Restart: true, Required: true}, + }, }, } project := &types.Project{ @@ -1548,10 +1556,11 @@ func TestReconcileContainers_MultipleParents_EitherTriggersCascade(t *testing.T) // TestReconcileContainers_RegularDependsOn_NoCascade ensures the cascade fires // only for namespace/volume-sharing dependencies, not for plain depends_on. func TestReconcileContainers_RegularDependsOn_NoCascade(t *testing.T) { - parent := types.ServiceConfig{Name: "parent", Image: "alpine", Scale: intPtr(1)} + parent := types.ServiceConfig{Name: "parent", Scale: intPtr(1), ContainerSpec: types.ContainerSpec{Image: "alpine"}} dependent := types.ServiceConfig{ - Name: "dependent", Image: "alpine", Scale: intPtr(1), - DependsOn: types.DependsOnConfig{"parent": {Condition: types.ServiceConditionStarted, Restart: true}}, + Name: "dependent", Scale: intPtr(1), + ContainerSpec: types.ContainerSpec{Image: "alpine"}, + WorkloadSpec: types.WorkloadSpec{DependsOn: types.DependsOnConfig{"parent": {Condition: types.ServiceConditionStarted, Restart: true}}}, } project := &types.Project{ Name: "myproject", diff --git a/pkg/compose/restart_test.go b/pkg/compose/restart_test.go index 9e4041dd51f..9c04f114757 100644 --- a/pkg/compose/restart_test.go +++ b/pkg/compose/restart_test.go @@ -51,16 +51,14 @@ func TestPrepareRestartProject(t *testing.T) { Name: "prj", Services: types.Services{ "proxy": { - Name: "proxy", - DependsOn: types.DependsOnConfig{ + Name: "proxy", WorkloadSpec: types.WorkloadSpec{DependsOn: types.DependsOnConfig{ "web": {Condition: types.ServiceConditionStarted, Restart: true, Required: true}, - }, + }}, }, "web": { - Name: "web", - DependsOn: types.DependsOnConfig{ + Name: "web", WorkloadSpec: types.WorkloadSpec{DependsOn: types.DependsOnConfig{ "db": {Condition: types.ServiceConditionStarted, Restart: false, Required: true}, - }, + }}, }, "db": {Name: "db"}, }, diff --git a/pkg/compose/service_containers_test.go b/pkg/compose/service_containers_test.go index d04a87979c0..6440401e954 100644 --- a/pkg/compose/service_containers_test.go +++ b/pkg/compose/service_containers_test.go @@ -249,13 +249,12 @@ func TestWaitDependencies(t *testing.T) { replicas := 0 project := types.Project{Name: strings.ToLower(testProject), Services: types.Services{ "app": { - Name: "app", - DependsOn: types.DependsOnConfig{ + Name: "app", WorkloadSpec: types.WorkloadSpec{DependsOn: types.DependsOnConfig{ "disabled": { Condition: ServiceConditionRunningOrHealthy, Required: true, }, - }, + }}, }, "disabled": { Name: "disabled", @@ -487,15 +486,14 @@ func TestCreateMobyContainer(t *testing.T) { apiClient.EXPECT().ClientVersion().Return("1.44").AnyTimes() service := types.ServiceConfig{ - Name: "test", - Networks: map[string]*types.ServiceNetworkConfig{ + Name: "test", ContainerSpec: types.ContainerSpec{Networks: map[string]*types.ServiceNetworkConfig{ "a": { Priority: 10, }, "b": { Priority: 100, }, - }, + }}, } project := types.Project{ Name: "bork", @@ -586,11 +584,10 @@ func TestCreateMobyContainerLegacyAPI(t *testing.T) { apiClient.EXPECT().ClientVersion().Return("1.43").AnyTimes() service := types.ServiceConfig{ - Name: "test", - Networks: map[string]*types.ServiceNetworkConfig{ + Name: "test", ContainerSpec: types.ContainerSpec{Networks: map[string]*types.ServiceNetworkConfig{ "a": {Priority: 10}, "b": {Priority: 100}, - }, + }}, } project := types.Project{ Name: "bork", @@ -675,11 +672,10 @@ func TestCreateMobyContainerLegacyAPI_NetworkConnectFailure(t *testing.T) { apiClient.EXPECT().ClientVersion().Return("1.43").AnyTimes() service := types.ServiceConfig{ - Name: "test", - Networks: map[string]*types.ServiceNetworkConfig{ + Name: "test", ContainerSpec: types.ContainerSpec{Networks: map[string]*types.ServiceNetworkConfig{ "a": {Priority: 10}, "b": {Priority: 100}, - }, + }}, } project := types.Project{ Name: "bork", diff --git a/pkg/compose/start_test.go b/pkg/compose/start_test.go index be1d80e7b76..b392112a026 100644 --- a/pkg/compose/start_test.go +++ b/pkg/compose/start_test.go @@ -26,6 +26,7 @@ import ( "testing" "github.com/compose-spec/compose-go/v2/types" + "github.com/docker/cli/cli/config/configfile" "github.com/moby/moby/api/types/container" "github.com/moby/moby/client" "go.uber.org/mock/gomock" @@ -89,6 +90,10 @@ func newStartTestService(t *testing.T) (*composeService, *mocks.MockAPIClient, * apiClient.EXPECT().Ping(gomock.Any(), client.PingOptions{NegotiateAPIVersion: true}). Return(client.PingResult{APIVersion: "1.44"}, nil).AnyTimes() apiClient.EXPECT().ClientVersion().Return("1.44").AnyTimes() + // the generic pre_start inheritance goes through getCreateConfigs, which + // reads the CLI configuration and the daemon host + cli.EXPECT().ConfigFile().Return(&configfile.ConfigFile{}).AnyTimes() + apiClient.EXPECT().DaemonHost().Return("unix:///var/run/docker.sock").AnyTimes() rec := &recordingEventProcessor{} svc, err := NewComposeService(cli, WithEventProcessor(rec)) @@ -164,7 +169,7 @@ func TestStartService_StartsOnlyStoppedReplicas(t *testing.T) { project := &types.Project{Name: "prj"} service := types.ServiceConfig{ Name: "web", - PreStart: []types.ServiceHook{{Command: types.ShellCommand{"init"}}}, + PreStart: []types.PreStartHook{{ContainerSpec: types.ContainerSpec{Command: types.ShellCommand{"init"}}}}, } running := serviceContainer("web", 1, container.StateRunning) stopped := serviceContainer("web", 2, container.StateExited) @@ -194,13 +199,14 @@ func TestStartService_PreStartOnLowestReplica(t *testing.T) { project := &types.Project{Name: "prj"} service := types.ServiceConfig{ - Name: "web", - Image: "alpine", - PreStart: []types.ServiceHook{{Command: types.ShellCommand{"init"}}}, + Name: "web", + ContainerSpec: types.ContainerSpec{Image: "alpine"}, + PreStart: []types.PreStartHook{{ContainerSpec: types.ContainerSpec{Command: types.ShellCommand{"init"}}}}, } - // Listed out of order on purpose: replica 2 first. + replica2 := serviceContainer("web", 2, container.StateExited) replica1 := serviceContainer("web", 1, container.StateExited) + // Listed out of order on purpose: replica 2 first. containers := Containers{replica2, replica1} // runPreStart sweeps orphan hook containers from any previous failed run @@ -276,9 +282,9 @@ func TestStartServiceContainer_Order(t *testing.T) { }, } service := types.ServiceConfig{ - Name: "web", - Secrets: []types.ServiceSecretConfig{{Source: "token"}}, - PostStart: []types.ServiceHook{{Command: types.ShellCommand{"notify"}}}, + Name: "web", + + PostStart: []types.ServiceHook{{Command: types.ShellCommand{"notify"}}}, ContainerSpec: types.ContainerSpec{Secrets: []types.ServiceSecretConfig{{Source: "token"}}}, } ctr := serviceContainer("web", 1, container.StateExited) @@ -350,10 +356,9 @@ func TestStartServiceContainer_FailedPostStart(t *testing.T) { func TestGetDependencyCondition(t *testing.T) { oneShot := types.ServiceConfig{Name: "migrate"} web := types.ServiceConfig{ - Name: "web", - DependsOn: types.DependsOnConfig{ + Name: "web", WorkloadSpec: types.WorkloadSpec{DependsOn: types.DependsOnConfig{ "migrate": {Condition: types.ServiceConditionCompletedSuccessfully}, - }, + }}, } project := &types.Project{ Name: "prj", diff --git a/pkg/compose/viz_test.go b/pkg/compose/viz_test.go index 2ecbb5ab06f..85c58e2b2a3 100644 --- a/pkg/compose/viz_test.go +++ b/pkg/compose/viz_test.go @@ -35,9 +35,13 @@ func TestViz(t *testing.T) { WorkingDir: "/home", Services: types.Services{ "service1": { - Name: "service1", - Image: "image-for-service1", - Ports: []types.ServicePortConfig{ + Name: "service1", ContainerSpec: types.ContainerSpec{ + Image: "image-for-service1", + + Networks: map[string]*types.ServiceNetworkConfig{ + "internal": nil, + }, + }, WorkloadSpec: types.WorkloadSpec{Ports: []types.ServicePortConfig{ { Published: "80", Target: 80, @@ -48,51 +52,47 @@ func TestViz(t *testing.T) { Target: 533, Protocol: "udp", }, - }, - 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"}, WorkloadSpec: types.WorkloadSpec{Ports: []types.ServicePortConfig{}}, }, "service3": { - Name: "service3", - Image: "some-image", - DependsOn: map[string]types.ServiceDependency{ + Name: "service3", ContainerSpec: types.ContainerSpec{Image: "some-image"}, WorkloadSpec: types.WorkloadSpec{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", + + Networks: map[string]*types.ServiceNetworkConfig{ + "external": nil, + }, + }, WorkloadSpec: types.WorkloadSpec{ + DependsOn: map[string]types.ServiceDependency{ + "service3": {}, + }, + Ports: []types.ServicePortConfig{ + { + Published: "8080", + Target: 80, + }, }, - }, - 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"}, WorkloadSpec: types.WorkloadSpec{ + DependsOn: map[string]types.ServiceDependency{ + "service1": {}, + }, + Ports: []types.ServicePortConfig{ + { + Published: "8888", + Target: 8080, + HostIP: "127.0.0.1", + }, }, }, }, diff --git a/pkg/compose/watch_test.go b/pkg/compose/watch_test.go index e217d627e8e..33fb48c76f3 100644 --- a/pkg/compose/watch_test.go +++ b/pkg/compose/watch_test.go @@ -280,8 +280,7 @@ func TestInitialSyncFilesRegularFile(t *testing.T) { syncer := &fakeSyncer{synced: make(chan []*sync.PathMapping, 1)} err := (&composeService{}).initialSync(t.Context(), types.ServiceConfig{ - Name: "svc", - Build: &types.BuildConfig{Context: hostDir}, + Name: "svc", WorkloadSpec: types.WorkloadSpec{Build: &types.BuildConfig{Context: hostDir}}, }, types.Trigger{ Path: hostFile, Target: "/app/test.txt", @@ -305,8 +304,8 @@ func TestInitialSync_ExcludesDockerfileAndComposeFiles(t *testing.T) { syncer := &fakeSyncer{synced: make(chan []*sync.PathMapping, 1)} err := (&composeService{}).initialSync(t.Context(), types.ServiceConfig{ - Name: "svc", - Build: &types.BuildConfig{Context: hostDir}, + Name: "svc", + WorkloadSpec: types.WorkloadSpec{Build: &types.BuildConfig{Context: hostDir}}, }, types.Trigger{ Path: hostDir, Target: "/app", @@ -331,8 +330,8 @@ func TestInitialSync_ExcludesCustomNamedDockerfile(t *testing.T) { syncer := &fakeSyncer{synced: make(chan []*sync.PathMapping, 1)} err := (&composeService{}).initialSync(t.Context(), types.ServiceConfig{ - Name: "svc", - Build: &types.BuildConfig{Context: hostDir, Dockerfile: "Dockerfile.prod"}, + Name: "svc", + WorkloadSpec: types.WorkloadSpec{Build: &types.BuildConfig{Context: hostDir, Dockerfile: "Dockerfile.prod"}}, }, types.Trigger{ Path: hostDir, Target: "/app", @@ -360,8 +359,8 @@ func TestInitialSync_ExcludesNestedCustomNamedDockerfile(t *testing.T) { syncer := &fakeSyncer{synced: make(chan []*sync.PathMapping, 1)} err := (&composeService{}).initialSync(t.Context(), types.ServiceConfig{ - Name: "svc", - Build: &types.BuildConfig{Context: hostDir, Dockerfile: "docker/Dockerfile.prod"}, + Name: "svc", + WorkloadSpec: types.WorkloadSpec{Build: &types.BuildConfig{Context: hostDir, Dockerfile: "docker/Dockerfile.prod"}}, }, types.Trigger{ Path: hostDir, Target: "/app", diff --git a/pkg/e2e/compose_test.go b/pkg/e2e/compose_test.go index 7c29a056515..20fee1cc1c4 100644 --- a/pkg/e2e/compose_test.go +++ b/pkg/e2e/compose_test.go @@ -213,11 +213,11 @@ func TestConfig(t *testing.T) { OutputContains(fmt.Sprintf(`name: %s services: nginx: + networks: + default: null build: context: %s dockerfile: Dockerfile - networks: - default: null networks: default: name: %s_default From a7d455517de3b124afbb072730ab94e037570519 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Wed, 19 Aug 2026 15:44:10 +0200 Subject: [PATCH 02/16] jobs: warn and ignore, reject active scheduled jobs on up, create and start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The model may declare jobs this runtime cannot execute yet. Commands that materialize the application warn about them, and refuse an active (profile-enabled) scheduled job before any resource is created — up, create and start alike: silently not scheduling would break expectations, while manual jobs just wait for an explicit trigger. Co-Authored-By: Claude Fable 5 Signed-off-by: Nicolas De Loof --- cmd/compose/compose.go | 10 ++++++++++ cmd/compose/create.go | 5 +++++ cmd/compose/start.go | 7 +++++++ cmd/compose/up.go | 22 ++++++++++++++++++++++ cmd/compose/up_test.go | 16 ++++++++++++++++ 5 files changed, 60 insertions(+) diff --git a/cmd/compose/compose.go b/cmd/compose/compose.go index 14757f8dd5d..ef8062f149e 100644 --- a/cmd/compose/compose.go +++ b/cmd/compose/compose.go @@ -25,6 +25,7 @@ import ( "os" "os/signal" "path/filepath" + "sort" "strconv" "strings" "syscall" @@ -368,6 +369,15 @@ func (o *ProjectOptions) ToProject( return nil, metrics, err } + if jobs := project.AllJobs(); len(jobs) > 0 { + names := make([]string, 0, len(jobs)) + for name := range jobs { + names = append(names, name) + } + sort.Strings(names) + logrus.Warnf("jobs are not supported yet and will be ignored: %s", strings.Join(names, ", ")) + } + return project, metrics, nil } diff --git a/cmd/compose/create.go b/cmd/compose/create.go index d8dc01b8173..81f45e97b31 100644 --- a/cmd/compose/create.go +++ b/cmd/compose/create.go @@ -98,6 +98,11 @@ func createCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *Bac } func runCreate(ctx context.Context, dockerCli command.Cli, backendOptions *BackendOptions, createOpts createOptions, buildOpts buildOptions, project *types.Project, services []string) error { + // same contract as up: an active scheduled job is refused before any + // resource is created — silently not scheduling would break expectations + if err := rejectScheduledJobs(project); err != nil { + return err + } if err := createOpts.Apply(project); err != nil { return err } diff --git a/cmd/compose/start.go b/cmd/compose/start.go index 062efb680d2..7ecde69e131 100644 --- a/cmd/compose/start.go +++ b/cmd/compose/start.go @@ -56,6 +56,13 @@ func runStart(ctx context.Context, dockerCli command.Cli, backendOptions *Backen if err != nil { return err } + // a label-reconstructed project (no compose file) declares no jobs; + // with a file, refuse active scheduled jobs like up and create do + if project != nil { + if err := rejectScheduledJobs(project); err != nil { + return err + } + } var timeout time.Duration if opts.waitTimeout > 0 { diff --git a/cmd/compose/up.go b/cmd/compose/up.go index 89bb6399126..5295032fd58 100644 --- a/cmd/compose/up.go +++ b/cmd/compose/up.go @@ -21,6 +21,7 @@ import ( "errors" "fmt" "os" + "sort" "strings" "time" @@ -241,6 +242,10 @@ func runUp( return err } + if err := rejectScheduledJobs(project); err != nil { + return err + } + err := createOptions.Apply(project) if err != nil { return err @@ -349,3 +354,20 @@ func runUp( }, }) } + +// rejectScheduledJobs refuses to bring a project up when it declares active +// scheduled jobs: silently not scheduling them would break the user's +// expectations, unlike manual jobs which simply wait for an explicit trigger. +func rejectScheduledJobs(project *types.Project) error { + names := make([]string, 0, len(project.Jobs)) + for name, job := range project.Jobs { + if job.Triggers != nil && len(job.Triggers.Schedule) > 0 { + names = append(names, name) + } + } + if len(names) == 0 { + return nil + } + sort.Strings(names) + return fmt.Errorf("scheduled jobs are not supported in this version: %s", strings.Join(names, ", ")) +} diff --git a/cmd/compose/up_test.go b/cmd/compose/up_test.go index ee159d08b7d..e3f28bb919d 100644 --- a/cmd/compose/up_test.go +++ b/cmd/compose/up_test.go @@ -178,3 +178,19 @@ services: assert.Assert(t, strings.Contains(output, "LXKNS_PORT"), output) assert.Assert(t, !strings.Contains(fmt.Sprint(err), "invalid ip address"), fmt.Sprint(err)) } + +func TestRejectScheduledJobs(t *testing.T) { + yes := true + manual := types.JobConfig{Triggers: &types.TriggerConfig{Manual: &yes}} + scheduled := types.JobConfig{Triggers: &types.TriggerConfig{ + Schedule: []types.ScheduleConfig{{Cron: "0 3 * * *"}}, + }} + + assert.NilError(t, rejectScheduledJobs(&types.Project{})) + assert.NilError(t, rejectScheduledJobs(&types.Project{Jobs: types.Jobs{"migrate": manual}})) + // profile-disabled scheduled jobs don't block up + assert.NilError(t, rejectScheduledJobs(&types.Project{DisabledJobs: types.Jobs{"backup": scheduled}})) + + err := rejectScheduledJobs(&types.Project{Jobs: types.Jobs{"backup": scheduled, "sync": scheduled, "migrate": manual}}) + assert.Error(t, err, "scheduled jobs are not supported in this version: backup, sync") +} From 329b2a3759758c124173498900e29b247cfc2464 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Wed, 19 Aug 2026 15:54:52 +0200 Subject: [PATCH 03/16] run: execute jobs exactly like services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `docker compose run ` materializes the job — a ContainerSpec + WorkloadSpec, the same layers a service is made of — as a service for the one-off machinery: its profile is activated, the project narrowed to its dependencies by WithSelectedJob, and the exit code flows back. The materialization happens during project loading, BEFORE service selection and environment resolution: the job's own env_file and label_file resolve exactly like a selected service's would, unrelated services' env_file still don't need to exist, and a target that is neither a service nor a job keeps the precise selection error (the retry is keyed on "the target is a declared job", never on matching an error message). A service and a job may share a name: the service wins, matching what the selector resolved. A job may depend on other jobs: the whole job closure materializes, so every depends_on edge resolves to a runnable service satisfying its declared condition — a dependency job runs to completion through the exact machinery a service dependency does — instead of dangling as an unresolvable name. Job extensions (x-*) survive materialization. Co-Authored-By: Claude Fable 5 Signed-off-by: Nicolas De Loof --- cmd/compose/compose.go | 10 ---- cmd/compose/run.go | 119 +++++++++++++++++++++++++++++++++++++- cmd/compose/run_test.go | 124 ++++++++++++++++++++++++++++++++++++++++ cmd/compose/up.go | 16 ++++++ cmd/compose/up_test.go | 19 ++++++ 5 files changed, 276 insertions(+), 12 deletions(-) create mode 100644 cmd/compose/run_test.go diff --git a/cmd/compose/compose.go b/cmd/compose/compose.go index ef8062f149e..14757f8dd5d 100644 --- a/cmd/compose/compose.go +++ b/cmd/compose/compose.go @@ -25,7 +25,6 @@ import ( "os" "os/signal" "path/filepath" - "sort" "strconv" "strings" "syscall" @@ -369,15 +368,6 @@ func (o *ProjectOptions) ToProject( return nil, metrics, err } - if jobs := project.AllJobs(); len(jobs) > 0 { - names := make([]string, 0, len(jobs)) - for name := range jobs { - names = append(names, name) - } - sort.Strings(names) - logrus.Warnf("jobs are not supported yet and will be ignored: %s", strings.Join(names, ", ")) - } - return project, metrics, nil } diff --git a/cmd/compose/run.go b/cmd/compose/run.go index b88f66cf582..33321aaa4f9 100644 --- a/cmd/compose/run.go +++ b/cmd/compose/run.go @@ -19,6 +19,7 @@ package compose import ( "context" "errors" + "fmt" "os" "strings" @@ -71,6 +72,11 @@ type runOptions struct { } func (options runOptions) apply(project *types.Project) (*types.Project, error) { + project, err := materializeManualJob(project, options.Service) + if err != nil { + return nil, err + } + if options.noDeps { var err error project, err = project.WithSelectedServices([]string{options.Service}, types.IgnoreDependencies) @@ -83,7 +89,6 @@ func (options runOptions) apply(project *types.Project) (*types.Project, error) if err != nil { return nil, err } - target.Tty = !options.noTty target.StdinOpen = options.interactive @@ -273,7 +278,31 @@ func normalizeRunFlags(f *pflag.FlagSet, name string) pflag.NormalizedName { func runProject(ctx context.Context, dockerCli command.Cli, backend api.Compose, p *ProjectOptions, service string) (*types.Project, error) { project, _, err := p.ToProject(ctx, dockerCli, backend, []string{service}, warnUnsupportedAttributes, composecli.WithoutEnvironmentResolution) if err != nil { - return nil, err + // The run target may be a job — invisible to the service selector. + // Only retry unselected for that specific selection failure: any + // other load error (a bad include:, an interpolation error, ...) + // would only be duplicated by a second full load — remote include: + // fetches and unsupported-attribute warnings included — before + // falling through to the same, still correct, error anyway. + if !isNoSuchServiceErr(err) { + return nil, err + } + // Reload unselected, materialize the job as a service, and narrow to + // it, so the env resolution below sees the job like any selected + // service (its env_file resolves; unrelated services' env_file still + // doesn't need to exist). A target that is not a declared job keeps + // the original, precise selection error. + unselected, _, uerr := p.ToProject(ctx, dockerCli, backend, nil, warnUnsupportedAttributes, composecli.WithoutEnvironmentResolution) + if uerr != nil { + return nil, err + } + if _, isJob := unselected.AllJobs()[service]; !isJob { + return nil, err + } + project, err = materializeManualJob(unselected, service) + if err != nil { + return nil, err + } } project, err = project.WithServicesEnvironmentResolved(true) if err != nil { @@ -284,6 +313,13 @@ func runProject(ctx context.Context, dockerCli command.Cli, backend api.Compose, return project, nil } +// isNoSuchServiceErr reports whether err is compose-go's selection failure +// for a service/job name that isn't part of the (possibly narrowed) +// project — as opposed to any other project-load error. +func isNoSuchServiceErr(err error) bool { + return strings.Contains(err.Error(), "no such service") +} + 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) if err != nil { @@ -366,3 +402,82 @@ func runRun(ctx context.Context, backend api.Compose, project *types.Project, op } return err } + +// materializeManualJob lets run target a manual-trigger job exactly like a +// service. A job is a ContainerSpec+WorkloadSpec — the same layers a service +// is made of — so it materializes as a service for the one-off machinery: +// its profile is activated and the project narrowed to its dependencies by +// WithSelectedJob, then the job joins Services under its own name. +func materializeManualJob(project *types.Project, name string) (*types.Project, error) { + // jobs and services share the depends_on namespace but not their own: a + // service with the target's name wins — it is what the service selector + // resolved — and a job already materialized must not be re-materialized + // (it would shed whatever resolution ran on it since). + if _, exists := project.Services[name]; exists { + return project, nil + } + job, ok := project.AllJobs()[name] + if !ok { + return project, nil + } + if job.Triggers == nil || job.Triggers.Manual == nil || !*job.Triggers.Manual { + return nil, fmt.Errorf("job %q has no manual trigger, it cannot be run", name) + } + project, err := project.WithSelectedJob(name) + if err != nil { + return nil, err + } + // A job may depend on other jobs: materialize the whole job closure so + // every depends_on reference resolves to a service — the dependency job + // runs through the exact machinery a service dependency does (a + // run-to-completion container satisfying its declared condition), + // instead of dangling as an unresolvable name. + jobs := project.AllJobs() + materializeJobClosure(project, jobs, job, map[string]bool{name: true}) + project.Services[name] = jobAsService(project, name, job) + return project, nil +} + +// materializeJobClosure adds every job reachable through job-typed +// depends_on edges to project.Services. seen carries the starting job and +// guards against dependency cycles. +func materializeJobClosure(project *types.Project, jobs types.Jobs, job types.JobConfig, seen map[string]bool) { + for dep := range job.DependsOn { + if seen[dep] { + continue + } + seen[dep] = true + depJob, isJob := jobs[dep] + if !isJob { + continue + } + materializeJobClosure(project, jobs, depJob, seen) + project.Services[dep] = jobAsService(project, dep, depJob) + } +} + +// jobAsService materializes a job as a service for the one-off machinery: a +// job is a ContainerSpec+WorkloadSpec, the same layers a service is made of. +// It carries the standard custom labels the loader stamps on every service — +// materialization happens after loading, so without them the containers +// created for a dependency job would be invisible to every label-driven +// path: start would silently skip them, ps/down would not see them, and the +// dependency wait would report the job as a missing dependency. +func jobAsService(project *types.Project, name string, job types.JobConfig) types.ServiceConfig { + svc := types.ServiceConfig{ + Name: name, + Profiles: job.Profiles, + Extensions: job.Extensions, + ContainerSpec: job.ContainerSpec, + WorkloadSpec: job.WorkloadSpec, + } + svc.CustomLabels = types.Labels{ + api.ProjectLabel: project.Name, + api.ServiceLabel: name, + api.VersionLabel: api.ComposeVersion, + api.WorkingDirLabel: project.WorkingDir, + api.ConfigFilesLabel: strings.Join(project.ComposeFiles, ","), + api.OneoffLabel: "False", + } + return svc +} diff --git a/cmd/compose/run_test.go b/cmd/compose/run_test.go new file mode 100644 index 00000000000..6fe87abd414 --- /dev/null +++ b/cmd/compose/run_test.go @@ -0,0 +1,124 @@ +/* + 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 ( + "errors" + "testing" + + "github.com/compose-spec/compose-go/v2/types" + "gotest.tools/v3/assert" +) + +func TestMaterializeManualJob(t *testing.T) { + yes := true + base := func() *types.Project { + return &types.Project{ + Services: types.Services{ + "db": {Name: "db", ContainerSpec: types.ContainerSpec{Image: "postgres"}}, + }, + Jobs: types.Jobs{ + "migrate": { + Name: "migrate", + Triggers: &types.TriggerConfig{Manual: &yes}, + ContainerSpec: types.ContainerSpec{Image: "migrator", Command: types.ShellCommand{"migrate"}}, + WorkloadSpec: types.WorkloadSpec{DependsOn: types.DependsOnConfig{"db": {Condition: types.ServiceConditionStarted, Required: true}}}, + }, + "backup": { + Name: "backup", + Triggers: &types.TriggerConfig{Schedule: []types.ScheduleConfig{{Cron: "0 3 * * *"}}}, + }, + "prep": { + Name: "prep", + Triggers: &types.TriggerConfig{Manual: &yes}, + ContainerSpec: types.ContainerSpec{Image: "prep-tool"}, + WorkloadSpec: types.WorkloadSpec{DependsOn: types.DependsOnConfig{"db": {Condition: types.ServiceConditionStarted, Required: true}}}, + }, + "deploy": { + Name: "deploy", + Triggers: &types.TriggerConfig{Manual: &yes}, + Extensions: types.Extensions{"x-team": "platform"}, + ContainerSpec: types.ContainerSpec{Image: "deployer"}, + WorkloadSpec: types.WorkloadSpec{DependsOn: types.DependsOnConfig{"prep": {Condition: types.ServiceConditionCompletedSuccessfully, Required: true}}}, + }, + }, + } + } + + t.Run("a service name passes through", func(t *testing.T) { + p := base() + got, err := materializeManualJob(p, "db") + assert.NilError(t, err) + assert.Equal(t, got, p) + }) + + t.Run("a manual job materializes as a service with its spec and deps", func(t *testing.T) { + got, err := materializeManualJob(base(), "migrate") + assert.NilError(t, err) + svc, err := got.GetService("migrate") + assert.NilError(t, err) + assert.Equal(t, svc.Image, "migrator") + assert.DeepEqual(t, []string(svc.Command), []string{"migrate"}) + _, hasDep := svc.DependsOn["db"] + assert.Check(t, hasDep) + // the dependency is kept in the narrowed project + _, err = got.GetService("db") + assert.NilError(t, err) + }) + + t.Run("a job dependency on another job materializes the whole closure", func(t *testing.T) { + got, err := materializeManualJob(base(), "deploy") + assert.NilError(t, err) + // deploy -> job prep -> service db: prep becomes a runnable service + // so the depends_on edge resolves, and db is kept for prep + prep, err := got.GetService("prep") + assert.NilError(t, err) + assert.Equal(t, prep.Image, "prep-tool") + _, err = got.GetService("db") + assert.NilError(t, err) + deploy, err := got.GetService("deploy") + assert.NilError(t, err) + assert.Equal(t, deploy.Extensions["x-team"], "platform", "job extensions survive materialization") + }) + + t.Run("an existing service with the target name wins over a job", func(t *testing.T) { + p := base() + p.Services["migrate"] = types.ServiceConfig{Name: "migrate", ContainerSpec: types.ContainerSpec{Image: "the-service"}} + got, err := materializeManualJob(p, "migrate") + assert.NilError(t, err) + svc, err := got.GetService("migrate") + assert.NilError(t, err) + assert.Equal(t, svc.Image, "the-service", "a same-named service must not be shadowed by the job") + }) + + t.Run("a schedule-only job is rejected", func(t *testing.T) { + _, err := materializeManualJob(base(), "backup") + assert.Error(t, err, `job "backup" has no manual trigger, it cannot be run`) + }) +} + +// runProject only retries the unselected/full load to look for a job when +// the narrowed load failed specifically because the target isn't a known +// service — any other load error (a bad include:, an interpolation +// error, ...) must not trigger that retry, since it would only duplicate +// side effects (remote include: fetches, unsupported-attribute warnings) +// before falling through to the same, unrecoverable error anyway. +func TestIsNoSuchServiceErr(t *testing.T) { + assert.Assert(t, isNoSuchServiceErr(errors.New("no such service: migrate"))) + assert.Assert(t, !isNoSuchServiceErr(errors.New("interpolation error: bad substitution"))) + assert.Assert(t, !isNoSuchServiceErr(errors.New("include: remote resource fetch failed"))) +} diff --git a/cmd/compose/up.go b/cmd/compose/up.go index 5295032fd58..3716b5c8be2 100644 --- a/cmd/compose/up.go +++ b/cmd/compose/up.go @@ -245,6 +245,7 @@ func runUp( if err := rejectScheduledJobs(project); err != nil { return err } + warnIgnoredJobs(project) err := createOptions.Apply(project) if err != nil { @@ -355,6 +356,21 @@ func runUp( }) } +// warnIgnoredJobs names the declared jobs up will not act on: manual jobs +// wait for an explicit `compose run ` trigger. +func warnIgnoredJobs(project *types.Project) { + jobs := project.Jobs + if len(jobs) == 0 { + return + } + names := make([]string, 0, len(jobs)) + for name := range jobs { + names = append(names, name) + } + sort.Strings(names) + logrus.Warnf("jobs are not started by up; trigger them with `docker compose run`: %s", strings.Join(names, ", ")) +} + // rejectScheduledJobs refuses to bring a project up when it declares active // scheduled jobs: silently not scheduling them would break the user's // expectations, unlike manual jobs which simply wait for an explicit trigger. diff --git a/cmd/compose/up_test.go b/cmd/compose/up_test.go index e3f28bb919d..98ac8949db9 100644 --- a/cmd/compose/up_test.go +++ b/cmd/compose/up_test.go @@ -29,6 +29,7 @@ import ( "github.com/compose-spec/compose-go/v2/loader" "github.com/compose-spec/compose-go/v2/types" "github.com/docker/cli/cli/streams" + logrustest "github.com/sirupsen/logrus/hooks/test" "go.uber.org/mock/gomock" "gotest.tools/v3/assert" @@ -194,3 +195,21 @@ func TestRejectScheduledJobs(t *testing.T) { err := rejectScheduledJobs(&types.Project{Jobs: types.Jobs{"backup": scheduled, "sync": scheduled, "migrate": manual}}) assert.Error(t, err, "scheduled jobs are not supported in this version: backup, sync") } + +// warnIgnoredJobs must only name profile-enabled jobs: a job disabled by +// profile selection isn't part of this invocation and up never reaching it +// isn't worth a warning — unlike rejectScheduledJobs, which already gets +// this right via project.Jobs. +func TestWarnIgnoredJobs(t *testing.T) { + hook := logrustest.NewGlobal() + + warnIgnoredJobs(&types.Project{ + Jobs: types.Jobs{"migrate": {}}, + DisabledJobs: types.Jobs{"backup": {}}, + }) + + assert.Equal(t, len(hook.AllEntries()), 1) + msg := hook.LastEntry().Message + assert.Assert(t, strings.Contains(msg, "migrate"), msg) + assert.Assert(t, !strings.Contains(msg, "backup"), msg) +} From 3dc87c85470f27592617712156762f84bc4d578f Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Wed, 19 Aug 2026 16:56:05 +0200 Subject: [PATCH 04/16] pre_start: inherit the whole container specification generically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pre_start hooks carry the full container specification, but the init-container runner only consumed a handful of attributes (image, command, user, env, workdir) — everything else was silently dropped. Instead of wiring attributes one by one, the hook's specification is merged over the service's ContainerSpec through compose-go's own file merge machinery (override.Merge on the canonical yaml tree): command and entrypoint replace, environment merges per key with the hook winning, extra_hosts and dns accumulate entries, ulimits merge — the exact per-attribute rules of multi-file compose, maintained in one place. Every ContainerSpec attribute inherits this way, current and future, with zero attribute-specific code. The merged spec then runs through the standard create path (getCreateConfigs) as a service-shaped one-off, so resources, capabilities, dns, sysctls, logging... materialize exactly as they would for a service container. Hook containers keep their minimal labels and carry no container-number, so tooling telling replicas apart does not count them. The only deliberate exception is volumes: mounts inherit at runtime through volumes_from — the only mechanism that shares the service's anonymous and image volumes — and the hook's own volume declarations, materialized by the standard path, take precedence per target. This is what lets an init container get read-write access to a volume the service mounts read-only (fixes: see PR). e2e scenarios lock extra_hosts inheritance and accumulation, volume override and completion, and the unit tests pin the merge rules. Signed-off-by: Nicolas De Loof --- pkg/compose/hook_spec.go | 89 +++++++++++++++++++ pkg/compose/hook_spec_test.go | 86 ++++++++++++++++++ pkg/compose/pre_start.go | 68 ++++++++------ pkg/compose/pre_start_test.go | 6 ++ pkg/e2e/hooks_test.go | 35 ++++++++ .../TestPreStartHookExtraHosts/compose.yaml | 21 +++++ .../TestPreStartHookVolumes/compose.yaml | 15 ++++ .../TestPreStartHookVolumesMerge/compose.yaml | 17 ++++ 8 files changed, 308 insertions(+), 29 deletions(-) create mode 100644 pkg/compose/hook_spec.go create mode 100644 pkg/compose/hook_spec_test.go create mode 100644 pkg/e2e/testdata/TestPreStartHookExtraHosts/compose.yaml create mode 100644 pkg/e2e/testdata/TestPreStartHookVolumes/compose.yaml create mode 100644 pkg/e2e/testdata/TestPreStartHookVolumesMerge/compose.yaml diff --git a/pkg/compose/hook_spec.go b/pkg/compose/hook_spec.go new file mode 100644 index 00000000000..41017947f16 --- /dev/null +++ b/pkg/compose/hook_spec.go @@ -0,0 +1,89 @@ +/* + 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 ( + "errors" + "fmt" + + "github.com/compose-spec/compose-go/v2/loader" + "github.com/compose-spec/compose-go/v2/override" + "github.com/compose-spec/compose-go/v2/types" + yaml "go.yaml.in/yaml/v4" +) + +// mergedPreStartSpec merges the hook's container specification over the +// service's, following the compose file merge rules: command and entrypoint +// replace, environment merges per key with the hook winning, extra_hosts and +// dns accumulate entries, ulimits merge, ... Every ContainerSpec attribute +// inherits this way, current and future, without attribute-specific code. +// +// Volumes are deliberately dropped from the inherited side: mounts inherit +// at runtime through volumes_from, the only mechanism that shares the +// service's anonymous and image volumes; the merged spec carries the hook's +// own volume declarations only, which take precedence per target. +func mergedPreStartSpec(service types.ServiceConfig, hook types.PreStartHook) (types.ContainerSpec, error) { + base, err := containerSpecDict(service.ContainerSpec) + if err != nil { + return types.ContainerSpec{}, err + } + delete(base, "volumes") + over, err := containerSpecDict(hook.ContainerSpec) + if err != nil { + return types.ContainerSpec{}, err + } + + merged, err := override.Merge( + map[string]any{"services": map[string]any{"hook": base}}, + map[string]any{"services": map[string]any{"hook": over}}, + ) + if err != nil { + return types.ContainerSpec{}, fmt.Errorf("merging pre_start hook specification: %w", err) + } + dict, ok := merged["services"].(map[string]any)["hook"].(map[string]any) + if !ok { + return types.ContainerSpec{}, errors.New("internal: unexpected merged hook specification shape") + } + + var spec types.ContainerSpec + if err := loader.Transform(dict, &spec); err != nil { + return types.ContainerSpec{}, fmt.Errorf("decoding merged pre_start hook specification: %w", err) + } + return spec, nil +} + +// containerSpecDict serializes a ContainerSpec to the canonical yaml tree the +// compose merge rules operate on — the same serialization compose config +// uses. +func containerSpecDict(spec types.ContainerSpec) (map[string]any, error) { + p := &types.Project{Services: types.Services{"hook": {ContainerSpec: spec}}} + raw, err := p.MarshalYAML() + if err != nil { + return nil, err + } + var dict map[string]any + if err := yaml.Unmarshal(raw, &dict); err != nil { + return nil, err + } + svc, ok := dict["services"].(map[string]any)["hook"].(map[string]any) + if !ok { + // a zero spec marshals to a null service entry + return map[string]any{}, nil + } + delete(svc, "name") + return svc, nil +} diff --git a/pkg/compose/hook_spec_test.go b/pkg/compose/hook_spec_test.go new file mode 100644 index 00000000000..2a116a96200 --- /dev/null +++ b/pkg/compose/hook_spec_test.go @@ -0,0 +1,86 @@ +/* + 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" + is "gotest.tools/v3/assert/cmp" +) + +func strPtr(s string) *string { return &s } + +func TestMergedPreStartSpec(t *testing.T) { + service := types.ServiceConfig{ + Name: "db", + ContainerSpec: types.ContainerSpec{ + Image: "postgres:16", + User: "999", + WorkingDir: "/srv", + Command: types.ShellCommand{"postgres"}, + Environment: types.MappingWithEquals{"PGDATA": strPtr("/data"), "SHARED": strPtr("service")}, + ExtraHosts: types.HostsList{"inherited": {"192.0.2.10"}, "shared": {"192.0.2.11"}}, + DNS: types.StringList{"1.1.1.1"}, + CapAdd: []string{"NET_ADMIN"}, + Volumes: []types.ServiceVolumeConfig{{Type: types.VolumeTypeVolume, Source: "data", Target: "/data", ReadOnly: true}}, + }, + } + hook := types.PreStartHook{ContainerSpec: types.ContainerSpec{ + Command: types.ShellCommand{"init.sh"}, + Environment: types.MappingWithEquals{"SHARED": strPtr("hook"), "ONLY": strPtr("hook")}, + ExtraHosts: types.HostsList{"declared": {"192.0.2.20"}, "shared": {"192.0.2.99"}}, + DNS: types.StringList{"8.8.8.8"}, + Volumes: []types.ServiceVolumeConfig{{Type: types.VolumeTypeVolume, Source: "data", Target: "/data"}}, + }} + + spec, err := mergedPreStartSpec(service, hook) + assert.NilError(t, err) + + // scalars inherit when undeclared + assert.Check(t, is.Equal(spec.Image, "postgres:16")) + assert.Check(t, is.Equal(spec.User, "999")) + assert.Check(t, is.Equal(spec.WorkingDir, "/srv")) + // command replaces + assert.Check(t, is.DeepEqual([]string(spec.Command), []string{"init.sh"})) + // collections merge per the compose file rules: environment per key + // (hook wins), extra_hosts accumulate entries per hostname + assert.Check(t, is.Equal(*spec.Environment["PGDATA"], "/data")) + assert.Check(t, is.Equal(*spec.Environment["SHARED"], "hook")) + assert.Check(t, is.Equal(*spec.Environment["ONLY"], "hook")) + assert.Check(t, is.DeepEqual(spec.ExtraHosts["inherited"], []string{"192.0.2.10"})) + assert.Check(t, is.DeepEqual(spec.ExtraHosts["declared"], []string{"192.0.2.20"})) + assert.Check(t, is.DeepEqual(spec.ExtraHosts["shared"], []string{"192.0.2.11", "192.0.2.99"})) + assert.Check(t, is.DeepEqual([]string(spec.DNS), []string{"1.1.1.1", "8.8.8.8"})) + // previously-unwired attributes inherit too, with zero dedicated code + assert.Check(t, is.DeepEqual(spec.CapAdd, []string{"NET_ADMIN"})) + // volumes are NOT inherited at the model level: runtime volumes_from does + assert.Assert(t, is.Len(spec.Volumes, 1)) + assert.Check(t, !spec.Volumes[0].ReadOnly, "hook keeps its own rw declaration") +} + +func TestMergedPreStartSpecEmptyHook(t *testing.T) { + service := types.ServiceConfig{ContainerSpec: types.ContainerSpec{ + Image: "alpine", Sysctls: types.Mapping{"net.core.somaxconn": "1024"}, + }} + spec, err := mergedPreStartSpec(service, types.PreStartHook{}) + assert.NilError(t, err) + assert.Check(t, is.Equal(spec.Image, "alpine")) + assert.Check(t, is.Equal(spec.Sysctls["net.core.somaxconn"], "1024")) + assert.Check(t, is.Len(spec.Volumes, 0)) +} diff --git a/pkg/compose/pre_start.go b/pkg/compose/pre_start.go index 1d350f6059a..77cdec2ca72 100644 --- a/pkg/compose/pre_start.go +++ b/pkg/compose/pre_start.go @@ -166,47 +166,57 @@ func (s *composeService) createPreStartContainer( ctx context.Context, project *types.Project, service types.ServiceConfig, ctr container.Summary, hook types.PreStartHook, ) (client.ContainerCreateResult, error) { - image := hook.Image - if image == "" { - image = api.GetImageNameOrDefault(service, project.Name) + // A pre_start hook is a full container specification (compose-spec#656) + // inheriting from the service per the compose file merge rules; the + // merged spec runs through the standard create path, so every attribute + // — resources, capabilities, dns, sysctls, ... — materializes exactly as + // it would for a service container. + spec, err := mergedPreStartSpec(service, hook) + if err != nil { + return client.ContainerCreateResult{}, err } - - cfg := &container.Config{ - Image: image, - Cmd: hook.Command, - User: hook.User, - WorkingDir: hook.WorkingDir, - Env: append(ToMobyEnv(service.Environment), ToMobyEnv(hook.Environment)...), - // Tag the ephemeral hook container with the project/service it belongs - // to so it can be found by `compose down` and label-scoped tooling. - // HookLabel also distinguishes hook containers from the real service - // container (which shares ProjectLabel and ServiceLabel). - Labels: map[string]string{ + if spec.Image == "" { + spec.Image = api.GetImageNameOrDefault(service, project.Name) + } + hookService := types.ServiceConfig{ + Name: service.Name, + ContainerSpec: spec, + } + cfgs, err := s.getCreateConfigs(ctx, project, hookService, 0, nil, createOptions{ + // AutoRemove is intentionally false: a failed hook container is + // retained so the operator can inspect its logs. runPreStartHook + // removes it explicitly on success, and the orphan sweep catches + // leftovers on the next run. + AutoRemove: false, + UseNetworkAliases: true, + // Tag the ephemeral hook container with the project/service it + // belongs to so `compose down` and label-scoped tooling can find it. + // HookLabel distinguishes hook containers from the real service + // container; no container-number: tooling telling replicas apart must + // not count hook containers. + Labels: types.Labels{ api.ProjectLabel: project.Name, api.ServiceLabel: service.Name, api.VersionLabel: api.ComposeVersion, api.HookLabel: preStartHookType, }, - } - hostCfg := &container.HostConfig{ - // AutoRemove is intentionally false: a failed hook container is retained - // so the operator can inspect its logs. On success runPreStartHook - // removes the container explicitly (see the success path below). - AutoRemove: false, - Privileged: hook.Privileged, - VolumesFrom: []string{ctr.ID}, - } - - apiVersion, err := s.RuntimeAPIVersion(ctx) + }) if err != nil { return client.ContainerCreateResult{}, err } + // Mounts inherit from the live service container: volumes_from carries + // its anonymous and image volumes too. The hook's own mounts, already in + // the host config from the merged spec, take precedence on shared + // targets. + cfgs.Host.VolumesFrom = append(cfgs.Host.VolumesFrom, ctr.ID) + cfg := cfgs.Container + hostCfg := cfgs.Host + networkingConfig := cfgs.Network - networkMode, networkingConfig, err := defaultNetworkSettings(project, service, 0, nil, true, apiVersion) + apiVersion, err := s.RuntimeAPIVersion(ctx) if err != nil { return client.ContainerCreateResult{}, err } - hostCfg.NetworkMode = networkMode created, err := s.apiClient().ContainerCreate(ctx, client.ContainerCreateOptions{ Config: cfg, @@ -218,7 +228,7 @@ func (s *composeService) createPreStartContainer( } if versions.LessThan(apiVersion, apiVersion144) { - if err := s.connectPreStartExtraNetworks(ctx, project, service, created.ID, networkMode); err != nil { + if err := s.connectPreStartExtraNetworks(ctx, project, service, created.ID, hostCfg.NetworkMode); err != nil { // AutoRemove is false; remove the container explicitly since it was // never started. Log failures so the orphan is at least visible. if _, removeErr := s.apiClient().ContainerRemove(ctx, created.ID, client.ContainerRemoveOptions{Force: true, RemoveVolumes: true}); removeErr != nil { diff --git a/pkg/compose/pre_start_test.go b/pkg/compose/pre_start_test.go index 1f803f0360c..b744db9c94e 100644 --- a/pkg/compose/pre_start_test.go +++ b/pkg/compose/pre_start_test.go @@ -25,6 +25,7 @@ import ( "testing" "github.com/compose-spec/compose-go/v2/types" + "github.com/docker/cli/cli/config/configfile" "github.com/moby/moby/api/types/container" "github.com/moby/moby/client" "go.uber.org/goleak" @@ -50,9 +51,11 @@ func newPreStartTestServiceWithVersion(t *testing.T, apiVersion string) (*compos apiClient := mocks.NewMockAPIClient(mockCtrl) cli := mocks.NewMockCli(mockCtrl) cli.EXPECT().Client().Return(apiClient).AnyTimes() + cli.EXPECT().ConfigFile().Return(&configfile.ConfigFile{}).AnyTimes() apiClient.EXPECT().Ping(gomock.Any(), client.PingOptions{NegotiateAPIVersion: true}). Return(client.PingResult{APIVersion: apiVersion}, nil).AnyTimes() apiClient.EXPECT().ClientVersion().Return(apiVersion).AnyTimes() + apiClient.EXPECT().DaemonHost().Return("unix:///var/run/docker.sock").AnyTimes() tested, err := NewComposeService(cli) assert.NilError(t, err) return tested.(*composeService), apiClient @@ -1129,6 +1132,9 @@ func TestPreStart_RuntimeAPIVersionError(t *testing.T) { apiClient := mocks.NewMockAPIClient(mockCtrl) cli := mocks.NewMockCli(mockCtrl) cli.EXPECT().Client().Return(apiClient).AnyTimes() + // the merged-spec create path reads the CLI config and daemon host + cli.EXPECT().ConfigFile().Return(&configfile.ConfigFile{}).AnyTimes() + apiClient.EXPECT().DaemonHost().Return("unix:///var/run/docker.sock").AnyTimes() tested, err := NewComposeService(cli) assert.NilError(t, err) s := tested.(*composeService) diff --git a/pkg/e2e/hooks_test.go b/pkg/e2e/hooks_test.go index e7db829f33a..77063f505fc 100644 --- a/pkg/e2e/hooks_test.go +++ b/pkg/e2e/hooks_test.go @@ -209,3 +209,38 @@ func TestPreStartHookRunsOnceForScaledService(t *testing.T) { probeVolume(s, "wc", "-l", "/mnt/log"), OutputContains("1 /mnt/log")) } + +// A pre_start hook is a full container specification inheriting in the +// spirit of the yaml merge rules: collections complete the inherited value, +// the hook's declarations winning on conflicts (docker/compose#13939). +func TestPreStartHookExtraHosts(t *testing.T) { + NewScenario(t, "pre_start hooks must inherit the service's extra_hosts, completed by their own declarations"). + Step("up succeeds: both hooks asserted their /etc/hosts content", + ComposeCmd("up", "-d", "--wait").Within(60*time.Second), + ServiceState("app", "running")) +} + +// The init-container use case of docker/compose#13934: the hook redeclares a +// service volume read-write to prepare content the service mounts read-only. +func TestPreStartHookVolumes(t *testing.T) { + NewScenario(t, "a pre_start hook must write through its own read-write volume declaration while the service stays read-only"). + Step("up succeeds: the hook wrote through its rw mount", + ComposeCmd("up", "-d", "--wait").Within(60*time.Second)). + Step("the service reads what the hook prepared", + ComposeCmd("exec", "app", "cat", "/data/init.txt"), + OutputContains("initialized")). + Step("the service's own mount is still read-only", + ComposeCmd("exec", "app", "touch", "/data/blocked").MayFail(), + OutputContains("Read-only file system")) +} + +// Volumes merge by target: the hook keeps every service mount it did not +// redeclare (docker/compose#13934). +func TestPreStartHookVolumesMerge(t *testing.T) { + NewScenario(t, "a pre_start hook declaring a volume must still see the service's other mounts"). + Step("up succeeds: the hook read the inherited mount and wrote through its own", + ComposeCmd("up", "-d", "--wait").Within(60*time.Second)). + Step("the hook combined the inherited mount's content with its own write", + ComposeCmd("exec", "app", "cat", "/data/init.txt"), + OutputContains("saw-config")) +} diff --git a/pkg/e2e/testdata/TestPreStartHookExtraHosts/compose.yaml b/pkg/e2e/testdata/TestPreStartHookExtraHosts/compose.yaml new file mode 100644 index 00000000000..ef78d6cdb12 --- /dev/null +++ b/pkg/e2e/testdata/TestPreStartHookExtraHosts/compose.yaml @@ -0,0 +1,21 @@ +services: + app: + image: alpine + init: true + command: sleep infinity + extra_hosts: + - "inherited-host:192.0.2.10" + - "shared-host:192.0.2.11" + pre_start: + # no extra_hosts declared: the service's entries are inherited + - command: sh -c 'grep inherited-host /etc/hosts' + # declared extra_hosts complete the inherited ones; entries for the + # same hostname accumulate, exactly like the compose file merge rules + - command: > + sh -c 'grep declared-host /etc/hosts && + grep inherited-host /etc/hosts && + grep "192.0.2.99.*shared-host" /etc/hosts && + grep "192.0.2.11.*shared-host" /etc/hosts' + extra_hosts: + - "declared-host:192.0.2.20" + - "shared-host:192.0.2.99" diff --git a/pkg/e2e/testdata/TestPreStartHookVolumes/compose.yaml b/pkg/e2e/testdata/TestPreStartHookVolumes/compose.yaml new file mode 100644 index 00000000000..26b0fd0370f --- /dev/null +++ b/pkg/e2e/testdata/TestPreStartHookVolumes/compose.yaml @@ -0,0 +1,15 @@ +services: + app: + image: alpine + init: true + command: sleep infinity + volumes: + - data:/data:ro + pre_start: + # the hook redeclares the volume read-write to prepare content the + # service will only ever read + - command: sh -c 'echo initialized > /data/init.txt' + volumes: + - data:/data:rw +volumes: + data: {} diff --git a/pkg/e2e/testdata/TestPreStartHookVolumesMerge/compose.yaml b/pkg/e2e/testdata/TestPreStartHookVolumesMerge/compose.yaml new file mode 100644 index 00000000000..2a48265b99c --- /dev/null +++ b/pkg/e2e/testdata/TestPreStartHookVolumesMerge/compose.yaml @@ -0,0 +1,17 @@ +services: + app: + image: alpine + init: true + command: sleep infinity + volumes: + - data:/data:ro + - config:/config:ro + pre_start: + # declares only /data read-write: /config must still be visible, + # inherited through volumes_from + - command: sh -c 'test -d /config && echo "saw-config" > /data/init.txt' + volumes: + - data:/data:rw +volumes: + data: {} + config: {} From 686193a951af7e8a49d792b0803a55c7441fde08 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Wed, 19 Aug 2026 17:09:41 +0200 Subject: [PATCH 05/16] =?UTF-8?q?e2e:=20jobs=20scenarios=20=E2=80=94=20sch?= =?UTF-8?q?eduled=20rejection,=20manual=20run,=20env=5Ffile,=20job=20chain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scenario-DSL coverage for the jobs entry points: - up refuses a project declaring an active scheduled job before creating any resource; - run executes a manual job like a service, starting its depends_on services first, and refuses a schedule-only job; - a job's own env_file feeds its environment through run — the materialization happens before environment resolution; - a job depending on another job runs the dependency to completion first, through the exact machinery a service dependency uses. Co-Authored-By: Claude Fable 5 Signed-off-by: Nicolas De Loof --- pkg/e2e/jobs_test.go | 67 +++++++++++++++++++ .../TestRunJobDependsOnJob/compose.yaml | 22 ++++++ .../testdata/TestRunJobEnvFile/compose.yaml | 7 ++ pkg/e2e/testdata/TestRunJobEnvFile/db.env | 1 + .../testdata/TestRunManualJob/compose.yaml | 20 ++++++ .../TestUpRejectsScheduledJobs/compose.yaml | 12 ++++ 6 files changed, 129 insertions(+) create mode 100644 pkg/e2e/jobs_test.go create mode 100644 pkg/e2e/testdata/TestRunJobDependsOnJob/compose.yaml create mode 100644 pkg/e2e/testdata/TestRunJobEnvFile/compose.yaml create mode 100644 pkg/e2e/testdata/TestRunJobEnvFile/db.env create mode 100644 pkg/e2e/testdata/TestRunManualJob/compose.yaml create mode 100644 pkg/e2e/testdata/TestUpRejectsScheduledJobs/compose.yaml diff --git a/pkg/e2e/jobs_test.go b/pkg/e2e/jobs_test.go new file mode 100644 index 00000000000..1b17264e688 --- /dev/null +++ b/pkg/e2e/jobs_test.go @@ -0,0 +1,67 @@ +//go:build e2e + +/* + 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 e2e + +import ( + "testing" +) + +// Scheduled jobs cannot run in this version: silently not scheduling them +// would break the user's expectations, so up must refuse the whole project. +func TestUpRejectsScheduledJobs(t *testing.T) { + NewScenario(t, "up must reject a project declaring active scheduled jobs, before creating anything"). + Step("up fails naming the scheduled job", + ComposeCmd("up", "-d").MayFail(), + StderrContains("scheduled jobs are not supported in this version: backup"), + ServiceNotCreated("web")) +} + +// A manual-trigger job runs through `compose run` exactly like a service +// would: its declared dependencies start first, its output and exit flow +// back. A schedule-only job stays out of reach. +func TestRunManualJob(t *testing.T) { + NewScenario(t, "run must execute a manual job like a service, starting its depends_on services first"). + Step("run executes the job after starting its dependency", + ComposeCmd("run", "--rm", "migrate"), + OutputContains("migration done"), + ServiceState("db", "running")). + Step("a schedule-only job cannot be run", + ComposeCmd("run", "--rm", "backup").MayFail(), + StderrContains(`job "backup" has no manual trigger`)) +} + +// A job's own env_file resolves exactly like a selected service's would: the +// materialization happens before environment resolution, not after. +func TestRunJobEnvFile(t *testing.T) { + NewScenario(t, "a job's env_file must feed its environment through run"). + Step("the job sees the env_file variable", + ComposeCmd("run", "--rm", "migrate"), + OutputContains("DB_URL=postgres://db:5432/app")) +} + +// A job may depend on another job: the dependency job runs to completion +// first — through the exact machinery a service dependency does — then the +// target runs. +func TestRunJobDependsOnJob(t *testing.T) { + NewScenario(t, "a job depending on a job must run the dependency to completion first"). + Step("the dependency job completes before the target runs", + ComposeCmd("run", "--rm", "deploy"), + OutputContains("deploy done"), + ServiceState("db", "running")) +} diff --git a/pkg/e2e/testdata/TestRunJobDependsOnJob/compose.yaml b/pkg/e2e/testdata/TestRunJobDependsOnJob/compose.yaml new file mode 100644 index 00000000000..35765a5f96d --- /dev/null +++ b/pkg/e2e/testdata/TestRunJobDependsOnJob/compose.yaml @@ -0,0 +1,22 @@ +services: + db: + image: alpine + init: true + command: sleep infinity +jobs: + prep: + image: alpine + command: sh -c 'echo "prep done"' + triggers: + manual: true + depends_on: + db: + condition: service_started + deploy: + image: alpine + command: sh -c 'echo "deploy done"' + triggers: + manual: true + depends_on: + prep: + condition: service_completed_successfully diff --git a/pkg/e2e/testdata/TestRunJobEnvFile/compose.yaml b/pkg/e2e/testdata/TestRunJobEnvFile/compose.yaml new file mode 100644 index 00000000000..3865e494546 --- /dev/null +++ b/pkg/e2e/testdata/TestRunJobEnvFile/compose.yaml @@ -0,0 +1,7 @@ +jobs: + migrate: + image: alpine + command: sh -c 'echo "DB_URL=$$DB_URL"' + env_file: db.env + triggers: + manual: true diff --git a/pkg/e2e/testdata/TestRunJobEnvFile/db.env b/pkg/e2e/testdata/TestRunJobEnvFile/db.env new file mode 100644 index 00000000000..d819a83fa7a --- /dev/null +++ b/pkg/e2e/testdata/TestRunJobEnvFile/db.env @@ -0,0 +1 @@ +DB_URL=postgres://db:5432/app diff --git a/pkg/e2e/testdata/TestRunManualJob/compose.yaml b/pkg/e2e/testdata/TestRunManualJob/compose.yaml new file mode 100644 index 00000000000..675477855e4 --- /dev/null +++ b/pkg/e2e/testdata/TestRunManualJob/compose.yaml @@ -0,0 +1,20 @@ +services: + db: + image: alpine + init: true + command: sleep infinity +jobs: + migrate: + image: alpine + command: sh -c 'echo "migration done"' + triggers: + manual: true + depends_on: + db: + condition: service_started + backup: + image: alpine + command: echo backup + triggers: + schedule: + - cron: "0 3 * * *" diff --git a/pkg/e2e/testdata/TestUpRejectsScheduledJobs/compose.yaml b/pkg/e2e/testdata/TestUpRejectsScheduledJobs/compose.yaml new file mode 100644 index 00000000000..5efd658bb6d --- /dev/null +++ b/pkg/e2e/testdata/TestUpRejectsScheduledJobs/compose.yaml @@ -0,0 +1,12 @@ +services: + web: + image: alpine + init: true + command: sleep infinity +jobs: + backup: + image: alpine + command: echo backup + triggers: + schedule: + - cron: "0 3 * * *" From 4bbc568d84c71c2fbfdd05cef3086566209df5cd Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Wed, 26 Aug 2026 15:40:01 +0200 Subject: [PATCH 06/16] run: honor the explicit manual: false opt-out on jobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the spec, any job can be triggered manually by an explicit run command, its automated triggers notwithstanding — so run now accepts scheduled jobs too. The exception is a job explicitly declared with triggers.manual: false, which run rejects: meant for scheduled jobs whose out-of-schedule execution would be harmful. compose-go is bumped to the jobs-branch commit making Manual tri-state (*bool) and allowing manual and schedule to be combined. Signed-off-by: Nicolas De Loof --- cmd/compose/run.go | 14 ++++++----- cmd/compose/run_test.go | 25 ++++++++++++++----- pkg/e2e/jobs_test.go | 18 +++++++------ .../testdata/TestRunManualJob/compose.yaml | 7 ++++++ 4 files changed, 45 insertions(+), 19 deletions(-) diff --git a/cmd/compose/run.go b/cmd/compose/run.go index 33321aaa4f9..3d89d2737bd 100644 --- a/cmd/compose/run.go +++ b/cmd/compose/run.go @@ -403,10 +403,12 @@ func runRun(ctx context.Context, backend api.Compose, project *types.Project, op return err } -// materializeManualJob lets run target a manual-trigger job exactly like a -// service. A job is a ContainerSpec+WorkloadSpec — the same layers a service -// is made of — so it materializes as a service for the one-off machinery: -// its profile is activated and the project narrowed to its dependencies by +// materializeManualJob lets run target a job exactly like a service: per the +// spec, any job can be triggered manually regardless of its automated +// triggers, unless it explicitly opts out with `triggers.manual: false`. +// A job is a ContainerSpec+WorkloadSpec — the same layers a service is made +// of — so it materializes as a service for the one-off machinery: its +// profile is activated and the project narrowed to its dependencies by // WithSelectedJob, then the job joins Services under its own name. func materializeManualJob(project *types.Project, name string) (*types.Project, error) { // jobs and services share the depends_on namespace but not their own: a @@ -420,8 +422,8 @@ func materializeManualJob(project *types.Project, name string) (*types.Project, if !ok { return project, nil } - if job.Triggers == nil || job.Triggers.Manual == nil || !*job.Triggers.Manual { - return nil, fmt.Errorf("job %q has no manual trigger, it cannot be run", name) + if job.Triggers != nil && job.Triggers.Manual != nil && !*job.Triggers.Manual { + return nil, fmt.Errorf("job %q is declared with manual: false, it cannot be run manually", name) } project, err := project.WithSelectedJob(name) if err != nil { diff --git a/cmd/compose/run_test.go b/cmd/compose/run_test.go index 6fe87abd414..120f95f192c 100644 --- a/cmd/compose/run_test.go +++ b/cmd/compose/run_test.go @@ -25,7 +25,7 @@ import ( ) func TestMaterializeManualJob(t *testing.T) { - yes := true + yes, no := true, false base := func() *types.Project { return &types.Project{ Services: types.Services{ @@ -39,8 +39,13 @@ func TestMaterializeManualJob(t *testing.T) { WorkloadSpec: types.WorkloadSpec{DependsOn: types.DependsOnConfig{"db": {Condition: types.ServiceConditionStarted, Required: true}}}, }, "backup": { - Name: "backup", - Triggers: &types.TriggerConfig{Schedule: []types.ScheduleConfig{{Cron: "0 3 * * *"}}}, + Name: "backup", + Triggers: &types.TriggerConfig{Schedule: []types.ScheduleConfig{{Cron: "0 3 * * *"}}}, + ContainerSpec: types.ContainerSpec{Image: "backup-tool"}, + }, + "rotation": { + Name: "rotation", + Triggers: &types.TriggerConfig{Manual: &no, Schedule: []types.ScheduleConfig{{Cron: "0 3 1 * *"}}}, }, "prep": { Name: "prep", @@ -105,9 +110,17 @@ func TestMaterializeManualJob(t *testing.T) { assert.Equal(t, svc.Image, "the-service", "a same-named service must not be shadowed by the job") }) - t.Run("a schedule-only job is rejected", func(t *testing.T) { - _, err := materializeManualJob(base(), "backup") - assert.Error(t, err, `job "backup" has no manual trigger, it cannot be run`) + t.Run("a scheduled job without explicit manual opt-out can be run", func(t *testing.T) { + got, err := materializeManualJob(base(), "backup") + assert.NilError(t, err) + svc, err := got.GetService("backup") + assert.NilError(t, err) + assert.Equal(t, svc.Image, "backup-tool") + }) + + t.Run("manual: false explicitly forbids manual execution", func(t *testing.T) { + _, err := materializeManualJob(base(), "rotation") + assert.Error(t, err, `job "rotation" is declared with manual: false, it cannot be run manually`) }) } diff --git a/pkg/e2e/jobs_test.go b/pkg/e2e/jobs_test.go index 1b17264e688..a82e5b5d85c 100644 --- a/pkg/e2e/jobs_test.go +++ b/pkg/e2e/jobs_test.go @@ -32,18 +32,22 @@ func TestUpRejectsScheduledJobs(t *testing.T) { ServiceNotCreated("web")) } -// A manual-trigger job runs through `compose run` exactly like a service -// would: its declared dependencies start first, its output and exit flow -// back. A schedule-only job stays out of reach. +// A job runs through `compose run` exactly like a service would: its +// declared dependencies start first, its output and exit flow back. Per the +// spec, manual execution is always available — scheduled jobs included — +// unless the job explicitly opts out with `triggers.manual: false`. func TestRunManualJob(t *testing.T) { - NewScenario(t, "run must execute a manual job like a service, starting its depends_on services first"). + NewScenario(t, "run must execute a job like a service, starting its depends_on services first"). Step("run executes the job after starting its dependency", ComposeCmd("run", "--rm", "migrate"), OutputContains("migration done"), ServiceState("db", "running")). - Step("a schedule-only job cannot be run", - ComposeCmd("run", "--rm", "backup").MayFail(), - StderrContains(`job "backup" has no manual trigger`)) + Step("a scheduled job without explicit opt-out can be run manually", + ComposeCmd("run", "--rm", "backup"), + OutputContains("backup")). + Step("manual: false explicitly forbids manual execution", + ComposeCmd("run", "--rm", "rotation").MayFail(), + StderrContains(`job "rotation" is declared with manual: false`)) } // A job's own env_file resolves exactly like a selected service's would: the diff --git a/pkg/e2e/testdata/TestRunManualJob/compose.yaml b/pkg/e2e/testdata/TestRunManualJob/compose.yaml index 675477855e4..5157e2bd24f 100644 --- a/pkg/e2e/testdata/TestRunManualJob/compose.yaml +++ b/pkg/e2e/testdata/TestRunManualJob/compose.yaml @@ -18,3 +18,10 @@ jobs: triggers: schedule: - cron: "0 3 * * *" + rotation: + image: alpine + command: echo rotation + triggers: + manual: false + schedule: + - cron: "0 3 1 * *" From 08042172d14b555ea3236767220dc1e179139e6e Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Mon, 31 Aug 2026 10:22:03 +0200 Subject: [PATCH 07/16] pre_start: consume the load-time-resolved hook specification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit compose-go resolves each hook against its service at load time: the model itself carries the full container specification a hook runs with, and the runtime consumes it as-is through the standard create path — the runtime-side merge helper goes away. Consuming the full spec means honoring ALL of it: - service references it may carry — volumes_from entries, and service:-scoped network_mode/ipc/pid, inherited or declared — resolve to live container IDs exactly like the service create path does; the daemon knows nothing about service names and rejected them, failing up for any service combining volumes_from with a pre_start hook (locked by an e2e scenario). - hook labels — declared or inherited — merge into the container's labels, the runtime identification set winning on conflicts. - the Signed-off-by: Nicolas De Loof --- pkg/compose/hook_spec.go | 89 ------------- pkg/compose/hook_spec_test.go | 86 ------------- pkg/compose/pre_start.go | 55 ++++++-- pkg/compose/pre_start_test.go | 119 +++++++++++++++++- pkg/e2e/checks.go | 25 ++++ pkg/e2e/hooks_test.go | 12 ++ .../TestPreStartHookVolumesFrom/compose.yaml | 20 +++ 7 files changed, 216 insertions(+), 190 deletions(-) delete mode 100644 pkg/compose/hook_spec.go delete mode 100644 pkg/compose/hook_spec_test.go create mode 100644 pkg/e2e/testdata/TestPreStartHookVolumesFrom/compose.yaml diff --git a/pkg/compose/hook_spec.go b/pkg/compose/hook_spec.go deleted file mode 100644 index 41017947f16..00000000000 --- a/pkg/compose/hook_spec.go +++ /dev/null @@ -1,89 +0,0 @@ -/* - Copyright 2020 Docker Compose CLI authors - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -package compose - -import ( - "errors" - "fmt" - - "github.com/compose-spec/compose-go/v2/loader" - "github.com/compose-spec/compose-go/v2/override" - "github.com/compose-spec/compose-go/v2/types" - yaml "go.yaml.in/yaml/v4" -) - -// mergedPreStartSpec merges the hook's container specification over the -// service's, following the compose file merge rules: command and entrypoint -// replace, environment merges per key with the hook winning, extra_hosts and -// dns accumulate entries, ulimits merge, ... Every ContainerSpec attribute -// inherits this way, current and future, without attribute-specific code. -// -// Volumes are deliberately dropped from the inherited side: mounts inherit -// at runtime through volumes_from, the only mechanism that shares the -// service's anonymous and image volumes; the merged spec carries the hook's -// own volume declarations only, which take precedence per target. -func mergedPreStartSpec(service types.ServiceConfig, hook types.PreStartHook) (types.ContainerSpec, error) { - base, err := containerSpecDict(service.ContainerSpec) - if err != nil { - return types.ContainerSpec{}, err - } - delete(base, "volumes") - over, err := containerSpecDict(hook.ContainerSpec) - if err != nil { - return types.ContainerSpec{}, err - } - - merged, err := override.Merge( - map[string]any{"services": map[string]any{"hook": base}}, - map[string]any{"services": map[string]any{"hook": over}}, - ) - if err != nil { - return types.ContainerSpec{}, fmt.Errorf("merging pre_start hook specification: %w", err) - } - dict, ok := merged["services"].(map[string]any)["hook"].(map[string]any) - if !ok { - return types.ContainerSpec{}, errors.New("internal: unexpected merged hook specification shape") - } - - var spec types.ContainerSpec - if err := loader.Transform(dict, &spec); err != nil { - return types.ContainerSpec{}, fmt.Errorf("decoding merged pre_start hook specification: %w", err) - } - return spec, nil -} - -// containerSpecDict serializes a ContainerSpec to the canonical yaml tree the -// compose merge rules operate on — the same serialization compose config -// uses. -func containerSpecDict(spec types.ContainerSpec) (map[string]any, error) { - p := &types.Project{Services: types.Services{"hook": {ContainerSpec: spec}}} - raw, err := p.MarshalYAML() - if err != nil { - return nil, err - } - var dict map[string]any - if err := yaml.Unmarshal(raw, &dict); err != nil { - return nil, err - } - svc, ok := dict["services"].(map[string]any)["hook"].(map[string]any) - if !ok { - // a zero spec marshals to a null service entry - return map[string]any{}, nil - } - delete(svc, "name") - return svc, nil -} diff --git a/pkg/compose/hook_spec_test.go b/pkg/compose/hook_spec_test.go deleted file mode 100644 index 2a116a96200..00000000000 --- a/pkg/compose/hook_spec_test.go +++ /dev/null @@ -1,86 +0,0 @@ -/* - Copyright 2020 Docker Compose CLI authors - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -package compose - -import ( - "testing" - - "github.com/compose-spec/compose-go/v2/types" - "gotest.tools/v3/assert" - is "gotest.tools/v3/assert/cmp" -) - -func strPtr(s string) *string { return &s } - -func TestMergedPreStartSpec(t *testing.T) { - service := types.ServiceConfig{ - Name: "db", - ContainerSpec: types.ContainerSpec{ - Image: "postgres:16", - User: "999", - WorkingDir: "/srv", - Command: types.ShellCommand{"postgres"}, - Environment: types.MappingWithEquals{"PGDATA": strPtr("/data"), "SHARED": strPtr("service")}, - ExtraHosts: types.HostsList{"inherited": {"192.0.2.10"}, "shared": {"192.0.2.11"}}, - DNS: types.StringList{"1.1.1.1"}, - CapAdd: []string{"NET_ADMIN"}, - Volumes: []types.ServiceVolumeConfig{{Type: types.VolumeTypeVolume, Source: "data", Target: "/data", ReadOnly: true}}, - }, - } - hook := types.PreStartHook{ContainerSpec: types.ContainerSpec{ - Command: types.ShellCommand{"init.sh"}, - Environment: types.MappingWithEquals{"SHARED": strPtr("hook"), "ONLY": strPtr("hook")}, - ExtraHosts: types.HostsList{"declared": {"192.0.2.20"}, "shared": {"192.0.2.99"}}, - DNS: types.StringList{"8.8.8.8"}, - Volumes: []types.ServiceVolumeConfig{{Type: types.VolumeTypeVolume, Source: "data", Target: "/data"}}, - }} - - spec, err := mergedPreStartSpec(service, hook) - assert.NilError(t, err) - - // scalars inherit when undeclared - assert.Check(t, is.Equal(spec.Image, "postgres:16")) - assert.Check(t, is.Equal(spec.User, "999")) - assert.Check(t, is.Equal(spec.WorkingDir, "/srv")) - // command replaces - assert.Check(t, is.DeepEqual([]string(spec.Command), []string{"init.sh"})) - // collections merge per the compose file rules: environment per key - // (hook wins), extra_hosts accumulate entries per hostname - assert.Check(t, is.Equal(*spec.Environment["PGDATA"], "/data")) - assert.Check(t, is.Equal(*spec.Environment["SHARED"], "hook")) - assert.Check(t, is.Equal(*spec.Environment["ONLY"], "hook")) - assert.Check(t, is.DeepEqual(spec.ExtraHosts["inherited"], []string{"192.0.2.10"})) - assert.Check(t, is.DeepEqual(spec.ExtraHosts["declared"], []string{"192.0.2.20"})) - assert.Check(t, is.DeepEqual(spec.ExtraHosts["shared"], []string{"192.0.2.11", "192.0.2.99"})) - assert.Check(t, is.DeepEqual([]string(spec.DNS), []string{"1.1.1.1", "8.8.8.8"})) - // previously-unwired attributes inherit too, with zero dedicated code - assert.Check(t, is.DeepEqual(spec.CapAdd, []string{"NET_ADMIN"})) - // volumes are NOT inherited at the model level: runtime volumes_from does - assert.Assert(t, is.Len(spec.Volumes, 1)) - assert.Check(t, !spec.Volumes[0].ReadOnly, "hook keeps its own rw declaration") -} - -func TestMergedPreStartSpecEmptyHook(t *testing.T) { - service := types.ServiceConfig{ContainerSpec: types.ContainerSpec{ - Image: "alpine", Sysctls: types.Mapping{"net.core.somaxconn": "1024"}, - }} - spec, err := mergedPreStartSpec(service, types.PreStartHook{}) - assert.NilError(t, err) - assert.Check(t, is.Equal(spec.Image, "alpine")) - assert.Check(t, is.Equal(spec.Sysctls["net.core.somaxconn"], "1024")) - assert.Check(t, is.Len(spec.Volumes, 0)) -} diff --git a/pkg/compose/pre_start.go b/pkg/compose/pre_start.go index 77cdec2ca72..f7f46029bb9 100644 --- a/pkg/compose/pre_start.go +++ b/pkg/compose/pre_start.go @@ -21,6 +21,7 @@ import ( "fmt" "io" "strconv" + "strings" "github.com/compose-spec/compose-go/v2/types" "github.com/moby/moby/api/pkg/stdcopy" @@ -38,6 +39,25 @@ import ( // by a project+service+hook label filter. const preStartHookType = "pre_start" +// resolveHookServiceReferences resolves service-name references carried by a +// hook's spec (volumes_from entries, service:-scoped network_mode/ipc/pid) +// into live container IDs, listing the project's containers only when the +// spec actually holds such a reference — the common hook has none. +func (s *composeService) resolveHookServiceReferences(ctx context.Context, project *types.Project, hookService *types.ServiceConfig) error { + needs := len(hookService.VolumesFrom) > 0 || + strings.HasPrefix(hookService.NetworkMode, types.ServicePrefix) || + strings.HasPrefix(hookService.Ipc, types.ServicePrefix) || + strings.HasPrefix(hookService.Pid, types.ServicePrefix) + if !needs { + return nil + } + byService, err := s.getContainersByService(ctx, project.Name) + if err != nil { + return err + } + return resolveServiceReferences(hookService, byService) +} + // lowestNumberedContainer returns the container with the lowest // com.docker.compose.container-number label, so pre_start always targets the // same replica regardless of the order the daemon returned them in. @@ -166,15 +186,13 @@ func (s *composeService) createPreStartContainer( ctx context.Context, project *types.Project, service types.ServiceConfig, ctr container.Summary, hook types.PreStartHook, ) (client.ContainerCreateResult, error) { - // A pre_start hook is a full container specification (compose-spec#656) - // inheriting from the service per the compose file merge rules; the - // merged spec runs through the standard create path, so every attribute - // — resources, capabilities, dns, sysctls, ... — materializes exactly as - // it would for a service container. - spec, err := mergedPreStartSpec(service, hook) - if err != nil { - return client.ContainerCreateResult{}, err - } + // A pre_start hook is a full container specification (compose-spec#656), + // already resolved by compose-go at load time: every service attribute + // the hook doesn't override — resources, capabilities, dns, sysctls, + // image, ... — is inherited in the model itself, and the spec runs + // through the standard create path exactly as a service container would. + // Only volumes inherit here, at runtime, through volumes_from below. + spec := hook.ContainerSpec if spec.Image == "" { spec.Image = api.GetImageNameOrDefault(service, project.Name) } @@ -182,6 +200,13 @@ func (s *composeService) createPreStartContainer( Name: service.Name, ContainerSpec: spec, } + // The inherited (or hook-declared) spec may reference sibling services: + // volumes_from, and service:-scoped network_mode/ipc/pid. Resolve them to + // live container IDs exactly like the service create path does — the + // daemon knows nothing about service names and would reject them. + if err := s.resolveHookServiceReferences(ctx, project, &hookService); err != nil { + return client.ContainerCreateResult{}, err + } cfgs, err := s.getCreateConfigs(ctx, project, hookService, 0, nil, createOptions{ // AutoRemove is intentionally false: a failed hook container is // retained so the operator can inspect its logs. runPreStartHook @@ -193,13 +218,15 @@ func (s *composeService) createPreStartContainer( // belongs to so `compose down` and label-scoped tooling can find it. // HookLabel distinguishes hook containers from the real service // container; no container-number: tooling telling replicas apart must - // not count hook containers. - Labels: types.Labels{ + // not count hook containers. The hook's own labels — declared or + // inherited, like any other ContainerSpec attribute — merge in, the + // runtime set winning on conflicts. + Labels: mergeLabels(spec.Labels, types.Labels{ api.ProjectLabel: project.Name, api.ServiceLabel: service.Name, api.VersionLabel: api.ComposeVersion, api.HookLabel: preStartHookType, - }, + }), }) if err != nil { return client.ContainerCreateResult{}, err @@ -228,7 +255,9 @@ func (s *composeService) createPreStartContainer( } if versions.LessThan(apiVersion, apiVersion144) { - if err := s.connectPreStartExtraNetworks(ctx, project, service, created.ID, hostCfg.NetworkMode); err != nil { + // the hook's resolved spec drives the fallback connections too: a + // hook overriding networks must join ITS networks, not the service's + if err := s.connectPreStartExtraNetworks(ctx, project, hookService, created.ID, hostCfg.NetworkMode); err != nil { // AutoRemove is false; remove the container explicitly since it was // never started. Log failures so the orphan is at least visible. if _, removeErr := s.apiClient().ContainerRemove(ctx, created.ID, client.ContainerRemoveOptions{Force: true, RemoveVolumes: true}); removeErr != nil { diff --git a/pkg/compose/pre_start_test.go b/pkg/compose/pre_start_test.go index b744db9c94e..f93ca8d018c 100644 --- a/pkg/compose/pre_start_test.go +++ b/pkg/compose/pre_start_test.go @@ -288,6 +288,59 @@ func TestPreStart_VolumesFromServiceContainer(t *testing.T) { assert.Equal(t, gotLabels[api.HookLabel], preStartHookType) } +// A pre_start hook is a full container specification, resolved by +// compose-go at load time: attributes the hook doesn't override — including +// security-sensitive ones — are inherited from the service in the model +// itself. createPreStartContainer must forward the hook's resolved spec as +// faithfully as a service container's own create path does; a regression +// here would silently run hook containers with weaker isolation than the +// service they act on. +func TestPreStart_SecuritySensitiveFieldsHonored(t *testing.T) { + tested, apiClient := newPreStartTestService(t) + + project := &types.Project{Name: "demo"} + service := types.ServiceConfig{ + Name: "web", + PreStart: []types.PreStartHook{ + {ContainerSpec: types.ContainerSpec{ + Image: "alpine", + Command: types.ShellCommand{"true"}, + Privileged: true, + CapAdd: []string{"SYS_ADMIN"}, + CapDrop: []string{"ALL"}, + SecurityOpt: []string{"no-new-privileges"}, + ReadOnly: true, + Sysctls: types.Mapping{"net.ipv4.ip_forward": "1"}, + }}, + }, + } + ctr := container.Summary{ID: "service-ctr-id"} + + var got client.ContainerCreateOptions + scan := expectEmptyOrphanScan(apiClient) + apiClient.EXPECT().ContainerCreate(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ any, opts client.ContainerCreateOptions) (client.ContainerCreateResult, error) { + got = opts + return client.ContainerCreateResult{ID: "hook-1"}, nil + }).After(scan) + apiClient.EXPECT().ContainerStart(gomock.Any(), "hook-1", gomock.Any()). + Return(client.ContainerStartResult{}, nil) + apiClient.EXPECT().ContainerLogs(gomock.Any(), "hook-1", gomock.Any()). + Return(emptyLogs(), nil) + apiClient.EXPECT().ContainerWait(gomock.Any(), "hook-1", gomock.Any()). + Return(waitResultExit(0)) + expectSuccessRemove(apiClient, "hook-1") + + err := tested.runPreStart(t.Context(), project, service, ctr, func(api.ContainerEvent) {}) + assert.NilError(t, err) + assert.Assert(t, got.HostConfig.Privileged, "Privileged must be honored") + assert.DeepEqual(t, got.HostConfig.CapAdd, []string{"SYS_ADMIN"}) + assert.DeepEqual(t, got.HostConfig.CapDrop, []string{"ALL"}) + assert.DeepEqual(t, got.HostConfig.SecurityOpt, []string{"no-new-privileges"}) + assert.Assert(t, got.HostConfig.ReadonlyRootfs, "ReadonlyRootfs must be honored") + assert.DeepEqual(t, got.HostConfig.Sysctls, map[string]string{"net.ipv4.ip_forward": "1"}) +} + func TestPreStart_ContainerCreateFailurePropagates(t *testing.T) { tested, apiClient := newPreStartTestService(t) @@ -1060,7 +1113,17 @@ func TestPreStart_OldAPINetworkConnectFails(t *testing.T) { }, }, PreStart: []types.PreStartHook{ - {ContainerSpec: types.ContainerSpec{Image: "alpine", Command: types.ShellCommand{"true"}}}, + // the loader resolves the hook spec against the service at load + // time: a directly-constructed hook mimics that resolved form, + // networks included + {ContainerSpec: types.ContainerSpec{ + Image: "alpine", + Command: types.ShellCommand{"true"}, + Networks: map[string]*types.ServiceNetworkConfig{ + "default": nil, + "extra": nil, + }, + }}, }, } ctr := container.Summary{ID: "svc-ctr"} @@ -1102,7 +1165,17 @@ func TestPreStart_OldAPINetworkConnectAndRemoveFails(t *testing.T) { }, }, PreStart: []types.PreStartHook{ - {ContainerSpec: types.ContainerSpec{Image: "alpine", Command: types.ShellCommand{"true"}}}, + // the loader resolves the hook spec against the service at load + // time: a directly-constructed hook mimics that resolved form, + // networks included + {ContainerSpec: types.ContainerSpec{ + Image: "alpine", + Command: types.ShellCommand{"true"}, + Networks: map[string]*types.ServiceNetworkConfig{ + "default": nil, + "extra": nil, + }, + }}, }, } ctr := container.Summary{ID: "svc-ctr"} @@ -1195,3 +1268,45 @@ func TestPreStart_FailureStdoutOnlyTail(t *testing.T) { // Stdout fallback: no stderr → stdout content appears in the error. assert.ErrorContains(t, err, "schema mismatch") } + +// TestPreStart_HookLabelsMerged locks the "every ContainerSpec attribute" +// contract for labels: a label declared on (or inherited by) the hook lands +// on the hook container, with the runtime identification set winning on +// conflicts. +func TestPreStart_HookLabelsMerged(t *testing.T) { + tested, apiClient := newPreStartTestService(t) + + project := &types.Project{Name: "proj"} + service := types.ServiceConfig{ + Name: "web", + ContainerSpec: types.ContainerSpec{Image: "alpine"}, + PreStart: []types.PreStartHook{ + {ContainerSpec: types.ContainerSpec{ + Command: types.ShellCommand{"true"}, + Labels: types.Labels{"telemetry": "on", api.ServiceLabel: "spoofed"}, + }}, + }, + } + ctr := container.Summary{ID: "svc-ctr"} + + var gotLabels map[string]string + scan := expectEmptyOrphanScan(apiClient) + apiClient.EXPECT().ContainerCreate(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ any, opts client.ContainerCreateOptions) (client.ContainerCreateResult, error) { + gotLabels = opts.Config.Labels + return client.ContainerCreateResult{ID: "hook-1"}, nil + }).After(scan) + apiClient.EXPECT().ContainerWait(gomock.Any(), "hook-1", gomock.Any()). + Return(waitResultExit(0)) + apiClient.EXPECT().ContainerLogs(gomock.Any(), "hook-1", gomock.Any()). + Return(emptyLogs(), nil) + apiClient.EXPECT().ContainerStart(gomock.Any(), "hook-1", gomock.Any()). + Return(client.ContainerStartResult{}, nil) + expectSuccessRemove(apiClient, "hook-1") + + err := tested.runPreStart(t.Context(), project, service, ctr, nil) + assert.NilError(t, err) + assert.Equal(t, gotLabels["telemetry"], "on", "hook-declared labels must reach the container") + assert.Equal(t, gotLabels[api.ServiceLabel], "web", "the runtime identification set wins on conflicts") + assert.Equal(t, gotLabels[api.HookLabel], "pre_start") +} diff --git a/pkg/e2e/checks.go b/pkg/e2e/checks.go index 0cce5f42541..55ceb2c5049 100644 --- a/pkg/e2e/checks.go +++ b/pkg/e2e/checks.go @@ -636,3 +636,28 @@ func RunsOnPlatform(service, platform string) Check { }, } } + +// ExecOutputContains expects `docker exec` of the command in the service's +// first container to succeed and print a string — a state probe for effects +// only visible from inside the container (files written by hooks, mounted +// volumes), where no host-side observable exists. +func ExecOutputContains(service, command, sub string) Check { + return Check{ + name: fmt.Sprintf("exec %q in service %q prints %q", command, service, sub), + fn: func(ctx *CheckContext) error { + containers := ctx.curr.service(service) + if len(containers) == 0 { + return errors.New("service has no container") + } + res := icmd.RunCmd(ctx.scenario.cli.NewDockerCmd(ctx.scenario.t, + "exec", containers[0].ID, "sh", "-c", command)) + if res.ExitCode != 0 { + return fmt.Errorf("exec failed: %s", res.Combined()) + } + if !strings.Contains(res.Stdout(), sub) { + return fmt.Errorf("output %q does not contain %q", res.Stdout(), sub) + } + return nil + }, + } +} diff --git a/pkg/e2e/hooks_test.go b/pkg/e2e/hooks_test.go index 77063f505fc..2b159db87cb 100644 --- a/pkg/e2e/hooks_test.go +++ b/pkg/e2e/hooks_test.go @@ -244,3 +244,15 @@ func TestPreStartHookVolumesMerge(t *testing.T) { ComposeCmd("exec", "app", "cat", "/data/init.txt"), OutputContains("saw-config")) } + +// A service using volumes_from inherits it into its pre_start hook (full +// container specification): the reference must resolve to the live container +// like the service create path does, not reach the daemon as a raw service +// name. +func TestPreStartHookVolumesFrom(t *testing.T) { + NewScenario(t, "pre_start must work on a service declaring volumes_from"). + Step("up succeeds and the hook wrote through the inherited volume", + ComposeCmd("up", "-d", "--wait"), + ServiceState("app", "running"), + ExecOutputContains("data", "cat /shared/marker", "ready")) +} diff --git a/pkg/e2e/testdata/TestPreStartHookVolumesFrom/compose.yaml b/pkg/e2e/testdata/TestPreStartHookVolumesFrom/compose.yaml new file mode 100644 index 00000000000..6dcd31219b0 --- /dev/null +++ b/pkg/e2e/testdata/TestPreStartHookVolumesFrom/compose.yaml @@ -0,0 +1,20 @@ +services: + data: + image: alpine + init: true + command: sleep infinity + volumes: + - shared:/shared + app: + image: alpine + init: true + command: sleep infinity + volumes_from: + - data + depends_on: + data: + condition: service_started + pre_start: + - command: sh -c 'echo ready > /shared/marker' +volumes: + shared: From 051ccb9a0995ea846ed1bf717edae5edb7f702b3 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Fri, 11 Sep 2026 19:17:16 +0200 Subject: [PATCH 08/16] publish, config --images: jobs are scanned and pinned like services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every publish safeguard and image-pinning path used to walk only project.Services, leaving jobs invisible: - the sensitive-data checks (literal environment values, env_file scans, bind-mount warnings, build-only rejection) now cover jobs — a job declaring AWS_SECRET_ACCESS_KEY=... was published without the guard rail a service gets; - the image-digest override pins job images too (jobs dressed as services run through the exact WithImagesResolved semantics), so the published artifact is reproducible for jobs as well; - the application index references job images; - `config --images` lists them. Co-Authored-By: Claude Fable 5 Signed-off-by: Nicolas De Loof --- cmd/compose/config.go | 17 +++- pkg/compose/publish.go | 95 ++++++++++++++++---- pkg/compose/publish_test.go | 169 +++++++++++++++++++++++++++++++++++- pkg/e2e/publish_test.go | 10 +-- 4 files changed, 265 insertions(+), 26 deletions(-) diff --git a/cmd/compose/config.go b/cmd/compose/config.go index 08fcd9fb4e8..d39c34e541c 100644 --- a/cmd/compose/config.go +++ b/cmd/compose/config.go @@ -271,7 +271,22 @@ func imagesOnly(project *types.Project) *types.Project { } digests[name] = service } - project = &types.Project{Services: digests} + var jobDigests types.Jobs + if len(project.Jobs) > 0 { + jobDigests = types.Jobs{} + } + for name, config := range project.Jobs { + job := types.JobConfig{ + ContainerSpec: types.ContainerSpec{Image: config.Image}, + } + for _, vol := range config.Volumes { + if vol.Type == types.VolumeTypeImage { + job.Volumes = append(job.Volumes, vol) + } + } + jobDigests[name] = job + } + project = &types.Project{Services: digests, Jobs: jobDigests} return project } diff --git a/pkg/compose/publish.go b/pkg/compose/publish.go index ae27ca4e586..fbb23da64d7 100644 --- a/pkg/compose/publish.go +++ b/pkg/compose/publish.go @@ -146,8 +146,15 @@ func (s *composeService) pushComposeArtifact(ctx context.Context, project *types // so the application can be pulled as a single artifact func pushApplicationIndex(ctx context.Context, resolver remotes.Resolver, named reference.Named, descriptor v1.Descriptor, project *types.Project) error { manifests := []v1.Descriptor{} + images := make([]string, 0, len(project.Services)+len(project.Jobs)) for _, service := range project.Services { - ref, err := reference.ParseDockerRef(service.Image) + images = append(images, service.Image) + } + for _, job := range project.Jobs { + images = append(images, job.Image) + } + for _, image := range images { + ref, err := reference.ParseDockerRef(image) if err != nil { return err } @@ -328,14 +335,41 @@ func (s *composeService) generateImageDigestsOverride(ctx context.Context, proje if err != nil { return nil, err } + // WithImagesResolved only walks services: dress the jobs as services to + // run them through the exact same resolution, then fold the digests back + // into a jobs override. + if len(project.Jobs) > 0 { + jobsAsServices := &types.Project{Services: types.Services{}} + for name, job := range project.Jobs { + jobsAsServices.Services[name] = types.ServiceConfig{ + Name: name, + ContainerSpec: types.ContainerSpec{Image: job.Image}, + } + } + jobsAsServices, err = jobsAsServices.WithImagesResolved(ImageDigestResolver(ctx, s.configFile(), s.apiClient())) + if err != nil { + return nil, err + } + for name, resolved := range jobsAsServices.Services { + job := project.Jobs[name] + job.Image = resolved.Image + project.Jobs[name] = job + } + } override := types.Project{ Services: types.Services{}, + Jobs: types.Jobs{}, } for name, service := range project.Services { override.Services[name] = types.ServiceConfig{ ContainerSpec: types.ContainerSpec{Image: service.Image}, } } + for name, job := range project.Jobs { + override.Jobs[name] = types.JobConfig{ + ContainerSpec: types.ContainerSpec{Image: job.Image}, + } + } return override.MarshalYAML() } @@ -515,11 +549,15 @@ func collectEnvCheckFindings(ctx context.Context, project *types.Project) (*envC } for _, service := range unresolved.Services { - recordServiceEnvFindings(findings.services, keywordDetector, service) + recordEnvFindings(findings.services, keywordDetector, service.Name, service.ContainerSpec) if parent := localExtendsParent(service); parent != "" { queue = append(queue, parent) } } + for name, job := range unresolved.Jobs { + // jobs carry environment/env_file like services: same leak surface + recordEnvFindings(findings.services, keywordDetector, name, job.ContainerSpec) + } for name, config := range unresolved.Configs { // config.Environment is a variable *name* (only the name is // published, not its resolved value) so it is not a leak. Inline @@ -540,9 +578,9 @@ func collectEnvCheckFindings(ctx context.Context, project *types.Project) (*envC return findings, nil } -func recordServiceEnvFindings(services map[string]*serviceEnvFindings, detector secrets.Detector, service types.ServiceConfig) { +func recordEnvFindings(services map[string]*serviceEnvFindings, detector secrets.Detector, name string, spec types.ContainerSpec) { envValues := map[string]string{} - for key, value := range service.Environment { + for key, value := range spec.Environment { if value == nil { continue } @@ -550,16 +588,16 @@ func recordServiceEnvFindings(services map[string]*serviceEnvFindings, detector } hits, _ := detector.ScanMap(envValues) - if len(hits) == 0 && len(service.EnvFiles) == 0 { + if len(hits) == 0 && len(spec.EnvFiles) == 0 { return } - f := services[service.Name] + f := services[name] if f == nil { f = &serviceEnvFindings{suspiciousKeys: map[string]struct{}{}} - services[service.Name] = f + services[name] = f } - if len(service.EnvFiles) > 0 { + if len(spec.EnvFiles) > 0 { f.hasEnvFile = true } for _, hit := range hits { @@ -611,15 +649,16 @@ func buildEnvPromptMessage(services map[string]*serviceEnvFindings) string { b.WriteString("interpolated values like \"${VAR}\" are kept symbolic and have already been excluded.\n") for _, name := range sortedMapKeys(services) { f := services[name] + // name may be a service or a job: findings.services carries both. if f.hasEnvFile { - fmt.Fprintf(&b, " service %q: env_file declared\n", name) + fmt.Fprintf(&b, " %q: env_file declared\n", name) } if keys := f.sortedSuspiciousKeys(); len(keys) > 0 { quoted := make([]string, len(keys)) for i, k := range keys { quoted[i] = strconv.Quote(k) } - fmt.Fprintf(&b, " service %q: literal value for %s\n", name, strings.Join(quoted, ", ")) + fmt.Fprintf(&b, " %q: literal value for %s\n", name, strings.Join(quoted, ", ")) } } b.WriteString("Use --with-env to silence this prompt and always publish env declarations.\n") @@ -681,6 +720,11 @@ func (s *composeService) checkOnlyBuildSection(project *types.Project) (bool, er errorList = append(errorList, service.Name) } } + for name, job := range project.Jobs { + if job.Image == "" && job.Build != nil { + errorList = append(errorList, name) + } + } if len(errorList) > 0 { var errMsg strings.Builder errMsg.WriteString("your Compose stack cannot be published as it only contains a build section for service(s):\n") @@ -694,17 +738,23 @@ func (s *composeService) checkOnlyBuildSection(project *types.Project) (bool, er func (s *composeService) checkForBindMount(project *types.Project) map[string][]types.ServiceVolumeConfig { allFindings := map[string][]types.ServiceVolumeConfig{} - for serviceName, config := range project.Services { + record := func(name string, volumes []types.ServiceVolumeConfig) { bindMounts := []types.ServiceVolumeConfig{} - for _, volume := range config.Volumes { + for _, volume := range volumes { if volume.Type == types.VolumeTypeBind { bindMounts = append(bindMounts, volume) } } if len(bindMounts) > 0 { - allFindings[serviceName] = bindMounts + allFindings[name] = bindMounts } } + for serviceName, config := range project.Services { + record(serviceName, config.Volumes) + } + for name, job := range project.Jobs { + record(name, job.Volumes) + } return allFindings } @@ -726,9 +776,16 @@ func (s *composeService) checkForSensitiveData(ctx context.Context, project *typ allFindings = append(allFindings, findings...) } - // Check env files + // Check env files — jobs declare env_file like services do for _, service := range project.Services { - findings, err := scanEnvFiles(scan, service) + findings, err := scanEnvFiles(scan, service.EnvFiles) + if err != nil { + return nil, err + } + allFindings = append(allFindings, findings...) + } + for _, job := range project.Jobs { + findings, err := scanEnvFiles(scan, job.EnvFiles) if err != nil { return nil, err } @@ -760,11 +817,11 @@ func (s *composeService) checkForSensitiveData(ctx context.Context, project *typ return allFindings, nil } -// scanEnvFiles scans a service's env files for sensitive data; a missing env -// file is only an error when the service requires it -func scanEnvFiles(scan secrets.Scanner, service types.ServiceConfig) ([]secrets.DetectedSecret, error) { +// scanEnvFiles scans declared env files for sensitive data; a missing env +// file is only an error when the declaration requires it +func scanEnvFiles(scan secrets.Scanner, envFiles []types.EnvFile) ([]secrets.DetectedSecret, error) { var allFindings []secrets.DetectedSecret - for _, envFile := range service.EnvFiles { + for _, envFile := range envFiles { if _, statErr := os.Stat(envFile.Path); statErr != nil { if !os.IsNotExist(statErr) { return nil, fmt.Errorf("failed to access env file %s: %w", envFile.Path, statErr) diff --git a/pkg/compose/publish_test.go b/pkg/compose/publish_test.go index 4a8519c15bd..79b3884da57 100644 --- a/pkg/compose/publish_test.go +++ b/pkg/compose/publish_test.go @@ -28,6 +28,7 @@ import ( "github.com/compose-spec/compose-go/v2/loader" "github.com/compose-spec/compose-go/v2/types" + "github.com/distribution/reference" "github.com/docker/cli/cli/config/configfile" "github.com/google/go-cmp/cmp" "github.com/moby/moby/api/types/registry" @@ -541,6 +542,32 @@ services: "unrelated": {"UNRELATED_SECRET"}, }, }, + { + // Jobs carry environment/env_file like services: same leak + // surface, so they must be scanned too, not just project.Services. + name: "job literal on suspicious key is flagged like a service's", + files: map[string]string{ + "compose.yaml": `name: test +services: + web: + image: alpine +jobs: + migrate: + image: alpine + environment: + DB_PASSWORD: toto + env_file: + - ./app.env + triggers: + manual: true +`, + "app.env": "FOO=bar\n", + }, + wantSuspicious: map[string][]string{ + "migrate": {"DB_PASSWORD"}, + }, + wantEnvFile: []string{"migrate"}, + }, } for _, tt := range tests { @@ -626,7 +653,7 @@ services: err := svc.checkEnvironmentVariables(t.Context(), project, api.PublishOptions{}) assert.NilError(t, err) assert.Equal(t, len(prompt.prompts), 1, "exactly one env-related prompt") - assert.Assert(t, strings.Contains(prompt.prompts[0], `service "db"`)) + assert.Assert(t, strings.Contains(prompt.prompts[0], `"db"`)) assert.Assert(t, strings.Contains(prompt.prompts[0], "MYSQL_ROOT_PASSWORD")) } @@ -785,3 +812,143 @@ func Test_generateImageDigestsOverride_resolvesDependentImages(t *testing.T) { assert.NilError(t, err) assert.Assert(t, strings.Contains(string(override), "docker.io/library/nginx:latest@"+serviceDigest)) } + +// generateImageDigestsOverride only walks project.Services natively: jobs +// must be dressed as services to run through the exact same +// WithImagesResolved resolution, then folded back — so the published +// artifact is reproducible for jobs too, not just services. +func Test_generateImageDigestsOverride_resolvesJobImages(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + apiClient, cli := prepareMocks(mockCtrl) + cli.EXPECT().ConfigFile().Return(configfile.New("")).AnyTimes() + tested := &composeService{dockerCli: cli} + + const jobDigest = "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + project := &types.Project{ + Name: "test", + Jobs: types.Jobs{ + "migrate": types.JobConfig{ + Name: "migrate", + ContainerSpec: types.ContainerSpec{Image: "migrate:latest"}, + }, + }, + } + + apiClient.EXPECT().DistributionInspect(gomock.Any(), "docker.io/library/migrate:latest", gomock.Any()). + Return(client.DistributionInspectResult{ + DistributionInspect: registry.DistributionInspect{Descriptor: v1.Descriptor{Digest: jobDigest}}, + }, nil) + + override, err := tested.generateImageDigestsOverride(t.Context(), project) + assert.NilError(t, err) + assert.Assert(t, strings.Contains(string(override), "docker.io/library/migrate:latest@"+jobDigest)) +} + +// checkOnlyBuildSection must reject a job that only has a build section, the +// same as a build-only service: neither can be published as-is. +func Test_checkOnlyBuildSection_rejectsJobWithoutImage(t *testing.T) { + project := &types.Project{ + Jobs: types.Jobs{ + "migrate": types.JobConfig{ + Name: "migrate", + WorkloadSpec: types.WorkloadSpec{Build: &types.BuildConfig{Context: "."}}, + }, + }, + } + + svc := &composeService{} + ok, err := svc.checkOnlyBuildSection(project) + assert.Assert(t, !ok) + assert.ErrorContains(t, err, `"migrate"`) +} + +func Test_checkOnlyBuildSection_acceptsJobWithImage(t *testing.T) { + project := &types.Project{ + Jobs: types.Jobs{ + "migrate": types.JobConfig{ + Name: "migrate", + ContainerSpec: types.ContainerSpec{Image: "migrate:latest"}, + WorkloadSpec: types.WorkloadSpec{Build: &types.BuildConfig{Context: "."}}, + }, + }, + } + + svc := &composeService{} + ok, err := svc.checkOnlyBuildSection(project) + assert.NilError(t, err) + assert.Assert(t, ok) +} + +// checkForBindMount must flag a job's bind mounts the same as a service's: +// a bind mount references the local filesystem, meaningless once published. +func Test_checkForBindMount_flagsJobBindMount(t *testing.T) { + project := &types.Project{ + Jobs: types.Jobs{ + "migrate": types.JobConfig{ + Name: "migrate", + ContainerSpec: types.ContainerSpec{ + Image: "migrate:latest", + Volumes: []types.ServiceVolumeConfig{ + {Type: types.VolumeTypeBind, Source: "/host/data", Target: "/data"}, + {Type: types.VolumeTypeVolume, Source: "named", Target: "/named"}, + }, + }, + }, + }, + } + + svc := &composeService{} + findings := svc.checkForBindMount(project) + assert.Equal(t, len(findings["migrate"]), 1) + assert.Equal(t, findings["migrate"][0].Source, "/host/data") +} + +// checkForSensitiveData must scan a job's env files, same as a service's. +func Test_checkForSensitiveData_jobEnvFile(t *testing.T) { + dir := t.TempDir() + envPath := filepath.Join(dir, "secrets.env") + assert.NilError(t, os.WriteFile(envPath, []byte(`AWS_SECRET_ACCESS_KEY="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"`), 0o600)) + + project := &types.Project{ + Jobs: types.Jobs{ + "migrate": types.JobConfig{ + Name: "migrate", + ContainerSpec: types.ContainerSpec{ + Image: "migrate:latest", + EnvFiles: []types.EnvFile{{Path: envPath, Required: false}}, + }, + }, + }, + } + + svc := &composeService{} + findings, err := svc.checkForSensitiveData(t.Context(), project) + assert.NilError(t, err) + assert.Assert(t, len(findings) > 0, "job env file should be scanned for secrets like a service's") +} + +// pushApplicationIndex walks every service AND job image to reference them +// in the application index manifest — a job's image must be part of the +// published artifact just like a service's. Copying/pushing a real image +// needs a live registry, out of reach for a unit test, but each image is +// parsed as a docker reference before that: an invalid job image surfaces +// its own parse error, proving the job loop is reached (a service-only walk +// would report no error at all, or a different one from the valid service). +func Test_pushApplicationIndex_walksJobImages(t *testing.T) { + // No services at all: if the job loop didn't feed into the same walk, + // there would be nothing to process and this would return nil, not the + // job's own reference error. + project := &types.Project{ + Jobs: types.Jobs{ + "migrate": types.JobConfig{Name: "migrate", ContainerSpec: types.ContainerSpec{Image: "Invalid/Image:Name"}}, + }, + } + + named, err := reference.ParseNormalizedNamed("myorg/myapp:latest") + assert.NilError(t, err) + + err = pushApplicationIndex(t.Context(), nil, named, v1.Descriptor{}, project) + assert.ErrorContains(t, err, "must be lowercase") +} diff --git a/pkg/e2e/publish_test.go b/pkg/e2e/publish_test.go index 902c5422151..75bf247b734 100644 --- a/pkg/e2e/publish_test.go +++ b/pkg/e2e/publish_test.go @@ -39,7 +39,7 @@ func TestPublishPromptEnvFile(t *testing.T) { ComposeCmd("publish", "test/test", "--dry-run").WithStdin("n\n").MayFail(), ExitCode(130), OutputContains("you are about to publish env-related declarations within your OCI artifact."), - OutputContains(`service "serviceA": env_file declared`), + OutputContains(`"serviceA": env_file declared`), OutputContains("Are you ok to publish these env declarations?"), OutputNotContains("test/test published")). Step("--with-env publishes without prompting", @@ -53,7 +53,7 @@ func TestPublishPromptSuspiciousEnv(t *testing.T) { Step("declining the prompt aborts the publication", ComposeCmd("publish", "test/test", "--dry-run").WithStdin("n\n").MayFail(), ExitCode(130), - OutputContains(`service "serviceA": literal value for "MYSQL_ROOT_PASSWORD"`)). + OutputContains(`"serviceA": literal value for "MYSQL_ROOT_PASSWORD"`)). Step("--with-env publishes without prompting", ComposeCmd("publish", "test/test", "--with-env", "-y", "--dry-run"), OutputContains("test/test publishing"), @@ -73,9 +73,9 @@ func TestPublishPromptAggregatesFindings(t *testing.T) { Step("every finding is listed before the prompt", ComposeCmd("publish", "test/test", "--dry-run").WithStdin("n\n").MayFail(), ExitCode(130), - OutputContains(`service "serviceB": env_file declared`), - OutputContains(`service "serviceA": literal value for "DB_PASSWORD"`), - OutputContains(`service "serviceB": literal value for "API_KEY"`), + OutputContains(`"serviceB": env_file declared`), + OutputContains(`"serviceA": literal value for "DB_PASSWORD"`), + OutputContains(`"serviceB": literal value for "API_KEY"`), OutputContains("Use --with-env to silence this prompt")) } From d28ad4ce4ecc2ce2a69a760c6fb82d060efe13bc Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Fri, 18 Sep 2026 14:54:25 +0200 Subject: [PATCH 09/16] fix(compose): adapt relay/start tests to the container-spec layering Rebasing onto main pulled in the relay work (added after this branch was cut), whose tests build ServiceConfig/PreStart literals against the flat, pre-layering shape: DependsOn/Networks set directly, and PreStart typed as []ServiceHook. Both are incompatible with the ContainerSpec/WorkloadSpec split this branch adopts -- the promoted DependsOn/Networks fields need go1.27 to set via a flat literal (this module stays on 1.26.3), and PreStart is now []PreStartHook, a full container specification, not a plain hook. Signed-off-by: Nicolas De Loof --- pkg/compose/relay_test.go | 16 ++++++++-------- pkg/compose/start_test.go | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/pkg/compose/relay_test.go b/pkg/compose/relay_test.go index 34e84e1b569..7743a64df18 100644 --- a/pkg/compose/relay_test.go +++ b/pkg/compose/relay_test.go @@ -110,18 +110,18 @@ func TestRelayNetworks(t *testing.T) { Services: types.Services{ "db": db, "app": { - Name: "app", - DependsOn: types.DependsOnConfig{"db": {}}, - Networks: map[string]*types.ServiceNetworkConfig{"backend": nil}, + Name: "app", + WorkloadSpec: types.WorkloadSpec{DependsOn: types.DependsOnConfig{"db": {}}}, + ContainerSpec: types.ContainerSpec{Networks: map[string]*types.ServiceNetworkConfig{"backend": nil}}, }, "web": { - Name: "web", - DependsOn: types.DependsOnConfig{"db": {}}, - Networks: map[string]*types.ServiceNetworkConfig{"frontend": nil, "backend": nil}, + Name: "web", + WorkloadSpec: types.WorkloadSpec{DependsOn: types.DependsOnConfig{"db": {}}}, + ContainerSpec: types.ContainerSpec{Networks: map[string]*types.ServiceNetworkConfig{"frontend": nil, "backend": nil}}, }, "other": { - Name: "other", - Networks: map[string]*types.ServiceNetworkConfig{"private": nil}, + Name: "other", + ContainerSpec: types.ContainerSpec{Networks: map[string]*types.ServiceNetworkConfig{"private": nil}}, }, }, Networks: types.Networks{"default": {}, "backend": {}, "frontend": {}, "private": {}}, diff --git a/pkg/compose/start_test.go b/pkg/compose/start_test.go index b392112a026..5f9aebd22dd 100644 --- a/pkg/compose/start_test.go +++ b/pkg/compose/start_test.go @@ -255,7 +255,7 @@ func TestStartService_PreStartSkippedWhenLowestIsRelay(t *testing.T) { project := &types.Project{Name: "prj"} service := types.ServiceConfig{ Name: "db", - PreStart: []types.ServiceHook{{Command: types.ShellCommand{"init"}}}, + PreStart: []types.PreStartHook{{ContainerSpec: types.ContainerSpec{Command: types.ShellCommand{"init"}}}}, } relay := serviceContainer("db", 1, container.StateExited) relay.Labels[api.RelayLabel] = "abc123" From f761e08e74d0f83bc75b1c9e5c93e663dfb55744 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Mon, 21 Sep 2026 11:54:09 +0200 Subject: [PATCH 10/16] fix(run): stop double-warning unsupported attributes on the job fallback The fallback reload runProject uses to detect a job target passed warnUnsupportedAttributes, the same option as the first, selective load right before it -- which had already fired every warning once. For the common case where the target is neither a service nor a job (a plain typo), every finding got logged twice before falling through to the original, correct error anyway. skipUnsupportedAttributesWarning already exists for exactly this secondary-load case (see its other call sites in compose.go and completion.go); use it here too. Signed-off-by: Nicolas De Loof --- cmd/compose/run.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cmd/compose/run.go b/cmd/compose/run.go index 3d89d2737bd..aba0acea8a4 100644 --- a/cmd/compose/run.go +++ b/cmd/compose/run.go @@ -291,8 +291,10 @@ func runProject(ctx context.Context, dockerCli command.Cli, backend api.Compose, // it, so the env resolution below sees the job like any selected // service (its env_file resolves; unrelated services' env_file still // doesn't need to exist). A target that is not a declared job keeps - // the original, precise selection error. - unselected, _, uerr := p.ToProject(ctx, dockerCli, backend, nil, warnUnsupportedAttributes, composecli.WithoutEnvironmentResolution) + // the original, precise selection error. The first load above already + // warned about unsupported attributes; skip it here so a target that + // turns out to be a plain typo doesn't get the same warnings twice. + unselected, _, uerr := p.ToProject(ctx, dockerCli, backend, nil, skipUnsupportedAttributesWarning, composecli.WithoutEnvironmentResolution) if uerr != nil { return nil, err } From 2bc29e38c34d38607dd8dcc473d1eebcaf9b5887 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Mon, 21 Sep 2026 11:54:58 +0200 Subject: [PATCH 11/16] fix(config): scan jobs in --images, --lock-image-digests, --resolve-image-digests Only the interpolated path (imagesOnly) and pkg/compose/publish.go walked project.Jobs the same way they walk project.Services. Three sibling paths in config.go were missed: - runConfigImages (--images) only printed service images. - lockModel (--no-interpolate --lock-image-digests) dropped the top-level "jobs" key entirely along with every other non-services key, instead of filtering it down to image/type:image-volumes like services. - resolveImageDigests (--no-interpolate --resolve-image-digests) only built its pseudo-project from model["services"], so job images and type: image volume sources never resolved -- confirmed by a new test that failed against the first attempt at this fix (job image stayed unpinned) until dressed as its own pseudo-project the same way generateImageDigestsOverride in publish.go already does, since WithImagesResolved only walks a project's Services, never its Jobs. The extra complexity that last one picked up is split into resolveServiceImageDigests (services, unchanged from before this commit) and resolveJobImageDigests (new), called from a small dispatcher. Signed-off-by: Nicolas De Loof --- cmd/compose/config.go | 81 ++++++++++++++++++++++++++++++++++---- cmd/compose/config_test.go | 55 ++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 7 deletions(-) diff --git a/cmd/compose/config.go b/cmd/compose/config.go index d39c34e541c..f17d752d155 100644 --- a/cmd/compose/config.go +++ b/cmd/compose/config.go @@ -350,10 +350,10 @@ func warnModelHooksNotLockable(model map[string]any) { } } -// lockModel removes from model all attributes but service images and `type: image` volumes +// lockModel removes from model all attributes but service/job images and `type: image` volumes func lockModel(model map[string]any) { for key, e := range model { - if key != "services" { + if key != "services" && key != "jobs" { delete(model, key) continue } @@ -382,17 +382,33 @@ func lockModel(model map[string]any) { } func resolveImageDigests(ctx context.Context, dockerCli command.Cli, model map[string]any) error { + _, hasServices := model["services"].(map[string]any) + _, hasJobs := model["jobs"].(map[string]any) + if !hasServices && !hasJobs { + // both are optional at the top level of the compose model + return nil + } + if hasServices { + if err := resolveServiceImageDigests(ctx, dockerCli, model); err != nil { + return err + } + } + if hasJobs { + if err := resolveJobImageDigests(ctx, dockerCli, model); err != nil { + return err + } + } + return nil +} + +func resolveServiceImageDigests(ctx context.Context, dockerCli command.Cli, model map[string]any) error { // create a pseudo-project so we can rely on WithImagesResolved to resolve images, // pre_start hook images and `type: image` volume sources, keyed by actual service // names so sources referencing another service are detected as such and kept unresolved p := &types.Project{ Services: types.Services{}, } - services, ok := model["services"].(map[string]any) - if !ok { - // services is optional at the top level of the compose model - return nil - } + services := model["services"].(map[string]any) for name, s := range services { service := s.(map[string]any) config := types.ServiceConfig{} @@ -440,6 +456,50 @@ func resolveImageDigests(ctx context.Context, dockerCli command.Cli, model map[s return nil } +// resolveJobImageDigests mirrors resolveServiceImageDigests for model["jobs"]. +// WithImagesResolved only walks a project's Services, so jobs are dressed as +// services in their own pseudo-project -- keyed by job name, which can't +// collide with an actual service since the two are never mixed here -- then +// the resolved digests are folded back into the raw job models. +func resolveJobImageDigests(ctx context.Context, dockerCli command.Cli, model map[string]any) error { + jobsAsServices := &types.Project{Services: types.Services{}} + jobs := model["jobs"].(map[string]any) + for name, j := range jobs { + job := j.(map[string]any) + config := types.ServiceConfig{} + if image, ok := job["image"].(string); ok { + config.Image = image + } + for _, volume := range imageVolumes(job) { + source, _ := volume["source"].(string) + config.Volumes = append(config.Volumes, types.ServiceVolumeConfig{ + Type: types.VolumeTypeImage, + Source: source, + }) + } + jobsAsServices.Services[name] = config + } + + jobsAsServices, err := jobsAsServices.WithImagesResolved(compose.ImageDigestResolver(ctx, dockerCli.ConfigFile(), dockerCli.Client())) + if err != nil { + return err + } + + for name, j := range jobs { + job := j.(map[string]any) + config := jobsAsServices.Services[name] + if config.Image != "" { + job["image"] = config.Image + } + for i, volume := range imageVolumes(job) { + if source := config.Volumes[i].Source; source != "" { + volume["source"] = source + } + } + } + return nil +} + // preStartHooks returns the pre_start hook declarations of a service raw model func preStartHooks(service map[string]any) []map[string]any { hooks, ok := service["pre_start"].([]any) @@ -666,6 +726,13 @@ func runConfigImages(ctx context.Context, dockerCli command.Cli, opts configOpti _, _ = fmt.Fprintln(dockerCli.Out(), img) } } + for name, j := range project.Jobs { + job := types.ServiceConfig{Name: name, ContainerSpec: types.ContainerSpec{Image: j.Image}} + _, _ = fmt.Fprintln(dockerCli.Out(), api.GetImageNameOrDefault(job, project.Name)) + for _, img := range api.GetDependentImages(job, project.Name) { + _, _ = fmt.Fprintln(dockerCli.Out(), img) + } + } return nil } diff --git a/cmd/compose/config_test.go b/cmd/compose/config_test.go index db557cb42df..a30713b7f77 100644 --- a/cmd/compose/config_test.go +++ b/cmd/compose/config_test.go @@ -130,6 +130,61 @@ func TestResolveImageDigestsWithoutServices(t *testing.T) { assert.NilError(t, err) } +// A job's image and `type: image` volume sources must resolve the same way a +// service's do -- jobs share the same build-and-publish surface, and +// resolveImageDigests originally only walked model["services"]. +func TestResolveImageDigestsWithJobs(t *testing.T) { + const ( + serviceDigest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + jobDigest = "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + jobVolDigest = "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + ) + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + apiClient := mocks.NewMockAPIClient(mockCtrl) + cli := mocks.NewMockCli(mockCtrl) + cli.EXPECT().Client().Return(apiClient).AnyTimes() + cli.EXPECT().ConfigFile().Return(configfile.New("")).AnyTimes() + + model := map[string]any{ + "services": map[string]any{ + "web": map[string]any{"image": "nginx:latest"}, + }, + "jobs": map[string]any{ + "migrate": map[string]any{ + "image": "migrate:latest", + "volumes": []any{ + map[string]any{"type": "image", "source": "migrate-data:latest", "target": "/data"}, + }, + }, + }, + } + + apiClient.EXPECT().DistributionInspect(gomock.Any(), "docker.io/library/nginx:latest", gomock.Any()). + Return(client.DistributionInspectResult{ + DistributionInspect: registry.DistributionInspect{Descriptor: ocispec.Descriptor{Digest: serviceDigest}}, + }, nil) + apiClient.EXPECT().DistributionInspect(gomock.Any(), "docker.io/library/migrate:latest", gomock.Any()). + Return(client.DistributionInspectResult{ + DistributionInspect: registry.DistributionInspect{Descriptor: ocispec.Descriptor{Digest: jobDigest}}, + }, nil) + apiClient.EXPECT().DistributionInspect(gomock.Any(), "docker.io/library/migrate-data:latest", gomock.Any()). + Return(client.DistributionInspectResult{ + DistributionInspect: registry.DistributionInspect{Descriptor: ocispec.Descriptor{Digest: jobVolDigest}}, + }, nil) + + err := resolveImageDigests(t.Context(), cli, model) + assert.NilError(t, err) + + service := model["services"].(map[string]any)["web"].(map[string]any) + assert.Equal(t, service["image"], "docker.io/library/nginx:latest@"+serviceDigest) + + job := model["jobs"].(map[string]any)["migrate"].(map[string]any) + assert.Equal(t, job["image"], "docker.io/library/migrate:latest@"+jobDigest) + volumes := job["volumes"].([]any) + assert.Equal(t, volumes[0].(map[string]any)["source"], "docker.io/library/migrate-data:latest@"+jobVolDigest) +} + func TestImagesOnly(t *testing.T) { project := &types.Project{ Name: "test", From c1163975df2f4d325311a04784bd8c3636a0ea59 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Mon, 21 Sep 2026 11:55:52 +0200 Subject: [PATCH 12/16] fix(create, start): clear error when targeting a job by name docker compose create / start failed with the raw compose-go error "no such service: ", since project loading rejects the job name before rejectScheduledJobs or any job-aware logic ever runs -- unlike run, neither command knows how to detect that a name is a declared job. The result gave no hint that jobs exist or that run is the right command. Jobs stay run-only by design; this only replaces the error message when the target is a declared job, via a small helper (jobTargetErr) shared by both commands, reloading unselected exactly like run's own job-detection fallback. Any other "no such service" (a real typo) keeps the original, precise error. Signed-off-by: Nicolas De Loof --- cmd/compose/create.go | 16 +++++++++++++--- cmd/compose/run.go | 28 ++++++++++++++++++++++++++++ cmd/compose/start.go | 3 +++ 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/cmd/compose/create.go b/cmd/compose/create.go index 81f45e97b31..211ec2baecd 100644 --- a/cmd/compose/create.go +++ b/cmd/compose/create.go @@ -31,6 +31,7 @@ import ( "github.com/spf13/cobra" "github.com/spf13/pflag" + "github.com/docker/compose/v5/cmd/display" "github.com/docker/compose/v5/pkg/api" "github.com/docker/compose/v5/pkg/compose" ) @@ -71,9 +72,18 @@ func createCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *Bac } return nil }), - RunE: p.WithServices(dockerCli, func(ctx context.Context, project *types.Project, services []string) error { - return runCreate(ctx, dockerCli, backendOptions, opts, buildOpts, project, services) - }), + RunE: func(cmd *cobra.Command, args []string) error { + err := p.WithServices(dockerCli, func(ctx context.Context, project *types.Project, services []string) error { + return runCreate(ctx, dockerCli, backendOptions, opts, buildOpts, project, services) + })(cmd, args) + if jobErr, replaced := jobTargetErr(cmd.Context(), dockerCli, p, args, err); replaced { + if display.Mode == display.ModeJSON { + return makeJSONError(jobErr) + } + return jobErr + } + return err + }, ValidArgsFunction: completeServiceNames(dockerCli, p), } flags := cmd.Flags() diff --git a/cmd/compose/run.go b/cmd/compose/run.go index aba0acea8a4..f3964f00b4a 100644 --- a/cmd/compose/run.go +++ b/cmd/compose/run.go @@ -322,6 +322,34 @@ func isNoSuchServiceErr(err error) bool { return strings.Contains(err.Error(), "no such service") } +// jobTargetErr recognizes an otherwise-raw compose-go selection error as +// targeting a declared job: unlike run, create and start don't materialize +// jobs — targeting one with either is run-only by design — so on a match it +// only returns a clearer error message (replaced=true), never falls +// through to acting on the job. names is the caller's original +// (unselected-by-profile) argument list, checked as-is against +// project.AllJobs() the same way run's own fallback resolves a job name. +func jobTargetErr(ctx context.Context, dockerCli command.Cli, p *ProjectOptions, names []string, err error) (jobErr error, replaced bool) { + if err == nil || len(names) == 0 || !isNoSuchServiceErr(err) { + return err, false + } + backend, berr := compose.NewComposeService(dockerCli) + if berr != nil { + return err, false + } + unselected, _, uerr := p.ToProject(ctx, dockerCli, backend, nil, skipUnsupportedAttributesWarning, composecli.WithoutEnvironmentResolution) + if uerr != nil { + return err, false + } + jobs := unselected.AllJobs() + for _, name := range names { + if _, ok := jobs[name]; ok { + return fmt.Errorf("job %q can only be triggered with \"docker compose run\"", name), true + } + } + return err, false +} + 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) if err != nil { diff --git a/cmd/compose/start.go b/cmd/compose/start.go index 7ecde69e131..b1d06c95031 100644 --- a/cmd/compose/start.go +++ b/cmd/compose/start.go @@ -54,6 +54,9 @@ func startCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *Back func runStart(ctx context.Context, dockerCli command.Cli, backendOptions *BackendOptions, opts startOptions, services []string) error { project, name, err := opts.projectOrName(ctx, dockerCli, services...) if err != nil { + if jobErr, replaced := jobTargetErr(ctx, dockerCli, opts.ProjectOptions, services, err); replaced { + return jobErr + } return err } // a label-reconstructed project (no compose file) declares no jobs; From 8fc49183100722b0b9fc7486ab499892eb60c1e4 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Mon, 21 Sep 2026 11:56:37 +0200 Subject: [PATCH 13/16] fix(run): manual: false also blocks a job pulled in transitively materializeJobClosure walked job-typed depends_on edges without re-checking triggers.manual: false on anything but the top-level run target, so `docker compose run A` where A depends_on job B (manual: false) silently ran B anyway. manual: false declares a job harmful to trigger outside its schedule; depends_on doesn't change who caused the execution or when -- pulling B in to satisfy A's dependency is still the run command causing that out-of-schedule execution, one hop removed. Refuse it the same way the top-level check already does, before anything in the closure is created. Signed-off-by: Nicolas De Loof --- cmd/compose/run.go | 20 ++++++++++++---- cmd/compose/run_test.go | 18 +++++++++++++++ pkg/e2e/jobs_test.go | 13 +++++++++++ .../compose.yaml | 23 +++++++++++++++++++ 4 files changed, 70 insertions(+), 4 deletions(-) create mode 100644 pkg/e2e/testdata/TestRunJobDependsOnManualFalseJob/compose.yaml diff --git a/cmd/compose/run.go b/cmd/compose/run.go index f3964f00b4a..dbfd55ad0d8 100644 --- a/cmd/compose/run.go +++ b/cmd/compose/run.go @@ -465,15 +465,21 @@ func materializeManualJob(project *types.Project, name string) (*types.Project, // run-to-completion container satisfying its declared condition), // instead of dangling as an unresolvable name. jobs := project.AllJobs() - materializeJobClosure(project, jobs, job, map[string]bool{name: true}) + if err := materializeJobClosure(project, jobs, job, map[string]bool{name: true}); err != nil { + return nil, err + } project.Services[name] = jobAsService(project, name, job) return project, nil } // materializeJobClosure adds every job reachable through job-typed // depends_on edges to project.Services. seen carries the starting job and -// guards against dependency cycles. -func materializeJobClosure(project *types.Project, jobs types.Jobs, job types.JobConfig, seen map[string]bool) { +// guards against dependency cycles. manual: false is checked here too, not +// just on the top-level run target: it declares a job harmful to trigger +// outside its schedule, and pulling it in as a dependency is still the +// user's run command causing that out-of-schedule execution, just one hop +// removed. +func materializeJobClosure(project *types.Project, jobs types.Jobs, job types.JobConfig, seen map[string]bool) error { for dep := range job.DependsOn { if seen[dep] { continue @@ -483,9 +489,15 @@ func materializeJobClosure(project *types.Project, jobs types.Jobs, job types.Jo if !isJob { continue } - materializeJobClosure(project, jobs, depJob, seen) + if depJob.Triggers != nil && depJob.Triggers.Manual != nil && !*depJob.Triggers.Manual { + return fmt.Errorf("job %q is declared with manual: false, it cannot be triggered even as a dependency of another job", dep) + } + if err := materializeJobClosure(project, jobs, depJob, seen); err != nil { + return err + } project.Services[dep] = jobAsService(project, dep, depJob) } + return nil } // jobAsService materializes a job as a service for the one-off machinery: a diff --git a/cmd/compose/run_test.go b/cmd/compose/run_test.go index 120f95f192c..cc27d9c4c1c 100644 --- a/cmd/compose/run_test.go +++ b/cmd/compose/run_test.go @@ -60,6 +60,16 @@ func TestMaterializeManualJob(t *testing.T) { ContainerSpec: types.ContainerSpec{Image: "deployer"}, WorkloadSpec: types.WorkloadSpec{DependsOn: types.DependsOnConfig{"prep": {Condition: types.ServiceConditionCompletedSuccessfully, Required: true}}}, }, + "sensitive": { + Name: "sensitive", + Triggers: &types.TriggerConfig{Manual: &no, Schedule: []types.ScheduleConfig{{Cron: "0 3 * * *"}}}, + }, + "escalate": { + Name: "escalate", + Triggers: &types.TriggerConfig{Manual: &yes}, + ContainerSpec: types.ContainerSpec{Image: "escalator"}, + WorkloadSpec: types.WorkloadSpec{DependsOn: types.DependsOnConfig{"sensitive": {Condition: types.ServiceConditionCompletedSuccessfully, Required: true}}}, + }, }, } } @@ -122,6 +132,14 @@ func TestMaterializeManualJob(t *testing.T) { _, err := materializeManualJob(base(), "rotation") assert.Error(t, err, `job "rotation" is declared with manual: false, it cannot be run manually`) }) + + // depends_on doesn't change who caused the execution or when: pulling in + // a manual: false job as a dependency of a manually-run job is still the + // run command triggering it out of schedule, one hop removed. + t.Run("manual: false also blocks the job when pulled in transitively", func(t *testing.T) { + _, err := materializeManualJob(base(), "escalate") + assert.Error(t, err, `job "sensitive" is declared with manual: false, it cannot be triggered even as a dependency of another job`) + }) } // runProject only retries the unselected/full load to look for a job when diff --git a/pkg/e2e/jobs_test.go b/pkg/e2e/jobs_test.go index a82e5b5d85c..b506fb2883c 100644 --- a/pkg/e2e/jobs_test.go +++ b/pkg/e2e/jobs_test.go @@ -69,3 +69,16 @@ func TestRunJobDependsOnJob(t *testing.T) { OutputContains("deploy done"), ServiceState("db", "running")) } + +// manual: false declares a job harmful to trigger outside its schedule. +// depends_on doesn't change who caused the execution or when: pulling the +// job in as a dependency of a manually-run job is still the run command +// causing that out-of-schedule execution, one hop removed, so it must be +// refused too — before anything else in the closure is created. +func TestRunJobDependsOnManualFalseJob(t *testing.T) { + NewScenario(t, "a job depending on a manual: false job must refuse to run, before creating anything"). + Step("run fails naming the manual: false dependency", + ComposeCmd("run", "--rm", "deploy").MayFail(), + StderrContains(`job "sensitive" is declared with manual: false, it cannot be triggered even as a dependency`), + ServiceNotCreated("db")) +} diff --git a/pkg/e2e/testdata/TestRunJobDependsOnManualFalseJob/compose.yaml b/pkg/e2e/testdata/TestRunJobDependsOnManualFalseJob/compose.yaml new file mode 100644 index 00000000000..0e1a3f27629 --- /dev/null +++ b/pkg/e2e/testdata/TestRunJobDependsOnManualFalseJob/compose.yaml @@ -0,0 +1,23 @@ +services: + db: + image: alpine + init: true + command: sleep infinity +jobs: + sensitive: + image: alpine + command: sh -c 'echo "sensitive ran"' + triggers: + manual: false + schedule: + - cron: "0 3 * * *" + deploy: + image: alpine + command: sh -c 'echo "deploy done"' + triggers: + manual: true + depends_on: + db: + condition: service_started + sensitive: + condition: service_completed_successfully From 06f51dd17467c17d99b94efe86bd4e2e5ae86e13 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Mon, 21 Sep 2026 14:28:56 +0200 Subject: [PATCH 14/16] fix(run): correlate jobTargetErr against the specific failing name jobTargetErr was called with the full [SERVICE...] argument list, but compose-go's selection error only ever names the one target it actually failed on. The loop matched any name in the list against the job set, so `docker compose create totally-bogus migrate` (a real typo plus a legitimate job) reported the job error instead of the real "no such service: totally-bogus", masking the typo. Match the name the error actually names, not any job name that happens to share the invocation. unselectedJobs is extracted out of jobTargetErr so start's own project-resolution fallback (next commit) can reuse the same unselected-reload logic. Signed-off-by: Nicolas De Loof --- cmd/compose/run.go | 34 +++++++++++++++++-------- cmd/compose/run_test.go | 56 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 10 deletions(-) diff --git a/cmd/compose/run.go b/cmd/compose/run.go index dbfd55ad0d8..852b48658b6 100644 --- a/cmd/compose/run.go +++ b/cmd/compose/run.go @@ -322,28 +322,42 @@ func isNoSuchServiceErr(err error) bool { return strings.Contains(err.Error(), "no such service") } +// unselectedJobs reloads the project without service selection and returns +// its full (profile-enabled and -disabled) job set, or ok=false if that +// reload itself fails — the caller then has only the original error to +// fall back on. +func unselectedJobs(ctx context.Context, dockerCli command.Cli, p *ProjectOptions) (jobs types.Jobs, ok bool) { + backend, err := compose.NewComposeService(dockerCli) + if err != nil { + return nil, false + } + unselected, _, err := p.ToProject(ctx, dockerCli, backend, nil, skipUnsupportedAttributesWarning, composecli.WithoutEnvironmentResolution) + if err != nil { + return nil, false + } + return unselected.AllJobs(), true +} + // jobTargetErr recognizes an otherwise-raw compose-go selection error as // targeting a declared job: unlike run, create and start don't materialize // jobs — targeting one with either is run-only by design — so on a match it // only returns a clearer error message (replaced=true), never falls // through to acting on the job. names is the caller's original -// (unselected-by-profile) argument list, checked as-is against -// project.AllJobs() the same way run's own fallback resolves a job name. +// (unselected-by-profile) argument list. func jobTargetErr(ctx context.Context, dockerCli command.Cli, p *ProjectOptions, names []string, err error) (jobErr error, replaced bool) { if err == nil || len(names) == 0 || !isNoSuchServiceErr(err) { return err, false } - backend, berr := compose.NewComposeService(dockerCli) - if berr != nil { - return err, false - } - unselected, _, uerr := p.ToProject(ctx, dockerCli, backend, nil, skipUnsupportedAttributesWarning, composecli.WithoutEnvironmentResolution) - if uerr != nil { + jobs, ok := unselectedJobs(ctx, dockerCli, p) + if !ok { return err, false } - jobs := unselected.AllJobs() + // names is the full [SERVICE...] argument list, but err only ever names + // the one target compose-go's selection actually failed on: match that + // specific name, not any job name that happens to also be in names, + // or a real typo among several targets gets misreported as the job. for _, name := range names { - if _, ok := jobs[name]; ok { + if _, isJob := jobs[name]; isJob && strings.Contains(err.Error(), "no such service: "+name) { return fmt.Errorf("job %q can only be triggered with \"docker compose run\"", name), true } } diff --git a/cmd/compose/run_test.go b/cmd/compose/run_test.go index cc27d9c4c1c..681f5263571 100644 --- a/cmd/compose/run_test.go +++ b/cmd/compose/run_test.go @@ -18,6 +18,8 @@ package compose import ( "errors" + "os" + "path/filepath" "testing" "github.com/compose-spec/compose-go/v2/types" @@ -153,3 +155,57 @@ func TestIsNoSuchServiceErr(t *testing.T) { assert.Assert(t, !isNoSuchServiceErr(errors.New("interpolation error: bad substitution"))) assert.Assert(t, !isNoSuchServiceErr(errors.New("include: remote resource fetch failed"))) } + +// jobTargetErrFixture writes a project declaring one service and one job, +// for jobTargetErr to reload unselected against. +func jobTargetErrFixture(t *testing.T) *ProjectOptions { + t.Helper() + path := filepath.Join(t.TempDir(), "compose.yaml") + content := ` +name: test +services: + web: + image: alpine +jobs: + migrate: + image: alpine + triggers: + manual: true +` + assert.NilError(t, os.WriteFile(path, []byte(content), 0o644)) + return &ProjectOptions{ConfigPaths: []string{path}} +} + +func TestJobTargetErr(t *testing.T) { + opts := jobTargetErrFixture(t) + + t.Run("a job name in the error is replaced with a clear message", func(t *testing.T) { + err, replaced := jobTargetErr(t.Context(), nil, opts, []string{"migrate"}, errors.New("no such service: migrate")) + assert.Assert(t, replaced) + assert.Error(t, err, `job "migrate" can only be triggered with "docker compose run"`) + }) + + t.Run("a real typo among several targets keeps its own error, not a same-invocation job's", func(t *testing.T) { + // "migrate" is a declared job and present in names, but the error + // names "typo" -- the actual selection failure -- not "migrate": + // only "typo" may be reported on, and it isn't a job, so the + // original error must survive unreplaced. + original := errors.New("no such service: typo") + err, replaced := jobTargetErr(t.Context(), nil, opts, []string{"typo", "migrate"}, original) + assert.Assert(t, !replaced) + assert.Equal(t, err, original) + }) + + t.Run("the job is still recognized regardless of its position in names", func(t *testing.T) { + err, replaced := jobTargetErr(t.Context(), nil, opts, []string{"web", "migrate"}, errors.New("no such service: migrate")) + assert.Assert(t, replaced) + assert.Error(t, err, `job "migrate" can only be triggered with "docker compose run"`) + }) + + t.Run("a non-job, non-selection error is returned unchanged", func(t *testing.T) { + original := errors.New("interpolation error: bad substitution") + err, replaced := jobTargetErr(t.Context(), nil, opts, []string{"migrate"}, original) + assert.Assert(t, !replaced) + assert.Equal(t, err, original) + }) +} From 2e5a6876c9ef9daf9193b60938e68c7182517731 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Mon, 21 Sep 2026 14:29:43 +0200 Subject: [PATCH 15/16] fix(start): still refuse a job when COMPOSE_PROJECT_NAME masks the error projectOrName falls back to a label-driven, file-less project on any load failure when COMPOSE_PROJECT_NAME is set -- swallowing the load error entirely, including "no such service" for a target that's actually a declared job. Since that branch returns err == nil, jobTargetErr (gated on err != nil) and rejectScheduledJobs (gated on project != nil) were both skipped: `docker compose start migrate` fell through to a label-driven start that finds no container for a job that was never run, and exited 0 having silently done nothing. Refuse a named job explicitly on that fallback path too, reusing unselectedJobs from the previous commit. Also adds the test coverage this and the previous fix (job-target error messages on create/start) were entirely missing: create.go and start.go had no unit tests at all, and pkg/e2e/jobs_test.go only exercised up/run. Signed-off-by: Nicolas De Loof --- cmd/compose/start.go | 19 +++++++++-- pkg/e2e/jobs_test.go | 33 +++++++++++++++++++ .../TestCreateRefusesJob/compose.yaml | 10 ++++++ .../testdata/TestStartRefusesJob/compose.yaml | 10 ++++++ .../compose.yaml | 10 ++++++ 5 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 pkg/e2e/testdata/TestCreateRefusesJob/compose.yaml create mode 100644 pkg/e2e/testdata/TestStartRefusesJob/compose.yaml create mode 100644 pkg/e2e/testdata/TestStartRefusesJobWithProjectNameEnv/compose.yaml diff --git a/cmd/compose/start.go b/cmd/compose/start.go index b1d06c95031..6e2e441961f 100644 --- a/cmd/compose/start.go +++ b/cmd/compose/start.go @@ -18,6 +18,7 @@ package compose import ( "context" + "fmt" "time" "github.com/docker/cli/cli/command" @@ -59,12 +60,26 @@ func runStart(ctx context.Context, dockerCli command.Cli, backendOptions *Backen } return err } - // a label-reconstructed project (no compose file) declares no jobs; - // with a file, refuse active scheduled jobs like up and create do if project != nil { + // with a file, refuse active scheduled jobs like up and create do if err := rejectScheduledJobs(project); err != nil { return err } + } else if len(services) > 0 { + // projectOrName falls back to this label-reconstructed, file-less + // project (no error) when loading otherwise fails and + // COMPOSE_PROJECT_NAME is set — swallowing a "no such service" for + // a target that's actually a declared job along with it. Refuse it + // explicitly here too: a job that was never run has no container + // for the label-driven start below to find, so it would otherwise + // exit 0 having silently done nothing. + if jobs, ok := unselectedJobs(ctx, dockerCli, opts.ProjectOptions); ok { + for _, s := range services { + if _, isJob := jobs[s]; isJob { + return fmt.Errorf("job %q can only be triggered with \"docker compose run\"", s) + } + } + } } var timeout time.Duration diff --git a/pkg/e2e/jobs_test.go b/pkg/e2e/jobs_test.go index b506fb2883c..20d29f2d862 100644 --- a/pkg/e2e/jobs_test.go +++ b/pkg/e2e/jobs_test.go @@ -82,3 +82,36 @@ func TestRunJobDependsOnManualFalseJob(t *testing.T) { StderrContains(`job "sensitive" is declared with manual: false, it cannot be triggered even as a dependency`), ServiceNotCreated("db")) } + +// A job is documented as run-only: create and start don't know how to +// materialize one, so targeting either by a job's name must say so clearly +// instead of surfacing compose-go's raw "no such service" selection error. +func TestCreateRefusesJob(t *testing.T) { + NewScenario(t, "create must refuse a job by name, naming run as the right command"). + Step("create fails naming the job", + ComposeCmd("create", "migrate").MayFail(), + StderrContains(`job "migrate" can only be triggered with "docker compose run"`), + ServiceNotCreated("migrate")) +} + +func TestStartRefusesJob(t *testing.T) { + NewScenario(t, "start must refuse a job by name, naming run as the right command"). + Step("start fails naming the job", + ComposeCmd("start", "migrate").MayFail(), + StderrContains(`job "migrate" can only be triggered with "docker compose run"`), + ServiceNotCreated("migrate")) +} + +// When COMPOSE_PROJECT_NAME is set, start's underlying project-resolution +// helper falls back to a label-driven, file-less project on any load +// failure -- including "no such service" for a job -- instead of +// surfacing it. Without an explicit job check on that path, start would +// exit 0 having silently done nothing: a job that was never run has no +// container for the label-driven fallback to find. +func TestStartRefusesJobWithProjectNameEnv(t *testing.T) { + s := NewScenario(t, "start must still refuse a job by name when COMPOSE_PROJECT_NAME triggers the label-driven fallback") + s.Step("start fails naming the job, not silently exiting 0", + ComposeCmd("start", "migrate").WithEnv("COMPOSE_PROJECT_NAME="+s.Project()).MayFail(), + StderrContains(`job "migrate" can only be triggered with "docker compose run"`), + ServiceNotCreated("migrate")) +} diff --git a/pkg/e2e/testdata/TestCreateRefusesJob/compose.yaml b/pkg/e2e/testdata/TestCreateRefusesJob/compose.yaml new file mode 100644 index 00000000000..7cbebdadebd --- /dev/null +++ b/pkg/e2e/testdata/TestCreateRefusesJob/compose.yaml @@ -0,0 +1,10 @@ +services: + web: + image: alpine + command: sleep infinity +jobs: + migrate: + image: alpine + command: sh -c 'echo "migration done"' + triggers: + manual: true diff --git a/pkg/e2e/testdata/TestStartRefusesJob/compose.yaml b/pkg/e2e/testdata/TestStartRefusesJob/compose.yaml new file mode 100644 index 00000000000..7cbebdadebd --- /dev/null +++ b/pkg/e2e/testdata/TestStartRefusesJob/compose.yaml @@ -0,0 +1,10 @@ +services: + web: + image: alpine + command: sleep infinity +jobs: + migrate: + image: alpine + command: sh -c 'echo "migration done"' + triggers: + manual: true diff --git a/pkg/e2e/testdata/TestStartRefusesJobWithProjectNameEnv/compose.yaml b/pkg/e2e/testdata/TestStartRefusesJobWithProjectNameEnv/compose.yaml new file mode 100644 index 00000000000..7cbebdadebd --- /dev/null +++ b/pkg/e2e/testdata/TestStartRefusesJobWithProjectNameEnv/compose.yaml @@ -0,0 +1,10 @@ +services: + web: + image: alpine + command: sleep infinity +jobs: + migrate: + image: alpine + command: sh -c 'echo "migration done"' + triggers: + manual: true From ede4c8f2004067d9d287877a5228ed1003f40bbe Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Mon, 21 Sep 2026 15:00:19 +0200 Subject: [PATCH 16/16] fix(compose): move the job-target refusal into projectOrName itself The previous fix only covered create/start. projectOrName backs every other service-targeting command too (stop, kill, pause, unpause, logs, rm, down, ps, events), so they still surfaced the same two problems: a raw "no such service" error for a job name, and -- when COMPOSE_PROJECT_NAME is set -- a silent no-op, since projectOrName's label-driven fallback swallows the load error entirely. Fold the check into projectOrName so every caller is fixed at once, instead of duplicating the wiring per command. start.go's own job-handling code, now redundant, is removed; jobTargetErr and unselectedJobs (cmd/compose/run.go) are unchanged, just called from one more place. Adds unit coverage for projectOrName itself (including the COMPOSE_PROJECT_NAME path and the typo-correlation edge case) and e2e coverage for two more callers (stop, down) to confirm the fix lives in the shared helper rather than being re-implemented per command. Signed-off-by: Nicolas De Loof --- cmd/compose/compose.go | 10 +++++ cmd/compose/compose_test.go | 25 ++++++++++++ cmd/compose/start.go | 19 ---------- pkg/e2e/jobs_test.go | 38 ++++++++++++++++--- .../compose.yaml | 10 +++++ .../testdata/TestStopRefusesJob/compose.yaml | 10 +++++ 6 files changed, 87 insertions(+), 25 deletions(-) create mode 100644 pkg/e2e/testdata/TestDownRefusesJobWithProjectNameEnv/compose.yaml create mode 100644 pkg/e2e/testdata/TestStopRefusesJob/compose.yaml diff --git a/cmd/compose/compose.go b/cmd/compose/compose.go index 14757f8dd5d..5b39a335939 100644 --- a/cmd/compose/compose.go +++ b/cmd/compose/compose.go @@ -254,6 +254,16 @@ func (o *ProjectOptions) projectOrName(ctx context.Context, dockerCli command.Cl p, _, err := o.ToProject(ctx, dockerCli, backend, services, skipUnsupportedAttributesWarning, cli.WithDiscardEnvFile, cli.WithoutEnvironmentResolution) if err != nil { + // a service name among services can genuinely be a declared job: + // every caller of projectOrName treats it as run-only and has + // nothing to act on for it, so report that clearly instead of + // either the raw "no such service" below or, worse, silently + // falling back to the label-driven project next -- a job that + // was never run left no container behind for that fallback to + // find, so it would otherwise look like a successful no-op. + if jobErr, replaced := jobTargetErr(ctx, dockerCli, o, services, err); replaced { + return nil, "", jobErr + } envProjectName := os.Getenv(ComposeProjectName) if envProjectName != "" { return nil, envProjectName, nil diff --git a/cmd/compose/compose_test.go b/cmd/compose/compose_test.go index c2492dfa201..6e3500998ed 100644 --- a/cmd/compose/compose_test.go +++ b/cmd/compose/compose_test.go @@ -52,3 +52,28 @@ func TestFilterServices(t *testing.T) { _, err = p.GetService("zot") assert.NilError(t, err) } + +// projectOrName backs every service-targeting command except run/create +// (down, stop, kill, pause, unpause, logs, rm, ps, events, start, ...): a +// job target must be refused the same way regardless of which of them is +// used, and the refusal must not be masked by the COMPOSE_PROJECT_NAME +// fallback below it. +func TestProjectOrNameRefusesJob(t *testing.T) { + opts := jobTargetErrFixture(t) + + t.Run("a job target is refused with a clear error", func(t *testing.T) { + _, _, err := opts.projectOrName(t.Context(), nil, "migrate") + assert.Error(t, err, `job "migrate" can only be triggered with "docker compose run"`) + }) + + t.Run("COMPOSE_PROJECT_NAME must not mask the refusal behind a silent fallback", func(t *testing.T) { + t.Setenv("COMPOSE_PROJECT_NAME", "test") + _, _, err := opts.projectOrName(t.Context(), nil, "migrate") + assert.Error(t, err, `job "migrate" can only be triggered with "docker compose run"`) + }) + + t.Run("a real typo among several targets keeps its own error, not a same-invocation job's", func(t *testing.T) { + _, _, err := opts.projectOrName(t.Context(), nil, "typo", "migrate") + assert.ErrorContains(t, err, "no such service: typo") + }) +} diff --git a/cmd/compose/start.go b/cmd/compose/start.go index 6e2e441961f..f3f69cec347 100644 --- a/cmd/compose/start.go +++ b/cmd/compose/start.go @@ -18,7 +18,6 @@ package compose import ( "context" - "fmt" "time" "github.com/docker/cli/cli/command" @@ -55,9 +54,6 @@ func startCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *Back func runStart(ctx context.Context, dockerCli command.Cli, backendOptions *BackendOptions, opts startOptions, services []string) error { project, name, err := opts.projectOrName(ctx, dockerCli, services...) if err != nil { - if jobErr, replaced := jobTargetErr(ctx, dockerCli, opts.ProjectOptions, services, err); replaced { - return jobErr - } return err } if project != nil { @@ -65,21 +61,6 @@ func runStart(ctx context.Context, dockerCli command.Cli, backendOptions *Backen if err := rejectScheduledJobs(project); err != nil { return err } - } else if len(services) > 0 { - // projectOrName falls back to this label-reconstructed, file-less - // project (no error) when loading otherwise fails and - // COMPOSE_PROJECT_NAME is set — swallowing a "no such service" for - // a target that's actually a declared job along with it. Refuse it - // explicitly here too: a job that was never run has no container - // for the label-driven start below to find, so it would otherwise - // exit 0 having silently done nothing. - if jobs, ok := unselectedJobs(ctx, dockerCli, opts.ProjectOptions); ok { - for _, s := range services { - if _, isJob := jobs[s]; isJob { - return fmt.Errorf("job %q can only be triggered with \"docker compose run\"", s) - } - } - } } var timeout time.Duration diff --git a/pkg/e2e/jobs_test.go b/pkg/e2e/jobs_test.go index 20d29f2d862..b64f1700bd2 100644 --- a/pkg/e2e/jobs_test.go +++ b/pkg/e2e/jobs_test.go @@ -86,6 +86,9 @@ func TestRunJobDependsOnManualFalseJob(t *testing.T) { // A job is documented as run-only: create and start don't know how to // materialize one, so targeting either by a job's name must say so clearly // instead of surfacing compose-go's raw "no such service" selection error. +// The same refusal is shared by every other service-targeting command +// through projectOrName -- see TestStopRefusesJob and +// TestDownRefusesJobWithProjectNameEnv below. func TestCreateRefusesJob(t *testing.T) { NewScenario(t, "create must refuse a job by name, naming run as the right command"). Step("create fails naming the job", @@ -102,12 +105,12 @@ func TestStartRefusesJob(t *testing.T) { ServiceNotCreated("migrate")) } -// When COMPOSE_PROJECT_NAME is set, start's underlying project-resolution -// helper falls back to a label-driven, file-less project on any load -// failure -- including "no such service" for a job -- instead of -// surfacing it. Without an explicit job check on that path, start would -// exit 0 having silently done nothing: a job that was never run has no -// container for the label-driven fallback to find. +// When COMPOSE_PROJECT_NAME is set, projectOrName falls back to a +// label-driven, file-less project on any load failure -- including "no +// such service" for a job -- instead of surfacing it. Without an explicit +// job check on that path, start would exit 0 having silently done +// nothing: a job that was never run has no container for the +// label-driven fallback to find. func TestStartRefusesJobWithProjectNameEnv(t *testing.T) { s := NewScenario(t, "start must still refuse a job by name when COMPOSE_PROJECT_NAME triggers the label-driven fallback") s.Step("start fails naming the job, not silently exiting 0", @@ -115,3 +118,26 @@ func TestStartRefusesJobWithProjectNameEnv(t *testing.T) { StderrContains(`job "migrate" can only be triggered with "docker compose run"`), ServiceNotCreated("migrate")) } + +// projectOrName is shared by every service-targeting command besides +// run/create/start (stop, kill, pause/unpause, logs, rm, down, ps, +// events): the same job refusal applies to all of them. stop stands in +// for that whole family here. +func TestStopRefusesJob(t *testing.T) { + NewScenario(t, "stop must refuse a job by name, naming run as the right command"). + Step("stop fails naming the job", + ComposeCmd("stop", "migrate").MayFail(), + StderrContains(`job "migrate" can only be triggered with "docker compose run"`), + ServiceNotCreated("migrate")) +} + +// Same COMPOSE_PROJECT_NAME fallback as TestStartRefusesJobWithProjectNameEnv, +// exercised through a second projectOrName caller (down) to confirm the fix +// lives in the shared helper, not duplicated per command. +func TestDownRefusesJobWithProjectNameEnv(t *testing.T) { + s := NewScenario(t, "down must still refuse a job by name when COMPOSE_PROJECT_NAME triggers the label-driven fallback") + s.Step("down fails naming the job, not silently exiting 0", + ComposeCmd("down", "migrate").WithEnv("COMPOSE_PROJECT_NAME="+s.Project()).MayFail(), + StderrContains(`job "migrate" can only be triggered with "docker compose run"`), + ServiceNotCreated("migrate")) +} diff --git a/pkg/e2e/testdata/TestDownRefusesJobWithProjectNameEnv/compose.yaml b/pkg/e2e/testdata/TestDownRefusesJobWithProjectNameEnv/compose.yaml new file mode 100644 index 00000000000..7cbebdadebd --- /dev/null +++ b/pkg/e2e/testdata/TestDownRefusesJobWithProjectNameEnv/compose.yaml @@ -0,0 +1,10 @@ +services: + web: + image: alpine + command: sleep infinity +jobs: + migrate: + image: alpine + command: sh -c 'echo "migration done"' + triggers: + manual: true diff --git a/pkg/e2e/testdata/TestStopRefusesJob/compose.yaml b/pkg/e2e/testdata/TestStopRefusesJob/compose.yaml new file mode 100644 index 00000000000..7cbebdadebd --- /dev/null +++ b/pkg/e2e/testdata/TestStopRefusesJob/compose.yaml @@ -0,0 +1,10 @@ +services: + web: + image: alpine + command: sleep infinity +jobs: + migrate: + image: alpine + command: sh -c 'echo "migration done"' + triggers: + manual: true