mirror of
https://github.com/block/buzz.git
synced 2026-10-30 14:01:37 +00:00
364 lines
13 KiB
Rust
364 lines
13 KiB
Rust
//! Live destructive versioned-bucket deletion coverage against docker-compose MinIO.
|
|
//!
|
|
//! This exercises the S3-compatible path that community deletion relies on when
|
|
//! a bucket has versioning enabled: list object versions/delete markers with
|
|
//! dual markers, delete exact `(Key, VersionId)` identifiers, retry an already
|
|
//! deleted version, and prove final `ListObjectVersions` emptiness.
|
|
//!
|
|
//! Run it against the docker-compose MinIO (creds `buzz_dev`/`buzz_dev_secret`):
|
|
//!
|
|
//! ```bash
|
|
//! docker compose up -d minio minio-init
|
|
//! cargo test -p buzz-media --test versioned_minio -- --ignored --nocapture
|
|
//! ```
|
|
//!
|
|
//! The test creates and removes its own bucket. The MinIO container name is
|
|
//! overridable with `BUZZ_MINIO_CONTAINER`; credentials/endpoint/region/addressing
|
|
//! use the same `BUZZ_S3_*` env vars as `static_creds_minio`.
|
|
|
|
use std::process::Command;
|
|
|
|
use buzz_media::config::MediaConfig;
|
|
use buzz_media::storage::{MediaStorage, ObjectVersionKind, ObjectVersionRef};
|
|
|
|
fn env_or(name: &str, default: &str) -> String {
|
|
std::env::var(name).unwrap_or_else(|_| default.to_string())
|
|
}
|
|
|
|
fn minio_config(bucket: String) -> MediaConfig {
|
|
MediaConfig {
|
|
s3_endpoint: env_or("BUZZ_S3_ENDPOINT", "http://localhost:9000"),
|
|
s3_access_key: env_or("BUZZ_S3_ACCESS_KEY", "buzz_dev"),
|
|
s3_secret_key: env_or("BUZZ_S3_SECRET_KEY", "buzz_dev_secret"),
|
|
s3_bucket: bucket,
|
|
s3_region: env_or("BUZZ_S3_REGION", "us-east-1"),
|
|
s3_addressing_style: env_or("BUZZ_S3_ADDRESSING_STYLE", "path")
|
|
.parse()
|
|
.expect("BUZZ_S3_ADDRESSING_STYLE must be path or virtual"),
|
|
max_image_bytes: 50 * 1024 * 1024,
|
|
max_gif_bytes: 10 * 1024 * 1024,
|
|
max_video_bytes: 524_288_000,
|
|
max_file_bytes: 104_857_600,
|
|
public_base_url: "http://localhost:3000/media".to_string(),
|
|
upload_records_enabled: false,
|
|
upload_ip_header: None,
|
|
upload_port_header: None,
|
|
}
|
|
}
|
|
|
|
fn run_mc(args: &[String]) -> Result<(), String> {
|
|
let container = env_or("BUZZ_MINIO_CONTAINER", "buzz-minio");
|
|
let output = Command::new("docker")
|
|
.arg("exec")
|
|
.arg(container)
|
|
.arg("mc")
|
|
.args(args)
|
|
.output()
|
|
.map_err(|err| format!("failed to execute docker/mc: {err}"))?;
|
|
if output.status.success() {
|
|
Ok(())
|
|
} else {
|
|
Err(format!(
|
|
"mc {:?} failed with status {}\nstdout:\n{}\nstderr:\n{}",
|
|
args,
|
|
output.status,
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
))
|
|
}
|
|
}
|
|
|
|
fn mc_alias(access_key: &str, secret_key: &str) -> Result<(), String> {
|
|
run_mc(&[
|
|
"alias".to_string(),
|
|
"set".to_string(),
|
|
"local".to_string(),
|
|
"http://localhost:9000".to_string(),
|
|
access_key.to_string(),
|
|
secret_key.to_string(),
|
|
])
|
|
}
|
|
|
|
async fn list_all_versions(
|
|
storage: &MediaStorage,
|
|
prefix: &str,
|
|
max_keys: usize,
|
|
) -> Vec<buzz_media::storage::ObjectVersionEntry> {
|
|
let mut entries = Vec::new();
|
|
let mut key_marker = None;
|
|
let mut version_id_marker = None;
|
|
loop {
|
|
let page = storage
|
|
.list_prefix_versions_page(
|
|
prefix,
|
|
key_marker.take(),
|
|
version_id_marker.take(),
|
|
max_keys,
|
|
)
|
|
.await
|
|
.expect("list object versions page");
|
|
if page.is_truncated {
|
|
assert!(
|
|
page.next_key_marker.is_some(),
|
|
"truncated ListObjectVersions page must include NextKeyMarker"
|
|
);
|
|
assert!(
|
|
page.next_version_id_marker.is_some(),
|
|
"truncated ListObjectVersions page must include NextVersionIdMarker"
|
|
);
|
|
}
|
|
entries.extend(page.entries);
|
|
if !page.is_truncated {
|
|
break;
|
|
}
|
|
key_marker = page.next_key_marker;
|
|
version_id_marker = page.next_version_id_marker;
|
|
}
|
|
entries
|
|
}
|
|
|
|
fn refs_from(entries: &[buzz_media::storage::ObjectVersionEntry]) -> Vec<ObjectVersionRef> {
|
|
entries
|
|
.iter()
|
|
.map(|entry| ObjectVersionRef {
|
|
key: entry.key.clone(),
|
|
version_id: entry.version_id.clone(),
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "requires live docker-compose MinIO; permanently deletes exact test object versions"]
|
|
async fn never_versioned_bucket_lists_null_versions_and_exact_delete_empties_listing() {
|
|
let bucket = format!("buzz-media-never-versioned-{}", std::process::id());
|
|
let bucket_path = format!("local/{bucket}");
|
|
let config = minio_config(bucket.clone());
|
|
mc_alias(&config.s3_access_key, &config.s3_secret_key).expect("configure mc alias");
|
|
run_mc(&[
|
|
"mb".to_string(),
|
|
"--ignore-existing".to_string(),
|
|
bucket_path.clone(),
|
|
])
|
|
.expect("create isolated never-versioned test bucket");
|
|
|
|
let storage = MediaStorage::new(&config).expect("static MinIO storage client");
|
|
let prefix = format!("_test/never-versioned-{}/", uuid::Uuid::new_v4());
|
|
let key = format!("{prefix}plain.bin");
|
|
storage
|
|
.put(&key, b"plain", "application/octet-stream")
|
|
.await
|
|
.expect("put never-versioned object");
|
|
|
|
let listed = list_all_versions(&storage, &prefix, 2).await;
|
|
assert_eq!(
|
|
listed,
|
|
vec![buzz_media::storage::ObjectVersionEntry {
|
|
key: key.clone(),
|
|
version_id: "null".to_string(),
|
|
kind: ObjectVersionKind::Object,
|
|
size: 5,
|
|
}],
|
|
"never-versioned buckets must still enumerate exact null-version objects"
|
|
);
|
|
|
|
let delete = storage
|
|
.delete_object_versions(&refs_from(&listed))
|
|
.await
|
|
.expect("delete exact null-version object");
|
|
assert!(delete.failed.is_empty(), "{delete:?}");
|
|
assert!(delete.versioned_keys.is_empty(), "{delete:?}");
|
|
assert_eq!(
|
|
delete.deleted + delete.already_missing,
|
|
1,
|
|
"exact null-version delete must account for the listed object"
|
|
);
|
|
assert!(
|
|
list_all_versions(&storage, &prefix, 2).await.is_empty(),
|
|
"final ListObjectVersions must be empty after deleting the exact null version"
|
|
);
|
|
|
|
run_mc(&["rb".to_string(), "--force".to_string(), bucket_path.clone()])
|
|
.expect("remove isolated never-versioned test bucket");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "requires live docker-compose MinIO; permanently deletes exact test object versions"]
|
|
async fn versioned_bucket_exact_version_delete_reaches_final_list_versions_emptiness() {
|
|
let bucket = format!("buzz-media-versioned-{}", std::process::id());
|
|
let bucket_path = format!("local/{bucket}");
|
|
let config = minio_config(bucket.clone());
|
|
mc_alias(&config.s3_access_key, &config.s3_secret_key).expect("configure mc alias");
|
|
run_mc(&[
|
|
"mb".to_string(),
|
|
"--ignore-existing".to_string(),
|
|
bucket_path.clone(),
|
|
])
|
|
.expect("create isolated versioned test bucket");
|
|
run_mc(&[
|
|
"version".to_string(),
|
|
"enable".to_string(),
|
|
bucket_path.clone(),
|
|
])
|
|
.expect("enable bucket versioning");
|
|
|
|
let storage = MediaStorage::new(&config).expect("static MinIO storage client");
|
|
let prefix = format!("_test/versioned-{}/", uuid::Uuid::new_v4());
|
|
let historical_key = format!("{prefix}historical.bin");
|
|
let marker_only_key = format!("{prefix}marker-only.bin");
|
|
let paginated_key = format!("{prefix}paginated.bin");
|
|
|
|
storage
|
|
.put(&historical_key, b"v1", "application/octet-stream")
|
|
.await
|
|
.expect("put historical v1");
|
|
storage
|
|
.put(&historical_key, b"v2", "application/octet-stream")
|
|
.await
|
|
.expect("put historical v2");
|
|
storage
|
|
.delete(&historical_key)
|
|
.await
|
|
.expect("delete historical current version creates delete marker");
|
|
storage
|
|
.put(&marker_only_key, b"marker-base", "application/octet-stream")
|
|
.await
|
|
.expect("put marker-only base version");
|
|
storage
|
|
.delete(&marker_only_key)
|
|
.await
|
|
.expect("delete current version creates marker-only delete marker");
|
|
let marker_versions = list_all_versions(&storage, &marker_only_key, 2).await;
|
|
let marker_objects: Vec<ObjectVersionRef> = marker_versions
|
|
.iter()
|
|
.filter(|entry| entry.kind == ObjectVersionKind::Object)
|
|
.map(|entry| ObjectVersionRef {
|
|
key: entry.key.clone(),
|
|
version_id: entry.version_id.clone(),
|
|
})
|
|
.collect();
|
|
assert_eq!(
|
|
marker_objects.len(),
|
|
1,
|
|
"marker-only setup should have one object version: {marker_versions:?}"
|
|
);
|
|
let marker_object_delete = storage
|
|
.delete_object_versions(&marker_objects)
|
|
.await
|
|
.expect("delete marker-only base object version");
|
|
assert!(
|
|
marker_object_delete.failed.is_empty(),
|
|
"{marker_object_delete:?}"
|
|
);
|
|
assert!(
|
|
marker_object_delete.versioned_keys.is_empty(),
|
|
"{marker_object_delete:?}"
|
|
);
|
|
storage
|
|
.put(&paginated_key, b"page-a", "application/octet-stream")
|
|
.await
|
|
.expect("put paginated v1");
|
|
storage
|
|
.put(&paginated_key, b"page-b", "application/octet-stream")
|
|
.await
|
|
.expect("put paginated v2");
|
|
|
|
let listed = list_all_versions(&storage, &prefix, 2).await;
|
|
assert!(
|
|
listed.len() >= 6,
|
|
"expected multiple versions/delete markers across small pages, got {listed:?}"
|
|
);
|
|
assert!(listed.iter().any(|entry| {
|
|
entry.key == historical_key && entry.kind == ObjectVersionKind::Object && entry.size == 2
|
|
}));
|
|
assert!(listed.iter().any(|entry| {
|
|
entry.key == historical_key && entry.kind == ObjectVersionKind::DeleteMarker
|
|
}));
|
|
assert!(listed.iter().any(|entry| {
|
|
entry.key == marker_only_key && entry.kind == ObjectVersionKind::DeleteMarker
|
|
}));
|
|
|
|
let refs = refs_from(&listed);
|
|
let first_chunk = &refs[..2.min(refs.len())];
|
|
let first_delete = storage
|
|
.delete_object_versions(first_chunk)
|
|
.await
|
|
.expect("delete first explicit version chunk");
|
|
assert!(first_delete.failed.is_empty(), "{first_delete:?}");
|
|
assert!(first_delete.versioned_keys.is_empty(), "{first_delete:?}");
|
|
assert_eq!(
|
|
first_delete.deleted + first_delete.already_missing,
|
|
first_chunk.len() as u64,
|
|
"explicit version delete should account for every requested identifier"
|
|
);
|
|
|
|
let retry = storage
|
|
.delete_object_versions(&first_chunk[..1])
|
|
.await
|
|
.expect("retry already-deleted explicit version");
|
|
assert!(retry.failed.is_empty(), "{retry:?}");
|
|
assert!(retry.versioned_keys.is_empty(), "{retry:?}");
|
|
assert_eq!(
|
|
retry.deleted + retry.already_missing,
|
|
1,
|
|
"retry should be idempotently accounted as deleted/already missing"
|
|
);
|
|
|
|
let rest_delete = storage
|
|
.delete_object_versions(&refs[first_chunk.len()..])
|
|
.await
|
|
.expect("delete remaining explicit versions");
|
|
assert!(rest_delete.failed.is_empty(), "{rest_delete:?}");
|
|
assert!(rest_delete.versioned_keys.is_empty(), "{rest_delete:?}");
|
|
assert_eq!(
|
|
rest_delete.deleted + rest_delete.already_missing,
|
|
(refs.len() - first_chunk.len()) as u64,
|
|
"remaining explicit version delete should account for every requested identifier"
|
|
);
|
|
|
|
let remaining = list_all_versions(&storage, &prefix, 2).await;
|
|
assert!(
|
|
remaining.is_empty(),
|
|
"final ListObjectVersions must be empty after exact-version deletion: {remaining:?}"
|
|
);
|
|
|
|
if run_mc(&[
|
|
"version".to_string(),
|
|
"suspend".to_string(),
|
|
bucket_path.clone(),
|
|
])
|
|
.is_ok()
|
|
{
|
|
let suspended_key = format!("{prefix}suspended.bin");
|
|
storage
|
|
.put(&suspended_key, b"suspended", "application/octet-stream")
|
|
.await
|
|
.expect("put suspended-versioning object");
|
|
storage
|
|
.delete(&suspended_key)
|
|
.await
|
|
.expect("delete suspended-versioning object");
|
|
let suspended_entries = list_all_versions(&storage, &prefix, 2).await;
|
|
assert!(
|
|
suspended_entries
|
|
.iter()
|
|
.any(|entry| entry.key == suspended_key),
|
|
"suspended-versioning write/delete should be visible to ListObjectVersions"
|
|
);
|
|
let suspended_delete = storage
|
|
.delete_object_versions(&refs_from(&suspended_entries))
|
|
.await
|
|
.expect("delete suspended-versioning entries by explicit version id");
|
|
assert!(suspended_delete.failed.is_empty(), "{suspended_delete:?}");
|
|
assert!(
|
|
suspended_delete.versioned_keys.is_empty(),
|
|
"{suspended_delete:?}"
|
|
);
|
|
assert!(list_all_versions(&storage, &prefix, 2).await.is_empty());
|
|
} else {
|
|
eprintln!("MinIO mc did not support version suspend; enabled-versioning coverage passed");
|
|
}
|
|
|
|
run_mc(&["rb".to_string(), "--force".to_string(), bucket_path.clone()])
|
|
.expect("remove isolated versioned test bucket");
|
|
}
|