Skip to content

Commit 18a55fe

Browse files
committed
feat(mount): support image-subpath for --mount type=image
Add the image-subpath option to --mount type=image, exposing 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 is unchanged: it still hands the snapshotter mount straight to the runtime, which owns its lifecycle. Signed-off-by: Mayur Das <mayur.das@neevcloud.com>
1 parent 543a843 commit 18a55fe

9 files changed

Lines changed: 356 additions & 39 deletions

File tree

cmd/nerdctl/container/container_run_mount_image_linux_test.go

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,9 +93,76 @@ func TestRunMountTypeImageReadOnly(t *testing.T) {
9393
testCase.Run(t)
9494
}
9595

96+
// TestRunMountTypeImageSubpath verifies that image-subpath exposes only the
97+
// selected directory of the image rootfs at the destination: the image's
98+
// /etc/os-release is reachable as <destination>/os-release.
99+
func TestRunMountTypeImageSubpath(t *testing.T) {
100+
testCase := nerdtest.Setup()
101+
102+
testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand {
103+
return helpers.Command("run", "--rm",
104+
"--mount", fmt.Sprintf("type=image,source=%s,destination=/mnt/img,image-subpath=etc", testutil.CommonImage),
105+
testutil.CommonImage, "cat", "/mnt/img/os-release")
106+
}
107+
108+
testCase.Expected = func(data test.Data, helpers test.Helpers) *test.Expected {
109+
return &test.Expected{
110+
ExitCode: expect.ExitCodeSuccess,
111+
Output: expect.Contains("Alpine"),
112+
}
113+
}
114+
115+
testCase.Run(t)
116+
}
117+
118+
// TestRunMountTypeImageSubpathMultiple verifies that two image-subpath mounts of
119+
// the same image at different destinations each expose their own subdirectory,
120+
// exercising the multi-mount label round-trip and cleanup.
121+
func TestRunMountTypeImageSubpathMultiple(t *testing.T) {
122+
testCase := nerdtest.Setup()
123+
124+
testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand {
125+
return helpers.Command("run", "--rm",
126+
"--mount", fmt.Sprintf("type=image,source=%s,destination=/mnt/etc,image-subpath=etc", testutil.CommonImage),
127+
"--mount", fmt.Sprintf("type=image,source=%s,destination=/mnt/bin,image-subpath=bin", testutil.CommonImage),
128+
testutil.CommonImage, "ls", "/mnt/etc", "/mnt/bin")
129+
}
130+
131+
testCase.Expected = func(data test.Data, helpers test.Helpers) *test.Expected {
132+
return &test.Expected{
133+
ExitCode: expect.ExitCodeSuccess,
134+
}
135+
}
136+
137+
testCase.Run(t)
138+
}
139+
140+
// TestRunMountTypeImageSubpathReadOnly verifies that an image-subpath mount is
141+
// read-only. nerdctl-only: Docker mounts images read-write by default.
142+
func TestRunMountTypeImageSubpathReadOnly(t *testing.T) {
143+
testCase := nerdtest.Setup()
144+
testCase.Require = require.Not(nerdtest.Docker)
145+
146+
testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand {
147+
return helpers.Command("run", "--rm",
148+
"--mount", fmt.Sprintf("type=image,source=%s,destination=/mnt/img,image-subpath=etc", testutil.CommonImage),
149+
testutil.CommonImage, "touch", "/mnt/img/should-fail")
150+
}
151+
152+
testCase.Expected = func(data test.Data, helpers test.Helpers) *test.Expected {
153+
return &test.Expected{
154+
ExitCode: expect.ExitCodeGenericFail,
155+
Errors: []error{fmt.Errorf("Read-only file system")},
156+
}
157+
}
158+
159+
testCase.Run(t)
160+
}
161+
96162
// TestRunMountTypeImageErrors verifies that an image mount missing its source,
97-
// or using the not-yet-supported subpath option, is rejected. subpath is
98-
// nerdctl-specific behaviour here, so the test is not run against Docker.
163+
// or using the not-yet-supported subpath option, or an image-subpath that
164+
// escapes the rootfs, is rejected. These are nerdctl-specific behaviours here,
165+
// so the test is not run against Docker.
99166
func TestRunMountTypeImageErrors(t *testing.T) {
100167
testCase := nerdtest.Setup()
101168
testCase.Require = require.Not(nerdtest.Docker)
@@ -128,6 +195,34 @@ func TestRunMountTypeImageErrors(t *testing.T) {
128195
}
129196
},
130197
},
198+
{
199+
Description: "image-subpath parent traversal rejected",
200+
Command: func(data test.Data, helpers test.Helpers) test.TestableCommand {
201+
return helpers.Command("run", "--rm",
202+
"--mount", fmt.Sprintf("type=image,source=%s,destination=/mnt/img,image-subpath=../etc", testutil.CommonImage),
203+
testutil.CommonImage, "true")
204+
},
205+
Expected: func(data test.Data, helpers test.Helpers) *test.Expected {
206+
return &test.Expected{
207+
ExitCode: expect.ExitCodeGenericFail,
208+
Errors: []error{fmt.Errorf("escapes")},
209+
}
210+
},
211+
},
212+
{
213+
Description: "image-subpath absolute rejected",
214+
Command: func(data test.Data, helpers test.Helpers) test.TestableCommand {
215+
return helpers.Command("run", "--rm",
216+
"--mount", fmt.Sprintf("type=image,source=%s,destination=/mnt/img,image-subpath=/etc", testutil.CommonImage),
217+
testutil.CommonImage, "true")
218+
},
219+
Expected: func(data test.Data, helpers test.Helpers) *test.Expected {
220+
return &test.Expected{
221+
ExitCode: expect.ExitCodeGenericFail,
222+
Errors: []error{fmt.Errorf("relative")},
223+
}
224+
},
225+
},
131226
}
132227

