mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-11-15 08:18:42 +01:00
mount: stop overwriting the mount root's filer entry (#12623)
When a mount is rooted at an existing directory, two code paths replace that directory's filer entry with one the mount builds itself: - every mount start re-creates the root with a non-exclusive mkdir, which the filer handles as an update of the whole entry; - creating or removing an entry directly in the root saves the root's entry to touch its mtime, and maybeLoadEntry synthesizes that entry from the mount options instead of reading the filer. The filer keeps only crtime and inode on such an update, so the root loses its mode, owner and extended attributes. When the root is a bucket it also loses the bucket quota, which silently drops to 0 (unlimited), and the settings S3 keeps on the bucket entry. The explicit allowEmptyFolders=false that #11370 meant to keep is replaced by true at every mount start, because the mkdir has wiped it before ensureBucketAllowEmptyFolders looks. Create the root only when a lookup says it is missing, and then exclusively. Leave the root out of the filer-side mtime touch: its attributes come from the mount options (setRootAttr), so the filer copy of its mtime was never visible through the mount anyway. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
737ef68a11
commit
911bfe4014
4 files changed
+272
-3
No files matched your search
@@ -4,6 +4,7 @@ package command
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net"
|
||||
@@ -257,9 +258,7 @@ func buildSeaweedFileSystem(option *MountOptions, p fileSystemParams) *mount.WFS
|
||||
|
||||
// createMountRoot makes the filer-side directory the mount is rooted at.
|
||||
func createMountRoot(wfs *mount.WFS, mountRoot, bucketRootPath string, filerAddresses []pb.ServerAddress) bool {
|
||||
mountRootPath := util.FullPath(mountRoot)
|
||||
mountRootParent, mountDir := mountRootPath.DirAndName()
|
||||
if err := filer_pb.Mkdir(context.Background(), wfs, mountRootParent, mountDir, nil); err != nil {
|
||||
if err := ensureMountRootDirectory(context.Background(), wfs, mountRoot); err != nil {
|
||||
fmt.Printf("failed to create dir %s on filer %s: %v\n", mountRoot, filerAddresses, err)
|
||||
return false
|
||||
}
|
||||
@@ -270,6 +269,45 @@ func createMountRoot(wfs *mount.WFS, mountRoot, bucketRootPath string, filerAddr
|
||||
return true
|
||||
}
|
||||
|
||||
// ensureMountRootDirectory creates the mount root only when it is missing. The
|
||||
// filer answers a non-exclusive create of an existing path by replacing the
|
||||
// entry, keeping only its crtime and inode, so creating the root again at every
|
||||
// mount start would reset its mode and owner and drop what only the filer
|
||||
// holds: its extended attributes and, when the root is a bucket, the bucket's
|
||||
// quota and the settings S3 keeps on it.
|
||||
func ensureMountRootDirectory(ctx context.Context, filerClient filer_pb.FilerClient, mountRoot string) error {
|
||||
mountRootPath := util.FullPath(mountRoot)
|
||||
if mountRootPath == "/" {
|
||||
return nil
|
||||
}
|
||||
entry, _, _, err := filer_pb.GetEntry(ctx, filerClient, mountRootPath)
|
||||
if err == nil {
|
||||
return mountRootMustBeDirectory(mountRoot, entry)
|
||||
}
|
||||
if !errors.Is(err, filer_pb.ErrNotFound) {
|
||||
return err
|
||||
}
|
||||
parent, name := mountRootPath.DirAndName()
|
||||
if err := filer_pb.MkdirExclusive(ctx, filerClient, parent, name, nil); err != nil {
|
||||
// Another client may have created it since the lookup. A filer that
|
||||
// predates error codes reports that only as text, so look again rather
|
||||
// than match the error.
|
||||
entry, _, _, lookupErr := filer_pb.GetEntry(ctx, filerClient, mountRootPath)
|
||||
if lookupErr != nil {
|
||||
return err
|
||||
}
|
||||
return mountRootMustBeDirectory(mountRoot, entry)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mountRootMustBeDirectory(mountRoot string, entry *filer_pb.Entry) error {
|
||||
if !entry.GetIsDirectory() {
|
||||
return fmt.Errorf("%s: %w", mountRoot, filer_pb.ErrExistingIsFile)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mountSocketCleanup returns a func that removes the control socket this mount
|
||||
// listens on, unless a later mount of the same directory has replaced it.
|
||||
func mountSocketCleanup(socketPath string) func() {
|
||||
|
||||
@@ -5,9 +5,11 @@ package command
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
@@ -255,3 +257,158 @@ func Test_mountSocketCleanup(t *testing.T) {
|
||||
t.Fatalf("exiting mount removed the socket of a later mount of the same directory")
|
||||
}
|
||||
}
|
||||
|
||||
// mountRootFiler serves the filer calls a mount start makes on its root path.
|
||||
// Like the filer, it answers a non-exclusive create of an existing path by
|
||||
// replacing the entry, keeping only its crtime and inode.
|
||||
type mountRootFiler struct {
|
||||
filer_pb.SeaweedFilerClient
|
||||
entry *filer_pb.Entry // nil while the path is missing
|
||||
textOnlyErrors bool // a filer that predates error codes
|
||||
beforeCreate func(f *mountRootFiler)
|
||||
lookups int
|
||||
creates []*filer_pb.CreateEntryRequest
|
||||
updates int
|
||||
}
|
||||
|
||||
func (f *mountRootFiler) WithFilerClient(_ bool, fn func(filer_pb.SeaweedFilerClient) error) error {
|
||||
return fn(f)
|
||||
}
|
||||
func (f *mountRootFiler) AdjustedUrl(_ *filer_pb.Location) string { return "" }
|
||||
func (f *mountRootFiler) GetDataCenter() string { return "" }
|
||||
|
||||
func (f *mountRootFiler) LookupDirectoryEntry(_ context.Context, _ *filer_pb.LookupDirectoryEntryRequest, _ ...grpc.CallOption) (*filer_pb.LookupDirectoryEntryResponse, error) {
|
||||
f.lookups++
|
||||
if f.entry == nil {
|
||||
return nil, status.Error(codes.NotFound, filer_pb.ErrNotFound.Error())
|
||||
}
|
||||
return &filer_pb.LookupDirectoryEntryResponse{Entry: proto.Clone(f.entry).(*filer_pb.Entry)}, nil
|
||||
}
|
||||
|
||||
func (f *mountRootFiler) CreateEntry(_ context.Context, in *filer_pb.CreateEntryRequest, _ ...grpc.CallOption) (*filer_pb.CreateEntryResponse, error) {
|
||||
f.creates = append(f.creates, proto.Clone(in).(*filer_pb.CreateEntryRequest))
|
||||
if f.beforeCreate != nil {
|
||||
f.beforeCreate(f)
|
||||
}
|
||||
failed := func(code filer_pb.FilerError, text string) (*filer_pb.CreateEntryResponse, error) {
|
||||
resp := &filer_pb.CreateEntryResponse{Error: text}
|
||||
if !f.textOnlyErrors {
|
||||
resp.ErrorCode = code
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
next := proto.Clone(in.Entry).(*filer_pb.Entry)
|
||||
if f.entry != nil {
|
||||
if in.OExcl {
|
||||
return failed(filer_pb.FilerError_ENTRY_ALREADY_EXISTS, "EEXIST: entry already exists")
|
||||
}
|
||||
if !f.entry.IsDirectory && next.IsDirectory {
|
||||
return failed(filer_pb.FilerError_EXISTING_IS_FILE, "existing entry is a file")
|
||||
}
|
||||
next.Attributes.Crtime = f.entry.Attributes.Crtime
|
||||
next.Attributes.Inode = f.entry.Attributes.Inode
|
||||
}
|
||||
f.entry = next
|
||||
return &filer_pb.CreateEntryResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *mountRootFiler) UpdateEntry(_ context.Context, in *filer_pb.UpdateEntryRequest, _ ...grpc.CallOption) (*filer_pb.UpdateEntryResponse, error) {
|
||||
for key, expected := range in.ExpectedExtended {
|
||||
actual, found := f.entry.Extended[key]
|
||||
if found && !bytes.Equal(actual, expected) || !found && len(expected) > 0 {
|
||||
return nil, status.Error(codes.FailedPrecondition, "extended attribute changed")
|
||||
}
|
||||
}
|
||||
f.updates++
|
||||
f.entry = proto.Clone(in.Entry).(*filer_pb.Entry)
|
||||
return &filer_pb.UpdateEntryResponse{}, nil
|
||||
}
|
||||
|
||||
func Test_ensureMountRootDirectory(t *testing.T) {
|
||||
bucket := func() *filer_pb.Entry {
|
||||
return &filer_pb.Entry{
|
||||
Name: "b1",
|
||||
IsDirectory: true,
|
||||
Quota: 1 << 30,
|
||||
Attributes: &filer_pb.FuseAttributes{FileMode: uint32(os.ModeDir | 0750), Uid: 1000, Gid: 1000, Crtime: 1, Inode: 7},
|
||||
Extended: map[string][]byte{
|
||||
s3_constants.ExtAllowEmptyFolders: []byte("false"),
|
||||
s3_constants.ExtAmzOwnerKey: []byte("alice"),
|
||||
s3_constants.ExtVersioningKey: []byte("Enabled"),
|
||||
},
|
||||
}
|
||||
}
|
||||
file := func() *filer_pb.Entry {
|
||||
return &filer_pb.Entry{Name: "b1", Attributes: &filer_pb.FuseAttributes{FileMode: 0644, Crtime: 1, Inode: 7}}
|
||||
}
|
||||
|
||||
t.Run("an existing bucket root survives the mount start", func(t *testing.T) {
|
||||
f := &mountRootFiler{entry: bucket()}
|
||||
if err := ensureMountRootDirectory(context.Background(), f, "/buckets/b1"); err != nil {
|
||||
t.Fatalf("ensureMountRootDirectory: %v", err)
|
||||
}
|
||||
if err := ensureBucketAllowEmptyFolders(context.Background(), f, "/buckets/b1", "/buckets"); err != nil {
|
||||
t.Fatalf("ensureBucketAllowEmptyFolders: %v", err)
|
||||
}
|
||||
if len(f.creates) != 0 || f.updates != 0 {
|
||||
t.Errorf("creates = %d, updates = %d, want none", len(f.creates), f.updates)
|
||||
}
|
||||
if !proto.Equal(f.entry, bucket()) {
|
||||
t.Errorf("bucket entry changed:\n got %v\nwant %v", f.entry, bucket())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a missing root is created exclusively", func(t *testing.T) {
|
||||
f := &mountRootFiler{}
|
||||
if err := ensureMountRootDirectory(context.Background(), f, "/buckets/b1/data"); err != nil {
|
||||
t.Fatalf("ensureMountRootDirectory: %v", err)
|
||||
}
|
||||
if len(f.creates) != 1 || !f.creates[0].OExcl {
|
||||
t.Fatalf("creates = %v, want one exclusive create", f.creates)
|
||||
}
|
||||
if got := f.creates[0]; got.Directory != "/buckets/b1" || got.Entry.Name != "data" || !got.Entry.IsDirectory {
|
||||
t.Errorf("created %s/%s (directory %v), want directory /buckets/b1/data", got.Directory, got.Entry.Name, got.Entry.IsDirectory)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a file at the root path is refused", func(t *testing.T) {
|
||||
f := &mountRootFiler{entry: file()}
|
||||
err := ensureMountRootDirectory(context.Background(), f, "/buckets/b1")
|
||||
if !errors.Is(err, filer_pb.ErrExistingIsFile) {
|
||||
t.Fatalf("err = %v, want %v", err, filer_pb.ErrExistingIsFile)
|
||||
}
|
||||
if len(f.creates) != 0 {
|
||||
t.Errorf("creates = %d, want none", len(f.creates))
|
||||
}
|
||||
})
|
||||
|
||||
for _, textOnly := range []bool{false, true} {
|
||||
f := &mountRootFiler{textOnlyErrors: textOnly, beforeCreate: func(f *mountRootFiler) { f.entry = bucket() }}
|
||||
t.Run("a root created meanwhile is kept, text-only errors "+strconv.FormatBool(textOnly), func(t *testing.T) {
|
||||
if err := ensureMountRootDirectory(context.Background(), f, "/buckets/b1"); err != nil {
|
||||
t.Fatalf("ensureMountRootDirectory: %v", err)
|
||||
}
|
||||
if len(f.creates) != 1 || !proto.Equal(f.entry, bucket()) {
|
||||
t.Errorf("creates = %d, entry %v; want one refused exclusive create and the other client's entry", len(f.creates), f.entry)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("a file created meanwhile is refused", func(t *testing.T) {
|
||||
f := &mountRootFiler{beforeCreate: func(f *mountRootFiler) { f.entry = file() }}
|
||||
err := ensureMountRootDirectory(context.Background(), f, "/buckets/b1")
|
||||
if !errors.Is(err, filer_pb.ErrExistingIsFile) {
|
||||
t.Fatalf("err = %v, want %v", err, filer_pb.ErrExistingIsFile)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the filer root is never created", func(t *testing.T) {
|
||||
f := &mountRootFiler{}
|
||||
if err := ensureMountRootDirectory(context.Background(), f, "/"); err != nil {
|
||||
t.Fatalf("ensureMountRootDirectory: %v", err)
|
||||
}
|
||||
if f.lookups != 0 || len(f.creates) != 0 {
|
||||
t.Errorf("lookups = %d, creates = %d, want none", f.lookups, len(f.creates))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -399,6 +399,14 @@ func (wfs *WFS) touchDirMtimeCtimeBest(dirPath util.FullPath) {
|
||||
// touchDirMtimeCtime updates a directory's mtime and ctime on the filer.
|
||||
// POSIX requires this when entries are created or removed in the directory.
|
||||
func (wfs *WFS) touchDirMtimeCtime(dirPath util.FullPath) {
|
||||
// The mount root reports its own times (setRootAttr), and maybeLoadEntry
|
||||
// synthesizes its entry from the mount options instead of reading the
|
||||
// filer. Saving that entry would replace the real directory on the filer,
|
||||
// dropping what only the filer holds: its extended attributes and, when the
|
||||
// root is a bucket, the bucket's quota and the settings S3 keeps on it.
|
||||
if string(dirPath) == wfs.option.FilerMountRootPath {
|
||||
return
|
||||
}
|
||||
dirEntry, _, code := wfs.maybeLoadEntry(context.Background(), dirPath)
|
||||
if code != fuse.OK || dirEntry == nil || dirEntry.Attributes == nil {
|
||||
return
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package mount
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/filer"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
// savedEntriesFiler records the entries a mount saves.
|
||||
type savedEntriesFiler struct {
|
||||
filer_pb.UnimplementedSeaweedFilerServer
|
||||
mu sync.Mutex
|
||||
updates []*filer_pb.UpdateEntryRequest
|
||||
}
|
||||
|
||||
func (s *savedEntriesFiler) UpdateEntry(_ context.Context, req *filer_pb.UpdateEntryRequest) (*filer_pb.UpdateEntryResponse, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.updates = append(s.updates, proto.Clone(req).(*filer_pb.UpdateEntryRequest))
|
||||
return &filer_pb.UpdateEntryResponse{}, nil
|
||||
}
|
||||
|
||||
// Creating or removing an entry directly in the mount root must not save the
|
||||
// root: its entry is synthesized from the mount options, so saving it would
|
||||
// replace the real directory on the filer, wiping a bucket's quota and the
|
||||
// settings S3 keeps on it. Directories below the root are still saved.
|
||||
func TestTouchDirMtimeCtimeLeavesTheMountRootOnTheFiler(t *testing.T) {
|
||||
wfs := newInvalidateTestWFS(t)
|
||||
root := util.FullPath("/buckets/b1")
|
||||
wfs.option.FilerMountRootPath = string(root)
|
||||
fake := &savedEntriesFiler{}
|
||||
startFakeFiler(t, wfs, fake)
|
||||
|
||||
wfs.inodeToPath.Lookup(root, 1, true, false, 0, false)
|
||||
wfs.inodeToPath.MarkChildrenCached(root)
|
||||
sub := &filer_pb.Entry{
|
||||
Name: "sub",
|
||||
IsDirectory: true,
|
||||
Attributes: &filer_pb.FuseAttributes{FileMode: uint32(os.ModeDir | 0755), Mtime: 1},
|
||||
}
|
||||
if err := wfs.metaCache.InsertEntry(context.Background(), filer.FromPbEntry(string(root), sub), 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
wfs.touchDirMtimeCtime(root)
|
||||
wfs.touchDirMtimeCtime(root.Child("sub"))
|
||||
|
||||
fake.mu.Lock()
|
||||
defer fake.mu.Unlock()
|
||||
var saved []string
|
||||
for _, req := range fake.updates {
|
||||
saved = append(saved, string(util.NewFullPath(req.Directory, req.Entry.Name)))
|
||||
}
|
||||
if len(saved) != 1 || saved[0] != "/buckets/b1/sub" {
|
||||
t.Fatalf("saved %v, want only /buckets/b1/sub", saved)
|
||||
}
|
||||
if fake.updates[0].Entry.Attributes.Mtime <= 1 {
|
||||
t.Errorf("sub mtime = %d, want it touched", fake.updates[0].Entry.Attributes.Mtime)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user