diff --git a/pkg/api/labels.go b/pkg/api/labels.go index 3a0f684b98..bc9d1f4a1b 100644 --- a/pkg/api/labels.go +++ b/pkg/api/labels.go @@ -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 diff --git a/pkg/compose/build.go b/pkg/compose/build.go index 2a86165992..e38be84ef8 100644 --- a/pkg/compose/build.go +++ b/pkg/compose/build.go @@ -19,6 +19,7 @@ package compose import ( "context" "fmt" + "sort" "strings" "time" @@ -167,25 +168,36 @@ 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 + 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 } } + img, ok := images[imgName] + if !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 + // contentDigest()). 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.Add(api.ImageVolumeDigestLabel, strings.Join(digests, ",")) } } diff --git a/pkg/compose/observed_state.go b/pkg/compose/observed_state.go index d2e187e0b6..aaf44269a3 100644 --- a/pkg/compose/observed_state.go +++ b/pkg/compose/observed_state.go @@ -103,12 +103,13 @@ func (s *ObservedState) selectVolume(key, desiredName string) (ObservedVolume, [ // ObservedContainer holds the relevant state extracted from a running or stopped // container, with label values pre-parsed for efficient comparison. type ObservedContainer struct { - ID string - Name string - State container.ContainerState // "running", "exited", "created", "restarting", etc. - ConfigHash string // label com.docker.compose.config-hash - ImageDigest string // label com.docker.compose.image - Number int // label com.docker.compose.container-number + ID string + Name string + State container.ContainerState // "running", "exited", "created", "restarting", etc. + ConfigHash string // label com.docker.compose.config-hash + ImageDigest string // label com.docker.compose.image + ImageVolumeDigest string // label com.docker.compose.image-volume-digest + Number int // label com.docker.compose.container-number // ConnectedNetworks maps network IDs found in the container's network // settings. Key is the network name as seen by Docker, value is the @@ -333,6 +334,7 @@ func toObservedContainer(c container.Summary) ObservedContainer { State: c.State, ConfigHash: c.Labels[api.ConfigHashLabel], ImageDigest: c.Labels[api.ImageDigestLabel], + ImageVolumeDigest: c.Labels[api.ImageVolumeDigestLabel], Number: number, ConnectedNetworks: networks, Summary: c, diff --git a/pkg/compose/reconcile.go b/pkg/compose/reconcile.go index 56eaced0f5..a065969bc1 100644 --- a/pkg/compose/reconcile.go +++ b/pkg/compose/reconcile.go @@ -770,6 +770,9 @@ func (r *reconciler) mustRecreate(expected types.ServiceConfig, expectedHash str if oc.ImageDigest != expected.CustomLabels[api.ImageDigestLabel] { return true } + if oc.ImageVolumeDigest != expected.CustomLabels[api.ImageVolumeDigestLabel] { + return true + } if oc.State == container.StateRunning && r.hasNetworkMismatch(expected, oc) { return true }