diff --git a/cmd/compose/config.go b/cmd/compose/config.go index cdf53f4b58..e1469734e2 100644 --- a/cmd/compose/config.go +++ b/cmd/compose/config.go @@ -31,7 +31,9 @@ import ( "github.com/compose-spec/compose-go/v2/loader" "github.com/compose-spec/compose-go/v2/template" "github.com/compose-spec/compose-go/v2/types" + "github.com/distribution/reference" "github.com/docker/cli/cli/command" + "github.com/opencontainers/go-digest" "github.com/spf13/cobra" "go.yaml.in/yaml/v4" @@ -40,6 +42,15 @@ import ( "github.com/docker/compose/v5/pkg/compose" ) +const ( + configModelServicesKey = "services" + configModelImageKey = "image" + configModelVolumesKey = "volumes" + configModelTypeKey = "type" + configModelSourceKey = "source" + configImageVolumeType = "image" +) + type configOptions struct { *ProjectOptions Format string @@ -214,7 +225,12 @@ func runConfigInterpolate(ctx context.Context, dockerCli command.Cli, opts confi } if opts.resolveImageDigests { - project, err = project.WithImagesResolved(compose.ImageDigestResolver(ctx, dockerCli.ConfigFile(), dockerCli.Client())) + resolveDigest := compose.ImageDigestResolver(ctx, dockerCli.ConfigFile(), dockerCli.Client()) + project, err = project.WithImagesResolved(resolveDigest) + if err != nil { + return nil, err + } + err = resolveProjectImageVolumeDigests(project, resolveDigest) if err != nil { return nil, err } @@ -253,13 +269,19 @@ func runConfigInterpolate(ctx context.Context, dockerCli command.Cli, opts confi return content, nil } -// imagesOnly return project with all attributes removed but service.images +// imagesOnly return project with all attributes removed but service images and image volumes. func imagesOnly(project *types.Project) *types.Project { digests := types.Services{} for name, config := range project.Services { - digests[name] = types.ServiceConfig{ + service := types.ServiceConfig{ Image: config.Image, } + for _, volume := range config.Volumes { + if volume.Type == types.VolumeTypeImage { + service.Volumes = append(service.Volumes, volume) + } + } + digests[name] = service } project = &types.Project{Services: digests} return project @@ -280,41 +302,68 @@ func runConfigNoInterpolate(ctx context.Context, dockerCli command.Cli, opts con } if opts.lockImageDigests { - for key, e := range model { - if key != "services" { - delete(model, key) - } else { - for _, s := range e.(map[string]any) { - service := s.(map[string]any) - for key := range service { - if key != "image" { - delete(service, key) - } - } + lockModelOnly(model) + } + + return formatModel(model, opts.Format) +} + +func lockModelOnly(model map[string]any) { + for key, e := range model { + if key != configModelServicesKey { + delete(model, key) + continue + } + services := e.(map[string]any) + for _, s := range services { + service := s.(map[string]any) + for key := range service { + if key != configModelImageKey && key != configModelVolumesKey { + delete(service, key) + } + } + volumes, ok := service[configModelVolumesKey].([]any) + if !ok { + continue + } + imageVolumes := []any{} + for _, v := range volumes { + volume, ok := v.(map[string]any) + if ok && volume[configModelTypeKey] == configImageVolumeType { + imageVolumes = append(imageVolumes, volume) } } + if len(imageVolumes) == 0 { + delete(service, configModelVolumesKey) + } else { + service[configModelVolumesKey] = imageVolumes + } } } - - return formatModel(model, opts.Format) } func resolveImageDigests(ctx context.Context, dockerCli command.Cli, model map[string]any) (err error) { + resolveDigest := compose.ImageDigestResolver(ctx, dockerCli.ConfigFile(), dockerCli.Client()) + // create a pseudo-project so we can rely on WithImagesResolved to resolve images p := &types.Project{ Services: types.Services{}, } - services := model["services"].(map[string]any) + services := model[configModelServicesKey].(map[string]any) for name, s := range services { service := s.(map[string]any) - if image, ok := service["image"]; ok { + if image, ok := service[configModelImageKey]; ok { p.Services[name] = types.ServiceConfig{ Image: image.(string), } } } - p, err = p.WithImagesResolved(compose.ImageDigestResolver(ctx, dockerCli.ConfigFile(), dockerCli.Client())) + p, err = p.WithImagesResolved(resolveDigest) + if err != nil { + return err + } + err = resolveModelImageVolumeDigests(services, resolveDigest) if err != nil { return err } @@ -324,14 +373,73 @@ func resolveImageDigests(ctx context.Context, dockerCli command.Cli, model map[s service := s.(map[string]any) config := p.Services[name] if config.Image != "" { - service["image"] = config.Image + service[configModelImageKey] = config.Image } services[name] = service } - model["services"] = services + model[configModelServicesKey] = services return nil } +func resolveProjectImageVolumeDigests(project *types.Project, resolveDigest func(reference.Named) (digest.Digest, error)) error { + for serviceName, service := range project.Services { + for index, volume := range service.Volumes { + if volume.Type != types.VolumeTypeImage || volume.Source == "" { + continue + } + resolved, err := resolveImageRefWithDigest(volume.Source, resolveDigest) + if err != nil { + return err + } + service.Volumes[index].Source = resolved + } + project.Services[serviceName] = service + } + return nil +} + +func resolveModelImageVolumeDigests(services map[string]any, resolveDigest func(reference.Named) (digest.Digest, error)) error { + for _, s := range services { + service := s.(map[string]any) + volumes, ok := service[configModelVolumesKey].([]any) + if !ok { + continue + } + for _, v := range volumes { + volume, ok := v.(map[string]any) + if !ok || volume[configModelTypeKey] != configImageVolumeType { + continue + } + source, ok := volume[configModelSourceKey].(string) + if !ok || source == "" { + continue + } + resolved, err := resolveImageRefWithDigest(source, resolveDigest) + if err != nil { + return err + } + volume[configModelSourceKey] = resolved + } + } + return nil +} + +func resolveImageRefWithDigest(image string, resolveDigest func(reference.Named) (digest.Digest, error)) (string, error) { + named, err := reference.ParseNormalizedNamed(image) + if err != nil { + return "", err + } + d, err := resolveDigest(named) + if err != nil { + return "", err + } + canonical, err := reference.WithDigest(reference.TagNameOnly(named), d) + if err != nil { + return "", err + } + return canonical.String(), nil +} + func formatModel(model map[string]any, format string) (content []byte, err error) { switch format { case "json": diff --git a/cmd/compose/config_test.go b/cmd/compose/config_test.go new file mode 100644 index 0000000000..a6af1f3666 --- /dev/null +++ b/cmd/compose/config_test.go @@ -0,0 +1,161 @@ +/* + Copyright 2026 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 ( + "context" + "testing" + + "github.com/compose-spec/compose-go/v2/types" + "github.com/docker/cli/cli/config/configfile" + "github.com/docker/compose/v5/pkg/mocks" + "github.com/moby/moby/api/types/registry" + "github.com/moby/moby/client" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "go.uber.org/mock/gomock" + "gotest.tools/v3/assert" +) + +const ( + configDigestServiceName = "app" + configDigestServiceImage = "busybox:latest" + configDigestServiceRef = "docker.io/library/busybox:latest" + configDigestServicePinned = "docker.io/library/busybox:latest@sha256:1111111111111111111111111111111111111111111111111111111111111111" + configDigestServiceDigest = "sha256:1111111111111111111111111111111111111111111111111111111111111111" + configDigestVolumeType = "image" + configDigestVolumeSource = "alpine:latest" + configDigestVolumeRef = "docker.io/library/alpine:latest" + configDigestVolumePinned = "docker.io/library/alpine:latest@sha256:2222222222222222222222222222222222222222222222222222222222222222" + configDigestVolumeDigest = "sha256:2222222222222222222222222222222222222222222222222222222222222222" + configDigestVolumeTarget = "/test_mount" + configDigestModelServicesKey = "services" + configDigestModelImageKey = "image" + configDigestModelVolumesKey = "volumes" + configDigestModelTypeKey = "type" + configDigestModelSourceKey = "source" + configDigestModelTargetKey = "target" + configDigestModelCommandKey = "command" + configDigestBindVolumeType = "bind" + configDigestBindVolumeSource = "./data" + configDigestBindVolumeTarget = "/data" +) + +func TestResolveImageDigestsPinsImageVolumeSources(t *testing.T) { + ctrl := gomock.NewController(t) + dockerCli := mocks.NewMockCli(ctrl) + apiClient := mocks.NewMockAPIClient(ctrl) + + dockerCli.EXPECT().ConfigFile().Return(configfile.New("")) + dockerCli.EXPECT().Client().Return(apiClient) + apiClient.EXPECT(). + DistributionInspect(gomock.Any(), configDigestServiceRef, gomock.Any()). + Return(client.DistributionInspectResult{ + DistributionInspect: registry.DistributionInspect{ + Descriptor: ocispec.Descriptor{Digest: configDigestServiceDigest}, + }, + }, nil) + apiClient.EXPECT(). + DistributionInspect(gomock.Any(), configDigestVolumeRef, gomock.Any()). + Return(client.DistributionInspectResult{ + DistributionInspect: registry.DistributionInspect{ + Descriptor: ocispec.Descriptor{Digest: configDigestVolumeDigest}, + }, + }, nil) + + volume := map[string]any{ + configDigestModelTypeKey: configDigestVolumeType, + configDigestModelSourceKey: configDigestVolumeSource, + configDigestModelTargetKey: configDigestVolumeTarget, + } + service := map[string]any{ + configDigestModelImageKey: configDigestServiceImage, + configDigestModelVolumesKey: []any{volume}, + } + model := map[string]any{ + configDigestModelServicesKey: map[string]any{ + configDigestServiceName: service, + }, + } + + err := resolveImageDigests(context.Background(), dockerCli, model) + assert.NilError(t, err) + + assert.Equal(t, service[configDigestModelImageKey], configDigestServicePinned) + assert.Equal(t, volume[configDigestModelSourceKey], configDigestVolumePinned) +} + +func TestImagesOnlyKeepsImageVolumeSources(t *testing.T) { + project := &types.Project{ + Services: types.Services{ + configDigestServiceName: { + Image: configDigestServicePinned, + Volumes: []types.ServiceVolumeConfig{ + { + Type: types.VolumeTypeImage, + Source: configDigestVolumePinned, + Target: configDigestVolumeTarget, + }, + { + Type: types.VolumeTypeBind, + Source: configDigestBindVolumeSource, + Target: configDigestBindVolumeTarget, + }, + }, + }, + }, + } + + locked := imagesOnly(project) + service := locked.Services[configDigestServiceName] + + assert.Equal(t, service.Image, configDigestServicePinned) + assert.Equal(t, len(service.Volumes), 1) + assert.Equal(t, service.Volumes[0].Type, types.VolumeTypeImage) + assert.Equal(t, service.Volumes[0].Source, configDigestVolumePinned) + assert.Equal(t, service.Volumes[0].Target, configDigestVolumeTarget) +} + +func TestLockModelOnlyKeepsImageVolumeSources(t *testing.T) { + imageVolume := map[string]any{ + configDigestModelTypeKey: configDigestVolumeType, + configDigestModelSourceKey: configDigestVolumePinned, + configDigestModelTargetKey: configDigestVolumeTarget, + } + bindVolume := map[string]any{ + configDigestModelTypeKey: configDigestBindVolumeType, + configDigestModelSourceKey: configDigestBindVolumeSource, + configDigestModelTargetKey: configDigestBindVolumeTarget, + } + service := map[string]any{ + configDigestModelImageKey: configDigestServicePinned, + configDigestModelCommandKey: []any{"true"}, + configDigestModelVolumesKey: []any{imageVolume, bindVolume}, + } + model := map[string]any{ + configDigestModelServicesKey: map[string]any{ + configDigestServiceName: service, + }, + } + + lockModelOnly(model) + + assert.Equal(t, len(service), 2) + assert.Equal(t, service[configDigestModelImageKey], configDigestServicePinned) + volumes := service[configDigestModelVolumesKey].([]any) + assert.Equal(t, len(volumes), 1) + assert.DeepEqual(t, volumes[0], imageVolume) +}