Files
moby/integration/volume/mount_test.go
2025-10-20 12:55:52 -05:00

404 lines
13 KiB
Go

package volume
import (
"bytes"
"context"
"fmt"
"io"
"path/filepath"
"strings"
"testing"
containertypes "github.com/moby/moby/api/types/container"
"github.com/moby/moby/api/types/mount"
"github.com/moby/moby/api/types/network"
"github.com/moby/moby/api/types/versions"
"github.com/moby/moby/client"
"github.com/moby/moby/v2/daemon/volume/safepath"
"github.com/moby/moby/v2/integration/internal/container"
"github.com/moby/moby/v2/internal/testutil/fakecontext"
"gotest.tools/v3/assert"
is "gotest.tools/v3/assert/cmp"
"gotest.tools/v3/skip"
)
func TestRunMountVolumeSubdir(t *testing.T) {
skip.If(t, versions.LessThan(testEnv.DaemonAPIVersion(), "1.45"), "skip test from new feature")
ctx := setupTest(t)
apiClient := testEnv.APIClient()
testVolumeName := setupTestVolume(t, apiClient)
for _, tc := range []struct {
name string
opts mount.VolumeOptions
cmd []string
volumeTarget string
createErr string
startErr string
expected string
skipPlatform string
}{
{name: "subdir", opts: mount.VolumeOptions{Subpath: "subdir"}, cmd: []string{"ls", "/volume"}, expected: "hello.txt"},
{name: "subdir link", opts: mount.VolumeOptions{Subpath: "hack/good"}, cmd: []string{"ls", "/volume"}, expected: "hello.txt"},
{name: "subdir with copy data", opts: mount.VolumeOptions{Subpath: "bin"}, volumeTarget: "/bin", cmd: []string{"ls", "/bin/busybox"}, expected: "/bin/busybox", skipPlatform: "windows:copy not supported on Windows"},
{name: "file", opts: mount.VolumeOptions{Subpath: "bar.txt"}, cmd: []string{"cat", "/volume"}, expected: "foo", skipPlatform: "windows:file bind mounts not supported on Windows"},
{name: "relative with backtracks", opts: mount.VolumeOptions{Subpath: "../../../../../../etc/passwd"}, cmd: []string{"cat", "/volume"}, createErr: "subpath must be a relative path within the volume"},
{name: "not existing", opts: mount.VolumeOptions{Subpath: "not-existing-path"}, cmd: []string{"cat", "/volume"}, startErr: (&safepath.ErrNotAccessible{}).Error()},
{name: "mount link", opts: mount.VolumeOptions{Subpath: filepath.Join("hack", "root")}, cmd: []string{"ls", "/volume"}, startErr: (&safepath.ErrEscapesBase{}).Error()},
{name: "mount link link", opts: mount.VolumeOptions{Subpath: filepath.Join("hack", "bad")}, cmd: []string{"ls", "/volume"}, startErr: (&safepath.ErrEscapesBase{}).Error()}, //nolint:dupword
} {
t.Run(tc.name, func(t *testing.T) {
if tc.skipPlatform != "" {
platform, reason, _ := strings.Cut(tc.skipPlatform, ":")
if testEnv.DaemonInfo.OSType == platform {
t.Skip(reason)
}
}
cfg := containertypes.Config{
Image: "busybox",
Cmd: tc.cmd,
}
hostCfg := containertypes.HostConfig{
Mounts: []mount.Mount{
{
Type: mount.TypeVolume,
Source: testVolumeName,
Target: "/volume",
VolumeOptions: &tc.opts,
},
},
}
if testEnv.DaemonInfo.OSType == "windows" {
hostCfg.Mounts[0].Target = `C:\volume`
}
if tc.volumeTarget != "" {
hostCfg.Mounts[0].Target = tc.volumeTarget
}
ctrName := strings.ReplaceAll(t.Name(), "/", "_")
create, creatErr := apiClient.ContainerCreate(ctx, &cfg, &hostCfg, &network.NetworkingConfig{}, nil, ctrName)
id := create.ID
if id != "" {
defer apiClient.ContainerRemove(ctx, id, client.ContainerRemoveOptions{Force: true})
}
if tc.createErr != "" {
assert.ErrorContains(t, creatErr, tc.createErr)
return
}
assert.NilError(t, creatErr, "container creation failed")
startErr := apiClient.ContainerStart(ctx, id, client.ContainerStartOptions{})
if tc.startErr != "" {
assert.ErrorContains(t, startErr, tc.startErr)
return
}
assert.NilError(t, startErr)
output, err := container.Output(ctx, apiClient, id)
assert.Check(t, err)
inspect, err := apiClient.ContainerInspect(ctx, id)
if assert.Check(t, err) {
assert.Check(t, is.Equal(inspect.State.ExitCode, 0))
}
assert.Check(t, is.Equal(strings.TrimSpace(output.Stderr), ""))
assert.Check(t, is.Equal(strings.TrimSpace(output.Stdout), tc.expected))
})
}
}
func TestRunMountImage(t *testing.T) {
skip.If(t, versions.LessThan(testEnv.DaemonAPIVersion(), "1.48"), "skip test from new feature")
skip.If(t, testEnv.DaemonInfo.OSType == "windows", "image mounts not supported on Windows")
ctx := setupTest(t)
apiClient := testEnv.APIClient()
for _, tc := range []struct {
name string
opts mount.ImageOptions
cmd []string
createErr string
startErr string
expected string
skipPlatform string
}{
{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)
if testImage != "" {
defer apiClient.ImageRemove(ctx, testImage, client.ImageRemoveOptions{Force: true})
}
cfg := containertypes.Config{
Image: "busybox",
Cmd: tc.cmd,
}
hostCfg := containertypes.HostConfig{
Mounts: []mount.Mount{
{
Type: mount.TypeImage,
Source: testImage,
Target: "/image",
ImageOptions: &tc.opts,
},
},
}
startContainer := func(id string) {
startErr := apiClient.ContainerStart(ctx, id, client.ContainerStartOptions{})
if tc.startErr != "" {
assert.ErrorContains(t, startErr, tc.startErr)
return
}
assert.NilError(t, startErr)
}
ctrName := strings.ReplaceAll(t.Name(), "/", "_")
create, creatErr := apiClient.ContainerCreate(ctx, &cfg, &hostCfg, &network.NetworkingConfig{}, nil, ctrName)
id := create.ID
if id != "" {
defer container.Remove(ctx, t, apiClient, id, client.ContainerRemoveOptions{Force: true})
}
if tc.createErr != "" {
assert.ErrorContains(t, creatErr, tc.createErr)
return
}
assert.NilError(t, creatErr, "container creation failed")
startContainer(id)
// Test image mounted is in use logic
if tc.name == "image_remove" {
img, _ := apiClient.ImageInspect(ctx, testImage)
imgId := strings.Split(img.ID, ":")[1]
_, removeErr := apiClient.ImageRemove(ctx, testImage, client.ImageRemoveOptions{})
assert.ErrorContains(t, removeErr, fmt.Sprintf(`container %s is using its referenced image %s`, id[:12], imgId[:12]))
}
// Test that the container servives a restart when mounted image is removed
if tc.name == "image_remove_force" {
stopErr := apiClient.ContainerStop(ctx, id, client.ContainerStopOptions{})
assert.NilError(t, stopErr)
_, removeErr := apiClient.ImageRemove(ctx, testImage, client.ImageRemoveOptions{Force: true})
assert.NilError(t, removeErr)
startContainer(id)
}
output, err := container.Output(ctx, apiClient, id)
assert.Check(t, err)
inspect, err := apiClient.ContainerInspect(ctx, id)
if tc.startErr == "" && assert.Check(t, err) {
assert.Check(t, is.Equal(inspect.State.ExitCode, 0))
}
assert.Check(t, is.Equal(strings.TrimSpace(output.Stderr), ""))
assert.Check(t, is.Equal(strings.TrimSpace(output.Stdout), tc.expected))
})
}
}
// setupTestVolume sets up a volume with:
// .
// |-- bar.txt (file with "foo")
// |-- bin (directory)
// |-- subdir (directory)
// | |-- hello.txt (file with "world")
// |-- hack (directory)
// | |-- root (symlink to /)
// | |-- good (symlink to ../subdir)
// | |-- bad (symlink to root)
func setupTestVolume(t *testing.T, apiClient client.APIClient) string {
t.Helper()
ctx := context.Background()
volumeName := t.Name() + "-volume"
err := apiClient.VolumeRemove(ctx, volumeName, client.VolumeRemoveOptions{Force: true})
assert.NilError(t, err, "failed to clean volume")
_, err = apiClient.VolumeCreate(ctx, client.VolumeCreateOptions{
Name: volumeName,
})
assert.NilError(t, err, "failed to setup volume")
rootFs := "/"
target := "/volume"
if testEnv.DaemonInfo.OSType == "windows" {
rootFs = `C:`
target = `C:\volume`
}
initCmd := "echo foo > /volume/bar.txt && " +
"mkdir /volume/bin && " +
"mkdir /volume/subdir && " +
"echo world > /volume/subdir/hello.txt && " +
"mkdir /volume/hack && " +
"ln -s " + rootFs + " /volume/hack/root && " +
"ln -s ../subdir /volume/hack/good && " +
"ln -s root /volume/hack/bad &&" +
"mkdir /volume/hack/iwanttobehackedwithtoctou"
opts := []func(*container.TestContainerConfig){
container.WithMount(mount.Mount{
Type: mount.TypeVolume,
Source: volumeName,
Target: target,
}),
container.WithCmd("sh", "-c", initCmd+"; ls -lah /volume /volume/hack/"),
}
if testEnv.DaemonInfo.OSType == "windows" {
// Can't create symlinks under HyperV isolation
opts = append(opts, container.WithIsolation(containertypes.IsolationProcess))
}
cid := container.Run(ctx, t, apiClient, opts...)
defer container.Remove(ctx, t, apiClient, cid, client.ContainerRemoveOptions{Force: true})
output, err := container.Output(ctx, apiClient, cid)
assert.NilError(t, err)
assert.Assert(t, is.Equal(output.Stderr, ""))
inspect, err := apiClient.ContainerInspect(ctx, cid)
assert.NilError(t, err)
assert.Assert(t, is.Equal(inspect.State.ExitCode, 0))
return volumeName
}
func setupTestImage(t *testing.T, ctx context.Context, apiClient client.APIClient, test string) string {
imgName := "test-image"
if test == "image_tag" {
imgName += ":foo"
}
//nolint:dupword // ignore "Duplicate words (subdir) found (dupword)"
dockerfile := `
FROM busybox as symlink
RUN mkdir /hack \
&& ln -s "../subdir" /hack/good \
&& ln -s "../../../../../docker" /hack/bad
#--
FROM scratch
COPY foo /
COPY subdir subdir
COPY --from=symlink /hack /hack
`
source := fakecontext.New(
t,
"",
fakecontext.WithDockerfile(dockerfile),
fakecontext.WithFile("foo", "bar"),
fakecontext.WithFile("subdir/hello", "world"),
)
defer source.Close()
resp, err := apiClient.ImageBuild(ctx, source.AsTarReader(t), client.ImageBuildOptions{
Remove: false,
ForceRemove: false,
Tags: []string{imgName},
})
assert.NilError(t, err)
out := bytes.NewBuffer(nil)
_, err = io.Copy(out, resp.Body)
assert.Check(t, resp.Body.Close())
assert.NilError(t, err)
_, err = apiClient.ImageInspect(ctx, imgName)
if err != nil {
t.Log(out)
}
assert.NilError(t, err)
return imgName
}
// TestRunMountImageMultipleTimes tests mounting the same image multiple times to different destinations
// Regression test for: https://github.com/moby/moby/issues/50122
func TestRunMountImageMultipleTimes(t *testing.T) {
skip.If(t, versions.LessThan(testEnv.DaemonAPIVersion(), "1.48"), "skip test from new feature")
skip.If(t, testEnv.DaemonInfo.OSType == "windows")
ctx := setupTest(t)
apiClient := testEnv.APIClient()
basePath := "/etc"
if testEnv.DaemonInfo.OSType == "windows" {
basePath = `C:\`
}
// hello-world image is a small image that only has /hello executable binary file
testImage := "hello-world:frozen"
id := container.Run(ctx, t, apiClient,
container.WithImage("busybox:latest"),
container.WithCmd("top"),
container.WithMount(mount.Mount{
Type: mount.TypeImage,
Source: testImage,
Target: filepath.Join(basePath, "foo"),
}),
container.WithMount(mount.Mount{
Type: mount.TypeImage,
Source: testImage,
Target: filepath.Join(basePath, "bar"),
}),
)
defer apiClient.ContainerRemove(ctx, id, client.ContainerRemoveOptions{Force: true})
inspect, err := apiClient.ContainerInspect(ctx, id)
assert.NilError(t, err)
assert.Equal(t, len(inspect.Mounts), 2)
var hasFoo, hasBar bool
for _, mnt := range inspect.Mounts {
if mnt.Destination == "/etc/foo" {
hasFoo = true
}
if mnt.Destination == "/etc/bar" {
hasBar = true
}
}
assert.Check(t, hasFoo, "Expected mount at /etc/foo")
assert.Check(t, hasBar, "Expected mount at /etc/bar")
t.Run("mounted foo", func(t *testing.T) {
res, err := container.Exec(ctx, apiClient, id, []string{"stat", "/etc/foo/hello"})
assert.NilError(t, err)
assert.Check(t, is.Equal(res.ExitCode, 0))
})
t.Run("mounted bar", func(t *testing.T) {
res, err := container.Exec(ctx, apiClient, id, []string{"stat", "/etc/bar/hello"})
assert.NilError(t, err)
assert.Check(t, is.Equal(res.ExitCode, 0))
})
}