Files
seaweedfs/weed/server/filer_grpc_server_create_route_test.go
T
Chris LuandClaude Opus 5.5 317d907334 filer: route tests forward to a peer port the test holds (#12576)
The ring tests used 127.0.0.1:18889 as an unreachable peer, but its gRPC
port 28889 is a common filer port: on a shared CI host a live filer there
accepted the forwarded exclusive create, and
TestCreateEntryExclusiveLeavesNonOwner failed. The peer now listens on a
port the test owns and drops every connection.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 19:10:58 -08:00

299 lines
9.9 KiB
Go

package weed_server
import (
"context"
"fmt"
"net"
"testing"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/peer"
"google.golang.org/grpc/status"
"github.com/seaweedfs/seaweedfs/weed/cluster/lock_manager"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/util"
)
// deadAddress returns a peer whose gRPC port this test holds and drops every
// connection on, so a forward to it fails even on a host where a fixed port
// could reach a live filer.
func deadAddress(t *testing.T) pb.ServerAddress {
t.Helper()
lis, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
go func() {
for {
conn, err := lis.Accept()
if err != nil {
return
}
conn.Close()
}
}()
address := pb.NewServerAddress("127.0.0.1", 1, lis.Addr().(*net.TCPAddr).Port)
pb.InvalidateGrpcConnection(address.ToGrpcAddress())
t.Cleanup(func() {
lis.Close()
pb.InvalidateGrpcConnection(address.ToGrpcAddress())
})
return address
}
// createRouteServer builds a filer whose ring holds an unreachable peer, so a
// forwarded create fails at the dial and is distinguishable from a local apply.
func createRouteServer(t *testing.T) (*FilerServer, *renameTestStore) {
t.Helper()
const self = pb.ServerAddress("127.0.0.1:18888")
store := newRenameTestStore()
f := newRenameTestFiler(t, store)
f.DirBucketsPath = "/buckets"
f.Dlm = lock_manager.NewDistributedLockManager(self)
f.Dlm.LockRing.SetSnapshot([]pb.ServerAddress{self, deadAddress(t)}, 1)
return &FilerServer{
filer: f,
option: &FilerOption{Host: self},
grpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
entryLockTable: util.NewLockTable[util.FullPath](),
}, store
}
// peerOwnedName returns a child name under /test whose entry the ring assigns to
// the peer, so a routed create must leave this filer.
func peerOwnedName(t *testing.T, fs *FilerServer) string {
t.Helper()
for i := 0; i < 4000; i++ {
name := fmt.Sprintf("obj-%d", i)
key := entryRouteKey(util.NewFullPath("/test", name))
if fs.filer.Dlm.LockRing.GetPrimary(key) != fs.option.Host {
return name
}
}
t.Skip("no name owned by the peer")
return ""
}
func createReq(name string, oExcl bool) *filer_pb.CreateEntryRequest {
return &filer_pb.CreateEntryRequest{
Directory: "/test",
OExcl: oExcl,
SkipCheckParentDirectory: true,
Entry: &filer_pb.Entry{
Name: name,
Attributes: &filer_pb.FuseAttributes{Mtime: 1700000000, FileMode: 0644, Inode: 1},
},
}
}
// An exclusive create for a peer-owned path must leave this filer rather than
// take the local lock, whose arbitration would not bind the other filers.
func TestCreateEntryExclusiveLeavesNonOwner(t *testing.T) {
fs, store := createRouteServer(t)
name := peerOwnedName(t, fs)
_, err := fs.CreateEntry(context.Background(), createReq(name, true))
if err == nil {
t.Fatal("exclusive create on a non-owner must not be applied locally")
}
if _, found := store.entries[string(util.NewFullPath("/test", name))]; found {
t.Fatal("forwarded create must not also write locally")
}
}
// Plain creates are upserts whichever filer applies them, so routing them would
// buy nothing and cost a hop.
func TestCreateEntryPlainStaysLocal(t *testing.T) {
fs, store := createRouteServer(t)
name := peerOwnedName(t, fs)
resp, err := fs.CreateEntry(context.Background(), createReq(name, false))
if err != nil || resp.Error != "" {
t.Fatalf("plain create must be applied locally, got err=%v resp=%v", err, resp.Error)
}
if _, found := store.entries[string(util.NewFullPath("/test", name))]; !found {
t.Fatal("plain create must reach the local store")
}
}
// ringPeerCtx simulates a request arriving over a connection from a ring
// member, the provenance an is_moved marker needs before it is trusted.
func ringPeerCtx() context.Context {
return peer.NewContext(context.Background(), &peer.Peer{
Addr: &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 49999},
})
}
// is_moved bounds forwarding to one hop: the owner applies it even if its own
// ring view says someone else owns the key.
func TestCreateEntryMovedAppliesLocally(t *testing.T) {
fs, store := createRouteServer(t)
name := peerOwnedName(t, fs)
req := createReq(name, true)
req.IsMoved = true
resp, err := fs.CreateEntry(ringPeerCtx(), req)
if err != nil || resp.Error != "" {
t.Fatalf("forwarded create must be applied locally, got err=%v resp=%v", err, resp.Error)
}
if _, found := store.entries[string(util.NewFullPath("/test", name))]; !found {
t.Fatal("forwarded create must reach the local store")
}
}
// A caller can set is_moved, but without a ring member's connection it is not
// trusted: on a non-owner the request is refused rather than evaluated under
// the wrong lock or forwarded again, where it could cycle while rings disagree.
func TestCreateEntryMovedFromClientIsNotTrusted(t *testing.T) {
fs, store := createRouteServer(t)
name := peerOwnedName(t, fs)
req := createReq(name, true)
req.IsMoved = true
_, err := fs.CreateEntry(context.Background(), req)
if status.Code(err) != codes.PermissionDenied {
t.Fatalf("a forged is_moved must be refused, got err=%v", err)
}
if _, found := store.entries[string(util.NewFullPath("/test", name))]; found {
t.Fatal("a forged is_moved must not cause a local write")
}
}
// Forwarding an unlock to an unreachable owner must surface the RPC error, not
// panic dereferencing the nil response of the failed call.
func TestDistributedUnlockFailedForwardReturnsError(t *testing.T) {
fs, _ := createRouteServer(t)
var name string
for i := 0; i < 4000; i++ {
candidate := fmt.Sprintf("lock-%d", i)
if fs.filer.Dlm.LockRing.GetPrimary(candidate) != fs.option.Host {
name = candidate
break
}
}
if name == "" {
t.Skip("no lock owned by the peer")
}
resp, err := fs.DistributedUnlock(context.Background(), &filer_pb.UnlockRequest{
Name: name,
RenewToken: "tok",
})
if err != nil {
t.Fatalf("unlock forward should report via resp.Error, not err: %v", err)
}
if resp.Error == "" {
t.Fatal("a failed forward must surface an error")
}
}
// An exclusive create this filer owns is applied locally, under its per-path lock.
func TestCreateEntryExclusiveAppliedByOwner(t *testing.T) {
fs, store := createRouteServer(t)
var name string
for i := 0; i < 4000 && name == ""; i++ {
candidate := fmt.Sprintf("obj-%d", i)
key := entryRouteKey(util.NewFullPath("/test", candidate))
if fs.filer.Dlm.LockRing.GetPrimary(key) == fs.option.Host {
name = candidate
}
}
if name == "" {
t.Skip("no name owned by this filer")
}
resp, err := fs.CreateEntry(context.Background(), createReq(name, true))
if err != nil || resp.Error != "" {
t.Fatalf("owner must apply the create, got err=%v resp=%v", err, resp.Error)
}
if _, found := store.entries[string(util.NewFullPath("/test", name))]; !found {
t.Fatal("owner's create must reach the local store")
}
}
// A key still inside the cooling-off window belongs to its prior owner. If that
// filer cannot be reached the create fails: it may be partitioned rather than
// down, and applying here would be the second serialization point on the key.
func TestCreateEntryFailsWhenPriorOwnerIsUnreachable(t *testing.T) {
const self = pb.ServerAddress("127.0.0.1:28888")
peer := deadAddress(t)
store := newRenameTestStore()
f := newRenameTestFiler(t, store)
f.DirBucketsPath = "/buckets"
f.Dlm = lock_manager.NewDistributedLockManager(self)
// Dropping the peer moves every key it owned to this filer, with the peer as
// their prior owner — and it answers no RPC.
f.Dlm.LockRing.SetSnapshot([]pb.ServerAddress{self, peer}, 1)
f.Dlm.LockRing.SetSnapshot([]pb.ServerAddress{self}, 2)
fs := &FilerServer{
filer: f,
option: &FilerOption{Host: self},
grpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
entryLockTable: util.NewLockTable[util.FullPath](),
}
var name string
for i := 0; i < 4000 && name == ""; i++ {
candidate := fmt.Sprintf("obj-%d", i)
if fs.filer.Dlm.LockRing.PriorOwner(entryRouteKey(util.NewFullPath("/test", candidate))) == peer {
name = candidate
}
}
if name == "" {
t.Skip("no key whose prior owner is the departed peer")
}
resp, err := fs.CreateEntry(context.Background(), createReq(name, true))
if err == nil {
t.Fatalf("unreachable prior owner must fail the create, got resp=%v", resp)
}
if resp == nil {
t.Fatal("a failed create must still answer with a response, not nil")
}
if _, found := store.entries[string(util.NewFullPath("/test", name))]; found {
t.Fatal("a create routed away must not also write locally")
}
}
func TestCreateEntryRejectsDotSegments(t *testing.T) {
fs, store := createRouteServer(t)
for _, tc := range []struct{ dir, name string }{
{"/../escape", "marker.txt"},
{"/test/./sub", "marker.txt"},
{"/test", ".."},
} {
req := createReq(tc.name, false)
req.Directory = tc.dir
resp, err := fs.CreateEntry(context.Background(), req)
if err == nil && resp.Error == "" {
t.Fatalf("CreateEntry %s/%s must be rejected", tc.dir, tc.name)
}
if _, found := store.entries[string(util.NewFullPath(tc.dir, tc.name))]; found {
t.Fatalf("CreateEntry %s/%s stored the raw path", tc.dir, tc.name)
}
}
resp, err := fs.CreateEntry(context.Background(), createReq("marker.txt", false))
if err != nil || resp.Error != "" {
t.Fatalf("CreateEntry /test/marker.txt: err=%v resp=%v", err, resp)
}
update := &filer_pb.UpdateEntryRequest{
Directory: "/x/../test",
Entry: createReq("marker.txt", false).Entry,
}
update.Entry.Attributes.Mtime++
if _, err := fs.UpdateEntry(context.Background(), update); err == nil {
t.Fatal("UpdateEntry with a dot segment must be rejected")
}
if _, found := store.entries["/x/../test/marker.txt"]; found {
t.Fatal("UpdateEntry stored the raw path")
}
}