diff --git a/weed/command/mount_common.go b/weed/command/mount_common.go index 5321c1af78..a7426c4c25 100644 --- a/weed/command/mount_common.go +++ b/weed/command/mount_common.go @@ -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() { diff --git a/weed/command/mount_common_test.go b/weed/command/mount_common_test.go index a472f59178..364aaf16f8 100644 --- a/weed/command/mount_common_test.go +++ b/weed/command/mount_common_test.go @@ -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)) + } + }) +} diff --git a/weed/mount/weedfs_attr.go b/weed/mount/weedfs_attr.go index c3f610bf41..765574b9d5 100644 --- a/weed/mount/weedfs_attr.go +++ b/weed/mount/weedfs_attr.go @@ -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 diff --git a/weed/mount/weedfs_attr_mount_root_test.go b/weed/mount/weedfs_attr_mount_root_test.go new file mode 100644 index 0000000000..e86a5613b8 --- /dev/null +++ b/weed/mount/weedfs_attr_mount_root_test.go @@ -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) + } +}