133228
testCase.Run(t)

docs/command-reference.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -314,7 +314,7 @@ Volume flags:
314314
- unimplemented options: `volume-nocopy`, `volume-label`, `volume-driver`, `volume-opt`
315315
- Options specific to `image`:
316316
- :whale: `src`, `source`: image reference (mandatory). The image filesystem is mounted read-only.
317-
- unimplemented options: `subpath`
317+
- :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).
318318
- :whale: `--volumes-from`: Mount volumes from the specified container(s), e.g. "--volumes-from my-container".
319319

320320
Rootfs flags:

pkg/cmd/container/create.go

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

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

@@ -823,13 +827,16 @@ func withInternalLabels(internalLabels internalLabels) (containerd.NewContainerO
823827
m[labels.AnonymousVolumes] = string(anonVolumeJSON)
824828
}
825829

826-
// Record the snapshot keys of any type=image mount views so they can be
827-
// removed when the container is deleted.
828-
var imageMountSnapshots []string
830+
// Record the snapshot keys and host materialization paths of any type=image
831+
// mounts so they can be removed when the container is deleted.
832+
var imageMountSnapshots, imageMountHostpaths []string
829833
for _, mp := range internalLabels.mountPoints {
830834
if mp.ImageMountSnapshot != "" {
831835
imageMountSnapshots = append(imageMountSnapshots, mp.ImageMountSnapshot)
832836
}
837+
if mp.ImageMountHostpath != "" {
838+
imageMountHostpaths = append(imageMountHostpaths, mp.ImageMountHostpath)
839+
}
833840
}
834841
if len(imageMountSnapshots) > 0 {
835842
b, err := json.Marshal(imageMountSnapshots)
@@ -838,6 +845,13 @@ func withInternalLabels(internalLabels internalLabels) (containerd.NewContainerO
838845
}
839846
m[labels.ImageMountSnapshots] = string(b)
840847
}
848+
if len(imageMountHostpaths) > 0 {
849+
b, err := json.Marshal(imageMountHostpaths)
850+
if err != nil {
851+
return nil, err
852+
}
853+
m[labels.ImageMountHostpaths] = string(b)
854+
}
841855

842856
if internalLabels.pidFile != "" {
843857
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)