Repository: docker/compose

Issue:
[FEAT] Sub-command for locking/pinning images to digests with lock-file support for the compose command in general
### Description

Currently, if one wants to ensure supply-chain security with regards to use of docker images in docker-compose files, users have to specify the digest manually in the compose file.

Previously there was a project ([docker-lock](https://pkg.go.dev/github.com/safe-waters/docker-lock#section-readme)) that could automate this, but this project has been deleted.

It would be great if docker-compose supported pinning images to digest, somewhat like [npm lock-files](https://docs.npmjs.com/cli/v7/configuring-npm/package-lock-json), [python lock-files](https://pip.pypa.io/en/stable/cli/pip_lock/) and [go sum-file](https://go.dev/ref/mod#go-sum-files).

If docker-compose could also support updating said images digest upon request, that would be great too, but that would not be necessary for a Minimal Viable Product version of the feature IMO, as long as i can request a "re-lock" from compose that updates the digests in the assumed new lock-file, when I have updated the tag in the compose-file

Discussion:
@ndeloof: You can rely on `docker compose config --resolve-image-digests` to fix image digest.
Then som yq trickery can be used to generate an override file that will only contain the image references

```
$ docker compose config --resolve-image-digests | yq '{"services": (.services|to_entries|.[]|{.key: {"image": .value.image}})}'

services:
  test:
    image: docker.io/library/alpine:latest@sha256:a8560b36e8b8210634f77d9f7f9efd7ffa463e380b75e2e74aff4511df3ef88c
```

This feature is actually already implemented in Docker Compose for the benefits of the `publish` command, so maybe we could  expose it as `docker compose config --image-digests`@andoks: Relates to https://github.com/docker/compose/issues/5818

Solution patch:
diff --git a/cmd/compose/config.go b/cmd/compose/config.go
index e59fcc2d..3cb96910 100644
--- a/cmd/compose/config.go
+++ b/cmd/compose/config.go
@@ -56,6 +56,7 @@ type configOptions struct {
 	noConsistency       bool
 	variables           bool
 	environment         bool
+	lockImageDigests    bool
 }
 
 func (o *configOptions) ToProject(ctx context.Context, dockerCli command.Cli, services []string, po ...cli.ProjectOptionsFn) (*types.Project, error) {
@@ -98,6 +99,9 @@ func configCommand(p *ProjectOptions, dockerCli command.Cli) *cobra.Command {
 			if p.Compatibility {
 				opts.noNormalize = true
 			}
+			if opts.lockImageDigests {
+				opts.resolveImageDigests = true
+			}
 			return nil
 		}),
 		RunE: Adapt(func(ctx context.Context, args []string) error {
@@ -133,6 +137,7 @@ func configCommand(p *ProjectOptions, dockerCli command.Cli) *cobra.Command {
 	flags := cmd.Flags()
 	flags.StringVar(&opts.Format, "format", "", "Format the output. Values: [yaml | json]")
 	flags.BoolVar(&opts.resolveImageDigests, "resolve-image-digests", false, "Pin image tags to digests")
+	flags.BoolVar(&opts.lockImageDigests, "lock-image-digests", false, "Produces an override file with image digests")
 	flags.BoolVarP(&opts.quiet, "quiet", "q", false, "Only validate the configuration, don't print anything")
 	flags.BoolVar(&opts.noInterpolate, "no-interpolate", false, "Don't interpolate environment variables")
 	flags.BoolVar(&opts.noNormalize, "no-normalize", false, "Don't normalize compose model")
@@ -208,6 +213,10 @@ func runConfigInterpolate(ctx context.Context, dockerCli command.Cli, opts confi
 		}
 	}
 
+	if opts.lockImageDigests {
+		project = imagesOnly(project)
+	}
+
 	var content []byte
 	switch opts.Format {
 	case "json":
@@ -223,6 +232,18 @@ func runConfigInterpolate(ctx context.Context, dockerCli command.Cli, opts confi
 	return content, nil
 }
 
+// imagesOnly return project with all attributes removed but service.images
+func imagesOnly(project *types.Project) *types.Project {
+	digests := types.Services{}
+	for name, config := range project.Services {
+		digests[name] = types.ServiceConfig{
+			Image: config.Image,
+		}
+	}
+	project = &types.Project{Services: digests}
+	return project
+}
+
 func runConfigNoInterpolate(ctx context.Context, dockerCli command.Cli, opts configOptions, services []string) ([]byte, error) {
 	// we can't use ToProject, so the model we render here is only partially resolved
 	model, err := opts.ToModel(ctx, dockerCli, services)
@@ -237,6 +258,23 @@ 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)
+						}
+					}
+				}
+			}
+		}
+	}
+
 	return formatModel(model, opts.Format)
 }
 
