diff --git a/cmd/containerd/builtins/builtins.go b/cmd/containerd/builtins/builtins.go index f6792266dd..4a1cbd33aa 100644 --- a/cmd/containerd/builtins/builtins.go +++ b/cmd/containerd/builtins/builtins.go @@ -18,6 +18,8 @@ package builtins // register containerd builtins here import ( + _ "github.com/containerd/containerd/v2/plugins/mount/fsview/erofs" + _ "github.com/containerd/containerd/v2/core/runtime/v2" _ "github.com/containerd/containerd/v2/plugins/content/local/plugin" _ "github.com/containerd/containerd/v2/plugins/events" diff --git a/internal/fsview/mount.go b/internal/fsview/mount.go index 19996ea85c..fa8d0ee47a 100644 --- a/internal/fsview/mount.go +++ b/internal/fsview/mount.go @@ -27,7 +27,6 @@ import ( "github.com/containerd/containerd/v2/core/mount" "github.com/containerd/errdefs" - "github.com/erofs/go-erofs" ) // View is an interface for temporarily viewing a filesystem, @@ -49,137 +48,58 @@ func (v view) Close() error { return nil } -// FSMounts will return a fs.FS for the provided mounts if possible to open -// the mounts directly without mounting. Possible mounts for direct open.. -// - Bind mounts: Able to just open the source path directly -// - Overlay mounts: Able to open the merged path directly if all lower/upper work dirs are accessible -// - erofs mounts: Able to open the source paths directory using go-erofs library and overlay the results -// - format/* mounts: Apply template formatting using previous mount sources, then process the result +// FSMounts returns a View for the provided mounts if possible to open +// the mounts directly without mounting. // -// If not supported, a nil fs.FS and an error will be returned. +// If not supported, a nil View and an error will be returned. func FSMounts(m []mount.Mount) (View, error) { if len(m) == 0 { return nil, nil } - - return mountToView(m[len(m)-1], m[:len(m)-1]) + return resolveMount(m[len(m)-1], m[:len(m)-1]) } -// mountToView converts a mount to a View, using preceding mounts for resolution if needed. -func mountToView(m mount.Mount, preceding []mount.Mount) (View, error) { - switch m.Type { - case "bind", "rbind": - r, err := os.OpenRoot(m.Source) - if err != nil { - return nil, err +// resolveMount tries registered handlers first, then built-in handlers. +func resolveMount(m mount.Mount, preceding []mount.Mount) (View, error) { + for _, h := range registered { + if h.HandleMount == nil { + continue } - return view{ - FS: r.FS(), - cleanup: r.Close, - }, nil - case "erofs": - return openEROFS(m) - case "overlay": + v, err := h.HandleMount(m) + if errors.Is(err, errdefs.ErrNotImplemented) { + continue + } + return v, err + } + + switch { + case m.Type == "bind" || m.Type == "rbind": + return openBind(m) + case m.Type == "overlay": return openOverlay(m) - default: - // Check if this is a format/* mount - if strings.HasPrefix(m.Type, "format/") { - return openFormatMount(m, preceding) - } + case strings.HasPrefix(m.Type, "format/"): + return openFormatMount(m, preceding) } return nil, fmt.Errorf("mount type %s cannot be directly viewed: %w", m.Type, errdefs.ErrNotImplemented) } -// openEROFS opens an EROFS mount as a View. -func openEROFS(m mount.Mount) (View, error) { - f, err := os.Open(m.Source) +func openBind(m mount.Mount) (View, error) { + r, err := os.OpenRoot(m.Source) if err != nil { return nil, err } - - // Check for additional devices in mount options - var extraDevices []io.ReaderAt - var closers []io.Closer - closers = append(closers, f) - - for _, opt := range m.Options { - if devPath, ok := strings.CutPrefix(opt, "device="); ok { - if devPath == "" { - continue - } - df, err := os.Open(devPath) - if err != nil { - for _, c := range closers { - c.Close() - } - return nil, err - } - closers = append(closers, df) - extraDevices = append(extraDevices, df) - } - } - - var opts []erofs.Opt - if len(extraDevices) > 0 { - opts = append(opts, erofs.WithExtraDevices(extraDevices...)) - } - - efs, err := erofs.EroFS(f, opts...) - if err != nil { - for _, c := range closers { - c.Close() - } - return nil, err - } - - return view{ - FS: efs, - cleanup: func() error { - var errs []error - for _, c := range closers { - errs = append(errs, c.Close()) - } - return errors.Join(errs...) - }, - }, nil + return view{FS: r.FS(), cleanup: r.Close}, nil } -// openOverlay opens an overlay mount as a View. func openOverlay(m mount.Mount) (View, error) { - layers, err := getOverlayFSLayers(m.Options) + layers, err := openOverlayPaths(m.Options) if err != nil { return nil, err } - - // Extract fs.FS from each View - var fsList []fs.FS - for _, layer := range layers { - fsList = append(fsList, layer) - } - - ofs, err := NewOverlayFS(fsList) - if err != nil { - // Cleanup all layer views - for _, layer := range layers { - layer.Close() - } - return nil, err - } - - return view{ - FS: ofs, - cleanup: func() error { - var errs []error - for _, layer := range layers { - errs = append(errs, layer.Close()) - } - return errors.Join(errs...) - }, - }, nil + return newOverlayView(layers) } -// openFormatMount opens a format/* mount by resolving templates and creating an overlay. func openFormatMount(m mount.Mount, preceding []mount.Mount) (View, error) { types := strings.Split(m.Type, "/") if len(types) < 2 || types[0] != "format" || types[len(types)-1] != "overlay" { @@ -187,17 +107,21 @@ func openFormatMount(m mount.Mount, preceding []mount.Mount) (View, error) { } var layers []View + closeLayers := func() { + for _, l := range layers { + l.Close() + } + } for _, opt := range m.Options { if val, ok := strings.CutPrefix(opt, "upperdir="); ok { - upper, err := handleOverlayFormat(val, preceding) + upper, err := resolveOverlayValue(val, preceding) if err != nil { if errors.Is(err, errdefs.ErrNotImplemented) { - // Do no include upper if not locally viewable, likely ephemeral and empty continue } + closeLayers() return nil, fmt.Errorf("failed to handle upperdir option: %w", err) } - // Extract the upperdir value and format it if len(layers) > 0 { layers = append(upper, layers...) } else { @@ -206,8 +130,9 @@ func openFormatMount(m mount.Mount, preceding []mount.Mount) (View, error) { } if val, ok := strings.CutPrefix(opt, "lowerdir="); ok { for l := range strings.SplitSeq(val, ":") { - lowers, err := handleOverlayFormat(l, preceding) + lowers, err := resolveOverlayValue(l, preceding) if err != nil { + closeLayers() return nil, fmt.Errorf("failed to handle lowerdir option: %w", err) } layers = append(layers, lowers...) @@ -215,7 +140,10 @@ func openFormatMount(m mount.Mount, preceding []mount.Mount) (View, error) { } } - // Extract fs.FS from each View + return newOverlayView(layers) +} + +func newOverlayView(layers []View) (View, error) { var fsList []fs.FS for _, layer := range layers { fsList = append(fsList, layer) @@ -223,7 +151,6 @@ func openFormatMount(m mount.Mount, preceding []mount.Mount) (View, error) { ofs, err := NewOverlayFS(fsList) if err != nil { - // Cleanup all layer views for _, layer := range layers { layer.Close() } @@ -242,31 +169,21 @@ func openFormatMount(m mount.Mount, preceding []mount.Mount) (View, error) { }, nil } -// handleOverlayFormat resolves formatted overlay values. -// A value may be a plain path or contain Go template expressions -// (e.g. "{{ mount 0 }}" or "{{ mount 0 }}/sub"). Any path suffix after the -// closing "}}" is separated before execution and applied via fs.Sub. -func handleOverlayFormat(s string, preceding []mount.Mount) ([]View, error) { +// resolveOverlayValue resolves a single overlay option value, which may be +// a plain directory path or a Go template expression like "{{ mount 0 }}". +func resolveOverlayValue(s string, preceding []mount.Mount) ([]View, error) { if !strings.Contains(s, "{{") { - // Only directories may be used for overlay r, err := os.OpenRoot(s) if err != nil { return nil, err } - return []View{view{ - FS: r.FS(), - cleanup: r.Close, - }}, nil + return []View{view{FS: r.FS(), cleanup: r.Close}}, nil } - // Split "{{ ... }}/optional/suffix" into the template and suffix parts - // so that the suffix can be applied with fs.Sub after execution. tmplExpr, suffix := splitTemplateSuffix(s) var layers []View - addLayer := func(v View) { - layers = append(layers, v) - } + addLayer := func(v View) { layers = append(layers, v) } boundsCheck := func(i int) error { if i < 0 || i >= len(preceding) { return fmt.Errorf("index out of bounds: %d, has %d preceding mounts", i, len(preceding)) @@ -275,26 +192,22 @@ func handleOverlayFormat(s string, preceding []mount.Mount) ([]View, error) { } fm := template.FuncMap{ - // source opens the raw Source path of the preceding mount, - // matching the real mount manager's a[i].Source behavior. "source": func(i int) (string, error) { if err := boundsCheck(i); err != nil { return "", err } - v, err := openPath(preceding[i].Source) + r, err := os.OpenRoot(preceding[i].Source) if err != nil { return "", fmt.Errorf("failed to open source of mount %d: %w", i, err) } - addLayer(v) + addLayer(view{FS: r.FS(), cleanup: r.Close}) return "", nil }, - // mount resolves the preceding mount into a full filesystem view, - // matching the real mount manager's a[i].MountPoint behavior. "mount": func(i int) (string, error) { if err := boundsCheck(i); err != nil { return "", err } - v, err := mountToView(preceding[i], preceding[:i]) + v, err := resolveMount(preceding[i], preceding[:i]) if err != nil { return "", fmt.Errorf("failed to resolve mount %d: %w", i, err) } @@ -307,7 +220,7 @@ func handleOverlayFormat(s string, preceding []mount.Mount) ([]View, error) { if err := boundsCheck(i); err != nil { return "", err } - v, err := mountToView(preceding[i], preceding[:i]) + v, err := resolveMount(preceding[i], preceding[:i]) if err != nil { return "", fmt.Errorf("failed to resolve mount %d: %w", i, err) } @@ -353,8 +266,6 @@ func handleOverlayFormat(s string, preceding []mount.Mount) ([]View, error) { return layers, nil } -// splitTemplateSuffix splits a template string like "{{ mount 0 }}/sub/path" -// into the template expression and the trailing path suffix. func splitTemplateSuffix(s string) (string, string) { i := strings.LastIndex(s, "}}") if i < 0 { @@ -365,7 +276,7 @@ func splitTemplateSuffix(s string) (string, string) { return tmpl, suffix } -func getOverlayFSLayers(options []string) ([]View, error) { +func openOverlayPaths(options []string) ([]View, error) { var ( lower string paths []string @@ -377,63 +288,20 @@ func getOverlayFSLayers(options []string) ([]View, error) { paths = append(paths, val) } } - if lower != "" { - lowers := strings.Split(lower, ":") - paths = append(paths, lowers...) + paths = append(paths, strings.Split(lower, ":")...) } var layers []View for _, p := range paths { - layer, err := openPath(p) + r, err := os.OpenRoot(p) if err != nil { - // Cleanup already opened layers for _, l := range layers { l.Close() } return nil, err } - layers = append(layers, layer) + layers = append(layers, view{FS: r.FS(), cleanup: r.Close}) } - return layers, nil } - -// openPath opens a filesystem path and returns a View. -// It tries to detect if the path is an EROFS file or a directory. -func openPath(path string) (View, error) { - // Check if path is a file or directory - info, err := os.Stat(path) - if err != nil { - return nil, err - } - - if info.IsDir() { - // Open as directory - root, err := os.OpenRoot(path) - if err != nil { - return nil, err - } - return view{ - FS: root.FS(), - cleanup: root.Close, - }, nil - } - - // Try to open as EROFS file - f, err := os.Open(path) - if err != nil { - return nil, err - } - - efs, err := erofs.EroFS(f) - if err != nil { - f.Close() - return nil, fmt.Errorf("failed to open %s as EROFS: %w", path, err) - } - - return view{ - FS: efs, - cleanup: f.Close, - }, nil -} diff --git a/internal/fsview/mount_format_test.go b/internal/fsview/mount_format_test.go index 47c39a673f..cc8eeecf13 100644 --- a/internal/fsview/mount_format_test.go +++ b/internal/fsview/mount_format_test.go @@ -14,13 +14,14 @@ limitations under the License. */ -package fsview +package fsview_test import ( "io/fs" "testing" "github.com/containerd/containerd/v2/core/mount" + "github.com/containerd/containerd/v2/internal/fsview" "github.com/containerd/errdefs" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -53,7 +54,7 @@ func TestFormatMountTemplates(t *testing.T) { }, } - viewFS, err := FSMounts(mounts) + viewFS, err := fsview.FSMounts(mounts) require.NoError(t, err) defer viewFS.Close() @@ -102,7 +103,7 @@ func TestFormatMountReversedRange(t *testing.T) { }, } - viewFS, err := FSMounts(mounts) + viewFS, err := fsview.FSMounts(mounts) require.NoError(t, err) defer viewFS.Close() @@ -135,7 +136,7 @@ func TestFormatMountIndexes(t *testing.T) { }, } - viewFS, err := FSMounts(mounts) + viewFS, err := fsview.FSMounts(mounts) require.NoError(t, err) defer viewFS.Close() @@ -161,7 +162,7 @@ func TestFormatMountUnsupportedType(t *testing.T) { }, } - _, err := FSMounts(mounts) + _, err := fsview.FSMounts(mounts) require.Error(t, err) assert.ErrorIs(t, err, errdefs.ErrNotImplemented) } diff --git a/internal/fsview/mount_test.go b/internal/fsview/mount_test.go index cfbe278f60..7ddc162d13 100644 --- a/internal/fsview/mount_test.go +++ b/internal/fsview/mount_test.go @@ -14,7 +14,7 @@ limitations under the License. */ -package fsview +package fsview_test import ( "context" @@ -28,6 +28,9 @@ import ( "github.com/containerd/containerd/v2/core/mount" "github.com/containerd/containerd/v2/internal/erofsutils" + "github.com/containerd/containerd/v2/internal/fsview" + _ "github.com/containerd/containerd/v2/plugins/mount/fsview/erofs" + "github.com/containerd/containerd/v2/pkg/archive/tartest" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -56,7 +59,7 @@ func TestFSMountsLast(t *testing.T) { } // Should pick the last one (dir2) - fs, err := FSMounts(mounts) + fs, err := fsview.FSMounts(mounts) if err != nil { t.Fatal(err) } @@ -169,7 +172,7 @@ func TestFSMountsEROFSWithDevices(t *testing.T) { metaPath := mergeEROFSLayers(t, layer1Path, layer2Path) - v, err := FSMounts([]mount.Mount{ + v, err := fsview.FSMounts([]mount.Mount{ { Type: "erofs", Source: metaPath, @@ -220,7 +223,7 @@ func TestFSMountsOverlayAbsoluteSymlinkEtcGroup(t *testing.T) { tc.Symlink("/nix/store/abcd/group", "etc/group"), )) - v, err := FSMounts([]mount.Mount{ + v, err := fsview.FSMounts([]mount.Mount{ { Type: "erofs", Source: layerPath, @@ -297,7 +300,7 @@ func TestFSMountsOverlayWithEROFSDevices(t *testing.T) { t.Fatal(err) } - v, err := FSMounts([]mount.Mount{ + v, err := fsview.FSMounts([]mount.Mount{ { Type: "erofs", Source: metaPath, @@ -377,7 +380,7 @@ func TestFormatMountIndexWithSuffix(t *testing.T) { }, } - viewFS, err := FSMounts(mounts) + viewFS, err := fsview.FSMounts(mounts) require.NoError(t, err) defer viewFS.Close() diff --git a/internal/fsview/overlay.go b/internal/fsview/overlay.go index 1e594355f6..066114e28b 100644 --- a/internal/fsview/overlay.go +++ b/internal/fsview/overlay.go @@ -24,11 +24,11 @@ import ( "sort" ) -// overlayOpaqueXattrs are the xattr names used to indicate an opaque directory. +// OverlayOpaqueXattrs are the xattr names used to indicate an opaque directory. // "trusted.overlay.opaque" is the traditional xattr used by overlay. // "user.overlay.opaque" is available since Linux 5.11. // See https://github.com/torvalds/linux/commit/2d2f2d7322ff43e0fe92bf8cccdc0b09449bf2e1 -var overlayOpaqueXattrs = []string{ +var OverlayOpaqueXattrs = []string{ "trusted.overlay.opaque", "user.overlay.opaque", } diff --git a/internal/fsview/overlay_erofs_test.go b/internal/fsview/overlay_erofs_test.go index 5375fef867..7add7e1d69 100644 --- a/internal/fsview/overlay_erofs_test.go +++ b/internal/fsview/overlay_erofs_test.go @@ -14,7 +14,7 @@ limitations under the License. */ -package fsview +package fsview_test import ( "io/fs" @@ -22,6 +22,9 @@ import ( "testing" "github.com/containerd/containerd/v2/core/mount" + "github.com/containerd/containerd/v2/internal/fsview" + _ "github.com/containerd/containerd/v2/plugins/mount/fsview/erofs" + "github.com/erofs/go-erofs" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -35,7 +38,7 @@ func TestEROFSBaseLayer(t *testing.T) { require.NoError(t, err) defer f.Close() - efs, err := erofs.EroFS(f) + efs, err := erofs.Open(f) require.NoError(t, err) testCases := []struct { @@ -95,12 +98,12 @@ func TestEROFSOverlayWithWhiteout(t *testing.T) { }, } - viewFS, err := FSMounts(mounts) + viewFS, err := fsview.FSMounts(mounts) require.NoError(t, err) defer viewFS.Close() layers := []fs.FS{viewFS} - overlayFS, err := NewOverlayFS(layers) + overlayFS, err := fsview.NewOverlayFS(layers) require.NoError(t, err) testCases := []struct { @@ -146,7 +149,7 @@ func TestEROFSWithFSMounts(t *testing.T) { }, } - viewFS, err := FSMounts(mounts) + viewFS, err := fsview.FSMounts(mounts) require.NoError(t, err) defer viewFS.Close() @@ -187,7 +190,7 @@ func TestEROFSMultipleLayersOverlay(t *testing.T) { require.NoError(t, err) defer baseFile.Close() - baseFS, err := erofs.EroFS(baseFile) + baseFS, err := erofs.Open(baseFile) require.NoError(t, err) // Open upper layer @@ -195,12 +198,12 @@ func TestEROFSMultipleLayersOverlay(t *testing.T) { require.NoError(t, err) defer upperFile.Close() - upperFS, err := erofs.EroFS(upperFile) + upperFS, err := erofs.Open(upperFile) require.NoError(t, err) // Create overlay with upper layer first (highest priority) layers := []fs.FS{upperFS, baseFS} - overlayFS, err := NewOverlayFS(layers) + overlayFS, err := fsview.NewOverlayFS(layers) require.NoError(t, err) t.Run("file from upper layer", func(t *testing.T) { @@ -245,7 +248,7 @@ func TestEROFSWhiteoutDetection(t *testing.T) { require.NoError(t, err) defer upperFile.Close() - upperFS, err := erofs.EroFS(upperFile) + upperFS, err := erofs.Open(upperFile) require.NoError(t, err) // Check the whiteout file @@ -253,6 +256,8 @@ func TestEROFSWhiteoutDetection(t *testing.T) { require.NoError(t, err) // Verify it's detected as a whiteout - assert.True(t, isWhiteout(fi), "should detect file1.txt as whiteout") assert.Equal(t, fs.ModeCharDevice, fi.Mode()&fs.ModeCharDevice, "should be char device") + estatfi, ok := fi.Sys().(*erofs.Stat) + assert.True(t, ok, "should be erofs stat") + assert.Equal(t, uint32(0), estatfi.Rdev, "file1.txt should be a whiteout") } diff --git a/internal/fsview/overlay_linux.go b/internal/fsview/overlay_linux.go index 7eeac480a7..0ae075e14a 100644 --- a/internal/fsview/overlay_linux.go +++ b/internal/fsview/overlay_linux.go @@ -21,40 +21,35 @@ import ( "os" "syscall" - "github.com/erofs/go-erofs" "golang.org/x/sys/unix" ) -func isOpaque(f fs.File) bool { - // Attempt to get underlying *os.File +func getxattr(f fs.File, name string) (string, bool) { if osf, ok := f.(*os.File); ok { - var dest [1]byte - for _, xattr := range overlayOpaqueXattrs { - sz, err := unix.Fgetxattr(int(osf.Fd()), xattr, dest[:]) - if err != nil { - continue - } - if sz == 1 && len(dest) == 1 && dest[0] == 'y' { - return true - } + var dest [256]byte + sz, err := unix.Fgetxattr(int(osf.Fd()), name, dest[:]) + if err != nil || sz <= 0 { + return "", false } - return false + return string(dest[:sz]), true //nolint:gosec // G602: sz is bounded by dest size } - // Check for EROFS file by getting Stat and checking xattrs - fi, err := f.Stat() - if err != nil { - return false - } - - if estatfi, ok := fi.Sys().(*erofs.Stat); ok { - for _, xattr := range overlayOpaqueXattrs { - if estatfi.Xattrs[xattr] == "y" { - return true + for _, h := range registered { + if h.Getxattr != nil { + if val, ok := h.Getxattr(f, name); ok { + return val, true } } } + return "", false +} +func isOpaque(f fs.File) bool { + for _, xattr := range OverlayOpaqueXattrs { + if val, ok := getxattr(f, xattr); ok && val == "y" { + return true + } + } return false } @@ -62,16 +57,13 @@ func isWhiteout(fi fs.FileInfo) bool { if (fi.Mode() & fs.ModeCharDevice) == 0 { return false } - - // Check for regular syscall.Stat_t (from os.File) if sys, ok := fi.Sys().(*syscall.Stat_t); ok { return sys.Rdev == 0 } - - // Check for EROFS Stat - if estatfi, ok := fi.Sys().(*erofs.Stat); ok { - return estatfi.Rdev == 0 + for _, h := range registered { + if h.IsWhiteout != nil && h.IsWhiteout(fi) { + return true + } } - return false } diff --git a/internal/fsview/overlay_linux_test.go b/internal/fsview/overlay_linux_test.go index 73ac238822..1aa4d13151 100644 --- a/internal/fsview/overlay_linux_test.go +++ b/internal/fsview/overlay_linux_test.go @@ -14,7 +14,7 @@ limitations under the License. */ -package fsview +package fsview_test import ( "errors" @@ -27,6 +27,8 @@ import ( "testing/fstest" "github.com/containerd/containerd/v2/core/mount" + "github.com/containerd/containerd/v2/internal/fsview" + "golang.org/x/sys/unix" ) @@ -47,7 +49,7 @@ func TestOverlayFS(t *testing.T) { "dir3/file4": &fstest.MapFile{Data: []byte("upper-file4")}, } - ofs, err := NewOverlayFS([]fs.FS{upper, lower}) + ofs, err := fsview.NewOverlayFS([]fs.FS{upper, lower}) if err != nil { t.Fatal(err) } @@ -158,7 +160,7 @@ func TestOverlayFSDirReplacedByFile(t *testing.T) { } lowerdir := strings.Join([]string{layer3, layer2, layer1}, ":") - v, err := FSMounts([]mount.Mount{ + v, err := fsview.FSMounts([]mount.Mount{ { Type: "overlay", Source: "overlay", diff --git a/internal/fsview/overlay_other.go b/internal/fsview/overlay_other.go index 1f28a42784..a01005a7e3 100644 --- a/internal/fsview/overlay_other.go +++ b/internal/fsview/overlay_other.go @@ -18,27 +18,25 @@ package fsview -import ( - "io/fs" +import "io/fs" - "github.com/erofs/go-erofs" -) - -func isOpaque(f fs.File) bool { - // Check for EROFS file by getting Stat and checking xattrs - fi, err := f.Stat() - if err != nil { - return false - } - - if estatfi, ok := fi.Sys().(*erofs.Stat); ok { - for _, xattr := range overlayOpaqueXattrs { - if estatfi.Xattrs[xattr] == "y" { - return true +func getxattr(f fs.File, name string) (string, bool) { + for _, h := range registered { + if h.Getxattr != nil { + if val, ok := h.Getxattr(f, name); ok { + return val, true } } } + return "", false +} +func isOpaque(f fs.File) bool { + for _, xattr := range OverlayOpaqueXattrs { + if val, ok := getxattr(f, xattr); ok && val == "y" { + return true + } + } return false } @@ -46,11 +44,10 @@ func isWhiteout(fi fs.FileInfo) bool { if (fi.Mode() & fs.ModeCharDevice) == 0 { return false } - - // Check for EROFS Stat - if estatfi, ok := fi.Sys().(*erofs.Stat); ok { - return estatfi.Rdev == 0 + for _, h := range registered { + if h.IsWhiteout != nil && h.IsWhiteout(fi) { + return true + } } - return false } diff --git a/internal/fsview/register.go b/internal/fsview/register.go new file mode 100644 index 0000000000..82154d7afa --- /dev/null +++ b/internal/fsview/register.go @@ -0,0 +1,46 @@ +/* + 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 fsview + +import ( + "io/fs" + + "github.com/containerd/containerd/v2/core/mount" +) + +// FSHandler extends fsview with support for additional filesystem types. +// All fields are optional — set only the capabilities the handler provides. +type FSHandler struct { + // HandleMount converts a mount into a View. It should return + // errdefs.ErrNotImplemented if it cannot handle the mount type. + HandleMount func(m mount.Mount) (View, error) + + // Getxattr returns the value of the named extended attribute on the + // given file. The boolean indicates whether the attribute was found. + Getxattr func(f fs.File, name string) (string, bool) + + // IsWhiteout checks if a file info represents a whiteout entry. + IsWhiteout func(fi fs.FileInfo) bool +} + +var registered []FSHandler + +// Register adds a filesystem handler to extend fsview with support +// for additional filesystem types. +func Register(h FSHandler) { + registered = append(registered, h) +} diff --git a/plugins/mount/fsview/erofs/erofs.go b/plugins/mount/fsview/erofs/erofs.go new file mode 100644 index 0000000000..c30ff09654 --- /dev/null +++ b/plugins/mount/fsview/erofs/erofs.go @@ -0,0 +1,125 @@ +/* + 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 erofs + +import ( + "errors" + "io" + "io/fs" + "os" + "strings" + + "github.com/containerd/containerd/v2/core/mount" + "github.com/containerd/containerd/v2/internal/fsview" + "github.com/containerd/errdefs" + "github.com/erofs/go-erofs" +) + +func init() { + fsview.Register(fsview.FSHandler{ + HandleMount: handleMount, + Getxattr: getxattr, + IsWhiteout: isWhiteout, + }) +} + +func handleMount(m mount.Mount) (fsview.View, error) { + if m.Type != "erofs" { + return nil, errdefs.ErrNotImplemented + } + + f, err := os.Open(m.Source) + if err != nil { + return nil, err + } + + var extraDevices []io.ReaderAt + var closers []io.Closer + closers = append(closers, f) + + for _, opt := range m.Options { + if devPath, ok := strings.CutPrefix(opt, "device="); ok { + if devPath == "" { + continue + } + df, err := os.Open(devPath) + if err != nil { + for _, c := range closers { + c.Close() + } + return nil, err + } + closers = append(closers, df) + extraDevices = append(extraDevices, df) + } + } + + var opts []erofs.OpenOpt + if len(extraDevices) > 0 { + opts = append(opts, erofs.WithExtraDevices(extraDevices...)) + } + + efs, err := erofs.Open(f, opts...) + if err != nil { + for _, c := range closers { + c.Close() + } + return nil, err + } + + return &erofsView{ + FS: efs, + closers: closers, + }, nil +} + +type erofsView struct { + fs.FS + closers []io.Closer +} + +func (v *erofsView) Close() error { + var errs []error + for _, c := range v.closers { + errs = append(errs, c.Close()) + } + return errors.Join(errs...) +} + +func getxattr(f fs.File, name string) (string, bool) { + fi, err := f.Stat() + if err != nil { + return "", false + } + estatfi, ok := fi.Sys().(*erofs.Stat) + if !ok { + return "", false + } + val, ok := estatfi.Xattrs[name] + return val, ok +} + +func isWhiteout(fi fs.FileInfo) bool { + if (fi.Mode() & fs.ModeCharDevice) == 0 { + return false + } + estatfi, ok := fi.Sys().(*erofs.Stat) + if !ok { + return false + } + return estatfi.Rdev == 0 +}