diff --git a/cmd/compose/compose.go b/cmd/compose/compose.go index 080d25f765..78b7154cce 100644 --- a/cmd/compose/compose.go +++ b/cmd/compose/compose.go @@ -594,6 +594,7 @@ func RootCommand(dockerCli command.Cli, backend Backend) *cobra.Command { //noli removeCommand(&opts, dockerCli, backend), execCommand(&opts, dockerCli, backend), attachCommand(&opts, dockerCli, backend), + exportCommand(&opts, dockerCli, backend), pauseCommand(&opts, dockerCli, backend), unpauseCommand(&opts, dockerCli, backend), topCommand(&opts, dockerCli, backend), diff --git a/cmd/compose/export.go b/cmd/compose/export.go new file mode 100644 index 0000000000..8ad08b7d2a --- /dev/null +++ b/cmd/compose/export.go @@ -0,0 +1,74 @@ +/* + 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" + + "github.com/docker/cli/cli/command" + "github.com/spf13/cobra" + + "github.com/docker/compose/v2/pkg/api" +) + +type exportOptions struct { + *ProjectOptions + + service string + output string + index int +} + +func exportCommand(p *ProjectOptions, dockerCli command.Cli, backend api.Service) *cobra.Command { + options := exportOptions{ + ProjectOptions: p, + } + cmd := &cobra.Command{ + Use: "export [OPTIONS] SERVICE", + Short: "Export a service container's filesystem as a tar archive", + Args: cobra.MinimumNArgs(1), + PreRunE: Adapt(func(ctx context.Context, args []string) error { + options.service = args[0] + return nil + }), + RunE: Adapt(func(ctx context.Context, args []string) error { + return runExport(ctx, dockerCli, backend, options) + }), + ValidArgsFunction: completeServiceNames(dockerCli, p), + } + + flags := cmd.Flags() + flags.IntVar(&options.index, "index", 0, "index of the container if service has multiple replicas.") + flags.StringVarP(&options.output, "output", "o", "", "Write to a file, instead of STDOUT") + + return cmd +} + +func runExport(ctx context.Context, dockerCli command.Cli, backend api.Service, options exportOptions) error { + projectName, err := options.toProjectName(ctx, dockerCli) + if err != nil { + return err + } + + exportOptions := api.ExportOptions{ + Service: options.service, + Index: options.index, + Output: options.output, + } + + return backend.Export(ctx, projectName, exportOptions) +} diff --git a/docs/reference/compose.md b/docs/reference/compose.md index bb376edfcd..5a69a01b50 100644 --- a/docs/reference/compose.md +++ b/docs/reference/compose.md @@ -19,6 +19,7 @@ Define and run multi-container applications with Docker | [`down`](compose_down.md) | Stop and remove containers, networks | | [`events`](compose_events.md) | Receive real time events from containers | | [`exec`](compose_exec.md) | Execute a command in a running container | +| [`export`](compose_export.md) | Export a service container's filesystem as a tar archive | | [`images`](compose_images.md) | List images used by the created containers | | [`kill`](compose_kill.md) | Force stop service containers | | [`logs`](compose_logs.md) | View output from containers | diff --git a/docs/reference/compose_export.md b/docs/reference/compose_export.md new file mode 100644 index 0000000000..942ea6a347 --- /dev/null +++ b/docs/reference/compose_export.md @@ -0,0 +1,16 @@ +# docker compose export + + +Export a service container's filesystem as a tar archive + +### Options + +| Name | Type | Default | Description | +|:-----------------|:---------|:--------|:---------------------------------------------------------| +| `--dry-run` | `bool` | | Execute command in dry run mode | +| `--index` | `int` | `0` | index of the container if service has multiple replicas. | +| `-o`, `--output` | `string` | | Write to a file, instead of STDOUT | + + + + diff --git a/docs/reference/docker_compose.yaml b/docs/reference/docker_compose.yaml index 2c927b1d3c..f59ec4a04b 100644 --- a/docs/reference/docker_compose.yaml +++ b/docs/reference/docker_compose.yaml @@ -13,6 +13,7 @@ cname: - docker compose down - docker compose events - docker compose exec + - docker compose export - docker compose images - docker compose kill - docker compose logs @@ -44,6 +45,7 @@ clink: - docker_compose_down.yaml - docker_compose_events.yaml - docker_compose_exec.yaml + - docker_compose_export.yaml - docker_compose_images.yaml - docker_compose_kill.yaml - docker_compose_logs.yaml diff --git a/docs/reference/docker_compose_export.yaml b/docs/reference/docker_compose_export.yaml new file mode 100644 index 0000000000..5dfb3be0a4 --- /dev/null +++ b/docs/reference/docker_compose_export.yaml @@ -0,0 +1,45 @@ +command: docker compose export +short: Export a service container's filesystem as a tar archive +long: Export a service container's filesystem as a tar archive +usage: docker compose export [OPTIONS] SERVICE +pname: docker compose +plink: docker_compose.yaml +options: + - option: index + value_type: int + default_value: "0" + description: index of the container if service has multiple replicas. + deprecated: false + hidden: false + experimental: false + experimentalcli: false + kubernetes: false + swarm: false + - option: output + shorthand: o + value_type: string + description: Write to a file, instead of STDOUT + 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 4ae36ed3be..3fc1e57217 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -90,6 +90,8 @@ type Service interface { Wait(ctx context.Context, projectName string, options WaitOptions) (int64, error) // Scale manages numbers of container instances running per service Scale(ctx context.Context, project *types.Project, options ScaleOptions) error + // Export a service container's filesystem as a tar archive + Export(ctx context.Context, projectName string, options ExportOptions) error } type ScaleOptions struct { @@ -553,6 +555,13 @@ type PauseOptions struct { Project *types.Project } +// ExportOptions group options of the Export API +type ExportOptions struct { + Service string + Index int + Output string +} + const ( // STARTING indicates that stack is being deployed STARTING string = "Starting" diff --git a/pkg/compose/export.go b/pkg/compose/export.go new file mode 100644 index 0000000000..795208ae19 --- /dev/null +++ b/pkg/compose/export.go @@ -0,0 +1,101 @@ +/* + 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" + "io" + "strings" + + "github.com/docker/cli/cli/command" + "github.com/docker/compose/v2/pkg/api" + "github.com/docker/compose/v2/pkg/progress" +) + +func (s *composeService) Export(ctx context.Context, projectName string, options api.ExportOptions) error { + return progress.RunWithTitle(ctx, func(ctx context.Context) error { + return s.export(ctx, projectName, options) + }, s.stdinfo(), "Exporting") +} + +func (s *composeService) export(ctx context.Context, projectName string, options api.ExportOptions) error { + projectName = strings.ToLower(projectName) + + container, err := s.getSpecifiedContainer(ctx, projectName, oneOffInclude, false, options.Service, options.Index) + if err != nil { + return err + } + + if options.Output == "" && s.dockerCli.Out().IsTerminal() { + return fmt.Errorf("output option is required when exporting to terminal") + } + + if err := command.ValidateOutputPath(options.Output); err != nil { + return fmt.Errorf("failed to export container: %w", err) + } + + clnt := s.dockerCli.Client() + + w := progress.ContextWriter(ctx) + + name := getCanonicalContainerName(container) + msg := fmt.Sprintf("export %s to %s", name, options.Output) + + w.Event(progress.Event{ + ID: name, + Text: msg, + Status: progress.Working, + StatusText: "Exporting", + }) + + responseBody, err := clnt.ContainerExport(ctx, container.ID) + if err != nil { + return err + } + + defer func() { + if err := responseBody.Close(); err != nil { + w.Event(progress.Event{ + ID: name, + Text: msg, + Status: progress.Error, + StatusText: fmt.Sprintf("Failed to close response body: %v", err), + }) + } + }() + + if !s.dryRun { + if options.Output == "" { + _, err := io.Copy(s.dockerCli.Out(), responseBody) + return err + } + + if err := command.CopyToFile(options.Output, responseBody); err != nil { + return err + } + } + + w.Event(progress.Event{ + ID: name, + Text: msg, + Status: progress.Done, + StatusText: "Exported", + }) + + return nil +} diff --git a/pkg/e2e/export_test.go b/pkg/e2e/export_test.go new file mode 100644 index 0000000000..baa0dc5b94 --- /dev/null +++ b/pkg/e2e/export_test.go @@ -0,0 +1,50 @@ +/* + Copyright 2023 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 e2e + +import ( + "testing" +) + +func TestExport(t *testing.T) { + const projectName = "e2e-export-service" + c := NewParallelCLI(t) + + cleanup := func() { + c.RunDockerComposeCmd(t, "--project-name", projectName, "down", "--timeout=0", "--remove-orphans") + } + t.Cleanup(cleanup) + cleanup() + + c.RunDockerComposeCmd(t, "-f", "./fixtures/export/compose.yaml", "--project-name", projectName, "up", "-d", "service") + c.RunDockerComposeCmd(t, "--project-name", projectName, "export", "-o", "service.tar", "service") +} + +func TestExportWithReplicas(t *testing.T) { + const projectName = "e2e-export-service-with-replicas" + c := NewParallelCLI(t) + + cleanup := func() { + c.RunDockerComposeCmd(t, "--project-name", projectName, "down", "--timeout=0", "--remove-orphans") + } + t.Cleanup(cleanup) + cleanup() + + c.RunDockerComposeCmd(t, "-f", "./fixtures/export/compose.yaml", "--project-name", projectName, "up", "-d", "service-with-replicas") + c.RunDockerComposeCmd(t, "--project-name", projectName, "export", "-o", "r1.tar", "--index=1", "service-with-replicas") + c.RunDockerComposeCmd(t, "--project-name", projectName, "export", "-o", "r2.tar", "--index=2", "service-with-replicas") +} diff --git a/pkg/e2e/fixtures/export/compose.yaml b/pkg/e2e/fixtures/export/compose.yaml new file mode 100644 index 0000000000..28e4b15bd6 --- /dev/null +++ b/pkg/e2e/fixtures/export/compose.yaml @@ -0,0 +1,9 @@ +services: + service: + image: alpine + command: sleep infinity + service-with-replicas: + image: alpine + command: sleep infinity + deploy: + replicas: 3 \ No newline at end of file diff --git a/pkg/mocks/mock_docker_compose_api.go b/pkg/mocks/mock_docker_compose_api.go index 1390a85fb7..858c6e7b13 100644 --- a/pkg/mocks/mock_docker_compose_api.go +++ b/pkg/mocks/mock_docker_compose_api.go @@ -155,6 +155,20 @@ func (mr *MockServiceMockRecorder) Exec(ctx, projectName, options any) *gomock.C return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Exec", reflect.TypeOf((*MockService)(nil).Exec), ctx, projectName, options) } +// Export mocks base method. +func (m *MockService) Export(ctx context.Context, projectName string, options api.ExportOptions) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Export", ctx, projectName, options) + ret0, _ := ret[0].(error) + return ret0 +} + +// Export indicates an expected call of Export. +func (mr *MockServiceMockRecorder) Export(ctx, projectName, options any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Export", reflect.TypeOf((*MockService)(nil).Export), ctx, projectName, options) +} + // Images mocks base method. func (m *MockService) Images(ctx context.Context, projectName string, options api.ImagesOptions) ([]api.ImageSummary, error) { m.ctrl.T.Helper()