Skip to content
Draft
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
29 changes: 27 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ jobs:

e2e:
runs-on: ubuntu-latest
name: e2e (${{ matrix.mode }}, ${{ matrix.channel }})
name: e2e (${{ matrix.mode }}, ${{ matrix.channel }}${{ matrix.store && format(', {0}', matrix.store) || '' }})
strategy:
fail-fast: false
matrix:
Expand All @@ -185,12 +185,23 @@ jobs:
- mode: standalone
engine: 28
channel: oldstable

# containerd image store (non-graphdriver digest behavior)
- mode: plugin
engine: 29
channel: stable
store: containerd
steps:
- name: Prepare
run: |
mode=${{ matrix.mode }}
engine=${{ matrix.engine }}
echo "MODE_ENGINE_PAIR=${mode}-${engine}" >> $GITHUB_ENV
store=${{ matrix.store }}
pair="${mode}-${engine}"
if [ -n "$store" ]; then
pair="${pair}-${store}"
fi
echo "MODE_ENGINE_PAIR=${pair}" >> $GITHUB_ENV

- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Expand All @@ -206,6 +217,20 @@ jobs:
- name: Check Docker Version
run: docker --version

- name: Enable containerd image store
if: ${{ matrix.store == 'containerd' }}
run: |
sudo mkdir -p /etc/docker
if [ -s /etc/docker/daemon.json ]; then
jq -s '.[0] * .[1]' /etc/docker/daemon.json <(echo '{"features": {"containerd-snapshotter": true}}') \
| sudo tee /etc/docker/daemon.json.new > /dev/null
sudo mv /etc/docker/daemon.json.new /etc/docker/daemon.json
else
echo '{"features": {"containerd-snapshotter": true}}' | sudo tee /etc/docker/daemon.json > /dev/null
fi
sudo systemctl restart docker.service
docker info -f '{{json .DriverStatus}}' | grep -q io.containerd.snapshotter.v1

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0

Expand Down
28 changes: 22 additions & 6 deletions cmd/compose/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -204,12 +204,7 @@ func runCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *Backen
return err
}

project, _, err := p.ToProject(ctx, dockerCli, backend, []string{options.Service}, composecli.WithoutEnvironmentResolution)
if err != nil {
return err
}

project, err = project.WithServicesEnvironmentResolved(true)
project, err := runProject(ctx, dockerCli, backend, p, options.Service)
if err != nil {
return err
}
Expand Down Expand Up @@ -269,6 +264,27 @@ func normalizeRunFlags(f *pflag.FlagSet, name string) pflag.NormalizedName {
return pflag.NormalizedName(name)
}

