Skip to content

Commit 92a7d9a

Browse files
authored
Merge pull request #4704 from sondavidb/add-container-cp-tarballs
feat: add support for container cp with tarballs
2 parents deec874 + 97facf2 commit 92a7d9a

File tree

6 files changed

+266
-47
lines changed

6 files changed

+266
-47
lines changed

cmd/nerdctl/container/container_cp_linux.go

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -107,12 +107,6 @@ func copyOptions(cmd *cobra.Command, args []string) (types.ContainerCpOptions, e
107107
if srcSpec.Container == nil && destSpec.Container == nil {
108108
return types.ContainerCpOptions{}, fmt.Errorf("one of src or dest must be a container file specification")
109109
}
110-
if srcSpec.Path == "-" {
111-
return types.ContainerCpOptions{}, fmt.Errorf("support for reading a tar archive from stdin is not implemented yet")
112-
}
113-
if destSpec.Path == "-" {
114-
return types.ContainerCpOptions{}, fmt.Errorf("support for writing a tar archive to stdout is not implemented yet")
115-
}
116110

117111
container2host := srcSpec.Container != nil
118112
var containerReq string
@@ -128,6 +122,8 @@ func copyOptions(cmd *cobra.Command, args []string) (types.ContainerCpOptions, e
128122
DestPath: destSpec.Path,
129123
SrcPath: srcSpec.Path,
130124
FollowSymLink: flagL,
125+
FromStdin: srcSpec.Path == "-",
126+
ToStdout: destSpec.Path == "-",
131127
}, nil
132128
}
133129

@@ -138,6 +134,12 @@ func AddCpCommand(rootCmd *cobra.Command) {
138134
var errFileSpecDoesntMatchFormat = errors.New("filespec must match the canonical format: [container:]file/path")
139135

140136
func parseCpFileSpec(arg string) (*copyFileSpec, error) {
137+
if arg == "" {
138+
return &copyFileSpec{
139+
Path: "-",
140+
}, nil
141+
}
142+
141143
i := strings.Index(arg, ":")
142144

143145
// filespec starting with a semicolon is invalid

cmd/nerdctl/container/container_cp_linux_test.go

Lines changed: 191 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ package container
1919
import (
2020
"fmt"
2121
"os"
22+
"os/exec"
2223
"path/filepath"
2324
"strings"
2425
"syscall"
@@ -29,6 +30,7 @@ import (
2930

3031
"github.com/containerd/nerdctl/v2/pkg/containerutil"
3132
"github.com/containerd/nerdctl/v2/pkg/rootlessutil"
33+
"github.com/containerd/nerdctl/v2/pkg/tarutil"
3234
"github.com/containerd/nerdctl/v2/pkg/testutil"
3335
"github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest"
3436
)
@@ -51,7 +53,9 @@ const (
5153
pathIsADirAbsolute = string(os.PathSeparator) + "is-a-dir" + complexify
5254
pathIsAVolumeMount = string(os.PathSeparator) + "is-a-volume-mount" + complexify
5355

54-
srcFileName = "test-file" + complexify
56+
srcFileName = "test-file" + complexify
57+
tarballName = "test-tar" + complexify
58+
cpFolderName = "nerdctl-cp-test"
5559

5660
// Since nerdctl cp must NOT obey container wd, but instead resolve paths against the root, we set this
5761
// explicitly to ensure we do the right thing wrt that.
@@ -400,6 +404,49 @@ func TestCopyToContainer(t *testing.T) {
400404
},
401405
},
402406
},
407+
{
408+
description: "Copying to container, SRC_PATH is stdin",
409+
sourceSpec: "-",
410+
sourceIsAFile: true,
411+
toContainer: true,
412+
testCases: []testcases{
413+
{
414+
description: "DEST_PATH is a directory, relative",
415+
destinationSpec: pathIsADirRelative,
416+
catFile: filepath.Join(pathIsADirRelative, srcFileName),
417+
setup: func(base *testutil.Base, container string, destPath string) {
418+
base.Cmd("exec", container, "mkdir", "-p", destPath).AssertOK()
419+
},
420+
},
421+
{
422+
description: "DEST_PATH is a directory, absolute",
423+
destinationSpec: pathIsADirAbsolute,
424+
catFile: filepath.Join(pathIsADirAbsolute, srcFileName),
425+
setup: func(base *testutil.Base, container string, destPath string) {
426+
base.Cmd("exec", container, "mkdir", "-p", destPath).AssertOK()
427+
},
428+
},
429+
{
430+
description: "DEST_PATH is stdout",
431+
destinationSpec: "-",
432+
expect: icmd.Expected{
433+
ExitCode: 1,
434+
Err: "one of src or dest must be a container file specification",
435+
},
436+
},
437+
{
438+
description: "DEST_PATH is a file",
439+
destinationSpec: pathIsAFileAbsolute,
440+
setup: func(base *testutil.Base, container string, destPath string) {
441+
base.Cmd("exec", container, "touch", destPath).AssertOK()
442+
},
443+
expect: icmd.Expected{
444+
ExitCode: 1,
445+
Err: containerutil.ErrCannotCopyDirToFile.Error(),
446+
},
447+
},
448+
},
449+
},
403450
}
404451

405452
for _, tg := range testGroups {
@@ -540,6 +587,19 @@ func TestCopyFromContainer(t *testing.T) {
540587
assert.NilError(t, err)
541588
},
542589
},
590+
{
591+
description: "DEST_PATH is stdout",
592+
destinationSpec: "-",
593+
// Extra dir to account for folder created from extracted tar file
594+
catFile: filepath.Join(pathIsADirAbsolute, filepath.Base(srcDirName), srcFileName),
595+
expect: icmd.Expected{
596+
ExitCode: 0,
597+
},
598+
setup: func(base *testutil.Base, container string, destPath string) {
599+
err := os.MkdirAll(destPath, dirPerm)
600+
assert.NilError(t, err)
601+
},
602+
},
543603
},
544604
},
545605
{
@@ -682,6 +742,19 @@ func TestCopyFromContainer(t *testing.T) {
682742
assert.NilError(t, err)
683743
},
684744
},
745+
{
746+
description: "DEST_PATH is stdout",
747+
destinationSpec: "-",
748+
catFile: filepath.Join(pathIsADirAbsolute, srcDirName, srcFileName),
749+
expect: icmd.Expected{
750+
ExitCode: 0,
751+
},
752+
setup: func(base *testutil.Base, container string, destPath string) {
753+
// Don't make the topmost dir as this is where the tarball must extract
754+
err := os.MkdirAll(filepath.Dir(destPath), dirPerm)
755+
assert.NilError(t, err)
756+
},
757+
},
685758
},
686759
},
687760

