Skip to content

Commit b86abb8

Browse files
committed
feat(mount): support --mount type=image with image-subpath
Mount an image's filesystem into a container read-only, matching Docker: --mount type=image,source=<image>,destination=<path>. The source image is ensured and unpacked, a read-only snapshot view of its rootfs is created and mounted at the destination, and the view is removed when the container is deleted. The image-subpath option exposes a single directory of the image rootfs at the destination instead of the whole rootfs. An OCI overlay mount cannot select a subdirectory, so a subpath mount materializes the read-only view on a host directory under the data root, resolves the subpath with securejoin (blocking absolute paths and parent traversal, including via symlinks), and bind-mounts the resolved directory read-only into the container. The host materialization path is recorded on a container label and unmounted and removed on container deletion, alongside the snapshot view. The whole-rootfs path hands the snapshotter mount straight to the runtime, which owns its lifecycle. Mounting the same image at multiple destinations is supported; the corresponding tests are skipped on Docker, which rejects mounting the same image more than once. Signed-off-by: Mayur Das <mayur.das@neevcloud.com>
1 parent 31641bd commit b86abb8

9 files changed

Lines changed: 363 additions & 46 deletions

File tree

cmd/nerdctl/container/container_run_mount_image_linux_test.go

Lines changed: 103 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -95,10 +95,79 @@ func TestRunMountTypeImageReadOnly(t *testing.T) {
9595
testCase.Run(t)
9696
}
9797

