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..fdcfe844376 --- /dev/null +++ b/cmd/nerdctl/container/container_export.go @@ -0,0 +1,77 @@ +package container + +import ( + "fmt" + "os" + + "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" + "github.com/mattn/go-isatty" + "github.com/spf13/cobra" +) + +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..256ec08fb20 --- /dev/null +++ b/cmd/nerdctl/container/container_export_test.go @@ -0,0 +1,156 @@ +/* + 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..0831c4b2910 --- /dev/null +++ b/pkg/cmd/container/export.go @@ -0,0 +1,162 @@ +/* + 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" + "os/exec" + + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/core/containers" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/containerutil" + "github.com/containerd/nerdctl/v2/pkg/idutil/containerwalker" + "github.com/containerd/nerdctl/v2/pkg/tarutil" +) + +// 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 { + // Try to get a running container root first + root, pid, err := getContainerRoot(ctx, container) + var cleanup func() error + + if err != nil { + // Container is not running, try to mount the snapshot + var conInfo containers.Container + conInfo, err = container.Info(ctx) + if err != nil { + return fmt.Errorf("failed to get container info: %w", err) + } + + root, cleanup, err = containerutil.MountSnapshotForContainer(ctx, client, conInfo, options.GOptions.Snapshotter) + if cleanup != nil { + defer func() { + if cleanupErr := cleanup(); cleanupErr != nil { + log.G(ctx).WithError(cleanupErr).Warn("Failed to cleanup mounted snapshot") + } + }() + } + + if err != nil { + return fmt.Errorf("failed to mount container snapshot: %w", err) + } + log.G(ctx).Debugf("Mounted snapshot at %s", root) + // For stopped containers, set pid to 0 to avoid nsenter + pid = 0 + } else { + log.G(ctx).Debugf("Using running container root %s (pid %d)", root, pid) + } + + // Create tar command to export the rootfs + return createTarArchive(ctx, root, pid, options) +} + +func getContainerRoot(ctx context.Context, container containerd.Container) (string, int, error) { + task, err := container.Task(ctx, nil) + if err != nil { + return "", 0, err + } + + status, err := task.Status(ctx) + if err != nil { + return "", 0, err + } + + if status.Status != containerd.Running { + return "", 0, fmt.Errorf("container is not running") + } + + pid := int(task.Pid()) + return fmt.Sprintf("/proc/%d/root", pid), pid, nil +} + +func createTarArchive(ctx context.Context, rootPath string, pid int, options types.ContainerExportOptions) error { + tarBinary, isGNUTar, tar_err := tarutil.FindTarBinary() + if tar_err != nil { + return tar_err + } + log.G(ctx).Debugf("Detected tar binary %q (GNU=%v)", tarBinary, isGNUTar) + + // For now, use direct tar access. nsenter may have permission issues in rootless mode. + tarArgs := []string{"-c", "-f", "-", "-C", rootPath, "."} + cmd := exec.CommandContext(ctx, tarBinary, tarArgs...) + + log.G(ctx).Debugf("Using tar directly: %s %v", cmd.Path, cmd.Args) + + cmd.Stdout = options.Stdout + + // For running containers (pid > 0), suppress stderr entirely as virtual filesystem + // errors are expected and not useful to the user. For stopped containers, show stderr + // as those errors might be legitimate issues. + if pid > 0 { + // Running container - suppress all stderr by redirecting to /dev/null + devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + if err != nil { + return fmt.Errorf("failed to open /dev/null: %w", err) + } + defer devNull.Close() + cmd.Stderr = devNull + log.G(ctx).Debugf("Suppressing stderr for running container export (virtual filesystem errors expected)") + } else { + // Stopped container - show stderr as normal + cmd.Stderr = os.Stderr + } + + err := cmd.Run() + + // When exporting running containers, tar may fail with exit code 2 due to + // permission issues with virtual filesystems like /proc and /sys. + // This is expected behavior and should not cause the export to fail. + if err != nil { + if exitError, ok := err.(*exec.ExitError); ok { + // Exit code 2 typically indicates "fatal error" but in the context + // of exporting containers, it's often due to permission denied errors + // on virtual filesystems which are expected and acceptable. + if exitError.ExitCode() == 2 && pid > 0 { + log.G(ctx).Debugf("tar exited with code 2, likely due to permission issues with virtual filesystems (expected for running containers)") + return nil + } + } + return err + } + + 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 {