diff --git a/README.md b/README.md index 90b92ff807e..318435c215a 100644 --- a/README.md +++ b/README.md @@ -226,7 +226,10 @@ Trivial: - Recursive read-only (RRO) bind-mount: `nerdctl run -v /mnt:/mnt:rro` (make children such as `/mnt/usb` to be read-only, too). Requires kernel >= 5.12. -The same feature was later introduced in Docker v25 with a different syntax. nerdctl will support Docker v25 syntax too in the future. + The same feature was later introduced in Docker v25 with a different syntax: read-only mounts are now recursively read-only by default when supported, + and the behavior is customizable with `--mount type=bind,...,readonly,bind-recursive=`. + nerdctl now supports the Docker v25 syntax too, and the old `rro` syntax is deprecated. + ## Similar tools - [`ctr`](https://github.com/containerd/containerd/tree/main/cmd/ctr): incompatible with Docker CLI, and not friendly to users. diff --git a/cmd/nerdctl/container/container_run_mount_linux_test.go b/cmd/nerdctl/container/container_run_mount_linux_test.go index c94dd004a4b..76a204588c1 100644 --- a/cmd/nerdctl/container/container_run_mount_linux_test.go +++ b/cmd/nerdctl/container/container_run_mount_linux_test.go @@ -19,6 +19,7 @@ package container import ( "fmt" "os" + "path/filepath" "strings" "testing" @@ -31,6 +32,7 @@ import ( "github.com/containerd/nerdctl/mod/tigron/tig" "github.com/containerd/nerdctl/v2/pkg/mountutil" + "github.com/containerd/nerdctl/v2/pkg/ociruntimeutil" "github.com/containerd/nerdctl/v2/pkg/testutil" "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" ) @@ -744,6 +746,145 @@ func TestRunVolumeBindMode(t *testing.T) { testCase.Run(t) } +// requiresRRO requires that the kernel and the default OCI runtime support +// recursive read-only (RRO) mounts. +var requiresRRO = &test.Requirement{ + Check: func(data test.Data, helpers test.Helpers) (bool, string) { + if err := ociruntimeutil.SupportsRecursivelyReadOnly(""); err != nil { + return false, fmt.Sprintf("recursive read-only mounts are not supported: %v", err) + } + return true, "recursive read-only mounts are supported" + }, +} + +// setupBindMountWithSubmount creates a temp directory ("top") containing a +// writable submount ("top/mnt"), for testing recursive read-only (RRO) mounts, +// and stores the path of the top directory in the "top" label. +func setupBindMountWithSubmount(data test.Data, helpers test.Helpers) { + top := data.Temp().Dir("top") + topMnt := data.Temp().Dir("top", "mnt") + sub := data.Temp().Dir("sub") + assert.NilError(helpers.T(), mobymount.Mount(sub, topMnt, "none", "bind")) + data.Labels().Set("top", top) +} + +func cleanupBindMountWithSubmount(data test.Data, helpers test.Helpers) { + if top := data.Labels().Get("top"); top != "" { + topMnt := filepath.Join(top, "mnt") + if err := mobymount.Unmount(topMnt); err != nil { + helpers.T().Log(fmt.Sprintf("failed to unmount %q: %v", topMnt, err)) + } + } +} + +// TestRunBindMountRecursiveReadOnly tests that read-only bind mounts are +// recursively read-only when the kernel and the OCI runtime support it +// (Docker v25 behavior), and that the mode is customizable with the +// `bind-recursive` option of `--mount`. +func TestRunBindMountRecursiveReadOnly(t *testing.T) { + testCase := nerdtest.Setup() + + // The test creates a bind mount on the host, in a mount namespace shared + // with the daemon. With the rootless harness, the test process runs on the + // host, while the daemon runs inside the mount namespace of RootlessKit, + // so the mount would not be visible to the daemon. + testCase.Require = require.All( + require.Not(nerdtest.Rootless), + requiresRRO, + ) + + testCase.Setup = setupBindMountWithSubmount + + testCase.SubTests = []*test.Case{ + { + Description: "-v :ro is recursively read-only by default", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--rm", + "-v", data.Labels().Get("top")+":/mnt1:ro", + testutil.AlpineImage, + "sh", "-euxc", "! touch /mnt1/mnt/file") + }, + Expected: test.Expects(expect.ExitCodeSuccess, nil, nil), + }, + { + Description: "--mount readonly is recursively read-only by default", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--rm", + "--mount", fmt.Sprintf("type=bind,src=%s,target=/mnt1,readonly", data.Labels().Get("top")), + testutil.AlpineImage, + "sh", "-euxc", "! touch /mnt1/mnt/file") + }, + Expected: test.Expects(expect.ExitCodeSuccess, nil, nil), + }, + { + Description: "bind-recursive=writable keeps the submounts writable (Docker v24 behavior)", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--rm", + "--mount", fmt.Sprintf("type=bind,src=%s,target=/mnt1,readonly,bind-recursive=writable", data.Labels().Get("top")), + testutil.AlpineImage, + "sh", "-euxc", "! touch /mnt1/file && touch /mnt1/mnt/file") + }, + Expected: test.Expects(expect.ExitCodeSuccess, nil, nil), + }, + { + Description: "bind-recursive=readonly forces the recursive read-only mount", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--rm", + "--mount", fmt.Sprintf("type=bind,src=%s,target=/mnt1,readonly,bind-propagation=rprivate,bind-recursive=readonly", data.Labels().Get("top")), + testutil.AlpineImage, + "sh", "-euxc", "! touch /mnt1/mnt/file") + }, + Expected: test.Expects(expect.ExitCodeSuccess, nil, nil), + }, + } + + testCase.Cleanup = cleanupBindMountWithSubmount + + testCase.Run(t) +} + +// TestRunBindMountDeprecatedRRO tests the deprecated `rro` option of `-v` and +// `--mount`, which predates the `bind-recursive=readonly` option of Docker v25. +func TestRunBindMountDeprecatedRRO(t *testing.T) { + testCase := nerdtest.Setup() + + // See TestRunBindMountRecursiveReadOnly for the rootless restriction. + testCase.Require = require.All( + require.Not(nerdtest.Docker), + require.Not(nerdtest.Rootless), + requiresRRO, + ) + + testCase.Setup = setupBindMountWithSubmount + + testCase.SubTests = []*test.Case{ + { + Description: "-v :rro", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--rm", + "-v", data.Labels().Get("top")+":/mnt1:rro,rprivate", + testutil.AlpineImage, + "sh", "-euxc", "! touch /mnt1/mnt/file") + }, + Expected: test.Expects(expect.ExitCodeSuccess, nil, nil), + }, + { + Description: "--mount rro", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--rm", + "--mount", fmt.Sprintf("type=bind,src=%s,target=/mnt1,rro,bind-propagation=rprivate", data.Labels().Get("top")), + testutil.AlpineImage, + "sh", "-euxc", "! touch /mnt1/mnt/file") + }, + Expected: test.Expects(expect.ExitCodeSuccess, nil, nil), + }, + } + + testCase.Cleanup = cleanupBindMountWithSubmount + + testCase.Run(t) +} + func TestRunBindMountPropagation(t *testing.T) { testCase := nerdtest.Setup() testCase.Setup = func(data test.Data, helpers test.Helpers) { diff --git a/docs/command-reference.md b/docs/command-reference.md index 3eb172bae10..726655e9ea2 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -280,11 +280,14 @@ Runtime flags: Volume flags: -- :whale: `-v, --volume :[:]`: Bind mount a volume, e.g., `-v /mnt:/mnt:rro,rprivate` +- :whale: `-v, --volume :[:]`: Bind mount a volume, e.g., `-v /mnt:/mnt:ro` - :whale: option `rw` : Read/Write (when writable) - - :whale: option `ro` : Non-recursive read-only - - :nerd_face: option `rro`: Recursive read-only. Should be used in conjunction with `rprivate`. e.g., `-v /mnt:/mnt:rro,rprivate` makes children such as `/mnt/usb` to be read-only, too. - Requires kernel >= 5.12, and crun >= 1.4 or runc >= 1.1 (PR [#3272](https://github.com/opencontainers/runc/pull/3272)). With older runc, `rro` just works as `ro`. + - :whale: option `ro` : Read-only. Recursively read-only (e.g., making children such as `/mnt/usb` read-only, too) when the kernel and the OCI runtime support it + (kernel >= 5.12, and runc >= 1.1 or crun >= 1.8.6, as in Docker v25), otherwise non-recursive read-only. + Use `--mount type=bind,...,readonly,bind-recursive=` to control the recursive read-only mode explicitly. + - :nerd_face: option `rro`: **Deprecated** since the same feature was introduced in Docker v25 with a different syntax; use `--mount type=bind,...,readonly,bind-propagation=rprivate,bind-recursive=readonly` instead. + Recursive read-only. Should be used in conjunction with `rprivate`. e.g., `-v /mnt:/mnt:rro,rprivate` makes children such as `/mnt/usb` to be read-only, too. + Requires kernel >= 5.12, and runc >= 1.1 or crun >= 1.8.6; an error is raised when the recursive read-only mount is not supported. - :whale: option `shared`, `slave`, `private`: Non-recursive "shared" / "slave" / "private" propagation - :whale: option `rshared`, `rslave`, `rprivate`: Recursive "shared" / "slave" / "private" propagation - :nerd_face: option `bind`: Not-recursively bind-mounted @@ -302,12 +305,20 @@ Volume flags: - Common Options: - :whale: `src`, `source`: Mount source spec for bind and volume. Mandatory for bind. - :whale: `dst`, `destination`, `target`: Mount destination spec. - - :whale: `readonly`, `ro`: mount the filesystem read-only. - - :nerd_face: `rro`: mount the filesystem recursively read-only. + - :whale: `readonly`, `ro`: mount the filesystem read-only. Recursively read-only when the kernel and the OCI runtime support it + (kernel >= 5.12, and runc >= 1.1 or crun >= 1.8.6, as in Docker v25). See the `bind-recursive` option below to control the recursive read-only mode explicitly. + - :nerd_face: `rro`: **Deprecated** since the same feature was introduced in Docker v25 with a different syntax; use `readonly` with `bind-propagation=rprivate` and `bind-recursive=readonly` instead. + Mount the filesystem recursively read-only. - Options specific to `bind`: - :whale: `bind-propagation`: `shared`, `slave`, `private`, `rshared`, `rslave`, or `rprivate`(default). - - :whale: `bind-recursive`: `enabled`(default) or `disabled`. If set to `disabled`, submounts are not recursively bind-mounted. This option is useful for readonly bind mount. - - :whale: `bind-nonrecursive`: `true` or `false`(default). Deprecated alias for `bind-recursive=disabled` / `bind-recursive=enabled`. If set to true, submounts are not recursively bind-mounted. + - :whale: `bind-recursive`: `enabled`(default), `disabled`, `writable`, or `readonly`. + - `enabled`: submounts are recursively bind-mounted, and a `readonly` mount is recursively read-only when the kernel and the OCI runtime support it. + - `disabled`: submounts are not recursively bind-mounted. + - `writable`: submounts of a `readonly` mount are kept writable (the default behavior of Docker until v24). + - `readonly`: a `readonly` mount is forced to be recursively read-only; an error is raised when the kernel or the OCI runtime does not support it. + Requires `bind-propagation=rprivate` to be specified in conjunction. + Whether the OCI runtime supports recursive read-only mounts is detected by running `$RUNTIME features`, and the result is cached in the XDG cache directory (e.g., `~/.cache/nerdctl/oci-runtime-features`). + - :whale: `bind-nonrecursive`: `true` or `false`(default). Deprecated alias for `bind-recursive=disabled` / `bind-recursive=enabled` (removed in Docker v29). If set to true, submounts are not recursively bind-mounted. - unimplemented options: `consistency` - Options specific to `tmpfs`: - :whale: `tmpfs-size`: Size of the tmpfs mount in bytes. Unlimited by default. diff --git a/docs/dir.md b/docs/dir.md index 43da6dff528..c842b09f4ff 100644 --- a/docs/dir.md +++ b/docs/dir.md @@ -72,3 +72,10 @@ and its networks definitions are private. Files: - `nerdctl-.conflist`: CNI conf list created by nerdctl + +## Cache + +### `/.nerdctl/oci-runtime-features/.json` +e.g., `~/.cache/nerdctl/oci-runtime-features/67865418f7d73228cb3df1357ba93456ab21567f3c1f1b5eca680b0b8cfbc04e.json` + +A cached result of ` features` (e.g., `runc features`). diff --git a/pkg/cmd/container/run_mount.go b/pkg/cmd/container/run_mount.go index 5850cad92f0..266ca070c82 100644 --- a/pkg/cmd/container/run_mount.go +++ b/pkg/cmd/container/run_mount.go @@ -96,7 +96,7 @@ func parseMountFlags(volStore volumestore.VolumeStore, options types.ContainerCr var parsed []*mountutil.Processed //nolint:prealloc for _, v := range strutil.DedupeStrSlice(options.Volume) { // createDir=true for -v option to allow creation of directory on host if not found. - x, err := mountutil.ProcessFlagV(v, volStore, true) + x, err := mountutil.ProcessFlagV(v, volStore, true, options.Runtime) if err != nil { return nil, err } @@ -112,7 +112,7 @@ func parseMountFlags(volStore volumestore.VolumeStore, options types.ContainerCr } for _, v := range strutil.DedupeStrSlice(options.Mount) { - x, err := mountutil.ProcessFlagMount(v, volStore) + x, err := mountutil.ProcessFlagMount(v, volStore, options.Runtime) if err != nil { return nil, err } diff --git a/pkg/containerutil/cp_resolve_linux.go b/pkg/containerutil/cp_resolve_linux.go index 1ee747730c2..39fb11d5b1e 100644 --- a/pkg/containerutil/cp_resolve_linux.go +++ b/pkg/containerutil/cp_resolve_linux.go @@ -293,7 +293,7 @@ func (res *resolver) getMount(path string) (*locator, string) { if len(mnt.Destination) > len(loc.containerPath) { loc.readonly = false for _, option := range mnt.Options { - if option == "ro" { + if option == "ro" || option == "rro" { loc.readonly = true } } diff --git a/pkg/mountutil/mountutil.go b/pkg/mountutil/mountutil.go index d55a2cb6646..2f78cb7f1f8 100644 --- a/pkg/mountutil/mountutil.go +++ b/pkg/mountutil/mountutil.go @@ -59,7 +59,10 @@ type volumeSpec struct { AnonymousVolume string } -func ProcessFlagV(s string, volStore volumestore.VolumeStore, createDir bool) (*Processed, error) { +// ProcessFlagV processes the value of the `-v` flag. +// ociRuntime is the value of the `--runtime` flag, used for detecting whether the +// OCI runtime supports recursive read-only mounts. +func ProcessFlagV(s string, volStore volumestore.VolumeStore, createDir bool, ociRuntime string) (*Processed, error) { var ( res *Processed volSpec volumeSpec @@ -119,7 +122,7 @@ func ProcessFlagV(s string, volStore volumestore.VolumeStore, createDir bool) (* rawOpts := res.Mode - options, res.Opts, err = getVolumeOptions(src, res.Type, rawOpts) + options, res.Opts, err = getVolumeOptions(src, res.Type, rawOpts, ociRuntime) if err != nil { return nil, err } @@ -215,16 +218,12 @@ func handleNamedVolumes(source string, volStore volumestore.VolumeStore) (volume return res, nil } -func getVolumeOptions(src string, vType string, rawOpts string) ([]string, []oci.SpecOpts, error) { +func getVolumeOptions(src, vType, rawOpts, ociRuntime string) ([]string, []oci.SpecOpts, error) { // always call parseVolumeOptions for bind mount to allow the parser to add some default options - var err error - var specOpts []oci.SpecOpts - options, specOpts, err := parseVolumeOptions(vType, src, rawOpts) + options, specOpts, err := parseVolumeOptions(vType, src, rawOpts, ociRuntime) if err != nil { return nil, nil, fmt.Errorf("failed to parse volume options (%q, %q, %q): %w", vType, src, rawOpts, err) } - - specOpts = append(specOpts, specOpts...) return options, specOpts, nil } diff --git a/pkg/mountutil/mountutil_darwin.go b/pkg/mountutil/mountutil_darwin.go index c86d9a3cdec..8d14b76b7f4 100644 --- a/pkg/mountutil/mountutil_darwin.go +++ b/pkg/mountutil/mountutil_darwin.go @@ -40,7 +40,7 @@ func UnprivilegedMountFlags(path string) ([]string, error) { // parseVolumeOptions parses specified optsRaw with using information of // the volume type and the src directory when necessary. -func parseVolumeOptions(vType, src, optsRaw string) ([]string, []oci.SpecOpts, error) { +func parseVolumeOptions(vType, src, optsRaw, ociRuntime string) ([]string, []oci.SpecOpts, error) { var writeModeRawOpts []string for _, opt := range strings.Split(optsRaw, ",") { switch opt { @@ -67,6 +67,6 @@ func ProcessFlagTmpfs(s string) (*Processed, error) { return nil, errdefs.ErrNotImplemented } -func ProcessFlagMount(s string, volStore volumestore.VolumeStore) (*Processed, error) { +func ProcessFlagMount(s string, volStore volumestore.VolumeStore, ociRuntime string) (*Processed, error) { return nil, errdefs.ErrNotImplemented } diff --git a/pkg/mountutil/mountutil_freebsd.go b/pkg/mountutil/mountutil_freebsd.go index 58b32075b82..21749c93d33 100644 --- a/pkg/mountutil/mountutil_freebsd.go +++ b/pkg/mountutil/mountutil_freebsd.go @@ -41,7 +41,7 @@ func UnprivilegedMountFlags(path string) ([]string, error) { // parseVolumeOptions parses specified optsRaw with using information of // the volume type and the src directory when necessary. -func parseVolumeOptions(vType, src, optsRaw string) ([]string, []oci.SpecOpts, error) { +func parseVolumeOptions(vType, src, optsRaw, ociRuntime string) ([]string, []oci.SpecOpts, error) { var writeModeRawOpts []string for _, opt := range strings.Split(optsRaw, ",") { switch opt { @@ -68,6 +68,6 @@ func ProcessFlagTmpfs(s string) (*Processed, error) { return nil, errdefs.ErrNotImplemented } -func ProcessFlagMount(s string, volStore volumestore.VolumeStore) (*Processed, error) { +func ProcessFlagMount(s string, volStore volumestore.VolumeStore, ociRuntime string) (*Processed, error) { return nil, errdefs.ErrNotImplemented } diff --git a/pkg/mountutil/mountutil_linux.go b/pkg/mountutil/mountutil_linux.go index b40cf9750ec..28c67e4dbbd 100644 --- a/pkg/mountutil/mountutil_linux.go +++ b/pkg/mountutil/mountutil_linux.go @@ -37,6 +37,8 @@ import ( "github.com/containerd/log" "github.com/containerd/nerdctl/v2/pkg/mountutil/volumestore" + "github.com/containerd/nerdctl/v2/pkg/ociruntimeutil" + "github.com/containerd/nerdctl/v2/pkg/strutil" ) /* @@ -90,10 +92,56 @@ func UnprivilegedMountFlags(path string) ([]string, error) { return flags, nil } +// supportsRecursivelyReadOnly is replaced in unit tests. +var supportsRecursivelyReadOnly = ociruntimeutil.SupportsRecursivelyReadOnly + +// readOnlyMode is the read-only mode of a mount. +// The modes correspond to the BindOptions of the Docker API >= v1.44. +// https://github.com/moby/moby/pull/45278 +type readOnlyMode int + +const ( + // readOnlyModeRecursiveIfPossible makes the mount recursively read-only when + // the kernel and the OCI runtime support the "rro" mount option, and falls + // back to the plain (non-recursive) read-only otherwise. + // This is the default mode of read-only mounts since Docker v25. + readOnlyModeRecursiveIfPossible readOnlyMode = iota + // readOnlyModeNonRecursive makes the mount read-only, but keeps its submounts + // writable. This was the default mode of read-only mounts until Docker v24. + // Corresponds to `--mount type=bind,readonly,bind-recursive=writable`. + readOnlyModeNonRecursive + // readOnlyModeForceRecursive makes the mount recursively read-only, or + // raises an error when the kernel or the OCI runtime does not support "rro". + // Corresponds to `--mount type=bind,readonly,bind-recursive=readonly`. + readOnlyModeForceRecursive +) + +// readOnlyMountOptions returns the mount options for the given read-only mode. +// Whether the OCI runtime supports the "rro" mount option is detected by running +// `$RUNTIME features`; ociRuntime is the value of the `--runtime` flag. +func readOnlyMountOptions(mode readOnlyMode, ociRuntime string) ([]string, error) { + switch mode { + case readOnlyModeRecursiveIfPossible: + if err := supportsRecursivelyReadOnly(ociRuntime); err != nil { + log.L.WithError(err).Debug("recursive read-only mounts are not supported, falling back to non-recursive read-only") + return []string{"ro"}, nil + } + return []string{"rro"}, nil + case readOnlyModeNonRecursive: + return []string{"ro"}, nil + case readOnlyModeForceRecursive: + if err := supportsRecursivelyReadOnly(ociRuntime); err != nil { + return nil, err + } + return []string{"rro"}, nil + } + return nil, fmt.Errorf("unexpected read-only mode %v", mode) +} + // parseVolumeOptions parses specified optsRaw with using information of // the volume type and the src directory when necessary. -func parseVolumeOptions(vType, src, optsRaw string) ([]string, []oci.SpecOpts, error) { - return parseVolumeOptionsWithMountInfo(vType, src, optsRaw, getMountInfo) +func parseVolumeOptions(vType, src, optsRaw, ociRuntime string) ([]string, []oci.SpecOpts, error) { + return parseVolumeOptionsWithMountInfo(vType, src, optsRaw, ociRuntime, getMountInfo) } // getMountInfo gets mount.Info of a directory. @@ -107,7 +155,7 @@ func getMountInfo(dir string) (mount.Info, error) { // parseVolumeOptionsWithMountInfo is the testable implementation // of parseVolumeOptions. -func parseVolumeOptionsWithMountInfo(vType, src, optsRaw string, getMountInfoFunc func(string) (mount.Info, error)) ([]string, []oci.SpecOpts, error) { +func parseVolumeOptionsWithMountInfo(vType, src, optsRaw, ociRuntime string, getMountInfoFunc func(string) (mount.Info, error)) ([]string, []oci.SpecOpts, error) { var ( writeModeRawOpts []string propagationRawOpts []string @@ -154,14 +202,25 @@ func parseVolumeOptionsWithMountInfo(vType, src, optsRaw string, getMountInfoFun } else if len(writeModeRawOpts) > 0 { switch writeModeRawOpts[0] { case "ro": - opts = append(opts, "ro") + // Docker (since v25) attempts to make the mount recursively read-only. + // https://github.com/moby/moby/pull/45278 + roOpts, err := readOnlyMountOptions(readOnlyModeRecursiveIfPossible, ociRuntime) + if err != nil { + return nil, nil, err + } + opts = append(opts, roOpts...) case "rro": - // Mount option "rro" is supported since crun v1.4 / runc v1.1 (https://github.com/opencontainers/runc/pull/3272), with kernel >= 5.12. - // Older version of runc just ignores "rro", so we have to add "ro" too, to our best effort. - opts = append(opts, "ro", "rro") + // "rro" was introduced in nerdctl v0.14 (2021), ahead of Docker. + // Docker v25 introduced `--mount type=bind,...,readonly,bind-recursive=readonly` instead. + log.L.Warn("The volume option \"rro\" is deprecated; use `--mount type=bind,src=...,dst=...,readonly,bind-propagation=rprivate,bind-recursive=readonly` instead") if len(propagationRawOpts) != 1 || propagationRawOpts[0] != "rprivate" { log.L.Warn("Mount option \"rro\" should be used in conjunction with \"rprivate\"") } + roOpts, err := readOnlyMountOptions(readOnlyModeForceRecursive, ociRuntime) + if err != nil { + return nil, nil, err + } + opts = append(opts, roOpts...) case "rw": // NOP default: @@ -301,7 +360,7 @@ func ProcessFlagTmpfs(s string) (*Processed, error) { return res, nil } -func ProcessFlagMount(s string, volStore volumestore.VolumeStore) (*Processed, error) { +func ProcessFlagMount(s string, volStore volumestore.VolumeStore, ociRuntime string) (*Processed, error) { fields := strings.Split(s, ",") var ( mountType string @@ -309,6 +368,7 @@ func ProcessFlagMount(s string, volStore volumestore.VolumeStore) (*Processed, e dst string bindPropagation string bindNonRecursive bool + bindRecursive string // "enabled", "disabled", "writable", or "readonly" rwOption string tmpfsSize int64 tmpfsMode os.FileMode @@ -332,11 +392,16 @@ func ProcessFlagMount(s string, volStore volumestore.VolumeStore) (*Processed, e if len(parts) == 1 { switch key { - case "readonly", "ro", "rro": + case "readonly", "ro": + rwOption = key + continue + case "rro": + log.L.Warn("The mount option \"rro\" is deprecated; use \"readonly\" with \"bind-propagation=rprivate\" and \"bind-recursive=readonly\" instead") rwOption = key continue case "bind-nonrecursive": // Removed in Docker v29, in favor of `bind-recursive=disabled` https://github.com/docker/cli/pull/6241 + log.L.Warn("The mount option \"bind-nonrecursive\" is deprecated; use \"bind-recursive=disabled\" instead") bindNonRecursive = true continue } @@ -367,6 +432,9 @@ func ProcessFlagMount(s string, volStore volumestore.VolumeStore) (*Processed, e if err != nil { return nil, fmt.Errorf("invalid value for %s: %s", key, value) } + if key == "rro" { + log.L.Warn("The mount option \"rro\" is deprecated; use \"readonly\" with \"bind-propagation=rprivate\" and \"bind-recursive=readonly\" instead") + } if trueValue { rwOption = key } @@ -376,29 +444,19 @@ func ProcessFlagMount(s string, volStore volumestore.VolumeStore) (*Processed, e bindPropagation = value case "bind-nonrecursive": // Removed in Docker v29, in favor of `bind-recursive=disabled` https://github.com/docker/cli/pull/6241 + log.L.Warn("The mount option \"bind-nonrecursive\" is deprecated; use \"bind-recursive=disabled\" instead") bindNonRecursive, err = strconv.ParseBool(value) if err != nil { return nil, fmt.Errorf("invalid value for %s: %s", key, value) } case "bind-recursive": - // bind-recursive is the Docker option that supersedes bind-nonrecursive. - valS := value - // Allow boolean as an alias to "enabled" or "disabled" - if b, err := strconv.ParseBool(valS); err == nil { - if b { - valS = "enabled" - } else { - valS = "disabled" - } - } - switch valS { - case "enabled": - bindNonRecursive = false - case "disabled": - bindNonRecursive = true + // bind-recursive is the Docker (v25) option that supersedes bind-nonrecursive. + // https://github.com/docker/cli/pull/4316 + switch value { + case "enabled", "disabled", "writable", "readonly": + bindRecursive = value default: - // TODO: support "writable", "readonly" - return nil, fmt.Errorf("invalid value for %s: %s (must be \"enabled\" or \"disabled\")", key, value) + return nil, fmt.Errorf("invalid value for %s: %s (must be \"enabled\", \"disabled\", \"writable\", or \"readonly\")", key, value) } case "tmpfs-size": tmpfsSize, err = units.RAMInBytes(value) @@ -416,20 +474,56 @@ func ProcessFlagMount(s string, volStore volumestore.VolumeStore) (*Processed, e } } + // Resolve the read-only mode of the mount, for Docker (v25) compatibility. + // https://github.com/docker/cli/pull/4316 + roMode := readOnlyModeRecursiveIfPossible + if rwOption == "rro" { + // Deprecated form: force RRO, like `bind-recursive=readonly` (but the + // propagation is not validated, for compatibility with older nerdctl). + roMode = readOnlyModeForceRecursive + if bindPropagation != "rprivate" { + log.L.Warn("Mount option \"rro\" should be used in conjunction with \"bind-propagation=rprivate\"") + } + } + if bindRecursive != "" { + if mountType != Bind { + return nil, fmt.Errorf("the option bind-recursive is only supported for bind mounts") + } + switch bindRecursive { + case "enabled": + bindNonRecursive = false + case "disabled": + bindNonRecursive = true + case "writable": + if rwOption == "" { + return nil, fmt.Errorf("the option 'bind-recursive=writable' requires 'readonly' to be specified in conjunction") + } + roMode = readOnlyModeNonRecursive + case "readonly": + if rwOption == "" { + return nil, fmt.Errorf("the option 'bind-recursive=readonly' requires 'readonly' to be specified in conjunction") + } + if bindPropagation != "rprivate" { + return nil, fmt.Errorf("the option 'bind-recursive=readonly' requires 'bind-propagation=rprivate' to be specified in conjunction") + } + if bindNonRecursive { + return nil, fmt.Errorf("the option 'bind-recursive=readonly' conflicts with 'bind-nonrecursive'") + } + roMode = readOnlyModeForceRecursive + } + } + // compose new fileds and join into a string // to call legacy ProcessFlagTmpfs or ProcessFlagV function fields = []string{} options := []string{} - if rwOption != "" { - if rwOption == "readonly" { - rwOption = "ro" - } - options = append(options, rwOption) - } switch mountType { case Tmpfs: fields = []string{dst} + if rwOption != "" { + options = append(options, "ro") + } if tmpfsMode != 0 { options = append(options, fmt.Sprintf("mode=%o", tmpfsMode)) } @@ -437,6 +531,9 @@ func ProcessFlagMount(s string, volStore volumestore.VolumeStore) (*Processed, e options = append(options, getTmpfsSize(tmpfsSize)) } case Volume, Bind: + // The read-only option is not composed here; it is applied to the + // processed mount below, as the legacy volume option syntax cannot + // express all the read-only modes. fields = []string{src, dst} if bindPropagation != "" { options = append(options, bindPropagation) @@ -463,7 +560,19 @@ func ProcessFlagMount(s string, volStore volumestore.VolumeStore) (*Processed, e return ProcessFlagTmpfs(fieldsStr) case Volume, Bind: // createDir=false for --mount option to disallow creating directories on host if not found - return ProcessFlagV(fieldsStr, volStore, false) + res, err := ProcessFlagV(fieldsStr, volStore, false, ociRuntime) + if err != nil { + return nil, err + } + if rwOption != "" { + roOpts, err := readOnlyMountOptions(roMode, ociRuntime) + if err != nil { + return nil, err + } + res.Mount.Options = strutil.DedupeStrSlice(append(res.Mount.Options, roOpts...)) + res.Mode = strings.Join(res.Mount.Options, ",") + } + return res, nil } return nil, fmt.Errorf("invalid mount type '%s' must be a volume/bind/tmpfs", mountType) } diff --git a/pkg/mountutil/mountutil_linux_test.go b/pkg/mountutil/mountutil_linux_test.go index f4209bc5aae..2074a3c85d7 100644 --- a/pkg/mountutil/mountutil_linux_test.go +++ b/pkg/mountutil/mountutil_linux_test.go @@ -18,6 +18,8 @@ package mountutil import ( "context" + "errors" + "fmt" "slices" "strings" "testing" @@ -30,6 +32,20 @@ import ( "github.com/containerd/containerd/v2/pkg/oci" ) +// stubRROSupport replaces the detection of the recursive read-only (RRO) +// support (which executes `$RUNTIME features` on the real implementation) +// for unit testing. +func stubRROSupport(t *testing.T, supported bool) { + orig := supportsRecursivelyReadOnly + supportsRecursivelyReadOnly = func(string) error { + if supported { + return nil + } + return errors.New("recursive read-only mounts are not supported (stubbed)") + } + t.Cleanup(func() { supportsRecursivelyReadOnly = orig }) +} + // TestParseVolumeOptions tests volume options are parsed as expected. func TestParseVolumeOptions(t *testing.T) { tests := []struct { @@ -37,6 +53,7 @@ func TestParseVolumeOptions(t *testing.T) { vType string src string optsRaw string + rroSupported bool srcOptional []string initialRootfsPropagation string wants []string @@ -66,6 +83,29 @@ func TestParseVolumeOptions(t *testing.T) { optsRaw: "ro", wants: []string{"ro"}, }, + { + name: "read only is recursive when the kernel and the runtime support RRO (Docker v25 behavior)", + vType: "bind", + src: "dummy", + optsRaw: "ro", + rroSupported: true, + wants: []string{"rro", "rprivate"}, + }, + { + name: "deprecated rro option forces recursive read-only", + vType: "bind", + src: "dummy", + optsRaw: "rro,rprivate", + rroSupported: true, + wants: []string{"rro", "rprivate"}, + }, + { + name: "deprecated rro option fails when RRO is not supported", + vType: "bind", + src: "dummy", + optsRaw: "rro,rprivate", + wantFail: true, + }, { name: "duplicated flags are not allowed", vType: "bind", @@ -173,7 +213,8 @@ func TestParseVolumeOptions(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - opts, specOpts, err := parseVolumeOptionsWithMountInfo(tt.vType, tt.src, tt.optsRaw, func(string) (mount.Info, error) { + stubRROSupport(t, tt.rroSupported) + opts, specOpts, err := parseVolumeOptionsWithMountInfo(tt.vType, tt.src, tt.optsRaw, "", func(string) (mount.Info, error) { return mount.Info{ Mountpoint: tt.src, Optional: strings.Join(tt.srcOptional, " "), @@ -268,7 +309,8 @@ func TestProcessFlagV(t *testing.T) { for _, tt := range tests { t.Run(tt.rawSpec, func(t *testing.T) { - processedVolSpec, err := ProcessFlagV(tt.rawSpec, mockVolumeStore, false) + stubRROSupport(t, false) + processedVolSpec, err := ProcessFlagV(tt.rawSpec, mockVolumeStore, false, "") if err != nil { assert.Error(t, err, tt.err) return @@ -333,7 +375,8 @@ func TestProcessFlagVAnonymousVolumes(t *testing.T) { for _, tt := range tests { t.Run(tt.rawSpec, func(t *testing.T) { - processedVolSpec, err := ProcessFlagV(tt.rawSpec, mockVolumeStore, true) + stubRROSupport(t, false) + processedVolSpec, err := ProcessFlagV(tt.rawSpec, mockVolumeStore, true, "") if err != nil { assert.ErrorContains(t, err, tt.err) return @@ -368,7 +411,7 @@ func TestProcessFlagMountRW(t *testing.T) { } for _, tt := range rejected { t.Run(tt.spec, func(t *testing.T) { - _, err := ProcessFlagMount(tt.spec, nil) + _, err := ProcessFlagMount(tt.spec, nil, "") assert.ErrorContains(t, err, tt.want) }) } @@ -376,8 +419,9 @@ func TestProcessFlagMountRW(t *testing.T) { // ro/rro still parse into a complete read-only bind mount. src := t.TempDir() accepted := []struct { - spec string - wants *Processed + spec string + rroSupported bool + wants *Processed }{ { spec: "type=bind,source=" + src + ",target=/bar,ro", @@ -387,26 +431,43 @@ func TestProcessFlagMountRW(t *testing.T) { Type: "bind", Source: src, Destination: "/bar", - Options: []string{"rbind", "ro", "rprivate"}, + Options: []string{"rbind", "rprivate", "ro"}, }, }, }, { - spec: "type=bind,source=" + src + ",target=/bar,rro", + // Read-only mounts are recursively read-only when possible (Docker v25 behavior). + spec: "type=bind,source=" + src + ",target=/bar,ro", + rroSupported: true, wants: &Processed{ Type: Bind, Mount: specs.Mount{ Type: "bind", Source: src, Destination: "/bar", - Options: []string{"rbind", "ro", "rro", "rprivate"}, + Options: []string{"rbind", "rprivate", "rro"}, + }, + }, + }, + { + // Deprecated alias of readonly,bind-propagation=rprivate,bind-recursive=readonly + spec: "type=bind,source=" + src + ",target=/bar,rro", + rroSupported: true, + wants: &Processed{ + Type: Bind, + Mount: specs.Mount{ + Type: "bind", + Source: src, + Destination: "/bar", + Options: []string{"rbind", "rprivate", "rro"}, }, }, }, } for _, tt := range accepted { - t.Run(tt.spec, func(t *testing.T) { - got, err := ProcessFlagMount(tt.spec, nil) + t.Run(fmt.Sprintf("%s (rroSupported=%v)", tt.spec, tt.rroSupported), func(t *testing.T) { + stubRROSupport(t, tt.rroSupported) + got, err := ProcessFlagMount(tt.spec, nil, "") assert.NilError(t, err) assert.Equal(t, got.Type, tt.wants.Type) assert.Equal(t, got.Mount.Type, tt.wants.Mount.Type) @@ -415,35 +476,95 @@ func TestProcessFlagMountRW(t *testing.T) { assert.DeepEqual(t, got.Mount.Options, tt.wants.Mount.Options) }) } + + // The deprecated rro option fails when RRO is not supported. + stubRROSupport(t, false) + _, err := ProcessFlagMount("type=bind,source="+src+",target=/bar,rro", nil, "") + assert.ErrorContains(t, err, "not supported") } // TestProcessFlagMountBindRecursive verifies that the Docker `bind-recursive` // option (which supersedes the deprecated `bind-nonrecursive`) is honored and -// maps to non-recursive (bind) or recursive (rbind) mounts. +// maps to non-recursive (bind) or recursive (rbind) mounts, and controls the +// recursive read-only (RRO) mode of read-only mounts. func TestProcessFlagMountBindRecursive(t *testing.T) { src := t.TempDir() accepted := []struct { - spec string - wantBindOpt string + spec string + rroSupported bool + wantOpts []string }{ - {"type=bind,source=" + src + ",target=/bar,bind-recursive=disabled", "bind"}, - {"type=bind,source=" + src + ",target=/bar,bind-recursive=enabled", "rbind"}, - {"type=bind,source=" + src + ",target=/bar,bind-recursive=false", "bind"}, - {"type=bind,source=" + src + ",target=/bar,bind-recursive=1", "rbind"}, + {spec: "type=bind,source=" + src + ",target=/bar,bind-recursive=disabled", wantOpts: []string{"bind"}}, + {spec: "type=bind,source=" + src + ",target=/bar,bind-recursive=enabled", wantOpts: []string{"rbind"}}, // The deprecated bind-nonrecursive option keeps working. - {"type=bind,source=" + src + ",target=/bar,bind-nonrecursive", "bind"}, - {"type=bind,source=" + src + ",target=/bar,bind-nonrecursive=false", "rbind"}, + {spec: "type=bind,source=" + src + ",target=/bar,bind-nonrecursive", wantOpts: []string{"bind"}}, + {spec: "type=bind,source=" + src + ",target=/bar,bind-nonrecursive=false", wantOpts: []string{"rbind"}}, + // bind-recursive=writable keeps the read-only mount non-recursively read-only (Docker v24 behavior). + { + spec: "type=bind,source=" + src + ",target=/bar,readonly,bind-recursive=writable", + rroSupported: true, + wantOpts: []string{"rbind", "ro"}, + }, + // bind-recursive=readonly forces the recursive read-only mount. + { + spec: "type=bind,source=" + src + ",target=/bar,readonly,bind-propagation=rprivate,bind-recursive=readonly", + rroSupported: true, + wantOpts: []string{"rbind", "rro"}, + }, } for _, tt := range accepted { t.Run(tt.spec, func(t *testing.T) { - got, err := ProcessFlagMount(tt.spec, nil) + stubRROSupport(t, tt.rroSupported) + got, err := ProcessFlagMount(tt.spec, nil, "") assert.NilError(t, err) - assert.Assert(t, slices.Contains(got.Mount.Options, tt.wantBindOpt), - "expected option %q in %v", tt.wantBindOpt, got.Mount.Options) + for _, o := range tt.wantOpts { + assert.Assert(t, slices.Contains(got.Mount.Options, o), + "expected option %q in %v", o, got.Mount.Options) + } }) } - _, err := ProcessFlagMount("type=bind,source="+src+",target=/bar,bind-recursive=bogus", nil) - assert.ErrorContains(t, err, "invalid value for bind-recursive") + rejected := []struct { + spec string + rroSupported bool + want string + }{ + // Boolean aliases were removed for Docker compatibility (https://github.com/docker/cli/pull/4671). + {spec: "type=bind,source=" + src + ",target=/bar,bind-recursive=false", want: "invalid value for bind-recursive"}, + {spec: "type=bind,source=" + src + ",target=/bar,bind-recursive=1", want: "invalid value for bind-recursive"}, + {spec: "type=bind,source=" + src + ",target=/bar,bind-recursive=bogus", want: "invalid value for bind-recursive"}, + { + spec: "type=bind,source=" + src + ",target=/bar,bind-recursive=writable", + want: "'bind-recursive=writable' requires 'readonly'", + }, + { + spec: "type=bind,source=" + src + ",target=/bar,bind-recursive=readonly", + want: "'bind-recursive=readonly' requires 'readonly'", + }, + { + spec: "type=bind,source=" + src + ",target=/bar,readonly,bind-recursive=readonly", + want: "'bind-recursive=readonly' requires 'bind-propagation=rprivate'", + }, + { + spec: "type=bind,source=" + src + ",target=/bar,readonly,bind-propagation=rprivate,bind-nonrecursive,bind-recursive=readonly", + want: "conflicts with 'bind-nonrecursive'", + }, + { + spec: "type=volume,source=foo,target=/bar,bind-recursive=enabled", + want: "only supported for bind mounts", + }, + // bind-recursive=readonly fails when RRO is not supported. + { + spec: "type=bind,source=" + src + ",target=/bar,readonly,bind-propagation=rprivate,bind-recursive=readonly", + want: "not supported", + }, + } + for _, tt := range rejected { + t.Run(tt.spec, func(t *testing.T) { + stubRROSupport(t, tt.rroSupported) + _, err := ProcessFlagMount(tt.spec, nil, "") + assert.ErrorContains(t, err, tt.want) + }) + } } diff --git a/pkg/mountutil/mountutil_windows.go b/pkg/mountutil/mountutil_windows.go index d036d420580..69394be4992 100644 --- a/pkg/mountutil/mountutil_windows.go +++ b/pkg/mountutil/mountutil_windows.go @@ -54,7 +54,7 @@ func UnprivilegedMountFlags(path string) ([]string, error) { // parseVolumeOptions parses specified optsRaw with using information of // the volume type and the src directory when necessary. -func parseVolumeOptions(vType, src, optsRaw string) ([]string, []oci.SpecOpts, error) { +func parseVolumeOptions(vType, src, optsRaw, ociRuntime string) ([]string, []oci.SpecOpts, error) { var writeModeRawOpts []string for _, opt := range strings.Split(optsRaw, ",") { switch opt { @@ -81,7 +81,7 @@ func ProcessFlagTmpfs(s string) (*Processed, error) { return nil, errdefs.ErrNotImplemented } -func ProcessFlagMount(s string, volStore volumestore.VolumeStore) (*Processed, error) { +func ProcessFlagMount(s string, volStore volumestore.VolumeStore, ociRuntime string) (*Processed, error) { return nil, errdefs.ErrNotImplemented } diff --git a/pkg/mountutil/mountutil_windows_test.go b/pkg/mountutil/mountutil_windows_test.go index aeb55d2bb2c..5695a7e0c1b 100644 --- a/pkg/mountutil/mountutil_windows_test.go +++ b/pkg/mountutil/mountutil_windows_test.go @@ -67,7 +67,7 @@ func TestParseVolumeOptions(t *testing.T) { } for _, tt := range tests { t.Run(strings.Join([]string{tt.vType, tt.src, tt.optsRaw}, "-"), func(t *testing.T) { - opts, _, err := parseVolumeOptions(tt.vType, tt.src, tt.optsRaw) + opts, _, err := parseVolumeOptions(tt.vType, tt.src, tt.optsRaw, "") if err != nil { if tt.wantFail { return @@ -270,7 +270,7 @@ func TestProcessFlagV(t *testing.T) { for _, tt := range tests { t.Run(tt.rawSpec, func(t *testing.T) { - processedVolSpec, err := ProcessFlagV(tt.rawSpec, mockVolumeStore, true) + processedVolSpec, err := ProcessFlagV(tt.rawSpec, mockVolumeStore, true, "") if err != nil { assert.Error(t, err, tt.err) return @@ -327,7 +327,7 @@ func TestProcessFlagVAnonymousVolumes(t *testing.T) { for _, tt := range tests { t.Run(tt.rawSpec, func(t *testing.T) { - processedVolSpec, err := ProcessFlagV(tt.rawSpec, mockVolumeStore, true) + processedVolSpec, err := ProcessFlagV(tt.rawSpec, mockVolumeStore, true, "") if err != nil { assert.ErrorContains(t, err, tt.err) return diff --git a/pkg/ociruntimeutil/ociruntimeutil.go b/pkg/ociruntimeutil/ociruntimeutil.go new file mode 100644 index 00000000000..88f05baf766 --- /dev/null +++ b/pkg/ociruntimeutil/ociruntimeutil.go @@ -0,0 +1,179 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +// Package ociruntimeutil provides client-side utilities for inspecting OCI runtimes +// such as runc and crun. +package ociruntimeutil + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/opencontainers/runtime-spec/specs-go/features" + + "github.com/containerd/log" +) + +// BinaryFromRuntimeStr resolves the path of the OCI runtime binary from runtimeStr, +// which is the value of the `--runtime` flag of `nerdctl run`, e.g., +// "" (default), "io.containerd.runc.v2", "crun", or "/usr/local/sbin/runc". +// +// An error is returned when the binary cannot be determined, e.g., for a shim +// like "io.containerd.kata.v2" that does not expose the runtime binary name. +// +// The resolution is a best-effort guess of the client and may not match the +// actual binary used by the containerd daemon, e.g., when the daemon overrides +// the BinaryName option of the "io.containerd.runc.v2" shim in its config. +func BinaryFromRuntimeStr(runtimeStr string) (string, error) { + if runtimeStr == "" || strings.HasPrefix(runtimeStr, "io.containerd.runc.") { + // The "io.containerd.runc.v2" shim executes "runc" from $PATH by default. + return exec.LookPath("runc") + } + if strings.HasPrefix(runtimeStr, "io.containerd.") || runtimeStr == "wtf.sbk.runj.v1" { + return "", fmt.Errorf("cannot determine the OCI runtime binary for runtime %q", runtimeStr) + } + // runtimeStr refers to a binary such as "crun" or "/usr/local/sbin/runc" + // (consistent with generateRuntimeCOpts in pkg/cmd/container). + binary, err := exec.LookPath(runtimeStr) + if err != nil { + return "", fmt.Errorf("cannot determine the OCI runtime binary for runtime %q: %w", runtimeStr, err) + } + return binary, nil +} + +var ( + featuresCacheMu sync.Mutex + featuresCache = make(map[string]*features.Features) // key: the resolved binary path +) + +// Features returns the parsed output of ` features`. +// https://github.com/opencontainers/runtime-spec/blob/v1.2.1/features.md +// +// The `features` subcommand is supported by runc >= 1.1 and crun >= 1.8.6. +// +// The result is cached in the XDG cache directory (e.g., ~/.cache/nerdctl/oci-runtime-features), +// with the cache entry being invalidated when the binary is modified. +func Features(binary string) (*features.Features, error) { + binPath, err := exec.LookPath(binary) + if err != nil { + return nil, err + } + realPath, err := filepath.EvalSymlinks(binPath) + if err != nil { + return nil, err + } + + featuresCacheMu.Lock() + defer featuresCacheMu.Unlock() + if f, ok := featuresCache[realPath]; ok { + return f, nil + } + + cachePath, err := featuresCachePath(realPath) + if err != nil { + log.L.WithError(err).Debugf("failed to determine the cache path for the features of %q", realPath) + cachePath = "" + } + if cachePath != "" { + // The cache entry is valid only when it is newer than the binary. + if stale, err := isCacheStale(cachePath, realPath); err == nil && !stale { + if b, err := os.ReadFile(cachePath); err == nil { + var f features.Features + if err = json.Unmarshal(b, &f); err == nil { + featuresCache[realPath] = &f + return &f, nil + } + log.L.WithError(err).Warnf("failed to parse the cached OCI runtime features %q (ignored)", cachePath) + } + } + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, binPath, "features") + out, err := cmd.Output() + if err != nil { + if ee, ok := err.(*exec.ExitError); ok && len(ee.Stderr) > 0 { + err = fmt.Errorf("%w: %s", err, strings.TrimSpace(string(ee.Stderr))) + } + return nil, fmt.Errorf("failed to run `%s features`: %w", binPath, err) + } + var f features.Features + if err = json.Unmarshal(out, &f); err != nil { + return nil, fmt.Errorf("failed to parse the output of `%s features`: %w", binPath, err) + } + featuresCache[realPath] = &f + if cachePath != "" { + if err = writeFileAtomically(cachePath, out); err != nil { + log.L.WithError(err).Debugf("failed to cache the OCI runtime features to %q (ignored)", cachePath) + } + } + return &f, nil +} + +// featuresCachePath returns the cache file path for the features of the binary. +func featuresCachePath(realPath string) (string, error) { + // os.UserCacheDir returns $XDG_CACHE_HOME (or ~/.cache) on Linux. + cacheHome, err := os.UserCacheDir() + if err != nil { + return "", err + } + h := sha256.Sum256([]byte(realPath)) + return filepath.Join(cacheHome, "nerdctl", "oci-runtime-features", hex.EncodeToString(h[:])+".json"), nil +} + +// isCacheStale returns whether the cache file is older than the binary, +// i.e., the binary was modified after the cache entry was created. +func isCacheStale(cachePath, realPath string) (bool, error) { + stCache, err := os.Stat(cachePath) + if err != nil { + return true, err + } + stBin, err := os.Stat(realPath) + if err != nil { + return true, err + } + return !stCache.ModTime().After(stBin.ModTime()), nil +} + +func writeFileAtomically(path string, b []byte) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + tmp, err := os.CreateTemp(dir, ".tmp-"+filepath.Base(path)) + if err != nil { + return err + } + defer os.Remove(tmp.Name()) + if _, err = tmp.Write(b); err != nil { + tmp.Close() + return err + } + if err = tmp.Close(); err != nil { + return err + } + return os.Rename(tmp.Name(), path) +} diff --git a/pkg/ociruntimeutil/ociruntimeutil_linux_test.go b/pkg/ociruntimeutil/ociruntimeutil_linux_test.go new file mode 100644 index 00000000000..191b4f618f8 --- /dev/null +++ b/pkg/ociruntimeutil/ociruntimeutil_linux_test.go @@ -0,0 +1,113 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package ociruntimeutil + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/opencontainers/runtime-spec/specs-go/features" + "gotest.tools/v3/assert" +) + +// fakeRuntime creates a fake OCI runtime binary whose `features` subcommand +// prints featuresJSON, and appends a line to the log file on every execution. +func fakeRuntime(t *testing.T, featuresJSON string) (binary, execLog string) { + t.Helper() + dir := t.TempDir() + binary = filepath.Join(dir, "fake-runtime") + execLog = filepath.Join(dir, "exec.log") + script := `#!/bin/sh +set -eu +echo executed >>` + execLog + ` +if [ "${1:-}" != "features" ]; then + echo >&2 "unknown command ${1:-}" + exit 1 +fi +cat <<'EOF' +` + featuresJSON + ` +EOF +` + assert.NilError(t, os.WriteFile(binary, []byte(script), 0o700)) + return binary, execLog +} + +func countLines(t *testing.T, path string) int { + t.Helper() + b, err := os.ReadFile(path) + if os.IsNotExist(err) { + return 0 + } + assert.NilError(t, err) + n := 0 + for _, c := range b { + if c == '\n' { + n++ + } + } + return n +} + +func TestFeatures(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + binary, execLog := fakeRuntime(t, `{"ociVersionMin": "1.0.0", "ociVersionMax": "1.2.0", "mountOptions": ["ro", "rro", "rbind"]}`) + + f, err := Features(binary) + assert.NilError(t, err) + assert.DeepEqual(t, []string{"ro", "rro", "rbind"}, f.MountOptions) + assert.Equal(t, 1, countLines(t, execLog)) + + // The second call must not execute the binary again (in-process cache). + f, err = Features(binary) + assert.NilError(t, err) + assert.DeepEqual(t, []string{"ro", "rro", "rbind"}, f.MountOptions) + assert.Equal(t, 1, countLines(t, execLog)) + + // Drop the in-process cache: the XDG cache must be used, still without executing the binary. + featuresCacheMu.Lock() + featuresCache = make(map[string]*features.Features) + featuresCacheMu.Unlock() + f, err = Features(binary) + assert.NilError(t, err) + assert.DeepEqual(t, []string{"ro", "rro", "rbind"}, f.MountOptions) + assert.Equal(t, 1, countLines(t, execLog)) + + // Modifying the binary must invalidate the XDG cache entry + // (the cache file is older than the binary now). + // The mtime is set explicitly, as the timestamps of the cache file and the + // binary might collide otherwise. + future := time.Now().Add(time.Hour) + assert.NilError(t, os.Chtimes(binary, future, future)) + featuresCacheMu.Lock() + featuresCache = make(map[string]*features.Features) + featuresCacheMu.Unlock() + f, err = Features(binary) + assert.NilError(t, err) + assert.DeepEqual(t, []string{"ro", "rro", "rbind"}, f.MountOptions) + assert.Equal(t, 2, countLines(t, execLog)) +} + +func TestFeaturesError(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + dir := t.TempDir() + binary := filepath.Join(dir, "fake-runtime-no-features") + assert.NilError(t, os.WriteFile(binary, []byte("#!/bin/sh\necho >&2 'unknown command'\nexit 1\n"), 0o700)) + _, err := Features(binary) + assert.ErrorContains(t, err, "unknown command") +} diff --git a/pkg/ociruntimeutil/rro_linux.go b/pkg/ociruntimeutil/rro_linux.go new file mode 100644 index 00000000000..8f069dd35fd --- /dev/null +++ b/pkg/ociruntimeutil/rro_linux.go @@ -0,0 +1,71 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package ociruntimeutil + +import ( + "errors" + "fmt" + "slices" + "sync" + + "github.com/containerd/containerd/v2/pkg/kernelversion" +) + +var ( + rroCacheMu sync.Mutex + rroCache = make(map[string]error) // key: runtimeStr +) + +// SupportsRecursivelyReadOnly returns nil when the kernel and the OCI runtime +// specified by runtimeStr (the value of the `--runtime` flag) support +// recursive read-only (RRO) bind mounts. +// The result is cached per runtimeStr for the lifetime of the process. +func SupportsRecursivelyReadOnly(runtimeStr string) error { + rroCacheMu.Lock() + defer rroCacheMu.Unlock() + if err, ok := rroCache[runtimeStr]; ok { + return err + } + err := supportsRecursivelyReadOnly(runtimeStr) + rroCache[runtimeStr] = err + return err +} + +func supportsRecursivelyReadOnly(runtimeStr string) error { + // Recursive read-only mounts (mount_setattr(2) with MOUNT_ATTR_RDONLY and + // AT_RECURSIVE) require kernel >= 5.12. + ok, err := kernelversion.GreaterEqualThan(kernelversion.KernelVersion{Kernel: 5, Major: 12}) + if err != nil { + return fmt.Errorf("failed to detect whether the kernel supports recursive read-only mounts: %w", err) + } + if !ok { + return errors.New("recursive read-only mounts require kernel >= 5.12") + } + binary, err := BinaryFromRuntimeStr(runtimeStr) + if err != nil { + return fmt.Errorf("failed to detect whether the OCI runtime supports recursive read-only mounts: %w", err) + } + f, err := Features(binary) + if err != nil { + return fmt.Errorf("failed to detect whether the OCI runtime %q supports recursive read-only mounts (hint: recursive read-only mounts require runc >= 1.1 or crun >= 1.8.6): %w", + binary, err) + } + if !slices.Contains(f.MountOptions, "rro") { + return fmt.Errorf("the OCI runtime %q does not support recursive read-only (\"rro\") mounts", binary) + } + return nil +}