diff --git a/cmd/compose/compose.go b/cmd/compose/compose.go index 10b53e53f85..2be3ab15738 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 00000000000..d98f71db326 --- /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 391bf1a9705..74d129d832f 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 00000000000..6bad874f187 --- /dev/null +++ b/docs/reference/compose_volumes.md @@ -0,0 +1,16 @@ +# docker compose volumes + + +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:
'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 | +| `-q`, `--quiet` | `bool` | | Only display volume names | + + + + diff --git a/docs/reference/docker_compose.yaml b/docs/reference/docker_compose.yaml index 93332702cda..02a39d93232 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 00000000000..20516db7f13 --- /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 1be883899c6..d3e8f0e9fe4 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 00000000000..8c7bbed2f6f --- /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/compose/volumes_test.go b/pkg/compose/volumes_test.go new file mode 100644 index 00000000000..8e149159b23 --- /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) +} diff --git a/pkg/mocks/mock_docker_compose_api.go b/pkg/mocks/mock_docker_compose_api.go index 811187ea721..6bb065f342e 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()