@@ -713,6 +786,18 @@ func TestCopyFromContainer(t *testing.T) {
713786
assert.NilError(t, err)
714787
},
715788
},
789+
{
790+
description: "DEST_PATH is stdout",
791+
destinationSpec: "-",
792+
catFile: filepath.Join(pathIsADirAbsolute, srcFileName),
793+
expect: icmd.Expected{
794+
ExitCode: 0,
795+
},
796+
setup: func(base *testutil.Base, container string, destPath string) {
797+
err := os.MkdirAll(destPath, dirPerm)
798+
assert.NilError(t, err)
799+
},
800+
},
716801
},
717802
},
718803
}
@@ -749,7 +834,12 @@ func cpTestHelper(t *testing.T, tg *testgroup) {
749834
// Get the source path
750835
groupSourceSpec := tg.sourceSpec
751836
groupSourceDir := groupSourceSpec
752-
if tg.sourceIsAFile {
837+
fromStdin := false
838+
if tg.sourceSpec == "-" {
839+
groupSourceSpec = filepath.Join(srcDirName, tarballName)
840+
groupSourceDir = srcDirName
841+
fromStdin = true
842+
} else if tg.sourceIsAFile {
753843
groupSourceDir = filepath.Dir(groupSourceSpec)
754844
}
755845

@@ -794,25 +884,37 @@ func cpTestHelper(t *testing.T, tg *testgroup) {
794884

795885
// Prepare the specs and derived variables
796886
sourceSpec := groupSourceSpec
887+
catFile := testCase.catFile
888+
797889
destinationSpec := testCase.destinationSpec
890+
toStdout := false
891+
// tarball destination just sets up the dir to extract to
892+
if destinationSpec == "-" {
893+
toStdout = true
894+
destinationSpec = filepath.Dir(catFile)
895+
}
798896

799897
// If the test case does not specify a catFile, start with the destination spec
800-
catFile := testCase.catFile
801898
if catFile == "" {
802899
catFile = destinationSpec
803900
}
804901

805902
sourceFile := filepath.Join(groupSourceDir, srcFileName)
806903
if copyToContainer {
807-
// Use an absolute path for evaluation
808904
if !filepath.IsAbs(catFile) {
809905
catFile = filepath.Join(string(os.PathSeparator), catFile)
810906
}
811-
// If the sourceFile is still relative, make it absolute to the temp
812-
sourceFile = filepath.Join(tempDir, sourceFile)
813-
// If the spec path for source on the host was absolute, make sure we put that under tempDir
814-
if filepath.IsAbs(sourceSpec) {
815-
sourceSpec = tempDir + sourceSpec
907+
908+
if fromStdin {
909+
sourceFile = filepath.Join(tempDir, groupSourceDir, tarballName)
910+
} else {
911+
// Use an absolute path for evaluation
912+
// If the sourceFile is still relative, make it absolute to the temp
913+
sourceFile = filepath.Join(tempDir, sourceFile)
914+
// If the spec path for source on the host was absolute, make sure we put that under tempDir
915+
if filepath.IsAbs(sourceSpec) {
916+
sourceSpec = tempDir + sourceSpec
917+
}
816918
}
817919
} else {
818920
// If we are copying to host, we need to make sure we have an absolute path to cat, relative to temp,
@@ -835,11 +937,29 @@ func cpTestHelper(t *testing.T, tg *testgroup) {
835937
}
836938

837939
createFileOnHost := func() {
838-
// Create file on the host
839-
err := os.MkdirAll(filepath.Dir(sourceFile), dirPerm)
840-
assert.NilError(t, err)
841-
err = os.WriteFile(sourceFile, sourceFileContent, filePerm)
842-
assert.NilError(t, err)
940+
switch fromStdin {
941+
case true:
942+
d := filepath.Dir(sourceFile)
943+
tarCpFolder := filepath.Join(d, cpFolderName)
944+
tarBinary, _, err := tarutil.FindTarBinary()
945+
assert.NilError(t, err)
946+
947+
err = os.MkdirAll(tarCpFolder, dirPerm)
948+
assert.NilError(t, err)
949+
err = os.WriteFile(filepath.Join(tarCpFolder, srcFileName), sourceFileContent, filePerm)
950+
assert.NilError(t, err)
951+
952+
err = exec.Command(tarBinary, "-cf", sourceFile, "-C", tarCpFolder, ".").Run()
953+
assert.NilError(t, err)
954+
err = os.RemoveAll(tarCpFolder)
955+
assert.NilError(t, err)
956+
case false:
957+
// Create file on the host
958+
err := os.MkdirAll(filepath.Dir(sourceFile), dirPerm)
959+
assert.NilError(t, err)
960+
err = os.WriteFile(sourceFile, sourceFileContent, filePerm)
961+
assert.NilError(t, err)
962+
}
843963
}
844964

845965
// Setup: create volume, containers, create the source file
@@ -906,10 +1026,46 @@ func cpTestHelper(t *testing.T, tg *testgroup) {
9061026
// Build the final src and dest specifiers, including `containerXYZ:`
9071027
container := ""
9081028
if copyToContainer {
1029+
if fromStdin {
1030+
if toStdout {
1031+
nerdctlCmd := base.Cmd("cp", "-", "-")
1032+
nerdctlCmd.Run()
1033+
nerdctlCmd.Assert(testCase.expect)
1034+
} else {
1035+
sourceSpec = "-"
1036+
f, err := os.Open(sourceFile)
1037+
assert.NilError(t, err)
1038+
nerdctlCmd := base.Cmd("cp", sourceSpec, containerRunning+":"+destinationSpec)
1039+
nerdctlCmd.Stdin = f
1040+
1041+
nerdctlCmd.Run()
1042+
nerdctlCmd.Assert(testCase.expect)
1043+
f.Close()
1044+
}
1045+
} else {
1046+
base.Cmd("cp", sourceSpec, containerRunning+":"+destinationSpec).Assert(testCase.expect)
1047+
}
9091048
container = containerRunning
910-
base.Cmd("cp", sourceSpec, containerRunning+":"+destinationSpec).Assert(testCase.expect)
9111049
} else {
912-
base.Cmd("cp", containerRunning+":"+sourceSpec, destinationSpec).Assert(testCase.expect)
1050+
nerdctlCmd := base.Cmd("cp", containerRunning+":"+sourceSpec, destinationSpec)
1051+
if toStdout {
1052+
out := nerdctlCmd.Out()
1053+
nerdctlCmd.Assert(testCase.expect)
1054+
1055+
// Since we can't check tar file directly easily, extract to the same destination
1056+
tarDst := filepath.Dir(catFile)
1057+
tarBinary, _, err := tarutil.FindTarBinary()
1058+
assert.NilError(t, err)
1059+
1060+
tarCmd := exec.Command(tarBinary, "-C", tarDst, "-xf", "-")
1061+
tarCmd.Stdin = strings.NewReader(out)
1062+
tarCmd.Stdout = os.Stdout
1063+
1064+
tarCmd.Run()
1065+
assert.NilError(t, tarCmd.Err)
1066+
} else {
1067+
nerdctlCmd.Assert(testCase.expect)
1068+
}
9131069
}
9141070

9151071
// Run the actual test for the running container
@@ -932,19 +1088,32 @@ func cpTestHelper(t *testing.T, tg *testgroup) {
9321088
// ... and for the stopped container
9331089
container = ""
9341090
var cmd *testutil.Cmd
935-
if copyToContainer {
1091+
if fromStdin && toStdout {
1092+
cmd = base.Cmd("cp", "-", "-")
1093+
} else if copyToContainer {
9361094
container = containerStopped
9371095
cmd = base.Cmd("cp", sourceSpec, containerStopped+":"+destinationSpec)
1096+
if fromStdin {
1097+
f, err := os.Open(sourceFile)
1098+
assert.NilError(t, err)
1099+
defer f.Close()
1100+
cmd.Stdin = f
1101+
}
9381102
} else {
9391103
cmd = base.Cmd("cp", containerStopped+":"+sourceSpec, destinationSpec)
9401104
}
9411105

9421106
if rootlessutil.IsRootless() && !nerdtest.IsDocker() {
943-
cmd.Assert(
944-
icmd.Expected{
945-
ExitCode: 1,
946-
Err: containerutil.ErrRootlessCannotCp.Error(),
947-
})
1107+
if fromStdin && toStdout {
1108+
// Regular assert test case should work fine if src and dst are invalid
1109+
cmd.Assert(testCase.expect)
1110+
} else {
1111+
cmd.Assert(
1112+
icmd.Expected{
1113+
ExitCode: 1,
1114+
Err: containerutil.ErrRootlessCannotCp.Error(),
1115+
})
1116+
}
9481117
return
9491118
}
9501119

docs/command-reference.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -494,6 +494,8 @@ Usage:
494494
- `nerdctl cp [OPTIONS] CONTAINER:SRC_PATH DEST_PATH|-`
495495
- `nerdctl cp [OPTIONS] SRC_PATH|- CONTAINER:DEST_PATH`
496496

497+
Using `-` as the `SRC_PATH` streams the contents of `STDIN` as a tar archive. The command extracts the content of the tar to the `DEST_PATH` in container's filesystem. In this case, `DEST_PATH` must specify a directory. Using `-` as the `DEST_PATH` streams the contents of the resource as a tar archive to `STDOUT`.
498+
497499
:warning: `nerdctl cp` is designed only for use with trusted, cooperating containers.
498500
Using `nerdctl cp` with untrusted or malicious containers is unsupported and may not provide protection against unexpected behavior.
499501

pkg/api/types/container_types.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -541,6 +541,10 @@ type ContainerCpOptions struct {
541541
SrcPath string
542542
// Follow symbolic links in SRC_PATH
543543
FollowSymLink bool
544+
// true if copying to container from tarball in stdin
545+
FromStdin bool
546+
// true if copying from container to stdout in tarball format
547+
ToStdout bool
544548
}
545549

546550
// ContainerStatsOptions specifies options for `nerdctl stats`.

0 commit comments

Comments
 (0)