From 75ec5ec193ddcade07e52b5017fe54b6bc4757ab Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Mon, 7 Sep 2026 18:40:37 -0700 Subject: [PATCH] admin: allow setting volume read-only and read/write modes (#11217) * 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. --- weed/admin/dash/volume_management.go | 52 ++++++ weed/admin/dash/volume_management_test.go | 159 ++++++++++++++++++ weed/admin/handlers/admin_handlers.go | 1 + weed/admin/handlers/cluster_handlers.go | 46 +++++ weed/admin/handlers/cluster_handlers_test.go | 62 +++++++ weed/admin/view/app/volume_details.templ | 58 ++++++- weed/admin/view/app/volume_details_templ.go | 106 ++++++++++-- weed/admin/view/app/volume_details_test.go | 47 ++++++ weed/server/volume_grpc_admin.go | 31 +++- weed/server/volume_grpc_erasure_coding.go | 10 +- weed/server/volume_grpc_tier_download.go | 11 +- weed/storage/needle_map_file_pool_test.go | 4 +- weed/storage/store.go | 37 +++- weed/storage/volume.go | 21 ++- weed/storage/volume_info/volume_info.go | 62 ++++++- .../volume_loading_corrupt_idx_test.go | 4 +- weed/storage/volume_mark_writable_test.go | 4 +- weed/storage/volume_write_test.go | 4 +- 18 files changed, 678 insertions(+), 41 deletions(-) create mode 100644 weed/admin/dash/volume_management_test.go create mode 100644 weed/admin/handlers/cluster_handlers_test.go create mode 100644 weed/admin/view/app/volume_details_test.go diff --git a/weed/admin/dash/volume_management.go b/weed/admin/dash/volume_management.go index b07f7409a..da848e1ea 100644 --- a/weed/admin/dash/volume_management.go +++ b/weed/admin/dash/volume_management.go @@ -9,6 +9,7 @@ import ( "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/storage/erasure_coding" ) @@ -399,6 +400,57 @@ func (s *AdminServer) GetVolumeDetails(volumeID uint32, server string) (*VolumeD }, nil } +// SetVolumeReadOnly changes the access mode of a single volume replica. +func (s *AdminServer) SetVolumeReadOnly(ctx context.Context, volumeID uint32, server string, readOnly bool) error { + var address pb.ServerAddress + err := s.masterClient.WithClient(ctx, false, func(client master_pb.SeaweedClient) error { + resp, err := client.VolumeList(ctx, &master_pb.VolumeListRequest{VolumeIds: []uint32{volumeID}}) + if err != nil { + return err + } + address, err = volumeReplicaAddress(resp.GetTopologyInfo(), volumeID, server) + return err + }) + if err != nil { + return err + } + + return s.WithVolumeServerClient(address, func(client volume_server_pb.VolumeServerClient) error { + if readOnly { + _, err := client.VolumeMarkReadonly(ctx, &volume_server_pb.VolumeMarkReadonlyRequest{ + VolumeId: volumeID, + Persist: true, + }) + return err + } + _, err := client.VolumeMarkWritable(ctx, &volume_server_pb.VolumeMarkWritableRequest{VolumeId: volumeID}) + return err + }) +} + +// Resolve the selected replica through the master rather than dialing a +// user-supplied address. Node IDs can differ from their network addresses. +func volumeReplicaAddress(topology *master_pb.TopologyInfo, volumeID uint32, server string) (pb.ServerAddress, error) { + for _, dc := range topology.GetDataCenterInfos() { + for _, rack := range dc.GetRackInfos() { + for _, node := range rack.GetDataNodeInfos() { + if node.GetId() != server { + continue + } + for _, disk := range node.GetDiskInfos() { + for _, volume := range disk.GetVolumeInfos() { + // Older masters may ignore the volume ID filter. + if volume.GetId() == volumeID { + return pb.NewServerAddressFromDataNode(node), nil + } + } + } + } + } + } + return "", fmt.Errorf("volume %d not found on server %s", volumeID, server) +} + // VacuumVolume performs a vacuum operation on a specific volume func (s *AdminServer) VacuumVolume(volumeID int, server string) error { // Validate volumeID range before converting to uint32 diff --git a/weed/admin/dash/volume_management_test.go b/weed/admin/dash/volume_management_test.go new file mode 100644 index 000000000..7381dec71 --- /dev/null +++ b/weed/admin/dash/volume_management_test.go @@ -0,0 +1,159 @@ +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) +} diff --git a/weed/admin/handlers/admin_handlers.go b/weed/admin/handlers/admin_handlers.go index 67131b4b7..007d1e14b 100644 --- a/weed/admin/handlers/admin_handlers.go +++ b/weed/admin/handlers/admin_handlers.go @@ -265,6 +265,7 @@ func (h *AdminHandlers) registerAPIRoutes(api *mux.Router, enforceWrite bool) { volumeApi := api.PathPrefix("/volumes").Subrouter() volumeApi.HandleFunc("/export", h.clusterHandlers.ExportClusterVolumes).Methods(http.MethodGet) volumeApi.Handle("/{id}/{server}/vacuum", wrapWrite(h.clusterHandlers.VacuumVolume)).Methods(http.MethodPost) + volumeApi.Handle("/{id}/{server}/read-only", wrapWrite(h.clusterHandlers.SetVolumeReadOnly)).Methods(http.MethodPost) pluginApi := api.PathPrefix("/plugin").Subrouter() pluginApi.HandleFunc("/status", h.adminServer.GetPluginStatusAPI).Methods(http.MethodGet) diff --git a/weed/admin/handlers/cluster_handlers.go b/weed/admin/handlers/cluster_handlers.go index 5a453a17c..5551d3355 100644 --- a/weed/admin/handlers/cluster_handlers.go +++ b/weed/admin/handlers/cluster_handlers.go @@ -1,8 +1,10 @@ package handlers import ( + "context" "encoding/json" "fmt" + "io" "math" "mime" "net/http" @@ -416,6 +418,50 @@ func (h *ClusterHandlers) GetVolumeServers(w http.ResponseWriter, r *http.Reques writeJSON(w, http.StatusOK, map[string]interface{}{"volume_servers": topology.VolumeServers}) } +// SetVolumeReadOnly handles access mode changes for a single volume replica. +func (h *ClusterHandlers) SetVolumeReadOnly(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + volumeID, err := strconv.ParseUint(vars["id"], 10, 32) + if err != nil { + writeJSONError(w, http.StatusBadRequest, "Invalid volume ID") + return + } + server := vars["server"] + if server == "" { + writeJSONError(w, http.StatusBadRequest, "Server is required") + return + } + var request struct { + ReadOnly *bool `json:"read_only"` + } + decoder := json.NewDecoder(r.Body) + if err := decoder.Decode(&request); err != nil || request.ReadOnly == nil { + writeJSONError(w, http.StatusBadRequest, "read_only must be a boolean") + return + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + writeJSONError(w, http.StatusBadRequest, "read_only must be a boolean") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + if err := h.adminServer.SetVolumeReadOnly(ctx, uint32(volumeID), server, *request.ReadOnly); err != nil { + writeJSONError(w, http.StatusInternalServerError, "Failed to change volume access mode: "+err.Error()) + return + } + mode := "read/write" + if *request.ReadOnly { + mode = "read-only" + } + writeJSON(w, http.StatusOK, map[string]interface{}{ + "message": fmt.Sprintf("Volume %d on %s marked %s", volumeID, server, mode), + "volume_id": volumeID, + "server": server, + "read_only": *request.ReadOnly, + }) +} + // VacuumVolume handles volume vacuum requests via API func (h *ClusterHandlers) VacuumVolume(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) diff --git a/weed/admin/handlers/cluster_handlers_test.go b/weed/admin/handlers/cluster_handlers_test.go new file mode 100644 index 000000000..4dfa8de64 --- /dev/null +++ b/weed/admin/handlers/cluster_handlers_test.go @@ -0,0 +1,62 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gorilla/mux" + "github.com/seaweedfs/seaweedfs/weed/admin/dash" + "github.com/stretchr/testify/require" +) + +func TestSetVolumeReadOnlyInvalidRequests(t *testing.T) { + for _, tc := range []struct { + name, id, server, body string + }{ + {"missing volume", "", "node-a", `{"read_only":true}`}, + {"negative volume", "-1", "node-a", `{"read_only":true}`}, + {"overflow volume", "4294967296", "node-a", `{"read_only":true}`}, + {"invalid volume", "abc", "node-a", `{"read_only":true}`}, + {"missing server", "7", "", `{"read_only":true}`}, + {"missing mode", "7", "node-a", `{}`}, + {"null mode", "7", "node-a", `{"read_only":null}`}, + {"string mode", "7", "node-a", `{"read_only":"false"}`}, + {"malformed JSON", "7", "node-a", `{"read_only":`}, + {"trailing JSON", "7", "node-a", `{"read_only":true}{}`}, + } { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(tc.body)) + req = mux.SetURLVars(req, map[string]string{"id": tc.id, "server": tc.server}) + w := httptest.NewRecorder() + // Invalid input must be rejected before accessing the cluster. + (&ClusterHandlers{}).SetVolumeReadOnly(w, req) + require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) + }) + } +} + +func TestSetVolumeReadOnlyRoutePermissions(t *testing.T) { + for _, tc := range []struct { + name, role string + enforce bool + wantStatus int + }{ + {"read-only user", dash.RoleReadOnly, true, http.StatusForbidden}, + {"admin", "admin", true, http.StatusBadRequest}, + {"no authentication", "", false, http.StatusBadRequest}, + } { + t.Run(tc.name, func(t *testing.T) { + router := mux.NewRouter() + newRouteTestAdminHandlers().registerAPIRoutes(router.PathPrefix("/api").Subrouter(), tc.enforce) + // Deliberately omit the mode: allowed callers reach validation, while + // read-only callers must be rejected by the route's write guard. + req := httptest.NewRequest(http.MethodPost, "/api/volumes/7/node-a/read-only", strings.NewReader(`{}`)) + req = req.WithContext(dash.WithAuthContext(req.Context(), "", tc.role, "")) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + require.Equal(t, tc.wantStatus, w.Code, w.Body.String()) + }) + } +} diff --git a/weed/admin/view/app/volume_details.templ b/weed/admin/view/app/volume_details.templ index b4ae7c7be..7d61b93c3 100644 --- a/weed/admin/view/app/volume_details.templ +++ b/weed/admin/view/app/volume_details.templ @@ -355,14 +355,29 @@ templ VolumeDetails(data dash.VolumeDetailsData) {
-
+
+ if !dash.IsReadOnlyRole(dash.RoleFromContext(ctx)) { + + + }
+ if !dash.IsReadOnlyRole(dash.RoleFromContext(ctx)) { +
Access mode changes apply only to this replica and persist across restarts. Volume size and disk limits still apply.
+ }
@@ -396,8 +411,38 @@ templ VolumeDetails(data dash.VolumeDetailsData) { performVacuum(volumeId, server, this); }); } + document.querySelectorAll('.volume-access-btn').forEach(button => { + button.addEventListener('click', function() { + setVolumeReadOnly(this.dataset.volumeId, this.dataset.server, this.dataset.readOnly === 'true'); + }); + }); }); + async function setVolumeReadOnly(volumeId, server, readOnly) { + const mode = readOnly ? 'read-only' : 'read/write'; + if (!confirm(`Set volume ${volumeId} on ${server} to ${mode}? This applies only to this replica.`)) { + return; + } + const buttons = document.querySelectorAll('.volume-access-btn'); + buttons.forEach(button => { button.disabled = true; }); + try { + const response = await fetch((window.__BASE_PATH__ || '') + `/api/volumes/${volumeId}/${encodeURIComponent(server)}/read-only`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ read_only: readOnly }) + }); + const data = await response.json(); + if (!response.ok || data.error) { + throw new Error(data.error || 'Failed to change volume access mode'); + } + showMessage(data.message, 'success'); + setTimeout(() => window.location.reload(), 1000); + } catch (error) { + showMessage(error.message, 'error'); + buttons.forEach(button => { button.disabled = false; }); + } + } + function performVacuum(volumeId, server, button) { // Disable button and show loading state const originalText = button.innerHTML; @@ -443,10 +488,13 @@ templ VolumeDetails(data dash.VolumeDetailsData) { toast.style.zIndex = '9999'; toast.style.minWidth = '300px'; - toast.innerHTML = ` - ${message} - - `; + toast.textContent = message; + const closeButton = document.createElement('button'); + closeButton.type = 'button'; + closeButton.className = 'btn-close'; + closeButton.setAttribute('data-bs-dismiss', 'alert'); + closeButton.setAttribute('aria-label', 'Close'); + toast.appendChild(closeButton); document.body.appendChild(toast); diff --git a/weed/admin/view/app/volume_details_templ.go b/weed/admin/view/app/volume_details_templ.go index 676cf77eb..c8bf198b4 100644 --- a/weed/admin/view/app/volume_details_templ.go +++ b/weed/admin/view/app/volume_details_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.1001 +// templ: version: v0.3.1020 package app //lint:file-ignore SA4006 This context is only used if a nested component is present. @@ -328,11 +328,11 @@ func VolumeDetails(data dash.VolumeDetailsData) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var22 string - templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f", float64(data.Volume.Size-data.Volume.DeletedByteCount)/float64(data.Volume.Size)*100)) + templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%.1f", float64(data.Volume.Size-data.Volume.DeletedByteCount)/float64(data.Volume.Size)*100)) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/volume_details.templ`, Line: 177, Col: 157} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -451,11 +451,11 @@ func VolumeDetails(data dash.VolumeDetailsData) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var27 string - templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(data.Volume.RemoteStorageKey) + templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(data.Volume.RemoteStorageKey) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/volume_details.templ`, Line: 261, Col: 138} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -680,16 +680,16 @@ func VolumeDetails(data dash.VolumeDetailsData) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "
Actions
Use these actions to perform maintenance operations on the volume.
Last updated: ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "\">Vacuum ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var45 string - templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("2006-01-02 15:04:05")) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/volume_details.templ`, Line: 382, Col: 77} + if !dash.IsReadOnlyRole(dash.RoleFromContext(ctx)) { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45)) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "
") + if !dash.IsReadOnlyRole(dash.RoleFromContext(ctx)) { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "
Access mode changes apply only to this replica and persist across restarts. Volume size and disk limits still apply.
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "
Use these actions to perform maintenance operations on the volume.
Last updated: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var49 string + templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("2006-01-02 15:04:05")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/volume_details.templ`, Line: 397, Col: 77} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/weed/admin/view/app/volume_details_test.go b/weed/admin/view/app/volume_details_test.go new file mode 100644 index 000000000..141074bf7 --- /dev/null +++ b/weed/admin/view/app/volume_details_test.go @@ -0,0 +1,47 @@ +package app + +import ( + "bytes" + "testing" + + "github.com/seaweedfs/seaweedfs/weed/admin/dash" + "github.com/seaweedfs/seaweedfs/weed/pb/master_pb" + "github.com/stretchr/testify/require" + "golang.org/x/net/html" +) + +func TestVolumeDetailsAccessControls(t *testing.T) { + for _, role := range []string{"", "admin", dash.RoleReadOnly} { + for _, readOnly := range []bool{false, true} { + data := dash.VolumeDetailsData{Volume: dash.VolumeWithTopology{ + VolumeInformationMessage: &master_pb.VolumeInformationMessage{Id: 7, ReadOnly: readOnly}, + Server: "node-a", + }} + var rendered bytes.Buffer + ctx := dash.WithAuthContext(t.Context(), "", role, "") + require.NoError(t, VolumeDetails(data).Render(ctx, &rendered)) + doc, err := html.Parse(&rendered) + require.NoError(t, err) + var modes []string + var walk func(*html.Node) + walk = func(node *html.Node) { + if node.Type == html.ElementNode && node.Data == "button" { + for _, attr := range node.Attr { + if attr.Key == "data-read-only" { + modes = append(modes, attr.Val) + } + } + } + for child := node.FirstChild; child != nil; child = child.NextSibling { + walk(child) + } + } + walk(doc) + if role == dash.RoleReadOnly { + require.Empty(t, modes) + } else { + require.Equal(t, []string{"true", "false"}, modes, "both modes must be available even when the displayed state is stale") + } + } + } +} diff --git a/weed/server/volume_grpc_admin.go b/weed/server/volume_grpc_admin.go index a4b6b2241..d12092af6 100644 --- a/weed/server/volume_grpc_admin.go +++ b/weed/server/volume_grpc_admin.go @@ -27,6 +27,7 @@ import ( "github.com/seaweedfs/seaweedfs/weed/storage/needle" "github.com/seaweedfs/seaweedfs/weed/storage/super_block" "github.com/seaweedfs/seaweedfs/weed/storage/types" + "github.com/seaweedfs/seaweedfs/weed/storage/volume_info" ) // checkGrpcAdminAuth verifies the gRPC caller is authorized for destructive @@ -282,9 +283,18 @@ func (vs *VolumeServer) makeVolumeReadonly(ctx context.Context, v *storage.Volum // rare case 1.5: it will be unlucky if heartbeat happened between step 1 and 2. // step 2: mark local volume as readonly + var persistErr error if err := vs.store.MarkVolumeReadonly(v.Id, canDelete, persist); err != nil { - glog.Errorf("mark volume %d readonly: %v", v.Id, err) - return err + var ndErr *volume_info.NotCrashDurableError + if !errors.As(err, &ndErr) { + glog.Errorf("mark volume %d readonly: %v", v.Id, err) + return err + } + // Post-rename durability failure: the .vif already holds the new + // mode. Continue with step 3 so the master reflects the change, + // then propagate the durability warning. + glog.Warningf("mark volume %d readonly: %v", v.Id, err) + persistErr = err } else { glog.V(2).Infof("volume %d marked readonly", v.Id) } @@ -294,7 +304,7 @@ func (vs *VolumeServer) makeVolumeReadonly(ctx context.Context, v *storage.Volum return err } - return nil + return persistErr } func (vs *VolumeServer) makeVolumeWritable(ctx context.Context, v *storage.Volume) error { @@ -302,9 +312,18 @@ func (vs *VolumeServer) makeVolumeWritable(ctx context.Context, v *storage.Volum return err } + var persistErr error if err := vs.store.MarkVolumeWritable(v.Id); err != nil { - glog.Errorf("mark volume %d writable: %v", v.Id, err) - return err + var ndErr *volume_info.NotCrashDurableError + if !errors.As(err, &ndErr) { + glog.Errorf("mark volume %d writable: %v", v.Id, err) + return err + } + // Post-rename durability failure: the .vif already holds the new + // mode. Continue notifying the master so traffic is redirected, + // then propagate the durability warning. + glog.Warningf("mark volume %d writable: %v", v.Id, err) + persistErr = err } else { glog.V(2).Infof("volume %d marked writable", v.Id) } @@ -314,7 +333,7 @@ func (vs *VolumeServer) makeVolumeWritable(ctx context.Context, v *storage.Volum return err } - return nil + return persistErr } func isNotLeaderErr(err error) bool { diff --git a/weed/server/volume_grpc_erasure_coding.go b/weed/server/volume_grpc_erasure_coding.go index 64085cfb6..cd520b287 100644 --- a/weed/server/volume_grpc_erasure_coding.go +++ b/weed/server/volume_grpc_erasure_coding.go @@ -2,6 +2,7 @@ package weed_server import ( "context" + "errors" "fmt" "io" "math" @@ -164,7 +165,14 @@ func (vs *VolumeServer) VolumeEcShardsGenerate(ctx context.Context, req *volume_ req.VolumeId, ecCtx.DataShards, ecCtx.ParityShards, ecCtx.Total()) if err := volume_info.SaveVolumeInfo(baseFileName+".vif", volumeInfo); err != nil { - return nil, fmt.Errorf("SaveVolumeInfo %s: %v", baseFileName, err) + var ndErr *volume_info.NotCrashDurableError + if !errors.As(err, &ndErr) { + return nil, fmt.Errorf("SaveVolumeInfo %s: %v", baseFileName, err) + } + // The .vif is committed but may not be crash-durable. The EC + // config is already on disk, so do not clean up the generated + // shard files; a restart will find them and the matching metadata. + glog.Warningf("SaveVolumeInfo %s saved but not crash-durable: %v", baseFileName, err) } shouldCleanup = false diff --git a/weed/server/volume_grpc_tier_download.go b/weed/server/volume_grpc_tier_download.go index afac16b81..32e65979b 100644 --- a/weed/server/volume_grpc_tier_download.go +++ b/weed/server/volume_grpc_tier_download.go @@ -1,6 +1,7 @@ package weed_server import ( + "errors" "fmt" "os" "path/filepath" @@ -12,6 +13,7 @@ import ( "github.com/seaweedfs/seaweedfs/weed/storage" "github.com/seaweedfs/seaweedfs/weed/storage/backend" "github.com/seaweedfs/seaweedfs/weed/storage/needle" + "github.com/seaweedfs/seaweedfs/weed/storage/volume_info" ) // VolumeTierMoveDatFromRemote copy dat file from a remote tier to local volume server @@ -94,7 +96,14 @@ func (vs *VolumeServer) VolumeTierMoveDatFromRemote(req *volume_server_pb.Volume // with a .vif referencing the remote object while that object is deleted. v.GetVolumeInfo().Files = v.GetVolumeInfo().Files[1:] if err := v.SaveVolumeInfo(); err != nil { - return fmt.Errorf("volume %d failed to save remote file info: %v", v.Id, err) + var ndErr *volume_info.NotCrashDurableError + if !errors.As(err, &ndErr) { + return fmt.Errorf("volume %d failed to save remote file info: %v", v.Id, err) + } + // The .vif is committed but may not be crash-durable. Continue + // with the backend switch and remote deletion since the metadata + // already reflects the local-only state. + glog.Warningf("volume %d saved remote file info but not crash-durable: %v", v.Id, err) } // fsync the directory again so the rewritten .vif is durable. diff --git a/weed/storage/needle_map_file_pool_test.go b/weed/storage/needle_map_file_pool_test.go index 9f1f15e35..c313ad467 100644 --- a/weed/storage/needle_map_file_pool_test.go +++ b/weed/storage/needle_map_file_pool_test.go @@ -77,7 +77,9 @@ func TestSortedFileNeedleMap_HoldsNoDescriptors(t *testing.T) { t.Fatalf("write needle %d: %v", i, err) } } - v.PersistReadOnly(true, true) + if err := v.PersistReadOnly(true, true); err != nil { + t.Fatalf("persist read-only: %v", err) + } v.Close() v, err = NewVolume(dir, dir, "", 1, NeedleMapInMemory, &super_block.ReplicaPlacement{}, &needle.TTL{}, 0, needle.GetCurrentVersion(), 0, 0) diff --git a/weed/storage/store.go b/weed/storage/store.go index 19632d0c3..3b7818e11 100644 --- a/weed/storage/store.go +++ b/weed/storage/store.go @@ -838,6 +838,8 @@ func (s *Store) MarkVolumeReadonly(i needle.VolumeId, canDelete bool, persist bo } } v.noWriteLock.Lock() + prevNoWriteOrDelete := v.noWriteOrDelete + prevNoWriteCanDelete := v.noWriteCanDelete v.noWriteOrDelete = !canDelete if canDelete { v.noWriteCanDelete = true @@ -846,7 +848,18 @@ func (s *Store) MarkVolumeReadonly(i needle.VolumeId, canDelete bool, persist bo v.noWriteCanDelete = false } if persist { - v.PersistReadOnly(true, canDelete) + if err := v.PersistReadOnly(true, canDelete); err != nil { + // A pre-commit failure leaves the old .vif intact, so roll + // back the in-memory flags. A NotCrashDurableError means the + // rename already committed; keep flags aligned with the file. + var ndErr *volume_info.NotCrashDurableError + if !errors.As(err, &ndErr) { + v.noWriteOrDelete = prevNoWriteOrDelete + v.noWriteCanDelete = prevNoWriteCanDelete + } + v.noWriteLock.Unlock() + return fmt.Errorf("volume %d persist read-only: %w", i, err) + } } v.noWriteLock.Unlock() return nil @@ -865,18 +878,36 @@ func (s *Store) MarkVolumeWritable(i needle.VolumeId) error { return fmt.Errorf("volume %d reopen idx for write: %v", i, err) } v.noWriteLock.Lock() + prevNoWriteOrDelete := v.noWriteOrDelete + prevNoWriteCanDelete := v.noWriteCanDelete v.noWriteOrDelete = false // Remote-tiered volumes must stay noWriteCanDelete regardless of marks. if !v.HasRemoteFile() { v.noWriteCanDelete = false } - v.PersistReadOnly(false, false) + persistErr := v.PersistReadOnly(false, false) + if persistErr != nil { + var ndErr *volume_info.NotCrashDurableError + if !errors.As(persistErr, &ndErr) { + // Pre-commit failure: the old .vif is intact, so roll back + // the in-memory flags and return early. + v.noWriteOrDelete = prevNoWriteOrDelete + v.noWriteCanDelete = prevNoWriteCanDelete + v.noWriteLock.Unlock() + return fmt.Errorf("volume %d persist writable: %w", i, persistErr) + } + // Post-rename durability failure: the file already holds the new + // mode. Wrap the error but continue with post-commit work so the + // volume is usable in memory even though the rename may not + // survive a crash. + persistErr = fmt.Errorf("volume %d persist writable: %w", i, persistErr) + } v.noWriteLock.Unlock() // Clear the EIO streak and the sticky quarantine flag so the next // CollectHeartbeat can announce the volume again. If the disk is // still bad, the next failed op will re-arm the streak. v.resetIoErrorState() - return nil + return persistErr } func (s *Store) MountVolume(i needle.VolumeId) error { diff --git a/weed/storage/volume.go b/weed/storage/volume.go index 5a3baf823..404d3ba43 100644 --- a/weed/storage/volume.go +++ b/weed/storage/volume.go @@ -1,6 +1,7 @@ package storage import ( + "errors" "fmt" "os" "path" @@ -16,6 +17,7 @@ import ( "github.com/seaweedfs/seaweedfs/weed/storage/needle" "github.com/seaweedfs/seaweedfs/weed/storage/super_block" "github.com/seaweedfs/seaweedfs/weed/storage/types" + "github.com/seaweedfs/seaweedfs/weed/storage/volume_info" "github.com/seaweedfs/seaweedfs/weed/glog" ) @@ -575,10 +577,25 @@ func (v *Volume) ReadOnlyReasons() (readOnly, noWriteOrDelete, noWriteCanDelete, return noWriteOrDelete || noWriteCanDelete || diskSpaceLow, noWriteOrDelete, noWriteCanDelete, diskSpaceLow } -func (v *Volume) PersistReadOnly(readOnly bool, canDelete bool) { +func (v *Volume) PersistReadOnly(readOnly bool, canDelete bool) error { v.volumeInfoRWLock.Lock() defer v.volumeInfoRWLock.Unlock() + prevReadOnly := v.volumeInfo.ReadOnly + prevReadOnlyCanDelete := v.volumeInfo.ReadOnlyCanDelete v.volumeInfo.ReadOnly = readOnly v.volumeInfo.ReadOnlyCanDelete = readOnly && canDelete - v.SaveVolumeInfo() + if err := v.SaveVolumeInfo(); err != nil { + // A pre-commit failure (write/sync/close/rename) leaves the old + // .vif intact, so roll back in-memory state to match it. A + // NotCrashDurableError means the rename already committed the + // new mode to disk; rolling back would split in-memory state + // from the durable file, so keep the new state and propagate. + var ndErr *volume_info.NotCrashDurableError + if !errors.As(err, &ndErr) { + v.volumeInfo.ReadOnly = prevReadOnly + v.volumeInfo.ReadOnlyCanDelete = prevReadOnlyCanDelete + } + return fmt.Errorf("persist volume read-only state: %w", err) + } + return nil } diff --git a/weed/storage/volume_info/volume_info.go b/weed/storage/volume_info/volume_info.go index 78c931af6..b10be555d 100644 --- a/weed/storage/volume_info/volume_info.go +++ b/weed/storage/volume_info/volume_info.go @@ -3,6 +3,7 @@ package volume_info import ( "fmt" "os" + "path/filepath" "github.com/seaweedfs/seaweedfs/weed/glog" "github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb" @@ -68,6 +69,22 @@ func MaybeLoadVolumeInfo(fileName string) (volumeInfo *volume_server_pb.VolumeIn return } +// NotCrashDurableError indicates that the .vif file was renamed +// successfully but the directory entry may not survive a crash. The +// on-disk file already holds the new metadata, so callers should keep +// in-memory state aligned with the file rather than rolling back, while +// still propagating the durability failure to the user. +type NotCrashDurableError struct { + FileName string + Err error +} + +func (e *NotCrashDurableError) Error() string { + return fmt.Sprintf("volume info %s saved but not crash-durable: %v", e.FileName, e.Err) +} + +func (e *NotCrashDurableError) Unwrap() error { return e.Err } + func SaveVolumeInfo(fileName string, volumeInfo *volume_server_pb.VolumeInfo) error { if exists, _, canWrite, _, _ := util.CheckFile(fileName); exists && !canWrite { @@ -85,8 +102,49 @@ func SaveVolumeInfo(fileName string, volumeInfo *volume_server_pb.VolumeInfo) er return fmt.Errorf("failed to marshal %s: %v", fileName, marshalErr) } - if err := util.WriteFile(fileName, text, 0644); err != nil { - return fmt.Errorf("failed to write %s: %v", fileName, err) + // Write atomically so a write/sync/close failure leaves the existing + // .vif file intact. PersistReadOnly rolls back in-memory state on + // error; the atomic rename guarantees the durable file still matches + // that rolled-back state rather than the requested mode. Use a + // unique temp file so concurrent saves for the same volume do not + // collide on a shared .tmp path. + f, err := os.CreateTemp(filepath.Dir(fileName), filepath.Base(fileName)+".tmp.*") + if err != nil { + return fmt.Errorf("failed to create temp file for %s: %w", fileName, err) + } + tmpName := f.Name() + if _, err := f.Write(text); err != nil { + f.Close() + os.Remove(tmpName) + return fmt.Errorf("failed to write %s: %w", fileName, err) + } + if err := f.Chmod(0644); err != nil { + f.Close() + os.Remove(tmpName) + return fmt.Errorf("failed to chmod %s: %w", fileName, err) + } + if err := f.Sync(); err != nil { + f.Close() + os.Remove(tmpName) + return fmt.Errorf("failed to sync %s: %w", fileName, err) + } + if err := f.Close(); err != nil { + os.Remove(tmpName) + return fmt.Errorf("failed to close %s: %w", fileName, err) + } + if err := os.Rename(tmpName, fileName); err != nil { + os.Remove(tmpName) + return fmt.Errorf("failed to rename %s: %w", fileName, err) + } + // The rename has committed the new metadata to the on-disk file. + // A directory fsync failure only risks losing the rename across a + // crash; the file content is already correct, so callers must not + // roll back in-memory state. Return NotCrashDurableError so they + // can distinguish this from a pre-commit failure and keep state + // aligned with the renamed file while still reporting the issue. + if err := util.FsyncDir(filepath.Dir(fileName)); err != nil { + glog.Warningf("fsync dir for %s: %v", fileName, err) + return &NotCrashDurableError{FileName: fileName, Err: err} } return nil diff --git a/weed/storage/volume_loading_corrupt_idx_test.go b/weed/storage/volume_loading_corrupt_idx_test.go index aeba1269c..f872ec622 100644 --- a/weed/storage/volume_loading_corrupt_idx_test.go +++ b/weed/storage/volume_loading_corrupt_idx_test.go @@ -20,7 +20,9 @@ func TestLoad_CorruptIdx_NoSegfault(t *testing.T) { if _, _, _, err := v.writeNeedle2(newRandomNeedle(1), true, false, false); err != nil { t.Fatalf("seed write: %v", err) } - v.PersistReadOnly(true, false) // reload goes through SortedFileNeedleMap + if err := v.PersistReadOnly(true, false); err != nil { // reload goes through SortedFileNeedleMap + t.Fatalf("persist read-only: %v", err) + } v.Close() // Truncate .idx to a non-aligned size so the walk rejects it. diff --git a/weed/storage/volume_mark_writable_test.go b/weed/storage/volume_mark_writable_test.go index 33e1097ec..2b120e1a1 100644 --- a/weed/storage/volume_mark_writable_test.go +++ b/weed/storage/volume_mark_writable_test.go @@ -27,7 +27,9 @@ func TestMarkVolumeWritable_ReopensPersistedReadOnly(t *testing.T) { // Persist read-only state into .vif, then simulate a server restart by // closing and re-opening the volume from the same directory. - v.PersistReadOnly(true, false) + if err := v.PersistReadOnly(true, false); err != nil { + t.Fatalf("persist read-only: %v", err) + } v.Close() v2, err := NewVolume(dir, dir, "", 1, NeedleMapInMemory, &super_block.ReplicaPlacement{}, &needle.TTL{}, 0, needle.GetCurrentVersion(), 0, 0) diff --git a/weed/storage/volume_write_test.go b/weed/storage/volume_write_test.go index e479367ee..9073de325 100644 --- a/weed/storage/volume_write_test.go +++ b/weed/storage/volume_write_test.go @@ -187,7 +187,9 @@ func TestWriteNeedleBlobRejectedOnReadOnlyVolume(t *testing.T) { if err != nil { t.Fatalf("read needle blob: %v", err) } - v.PersistReadOnly(true, false) + if err := v.PersistReadOnly(true, false); err != nil { + t.Fatalf("persist read-only: %v", err) + } v.Close() v, err = NewVolume(dir, dir, "", 7, NeedleMapInMemory, &super_block.ReplicaPlacement{}, &needle.TTL{}, 0, needle.GetCurrentVersion(), 0, 0)