From b9d0c77cde8d81d1f527db49cefb00b0dae0cf8e Mon Sep 17 00:00:00 2001
From: MohammadHasan Akbari <116190942+jarqvi@users.noreply.github.com>
Date: Tue, 17 Sep 2024 22:13:13 +0330
Subject: [PATCH] feat: add export command

Signed-off-by: MohammadHasan Akbari <jarqvi.jarqvi@gmail.com>
---
 cmd/compose/compose.go                    |  1 +
 cmd/compose/export.go                     | 74 ++++++++++++++++++
 docs/reference/compose.md                 |  1 +
 docs/reference/compose_export.md          | 16 ++++
 docs/reference/docker_compose.yaml        |  2 +
 docs/reference/docker_compose_export.yaml | 45 +++++++++++
 pkg/api/api.go                            |  9 +++
 pkg/compose/export.go                     | 93 +++++++++++++++++++++++
 pkg/e2e/export_test.go                    | 51 +++++++++++++
 pkg/e2e/fixtures/export/compose.yaml      |  9 +++
 pkg/mocks/mock_docker_compose_api.go      | 14 ++++
 11 files changed, 315 insertions(+)
 create mode 100644 cmd/compose/export.go
 create mode 100644 docs/reference/compose_export.md
 create mode 100644 docs/reference/docker_compose_export.yaml
 create mode 100644 pkg/compose/export.go
 create mode 100644 pkg/e2e/export_test.go
 create mode 100644 pkg/e2e/fixtures/export/compose.yaml

diff --git a/cmd/compose/compose.go b/cmd/compose/compose.go
index 080d25f76..78b7154cc 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 000000000..8ad08b7d2
--- /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 bb376edfc..5a69a01b5 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 000000000..942ea6a34
--- /dev/null
+++ b/docs/reference/compose_export.md
@@ -0,0 +1,16 @@
+# docker compose export
+
+<!---MARKER_GEN_START-->
+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                       |
+
+
+<!---MARKER_GEN_END-->
+
diff --git a/docs/reference/docker_compose.yaml b/docs/reference/docker_compose.yaml
index 2c927b1d3..f59ec4a04 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 000000000..5dfb3be0a
--- /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 4ae36ed3b..3fc1e5721 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 000000000..8a7248959
--- /dev/null
+++ b/pkg/compose/export.go
@@ -0,0 +1,93 @@
+/*
+   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"
+	"github.com/pkg/errors"
+)
+
+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 errors.New("output option is required when exporting to terminal")
+	}
+
+	if err := command.ValidateOutputPath(options.Output); err != nil {
+		return errors.Wrap(err, "failed to export container")
+	}
+
+	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 responseBody.Close()
+
+	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 000000000..58e57b576
--- /dev/null
+++ b/pkg/e2e/export_test.go
@@ -0,0 +1,51 @@
+/*
+   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 000000000..28e4b15bd
--- /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 1390a85fb..858c6e7b1 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()