// Copyright 2026 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. //! Shared 4-node distributed e2e helpers. //! //! Two localhost-expressible layouts cover the suite: //! //! * **4×4 single pool** (`four_by_four`) — four processes, four drives each, //! one `DistErasure` pool (16 explicit volume endpoints). This is the //! default S3 / lock / versioning / chaos topology. //! * **4×4 four pool** — start one durable single-node pool, then append three //! single-node pools one at a time. Required for decommission/rebalance/expand, //! which the server rejects on a single pool. Cold-starting multiple empty //! pools races their bootstrap identities on localhost DistErasure. //! //! Genuine multi-node *striped* pools still need multi-host CI (backlog //! #1313 / #1314). Site replication uses two 4-node 1-drive clusters so the //! process count stays at eight rather than sixteen. use crate::common::{ ClusterTopology, FAST_DATA_USAGE_SCANNER_ENV, RustFSTestClusterEnvironment, admin_request, build_test_s3_config, local_http_client, replication_fast_env, signed_request, }; use crate::replication_extension_test::LOOPBACK_REPLICATION_TARGET_ENV; use aws_sdk_s3::Client; use aws_sdk_s3::primitives::ByteStream; use aws_sdk_s3::types::{BucketVersioningStatus, VersioningConfiguration}; use http::{Method, StatusCode}; use sha2::{Digest, Sha256}; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::time::Duration; use tokio::time::{Instant, sleep}; use uuid::Uuid; pub(crate) type TestResult = Result>; pub(crate) const NODE_COUNT: usize = 4; pub(crate) const DRIVES_PER_NODE: usize = 4; /// Retire the seed pool after test data is written there, proving that user /// objects—not only internal metadata—move to the expansion pools. pub(crate) const DECOMMISSION_POOL_ID: usize = 0; const POOL_ROOTS_ENV: &str = "RUSTFS_E2E_POOL_ROOTS"; const POOL_META_V3_ENV: [(&str, &str); 2] = [ ("RUSTFS_POOL_META_V3_WRITE", "true"), ("RUSTFS_POOL_META_V3_FLEET_CONFIRMED", "true"), ]; #[derive(Clone, Copy, Debug)] pub(crate) enum DistLayout { /// 4 nodes × 4 drives, one erasure pool spanning every endpoint. FourByFour, /// 4 nodes × 1 drive, one erasure pool (minimum 4-node 4-disk layout). FourNodeFourDisk, /// 1 node × 4 drives, used only as the durable seed for pool expansion. SingleNodeFourDrive, } pub(crate) struct DistCluster { pub cluster: RustFSTestClusterEnvironment, pool_storage_roots: Option>, owned_pool_dirs: Vec, } impl DistCluster { pub async fn start(layout: DistLayout) -> TestResult { Self::start_with_env(layout, &[]).await } pub async fn start_with_env(layout: DistLayout, extra_env: &[(&str, &str)]) -> TestResult { let mut dist = Self::new_stopped_with_env(layout, extra_env).await?; dist.cluster.start().await?; Ok(dist) } /// Allocate ports and data dirs without spawning processes. /// /// Upgrade tests configure capture logs, then start a pinned previous /// binary against the same directories. pub async fn new_stopped(layout: DistLayout) -> TestResult { Self::new_stopped_with_env(layout, &[]).await } pub async fn new_stopped_with_env(layout: DistLayout, extra_env: &[(&str, &str)]) -> TestResult { let topology = match layout { DistLayout::FourByFour => ClusterTopology::single_pool_multidrive(NODE_COUNT, DRIVES_PER_NODE), DistLayout::FourNodeFourDisk => ClusterTopology::single_pool(NODE_COUNT), DistLayout::SingleNodeFourDrive => ClusterTopology::per_node_pools(DRIVES_PER_NODE, vec![vec![0]]), }; let mut cluster = RustFSTestClusterEnvironment::with_topology(topology).await?; let pool_storage_roots = match layout { DistLayout::SingleNodeFourDrive => Some(configured_pool_storage_roots()?), DistLayout::FourByFour | DistLayout::FourNodeFourDisk => None, }; let mut owned_pool_dirs = Vec::new(); if let Some(roots) = pool_storage_roots.as_deref() { owned_pool_dirs.push(relocate_pool_storage(&mut cluster, 0, roots)?); } cluster.set_env("NO_PROXY", "127.0.0.1,localhost"); cluster.set_env("HTTP_PROXY", ""); cluster.set_env("HTTPS_PROXY", ""); if matches!(layout, DistLayout::SingleNodeFourDrive) { // This fresh, same-version fleet is safe to initialize at V3. The // durable generation protocol is required for concurrent // decommission progress updates and crash-recoverable movement. for (key, value) in POOL_META_V3_ENV { cluster.set_env(key, value); } } for &(key, value) in extra_env { cluster.set_env(key, value); } configure_node_logs(&mut cluster)?; Ok(Self { cluster, pool_storage_roots, owned_pool_dirs, }) } /// Start every node with a specific `rustfs` binary, keeping the allocated /// data directories. Used to seed an old on-disk format before upgrading. pub async fn start_from_binary(&mut self, binary: &Path) -> TestResult { self.cluster.start_with_binary(binary).await?; wait_for_ready(&self.cluster).await?; Ok(()) } /// Stop every node and bring the same data directories up on the workspace /// binary (direct upgrade). pub async fn restart_with_current_binary(&mut self) -> TestResult { self.cluster.stop(); self.cluster.start().await?; wait_for_ready(&self.cluster).await?; Ok(()) } /// Planned topology changes use graceful shutdown. Crash semantics are /// exercised separately by the chaos cases. pub async fn restart_current_binary_gracefully(&mut self) -> TestResult { self.stop_all_gracefully().await?; self.cluster.start().await?; wait_for_ready(&self.cluster).await?; Ok(()) } /// Replace one running node with the workspace binary (rolling upgrade). pub async fn replace_node_with_current_binary(&mut self, node_idx: usize) -> TestResult { self.cluster.stop_node(node_idx)?; self.cluster.start_node(node_idx).await?; wait_for_ready(&self.cluster).await?; Ok(()) } pub fn client_with_credentials(&self, node_idx: usize, access_key: &str, secret_key: &str) -> TestResult { if node_idx >= self.cluster.nodes.len() { return Err("node_idx is invalid".into()); } Ok(Client::from_conf(build_test_s3_config( &self.cluster.nodes[node_idx].url, access_key, secret_key, None, "cluster-iam-test", ))) } pub async fn append_pool_and_restart(&mut self) -> TestResult { self.stop_all_gracefully().await?; let node_idx = self.cluster.append_single_node_pool().await?; let roots = self .pool_storage_roots .as_deref() .ok_or("pool expansion requires configured isolated pool filesystems")?; self.owned_pool_dirs .push(relocate_pool_storage(&mut self.cluster, node_idx, roots)?); configure_node_logs(&mut self.cluster)?; self.cluster.start().await?; wait_for_ready(&self.cluster).await?; Ok(node_idx) } async fn stop_all_gracefully(&mut self) -> TestResult { for node_idx in 0..self.cluster.nodes.len() { self.cluster.stop_node_gracefully(node_idx).await?; } Ok(()) } /// Expand the durable seed into four single-node pools through three /// serialized additions. Callers can seed objects before this step so a /// later pool-0 decommission proves that user data crosses pool boundaries. pub async fn expand_to_four_pools(&mut self) -> TestResult { for _ in 0..3 { self.append_pool_and_restart().await?; } self.restart_current_binary_gracefully().await?; Ok(()) } pub async fn start_replication_pair() -> TestResult<(Self, Self)> { let mut extra: Vec<(&str, &str)> = replication_fast_env(); extra.extend_from_slice(LOOPBACK_REPLICATION_TARGET_ENV); extra.extend_from_slice(FAST_DATA_USAGE_SCANNER_ENV); let source = Self::start_with_env(DistLayout::FourNodeFourDisk, &extra).await?; let target = Self::start_with_env(DistLayout::FourNodeFourDisk, &extra).await?; Ok((source, target)) } pub fn client(&self, node_idx: usize) -> TestResult { self.cluster.create_s3_client(node_idx) } pub fn clients(&self) -> TestResult> { self.cluster.create_all_clients() } pub async fn create_bucket(&self, bucket: &str) -> TestResult { self.cluster.create_test_bucket(bucket).await } } impl Drop for DistCluster { fn drop(&mut self) { self.cluster.stop(); for path in &self.owned_pool_dirs { if let Err(error) = std::fs::remove_dir_all(path) { eprintln!("failed to clean up isolated distributed E2E pool directory {}: {error}", path.display()); } } } } fn configured_pool_storage_roots() -> TestResult> { let raw = std::env::var_os(POOL_ROOTS_ENV).ok_or_else(|| { format!( "{POOL_ROOTS_ENV} must name {NODE_COUNT} isolated filesystems for pool expansion, decommission, and rebalance tests" ) })?; validate_pool_storage_roots(&raw) } fn validate_pool_storage_roots(raw: &std::ffi::OsStr) -> TestResult> { let roots: Vec = std::env::split_paths(raw).collect(); if roots.len() != NODE_COUNT { return Err(format!("{POOL_ROOTS_ENV} must contain exactly {NODE_COUNT} paths, got {}", roots.len()).into()); } let mut canonical_roots = Vec::with_capacity(roots.len()); for root in roots { if !root.is_absolute() { return Err(format!("{POOL_ROOTS_ENV} path must be absolute: {}", root.display()).into()); } let canonical = root .canonicalize() .map_err(|error| format!("{POOL_ROOTS_ENV} path {} is unavailable: {error}", root.display()))?; if !canonical.is_dir() { return Err(format!("{POOL_ROOTS_ENV} path is not a directory: {}", canonical.display()).into()); } if canonical_roots.contains(&canonical) { return Err(format!("{POOL_ROOTS_ENV} contains a duplicate path: {}", canonical.display()).into()); } canonical_roots.push(canonical); } #[cfg(unix)] { use std::os::unix::fs::MetadataExt; let devices: std::collections::BTreeSet = canonical_roots .iter() .map(|root| std::fs::metadata(root).map(|metadata| metadata.dev())) .collect::>()?; if devices.len() != canonical_roots.len() { return Err(format!( "{POOL_ROOTS_ENV} paths must be backed by distinct filesystems; found {} devices for {} paths", devices.len(), canonical_roots.len() ) .into()); } } Ok(canonical_roots) } fn relocate_pool_storage(cluster: &mut RustFSTestClusterEnvironment, node_idx: usize, roots: &[PathBuf]) -> TestResult { let root = roots .get(node_idx) .ok_or_else(|| format!("no isolated pool filesystem configured for node {node_idx}"))?; let cluster_name = Path::new(&cluster.temp_dir) .file_name() .ok_or("cluster temp directory omitted a basename")?; let pool_run_root = root.join(cluster_name); let node_root = pool_run_root.join(format!("node{node_idx}")); let data_dirs: Vec = (0..cluster.topology.drives_per_node) .map(|drive| node_root.join(format!("drive{drive}")).to_string_lossy().into_owned()) .collect(); for data_dir in &data_dirs { std::fs::create_dir_all(data_dir)?; } let node = cluster .nodes .get_mut(node_idx) .ok_or_else(|| format!("cannot relocate missing cluster node {node_idx}"))?; node.data_dir = data_dirs[0].clone(); node.data_dirs = data_dirs; Ok(pool_run_root) } fn configure_node_logs(cluster: &mut RustFSTestClusterEnvironment) -> TestResult { let Some(log_dir) = std::env::var_os("RUSTFS_E2E_LOG_DIR") else { return Ok(()); }; std::fs::create_dir_all(&log_dir)?; let cluster_id = Uuid::new_v4().simple().to_string(); for node_idx in 0..cluster.nodes.len() { let path = Path::new(&log_dir).join(format!("cluster-{cluster_id}-node-{node_idx}.log")); cluster.set_node_capture_log_path(node_idx, path.to_string_lossy().into_owned())?; } Ok(()) } pub(crate) fn unique_bucket(prefix: &str) -> String { let id = Uuid::new_v4().simple().to_string(); format!("{prefix}-{}", &id[..12]) } pub(crate) fn sha256_hex(bytes: &[u8]) -> String { let digest = Sha256::digest(bytes); digest.iter().map(|byte| format!("{byte:02x}")).collect() } pub(crate) fn payload_for(key: &str, size: usize) -> Vec { let seed = key.as_bytes(); (0..size) .map(|idx| seed.get(idx % seed.len()).copied().unwrap_or(0) ^ (idx as u8)) .collect() } pub(crate) async fn put_object(client: &Client, bucket: &str, key: &str, body: Vec) -> TestResult { client .put_object() .bucket(bucket) .key(key) .body(ByteStream::from(body)) .send() .await?; Ok(()) } pub(crate) async fn get_object_bytes(client: &Client, bucket: &str, key: &str) -> TestResult> { let output = client.get_object().bucket(bucket).key(key).send().await?; Ok(output.body.collect().await?.into_bytes().to_vec()) } pub(crate) async fn assert_object_bytes(client: &Client, bucket: &str, key: &str, expected: &[u8]) -> TestResult { let got = get_object_bytes(client, bucket, key).await?; if got.as_slice() != expected { return Err(format!( "object {bucket}/{key} bytes mismatch: expected {} bytes sha256={} got {} bytes sha256={}", expected.len(), sha256_hex(expected), got.len(), sha256_hex(&got) ) .into()); } Ok(()) } pub(crate) async fn put_inventory( client: &Client, bucket: &str, count: usize, size: usize, ) -> TestResult>> { let mut inventory = BTreeMap::new(); for idx in 0..count { let key = format!("obj-{idx:04}"); let body = payload_for(&key, size); put_object(client, bucket, &key, body.clone()).await?; inventory.insert(key, body); } Ok(inventory) } /// Retry only transport-level service availability failures while a data /// movement operation changes the pool map. Generic InternalError responses /// remain fatal because accepting them would hide server defects. pub(crate) async fn put_inventory_retrying( client: &Client, bucket: &str, count: usize, size: usize, timeout: Duration, ) -> TestResult>> { let mut inventory = BTreeMap::new(); for idx in 0..count { let key = format!("obj-{idx:04}"); let body = payload_for(&key, size); retrying_put(client, bucket, &key, body.clone(), timeout).await?; inventory.insert(key, body); } Ok(inventory) } pub(crate) async fn assert_inventory(client: &Client, bucket: &str, inventory: &BTreeMap>) -> TestResult { for (key, expected) in inventory { assert_object_bytes(client, bucket, key, expected).await?; } Ok(()) } pub(crate) async fn enable_versioning(client: &Client, bucket: &str) -> TestResult { client .put_bucket_versioning() .bucket(bucket) .versioning_configuration( VersioningConfiguration::builder() .status(BucketVersioningStatus::Enabled) .build(), ) .send() .await?; Ok(()) } pub(crate) async fn wait_until(timeout: Duration, mut probe: F, label: &str) -> TestResult where F: FnMut() -> Fut, Fut: std::future::Future>, { let deadline = Instant::now() + timeout; let mut delay = Duration::from_millis(50); loop { let last_error = match probe().await { Ok(true) => return Ok(()), Ok(false) => format!("{label} still false"), Err(error) => error.to_string(), }; if Instant::now() >= deadline { return Err(format!("{label} did not become true within {timeout:?}: {last_error}").into()); } sleep(delay).await; delay = (delay * 2).min(Duration::from_secs(1)); } } pub(crate) async fn cluster_admin( cluster: &RustFSTestClusterEnvironment, method: Method, path_and_query: &str, body: Option, ) -> TestResult<(StatusCode, String)> { admin_request( &cluster.nodes[0].url, method, path_and_query, body, &cluster.access_key, &cluster.secret_key, ) .await } pub(crate) async fn cluster_admin_ok( cluster: &RustFSTestClusterEnvironment, method: Method, path_and_query: &str, body: Option, ) -> TestResult { let (status, response) = cluster_admin(cluster, method.clone(), path_and_query, body).await?; if !status.is_success() { return Err(format!("{method} {path_and_query} failed: {status} {response}").into()); } Ok(response) } pub(crate) async fn wait_for_ready(cluster: &RustFSTestClusterEnvironment) -> TestResult { let client = local_http_client(); for node in &cluster.nodes { let url = format!("{}/health/ready", node.url); wait_until( Duration::from_secs(30), || { let client = client.clone(); let url = url.clone(); async move { match client.get(&url).send().await { Ok(response) if response.status().is_success() => Ok(true), _ => Ok(false), } } }, &format!("node {} ready", node.address), ) .await?; } Ok(()) } pub(crate) async fn set_remote_target( source: &RustFSTestClusterEnvironment, source_bucket: &str, target: &RustFSTestClusterEnvironment, target_bucket: &str, ) -> TestResult { let body = serde_json::json!({ "endpoint": target.nodes[0].address, "credentials": { "accessKey": target.access_key, "secretKey": target.secret_key }, "targetbucket": target_bucket, "secure": false, "type": "replication" }); let url = format!( "{}/rustfs/admin/v3/set-remote-target?bucket={}", source.nodes[0].url, urlencoding::encode(source_bucket) ); let response = signed_request( Method::PUT, &url, &source.access_key, &source.secret_key, Some(body.to_string().into_bytes()), Some("application/json"), ) .await?; if response.status() != StatusCode::OK { let status = response.status(); let body = response.text().await.unwrap_or_default(); return Err(format!("set remote target failed: {status} {body}").into()); } Ok(serde_json::from_slice(&response.bytes().await?)?) } pub(crate) async fn put_bucket_replication(source: &RustFSTestClusterEnvironment, bucket: &str, target_arn: &str) -> TestResult { let body = format!( r#" rule-1 1 Enabled Enabled Enabled {target_arn} "# ); let url = format!("{}/{bucket}?replication", source.nodes[0].url); let response = signed_request( Method::PUT, &url, &source.access_key, &source.secret_key, Some(body.into_bytes()), Some("application/xml"), ) .await?; if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); return Err(format!("put bucket replication failed: {status} {body}").into()); } Ok(()) } pub(crate) async fn wait_for_replicated_bytes( client: &Client, bucket: &str, key: &str, expected: &[u8], timeout: Duration, ) -> TestResult { wait_until( timeout, || async { match get_object_bytes(client, bucket, key).await { Ok(got) if got.as_slice() == expected => Ok(true), Ok(_) => Ok(false), Err(error) => { let message = error.to_string(); if message.contains("NoSuchKey") || message.contains("NotFound") { Ok(false) } else { Err(error) } } } }, &format!("replicated object {bucket}/{key}"), ) .await } pub(crate) async fn set_bucket_quota(cluster: &RustFSTestClusterEnvironment, bucket: &str, quota_bytes: u64) -> TestResult { wait_until( Duration::from_secs(30), || async { let (status, _) = cluster_admin(cluster, Method::GET, &format!("/rustfs/admin/v3/quota-stats/{bucket}"), None).await?; Ok(status.is_success() || status == StatusCode::NOT_FOUND) }, "quota stats ready", ) .await?; let body = serde_json::json!({ "quota": quota_bytes, "quota_type": "HARD" }).to_string(); wait_until( Duration::from_secs(30), || async { let (status, response) = cluster_admin(cluster, Method::PUT, &format!("/rustfs/admin/v3/quota/{bucket}"), Some(body.clone())).await?; if status.is_success() { return Ok(true); } if status == StatusCode::SERVICE_UNAVAILABLE { return Ok(false); } Err(format!("failed to set quota for {bucket}: {status} {response}").into()) }, "set hard quota", ) .await } /// Start decommission and fail closed unless the admin API acknowledges it. pub(crate) async fn start_decommission(cluster: &RustFSTestClusterEnvironment, pool_id: usize) -> TestResult { let path = format!("/rustfs/admin/v3/pools/decommission?pool={pool_id}&by-id=true"); let deadline = Instant::now() + Duration::from_secs(45); loop { let (status, response) = cluster_admin(cluster, Method::POST, &path, None).await?; if status.is_success() { return Ok(()); } if status == StatusCode::INTERNAL_SERVER_ERROR && response.contains("requires a live fleet capability proof") && Instant::now() < deadline { sleep(Duration::from_secs(1)).await; continue; } return Err(format!("POST {path} did not start decommission: {status} {response}").into()); } } pub(crate) async fn decommission_status_json(cluster: &RustFSTestClusterEnvironment) -> TestResult { let body = cluster_admin_ok(cluster, Method::GET, "/rustfs/admin/v3/decommission/status", None).await?; Ok(serde_json::from_str(&body)?) } fn pool_entry(status: &serde_json::Value, pool_id: usize) -> Option<&serde_json::Value> { if let Some(pools) = status.get("pools").and_then(serde_json::Value::as_array) { return pools .iter() .find(|pool| pool.get("id").and_then(serde_json::Value::as_u64) == Some(pool_id as u64)); } if status.get("id").and_then(serde_json::Value::as_u64) == Some(pool_id as u64) { Some(status) } else { None } } fn nonzero_u64(value: Option<&serde_json::Value>) -> bool { value.and_then(serde_json::Value::as_u64).is_some_and(|count| count > 0) } fn decommission_failure(pool: &serde_json::Value) -> Option { let info = pool.get("decommissionInfo"); let flagged = |key: &str| info.and_then(|value| value.get(key)).and_then(serde_json::Value::as_bool) == Some(true); let terminal_flag = flagged("failed") || flagged("canceled") || pool .get("status") .and_then(serde_json::Value::as_str) .is_some_and(|status| status.eq_ignore_ascii_case("failed") || status.eq_ignore_ascii_case("canceled")); let object_failures = nonzero_u64(info.and_then(|value| value.get("objectsDecommissionedFailed"))); let byte_failures = nonzero_u64(info.and_then(|value| value.get("bytesDecommissionedFailed"))); let unresolved = info .and_then(|value| value.get("unresolvedEntries")) .and_then(serde_json::Value::as_array) .is_some_and(|entries| !entries.is_empty()); terminal_flag .then(|| "decommission reported failed or canceled".to_string()) .or_else(|| object_failures.then(|| "decommission reported failed objects".to_string())) .or_else(|| byte_failures.then(|| "decommission reported failed bytes".to_string())) .or_else(|| unresolved.then(|| "decommission reported unresolved entries".to_string())) } pub(crate) fn decommission_active(status: &serde_json::Value, pool_id: usize) -> TestResult { let pool = pool_entry(status, pool_id).ok_or_else(|| format!("pool {pool_id} missing from decommission status: {status}"))?; if let Some(reason) = decommission_failure(pool) { return Err(format!("{reason}: {pool}").into()); } let info = pool .get("decommissionInfo") .ok_or_else(|| format!("pool {pool_id} has no decommissionInfo: {pool}"))?; let status_text = pool.get("status").and_then(serde_json::Value::as_str).unwrap_or(""); let pool_status = pool.get("poolStatus").and_then(serde_json::Value::as_str).unwrap_or(""); let queued = info.get("queued").and_then(serde_json::Value::as_bool) == Some(true); Ok(queued || status_text.eq_ignore_ascii_case("running") || pool_status.eq_ignore_ascii_case("decommissioning")) } pub(crate) fn decommission_running_with_progress(status: &serde_json::Value, pool_id: usize) -> TestResult { let pool = pool_entry(status, pool_id).ok_or_else(|| format!("pool {pool_id} missing from decommission status: {status}"))?; if let Some(reason) = decommission_failure(pool) { return Err(format!("{reason}: {pool}").into()); } let info = pool .get("decommissionInfo") .ok_or_else(|| format!("pool {pool_id} has no decommissionInfo: {pool}"))?; let status_text = pool.get("status").and_then(serde_json::Value::as_str).unwrap_or(""); let pool_status = pool.get("poolStatus").and_then(serde_json::Value::as_str).unwrap_or(""); let running = status_text.eq_ignore_ascii_case("running") || pool_status.eq_ignore_ascii_case("decommissioning"); let progressed = nonzero_u64(info.get("objectsDecommissioned")) || nonzero_u64(info.get("bytesDecommissioned")); Ok(running && progressed) } pub(crate) fn decommission_complete(status: &serde_json::Value, pool_id: usize) -> TestResult { let pool = pool_entry(status, pool_id).ok_or_else(|| format!("pool {pool_id} missing from decommission status: {status}"))?; if let Some(reason) = decommission_failure(pool) { return Err(format!("{reason}: {pool}").into()); } let info = pool .get("decommissionInfo") .ok_or_else(|| format!("pool {pool_id} has no decommissionInfo: {pool}"))?; let complete = info.get("complete").and_then(serde_json::Value::as_bool) == Some(true); let status_text = pool.get("status").and_then(serde_json::Value::as_str).unwrap_or(""); let pool_status = pool.get("poolStatus").and_then(serde_json::Value::as_str).unwrap_or(""); let terminal = status_text.eq_ignore_ascii_case("complete") && pool_status.eq_ignore_ascii_case("decommissioned"); let moved_data = nonzero_u64(info.get("objectsDecommissioned")) && nonzero_u64(info.get("bytesDecommissioned")); Ok(complete && terminal && moved_data) } pub(crate) async fn wait_for_decommission_active( cluster: &RustFSTestClusterEnvironment, pool_id: usize, timeout: Duration, ) -> TestResult { wait_for_decommission_state(cluster, pool_id, timeout, "active", decommission_active).await } pub(crate) async fn wait_for_decommission_running_with_progress( cluster: &RustFSTestClusterEnvironment, pool_id: usize, timeout: Duration, ) -> TestResult { let deadline = Instant::now() + timeout; loop { let status = decommission_status_json(cluster).await?; if decommission_running_with_progress(&status, pool_id)? { return Ok(()); } if Instant::now() >= deadline { return Err(format!( "decommission did not become active with non-zero progress within {timeout:?}; last status: {status}" ) .into()); } sleep(Duration::from_millis(100)).await; } } pub(crate) async fn wait_for_decommission_complete( cluster: &RustFSTestClusterEnvironment, pool_id: usize, timeout: Duration, ) -> TestResult { wait_for_decommission_state(cluster, pool_id, timeout, "complete with non-zero progress", decommission_complete).await } async fn wait_for_decommission_state( cluster: &RustFSTestClusterEnvironment, pool_id: usize, timeout: Duration, expected: &str, predicate: fn(&serde_json::Value, usize) -> TestResult, ) -> TestResult { let deadline = Instant::now() + timeout; loop { let status = decommission_status_json(cluster).await?; if predicate(&status, pool_id)? { return Ok(()); } if Instant::now() >= deadline { return Err(format!("decommission did not become {expected} within {timeout:?}; last status: {status}").into()); } sleep(Duration::from_secs(1)).await; } } pub(crate) async fn start_rebalance(cluster: &RustFSTestClusterEnvironment) -> TestResult { let path = "/rustfs/admin/v3/rebalance/start"; let deadline = Instant::now() + Duration::from_secs(45); let response = loop { let (status, response) = cluster_admin(cluster, Method::POST, path, None).await?; if status.is_success() { break response; } if status == StatusCode::INTERNAL_SERVER_ERROR && response.contains("requires a live fleet capability proof") && Instant::now() < deadline { sleep(Duration::from_secs(1)).await; continue; } return Err(format!("POST {path} did not start rebalance: {status} {response}").into()); }; let parsed: serde_json::Value = serde_json::from_str(&response)?; parsed .get("id") .and_then(serde_json::Value::as_str) .filter(|id| !id.is_empty()) .map(str::to_string) .ok_or_else(|| format!("rebalance start response omitted id: {response}").into()) } pub(crate) async fn rebalance_status_json(cluster: &RustFSTestClusterEnvironment) -> TestResult { let body = cluster_admin_ok(cluster, Method::GET, "/rustfs/admin/v3/rebalance/status", None).await?; Ok(serde_json::from_str(&body)?) } fn validate_rebalance_status<'a>(status: &'a serde_json::Value, expected_id: &str) -> TestResult<&'a [serde_json::Value]> { let id = status .get("id") .and_then(serde_json::Value::as_str) .ok_or("rebalance status omitted id")?; if id != expected_id { return Err(format!("rebalance status id changed: expected {expected_id}, got {id}").into()); } let pools = status .get("pools") .and_then(serde_json::Value::as_array) .filter(|pools| !pools.is_empty()) .ok_or("rebalance status omitted non-empty pools")?; for pool in pools { if pool.get("status").and_then(serde_json::Value::as_str).is_some_and(|status| { ["failed", "stopped", "canceled", "cancelled"] .iter() .any(|terminal| status.eq_ignore_ascii_case(terminal)) }) { return Err(format!("rebalance entered an unsuccessful terminal state: {status}").into()); } if pool.get("stopping").and_then(serde_json::Value::as_bool) == Some(true) { return Err(format!("rebalance entered stopping state: {status}").into()); } if pool .get("lastError") .and_then(serde_json::Value::as_str) .is_some_and(|error| !error.is_empty()) { return Err(format!("rebalance reported lastError: {status}").into()); } if nonzero_u64(pool.get("cleanupWarnings").and_then(|warnings| warnings.get("count"))) { return Err(format!("rebalance reported cleanup warnings: {status}").into()); } } let propagation_failed = status.get("stopPropagation").is_some_and(|propagation| { propagation .get("failedPeers") .and_then(serde_json::Value::as_array) .is_some_and(|peers| !peers.is_empty()) || propagation .get("terminalReloadFailedPeers") .and_then(serde_json::Value::as_array) .is_some_and(|peers| !peers.is_empty()) || propagation.get("pendingTerminalReload").and_then(serde_json::Value::as_bool) == Some(true) }); if propagation_failed { return Err(format!("rebalance stop propagation is incomplete: {status}").into()); } Ok(pools) } pub(crate) fn rebalance_active(status: &serde_json::Value, expected_id: &str) -> TestResult { Ok(validate_rebalance_status(status, expected_id)?.iter().any(|pool| { pool.get("status") .and_then(serde_json::Value::as_str) .is_some_and(|value| value.eq_ignore_ascii_case("started")) })) } pub(crate) fn rebalance_running_with_progress(status: &serde_json::Value, expected_id: &str) -> TestResult { Ok(validate_rebalance_status(status, expected_id)?.iter().any(|pool| { let started = pool .get("status") .and_then(serde_json::Value::as_str) .is_some_and(|value| value.eq_ignore_ascii_case("started")); let progress = pool.get("progress"); started && (nonzero_u64(progress.and_then(|value| value.get("objects"))) || nonzero_u64(progress.and_then(|value| value.get("versions"))) || nonzero_u64(progress.and_then(|value| value.get("bytes")))) })) } pub(crate) fn rebalance_complete(status: &serde_json::Value, expected_id: &str) -> TestResult { let pools = validate_rebalance_status(status, expected_id)?; let completed: Vec<&serde_json::Value> = pools .iter() .filter(|pool| { pool.get("status") .and_then(serde_json::Value::as_str) .is_some_and(|value| value.eq_ignore_ascii_case("completed")) }) .collect(); let all_terminal = pools.iter().all(|pool| { pool.get("status") .and_then(serde_json::Value::as_str) .is_some_and(|value| value.eq_ignore_ascii_case("completed") || value.eq_ignore_ascii_case("none")) }); let any_progress = completed.iter().any(|pool| { let progress = pool.get("progress"); nonzero_u64(progress.and_then(|value| value.get("objects"))) || nonzero_u64(progress.and_then(|value| value.get("versions"))) || nonzero_u64(progress.and_then(|value| value.get("bytes"))) }); Ok(all_terminal && !completed.is_empty() && any_progress) } pub(crate) async fn wait_for_rebalance_active( cluster: &RustFSTestClusterEnvironment, expected_id: &str, timeout: Duration, ) -> TestResult { let deadline = Instant::now() + timeout; loop { let status = rebalance_status_json(cluster).await?; if rebalance_active(&status, expected_id)? { return Ok(()); } if Instant::now() >= deadline { return Err(format!("rebalance did not become active within {timeout:?}; last status: {status}").into()); } sleep(Duration::from_secs(1)).await; } } pub(crate) async fn wait_for_rebalance_running_with_progress( cluster: &RustFSTestClusterEnvironment, expected_id: &str, timeout: Duration, ) -> TestResult { let deadline = Instant::now() + timeout; loop { let status = rebalance_status_json(cluster).await?; if rebalance_running_with_progress(&status, expected_id)? { return Ok(()); } if Instant::now() >= deadline { return Err(format!( "rebalance did not become active with non-zero progress within {timeout:?}; last status: {status}" ) .into()); } sleep(Duration::from_millis(100)).await; } } pub(crate) async fn wait_for_rebalance_complete( cluster: &RustFSTestClusterEnvironment, expected_id: &str, timeout: Duration, ) -> TestResult { let deadline = Instant::now() + timeout; loop { let status = rebalance_status_json(cluster).await?; if rebalance_complete(&status, expected_id)? { return Ok(()); } if Instant::now() >= deadline { return Err( format!("rebalance did not complete with non-zero progress within {timeout:?}; last status: {status}").into(), ); } sleep(Duration::from_secs(1)).await; } } pub(crate) async fn list_pools_json(cluster: &RustFSTestClusterEnvironment) -> TestResult { let body = cluster_admin_ok(cluster, Method::GET, "/rustfs/admin/v3/pools/list", None).await?; Ok(serde_json::from_str(&body)?) } pub(crate) async fn retrying_put(client: &Client, bucket: &str, key: &str, body: Vec, timeout: Duration) -> TestResult { wait_until( timeout, || { let client = client.clone(); let bucket = bucket.to_string(); let key = key.to_string(); let body = body.clone(); async move { match put_object(&client, &bucket, &key, body).await { Ok(()) => Ok(true), Err(error) => { let message = error.to_string(); if message.contains("SlowDown") || message.contains("ServiceUnavailable") || message.contains("503") { Ok(false) } else { Err(error) } } } } }, &format!("put {bucket}/{key} during data movement"), ) .await } pub(crate) async fn retrying_get_equals( client: &Client, bucket: &str, key: &str, expected: &[u8], timeout: Duration, ) -> TestResult { wait_until( timeout, || async { match get_object_bytes(client, bucket, key).await { Ok(got) if got.as_slice() == expected => Ok(true), Ok(_) => Ok(false), Err(error) => { let message = error.to_string(); if message.contains("NoSuchKey") || message.contains("SlowDown") || message.contains("ServiceUnavailable") || message.contains("503") { Ok(false) } else { Err(error) } } } }, &format!("get {bucket}/{key} during data movement"), ) .await } #[tokio::test] async fn append_single_node_pool_extends_ellipses_volumes() { let mut env = RustFSTestClusterEnvironment::with_topology(ClusterTopology::per_node_pools(2, vec![vec![0], vec![1]])) .await .expect("two-pool seed topology"); assert_eq!(env.rustfs_volumes_arg().split(' ').count(), 2); let added = env.append_single_node_pool().await.expect("append third pool"); assert_eq!(added, 2); assert_eq!(env.nodes.len(), 3); assert_eq!(env.nodes[2].pool_idx, 2); assert_eq!(env.nodes[2].data_dirs.len(), 2); let volumes = env.rustfs_volumes_arg(); assert_eq!(volumes.split(' ').count(), 3, "expected three pool arguments, got: {volumes}"); assert!(volumes.contains("/drive{0...1}"), "expanded layout must keep drive ellipses: {volumes}"); } #[tokio::test] async fn append_single_node_pool_rejects_striped_single_pool() { let mut env = RustFSTestClusterEnvironment::new(4).await.expect("four-node single pool"); let err = env .append_single_node_pool() .await .expect_err("a striped single pool cannot gain a localhost pool"); let message = err.to_string(); assert!( message.contains("drives_per_node") || message.contains("one node per pool"), "unexpected error: {message}" ); } #[test] fn pool_storage_roots_require_exactly_four_unique_paths() { let temp_root = std::env::temp_dir(); let too_few = std::env::join_paths([temp_root.as_path()]).expect("join one path"); let error = validate_pool_storage_roots(&too_few).expect_err("one pool root must be rejected"); assert!(error.to_string().contains("exactly 4 paths")); let duplicates = std::env::join_paths([ temp_root.as_path(), temp_root.as_path(), temp_root.as_path(), temp_root.as_path(), ]) .expect("join duplicate paths"); let error = validate_pool_storage_roots(&duplicates).expect_err("duplicate pool roots must be rejected"); assert!(error.to_string().contains("duplicate path")); } #[cfg(unix)] #[tokio::test] async fn pool_storage_roots_reject_distinct_paths_on_the_same_device() { let env = RustFSTestClusterEnvironment::new(NODE_COUNT) .await .expect("create same-device pool root fixture"); let roots: Vec = env.nodes.iter().map(|node| PathBuf::from(&node.data_dir)).collect(); let joined = std::env::join_paths(&roots).expect("join same-device paths"); let error = validate_pool_storage_roots(&joined).expect_err("same-device pool roots must be rejected"); assert!(error.to_string().contains("distinct filesystems"), "unexpected error: {error}"); } #[test] fn decommission_complete_requires_terminal_status_and_clean_counters() { let status = serde_json::json!({ "pools": [ { "id": 0, "status": "complete", "poolStatus": "decommissioned", "decommissionInfo": { "complete": true, "failed": false, "canceled": false, "objectsDecommissioned": 1, "bytesDecommissioned": 1024 } }, { "id": 1, "status": "none", "poolStatus": "active" } ] }); assert!(decommission_complete(&status, 0).expect("complete fixture should be accepted")); assert!(decommission_complete(&status, 1).is_err()); for missing_counter in ["objectsDecommissioned", "bytesDecommissioned"] { let mut one_sided = status.clone(); one_sided["pools"][0]["decommissionInfo"][missing_counter] = serde_json::json!(0); assert!( !decommission_complete(&one_sided, 0).expect("one-sided progress fixture should be readable"), "completion must require both movement counters; zeroed {missing_counter}" ); } } #[test] fn decommission_overlap_requires_running_state_and_progress() { let mut status = serde_json::json!({ "pools": [{ "id": 0, "status": "queued", "poolStatus": "active", "decommissionInfo": { "queued": true, "objectsDecommissioned": 1, "bytesDecommissioned": 1024 } }] }); assert!( !decommission_running_with_progress(&status, 0).expect("queued fixture should be readable"), "queued work is not temporal overlap" ); status["pools"][0]["status"] = serde_json::json!("running"); assert!(decommission_running_with_progress(&status, 0).expect("running fixture should be readable")); status["pools"][0]["decommissionInfo"]["objectsDecommissioned"] = serde_json::json!(0); status["pools"][0]["decommissionInfo"]["bytesDecommissioned"] = serde_json::json!(0); assert!( !decommission_running_with_progress(&status, 0).expect("zero-progress fixture should be readable"), "running state alone does not prove movement started" ); } #[test] fn rebalance_active_treats_started_as_in_progress() { let started = serde_json::json!({ "id": "run-1", "pools": [{ "id": 0, "status": "Started", "stopping": false }] }); let done = serde_json::json!({ "id": "run-1", "pools": [{ "id": 0, "status": "Completed", "stopping": false }] }); assert!(rebalance_active(&started, "run-1").unwrap()); assert!(!rebalance_active(&done, "run-1").unwrap()); } #[test] fn rebalance_overlap_requires_started_state_and_progress() { let mut status = serde_json::json!({ "id": "run-1", "pools": [{ "id": 0, "status": "Started", "stopping": false, "progress": { "objects": 0, "bytes": 0 } }] }); assert!( !rebalance_running_with_progress(&status, "run-1").expect("zero-progress fixture should be readable"), "started state alone does not prove movement" ); status["pools"][0]["progress"]["objects"] = serde_json::json!(1); assert!(rebalance_running_with_progress(&status, "run-1").expect("progress fixture should be readable")); status["pools"][0]["status"] = serde_json::json!("Completed"); assert!( !rebalance_running_with_progress(&status, "run-1").expect("completed fixture should be readable"), "completed movement is not temporal overlap" ); } #[test] fn rebalance_complete_accepts_non_participating_pools_but_requires_progress() { let completed = serde_json::json!({ "id": "run-1", "pools": [ { "id": 0, "status": "Completed", "stopping": false, "progress": { "objects": 2, "bytes": 1024 } }, { "id": 1, "status": "None", "stopping": false, "progress": null }, { "id": 2, "status": "None", "stopping": false, "progress": null }, { "id": 3, "status": "None", "stopping": false, "progress": null } ] }); assert!(rebalance_complete(&completed, "run-1").unwrap()); let no_movement = serde_json::json!({ "id": "run-1", "pools": [ { "id": 0, "status": "None", "stopping": false, "progress": null }, { "id": 1, "status": "None", "stopping": false, "progress": null } ] }); assert!(!rebalance_complete(&no_movement, "run-1").unwrap()); }