98+
// TestRunMountTypeImageSubpath verifies that image-subpath exposes only the
99+
// selected directory of the image rootfs at the destination: the image's
100+
// /etc/os-release is reachable as <destination>/os-release.
101+
func TestRunMountTypeImageSubpath(t *testing.T) {
102+
testCase := nerdtest.Setup()
103+
104+
testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand {
105+
return helpers.Command("run", "--rm",
106+
"--mount", fmt.Sprintf("type=image,source=%s,destination=/mnt/img,image-subpath=etc", testutil.CommonImage),
107+
testutil.CommonImage, "cat", "/mnt/img/os-release")
108+
}
109+
110+
testCase.Expected = func(data test.Data, helpers test.Helpers) *test.Expected {
111+
return &test.Expected{
112+
ExitCode: expect.ExitCodeSuccess,
113+
Output: expect.Contains("Alpine"),
114+
}
115+
}
116+
117+
testCase.Run(t)
118+
}
119+
120+
// TestRunMountTypeImageSubpathMultiple verifies that two image-subpath mounts of
121+
// the same image at different destinations each expose their own subdirectory,
122+
// exercising the multi-mount label round-trip and cleanup.
123+
func TestRunMountTypeImageSubpathMultiple(t *testing.T) {
124+
testCase := nerdtest.Setup()
125+
// nerdctl-only: Docker keys an image mount by its source image and rejects
126+
// mounting the same image twice ("mount already exists with name").
127+
testCase.Require = require.Not(nerdtest.Docker)
128+
129+
testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand {
130+
return helpers.Command("run", "--rm",
131+
"--mount", fmt.Sprintf("type=image,source=%s,destination=/mnt/etc,image-subpath=etc", testutil.CommonImage),
132+
"--mount", fmt.Sprintf("type=image,source=%s,destination=/mnt/bin,image-subpath=bin", testutil.CommonImage),
133+
testutil.CommonImage, "ls", "/mnt/etc", "/mnt/bin")
134+
}
135+
136+
testCase.Expected = func(data test.Data, helpers test.Helpers) *test.Expected {
137+
return &test.Expected{
138+
ExitCode: expect.ExitCodeSuccess,
139+
}
140+
}
141+
142+
testCase.Run(t)
143+
}
144+
145+
// TestRunMountTypeImageSubpathReadOnly verifies that an image-subpath mount is
146+
// read-only so writing fails. This matches Docker, which also mounts images
147+
// read-only.
148+
func TestRunMountTypeImageSubpathReadOnly(t *testing.T) {
149+
testCase := nerdtest.Setup()
150+
151+
testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand {
152+
return helpers.Command("run", "--rm",
153+
"--mount", fmt.Sprintf("type=image,source=%s,destination=/mnt/img,image-subpath=etc", testutil.CommonImage),
154+
testutil.CommonImage, "touch", "/mnt/img/should-fail")
155+
}
156+
157+
testCase.Expected = func(data test.Data, helpers test.Helpers) *test.Expected {
158+
return &test.Expected{
159+
ExitCode: expect.ExitCodeGenericFail,
160+
Errors: []error{fmt.Errorf("Read-only file system")},
161+
}
162+
}
163+
164+
testCase.Run(t)
165+
}
166+
98167
// TestRunMountTypeImageErrors verifies that an image mount missing its source,
99-
// or using the not-yet-supported image-subpath option, is rejected. Docker
100-
// implements image-subpath, so that case diverges and the test is not run
101-
// against Docker.
168+
// or using the not-yet-supported subpath option, or an image-subpath that
169+
// escapes the rootfs, is rejected. These are nerdctl-specific behaviours here,
170+
// so the test is not run against Docker.
102171
func TestRunMountTypeImageErrors(t *testing.T) {
103172
testCase := nerdtest.Setup()
104173
testCase.Require = require.Not(nerdtest.Docker)
@@ -118,16 +187,44 @@ func TestRunMountTypeImageErrors(t *testing.T) {
118187
},
119188
},
120189
{
121-
Description: "image-subpath not supported",
190+
Description: "subpath not supported",
191+
Command: func(data test.Data, helpers test.Helpers) test.TestableCommand {
192+
return helpers.Command("run", "--rm",
193+
"--mount", fmt.Sprintf("type=image,source=%s,destination=/mnt/img,subpath=etc", testutil.CommonImage),
194+
testutil.CommonImage, "true")
195+
},
196+
Expected: func(data test.Data, helpers test.Helpers) *test.Expected {
197+
return &test.Expected{
198+
ExitCode: expect.ExitCodeGenericFail,
199+
Errors: []error{fmt.Errorf("subpath")},
200+
}
201+
},
202+
},
203+
{
204+
Description: "image-subpath parent traversal rejected",
205+
Command: func(data test.Data, helpers test.Helpers) test.TestableCommand {
206+
return helpers.Command("run", "--rm",
207+
"--mount", fmt.Sprintf("type=image,source=%s,destination=/mnt/img,image-subpath=../etc", testutil.CommonImage),
208+
testutil.CommonImage, "true")
209+
},
210+
Expected: func(data test.Data, helpers test.Helpers) *test.Expected {
211+
return &test.Expected{
212+
ExitCode: expect.ExitCodeGenericFail,
213+
Errors: []error{fmt.Errorf("escapes")},
214+
}
215+
},
216+
},
217+
{
218+
Description: "image-subpath absolute rejected",
122219
Command: func(data test.Data, helpers test.Helpers) test.TestableCommand {
123220
return helpers.Command("run", "--rm",
124-
"--mount", fmt.Sprintf("type=image,source=%s,destination=/mnt/img,image-subpath=etc", testutil.CommonImage),
221+
"--mount", fmt.Sprintf("type=image,source=%s,destination=/mnt/img,image-subpath=/etc", testutil.CommonImage),
125222
testutil.CommonImage, "true")
126223
},
127224
Expected: func(data test.Data, helpers test.Helpers) *test.Expected {
128225
return &test.Expected{
129226
ExitCode: expect.ExitCodeGenericFail,
130-
Errors: []error{fmt.Errorf("image-subpath")},
227+
Errors: []error{fmt.Errorf("relative")},
131228
}
132229
},
133230
},

docs/command-reference.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -328,7 +328,7 @@ Volume flags:
328328
- Options specific to `image`:
329329
- :whale: `src`, `source`: image reference (mandatory).
330330
- :whale: Currently, the image filesystem is mounted read-only.
331-
- unimplemented options: `image-subpath`
331+
- :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).
332332
- :whale: `--volumes-from`: Mount volumes from the specified container(s), e.g. "--volumes-from my-container".
333333

334334
Rootfs flags:

