Compare commits

..

32 Commits

Author SHA1 Message Date
overtrue a902886737 feat(capture): atomically migrate legacy Created history 2026-10-11 16:39:10 +08:00
overtrue 8e53b87421 test(capture): specify atomic populated native migration 2026-10-11 16:39:10 +08:00
overtrue f7631e91b1 feat(capture): stream an owned coherent snapshot view 2026-10-11 16:30:12 +08:00
overtrue f9bc4e921f test(capture): specify coherent snapshot view ownership 2026-10-11 16:30:12 +08:00
overtrue 781584307a feat(capture): apply committed native entries on the ordered worker 2026-10-11 15:35:02 +08:00
overtrue 59d641a5fe docs(capture): define recoverable checkpoint and purge contracts 2026-10-11 15:00:38 +08:00
overtrue c618b57870 feat(capture): stream snapshot rows with a permanent failure fence 2026-10-11 14:44:05 +08:00
overtrue bbe1d54098 feat(capture): add bounded streaming snapshot codec 2026-10-11 14:21:30 +08:00
overtrue 9e8ea4dcdb feat(capture-authority): serialize native async writes 2026-10-11 13:35:15 +08:00
overtrue 63cf8e2f2f feat(capture-authority): add native OpenRaft log reader 2026-10-11 13:01:43 +08:00
overtrue b7fe40f4b3 feat(capture): support complete native log range bounds 2026-10-11 12:28:23 +08:00
overtrue 5b872b8e56 feat(capture): apply native entries atomically with durable replay 2026-10-11 11:50:32 +08:00
overtrue ca5be1ef95 feat(capture): persist native Raft entries in explicitly enabled stores 2026-10-11 05:49:17 +08:00
overtrue 9373e8fa3e Bind capture commands and responses to native Raft entries 2026-10-11 03:45:17 +08:00
overtrue cb32965522 feat(capture): preserve exact raft values and membership codec 2026-10-11 02:04:47 +08:00
overtrue fb6cf49a67 feat(capture): validate peer metadata and CNOD codec 2026-10-11 01:25:21 +08:00
overtrue 8c97049e68 build(capture): pin OpenRaft and guard resolved features 2026-10-11 00:56:30 +08:00
overtrue 17d4fe27cc feat(capture): apply committed Created decisions atomically 2026-10-10 23:35:52 +08:00
overtrue fb0c7972b2 feat(capture): persist committed log boundary 2026-10-10 23:10:28 +08:00
overtrue eba7967755 feat(capture): persist atomic log suffix truncation 2026-10-10 22:59:26 +08:00
overtrue 451c1c31ad feat(capture): add durable local authority storage 2026-10-10 22:33:44 +08:00
overtrue 8780dd670e test(ecstore): verify publication across process kills 2026-10-10 20:19:46 +08:00
overtrue f29bf2c902 fix(iam): preserve empty identity read failures 2026-10-10 11:37:07 +08:00
overtrue fd03436090 feat(ecstore): expose unbounded preserve-empty config reads 2026-10-10 11:10:27 +08:00
overtrue 1f1e7b1c97 fix(iam): preserve identity decryption errors 2026-10-10 10:37:15 +08:00
overtrue 45a952609e test(iam): add real identity storage fixtures 2026-10-10 10:01:03 +08:00
overtrue 323190a95a test(test-utils): support caller-owned instance contexts 2026-10-10 09:43:01 +08:00
overtrue 38cdda9292 fix(ecstore): expose config reader without private bounds 2026-10-10 09:11:47 +08:00
overtrue 85c84fa73d feat(policy): add dormant principal incarnation records 2026-10-10 08:12:14 +08:00
overtrue 51402087c5 feat(ecstore): expose bounded config snapshot reader 2026-10-10 08:03:27 +08:00
overtrue a994984fe2 build(policy): prepare identity incarnation UUID dependency 2026-10-10 06:57:51 +08:00
overtrue 48eb0e402c test(iam): use identity constructor in existing fixtures 2026-10-10 06:52:04 +08:00
593 changed files with 25624 additions and 112983 deletions
@@ -25,7 +25,7 @@ Read only the current phase's procedure; retain completed evidence across turns.
| 4–5 | [Run the downloaded binary, Console CRUD, and latest-rc matrix](references/preview-acceptance.md). Every check must pass. |
| Confirmation | Complete the gate below and end the turn. |
| 6 | After fresh confirmation, [publish and verify the final tag](references/final-release.md) at `PREVIEW_HASH`. |
| 7 | [Reconcile Security Advisories, milestones, installation references, and the website](references/post-release-updates.md) only after complete publication. |
| 7 | [Maintain milestones, installation references, and the website](references/post-release-updates.md) only after complete publication. |
## Human Confirmation
@@ -39,4 +39,4 @@ Only a new affirmative user reply to that exact report authorizes Phase 6. The o
- Once the final tag exists, retry publication jobs for that exact tag. A source fix requires a newly confirmed target, not retagging or a newer preview. Phase 7 retries recheck artifact availability and superseding releases.
- If abandoned after the bump merged, report the unpublished version on main and record whether to revert or let the next release overwrite it. Deleting candidate branches or preview tags requires an explicit decision.
Report completed phase results and incomplete work with evidence: Console previous/latest tags, release need, hash and run/Release URLs; target, branch CI/SHA, previews and hash, confirmation state; preview/final assets, latest-channel state, Console/rc results and cleanup; advisory reconciliation results and pending disclosure decisions; both milestone states, installation PR checks/merge state, and website text/link/PR/deployment state. Keep artifact publication separate from follow-up delivery; do not call an open PR a live update. Report deviations and their authorization.
Report completed phase results and incomplete work with evidence: Console previous/latest tags, release need, hash and run/Release URLs; target, branch CI/SHA, previews and hash, confirmation state; preview/final assets, latest-channel state, Console/rc results and cleanup; both milestone states, installation PR checks/merge state, and website text/link/PR/deployment state. Keep artifact publication separate from follow-up delivery; do not call an open PR a live update. Report deviations and their authorization.
@@ -1,17 +1,7 @@
# Post-release Security Advisory, Milestone, Installation, and Website Updates
# Post-release Milestone, Installation, and Website Updates
Read only after the final non-preview tag has passed Phase 6. Tag creation or a green binary build alone is insufficient: the release assets, source archive, container images, Helm package, and applicable latest channels must be available. Preview tags never enter this phase.
## Security Advisories
Reconcile advisories after every completed final release, including Console-only releases. Read-only preparation may start earlier; do not claim an unpublished artifact is an available fix.
1. Fetch every page of the repository's live Security Advisories, including triage, draft, published, and closed states. A permission error or partial response blocks reconciliation; it does not mean there are no advisories. Keep unpublished advisory details in private task artifacts, never in a public PR, release note, or issue.
2. For each potentially relevant advisory, map its fix to the exact published tag and verify the regression evidence. Main-branch reachability, a merged private fix, or a green build alone does not prove the shipped version is fixed. Check cherry-picked equivalents and reverted fixes explicitly. For embedded Console or dependency fixes, verify the shipped component version.
3. Determine the first available fixed release and affected version ranges from evidence. Preserve an earlier correct patched version; do not relabel all fixes as the current release. An unchanged server baseline such as a Console-only release requires checking the earlier server release as well. Leave unresolved reports and fixes absent from the tag pending; do not close them merely because a release shipped.
4. Prepare any necessary corrections to package identity, affected ranges, patched versions, fix/release links, and advisory text. Preserve unrelated fields, credit, CVE identifiers, and disclosure coordination. Apply updates only within existing user authorization; read back the result to verify it. Publishing a draft, requesting a CVE, or closing a report is a separate action: require explicit authorization for the concrete advisories and respect any embargo. Release-tag approval alone does not authorize those actions. Prepare the exact proposed changes before asking for missing authorization.
5. Record each relevant advisory's URL, current state, fix evidence, first fixed version, action taken or proposed, and remaining blocker in private task artifacts. Report advisory follow-up separately from artifact publication. A successful release with unresolved advisory work is not complete follow-up; retain that work for the release monitor or a concrete user decision.
## Version milestones
1. Query all pages of both open and closed milestones in the RustFS release repository, matching titles exactly to version strings without a `v` prefix. A failed or incomplete query is not evidence that a milestone is absent; resolve the lookup before making changes.
@@ -0,0 +1,4 @@
interface:
display_name: "RustFS Release Bump"
short_description: "Prepare RustFS release branches like PR #2957."
default_prompt: "Use $rustfs-release-version-bump to prepare and verify an exact RustFS release-version bump."
@@ -0,0 +1,4 @@
interface:
display_name: "Security Advisory Lessons"
short_description: "Apply advisory lessons in reviews."
default_prompt: "Use $security-advisory-lessons for a dedicated RustFS security review grounded in past advisories."
@@ -45,13 +45,11 @@ Update this file only when an advisory adds or changes a reusable lesson, affect
- `GHSA-6r96-hmgc-726c`: request headers collided with lowercase server-derived condition keys such as `userid`, `groups`, `versionid`, and JWT/LDAP claims. Lesson: never let caller-controlled headers append to or replace server-derived policy context; reserve trusted condition keys and keep intentional request-header keys separate.
- `GHSA-v9cp-qfw9-9pfp`: quantified negated string conditions applied negation after aggregation, transposing `ForAllValues` and `ForAnyValue` semantics. Lesson: push negation into the per-value predicate for quantified operators and test partially overlapping multi-value sets.
- `GHSA-5w8r-p896-6vq2`: OPA policy mode skipped `ExistingObjectTag/*` loading, so tagged objects looked untagged to external policies. Lesson: external authorization plugins need the same object-tag and request context as built-in policy evaluation before they decide.
- `GHSA-9wgh-5r68-49p7`: substituted policy-variable values could recreate `${...}` and loop inside a single resolution pass, never reaching the outer iteration limit. Lesson: bound substitutions within each pass and test cycles formed across otherwise literal resolver values; confirm whether attacker-controlled claims can reach those values before assigning remote impact.
### S3 object actions, copy, multipart, and upload policy validation
- `GHSA-g8w9-qw9q-fghr` and `GHSA-xm99-m3gq-83g8`: a valid presigned `PutObject` accepted extra `x-amz-*` headers omitted from `SignedHeaders`; `x-amz-copy-source` could turn that upload capability into a cross-bucket read performed as the signer. Lesson: a presigned URL is a bounded capability; inspect all received security-sensitive headers and reject unsigned ones before selecting the S3 operation or reaching storage.
- `GHSA-3ppv-fx5m-m749`: explicit `versionId` reads and copy sources authorized `s3:GetObject` instead of `s3:GetObjectVersion`. Lesson: version-specific object access must select version-specific actions for direct reads, `CopyObject`, and `UploadPartCopy`, with tests proving the backend is not reached on denial.
- `GHSA-cg33-pxfp-qcj3` and `GHSA-65wq-gjw8-xr9w`: `HeadObject` and object-tagging paths authorized current-object actions while operating on an explicit historical `versionId`. Lesson: select the corresponding version action for metadata and tag reads and writes too; audit every operation that accepts a version ID.
- `GHSA-gjph-xcgw-4p7w`: `PutObject` and browser POST accepted a caller-supplied `versionId` and replaced an unlocked historical version with only `s3:PutObject`. Lesson: only replication-authorized writes may choose an existing version ID; ordinary uploads must not rewrite version history.
- `GHSA-x298-9x87-fvjq`: anonymous `ListObjectVersions` fell back to `ListBucket` and returned before public-access-block gates. Lesson: compatibility fallbacks must converge on the same post-authorization checks as direct grants, especially `RestrictPublicBuckets` and anonymous data-plane denies.
- `GHSA-mx42-j6wv-px98`: `UploadPartCopy` missed source authorization and allowed cross-bucket object exfiltration. Lesson: multipart copy must enforce the same source and destination contract as `CopyObject`.
@@ -112,7 +110,6 @@ Update this file only when an advisory adds or changes a reusable lesson, affect
- `GHSA-j548-9grx-fh4f`: Object Lock enforcement treated unreadable, fabricated, or unparsable bucket metadata as absent configuration and allowed retained objects to be deleted or expired. Lesson: retention must fail closed unless Object Lock absence is authoritative, and every delete, lifecycle, scanner, force-delete, and default-retention path needs the same state distinction.
- `GHSA-28w4-rp2p-636w` and `GHSA-c7hx-vr97-45rg`: an authorized `DeleteBucket` with `x-rustfs-force-delete: true` removed active COMPLIANCE-retained versions even though ordinary version deletion was denied. Lesson: bucket force-delete must enforce retention across every version, including against root/owner callers.
- `GHSA-q23w-fp7p-472v`: `PutObjectRetention` policy conditions read `x-amz-object-lock-*` headers while the handler applied mode and date from the XML body. Lesson: authorize the parsed retention that will be stored, or reject mismatched header/body values; compare retention dates as instants so offsets cannot change the applied bound.
### Serde deserialization and input validation
@@ -146,13 +143,13 @@ rg -n "deny_unknown_fields|serde.default|as u32|as usize|as i32" rustfs crates
- IAM fixes: include import/update/list service-account cases with attacker-controlled parent, claims, access key, secret key, and policy.
- Copy/upload fixes: include cross-bucket, cross-user, source-denied, destination-denied, copy-source-condition, and multipart completion cases.
- Presigned upload fixes: include a valid presign with extra unsigned tagging, redirect, storage-class, and cross-bucket copy-source headers; require rejection before source or destination storage access, and verify explicitly signed equivalents still work.
- Version-action fixes: include historical UUID, explicit current version, `null`, range, partNumber, presigned, STS/session, service-account, anonymous bucket-policy, copy source, multipart-copy source, `HeadObject`, and tag read/write cases.
- Version-action fixes: include historical UUID, explicit current version, `null`, range, partNumber, presigned, STS/session, service-account, anonymous bucket-policy, copy source, and multipart-copy source cases.
- Version-write fixes: a `PutObject`-only principal must not replace an existing version through `PutObject` or browser POST `?versionId=`; verify replication-authorized writes and versioned, suspended, and unversioned buckets separately.
- Policy-condition fixes: include reserved-key header collisions, missing keys, partially overlapping multi-value sets, cyclic variable substitutions, plugin mode, and built-in policy mode.
- Policy-condition fixes: include reserved-key header collisions, missing keys, partially overlapping multi-value sets, plugin mode, and built-in policy mode.
- Path fixes: include encoded traversal, absolute path, nested traversal, archive entries with `..`, valid object keys that resemble traversal text but should be rejected, and canonical bucket/prefix boundary checks; test per-key `DeleteObjects` errors and prefix-scoped denials on versioned buckets.
- Logging fixes: assert redacted output for structs and response bodies that may contain credentials.
- IAM export fixes: assert exported archives omit plaintext user and service-account secrets unless the format deliberately encrypts or seals them.
- RPC auth fixes: include captured metadata replay across two concrete methods, stale timestamps, wrong path, wrong method surrogate, wrong secret, and valid same-method calls.
- Browser/CORS fixes: assert no credentials on reflected/default origins, correct behavior for explicit allowlists, and no same-origin script execution for previewed object content.
- SSE fixes: inspect stored bytes and verify API metadata, read-back behavior, and on-disk ciphertext together; for ETags, upload equal plaintext under different keys and assert list-only callers cannot derive or correlate the plaintext digest, including multipart objects.
- Object Lock fixes: include unreadable metadata, fabricated metadata defaults, unparsable config, confirmed absent config, COMPLIANCE/GOVERNANCE retention, lifecycle expiry, scanner sweeps, and force-delete paths; an authorized force-delete must preserve active COMPLIANCE versions. For `PutObjectRetention`, test conflicting header/body values and offset dates against policy bounds and read-back retention.
- Object Lock fixes: include unreadable metadata, fabricated metadata defaults, unparsable config, confirmed absent config, COMPLIANCE/GOVERNANCE retention, lifecycle expiry, scanner sweeps, and force-delete paths; an authorized force-delete must preserve active COMPLIANCE versions.
+5
View File
@@ -0,0 +1,5 @@
# Bound individual tool outputs retained in context; retrieve relevant ranges
# from task-owned log files when more evidence is needed.
# https://learn.chatgpt.com/docs/config-file/config-reference
tool_output_token_limit = 4000
+2 -2
View File
@@ -1,2 +1,2 @@
sha256-darwin=3a7f52dd884ed269746029e983006ce69b62f260db2ad1acdcffdb2196c65d11
sha256-linux=4af4bb607919b96e63e2a3fb7a78bd18fcb08e265f4ca4ce6afb09a8be798305
sha256-darwin=98dff0961c6eb21d6128bfd2d4a6100a624ddf1fb7db05dba360a5d3d0a310c2
sha256-linux=890f5925b87e1bf3710c356253f3b2276d901e156df9ccc04ec9315374de6d22
+2 -2
View File
@@ -1,2 +1,2 @@
sha256-darwin=347a0d4e5e547d5388703062ce66a66167a845b6e57ec591ccb4c732a567c68d
sha256-linux=4f9efa47194279185bb68249e82c788fa147abe944b6c764050e3d47f31e7926
sha256-darwin=efe68675f4676a0d2901242c5815b1f7af5cdec8231f7cccd408f6dacf5c75db
sha256-linux=003ed6480935a0a95680cc061be143722fbd37bd477b8ff98607b1a064f3aef1
+1 -1
View File
@@ -1 +1 @@
sha256=ce16185f87c6cd30aa0891ebd22be3352aedc10040d48cc5b00652d72f7be3b5
sha256=ba8f392b617cf9f2338142233a417e43927b28096635e628233ce8a60cf49d10
-3
View File
@@ -52,9 +52,6 @@ script-tests: ## Run shell script tests
./scripts/check_embedded_secrets.sh --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/test_e2e_binary.py
$(RUSTFS_PYTHON_BIN) ./scripts/test_release_build_workflow.py
$(RUSTFS_PYTHON_BIN) ./scripts/test_release_glibc.py
$(RUSTFS_PYTHON_BIN) ./scripts/test_upgrade_workflow.py
$(RUSTFS_PYTHON_BIN) ./scripts/test_migration_gate_evidence.py
$(RUSTFS_PYTHON_BIN) ./scripts/ci_gate.py --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/check_security_coverage.py --self-test
-28
View File
@@ -76,13 +76,6 @@ threads-required = "num-test-threads"
filter = 'package(rustfs-ecstore) & (test(/^set_disk::tests::streaming_get_blocks_concurrent_/) | test(=store::object::tests::retired_marker_converges_after_bucket_recreation_on_sixteen_disks))'
threads-required = "num-test-threads"
# These real-disk lock probes must finish durable IO within production's 5s
# acquisition budget. Reserve capacity from sibling test processes while
# preserving the probes' internal concurrency, deadlines, and assertions.
[[profile.default.overrides]]
filter = '(package(rustfs-ecstore) & test(=core::pools_test::decommission_lock_order_tests::active_decommission_migration_does_not_invert_capacity_and_delete_object_tag_locks)) | (package(rustfs-heal) & test(=heal::mrf_queue::partial_write::tests::durable_retry_submission_error_keeps_checkpoint_and_uses_backoff))'
threads-required = "num-test-threads"
[[profile.default.overrides]]
filter = 'package(rustfs-heal) & (binary(mrf_partial_write_test) | (binary(mrf_pipeline_test) & test(/^journal_replay_survives_/)) | test(=admin_selector_survives_sigkill_with_original_scope_and_receipts))'
threads-required = "num-test-threads"
@@ -95,14 +88,6 @@ threads-required = "num-test-threads"
filter = 'package(rustfs-ecstore) & test(/^(bucket::lifecycle::bucket_lifecycle_ops::tests::manual_transition_worker_result_recovery_marks_unknown_for_corrupt_marker|services::rebalance::entry::tests::real_rebalance_run_fence_loss_blocks_multipart_publication|store::init::tests::(batch_transitioned_delete_uses_free_version_per_item|decommission_entry_(allows_free_version_consumed_before_source_lock|rejects_subquorum_free_version_conflict_and_retains_source|skips_cleanup_only_marker_when_free_version_is_present)|dispatched_tier_delete_recovery_(checks_later_pool_then_commits_after_source_removal|finds_directory_source_on_encoded_set|retains_journal_on_source_metadata_error)|force_tier_remove_blocks_on_physical_free_version_hidden_by_other_pool|legacy_unknown_transition_delete_falls_back_for_single_batch_and_blocks_prefix|multi_pool_(recursive_prefix_rejects_legacy_or_hidden_merge_loser_before_delete|same_remote_tuple_(batch|single)_delete_waits_for_all_sources|same_tuple_recursive_prefix_uses_one_journal_owner|transitioned_delete_persists_one_free_version_per_remote_tuple)|recursive_prefix_partial_(pool|set)_failure_keeps_prepared_cleanup_owners|restored_transitioned_delete_uses_free_version_as_cleanup_owner|stable_transitioned_recursive_prefix_delete_uses_journal_owners|suspended_null_transition_delete_uses_free_version_as_sole_owner|tier_mutation_peer_handler_applies_prepare_commit_and_abort_idempotently|transition_response_loss_persists_unknown_outcome_for_provider_recovery|transition_transaction_recovery_(drops_record_after_confirmed_local_commit|keeps_cleanup_pending_local_commit)|transitioned_delete_(free_version_replays_after_store_restart|local_quorum_failure_rolls_back_without_cleanup_owner|uses_free_version_as_cleanup_owner)|versioned_delete_marker_keeps_transitioned_source_and_remote_object|versioned_explicit_transition_delete_preserves_other_version_then_allows_bucket_delete))$/)'
setup = 'ecstore-large-stack'
# These cross-crate real-storage fixtures need the same bounded stack as ECStore tests.
# The admin target-repair fixtures compose the full bucket delete and recreate
# path on a real store; measured with RUST_MIN_STACK on 2026-10-15 they need
# 2016-2040 KiB of the 2 MiB libtest default and overflowed on Linux CI.
[[profile.default.scripts]]
filter = '(package(rustfs) & test(/^(admin::handlers::replication::target_repair_tests::[a-z_]+|admin::handlers::site_replication::tests::durable_iam_state_apply_iam_item_rejects_a_stale_recreate_after_a_replicated_delete|admin::service::federated_identity::tests::(binding_keeps_explicit_iam_mapping_strict|markerless_legacy_oidc_session_crosses_auth_and_iam_deletion_boundaries)|storage::scanner_dirty_journal::tests::(clear_callback_keeps_unknown_coverage_until_all_pending_work_is_confirmed|owned_journals_keep_peer_records_after_interleaved_writes_and_clear))$/)) | (package(rustfs-heal) & ((binary(heal_b5_versioned_regression_test) & test(/^serial_tests::(test_heal_unversioned_bucket_e2e|test_historical_null_recursive_heal_restores_physical_versions|test_recursive_deep_heal_reports_stale_delete_marker_repair)$/)) | (binary(usage_observation_read_repair_test) & test(=usage_observation_read_repair_survives_snapshot_cleanup)))) | (package(rustfs-test-utils) & test(=data_usage_snapshot_tests::data_usage_snapshot_recovery_wires_primary_backup_and_negative_cache))'
setup = 'ecstore-base-stack'
[[profile.default.scripts]]
filter = 'package(rustfs-ecstore) | package(rustfs-s3select-api) | package(rustfs-scanner) | (package(rustfs) & test(/^(app::multipart_usecase::tests::concurrent_completions_share_durable_bucket_quota_reservations|app::object::delete::tests::compressed_delete_requests_update_observed_usage_without_releasing_quota_floor|app::object::internal_put::tests::internal_multipart_roundtrip_completes_and_abort_leaves_nothing|app::object::restore::tests::execute_restore_object_maps_failures_to_typed_s3_errors|storage::access::tests::(delete_object_access_captures_authorized_bucket_incarnation|copy_operations_reject_recreated_source_bucket_after_authorization|request_slot_keeps_bucket_policy_bound_to_its_store))$/))'
setup = 'ecstore-base-stack'
@@ -342,13 +327,6 @@ threads-required = "num-test-threads"
filter = 'package(rustfs-ecstore) & (test(/^set_disk::tests::streaming_get_blocks_concurrent_/) | test(=store::object::tests::retired_marker_converges_after_bucket_recreation_on_sixteen_disks))'
threads-required = "num-test-threads"
# These real-disk lock probes must finish durable IO within production's 5s
# acquisition budget. Reserve capacity from sibling test processes while
# preserving the probes' internal concurrency, deadlines, and assertions.
[[profile.ci.overrides]]
filter = '(package(rustfs-ecstore) & test(=core::pools_test::decommission_lock_order_tests::active_decommission_migration_does_not_invert_capacity_and_delete_object_tag_locks)) | (package(rustfs-heal) & test(=heal::mrf_queue::partial_write::tests::durable_retry_submission_error_keeps_checkpoint_and_uses_backoff))'
threads-required = "num-test-threads"
[[profile.ci.overrides]]
filter = 'package(rustfs-heal) & (binary(mrf_partial_write_test) | (binary(mrf_pipeline_test) & test(/^journal_replay_survives_/)) | test(=admin_selector_survives_sigkill_with_original_scope_and_receipts))'
threads-required = "num-test-threads"
@@ -361,11 +339,6 @@ threads-required = "num-test-threads"
filter = 'package(rustfs-ecstore) & test(/^(bucket::lifecycle::bucket_lifecycle_ops::tests::manual_transition_worker_result_recovery_marks_unknown_for_corrupt_marker|services::rebalance::entry::tests::real_rebalance_run_fence_loss_blocks_multipart_publication|store::init::tests::(batch_transitioned_delete_uses_free_version_per_item|decommission_entry_(allows_free_version_consumed_before_source_lock|rejects_subquorum_free_version_conflict_and_retains_source|skips_cleanup_only_marker_when_free_version_is_present)|dispatched_tier_delete_recovery_(checks_later_pool_then_commits_after_source_removal|finds_directory_source_on_encoded_set|retains_journal_on_source_metadata_error)|force_tier_remove_blocks_on_physical_free_version_hidden_by_other_pool|legacy_unknown_transition_delete_falls_back_for_single_batch_and_blocks_prefix|multi_pool_(recursive_prefix_rejects_legacy_or_hidden_merge_loser_before_delete|same_remote_tuple_(batch|single)_delete_waits_for_all_sources|same_tuple_recursive_prefix_uses_one_journal_owner|transitioned_delete_persists_one_free_version_per_remote_tuple)|recursive_prefix_partial_(pool|set)_failure_keeps_prepared_cleanup_owners|restored_transitioned_delete_uses_free_version_as_cleanup_owner|stable_transitioned_recursive_prefix_delete_uses_journal_owners|suspended_null_transition_delete_uses_free_version_as_sole_owner|tier_mutation_peer_handler_applies_prepare_commit_and_abort_idempotently|transition_response_loss_persists_unknown_outcome_for_provider_recovery|transition_transaction_recovery_(drops_record_after_confirmed_local_commit|keeps_cleanup_pending_local_commit)|transitioned_delete_(free_version_replays_after_store_restart|local_quorum_failure_rolls_back_without_cleanup_owner|uses_free_version_as_cleanup_owner)|versioned_delete_marker_keeps_transitioned_source_and_remote_object|versioned_explicit_transition_delete_preserves_other_version_then_allows_bucket_delete))$/)'
setup = 'ecstore-large-stack'
# These cross-crate real-storage fixtures need the same bounded stack as ECStore tests.
[[profile.ci.scripts]]
filter = '(package(rustfs) & test(/^(admin::handlers::replication::target_repair_tests::[a-z_]+|admin::handlers::site_replication::tests::durable_iam_state_apply_iam_item_rejects_a_stale_recreate_after_a_replicated_delete|admin::service::federated_identity::tests::(binding_keeps_explicit_iam_mapping_strict|markerless_legacy_oidc_session_crosses_auth_and_iam_deletion_boundaries)|storage::scanner_dirty_journal::tests::(clear_callback_keeps_unknown_coverage_until_all_pending_work_is_confirmed|owned_journals_keep_peer_records_after_interleaved_writes_and_clear))$/)) | (package(rustfs-heal) & ((binary(heal_b5_versioned_regression_test) & test(/^serial_tests::(test_heal_unversioned_bucket_e2e|test_historical_null_recursive_heal_restores_physical_versions|test_recursive_deep_heal_reports_stale_delete_marker_repair)$/)) | (binary(usage_observation_read_repair_test) & test(=usage_observation_read_repair_survives_snapshot_cleanup)))) | (package(rustfs-test-utils) & test(=data_usage_snapshot_tests::data_usage_snapshot_recovery_wires_primary_backup_and_negative_cache))'
setup = 'ecstore-base-stack'
[[profile.ci.scripts]]
filter = 'package(rustfs-ecstore) | package(rustfs-s3select-api) | package(rustfs-scanner) | (package(rustfs) & test(/^(app::multipart_usecase::tests::concurrent_completions_share_durable_bucket_quota_reservations|app::object::delete::tests::compressed_delete_requests_update_observed_usage_without_releasing_quota_floor|app::object::internal_put::tests::internal_multipart_roundtrip_completes_and_abort_leaves_nothing|app::object::restore::tests::execute_restore_object_maps_failures_to_typed_s3_errors|storage::access::tests::(delete_object_access_captures_authorized_bucket_incarnation|copy_operations_reject_recreated_source_bucket_after_authorization|request_slot_keeps_bucket_policy_bound_to_its_store))$/))'
setup = 'ecstore-base-stack'
@@ -622,7 +595,6 @@ default-filter = """
test(/^(delete_marker_migration_semantics|version_id_regression|list_objects_v2_pagination|list_object_versions_regression|list_objects_duplicates|list_buckets_double_slash|list_buckets_auth|list_buckets_iam_filter|leading_slash_key|special_chars|create_bucket_region|delete_objects_versioning|head_object_consistency|head_object_range|copy_object_metadata|copy_object_tagging|copy_source_invalid_date|content_encoding|compression|multipart_storage_class|storage_class_capability|ssec_copy|anonymous_access|bucket_policy_check|presigned_negative|negative_sigv4|admin_auth|notification_webhook|tls_hot_reload|console_smoke|admin_iam_crud|admin_pools|sts_query_compat|data_usage)_test::|^fake_s3_target::/)
| test(/^replication_extension_test::(test_replication_check_succeeds_with_remote_target|test_replication_check_rejects_target_without_object_lock|test_set_remote_target_rejects_unversioned_source_bucket|test_replication_check_rejects_unversioned_source_bucket|test_replication_check_rejects_missing_replication_config|test_replication_check_rejects_invalid_bucket|test_set_remote_target_rejects_same_bucket_on_same_deployment|test_set_remote_target_rejects_unversioned_target_bucket|test_set_remote_target_update_requires_arn|test_set_remote_target_update_rejects_missing_target|test_set_remote_target_rejects_invalid_target_url|test_set_remote_target_rejects_self_signed_https_target_without_skip_tls_verify|test_set_remote_target_rejects_private_ca_https_target_without_ca_cert_pem|test_list_remote_targets_rejects_empty_bucket|test_list_remote_targets_rejects_invalid_bucket|test_remove_remote_target_rejects_missing_target|test_remove_remote_target_rejects_missing_arn|test_remove_remote_target_rejects_invalid_bucket|test_remove_remote_target_rejects_target_used_by_replication|test_delete_bucket_replication_removes_remote_target)$/)
| test(/^reliant::lifecycle::/)
| test(/^conditional_delete_objects_test::/)
| test(/^reliant::tiering::/)
| test(/^upgrade_compatibility_test::upgrade_write_readiness_tests::/)
| test(/^on_demand_migration::(get_basic_test::(get_miss_pulls_inline_and_serves_locally_afterwards|head_miss_answers_from_the_source_without_persisting)|interaction_test::test_odm_admin_config_is_redacted_and_status_counts_match_the_source)$/)
@@ -1748,7 +1748,7 @@
"uid": "${datasource}"
},
"editorMode": "code",
"expr": "max by (rustfs_cluster_id, job, bucket) (rustfs_cluster_usage_buckets_objects_count{rustfs_cluster_id=\"$storage_cluster\", job=~\"$job\", bucket=~\"$bucket\"})",
"expr": "max by (job, bucket) (rustfs_cluster_usage_buckets_objects_count{job=~\"$job\", bucket=~\"$bucket\"})",
"legendFormat": "{{bucket}}",
"range": true,
"refId": "A"
@@ -1848,7 +1848,7 @@
"uid": "${datasource}"
},
"editorMode": "code",
"expr": "max by (rustfs_cluster_id, job, bucket) (rustfs_cluster_usage_buckets_total_bytes{rustfs_cluster_id=\"$storage_cluster\", job=~\"$job\", bucket=~\"$bucket\"})",
"expr": "max by (job, bucket) (rustfs_cluster_usage_buckets_total_bytes{job=~\"$job\", bucket=~\"$bucket\"})",
"legendFormat": "{{bucket}}",
"range": true,
"refId": "A"
-1
View File
@@ -61,7 +61,6 @@ scrape_configs:
otlp:
promote_resource_attributes:
- service.instance.id
- process.pid
- service.name
- service.namespace
- cloud.availability_zone
@@ -1,48 +0,0 @@
evaluation_interval: 1m
tests:
- name: bucket usage keeps clusters separate and deduplicates reporting nodes
interval: 1m
input_series:
- series: rustfs_cluster_usage_buckets_total_bytes{rustfs_cluster_id="a",job="rustfs",bucket="shared",service_instance_id="a1"}
values: '4096 1024'
- series: rustfs_cluster_usage_buckets_total_bytes{rustfs_cluster_id="a",job="rustfs",bucket="shared",service_instance_id="a2"}
values: '4096 1024'
- series: rustfs_cluster_usage_buckets_total_bytes{rustfs_cluster_id="b",job="rustfs",bucket="shared",service_instance_id="b1"}
values: '65536 65536'
- series: rustfs_cluster_usage_buckets_total_bytes{rustfs_cluster_id="a",job="rustfs",bucket="empty",service_instance_id="a1"}
values: '0 0'
- series: rustfs_cluster_usage_buckets_objects_count{rustfs_cluster_id="a",job="rustfs",bucket="shared",service_instance_id="a1"}
values: '3 1'
- series: rustfs_cluster_usage_buckets_objects_count{rustfs_cluster_id="a",job="rustfs",bucket="shared",service_instance_id="a2"}
values: '3 1'
- series: rustfs_cluster_usage_buckets_objects_count{rustfs_cluster_id="b",job="rustfs",bucket="shared",service_instance_id="b1"}
values: '70 70'
- series: rustfs_cluster_usage_buckets_objects_count{rustfs_cluster_id="a",job="rustfs",bucket="empty",service_instance_id="a1"}
values: '0 0'
promql_expr_test:
- expr: max by (rustfs_cluster_id, job, bucket) (rustfs_cluster_usage_buckets_total_bytes{rustfs_cluster_id="a",job=~"rustfs",bucket=~".*"})
eval_time: 1m
exp_samples:
- labels: '{rustfs_cluster_id="a",job="rustfs",bucket="shared"}'
value: 1024
- labels: '{rustfs_cluster_id="a",job="rustfs",bucket="empty"}'
value: 0
- expr: max by (rustfs_cluster_id, job, bucket) (rustfs_cluster_usage_buckets_objects_count{rustfs_cluster_id="a",job=~"rustfs",bucket=~".*"})
eval_time: 1m
exp_samples:
- labels: '{rustfs_cluster_id="a",job="rustfs",bucket="shared"}'
value: 1
- labels: '{rustfs_cluster_id="a",job="rustfs",bucket="empty"}'
value: 0
- expr: max by (rustfs_cluster_id, job, bucket) (rustfs_cluster_usage_buckets_total_bytes)
eval_time: 1m
exp_samples:
- labels: '{rustfs_cluster_id="a",job="rustfs",bucket="shared"}'
value: 1024
- labels: '{rustfs_cluster_id="a",job="rustfs",bucket="empty"}'
value: 0
- labels: '{rustfs_cluster_id="b",job="rustfs",bucket="shared"}'
value: 65536
- expr: rustfs_cluster_usage_buckets_total_bytes{rustfs_cluster_id="a",bucket="unknown"}
eval_time: 1m
exp_samples: []
-5
View File
@@ -23,11 +23,6 @@ duplicate its rules here.
pass by fixing the cause, not by weakening the gate).
- If `shellcheck` is installed, actionlint also lints `run:` scripts; treat
those findings as part of the gate.
- Runner placement and billing rules live in `.github/workflows/README.md`.
A new job defaults to `ubuntu-latest` (free for this public repository);
a self-hosted `runs-on` needs one of the reasons listed there, stated in a
comment next to it, and a `rust-cache` key is never shared between the
hosted and self-hosted images.
## CI Alignment
@@ -1,39 +0,0 @@
# Copyright 2024 RustFS Team
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
name: Set up Docker Compose
description: Install the verified Compose CLI used by native Linux x86_64 Connect acceptance
runs:
using: composite
steps:
- name: Install verified Docker Compose CLI plugin
shell: bash
run: |
set -euo pipefail
[[ $(uname -s) == Linux && $(uname -m) == x86_64 ]]
plugin_dir="${DOCKER_CONFIG:-$HOME/.docker}/cli-plugins"
mkdir -p "$plugin_dir"
compose_download=$(mktemp "${RUNNER_TEMP:?}/connect-compose.XXXXXX")
compose_staged=$(mktemp "$plugin_dir/.docker-compose.XXXXXX")
trap 'rm -f "$compose_download" "$compose_staged"' EXIT
curl --fail --location --retry 3 \
https://github.com/docker/compose/releases/download/v2.40.3/docker-compose-linux-x86_64 \
--output "$compose_download"
printf '%s %s\n' \
dba9d98e1ba5bfe11d88c99b9bd32fc4a0624a30fafe68eea34d61a3e42fd372 \
"$compose_download" | sha256sum --check --strict
install -m 0755 "$compose_download" "$compose_staged"
mv -f "$compose_staged" "$plugin_dir/docker-compose"
[[ $(docker compose version --short) == 2.40.3 ]]
+1 -15
View File
@@ -36,16 +36,6 @@ inputs:
description: "Install cross-compilation tools"
required: false
default: "false"
zig-version:
description: >-
Zig version for cargo-zigbuild builds. Pinned so the `<target>.<glibc>`
baseline semantics cannot drift with an untested zig release.
required: false
default: "0.17.0"
cargo-zigbuild-version:
description: "cargo-zigbuild version installed for cross builds."
required: false
default: "0.23.4"
target:
description: "Target architecture to add"
required: false
@@ -109,14 +99,10 @@ runs:
- name: Install Zig
if: inputs.install-cross-tools == 'true'
uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2
with:
version: ${{ inputs.zig-version }}
- name: Install cargo-zigbuild
if: inputs.install-cross-tools == 'true'
uses: taiki-e/install-action@a21ae4029b089b9ddc45704028756f51ab8abe48 # cargo-zigbuild
with:
tool: cargo-zigbuild@${{ inputs.cargo-zigbuild-version }}
- name: Install cargo-nextest
if: inputs.install-test-tools == 'true'
@@ -140,5 +126,5 @@ runs:
# normalises, so cargo fingerprints stay valid.
cache-all-crates: false
cache-on-failure: true
shared-key: ${{ inputs.cache-shared-key }}${{ inputs.install-cross-tools == 'true' && format('-zig-{0}-zigbuild-{1}', inputs.zig-version, inputs.cargo-zigbuild-version) || '' }}
shared-key: ${{ inputs.cache-shared-key }}
save-if: ${{ inputs.cache-save-if }}
+1 -1
View File
@@ -1,6 +1,6 @@
[
{ "workflow": ".github/workflows/audit.yml", "max_age_hours": 36 },
{ "workflow": ".github/workflows/build.yml", "max_age_hours": 36 },
{ "workflow": ".github/workflows/build.yml", "max_age_hours": 192 },
{ "workflow": ".github/workflows/ci.yml", "max_age_hours": 192 },
{ "workflow": ".github/workflows/coverage.yml", "max_age_hours": 192 },
{ "workflow": ".github/workflows/e2e-replication-nightly.yml", "max_age_hours": 36 },
-74
View File
@@ -1,74 +0,0 @@
# Workflow runners: what is billed and where a job belongs
**Use this when:** adding a job, changing a `runs-on`, moving a lane between runner pools, or deciding whether a new check is worth its runner cost.
**Source of truth:** `runs-on` in each workflow file for current placement; `.github/actionlint.yaml` for the declared self-hosted labels; the organisation's Settings → Billing → Usage page for what is actually charged; `docs/testing/ci-gates.md` for which checks block a merge (that does not change when a runner changes).
## Billing facts
This repository is public. For a public repository, GitHub's standard hosted runners (`ubuntu-latest`, `windows-latest`, `macos-latest`) cost nothing and have no minute cap, whatever plan the organisation is on. GitHub bills only two things: larger hosted runners, which have custom labels and are not used here, and standard runners in private repositories.
Every other label in this repository is capacity RustFS pays for by the hour whether or not a job is running well:
| Label | Paid by | What it is | Use it for |
|---|---|---|---|
| `ubuntu-latest` | nobody | GitHub-hosted, Ubuntu 24.04, 4 vCPU, 16 GB RAM, about 14 GB of free disk. Ships a C toolchain, `docker`, `gh`, `curl`, `wget`, `jq`, Python. Can `mount` tmpfs and loop devices; io_uring works | the default for every job |
| `windows-latest`, `macos-latest` | nobody | GitHub-hosted standard runners | Windows and macOS targets |
| `sm-standard-2` | RustFS | ARC pod, Ubuntu 24.04, minimal image: no `gh`, no C toolchain; Docker only on the `dind-` label | only a job that a hosted runner cannot run (see the placement rule) |
| `sm-standard-4` | RustFS | ARC pod, Ubuntu 24.04, 14 CPUs and 28 GB (the label is not the core count), large disk | workspace-scale Cargo builds |
| `sm-standard-4-arm` | RustFS | ARC pod, aarch64; reached through the `build.yml` matrix, so `actionlint.yaml` does not list it | native aarch64 release builds |
| `dind-sm-standard-2` | RustFS | ARC pod with Docker-in-Docker | image builds that need `buildx` and a registry push |
| `smoke-testing`, `pf-testing` | RustFS | long-lived lab machines | the packaged functional chain and performance suites |
The self-hosted pods and the hosted image are both Ubuntu 24.04, so a binary built on one runs on the other; `ci.yml` builds the debug binary on `sm-standard-4` and runs it on both pools.
## Placement rule
A job runs on `ubuntu-latest` unless it has one of these reasons, and the reason goes in a comment next to its `runs-on`:
1. **Disk.** It restores or produces the workspace `target/`: `cargo build` or `cargo test` with `--all-targets`, `cargo nextest run --all`, or any reader of the `ci-dev` / `ci-feat-*` caches. The workspace test build leaves about 64 GB in `target/`; a hosted runner has about 14 GB.
2. **Critical-path CPU.** It is a build that several downstream lanes wait on and would blow its timeout at 4 vCPU (`build-rustfs-debug-binary` gates the e2e and s3-tests lanes).
3. **Hardware.** Native aarch64, Docker-in-Docker, or the lab machines.
4. **Cache ownership.** It reads a `rust-cache` key that a self-hosted job writes (see the cache rule below).
Jobs that do only the following have no reason to be self-hosted: shell or Python scripts, `cargo fmt`, `cargo deny`, typos, actionlint, API calls (`gh`, dependency review, issue and stale bots, CLA checks), downloading an artifact and running a prebuilt binary, or a `cargo build -p rustfs` with its own cache key. `e2e-distributed.yml` and `e2e-upgrade.yml` build the server and the `e2e_test` harness on `ubuntu-latest` inside their budgets, so "it compiles something" alone is not a reason.
A hosted runner is also the only choice for a few things the pods cannot do: `mount -o loop` and `mount -t tmpfs` fail inside the ARC pods, and a container runtime cannot guarantee that `io_uring_setup` is allowed. `uring-integration` and `e2e-distributed` are on `ubuntu-latest` for that reason, not for cost.
## Cache rule
`Swatinem/rust-cache` builds its key from the OS, architecture, toolchain and lockfile. It does not include the runner label or image, so a cache written on an `sm-standard-4` pod and one written on `ubuntu-latest` would overwrite each other under the same `shared-key`.
- Every `shared-key` has exactly one writer. `cache-warm.yml` owns `ci-dev`, `ci-feat-rio`, `ci-feat-proto` and `ci-uring`; every other `./.github/actions/setup` call states `cache-save-if` explicitly, and `scripts/security/check_cache_save_if.sh` fails the build when one does not.
- All readers of a key run on the same image as its writer. Keys that live on hosted runners say so in their name (`ci-uring` is warmed on `ubuntu-latest`; `ci-e2e-distributed-hosted`, `e2e-upgrade-server-hosted`, `e2e-upgrade-tests-hosted`).
- Moving a job between pools therefore means renaming its key. The new key is empty until a run from `main` (a scheduled run, or a manual dispatch of that workflow from `main`) saves it; until then that lane does a cold build.
- `CARGO_BUILD_JOBS` and every other `CARGO*`, `CC*`, `CFLAGS*`, `CXX*`, `CMAKE*` and `RUST*` variable is hashed into the key. `ci.yml` sets `CARGO_BUILD_JOBS` at step level for that reason; promoting it to job level rotates the key for every lane that shares it.
## Where self-hosted minutes leak
These are the patterns that have cost real money here without validating anything more:
- An `if: always()` summary or alert job on a self-hosted label inside a `workflow_run`-triggered workflow. The upstream workflow completes many times a day, the real jobs skip, and the summary still starts a pod each time. Gate it on the upstream job having run, and keep it on `ubuntu-latest`.
- Per-push workflows on `main`. A merge triggers the full `ci.yml` matrix, `cache-warm.yml` and the `build.yml` development build; with tens of merges a day that is as much self-hosted time as all pull requests together. Anything that does not verify the merged tree itself belongs on a schedule or behind a `paths` filter.
- `apt-get install` of tools the hosted image already ships. It is only needed on the minimal pod images.
- A throttled `CARGO_BUILD_JOBS` on a 14-CPU pod. The limit exists for link-storm reasons documented in `ci.yml`; measure before raising it, and measure before lowering it further.
- Tiny jobs on ARC pods. Each one pays roughly a minute of pod start-up before its first step; a compile-free job that takes ten seconds costs more to schedule than to run.
## Measuring a run
To see which pool each job of a run used and for how long:
```bash
gh api "repos/rustfs/rustfs/actions/runs/<run_id>/jobs?per_page=100" \
--jq '.jobs[] | "\(.conclusion)\t\(.labels | join(","))\t\(.started_at)\t\(.completed_at)\t\(.name)"'
```
Only rows whose label starts with `sm-`, `dind-`, `smoke-` or `pf-` cost anything. At the October 2026 review, a full pull-request run of `ci.yml` spent about 150 self-hosted minutes, a third of it in jobs that never compiled; treat that as the scale of what a placement mistake costs, not as a current figure.
## Checklist for a new or moved job
- `runs-on` is a literal label. A new self-hosted label is declared in `.github/actionlint.yaml` in the same change (`.github/AGENTS.md`).
- The job declares `timeout-minutes` (`scripts/security/check_job_timeouts.sh`).
- `actionlint` reports nothing.
- A self-hosted `runs-on` carries a comment naming its reason from the placement rule.
- Every `./.github/actions/setup` call states `cache-save-if`, and a key read from one pool is not written from the other.
- Moving a job does not change its check name, so the `main` ruleset and `docs/testing/ci-gates.md` stay as they are; renaming it does, and then that document's change checklist applies.
+4 -4
View File
@@ -77,7 +77,7 @@ jobs:
cancel-closed-pr-runs:
name: Cancel Closed PR Runs
if: github.event_name == 'pull_request' && github.event.action == 'closed'
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 10
steps:
- name: Explain cancellation run
@@ -89,7 +89,7 @@ jobs:
cargo-deny:
name: Cargo Deny
if: github.repository == 'rustfs/rustfs' && (github.event_name != 'pull_request' || github.event.action != 'closed')
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 20
steps:
- name: Checkout repository
@@ -132,7 +132,7 @@ jobs:
workflow-pin-report:
name: Workflow Pin Report
if: github.repository == 'rustfs/rustfs' && (github.event_name != 'pull_request' || github.event.action != 'closed')
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 5
steps:
- name: Checkout repository
@@ -169,7 +169,7 @@ jobs:
dependency-review:
name: Dependency Review
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 30
if: github.event_name == 'pull_request' && github.event.action != 'closed'
permissions:
+81 -68
View File
@@ -22,12 +22,9 @@
# 3. Trigger docker.yml to build and push images using the uploaded binaries
#
# Platform scope:
# - Development builds come from the nightly schedule and manual dispatch and
# cover the full platform matrix; they feed the dev download channel
# (artifacts/rustfs/dev on R2, the *-main-latest packages). Tags build the
# release matrix. Main pushes do not build: at the usual merge rate most
# per-merge builds were cancelled by the next merge before publishing
# anything, and the dev channel does not need per-merge granularity.
# - Pushes to main (development builds) build the Linux targets only — they
# feed the dev download channel and Docker dev images. Tags, the weekly
# schedule and manual dispatch build the full platform matrix.
#
# Manual Parameters:
# - publish: Publish binaries/releases for manual runs (default: false)
@@ -39,8 +36,24 @@ name: Build and Release
on:
push:
tags: [ "*.*.*" ]
branches: [ main ]
paths-ignore:
- "**.md"
- "**.txt"
- ".github/**"
- "docs/**"
- "deploy/**"
- "scripts/dev_*.sh"
- "LICENSE*"
- "README*"
- "**/*.png"
- "**/*.jpg"
- "**/*.svg"
- ".gitignore"
- ".dockerignore"
- "flake.lock"
schedule:
- cron: "13 1 * * *" # Nightly 01:13 UTC
- cron: "13 1 * * 0" # Weekly on Sunday 01:13 UTC
workflow_dispatch:
inputs:
publish:
@@ -80,7 +93,7 @@ jobs:
# Build strategy check - determine build type based on trigger
build-check:
name: Build Strategy Check
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 30
outputs:
should_build: ${{ steps.check.outputs.should_build }}
@@ -131,6 +144,12 @@ jobs:
build_type="release"
echo "📦 Release build detected: $tag_name"
fi
elif [[ "${{ github.ref }}" == "refs/heads/main" ]]; then
# Main branch push - development build
should_build=true
build_type="development"
version="dev-${short_sha}"
echo "🛠️ Development build detected"
elif [[ "${{ github.event_name }}" == "schedule" ]] || \
[[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
# Scheduled or manual build
@@ -159,7 +178,7 @@ jobs:
prepare-platform-matrix:
name: Prepare Platform Matrix
needs: build-check
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 30
outputs:
matrix: ${{ steps.select.outputs.matrix }}
@@ -181,14 +200,25 @@ jobs:
selected="all"
fi
# Per-merge development builds only feed the dev download channel
# and the Docker dev images, which consume the Linux binaries; at
# the usual merge cadence most per-merge builds are cancelled by the
# next merge anyway. macOS and Windows stay covered by tag builds,
# the weekly scheduled build and manual dispatch.
if [[ "${selected}" == "all" \
&& "${{ github.event_name }}" == "push" \
&& "${{ needs.build-check.outputs.build_type }}" == "development" ]]; then
selected="linux-x86_64-musl,linux-aarch64-musl,linux-x86_64-gnu,linux-aarch64-gnu"
echo "Development (main push) build: restricting to Linux targets"
fi
all='{"include":[
{"target_id":"linux-x86_64-musl","os":"sm-standard-4","target":"x86_64-unknown-linux-musl","cross":false,"platform":"linux","rustflags":"","features":"","glibc":""},
{"target_id":"linux-aarch64-musl","os":"sm-standard-4-arm","target":"aarch64-unknown-linux-musl","cross":false,"platform":"linux","rustflags":"","features":"","glibc":""},
{"target_id":"linux-x86_64-gnu","os":"sm-standard-4","target":"x86_64-unknown-linux-gnu","cross":true,"platform":"linux","rustflags":"","features":"pyroscope","glibc":"2.31"},
{"target_id":"linux-aarch64-gnu","os":"sm-standard-4-arm","target":"aarch64-unknown-linux-gnu","cross":true,"platform":"linux","rustflags":"","features":"pyroscope","glibc":"2.31"},
{"target_id":"macos-aarch64","os":"macos-latest","target":"aarch64-apple-darwin","cross":false,"platform":"macos","rustflags":"","features":"pyroscope","glibc":""},
{"target_id":"windows-x86_64","os":"windows-latest","target":"x86_64-pc-windows-msvc","cross":false,"platform":"windows","rustflags":"","features":"","glibc":""}
{"target_id":"linux-x86_64-musl","os":"sm-standard-4","target":"x86_64-unknown-linux-musl","cross":false,"platform":"linux","rustflags":"","features":""},
{"target_id":"linux-aarch64-musl","os":"sm-standard-4-arm","target":"aarch64-unknown-linux-musl","cross":false,"platform":"linux","rustflags":"","features":""},
{"target_id":"linux-x86_64-gnu","os":"sm-standard-4","target":"x86_64-unknown-linux-gnu","cross":false,"platform":"linux","rustflags":"","features":"pyroscope"},
{"target_id":"linux-aarch64-gnu","os":"sm-standard-4-arm","target":"aarch64-unknown-linux-gnu","cross":false,"platform":"linux","rustflags":"","features":"pyroscope"},
{"target_id":"macos-aarch64","os":"macos-latest","target":"aarch64-apple-darwin","cross":false,"platform":"macos","rustflags":"","features":"pyroscope"},
{"target_id":"windows-x86_64","os":"windows-latest","target":"x86_64-pc-windows-msvc","cross":false,"platform":"windows","rustflags":"","features":""}
]}'
if [[ "${selected}" == "all" ]]; then
@@ -216,8 +246,7 @@ jobs:
echo "Selected platforms: ${selected}"
build-rustfs:
# Acceptance workflows select artifacts by this stable platform name.
name: Build RustFS (${{ matrix.target_id }})
name: Build RustFS
needs: [ build-check, prepare-platform-matrix ]
if: needs.build-check.outputs.should_build == 'true' && needs.prepare-platform-matrix.result == 'success'
runs-on: ${{ matrix.os }}
@@ -244,7 +273,7 @@ jobs:
with:
rust-version: stable
target: ${{ matrix.target }}
cache-shared-key: build-${{ matrix.target }}${{ matrix.glibc && format('-glibc-{0}', matrix.glibc) || '' }}
cache-shared-key: build-${{ matrix.target }}
# main only. A cache saved on refs/tags/X is scoped to that tag: no
# other tag, no main run and no PR can restore it, so every release
# cycle wrote up to 12 entries of 1-2GB (preview tag plus final tag,
@@ -381,53 +410,35 @@ jobs:
shell: bash
env:
BUILD_TARGET: ${{ matrix.target }}
BUILD_OS: ${{ matrix.platform }}-${{ startsWith(matrix.target, 'aarch64-') && 'aarch64' || 'x86_64' }}
run: |
# Force rebuild by touching build.rs
touch rustfs/build.rs
BINARY_NAMES=(rustfs)
if [[ "${{ matrix.platform }}" == "linux" ]]; then
BINARY_NAMES+=(rustfs-cli)
fi
FEATURE_ARGS=()
if [[ -n "${{ matrix.features }}" ]]; then
FEATURE_ARGS=(--features "${{ matrix.features }}")
fi
# gnu targets pin an explicit glibc baseline (`<target>.<glibc>` for
# cargo-zigbuild) so release binaries link no symbols newer than it
# and run on older distros such as RHEL/Rocky 9 (glibc 2.34); #8062.
TARGET_ARG="$BUILD_TARGET"
if [[ -n "${{ matrix.glibc }}" ]]; then
TARGET_ARG="${TARGET_ARG}.${{ matrix.glibc }}"
fi
PROFILE_ARGS=()
if [[ "$BUILD_TARGET" == *-linux-gnu && "${{ matrix.features }}" == "pyroscope" ]]; then
# The in-process profiler resolves names from the final executable.
# Retain function symbols without enabling DWARF source information.
PROFILE_ARGS=(--config 'profile.release.strip="none"')
fi
if [[ "${{ matrix.cross }}" == "true" ]]; then
cargo zigbuild --release --locked --target "$TARGET_ARG" "${FEATURE_ARGS[@]}" "${PROFILE_ARGS[@]}" -p rustfs --bin rustfs
else
cargo build --release --locked --target "$TARGET_ARG" "${FEATURE_ARGS[@]}" "${PROFILE_ARGS[@]}" -p rustfs --bin rustfs
fi
# All release targets use same-architecture runners, including GNU
# builds that use Zig only to select the glibc ABI floor.
bin="target/${{ matrix.target }}/release/rustfs"
if [[ -f "${bin}.exe" ]]; then
bin="${bin}.exe"
fi
version_output=$("$bin" --version)
if ! grep -Fxq "build target : ${BUILD_TARGET}" <<< "$version_output" || \
! grep -Fxq "build os : ${BUILD_OS}" <<< "$version_output" || \
! grep -Fxq "rust channel : stable-${BUILD_TARGET}" <<< "$version_output"; then
echo "release binary reports an unexpected compile target" >&2
exit 1
fi
if [[ -n "${{ matrix.glibc }}" ]]; then
python3 scripts/release/check_glibc.py "${{ matrix.glibc }}" \
"target/${{ matrix.target }}/release/rustfs"
fi
# Build one binary at a time so release LTO links cannot exhaust a
# self-hosted runner by running concurrently.
for binary in "${BINARY_NAMES[@]}"; do
PROFILE_ARGS=()
if [[ "$binary" == "rustfs" && "$BUILD_TARGET" == *-linux-gnu && "${{ matrix.features }}" == "pyroscope" ]]; then
# The in-process profiler resolves names from the final executable.
# Retain function symbols without enabling DWARF source information.
PROFILE_ARGS=(--config 'profile.release.strip="none"')
fi
if [[ "${{ matrix.cross }}" == "true" ]]; then
# All cross targets in the matrix are Linux; zigbuild handles them.
cargo zigbuild --release --target ${{ matrix.target }} "${FEATURE_ARGS[@]}" "${PROFILE_ARGS[@]}" -p rustfs --bin "$binary"
else
cargo build --release --target ${{ matrix.target }} "${FEATURE_ARGS[@]}" "${PROFILE_ARGS[@]}" -p rustfs --bin "$binary"
fi
done
- name: Verify macOS binary has no dynamic liblzma dependency
if: matrix.platform == 'macos'
@@ -543,6 +554,9 @@ jobs:
fi
BINARY_FILES=("$BINARY_NAME")
if [[ "${{ matrix.platform }}" == "linux" ]]; then
BINARY_FILES+=("rustfs-cli")
fi
if [[ "$TARGET" == *-linux-gnu && "${{ matrix.features }}" == "pyroscope" ]]; then
BINARY_FILES+=(rustfs.cpu-symbol-catalog.json rustfs.cpu-symbol-catalog.sha256)
@@ -615,7 +629,7 @@ jobs:
LATEST_FILES="$LATEST_FILE"
fi
elif [[ "$BUILD_TYPE" == "development" ]]; then
# Development builds (nightly schedule and manual dispatch)
# Development builds (only main branch triggers development builds)
# Create main-latest version filename
# Convert from rustfs-linux-x86_64-dev-abc123 to rustfs-linux-x86_64-main-latest
MAIN_LATEST_FILE="${PACKAGE_BASENAME}-main-latest.zip"
@@ -657,7 +671,7 @@ jobs:
echo "🔧 Build type: ${BUILD_TYPE}"
echo "📊 Version: ${VERSION}"
- name: Verify packaged Linux binary
- name: Verify packaged Linux binaries
if: matrix.platform == 'linux'
shell: bash
run: |
@@ -665,7 +679,7 @@ jobs:
package_file="${{ steps.package.outputs.package_file }}"
archive_members="$(unzip -Z1 "$package_file" | sort)"
expected_members='rustfs'
expected_members=$'rustfs\nrustfs-cli'
target="${{ matrix.target }}"
features="${{ matrix.features }}"
if [[ "$target" == *-linux-gnu && "$features" == "pyroscope" ]]; then
@@ -677,14 +691,13 @@ jobs:
exit 1
fi
# musl targets build natively; the gnu legs are same-arch
# cargo-zigbuild builds pinned to the release glibc baseline. Either
# way the binaries run on their runner, so executing them here
# Every Linux target builds natively, so executing the binaries here
# proves each artifact matches its runner architecture.
binary_dir="$(mktemp -d)"
trap 'rm -rf "$binary_dir"' EXIT
unzip -q "$package_file" rustfs -d "$binary_dir"
"$binary_dir/rustfs" --version
cli_dir="$(mktemp -d)"
trap 'rm -rf "$cli_dir"' EXIT
unzip -q "$package_file" rustfs rustfs-cli -d "$cli_dir"
"$cli_dir/rustfs" --version
"$cli_dir/rustfs-cli" --help >/dev/null
- name: Verify packaged console
if: matrix.target == 'x86_64-unknown-linux-gnu' || matrix.target == 'aarch64-unknown-linux-gnu'
@@ -830,7 +843,7 @@ jobs:
name: Build Summary
needs: [ build-check, build-rustfs ]
if: always() && needs.build-check.outputs.should_build == 'true'
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 10
steps:
- name: Build completion summary
@@ -1019,7 +1032,7 @@ jobs:
name: Update Latest Version
needs: [ build-check, publish-release ]
if: needs.build-check.outputs.should_publish == 'true' && startsWith(github.ref, 'refs/tags/') && (needs.build-check.outputs.build_type == 'release' || needs.build-check.outputs.build_type == 'prerelease')
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 30
steps:
- name: Update latest.json
+23 -32
View File
@@ -42,23 +42,26 @@
name: Cache Warm
on:
schedule:
# Every six hours; the 22:41 UTC run lands before ci.yml's nightly 00:11
# UTC matrix. A reader restores the newest entry under its key and
# rebuilds whatever its own diff changed, so a cache up to six hours old
# costs a PR the handful of crates merged since. Warming after every merge
# (fourteen completed runs a day, about two sm-standard-4 hours each)
# spent far more than that to avoid it.
- cron: "41 4,10,16,22 * * *"
push:
branches: [ main ]
# A toolchain or lockfile change invalidates most of every key; those
# re-warm at once instead of waiting for the next slot.
paths:
- "Cargo.lock"
- "rust-toolchain.toml"
- ".github/actions/setup/**"
- ".github/workflows/cache-warm.yml"
# Mirrors ci.yml's push paths-ignore: if a commit cannot change what ci.yml
# compiles, it cannot change what ci.yml needs restored either.
paths-ignore:
- "**.md"
- "docs/**"
- "deploy/**"
- "scripts/dev_*.sh"
- "scripts/probe.sh"
- "LICENSE*"
- ".gitignore"
- ".dockerignore"
- "README*"
- "**/*.png"
- "**/*.jpg"
- "**/*.svg"
- ".github/workflows/build.yml"
- ".github/workflows/docker.yml"
- ".github/workflows/audit.yml"
workflow_dispatch:
inputs:
emit_timings:
@@ -72,7 +75,7 @@ on:
permissions:
contents: read
# Scoped by event. Scheduled, push and dispatch runs do not compete: GitHub keeps
# Scoped by event. A push run and a dispatch run do not compete: GitHub keeps
# one running plus one pending per group, so with a single shared group a
# manually dispatched run was displaced as pending by the next merge and
# cancelled — observed three times in a row, which made the --timings gate in
@@ -96,7 +99,7 @@ env:
RUST_BACKTRACE: 1
jobs:
# Readers: test-and-lint (workspace and ILM), build-rustfs-debug-binary,
# Readers: test-and-lint, test-ilm-integration-serial, build-rustfs-debug-binary,
# e2e-tests, e2e-full.
warm-ci-dev:
name: Warm ci-dev
@@ -159,17 +162,6 @@ jobs:
cargo build --workspace --all-targets
cargo build -p rustfs --bins --features e2e-test-hooks
# ci.yml runs Clippy on every pull request. Clippy's check-mode artifacts
# are separate from the build above, so warm them with the exact
# invocation ci.yml uses; a pull request then re-checks only what its diff
# touches. A lint failure on main must not stop the warm-up: the cache is
# still saved (cache-on-failure), and the failure belongs to ci.yml.
- name: Warm Clippy check artifacts
run: |
if ! cargo clippy --all-targets -- -D warnings; then
echo "::warning::cargo clippy reports findings on this commit; check artifacts are still cached"
fi
# Runs before rust-cache's post step, so these are the sizes it is about
# to archive. Reported so the cache-all-crates decision stays evidence-led:
# registry/src is what that flag prunes, registry/cache is what the pruned
@@ -193,10 +185,9 @@ jobs:
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
# Readers: test-and-lint-rio-v2 (nightly schedule, merge group, dispatch),
# build-rustfs-debug-binary-rio-v2 (nightly schedule / manual dispatch only —
# dormant rio-v2 variant, see rustfs/backlog#1835 and
# docs/architecture/minio-file-format-compat.md).
# Readers: test-and-lint-rio-v2 (per-PR), build-rustfs-debug-binary-rio-v2
# (weekly schedule / manual dispatch only — dormant rio-v2 variant, see
# rustfs/backlog#1835 and docs/architecture/minio-file-format-compat.md).
# The second build below stays despite the reduced cadence: it warms the
# rio-v2,e2e-test-hooks feature resolution the scheduled build restores,
# which keeps that lane inside its 30-minute timeout.
+184 -137
View File
@@ -42,7 +42,7 @@ on:
merge_group:
types: [ checks_requested ]
schedule:
- cron: "11 0 * * *" # Nightly 00:11 UTC, after the 22:41 UTC cache-warm run
- cron: "11 0 * * 0" # Weekly on Sunday 00:11 UTC
workflow_dispatch:
permissions:
@@ -62,15 +62,10 @@ env:
jobs:
# Compile-free lanes, and lanes that only run a prebuilt binary, use
# GitHub-hosted runners: they are free for this public repository, and the
# self-hosted pool is reserved for the lanes that need its CPU count and
# disk (the workspace test build leaves a ~64GB target/, hosted runners
# have 14GB). Placement and billing rules: README.md in this directory.
cancel-closed-pr-runs:
name: Cancel Closed PR Runs
if: github.event_name == 'pull_request' && github.event.action == 'closed'
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 10
steps:
- name: Explain cancellation run
@@ -79,7 +74,7 @@ jobs:
classify-changes:
name: Select CI scope
if: github.event_name != 'pull_request' || github.event.action != 'closed'
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 10
outputs:
mode: ${{ steps.scope.outputs.mode }}
@@ -105,12 +100,16 @@ jobs:
typos:
name: Typos
if: github.event_name != 'pull_request' || github.event.action != 'closed'
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 10
steps:
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Install typos download dependency
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends wget
- name: Typos check with custom config file
uses: crate-ci/typos@512fc24f32f44ab01972217aaaf3dc86ec234d53 # v1.50.2
@@ -118,7 +117,7 @@ jobs:
quick-checks:
name: Quick Checks
if: github.event_name != 'pull_request' || github.event.action != 'closed'
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 10
steps:
- name: Checkout repository
@@ -169,12 +168,12 @@ jobs:
echo "started_at=$(date --utc --iso-8601=seconds)"
} > artifacts/test-and-lint/run-metadata.txt
# Clippy gates every event, pull requests included. When it ran only after
# merge, a lint regression merged freely and then failed every main run
# until someone fixed it (10 of 12 main runs on 2026-10-17/18). cache-warm
# writes the same invocation's check artifacts into ci-dev, so a pull
# request re-checks only the crates its diff touches. Main keeps the step
# because pull requests can merge without being up to date with main.
# Clippy runs before the test pass: lint failures are the most common
# CI-only breakage and should surface in minutes, not after 20+ minutes
# of tests.
# Sampled too: clippy is the natural control arm for any CARGO_BUILD_JOBS
# experiment, since --all-targets is check-only for workspace members and
# never links the ~100 test binaries the limit exists to throttle.
- name: Run clippy lints
run: |
./scripts/ci/resource_sampler.sh start clippy
@@ -183,27 +182,31 @@ jobs:
- name: Run nextest tests
env:
# #5394 mitigation, settled by the backlog#1601 experiment.
# #5394 mitigation, now under a measured experiment (backlog#1601).
#
# 2 was chosen when three concurrent workspace test links were believed
# to saturate the runner's overlay I/O and wedge Cargo until the 75m
# timeout. cgroup v2 readings from the sampler show the pod actually
# has 14 CPUs and 28GB (peak use 2.1GB): the label name "sm-standard-4"
# had led everyone, including the original mitigation, to assume 4
# cores, and memory was never the constraint.
# has 14 CPUs and 28GB (peak use 2.1GB), so 2 throttles compilation to
# a seventh of what is available and memory was never the constraint —
# the label name "sm-standard-4" had led everyone, including the
# original mitigation, to assume 4 cores.
#
# Measured on the same ci-dev cache, the test-profile compile took
# 28m21s at 2 on a pull request and 16-23 min at 3 across three
# successful main runs. The first pull-request run at 3 that met a
# busy node (run 39193827281) then hit the 75m timeout before any
# test started: the sampler shows cargo spinning for 70 minutes with
# the pod's io.pressure "full" at 86-88% and CPU pressure at zero,
# while node-level IO pressure was already 36% from co-tenants when
# the step began. The constraint is the pod's shared storage, not
# CPU or memory, and the limit that storage sustains on a contended
# node is 2. Main pushes and dispatches keep 3 (rarer, and three
# successes); pull requests stay at 2 until the pods get local disk
# for target/, which is the lever that would unlock the 14 cores.
# Raised to 3 on main pushes and manual dispatches; PRs keep 2 so the
# merge path is untouched while the experiment runs.
#
# During baseline collection, main pushes cancelled superseded runs:
# only 4 of 20 Test and Lint jobs reached a terminal state. Running
# main validations now finish; dispatch remains an independent group
# for collecting additional samples on demand.
#
# Baseline over 17 samples at 2:
# median nextest/clippy step ratio 1.95, spread 1.85-2.06. The gate-2
# criterion is that ratio dropping at least 10% (below ~1.76) with no
# 75m timeout and no run showing three consecutive samples of
# rustc/collect2/rust-lld in D state. If it does not, the conclusion is
# "this limit is not the bottleneck" — fix it back at 2 and record the
# experiment, which is a result, not a failure.
#
# Must stay step-level: rust-cache hashes CARGO/CC/CFLAGS/CXX/CMAKE/RUST
# prefixed variables from process.env into the cache key, so promoting
@@ -280,55 +283,10 @@ jobs:
- name: Verify rebalance/decommission migration proofs
run: ./scripts/check_migration_gate_count.sh evidence artifacts/test-and-lint/core-test-listing.json target/nextest/ci/junit.xml
# The workspace already compiled these ignored tests. Keep the same Cargo
# package selection to reuse their binaries, then run them serially in
# separate processes because they share fixed ports and global state.
# test_transition_and_restore_flows was re-enabled by rustfs/backlog#1303:
# its "missing xl.meta on disk2" was a test-util bug (open_disk hardcoded
# disk_index 0), not an EC metadata-distribution issue.
# restore_object_usecase_reports_ongoing_conflict_and_completion was
# re-enabled by backlog#1304 (restore accepts serialize on a short CAS
# guard; the copy-back no longer holds the #4877 whole-copy-back lock,
# so the mid-restore ongoing read and fast 409 rejection it asserts are
# the implemented contract).
- name: Run ignored ILM integration tests serially
if: needs.classify-changes.outputs.mode == 'full'
env:
# Match the preceding workspace build and reuse its feature graph.
CARGO_BUILD_JOBS: ${{ (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && '3' || '2' }}
run: |
mkdir -p artifacts/ilm-integration
set +e
NEXTEST_HIDE_PROGRESS_BAR=1 timeout --verbose --signal=TERM --kill-after=30s 80m \
cargo nextest run --profile default -j1 --run-ignored ignored-only \
--all --exclude e2e_test \
-E 'binary(lifecycle_integration_test) or (package(rustfs) and test(lifecycle_transition_api_test))' \
--status-level all --final-status-level all \
2>&1 | tee artifacts/ilm-integration/nextest.log
status=${PIPESTATUS[0]}
{
echo "exit_status=${status}"
echo "finished_at=$(date --utc --iso-8601=seconds)"
echo
echo "Remaining test-related processes:"
pgrep -af 'cargo|nextest|target/.*/deps/' || true
} > artifacts/ilm-integration/diagnostics.txt
exit "${status}"
- name: Upload ILM test diagnostics
if: always() && needs.classify-changes.outputs.mode == 'full'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ilm-integration-${{ github.run_number }}-${{ github.run_attempt }}
path: artifacts/ilm-integration
retention-days: 3
# Both Connect boundaries rebuild only the rustfs crate on top of the restored
# ci-dev cache: the two e2e features are empty, so every dependency keeps its
# cached dev-profile artifact and each feature set recompiles rustfs once.
# The budget covers a ci-dev miss, where the first step builds the whole graph.
connect-feature-boundaries:
name: Connect Feature Boundaries
# This gate builds into a fresh target directory to isolate the E2E root.
# Give its cold build a separate budget from workspace tests and migration proofs.
offline-enrollment-e2e:
name: Offline Enrollment E2E Root Boundary
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
needs: [ quick-checks, classify-changes ]
runs-on: sm-standard-4
@@ -353,58 +311,86 @@ jobs:
- name: Protect Connect test home
run: chmod go-w "$(realpath "$HOME")"
- name: Run short credential behavior tests
env:
CARGO_BUILD_JOBS: "2"
run: |
cargo test -p rustfs --test connect_registration \
--features connect-e2e-short-credentials \
registration_enforces_build_profile_credential_lifetime -- --exact
cargo test -p rustfs --test connect_registration \
--features connect-e2e-short-credentials \
rotation_waits_for_threshold_and_stops_on_revocation -- --exact
# The guard in rustfs/src/connect/registration.rs is cfg(not(debug_assertions)),
# and it must be tripped by a profile that turns debug assertions off for
# the whole graph. Handing `-C debug-assertions=off` to the rustfs lib alone
# through `cargo rustc` leaves its dependencies on the dev-profile cfg, so
# the lib also fails on items gated on one side only (E0599 on
# Filenames::Embedded) and "fails solely at the compile_error" can never
# hold. `cargo check --release` is metadata-only and needs no
# link-concurrency limit; it is cold because ci-dev holds no release
# artifacts.
- name: Reject short credentials in release builds
run: |
log="$(mktemp)"
set +e
CARGO_TERM_COLOR=never cargo check -p rustfs --release \
--features connect-e2e-short-credentials >"$log" 2>&1
status=$?
set -e
cat "$log"
expected='error: connect-e2e-short-credentials is restricted to debug builds'
summary="error: could not compile \`rustfs\` (lib) due to 1 previous error"
expected_count="$(grep -Fxc "$expected" "$log" || true)"
summary_count="$(grep -Fc "$summary" "$log" || true)"
error_count="$(grep -Ec '^error(:|\[)' "$log" || true)"
if [ "$status" -ne 101 ] || [ "$expected_count" -ne 1 ] \
|| [ "$summary_count" -ne 1 ] || [ "$error_count" -ne 2 ]; then
echo "feature gate did not fail solely at the expected compile_error" >&2
rm -f "$log"
exit 1
fi
rm -f "$log"
- name: Check offline enrollment E2E root boundary
run: ./scripts/check_offline_enrollment_e2e.sh
# Dedicated serial lane for the ILM / lifecycle integration tests. These tests
# drive the object layer through process-global singletons (the GLOBAL_ENV
# ECStore, the global tier-config manager, background-expiry workers) and bind
# fixed ports (9002 for the scanner suite, 9003 for the app suite), so they are
# marked #[ignore] to keep them out of the default parallel `cargo nextest run
# --all` pass. Run them here explicitly with `--run-ignored ignored-only` and
# `-j1` so no two of them share global state or race for a port at once.
# serial_test's #[serial] does NOT serialize across nextest's process-per-test
# boundary (see .config/nextest.toml); `-j1` is what actually serializes them.
# See rustfs/backlog#1148 (ilm-1) and #1155.
test-ilm-integration-serial:
name: ILM Integration (serial)
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
needs: [ quick-checks, classify-changes ]
runs-on: sm-standard-4
timeout-minutes: 90
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
steps:
- name: Checkout repository
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Setup Rust environment
uses: ./.github/actions/setup
with:
rust-version: stable
cache-shared-key: ci-dev
cache-save-if: 'false'
install-build-packaging-tools: 'false'
# test_transition_and_restore_flows was re-enabled by rustfs/backlog#1303:
# its "missing xl.meta on disk2" was a test-util bug (open_disk hardcoded
# disk_index 0), not an EC metadata-distribution issue.
# restore_object_usecase_reports_ongoing_conflict_and_completion was
# re-enabled by backlog#1304 (restore accepts serialize on a short CAS
# guard; the copy-back no longer holds the #4877 whole-copy-back lock,
# so the mid-restore ongoing read and fast 409 rejection it asserts are
# the implemented contract).
- name: Run ignored ILM integration tests serially
env:
# Match the measured Test and Lint link budget. The default exposed
# all 14 pod CPUs and a cold cache spent the full 80m compiling
# without starting one ILM test (main run 32982910990).
CARGO_BUILD_JOBS: ${{ (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && '3' || '2' }}
run: |
mkdir -p artifacts/ilm-integration
set +e
NEXTEST_HIDE_PROGRESS_BAR=1 timeout --verbose --signal=TERM --kill-after=30s 80m \
cargo nextest run -j1 --run-ignored ignored-only \
-p rustfs-scanner -p rustfs \
-E 'binary(lifecycle_integration_test) or (package(rustfs) and test(lifecycle_transition_api_test))' \
--status-level all --final-status-level all \
2>&1 | tee artifacts/ilm-integration/nextest.log
status=${PIPESTATUS[0]}
{
echo "exit_status=${status}"
echo "finished_at=$(date --utc --iso-8601=seconds)"
echo
echo "Remaining test-related processes:"
pgrep -af 'cargo|nextest|target/.*/deps/' || true
} > artifacts/ilm-integration/diagnostics.txt
exit "${status}"
- name: Upload ILM test diagnostics
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ilm-integration-${{ github.run_number }}-${{ github.run_attempt }}
path: |
artifacts/ilm-integration
target/nextest/ci/junit.xml
test-and-lint-rio-v2:
name: Test and Lint (rio-v2)
# Alternate feature configurations run on the nightly schedule, merge
# groups and manual dispatch. The default workspace remains the PR gate and
# the per-push main baseline: repeating four feature builds on every merge
# bought nothing the nightly run does not report within a day.
if: needs.classify-changes.outputs.mode == 'full' && contains(fromJSON('["merge_group", "schedule", "workflow_dispatch"]'), github.event_name)
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
needs: [ quick-checks, classify-changes ]
runs-on: sm-standard-4
timeout-minutes: 90
@@ -460,10 +446,67 @@ jobs:
retention-days: 3
if-no-files-found: error
connect-short-credential-boundary:
name: Connect Short Credential Boundary
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
needs: [ quick-checks, classify-changes ]
runs-on: sm-standard-4
timeout-minutes: 60
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
steps:
- name: Checkout repository
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Setup Rust environment
uses: ./.github/actions/setup
with:
rust-version: stable
cache-shared-key: ci-dev
cache-save-if: 'false'
install-build-packaging-tools: 'false'
install-test-tools: 'false'
- name: Run short credential behavior tests
env:
CARGO_BUILD_JOBS: "2"
run: |
cargo test -p rustfs --test connect_registration \
--features connect-e2e-short-credentials \
registration_enforces_build_profile_credential_lifetime -- --exact
cargo test -p rustfs --test connect_registration \
--features connect-e2e-short-credentials \
rotation_waits_for_threshold_and_stops_on_revocation -- --exact
- name: Reject short credentials in release builds
env:
CARGO_BUILD_JOBS: "2"
run: |
log="$(mktemp)"
set +e
CARGO_TERM_COLOR=never cargo check -p rustfs --release \
--features connect-e2e-short-credentials >"$log" 2>&1
status=$?
set -e
cat "$log"
expected='error: connect-e2e-short-credentials is restricted to debug builds'
summary="error: could not compile \`rustfs\` (lib) due to 1 previous error"
expected_count="$(grep -Fxc "$expected" "$log" || true)"
summary_count="$(grep -Fc "$summary" "$log" || true)"
error_count="$(grep -Ec '^error(:|\[)' "$log" || true)"
if [ "$status" -ne 101 ] || [ "$expected_count" -ne 1 ] \
|| [ "$summary_count" -ne 1 ] || [ "$error_count" -ne 2 ]; then
echo "release feature gate did not fail solely at the expected compile_error" >&2
rm -f "$log"
exit 1
fi
rm -f "$log"
test-and-lint-protocols:
name: "Test and Lint (${{ matrix.features.name }})"
# Same cadence as test-and-lint-rio-v2 above.
if: needs.classify-changes.outputs.mode == 'full' && contains(fromJSON('["merge_group", "schedule", "workflow_dispatch"]'), github.event_name)
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
needs: [ quick-checks, classify-changes ]
runs-on: sm-standard-4
timeout-minutes: 90
@@ -619,7 +662,7 @@ jobs:
build-rustfs-debug-binary-rio-v2:
name: Build RustFS Debug Binary (rio-v2)
# Dormant rio-v2 variant (rustfs/backlog#1835): the feature ships in no
# default build, so this full-suite lane runs only on the nightly schedule
# default build, so this full-suite lane runs only on the weekly schedule
# and manual dispatch. Per-PR cfg-seam coverage stays with
# test-and-lint-rio-v2. Lifecycle and the promote-or-delete condition:
# docs/architecture/minio-file-format-compat.md ("rio-v2 variant lifecycle").
@@ -1089,7 +1132,7 @@ jobs:
s3-implemented-tests:
name: S3 Implemented Tests
needs: [ build-rustfs-debug-binary, e2e-tests ]
runs-on: ubuntu-latest
runs-on: sm-standard-4
timeout-minutes: 60
steps:
- name: Checkout repository
@@ -1158,12 +1201,12 @@ jobs:
name: S3 Lifecycle Behavior Tests
# Also gated on e2e-tests, matching s3-implemented-tests: when the e2e smoke
# suite is already red this lane cannot tell us anything new, and it holds a
# runner for up to 30 minutes doing so. Both lanes only download the
# sm-standard-4 for up to 30 minutes doing so. Both lanes only download the
# prebuilt debug binary (no cargo build), and s3-implemented-tests — which
# already waits on e2e-tests — finishes later anyway, so a green PR's total
# wall clock is unchanged.
needs: [ build-rustfs-debug-binary, e2e-tests ]
runs-on: ubuntu-latest
runs-on: sm-standard-4
timeout-minutes: 30
steps:
- name: Checkout repository
@@ -1220,8 +1263,10 @@ jobs:
- typos
- quick-checks
- test-and-lint
- connect-feature-boundaries
- offline-enrollment-e2e
- test-ilm-integration-serial
- test-and-lint-rio-v2
- connect-short-credential-boundary
- test-and-lint-protocols
- build-rustfs-debug-binary
- uring-integration
@@ -1231,7 +1276,7 @@ jobs:
- build-rustfs-debug-binary-rio-v2
- e2e-tests-rio-v2
- e2e-full
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 10
steps:
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
@@ -1247,11 +1292,13 @@ jobs:
name: Alert on scheduled failure
needs:
- classify-changes
- connect-feature-boundaries
- connect-short-credential-boundary
- required-checks
- typos
- quick-checks
- test-and-lint
- offline-enrollment-e2e
- test-ilm-integration-serial
- test-and-lint-rio-v2
- test-and-lint-protocols
- build-rustfs-debug-binary
+2 -2
View File
@@ -46,7 +46,7 @@ jobs:
# Echoes one line; the run exists only so the concurrency group cancels the
# in-flight run of a closed PR.
permissions: {}
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 10
steps:
- name: Explain cancellation run
@@ -61,7 +61,7 @@ jobs:
checks: write
issues: write
pull-requests: write
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 30
steps:
- name: Report CLA result for merge queue
@@ -57,14 +57,6 @@ jobs:
ref: ${{ inputs.connect_sha }}
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
- name: Set up Docker Compose
uses: ./.github/actions/setup-docker-compose
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.14"
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
@@ -102,7 +94,7 @@ jobs:
' <<<"$run" >/dev/null
jobs=$(gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/actions/runs/${BUILD_RUN_ID}/jobs?per_page=100")
jq -e '
[.[].jobs[] | select(.name | test("^Build RustFS \\(linux-x86_64-gnu\\)$"))] as $matches
[.[].jobs[] | select(.name | test("^Build RustFS \\(linux-x86_64-gnu, [a-z0-9-]+, x86_64-unknown-linux-gnu, false, linux, pyroscope\\)$"))] as $matches
| ($matches | length) == 1 and $matches[0].conclusion == "success"
' <<<"$jobs" >/dev/null
artifact=$(gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}")
@@ -145,7 +137,7 @@ jobs:
assert outer.testzip() is None
with zipfile.ZipFile(io.BytesIO(outer.read(package))) as inner:
entries = inner.infolist()
assert {item.filename for item in entries} == {'rustfs', 'rustfs.cpu-symbol-catalog.json', 'rustfs.cpu-symbol-catalog.sha256'} and len(entries) == 3
assert {item.filename for item in entries} == {'rustfs', 'rustfs-cli'} and len(entries) == 2
assert all(regular(item) and 0 < item.file_size <= 1073741824 for item in entries)
assert inner.testzip() is None
pathlib.Path('binary').mkdir()
@@ -170,7 +162,7 @@ jobs:
connect-harness/web/node_modules/.bin/playwright install --with-deps chromium
make -C connect-harness e2e-connected-dispatch-check
(cd connect-harness && ./tests/connectivity/diagnostic-schedule-evidence.test.sh)
TMPDIR="$GITHUB_WORKSPACE" RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
RUSTFS_WORKTREE="$GITHUB_WORKSPACE/rustfs-source" \
CONNECT_E2E_DIAGNOSTIC_SCHEDULE_EVIDENCE="$GITHUB_WORKSPACE/diagnostic-schedule-evidence.json" \
make -C connect-harness e2e-connected E2E_SCENARIO=diagnostic-schedule
@@ -57,14 +57,6 @@ jobs:
ref: ${{ inputs.connect_sha }}
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
- name: Set up Docker Compose
uses: ./.github/actions/setup-docker-compose
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.14"
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
@@ -135,7 +127,7 @@ jobs:
' <<<"$run" >/dev/null
jobs=$(gh api --paginate --slurp "repos/rustfs/rustfs/actions/runs/${BUILD_RUN_ID}/jobs?per_page=100")
jq -e '
[.[].jobs[] | select(.name | test("^Build RustFS \\(linux-x86_64-gnu\\)$"))] as $matches
[.[].jobs[] | select(.name | test("^Build RustFS \\(linux-x86_64-gnu, [a-z0-9-]+, x86_64-unknown-linux-gnu, false, linux, pyroscope\\)$"))] as $matches
| ($matches | length) == 1 and $matches[0].conclusion == "success"
' <<<"$jobs" >/dev/null
artifact=$(gh api "repos/rustfs/rustfs/actions/artifacts/${ARTIFACT_ID}")
@@ -180,7 +172,7 @@ jobs:
assert outer.testzip() is None
with zipfile.ZipFile(io.BytesIO(outer.read(package))) as inner:
entries = inner.infolist()
assert {entry.filename for entry in entries} == {'rustfs', 'rustfs.cpu-symbol-catalog.json', 'rustfs.cpu-symbol-catalog.sha256'} and len(entries) == 3
assert {entry.filename for entry in entries} == {'rustfs', 'rustfs-cli', 'rustfs.cpu-symbol-catalog.json', 'rustfs.cpu-symbol-catalog.sha256'} and len(entries) == 4
assert all(regular(entry) and 0 < entry.file_size <= 1073741824 for entry in entries)
assert inner.testzip() is None
pathlib.Path('binary').mkdir()
@@ -207,7 +199,7 @@ jobs:
connect-harness/web/node_modules/.bin/playwright install --with-deps chromium
make -C connect-harness e2e-connected-dispatch-check
(cd connect-harness && ./tests/connectivity/health-service-evidence.test.sh)
TMPDIR="$GITHUB_WORKSPACE" RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
RUSTFS_WORKTREE="$GITHUB_WORKSPACE/rustfs-source" \
CONNECT_E2E_HEALTH_SERVICE_EVIDENCE="$GITHUB_WORKSPACE/health-service-evidence.json" \
make -C connect-harness e2e-connected E2E_SCENARIO=health-service
@@ -73,14 +73,6 @@ jobs:
ref: ${{ inputs.connect_sha }}
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
- name: Set up Docker Compose
uses: ./.github/actions/setup-docker-compose
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.14"
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
@@ -115,7 +107,7 @@ jobs:
[[ $(jq -r '.head_repository.full_name' <<<"$run") == "$GITHUB_REPOSITORY" ]]
[[ $(jq -r '.name' <<<"$run") == "Build and Release" ]]
expected_job='Build RustFS (linux-x86_64-gnu)'
expected_job='Build RustFS (linux-x86_64-gnu, sm-standard-4, x86_64-unknown-linux-gnu, false, linux, pyroscope)'
jobs=$(gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/actions/runs/${BUILD_RUN_ID}/jobs?per_page=100")
jq -e --arg name "$expected_job" '
[.[].jobs[] | select(.name == $name)] as $matches
@@ -162,7 +154,7 @@ jobs:
npm --prefix connect-harness/web ci
connect-harness/web/node_modules/.bin/playwright install --with-deps chromium
make -C connect-harness e2e-connected-dispatch-check
TMPDIR="$GITHUB_WORKSPACE" RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
RUSTFS_WORKTREE="$GITHUB_WORKSPACE/rustfs-source" \
CONNECT_E2E_PERFORMANCE_DRIVE_EVIDENCE="$GITHUB_WORKSPACE/performance-drive-service-job-evidence.json" \
make -C connect-harness e2e-connected E2E_SCENARIO=performance-drive
@@ -73,14 +73,6 @@ jobs:
ref: ${{ inputs.connect_sha }}
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
- name: Set up Docker Compose
uses: ./.github/actions/setup-docker-compose
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.14"
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
@@ -115,7 +107,7 @@ jobs:
[[ $(jq -r '.head_repository.full_name' <<<"$run") == "$GITHUB_REPOSITORY" ]]
[[ $(jq -r '.name' <<<"$run") == "Build and Release" ]]
expected_job='Build RustFS (linux-x86_64-gnu)'
expected_job='Build RustFS (linux-x86_64-gnu, sm-standard-4, x86_64-unknown-linux-gnu, false, linux, pyroscope)'
jobs=$(gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/actions/runs/${BUILD_RUN_ID}/jobs?per_page=100")
jq -e --arg name "$expected_job" '
[.[].jobs[] | select(.name == $name)] as $matches
@@ -162,7 +154,7 @@ jobs:
npm --prefix connect-harness/web ci
connect-harness/web/node_modules/.bin/playwright install --with-deps chromium
make -C connect-harness e2e-connected-dispatch-check
TMPDIR="$GITHUB_WORKSPACE" RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
RUSTFS_WORKTREE="$GITHUB_WORKSPACE/rustfs-source" \
CONNECT_E2E_PERFORMANCE_NETWORK_EVIDENCE="$GITHUB_WORKSPACE/performance-network-service-job-evidence.json" \
make -C connect-harness e2e-connected E2E_SCENARIO=performance-network
@@ -77,14 +77,6 @@ jobs:
ref: ${{ inputs.connect_sha }}
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
- name: Set up Docker Compose
uses: ./.github/actions/setup-docker-compose
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.14"
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
@@ -126,7 +118,7 @@ jobs:
[[ $(jq -r '.head_repository.full_name' <<<"$run") == "$GITHUB_REPOSITORY" ]]
[[ $(jq -r '.name' <<<"$run") == "Build and Release" ]]
expected_job='Build RustFS (linux-x86_64-gnu)'
expected_job='Build RustFS (linux-x86_64-gnu, sm-standard-4, x86_64-unknown-linux-gnu, false, linux, pyroscope)'
jobs=$(github_api "repos/${GITHUB_REPOSITORY}/actions/runs/${BUILD_RUN_ID}/jobs?per_page=100")
jq -e --arg name "$expected_job" '
[.jobs[] | select(.name == $name)] as $matches
@@ -181,7 +173,7 @@ jobs:
npm --prefix connect-harness/web ci
connect-harness/web/node_modules/.bin/playwright install --with-deps chromium
make -C connect-harness e2e-connected-dispatch-check
TMPDIR="$GITHUB_WORKSPACE" RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
RUSTFS_WORKTREE="$GITHUB_WORKSPACE/rustfs-source" \
CONNECT_E2E_CPU_CATALOG="$GITHUB_WORKSPACE/binary/rustfs.cpu-symbol-catalog.json" \
CONNECT_E2E_CPU_CATALOG_SHA256="$CATALOG_SHA256" \
@@ -57,14 +57,6 @@ jobs:
ref: ${{ inputs.connect_sha }}
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
- name: Set up Docker Compose
uses: ./.github/actions/setup-docker-compose
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.14"
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
@@ -135,7 +127,7 @@ jobs:
' <<<"$run" >/dev/null
jobs=$(gh api --paginate --slurp "repos/rustfs/rustfs/actions/runs/${BUILD_RUN_ID}/jobs?per_page=100")
jq -e '
[.[].jobs[] | select(.name | test("^Build RustFS \\(linux-x86_64-gnu\\)$"))] as $matches
[.[].jobs[] | select(.name | test("^Build RustFS \\(linux-x86_64-gnu, [a-z0-9-]+, x86_64-unknown-linux-gnu, false, linux, pyroscope\\)$"))] as $matches
| ($matches | length) == 1 and $matches[0].conclusion == "success"
' <<<"$jobs" >/dev/null
artifact=$(gh api "repos/rustfs/rustfs/actions/artifacts/${ARTIFACT_ID}")
@@ -180,7 +172,7 @@ jobs:
assert outer.testzip() is None
with zipfile.ZipFile(io.BytesIO(outer.read(package))) as inner:
entries = inner.infolist()
expected = {'rustfs', 'rustfs.cpu-symbol-catalog.json', 'rustfs.cpu-symbol-catalog.sha256'}
expected = {'rustfs', 'rustfs-cli', 'rustfs.cpu-symbol-catalog.json', 'rustfs.cpu-symbol-catalog.sha256'}
assert {entry.filename for entry in entries} == expected and len(entries) == len(expected)
assert all(regular(entry) and 0 < entry.file_size <= 1073741824 for entry in entries)
assert inner.testzip() is None
@@ -206,7 +198,7 @@ jobs:
connect-harness/web/node_modules/.bin/playwright install --with-deps chromium
make -C connect-harness e2e-connected-dispatch-check
(cd connect-harness && ./tests/connectivity/profile-memory-evidence.test.sh)
TMPDIR="$GITHUB_WORKSPACE" RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
RUSTFS_WORKTREE="$GITHUB_WORKSPACE/rustfs-source" \
CONNECT_E2E_PROFILE_MEMORY_EVIDENCE="$GITHUB_WORKSPACE/profile-memory-evidence.json" \
make -C connect-harness e2e-connected E2E_SCENARIO=profile-memory
@@ -81,14 +81,6 @@ jobs:
ref: ${{ inputs.connect_sha }}
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
- name: Set up Docker Compose
uses: ./.github/actions/setup-docker-compose
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.14"
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
@@ -156,7 +148,7 @@ jobs:
[[ $(jq -r '.head_repository.full_name' <<<"$run") == "$GITHUB_REPOSITORY" ]]
[[ $(jq -r '.name' <<<"$run") == "Build and Release" ]]
expected_job='Build RustFS (linux-x86_64-gnu)'
expected_job='Build RustFS (linux-x86_64-gnu, sm-standard-4, x86_64-unknown-linux-gnu, false, linux, pyroscope)'
jobs=$(gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/actions/runs/${BUILD_RUN_ID}/jobs?per_page=100")
jq -e --arg name "$expected_job" '
[.[].jobs[] | select(.name == $name)] as $matches
@@ -209,7 +201,7 @@ jobs:
npm --prefix connect-harness/web ci
connect-harness/web/node_modules/.bin/playwright install --with-deps chromium
make -C connect-harness e2e-connected-dispatch-check
TMPDIR="$GITHUB_WORKSPACE" RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
RUSTFS_WORKTREE="$GITHUB_WORKSPACE/rustfs-source" \
CONNECT_E2E_PROFILE_THREADS_EVIDENCE="$GITHUB_WORKSPACE/profile-threads-service-job-evidence.json" \
make -C connect-harness e2e-connected E2E_SCENARIO=profile-threads
@@ -73,14 +73,6 @@ jobs:
ref: ${{ inputs.connect_sha }}
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
- name: Set up Docker Compose
uses: ./.github/actions/setup-docker-compose
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.14"
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
@@ -115,7 +107,7 @@ jobs:
[[ $(jq -r '.head_repository.full_name' <<<"$run") == "$GITHUB_REPOSITORY" ]]
[[ $(jq -r '.name' <<<"$run") == "Build and Release" ]]
expected_job='Build RustFS (linux-x86_64-gnu)'
expected_job='Build RustFS (linux-x86_64-gnu, sm-standard-4, x86_64-unknown-linux-gnu, false, linux, pyroscope)'
jobs=$(gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/actions/runs/${BUILD_RUN_ID}/jobs?per_page=100")
jq -e --arg name "$expected_job" '
[.[].jobs[] | select(.name == $name)] as $matches
@@ -162,7 +154,7 @@ jobs:
npm --prefix connect-harness/web ci
connect-harness/web/node_modules/.bin/playwright install --with-deps chromium
make -C connect-harness e2e-connected-dispatch-check
TMPDIR="$GITHUB_WORKSPACE" RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
RUSTFS_WORKTREE="$GITHUB_WORKSPACE/rustfs-source" \
CONNECT_E2E_TOP_API_EVIDENCE="$GITHUB_WORKSPACE/top-api-service-job-evidence.json" \
make -C connect-harness e2e-connected E2E_SCENARIO=top-api
@@ -68,14 +68,6 @@ jobs:
ref: ${{ inputs.connect_sha }}
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
- name: Set up Docker Compose
uses: ./.github/actions/setup-docker-compose
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.14"
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
@@ -146,7 +138,7 @@ jobs:
' <<<"$run" >/dev/null
jobs=$(gh api --paginate --slurp "repos/rustfs/rustfs/actions/runs/${BUILD_RUN_ID}/jobs?per_page=100")
jq -e '
[.[].jobs[] | select(.name | test("^Build RustFS \\(linux-x86_64-gnu\\)$"))] as $matches
[.[].jobs[] | select(.name | test("^Build RustFS \\(linux-x86_64-gnu, [a-z0-9-]+, x86_64-unknown-linux-gnu, false, linux, pyroscope\\)$"))] as $matches
| ($matches | length) == 1 and $matches[0].conclusion == "success"
' <<<"$jobs" >/dev/null
artifact=$(gh api "repos/rustfs/rustfs/actions/artifacts/${ARTIFACT_ID}")
@@ -191,7 +183,7 @@ jobs:
assert outer.testzip() is None
with zipfile.ZipFile(io.BytesIO(outer.read(package))) as inner:
entries = inner.infolist()
expected = {'rustfs', 'rustfs.cpu-symbol-catalog.json', 'rustfs.cpu-symbol-catalog.sha256'}
expected = {'rustfs', 'rustfs-cli', 'rustfs.cpu-symbol-catalog.json', 'rustfs.cpu-symbol-catalog.sha256'}
assert {entry.filename for entry in entries} == expected and len(entries) == len(expected)
assert all(regular(entry) and 0 < entry.file_size <= 1073741824 for entry in entries)
assert inner.testzip() is None
@@ -221,7 +213,7 @@ jobs:
npm --prefix connect-harness/web ci
connect-harness/web/node_modules/.bin/playwright install --with-deps chromium
make -C connect-harness e2e-connected-dispatch-check
TMPDIR="$GITHUB_WORKSPACE" RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
RUSTFS_WORKTREE="$GITHUB_WORKSPACE/rustfs-source" \
CONNECT_E2E_TOP_DISK_EVIDENCE="$GITHUB_WORKSPACE/top-disk-service-job-evidence.json" \
make -C connect-harness e2e-connected E2E_SCENARIO=top-disk
@@ -73,14 +73,6 @@ jobs:
ref: ${{ inputs.connect_sha }}
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
- name: Set up Docker Compose
uses: ./.github/actions/setup-docker-compose
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.14"
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
@@ -115,7 +107,7 @@ jobs:
[[ $(jq -r '.head_repository.full_name' <<<"$run") == "$GITHUB_REPOSITORY" ]]
[[ $(jq -r '.name' <<<"$run") == "Build and Release" ]]
expected_job='Build RustFS (linux-x86_64-gnu)'
expected_job='Build RustFS (linux-x86_64-gnu, sm-standard-4, x86_64-unknown-linux-gnu, false, linux, pyroscope)'
jobs=$(gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/actions/runs/${BUILD_RUN_ID}/jobs?per_page=100")
jq -e --arg name "$expected_job" '
[.[].jobs[] | select(.name == $name)] as $matches
@@ -162,7 +154,7 @@ jobs:
npm --prefix connect-harness/web ci
connect-harness/web/node_modules/.bin/playwright install --with-deps chromium
make -C connect-harness e2e-connected-dispatch-check
TMPDIR="$GITHUB_WORKSPACE" RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
RUSTFS_WORKTREE="$GITHUB_WORKSPACE/rustfs-source" \
CONNECT_E2E_TOP_LOCKS_EVIDENCE="$GITHUB_WORKSPACE/top-locks-service-job-evidence.json" \
make -C connect-harness e2e-connected E2E_SCENARIO=top-locks
@@ -73,14 +73,6 @@ jobs:
ref: ${{ inputs.connect_sha }}
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
- name: Set up Docker Compose
uses: ./.github/actions/setup-docker-compose
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.14"
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
@@ -115,7 +107,7 @@ jobs:
[[ $(jq -r '.head_repository.full_name' <<<"$run") == "$GITHUB_REPOSITORY" ]]
[[ $(jq -r '.name' <<<"$run") == "Build and Release" ]]
expected_job='Build RustFS (linux-x86_64-gnu)'
expected_job='Build RustFS (linux-x86_64-gnu, sm-standard-4, x86_64-unknown-linux-gnu, false, linux, pyroscope)'
jobs=$(gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/actions/runs/${BUILD_RUN_ID}/jobs?per_page=100")
jq -e --arg name "$expected_job" '
[.[].jobs[] | select(.name == $name)] as $matches
@@ -162,7 +154,7 @@ jobs:
npm --prefix connect-harness/web ci
connect-harness/web/node_modules/.bin/playwright install --with-deps chromium
make -C connect-harness e2e-connected-dispatch-check
TMPDIR="$GITHUB_WORKSPACE" RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
RUSTFS_WORKTREE="$GITHUB_WORKSPACE/rustfs-source" \
CONNECT_E2E_TOP_RPC_EVIDENCE="$GITHUB_WORKSPACE/top-rpc-service-job-evidence.json" \
make -C connect-harness e2e-connected E2E_SCENARIO=top-rpc
+3 -3
View File
@@ -83,7 +83,7 @@ jobs:
(github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_branch != 'main')
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 30
outputs:
should_build: ${{ steps.check.outputs.should_build }}
@@ -501,7 +501,7 @@ jobs:
name: Scan Docker Images
needs: [ build-check, build-docker ]
if: needs.build-check.outputs.should_build == 'true' && needs.build-check.outputs.should_push == 'true'
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 30
permissions:
contents: read
@@ -556,7 +556,7 @@ jobs:
name: Docker Build Summary
needs: [ build-check, build-docker ]
if: always() && needs.build-check.outputs.should_build == 'true'
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 10
steps:
- name: Docker build completion summary
+1 -1
View File
@@ -371,7 +371,7 @@ jobs:
name: Upstream HEAD classification canary
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
continue-on-error: true
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 20
steps:
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
+55 -58
View File
@@ -46,11 +46,14 @@ concurrency:
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
UPGRADE_SOURCE_VERSION: 1.0.0-rc.5
UPGRADE_SOURCE_ASSET: rustfs-linux-x86_64-gnu-v1.0.0-rc.5.zip
UPGRADE_SOURCE_SHA256: 3ee8df71e8edcfada533be452c4135868f697bc515460ae97b027313eade7a3d
jobs:
build:
name: Build upgrade candidate
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 60
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
@@ -63,11 +66,19 @@ jobs:
- name: Setup Rust environment
uses: ./.github/actions/setup
with:
cache-shared-key: e2e-upgrade-server-hosted
cache-shared-key: e2e-upgrade-server
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
install-build-packaging-tools: "false"
install-test-tools: "false"
- name: Download pinned previous release
run: |
mkdir -p target/debug
archive="target/debug/$UPGRADE_SOURCE_ASSET"
curl --fail --location --retry 3 --output "$archive" \
"https://github.com/${GITHUB_REPOSITORY}/releases/download/${UPGRADE_SOURCE_VERSION}/${UPGRADE_SOURCE_ASSET}"
echo "$UPGRADE_SOURCE_SHA256 $archive" | sha256sum --check --strict
- name: Build current RustFS binary
run: python3 scripts/e2e_binary.py build
@@ -78,31 +89,49 @@ jobs:
path: |
target/debug/rustfs
target/debug/rustfs.e2e.json
target/debug/${{ env.UPGRADE_SOURCE_ASSET }}
if-no-files-found: error
retention-days: 3
compression-level: 0
overwrite: true
upgrade:
name: Upgrade compatibility (${{ matrix.source.version }})
name: ${{ matrix.name }}
needs: build
runs-on: ubuntu-latest
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
source:
- version: 1.0.0-rc.5
asset: rustfs-linux-x86_64-gnu-v1.0.0-rc.5.zip
sha256: 3ee8df71e8edcfada533be452c4135868f697bc515460ae97b027313eade7a3d
- version: 1.0.1
asset: rustfs-linux-x86_64-gnu-v1.0.1.zip
sha256: 9742e183cb0dfb0d48fa909797af572fe47b7f827569036baa4156e0d3958ee5
include:
# The two `_from_rc2_` tests keep their names: they assert
# release-independent object contracts and pass unchanged against the
# newer pinned source, so renaming them would only churn history and
# the CI required-check names. UPGRADE_SOURCE_VERSION above is the
# single source of truth for which release they actually run against.
- name: Direct upgrade from the previous release
test: direct_upgrade_from_rc2_preserves_object_contracts
artifact: direct-upgrade
- name: Mixed-version rolling upgrade from the previous release
test: rolling_upgrade_from_rc2_preserves_mixed_version_contracts
artifact: mixed-version-upgrade
- name: Bucket configuration survives the upgrade
test: direct_upgrade_from_previous_release_preserves_bucket_configuration
artifact: bucket-config-upgrade
- name: Rollback reads current bucket metadata
test: rollback_to_previous_release_reads_current_bucket_metadata
artifact: bucket-config-rollback
- name: ODM configuration recovery after rc.5 rollback
test: rc5_rollback_requires_restoring_odm_configuration
artifact: odm-config-rollback
- name: Multipart layouts survive the rc.5 upgrade
test: direct_upgrade_from_rc5_preserves_multipart_layouts
artifact: multipart-layout-upgrade
- name: rc.5 multipart replication baseline
test: rc5_baseline_replicates_multipart_layouts
artifact: multipart-layout-baseline
runs-on: sm-standard-2
timeout-minutes: 60
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
UPGRADE_SOURCE_VERSION: ${{ matrix.source.version }}
UPGRADE_SOURCE_ASSET: ${{ matrix.source.asset }}
UPGRADE_SOURCE_SHA256: ${{ matrix.source.sha256 }}
steps:
- name: Checkout repository
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
@@ -112,8 +141,9 @@ jobs:
- name: Setup Rust environment
uses: ./.github/actions/setup
with:
cache-shared-key: e2e-upgrade-tests-hosted
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
cache-shared-key: e2e-upgrade-tests
# One main-branch consumer warms the shared test-harness dependencies.
cache-save-if: ${{ github.ref == 'refs/heads/main' && matrix.artifact == 'direct-upgrade' }}
install-build-packaging-tools: "false"
install-test-tools: "false"
@@ -133,8 +163,6 @@ jobs:
set -euo pipefail
mkdir -p "$SOURCE_DIR"
archive="target/debug/$UPGRADE_SOURCE_ASSET"
curl --fail --location --retry 3 --output "$archive" \
"https://github.com/rustfs/rustfs/releases/download/${UPGRADE_SOURCE_VERSION}/${UPGRADE_SOURCE_ASSET}"
echo "$UPGRADE_SOURCE_SHA256 $archive" | sha256sum --check --strict
unzip -q "$archive" -d "$SOURCE_DIR"
chmod +x "$SOURCE_DIR/rustfs"
@@ -142,53 +170,22 @@ jobs:
echo "RUSTFS_UPGRADE_SOURCE_BINARY=$SOURCE_DIR/rustfs" >> "$GITHUB_ENV"
echo "RUSTFS_E2E_LOG_DIR=$RUNNER_TEMP/rustfs-upgrade-logs" >> "$GITHUB_ENV"
# Reuse one compiled harness, while keeping each case in its own process.
# Continue after a failure to retain the former matrix's complete verdict.
- name: Run upgrade compatibility tests
shell: bash
- name: Run upgrade compatibility test
env:
RUSTFS_SCANNER_HEAL_G09_EVIDENCE_DIR: ${{ runner.temp }}/rustfs-upgrade-g09-evidence/${{ matrix.artifact }}
NO_PROXY: 127.0.0.1,localhost,::1
run: |
export no_proxy="$NO_PROXY"
cases=(
direct-upgrade:direct_upgrade_from_previous_release_preserves_object_contracts
mixed-version-upgrade:rolling_upgrade_from_previous_release_preserves_mixed_version_contracts
bucket-config-upgrade:direct_upgrade_from_previous_release_preserves_bucket_configuration
bucket-config-rollback:rollback_to_previous_release_reads_current_bucket_metadata
multipart-layout-upgrade:direct_upgrade_from_previous_release_preserves_multipart_layouts
multipart-layout-baseline:previous_release_baseline_replicates_multipart_layouts
)
if [[ "$UPGRADE_SOURCE_VERSION" == "1.0.0-rc.5" ]]; then
cases+=(odm-config-rollback:rc5_rollback_requires_restoring_odm_configuration)
else
cases+=(legacy-delete-reservation:stable_upgrade_deletes_foreign_reservations_without_losing_live_objects)
fi
status=0
for entry in "${cases[@]}"; do
artifact="${entry%%:*}"
test="${entry#*:}"
export RUSTFS_SCANNER_HEAL_G09_EVIDENCE_DIR="$RUNNER_TEMP/rustfs-upgrade-g09-evidence/$artifact"
export RUSTFS_E2E_LOG_DIR="$RUNNER_TEMP/rustfs-upgrade-logs/$artifact"
mkdir -p "$RUSTFS_E2E_LOG_DIR"
echo "::group::$test"
if python3 scripts/e2e_binary.py run -- cargo test --locked -p e2e_test \
"upgrade_compatibility_test::$test" -- --ignored --exact --nocapture \
2>&1 | tee "$RUSTFS_E2E_LOG_DIR/test.log"; then
echo "PASS: $test"
else
echo "::error::Upgrade compatibility failed: $test"
status=1
fi
echo "::endgroup::"
done
exit "$status"
python3 scripts/e2e_binary.py run -- cargo test --locked -p e2e_test \
"upgrade_compatibility_test::${{ matrix.test }}" \
-- --ignored --exact --nocapture
- name: Upload scanner/heal G09 evidence
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: upgrade-g09-evidence-${{ matrix.source.version }}-${{ github.run_number }}
path: ${{ runner.temp }}/rustfs-upgrade-g09-evidence
name: ${{ matrix.artifact }}-g09-evidence-${{ github.run_number }}
path: ${{ runner.temp }}/rustfs-upgrade-g09-evidence/${{ matrix.artifact }}
if-no-files-found: ignore
retention-days: 14
@@ -196,7 +193,7 @@ jobs:
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: upgrade-server-logs-${{ matrix.source.version }}-${{ github.run_number }}
name: ${{ matrix.artifact }}-server-logs-${{ github.run_number }}
path: ${{ runner.temp }}/rustfs-upgrade-logs
if-no-files-found: warn
retention-days: 14
+1 -1
View File
@@ -60,7 +60,7 @@ jobs:
cancel-closed-pr-runs:
name: Cancel Closed PR Runs
if: github.event_name == 'pull_request' && github.event.action == 'closed'
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 10
steps:
- name: Explain cancellation run
+65 -31
View File
@@ -6,20 +6,29 @@ name: Gateway Integration Sync
# Merges `main` into the long-lived `gateway/integration` branch every weekday
# so the s3s -> gateway migration (rustfs/backlog#2734, task T0.1 in #2736)
# never accumulates merge debt. A clean merge lands as a `--no-ff` merge commit
# pushed straight to the branch. A conflicting merge is aborted and fails the
# run after its summary records the source and target SHAs, run URL, and exact
# conflicting paths. It creates no sync branch or pull request. Resolve the
# merge in an isolated worktree based on the latest target, preserve the
# two-parent merge commit, and push only after the required checks pass and the
# target still matches the merge's first parent. If the target moves, restart
# from its new head.
# pushed straight to the branch. A conflicting merge is aborted and surfaced as
# a pull request from `sync/<source>-<date>`, whose head is the un-merged
# source commit, so the conflict is resolved the same day, in review, with no
# other change riding along. The run itself stays green in that case: the pull
# request is the signal. Any other failure (push rejected, pull request not
# created) fails the run and, on the schedule, files the shared failure issue.
#
# Write scope: only `gateway/integration` is ever pushed. The push uses a
# one-hour installation token of the organisation's GitHub App (contents:
# write) because that app is the only non-admin bypass actor of the
# Resolving a conflict pull request: merge `origin/gateway/integration` into
# the sync branch, resolve, push, get the review. Land it with a fast-forward
# push of the resolved merge commit to `gateway/integration`, not with the
# squash button: a squash drops the merge ancestry and the same hunks conflict
# again on the next run.
#
# Write scope: only `gateway/integration` and `sync/*` are ever pushed, and
# both push targets below are literal refs. The pushes use a one-hour
# installation token of the organisation's own GitHub App (contents: write)
# because that app is the only non-admin bypass actor of the
# `gateway/integration` ruleset; GITHUB_TOKEN cannot be a ruleset bypass actor,
# so it cannot push through the required reviews and checks. GITHUB_TOKEN is
# read-only here.
# so it cannot push through the required reviews and checks. The conflict pull
# request is opened with FLAKE_UPDATE_TOKEN, the PAT that already opens the
# weekly flake.lock pull requests, because this repository keeps "Allow GitHub
# Actions to create and approve pull requests" switched off. GITHUB_TOKEN is
# therefore read-only here.
#
# `schedule` fires only from the default branch, so the cron is live once this
# file exists on `main`. `workflow_dispatch` runs from any ref that carries the
@@ -85,7 +94,6 @@ jobs:
id: merge
env:
SOURCE_REF: ${{ inputs.source_ref || 'main' }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set -euo pipefail
if ! [[ "$SOURCE_REF" =~ ^[A-Za-z0-9][A-Za-z0-9._/-]*$ ]] \
@@ -111,25 +119,13 @@ jobs:
fi
exit 0
fi
conflicts=$(git diff --name-only --diff-filter=U)
if [[ -z "$conflicts" ]]; then
git merge --abort || true
git diff --name-only --diff-filter=U > "$RUNNER_TEMP/conflicts.txt"
git merge --abort
if [[ ! -s "$RUNNER_TEMP/conflicts.txt" ]]; then
echo "::error::git merge failed without reporting conflicting paths; this is not a conflict"
exit 1
fi
{
echo "## Gateway integration sync stopped on conflicts"
echo
echo "- Run: ${RUN_URL}"
echo "- Source: \`${SOURCE_REF}\` at ${source_sha}"
echo "- Target: \`${TARGET_BRANCH}\` at ${target_sha}"
echo
echo "Conflicting files:"
printf '%s\n' "$conflicts" | sed 's/^/- /'
} >> "$GITHUB_STEP_SUMMARY"
git merge --abort
echo "::error::${SOURCE_REF} cannot be merged cleanly into ${TARGET_BRANCH}; no branch or pull request was created"
exit 1
echo "result=conflict" >> "$GITHUB_OUTPUT"
- name: Push the merge commit
if: steps.merge.outputs.result == 'merged'
@@ -141,12 +137,50 @@ jobs:
git push origin HEAD:refs/heads/gateway/integration
echo "Merged \`${SOURCE_REF}\` (${SOURCE_SHA}) into \`${TARGET_BRANCH}\` as $(git rev-parse HEAD)." >> "$GITHUB_STEP_SUMMARY"
- name: Open the conflict pull request
if: steps.merge.outputs.result == 'conflict'
env:
GH_TOKEN: ${{ secrets.FLAKE_UPDATE_TOKEN }}
SOURCE_REF: ${{ steps.merge.outputs.source_ref }}
SOURCE_SHA: ${{ steps.merge.outputs.source_sha }}
TARGET_SHA: ${{ steps.merge.outputs.target_sha }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set -euo pipefail
date_utc=$(date -u +%Y-%m-%d)
sync_branch="sync/${SOURCE_REF//\//-}-${date_utc}"
git push origin "refs/remotes/origin/${SOURCE_REF}:refs/heads/${sync_branch}"
{
echo "The scheduled merge of \`${SOURCE_REF}\` into \`${TARGET_BRANCH}\` stopped on conflicts and was aborted; \`${TARGET_BRANCH}\` is unchanged."
echo
echo "This branch is the un-merged \`${SOURCE_REF}\` head. Merge \`origin/${TARGET_BRANCH}\` into it, resolve the files below, push, and get the review. Land the resolved merge commit with a fast-forward push to \`${TARGET_BRANCH}\`; a squash merge drops the merge ancestry and the same hunks conflict again on the next run. Do not include any change that is not conflict resolution."
echo
echo "- Run: ${RUN_URL}"
echo "- Source: \`${SOURCE_REF}\` at ${SOURCE_SHA}"
echo "- Target: \`${TARGET_BRANCH}\` at ${TARGET_SHA}"
echo
echo "## Conflicting files"
echo
echo "Output of \`git diff --name-only --diff-filter=U\` captured before \`git merge --abort\`:"
echo
sed 's/^/- /' "$RUNNER_TEMP/conflicts.txt"
} > "$RUNNER_TEMP/pr-body.md"
title="sync: merge ${SOURCE_REF} ${date_utc} (conflicts)"
existing=$(gh pr list --repo "$GITHUB_REPOSITORY" --base "$TARGET_BRANCH" --head "$sync_branch" --state open --json number --jq '.[0].number // empty')
if [[ -n "$existing" ]]; then
gh pr edit "$existing" --repo "$GITHUB_REPOSITORY" --title "$title" --body-file "$RUNNER_TEMP/pr-body.md"
pr_url=$(gh pr view "$existing" --repo "$GITHUB_REPOSITORY" --json url --jq .url)
else
pr_url=$(gh pr create --repo "$GITHUB_REPOSITORY" --base "$TARGET_BRANCH" --head "$sync_branch" --title "$title" --body-file "$RUNNER_TEMP/pr-body.md")
fi
echo "Conflicts in \`${SOURCE_REF}\` -> \`${TARGET_BRANCH}\`; pull request: ${pr_url}" >> "$GITHUB_STEP_SUMMARY"
alert-on-failure:
name: Alert on scheduled failure
needs: [sync]
# Same ci-8 mechanism audit.yml and coverage.yml use: a scheduled run that
# could not push or encountered merge conflicts must not stay visible only
# in the Actions tab.
# could not push or could not open the conflict pull request must not stay
# visible only in the Actions tab.
if: github.repository == 'rustfs/rustfs' && (always() && github.event_name == 'schedule' && contains(needs.*.result, 'failure'))
runs-on: ubuntu-latest
timeout-minutes: 10
+2 -2
View File
@@ -31,7 +31,7 @@ permissions:
jobs:
build-helm-package:
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 30
if: |
(github.event_name == 'workflow_dispatch' && !contains(github.event.inputs.version, '-preview')) ||
@@ -166,7 +166,7 @@ jobs:
retention-days: 1
publish-helm-package:
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 30
needs: [ build-helm-package ]
if: needs.build-helm-package.result == 'success'
+1 -1
View File
@@ -32,7 +32,7 @@ permissions:
jobs:
build:
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 10
steps:
- uses: usthe/issues-translate-action@b41f55ddc81d7d54bd542a4f289fe28ec081898e # v2.7
+3 -14
View File
@@ -45,7 +45,7 @@ jobs:
resolve-source:
if: github.repository == 'rustfs/rustfs'
name: Resolve nightly source
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 10
outputs:
source_sha: ${{ steps.source.outputs.sha }}
@@ -80,24 +80,13 @@ jobs:
- name: Setup Rust environment
uses: ./.github/actions/setup
with:
cache-shared-key: build-x86_64-unknown-linux-gnu-glibc-2.31
cache-shared-key: build-x86_64-unknown-linux-gnu
cache-save-if: 'false'
install-cross-tools: 'true'
target: x86_64-unknown-linux-gnu
install-build-packaging-tools: 'false'
install-test-tools: 'false'
- name: Build RustFS
# Pin the glibc baseline (`<target>.<glibc>` for cargo-zigbuild) so
# nightlies run on older distros such as RHEL/Rocky 9 (glibc 2.34); #8062.
run: cargo zigbuild --release --locked --target x86_64-unknown-linux-gnu.2.31 -p rustfs --bins
- name: Verify glibc symbol baseline
run: |
python3 scripts/release/check_glibc.py 2.31 \
target/x86_64-unknown-linux-gnu/release/rustfs \
target/x86_64-unknown-linux-gnu/release/rustfs-cli
target/x86_64-unknown-linux-gnu/release/rustfs --version | grep -Fx 'build target : x86_64-unknown-linux-gnu'
run: cargo build --release --locked --target x86_64-unknown-linux-gnu -p rustfs --bins
- name: Build DEB package
id: deb
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
update-flake:
if: github.repository == 'rustfs/rustfs'
name: Update flake.lock
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 90
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
+1 -1
View File
@@ -54,7 +54,7 @@ jobs:
cancel-closed-pr-runs:
name: Cancel Closed PR Runs
if: github.event_name == 'pull_request' && github.event.action == 'closed'
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 10
steps:
- name: Explain cancellation run
+2 -4
View File
@@ -20,9 +20,8 @@ on:
- ".github/workflows/oidc-keycloak.yml"
- "crates/config/src/constants/oidc.rs"
- "crates/iam/src/federation/**"
- "crates/iam/src/oidc/**"
- "crates/iam/src/oidc.rs"
- "rustfs/src/admin/handlers/oidc.rs"
- "rustfs/src/admin/service/oidc_config.rs"
- "rustfs/src/admin/handlers/sts.rs"
- "scripts/test/oidc_keycloak_live.sh"
- "scripts/test/fixtures/keycloak-rustfs-ci-realm.json"
@@ -32,9 +31,8 @@ on:
- ".github/workflows/oidc-keycloak.yml"
- "crates/config/src/constants/oidc.rs"
- "crates/iam/src/federation/**"
- "crates/iam/src/oidc/**"
- "crates/iam/src/oidc.rs"
- "rustfs/src/admin/handlers/oidc.rs"
- "rustfs/src/admin/service/oidc_config.rs"
- "rustfs/src/admin/handlers/sts.rs"
- "scripts/test/oidc_keycloak_live.sh"
- "scripts/test/fixtures/keycloak-rustfs-ci-realm.json"
@@ -186,7 +186,7 @@ jobs:
cloud-source:
name: ${{ matrix.provider }} source (three-case minimum)
if: github.repository == 'rustfs/rustfs'
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 45
strategy:
fail-fast: false
+2 -4
View File
@@ -618,10 +618,8 @@ jobs:
summary:
name: Summary
needs: [ resolve, package ]
# Every completed Build and Release run triggers this workflow; when
# resolve declined it (not a release tag), there is nothing to summarise.
if: always() && needs.resolve.result != 'skipped'
runs-on: ubuntu-latest
if: always()
runs-on: sm-standard-2
timeout-minutes: 5
steps:
- name: Print summary
+5 -436
View File
@@ -20,65 +20,13 @@ on:
workflow_dispatch:
inputs:
build_run_id:
description: Successful nightly or validation package run; empty for package-only build
description: Successful nightly build run on main
type: string
required: false
default: ''
required: true
build_run_attempt:
description: Exact successful build attempt
type: string
required: false
default: ''
validation_package_sha:
description: Exact source SHA for an isolated package-only build
type: string
required: false
default: ''
validation_case:
description: Canonical case ID, environment.inspect for read-only resources, or empty for functional chain
type: string
required: false
default: ''
auto_testing_sha:
description: Exact auto-testing commit for a validation case
type: string
required: false
default: ''
validation_tools_only:
description: Build candidate-bound validation tools without running a fleet case
type: boolean
required: false
default: false
validation_native_tools:
description: Include exact-source native regressions in a tools-only build
type: boolean
required: false
default: false
validation_shared_tools:
description: Include the controlled C10 shared-reference encoder in a tools-only build
type: boolean
required: false
default: false
validation_manager_tools:
description: Include exact checkpoint/MRF/incarnation regressions in a tools-only build
type: boolean
required: false
default: false
validation_cleanup_run_id:
description: Failed validation run whose supported retained environment needs evidence-based finalization
type: string
required: false
default: ''
validation_tools_run_id:
description: Successful tool build run; required by Heal physical metadata checks
type: string
required: false
default: ''
validation_tools_run_attempt:
description: Exact tool build attempt
type: string
required: false
default: '1'
required: true
workflow_run:
workflows: ["Nightly GNU Build"]
types: [completed]
@@ -88,47 +36,12 @@ permissions:
actions: read
concurrency:
group: ${{ github.event_name == 'workflow_dispatch' && inputs.validation_package_sha != '' && format('rustfs-functional-validation-package-{0}', inputs.validation_package_sha) || github.event_name == 'workflow_dispatch' && inputs.validation_tools_only && 'rustfs-functional-validation-tools' || 'rustfs-functional-chain-runs' }}
group: rustfs-functional-chain-runs
cancel-in-progress: false
jobs:
validation-package-request:
if: ${{ github.event_name == 'workflow_dispatch' && inputs.validation_package_sha != '' }}
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Validate exclusive package-only request
env:
PACKAGE_INPUTS: ${{ toJSON(inputs) }}
run: |
python3 - <<'PY'
import json
import os
import re
value = json.loads(os.environ["PACKAGE_INPUTS"])
if re.fullmatch(r"[0-9a-f]{40}", value["validation_package_sha"]) is None:
raise ValueError("package build requires an exact source SHA")
exclusive = ("build_run_id", "build_run_attempt", "validation_case", "auto_testing_sha",
"validation_tools_only", "validation_native_tools", "validation_shared_tools",
"validation_manager_tools", "validation_cleanup_run_id", "validation_tools_run_id")
if any(value.get(name) for name in exclusive):
raise ValueError("package-only build cannot select existing candidate, tools, fleet or recovery execution")
PY
validation-package:
needs: validation-package-request
if: ${{ github.event_name == 'workflow_dispatch' && inputs.validation_package_sha != '' }}
uses: ./.github/workflows/rustfs-validation-package.yml
with:
source_sha: ${{ inputs.validation_package_sha }}
secrets:
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
prepare:
if: ${{ (github.event_name == 'workflow_dispatch' && 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 == '') || github.event.workflow_run.event == 'schedule' }}
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.event == 'schedule' }}
runs-on: smoke-testing
timeout-minutes: 10
outputs:
@@ -176,350 +89,6 @@ jobs:
echo "performance_ready=offline" >> "$GITHUB_OUTPUT"
fi
validation:
if: ${{ github.event_name == 'workflow_dispatch' && 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 != '') }}
runs-on: ${{ inputs.validation_tools_only && 'ubuntu-latest' || 'smoke-testing' }}
timeout-minutes: 90
outputs:
source_sha: ${{ steps.candidate.outputs.source_sha }}
concurrency:
group: ${{ inputs.validation_tools_only && 'rustfs-validation-tools-resolver' || 'rustfs-shared-functional-tests-v2' }}
cancel-in-progress: false
env:
RUSTFS_ACCESS_KEY: ${{ !inputs.validation_tools_only && secrets.RUSTFS_ACCESS_KEY || '' }}
RUSTFS_SECRET_KEY: ${{ !inputs.validation_tools_only && secrets.RUSTFS_SECRET_KEY || '' }}
RUSTFS_API_ENDPOINT: ${{ !inputs.validation_tools_only && (secrets.RUSTFS_API_ENDPOINT || vars.RUSTFS_API_ENDPOINT || vars.RUSTFS_RC_ENDPOINT) || '' }}
RUSTFS_NODES: ${{ !inputs.validation_tools_only && (secrets.RUSTFS_NODES || vars.RUSTFS_NODES) || '' }}
RUSTFS_SSH_USER: ${{ !inputs.validation_tools_only && (secrets.RUSTFS_SSH_USER || vars.RUSTFS_SSH_USER) || '' }}
steps:
- name: Checkout candidate resolver
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Initialize validation evidence
run: |
set -euo pipefail
umask 077
VALIDATION_ROOT="${RUNNER_TEMP}/rustfs-validation-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
mkdir -- "${VALIDATION_ROOT}"
printf 'VALIDATION_ROOT=%s\n' "${VALIDATION_ROOT}" >> "${GITHUB_ENV}"
- name: Resolve immutable candidate
id: candidate
env:
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
BUILD_RUN_ID: ${{ inputs.build_run_id }}
BUILD_RUN_ATTEMPT: ${{ inputs.build_run_attempt }}
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
CLEANUP_RUN_ID: ${{ inputs.validation_cleanup_run_id }}
SELECTED_CASE: ${{ inputs.validation_case }}
TOOLS_ONLY: ${{ inputs.validation_tools_only }}
NATIVE_TOOLS: ${{ inputs.validation_native_tools }}
SHARED_TOOLS: ${{ inputs.validation_shared_tools }}
MANAGER_TOOLS: ${{ inputs.validation_manager_tools }}
run: |
set -euo pipefail
python3 - <<'PY'
import json
import os
from pathlib import Path
import re
from scripts.resolve_functional_candidate import resolve
revision = os.environ["AUTO_TESTING_SHA"]
cleanup = os.environ["CLEANUP_RUN_ID"]
if os.environ["TOOLS_ONLY"] == "true" and (cleanup or os.environ["SELECTED_CASE"]):
raise ValueError("tools-only execution cannot select a fleet case or cleanup")
if os.environ["NATIVE_TOOLS"] == "true" and (os.environ["TOOLS_ONLY"] != "true" or cleanup or os.environ["SELECTED_CASE"]):
raise ValueError("native regressions require tools-only execution, without a fleet case or cleanup")
if os.environ["SHARED_TOOLS"] == "true" and (os.environ["TOOLS_ONLY"] != "true" or cleanup or os.environ["SELECTED_CASE"]):
raise ValueError("shared fixture builds require tools-only execution, without a fleet case or cleanup")
if os.environ["MANAGER_TOOLS"] == "true" and (os.environ["TOOLS_ONLY"] != "true" or cleanup or os.environ["SELECTED_CASE"]):
raise ValueError("manager regressions require tools-only execution, without a fleet case or cleanup")
if cleanup and (not cleanup.isdigit() or int(cleanup) <= 0 or os.environ["SELECTED_CASE"] or os.environ["TOOLS_ONLY"] == "true"):
raise ValueError("cleanup requires one exact failed run and is exclusive with case/tool execution")
if re.fullmatch(r"[0-9a-f]{40}", revision) is None:
raise ValueError("validation requires an exact auto-testing commit SHA")
candidate = resolve(int(os.environ["BUILD_RUN_ID"]), int(os.environ["BUILD_RUN_ATTEMPT"]), allow_validation=True)
manifest = candidate["manifest"]
root = Path(os.environ["VALIDATION_ROOT"])
(root / "candidate-source.json").write_text(json.dumps(candidate, sort_keys=True) + "\n")
(root / "candidate.json").write_text(json.dumps({
"package_url": manifest["package_url"],
"package_sha256": manifest["package_sha256"],
"source_sha": manifest["source_sha"],
}, sort_keys=True) + "\n")
with open(os.environ["GITHUB_OUTPUT"], "a") as output:
output.write("source_sha=" + manifest["source_sha"] + "\n")
PY
- name: Checkout exact auto-testing revision
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
repository: rustfs/auto-testing
ref: ${{ inputs.auto_testing_sha }}
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
path: auto-testing
persist-credentials: false
- name: Install validation Python client
if: ${{ !inputs.validation_tools_only && (startsWith(inputs.validation_case, 'ilm.') || startsWith(inputs.validation_case, 'pool.') || startsWith(inputs.validation_case, 'bucket-replication.') || startsWith(inputs.validation_case, 'site-replication.') || startsWith(inputs.validation_case, 'heal.')) }}
run: |
set -euo pipefail
python3 -m venv "${RUNNER_TEMP}/rustfs-validation-python"
"${RUNNER_TEMP}/rustfs-validation-python/bin/python" -m pip install \
-r auto-testing/scripts/validation_ilm/requirements.txt
printf '%s\n' "${RUNNER_TEMP}/rustfs-validation-python/bin" >> "${GITHUB_PATH}"
- name: Validate Heal tool build identity
if: ${{ !inputs.validation_tools_only && startsWith(inputs.validation_case, 'heal.') }}
env:
GH_TOKEN: ${{ github.token }}
TOOL_RUN_ID: ${{ inputs.validation_tools_run_id }}
TOOL_RUN_ATTEMPT: ${{ inputs.validation_tools_run_attempt }}
run: |
set -euo pipefail
[[ "${TOOL_RUN_ID}" =~ ^[1-9][0-9]*$ ]]
[[ "${TOOL_RUN_ATTEMPT}" =~ ^[1-9][0-9]*$ ]]
gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${TOOL_RUN_ID}/attempts/${TOOL_RUN_ATTEMPT}" \
> "${VALIDATION_ROOT}/tool-run.json"
python3 - <<'PY'
import json
import os
from pathlib import Path
value = json.loads((Path(os.environ["VALIDATION_ROOT"]) / "tool-run.json").read_text())
if (value.get("conclusion") != "success" or value.get("status") != "completed"
or value.get("run_attempt") != int(os.environ["TOOL_RUN_ATTEMPT"])
or value.get("path") not in {".github/workflows/rustfs-functional-chain.yml", ".github/workflows/rustfs-validation-tools.yml"}):
raise ValueError("Heal tool run is not an exact successful validation build attempt")
PY
- name: Download pinned Heal metadata inspector
if: ${{ !inputs.validation_tools_only && startsWith(inputs.validation_case, 'heal.') }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
github-token: ${{ github.token }}
run-id: ${{ inputs.validation_tools_run_id }}
name: rustfs-validation-tools-${{ inputs.validation_tools_run_id }}-${{ inputs.validation_tools_run_attempt }}
path: ${{ runner.temp }}/rustfs-validation-tools-${{ github.run_id }}-${{ github.run_attempt }}
- name: Verify Heal metadata inspector
if: ${{ !inputs.validation_tools_only && startsWith(inputs.validation_case, 'heal.') }}
run: |
set -euo pipefail
TOOL_DIRECTORY="${RUNNER_TEMP}/rustfs-validation-tools-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
python3 -B auto-testing/scripts/validation_heal/inspector.py \
--directory "${TOOL_DIRECTORY}" --candidate "${VALIDATION_ROOT}/candidate.json" \
> "${VALIDATION_ROOT}/tool-manifest.json"
printf 'VALIDATION_HEAL_TOOL_DIRECTORY=%s\n' "${TOOL_DIRECTORY}" >> "${GITHUB_ENV}"
- name: Select exact-source race native evidence
id: race_native
if: ${{ inputs.validation_case == 'heal.race-unversioned-overwrite' || inputs.validation_case == 'heal.race-versioned-put' || inputs.validation_case == 'heal.race-version-delete' || inputs.validation_case == 'heal.race-markers-and-batch-delete' }}
env:
CANDIDATE_SHA: ${{ steps.candidate.outputs.source_sha }}
VALIDATION_CASE_ID: ${{ inputs.validation_case }}
run: |
set -euo pipefail
required=false
if [ -f auto-testing/scripts/validation_heal/race_native.py ]; then
status=0
python3 -B auto-testing/scripts/validation_heal/race_native.py supports \
--commit "${CANDIDATE_SHA}" --case-id "${VALIDATION_CASE_ID}" || status=$?
if [ "${status}" -eq 0 ]; then
required=true
elif [ "${status}" -ne 2 ]; then
exit "${status}"
fi
fi
reviewed=false
case "${CANDIDATE_SHA}:${VALIDATION_CASE_ID}" in
6afdec9fe1b0b88007f2e5b445c19bf73d51d3ca:heal.race-unversioned-overwrite | \
bd0671ba781d8ebf509f4f03533228dfb44496f1:heal.race-unversioned-overwrite | \
bd0671ba781d8ebf509f4f03533228dfb44496f1:heal.race-versioned-put | \
bd0671ba781d8ebf509f4f03533228dfb44496f1:heal.race-version-delete | \
8380466f3a27c3c3ef4115084178a0ab99a78f32:heal.race-unversioned-overwrite | \
8380466f3a27c3c3ef4115084178a0ab99a78f32:heal.race-versioned-put | \
8380466f3a27c3c3ef4115084178a0ab99a78f32:heal.race-version-delete | \
8380466f3a27c3c3ef4115084178a0ab99a78f32:heal.race-markers-and-batch-delete) reviewed=true ;;
esac
if [ "${reviewed}" = true ] && [ "${required}" != true ]; then
printf 'Race candidate requires its reviewed native collector before fleet execution\n' >&2
exit 1
fi
printf 'required=%s\n' "${required}" >> "${GITHUB_OUTPUT}"
- name: Download pinned native storage evidence
if: ${{ inputs.validation_case == 'heal.conflicting-subquorum-authority' || inputs.validation_case == 'heal.bucket-incarnation-recreation' || steps.race_native.outputs.required == 'true' }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
github-token: ${{ github.token }}
run-id: ${{ inputs.validation_tools_run_id }}
name: rustfs-validation-native-${{ inputs.validation_tools_run_id }}-${{ inputs.validation_tools_run_attempt }}
path: ${{ runner.temp }}/rustfs-validation-native-${{ github.run_id }}-${{ github.run_attempt }}
- name: Verify C07 native source and exact test evidence
if: ${{ inputs.validation_case == 'heal.conflicting-subquorum-authority' }}
run: |
set -euo pipefail
NATIVE_DIRECTORY="${RUNNER_TEMP}/rustfs-validation-native-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
cp "${VALIDATION_ROOT}/tool-run.json" "${NATIVE_DIRECTORY}/tool-run.json"
python3 -B auto-testing/scripts/validation_heal/authority.py \
--directory "${NATIVE_DIRECTORY}" --candidate "${VALIDATION_ROOT}/candidate.json" \
> "${VALIDATION_ROOT}/native-authority-manifest.json"
printf 'VALIDATION_HEAL_NATIVE_DIRECTORY=%s\n' "${NATIVE_DIRECTORY}" >> "${GITHUB_ENV}"
- name: Verify C11 physical storage and exact final-rename barrier
if: ${{ inputs.validation_case == 'heal.bucket-incarnation-recreation' }}
run: |
set -euo pipefail
NATIVE_DIRECTORY="${RUNNER_TEMP}/rustfs-validation-native-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
cp "${VALIDATION_ROOT}/tool-run.json" "${NATIVE_DIRECTORY}/tool-run.json"
python3 -B auto-testing/scripts/validation_heal/incarnation_native.py \
--directory "${NATIVE_DIRECTORY}" --candidate "${VALIDATION_ROOT}/candidate.json" \
--testing-commit "${{ inputs.auto_testing_sha }}" \
> "${VALIDATION_ROOT}/native-incarnation-manifest.json"
printf 'VALIDATION_HEAL_NATIVE_DIRECTORY=%s\n' "${NATIVE_DIRECTORY}" >> "${GITHUB_ENV}"
- name: Verify exact-source race native and runtime evidence
if: ${{ steps.race_native.outputs.required == 'true' }}
env:
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
VALIDATION_CASE_ID: ${{ inputs.validation_case }}
run: |
set -euo pipefail
NATIVE_DIRECTORY="${RUNNER_TEMP}/rustfs-validation-native-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
cp "${VALIDATION_ROOT}/tool-run.json" "${NATIVE_DIRECTORY}/tool-run.json"
python3 -B auto-testing/scripts/validation_heal/race_native_evidence.py \
--directory "${NATIVE_DIRECTORY}" --candidate "${VALIDATION_ROOT}/candidate.json" \
--testing-commit "${AUTO_TESTING_SHA}" --case-id "${VALIDATION_CASE_ID}" \
> "${VALIDATION_ROOT}/native-race-manifest.json"
VALIDATION_HEAL_NATIVE_DIRECTORY="${NATIVE_DIRECTORY}" \
python3 -B auto-testing/scripts/validation_heal/race.py preflight \
--candidate "${VALIDATION_ROOT}/candidate.json" --testing-commit "${AUTO_TESTING_SHA}" \
--case-id "${VALIDATION_CASE_ID}" \
> "${VALIDATION_ROOT}/native-race-runtime-preflight.json"
printf 'VALIDATION_HEAL_NATIVE_DIRECTORY=%s\n' "${NATIVE_DIRECTORY}" >> "${GITHUB_ENV}"
- name: Download pinned manager persistence and incarnation evidence
if: ${{ inputs.validation_case == 'heal.checkpoint-and-mrf-corruption' || inputs.validation_case == 'heal.bucket-incarnation-recreation' || inputs.validation_case == 'heal.outcome-counters-and-error-truth' }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
github-token: ${{ github.token }}
run-id: ${{ inputs.validation_tools_run_id }}
name: rustfs-validation-manager-native-${{ inputs.validation_tools_run_id }}-${{ inputs.validation_tools_run_attempt }}
path: ${{ runner.temp }}/rustfs-validation-manager-native-${{ github.run_id }}-${{ github.run_attempt }}
- name: Verify exact manager native prerequisites before fleet mutation
if: ${{ inputs.validation_case == 'heal.checkpoint-and-mrf-corruption' || inputs.validation_case == 'heal.bucket-incarnation-recreation' || inputs.validation_case == 'heal.outcome-counters-and-error-truth' }}
env:
VALIDATION_CASE: ${{ inputs.validation_case }}
run: |
set -euo pipefail
MANAGER_DIRECTORY="${RUNNER_TEMP}/rustfs-validation-manager-native-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
cp "${VALIDATION_ROOT}/tool-run.json" "${MANAGER_DIRECTORY}/tool-run.json"
python3 -B auto-testing/scripts/validation_heal/manager_fixture.py \
--directory "${MANAGER_DIRECTORY}" --candidate "${VALIDATION_ROOT}/candidate.json" \
--case-id "${VALIDATION_CASE}" > "${VALIDATION_ROOT}/manager-prerequisite-manifest.json"
printf 'VALIDATION_HEAL_MANAGER_DIRECTORY=%s\n' "${MANAGER_DIRECTORY}" >> "${GITHUB_ENV}"
- name: Download pinned C10 shared-reference encoder
if: ${{ inputs.validation_case == 'heal.dangling-and-orphan-safety' }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
github-token: ${{ github.token }}
run-id: ${{ inputs.validation_tools_run_id }}
name: rustfs-validation-shared-${{ inputs.validation_tools_run_id }}-${{ inputs.validation_tools_run_attempt }}
path: ${{ runner.temp }}/rustfs-validation-shared-${{ github.run_id }}-${{ github.run_attempt }}
- name: Verify C10 exact-source codec and original tests
if: ${{ inputs.validation_case == 'heal.dangling-and-orphan-safety' }}
run: |
set -euo pipefail
SHARED_DIRECTORY="${RUNNER_TEMP}/rustfs-validation-shared-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
cp "${VALIDATION_ROOT}/tool-run.json" "${SHARED_DIRECTORY}/tool-run.json"
python3 -B auto-testing/scripts/validation_heal/shared_fixture.py \
--directory "${SHARED_DIRECTORY}" --candidate "${VALIDATION_ROOT}/candidate.json" \
> "${VALIDATION_ROOT}/shared-fixture-manifest.json"
printf 'VALIDATION_HEAL_SHARED_DIRECTORY=%s\n' "${SHARED_DIRECTORY}" >> "${GITHUB_ENV}"
- name: Run selected canonical validation case
if: ${{ !inputs.validation_tools_only && inputs.validation_cleanup_run_id == '' && inputs.validation_case != 'environment.inspect' }}
env:
VALIDATION_CASE: ${{ inputs.validation_case }}
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
run: |
set -euo pipefail
test "$(git -C auto-testing rev-parse HEAD)" = "${AUTO_TESTING_SHA}"
./auto-testing/scripts/run_validation_case.sh \
"${VALIDATION_CASE}" "${VALIDATION_ROOT}/candidate.json" \
"${AUTO_TESTING_SHA}" "${VALIDATION_ROOT}/run"
- name: Inspect current Heal fleet resources without mutation
if: ${{ !inputs.validation_tools_only && inputs.validation_case == 'environment.inspect' }}
env:
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
run: |
set -euo pipefail
test "$(git -C auto-testing rev-parse HEAD)" = "${AUTO_TESTING_SHA}"
python3 -B auto-testing/scripts/validation_heal/environment.py \
--candidate "${VALIDATION_ROOT}/candidate.json" \
--testing-commit "${AUTO_TESTING_SHA}" --evidence "${VALIDATION_ROOT}/environment"
- name: Audit and finalize retained validation environment
if: ${{ inputs.validation_cleanup_run_id != '' }}
env:
GH_TOKEN: ${{ github.token }}
FAILED_RUN_ID: ${{ inputs.validation_cleanup_run_id }}
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
run: |
set -euo pipefail
test "$(git -C auto-testing rev-parse HEAD)" = "${AUTO_TESTING_SHA}"
gh run view "${FAILED_RUN_ID}" --repo "${GITHUB_REPOSITORY}" \
--json status,conclusion,headBranch,workflowName > "${VALIDATION_ROOT}/failed-run.json"
python3 - <<'PY'
import json
import os
from pathlib import Path
run = json.loads((Path(os.environ["VALIDATION_ROOT"]) / "failed-run.json").read_text())
if (run["status"] != "completed" or run["conclusion"] not in {"failure", "cancelled"}
or run["workflowName"] != "RustFS Functional Chain" or run["headBranch"] != os.environ["GITHUB_REF_NAME"]):
raise ValueError("cleanup source must be a completed failed or cancelled Functional Chain run")
PY
gh run download "${FAILED_RUN_ID}" --repo "${GITHUB_REPOSITORY}" \
--pattern "rustfs-validation-${FAILED_RUN_ID}-*" --dir "${VALIDATION_ROOT}/failed-artifact"
python3 -B auto-testing/scripts/validation_heal/recover_environment.py \
--failed-run-id "${FAILED_RUN_ID}" --artifact "${VALIDATION_ROOT}/failed-artifact" \
--candidate "${VALIDATION_ROOT}/candidate.json" --evidence "${VALIDATION_ROOT}/recovery"
- name: Show validation report
if: ${{ always() }}
run: |
if [ -f "${VALIDATION_ROOT}/run/report.md" ]; then
cat "${VALIDATION_ROOT}/run/report.md" >> "${GITHUB_STEP_SUMMARY}"
fi
- name: Upload validation evidence
if: ${{ always() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: rustfs-validation-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/rustfs-validation-${{ github.run_id }}-${{ github.run_attempt }}
if-no-files-found: error
validation-tools:
needs: [validation]
if: ${{ github.event_name == 'workflow_dispatch' && inputs.validation_package_sha == '' && inputs.validation_tools_only }}
uses: ./.github/workflows/rustfs-validation-tools.yml
with:
candidate_sha: ${{ needs.validation.outputs.source_sha }}
auto_testing_sha: ${{ inputs.auto_testing_sha }}
build_native_heal: ${{ inputs.validation_native_tools }}
build_shared_heal: ${{ inputs.validation_shared_tools }}
build_manager_native: ${{ inputs.validation_manager_tools }}
secrets: inherit
upgrade:
needs: [prepare]
if: ${{ always() && needs.prepare.result == 'success' }}
@@ -1,244 +0,0 @@
# Copyright 2024 RustFS Team
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
name: RustFS Validation Package
on:
workflow_call:
inputs:
source_sha:
description: Exact RustFS commit SHA to build
required: true
type: string
secrets:
R2_ACCESS_KEY_ID:
required: true
R2_SECRET_ACCESS_KEY:
required: true
R2_ENDPOINT:
required: true
R2_BUCKET:
required: true
workflow_dispatch:
inputs:
source_sha:
description: Exact RustFS commit SHA to build
required: true
type: string
permissions:
contents: read
concurrency:
group: rustfs-validation-package-${{ inputs.source_sha }}
cancel-in-progress: false
jobs:
resolve-source:
if: github.repository == 'rustfs/rustfs'
name: Resolve exact validation source
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
source_sha: ${{ steps.source.outputs.sha }}
source_ref: ${{ steps.source.outputs.ref }}
steps:
- name: Validate requested source
id: source
env:
REQUESTED_SHA: ${{ inputs.source_sha }}
run: |
set -euo pipefail
[[ "${REQUESTED_SHA}" =~ ^[0-9a-f]{40}$ ]]
source_directory="$(mktemp -d "${RUNNER_TEMP}/validation-source.XXXXXX")"
trap 'rm -rf "${source_directory}"' EXIT
git init -q "${source_directory}"
git -C "${source_directory}" fetch --no-tags --depth=1 https://github.com/rustfs/rustfs.git "${REQUESTED_SHA}"
test "$(git -C "${source_directory}" rev-parse FETCH_HEAD)" = "${REQUESTED_SHA}"
echo "sha=${REQUESTED_SHA}" >> "${GITHUB_OUTPUT}"
echo "ref=${REQUESTED_SHA}" >> "${GITHUB_OUTPUT}"
build:
needs: resolve-source
name: Build immutable validation DEB
runs-on: ubuntu-latest
timeout-minutes: 150
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
VALIDATION_SOURCE_SHA: ${{ needs.resolve-source.outputs.source_sha }}
steps:
- name: Checkout exact source
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
ref: ${{ needs.resolve-source.outputs.source_sha }}
- name: Confirm checked out source
env:
EXPECTED_SHA: ${{ needs.resolve-source.outputs.source_sha }}
run: test "$(git rev-parse HEAD)" = "${EXPECTED_SHA}"
- name: Setup Rust environment
uses: ./.github/actions/setup
with:
cache-shared-key: validation-package-hosted-x86_64-unknown-linux-gnu
cache-save-if: "false"
install-build-packaging-tools: "false"
install-test-tools: "false"
- name: Build RustFS
run: cargo build --release --locked --target x86_64-unknown-linux-gnu -p rustfs --bins
- name: Build validation DEB
shell: bash
run: |
set -euo pipefail
command -v fakeroot >/dev/null 2>&1 || sudo apt-get install -y -qq fakeroot
package_dir="rustfs-validation-${VALIDATION_SOURCE_SHA}"
package_file="${RUNNER_TEMP}/validation-package/rustfs.deb"
mkdir -p "${RUNNER_TEMP}/validation-package"
binary="target/x86_64-unknown-linux-gnu/release/rustfs"
test -x "${binary}"
mkdir -p "${package_dir}/DEBIAN" "${package_dir}/usr/bin" "${package_dir}/etc/default" \
"${package_dir}/lib/systemd/system" "${package_dir}/usr/share/doc/rustfs"
cp "${binary}" "${package_dir}/usr/bin/rustfs"
chmod 755 "${package_dir}/usr/bin/rustfs"
cp deploy/build/rustfs.service "${package_dir}/lib/systemd/system/"
cat > "${package_dir}/etc/default/rustfs" <<'ENVEOF'
# RustFS Environment Configuration
# RUSTFS_ACCESS_KEY=<changeme>
# RUSTFS_SECRET_KEY=<changeme>
# RUSTFS_VOLUMES=""
# RUSTFS_ADDRESS=":9000"
ENVEOF
cat > "${package_dir}/DEBIAN/control" <<'CONTROLEOF'
Package: rustfs
Version: 0.0.0~validation
Section: utils
Priority: optional
Architecture: amd64
Depends: libc6 (>= 2.31)
Maintainer: RustFS Team <support@rustfs.com>
Description: RustFS immutable validation package
Candidate package for RustFS validation only.
CONTROLEOF
printf '%s\n' /etc/default/rustfs > "${package_dir}/DEBIAN/conffiles"
./scripts/release/package_service_scripts.sh after-install > "${package_dir}/DEBIAN/postinst"
./scripts/release/package_service_scripts.sh before-remove > "${package_dir}/DEBIAN/prerm"
./scripts/release/package_service_scripts.sh after-remove > "${package_dir}/DEBIAN/postrm"
chmod 755 "${package_dir}/DEBIAN/postinst" "${package_dir}/DEBIAN/prerm" "${package_dir}/DEBIAN/postrm"
cp LICENSE README.md "${package_dir}/usr/share/doc/rustfs/"
fakeroot dpkg-deb --build "${package_dir}" "${package_file}"
test -s "${package_file}"
- name: Upload opaque validation package
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: validation-package-bytes-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/validation-package/rustfs.deb
if-no-files-found: error
publish:
needs: [resolve-source, build]
name: Publish immutable validation DEB
# Candidate-controlled build scripts never run on this fresh hosted runner.
runs-on: ubuntu-latest
timeout-minutes: 20
env:
VALIDATION_SOURCE_SHA: ${{ needs.resolve-source.outputs.source_sha }}
VALIDATION_SOURCE_REF: ${{ needs.resolve-source.outputs.source_ref }}
steps:
- name: Download opaque validation package
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: validation-package-bytes-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/validation-package-input
- name: Validate and stage opaque package bytes
shell: bash
run: |
set -euo pipefail
python3 -I - <<'PYTHON'
import os
from pathlib import Path
import shutil
import stat
root = Path(os.environ["RUNNER_TEMP"])
incoming = root / "validation-package-input"
if incoming.is_symlink() or not incoming.is_dir() or sorted(p.name for p in incoming.iterdir()) != ["rustfs.deb"]:
raise SystemExit("Validation artifact must contain exactly one rustfs.deb file")
fd = os.open(incoming / "rustfs.deb", os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
with os.fdopen(fd, "rb") as source:
info = os.fstat(source.fileno())
if not stat.S_ISREG(info.st_mode) or info.st_size == 0:
raise SystemExit("Validation package must be a nonempty regular file")
staged = root / "validation-publication"
staged.mkdir(mode=0o700)
with (staged / "rustfs.deb").open("xb") as target:
shutil.copyfileobj(source, target)
PYTHON
- name: Prepare trusted publication tools
id: tools
env:
AWS_EC2_METADATA_DISABLED: true
shell: bash
run: |
set -euo pipefail
AWS_CLI=aws
if ! "${AWS_CLI}" s3api put-object --generate-cli-skeleton input | jq -e 'has("IfNoneMatch")' >/dev/null; then
sudo apt-get update
sudo apt-get install -y -qq python3-venv
AWS_CLI_DIR="$(mktemp -d "${RUNNER_TEMP}/validation-awscli.XXXXXX")"
python3 -m venv "${AWS_CLI_DIR}"
"${AWS_CLI_DIR}/bin/python" -m pip install --disable-pip-version-check 'awscli==1.44.79'
AWS_CLI="${AWS_CLI_DIR}/bin/aws"
fi
"${AWS_CLI}" s3api put-object --generate-cli-skeleton input | jq -e 'has("IfNoneMatch")' >/dev/null
echo "aws_cli=${AWS_CLI}" >> "${GITHUB_OUTPUT}"
- name: Upload validation package to immutable R2 path
id: publish
env:
PACKAGE_FILE: ${{ runner.temp }}/validation-publication/rustfs.deb
AWS_CLI: ${{ steps.tools.outputs.aws_cli }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
AWS_EC2_METADATA_DISABLED: true
shell: bash
run: |
set -euo pipefail
if [[ -z "${R2_ACCESS_KEY_ID}" || -z "${R2_SECRET_ACCESS_KEY}" || -z "${R2_ENDPOINT}" || -z "${R2_BUCKET}" ]]; then
echo "Validation package publication requires complete R2 configuration" >&2
exit 1
fi
[[ "${VALIDATION_SOURCE_SHA}" =~ ^[0-9a-f]{40}$ ]]
test "${VALIDATION_SOURCE_REF}" = "${VALIDATION_SOURCE_SHA}"
test -s "${PACKAGE_FILE}" && test ! -L "${PACKAGE_FILE}"
export AWS_ACCESS_KEY_ID="${R2_ACCESS_KEY_ID}" AWS_SECRET_ACCESS_KEY="${R2_SECRET_ACCESS_KEY}" AWS_DEFAULT_REGION=auto
package_sha256="$(sha256sum "${PACKAGE_FILE}" | cut -d ' ' -f 1)"
key="artifacts/rustfs/packages/validation/runs/${GITHUB_RUN_ID}/${GITHUB_RUN_ATTEMPT}/${package_sha256}/rustfs.deb"
url="https://dl.rustfs.com/${key}"
"${AWS_CLI}" s3api put-object --bucket "${R2_BUCKET}" --key "${key}" --body "${PACKAGE_FILE}" \
--if-none-match '*' --endpoint-url "${R2_ENDPOINT}"
downloaded_sha256="$(curl -fsSL --retry 3 --connect-timeout 15 --max-time 300 "${url}" | sha256sum | cut -d ' ' -f 1)"
test "${downloaded_sha256}" = "${package_sha256}"
manifest="${RUNNER_TEMP}/validation-candidate-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}.json"
jq -n --arg workflow_sha "${GITHUB_SHA}" --arg source_ref "${VALIDATION_SOURCE_REF}" \
--arg source_sha "${VALIDATION_SOURCE_SHA}" --arg channel validation \
--argjson build_run_id "${GITHUB_RUN_ID}" --argjson build_run_attempt "${GITHUB_RUN_ATTEMPT}" \
--arg package_url "${url}" --arg package_sha256 "${package_sha256}" \
'{schema: 3, workflow_sha: $workflow_sha, source_ref: $source_ref, source_sha: $source_sha, channel: $channel, build_run_id: $build_run_id, build_run_attempt: $build_run_attempt, package_url: $package_url, package_sha256: $package_sha256}' > "${manifest}"
echo "candidate_file=${manifest}" >> "${GITHUB_OUTPUT}"
- name: Upload validation candidate manifest
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: validation-candidate-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.publish.outputs.candidate_file }}
if-no-files-found: error
@@ -1,343 +0,0 @@
# Build source-bound helpers separately so live validation needs only artifacts.
name: RustFS Validation Tools
on:
workflow_call:
inputs:
candidate_sha:
type: string
required: true
auto_testing_sha:
type: string
required: true
build_native_heal:
type: boolean
required: false
default: false
build_shared_heal:
type: boolean
required: false
default: false
build_manager_native:
type: boolean
required: false
default: false
secrets:
PF_TESTING_GH_TOKEN:
required: true
workflow_dispatch:
inputs:
candidate_sha:
description: Exact RustFS source SHA from the validation candidate manifest
type: string
required: true
auto_testing_sha:
description: Exact auto-testing SHA containing the Heal metadata inspector
type: string
required: true
build_native_heal:
description: Also compile and run separate exact-source native Heal regressions
type: boolean
required: false
default: false
build_shared_heal:
description: Build the controlled C10 shared-reference fixture encoder
type: boolean
required: false
default: false
build_manager_native:
description: Build and run exact candidate checkpoint/MRF/incarnation regressions
type: boolean
required: false
default: false
permissions:
contents: read
jobs:
manager-native-regressions:
if: ${{ inputs.build_manager_native }}
runs-on: ubuntu-22.04
timeout-minutes: 180
env:
CANDIDATE_SHA: ${{ inputs.candidate_sha }}
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
steps:
- name: Validate immutable manager native inputs
run: |
set -euo pipefail
[[ "${CANDIDATE_SHA}" =~ ^[0-9a-f]{40}$ ]]
[[ "${AUTO_TESTING_SHA}" =~ ^[0-9a-f]{40}$ ]]
- name: Checkout exact manager candidate source
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
ref: ${{ inputs.candidate_sha }}
path: candidate
persist-credentials: false
- name: Checkout exact manager collector source
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
repository: rustfs/auto-testing
ref: ${{ inputs.auto_testing_sha }}
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
path: auto-testing
persist-credentials: false
- name: Install manager native build dependencies
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y clang lld cmake pkg-config libssl-dev protobuf-compiler
df -h .
- name: Build exact unmodified candidate manager test executable
run: |
set -euo pipefail
test "$(git -C auto-testing rev-parse HEAD)" = "${AUTO_TESTING_SHA}"
python3 -B auto-testing/scripts/validation_heal/manager_native.py build \
--source candidate --commit "${CANDIDATE_SHA}" --out manager-tools
- name: Run exact manager regressions independently
run: |
set -euo pipefail
python3 -B auto-testing/scripts/validation_heal/manager_native.py run \
--directory manager-tools --out manager-evidence
- name: Upload manager executable identities and original diagnostics
if: ${{ always() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: rustfs-validation-manager-native-${{ github.run_id }}-${{ github.run_attempt }}
path: |
manager-tools/heal-manager-native-tests
manager-tools/*.json
manager-tools/*.jsonl
manager-tools/*.stderr
manager-tools/*.log
manager-evidence/
if-no-files-found: warn
native-heal-regressions:
if: ${{ inputs.build_native_heal }}
runs-on: ubuntu-22.04
timeout-minutes: 180
env:
CANDIDATE_SHA: ${{ inputs.candidate_sha }}
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
steps:
- name: Validate immutable native source inputs
run: |
set -euo pipefail
[[ "${CANDIDATE_SHA}" =~ ^[0-9a-f]{40}$ ]]
[[ "${AUTO_TESTING_SHA}" =~ ^[0-9a-f]{40}$ ]]
- name: Checkout exact native candidate source
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
ref: ${{ inputs.candidate_sha }}
path: candidate
persist-credentials: false
- name: Checkout exact native collector source
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
repository: rustfs/auto-testing
ref: ${{ inputs.auto_testing_sha }}
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
path: auto-testing
persist-credentials: false
- name: Install native build dependencies
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y clang lld cmake pkg-config libssl-dev protobuf-compiler
df -h .
- name: Build and enumerate the unmodified candidate test executable
run: |
set -euo pipefail
test "$(git -C auto-testing rev-parse HEAD)" = "${AUTO_TESTING_SHA}"
use_race_native=false
if [[ -f auto-testing/scripts/validation_heal/race_native.py ]]; then
support_rc=0
python3 -B auto-testing/scripts/validation_heal/race_native.py supports \
--commit "${CANDIDATE_SHA}" || support_rc=$?
case "${support_rc}" in
0) use_race_native=true ;;
2) ;; # The original exact-source collector handles legacy candidates.
*) exit "${support_rc}" ;;
esac
fi
if [[ "${use_race_native}" == true ]]; then
python3 -B auto-testing/scripts/validation_heal/race_native.py build \
--source candidate --commit "${CANDIDATE_SHA}" --testing-commit "${AUTO_TESTING_SHA}" --out native-tools
else
python3 -B auto-testing/scripts/validation_heal/native_regressions.py build \
--source candidate --commit "${CANDIDATE_SHA}" --out native-tools
fi
- name: Run exact native regressions separately from fleet validation
run: |
set -euo pipefail
if [[ -f native-tools/race-native.json ]]; then
test ! -e native-tools/native-regressions.json
python3 -B auto-testing/scripts/validation_heal/race_native.py run \
--directory native-tools --out native-evidence
else
python3 -B auto-testing/scripts/validation_heal/native_regressions.py run \
--directory native-tools --out native-evidence
fi
- name: Run existing F07 directory fsync failure regressions
if: ${{ inputs.candidate_sha == '1aa9be87d18cf941e5020f19e8622c26ba4b10ac' }}
run: |
set -euo pipefail
# Older immutable harness pins retain their original native suite;
# absence of this additive collector must never fabricate its proof.
if [[ ! -f auto-testing/scripts/validation_heal/persistence_native.py ]]; then
mkdir native-persistence-evidence
echo '{"complete":false,"reason":"pinned harness predates F07 directory-fsync collector","requiredNativeCoverageComplete":false,"canonicalCasePassClaimed":false}' \
> native-persistence-evidence/not-available.json
exit 0
fi
python3 -B auto-testing/scripts/validation_heal/persistence_native.py run \
--directory native-tools --native-evidence native-evidence --out native-persistence-evidence \
--testing-commit "${AUTO_TESTING_SHA}"
python3 -B auto-testing/scripts/validation_heal/persistence_native.py verify \
--directory native-tools --native-evidence native-evidence --out native-persistence-evidence \
--testing-commit "${AUTO_TESTING_SHA}"
- name: Upload native executable identities and original diagnostics
if: ${{ always() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: rustfs-validation-native-${{ github.run_id }}-${{ github.run_attempt }}
path: |
native-tools/ecstore-native-tests
native-tools/ecstore-race-native-tests
native-tools/*.json
native-tools/*.jsonl
native-tools/*.stderr
native-tools/*.log
native-evidence/
native-persistence-evidence/
if-no-files-found: warn
heal-metadata-inspector:
runs-on: ubuntu-22.04
timeout-minutes: 30
env:
CANDIDATE_SHA: ${{ inputs.candidate_sha }}
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
steps:
- name: Validate immutable source inputs
run: |
set -euo pipefail
[[ "${CANDIDATE_SHA}" =~ ^[0-9a-f]{40}$ ]]
[[ "${AUTO_TESTING_SHA}" =~ ^[0-9a-f]{40}$ ]]
- name: Checkout exact candidate source
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
ref: ${{ inputs.candidate_sha }}
path: candidate
persist-credentials: false
- name: Checkout exact inspector source
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
repository: rustfs/auto-testing
ref: ${{ inputs.auto_testing_sha }}
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
path: auto-testing
persist-credentials: false
- name: Build the read-only candidate metadata decoder
run: |
set -euo pipefail
test "$(git -C candidate rev-parse HEAD)" = "${CANDIDATE_SHA}"
test "$(git -C auto-testing rev-parse HEAD)" = "${AUTO_TESTING_SHA}"
df -h .
python3 auto-testing/scripts/validation_heal/prepare_heal_meta_inspector.py \
--source candidate --commit "${CANDIDATE_SHA}" --out inspector-build
# Seed the helper's separate graph from the candidate's frozen graph.
# Cargo adds the helper package; both lock identities are retained.
cp candidate/Cargo.lock inspector-build/Cargo.lock
cargo build --release --manifest-path inspector-build/Cargo.toml
python3 auto-testing/scripts/validation_heal/check_bitrot_tool.py \
--binary inspector-build/target/release/heal-meta-inspect --source candidate
mkdir validation-tools
cp inspector-build/target/release/heal-meta-inspect validation-tools/
cp inspector-build/Cargo.lock validation-tools/inspector-Cargo.lock
python3 - <<'PY'
import hashlib
import json
import os
from pathlib import Path
import subprocess
source = json.loads(Path("inspector-build/source.json").read_text())
binary = Path("validation-tools/heal-meta-inspect")
source.update(built=True, testingCommit=os.environ["AUTO_TESTING_SHA"],
binarySha256=hashlib.sha256(binary.read_bytes()).hexdigest(),
inspectorLockSha256=hashlib.sha256(Path("validation-tools/inspector-Cargo.lock").read_bytes()).hexdigest(),
toolchain=subprocess.check_output(["rustc", "-vV"], text=True),
target="x86_64-unknown-linux-gnu", runId=os.environ["GITHUB_RUN_ID"],
runAttempt=os.environ["GITHUB_RUN_ATTEMPT"])
Path("validation-tools/heal-meta-inspect.json").write_text(json.dumps(source, indent=2) + "\n")
PY
git -C candidate diff --exit-code
- name: Build and test the controlled C10 metadata encoder
if: ${{ inputs.build_shared_heal }}
run: |
set -euo pipefail
python3 -B auto-testing/scripts/validation_heal/prepare_heal_shared_fixture.py \
--source candidate --commit "${CANDIDATE_SHA}" --out shared-build
cp candidate/Cargo.lock shared-build/Cargo.lock
export CARGO_TARGET_DIR="${GITHUB_WORKSPACE}/inspector-build/target"
cargo build --release --manifest-path shared-build/Cargo.toml
mkdir shared-tools
if ! cargo test --release --manifest-path shared-build/Cargo.toml -- --test-threads=1 \
> shared-tools/shared-fixture-tests.log 2>&1; then
cat shared-tools/shared-fixture-tests.log
exit 1
fi
cp "${CARGO_TARGET_DIR}/release/heal-shared-fixture" shared-tools/
cp shared-build/Cargo.lock shared-tools/shared-Cargo.lock
python3 - <<'PY'
import hashlib
import json
import os
from pathlib import Path
import subprocess
source = json.loads(Path("shared-build/source.json").read_text())
source.update(schemaVersion=1, built=True, sourceUnmodified=True,
candidateSourceTree=subprocess.check_output(["git", "-C", "candidate", "rev-parse", "HEAD^{tree}"], text=True).strip(),
testingCommit=os.environ["AUTO_TESTING_SHA"],
binarySha256=hashlib.sha256(Path("shared-tools/heal-shared-fixture").read_bytes()).hexdigest(),
sharedLockSha256=hashlib.sha256(Path("shared-tools/shared-Cargo.lock").read_bytes()).hexdigest(),
toolchain=subprocess.check_output(["rustc", "-vV"], text=True), target="x86_64-unknown-linux-gnu",
runId=os.environ["GITHUB_RUN_ID"], runAttempt=os.environ["GITHUB_RUN_ATTEMPT"])
Path("shared-tools/heal-shared-fixture.json").write_text(json.dumps(source, indent=2) + "\n")
PY
git -C candidate diff --exit-code
- name: Upload controlled C10 fixture encoder evidence
if: ${{ always() && inputs.build_shared_heal }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: rustfs-validation-shared-${{ github.run_id }}-${{ github.run_attempt }}
path: shared-tools/
if-no-files-found: warn
- name: Upload immutable validation tools
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: rustfs-validation-tools-${{ github.run_id }}-${{ github.run_attempt }}
path: validation-tools/
if-no-files-found: error
@@ -44,7 +44,7 @@ permissions:
jobs:
forced-failure:
name: Forced failure
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 5
steps:
- name: Fail on purpose
+1 -1
View File
@@ -27,7 +27,7 @@ on:
jobs:
stale:
if: github.repository == 'rustfs/rustfs'
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 30
steps:
- uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
+1 -1
View File
@@ -15,7 +15,7 @@ concurrency:
jobs:
update:
if: github.repository == 'rustfs/rustfs'
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 10
steps:
- uses: overtrue/repo-visuals-action@ee2c632f6ce617e851fb46ea935ee8af762ebb93 # v1.4.0
+2 -2
View File
@@ -42,7 +42,7 @@ jobs:
targets-live:
if: github.repository == 'rustfs/rustfs'
name: PostgreSQL, MySQL, AMQP, and NATS
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 90
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
@@ -60,7 +60,7 @@ jobs:
- name: Setup Rust environment
uses: ./.github/actions/setup
with:
cache-shared-key: targets-live-lane-hosted
cache-shared-key: targets-live-lane
cache-save-if: ${{ github.ref == 'refs/heads/main' || github.event_name == 'schedule' }}
install-build-packaging-tools: 'false'
install-test-tools: 'false'
-130
View File
@@ -19,21 +19,8 @@ on:
branches: [ main ]
paths:
- "crates/ecstore/src/disk/**"
- "crates/ecstore/src/layout/**"
- "crates/ecstore/src/store/init_format.rs"
- "crates/ecstore/src/set_disk/ops/locking.rs"
- "crates/ecstore/src/object_api/write_commit_context.rs"
- "crates/ecstore/src/bucket/utils.rs"
- "crates/ecstore/Cargo.toml"
- "crates/utils/src/dunce.rs"
- "crates/utils/src/os/**"
- "crates/utils/Cargo.toml"
- "crates/utils/src/path.rs"
- "rustfs/build.rs"
- "rustfs/Cargo.toml"
- "rustfs/src/main.rs"
- "rustfs/src/server/runtime.rs"
- "rustfs/src/startup_*.rs"
- "Cargo.toml"
- "Cargo.lock"
- ".github/actions/setup/**"
@@ -42,21 +29,8 @@ on:
branches: [ main ]
paths:
- "crates/ecstore/src/disk/**"
- "crates/ecstore/src/layout/**"
- "crates/ecstore/src/store/init_format.rs"
- "crates/ecstore/src/set_disk/ops/locking.rs"
- "crates/ecstore/src/object_api/write_commit_context.rs"
- "crates/ecstore/src/bucket/utils.rs"
- "crates/ecstore/Cargo.toml"
- "crates/utils/src/dunce.rs"
- "crates/utils/src/os/**"
- "crates/utils/Cargo.toml"
- "crates/utils/src/path.rs"
- "rustfs/build.rs"
- "rustfs/Cargo.toml"
- "rustfs/src/main.rs"
- "rustfs/src/server/runtime.rs"
- "rustfs/src/startup_*.rs"
- "Cargo.toml"
- "Cargo.lock"
- ".github/actions/setup/**"
@@ -103,118 +77,14 @@ jobs:
shell: pwsh
run: cargo test -p rustfs-ecstore --lib rename_all_ -- --nocapture
- name: Test Windows path conversion
shell: pwsh
run: "cargo test -p rustfs-utils --no-default-features --features path --lib dunce:: -- --nocapture"
- name: Test Windows volume and disk-space probes
shell: pwsh
run: |
# Map a drive letter to the temp drive's administrative share so the
# remote-volume probe also covers mapped network drives.
$drive = (Split-Path -Qualifier ([System.IO.Path]::GetTempPath())).TrimEnd(':')
net use R: "\\localhost\$drive`$" /persistent:no
if ($LASTEXITCODE -eq 0) {
$env:RUSTFS_TEST_WINDOWS_MAPPED_DRIVE = 'R:'
} else {
Write-Output "::warning::could not map R: to \\localhost\$drive`$; mapped-drive test will skip"
}
cargo test -p rustfs-utils --features os --lib os:: -- --nocapture
$status = $LASTEXITCODE
if ($env:RUSTFS_TEST_WINDOWS_MAPPED_DRIVE) { net use R: /delete /y | Out-Null }
exit $status
- name: Test Windows handle guards
shell: pwsh
run: cargo test -p rustfs-ecstore --lib windows_ -- --nocapture
- name: Test endpoint path parsing
shell: pwsh
run: "cargo test -p rustfs-ecstore --lib layout::endpoint:: -- --nocapture"
- name: Test startup temporary-directory cleanup
shell: pwsh
run: cargo test -p rustfs-ecstore --lib cleanup_tmp_on_startup_ -- --nocapture
- name: Test object key Windows admission
shell: pwsh
run: "cargo test -p rustfs-ecstore --lib bucket::utils:: -- --nocapture"
- name: Test Windows file-name rules
shell: pwsh
run: "cargo test -p rustfs-utils --no-default-features --features path --lib dunce:: -- --nocapture"
- name: Test fresh format publication
shell: pwsh
run: cargo test -p rustfs-ecstore --lib fresh_format_load_initializes_all_disks -- --nocapture
# The native Windows main thread only gets the 1 MiB PE stack reserve, while
# Linux and macOS hand out 8 MiB. Startup runs the whole root future on the
# thread that calls `block_on`, so a debug server once overflowed before it
# listened on Windows only. Start a real server to keep that covered.
startup-smoke:
name: Startup Smoke
runs-on: windows-latest
timeout-minutes: 90
steps:
- name: Checkout repository
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Setup Rust environment
uses: ./.github/actions/setup
with:
rust-version: stable
cache-shared-key: build-x86_64-pc-windows-msvc
cache-save-if: 'false'
install-build-packaging-tools: 'false'
install-test-tools: 'false'
- name: Build debug server binary
shell: pwsh
run: cargo build --locked -p rustfs --bin rustfs
- name: Print version
shell: pwsh
run: |
& .\target\debug\rustfs.exe --version
if ($LASTEXITCODE -ne 0) { throw "rustfs --version exited with $LASTEXITCODE" }
- name: Start server and wait for readiness
shell: pwsh
env:
RUSTFS_ACCESS_KEY: rustfsadmin
RUSTFS_SECRET_KEY: rustfsadmin
run: |
$volume = Join-Path $env:RUNNER_TEMP 'rustfs-startup-smoke'
$stdout = Join-Path $env:RUNNER_TEMP 'rustfs-startup-smoke.out.log'
$stderr = Join-Path $env:RUNNER_TEMP 'rustfs-startup-smoke.err.log'
New-Item -ItemType Directory -Force -Path $volume | Out-Null
$server = Start-Process -FilePath .\target\debug\rustfs.exe `
-ArgumentList @('--address', '127.0.0.1:9000', "`"$volume`"") `
-RedirectStandardOutput $stdout -RedirectStandardError $stderr `
-NoNewWindow -PassThru
# Cache the process handle now; otherwise ExitCode reads back empty.
$null = $server.Handle
$ready = $false
try {
for ($i = 0; $i -lt 180; $i++) {
if ($server.HasExited) { break }
try {
$response = Invoke-WebRequest -Uri 'http://127.0.0.1:9000/health/ready' -UseBasicParsing -NoProxy -TimeoutSec 2
if ($response.StatusCode -eq 200) { $ready = $true; break }
} catch {}
Start-Sleep -Seconds 1
}
} finally {
if (-not $server.HasExited) { Stop-Process -Id $server.Id -Force }
$server.WaitForExit()
Write-Host '--- server stdout (tail) ---'
Get-Content $stdout -Tail 100 -ErrorAction SilentlyContinue
Write-Host '--- server stderr (tail) ---'
Get-Content $stderr -Tail 100 -ErrorAction SilentlyContinue
}
if (-not $ready) {
throw ("rustfs did not report ready on /health/ready (exit code: {0})" -f $server.ExitCode)
}
-1
View File
@@ -90,4 +90,3 @@ worktrees/*
# insta scratch files; the accepted .snap files ARE the assertions and are committed
*.snap.new
*.pending-snap
.mimosa
+28 -22
View File
@@ -7,9 +7,7 @@
"name": "Debug(only) executable 'rustfs'",
"env": {
"RUST_LOG": "rustfs=info,ecstore=info,s3s=info,iam=info",
"RUSTFS_SKIP_BACKGROUND_TASK": "on",
// Local test volumes share one physical disk.
"RUSTFS_UNSAFE_BYPASS_DISK_CHECK": "true"
"RUSTFS_SKIP_BACKGROUND_TASK": "on"
//"RUSTFS_OBS_LOG_DIRECTORY": "./deploy/logs",
// "RUSTFS_POLICY_PLUGIN_URL":"http://localhost:8181/v1/data/rustfs/authz/allow",
// "RUSTFS_POLICY_PLUGIN_AUTH_TOKEN":"your-opa-token"
@@ -46,7 +44,6 @@
"env": {
"RUST_LOG": "rustfs=debug,ecstore=info,s3s=debug,iam=debug",
"RUSTFS_SKIP_BACKGROUND_TASK": "on",
"RUSTFS_UNSAFE_BYPASS_DISK_CHECK": "true",
//"RUSTFS_OBS_LOG_DIRECTORY": "./deploy/logs",
// "RUSTFS_POLICY_PLUGIN_URL":"http://localhost:8181/v1/data/rustfs/authz/allow",
// "RUSTFS_POLICY_PLUGIN_AUTH_TOKEN":"your-opa-token"
@@ -120,8 +117,7 @@
"env": {
"RUSTFS_ACCESS_KEY": "rustfsadmin",
"RUSTFS_SECRET_KEY": "rustfsadmin",
"RUSTFS_VOLUMES": "./target/volume/test{1...4}",
"RUSTFS_UNSAFE_BYPASS_DISK_CHECK": "true",
//"RUSTFS_VOLUMES": "./target/volume/test{1...4}",
"RUSTFS_ADDRESS": ":9000",
"RUSTFS_CONSOLE_ENABLE": "true",
// "RUSTFS_OBS_TRACE_ENDPOINT": "http://127.0.0.1:4318/v1/traces", // jeager otlp http endpoint
@@ -139,31 +135,42 @@
{
"type": "lldb",
"request": "launch",
"name": "Debug test_transition_and_restore_flows",
"name": "Debug test_lifecycle_transition_basic",
"cargo": {
"args": [
"test",
"--no-run",
"-p",
"rustfs-scanner",
"--test",
"lifecycle_integration_test",
"serial_tests::test_lifecycle_transition_basic",
"-j",
"1"
],
"filter": {
"name": "lifecycle_integration_test",
"kind": "test"
}
]
},
"args": [
"serial_tests::test_transition_and_restore_flows",
"--exact",
"--ignored",
"--test-threads=1"
],
"args": [],
"cwd": "${workspaceFolder}"
},
{
"name": "Debug executable target/debug/test",
"type": "lldb",
"request": "launch",
"program": "${workspaceFolder}/target/debug/deps/lifecycle_integration_test-5915cbfcab491b3b",
"args": [
"--skip",
"test_lifecycle_expiry_basic",
"--skip",
"test_lifecycle_expiry_deletemarker",
//"--skip",
//"test_lifecycle_transition_basic",
],
"cwd": "${workspaceFolder}",
//"stopAtEntry": false,
//"preLaunchTask": "cargo build",
"sourceLanguages": [
"rust"
],
},
{
"name": "Debug executable target/debug/rustfs with sse kms",
"type": "lldb",
@@ -278,7 +285,7 @@
"RUSTFS_COMPRESSION_ENABLED": "true",
"RUSTFS_KMS_ENABLE": "true",
"RUSTFS_KMS_BACKEND": "local",
"RUSTFS_KMS_KEY_DIR": "${workspaceFolder}/target/rio-v2/kms-key-dir",
"RUSTFS_KMS_KEY_DIR": "./target/rio-v2/kms-key-dir",
"RUSTFS_KMS_LOCAL_MASTER_KEY": "my-secret-key", // Some Password
"RUSTFS_KMS_DEFAULT_KEY_ID": "rustfs-master-key",
},
@@ -300,7 +307,6 @@
"RUSTFS_ACCESS_KEY": "rustfsadmin",
"RUSTFS_SECRET_KEY": "rustfsadmin",
"RUSTFS_VOLUMES": "./target/e2e-test/test{1...4}",
"RUSTFS_UNSAFE_BYPASS_DISK_CHECK": "true",
"RUSTFS_REGION": "us-east-1",
"RUSTFS_ADDRESS": ":9000",
"RUSTFS_CONSOLE_ENABLE": "true",
@@ -309,7 +315,7 @@
"RUSTFS_KMS_ENABLE": "true",
"RUSTFS_KMS_BACKEND": "local",
"RUSTFS_KMS_KEY_DIR": "${workspaceFolder}/target/e2e-key-dir",
"RUSTFS_KMS_KEY_DIR": "./target/e2e-key-dir",
"RUSTFS_KMS_LOCAL_MASTER_KEY": "my-secret-key", // Some Password
"RUSTFS_KMS_DEFAULT_KEY_ID": "rustfs-master-key",
},
-37
View File
@@ -3,9 +3,6 @@
This file contains repository-wide rules. Use the nearest subdirectory
`AGENTS.md` for path-specific invariants.
S3-compatible object store in Rust, derived from MinIO. Erasure-coded,
multi-pool, supports ILM tiering/lifecycle.
## Precedence
1. System/developer instructions.
@@ -41,27 +38,6 @@ rules. A skill cannot expand the user's requested scope or grant authorization.
pending. Before requesting missing approval, prepare the concrete result that
is already authorized; retain explicit merge and release gates.
- Keep individual tool outputs concise. Save large outputs to task-owned logs
and retrieve only the relevant ranges.
## Common Commands
```bash
cargo build --release --bin rustfs # production binary
cargo check -p <crate> # fast type-check one crate
cargo test -p <crate> # test one crate
cargo fmt --all --check # for Rust changes; see AGENTS.md verification tiers
make pre-commit # fast gate: fmt + arch checks + quick-check (NO clippy/tests)
make pre-pr # optional full gate for broad cross-module changes
make build-docker BUILD_OS=ubuntu22.04
```
> **Docker build note**: `buildx build` without `--load` keeps the image in
> the buildx cache only — `docker run` will use a stale local image. The
> Makefile already includes `--load`; if you suspect a stale binary, add
> `--no-cache` to the `buildx build` invocation inside
> `.config/make/build-docker.mak`.
## Task-Specific Guidance
Read only the reference needed for the current task, once per unchanged context:
@@ -89,19 +65,6 @@ or agent scratch notes. Durable architecture belongs under `docs/architecture/`,
operations under `docs/operations/`, and testing references under
`docs/testing/`. `scripts/check_no_planning_docs.sh` enforces this boundary.
## Further References
- Agent knowledge base index and doc-writing rules: [docs/architecture/README.md](docs/architecture/README.md)
- Crate membership: `Cargo.toml` `[workspace].members`
- Architecture, layering, crate map: [ARCHITECTURE.md](ARCHITECTURE.md)
- Migration guardrails & readiness contracts: [docs/architecture/](docs/architecture/README.md)
- CI workflow steps: `.github/workflows/`; event, timeout, and required-status
matrix: [docs/testing/ci-gates.md](docs/testing/ci-gates.md)
- Test-layer taxonomy, per-layer entry commands, serial/nextest rules, flake
policy: [docs/testing/README.md](docs/testing/README.md)
- Tier/ILM transition debugging (xl.meta inspection, versionId tracing):
[docs/operations/tier-ilm-debugging.md](docs/operations/tier-ilm-debugging.md)
## Verification
Select checks from the final task-owned diff. Scoped `AGENTS.md` files may add a
-3
View File
@@ -25,8 +25,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Presigned URLs honour only signed headers** (GHSA-g8w9-qw9q-fghr): a SigV4 presigned request that carries an `x-amz-*` request header not listed in `X-Amz-SignedHeaders` is now rejected with `403 AccessDenied` ("There were headers present in the request which were not signed"), matching AWS S3. Previously the holder of a presigned `PutObject` URL could add unsigned `x-amz-tagging`, `x-amz-storage-class`, `x-amz-website-redirect-location`, ACL, metadata, Object Lock or SSE headers and have them applied. Presigners that intend a property must set it before signing so the SDK lists the header in `SignedHeaders`; `x-amz-cf-id` (CloudFront) remains tolerated unsigned. Header-signed SigV4 and SigV2 requests are unchanged.
### Fixed
- **Replication status writebacks no longer invalidate scanner cycles**: every replication status writeback (`persist_replication_state_if_current` in `Update` mode, including the heals the scanner queues during its own walk) marked the bucket dirty on the namespace path, advanced the dirty-usage generation and woke the scanner, so a site-replication resync or any steadily replicated bucket ended every cycle `superseded`, the authoritative usage baseline was never certified, and the console reported usage freshness as stale for as long as replication ran (#8375). Those writebacks are now status class: they do not touch the dirty-usage generation, the activity snapshot, the dirty-usage wake or the durable dirty journal, and only the process-local producer coverage is recorded. They need no rescan queue, because a deployment with an active replication rule already walks every bucket on every cycle and that walk refreshes `replication_stats`. Tier-expiration cleanup, which removes data, stays on the namespace path. No persisted format, protocol, admin API or configuration changes. Design: `docs/architecture/scanner-per-bucket-convergence.md`.
- **A bucket written during a scanner walk no longer supersedes the whole cycle**: the post-walk cluster activity proof required the entire activity snapshot and the dirty-usage generation to be unchanged, so a single-node deployment with one bucket written every few seconds never reached a `Complete` cycle, `usage_snapshot_converged` never became true and the authoritative usage baseline was never certified (#8494). Dirty usage is now certified per bucket: a structurally complete walk that the guardrail did not veto is `Complete`, buckets whose dirty generation still matches the start snapshot are cleared, and buckets written during the walk stay dirty and are published as the pending set, so `usage_snapshot_converged` means "no bucket pending" and the next cycle is scoped to the pending buckets. Such a cycle writes the authoritative usage object; the observed object is now selected by an explicit observational mark that vetoed cycles carry. The guardrail itself vetoes only on the fields that change which namespace the walk described (peer set, process instance, protocol version, topology, maintenance generation, data movement, movement generation, publication blocked) and reports the field and host in the scanner log and in the new `usage_freshness` fields `guardrail_veto_total`, `last_guardrail_veto_unix_secs` and `last_guardrail_veto_reason`. In a distributed deployment a remote peer whose dirty usage moved during the walk still supersedes the cycle until scoped acknowledgements can name its buckets. `.usage.v2.json` gains the additive, default-when-absent fields `usage_snapshot_pending_buckets`, `usage_snapshot_pending_bucket_count` and `usage_snapshot_observational`; older readers ignore them and treat the snapshot as an authoritative but unconverged publication. Quota is unchanged: an uncertified bucket keeps its last usage value as the floor and live writes do not advance it. No protocol or configuration changes. Design: `docs/architecture/scanner-per-bucket-convergence.md`.
- **SSE-C TLS requirement now covers copy-source keys**: with `RUSTFS_SSE_C_REQUIRE_TLS=true`, a `CopyObject` or `UploadPartCopy` that carried only the `x-amz-copy-source-server-side-encryption-customer-*` headers (an SSE-C source copied to a destination that is not SSE-C) was accepted on a plaintext transport and was not counted in `rustfs_ssec_plaintext_requests_total`, although those headers carry the source object's customer key. They now count as SSE-C headers: such requests are counted, and refused with `400 InvalidRequest` when the switch is on. Requests over TLS are unaffected.
- **SSE-KMS encryption context could replace the location entry**: data keys are wrapped under an encryption-context entry, keyed by the bucket name, that ties them to the object's location. A client `x-amz-server-side-encryption-context` entry under the same key replaced it, and because the client context is stored with the object, the replacement also applied on every read. SSE-KMS writes now refuse such a context with `400 InvalidArgument`; other keys, including the names of other buckets, are unaffected. Objects stored with such an entry by an earlier release remain readable. See `docs/operations/kms-backend-security.md`.
- **Two admin handlers reported a missing policy or user as `500 InternalError`**: `GET /rustfs/admin/v3/info-canned-policy` and `DELETE /rustfs/admin/v3/remove-user` wrapped every IAM error as an internal fault instead of classifying it, so a lookup for something that does not exist answered `500 InternalError` where the rest of the admin API answers `404 NoSuchResource` — `GET /rustfs/admin/v3/user-info` already did, and `iam_error_to_s3_error` already mapped `NoSuchPolicy`, `NoSuchUser`, `NoSuchServiceAccount` and `NoSuchTempAccount` to `NoSuchResource`. A client could not tell "does not exist" from a real server failure, and a controller that checks existence before creating (a Kubernetes operator reconciling policies or users) could never create the object: the missing-policy lookup failed with a `500` and it retried forever. Both handlers now map their IAM errors through the shared `iam_error_to_s3_error` helper, so a missing policy or user is `404 NoSuchResource` and the `error` is preserved as the S3 error's source; the three IAM calls in the removal path (temporary-user lookup, service-account lookup, delete) are all reclassified, and every other IAM error stays `500 InternalError`. Fixes #8117.
@@ -87,7 +85,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Four-layer regression-prevention tests guard against silent feature deletion: compile-time module assertion, module-presence unit test, cross-module `Protocol` enum assertion, end-to-end SSH banner test against the running binary
### Changed
- **Scanner bucket walkers are no longer tied to the disk count**: a new scanner key `walkers_per_disk` (`RUSTFS_SCANNER_WALKERS_PER_DISK`, `1` through `16`, default `1`) spawns that many bucket walkers per local disk of an erasure set, so a single-disk deployment can walk several buckets at once instead of one bucket, one object at a time. The walkers of one disk share its scanner admission, so a peer still sees one scan session per physical disk, and remote disks keep one walker. The walker count is what `max_concurrent_disk_scans` caps and the disk count stays the floor; the `disk_concurrency_budget` log line now reports `walkers_per_disk`, `walkers`, and the `reason` for the effective limit. The default keeps today's I/O profile because a local disk cannot yet report whether it is rotational; the design intends a higher default on flash once it can (`docs/architecture/scanner-per-bucket-convergence.md`). As groundwork for pacing, the raw-enumeration page writer inserts into its building page in place and commits pages without copying them, and a regression test pins the cost of recording 20,000 entries in one prefix so the quadratic bookkeeping removed by [#8114](https://github.com/rustfs/rustfs/pull/8114) (rustfs#8113) cannot return. No persisted format, protocol or admin API changes beyond the additional config key, which older releases preserve and ignore.
- **Encryption and KMS work merged since `1.0.0-rc.5`** (entries were missing from this section):
- **Persisted KMS configuration secrets** are sealed field-by-field with `RUSTFS_KMS_CONFIG_SECRET`. **When the variable is unset the secrets are persisted in cleartext and the server only warns** (`persisted KMS configuration carries cleartext secrets`); it never refuses the write. Set it, identically, on every node, and re-save the configuration to seal an existing one.
- **New v2 ciphertext frame format** with per-frame index binding and final-frame authentication. Its write switch `RUSTFS_ENCRYPTION_FRAME_V2` is **off by default**: v2 frames are unreadable by nodes without v2 read support, and encrypted ciphertext travels verbatim through transition, decommission and SSE-C replication passthrough, so turn it on only after every node — and every RustFS warm/replication target that receives raw ciphertext — runs a release with v2 read support. Reading v2 objects needs no switch.
+46
View File
@@ -0,0 +1,46 @@
# RustFS — CLAUDE.md
S3-compatible object store in Rust, derived from MinIO. Erasure-coded,
multi-pool, supports ILM tiering/lifecycle.
Agent rules (change style, verification, PR conventions, scoped per-crate
guidance) live in [AGENTS.md](AGENTS.md) — follow it. This file only adds
what Claude Code needs on top: commands and pointers.
## Commands
```bash
cargo build --release --bin rustfs # production binary
cargo check -p <crate> # fast type-check one crate
cargo test -p <crate> # test one crate
cargo fmt --all --check # for Rust changes; see AGENTS.md verification tiers
make pre-commit # fast gate: fmt + arch checks + quick-check (NO clippy/tests)
make pre-pr # optional full gate for broad cross-module changes
make build-docker BUILD_OS=ubuntu22.04
```
> **Docker build note**: `buildx build` without `--load` keeps the image in
> the buildx cache only — `docker run` will use a stale local image. The
> Makefile already includes `--load`; if you suspect a stale binary, add
> `--no-cache` to the `buildx build` invocation inside
> `.config/make/build-docker.mak`.
## Where to look (do not duplicate here)
- Agent knowledge base index and doc-writing rules: [docs/architecture/README.md](docs/architecture/README.md)
- Crate membership: `Cargo.toml` `[workspace].members`
- Architecture, layering, crate map: [ARCHITECTURE.md](ARCHITECTURE.md)
- Migration guardrails & readiness contracts: [docs/architecture/](docs/architecture/README.md)
- CI workflow steps: `.github/workflows/`; event, timeout, and required-status
matrix: [docs/testing/ci-gates.md](docs/testing/ci-gates.md)
- Test-layer taxonomy, per-layer entry commands, serial/nextest rules, flake
policy: [docs/testing/README.md](docs/testing/README.md)
- Tier/ILM transition debugging (xl.meta inspection, versionId tracing):
[docs/operations/tier-ilm-debugging.md](docs/operations/tier-ilm-debugging.md)
## Domain conventions worth knowing up front
Repo-wide domain invariants (dual internal metadata keys, defensive UUID
reads, unversioned tier buckets) live in [AGENTS.md](AGENTS.md) under
"Cross-Cutting Storage Invariants" — read them before touching metadata or
tiering code.
Generated
+530 -396
View File
File diff suppressed because it is too large Load Diff
+82 -80
View File
@@ -18,6 +18,7 @@ members = [
"crates/audit", # Audit target management system with multi-target fan-out
"crates/checksums", # client checksums
"crates/common", # Shared utilities and data structures
"crates/capture-authority", # Durable capture authority storage
"crates/data-usage", # Shared data usage models and algorithms
"crates/config", # Configuration management
"crates/connect-symbol-catalog", # Release-only CPU profile symbol catalog generator
@@ -74,7 +75,7 @@ edition = "2024"
license = "Apache-2.0"
repository = "https://github.com/rustfs/rustfs"
rust-version = "1.99.0"
version = "1.0.2"
version = "1.0.1"
homepage = "https://rustfs.com"
description = "RustFS is a high-performance distributed object storage software built using Rust, one of the most popular languages worldwide. "
keywords = ["RustFS", "Minio", "object-storage", "filesystem", "s3"]
@@ -91,56 +92,56 @@ redundant_clone = "warn"
[workspace.dependencies]
# RustFS Internal Crates
rustfs = { path = "./rustfs", version = "1.0.2" }
rustfs-heal = { path = "crates/heal", version = "1.0.2" }
rustfs-heal-contracts = { path = "crates/heal-contracts", version = "1.0.2" }
rustfs-scanner-metrics = { path = "crates/scanner-metrics", version = "1.0.2" }
rustfs-audit = { path = "crates/audit", version = "1.0.2" }
rustfs-checksums = { path = "crates/checksums", version = "1.0.2" }
rustfs-common = { path = "crates/common", version = "1.0.2" }
rustfs-data-usage = { path = "crates/data-usage", version = "1.0.2" }
rustfs-config = { path = "./crates/config", version = "1.0.2" }
rustfs-concurrency = { path = "./crates/concurrency", version = "1.0.2" }
rustfs-credentials = { path = "crates/credentials", version = "1.0.2" }
rustfs-crypto = { path = "crates/crypto", version = "1.0.2" }
rustfs-ecstore = { path = "crates/ecstore", version = "1.0.2" }
rustfs-filemeta = { path = "crates/filemeta", version = "1.0.2" }
rustfs-iam = { path = "crates/iam", version = "1.0.2" }
rustfs-keystone = { path = "crates/keystone", version = "1.0.2" }
rustfs-license = { path = "crates/license", version = "1.0.2" }
rustfs-lifecycle = { path = "crates/lifecycle", version = "1.0.2" }
rustfs-kms = { path = "crates/kms", version = "1.0.2" }
rustfs-lock = { path = "crates/lock", version = "1.0.2" }
rustfs-madmin = { path = "crates/madmin", version = "1.0.2" }
rustfs-notify = { path = "crates/notify", version = "1.0.2" }
rustfs-io-metrics = { path = "crates/io-metrics", version = "1.0.2" }
rustfs-io-core = { path = "crates/io-core", version = "1.0.2" }
rustfs-object-capacity = { path = "crates/object-capacity", version = "1.0.2" }
rustfs-object-data-cache = { path = "crates/object-data-cache", version = "1.0.2", default-features = false }
rustfs-log-analyzer = { path = "crates/log-analyzer", version = "1.0.2" }
rustfs-obs = { path = "crates/obs", version = "1.0.2" }
rustfs-policy = { path = "crates/policy", version = "1.0.2" }
rustfs-protos = { path = "crates/protos", version = "1.0.2" }
rustfs-protocols = { path = "crates/protocols", version = "1.0.2" }
rustfs-replication = { path = "crates/replication", version = "1.0.2" }
rustfs-rio = { path = "crates/rio", version = "1.0.2" }
rustfs-rio-v2 = { path = "crates/rio-v2", version = "1.0.2" }
rustfs-s3-client = { path = "crates/s3-client", version = "1.0.2" }
rustfs-s3-types = { path = "crates/s3-types", version = "1.0.2" }
rustfs-s3-ops = { path = "crates/s3-ops", version = "1.0.2" }
rustfs-s3select-api = { path = "crates/s3select-api", version = "1.0.2" }
rustfs-s3select-query = { path = "crates/s3select-query", version = "1.0.2" }
rustfs-scanner = { path = "crates/scanner", version = "1.0.2" }
rustfs-security-governance = { path = "crates/security-governance", version = "1.0.2" }
rustfs-extension-schema = { path = "crates/extension-schema", version = "1.0.2" }
rustfs-signer = { path = "crates/signer", version = "1.0.2" }
rustfs-storage-api = { path = "crates/storage-api", version = "1.0.2" }
rustfs-trusted-proxies = { path = "crates/trusted-proxies", version = "1.0.2" }
rustfs-targets = { path = "crates/targets", version = "1.0.2" }
rustfs-test-utils = { path = "crates/test-utils", version = "1.0.2" }
rustfs-tls-runtime = { path = "crates/tls-runtime", version = "1.0.2" }
rustfs-utils = { path = "crates/utils", version = "1.0.2" }
rustfs-zip = { path = "./crates/zip", version = "1.0.2" }
rustfs = { path = "./rustfs", version = "1.0.1" }
rustfs-heal = { path = "crates/heal", version = "1.0.1" }
rustfs-heal-contracts = { path = "crates/heal-contracts", version = "1.0.1" }
rustfs-scanner-metrics = { path = "crates/scanner-metrics", version = "1.0.1" }
rustfs-audit = { path = "crates/audit", version = "1.0.1" }
rustfs-checksums = { path = "crates/checksums", version = "1.0.1" }
rustfs-common = { path = "crates/common", version = "1.0.1" }
rustfs-data-usage = { path = "crates/data-usage", version = "1.0.1" }
rustfs-config = { path = "./crates/config", version = "1.0.1" }
rustfs-concurrency = { path = "./crates/concurrency", version = "1.0.1" }
rustfs-credentials = { path = "crates/credentials", version = "1.0.1" }
rustfs-crypto = { path = "crates/crypto", version = "1.0.1" }
rustfs-ecstore = { path = "crates/ecstore", version = "1.0.1" }
rustfs-filemeta = { path = "crates/filemeta", version = "1.0.1" }
rustfs-iam = { path = "crates/iam", version = "1.0.1" }
rustfs-keystone = { path = "crates/keystone", version = "1.0.1" }
rustfs-license = { path = "crates/license", version = "1.0.1" }
rustfs-lifecycle = { path = "crates/lifecycle", version = "1.0.1" }
rustfs-kms = { path = "crates/kms", version = "1.0.1" }
rustfs-lock = { path = "crates/lock", version = "1.0.1" }
rustfs-madmin = { path = "crates/madmin", version = "1.0.1" }
rustfs-notify = { path = "crates/notify", version = "1.0.1" }
rustfs-io-metrics = { path = "crates/io-metrics", version = "1.0.1" }
rustfs-io-core = { path = "crates/io-core", version = "1.0.1" }
rustfs-object-capacity = { path = "crates/object-capacity", version = "1.0.1" }
rustfs-object-data-cache = { path = "crates/object-data-cache", version = "1.0.1", default-features = false }
rustfs-log-analyzer = { path = "crates/log-analyzer", version = "1.0.1" }
rustfs-obs = { path = "crates/obs", version = "1.0.1" }
rustfs-policy = { path = "crates/policy", version = "1.0.1" }
rustfs-protos = { path = "crates/protos", version = "1.0.1" }
rustfs-protocols = { path = "crates/protocols", version = "1.0.1" }
rustfs-replication = { path = "crates/replication", version = "1.0.1" }
rustfs-rio = { path = "crates/rio", version = "1.0.1" }
rustfs-rio-v2 = { path = "crates/rio-v2", version = "1.0.1" }
rustfs-s3-client = { path = "crates/s3-client", version = "1.0.1" }
rustfs-s3-types = { path = "crates/s3-types", version = "1.0.1" }
rustfs-s3-ops = { path = "crates/s3-ops", version = "1.0.1" }
rustfs-s3select-api = { path = "crates/s3select-api", version = "1.0.1" }
rustfs-s3select-query = { path = "crates/s3select-query", version = "1.0.1" }
rustfs-scanner = { path = "crates/scanner", version = "1.0.1" }
rustfs-security-governance = { path = "crates/security-governance", version = "1.0.1" }
rustfs-extension-schema = { path = "crates/extension-schema", version = "1.0.1" }
rustfs-signer = { path = "crates/signer", version = "1.0.1" }
rustfs-storage-api = { path = "crates/storage-api", version = "1.0.1" }
rustfs-trusted-proxies = { path = "crates/trusted-proxies", version = "1.0.1" }
rustfs-targets = { path = "crates/targets", version = "1.0.1" }
rustfs-test-utils = { path = "crates/test-utils", version = "1.0.1" }
rustfs-tls-runtime = { path = "crates/tls-runtime", version = "1.0.1" }
rustfs-utils = { path = "crates/utils", version = "1.0.1" }
rustfs-zip = { path = "./crates/zip", version = "1.0.1" }
# Async Runtime and Networking
async-channel = "2.5.0"
@@ -155,10 +156,9 @@ futures-core = "0.3.34"
futures-lite = "2.6.1"
futures-util = "0.3.34"
pollster = "1.0.1"
pulsar = { default-features = false, version = "=6.9.0" }
lapin = { default-features = false, version = "4.12.2" }
pulsar = { default-features = false, version = "6.9.0" }
lapin = { default-features = false, version = "4.12.0" }
hyper = { version = "1.12.0" }
h2 = { version = "0.4.20" }
hyper-rustls = { default-features = false, version = "0.27.10" }
hyper-util = { version = "0.1.21" }
http = "1.5.0"
@@ -169,11 +169,11 @@ minlz = "1.2.3"
reqwest = "0.13.5"
rustfs-kafka-async = { version = "1.4.0" }
socket2 = { version = "0.6.5" }
tokio = { version = "1.53.3" }
tokio = { version = "1.53.2" }
tokio-rustls = { default-features = false, version = "0.26.6" }
tokio-stream = { version = "0.1.19" }
tokio-test = "0.4.6"
tokio-util = { version = "0.7.20" }
tokio-util = { version = "0.7.19" }
tonic = { version = "0.14.6" }
tonic-prost = { version = "0.14.6" }
tonic-prost-build = { version = "0.14.6" }
@@ -181,7 +181,7 @@ tower = { version = "0.5.3" }
tower-http = { version = "0.7.1" }
h3 = "0.0.8"
h3-quinn = "0.0.10"
quinn = { version = "0.11.12", default-features = false, features = [
quinn = { version = "0.11.11", default-features = false, features = [
"futures-io",
"runtime-tokio",
"rustls-aws-lc-rs",
@@ -201,7 +201,7 @@ rmp-serde = { version = "1.3.1" }
serde = { version = "1.0.229" }
serde_ignored = { version = "0.1" }
serde_with = { version = "3", default-features = false, features = ["macros", "std"] }
serde_json = { version = "1.0.154" }
serde_json = { version = "1.0.151" }
serde_urlencoded = "0.7.1"
# Cryptography and Security
@@ -220,12 +220,12 @@ openidconnect = { default-features = false, version = "4.0" }
pbkdf2 = "0.13.0"
p256 = { version = "0.14.0", features = ["ecdsa", "pkcs8"] }
rsa = { version = "=0.10.0-rc.18" }
rustls = { default-features = false, version = "0.23.46" }
rustls = { default-features = false, version = "0.23.45" }
rustls-native-certs = "0.8"
rustls-pki-types = "1.15.1"
x509-parser = "0.18.1"
sha1 = "0.11.0"
sha2 = "0.11.1"
sha2 = "0.11.0"
subtle = "2.6"
zeroize = { version = "1.9.1" }
proptest = "1"
@@ -246,8 +246,8 @@ anyhow = "1.0.104"
arc-swap = "1.9.2"
rustfs-tokio-tar = { version = "0.7.1" }
# Candidate Snowball parser versions exercised by rustfs-zip compatibility fixtures.
tar-codec = "0.0.15"
tar-framing = "0.0.15"
tar-codec = "0.0.14"
tar-framing = "0.0.14"
atoi = "3.1.0"
atomic_enum = "0.3.0"
aws-config = { version = "1.12.0" }
@@ -269,7 +269,7 @@ crossbeam-queue = "0.3.14"
crossbeam-channel = "0.5.17"
crossbeam-deque = "0.8.8"
crossbeam-utils = "0.8.23"
datafusion = { default-features = false, version = "55.2.0" }
datafusion = { default-features = false, version = "55.1.0" }
derive_builder = "0.20.2"
ed25519-dalek = "3.0.0"
enumset = "1.1.14"
@@ -315,19 +315,21 @@ ratelimit = "2.0.0"
rayon = "1.12.0"
rustfs-erasure-codec = { version = "9.0.0" }
reed-solomon-simd = "3.1.0"
openraft = { version = "=0.9.25", default-features = false, features = ["serde"] }
redb = "=4.3.0"
regex = { version = "1.13.1" }
rumqttc = { package = "rumqttc-next", version = "0.34.0" }
redis = { version = "1.7.1" }
rustify = { version = "0.7", default-features = false }
rustix = { version = "1.1.5" }
rust-embed = { version = "8.13.0" }
rust-embed = { version = "8.12.0" }
rustc-hash = { version = "2.1.3" }
s3s = { git = "https://github.com/s3s-project/s3s", rev = "5761ddfe4c509fcac87e3b99fd5bd9e176b442c0", features = ["minio"] }
s3s-sigv4 = { git = "https://github.com/s3s-project/s3s", rev = "5761ddfe4c509fcac87e3b99fd5bd9e176b442c0" }
serial_test = "4.0.1"
shadow-rs = { default-features = false, version = "2.0.0" }
siphasher = "1.0.4"
smallvec = { version = "1.16.3" }
smallvec = { version = "1.16.2" }
compact_str = "0.10.0"
snap = "1.1.2"
starshard = { version = "2.4.0" }
@@ -341,12 +343,12 @@ tracing = { version = "0.1.44" }
tracing-appender = "0.2.5"
tracing-core = "0.1.36"
tracing-error = "0.2.1"
tracing-opentelemetry = { version = "0.34.0" }
tracing-opentelemetry = { version = "0.34" }
tracing-subscriber = { version = "0.3.23" }
transform-stream = "0.3.1"
url = "2.5.8"
urlencoding = "2.1.3"
uuid = { version = "1.28.0" }
uuid = { version = "1.27.0" }
vaultrs = { version = "0.8.0" }
tar = "0.4.46"
walkdir = "2.5.0"
@@ -354,26 +356,26 @@ winapi-util = "0.1.11"
windows = { version = "0.62.2" }
windows-sys = "0.61.2"
xxhash-rust = { version = "0.8.19" }
zip = "9.0.3"
zstd = "0.14.2"
zip = "8.6.0"
zstd = "0.14.0"
# Observability and Metrics
metrics = "0.24.6"
metrics-util = "0.20"
dial9 = { version = "0.5.4", default-features = false }
opentelemetry = { version = "0.33.1" }
opentelemetry-appender-tracing = { version = "0.33.1" }
opentelemetry-otlp = { version = "0.33.1" }
opentelemetry-proto = { version = "0.33.1", default-features = false, features = ["metrics", "gen-tonic-messages"] }
opentelemetry_sdk = { version = "0.33.1" }
opentelemetry-semantic-conventions = { version = "0.33.1" }
opentelemetry-stdout = { version = "0.33.1" }
pyroscope = { version = "2.1.2" }
dial9 = { version = "0.5.3", default-features = false }
opentelemetry = { version = "0.33.0" }
opentelemetry-appender-tracing = { version = "0.33.0" }
opentelemetry-otlp = { version = "0.33.0" }
opentelemetry-proto = { version = "0.33.0", default-features = false, features = ["metrics", "gen-tonic-messages"] }
opentelemetry_sdk = { version = "0.33.0" }
opentelemetry-semantic-conventions = { version = "0.33.0" }
opentelemetry-stdout = { version = "0.33.0" }
pyroscope = { version = "2.1.1" }
# FTP and SFTP
libunftp = { version = "0.23.1" }
unftp-core = "0.1.0"
suppaftp = { version = "12.2.1" }
suppaftp = { version = "12.1.1" }
rcgen = { version = "0.14.10", default-features = false, features = ["aws_lc_rs", "crypto", "pem"] }
russh = { version = "0.64.1" }
russh-sftp = "3.0.1"
@@ -384,7 +386,7 @@ dav-server = "0.11.0"
# Performance Analysis and Memory Profiling
rustfs-mimalloc = { version = "0.5.6" }
# Preserve Unicode focus filters until rustfs/backlog#2302 is resolved.
hotpath = { version = "0.28.6", default-features = false }
hotpath = { version = "0.28.5", default-features = false }
# Snapshot testing for output format regression detection
insta = { version = "1.49" }
+2 -2
View File
@@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
FROM alpine:3.24.2 AS build
FROM alpine:3.24.1 AS build
ARG TARGETARCH
ARG RELEASE=latest
@@ -70,7 +70,7 @@ RUN set -eux; \
rm -rf rustfs.zip /build/.tmp || true
FROM alpine:3.24.2
FROM alpine:3.24.1
ARG RELEASE=latest
ARG BUILD_DATE
+3 -3
View File
@@ -141,7 +141,7 @@ chown -R 10001:10001 data logs
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
# Using specific version
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.3
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.1
```
If you use [podman](https://github.com/containers/podman) instead of docker, you can install the RustFS with the below command
@@ -220,7 +220,7 @@ For developers who want to build RustFS Docker images from source with multi-arc
./docker-buildx.sh --push
# Build specific version
./docker-buildx.sh --release 1.0.3 --push
./docker-buildx.sh --release 1.0.1 --push
# Build for custom registry
./docker-buildx.sh --registry your-registry.com --namespace yourname --push
@@ -238,7 +238,7 @@ You can also use Make targets for convenience:
```bash
make docker-buildx # Build locally
make docker-buildx-push # Build and push
make docker-buildx-version VERSION=1.0.3 # Build specific version
make docker-buildx-version VERSION=1.0.1 # Build specific version
make help-docker # Show all Docker-related commands
```
+3 -3
View File
@@ -138,7 +138,7 @@ chown -R 10001:10001 data logs
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
# 使用指定版本运行
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.3
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.1
```
如果您通过绑定挂载启用 TLS 证书目录,也请用同样方式准备该目录:
@@ -176,7 +176,7 @@ docker compose -f docker-compose-simple.yml up -d
./docker-buildx.sh --push
# 构建指定版本
./docker-buildx.sh --release 1.0.3 --push
./docker-buildx.sh --release 1.0.1 --push
# 构建并推送到自定义仓库
./docker-buildx.sh --registry your-registry.com --namespace yourname --push
@@ -193,7 +193,7 @@ docker compose -f docker-compose-simple.yml up -d
```bash
make docker-buildx # 本地构建
make docker-buildx-push # 构建并推送
make docker-buildx-version VERSION=1.0.3 # 构建指定版本
make docker-buildx-version VERSION=1.0.1 # 构建指定版本
make help-docker # 显示所有 Docker 相关命令
```
-2
View File
@@ -51,8 +51,6 @@ unparseable = "unparseable"
# Disaster-recovery objectives: recovery time and recovery point.
RTO = "RTO"
rto = "rto"
# rustix OFlags variant (POSIX O_WRONLY equivalent).
WRONLY = "WRONLY"
[files]
extend-exclude = []
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "rustfs-capture-authority"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
description = "Durable local storage for capture authority"
[dependencies]
openraft = { workspace = true }
redb = { workspace = true }
sha2 = { workspace = true }
serde = { workspace = true, features = ["derive"] }
thiserror = { workspace = true }
tokio = { workspace = true }
url = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
tempfile = { workspace = true }
[lints]
workspace = true
+941
View File
@@ -0,0 +1,941 @@
fn read_native_range_in<R: std::ops::RangeBounds<u64>>(
tx: &redb::ReadTransaction,
range: R,
) -> Result<Vec<crate::CaptureRaftEntry>, StoreError> {
use std::ops::Bound::{Excluded, Included, Unbounded};
let logs = tx.open_table(LOGS)?;
let start = match range.start_bound() {
Included(value) => *value,
Excluded(value) => match value.checked_add(1) {
Some(value) => value,
None => return Ok(Vec::new()),
},
Unbounded => 0,
};
let end = match range.end_bound() {
Included(value) => *value,
Excluded(value) => match value.checked_sub(1) {
Some(value) => value,
None => return Ok(Vec::new()),
},
Unbounded => u64::MAX,
};
if start > end {
return Ok(Vec::new());
}
let mut entries = Vec::new();
for row in logs.range(start..=end)? {
let (key, value) = row?;
let entry = decode_log(value.value())?;
if entry.id.index != key.value() {
return Err(StoreError::CorruptRecord);
}
entries.push(decode_native_entry(entry)?);
}
Ok(entries)
}
use crate::{
LOGS, LogId, META, Store, StoreError, committed::validate_committed, created::*, decode_log, encode_id, encode_log,
next_index, number,
};
use redb::{ReadTransaction, ReadableDatabase, ReadableTable, ReadableTableMetadata, TableDefinition};
use std::collections::BTreeMap;
pub(crate) const HEADS: TableDefinition<&[u8], &[u8]> = TableDefinition::new("heads");
pub(crate) const VERSIONS: TableDefinition<&[u8], &[u8]> = TableDefinition::new("versions");
pub(crate) const EVENTS: TableDefinition<&[u8], &[u8]> = TableDefinition::new("events");
pub(crate) const OPERATIONS: TableDefinition<&[u8], &[u8]> = TableDefinition::new("operations");
pub(crate) const APPLY_RESULTS: TableDefinition<u64, &[u8]> = TableDefinition::new("apply_results");
fn version_key(object: &ObjectIdentity, version: [u8; 16]) -> Result<Vec<u8>, StoreError> {
let mut b = object_bytes(object)?;
b.extend(version);
Ok(b)
}
fn decode_version(b: &[u8]) -> Result<CreatedVersion, StoreError> {
let mut d = Decoder::new(b);
let v = CreatedVersion {
prepared: d.prepared()?,
decision_log_id: d.id()?,
binding_revision: d.u64()?,
};
d.finish()?;
Ok(v)
}
fn decode_event(b: &[u8]) -> Result<CreatedEvent, StoreError> {
let mut d = Decoder::new(b);
let e = CreatedEvent {
object: d.object()?,
prepared: d.prepared()?,
decision_log_id: d.id()?,
binding: d.binding()?,
};
d.finish()?;
Ok(e)
}
fn decode_operation(b: &[u8]) -> Result<CreatedOperation, StoreError> {
let mut d = Decoder::new(b);
let len = usize::try_from(d.u64()?).map_err(|_| StoreError::CorruptRecord)?;
let command = b
.get(8..8usize.checked_add(len).ok_or(StoreError::CorruptRecord)?)
.ok_or(StoreError::CorruptRecord)?;
DecideCreated::decode(command)?;
let mut d = Decoder::new(&b[8 + len..]);
let result = d.result()?;
d.finish()?;
Ok(CreatedOperation {
canonical_command: command.to_vec(),
result,
})
}
fn decode_apply(b: &[u8]) -> Result<CreatedApplyResult, StoreError> {
let mut d = Decoder::new(b);
let r = CreatedApplyResult {
log_id: d.id()?,
result: d.result()?,
};
d.finish()?;
Ok(r)
}
pub(crate) fn read_binding(meta: &impl ReadableTable<&'static str, &'static [u8]>) -> Result<CaptureBinding, StoreError> {
let b = meta.get("capture_binding")?.ok_or(StoreError::BindingNotPinned)?;
let mut d = Decoder::new(b.value());
let binding = d.binding()?;
d.finish()?;
Ok(binding)
}
fn read_applied(meta: &impl ReadableTable<&'static str, &'static [u8]>) -> Result<Option<LogId>, StoreError> {
let b = meta.get("last_applied")?.ok_or(StoreError::CorruptRecord)?;
let mut d = Decoder::new(b.value());
let id = match d.take::<1>()?[0] {
0 => None,
1 => Some(d.id()?),
_ => return Err(StoreError::CorruptRecord),
};
d.finish()?;
Ok(id)
}
fn required<T>(value: Option<T>) -> Result<T, StoreError> {
value.ok_or(StoreError::CorruptRecord)
}
fn encode_native_entry(entry: &crate::CaptureRaftEntry) -> Result<Vec<u8>, StoreError> {
let id: LogId = entry.log_id.into();
let (kind, body) = match &entry.payload {
openraft::EntryPayload::Blank => (0, vec![]),
openraft::EntryPayload::Normal(command) => (1, command.created().encode()?),
openraft::EntryPayload::Membership(membership) => {
let stored = openraft::StoredMembership::new(Some(entry.log_id), membership.clone());
let checked = crate::CaptureMembership::try_from(&stored)?;
(2, checked.encode()?)
}
};
let mut payload = b"CENR\x01".to_vec();
payload.push(kind);
payload.extend(
u64::try_from(body.len())
.map_err(|_| StoreError::CorruptRecord)?
.to_be_bytes(),
);
payload.extend(body);
encode_log(&crate::LogEntry { id, payload })
}
pub(crate) fn decode_native_entry(entry: crate::LogEntry) -> Result<crate::CaptureRaftEntry, StoreError> {
let bytes = &entry.payload;
if bytes.len() < 14 || &bytes[..5] != b"CENR\x01" {
return Err(StoreError::CorruptRecord);
}
let length = usize::try_from(number(&bytes[6..14])?).map_err(|_| StoreError::CorruptRecord)?;
let body = &bytes[14..];
if length != body.len() {
return Err(StoreError::CorruptRecord);
}
let payload = match bytes[5] {
0 if body.is_empty() => openraft::EntryPayload::Blank,
1 => openraft::EntryPayload::Normal(crate::CaptureCommand::try_created(DecideCreated::decode(body)?)?),
2 => {
let membership = crate::CaptureMembership::decode(body).map_err(|_| StoreError::CorruptRecord)?;
if *membership.log_id() != Some(entry.id) {
return Err(StoreError::CorruptRecord);
}
let stored = openraft::StoredMembership::try_from(&membership).map_err(|_| StoreError::CorruptRecord)?;
openraft::EntryPayload::Membership(stored.membership().clone())
}
_ => return Err(StoreError::CorruptRecord),
};
Ok(crate::CaptureRaftEntry {
log_id: entry.id.into(),
payload,
})
}
fn apply_created(
tx: &redb::WriteTransaction,
id: LogId,
command: &DecideCreated,
canonical: &[u8],
binding: &CaptureBinding,
) -> Result<CreatedResult, StoreError> {
let mut operations = tx.open_table(OPERATIONS)?;
let existing = operations
.get(command.operation_id.as_slice())?
.map(|v| decode_operation(v.value()))
.transpose()?;
let result = if let Some(existing) = existing {
if existing.canonical_command == canonical {
existing.result
} else {
CreatedResult::OperationConflict
}
} else {
let mut heads = tx.open_table(HEADS)?;
let mut versions = tx.open_table(VERSIONS)?;
let object = object_bytes(&command.object)?;
let key = version_key(&command.object, command.prepared.version_id)?;
let actual = heads
.get(object.as_slice())?
.map(|v| {
let mut d = Decoder::new(v.value());
let id = d.uuid()?;
d.finish()?;
Ok::<_, StoreError>(id)
})
.transpose()?;
let outcome = if command.binding_revision != binding.revision {
CreatedResult::BindingMismatch
} else if actual != command.expected_head {
CreatedResult::HeadMismatch { actual_head: actual }
} else if versions.get(key.as_slice())?.is_some() {
CreatedResult::VersionConflict
} else {
let mut version = Vec::new();
put_prepared(&mut version, &command.prepared);
version.extend(encode_id(id));
version.extend(binding.revision.to_be_bytes());
versions.insert(key.as_slice(), version.as_slice())?;
let mut event = object;
put_prepared(&mut event, &command.prepared);
event.extend(encode_id(id));
event.extend(binding_bytes(&binding)?);
tx.open_table(EVENTS)?
.insert(command.operation_id.as_slice(), event.as_slice())?;
heads.insert(object_bytes(&command.object)?.as_slice(), command.prepared.version_id.as_slice())?;
CreatedResult::Created {
version_id: command.prepared.version_id,
event_id: command.operation_id,
decision_log_id: id,
}
};
let mut op = u64::try_from(canonical.len())
.map_err(|_| StoreError::CorruptRecord)?
.to_be_bytes()
.to_vec();
op.extend(canonical);
op.extend(result_bytes(&outcome));
operations.insert(command.operation_id.as_slice(), op.as_slice())?;
outcome
};
Ok(result)
}
fn validate_response_kind(entry: &crate::CaptureRaftEntry, response: &crate::CaptureResponse) -> Result<(), StoreError> {
let kind = match entry.payload {
openraft::EntryPayload::Blank => crate::CaptureResponseKind::Blank,
openraft::EntryPayload::Normal(_) => crate::CaptureResponseKind::Created,
openraft::EntryPayload::Membership(_) => crate::CaptureResponseKind::Membership,
};
if response.kind() != kind || *response.log_id() != LogId::from(entry.log_id) {
return Err(StoreError::CorruptRecord);
}
Ok(())
}
fn encode_response(response: &crate::CaptureResponse) -> Result<Vec<u8>, StoreError> {
let (kind, body) = match response.kind() {
crate::CaptureResponseKind::Blank => (0, vec![]),
crate::CaptureResponseKind::Created => (1, result_bytes(response.created_result().ok_or(StoreError::CorruptRecord)?)),
crate::CaptureResponseKind::Membership => (2, vec![]),
};
let mut bytes = b"CRSP\x01".to_vec();
bytes.push(kind);
bytes.extend(encode_id(*response.log_id()));
bytes.extend(
u64::try_from(body.len())
.map_err(|_| StoreError::CorruptRecord)?
.to_be_bytes(),
);
bytes.extend(body);
Ok(bytes)
}
fn decode_response(bytes: &[u8]) -> Result<crate::CaptureResponse, StoreError> {
if bytes.len() < 38
|| &bytes[..5] != b"CRSP\x01"
|| number(&bytes[30..38])? != u64::try_from(bytes.len() - 38).map_err(|_| StoreError::CorruptRecord)?
{
return Err(StoreError::CorruptRecord);
}
let id = crate::decode_id(&bytes[6..30])?;
match bytes[5] {
0 if bytes.len() == 38 => crate::CaptureResponse::blank(id),
1 => {
let mut body = encode_id(id);
body.extend(&bytes[38..]);
crate::CaptureResponse::created(decode_apply(&body)?)
}
2 if bytes.len() == 38 => crate::CaptureResponse::membership(id),
_ => Err(StoreError::CorruptRecord),
}
}
fn entry_command(entry: &crate::LogEntry, native: bool) -> Result<DecideCreated, StoreError> {
if native {
match decode_native_entry(entry.clone())?.payload {
openraft::EntryPayload::Normal(command) => Ok(command.created().clone()),
_ => Err(StoreError::CorruptRecord),
}
} else {
DecideCreated::decode(&entry.payload)
}
}
pub(crate) fn native_application_state(tx: &ReadTransaction) -> Result<(Option<LogId>, crate::CaptureMembership), StoreError> {
native_application_metadata(&tx.open_table(META)?)
}
fn native_application_metadata(
meta: &impl ReadableTable<&'static str, &'static [u8]>,
) -> Result<(Option<LogId>, crate::CaptureMembership), StoreError> {
if number(required(meta.get("schema")?)?.value())? != 4 {
return Err(StoreError::UnsupportedSchema);
}
let last = read_applied(meta)?;
let membership = crate::CaptureMembership::decode(required(meta.get("native_membership")?)?.value())
.map_err(|_| StoreError::CorruptRecord)?;
if membership
.log_id()
.is_some_and(|id| last.is_none_or(|last| id.index > last.index))
{
return Err(StoreError::CorruptRecord);
}
Ok((last, membership))
}
impl Store {
pub fn migrate_legacy_created_to_native(&mut self) -> Result<(), StoreError> {
let tx = self.write_transaction()?;
{
let mut meta = tx.open_table(META)?;
let schema = number(required(meta.get("schema")?)?.value())?;
if !matches!(schema, 3 | 4) {
return Err(StoreError::UnsupportedSchema);
}
let mut logs = tx.open_table(LOGS)?;
let heads = tx.open_table(HEADS)?;
let versions = tx.open_table(VERSIONS)?;
let events = tx.open_table(EVENTS)?;
let operations = tx.open_table(OPERATIONS)?;
let mut results = tx.open_table(APPLY_RESULTS)?;
validate_application_tables(&meta, &logs, &heads, &versions, &events, &operations, &results)?;
if schema == 4 {
return Ok(());
}
if meta.get("native_membership")?.is_some() {
return Err(StoreError::CorruptRecord);
}
// Validate the entire retained suffix before replacing any record.
let mut native_logs = Vec::new();
for row in logs.iter()? {
let (index, bytes) = row?;
let entry = decode_log(bytes.value())?;
let command = DecideCreated::decode(&entry.payload)?;
if entry.id.index != index.value() || command.encode()? != entry.payload {
return Err(StoreError::CorruptRecord);
}
let native = crate::CaptureRaftEntry {
log_id: entry.id.into(),
payload: openraft::EntryPayload::Normal(crate::CaptureCommand::try_created(command)?),
};
native_logs.push((index.value(), encode_native_entry(&native)?));
}
let mut native_results = Vec::new();
for row in results.iter()? {
let (index, bytes) = row?;
let response = crate::CaptureResponse::created(decode_apply(bytes.value())?)?;
native_results.push((index.value(), encode_response(&response)?));
}
for (index, bytes) in native_logs {
logs.insert(index, bytes.as_slice())?;
}
for (index, bytes) in native_results {
results.insert(index, bytes.as_slice())?;
}
let membership = crate::CaptureMembership::new(None, vec![], BTreeMap::new())?;
meta.insert("native_membership", membership.encode()?.as_slice())?;
meta.insert("schema", 4u64.to_be_bytes().as_slice())?;
// Replay native decisions inside the same durable writer boundary.
validate_application_tables(&meta, &logs, &heads, &versions, &events, &operations, &results)?;
}
self.commit(tx)
}
/// Explicitly opt an empty, bound store into native entry persistence.
pub fn enable_native_empty(&mut self) -> Result<(), StoreError> {
let tx = self.write_transaction()?;
{
let mut meta = tx.open_table(META)?;
let schema = number(required(meta.get("schema")?)?.value())?;
if !matches!(schema, 3 | 4) {
return Err(StoreError::UnsupportedSchema);
}
read_binding(&meta)?;
if schema == 4 {
let membership = crate::CaptureMembership::decode(required(meta.get("native_membership")?)?.value())
.map_err(|_| StoreError::CorruptRecord)?;
let last = read_applied(&meta)?;
if membership
.log_id()
.is_some_and(|id| last.is_none_or(|last| id.index > last.index))
|| (last.is_none() && (!membership.configs().is_empty() || !membership.nodes().is_empty()))
{
return Err(StoreError::CorruptRecord);
}
return Ok(());
}
if read_applied(&meta)?.is_some() || !tx.open_table(LOGS)?.is_empty()? {
return Err(StoreError::UnsupportedSchema);
}
for table in [HEADS, VERSIONS, EVENTS, OPERATIONS] {
if !tx.open_table(table)?.is_empty()? {
return Err(StoreError::UnsupportedSchema);
}
}
if !tx.open_table(APPLY_RESULTS)?.is_empty()? || meta.get("committed")?.is_some() || meta.get("tail")?.is_some() {
return Err(StoreError::UnsupportedSchema);
}
if meta.get("native_membership")?.is_some() {
return Err(StoreError::CorruptRecord);
}
let membership = crate::CaptureMembership::new(None, vec![], BTreeMap::new())?;
meta.insert("native_membership", membership.encode()?.as_slice())?;
meta.insert("schema", 4u64.to_be_bytes().as_slice())?;
}
self.commit(tx)
}
pub fn append_native(&mut self, entries: &[crate::CaptureRaftEntry]) -> Result<(), StoreError> {
let tx = self.write_transaction()?;
{
let mut meta = tx.open_table(META)?;
if number(required(meta.get("schema")?)?.value())? != 4 {
return Err(StoreError::UnsupportedSchema);
}
if entries.is_empty() {
return Ok(());
}
let tail = meta.get("tail")?.map(|value| crate::decode_id(value.value())).transpose()?;
let mut next = next_index(tail.map(|id| id.index))?;
let mut encoded = Vec::with_capacity(entries.len());
for (position, entry) in entries.iter().enumerate() {
if entry.log_id.index != next {
return Err(StoreError::InvalidLogSequence);
}
encoded.push(encode_native_entry(entry)?);
if position + 1 < entries.len() {
next = next_index(Some(next))?;
}
}
let mut logs = tx.open_table(LOGS)?;
for (entry, bytes) in entries.iter().zip(&encoded) {
logs.insert(entry.log_id.index, bytes.as_slice())?;
}
let last = entries.last().ok_or(StoreError::InvalidLogSequence)?;
meta.insert("tail", encode_id(last.log_id.into()).as_slice())?;
}
self.commit(tx)
}
pub fn read_native_entry(&self, index: u64) -> Result<Option<crate::CaptureRaftEntry>, StoreError> {
let tx = self.database.begin_read()?;
if number(required(tx.open_table(META)?.get("schema")?)?.value())? != 4 {
return Err(StoreError::UnsupportedSchema);
}
tx.open_table(LOGS)?
.get(index)?
.map(|value| {
let entry = decode_log(value.value())?;
if entry.id.index != index {
return Err(StoreError::CorruptRecord);
}
decode_native_entry(entry)
})
.transpose()
}
/// Read typed native entries within the requested bounds.
pub fn read_native_range<R: std::ops::RangeBounds<u64>>(&self, range: R) -> Result<Vec<crate::CaptureRaftEntry>, StoreError> {
let tx = self.database.begin_read()?;
if number(required(tx.open_table(META)?.get("schema")?)?.value())? != 4 {
return Err(StoreError::UnsupportedSchema);
}
read_native_range_in(&tx, range)
}
pub fn read_native_entries(&self, start: u64, end: u64) -> Result<Vec<crate::CaptureRaftEntry>, StoreError> {
let tx = self.database.begin_read()?;
if number(required(tx.open_table(META)?.get("schema")?)?.value())? != 4 {
return Err(StoreError::UnsupportedSchema);
}
if start > end {
return Err(StoreError::InvalidLogSequence);
}
read_native_range_in(&tx, start..end)
}
pub fn apply_next_native(&mut self, id: LogId) -> Result<crate::CaptureResponse, StoreError> {
let tx = self.write_transaction()?;
let response;
{
let mut meta = tx.open_table(META)?;
if number(required(meta.get("schema")?)?.value())? != 4 {
return Err(StoreError::UnsupportedSchema);
}
let binding = read_binding(&meta)?;
let logs = tx.open_table(LOGS)?;
let committed = validate_committed(&meta, &logs)?.ok_or(StoreError::InvalidCommittedLog)?;
let entry = decode_log(required(logs.get(id.index)?)?.value())?;
if entry.id != id || id.index > committed.index {
return Err(StoreError::InvalidCommittedLog);
}
let native = decode_native_entry(entry)?;
let last = read_applied(&meta)?;
let mut results = tx.open_table(APPLY_RESULTS)?;
if last.is_some_and(|last| id.index <= last.index) {
let saved = decode_response(required(results.get(id.index)?)?.value())?;
if *saved.log_id() != id {
return Err(StoreError::InvalidApplySequence);
}
validate_response_kind(&native, &saved)?;
return Ok(saved);
}
if id.index != next_index(last.map(|last| last.index))? {
return Err(StoreError::InvalidApplySequence);
}
response = match native.payload {
openraft::EntryPayload::Blank => crate::CaptureResponse::blank(id)?,
openraft::EntryPayload::Normal(command) => {
let canonical = command.created().encode()?;
let result = apply_created(&tx, id, command.created(), &canonical, &binding)?;
crate::CaptureResponse::created(CreatedApplyResult { log_id: id, result })?
}
openraft::EntryPayload::Membership(membership) => {
let stored = openraft::StoredMembership::new(Some(id.into()), membership);
let checked = crate::CaptureMembership::try_from(&stored)?;
meta.insert("native_membership", checked.encode()?.as_slice())?;
crate::CaptureResponse::membership(id)?
}
};
results.insert(id.index, encode_response(&response)?.as_slice())?;
let mut last = vec![1];
last.extend(encode_id(id));
meta.insert("last_applied", last.as_slice())?;
}
self.commit(tx)?;
Ok(response)
}
pub fn native_apply_response(&self, index: u64) -> Result<Option<crate::CaptureResponse>, StoreError> {
let tx = self.database.begin_read()?;
if number(required(tx.open_table(META)?.get("schema")?)?.value())? != 4 {
return Err(StoreError::UnsupportedSchema);
}
let results = tx.open_table(APPLY_RESULTS)?;
results.get(index)?.map(|v| decode_response(v.value())).transpose()
}
pub fn native_application_state(&self) -> Result<(Option<LogId>, crate::CaptureMembership), StoreError> {
native_application_state(&self.database.begin_read()?)
}
/// Bootstrap the immutable capture policy. The caller must be the trusted
/// deployment provisioning owner. This crate does not authenticate callers;
/// do not expose this public method through an ordinary object request API.
/// Prepared identities assert identity only, not physical data durability.
pub fn pin_capture_binding(&mut self, binding: CaptureBinding) -> Result<(), StoreError> {
let encoded = binding_bytes(&binding)?;
let tx = self.write_transaction()?;
{
let mut meta = tx.open_table(META)?;
if matches!(number(required(meta.get("schema")?)?.value())?, 3 | 4) {
if read_binding(&meta)? == binding {
return Ok(());
}
return Err(StoreError::BindingConflict);
}
if meta.get("capture_binding")?.is_some() || meta.get("last_applied")?.is_some() {
return Err(StoreError::CorruptRecord);
}
for table in [HEADS, VERSIONS, EVENTS, OPERATIONS] {
if !tx.open_table(table)?.is_empty()? {
return Err(StoreError::CorruptRecord);
}
}
if !tx.open_table(APPLY_RESULTS)?.is_empty()? {
return Err(StoreError::CorruptRecord);
}
meta.insert("capture_binding", encoded.as_slice())?;
meta.insert("last_applied", &[0][..])?;
meta.insert("schema", 3u64.to_be_bytes().as_slice())?;
}
self.commit(tx)
}
/// Apply the next exact persisted committed Created command atomically.
pub fn apply_next_created(&mut self, id: LogId) -> Result<CreatedResult, StoreError> {
let tx = self.write_transaction()?;
let result;
{
let mut meta = tx.open_table(META)?;
if number(required(meta.get("schema")?)?.value())? != 3 {
return Err(StoreError::UnsupportedSchema);
}
let binding = read_binding(&meta)?;
let logs = tx.open_table(LOGS)?;
let committed = validate_committed(&meta, &logs)?.ok_or(StoreError::InvalidCommittedLog)?;
let entry = decode_log(required(logs.get(id.index)?)?.value())?;
if entry.id != id || id.index > committed.index {
return Err(StoreError::InvalidCommittedLog);
}
let last = read_applied(&meta)?;
let mut results = tx.open_table(APPLY_RESULTS)?;
if last.is_some_and(|last| id.index <= last.index) {
let saved = decode_apply(required(results.get(id.index)?)?.value())?;
if saved.log_id != id {
return Err(StoreError::InvalidApplySequence);
}
return Ok(saved.result);
}
if id.index != next_index(last.map(|last| last.index))? {
return Err(StoreError::InvalidApplySequence);
}
let command = DecideCreated::decode(&entry.payload)?;
result = apply_created(&tx, id, &command, &entry.payload, &binding)?;
let mut applied = encode_id(id);
applied.extend(result_bytes(&result));
results.insert(id.index, applied.as_slice())?;
let mut last = vec![1];
last.extend(encode_id(id));
meta.insert("last_applied", last.as_slice())?;
}
self.commit(tx)?;
Ok(result)
}
pub fn capture_binding(&self) -> Result<Option<CaptureBinding>, StoreError> {
let tx = self.database.begin_read()?;
let meta = tx.open_table(META)?;
if !matches!(number(required(meta.get("schema")?)?.value())?, 3 | 4) {
return Ok(None);
}
Ok(Some(read_binding(&meta)?))
}
pub fn last_applied(&self) -> Result<Option<LogId>, StoreError> {
let tx = self.database.begin_read()?;
let meta = tx.open_table(META)?;
if !matches!(number(required(meta.get("schema")?)?.value())?, 3 | 4) {
return Ok(None);
}
read_applied(&meta)
}
pub fn created_head(&self, object: &ObjectIdentity) -> Result<Option<[u8; 16]>, StoreError> {
let tx = self.database.begin_read()?;
let t = tx.open_table(HEADS)?;
t.get(object_bytes(object)?.as_slice())?
.map(|v| {
let mut d = Decoder::new(v.value());
let id = d.uuid()?;
d.finish()?;
Ok(id)
})
.transpose()
}
pub fn created_version(&self, object: &ObjectIdentity, version: [u8; 16]) -> Result<Option<CreatedVersion>, StoreError> {
let tx = self.database.begin_read()?;
let t = tx.open_table(VERSIONS)?;
t.get(version_key(object, version)?.as_slice())?
.map(|v| decode_version(v.value()))
.transpose()
}
pub fn created_event(&self, operation: [u8; 16]) -> Result<Option<CreatedEvent>, StoreError> {
let tx = self.database.begin_read()?;
let t = tx.open_table(EVENTS)?;
t.get(operation.as_slice())?.map(|v| decode_event(v.value())).transpose()
}
pub fn created_operation(&self, operation: [u8; 16]) -> Result<Option<CreatedOperation>, StoreError> {
let tx = self.database.begin_read()?;
let t = tx.open_table(OPERATIONS)?;
t.get(operation.as_slice())?.map(|v| decode_operation(v.value())).transpose()
}
pub fn created_apply_result(&self, index: u64) -> Result<Option<CreatedApplyResult>, StoreError> {
let tx = self.database.begin_read()?;
let t = tx.open_table(APPLY_RESULTS)?;
if number(required(tx.open_table(META)?.get("schema")?)?.value())? == 4 {
t.get(index)?
.map(|v| {
decode_response(v.value()).map(|r| {
r.created_result().cloned().map(|result| CreatedApplyResult {
log_id: *r.log_id(),
result,
})
})
})
.transpose()
.map(Option::flatten)
} else {
t.get(index)?.map(|v| decode_apply(v.value())).transpose()
}
}
}
pub(crate) fn validate_application(tx: &ReadTransaction) -> Result<(), StoreError> {
let meta = tx.open_table(META)?;
if !matches!(number(required(meta.get("schema")?)?.value())?, 3 | 4) {
return Ok(());
}
validate_application_tables(
&tx.open_table(META)?,
&tx.open_table(LOGS)?,
&tx.open_table(HEADS)?,
&tx.open_table(VERSIONS)?,
&tx.open_table(EVENTS)?,
&tx.open_table(OPERATIONS)?,
&tx.open_table(APPLY_RESULTS)?,
)
}
fn validate_application_tables(
meta: &impl ReadableTable<&'static str, &'static [u8]>,
logs: &impl ReadableTable<u64, &'static [u8]>,
heads: &impl ReadableTable<&'static [u8], &'static [u8]>,
versions: &impl ReadableTable<&'static [u8], &'static [u8]>,
events: &impl ReadableTable<&'static [u8], &'static [u8]>,
operations: &impl ReadableTable<&'static [u8], &'static [u8]>,
results: &impl ReadableTable<u64, &'static [u8]>,
) -> Result<(), StoreError> {
let schema = number(required(meta.get("schema")?)?.value())?;
let native = schema == 4;
if !matches!(schema, 3 | 4) {
return Ok(());
}
let binding = read_binding(meta).map_err(|error| match error {
StoreError::BindingNotPinned => StoreError::CorruptRecord,
other => other,
})?;
let last = read_applied(meta)?;
let committed = validate_committed(meta, logs)?;
if last.is_some_and(|last| committed.is_none_or(|c| last.index > c.index)) {
return Err(StoreError::CorruptRecord);
}
let mut next = Some(0);
let mut observed = None;
let mut first_operations = BTreeMap::new();
let mut expected_membership = crate::CaptureMembership::new(None, vec![], BTreeMap::new())?;
let mut replay_operations = BTreeMap::<[u8; 16], (Vec<u8>, CreatedResult)>::new();
let mut replay_heads = BTreeMap::<Vec<u8>, [u8; 16]>::new();
let mut replay_versions = std::collections::BTreeSet::new();
for row in results.iter()? {
let (k, v) = row?;
let entry = decode_log(required(logs.get(k.value())?)?.value())?;
let r = if native {
let response = decode_response(v.value())?;
let native_entry = decode_native_entry(entry.clone())?;
validate_response_kind(&native_entry, &response)?;
if next != Some(k.value()) || response.log_id().index != k.value() {
return Err(StoreError::CorruptRecord);
}
observed = Some(*response.log_id());
next = k.value().checked_add(1);
match native_entry.payload {
openraft::EntryPayload::Membership(membership) => {
expected_membership =
crate::CaptureMembership::try_from(&openraft::StoredMembership::new(Some(entry.id.into()), membership))?;
continue;
}
openraft::EntryPayload::Blank => continue,
openraft::EntryPayload::Normal(_) => CreatedApplyResult {
log_id: *response.log_id(),
result: response.created_result().cloned().ok_or(StoreError::CorruptRecord)?,
},
}
} else {
decode_apply(v.value())?
};
if !native && (next != Some(k.value()) || r.log_id.index != k.value()) {
return Err(StoreError::CorruptRecord);
}
if entry.id != r.log_id {
return Err(StoreError::CorruptRecord);
}
let c = entry_command(&entry, native)?;
let canonical = c.encode()?;
let op = decode_operation(required(operations.get(c.operation_id.as_slice())?)?.value())?;
let canonical_matches = op.canonical_command == canonical;
let expected = if canonical_matches {
op.result
} else {
CreatedResult::OperationConflict
};
if native {
let replay = if let Some((previous, result)) = replay_operations.get(&c.operation_id) {
if *previous == canonical {
result.clone()
} else {
CreatedResult::OperationConflict
}
} else {
let object = object_bytes(&c.object)?;
let key = version_key(&c.object, c.prepared.version_id)?;
let actual = replay_heads.get(&object).copied();
let result = if c.binding_revision != binding.revision {
CreatedResult::BindingMismatch
} else if actual != c.expected_head {
CreatedResult::HeadMismatch { actual_head: actual }
} else if replay_versions.contains(&key) {
CreatedResult::VersionConflict
} else {
replay_heads.insert(object, c.prepared.version_id);
replay_versions.insert(key);
CreatedResult::Created {
version_id: c.prepared.version_id,
event_id: c.operation_id,
decision_log_id: r.log_id,
}
};
replay_operations.insert(c.operation_id, (canonical.clone(), result.clone()));
result
};
if replay != r.result {
return Err(StoreError::CorruptRecord);
}
}
if expected != r.result {
return Err(StoreError::CorruptRecord);
}
if canonical_matches {
first_operations.entry(c.operation_id).or_insert(r.log_id);
}
observed = Some(r.log_id);
next = k.value().checked_add(1)
}
if observed != last {
return Err(StoreError::CorruptRecord);
}
if native {
if native_application_metadata(meta)?.1 != expected_membership
|| heads.len()? != u64::try_from(replay_heads.len()).map_err(|_| StoreError::CorruptRecord)?
|| versions.len()? != u64::try_from(replay_versions.len()).map_err(|_| StoreError::CorruptRecord)?
{
return Err(StoreError::CorruptRecord);
}
for (object, id) in replay_heads {
if required(heads.get(object.as_slice())?)?.value() != id {
return Err(StoreError::CorruptRecord);
}
}
}
for row in operations.iter()? {
let (k, v) = row?;
let op = decode_operation(v.value())?;
let c = DecideCreated::decode(&op.canonical_command)?;
if k.value() != c.operation_id {
return Err(StoreError::CorruptRecord);
}
let first = required(first_operations.get(&c.operation_id))?;
if let CreatedResult::Created {
version_id,
event_id,
decision_log_id,
} = op.result
{
if version_id != c.prepared.version_id
|| event_id != c.operation_id
|| c.binding_revision != binding.revision
|| decision_log_id != *first
{
return Err(StoreError::CorruptRecord);
}
let event = decode_event(required(events.get(event_id.as_slice())?)?.value())?;
if event
!= (CreatedEvent {
object: c.object.clone(),
prepared: c.prepared.clone(),
decision_log_id,
binding: binding.clone(),
})
{
return Err(StoreError::CorruptRecord);
}
} else if events.get(c.operation_id.as_slice())?.is_some() {
return Err(StoreError::CorruptRecord);
}
}
for row in events.iter()? {
let (k, v) = row?;
let e = decode_event(v.value())?;
if e.binding != binding {
return Err(StoreError::CorruptRecord);
}
let op = decode_operation(required(operations.get(k.value())?)?.value())?;
let c = DecideCreated::decode(&op.canonical_command)?;
if c.object != e.object
|| c.prepared != e.prepared
|| op.result
!= (CreatedResult::Created {
version_id: e.prepared.version_id,
event_id: c.operation_id,
decision_log_id: e.decision_log_id,
})
{
return Err(StoreError::CorruptRecord);
}
let version =
decode_version(required(versions.get(version_key(&e.object, e.prepared.version_id)?.as_slice())?)?.value())?;
if version
!= (CreatedVersion {
prepared: e.prepared.clone(),
decision_log_id: e.decision_log_id,
binding_revision: binding.revision,
})
{
return Err(StoreError::CorruptRecord);
}
}
for row in versions.iter()? {
let (k, v) = row?;
let version = decode_version(v.value())?;
let mut d = Decoder::new(k.value());
let object = d.object()?;
let id = d.uuid()?;
d.finish()?;
if id != version.prepared.version_id || version.binding_revision != binding.revision {
return Err(StoreError::CorruptRecord);
}
let entry = decode_log(required(logs.get(version.decision_log_id.index)?)?.value())?;
let c = entry_command(&entry, native)?;
if entry.id != version.decision_log_id
|| c.object != object
|| c.prepared != version.prepared
|| events.get(c.operation_id.as_slice())?.is_none()
{
return Err(StoreError::CorruptRecord);
}
let head = required(heads.get(object_bytes(&object)?.as_slice())?)?;
let mut d = Decoder::new(head.value());
let head_id = d.uuid()?;
d.finish()?;
let current = decode_version(required(versions.get(version_key(&object, head_id)?.as_slice())?)?.value())?;
if current.decision_log_id.index < version.decision_log_id.index {
return Err(StoreError::CorruptRecord);
}
}
for row in heads.iter()? {
let (k, v) = row?;
let mut d = Decoder::new(k.value());
let object = d.object()?;
d.finish()?;
let mut d = Decoder::new(v.value());
let id = d.uuid()?;
d.finish()?;
decode_version(required(versions.get(version_key(&object, id)?.as_slice())?)?.value())?;
}
Ok(())
}
@@ -0,0 +1,531 @@
use super::*;
use crate::application::{APPLY_RESULTS, EVENTS, OPERATIONS, VERSIONS};
use crate::tests::CrashBackend;
use redb::ReadableTableMetadata;
use std::{
io::{BufRead, BufReader, Write},
process::{Command, Stdio},
sync::mpsc,
time::Duration,
};
fn identity() -> NodeIdentity {
NodeIdentity {
cluster_id: [7; 16],
node_id: 0,
}
}
fn binding() -> CaptureBinding {
CaptureBinding {
revision: 9,
binding_id: [8; 16],
target_id: [9; 16],
}
}
fn command() -> DecideCreated {
DecideCreated {
operation_id: [1; 16],
object: ObjectIdentity {
bucket_incarnation: [2; 16],
key: vec![0, 255, 3],
},
expected_head: None,
prepared: PreparedIdentity {
version_id: [3; 16],
preparation_id: [4; 16],
content_digest: [5; 32],
content_length: 123,
},
binding_revision: 9,
}
}
fn id(index: u64) -> LogId {
LogId {
term: 8,
leader_node: 0,
index,
}
}
fn append(store: &mut Store, index: u64, c: &DecideCreated) {
store
.append(&[LogEntry {
id: id(index),
payload: c.encode().expect("encode command"),
}])
.expect("append command");
}
fn apply(store: &mut Store, index: u64, c: &DecideCreated) -> CreatedResult {
append(store, index, c);
store.save_committed(id(index)).expect("commit log");
store.apply_next_created(id(index)).expect("apply command")
}
fn success(c: &DecideCreated, index: u64) -> CreatedResult {
CreatedResult::Created {
version_id: c.prepared.version_id,
event_id: c.operation_id,
decision_log_id: id(index),
}
}
fn complete(store: &Store) {
let c = command();
assert_eq!(store.capture_binding().expect("binding"), Some(binding()));
assert_eq!(
store.created_head(&c.object).expect("head"),
Some(c.prepared.version_id),
"U11 acknowledged head lost"
);
assert_eq!(
store.created_version(&c.object, c.prepared.version_id).expect("version"),
Some(CreatedVersion {
prepared: c.prepared.clone(),
decision_log_id: id(0),
binding_revision: 9
})
);
assert_eq!(
store.created_event(c.operation_id).expect("event"),
Some(CreatedEvent {
object: c.object.clone(),
prepared: c.prepared.clone(),
decision_log_id: id(0),
binding: binding()
}),
"U11 complete event missing"
);
assert_eq!(
store.created_operation(c.operation_id).expect("operation"),
Some(CreatedOperation {
canonical_command: c.encode().expect("encode"),
result: success(&c, 0)
})
);
assert_eq!(
store.created_apply_result(0).expect("result"),
Some(CreatedApplyResult {
log_id: id(0),
result: success(&c, 0)
})
);
assert_eq!(store.last_applied().expect("last applied"), Some(id(0)));
}
fn fixture() -> (tempfile::TempDir, Store) {
let dir = tempfile::tempdir().expect("tempdir");
let mut store = Store::initialize(dir.path().join("db"), identity()).expect("initialize");
store.pin_capture_binding(binding()).expect("pin");
(dir, store)
}
fn reopen(path: &std::path::Path) -> Store {
let result = Store::open(path, identity());
assert!(result.is_ok(), "U11 complete reopen: {:?}", result.as_ref().err());
result.expect("asserted successful reopen")
}
#[test]
fn created_codec_is_canonical_and_strict() {
let c = command();
let b = c.encode().expect("encode");
let mut golden = b"CRTD\x01".to_vec();
golden.extend([1; 16]);
golden.extend([2; 16]);
golden.extend(3u32.to_be_bytes());
golden.extend([0, 255, 3]);
golden.push(0);
golden.extend([3; 16]);
golden.extend([4; 16]);
golden.extend([5; 32]);
golden.extend(123u64.to_be_bytes());
golden.extend(9u64.to_be_bytes());
assert_eq!(b, golden);
assert_eq!(DecideCreated::decode(&b).expect("decode"), c);
let mut variants = Vec::new();
let mut v = c.clone();
v.operation_id = [6; 16];
variants.push(v);
let mut v = c.clone();
v.object.bucket_incarnation = [6; 16];
variants.push(v);
let mut v = c.clone();
v.object.key.push(4);
variants.push(v);
let mut v = c.clone();
v.expected_head = Some([6; 16]);
variants.push(v);
let mut v = c.clone();
v.prepared.version_id = [6; 16];
variants.push(v);
let mut v = c.clone();
v.prepared.preparation_id = [6; 16];
variants.push(v);
let mut v = c.clone();
v.prepared.content_digest = [6; 32];
variants.push(v);
let mut v = c.clone();
v.prepared.content_length += 1;
variants.push(v);
let mut v = c.clone();
v.binding_revision += 1;
variants.push(v);
for v in variants {
assert_ne!(v.encode().expect("variant"), b)
}
for n in 0..b.len() {
assert!(DecideCreated::decode(&b[..n]).is_err())
}
let mut bad = b.clone();
bad[4] = 2;
assert!(DecideCreated::decode(&bad).is_err());
let mut bad = b.clone();
bad[44] = 2;
assert!(DecideCreated::decode(&bad).is_err());
let mut bad = b.clone();
bad.push(0);
assert!(DecideCreated::decode(&bad).is_err());
let mut bad = b;
bad[37..41].copy_from_slice(&4097u32.to_be_bytes());
assert!(DecideCreated::decode(&bad).is_err());
let mut bad = c.clone();
bad.operation_id = [0; 16];
assert!(bad.encode().is_err());
bad = c;
bad.object.key = vec![0; MAX_OBJECT_KEY_LENGTH + 1];
assert!(bad.encode().is_err());
}
#[test]
fn created_committed_order_and_identity_are_enforced() {
let (_dir, mut s) = fixture();
let c = command();
append(&mut s, 0, &c);
append(&mut s, 1, &c);
assert!(s.apply_next_created(id(0)).is_err());
assert_eq!(s.last_applied().expect("no apply"), None);
s.save_committed(id(1)).expect("committed");
assert!(matches!(s.apply_next_created(id(1)), Err(StoreError::InvalidApplySequence)));
let mut wrong = id(0);
wrong.term += 1;
assert!(s.apply_next_created(wrong).is_err());
wrong = id(0);
wrong.leader_node += 1;
assert!(s.apply_next_created(wrong).is_err());
assert_eq!(s.apply_next_created(id(0)).expect("first"), success(&c, 0));
complete(&s);
assert_eq!(s.apply_next_created(id(0)).expect("retry"), success(&c, 0));
complete(&s);
assert!(s.apply_next_created(wrong).is_err());
assert_eq!(s.apply_next_created(id(1)).expect("duplicate log"), success(&c, 0));
assert_eq!(s.last_applied().expect("last"), Some(id(1)));
assert_eq!(
s.created_apply_result(1).expect("result"),
Some(CreatedApplyResult {
log_id: id(1),
result: success(&c, 0)
})
);
let tx = s.database.begin_read().expect("read");
assert_eq!(tx.open_table(EVENTS).expect("events").len().expect("len"), 1);
}
#[test]
fn created_head_version_event_and_dedup_reopen_together() {
let (dir, mut s) = fixture();
let c = command();
assert_eq!(apply(&mut s, 0, &c), success(&c, 0));
drop(s);
let mut s = reopen(&dir.path().join("db"));
complete(&s);
assert_eq!(apply(&mut s, 1, &c), success(&c, 0));
let mut changed = c.clone();
changed.prepared.content_digest = [6; 32];
assert_eq!(
apply(&mut s, 2, &changed),
CreatedResult::OperationConflict,
"U11 full canonical operation conflict"
);
assert_eq!(
s.created_operation(c.operation_id)
.expect("original")
.expect("operation")
.result,
success(&c, 0)
);
let mut stale = c.clone();
stale.operation_id = [6; 16];
stale.prepared.version_id = [6; 16];
assert_eq!(
apply(&mut s, 3, &stale),
CreatedResult::HeadMismatch {
actual_head: Some(c.prepared.version_id)
}
);
let mut reuse = c.clone();
reuse.operation_id = [7; 16];
reuse.expected_head = Some(c.prepared.version_id);
assert_eq!(apply(&mut s, 4, &reuse), CreatedResult::VersionConflict);
let mut replacement = stale.clone();
replacement.operation_id = [10; 16];
replacement.expected_head = Some(c.prepared.version_id);
assert_eq!(apply(&mut s, 5, &replacement), success(&replacement, 5));
assert_eq!(s.created_head(&c.object).expect("head"), Some(replacement.prepared.version_id));
assert_eq!(
s.created_version(&c.object, c.prepared.version_id)
.expect("old version")
.expect("version")
.decision_log_id,
id(0)
);
assert!(s.created_event(c.operation_id).expect("old event").is_some());
assert!(s.created_event(replacement.operation_id).expect("new event").is_some());
assert!(s.created_event(stale.operation_id).expect("rejection event").is_none());
assert_eq!(
apply(&mut s, 6, &stale),
CreatedResult::HeadMismatch {
actual_head: Some(c.prepared.version_id)
}
);
drop(s);
let s = reopen(&dir.path().join("db"));
assert_eq!(s.last_applied().expect("last"), Some(id(6)));
let tx = s.database.begin_read().expect("read");
assert_eq!(tx.open_table(EVENTS).expect("events").len().expect("len"), 2);
}
#[test]
fn created_binding_is_pinned_and_not_caller_selected() {
let (dir, mut s) = fixture();
let mut bad = command();
bad.binding_revision = 10;
assert_eq!(apply(&mut s, 0, &bad), CreatedResult::BindingMismatch);
assert!(s.created_head(&bad.object).expect("head").is_none());
assert!(s.created_event(bad.operation_id).expect("event").is_none());
assert_eq!(
s.created_operation(bad.operation_id)
.expect("operation")
.expect("saved")
.result,
CreatedResult::BindingMismatch
);
s.pin_capture_binding(binding()).expect("idempotent pin");
let mut changed = binding();
changed.target_id = [10; 16];
assert!(matches!(s.pin_capture_binding(changed), Err(StoreError::BindingConflict)));
drop(s);
let s = reopen(&dir.path().join("db"));
assert_eq!(s.capture_binding().expect("binding"), Some(binding()));
}
#[test]
fn created_schema_compatibility_and_corruption_are_checked() {
let dir = tempfile::tempdir().expect("dir");
let path = dir.path().join("db");
let s = Store::initialize(&path, identity()).expect("schema1");
assert_eq!(s.capture_binding().expect("unmigrated"), None);
drop(s);
let mut s = reopen(&path);
assert_eq!(s.last_applied().expect("no apply"), None);
append(&mut s, 0, &command());
s.save_committed(id(0)).expect("schema2");
drop(s);
let mut s = reopen(&path);
assert_eq!(s.capture_binding().expect("schema2 unchanged"), None);
s.pin_capture_binding(binding()).expect("explicit upgrade");
drop(s);
let mut s = reopen(&path);
assert_eq!(s.capture_binding().expect("schema3"), Some(binding()));
s.apply_next_created(id(0)).expect("apply");
drop(s);
let db = Database::open(&path).expect("raw db");
let tx = db.begin_write().expect("write corruption");
tx.open_table(EVENTS)
.expect("events")
.remove(command().operation_id.as_slice())
.expect("remove reference");
tx.commit().expect("commit corruption");
drop(db);
let result = Store::open(&path, identity());
assert!(
matches!(result, Err(StoreError::CorruptRecord)),
"U11 missing event rejected before expect"
);
let (dir, mut s) = fixture();
assert_eq!(s.read_committed().expect("empty committed"), None);
append(&mut s, 0, &command());
s.save_committed(id(0)).expect("committed schema3");
assert_eq!(s.capture_binding().expect("no downgrade"), Some(binding()));
drop(s);
let path = dir.path().join("db");
let db = Database::open(&path).expect("raw db");
let tx = db.begin_write().expect("write");
let mut marker = vec![1];
marker.extend(encode_id(id(0)));
tx.open_table(META)
.expect("meta")
.insert("last_applied", marker.as_slice())
.expect("corrupt applied");
tx.commit().expect("commit");
drop(db);
assert!(
matches!(Store::open(&path, identity()), Err(StoreError::CorruptRecord)),
"U11 last applied missing result rejected"
);
let (dir, s) = fixture();
drop(s);
let path = dir.path().join("db");
let db = Database::open(&path).expect("raw pinned db");
let tx = db.begin_write().expect("missing binding write");
tx.open_table(META)
.expect("meta")
.remove("capture_binding")
.expect("remove binding");
tx.commit().expect("binding corruption commit");
drop(db);
assert!(
matches!(Store::open(&path, identity()), Err(StoreError::CorruptRecord)),
"U11 missing pinned binding rejected"
);
let (dir, mut s) = fixture();
let c = command();
assert_eq!(apply(&mut s, 0, &c), success(&c, 0));
assert_eq!(apply(&mut s, 1, &c), success(&c, 0));
drop(s);
let path = dir.path().join("db");
let db = Database::open(&path).expect("raw duplicate db");
let tx = db.begin_write().expect("retarget decision write");
let mut key = created::object_bytes(&c.object).expect("object key");
key.extend(c.prepared.version_id);
let mut version = Vec::new();
created::put_prepared(&mut version, &c.prepared);
version.extend(encode_id(id(1)));
version.extend(binding().revision.to_be_bytes());
tx.open_table(VERSIONS)
.expect("versions")
.insert(key.as_slice(), version.as_slice())
.expect("retarget version");
let mut event = created::object_bytes(&c.object).expect("event object");
created::put_prepared(&mut event, &c.prepared);
event.extend(encode_id(id(1)));
event.extend(created::binding_bytes(&binding()).expect("binding bytes"));
tx.open_table(EVENTS)
.expect("events")
.insert(c.operation_id.as_slice(), event.as_slice())
.expect("retarget event");
let canonical = c.encode().expect("canonical");
let mut operation = u64::try_from(canonical.len()).expect("length").to_be_bytes().to_vec();
operation.extend(canonical);
operation.extend(created::result_bytes(&success(&c, 1)));
tx.open_table(OPERATIONS)
.expect("operations")
.insert(c.operation_id.as_slice(), operation.as_slice())
.expect("retarget original response");
for index in 0..=1 {
let mut result = encode_id(id(index));
result.extend(created::result_bytes(&success(&c, 1)));
tx.open_table(APPLY_RESULTS)
.expect("results")
.insert(index, result.as_slice())
.expect("retarget duplicate response");
}
tx.commit().expect("coherent retarget commit");
drop(db);
assert!(
matches!(Store::open(&path, identity()), Err(StoreError::CorruptRecord)),
"U11 original decision cannot retarget duplicate log"
);
}
#[test]
fn created_process_child() {
if let Some(path) = std::env::var_os("U11_CHILD_DB") {
let mut s = Store::initialize(path, identity()).expect("child init");
s.pin_capture_binding(binding()).expect("child pin");
assert_eq!(apply(&mut s, 0, &command()), success(&command(), 0));
complete(&s);
println!("U11_ACK");
std::io::stdout().flush().expect("flush ACK");
loop {
std::thread::park()
}
}
let (_dir, mut s) = fixture();
assert_eq!(apply(&mut s, 0, &command()), success(&command(), 0));
complete(&s);
}
#[test]
fn created_acknowledged_apply_survives_child_kill() {
let dir = tempfile::tempdir().expect("dir");
let path = dir.path().join("db");
let mut child = Command::new(std::env::current_exe().expect("test executable"))
.args(["--exact", "application_tests::created_process_child", "--nocapture"])
.env("U11_CHILD_DB", &path)
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.spawn()
.expect("spawn");
let stdout = child.stdout.take().expect("stdout");
let (tx, rx) = mpsc::channel();
let reader = std::thread::spawn(move || {
for line in BufReader::new(stdout).lines() {
let line = line.expect("child line");
if line.ends_with("U11_ACK") {
let _ = tx.send(());
break;
}
}
});
let acknowledged = rx.recv_timeout(Duration::from_secs(20));
child.kill().expect("kill child");
child.wait().expect("wait child");
reader.join().expect("reader cleanup");
assert!(acknowledged.is_ok(), "U11 bounded child ACK missing");
complete(&reopen(&path));
}
#[test]
fn created_power_cut_before_and_after_apply_flush() {
let (dir, mut s) = fixture();
append(&mut s, 0, &command());
s.save_committed(id(0)).expect("committed");
drop(s);
let path = dir.path().join("db");
let backend = CrashBackend::new(&path);
let mut s = Store::from_database(backend.database(), identity()).expect("backend");
assert_eq!(s.apply_next_created(id(0)).expect("apply ACK"), success(&command(), 0));
backend.cut();
drop(s);
complete(&reopen(&path));
for sync in 1..=2 {
for after in [false, true] {
let (dir, mut s) = fixture();
append(&mut s, 0, &command());
s.save_committed(id(0)).expect("committed");
drop(s);
let path = dir.path().join("db");
let backend = CrashBackend::new(&path);
let mut s = Store::from_database(backend.database(), identity()).expect("backend");
let (entered, resume) = backend.pause_sync(sync, after);
let writer = std::thread::spawn(move || {
let result = s.apply_next_created(id(0));
(s, result)
});
entered.wait();
backend.cut();
resume.wait();
let (s, result) = writer.join().expect("writer");
drop(s);
let reopened = Store::open(&path, identity());
assert!(
reopened.is_ok(),
"U11 apply cut must reopen whole old/new state sync={sync} after={after}: {:?}",
reopened.as_ref().err()
);
let s = reopened.expect("asserted reopen");
if s.last_applied().expect("last").is_some() {
complete(&s)
} else {
assert!(result.is_err());
assert_eq!(s.created_head(&command().object).expect("old head"), None);
assert_eq!(
s.created_version(&command().object, command().prepared.version_id)
.expect("old version"),
None
);
assert_eq!(s.created_event(command().operation_id).expect("old event"), None);
assert_eq!(s.created_operation(command().operation_id).expect("old op"), None);
assert_eq!(s.created_apply_result(0).expect("old result"), None)
}
eprintln!("U11 cut boundary sync={sync} after={after}");
}
}
}
+63
View File
@@ -0,0 +1,63 @@
use crate::{LOGS, LogId, META, Store, StoreError, decode_id, decode_log, encode_id, number};
use redb::{ReadableDatabase, ReadableTable};
pub(crate) fn validate_committed(
meta: &impl ReadableTable<&'static str, &'static [u8]>,
logs: &impl ReadableTable<u64, &'static [u8]>,
) -> Result<Option<LogId>, StoreError> {
let schema = number(meta.get("schema")?.ok_or(StoreError::CorruptRecord)?.value())?;
let marker = meta.get("committed")?;
match (schema, marker) {
(1 | 3 | 4, None) => {
if matches!(schema, 3 | 4) && meta.get("last_applied")?.is_none_or(|v| v.value() != [0]) {
return Err(StoreError::CorruptRecord);
}
Ok(None)
}
(2 | 3 | 4, Some(marker)) => {
let id = decode_id(marker.value())?;
let record = logs.get(id.index)?.ok_or(StoreError::CorruptRecord)?;
if decode_log(record.value())?.id != id {
return Err(StoreError::CorruptRecord);
}
Ok(Some(id))
}
(1 | 2, _) => Err(StoreError::CorruptRecord),
_ => Err(StoreError::UnsupportedSchema),
}
}
impl Store {
/// Read the exact committed boundary, validating it against the stored log.
pub fn read_committed(&self) -> Result<Option<LogId>, StoreError> {
let tx = self.database.begin_read()?;
validate_committed(&tx.open_table(META)?, &tx.open_table(LOGS)?)
}
/// Persist a monotonic committed boundary supplied by the authority owner.
pub fn save_committed(&mut self, id: LogId) -> Result<(), StoreError> {
let tx = self.write_transaction()?;
{
let mut meta = tx.open_table(META)?;
let logs = tx.open_table(LOGS)?;
let previous = validate_committed(&meta, &logs)?;
let record = logs.get(id.index)?.ok_or(StoreError::InvalidCommittedLog)?;
if decode_log(record.value())?.id != id {
return Err(StoreError::InvalidCommittedLog);
}
if let Some(previous) = previous {
if id.index < previous.index {
return Err(StoreError::CommittedRegression);
}
if id == previous {
return Ok(());
}
}
meta.insert("committed", encode_id(id).as_slice())?;
if previous.is_none() && !matches!(number(meta.get("schema")?.ok_or(StoreError::CorruptRecord)?.value())?, 3 | 4) {
meta.insert("schema", 2u64.to_be_bytes().as_slice())?;
}
}
self.commit(tx)
}
}
+242
View File
@@ -0,0 +1,242 @@
use crate::{LogId, StoreError, decode_id, encode_id};
pub const MAX_OBJECT_KEY_LENGTH: usize = 4096;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ObjectIdentity {
pub bucket_incarnation: [u8; 16],
pub key: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PreparedIdentity {
pub version_id: [u8; 16],
pub preparation_id: [u8; 16],
pub content_digest: [u8; 32],
pub content_length: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DecideCreated {
pub operation_id: [u8; 16],
pub object: ObjectIdentity,
pub expected_head: Option<[u8; 16]>,
pub prepared: PreparedIdentity,
pub binding_revision: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CaptureBinding {
pub revision: u64,
pub binding_id: [u8; 16],
pub target_id: [u8; 16],
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CreatedResult {
Created {
version_id: [u8; 16],
event_id: [u8; 16],
decision_log_id: LogId,
},
HeadMismatch {
actual_head: Option<[u8; 16]>,
},
OperationConflict,
BindingMismatch,
VersionConflict,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreatedVersion {
pub prepared: PreparedIdentity,
pub decision_log_id: LogId,
pub binding_revision: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreatedEvent {
pub object: ObjectIdentity,
pub prepared: PreparedIdentity,
pub decision_log_id: LogId,
pub binding: CaptureBinding,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreatedOperation {
pub canonical_command: Vec<u8>,
pub result: CreatedResult,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreatedApplyResult {
pub log_id: LogId,
pub result: CreatedResult,
}
pub(crate) struct Decoder<'a> {
bytes: &'a [u8],
}
impl<'a> Decoder<'a> {
pub(crate) fn new(bytes: &'a [u8]) -> Self {
Self { bytes }
}
pub(crate) fn take<const N: usize>(&mut self) -> Result<[u8; N], StoreError> {
let a = self
.bytes
.get(..N)
.ok_or(StoreError::CorruptRecord)?
.try_into()
.map_err(|_| StoreError::CorruptRecord)?;
self.bytes = &self.bytes[N..];
Ok(a)
}
pub(crate) fn u64(&mut self) -> Result<u64, StoreError> {
Ok(u64::from_be_bytes(self.take()?))
}
pub(crate) fn uuid(&mut self) -> Result<[u8; 16], StoreError> {
let id = self.take()?;
if id == [0; 16] {
return Err(StoreError::CorruptRecord);
}
Ok(id)
}
pub(crate) fn option(&mut self) -> Result<Option<[u8; 16]>, StoreError> {
match self.take::<1>()?[0] {
0 => Ok(None),
1 => Ok(Some(self.uuid()?)),
_ => Err(StoreError::CorruptRecord),
}
}
pub(crate) fn object(&mut self) -> Result<ObjectIdentity, StoreError> {
let bucket_incarnation = self.uuid()?;
let len = usize::try_from(u32::from_be_bytes(self.take()?)).map_err(|_| StoreError::CorruptRecord)?;
if len > MAX_OBJECT_KEY_LENGTH {
return Err(StoreError::CorruptRecord);
}
let key = self.bytes.get(..len).ok_or(StoreError::CorruptRecord)?.to_vec();
self.bytes = &self.bytes[len..];
Ok(ObjectIdentity { bucket_incarnation, key })
}
pub(crate) fn prepared(&mut self) -> Result<PreparedIdentity, StoreError> {
Ok(PreparedIdentity {
version_id: self.uuid()?,
preparation_id: self.uuid()?,
content_digest: self.take()?,
content_length: self.u64()?,
})
}
pub(crate) fn id(&mut self) -> Result<LogId, StoreError> {
decode_id(&self.take::<24>()?)
}
pub(crate) fn binding(&mut self) -> Result<CaptureBinding, StoreError> {
Ok(CaptureBinding {
revision: self.u64()?,
binding_id: self.uuid()?,
target_id: self.uuid()?,
})
}
pub(crate) fn finish(self) -> Result<(), StoreError> {
if self.bytes.is_empty() {
Ok(())
} else {
Err(StoreError::CorruptRecord)
}
}
pub(crate) fn result(&mut self) -> Result<CreatedResult, StoreError> {
match self.take::<1>()?[0] {
0 => Ok(CreatedResult::Created {
version_id: self.uuid()?,
event_id: self.uuid()?,
decision_log_id: self.id()?,
}),
1 => Ok(CreatedResult::HeadMismatch {
actual_head: self.option()?,
}),
2 => Ok(CreatedResult::OperationConflict),
3 => Ok(CreatedResult::BindingMismatch),
4 => Ok(CreatedResult::VersionConflict),
_ => Err(StoreError::CorruptRecord),
}
}
}
pub(crate) fn put_option(b: &mut Vec<u8>, id: Option<[u8; 16]>) {
b.push(u8::from(id.is_some()));
if let Some(id) = id {
b.extend(id)
}
}
pub(crate) fn object_bytes(o: &ObjectIdentity) -> Result<Vec<u8>, StoreError> {
if o.bucket_incarnation == [0; 16] || o.key.len() > MAX_OBJECT_KEY_LENGTH {
return Err(StoreError::CorruptRecord);
}
let capacity = 20usize.checked_add(o.key.len()).ok_or(StoreError::CorruptRecord)?;
let mut b = Vec::with_capacity(capacity);
b.extend(o.bucket_incarnation);
b.extend(
u32::try_from(o.key.len())
.map_err(|_| StoreError::CorruptRecord)?
.to_be_bytes(),
);
b.extend(&o.key);
Ok(b)
}
pub(crate) fn put_prepared(b: &mut Vec<u8>, p: &PreparedIdentity) {
b.extend(p.version_id);
b.extend(p.preparation_id);
b.extend(p.content_digest);
b.extend(p.content_length.to_be_bytes())
}
pub(crate) fn binding_bytes(v: &CaptureBinding) -> Result<Vec<u8>, StoreError> {
let mut b = v.revision.to_be_bytes().to_vec();
b.extend(v.binding_id);
b.extend(v.target_id);
let mut d = Decoder::new(&b);
d.binding()?;
d.finish()?;
Ok(b)
}
pub(crate) fn result_bytes(r: &CreatedResult) -> Vec<u8> {
let mut b = Vec::new();
match r {
CreatedResult::Created {
version_id,
event_id,
decision_log_id,
} => {
b.push(0);
b.extend(version_id);
b.extend(event_id);
b.extend(encode_id(*decision_log_id))
}
CreatedResult::HeadMismatch { actual_head } => {
b.push(1);
put_option(&mut b, *actual_head)
}
CreatedResult::OperationConflict => b.push(2),
CreatedResult::BindingMismatch => b.push(3),
CreatedResult::VersionConflict => b.push(4),
}
b
}
impl DecideCreated {
pub fn encode(&self) -> Result<Vec<u8>, StoreError> {
let object = object_bytes(&self.object)?;
let capacity = object.len().checked_add(134).ok_or(StoreError::CorruptRecord)?;
let mut b = Vec::with_capacity(capacity);
b.extend(b"CRTD\x01");
b.extend(self.operation_id);
b.extend(object);
put_option(&mut b, self.expected_head);
put_prepared(&mut b, &self.prepared);
b.extend(self.binding_revision.to_be_bytes());
Self::decode(&b)?;
Ok(b)
}
pub fn decode(bytes: &[u8]) -> Result<Self, StoreError> {
let mut d = Decoder::new(bytes);
if d.take::<5>()? != *b"CRTD\x01" {
return Err(StoreError::CorruptRecord);
}
let c = Self {
operation_id: d.uuid()?,
object: d.object()?,
expected_head: d.option()?,
prepared: d.prepared()?,
binding_revision: d.u64()?,
};
d.finish()?;
Ok(c)
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,95 @@
//! Owned consistent native application snapshot view.
use crate::{Store, StoreError, application, snapshot};
use redb::{ReadTransaction, ReadableDatabase, ReadableTable, ReadableTableMetadata};
use std::{
io::Write,
sync::{Arc, Mutex},
};
#[derive(Debug, thiserror::Error)]
pub enum NativeSnapshotError {
#[error(transparent)]
Store(#[from] StoreError),
#[error(transparent)]
Snapshot(#[from] snapshot::SnapshotError),
#[error("native snapshot Store lock poisoned")]
Poisoned,
#[error("native snapshot worker failed")]
Worker(#[source] tokio::task::JoinError),
}
pub struct NativeSnapshotView {
// Drop the transaction before the owner so the Database stays open throughout the view.
transaction: ReadTransaction,
header: snapshot::Header,
counts: [u64; 4],
_owner: Arc<Mutex<Store>>,
}
impl NativeSnapshotView {
pub(crate) fn acquire(owner: Arc<Mutex<Store>>) -> Result<Self, NativeSnapshotError> {
let transaction = {
let store = owner.lock().map_err(|_| NativeSnapshotError::Poisoned)?;
if !store.writable {
return Err(StoreError::WriteDisabled.into());
}
store.database.begin_read().map_err(StoreError::from)?
};
let (applied, membership) = application::native_application_state(&transaction)?;
let header = {
let meta = transaction.open_table(crate::META).map_err(StoreError::from)?;
let identity = meta
.get("identity")
.map_err(StoreError::from)?
.ok_or(StoreError::CorruptRecord)?;
snapshot::Header {
cluster_id: crate::decode_identity(identity.value())?.cluster_id,
applied,
membership,
binding: application::read_binding(&meta)?,
}
};
let mut counts = [0; 4];
for (index, definition) in [
application::HEADS,
application::VERSIONS,
application::EVENTS,
application::OPERATIONS,
]
.into_iter()
.enumerate()
{
counts[index] = transaction
.open_table(definition)
.map_err(StoreError::from)?
.len()
.map_err(StoreError::from)?;
}
Ok(Self {
transaction,
header,
counts,
_owner: owner,
})
}
pub fn stream<W: Write>(self, writer: W, limits: snapshot::Limits) -> Result<W, NativeSnapshotError> {
let mut encoder = snapshot::Encoder::new(writer, &self.header, self.counts, limits)?;
for (definition, section) in [
(application::HEADS, snapshot::Section::Heads),
(application::VERSIONS, snapshot::Section::Versions),
(application::EVENTS, snapshot::Section::Events),
(application::OPERATIONS, snapshot::Section::Operations),
] {
let table = self.transaction.open_table(definition).map_err(StoreError::from)?;
for row in table.iter().map_err(StoreError::from)? {
let (key, value) = row.map_err(StoreError::from)?;
encoder.row(snapshot::Row {
section,
key: key.value(),
value: value.value(),
})?;
}
}
Ok(encoder.finish()?)
}
}
+371
View File
@@ -0,0 +1,371 @@
//! Validated capture peer metadata and its independent CNOD v1 codec.
use serde::{Deserialize, Deserializer, Serialize, Serializer, ser::SerializeStruct};
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct CaptureNode {
rpc_uri: String,
}
#[derive(Debug, thiserror::Error)]
pub enum CaptureNodeError {
#[error("invalid RPC URI")]
InvalidRpcUri,
#[error("invalid RPC URI")]
InvalidRpcUriParse(#[source] url::ParseError),
#[error("RPC URI too long")]
RpcUriTooLong,
#[error("invalid node magic")]
InvalidNodeMagic,
#[error("unsupported node version")]
UnsupportedNodeVersion,
#[error("invalid node length")]
InvalidNodeLength,
#[error("truncated node record")]
TruncatedNodeRecord,
#[error("invalid node UTF-8")]
InvalidNodeUtf8(#[source] std::str::Utf8Error),
#[error("trailing node bytes")]
TrailingNodeBytes,
}
impl CaptureNodeError {
pub fn code(&self) -> &'static str {
match self {
Self::InvalidRpcUri | Self::InvalidRpcUriParse(_) => "invalid_rpc_uri",
Self::RpcUriTooLong => "rpc_uri_too_long",
Self::InvalidNodeMagic => "invalid_node_magic",
Self::UnsupportedNodeVersion => "unsupported_node_version",
Self::InvalidNodeLength => "invalid_node_length",
Self::TruncatedNodeRecord => "truncated_node_record",
Self::InvalidNodeUtf8(_) => "invalid_node_utf8",
Self::TrailingNodeBytes => "trailing_node_bytes",
}
}
}
impl CaptureNode {
pub fn new(rpc_uri: String) -> Result<Self, CaptureNodeError> {
let node = Self { rpc_uri };
node.validate()?;
Ok(node)
}
pub fn rpc_uri(&self) -> &str {
&self.rpc_uri
}
pub fn validate(&self) -> Result<(), CaptureNodeError> {
let uri = &self.rpc_uri;
if uri.len() > 4096 {
return Err(CaptureNodeError::RpcUriTooLong);
}
if uri.is_empty() || uri.chars().any(|c| c.is_whitespace() || c.is_control() || c == '\\') {
return Err(CaptureNodeError::InvalidRpcUri);
}
let rest = uri
.strip_prefix("http://")
.or_else(|| uri.strip_prefix("https://"))
.ok_or(CaptureNodeError::InvalidRpcUri)?;
if uri.contains('?') || uri.contains('#') {
return Err(CaptureNodeError::InvalidRpcUri);
}
let (authority, path) = rest.split_once('/').map_or((rest, ""), |(a, p)| (a, p));
// An empty suffix after the first slash is the only allowed path.
if !path.is_empty() || authority.contains('@') {
return Err(CaptureNodeError::InvalidRpcUri);
}
let (host, port) = if authority.starts_with('[') {
let end = authority.find(']').ok_or(CaptureNodeError::InvalidRpcUri)?;
let port = authority
.get(end + 1..)
.and_then(|suffix| suffix.strip_prefix(':'))
.ok_or(CaptureNodeError::InvalidRpcUri)?;
(&authority[..=end], port)
} else {
authority.split_once(':').ok_or(CaptureNodeError::InvalidRpcUri)?
};
if host.is_empty() || port.is_empty() || !port.bytes().all(|b| b.is_ascii_digit()) {
return Err(CaptureNodeError::InvalidRpcUri);
}
let port = port.parse::<u16>().map_err(|_| CaptureNodeError::InvalidRpcUri)?;
if port == 0 {
return Err(CaptureNodeError::InvalidRpcUri);
}
let parsed_url = url::Url::parse(uri).map_err(CaptureNodeError::InvalidRpcUriParse)?;
if parsed_url.host().is_none() {
return Err(CaptureNodeError::InvalidRpcUri);
}
Ok(())
}
pub fn encode(&self) -> Result<Vec<u8>, CaptureNodeError> {
self.validate()?;
let length = u32::try_from(self.rpc_uri.len()).map_err(|_| CaptureNodeError::InvalidNodeLength)?;
let mut bytes = b"CNOD\x01".to_vec();
bytes.extend_from_slice(&length.to_be_bytes());
bytes.extend_from_slice(self.rpc_uri.as_bytes());
Ok(bytes)
}
pub fn decode(bytes: &[u8]) -> Result<Self, CaptureNodeError> {
if bytes.len() < 9 {
return Err(CaptureNodeError::TruncatedNodeRecord);
}
if &bytes[..4] != b"CNOD" {
return Err(CaptureNodeError::InvalidNodeMagic);
}
if bytes[4] != 1 {
return Err(CaptureNodeError::UnsupportedNodeVersion);
}
let length = u32::from_be_bytes(bytes[5..9].try_into().map_err(|_| CaptureNodeError::InvalidNodeLength)?);
if !(1..=4096).contains(&length) {
return Err(CaptureNodeError::InvalidNodeLength);
}
let length = usize::try_from(length).map_err(|_| CaptureNodeError::InvalidNodeLength)?;
let end = 9_usize.checked_add(length).ok_or(CaptureNodeError::InvalidNodeLength)?;
if bytes.len() < end {
return Err(CaptureNodeError::TruncatedNodeRecord);
}
if bytes.len() != end {
return Err(CaptureNodeError::TrailingNodeBytes);
}
let uri = std::str::from_utf8(&bytes[9..end]).map_err(CaptureNodeError::InvalidNodeUtf8)?;
Self::new(uri.to_owned())
}
}
impl Serialize for CaptureNode {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
self.validate().map_err(serde::ser::Error::custom)?;
let mut state = serializer.serialize_struct("CaptureNode", 1)?;
state.serialize_field("rpc_uri", &self.rpc_uri)?;
state.end()
}
}
impl<'de> Deserialize<'de> for CaptureNode {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Fields {
rpc_uri: String,
}
let fields = Fields::deserialize(deserializer).map_err(|_| serde::de::Error::custom("invalid capture node fields"))?;
Self::new(fields.rpc_uri).map_err(serde::de::Error::custom)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::error::Error;
fn record(payload: &[u8]) -> Vec<u8> {
let mut bytes = b"CNOD\x01".to_vec();
bytes.extend_from_slice(&u32::try_from(payload.len()).expect("test payload length").to_be_bytes());
bytes.extend_from_slice(payload);
bytes
}
fn reject_uri(uri: &str) {
let result = CaptureNode::new(uri.to_owned());
assert!(result.is_err(), "URI should reject: {uri:?}");
assert_eq!(result.expect_err("rejected URI").code(), "invalid_rpc_uri");
}
fn reject_record(bytes: &[u8], code: &str) {
let result = CaptureNode::decode(bytes);
assert!(result.is_err(), "record should reject with {code}");
assert_eq!(result.expect_err("rejected record").code(), code);
}
#[test]
fn actual_openraft_node_bound() {
fn require_node<T: openraft::Node>() {}
require_node::<CaptureNode>();
assert_eq!(CaptureNode::default().rpc_uri(), "");
}
#[test]
fn default_and_empty_rejected_at_every_boundary() {
let node = CaptureNode::default();
assert_eq!(node.rpc_uri(), "");
reject_uri("");
assert_eq!(node.validate().expect_err("empty validation").code(), "invalid_rpc_uri");
assert!(serde_json::to_string(&node).is_err());
assert!(serde_json::from_str::<CaptureNode>(r#"{"rpc_uri":""}"#).is_err());
assert_eq!(node.encode().expect_err("empty encoding").code(), "invalid_rpc_uri");
reject_record(&record(b""), "invalid_node_length");
}
#[test]
fn accepted_uris_preserve_exact_bytes() {
for uri in [
"http://node:80",
"https://node:443",
"http://node:1",
"https://node:65535/",
"http://127.0.0.1:9000",
"https://[::1]:443/",
"http://[2001:db8::1]:1",
"http://NoDe:00080/",
] {
let result = CaptureNode::new(uri.to_owned());
assert!(result.is_ok(), "valid URI construction: {uri}");
let node = result.expect("valid URI");
assert_eq!(node.rpc_uri(), uri, "preserve explicit default port and raw URI");
assert_eq!(node.encode().expect("encode URI"), record(uri.as_bytes()));
assert_eq!(CaptureNode::decode(&record(uri.as_bytes())).expect("decode URI"), node);
}
}
#[test]
fn golden_cnod_bytes() {
let node = CaptureNode {
rpc_uri: "http://node:80".to_owned(),
};
let golden = b"CNOD\x01\x00\x00\x00\x0ehttp://node:80";
assert_eq!(node.encode().ok().as_deref(), Some(golden.as_slice()), "golden CNOD bytes");
}
#[test]
fn valid_cnod_decode() {
let golden = b"CNOD\x01\x00\x00\x00\x0ehttp://node:80";
let decoded = CaptureNode::decode(golden);
assert!(decoded.is_ok(), "golden decode succeeds");
assert_eq!(decoded.expect("golden decoded node").rpc_uri(), "http://node:80");
}
#[test]
fn serde_shape_roundtrip_and_strict_fields() {
let node = CaptureNode::new("https://node:443/".to_owned()).expect("serde node");
let json = r#"{"rpc_uri":"https://node:443/"}"#;
assert_eq!(serde_json::to_string(&node).expect("serialize node"), json);
assert_eq!(serde_json::from_str::<CaptureNode>(json).expect("deserialize node"), node);
for json in [
r#"{}"#,
r#"{"rpc_uri":1}"#,
r#"{"rpc_uri":null}"#,
r#"{"rpc_uri":"http://node:1","extra":0}"#,
r#"{"rpc_uri":"http://node:1","rpc_uri":"http://node:2"}"#,
] {
assert!(serde_json::from_str::<CaptureNode>(json).is_err(), "strict fields: {json}");
}
}
#[test]
fn credentials_and_raw_uri_shapes_reject() {
for uri in [
"http://user@node:1",
"http://user:password@node:1",
"http://@node:1",
"HTTP://node:1",
"Https://node:1",
"http://node",
"http://node:",
"http://node:0",
"http://node:-1",
"http://node:+1",
"http://node:65536",
"http://node:999999999999999999999",
"http://[::1:1",
"http://[::1]extra:1",
"http://[invalid]:1",
"http://::1:1",
"http://:1",
"/disk/path",
"file:///disk/path",
"http://node:1/./",
"http://node:1/../",
"http://node:1/%2e/",
"http://node:1/%2E%2e/",
"http://node:1/path",
"http://node:1\\",
" http://node:1",
"http://node:1\n",
"http://no\tde:1",
"http://node:1\u{00a0}",
"http://node:1\u{0085}",
"http://node:1\0",
] {
reject_uri(uri);
}
}
#[test]
fn query_and_fragment_markers_reject() {
for uri in [
"http://node:1?",
"http://node:1?x=y",
"http://node:1/?",
"http://node:1#",
"http://node:1#fragment",
"http://node:1/#",
] {
reject_uri(uri);
}
}
#[test]
fn utf8_byte_length_boundaries() {
for length in [4095, 4096] {
let prefix = "http://node:";
let uri = format!("{prefix}{}1", "0".repeat(length - prefix.len() - 1));
assert_eq!(uri.len(), length);
assert!(url::Url::parse(&uri).is_ok(), "boundary fixture accepted by actual URL parser");
let node = CaptureNode::new(uri.clone()).expect("boundary node");
assert_eq!(node.rpc_uri(), uri);
assert_eq!(
CaptureNode::decode(&node.encode().expect("boundary encoding")).expect("boundary decoding"),
node
);
}
let uri = format!("http://node:{}1", "0".repeat(4097 - "http://node:".len() - 1));
assert_eq!(CaptureNode::new(uri).expect_err("oversize URI").code(), "rpc_uri_too_long");
let unicode = format!("http://{}:1", "é".repeat(2044));
assert!(unicode.chars().count() < 4096);
assert!(unicode.len() > 4096);
assert_eq!(CaptureNode::new(unicode).expect_err("UTF-8 byte limit").code(), "rpc_uri_too_long");
}
#[test]
fn cnod_headers_and_lengths_reject_exact_codes() {
let valid = record(b"http://node:80");
for end in 0..9 {
reject_record(&valid[..end], "truncated_node_record");
}
let mut bad = valid.clone();
bad[0] = b'X';
reject_record(&bad, "invalid_node_magic");
bad = valid.clone();
bad[4] = 2;
reject_record(&bad, "unsupported_node_version");
for length in [0_u32, 4097, u32::MAX] {
bad = b"CNOD\x01".to_vec();
bad.extend_from_slice(&length.to_be_bytes());
reject_record(&bad, "invalid_node_length");
}
for end in 9..valid.len() {
reject_record(&valid[..end], "truncated_node_record");
}
reject_record(&record(&[0xff]), "invalid_node_utf8");
reject_record(&record(&[0xc3, 0x28]), "invalid_node_utf8");
reject_record(&record(b"invalid URI"), "invalid_rpc_uri");
}
#[test]
fn trailing_bytes_reject() {
for tail in [&b"x"[..], &b"xyz"[..]] {
let mut bytes = record(b"http://node:80");
bytes.extend_from_slice(tail);
reject_record(&bytes, "trailing_node_bytes");
}
}
#[test]
fn errors_and_sources_redact_input() {
let sentinel = "http://SECRET_USER:SECRET_PASSWORD@node:1";
let error = CaptureNode::new(sentinel.to_owned()).expect_err("credentials reject");
let mut current: &dyn Error = &error;
loop {
let rendered = format!("{current} {current:?}");
assert!(!rendered.contains("SECRET"));
match current.source() {
Some(source) => current = source,
None => break,
}
}
let json = format!(r#"{{"rpc_uri":"{sentinel}"}}"#);
let error = serde_json::from_str::<CaptureNode>(&json).expect_err("serde credentials reject");
assert!(!format!("{error} {error:?}").contains("SECRET"));
let json = format!(r#"{{"{sentinel}":0}}"#);
let error = serde_json::from_str::<CaptureNode>(&json).expect_err("unknown fields reject");
assert!(!format!("{error} {error:?}").contains("SECRET"));
let error = CaptureNode::new("http://[bad]:1".to_owned()).expect_err("parser rejection");
assert!(error.source().is_some(), "retain URL parser source");
let error = CaptureNode::decode(&record(&[0xff])).expect_err("UTF-8 rejection");
assert!(error.source().is_some(), "retain UTF-8 source");
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
use crate::{LOGS, META, Store, StoreError, decode_id, decode_log, encode_id};
use redb::ReadableTable;
impl Store {
/// Atomically remove every log entry at or after `from`.
pub fn truncate_suffix(&mut self, from: u64) -> Result<(), StoreError> {
let tx = self.write_transaction()?;
{
let mut meta = tx.open_table(META)?;
let logs = tx.open_table(LOGS)?;
if crate::committed::validate_committed(&meta, &logs)?.is_some_and(|id| from <= id.index) {
return Err(StoreError::CommittedLogProtected);
}
drop(logs);
let tail = meta.get("tail")?.map(|v| decode_id(v.value())).transpose()?;
if tail.is_none_or(|id| from > id.index) {
return Ok(());
}
let mut logs = tx.open_table(LOGS)?;
let new_tail = if from == 0 {
None
} else {
let index = from - 1;
let record = logs.get(index)?.ok_or(StoreError::InvalidLogSequence)?;
let id = decode_log(record.value())?.id;
if id.index != index {
return Err(StoreError::CorruptRecord);
}
Some(id)
};
logs.retain(|index, _| index < from)?;
match new_tail {
Some(id) => {
meta.insert("tail", encode_id(id).as_slice())?;
}
None => {
meta.remove("tail")?;
}
}
}
self.commit(tx)
}
}
-1
View File
@@ -34,7 +34,6 @@ hotpath-alloc = ["hotpath", "hotpath/hotpath-alloc"]
hotpath-cpu = ["hotpath", "hotpath/hotpath-cpu"]
[dependencies]
arc-swap.workspace = true
hotpath.workspace = true
tokio = { workspace = true, features = ["fs", "rt-multi-thread"] }
tonic = { workspace = true, features = ["gzip", "deflate"] }
+5 -131
View File
@@ -12,10 +12,9 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use arc_swap::ArcSwap;
use std::collections::HashMap;
use std::sync::LazyLock;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, LazyLock};
use tokio::sync::RwLock;
use tonic::transport::Channel;
@@ -28,42 +27,6 @@ pub static GLOBAL_ROOT_CERT: LazyLock<RwLock<Option<Vec<u8>>>> = LazyLock::new(|
pub static GLOBAL_MTLS_IDENTITY: LazyLock<RwLock<Option<MtlsIdentityPem>>> = LazyLock::new(|| RwLock::new(None));
pub static GLOBAL_OUTBOUND_TLS_GENERATION: LazyLock<AtomicU64> = LazyLock::new(|| AtomicU64::new(0));
#[derive(Clone, Default)]
pub struct OutboundTlsSnapshot {
pub generation: u64,
pub root_ca_pem: Option<Vec<u8>>,
pub mtls_identity: Option<MtlsIdentityPem>,
}
static GLOBAL_OUTBOUND_TLS_STATE: LazyLock<ArcSwap<OutboundTlsSnapshot>> =
LazyLock::new(|| ArcSwap::from_pointee(OutboundTlsSnapshot::default()));
pub fn get_global_outbound_tls_snapshot() -> Arc<OutboundTlsSnapshot> {
GLOBAL_OUTBOUND_TLS_STATE.load_full()
}
/// Publish identity, trust and generation together; the individual globals are legacy views.
pub async fn publish_global_outbound_tls_snapshot(snapshot: OutboundTlsSnapshot) -> u64 {
GLOBAL_OUTBOUND_TLS_STATE.rcu(|current| {
if current.generation != 0
&& current.root_ca_pem == snapshot.root_ca_pem
&& current.mtls_identity == snapshot.mtls_identity
{
return Arc::clone(current);
}
Arc::new(OutboundTlsSnapshot {
generation: snapshot.generation.max(current.generation.saturating_add(1)),
root_ca_pem: snapshot.root_ca_pem.clone(),
mtls_identity: snapshot.mtls_identity.clone(),
})
});
let accepted = get_global_outbound_tls_snapshot();
*GLOBAL_ROOT_CERT.write().await = accepted.root_ca_pem.clone();
*GLOBAL_MTLS_IDENTITY.write().await = accepted.mtls_identity.clone();
GLOBAL_OUTBOUND_TLS_GENERATION.store(accepted.generation, Ordering::Relaxed);
accepted.generation
}
/// Log level to use when reporting cached gRPC connection eviction.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ConnectionEvictionLogLevel {
@@ -122,27 +85,17 @@ pub async fn get_global_addr() -> String {
/// # Arguments
/// * `cert` - A vector of bytes representing the PEM-encoded root CA certificate.
pub async fn set_global_root_cert(cert: Vec<u8>) {
GLOBAL_OUTBOUND_TLS_STATE.rcu(|current| {
let mut next = (**current).clone();
next.root_ca_pem = Some(cert.clone());
Arc::new(next)
});
*GLOBAL_ROOT_CERT.write().await = Some(cert);
}
/// Clear the global root CA certificate for outbound gRPC clients.
pub async fn clear_global_root_cert() {
GLOBAL_OUTBOUND_TLS_STATE.rcu(|current| {
let mut next = (**current).clone();
next.root_ca_pem = None;
Arc::new(next)
});
*GLOBAL_ROOT_CERT.write().await = None;
}
/// Get the global root CA certificate for outbound gRPC clients.
pub async fn get_global_root_cert() -> Option<Vec<u8>> {
get_global_outbound_tls_snapshot().root_ca_pem.clone()
GLOBAL_ROOT_CERT.read().await.clone()
}
/// Set the global mTLS identity (cert+key PEM) for outbound gRPC clients.
@@ -152,32 +105,22 @@ pub async fn get_global_root_cert() -> Option<Vec<u8>> {
/// # Arguments
/// * `identity` - An optional MtlsIdentityPem struct containing the cert and key PEM.
pub async fn set_global_mtls_identity(identity: Option<MtlsIdentityPem>) {
GLOBAL_OUTBOUND_TLS_STATE.rcu(|current| {
let mut next = (**current).clone();
next.mtls_identity = identity.clone();
Arc::new(next)
});
*GLOBAL_MTLS_IDENTITY.write().await = identity;
}
/// Get the global mTLS identity for outbound gRPC clients.
pub async fn get_global_mtls_identity() -> Option<MtlsIdentityPem> {
get_global_outbound_tls_snapshot().mtls_identity.clone()
GLOBAL_MTLS_IDENTITY.read().await.clone()
}
/// Set the global outbound TLS generation.
pub fn set_global_outbound_tls_generation(generation: u64) {
GLOBAL_OUTBOUND_TLS_STATE.rcu(|current| {
let mut next = (**current).clone();
next.generation = generation;
Arc::new(next)
});
GLOBAL_OUTBOUND_TLS_GENERATION.store(generation, Ordering::Relaxed);
}
/// Get the global outbound TLS generation.
pub fn get_global_outbound_tls_generation() -> u64 {
get_global_outbound_tls_snapshot().generation
GLOBAL_OUTBOUND_TLS_GENERATION.load(Ordering::Relaxed)
}
/// Evict a stale/dead connection from the global connection cache.
@@ -251,77 +194,8 @@ pub async fn clear_all_connections() {
///
/// When present, gRPC clients will present this identity to servers requesting/requiring mTLS.
/// When absent, clients proceed with standard server-authenticated TLS.
#[derive(Clone, PartialEq, Eq)]
#[derive(Clone, Debug)]
pub struct MtlsIdentityPem {
pub cert_pem: Vec<u8>,
pub key_pem: Vec<u8>,
}
impl std::fmt::Debug for MtlsIdentityPem {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MtlsIdentityPem")
.field("cert_bytes", &self.cert_pem.len())
.field("key_pem", &"[REDACTED]")
.finish()
}
}
#[cfg(test)]
mod tls_snapshot_tests {
use super::*;
#[tokio::test]
async fn outbound_snapshot_publication_is_coherent_and_skips_unchanged_material() {
let start = get_global_outbound_tls_generation().saturating_add(1);
let snapshot = OutboundTlsSnapshot {
generation: start,
root_ca_pem: Some(vec![1]),
mtls_identity: Some(MtlsIdentityPem {
cert_pem: vec![1],
key_pem: vec![1],
}),
};
let first = publish_global_outbound_tls_snapshot(snapshot.clone()).await;
assert_eq!(
publish_global_outbound_tls_snapshot(OutboundTlsSnapshot {
generation: first.saturating_add(1),
..snapshot
})
.await,
first
);
let writer = tokio::spawn(async move {
for tag in 2_u8..=255 {
publish_global_outbound_tls_snapshot(OutboundTlsSnapshot {
generation: start.saturating_add(u64::from(tag)),
root_ca_pem: Some(vec![tag]),
mtls_identity: Some(MtlsIdentityPem {
cert_pem: vec![tag],
key_pem: vec![tag],
}),
})
.await;
tokio::task::yield_now().await;
}
});
while !writer.is_finished() {
let state = get_global_outbound_tls_snapshot();
let identity = state.mtls_identity.as_ref().expect("identity in complete snapshot");
assert_eq!(state.root_ca_pem.as_ref().expect("roots in complete snapshot"), &identity.cert_pem);
assert_eq!(identity.cert_pem, identity.key_pem);
tokio::task::yield_now().await;
}
writer.await.expect("writer completes");
}
#[test]
fn mtls_identity_debug_redacts_private_key() {
let identity = MtlsIdentityPem {
cert_pem: b"public certificate".to_vec(),
key_pem: b"PRIVATE-KEY-MARKER".to_vec(),
};
let debug = format!("{identity:?}");
assert!(!debug.contains("PRIVATE-KEY-MARKER"));
assert!(debug.contains("[REDACTED]"));
}
}
+1 -9
View File
@@ -119,19 +119,11 @@ when restoring `/rustfs/console` for a custom OEM build.
- `RUSTFS_SCANNER_CYCLE_MAX_DURATION_SECS` (canonical)
- `RUSTFS_SCANNER_CYCLE_MAX_OBJECTS` (canonical)
- `RUSTFS_SCANNER_CYCLE_MAX_DIRECTORIES` (canonical)
- `RUSTFS_SCANNER_MAX_CONCURRENT_SET_SCANS` (canonical)
- `RUSTFS_SCANNER_MAX_CONCURRENT_DISK_SCANS` (canonical)
- `RUSTFS_SCANNER_WALKERS_PER_DISK` (canonical)
Scanner cycle budget controls:
- When `RUSTFS_SCANNER_CYCLE_MAX_DURATION_SECS` is unset, the finite default is 1800 seconds (30 minutes), matching the scanner benchmark guidance.
- An explicit `0` preserves the compatibility behavior of an unbounded runtime budget. Object and directory budgets likewise remain unbounded when explicitly set to `0`.
Scanner walker controls:
- `RUSTFS_SCANNER_WALKERS_PER_DISK` sets how many bucket walkers each local disk of an erasure set runs; the default `1` walks one bucket per disk at a time, which is the right value for rotational media. On flash, `2` to `4` lets one disk walk several buckets concurrently, which matters most for single-disk deployments with many buckets, where the walk is otherwise fully serial. Values outside `1` through `16` are rejected by `admin config set`; an out-of-range or non-numeric environment value is logged as a scanner runtime config error (`state = "startup_apply_failed"`) and the scanner keeps the default. The walkers of one disk share that disk's scanner admission, so peers still see one scan session per physical disk, and `RUSTFS_SCANNER_MAX_CONCURRENT_DISK_SCANS` still caps the total.
- The same value is available as the persistent `scanner.walkers_per_disk` key and is reported under `runtime_config.walkers_per_disk` in the admin scanner status; see [Scanner Runtime Controls](../../docs/operations/scanner-runtime-controls.md).
- A timed-out cycle cancels cooperative scanner work, then fences its leader epoch before releasing the lease. An uncooperative I/O operation is dropped after the bounded shutdown window; its cursor is not claimed to be durable and the scanner reports `recovery-required` when the worker cannot stop cooperatively, the cycle state was not confirmed durable, or epoch fencing cannot be persisted.
## Mmap read environment aliases
@@ -274,7 +266,7 @@ Drive timeout profile preset:
- `warn` (default): log warning and continue startup.
- `fail`: abort startup with an error.
RustFS production guidance remains direct-attached local POSIX filesystems. Network-mounted filesystems (for example `nfs`, `cifs`, `smb2`, and `fuse.*`) are treated as unsupported by this startup guard. On Windows, endpoints on network drives (UNC shares such as `\\server\share` and mapped drive letters) are treated the same way, even though Windows reports the server's filesystem (usually `NTFS`) for them.
RustFS production guidance remains direct-attached local POSIX filesystems. Network-mounted filesystems (for example `nfs`, `cifs`, `smb2`, and `fuse.*`) are treated as unsupported by this startup guard.
## 📄 License
+3 -25
View File
@@ -69,17 +69,15 @@ pub const ENV_CONSOLE_RATE_LIMIT_RPM: &str = "RUSTFS_CONSOLE_RATE_LIMIT_RPM";
pub const DEFAULT_CONSOLE_RATE_LIMIT_RPM: u32 = 100;
/// Set the console authentication timeout in seconds
/// Bounds how long the console waits for a single authenticated HTTP request before it is
/// considered timed out. This is a request timeout, not the session lifetime: the Console
/// credential lifetime issued by the OIDC callback is `RUSTFS_CONSOLE_OIDC_SESSION_DURATION`.
/// Specifies how long a console authentication session remains valid
/// Default: 3600 seconds (1 hour)
/// Minimum: 300 seconds (5 minutes)
/// Maximum: 86400 seconds (24 hours)
pub const ENV_CONSOLE_AUTH_TIMEOUT: &str = "RUSTFS_CONSOLE_AUTH_TIMEOUT";
/// Default console authentication timeout in seconds
/// This is the default timeout for console authentication requests.
/// After this timeout, a pending console request is abandoned.
/// This is the default timeout for console authentication sessions.
/// After this timeout, users need to re-authenticate to access the console.
/// Default value: 3600 seconds (1 hour)
/// Environment variable: RUSTFS_CONSOLE_AUTH_TIMEOUT
/// Command line argument: --console-auth-timeout
@@ -87,26 +85,6 @@ pub const ENV_CONSOLE_AUTH_TIMEOUT: &str = "RUSTFS_CONSOLE_AUTH_TIMEOUT";
/// Example: --console-auth-timeout 3600
pub const DEFAULT_CONSOLE_AUTH_TIMEOUT: u64 = 3600;
/// Console credential lifetime, in seconds, for Console credentials issued by the OIDC callback.
///
/// Process-level, Console-scoped setting: it applies to every OIDC provider and deliberately
/// lives outside the per-provider `RUSTFS_IDENTITY_OPENID_*` namespace, so it is never
/// suffix-scanned into a provider id and has no `identity_openid` KVS equivalent. The same value
/// also sets how long the Console logout token stays valid.
pub const ENV_CONSOLE_OIDC_SESSION_DURATION: &str = "RUSTFS_CONSOLE_OIDC_SESSION_DURATION";
/// Default Console credential lifetime in seconds, used when the environment variable is unset,
/// not a positive integer, or zero. Matches the value this path hard-coded before it was
/// parameterised.
pub const DEFAULT_CONSOLE_OIDC_SESSION_DURATION_SECS: usize = 3600;
/// Upper bound on the Console credential lifetime in seconds (7 days).
///
/// Values above this are clamped with a warning. The bound keeps the moka logout cache's
/// `time_to_live` well under its 1000-year assertion, and stops an operator from minting an
/// effectively permanent Console STS credential on every node.
pub const CONSOLE_OIDC_SESSION_DURATION_MAX_SECS: usize = 604_800;
/// Toggle update check
/// It controls whether to check for newer versions of rustfs
/// Default value: true
-14
View File
@@ -60,20 +60,6 @@ pub const DEFAULT_HEALTH_LOCK_ONLINE_TIMEOUT_MS: u64 = 1000;
pub const ENV_STARTUP_READINESS_MAX_WAIT_SECS: &str = "RUSTFS_STARTUP_READINESS_MAX_WAIT_SECS";
pub const DEFAULT_STARTUP_READINESS_MAX_WAIT_SECS: u64 = 120;
/// Wait for peer re-admission before a distributed node publishes startup
/// readiness.
///
/// After this node reaches local readiness it asks the peers in each erasure
/// set it serves, over the internode RPC channel, whether they count its
/// drives as online. Startup readiness is published once a strict majority of
/// each set's peers do, or when the startup budget
/// ([`ENV_STARTUP_READINESS_MAX_WAIT_SECS`]) elapses, or when unreachable or
/// older peers make that majority impossible. The gate always fails open; it
/// never affects steady-state `/health/ready` or request handling. Disable it
/// to restore the local-view-only startup readiness.
pub const ENV_STARTUP_PEER_READMISSION_ENABLE: &str = "RUSTFS_STARTUP_PEER_READMISSION_ENABLE";
pub const DEFAULT_STARTUP_PEER_READMISSION_ENABLE: bool = true;
/// Enable minimal health payload mode for GET `/health*` responses.
/// When enabled, only `status` and `ready` fields are returned.
pub const ENV_HEALTH_MINIMAL_RESPONSE_ENABLE: &str = "RUSTFS_HEALTH_MINIMAL_RESPONSE_ENABLE";
+1 -1
View File
@@ -242,7 +242,7 @@ const _: () = assert!(!DEFAULT_INTERNODE_OFFLINE_BYPASS);
/// read-after-write window can erode the read quorum and surface as a spurious
/// `InsufficientReadQuorum` (issue #2761). One bounded retry absorbs that transient failure.
/// **Only** idempotent reads use this; write/lock RPCs (`WriteAll`/`RenameData`/`Delete*`/`Lock`
/// /`UnLock`) must never auto-retry, to preserve quorum and idempotency semantics (see `AGENTS.md`).
/// /`UnLock`) must never auto-retry, to preserve quorum and idempotency semantics (see `CLAUDE.md`).
pub const ENV_INTERNODE_IDEMPOTENT_READ_RETRIES: &str = "RUSTFS_INTERNODE_IDEMPOTENT_READ_RETRIES";
pub const DEFAULT_INTERNODE_IDEMPOTENT_READ_RETRIES: usize = 1;
-54
View File
@@ -59,15 +59,6 @@ pub const SCANNER_MAX_CONCURRENT_SET_SCANS: &str = "max_concurrent_set_scans";
/// Scanner config key capping concurrent scanner disk bucket walks per set.
pub const SCANNER_MAX_CONCURRENT_DISK_SCANS: &str = "max_concurrent_disk_scans";
/// Scanner config key setting how many bucket walkers each local disk runs.
pub const SCANNER_WALKERS_PER_DISK: &str = "walkers_per_disk";
/// Per-local-disk scanner I/O ceiling in operations per second.
pub const SCANNER_DISK_IO_BUDGET_IOPS: &str = "disk_io_budget_iops";
/// Per-local-disk scanner I/O ceiling in bytes per second.
pub const SCANNER_DISK_IO_BUDGET_BYTES_PER_SEC: &str = "disk_io_budget_bytes_per_sec";
/// Scanner config key controlling how often object loops yield.
pub const SCANNER_YIELD_EVERY_N_OBJECTS: &str = "yield_every_n_objects";
@@ -95,9 +86,6 @@ pub const SCANNER_KEYS: &[&str] = &[
SCANNER_CACHE_SAVE_TIMEOUT,
SCANNER_MAX_CONCURRENT_SET_SCANS,
SCANNER_MAX_CONCURRENT_DISK_SCANS,
SCANNER_WALKERS_PER_DISK,
SCANNER_DISK_IO_BUDGET_IOPS,
SCANNER_DISK_IO_BUDGET_BYTES_PER_SEC,
SCANNER_YIELD_EVERY_N_OBJECTS,
SCANNER_ALERT_EXCESS_VERSIONS,
SCANNER_ALERT_EXCESS_VERSION_SIZE,
@@ -226,18 +214,6 @@ pub const ENV_SCANNER_MAX_CONCURRENT_SET_SCANS: &str = "RUSTFS_SCANNER_MAX_CONCU
/// - Example: `export RUSTFS_SCANNER_MAX_CONCURRENT_DISK_SCANS=1`
pub const ENV_SCANNER_MAX_CONCURRENT_DISK_SCANS: &str = "RUSTFS_SCANNER_MAX_CONCURRENT_DISK_SCANS";
/// Environment variable that sets how many bucket walkers each local disk runs.
/// The walkers of one disk share its scanner admission, so a peer still sees one
/// scan session per physical disk. Remote disks keep one walker. The product of
/// local disks and this factor is what `RUSTFS_SCANNER_MAX_CONCURRENT_DISK_SCANS`
/// caps; the disk count stays the floor. Valid range is `1` through `16`.
/// - Example: `export RUSTFS_SCANNER_WALKERS_PER_DISK=4`
pub const ENV_SCANNER_WALKERS_PER_DISK: &str = "RUSTFS_SCANNER_WALKERS_PER_DISK";
pub const ENV_SCANNER_DISK_IO_BUDGET_IOPS: &str = "RUSTFS_SCANNER_DISK_IO_BUDGET_IOPS";
pub const ENV_SCANNER_DISK_IO_BUDGET_BYTES_PER_SEC: &str = "RUSTFS_SCANNER_DISK_IO_BUDGET_BYTES_PER_SEC";
/// Environment variable that controls how often scanner object loops yield to the async runtime.
/// A value of `0` disables this extra object-count yield.
/// - Example: `export RUSTFS_SCANNER_YIELD_EVERY_N_OBJECTS=32`
@@ -252,36 +228,6 @@ pub const DEFAULT_SCANNER_MAX_CONCURRENT_SET_SCANS: usize = 4;
/// Default disk scan concurrency budget.
pub const DEFAULT_SCANNER_MAX_CONCURRENT_DISK_SCANS: usize = 4;
/// Default bucket walkers per local disk.
///
/// One walker per disk keeps today's I/O profile, which is what rotational
/// media needs. Flash is meant to default higher once a local disk can report
/// its media type; `rustfs_utils::os::Info::rotational` is never populated
/// today, so every deployment starts from one walker.
pub const DEFAULT_SCANNER_WALKERS_PER_DISK: usize = 1;
/// Upper bound for `walkers_per_disk`; a larger factor only multiplies
/// contention on one spindle or NVMe queue without more disks to spread it.
pub const MAX_SCANNER_WALKERS_PER_DISK: usize = 16;
/// Default spelling of both per-disk scanner I/O budget keys: resolve the
/// rate from the detected media. `off` removes the cap; a number is explicit.
pub const DEFAULT_SCANNER_DISK_IO_BUDGET: &str = "auto";
/// Rotational media budget, also used when media cannot be detected: about
/// half of a 7200 rpm spindle's random reads stay available to foreground
/// requests, and the byte ceiling sits well above the steady ~2 MB/s a paced
/// single-disk scan was measured at once admission landed (rustfs/rustfs#8377).
pub const DEFAULT_SCANNER_DISK_IO_BUDGET_ROTATIONAL_IOPS: u64 = 100;
pub const DEFAULT_SCANNER_DISK_IO_BUDGET_ROTATIONAL_BYTES_PER_SEC: u64 = 16 * 1024 * 1024;
/// Flash media budget: a few percent of a SATA SSD's random-read capacity,
/// enough to keep metadata walks far from saturating the device.
pub const DEFAULT_SCANNER_DISK_IO_BUDGET_FLASH_IOPS: u64 = 4000;
pub const DEFAULT_SCANNER_DISK_IO_BUDGET_FLASH_BYTES_PER_SEC: u64 = 256 * 1024 * 1024;
/// Default object interval for cooperative scanner yields.
pub const DEFAULT_SCANNER_YIELD_EVERY_N_OBJECTS: u64 = 128;
-14
View File
@@ -51,11 +51,6 @@ pub const ENV_OBS_PROFILING_EXPORT_ENABLED: &str = "RUSTFS_OBS_PROFILING_EXPORT_
/// PUT records multiple timers and histograms when attribution is active.
pub const ENV_OBS_PUT_STAGE_METRICS_ENABLED: &str = "RUSTFS_OBS_PUT_STAGE_METRICS_ENABLED";
/// Enables sampled in-process GetObject stage summaries without OTLP histograms.
pub const ENV_OBS_GET_STAGE_LOCAL_SUMMARY_ENABLED: &str = "RUSTFS_OBS_GET_STAGE_LOCAL_SUMMARY_ENABLED";
pub const ENV_OBS_GET_STAGE_LOCAL_SUMMARY_SAMPLE_RATE: &str = "RUSTFS_OBS_GET_STAGE_LOCAL_SUMMARY_SAMPLE_RATE";
pub const ENV_OBS_GET_STAGE_LOCAL_SUMMARY_INTERVAL_SECS: &str = "RUSTFS_OBS_GET_STAGE_LOCAL_SUMMARY_INTERVAL_SECS";
pub const ENV_OBS_LOGGER_LEVEL: &str = "RUSTFS_OBS_LOGGER_LEVEL";
pub const ENV_OBS_LOG_STDOUT_ENABLED: &str = "RUSTFS_OBS_LOG_STDOUT_ENABLED";
pub const ENV_OBS_LOG_DIRECTORY: &str = "RUSTFS_OBS_LOG_DIRECTORY";
@@ -164,15 +159,6 @@ mod tests {
assert_eq!(ENV_OBS_LOGS_EXPORT_ENABLED, "RUSTFS_OBS_LOGS_EXPORT_ENABLED");
assert_eq!(ENV_OBS_PROFILING_EXPORT_ENABLED, "RUSTFS_OBS_PROFILING_EXPORT_ENABLED");
assert_eq!(ENV_OBS_PUT_STAGE_METRICS_ENABLED, "RUSTFS_OBS_PUT_STAGE_METRICS_ENABLED");
assert_eq!(ENV_OBS_GET_STAGE_LOCAL_SUMMARY_ENABLED, "RUSTFS_OBS_GET_STAGE_LOCAL_SUMMARY_ENABLED");
assert_eq!(
ENV_OBS_GET_STAGE_LOCAL_SUMMARY_SAMPLE_RATE,
"RUSTFS_OBS_GET_STAGE_LOCAL_SUMMARY_SAMPLE_RATE"
);
assert_eq!(
ENV_OBS_GET_STAGE_LOCAL_SUMMARY_INTERVAL_SECS,
"RUSTFS_OBS_GET_STAGE_LOCAL_SUMMARY_INTERVAL_SECS"
);
// Test log cleanup related env keys
assert_eq!(ENV_OBS_LOG_MAX_TOTAL_SIZE_BYTES, "RUSTFS_OBS_LOG_MAX_TOTAL_SIZE_BYTES");
assert_eq!(ENV_OBS_LOG_MAX_SINGLE_FILE_SIZE_BYTES, "RUSTFS_OBS_LOG_MAX_SINGLE_FILE_SIZE_BYTES");
-75
View File
@@ -569,25 +569,6 @@ pub struct DataUsageInfo {
/// which last-known-good generation contributed to the view.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub usage_snapshot_set_states: Vec<DataUsageSnapshotSetState>,
/// Buckets whose dirty-usage generation moved while this complete
/// snapshot was being walked. Their `buckets_usage` values are the walk
/// result, which the cycle could not certify as current; the next cycle
/// rescans them. Sorted. Empty on a converged snapshot and on an
/// observational one. A bucket created during the walk may be listed
/// here without a `buckets_usage` entry.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub usage_snapshot_pending_buckets: Vec<String>,
/// Length of `usage_snapshot_pending_buckets`, carried for readers that
/// only need the count. The scanner writes both from one set.
#[serde(default, skip_serializing_if = "u64_is_zero")]
pub usage_snapshot_pending_bucket_count: u64,
/// Set by the scanner on a snapshot whose authoritative publication was
/// vetoed or could not be verified. It is routed to the observed object
/// and never replaces the authoritative snapshot. Absent on snapshots
/// written before per-bucket certification, which keep
/// `usage_snapshot_converged == Some(false)` as their observed marker.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub usage_snapshot_observational: bool,
/// An observational view may contain only the sets that completed this
/// cycle (or retained a compatible last-known-good cache).
#[serde(default)]
@@ -603,10 +584,6 @@ pub struct DataUsageInfo {
pub disk_usage_status: Vec<DiskUsageStatus>,
}
fn u64_is_zero(value: &u64) -> bool {
*value == 0
}
/// Stable identity fields changed by both coordinated scanner publication and
/// backward-compatible bucket namespace cleanup.
#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
@@ -3034,58 +3011,6 @@ mod tests {
assert_eq!(current.usage_snapshot_converged, Some(false));
}
#[test]
fn pending_bucket_certification_fields_round_trip_and_stay_additive() {
let certified = DataUsageInfo {
last_update: Some(SystemTime::UNIX_EPOCH),
usage_snapshot_complete: true,
usage_snapshot_converged: Some(false),
usage_snapshot_pending_buckets: vec!["hot-a".to_string(), "hot-b".to_string()],
usage_snapshot_pending_bucket_count: 2,
..Default::default()
};
let encoded = rmp_serde::to_vec_named(&certified).expect("encode certified snapshot");
let decoded: DataUsageInfo = rmp_serde::from_slice(&encoded).expect("decode certified snapshot");
assert_eq!(decoded, certified);
assert!(!decoded.usage_snapshot_observational);
let legacy: LegacyUsageReader = rmp_serde::from_slice(&encoded).expect("legacy reader should ignore pending fields");
assert_eq!(legacy.buckets_count, 0);
let observed = DataUsageInfo {
usage_snapshot_observational: true,
..certified
};
let encoded = rmp_serde::to_vec_named(&observed).expect("encode observed snapshot");
let decoded: DataUsageInfo = rmp_serde::from_slice(&encoded).expect("decode observed snapshot");
assert!(decoded.usage_snapshot_observational);
// Older snapshots carry none of the fields and decode to the defaults;
// a converged snapshot writes none of them (named encoding carries
// field names, so a skipped field leaves no trace in the bytes).
let converged = DataUsageInfo {
last_update: Some(SystemTime::UNIX_EPOCH),
usage_snapshot_complete: true,
usage_snapshot_converged: Some(true),
..Default::default()
};
let encoded = rmp_serde::to_vec_named(&converged).expect("encode converged snapshot");
for key in [
"usage_snapshot_pending_buckets",
"usage_snapshot_pending_bucket_count",
"usage_snapshot_observational",
] {
assert!(
!encoded.windows(key.len()).any(|window| window == key.as_bytes()),
"{key} must be skipped when empty"
);
}
let decoded: DataUsageInfo = rmp_serde::from_slice(&encoded).expect("decode converged snapshot");
assert!(decoded.usage_snapshot_pending_buckets.is_empty());
assert_eq!(decoded.usage_snapshot_pending_bucket_count, 0);
assert!(!decoded.usage_snapshot_observational);
assert!(decoded.is_complete_bucket_usage_snapshot());
}
#[test]
fn convergence_marker_defaults_to_unknown_for_older_snapshots() {
let encoded = rmp_serde::to_vec_named(&DataUsageInfo {
-2
View File
@@ -146,6 +146,4 @@ zip.workspace = true
clap = { workspace = true, features = ["derive", "env"] }
[dev-dependencies]
aws-smithy-http-client = { workspace = true, default-features = false, features = ["test-util"] }
tempfile.workspace = true
tokio = { workspace = true, features = ["test-util"] }
+1 -1
View File
@@ -22,7 +22,7 @@ Registered in [`src/lib.rs`](src/lib.rs). Grouped by concern:
| **functional** | top-level `*_test.rs` | S3 data plane: `list_objects_*`, `copy_object_*`, `delete_objects_versioning`, `head_object_*`, `checksum_upload`, `compression`, `content_encoding`, `special_chars`, `leading_slash_key`, `create_bucket_region`, `quota`, `data_usage`, `snowball_auto_extract`, `mc_mirror_small_bucket`, `archive_download_integrity`, `version_id_regression`, `delete_marker_migration_semantics` |
| **object_lock** | [`src/object_lock/`](src/object_lock) | Retention / legal-hold / WORM semantics |
| **kms** | [`src/kms/`](src/kms) | SSE-S3 / SSE-KMS / SSE-C, local + Vault backends, multipart encryption. Own guide: [`src/kms/README.md`](src/kms/README.md) |
| **policy** | [`src/policy/`](src/policy), `existing_object_tag_policy_test`, `conditional_write_policy_test`, `bucket_policy_check_test`, `anonymous_access_test`, `security_boundary_test`, `multipart_auth_test` | IAM / bucket-policy / STS session policy, policy variables, anonymous access, DoS/SSRF boundaries. Own guide: [`src/policy/README.md`](src/policy/README.md) |
| **policy** | [`src/policy/`](src/policy), `existing_object_tag_policy_test`, `bucket_policy_check_test`, `anonymous_access_test`, `security_boundary_test`, `multipart_auth_test` | IAM / bucket-policy / STS session policy, policy variables, anonymous access, DoS/SSRF boundaries. Own guide: [`src/policy/README.md`](src/policy/README.md) |
| **protocols** | [`src/protocols/`](src/protocols) | FTPS, WebDAV, SFTP compliance. Fixed ports, own guide: [`src/protocols/README.md`](src/protocols/README.md) |
| **reliant** | [`src/reliant/`](src/reliant) | Tests that reuse an **externally started** server (SQL/select, conditional writes, lifecycle, deleted-object reads, node-interact). Run via [`scripts/run_e2e_tests.sh`](../../scripts/run_e2e_tests.sh); see [`src/reliant/README.md`](src/reliant/README.md) |
| **cluster** | `cluster_concurrency_test`, `stale_multipart_cleanup_cluster_test`, `namespace_lock_quorum_test`, `admin_timeout_regression_test`, `object_lambda_test`, `replication_extension_test`, `tier_stats_cluster_test` | Multi-node scenarios via `RustFSTestClusterEnvironment` |
-25
View File
@@ -1037,22 +1037,6 @@ pub async fn awscurl_post_sts_form_urlencoded(
Ok(response)
}
fn extract_xml_tag(xml: &str, tag: &str) -> Option<String> {
let open = format!("<{tag}>");
let close = format!("</{tag}>");
let start = xml.find(&open)? + open.len();
let end = xml[start..].find(&close)? + start;
Some(xml[start..end].to_string())
}
/// Returns `(access_key, secret_key, session_token)` from an STS `AssumeRole` XML response.
pub fn parse_assume_role_credentials(xml: &str) -> Result<(String, String, String), Box<dyn std::error::Error + Send + Sync>> {
let access_key = extract_xml_tag(xml, "AccessKeyId").ok_or("missing AccessKeyId in AssumeRole response")?;
let secret_key = extract_xml_tag(xml, "SecretAccessKey").ok_or("missing SecretAccessKey in AssumeRole response")?;
let session_token = extract_xml_tag(xml, "SessionToken").ok_or("missing SessionToken in AssumeRole response")?;
Ok((access_key, secret_key, session_token))
}
/// Helper function for POST requests
pub async fn awscurl_post(
url: &str,
@@ -1558,15 +1542,6 @@ impl RustFSTestClusterEnvironment {
self.start_node_from_binary(node_idx, &binary_path).await
}
/// Start a process and wait only for TCP, allowing tests to bring up a
/// quorum one node at a time before the S3 service can become ready.
pub async fn start_node_process(&mut self, node_idx: usize) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let binary_path = rustfs_binary_path();
let volumes_arg = self.build_volumes_arg();
self.spawn_node(node_idx, &binary_path, &volumes_arg)?;
self.wait_for_node_ready(&self.nodes[node_idx].address, node_idx).await
}
/// Start one stopped cluster node with a specific RustFS binary while
/// preserving the cluster's volume layout and that node's data directory.
pub async fn start_node_from_binary(
@@ -1,381 +0,0 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! S3 wire semantics for per-object conditional bulk deletion.
use crate::common::RustFSTestEnvironment;
use aws_sdk_s3::Client;
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::operation::delete_objects::DeleteObjectsOutput;
use aws_sdk_s3::operation::put_object::PutObjectOutput;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{BucketVersioningStatus, Delete, ObjectIdentifier, VersioningConfiguration};
use std::collections::BTreeSet;
use std::error::Error;
type TestResult<T = ()> = Result<T, Box<dyn Error + Send + Sync>>;
type VersionSnapshot = BTreeSet<(String, bool)>;
async fn put(client: &Client, bucket: &str, key: &str, body: &'static [u8]) -> TestResult<PutObjectOutput> {
let result = client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from_static(body))
.send()
.await?;
assert!(result.e_tag().is_some(), "fixture PUT must provide an ETag for the following conditions");
Ok(result)
}
fn identifier(key: &str, etag: Option<&str>, version: Option<&str>) -> TestResult<ObjectIdentifier> {
Ok(ObjectIdentifier::builder()
.key(key)
.set_e_tag(etag.map(str::to_owned))
.set_version_id(version.map(str::to_owned))
.build()?)
}
async fn delete_batch(
client: &Client,
bucket: &str,
objects: Vec<ObjectIdentifier>,
quiet: bool,
) -> TestResult<DeleteObjectsOutput> {
Ok(client
.delete_objects()
.bucket(bucket)
.delete(Delete::builder().set_objects(Some(objects)).quiet(quiet).build()?)
.send()
.await?)
}
async fn assert_body(client: &Client, bucket: &str, key: &str, expected: &[u8]) -> TestResult {
let result = client.get_object().bucket(bucket).key(key).send().await?;
assert_eq!(result.body.collect().await?.into_bytes().as_ref(), expected);
Ok(())
}
async fn assert_absent(client: &Client, bucket: &str, key: &str) {
let error = client
.head_object()
.bucket(bucket)
.key(key)
.send()
.await
.expect_err("deleted key must be absent");
assert_eq!(error.as_service_error().and_then(|error| error.code()), Some("NotFound"));
}
async fn versions(client: &Client, bucket: &str, key: &str) -> TestResult<VersionSnapshot> {
let result = client.list_object_versions().bucket(bucket).prefix(key).send().await?;
assert!(!result.is_truncated().unwrap_or_default(), "focused version fixture fits a single page");
let mut snapshot = BTreeSet::new();
for version in result.versions().iter().filter(|version| version.key() == Some(key)) {
snapshot.insert((version.version_id().expect("version has an identity").to_owned(), false));
}
for marker in result.delete_markers().iter().filter(|marker| marker.key() == Some(key)) {
snapshot.insert((marker.version_id().expect("delete marker has an identity").to_owned(), true));
}
Ok(snapshot)
}
#[tokio::test]
async fn sdk_conditional_delete_objects_mixed_results_overwrites_duplicates_and_quiet() -> TestResult {
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let client = env.create_s3_client();
let bucket = "conditional-bulk-mixed";
client.create_bucket().bucket(bucket).send().await?;
let old = put(&client, bucket, "overwritten", b"generation A").await?;
let current = put(&client, bucket, "overwritten", b"generation B").await?;
let matched = put(&client, bucket, "matched", b"matching object").await?;
put(&client, bucket, "exists", b"existence object").await?;
put(&client, bucket, "unconditional", b"unconditional object").await?;
put(&client, bucket, "empty", b"empty condition must preserve this").await?;
put(&client, bucket, "empty-quoted", b"quoted empty condition must preserve this").await?;
let weak = put(&client, bucket, "weak", b"weak condition must preserve this").await?;
let weak_etag = format!("W/\"{}\"", weak.e_tag().expect("PUT supplies an ETag").trim_matches('"'));
assert_ne!(old.e_tag(), current.e_tag(), "overwrite fixture must have distinct ETags");
let matched_etag = matched.e_tag().expect("PUT supplies an ETag");
let result = delete_batch(
&client,
bucket,
vec![
identifier("matched", Some(matched_etag), None)?,
identifier("overwritten", old.e_tag(), None)?,
identifier("matched", Some("stale-etag"), None)?,
identifier("matched", Some(matched_etag), None)?,
identifier("unconditional", None, None)?,
identifier("exists", Some("*"), None)?,
identifier("missing", Some("*"), None)?,
identifier("empty", Some(""), None)?,
identifier("empty-quoted", Some("\"\""), None)?,
identifier("weak", Some(&weak_etag), None)?,
identifier("unconditional-missing", None, None)?,
identifier("missing-specific", Some("opaque-etag"), None)?,
],
false,
)
.await?;
assert_eq!(
result.deleted().iter().map(|object| object.key()).collect::<Vec<_>>(),
[
Some("matched"),
Some("matched"),
Some("unconditional"),
Some("exists"),
Some("unconditional-missing")
],
"duplicate successful inputs retain their separate response entries"
);
assert_eq!(
result
.errors()
.iter()
.map(|error| (error.key(), error.code()))
.collect::<Vec<_>>(),
[
(Some("overwritten"), Some("PreconditionFailed")),
(Some("matched"), Some("PreconditionFailed")),
(Some("missing"), Some("NoSuchKey")),
(Some("empty"), Some("InvalidArgument")),
(Some("empty-quoted"), Some("InvalidArgument")),
(Some("weak"), Some("InvalidArgument")),
(Some("missing-specific"), Some("NoSuchKey")),
]
);
assert_body(&client, bucket, "overwritten", b"generation B").await?;
assert_body(&client, bucket, "empty", b"empty condition must preserve this").await?;
assert_body(&client, bucket, "empty-quoted", b"quoted empty condition must preserve this").await?;
assert_body(&client, bucket, "weak", b"weak condition must preserve this").await?;
for key in ["matched", "unconditional", "exists"] {
assert_absent(&client, bucket, key).await;
}
let quiet = put(&client, bucket, "quiet-match", b"quiet matching object").await?;
put(&client, bucket, "quiet-rejected", b"quiet rejected object").await?;
let result = delete_batch(
&client,
bucket,
vec![
identifier("quiet-match", quiet.e_tag(), None)?,
identifier("quiet-match", quiet.e_tag(), None)?,
identifier("quiet-rejected", Some("stale-etag"), None)?,
identifier("missing", Some("*"), None)?,
],
true,
)
.await?;
assert!(result.deleted().is_empty(), "Quiet suppresses successful entries");
assert_eq!(
result
.errors()
.iter()
.map(|error| (error.key(), error.code()))
.collect::<Vec<_>>(),
[
(Some("quiet-rejected"), Some("PreconditionFailed")),
(Some("missing"), Some("NoSuchKey"))
]
);
assert_absent(&client, bucket, "quiet-match").await;
assert_body(&client, bucket, "quiet-rejected", b"quiet rejected object").await?;
Ok(())
}
#[tokio::test]
async fn sdk_conditional_delete_objects_versioning_current_markers_and_explicit_versions() -> TestResult {
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let client = env.create_s3_client();
for suspended in [false, true] {
let bucket = if suspended {
"conditional-bulk-suspended"
} else {
"conditional-bulk-enabled"
};
client.create_bucket().bucket(bucket).send().await?;
client
.put_bucket_versioning()
.bucket(bucket)
.versioning_configuration(
VersioningConfiguration::builder()
.status(BucketVersioningStatus::Enabled)
.build(),
)
.send()
.await?;
let historical = put(&client, bucket, "history", b"historical payload").await?;
let invalid_historical = put(&client, bucket, "invalid", b"historical rejected payload").await?;
if suspended {
client
.put_bucket_versioning()
.bucket(bucket)
.versioning_configuration(
VersioningConfiguration::builder()
.status(BucketVersioningStatus::Suspended)
.build(),
)
.send()
.await?;
}
let current = put(&client, bucket, "history", b"current payload").await?;
let invalid = put(&client, bucket, "invalid", b"current rejected payload").await?;
let invalid_before = versions(&client, bucket, "invalid").await?;
let historical_id = historical.version_id().expect("enabled fixture has a UUID version");
let invalid_historical_id = invalid_historical
.version_id()
.expect("enabled rejected fixture has a UUID version");
let result = delete_batch(
&client,
bucket,
vec![
identifier("history", current.e_tag(), None)?,
identifier("history", None, Some(historical_id))?,
identifier("history", current.e_tag(), None)?,
identifier("invalid", invalid.e_tag(), Some(invalid_historical_id))?,
identifier("invalid", Some("*"), Some("null"))?,
identifier("invalid", Some("stale-etag"), None)?,
],
false,
)
.await?;
assert_eq!(result.deleted().len(), 3, "both current input entries and the historical delete succeed");
assert_eq!(
result
.errors()
.iter()
.map(|error| (error.key(), error.code()))
.collect::<Vec<_>>(),
[
(Some("invalid"), Some("InvalidRequest")),
(Some("invalid"), Some("InvalidRequest")),
(Some("invalid"), Some("PreconditionFailed")),
]
);
assert_eq!(result.errors()[0].version_id(), Some(invalid_historical_id));
assert_eq!(result.errors()[1].version_id(), Some("null"));
assert_eq!(versions(&client, bucket, "invalid").await?, invalid_before);
assert_body(&client, bucket, "invalid", b"current rejected payload").await?;
let marker = result.deleted()[0]
.delete_marker_version_id()
.expect("current conditional delete returns a marker");
assert_eq!(result.deleted()[0].delete_marker(), Some(true));
assert_eq!(result.deleted()[2].delete_marker_version_id(), Some(marker));
assert_eq!(result.deleted()[1].version_id(), Some(historical_id));
if suspended {
assert_eq!(marker, "null", "a suspended current delete returns the created null marker identity");
}
let history_after = versions(&client, bucket, "history").await?;
assert_eq!(
history_after.iter().filter(|(_, marker)| *marker).count(),
1,
"duplicate actions create one marker"
);
assert!(
!history_after.contains(&(historical_id.to_string(), false)),
"distinct historical target is removed"
);
if suspended {
assert_eq!(history_after, BTreeSet::from([("null".to_string(), true)]));
} else {
assert_eq!(
history_after,
BTreeSet::from([
(current.version_id().expect("current enabled object has a UUID").to_string(), false),
(marker.to_string(), true),
]),
"enabled conditional current deletion retains the current data version behind the marker"
);
}
let result = delete_batch(
&client,
bucket,
vec![
identifier("history", Some("*"), None)?,
identifier("history", current.e_tag(), None)?,
identifier("missing", Some("*"), None)?,
],
false,
)
.await?;
assert!(
result.deleted().is_empty(),
"current markers and absent objects cannot satisfy a live-object condition"
);
assert_eq!(result.errors().len(), 3);
assert!(result.errors().iter().all(|error| error.code() == Some("NoSuchKey")));
assert_eq!(
versions(&client, bucket, "history").await?,
history_after,
"failed conditions create no marker"
);
assert!(versions(&client, bucket, "missing").await?.is_empty());
put(&client, bucket, "exists", b"versioned existence payload").await?;
let result = delete_batch(&client, bucket, vec![identifier("exists", Some("*"), None)?], false).await?;
assert!(result.errors().is_empty());
assert_eq!(result.deleted().len(), 1);
assert_eq!(result.deleted()[0].delete_marker(), Some(true));
assert_eq!(
versions(&client, bucket, "exists")
.await?
.iter()
.filter(|(_, marker)| *marker)
.count(),
1
);
if !suspended {
let directory = put(&client, bucket, "dir/", b"directory null-version payload").await?;
assert_eq!(
versions(&client, bucket, "dir/").await?,
BTreeSet::from([("null".to_string(), false)]),
"the directory PUT fixture must identify the synthetic null data version"
);
let result = delete_batch(
&client,
bucket,
vec![identifier("dir/", directory.e_tag(), None)?, identifier("dir/", None, None)?],
false,
)
.await?;
assert!(result.errors().is_empty(), "both distinct directory actions must succeed");
assert_eq!(result.deleted().len(), 2);
assert_eq!(result.deleted()[0].key(), Some("dir/"));
assert_eq!(result.deleted()[1].key(), Some("dir/"));
assert_eq!(result.deleted()[0].delete_marker(), Some(true));
let marker = result.deleted()[0]
.delete_marker_version_id()
.expect("conditional current-directory deletion creates a versioned marker");
assert!(
!uuid::Uuid::parse_str(marker)?.is_nil(),
"enabled current-directory deletion creates a UUID marker"
);
assert_eq!(result.deleted()[1].delete_marker(), None);
assert_eq!(result.deleted()[1].delete_marker_version_id(), None);
assert_eq!(result.deleted()[1].version_id(), None);
assert_eq!(
versions(&client, bucket, "dir/").await?,
BTreeSet::from([(marker.to_string(), true)]),
"current-marker creation and synthetic null-version removal are distinct actions: one marker remains and the null data version is gone"
);
}
}
Ok(())
}
@@ -1,377 +0,0 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! E2E: conditional write enforcement with the `s3:if-none-match` and `s3:if-match`
//! condition keys in a **bucket policy**, an **IAM identity policy**, and an **STS
//! AssumeRole session policy**. Covers PutObject and CompleteMultipartUpload, the two
//! upload operations that accept the conditional headers. The plain precondition
//! semantics without a policy live in `reliant/conditional_writes.rs`.
use crate::common::{
AdminTransport, RustFSTestEnvironment, admin_add_canned_policy_via, admin_attach_user_policy_via, admin_create_user,
awscurl_post_sts_form_urlencoded, build_test_s3_config, init_logging, parse_assume_role_credentials,
};
use aws_sdk_s3::Client;
use aws_sdk_s3::config::http::HttpResponse;
use aws_sdk_s3::error::{ProvideErrorMetadata, SdkError};
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart};
use tracing::info;
use uuid::Uuid;
type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
fn assert_error<E: ProvideErrorMetadata + std::fmt::Debug>(
err: &SdkError<E, HttpResponse>,
code: &str,
status: u16,
context: &str,
) {
assert_eq!(
err.as_service_error().and_then(ProvideErrorMetadata::code),
Some(code),
"{context}: {err:?}"
);
assert_eq!(
err.raw_response().map(|response| response.status().as_u16()),
Some(status),
"{context}: {err:?}"
);
}
async fn admin_add_and_attach_policy(
env: &RustFSTestEnvironment,
policy_name: &str,
policy_json: &str,
username: &str,
) -> TestResult {
admin_add_canned_policy_via(
AdminTransport::Signed,
&env.url,
&env.access_key,
&env.secret_key,
policy_name,
policy_json,
)
.await?;
admin_attach_user_policy_via(AdminTransport::Signed, &env.url, &env.access_key, &env.secret_key, policy_name, username).await
}
/// Starts a one-part multipart upload as the admin so that only the complete step
/// runs under the policy being tested. CreateMultipartUpload and UploadPart are both
/// authorized as `s3:PutObject` and cannot carry a conditional header, so a policy
/// that requires `s3:if-none-match` denies them. AWS exempts them with
/// `s3:ObjectCreationOperation`, which RustFS does not support yet.
async fn admin_start_upload(
admin: &Client,
bucket: &str,
key: &str,
) -> Result<(String, CompletedMultipartUpload), Box<dyn std::error::Error + Send + Sync>> {
let upload = admin.create_multipart_upload().bucket(bucket).key(key).send().await?;
let upload_id = upload.upload_id().ok_or("missing upload id")?.to_string();
let part = admin
.upload_part()
.bucket(bucket)
.key(key)
.upload_id(&upload_id)
.part_number(1)
.body(ByteStream::from_static(b"multipart-conditional-write"))
.send()
.await?;
let completed = CompletedMultipartUpload::builder()
.parts(
CompletedPart::builder()
.part_number(1)
.set_e_tag(part.e_tag().map(str::to_string))
.build(),
)
.build();
Ok((upload_id, completed))
}
/// One server for the three policy kinds; each scenario uses its own principal and bucket.
#[tokio::test]
async fn test_e2e_policies_enforce_conditional_write_keys() -> TestResult {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let admin = env.create_s3_client();
let suffix = Uuid::new_v4();
bucket_policy_requires_if_none_match(&env, &admin, suffix).await?;
iam_policy_requires_if_match(&env, &admin, suffix).await?;
sts_session_policy_requires_if_none_match(&env, &admin, suffix).await?;
info!("test_e2e_policies_enforce_conditional_write_keys passed");
Ok(())
}
/// Bucket policy in the shape of the AWS enforcement example: PutObject is allowed only
/// when the request carries `If-None-Match`, which makes the bucket create-only.
async fn bucket_policy_requires_if_none_match(env: &RustFSTestEnvironment, admin: &Client, suffix: Uuid) -> TestResult {
let user = format!("e2econdwrite-{suffix}");
let user_secret = "longSecretKeyForCondWrite1!";
let bucket = format!("e2e-cond-write-bkt-{suffix}");
admin_create_user(env, &user, user_secret).await?;
admin.create_bucket().bucket(&bucket).send().await?;
let bucket_policy = serde_json::json!({
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "AWS": [user.clone()] },
"Action": ["s3:PutObject"],
"Resource": [format!("arn:aws:s3:::{bucket}/*")],
"Condition": { "Null": { "s3:if-none-match": "false" } }
}]
})
.to_string();
admin
.put_bucket_policy()
.bucket(&bucket)
.policy(&bucket_policy)
.send()
.await?;
let client = env.create_s3_client_with_credentials(&user, user_secret);
let denied = client
.put_object()
.bucket(&bucket)
.key("plain.txt")
.body(ByteStream::from_static(b"plain"))
.send()
.await
.expect_err("PutObject without If-None-Match must be denied");
assert_error(&denied, "AccessDenied", 403, "unconditional PutObject");
let created = client
.put_object()
.bucket(&bucket)
.key("created.txt")
.if_none_match("*")
.body(ByteStream::from_static(b"created"))
.send()
.await?;
let etag = created.e_tag().ok_or("missing ETag on conditional PutObject")?.to_string();
let overwrite = client
.put_object()
.bucket(&bucket)
.key("created.txt")
.if_none_match("*")
.body(ByteStream::from_static(b"overwrite"))
.send()
.await
.expect_err("If-None-Match: * must not overwrite an existing key");
assert_error(
&overwrite,
"PreconditionFailed",
412,
"allowed PutObject still evaluates the precondition",
);
// A non-`*` value satisfies `Null: false` but must not act as an ETag comparison
// that lets the write overwrite the key.
let overwrite = client
.put_object()
.bucket(&bucket)
.key("created.txt")
.if_none_match("\"0123456789abcdef0123456789abcdef\"")
.body(ByteStream::from_static(b"overwrite"))
.send()
.await
.expect_err("If-None-Match with an ETag value must be rejected");
assert_error(&overwrite, "NotImplemented", 501, "PutObject with an ETag If-None-Match");
let current = admin.head_object().bucket(&bucket).key("created.txt").send().await?;
assert_eq!(current.e_tag(), Some(etag.as_str()), "created.txt must not be overwritten");
let (upload_id, completed) = admin_start_upload(admin, &bucket, "multipart.bin").await?;
let denied = client
.complete_multipart_upload()
.bucket(&bucket)
.key("multipart.bin")
.upload_id(&upload_id)
.multipart_upload(completed.clone())
.send()
.await
.expect_err("CompleteMultipartUpload without If-None-Match must be denied");
assert_error(&denied, "AccessDenied", 403, "unconditional CompleteMultipartUpload");
client
.complete_multipart_upload()
.bucket(&bucket)
.key("multipart.bin")
.upload_id(&upload_id)
.if_none_match("*")
.multipart_upload(completed)
.send()
.await?;
let (upload_id, completed) = admin_start_upload(admin, &bucket, "multipart.bin").await?;
let overwrite = client
.complete_multipart_upload()
.bucket(&bucket)
.key("multipart.bin")
.upload_id(&upload_id)
.if_none_match("*")
.multipart_upload(completed)
.send()
.await
.expect_err("CompleteMultipartUpload with If-None-Match: * must not overwrite an existing key");
assert_error(
&overwrite,
"PreconditionFailed",
412,
"allowed CompleteMultipartUpload still evaluates the precondition",
);
let _ = admin
.abort_multipart_upload()
.bucket(&bucket)
.key("multipart.bin")
.upload_id(&upload_id)
.send()
.await;
for key in ["created.txt", "multipart.bin"] {
let _ = admin.delete_object().bucket(&bucket).key(key).send().await;
}
let _ = admin.delete_bucket().bucket(&bucket).send().await;
Ok(())
}
/// IAM identity policy: PutObject is allowed only when the request carries `If-Match`.
async fn iam_policy_requires_if_match(env: &RustFSTestEnvironment, admin: &Client, suffix: Uuid) -> TestResult {
let user = format!("e2econdmatch-{suffix}");
let user_secret = "longSecretKeyForCondMatch2!";
let policy_name = format!("e2e-cond-match-pol-{suffix}");
let bucket = format!("e2e-cond-match-bkt-{suffix}");
let key = "guarded.txt";
admin_create_user(env, &user, user_secret).await?;
let policy = serde_json::json!({
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["s3:PutObject"],
"Resource": [format!("arn:aws:s3:::{bucket}/*")],
"Condition": { "Null": { "s3:if-match": "false" } }
}]
})
.to_string();
admin_add_and_attach_policy(env, &policy_name, &policy, &user).await?;
admin.create_bucket().bucket(&bucket).send().await?;
let original = admin
.put_object()
.bucket(&bucket)
.key(key)
.body(ByteStream::from_static(b"v1"))
.send()
.await?;
let etag = original.e_tag().ok_or("missing ETag on initial PutObject")?.to_string();
let client = env.create_s3_client_with_credentials(&user, user_secret);
let denied = client
.put_object()
.bucket(&bucket)
.key(key)
.body(ByteStream::from_static(b"blind-overwrite"))
.send()
.await
.expect_err("PutObject without If-Match must be denied");
assert_error(&denied, "AccessDenied", 403, "unconditional PutObject");
client
.put_object()
.bucket(&bucket)
.key(key)
.if_match(&etag)
.body(ByteStream::from_static(b"v2"))
.send()
.await?;
let _ = admin.delete_object().bucket(&bucket).key(key).send().await;
let _ = admin.delete_bucket().bucket(&bucket).send().await;
Ok(())
}
/// STS session policy: AssumeRole must accept `s3:if-none-match`, and the session may
/// only create objects with `If-None-Match: *`.
async fn sts_session_policy_requires_if_none_match(env: &RustFSTestEnvironment, admin: &Client, suffix: Uuid) -> TestResult {
let parent = format!("e2e-sts-cond-par-{suffix}");
let parent_secret = "longSecretKeyForCondParent3!";
let policy_name = format!("e2e-sts-cond-rw-{suffix}");
let bucket = format!("e2e-sts-cond-bkt-{suffix}");
admin_create_user(env, &parent, parent_secret).await?;
let parent_policy = serde_json::json!({
"Version": "2012-10-17",
"Statement": [
{ "Effect": "Allow", "Action": ["s3:*"], "Resource": ["arn:aws:s3:::*"] },
{ "Effect": "Allow", "Action": ["sts:AssumeRole"], "Resource": ["arn:aws:s3:::*"] }
]
})
.to_string();
admin_add_and_attach_policy(env, &policy_name, &parent_policy, &parent).await?;
admin.create_bucket().bucket(&bucket).send().await?;
let session_policy = serde_json::json!({
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["s3:PutObject"],
"Resource": [format!("arn:aws:s3:::{bucket}/*")],
"Condition": { "StringEquals": { "s3:if-none-match": "*" } }
}]
})
.to_string();
let body = format!(
"Action=AssumeRole&Version=2011-06-15&DurationSeconds=3600&Policy={}",
urlencoding::encode(&session_policy)
);
let url = format!("{}/", env.url.trim_end_matches('/'));
let xml = awscurl_post_sts_form_urlencoded(&url, &body, &parent, parent_secret).await?;
let (access_key, secret_key, session_token) = parse_assume_role_credentials(&xml)?;
let session = Client::from_conf(build_test_s3_config(
&env.url,
&access_key,
&secret_key,
Some(&session_token),
"e2e-sts-session",
));
let denied = session
.put_object()
.bucket(&bucket)
.key("plain.txt")
.body(ByteStream::from_static(b"plain"))
.send()
.await
.expect_err("session PutObject without If-None-Match must be denied");
assert_error(&denied, "AccessDenied", 403, "unconditional session PutObject");
session
.put_object()
.bucket(&bucket)
.key("created.txt")
.if_none_match("*")
.body(ByteStream::from_static(b"created"))
.send()
.await?;
let _ = admin.delete_object().bucket(&bucket).key("created.txt").send().await;
let _ = admin.delete_bucket().bucket(&bucket).send().await;
Ok(())
}
+4 -6
View File
@@ -860,12 +860,10 @@ pub(crate) async fn start_rebalance(cluster: &RustFSTestClusterEnvironment) -> T
if status.is_success() {
break response;
}
// The init stage answers 503 + Retry-After while the cluster capability
// proof is still missing; older binaries surfaced the same condition as
// a 500 carrying the proof marker.
let waiting_for_readiness = status == StatusCode::SERVICE_UNAVAILABLE
|| (status == StatusCode::INTERNAL_SERVER_ERROR && response.contains("requires a live fleet capability proof"));
if waiting_for_readiness && Instant::now() < deadline {
if status == StatusCode::INTERNAL_SERVER_ERROR
&& response.contains("requires a live fleet capability proof")
&& Instant::now() < deadline
{
sleep(Duration::from_secs(1)).await;
continue;
}
@@ -18,7 +18,7 @@
use crate::common::{
AdminTransport, RustFSTestEnvironment, admin_add_canned_policy_via, admin_attach_user_policy_via, admin_create_user_via,
awscurl_delete, awscurl_post_sts_form_urlencoded, build_test_s3_config, init_logging, parse_assume_role_credentials,
awscurl_delete, awscurl_post_sts_form_urlencoded, build_test_s3_config, init_logging,
};
use aws_sdk_s3::Client;
use aws_sdk_s3::error::ProvideErrorMetadata;
@@ -41,6 +41,21 @@ fn sts_session_client(env: &RustFSTestEnvironment, access_key: &str, secret_key:
))
}
fn extract_xml_tag(xml: &str, tag: &str) -> Option<String> {
let open = format!("<{tag}>");
let close = format!("</{tag}>");
let start = xml.find(&open)? + open.len();
let end = xml[start..].find(&close)? + start;
Some(xml[start..end].to_string())
}
fn parse_assume_role_credentials(xml: &str) -> Result<(String, String, String), Box<dyn std::error::Error + Send + Sync>> {
let ak = extract_xml_tag(xml, "AccessKeyId").ok_or("missing AccessKeyId in AssumeRole response")?;
let sk = extract_xml_tag(xml, "SecretAccessKey").ok_or("missing SecretAccessKey in AssumeRole response")?;
let token = extract_xml_tag(xml, "SessionToken").ok_or("missing SessionToken in AssumeRole response")?;
Ok((ak, sk, token))
}
async fn assume_role_with_session_policy(
env: &RustFSTestEnvironment,
parent_ak: &str,
+1 -1
View File
@@ -6,7 +6,7 @@ This module is the shared failure-injection boundary for replication end-to-end
Supported data operations are HeadBucket, GetBucketVersioning, ListObjectsV2, PUT/GET/HEAD/DELETE Object, Get/Put/Delete ObjectTagging (tags live per version; Put replaces the whole set, Delete clears it), and create/upload/complete/abort multipart upload. `create_bucket` models general-purpose buckets in S3's shared global namespace; account-regional namespace buckets and their `-an` names are intentionally out of scope. Buckets created with `create_bucket` are versioned: PUT creates a version, DELETE without `versionId` creates a delete marker, and DELETE with `versionId` removes exactly that version. Internal source version IDs must be UUIDs and are stored canonically. Source mtime is honored only for source-replication PUT/DELETE requests; absent or invalid values use receipt time, matching RustFS, while multipart completion always uses receipt time. Replicated versions are ordered newest-first by source mtime so late older versions and delete markers do not become current. Equal mtimes prefer objects over delete markers, then canonical UUID order; RustFS's internal FileMeta signature tie-break is intentionally out of scope because it is not part of the target S3 protocol. Multipart part numbers follow S3's `1..=10000` range, and every completed part except the final part must be at least 5 MiB.
`create_bucket_with_object_lock(name)` creates a versioned bucket whose GetObjectLockConfiguration reports `Enabled`; every other bucket answers `ObjectLockConfigurationNotFoundError`, the code RustFS's replication-check classifies as "not enabled". Four switches model remote-target behaviors the fleet has shown, so the outbound target matrix (`crates/e2e_test/src/replication_target_matrix_test.rs`) can replicate every object shape against each: `assign_own_version_ids(true)` ignores the source version id and mints its own (AWS S3 / Wasabi); `reject_aws_chunked_uploads(true)` refuses any PutObject or UploadPart announcing `aws-chunked` framing (`Content-Encoding: aws-chunked`, an `x-amz-trailer`, or a `STREAMING-*` payload hash) with `InvalidRequest` before the body is read (SeaweedFS 3.97, rustfs#6853); `require_checksum_for_object_lock(true)` rejects a PutObject carrying any `x-amz-object-lock-*` header unless it also carries `Content-MD5`, an `x-amz-checksum-*` header, or `x-amz-sdk-checksum-algorithm` (AWS S3 / MinIO, rustfs#7082). `ignore_source_etags(true)` ignores the `x-rustfs-source-etag` / `x-minio-source-etag` replication headers and reports the ETag of the received bytes (AWS S3 and other generic S3 services; the default adopts the source ETag like RustFS/MinIO). Independently of that switch, a `Content-MD5` header is always verified against the body and a mismatch answers `BadDigest`.
`create_bucket_with_object_lock(name)` creates a versioned bucket whose GetObjectLockConfiguration reports `Enabled`; every other bucket answers `ObjectLockConfigurationNotFoundError`, the code RustFS's replication-check classifies as "not enabled". Three switches model remote-target behaviors the fleet has shown, so the outbound target matrix (`crates/e2e_test/src/replication_target_matrix_test.rs`) can replicate every object shape against each: `assign_own_version_ids(true)` ignores the source version id and mints its own (AWS S3 / Wasabi); `reject_aws_chunked_uploads(true)` refuses any PutObject or UploadPart announcing `aws-chunked` framing (`Content-Encoding: aws-chunked`, an `x-amz-trailer`, or a `STREAMING-*` payload hash) with `InvalidRequest` before the body is read (SeaweedFS 3.97, rustfs#6853); `require_checksum_for_object_lock(true)` rejects a PutObject carrying any `x-amz-object-lock-*` header unless it also carries `Content-MD5`, an `x-amz-checksum-*` header, or `x-amz-sdk-checksum-algorithm` (AWS S3 / MinIO, rustfs#7082). Independently of that switch, a `Content-MD5` header is always verified against the body and a mismatch answers `BadDigest`.
`create_bucket_with_mode(name, BucketMode::Unversioned)` models a plain migration source: PUT overwrites in place, DELETE removes the key without a delete marker, GetBucketVersioning reports no status, and no `x-amz-version-id` is returned by PUT, GET, HEAD, tagging, or multipart completion. The only `versionId` such a bucket accepts is `null`; any other value is rejected with `InvalidArgument`. The mode is fixed at creation.
+3 -18
View File
@@ -537,10 +537,6 @@ struct StoreState {
/// PutObject carrying any `x-amz-object-lock-*` header must also carry
/// `Content-MD5` or an `x-amz-checksum-*` header.
require_checksum_for_object_lock: bool,
/// Models AWS S3 and other generic S3 services: the `x-rustfs-source-etag`
/// / `x-minio-source-etag` replication headers are ignored and every
/// stored object reports the ETag computed from the bytes it received.
ignore_source_etags: bool,
/// Models Wasabi (rustfs/backlog#2340): a version-addressed DELETE of a
/// version id the target never had answers 404 `NoSuchVersion` instead of
/// the idempotent 204 RustFS/MinIO give.
@@ -1056,13 +1052,6 @@ impl FakeS3Target {
lock(&self.backend.store).require_checksum_for_object_lock = enabled;
}
/// AWS-like mode: ignore the replication source-ETag headers and report
/// the ETag of the received bytes, as AWS S3 does for SSE-S3 and
/// unencrypted objects. The default adopts the source ETag (RustFS/MinIO).
pub fn ignore_source_etags(&self, enabled: bool) {
lock(&self.backend.store).ignore_source_etags = enabled;
}
pub fn assign_own_multipart_version_ids(&self, enabled: bool) {
lock(&self.backend.store).assign_own_multipart_version_ids = enabled;
}
@@ -2455,13 +2444,12 @@ impl S3 for FakeBackend {
.map_err(|_| s3s::s3_error!(ServiceUnavailable, "fake target body limiter closed"))?;
let headers = req.headers;
let input = req.input;
let (assign_own, drop_unlisted, require_checksum, ignore_source_etags, limits, versioned) = {
let (assign_own, drop_unlisted, require_checksum, limits, versioned) = {
let state = lock(&self.store);
(
state.assign_own_version_ids,
state.drop_unlisted_replication_headers,
state.require_checksum_for_object_lock,
state.ignore_source_etags,
state.limits,
bucket_versioned(&state, &input.bucket),
)
@@ -2501,7 +2489,7 @@ impl S3 for FakeBackend {
} else {
NULL_VERSION_ID.to_string()
};
let e_tag = match source_etag(&headers)?.filter(|_| !ignore_source_etags) {
let e_tag = match source_etag(&headers)? {
Some(value) => value,
None => {
let (digest, permit) = md5_digest(body.clone(), body_permit).await?;
@@ -3124,10 +3112,7 @@ impl S3 for FakeBackend {
.collect();
let (body, digests, _body_permits) = assemble_multipart(assembly_parts, total_len, _body_permits).await?;
let part_count = requested.len();
let ignore_source_etags = lock(&self.store).ignore_source_etags;
let e_tag = source_etag(&headers)?
.filter(|_| !ignore_source_etags)
.unwrap_or_else(|| format!("{}-{part_count}", md5_hex(digests)));
let e_tag = source_etag(&headers)?.unwrap_or_else(|| format!("{}-{part_count}", md5_hex(digests)));
let version = ObjectVersion {
version_id: upload.version_id.clone(),
body,
@@ -1032,7 +1032,7 @@ mod tests {
let heal_body = r#"{"recursive":true,"dryRun":false,"remove":false,"recreate":true,"scanMode":2,"updateParity":false,"nolock":false}"#;
let heal_url = format!("{}/rustfs/admin/v3/heal/{}?forceStart=true", env.url, bucket);
start_root_heal_when_control_ready(&heal_url, heal_body, &env.access_key, &env.secret_key)
signed_admin_post(&heal_url, Some(heal_body), &env.access_key, &env.secret_key)
.await
.expect("admin deep heal should be accepted");
@@ -1191,59 +1191,11 @@ mod tests {
.into())
}
fn scanner_cycle_completion_budget(
cycle_end: u64,
interval: Option<u64>,
now: u64,
active: bool,
) -> Result<Duration, &'static str> {
if cycle_end > now {
return Err("scanner cycle timestamp is in the future");
}
if active {
return Ok(Duration::from_secs(60));
}
let interval = interval
.filter(|interval| *interval != 0)
.ok_or("scanner schedule has no positive interval")?;
let next_cycle = cycle_end
.checked_add(interval)
.ok_or("scanner schedule timestamp overflows")?;
let budget = next_cycle
.saturating_sub(now)
.checked_add(60)
.ok_or("scanner completion budget overflows")?;
Ok(Duration::from_secs(budget))
}
#[test]
fn scanner_cycle_completion_budget_accounts_for_captured_retry_schedule() {
assert_eq!(
scanner_cycle_completion_budget(1_791_815_390, Some(256), 1_791_815_447, false),
Ok(Duration::from_secs(259)),
);
assert_eq!(scanner_cycle_completion_budget(100, Some(1), 110, false), Ok(Duration::from_secs(60)));
}
#[test]
fn scanner_cycle_completion_budget_does_not_grant_planned_sleep_to_active_cycle() {
assert_eq!(scanner_cycle_completion_budget(100, Some(256), 110, true), Ok(Duration::from_secs(60)));
assert_eq!(scanner_cycle_completion_budget(0, None, 110, true), Ok(Duration::from_secs(60)));
}
#[test]
fn scanner_cycle_completion_budget_rejects_invalid_schedule() {
for (end, interval, now) in [(101, 1, 100), (100, 0, 100), (u64::MAX, 1, u64::MAX), (1, u64::MAX - 1, 1)] {
assert!(scanner_cycle_completion_budget(end, Some(interval), now, false).is_err());
}
}
async fn wait_for_scanner_cycle_after(
cluster: &RustFSTestClusterEnvironment,
previous_cycle_end: u64,
) -> Result<u64, Box<dyn Error + Send + Sync>> {
let mut deadline = Instant::now() + Duration::from_secs(60);
let mut schedule_accounted = false;
let deadline = Instant::now() + Duration::from_secs(60);
loop {
let mut latest_cycle_end = 0;
let mut versions_observed = false;
@@ -1285,36 +1237,14 @@ mod tests {
let versions_scanned = metrics["versions_scanned"]
.as_u64()
.ok_or("scanner status is missing its version-coverage counter")?;
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_secs();
if cycle_end > now {
return Err("scanner cycle timestamp is in the future".into());
}
let schedule = &status["cycle_schedule"];
if !schedule_accounted && schedule["execution_role"].as_str() == Some("leader") {
let active = metrics["current_cycle_active"]
.as_bool()
.ok_or("scanner status is missing its active-cycle flag")?;
if active || (cycle_end != 0 && schedule["effective_interval_available"].as_bool() == Some(true)) {
let interval = schedule["effective_interval_seconds"].as_u64();
let budget = scanner_cycle_completion_budget(cycle_end, interval, now, active)?;
deadline = Instant::now().checked_add(budget).ok_or("scanner deadline overflows")?;
schedule_accounted = true;
}
}
// Superseded walks complete all sets while withholding quota authority.
// Deferred and partial attempts do not certify completed scanning.
if matches!(metrics["last_cycle_result"].as_str(), Some("success" | "superseded")) {
latest_cycle_end = latest_cycle_end.max(cycle_end);
}
latest_cycle_end = latest_cycle_end.max(cycle_end);
versions_observed |= versions_scanned > 0;
observations.push(format!(
"node{node_index}: end={cycle_end}, versions={versions_scanned}, cycle={}, active={}, leader={}, result={}, partial_reason={}, schedule={}",
"node{node_index}: end={cycle_end}, versions={versions_scanned}, cycle={}, active={}, leader={}, result={}",
metrics["current_cycle"],
metrics["current_cycle_active"],
metrics["leader_lock_state"],
metrics["last_cycle_result"],
metrics["last_cycle_partial_reason"],
schedule,
));
}
// The coordinator records cycle completion, but remote workers
@@ -1625,8 +1555,7 @@ mod tests {
}
if background_enabled {
let baseline_cycle_floor = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_secs();
wait_for_scanner_cycle_after(&cluster, baseline_cycle_floor).await?;
wait_for_scanner_cycle_after(&cluster, 0).await?;
}
cluster.stop_node(1)?;
@@ -1969,16 +1898,11 @@ mod tests {
let status_body = signed_admin_post(&status_url, None, &cluster.access_key, &cluster.secret_key).await?;
let status: serde_json::Value = serde_json::from_str(&status_body)
.map_err(|err| format!("pre-interrupt background heal status is not JSON ({err}): {status_body}"))?;
let admin_active = background_rejoin_heal_evidence
|| (status["state"].as_str() == Some("active")
&& status["healOperations"]["activeBySource"]["admin"].as_u64() == Some(1));
if admin_active && (background_enabled || only_admin_heal_is_active(&status)) {
if background_enabled || only_admin_heal_is_active(&status) {
break status;
}
if Instant::now() >= partial_deadline {
return Err(
format!("heal tasks did not reach the required interruption state before the deadline: {status}").into(),
);
return Err(format!("non-admin heal did not drain before the interruption deadline: {status}").into());
}
sleep(Duration::from_millis(50)).await;
};
@@ -2638,157 +2562,4 @@ mod tests {
};
assert!(!outage_candidate_targets_replacement(&insufficient_peer_shards, 12, replacement_set_slot));
}
async fn two_node_heal_status(
cluster: &RustFSTestClusterEnvironment,
bucket: Option<&str>,
recreate: bool,
scan_mode: u8,
) -> Result<serde_json::Value, Box<dyn Error + Send + Sync>> {
let url = format!("{}/rustfs/admin/v3/heal/{}", cluster.nodes[0].url, bucket.unwrap_or_default());
let options = serde_json::json!({
"recursive": true, "dryRun": false, "remove": false,
"recreate": recreate, "scanMode": scan_mode, "updateParity": false, "nolock": false,
});
let started =
start_root_heal_when_control_ready(&url, &options.to_string(), &cluster.access_key, &cluster.secret_key).await?;
let started: serde_json::Value = serde_json::from_str(&started)?;
let token = started["clientToken"]
.as_str()
.ok_or_else(|| format!("missing heal token: {started}"))?;
let status_url = format!("{url}?clientToken={token}");
let deadline = Instant::now() + Duration::from_secs(240);
loop {
let body = timeout(
Duration::from_secs(20),
signed_admin_post(&status_url, None, &cluster.access_key, &cluster.secret_key),
)
.await??;
let status: serde_json::Value = serde_json::from_str(&body)?;
match status["summary"].as_str() {
Some("finished" | "stopped") => return Ok(status),
Some("running") if Instant::now() < deadline => sleep(Duration::from_millis(250)).await,
_ => return Err(format!("heal did not reach a terminal state: {status}").into()),
}
}
}
async fn verify_two_node_directory_recovery(
remove_bucket: bool,
scan_mode: u8,
bucket_scoped: bool,
recreate: bool,
) -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut cluster = RustFSTestClusterEnvironment::new(2).await?;
// Isolate explicit administrator recovery from scanner, MRF, and disk healing.
for key in [
"RUSTFS_HEAL_AUTO_HEAL_ENABLE",
"RUSTFS_HEAL_MRF_ENABLE",
"RUSTFS_SCANNER_ENABLED",
"RUSTFS_PUT_RENAME_EARLY_ACK_ENABLE",
] {
cluster.set_env(key, "false");
}
cluster.start().await?;
let source = cluster.create_s3_client(0)?;
let target = cluster.create_s3_client(1)?;
let bucket = "two-node-directory-recovery";
source.create_bucket().bucket(bucket).send().await?;
let objects = [
("empty", Vec::new()),
("nested/inline", vec![0x3c; 1024]),
("nested/external", (0_u8..=255).cycle().take(1024 * 1024).collect::<Vec<_>>()),
];
for (key, bytes) in &objects {
source
.put_object()
.bucket(bucket)
.key(*key)
.body(ByteStream::from(bytes.clone()))
.send()
.await?;
}
let target_bucket = Path::new(&cluster.nodes[1].data_dir).join(bucket);
let original = objects
.iter()
.map(|(key, _)| census_object_version_on_disk(Path::new(&cluster.nodes[1].data_dir), bucket, key, None))
.collect::<Result<Vec<_>, _>>()?;
for census in &original {
assert!(census.has_xl_meta, "fixture must have a target metadata copy: {census:?}");
}
assert!(
!original[2].present_part_fingerprints.is_empty(),
"large fixture must exercise external parts"
);
let removed = Path::new(&cluster.temp_dir).join("removed");
tokio::fs::create_dir(&removed).await?;
if remove_bucket {
tokio::fs::rename(&target_bucket, removed.join(bucket)).await?;
} else {
for (index, (key, _)) in objects.iter().enumerate() {
tokio::fs::rename(target_bucket.join(key), removed.join(index.to_string())).await?;
}
}
let status = two_node_heal_status(&cluster, bucket_scoped.then_some(bucket), recreate, scan_mode).await?;
assert_eq!(status["summary"], "finished", "administrator recovery should complete: {status}");
assert_eq!(status["outcome"]["execution"]["state"], "completed");
assert_eq!(status["outcome"]["coverage"], "complete");
for ((key, _), before) in objects.iter().zip(&original) {
let after = census_object_version_on_disk(Path::new(&cluster.nodes[1].data_dir), bucket, key, None)?;
assert!(after.has_xl_meta, "metadata must be restored before GET: {key}");
assert_eq!(
after.present_part_fingerprints, before.present_part_fingerprints,
"external shard bytes must match: {key}"
);
assert_eq!(
after.inline_data_fingerprint, before.inline_data_fingerprint,
"inline shard bytes must match: {key}"
);
}
cluster.stop_node(0)?;
for (key, expected) in &objects {
let actual = timeout(Duration::from_secs(30), async {
target
.get_object()
.bucket(bucket)
.key(*key)
.send()
.await?
.body
.collect()
.await
.map(|body| body.into_bytes())
.map_err(Box::<dyn Error + Send + Sync>::from)
})
.await??;
assert_eq!(actual.len(), expected.len(), "target-only read length: {key}");
assert_eq!(sha256_hex(&actual), sha256_hex(expected), "target-only content: {key}");
}
Ok(())
}
#[tokio::test]
async fn test_two_node_bucket_discovery_and_explicit_recreate() -> Result<(), Box<dyn Error + Send + Sync>> {
verify_two_node_directory_recovery(true, 2, true, true).await
}
#[tokio::test]
async fn test_two_node_missing_bucket_default_cluster_recovery() -> Result<(), Box<dyn Error + Send + Sync>> {
verify_two_node_directory_recovery(true, 2, false, false).await
}
#[tokio::test]
async fn test_two_node_missing_bucket_default_scoped_recovery() -> Result<(), Box<dyn Error + Send + Sync>> {
verify_two_node_directory_recovery(true, 2, true, false).await
}
#[tokio::test]
async fn test_two_node_object_directory_recovery_normal() -> Result<(), Box<dyn Error + Send + Sync>> {
verify_two_node_directory_recovery(false, 1, false, false).await
}
#[tokio::test]
async fn test_two_node_object_directory_recovery_deep() -> Result<(), Box<dyn Error + Send + Sync>> {
verify_two_node_directory_recovery(false, 2, false, false).await
}
}
@@ -264,7 +264,7 @@ async fn call_make_volume_response(
request: MakeVolumeRequest,
headers: HeaderMap,
) -> Result<Response<MakeVolumeResponse>, Status> {
let mut client = node_service_time_out_client_no_auth(url)
let mut client = node_service_time_out_client_no_auth(&url.to_string())
.await
.map_err(|err| Status::unavailable(format!("cannot reach the node service: {err}")))?;
let mut rpc_request = Request::new(request);
@@ -273,7 +273,7 @@ async fn call_make_volume_response(
}
async fn call_ping_response(url: &str, headers: HeaderMap) -> Result<Response<PingResponse>, Status> {
let mut client = node_service_time_out_client_no_auth(url)
let mut client = node_service_time_out_client_no_auth(&url.to_string())
.await
.map_err(|err| Status::unavailable(format!("cannot reach the node service: {err}")))?;
let mut rpc_request = Request::new(PingRequest {
-7
View File
@@ -138,10 +138,6 @@ mod admin_auth_test;
#[cfg(test)]
mod existing_object_tag_policy_test;
/// IAM / bucket / STS session policy with `s3:if-none-match` / `s3:if-match` conditions (E2E).
#[cfg(test)]
mod conditional_write_policy_test;
#[cfg(test)]
mod sts_query_compat_test;
@@ -183,9 +179,6 @@ mod compression_test;
#[cfg(test)]
mod delete_objects_versioning_test;
#[cfg(test)]
mod conditional_delete_objects_test;
#[cfg(test)]
mod delete_authorization_test;
-376
View File
@@ -1001,382 +1001,6 @@ async fn test_anonymous_post_object_rejects_sse_kms_params_outside_policy_condit
Ok(())
}
#[tokio::test]
async fn test_object_creation_operation_separates_staging_from_publication() -> Result<(), Box<dyn Error + Send + Sync>> {
use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart};
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let client = env.create_s3_client();
let bucket = "object-creation-operation";
client.create_bucket().bucket(bucket).send().await?;
client
.put_object()
.bucket(bucket)
.key("source")
.body(ByteStream::from_static(b"copied-part"))
.send()
.await?;
let policy = |deny_creation: bool| {
serde_json::json!({
"Version": "2012-10-17",
"Statement": [
{ "Effect": "Allow", "Principal": "*", "Action": "s3:PutObject", "Resource": format!("arn:aws:s3:::{bucket}/*") },
{ "Effect": "Deny", "Principal": "*", "Action": "s3:PutObject", "Resource": format!("arn:aws:s3:::{bucket}/target*"),
"Condition": { "Bool": { "s3:ObjectCreationOperation": deny_creation.to_string() } } },
{ "Effect": "Deny", "Principal": "*", "Action": "s3:GetObject", "Resource": format!("arn:aws:s3:::{bucket}/*"),
"Condition": { "Null": { "s3:ObjectCreationOperation": "false" } } }
]
}).to_string()
};
client.put_bucket_policy().bucket(bucket).policy(policy(true)).send().await?;
let upload = client
.create_multipart_upload()
.bucket(bucket)
.key("target-multipart")
.send()
.await?;
let upload_id = upload.upload_id().expect("multipart initiation should return upload ID");
let first_body = vec![b'a'; 5 * 1024 * 1024];
let part = client
.upload_part()
.bucket(bucket)
.key("target-multipart")
.upload_id(upload_id)
.part_number(1)
.body(ByteStream::from(first_body.clone()))
.send()
.await?;
let copy = client
.upload_part_copy()
.bucket(bucket)
.key("target-multipart")
.upload_id(upload_id)
.part_number(2)
.copy_source(format!("{bucket}/source"))
.send()
.await?;
let completed = CompletedMultipartUpload::builder()
.parts(
CompletedPart::builder()
.part_number(1)
.e_tag(part.e_tag().expect("part ETag"))
.build(),
)
.parts(
CompletedPart::builder()
.part_number(2)
.e_tag(copy.copy_part_result().and_then(|part| part.e_tag()).expect("copy part ETag"))
.build(),
)
.build();
let error = client
.complete_multipart_upload()
.bucket(bucket)
.key("target-multipart")
.upload_id(upload_id)
.multipart_upload(completed.clone())
.send()
.await
.expect_err("publication must be denied");
assert_eq!(error.as_service_error().and_then(|error| error.code()), Some("AccessDenied"));
let error = client
.put_object()
.bucket(bucket)
.key("target-put")
.body(ByteStream::from_static(b"blocked"))
.customize()
.mutate_request(|req| {
req.headers_mut().insert("objectcreationoperation", "false");
})
.send()
.await
.expect_err("a spoofed header must not exempt PutObject");
assert_eq!(error.as_service_error().and_then(|error| error.code()), Some("AccessDenied"));
let error = client
.copy_object()
.bucket(bucket)
.key("target-copy")
.copy_source(format!("{bucket}/source"))
.send()
.await
.expect_err("CopyObject publishes an object");
assert_eq!(error.as_service_error().and_then(|error| error.code()), Some("AccessDenied"));
let archive = make_tar(&[("target-member", b"blocked member")], &[]).await;
let error = client
.put_object()
.bucket(bucket)
.key("archive.tar")
.body(ByteStream::from(archive))
.customize()
.mutate_request(|req| {
req.headers_mut().insert("x-amz-meta-snowball-auto-extract", "true");
})
.send()
.await
.expect_err("each extracted member must authorize object creation");
assert_eq!(error.as_service_error().and_then(|error| error.code()), Some("AccessDenied"));
let missing = client
.head_object()
.bucket(bucket)
.key("target-member")
.send()
.await
.expect_err("denied archive member must not exist");
assert!(missing.as_service_error().is_some_and(|error| error.is_not_found()));
for deny_creation in [true, false] {
client
.put_bucket_policy()
.bucket(bucket)
.policy(policy(deny_creation))
.send()
.await?;
let form = reqwest::multipart::Form::new()
.text("key", "target-post")
.text("success_action_status", "201")
.part(
"file",
reqwest::multipart::Part::bytes(b"post body".to_vec())
.file_name("upload.txt")
.mime_str("text/plain")?,
);
let response = local_http_client()
.post(format!("{}/{bucket}", env.url))
.multipart(form)
.send()
.await?;
assert_eq!(
response.status(),
if deny_creation {
reqwest::StatusCode::FORBIDDEN
} else {
reqwest::StatusCode::CREATED
},
"POST object creation={deny_creation}"
);
}
let error = client
.create_multipart_upload()
.bucket(bucket)
.key("target-denied")
.send()
.await
.expect_err("Bool=false must deny multipart staging under the inverted policy");
assert_eq!(error.as_service_error().and_then(|error| error.code()), Some("AccessDenied"));
client
.complete_multipart_upload()
.bucket(bucket)
.key("target-multipart")
.upload_id(upload_id)
.multipart_upload(completed)
.send()
.await?;
let object = client
.get_object()
.bucket(bucket)
.key("target-multipart")
.send()
.await?
.body
.collect()
.await?
.into_bytes();
let mut expected = first_body;
expected.extend_from_slice(b"copied-part");
assert_eq!(object.as_ref(), expected, "completion must publish both regular and copied parts");
Ok(())
}
/// A vended STS credential whose inline session policy follows AWS's conditional-write
/// example 1: object publication needs a request header, multipart staging is exempt
/// through `s3:ObjectCreationOperation`. `s3:x-amz-storage-class` stands in for
/// `s3:if-none-match` because only the former is a header SDK requests can carry here.
#[tokio::test]
async fn test_object_creation_operation_in_sts_session_policy() -> Result<(), Box<dyn Error + Send + Sync>> {
use crate::common::{
AdminTransport, admin_add_canned_policy_via, admin_attach_user_policy_via, admin_create_user_via,
awscurl_post_sts_form_urlencoded, build_test_s3_config,
};
use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart, StorageClass};
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let admin = env.create_s3_client();
let bucket = "object-creation-sts";
admin.create_bucket().bucket(bucket).send().await?;
admin
.put_object()
.bucket(bucket)
.key("source")
.body(ByteStream::from_static(b"copied-part"))
.send()
.await?;
let (parent, parent_secret, parent_policy) = ("objcreate-sts-parent", "objcreate-sts-parent-secret", "objcreate-sts-rw");
let (url, access_key, secret_key) = (env.url.as_str(), env.access_key.as_str(), env.secret_key.as_str());
admin_create_user_via(AdminTransport::Signed, url, access_key, secret_key, parent, parent_secret).await?;
let read_write = serde_json::json!({
"Version": "2012-10-17",
"Statement": [
{ "Effect": "Allow", "Action": ["s3:*"], "Resource": ["arn:aws:s3:::*"] },
{ "Effect": "Allow", "Action": ["sts:AssumeRole"], "Resource": ["arn:aws:s3:::*"] }
]
})
.to_string();
admin_add_canned_policy_via(AdminTransport::Signed, url, access_key, secret_key, parent_policy, &read_write).await?;
admin_attach_user_policy_via(AdminTransport::Signed, url, access_key, secret_key, parent_policy, parent).await?;
let objects = format!("arn:aws:s3:::{bucket}/*");
let session_policy = serde_json::json!({
"Version": "2012-10-17",
"Statement": [
{ "Effect": "Allow", "Action": "s3:PutObject", "Resource": objects,
"Condition": { "Null": { "s3:x-amz-storage-class": "false" } } },
{ "Effect": "Allow", "Action": "s3:PutObject", "Resource": objects,
"Condition": { "Bool": { "s3:ObjectCreationOperation": "false" } } },
{ "Effect": "Allow", "Action": "s3:GetObject", "Resource": objects }
]
})
.to_string();
let body = format!(
"Action=AssumeRole&Version=2011-06-15&DurationSeconds=3600&Policy={}",
urlencoding::encode(&session_policy)
);
let xml =
awscurl_post_sts_form_urlencoded(&format!("{}/", env.url.trim_end_matches('/')), &body, parent, parent_secret).await?;
let field = |tag: &str| -> Result<String, Box<dyn Error + Send + Sync>> {
let open = format!("<{tag}>");
let start = xml.find(&open).ok_or(format!("missing {tag} in AssumeRole response"))? + open.len();
let end = xml[start..].find(&format!("</{tag}>")).ok_or(format!("unterminated {tag}"))? + start;
Ok(xml[start..end].to_string())
};
let session = aws_sdk_s3::Client::from_conf(build_test_s3_config(
&env.url,
&field("AccessKeyId")?,
&field("SecretAccessKey")?,
Some(&field("SessionToken")?),
"object-creation-sts-session",
));
let denied = |error: Option<&str>, what: &str| assert_eq!(error, Some("AccessDenied"), "{what}");
let error = session
.put_object()
.bucket(bucket)
.key("target-put")
.body(ByteStream::from_static(b"blocked"))
.send()
.await
.expect_err("PutObject without the header must be denied");
denied(error.as_service_error().and_then(|error| error.code()), "PutObject without the header");
session
.put_object()
.bucket(bucket)
.key("target-put")
.storage_class(StorageClass::Standard)
.body(ByteStream::from_static(b"allowed"))
.send()
.await?;
let error = session
.copy_object()
.bucket(bucket)
.key("target-copy")
.copy_source(format!("{bucket}/source"))
.send()
.await
.expect_err("CopyObject without the header must be denied");
denied(error.as_service_error().and_then(|error| error.code()), "CopyObject without the header");
session
.copy_object()
.bucket(bucket)
.key("target-copy")
.copy_source(format!("{bucket}/source"))
.storage_class(StorageClass::Standard)
.send()
.await?;
let upload = session
.create_multipart_upload()
.bucket(bucket)
.key("target-multipart")
.send()
.await?;
let upload_id = upload.upload_id().expect("multipart initiation should return upload ID");
let first_body = vec![b'a'; 5 * 1024 * 1024];
let part = session
.upload_part()
.bucket(bucket)
.key("target-multipart")
.upload_id(upload_id)
.part_number(1)
.body(ByteStream::from(first_body.clone()))
.send()
.await?;
let copy = session
.upload_part_copy()
.bucket(bucket)
.key("target-multipart")
.upload_id(upload_id)
.part_number(2)
.copy_source(format!("{bucket}/source"))
.send()
.await?;
let completed = CompletedMultipartUpload::builder()
.parts(
CompletedPart::builder()
.part_number(1)
.e_tag(part.e_tag().expect("part ETag"))
.build(),
)
.parts(
CompletedPart::builder()
.part_number(2)
.e_tag(copy.copy_part_result().and_then(|part| part.e_tag()).expect("copy part ETag"))
.build(),
)
.build();
let error = session
.complete_multipart_upload()
.bucket(bucket)
.key("target-multipart")
.upload_id(upload_id)
.multipart_upload(completed.clone())
.send()
.await
.expect_err("CompleteMultipartUpload without the header must be denied");
denied(
error.as_service_error().and_then(|error| error.code()),
"CompleteMultipartUpload without the header",
);
session
.complete_multipart_upload()
.bucket(bucket)
.key("target-multipart")
.upload_id(upload_id)
.multipart_upload(completed)
.customize()
.mutate_request(|req| {
req.headers_mut()
.insert("x-amz-storage-class", HeaderValue::from_static("STANDARD"));
})
.send()
.await?;
let object = session
.get_object()
.bucket(bucket)
.key("target-multipart")
.send()
.await?
.body
.collect()
.await?
.into_bytes();
let mut expected = first_body;
expected.extend_from_slice(b"copied-part");
assert_eq!(object.as_ref(), expected, "completion must publish both regular and copied parts");
Ok(())
}
#[tokio::test]
async fn test_anonymous_multipart_control_apis_require_auth() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();

Some files were not shown because too many files have changed in this diff Show More