mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-20 13:30:46 +02:00
* admin: support setting volume read-only and read/write modes
* admin: address PR review on volume access-mode persistence
Reject trailing JSON values in the SetVolumeReadOnly handler so
requests like {"read_only":true}{} no longer pass validation, and add
a trailing-value case to the invalid-request test.
Propagate .vif persistence failures through the access-mode chain.
PersistReadOnly now returns the SaveVolumeInfo error and rolls back
the in-memory volumeInfo on failure; Store.MarkVolumeReadonly and
Store.MarkVolumeWritable propagate that error and roll back their
noWrite flags, so the API reports failure instead of success while
restart would revert the mode.
* admin: make .vif persistence atomic and preserve error chain
SaveVolumeInfo now writes to a .vif.tmp file, syncs it, renames it
over the target, and fsyncs the directory. A write/sync/close failure
leaves the existing .vif intact, so the PersistReadOnly in-memory
rollback matches the durable state instead of diverging from a
partially written file that restart would apply.
Switch the error wrappers in PersistReadOnly, MarkVolumeReadonly, and
MarkVolumeWritable from %v to %w so callers can use errors.Is and
errors.As to classify persistence failures.
* admin: treat post-rename dir fsync failure as a warning
After os.Rename commits the new .vif, the on-disk file already holds
the requested mode. A directory fsync failure only risks losing the
rename across a crash; returning an error here would make
PersistReadOnly roll back in-memory state while the durable file keeps
the new mode, splitting the replica. Log the failure as a warning
instead, matching the best-effort nature of FsyncDir (already skipped
on Windows).
* admin: distinguish post-rename durability failures and use unique temp files
SaveVolumeInfo now uses os.CreateTemp for the staging file, preventing
concurrent saves for the same volume from colliding on a shared .tmp
path.
A directory fsync failure after os.Rename returns a
NotCrashDurableError instead of being silently swallowed. The rename
already committed the new metadata to disk, so PersistReadOnly,
MarkVolumeReadonly, and MarkVolumeWritable skip the in-memory rollback
for this error type (keeping state aligned with the durable file) while
still propagating the failure to the API. Pre-commit failures continue
to roll back as before.
* admin: continue post-commit work after NotCrashDurableError
MarkVolumeWritable now clears the EIO quarantine and the gRPC handlers
(makeVolumeReadonly step 3, makeVolumeWritable master notification)
proceed with their post-commit work when SaveVolumeInfo returns a
NotCrashDurableError, instead of aborting and leaving the volume
unavailable or the master unaware of the mode change. The durability
warning is still propagated to the API caller. Pre-commit failures
continue to abort early as before.
* admin: handle NotCrashDurableError in tier and EC callers
VolumeTierMoveDatFromRemote and VolumeEcShardsGenerate now check for
NotCrashDurableError from SaveVolumeInfo. When the rename has already
committed the new .vif, they continue with their post-commit work
(backend switch, remote deletion, keeping generated EC shards) instead
of aborting and leaving the on-disk metadata inconsistent with the
file layout. The durability warning is logged for the operator.
160 lines
5.9 KiB
Go
160 lines
5.9 KiB
Go
package dash
|
|
|
|
import (
|
|
"context"
|
|
"net"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/wdclient"
|
|
"github.com/stretchr/testify/require"
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/credentials/insecure"
|
|
"google.golang.org/grpc/status"
|
|
)
|
|
|
|
type volumeAccessTestMaster struct {
|
|
master_pb.UnimplementedSeaweedServer
|
|
topology *master_pb.TopologyInfo
|
|
}
|
|
|
|
type volumeAccessTestServer struct {
|
|
volume_server_pb.UnimplementedVolumeServerServer
|
|
rpcError error
|
|
calls chan any
|
|
}
|
|
|
|
func (s *volumeAccessTestMaster) KeepConnected(stream master_pb.Seaweed_KeepConnectedServer) error {
|
|
if _, err := stream.Recv(); err != nil {
|
|
return err
|
|
}
|
|
if err := stream.Send(&master_pb.KeepConnectedResponse{}); err != nil {
|
|
return err
|
|
}
|
|
<-stream.Context().Done()
|
|
return stream.Context().Err()
|
|
}
|
|
|
|
func (s *volumeAccessTestMaster) VolumeList(context.Context, *master_pb.VolumeListRequest) (*master_pb.VolumeListResponse, error) {
|
|
// Return unfiltered topology to exercise compatibility with older masters.
|
|
return &master_pb.VolumeListResponse{TopologyInfo: s.topology}, nil
|
|
}
|
|
|
|
func (s *volumeAccessTestServer) VolumeMarkReadonly(_ context.Context, req *volume_server_pb.VolumeMarkReadonlyRequest) (*volume_server_pb.VolumeMarkReadonlyResponse, error) {
|
|
s.calls <- req
|
|
return &volume_server_pb.VolumeMarkReadonlyResponse{}, s.rpcError
|
|
}
|
|
|
|
func (s *volumeAccessTestServer) VolumeMarkWritable(_ context.Context, req *volume_server_pb.VolumeMarkWritableRequest) (*volume_server_pb.VolumeMarkWritableResponse, error) {
|
|
s.calls <- req
|
|
return &volume_server_pb.VolumeMarkWritableResponse{}, s.rpcError
|
|
}
|
|
|
|
func volumeAccessTestTopology(nodes ...*master_pb.DataNodeInfo) *master_pb.TopologyInfo {
|
|
return &master_pb.TopologyInfo{DataCenterInfos: []*master_pb.DataCenterInfo{{
|
|
RackInfos: []*master_pb.RackInfo{{DataNodeInfos: nodes}},
|
|
}}}
|
|
}
|
|
|
|
func TestSetVolumeReadOnly(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
readOnly bool
|
|
volumeID uint32
|
|
server string
|
|
rpcError error
|
|
wantCall bool
|
|
}{
|
|
{name: "persist read-only", readOnly: true, volumeID: 7, server: "node-a", wantCall: true},
|
|
{name: "restore read-write", volumeID: 7, server: "node-a", wantCall: true},
|
|
{name: "propagate volume server error", volumeID: 7, server: "node-a", wantCall: true, rpcError: status.Error(codes.PermissionDenied, "server is in maintenance mode")},
|
|
{name: "reject unknown volume", readOnly: true, volumeID: 8, server: "node-a"},
|
|
{name: "reject unknown server", readOnly: true, volumeID: 7, server: "127.0.0.1:9999"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
require.NoError(t, err)
|
|
port := listener.Addr().(*net.TCPAddr).Port
|
|
master := &volumeAccessTestMaster{
|
|
topology: volumeAccessTestTopology(&master_pb.DataNodeInfo{
|
|
Id: "node-a", Address: "127.0.0.1:8080", GrpcPort: uint32(port),
|
|
DiskInfos: map[string]*master_pb.DiskInfo{"hdd": {
|
|
VolumeInfos: []*master_pb.VolumeInformationMessage{{Id: 7}},
|
|
}},
|
|
}),
|
|
}
|
|
fake := &volumeAccessTestServer{
|
|
rpcError: tc.rpcError,
|
|
calls: make(chan any, 1),
|
|
}
|
|
grpcServer := grpc.NewServer()
|
|
master_pb.RegisterSeaweedServer(grpcServer, master)
|
|
volume_server_pb.RegisterVolumeServerServer(grpcServer, fake)
|
|
go grpcServer.Serve(listener)
|
|
t.Cleanup(grpcServer.Stop)
|
|
|
|
dialOption := grpc.WithTransportCredentials(insecure.NewCredentials())
|
|
address := pb.NewServerAddress("127.0.0.1", 9333, port)
|
|
discovery := pb.NewServiceDiscoveryFromMap(map[string]pb.ServerAddress{"master": address})
|
|
masterClient := wdclient.NewMasterClient(dialOption, "", "admin", "", "", "", *discovery)
|
|
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
|
|
defer cancel()
|
|
go masterClient.KeepConnectedToMaster(ctx)
|
|
admin := &AdminServer{masterClient: masterClient, grpcDialOption: dialOption}
|
|
|
|
err = admin.SetVolumeReadOnly(ctx, tc.volumeID, tc.server, tc.readOnly)
|
|
if !tc.wantCall {
|
|
require.ErrorContains(t, err, "not found on server")
|
|
require.Empty(t, fake.calls, "an unknown replica must not receive a mutation")
|
|
return
|
|
}
|
|
if tc.rpcError != nil {
|
|
require.Equal(t, status.Code(tc.rpcError), status.Code(err))
|
|
} else {
|
|
require.NoError(t, err)
|
|
}
|
|
select {
|
|
case call := <-fake.calls:
|
|
if tc.readOnly {
|
|
req, ok := call.(*volume_server_pb.VolumeMarkReadonlyRequest)
|
|
require.True(t, ok)
|
|
require.Equal(t, tc.volumeID, req.VolumeId)
|
|
require.True(t, req.Persist, "operator changes must survive a restart")
|
|
require.False(t, req.CanDelete)
|
|
} else {
|
|
req, ok := call.(*volume_server_pb.VolumeMarkWritableRequest)
|
|
require.True(t, ok)
|
|
require.Equal(t, tc.volumeID, req.VolumeId)
|
|
}
|
|
default:
|
|
t.Fatal("volume server did not receive an access mode change")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestVolumeReplicaAddress(t *testing.T) {
|
|
topology := volumeAccessTestTopology(
|
|
&master_pb.DataNodeInfo{Id: "legacy:8080", DiskInfos: map[string]*master_pb.DiskInfo{
|
|
"hdd": {VolumeInfos: []*master_pb.VolumeInformationMessage{{Id: 7}}},
|
|
}},
|
|
&master_pb.DataNodeInfo{Id: "node-b", Address: "host-b:8081", GrpcPort: 18082, DiskInfos: map[string]*master_pb.DiskInfo{
|
|
"ssd": {VolumeInfos: []*master_pb.VolumeInformationMessage{{Id: 7}}, EcShardInfos: []*master_pb.VolumeEcShardInformationMessage{{Id: 8}}},
|
|
}},
|
|
)
|
|
address, err := volumeReplicaAddress(topology, 7, "legacy:8080")
|
|
require.NoError(t, err)
|
|
require.Equal(t, "legacy:18080", address.ToGrpcAddress())
|
|
address, err = volumeReplicaAddress(topology, 7, "node-b")
|
|
require.NoError(t, err)
|
|
require.Equal(t, "host-b:18082", address.ToGrpcAddress())
|
|
_, err = volumeReplicaAddress(topology, 8, "node-b")
|
|
require.Error(t, err, "EC shards must not be treated as regular volumes")
|
|
_, err = volumeReplicaAddress(nil, 7, "node-b")
|
|
require.Error(t, err)
|
|
}
|