Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions pkg/compose/images.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,27 @@ func (s *composeService) manifestsSupported(ctx context.Context) (bool, error) {
return versions.GreaterThanOrEqualTo(version, apiVersion148), nil
}

// inspectContentDigest inspects ref, requesting per-manifest data on engines
// that support it, and returns the digest identifying the image's runnable
// content for the default platform. Callers that record an image identity
// compose later compares for staleness must go through this, so every such
// identity is computed the same way — see contentDigest.
func (s *composeService) inspectContentDigest(ctx context.Context, ref string) (string, error) {
withManifests, err := s.manifestsSupported(ctx)
if err != nil {
return "", err
}
var opts []client.ImageInspectOption
if withManifests {
opts = append(opts, client.ImageInspectWithManifests(true))
}
inspected, err := s.apiClient().ImageInspect(ctx, ref, opts...)
if err != nil {
return "", err
}
return contentDigest(inspected.InspectResponse, platforms.Default()), nil
}

// contentDigest returns the digest identifying an image's runnable content
// (config + layers) for the given platform. With BuildKit provenance
// attestations enabled (the default since recent Buildx/BuildKit), the image is
Expand Down
13 changes: 8 additions & 5 deletions pkg/compose/pull.go
Original file line number Diff line number Diff line change
Expand Up @@ -286,11 +286,14 @@ func (s *composeService) pullServiceImage(ctx context.Context, service types.Ser
}
s.events.On(newEvent(resource, api.Done, api.StatusPulled))

inspected, err := s.apiClient().ImageInspect(ctx, service.Image)
if err != nil {
return "", err
}
return inspected.ID, nil
// Resolve the pulled image's identity exactly the way getImageSummaries
// does for already-local images: both values feed the
// com.docker.compose.image label used to detect stale containers, so they
// must be computed identically. Returning the raw inspect ID here (the
// index digest, under the containerd store with a tag@digest ref) while
// later ups resolve the platform manifest digest via contentDigest made
// the first up after a pull recreate every container despite no change.
return s.inspectContentDigest(ctx, service.Image)
}

// ImageDigestResolver creates a func able to resolve image digest from a docker ref,
Expand Down
65 changes: 63 additions & 2 deletions pkg/compose/pull_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,18 @@
package compose

import (
"context"
"io"
"iter"
"sort"
"testing"

"github.com/compose-spec/compose-go/v2/types"
"github.com/docker/cli/cli/config/configfile"
"github.com/moby/moby/api/types/image"
"github.com/moby/moby/api/types/jsonstream"
"github.com/moby/moby/client"
"go.uber.org/mock/gomock"
"gotest.tools/v3/assert"

"github.com/docker/compose/v5/pkg/api"
Expand All @@ -46,10 +54,10 @@ func scheduledHookImages(t *testing.T, project *types.Project, present map[strin
return images
}

func serviceWithHook(name, image, policy string) types.ServiceConfig {
func serviceWithHook(name, img, policy string) types.ServiceConfig {
return types.ServiceConfig{
Name: name,
Image: image,
Image: img,
PullPolicy: policy,
PreStart: []types.ServiceHook{{Image: "init:latest"}},
}
Expand Down Expand Up @@ -102,6 +110,59 @@ func TestAddPreStartHookPulls_NeverSkips(t *testing.T) {
assert.Equal(t, len(scheduledHookImages(t, project, map[string]api.ImageSummary{})), 0)
}

// fakePullResponse is an empty, already-complete pull stream.
type fakePullResponse struct{}

func (fakePullResponse) Read([]byte) (int, error) { return 0, io.EOF }
func (fakePullResponse) Close() error { return nil }
func (fakePullResponse) Wait(context.Context) error {
return nil
}

func (fakePullResponse) JSONMessages(context.Context) iter.Seq2[jsonstream.Message, error] {
return func(func(jsonstream.Message, error) bool) {}
}

// TestPullServiceImageUsesContentDigest verifies the pull path resolves the
// pulled image's identity with the same contentDigest scheme
// getImageSummaries uses for already-local images. Both values feed the
// com.docker.compose.image label that detects stale containers, so when the
// pull path returned the raw inspect ID instead (the index digest, under the
// containerd store with a tag@digest ref), the first up after the pulling up
// saw a phantom image change and recreated every container once.
func TestPullServiceImageUsesContentDigest(t *testing.T) {
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()

mockAPI, cli := prepareMocks(mockCtrl)
cli.EXPECT().ConfigFile().Return(configfile.New("")).AnyTimes()
tested, err := NewComposeService(cli)
assert.NilError(t, err)
mockAPI.EXPECT().Ping(gomock.Any(), client.PingOptions{NegotiateAPIVersion: true}).
Return(client.PingResult{APIVersion: "1.48"}, nil).AnyTimes()
mockAPI.EXPECT().ClientVersion().Return("1.48").AnyTimes()

ref := "foo:1@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
mockAPI.EXPECT().
ImagePull(anyCancellableContext(), ref, gomock.Any()).
Return(fakePullResponse{}, nil)
inspect := image.InspectResponse{
ID: "sha256:index",
Manifests: []image.ManifestSummary{
imageManifest("sha256:image", "amd64", true),
attestationManifest(),
},
}
mockAPI.EXPECT().
ImageInspect(anyCancellableContext(), ref, gomock.Any()).
Return(client.ImageInspectResult{InspectResponse: inspect}, nil)

id, err := tested.(*composeService).
pullServiceImage(t.Context(), types.ServiceConfig{Name: "web", Image: ref}, true, "")
assert.NilError(t, err)
assert.Equal(t, id, "sha256:image")
}

// TestAddPreStartHookPulls_DedupsSharedHookImage verifies a hook image shared by
// several services is scheduled at most once.
func TestAddPreStartHookPulls_DedupsSharedHookImage(t *testing.T) {
Expand Down
Loading