mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-10 23:26:53 +00:00
5594b18912
* 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)
233 lines
7.8 KiB
Rust
233 lines
7.8 KiB
Rust
// Copyright 2024 RustFS Team
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
//! `BucketOperations` for `SetDisks`.
|
|
//!
|
|
//! P4 of the SetDisks God-Object split (tracking backlog#815, issue #819).
|
|
//! Relocated verbatim from `set_disk/mod.rs`; the contract stays implemented
|
|
//! `for SetDisks`, so its associated-type bounds are unchanged and runtime
|
|
//! behavior is the same.
|
|
|
|
use super::super::*;
|
|
|
|
#[async_trait::async_trait]
|
|
impl BucketOperations for SetDisks {
|
|
type Error = Error;
|
|
|
|
#[tracing::instrument(skip(self))]
|
|
async fn make_bucket(&self, bucket: &str, opts: &MakeBucketOptions) -> Result<()> {
|
|
let disks = self.disk_inventory().await;
|
|
let write_quorum = (disks.len() / 2) + 1;
|
|
let force_create = opts.force_create;
|
|
|
|
let mut futures = Vec::with_capacity(disks.len());
|
|
for disk in disks {
|
|
let bucket = bucket.to_string();
|
|
futures.push(async move {
|
|
match disk {
|
|
Some(disk) => match disk.make_volume(&bucket).await {
|
|
Ok(()) => Ok(()),
|
|
Err(err) if force_create && matches!(err, DiskError::VolumeExists) => Ok(()),
|
|
Err(err) => Err(err),
|
|
},
|
|
None => Err(DiskError::DiskNotFound),
|
|
}
|
|
});
|
|
}
|
|
|
|
let results = join_all(futures).await;
|
|
let errs = results
|
|
.into_iter()
|
|
.map(|result| result.err())
|
|
.collect::<Vec<Option<DiskError>>>();
|
|
|
|
if let Some(err) = reduce_write_quorum_errs(&errs, BUCKET_OP_IGNORED_ERRS, write_quorum) {
|
|
return Err(err.into());
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tracing::instrument(skip(self))]
|
|
async fn get_bucket_info(&self, bucket: &str, _opts: &BucketOptions) -> Result<BucketInfo> {
|
|
let disks = self.disk_inventory().await;
|
|
let write_quorum = (disks.len() / 2) + 1;
|
|
|
|
let mut futures = Vec::with_capacity(disks.len());
|
|
for disk in disks {
|
|
let bucket = bucket.to_string();
|
|
futures.push(async move {
|
|
match disk {
|
|
Some(disk) => disk.stat_volume(&bucket).await,
|
|
None => Err(DiskError::DiskNotFound),
|
|
}
|
|
});
|
|
}
|
|
|
|
let results = join_all(futures).await;
|
|
let mut infos = Vec::with_capacity(results.len());
|
|
let mut errs = Vec::with_capacity(results.len());
|
|
for result in results {
|
|
match result {
|
|
Ok(info) => {
|
|
infos.push(Some(info));
|
|
errs.push(None);
|
|
}
|
|
Err(err) => {
|
|
infos.push(None);
|
|
errs.push(Some(err));
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(err) = reduce_write_quorum_errs(&errs, BUCKET_OP_IGNORED_ERRS, write_quorum) {
|
|
return Err(err.into());
|
|
}
|
|
|
|
let mut versioning = false;
|
|
let mut object_locking = false;
|
|
if let Ok(sys) = metadata_sys::get(bucket).await {
|
|
versioning = sys.versioning();
|
|
object_locking = sys.object_locking();
|
|
}
|
|
|
|
infos
|
|
.into_iter()
|
|
.flatten()
|
|
.next()
|
|
.map(|info| BucketInfo {
|
|
name: info.name,
|
|
created: info.created,
|
|
versioning,
|
|
object_locking,
|
|
..Default::default()
|
|
})
|
|
.ok_or(Error::VolumeNotFound)
|
|
}
|
|
|
|
#[tracing::instrument(skip(self))]
|
|
async fn list_bucket(&self, _opts: &BucketOptions) -> Result<Vec<BucketInfo>> {
|
|
let disks = self.disk_inventory().await;
|
|
let write_quorum = (disks.len() / 2) + 1;
|
|
|
|
let mut futures = Vec::with_capacity(disks.len());
|
|
for disk in disks {
|
|
futures.push(async move {
|
|
match disk {
|
|
Some(disk) => disk.list_volumes().await,
|
|
None => Err(DiskError::DiskNotFound),
|
|
}
|
|
});
|
|
}
|
|
|
|
let results = join_all(futures).await;
|
|
let mut infos = Vec::with_capacity(results.len());
|
|
let mut errs = Vec::with_capacity(results.len());
|
|
for result in results {
|
|
match result {
|
|
Ok(volumes) => {
|
|
infos.push(Some(volumes));
|
|
errs.push(None);
|
|
}
|
|
Err(err) => {
|
|
infos.push(None);
|
|
errs.push(Some(err));
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(err) = reduce_write_quorum_errs(&errs, BUCKET_OP_IGNORED_ERRS, write_quorum) {
|
|
return Err(err.into());
|
|
}
|
|
|
|
let mut counts: HashMap<String, (usize, BucketInfo)> = HashMap::new();
|
|
for volumes in infos.into_iter().flatten() {
|
|
for volume in volumes {
|
|
if is_reserved_or_invalid_bucket(&volume.name, false) {
|
|
continue;
|
|
}
|
|
|
|
let entry = counts.entry(volume.name.clone()).or_insert((
|
|
0,
|
|
BucketInfo {
|
|
name: volume.name.clone(),
|
|
created: volume.created,
|
|
..Default::default()
|
|
},
|
|
));
|
|
entry.0 += 1;
|
|
}
|
|
}
|
|
|
|
let mut buckets = counts
|
|
.into_values()
|
|
.filter_map(|(count, bucket)| (count >= write_quorum).then_some(bucket))
|
|
.collect::<Vec<_>>();
|
|
buckets.sort_by(|left, right| left.name.cmp(&right.name));
|
|
Ok(buckets)
|
|
}
|
|
|
|
#[tracing::instrument(skip(self))]
|
|
async fn delete_bucket(&self, bucket: &str, opts: &DeleteBucketOptions) -> Result<()> {
|
|
let disks = self.disk_inventory().await;
|
|
let write_quorum = (disks.len() / 2) + 1;
|
|
|
|
let mut futures = Vec::with_capacity(disks.len());
|
|
for disk in disks.iter().cloned() {
|
|
let bucket = bucket.to_string();
|
|
let force = opts.force;
|
|
futures.push(async move {
|
|
match disk {
|
|
// Non-force refuses a non-empty bucket (VolumeNotEmpty); only
|
|
// an explicit force delete removes recursively (backlog#799 B1).
|
|
Some(disk) => disk.delete_volume(&bucket, force).await,
|
|
None => Err(DiskError::DiskNotFound),
|
|
}
|
|
});
|
|
}
|
|
|
|
let results = join_all(futures).await;
|
|
let mut errs = Vec::with_capacity(results.len());
|
|
let mut recreate = false;
|
|
for result in results {
|
|
match result {
|
|
Ok(()) => errs.push(None),
|
|
Err(err) => {
|
|
if matches!(err, DiskError::VolumeNotEmpty) {
|
|
recreate = true;
|
|
}
|
|
errs.push(Some(err));
|
|
}
|
|
}
|
|
}
|
|
|
|
if recreate {
|
|
for (index, err) in errs.iter().enumerate() {
|
|
if err.is_none()
|
|
&& let Some(Some(disk)) = disks.get(index)
|
|
{
|
|
let _ = disk.make_volume(bucket).await;
|
|
}
|
|
}
|
|
return Err(Error::VolumeNotEmpty);
|
|
}
|
|
|
|
if let Some(err) = reduce_write_quorum_errs(&errs, BUCKET_OP_IGNORED_ERRS, write_quorum) {
|
|
return Err(err.into());
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
}
|