Repository: docker/compose (go)

Issue:
Having a `volume`   command
### Description

Not much needs to be said I suppose. Having a `volume` command, context-aware of the services running in the compose file would make it a lot easier to delete, prune, and inspect volumes related to the services. The main usage for this is *not having to type the whole project name - service name - volume name, and not having to browse amidst all volumes you have available on a host.

If pointed in the right direction I can do my best to provide a patch for this. 

Pull request description:
Add volumes command
**What I did**
Implemented `volumes` command with:
- GO template support
- service filtering
- test cases 
- docs update.

Given the following compose files:

```yaml
# compose_1.yaml
name: compose_1

volumes:
  vol_1:
  vol_2:
  vol_3:

services:

  service_1:
    image: gcr.io/google-containers/pause
    restart: no
    volumes:
      - vol_1:/tmp/vol_1
      - vol_2:/tmp/vol_2

  service_2:
    image: gcr.io/google-containers/pause
    restart: no
    volumes:
      - vol_3:/tmp/vol_3
```


```yaml
# compose_2.yaml
name: compose_2

volumes:
  vol_1:
  # not used
  vol_2:

services:

  service_1:
    image: gcr.io/google-containers/pause
    restart: no
    volumes:
      - vol_1:/tmp/vol_1
```

The following manual tests should hold:

```bash
docker compose -f compose_1.yaml up -d
docker compose -f compose_2.yaml up -d

docker volume ls
# DRIVER    VOLUME NAME
# local     compose_1_vol_1
# local     compose_1_vol_2
# local     compose_1_vol_3
# local     compose_2_vol_1
# local     compose_2_vol_2

docker compose -f compose_1.yaml volumes
# DRIVER    VOLUME NAME
# local     compose_1_vol_1
# local     compose_1_vol_2
# local     compose_1_vol_3

docker compose -f compose_1.yaml volumes service_1
# DRIVER    VOLUME NAME
# local     compose_1_vol_1
# local     compose_1_vol_2

docker compose -f compose_1.yaml volumes service_2
# DRIVER    VOLUME NAME
# local     compose_1_vol_3

docker compose -f compose_1.yaml volumes service_1 service_2
# DRIVER    VOLUME NAME
# local     compose_1_vol_1
# local     compose_1_vol_2
# local     compose_1_vol_3

docker compose -f compose_2.yaml volumes
# DRIVER    VOLUME NAME
# local     compose_2_vol_1

docker compose -f compose_2.yaml volumes service_1
# DRIVER    VOLUME NAME
# local     compose_2_vol_1

docker compose -f compose_2.yaml volumes wrong_service_name
# no such service: wrong_service_name

docker compose -f compose_1.yaml volumes --format 'table {{.Name}}'
# VOLUME NAME
# compose_1_vol_1
# compose_1_vol_3
# compose_1_vol_2

docker compose -f compose_1.yaml volumes --format json
# {"Availability":"N/A","Driver":"local","Group":"N/A","Labels":"com.docker.compose.project=compose_1,com.docker.compose.version=2.36.0,com.docker.compose.volume=vol_3,com.docker.compose.config-hash=4b9e9f43e5d7acc8216829918e68c3e509add0e7e0314f287b6619be566c50f1","Links":"N/A","Mountpoint":"/var/lib/docker/volumes/compose_1_vol_3/_data","Name":"compose_1_vol_3","Scope":"local","Size":"N/A","Status":"N/A"}
# {"Availability":"N/A","Driver":"local","Group":"N/A","Labels":"com.docker.compose.config-hash=2dfb8b5aea067aa119a8c5e855f68412522bebd53b3d6e685b2322e5dfdc892d,com.docker.compose.project=compose_1,com.docker.compose.version=2.36.0,com.docker.compose.volume=vol_1","Links":"N/A","Mountpoint":"/var/lib/docker/volumes/compose_1_vol_1/_data","Name":"compose_1_vol_1","Scope":"local","Size":"N/A","Status":"N/A"}
# {"Availability":"N/A","Driver":"local","Group":"N/A","Labels":"com.docker.compose.version=2.36.0,com.docker.compose.volume=vol_2,com.docker.compose.config-hash=ab52462cd8693689c92819e3115b636c9df42021828d0aeda344b185185e9f2a,com.docker.compose.project=compose_1","Links":"N/A","Mountpoint":"/var/lib/docker/volumes/compose_1_vol_2/_data","Name":"compose_1_vol_2","Scope":"local","Size":"N/A","Status":"N/A"}
```

