Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 103 additions & 6 deletions cmd/nerdctl/container/container_run_mount_image_linux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,10 +95,79 @@ func TestRunMountTypeImageReadOnly(t *testing.T) {
testCase.Run(t)
}

// TestRunMountTypeImageSubpath verifies that image-subpath exposes only the
// selected directory of the image rootfs at the destination: the image's
// /etc/os-release is reachable as <destination>/os-release.
func TestRunMountTypeImageSubpath(t *testing.T) {
testCase := nerdtest.Setup()

testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand {
return helpers.Command("run", "--rm",
"--mount", fmt.Sprintf("type=image,source=%s,destination=/mnt/img,image-subpath=etc", testutil.CommonImage),
testutil.CommonImage, "cat", "/mnt/img/os-release")
}

testCase.Expected = func(data test.Data, helpers test.Helpers) *test.Expected {
return &test.Expected{
ExitCode: expect.ExitCodeSuccess,
Output: expect.Contains("Alpine"),
}
}

testCase.Run(t)
}

// TestRunMountTypeImageSubpathMultiple verifies that two image-subpath mounts of
// the same image at different destinations each expose their own subdirectory,
// exercising the multi-mount label round-trip and cleanup.
func TestRunMountTypeImageSubpathMultiple(t *testing.T) {
testCase := nerdtest.Setup()
// nerdctl-only: Docker keys an image mount by its source image and rejects
// mounting the same image twice ("mount already exists with name").
testCase.Require = require.Not(nerdtest.Docker)

testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand {
return helpers.Command("run", "--rm",
"--mount", fmt.Sprintf("type=image,source=%s,destination=/mnt/etc,image-subpath=etc", testutil.CommonImage),
"--mount", fmt.Sprintf("type=image,source=%s,destination=/mnt/bin,image-subpath=bin", testutil.CommonImage),
testutil.CommonImage, "ls", "/mnt/etc", "/mnt/bin")
}

testCase.Expected = func(data test.Data, helpers test.Helpers) *test.Expected {
return &test.Expected{
ExitCode: expect.ExitCodeSuccess,
}
}

testCase.Run(t)
}

// TestRunMountTypeImageSubpathReadOnly verifies that an image-subpath mount is
// read-only so writing fails. This matches Docker, which also mounts images
// read-only.
func TestRunMountTypeImageSubpathReadOnly(t *testing.T) {
testCase := nerdtest.Setup()

testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand {
return helpers.Command("run", "--rm",
"--mount", fmt.Sprintf("type=image,source=%s,destination=/mnt/img,image-subpath=etc", testutil.CommonImage),
testutil.CommonImage, "touch", "/mnt/img/should-fail")
}

testCase.Expected = func(data test.Data, helpers test.Helpers) *test.Expected {
return &test.Expected{
ExitCode: expect.ExitCodeGenericFail,
Errors: []error{fmt.Errorf("Read-only file system")},
}
}

testCase.Run(t)
}

// TestRunMountTypeImageErrors verifies that an image mount missing its source,
// or using the not-yet-supported image-subpath option, is rejected. Docker
// implements image-subpath, so that case diverges and the test is not run
// against Docker.
// or using the not-yet-supported subpath option, or an image-subpath that
// escapes the rootfs, is rejected. These are nerdctl-specific behaviours here,
// so the test is not run against Docker.
func TestRunMountTypeImageErrors(t *testing.T) {
testCase := nerdtest.Setup()
testCase.Require = require.Not(nerdtest.Docker)
Expand All @@ -118,16 +187,44 @@ func TestRunMountTypeImageErrors(t *testing.T) {
},
},
{
Description: "image-subpath not supported",
Description: "subpath not supported",
Command: func(data test.Data, helpers test.Helpers) test.TestableCommand {
return helpers.Command("run", "--rm",
"--mount", fmt.Sprintf("type=image,source=%s,destination=/mnt/img,subpath=etc", testutil.CommonImage),
testutil.CommonImage, "true")
},
Expected: func(data test.Data, helpers test.Helpers) *test.Expected {
return &test.Expected{
ExitCode: expect.ExitCodeGenericFail,
Errors: []error{fmt.Errorf("subpath")},
}
},
},
{
Description: "image-subpath parent traversal rejected",
Command: func(data test.Data, helpers test.Helpers) test.TestableCommand {
return helpers.Command("run", "--rm",
"--mount", fmt.Sprintf("type=image,source=%s,destination=/mnt/img,image-subpath=../etc", testutil.CommonImage),
testutil.CommonImage, "true")
},
Expected: func(data test.Data, helpers test.Helpers) *test.Expected {
return &test.Expected{
ExitCode: expect.ExitCodeGenericFail,
Errors: []error{fmt.Errorf("escapes")},
}
},
},
{
Description: "image-subpath absolute rejected",
Command: func(data test.Data, helpers test.Helpers) test.TestableCommand {
return helpers.Command("run", "--rm",
"--mount", fmt.Sprintf("type=image,source=%s,destination=/mnt/img,image-subpath=etc", testutil.CommonImage),
"--mount", fmt.Sprintf("type=image,source=%s,destination=/mnt/img,image-subpath=/etc", testutil.CommonImage),
testutil.CommonImage, "true")
},
Expected: func(data test.Data, helpers test.Helpers) *test.Expected {
return &test.Expected{
ExitCode: expect.ExitCodeGenericFail,
Errors: []error{fmt.Errorf("image-subpath")},
Errors: []error{fmt.Errorf("relative")},
}
},
},
Expand Down
2 changes: 1 addition & 1 deletion docs/command-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,7 @@ Volume flags:
- Options specific to `image`:
- :whale: `src`, `source`: image reference (mandatory).
- :whale: Currently, the image filesystem is mounted read-only.
- unimplemented options: `image-subpath`
- :whale: `image-subpath`: relative path inside the image rootfs to mount instead of the whole rootfs. Must stay within the rootfs (no absolute paths or `..` traversal).
- :whale: `--volumes-from`: Mount volumes from the specified container(s), e.g. "--volumes-from my-container".

