mirror of
https://github.com/rustfs/rustfs.git
synced 2026-07-28 00:58:59 +00:00
test(e2e): add drivesPerNode and 2-pool cluster harness topology (backlog #1325) Extend the e2e cluster harness so tests can declare per-node multiple drives (drivesPerNode) and a two-pool topology, alongside a smoke suite that boots such a cluster and round-trips a PUT/GET. A new `ClusterTopology` describes node_count, drives_per_node, and pool membership, and `RustFSTestClusterEnvironment::with_topology` builds the matching on-disk drive directories and `RUSTFS_VOLUMES` string. `new(node_count)` now delegates to a single-pool single-drive topology, so existing single-drive cluster tests are byte-for-byte unchanged. `ClusterNode` gains `data_dirs` (all drives, `data_dirs[0] == data_dir`) and `pool_idx`; multi-drive/multi-pool clusters automatically add `RUSTFS_UNSAFE_BYPASS_DISK_CHECK=true` because their drives share the same temp filesystem. The `RUSTFS_VOLUMES` assembly matches the two forms the server parser accepts on a single localhost machine (verified against ecstore `DisksLayout::from_volumes`): a single pool is the explicit enumeration of every `(node, drive)` endpoint (no ellipses, one legacy DistErasure pool), while multiple pools each contribute one ellipses argument `http://<addr><node-base>/drive{0...N-1}`. A pool argument is a single URL template, so it cannot enumerate multiple distinct-port hosts; a pool striped across several localhost nodes would need a host ellipses that forces a shared on-disk path and collides physically. The topology validator therefore requires every pool in a multi-pool layout to own exactly one node and requires drives_per_node >= 2 (the parser rejects a single-drive `drive{0...0}` ellipses pool). Genuine multi-node pools need real multi-host infrastructure and are deferred to the nightly cluster lane (backlog #1313/#1314). Unit tests assert the volumes-string layout for single-pool single-drive (backward compatible), single-pool multi-drive (8 explicit endpoints), and two-pool (one ellipses arg per pool), plus the validator rejections. The smoke suite `cluster_multidrive_pool_test` boots a 4-node x 2-drive single pool and a 2-node/2-pool cluster, asserts the volumes layout, and round-trips a PUT/GET using the harness readiness handshake (no fixed sleeps). It joins the six existing RustFSTestClusterEnvironment suites excluded from the merge-gate `e2e-full` profile so it runs in the nightly 4-node lane. Network fault injection (toxiproxy / socket proxy) and 5GiB large-object budgets remain out of scope for this block.
This commit is contained in:
@@ -263,8 +263,8 @@ path = "junit.xml"
|
||||
# The filter is "the whole e2e_test crate MINUS the sets owned by other lanes":
|
||||
# * protocols:: — FTPS/SFTP/WebDAV, still pinned to --test-threads=1 by fixed
|
||||
# ports; they join a scheduled lane once ci-6 randomises the ports (ci-7).
|
||||
# * the 6 cluster suites that spin up a RustFSTestClusterEnvironment
|
||||
# (cluster_concurrency, stale_multipart_cleanup_cluster,
|
||||
# * the 7 cluster suites that spin up a RustFSTestClusterEnvironment
|
||||
# (cluster_concurrency, cluster_multidrive_pool, stale_multipart_cleanup_cluster,
|
||||
# namespace_lock_quorum, heal_erasure_disk_rebuild, admin_timeout_regression,
|
||||
# object_lambda) — too heavy for the merge budget; they run in ci-7's
|
||||
# nightly 4-node lane.
|
||||
@@ -301,7 +301,7 @@ path = "junit.xml"
|
||||
default-filter = """
|
||||
package(e2e_test)
|
||||
& !test(/^protocols::/)
|
||||
& !test(/^(admin_timeout_regression_test|cluster_concurrency_test|heal_erasure_disk_rebuild_test|namespace_lock_quorum_test|object_lambda_test|stale_multipart_cleanup_cluster_test)::/)
|
||||
& !test(/^(admin_timeout_regression_test|cluster_concurrency_test|cluster_multidrive_pool_test|heal_erasure_disk_rebuild_test|namespace_lock_quorum_test|object_lambda_test|stale_multipart_cleanup_cluster_test)::/)
|
||||
& !test(/^replication_extension_test::/)
|
||||
& !test(/^multipart_auth_test::test_signed_put_object_extract_(accepts_compat_header|expands_tar_entries_with_prefix_headers|expands_tar_gz_archive|expands_tbz2_archive|expands_tgz_archive|expands_txz_archive|expands_tzst_archive|normalizes_prefix_header_value|preserves_directory_markers_by_default|preserves_object_lock_legal_hold|preserves_object_lock_retention|preserves_pax_metadata_and_version_id|preserves_request_metadata_on_extracted_objects|preserves_sse_c|preserves_sse_s3_and_redirect|preserves_storage_class|uses_bucket_default_sse_s3)$/)
|
||||
& !test(/^snowball_auto_extract_test::tests::snowball_auto_extract_(prefers_exact_minio_prefix_over_suffix_fallback|supports_minio_prefix_and_directory_markers)$/)
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Smoke tests for the multi-drive / multi-pool cluster harness.
|
||||
//!
|
||||
//! These belong to the nightly 4-node cluster lane (they spin up real RustFS
|
||||
//! processes, so they are excluded from the merge-gate `e2e-full` profile in
|
||||
//! `.config/nextest.toml`). They assert that:
|
||||
//!
|
||||
//! * `drivesPerNode > 1` produces a bootable single-pool cluster whose
|
||||
//! `RUSTFS_VOLUMES` string enumerates every drive, and that a PUT/GET
|
||||
//! round-trips through the multi-drive erasure layout.
|
||||
//! * A two-pool topology (one node per pool, `drivesPerNode` drives each) boots
|
||||
//! with the ellipses `RUSTFS_VOLUMES` form and round-trips a PUT/GET.
|
||||
//!
|
||||
//! Readiness is established by the harness's `start()` handshake (TCP reachability
|
||||
//! plus an S3 `ListBuckets` poll) — there are no fixed sleeps.
|
||||
//!
|
||||
//! Out of scope for this block (tracked separately): network fault injection
|
||||
//! (toxiproxy / socket proxy) and 5GiB large-object budgets.
|
||||
|
||||
use crate::common::{ClusterTopology, RustFSTestClusterEnvironment};
|
||||
use serial_test::serial;
|
||||
|
||||
type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
|
||||
|
||||
const BUCKET: &str = "cluster-multidrive-pool-bucket";
|
||||
|
||||
/// PUT an object then GET it back and assert the bytes round-trip.
|
||||
async fn put_get_roundtrip(cluster: &RustFSTestClusterEnvironment, key: &str, payload: &[u8]) -> TestResult {
|
||||
let writer = cluster.create_s3_client(0)?;
|
||||
writer
|
||||
.put_object()
|
||||
.bucket(BUCKET)
|
||||
.key(key)
|
||||
.body(bytes::Bytes::copy_from_slice(payload).into())
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
// Read back from the last node to exercise the cross-node/cross-pool path.
|
||||
let reader = cluster.create_s3_client(cluster.nodes.len() - 1)?;
|
||||
let got = reader.get_object().bucket(BUCKET).key(key).send().await?;
|
||||
let body = got.body.collect().await?.into_bytes();
|
||||
assert_eq!(body.as_ref(), payload, "round-tripped object bytes must match");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 4 nodes x 2 drives, single pool: the multi-drive layout boots and round-trips.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn cluster_multidrive_single_pool_smoke() -> TestResult {
|
||||
crate::common::init_logging();
|
||||
|
||||
let mut cluster = RustFSTestClusterEnvironment::with_topology(ClusterTopology::single_pool_multidrive(4, 2)).await?;
|
||||
|
||||
// The single-pool multi-drive layout must list every (node, drive) endpoint
|
||||
// explicitly (8 endpoints, no ellipses) so the server keeps one legacy pool.
|
||||
let volumes = cluster.rustfs_volumes_arg();
|
||||
assert_eq!(volumes.split(' ').count(), 8, "expected 8 explicit endpoints, got: {volumes}");
|
||||
assert!(!volumes.contains('{'), "single-pool layout must not use ellipses: {volumes}");
|
||||
|
||||
cluster.start().await?;
|
||||
cluster.create_test_bucket(BUCKET).await?;
|
||||
|
||||
let payload = vec![0xA5u8; 512 * 1024];
|
||||
put_get_roundtrip(&cluster, "multidrive/object", &payload).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Two single-node pools, 2 drives each: the multi-pool layout boots and
|
||||
/// round-trips. Every pool is a distinct erasure pool (`pool_idx` 0 and 1).
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn cluster_two_pool_smoke() -> TestResult {
|
||||
crate::common::init_logging();
|
||||
|
||||
let mut cluster =
|
||||
RustFSTestClusterEnvironment::with_topology(ClusterTopology::per_node_pools(2, vec![vec![0], vec![1]])).await?;
|
||||
|
||||
// The two-pool layout must emit one ellipses argument per pool.
|
||||
let volumes = cluster.rustfs_volumes_arg();
|
||||
let args: Vec<&str> = volumes.split(' ').collect();
|
||||
assert_eq!(args.len(), 2, "expected two pool arguments, got: {volumes}");
|
||||
assert!(
|
||||
args.iter().all(|a| a.contains("/drive{0...1}")),
|
||||
"each pool arg must use the drive ellipses form: {volumes}"
|
||||
);
|
||||
assert_eq!(cluster.nodes[0].pool_idx, 0);
|
||||
assert_eq!(cluster.nodes[1].pool_idx, 1);
|
||||
|
||||
cluster.start().await?;
|
||||
cluster.create_test_bucket(BUCKET).await?;
|
||||
|
||||
let payload = vec![0x5Au8; 256 * 1024];
|
||||
put_get_roundtrip(&cluster, "twopool/object", &payload).await?;
|
||||
Ok(())
|
||||
}
|
||||
+378
-15
@@ -717,15 +717,166 @@ pub async fn awscurl_delete(
|
||||
execute_awscurl(url, "DELETE", None, access_key, secret_key).await
|
||||
}
|
||||
|
||||
/// Cluster topology declaration for a `RustFSTestClusterEnvironment`.
|
||||
///
|
||||
/// Describes how many nodes to launch, how many data drives each node exposes,
|
||||
/// and how those nodes are grouped into erasure pools. The topology drives both
|
||||
/// on-disk directory creation and the `RUSTFS_VOLUMES` string assembled for the
|
||||
/// node processes.
|
||||
///
|
||||
/// # Single-host expressibility constraints
|
||||
///
|
||||
/// The harness runs every node on `127.0.0.1` with a distinct port, so the
|
||||
/// `RUSTFS_VOLUMES` string can only use the two forms the server parser accepts
|
||||
/// on a single machine (verified against `ecstore` `DisksLayout::from_volumes`):
|
||||
///
|
||||
/// * **Single pool** — every `(node, drive)` endpoint listed explicitly, no
|
||||
/// ellipses. This is the legacy form and yields exactly one pool spanning all
|
||||
/// nodes and drives (`DistErasure`). Any `drives_per_node >= 1` works.
|
||||
/// * **Multiple pools** — one ellipses arg per pool. A pool argument is a single
|
||||
/// URL template, so it can enumerate several drives on *one* host but cannot
|
||||
/// enumerate multiple distinct-port hosts. A pool that spanned two localhost
|
||||
/// nodes would need a host ellipses (`127.0.0.1:900{0...1}`), which forces the
|
||||
/// *same* on-disk path across those ports and collides physically. Therefore
|
||||
/// every pool in a multi-pool topology owns exactly one node. In addition, the
|
||||
/// parser rejects a single-drive ellipses pool (`drive{0...0}`), so a
|
||||
/// multi-pool topology requires `drives_per_node >= 2`.
|
||||
///
|
||||
/// Genuine multi-node pools (a pool striped across several hosts) require real
|
||||
/// multi-host infrastructure and are deferred to the nightly cluster CI lane
|
||||
/// (backlog #1313 / #1314); they are intentionally not expressible here.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ClusterTopology {
|
||||
/// Number of node processes to launch.
|
||||
pub node_count: usize,
|
||||
/// Number of independent data drives (directories) each node exposes.
|
||||
pub drives_per_node: usize,
|
||||
/// Pool membership as a list of node-index groups. An empty vector means a
|
||||
/// single pool spanning every node.
|
||||
pub pools: Vec<Vec<usize>>,
|
||||
}
|
||||
|
||||
impl ClusterTopology {
|
||||
/// Single pool, one drive per node — identical to the historical
|
||||
/// `RustFSTestClusterEnvironment::new` layout.
|
||||
pub fn single_pool(node_count: usize) -> Self {
|
||||
Self {
|
||||
node_count,
|
||||
drives_per_node: 1,
|
||||
pools: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Single pool with `drives_per_node` drives on every node. The pool spans
|
||||
/// all nodes and all drives (one `DistErasure` pool).
|
||||
pub fn single_pool_multidrive(node_count: usize, drives_per_node: usize) -> Self {
|
||||
Self {
|
||||
node_count,
|
||||
drives_per_node,
|
||||
pools: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Multi-pool topology where every pool owns exactly one node. `pools` lists
|
||||
/// the node index backing each pool (e.g. `vec![vec![0], vec![1]]` for a
|
||||
/// two-pool cluster). Requires `drives_per_node >= 2` (see the type-level
|
||||
/// note on single-host expressibility).
|
||||
pub fn per_node_pools(drives_per_node: usize, pools: Vec<Vec<usize>>) -> Self {
|
||||
let node_count = pools.iter().flatten().copied().max().map(|m| m + 1).unwrap_or(0);
|
||||
Self {
|
||||
node_count,
|
||||
drives_per_node,
|
||||
pools,
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalized pool membership: an empty `pools` becomes a single pool over
|
||||
/// all node indices.
|
||||
fn normalized_pools(&self) -> Vec<Vec<usize>> {
|
||||
if self.pools.is_empty() {
|
||||
vec![(0..self.node_count).collect()]
|
||||
} else {
|
||||
self.pools.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate that the topology is expressible on a single localhost machine.
|
||||
fn validate(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
if self.node_count == 0 {
|
||||
return Err("Node count must be greater than zero".into());
|
||||
}
|
||||
if self.drives_per_node == 0 {
|
||||
return Err("drives_per_node must be greater than zero".into());
|
||||
}
|
||||
|
||||
let pools = self.normalized_pools();
|
||||
|
||||
// Every referenced node index must be in range.
|
||||
for (pool_idx, nodes) in pools.iter().enumerate() {
|
||||
if nodes.is_empty() {
|
||||
return Err(format!("pool {pool_idx} has no nodes").into());
|
||||
}
|
||||
for &n in nodes {
|
||||
if n >= self.node_count {
|
||||
return Err(format!("pool {pool_idx} references node {n} but node_count is {}", self.node_count).into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Each node must belong to exactly one pool.
|
||||
let mut seen = vec![0usize; self.node_count];
|
||||
for nodes in &pools {
|
||||
for &n in nodes {
|
||||
seen[n] += 1;
|
||||
}
|
||||
}
|
||||
for (n, count) in seen.iter().enumerate() {
|
||||
match count {
|
||||
0 => return Err(format!("node {n} is not assigned to any pool").into()),
|
||||
1 => {}
|
||||
_ => return Err(format!("node {n} is assigned to more than one pool").into()),
|
||||
}
|
||||
}
|
||||
|
||||
// Multi-pool constraints imposed by the single-host RUSTFS_VOLUMES syntax.
|
||||
if pools.len() > 1 {
|
||||
if self.drives_per_node < 2 {
|
||||
return Err(
|
||||
"multi-pool topology requires drives_per_node >= 2 (the server parser rejects a single-drive ellipses pool)"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
for (pool_idx, nodes) in pools.iter().enumerate() {
|
||||
if nodes.len() != 1 {
|
||||
return Err(format!(
|
||||
"pool {pool_idx} spans {} nodes; a pool striped across multiple localhost nodes is not expressible (needs host ellipses, which collides on shared paths). Use one node per pool, or the nightly multi-host lane (backlog #1313/#1314).",
|
||||
nodes.len()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a single RustFS server instance in a test cluster.
|
||||
///
|
||||
/// Each `ClusterNode` tracks the node's network address, base URL for
|
||||
/// S3-compatible requests, on-disk data directory, and the underlying
|
||||
/// S3-compatible requests, on-disk data directories, and the underlying
|
||||
/// child process handle when the node is running.
|
||||
pub struct ClusterNode {
|
||||
pub address: String,
|
||||
pub url: String,
|
||||
/// Primary data directory for the node. For single-drive nodes this is the
|
||||
/// node's only drive; for multi-drive nodes it is the first drive. Kept as a
|
||||
/// stable field so existing single-drive tests continue to compile.
|
||||
pub data_dir: String,
|
||||
/// All data drives exposed by this node (`data_dirs[0] == data_dir`).
|
||||
pub data_dirs: Vec<String>,
|
||||
/// Index of the pool this node belongs to.
|
||||
pub pool_idx: usize,
|
||||
pub process: Option<Child>,
|
||||
}
|
||||
|
||||
@@ -741,6 +892,7 @@ pub struct RustFSTestClusterEnvironment {
|
||||
pub access_key: String,
|
||||
pub secret_key: String,
|
||||
pub extra_env: Vec<(String, String)>,
|
||||
pub topology: ClusterTopology,
|
||||
}
|
||||
|
||||
impl RustFSTestClusterEnvironment {
|
||||
@@ -762,34 +914,82 @@ impl RustFSTestClusterEnvironment {
|
||||
/// * `Err(Box<dyn Error + Send + Sync>)` - An error if any step fails, such as temporary
|
||||
/// directory creation failure or available port lookup failure.
|
||||
pub async fn new(node_count: usize) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
|
||||
if node_count == 0 {
|
||||
return Err("Node count must be greater than zero".into());
|
||||
}
|
||||
Self::with_topology(ClusterTopology::single_pool(node_count)).await
|
||||
}
|
||||
|
||||
/// Create a RustFS test cluster environment from an explicit topology.
|
||||
///
|
||||
/// Allocates a unique temporary root directory, an available TCP port per
|
||||
/// node, and one data directory per drive. The topology is validated up front
|
||||
/// (node/pool assignment, single-host multi-pool constraints); node processes
|
||||
/// are not started at this stage.
|
||||
///
|
||||
/// When the topology exposes more than one local drive on a node (multi-drive
|
||||
/// or multi-pool layouts), `RUSTFS_UNSAFE_BYPASS_DISK_CHECK=true` is added to
|
||||
/// every node's environment: those drives live on the same temp filesystem, so
|
||||
/// the server's distinct-physical-disk safety check would otherwise reject
|
||||
/// startup. Single-drive single-pool clusters keep the historical environment
|
||||
/// unchanged.
|
||||
pub async fn with_topology(topology: ClusterTopology) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
|
||||
topology.validate()?;
|
||||
|
||||
let temp_dir = format!("/tmp/rustfs_cluster_test_{}", Uuid::new_v4());
|
||||
fs::create_dir_all(&temp_dir).await?;
|
||||
|
||||
let mut nodes = Vec::with_capacity(node_count);
|
||||
for i in 0..node_count {
|
||||
// Map every node to its owning pool index.
|
||||
let pools = topology.normalized_pools();
|
||||
let mut pool_of_node = vec![0usize; topology.node_count];
|
||||
for (pool_idx, nodes) in pools.iter().enumerate() {
|
||||
for &n in nodes {
|
||||
pool_of_node[n] = pool_idx;
|
||||
}
|
||||
}
|
||||
|
||||
let multidrive = topology.drives_per_node > 1;
|
||||
|
||||
let mut nodes = Vec::with_capacity(topology.node_count);
|
||||
for (i, &pool_idx) in pool_of_node.iter().enumerate() {
|
||||
let port = RustFSTestEnvironment::find_available_port().await?;
|
||||
let address = format!("127.0.0.1:{}", port);
|
||||
let url = format!("http://{}", address);
|
||||
let data_dir = format!("{}/node{}", temp_dir, i);
|
||||
fs::create_dir_all(&data_dir).await?;
|
||||
|
||||
// Single-drive nodes keep the historical `{temp}/node{i}` path so
|
||||
// existing tests (and their on-disk assertions) are unaffected.
|
||||
// Multi-drive nodes nest one `drive{d}` directory per drive so the
|
||||
// ellipses form `drive{0...N-1}` can address them.
|
||||
let data_dirs: Vec<String> = if multidrive {
|
||||
(0..topology.drives_per_node)
|
||||
.map(|d| format!("{}/node{}/drive{}", temp_dir, i, d))
|
||||
.collect()
|
||||
} else {
|
||||
vec![format!("{}/node{}", temp_dir, i)]
|
||||
};
|
||||
for dir in &data_dirs {
|
||||
fs::create_dir_all(dir).await?;
|
||||
}
|
||||
|
||||
nodes.push(ClusterNode {
|
||||
address,
|
||||
url,
|
||||
data_dir,
|
||||
data_dir: data_dirs[0].clone(),
|
||||
data_dirs,
|
||||
pool_idx,
|
||||
process: None,
|
||||
});
|
||||
}
|
||||
|
||||
let mut extra_env = Vec::new();
|
||||
if multidrive {
|
||||
extra_env.push(("RUSTFS_UNSAFE_BYPASS_DISK_CHECK".to_string(), "true".to_string()));
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
nodes,
|
||||
temp_dir,
|
||||
access_key: DEFAULT_ACCESS_KEY.to_string(),
|
||||
secret_key: DEFAULT_SECRET_KEY.to_string(),
|
||||
extra_env: Vec::new(),
|
||||
extra_env,
|
||||
topology,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -809,14 +1009,51 @@ impl RustFSTestClusterEnvironment {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build the volumes argument string for RustFS binary (internal helper method).
|
||||
/// Build the `RUSTFS_VOLUMES` argument string for the cluster's topology.
|
||||
///
|
||||
/// Concatenates the address and data directory of all cluster nodes into a single string
|
||||
/// used as the `RUSTFS_VOLUMES` environment variable for RustFS node processes.
|
||||
/// * **Single pool** — every `(node, drive)` endpoint is listed explicitly and
|
||||
/// space-joined. With no ellipses the server parser collapses them into one
|
||||
/// legacy pool spanning all nodes and drives.
|
||||
/// * **Multiple pools** — each pool (exactly one node) contributes one ellipses
|
||||
/// argument `http://<addr><node-base>/drive{0...N-1}`; the space-separated
|
||||
/// arguments become one pool each.
|
||||
///
|
||||
/// The server splits `RUSTFS_VOLUMES` on spaces (`value_delimiter = ' '`), which
|
||||
/// this assembly matches, and the resulting layout is verified against the
|
||||
/// `ecstore` parser in the unit tests below.
|
||||
/// Public view of the assembled `RUSTFS_VOLUMES` string, so tests can assert
|
||||
/// the pool/drive layout without starting node processes.
|
||||
pub fn rustfs_volumes_arg(&self) -> String {
|
||||
self.build_volumes_arg()
|
||||
}
|
||||
|
||||
fn build_volumes_arg(&self) -> String {
|
||||
self.nodes
|
||||
let pools = self.topology.normalized_pools();
|
||||
|
||||
if pools.len() <= 1 {
|
||||
// Single pool: explicit enumeration of every drive on every node.
|
||||
return self
|
||||
.nodes
|
||||
.iter()
|
||||
.flat_map(|n| n.data_dirs.iter().map(move |dir| format!("http://{}{}", n.address, dir)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
// Multi-pool: one ellipses argument per single-node pool. The drive
|
||||
// directories are `<temp>/node{i}/drive{0..N-1}`, so the ellipses base is
|
||||
// the shared parent of the node's drives.
|
||||
pools
|
||||
.iter()
|
||||
.map(|n| format!("http://{}{}", n.address, n.data_dir))
|
||||
.map(|nodes| {
|
||||
let node = &self.nodes[nodes[0]];
|
||||
let base = node
|
||||
.data_dirs
|
||||
.first()
|
||||
.and_then(|d| d.rsplit_once('/').map(|(parent, _)| parent))
|
||||
.unwrap_or(&node.data_dir);
|
||||
format!("http://{}{}/drive{{0...{}}}", node.address, base, self.topology.drives_per_node - 1)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
@@ -1091,4 +1328,130 @@ mod tests {
|
||||
|
||||
stdfs::remove_file(stamp_path).ok();
|
||||
}
|
||||
|
||||
/// Build a cluster environment struct in-memory (no ports, no processes) so
|
||||
/// that `build_volumes_arg` can be exercised as a pure string builder. Node
|
||||
/// directories mirror what `with_topology` would create for the topology.
|
||||
fn fake_cluster(topology: ClusterTopology) -> RustFSTestClusterEnvironment {
|
||||
let temp_dir = "/tmp/rustfs_cluster_test_FAKE".to_string();
|
||||
let pools = topology.normalized_pools();
|
||||
let mut pool_of_node = vec![0usize; topology.node_count];
|
||||
for (pool_idx, nodes) in pools.iter().enumerate() {
|
||||
for &n in nodes {
|
||||
pool_of_node[n] = pool_idx;
|
||||
}
|
||||
}
|
||||
let multidrive = topology.drives_per_node > 1;
|
||||
|
||||
let nodes = (0..topology.node_count)
|
||||
.map(|i| {
|
||||
let address = format!("127.0.0.1:{}", 9000 + i);
|
||||
let data_dirs: Vec<String> = if multidrive {
|
||||
(0..topology.drives_per_node)
|
||||
.map(|d| format!("{}/node{}/drive{}", temp_dir, i, d))
|
||||
.collect()
|
||||
} else {
|
||||
vec![format!("{}/node{}", temp_dir, i)]
|
||||
};
|
||||
ClusterNode {
|
||||
url: format!("http://{}", address),
|
||||
address,
|
||||
data_dir: data_dirs[0].clone(),
|
||||
data_dirs,
|
||||
pool_idx: pool_of_node[i],
|
||||
process: None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
RustFSTestClusterEnvironment {
|
||||
nodes,
|
||||
temp_dir,
|
||||
access_key: DEFAULT_ACCESS_KEY.to_string(),
|
||||
secret_key: DEFAULT_SECRET_KEY.to_string(),
|
||||
extra_env: Vec::new(),
|
||||
topology,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn volumes_single_pool_single_drive_is_backward_compatible() {
|
||||
// The historical layout: one explicit endpoint per node, space-joined,
|
||||
// no ellipses, no `drive` sub-directory.
|
||||
let env = fake_cluster(ClusterTopology::single_pool(4));
|
||||
assert_eq!(
|
||||
env.build_volumes_arg(),
|
||||
"http://127.0.0.1:9000/tmp/rustfs_cluster_test_FAKE/node0 \
|
||||
http://127.0.0.1:9001/tmp/rustfs_cluster_test_FAKE/node1 \
|
||||
http://127.0.0.1:9002/tmp/rustfs_cluster_test_FAKE/node2 \
|
||||
http://127.0.0.1:9003/tmp/rustfs_cluster_test_FAKE/node3"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn volumes_single_pool_multidrive_enumerates_every_drive() {
|
||||
// 4 nodes x 2 drives -> 8 explicit endpoints, one legacy pool. No
|
||||
// ellipses, so the server parser keeps this as a single DistErasure pool.
|
||||
let env = fake_cluster(ClusterTopology::single_pool_multidrive(4, 2));
|
||||
let expected = (0..4)
|
||||
.flat_map(|i| {
|
||||
(0..2).map(move |d| format!("http://127.0.0.1:{}/tmp/rustfs_cluster_test_FAKE/node{}/drive{}", 9000 + i, i, d))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
assert_eq!(env.build_volumes_arg(), expected);
|
||||
assert_eq!(env.build_volumes_arg().split(' ').count(), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn volumes_two_pool_uses_one_ellipses_arg_per_pool() {
|
||||
// Two single-node pools, 2 drives each -> two ellipses arguments. The
|
||||
// server parser treats each space-separated ellipses arg as its own pool.
|
||||
let env = fake_cluster(ClusterTopology::per_node_pools(2, vec![vec![0], vec![1]]));
|
||||
assert_eq!(
|
||||
env.build_volumes_arg(),
|
||||
"http://127.0.0.1:9000/tmp/rustfs_cluster_test_FAKE/node0/drive{0...1} \
|
||||
http://127.0.0.1:9001/tmp/rustfs_cluster_test_FAKE/node1/drive{0...1}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topology_rejects_multi_node_pool() {
|
||||
// A pool striped across two localhost nodes is not expressible.
|
||||
let err = ClusterTopology::per_node_pools(2, vec![vec![0, 1], vec![2, 3]])
|
||||
.validate()
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("not expressible"), "unexpected error: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topology_rejects_single_drive_multi_pool() {
|
||||
// The server parser rejects a single-drive ellipses pool (`drive{0...0}`).
|
||||
let err = ClusterTopology::per_node_pools(1, vec![vec![0], vec![1]])
|
||||
.validate()
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("drives_per_node >= 2"), "unexpected error: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topology_rejects_unassigned_and_duplicated_nodes() {
|
||||
// Node 2 is never assigned to a pool.
|
||||
let mut t = ClusterTopology::single_pool_multidrive(3, 2);
|
||||
t.pools = vec![vec![0], vec![1]];
|
||||
assert!(t.validate().unwrap_err().to_string().contains("not assigned"));
|
||||
|
||||
// Node 0 assigned twice.
|
||||
let mut t = ClusterTopology::single_pool_multidrive(2, 2);
|
||||
t.pools = vec![vec![0], vec![0]];
|
||||
assert!(t.validate().unwrap_err().to_string().contains("more than one pool"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topology_single_pool_accepts_any_drive_count() {
|
||||
assert!(ClusterTopology::single_pool(4).validate().is_ok());
|
||||
assert!(ClusterTopology::single_pool_multidrive(4, 4).validate().is_ok());
|
||||
assert!(ClusterTopology::single_pool_multidrive(1, 1).validate().is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,6 +151,10 @@ mod object_lock;
|
||||
#[cfg(test)]
|
||||
mod cluster_concurrency_test;
|
||||
|
||||
// Multi-drive (drivesPerNode) and 2-pool cluster harness smoke tests
|
||||
#[cfg(test)]
|
||||
mod cluster_multidrive_pool_test;
|
||||
|
||||
// PutObject / MultipartUpload with checksum (Content-MD5, x-amz-checksum-*)
|
||||
#[cfg(test)]
|
||||
mod checksum_upload_test;
|
||||
|
||||
Reference in New Issue
Block a user