diff --git a/docs/reference/compose_config.md b/docs/reference/compose_config.md
index ee25c152..3ec2d486 100644
--- a/docs/reference/compose_config.md
+++ b/docs/reference/compose_config.md
@@ -14,6 +14,7 @@ the canonical format.
 | `--format`                | `string` |         | Format the output. Values: [yaml \| json]                                   |
 | `--hash`                  | `string` |         | Print the service config hash, one per line.                                |
 | `--images`                | `bool`   |         | Print the image names, one per line.                                        |
+| `--lock-image-digests`    | `bool`   |         | Produces an override file with image digests                                |
 | `--no-consistency`        | `bool`   |         | Don't check model consistency - warning: may produce invalid Compose output |
 | `--no-env-resolution`     | `bool`   |         | Don't resolve service env files                                             |
 | `--no-interpolate`        | `bool`   |         | Don't interpolate environment variables                                     |
diff --git a/docs/reference/docker_compose_config.yaml b/docs/reference/docker_compose_config.yaml
index 2f0f9184..080fe674 100644
--- a/docs/reference/docker_compose_config.yaml
+++ b/docs/reference/docker_compose_config.yaml
@@ -46,6 +46,16 @@ options:
       experimentalcli: false
       kubernetes: false
       swarm: false
+    - option: lock-image-digests
+      value_type: bool
+      default_value: "false"
+      description: Produces an override file with image digests
+      deprecated: false
+      hidden: false
+      experimental: false
+      experimentalcli: false
+      kubernetes: false
+      swarm: false
     - option: no-consistency
       value_type: bool
       default_value: "false"



Test patch:
diff --git a/pkg/e2e/compose_test.go b/pkg/e2e/compose_test.go
index 78f6ea37..3b5a7341 100644
--- a/pkg/e2e/compose_test.go
+++ b/pkg/e2e/compose_test.go
@@ -235,7 +235,7 @@ func TestCompatibility(t *testing.T) {
 }
 
 func TestConfig(t *testing.T) {
-	const projectName = "compose-e2e-convert"
+	const projectName = "compose-e2e-config"
 	c := NewParallelCLI(t)
 
 	wd, err := os.Getwd()
@@ -253,24 +253,24 @@ services:
       default: null
 networks:
   default:
-    name: compose-e2e-convert_default
+    name: compose-e2e-config_default
 `, projectName, filepath.Join(wd, "fixtures", "simple-build-test", "nginx-build")), ExitCode: 0})
 	})
 }
 
 func TestConfigInterpolate(t *testing.T) {
-	const projectName = "compose-e2e-convert-interpolate"
+	const projectName = "compose-e2e-config-interpolate"
 	c := NewParallelCLI(t)
 
 	wd, err := os.Getwd()
 	assert.NilError(t, err)
 
-	t.Run("convert", func(t *testing.T) {
+	t.Run("config", func(t *testing.T) {
 		res := c.RunDockerComposeCmd(t, "-f", "./fixtures/simple-build-test/compose-interpolate.yaml", "-p", projectName, "config", "--no-interpolate")
 		res.Assert(t, icmd.Expected{Out: fmt.Sprintf(`name: %s
 networks:
   default:
-    name: compose-e2e-convert-interpolate_default
+    name: compose-e2e-config-interpolate_default
 services:
   nginx:
     build: