fix(ci): reduce duplicate work and preserve reliable test failures (#8233)

* fix(ci): reduce duplicate work and preserve reliable test failures

* fix(ci): retain protocol evidence and repair stale test fixtures

* test(connect): honor parent deadline during API fixture readiness

* fix(ci): reserve IO capacity for state writer proofs

* test(connect): align RPC fixtures with service capture contracts

* test(connect): cover pinned service capture failures
This commit is contained in:
Chris
2026-09-29 21:06:45 +08:00
committed by GitHub
parent 1cca0dd25c
commit a88225d208
25 changed files with 1197 additions and 247 deletions
+2 -2
View File
@@ -1,2 +1,2 @@
sha256-linux=21ab66fd172018682ce825a53f26aa8f286d57f3f687d8616d1850e56474dcd8
sha256-darwin=21ab66fd172018682ce825a53f26aa8f286d57f3f687d8616d1850e56474dcd8
sha256-linux=6fc377fa1f9f06e065f077c7bd53d04efbccf4453f95dde67afaa47778185377
sha256-darwin=6fc377fa1f9f06e065f077c7bd53d04efbccf4453f95dde67afaa47778185377
+2
View File
@@ -52,6 +52,7 @@ script-tests: ## Run shell script tests
./scripts/check_embedded_secrets.sh --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/test_e2e_binary.py
$(RUSTFS_PYTHON_BIN) ./scripts/test_migration_gate_evidence.py
$(RUSTFS_PYTHON_BIN) ./scripts/ci_gate.py --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/check_security_coverage.py --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/check_scheduled_validation_freshness.py --self-test
@@ -61,6 +62,7 @@ script-tests: ## Run shell script tests
$(RUSTFS_PYTHON_BIN) ./scripts/test_functional_chain_health.py
$(RUSTFS_PYTHON_BIN) ./scripts/test_ci_timing_report.py
$(RUSTFS_PYTHON_BIN) ./scripts/s3-tests/test_report_compat.py
$(RUSTFS_PYTHON_BIN) ./scripts/s3-tests/test_runner_tools.py
bash -n ./scripts/validate_object_data_cache_cold_stampede.sh
$(RUSTFS_PYTHON_BIN) ./scripts/check_object_data_cache_follower_samples.py --self-test
./scripts/validate_object_data_cache_cold_stampede.sh --self-test
+13
View File
@@ -149,6 +149,13 @@ test-group = 'embedded-test-ports'
filter = 'package(rustfs) & (binary(connect_inventory) | binary(connect_perf_drive) | (binary(connect_perf_object) & test(=real_rustfs_endpoint_and_production_cli_support_bounded_get_and_put)))'
threads-required = "num-test-threads"
# These concurrent state writers keep the production 5s lock-acquisition limit
# while their peer completes durable IO. Reserve capacity from unrelated test
# processes while preserving each test's internal two-writer race.
[[profile.default.overrides]]
filter = 'package(rustfs) & test(/^admin::handlers::site_replication::tests::test_(peer_edit_generations_are_unique_across_nodes|recreated_state_object_allocates_over_the_previous_lifetimes_mark|state_object_lock_serializes_writers_from_separate_nodes|retry_event_persist_must_not_wipe_concurrent_locked_rmw)$/)'
threads-required = "num-test-threads"
# Reserve capacity for these short heartbeat TLS/fsync probes (rustfs#8129).
# Their existing deadlines, assertions, and retry policy remain in force.
[[profile.default.overrides]]
@@ -359,6 +366,12 @@ test-group = 'embedded-test-ports'
filter = 'package(rustfs) & (binary(connect_inventory) | binary(connect_perf_drive) | (binary(connect_perf_object) & test(=real_rustfs_endpoint_and_production_cli_support_bounded_get_and_put)))'
threads-required = "num-test-threads"
# Keep the same state-writer capacity reservation in CI without changing the
# production lock deadline, internal concurrency, assertions, or retry policy.
[[profile.ci.overrides]]
filter = 'package(rustfs) & test(/^admin::handlers::site_replication::tests::test_(peer_edit_generations_are_unique_across_nodes|recreated_state_object_allocates_over_the_previous_lifetimes_mark|state_object_lock_serializes_writers_from_separate_nodes|retry_event_persist_must_not_wipe_concurrent_locked_rmw)$/)'
threads-required = "num-test-threads"
# Reserve capacity for these short heartbeat TLS/fsync probes (rustfs#8129).
# Their existing deadlines, assertions, and retry policy remain in force.
[[profile.ci.overrides]]
+1 -6
View File
@@ -100,12 +100,7 @@ runs:
- name: Check test wiring
shell: bash
run: |
python3 ./scripts/check_test_wiring.py --self-test
python3 ./scripts/check_scheduled_validation_freshness.py --self-test
python3 ./scripts/test_security_workflow.py
python3 ./scripts/test_nightly_candidate.py
python3 ./scripts/check_test_wiring.py
run: python3 ./scripts/check_test_wiring.py
- name: Check no planning docs committed
shell: bash
+2
View File
@@ -107,6 +107,8 @@ runs:
- name: Install cargo-nextest
if: inputs.install-test-tools == 'true'
uses: taiki-e/install-action@96c7780c1d8a2b8723e12031def873a434d39d8d # nextest
with:
tool: cargo-nextest@0.9.138
- name: Setup Rust cache
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
@@ -1,61 +0,0 @@
# 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.
name: Architecture Migration Rules
on:
pull_request:
types: [ opened, synchronize, reopened, closed ]
branches: [ main ]
paths:
- "ARCHITECTURE.md"
- "docs/architecture/**"
- "scripts/check_architecture_migration_rules.sh"
- ".github/workflows/architecture-migration-rules.yml"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
cancel-closed-pr-runs:
name: Cancel Closed PR Runs
if: github.event_name == 'pull_request' && github.event.action == 'closed'
runs-on: sm-standard-2
timeout-minutes: 10
steps:
- name: Explain cancellation run
run: echo "PR closed; this run only cancels older runs in the same concurrency group."
architecture-migration-rules:
name: Architecture Migration Rules
if: github.event_name != 'pull_request' || github.event.action != 'closed'
runs-on: sm-standard-2
timeout-minutes: 10
steps:
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Install ripgrep
uses: taiki-e/install-action@7623a79cdfecb99d681017af368ca353d9f49bb5 # v2
with:
tool: ripgrep@15.2.0
- name: Check architecture migration rules
run: ./scripts/check_architecture_migration_rules.sh
+6 -6
View File
@@ -15,16 +15,16 @@
# Sole writer of the Rust dependency caches that ci.yml restores.
#
# Why this is a separate workflow rather than steps inside ci.yml: ci.yml's
# concurrency group cancels in-progress runs on main pushes, and merges land far
# faster than its 70-minute pipeline. Measured over 15 consecutive main pushes:
# concurrency group originally cancelled in-progress main runs, while merges
# landed faster than its 70-minute pipeline. Over 15 consecutive main pushes:
# 12 cancelled, 2 failed, 0 succeeded. A cancelled run never reaches
# Swatinem/rust-cache's post step (cache-on-failure does not cover cancellation),
# so the writer lanes were saving nothing and every PR paid a cold restore —
# 11.8-20.9 minutes of "Setup Rust environment" against 0.7-3.4 warm.
#
# Splitting cache writing out of the test pipeline lets ci.yml keep cancelling
# superseded runs (which is correct — nobody needs test results for a commit
# that is already three merges behind) while the caches still get written.
# Keep cache writing separate from validation: only these jobs publish the
# complete feature closure for each key. Main validation now also finishes its
# running baseline, while superseded PR attempts can still be cancelled.
#
# The group below deliberately does NOT cancel in progress; see the comment on
# it for how that bounds concurrency and why it is scoped by event.
@@ -256,7 +256,7 @@ jobs:
# belonged to it.
warm-ci-uring:
name: Warm ci-uring
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- name: Checkout repository
+34 -34
View File
@@ -47,12 +47,12 @@ permissions:
contents: read
# Concurrency groups are scoped per event so different triggers never cancel
# each other: PR pushes cancel the previous run of that PR, main pushes keep
# latest-wins semantics among themselves, and scheduled runs always complete
# (a shared group used to let every merge kill the weekly scheduled run).
# each other. PR pushes cancel superseded attempts; main and scheduled runs
# finish so a busy merge stream cannot starve the complete baseline. For main,
# GitHub retains one running run and replaces the pending run with the latest.
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name != 'schedule' }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
CARGO_TERM_COLOR: always
@@ -128,7 +128,7 @@ jobs:
test-and-lint:
name: Workspace Test and Lint
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
if: contains(fromJSON('["full", "e2e"]'), needs.classify-changes.outputs.mode) && (github.event_name != 'pull_request' || github.event.action != 'closed')
needs: [ quick-checks, classify-changes ]
runs-on: sm-standard-4
timeout-minutes: 90
@@ -146,11 +146,9 @@ jobs:
uses: ./.github/actions/setup
with:
rust-version: stable
# Every lane in this workflow reads its cache and none writes it.
# cache-warm.yml is the sole writer for all four keys: this workflow
# cancels superseded runs on main, and a cancelled run never reaches
# rust-cache's post step, so writing from here saved nothing (12 of 15
# consecutive main-push runs were cancelled). See rustfs/backlog#1600.
# Every lane reads its cache; cache-warm.yml remains the sole writer
# with the matching feature closure. Tests never compete to replace
# shared caches, including when a PR attempt is cancelled.
cache-shared-key: ci-dev
cache-save-if: 'false'
install-build-packaging-tools: 'false'
@@ -281,21 +279,10 @@ jobs:
- name: Check log-analyzer rule anchors
run: ./scripts/check_log_analyzer_rules.sh
# Explicit gate for migration-critical suites. These tests already ran in
# the full nextest pass above; a single filtered nextest invocation keeps
# the named gate without rebuilding or re-running them one package at a time.
#
# The gate selects tests by name substring (data_movement / rebalance /
# decommission / source_cleanup / delete_marker), so renames can silently
# thin it. The script owns the filter expression and first verifies the
# selected-test count against the committed floor in
# .config/migration-gate-floor.txt before running the gate; renames or
# removals must update that file consciously (see the script header).
# Kept on the default profile (no --profile ci): a second --profile ci run
# would clobber target/nextest/ci/junit.xml, and none of these tests are
# quarantined so they gain nothing from the ci profile's retry overrides.
- name: Run rebalance/decommission migration proofs
run: ./scripts/check_migration_gate_count.sh
# Preserve the migration floor and require successful execution evidence
# from the workspace run, without compiling or executing its subset again.
- name: Verify rebalance/decommission migration proofs
run: ./scripts/check_migration_gate_count.sh evidence artifacts/test-and-lint/core-test-listing.json target/nextest/ci/junit.xml
# This gate builds into a fresh target directory to isolate the E2E root.
# Give its cold build a separate budget from workspace tests and migration proofs.
@@ -525,13 +512,9 @@ jobs:
runs-on: sm-standard-4
timeout-minutes: 90
strategy:
# On a PR, one failing protocol leg is enough to know the PR is not ready,
# so stop the sibling leg instead of paying another ~40 minutes for it.
# Everywhere else (main pushes, the merge queue, the weekly schedule) keep
# the full signal: there we want to know whether swift AND sftp are broken,
# not just whichever failed first. This is the only part of the early-stop
# work that also covers fork PRs, since it needs no token.
fail-fast: ${{ github.event_name == 'pull_request' }}
# Preserve both independent results when one protocol fails, so diagnosis
# and a focused fix do not require rebuilding an interrupted sibling.
fail-fast: false
matrix:
features:
- name: swift
@@ -562,6 +545,8 @@ jobs:
cargo clippy -p rustfs -p rustfs-protocols --all-targets ${{ matrix.features.flags }} -- -D warnings
- name: Run tests with ${{ matrix.features.name }}
id: protocol-tests
shell: bash
env:
# Keep feature-test linking under the same bounded concurrency as the
# main nextest lane; Clippy is metadata-only and needs no such limit.
@@ -570,11 +555,26 @@ jobs:
# --profile ci so the quarantine list (and its junit flaky markers)
# covers this leg too; the default profile is the local no-retry
# profile and silently ignored quarantined flakes here (rustfs#6703).
cargo nextest run --profile ci -p rustfs -p rustfs-protocols ${{ matrix.features.flags }}
mkdir -p artifacts/protocol-tests
rm -f target/nextest/ci/junit.xml
cargo nextest run --profile ci -p rustfs -p rustfs-protocols ${{ matrix.features.flags }} \
2>&1 | tee artifacts/protocol-tests/nextest.log
- name: Upload protocol test reports and diagnostics
if: >-
always() && contains(fromJSON('["success", "failure", "cancelled"]'), steps.protocol-tests.outcome)
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: junit-test-and-lint-${{ matrix.features.name }}-${{ github.run_number }}-${{ github.run_attempt }}
path: |
target/nextest/ci/junit.xml
artifacts/protocol-tests
retention-days: 3
if-no-files-found: warn
build-rustfs-debug-binary:
name: Build RustFS Debug Binary
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
if: contains(fromJSON('["full", "e2e"]'), needs.classify-changes.outputs.mode) && (github.event_name != 'pull_request' || github.event.action != 'closed')
needs: [ quick-checks, classify-changes ]
runs-on: sm-standard-4
timeout-minutes: 30
+1
View File
@@ -38,6 +38,7 @@ on:
- "Cargo.lock"
- "Cargo.toml"
- ".config/nextest.toml"
- ".config/e2e-distributed-selection.txt"
- ".github/workflows/e2e-distributed.yml"
- "crates/audit/**"
- "crates/common/**"
+62 -17
View File
@@ -51,8 +51,53 @@ env:
UPGRADE_SOURCE_SHA256: 3ee8df71e8edcfada533be452c4135868f697bc515460ae97b027313eade7a3d
jobs:
build:
name: Build upgrade candidate
runs-on: sm-standard-2
timeout-minutes: 60
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
steps:
- name: Checkout repository
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Setup Rust environment
uses: ./.github/actions/setup
with:
cache-shared-key: e2e-upgrade-server
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
install-build-packaging-tools: "false"
install-test-tools: "false"
- name: Download pinned previous release
run: |
mkdir -p target/debug
archive="target/debug/$UPGRADE_SOURCE_ASSET"
curl --fail --location --retry 3 --output "$archive" \
"https://github.com/${GITHUB_REPOSITORY}/releases/download/${UPGRADE_SOURCE_VERSION}/${UPGRADE_SOURCE_ASSET}"
echo "$UPGRADE_SOURCE_SHA256 $archive" | sha256sum --check --strict
- name: Build current RustFS binary
run: python3 scripts/e2e_binary.py build
- name: Upload verified upgrade candidate
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: upgrade-candidate-${{ github.run_id }}
path: |
target/debug/rustfs
target/debug/rustfs.e2e.json
target/debug/${{ env.UPGRADE_SOURCE_ASSET }}
if-no-files-found: error
retention-days: 3
compression-level: 0
overwrite: true
upgrade:
name: ${{ matrix.name }}
needs: build
strategy:
fail-fast: false
matrix:
@@ -63,31 +108,24 @@ jobs:
# the CI required-check names. UPGRADE_SOURCE_VERSION above is the
# single source of truth for which release they actually run against.
- name: Direct upgrade from the previous release
cache_key: e2e-direct-upgrade
test: direct_upgrade_from_rc2_preserves_object_contracts
artifact: direct-upgrade
- name: Mixed-version rolling upgrade from the previous release
cache_key: e2e-mixed-version-upgrade
test: rolling_upgrade_from_rc2_preserves_mixed_version_contracts
artifact: mixed-version-upgrade
- name: Bucket configuration survives the upgrade
cache_key: e2e-bucket-config-upgrade
test: direct_upgrade_from_previous_release_preserves_bucket_configuration
artifact: bucket-config-upgrade
- name: Rollback reads current bucket metadata
cache_key: e2e-bucket-config-rollback
test: rollback_to_previous_release_reads_current_bucket_metadata
artifact: bucket-config-rollback
- name: ODM configuration recovery after rc.5 rollback
cache_key: e2e-odm-config-rollback
test: rc5_rollback_requires_restoring_odm_configuration
artifact: odm-config-rollback
- name: Multipart layouts survive the rc.5 upgrade
cache_key: e2e-multipart-layout-upgrade
test: direct_upgrade_from_rc5_preserves_multipart_layouts
artifact: multipart-layout-upgrade
- name: rc.5 multipart replication baseline
cache_key: e2e-multipart-layout-baseline
test: rc5_baseline_replicates_multipart_layouts
artifact: multipart-layout-baseline
runs-on: sm-standard-2
@@ -103,19 +141,28 @@ jobs:
- name: Setup Rust environment
uses: ./.github/actions/setup
with:
cache-shared-key: ${{ matrix.cache_key }}
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
cache-shared-key: e2e-upgrade-tests
# One main-branch consumer warms the shared test-harness dependencies.
cache-save-if: ${{ github.ref == 'refs/heads/main' && matrix.artifact == 'direct-upgrade' }}
install-build-packaging-tools: "false"
install-test-tools: "false"
- name: Download pinned previous release
- name: Download verified upgrade candidate
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: upgrade-candidate-${{ github.run_id }}
path: target/debug
- name: Restore candidate executable bit
run: chmod +x target/debug/rustfs
- name: Verify and unpack pinned previous release
env:
SOURCE_DIR: ${{ runner.temp }}/rustfs-upgrade-source
run: |
set -euo pipefail
mkdir -p "$SOURCE_DIR"
archive="$SOURCE_DIR/$UPGRADE_SOURCE_ASSET"
curl --fail --location --retry 3 --output "$archive" \
"https://github.com/${GITHUB_REPOSITORY}/releases/download/${UPGRADE_SOURCE_VERSION}/${UPGRADE_SOURCE_ASSET}"
archive="target/debug/$UPGRADE_SOURCE_ASSET"
echo "$UPGRADE_SOURCE_SHA256 $archive" | sha256sum --check --strict
unzip -q "$archive" -d "$SOURCE_DIR"
chmod +x "$SOURCE_DIR/rustfs"
@@ -123,14 +170,12 @@ jobs:
echo "RUSTFS_UPGRADE_SOURCE_BINARY=$SOURCE_DIR/rustfs" >> "$GITHUB_ENV"
echo "RUSTFS_E2E_LOG_DIR=$RUNNER_TEMP/rustfs-upgrade-logs" >> "$GITHUB_ENV"
- name: Build current RustFS binary
run: |
python3 scripts/e2e_binary.py build
- name: Run upgrade compatibility test
env:
RUSTFS_SCANNER_HEAL_G09_EVIDENCE_DIR: ${{ runner.temp }}/rustfs-upgrade-g09-evidence/${{ matrix.artifact }}
NO_PROXY: 127.0.0.1,localhost,::1
run: |
export no_proxy="$NO_PROXY"
python3 scripts/e2e_binary.py run -- cargo test --locked -p e2e_test \
"upgrade_compatibility_test::${{ matrix.test }}" \
-- --ignored --exact --nocapture
+219 -25
View File
@@ -34,6 +34,9 @@ use crate::common::{
};
use crate::replication_extension_test::LOOPBACK_REPLICATION_TARGET_ENV;
use aws_sdk_s3::Client;
use aws_sdk_s3::config::retry::RetryConfig;
use aws_sdk_s3::error::{ProvideErrorMetadata, SdkError};
use aws_sdk_s3::operation::{get_object::GetObjectError, put_object::PutObjectError};
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{BucketVersioningStatus, VersioningConfiguration};
use http::{Method, StatusCode};
@@ -41,7 +44,7 @@ use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::Duration;
use tokio::time::{Instant, sleep};
use tokio::time::{Instant, sleep, timeout_at};
use uuid::Uuid;
pub(crate) type TestResult<T = ()> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
@@ -400,9 +403,9 @@ pub(crate) async fn put_inventory(
Ok(inventory)
}
/// Retry only transport-level service availability failures while a data
/// movement operation changes the pool map. Generic InternalError responses
/// remain fatal because accepting them would hide server defects.
/// Retry only explicit S3 availability responses while a data movement
/// operation changes the pool map. Transport failures and InternalError
/// responses remain fatal because callers already require a ready endpoint.
pub(crate) async fn put_inventory_retrying(
client: &Client,
bucket: &str,
@@ -449,19 +452,48 @@ where
let deadline = Instant::now() + timeout;
let mut delay = Duration::from_millis(50);
loop {
let last_error = match probe().await {
Ok(true) => return Ok(()),
Ok(false) => format!("{label} still false"),
Err(error) => error.to_string(),
};
if Instant::now() >= deadline {
return Err(format!("{label} did not become true within {timeout:?}: {last_error}").into());
return Err(format!("{label} did not become true within {timeout:?}").into());
}
sleep(delay).await;
// A pending request must consume the same budget as unsuccessful probes.
match timeout_at(deadline, probe()).await {
Err(_) => return Err(format!("{label} did not become true within {timeout:?}").into()),
Ok(Err(error)) => return Err(error),
Ok(Ok(true)) => return Ok(()),
Ok(Ok(false)) => {}
}
tokio::time::sleep_until((Instant::now() + delay).min(deadline)).await;
delay = (delay * 2).min(Duration::from_secs(1));
}
}
// Boxed SDK errors retain the operation's service code; Display only says
// "service error" and cannot distinguish convergence from a server defect.
pub(crate) fn s3_probe_error_code<'a>(error: &'a (dyn std::error::Error + Send + Sync + 'static)) -> Option<&'a str> {
error
.downcast_ref::<SdkError<GetObjectError>>()
.and_then(|error| error.as_service_error())
.and_then(ProvideErrorMetadata::code)
.or_else(|| {
error
.downcast_ref::<SdkError<PutObjectError>>()
.and_then(|error| error.as_service_error())
.and_then(ProvideErrorMetadata::code)
})
}
pub(crate) fn s3_probe_client(client: &Client) -> Client {
// The probe owns the retry policy. SDK retries must not hide a response
// that the probe would classify as fatal.
Client::from_conf(
client
.config()
.to_builder()
.retry_config(RetryConfig::standard().with_max_attempts(1))
.build(),
)
}
pub(crate) async fn cluster_admin(
cluster: &RustFSTestClusterEnvironment,
method: Method,
@@ -598,15 +630,20 @@ pub(crate) async fn wait_for_replicated_bytes(
expected: &[u8],
timeout: Duration,
) -> TestResult {
let client = s3_probe_client(client);
wait_until(
timeout,
|| async {
match get_object_bytes(client, bucket, key).await {
match get_object_bytes(&client, bucket, key).await {
Ok(got) if got.as_slice() == expected => Ok(true),
Ok(_) => Ok(false),
Ok(got) => Err(format!(
"replicated object {bucket}/{key} bytes mismatch: expected sha256={} got sha256={}",
sha256_hex(expected),
sha256_hex(&got)
)
.into()),
Err(error) => {
let message = error.to_string();
if message.contains("NoSuchKey") || message.contains("NotFound") {
if matches!(s3_probe_error_code(error.as_ref()), Some("NoSuchKey" | "NotFound")) {
Ok(false)
} else {
Err(error)
@@ -988,6 +1025,7 @@ pub(crate) async fn list_pools_json(cluster: &RustFSTestClusterEnvironment) -> T
}
pub(crate) async fn retrying_put(client: &Client, bucket: &str, key: &str, body: Vec<u8>, timeout: Duration) -> TestResult {
let client = s3_probe_client(client);
wait_until(
timeout,
|| {
@@ -999,8 +1037,7 @@ pub(crate) async fn retrying_put(client: &Client, bucket: &str, key: &str, body:
match put_object(&client, &bucket, &key, body).await {
Ok(()) => Ok(true),
Err(error) => {
let message = error.to_string();
if message.contains("SlowDown") || message.contains("ServiceUnavailable") || message.contains("503") {
if matches!(s3_probe_error_code(error.as_ref()), Some("SlowDown" | "ServiceUnavailable")) {
Ok(false)
} else {
Err(error)
@@ -1021,19 +1058,20 @@ pub(crate) async fn retrying_get_equals(
expected: &[u8],
timeout: Duration,
) -> TestResult {
let client = s3_probe_client(client);
wait_until(
timeout,
|| async {
match get_object_bytes(client, bucket, key).await {
match get_object_bytes(&client, bucket, key).await {
Ok(got) if got.as_slice() == expected => Ok(true),
Ok(_) => Ok(false),
Ok(got) => Err(format!(
"object {bucket}/{key} bytes mismatch: expected sha256={} got sha256={}",
sha256_hex(expected),
sha256_hex(&got)
)
.into()),
Err(error) => {
let message = error.to_string();
if message.contains("NoSuchKey")
|| message.contains("SlowDown")
|| message.contains("ServiceUnavailable")
|| message.contains("503")
{
if matches!(s3_probe_error_code(error.as_ref()), Some("SlowDown" | "ServiceUnavailable")) {
Ok(false)
} else {
Err(error)
@@ -1046,6 +1084,162 @@ pub(crate) async fn retrying_get_equals(
.await
}
#[cfg(test)]
mod retry_tests {
use super::*;
use crate::fake_s3_target::{FAKE_ACCESS_KEY, FAKE_SECRET_KEY, FakeS3Target, FaultAction, Operation, SeedMetadata};
use std::cell::Cell;
fn client(target: &FakeS3Target) -> Client {
Client::from_conf(build_test_s3_config(
target.endpoint(),
FAKE_ACCESS_KEY,
FAKE_SECRET_KEY,
None,
"distributed-retry-test",
))
}
#[tokio::test]
async fn wait_until_preserves_first_error() {
let attempts = Cell::new(0);
let error = wait_until(
Duration::from_secs(5),
|| {
attempts.set(attempts.get() + 1);
std::future::ready(if attempts.get() == 1 {
Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "permanent probe failure").into())
} else {
Ok(true)
})
},
"permanent failure",
)
.await
.expect_err("a later success must not hide a permanent failure");
assert_eq!(attempts.get(), 1);
assert_eq!(
error
.downcast_ref::<std::io::Error>()
.expect("preserve the original error")
.kind(),
std::io::ErrorKind::PermissionDenied
);
}
#[tokio::test]
async fn wait_until_retries_explicit_pending_state() -> TestResult {
let attempts = Cell::new(0);
wait_until(
Duration::from_secs(5),
|| {
attempts.set(attempts.get() + 1);
std::future::ready(Ok(attempts.get() == 2))
},
"eventual readiness",
)
.await?;
assert_eq!(attempts.get(), 2);
Ok(())
}
#[tokio::test]
async fn wait_until_bounds_a_pending_probe() {
let error = tokio::time::timeout(
Duration::from_secs(5),
wait_until(Duration::from_millis(10), std::future::pending, "hung probe"),
)
.await
.expect("the probe deadline must finish before the test watchdog")
.expect_err("a permanently pending probe must time out");
assert!(error.to_string().contains("hung probe did not become true"));
}
#[tokio::test]
async fn wait_until_does_not_start_a_probe_after_its_deadline() {
let attempts = Cell::new(0);
wait_until(
Duration::ZERO,
|| {
attempts.set(attempts.get() + 1);
std::future::ready(Ok(true))
},
"expired budget",
)
.await
.expect_err("an expired budget must not admit another probe");
assert_eq!(attempts.get(), 0);
}
#[tokio::test]
async fn retrying_put_does_not_hide_internal_error() -> TestResult {
let target = FakeS3Target::start().await?;
target.create_bucket("retry-probe");
target.inject(Operation::PutObject, FaultAction::ResponseStatus(500), 1);
let error = retrying_put(&client(&target), "retry-probe", "key", b"body".to_vec(), Duration::from_secs(5))
.await
.expect_err("InternalError must remain fatal even if the next PUT would succeed");
assert_eq!(s3_probe_error_code(error.as_ref()), Some("InternalError"));
assert_eq!(target.requests().len(), 1);
Ok(())
}
#[tokio::test]
async fn retrying_put_allows_transient_availability_errors() -> TestResult {
let target = FakeS3Target::start().await?;
target.create_bucket("retry-probe");
for status in [429, 503] {
target.inject(Operation::PutObject, FaultAction::ResponseStatus(status), 1);
retrying_put(&client(&target), "retry-probe", "key", b"body".to_vec(), Duration::from_secs(5)).await?;
assert_eq!(target.take_requests().len(), 2);
}
Ok(())
}
#[tokio::test]
async fn object_polls_reject_wrong_object_bytes() -> TestResult {
let target = FakeS3Target::start().await?;
target.create_bucket("retry-probe");
target.put_seed_object("retry-probe", "key", "wrong bytes", &SeedMetadata::default());
let error = retrying_get_equals(&client(&target), "retry-probe", "key", b"expected bytes", Duration::from_secs(5))
.await
.expect_err("a successful response with wrong bytes must fail immediately");
assert!(error.to_string().contains("bytes mismatch"));
assert_eq!(target.take_requests().len(), 1);
let error = wait_for_replicated_bytes(&client(&target), "retry-probe", "key", b"expected bytes", Duration::from_secs(5))
.await
.expect_err("replication may lag, but it must not return corrupt bytes");
assert!(error.to_string().contains("bytes mismatch"));
assert_eq!(target.requests().len(), 1);
Ok(())
}
#[tokio::test]
async fn object_polls_distinguish_committed_and_replicated_keys() -> TestResult {
let target = FakeS3Target::start().await?;
target.create_bucket("retry-probe");
target.put_seed_object("retry-probe", "key", "expected bytes", &SeedMetadata::default());
let client = client(&target);
for (status, expected_code) in [(404, "NoSuchKey"), (500, "InternalError"), (403, "AccessDenied")] {
target.inject(Operation::GetObject, FaultAction::ResponseStatus(status), 1);
let error = retrying_get_equals(&client, "retry-probe", "key", b"expected bytes", Duration::from_secs(5))
.await
.expect_err("an acknowledged object must not disappear or fail before a later successful read");
assert_eq!(s3_probe_error_code(error.as_ref()), Some(expected_code));
assert_eq!(target.take_requests().len(), 1);
}
for status in [429, 503] {
target.inject(Operation::GetObject, FaultAction::ResponseStatus(status), 1);
retrying_get_equals(&client, "retry-probe", "key", b"expected bytes", Duration::from_secs(5)).await?;
assert_eq!(target.take_requests().len(), 2);
}
target.inject(Operation::GetObject, FaultAction::ResponseStatus(404), 1);
wait_for_replicated_bytes(&client, "retry-probe", "key", b"expected bytes", Duration::from_secs(5)).await?;
assert_eq!(target.requests().len(), 2);
Ok(())
}
}
#[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]]))
@@ -26,7 +26,7 @@
use super::harness::{
DistCluster, DistLayout, TestResult, assert_object_bytes, cluster_admin_ok, enable_versioning, get_object_bytes, put_object,
unique_bucket, wait_until,
s3_probe_client, s3_probe_error_code, sha256_hex, unique_bucket, wait_until,
};
use crate::common::{
AdminTransport, admin_add_canned_policy_via, admin_attach_user_policy_via, admin_create_user_via, init_logging,
@@ -111,6 +111,7 @@ async fn create_iam_user(dist: &DistCluster, user: &str, secret: &str, policy_na
}
async fn wait_for_put(client: &Client, bucket: &str, key: &str, body: Vec<u8>, label: &str) -> TestResult {
let client = s3_probe_client(client);
wait_until(
CREDENTIAL_TIMEOUT,
|| {
@@ -119,8 +120,18 @@ async fn wait_for_put(client: &Client, bucket: &str, key: &str, body: Vec<u8>, l
let key = key.to_string();
let body = body.clone();
async move {
put_object(&client, &bucket, &key, body).await?;
Ok(true)
match put_object(&client, &bucket, &key, body).await {
Ok(()) => Ok(true),
Err(error)
if matches!(
s3_probe_error_code(error.as_ref()),
Some("AccessDenied" | "InvalidAccessKeyId" | "SlowDown" | "ServiceUnavailable")
) =>
{
Ok(false)
}
Err(error) => Err(error),
}
}
},
label,
@@ -129,6 +140,7 @@ async fn wait_for_put(client: &Client, bucket: &str, key: &str, body: Vec<u8>, l
}
async fn wait_for_bytes(client: &Client, bucket: &str, key: &str, expected: &[u8], label: &str) -> TestResult {
let client = s3_probe_client(client);
wait_until(
CREDENTIAL_TIMEOUT,
|| {
@@ -137,8 +149,24 @@ async fn wait_for_bytes(client: &Client, bucket: &str, key: &str, expected: &[u8
let key = key.to_string();
let expected = expected.to_vec();
async move {
let got = get_object_bytes(&client, &bucket, &key).await?;
Ok(got == expected)
match get_object_bytes(&client, &bucket, &key).await {
Ok(got) if got == expected => Ok(true),
Ok(got) => Err(format!(
"{label}: object {bucket}/{key} bytes mismatch: expected sha256={} got sha256={}",
sha256_hex(&expected),
sha256_hex(&got)
)
.into()),
Err(error)
if matches!(
s3_probe_error_code(error.as_ref()),
Some("AccessDenied" | "InvalidAccessKeyId" | "SlowDown" | "ServiceUnavailable")
) =>
{
Ok(false)
}
Err(error) => Err(error),
}
}
},
label,
@@ -146,6 +174,57 @@ async fn wait_for_bytes(client: &Client, bucket: &str, key: &str, expected: &[u8
.await
}
#[cfg(test)]
mod retry_tests {
use super::*;
use crate::common::build_test_s3_config;
use crate::fake_s3_target::{FAKE_ACCESS_KEY, FAKE_SECRET_KEY, FakeS3Target, FaultAction, Operation};
#[tokio::test]
async fn iam_polls_only_retry_auth_propagation_and_availability() -> TestResult {
let target = FakeS3Target::start().await?;
target.create_bucket("upgrade-retry-probe");
let client = Client::from_conf(build_test_s3_config(
target.endpoint(),
FAKE_ACCESS_KEY,
FAKE_SECRET_KEY,
None,
"upgrade-retry-test",
));
for status in [403, 503] {
target.inject(Operation::PutObject, FaultAction::ResponseStatus(status), 1);
wait_for_put(&client, "upgrade-retry-probe", "key", b"body".to_vec(), "IAM PUT").await?;
assert_eq!(target.take_requests().len(), 2);
target.inject(Operation::GetObject, FaultAction::ResponseStatus(status), 1);
wait_for_bytes(&client, "upgrade-retry-probe", "key", b"body", "IAM GET").await?;
assert_eq!(target.take_requests().len(), 2);
}
target.inject(Operation::PutObject, FaultAction::ResponseStatus(500), 1);
let error = wait_for_put(&client, "upgrade-retry-probe", "key", b"body".to_vec(), "IAM PUT")
.await
.expect_err("IAM convergence must not hide InternalError on PUT");
assert_eq!(s3_probe_error_code(error.as_ref()), Some("InternalError"));
assert_eq!(target.take_requests().len(), 1);
target.inject(Operation::GetObject, FaultAction::ResponseStatus(500), 1);
let error = wait_for_bytes(&client, "upgrade-retry-probe", "key", b"body", "IAM GET")
.await
.expect_err("IAM convergence must not hide InternalError on GET");
assert_eq!(s3_probe_error_code(error.as_ref()), Some("InternalError"));
assert_eq!(target.take_requests().len(), 1);
let error = wait_for_bytes(&client, "upgrade-retry-probe", "key", b"wrong bytes", "IAM GET")
.await
.expect_err("IAM convergence must not hide a payload mismatch");
assert!(error.to_string().contains("bytes mismatch"));
assert_eq!(target.requests().len(), 1);
Ok(())
}
}
async fn seed_history_and_iam(dist: &DistCluster) -> TestResult<UpgradeSeed> {
let history_bucket = unique_bucket("upg-hist");
let versioned_bucket = unique_bucket("upg-ver");
+118 -17
View File
@@ -930,17 +930,38 @@ pub fn spawn_mrf_consumer(manager: Arc<HealManager>) {
tracing::info!(target: "rustfs::heal::mrf", "MRF intent consumer started");
}
/// Replay the durable journal into a fresh pending queue and submit whatever
/// it armed. Returns the number of intact intents replayed. Duplicates are
/// merged by the manager's dedup key; the journal is retained whenever replay
/// cannot fully hand off a successor in-memory snapshot (torn tails truncate
/// via the per-record CRC). Public for integration tests; the live consumer
/// invokes this through [`replay_into`] at startup.
/// Replay the journal once for integration tests, publishing a durable
/// successor before dispatching partial writes. Returns the number of intact
/// intents replayed. Ordinary-only admission retries remain in the startup journal;
/// this helper does not run the consumer's retry loop or proof-driven cleanup.
/// The live consumer invokes [`replay_into`] directly at startup.
pub async fn replay_journal_once(manager: &Arc<HealManager>) -> usize {
let config = MrfConsumerConfig::default();
let mut queue = MrfQueue::new(config.queue_capacity, config.journal_max_bytes);
let mut backoff_until: Option<tokio::time::Instant> = None;
replay_into(manager, &mut queue, &mut backoff_until).await.replayed
let replay = replay_into(manager, &mut queue, &mut backoff_until).await;
if replay.partial_writes.is_empty() {
return replay.replayed;
}
let mut runtime = MrfRuntime {
partial_writes: PartialWrites::default(),
queue,
config,
checkpoint_owner: Uuid::new_v4(),
next_checkpoint_sequence: replay.next_checkpoint_sequence,
new_since_flush: 0,
dirty: false,
journal_on_disk: replay.journal_on_disk,
retain_replay_journal: replay.retain_journal_for_replay,
durable_replay_anchors: replay.durable_replay_anchors,
replay_cleanup: replay.cleanup,
runtime_checkpoint: None,
backoff_until,
};
runtime.adopt_replayed_partial_writes(replay.partial_writes);
runtime.flush().await;
runtime.dispatch(manager).await;
replay.replayed
}
struct ReplayOutcome {
@@ -1551,15 +1572,15 @@ mod tests {
let mut backoff_until = None;
let replay = replay_into(&manager, &mut queue, &mut backoff_until).await;
assert_eq!(replay.replayed, 1, "W13 committed checkpoint must replay one record");
assert_eq!(queue.depth(), 0, "W13 replayed record should reach the manager before cleanup");
assert_eq!(queue.depth(), 0, "W13 durable replay must leave the ordinary queue");
assert_eq!(replay.partial_writes.len(), 1, "W13 replay must retain its executable durable record");
assert_eq!(replay.durable_replay_anchors.len(), 1, "W13 replay must create a proof anchor");
assert_eq!(
manager.operations_snapshot().await.queued_by_source.mrf,
1,
"W13 replayed work must be visible as MRF manager work"
0,
"W13 durable replay must wait for its successor checkpoint before dispatch"
);
let anchor = replay.durable_replay_anchors[0].clone();
let mut runtime = MrfRuntime {
partial_writes: PartialWrites::default(),
queue,
@@ -1575,6 +1596,45 @@ mod tests {
runtime_checkpoint: None,
backoff_until,
};
runtime.adopt_replayed_partial_writes(replay.partial_writes);
assert_eq!(runtime.partial_writes.depth(), 1, "durable replay must have one executable owner");
assert!(
runtime.durable_replay_anchors.is_empty(),
"adoption must remove the duplicate startup anchor"
);
runtime.dispatch(&manager).await;
assert_eq!(
manager.operations_snapshot().await.queued_by_source.mrf,
0,
"unpersisted replay must not enter the manager"
);
assert!(runtime.flush().await, "publish the durable replay successor before dispatch");
let successor = snapshot::inspect_local_committed_snapshot(runtime.config.journal_max_bytes)
.await
.expect("inspect durable replay successor")
.expect("durable replay must publish a committed successor");
assert_eq!((successor.owner(), successor.sequence()), (runtime.checkpoint_owner, 12));
assert_eq!(runtime.runtime_checkpoint, Some((runtime.checkpoint_owner, 12)));
assert!(
env.disk_paths.iter().all(|path| {
[".heal-mrf-commit.0.bin", ".heal-mrf-commit.1.bin"]
.iter()
.all(|manifest| path.join(".rustfs.sys").join(manifest).exists())
}),
"both startup and successor checkpoints must remain before proof"
);
runtime.dispatch(&manager).await;
assert_eq!(
manager.operations_snapshot().await.queued_by_source.mrf,
1,
"the checkpointed replay must be visible as one MRF manager request"
);
let anchor = runtime
.partial_writes
.anchors()
.next()
.expect("dispatched replay has a proof anchor")
.clone();
let retained_before_proof = runtime.retained_replay_journal();
assert!(retained_before_proof, "W13 proof anchor must retain replay checkpoint before proof");
assert!(
@@ -2469,12 +2529,13 @@ mod tests {
let mut backoff_until = None;
let replay = replay_into(&manager, &mut queue, &mut backoff_until).await;
assert_eq!(replay.replayed, 1, "the committed replay checkpoint must decode one record");
assert_eq!(queue.depth(), 0, "the replayed record must be admitted before cleanup is considered");
assert_eq!(queue.depth(), 0, "durable replay must leave the ordinary queue");
assert_eq!(replay.partial_writes.len(), 1, "replay must retain its executable durable record");
assert!(backoff_until.is_none(), "the accepted replay must not arm admission backoff");
assert_eq!(
manager.operations_snapshot().await.queued_by_source.mrf,
1,
"the replayed record must be visible as an MRF manager request"
0,
"durable replay must wait for its successor checkpoint before dispatch"
);
assert!(
replay.journal_on_disk,
@@ -2498,7 +2559,6 @@ mod tests {
"cleanup must remember the committed checkpoint generation read at startup"
);
let anchor = replay.durable_replay_anchors[0].clone();
let mut runtime = MrfRuntime {
partial_writes: PartialWrites::default(),
queue,
@@ -2514,6 +2574,45 @@ mod tests {
runtime_checkpoint: None,
backoff_until,
};
runtime.adopt_replayed_partial_writes(replay.partial_writes);
assert_eq!(runtime.partial_writes.depth(), 1, "durable replay must have one executable owner");
assert!(
runtime.durable_replay_anchors.is_empty(),
"adoption must remove the duplicate startup anchor"
);
runtime.dispatch(&manager).await;
assert_eq!(
manager.operations_snapshot().await.queued_by_source.mrf,
0,
"unpersisted replay must not enter the manager"
);
assert!(runtime.flush().await, "publish the durable replay successor before dispatch");
let successor = snapshot::inspect_local_committed_snapshot(runtime.config.journal_max_bytes)
.await
.expect("inspect durable replay successor")
.expect("durable replay must publish a committed successor");
assert_eq!((successor.owner(), successor.sequence()), (runtime.checkpoint_owner, 12));
assert_eq!(runtime.runtime_checkpoint, Some((runtime.checkpoint_owner, 12)));
assert!(
env.disk_paths.iter().all(|path| {
[".heal-mrf-commit.0.bin", ".heal-mrf-commit.1.bin"]
.iter()
.all(|manifest| path.join(".rustfs.sys").join(manifest).exists())
}),
"both startup and successor checkpoints must remain before proof"
);
runtime.dispatch(&manager).await;
assert_eq!(
manager.operations_snapshot().await.queued_by_source.mrf,
1,
"the checkpointed replay must be visible as one MRF manager request"
);
let anchor = runtime
.partial_writes
.anchors()
.next()
.expect("dispatched replay has a proof anchor")
.clone();
assert!(runtime.retained_replay_journal(), "proof-bearing replay anchors must block idle cleanup");
assert!(
snapshot::inspect_local_committed_snapshot(runtime.config.journal_max_bytes)
@@ -2541,7 +2640,7 @@ mod tests {
);
assert!(
runtime.delete_idle_recovery_anchors().await,
"idle cleanup must delete the proof-discharged committed replay checkpoint"
"idle cleanup must delete both proof-discharged checkpoint generations"
);
runtime.journal_on_disk = false;
assert!(
@@ -2549,8 +2648,10 @@ mod tests {
.await
.expect("inspect committed checkpoints after proof cleanup")
.is_none(),
"the committed replay checkpoint must be gone after proof-driven cleanup"
"both committed checkpoint generations must be gone after proof-driven cleanup"
);
assert_eq!(read_journal(MRF_SCOPED_JOURNAL_PATH).await, None);
assert_eq!(read_journal(MRF_JOURNAL_PATH).await, None);
let restart_manager = Arc::new(HealManager::new(
storage,
+33 -12
View File
@@ -60,6 +60,16 @@ async fn heal_env() -> (Vec<std::path::PathBuf>, Arc<dyn HealStorageAPI>) {
heal_env_at(None).await
}
async fn heal_env_with_bucket(bucket: &str) -> (Vec<PathBuf>, Arc<dyn HealStorageAPI>) {
let env = rustfs_test_utils::TestECStoreEnv::builder()
.prefix("rustfs_heal_mrf_test")
.build()
.await;
env.make_bucket(bucket, false).await;
let storage: Arc<dyn HealStorageAPI> = Arc::new(ECStoreHealStorage::new(env.ecstore.clone()));
(env.disk_paths, storage)
}
async fn heal_env_at(base_dir: Option<&Path>) -> (Vec<std::path::PathBuf>, Arc<dyn HealStorageAPI>) {
let mut builder = rustfs_test_utils::TestECStoreEnv::builder().prefix("rustfs_heal_mrf_test");
if let Some(base_dir) = base_dir {
@@ -356,7 +366,7 @@ async fn decode_failure_intent_maps_to_urgent_mrf_heal_request() {
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[serial]
async fn journal_replay_arms_intents_and_retains_unproven_partial_write_anchor() {
let (disk_paths, storage) = heal_env().await;
let (disk_paths, storage) = heal_env_with_bucket("replay-bucket").await;
// The journal reader resolves disks through the process-local disk map;
// register the environment's disks the same way server startup does.
@@ -387,8 +397,14 @@ async fn journal_replay_arms_intents_and_retains_unproven_partial_write_anchor()
assert!(
disk_paths
.iter()
.all(|path| !Path::new(path).join(META_BUCKET).join(SCOPED_JOURNAL_REL).exists()),
"missing authoritative journal remains absent"
.all(|path| Path::new(path).join(META_BUCKET).join(SCOPED_JOURNAL_REL).exists()),
"partial-write dispatch must first publish its authoritative successor"
);
let successor = journal_record(3, "replay-bucket", "partial-object", None, 1);
assert!(
journal_matches_on_all_disks(&disk_paths, SCOPED_JOURNAL_REL, &successor)
&& committed_checkpoint_matches_on_all_disks(&disk_paths, 1, &successor),
"the committed and authoritative successor must preserve the partial-write identity"
);
let snapshot = manager.operations_snapshot().await;
@@ -402,7 +418,7 @@ async fn journal_replay_arms_intents_and_retains_unproven_partial_write_anchor()
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[serial]
async fn committed_snapshot_replay_takes_precedence_over_stale_legacy_mirror() {
let (disk_paths, storage) = heal_env().await;
let (disk_paths, storage) = heal_env_with_bucket("committed-bucket").await;
register_local_disks(&disk_paths, "mrf-committed-replay-test").await;
let committed = scoped_journal_record(3, "committed-bucket", "committed-object", Some([9u8; 16]), 0, 0, 0);
@@ -534,11 +550,13 @@ async fn authoritative_journal_is_not_merged_with_legacy_mirror() {
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[serial]
async fn authoritative_journal_replay_preserves_kind_and_scope_identity() {
let (disk_paths, storage) = heal_env().await;
let (disk_paths, storage) = heal_env_with_bucket("identity-bucket").await;
register_local_disks(&disk_paths, "mrf-authoritative-identity-test").await;
let mut authoritative = scoped_journal_record(3, "identity-bucket", "same-object", None, 0, 3, 7);
authoritative.extend(scoped_journal_record(3, "identity-bucket", "same-object", None, 0, 3, 8));
let first_partial = scoped_journal_record(3, "identity-bucket", "same-object", None, 0, 3, 7);
let second_partial = scoped_journal_record(3, "identity-bucket", "same-object", None, 0, 3, 8);
let mut authoritative = first_partial.clone();
authoritative.extend_from_slice(&second_partial);
authoritative.extend(journal_record(2, "identity-bucket", "same-object", None, 0));
authoritative.extend(journal_record(1, "identity-bucket", "same-object", Some([4u8; 16]), 0));
let stale_legacy = journal_record(3, "identity-bucket", "stale-legacy-object", None, 0);
@@ -570,11 +588,14 @@ async fn authoritative_journal_replay_preserves_kind_and_scope_identity() {
"decode-failure repair must not merge with object repair responsibility"
);
assert!(
disk_paths.iter().all(|path| {
Path::new(path).join(META_BUCKET).join(JOURNAL_REL).exists()
&& Path::new(path).join(META_BUCKET).join(SCOPED_JOURNAL_REL).exists()
}),
"partial-write responsibilities keep both replay anchors until proof"
journal_contains_on_all_disks(&disk_paths, SCOPED_JOURNAL_REL, &first_partial)
&& journal_contains_on_all_disks(&disk_paths, SCOPED_JOURNAL_REL, &second_partial)
&& committed_payload_contains_on_all_disks(&disk_paths, &[&first_partial, &second_partial]),
"both scoped partial-write identities must survive in the authoritative and committed successor"
);
assert!(
journal_matches_on_all_disks(&disk_paths, JOURNAL_REL, &[]),
"the legacy mirror must not misrepresent scoped-only partial-write responsibilities"
);
}
@@ -21,7 +21,7 @@ use rustfs_common::mrf_channel::{
use rustfs_heal::heal::{
manager::{HealConfig, HealManager},
mrf_queue::spawn_mrf_consumer,
storage::{HealListItem, HealObjectInfo, HealStorageAPI},
storage::{HealListItem, HealObjectInfo, HealStorageAPI, HealStorageObjectResult},
};
use rustfs_heal_contracts::heal_channel::HealOpts;
@@ -239,12 +239,26 @@ impl HealStorageAPI for NoticeStorage {
async fn mrf_bucket_incarnation_id(&self, _: &str) -> rustfs_heal::Result<Option<Uuid>> {
Ok(Some(self.bucket_incarnation_id))
}
async fn bucket_incarnation_id(&self, _: &str) -> rustfs_heal::Result<Option<Uuid>> {
Ok(Some(self.bucket_incarnation_id))
}
async fn list_buckets(&self) -> rustfs_heal::Result<Vec<BucketInfo>> {
Ok(Vec::new())
}
async fn object_exists(&self, _: &str, _: &str) -> rustfs_heal::Result<bool> {
Ok(true)
}
async fn heal_object_at_incarnation(
&self,
bucket: &str,
object: &str,
version_id: Option<&str>,
expected: Uuid,
opts: &HealOpts,
) -> rustfs_heal::Result<HealStorageObjectResult> {
self.validate_bucket_incarnation(bucket, Some(expected)).await?;
self.heal_object_with_receipt(bucket, object, version_id, opts).await
}
async fn heal_object(
&self,
_: &str,
@@ -396,8 +410,8 @@ async fn mrf_ownership_manager_completion_preserves_scanner_pending() {
if *object == "unknown" {
assert_eq!(
manager.get_statistics().await.total_objects_healed,
1,
"legacy healed count is not repair proof"
0,
"unproved durable repairs must not increment the healed count"
);
}
assert_eq!(
+16 -6
View File
@@ -17,6 +17,8 @@ The `main` ruleset (`6436880`) requires exactly these contexts, with `strict_req
Every PR enters `ci.yml`. The `classify-changes` job uses the base revision of `scripts/ci_gate.py` to select a conservative documentation-only path: root Markdown/licenses, `AGENTS.md`, Markdown under `docs/` or `.agents/skills/`, and documentation images. Unknown paths, unavailable Git history, an empty diff, or a missing base policy select the full matrix. Renames include their deleted source path. Documentation-only PRs still run Quick Checks and Typos; the aggregate requires the expensive jobs to be skipped exactly as selected.
PRs changing only Rust sources under `crates/e2e_test/src/` and known E2E selection digests (optionally with those documentation paths) use the `e2e` scope. They keep workspace validation, the server build, E2E smoke, and both S3 lanes; independent workflows retain their path selection. Connect boundary, serial ILM, optional protocol/rio-v2, and real io_uring jobs are skipped because their sources and test inputs are unchanged. Manifests, fixtures, shared nextest configuration, scripts, production sources, and unknown paths still select `full`. A dependency on `e2e_test` from another workspace member or reachable local dependency disables this shortcut. All non-PR events retain their full selection.
`required-checks` runs even after failed or skipped dependencies. `scripts/ci_gate.py verify` rejects missing jobs, unexpected jobs, failure, cancellation, and unexpected skips; optional lanes are required only on their declared events. `Workspace Test and Lint` is the ordinary Rust job, while `Test and Lint` uniquely names the aggregate. New validation jobs must update both its direct dependencies and the script contract. Test this wiring and its failure cases with `python3 scripts/ci_gate.py --self-test`.
Verify the live rule before changing merge policy:
@@ -28,6 +30,12 @@ gh api repos/rustfs/rustfs/rulesets/6436880 \
The aggregate requires the validation lanes already selected by `ci.yml`; this closes the gap where a failing critical lane left the required workspace check green. Independent workflows remain report-only unless separately required. Before adding a new expensive lane or moving existing PR coverage to a schedule, collect representative execution and regression evidence, establish ownership and a working scheduled replacement, and update this reference with the resulting policy.
Superseded PR attempts are cancelled. A running main validation finishes, with only the newest pending main run retained, so frequent merges cannot continuously cancel the full baseline. Having a `merge_group` trigger does not itself require use of the merge queue.
Protocol matrix jobs finish independently when a sibling fails. Each executed protocol test step preserves its log and available JUnit under a separate, attempt-specific artifact; a skipped test step cannot upload a cached report.
The four site-replication state-writer concurrency proofs reserve the available nextest slots in both local and CI profiles. This isolates their durable IO from unrelated test processes while retaining each proof's internal two-writer race, production five-second lock-acquisition limit, assertions, and zero retries.
## Pull request and merge matrix
"Via aggregate" means a wrong result fails the required `Test and Lint` check. "Report-only" means visible and actionable but outside both the required list and aggregate. Budgets are each job's `timeout-minutes` in the named workflow and are not copied here.
@@ -35,18 +43,18 @@ The aggregate requires the validation lanes already selected by `ci.yml`; this c
| Event | Check name | Workflow / job | Merge status | Reproduce |
|---|---|---|---|---|
| PR, non-doc change | `Quick Checks` | `ci.yml` `quick-checks` | Required | `make pre-commit` |
| PR, non-doc change | `Workspace Test and Lint` | `ci.yml` `test-and-lint` | Via aggregate | `cargo clippy --all-targets -- -D warnings`; `cargo nextest run --profile ci --all --exclude e2e_test`; `cargo test --all --doc`; `scripts/check_migration_gate_count.sh` |
| PR, non-doc change | `Workspace Test and Lint` | `ci.yml` `test-and-lint` | Via aggregate | `cargo clippy --all-targets -- -D warnings`; `cargo nextest run --profile ci --all --exclude e2e_test`; `cargo test --all --doc`; migration evidence check described below |
| PR, non-doc change | `Typos` | `ci.yml` `typos` | Via aggregate | `typos` |
| PR, non-doc change | `ILM Integration (serial)` | `ci.yml` `test-ilm-integration-serial` | Via aggregate | exact command in the job |
| PR, non-doc change | `Test and Lint (rio-v2)`, `Test and Lint (swift)`, `Test and Lint (sftp)` | `ci.yml` `test-and-lint-rio-v2`, `test-and-lint-protocols` | Via aggregate | `cargo nextest run` with the job's `--features` |
| PR, non-doc change | `Connect Short Credential Boundary` | `ci.yml` `connect-short-credential-boundary` | Via aggregate | `cargo test -p rustfs --test connect_registration --features connect-e2e-short-credentials`; `cargo check -p rustfs --release --features connect-e2e-short-credentials` must fail |
| PR, full selection | `ILM Integration (serial)` | `ci.yml` `test-ilm-integration-serial` | Via aggregate | exact command in the job |
| PR, full selection | `Test and Lint (rio-v2)`, `Test and Lint (swift)`, `Test and Lint (sftp)` | `ci.yml` `test-and-lint-rio-v2`, `test-and-lint-protocols` | Via aggregate | `cargo nextest run` with the job's `--features` |
| PR, full selection | `Connect Short Credential Boundary` | `ci.yml` `connect-short-credential-boundary` | Via aggregate | `cargo test -p rustfs --test connect_registration --features connect-e2e-short-credentials`; `cargo check -p rustfs --release --features connect-e2e-short-credentials` must fail |
| PR, full selection | `Offline Enrollment E2E Root Boundary` | `ci.yml` `offline-enrollment-e2e` | Via aggregate | `scripts/check_offline_enrollment_e2e.sh` |
| PR, non-doc change | `Build RustFS Debug Binary` | `ci.yml` `build-rustfs-debug-binary` | Via aggregate; prerequisite for black-box jobs | `python3 scripts/e2e_binary.py build --bins --features e2e-test-hooks` (binary plus its `rustfs.e2e.json` sidecar) |
| PR, non-doc change | `io_uring Integration (real)` | `ci.yml` `uring-integration` | Via aggregate | `cargo test -p rustfs-ecstore --lib uring_ -- --test-threads=1 --nocapture` |
| PR, full selection | `io_uring Integration (real)` | `ci.yml` `uring-integration` | Via aggregate | `cargo test -p rustfs-ecstore --lib uring_ -- --test-threads=1 --nocapture` |
| PR, non-doc change | `End-to-End Tests` | `ci.yml` `e2e-tests` | Via aggregate | `python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-smoke -p e2e_test`, then `./scripts/e2e-run.sh ./target/debug/rustfs <data-dir>` under the same wrapper; membership guards `scripts/check_test_wiring.py --check-profile e2e-smoke <listing.json>` and `scripts/check_security_smoke_count.sh check <listing.json>` |
| PR, non-doc change | `S3 Implemented Tests` | `ci.yml` `s3-implemented-tests` | Via aggregate | build `rustfs`, then `scripts/s3-tests/run.sh` with the job's `DEPLOY_MODE` / `TEST_MODE` / `MAXFAIL` env |
| PR, non-doc change | `S3 Lifecycle Behavior Tests` | `ci.yml` `s3-lifecycle-behavior-tests` | Via aggregate | `scripts/s3-tests/run.sh` with the job's accelerated-scanner env |
| PR touching `paths` in `audit.yml` | `Cargo Deny`, `Workflow Pin Report`, `Dependency Review` | `audit.yml` `cargo-deny`, `workflow-pin-report`, `dependency-review` | Report-only | `cargo deny check`; `scripts/security/check_workflow_pins.sh` |
| PR touching `paths` in `architecture-migration-rules.yml` | `Architecture Migration Rules` | `architecture-migration-rules.yml` `architecture-migration-rules` | Report-only | `scripts/check_architecture_migration_rules.sh` |
| PR touching `paths` in `nix.yml` | `Nix Build & Check` | `nix.yml` `nix-validation` | Report-only | `nix flake check` |
| PR touching `paths` in `fuzz.yml` | `Build Fuzz Harness`, `Smoke / <target>` | `fuzz.yml` `fuzz-build`, `pr-fuzz-smoke` | Report-only | `MAX_TOTAL_TIME=60 ./scripts/fuzz/run.sh` |
| PR touching `paths` in `windows-filesystem.yml` | `Rename Safety` | `windows-filesystem.yml` `rename-safety` | Report-only | the `cargo test -p rustfs-ecstore --lib <filter>` commands in the job, on Windows |
@@ -59,6 +67,8 @@ The aggregate requires the validation lanes already selected by `ci.yml`; this c
e2e filters live in `.config/nextest.toml`; extend a profile instead of adding a second selector. Before a profile runs, `scripts/check_test_wiring.py` compares its listing to the committed digest in `.config/e2e-<profile>-selection.txt`, so a silent test drop fails closed.
Architecture migration rules run once in the required Quick Checks job. Migration proofs reuse the successful workspace run: `scripts/check_migration_gate_count.sh evidence <core-listing.json> <junit.xml>` checks the unchanged name selection and committed floor, and requires exactly one successful execution without retries for each proof. Missing, filtered, skipped, failed, or duplicate evidence fails the gate. Local `check` and `run` modes remain available. Upgrade compatibility builds one current server and shares its binary and provenance sidecar across the seven cases; each case independently verifies the pinned previous release checksum and runs its existing test.
Scanner usage and heal rebuild coverage are intentionally split by risk and
cost. `data_usage_test` runs in the PR `e2e-smoke` lane so changes that affect
authoritative scanner usage publication, quota-visible usage, or admin usage
+10 -14
View File
@@ -2410,13 +2410,11 @@ mod tests {
rustfs_credentials::set_global_rpc_secret("top-rpc-local-test-secret".to_owned()).unwrap();
let runtime = spawn_local_trace_capture_runtime(std::path::Path::new(&state), &stop).unwrap();
let emit = async {
tokio::time::timeout(Duration::from_secs(10), async {
while telemetry_trace_subscriber_count() == 0 {
tokio::task::yield_now().await;
}
})
.await
.expect("top.rpc service subscriber");
// Executable hashing precedes subscription. The parent's bounded
// request and stdin cancellation govern this readiness wait.
while telemetry_trace_subscriber_count() == 0 {
tokio::task::yield_now().await;
}
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
@@ -2552,13 +2550,11 @@ mod tests {
tokio::runtime::Runtime::new().unwrap().block_on(async {
let runtime = spawn_local_trace_capture_runtime(std::path::Path::new(&state), &stop).unwrap();
let emit = async {
tokio::time::timeout(Duration::from_secs(10), async {
while telemetry_trace_subscriber_count() == 0 {
tokio::task::yield_now().await;
}
})
.await
.expect("top.api service subscriber");
// Executable hashing precedes subscription. The parent's bounded
// request and stdin cancellation govern this readiness wait.
while telemetry_trace_subscriber_count() == 0 {
tokio::task::yield_now().await;
}
for (operation, status) in [
(S3Operation::GetObject, 200),
(S3Operation::GetObject, 503),
+29 -18
View File
@@ -345,9 +345,14 @@ fn local_top_export_is_private_no_clobber_cancel_safe_and_rejects_forged_artifac
}
#[test]
fn production_cli_exports_top_net_and_fails_closed_for_unavailable_unsupported_and_invalid_runs() {
fn production_cli_exports_top_net_and_fails_closed_for_unavailable_and_invalid_runs() {
let directory = tempfile::tempdir().expect("CLI directory");
let state = directory.path().join("state");
let offline_identity = rustfs::connect::OfflineKeyStore::new(&state)
.load_or_create()
.expect("offline identity");
let offline_key_id =
hex_simd::encode_to_string(Sha256::digest(offline_identity.public_key_der()), hex_simd::AsciiCase::Lower);
let identity = rustfs::connect::IdentityStore::new(state.join("identity"))
.load_or_create()
.expect("enrolled identity");
@@ -412,26 +417,32 @@ fn production_cli_exports_top_net_and_fails_closed_for_unavailable_unsupported_a
.verify(&signed, &signature)
.expect("valid ES256 signature");
let locks_output = directory.path().join("locks.zip");
let locks = top_command("locks", &state, &locks_output, "019e3ae0-0000-7000-8000-000000000031", 1, true)
.output()
.expect("run top.locks outside the server process");
assert!(!locks.status.success());
let stdout = String::from_utf8(locks.stdout).expect("UTF-8 stdout");
assert!(stdout.contains(r#""outcome":"FAILED""#));
assert!(stdout.contains(r#""reasonCode":"SOURCE_UNAVAILABLE""#));
assert!(!locks_output.exists());
for (index, tool) in ["api", "rpc"].into_iter().enumerate() {
// Service-backed captures require an explicit offline identity pin before
// connecting to the server or emitting an export.
for (index, tool) in ["locks", "api", "rpc", "disk"].into_iter().enumerate() {
let output = directory.path().join(format!("{tool}.zip"));
let artifact_uid = format!("019e3ae0-0000-7000-8000-00000000003{}", index + 1);
let run = top_command(tool, &state, &output, &artifact_uid, 1, true)
.output()
.expect("run unsupported top command");
assert!(!run.status.success());
let mut command = top_command(tool, &state, &output, &artifact_uid, 1, true);
let run = command.output().expect("run top command without an offline identity pin");
assert!(!run.status.success(), "{tool} must reject a missing offline identity pin");
let stdout = String::from_utf8(run.stdout).expect("UTF-8 stdout");
assert!(stdout.contains("\"outcome\":\"UNSUPPORTED\""));
assert!(stdout.contains("\"reasonCode\":\"UNSUPPORTED_TOOL\""));
let stderr = String::from_utf8(run.stderr).expect("UTF-8 stderr");
assert!(stdout.is_empty(), "{tool} stdout: {stdout}");
assert!(
stderr.contains("--offline-key-id must select an existing offline identity"),
"{tool} stderr: {stderr}"
);
assert!(!output.exists());
let unavailable = command
.args(["--offline-key-id", &offline_key_id])
.output()
.expect("run service capture without the server");
assert!(!unavailable.status.success(), "{tool} must reject an unavailable server");
let stdout = String::from_utf8(unavailable.stdout).expect("UTF-8 stdout");
let stderr = String::from_utf8(unavailable.stderr).expect("UTF-8 stderr");
assert!(stdout.is_empty(), "{tool} stdout: {stdout}");
assert!(stderr.contains("telemetry server runtime is unavailable"), "{tool} stderr: {stderr}");
assert!(!output.exists());
}
@@ -6,7 +6,7 @@
# #1052 — all closed) and now keep the resulting boundaries from rotting
# (facade bypasses, compat-shim resurrection, owner-module drift). Closed
# migration issues are NOT a reason to retire this script or its pins.
# Runs in ci.yml Quick Checks and .github/workflows/architecture-migration-rules.yml.
# Runs in ci.yml Quick Checks for every pull request.
set -euo pipefail
+15 -5
View File
@@ -4,8 +4,8 @@
# ci.yml's migration gate selects tests BY NAME SUBSTRING. A rename that drops
# a test out of the filter silently thins the gate — potentially to zero —
# without any CI signal (this is how the layered gate died when #878 closed).
# This script owns the filter expression so the count check and the test run
# cannot drift, and fails fast when the number of selected tests drops below
# The shared filter keeps the count check, evidence check, and test run aligned.
# Fail fast when the number of selected tests drops below
# the committed floor in .config/migration-gate-floor.txt.
#
# NAMING CONVENTION DEPENDENCY: migration-gate tests are matched by these
@@ -28,24 +28,34 @@
# scripts/check_migration_gate_count.sh # count check + run the gate
# scripts/check_migration_gate_count.sh check # count check only
# scripts/check_migration_gate_count.sh run # run the gate only
# scripts/check_migration_gate_count.sh evidence CORE_LISTING JUNIT
# Verify the same selection already passed in the workspace test run.
set -euo pipefail
cd "$(dirname "$0")/.."
# Single source of truth for the migration-gate target and filter. The
# Single source of truth for the migration-gate target and shared filter. The
# test-util feature activates migration-critical tests that otherwise leave
# their shared fixtures compiled but unused. ci.yml must invoke this script
# instead of inlining either selection.
MIGRATION_GATE_TARGET_ARGS=(-p rustfs-ecstore --lib --features test-util)
MIGRATION_GATE_FILTER='test(data_movement) or test(rebalance) or test(decommission) or test(source_cleanup) or test(delete_marker)'
MIGRATION_GATE_FILTER="$(python3 scripts/check_migration_gate_evidence.py --filter)"
FLOOR_FILE=".config/migration-gate-floor.txt"
mode="${1:-all}"
case "$mode" in
all | check | run) ;;
evidence)
if [[ "$#" -ne 3 ]]; then
echo "usage: $0 evidence CORE_LISTING JUNIT" >&2
exit 2
fi
python3 scripts/check_migration_gate_evidence.py "$2" "$3" "$FLOOR_FILE"
exit
;;
*)
echo "usage: $0 [all|check|run]" >&2
echo "usage: $0 [all|check|run|evidence CORE_LISTING JUNIT]" >&2
exit 2
;;
esac
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env python3
"""Verify migration proofs already passed in the workspace nextest run."""
import json
from pathlib import Path
import sys
import xml.etree.ElementTree as ET
SUITE = "rustfs-ecstore"
NAME_PARTS = ("data_movement", "rebalance", "decommission", "source_cleanup", "delete_marker")
def unique_object(pairs):
result = {}
for key, value in pairs:
if key in result:
raise ValueError(f"duplicate JSON key: {key}")
result[key] = value
return result
def library_suite(path):
listing = json.loads(Path(path).read_text(), object_pairs_hook=unique_object)
suite = listing["rust-suites"][SUITE]
if any(suite.get(key) != value for key, value in {
"package-name": SUITE, "binary-id": SUITE, "kind": "lib", "status": "listed",
}.items()) or not isinstance(suite.get("testcases"), dict):
raise ValueError(f"{path}: expected the listed {SUITE} library test binary")
return suite["testcases"]
def verify(core_path, junit_path, floor_path):
floor_lines = [line.strip() for line in Path(floor_path).read_text().splitlines()
if line.strip() and not line.lstrip().startswith("#")]
if len(floor_lines) != 1 or not floor_lines[0].isdigit() or int(floor_lines[0]) <= 0:
raise ValueError("migration floor must contain one positive integer")
floor = int(floor_lines[0])
expected = set()
for name, case in library_suite(core_path).items():
if not any(part in name for part in NAME_PARTS) or case.get("ignored") is True:
continue
if (case.get("kind") != "test" or case.get("ignored") is not False
or case.get("filter-match", {}).get("status") != "matches"):
raise ValueError(f"{name}: migration test is malformed or filtered from the core run")
expected.add(name)
if len(expected) < floor:
raise ValueError(f"migration selection has {len(expected)} tests, below the committed floor {floor}")
report = ET.parse(junit_path).getroot()
if (report.tag != "testsuites" or int(report.get("tests", "0")) < len(expected)
or report.get("failures") != "0" or report.get("errors") != "0"):
raise ValueError("core JUnit must report a successful nonempty nextest run")
suites = [suite for suite in report.findall("testsuite") if suite.get("name") == SUITE]
if len(suites) != 1:
raise ValueError("core JUnit must contain exactly one migration library suite")
suite_cases = set(suites[0].findall("testcase"))
executions = {}
for case in report.iter("testcase"):
if case.get("classname") == SUITE:
executions.setdefault(case.get("name"), []).append(case)
for name in sorted(expected):
cases = executions.get(name, [])
if len(cases) != 1 or cases[0] not in suite_cases:
raise ValueError(f"{name}: expected exactly one core JUnit execution in the migration library suite")
case = cases[0]
if (case.get("status", "passed") != "passed"
or any(child.tag not in ("system-out", "system-err", "properties") for child in case)):
raise ValueError(f"{name}: migration proof failed, skipped, or required a retry")
return len(expected), floor
def main():
if sys.argv[1:] == ["--filter"]:
print(" or ".join(f"test({part})" for part in NAME_PARTS))
return 0
if len(sys.argv) != 4:
print("usage: check_migration_gate_evidence.py CORE_LISTING JUNIT FLOOR | --filter", file=sys.stderr)
return 2
try:
count, floor = verify(*sys.argv[1:])
except (OSError, ValueError, TypeError, KeyError, AttributeError, ET.ParseError) as error:
print(f"migration gate evidence failed: {error}", file=sys.stderr)
return 1
print(f"migration gate evidence OK: {count} proofs passed without retries (floor: {floor})")
return 0
if __name__ == "__main__":
sys.exit(main())
+139 -5
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Select safe documentation-only CI and verify the complete required job set."""
"""Select conservative PR scopes and verify the complete required job set."""
from __future__ import annotations
import json
@@ -9,6 +9,8 @@ import re
import subprocess
import sys
import tempfile
import tomllib
from unittest.mock import patch
import unittest
ROOT = Path(__file__).resolve().parent.parent
@@ -19,6 +21,19 @@ CODE_JOBS = (
"build-rustfs-debug-binary", "uring-integration", "e2e-tests",
"s3-implemented-tests", "s3-lifecycle-behavior-tests",
)
# These jobs still exercise the server and its black-box harness when only E2E
# Rust sources change. Production code, manifests and shared test configuration
# always select the full matrix. No production package depends on e2e_test.
E2E_JOBS = (
"test-and-lint", "build-rustfs-debug-binary", "e2e-tests",
"s3-implemented-tests", "s3-lifecycle-behavior-tests",
)
E2E_SELECTION_FILES = {
f".config/{profile}-selection.txt" for profile in (
"e2e-smoke", "e2e-full", "e2e-nightly", "e2e-repl-nightly",
"e2e-distributed", "e2e-protocols", "e2e-odm-interop",
)
}
OPTIONAL_JOBS = ("build-rustfs-debug-binary-rio-v2", "e2e-tests-rio-v2", "e2e-full")
NON_VALIDATION_JOBS = {"required-checks", "cancel-closed-pr-runs", "alert-on-failure"}
@@ -36,6 +51,53 @@ def documentation_path(path: str) -> bool:
return path.startswith("docs/") and path.endswith((".png", ".jpg", ".svg"))
def e2e_crate_is_isolated(root: Path) -> bool:
"""A new dependency on the harness invalidates the test-only shortcut."""
target = (root / "crates/e2e_test").resolve()
manifests = {root / "Cargo.toml"}
def references_harness(value: object, directory: Path) -> bool:
if not isinstance(value, dict):
return False
for key, item in value.items():
if key in ("dependencies", "dev-dependencies", "build-dependencies") and isinstance(item, dict):
for name, dependency in item.items():
if name == "e2e_test":
return True
if isinstance(dependency, dict):
if dependency.get("package") == "e2e_test":
return True
if isinstance(dependency.get("path"), str):
dependency_root = (directory / dependency["path"]).resolve()
if dependency_root == target or not dependency_root.is_relative_to(root.resolve()):
return True
# Cargo also includes in-tree path dependencies that
# are not explicitly listed as workspace members.
manifests.add(dependency_root / "Cargo.toml")
if references_harness(item, directory):
return True
return False
try:
workspace = tomllib.loads((root / "Cargo.toml").read_text())
for member in workspace["workspace"]["members"]:
members = list(root.glob(member))
if not members:
return False
manifests.update(directory / "Cargo.toml" for directory in members if directory.resolve() != target)
visited = set()
while manifests:
path = manifests.pop().resolve()
if path in visited:
continue
visited.add(path)
if references_harness(tomllib.loads(path.read_text()), path.parent):
return False
return True
except (OSError, ValueError, KeyError, TypeError):
return False
def select_mode(event: str, base: str, head: str, root: Path) -> str:
if event != "pull_request" or not all(re.fullmatch(r"[0-9a-f]{40}", sha) for sha in (base, head)):
return "full"
@@ -47,16 +109,24 @@ def select_mode(event: str, base: str, head: str, root: Path) -> str:
except (subprocess.CalledProcessError, UnicodeError):
return "full"
paths = changed.rstrip("\0").split("\0") if changed else []
return "docs" if paths and all(documentation_path(path) for path in paths) else "full"
if paths and all(documentation_path(path) for path in paths):
return "docs"
if paths and all(documentation_path(path) or path in E2E_SELECTION_FILES or (
path.startswith("crates/e2e_test/src/") and path.endswith(".rs")
and not any(part in (".", "..") for part in path.split("/"))
and not any(ord(char) < 32 for char in path)
) for path in paths) and e2e_crate_is_isolated(root):
return "e2e"
return "full"
def expected_results(mode: str, event: str, ref: str) -> dict[str, str]:
if event not in ("pull_request", "push", "merge_group", "schedule", "workflow_dispatch"):
raise ValueError(f"unsupported CI event: {event!r}")
if mode not in ("docs", "full") or (mode == "docs" and event != "pull_request"):
if mode not in ("docs", "e2e", "full") or (mode != "full" and event != "pull_request"):
raise ValueError(f"invalid CI selection: {mode!r} for {event!r}")
expected = {job: "success" for job in ALWAYS_JOBS}
expected.update({job: "success" if mode == "full" else "skipped" for job in CODE_JOBS})
expected.update({job: "success" if mode == "full" or (mode == "e2e" and job in E2E_JOBS) else "skipped" for job in CODE_JOBS})
rio = mode == "full" and event in ("schedule", "workflow_dispatch")
expected.update({job: "success" if rio else "skipped" for job in OPTIONAL_JOBS[:2]})
full = mode == "full" and (event in ("merge_group", "workflow_dispatch") or (event == "push" and ref == "refs/heads/main"))
@@ -158,6 +228,62 @@ def check_workflow(root: Path) -> list[str]:
class SelfTests(unittest.TestCase):
def test_e2e_shortcut_rejects_new_direct_renamed_and_target_dependencies(self):
self.assertTrue(e2e_crate_is_isolated(ROOT))
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / "rustfs").mkdir()
(root / "Cargo.toml").write_text('[workspace]\nmembers = ["rustfs"]\n')
path = root / "rustfs/Cargo.toml"
path.write_text('[package]\nname = "rustfs"\n')
self.assertTrue(e2e_crate_is_isolated(root))
for dependency in (
'[dependencies]\ne2e_test = "1"\n',
'[dev-dependencies]\nharness = { package = "e2e_test", version = "1" }\n',
'[target.\'cfg(unix)\'.build-dependencies]\nharness = { path = "../crates/e2e_test" }\n',
):
path.write_text(dependency)
self.assertFalse(e2e_crate_is_isolated(root), dependency)
path.unlink()
self.assertFalse(e2e_crate_is_isolated(root))
def test_e2e_shortcut_traverses_implicit_path_dependencies(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / "rustfs").mkdir()
(root / "helper").mkdir()
(root / "Cargo.toml").write_text('[workspace]\nmembers = ["rustfs"]\n')
(root / "rustfs/Cargo.toml").write_text('[dependencies]\nhelper = { path = "../helper" }\n')
helper = root / "helper/Cargo.toml"
helper.write_text('[package]\nname = "helper"\n')
self.assertTrue(e2e_crate_is_isolated(root))
helper.write_text('[dependencies]\nharness = { path = "../crates/e2e_test" }\n')
self.assertFalse(e2e_crate_is_isolated(root))
helper.unlink()
self.assertFalse(e2e_crate_is_isolated(root))
def test_e2e_scope_keeps_production_and_shared_configuration_full(self):
cases = (
(["crates/e2e_test/src/distributed/harness.rs"], "e2e"),
(["README.md", "crates/e2e_test/src/common.rs"], "e2e"),
(["crates/e2e_test/src/distributed/harness.rs", ".config/e2e-distributed-selection.txt"], "e2e"),
([".config/e2e-full-selection.txt"], "e2e"),
([".config/unrecognized-selection.txt"], "full"),
(["crates/e2e_test/src/common.rs", "crates/ecstore/src/lib.rs"], "full"),
(["crates/e2e_test/src/common.rs", "crates/e2e_test/Cargo.toml"], "full"),
(["crates/e2e_test/build.rs"], "full"),
(["crates/e2e_test/src/fixture.json"], "full"),
([".config/nextest.toml"], "full"),
(["Cargo.lock"], "full"),
(["scripts/e2e_binary.py"], "full"),
(["crates/e2e_test/src/../Cargo.toml.rs"], "full"),
(["crates/e2e_test/src/unusual\nname.rs"], "full"),
([], "full"),
)
for paths, expected in cases:
with self.subTest(paths=paths), patch("subprocess.check_output", return_value=("\0".join(paths) + "\0").encode()):
self.assertEqual(select_mode("pull_request", "a" * 40, "b" * 40, ROOT), expected)
def test_documentation_paths_do_not_hide_build_or_fixture_changes(self):
for path in ("README.md", "AGENTS.md", "crates/utils/AGENTS.md", "docs/testing/README.md", "docs/diagram.svg", ".agents/skills/example/SKILL.md"):
self.assertTrue(documentation_path(path), path)
@@ -192,15 +318,19 @@ class SelfTests(unittest.TestCase):
self.assertEqual({job for job, state in ordinary.items() if state == "skipped"}, set(OPTIONAL_JOBS))
docs = expected_results("docs", "pull_request", "refs/pull/1/merge")
self.assertEqual({job for job, state in docs.items() if state == "success"}, set(ALWAYS_JOBS))
e2e = expected_results("e2e", "pull_request", "refs/pull/1/merge")
self.assertEqual({job for job, state in e2e.items() if state == "success"}, set(ALWAYS_JOBS + E2E_JOBS))
for event in ("schedule", "workflow_dispatch", "merge_group", "push"):
result = expected_results("full", event, "refs/heads/main")
self.assertEqual(result["e2e-full"], "skipped" if event == "schedule" else "success")
self.assertEqual(result["e2e-tests-rio-v2"], "success" if event in ("schedule", "workflow_dispatch") else "skipped")
with self.assertRaises(ValueError):
expected_results("docs", event, "refs/heads/main")
with self.assertRaises(ValueError):
expected_results("e2e", event, "refs/heads/main")
def test_every_wrong_result_missing_job_or_selection_fails_closed(self):
for mode, event in (("full", "pull_request"), ("docs", "pull_request"), ("full", "schedule"), ("full", "workflow_dispatch"), ("full", "merge_group")):
for mode, event in (("full", "pull_request"), ("docs", "pull_request"), ("e2e", "pull_request"), ("full", "schedule"), ("full", "workflow_dispatch"), ("full", "merge_group")):
good = {job: {"result": value} for job, value in expected_results(mode, event, "refs/heads/main").items()}
good["classify-changes"]["outputs"] = {"mode": mode}
self.assertEqual(verify_results(good, event, "refs/heads/main"), [])
@@ -324,6 +454,9 @@ class SelfTests(unittest.TestCase):
for event, changed, base_sha, available, broken, expected in (
("pull_request", "README.md", "b" * 40, True, False, "docs"),
("pull_request", "src/server.rs", "b" * 40, True, False, "full"),
("pull_request", "crates/e2e_test/src/distributed/harness.rs", "b" * 40, True, False, "e2e"),
("pull_request", "crates/e2e_test/Cargo.toml", "b" * 40, True, False, "full"),
("pull_request", ".config/nextest.toml", "b" * 40, True, False, "full"),
("pull_request", "README.md", "b" * 40, False, False, "full"),
("merge_group", "README.md", "b" * 40, False, False, "full"),
("pull_request", "README.md", "b" * 40, True, True, None),
@@ -333,6 +466,7 @@ class SelfTests(unittest.TestCase):
root = Path(directory)
(root / "scripts").mkdir()
(root / "scripts/ci_gate.py").write_text("raise SystemExit(71)\n")
(root / "Cargo.toml").write_text('[workspace]\nmembers = []\n')
(root / "python3").symlink_to(sys.executable)
base = root / "base-policy.py"
base.write_text("raise SystemExit(29)\n" if broken else Path(__file__).read_text())
+14 -10
View File
@@ -929,7 +929,7 @@ install_python_package() {
}
if ! command -v awscurl >/dev/null 2>&1; then
install_python_package awscurl || {
install_python_package "awscurl==0.44" || {
log_error "Failed to install awscurl"
exit 1
}
@@ -1021,15 +1021,18 @@ fi
cd "${PROJECT_ROOT}/s3-tests"
# Install tox if not available
if ! command -v tox >/dev/null 2>&1; then
install_python_package tox || {
log_error "Failed to install tox"
# Match the weekly compatibility workflow even on runners with an older tox.
TOX_VERSION="$(tox --version 2>/dev/null || true)"
if [[ "${TOX_VERSION%% *}" != "4.60.0" ]]; then
install_python_package "tox==4.60.0" || {
log_error "Failed to install tox 4.60.0"
exit 1
}
# Add common Python user bin directories to PATH (same as awscurl)
PYTHON_VERSION=$(python3 -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')" 2>/dev/null || echo "3.14")
export PATH="$HOME/Library/Python/${PYTHON_VERSION}/bin:$HOME/.local/bin:$PATH"
fi
TOX_VERSION="$(tox --version 2>/dev/null || true)"
if [[ "${TOX_VERSION%% *}" != "4.60.0" ]]; then
log_error "Expected tox 4.60.0, found: ${TOX_VERSION:-unavailable}"
exit 1
fi
# Step 9: Run ceph s3-tests
@@ -1039,10 +1042,11 @@ mkdir -p "${ARTIFACTS_DIR}"
XDIST_ARGS=""
if [ "${XDIST}" != "0" ]; then
# Add pytest-xdist to requirements.txt so tox installs it inside its virtualenv
grep -qxF "pytest-xdist" requirements.txt || echo "pytest-xdist" >> requirements.txt
grep -qxF "pytest-xdist==3.8.0" requirements.txt || echo "pytest-xdist==3.8.0" >> requirements.txt
XDIST_ARGS="-n ${XDIST} --dist=loadgroup"
fi
grep -qxF "pytest-timeout" requirements.txt || echo "pytest-timeout" >> requirements.txt
grep -qxF "pytest-timeout==2.4.0" requirements.txt || echo "pytest-timeout==2.4.0" >> requirements.txt
grep -qxF "tox==4.60.0" requirements.txt || echo "tox==4.60.0" >> requirements.txt
# Resolve config path (absolute path for tox)
if [[ "${S3TESTS_CONF}" = /* ]]; then
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""Exercise S3 harness tool setup without installing packages or starting RustFS."""
from __future__ import annotations
import os
import subprocess
import tempfile
import unittest
from pathlib import Path
SOURCE = Path(__file__).with_name("run.sh").read_text()
TOX_SETUP = SOURCE[
SOURCE.index("# Match the weekly compatibility workflow") : SOURCE.index("# Step 9: Run ceph s3-tests")
]
PLUGIN_SETUP = SOURCE[SOURCE.index('XDIST_ARGS=""') : SOURCE.index("# Resolve config path (absolute path for tox)")]
PATH_SETUP = SOURCE[SOURCE.index("# Ensure user-level Python scripts") : SOURCE.index("# Configuration")]
INSTALLER = SOURCE[SOURCE.index("ensure_python_pip() {") : SOURCE.index('if ! command -v awscurl')]
class RunnerToolsTests(unittest.TestCase):
def test_real_pip_installer_finds_new_user_binary_without_uv(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
tools = root / "tools"
tools.mkdir()
python = tools / "python3"
python.write_text(r'''#!/bin/bash
if [[ "$1" == -c ]]; then
printf '3.12\n'
elif [[ "$*" == '-m pip --version' ]]; then
printf 'pip 25.0\n'
elif [[ "$*" == *'tox==4.60.0'* ]]; then
mkdir -p "$TOOL_TEST_HOME/.local/bin"
printf '#!/bin/sh\nprintf "4.60.0 from user-site\\n"\n' > "$TOOL_TEST_HOME/.local/bin/tox"
chmod +x "$TOOL_TEST_HOME/.local/bin/tox"
else
exit 99
fi
''')
python.chmod(0o755)
awscurl = tools / "awscurl"
awscurl.write_text("#!/bin/sh\nexit 0\n")
awscurl.chmod(0o755)
# Redirect only the extracted home paths into this test's sandbox.
# Keep the real initialization and installer to exercise PATH ordering.
script = (PATH_SETUP + INSTALLER + TOX_SETUP).replace("$HOME", "$TOOL_TEST_HOME")
script = 'log_error() { printf "%s\\n" "$*" >&2; }\n' + script
script += 'command -v tox\n'
result = subprocess.run(
["/bin/bash", "-euo", "pipefail", "-c", script],
env={**os.environ, "PATH": f"{tools}:/usr/bin:/bin", "TOOL_TEST_HOME": str(root)},
capture_output=True,
text=True,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout.strip(), str(root / ".local/bin/tox"))
def test_tox_version_is_enforced_before_collection(self) -> None:
stubs = r'''
tox() {
[[ "$TOOL_TEST_VERSION" != missing ]] || return 127
printf '%s from /runner/tox\n' "$TOOL_TEST_VERSION"
}
install_python_package() {
printf 'INSTALL %s\n' "$1"
[[ "$TOOL_TEST_INSTALL" != fail ]] || return 1
if [[ "$TOOL_TEST_INSTALL" != shadowed ]]; then
TOOL_TEST_VERSION=4.60.0
fi
}
log_error() { printf '%s\n' "$*" >&2; }
'''
for version, install, expected, installs in (
("4.60.0", "ok", 0, 0),
("4.59.0", "ok", 0, 1),
("missing", "ok", 0, 1),
("4.59.0", "fail", 1, 1),
("4.59.0", "shadowed", 1, 1),
):
with self.subTest(version=version, install=install):
result = subprocess.run(
["bash", "-euo", "pipefail", "-c", stubs + TOX_SETUP + "printf 'COLLECT\n'"],
env={**os.environ, "TOOL_TEST_VERSION": version, "TOOL_TEST_INSTALL": install},
capture_output=True,
text=True,
)
self.assertEqual(result.returncode, expected, result.stderr)
self.assertEqual(result.stdout.count("INSTALL tox==4.60.0"), installs)
self.assertEqual("COLLECT" in result.stdout, expected == 0)
if install == "shadowed":
self.assertIn("Expected tox 4.60.0", result.stderr)
def test_plugin_pins_preserve_serial_and_parallel_selection(self) -> None:
for workers in ("0", "2"):
with self.subTest(workers=workers), tempfile.TemporaryDirectory() as directory:
requirements = Path(directory) / "requirements.txt"
requirements.write_text("pytest\ntox\n")
result = subprocess.run(
[
"bash", "-euo", "pipefail", "-c",
PLUGIN_SETUP + PLUGIN_SETUP + 'printf "%s" "$XDIST_ARGS"',
],
cwd=directory,
env={**os.environ, "XDIST": workers},
capture_output=True,
text=True,
)
self.assertEqual(result.returncode, 0, result.stderr)
dependencies = requirements.read_text().splitlines()
self.assertEqual(dependencies.count("tox==4.60.0"), 1)
self.assertEqual(dependencies.count("pytest-timeout==2.4.0"), 1)
self.assertEqual(dependencies.count("pytest-xdist==3.8.0"), int(workers != "0"))
self.assertEqual(result.stdout, "" if workers == "0" else "-n 2 --dist=loadgroup")
if __name__ == "__main__":
unittest.main()
+170
View File
@@ -0,0 +1,170 @@
#!/usr/bin/env python3
"""Check that reusing core test evidence cannot silently drop migration proofs."""
import copy
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
import xml.etree.ElementTree as ET
from check_migration_gate_evidence import NAME_PARTS, SUITE, verify
class MigrationEvidenceTests(unittest.TestCase):
def setUp(self):
directory = tempfile.TemporaryDirectory()
self.addCleanup(directory.cleanup)
self.root = Path(directory.name)
self.listing = self.root / "core.json"
self.junit = self.root / "junit.xml"
self.floor = self.root / "floor.txt"
self.floor.write_text("# Existing floor\n2\n")
self.names = ("store::rebalance_commits", "object::delete_marker_preserves_version")
self.suite = {
"package-name": SUITE, "binary-id": SUITE, "kind": "lib", "status": "listed",
"testcases": {name: self.runnable_case() for name in self.names},
}
self.data = {"rust-suites": {SUITE: self.suite}}
self.write_listing()
self.write_junit(self.names)
@staticmethod
def runnable_case():
return {"kind": "test", "ignored": False, "filter-match": {"status": "matches"}}
def write_listing(self):
self.listing.write_text(json.dumps(self.data))
def write_junit(self, names):
report = ET.Element("testsuites", tests=str(len(names)), failures="0", errors="0")
suite = ET.SubElement(report, "testsuite", name=SUITE)
for name in names:
ET.SubElement(suite, "testcase", name=name, classname=SUITE)
ET.ElementTree(report).write(self.junit)
def check(self):
return verify(self.listing, self.junit, self.floor)
def test_successful_core_evidence_meets_the_existing_floor(self):
self.assertEqual(self.check(), (2, 2))
def test_substring_selection_matches_the_canonical_nextest_filter(self):
names = [f"nested::prefix_{part}_suffix" for part in NAME_PARTS]
self.suite["testcases"] = {name: self.runnable_case() for name in names}
for name in ("nested::Rebalance", "nested::rebalancing", "nested::ordinary_test"):
self.suite["testcases"][name] = self.runnable_case()
self.data["rust-suites"]["other-package"] = copy.deepcopy(self.suite)
self.write_listing()
self.write_junit(names)
self.assertEqual(self.check(), (5, 2))
script = Path(__file__).with_name("check_migration_gate_evidence.py")
result = subprocess.run([sys.executable, str(script), "--filter"], capture_output=True, text=True, check=True)
self.assertEqual(result.stdout.strip(),
"test(data_movement) or test(rebalance) or test(decommission) or test(source_cleanup) or test(delete_marker)")
def test_ignored_tests_do_not_count_towards_the_floor(self):
self.suite["testcases"]["store::rebalance_ignored"] = dict(self.runnable_case(), ignored=True)
self.write_listing()
self.assertEqual(self.check(), (2, 2))
self.suite["testcases"][self.names[0]]["ignored"] = True
self.write_listing()
with self.assertRaisesRegex(ValueError, "below the committed floor"):
self.check()
def test_filtered_migration_test_is_rejected_even_above_the_floor(self):
case = self.runnable_case()
case["filter-match"] = {"status": "mismatch", "reason": "expression"}
self.suite["testcases"]["store::rebalance_filtered"] = case
self.write_listing()
with self.assertRaisesRegex(ValueError, "filtered"):
self.check()
def test_wrong_library_identity_and_unlisted_suite_are_rejected(self):
for key, value in (("package-name", "impostor"), ("binary-id", "other"), ("kind", "test"), ("status", "skipped")):
with self.subTest(key=key):
bad = copy.deepcopy(self.data)
bad["rust-suites"][SUITE][key] = value
self.listing.write_text(json.dumps(bad))
with self.assertRaisesRegex(ValueError, "library test binary"):
self.check()
def test_empty_malformed_and_duplicate_listing_inputs_fail(self):
for value in ("", "[]", "{}", '{"rust-suites":{},"rust-suites":{}}'):
with self.subTest(value=value):
self.listing.write_text(value)
with self.assertRaises((ValueError, KeyError, TypeError)):
self.check()
self.suite["testcases"] = {}
self.write_listing()
with self.assertRaisesRegex(ValueError, "below the committed floor"):
self.check()
def test_missing_duplicate_and_wrong_junit_test_identity_fail(self):
for fault in ("missing", "duplicate", "wrong-class", "wrong-suite", "duplicate-suite"):
with self.subTest(fault=fault):
self.write_junit(self.names)
report = ET.parse(self.junit)
suite = report.getroot().find("testsuite")
case = suite.find("testcase")
if fault == "missing":
suite.remove(case)
elif fault == "duplicate":
suite.append(copy.deepcopy(case))
elif fault == "wrong-class":
case.set("classname", "impostor")
elif fault == "wrong-suite":
suite.set("name", "impostor")
else:
report.getroot().append(copy.deepcopy(suite))
report.write(self.junit)
with self.assertRaises(ValueError):
self.check()
def test_failed_skipped_or_retried_proofs_are_not_successes(self):
for tag in ("failure", "error", "skipped", "rerunFailure", "rerunError", "flakyFailure", "flakyError"):
with self.subTest(tag=tag):
self.write_junit(self.names)
report = ET.parse(self.junit)
ET.SubElement(report.getroot().find("testsuite/testcase"), tag)
report.write(self.junit)
with self.assertRaisesRegex(ValueError, "failed, skipped, or required a retry"):
self.check()
def test_nonempty_successful_junit_and_positive_floor_are_required(self):
for content in ("", "<testsuites/>", '<testsuites tests="2" failures="1" errors="0"/>'):
self.junit.write_text(content)
with self.assertRaises((ValueError, ET.ParseError)):
self.check()
self.write_junit(self.names)
for content in ("", "0", "-1", "2\n3", "invalid"):
self.floor.write_text(content)
with self.assertRaisesRegex(ValueError, "positive integer"):
self.check()
def test_evidence_shell_mode_never_invokes_cargo(self):
scripts = self.root / "scripts"
scripts.mkdir()
source_dir = Path(__file__).resolve().parent
for name in ("check_migration_gate_count.sh", "check_migration_gate_evidence.py"):
shutil.copy(source_dir / name, scripts / name)
(self.root / ".config").mkdir()
shutil.copy(self.floor, self.root / ".config/migration-gate-floor.txt")
commands = self.root / "commands"
commands.mkdir()
cargo = commands / "cargo"
cargo.write_text("#!/bin/sh\necho unexpected cargo invocation >&2\nexit 99\n")
cargo.chmod(0o755)
env = dict(os.environ, PATH=f"{commands}{os.pathsep}{os.environ['PATH']}")
result = subprocess.run(["bash", str(scripts / "check_migration_gate_count.sh"), "evidence",
str(self.listing), str(self.junit)], env=env, capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("2 proofs passed without retries", result.stdout)
if __name__ == "__main__":
unittest.main()