// runProject loads and prepares the project for a one-off run: environment
// resolved after service selection (so env_file of unrelated services doesn't
// need to exist) and DOCKER_DEFAULT_PLATFORM resolved into service.Platform
// exactly like `up`/`create` do — Platform feeds the config-hash of the
// dependencies started by run, so hashing a different value would recreate
// their containers.
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}, composecli.WithoutEnvironmentResolution)
if err != nil {
return nil, err
}
project, err = project.WithServicesEnvironmentResolved(true)
if err != nil {
return nil, err
}
if err := applyPlatforms(project, true); err != nil {
return nil, err
}
return project, nil
}

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 {
Expand Down
7 changes: 7 additions & 0 deletions cmd/compose/scale.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,13 @@ func runScale(ctx context.Context, dockerCli command.Cli, backendOptions *Backen
return err
}

// resolve DOCKER_DEFAULT_PLATFORM into service.Platform exactly like
// `up`/`create` do: Platform feeds the service config-hash, so scale
// hashing a different value would recreate every container
if err := applyPlatforms(project, true); err != nil {
return err
}

if opts.noDeps {
if project, err = project.WithSelectedServices(services, types.IgnoreDependencies); err != nil {
return err
Expand Down
5 changes: 5 additions & 0 deletions pkg/api/labels.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ const (
SlugLabel = "com.docker.compose.slug"
// ImageDigestLabel stores digest of the container image used to run service
ImageDigestLabel = "com.docker.compose.image"
// ImageVolumeDigestLabel stores the content digest of each `type: image`
// volume's source image, as "target=digest" pairs joined by ",", so
// mustRecreate can detect a rebuilt/updated source image independently of
// the mount Source (which must stay a resolvable name, not a digest).
ImageVolumeDigestLabel = "com.docker.compose.image-volume-digest"
// DependenciesLabel stores service dependencies
DependenciesLabel = "com.docker.compose.depends_on"
// VersionLabel stores the compose tool version used to build/run application
Expand Down
126 changes: 73 additions & 53 deletions pkg/compose/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,11 @@ package compose
import (
"context"
"fmt"
"sort"
"strings"
"time"

"github.com/compose-spec/compose-go/v2/types"
"github.com/containerd/platforms"
specs "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/sirupsen/logrus"

"github.com/docker/compose/v5/internal/tracing"
Expand Down Expand Up @@ -114,7 +113,7 @@ func (s *composeService) ensureImagesExists(ctx context.Context, project *types.
}
}

images, err := s.getLocalImagesDigests(ctx, project)
images, pinnedDigests, err := s.getLocalImagesDigests(ctx, project)
if err != nil {
return err
}
Expand Down Expand Up @@ -151,12 +150,17 @@ func (s *composeService) ensureImagesExists(ctx context.Context, project *types.
}
}

// set digest as com.docker.compose.image label so we can detect outdated containers
// set digest as com.docker.compose.image label so we can detect outdated
// containers — the single writer of that label, so the platform-pinned
// resolution below can't be overwritten by another code path
for name, service := range project.Services {
image := api.GetImageNameOrDefault(service, project.Name)
img, ok := images[image]
if ok {
service.CustomLabels.Add(api.ImageDigestLabel, img.ID)
// a platform-pinned service is labelled with the digest of ITS
// platform's manifest — see serviceImageDigest
digest := s.serviceImageDigest(ctx, service, image, img, pinnedDigests)
service.CustomLabels = service.CustomLabels.Add(api.ImageDigestLabel, digest)
}

resolveImageVolumes(&service, images, project.Name)
Expand All @@ -167,29 +171,48 @@ func (s *composeService) ensureImagesExists(ctx context.Context, project *types.
}

func resolveImageVolumes(service *types.ServiceConfig, images map[string]api.ImageSummary, projectName string) {
var digests []string
for i, vol := range service.Volumes {
if vol.Type == types.VolumeTypeImage {
imgName := vol.Source
if _, ok := images[vol.Source]; !ok {
// check if source is another service in the project
imgName = api.GetImageNameOrDefault(types.ServiceConfig{Name: vol.Source}, projectName)
// If we still can't find it, it might be an external image that wasn't pulled yet or doesn't exist
if _, ok := images[imgName]; !ok {
continue
}
}
if img, ok := images[imgName]; ok {
// Use Image ID directly as source.
// Using name@digest format (via reference.WithDigest) fails for local-only images
// that don't have RepoDigests (e.g. built locally in CI).
// Image ID (sha256:...) is always valid and ensures ServiceHash changes on rebuild.
service.Volumes[i].Source = img.ID
if vol.Type != types.VolumeTypeImage {
continue
}
imgName := vol.Source
img, ok := images[imgName]
if !ok {
// check if source is another service in the project
imgName = api.GetImageNameOrDefault(types.ServiceConfig{Name: vol.Source}, projectName)
// If we still can't find it, it might be an external image that wasn't pulled yet or doesn't exist
if img, ok = images[imgName]; !ok {
continue
}
}
// The daemon only resolves a `type=image` mount Source that is a name/tag
// or a top-level image ID, not a per-platform manifest digest (which is
// what ImageSummary.ID holds to stay stable across attested rebuilds, see
// localContentDigest). Keep Source as the resolved name so mounting always
// works, and track the digest separately so mustRecreate can still detect
// a changed source image.
service.Volumes[i].Source = imgName
digests = append(digests, vol.Target+"="+img.ID)
}
if len(digests) > 0 {
sort.Strings(digests)
service.CustomLabels = service.CustomLabels.Add(api.ImageVolumeDigestLabel, strings.Join(digests, ","))
}
}

func (s *composeService) getLocalImagesDigests(ctx context.Context, project *types.Project) (map[string]api.ImageSummary, error) {
// pinnedImageDigest is the content digest of a platform-pinned service's
// image, resolved for the service's platform rather than the host's.
type pinnedImageDigest struct {
digest string
// from is the shared summary digest at resolution time: once a pull or
// build refreshed the summary, this resolution is stale and
// serviceImageDigest re-resolves the pinned platform, keeping the
// refreshed shared value only as fallback.
from string
}

func (s *composeService) getLocalImagesDigests(ctx context.Context, project *types.Project) (map[string]api.ImageSummary, map[string]pinnedImageDigest, error) {
imageNames := utils.Set[string]{}
for _, s := range project.Services {
imageNames.Add(api.GetImageNameOrDefault(s, project.Name))
Expand All @@ -202,48 +225,45 @@ func (s *composeService) getLocalImagesDigests(ctx context.Context, project *typ
imageNames.Add(img)
}
}
imgs, err := s.getImageSummaries(ctx, imageNames.Elements())
inspections, err := s.inspectLocalImages(ctx, imageNames.Elements())
if err != nil {
return nil, err
return nil, nil, err
}
imgs := make(map[string]api.ImageSummary, len(inspections))
for repoTag, inspect := range inspections {
imgs[repoTag] = imageSummary(repoTag, inspect)
}

for i, service := range project.Services {
pinnedDigests := map[string]pinnedImageDigest{}
for name, service := range project.Services {
if service.Platform == "" {
continue
}
imgName := api.GetImageNameOrDefault(service, project.Name)
img, ok := imgs[imgName]
if !ok {
continue
}
if service.Platform != "" {
platform, err := platforms.Parse(service.Platform)
if err != nil {
return nil, err
}
// inspect by name, not img.ID: img.ID now holds a content-manifest
// digest (see contentDigest) which is not necessarily inspectable,
// whereas the image name always resolves.
inspect, err := s.apiClient().ImageInspect(ctx, imgName)
if err != nil {
return nil, err
}
actual := specs.Platform{
Architecture: inspect.Architecture,
OS: inspect.Os,
Variant: inspect.Variant,
}
if !platforms.NewMatcher(platform).Match(actual) {
logrus.Debugf("local image %s doesn't match expected platform %s", service.Image, service.Platform)
// there is a local image, but it's for the wrong platform, so
// pretend it doesn't exist so that we can pull/build an image
// for the correct platform instead
delete(imgs, imgName)
}
// digest selection and platform validation share the same manifest
// resolution (matchLocalManifest) on the inspect we already hold,
// otherwise the digest recorded and the platform validated could
// refer to different manifests of the same image
digest, satisfied, err := localContentDigest(inspections[imgName], service.Platform)
if err != nil {
return nil, nil, err
}

project.Services[i].CustomLabels.Add(api.ImageDigestLabel, img.ID)

if !satisfied {
logrus.Debugf("local image %s doesn't match expected platform %s", service.Image, service.Platform)
// there is a local image, but it's for the wrong platform, so
// pretend it doesn't exist so that we can pull/build an image
// for the correct platform instead
delete(imgs, imgName)
continue
}
pinnedDigests[name] = pinnedImageDigest{digest: digest, from: img.ID}
}

return imgs, nil
return imgs, pinnedDigests, nil
}

// resolveAndMergeBuildArgs returns the final set of build arguments to use for the service image build.
Expand Down
30 changes: 8 additions & 22 deletions pkg/compose/build_bake.go
Original file line number Diff line number Diff line change
Expand Up @@ -403,38 +403,24 @@ func (s *composeService) doBuildBake(ctx context.Context, project *types.Project
return nil, err
}

// Bake reports the top-level attested image/index digest, which changes on
// every build when provenance attestations are enabled — even for a fully
// cached build (see https://github.com/docker/compose/issues/13636). For
// images loaded into the local engine (the common non-push case), resolve
// the canonical content digest — with the service's pinned platform when
// set — so unchanged rebuilds don't recreate containers.
results := map[string]string{}
var builtImages []string
for name := range serviceToBeBuild {
for name, service := range serviceToBeBuild {
image := expectedImages[name]
target := targets[name]
built, ok := md[target]
if !ok {
return nil, fmt.Errorf("build result not found in Bake metadata for service %s", name)
}
results[image] = built.Digest
builtImages = append(builtImages, image)
results[image] = s.canonicalBuiltDigest(ctx, image, service.Platform, built.Digest)
s.events.On(builtEvent(image))
}

// Bake reports the top-level attested image/index digest, which changes on
// every build when provenance attestations are enabled — even for a fully
// cached build (see https://github.com/docker/compose/issues/13636). For
// images loaded into the local engine (the common non-push case), substitute
// the content digest so unchanged rebuilds don't recreate containers.
// Registry-only images (push/multi-platform) aren't inspectable locally, so
// they keep the Bake-reported digest.
// Best effort: if the built images can't be inspected, keep the
// Bake-reported digests rather than failing an already-successful build.
summaries, err := s.getImageSummaries(ctx, builtImages)
if err != nil {
logrus.Debugf("unable to inspect built images for content digest, keeping bake digests: %v", err)
} else {
for image, summary := range summaries {
results[image] = summary.ID
}
}

return results, nil
}

Expand Down
6 changes: 5 additions & 1 deletion pkg/compose/build_classic.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,11 @@ func (s *composeService) doBuildClassic(ctx context.Context, project *types.Proj
return err
}
s.events.On(builtEvent(image))
builtDigests[getServiceIndex(name)] = id
// the classic builder reports the raw image ID from the build stream;
// resolve the canonical content digest instead so the recorded
// identity matches what later runs compute for the same local image
// (resolved here to inherit the build traversal's concurrency)
builtDigests[getServiceIndex(name)] = s.canonicalBuiltDigest(ctx, image, service.Platform, id)

if options.Push {
return s.push(ctx, project, api.PushOptions{})
Expand Down
Loading
Loading