diff --git a/cmd/nerdctl/container/container.go b/cmd/nerdctl/container/container.go index 6188e7013a0..1696874be01 100644 --- a/cmd/nerdctl/container/container.go +++ b/cmd/nerdctl/container/container.go @@ -55,6 +55,7 @@ func Command() *cobra.Command { StatsCommand(), AttachCommand(), HealthCheckCommand(), + ExportCommand(), ) AddCpCommand(cmd) return cmd diff --git a/cmd/nerdctl/container/container_export.go b/cmd/nerdctl/container/container_export.go new file mode 100644 index 00000000000..c7fee9b8f5d --- /dev/null +++ b/cmd/nerdctl/container/container_export.go @@ -0,0 +1,78 @@ +package container + +import ( + "fmt" + "os" + + "github.com/mattn/go-isatty" + "github.com/spf13/cobra" + + "github.com/containerd/nerdctl/v2/cmd/nerdctl/completion" + "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/clientutil" + "github.com/containerd/nerdctl/v2/pkg/cmd/container" +) + +func ExportCommand() *cobra.Command { + var exportCommand = &cobra.Command{ + Use: "export [OPTIONS] CONTAINER", + Args: cobra.ExactArgs(1), + Short: "Export a containers filesystem as a tar archive", + Long: "Export a containers filesystem as a tar archive", + RunE: exportAction, + ValidArgsFunction: exportShellComplete, + SilenceUsage: true, + SilenceErrors: true, + } + exportCommand.Flags().StringP("output", "o", "", "Write to a file, instead of STDOUT") + + return exportCommand +} + +func exportAction(cmd *cobra.Command, args []string) error { + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) + if err != nil { + return err + } + if len(args) == 0 { + return fmt.Errorf("requires at least 1 argument") + } + + output, err := cmd.Flags().GetString("output") + if err != nil { + return err + } + + client, ctx, cancel, err := clientutil.NewClient(cmd.Context(), globalOptions.Namespace, globalOptions.Address) + if err != nil { + return err + } + defer cancel() + + writer := cmd.OutOrStdout() + if output != "" { + f, err := os.OpenFile(output, os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return err + } + defer f.Close() + writer = f + } else { + if isatty.IsTerminal(os.Stdout.Fd()) { + return fmt.Errorf("cowardly refusing to save to a terminal. Use the -o flag or redirect") + } + } + + options := types.ContainerExportOptions{ + Stdout: writer, + GOptions: globalOptions, + } + + return container.Export(ctx, client, args[0], options) +} + +func exportShellComplete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + // show container names + return completion.ContainerNames(cmd, nil) +} diff --git a/cmd/nerdctl/container/container_export_test.go b/cmd/nerdctl/container/container_export_test.go new file mode 100644 index 00000000000..2035f8e54b2 --- /dev/null +++ b/cmd/nerdctl/container/container_export_test.go @@ -0,0 +1,157 @@ +/* + Copyright The containerd 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 container + +import ( + "archive/tar" + "io" + "os" + "path/filepath" + "testing" + + "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/mod/tigron/tig" + + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" +) + +// validateExportedTar checks that the tar file exists and contains /bin/busybox +func validateExportedTar(outFile string) test.Comparator { + return func(stdout string, t tig.T) { + // Check if the tar file was created + _, err := os.Stat(outFile) + assert.Assert(t, !os.IsNotExist(err), "exported tar file %s was not created", outFile) + + // Open and read the tar file to check for /bin/busybox + file, err := os.Open(outFile) + assert.NilError(t, err, "failed to open tar file %s", outFile) + defer file.Close() + + tarReader := tar.NewReader(file) + busyboxFound := false + + for { + header, err := tarReader.Next() + if err == io.EOF { + break + } + assert.NilError(t, err, "failed to read tar entry") + + if header.Name == "bin/busybox" || header.Name == "./bin/busybox" { + busyboxFound = true + break + } + } + + assert.Assert(t, busyboxFound, "exported tar file %s does not contain /bin/busybox", outFile) + t.Log("Export validation passed: tar file exists and contains /bin/busybox") + } +} + +func TestExportStoppedContainer(t *testing.T) { + testCase := nerdtest.Setup() + testCase.Setup = func(data test.Data, helpers test.Helpers) { + identifier := data.Identifier("container") + helpers.Ensure("create", "--name", identifier, testutil.CommonImage) + data.Labels().Set("cID", identifier) + data.Labels().Set("outFile", filepath.Join(os.TempDir(), identifier+".tar")) + } + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("container", "rm", "-f", data.Labels().Get("cID")) + helpers.Anyhow("rm", "-f", data.Labels().Get("cID")) + os.Remove(data.Labels().Get("outFile")) + } + + testCase.SubTests = []*test.Case{ + { + Description: "export command succeeds", + NoParallel: true, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("export", "-o", data.Labels().Get("outFile"), data.Labels().Get("cID")) + }, + Expected: test.Expects(0, nil, nil), + }, + { + Description: "tar file exists and has content", + NoParallel: true, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + // Use a simple command that always succeeds to trigger the validation + return helpers.Custom("echo", "validating tar file") + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: validateExportedTar(data.Labels().Get("outFile")), + } + }, + }, + } + + testCase.Run(t) +} + +func TestExportRunningContainer(t *testing.T) { + testCase := nerdtest.Setup() + testCase.Setup = func(data test.Data, helpers test.Helpers) { + identifier := data.Identifier("container") + helpers.Ensure("run", "-d", "--name", identifier, testutil.CommonImage, "sleep", nerdtest.Infinity) + data.Labels().Set("cID", identifier) + data.Labels().Set("outFile", filepath.Join(os.TempDir(), identifier+".tar")) + } + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Labels().Get("cID")) + os.Remove(data.Labels().Get("outFile")) + } + + testCase.SubTests = []*test.Case{ + { + Description: "export command succeeds", + NoParallel: true, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("export", "-o", data.Labels().Get("outFile"), data.Labels().Get("cID")) + }, + Expected: test.Expects(0, nil, nil), + }, + { + Description: "tar file exists and has content", + NoParallel: true, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + // Use a simple command that always succeeds to trigger the validation + return helpers.Custom("echo", "validating tar file") + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: validateExportedTar(data.Labels().Get("outFile")), + } + }, + }, + } + + testCase.Run(t) +} + +func TestExportNonexistentContainer(t *testing.T) { + testCase := nerdtest.Setup() + testCase.Command = test.Command("export", "nonexistent-container") + testCase.Expected = test.Expects(1, nil, nil) + + testCase.Run(t) +} diff --git a/cmd/nerdctl/main.go b/cmd/nerdctl/main.go index 55cc12c9bd6..019271b47d1 100644 --- a/cmd/nerdctl/main.go +++ b/cmd/nerdctl/main.go @@ -287,6 +287,7 @@ Config file ($NERDCTL_TOML): %s container.PauseCommand(), container.UnpauseCommand(), container.CommitCommand(), + container.ExportCommand(), container.WaitCommand(), container.RenameCommand(), container.AttachCommand(), diff --git a/pkg/api/types/container_types.go b/pkg/api/types/container_types.go index 4583e44d733..3e157bb303d 100644 --- a/pkg/api/types/container_types.go +++ b/pkg/api/types/container_types.go @@ -44,6 +44,13 @@ type ContainerKillOptions struct { KillSignal string } +// ContainerExportOptions specifies options for `nerdctl (container) export`. +type ContainerExportOptions struct { + Stdout io.Writer + // GOptions is the global options + GOptions GlobalCommandOptions +} + // ContainerCreateOptions specifies options for `nerdctl (container) create` and `nerdctl (container) run`. type ContainerCreateOptions struct { Stdout io.Writer diff --git a/pkg/cmd/container/export.go b/pkg/cmd/container/export.go new file mode 100644 index 00000000000..d50d45f50d8 --- /dev/null +++ b/pkg/cmd/container/export.go @@ -0,0 +1,146 @@ +/* + Copyright The containerd 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 container + +import ( + "context" + "fmt" + "os" + + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/core/mount" + "github.com/containerd/containerd/v2/pkg/archive" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/idutil/containerwalker" +) + +// Export exports a container's filesystem as a tar archive +func Export(ctx context.Context, client *containerd.Client, containerReq string, options types.ContainerExportOptions) error { + walker := &containerwalker.ContainerWalker{ + Client: client, + OnFound: func(ctx context.Context, found containerwalker.Found) error { + if found.MatchCount > 1 { + return fmt.Errorf("multiple IDs found with provided prefix: %s", found.Req) + } + return exportContainer(ctx, client, found.Container, options) + }, + } + + n, err := walker.Walk(ctx, containerReq) + if err != nil { + return err + } else if n == 0 { + return fmt.Errorf("no such container %s", containerReq) + } + return nil +} + +func exportContainer(ctx context.Context, client *containerd.Client, container containerd.Container, options types.ContainerExportOptions) error { + // Get container info to access the snapshot + conInfo, err := container.Info(ctx) + if err != nil { + return fmt.Errorf("failed to get container info: %w", err) + } + + // Use the container's snapshot service to get mounts + // This works for both running and stopped containers + sn := client.SnapshotService(conInfo.Snapshotter) + mounts, err := sn.Mounts(ctx, container.ID()) + if err != nil { + return fmt.Errorf("failed to get container mounts: %w", err) + } + + // Create a temporary directory to mount the snapshot + tempDir, err := os.MkdirTemp("", "nerdctl-export-") + if err != nil { + return fmt.Errorf("failed to create temporary mount directory: %w", err) + } + defer os.RemoveAll(tempDir) + + // Mount the container's filesystem + err = mount.All(mounts, tempDir) + if err != nil { + return fmt.Errorf("failed to mount container snapshot: %w", err) + } + defer func() { + if unmountErr := mount.Unmount(tempDir, 0); unmountErr != nil { + log.G(ctx).WithError(unmountErr).Warn("Failed to unmount snapshot") + } + }() + + log.G(ctx).Debugf("Mounted container snapshot at %s", tempDir) + + // Create tar archive using WriteDiff + return createTarArchiveWithWriteDiff(ctx, tempDir, options) +} + +func createTarArchiveWithWriteDiff(ctx context.Context, rootPath string, options types.ContainerExportOptions) error { + // Create a temporary empty directory to use as the "before" state for WriteDiff + emptyDir, err := os.MkdirTemp("", "nerdctl-export-empty-") + if err != nil { + return fmt.Errorf("failed to create temporary empty directory: %w", err) + } + defer os.RemoveAll(emptyDir) + + // Debug logging + log.G(ctx).Debugf("Using WriteDiff to export container filesystem from %s", rootPath) + log.G(ctx).Debugf("Empty directory: %s", emptyDir) + log.G(ctx).Debugf("Output writer type: %T", options.Stdout) + + // Check if the rootPath directory exists and has contents + if entries, err := os.ReadDir(rootPath); err != nil { + log.G(ctx).Debugf("Failed to read rootPath directory %s: %v", rootPath, err) + } else { + log.G(ctx).Debugf("RootPath %s contains %d entries", rootPath, len(entries)) + for i, entry := range entries { + if i < 10 { // Only log first 10 entries to avoid spam + log.G(ctx).Debugf(" - %s (dir: %v)", entry.Name(), entry.IsDir()) + } + } + if len(entries) > 10 { + log.G(ctx).Debugf(" ... and %d more entries", len(entries)-10) + } + } + + // Double check that emptyDir is empty + if entries, err := os.ReadDir(emptyDir); err != nil { + log.G(ctx).Debugf("Failed to read emptyDir directory %s: %v", emptyDir, err) + } else { + log.G(ctx).Debugf("EmptyDir %s contains %d entries", emptyDir, len(entries)) + for i, entry := range entries { + if i < 10 { // Only log first 10 entries to avoid spam + log.G(ctx).Debugf(" - %s (dir: %v)", entry.Name(), entry.IsDir()) + } + } + if len(entries) > 10 { + log.G(ctx).Debugf(" ... and %d more entries", len(entries)-10) + } + } + + // Use WriteDiff to create a tar stream comparing the container rootfs (rootPath) + // with an empty directory (emptyDir). This produces a complete export of the container. + err = archive.WriteDiff(ctx, options.Stdout, emptyDir, rootPath) + if err != nil { + return fmt.Errorf("failed to write tar diff: %w", err) + } + + log.G(ctx).Debugf("WriteDiff completed successfully") + + return nil +} diff --git a/pkg/containerutil/cp_linux.go b/pkg/containerutil/cp_linux.go index 77425aa57be..1007833cd80 100644 --- a/pkg/containerutil/cp_linux.go +++ b/pkg/containerutil/cp_linux.go @@ -129,7 +129,7 @@ func CopyFiles(ctx context.Context, client *containerd.Client, container contain } var cleanup func() error - root, cleanup, err = mountSnapshotForContainer(ctx, client, conInfo, options.GOptions.Snapshotter) + root, cleanup, err = MountSnapshotForContainer(ctx, client, conInfo, options.GOptions.Snapshotter) if cleanup != nil { defer func() { err = errors.Join(err, cleanup()) @@ -321,7 +321,7 @@ func CopyFiles(ctx context.Context, client *containerd.Client, container contain return nil } -func mountSnapshotForContainer(ctx context.Context, client *containerd.Client, conInfo containers.Container, snapshotter string) (string, func() error, error) { +func MountSnapshotForContainer(ctx context.Context, client *containerd.Client, conInfo containers.Container, snapshotter string) (string, func() error, error) { snapKey := conInfo.SnapshotKey resp, err := client.SnapshotService(snapshotter).Mounts(ctx, snapKey) if err != nil {