test(e2e): observe 4x4 movement refusals without production changes

Keep the distributed lane on 4x4 single-pool layouts. Classify
decommission/rebalance product refusals in tests only, and do not
change ecstore pool-meta bootstrap.

Co-authored-by: RustFS <hello@rustfs.com>
This commit is contained in:
Cursor Agent
2026-09-04 16:04:25 +00:00
parent 5a3b3a86a2
commit 431011f592
9 changed files with 183 additions and 129 deletions
+2 -2
View File
@@ -1,2 +1,2 @@
sha256-linux=ede716c586b7ea58eafa0908b4ee4b40f29aeaec5bbf8600739208d360ef9c69
sha256-darwin=ede716c586b7ea58eafa0908b4ee4b40f29aeaec5bbf8600739208d360ef9c69
sha256-linux=52a426e7fe4d41497bbe44db0dc450edbcb133dd966d7a41601cdae1e6788741
sha256-darwin=52a426e7fe4d41497bbe44db0dc450edbcb133dd966d7a41601cdae1e6788741
+3 -3
View File
@@ -100,11 +100,11 @@ jobs:
- name: Run distributed 4-node e2e suite
env:
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-e2e-distributed-logs
NEXTEST_FILTER: ${{ github.event.inputs.filter }}
run: |
set -euo pipefail
FILTER='${{ github.event.inputs.filter }}'
if [ -n "${FILTER}" ]; then
cargo nextest run --profile e2e-distributed -p e2e_test -E "${FILTER}"
if [ -n "${NEXTEST_FILTER}" ]; then
cargo nextest run --profile e2e-distributed -p e2e_test -E "${NEXTEST_FILTER}" --no-tests=fail
else
cargo nextest run --profile e2e-distributed -p e2e_test --no-tests=fail
fi
@@ -13,7 +13,7 @@
// limitations under the License.
use super::harness::{
DistCluster, DistLayout, TestResult, assert_inventory, decommission_started_or_fenced, payload_for, put_inventory_retrying,
DistCluster, DistLayout, TestResult, assert_inventory, decommission_started_or_refused, payload_for, put_inventory_retrying,
retrying_get_equals, retrying_put, unique_bucket, wait_for_decommission_complete,
};
use crate::common::init_logging;
@@ -24,13 +24,13 @@ 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(DistLayout::TwoPoolFourDrive).await?;
let dist = DistCluster::start(DistLayout::FourByFour).await?;
let bucket = unique_bucket("concdecom");
dist.create_bucket(&bucket).await?;
let baseline_client = dist.client(0)?;
let inventory = put_inventory_retrying(&baseline_client, &bucket, 10, 24 * 1024, Duration::from_secs(30)).await?;
let decommission_started = decommission_started_or_fenced(&dist.cluster, 0).await?;
let decommission_started = decommission_started_or_refused(&dist.cluster, 0).await?;
let clients = Arc::new(dist.clients()?);
let barrier = Arc::new(Barrier::new(16));
@@ -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(1)?;
let checker = dist.client(3)?;
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,27 +13,27 @@
// limitations under the License.
use super::harness::{
DistCluster, DistLayout, TestResult, assert_inventory, decommission_started_or_fenced, put_inventory_retrying, sha256_hex,
DistCluster, DistLayout, TestResult, assert_inventory, decommission_started_or_refused, put_inventory_retrying, sha256_hex,
unique_bucket, wait_for_decommission_complete,
};
use crate::common::init_logging;
use std::time::Duration;
#[tokio::test]
async fn decommission_does_not_alter_object_sha256_across_pools() -> TestResult {
async fn decommission_attempt_does_not_alter_object_sha256() -> TestResult {
init_logging();
let dist = DistCluster::start(DistLayout::TwoPoolFourDrive).await?;
let dist = DistCluster::start(DistLayout::FourByFour).await?;
let bucket = unique_bucket("integrity");
dist.create_bucket(&bucket).await?;
let client = dist.client(0)?;
let inventory = put_inventory_retrying(&client, &bucket, 20, 64 * 1024, Duration::from_secs(30)).await?;
let before: Vec<(String, String)> = inventory.iter().map(|(key, body)| (key.clone(), sha256_hex(body))).collect();
if decommission_started_or_fenced(&dist.cluster, 0).await? {
if decommission_started_or_refused(&dist.cluster, 0).await? {
wait_for_decommission_complete(&dist.cluster, 0, Duration::from_secs(180)).await?;
}
let after_client = dist.client(1)?;
let after_client = dist.client(2)?;
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?;
@@ -13,16 +13,16 @@
// limitations under the License.
use super::harness::{
DistCluster, DistLayout, TestResult, assert_inventory, decommission_started_or_fenced, list_pools_json, put_inventory,
rebalance_started_or_fenced, unique_bucket, wait_for_decommission_complete, wait_for_rebalance_idle,
DistCluster, DistLayout, TestResult, assert_inventory, decommission_started_or_refused, list_pools_json, put_inventory,
rebalance_started_or_refused, unique_bucket, wait_for_decommission_complete, wait_for_rebalance_idle,
};
use crate::common::init_logging;
use std::time::Duration;
#[tokio::test]
async fn two_pool_restart_preserves_objects_then_rebalance_attempt() -> TestResult {
async fn four_node_four_drive_restart_preserves_objects_then_rebalance_attempt() -> TestResult {
init_logging();
let mut dist = DistCluster::start(DistLayout::TwoPoolFourDrive).await?;
let mut dist = DistCluster::start(DistLayout::FourByFour).await?;
let bucket = unique_bucket("expand");
dist.create_bucket(&bucket).await?;
let client = dist.client(0)?;
@@ -34,10 +34,10 @@ async fn two_pool_restart_preserves_objects_then_rebalance_attempt() -> TestResu
let after_restart = dist.client(0)?;
assert_inventory(&after_restart, &bucket, &inventory).await?;
let peer = dist.client(1)?;
let peer = dist.client(3)?;
assert_inventory(&peer, &bucket, &inventory).await?;
if rebalance_started_or_fenced(&dist.cluster).await? {
if rebalance_started_or_refused(&dist.cluster).await? {
wait_for_rebalance_idle(&dist.cluster, Duration::from_secs(90)).await?;
}
assert_inventory(&peer, &bucket, &inventory).await?;
@@ -45,9 +45,9 @@ async fn two_pool_restart_preserves_objects_then_rebalance_attempt() -> TestResu
}
#[tokio::test]
async fn two_pool_decommission_attempt_does_not_lose_objects() -> TestResult {
async fn four_node_four_drive_decommission_attempt_does_not_lose_objects() -> TestResult {
init_logging();
let dist = DistCluster::start(DistLayout::TwoPoolFourDrive).await?;
let dist = DistCluster::start(DistLayout::FourByFour).await?;
let bucket = unique_bucket("decom");
dist.create_bucket(&bucket).await?;
let client = dist.client(1)?;
@@ -58,36 +58,14 @@ async fn two_pool_decommission_attempt_does_not_lose_objects() -> TestResult {
.as_array()
.map(Vec::len)
.or_else(|| pools_before.get("pools").and_then(serde_json::Value::as_array).map(Vec::len))
.unwrap_or(2);
assert!(pool_count >= 2, "expected two pools before decommission: {pools_before}");
.unwrap_or(1);
assert!(pool_count >= 1, "expected at least one pool before decommission: {pools_before}");
if decommission_started_or_fenced(&dist.cluster, 0).await? {
if decommission_started_or_refused(&dist.cluster, 0).await? {
wait_for_decommission_complete(&dist.cluster, 0, Duration::from_secs(180)).await?;
}
let after = dist.client(1)?;
let after = dist.client(3)?;
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(())
}
+129 -70
View File
@@ -19,13 +19,12 @@
//! * **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 two single-node pools then
//! `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.
//! * **4×4 four pool** — `append_single_node_pool` exists for harness unit
//! tests. Live expand-then-restart currently hits `pool metadata recovery
//! required` on localhost DistErasure. That is a production bootstrap-proof
//! limitation this test lane does not change. Movement tests use 4×4 single
//! pool and classify decommission/rebalance 4xx/5xx as a refused move 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
@@ -225,8 +224,8 @@ pub(crate) async fn put_inventory(
Ok(inventory)
}
/// Four-pool DistErasure on localhost can 500 a PUT while heal_bucket hits a
/// pool-meta write fence. Retry only those transient codes.
/// Localhost DistErasure can 500 a PUT while heal_bucket hits a pool-meta
/// write fence. Retry only those transient codes.
pub(crate) async fn put_inventory_retrying(
client: &Client,
bucket: &str,
@@ -505,10 +504,10 @@ pub(crate) async fn set_bucket_quota(cluster: &RustFSTestClusterEnvironment, buc
.await
}
/// Localhost DistErasure multi-pool can boot and serve S3 while still refusing
/// pool.bin mutations (`pool metadata writes remain blocked` / missing fleet
/// capability proof). Decommission and rebalance POST then 500. That is a
/// product gate, not a harness URL mistake; tests must not pretend a move ran.
/// Localhost DistErasure can boot and serve S3 while refusing pool.bin
/// mutations (`pool metadata writes remain blocked` / missing fleet
/// capability proof). Single-pool 4×4 also rejects decommission/rebalance
/// with a product error. Tests must not pretend a move ran.
pub(crate) fn is_pool_meta_write_fence(body: &str) -> bool {
body.contains("pool metadata writes remain blocked")
|| body.contains("pool metadata recovery required")
@@ -517,10 +516,30 @@ pub(crate) fn is_pool_meta_write_fence(body: &str) -> bool {
|| body.contains("live fleet capability proof")
}
/// Product refusals that movement tests observe. Opaque 5xx stays in
/// [`classify_data_movement_http`] because admin often wraps the fence as
/// InternalError XML without the inner string. Auth failures are not refusals.
pub(crate) fn is_known_data_movement_refusal(body: &str) -> bool {
is_pool_meta_write_fence(body)
|| body.contains("NotImplemented")
|| body.contains("single pool deployments do not support")
|| body.contains("at least one active pool must remain")
}
#[derive(Debug)]
pub(crate) enum DataMovementStart {
Started,
RefusedByPoolMetaFence(String),
Refused(String),
}
pub(crate) fn classify_data_movement_http(status: StatusCode, body: &str) -> Result<DataMovementStart, String> {
if status.is_success() {
return Ok(DataMovementStart::Started);
}
if is_known_data_movement_refusal(body) || status.as_u16() == 501 || status.is_server_error() {
return Ok(DataMovementStart::Refused(format!("{status} {body}")));
}
Err(format!("{status} {body}"))
}
pub(crate) async fn try_start_decommission(
@@ -529,29 +548,16 @@ pub(crate) async fn try_start_decommission(
) -> TestResult<DataMovementStart> {
let path = format!("/rustfs/admin/v3/pools/decommission?pool={pool_id}&by-id=true");
let (status, response) = cluster_admin(cluster, Method::POST, &path, None).await?;
if status.is_success() {
return Ok(DataMovementStart::Started);
}
// Admin handlers wrap the pool-meta fence as S3 InternalError XML, so the
// inner "writes remain blocked" string is often only in server logs.
// 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())
classify_data_movement_http(status, &response).map_err(|detail| format!("POST {path} failed: {detail}").into())
}
/// Returns whether decommission actually started. A pool-meta fence is not a
/// test failure: callers still assert object bytes. Any other error fails.
pub(crate) async fn decommission_started_or_fenced(cluster: &RustFSTestClusterEnvironment, pool_id: usize) -> TestResult<bool> {
/// Returns whether decommission actually started. A product refusal or opaque
/// 5xx is not a test failure: callers still assert object bytes.
pub(crate) async fn decommission_started_or_refused(cluster: &RustFSTestClusterEnvironment, pool_id: usize) -> TestResult<bool> {
match try_start_decommission(cluster, pool_id).await? {
DataMovementStart::Started => Ok(true),
DataMovementStart::RefusedByPoolMetaFence(detail) => {
eprintln!("decommission POST refused by pool-meta write fence on localhost DistErasure: {detail}");
DataMovementStart::Refused(detail) => {
eprintln!("decommission POST refused; objects still asserted: {detail}");
Ok(false)
}
}
@@ -607,49 +613,50 @@ pub(crate) fn decommission_failed(status: &serde_json::Value, pool_id: usize) ->
pool_entry(status, pool_id).is_some_and(decommission_pool_failed)
}
/// `Ok(true)` complete, `Ok(false)` still running, `Err` terminal failure.
pub(crate) fn decommission_progress(status: &serde_json::Value, pool_id: usize) -> Result<bool, String> {
if decommission_failed(status, pool_id) {
return Err(format!("decommission failed for pool {pool_id}: {status}"));
}
Ok(decommission_complete(status, pool_id))
}
pub(crate) async fn wait_for_decommission_complete(
cluster: &RustFSTestClusterEnvironment,
pool_id: usize,
timeout: Duration,
) -> TestResult {
wait_until(
timeout,
|| async {
let status = decommission_status_json(cluster).await?;
if decommission_failed(&status, pool_id) {
return Err(format!("decommission failed for pool {pool_id}: {status}").into());
}
Ok(decommission_complete(&status, pool_id))
},
"decommission complete",
)
.await
let deadline = Instant::now() + timeout;
let mut delay = Duration::from_millis(50);
let mut last_error;
loop {
last_error = match decommission_status_json(cluster).await {
Ok(status) => match decommission_progress(&status, pool_id) {
Ok(true) => return Ok(()),
Ok(false) => format!("decommission complete still false: {status}"),
Err(failed) => return Err(failed.into()),
},
Err(error) => error.to_string(),
};
if Instant::now() >= deadline {
return Err(format!("decommission complete did not become true within {timeout:?}: {last_error}").into());
}
sleep(delay).await;
delay = (delay * 2).min(Duration::from_secs(1));
}
}
pub(crate) async fn try_start_rebalance(cluster: &RustFSTestClusterEnvironment) -> TestResult<DataMovementStart> {
let path = "/rustfs/admin/v3/rebalance/start";
let (status, response) = cluster_admin(cluster, Method::POST, path, None).await?;
if status.is_success() {
return Ok(DataMovementStart::Started);
}
// Admin handlers wrap the pool-meta fence as S3 InternalError XML, so the
// inner "writes remain blocked" string is often only in server logs.
// 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())
classify_data_movement_http(status, &response).map_err(|detail| format!("POST {path} failed: {detail}").into())
}
pub(crate) async fn rebalance_started_or_fenced(cluster: &RustFSTestClusterEnvironment) -> TestResult<bool> {
pub(crate) async fn rebalance_started_or_refused(cluster: &RustFSTestClusterEnvironment) -> TestResult<bool> {
match try_start_rebalance(cluster).await? {
DataMovementStart::Started => Ok(true),
DataMovementStart::RefusedByPoolMetaFence(detail) => {
eprintln!("rebalance POST refused by pool-meta write fence on localhost DistErasure: {detail}");
DataMovementStart::Refused(detail) => {
eprintln!("rebalance POST refused; objects still asserted: {detail}");
Ok(false)
}
}
@@ -771,19 +778,19 @@ pub(crate) async fn retrying_get_equals(
#[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]]))
let mut dist = DistCluster::new_stopped(DistLayout::TwoPoolFourDrive)
.await
.expect("two-pool seed topology");
assert_eq!(env.rustfs_volumes_arg().split(' ').count(), 2);
assert_eq!(dist.cluster.rustfs_volumes_arg().split(' ').count(), 2);
let added = env.append_single_node_pool().await.expect("append third pool");
let added = dist.cluster.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!(dist.cluster.nodes.len(), 3);
assert_eq!(dist.cluster.nodes[2].pool_idx, 2);
assert_eq!(dist.cluster.nodes[2].data_dirs.len(), DRIVES_PER_NODE);
let volumes = dist.cluster.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}");
assert!(volumes.contains("/drive{0...3}"), "expanded layout must keep drive ellipses: {volumes}");
}
#[tokio::test]
@@ -816,6 +823,22 @@ fn decommission_complete_reads_pool_status_and_info_flag() {
assert!(decommission_complete(&status, 0));
assert!(!decommission_complete(&status, 1));
assert!(!decommission_failed(&status, 0));
assert!(decommission_progress(&status, 0).expect("complete pool"));
assert!(!decommission_progress(&status, 1).expect("other pool is not complete"));
}
#[test]
fn decommission_progress_fails_closed_on_failed_flag() {
let failed = serde_json::json!({
"pools": [{
"id": 0,
"status": "failed",
"decommissionInfo": { "complete": false, "failed": true, "canceled": false }
}]
});
let err = decommission_progress(&failed, 0).expect_err("failed decommission must not look complete");
assert!(err.contains("decommission failed for pool 0"), "{err}");
assert!(!decommission_progress(&failed, 1).expect("missing pool is still running"));
}
#[test]
@@ -838,3 +861,39 @@ fn pool_meta_write_fence_matches_known_product_gates() {
assert!(!is_pool_meta_write_fence("NotImplemented: single pool cannot decommission"));
assert!(!is_pool_meta_write_fence("AccessDenied"));
}
#[test]
fn classify_data_movement_http_observes_product_refusals_not_auth_failures() {
assert!(matches!(classify_data_movement_http(StatusCode::OK, ""), Ok(DataMovementStart::Started)));
assert!(matches!(
classify_data_movement_http(
StatusCode::BAD_REQUEST,
"failed to start decommission: single pool deployments do not support decommission"
),
Ok(DataMovementStart::Refused(_))
));
assert!(matches!(
classify_data_movement_http(
StatusCode::BAD_REQUEST,
"failed to start decommission: at least one active pool must remain after decommission start"
),
Ok(DataMovementStart::Refused(_))
));
assert!(matches!(
classify_data_movement_http(StatusCode::NOT_IMPLEMENTED, "NotImplemented"),
Ok(DataMovementStart::Refused(_))
));
assert!(matches!(
classify_data_movement_http(
StatusCode::INTERNAL_SERVER_ERROR,
"pool metadata writes remain blocked after a recovery-required replica state"
),
Ok(DataMovementStart::Refused(_))
));
assert!(matches!(
classify_data_movement_http(StatusCode::INTERNAL_SERVER_ERROR, "InternalError"),
Ok(DataMovementStart::Refused(_))
));
let denied = classify_data_movement_http(StatusCode::FORBIDDEN, "AccessDenied").expect_err("auth failure is not a refusal");
assert!(denied.contains("AccessDenied"), "{denied}");
}
@@ -13,8 +13,8 @@
// limitations under the License.
use super::harness::{
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,
DistCluster, DistLayout, TestResult, assert_inventory, decommission_started_or_refused, put_inventory_retrying,
rebalance_started_or_refused, retrying_get_equals, retrying_put, unique_bucket, wait_for_decommission_complete,
};
use crate::common::init_logging;
use std::time::Duration;
@@ -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(DistLayout::TwoPoolFourDrive).await?;
let dist = DistCluster::start(DistLayout::FourByFour).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(1)?;
let decommission_started = decommission_started_or_refused(&dist.cluster, 0).await?;
let live = dist.client(2)?;
retrying_put(
&live,
&bucket,
@@ -60,7 +60,7 @@ async fn s3_put_get_list_succeed_during_decommission_and_rebalance() -> TestResu
}
assert_inventory(&live, &bucket, &inventory).await?;
let _ = rebalance_started_or_fenced(&dist.cluster).await?;
let _ = rebalance_started_or_refused(&dist.cluster).await?;
retrying_put(
&live,
&bucket,
@@ -33,6 +33,7 @@ use crate::common::{
};
use aws_sdk_s3::Client;
use aws_sdk_s3::error::ProvideErrorMetadata;
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::time::Duration;
use uuid::Uuid;
@@ -59,8 +60,8 @@ struct UpgradeSeed {
iam_secret: &'static str,
}
fn source_binary() -> TestResult<PathBuf> {
let path = std::env::var_os(SOURCE_BINARY_ENV).map(PathBuf::from).ok_or_else(|| {
fn resolve_source_binary(value: Option<OsString>) -> TestResult<PathBuf> {
let path = value.map(PathBuf::from).ok_or_else(|| {
format!(
"{SOURCE_BINARY_ENV} must point to the pinned previous release binary (the e2e-distributed workflow downloads it)"
)
@@ -71,6 +72,10 @@ fn source_binary() -> TestResult<PathBuf> {
Ok(path)
}
fn source_binary() -> TestResult<PathBuf> {
resolve_source_binary(std::env::var_os(SOURCE_BINARY_ENV))
}
fn capture_upgrade_logs(cluster: &mut DistCluster, label: &str) -> TestResult {
let Some(log_dir) = std::env::var_os("RUSTFS_E2E_LOG_DIR") else {
return Ok(());
@@ -344,3 +349,15 @@ async fn four_node_rolling_upgrade_preserves_history_and_iam_credentials() -> Te
assert_history_and_iam(&dist, &seed, "homogeneous-current").await?;
Ok(())
}
#[test]
fn missing_upgrade_source_binary_fails_closed() {
let err = resolve_source_binary(None).expect_err("absent env must fail closed");
assert!(err.to_string().contains(SOURCE_BINARY_ENV), "{err}");
}
#[test]
fn missing_upgrade_source_binary_file_fails_closed() {
let err = resolve_source_binary(Some("/no/such/rustfs-upgrade-source".into())).expect_err("missing file must fail closed");
assert!(err.to_string().contains("does not exist"), "{err}");
}
+3 -3
View File
@@ -11,11 +11,11 @@ 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 | `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 |
| 2 single-node pools × 4 drives | `ClusterTopology::per_node_pools(4, [[0],[1]])` | Harness-only: `append_single_node_pool` unit tests. Live multi-pool expand/restart currently dies with `pool metadata recovery required`; this lane does not change that production gate |
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.
Decommission and rebalance POST currently 500 on localhost DistErasure multi-pool when pool.bin writes are fenced (`pool metadata writes remain blocked` / missing fleet capability proof). Those cases still assert object bytes and SHA-256; when the API starts they wait for completion and assert post-move integrity. They do not treat the fence as a successful move. This lane does not change production pool-meta bootstrap or write-fence logic; it only observes the current server behavior.
Decommission and rebalance POST on the 4×4 single-pool layout is refused by the current product (`single pool deployments do not support decommission`, NotImplemented, or an opaque admin 5xx when the inner pool-meta fence is wrapped as InternalError). Those cases still assert object bytes and SHA-256; when the API starts they wait for completion and assert post-move integrity. They do not treat a refusal as a successful move. This lane does not change production pool-meta bootstrap, write-fence, or decommission policy; it only observes the current server behavior.
## What this lane covers
@@ -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 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
- Pool restart, decommission/rebalance *attempts*, checksum integrity, S3 during those attempts on 4×4. Live multi-pool expand/restart is a production pool-meta bootstrap limitation and is not patched here; `append_single_node_pool` is covered by harness unit tests
- 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