mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-22 20:36:38 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a2e6f1977d |
@@ -21,6 +21,9 @@
|
||||
# suite and reports promotion candidates. Regressions, unclassified tests,
|
||||
# incomplete execution, and infrastructure errors fail the job; classified
|
||||
# failures for not-yet-implemented features remain informational.
|
||||
# - Non-blocking upstream HEAD canary: collects current upstream node IDs and
|
||||
# reports new, removed, duplicate, or overlapping classifications without
|
||||
# making upstream drift a release gate.
|
||||
# - Manual runs (workflow_dispatch): same, with configurable mode/scope.
|
||||
#
|
||||
# All test execution is delegated to scripts/s3-tests/run.sh (single source of
|
||||
@@ -354,6 +357,85 @@ jobs:
|
||||
name: s3tests-${{ env.TEST_MODE }}-shard-${{ matrix.shard-index }}
|
||||
path: artifacts/**
|
||||
|
||||
upstream-head-canary:
|
||||
name: Upstream HEAD classification canary
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
continue-on-error: true
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install collection tool
|
||||
run: |
|
||||
python3 -m pip install --user "tox==4.60.0"
|
||||
python3 - <<'PY'
|
||||
from importlib.metadata import version
|
||||
|
||||
assert version("tox") == "4.60.0"
|
||||
PY
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Compare upstream HEAD classifications
|
||||
id: upstream-compare
|
||||
run: |
|
||||
ARTIFACT_DIR="artifacts/s3tests-upstream-head"
|
||||
UPSTREAM_DIR="${RUNNER_TEMP}/s3-tests-upstream"
|
||||
mkdir -p "${ARTIFACT_DIR}"
|
||||
git clone --depth 1 https://github.com/ceph/s3-tests.git "${UPSTREAM_DIR}"
|
||||
git -C "${UPSTREAM_DIR}" rev-parse HEAD > "${ARTIFACT_DIR}/upstream-sha.txt"
|
||||
cp "${UPSTREAM_DIR}/s3tests.conf.SAMPLE" "${UPSTREAM_DIR}/s3tests.conf"
|
||||
(
|
||||
cd "${UPSTREAM_DIR}"
|
||||
S3TEST_CONF="${UPSTREAM_DIR}/s3tests.conf" tox -- \
|
||||
-q --collect-only s3tests/functional/test_s3.py \
|
||||
-m "not rustfs_never_marker"
|
||||
) 2>&1 | tee "${ARTIFACT_DIR}/collect.log"
|
||||
grep -E '^s3tests/functional/test_s3\.py::' \
|
||||
"${ARTIFACT_DIR}/collect.log" > "${ARTIFACT_DIR}/collected-nodeids.txt"
|
||||
python3 scripts/s3-tests/report_compat.py \
|
||||
--lists-dir scripts/s3-tests \
|
||||
--collected-nodeids "${ARTIFACT_DIR}/collected-nodeids.txt" \
|
||||
--check-classifications-only 2>&1 | tee "${ARTIFACT_DIR}/classification-drift.txt"
|
||||
|
||||
- name: Publish canary report
|
||||
if: always()
|
||||
env:
|
||||
CANARY_OUTCOME: ${{ steps.upstream-compare.outcome }}
|
||||
run: |
|
||||
{
|
||||
echo "## ceph/s3-tests upstream HEAD canary"
|
||||
echo
|
||||
if [ -f artifacts/s3tests-upstream-head/upstream-sha.txt ]; then
|
||||
echo "Upstream HEAD: $(cat artifacts/s3tests-upstream-head/upstream-sha.txt)"
|
||||
fi
|
||||
echo
|
||||
echo '```text'
|
||||
if [ -s artifacts/s3tests-upstream-head/classification-drift.txt ]; then
|
||||
cat artifacts/s3tests-upstream-head/classification-drift.txt
|
||||
elif [ "${CANARY_OUTCOME}" != "success" ]; then
|
||||
echo "Canary did not complete; inspect the collection log artifact."
|
||||
else
|
||||
echo "No classification drift detected."
|
||||
fi
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload canary artifacts
|
||||
if: always() && env.ACT != 'true'
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: s3tests-upstream-head
|
||||
path: artifacts/s3tests-upstream-head/**
|
||||
retention-days: 14
|
||||
|
||||
alert-on-failure:
|
||||
name: Alert on scheduled failure
|
||||
needs: [s3tests]
|
||||
|
||||
@@ -30,8 +30,7 @@ make build-docker BUILD_OS=ubuntu22.04
|
||||
- Crate membership: `Cargo.toml` `[workspace].members`
|
||||
- Architecture, layering, crate map: [ARCHITECTURE.md](ARCHITECTURE.md)
|
||||
- Migration guardrails & readiness contracts: [docs/architecture/](docs/architecture/README.md)
|
||||
- CI workflow steps: `.github/workflows/`; event, timeout, and required-status
|
||||
matrix: [docs/testing/ci-gates.md](docs/testing/ci-gates.md)
|
||||
- CI gates: `.github/workflows/ci.yml` (source of truth; never copy its steps into docs)
|
||||
- Test-layer taxonomy, per-layer entry commands, serial/nextest rules, flake
|
||||
policy: [docs/testing/README.md](docs/testing/README.md)
|
||||
- Tier/ILM transition debugging (xl.meta inspection, versionId tracing):
|
||||
|
||||
@@ -70,8 +70,6 @@ make pre-pr
|
||||
|
||||
> For the full test-layer taxonomy (unit / ecstore black-box / e2e / s3s-e2e / S3 compatibility / chaos / fuzz / bench), each layer's entry command, the naming conventions the migration gate depends on, and the serial/nextest rules, see [docs/testing/README.md](docs/testing/README.md).
|
||||
|
||||
> For the event, timeout, required-status, and local reproduction matrix, see [docs/testing/ci-gates.md](docs/testing/ci-gates.md).
|
||||
|
||||
### 🔒 Automated Pre-commit Hooks
|
||||
#### What `make pre-commit` and `make pre-pr` actually run
|
||||
|
||||
|
||||
Generated
-1
@@ -12688,7 +12688,6 @@ dependencies = [
|
||||
"js-sys",
|
||||
"rand 0.10.2",
|
||||
"serde_core",
|
||||
"sha1_smol",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
|
||||
@@ -2026,7 +2026,7 @@ impl PoolMeta {
|
||||
self.load_no_lock(pool).await
|
||||
}
|
||||
|
||||
pub(crate) async fn load_no_lock<S>(&mut self, pool: Arc<S>) -> Result<()>
|
||||
async fn load_no_lock<S>(&mut self, pool: Arc<S>) -> Result<()>
|
||||
where
|
||||
S: EcstoreObjectIO,
|
||||
{
|
||||
|
||||
@@ -988,11 +988,14 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for Sets {
|
||||
}
|
||||
}
|
||||
|
||||
impl Sets {
|
||||
pub(crate) async fn heal_format_with_fence<F>(&self, dry_run: bool, fence_lost: F) -> Result<(HealResultItem, Option<Error>)>
|
||||
where
|
||||
F: Fn() -> bool + Send + Sync,
|
||||
{
|
||||
#[async_trait::async_trait]
|
||||
impl crate::storage_api_contracts::heal::HealOperations for Sets {
|
||||
type Error = Error;
|
||||
type HealResultItem = HealResultItem;
|
||||
type HealOptions = HealOpts;
|
||||
|
||||
#[tracing::instrument(skip(self))]
|
||||
async fn heal_format(&self, dry_run: bool) -> Result<(HealResultItem, Option<Error>)> {
|
||||
let (disks, init_errs) = init_storage_disks_with_errors(
|
||||
&self.endpoints.endpoints,
|
||||
&DiskOption {
|
||||
@@ -1065,9 +1068,6 @@ impl Sets {
|
||||
// Save new formats `format.json` on unformatted disks.
|
||||
for (index, (fm, disk)) in tmp_new_formats.iter_mut().zip(disks.iter()).enumerate() {
|
||||
if fm.is_some() && disk.is_some() {
|
||||
if fence_lost() {
|
||||
return Ok((res, Some(StorageError::SlowDown)));
|
||||
}
|
||||
if let Err(err) = save_format_file(disk, fm).await {
|
||||
if let Some(disk) = disk.as_ref() {
|
||||
let _ = disk.close().await;
|
||||
@@ -1101,18 +1101,6 @@ impl Sets {
|
||||
}
|
||||
Ok((res, None))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl crate::storage_api_contracts::heal::HealOperations for Sets {
|
||||
type Error = Error;
|
||||
type HealResultItem = HealResultItem;
|
||||
type HealOptions = HealOpts;
|
||||
|
||||
#[tracing::instrument(skip(self))]
|
||||
async fn heal_format(&self, dry_run: bool) -> Result<(HealResultItem, Option<Error>)> {
|
||||
self.heal_format_with_fence(dry_run, || false).await
|
||||
}
|
||||
#[tracing::instrument(skip(self))]
|
||||
async fn heal_bucket(&self, bucket: &str, opts: &HealOpts) -> Result<HealResultItem> {
|
||||
let mut result = HealResultItem {
|
||||
|
||||
@@ -13,12 +13,7 @@
|
||||
// limitations under the License.
|
||||
|
||||
use super::*;
|
||||
use crate::core::pools::POOL_META_NAME;
|
||||
use crate::services::rebalance::{REBAL_META_NAME, RebalStatus};
|
||||
use crate::set_disk::get_lock_acquire_timeout;
|
||||
use crate::storage_api_contracts::heal::HealOperations as _;
|
||||
use crate::storage_api_contracts::namespace::NamespaceLocking as _;
|
||||
use rustfs_lock::NamespaceLockGuard;
|
||||
use tracing::trace;
|
||||
|
||||
const LOG_COMPONENT_ECSTORE: &str = "ecstore";
|
||||
@@ -35,119 +30,7 @@ fn invalid_heal_pool_index(pool_idx: usize, pool_count: usize) -> Error {
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum HealFormatPoolSkip {
|
||||
Completed,
|
||||
Retryable,
|
||||
}
|
||||
|
||||
fn classify_heal_format_pool(
|
||||
pool_idx: usize,
|
||||
pool_cmd_line: &str,
|
||||
pool_meta: &PoolMeta,
|
||||
rebalance_meta: Option<&RebalanceMeta>,
|
||||
) -> Option<HealFormatPoolSkip> {
|
||||
let Some(pool) = pool_meta.pools.get(pool_idx) else {
|
||||
return Some(HealFormatPoolSkip::Retryable);
|
||||
};
|
||||
|
||||
if pool.id != pool_idx || pool_cmd_line.is_empty() || pool.cmd_line.is_empty() || pool.cmd_line != pool_cmd_line {
|
||||
return Some(HealFormatPoolSkip::Retryable);
|
||||
}
|
||||
|
||||
if let Some(decommission) = pool.decommission.as_ref() {
|
||||
if decommission.complete {
|
||||
return Some(HealFormatPoolSkip::Completed);
|
||||
}
|
||||
if decommission.failed || decommission.canceled || decommission.queued || pool_meta.is_suspended(pool_idx) {
|
||||
return Some(HealFormatPoolSkip::Retryable);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(meta) = rebalance_meta {
|
||||
let Some(pool_stats) = meta.pool_stats.get(pool_idx) else {
|
||||
return Some(HealFormatPoolSkip::Retryable);
|
||||
};
|
||||
if pool_stats.info.stopping || (pool_stats.participating && pool_stats.info.status == RebalStatus::Started) {
|
||||
return Some(HealFormatPoolSkip::Retryable);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn heal_format_pool_skip_error(skip: HealFormatPoolSkip) -> Error {
|
||||
match skip {
|
||||
HealFormatPoolSkip::Completed => StorageError::NoHealRequired,
|
||||
HealFormatPoolSkip::Retryable => StorageError::SlowDown,
|
||||
}
|
||||
}
|
||||
|
||||
fn heal_format_fence_lost_error() -> Error {
|
||||
StorageError::SlowDown
|
||||
}
|
||||
|
||||
impl ECStore {
|
||||
async fn acquire_heal_format_fence(
|
||||
&self,
|
||||
) -> Result<(NamespaceLockGuard, NamespaceLockGuard, PoolMeta, Option<RebalanceMeta>)> {
|
||||
let metadata_pool = self
|
||||
.pools
|
||||
.first()
|
||||
.cloned()
|
||||
.ok_or_else(|| Error::other("heal format requires at least one storage pool"))?;
|
||||
|
||||
// Metadata fence order is part of the decommission/rebalance protocol:
|
||||
// pool.bin must always be acquired before rebalance.bin.
|
||||
let pool_lock = metadata_pool.new_ns_lock(RUSTFS_META_BUCKET, POOL_META_NAME).await?;
|
||||
let pool_guard = pool_lock.get_write_lock(get_lock_acquire_timeout()).await?;
|
||||
let rebalance_lock = metadata_pool.new_ns_lock(RUSTFS_META_BUCKET, REBAL_META_NAME).await?;
|
||||
let rebalance_guard = rebalance_lock.get_write_lock(get_lock_acquire_timeout()).await?;
|
||||
|
||||
if pool_guard.is_lock_lost() || rebalance_guard.is_lock_lost() {
|
||||
return Err(heal_format_fence_lost_error());
|
||||
}
|
||||
|
||||
let mut pool_meta = PoolMeta::default();
|
||||
pool_meta.load_no_lock(metadata_pool.clone()).await?;
|
||||
if pool_meta.pools.len() != self.pools.len()
|
||||
|| pool_meta.pools.iter().enumerate().any(|(pool_idx, pool)| {
|
||||
pool.id != pool_idx || pool.cmd_line.is_empty() || pool.cmd_line != self.pools[pool_idx].endpoints.cmd_line
|
||||
})
|
||||
{
|
||||
return Err(heal_format_fence_lost_error());
|
||||
}
|
||||
|
||||
let mut rebalance_meta = RebalanceMeta::new();
|
||||
let rebalance_meta = match rebalance_meta
|
||||
.load_with_opts(
|
||||
metadata_pool,
|
||||
ObjectOptions {
|
||||
no_lock: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => Some(rebalance_meta),
|
||||
Err(Error::ConfigNotFound) => None,
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
|
||||
if rebalance_meta
|
||||
.as_ref()
|
||||
.is_some_and(|meta| meta.pool_stats.len() != self.pools.len())
|
||||
{
|
||||
return Err(heal_format_fence_lost_error());
|
||||
}
|
||||
|
||||
if pool_guard.is_lock_lost() || rebalance_guard.is_lock_lost() {
|
||||
return Err(heal_format_fence_lost_error());
|
||||
}
|
||||
|
||||
Ok((pool_guard, rebalance_guard, pool_meta, rebalance_meta))
|
||||
}
|
||||
|
||||
fn get_pools_for_heal_object(&self, opts: &HealOpts) -> Result<Vec<Arc<Sets>>> {
|
||||
match opts.pool {
|
||||
Some(pool_idx) => Ok(vec![
|
||||
@@ -169,26 +52,9 @@ impl ECStore {
|
||||
};
|
||||
|
||||
let mut count_no_heal = 0;
|
||||
let mut count_completed = 0;
|
||||
let mut first_error = None;
|
||||
for (pool_idx, pool) in self.pools.iter().enumerate() {
|
||||
let (pool_guard, rebalance_guard, pool_meta, rebalance_meta) = self.acquire_heal_format_fence().await?;
|
||||
if pool_guard.is_lock_lost() || rebalance_guard.is_lock_lost() {
|
||||
first_error.get_or_insert(heal_format_fence_lost_error());
|
||||
break;
|
||||
}
|
||||
if let Some(skip) = classify_heal_format_pool(pool_idx, &pool.endpoints.cmd_line, &pool_meta, rebalance_meta.as_ref())
|
||||
{
|
||||
if matches!(skip, HealFormatPoolSkip::Completed) {
|
||||
count_completed += 1;
|
||||
} else {
|
||||
first_error.get_or_insert(heal_format_pool_skip_error(skip));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let fence_lost = || pool_guard.is_lock_lost() || rebalance_guard.is_lock_lost();
|
||||
let (mut result, err) = pool.heal_format_with_fence(dry_run, fence_lost).await?;
|
||||
for pool in self.pools.iter() {
|
||||
let (mut result, err) = pool.heal_format(dry_run).await?;
|
||||
if let Some(err) = err {
|
||||
match err {
|
||||
StorageError::NoHealRequired => {
|
||||
@@ -203,18 +69,11 @@ impl ECStore {
|
||||
r.set_count += result.set_count;
|
||||
r.before.drives.append(&mut result.before.drives);
|
||||
r.after.drives.append(&mut result.after.drives);
|
||||
|
||||
// A lease can be lost after the final write; fail closed before
|
||||
// reporting the pool as successfully healed.
|
||||
if pool_guard.is_lock_lost() || rebalance_guard.is_lock_lost() {
|
||||
first_error.get_or_insert(heal_format_fence_lost_error());
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Some(err) = first_error {
|
||||
return Ok((r, Some(err)));
|
||||
}
|
||||
if count_no_heal + count_completed == self.pools.len() {
|
||||
if count_no_heal == self.pools.len() {
|
||||
info!(
|
||||
event = EVENT_HEAL_FORMAT_COMPLETED,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
@@ -443,7 +302,6 @@ mod tests {
|
||||
use crate::disk::{DeleteOptions, DiskOption, format::FormatV3, new_disk};
|
||||
use crate::layout::endpoints::{EndpointServerPools, Endpoints, PoolEndpoints};
|
||||
use crate::runtime::instance::InstanceContext;
|
||||
use crate::services::rebalance::{RebalanceInfo, RebalanceStats};
|
||||
use crate::storage_api_contracts::bucket::{BucketOperations, MakeBucketOptions};
|
||||
use crate::storage_api_contracts::object::{ObjectIO as _, ObjectOperations};
|
||||
use crate::store::init_format::{load_format_erasure, save_format_file};
|
||||
@@ -495,164 +353,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn pool_meta_with_decommission(info: PoolDecommissionInfo) -> PoolMeta {
|
||||
PoolMeta {
|
||||
pools: vec![PoolStatus {
|
||||
id: 0,
|
||||
cmd_line: "pool-0".to_string(),
|
||||
last_update: OffsetDateTime::UNIX_EPOCH,
|
||||
decommission: Some(info),
|
||||
}],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heal_format_pool_state_barriers_are_classified() {
|
||||
let active = pool_meta_with_decommission(PoolDecommissionInfo {
|
||||
start_time: Some(OffsetDateTime::UNIX_EPOCH),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(matches!(
|
||||
classify_heal_format_pool(0, "pool-0", &active, None),
|
||||
Some(HealFormatPoolSkip::Retryable)
|
||||
));
|
||||
|
||||
for info in [
|
||||
PoolDecommissionInfo {
|
||||
failed: true,
|
||||
..Default::default()
|
||||
},
|
||||
PoolDecommissionInfo {
|
||||
canceled: true,
|
||||
..Default::default()
|
||||
},
|
||||
] {
|
||||
assert!(matches!(
|
||||
classify_heal_format_pool(0, "pool-0", &pool_meta_with_decommission(info), None),
|
||||
Some(HealFormatPoolSkip::Retryable)
|
||||
));
|
||||
}
|
||||
|
||||
let completed = pool_meta_with_decommission(PoolDecommissionInfo {
|
||||
complete: true,
|
||||
..Default::default()
|
||||
});
|
||||
assert!(matches!(
|
||||
classify_heal_format_pool(0, "pool-0", &completed, None),
|
||||
Some(HealFormatPoolSkip::Completed)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heal_format_pool_rebalance_barriers_and_identity_are_fail_closed() {
|
||||
let identity_meta = pool_meta_with_decommission(PoolDecommissionInfo::default());
|
||||
let rebalance = RebalanceMeta {
|
||||
pool_stats: vec![RebalanceStats {
|
||||
participating: true,
|
||||
info: RebalanceInfo {
|
||||
status: RebalStatus::Started,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(matches!(
|
||||
classify_heal_format_pool(0, "pool-0", &identity_meta, Some(&rebalance)),
|
||||
Some(HealFormatPoolSkip::Retryable)
|
||||
));
|
||||
|
||||
let stopping = RebalanceMeta {
|
||||
pool_stats: vec![RebalanceStats {
|
||||
info: RebalanceInfo {
|
||||
stopping: true,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(matches!(
|
||||
classify_heal_format_pool(0, "pool-0", &identity_meta, Some(&stopping)),
|
||||
Some(HealFormatPoolSkip::Retryable)
|
||||
));
|
||||
|
||||
let identity = pool_meta_with_decommission(PoolDecommissionInfo::default());
|
||||
assert!(matches!(
|
||||
classify_heal_format_pool(0, "pool-new", &identity, None),
|
||||
Some(HealFormatPoolSkip::Retryable)
|
||||
));
|
||||
|
||||
let identity_without_decommission = PoolMeta {
|
||||
pools: vec![PoolStatus {
|
||||
id: 0,
|
||||
cmd_line: "pool-0".to_string(),
|
||||
last_update: OffsetDateTime::UNIX_EPOCH,
|
||||
decommission: None,
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(matches!(
|
||||
classify_heal_format_pool(0, "pool-new", &identity_without_decommission, None),
|
||||
Some(HealFormatPoolSkip::Retryable)
|
||||
));
|
||||
|
||||
assert!(matches!(
|
||||
classify_heal_format_pool(0, "", &identity_meta, None),
|
||||
Some(HealFormatPoolSkip::Retryable)
|
||||
));
|
||||
|
||||
assert!(matches!(
|
||||
classify_heal_format_pool(0, "pool-0", &PoolMeta::default(), None),
|
||||
Some(HealFormatPoolSkip::Retryable)
|
||||
));
|
||||
|
||||
let stopped = RebalanceMeta {
|
||||
stopped_at: Some(OffsetDateTime::UNIX_EPOCH),
|
||||
pool_stats: vec![RebalanceStats {
|
||||
participating: true,
|
||||
info: RebalanceInfo {
|
||||
status: RebalStatus::Stopped,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(classify_heal_format_pool(0, "pool-0", &identity_meta, Some(&stopped)).is_none());
|
||||
|
||||
let stopping_after_stop = RebalanceMeta {
|
||||
stopped_at: Some(OffsetDateTime::UNIX_EPOCH),
|
||||
pool_stats: vec![RebalanceStats {
|
||||
participating: true,
|
||||
info: RebalanceInfo {
|
||||
status: RebalStatus::Started,
|
||||
stopping: true,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(matches!(
|
||||
classify_heal_format_pool(0, "pool-0", &identity_meta, Some(&stopping_after_stop)),
|
||||
Some(HealFormatPoolSkip::Retryable)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skipped_heal_format_pool_is_never_reported_as_success() {
|
||||
assert!(matches!(
|
||||
heal_format_pool_skip_error(HealFormatPoolSkip::Retryable),
|
||||
StorageError::SlowDown
|
||||
));
|
||||
assert!(matches!(
|
||||
heal_format_pool_skip_error(HealFormatPoolSkip::Completed),
|
||||
StorageError::NoHealRequired
|
||||
));
|
||||
}
|
||||
|
||||
async fn multi_pool_heal_store() -> (tempfile::TempDir, Arc<ECStore>, CancellationToken) {
|
||||
let temp_dir = tempfile::tempdir().expect("multi-pool heal test directory should be created");
|
||||
let mut pool_endpoints = Vec::new();
|
||||
@@ -1189,18 +889,6 @@ mod tests {
|
||||
bucket_fence_registry: std::sync::Arc::default(),
|
||||
};
|
||||
|
||||
let err = store
|
||||
.handle_heal_format(false)
|
||||
.await
|
||||
.expect_err("missing pool metadata must fail closed before format writes");
|
||||
assert!(matches!(err, StorageError::SlowDown));
|
||||
|
||||
let pool_meta = PoolMeta::new(&store.pools, &PoolMeta::default());
|
||||
pool_meta
|
||||
.save(store.pools.clone())
|
||||
.await
|
||||
.expect("pool metadata should be persisted before format heal");
|
||||
|
||||
let (result, err) = store
|
||||
.handle_heal_format(false)
|
||||
.await
|
||||
@@ -1214,22 +902,5 @@ mod tests {
|
||||
.await
|
||||
.expect("the later pool should be healed despite the first pool error");
|
||||
assert_eq!(healed.erasure.this, recoverable_format.erasure.sets[0][2]);
|
||||
|
||||
let mut completed_meta = PoolMeta::new(&store.pools, &PoolMeta::default());
|
||||
for status in &mut completed_meta.pools {
|
||||
status.decommission = Some(PoolDecommissionInfo {
|
||||
complete: true,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
completed_meta
|
||||
.save(store.pools.clone())
|
||||
.await
|
||||
.expect("completed pool metadata should be persisted");
|
||||
let (_, err) = store
|
||||
.handle_heal_format(false)
|
||||
.await
|
||||
.expect("completed pools should be reported as a no-op");
|
||||
assert!(matches!(err, Some(StorageError::NoHealRequired)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,10 +231,6 @@ impl HealTask {
|
||||
"Heal erasure set format repair skipped because no format heal was required"
|
||||
);
|
||||
} else {
|
||||
let error = e;
|
||||
if error.is_recoverable_heal() {
|
||||
return Err(error);
|
||||
}
|
||||
error!(
|
||||
target: "rustfs::heal::task",
|
||||
event = EVENT_HEAL_ERASURE_SET_RESULT,
|
||||
@@ -243,7 +239,7 @@ impl HealTask {
|
||||
task_id = %self.id,
|
||||
set_disk_id,
|
||||
result = "format_failed",
|
||||
error = %error,
|
||||
error = %e,
|
||||
"Heal erasure set failed"
|
||||
);
|
||||
{
|
||||
@@ -251,7 +247,7 @@ impl HealTask {
|
||||
progress.update_progress(4, 4, 0, 0);
|
||||
}
|
||||
return Err(Error::TaskExecutionFailed {
|
||||
message: format!("Failed to heal disk format for {set_disk_id}: {error}"),
|
||||
message: format!("Failed to heal disk format for {set_disk_id}: {e}"),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@@ -288,9 +284,6 @@ impl HealTask {
|
||||
Err(Error::TaskCancelled) => return Err(Error::TaskCancelled),
|
||||
Err(Error::TaskTimeout) => return Err(Error::TaskTimeout),
|
||||
Err(e) => {
|
||||
if e.is_recoverable_heal() {
|
||||
return Err(e);
|
||||
}
|
||||
error!(
|
||||
target: "rustfs::heal::task",
|
||||
event = EVENT_HEAL_ERASURE_SET_RESULT,
|
||||
|
||||
@@ -547,7 +547,6 @@ struct MockStorage {
|
||||
heal_object_outcome: Mutex<Option<MockHealObjectOutcome>>,
|
||||
heal_object_outcomes: Mutex<HashMap<String, VecDeque<MockHealObjectOutcome>>>,
|
||||
format_no_heal_required: Mutex<bool>,
|
||||
format_error: Mutex<Option<Error>>,
|
||||
global_format_calls: Mutex<u32>,
|
||||
replacement_format_calls: Mutex<Vec<(usize, usize, Vec<String>)>>,
|
||||
replacement_targets_ready: Mutex<bool>,
|
||||
@@ -868,9 +867,6 @@ impl HealStorageAPI for MockStorage {
|
||||
|
||||
async fn heal_format(&self, _dry_run: bool) -> Result<(HealResultItem, Option<Error>)> {
|
||||
*self.global_format_calls.lock().unwrap() += 1;
|
||||
if let Some(error) = self.format_error.lock().unwrap().take() {
|
||||
return Err(error);
|
||||
}
|
||||
let no_heal_required = *self.format_no_heal_required.lock().unwrap();
|
||||
if no_heal_required {
|
||||
Ok((HealResultItem::default(), Some(Error::Storage(EcstoreError::NoHealRequired))))
|
||||
@@ -2056,30 +2052,6 @@ async fn test_erasure_set_heal_continues_after_format_no_heal_required() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn erasure_set_format_slowdown_is_propagated() {
|
||||
let storage = Arc::new(MockStorage {
|
||||
format_error: Mutex::new(Some(Error::Storage(EcstoreError::SlowDown))),
|
||||
..Default::default()
|
||||
});
|
||||
let request = HealRequest::new(
|
||||
HealType::ErasureSet {
|
||||
buckets: Vec::new(),
|
||||
set_disk_id: "pool_0_set_0".to_string(),
|
||||
},
|
||||
HealOptions::default(),
|
||||
HealPriority::Normal,
|
||||
);
|
||||
let task = HealTask::from_request(request, storage);
|
||||
|
||||
let error = task
|
||||
.execute()
|
||||
.await
|
||||
.expect_err("format SlowDown must remain recoverable for the task manager");
|
||||
|
||||
assert!(matches!(error, Error::Storage(EcstoreError::SlowDown)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn erasure_set_bucket_prepass_failure_stops_before_object_heal() {
|
||||
let temp = TempDir::new().expect("temporary directory should be created");
|
||||
|
||||
@@ -245,18 +245,6 @@ impl TestECStoreEnvBuilder {
|
||||
.await
|
||||
.expect("build test ECStore");
|
||||
|
||||
// The production bootstrap only persists pool.bin from the elected
|
||||
// first cluster node. Test stores intentionally have no cluster
|
||||
// election, but heal-format still requires that durable fence before
|
||||
// it can write any disk format. Materialize the validated topology
|
||||
// here so the shared fixture models a ready single-node store.
|
||||
let mut pool_meta = ecstore.pool_meta.read().await.clone();
|
||||
pool_meta.dont_save = false;
|
||||
pool_meta
|
||||
.save(ecstore.pools.clone())
|
||||
.await
|
||||
.expect("persist test pool metadata");
|
||||
|
||||
if self.init_bucket_metadata {
|
||||
let buckets_list = ecstore
|
||||
.list_bucket(&BucketOptions {
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
# CI gate matrix
|
||||
|
||||
This file is the source of truth for which validation runs on each event, its
|
||||
configured wall-clock budget, and whether it can block a merge. Test taxonomy,
|
||||
naming, and nextest serialization rules remain in [README.md](README.md); e2e
|
||||
membership and counts remain in
|
||||
[e2e-suite-inventory.md](e2e-suite-inventory.md).
|
||||
|
||||
The distinction between **required** and **report-only** is load-bearing:
|
||||
a failing job blocks a merge only when its exact check name is present in the
|
||||
live `main` ruleset. A workflow name, a `merge_group` trigger, or a red PR check
|
||||
does not make a job required by itself.
|
||||
|
||||
## Required merge checks
|
||||
|
||||
The live `main` ruleset (`6436880`) currently requires exactly these contexts:
|
||||
|
||||
| Required context | Producer | Validation |
|
||||
|---|---|---|
|
||||
| `CLA Check` | `.github/workflows/cla.yml` | Contributor agreement |
|
||||
| `Quick Checks` | `.github/workflows/ci.yml` | Formatting and repository guard scripts |
|
||||
| `Test and Lint` | `.github/workflows/ci.yml` | Clippy, workspace nextest excluding `e2e_test`, doctests, and migration proofs |
|
||||
|
||||
For pull requests limited to the paths excluded by the main CI workflow,
|
||||
`.github/workflows/ci-docs-only.yml` reports `Quick Checks` and
|
||||
`Test and Lint` under the same names. It runs the real quick checks and the
|
||||
planning-document guard; it does not claim that Rust compilation or runtime
|
||||
tests ran. Despite the workflow name, these paths also include selected deploy,
|
||||
workflow, and lock files.
|
||||
|
||||
Verify the live rule rather than trusting this snapshot before changing merge
|
||||
policy:
|
||||
|
||||
```bash
|
||||
gh api repos/rustfs/rustfs/rulesets/6436880 \
|
||||
--jq '.rules[] | select(.type == "required_status_checks") | .parameters'
|
||||
```
|
||||
|
||||
The ruleset currently has `strict_required_status_checks_policy=false`.
|
||||
`Continuous Integration` accepts `merge_group` events and runs `e2e-full` for
|
||||
them, but `End-to-End Tests (full merge gate)` is not currently a required
|
||||
context. Therefore the repository is prepared to test a merge-queue SHA, but
|
||||
the workflow alone does not prove that every merge passed that lane.
|
||||
|
||||
## Pull request and merge matrix
|
||||
|
||||
Budgets below are job `timeout-minutes`, not typical runtimes. “Report-only”
|
||||
means the result is visible and actionable but is not in the live required
|
||||
context list.
|
||||
|
||||
| Event | Validation | Budget | Merge status | Reproduction |
|
||||
|---|---|---:|---|---|
|
||||
| PR, non-doc change | `Quick Checks` | 10 min | Required | `make pre-commit` (broader local umbrella) |
|
||||
| PR, non-doc change | `Test and Lint` | 90 min | Required | `cargo nextest run --profile ci --all --exclude e2e_test` |
|
||||
| PR, non-doc change | `Typos` | 10 min | Report-only | `typos` |
|
||||
| PR, non-doc change | `ILM Integration (serial)` | 90 min | Report-only | Use the exact command in `.github/workflows/ci.yml` |
|
||||
| PR, non-doc change | rio-v2 / swift / sftp test-and-lint variants | 90 min each | Report-only | `cargo nextest run` with the workflow's feature set |
|
||||
| PR, non-doc change | `Build RustFS Debug Binary` | 30 min | Report-only; prerequisite for black-box lanes | `cargo build -p rustfs --bins` |
|
||||
| PR, non-doc change | `io_uring Integration (real)` | 30 min | Report-only | `cargo test -p rustfs-ecstore --lib uring_ -- --test-threads=1 --nocapture` |
|
||||
| PR, non-doc change | `End-to-End Tests` (`e2e-smoke` plus `s3s-e2e`) | 30 min | Report-only | `cargo nextest run --profile e2e-smoke -p e2e_test`; then `./scripts/e2e-run.sh ./target/debug/rustfs <data-dir>` |
|
||||
| PR, non-doc change | `S3 Implemented Tests` | 60 min | Report-only | Build `rustfs`, then run `scripts/s3-tests/run.sh` with `DEPLOY_MODE=binary`, `TEST_MODE=single`, and `MAXFAIL=0` |
|
||||
| PR, non-doc change | `S3 Lifecycle Behavior Tests` | 30 min | Report-only | Use the accelerated scanner environment in `.github/workflows/ci.yml` with `scripts/s3-tests/run.sh` |
|
||||
| PR touching dependency or workflow inputs | Cargo Deny / Workflow Pin Report / Dependency Review | 20 / 5 / 30 min | Report-only | `cargo deny check`; `scripts/security/check_workflow_pins.sh` |
|
||||
| PR touching architecture rules or architecture docs | `Architecture Migration Rules` | 10 min | Report-only | `scripts/check_architecture_migration_rules.sh` |
|
||||
| PR touching Nix or workspace manifests | `Nix Build & Check` | 60 min | Report-only | `nix flake check` |
|
||||
| PR limited to main-CI-excluded paths | companion `Quick Checks` and `Test and Lint` | 10 min each | Required | `git diff --check`; `make doc-paths-check` when documentation paths changed |
|
||||
| `merge_group` | Standard CI plus `e2e-full` | 55 min for `e2e-full` | Standard required contexts only; `e2e-full` report-only | `cargo nextest run --profile e2e-full -p e2e_test` |
|
||||
| Push to `main` | Standard CI plus `e2e-full` | 55 min for `e2e-full` | Post-merge detection | Same as `merge_group` |
|
||||
| PR touching fuzz inputs or harness paths | Build plus five 60-second fuzz smoke targets | 60 min build; 30 min per target | Report-only | `MAX_TOTAL_TIME=60 ./scripts/fuzz/run.sh` |
|
||||
| PR touching selected ecstore disk/format paths | `Rename Safety` on Windows | 60 min | Report-only | Run the four `cargo test -p rustfs-ecstore --lib <filter>` commands in `windows-filesystem.yml` on Windows |
|
||||
|
||||
The authoritative e2e filters live in `.config/nextest.toml`; extend a profile
|
||||
instead of adding a second ad-hoc selector. Before a profile runs,
|
||||
`scripts/check_test_wiring.py` compares its exact membership to the committed
|
||||
digest so a silent test drop fails closed.
|
||||
|
||||
## Scheduled and manual validation
|
||||
|
||||
Scheduled lanes are independent fault domains. They do not block a pull
|
||||
request, but their workflow-local gate can fail the run and scheduled failures
|
||||
are routed to the shared failure-issue action. The scheduled-validation
|
||||
watchdog and freshness workflow separately detect incomplete runs and missing
|
||||
schedules.
|
||||
|
||||
| Cadence (UTC unless noted) | Workflow / validation | Budget | Verdict and artifacts | Reproduction |
|
||||
|---|---|---:|---|---|
|
||||
| Daily 02:17 | Fuzz: five nightly corpus targets | 60 min build; 60 min per target | Gate; corpus/crash artifacts, scheduled failure alert | `MAX_TOTAL_TIME=<seconds> ./scripts/fuzz/run.sh` |
|
||||
| Daily 03:17 | MinIO interop (EC + SSE read parity) | 40 min | Gate; scheduled failure alert | Dispatch `minio-interop.yml` or follow its pinned Docker fixture steps |
|
||||
| Daily 04:29 | Replication / cluster-fault / protocol e2e | 45 / 90 / 90 min | Three independent gates; JUnit, membership, and server logs | `cargo nextest run --profile e2e-repl-nightly -p e2e_test`; `--profile e2e-nightly`; `-j 1 --profile e2e-protocols` |
|
||||
| Daily 06:31 | Warp performance A/B | 180 min | Regression budget gate; A/B summaries and server logs | `bash scripts/run_hotpath_warp_abba.sh --help` |
|
||||
| Daily 00:07 Asia/Shanghai (16:07 UTC previous day) | Nightly GNU build and Vault lanes | 150 / 90 / 60 min | Build, live Vault, and HA failover gates | Use the commands and pinned Vault images in `nightly-gnu.yml` |
|
||||
| Daily 03:23 | Security Audit | 20 / 5 min, plus 30 min on PR dependency review | Cargo Deny and workflow-pin gates; scheduled failure alert | `cargo deny check`; `scripts/security/check_workflow_pins.sh` |
|
||||
| Daily 23:47 | Scheduled Validation Freshness | 10 min | Fails when a critical schedule was never created or is stale | Dispatch `scheduled-validation-freshness.yml` |
|
||||
| Sunday 00:11 | Full `Continuous Integration` matrix | Per-job budgets above | Weekly variant coverage, including dormant rio-v2 binary/e2e lanes | Dispatch `ci.yml` |
|
||||
| Sunday 01:13 | Seven-platform build matrix | 150 min per platform | Build/package integrity; scheduled failure alert | Dispatch `build.yml` with an exact platform set |
|
||||
| Sunday 02:19 | Ceph s3-tests full sweep: single and real four-node, four shards each | 180 min per shard | Compatibility gate; report, JUnit, exact node IDs, and server logs | `scripts/s3-tests/run.sh` against an existing single or distributed target |
|
||||
| Sunday 06:41 | Mint | 120 min | **Report-only by design**; per-suite PASS/FAIL/NA and raw `log.json` | Reproduce the pinned Docker sequence in `mint.yml` or dispatch it |
|
||||
| Sunday 07:43 | Workspace line coverage | 120 min | Report-only trend; lcov and JSON retained 90 days | `make coverage` |
|
||||
| Monthly, day 1 06:37 | Runner Hygiene | 15 min | Validates runner ephemerality; scheduled failure alert | Dispatch `runner-hygiene.yml` |
|
||||
|
||||
Manual `workflow_dispatch` exists for the scheduled workflows above. Manual
|
||||
runs are debugging evidence and intentionally do not open scheduled-failure
|
||||
issues. A manual performance run may explicitly allow a known regression; that
|
||||
override must not be treated as an ordinary passing baseline.
|
||||
|
||||
## Release validation
|
||||
|
||||
Release validation is post-merge and tag-driven; it does not substitute for a
|
||||
pull-request gate.
|
||||
|
||||
| Event | Validation | Budget | Result |
|
||||
|---|---|---:|---|
|
||||
| Push to `main` or weekly schedule | `Build and Release` platform matrix | 150 min per platform | Build artifacts for all selected targets; no release publication on a main push |
|
||||
| Valid release or preview tag | `Build and Release` plus asset checks | 150 min per platform | Draft release, checksummed assets, and publish step |
|
||||
| Successful non-preview release-tag build | Docker image build and image scan | 60 min build; 30 min scan | Multi-architecture images plus vulnerability report |
|
||||
| Successful release-tag build | DEB/RPM packaging | 30 min per architecture | Packages and checksum files uploaded to the release |
|
||||
| Successful non-preview release-tag build | Helm template test and package | 30 min build; 30 min publish | Versioned chart and repository index |
|
||||
|
||||
Use an exact preview tag for end-to-end release rehearsal. Manual dispatches
|
||||
are backfill/debug paths and do not prove the automatic `workflow_run` chain.
|
||||
|
||||
## Evidence requirements
|
||||
|
||||
A green check is useful only when it proves the intended behavior ran:
|
||||
|
||||
- Record the exact commit SHA and run URL.
|
||||
- Separate product failure from runner prerequisites, service readiness, and
|
||||
cancellation. Repair the precondition, then rerun the exact workload.
|
||||
- Preserve membership manifests, JUnit, raw compatibility logs, seeds, and
|
||||
server logs where the workflow provides them.
|
||||
- For a bug fix or a new fault checker, provide sensitivity evidence: the old
|
||||
behavior or an intentional mutation must fail the new oracle, and the fixed
|
||||
behavior must pass it.
|
||||
- Never promote a report-only lane to required from one green run. Require at
|
||||
least 14 days and 30 representative pull requests with at least 99% complete
|
||||
execution, then update the ruleset and this table together.
|
||||
|
||||
## Change checklist
|
||||
|
||||
Update this file in the same pull request when any of these change:
|
||||
|
||||
- workflow triggers, job names, timeouts, or nextest profile ownership;
|
||||
- required status contexts or strict/merge-queue policy;
|
||||
- scheduled cadence, alert routing, artifact contract, or local reproduction;
|
||||
- report-only versus gating semantics.
|
||||
|
||||
Do not copy per-module test counts here. Update
|
||||
[e2e-suite-inventory.md](e2e-suite-inventory.md) and its enforced membership
|
||||
digest instead.
|
||||
+2
-2
@@ -322,7 +322,7 @@ thiserror = { workspace = true }
|
||||
tracing.workspace = true
|
||||
url = { workspace = true }
|
||||
urlencoding = { workspace = true }
|
||||
uuid = { workspace = true, features = ["v4", "v5", "fast-rng", "macro-diagnostics"] }
|
||||
uuid = { workspace = true, features = ["v4", "fast-rng", "macro-diagnostics"] }
|
||||
zip = { workspace = true }
|
||||
libc = { workspace = true }
|
||||
rand = { workspace = true, features = ["serde"] }
|
||||
@@ -345,7 +345,7 @@ libsystemd.workspace = true
|
||||
libmimalloc-sys.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
uuid = { workspace = true, features = ["v4", "v5", "fast-rng", "macro-diagnostics"] }
|
||||
uuid = { workspace = true, features = ["v4", "fast-rng", "macro-diagnostics"] }
|
||||
serial_test = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
aws-config = { workspace = true }
|
||||
|
||||
@@ -41,7 +41,7 @@ use crate::admin::storage_api::config::save_admin_config;
|
||||
use crate::admin::storage_api::contract::bucket::{
|
||||
BucketOperations, BucketOptions, DeleteBucketOptions, MakeBucketOptions, SRBucketDeleteOp,
|
||||
};
|
||||
use crate::admin::storage_api::error::{Error as StorageError, is_err_bucket_not_found};
|
||||
use crate::admin::storage_api::error::Error as StorageError;
|
||||
use crate::admin::storage_api::runtime::ECStore;
|
||||
use crate::admin::utils::{encode_compatible_admin_payload, read_compatible_admin_body};
|
||||
use crate::auth::constant_time_eq;
|
||||
@@ -55,7 +55,6 @@ use crate::storage::storage_api::{
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use futures::StreamExt;
|
||||
use hmac::{Hmac, Mac};
|
||||
use http::header::{CONTENT_TYPE, HOST};
|
||||
use http::{HeaderMap, HeaderValue, Uri};
|
||||
@@ -2097,18 +2096,6 @@ async fn remote_add_preflight_info(site: &PeerSite) -> S3Result<SiteReplicationA
|
||||
format!("invalid site replication metainfo from `{}`: {e}", site.endpoint),
|
||||
)
|
||||
})?;
|
||||
if info.deployment_id.is_empty() {
|
||||
// The peer will be tracked under a locally derived fallback ID
|
||||
// (deployment_id_for_endpoint) instead of its real deployment ID.
|
||||
warn!(
|
||||
event = EVENT_ADMIN_SITE_REPLICATION_STATE,
|
||||
component = LOG_COMPONENT_ADMIN,
|
||||
subsystem = LOG_SUBSYSTEM_SITE_REPLICATION,
|
||||
result = "peer_deployment_id_missing",
|
||||
peer_endpoint = %site.endpoint,
|
||||
"admin site replication state"
|
||||
);
|
||||
}
|
||||
|
||||
let idp_body = send_peer_admin_get_request_with_client(
|
||||
&client,
|
||||
@@ -2219,30 +2206,20 @@ fn site_replication_bootstrap_token(uri: &Uri) -> Option<String> {
|
||||
query_pairs(uri).get("bootstrapToken").cloned()
|
||||
}
|
||||
|
||||
/// Query for a peer `make-with-versioning` bucket op. `versioningEnabled`
|
||||
/// always travels so the outbound query matches MinIO's site-replication
|
||||
/// make-bucket wire contract: MinIO's own create-bucket hook sends
|
||||
/// `versioningEnabled=true` on this op. RustFS's inbound handler
|
||||
/// force-enables versioning either way.
|
||||
fn make_with_versioning_bucket_op_path(bucket: &str, created_at: Option<&str>, lock_enabled: bool) -> String {
|
||||
fn bootstrap_bucket_make_op_path(bucket: &SRBucketInfo) -> String {
|
||||
let mut query = form_urlencoded::Serializer::new(String::new());
|
||||
query.append_pair("bucket", bucket);
|
||||
query.append_pair("operation", SITE_REPLICATION_BUCKET_OP_MAKE_WITH_VERSIONING);
|
||||
query.append_pair("versioningEnabled", "true");
|
||||
if let Some(created_at) = created_at {
|
||||
query.append_pair("createdAt", created_at);
|
||||
query.append_pair("bucket", &bucket.bucket);
|
||||
query.append_pair("operation", "make-with-versioning");
|
||||
if let Some(created_at) = bucket
|
||||
.created_at
|
||||
.and_then(|value| value.format(&time::format_description::well_known::Rfc3339).ok())
|
||||
{
|
||||
query.append_pair("createdAt", &created_at);
|
||||
}
|
||||
if lock_enabled {
|
||||
if bucket.object_lock_config.is_some() {
|
||||
query.append_pair("lockEnabled", "true");
|
||||
}
|
||||
format!("{SITE_REPLICATION_PEER_BUCKET_OPS_PATH}?{}", query.finish())
|
||||
}
|
||||
|
||||
fn bootstrap_bucket_make_op_path(bucket: &SRBucketInfo) -> String {
|
||||
let created_at = bucket
|
||||
.created_at
|
||||
.and_then(|value| value.format(&time::format_description::well_known::Rfc3339).ok());
|
||||
make_with_versioning_bucket_op_path(&bucket.bucket, created_at.as_deref(), bucket.object_lock_config.is_some())
|
||||
format!("/rustfs/admin/v3/site-replication/peer/bucket-ops?{}", query.finish())
|
||||
}
|
||||
|
||||
fn bootstrap_bucket_meta_item(bucket: &SRBucketInfo, item_type: &str, updated_at: Option<OffsetDateTime>) -> SRBucketMeta {
|
||||
@@ -4269,7 +4246,16 @@ async fn broadcast_site_replication_make_bucket(
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.unwrap_or_default();
|
||||
|
||||
let path = make_with_versioning_bucket_op_path(bucket, Some(&created_at), lock_enabled);
|
||||
let path = {
|
||||
let mut query = form_urlencoded::Serializer::new(String::new());
|
||||
query.append_pair("bucket", bucket);
|
||||
query.append_pair("operation", "make-with-versioning");
|
||||
query.append_pair("createdAt", &created_at);
|
||||
if lock_enabled {
|
||||
query.append_pair("lockEnabled", "true");
|
||||
}
|
||||
format!("/rustfs/admin/v3/site-replication/peer/bucket-ops?{}", query.finish())
|
||||
};
|
||||
let path = if let Some(token) = bootstrap_token {
|
||||
with_site_replication_bootstrap_token(&path, token)
|
||||
} else {
|
||||
@@ -10220,25 +10206,13 @@ impl Operation for SiteReplicationStatusHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /v3/site-replication/devnull` — peer link-check upload drain.
|
||||
/// MinIO streams multi-megabyte probe bodies here during site netperf link
|
||||
/// checks and expects an unbounded discard (its handler copies to io.Discard);
|
||||
/// buffering through the 1MB admin body cap turned any larger probe into a
|
||||
/// 400 and a false link failure. Stream and discard instead — no size cap.
|
||||
async fn drain_site_replication_devnull(mut input: Body) -> S3Result<()> {
|
||||
while let Some(chunk) = input.next().await {
|
||||
chunk.map_err(|e| s3_error!(InvalidRequest, "failed to read devnull stream: {}", e))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub struct SiteReplicationDevNullHandler {}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Operation for SiteReplicationDevNullHandler {
|
||||
async fn call(&self, req: S3Request<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||
validate_site_replication_admin_request(&req, AdminAction::SiteReplicationOperationAction).await?;
|
||||
drain_site_replication_devnull(req.input).await?;
|
||||
let _ = read_plain_admin_body(req.input).await?;
|
||||
Ok(empty_response(StatusCode::NO_CONTENT))
|
||||
}
|
||||
}
|
||||
@@ -10497,19 +10471,6 @@ impl Operation for SRPeerJoinHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/// Outcome of a peer-driven `purge-deleted-bucket` replay. A bucket that is
|
||||
/// already gone means the purge raced an earlier replay or a local delete —
|
||||
/// that is success — but any other failure must reach the sender like the
|
||||
/// sibling delete branches do: swallowing it answered 200 while the bucket
|
||||
/// survived on this site.
|
||||
fn purge_deleted_bucket_result(result: Result<(), StorageError>) -> S3Result<()> {
|
||||
match result {
|
||||
Ok(()) => Ok(()),
|
||||
Err(err) if is_err_bucket_not_found(&err) => Ok(()),
|
||||
Err(err) => Err(ApiError::from(err).into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SRPeerBucketOpsHandler {}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
@@ -10609,18 +10570,16 @@ impl Operation for SRPeerBucketOpsHandler {
|
||||
.map_err(ApiError::from)?;
|
||||
}
|
||||
"purge-deleted-bucket" => {
|
||||
purge_deleted_bucket_result(
|
||||
store
|
||||
.delete_bucket(
|
||||
&bucket,
|
||||
&DeleteBucketOptions {
|
||||
force: true,
|
||||
srdelete_op: SRBucketDeleteOp::Purge,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await,
|
||||
)?;
|
||||
let _ = store
|
||||
.delete_bucket(
|
||||
&bucket,
|
||||
&DeleteBucketOptions {
|
||||
force: true,
|
||||
srdelete_op: SRBucketDeleteOp::Purge,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
_ => return Err(s3_error!(InvalidRequest, "unsupported site replication bucket operation")),
|
||||
}
|
||||
@@ -13966,54 +13925,6 @@ mod tests {
|
||||
assert!(!query_flag(&uri, "missing"));
|
||||
}
|
||||
|
||||
/// A5 red-light: a `purge-deleted-bucket` replay must report success when
|
||||
/// the bucket is already gone, and must propagate every other failure —
|
||||
/// the swallowed error answered 200 while the bucket survived.
|
||||
#[test]
|
||||
fn test_purge_deleted_bucket_result_tolerates_only_missing_bucket() {
|
||||
assert!(purge_deleted_bucket_result(Ok(())).is_ok());
|
||||
assert!(purge_deleted_bucket_result(Err(StorageError::BucketNotFound("photos".to_string()))).is_ok());
|
||||
assert!(purge_deleted_bucket_result(Err(StorageError::VolumeNotFound)).is_ok());
|
||||
let err = purge_deleted_bucket_result(Err(StorageError::StorageFull))
|
||||
.expect_err("non-not-found delete failures must propagate");
|
||||
assert_ne!(*err.code(), S3ErrorCode::NoSuchBucket);
|
||||
}
|
||||
|
||||
/// C5 red-light: the site-replication devnull drain must accept bodies
|
||||
/// beyond the 1MB admin body cap — MinIO's link check streams large
|
||||
/// probe bodies and treats a 400 as a broken link.
|
||||
#[tokio::test]
|
||||
async fn test_site_replication_devnull_drains_body_beyond_admin_cap() {
|
||||
let body = Body::from(vec![0u8; MAX_ADMIN_REQUEST_BODY_SIZE + 1]);
|
||||
drain_site_replication_devnull(body)
|
||||
.await
|
||||
.expect("devnull must drain bodies larger than the admin body cap");
|
||||
}
|
||||
|
||||
/// A3 red-light: `versioningEnabled` must travel on every outbound
|
||||
/// make-with-versioning bucket op so the query matches MinIO's
|
||||
/// site-replication make-bucket wire contract (MinIO's own hook sends
|
||||
/// `versioningEnabled=true` on this op).
|
||||
#[test]
|
||||
fn test_make_with_versioning_op_paths_send_versioning_enabled() {
|
||||
let bucket = SRBucketInfo {
|
||||
bucket: "photos".to_string(),
|
||||
created_at: Some(OffsetDateTime::UNIX_EPOCH),
|
||||
object_lock_config: Some(BASE64_STANDARD.encode("<ObjectLockConfiguration/>")),
|
||||
..Default::default()
|
||||
};
|
||||
let bootstrap = bootstrap_bucket_make_op_path(&bucket);
|
||||
assert!(bootstrap.contains("operation=make-with-versioning"), "{bootstrap}");
|
||||
assert!(bootstrap.contains("versioningEnabled=true"), "{bootstrap}");
|
||||
assert!(bootstrap.contains("createdAt="), "{bootstrap}");
|
||||
assert!(bootstrap.contains("lockEnabled=true"), "{bootstrap}");
|
||||
|
||||
// The broadcast path (create-bucket hook) shares the same builder.
|
||||
let broadcast = make_with_versioning_bucket_op_path("photos", Some("1970-01-01T00:00:00Z"), false);
|
||||
assert!(broadcast.contains("versioningEnabled=true"), "{broadcast}");
|
||||
assert!(!broadcast.contains("lockEnabled"), "{broadcast}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_add_bootstrap_scope_only_allows_expected_bucket_setup_until_guard_drops() {
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
// limitations under the License.
|
||||
|
||||
use rustfs_madmin::{PeerInfo, SyncStatus};
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, hash_map::DefaultHasher};
|
||||
use std::hash::{Hash, Hasher};
|
||||
use url::Url;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn has_http_scheme(endpoint: &str) -> bool {
|
||||
endpoint.get(..7).is_some_and(|prefix| prefix.eq_ignore_ascii_case("http://"))
|
||||
@@ -66,12 +66,10 @@ pub fn site_identity_key(endpoint: &str) -> String {
|
||||
.unwrap_or_else(|| trimmed.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
/// Fallback deployment ID for a peer that reported none. UUIDv5 over the
|
||||
/// canonical endpoint: the ID is persisted in site-replication state and
|
||||
/// broadcast to peers, so it must be identical across Rust toolchains
|
||||
/// (`DefaultHasher` is not) and across spellings of the same endpoint.
|
||||
pub fn deployment_id_for_endpoint(endpoint: &str) -> String {
|
||||
Uuid::new_v5(&Uuid::NAMESPACE_URL, canonical_endpoint(endpoint).as_bytes()).to_string()
|
||||
let mut hasher = DefaultHasher::new();
|
||||
endpoint.hash(&mut hasher);
|
||||
format!("{:016x}", hasher.finish())
|
||||
}
|
||||
|
||||
pub fn same_identity_endpoint(left: &str, right: &str) -> bool {
|
||||
@@ -176,23 +174,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// B8 red-light: the fallback deployment ID must be a toolchain-stable
|
||||
/// UUIDv5 over the canonical endpoint — `DefaultHasher` output is not
|
||||
/// guaranteed stable across Rust releases, yet the ID is persisted in
|
||||
/// site-replication state and broadcast to peers.
|
||||
#[test]
|
||||
fn deployment_id_for_endpoint_is_stable_uuid_v5_over_canonical_endpoint() {
|
||||
let endpoint = "https://node-a.example.com:9000";
|
||||
let id = deployment_id_for_endpoint(endpoint);
|
||||
let parsed = uuid::Uuid::parse_str(&id).expect("fallback deployment ID must be a UUID");
|
||||
assert_eq!(parsed.get_version_num(), 5, "fallback deployment ID must be UUIDv5");
|
||||
// Deterministic for the same endpoint and for spelling variants that
|
||||
// share a canonical form; distinct endpoints stay distinct.
|
||||
assert_eq!(id, deployment_id_for_endpoint(endpoint));
|
||||
assert_eq!(id, deployment_id_for_endpoint(" HTTPS://Node-A.Example.Com:9000/ "));
|
||||
assert_ne!(id, deployment_id_for_endpoint("https://node-b.example.com:9000"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_endpoint_accepts_case_insensitive_scheme() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -51,7 +51,7 @@ mod ecstore_disk {
|
||||
}
|
||||
|
||||
mod ecstore_error {
|
||||
pub(crate) use crate::storage::storage_api::ecstore_error::{StorageError, is_err_bucket_not_found};
|
||||
pub(crate) use crate::storage::storage_api::ecstore_error::StorageError;
|
||||
}
|
||||
|
||||
#[allow(unused_imports)]
|
||||
@@ -919,7 +919,6 @@ pub(crate) mod contract {
|
||||
}
|
||||
|
||||
pub(crate) mod error {
|
||||
pub(crate) use super::ecstore_error::is_err_bucket_not_found;
|
||||
pub(crate) use super::{Error, StorageError};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user