Remarks:

- a volume declared in the compose but not used will not appear, as shown before with `compose_2_vol_2`
- should this be marked as experimental?
 
**Related issue**
<!-- If this is a bug fix, make sure your description includes "fixes #xxxx", or "closes #xxxx" -->

This closes #11111 


Patch:
diff --git a/cmd/compose/compose.go b/cmd/compose/compose.go
index 10b53e53f..2be3ab157 100644
--- a/cmd/compose/compose.go
+++ b/cmd/compose/compose.go
@@ -636,6 +636,7 @@ func RootCommand(dockerCli command.Cli, backend Backend) *cobra.Command { //noli
 		publishCommand(&opts, dockerCli, backend),
 		alphaCommand(&opts, dockerCli, backend),
 		bridgeCommand(&opts, dockerCli),
+		volumesCommand(&opts, dockerCli, backend),
 	)
 
 	c.Flags().SetInterspersed(false)
diff --git a/cmd/compose/volumes.go b/cmd/compose/volumes.go
new file mode 100644
index 000000000..d98f71db3
--- /dev/null
+++ b/cmd/compose/volumes.go
@@ -0,0 +1,95 @@
+/*
+   Copyright 2020 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"
+	"fmt"
+	"slices"
+
+	"github.com/docker/cli/cli/command"
+	"github.com/docker/cli/cli/command/formatter"
+	"github.com/docker/cli/cli/flags"
+	"github.com/docker/compose/v2/pkg/api"
+	"github.com/spf13/cobra"
+)
+
+type volumesOptions struct {
+	*ProjectOptions
+	Quiet  bool
+	Format string
+}
+
+func volumesCommand(p *ProjectOptions, dockerCli command.Cli, backend api.Service) *cobra.Command {
+	options := volumesOptions{
+		ProjectOptions: p,
+	}
+
+	cmd := &cobra.Command{
+		Use:   "volumes [OPTIONS] [SERVICE...]",
+		Short: "List volumes",
+		RunE: Adapt(func(ctx context.Context, args []string) error {
+			return runVol(ctx, dockerCli, backend, args, options)
+		}),
+		ValidArgsFunction: completeServiceNames(dockerCli, p),
+	}
+
+	cmd.Flags().BoolVarP(&options.Quiet, "quiet", "q", false, "Only display volume names")
+	cmd.Flags().StringVar(&options.Format, "format", "table", flags.FormatHelp)
+
+	return cmd
+}
+
+func runVol(ctx context.Context, dockerCli command.Cli, backend api.Service, services []string, options volumesOptions) error {
+	project, _, err := options.projectOrName(ctx, dockerCli, services...)
+	if err != nil {
+		return err
+	}
+
+	names := project.ServiceNames()
+
+	if len(services) == 0 {
+		services = names
+	}
+
+	for _, service := range services {
+		if !slices.Contains(names, service) {
+			return fmt.Errorf("no such service: %s", service)
+		}
+	}
+
+	volumes, err := backend.Volumes(ctx, project, api.VolumesOptions{
+		Services: services,
+	})
+	if err != nil {
+		return err
+	}
+
+	if options.Quiet {
+		for _, v := range volumes {
+			_, _ = fmt.Fprintln(dockerCli.Out(), v.Name)
+		}
+		return nil
+	}
+
+	volumeCtx := formatter.Context{
+		Output: dockerCli.Out(),
+		Format: formatter.NewVolumeFormat(options.Format, options.Quiet),
+	}
+
+	return formatter.VolumeWrite(volumeCtx, volumes)
+}
diff --git a/docs/reference/compose.md b/docs/reference/compose.md
index 391bf1a97..74d129d83 100644
--- a/docs/reference/compose.md
+++ b/docs/reference/compose.md
@@ -43,6 +43,7 @@ Define and run multi-container applications with Docker
 | [`unpause`](compose_unpause.md) | Unpause services                                                                        |
 | [`up`](compose_up.md)           | Create and start containers                                                             |
 | [`version`](compose_version.md) | Show the Docker Compose version information                                             |
+| [`volumes`](compose_volumes.md) | List volumes                                                                            |
 | [`wait`](compose_wait.md)       | Block until containers of all (or specified) services stop.                             |
 | [`watch`](compose_watch.md)     | Watch build context for service and rebuild/refresh containers when files are updated   |
 
diff --git a/docs/reference/compose_volumes.md b/docs/reference/compose_volumes.md
new file mode 100644
index 000000000..6bad874f1
--- /dev/null
+++ b/docs/reference/compose_volumes.md
@@ -0,0 +1,16 @@
+# docker compose volumes
+
+<!---MARKER_GEN_START-->
+List volumes
+
+### Options
+
+| Name            | Type     | Default | Description                                                                                                                                                                                                                                                                                                                                                                                                                          |
+|:----------------|:---------|:--------|:-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| `--dry-run`     | `bool`   |         | Execute command in dry run mode                                                                                                                                                                                                                                                                                                                                                                                                      |
+| `--format`      | `string` | `table` | Format output using a custom template:<br>'table':            Print output in table format with column headers (default)<br>'table TEMPLATE':   Print output in table format using the given Go template<br>'json':             Print in JSON format<br>'TEMPLATE':         Print output using the given Go template.<br>Refer to https://docs.docker.com/go/formatting/ for more information about formatting output with templates |
+| `-q`, `--quiet` | `bool`   |         | Only display volume names                                                                                                                                                                                                                                                                                                                                                                                                            |
+
+
+<!---MARKER_GEN_END-->
+
diff --git a/docs/reference/docker_compose.yaml b/docs/reference/docker_compose.yaml
index 93332702c..02a39d932 100644
--- a/docs/reference/docker_compose.yaml
+++ b/docs/reference/docker_compose.yaml
@@ -37,6 +37,7 @@ cname:
     - docker compose unpause
     - docker compose up
     - docker compose version
+    - docker compose volumes
     - docker compose wait
     - docker compose watch
 clink:
@@ -72,6 +73,7 @@ clink:
     - docker_compose_unpause.yaml
     - docker_compose_up.yaml
     - docker_compose_version.yaml
+    - docker_compose_volumes.yaml
     - docker_compose_wait.yaml
     - docker_compose_watch.yaml
 options:
diff --git a/docs/reference/docker_compose_volumes.yaml b/docs/reference/docker_compose_volumes.yaml
new file mode 100644
index 000000000..20516db7f
--- /dev/null
+++ b/docs/reference/docker_compose_volumes.yaml
@@ -0,0 +1,52 @@
+command: docker compose volumes
+short: List volumes
+long: List volumes
+usage: docker compose volumes [OPTIONS] [SERVICE...]
+pname: docker compose
+plink: docker_compose.yaml
+options:
+    - option: format
+      value_type: string
+      default_value: table
+      description: |-
+        Format output using a custom template:
+        'table':            Print output in table format with column headers (default)
+        'table TEMPLATE':   Print output in table format using the given Go template
+        'json':             Print in JSON format
+        'TEMPLATE':         Print output using the given Go template.
+        Refer to https://docs.docker.com/go/formatting/ for more information about formatting output with templates
+      deprecated: false
+      hidden: false
+      experimental: false
+      experimentalcli: false
+      kubernetes: false
+      swarm: false
+    - option: quiet
+      shorthand: q
+      value_type: bool
+      default_value: "false"
+      description: Only display volume names
+      deprecated: false
+      hidden: false
+      experimental: false
+      experimentalcli: false
+      kubernetes: false
+      swarm: false
+inherited_options:
+    - option: dry-run
+      value_type: bool
+      default_value: "false"
+      description: Execute command in dry run mode
+      deprecated: false
+      hidden: false
+      experimental: false
+      experimentalcli: false
+      kubernetes: false
+      swarm: false
+deprecated: false
+hidden: false
+experimental: false
+experimentalcli: false
+kubernetes: false
+swarm: false
+
diff --git a/pkg/api/api.go b/pkg/api/api.go
index 1be883899..d3e8f0e9f 100644
--- a/pkg/api/api.go
+++ b/pkg/api/api.go
@@ -26,6 +26,7 @@ import (
 	"github.com/compose-spec/compose-go/v2/types"
 	"github.com/containerd/platforms"
 	"github.com/docker/cli/opts"
+	"github.com/docker/docker/api/types/volume"
 )
 
 // Service manages a compose project
@@ -98,8 +99,16 @@ type Service interface {
 	Commit(ctx context.Context, projectName string, options CommitOptions) error
 	// Generate generates a Compose Project from existing containers
 	Generate(ctx context.Context, options GenerateOptions) (*types.Project, error)
+	// Volumes executes the equivalent to a `docker volume ls`
+	Volumes(ctx context.Context, project *types.Project, options VolumesOptions) ([]VolumesSummary, error)
 }
 
+type VolumesOptions struct {
+	Services []string
+}
+
+type VolumesSummary = *volume.Volume
+
 type ScaleOptions struct {
 	Services []string
 }
diff --git a/pkg/compose/volumes.go b/pkg/compose/volumes.go
new file mode 100644
index 000000000..8c7bbed2f
--- /dev/null
+++ b/pkg/compose/volumes.go
@@ -0,0 +1,85 @@
+/*
+   Copyright 2020 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"
+	"slices"
+
+	"github.com/compose-spec/compose-go/v2/types"
+	"github.com/docker/compose/v2/pkg/api"
+	"github.com/docker/docker/api/types/container"
+	"github.com/docker/docker/api/types/filters"
+	"github.com/docker/docker/api/types/volume"
+)
+
+func (s *composeService) Volumes(ctx context.Context, project *types.Project, options api.VolumesOptions) ([]api.VolumesSummary, error) {
+	projectName := project.Name
+
+	allContainers, err := s.apiClient().ContainerList(ctx, container.ListOptions{
+		Filters: filters.NewArgs(projectFilter(projectName)),
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	var containers []container.Summary
+
+	if len(options.Services) > 0 {
+		// filter service containers
+		for _, c := range allContainers {
+			if slices.Contains(options.Services, c.Labels[api.ServiceLabel]) {
+				containers = append(containers, c)
+			}
+		}
+	} else {
+		containers = allContainers
+	}
+
+	volumesResponse, err := s.apiClient().VolumeList(ctx, volume.ListOptions{
+		Filters: filters.NewArgs(projectFilter(projectName)),
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	projectVolumes := volumesResponse.Volumes
+
+	if len(options.Services) == 0 {
+		return projectVolumes, nil
+	}
+
+	var volumes []api.VolumesSummary
+
+	// create a name lookup of volumes used by containers
+	serviceVolumes := make(map[string]bool)
+
+	for _, container := range containers {
+		for _, mount := range container.Mounts {
+			serviceVolumes[mount.Name] = true
+		}
+	}
+
+	// append if volumes in this project are in serviceVolumes
+	for _, v := range projectVolumes {
+		if serviceVolumes[v.Name] {
+			volumes = append(volumes, v)
+		}
+	}
+
+	return volumes, nil
+}
diff --git a/pkg/mocks/mock_docker_compose_api.go b/pkg/mocks/mock_docker_compose_api.go
index 811187ea7..6bb065f34 100644
--- a/pkg/mocks/mock_docker_compose_api.go
+++ b/pkg/mocks/mock_docker_compose_api.go
@@ -497,6 +497,21 @@ func (mr *MockServiceMockRecorder) Viz(ctx, project, options any) *gomock.Call {
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Viz", reflect.TypeOf((*MockService)(nil).Viz), ctx, project, options)
 }
 
+// Volumes mocks base method.
+func (m *MockService) Volumes(ctx context.Context, project *types.Project, options api.VolumesOptions) ([]api.VolumesSummary, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Volumes", ctx, project, options)
+	ret0, _ := ret[0].([]api.VolumesSummary)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// Volumes indicates an expected call of Volumes.
+func (mr *MockServiceMockRecorder) Volumes(ctx, project, options any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Volumes", reflect.TypeOf((*MockService)(nil).Volumes), ctx, project, options)
+}
+
 // Wait mocks base method.
 func (m *MockService) Wait(ctx context.Context, projectName string, options api.WaitOptions) (int64, error) {
 	m.ctrl.T.Helper()


Test patch:
diff --git a/pkg/compose/volumes_test.go b/pkg/compose/volumes_test.go
new file mode 100644
index 000000000..8e149159b
--- /dev/null
+++ b/pkg/compose/volumes_test.go
@@ -0,0 +1,89 @@
+/*
+   Copyright 2020 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/compose/v2/pkg/api"
+	"github.com/docker/docker/api/types/container"
+	"github.com/docker/docker/api/types/filters"
+	"github.com/docker/docker/api/types/volume"
+	"go.uber.org/mock/gomock"
+	"gotest.tools/v3/assert"
+)
+
+func TestVolumes(t *testing.T) {
+	mockCtrl := gomock.NewController(t)
+	defer mockCtrl.Finish()
+
+	mockApi, mockCli := prepareMocks(mockCtrl)
+	tested := composeService{
+		dockerCli: mockCli,
+	}
+
+	// Create test volumes
+	vol1 := &volume.Volume{Name: testProject + "_vol1"}
+	vol2 := &volume.Volume{Name: testProject + "_vol2"}
+	vol3 := &volume.Volume{Name: testProject + "_vol3"}
+
+	// Create test containers with volume mounts
+	c1 := container.Summary{
+		Labels: map[string]string{api.ServiceLabel: "service1"},
+		Mounts: []container.MountPoint{
+			{Name: testProject + "_vol1"},
+			{Name: testProject + "_vol2"},
+		},
+	}
+	c2 := container.Summary{
+		Labels: map[string]string{api.ServiceLabel: "service2"},
+		Mounts: []container.MountPoint{
+			{Name: testProject + "_vol3"},
+		},
+	}
+
+	ctx := context.Background()
+	project := &types.Project{Name: testProject}
+	args := filters.NewArgs(projectFilter(testProject))
+	listOpts := container.ListOptions{Filters: args}
+	volumeListArgs := filters.NewArgs(projectFilter(testProject))
+	volumeListOpts := volume.ListOptions{Filters: volumeListArgs}
+	volumeReturn := volume.ListResponse{
+		Volumes: []*volume.Volume{vol1, vol2, vol3},
+	}
+	containerReturn := []container.Summary{c1, c2}
+
+	// Mock API calls
+	mockApi.EXPECT().ContainerList(ctx, listOpts).Times(2).Return(containerReturn, nil)
+	mockApi.EXPECT().VolumeList(ctx, volumeListOpts).Times(2).Return(volumeReturn, nil)
+
+	// Test without service filter - should return all project volumes
+	volumeOptions := api.VolumesOptions{}
+	volumes, err := tested.Volumes(ctx, project, volumeOptions)
+	expected := []api.VolumesSummary{vol1, vol2, vol3}
+	assert.NilError(t, err)
+	assert.DeepEqual(t, volumes, expected)
+
+	// Test with service filter - should only return volumes used by service1
+	volumeOptions = api.VolumesOptions{Services: []string{"service1"}}
+	volumes, err = tested.Volumes(ctx, project, volumeOptions)
+	expected = []api.VolumesSummary{vol1, vol2}
+	assert.NilError(t, err)
+	assert.DeepEqual(t, volumes, expected)
+}