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
235 changes: 235 additions & 0 deletions cmd/nerdctl/container/container_run_mount_image_linux_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
/*
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 (
"fmt"
"testing"

"github.com/containerd/nerdctl/mod/tigron/expect"
"github.com/containerd/nerdctl/mod/tigron/require"
"github.com/containerd/nerdctl/mod/tigron/test"

"github.com/containerd/nerdctl/v2/pkg/testutil"
"github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest"
)

// TestRunMountTypeImage verifies that `--mount type=image` mounts the source
// image's filesystem into the container so its files are readable at the target.
func TestRunMountTypeImage(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", testutil.CommonImage),
testutil.CommonImage, "cat", "/mnt/img/etc/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)
}

// TestRunMountTypeImageMultipleDestinations verifies the same image can be
// mounted at two destinations in one container.
func TestRunMountTypeImageMultipleDestinations(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/a", testutil.CommonImage),
"--mount", fmt.Sprintf("type=image,source=%s,destination=/mnt/b", testutil.CommonImage),
testutil.CommonImage, "cat", "/mnt/a/etc/os-release", "/mnt/b/etc/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)
}

// TestRunMountTypeImageReadOnly verifies an image mount is read-only (writing
// fails). nerdctl-only: Docker mounts images read-write by default.
func TestRunMountTypeImageReadOnly(t *testing.T) {
testCase := nerdtest.Setup()
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/img", 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)
}

// 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. nerdctl-only: Docker mounts images read-write by default.
func TestRunMountTypeImageSubpathReadOnly(t *testing.T) {
testCase := nerdtest.Setup()
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/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 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)

testCase.SubTests = []*test.Case{
{
Description: "missing source",
Command: func(data test.Data, helpers test.Helpers) test.TestableCommand {
return helpers.Command("run", "--rm", "--mount", "type=image,destination=/mnt/img",
testutil.CommonImage, "true")
},
Expected: func(data test.Data, helpers test.Helpers) *test.Expected {
return &test.Expected{
ExitCode: expect.ExitCodeGenericFail,
Errors: []error{fmt.Errorf("source")},
}
},
},
{
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),
testutil.CommonImage, "true")
},
Expected: func(data test.Data, helpers test.Helpers) *test.Expected {
return &test.Expected{
ExitCode: expect.ExitCodeGenericFail,
Errors: []error{fmt.Errorf("relative")},
}
},
},
}

testCase.Run(t)
}
6 changes: 4 additions & 2 deletions docs/command-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,10 +295,9 @@ Volume flags:
Consists of multiple key-value pairs, separated by commas and each
consisting of a `<key>=<value>` tuple.
e.g., `-- mount type=bind,source=/src,target=/app,bind-propagation=shared`.
- :whale: `type`: Current supported mount types are `bind`, `volume`, `tmpfs`.
- :whale: `type`: Current supported mount types are `bind`, `volume`, `tmpfs`, `image`.
The default type will be set to `volume` if not specified.
i.e., `--mount src=vol-1,dst=/app,readonly` equals `--mount type=volume,src=vol-1,dst=/app,readonly`
- unimplemented type: `image`
- Common Options:
- :whale: `src`, `source`: Mount source spec for bind and volume. Mandatory for bind.
- :whale: `dst`, `destination`, `target`: Mount destination spec.
Expand All @@ -313,6 +312,9 @@ Volume flags:
Defaults to `1777` or world-writable.
- Options specific to `volume`:
- unimplemented options: `volume-nocopy`, `volume-label`, `volume-driver`, `volume-opt`
- Options specific to `image`:
- :whale: `src`, `source`: image reference (mandatory). The image filesystem is mounted read-only.
- :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
49 changes: 48 additions & 1 deletion pkg/cmd/container/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ import (
)

// Create will create a container.
func Create(ctx context.Context, client *containerd.Client, args []string, netManager containerutil.NetworkOptionsManager, options types.ContainerCreateOptions) (containerd.Container, func(), error) {
func Create(ctx context.Context, client *containerd.Client, args []string, netManager containerutil.NetworkOptionsManager, options types.ContainerCreateOptions) (_ containerd.Container, _ func(), retErr error) {
// Acquire an exclusive lock on the volume store until we are done to avoid being raced by any other
// volume operations (or any other operation involving volume manipulation)
volStore, err := volume.Store(options.GOptions.Namespace, options.GOptions.DataRoot, options.GOptions.Address)
Expand All @@ -94,6 +94,27 @@ 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 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, 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 || len(hostpaths) > 0 {
removeImageMounts(ctx, client.SnapshotService(options.GOptions.Snapshotter), hostpaths, keys)
}
}()

var (
id = idgen.GenerateID()
opts []oci.SpecOpts
Expand Down Expand Up @@ -806,6 +827,32 @@ func withInternalLabels(internalLabels internalLabels) (containerd.NewContainerO
m[labels.AnonymousVolumes] = string(anonVolumeJSON)
}

// 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)
if err != nil {
return nil, err
}
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
24 changes: 24 additions & 0 deletions pkg/cmd/container/remove.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,13 @@ func RemoveContainer(ctx context.Context, c containerd.Container, globalOptions
return err
}

// Capture the container's snapshotter before deletion: image-mount views were
// created against it, which may differ from the current --snapshotter flag.
imageMountSnapshotter := globalOptions.Snapshotter
if info, err := c.Info(ctx); err == nil && info.Snapshotter != "" {
imageMountSnapshotter = info.Snapshotter
}

// Get datastore
dataStore, err := clientutil.DataStore(globalOptions.DataRoot, globalOptions.Address)
if err != nil {
Expand Down Expand Up @@ -275,6 +282,23 @@ func RemoveContainer(ctx context.Context, c containerd.Container, globalOptions
}
}
}

// 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 {
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)
}
}
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