mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-29 11:15:34 +00:00
* filer: keep lazy remote reads from resurrecting deleted paths Under a remote mount with filer.remote.sync as write-back, a path that was deleted or renamed away could come back as a chunkless remote-only entry: between the local delete and the daemon's remote delete, a store miss made maybeLazyFetchFromRemote trust a bucket that was behind the filer. The ghost then outlived the remote object -- HEAD answered 200, GET failed, and nothing cleaned it up. The filer now tombstones paths it deletes under a remote mount, learned both synchronously from its own delete path and from peer metadata events. The lazy fetch and the lazy listing skip a tombstoned path until the path is written again, until the mount's persisted write-back sync offset has passed the delete event (the remote delete has landed), or until a generous TTL covers a mount without a daemon. Fixes #11440 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * filer: cover recursive remote deletes with an ancestor tombstone A recursive delete now records the directory tombstone before walking children, so a partial traversal or a store that drops the subtree without listing it still leaves every descendant covered. Directory tombstones also subsume older descendant entries on add, descendant adds covered by a standing ancestor are skipped, and an existing tombstone can be refreshed even at capacity. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * filer: scope remote tombstones to the deleted object's generation A remote object whose own mtime postdates the local delete is a new generation, not the one the tombstone hides, so a recreated directory can surface remote writes made after its delete while old-generation objects stay hidden. Lazy fetch now stats the remote object before deciding, listings pass each child's remote mtime, and a sync offset releases a tombstone once it reaches the delete's own timestamp. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * filer: rebuild remote deletion tombstones after restart In-memory tombstones are lost on restart while remote write-back offsets persist, so a filer boot replays the persisted metadata log from the oldest mount offset and folds deletes back into the tombstone set through the same event handler. Lazy remote reads hold off while the replay runs so a pending delete cannot resurrect in the gap. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * filer: release remote tombstones only after their delete event lands The write-back offset orders against event timestamps, but the synchronous delete path recorded tombstones with the local clock before its event was emitted — a later unrelated event could already have pushed the mount's watermark past that guess, releasing the tombstone before the daemon applied the delete. Tombstones recorded ahead of their event are now marked pending and can only be lifted by the event confirming them or by TTL; event-stamped tombstones release through the offset as before. The remote-mtime generation bypass is dropped: remote and filer clocks are independent, and a pending remote delete removes whatever object sits at the path, so a "newer" remote object would only resurrect as a phantom. Tombstoned lookups now skip the remote stat entirely. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * filer: drop dir tombstone when recursive delete fails before listing The ancestor tombstone is recorded before the child listing; if that listing fails nothing was deleted, and the leftover tombstone would hide still-existing remote children for the whole TTL. Tombstones for children already deleted stay, since their remote deletes are still owed. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * filer: block lazy remote reads on startup tombstone rebuild The rebuild gate is now a done-channel set synchronously before the replay goroutine starts, so no lazy read can slip through in between. Reads wait on it with context cancellation instead of returning an empty miss that makes remote-only objects look deleted. The replay start is floored at now-TTL: mounts without a recorded write-back offset previously replayed the whole persisted history, and events older than the TTL would only build already-expired tombstones. The gate check now runs after the mount lookup so replaying the meta log's own directory listings does not deadlock on the gate, and the replay retries with backoff until it succeeds instead of failing open. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * filer: mark restamped tombstone pending until its delete event lands When a local delete raises an existing tombstone's timestamp, the new value is only a local clock guess ahead of that delete's event. Leaving the tombstone un-pending lets a write-back offset release it before the event is actually consumed, reopening the resurrection window. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * filer: bound tombstone replay to the tombstone TTL Persisted-log replay retried forever, keeping lazy remote reads gated indefinitely when the log cannot be read. Cap retries at the tombstone TTL measured from replay start: past that point every tombstone would have expired anyway, so opening the gate loses no protection. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * filer: re-check deletion tombstone before persisting lazy fetch A delete landing while StatFile is in flight passed the earlier tombstone check but still persisted the fetched entry, resurrecting a path whose remote delete is pending. Re-check right before CreateEntry. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * filer: retract a lazily persisted entry when a delete raced the insert The pre-insert tombstone check still leaves a window between the check and the store insert. Since deletes always record the tombstone before removing the entry, a tombstone visible right after a successful insert means the delete already ran: delete the entry back out so the tombstoned path stays deleted. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * filer: note why the replay deadline can safely open the gate Deletes made after startup are captured by the live delete and event paths, so a stalled replay can only be missing pre-restart deletes, all of which are past the tombstone TTL by the deadline. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * filer: retract only the entry a lazy remote read materialized Deleting by path after a raced delete could remove a legitimate rewrite that replaced the fetched entry. Verify the stored entry still matches the remote object (or the just-created directory shape) before deleting, and apply the same post-insert check to lazy listing children. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * filer: require full-entry equality before retracting a lazy entry Remote-only matching still removed a write that had updated the fetched entry, e.g. appended chunks. Compare the persisted entry against what this read materialized; any change means a real update owns the path. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
277 lines
10 KiB
Go
277 lines
10 KiB
Go
package filer
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"math"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/remote_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/remote_storage"
|
|
"github.com/seaweedfs/seaweedfs/weed/util"
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/protobuf/proto"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/glog"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
"github.com/viant/ptrie"
|
|
)
|
|
|
|
const REMOTE_STORAGE_CONF_SUFFIX = ".conf"
|
|
const REMOTE_STORAGE_MOUNT_FILE = "mount.mapping"
|
|
|
|
// RemoteStorageConfValidator rejects a RemoteConf whose endpoint resolves to
|
|
// an address the filer must not dial (loopback / link-local / private / IMDS).
|
|
// The filer server injects the volume server's SSRF deny-list validator so the
|
|
// filer package — which cannot import the server package — applies the same
|
|
// check the volume server's BuildGuardedRemoteStorageClient does. A nil
|
|
// validator leaves the historical unguarded behavior for unit tests that
|
|
// never dial.
|
|
type RemoteStorageConfValidator func(ctx context.Context, conf *remote_pb.RemoteConf) error
|
|
|
|
type FilerRemoteStorage struct {
|
|
// guards rules and storageNameToConf, which are replaced wholesale
|
|
// whenever /etc/remote changes
|
|
mu sync.RWMutex
|
|
rules ptrie.Trie[*remote_pb.RemoteStorageLocation]
|
|
mountDirs []util.FullPath
|
|
storageNameToConf map[string]*remote_pb.RemoteConf
|
|
// confValidator, when set, is applied to every RemoteConf as it is loaded
|
|
// from /etc/remote. A conf that fails is dropped from storageNameToConf so
|
|
// the lazy-fetch, lazy-list, and remote-delete paths (which resolve clients
|
|
// by name) can never dial its endpoint. This closes the unauthenticated-plant
|
|
// SSRF: a conf written to /etc/remote with a loopback S3 endpoint is rejected
|
|
// at reload instead of being dialed on the next cache miss.
|
|
confValidator RemoteStorageConfValidator
|
|
}
|
|
|
|
// RemoteStorageClientBuilder builds a remote-storage client for a conf. The
|
|
// filer server sets it to the guarded builder (endpoint deny-list + DNS
|
|
// rebinding-safe dialer) so the lazy-remote paths apply the same SSRF checks
|
|
// as the volume and streaming read paths. When nil the lazy paths fall back to
|
|
// the shared unguarded cache.
|
|
type RemoteStorageClientBuilder func(ctx context.Context, remoteConf *remote_pb.RemoteConf, allowUntrusted bool) (remote_storage.RemoteStorageClient, error)
|
|
|
|
func NewFilerRemoteStorage() (rs *FilerRemoteStorage) {
|
|
rs = &FilerRemoteStorage{
|
|
rules: ptrie.New[*remote_pb.RemoteStorageLocation](),
|
|
storageNameToConf: make(map[string]*remote_pb.RemoteConf),
|
|
}
|
|
return rs
|
|
}
|
|
|
|
// SetConfValidator installs the SSRF deny-list validator applied to every
|
|
// RemoteConf loaded from /etc/remote. It is called once by the filer server
|
|
// after construction.
|
|
func (rs *FilerRemoteStorage) SetConfValidator(v RemoteStorageConfValidator) {
|
|
rs.mu.Lock()
|
|
rs.confValidator = v
|
|
rs.mu.Unlock()
|
|
}
|
|
|
|
func (rs *FilerRemoteStorage) LoadRemoteStorageConfigurationsAndMapping(filer *Filer) (err error) {
|
|
// execute this on filer
|
|
|
|
limit := int64(math.MaxInt32)
|
|
|
|
entries, _, err := filer.ListDirectoryEntries(context.Background(), DirectoryEtcRemote, "", false, limit, "", "", "")
|
|
if err != nil {
|
|
if err == filer_pb.ErrNotFound {
|
|
return nil
|
|
}
|
|
glog.Errorf("read remote storage %s: %v", DirectoryEtcRemote, err)
|
|
return
|
|
}
|
|
|
|
// build into fresh containers so an unmounted directory disappears instead
|
|
// of lingering in the trie, which has no way to drop a key
|
|
rules := ptrie.New[*remote_pb.RemoteStorageLocation]()
|
|
var mountDirs []util.FullPath
|
|
storageNameToConf := make(map[string]*remote_pb.RemoteConf)
|
|
|
|
for _, entry := range entries {
|
|
if entry.Name() == REMOTE_STORAGE_MOUNT_FILE {
|
|
mountDirs, err = loadRemoteStorageMountMapping(rules, entry.Content)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
if !strings.HasSuffix(entry.Name(), REMOTE_STORAGE_CONF_SUFFIX) {
|
|
continue
|
|
}
|
|
conf := &remote_pb.RemoteConf{}
|
|
if err := proto.Unmarshal(entry.Content, conf); err != nil {
|
|
return fmt.Errorf("unmarshal %s/%s: %v", DirectoryEtcRemote, entry.Name(), err)
|
|
}
|
|
if rs.confValidator != nil {
|
|
if vErr := rs.confValidator(context.Background(), conf); vErr != nil {
|
|
// Drop the conf rather than fail the whole load: a single bad conf
|
|
// must not evict the rest of /etc/remote, and the mount mapping that
|
|
// references it resolves to "no client" on the lazy paths instead
|
|
// of dialing the blocked endpoint.
|
|
glog.Warningf("reject remote storage conf %s/%s: %v", DirectoryEtcRemote, entry.Name(), vErr)
|
|
continue
|
|
}
|
|
}
|
|
storageNameToConf[conf.Name] = conf
|
|
}
|
|
|
|
rs.mu.Lock()
|
|
rs.rules, rs.mountDirs, rs.storageNameToConf = rules, mountDirs, storageNameToConf
|
|
rs.mu.Unlock()
|
|
|
|
return nil
|
|
}
|
|
|
|
func loadRemoteStorageMountMapping(rules ptrie.Trie[*remote_pb.RemoteStorageLocation], data []byte) (mountDirs []util.FullPath, err error) {
|
|
mappings := &remote_pb.RemoteStorageMapping{}
|
|
if err := proto.Unmarshal(data, mappings); err != nil {
|
|
return nil, fmt.Errorf("unmarshal %s/%s: %v", DirectoryEtcRemote, REMOTE_STORAGE_MOUNT_FILE, err)
|
|
}
|
|
for dir, storageLocation := range mappings.Mappings {
|
|
putDirectoryToRemoteStorage(rules, util.FullPath(dir), storageLocation)
|
|
mountDirs = append(mountDirs, util.FullPath(dir))
|
|
}
|
|
return mountDirs, nil
|
|
}
|
|
|
|
func (rs *FilerRemoteStorage) mapDirectoryToRemoteStorage(dir util.FullPath, loc *remote_pb.RemoteStorageLocation) {
|
|
rs.mu.Lock()
|
|
defer rs.mu.Unlock()
|
|
putDirectoryToRemoteStorage(rs.rules, dir, loc)
|
|
found := false
|
|
for _, d := range rs.mountDirs {
|
|
if d == dir {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
rs.mountDirs = append(rs.mountDirs, dir)
|
|
}
|
|
}
|
|
|
|
// MountedDirectories returns the directories currently mapped to remote
|
|
// storage, for callers that must reason about every mount rather than one path.
|
|
func (rs *FilerRemoteStorage) MountedDirectories() []util.FullPath {
|
|
rs.mu.RLock()
|
|
defer rs.mu.RUnlock()
|
|
return append([]util.FullPath(nil), rs.mountDirs...)
|
|
}
|
|
|
|
func putDirectoryToRemoteStorage(rules ptrie.Trie[*remote_pb.RemoteStorageLocation], dir util.FullPath, loc *remote_pb.RemoteStorageLocation) {
|
|
rules.Put([]byte(dir+"/"), loc)
|
|
}
|
|
|
|
// FindMountDirectory returns the mount directory and location for p. When multiple
|
|
// mounts match (e.g. /buckets/b and /buckets/b/prefix), ptrie MatchPrefix visits
|
|
// shorter prefixes first, so the last match is the longest prefix.
|
|
func (rs *FilerRemoteStorage) FindMountDirectory(p util.FullPath) (mountDir util.FullPath, remoteLocation *remote_pb.RemoteStorageLocation) {
|
|
rs.mu.RLock()
|
|
defer rs.mu.RUnlock()
|
|
rs.rules.MatchPrefix([]byte(p), func(key []byte, value *remote_pb.RemoteStorageLocation) bool {
|
|
mountDir = util.FullPath(string(key[:len(key)-1]))
|
|
remoteLocation = value
|
|
return true
|
|
})
|
|
return
|
|
}
|
|
|
|
func (rs *FilerRemoteStorage) FindRemoteStorageClient(p util.FullPath) (client remote_storage.RemoteStorageClient, remoteConf *remote_pb.RemoteConf, found bool) {
|
|
_, storageLocation := rs.FindMountDirectory(p)
|
|
if storageLocation == nil {
|
|
return nil, nil, false
|
|
}
|
|
|
|
return rs.GetRemoteStorageClient(storageLocation.Name)
|
|
}
|
|
|
|
func (rs *FilerRemoteStorage) GetRemoteStorageClient(storageName string) (client remote_storage.RemoteStorageClient, remoteConf *remote_pb.RemoteConf, found bool) {
|
|
rs.mu.RLock()
|
|
remoteConf, found = rs.storageNameToConf[storageName]
|
|
rs.mu.RUnlock()
|
|
if !found {
|
|
return
|
|
}
|
|
|
|
var err error
|
|
if client, err = remote_storage.GetRemoteStorage(remoteConf); err == nil {
|
|
found = true
|
|
return
|
|
}
|
|
return
|
|
}
|
|
|
|
func (rs *FilerRemoteStorage) FindRemoteStorageConf(p util.FullPath) (*remote_pb.RemoteConf, bool) {
|
|
_, storageLocation := rs.FindMountDirectory(p)
|
|
if storageLocation == nil {
|
|
return nil, false
|
|
}
|
|
return rs.GetRemoteStorageConf(storageLocation.Name)
|
|
}
|
|
|
|
func (rs *FilerRemoteStorage) GetRemoteStorageConf(storageName string) (*remote_pb.RemoteConf, bool) {
|
|
rs.mu.RLock()
|
|
defer rs.mu.RUnlock()
|
|
conf, found := rs.storageNameToConf[storageName]
|
|
return conf, found
|
|
}
|
|
|
|
func UnmarshalRemoteStorageMappings(oldContent []byte) (mappings *remote_pb.RemoteStorageMapping, err error) {
|
|
mappings = &remote_pb.RemoteStorageMapping{
|
|
Mappings: make(map[string]*remote_pb.RemoteStorageLocation),
|
|
}
|
|
if len(oldContent) > 0 {
|
|
if err = proto.Unmarshal(oldContent, mappings); err != nil {
|
|
glog.Warningf("unmarshal existing mappings: %v", err)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
func ReadRemoteStorageConf(grpcDialOption grpc.DialOption, filerAddress pb.ServerAddress, storageName string) (conf *remote_pb.RemoteConf, readErr error) {
|
|
var oldContent []byte
|
|
if readErr = pb.WithFilerClient(false, 0, filerAddress, grpcDialOption, func(client filer_pb.SeaweedFilerClient) error {
|
|
oldContent, readErr = ReadInsideFiler(context.Background(), client, DirectoryEtcRemote, storageName+REMOTE_STORAGE_CONF_SUFFIX)
|
|
return readErr
|
|
}); readErr != nil {
|
|
return nil, readErr
|
|
}
|
|
|
|
// unmarshal storage configuration
|
|
conf = &remote_pb.RemoteConf{}
|
|
if unMarshalErr := proto.Unmarshal(oldContent, conf); unMarshalErr != nil {
|
|
readErr = fmt.Errorf("unmarshal %s/%s: %v", DirectoryEtcRemote, storageName+REMOTE_STORAGE_CONF_SUFFIX, unMarshalErr)
|
|
return
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
func DetectMountInfo(grpcDialOption grpc.DialOption, filerAddress pb.ServerAddress, dir string) (*remote_pb.RemoteStorageMapping, string, *remote_pb.RemoteStorageLocation, *remote_pb.RemoteConf, error) {
|
|
|
|
mappings, listErr := ReadMountMappings(grpcDialOption, filerAddress)
|
|
if listErr != nil {
|
|
return nil, "", nil, nil, listErr
|
|
}
|
|
if dir == "" {
|
|
return mappings, "", nil, nil, fmt.Errorf("need to specify '-dir' option")
|
|
}
|
|
|
|
localMountedDir, remoteStorageMountedLocation, findErr := FindMountedRemoteMapping(mappings, dir)
|
|
if findErr != nil {
|
|
return mappings, localMountedDir, remoteStorageMountedLocation, nil, findErr
|
|
}
|
|
|
|
// find remote storage configuration
|
|
remoteStorageConf, err := ReadRemoteStorageConf(grpcDialOption, filerAddress, remoteStorageMountedLocation.Name)
|
|
if err != nil {
|
|
return mappings, localMountedDir, remoteStorageMountedLocation, remoteStorageConf, err
|
|
}
|
|
|
|
return mappings, localMountedDir, remoteStorageMountedLocation, remoteStorageConf, nil
|
|
}
|