From 90aea3b85ff55f338a6cc65d3327502492b91c95 Mon Sep 17 00:00:00 2001 From: Laurent Goderre Date: Fri, 10 Jan 2025 15:01:04 -0500 Subject: [PATCH] Add image subpath mounting functionality Signed-off-by: Laurent Goderre --- api/swagger.yaml | 8 +++++++ api/types/mount/mount.go | 5 ++++ integration/volume/mount_test.go | 41 +++++++++++++++++++++++++------- volume/mounts/linux_parser.go | 14 +++++++++++ volume/mounts/mounts.go | 14 +++++++++++ volume/mounts/validate_test.go | 16 +++++++++++++ 6 files changed, 90 insertions(+), 8 deletions(-) diff --git a/api/swagger.yaml b/api/swagger.yaml index 9ca6f67b9d..5c43b7237b 100644 --- a/api/swagger.yaml +++ b/api/swagger.yaml @@ -435,6 +435,14 @@ definitions: description: "Source path inside the volume. Must be relative without any back traversals." type: "string" example: "dir-inside-volume/subdirectory" + ImageOptions: + description: "Optional configuration for the `image` type." + type: "object" + properties: + Subpath: + description: "Source path inside the image. Must be relative without any back traversals." + type: "string" + example: "dir-inside-image/subdirectory" TmpfsOptions: description: "Optional configuration for the `tmpfs` type." type: "object" diff --git a/api/types/mount/mount.go b/api/types/mount/mount.go index 15538147c4..d98dbec991 100644 --- a/api/types/mount/mount.go +++ b/api/types/mount/mount.go @@ -36,6 +36,7 @@ type Mount struct { BindOptions *BindOptions `json:",omitempty"` VolumeOptions *VolumeOptions `json:",omitempty"` + ImageOptions *ImageOptions `json:",omitempty"` TmpfsOptions *TmpfsOptions `json:",omitempty"` ClusterOptions *ClusterOptions `json:",omitempty"` } @@ -102,6 +103,10 @@ type VolumeOptions struct { DriverConfig *Driver `json:",omitempty"` } +type ImageOptions struct { + Subpath string `json:",omitempty"` +} + // Driver represents a volume driver. type Driver struct { Name string `json:",omitempty"` diff --git a/integration/volume/mount_test.go b/integration/volume/mount_test.go index 57c6a7f234..0ffcfe4746 100644 --- a/integration/volume/mount_test.go +++ b/integration/volume/mount_test.go @@ -127,8 +127,8 @@ func TestRunMountImage(t *testing.T) { for _, tc := range []struct { name string + opts mount.ImageOptions cmd []string - volumeTarget string createErr string startErr string expected string @@ -136,12 +136,20 @@ func TestRunMountImage(t *testing.T) { }{ {name: "image", cmd: []string{"cat", "/image/foo"}, expected: "bar"}, {name: "image_tag", cmd: []string{"cat", "/image/foo"}, expected: "bar"}, + + {name: "subdir", opts: mount.ImageOptions{Subpath: "subdir"}, cmd: []string{"ls", "/image"}, expected: "hello"}, + {name: "subdir link", opts: mount.ImageOptions{Subpath: "hack/good"}, cmd: []string{"ls", "/image"}, expected: "hello"}, + {name: "subdir link outside context", opts: mount.ImageOptions{Subpath: "hack/bad"}, cmd: []string{"ls", "/image"}, startErr: (&safepath.ErrEscapesBase{}).Error()}, + {name: "file", opts: mount.ImageOptions{Subpath: "subdir/hello"}, cmd: []string{"cat", "/image"}, expected: "world"}, + {name: "relative with backtracks", opts: mount.ImageOptions{Subpath: "../../../../../../etc/passwd"}, cmd: []string{"cat", "/image"}, createErr: "subpath must be a relative path within the volume"}, + {name: "not existing", opts: mount.ImageOptions{Subpath: "not-existing-path"}, cmd: []string{"cat", "/image"}, startErr: (&safepath.ErrNotAccessible{}).Error()}, + {name: "image_remove", cmd: []string{"cat", "/image/foo"}, expected: "bar"}, // Expected is duplicated because the container runs twice {name: "image_remove_force", cmd: []string{"cat", "/image/foo"}, expected: "barbar"}, } { t.Run(tc.name, func(t *testing.T) { - testImage := setupTestImage(t, ctx, apiClient, tc.name) + testImage := setupTestImage(t, ctx, apiClient, tc.name, testEnv.UsingSnapshotter()) if testImage != "" { defer apiClient.ImageRemove(ctx, testImage, image.RemoveOptions{Force: true}) } @@ -154,9 +162,10 @@ func TestRunMountImage(t *testing.T) { hostCfg := containertypes.HostConfig{ Mounts: []mount.Mount{ { - Type: mount.TypeImage, - Source: testImage, - Target: "/image", + Type: mount.TypeImage, + Source: testImage, + Target: "/image", + ImageOptions: &tc.opts, }, }, } @@ -207,7 +216,7 @@ func TestRunMountImage(t *testing.T) { assert.Check(t, err) inspect, err := apiClient.ContainerInspect(ctx, id) - if assert.Check(t, err) { + if tc.startErr == "" && assert.Check(t, err) { assert.Check(t, is.Equal(inspect.State.ExitCode, 0)) } @@ -289,16 +298,31 @@ func setupTestVolume(t *testing.T, client client.APIClient) string { return volumeName } -func setupTestImage(t *testing.T, ctx context.Context, client client.APIClient, test string) string { +func setupTestImage(t *testing.T, ctx context.Context, client client.APIClient, test string, snapshotter bool) string { imgName := "test-image" if test == "image_tag" { imgName += ":foo" } + var symlink string + if snapshotter { + symlink = "../../../../rootfs" + } else { + symlink = "../../../../../docker" + } + + //nolint:dupword // ignore "Duplicate words (subdir) found (dupword)" dockerfile := ` + FROM busybox as symlink + RUN mkdir /hack \ + && ln -s "../subdir" /hack/good \ + && ln -s "` + symlink + `" /hack/bad + #-- FROM scratch - ADD foo / + COPY foo / + COPY subdir subdir + COPY --from=symlink /hack /hack ` source := fakecontext.New( @@ -306,6 +330,7 @@ func setupTestImage(t *testing.T, ctx context.Context, client client.APIClient, "", fakecontext.WithDockerfile(dockerfile), fakecontext.WithFile("foo", "bar"), + fakecontext.WithFile("subdir/hello", "world"), ) defer source.Close() diff --git a/volume/mounts/linux_parser.go b/volume/mounts/linux_parser.go index 73f4b69c3f..72b26132fc 100644 --- a/volume/mounts/linux_parser.go +++ b/volume/mounts/linux_parser.go @@ -74,6 +74,9 @@ func (p *linuxParser) validateMountConfigImpl(mnt *mount.Mount, validateBindSour if mnt.VolumeOptions != nil { return &errMountConfig{mnt, errExtraField("VolumeOptions")} } + if mnt.ImageOptions != nil { + return &errMountConfig{mnt, errExtraField("ImageOptions")} + } if err := linuxValidateAbsolute(mnt.Source); err != nil { return &errMountConfig{mnt, err} @@ -95,6 +98,9 @@ func (p *linuxParser) validateMountConfigImpl(mnt *mount.Mount, validateBindSour if mnt.BindOptions != nil { return &errMountConfig{mnt, errExtraField("BindOptions")} } + if mnt.ImageOptions != nil { + return &errMountConfig{mnt, errExtraField("ImageOptions")} + } anonymousVolume := len(mnt.Source) == 0 if mnt.VolumeOptions != nil && mnt.VolumeOptions.Subpath != "" { @@ -113,6 +119,9 @@ func (p *linuxParser) validateMountConfigImpl(mnt *mount.Mount, validateBindSour if mnt.BindOptions != nil { return &errMountConfig{mnt, errExtraField("BindOptions")} } + if mnt.ImageOptions != nil { + return &errMountConfig{mnt, errExtraField("ImageOptions")} + } if len(mnt.Source) != 0 { return &errMountConfig{mnt, errExtraField("Source")} } @@ -129,6 +138,11 @@ func (p *linuxParser) validateMountConfigImpl(mnt *mount.Mount, validateBindSour if len(mnt.Source) == 0 { return &errMountConfig{mnt, errMissingField("Source")} } + if mnt.ImageOptions != nil && mnt.ImageOptions.Subpath != "" { + if !filepath.IsLocal(mnt.ImageOptions.Subpath) { + return &errMountConfig{mnt, errInvalidSubpath} + } + } default: return &errMountConfig{mnt, errors.New("mount type unknown")} } diff --git a/volume/mounts/mounts.go b/volume/mounts/mounts.go index 7278f21e7e..276d972d4c 100644 --- a/volume/mounts/mounts.go +++ b/volume/mounts/mounts.go @@ -219,6 +219,20 @@ func (m *MountPoint) Setup(ctx context.Context, mountLabel string, rootIDs idtoo return volumePath, clean, nil } + if m.Type == mounttypes.TypeImage { + if m.Spec.ImageOptions != nil && m.Spec.ImageOptions.Subpath != "" { + subpath := m.Spec.ImageOptions.Subpath + + safePath, err := safepath.Join(ctx, m.Source, subpath) + if err != nil { + return "", noCleanup, err + } + m.safePaths = append(m.safePaths, safePath) + log.G(ctx).Debugf("mounting (%s|%s) via %s", m.Source, subpath, safePath.Path()) + return safePath.Path(), safePath.Close, nil + } + } + if len(m.Source) == 0 { return "", noCleanup, fmt.Errorf("Unable to setup mount point, neither source nor volume defined") } diff --git a/volume/mounts/validate_test.go b/volume/mounts/validate_test.go index 9266062582..c40a64c7c1 100644 --- a/volume/mounts/validate_test.go +++ b/volume/mounts/validate_test.go @@ -31,6 +31,10 @@ func TestValidateMount(t *testing.T) { { input: mount.Mount{Type: mount.TypeVolume, Target: testDestinationPath, Source: "hello", VolumeOptions: &mount.VolumeOptions{Subpath: "world"}}, }, + { + input: mount.Mount{Type: mount.TypeVolume, Target: testDestinationPath, Source: "hello", BindOptions: &mount.BindOptions{}}, + expected: errExtraField("BindOptions"), + }, { input: mount.Mount{Type: mount.TypeBind}, expected: errMissingField("Target"), @@ -72,6 +76,9 @@ func TestValidateMount(t *testing.T) { { input: mount.Mount{Type: mount.TypeImage, Target: testDestinationPath, Source: "hello"}, }, + { + input: mount.Mount{Type: mount.TypeImage, Target: testDestinationPath, Source: "hello", ImageOptions: &mount.ImageOptions{Subpath: "world"}}, + }, { input: mount.Mount{Type: mount.TypeImage, Target: testDestinationPath, Source: "hello", BindOptions: &mount.BindOptions{}}, expected: errExtraField("BindOptions"), @@ -80,6 +87,15 @@ func TestValidateMount(t *testing.T) { input: mount.Mount{Type: mount.TypeImage, Target: testDestinationPath, Source: "hello", VolumeOptions: &mount.VolumeOptions{}}, expected: errExtraField("VolumeOptions"), }, + + { + input: mount.Mount{Type: mount.TypeVolume, Target: testDestinationPath, Source: "hello", ImageOptions: &mount.ImageOptions{}}, + expected: errExtraField("ImageOptions"), + }, + { + input: mount.Mount{Type: mount.TypeBind, Target: testDestinationPath, Source: testSourcePath, ImageOptions: &mount.ImageOptions{}}, + expected: errExtraField("ImageOptions"), + }, } tests = append(tests, imageTests...) }