fix(ecstore): make delete_volume non-recursive by default to prevent bucket-heal wipe (backlog#799 B1) (#4339)

* fix(ecstore): make delete_volume non-recursive by default to prevent bucket-heal wipe (backlog#799 B1)

`delete_volume` unconditionally `remove_dir_all`'d the whole bucket tree, and the
bucket-heal "remove" branch called it fire-and-forget on every local disk. A
mis-classified "dangling" bucket (or a non-force S3 DeleteBucket on a populated
bucket) was therefore recursively wiped — a potential whole-bucket data loss.
The `VolumeNotEmpty` -> recreate/`BucketNotEmpty` handling already present in
both delete_bucket paths was dead code because the primitive never refused.

Add an explicit `force_delete` flag to `DiskAPI::delete_volume` and default the
non-force path to a non-recursive `remove_dir` (rmdir), which fails atomically
with `VolumeNotEmpty` if the bucket still holds any object data. Only an explicit
force delete (S3 force bucket delete) removes recursively. Mirrors MinIO's
`xlStorage.DeleteVol` (`Remove` vs `RemoveAll`).

- Trait + all impls (local behavior, dispatch, disk_store, remote RPC) take the
  flag; the gRPC `DeleteVolumeRequest` gains a `force` field (proto3 default
  false → old peers get the safe non-recursive behavior on rolling upgrade).
- Heal remove branch passes `false` and no longer discards the result: a
  `VolumeNotEmpty` refusal is logged (the bucket is not dangling) instead of
  wiping data.
- Both `delete_bucket` paths pass `opts.force`, activating the previously-dead
  `VolumeNotEmpty` -> `BucketNotEmpty`/recreate handling (correct S3 semantics).

Adds a regression test: non-force delete of a non-empty bucket returns
VolumeNotEmpty and preserves the data; force delete removes it.

Design converged by two independent expert reviews (MinIO-fidelity +
defense-in-depth) referencing MinIO xl-storage.go. Refs backlog#799 (B1),
issue rustfs/backlog#850. The safety expert's deeper hardening (typed
capability instead of a bool, trash-instead-of-in-place for force, quorum
re-verification of dangling) is noted on #850 as follow-up.

* fix(ecstore): reword 'mis-classified' -> 'misclassified' to satisfy typos (backlog#799 B1)

* fix(rustfs): thread force_delete through StorageDiskRpcExt::delete_volume + test literal (backlog#799 B1)
This commit is contained in:
Zhengchao An
2026-07-07 08:25:17 +08:00
committed by GitHub
parent 28c0543f3c
commit 5594b18912
11 changed files with 105 additions and 24 deletions
+1
View File
@@ -2061,6 +2061,7 @@ mod tests {
let request = Request::new(DeleteVolumeRequest {
disk: "invalid-disk-path".to_string(),
volume: "test-volume".to_string(),
force: false,
});
let response = service.delete_volume(request).await;
+1 -1
View File
@@ -159,7 +159,7 @@ impl NodeService {
) -> Result<Response<DeleteVolumeResponse>, Status> {
let request = request.into_inner();
if let Some(disk) = self.find_disk(&request.disk).await {
match disk.delete_volume(&request.volume).await {
match disk.delete_volume(&request.volume, request.force).await {
Ok(_) => Ok(Response::new(DeleteVolumeResponse {
success: true,
error: None,
+3 -3
View File
@@ -804,7 +804,7 @@ pub(crate) async fn update_erasure_type(setup_type: SetupType) {
pub(crate) trait StorageDiskRpcExt {
async fn disk_info(&self, opts: &DiskInfoOptions) -> DiskResult<DiskInfo>;
async fn delete_volume(&self, volume: &str) -> DiskResult<()>;
async fn delete_volume(&self, volume: &str, force_delete: bool) -> DiskResult<()>;
async fn read_multiple(&self, req: ReadMultipleReq) -> DiskResult<Vec<ReadMultipleResp>>;
async fn batch_read_version(&self, req: BatchReadVersionReq) -> DiskResult<Vec<BatchReadVersionResp>>;
async fn delete_versions(&self, volume: &str, versions: Vec<FileInfoVersions>, opts: DeleteOptions)
@@ -884,8 +884,8 @@ where
ecstore_disk::DiskAPI::disk_info(self, opts).await
}
async fn delete_volume(&self, volume: &str) -> DiskResult<()> {
ecstore_disk::DiskAPI::delete_volume(self, volume).await
async fn delete_volume(&self, volume: &str, force_delete: bool) -> DiskResult<()> {
ecstore_disk::DiskAPI::delete_volume(self, volume, force_delete).await
}
async fn read_multiple(&self, req: ReadMultipleReq) -> DiskResult<Vec<ReadMultipleResp>> {