Rootfs flags:
Expand Down
30 changes: 22 additions & 8 deletions pkg/cmd/container/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,20 +95,24 @@ func Create(ctx context.Context, client *containerd.Client, args []string, netMa
internalLabels.platform = options.Platform
internalLabels.namespace = options.GOptions.Namespace

// If creation fails after image-mount views are created, remove them so the
// snapshots do not leak (the cleanup label is only persisted on success).
// If creation fails after image-mount state is created, tear it down so the
// snapshots and host mounts do not leak (the cleanup labels are only persisted
// on success).
defer func() {
if retErr == nil {
return
}
var keys []string
var keys, hostpaths []string
for _, mp := range internalLabels.mountPoints {
if mp.ImageMountSnapshot != "" {
keys = append(keys, mp.ImageMountSnapshot)
}
if mp.ImageMountHostpath != "" {
hostpaths = append(hostpaths, mp.ImageMountHostpath)
}
}
if len(keys) > 0 {
removeImageMountViews(ctx, client.SnapshotService(options.GOptions.Snapshotter), keys)
if len(keys) > 0 || len(hostpaths) > 0 {
removeImageMounts(ctx, client.SnapshotService(options.GOptions.Snapshotter), hostpaths, keys)
}
}()

Expand Down Expand Up @@ -886,13 +890,16 @@ func withInternalLabels(internalLabels internalLabels) (containerd.NewContainerO
m[labels.AnonymousVolumes] = string(anonVolumeJSON)
}

// Record the snapshot keys of any type=image mount views so they can be
// removed when the container is deleted.
var imageMountSnapshots []string
// Record the snapshot keys and host materialization paths of any type=image
// mounts so they can be removed when the container is deleted.
var imageMountSnapshots, imageMountHostpaths []string
for _, mp := range internalLabels.mountPoints {
if mp.ImageMountSnapshot != "" {
imageMountSnapshots = append(imageMountSnapshots, mp.ImageMountSnapshot)
}
if mp.ImageMountHostpath != "" {
imageMountHostpaths = append(imageMountHostpaths, mp.ImageMountHostpath)
}
}
if len(imageMountSnapshots) > 0 {
b, err := json.Marshal(imageMountSnapshots)
Expand All @@ -901,6 +908,13 @@ func withInternalLabels(internalLabels internalLabels) (containerd.NewContainerO
}
m[labels.ImageMountSnapshots] = string(b)
}
if len(imageMountHostpaths) > 0 {
b, err := json.Marshal(imageMountHostpaths)
if err != nil {
return nil, err
}
m[labels.ImageMountHostpaths] = string(b)
}

if internalLabels.pidFile != "" {
m[labels.PIDFile] = internalLabels.pidFile
Expand Down
17 changes: 12 additions & 5 deletions pkg/cmd/container/remove.go
Original file line number Diff line number Diff line change
Expand Up @@ -283,15 +283,22 @@ func RemoveContainer(ctx context.Context, c containerd.Container, globalOptions
}
}

// Remove the read-only views backing type=image mounts - soft failure.
// Tear down type=image mount state (host materializations and read-only
// views) backing this container - soft failure.
var imageMountKeys, imageMountHostpaths []string
if snapshotsJSON, ok := containerLabels[labels.ImageMountSnapshots]; ok {
var keys []string
if err = json.Unmarshal([]byte(snapshotsJSON), &keys); err != nil {
if err = json.Unmarshal([]byte(snapshotsJSON), &imageMountKeys); err != nil {
log.G(ctx).WithError(err).Warnf("failed to unmarshal image-mount snapshots for container %q", id)
} else {
removeImageMountViews(ctx, client.SnapshotService(imageMountSnapshotter), keys)
}
}
if hostpathsJSON, ok := containerLabels[labels.ImageMountHostpaths]; ok {
if err = json.Unmarshal([]byte(hostpathsJSON), &imageMountHostpaths); err != nil {
log.G(ctx).WithError(err).Warnf("failed to unmarshal image-mount host paths for container %q", id)
}
}
if len(imageMountKeys) > 0 || len(imageMountHostpaths) > 0 {
removeImageMounts(ctx, client.SnapshotService(imageMountSnapshotter), imageMountHostpaths, imageMountKeys)
}
}()

// Get the task.
Expand Down
Loading
Loading