diff --git a/cmd/compose/config.go b/cmd/compose/config.go index f17d752d15..933e2bcd0b 100644 --- a/cmd/compose/config.go +++ b/cmd/compose/config.go @@ -403,8 +403,8 @@ func resolveImageDigests(ctx context.Context, dockerCli command.Cli, model map[s 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 + // pre_start hook images and `type: image` volume sources, keyed by the actual + // service names so the resolved digests can be folded back into the raw model below p := &types.Project{ Services: types.Services{}, } diff --git a/cmd/compose/config_test.go b/cmd/compose/config_test.go index a30713b7f7..e297ccca10 100644 --- a/cmd/compose/config_test.go +++ b/cmd/compose/config_test.go @@ -39,6 +39,7 @@ func TestResolveImageDigests(t *testing.T) { serviceDigest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" hookDigest = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" volumeDigest = "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + builderDigest = "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" ) mockCtrl := gomock.NewController(t) defer mockCtrl.Finish() @@ -60,7 +61,9 @@ func TestResolveImageDigests(t *testing.T) { map[string]any{"type": "image", "source": "someimage:latest", "target": "/data"}, // already digested: must NOT trigger any registry call and must be kept as-is map[string]any{"type": "image", "source": "docker.io/library/pinned@" + testDigest, "target": "/pinned"}, - // source referencing another service: locally built image, must be kept as-is + // happens to share its name with the "builder" service below: a type=image + // volume source is always a plain docker image reference (compose-go#929), + // never an implicit reference to another service, so it still gets resolved map[string]any{"type": "image", "source": "builder", "target": "/built"}, map[string]any{"type": "bind", "source": "/host", "target": "/bind"}, "./data:/short", @@ -91,6 +94,10 @@ func TestResolveImageDigests(t *testing.T) { Return(client.DistributionInspectResult{ DistributionInspect: registry.DistributionInspect{Descriptor: ocispec.Descriptor{Digest: hookDigest}}, }, nil) + apiClient.EXPECT().DistributionInspect(gomock.Any(), "docker.io/library/builder:latest", gomock.Any()). + Return(client.DistributionInspectResult{ + DistributionInspect: registry.DistributionInspect{Descriptor: ocispec.Descriptor{Digest: builderDigest}}, + }, nil) err := resolveImageDigests(t.Context(), cli, model) assert.NilError(t, err) @@ -105,7 +112,7 @@ func TestResolveImageDigests(t *testing.T) { volumes := service["volumes"].([]any) assert.Equal(t, volumes[0].(map[string]any)["source"], "docker.io/library/someimage:latest@"+volumeDigest) assert.Equal(t, volumes[1].(map[string]any)["source"], "docker.io/library/pinned@"+testDigest) - assert.Equal(t, volumes[2].(map[string]any)["source"], "builder") + assert.Equal(t, volumes[2].(map[string]any)["source"], "docker.io/library/builder:latest@"+builderDigest) assert.Equal(t, volumes[3].(map[string]any)["source"], "/host") assert.Equal(t, volumes[4], "./data:/short") assert.Equal(t, services["builder"].(map[string]any)["image"], "docker.io/library/pinned@"+testDigest) diff --git a/go.mod b/go.mod index 58b25f1c61..3dd351baf0 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.20260910154416-11feead015fc + github.com/compose-spec/compose-go/v2 v2.15.1-0.20260918184426-f18e211cbeaf 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 bd7ac8241c..a1ef6b439e 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.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/compose-spec/compose-go/v2 v2.15.1-0.20260918184426-f18e211cbeaf h1:ot3ZuEYubwVMNT5sO8/5MONyTkGi+GNxezgUT2Wwf9E= +github.com/compose-spec/compose-go/v2 v2.15.1-0.20260918184426-f18e211cbeaf/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/pkg/e2e/build_test.go b/pkg/e2e/build_test.go index af5c80a2e8..fad49cfe2f 100644 --- a/pkg/e2e/build_test.go +++ b/pkg/e2e/build_test.go @@ -601,6 +601,29 @@ func TestBuildDependentImageWithProfile(t *testing.T) { OutputContains("secret-build-test Built")) } +// A service reachable only as another service's additional_contexts build +// dependency must still build even when its own profile is inactive: it's +// referenced for its image, not started as a workload. Regression test for +// docker/compose#14223 — currently fails at project load ("declares unknown +// service base as additional contexts base"): compose-go's consistency check +// rejects an additional_contexts target that's disabled by profile, before +// the build path (which already special-cases DisabledServices, see +// addBuildDependencies) ever runs. Fixed by compose-spec/compose-go#931; +// this test stays red until that dependency bump lands. +func TestBuildAdditionalContextDisabledService(t *testing.T) { + s := NewScenario(t, "a build-only service reachable only via additional_contexts must build with its profile inactive, and never start") + s.Defer( + DockerCmd("image", "rm", "-f", "e2e-additional-context-base:latest").MayFail(), + DockerCmd("image", "rm", "-f", "e2e-additional-context-classroom:latest").MayFail()). + Step("up with only the classroom profile active builds base as a dependency but never starts it", + // --build: a stale image from a previous run must not let this + // step skip the build it exists to exercise. + ComposeCmd("--profile", "classroom", "up", "--build", "-d"), + ServiceState("classroom", "running"), + ImageExists("e2e-additional-context-base:latest"), + ServiceNotCreated("base")) +} + func TestBuildTLS(t *testing.T) { t.Helper() diff --git a/pkg/e2e/testdata/TestBuildAdditionalContextDisabledService/compose.yaml b/pkg/e2e/testdata/TestBuildAdditionalContextDisabledService/compose.yaml new file mode 100644 index 0000000000..89898c992d --- /dev/null +++ b/pkg/e2e/testdata/TestBuildAdditionalContextDisabledService/compose.yaml @@ -0,0 +1,20 @@ +services: + base: + profiles: ["disabled"] + image: e2e-additional-context-base:latest + build: + context: . + dockerfile_inline: | + FROM alpine + RUN echo base > /base-marker + + classroom: + profiles: ["classroom"] + image: e2e-additional-context-classroom:latest + build: + context: . + additional_contexts: + base: service:base + dockerfile_inline: | + FROM base + ENTRYPOINT ["sleep", "infinity"]