pkg/cmd/container/create.go

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -95,20 +95,24 @@ func Create(ctx context.Context, client *containerd.Client, args []string, netMa
9595
internalLabels.platform = options.Platform
9696
internalLabels.namespace = options.GOptions.Namespace
9797

98-
// If creation fails after image-mount views are created, remove them so the
99-
// snapshots do not leak (the cleanup label is only persisted on success).
98+
// If creation fails after image-mount state is created, tear it down so the
99+
// snapshots and host mounts do not leak (the cleanup labels are only persisted
100+
// on success).
100101
defer func() {
101102
if retErr == nil {
102103
return
103104
}
104-
var keys []string
105+
var keys, hostpaths []string
105106
for _, mp := range internalLabels.mountPoints {
106107
if mp.ImageMountSnapshot != "" {
107108
keys = append(keys, mp.ImageMountSnapshot)
108109
}
110+
if mp.ImageMountHostpath != "" {
111+
hostpaths = append(hostpaths, mp.ImageMountHostpath)
112+
}
109113
}
110-
if len(keys) > 0 {
111-
removeImageMountViews(ctx, client.SnapshotService(options.GOptions.Snapshotter), keys)
114+
if len(keys) > 0 || len(hostpaths) > 0 {
115+
removeImageMounts(ctx, client.SnapshotService(options.GOptions.Snapshotter), hostpaths, keys)
112116
}
113117
}()
114118

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

889-
// Record the snapshot keys of any type=image mount views so they can be
890-
// removed when the container is deleted.
891-
var imageMountSnapshots []string
893+
// Record the snapshot keys and host materialization paths of any type=image
894+
// mounts so they can be removed when the container is deleted.
895+
var imageMountSnapshots, imageMountHostpaths []string
892896
for _, mp := range internalLabels.mountPoints {
893897
if mp.ImageMountSnapshot != "" {
894898
imageMountSnapshots = append(imageMountSnapshots, mp.ImageMountSnapshot)
895899
}
900+
if mp.ImageMountHostpath != "" {
901+
imageMountHostpaths = append(imageMountHostpaths, mp.ImageMountHostpath)
902+
}
896903
}
897904
if len(imageMountSnapshots) > 0 {
898905
b, err := json.Marshal(imageMountSnapshots)
@@ -901,6 +908,13 @@ func withInternalLabels(internalLabels internalLabels) (containerd.NewContainerO
901908
}
902909
m[labels.ImageMountSnapshots] = string(b)
903910
}
911+
if len(imageMountHostpaths) > 0 {
912+
b, err := json.Marshal(imageMountHostpaths)
913+
if err != nil {
914+
return nil, err
915+
}
916+
m[labels.ImageMountHostpaths] = string(b)
917+
}
904918

905919
if internalLabels.pidFile != "" {
906920
m[labels.PIDFile] = internalLabels.pidFile

pkg/cmd/container/remove.go

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -283,15 +283,22 @@ func RemoveContainer(ctx context.Context, c containerd.Container, globalOptions
283283
}
284284
}
285285

286-
// Remove the read-only views backing type=image mounts - soft failure.
286+
// Tear down type=image mount state (host materializations and read-only
287+
// views) backing this container - soft failure.
288+
var imageMountKeys, imageMountHostpaths []string
287289
if snapshotsJSON, ok := containerLabels[labels.ImageMountSnapshots]; ok {
288-
var keys []string
289-
if err = json.Unmarshal([]byte(snapshotsJSON), &keys); err != nil {
290+
if err = json.Unmarshal([]byte(snapshotsJSON), &imageMountKeys); err != nil {
290291
log.G(ctx).WithError(err).Warnf("failed to unmarshal image-mount snapshots for container %q", id)
291-
} else {
292-
removeImageMountViews(ctx, client.SnapshotService(imageMountSnapshotter), keys)
293292
}
294293
}
294+
if hostpathsJSON, ok := containerLabels[labels.ImageMountHostpaths]; ok {
295+
if err = json.Unmarshal([]byte(hostpathsJSON), &imageMountHostpaths); err != nil {
296+
log.G(ctx).WithError(err).Warnf("failed to unmarshal image-mount host paths for container %q", id)
297+
}
298+
}
299+
if len(imageMountKeys) > 0 || len(imageMountHostpaths) > 0 {
300+
removeImageMounts(ctx, client.SnapshotService(imageMountSnapshotter), imageMountHostpaths, imageMountKeys)
301+
}
295302
}()
296303

297304
// Get the task.

0 commit comments

Comments
 (0)