test(e2e): observe pool-meta expand refusal without changing production

Two-pool restart and decommission attempts stay on current server
behavior. Appending pools and restarting is asserted to fail closed
on pool-meta recovery. Cluster wait loops fail fast if a node process
exits.

Co-authored-by: RustFS <hello@rustfs.com>
This commit is contained in:
Cursor Agent
2026-09-04 15:48:48 +00:00
parent 7f15ac86c3
commit ff632794a4
8 changed files with 85 additions and 51 deletions
+2 -2
View File
@@ -1,2 +1,2 @@
sha256-linux=3a905602a68459b9f1dc0924b1fcc5b1aade9341999f2254ba986d2fd85ddde9
sha256-darwin=3a905602a68459b9f1dc0924b1fcc5b1aade9341999f2254ba986d2fd85ddde9
sha256-linux=ede716c586b7ea58eafa0908b4ee4b40f29aeaec5bbf8600739208d360ef9c69
sha256-darwin=ede716c586b7ea58eafa0908b4ee4b40f29aeaec5bbf8600739208d360ef9c69
+20 -5
View File
@@ -1483,8 +1483,9 @@ impl RustFSTestClusterEnvironment {
self.spawn_node(node_idx, binary_path, &volumes_arg)?;
}
for (i, node) in self.nodes.iter().enumerate() {
self.wait_for_node_ready(&node.address, i).await?;
for i in 0..self.nodes.len() {
let address = self.nodes[i].address.clone();
self.wait_for_node_ready(&address, i).await?;
}
for node_idx in 0..self.nodes.len() {
@@ -1510,7 +1511,8 @@ impl RustFSTestClusterEnvironment {
let volumes_arg = self.build_volumes_arg();
self.spawn_node(node_idx, binary_path, &volumes_arg)?;
self.wait_for_node_ready(&self.nodes[node_idx].address, node_idx).await?;
let address = self.nodes[node_idx].address.clone();
self.wait_for_node_ready(&address, node_idx).await?;
self.wait_for_node_service_ready(node_idx).await?;
Ok(())
}
@@ -1559,8 +1561,18 @@ impl RustFSTestClusterEnvironment {
///
/// Attempts to establish a TCP connection to the node's address, retries up to 60 times
/// with a 1-second interval between attempts. Fails if the port is unreachable after all retries.
async fn wait_for_node_ready(&self, address: &str, idx: usize) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
fn node_process_exited(&mut self, idx: usize) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
let Some(process) = self.nodes.get_mut(idx).and_then(|node| node.process.as_mut()) else {
return Ok(true);
};
Ok(process.try_wait()?.is_some())
}
async fn wait_for_node_ready(&mut self, address: &str, idx: usize) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
for attempt in 0..60 {
if self.node_process_exited(idx)? {
return Err(format!("cluster node {idx} process exited before TCP ready").into());
}
if TcpStream::connect(address).await.is_ok() {
info!("Node {} ({}) TCP ready after {} attempts", idx, address, attempt + 1);
return Ok(());
@@ -1574,10 +1586,13 @@ impl RustFSTestClusterEnvironment {
///
/// Verifies service availability by calling the S3 `list_buckets` API against the requested node,
/// retries up to 120 times with a 1-second interval between attempts.
async fn wait_for_node_service_ready(&self, node_idx: usize) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
async fn wait_for_node_service_ready(&mut self, node_idx: usize) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let client = self.create_s3_client(node_idx)?;
for attempt in 0..120 {
if self.node_process_exited(node_idx)? {
return Err(format!("cluster node {node_idx} process exited before S3 ready").into());
}
match client.list_buckets().send().await {
Ok(_) => {
info!("Cluster node {} service ready after {} attempts", node_idx, attempt + 1);
@@ -13,7 +13,7 @@
// limitations under the License.
use super::harness::{
DistCluster, TestResult, assert_inventory, decommission_started_or_fenced, payload_for, put_inventory_retrying,
DistCluster, DistLayout, TestResult, assert_inventory, decommission_started_or_fenced, payload_for, put_inventory_retrying,
retrying_get_equals, retrying_put, unique_bucket, wait_for_decommission_complete,
};
use crate::common::init_logging;
@@ -24,7 +24,7 @@ use tokio::sync::Barrier;
#[tokio::test]
async fn concurrent_puts_during_decommission_do_not_lose_baseline_or_new_objects() -> TestResult {
init_logging();
let dist = DistCluster::start_four_pool_via_expand().await?;
let dist = DistCluster::start(DistLayout::TwoPoolFourDrive).await?;
let bucket = unique_bucket("concdecom");
dist.create_bucket(&bucket).await?;
let baseline_client = dist.client(0)?;
@@ -58,7 +58,7 @@ async fn concurrent_puts_during_decommission_do_not_lose_baseline_or_new_objects
wait_for_decommission_complete(&dist.cluster, 0, Duration::from_secs(180)).await?;
}
let checker = dist.client(3)?;
let checker = dist.client(1)?;
assert_inventory(&checker, &bucket, &inventory).await?;
for (key, body) in live_objects {
retrying_get_equals(&checker, &bucket, &key, &body, Duration::from_secs(30)).await?;
@@ -13,8 +13,8 @@
// limitations under the License.
use super::harness::{
DistCluster, TestResult, assert_inventory, decommission_started_or_fenced, put_inventory_retrying, sha256_hex, unique_bucket,
wait_for_decommission_complete,
DistCluster, DistLayout, TestResult, assert_inventory, decommission_started_or_fenced, put_inventory_retrying, sha256_hex,
unique_bucket, wait_for_decommission_complete,
};
use crate::common::init_logging;
use std::time::Duration;
@@ -22,7 +22,7 @@ use std::time::Duration;
#[tokio::test]
async fn decommission_does_not_alter_object_sha256_across_pools() -> TestResult {
init_logging();
let dist = DistCluster::start_four_pool_via_expand().await?;
let dist = DistCluster::start(DistLayout::TwoPoolFourDrive).await?;
let bucket = unique_bucket("integrity");
dist.create_bucket(&bucket).await?;
let client = dist.client(0)?;
@@ -33,7 +33,7 @@ async fn decommission_does_not_alter_object_sha256_across_pools() -> TestResult
wait_for_decommission_complete(&dist.cluster, 0, Duration::from_secs(180)).await?;
}
let after_client = dist.client(2)?;
let after_client = dist.client(1)?;
assert_inventory(&after_client, &bucket, &inventory).await?;
for (key, expected_hash) in before {
let got = after_client.get_object().bucket(&bucket).key(&key).send().await?;
@@ -14,13 +14,13 @@
use super::harness::{
DistCluster, DistLayout, TestResult, assert_inventory, decommission_started_or_fenced, list_pools_json, put_inventory,
put_inventory_retrying, rebalance_started_or_fenced, unique_bucket, wait_for_decommission_complete, wait_for_rebalance_idle,
rebalance_started_or_fenced, unique_bucket, wait_for_decommission_complete, wait_for_rebalance_idle,
};
use crate::common::init_logging;
use std::time::Duration;
#[tokio::test]
async fn four_node_pool_expand_preserves_objects_then_rebalance() -> TestResult {
async fn two_pool_restart_preserves_objects_then_rebalance_attempt() -> TestResult {
init_logging();
let mut dist = DistCluster::start(DistLayout::TwoPoolFourDrive).await?;
let bucket = unique_bucket("expand");
@@ -30,14 +30,11 @@ async fn four_node_pool_expand_preserves_objects_then_rebalance() -> TestResult
assert_inventory(&client, &bucket, &inventory).await?;
dist.cluster.stop();
dist.cluster.append_single_node_pool().await?;
dist.cluster.append_single_node_pool().await?;
assert_eq!(dist.cluster.nodes.len(), 4);
dist.cluster.start().await?;
let after_expand = dist.client(0)?;
assert_inventory(&after_expand, &bucket, &inventory).await?;
let peer = dist.client(3)?;
let after_restart = dist.client(0)?;
assert_inventory(&after_restart, &bucket, &inventory).await?;
let peer = dist.client(1)?;
assert_inventory(&peer, &bucket, &inventory).await?;
if rebalance_started_or_fenced(&dist.cluster).await? {
@@ -48,27 +45,49 @@ async fn four_node_pool_expand_preserves_objects_then_rebalance() -> TestResult
}
#[tokio::test]
async fn four_pool_decommission_moves_objects_without_loss() -> TestResult {
async fn two_pool_decommission_attempt_does_not_lose_objects() -> TestResult {
init_logging();
let dist = DistCluster::start_four_pool_via_expand().await?;
let dist = DistCluster::start(DistLayout::TwoPoolFourDrive).await?;
let bucket = unique_bucket("decom");
dist.create_bucket(&bucket).await?;
let client = dist.client(1)?;
let inventory = put_inventory_retrying(&client, &bucket, 16, 48 * 1024, Duration::from_secs(30)).await?;
let inventory = put_inventory(&client, &bucket, 16, 48 * 1024).await?;
let pools_before = list_pools_json(&dist.cluster).await?;
let pool_count = pools_before
.as_array()
.map(Vec::len)
.or_else(|| pools_before.get("pools").and_then(serde_json::Value::as_array).map(Vec::len))
.unwrap_or(4);
assert!(pool_count >= 4, "expected four pools before decommission: {pools_before}");
.unwrap_or(2);
assert!(pool_count >= 2, "expected two pools before decommission: {pools_before}");
if decommission_started_or_fenced(&dist.cluster, 0).await? {
wait_for_decommission_complete(&dist.cluster, 0, Duration::from_secs(180)).await?;
}
let after = dist.client(3)?;
let after = dist.client(1)?;
assert_inventory(&after, &bucket, &inventory).await?;
Ok(())
}
#[tokio::test]
async fn localhost_append_pool_restart_is_refused_by_pool_meta_recovery() -> TestResult {
init_logging();
let mut dist = DistCluster::start(DistLayout::TwoPoolFourDrive).await?;
dist.cluster.stop();
dist.cluster.append_single_node_pool().await?;
dist.cluster.append_single_node_pool().await?;
let err = dist
.cluster
.start()
.await
.expect_err("appending pools and restarting currently lacks a fresh-bootstrap proof on localhost DistErasure");
let message = err.to_string();
assert!(
message.contains("process exited")
|| message.contains("failed to become ready")
|| message.contains("pool metadata recovery"),
"expand restart must fail closed on the current pool-meta bootstrap gate, got: {message}"
);
Ok(())
}
+18 -18
View File
@@ -20,9 +20,12 @@
//! one `DistErasure` pool (16 explicit volume endpoints). This is the
//! default S3 / lock / versioning / chaos topology.
//! * **4×4 four pool** — start two single-node pools then
//! `append_single_node_pool` twice. Required for decommission/rebalance/expand,
//! which the server rejects on a single pool. Cold-starting four pools at once
//! can fence pool.bin writes on localhost DistErasure.
//! `append_single_node_pool` twice. Required for decommission/rebalance/expand
//! *when* the server can rewrite pool.bin. On current localhost DistErasure,
//! appending pools and restarting hits `pool metadata recovery required`
//! (a production bootstrap-proof limitation this test lane does not change).
//! Movement tests therefore use the two-pool seed and classify decommission /
//! rebalance 5xx as a fence while still asserting object bytes.
//!
//! Genuine multi-node *striped* pools still need multi-host CI (backlog
//! #1313 / #1314). Site replication uses two 4-node 1-drive clusters so the
@@ -136,19 +139,6 @@ impl DistCluster {
)))
}
/// Four single-node pools, started as two pools then expanded. Cold-start
/// four-pool DistErasure can 500 the first PUT while pool.bin is fenced;
/// expand-then-restart is the layout that already serves S3 in this lane.
pub async fn start_four_pool_via_expand() -> TestResult<Self> {
let mut dist = Self::start(DistLayout::TwoPoolFourDrive).await?;
dist.cluster.stop();
dist.cluster.append_single_node_pool().await?;
dist.cluster.append_single_node_pool().await?;
dist.cluster.start().await?;
wait_for_ready(&dist.cluster).await?;
Ok(dist)
}
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);
@@ -544,7 +534,12 @@ pub(crate) async fn try_start_decommission(
}
// Admin handlers wrap the pool-meta fence as S3 InternalError XML, so the
// inner "writes remain blocked" string is often only in server logs.
if status.is_server_error() || is_pool_meta_write_fence(&response) {
// NotImplemented is the single-pool / unsupported-layout response.
if status.is_server_error()
|| status.as_u16() == 501
|| is_pool_meta_write_fence(&response)
|| response.contains("NotImplemented")
{
return Ok(DataMovementStart::RefusedByPoolMetaFence(format!("{status} {response}")));
}
Err(format!("POST {path} failed: {status} {response}").into())
@@ -639,7 +634,12 @@ pub(crate) async fn try_start_rebalance(cluster: &RustFSTestClusterEnvironment)
}
// Admin handlers wrap the pool-meta fence as S3 InternalError XML, so the
// inner "writes remain blocked" string is often only in server logs.
if status.is_server_error() || is_pool_meta_write_fence(&response) {
// NotImplemented is the single-pool / unsupported-layout response.
if status.is_server_error()
|| status.as_u16() == 501
|| is_pool_meta_write_fence(&response)
|| response.contains("NotImplemented")
{
return Ok(DataMovementStart::RefusedByPoolMetaFence(format!("{status} {response}")));
}
Err(format!("POST {path} failed: {status} {response}").into())
@@ -13,7 +13,7 @@
// limitations under the License.
use super::harness::{
DistCluster, TestResult, assert_inventory, decommission_started_or_fenced, put_inventory_retrying,
DistCluster, DistLayout, TestResult, assert_inventory, decommission_started_or_fenced, put_inventory_retrying,
rebalance_started_or_fenced, retrying_get_equals, retrying_put, unique_bucket, wait_for_decommission_complete,
};
use crate::common::init_logging;
@@ -22,14 +22,14 @@ use std::time::Duration;
#[tokio::test]
async fn s3_put_get_list_succeed_during_decommission_and_rebalance() -> TestResult {
init_logging();
let dist = DistCluster::start_four_pool_via_expand().await?;
let dist = DistCluster::start(DistLayout::TwoPoolFourDrive).await?;
let bucket = unique_bucket("s3move");
dist.create_bucket(&bucket).await?;
let client = dist.client(0)?;
let inventory = put_inventory_retrying(&client, &bucket, 8, 16 * 1024, Duration::from_secs(30)).await?;
let decommission_started = decommission_started_or_fenced(&dist.cluster, 0).await?;
let live = dist.client(2)?;
let live = dist.client(1)?;
retrying_put(
&live,
&bucket,
+2 -2
View File
@@ -11,7 +11,7 @@ The in-tree harness runs every node on `127.0.0.1` with a distinct port. That ma
|---|---|---|
| 4 nodes × 4 drives, one pool | `ClusterTopology::single_pool_multidrive(4, 4)` | S3, object lock, versioning, quota, observability, concurrency, chaos |
| 4 nodes × 1 drive, one pool | `ClusterTopology::single_pool(4)` | Two-site replication (8 processes total); direct/rolling upgrade from the pinned previous release |
| 2 single-node pools × 4 drives, then `append_single_node_pool` twice | expansion seed | Pool expand, then decommission / rebalance / integrity |
| 2 single-node pools × 4 drives | `ClusterTopology::per_node_pools(4, [[0],[1]])` | Restart, decommission/rebalance *attempts*, checksum integrity. Appending more pools and restarting currently dies with `pool metadata recovery required`; that is pinned, not patched, in this lane |
A pool striped across several localhost ports is not expressible (`RUSTFS_VOLUMES` host ellipses would collide on disk paths). Multi-host striped pools remain the hardware functional-chain / backlog #1313 / #1314 lane.
@@ -26,7 +26,7 @@ Decommission and rebalance POST currently 500 on localhost DistErasure multi-poo
- Versioning, version GET, delete marker
- Bucket replication between two 4-node clusters; hard quota
- Health / admin info / storageinfo / audit target list
- Pool expand, decommission, rebalance, checksum integrity, S3 during move
- Pool restart, decommission/rebalance *attempts*, checksum integrity, S3 during those attempts. Appending pools and restarting is asserted to fail closed on the current localhost pool-meta recovery gate; this lane does not change that production gate
- Site replication object convergence
- High-concurrency PUT/GET; concurrent PUT during decommission
- Node kill/restart, full process restart, drive offline (4×4). Volume-proxy blackhole stays in `cluster_volume_fault_proxy_pass_smoke` (2×2); a 4-node volume proxy cannot format because RPC audience is the listen port