Compare commits

...

4 Commits

Author SHA1 Message Date
唐小鸭 42a4096acb fix(replication): count force-delete outcomes in replication stats
A replicated force-delete that a target rejected (for example a peer whose
credentials lack s3:ForceDeleteObject) was only logged; the per-target
replication counters never saw it, so `replication status` reported zero
failures while the sites had already diverged.

Build a ReplicatedTargetInfo for every target outcome of
replicate_force_delete_to_targets (missing client, offline target,
rejected remove, success) with a Pending previous status, and feed each
through ReplicationStats::update so rejections count as failures and
accepted force-deletes count as replicated.

Document that the managed site-replicator-0 policy must name
s3:ForceDeleteObject explicitly and that a bucket-replication target with
its own credentials needs the same grant.
2026-10-30 14:55:31 +08:00
hector 589b499e3b ci(ft,heal,chain): FT gate 54, heal budget, and a temporary performance lane disable (#9057)
* ci(ft): update the lane gate to the 54-case manifest with scenario F

auto-testing #118/#119 added FT scenario F and case B5, so the lane now
runs 54 cases over scenarios A,B,C,C2,D,E,F and passes 54/54 — but the
workflow gate still pinned the 44-case A-E manifest and failed the
report step. Align the gate and the contract-test fixture with the
54-case manifest.

* ci(heal): give heal a 90-minute run budget inside a 120-minute job

The heal task was still progressing (scanned 494 -> 571+, failed=0)
when the 45-minute step cap killed it, so the verification steps never
ran and the lane produced invalid evidence (chain 40796474356). The
step budget is now 90 minutes, and the job-level budget rises from 60
to 120 minutes so the enclosing job cannot cancel the raised step
budget. The timeout contract now records heal as the exception to the
shared 60/45 baseline.

* ci(chain): force the performance probe offline while pf-testing is broken

The pf-testing environment is temporarily broken, so every nightly
chain would end red in the performance lane. performance is
required=false in scheduled-validations.json, so forcing the probe's
performance_ready output to offline takes the chain through its
designed skip path: the lane job is skipped, aggregate receives
--allow-skipped performance, and the report records the optional-lane
skip with its reason. Marked TEMPORARY; restore the probe once the
environment is healthy again.

* test(table): drop a stale workflow-comment pin left by #9059

#9059 rewrote the table suite step comments but kept the assertion for
the old '# Red only for harness/environment breakdowns' line, so
test_security_workflow.py fails against current main. The semantics
stay pinned by the continue-on-error and HARNESS_OK assertions.
2026-10-30 14:03:22 +08:00
Mathew Beane 69f8e364b0 fix(audit): emit entries for multipart parts and bucket config writes (#9067)
* fix(audit): replay queued payloads for internal event names

Audit entries are persisted by the store targets and read back through
serde. Three internal events (CreateMultipartUpload, AbortMultipartUpload,
DeleteObjects) serialized to a string that EventName::deserialize rejected,
so every queued entry for them was dropped on replay. Read the persisted
event name through a lenient deserialize_with function; EventName's own
Deserialize and parse stay strict because they also back notification
configuration.

* fix(audit): emit entries for multipart parts and bucket config writes

UploadPart, UploadPartCopy, AbortMultipartUpload and the mutating bucket
configuration operations never produced an audit entry: the audit path
required a bucket notification event and these operations have none, or
(AbortMultipartUpload) never built a helper. Add an audit-only
OperationHelper constructor that builds no notification event and wrap
the S3 handlers for these operations with it. Operations without a
notification event use one coarse audit-only event name, with the
operation in api.name.

---------

Co-authored-by: Hauser Zhi <housemecn@gmail.com>
2026-10-30 11:58:54 +08:00
Chris An 191c95a469 ci: enforce affected validation and rehearse bounded shards (#9070)
* ci: enforce affected validation and rehearse bounded shards

* test(ci): keep regression output stable under forced color
2026-10-30 11:58:38 +08:00
32 changed files with 1533 additions and 241 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"schema_version": 1,
"rollout": "shadow",
"rollout": "enforced",
"core_packages": ["rustfs", "rustfs-ecstore", "rustfs-filemeta", "rustfs-iam", "rustfs-policy"],
"connect_packages": [
"rustfs-credentials", "rustfs-config", "rustfs-common", "rustfs-crypto",
+1
View File
@@ -61,6 +61,7 @@ script-tests: ## Run shell script tests
$(RUSTFS_PYTHON_BIN) ./scripts/ci_gate.py --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/check_connect_release_boundary.py --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/test_ci_test_artifact.py
$(RUSTFS_PYTHON_BIN) ./scripts/test_ci_test_shards.py
$(RUSTFS_PYTHON_BIN) ./scripts/test_ci_merge_queue.py
$(RUSTFS_PYTHON_BIN) -m unittest discover -s scripts/ci -p 'test_*.py'
$(RUSTFS_PYTHON_BIN) ./scripts/check_security_coverage.py --self-test
+98 -3
View File
@@ -44,6 +44,11 @@ on:
schedule:
- cron: "11 0 * * *" # Nightly 00:11 UTC, after the 22:41 UTC cache-warm run
workflow_dispatch:
inputs:
rehearse_workspace_shards:
description: Compare one full execution with at most two resource-safe shards of the same archive
type: boolean
default: false
permissions:
contents: read
@@ -87,6 +92,7 @@ jobs:
connect: ${{ steps.scope.outputs.connect || 'true' }}
packages: ${{ steps.scope.outputs.packages }}
full_validation: ${{ steps.scope.outputs.full_validation || 'true' }}
rehearse_shards: ${{ steps.scope.outputs.rehearse_shards || 'false' }}
steps:
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
@@ -97,6 +103,7 @@ jobs:
env:
CI_BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.before }}
CI_EVENT_ACTION: ${{ github.event.action }}
CI_REHEARSE_SHARDS: ${{ inputs.rehearse_workspace_shards && 'true' || 'false' }}
run: |
if [[ "$GITHUB_EVENT_NAME" != "pull_request" ]]; then
python3 -I scripts/ci_gate.py select
@@ -106,9 +113,9 @@ jobs:
printf '%s\n' 'mode=full' >> "$GITHUB_OUTPUT"
echo "Base CI policy unavailable; running the full matrix."
fi
if ! grep -q '^plan=' "$GITHUB_OUTPUT"; then
printf '%s\n' 'mode=full' 'connect=true' 'full_validation=true' >> "$GITHUB_OUTPUT"
echo "Legacy policy has no verification plan; retaining the full matrix."
if ! grep -q '^plan=' "$GITHUB_OUTPUT" || ! grep -q '^rehearse_shards=' "$GITHUB_OUTPUT"; then
printf '%s\n' 'mode=full' 'connect=true' 'full_validation=true' 'rehearse_shards=false' 'packages=' 'plan=' >> "$GITHUB_OUTPUT"
echo "Base policy does not cover this workflow contract; retaining the full matrix."
fi
- name: Retain verification plan
if: steps.scope.outputs.plan != ''
@@ -266,6 +273,8 @@ jobs:
# requires the same runner image until the hosted-runtime pilot passes.
runs-on: sm-standard-4
timeout-minutes: 90
outputs:
workspace_shards: ${{ steps.shards.outputs.workspace_shards || '[]' }}
env:
CI_PACKAGES: ${{ needs.classify-changes.outputs.packages }}
steps:
@@ -354,6 +363,90 @@ jobs:
retention-days: 3
if-no-files-found: error
- name: Prepare bounded shards from this candidate's observed durations
id: shards
if: needs.classify-changes.outputs.rehearse_shards == 'true'
run: |
python3 scripts/ci_test_shards.py prepare \
--listing artifacts/test-and-lint/core-test-listing.json \
--junit target/nextest/ci/junit.xml --output artifacts/workspace-shard-plan
- name: Upload same-run shard plan
if: needs.classify-changes.outputs.rehearse_shards == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: workspace-shard-plan-${{ github.run_attempt }}
path: artifacts/workspace-shard-plan
retention-days: 3
if-no-files-found: error
workspace-shards:
name: Workspace shards (${{ matrix.shard }})
if: needs.classify-changes.outputs.rehearse_shards == 'true'
needs: [classify-changes, workspace-tests]
# Reuse the producer image and checkout path for the full RustFS archive.
runs-on: sm-standard-4
timeout-minutes: 90
strategy:
fail-fast: false
max-parallel: 2
matrix:
shard: ${{ fromJSON(needs.workspace-tests.outputs.workspace_shards || '[]') }}
env:
CI_PACKAGES: ${{ needs.classify-changes.outputs.packages }}
steps:
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- uses: ./.github/actions/test-runtime
- name: Protect Connect test home
run: chmod go-w "$(realpath "$HOME")"
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: workspace-test-binaries-${{ github.run_attempt }}
path: ${{ runner.temp }}
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: workspace-shard-plan-${{ github.run_attempt }}
path: artifacts/workspace-shard-plan
- name: Verify and execute this shard without compilation
env:
SHARD_INDEX: ${{ matrix.shard }}
run: |
python3 scripts/ci_test_artifact.py verify --configuration workspace --archive "$RUNNER_TEMP/workspace-tests.tar.zst"
python3 scripts/ci_test_shards.py execute --plan artifacts/workspace-shard-plan \
--archive "$RUNNER_TEMP/workspace-tests.tar.zst" --index "$SHARD_INDEX" --output artifacts/workspace-shard-result
- name: Retain shard evidence including failures
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: workspace-shard-result-${{ matrix.shard }}-${{ github.run_attempt }}
path: artifacts/workspace-shard-result
retention-days: 3
if-no-files-found: error
workspace-shard-evidence:
name: Workspace shard completeness
if: always() && needs.classify-changes.outputs.rehearse_shards == 'true'
needs: [classify-changes, workspace-tests, workspace-shards]
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: workspace-shard-plan-${{ github.run_attempt }}
path: artifacts/workspace-shard-plan
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: workspace-shard-result-*-${{ github.run_attempt }}
path: artifacts/workspace-shards
- name: Require the exact union of successful shard executions
run: |
python3 scripts/ci_test_shards.py verify --plan artifacts/workspace-shard-plan --evidence artifacts/workspace-shards
./scripts/check_migration_gate_count.sh evidence artifacts/workspace-shard-plan/listing.json artifacts/workspace-shards/combined.xml
connect-feature-boundaries:
name: Connect Feature Boundaries
if: needs.classify-changes.outputs.mode == 'full' && needs.classify-changes.outputs.connect != 'false' && (github.event_name != 'pull_request' || github.event.action != 'closed')
@@ -1298,6 +1391,8 @@ jobs:
- quick-checks
- test-and-lint
- workspace-tests
- workspace-shards
- workspace-shard-evidence
- core-contract
- connect-feature-boundaries
- test-and-lint-rio-v2
@@ -197,21 +197,21 @@ jobs:
jq -e --arg testing_sha "${AUTO_TESTING_SHA}" '
.schema_version == 1 and
.auto_testing_sha == $testing_sha and
.selected_scenarios == ["A", "B", "C", "C2", "D", "E"] and
.selected_scenarios == ["A", "B", "C", "C2", "D", "E", "F"] and
.complete == true and
.counts.expected == 44 and
.counts.seen == 44 and
.counts.expected == 54 and
.counts.seen == 54 and
.counts.harness_errors == 0 and
(.expected_cases | length) == 44 and
(.expected_cases | unique | length) == 44 and
(.seen_cases | length) == 44 and
(.seen_cases | unique | length) == 44 and
(.expected_cases | length) == 54 and
(.expected_cases | unique | length) == 54 and
(.seen_cases | length) == 54 and
(.seen_cases | unique | length) == 54 and
(.seen_cases | sort) == (.expected_cases | sort) and
(.missing_cases | length) == 0 and
(.harness_errors | length) == 0 and
(.cases | length) == 44 and
(.cases | length) == 54 and
([.cases[].case_id] | sort) == (.expected_cases | sort) and
([.cases[].case_id] | unique | length) == 44 and
([.cases[].case_id] | unique | length) == 54 and
all(.cases[]; .verdict == "pass" or .verdict == "known-divergence" or .verdict == "UNEXPECTED") and
.counts.unexpected == ([.cases[] | select(.verdict == "UNEXPECTED")] | length) and
.counts.known_divergence == ([.cases[] | select(.verdict == "known-divergence")] | length)
+14 -6
View File
@@ -281,12 +281,20 @@ jobs:
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
PERFORMANCE_OPTIONAL: ${{ contains(fromJSON(steps.selection.outputs.optional_suites), 'performance') }}
run: |
if python3 scripts/check_functional_runners.py pf-testing; then
echo "performance_ready=online" >> "$GITHUB_OUTPUT"
else
echo "performance_ready=offline" >> "$GITHUB_OUTPUT"
test "$PERFORMANCE_OPTIONAL" = true
fi
# TEMPORARY: the pf-testing environment is broken, so the
# performance lane is disabled until it is repaired. Forcing
# offline keeps the chain on its designed skip path: the lane job
# is skipped, aggregate receives --allow-skipped performance
# (required=false in scheduled-validations.json), and the report
# records the optional-lane skip with its reason.
# Restore the probe below once the environment is healthy again.
# if python3 scripts/check_functional_runners.py pf-testing; then
# echo "performance_ready=online" >> "$GITHUB_OUTPUT"
# else
# echo "performance_ready=offline" >> "$GITHUB_OUTPUT"
# test "$PERFORMANCE_OPTIONAL" = true
# fi
echo "performance_ready=offline" >> "$GITHUB_OUTPUT"
validation:
if: ${{ github.event_name == 'workflow_dispatch' && !inputs.validation_runner_inspect_only && inputs.validation_retain_evidence_run_id == '' && inputs.validation_package_sha == '' && (inputs.validation_case != '' || inputs.validation_tools_only || inputs.validation_native_tools || inputs.validation_shared_tools || inputs.validation_manager_tools || inputs.validation_cleanup_run_id != '' || inputs.validation_cleanup_retained_evidence) }}
+3 -2
View File
@@ -55,7 +55,8 @@ env:
jobs:
heal-test:
runs-on: smoke-testing
timeout-minutes: 60
# 90-minute heal run (see the step below) plus setup, report and cleanup.
timeout-minutes: 120
# Standalone manual run, or one link of the nightly functional chain
# (storage -> heal -> pool). Pool expansion no longer re-runs heal.
if: ${{ inputs.chain_manifest != '' || github.event_name == 'workflow_dispatch' }}
@@ -154,7 +155,7 @@ jobs:
./auto-testing/rustfs_heal_test.sh "${ARGS[@]}" --log-file "${LOG_FILE}"
- name: Run heal test (write -> outage -> heal -> verify)
timeout-minutes: 45
timeout-minutes: 90
id: test
# Collect reports and backlog evidence before the final suite verdict.
continue-on-error: true
@@ -3282,10 +3282,18 @@ async fn replicate_force_delete_to_targets<S: ReplicationStorage>(dobj: &Deleted
let mut join_set = JoinSet::new();
let mut all_succeeded = true;
let mut target_infos = Vec::with_capacity(tgt_arns.len());
for arn in tgt_arns {
let Some(tgt_client) = ReplicationTargetStore::remote_target_client(bucket, &arn).await else {
all_succeeded = false;
target_infos.push(force_delete_target_info(
dobj.op_type,
&arn,
"",
false,
Err(TARGET_CLIENT_UNAVAILABLE_ERROR.to_string()),
));
debug!(
event = EVENT_REPLICATION_FORCE_DELETE_SKIPPED,
component = LOG_COMPONENT_ECSTORE,
@@ -3311,6 +3319,7 @@ async fn replicate_force_delete_to_targets<S: ReplicationStorage>(dobj: &Deleted
let bucket = bucket.clone();
let object_name = object_name.clone();
let op_type = dobj.op_type;
join_set.spawn(async move {
if ReplicationTargetStore::target_is_offline(&tgt_client).await {
@@ -3335,9 +3344,16 @@ async fn replicate_force_delete_to_targets<S: ReplicationStorage>(dobj: &Deleted
user_agent: "Internal: [Replication]".to_string(),
..Default::default()
});
return false;
return force_delete_target_info(
op_type,
&tgt_client.arn,
&tgt_client.endpoint,
tgt_client.secure,
Err(FORCE_DELETE_TARGET_OFFLINE_ERROR.to_string()),
);
}
let started = Instant::now();
if let Err(e) = tgt_client
.remove_object(&tgt_client.bucket, &object_name, None, replication_force_delete_remove_options())
.await
@@ -3364,16 +3380,25 @@ async fn replicate_force_delete_to_targets<S: ReplicationStorage>(dobj: &Deleted
user_agent: "Internal: [Replication]".to_string(),
..Default::default()
});
return false;
return force_delete_target_info(
op_type,
&tgt_client.arn,
&tgt_client.endpoint,
tgt_client.secure,
Err(e.to_string()),
);
}
true
force_delete_target_info(op_type, &tgt_client.arn, &tgt_client.endpoint, tgt_client.secure, Ok(started.elapsed()))
});
}
while let Some(result) = join_set.join_next().await {
match result {
Ok(success) => all_succeeded &= success,
Ok(info) => {
all_succeeded &= info.replication_status == ReplicationStatusType::Completed;
target_infos.push(info);
}
Err(error) => {
all_succeeded = false;
error!(
@@ -3390,6 +3415,8 @@ async fn replicate_force_delete_to_targets<S: ReplicationStorage>(dobj: &Deleted
}
}
record_force_delete_target_stats(bucket, &target_infos).await;
if all_succeeded
&& let Some(operation_id) = dobj.delete_object.force_delete_id
&& let Err(error) = super::replication_pool::complete_force_delete_intent(storage, operation_id).await
@@ -3409,6 +3436,53 @@ async fn replicate_force_delete_to_targets<S: ReplicationStorage>(dobj: &Deleted
all_succeeded
}
const FORCE_DELETE_TARGET_OFFLINE_ERROR: &str = "replication target is offline";
/// One target's outcome of a replicated force-delete in the shape the
/// replication statistics consume. A force-delete carries no per-version
/// replication state, so the previous status is always `Pending`: that is
/// the transition `ReplicationStats::update` counts as a failure, whereas an
/// `Empty` previous status would drop the outcome and leave `replication
/// status` reporting zero failures while the peer still holds the objects.
fn force_delete_target_info(
op_type: ReplicationType,
arn: &str,
endpoint: &str,
secure: bool,
outcome: std::result::Result<std::time::Duration, String>,
) -> ReplicatedTargetInfo {
let mut rinfo = ReplicatedTargetInfo {
arn: arn.to_string(),
op_type,
endpoint: endpoint.to_string(),
secure,
prev_replication_status: ReplicationStatusType::Pending,
..Default::default()
};
match outcome {
Ok(duration) => {
rinfo.replication_status = ReplicationStatusType::Completed;
rinfo.duration = duration;
}
Err(error) => {
rinfo.replication_status = ReplicationStatusType::Failed;
rinfo.error = Some(error);
}
}
rinfo
}
async fn record_force_delete_target_stats(bucket: &str, targets: &[ReplicatedTargetInfo]) {
let Some(stats) = runtime_sources::replication_stats() else {
return;
};
for target in targets {
stats
.update(bucket, target, target.replication_status.clone(), target.prev_replication_status.clone())
.await;
}
}
fn unavailable_delete_target_info(dobj: &DeletedObjectReplicationInfo, arn: &str) -> ReplicatedTargetInfo {
let mut rinfo = dobj
.delete_object
@@ -7444,6 +7518,51 @@ mod tests {
server.join().expect("test HTTP server should finish");
}
/// A force-delete the peer rejects must reach the replication failure
/// counters. A replicated force-delete used to only log the rejection, so
/// `replication status` reported zero failures while the sites diverged.
#[tokio::test]
async fn force_delete_target_outcomes_reach_replication_stats() {
let stats = super::super::replication_state::ReplicationStats::new();
let denied = force_delete_target_info(
ReplicationType::Delete,
"arn:rustfs:replication::denied:bucket",
"http://peer-a:9000",
false,
Err("remove_object failed: AccessDenied".to_string()),
);
assert_eq!(denied.replication_status, ReplicationStatusType::Failed);
assert_eq!(denied.prev_replication_status, ReplicationStatusType::Pending);
assert_eq!(denied.error.as_deref(), Some("remove_object failed: AccessDenied"));
let accepted = force_delete_target_info(
ReplicationType::Heal,
"arn:rustfs:replication::accepted:bucket",
"https://peer-b:9000",
true,
Ok(std::time::Duration::from_millis(3)),
);
assert_eq!(accepted.replication_status, ReplicationStatusType::Completed);
assert!(accepted.error.is_none());
for target in [&denied, &accepted] {
stats
.update(
"bucket",
target,
target.replication_status.clone(),
target.prev_replication_status.clone(),
)
.await;
}
let bucket_stats = stats.get("bucket").await;
let denied_stat = bucket_stats.stats.get(&denied.arn).expect("denied target: stats entry");
assert_eq!((denied_stat.failed.count, denied_stat.replicated_count), (1, 0));
let accepted_stat = bucket_stats.stats.get(&accepted.arn).expect("accepted target: stats entry");
assert_eq!((accepted_stat.failed.count, accepted_stat.replicated_count), (0, 1));
}
/// AWS answers `HEAD ?versionId=<source marker id>` with a bodiless 400.
/// The marker is verified through the latest version instead: a
/// versionless 404 with `x-amz-delete-marker: true` proves it propagated.
+28
View File
@@ -259,6 +259,34 @@ mod integration_tests {
assert!(!targets.is_empty(), "All files should match");
}
/// Internal events are never valid in a user-supplied notification configuration,
/// even though the audit queue persists and replays them.
#[test]
fn test_internal_event_names_are_rejected_in_notification_xml() {
for event in [
"s3:ObjectCreated:CreateMultipartUpload",
"s3:ObjectRemoved:AbortMultipartUpload",
"s3:ObjectRemoved:DeleteObjects",
"audit:S3:Operation",
] {
let xml = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<NotificationConfiguration>
<QueueConfiguration>
<Id>internal</Id>
<Queue>arn:rustfs:sqs:ap-northeast-1:primary:webhook</Queue>
<Event>{event}</Event>
</QueueConfiguration>
</NotificationConfiguration>"#
);
let arn_list = vec!["arn:rustfs:sqs:ap-northeast-1:primary:webhook".to_string()];
assert!(
BucketNotificationConfig::from_xml(Cursor::new(xml.as_bytes()), "ap-northeast-1", &arn_list).is_err(),
"{event} must be rejected in notification XML"
);
}
}
/// Test specific event type instead of compound
#[test]
fn test_specific_event_type_xml() {
+95 -3
View File
@@ -116,6 +116,12 @@ pub enum EventName {
// Splitting these per-operation would need `mask()` widened first.
IamIdentityCredentialChanged,
IamIdentityAuthChallenge,
// Audit-only S3 API operations that have no bucket notification event, such as
// `UploadPart` and the bucket configuration writes. One coarse variant covers
// all of them for the same mask-budget reason as the IAM block above: the
// specific operation lives in `AuditEntry::api.name`. It must stay last.
AuditS3Operation,
}
// Single event type sequential array for Everything.expand()
@@ -170,7 +176,7 @@ const LAST_SINGLE_TYPE_VALUE: u32 = EventName::IntelligentTiering as u32;
/// meaningful: `mask()` turns a leaf variant's discriminant `v` into the bit
/// `1 << (v - 1)`, so the highest discriminant is also the highest bit index in
/// use. Keep this pointing at whatever variant is declared last.
const LAST_EVENT_NAME_VALUE: u32 = EventName::IamIdentityAuthChallenge as u32;
const LAST_EVENT_NAME_VALUE: u32 = EventName::AuditS3Operation as u32;
/// `mask()` returns a `u64`, so discriminants may run from 1 to 64 inclusive.
///
@@ -264,6 +270,23 @@ impl EventName {
}
}
/// Parses the string form of the internal events that `parse` rejects.
///
/// These events are never valid in a bucket notification configuration, so
/// `parse` and the default `Deserialize` stay strict. They do reach the audit
/// sink and are persisted by the store targets, which read them back through
/// [`deserialize_persisted_event_name`].
fn parse_internal(s: &str) -> Option<Self> {
match s {
"s3:ObjectRemoved:AbortMultipartUpload" => Some(EventName::ObjectRemovedAbortMultipartUpload),
"s3:ObjectCreated:CreateMultipartUpload" => Some(EventName::ObjectCreatedCreateMultipartUpload),
"s3:ObjectRemoved:DeleteObjects" => Some(EventName::ObjectRemovedDeleteObjects),
// Audit-only S3 operations: reach the audit sink, never a notification rule.
"audit:S3:Operation" => Some(EventName::AuditS3Operation),
_ => None,
}
}
/// Parses an event string into an EventName with explicit error handling.
#[inline]
pub fn try_from_event_str(s: &str) -> Result<Self, ParseEventNameError> {
@@ -336,6 +359,7 @@ impl EventName {
EventName::KmsServiceStopped => "kms:Service:Stopped",
EventName::IamIdentityCredentialChanged => "iam:Identity:CredentialChanged",
EventName::IamIdentityAuthChallenge => "iam:Identity:AuthChallenge",
EventName::AuditS3Operation => "audit:S3:Operation",
}
}
@@ -536,6 +560,20 @@ impl serde::ser::Serialize for EventName {
}
}
/// `deserialize_with` function for fields that hold an `EventName` the server itself
/// persisted, such as queued audit payload metadata. Unlike the default `Deserialize`,
/// which also backs user-supplied notification XML, it accepts the internal events.
pub fn deserialize_persisted_event_name<'de, D>(deserializer: D) -> Result<EventName, D::Error>
where
D: serde::de::Deserializer<'de>,
{
let s = <String as serde::Deserialize>::deserialize(deserializer)?;
EventName::parse(&s)
.ok()
.or_else(|| EventName::parse_internal(&s))
.ok_or_else(|| serde::de::Error::custom(ParseEventNameError(s)))
}
impl<'de> serde::de::Deserialize<'de> for EventName {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
@@ -728,7 +766,8 @@ mod tests {
| EventName::KmsServiceStarted
| EventName::KmsServiceStopped
| EventName::IamIdentityCredentialChanged
| EventName::IamIdentityAuthChallenge => {}
| EventName::IamIdentityAuthChallenge
| EventName::AuditS3Operation => {}
}
}
@@ -798,6 +837,7 @@ mod tests {
EventName::KmsServiceStopped,
EventName::IamIdentityCredentialChanged,
EventName::IamIdentityAuthChallenge,
EventName::AuditS3Operation,
];
/// Every KMS management-plane event.
@@ -886,7 +926,7 @@ mod tests {
/// only as one element of an array someone may forget to extend.
#[test]
fn test_last_variant_still_gets_its_own_bit() {
let last = EventName::IamIdentityAuthChallenge;
let last = EventName::AuditS3Operation;
assert_ne!(last.mask(), 0, "the last variant's mask overflowed to zero");
assert_eq!(
last.mask(),
@@ -950,6 +990,7 @@ mod tests {
EventName::ObjectRemovedAbortMultipartUpload,
EventName::ObjectCreatedCreateMultipartUpload,
EventName::ObjectRemovedDeleteObjects,
EventName::AuditS3Operation,
];
for ev in ALL_EVENT_NAMES {
@@ -967,6 +1008,40 @@ mod tests {
}
}
/// Audit entries are persisted by the store targets and read back through
/// `deserialize_persisted_event_name`, so every event that can be serialized, other than
/// `Everything`, must come back as itself. The default `Deserialize` also backs
/// user-supplied notification XML and must keep rejecting the internal events.
#[test]
fn test_persisted_round_trip_for_every_serializable_variant() {
#[derive(serde::Deserialize)]
struct Persisted(#[serde(deserialize_with = "deserialize_persisted_event_name")] EventName);
for ev in ALL_EVENT_NAMES {
if *ev == EventName::Everything {
continue;
}
let json = serde_json::to_string(ev).unwrap();
let back: Persisted = serde_json::from_str(&json)
.unwrap_or_else(|err| panic!("{ev:?} serialized as {json} does not deserialize: {err}"));
assert_eq!(back.0, *ev, "persisted round-trip changed {ev:?}");
}
for internal in [
EventName::ObjectRemovedAbortMultipartUpload,
EventName::ObjectCreatedCreateMultipartUpload,
EventName::ObjectRemovedDeleteObjects,
EventName::AuditS3Operation,
] {
assert!(EventName::parse(internal.as_str()).is_err(), "parse must reject {internal:?}");
let json = serde_json::to_string(&internal).unwrap();
assert!(
serde_json::from_str::<EventName>(&json).is_err(),
"default Deserialize must reject {internal:?}"
);
}
}
/// `ObjectScannerAll` specifically must round-trip via "s3:Scanner:*".
#[test]
fn test_object_scanner_all_round_trips() {
@@ -1055,6 +1130,23 @@ mod tests {
}
}
/// The audit-only S3 operation event must stay out of the `s3:` namespace and out of
/// every compound selector, so a bucket notification can never subscribe to it,
/// and it must survive the audit queue round trip.
#[test]
fn test_audit_s3_operation_is_audit_only_and_round_trips() {
let ev = EventName::AuditS3Operation;
assert!(ev.as_str().starts_with("audit:"), "unexpected name {:?}", ev.as_str());
// Notification configuration and the default `Deserialize` reject it; only the
// persisted path (queued audit entries) accepts it.
assert!(EventName::parse(ev.as_str()).is_err());
assert!(serde_json::from_str::<EventName>(&serde_json::to_string(&ev).unwrap()).is_err());
assert_eq!(ev.expand(), vec![ev]);
for other in ALL_EVENT_NAMES.iter().filter(|other| **other != ev) {
assert_eq!(other.mask() & ev.mask(), 0, "{other} shares a mask bit with {ev}");
}
}
/// Each IAM event must own a distinct mask bit that no S3 selector shares.
#[test]
fn test_iam_event_masks_do_not_collide() {
+1 -1
View File
@@ -14,4 +14,4 @@
mod event_name;
pub use event_name::{EventName, ParseEventNameError, event_schema_version};
pub use event_name::{EventName, ParseEventNameError, deserialize_persisted_event_name, event_schema_version};
+19
View File
@@ -357,6 +357,7 @@ where
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QueuedPayloadMeta {
#[serde(deserialize_with = "rustfs_s3_types::deserialize_persisted_event_name")]
pub event_name: EventName,
pub bucket_name: String,
pub object_name: String,
@@ -1177,6 +1178,24 @@ mod tests {
assert_eq!(decoded.dedup_id, "minted-id");
}
/// Regression: the internal multipart events were dropped on replay because their
/// persisted name did not parse back.
#[test]
fn queued_payload_round_trips_internal_event_names() {
for event in [
EventName::ObjectCreatedCreateMultipartUpload,
EventName::ObjectRemovedAbortMultipartUpload,
EventName::ObjectRemovedDeleteObjects,
EventName::AuditS3Operation,
] {
let body = br#"{"ok":true}"#.to_vec();
let meta = QueuedPayloadMeta::new(event, "bucket-a".to_string(), "obj".to_string(), "application/json", body.len());
let encoded = QueuedPayload::new(meta, body).encode().unwrap();
let decoded = QueuedPayload::decode(&encoded).unwrap_or_else(|err| panic!("{event:?} dropped on replay: {err}"));
assert_eq!(decoded.meta.event_name, event);
}
}
#[test]
fn queued_payload_round_trips_meta_and_body() {
let body = br#"{"ok":true}"#.to_vec();
@@ -289,6 +289,16 @@ receiving site. If a group ended up disabled on one site only, re-enable it
there explicitly with `mc admin group enable`; a snapshot or repair will not
do it.
The managed `site-replicator-0` service account carries an embedded policy
that the server owns. It names `s3:ForceDeleteObject` explicitly because a
replicated force-delete (Console folder delete, `x-rustfs-force-delete: true`)
is authorized on the peer like any other force-delete and `s3:*` does not
grant that action. The reconcile tick refreshes an account whose stored policy
predates the grant; do not edit the policy by hand, the next repair rewrites
it. A bucket-replication target configured with its own credentials needs the
same `s3:ForceDeleteObject` grant on the target side, or its force-deletes are
rejected and counted as replication failures.
Treat IAM divergence as a security incident: a user deleted on one site can
remain usable on an unreachable peer until replay or repair completes. A peer
whose IAM entry is escalated does not receive scheduled snapshots either -
+1 -1
View File
@@ -16,7 +16,7 @@ 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 scope for that event. 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. The structured planner also records proposed affected-package and Connect selections in shadow mode; effective responsibility remains unchanged until a reviewed promotion. See [verification plans and artifacts](ci-verification-pipeline.md) for identity, rollout and queue-switch contracts.
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. The structured planner enforces affected-package and Connect selection from the trusted base policy, retaining fixed core coverage and full validation for shared or unknown inputs. See [verification plans and artifacts](ci-verification-pipeline.md) for identity, rollout and queue-switch contracts.
Alternate-feature unit tests and Clippy for rio-v2, Swift, SFTP, and HTTP/3 run on merge groups and on scheduled (nightly) or manual validation. PRs and main pushes run the default workspace and retain ILM, S3, real io_uring, and Connect security boundaries in their applicable scopes. The aggregate requires the alternate-feature jobs to be skipped on PRs and main pushes and successful on merge-group, scheduled, and manual events. This avoids repeating four feature builds on every PR revision and on every merge; feature-specific regressions are detected by the nightly run when the merge queue is not used. For changes to these features, dispatch `ci.yml` against the candidate branch before merging when that signal is needed, including PRs targeting `gateway/integration`, which has no automatic push trigger in this workflow. Scheduled results supplement the main baseline and do not replace it.
+11
View File
@@ -41,6 +41,17 @@ The tool requires that assertion to match the observed filesystem, mount and ima
Before admitting PR work, the infrastructure owner must execute two separate jobs and demonstrate that the second cannot read a marker or credential fixture left by the first. Repeat for cancellation and abnormal termination, retaining controller events and worker identities. Never put publication credentials in this pool. The monthly `scripts/ci/check_runner_ephemerality.sh` audit remains independent. No deployment or isolation result is implied by the fixtures in this repository.
## Local controller rehearsal
When the infrastructure control plane is unavailable, a local process rehearsal can exercise the controller protocol without Docker or Kubernetes:
```bash
python3 scripts/ci/runner_pilot.py --root /tmp \
--output /tmp/runner-isolation-evidence --isolation-rehearsal
```
Use a new evidence directory for each attempt. The rehearsal starts real processes with an isolated temporary home and synthetic credential/data fixtures, covers ordinary completion, cancellation and abnormal termination, removes the owned worker directory, then starts a successor that checks for residual paths. Incomplete cleanup prevents successor admission and retains a failed receipt. This is explicitly a local process simulation: it does not prove concurrent tenant isolation, ARC pod destruction, image provenance or physical SSD locality. The receipt cannot be used to authorize production runner placement.
## Artifact consumers and rollout decision
Run the existing artifact identity/ABI verifier before a real consumer trial. A matching architecture and glibc version alone is insufficient: execute the exact selected test set, verify required dynamic libraries and Docker/privilege requirements, and retain the archive's result contract. Rust fixtures can embed absolute build-time paths. Preserve the producer workspace path and required fixture files, or explicitly establish a supported relocation strategy before using a different runner layout. Required consumers keep their current runner until that acceptance is complete.
+5 -2
View File
@@ -6,7 +6,7 @@ Use this guide when changing validation selection, test execution, or promoting
The classifier reads the pull request base revision's planner and policy. It computes the changed packages and their reverse dependency closure from both revisions, including renamed dependencies, build dependencies, target dependencies and proc macros. Core package responsibilities are unioned with the committed test inventory. Unknown paths, changed build inputs, unavailable history or unresolved inventory expand validation. The proposed plan explains each selection and records candidate commit/tree, policy revision, event, configurations and expected job outcomes.
The initial policy is `shadow`: proposed selection is retained for comparison, while effective execution retains the full existing workspace and Connect responsibility for ordinary code changes. Existing documentation and isolated E2E scopes remain conservative. A base policy predating structured plans selects the full matrix. Promotion to `enforced` is a separate reviewed policy change after representative shadow comparisons, changed-test leak checks and failed-selection fixtures pass. Changing a PR's own policy cannot authorize skipping its base revision's requirements.
The committed policy is `enforced`: ordinary Rust changes select their reverse dependency closure plus the fixed core inventory, and unrelated changes can omit the Connect specialization. Package-owned fixtures, non-Rust inputs, build scripts, manifests, unknown paths and non-PR events retain full validation. Existing documentation and isolated E2E scopes remain conservative. Changing a PR's own policy cannot authorize skipping its base revision's requirements. A base planner predating the current workflow contract clears all narrower outputs and selects the full matrix. Set the policy back to `shadow` to retain proposed selections while restoring full ordinary-code execution; do not remove core inventories or negative checks as a rollback.
`Test and Lint` remains the required aggregate name. Every verification producer and consumer is a direct dependency. The verifier accepts only the effective plan's exact expected success/skipped outcomes; failure, cancellation, missing results, extra jobs or a plan for a different candidate fail. Quick Checks and CLA retain their separate required names.
@@ -20,7 +20,9 @@ The E2E producer builds the hooks server and its existing provenance sidecars, b
Consumer setup does not restore a Cargo target cache. It installs the runtime, then downloads and validates the current artifact. Each consumer extracts once when listing tests; subsequent runs use the extracted Cargo and binary metadata, including the serial ignored ILM run. Missing metadata fails instead of triggering a rebuild. Several tests embed absolute manifest paths and binary paths at compile time. Until those fixtures are made relocatable and tested, consumers require the same absolute checkout path as the producer, use the same runner family, and extract into that checkout's `target` directory. `--workspace-remap` alone does not rewrite strings compiled into a test executable. Source fixtures remain in the exact checkout, while non-test binaries and build outputs travel in the archive. The external s3s tool retains its independent pinned installation.
Workspace execution initially has one shard. There is no measured evidence yet that distributing this workspace archive improves total occupancy after compression, transfer and extraction. This also preserves every cross-test resource group. A future multi-shard promotion must use observed durations, keep every shared-resource component in one shard, cap concurrency, and prove the union of successful shard executions equals the selected listing. Merely applying nextest hash partitions would split process-local resource groups and is not an acceptable promotion.
Ordinary workspace execution uses one shard. To measure bounded sharding, manually dispatch `ci.yml` with `rehearse_workspace_shards=true`. The successful full execution supplies exact candidate durations and the test inventory. `scripts/ci_test_shards.py` asks nextest to resolve inherited group and thread constraints, keeps all constrained binaries together, then balances the remaining whole binaries across at most two ARC workers. Each worker verifies and extracts the same run attempt's archive once and executes from its metadata. The aggregate rejects missing, failed, cancelled, overlapping or mismatched shard results and verifies the exact union, including the migration proofs. The original full execution and serial ignored ILM run remain required during the rehearsal.
Compare execution time and complete occupancy, including the extra transfer, extraction and aggregation, with the full baseline from that same run. This manual rehearsal deliberately pays for both paths; its total is not a production saving. Retain one production shard unless repeated measurements justify promotion. Native nextest hash partitions alone would split process-local resource groups and are not an acceptable replacement. Disabling the manual rehearsal restores the ordinary path without changing test responsibility.
## Measurements and operational switches
@@ -53,6 +55,7 @@ python3 -m pip install PyYAML==6.0.3
python3 scripts/ci_gate.py --self-test
python3 scripts/ci_gate.py --check-workflow
python3 scripts/test_ci_test_artifact.py
python3 scripts/test_ci_test_shards.py
python3 scripts/check_connect_release_boundary.py --self-test
python3 scripts/test_ci_merge_queue.py
python3 scripts/check_test_wiring.py --self-test
+1 -1
View File
@@ -18,7 +18,7 @@ python3 scripts/validation_registry.py check
Shared functional suites run serially, and an earlier test failure does not stop later selected suites from gathering results. Performance uses an isolated fleet and can run concurrently. The final aggregate rejects required failures, cancellation, unexpected skips, missing or foreign evidence, and execution outside the selected plan. A successful subset remains subset evidence; it cannot satisfy full-chain health.
The old `repository_dispatch` relay is retired. Each individual workflow remains a manual debugging entrypoint and stops after its own suite. Package-only builds, tools, read-only runner/fleet inspection, and interrupted evidence recovery remain separate driver modes. Their success does not represent suite completion. No workflow is enabled automatically by registration or monitoring.
The old `repository_dispatch` relay is retired. Each individual workflow remains a manual debugging entrypoint and stops after its own suite. Package-only builds, tools, read-only runner/fleet inspection, and interrupted evidence recovery remain separate driver modes. The read-only and recovery validators accept the workflow's default `validation_plan=nightly`, but reject a different cadence or an explicit suite selection combined with those modes. Their success does not represent suite completion. No workflow is enabled automatically by registration or monitoring.
## Shared environment ownership
+256 -170
View File
@@ -24,7 +24,7 @@ use super::{
use crate::admin::handlers::site_replication::site_replication_bucket_meta_hook;
use crate::error::{ApiError, ApiErrorDiagnostic};
use crate::storage::access::{apply_bucket_generation_guard, bucket_config_mutation_incarnation, has_bypass_governance_header};
use crate::storage::helper::OperationHelper;
use crate::storage::helper::{OperationHelper, audit_only_operation};
use crate::storage::options::get_opts;
use crate::storage::s3_api::{self, acl};
use crate::storage::storage_api::ecfs_consumer::contract::{
@@ -536,6 +536,190 @@ pub(crate) fn validate_object_lock_configuration_input(input_cfg: &ObjectLockCon
Ok(())
}
/// Handler bodies that [`S3`] runs through `audit_only_operation`.
impl FS {
async fn delete_bucket_replication_inner(
&self,
req: S3Request<DeleteBucketReplicationInput>,
) -> S3Result<S3Response<DeleteBucketReplicationOutput>> {
deny_replication_config_edit_for_non_owner(&req).await?;
let (site_peers, contract) = site_replication_edit_context().await?;
let usecase = s3_api::bucket_usecase_for(self);
usecase.execute_delete_bucket_replication(req, site_peers, contract).await
}
async fn put_bucket_replication_inner(
&self,
req: S3Request<PutBucketReplicationInput>,
) -> S3Result<S3Response<PutBucketReplicationOutput>> {
deny_replication_config_edit_for_non_owner(&req).await?;
let (site_peers, contract) = site_replication_edit_context().await?;
let usecase = s3_api::bucket_usecase_for(self);
usecase.execute_put_bucket_replication(req, site_peers, contract).await
}
async fn put_bucket_acl_inner(&self, req: S3Request<PutBucketAclInput>) -> S3Result<S3Response<PutBucketAclOutput>> {
let PutBucketAclInput {
bucket,
access_control_policy,
..
} = req.input;
record_s3_op(S3Operation::PutBucketAcl);
let Some(store) = self.server_ctx.object_store() else {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
};
store
.get_bucket_info(&bucket, &BucketOptions::default())
.await
.map_err(ApiError::from)?;
if access_control_policy.is_some() {
return Err(s3_error!(
NotImplemented,
"ACL XML grants are not supported; use canned ACL headers or omit ACL"
));
}
Ok(S3Response::new(PutBucketAclOutput::default()))
}
async fn put_bucket_logging_inner(
&self,
req: S3Request<PutBucketLoggingInput>,
) -> S3Result<S3Response<PutBucketLoggingOutput>> {
let expected_incarnation_id = bucket_config_mutation_incarnation(&req, &req.input.bucket)?;
record_s3_op(S3Operation::PutBucketLogging);
let Some(store) = self.server_ctx.object_store() else {
return Err(s3_error!(InternalError, "Not init"));
};
store
.get_bucket_info(&req.input.bucket, &BucketOptions::default())
.await
.map_err(crate::error::ApiError::from)?;
let logging_config = serialize(&req.input.bucket_logging_status)
.map_err(|err| S3Error::with_message(S3ErrorCode::MalformedXML, format!("{err}")))?;
update_bucket_metadata_config_if_incarnation(
&req.input.bucket,
BUCKET_LOGGING_CONFIG,
logging_config,
expected_incarnation_id,
)
.await
.map_err(crate::error::ApiError::from)?;
Ok(S3Response::new(PutBucketLoggingOutput::default()))
}
async fn put_object_lock_configuration_inner(
&self,
req: S3Request<PutObjectLockConfigurationInput>,
) -> S3Result<S3Response<PutObjectLockConfigurationOutput>> {
let expected_incarnation_id = bucket_config_mutation_incarnation(&req, &req.input.bucket)?;
let PutObjectLockConfigurationInput {
bucket,
object_lock_configuration,
..
} = req.input;
let Some(input_cfg) = object_lock_configuration else { return Err(s3_error!(InvalidArgument)) };
let Some(store) = self.server_ctx.object_store() else {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
};
store
.get_bucket_info(&bucket, &BucketOptions::default())
.await
.map_err(ApiError::from)?;
validate_object_lock_configuration_input(&input_cfg)?;
match get_bucket_object_lock_config(&bucket).await {
Ok(_) => {}
Err(err) => {
if err == StorageError::ConfigNotFound {
if !BucketVersioningSys::enabled(&bucket).await {
return Err(S3Error::with_message(
S3ErrorCode::InvalidBucketState,
"Object Lock configuration cannot be enabled on existing buckets".to_string(),
));
}
} else {
warn!("get_object_lock_config err {:?}", err);
return Err(S3Error::with_message(
S3ErrorCode::InternalError,
"Failed to get bucket ObjectLockConfiguration".to_string(),
));
}
}
};
let data = serialize(&input_cfg).map_err(|err| S3Error::with_message(S3ErrorCode::InternalError, format!("{err}")))?;
let object_lock_config =
String::from_utf8(data.clone()).map_err(|err| S3Error::with_message(S3ErrorCode::InternalError, format!("{err}")))?;
let updated_at = update_bucket_metadata_config_if_incarnation(&bucket, OBJECT_LOCK_CONFIG, data, expected_incarnation_id)
.await
.map_err(ApiError::from)?;
// When Object Lock is enabled, automatically enable versioning if not already enabled.
// This matches S3-compatible behavior.
let versioning_config = BucketVersioningSys::get(&bucket).await.map_err(ApiError::from)?;
if !versioning_config.enabled() {
let enable_versioning_config = VersioningConfiguration {
status: Some(BucketVersioningStatus::from_static(BucketVersioningStatus::ENABLED)),
..Default::default()
};
let versioning_data = serialize(&enable_versioning_config)
.map_err(|err| S3Error::with_message(S3ErrorCode::InternalError, format!("{err}")))?;
update_bucket_metadata_config_if_incarnation(
&bucket,
BUCKET_VERSIONING_CONFIG,
versioning_data,
expected_incarnation_id,
)
.await
.map_err(ApiError::from)?;
}
if let Some(notification_sys) =
runtime_sources::current_notification_system_for_context(self.server_ctx.app_context().as_deref())
{
propagate_object_lock_peer_reload(notification_sys.load_bucket_metadata(&bucket).await)?;
}
if let Err(err) = site_replication_bucket_meta_hook(SRBucketMeta {
bucket: bucket.clone(),
r#type: "object-lock-config".to_string(),
object_lock_config: Some(object_lock_config),
updated_at: Some(updated_at),
api_version: Some(SITE_REPL_API_VERSION.to_string()),
..Default::default()
})
.await
{
warn!(
component = LOG_COMPONENT_STORAGE,
subsystem = LOG_SUBSYSTEM_OBJECT_LOCK,
event = "put_object_lock_configuration",
bucket = %bucket,
result = "site_replication_hook_failed",
error = ?err,
"storage object lock state"
);
}
rustfs_scanner::record_dirty_usage_bucket_from_producer(
&bucket,
rustfs_scanner::SegmentInvalidationProducerIdentity::BucketMetadata,
);
Ok(S3Response::new(PutObjectLockConfigurationOutput::default()))
}
}
#[async_trait::async_trait]
impl S3 for FS {
#[instrument(level = "debug", skip(self))]
@@ -544,7 +728,7 @@ impl S3 for FS {
req: S3Request<AbortMultipartUploadInput>,
) -> S3Result<S3Response<AbortMultipartUploadOutput>> {
let usecase = s3_api::multipart_usecase_for(self);
usecase.execute_abort_multipart_upload(req).await
audit_only_operation(req, S3Operation::AbortMultipartUpload, |req| usecase.execute_abort_multipart_upload(req)).await
}
#[instrument(level = "debug", skip(self, req))]
@@ -594,7 +778,7 @@ impl S3 for FS {
#[instrument(level = "debug", skip(self))]
async fn delete_bucket_cors(&self, req: S3Request<DeleteBucketCorsInput>) -> S3Result<S3Response<DeleteBucketCorsOutput>> {
let usecase = s3_api::bucket_usecase_for(self);
usecase.execute_delete_bucket_cors(req).await
audit_only_operation(req, S3Operation::DeleteBucketCors, |req| usecase.execute_delete_bucket_cors(req)).await
}
async fn delete_bucket_encryption(
@@ -602,7 +786,10 @@ impl S3 for FS {
req: S3Request<DeleteBucketEncryptionInput>,
) -> S3Result<S3Response<DeleteBucketEncryptionOutput>> {
let usecase = s3_api::bucket_usecase_for(self);
usecase.execute_delete_bucket_encryption(req).await
audit_only_operation(req, S3Operation::DeleteBucketEncryption, |req| {
usecase.execute_delete_bucket_encryption(req)
})
.await
}
#[instrument(level = "debug", skip(self))]
@@ -611,7 +798,10 @@ impl S3 for FS {
req: S3Request<DeleteBucketLifecycleInput>,
) -> S3Result<S3Response<DeleteBucketLifecycleOutput>> {
let usecase = s3_api::bucket_usecase_for(self);
usecase.execute_delete_bucket_lifecycle(req).await
audit_only_operation(req, S3Operation::DeleteBucketLifecycle, |req| {
usecase.execute_delete_bucket_lifecycle(req)
})
.await
}
async fn delete_bucket_policy(
@@ -619,17 +809,14 @@ impl S3 for FS {
req: S3Request<DeleteBucketPolicyInput>,
) -> S3Result<S3Response<DeleteBucketPolicyOutput>> {
let usecase = s3_api::bucket_usecase_for(self);
usecase.execute_delete_bucket_policy(req).await
audit_only_operation(req, S3Operation::DeleteBucketPolicy, |req| usecase.execute_delete_bucket_policy(req)).await
}
async fn delete_bucket_replication(
&self,
req: S3Request<DeleteBucketReplicationInput>,
) -> S3Result<S3Response<DeleteBucketReplicationOutput>> {
deny_replication_config_edit_for_non_owner(&req).await?;
let (site_peers, contract) = site_replication_edit_context().await?;
let usecase = s3_api::bucket_usecase_for(self);
usecase.execute_delete_bucket_replication(req, site_peers, contract).await
audit_only_operation(req, S3Operation::DeleteBucketReplication, |req| self.delete_bucket_replication_inner(req)).await
}
#[instrument(level = "debug", skip(self))]
@@ -638,7 +825,7 @@ impl S3 for FS {
req: S3Request<DeleteBucketTaggingInput>,
) -> S3Result<S3Response<DeleteBucketTaggingOutput>> {
let usecase = s3_api::bucket_usecase_for(self);
usecase.execute_delete_bucket_tagging(req).await
audit_only_operation(req, S3Operation::DeleteBucketTagging, |req| usecase.execute_delete_bucket_tagging(req)).await
}
async fn delete_bucket_website(
@@ -668,7 +855,10 @@ impl S3 for FS {
req: S3Request<DeletePublicAccessBlockInput>,
) -> S3Result<S3Response<DeletePublicAccessBlockOutput>> {
let usecase = s3_api::bucket_usecase_for(self);
usecase.execute_delete_public_access_block(req).await
audit_only_operation(req, S3Operation::DeletePublicAccessBlock, |req| {
usecase.execute_delete_public_access_block(req)
})
.await
}
/// Delete an object
@@ -1384,30 +1574,7 @@ impl S3 for FS {
}
async fn put_bucket_acl(&self, req: S3Request<PutBucketAclInput>) -> S3Result<S3Response<PutBucketAclOutput>> {
let PutBucketAclInput {
bucket,
access_control_policy,
..
} = req.input;
record_s3_op(S3Operation::PutBucketAcl);
let Some(store) = self.server_ctx.object_store() else {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
};
store
.get_bucket_info(&bucket, &BucketOptions::default())
.await
.map_err(ApiError::from)?;
if access_control_policy.is_some() {
return Err(s3_error!(
NotImplemented,
"ACL XML grants are not supported; use canned ACL headers or omit ACL"
));
}
Ok(S3Response::new(PutBucketAclOutput::default()))
audit_only_operation(req, S3Operation::PutBucketAcl, |req| self.put_bucket_acl_inner(req)).await
}
async fn put_bucket_accelerate_configuration(
@@ -1440,7 +1607,7 @@ impl S3 for FS {
#[instrument(level = "debug", skip(self))]
async fn put_bucket_cors(&self, req: S3Request<PutBucketCorsInput>) -> S3Result<S3Response<PutBucketCorsOutput>> {
let usecase = s3_api::bucket_usecase_for(self);
usecase.execute_put_bucket_cors(req).await
audit_only_operation(req, S3Operation::PutBucketCors, |req| usecase.execute_put_bucket_cors(req)).await
}
async fn get_bucket_logging(&self, req: S3Request<GetBucketLoggingInput>) -> S3Result<S3Response<GetBucketLoggingOutput>> {
@@ -1463,28 +1630,7 @@ impl S3 for FS {
}
async fn put_bucket_logging(&self, req: S3Request<PutBucketLoggingInput>) -> S3Result<S3Response<PutBucketLoggingOutput>> {
let expected_incarnation_id = bucket_config_mutation_incarnation(&req, &req.input.bucket)?;
record_s3_op(S3Operation::PutBucketLogging);
let Some(store) = self.server_ctx.object_store() else {
return Err(s3_error!(InternalError, "Not init"));
};
store
.get_bucket_info(&req.input.bucket, &BucketOptions::default())
.await
.map_err(crate::error::ApiError::from)?;
let logging_config = serialize(&req.input.bucket_logging_status)
.map_err(|err| S3Error::with_message(S3ErrorCode::MalformedXML, format!("{err}")))?;
update_bucket_metadata_config_if_incarnation(
&req.input.bucket,
BUCKET_LOGGING_CONFIG,
logging_config,
expected_incarnation_id,
)
.await
.map_err(crate::error::ApiError::from)?;
Ok(S3Response::new(PutBucketLoggingOutput::default()))
audit_only_operation(req, S3Operation::PutBucketLogging, |req| self.put_bucket_logging_inner(req)).await
}
async fn put_bucket_encryption(
@@ -1492,7 +1638,7 @@ impl S3 for FS {
req: S3Request<PutBucketEncryptionInput>,
) -> S3Result<S3Response<PutBucketEncryptionOutput>> {
let usecase = s3_api::bucket_usecase_for(self);
usecase.execute_put_bucket_encryption(req).await
audit_only_operation(req, S3Operation::PutBucketEncryption, |req| usecase.execute_put_bucket_encryption(req)).await
}
#[instrument(level = "debug", skip(self))]
@@ -1501,7 +1647,10 @@ impl S3 for FS {
req: S3Request<PutBucketLifecycleConfigurationInput>,
) -> S3Result<S3Response<PutBucketLifecycleConfigurationOutput>> {
let usecase = s3_api::bucket_usecase_for(self);
usecase.execute_put_bucket_lifecycle_configuration(req).await
audit_only_operation(req, S3Operation::PutBucketLifecycleConfiguration, |req| {
usecase.execute_put_bucket_lifecycle_configuration(req)
})
.await
}
async fn put_bucket_notification_configuration(
@@ -1509,22 +1658,22 @@ impl S3 for FS {
req: S3Request<PutBucketNotificationConfigurationInput>,
) -> S3Result<S3Response<PutBucketNotificationConfigurationOutput>> {
let usecase = s3_api::bucket_usecase_for(self);
usecase.execute_put_bucket_notification_configuration(req).await
audit_only_operation(req, S3Operation::PutBucketNotificationConfiguration, |req| {
usecase.execute_put_bucket_notification_configuration(req)
})
.await
}
async fn put_bucket_policy(&self, req: S3Request<PutBucketPolicyInput>) -> S3Result<S3Response<PutBucketPolicyOutput>> {
let usecase = s3_api::bucket_usecase_for(self);
usecase.execute_put_bucket_policy(req).await
audit_only_operation(req, S3Operation::PutBucketPolicy, |req| usecase.execute_put_bucket_policy(req)).await
}
async fn put_bucket_replication(
&self,
req: S3Request<PutBucketReplicationInput>,
) -> S3Result<S3Response<PutBucketReplicationOutput>> {
deny_replication_config_edit_for_non_owner(&req).await?;
let (site_peers, contract) = site_replication_edit_context().await?;
let usecase = s3_api::bucket_usecase_for(self);
usecase.execute_put_bucket_replication(req, site_peers, contract).await
audit_only_operation(req, S3Operation::PutBucketReplication, |req| self.put_bucket_replication_inner(req)).await
}
async fn put_bucket_request_payment(
@@ -1560,13 +1709,13 @@ impl S3 for FS {
req: S3Request<PutPublicAccessBlockInput>,
) -> S3Result<S3Response<PutPublicAccessBlockOutput>> {
let usecase = s3_api::bucket_usecase_for(self);
usecase.execute_put_public_access_block(req).await
audit_only_operation(req, S3Operation::PutPublicAccessBlock, |req| usecase.execute_put_public_access_block(req)).await
}
#[instrument(level = "debug", skip(self))]
async fn put_bucket_tagging(&self, req: S3Request<PutBucketTaggingInput>) -> S3Result<S3Response<PutBucketTaggingOutput>> {
let usecase = s3_api::bucket_usecase_for(self);
usecase.execute_put_bucket_tagging(req).await
audit_only_operation(req, S3Operation::PutBucketTagging, |req| usecase.execute_put_bucket_tagging(req)).await
}
#[instrument(level = "debug", skip(self))]
@@ -1575,7 +1724,7 @@ impl S3 for FS {
req: S3Request<PutBucketVersioningInput>,
) -> S3Result<S3Response<PutBucketVersioningOutput>> {
let usecase = s3_api::bucket_usecase_for(self);
usecase.execute_put_bucket_versioning(req).await
audit_only_operation(req, S3Operation::PutBucketVersioning, |req| usecase.execute_put_bucket_versioning(req)).await
}
async fn put_bucket_website(&self, req: S3Request<PutBucketWebsiteInput>) -> S3Result<S3Response<PutBucketWebsiteOutput>> {
@@ -1746,106 +1895,10 @@ impl S3 for FS {
&self,
req: S3Request<PutObjectLockConfigurationInput>,
) -> S3Result<S3Response<PutObjectLockConfigurationOutput>> {
let expected_incarnation_id = bucket_config_mutation_incarnation(&req, &req.input.bucket)?;
let PutObjectLockConfigurationInput {
bucket,
object_lock_configuration,
..
} = req.input;
let Some(input_cfg) = object_lock_configuration else { return Err(s3_error!(InvalidArgument)) };
let Some(store) = self.server_ctx.object_store() else {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
};
store
.get_bucket_info(&bucket, &BucketOptions::default())
.await
.map_err(ApiError::from)?;
validate_object_lock_configuration_input(&input_cfg)?;
match get_bucket_object_lock_config(&bucket).await {
Ok(_) => {}
Err(err) => {
if err == StorageError::ConfigNotFound {
if !BucketVersioningSys::enabled(&bucket).await {
return Err(S3Error::with_message(
S3ErrorCode::InvalidBucketState,
"Object Lock configuration cannot be enabled on existing buckets".to_string(),
));
}
} else {
warn!("get_object_lock_config err {:?}", err);
return Err(S3Error::with_message(
S3ErrorCode::InternalError,
"Failed to get bucket ObjectLockConfiguration".to_string(),
));
}
}
};
let data = serialize(&input_cfg).map_err(|err| S3Error::with_message(S3ErrorCode::InternalError, format!("{err}")))?;
let object_lock_config =
String::from_utf8(data.clone()).map_err(|err| S3Error::with_message(S3ErrorCode::InternalError, format!("{err}")))?;
let updated_at = update_bucket_metadata_config_if_incarnation(&bucket, OBJECT_LOCK_CONFIG, data, expected_incarnation_id)
.await
.map_err(ApiError::from)?;
// When Object Lock is enabled, automatically enable versioning if not already enabled.
// This matches S3-compatible behavior.
let versioning_config = BucketVersioningSys::get(&bucket).await.map_err(ApiError::from)?;
if !versioning_config.enabled() {
let enable_versioning_config = VersioningConfiguration {
status: Some(BucketVersioningStatus::from_static(BucketVersioningStatus::ENABLED)),
..Default::default()
};
let versioning_data = serialize(&enable_versioning_config)
.map_err(|err| S3Error::with_message(S3ErrorCode::InternalError, format!("{err}")))?;
update_bucket_metadata_config_if_incarnation(
&bucket,
BUCKET_VERSIONING_CONFIG,
versioning_data,
expected_incarnation_id,
)
.await
.map_err(ApiError::from)?;
}
if let Some(notification_sys) =
runtime_sources::current_notification_system_for_context(self.server_ctx.app_context().as_deref())
{
propagate_object_lock_peer_reload(notification_sys.load_bucket_metadata(&bucket).await)?;
}
if let Err(err) = site_replication_bucket_meta_hook(SRBucketMeta {
bucket: bucket.clone(),
r#type: "object-lock-config".to_string(),
object_lock_config: Some(object_lock_config),
updated_at: Some(updated_at),
api_version: Some(SITE_REPL_API_VERSION.to_string()),
..Default::default()
audit_only_operation(req, S3Operation::PutObjectLockConfiguration, |req| {
self.put_object_lock_configuration_inner(req)
})
.await
{
warn!(
component = LOG_COMPONENT_STORAGE,
subsystem = LOG_SUBSYSTEM_OBJECT_LOCK,
event = "put_object_lock_configuration",
bucket = %bucket,
result = "site_replication_hook_failed",
error = ?err,
"storage object lock state"
);
}
rustfs_scanner::record_dirty_usage_bucket_from_producer(
&bucket,
rustfs_scanner::SegmentInvalidationProducerIdentity::BucketMetadata,
);
Ok(S3Response::new(PutObjectLockConfigurationOutput::default()))
}
async fn put_object_retention(
@@ -2104,14 +2157,47 @@ impl S3 for FS {
crate::hp_guard!("S3::upload_part");
record_s3_op(S3Operation::UploadPart);
let usecase = s3_api::multipart_usecase_for(self);
usecase.execute_upload_part(req).await
audit_only_operation(req, S3Operation::UploadPart, |req| usecase.execute_upload_part(req)).await
}
#[instrument(level = "debug", skip(self, req))]
async fn upload_part_copy(&self, req: S3Request<UploadPartCopyInput>) -> S3Result<S3Response<UploadPartCopyOutput>> {
record_s3_op(S3Operation::UploadPartCopy);
let usecase = s3_api::multipart_usecase_for(self);
Box::pin(usecase.execute_upload_part_copy(req)).await
audit_only_operation(req, S3Operation::UploadPartCopy, |req| Box::pin(usecase.execute_upload_part_copy(req))).await
}
}
/// Every mutating S3 operation that has no bucket notification event reaches the audit
/// stream only through `audit_only_operation`; a new such operation, or a dropped
/// wrapper, must fail here rather than silently go unaudited.
#[cfg(test)]
mod audit_coverage_tests {
use rustfs_s3_ops::S3Operation;
const SOURCE: &str = include_str!("ecfs.rs");
fn is_read_only(op: S3Operation) -> bool {
let name = op.as_str().trim_start_matches("s3:");
["Get", "Head", "List", "Select"]
.iter()
.any(|prefix| name.starts_with(prefix))
}
#[test]
fn mutating_operations_without_a_notification_event_are_wrapped_for_audit() {
let mut expected: Vec<S3Operation> = S3Operation::ALL
.into_iter()
.filter(|op| !is_read_only(*op) && op.to_event_name().is_none())
.collect();
// AbortMultipartUpload has an internal event but no handler-built helper.
expected.push(S3Operation::AbortMultipartUpload);
assert_eq!(expected.len(), 22, "an operation was added or removed: {expected:?}");
for op in expected {
let wrapper = format!("audit_only_operation(req, S3Operation::{op:?},");
assert!(SOURCE.contains(&wrapper), "{} is not run through audit_only_operation", op.as_str());
}
}
}
+187 -4
View File
@@ -119,7 +119,23 @@ pub struct EnabledOperationHelper {
impl OperationHelper {
/// Create a new OperationHelper for S3 requests.
pub fn new(req: &S3Request<impl Send + Sync>, event: EventName, op: S3Operation) -> Self {
let op_event_matches = operation_matches_event_name(op, event);
Self::build(req, event, op, false)
}
/// Create an OperationHelper that only emits an audit entry, never a bucket notification.
///
/// For operations that have no notification event of their own (`UploadPart`, the
/// bucket configuration writes) or whose event is internal (`AbortMultipartUpload`).
/// Unlike `new` it does not call `record_s3_op`, so per-operation metrics stay as the
/// handlers already report them.
pub fn audit_only(req: &S3Request<impl Send + Sync>, op: S3Operation) -> Self {
let event = op.to_event_name().unwrap_or(EventName::AuditS3Operation);
Self::build(req, event, op, true)
}
fn build(req: &S3Request<impl Send + Sync>, event: EventName, op: S3Operation, audit_only: bool) -> Self {
// `audit_only` derives the event from the operation, so there is nothing to cross-check.
let op_event_matches = audit_only || operation_matches_event_name(op, event);
debug_assert!(op_event_matches, "operation/event mismatch: op={} event={}", op.as_str(), event.as_str());
if !op_event_matches {
counter!(
@@ -136,7 +152,11 @@ impl OperationHelper {
}
let audit_enabled = is_audit_module_enabled();
let notify_enabled = should_build_notification_event(is_notify_module_enabled());
let notify_enabled = !audit_only && should_build_notification_event(is_notify_module_enabled());
if audit_only && !audit_enabled {
// Nothing to record and no metric to bump: skip the path parsing below.
return Self::Disabled;
}
let path = req.uri.path().trim_start_matches('/');
let mut segs = path.splitn(2, '/');
@@ -148,7 +168,9 @@ impl OperationHelper {
.filter(|value| !value.is_empty())
.unwrap_or(path_bucket);
record_s3_op(op);
if !audit_only {
record_s3_op(op);
}
// Fast path: when both chains are disabled, avoid audit/notify builder work.
if !audit_enabled && !notify_enabled {
@@ -418,6 +440,22 @@ impl OperationHelper {
}
}
/// Runs `handler` on `req` and records the outcome as an audit-only entry for `op`.
///
/// The helper is built before the request is handed over so it sees the original headers
/// and extensions, and it completes with the handler's result, so failed requests are audited too.
pub(crate) async fn audit_only_operation<I, O, F, Fut>(req: S3Request<I>, op: S3Operation, handler: F) -> S3Result<S3Response<O>>
where
I: Send + Sync,
F: FnOnce(S3Request<I>) -> Fut,
Fut: Future<Output = S3Result<S3Response<O>>>,
{
let helper = OperationHelper::audit_only(&req, op);
let result = handler(req).await;
let _helper = helper.complete(&result);
result
}
fn should_build_notification_event(notify_module_enabled: bool) -> bool {
notify_module_enabled || rustfs_notify::notification_system().is_some_and(|system| system.has_live_listeners())
}
@@ -470,7 +508,7 @@ mod tests {
use rustfs_s3_types::EventName;
use rustfs_utils::http::headers::{AMZ_REQUEST_ID, REQUEST_ID_HEADER};
use s3s::dto::{DeleteObjectTaggingInput, DeleteObjectTaggingOutput, GetObjectInput, GetObjectOutput};
use s3s::{S3Error, S3ErrorCode, S3Request, S3Response};
use s3s::{S3Error, S3ErrorCode, S3Request, S3Response, S3Result};
use std::sync::{Arc, Mutex};
use temp_env::{async_with_vars, with_vars};
@@ -740,6 +778,151 @@ mod tests {
);
}
/// Operations without a notification event still produce an audit entry, and the
/// audit-only constructor never builds a notification event, even with notify on.
#[test]
fn audit_only_helper_audits_operations_without_a_notification_event() {
with_vars(
[
(rustfs_config::ENV_NOTIFY_ENABLE, Some("true")),
(rustfs_config::ENV_AUDIT_ENABLE, Some("true")),
],
|| {
refresh_notify_module_enabled();
refresh_audit_module_enabled();
for (op, uri, expected_event) in [
(S3Operation::UploadPart, "/audit-bucket/object.txt", EventName::AuditS3Operation),
(S3Operation::UploadPartCopy, "/audit-bucket/object.txt", EventName::AuditS3Operation),
(S3Operation::PutBucketPolicy, "/audit-bucket", EventName::AuditS3Operation),
(S3Operation::PutBucketVersioning, "/audit-bucket", EventName::AuditS3Operation),
(
S3Operation::AbortMultipartUpload,
"/audit-bucket/object.txt",
EventName::ObjectRemovedAbortMultipartUpload,
),
] {
let input = DeleteObjectTaggingInput::builder()
.bucket("audit-bucket".to_string())
.key("object.txt".to_string())
.build()
.unwrap();
let mut req = build_request(input, Method::PUT, Uri::from_static(uri));
req.extensions.insert(ReqInfo {
cred: Some(Credentials {
access_key: "auditor".to_string(),
..Default::default()
}),
..Default::default()
});
let result = Ok(S3Response::new(DeleteObjectTaggingOutput::default()));
let mut helper = OperationHelper::audit_only(&req, op).complete(&result);
assert!(!helper.wants_object_info(), "{} must not build a notification event", op.as_str());
assert!(helper.event_args().is_none(), "{} must not build a notification event", op.as_str());
let OperationHelper::Enabled(state) = &mut helper else {
panic!("helper should be enabled when the audit switch is on");
};
let entry = state.audit_builder.take().expect("audit builder should exist").build();
assert_eq!(entry.event, expected_event, "{}", op.as_str());
assert_eq!(entry.api.name.as_deref(), Some(op.as_str()));
assert_eq!(entry.api.bucket.as_deref(), Some("audit-bucket"));
assert_eq!(entry.api.status.as_deref(), Some("success"));
assert_eq!(entry.access_key.as_deref(), Some("auditor"));
}
},
);
}
/// With audit off, the audit-only helper does no work: it is the disabled helper.
#[test]
fn audit_only_helper_is_disabled_when_audit_is_off() {
with_vars(
[
(rustfs_config::ENV_NOTIFY_ENABLE, Some("true")),
(rustfs_config::ENV_AUDIT_ENABLE, Some("false")),
],
|| {
refresh_notify_module_enabled();
refresh_audit_module_enabled();
let input = DeleteObjectTaggingInput::builder()
.bucket("audit-bucket".to_string())
.key("object.txt".to_string())
.build()
.unwrap();
let req = build_request(input, Method::PUT, Uri::from_static("/audit-bucket/object.txt"));
assert!(matches!(
OperationHelper::audit_only(&req, S3Operation::UploadPart),
OperationHelper::Disabled
));
},
);
}
/// A failed audit-only operation is audited with a failure status, the error message and
/// the error's status code.
#[test]
fn audit_only_helper_records_failure_status_and_error() {
with_vars(
[
(rustfs_config::ENV_NOTIFY_ENABLE, Some("false")),
(rustfs_config::ENV_AUDIT_ENABLE, Some("true")),
],
|| {
refresh_notify_module_enabled();
refresh_audit_module_enabled();
let input = DeleteObjectTaggingInput::builder()
.bucket("audit-bucket".to_string())
.key("object.txt".to_string())
.build()
.unwrap();
let req = build_request(input, Method::PUT, Uri::from_static("/audit-bucket"));
let result: S3Result<S3Response<DeleteObjectTaggingOutput>> =
Err(S3Error::with_message(S3ErrorCode::AccessDenied, "policy refused".to_string()));
let mut helper = OperationHelper::audit_only(&req, S3Operation::PutBucketPolicy).complete(&result);
let OperationHelper::Enabled(state) = &mut helper else {
panic!("helper should be enabled when the audit switch is on");
};
let entry = state.audit_builder.take().expect("audit builder should exist").build();
assert_eq!(entry.api.status.as_deref(), Some("failure"));
assert_eq!(entry.api.status_code, Some(403));
assert_eq!(entry.error.as_deref(), Some("policy refused"));
},
);
}
/// A failed request is audited with its failure status, and the wrapper hands the
/// handler's result back unchanged.
#[tokio::test]
async fn audit_only_operation_returns_the_handler_result_unchanged() {
async_with_vars(
[
(rustfs_config::ENV_NOTIFY_ENABLE, Some("true")),
(rustfs_config::ENV_AUDIT_ENABLE, Some("true")),
],
async {
refresh_notify_module_enabled();
refresh_audit_module_enabled();
let input = DeleteObjectTaggingInput::builder()
.bucket("audit-bucket".to_string())
.key("object.txt".to_string())
.build()
.unwrap();
let req = build_request(input, Method::PUT, Uri::from_static("/audit-bucket"));
let err = super::audit_only_operation(req, S3Operation::PutBucketPolicy, |req| async move {
assert_eq!(req.input.bucket, "audit-bucket");
Err::<S3Response<DeleteObjectTaggingOutput>, _>(S3Error::new(S3ErrorCode::AccessDenied))
})
.await
.unwrap_err();
assert_eq!(*err.code(), S3ErrorCode::AccessDenied);
},
)
.await;
}
#[test]
fn operation_helper_audits_the_authenticated_request_identity() {
with_vars(
+87 -3
View File
@@ -12,6 +12,7 @@ import os
from pathlib import Path
import platform
import re
import selectors
import shutil
import signal
import subprocess
@@ -224,6 +225,82 @@ def benchmark(source, root, output, sha, toolchain, workload, jobs, timeout, *,
return report
def isolation_rehearsal(root, output, *, remove_tree=shutil.rmtree):
"""Exercise a local worker controller with real processes and synthetic data.
This proves the controller's cleanup protocol, not a container boundary,
physical SSD locality, ARC deployment or cloud worker destruction.
"""
report = {"schema": 1, "kind": "local-process-simulation", "passed": False, "cases": [],
"storage_locality_verified": False, "worker_destruction_verified": False}
output.mkdir(parents=True, exist_ok=True)
receipt = output / "isolation.json"
if receipt.exists():
raise ValueError("retain the original isolation receipt; choose a new output directory")
worker = """
import os, sys
from pathlib import Path
home = Path(os.environ['HOME'])
(home / 'credential-fixture').write_text('synthetic-credential-not-a-secret')
(Path.cwd() / 'workspace-fixture').write_text('first-worker-data')
print('ready', flush=True)
sys.stdin.readline()
"""
try:
for mode in ("normal", "cancelled", "crashed"):
directory = Path(tempfile.mkdtemp(prefix="rustfs-local-worker-", dir=root))
home = directory / "home"
home.mkdir(mode=0o700)
process = None
case = {"termination": mode, "passed": False, "next_worker_started": False}
report["cases"].append(case)
try:
# Deliberately exclude the caller's credentials and home.
environment = {"HOME": str(home), "PATH": os.environ.get("PATH", "")}
process = subprocess.Popen([sys.executable, "-I", "-u", "-c", worker], cwd=directory,
env=environment, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, text=True, start_new_session=True)
with selectors.DefaultSelector() as ready:
ready.register(process.stdout, selectors.EVENT_READ)
if not ready.select(timeout=10) or process.stdout.readline().strip() != "ready":
raise ValueError("local worker did not reach the fixture boundary")
if mode == "normal":
process.communicate(input="finish\n", timeout=10)
expected = 0
else:
signum = signal.SIGTERM if mode == "cancelled" else signal.SIGKILL
os.killpg(process.pid, signum)
process.communicate(timeout=10)
expected = -signum
case["exit_code"] = process.returncode
if process.returncode != expected:
raise ValueError("local worker did not terminate by the requested path")
remove_tree(directory)
if directory.exists():
raise ValueError("cleanup incomplete; refusing the next worker")
with tempfile.TemporaryDirectory(prefix="rustfs-next-worker-", dir=root) as successor:
case["next_worker_started"] = True
result = subprocess.run([sys.executable, "-I", "-c",
"import pathlib,sys; sys.exit(any(pathlib.Path(p).exists() for p in sys.argv[1:]))",
str(directory), str(home / "credential-fixture"), str(directory / "workspace-fixture")],
cwd=successor, env={"HOME": successor, "PATH": environment["PATH"]}, timeout=10)
if result.returncode:
raise ValueError("a later worker can observe previous task data")
case["passed"] = True
finally:
if process is not None:
if process.poll() is None:
os.killpg(process.pid, signal.SIGKILL)
process.communicate(timeout=10)
# Only this rehearsal's directory is owned by this controller.
if directory.exists():
shutil.rmtree(directory)
report["passed"] = True
return report
finally:
receipt.write_text(json.dumps(report, indent=2) + "\n")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", type=Path, required=True, help="existing scratch directory on the disk under test")
@@ -235,6 +312,7 @@ def main():
parser.add_argument("--require-io-uring", action="store_true")
parser.add_argument("--local-ssd-attestation", type=Path)
parser.add_argument("--benchmark", choices=("server", "workspace-tests"))
parser.add_argument("--isolation-rehearsal", action="store_true", help="local process simulation; no infrastructure claim")
parser.add_argument("--source", type=Path, default=Path.cwd())
parser.add_argument("--source-sha")
parser.add_argument("--toolchain")
@@ -246,6 +324,15 @@ def main():
parser.error("resource requirements are outside the bounded pilot limits")
if args.benchmark and not (args.source_sha and args.toolchain):
parser.error("--benchmark requires --source-sha and --toolchain")
def cancel(_signum, _frame):
raise KeyboardInterrupt
signal.signal(signal.SIGTERM, cancel)
if args.isolation_rehearsal:
if args.benchmark or args.local_ssd_attestation or args.require_docker or args.require_io_uring:
parser.error("local isolation simulation cannot certify runner capabilities or a build")
result = isolation_rehearsal(args.root.resolve(), args.output.resolve())
print("Local process simulation passed; ARC and physical storage remain unverified")
return 0 if result["passed"] else 1
args.output.mkdir(parents=True, exist_ok=True)
report = inspect(args.root)
failures = preflight(report, min_free_gib=args.min_free_gib, min_memory_gib=args.min_memory_gib,
@@ -261,9 +348,6 @@ def main():
print("Runner preflight failed: " + "; ".join(failures), file=sys.stderr)
return 1
if args.benchmark:
def cancel(_signum, _frame):
raise KeyboardInterrupt
signal.signal(signal.SIGTERM, cancel)
result = benchmark(args.source.resolve(), args.root.resolve(), args.output.resolve(), args.source_sha,
args.toolchain, args.benchmark, args.jobs, args.timeout_seconds)
return 0 if result["passed"] else 1
+20
View File
@@ -34,6 +34,26 @@ class PilotTests(unittest.TestCase):
failures = pilot.preflight(broken, min_free_gib=101, min_memory_gib=8, architecture="x86_64", docker=True, uring=True)
self.assertEqual(len(failures), 5)
def test_real_local_workers_clean_normal_cancelled_and_crashed_tasks(self):
report = pilot.isolation_rehearsal(self.scratch, self.output)
self.assertTrue(report['passed'])
self.assertEqual([case['termination'] for case in report['cases']], ['normal', 'cancelled', 'crashed'])
self.assertTrue(all(case['passed'] and case['next_worker_started'] for case in report['cases']))
self.assertEqual(list(self.scratch.iterdir()), [])
self.assertFalse(report['worker_destruction_verified'])
self.assertFalse(report['storage_locality_verified'])
with self.assertRaisesRegex(ValueError, 'retain the original'):
pilot.isolation_rehearsal(self.scratch, self.output)
def test_incomplete_local_cleanup_blocks_next_worker_and_preserves_failure(self):
with self.assertRaisesRegex(ValueError, 'refusing the next worker'):
pilot.isolation_rehearsal(self.scratch, self.output, remove_tree=lambda _: None)
report = json.loads((self.output / 'isolation.json').read_text())
self.assertFalse(report['passed'])
self.assertEqual(len(report['cases']), 1)
self.assertFalse(report['cases'][0]['next_worker_started'])
self.assertEqual(list(self.scratch.iterdir()), [])
def test_memory_limit_obeys_container_limit(self):
values = {"/proc/meminfo": "MemTotal: 67108864 kB",
"/sys/fs/cgroup/memory.max": str(8 * 1024 ** 3)}
+26 -13
View File
@@ -38,7 +38,8 @@ E2E_SELECTION_FILES = {
"e2e-distributed", "e2e-protocols", "e2e-odm-interop",
)
}
OPTIONAL_JOBS = ("build-rustfs-debug-binary-rio-v2", "e2e-tests-rio-v2", "e2e-full")
SHARD_JOBS = ("workspace-shards", "workspace-shard-evidence")
OPTIONAL_JOBS = ("build-rustfs-debug-binary-rio-v2", "e2e-tests-rio-v2", "e2e-full", *SHARD_JOBS)
NON_VALIDATION_JOBS = {"required-checks", "cancel-closed-pr-runs", "alert-on-failure"}
@@ -157,11 +158,13 @@ def closure(seeds: set[str], graph: dict[str, set[str]], reverse: bool = False)
reached = expanded
def expected_results(mode: str, event: str, ref: str, connect: bool = True, action: str = "") -> dict[str, str]:
def expected_results(mode: str, event: str, ref: str, connect: bool = True, action: str = "", rehearse_shards: bool = False) -> dict[str, str]:
if event not in EVENTS:
raise ValueError(f"unsupported CI event: {event!r}")
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}")
if type(rehearse_shards) is not bool or (rehearse_shards and event != "workflow_dispatch"):
raise ValueError("shard rehearsal requires an explicit manual event")
expected = {job: "success" for job in ALWAYS_JOBS}
expected.update({job: "success" if mode == "full" or (mode == "e2e" and job in E2E_JOBS) else "skipped" for job in CODE_JOBS})
expected["connect-feature-boundaries"] = "success" if mode == "full" and connect else "skipped"
@@ -171,6 +174,7 @@ def expected_results(mode: str, event: str, ref: str, connect: bool = True, acti
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"))
expected["e2e-full"] = "success" if full else "skipped"
expected.update({job: "success" if rehearse_shards else "skipped" for job in SHARD_JOBS})
if event == "pull_request" and action == "closed":
return {job: "skipped" for job in expected}
return expected
@@ -212,7 +216,7 @@ def configurations(jobs: dict[str, str]) -> list[dict]:
"default_features": True, "features": features} for identity, job, profile, features in values if jobs[job] == "success"]
def select_plan(event: str, base: str, head: str, ref: str, root: Path, action: str = "") -> dict:
def select_plan(event: str, base: str, head: str, ref: str, root: Path, action: str = "", rehearse_shards: bool = False) -> dict:
if event not in EVENTS or not SHA.fullmatch(head):
raise ValueError("unsupported event or missing candidate SHA")
tree = git(root, "rev-parse", f"{head}^{{tree}}").strip()
@@ -250,7 +254,12 @@ def select_plan(event: str, base: str, head: str, ref: str, root: Path, action:
if path in E2E_SELECTION_FILES:
affected.add("e2e_test")
continue
if any(fnmatch.fnmatchcase(path, pattern) for pattern in policy["full_paths"]):
# Cargo's graph cannot describe include_bytes!, fixture readers or
# build-script inputs shared across packages. Narrow only Rust
# sources; all other package-owned inputs retain the full gate.
if (not path.endswith(".rs") or PurePosixPath(path).name == "build.rs"
or "fixtures" in PurePosixPath(path).parts
or any(fnmatch.fnmatchcase(path, pattern) for pattern in policy["full_paths"])):
full = True
reasons.append(f"shared build or policy input: {path}")
continue
@@ -292,15 +301,16 @@ def select_plan(event: str, base: str, head: str, ref: str, root: Path, action:
reasons.append("selected changed packages, reverse dependencies and fixed core contracts")
reasons.append("Connect boundary or a shared input changed" if connect else "Connect has no changed credential/configuration dependency")
proposed = {"packages": sorted(selected), "connect": connect, "full_validation": full, "reasons": reasons,
"jobs": expected_results(mode, event, ref, connect, action)}
"jobs": expected_results(mode, event, ref, connect, action, rehearse_shards)}
shadow = policy is None or policy["rollout"] == "shadow"
effective_packages = all_packages if shadow and mode != "docs" else sorted(selected)
effective_connect = mode == "full" if shadow else connect
effective_full = mode == "full" if shadow else full
jobs = expected_results(mode, event, ref, effective_connect, action)
jobs = expected_results(mode, event, ref, effective_connect, action, rehearse_shards)
plan = {"schema_version": 1, "event": event, "action": action, "ref": ref, "base": base if SHA.fullmatch(base) else "",
"head": head, "tree": tree, "policy": {"revision": policy_revision, "rollout": "shadow" if shadow else "enforced"},
"mode": mode, "packages": effective_packages, "full_validation": effective_full, "connect": effective_connect,
"rehearse_shards": rehearse_shards,
"configurations": configurations(jobs), "jobs": jobs, "proposed": proposed,
"reasons": ["shadow rollout preserves current validation responsibility"] + reasons if shadow else reasons,
"not_applicable": {job: f"not required for {mode}/{event}" for job, result in jobs.items() if result == "skipped"}}
@@ -323,7 +333,7 @@ def validate_plan(plan: object, event: str, ref: str, head: str = "", tree: str
raise ValueError(f"invalid plan {field}")
if (head and plan["head"] != head) or (tree and plan["tree"] != tree):
raise ValueError("plan candidate identity does not match this run")
if type(plan.get("connect")) is not bool or type(plan.get("full_validation")) is not bool:
if any(type(plan.get(name)) is not bool for name in ("connect", "full_validation", "rehearse_shards")):
raise ValueError("invalid plan selection flags")
policy = plan.get("policy")
if not isinstance(policy, dict) or policy.get("rollout") not in ("shadow", "enforced") or not isinstance(policy.get("revision"), str) or not SHA.fullmatch(policy["revision"]):
@@ -335,7 +345,7 @@ def validate_plan(plan: object, event: str, ref: str, head: str = "", tree: str
raise ValueError("invalid plan packages")
if plan.get("mode") != "docs" and not packages:
raise ValueError("code validation plan must not be empty")
expected = expected_results(plan.get("mode"), event, ref, plan["connect"], plan.get("action", ""))
expected = expected_results(plan.get("mode"), event, ref, plan["connect"], plan.get("action", ""), plan["rehearse_shards"])
if plan.get("jobs") != expected:
raise ValueError("plan job contract differs from selected responsibilities")
if plan.get("configurations") != configurations(expected):
@@ -360,7 +370,7 @@ def verify_results(needs: object, event: str, ref: str, head: str = "", tree: st
expected = validate_plan(plan, event, ref, head, tree, base, action)
if outputs.get("mode") != plan["mode"]:
raise ValueError("plan mode differs from scheduling output")
for name in ("connect", "full_validation"):
for name in ("connect", "full_validation", "rehearse_shards"):
if name in outputs and outputs[name] != str(plan[name]).lower():
raise ValueError(f"plan {name} differs from scheduling output")
if outputs.get("packages") and json.loads(outputs["packages"]) != plan["packages"]:
@@ -545,14 +555,16 @@ def check_workflow(root: Path) -> list[str]:
if (root / ".github/workflows/ci-docs-only.yml").exists():
errors.append("the duplicate required-status companion must be removed")
# Truth tables protect behavior while allowing equivalent expression and YAML formatting.
for event in EVENTS:
for event, rehearse_shards in [(event, rehearsal) for event in EVENTS
for rehearsal in ((False, True) if event == "workflow_dispatch" else (False,))]:
for mode in (("docs", "e2e", "full") if event == "pull_request" else ("full",)):
for ref in ("refs/heads/main", "refs/heads/release"):
for connect in (True, False) if event == "pull_request" and mode == "full" else (mode == "full",):
for action in ("opened", "closed") if event == "pull_request" else ("",):
expected = expected_results(mode, event, ref, connect, action)
expected = expected_results(mode, event, ref, connect, action, rehearse_shards)
context = {"github.event_name": event, "github.ref": ref, "github.event.action": action,
"needs.classify-changes.outputs.mode": mode, "needs.classify-changes.outputs.connect": str(connect).lower(),
"needs.classify-changes.outputs.rehearse_shards": str(rehearse_shards).lower(),
"success": True}
evaluated, evaluating = {}, set()
@@ -603,11 +615,12 @@ def main() -> int:
try:
plan = select_plan(os.environ.get("GITHUB_EVENT_NAME", ""), os.environ.get("CI_BASE_SHA", ""),
os.environ.get("GITHUB_SHA", ""), os.environ.get("GITHUB_REF", ""),
Path.cwd(), os.environ.get("CI_EVENT_ACTION", ""))
Path.cwd(), os.environ.get("CI_EVENT_ACTION", ""), os.environ.get("CI_REHEARSE_SHARDS", "false") == "true")
with open(os.environ["GITHUB_OUTPUT"], "a") as output:
for name, value in (("mode", plan["mode"]), ("plan", json.dumps(plan, separators=(",", ":"))),
("connect", str(plan["connect"]).lower()), ("packages", json.dumps(plan["packages"], separators=(",", ":"))),
("full_validation", str(plan["full_validation"]).lower())):
("full_validation", str(plan["full_validation"]).lower()),
("rehearse_shards", str(plan["rehearse_shards"]).lower())):
output.write(f"{name}={value}\n")
print(json.dumps(plan, indent=2))
if os.environ.get("GITHUB_STEP_SUMMARY"):
+224
View File
@@ -0,0 +1,224 @@
#!/usr/bin/env python3
"""Rehearse at most two workspace shards against a successful same-run baseline."""
import argparse
import copy
import json
import math
import os
from pathlib import Path
import re
import subprocess
import sys
import time
import tomllib
import xml.etree.ElementTree as ET
from ci_test_artifact import ROOT, file_hash, selected_tests, source_identity, verify_execution
def require(value, message):
if not value:
raise ValueError(message)
def metadata_arguments(root):
return ["--cargo-metadata", str(root / "target/nextest/cargo-metadata.json"),
"--binaries-metadata", str(root / "target/nextest/binaries-metadata.json"),
"--workspace-remap", str(root), "--target-dir-remap", str(root / "target")]
def list_tests(root, arguments):
return json.loads(subprocess.check_output(
["cargo", "nextest", "list", "--profile", "ci", *arguments, "--message-format", "json"], cwd=root))
def matches(listing):
return {(binary, name) for binary, suite in listing["rust-suites"].items()
for name, case in suite["testcases"].items()
if not case.get("ignored") and case.get("filter-match", {}).get("status") == "matches"}
def protected_filter(configuration):
# Nextest evaluates the group assignments and filtersets. Reserve every
# constrained binary in one component, including inherited thread budgets.
filters = set()
name, seen = "ci", set()
while name:
require(name not in seen, "cyclic nextest profile inheritance")
seen.add(name)
profile = configuration.get("profile", {}).get(name, {})
if "threads-required" in profile or profile.get("test-group", "@global") != "@global":
filters.add("all()")
for override in profile.get("overrides", []):
if "threads-required" in override or override.get("test-group", "@global") != "@global":
filters.add(override.get("filter", "all()"))
name = profile.get("inherits", "default") if name != "default" else None
return " or ".join(f"({value})" for value in sorted(filters)) or "none()"
def partition(listing, junit, protected, count=2):
require(type(count) is int and 1 <= count <= 2, "shard count must be one or two")
expected = selected_tests(listing)
require(protected <= expected, "protected selection contains an unknown test")
durations = {}
for suite in ET.parse(junit).getroot().findall("testsuite"):
for case in suite.findall("testcase"):
key = (suite.get("name"), case.get("name"))
if key not in expected:
continue
seconds = float(case.attrib["time"])
require(math.isfinite(seconds) and seconds >= 0, "invalid observed test duration")
require(key not in durations, "duplicate observed test duration")
durations[key] = seconds
require(set(durations) == expected, "duration baseline does not cover the selected tests")
binaries = {binary for binary, _ in expected}
require(all(re.fullmatch(r"[A-Za-z0-9_.:-]+", binary) for binary in binaries), "unsupported binary ID")
reserved = {binary for binary, _ in protected}
components = [reserved] if reserved else []
components += [{binary} for binary in sorted(binaries - reserved)]
weight = {binary: sum(seconds for (owner, _), seconds in durations.items() if owner == binary) for binary in binaries}
shards = [{"index": index + 1, "binaries": [], "observed_test_seconds": 0.0}
for index in range(min(count, len(components)))]
for component in sorted(components, key=lambda names: (-sum(weight[name] for name in names), sorted(names))):
shard = min(shards, key=lambda item: (item["observed_test_seconds"], len(item["binaries"]), item["index"]))
shard["binaries"].extend(sorted(component))
shard["observed_test_seconds"] += sum(weight[name] for name in component)
for shard in shards:
shard["binaries"].sort()
shard["tests"] = sum(binary in shard["binaries"] for binary, _ in expected)
shard["filter"] = " or ".join(f"binary_id(={binary})" for binary in shard["binaries"])
require(shard["tests"] > 0, "empty shard")
return shards
def write_json(path, value):
path.write_text(json.dumps(value, indent=2) + "\n")
def prepare(root, listing_path, junit, output, count):
verify_execution(listing_path, junit)
listing = json.loads(listing_path.read_text())
protected = list_tests(root, [*metadata_arguments(root), "-E",
protected_filter(tomllib.loads((root / ".config/nextest.toml").read_text()))])
shards = partition(listing, junit, matches(protected), count)
output.mkdir(parents=True, exist_ok=False)
write_json(output / "listing.json", listing)
plan = {"schema": 1, "source": source_identity(), "run_id": os.environ.get("GITHUB_RUN_ID", "local"),
"run_attempt": os.environ.get("GITHUB_RUN_ATTEMPT", "local"),
"listing_sha256": file_hash(output / "listing.json"), "duration_sha256": file_hash(junit),
"protected_binaries": sorted({binary for binary, _ in matches(protected)}), "shards": shards}
write_json(output / "plan.json", plan)
if os.environ.get("GITHUB_OUTPUT"):
with open(os.environ["GITHUB_OUTPUT"], "a") as stream:
stream.write("workspace_shards=" + json.dumps([item["index"] for item in shards]) + "\n")
return plan
def load_plan(directory):
plan = json.loads((directory / "plan.json").read_text())
require(plan.get("schema") == 1 and plan.get("source") == source_identity(), "shard candidate mismatch")
require(plan.get("run_id") == os.environ.get("GITHUB_RUN_ID", "local")
and plan.get("run_attempt") == os.environ.get("GITHUB_RUN_ATTEMPT", "local"), "shard run/attempt mismatch")
require(plan.get("listing_sha256") == file_hash(directory / "listing.json"), "shard listing mismatch")
listing = json.loads((directory / "listing.json").read_text())
expected = {binary for binary, _ in selected_tests(listing)}
shards = plan["shards"]
require(1 <= len(shards) <= 2 and [item["index"] for item in shards] == list(range(1, len(shards) + 1)), "invalid shard indices")
owners = [binary for shard in shards for binary in shard["binaries"]]
require(len(owners) == len(set(owners)) and set(owners) == expected, "missing or overlapping shard binary")
protected = set(plan["protected_binaries"])
require(protected <= expected and any(protected <= set(shard["binaries"]) for shard in shards), "split resource component")
for shard in shards:
require(shard["binaries"] and all(re.fullmatch(r"[A-Za-z0-9_.:-]+", name) for name in shard["binaries"]), "invalid shard binary")
require(shard["filter"] == " or ".join(f"binary_id(={name})" for name in shard["binaries"]), "shard filter mismatch")
return plan, listing
def shard_listing(listing, binaries):
result = copy.deepcopy(listing)
result["rust-suites"] = {key: value for key, value in result["rust-suites"].items() if key in binaries}
selected_tests(result)
return result
def execute(root, directory, archive, index, output):
plan, listing = load_plan(directory)
require(type(index) is int and 1 <= index <= len(plan["shards"]), "unknown shard index")
shard = plan["shards"][index - 1]
output.mkdir(parents=True, exist_ok=False)
extracted = list_tests(root, ["--archive-file", str(archive), "--workspace-remap", str(root),
"--extract-to", str(root), "--extract-overwrite"])
require(selected_tests(extracted) == selected_tests(listing), "archive does not match the original baseline")
write_json(output / "listing.json", shard_listing(listing, shard["binaries"]))
junit = root / "target/nextest/ci/junit.xml"
junit.unlink(missing_ok=True)
started = time.monotonic()
result = subprocess.run(["cargo", "nextest", "run", "--profile", "ci", *metadata_arguments(root),
"-E", shard["filter"], "--status-level", "all", "--final-status-level", "all"], cwd=root)
receipt = {"schema": 1, "index": index, "plan_sha256": file_hash(directory / "plan.json"),
"exit_code": result.returncode, "elapsed_seconds": time.monotonic() - started}
if junit.exists():
(output / "junit.xml").write_bytes(junit.read_bytes())
write_json(output / "receipt.json", receipt)
require(result.returncode == 0, "shard execution failed")
return verify_execution(output / "listing.json", output / "junit.xml")
def verify(directory, evidence):
plan, listing = load_plan(directory)
receipts = list(evidence.glob("*/receipt.json"))
require(len(receipts) == len(plan["shards"]), "missing or extra shard receipt")
seen = set()
combined = ET.Element("testsuites", failures="0", errors="0")
for path in receipts:
receipt = json.loads(path.read_text())
index = receipt.get("index")
require(type(index) is int and 1 <= index <= len(plan["shards"]) and index not in seen, "duplicate or unknown shard receipt")
require(receipt.get("schema") == 1 and receipt.get("exit_code") == 0
and receipt.get("plan_sha256") == file_hash(directory / "plan.json"), "failed or mismatched shard receipt")
seen.add(index)
subset = json.loads((path.parent / "listing.json").read_text())
require(subset == shard_listing(listing, plan["shards"][index - 1]["binaries"]), "shard selected a different test set")
verify_execution(path.parent / "listing.json", path.parent / "junit.xml")
# Setup scripts are validated per shard, but are not Rust test members.
combined.extend(suite for suite in ET.parse(path.parent / "junit.xml").getroot().findall("testsuite")
if not suite.get("name", "").startswith("@setup-script:"))
combined.set("tests", str(sum(len(suite.findall("testcase")) for suite in combined)))
ET.ElementTree(combined).write(evidence / "combined.xml", encoding="utf-8", xml_declaration=True)
return verify_execution(directory / "listing.json", evidence / "combined.xml")
def main():
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest="command", required=True)
create = sub.add_parser("prepare")
create.add_argument("--listing", type=Path, required=True)
create.add_argument("--junit", type=Path, required=True)
create.add_argument("--output", type=Path, required=True)
create.add_argument("--count", type=int, choices=(1, 2), default=2)
run = sub.add_parser("execute")
run.add_argument("--plan", type=Path, required=True)
run.add_argument("--archive", type=Path, required=True)
run.add_argument("--index", type=int, required=True)
run.add_argument("--output", type=Path, required=True)
check = sub.add_parser("verify")
check.add_argument("--plan", type=Path, required=True)
check.add_argument("--evidence", type=Path, required=True)
args = parser.parse_args()
try:
if args.command == "prepare":
result = prepare(ROOT, args.listing, args.junit, args.output, args.count)
print(f"Prepared {len(result['shards'])} bounded shards")
elif args.command == "execute":
print(f"Verified {execute(ROOT, args.plan, args.archive, args.index, args.output)} shard tests")
else:
print(f"Verified {verify(args.plan, args.evidence)} tests across all shards")
except (OSError, ValueError, KeyError, TypeError, ET.ParseError, subprocess.CalledProcessError) as error:
print(f"workspace shard verification: {error}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+1 -1
View File
@@ -18,7 +18,7 @@ ACTIVE_STATUSES = ("queued", "in_progress", "pending", "waiting", "requested")
def validate_request(inputs):
require(isinstance(inputs, dict) and inputs.get("validation_runner_inspect_only") is True,
"runner inspection must be explicitly selected")
defaults = {"validation_runner_inspect_only": True,
defaults = {"validation_runner_inspect_only": True, "validation_plan": "nightly",
"validation_retain_evidence_run_attempt": "1",
"i05_diagnostic_branch": "source-plan", "validation_tools_run_attempt": "1"}
require(all(value == defaults[key] if key in defaults else value in ("", False, None)
+1 -1
View File
@@ -101,7 +101,7 @@ def prepare(env, report):
attempt = inputs.get("validation_retain_evidence_run_attempt", "1")
require(isinstance(source, str) and re.fullmatch(r"[1-9][0-9]{0,19}", source), "exact source run ID required")
require(isinstance(attempt, str) and re.fullmatch(r"[1-9][0-9]{0,9}", attempt), "exact source run attempt required")
defaults = {"validation_retain_evidence_run_id": source,
defaults = {"validation_retain_evidence_run_id": source, "validation_plan": "nightly",
"validation_retain_evidence_run_attempt": attempt,
"i05_diagnostic_branch": "source-plan", "validation_tools_run_attempt": "1"}
require(all(value == defaults[key] if key in defaults else value in ("", False, None)
+41
View File
@@ -1,8 +1,14 @@
#!/usr/bin/env python3
import copy
import json
from pathlib import Path
import subprocess
import tempfile
import unittest
from ci_merge_queue import REQUIRED_CHECKS, compare, prepare
import ci_gate
from test_ci_plan import Repository
class QueueTests(unittest.TestCase):
@@ -48,6 +54,41 @@ class QueueTests(unittest.TestCase):
with self.assertRaises(ValueError):
prepare(dict(record["after"], id=12))
def test_individually_passing_changes_fail_on_the_actual_combined_git_candidate(self):
with tempfile.TemporaryDirectory() as directory:
repo = Repository(Path(directory))
repo.write('rust-toolchain.toml', (ci_gate.ROOT / 'rust-toolchain.toml').read_text())
repo.write('core/src/lib.rs', 'mod a; mod b; #[test] fn combined_contract() { assert!(a::VALUE + b::VALUE <= 3); }\n')
repo.write('core/src/a.rs', 'pub const VALUE: u8 = 1;\n')
repo.write('core/src/b.rs', 'pub const VALUE: u8 = 1;\n')
baseline = repo.commit()
def check():
return subprocess.run(['cargo', 'nextest', 'run', '--offline', '--color', 'never', '-p', 'core'],
cwd=repo.root, capture_output=True, text=True)
repo.git('checkout', '-b', 'first')
repo.write('core/src/a.rs', 'pub const VALUE: u8 = 2;\n')
first = repo.commit()
self.assertEqual(check().returncode, 0)
repo.git('checkout', '-b', 'second', baseline)
repo.write('core/src/b.rs', 'pub const VALUE: u8 = 2;\n')
second = repo.commit()
self.assertEqual(check().returncode, 0)
repo.git('checkout', '-b', 'combined', first)
repo.git('merge', '--no-ff', '-m', 'combined candidate', second)
head = repo.git('rev-parse', 'HEAD')
plan = ci_gate.select_plan('merge_group', baseline, head, 'refs/heads/main', repo.root)
self.assertEqual(plan['head'], head)
self.assertEqual(plan['tree'], repo.git('rev-parse', head + '^{tree}'))
self.assertTrue(plan['full_validation'])
result = check()
self.assertNotEqual(result.returncode, 0)
self.assertIn('combined_contract', result.stderr)
self.assertIn('1 failed', result.stderr)
needs = {job: {'result': value} for job, value in plan['jobs'].items()}
needs['classify-changes']['outputs'] = {'mode': 'full', 'plan': json.dumps(plan)}
needs['workspace-tests']['result'] = 'failure'
self.assertTrue(ci_gate.verify_results(needs, 'merge_group', plan['ref'], head, plan['tree']))
if __name__ == "__main__":
unittest.main()
+63 -2
View File
@@ -17,6 +17,7 @@ class Repository:
def __init__(self, root):
self.root = root
self.git('init', '-q')
self.write('.gitignore', '/target/\n')
self.write('Cargo.toml', '[workspace]\nmembers = ["rustfs", "core", "leaf", "consumer", "macro", "crates/e2e_test"]\n[workspace.dependencies]\nrenamed = { package="leaf", path="leaf" }\n')
for name, directory in [('rustfs', 'rustfs'), ('core', 'core'), ('leaf', 'leaf'), ('consumer', 'consumer'), ('macro', 'macro'), ('e2e_test', 'crates/e2e_test')]:
self.write(f'{directory}/Cargo.toml', f'[package]\nname = "{name}"\nversion = "0.1.0"\n')
@@ -96,6 +97,47 @@ class PlannerTests(unittest.TestCase):
self.assertTrue(plan['full_validation'])
self.assertTrue(plan['connect'])
def test_enforced_selection_keeps_shared_package_inputs_full(self):
self.repo.write(gate.POLICY_PATH, json.dumps(dict(self.repo.policy, rollout='enforced')))
baseline = self.repo.commit()
for path in ('leaf/build.rs', 'leaf/tests/fixtures/data.json', 'leaf/tests/fixtures/shared.rs',
'leaf/src/template.html', 'leaf/native/include.h'):
with self.subTest(path=path):
self.repo.write(path, 'changed\n')
plan = self.repo.plan(self.repo.commit(), base=baseline)
self.assertTrue(plan['full_validation'])
self.assertTrue(plan['connect'])
self.assertEqual(set(plan['packages']), {'rustfs', 'core', 'leaf', 'consumer', 'macro'})
def test_enforced_unrelated_source_omits_connect_but_keeps_its_dependents_and_core(self):
self.repo.write(gate.POLICY_PATH, json.dumps(dict(self.repo.policy, rollout='enforced')))
baseline = self.repo.commit()
self.repo.write('leaf/src/lib.rs', '// changed\n')
plan = self.repo.plan(self.repo.commit(), base=baseline)
self.assertEqual(plan['packages'], ['consumer', 'core', 'leaf', 'rustfs'])
self.assertEqual(plan['jobs']['connect-feature-boundaries'], 'skipped')
for job in ('test-and-lint', 'workspace-tests', 'core-contract', 'e2e-tests',
's3-implemented-tests', 's3-lifecycle-behavior-tests'):
self.assertEqual(plan['jobs'][job], 'success')
def test_enforced_plan_runs_a_real_failing_reverse_dependency_test(self):
self.repo.write('rust-toolchain.toml', (gate.ROOT / 'rust-toolchain.toml').read_text())
self.repo.write(gate.POLICY_PATH, json.dumps(dict(self.repo.policy, rollout='enforced')))
self.repo.write('leaf/src/lib.rs', 'pub fn answer() -> u8 { 1 }\n')
self.repo.write('core/src/lib.rs', '#[test] fn core_contract() { assert_eq!(1, 1); }\n')
self.repo.write('consumer/src/lib.rs', '#[cfg(test)] mod tests { extern crate renamed; #[test] fn reverse_dependency_contract() { assert_eq!(renamed::answer(), 1); } }\n')
baseline = self.repo.commit()
self.repo.write('leaf/src/lib.rs', 'pub fn answer() -> u8 { 2 }\n')
plan = self.repo.plan(self.repo.commit(), base=baseline)
arguments = ['cargo', 'nextest', 'run', '--offline', '--color', 'never', '--no-fail-fast']
for package in plan['packages']:
arguments.extend(['-p', package])
result = subprocess.run(arguments, cwd=self.repo.root, capture_output=True, text=True)
self.assertNotEqual(result.returncode, 0)
self.assertIn('reverse_dependency_contract', result.stderr)
self.assertIn('1 failed', result.stderr)
self.assertIn('1 passed', result.stderr)
def test_docs_and_e2e_shortcuts_do_not_hide_production_or_fixture_inputs(self):
self.repo.write('README.md', '# docs\n')
docs = self.repo.plan(self.repo.commit())
@@ -155,7 +197,7 @@ class PlannerTests(unittest.TestCase):
def test_actual_workflow_bootstrap_never_executes_pr_policy_and_upgrades_legacy_output(self):
workflow = gate.workflow_document((Path(gate.__file__).resolve().parent.parent / '.github/workflows/ci.yml').read_text())
body = next(step['run'] for step in workflow['jobs']['classify-changes']['steps'] if step.get('id') == 'scope')
self.repo.write('scripts/ci_gate.py', "import os\nwith open(os.environ['GITHUB_OUTPUT'], 'a') as output: output.write('mode=docs\\n')\n")
self.repo.write('scripts/ci_gate.py', "import os\nwith open(os.environ['GITHUB_OUTPUT'], 'a') as output: output.write('mode=docs\\npackages=[\"core\"]\\nplan={}\\n')\n")
base = self.repo.commit()
self.repo.write('scripts/ci_gate.py', 'raise SystemExit(71)\n')
head = self.repo.commit()
@@ -170,6 +212,8 @@ class PlannerTests(unittest.TestCase):
values = dict(line.split('=', 1) for line in output.read_text().splitlines())
self.assertEqual(values['mode'], 'full')
self.assertEqual(values['connect'], 'true')
self.assertEqual(values['packages'], '')
self.assertEqual(values['plan'], '')
self.repo.write('scripts/ci_gate.py', 'raise SystemExit(29)\n')
broken = self.repo.commit()
env['CI_BASE_SHA'] = broken
@@ -189,7 +233,7 @@ class PlannerTests(unittest.TestCase):
result = subprocess.run([sys.executable, '-I', str(script), 'select'], cwd=self.repo.root, env=env, capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
values = dict(line.split('=', 1) for line in output.read_text().splitlines())
self.assertEqual(set(values), {'mode', 'plan', 'connect', 'packages', 'full_validation'})
self.assertEqual(set(values), {'mode', 'plan', 'connect', 'packages', 'full_validation', 'rehearse_shards'})
plan = json.loads(values['plan'])
self.assertEqual(plan['policy']['revision'], self.repo.base)
self.assertEqual(plan['policy']['rollout'], 'shadow')
@@ -201,6 +245,7 @@ class GateTests(unittest.TestCase):
jobs = gate.expected_results(mode, event, 'refs/heads/main', connect)
return {'schema_version': 1, 'event': event, 'ref': 'refs/heads/main', 'head': 'a' * 40, 'tree': 'b' * 40,
'base': 'c' * 40, 'action': '', 'mode': mode, 'connect': connect, 'full_validation': mode == 'full', 'packages': ['rustfs'] if mode != 'docs' else [],
'rehearse_shards': False,
'policy': {'revision': 'c' * 40, 'rollout': 'enforced'}, 'jobs': jobs, 'configurations': gate.configurations(jobs),
'reasons': ['fixture'], 'not_applicable': {job: 'fixture' for job, state in jobs.items() if state == 'skipped'}}
@@ -262,6 +307,8 @@ class GateTests(unittest.TestCase):
expression = "contains(fromJSON('[\"schedule\",\"workflow_dispatch\"]'), github.event_name)"
elif job == 'e2e-full':
expression = "github.event_name == 'merge_group' || github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main')"
elif job in gate.SHARD_JOBS:
expression = "needs.classify-changes.outputs.rehearse_shards == 'true'"
elif job == 'connect-feature-boundaries':
expression = "needs.classify-changes.outputs.mode == 'full' && needs.classify-changes.outputs.connect == 'true'"
elif job in gate.E2E_JOBS:
@@ -319,6 +366,20 @@ class GateTests(unittest.TestCase):
self.assertEqual(arguments[arguments.index('--target-dir-remap') + 1], str(root / 'target'))
self.assertIn(f'exit_status={status}', (root / 'artifacts/ilm-integration/diagnostics.txt').read_text())
def test_requested_shards_and_union_proof_are_required_and_cannot_be_skipped(self):
plan = self.plan('workflow_dispatch')
plan['rehearse_shards'] = True
plan['jobs'] = gate.expected_results('full', 'workflow_dispatch', plan['ref'], rehearse_shards=True)
plan['not_applicable'] = {job: 'not selected' for job, state in plan['jobs'].items() if state == 'skipped'}
self.assertEqual(gate.verify_results(self.needs(plan), plan['event'], plan['ref']), [])
for job in gate.SHARD_JOBS:
for state in ('failure', 'cancelled', 'skipped', None):
needs = self.needs(plan)
needs[job]['result'] = state
self.assertTrue(gate.verify_results(needs, plan['event'], plan['ref']))
with self.assertRaises(ValueError):
gate.expected_results('full', 'pull_request', plan['ref'], rehearse_shards=True)
def test_semantic_yaml_equivalence_and_wiring_boundaries(self):
workflow = self.workflow()
self.assertEqual(self.check(workflow), [])
+22
View File
@@ -195,6 +195,28 @@ class ArtifactIdentityTests(unittest.TestCase):
self.assertEqual(artifact.main(), 0)
self.assertEqual(run.call_args.args[0], ["cargo", "test", "--locked", "--workspace", "--exclude", "e2e_test", "--doc"])
def test_real_lint_rejects_a_warning_even_when_archive_tests_pass(self):
(self.root / 'rust-toolchain.toml').write_text((artifact.ROOT / 'rust-toolchain.toml').read_text())
(self.root / 'Cargo.toml').write_text('[workspace]\nmembers = ["probe", "e2e_test"]\n')
for name in ('probe', 'e2e_test'):
package = self.root / name
(package / 'src').mkdir(parents=True)
(package / 'Cargo.toml').write_text(f'[package]\nname="{name}"\nversion="0.1.0"\nedition="2021"\n')
(package / 'src/lib.rs').write_text('')
source = self.root / 'probe/src/lib.rs'
source.write_text('pub fn answer() -> u8 { let unused = 1; 2 }\n#[test] fn value() { assert_eq!(answer(), 2); }\n')
archive = self.root / 'lint-probe.tar.zst'
for arguments in (['archive', '--offline', '--archive-file', str(archive)],
['run', '--color', 'never', '--archive-file', str(archive)]):
result = subprocess.run(['cargo', 'nextest', *arguments], cwd=self.root, text=True, capture_output=True)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn('1 passed', result.stderr)
with patch.object(artifact, 'ROOT', self.root), patch.dict(os.environ, {'CI_PACKAGES': '["probe"]'}), \
patch.object(sys, 'argv', ['ci_test_artifact.py', 'cargo', 'lint', '--configuration', 'workspace']):
self.assertEqual(artifact.main(), 1)
source.write_text(source.read_text().replace('let unused = 1; ', ''))
self.assertEqual(artifact.main(), 0)
class ExecutionEvidenceTests(unittest.TestCase):
def setUp(self):
+163
View File
@@ -0,0 +1,163 @@
#!/usr/bin/env python3
"""A bounded shard run must preserve resource groups and exact test membership."""
import copy
import json
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
from unittest.mock import patch
import xml.etree.ElementTree as ET
import ci_test_shards as shards
import check_migration_gate_evidence as migration
from test_ci_test_artifact import listing
class PartitionTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.listing = listing([('a', 'one', False), ('b', 'two', False), ('c', 'three', False)])
self.junit = self.root / 'baseline.xml'
self.junit.write_text('<testsuites failures="0" errors="0">' + ''.join(
f'<testsuite name="{binary}"><testcase name="{name}" classname="{binary}" time="{seconds}"/></testsuite>'
for binary, name, seconds in [('a', 'one', 20), ('b', 'two', 10), ('c', 'three', 25)]) + '</testsuites>')
def test_observed_durations_balance_whole_protected_component(self):
result = shards.partition(self.listing, self.junit, {('a', 'one'), ('b', 'two')})
self.assertEqual([item['binaries'] for item in result], [['a', 'b'], ['c']])
self.assertEqual([item['observed_test_seconds'] for item in result], [30, 25])
self.assertEqual(sum(item['tests'] for item in result), 3)
self.assertEqual(len(shards.partition(self.listing, self.junit, set(), 1)), 1)
self.assertEqual(len(shards.partition(self.listing, self.junit, {('a', 'one'), ('b', 'two'), ('c', 'three')})), 1)
def test_missing_invalid_and_unknown_observations_fail(self):
for duration in ('NaN', '-1', 'Infinity'):
with self.subTest(duration=duration):
original = self.junit.read_text()
self.junit.write_text(original.replace('time="20"', f'time="{duration}"'))
with self.assertRaises(ValueError):
shards.partition(self.listing, self.junit, set())
self.junit.write_text(original)
with self.assertRaises(ValueError):
shards.partition(self.listing, self.junit, {('unknown', 'test')})
for count in (0, 3, True):
with self.assertRaises(ValueError):
shards.partition(self.listing, self.junit, set(), count)
self.junit.write_text('<testsuites/>')
with self.assertRaisesRegex(ValueError, 'does not cover'):
shards.partition(self.listing, self.junit, set())
def test_inherited_thread_constraints_are_included_without_parsing_the_dsl(self):
config = {'profile': {'ci': {'inherits': 'intermediate', 'overrides': [
{'filter': 'test(=shared)', 'test-group': 'shared'}]}, 'intermediate': {'overrides': [
{'filter': 'test(=thread_bound)', 'threads-required': 2}]}, 'default': {'overrides': [
{'filter': 'test(=exclusive)', 'threads-required': 'num-test-threads'}]}}}
expression = shards.protected_filter(config)
self.assertIn('test(=shared)', expression)
self.assertIn('test(=thread_bound)', expression)
self.assertIn('test(=exclusive)', expression)
config['profile']['intermediate']['inherits'] = 'ci'
with self.assertRaisesRegex(ValueError, 'cyclic'):
shards.protected_filter(config)
class RealNextestTests(unittest.TestCase):
def test_archive_shards_execute_without_rebuilding_and_reject_incomplete_evidence(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
(root / 'src').mkdir()
(root / 'tests').mkdir()
(root / '.config').mkdir()
(root / 'rust-toolchain.toml').write_text((shards.ROOT / 'rust-toolchain.toml').read_text())
(root / 'Cargo.toml').write_text('[package]\nname="probe"\nversion="0.1.0"\nedition="2021"\n')
(root / 'src/lib.rs').write_text('#[test] fn first() { assert!(std::env::var_os("SHARD_FAIL").is_none()); }\n')
(root / 'tests/second.rs').write_text('#[test] fn second() {}\n#[test] #[ignore] fn omitted() {}\n')
(root / 'tests/third.rs').write_text('#[test] fn third() {}\n')
(root / '.config/nextest.toml').write_text('''experimental = ["setup-scripts"]
[test-groups]
shared = { max-threads = 1 }
[scripts.setup.fixture]
command = ['sh', '-c', 'echo SHARD_SETUP=present >> "$NEXTEST_ENV"']
[profile.ci]
retries = 0
fail-fast = false
[profile.ci.junit]
path = "junit.xml"
[[profile.ci.overrides]]
filter = 'binary_id(=probe) or binary_id(=probe::second)'
test-group = 'shared'
[[profile.ci.scripts]]
filter = 'all()'
setup = ['fixture']
''')
archive = root / 'tests.tar.zst'
def cargo(*arguments):
result = subprocess.run(['cargo', 'nextest', *arguments], cwd=root, text=True, capture_output=True)
self.assertEqual(result.returncode, 0, result.stderr)
return result
cargo('archive', '--offline', '--archive-file', str(archive))
full = shards.list_tests(root, ['--archive-file', str(archive), '--extract-to', str(root), '--extract-overwrite'])
full_path = root / 'listing.json'
shards.write_json(full_path, full)
cargo('run', '--profile', 'ci', *shards.metadata_arguments(root))
junit = root / 'baseline.xml'
shutil.copyfile(root / 'target/nextest/ci/junit.xml', junit)
with patch.object(shards, 'source_identity', return_value={'commit': 'a' * 40}):
plan_dir = root / 'plan'
plan = shards.prepare(root, full_path, junit, plan_dir, 2)
self.assertEqual(len(plan['shards']), 2)
reserved = next(item for item in plan['shards'] if 'probe' in item['binaries'])
self.assertIn('probe::second', reserved['binaries'])
(root / 'src/lib.rs').write_text('compile_error!("rebuilding is forbidden");')
evidence = root / 'evidence'
for shard in plan['shards']:
shards.execute(root, plan_dir, archive, shard['index'], evidence / str(shard['index']))
self.assertEqual(shards.verify(plan_dir, evidence), 3)
floor = root / 'migration-floor.txt'
floor.write_text('1\n')
# Older local nextest versions omit the test kind; all fixture
# cases are Rust #[test] functions, as CI's listing records.
migration_listing = copy.deepcopy(full)
for case in migration_listing['rust-suites']['probe']['testcases'].values():
case.setdefault('kind', 'test')
shards.write_json(root / 'migration-listing.json', migration_listing)
with patch.object(migration, 'SUITE', 'probe'), patch.object(migration, 'NAME_PARTS', ('first',)):
self.assertEqual(migration.verify(root / 'migration-listing.json', evidence / 'combined.xml', floor), (1, 1))
receipt = evidence / '1/receipt.json'
original = receipt.read_text()
for field, value in [('index', 2), ('exit_code', 1), ('plan_sha256', 'wrong')]:
shards.write_json(receipt, dict(json.loads(original), **{field: value}))
with self.assertRaises(ValueError):
shards.verify(plan_dir, evidence)
receipt.write_text(original)
receipt.rename(receipt.with_suffix('.absent'))
with self.assertRaisesRegex(ValueError, 'missing'):
shards.verify(plan_dir, evidence)
receipt.with_suffix('.absent').rename(receipt)
report = evidence / '1/junit.xml'
original_report = report.read_bytes()
xml = ET.fromstring(original_report)
suite = next(item for item in xml.findall('testsuite') if not item.get('name').startswith('@setup-script:'))
suite.remove(suite.findall('testcase')[0])
ET.ElementTree(xml).write(report)
with self.assertRaises(ValueError):
shards.verify(plan_dir, evidence)
report.write_bytes(original_report)
bad = copy.deepcopy(plan)
bad['shards'][0]['binaries'].append(bad['shards'][1]['binaries'][0])
shards.write_json(plan_dir / 'plan.json', bad)
with self.assertRaisesRegex(ValueError, 'overlapping'):
shards.load_plan(plan_dir)
shards.write_json(plan_dir / 'plan.json', plan)
with patch.dict(os.environ, {'SHARD_FAIL': '1'}), self.assertRaisesRegex(ValueError, 'execution failed'):
shards.execute(root, plan_dir, archive, reserved['index'], root / 'failed')
if __name__ == '__main__':
unittest.main()
+6 -1
View File
@@ -919,7 +919,10 @@ class WorkflowTimeoutTests(unittest.TestCase):
with self.subTest(suite=suite):
source = (candidate.ROOT / f".github/workflows/rustfs-{suite}-test.yml").read_text()
job = yaml_block(source.splitlines(), job_id, 2)
job_timeout = 360 if suite == "pool-expand" else 60
# heal: a progressing heal outran the shared 45-minute step
# cap (chain 40796474356), so it gets a 90-minute run budget
# and a 120-minute job budget for setup, report and cleanup.
job_timeout = 360 if suite == "pool-expand" else 120 if suite == "heal" else 60
self.assertIn(f" timeout-minutes: {job_timeout}", job)
steps = named_steps(job)
primary = [step for step in steps.values() if any(
@@ -933,6 +936,8 @@ class WorkflowTimeoutTests(unittest.TestCase):
self.assertIsNotNone(timeout_input, event)
self.assertIn(" default: '240'", timeout_input)
self.assertIn(" required: false", timeout_input)
elif suite == "heal":
self.assertIn(" timeout-minutes: 90", primary[0])
else:
self.assertIn(" timeout-minutes: 45", primary[0])
cleanup = "Cleanup environment"
+6 -4
View File
@@ -9,7 +9,7 @@ import unittest
from unittest.mock import patch
import inspect_validation_runner as inspection
from check_test_wiring import yaml_block
from ci_gate import workflow_document
class RunnerInspectionTests(unittest.TestCase):
@@ -20,9 +20,10 @@ class RunnerInspectionTests(unittest.TestCase):
def test_workflow_default_allows_inspection_request(self):
workflow = Path(__file__).resolve().parents[1] / ".github/workflows/rustfs-functional-chain.yml"
block = yaml_block(workflow.read_text().splitlines(), "i05_diagnostic_branch", 6)
default = next(line.split(":", 1)[1].strip() for line in block if line.strip().startswith("default:"))
inspection.validate_request({**self.request(), "i05_diagnostic_branch": default})
definitions = workflow_document(workflow.read_text())['on']['workflow_dispatch']['inputs']
defaults = {key: value['default'] == 'true' if value.get('type') == 'boolean' else value['default']
for key, value in definitions.items() if 'default' in value}
inspection.validate_request({**defaults, **self.request()})
def test_exclusive_request_rejects_every_execution_mode_before_api_access(self):
inspection.validate_request(self.request())
@@ -34,6 +35,7 @@ class RunnerInspectionTests(unittest.TestCase):
("auto_testing_sha", "a" * 40), ("validation_tools_run_id", "123"),
("i05_diagnostic_branch", "queued-successor"),
("i05_diagnostic_branch", "timeout"),
("validation_plan", "weekly"), ("suites", "s3"),
("validation_tools_run_attempt", "2")]:
with self.subTest(key=key), self.assertRaises(ValueError):
inspection.validate_request({**self.request(), key: value})
+6 -4
View File
@@ -10,7 +10,7 @@ from unittest.mock import patch
import retain_validation_evidence as retain
import prepare_retained_validation_recovery as recovery_input
from check_test_wiring import yaml_block
from ci_gate import workflow_document
class RetainEvidenceTests(unittest.TestCase):
@@ -38,12 +38,13 @@ class RetainEvidenceTests(unittest.TestCase):
def test_workflow_default_allows_evidence_retention(self):
workflow = Path(__file__).resolve().parents[1] / ".github/workflows/rustfs-functional-chain.yml"
block = yaml_block(workflow.read_text().splitlines(), "i05_diagnostic_branch", 6)
default = next(line.split(":", 1)[1].strip() for line in block if line.strip().startswith("default:"))
definitions = workflow_document(workflow.read_text())['on']['workflow_dispatch']['inputs']
defaults = {key: value['default'] == 'true' if value.get('type') == 'boolean' else value['default']
for key, value in definitions.items() if 'default' in value}
with tempfile.TemporaryDirectory() as directory:
env, root, run, jobs = self.fixture(directory)
inputs = json.loads(env["EVIDENCE_RETAIN_INPUTS"])
env["EVIDENCE_RETAIN_INPUTS"] = json.dumps({**inputs, "i05_diagnostic_branch": default})
env["EVIDENCE_RETAIN_INPUTS"] = json.dumps({**defaults, **inputs})
with patch.object(retain, "api", side_effect=[run, jobs]):
self.assertEqual(retain.prepare(env, {}), root)
@@ -71,6 +72,7 @@ class RetainEvidenceTests(unittest.TestCase):
("build_run_id", "1"), ("validation_retain_evidence_run_id", "../77"),
("i05_diagnostic_branch", "timeout"),
("i05_diagnostic_branch", "queued-successor"),
("validation_plan", "weekly"), ("suites", "s3"),
("validation_retain_evidence_run_id", "88")]:
with self.subTest(field=field, value=value), patch.object(retain, "api") as api:
altered = {**env, "EVIDENCE_RETAIN_INPUTS": json.dumps({**inputs, field: value})}
+4 -5
View File
@@ -540,10 +540,10 @@ class FaultToleranceWorkflowContractTests(unittest.TestCase):
"GITHUB_RUN_ID": "314159",
}
def write_result(self, *, seen: int = 44, complete: bool = True,
def write_result(self, *, seen: int = 54, complete: bool = True,
duplicate: bool = False, testing_sha: str | None = None,
unrelated_seen: bool = False, missing_verdict: bool = False) -> None:
expected = [f"CASE-{index:02d}" for index in range(44)]
expected = [f"CASE-{index:02d}" for index in range(54)]
seen_cases = expected[:seen]
if unrelated_seen:
seen_cases = [f"OTHER-{index:02d}" for index in range(seen)]
@@ -555,14 +555,14 @@ class FaultToleranceWorkflowContractTests(unittest.TestCase):
payload = {
"schema_version": 1,
"auto_testing_sha": testing_sha or self.TESTING_SHA,
"selected_scenarios": ["A", "B", "C", "C2", "D", "E"],
"selected_scenarios": ["A", "B", "C", "C2", "D", "E", "F"],
"complete": complete,
"expected_cases": expected,
"seen_cases": seen_cases,
"missing_cases": expected[seen:],
"harness_errors": [],
"counts": {
"expected": 44,
"expected": 54,
"seen": seen,
"unexpected": 1,
"known_divergence": 0,
@@ -1186,7 +1186,6 @@ class TableSuiteTests(unittest.TestCase):
self.assertIn("runs-on: smoke-testing", source)
# New gate semantics: case failures stay green, harness breakdowns red.
self.assertRegex(source, r"(?m)^ continue-on-error: true")
self.assertIn("# Red only for harness/environment breakdowns; case failures stay green.", source)
self.assertIn("HARNESS_OK", source)
self.assertIn("Product result: ${CASES_PASS} passed, ${CASES_FAIL} failed", source)
self.assertIn('python3 scripts/functional_case_report.py "${LOG_FILE}" "${CASE_TABLE}"', source)