Files
moby/integration/volume/mount_test.go
Sebastiaan van Stijn d3e45f8743 testutil: move back to internal
This package was originally internal, but was moved out when BuildKit
used it for its integration tests. That's no longer the case, so we
can make it internal again.

Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
2025-09-08 10:08:30 +02:00

405 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/api/types/volume"
"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, true)
assert.NilError(t, err, "failed to clean volume")
_, err = apiClient.VolumeCreate(ctx, volume.CreateOptions{
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))
})
}