package container import ( "fmt" "os" "path/filepath" "syscall" "testing" cerrdefs "github.com/containerd/errdefs" containertypes "github.com/moby/moby/api/types/container" mounttypes "github.com/moby/moby/api/types/mount" "github.com/moby/moby/api/types/network" "github.com/moby/moby/client" "github.com/moby/moby/client/pkg/versions" "github.com/moby/moby/v2/daemon/config" "github.com/moby/moby/v2/daemon/volume" "github.com/moby/moby/v2/integration/internal/container" "github.com/moby/moby/v2/internal/testutil" "github.com/moby/moby/v2/pkg/parsers/kernel" "github.com/moby/sys/mount" "github.com/moby/sys/mountinfo" "gotest.tools/v3/assert" is "gotest.tools/v3/assert/cmp" "gotest.tools/v3/fs" "gotest.tools/v3/poll" "gotest.tools/v3/skip" ) // testNonExistingPlugin is a special plugin-name, which overrides defaultTimeOut in tests. // this is a copy of https://github.com/moby/moby/blob/9e00a63d65434cdedc444e79a2b33a7c202b10d8/pkg/plugins/client.go#L253-L254 const testNonExistingPlugin = "this-plugin-does-not-exist" func TestContainerNetworkMountsNoChown(t *testing.T) { // chown only applies to Linux bind mounted volumes; must be same host to verify skip.If(t, testEnv.IsRemoteDaemon) ctx := setupTest(t) tmpDir := fs.NewDir(t, "network-file-mounts", fs.WithMode(0o755), fs.WithFile("nwfile", "network file bind mount", fs.WithMode(0o644))) tmpNWFileMount := tmpDir.Join("nwfile") config := containertypes.Config{ Image: "busybox", } hostConfig := containertypes.HostConfig{ Mounts: []mounttypes.Mount{ { Type: "bind", Source: tmpNWFileMount, Target: "/etc/resolv.conf", }, { Type: "bind", Source: tmpNWFileMount, Target: "/etc/hostname", }, { Type: "bind", Source: tmpNWFileMount, Target: "/etc/hosts", }, }, } cli, err := client.New(client.FromEnv) assert.NilError(t, err) defer cli.Close() ctrCreate, err := cli.ContainerCreate(ctx, client.ContainerCreateOptions{ Config: &config, HostConfig: &hostConfig, NetworkingConfig: &network.NetworkingConfig{}, }) assert.NilError(t, err) // container will exit immediately because of no tty, but we only need the start sequence to test the condition _, err = cli.ContainerStart(ctx, ctrCreate.ID, client.ContainerStartOptions{}) assert.NilError(t, err) // Check that host-located bind mount network file did not change ownership when the container was started // Note: If the user specifies a mountpath from the host, we should not be // attempting to chown files outside the daemon's metadata directory // (represented by `daemon.repository` at init time). // This forces users who want to use user namespaces to handle the // ownership needs of any external files mounted as network files // (/etc/resolv.conf, /etc/hosts, /etc/hostname) separately from the // daemon. In all other volume/bind mount situations we have taken this // same line--we don't chown host file content. // See GitHub PR 34224 for details. info, err := os.Stat(tmpNWFileMount) assert.NilError(t, err) fi := info.Sys().(*syscall.Stat_t) assert.Check(t, is.Equal(fi.Uid, uint32(0)), "bind mounted network file should not change ownership from root") } func TestMountDaemonRoot(t *testing.T) { skip.If(t, testEnv.IsRemoteDaemon) ctx := setupTest(t) apiClient := testEnv.APIClient() result, err := apiClient.Info(ctx, client.InfoOptions{}) if err != nil { t.Fatal(err) } info := result.Info for _, test := range []struct { desc string propagation mounttypes.Propagation expected mounttypes.Propagation }{ { desc: "default", propagation: "", expected: mounttypes.PropagationRSlave, }, { desc: "private", propagation: mounttypes.PropagationPrivate, }, { desc: "rprivate", propagation: mounttypes.PropagationRPrivate, }, { desc: "slave", propagation: mounttypes.PropagationSlave, }, { desc: "rslave", propagation: mounttypes.PropagationRSlave, expected: mounttypes.PropagationRSlave, }, { desc: "shared", propagation: mounttypes.PropagationShared, }, { desc: "rshared", propagation: mounttypes.PropagationRShared, expected: mounttypes.PropagationRShared, }, } { t.Run(test.desc, func(t *testing.T) { test := test t.Parallel() ctx := testutil.StartSpan(ctx, t) propagationSpec := fmt.Sprintf(":%s", test.propagation) if test.propagation == "" { propagationSpec = "" } bindSpecRoot := info.DockerRootDir + ":" + "/foo" + propagationSpec bindSpecSub := filepath.Join(info.DockerRootDir, "containers") + ":/foo" + propagationSpec for name, hc := range map[string]*containertypes.HostConfig{ "bind root": {Binds: []string{bindSpecRoot}}, "bind subpath": {Binds: []string{bindSpecSub}}, "mount root": { Mounts: []mounttypes.Mount{ { Type: mounttypes.TypeBind, Source: info.DockerRootDir, Target: "/foo", BindOptions: &mounttypes.BindOptions{Propagation: test.propagation}, }, }, }, "mount subpath": { Mounts: []mounttypes.Mount{ { Type: mounttypes.TypeBind, Source: filepath.Join(info.DockerRootDir, "containers"), Target: "/foo", BindOptions: &mounttypes.BindOptions{Propagation: test.propagation}, }, }, }, } { t.Run(name, func(t *testing.T) { hc := hc t.Parallel() ctx := testutil.StartSpan(ctx, t) c, err := apiClient.ContainerCreate(ctx, client.ContainerCreateOptions{ Config: &containertypes.Config{ Image: "busybox", Cmd: []string{"true"}, }, HostConfig: hc, }) if err != nil { if test.expected != "" { t.Fatal(err) } // expected an error, so this is ok and should not continue return } if test.expected == "" { t.Fatal("expected create to fail") } defer func() { if _, err := apiClient.ContainerRemove(ctx, c.ID, client.ContainerRemoveOptions{Force: true}); err != nil { panic(err) } }() inspect, err := apiClient.ContainerInspect(ctx, c.ID, client.ContainerInspectOptions{}) if err != nil { t.Fatal(err) } if len(inspect.Container.Mounts) != 1 { t.Fatalf("unexpected number of mounts: %+v", inspect.Container.Mounts) } m := inspect.Container.Mounts[0] if m.Propagation != test.expected { t.Fatalf("got unexpected propagation mode, expected %q, got: %v", test.expected, m.Propagation) } }) } }) } } func TestContainerBindMountNonRecursive(t *testing.T) { skip.If(t, testEnv.IsRemoteDaemon) skip.If(t, testEnv.IsRootless, "cannot be tested because RootlessKit executes the daemon in private mount namespace (https://github.com/rootless-containers/rootlesskit/issues/97)") ctx := setupTest(t) tmpDir1 := fs.NewDir(t, "tmpdir1", fs.WithMode(0o755), fs.WithDir("mnt", fs.WithMode(0o755))) tmpDir1Mnt := filepath.Join(tmpDir1.Path(), "mnt") tmpDir2 := fs.NewDir(t, "tmpdir2", fs.WithMode(0o755), fs.WithFile("file", "should not be visible when NonRecursive", fs.WithMode(0o644))) err := mount.Mount(tmpDir2.Path(), tmpDir1Mnt, "none", "bind,ro") if err != nil { t.Fatal(err) } defer func() { if err := mount.Unmount(tmpDir1Mnt); err != nil { t.Fatal(err) } }() // implicit is recursive (NonRecursive: false) implicit := mounttypes.Mount{ Type: "bind", Source: tmpDir1.Path(), Target: "/foo", ReadOnly: true, } recursive := implicit recursive.BindOptions = &mounttypes.BindOptions{ NonRecursive: false, } recursiveVerifier := []string{"test", "-f", "/foo/mnt/file"} nonRecursive := implicit nonRecursive.BindOptions = &mounttypes.BindOptions{ NonRecursive: true, } nonRecursiveVerifier := []string{"test", "!", "-f", "/foo/mnt/file"} apiClient := testEnv.APIClient() containers := []string{ container.Run(ctx, t, apiClient, container.WithMount(implicit), container.WithCmd(recursiveVerifier...)), container.Run(ctx, t, apiClient, container.WithMount(recursive), container.WithCmd(recursiveVerifier...)), container.Run(ctx, t, apiClient, container.WithMount(nonRecursive), container.WithCmd(nonRecursiveVerifier...)), } for _, c := range containers { poll.WaitOn(t, container.IsSuccessful(ctx, apiClient, c)) } } func TestContainerVolumesMountedAsShared(t *testing.T) { // Volume propagation is linux only. Also it creates directories for // bind mounting, so needs to be same host. skip.If(t, testEnv.IsRemoteDaemon) skip.If(t, testEnv.IsUserNamespace) skip.If(t, testEnv.IsRootless, "cannot be tested because RootlessKit executes the daemon in private mount namespace (https://github.com/rootless-containers/rootlesskit/issues/97)") ctx := setupTest(t) // Prepare a source directory to bind mount tmpDir1 := fs.NewDir(t, "volume-source", fs.WithMode(0o755), fs.WithDir("mnt1", fs.WithMode(0o755))) tmpDir1Mnt := filepath.Join(tmpDir1.Path(), "mnt1") // Convert this directory into a shared mount point so that we do // not rely on propagation properties of parent mount. if err := mount.MakePrivate(tmpDir1.Path()); err != nil { t.Fatal(err) } defer func() { if err := mount.Unmount(tmpDir1.Path()); err != nil { t.Fatal(err) } }() if err := mount.MakeShared(tmpDir1.Path()); err != nil { t.Fatal(err) } sharedMount := mounttypes.Mount{ Type: mounttypes.TypeBind, Source: tmpDir1.Path(), Target: "/volume-dest", BindOptions: &mounttypes.BindOptions{ Propagation: mounttypes.PropagationShared, }, } bindMountCmd := []string{"mount", "--bind", "/volume-dest/mnt1", "/volume-dest/mnt1"} apiClient := testEnv.APIClient() containerID := container.Run(ctx, t, apiClient, container.WithPrivileged(true), container.WithMount(sharedMount), container.WithCmd(bindMountCmd...)) poll.WaitOn(t, container.IsSuccessful(ctx, apiClient, containerID)) // Make sure a bind mount under a shared volume propagated to host. if mounted, _ := mountinfo.Mounted(tmpDir1Mnt); !mounted { t.Fatalf("Bind mount under shared volume did not propagate to host") } mount.Unmount(tmpDir1Mnt) } func TestContainerVolumesMountedAsSlave(t *testing.T) { // Volume propagation is linux only. Also it creates directories for // bind mounting, so needs to be same host. skip.If(t, testEnv.IsRemoteDaemon) skip.If(t, testEnv.IsUserNamespace) skip.If(t, testEnv.IsRootless, "cannot be tested because RootlessKit executes the daemon in private mount namespace (https://github.com/rootless-containers/rootlesskit/issues/97)") ctx := testutil.StartSpan(baseContext, t) // Prepare a source directory to bind mount tmpDir1 := fs.NewDir(t, "volume-source", fs.WithMode(0o755), fs.WithDir("mnt1", fs.WithMode(0o755))) tmpDir1Mnt := filepath.Join(tmpDir1.Path(), "mnt1") // Prepare a source directory with file in it. We will bind mount this // directory and see if file shows up. tmpDir2 := fs.NewDir(t, "volume-source2", fs.WithMode(0o755), fs.WithFile("slave-testfile", "Test", fs.WithMode(0o644))) // Convert this directory into a shared mount point so that we do // not rely on propagation properties of parent mount. if err := mount.MakePrivate(tmpDir1.Path()); err != nil { t.Fatal(err) } defer func() { if err := mount.Unmount(tmpDir1.Path()); err != nil { t.Fatal(err) } }() if err := mount.MakeShared(tmpDir1.Path()); err != nil { t.Fatal(err) } slaveMount := mounttypes.Mount{ Type: mounttypes.TypeBind, Source: tmpDir1.Path(), Target: "/volume-dest", BindOptions: &mounttypes.BindOptions{ Propagation: mounttypes.PropagationSlave, }, } topCmd := []string{"top"} apiClient := testEnv.APIClient() containerID := container.Run(ctx, t, apiClient, container.WithTty(true), container.WithMount(slaveMount), container.WithCmd(topCmd...), container.WithSecurityOpt("label=disable")) // Bind mount tmpDir2/ onto tmpDir1/mnt1. If mount propagates inside // container then contents of tmpDir2/slave-testfile should become // visible at "/volume-dest/mnt1/slave-testfile" if err := mount.Mount(tmpDir2.Path(), tmpDir1Mnt, "none", "bind"); err != nil { t.Fatal(err) } defer func() { if err := mount.Unmount(tmpDir1Mnt); err != nil { t.Fatal(err) } }() mountCmd := []string{"cat", "/volume-dest/mnt1/slave-testfile"} if result, err := container.Exec(ctx, apiClient, containerID, mountCmd); err == nil { if result.Stdout() != "Test" { t.Fatalf("Bind mount under slave volume did not propagate to container") } } else { t.Fatal(err) } } // TestContainerVolumeAnonymous verifies that anonymous volumes created through // the Mounts API get a random name generated, and have the "AnonymousLabel" // (com.docker.volume.anonymous) label set. // // regression test for https://github.com/moby/moby/issues/48748 func TestContainerVolumeAnonymous(t *testing.T) { skip.If(t, testEnv.IsRemoteDaemon) ctx := setupTest(t) apiClient := testEnv.APIClient() t.Run("no driver specified", func(t *testing.T) { mntOpts := mounttypes.Mount{Type: mounttypes.TypeVolume, Target: "/foo"} cID := container.Create(ctx, t, apiClient, container.WithMount(mntOpts)) inspect := container.Inspect(ctx, t, apiClient, cID) assert.Assert(t, is.Len(inspect.HostConfig.Mounts, 1)) assert.Check(t, is.Equal(inspect.HostConfig.Mounts[0], mntOpts)) assert.Assert(t, is.Len(inspect.Mounts, 1)) vol := inspect.Mounts[0] assert.Check(t, is.Len(vol.Name, 64), "volume name should be 64 bytes (from stringid.GenerateRandomID())") assert.Check(t, is.Equal(vol.Driver, volume.DefaultDriverName)) res, err := apiClient.VolumeInspect(ctx, vol.Name, client.VolumeInspectOptions{}) assert.NilError(t, err) // see [daemon.AnonymousLabel]; we don't want to import the daemon package here. const expectedAnonymousLabel = "com.docker.volume.anonymous" assert.Check(t, is.Contains(res.Volume.Labels, expectedAnonymousLabel)) assert.Check(t, is.Equal(res.Volume.Driver, volume.DefaultDriverName)) }) // Verify that specifying a custom driver is still taken into account. t.Run("custom driver", func(t *testing.T) { config := container.NewTestConfig(container.WithMount(mounttypes.Mount{ Type: mounttypes.TypeVolume, Target: "/foo", VolumeOptions: &mounttypes.VolumeOptions{ DriverConfig: &mounttypes.Driver{ Name: testNonExistingPlugin, }, }, })) _, err := apiClient.ContainerCreate(ctx, client.ContainerCreateOptions{ Config: config.Config, HostConfig: config.HostConfig, NetworkingConfig: config.NetworkingConfig, Platform: config.Platform, Name: config.Name, }) // We use [testNonExistingPlugin] for this, which produces an error // when used, which we use as indicator that the driver was passed // through. We should have a cleaner way for this, but that would // require a custom volume plugin to be installed. assert.Check(t, is.ErrorType(err, cerrdefs.IsNotFound)) assert.Check(t, is.ErrorContains(err, fmt.Sprintf(`plugin %q not found`, testNonExistingPlugin))) }) } // Regression test for #38995 and #43390. func TestContainerCopyLeaksMounts(t *testing.T) { ctx := setupTest(t) bindMount := mounttypes.Mount{ Type: mounttypes.TypeBind, Source: "/var", Target: "/hostvar", BindOptions: &mounttypes.BindOptions{ Propagation: mounttypes.PropagationRSlave, }, } apiClient := testEnv.APIClient() cid := container.Run(ctx, t, apiClient, container.WithMount(bindMount), container.WithCmd("sleep", "120s")) getMounts := func() string { t.Helper() res, err := container.Exec(ctx, apiClient, cid, []string{"cat", "/proc/self/mountinfo"}) assert.NilError(t, err) assert.Equal(t, res.ExitCode, 0) return res.Stdout() } mountsBefore := getMounts() _, err := apiClient.CopyFromContainer(ctx, cid, client.CopyFromContainerOptions{SourcePath: "/etc/passwd"}) assert.NilError(t, err) mountsAfter := getMounts() assert.Equal(t, mountsBefore, mountsAfter) } func TestContainerBindMountReadOnlyDefault(t *testing.T) { skip.If(t, testEnv.IsRemoteDaemon) skip.If(t, !isRROSupported(), "requires recursive read-only mounts") ctx := setupTest(t) // The test will run a container with a simple readonly /dev bind mount (-v /dev:/dev:ro) // It will then check /proc/self/mountinfo for the mount type of /dev/shm (submount of /dev) // If /dev/shm is rw, that will mean that the read-only mounts are NOT recursive by default. const nonRecursive = " /dev/shm rw," // If /dev/shm is ro, that will mean that the read-only mounts ARE recursive by default. const recursive = " /dev/shm ro," for _, tc := range []struct { clientVersion string expectedOut string name string }{ {clientVersion: "", expectedOut: recursive, name: "latest should be the same as 1.44"}, {clientVersion: "1.44", expectedOut: recursive, name: "submount should be recursive by default on 1.44"}, {clientVersion: "1.43", expectedOut: nonRecursive, name: "older than 1.44 should be non-recursive by default"}, // TODO: Remove when DefaultMinAPIVersion >= 1.44 {clientVersion: config.MinAPIVersion, expectedOut: nonRecursive, name: "minimum API should be non-recursive by default"}, } { t.Run(tc.name, func(t *testing.T) { apiClient := testEnv.APIClient() minDaemonVersion := tc.clientVersion if minDaemonVersion == "" { minDaemonVersion = "1.44" } skip.If(t, versions.LessThan(testEnv.DaemonAPIVersion(), minDaemonVersion), "requires API v"+minDaemonVersion) if tc.clientVersion != "" { c, err := client.New(client.FromEnv, client.WithAPIVersion(tc.clientVersion)) assert.NilError(t, err, "failed to create client with version v%s", tc.clientVersion) apiClient = c } for _, tc2 := range []struct { subname string mountOpt func(*container.TestContainerConfig) }{ {"mount", container.WithMount(mounttypes.Mount{ Type: mounttypes.TypeBind, Source: "/dev", Target: "/dev", ReadOnly: true, })}, {"bind mount", container.WithBindRaw("/dev:/dev:ro")}, } { t.Run(tc2.subname, func(t *testing.T) { cid := container.Run(ctx, t, apiClient, tc2.mountOpt, container.WithCmd("sh", "-c", "grep /dev/shm /proc/self/mountinfo"), ) out, err := container.Output(ctx, apiClient, cid) assert.NilError(t, err) assert.Check(t, is.Equal(out.Stderr, "")) // Output should be either: // 545 526 0:160 / /dev/shm ro,nosuid,nodev,noexec,relatime shared:90 - tmpfs shm rw,size=65536k // or // 545 526 0:160 / /dev/shm rw,nosuid,nodev,noexec,relatime shared:90 - tmpfs shm rw,size=65536k assert.Check(t, is.Contains(out.Stdout, tc.expectedOut)) }) } }) } } func TestContainerBindMountRecursivelyReadOnly(t *testing.T) { skip.If(t, testEnv.IsRemoteDaemon) skip.If(t, versions.LessThan(testEnv.DaemonAPIVersion(), "1.44"), "requires API v1.44") ctx := setupTest(t) // 0o777 for allowing rootless containers to write to this directory tmpDir1 := fs.NewDir(t, "tmpdir1", fs.WithMode(0o777), fs.WithDir("mnt", fs.WithMode(0o777))) defer tmpDir1.Remove() tmpDir1Mnt := filepath.Join(tmpDir1.Path(), "mnt") tmpDir2 := fs.NewDir(t, "tmpdir2", fs.WithMode(0o777), fs.WithFile("file", "should not be writable when recursively read only", fs.WithMode(0o666))) defer tmpDir2.Remove() if err := mount.Mount(tmpDir2.Path(), tmpDir1Mnt, "none", "bind"); err != nil { t.Fatal(err) } defer func() { if err := mount.Unmount(tmpDir1Mnt); err != nil { t.Fatal(err) } }() rroSupported := isRROSupported() nonRecursiveVerifier := []string{`/bin/sh`, `-xc`, `touch /foo/mnt/file; [ $? = 0 ]`} forceRecursiveVerifier := []string{`/bin/sh`, `-xc`, `touch /foo/mnt/file; [ $? != 0 ]`} // ro (recursive if kernel >= 5.12) ro := mounttypes.Mount{ Type: mounttypes.TypeBind, Source: tmpDir1.Path(), Target: "/foo", ReadOnly: true, BindOptions: &mounttypes.BindOptions{ Propagation: mounttypes.PropagationRPrivate, }, } roAsStr := ro.Source + ":" + ro.Target + ":ro,rprivate" roVerifier := nonRecursiveVerifier if rroSupported { roVerifier = forceRecursiveVerifier } // Non-recursive nonRecursive := ro nonRecursive.BindOptions = &mounttypes.BindOptions{ ReadOnlyNonRecursive: true, Propagation: mounttypes.PropagationRPrivate, } // Force recursive forceRecursive := ro forceRecursive.BindOptions = &mounttypes.BindOptions{ ReadOnlyForceRecursive: true, Propagation: mounttypes.PropagationRPrivate, } apiClient := testEnv.APIClient() containers := []string{ container.Run(ctx, t, apiClient, container.WithMount(ro), container.WithCmd(roVerifier...), container.WithSecurityOpt("label=disable")), container.Run(ctx, t, apiClient, container.WithBindRaw(roAsStr), container.WithCmd(roVerifier...), container.WithSecurityOpt("label=disable")), container.Run(ctx, t, apiClient, container.WithMount(nonRecursive), container.WithCmd(nonRecursiveVerifier...), container.WithSecurityOpt("label=disable")), } if rroSupported { containers = append(containers, container.Run(ctx, t, apiClient, container.WithMount(forceRecursive), container.WithCmd(forceRecursiveVerifier...), container.WithSecurityOpt("label=disable")), ) } for _, c := range containers { poll.WaitOn(t, container.IsSuccessful(ctx, apiClient, c)) } } func isRROSupported() bool { return kernel.CheckKernelVersion(5, 12, 0) }