Skip to content

Commit 15f1649

Browse files
authored
Merge pull request #4405 from craigloewen-msft/main
Added --export command
2 parents eaf5e80 + 3e50703 commit 15f1649

File tree

7 files changed

+432
-1
lines changed

7 files changed

+432
-1
lines changed

cmd/nerdctl/container/container.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ func Command() *cobra.Command {
5555
StatsCommand(),
5656
AttachCommand(),
5757
HealthCheckCommand(),
58+
ExportCommand(),
5859
)
5960
AddCpCommand(cmd)
6061
return cmd
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/*
2+
Copyright The containerd Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package container
18+
19+
import (
20+
"fmt"
21+
"os"
22+
23+
"github.com/mattn/go-isatty"
24+
"github.com/spf13/cobra"
25+
26+
"github.com/containerd/nerdctl/v2/cmd/nerdctl/completion"
27+
"github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers"
28+
"github.com/containerd/nerdctl/v2/pkg/api/types"
29+
"github.com/containerd/nerdctl/v2/pkg/clientutil"
30+
"github.com/containerd/nerdctl/v2/pkg/cmd/container"
31+
)
32+
33+
func ExportCommand() *cobra.Command {
34+
var exportCommand = &cobra.Command{
35+
Use: "export [OPTIONS] CONTAINER",
36+
Args: cobra.ExactArgs(1),
37+
Short: "Export a containers filesystem as a tar archive",
38+
Long: "Export a containers filesystem as a tar archive",
39+
RunE: exportAction,
40+
ValidArgsFunction: exportShellComplete,
41+
SilenceUsage: true,
42+
SilenceErrors: true,
43+
}
44+
exportCommand.Flags().StringP("output", "o", "", "Write to a file, instead of STDOUT")
45+
46+
return exportCommand
47+
}
48+
49+
func exportAction(cmd *cobra.Command, args []string) error {
50+
globalOptions, err := helpers.ProcessRootCmdFlags(cmd)
51+
if err != nil {
52+
return err
53+
}
54+
if len(args) == 0 {
55+
return fmt.Errorf("requires at least 1 argument")
56+
}
57+
58+
output, err := cmd.Flags().GetString("output")
59+
if err != nil {
60+
return err
61+
}
62+
63+
client, ctx, cancel, err := clientutil.NewClient(cmd.Context(), globalOptions.Namespace, globalOptions.Address)
64+
if err != nil {
65+
return err
66+
}
67+
defer cancel()
68+
69+
writer := cmd.OutOrStdout()
70+
if output != "" {
71+
f, err := os.OpenFile(output, os.O_CREATE|os.O_WRONLY, 0644)
72+
if err != nil {
73+
return err
74+
}
75+
defer f.Close()
76+
writer = f
77+
} else {
78+
if isatty.IsTerminal(os.Stdout.Fd()) {
79+
return fmt.Errorf("cowardly refusing to save to a terminal. Use the -o flag or redirect")
80+
}
81+
}
82+
83+
options := types.ContainerExportOptions{
84+
Stdout: writer,
85+
GOptions: globalOptions,
86+
}
87+
88+
return container.Export(ctx, client, args[0], options)
89+
}
90+
91+
func exportShellComplete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
92+
// show container names
93+
return completion.ContainerNames(cmd, nil)
94+
}
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
/*
2+
Copyright The containerd Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package container
18+
19+
import (
20+
"archive/tar"
21+
"io"
22+
"os"
23+
"path/filepath"
24+
"runtime"
25+
"testing"
26+
27+
"gotest.tools/v3/assert"
28+
29+
"github.com/containerd/nerdctl/mod/tigron/test"
30+
"github.com/containerd/nerdctl/mod/tigron/tig"
31+
32+
"github.com/containerd/nerdctl/v2/pkg/testutil"
33+
"github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest"
34+
)
35+
36+
// validateExportedTar checks that the tar file exists and contains /bin/busybox
37+
func validateExportedTar(outFile string) test.Comparator {
38+
return func(stdout string, t tig.T) {
39+
// Check if the tar file was created
40+
_, err := os.Stat(outFile)
41+
assert.Assert(t, !os.IsNotExist(err), "exported tar file %s was not created", outFile)
42+
43+
// Open and read the tar file to check for /bin/busybox
44+
file, err := os.Open(outFile)
45+
assert.NilError(t, err, "failed to open tar file %s", outFile)
46+
defer file.Close()
47+
48+
tarReader := tar.NewReader(file)
49+
busyboxFound := false
50+
51+
for {
52+
header, err := tarReader.Next()
53+
if err == io.EOF {
54+
break
55+
}
56+
assert.NilError(t, err, "failed to read tar entry")
57+
58+
if header.Name == "bin/busybox" || header.Name == "./bin/busybox" {
59+
busyboxFound = true
60+
break
61+
}
62+
}
63+
64+
assert.Assert(t, busyboxFound, "exported tar file %s does not contain /bin/busybox", outFile)
65+
t.Log("Export validation passed: tar file exists and contains /bin/busybox")
66+
}
67+
}
68+
69+
func TestExportStoppedContainer(t *testing.T) {
70+
if runtime.GOOS == "windows" {
71+
t.Skip("export is not supported on Windows")
72+
}
73+
74+
testCase := nerdtest.Setup()
75+
testCase.Setup = func(data test.Data, helpers test.Helpers) {
76+
identifier := data.Identifier("container")
77+
helpers.Ensure("create", "--name", identifier, testutil.CommonImage)
78+
data.Labels().Set("cID", identifier)
79+
data.Labels().Set("outFile", filepath.Join(os.TempDir(), identifier+".tar"))
80+
}
81+
testCase.Cleanup = func(data test.Data, helpers test.Helpers) {
82+
helpers.Anyhow("container", "rm", "-f", data.Labels().Get("cID"))
83+
helpers.Anyhow("rm", "-f", data.Labels().Get("cID"))
84+
os.Remove(data.Labels().Get("outFile"))
85+
}
86+
87+
testCase.SubTests = []*test.Case{
88+
{
89+
Description: "export command succeeds",
90+
NoParallel: true,
91+
Command: func(data test.Data, helpers test.Helpers) test.TestableCommand {
92+
return helpers.Command("export", "-o", data.Labels().Get("outFile"), data.Labels().Get("cID"))
93+
},
94+
Expected: test.Expects(0, nil, nil),
95+
},
96+
{
97+
Description: "tar file exists and has content",
98+
NoParallel: true,
99+
Command: func(data test.Data, helpers test.Helpers) test.TestableCommand {
100+
// Use a simple command that always succeeds to trigger the validation
101+
return helpers.Custom("echo", "validating tar file")
102+
},
103+
Expected: func(data test.Data, helpers test.Helpers) *test.Expected {
104+
return &test.Expected{
105+
ExitCode: 0,
106+
Output: validateExportedTar(data.Labels().Get("outFile")),
107+
}
108+
},
109+
},
110+
}
111+
112+
testCase.Run(t)
113+
}
114+
115+
func TestExportRunningContainer(t *testing.T) {
116+
if runtime.GOOS == "windows" {
117+
t.Skip("export is not supported on Windows")
118+
}
119+
120+
testCase := nerdtest.Setup()
121+
testCase.Setup = func(data test.Data, helpers test.Helpers) {
122+
identifier := data.Identifier("container")
123+
helpers.Ensure("run", "-d", "--name", identifier, testutil.CommonImage, "sleep", nerdtest.Infinity)
124+
data.Labels().Set("cID", identifier)
125+
data.Labels().Set("outFile", filepath.Join(os.TempDir(), identifier+".tar"))
126+
}
127+
testCase.Cleanup = func(data test.Data, helpers test.Helpers) {
128+
helpers.Anyhow("rm", "-f", data.Labels().Get("cID"))
129+
os.Remove(data.Labels().Get("outFile"))
130+
}
131+
132+
testCase.SubTests = []*test.Case{
133+
{
134+
Description: "export command succeeds",
135+
NoParallel: true,
136+
Command: func(data test.Data, helpers test.Helpers) test.TestableCommand {
137+
return helpers.Command("export", "-o", data.Labels().Get("outFile"), data.Labels().Get("cID"))
138+
},
139+
Expected: test.Expects(0, nil, nil),
140+
},
141+
{
142+
Description: "tar file exists and has content",
143+
NoParallel: true,
144+
Command: func(data test.Data, helpers test.Helpers) test.TestableCommand {
145+
// Use a simple command that always succeeds to trigger the validation
146+
return helpers.Custom("echo", "validating tar file")
147+
},
148+
Expected: func(data test.Data, helpers test.Helpers) *test.Expected {
149+
return &test.Expected{
150+
ExitCode: 0,
151+
Output: validateExportedTar(data.Labels().Get("outFile")),
152+
}
153+
},
154+
},
155+
}
156+
157+
testCase.Run(t)
158+
}
159+
160+
func TestExportNonexistentContainer(t *testing.T) {
161+
if runtime.GOOS == "windows" {
162+
t.Skip("export is not supported on Windows")
163+
}
164+
165+
testCase := nerdtest.Setup()
166+
testCase.Command = test.Command("export", "nonexistent-container")
167+
testCase.Expected = test.Expects(1, nil, nil)
168+
169+
testCase.Run(t)
170+
}

cmd/nerdctl/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,7 @@ Config file ($NERDCTL_TOML): %s
288288
container.PauseCommand(),
289289
container.UnpauseCommand(),
290290
container.CommitCommand(),
291+
container.ExportCommand(),
291292
container.WaitCommand(),
292293
container.RenameCommand(),
293294
container.AttachCommand(),

docs/command-reference.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ It does not necessarily mean that the corresponding features are missing in cont
3434
- [:whale: nerdctl attach](#whale-nerdctl-attach)
3535
- [:whale: nerdctl container prune](#whale-nerdctl-container-prune)
3636
- [:whale: nerdctl diff](#whale-nerdctl-diff)
37+
- [:whale: nerdctl export](#whale-nerdctl-export)
3738
- [Build](#build)
3839
- [:whale: nerdctl build](#whale-nerdctl-build)
3940
- [:whale: nerdctl commit](#whale-nerdctl-commit)
@@ -724,6 +725,12 @@ Inspect changes to files or directories on a container's filesystem
724725

725726
Usage: `nerdctl diff CONTAINER`
726727

728+
### :whale: nerdctl export
729+
730+
Export a containers filesystem as a tar archive.
731+
732+
Usage: `nerdctl export CONTAINER`
733+
727734
## Build
728735

729736
### :whale: nerdctl build
@@ -1894,7 +1901,7 @@ Container management:
18941901

18951902
Image:
18961903

1897-
- `docker export` and `docker import`
1904+
- `docker import`
18981905
- `docker trust *` (Instead, nerdctl supports `nerdctl pull --verify=cosign|notation` and `nerdctl push --sign=cosign|notation`. See [`./cosign.md`](./cosign.md) and [`./notation.md`](./notation.md).)
18991906
- `docker manifest *`
19001907

pkg/api/types/container_types.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,13 @@ type ContainerKillOptions struct {
4444
KillSignal string
4545
}
4646

47+
// ContainerExportOptions specifies options for `nerdctl (container) export`.
48+
type ContainerExportOptions struct {
49+
Stdout io.Writer
50+
// GOptions is the global options
51+
GOptions GlobalCommandOptions
52+
}
53+
4754
// ContainerCreateOptions specifies options for `nerdctl (container) create` and `nerdctl (container) run`.
4855
type ContainerCreateOptions struct {
4956
Stdout io.Writer

0 commit comments

Comments
 (0)