mirror of
https://github.com/rustfs/rustfs.git
synced 2026-10-27 14:27:10 +00:00
merge: sync main into plugin runner integration
This commit is contained in:
@@ -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 | [Maintain milestones, installation references, and the website](references/post-release-updates.md) only after complete publication. |
|
||||
| 7 | [Reconcile Security Advisories, 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; 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; 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.
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
# Post-release Milestone, Installation, and Website Updates
|
||||
# Post-release Security Advisory, 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.
|
||||
|
||||
@@ -45,11 +45,13 @@ 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`.
|
||||
@@ -110,6 +112,7 @@ 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
|
||||
|
||||
@@ -143,13 +146,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, and multipart-copy source cases.
|
||||
- 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-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, plugin mode, and built-in policy mode.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
sha256-darwin=98dff0961c6eb21d6128bfd2d4a6100a624ddf1fb7db05dba360a5d3d0a310c2
|
||||
sha256-linux=890f5925b87e1bf3710c356253f3b2276d901e156df9ccc04ec9315374de6d22
|
||||
sha256-darwin=11f953852ca8305e132cb25bfa33a494fe0e4b34847f9e96ce7097d79acccf3d
|
||||
sha256-linux=d5f1f598f10680a790fb854f5eec884e4c1dd95eb94ab4b33c791a8fab998e9c
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
sha256-darwin=efe68675f4676a0d2901242c5815b1f7af5cdec8231f7cccd408f6dacf5c75db
|
||||
sha256-linux=003ed6480935a0a95680cc061be143722fbd37bd477b8ff98607b1a064f3aef1
|
||||
sha256-darwin=347a0d4e5e547d5388703062ce66a66167a845b6e57ec591ccb4c732a567c68d
|
||||
sha256-linux=4f9efa47194279185bb68249e82c788fa147abe944b6c764050e3d47f31e7926
|
||||
|
||||
@@ -1 +1 @@
|
||||
sha256=ba8f392b617cf9f2338142233a417e43927b28096635e628233ce8a60cf49d10
|
||||
sha256=ce16185f87c6cd30aa0891ebd22be3352aedc10040d48cc5b00652d72f7be3b5
|
||||
|
||||
@@ -52,6 +52,9 @@ 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
|
||||
|
||||
@@ -76,6 +76,13 @@ 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"
|
||||
@@ -88,6 +95,14 @@ 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'
|
||||
@@ -327,6 +342,13 @@ 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"
|
||||
@@ -339,6 +361,11 @@ 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'
|
||||
@@ -595,6 +622,7 @@ 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 (job, bucket) (rustfs_cluster_usage_buckets_objects_count{job=~\"$job\", bucket=~\"$bucket\"})",
|
||||
"expr": "max by (rustfs_cluster_id, job, bucket) (rustfs_cluster_usage_buckets_objects_count{rustfs_cluster_id=\"$storage_cluster\", job=~\"$job\", bucket=~\"$bucket\"})",
|
||||
"legendFormat": "{{bucket}}",
|
||||
"range": true,
|
||||
"refId": "A"
|
||||
@@ -1848,7 +1848,7 @@
|
||||
"uid": "${datasource}"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "max by (job, bucket) (rustfs_cluster_usage_buckets_total_bytes{job=~\"$job\", bucket=~\"$bucket\"})",
|
||||
"expr": "max by (rustfs_cluster_id, job, bucket) (rustfs_cluster_usage_buckets_total_bytes{rustfs_cluster_id=\"$storage_cluster\", job=~\"$job\", bucket=~\"$bucket\"})",
|
||||
"legendFormat": "{{bucket}}",
|
||||
"range": true,
|
||||
"refId": "A"
|
||||
|
||||
@@ -61,6 +61,7 @@ scrape_configs:
|
||||
otlp:
|
||||
promote_resource_attributes:
|
||||
- service.instance.id
|
||||
- process.pid
|
||||
- service.name
|
||||
- service.namespace
|
||||
- cloud.availability_zone
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
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: []
|
||||
@@ -0,0 +1,39 @@
|
||||
# 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 ]]
|
||||
@@ -36,6 +36,16 @@ 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
|
||||
@@ -99,10 +109,14 @@ 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'
|
||||
@@ -126,5 +140,5 @@ runs:
|
||||
# normalises, so cargo fingerprints stay valid.
|
||||
cache-all-crates: false
|
||||
cache-on-failure: true
|
||||
shared-key: ${{ inputs.cache-shared-key }}
|
||||
shared-key: ${{ inputs.cache-shared-key }}${{ inputs.install-cross-tools == 'true' && format('-zig-{0}-zigbuild-{1}', inputs.zig-version, inputs.cargo-zigbuild-version) || '' }}
|
||||
save-if: ${{ inputs.cache-save-if }}
|
||||
|
||||
+56
-39
@@ -213,12 +213,12 @@ jobs:
|
||||
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":""},
|
||||
{"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":""}
|
||||
{"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":""}
|
||||
]}'
|
||||
|
||||
if [[ "${selected}" == "all" ]]; then
|
||||
@@ -246,7 +246,8 @@ jobs:
|
||||
echo "Selected platforms: ${selected}"
|
||||
|
||||
build-rustfs:
|
||||
name: Build RustFS
|
||||
# Acceptance workflows select artifacts by this stable platform name.
|
||||
name: Build RustFS (${{ matrix.target_id }})
|
||||
needs: [ build-check, prepare-platform-matrix ]
|
||||
if: needs.build-check.outputs.should_build == 'true' && needs.prepare-platform-matrix.result == 'success'
|
||||
runs-on: ${{ matrix.os }}
|
||||
@@ -273,7 +274,7 @@ jobs:
|
||||
with:
|
||||
rust-version: stable
|
||||
target: ${{ matrix.target }}
|
||||
cache-shared-key: build-${{ matrix.target }}
|
||||
cache-shared-key: build-${{ matrix.target }}${{ matrix.glibc && format('-glibc-{0}', matrix.glibc) || '' }}
|
||||
# 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,
|
||||
@@ -410,35 +411,53 @@ 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
|
||||
|
||||
# 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
|
||||
# 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
|
||||
|
||||
- name: Verify macOS binary has no dynamic liblzma dependency
|
||||
if: matrix.platform == 'macos'
|
||||
@@ -554,9 +573,6 @@ 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)
|
||||
@@ -671,7 +687,7 @@ jobs:
|
||||
echo "🔧 Build type: ${BUILD_TYPE}"
|
||||
echo "📊 Version: ${VERSION}"
|
||||
|
||||
- name: Verify packaged Linux binaries
|
||||
- name: Verify packaged Linux binary
|
||||
if: matrix.platform == 'linux'
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -679,7 +695,7 @@ jobs:
|
||||
|
||||
package_file="${{ steps.package.outputs.package_file }}"
|
||||
archive_members="$(unzip -Z1 "$package_file" | sort)"
|
||||
expected_members=$'rustfs\nrustfs-cli'
|
||||
expected_members='rustfs'
|
||||
target="${{ matrix.target }}"
|
||||
features="${{ matrix.features }}"
|
||||
if [[ "$target" == *-linux-gnu && "$features" == "pyroscope" ]]; then
|
||||
@@ -691,13 +707,14 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Every Linux target builds natively, so executing the binaries here
|
||||
# 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
|
||||
# proves each artifact matches its runner architecture.
|
||||
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
|
||||
binary_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$binary_dir"' EXIT
|
||||
unzip -q "$package_file" rustfs -d "$binary_dir"
|
||||
"$binary_dir/rustfs" --version
|
||||
|
||||
- name: Verify packaged console
|
||||
if: matrix.target == 'x86_64-unknown-linux-gnu' || matrix.target == 'aarch64-unknown-linux-gnu'
|
||||
|
||||
@@ -99,7 +99,7 @@ env:
|
||||
RUST_BACKTRACE: 1
|
||||
|
||||
jobs:
|
||||
# Readers: test-and-lint, test-ilm-integration-serial, build-rustfs-debug-binary,
|
||||
# Readers: test-and-lint (workspace and ILM), build-rustfs-debug-binary,
|
||||
# e2e-tests, e2e-full.
|
||||
warm-ci-dev:
|
||||
name: Warm ci-dev
|
||||
|
||||
+50
-84
@@ -168,13 +168,10 @@ jobs:
|
||||
echo "started_at=$(date --utc --iso-8601=seconds)"
|
||||
} > artifacts/test-and-lint/run-metadata.txt
|
||||
|
||||
# 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.
|
||||
# Keep full Clippy validation on main, merge groups, and scheduled/manual
|
||||
# runs. PRs retain the complete default test suite and compile-free checks.
|
||||
- name: Run clippy lints
|
||||
if: github.event_name != 'pull_request'
|
||||
run: |
|
||||
./scripts/ci/resource_sampler.sh start clippy
|
||||
trap './scripts/ci/resource_sampler.sh stop' EXIT
|
||||
@@ -283,6 +280,49 @@ 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
|
||||
|
||||
# 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:
|
||||
@@ -314,83 +354,11 @@ jobs:
|
||||
- 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)
|
||||
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||
# Alternate feature configurations run on main, merge groups, and scheduled
|
||||
# or manual validation; the default workspace remains the PR gate.
|
||||
if: needs.classify-changes.outputs.mode == 'full' && github.event_name != 'pull_request'
|
||||
needs: [ quick-checks, classify-changes ]
|
||||
runs-on: sm-standard-4
|
||||
timeout-minutes: 90
|
||||
@@ -506,7 +474,7 @@ jobs:
|
||||
|
||||
test-and-lint-protocols:
|
||||
name: "Test and Lint (${{ matrix.features.name }})"
|
||||
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||
if: needs.classify-changes.outputs.mode == 'full' && github.event_name != 'pull_request'
|
||||
needs: [ quick-checks, classify-changes ]
|
||||
runs-on: sm-standard-4
|
||||
timeout-minutes: 90
|
||||
@@ -1264,7 +1232,6 @@ jobs:
|
||||
- quick-checks
|
||||
- test-and-lint
|
||||
- offline-enrollment-e2e
|
||||
- test-ilm-integration-serial
|
||||
- test-and-lint-rio-v2
|
||||
- connect-short-credential-boundary
|
||||
- test-and-lint-protocols
|
||||
@@ -1298,7 +1265,6 @@ jobs:
|
||||
- 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
|
||||
|
||||
@@ -57,6 +57,14 @@ 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:
|
||||
@@ -94,7 +102,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, [a-z0-9-]+, x86_64-unknown-linux-gnu, false, linux, pyroscope\\)$"))] as $matches
|
||||
[.[].jobs[] | select(.name | test("^Build RustFS \\(linux-x86_64-gnu\\)$"))] as $matches
|
||||
| ($matches | length) == 1 and $matches[0].conclusion == "success"
|
||||
' <<<"$jobs" >/dev/null
|
||||
artifact=$(gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}")
|
||||
@@ -137,7 +145,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-cli'} and len(entries) == 2
|
||||
assert {item.filename for item in entries} == {'rustfs', 'rustfs.cpu-symbol-catalog.json', 'rustfs.cpu-symbol-catalog.sha256'} and len(entries) == 3
|
||||
assert all(regular(item) and 0 < item.file_size <= 1073741824 for item in entries)
|
||||
assert inner.testzip() is None
|
||||
pathlib.Path('binary').mkdir()
|
||||
@@ -162,7 +170,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)
|
||||
RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
|
||||
TMPDIR="$GITHUB_WORKSPACE" 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,6 +57,14 @@ 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:
|
||||
@@ -127,7 +135,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, [a-z0-9-]+, x86_64-unknown-linux-gnu, false, linux, pyroscope\\)$"))] as $matches
|
||||
[.[].jobs[] | select(.name | test("^Build RustFS \\(linux-x86_64-gnu\\)$"))] as $matches
|
||||
| ($matches | length) == 1 and $matches[0].conclusion == "success"
|
||||
' <<<"$jobs" >/dev/null
|
||||
artifact=$(gh api "repos/rustfs/rustfs/actions/artifacts/${ARTIFACT_ID}")
|
||||
@@ -172,7 +180,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-cli', 'rustfs.cpu-symbol-catalog.json', 'rustfs.cpu-symbol-catalog.sha256'} and len(entries) == 4
|
||||
assert {entry.filename for entry in entries} == {'rustfs', 'rustfs.cpu-symbol-catalog.json', 'rustfs.cpu-symbol-catalog.sha256'} and len(entries) == 3
|
||||
assert all(regular(entry) and 0 < entry.file_size <= 1073741824 for entry in entries)
|
||||
assert inner.testzip() is None
|
||||
pathlib.Path('binary').mkdir()
|
||||
@@ -199,7 +207,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)
|
||||
RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
|
||||
TMPDIR="$GITHUB_WORKSPACE" 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,6 +73,14 @@ 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:
|
||||
@@ -107,7 +115,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, sm-standard-4, x86_64-unknown-linux-gnu, false, linux, pyroscope)'
|
||||
expected_job='Build RustFS (linux-x86_64-gnu)'
|
||||
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
|
||||
@@ -154,7 +162,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
|
||||
RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
|
||||
TMPDIR="$GITHUB_WORKSPACE" 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,6 +73,14 @@ 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:
|
||||
@@ -107,7 +115,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, sm-standard-4, x86_64-unknown-linux-gnu, false, linux, pyroscope)'
|
||||
expected_job='Build RustFS (linux-x86_64-gnu)'
|
||||
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
|
||||
@@ -154,7 +162,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
|
||||
RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
|
||||
TMPDIR="$GITHUB_WORKSPACE" 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,6 +77,14 @@ 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:
|
||||
@@ -118,7 +126,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, sm-standard-4, x86_64-unknown-linux-gnu, false, linux, pyroscope)'
|
||||
expected_job='Build RustFS (linux-x86_64-gnu)'
|
||||
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
|
||||
@@ -173,7 +181,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
|
||||
RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
|
||||
TMPDIR="$GITHUB_WORKSPACE" 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,6 +57,14 @@ 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:
|
||||
@@ -127,7 +135,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, [a-z0-9-]+, x86_64-unknown-linux-gnu, false, linux, pyroscope\\)$"))] as $matches
|
||||
[.[].jobs[] | select(.name | test("^Build RustFS \\(linux-x86_64-gnu\\)$"))] as $matches
|
||||
| ($matches | length) == 1 and $matches[0].conclusion == "success"
|
||||
' <<<"$jobs" >/dev/null
|
||||
artifact=$(gh api "repos/rustfs/rustfs/actions/artifacts/${ARTIFACT_ID}")
|
||||
@@ -172,7 +180,7 @@ jobs:
|
||||
assert outer.testzip() is None
|
||||
with zipfile.ZipFile(io.BytesIO(outer.read(package))) as inner:
|
||||
entries = inner.infolist()
|
||||
expected = {'rustfs', 'rustfs-cli', 'rustfs.cpu-symbol-catalog.json', 'rustfs.cpu-symbol-catalog.sha256'}
|
||||
expected = {'rustfs', '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
|
||||
@@ -198,7 +206,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)
|
||||
RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
|
||||
TMPDIR="$GITHUB_WORKSPACE" 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,6 +81,14 @@ 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:
|
||||
@@ -148,7 +156,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, sm-standard-4, x86_64-unknown-linux-gnu, false, linux, pyroscope)'
|
||||
expected_job='Build RustFS (linux-x86_64-gnu)'
|
||||
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
|
||||
@@ -201,7 +209,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
|
||||
RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
|
||||
TMPDIR="$GITHUB_WORKSPACE" 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,6 +73,14 @@ 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:
|
||||
@@ -107,7 +115,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, sm-standard-4, x86_64-unknown-linux-gnu, false, linux, pyroscope)'
|
||||
expected_job='Build RustFS (linux-x86_64-gnu)'
|
||||
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
|
||||
@@ -154,7 +162,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
|
||||
RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
|
||||
TMPDIR="$GITHUB_WORKSPACE" 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,6 +68,14 @@ 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:
|
||||
@@ -138,7 +146,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, [a-z0-9-]+, x86_64-unknown-linux-gnu, false, linux, pyroscope\\)$"))] as $matches
|
||||
[.[].jobs[] | select(.name | test("^Build RustFS \\(linux-x86_64-gnu\\)$"))] as $matches
|
||||
| ($matches | length) == 1 and $matches[0].conclusion == "success"
|
||||
' <<<"$jobs" >/dev/null
|
||||
artifact=$(gh api "repos/rustfs/rustfs/actions/artifacts/${ARTIFACT_ID}")
|
||||
@@ -183,7 +191,7 @@ jobs:
|
||||
assert outer.testzip() is None
|
||||
with zipfile.ZipFile(io.BytesIO(outer.read(package))) as inner:
|
||||
entries = inner.infolist()
|
||||
expected = {'rustfs', 'rustfs-cli', 'rustfs.cpu-symbol-catalog.json', 'rustfs.cpu-symbol-catalog.sha256'}
|
||||
expected = {'rustfs', '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
|
||||
@@ -213,7 +221,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
|
||||
RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
|
||||
TMPDIR="$GITHUB_WORKSPACE" 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,6 +73,14 @@ 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:
|
||||
@@ -107,7 +115,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, sm-standard-4, x86_64-unknown-linux-gnu, false, linux, pyroscope)'
|
||||
expected_job='Build RustFS (linux-x86_64-gnu)'
|
||||
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
|
||||
@@ -154,7 +162,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
|
||||
RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
|
||||
TMPDIR="$GITHUB_WORKSPACE" 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,6 +73,14 @@ 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:
|
||||
@@ -107,7 +115,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, sm-standard-4, x86_64-unknown-linux-gnu, false, linux, pyroscope)'
|
||||
expected_job='Build RustFS (linux-x86_64-gnu)'
|
||||
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
|
||||
@@ -154,7 +162,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
|
||||
RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
|
||||
TMPDIR="$GITHUB_WORKSPACE" 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
|
||||
|
||||
@@ -46,9 +46,6 @@ 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:
|
||||
@@ -71,14 +68,6 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -89,49 +78,31 @@ 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: ${{ matrix.name }}
|
||||
name: Upgrade compatibility (${{ matrix.source.version }})
|
||||
needs: build
|
||||
runs-on: sm-standard-2
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
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
|
||||
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
|
||||
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
|
||||
@@ -142,8 +113,7 @@ jobs:
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
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' }}
|
||||
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
install-build-packaging-tools: "false"
|
||||
install-test-tools: "false"
|
||||
|
||||
@@ -163,6 +133,8 @@ 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"
|
||||
@@ -170,22 +142,53 @@ jobs:
|
||||
echo "RUSTFS_UPGRADE_SOURCE_BINARY=$SOURCE_DIR/rustfs" >> "$GITHUB_ENV"
|
||||
echo "RUSTFS_E2E_LOG_DIR=$RUNNER_TEMP/rustfs-upgrade-logs" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run upgrade compatibility test
|
||||
# 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
|
||||
env:
|
||||
RUSTFS_SCANNER_HEAL_G09_EVIDENCE_DIR: ${{ runner.temp }}/rustfs-upgrade-g09-evidence/${{ matrix.artifact }}
|
||||
NO_PROXY: 127.0.0.1,localhost,::1
|
||||
run: |
|
||||
export no_proxy="$NO_PROXY"
|
||||
python3 scripts/e2e_binary.py run -- cargo test --locked -p e2e_test \
|
||||
"upgrade_compatibility_test::${{ matrix.test }}" \
|
||||
-- --ignored --exact --nocapture
|
||||
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"
|
||||
|
||||
- name: Upload scanner/heal G09 evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: ${{ matrix.artifact }}-g09-evidence-${{ github.run_number }}
|
||||
path: ${{ runner.temp }}/rustfs-upgrade-g09-evidence/${{ matrix.artifact }}
|
||||
name: upgrade-g09-evidence-${{ matrix.source.version }}-${{ github.run_number }}
|
||||
path: ${{ runner.temp }}/rustfs-upgrade-g09-evidence
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
||||
@@ -193,7 +196,7 @@ jobs:
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: ${{ matrix.artifact }}-server-logs-${{ github.run_number }}
|
||||
name: upgrade-server-logs-${{ matrix.source.version }}-${{ github.run_number }}
|
||||
path: ${{ runner.temp }}/rustfs-upgrade-logs
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
|
||||
@@ -6,29 +6,20 @@ 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 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.
|
||||
# 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.
|
||||
#
|
||||
# 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
|
||||
# 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
|
||||
# `gateway/integration` ruleset; GITHUB_TOKEN cannot be a ruleset bypass actor,
|
||||
# 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.
|
||||
# so it cannot push through the required reviews and checks. GITHUB_TOKEN is
|
||||
# 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
|
||||
@@ -94,6 +85,7 @@ 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._/-]*$ ]] \
|
||||
@@ -119,13 +111,25 @@ jobs:
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
git diff --name-only --diff-filter=U > "$RUNNER_TEMP/conflicts.txt"
|
||||
git merge --abort
|
||||
if [[ ! -s "$RUNNER_TEMP/conflicts.txt" ]]; then
|
||||
conflicts=$(git diff --name-only --diff-filter=U)
|
||||
if [[ -z "$conflicts" ]]; then
|
||||
git merge --abort || true
|
||||
echo "::error::git merge failed without reporting conflicting paths; this is not a conflict"
|
||||
exit 1
|
||||
fi
|
||||
echo "result=conflict" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
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
|
||||
|
||||
- name: Push the merge commit
|
||||
if: steps.merge.outputs.result == 'merged'
|
||||
@@ -137,50 +141,12 @@ 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 could not open the conflict pull request must not stay
|
||||
# visible only in the Actions tab.
|
||||
# could not push or encountered merge conflicts 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
|
||||
|
||||
@@ -80,13 +80,24 @@ jobs:
|
||||
- name: Setup Rust environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
cache-shared-key: build-x86_64-unknown-linux-gnu
|
||||
cache-shared-key: build-x86_64-unknown-linux-gnu-glibc-2.31
|
||||
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
|
||||
run: cargo build --release --locked --target x86_64-unknown-linux-gnu -p rustfs --bins
|
||||
# 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'
|
||||
|
||||
- name: Build DEB package
|
||||
id: deb
|
||||
|
||||
@@ -20,8 +20,9 @@ on:
|
||||
- ".github/workflows/oidc-keycloak.yml"
|
||||
- "crates/config/src/constants/oidc.rs"
|
||||
- "crates/iam/src/federation/**"
|
||||
- "crates/iam/src/oidc.rs"
|
||||
- "crates/iam/src/oidc/**"
|
||||
- "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"
|
||||
@@ -31,8 +32,9 @@ on:
|
||||
- ".github/workflows/oidc-keycloak.yml"
|
||||
- "crates/config/src/constants/oidc.rs"
|
||||
- "crates/iam/src/federation/**"
|
||||
- "crates/iam/src/oidc.rs"
|
||||
- "crates/iam/src/oidc/**"
|
||||
- "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"
|
||||
|
||||
@@ -19,8 +19,21 @@ 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/**"
|
||||
@@ -29,8 +42,21 @@ 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/**"
|
||||
@@ -77,14 +103,118 @@ 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)
|
||||
}
|
||||
|
||||
@@ -90,3 +90,4 @@ worktrees/*
|
||||
# insta scratch files; the accepted .snap files ARE the assertions and are committed
|
||||
*.snap.new
|
||||
*.pending-snap
|
||||
.mimosa
|
||||
Vendored
+21
-27
@@ -7,7 +7,9 @@
|
||||
"name": "Debug(only) executable 'rustfs'",
|
||||
"env": {
|
||||
"RUST_LOG": "rustfs=info,ecstore=info,s3s=info,iam=info",
|
||||
"RUSTFS_SKIP_BACKGROUND_TASK": "on"
|
||||
"RUSTFS_SKIP_BACKGROUND_TASK": "on",
|
||||
// Local test volumes share one physical disk.
|
||||
"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"
|
||||
@@ -44,6 +46,7 @@
|
||||
"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"
|
||||
@@ -117,7 +120,8 @@
|
||||
"env": {
|
||||
"RUSTFS_ACCESS_KEY": "rustfsadmin",
|
||||
"RUSTFS_SECRET_KEY": "rustfsadmin",
|
||||
//"RUSTFS_VOLUMES": "./target/volume/test{1...4}",
|
||||
"RUSTFS_VOLUMES": "./target/volume/test{1...4}",
|
||||
"RUSTFS_UNSAFE_BYPASS_DISK_CHECK": "true",
|
||||
"RUSTFS_ADDRESS": ":9000",
|
||||
"RUSTFS_CONSOLE_ENABLE": "true",
|
||||
// "RUSTFS_OBS_TRACE_ENDPOINT": "http://127.0.0.1:4318/v1/traces", // jeager otlp http endpoint
|
||||
@@ -135,41 +139,30 @@
|
||||
{
|
||||
"type": "lldb",
|
||||
"request": "launch",
|
||||
"name": "Debug test_lifecycle_transition_basic",
|
||||
"name": "Debug test_transition_and_restore_flows",
|
||||
"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": [],
|
||||
"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"
|
||||
"serial_tests::test_transition_and_restore_flows",
|
||||
"--exact",
|
||||
"--ignored",
|
||||
"--test-threads=1"
|
||||
],
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
{
|
||||
"name": "Debug executable target/debug/rustfs with sse kms",
|
||||
@@ -285,7 +278,7 @@
|
||||
"RUSTFS_COMPRESSION_ENABLED": "true",
|
||||
"RUSTFS_KMS_ENABLE": "true",
|
||||
"RUSTFS_KMS_BACKEND": "local",
|
||||
"RUSTFS_KMS_KEY_DIR": "./target/rio-v2/kms-key-dir",
|
||||
"RUSTFS_KMS_KEY_DIR": "${workspaceFolder}/target/rio-v2/kms-key-dir",
|
||||
"RUSTFS_KMS_LOCAL_MASTER_KEY": "my-secret-key", // Some Password
|
||||
"RUSTFS_KMS_DEFAULT_KEY_ID": "rustfs-master-key",
|
||||
},
|
||||
@@ -307,6 +300,7 @@
|
||||
"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",
|
||||
@@ -315,7 +309,7 @@
|
||||
|
||||
"RUSTFS_KMS_ENABLE": "true",
|
||||
"RUSTFS_KMS_BACKEND": "local",
|
||||
"RUSTFS_KMS_KEY_DIR": "./target/e2e-key-dir",
|
||||
"RUSTFS_KMS_KEY_DIR": "${workspaceFolder}/target/e2e-key-dir",
|
||||
"RUSTFS_KMS_LOCAL_MASTER_KEY": "my-secret-key", // Some Password
|
||||
"RUSTFS_KMS_DEFAULT_KEY_ID": "rustfs-master-key",
|
||||
},
|
||||
|
||||
@@ -25,6 +25,8 @@ 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.
|
||||
@@ -85,6 +87,7 @@ 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.
|
||||
|
||||
Generated
+416
-374
File diff suppressed because it is too large
Load Diff
+80
-79
@@ -74,7 +74,7 @@ edition = "2024"
|
||||
license = "Apache-2.0"
|
||||
repository = "https://github.com/rustfs/rustfs"
|
||||
rust-version = "1.99.0"
|
||||
version = "1.0.1"
|
||||
version = "1.0.2"
|
||||
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 +91,56 @@ redundant_clone = "warn"
|
||||
|
||||
[workspace.dependencies]
|
||||
# RustFS Internal Crates
|
||||
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" }
|
||||
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" }
|
||||
|
||||
# Async Runtime and Networking
|
||||
async-channel = "2.5.0"
|
||||
@@ -155,9 +155,10 @@ 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.0" }
|
||||
pulsar = { default-features = false, version = "=6.9.0" }
|
||||
lapin = { default-features = false, version = "4.12.2" }
|
||||
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"
|
||||
@@ -168,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.2" }
|
||||
tokio = { version = "1.53.3" }
|
||||
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.19" }
|
||||
tokio-util = { version = "0.7.20" }
|
||||
tonic = { version = "0.14.6" }
|
||||
tonic-prost = { version = "0.14.6" }
|
||||
tonic-prost-build = { version = "0.14.6" }
|
||||
@@ -180,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.11", default-features = false, features = [
|
||||
quinn = { version = "0.11.12", default-features = false, features = [
|
||||
"futures-io",
|
||||
"runtime-tokio",
|
||||
"rustls-aws-lc-rs",
|
||||
@@ -200,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.151" }
|
||||
serde_json = { version = "1.0.154" }
|
||||
serde_urlencoded = "0.7.1"
|
||||
|
||||
# Cryptography and Security
|
||||
@@ -219,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.45" }
|
||||
rustls = { default-features = false, version = "0.23.46" }
|
||||
rustls-native-certs = "0.8"
|
||||
rustls-pki-types = "1.15.1"
|
||||
x509-parser = "0.18.1"
|
||||
sha1 = "0.11.0"
|
||||
sha2 = "0.11.0"
|
||||
sha2 = "0.11.1"
|
||||
subtle = "2.6"
|
||||
zeroize = { version = "1.9.1" }
|
||||
proptest = "1"
|
||||
@@ -245,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.14"
|
||||
tar-framing = "0.0.14"
|
||||
tar-codec = "0.0.15"
|
||||
tar-framing = "0.0.15"
|
||||
atoi = "3.1.0"
|
||||
atomic_enum = "0.3.0"
|
||||
aws-config = { version = "1.12.0" }
|
||||
@@ -268,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.1.0" }
|
||||
datafusion = { default-features = false, version = "55.2.0" }
|
||||
derive_builder = "0.20.2"
|
||||
ed25519-dalek = "3.0.0"
|
||||
enumset = "1.1.14"
|
||||
@@ -319,14 +320,14 @@ 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.12.0" }
|
||||
rust-embed = { version = "8.13.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.2" }
|
||||
smallvec = { version = "1.16.3" }
|
||||
compact_str = "0.10.0"
|
||||
snap = "1.1.2"
|
||||
starshard = { version = "2.4.0" }
|
||||
@@ -340,12 +341,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" }
|
||||
tracing-opentelemetry = { version = "0.34.0" }
|
||||
tracing-subscriber = { version = "0.3.23" }
|
||||
transform-stream = "0.3.1"
|
||||
url = "2.5.8"
|
||||
urlencoding = "2.1.3"
|
||||
uuid = { version = "1.27.0" }
|
||||
uuid = { version = "1.28.0" }
|
||||
vaultrs = { version = "0.8.0" }
|
||||
tar = "0.4.46"
|
||||
walkdir = "2.5.0"
|
||||
@@ -353,26 +354,26 @@ winapi-util = "0.1.11"
|
||||
windows = { version = "0.62.2" }
|
||||
windows-sys = "0.61.2"
|
||||
xxhash-rust = { version = "0.8.19" }
|
||||
zip = "8.6.0"
|
||||
zstd = "0.14.0"
|
||||
zip = "9.0.3"
|
||||
zstd = "0.14.2"
|
||||
|
||||
# Observability and Metrics
|
||||
metrics = "0.24.6"
|
||||
metrics-util = "0.20"
|
||||
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" }
|
||||
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" }
|
||||
|
||||
# FTP and SFTP
|
||||
libunftp = { version = "0.23.1" }
|
||||
unftp-core = "0.1.0"
|
||||
suppaftp = { version = "12.1.1" }
|
||||
suppaftp = { version = "12.2.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"
|
||||
@@ -383,7 +384,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.5", default-features = false }
|
||||
hotpath = { version = "0.28.6", default-features = false }
|
||||
# Snapshot testing for output format regression detection
|
||||
insta = { version = "1.49" }
|
||||
|
||||
|
||||
@@ -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.1
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.3
|
||||
```
|
||||
|
||||
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.1 --push
|
||||
./docker-buildx.sh --release 1.0.3 --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.1 # Build specific version
|
||||
make docker-buildx-version VERSION=1.0.3 # Build specific version
|
||||
make help-docker # Show all Docker-related commands
|
||||
```
|
||||
|
||||
|
||||
+3
-3
@@ -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.1
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.3
|
||||
```
|
||||
|
||||
如果您通过绑定挂载启用 TLS 证书目录,也请用同样方式准备该目录:
|
||||
@@ -176,7 +176,7 @@ docker compose -f docker-compose-simple.yml up -d
|
||||
./docker-buildx.sh --push
|
||||
|
||||
# 构建指定版本
|
||||
./docker-buildx.sh --release 1.0.1 --push
|
||||
./docker-buildx.sh --release 1.0.3 --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.1 # 构建指定版本
|
||||
make docker-buildx-version VERSION=1.0.3 # 构建指定版本
|
||||
make help-docker # 显示所有 Docker 相关命令
|
||||
```
|
||||
|
||||
|
||||
@@ -51,6 +51,8 @@ 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 = []
|
||||
|
||||
@@ -34,6 +34,7 @@ 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"] }
|
||||
|
||||
@@ -12,9 +12,10 @@
|
||||
// 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;
|
||||
|
||||
@@ -27,6 +28,42 @@ 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 {
|
||||
@@ -85,17 +122,27 @@ 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>> {
|
||||
GLOBAL_ROOT_CERT.read().await.clone()
|
||||
get_global_outbound_tls_snapshot().root_ca_pem.clone()
|
||||
}
|
||||
|
||||
/// Set the global mTLS identity (cert+key PEM) for outbound gRPC clients.
|
||||
@@ -105,22 +152,32 @@ 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> {
|
||||
GLOBAL_MTLS_IDENTITY.read().await.clone()
|
||||
get_global_outbound_tls_snapshot().mtls_identity.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 {
|
||||
GLOBAL_OUTBOUND_TLS_GENERATION.load(Ordering::Relaxed)
|
||||
get_global_outbound_tls_snapshot().generation
|
||||
}
|
||||
|
||||
/// Evict a stale/dead connection from the global connection cache.
|
||||
@@ -194,8 +251,77 @@ 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, Debug)]
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
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]"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,11 +119,19 @@ 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
|
||||
@@ -266,7 +274,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.
|
||||
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.
|
||||
|
||||
## 📄 License
|
||||
|
||||
|
||||
@@ -69,15 +69,17 @@ 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
|
||||
/// Specifies how long a console authentication session remains valid
|
||||
/// 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`.
|
||||
/// 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 sessions.
|
||||
/// After this timeout, users need to re-authenticate to access the console.
|
||||
/// This is the default timeout for console authentication requests.
|
||||
/// After this timeout, a pending console request is abandoned.
|
||||
/// Default value: 3600 seconds (1 hour)
|
||||
/// Environment variable: RUSTFS_CONSOLE_AUTH_TIMEOUT
|
||||
/// Command line argument: --console-auth-timeout
|
||||
@@ -85,6 +87,26 @@ 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
|
||||
|
||||
@@ -59,6 +59,9 @@ 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";
|
||||
|
||||
/// Scanner config key controlling how often object loops yield.
|
||||
pub const SCANNER_YIELD_EVERY_N_OBJECTS: &str = "yield_every_n_objects";
|
||||
|
||||
@@ -86,6 +89,7 @@ pub const SCANNER_KEYS: &[&str] = &[
|
||||
SCANNER_CACHE_SAVE_TIMEOUT,
|
||||
SCANNER_MAX_CONCURRENT_SET_SCANS,
|
||||
SCANNER_MAX_CONCURRENT_DISK_SCANS,
|
||||
SCANNER_WALKERS_PER_DISK,
|
||||
SCANNER_YIELD_EVERY_N_OBJECTS,
|
||||
SCANNER_ALERT_EXCESS_VERSIONS,
|
||||
SCANNER_ALERT_EXCESS_VERSION_SIZE,
|
||||
@@ -214,6 +218,14 @@ 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";
|
||||
|
||||
/// 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`
|
||||
@@ -228,6 +240,18 @@ 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 object interval for cooperative scanner yields.
|
||||
pub const DEFAULT_SCANNER_YIELD_EVERY_N_OBJECTS: u64 = 128;
|
||||
|
||||
|
||||
@@ -51,6 +51,11 @@ 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";
|
||||
@@ -159,6 +164,15 @@ 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");
|
||||
|
||||
@@ -569,6 +569,25 @@ 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)]
|
||||
@@ -584,6 +603,10 @@ 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)]
|
||||
@@ -3011,6 +3034,58 @@ 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 {
|
||||
|
||||
@@ -146,4 +146,6 @@ 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"] }
|
||||
|
||||
@@ -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`, `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`, `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) |
|
||||
| **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` |
|
||||
|
||||
@@ -1037,6 +1037,22 @@ 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,
|
||||
@@ -1542,6 +1558,15 @@ 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(
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
// 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(())
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
// 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(())
|
||||
}
|
||||
@@ -860,10 +860,12 @@ pub(crate) async fn start_rebalance(cluster: &RustFSTestClusterEnvironment) -> T
|
||||
if status.is_success() {
|
||||
break response;
|
||||
}
|
||||
if status == StatusCode::INTERNAL_SERVER_ERROR
|
||||
&& response.contains("requires a live fleet capability proof")
|
||||
&& Instant::now() < deadline
|
||||
{
|
||||
// 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 {
|
||||
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,
|
||||
awscurl_delete, awscurl_post_sts_form_urlencoded, build_test_s3_config, init_logging, parse_assume_role_credentials,
|
||||
};
|
||||
use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
@@ -41,21 +41,6 @@ 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,
|
||||
|
||||
@@ -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". 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_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_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.
|
||||
|
||||
|
||||
@@ -537,6 +537,10 @@ 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.
|
||||
@@ -1052,6 +1056,13 @@ 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;
|
||||
}
|
||||
@@ -2444,12 +2455,13 @@ 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, limits, versioned) = {
|
||||
let (assign_own, drop_unlisted, require_checksum, ignore_source_etags, 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),
|
||||
)
|
||||
@@ -2489,7 +2501,7 @@ impl S3 for FakeBackend {
|
||||
} else {
|
||||
NULL_VERSION_ID.to_string()
|
||||
};
|
||||
let e_tag = match source_etag(&headers)? {
|
||||
let e_tag = match source_etag(&headers)?.filter(|_| !ignore_source_etags) {
|
||||
Some(value) => value,
|
||||
None => {
|
||||
let (digest, permit) = md5_digest(body.clone(), body_permit).await?;
|
||||
@@ -3112,7 +3124,10 @@ 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 e_tag = source_etag(&headers)?.unwrap_or_else(|| format!("{}-{part_count}", md5_hex(digests)));
|
||||
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 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);
|
||||
signed_admin_post(&heal_url, Some(heal_body), &env.access_key, &env.secret_key)
|
||||
start_root_heal_when_control_ready(&heal_url, heal_body, &env.access_key, &env.secret_key)
|
||||
.await
|
||||
.expect("admin deep heal should be accepted");
|
||||
|
||||
@@ -1191,11 +1191,59 @@ 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 deadline = Instant::now() + Duration::from_secs(60);
|
||||
let mut deadline = Instant::now() + Duration::from_secs(60);
|
||||
let mut schedule_accounted = false;
|
||||
loop {
|
||||
let mut latest_cycle_end = 0;
|
||||
let mut versions_observed = false;
|
||||
@@ -1237,14 +1285,36 @@ mod tests {
|
||||
let versions_scanned = metrics["versions_scanned"]
|
||||
.as_u64()
|
||||
.ok_or("scanner status is missing its version-coverage counter")?;
|
||||
latest_cycle_end = latest_cycle_end.max(cycle_end);
|
||||
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);
|
||||
}
|
||||
versions_observed |= versions_scanned > 0;
|
||||
observations.push(format!(
|
||||
"node{node_index}: end={cycle_end}, versions={versions_scanned}, cycle={}, active={}, leader={}, result={}",
|
||||
"node{node_index}: end={cycle_end}, versions={versions_scanned}, cycle={}, active={}, leader={}, result={}, partial_reason={}, schedule={}",
|
||||
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
|
||||
@@ -1555,7 +1625,8 @@ mod tests {
|
||||
}
|
||||
|
||||
if background_enabled {
|
||||
wait_for_scanner_cycle_after(&cluster, 0).await?;
|
||||
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?;
|
||||
}
|
||||
|
||||
cluster.stop_node(1)?;
|
||||
@@ -1898,11 +1969,16 @@ 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}"))?;
|
||||
if background_enabled || only_admin_heal_is_active(&status) {
|
||||
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)) {
|
||||
break status;
|
||||
}
|
||||
if Instant::now() >= partial_deadline {
|
||||
return Err(format!("non-admin heal did not drain before the interruption deadline: {status}").into());
|
||||
return Err(
|
||||
format!("heal tasks did not reach the required interruption state before the deadline: {status}").into(),
|
||||
);
|
||||
}
|
||||
sleep(Duration::from_millis(50)).await;
|
||||
};
|
||||
@@ -2562,4 +2638,157 @@ 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.to_string())
|
||||
let mut client = node_service_time_out_client_no_auth(url)
|
||||
.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.to_string())
|
||||
let mut client = node_service_time_out_client_no_auth(url)
|
||||
.await
|
||||
.map_err(|err| Status::unavailable(format!("cannot reach the node service: {err}")))?;
|
||||
let mut rpc_request = Request::new(PingRequest {
|
||||
|
||||
@@ -138,6 +138,10 @@ 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;
|
||||
|
||||
@@ -179,6 +183,9 @@ mod compression_test;
|
||||
#[cfg(test)]
|
||||
mod delete_objects_versioning_test;
|
||||
|
||||
#[cfg(test)]
|
||||
mod conditional_delete_objects_test;
|
||||
|
||||
#[cfg(test)]
|
||||
mod delete_authorization_test;
|
||||
|
||||
|
||||
@@ -1001,6 +1001,382 @@ 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();
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use crate::common::RustFSTestClusterEnvironment;
|
||||
use crate::common::{ClusterTopology, RustFSTestClusterEnvironment};
|
||||
use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::error::SdkError;
|
||||
use bytes::Bytes;
|
||||
@@ -185,21 +185,113 @@ async fn wait_for_quorum_read_admission(clients: &[Client], bucket: &str) -> Tes
|
||||
return Ok(());
|
||||
}
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return Err(format!("read quorum did not become available after lease convergence for {bucket}").into());
|
||||
let diagnostics = clients[0].get_object().bucket(bucket).key("warm-small").send().await;
|
||||
return Err(format!(
|
||||
"read quorum did not become available after lease convergence for {bucket}: GET={diagnostics:?}"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cold_degraded_object_mutations_require_only_storage_quorum() -> TestResult {
|
||||
crate::common::init_logging();
|
||||
let mut cluster = RustFSTestClusterEnvironment::new(4).await?;
|
||||
cluster.set_env("RUSTFS_STORAGE_CLASS_STANDARD", "EC:2");
|
||||
cluster.set_env("RUSTFS_PUT_RENAME_EARLY_ACK_ENABLE", "false");
|
||||
cluster.set_env("RUSTFS_OBS_METRICS_EXPORT_ENABLED", "false");
|
||||
cluster.set_env("RUSTFS_GET_OBJECT_METADATA_CACHE_DISTRIBUTED_ENABLE", "false");
|
||||
cluster.start().await?;
|
||||
let clients = cluster
|
||||
.create_all_clients()?
|
||||
.into_iter()
|
||||
.map(|client| {
|
||||
Client::from_conf(
|
||||
client
|
||||
.config()
|
||||
.to_builder()
|
||||
.retry_config(aws_sdk_s3::config::retry::RetryConfig::standard().with_max_attempts(1))
|
||||
.build(),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let bucket = "cold-degraded-mutations";
|
||||
clients[0].create_bucket().bucket(bucket).send().await?;
|
||||
// No object mutation may warm a process-wide configuration probe before
|
||||
// the missing peer is introduced. Three shards still satisfy EC:2 writes.
|
||||
cluster.stop_node(3)?;
|
||||
let admission_deadline = Instant::now() + Duration::from_secs(30);
|
||||
for client in &clients[..3] {
|
||||
loop {
|
||||
match client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key("never-created-readiness-probe")
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Err(error) if error.as_service_error().and_then(|error| error.meta().code()) == Some("NoSuchKey") => break,
|
||||
Err(error)
|
||||
if error.raw_response().is_some_and(|response| response.status().as_u16() == 503)
|
||||
&& Instant::now() < admission_deadline =>
|
||||
{
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
Err(error) => return Err(error.into()),
|
||||
Ok(_) => panic!("a cold readiness probe must not create an object"),
|
||||
}
|
||||
}
|
||||
}
|
||||
let body = b"first write while one peer is offline";
|
||||
clients[0]
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("object")
|
||||
.body(Bytes::from_static(body).into())
|
||||
.send()
|
||||
.await?;
|
||||
for client in &clients[..3] {
|
||||
assert_quorum_object_body(client, bucket, "object", body).await?;
|
||||
}
|
||||
// This coordinator has not previously committed an object mutation either.
|
||||
clients[1].delete_object().bucket(bucket).key("object").send().await?;
|
||||
let error = clients[2]
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key("object")
|
||||
.send()
|
||||
.await
|
||||
.expect_err("acknowledged deletion must be visible at another entrypoint");
|
||||
assert_eq!(error.as_service_error().and_then(|error| error.meta().code()), Some("NoSuchKey"));
|
||||
|
||||
cluster.stop_node(2)?;
|
||||
let error = clients[0]
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("below-quorum")
|
||||
.body(Bytes::from_static(body).into())
|
||||
.send()
|
||||
.await
|
||||
.expect_err("removing the cache gate must not weaken storage write quorum");
|
||||
assert_eq!(error.as_service_error().and_then(|error| error.meta().code()), Some("ServiceUnavailable"));
|
||||
cluster.stop();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_degraded_cluster_read_quorum_follows_erasure_layout() -> TestResult {
|
||||
crate::common::init_logging();
|
||||
|
||||
for (node_count, parity) in [(4, 2), (6, 3), (6, 2)] {
|
||||
for (node_count, parity) in [(3, 1), (4, 2), (6, 3), (6, 2), (4, 0)] {
|
||||
let read_quorum = node_count - parity;
|
||||
let write_quorum = read_quorum + usize::from(read_quorum == parity);
|
||||
let mut cluster = RustFSTestClusterEnvironment::new(node_count).await?;
|
||||
cluster.set_env("RUSTFS_STORAGE_CLASS_STANDARD", format!("EC:{parity}"));
|
||||
if parity == 0 {
|
||||
cluster.set_env("RUSTFS_STORAGE_CLASS_RRS", "EC:0");
|
||||
}
|
||||
// Wait for every seed fanout before removing any physical shard.
|
||||
cluster.set_env("RUSTFS_PUT_RENAME_EARLY_ACK_ENABLE", "false");
|
||||
cluster.set_env("RUSTFS_OBS_METRICS_EXPORT_ENABLED", "false");
|
||||
@@ -338,6 +430,277 @@ async fn test_degraded_cluster_read_quorum_follows_erasure_layout() -> TestResul
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cold_start_read_quorum_follows_erasure_layout() -> TestResult {
|
||||
crate::common::init_logging();
|
||||
for (node_count, parity) in [(3, 1), (4, 2), (6, 3), (6, 2), (4, 0)] {
|
||||
let read_quorum = node_count - parity;
|
||||
let write_quorum = read_quorum + usize::from(read_quorum == parity);
|
||||
let mut cluster = RustFSTestClusterEnvironment::new(node_count).await?;
|
||||
cluster.set_env("RUSTFS_STORAGE_CLASS_STANDARD", format!("EC:{parity}"));
|
||||
if parity == 0 {
|
||||
cluster.set_env("RUSTFS_STORAGE_CLASS_RRS", "EC:0");
|
||||
}
|
||||
cluster.set_env("RUSTFS_PUT_RENAME_EARLY_ACK_ENABLE", "false");
|
||||
cluster.set_env("RUSTFS_OBS_METRICS_EXPORT_ENABLED", "false");
|
||||
cluster.set_env("RUST_LOG", "warn");
|
||||
cluster.start().await?;
|
||||
let clients = cluster
|
||||
.create_all_clients()?
|
||||
.into_iter()
|
||||
.map(|client| {
|
||||
Client::from_conf(
|
||||
client
|
||||
.config()
|
||||
.to_builder()
|
||||
.retry_config(aws_sdk_s3::config::retry::RetryConfig::standard().with_max_attempts(1))
|
||||
.build(),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let bucket = format!("cold-read-quorum-{node_count}-{parity}");
|
||||
clients[0].create_bucket().bucket(&bucket).send().await?;
|
||||
let small = b"cold startup follows the stored EC layout".to_vec();
|
||||
let large = vec![73; 256 * 1024];
|
||||
for (key, body) in [
|
||||
("warm-small", &small),
|
||||
("warm-large", &large),
|
||||
("cold-large", &large),
|
||||
("below-quorum", &large),
|
||||
] {
|
||||
clients[0]
|
||||
.put_object()
|
||||
.bucket(&bucket)
|
||||
.key(key)
|
||||
.body(Bytes::copy_from_slice(body).into())
|
||||
.send()
|
||||
.await?;
|
||||
for node in &cluster.nodes {
|
||||
let census =
|
||||
crate::chaos::census_object_version_on_disk(std::path::Path::new(&node.data_dir), &bucket, key, None)?;
|
||||
assert!(census.is_complete(), "seed shard must exist before cold startup: {census:?}");
|
||||
assert_eq!((census.data_blocks, census.parity_blocks), (Some(read_quorum), Some(parity)));
|
||||
}
|
||||
}
|
||||
for node in 0..node_count {
|
||||
cluster.stop_node(node)?;
|
||||
}
|
||||
// Rebuild every process's in-memory state below read quorum, then
|
||||
// cross the EC boundary. A format bootstrap must not admit S3 reads
|
||||
// merely because half the peers are online (e.g. six disks, EC:2).
|
||||
for node in 0..read_quorum - 1 {
|
||||
cluster.start_node_process(node).await?;
|
||||
}
|
||||
let error = clients[0]
|
||||
.get_object()
|
||||
.bucket(&bucket)
|
||||
.key("below-quorum")
|
||||
.send()
|
||||
.await
|
||||
.expect_err("cold startup below the configured EC read quorum must stay unavailable");
|
||||
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(503));
|
||||
cluster.start_node_process(read_quorum - 1).await?;
|
||||
wait_for_quorum_read_admission(&clients[..read_quorum], &bucket).await?;
|
||||
for client in clients.iter().take(read_quorum) {
|
||||
assert_quorum_object_body(client, &bucket, "warm-small", &small).await?;
|
||||
assert_quorum_object_body(client, &bucket, "cold-large", &large).await?;
|
||||
let write = client
|
||||
.put_object()
|
||||
.bucket(&bucket)
|
||||
.key("cold-quorum-write")
|
||||
.body(Bytes::copy_from_slice(&small).into())
|
||||
.send()
|
||||
.await;
|
||||
if read_quorum >= write_quorum {
|
||||
write?;
|
||||
assert_quorum_object_body(client, &bucket, "cold-quorum-write", &small).await?;
|
||||
} else {
|
||||
let error = write.expect_err("cold startup at read quorum must preserve the EC write quorum");
|
||||
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(503));
|
||||
}
|
||||
}
|
||||
for node in read_quorum..node_count {
|
||||
cluster.start_node(node).await?;
|
||||
}
|
||||
for client in &clients {
|
||||
assert_quorum_object_body(client, &bucket, "cold-large", &large).await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_four_by_six_cold_start_at_read_quorum() -> TestResult {
|
||||
crate::common::init_logging();
|
||||
let mut cluster = RustFSTestClusterEnvironment::with_topology(ClusterTopology::single_pool_multidrive(4, 6)).await?;
|
||||
// Explicit URLs are consumed in order. Place one drive from each node in
|
||||
// every set, matching a four-host, six-drive-per-host deployment.
|
||||
let volumes = (0..6)
|
||||
.flat_map(|drive| {
|
||||
cluster
|
||||
.nodes
|
||||
.iter()
|
||||
.map(move |node| format!("http://{}{}", node.address, node.data_dirs[drive]))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
cluster.set_env("RUSTFS_VOLUMES", volumes);
|
||||
cluster.set_env("RUSTFS_ERASURE_SET_DRIVE_COUNT", "4");
|
||||
cluster.set_env("RUSTFS_STORAGE_CLASS_STANDARD", "EC:2");
|
||||
cluster.set_env("RUSTFS_PUT_RENAME_EARLY_ACK_ENABLE", "false");
|
||||
cluster.set_env("RUSTFS_OBS_METRICS_EXPORT_ENABLED", "false");
|
||||
cluster.set_env("RUST_LOG", "error");
|
||||
cluster.start().await?;
|
||||
let clients = cluster
|
||||
.create_all_clients()?
|
||||
.into_iter()
|
||||
.map(|client| {
|
||||
Client::from_conf(
|
||||
client
|
||||
.config()
|
||||
.to_builder()
|
||||
.retry_config(aws_sdk_s3::config::retry::RetryConfig::standard().with_max_attempts(1))
|
||||
.build(),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let bucket = "cold-start-read-quorum";
|
||||
clients[0].create_bucket().bucket(bucket).send().await?;
|
||||
let mut fixtures = Vec::new();
|
||||
let mut covered = [[false; 2]; 6];
|
||||
for index in 0..128 {
|
||||
let key = match index {
|
||||
0 => "warm-small".to_string(),
|
||||
1 => "warm-large".to_string(),
|
||||
_ => format!("cold-{index}"),
|
||||
};
|
||||
let size_class = index % 2;
|
||||
let size = if size_class == 0 { 71 } else { 256 * 1024 };
|
||||
let body = (0..size)
|
||||
.map(|offset| u8::try_from(offset % 251).expect("bounded fixture byte"))
|
||||
.collect::<Vec<_>>();
|
||||
clients[0]
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(&key)
|
||||
.body(Bytes::copy_from_slice(&body).into())
|
||||
.send()
|
||||
.await?;
|
||||
for node in &cluster.nodes {
|
||||
let mut complete_shards = 0;
|
||||
for (drive, path) in node.data_dirs.iter().enumerate() {
|
||||
let census = crate::chaos::census_object_version_on_disk(std::path::Path::new(path), bucket, &key, None)?;
|
||||
if census.has_xl_meta {
|
||||
assert!(census.is_complete(), "seed shard must be complete before shutdown: {census:?}");
|
||||
assert_eq!((census.data_blocks, census.parity_blocks), (Some(2), Some(2)));
|
||||
covered[drive][size_class] = true;
|
||||
complete_shards += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(complete_shards, 1, "each node must own one shard of every seed object");
|
||||
}
|
||||
fixtures.push((key, body));
|
||||
if covered.iter().flatten().all(|covered| *covered) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
covered.iter().flatten().all(|covered| *covered),
|
||||
"fixtures must cover all six sets with inline and large objects"
|
||||
);
|
||||
|
||||
for first in 0..4 {
|
||||
for second in first + 1..4 {
|
||||
for node in 0..4 {
|
||||
cluster.stop_node(node)?;
|
||||
}
|
||||
cluster.start_node_process(first).await?;
|
||||
let error = clients[first]
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key("warm-large")
|
||||
.send()
|
||||
.await
|
||||
.expect_err("one node cannot satisfy read quorum after a cold start");
|
||||
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(503));
|
||||
cluster.start_node_process(second).await?;
|
||||
wait_for_quorum_read_admission(&[clients[first].clone(), clients[second].clone()], bucket).await?;
|
||||
for node in [first, second] {
|
||||
for (key, body) in &fixtures {
|
||||
assert_quorum_object_body(&clients[node], bucket, key, body).await?;
|
||||
}
|
||||
let range = clients[node]
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key("warm-large")
|
||||
.range("bytes=17-1023")
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(range.body.collect().await?.into_bytes().as_ref(), &fixtures[1].1[17..1024]);
|
||||
let listing = clients[node].list_objects_v2().bucket(bucket).send().await?;
|
||||
for (key, _) in &fixtures {
|
||||
assert!(
|
||||
listing.contents().iter().any(|object| object.key() == Some(key.as_str())),
|
||||
"listing omitted {key}"
|
||||
);
|
||||
}
|
||||
let error = clients[node]
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("recovery-write")
|
||||
.body(Bytes::from_static(b"after recovery").into())
|
||||
.send()
|
||||
.await
|
||||
.expect_err("two nodes must not authorize a write");
|
||||
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(503));
|
||||
}
|
||||
let error = clients[first]
|
||||
.delete_object()
|
||||
.bucket(bucket)
|
||||
.key("warm-large")
|
||||
.send()
|
||||
.await
|
||||
.expect_err("two nodes must not authorize a delete");
|
||||
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(503));
|
||||
assert_quorum_object_body(&clients[first], bucket, "warm-large", &fixtures[1].1).await?;
|
||||
|
||||
let remaining = (0..4).filter(|node| *node != first && *node != second).collect::<Vec<_>>();
|
||||
cluster.start_node(remaining[0]).await?;
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(90);
|
||||
loop {
|
||||
match clients[first]
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("recovery-write")
|
||||
.body(Bytes::from_static(b"after recovery").into())
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(_) => break,
|
||||
Err(error)
|
||||
if error.raw_response().is_some_and(|response| response.status().as_u16() == 503)
|
||||
&& tokio::time::Instant::now() < deadline =>
|
||||
{
|
||||
tokio::time::sleep(Duration::from_millis(200)).await
|
||||
}
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
}
|
||||
for node in [first, second, remaining[0]] {
|
||||
assert_quorum_object_body(&clients[node], bucket, "recovery-write", b"after recovery").await?;
|
||||
}
|
||||
cluster.start_node(remaining[1]).await?;
|
||||
for client in &clients {
|
||||
for (key, body) in &fixtures {
|
||||
assert_quorum_object_body(client, bucket, key, body).await?;
|
||||
}
|
||||
assert_quorum_object_body(client, bucket, "recovery-write", b"after recovery").await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn put_object(client: Client, payload: Vec<u8>, writer_id: usize) -> Result<(), String> {
|
||||
client
|
||||
.put_object()
|
||||
|
||||
@@ -851,13 +851,20 @@ mod integration_tests {
|
||||
.delete(
|
||||
aws_sdk_s3::types::Delete::builder()
|
||||
.set_objects(Some(objects))
|
||||
.quiet(true)
|
||||
// Quiet mode hides successful entries, so the batch must be
|
||||
// verbose for the assertion on the deleted list below.
|
||||
.quiet(false)
|
||||
.build()
|
||||
.unwrap(),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
assert!(
|
||||
delete_result.errors().is_empty(),
|
||||
"batch delete must not report errors: {:?}",
|
||||
delete_result.errors()
|
||||
);
|
||||
assert_eq!(delete_result.deleted().len(), 2);
|
||||
|
||||
// Now should be able to upload again (quota freed up)
|
||||
|
||||
@@ -70,7 +70,7 @@ async fn test_conditional_put_okay() -> TestResult {
|
||||
let test_key = "conditional-put-ok";
|
||||
let initial_data = generate_test_data(1024); // 1KB test data
|
||||
let matching_data = generate_test_data(2048); // 2KB updated data
|
||||
let non_matching_data = generate_test_data(3072); // 3KB updated data
|
||||
let created_data = generate_test_data(3072); // 3KB new object
|
||||
|
||||
// Upload initial object and get its ETag
|
||||
let initial_etag = upload_object_with_metadata(&client, TEST_BUCKET, test_key, &initial_data).await?;
|
||||
@@ -86,17 +86,17 @@ async fn test_conditional_put_okay() -> TestResult {
|
||||
.await?;
|
||||
assert_eq!(object_body(&client, test_key).await?.as_ref(), matching_data);
|
||||
|
||||
// Test 2: PUT with non-matching If-None-Match condition (should succeed)
|
||||
let fake_etag = "\"fake-etag-12345\"";
|
||||
// Test 2: PUT with If-None-Match: * on a missing key (should succeed)
|
||||
let created_key = "conditional-put-ok-created";
|
||||
client
|
||||
.put_object()
|
||||
.bucket(TEST_BUCKET)
|
||||
.key(test_key)
|
||||
.body(Bytes::from(non_matching_data.clone()).into())
|
||||
.if_none_match(fake_etag)
|
||||
.key(created_key)
|
||||
.body(Bytes::from(created_data.clone()).into())
|
||||
.if_none_match("*")
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(object_body(&client, test_key).await?.as_ref(), non_matching_data);
|
||||
assert_eq!(object_body(&client, created_key).await?.as_ref(), created_data);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -130,17 +130,30 @@ async fn test_conditional_put_failed() -> TestResult {
|
||||
assert_s3_error_code(response1, "PreconditionFailed");
|
||||
assert_eq!(object_body(&client, test_key).await?.as_ref(), initial_data);
|
||||
|
||||
// Test 2: PUT with matching If-None-Match condition (should fail with 412)
|
||||
let response2 = client
|
||||
.put_object()
|
||||
// Test 2: writes accept If-None-Match only as `*`; RustFS rejects an ETag
|
||||
// value with 501 NotImplemented whether or not it matches.
|
||||
for if_none_match in [initial_etag.as_str(), "\"fake-etag-12345\""] {
|
||||
let response = client
|
||||
.put_object()
|
||||
.bucket(TEST_BUCKET)
|
||||
.key(test_key)
|
||||
.body(Bytes::from(updated_data.clone()).into())
|
||||
.if_none_match(if_none_match)
|
||||
.send()
|
||||
.await;
|
||||
assert_s3_error_code(response, "NotImplemented");
|
||||
}
|
||||
|
||||
let response = client
|
||||
.copy_object()
|
||||
.bucket(TEST_BUCKET)
|
||||
.key(test_key)
|
||||
.body(Bytes::from(updated_data.clone()).into())
|
||||
.if_none_match(&initial_etag)
|
||||
.copy_source(format!("{TEST_BUCKET}/{test_key}"))
|
||||
.metadata_directive(aws_sdk_s3::types::MetadataDirective::Replace)
|
||||
.if_none_match("\"fake-etag-12345\"")
|
||||
.send()
|
||||
.await;
|
||||
|
||||
assert_s3_error_code(response2, "PreconditionFailed");
|
||||
assert_s3_error_code(response, "NotImplemented");
|
||||
assert_eq!(object_body(&client, test_key).await?.as_ref(), initial_data);
|
||||
|
||||
Ok(())
|
||||
@@ -252,7 +265,7 @@ async fn test_conditional_multi_part_upload() -> TestResult {
|
||||
|
||||
assert_s3_error_code(complete_response, "PreconditionFailed");
|
||||
|
||||
// Test 2: Multipart upload with matching If-None-Match, should fail
|
||||
// Test 2: Multipart upload with an unsupported ETag If-None-Match
|
||||
let complete_response = client
|
||||
.complete_multipart_upload()
|
||||
.bucket(TEST_BUCKET)
|
||||
@@ -263,7 +276,7 @@ async fn test_conditional_multi_part_upload() -> TestResult {
|
||||
.send()
|
||||
.await;
|
||||
|
||||
assert_s3_error_code(complete_response, "PreconditionFailed");
|
||||
assert_s3_error_code(complete_response, "NotImplemented");
|
||||
|
||||
// Test 3: Multipart upload with unmatching If-Match, should fail
|
||||
let complete_response = client
|
||||
|
||||
@@ -113,6 +113,13 @@ impl NodeService for MinimalLockNodeService {
|
||||
Err(Status::unimplemented("MinimalLockNodeService only supports lock RPCs"))
|
||||
}
|
||||
|
||||
async fn acquire_quota_mutation_fence(
|
||||
&self,
|
||||
_request: Request<SnapshotLeaseRequest>,
|
||||
) -> Result<Response<SnapshotLeaseResponse>, Status> {
|
||||
Err(Status::unimplemented("MinimalLockNodeService only supports lock RPCs"))
|
||||
}
|
||||
|
||||
async fn renew_snapshot_lease(
|
||||
&self,
|
||||
_request: Request<SnapshotLeaseRenewRequest>,
|
||||
@@ -127,6 +134,13 @@ impl NodeService for MinimalLockNodeService {
|
||||
Err(Status::unimplemented("MinimalLockNodeService only supports lock RPCs"))
|
||||
}
|
||||
|
||||
async fn release_quota_mutation_fence(
|
||||
&self,
|
||||
_request: Request<SnapshotLeaseReleaseRequest>,
|
||||
) -> Result<Response<SnapshotLeaseMutationResponse>, Status> {
|
||||
Err(Status::unimplemented("MinimalLockNodeService only supports lock RPCs"))
|
||||
}
|
||||
|
||||
async fn acquire_scanner_publication_lease(
|
||||
&self,
|
||||
_request: Request<ScannerPublicationLeaseRequest>,
|
||||
@@ -465,6 +479,13 @@ impl NodeService for MinimalLockNodeService {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn recover_delete_transaction(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::RecoverDeleteTransactionRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::RecoverDeleteTransactionResponse>, Status> {
|
||||
Err(Status::unimplemented("delete recovery is not supported by the lock fixture"))
|
||||
}
|
||||
|
||||
async fn settle_part_transaction(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::SettlePartTransactionRequest>,
|
||||
@@ -569,6 +590,13 @@ impl NodeService for MinimalLockNodeService {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn rename_data_with_quota_fence(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::RenameDataRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::RenameDataResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn make_volumes(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::MakeVolumesRequest>,
|
||||
@@ -667,6 +695,13 @@ impl NodeService for MinimalLockNodeService {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn guarded_delete_versions(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::GuardedDeleteVersionsRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::DeleteVersionsResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn delete_versions(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::DeleteVersionsRequest>,
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
|
||||
use crate::common::{
|
||||
AdminTransport, RustFSTestEnvironment, admin_add_canned_policy_via, admin_attach_user_policy_via, admin_create_user,
|
||||
awscurl_post_sts_form_urlencoded, init_logging, local_http_client, replication_fast_env, rustfs_binary_path, signed_request,
|
||||
signed_request_with_client, signed_request_with_session_token,
|
||||
awscurl_post_sts_form_urlencoded, init_logging, local_http_client, parse_assume_role_credentials, replication_fast_env,
|
||||
rustfs_binary_path, signed_request, signed_request_with_client, signed_request_with_session_token,
|
||||
};
|
||||
use crate::fake_s3_target::{
|
||||
FAKE_ACCESS_KEY, FAKE_SECRET_KEY, FakeS3Target, FaultAction as FakeTargetFault, Operation as FakeTargetOperation,
|
||||
@@ -387,21 +387,6 @@ pub(crate) struct ReplicationResetStatusTarget {
|
||||
pub(crate) object: String,
|
||||
}
|
||||
|
||||
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 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))
|
||||
}
|
||||
|
||||
pub(crate) struct ReplicationTargetOptions<'a> {
|
||||
pub(crate) endpoint: &'a str,
|
||||
pub(crate) access_key: &'a str,
|
||||
@@ -3945,7 +3930,15 @@ async fn test_bucket_replication_version_purge_of_non_inline_object_releases_sou
|
||||
(sse_key, sse_put.version_id().ok_or("SSE PUT omitted version ID")?.to_string()),
|
||||
(large_key, large_put.version_id().ok_or("large PUT omitted version ID")?.to_string()),
|
||||
];
|
||||
assert_replication_converged(&source_client, &source_bucket, &target_client, &target_bucket).await?;
|
||||
// Managed-SSE listings may omit ETags. Check the replicated version's body
|
||||
// before deleting it rather than comparing keyless listing fingerprints.
|
||||
for (key, version_id) in &purged {
|
||||
let source_body = get_version_body(&source_client, &source_bucket, key, version_id).await?;
|
||||
let expected_sha256: [u8; 32] = Sha256::digest(&source_body).into();
|
||||
wait_for_replicated_sha256(&target_client, &target_bucket, key, expected_sha256).await?;
|
||||
let target_body = get_version_body(&target_client, &target_bucket, key, version_id).await?;
|
||||
assert_eq!(target_body, source_body, "replicated version body differs for {key}/{version_id}");
|
||||
}
|
||||
|
||||
for (key, version_id) in &purged {
|
||||
source_client
|
||||
@@ -3956,10 +3949,6 @@ async fn test_bucket_replication_version_purge_of_non_inline_object_releases_sou
|
||||
.send()
|
||||
.await?;
|
||||
}
|
||||
assert_replication_converged(&source_client, &source_bucket, &target_client, &target_bucket).await?;
|
||||
let target_state = list_replication_state(&target_client, &target_bucket).await?;
|
||||
assert!(target_state.is_empty(), "target retained an explicitly purged version: {target_state:?}");
|
||||
|
||||
// The purge state is applied on the source asynchronously after the target
|
||||
// acknowledges the delete; only then does the retained version go away and
|
||||
// the bucket become deletable. A listing that is empty while DeleteBucket
|
||||
@@ -3984,6 +3973,9 @@ async fn test_bucket_replication_version_purge_of_non_inline_object_releases_sou
|
||||
}
|
||||
}
|
||||
|
||||
let target_state = list_replication_state(&target_client, &target_bucket).await?;
|
||||
assert!(target_state.is_empty(), "target retained an explicitly purged version: {target_state:?}");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -45,9 +45,10 @@ use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
use aws_sdk_s3::primitives::{ByteStream, DateTime};
|
||||
use aws_sdk_s3::types::{
|
||||
Checksum, ChecksumAlgorithm, CompletedMultipartUpload, CompletedPart, ObjectAttributes, ObjectLockLegalHold,
|
||||
ObjectLockLegalHoldStatus, ObjectLockMode, ObjectLockRetention, ObjectLockRetentionMode, Tag, Tagging,
|
||||
ObjectLockLegalHoldStatus, ObjectLockMode, ObjectLockRetention, ObjectLockRetentionMode, ServerSideEncryption, Tag, Tagging,
|
||||
};
|
||||
use bytes::Bytes;
|
||||
use md5::{Digest as _, Md5};
|
||||
use std::error::Error;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tokio::time::{Duration, sleep, timeout};
|
||||
@@ -73,14 +74,20 @@ enum TargetMode {
|
||||
/// land, and every version-addressed mutation must resolve the replica
|
||||
/// through the target-version ledger.
|
||||
MintOwnVersionIds,
|
||||
/// AWS S3 and other generic S3 services: ignores the replication
|
||||
/// source-ETag headers and reports the MD5 of the plaintext it received.
|
||||
/// Every single-part replica ETag is therefore checked against the source
|
||||
/// ETag for real (the other modes echo the source ETag back).
|
||||
ComputesOwnEtags,
|
||||
}
|
||||
|
||||
impl TargetMode {
|
||||
const ALL: [TargetMode; 4] = [
|
||||
const ALL: [TargetMode; 5] = [
|
||||
TargetMode::Baseline,
|
||||
TargetMode::RejectAwsChunked,
|
||||
TargetMode::RequireChecksumWithObjectLock,
|
||||
TargetMode::MintOwnVersionIds,
|
||||
TargetMode::ComputesOwnEtags,
|
||||
];
|
||||
|
||||
fn apply(self, target: &FakeS3Target) {
|
||||
@@ -92,6 +99,7 @@ impl TargetMode {
|
||||
target.assign_own_version_ids(true);
|
||||
target.reject_unknown_version_deletes(true);
|
||||
}
|
||||
TargetMode::ComputesOwnEtags => target.ignore_source_etags(true),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +109,7 @@ impl TargetMode {
|
||||
TargetMode::RejectAwsChunked => "reject-aws-chunked",
|
||||
TargetMode::RequireChecksumWithObjectLock => "require-checksum-object-lock",
|
||||
TargetMode::MintOwnVersionIds => "mint-own-version-ids",
|
||||
TargetMode::ComputesOwnEtags => "computes-own-etags",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -126,10 +135,14 @@ enum ObjectShape {
|
||||
/// Single-part object uploaded with `x-amz-checksum-sha256`; the replica
|
||||
/// must carry the same header (rustfs/backlog#2340).
|
||||
Checksummed,
|
||||
/// Single-part SSE-S3 object. Its ETag is a keyed fingerprint, not the
|
||||
/// plaintext MD5 (docs/operations/encrypted-etags.md), while the replica
|
||||
/// travels as plaintext under `x-amz-server-side-encryption: AES256`.
|
||||
SseS3,
|
||||
}
|
||||
|
||||
impl ObjectShape {
|
||||
const ALL: [ObjectShape; 8] = [
|
||||
const ALL: [ObjectShape; 9] = [
|
||||
ObjectShape::Empty,
|
||||
ObjectShape::Plain,
|
||||
ObjectShape::Retention,
|
||||
@@ -138,6 +151,7 @@ impl ObjectShape {
|
||||
ObjectShape::LockedMultipart,
|
||||
ObjectShape::OdmPreservedMd5Multipart,
|
||||
ObjectShape::Checksummed,
|
||||
ObjectShape::SseS3,
|
||||
];
|
||||
|
||||
fn key(self) -> &'static str {
|
||||
@@ -150,6 +164,7 @@ impl ObjectShape {
|
||||
ObjectShape::LockedMultipart => "matrix/locked-multipart.bin",
|
||||
ObjectShape::OdmPreservedMd5Multipart => "matrix/odm-preserved-md5.bin",
|
||||
ObjectShape::Checksummed => "matrix/checksummed.bin",
|
||||
ObjectShape::SseS3 => "matrix/sse-s3.bin",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,6 +248,24 @@ impl ObjectShape {
|
||||
.await?;
|
||||
Ok(body)
|
||||
}
|
||||
ObjectShape::SseS3 => {
|
||||
let body = payload(56 * 1024, 0x77);
|
||||
let put = client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from(body.clone()))
|
||||
.server_side_encryption(ServerSideEncryption::Aes256)
|
||||
.send()
|
||||
.await?;
|
||||
// The cell only proves something while the source ETag is
|
||||
// not the content MD5 a generic target reports back.
|
||||
let plaintext_md5 = hex_simd::encode_to_string(Md5::digest(&body), hex_simd::AsciiCase::Lower);
|
||||
if put.e_tag().map(|etag| etag.trim_matches('"')) == Some(plaintext_md5.as_str()) {
|
||||
return Err("SSE-S3 source ETag is the plaintext MD5; the cell no longer exercises encrypted ETags".into());
|
||||
}
|
||||
Ok(body)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -270,14 +303,40 @@ fn expectation(mode: TargetMode, shape: ObjectShape) -> Expectation {
|
||||
/// - the first PUT lands but its response is lost, so the object is FAILED
|
||||
/// and the scanner heal pass re-drives it;
|
||||
/// - an existing-object resync re-drives a COMPLETED object unconditionally.
|
||||
///
|
||||
/// A permanent delete of the healed version then has to remove exactly the
|
||||
/// replica the heal pass located.
|
||||
#[tokio::test]
|
||||
async fn matrix_mint_own_version_ids_redrive_does_not_duplicate() -> TestResult {
|
||||
run_redrive_does_not_duplicate(RedriveObjects::Plaintext).await
|
||||
}
|
||||
|
||||
/// The same re-drives for SSE-S3 objects against a target that also computes
|
||||
/// its own ETags (AWS S3). The source ETag is a keyed fingerprint
|
||||
/// (docs/operations/encrypted-etags.md) while the replica reports the MD5 of
|
||||
/// the plaintext it received, so no exact-ETag lookup can ever find the
|
||||
/// replica: the lost-response heal, the resync and the purge must locate it
|
||||
/// without comparing ETags.
|
||||
#[tokio::test]
|
||||
async fn matrix_mint_own_version_ids_encrypted_redrive_does_not_duplicate() -> TestResult {
|
||||
run_redrive_does_not_duplicate(RedriveObjects::SseS3OnEtagComputingTarget).await
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum RedriveObjects {
|
||||
Plaintext,
|
||||
SseS3OnEtagComputingTarget,
|
||||
}
|
||||
|
||||
async fn run_redrive_does_not_duplicate(objects: RedriveObjects) -> TestResult {
|
||||
init_logging();
|
||||
let encrypted = objects == RedriveObjects::SseS3OnEtagComputingTarget;
|
||||
|
||||
let target = FakeS3Target::start().await?;
|
||||
let target_bucket = "matrix-mint-own-redrive-dst".to_string();
|
||||
target.create_bucket_with_object_lock(target_bucket.clone());
|
||||
target.assign_own_version_ids(true);
|
||||
target.ignore_source_etags(encrypted);
|
||||
|
||||
let mut env_vars = replication_fast_env();
|
||||
env_vars.extend_from_slice(LOOPBACK_REPLICATION_TARGET_ENV);
|
||||
@@ -291,6 +350,7 @@ async fn matrix_mint_own_version_ids_redrive_does_not_duplicate() -> TestResult
|
||||
]);
|
||||
let env = OdmTestEnv::start_with(OdmEnvOptions {
|
||||
env: env_vars,
|
||||
local_kms: encrypted,
|
||||
..OdmEnvOptions::default()
|
||||
})
|
||||
.await?;
|
||||
@@ -319,45 +379,63 @@ async fn matrix_mint_own_version_ids_redrive_does_not_duplicate() -> TestResult
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
put_bucket_replication(source_env, source_bucket, &target_arn).await?;
|
||||
put_bucket_replication_with_delete_statuses(source_env, source_bucket, &target_arn, "Enabled", Some("Enabled")).await?;
|
||||
|
||||
let put = |key: &'static str, fill: u8, len: usize| {
|
||||
let request = source_client
|
||||
.put_object()
|
||||
.bucket(source_bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from(payload(len, fill)));
|
||||
let request = if encrypted {
|
||||
request.server_side_encryption(ServerSideEncryption::Aes256)
|
||||
} else {
|
||||
request
|
||||
};
|
||||
async move { request.send().await }
|
||||
};
|
||||
|
||||
// Teach the worker the target's identity contract with one ordinary
|
||||
// write, exactly as production learns it (the PUT response carries the
|
||||
// minted id).
|
||||
let probe_key = "redrive/identity-probe.bin";
|
||||
source_client
|
||||
.put_object()
|
||||
.bucket(source_bucket)
|
||||
.key(probe_key)
|
||||
.body(ByteStream::from(payload(4 * 1024, 0x01)))
|
||||
.send()
|
||||
.await?;
|
||||
let probe = put(probe_key, 0x01, 4 * 1024).await?;
|
||||
assert_eq!(
|
||||
wait_for_terminal_replication_status(&source_client, source_bucket, probe_key).await?,
|
||||
"COMPLETED"
|
||||
);
|
||||
if encrypted {
|
||||
// The variant only proves something while the replica's ETag differs
|
||||
// from the source's encrypted-ETag fingerprint.
|
||||
let replica = single_target_version(&target, &target_bucket, probe_key)?;
|
||||
let replica_etag = fake_source_client(&target)
|
||||
.head_object()
|
||||
.bucket(&target_bucket)
|
||||
.key(probe_key)
|
||||
.version_id(&replica)
|
||||
.send()
|
||||
.await?
|
||||
.e_tag()
|
||||
.map(str::to_string);
|
||||
if replica_etag.is_none() || replica_etag.as_deref() == probe.e_tag() {
|
||||
return Err(format!(
|
||||
"the replica ETag {replica_etag:?} must differ from the source fingerprint {:?}",
|
||||
probe.e_tag()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
|
||||
// Shape 1: the PUT is stored, its response never arrives, heal re-drives.
|
||||
let heal_key = "redrive/heal.bin";
|
||||
target.inject_for_key(FakeTargetOperation::PutObject, heal_key, FakeTargetFault::DisconnectAfterResponse, 1);
|
||||
source_client
|
||||
.put_object()
|
||||
.bucket(source_bucket)
|
||||
.key(heal_key)
|
||||
.body(ByteStream::from(payload(8 * 1024, 0x02)))
|
||||
.send()
|
||||
.await?;
|
||||
let healed = put(heal_key, 0x02, 8 * 1024).await?;
|
||||
let healed_version = healed.version_id().ok_or("source PUT returned no version id")?.to_string();
|
||||
wait_for_replication_status_and_single_version(&source_client, source_bucket, &target, &target_bucket, heal_key).await?;
|
||||
|
||||
// Shape 2: an existing-object resync re-drives a COMPLETED object.
|
||||
let resync_key = "redrive/resync.bin";
|
||||
source_client
|
||||
.put_object()
|
||||
.bucket(source_bucket)
|
||||
.key(resync_key)
|
||||
.body(ByteStream::from(payload(8 * 1024, 0x03)))
|
||||
.send()
|
||||
.await?;
|
||||
put(resync_key, 0x03, 8 * 1024).await?;
|
||||
assert_eq!(
|
||||
wait_for_terminal_replication_status(&source_client, source_bucket, resync_key).await?,
|
||||
"COMPLETED"
|
||||
@@ -388,6 +466,20 @@ async fn matrix_mint_own_version_ids_redrive_does_not_duplicate() -> TestResult
|
||||
);
|
||||
}
|
||||
|
||||
// The heal pass recorded the replica it located; a permanent delete of
|
||||
// that version must remove exactly it.
|
||||
source_client
|
||||
.delete_object()
|
||||
.bucket(source_bucket)
|
||||
.key(heal_key)
|
||||
.version_id(&healed_version)
|
||||
.send()
|
||||
.await?;
|
||||
wait_until("permanent delete of the healed replica", || async {
|
||||
Ok(target.stored_versions(&target_bucket, heal_key).is_empty())
|
||||
})
|
||||
.await?;
|
||||
|
||||
target.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
@@ -871,6 +963,11 @@ async fn matrix_mint_own_version_ids_target() -> TestResult {
|
||||
run_row(TargetMode::MintOwnVersionIds).await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn matrix_computes_own_etags_target() -> TestResult {
|
||||
run_row(TargetMode::ComputesOwnEtags).await
|
||||
}
|
||||
|
||||
/// Every known-red entry must name a real cell and an issue, and the lookup
|
||||
/// must round-trip, so a stale or mistyped entry cannot silently pin nothing.
|
||||
#[test]
|
||||
@@ -907,6 +1004,8 @@ async fn run_row(mode: TargetMode) -> TestResult {
|
||||
env_vars.extend_from_slice(&[("NO_PROXY", "127.0.0.1,localhost"), ("HTTP_PROXY", ""), ("HTTPS_PROXY", "")]);
|
||||
let env = OdmTestEnv::start_with(OdmEnvOptions {
|
||||
env: env_vars,
|
||||
// The SseS3 shape needs a KMS for SSE-S3.
|
||||
local_kms: true,
|
||||
..OdmEnvOptions::default()
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -740,7 +740,7 @@ async fn test_bucket_tags_opa_contract() -> TestResult {
|
||||
.version_id(version)
|
||||
.send()
|
||||
.await?;
|
||||
opa.expect_bucket_tags("s3:GetObject", source, Some("finance")).await?;
|
||||
opa.expect_bucket_tags("s3:GetObjectVersion", source, Some("finance")).await?;
|
||||
client.list_objects_v2().bucket(source).send().await?;
|
||||
opa.expect_bucket_tags("s3:ListBucket", source, Some("finance")).await?;
|
||||
client.list_object_versions().bucket(source).send().await?;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,6 +31,7 @@ workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
diagnostic-read-version-caller = ["rustfs-io-metrics/diagnostic-read-version-caller"]
|
||||
gcs = ["dep:google-cloud-storage", "dep:google-cloud-auth"]
|
||||
# Compiles the controlled list-objects namespace-journal chaos injector into a
|
||||
# production binary (it is always available to tests). Off by default so the
|
||||
@@ -183,6 +184,7 @@ sha2 = { workspace = true }
|
||||
hex-simd = { workspace = true }
|
||||
tempfile.workspace = true
|
||||
hyper = { workspace = true, features = ["http2", "http1", "server"] }
|
||||
h2 = { workspace = true }
|
||||
hyper-util = { workspace = true, features = ["tokio", "server-auto", "server-graceful", "tracing"] }
|
||||
hyper-rustls = { workspace = true, default-features = false, features = ["native-tokio", "http1", "tls12", "logging", "http2", "aws-lc-rs"] }
|
||||
hostname.workspace = true
|
||||
@@ -200,8 +202,8 @@ md-5.workspace = true
|
||||
memmap2 = { workspace = true }
|
||||
libc.workspace = true
|
||||
# "process" adds getrlimit for the io_uring fd-cache RLIMIT_NOFILE gate
|
||||
# (backlog#1178); "fs" comes from the workspace default.
|
||||
rustix = { workspace = true, features = ["process", "fs"] }
|
||||
# (backlog#1178); "thread" provides cleanup affinity; "fs" is a workspace default.
|
||||
rustix = { workspace = true, features = ["process", "fs", "thread"] }
|
||||
rustfs-madmin.workspace = true
|
||||
reqwest = { workspace = true }
|
||||
aws-sdk-s3 = { workspace = true, default-features = false, features = ["sigv4a", "default-https-client", "rt-tokio"] }
|
||||
@@ -248,6 +250,7 @@ windows-sys = { workspace = true, features = ["Win32_System_Ioctl"] }
|
||||
|
||||
[dev-dependencies]
|
||||
aws-smithy-async.workspace = true
|
||||
rustfs-s3-ops.workspace = true
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "test-util", "fs"] }
|
||||
criterion = { workspace = true, features = ["html_reports"] }
|
||||
temp-env = { workspace = true, features = ["async_closure"] }
|
||||
|
||||
@@ -14,6 +14,15 @@
|
||||
|
||||
//! Explicit ECStore public facades for outer crate compatibility boundaries.
|
||||
|
||||
#[cfg(feature = "diagnostic-read-version-caller")]
|
||||
pub mod diagnostic {
|
||||
pub use crate::cluster::rpc::runtime_sources::{
|
||||
current_read_version_entry_source, observe_read_version_context_boundary, with_read_version_entry,
|
||||
with_read_version_spawn_context,
|
||||
};
|
||||
pub use rustfs_io_metrics::internode_metrics::{ReadVersionContextBoundary, ReadVersionEntrySource};
|
||||
}
|
||||
|
||||
pub mod admin {
|
||||
pub use crate::diagnostics::admin_server_info::{get_local_server_property, get_server_info};
|
||||
}
|
||||
@@ -199,10 +208,10 @@ pub mod bucket {
|
||||
get_object_lock_config, get_object_lock_config_state, get_on_demand_migration_config,
|
||||
get_on_demand_migration_config_in, get_public_access_block_config, get_quota_config, get_replication_config,
|
||||
get_request_payment_config, get_sse_config, get_tagging_config, get_versioning_config, get_website_config,
|
||||
init_bucket_metadata_sys, list_bucket_targets, reload_bucket_metadata, remove_bucket_metadata, set_bucket_metadata,
|
||||
update, update_bucket_targets_under_transaction_lock, update_config_with, update_if_incarnation,
|
||||
update_if_incarnation_at, update_quota_if_incarnation, update_quota_if_incarnation_at, update_under_transaction_lock,
|
||||
update_under_transaction_lock_at,
|
||||
init_bucket_metadata_sys, list_bucket_targets, reload_bucket_metadata, remove_bucket_metadata,
|
||||
remove_bucket_metadata_if_absent, set_bucket_metadata, update, update_bucket_targets_under_transaction_lock,
|
||||
update_config_with, update_if_incarnation, update_if_incarnation_at, update_quota_if_incarnation,
|
||||
update_quota_if_incarnation_at, update_under_transaction_lock, update_under_transaction_lock_at,
|
||||
};
|
||||
#[cfg(feature = "test-util")]
|
||||
pub use crate::bucket::metadata_sys::{ConfigWriteLockProbe, test_support};
|
||||
@@ -210,7 +219,8 @@ pub mod bucket {
|
||||
|
||||
pub mod migration {
|
||||
pub use crate::bucket::migration::{
|
||||
LegacyBlobDecryptFn, migration_startup_error, try_migrate_bucket_metadata, try_migrate_iam_config,
|
||||
LegacyBlobDecryptFn, migration_startup_error, try_migrate_bucket_metadata, try_migrate_bucket_metadata_for_buckets,
|
||||
try_migrate_iam_config,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -459,12 +469,16 @@ pub mod disk {
|
||||
pub use crate::disk::os::{LocalPublicationPause, LocalPublicationStage};
|
||||
pub use crate::disk::{
|
||||
BATCH_READ_VERSION_MAX_ITEMS, BUCKET_META_PREFIX, BatchReadVersionItem, BatchReadVersionReq, BatchReadVersionResp,
|
||||
CheckPartsResp, ConditionalFileUpdate, DeleteOptions, Disk, DiskAPI, DiskInfo, DiskInfoOptions, DiskLocation, DiskOption,
|
||||
DiskStore, FileInfoVersions, FileReader, FileWriter, HEALING_MARKER_PATH, LegacyFileStream, LegacyFileWrite,
|
||||
CheckPartsResp, ConditionalFileUpdate, CurrentDeleteGeneration, CurrentDeleteTransition,
|
||||
DELETE_RECOVERY_PROTOCOL_VERSION, DeleteOptions, DeleteRecoveryAction, DeleteRecoveryCursor, DeleteRecoveryDigest,
|
||||
DeleteRecoveryInspection, DeleteRecoveryPage, DeleteRecoveryRpcOperation, DeleteRecoveryRpcReply, DeleteRecoveryState,
|
||||
Disk, DiskAPI, DiskInfo, DiskInfoOptions, DiskLocation, DiskOption, DiskStore, FileInfoVersions, FileReader, FileWriter,
|
||||
GuardedDeleteVersionsPayload, GuardedFileInfoVersions, HEALING_MARKER_PATH, LegacyFileStream, LegacyFileWrite,
|
||||
LegacyMetadataWrite, LegacyPartMutation, LegacyPartOperation, LegacyPathDelete, LegacyPathDeleteOperation,
|
||||
LegacyRenameData, LegacyRenameFile, NsScannerOpenRequest, OldCurrentSize, PartTransactionAction, RUSTFS_META_BUCKET,
|
||||
ReadMultipleReq, ReadMultipleResp, ReadOptions, RenameDataResp, STORAGE_FORMAT_FILE, SnapshotLeaseToken,
|
||||
UpdateMetadataOpts, VolumeInfo, WalkDirOptions, new_disk, validate_batch_read_version_item_count,
|
||||
LegacyRenameData, LegacyRenameFile, NsScannerOpenRequest, OldCurrentSize, PartTransactionAction,
|
||||
QUOTA_MUTATION_FENCE_METADATA_SUFFIX, RUSTFS_META_BUCKET, ReadMultipleReq, ReadMultipleResp, ReadOptions, RenameDataResp,
|
||||
STORAGE_FORMAT_FILE, SnapshotLeaseToken, UpdateMetadataOpts, VolumeInfo, WalkDirOptions, is_quota_mutation_fence_path,
|
||||
new_disk, validate_batch_read_version_item_count,
|
||||
};
|
||||
pub use bytes::Bytes;
|
||||
pub use endpoint::Endpoint;
|
||||
@@ -533,17 +547,19 @@ pub mod metrics {
|
||||
}
|
||||
|
||||
pub mod notification {
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
pub use crate::services::notification_sys::rotate_cross_pool_fence_fleet_proof_for_test;
|
||||
pub use crate::services::notification_sys::{
|
||||
ClusterTierDailyStats, CrossPoolFenceFleetProofToken, IlmRecoveryExportFleetProofToken,
|
||||
LegacyTransitionStateReconcileFleetProofToken, NotificationPeerErr, NotificationSys, ScannerPublicationLeaseGrant,
|
||||
acquire_cross_pool_fence_fleet_proof, acquire_ilm_recovery_export_fleet_proof,
|
||||
acquire_legacy_transition_state_reconcile_fleet_proof, cross_pool_fence_fleet_proof_matches, get_global_notification_sys,
|
||||
ilm_recovery_export_fleet_proof_matches, ilm_recovery_export_local_process_epoch,
|
||||
ilm_recovery_export_member_epochs_sha256, ilm_recovery_export_topology_generation,
|
||||
legacy_transition_state_reconcile_fleet_proof_matches, new_global_notification_sys,
|
||||
scanner_peer_transport_error_message_is_retryable, start_remote_version_state_fleet_probe,
|
||||
acquire_legacy_transition_state_reconcile_fleet_proof, configured_fleet_topology_fingerprint,
|
||||
cross_pool_fence_fleet_proof_matches, get_global_notification_sys, ilm_recovery_export_fleet_proof_matches,
|
||||
ilm_recovery_export_local_process_epoch, ilm_recovery_export_member_epochs_sha256,
|
||||
ilm_recovery_export_topology_generation, legacy_transition_state_reconcile_fleet_proof_matches,
|
||||
new_global_notification_sys, scanner_peer_transport_error_message_is_retryable, start_remote_version_state_fleet_probe,
|
||||
};
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
pub use crate::services::notification_sys::{
|
||||
rotate_cross_pool_fence_fleet_proof_for_test, without_cross_pool_fence_fleet_proof_for_test,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -557,13 +573,14 @@ pub mod integrity {
|
||||
pub mod object {
|
||||
pub use crate::object_api::{
|
||||
BLOCK_SIZE_V2, ERASURE_ALGORITHM, EncryptionResolutionError, EncryptionResolutionErrorKind, GetObjectBodyCacheHook,
|
||||
GetObjectBodyCacheHookLookup, GetObjectBodySource, GetObjectReader, NamespaceLockFence, ObjectEncryptionResolver,
|
||||
ObjectInfo, ObjectLockConfigSnapshot, ObjectMutationHook, ObjectOptions, PutObjReader, QuotaAdmission,
|
||||
RangedDecompressReader, ReadEncryptionMaterial, ReadEncryptionMode, ReadEncryptionRequest,
|
||||
GetObjectBodyCacheHookLookup, GetObjectBodySource, GetObjectReadCondition, GetObjectReadMetadata, GetObjectReader,
|
||||
NamespaceLockFence, ObjectEncryptionResolver, ObjectInfo, ObjectLockConfigSnapshot, ObjectMutationHook, ObjectOptions,
|
||||
PutObjReader, QuotaAdmission, RangedDecompressReader, ReadEncryptionMaterial, ReadEncryptionMode, ReadEncryptionRequest,
|
||||
SCANNER_PUBLICATION_LEASE_FENCE_METADATA_KEY, ScannerPublicationCommitScope, ScannerPublicationCommitStartError,
|
||||
ScannerPublicationCommitState, ShardIntegrityWriteMode, StreamConsumer, WriteCommitGuard, WriteCompletion,
|
||||
get_object_body_cache_plaintext_len, lookup_get_object_body_cache_hook, register_get_object_body_cache_hook,
|
||||
register_object_mutation_hook, unregister_get_object_body_cache_hook, unregister_object_mutation_hook,
|
||||
with_get_object_read_condition,
|
||||
};
|
||||
pub use crate::store::{
|
||||
PrepareSelectObjectSnapshotError, PreparedGetObjectReader, SelectObjectSnapshot, SelectObjectSnapshotReadError,
|
||||
@@ -628,6 +645,7 @@ pub mod set_disk {
|
||||
#[cfg(feature = "test-util")]
|
||||
pub mod test_util {
|
||||
pub use crate::bucket::quota::reservation::fail_next_quota_ledger_save_for_test;
|
||||
pub use crate::set_disk::ConditionalReadBenchmarkMetadataGuard;
|
||||
pub use crate::set_disk::{MultipartCommitBarrier, MultipartCommitPause, PutObjectCommitBarrier, PutObjectCommitPause};
|
||||
|
||||
/// Keep a namespace commit pending until the returned owner is dropped.
|
||||
|
||||
@@ -142,11 +142,11 @@ fn same_replication_service(edited: &BucketTarget, previous: &BucketTarget) -> b
|
||||
&& access_key(edited) == access_key(previous)
|
||||
}
|
||||
|
||||
/// Page size and page budget for [`TargetClient::locate_replica_by_etag`].
|
||||
/// Page size and page budget for [`TargetClient::replica_candidates`].
|
||||
const FIND_VERSION_BY_ETAG_PAGE_SIZE: i32 = 1000;
|
||||
const FIND_VERSION_BY_ETAG_MAX_PAGES: usize = 8;
|
||||
/// Candidate cap for [`TargetClient::replica_candidates_by_etag`]: more than
|
||||
/// this many same-content versions of one key is ambiguity by any measure.
|
||||
/// Candidate cap for [`TargetClient::replica_candidates`]: more than this
|
||||
/// many matching versions of one key is ambiguity by any measure.
|
||||
const FIND_VERSION_BY_ETAG_MAX_MATCHES: usize = 16;
|
||||
pub type GetObjectSdkError = Box<SdkError<GetObjectError>>;
|
||||
pub type GetObjectTaggingSdkError = Box<SdkError<GetObjectTaggingError>>;
|
||||
@@ -2030,27 +2030,28 @@ impl TargetClient {
|
||||
|
||||
/// Candidate replicas by content identity on a target that mints its own
|
||||
/// version ids: page `ListObjectVersions` under the exact key and report
|
||||
/// the live versions whose ETag matches `source_etag`, newest first.
|
||||
/// Delete markers and prefix siblings never match. Bounded to
|
||||
/// the live versions that match `identity`, newest first. Delete markers
|
||||
/// and prefix siblings never match. Bounded to
|
||||
/// [`FIND_VERSION_BY_ETAG_MAX_PAGES`] pages and
|
||||
/// [`FIND_VERSION_BY_ETAG_MAX_MATCHES`] candidates so a key with a very
|
||||
/// deep history cannot turn one convergence check into an unbounded scan;
|
||||
/// a replica beyond that window reads as missing, which only costs a
|
||||
/// re-PUT (today's behaviour), never a lost object.
|
||||
/// [`FIND_VERSION_BY_ETAG_MAX_MATCHES`] candidates per kind so a key with
|
||||
/// a very deep history cannot turn one convergence check into an
|
||||
/// unbounded scan; a replica beyond that window reads as missing, which
|
||||
/// only costs a re-PUT (today's behaviour), never a lost object.
|
||||
///
|
||||
/// Content identity is not version identity: two source generations with
|
||||
/// the same bytes have the same ETag. Callers drop the candidates other
|
||||
/// source versions already claim through their ledgers and refuse an
|
||||
/// [`ReplicaLocation::Ambiguous`] remainder before mutating or deleting.
|
||||
pub async fn replica_candidates_by_etag(
|
||||
/// the same bytes have the same ETag, and two with the same size have the
|
||||
/// same shape. Callers drop the candidates other source versions already
|
||||
/// claim through their ledgers and decide through
|
||||
/// [`ReplicaCandidates::locate`].
|
||||
pub async fn replica_candidates(
|
||||
&self,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
source_etag: &str,
|
||||
) -> Result<Vec<String>, Box<SdkError<aws_sdk_s3::operation::list_object_versions::ListObjectVersionsError>>> {
|
||||
identity: &ReplicaIdentity<'_>,
|
||||
) -> Result<ReplicaCandidates, Box<SdkError<aws_sdk_s3::operation::list_object_versions::ListObjectVersionsError>>> {
|
||||
let mut key_marker: Option<String> = None;
|
||||
let mut version_id_marker: Option<String> = None;
|
||||
let mut matches: Vec<String> = Vec::new();
|
||||
let mut candidates = ReplicaCandidates::default();
|
||||
for _ in 0..FIND_VERSION_BY_ETAG_MAX_PAGES {
|
||||
let page = self
|
||||
.client
|
||||
@@ -2063,19 +2064,27 @@ impl TargetClient {
|
||||
.send()
|
||||
.await
|
||||
.map_err(Box::new)?;
|
||||
matches.extend(
|
||||
page.versions()
|
||||
.iter()
|
||||
.filter(|version| {
|
||||
version.key() == Some(object)
|
||||
&& version.version_id().is_some_and(|id| !id.is_empty())
|
||||
&& replication_etags_match(Some(source_etag), version.e_tag())
|
||||
})
|
||||
.filter_map(|version| version.version_id().map(str::to_string)),
|
||||
);
|
||||
for version in page.versions() {
|
||||
let Some(version_id) = version.version_id().filter(|id| !id.is_empty()) else {
|
||||
continue;
|
||||
};
|
||||
if version.key() != Some(object) {
|
||||
continue;
|
||||
}
|
||||
match identity.evidence(version.e_tag(), version.size(), version.last_modified()) {
|
||||
Some(ReplicaEvidence::Etag) => candidates.exact.push(version_id.to_string()),
|
||||
// An unversioned target overwrites the key in place: its
|
||||
// `null` version holds whichever generation was written
|
||||
// last, so a shape match there identifies nothing.
|
||||
Some(ReplicaEvidence::Shape) if version_id != "null" => candidates.shaped.push(version_id.to_string()),
|
||||
Some(ReplicaEvidence::Shape) => {}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
// A listing that moved past the exact key (every listed key is >=
|
||||
// the prefix), ended, or already filled the candidate cap decides.
|
||||
if matches.len() >= FIND_VERSION_BY_ETAG_MAX_MATCHES
|
||||
// the prefix), ended, or already filled a candidate cap decides.
|
||||
if candidates.exact.len() >= FIND_VERSION_BY_ETAG_MAX_MATCHES
|
||||
|| candidates.shaped.len() >= FIND_VERSION_BY_ETAG_MAX_MATCHES
|
||||
|| page
|
||||
.versions()
|
||||
.iter()
|
||||
@@ -2090,8 +2099,9 @@ impl TargetClient {
|
||||
break;
|
||||
}
|
||||
}
|
||||
matches.truncate(FIND_VERSION_BY_ETAG_MAX_MATCHES);
|
||||
Ok(matches)
|
||||
candidates.exact.truncate(FIND_VERSION_BY_ETAG_MAX_MATCHES);
|
||||
candidates.shaped.truncate(FIND_VERSION_BY_ETAG_MAX_MATCHES);
|
||||
Ok(candidates)
|
||||
}
|
||||
|
||||
/// PutObjectRetention against a replica version on a target that does not
|
||||
@@ -2614,19 +2624,18 @@ impl TargetClient {
|
||||
}
|
||||
|
||||
/// Where a replica stands on a target that mints its own version ids, by
|
||||
/// content identity (exact key + ETag) after the candidates other source
|
||||
/// versions claim were removed. See
|
||||
/// [`TargetClient::replica_candidates_by_etag`].
|
||||
/// content identity after the candidates other source versions claim were
|
||||
/// removed. See [`TargetClient::replica_candidates`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ReplicaLocation {
|
||||
/// No live version under the key carries the source ETag.
|
||||
/// No live version under the key matches the source.
|
||||
Missing,
|
||||
/// Exactly one live version carries it: safe to address.
|
||||
/// Exactly one live version matches: safe to address.
|
||||
Unique(String),
|
||||
/// More than one live version carries it (same bytes replicated for
|
||||
/// several source generations). `newest` is the most recently listed
|
||||
/// one — good enough to prove the replica exists, never good enough to
|
||||
/// pick which one to mutate or delete.
|
||||
/// More than one live version carries the source ETag (same bytes
|
||||
/// replicated for several source generations). `newest` is the most
|
||||
/// recently listed one — good enough to prove the replica exists, never
|
||||
/// good enough to pick which one to mutate or delete.
|
||||
Ambiguous { newest: String },
|
||||
}
|
||||
|
||||
@@ -2652,6 +2661,107 @@ impl ReplicaLocation {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a replica of an encrypted source version still has in common with
|
||||
/// it on a target that computes its own ETags. The source ETag is a keyed
|
||||
/// fingerprint (docs/operations/encrypted-etags.md) or a legacy plaintext
|
||||
/// MD5 the target's re-encrypted replica never reports, so the replica is
|
||||
/// recognised by the byte count the replication PUT sent and by a write time
|
||||
/// no earlier than the source version's own.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct EncryptedReplicaShape {
|
||||
/// Bytes on the replication wire: the plaintext size for managed SSE,
|
||||
/// the stored ciphertext size for an SSE-C passthrough.
|
||||
pub size: i64,
|
||||
/// The source version's mtime in whole seconds; listings and HEAD report
|
||||
/// second-precision times.
|
||||
pub not_before_secs: i64,
|
||||
}
|
||||
|
||||
impl EncryptedReplicaShape {
|
||||
pub fn matches(&self, size: Option<i64>, last_modified: Option<&aws_sdk_s3::primitives::DateTime>) -> bool {
|
||||
size == Some(self.size) && last_modified.is_some_and(|written| written.secs() >= self.not_before_secs)
|
||||
}
|
||||
}
|
||||
|
||||
/// How a source version recognises its replica among a key's target
|
||||
/// versions.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ReplicaIdentity<'a> {
|
||||
/// Unencrypted source: its ETag is the MD5 of the replicated bytes, which
|
||||
/// every target reports back — exact identity.
|
||||
Etag(&'a str),
|
||||
/// Encrypted source: a target that adopts the source ETag (RustFS,
|
||||
/// MinIO) still reports `etag`; any other target only keeps the shape.
|
||||
Encrypted {
|
||||
etag: Option<&'a str>,
|
||||
shape: EncryptedReplicaShape,
|
||||
},
|
||||
}
|
||||
|
||||
/// Which kind of evidence tied a target version to the source.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ReplicaEvidence {
|
||||
/// The target reports the source ETag.
|
||||
Etag,
|
||||
/// Encrypted source on a target that computes its own ETag: same size,
|
||||
/// written no earlier than the source version.
|
||||
Shape,
|
||||
}
|
||||
|
||||
impl ReplicaIdentity<'_> {
|
||||
pub fn evidence(
|
||||
&self,
|
||||
etag: Option<&str>,
|
||||
size: Option<i64>,
|
||||
last_modified: Option<&aws_sdk_s3::primitives::DateTime>,
|
||||
) -> Option<ReplicaEvidence> {
|
||||
match *self {
|
||||
Self::Etag(source_etag) => replication_etags_match(Some(source_etag), etag).then_some(ReplicaEvidence::Etag),
|
||||
Self::Encrypted {
|
||||
etag: source_etag,
|
||||
shape,
|
||||
} => {
|
||||
if replication_etags_match(source_etag, etag) {
|
||||
Some(ReplicaEvidence::Etag)
|
||||
} else {
|
||||
shape.matches(size, last_modified).then_some(ReplicaEvidence::Shape)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Content-identity candidates of one key, newest first, by evidence kind.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct ReplicaCandidates {
|
||||
/// Versions that report the source ETag.
|
||||
pub exact: Vec<String>,
|
||||
/// Encrypted sources only: versions without the source ETag whose shape
|
||||
/// matches.
|
||||
pub shaped: Vec<String>,
|
||||
}
|
||||
|
||||
impl ReplicaCandidates {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.exact.is_empty() && self.shaped.is_empty()
|
||||
}
|
||||
|
||||
/// Drop the candidates other source versions claim and decide. An ETag
|
||||
/// match outranks any shape match. A shape is weaker evidence than an
|
||||
/// ETag — two generations of one size share it — so several shape
|
||||
/// matches are never resolved by picking one: they read as
|
||||
/// [`ReplicaLocation::Missing`] for convergence (the caller re-PUTs, a
|
||||
/// duplicate at worst) and as ambiguous for a purge (it refuses).
|
||||
pub fn locate(mut self, claimed: &HashSet<String>) -> (ReplicaLocation, ReplicaEvidence) {
|
||||
self.exact.retain(|candidate| !claimed.contains(candidate));
|
||||
if !self.exact.is_empty() {
|
||||
return (ReplicaLocation::from_matches(self.exact), ReplicaEvidence::Etag);
|
||||
}
|
||||
self.shaped.retain(|candidate| !claimed.contains(candidate));
|
||||
(ReplicaLocation::from_matches(self.shaped), ReplicaEvidence::Shape)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum BucketTargetError {
|
||||
BucketRemoteTargetNotFound {
|
||||
|
||||
@@ -2418,6 +2418,16 @@ fn is_slow_down(err: &Error) -> bool {
|
||||
matches!(err, Error::SlowDown)
|
||||
}
|
||||
|
||||
/// Lifecycle expiry is idempotent when the exact object or version has already
|
||||
/// disappeared between the scanner listing and the delete admission.
|
||||
///
|
||||
/// Keep this predicate narrower than bucket/volume "not found" helpers: an
|
||||
/// unavailable bucket or disk is an operational failure and must not be
|
||||
/// reported as a completed expiry.
|
||||
fn is_lifecycle_expiry_already_gone(err: &Error) -> bool {
|
||||
is_err_object_not_found(err) || is_err_version_not_found(err)
|
||||
}
|
||||
|
||||
/// Resolves the expiry worker count from the single documented knob,
|
||||
/// `RUSTFS_MAX_EXPIRY_WORKERS`: a set, parsable, non-zero value wins;
|
||||
/// anything else falls back to `min(cpus, 16)`. The historical
|
||||
@@ -2452,7 +2462,13 @@ fn spawn_recovery_disposition_maintenance_once(api: Arc<ECStore>) -> Option<Join
|
||||
return None;
|
||||
}
|
||||
Some(tokio::spawn(async move {
|
||||
run_recovery_disposition_maintenance_loop(api, cancel_token).await;
|
||||
let maintenance = run_recovery_disposition_maintenance_loop(api, cancel_token);
|
||||
#[cfg(feature = "diagnostic-read-version-caller")]
|
||||
let maintenance = crate::cluster::rpc::runtime_sources::with_read_version_entry(
|
||||
rustfs_io_metrics::internode_metrics::ReadVersionEntrySource::RecoveryDispositionMaintenance,
|
||||
maintenance,
|
||||
);
|
||||
maintenance.await;
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -3335,7 +3351,13 @@ fn spawn_tier_delete_journal_recovery_once(api: Arc<ECStore>) {
|
||||
return;
|
||||
}
|
||||
tokio::spawn(async move {
|
||||
run_tier_delete_journal_recovery_loop(api, cancel_token).await;
|
||||
let recovery = run_tier_delete_journal_recovery_loop(api, cancel_token);
|
||||
#[cfg(feature = "diagnostic-read-version-caller")]
|
||||
let recovery = crate::cluster::rpc::runtime_sources::with_read_version_entry(
|
||||
rustfs_io_metrics::internode_metrics::ReadVersionEntrySource::LifecycleRecovery,
|
||||
recovery,
|
||||
);
|
||||
recovery.await;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3354,7 +3376,13 @@ fn spawn_transition_transaction_recovery_once(api: Arc<ECStore>) {
|
||||
return;
|
||||
}
|
||||
tokio::spawn(async move {
|
||||
run_transition_transaction_recovery_loop(api, cancel_token).await;
|
||||
let recovery = run_transition_transaction_recovery_loop(api, cancel_token);
|
||||
#[cfg(feature = "diagnostic-read-version-caller")]
|
||||
let recovery = crate::cluster::rpc::runtime_sources::with_read_version_entry(
|
||||
rustfs_io_metrics::internode_metrics::ReadVersionEntrySource::LifecycleRecovery,
|
||||
recovery,
|
||||
);
|
||||
recovery.await;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3939,44 +3967,28 @@ pub(crate) async fn enqueue_immediate_expiry(api: Arc<ECStore>, oi: &ObjectInfo,
|
||||
return;
|
||||
};
|
||||
|
||||
let mut marker = None;
|
||||
let mut version_marker = None;
|
||||
let mut object_infos = Vec::new();
|
||||
|
||||
loop {
|
||||
let page = match api
|
||||
.clone()
|
||||
.list_object_versions_for_lifecycle(&oi.bucket, &oi.name, marker.clone(), version_marker.clone(), None, 1000)
|
||||
.await
|
||||
{
|
||||
Ok(page) => page,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
event = EVENT_LIFECYCLE_EVALUATION_FAILED,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
|
||||
bucket = %oi.bucket,
|
||||
object = %oi.name,
|
||||
error = %err,
|
||||
reason = "list_versions_failed",
|
||||
"Failed to load lifecycle version group"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
object_infos.extend(page.objects.into_iter().filter(|object| object.name == oi.name));
|
||||
|
||||
if !page.is_truncated {
|
||||
break;
|
||||
let object_infos = match api
|
||||
.get_object_versions_for_lifecycle(&oi.bucket, &oi.name, opts, configs.bucket_incarnation_id)
|
||||
.await
|
||||
{
|
||||
Ok(objects) => objects,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
event = EVENT_LIFECYCLE_EVALUATION_FAILED,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
|
||||
result = "failed",
|
||||
bucket = %oi.bucket,
|
||||
object = %oi.name,
|
||||
error = %err,
|
||||
reason = "read_versions_failed",
|
||||
"Failed to load lifecycle version group"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
marker = page.next_marker;
|
||||
version_marker = page.next_version_idmarker;
|
||||
}
|
||||
|
||||
};
|
||||
if object_infos.is_empty() {
|
||||
object_infos.push(oi.clone());
|
||||
return;
|
||||
}
|
||||
|
||||
let object_opts = object_infos
|
||||
@@ -4901,6 +4913,14 @@ async fn expire_transitioned_object_with_lock_lost_signal(
|
||||
let (versioned, version_suspended) = snapshot.versioning_config().delete_state(&oi.name);
|
||||
let mut opts = transitioned_object_delete_opts(oi, lc_event.action, versioned, version_suspended, bucket_incarnation_id)
|
||||
.map_err(std::io::Error::other)?;
|
||||
if lc_event.requires_expiry_policy() {
|
||||
opts.lifecycle_expiry_policy = Some(
|
||||
lc_event
|
||||
.expiry_policy
|
||||
.clone()
|
||||
.ok_or_else(|| std::io::Error::other("lifecycle expiration is missing its policy snapshot"))?,
|
||||
);
|
||||
}
|
||||
opts.add_namespace_lock_guard(&publication_guard);
|
||||
if let Some(signal) = lock_lost_signal {
|
||||
opts.add_namespace_lock_lost_signal(signal);
|
||||
@@ -4921,6 +4941,20 @@ async fn expire_transitioned_object_with_lock_lost_signal(
|
||||
//audit_log_lifecycle(*oi, ILMExpiry, tags, traceFn);
|
||||
Ok(dobj)
|
||||
}
|
||||
Err(err) if is_lifecycle_expiry_already_gone(&err) => {
|
||||
debug!(
|
||||
event = EVENT_LIFECYCLE_DELETE_COMPLETED,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
|
||||
bucket = %oi.bucket,
|
||||
object = %oi.name,
|
||||
operation = "delete_restored_object",
|
||||
reason = "already_gone",
|
||||
"Lifecycle delete target was already absent"
|
||||
);
|
||||
crate::object_api::notify_object_mutation(&oi.bucket, &oi.name).await;
|
||||
Ok(oi.clone())
|
||||
}
|
||||
Err(err) => Err(std::io::Error::other(err)),
|
||||
};
|
||||
}
|
||||
@@ -4931,6 +4965,8 @@ async fn expire_transitioned_object_with_lock_lost_signal(
|
||||
// pointing to a remote tier version that has already been removed. If this
|
||||
// only creates a delete marker, remote cleanup must be driven by persisted
|
||||
// free-version recovery rather than the visible delete result.
|
||||
// Keep remote transitioned not-found errors visible: local metadata
|
||||
// absence alone does not prove that the tier object was cleaned up.
|
||||
let dobj = match api.delete_object(&oi.bucket, &oi.name, opts).await {
|
||||
Ok(obj) => obj,
|
||||
Err(e) => {
|
||||
@@ -5735,6 +5771,9 @@ async fn apply_expiry_on_non_transitioned_objects_with_lock_lost_signal(
|
||||
bucket_incarnation_id: Uuid,
|
||||
lock_lost_signal: Option<Arc<rustfs_lock::distributed_lock::LockLostSignal>>,
|
||||
) -> bool {
|
||||
let Some(expiry_policy) = lc_event.expiry_policy.clone() else {
|
||||
return false;
|
||||
};
|
||||
let Some(publication_guard) = lifecycle_expiry_publication_guard(&api, &oi.bucket, &oi.name, bucket_incarnation_id).await
|
||||
else {
|
||||
return false;
|
||||
@@ -5765,6 +5804,7 @@ async fn apply_expiry_on_non_transitioned_objects_with_lock_lost_signal(
|
||||
versioned,
|
||||
version_suspended,
|
||||
expiration: ExpirationOptions { expire: true },
|
||||
lifecycle_expiry_policy: Some(expiry_policy),
|
||||
delete_replication_config_snapshot: Some(Arc::new(snapshot)),
|
||||
expected_bucket_incarnation_id: Some(bucket_incarnation_id),
|
||||
..Default::default()
|
||||
@@ -5803,6 +5843,20 @@ async fn apply_expiry_on_non_transitioned_objects_with_lock_lost_signal(
|
||||
.await
|
||||
{
|
||||
Ok(dobj) => dobj,
|
||||
Err(err) if is_lifecycle_expiry_already_gone(&err) => {
|
||||
debug!(
|
||||
event = EVENT_LIFECYCLE_DELETE_COMPLETED,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
|
||||
bucket = %oi.bucket,
|
||||
object = %oi.name,
|
||||
operation = "delete_object",
|
||||
reason = "already_gone",
|
||||
"Lifecycle delete target was already absent"
|
||||
);
|
||||
crate::object_api::notify_object_mutation(&oi.bucket, &oi.name).await;
|
||||
return true;
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
event = EVENT_LIFECYCLE_DELETE_FAILED,
|
||||
@@ -6032,6 +6086,7 @@ pub async fn apply_lifecycle_action(event: &lifecycle::Event, src: &LcEventSrc,
|
||||
mod tests {
|
||||
use super::expiry_worker_count;
|
||||
use super::lifecycle_transition_publication_guard;
|
||||
use super::metadata_boundary::get_expiry_configs;
|
||||
use super::{
|
||||
DATE_EXPIRY_EXISTING_OBJECTS_GRACE_SECS, DEFAULT_TRANSITION_QUEUE_CAPACITY, DEFAULT_TRANSITION_WORKERS_ABSOLUTE_MAX,
|
||||
DEFAULT_TRANSITION_WORKERS_CAP, EVENT_LIFECYCLE_EVALUATION_FAILED, EVENT_LIFECYCLE_EXPIRED_DETECTED,
|
||||
@@ -6042,18 +6097,19 @@ mod tests {
|
||||
cleanup_empty_multipart_sha_dirs_on_local_disks, cleanup_stale_multipart_uploads_once_at,
|
||||
enqueue_recovered_free_version_with_state, enqueue_transition_for_existing_objects_scoped,
|
||||
enqueue_transition_with_lifecycle, enqueue_transition_with_lifecycle_report, eval_action_from_lifecycle,
|
||||
get_lock_acquire_timeout, jitter_tier_free_version_recovery_delay, lifecycle_action_blocked_by_replication,
|
||||
lifecycle_delete_all_versions_replication_scan, lifecycle_deleted_object, lifecycle_replication_blocks_action,
|
||||
lifecycle_rule_has_date_expiration, lifecycle_version_delete_target, manual_transition_duration_elapsed,
|
||||
manual_transition_has_more_after_limit, manual_transition_recovery_progress_sink, manual_transition_version_marker,
|
||||
manual_transition_worker_failure_reason, mark_delete_opts_skip_decommissioned_on_remote_success,
|
||||
merge_stale_multipart_candidate, persist_manual_transition_job_progress_if_owned,
|
||||
persist_manual_transition_page_checkpoint, recover_manual_transition_job, recover_manual_transition_jobs,
|
||||
resolve_tier_free_version_recovery_enabled, resolve_transition_queue_capacity, resolve_transition_queue_send_timeout,
|
||||
resolve_transition_worker_count, resolve_transition_workers_absolute_max, run_tier_free_version_recovery_coordinator,
|
||||
run_tier_free_version_recovery_loop, select_restore_s3_location, set_lifecycle_observability_observer,
|
||||
set_recovered_free_version_enqueue_observer, should_defer_date_expiry_for_recent_config_update,
|
||||
transitioned_cleanup_tuple, transitioned_object_delete_opts, wait_for_tier_free_version_recovery,
|
||||
get_lock_acquire_timeout, is_lifecycle_expiry_already_gone, jitter_tier_free_version_recovery_delay,
|
||||
lifecycle_action_blocked_by_replication, lifecycle_delete_all_versions_replication_scan, lifecycle_deleted_object,
|
||||
lifecycle_replication_blocks_action, lifecycle_rule_has_date_expiration, lifecycle_version_delete_target,
|
||||
manual_transition_duration_elapsed, manual_transition_has_more_after_limit, manual_transition_recovery_progress_sink,
|
||||
manual_transition_version_marker, manual_transition_worker_failure_reason,
|
||||
mark_delete_opts_skip_decommissioned_on_remote_success, merge_stale_multipart_candidate,
|
||||
persist_manual_transition_job_progress_if_owned, persist_manual_transition_page_checkpoint,
|
||||
recover_manual_transition_job, recover_manual_transition_jobs, resolve_tier_free_version_recovery_enabled,
|
||||
resolve_transition_queue_capacity, resolve_transition_queue_send_timeout, resolve_transition_worker_count,
|
||||
resolve_transition_workers_absolute_max, run_tier_free_version_recovery_coordinator, run_tier_free_version_recovery_loop,
|
||||
select_restore_s3_location, set_lifecycle_observability_observer, set_recovered_free_version_enqueue_observer,
|
||||
should_defer_date_expiry_for_recent_config_update, transitioned_cleanup_tuple, transitioned_object_delete_opts,
|
||||
wait_for_tier_free_version_recovery,
|
||||
};
|
||||
#[cfg(feature = "test-util")]
|
||||
use super::{delete_free_version_remote_object_then, encode_dir_object, get_transitioned_object_reader_with_tier_manager};
|
||||
@@ -6079,7 +6135,7 @@ mod tests {
|
||||
save_manual_transition_scope_admission_if_current, save_manual_transition_task_if_absent,
|
||||
save_manual_transition_worker_result_if_absent,
|
||||
};
|
||||
use crate::bucket::lifecycle::replication_sink::{ReplicationStatusType, VersionPurgeStatusType};
|
||||
use crate::bucket::lifecycle::replication_sink::{self, ReplicationStatusType, VersionPurgeStatusType};
|
||||
use crate::bucket::lifecycle::runtime_boundary as runtime_sources;
|
||||
use crate::bucket::lifecycle::tier_free_version_recovery::{
|
||||
FreeVersionRecoveryStats, RecoveryWalkTestAction, RecoveryWorkBudget, list_tier_free_versions,
|
||||
@@ -10610,6 +10666,26 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifecycle_expiry_already_gone_only_accepts_object_or_version_absence() {
|
||||
assert!(is_lifecycle_expiry_already_gone(&Error::FileNotFound));
|
||||
assert!(is_lifecycle_expiry_already_gone(&Error::ObjectNotFound(
|
||||
"bucket".to_string(),
|
||||
"object".to_string(),
|
||||
)));
|
||||
assert!(is_lifecycle_expiry_already_gone(&Error::FileVersionNotFound));
|
||||
assert!(is_lifecycle_expiry_already_gone(&Error::VersionNotFound(
|
||||
"bucket".to_string(),
|
||||
"object".to_string(),
|
||||
"version".to_string(),
|
||||
)));
|
||||
|
||||
assert!(!is_lifecycle_expiry_already_gone(&Error::VolumeNotFound));
|
||||
assert!(!is_lifecycle_expiry_already_gone(&Error::DiskNotFound));
|
||||
assert!(!is_lifecycle_expiry_already_gone(&Error::ErasureReadQuorum));
|
||||
assert!(!is_lifecycle_expiry_already_gone(&Error::MethodNotAllowed));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_transition_counts_already_transitioned_object() {
|
||||
let lc = latest_transition_lifecycle();
|
||||
@@ -12931,6 +13007,7 @@ mod tests {
|
||||
.await
|
||||
.expect("bucket incarnation should be available");
|
||||
|
||||
let expiry_policy = install_test_expiry_policy(&ecstore, &bucket).await;
|
||||
test_recorder::install();
|
||||
|
||||
// One version that exists and one that never did: `delete_objects`
|
||||
@@ -12949,15 +13026,24 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
let _ = replication_sink::take_scheduled_deletes_for_test();
|
||||
let failed = crate::bucket::lifecycle::object_handlers_common::delete_object_versions(
|
||||
&ecstore,
|
||||
&bucket,
|
||||
&targets,
|
||||
lifecycle::Event::default(),
|
||||
lifecycle::Event {
|
||||
action: IlmAction::DeleteVersionAction,
|
||||
expiry_policy: Some(expiry_policy.into()),
|
||||
..Default::default()
|
||||
},
|
||||
incarnation,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(failed, 0, "a missing version is not a batch failure");
|
||||
assert!(
|
||||
replication_sink::take_scheduled_deletes_for_test().is_empty(),
|
||||
"a bucket without replication must not schedule deletes for removed or missing versions"
|
||||
);
|
||||
|
||||
let events = test_recorder::recorded_for_bucket(&bucket);
|
||||
let announced = events.iter().map(|event| event.version_id).collect::<Vec<_>>();
|
||||
@@ -12984,6 +13070,97 @@ mod tests {
|
||||
assert_eq!(remaining[0].version_id, Some(current_version_id));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn lifecycle_noncurrent_batch_expiry_schedules_only_matching_delete_rules() {
|
||||
use crate::bucket::metadata::BUCKET_REPLICATION_CONFIG;
|
||||
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
for (prefix, delete_status, should_replicate) in [
|
||||
("other/", "Enabled", false),
|
||||
("logs/", "Disabled", false),
|
||||
("logs/", "Enabled", true),
|
||||
] {
|
||||
let bucket = format!("lifecycle-repl-admission-{}", Uuid::new_v4().simple());
|
||||
let object = "logs/object";
|
||||
create_test_bucket(&ecstore, &bucket).await;
|
||||
metadata_sys::update_in(
|
||||
&ecstore.ctx,
|
||||
&bucket,
|
||||
BUCKET_VERSIONING_CONFIG,
|
||||
b"<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>".to_vec(),
|
||||
)
|
||||
.await
|
||||
.expect("versioning should be enabled");
|
||||
|
||||
let mut versions = Vec::new();
|
||||
for body in [b"noncurrent".as_slice(), b"current".as_slice()] {
|
||||
let mut reader = PutObjReader::from_vec(body.to_vec());
|
||||
let version = ecstore
|
||||
.put_object(
|
||||
&bucket,
|
||||
object,
|
||||
&mut reader,
|
||||
&ObjectOptions {
|
||||
versioned: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("source version should be created");
|
||||
versions.push(version.version_id.expect("versioned PUT must have an identity"));
|
||||
}
|
||||
let config = format!(
|
||||
"<ReplicationConfiguration><Role></Role><Rule><ID>delete-rule</ID><Status>Enabled</Status>\
|
||||
<Prefix>{prefix}</Prefix><Destination><Bucket>arn:test</Bucket></Destination>\
|
||||
<DeleteReplication><Status>{delete_status}</Status></DeleteReplication>\
|
||||
</Rule></ReplicationConfiguration>"
|
||||
);
|
||||
metadata_sys::update_in(&ecstore.ctx, &bucket, BUCKET_REPLICATION_CONFIG, config.into_bytes())
|
||||
.await
|
||||
.expect("replication configuration should be persisted");
|
||||
let expiry_policy = install_test_expiry_policy(&ecstore, &bucket).await;
|
||||
let incarnation = ecstore
|
||||
.bucket_incarnation_id_from_disk(&bucket)
|
||||
.await
|
||||
.expect("bucket incarnation should be available");
|
||||
let _ = replication_sink::take_scheduled_deletes_for_test();
|
||||
let failed = crate::bucket::lifecycle::object_handlers_common::delete_object_versions(
|
||||
&ecstore,
|
||||
&bucket,
|
||||
&[ObjectToDelete {
|
||||
object_name: object.to_string(),
|
||||
version_id: Some(versions[0]),
|
||||
..Default::default()
|
||||
}],
|
||||
lifecycle::Event {
|
||||
action: IlmAction::DeleteVersionAction,
|
||||
expiry_policy: Some(expiry_policy.into()),
|
||||
..Default::default()
|
||||
},
|
||||
incarnation,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(failed, 0, "batch expiry should commit");
|
||||
let scheduled = replication_sink::take_scheduled_deletes_for_test();
|
||||
assert_eq!(scheduled.len(), usize::from(should_replicate), "prefix={prefix}, delete={delete_status}");
|
||||
if should_replicate {
|
||||
assert_eq!(scheduled[0].version_id, Some(versions[0]));
|
||||
let state = scheduled[0]
|
||||
.replication_state
|
||||
.as_ref()
|
||||
.expect("admitted purge state must survive");
|
||||
assert_eq!(state.version_purge_status_internal.as_deref(), Some("arn:test=PENDING;"));
|
||||
assert!(state.purge_targets.contains_key("arn:test"));
|
||||
}
|
||||
let current = ecstore
|
||||
.get_object_info(&bucket, object, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("batch expiry must retain the current version");
|
||||
assert_eq!(current.version_id, Some(versions[1]));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn lifecycle_deletes_only_the_historical_null_version_after_versioning_is_reenabled() {
|
||||
@@ -13071,6 +13248,14 @@ mod tests {
|
||||
assert!(!object_infos[null_index].is_latest);
|
||||
|
||||
let lifecycle = current_and_noncurrent_expiration_lifecycle();
|
||||
metadata_sys::update_in(
|
||||
&ecstore.ctx,
|
||||
&bucket,
|
||||
BUCKET_LIFECYCLE_CONFIG,
|
||||
crate::bucket::utils::serialize(&lifecycle).unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let object_opts = object_infos
|
||||
.iter()
|
||||
.map(lifecycle::object_opts_from_object_info)
|
||||
@@ -13241,6 +13426,81 @@ mod tests {
|
||||
assert_eq!(event.action, IlmAction::NoneAction);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn lifecycle_exact_immediate_expiry_preserves_guards_and_skips_stale_writes() {
|
||||
let (paths, ecstore) = setup_test_env().await;
|
||||
let bucket = format!("lifecycle-exact-expiry-{}", Uuid::new_v4().simple());
|
||||
let object = "object";
|
||||
create_test_bucket(&ecstore, &bucket).await;
|
||||
let oi = ecstore
|
||||
.put_object(
|
||||
&bucket,
|
||||
object,
|
||||
&mut PutObjReader::from_vec(b"payload".to_vec()),
|
||||
&ObjectOptions::default(),
|
||||
)
|
||||
.await
|
||||
.expect("create object before configuring expiry");
|
||||
|
||||
// Exercise persisted legacy immediate rules without relaxing the S3
|
||||
// validator, which correctly rejects newly submitted Expiration.Days=0.
|
||||
let sys = metadata_sys::bucket_metadata_sys_of(&ecstore.ctx).expect("metadata system");
|
||||
let sys = sys.read().await.clone();
|
||||
let mut metadata = (*sys.get(&bucket).await.expect("bucket metadata")).clone();
|
||||
let mut lifecycle = latest_expiration_lifecycle();
|
||||
lifecycle.rules[0].expiration.as_mut().expect("expiration rule").days = Some(0);
|
||||
metadata.lifecycle_config_xml = crate::bucket::utils::serialize(&lifecycle).expect("encode legacy policy");
|
||||
metadata.lifecycle_config = Some(lifecycle);
|
||||
sys.persist_and_set(metadata).await.expect("persist legacy policy");
|
||||
|
||||
let state = install_unconsumed_runtime_expiry_worker(&ecstore, 4).await;
|
||||
super::enqueue_immediate_expiry(ecstore.clone(), &oi, LcEventSrc::S3PutObject, &ObjectOptions::default()).await;
|
||||
assert_eq!(state.read().await.stats.pending_tasks(), 1, "exact reads must preserve immediate expiry");
|
||||
|
||||
let state = install_unconsumed_runtime_expiry_worker(&ecstore, 4).await;
|
||||
for path in &paths {
|
||||
let metadata_path = path.join(&bucket).join(object).join(crate::disk::STORAGE_FORMAT_FILE);
|
||||
let mut meta = rustfs_filemeta::FileMeta::load(&fs::read(&metadata_path).await.expect("read object metadata"))
|
||||
.expect("decode object metadata");
|
||||
let mut version = meta
|
||||
.get_all_file_info_versions(&bucket, object, true)
|
||||
.expect("version group")
|
||||
.versions
|
||||
.remove(0);
|
||||
version.metadata.insert("x-amz-object-lock-legal-hold".into(), "ON".into());
|
||||
meta.add_version(version).expect("persist legal hold");
|
||||
fs::write(metadata_path, meta.marshal_msg().expect("encode held metadata"))
|
||||
.await
|
||||
.expect("write legal hold");
|
||||
}
|
||||
super::enqueue_immediate_expiry(ecstore.clone(), &oi, LcEventSrc::S3PutObject, &ObjectOptions::default()).await;
|
||||
assert_eq!(
|
||||
state.read().await.stats.pending_tasks(),
|
||||
0,
|
||||
"use current lock metadata, not the stale write result"
|
||||
);
|
||||
|
||||
for path in &paths {
|
||||
fs::write(path.join(&bucket).join(object).join(crate::disk::STORAGE_FORMAT_FILE), b"corrupt")
|
||||
.await
|
||||
.expect("inject corrupt metadata");
|
||||
}
|
||||
super::enqueue_immediate_expiry(ecstore.clone(), &oi, LcEventSrc::S3PutObject, &ObjectOptions::default()).await;
|
||||
assert_eq!(state.read().await.stats.pending_tasks(), 0, "read failure must never enqueue expiry");
|
||||
for path in &paths {
|
||||
fs::remove_dir_all(path.join(&bucket).join(object))
|
||||
.await
|
||||
.expect("simulate concurrent object removal");
|
||||
}
|
||||
super::enqueue_immediate_expiry(ecstore.clone(), &oi, LcEventSrc::S3PutObject, &ObjectOptions::default()).await;
|
||||
assert_eq!(
|
||||
state.read().await.stats.pending_tasks(),
|
||||
0,
|
||||
"confirmed absence must not fall back to the old ObjectInfo"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn lifecycle_expiry_fails_closed_on_corrupt_object_lock_metadata() {
|
||||
@@ -13350,6 +13610,7 @@ mod tests {
|
||||
.await
|
||||
.expect("table data object should be created");
|
||||
|
||||
let expiry_policy = install_test_expiry_policy(&ecstore, &bucket).await;
|
||||
let publication_lock = ecstore
|
||||
.new_ns_lock(&bucket, rustfs_common::table_catalog::TABLE_BUCKET_PUBLICATION_LOCK_PATH)
|
||||
.await
|
||||
@@ -13364,6 +13625,7 @@ mod tests {
|
||||
let mut expiry = tokio::spawn(async move {
|
||||
let event = crate::bucket::lifecycle::lifecycle::Event {
|
||||
action: IlmAction::DeleteAction,
|
||||
expiry_policy: Some(expiry_policy.into()),
|
||||
..Default::default()
|
||||
};
|
||||
let bucket_incarnation_id = expiry_store
|
||||
@@ -13478,13 +13740,16 @@ mod tests {
|
||||
queued.size = 1;
|
||||
let event = lifecycle::Event {
|
||||
action: IlmAction::DeleteAction,
|
||||
expiry_policy: Some(install_test_expiry_policy(&ecstore, &bucket).await.into()),
|
||||
..Default::default()
|
||||
};
|
||||
let incarnation = ecstore
|
||||
.bucket_incarnation_id_from_disk(&bucket)
|
||||
.await
|
||||
.expect("bucket incarnation");
|
||||
for (attempt_incarnation, expected) in [(Uuid::new_v4(), false), (incarnation, true), (incarnation, false)] {
|
||||
// Replaying the same scanner snapshot after the delete is an
|
||||
// already-gone success and must not be reported as a failure.
|
||||
for (attempt_incarnation, expected) in [(Uuid::new_v4(), false), (incarnation, true), (incarnation, true)] {
|
||||
assert_eq!(
|
||||
super::apply_expiry_on_non_transitioned_objects(
|
||||
ecstore.clone(),
|
||||
@@ -13587,6 +13852,7 @@ mod tests {
|
||||
.bucket_incarnation_id_from_disk(&bucket)
|
||||
.await
|
||||
.expect("bucket incarnation should be available");
|
||||
let expiry_policy = install_test_expiry_policy(&ecstore, &bucket).await;
|
||||
let publication_lock = ecstore
|
||||
.new_ns_lock(&bucket, rustfs_common::table_catalog::TABLE_BUCKET_PUBLICATION_LOCK_PATH)
|
||||
.await
|
||||
@@ -13609,7 +13875,11 @@ mod tests {
|
||||
&delete_store,
|
||||
&delete_bucket,
|
||||
std::slice::from_ref(&target),
|
||||
lifecycle::Event::default(),
|
||||
lifecycle::Event {
|
||||
action: IlmAction::DeleteVersionAction,
|
||||
expiry_policy: Some(expiry_policy.into()),
|
||||
..Default::default()
|
||||
},
|
||||
incarnation,
|
||||
)
|
||||
.await
|
||||
@@ -13765,6 +14035,7 @@ mod tests {
|
||||
let queued_event = crate::bucket::lifecycle::lifecycle::Event {
|
||||
action: IlmAction::DelMarkerDeleteAllVersionsAction,
|
||||
rule_id: "delete-marker-history".to_string(),
|
||||
expiry_policy: get_expiry_configs(&ecstore, &bucket).await.unwrap().lifecycle.map(Into::into),
|
||||
..Default::default()
|
||||
};
|
||||
metadata_sys::update(&bucket, BUCKET_LIFECYCLE_CONFIG, lifecycle_xml(30).into_bytes())
|
||||
@@ -13882,6 +14153,7 @@ mod tests {
|
||||
&crate::bucket::lifecycle::lifecycle::Event {
|
||||
action: IlmAction::DeleteAllVersionsAction,
|
||||
rule_id: "delete-all-versions".to_string(),
|
||||
expiry_policy: get_expiry_configs(&ecstore, &bucket).await.unwrap().lifecycle.map(Into::into),
|
||||
..Default::default()
|
||||
},
|
||||
&LcEventSrc::Scanner,
|
||||
@@ -14068,6 +14340,221 @@ mod tests {
|
||||
(disk_paths, ecstore)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn queued_expiry_rejects_withdrawn_or_changed_policy() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
for case in [
|
||||
"removed",
|
||||
"disabled",
|
||||
"longer-days",
|
||||
"different-prefix",
|
||||
"unreadable",
|
||||
"missing-snapshot",
|
||||
"unchanged",
|
||||
] {
|
||||
let bucket = format!("expiry-policy-{case}");
|
||||
create_test_bucket(&ecstore, &bucket).await;
|
||||
metadata_sys::update_in(
|
||||
&ecstore.ctx,
|
||||
&bucket,
|
||||
BUCKET_VERSIONING_CONFIG,
|
||||
b"<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>".to_vec(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
for object in ["single", "batch"] {
|
||||
for age in [40, 2] {
|
||||
let mut reader = PutObjReader::from_vec(vec![1; 8]);
|
||||
ecstore
|
||||
.put_object(
|
||||
&bucket,
|
||||
object,
|
||||
&mut reader,
|
||||
&ObjectOptions {
|
||||
versioned: true,
|
||||
mod_time: Some(OffsetDateTime::now_utc() - time::Duration::days(age)),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
let policy = current_and_noncurrent_expiration_lifecycle();
|
||||
metadata_sys::update_in(
|
||||
&ecstore.ctx,
|
||||
&bucket,
|
||||
BUCKET_LIFECYCLE_CONFIG,
|
||||
crate::bucket::utils::serialize(&policy).unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let incarnation = ecstore.bucket_incarnation_id_from_disk(&bucket).await.unwrap();
|
||||
let state = install_unconsumed_runtime_expiry_worker(&ecstore, 3).await;
|
||||
for object in ["single", "batch"] {
|
||||
let versions = ecstore
|
||||
.clone()
|
||||
.list_object_versions(&bucket, object, None, None, None, 10)
|
||||
.await
|
||||
.unwrap()
|
||||
.objects;
|
||||
let opts = versions
|
||||
.iter()
|
||||
.map(lifecycle::object_opts_from_object_info)
|
||||
.collect::<Vec<_>>();
|
||||
let events = Evaluator::new(Arc::new(policy.clone())).eval(&opts).await.unwrap();
|
||||
let index = versions.iter().position(|version| !version.is_latest).unwrap();
|
||||
let mut event = events[index].clone();
|
||||
assert_eq!(event.action, IlmAction::DeleteVersionAction);
|
||||
if case == "missing-snapshot" {
|
||||
event.expiry_policy = None;
|
||||
}
|
||||
let queued = if object == "single" {
|
||||
state
|
||||
.write()
|
||||
.await
|
||||
.enqueue_by_days(&versions[index], &event, &LcEventSrc::Scanner, incarnation)
|
||||
} else {
|
||||
state.write().await.enqueue_by_newer_noncurrent(
|
||||
&bucket,
|
||||
vec![lifecycle_version_delete_target(&versions[index]).unwrap()],
|
||||
event,
|
||||
&LcEventSrc::Scanner,
|
||||
incarnation,
|
||||
)
|
||||
};
|
||||
assert!(queued);
|
||||
}
|
||||
let mut changed = policy.clone();
|
||||
match case {
|
||||
"removed" => {
|
||||
metadata_sys::delete(&bucket, BUCKET_LIFECYCLE_CONFIG).await.unwrap();
|
||||
}
|
||||
"disabled" => changed.rules[0].status = ExpirationStatus::from_static(ExpirationStatus::DISABLED),
|
||||
"longer-days" => {
|
||||
changed.rules[0]
|
||||
.noncurrent_version_expiration
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.noncurrent_days = Some(60)
|
||||
}
|
||||
"different-prefix" => changed.rules[0].prefix = Some("keep/".to_string()),
|
||||
"unreadable" => {
|
||||
let sys = metadata_sys::bucket_metadata_sys_of(&ecstore.ctx)
|
||||
.unwrap()
|
||||
.read()
|
||||
.await
|
||||
.clone();
|
||||
let mut metadata = (*sys.get(&bucket).await.unwrap()).clone();
|
||||
metadata.lifecycle_config_xml = b"<LifecycleConfiguration>".to_vec();
|
||||
metadata.lifecycle_config = None;
|
||||
sys.persist_and_set(metadata).await.unwrap();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if !matches!(case, "removed" | "unchanged" | "missing-snapshot" | "unreadable") {
|
||||
metadata_sys::update_in(
|
||||
&ecstore.ctx,
|
||||
&bucket,
|
||||
BUCKET_LIFECYCLE_CONFIG,
|
||||
crate::bucket::utils::serialize(&changed).unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let (rx, stats, recovery_notify) = {
|
||||
let state = state.read().await;
|
||||
state.tasks_tx[0].try_send(None).unwrap();
|
||||
(state.tasks_rx[0].clone(), state.stats.clone(), state.recovery_notify.clone())
|
||||
};
|
||||
tokio::time::timeout(StdDuration::from_secs(30), async {
|
||||
let mut receiver = rx.lock().await;
|
||||
ExpiryState::worker(&mut receiver, Arc::downgrade(&ecstore), stats, recovery_notify).await;
|
||||
})
|
||||
.await
|
||||
.expect("queued expiry worker must drain");
|
||||
let versions = ecstore
|
||||
.clone()
|
||||
.list_object_versions(&bucket, "", None, None, None, 10)
|
||||
.await
|
||||
.unwrap()
|
||||
.objects;
|
||||
assert_eq!(versions.len(), if case == "unchanged" { 2 } else { 4 }, "case: {case}");
|
||||
assert_eq!(versions.iter().filter(|version| version.is_latest).count(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn lifecycle_policy_update_waits_for_admitted_delete_commit() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let bucket = "expiry-policy-commit-fence";
|
||||
create_test_bucket(&ecstore, bucket).await;
|
||||
let mut reader = PutObjReader::from_vec(b"expired".to_vec());
|
||||
let object = ecstore
|
||||
.put_object(
|
||||
bucket,
|
||||
"object",
|
||||
&mut reader,
|
||||
&ObjectOptions {
|
||||
mod_time: Some(OffsetDateTime::now_utc() - time::Duration::days(40)),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let policy = install_test_expiry_policy(&ecstore, bucket).await;
|
||||
let event = eval_action_from_lifecycle(&policy, None, &object).await;
|
||||
assert_eq!(event.action, IlmAction::DeleteAction);
|
||||
let incarnation = ecstore.bucket_incarnation_id_from_disk(bucket).await.unwrap();
|
||||
let barrier = crate::store::DeleteAfterObjectLockSnapshotBarrier::install(bucket);
|
||||
let deleting_store = ecstore.clone();
|
||||
let deleting = tokio::spawn(async move {
|
||||
super::apply_expiry_on_non_transitioned_objects(deleting_store, &object, &event, &LcEventSrc::Scanner, incarnation)
|
||||
.await
|
||||
});
|
||||
tokio::time::timeout(StdDuration::from_secs(30), barrier.wait_until_paused())
|
||||
.await
|
||||
.unwrap();
|
||||
let probe = metadata_sys::ConfigWriteLockProbe::install(bucket);
|
||||
let mut removing = tokio::spawn(async move { metadata_sys::delete(bucket, BUCKET_LIFECYCLE_CONFIG).await });
|
||||
probe.wait_until_attempted().await;
|
||||
assert!(
|
||||
tokio::time::timeout(StdDuration::from_millis(100), &mut removing)
|
||||
.await
|
||||
.is_err(),
|
||||
"configuration removal must wait for the admitted delete"
|
||||
);
|
||||
barrier.release();
|
||||
assert!(
|
||||
tokio::time::timeout(StdDuration::from_secs(30), deleting)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
);
|
||||
tokio::time::timeout(StdDuration::from_secs(30), removing)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(get_expiry_configs(&ecstore, bucket).await.unwrap().lifecycle.is_none());
|
||||
assert!(
|
||||
ecstore
|
||||
.get_object_info(bucket, "object", &ObjectOptions::default())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
async fn install_test_expiry_policy(ecstore: &Arc<ECStore>, bucket: &str) -> Arc<BucketLifecycleConfiguration> {
|
||||
let xml = br#"<LifecycleConfiguration><Rule><ID>expire</ID><Status>Enabled</Status><Filter><Prefix></Prefix></Filter><Expiration><Days>0</Days></Expiration><NoncurrentVersionExpiration><NoncurrentDays>0</NoncurrentDays></NoncurrentVersionExpiration></Rule></LifecycleConfiguration>"#;
|
||||
metadata_sys::update_in(&ecstore.ctx, bucket, BUCKET_LIFECYCLE_CONFIG, xml.to_vec())
|
||||
.await
|
||||
.expect("install expiration policy");
|
||||
get_expiry_configs(ecstore, bucket).await.unwrap().lifecycle.unwrap()
|
||||
}
|
||||
|
||||
async fn create_test_bucket(ecstore: &Arc<ECStore>, bucket: &str) {
|
||||
ecstore
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
|
||||
@@ -33,13 +33,17 @@ pub async fn delete_object_versions(
|
||||
api: &Arc<ECStore>,
|
||||
bucket: &str,
|
||||
to_del: &[ObjectToDelete],
|
||||
_lc_event: lifecycle::Event,
|
||||
lc_event: lifecycle::Event,
|
||||
bucket_incarnation_id: Uuid,
|
||||
) -> usize {
|
||||
if to_del.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let Some(expiry_policy) = lc_event.expiry_policy else {
|
||||
return to_del.len();
|
||||
};
|
||||
|
||||
if to_del.iter().any(|target| {
|
||||
target.version_id.is_none()
|
||||
|| (target.version_id.is_some_and(|version_id| version_id.is_nil()) && target.expected_identity.is_none())
|
||||
@@ -104,6 +108,7 @@ pub async fn delete_object_versions(
|
||||
let mut delete_options = ObjectOptions {
|
||||
delete_replication_config_snapshot: Some(Arc::clone(&delete_config_snapshot)),
|
||||
expected_bucket_incarnation_id: Some(bucket_incarnation_id),
|
||||
lifecycle_expiry_policy: Some(expiry_policy.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
delete_options.add_namespace_lock_guard(&publication_guard);
|
||||
@@ -125,7 +130,11 @@ pub async fn delete_object_versions(
|
||||
// already happened (backlog#2202).
|
||||
emit_noncurrent_expiration_event(bucket, target, deleted_obj, false);
|
||||
}
|
||||
if deleted_obj.replication_state.is_none() {
|
||||
if !deleted_obj
|
||||
.replication_state
|
||||
.as_ref()
|
||||
.is_some_and(|state| !state.targets.is_empty() || !state.purge_targets.is_empty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
replication_sink::schedule_delete(bucket.to_string(), deleted_obj.clone()).await;
|
||||
|
||||
@@ -70,6 +70,11 @@ pub(crate) async fn schedule_delete(bucket: String, delete_object: DeletedObject
|
||||
ReplicationLifecycleBridge::schedule_delete(bucket, delete_object).await;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn take_scheduled_deletes_for_test() -> Vec<DeletedObject> {
|
||||
ReplicationLifecycleBridge::take_scheduled_deletes_for_test()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -178,6 +178,13 @@ pub(crate) fn object_lock_config_state_from_authoritative_metadata(bm: &BucketMe
|
||||
Ok(ObjectLockConfigState::ConfirmedAbsent)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(crate) struct CachedQuotaMetadataSnapshot {
|
||||
pub(crate) quota: Option<BucketQuota>,
|
||||
pub(crate) bucket_incarnation: Uuid,
|
||||
pub(crate) quota_revision: OffsetDateTime,
|
||||
}
|
||||
|
||||
/// Convert the persisted serving-layer configuration into the storage-level
|
||||
/// [`DefaultRetention`](crate::bucket::object_lock::types::DefaultRetention)
|
||||
/// the WORM evaluation code consumes (rustfs/backlog#1842). A rule without a
|
||||
@@ -316,6 +323,33 @@ pub async fn remove_bucket_metadata(bucket: &str) -> Result<bool> {
|
||||
Ok(lock.remove(bucket).await)
|
||||
}
|
||||
|
||||
/// Remove a peer's cached metadata only when the bucket is absent under the
|
||||
/// namespace read fence. A delayed delete notification must not withdraw the
|
||||
/// metadata of a same-name bucket that has already been recreated.
|
||||
pub async fn remove_bucket_metadata_if_absent(api: Arc<ECStore>, bucket: &str) -> Result<bool> {
|
||||
if is_meta_bucketname(bucket) {
|
||||
return Ok(false);
|
||||
}
|
||||
// Lock order: bucket namespace read -> metadata system read (released)
|
||||
// -> metadata publish, matching peer reloads.
|
||||
let namespace_lock = api.new_ns_lock(bucket, bucket).await?;
|
||||
let namespace_guard = namespace_lock
|
||||
.get_read_lock(crate::set_disk::get_lock_acquire_timeout())
|
||||
.await?;
|
||||
let sys = bucket_metadata_sys_of(&api.ctx)?;
|
||||
let sys = sys.read().await.clone();
|
||||
if sys
|
||||
.bucket_exists(bucket, &namespace_guard, "peer bucket metadata removal existence check")
|
||||
.await?
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
await_bucket_namespace_operation(Some(&namespace_guard), bucket, "peer bucket metadata removal", async {
|
||||
Ok(sys.remove(bucket).await)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
fn start_refresh_buckets_metadata_loop(sys: Arc<RwLock<BucketMetadataSys>>) {
|
||||
let Some(cancel_token) = runtime_sources::background_services_cancel_token() else {
|
||||
warn!("bucket metadata refresh loop skipped because background cancellation token is not initialized");
|
||||
@@ -493,6 +527,36 @@ pub(crate) async fn get_bucket_incarnation_id_in(ctx: &crate::runtime::instance:
|
||||
sys.get_bucket_incarnation_id_from_disk(bucket).await
|
||||
}
|
||||
|
||||
/// Directory loss is recoverable only for a committed, non-retired generation.
|
||||
/// The caller retains the bucket lifecycle fence through the ensuing reconstruction.
|
||||
pub(crate) async fn validate_bucket_reconstruction_in(
|
||||
ctx: &crate::runtime::instance::InstanceContext,
|
||||
bucket: &str,
|
||||
expected: Uuid,
|
||||
) -> Result<()> {
|
||||
let api = bucket_metadata_sys_of(ctx)?.read().await.object_store();
|
||||
let lock = api
|
||||
.new_ns_lock(RUSTFS_META_BUCKET, &bucket_metadata_transaction_lock_key(bucket))
|
||||
.await?;
|
||||
let guard = lock.get_read_lock(crate::set_disk::get_lock_acquire_timeout()).await?;
|
||||
let (metadata, persisted) = load_bucket_metadata_parse_with_presence(api.clone(), bucket, true).await?;
|
||||
if !persisted
|
||||
|| !metadata.bucket_incarnation_sidecar
|
||||
|| expected.is_nil()
|
||||
|| metadata.bucket_incarnation_id != expected
|
||||
|| !metadata.bucket_creation_committed
|
||||
{
|
||||
return Err(Error::PreconditionFailed);
|
||||
}
|
||||
if crate::bucket::retirement::is_retired(api, bucket, expected).await? {
|
||||
return Err(Error::BucketNotFound(bucket.to_owned()));
|
||||
}
|
||||
if guard.is_lock_lost() {
|
||||
return Err(Error::PreconditionFailed);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate against disk while retaining an already-held Object Lock fence.
|
||||
pub(crate) async fn get_bucket_incarnation_id_for_options_in(
|
||||
ctx: &crate::runtime::instance::InstanceContext,
|
||||
@@ -1314,6 +1378,18 @@ pub(crate) async fn get_object_lock_config_and_incarnation_from_disk_with_guard_
|
||||
ctx: &crate::runtime::instance::InstanceContext,
|
||||
bucket: &str,
|
||||
guard: Option<&rustfs_lock::NamespaceLockGuard>,
|
||||
) -> Result<(ObjectLockConfigState, Uuid, OffsetDateTime)> {
|
||||
get_delete_config_from_disk_in(ctx, bucket, guard, None).await
|
||||
}
|
||||
|
||||
/// Validate queued expiration against the same fresh metadata used for Object
|
||||
/// Lock admission. The caller holds the transaction read lock through commit,
|
||||
/// so a successful lifecycle update cannot be followed by an old-policy delete.
|
||||
pub(crate) async fn get_delete_config_from_disk_in(
|
||||
ctx: &crate::runtime::instance::InstanceContext,
|
||||
bucket: &str,
|
||||
guard: Option<&rustfs_lock::NamespaceLockGuard>,
|
||||
expiry_policy: Option<&rustfs_lifecycle::ExpiryPolicySnapshot>,
|
||||
) -> Result<(ObjectLockConfigState, Uuid, OffsetDateTime)> {
|
||||
let bucket_meta_sys_lock = bucket_metadata_sys_of(ctx)?;
|
||||
let bucket_meta_sys = bucket_meta_sys_lock.read().await.clone();
|
||||
@@ -1334,6 +1410,13 @@ pub(crate) async fn get_object_lock_config_and_incarnation_from_disk_with_guard_
|
||||
BucketMetadataAuthority::Authoritative(metadata)
|
||||
if metadata.bucket_incarnation_sidecar && !metadata.bucket_incarnation_id.is_nil() =>
|
||||
{
|
||||
if let Some(expected) = expiry_policy {
|
||||
let current = ConfigState::of(&metadata.lifecycle_config_xml, &metadata.lifecycle_config)
|
||||
.require(bucket, BUCKET_LIFECYCLE_CONFIG)?;
|
||||
if !expected.matches_configuration(current) {
|
||||
return Err(Error::PreconditionFailed);
|
||||
}
|
||||
}
|
||||
let object_lock_config_updated_at = metadata.object_lock_config_updated_at;
|
||||
Ok((
|
||||
object_lock_config_state_from_authoritative_metadata(&metadata)?,
|
||||
@@ -1434,6 +1517,9 @@ fn quota_config_and_incarnation_from_authority(
|
||||
BucketMetadataAuthority::Authoritative(metadata)
|
||||
if metadata.bucket_incarnation_sidecar && !metadata.bucket_incarnation_id.is_nil() =>
|
||||
{
|
||||
if !metadata.quota_config_json.is_empty() && metadata.quota_config.is_none() {
|
||||
return Err(Error::other("persisted bucket quota configuration is invalid"));
|
||||
}
|
||||
Ok((
|
||||
metadata.quota_config.clone(),
|
||||
metadata.bucket_incarnation_id,
|
||||
@@ -1448,6 +1534,15 @@ fn quota_config_and_incarnation_from_authority(
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn get_cached_quota_config_and_incarnation_in(
|
||||
ctx: &crate::runtime::instance::InstanceContext,
|
||||
bucket: &str,
|
||||
) -> Result<Option<CachedQuotaMetadataSnapshot>> {
|
||||
let bucket_meta_sys_lock = bucket_metadata_sys_of(ctx)?;
|
||||
let bucket_meta_sys = bucket_meta_sys_lock.read().await.clone();
|
||||
bucket_meta_sys.cached_quota_config_and_incarnation(bucket).await
|
||||
}
|
||||
|
||||
pub async fn get_replication_config(bucket: &str) -> Result<(ReplicationConfiguration, OffsetDateTime)> {
|
||||
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
|
||||
let bucket_meta_sys = bucket_meta_sys_lock.read().await;
|
||||
@@ -2982,6 +3077,27 @@ impl BucketMetadataSys {
|
||||
}
|
||||
}
|
||||
|
||||
async fn cached_quota_config_and_incarnation(&self, bucket: &str) -> Result<Option<CachedQuotaMetadataSnapshot>> {
|
||||
let metadata = {
|
||||
let metadata_map = self.metadata_map.read().await;
|
||||
metadata_map.get(bucket).cloned()
|
||||
};
|
||||
let Some(metadata) = metadata else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !metadata.bucket_incarnation_sidecar || metadata.bucket_incarnation_id.is_nil() {
|
||||
return Ok(None);
|
||||
}
|
||||
if !metadata.quota_config_json.is_empty() && metadata.quota_config.is_none() {
|
||||
return Err(Error::other("persisted bucket quota configuration is invalid"));
|
||||
}
|
||||
Ok(Some(CachedQuotaMetadataSnapshot {
|
||||
quota: metadata.quota_config.clone(),
|
||||
bucket_incarnation: metadata.bucket_incarnation_id,
|
||||
quota_revision: metadata.quota_config_updated_at,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn get_replication_config(&self, bucket: &str) -> Result<(ReplicationConfiguration, OffsetDateTime)> {
|
||||
let (bm, _) = self.get_config(bucket).await?;
|
||||
|
||||
@@ -4886,6 +5002,154 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn quota_admission_rejects_stale_cached_no_quota() {
|
||||
let (_dirs, store) = isolated_store_over_temp_disks().await;
|
||||
init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||
let sys = bucket_metadata_sys_of(&store.ctx).expect("scoped metadata system");
|
||||
let bucket = "stale-no-quota-admission";
|
||||
store
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("create bucket");
|
||||
let (stale, _) = sys.read().await.get_config(bucket).await.expect("cache initial metadata");
|
||||
store
|
||||
.update_bucket_metadata_config(bucket, "quota.json", br#"{"quota":1}"#.to_vec())
|
||||
.await
|
||||
.expect("publish quota to authoritative storage");
|
||||
// Model a peer that has not received the newer configuration snapshot.
|
||||
sys.read().await.set(bucket.to_owned(), stale).await;
|
||||
assert!(
|
||||
get_cached_quota_config_and_incarnation_in(&store.ctx, bucket)
|
||||
.await
|
||||
.expect("read stale cache")
|
||||
.expect("cached bucket identity")
|
||||
.quota
|
||||
.is_none()
|
||||
);
|
||||
|
||||
temp_env::async_with_vars([("RUSTFS_QUOTA_BEGIN_SAFE_NO_QUOTA_CACHE_FAST_PATH", Some("true"))], async {
|
||||
let error = crate::bucket::quota::reservation::begin(&store.ctx, bucket, "object", &ObjectOptions::default(), 0, 0)
|
||||
.await
|
||||
.err()
|
||||
.expect("persisted quota must reject admission without its snapshot");
|
||||
assert!(matches!(error, Error::PartMissingOrCorrupt), "{error:?}");
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn peer_reload_clears_negative_cache_after_remote_bucket_creation() {
|
||||
let (dirs, node_a, node_b) = two_nodes_over_one_store().await;
|
||||
let bucket = "cross-node-negative-cache-refresh";
|
||||
let node_b_sys = node_b.read().await.clone();
|
||||
|
||||
assert_eq!(
|
||||
node_b_sys.get_bucket_incarnation_id(bucket).await.unwrap_err(),
|
||||
Error::BucketNotFound(bucket.to_string()),
|
||||
"the probe must populate node B's missing-bucket cache"
|
||||
);
|
||||
|
||||
for dir in &dirs {
|
||||
std::fs::create_dir_all(dir.path().join(bucket)).expect("physical bucket should exist before creation metadata");
|
||||
}
|
||||
let created = BucketMetadata::new(bucket);
|
||||
let created_incarnation = created.bucket_incarnation_id;
|
||||
node_a
|
||||
.read()
|
||||
.await
|
||||
.persist_new_and_set(created)
|
||||
.await
|
||||
.expect("node A should publish the created bucket metadata");
|
||||
|
||||
node_b_sys
|
||||
.reload_from_store(bucket)
|
||||
.await
|
||||
.expect("peer reload should clear the stale negative cache");
|
||||
assert_eq!(
|
||||
node_b_sys.get_bucket_incarnation_id(bucket).await.unwrap(),
|
||||
created_incarnation,
|
||||
"node B should resolve the newly created bucket immediately"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[serial]
|
||||
async fn peer_delete_clears_metadata_for_absent_bucket() {
|
||||
let (_dirs, store) = isolated_store_over_temp_disks().await;
|
||||
init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||
let sys = bucket_metadata_sys_of(&store.ctx).expect("metadata owner");
|
||||
let bucket = "peer-delete-absent-bucket";
|
||||
|
||||
store
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("initial bucket creation");
|
||||
let cached = sys.read().await.get(bucket).await.expect("initial cached metadata");
|
||||
store
|
||||
.delete_bucket(bucket, &DeleteBucketOptions::default())
|
||||
.await
|
||||
.expect("bucket deletion");
|
||||
|
||||
// Model a peer that still caches the metadata after the physical delete.
|
||||
sys.read().await.set(bucket.to_string(), cached).await;
|
||||
assert!(
|
||||
remove_bucket_metadata_if_absent(store.clone(), bucket)
|
||||
.await
|
||||
.expect("conditional peer delete"),
|
||||
"the peer must evict the deleted bucket's cached metadata"
|
||||
);
|
||||
let sys = sys.read().await;
|
||||
assert!(matches!(sys.get(bucket).await, Err(Error::ConfigNotFound)));
|
||||
assert_eq!(
|
||||
sys.get_bucket_incarnation_id(bucket)
|
||||
.await
|
||||
.expect_err("deleted bucket lookup"),
|
||||
Error::BucketNotFound(bucket.to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[serial]
|
||||
async fn delayed_peer_delete_preserves_recreated_bucket_metadata() {
|
||||
let (_dirs, store) = isolated_store_over_temp_disks().await;
|
||||
init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||
let sys = bucket_metadata_sys_of(&store.ctx).expect("metadata owner");
|
||||
let bucket = "delayed-peer-delete-recreated-bucket";
|
||||
|
||||
store
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("initial bucket creation");
|
||||
let old_incarnation = store.bucket_incarnation_id_from_disk(bucket).await.expect("old incarnation");
|
||||
store
|
||||
.delete_bucket(bucket, &DeleteBucketOptions::default())
|
||||
.await
|
||||
.expect("bucket deletion");
|
||||
store
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("bucket recreation");
|
||||
let new_incarnation = store.bucket_incarnation_id_from_disk(bucket).await.expect("new incarnation");
|
||||
assert_ne!(old_incarnation, new_incarnation);
|
||||
|
||||
let removed = remove_bucket_metadata_if_absent(store.clone(), bucket)
|
||||
.await
|
||||
.expect("conditional peer delete");
|
||||
assert!(!removed, "a delayed delete must not remove metadata for a live recreated bucket");
|
||||
assert_eq!(
|
||||
sys.read()
|
||||
.await
|
||||
.get(bucket)
|
||||
.await
|
||||
.expect("recreated metadata should remain cached")
|
||||
.bucket_incarnation_id,
|
||||
new_incarnation,
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[serial]
|
||||
async fn stale_config_request_cannot_mutate_a_recreated_bucket() {
|
||||
|
||||
@@ -273,6 +273,30 @@ where
|
||||
|
||||
let buckets: Vec<String> = buckets_list.into_iter().map(|b| b.name).collect();
|
||||
|
||||
try_migrate_bucket_metadata_for_buckets(store, &buckets).await
|
||||
}
|
||||
|
||||
/// Migrate the already discovered startup candidates without repeating a
|
||||
/// majority-only namespace listing. Every migration write retains its quorum.
|
||||
pub async fn try_migrate_bucket_metadata_for_buckets<S>(store: Arc<S>, buckets: &[String]) -> Result<()>
|
||||
where
|
||||
S: ObjectIO<
|
||||
Error = Error,
|
||||
RangeSpec = HTTPRangeSpec,
|
||||
HeaderMap = HeaderMap,
|
||||
ObjectOptions = ObjectOptions,
|
||||
ObjectInfo = ObjectInfo,
|
||||
GetObjectReader = GetObjectReader,
|
||||
PutObjectReader = PutObjReader,
|
||||
> + ObjectOperations<
|
||||
Error = Error,
|
||||
ObjectInfo = ObjectInfo,
|
||||
ObjectOptions = ObjectOptions,
|
||||
FileInfo = FileInfo,
|
||||
ObjectToDelete = ObjectToDelete,
|
||||
DeletedObject = DeletedObject,
|
||||
>,
|
||||
{
|
||||
if buckets.is_empty() {
|
||||
debug!("No migrating bucket metadata found");
|
||||
return Ok(());
|
||||
|
||||
@@ -419,7 +419,6 @@ pub(crate) struct QuotaContext {
|
||||
quota_revision: Option<OffsetDateTime>,
|
||||
quota_limit: Option<u64>,
|
||||
reservation_protocol: Option<u32>,
|
||||
capability_proof: Option<crate::services::notification_sys::CrossPoolFenceFleetProofToken>,
|
||||
snapshot_admission: Option<QuotaAdmission>,
|
||||
legacy_data_movement: bool,
|
||||
metadata_guard: Option<Arc<NamespaceLockGuard>>,
|
||||
@@ -479,7 +478,6 @@ impl QuotaContext {
|
||||
reservation: reservation.clone(),
|
||||
};
|
||||
let metadata_guard = self.metadata_guard;
|
||||
let capability_proof = self.capability_proof;
|
||||
|
||||
tokio::spawn(async move {
|
||||
reap_stale_reservations(Arc::clone(&store), &ledger_data.bucket, &ledger_data.ledger_object).await?;
|
||||
@@ -526,7 +524,6 @@ impl QuotaContext {
|
||||
ledger: Some(ReservationLedgerData::Single(ledger_data)),
|
||||
operation_guard: Some(operation_guard),
|
||||
metadata_guard,
|
||||
capability_proof,
|
||||
state: ReservationState::Pending,
|
||||
})
|
||||
})
|
||||
@@ -555,7 +552,6 @@ async fn reserve_sharded(context: QuotaContext, old_size: u64, new_size: u64) ->
|
||||
commit_started: false,
|
||||
};
|
||||
let metadata_guard = context.metadata_guard;
|
||||
let capability_proof = context.capability_proof;
|
||||
let data = ShardedReservationData {
|
||||
store: Arc::clone(&store),
|
||||
bucket: context.bucket,
|
||||
@@ -640,7 +636,6 @@ async fn reserve_sharded(context: QuotaContext, old_size: u64, new_size: u64) ->
|
||||
ledger: Some(ReservationLedgerData::Sharded(data)),
|
||||
operation_guard: Some(operation_guard),
|
||||
metadata_guard,
|
||||
capability_proof,
|
||||
state: ReservationState::Pending,
|
||||
});
|
||||
}
|
||||
@@ -725,20 +720,12 @@ impl ReservationLedgerData {
|
||||
Self::Sharded(data) => &data.bucket,
|
||||
}
|
||||
}
|
||||
|
||||
fn error_object(&self) -> &str {
|
||||
match self {
|
||||
Self::Single(data) => &data.ledger_object,
|
||||
Self::Sharded(data) => &data.shard_object,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct QuotaReservation {
|
||||
ledger: Option<ReservationLedgerData>,
|
||||
operation_guard: Option<NamespaceLockGuard>,
|
||||
metadata_guard: Option<Arc<NamespaceLockGuard>>,
|
||||
capability_proof: Option<crate::services::notification_sys::CrossPoolFenceFleetProofToken>,
|
||||
state: ReservationState,
|
||||
}
|
||||
|
||||
@@ -756,7 +743,6 @@ impl QuotaReservation {
|
||||
ledger: None,
|
||||
operation_guard: None,
|
||||
metadata_guard,
|
||||
capability_proof: None,
|
||||
state: ReservationState::Pending,
|
||||
}
|
||||
}
|
||||
@@ -766,17 +752,7 @@ impl QuotaReservation {
|
||||
|| self.metadata_guard.as_ref().is_some_and(|guard| guard.is_lock_lost())
|
||||
}
|
||||
|
||||
pub(crate) fn capability_proof_matches(&self) -> bool {
|
||||
self.capability_proof
|
||||
.as_ref()
|
||||
.is_none_or(crate::services::notification_sys::cross_pool_fence_fleet_proof_matches)
|
||||
}
|
||||
|
||||
pub(crate) async fn mark_commit_started(&mut self) -> Result<()> {
|
||||
if !self.capability_proof_matches() {
|
||||
let ledger = self.ledger.as_ref().ok_or(StorageError::PartMissingOrCorrupt)?;
|
||||
return Err(quota_capability_error(ledger.bucket(), ledger.error_object()));
|
||||
}
|
||||
if let Some(ledger) = self.ledger.as_ref() {
|
||||
mark_commit_started(ledger).await?;
|
||||
}
|
||||
@@ -850,6 +826,11 @@ pub(crate) async fn begin(
|
||||
let snapshot_admission = opts.quota_admission;
|
||||
let data_movement = opts.data_movement;
|
||||
if crate::bucket::utils::is_meta_bucketname(bucket) {
|
||||
let meta_bucket_fast_path_started = rustfs_io_metrics::put_stage_timer();
|
||||
rustfs_io_metrics::record_put_object_stage_duration_from(
|
||||
rustfs_io_metrics::PUT_STAGE_PUT_OBJECT_QUOTA_BEGIN_META_BUCKET_FAST_PATH,
|
||||
meta_bucket_fast_path_started,
|
||||
);
|
||||
return Ok(QuotaContext {
|
||||
store: None,
|
||||
bucket: bucket.to_string(),
|
||||
@@ -859,7 +840,6 @@ pub(crate) async fn begin(
|
||||
quota_revision: None,
|
||||
quota_limit: None,
|
||||
reservation_protocol: None,
|
||||
capability_proof: None,
|
||||
snapshot_admission: None,
|
||||
legacy_data_movement: false,
|
||||
metadata_guard: None,
|
||||
@@ -878,7 +858,6 @@ pub(crate) async fn begin(
|
||||
quota_revision: None,
|
||||
quota_limit: Some(snapshot_admission.quota_limit()),
|
||||
reservation_protocol: None,
|
||||
capability_proof: None,
|
||||
snapshot_admission: Some(snapshot_admission),
|
||||
legacy_data_movement: false,
|
||||
metadata_guard: None,
|
||||
@@ -897,7 +876,6 @@ pub(crate) async fn begin(
|
||||
quota_revision: None,
|
||||
quota_limit: None,
|
||||
reservation_protocol: None,
|
||||
capability_proof: None,
|
||||
snapshot_admission: None,
|
||||
legacy_data_movement: false,
|
||||
metadata_guard: None,
|
||||
@@ -906,9 +884,63 @@ pub(crate) async fn begin(
|
||||
});
|
||||
}
|
||||
|
||||
let metadata_lock_started = rustfs_io_metrics::put_stage_timer();
|
||||
let metadata_guard = metadata_sys::acquire_bucket_metadata_transaction_read_lock_for_options_in(ctx, bucket, opts).await?;
|
||||
rustfs_io_metrics::record_put_object_stage_duration_from(
|
||||
rustfs_io_metrics::PUT_STAGE_PUT_OBJECT_QUOTA_BEGIN_METADATA_LOCK,
|
||||
metadata_lock_started,
|
||||
);
|
||||
let cache_lookup_started = rustfs_io_metrics::put_stage_timer();
|
||||
let cached_quota_snapshot = if rustfs_io_metrics::put_stage_metrics_enabled() {
|
||||
match metadata_sys::get_cached_quota_config_and_incarnation_in(ctx, bucket).await {
|
||||
Ok(Some(snapshot)) => {
|
||||
rustfs_io_metrics::record_put_object_quota_cache_decision(
|
||||
"candidate",
|
||||
quota_cache_reason(snapshot.quota.as_ref()),
|
||||
);
|
||||
Some(snapshot)
|
||||
}
|
||||
Ok(None) => {
|
||||
rustfs_io_metrics::record_put_object_quota_cache_decision("miss", "no_authoritative_cache");
|
||||
None
|
||||
}
|
||||
Err(_) => {
|
||||
rustfs_io_metrics::record_put_object_quota_cache_decision("miss", "invalid_cached_quota");
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
rustfs_io_metrics::record_put_object_stage_duration_from(
|
||||
rustfs_io_metrics::PUT_STAGE_PUT_OBJECT_QUOTA_BEGIN_CACHE_LOOKUP,
|
||||
cache_lookup_started,
|
||||
);
|
||||
let config_read_started = rustfs_io_metrics::put_stage_timer();
|
||||
let (quota, bucket_incarnation, quota_revision) =
|
||||
metadata_sys::get_quota_config_and_incarnation_from_disk_for_options_in(ctx, bucket, opts, &metadata_guard).await?;
|
||||
if let Some(cached) = cached_quota_snapshot.as_ref() {
|
||||
let reason = quota_cache_reason(quota.as_ref());
|
||||
if cached.quota == quota && cached.bucket_incarnation == bucket_incarnation && cached.quota_revision == quota_revision {
|
||||
rustfs_io_metrics::record_put_object_quota_cache_decision("authoritative_match", reason);
|
||||
} else {
|
||||
rustfs_io_metrics::record_put_object_quota_cache_decision("authoritative_mismatch", reason);
|
||||
if cached.quota != quota {
|
||||
rustfs_io_metrics::record_put_object_quota_cache_decision("authoritative_mismatch_field", "quota");
|
||||
}
|
||||
if cached.bucket_incarnation != bucket_incarnation {
|
||||
rustfs_io_metrics::record_put_object_quota_cache_decision("authoritative_mismatch_field", "bucket_incarnation");
|
||||
}
|
||||
if cached.quota_revision != quota_revision {
|
||||
rustfs_io_metrics::record_put_object_quota_cache_decision("authoritative_mismatch_field", "quota_revision");
|
||||
}
|
||||
}
|
||||
}
|
||||
rustfs_io_metrics::record_put_object_stage_duration_from(
|
||||
rustfs_io_metrics::PUT_STAGE_PUT_OBJECT_QUOTA_BEGIN_CONFIG_READ,
|
||||
config_read_started,
|
||||
);
|
||||
let lock_lost_check_started = rustfs_io_metrics::put_stage_timer();
|
||||
if metadata_guard.is_lock_lost() {
|
||||
return Err(StorageError::NamespaceLockQuorumUnavailable {
|
||||
mode: "quota_config",
|
||||
@@ -918,6 +950,11 @@ pub(crate) async fn begin(
|
||||
achieved: 0,
|
||||
});
|
||||
}
|
||||
rustfs_io_metrics::record_put_object_stage_duration_from(
|
||||
rustfs_io_metrics::PUT_STAGE_PUT_OBJECT_QUOTA_BEGIN_LOCK_LOST_CHECK,
|
||||
lock_lost_check_started,
|
||||
);
|
||||
let policy_evaluate_started = rustfs_io_metrics::put_stage_timer();
|
||||
if quota
|
||||
.as_ref()
|
||||
.is_some_and(|quota| quota.has_unsupported_reservation_protocol())
|
||||
@@ -926,15 +963,15 @@ pub(crate) async fn begin(
|
||||
return Err(StorageError::PartMissingOrCorrupt);
|
||||
}
|
||||
let durable_quota = quota.as_ref().filter(|quota| quota.uses_durable_reservations());
|
||||
let capability_proof = if durable_quota.is_some() {
|
||||
Some(
|
||||
crate::services::notification_sys::acquire_cross_pool_fence_fleet_proof()
|
||||
.ok_or_else(|| quota_capability_error(bucket, &ledger_object(bucket)))?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
rustfs_io_metrics::record_put_object_stage_duration_from(
|
||||
rustfs_io_metrics::PUT_STAGE_PUT_OBJECT_QUOTA_BEGIN_POLICY_EVALUATE,
|
||||
policy_evaluate_started,
|
||||
);
|
||||
// Durable writes are fenced by a write quorum of participating disks that
|
||||
// enforce the configured topology. Fleet-wide proof remains a configuration gate;
|
||||
// losing an unrelated peer must not revoke an admitted object write.
|
||||
let durable_quota_limit = durable_quota.and_then(|quota| quota.quota);
|
||||
let snapshot_admission_started = rustfs_io_metrics::put_stage_timer();
|
||||
let reservation_protocol = durable_quota.and_then(|quota| quota.reservation_protocol);
|
||||
let snapshot_admission = match quota.as_ref().filter(|quota| !quota.uses_durable_reservations()) {
|
||||
Some(quota) => match (quota.quota, snapshot_admission) {
|
||||
@@ -954,6 +991,10 @@ pub(crate) async fn begin(
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
rustfs_io_metrics::record_put_object_stage_duration_from(
|
||||
rustfs_io_metrics::PUT_STAGE_PUT_OBJECT_QUOTA_BEGIN_SNAPSHOT_ADMISSION,
|
||||
snapshot_admission_started,
|
||||
);
|
||||
let legacy_data_movement = durable_quota_limit.is_none()
|
||||
&& quota.as_ref().and_then(|quota| quota.quota).is_some()
|
||||
&& snapshot_admission.is_none()
|
||||
@@ -965,11 +1006,21 @@ pub(crate) async fn begin(
|
||||
.then(|| quota.as_ref().and_then(|quota| quota.quota))
|
||||
.flatten()
|
||||
});
|
||||
let object_store_lookup_started = rustfs_io_metrics::put_stage_timer();
|
||||
let store = if durable_quota_limit.is_some() {
|
||||
Some(metadata_sys::object_store_in(ctx).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
rustfs_io_metrics::record_put_object_stage_duration_from(
|
||||
rustfs_io_metrics::PUT_STAGE_PUT_OBJECT_QUOTA_BEGIN_OBJECT_STORE_LOOKUP,
|
||||
object_store_lookup_started,
|
||||
);
|
||||
let context_build_started = rustfs_io_metrics::put_stage_timer();
|
||||
rustfs_io_metrics::record_put_object_stage_duration_from(
|
||||
rustfs_io_metrics::PUT_STAGE_PUT_OBJECT_QUOTA_BEGIN_CONTEXT_BUILD,
|
||||
context_build_started,
|
||||
);
|
||||
Ok(QuotaContext {
|
||||
store,
|
||||
bucket: bucket.to_string(),
|
||||
@@ -979,7 +1030,6 @@ pub(crate) async fn begin(
|
||||
quota_revision: Some(quota_revision),
|
||||
quota_limit,
|
||||
reservation_protocol,
|
||||
capability_proof,
|
||||
snapshot_admission,
|
||||
legacy_data_movement,
|
||||
metadata_guard: Some(metadata_guard),
|
||||
@@ -988,13 +1038,12 @@ pub(crate) async fn begin(
|
||||
})
|
||||
}
|
||||
|
||||
fn quota_capability_error(bucket: &str, object: &str) -> StorageError {
|
||||
StorageError::NamespaceLockQuorumUnavailable {
|
||||
mode: "quota_capability",
|
||||
bucket: bucket.to_string(),
|
||||
object: object.to_string(),
|
||||
required: 1,
|
||||
achieved: 0,
|
||||
fn quota_cache_reason(quota: Option<&crate::bucket::quota::BucketQuota>) -> &'static str {
|
||||
match quota {
|
||||
None => "no_quota",
|
||||
Some(quota) if quota.uses_durable_reservations() => "durable_quota",
|
||||
Some(quota) if quota.quota.is_some() => "snapshot_quota",
|
||||
Some(_) => "quota_without_limit",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3383,7 +3383,7 @@ async fn queue_replicate_deletes(batch: ReplicationHealResyncDeletes) -> Replica
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::replication_filemeta_boundary::ReplicateTargetDecision;
|
||||
use super::super::replication_filemeta_boundary::{ReplicateTargetDecision, ReplicationState, VersionPurgeStatusType};
|
||||
use super::super::replication_resync_boundary::{decode_mrf_file, encode_mrf_file, encode_resync_file};
|
||||
use super::super::replication_storage_boundary::{
|
||||
DeletedObject, FileInfo, GetObjectReader, HTTPRangeSpec, ListOperations, ObjectIO, ObjectOperations, PutObjReader,
|
||||
@@ -3420,6 +3420,7 @@ mod tests {
|
||||
hold_first_read: AtomicBool,
|
||||
allow_first_read: Notify,
|
||||
read_count: AtomicUsize,
|
||||
delete_storage_calls: AtomicUsize,
|
||||
reported_size: StdMutex<Option<i64>>,
|
||||
stream_read_bytes: Arc<AtomicUsize>,
|
||||
write_count: AtomicUsize,
|
||||
@@ -3628,6 +3629,7 @@ mod tests {
|
||||
_object: &str,
|
||||
_opts: &Self::ObjectOptions,
|
||||
) -> Result<Self::ObjectInfo, Self::Error> {
|
||||
self.shared.delete_storage_calls.fetch_add(1, Ordering::SeqCst);
|
||||
Err(EcstoreError::NotImplemented)
|
||||
}
|
||||
|
||||
@@ -3669,6 +3671,7 @@ mod tests {
|
||||
_object: &str,
|
||||
_opts: Self::ObjectOptions,
|
||||
) -> Result<Self::ObjectInfo, Self::Error> {
|
||||
self.shared.delete_storage_calls.fetch_add(1, Ordering::SeqCst);
|
||||
Err(EcstoreError::NotImplemented)
|
||||
}
|
||||
|
||||
@@ -3790,6 +3793,7 @@ mod tests {
|
||||
type NamespaceLock = rustfs_lock::NamespaceLockWrapper;
|
||||
|
||||
async fn new_ns_lock(&self, bucket: &str, object: &str) -> Result<Self::NamespaceLock, Self::Error> {
|
||||
self.shared.delete_storage_calls.fetch_add(1, Ordering::SeqCst);
|
||||
let lock =
|
||||
rustfs_lock::NamespaceLock::with_local_manager("load-resync-test".to_string(), self.shared.lock_manager.clone());
|
||||
Ok(rustfs_lock::NamespaceLockWrapper::new(
|
||||
@@ -4166,6 +4170,7 @@ mod tests {
|
||||
hold_first_read: AtomicBool::new(false),
|
||||
allow_first_read: Notify::new(),
|
||||
read_count: AtomicUsize::new(0),
|
||||
delete_storage_calls: AtomicUsize::new(0),
|
||||
reported_size: StdMutex::new(None),
|
||||
stream_read_bytes: Arc::new(AtomicUsize::new(0)),
|
||||
write_count: AtomicUsize::new(0),
|
||||
@@ -4177,6 +4182,106 @@ mod tests {
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_worker_empty_admission_skips_storage() {
|
||||
let mut declined = ReplicateDecision::default();
|
||||
declined.set(ReplicateTargetDecision::new("arn:test".to_string(), false, false));
|
||||
for state in [
|
||||
None,
|
||||
Some(ReplicationState::default()),
|
||||
Some(ReplicationState {
|
||||
replicate_decision_str: declined.to_string(),
|
||||
..Default::default()
|
||||
}),
|
||||
] {
|
||||
for delete_marker in [false, true] {
|
||||
let shared = empty_resync_shared_state();
|
||||
let storage = Arc::new(LoadResyncNodeStore::new("empty-delete", shared.clone()));
|
||||
let version_id = Uuid::new_v4();
|
||||
let delete = DeletedObjectReplicationInfo {
|
||||
bucket: "no-replication".to_string(),
|
||||
delete_object: ReplicationDeletedObject {
|
||||
object_name: "expired-version".to_string(),
|
||||
version_id: (!delete_marker).then_some(version_id),
|
||||
delete_marker_version_id: delete_marker.then_some(version_id),
|
||||
delete_marker,
|
||||
replication_state: state.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let completed = replicate_delete_with_outcome(delete, storage).await;
|
||||
assert_eq!(
|
||||
shared.delete_storage_calls.load(Ordering::SeqCst),
|
||||
0,
|
||||
"empty admission must not lock, read, or write storage (marker={delete_marker}, state={state:?})"
|
||||
);
|
||||
assert!(completed, "an empty delete is a completed no-op");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_worker_preserves_persisted_admission_without_live_config() {
|
||||
let arn = "arn:test";
|
||||
for state in [
|
||||
ReplicationState {
|
||||
replicate_decision_str: test_replicate_decision(&[arn]).to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
ReplicationState {
|
||||
targets: HashMap::from([(arn.to_string(), ReplicationStatusType::Pending)]),
|
||||
..Default::default()
|
||||
},
|
||||
ReplicationState {
|
||||
purge_targets: HashMap::from([(arn.to_string(), VersionPurgeStatusType::Pending)]),
|
||||
..Default::default()
|
||||
},
|
||||
] {
|
||||
let shared = empty_resync_shared_state();
|
||||
let storage = Arc::new(LoadResyncNodeStore::new("pending-delete", shared.clone()));
|
||||
let delete = DeletedObjectReplicationInfo {
|
||||
bucket: "no-live-config".to_string(),
|
||||
delete_object: ReplicationDeletedObject {
|
||||
object_name: "pending-version".to_string(),
|
||||
version_id: Some(Uuid::new_v4()),
|
||||
replication_state: Some(state),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
!replicate_delete_with_outcome(delete, storage).await,
|
||||
"the injected storage failure must retain admitted work for retry"
|
||||
);
|
||||
assert!(shared.delete_storage_calls.load(Ordering::SeqCst) > 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_worker_rejects_malformed_empty_admission() {
|
||||
let shared = empty_resync_shared_state();
|
||||
let storage = Arc::new(LoadResyncNodeStore::new("invalid-delete", shared.clone()));
|
||||
let delete = DeletedObjectReplicationInfo {
|
||||
bucket: "no-replication".to_string(),
|
||||
delete_object: ReplicationDeletedObject {
|
||||
object_name: "invalid-decision".to_string(),
|
||||
version_id: Some(Uuid::new_v4()),
|
||||
replication_state: Some(ReplicationState {
|
||||
replicate_decision_str: "invalid".to_string(),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
!replicate_delete_with_outcome(delete, storage).await,
|
||||
"malformed decisions must not be acknowledged as empty work"
|
||||
);
|
||||
assert_eq!(shared.delete_storage_calls.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn durable_resync_read_observes_cancellation_after_cached_pending_intent() {
|
||||
let bucket = "startup-cancel-race";
|
||||
@@ -5198,6 +5303,7 @@ mod tests {
|
||||
hold_first_read: AtomicBool::new(false),
|
||||
allow_first_read: Notify::new(),
|
||||
read_count: AtomicUsize::new(0),
|
||||
delete_storage_calls: AtomicUsize::new(0),
|
||||
reported_size: StdMutex::new(None),
|
||||
stream_read_bytes: Arc::new(AtomicUsize::new(0)),
|
||||
write_count: AtomicUsize::new(0),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -37,8 +37,9 @@ use time::OffsetDateTime;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
|
||||
pub(crate) use crate::bucket::bucket_target_sys::{
|
||||
AdvancedPutOptions, HeadObjectSdkError, PutObjectOptions, PutObjectPartOptions, RemotePutObjectResponse, RemoveObjectOptions,
|
||||
ReplicaLocation, S3ClientError, TargetClient, resolve_read_api_version_id,
|
||||
AdvancedPutOptions, EncryptedReplicaShape, HeadObjectSdkError, PutObjectOptions, PutObjectPartOptions,
|
||||
RemotePutObjectResponse, RemoveObjectOptions, ReplicaCandidates, ReplicaEvidence, ReplicaIdentity, ReplicaLocation,
|
||||
S3ClientError, TargetClient, resolve_read_api_version_id,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use crate::bucket::target::BucketTarget;
|
||||
@@ -157,6 +158,19 @@ pub(crate) fn replication_object_is_ssec_encrypted(user_defined: &HashMap<String
|
||||
rustfs_replication::is_ssec_encrypted(user_defined)
|
||||
}
|
||||
|
||||
/// Whether the stored ETag of a replication source can be compared with the
|
||||
/// ETag a target returns for the bytes it received. Only an unencrypted source
|
||||
/// stores the MD5 of the bytes that go on the wire. A managed-SSE (SSE-S3 /
|
||||
/// SSE-KMS) or SSE-C source stores a keyed fingerprint (new writes) or a legacy
|
||||
/// plaintext MD5, and the target re-encrypts what it receives, so neither
|
||||
/// equality nor inequality of the two ETags proves anything about the replica.
|
||||
pub(crate) fn replication_source_etag_is_comparable(user_defined: &HashMap<String, String>) -> bool {
|
||||
matches!(
|
||||
classify_replication_source_encryption(user_defined),
|
||||
ReplicationSourceEncryption::Plaintext
|
||||
)
|
||||
}
|
||||
|
||||
/// HeadObjectOutput adapter over the pure SSE-C passthrough evidence
|
||||
/// judgment owned by `rustfs-replication`: extract the echoed
|
||||
/// customer-algorithm header and let the crate-owned policy decide.
|
||||
@@ -578,6 +592,22 @@ mod tests {
|
||||
use time::Duration;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[test]
|
||||
fn replication_source_etag_is_comparable_only_for_plaintext_sources() {
|
||||
assert!(replication_source_etag_is_comparable(&HashMap::new()));
|
||||
for (key, value) in [
|
||||
(metadata_keys::SERVER_SIDE_ENCRYPTION, "AES256"),
|
||||
(metadata_keys::SERVER_SIDE_ENCRYPTION, "aws:kms"),
|
||||
(SSEC_ALGORITHM_HEADER, "AES256"),
|
||||
] {
|
||||
let metadata = HashMap::from([(key.to_string(), value.to_string())]);
|
||||
assert!(
|
||||
!replication_source_etag_is_comparable(&metadata),
|
||||
"{key}={value} must not expose its stored ETag to the replica audit"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// backlog#1735 A3b SSE key-case finding. Every RustFS writer persists the
|
||||
/// SSE intent as lowercase `x-amz-server-side-encryption`
|
||||
/// (`encryption_material_to_metadata`; header-derived keys come from a
|
||||
|
||||
+222
-127
@@ -15,6 +15,7 @@
|
||||
use crate::disk::{MIGRATING_META_BUCKET, RUSTFS_META_BUCKET};
|
||||
use crate::error::{Error, Result, StorageError};
|
||||
use regex::Regex;
|
||||
use rustfs_utils::WindowsFileNameError;
|
||||
use rustfs_utils::path::SLASH_SEPARATOR;
|
||||
use s3s::xml;
|
||||
use tracing::instrument;
|
||||
@@ -178,8 +179,7 @@ pub fn is_valid_object_name(object: &str) -> bool {
|
||||
dead_code,
|
||||
reason = "live on Windows: callers sit inside the #[cfg(target_os = \"windows\")] block in check_object_name_for_length_and_slash (backlog#1823)"
|
||||
)]
|
||||
pub const WINDOWS_RESERVED_CHARACTERS_REASON: &str =
|
||||
"object key contains characters unsupported on Windows hosts (one of ':', '*', '?', '\"', '|', '<', '>')";
|
||||
pub const WINDOWS_RESERVED_CHARACTERS_REASON: &str = "object key contains characters unsupported on Windows hosts (one of ':', '*', '?', '\"', '|', '<', '>', or a control character)";
|
||||
|
||||
/// Client-facing reason for path segments Windows can store but not address
|
||||
/// afterwards (issue #3449): trailing dot/space or reserved DOS device names.
|
||||
@@ -187,37 +187,46 @@ pub const WINDOWS_RESERVED_CHARACTERS_REASON: &str =
|
||||
dead_code,
|
||||
reason = "live on Windows: callers sit inside the #[cfg(target_os = \"windows\")] block in check_object_name_for_length_and_slash (backlog#1823)"
|
||||
)]
|
||||
pub const WINDOWS_RESERVED_SEGMENT_REASON: &str = "object key contains a path segment unsupported on Windows hosts (trailing dot or space, or a reserved device name such as NUL/CON/COM1)";
|
||||
pub const WINDOWS_RESERVED_SEGMENT_REASON: &str = "object key contains a path segment unsupported on Windows hosts (trailing dot or space, or a reserved device name such as NUL/CON/COM1/CONIN$)";
|
||||
|
||||
/// Reserved DOS device names that shadow regular files on Windows, even when
|
||||
/// an extension is appended (e.g. `NUL.txt` resolves to the `NUL` device).
|
||||
/// Client-facing reason for keys containing `\`: Windows hosts treat it as a
|
||||
/// path separator, so `a\b` would land on the same file as `a/b`.
|
||||
pub const WINDOWS_BACKSLASH_REASON: &str =
|
||||
"object key contains '\\', which Windows hosts treat as a path separator (it would alias the same key spelled with '/')";
|
||||
|
||||
/// Returns the client-facing reason when `object` cannot round-trip through
|
||||
/// the Win32 path layer, or `None` when it can.
|
||||
///
|
||||
/// Each segment is checked with [`rustfs_utils::check_windows_file_name`].
|
||||
/// `\` splits segments here, like on a Windows disk, so this check never
|
||||
/// rejects it: an existing `a\b` key must stay readable and deletable. New
|
||||
/// writes reject `\` separately in [`check_new_object_args`].
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "live on Windows: callers sit inside the #[cfg(target_os = \"windows\")] block in check_object_name_for_length_and_slash (backlog#1823)"
|
||||
)]
|
||||
const WINDOWS_RESERVED_NAMES: &[&str] = &[
|
||||
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "LPT1", "LPT2", "LPT3",
|
||||
"LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
|
||||
];
|
||||
pub fn windows_object_key_violation(object: &str) -> Option<&'static str> {
|
||||
// A NUL byte is rejected on every platform as ObjectNameInvalid by
|
||||
// is_valid_object_prefix; keep that answer on Windows too.
|
||||
if object.contains('\0') {
|
||||
return None;
|
||||
}
|
||||
|
||||
/// Returns true when `object` contains a path segment that NTFS can store but
|
||||
/// the Win32 API cannot address afterwards (issue #3449): segments ending in a
|
||||
/// dot or a space, and reserved DOS device names — bare or with an extension
|
||||
/// (`NUL.txt`), matching classic Win32 path resolution semantics.
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "live on Windows: callers sit inside the #[cfg(target_os = \"windows\")] block in check_object_name_for_length_and_slash (backlog#1823)"
|
||||
)]
|
||||
pub fn object_name_has_windows_incompatible_segment(object: &str) -> bool {
|
||||
object.split(['/', '\\']).any(|segment| {
|
||||
if segment.ends_with('.') || segment.ends_with(' ') {
|
||||
return true;
|
||||
let mut segment_violation = false;
|
||||
for segment in object.split(['/', '\\']).filter(|segment| !segment.is_empty()) {
|
||||
match rustfs_utils::check_windows_file_name(segment) {
|
||||
Err(WindowsFileNameError::ReservedCharacter | WindowsFileNameError::ControlCharacter) => {
|
||||
return Some(WINDOWS_RESERVED_CHARACTERS_REASON);
|
||||
}
|
||||
Err(WindowsFileNameError::TrailingDotOrSpace | WindowsFileNameError::ReservedDeviceName) => {
|
||||
segment_violation = true;
|
||||
}
|
||||
// Segment length is enforced in bytes by object_key_segments_fit_on_disk,
|
||||
// which is stricter than the UTF-16 limit and answers ObjectNameInvalid.
|
||||
Ok(()) | Err(WindowsFileNameError::TooLong | WindowsFileNameError::Empty) => {}
|
||||
}
|
||||
// Device names are matched on the part before the first dot, with
|
||||
// trailing spaces ignored (so `NUL .txt` is also reserved).
|
||||
let base = segment.split('.').next().unwrap_or(segment).trim_end_matches(' ');
|
||||
WINDOWS_RESERVED_NAMES.iter().any(|name| base.eq_ignore_ascii_case(name))
|
||||
})
|
||||
}
|
||||
segment_violation.then_some(WINDOWS_RESERVED_SEGMENT_REASON)
|
||||
}
|
||||
|
||||
pub fn check_object_name_for_length_and_slash(bucket: &str, object: &str) -> Result<()> {
|
||||
@@ -229,33 +238,33 @@ pub fn check_object_name_for_length_and_slash(bucket: &str, object: &str) -> Res
|
||||
return Err(StorageError::ObjectNamePrefixAsSlash(bucket.to_owned(), object.to_owned()));
|
||||
}
|
||||
|
||||
// Reject names that NTFS would refuse, or create but the Win32 path layer
|
||||
// cannot read back (os error 3), e.g. `a:b`, `baddir.` or `NUL.txt`.
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if object.contains(':')
|
||||
|| object.contains('*')
|
||||
|| object.contains('?')
|
||||
|| object.contains('"')
|
||||
|| object.contains('|')
|
||||
|| object.contains('<')
|
||||
|| object.contains('>')
|
||||
// || object.contains('\\')
|
||||
{
|
||||
return Err(StorageError::InvalidArgument(
|
||||
bucket.to_owned(),
|
||||
object.to_owned(),
|
||||
WINDOWS_RESERVED_CHARACTERS_REASON.to_owned(),
|
||||
));
|
||||
}
|
||||
if let Some(reason) = windows_object_key_violation(object) {
|
||||
return Err(StorageError::InvalidArgument(bucket.to_owned(), object.to_owned(), reason.to_owned()));
|
||||
}
|
||||
|
||||
// Reject names that NTFS would happily create but the Win32 path
|
||||
// layer cannot read back (os error 3), e.g. `baddir.` or `NUL.txt`.
|
||||
if object_name_has_windows_incompatible_segment(object) {
|
||||
return Err(StorageError::InvalidArgument(
|
||||
bucket.to_owned(),
|
||||
object.to_owned(),
|
||||
WINDOWS_RESERVED_SEGMENT_REASON.to_owned(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Admission check for every request that introduces `object` into the
|
||||
/// namespace: PutObject, a CopyObject destination, and CreateMultipartUpload.
|
||||
/// Sharing one entry keeps their rejections (and S3 error codes) identical.
|
||||
///
|
||||
/// `data_movement` writes (decommission, rebalance) relocate keys that already
|
||||
/// exist, so they skip the new-key-only rules and stay able to move old data.
|
||||
/// Internal meta buckets are exempt too: their keys are produced by RustFS
|
||||
/// itself and resolve to the same path either way.
|
||||
pub fn check_new_object_args(bucket: &str, object: &str, data_movement: bool) -> Result<()> {
|
||||
check_put_object_args(bucket, object)?;
|
||||
|
||||
if cfg!(target_os = "windows") && !data_movement && !is_meta_bucketname(bucket) && object.contains('\\') {
|
||||
return Err(StorageError::InvalidArgument(
|
||||
bucket.to_owned(),
|
||||
object.to_owned(),
|
||||
WINDOWS_BACKSLASH_REASON.to_owned(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -316,10 +325,6 @@ pub fn check_bucket_and_object_names(bucket: &str, object: &str) -> Result<()> {
|
||||
return Err(StorageError::ObjectNameInvalid(bucket.to_string(), object.to_string()));
|
||||
}
|
||||
|
||||
// if cfg!(target_os = "windows") && object.contains('\\') {
|
||||
// return Err(StorageError::ObjectNameInvalid(bucket.to_string(), object.to_string()));
|
||||
// }
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -586,103 +591,193 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_object_name_has_windows_incompatible_segment() {
|
||||
fn test_windows_object_key_violation_segments() {
|
||||
let segment = Some(WINDOWS_RESERVED_SEGMENT_REASON);
|
||||
|
||||
// Segments ending in a dot are writable on NTFS but unreadable via Win32 paths.
|
||||
assert!(object_name_has_windows_incompatible_segment("baddir."));
|
||||
assert!(object_name_has_windows_incompatible_segment("dir1/dir2/baddir."));
|
||||
assert!(object_name_has_windows_incompatible_segment("dir1/baddir./file.txt"));
|
||||
assert!(object_name_has_windows_incompatible_segment("file.txt."));
|
||||
assert_eq!(windows_object_key_violation("baddir."), segment);
|
||||
assert_eq!(windows_object_key_violation("dir1/dir2/baddir."), segment);
|
||||
assert_eq!(windows_object_key_violation("dir1/baddir./file.txt"), segment);
|
||||
assert_eq!(windows_object_key_violation("file.txt."), segment);
|
||||
|
||||
// Segments ending in a space.
|
||||
assert!(object_name_has_windows_incompatible_segment("file.txt "));
|
||||
assert!(object_name_has_windows_incompatible_segment("dir /file.txt"));
|
||||
assert_eq!(windows_object_key_violation("file.txt "), segment);
|
||||
assert_eq!(windows_object_key_violation("dir /file.txt"), segment);
|
||||
|
||||
// Reserved DOS device names, bare and case-insensitive.
|
||||
assert!(object_name_has_windows_incompatible_segment("NUL"));
|
||||
assert!(object_name_has_windows_incompatible_segment("nul"));
|
||||
assert!(object_name_has_windows_incompatible_segment("CON"));
|
||||
assert!(object_name_has_windows_incompatible_segment("prn"));
|
||||
assert!(object_name_has_windows_incompatible_segment("Aux"));
|
||||
assert!(object_name_has_windows_incompatible_segment("COM1"));
|
||||
assert!(object_name_has_windows_incompatible_segment("com9"));
|
||||
assert!(object_name_has_windows_incompatible_segment("LPT1"));
|
||||
assert!(object_name_has_windows_incompatible_segment("lpt9"));
|
||||
assert!(object_name_has_windows_incompatible_segment("dir/NUL/file.txt"));
|
||||
for object in [
|
||||
"NUL",
|
||||
"nul",
|
||||
"CON",
|
||||
"prn",
|
||||
"Aux",
|
||||
"COM1",
|
||||
"com9",
|
||||
"LPT1",
|
||||
"lpt9",
|
||||
"dir/NUL/file.txt",
|
||||
// Superscript port numbers and console aliases are devices too.
|
||||
"COM\u{b9}",
|
||||
"dir/lpt\u{b3}",
|
||||
"CONIN$",
|
||||
"conout$",
|
||||
] {
|
||||
assert_eq!(windows_object_key_violation(object), segment, "{object:?}");
|
||||
}
|
||||
|
||||
// Reserved device names with an extension still resolve to the device.
|
||||
assert!(object_name_has_windows_incompatible_segment("NUL.txt"));
|
||||
assert!(object_name_has_windows_incompatible_segment("dir/aux.log"));
|
||||
assert!(object_name_has_windows_incompatible_segment("con.tar.gz"));
|
||||
assert!(object_name_has_windows_incompatible_segment("com1.dat"));
|
||||
assert!(object_name_has_windows_incompatible_segment("NUL .txt"));
|
||||
for object in [
|
||||
"NUL.txt",
|
||||
"dir/aux.log",
|
||||
"con.tar.gz",
|
||||
"com1.dat",
|
||||
"NUL .txt",
|
||||
"COM\u{b2}.log",
|
||||
] {
|
||||
assert_eq!(windows_object_key_violation(object), segment, "{object:?}");
|
||||
}
|
||||
|
||||
// Backslash-separated segments are checked as well.
|
||||
assert!(object_name_has_windows_incompatible_segment("dir\\baddir.\\file.txt"));
|
||||
assert!(object_name_has_windows_incompatible_segment("dir\\nul"));
|
||||
assert_eq!(windows_object_key_violation("dir\\baddir.\\file.txt"), segment);
|
||||
assert_eq!(windows_object_key_violation("dir\\nul"), segment);
|
||||
|
||||
// Valid names must not be flagged.
|
||||
assert!(!object_name_has_windows_incompatible_segment("file.txt"));
|
||||
assert!(!object_name_has_windows_incompatible_segment("dir.name/file"));
|
||||
assert!(!object_name_has_windows_incompatible_segment("path/to/file.tar.gz"));
|
||||
assert!(!object_name_has_windows_incompatible_segment("nullable"));
|
||||
assert!(!object_name_has_windows_incompatible_segment("CONSOLE"));
|
||||
assert!(!object_name_has_windows_incompatible_segment("com10"));
|
||||
assert!(!object_name_has_windows_incompatible_segment("com0"));
|
||||
assert!(!object_name_has_windows_incompatible_segment("lpt"));
|
||||
assert!(!object_name_has_windows_incompatible_segment("aux-data/file"));
|
||||
assert!(!object_name_has_windows_incompatible_segment("object with spaces inside"));
|
||||
assert!(!object_name_has_windows_incompatible_segment(""));
|
||||
for object in [
|
||||
"file.txt",
|
||||
"dir.name/file",
|
||||
"path/to/file.tar.gz",
|
||||
"nullable",
|
||||
"CONSOLE",
|
||||
"com10",
|
||||
"com0",
|
||||
"lpt",
|
||||
"CONIN",
|
||||
"aux-data/file",
|
||||
"object with spaces inside",
|
||||
"dir/",
|
||||
"",
|
||||
] {
|
||||
assert_eq!(windows_object_key_violation(object), None, "{object:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_object_name_windows_incompatible_segments() {
|
||||
// Rejected on Windows (would be written but unreadable, issue #3449);
|
||||
// valid on non-Windows platforms.
|
||||
for object in [
|
||||
"baddir.",
|
||||
"dir1/dir2/baddir.",
|
||||
"dir1/baddir./file.txt",
|
||||
"file.txt ",
|
||||
"NUL",
|
||||
"nul.txt",
|
||||
"COM1",
|
||||
"dir/LPT9.log",
|
||||
] {
|
||||
let result = check_object_name_for_length_and_slash("test-bucket", object);
|
||||
if cfg!(target_os = "windows") {
|
||||
assert!(
|
||||
matches!(&result, Err(StorageError::InvalidArgument(_, _, reason)) if reason == WINDOWS_RESERVED_SEGMENT_REASON),
|
||||
"object name must be rejected on Windows with a descriptive reason: {object:?}, got {result:?}"
|
||||
);
|
||||
} else {
|
||||
assert!(result.is_ok(), "object name must remain valid on non-Windows: {object:?}");
|
||||
}
|
||||
fn test_windows_object_key_violation_characters() {
|
||||
let characters = Some(WINDOWS_RESERVED_CHARACTERS_REASON);
|
||||
for object in ["path/*sUt*mykey", "a:b", "what?", "pipe|name", "quote\"d", "lt<gt>"] {
|
||||
assert_eq!(windows_object_key_violation(object), characters, "{object:?}");
|
||||
}
|
||||
|
||||
// Valid on every platform.
|
||||
for object in ["file.txt", "dir.name/file", "nullable", "CONSOLE", "com10"] {
|
||||
assert!(
|
||||
check_object_name_for_length_and_slash("test-bucket", object).is_ok(),
|
||||
"object name must be valid on all platforms: {object:?}"
|
||||
// Control characters 0x01-0x1F cannot appear in a Win32 file name.
|
||||
for object in ["a\u{1}b", "tab\tname", "dir/line\nbreak", "esc\u{1b}", "unit\u{1f}"] {
|
||||
assert_eq!(windows_object_key_violation(object), characters, "{object:?}");
|
||||
}
|
||||
|
||||
// NUL keeps its platform-independent ObjectNameInvalid answer.
|
||||
assert_eq!(windows_object_key_violation("a\0b"), None);
|
||||
|
||||
// A character violation outranks a segment violation anywhere in the key.
|
||||
assert_eq!(windows_object_key_violation("baddir./a:b"), characters);
|
||||
}
|
||||
|
||||
/// Existing `a\b` keys must stay readable, deletable and abortable, so the
|
||||
/// shared name check never rejects `\` (only new writes do, on Windows).
|
||||
#[test]
|
||||
fn test_backslash_passes_the_shared_name_check() {
|
||||
assert_eq!(windows_object_key_violation("a\\b"), None);
|
||||
assert_eq!(windows_object_key_violation("dir\\sub\\file.txt"), None);
|
||||
assert!(check_object_name_for_length_and_slash("test-bucket", "a\\b").is_ok());
|
||||
assert!(check_object_args("test-bucket", "a\\b").is_ok());
|
||||
assert!(check_bucket_and_object_names("test-bucket", "a\\b").is_ok());
|
||||
// Data movement relocates keys that already exist.
|
||||
assert!(check_new_object_args("test-bucket", "a\\b", true).is_ok());
|
||||
assert!(check_new_object_args(RUSTFS_META_BUCKET, "config\\a", false).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_new_object_args_matches_put_object_args() {
|
||||
for object in [
|
||||
"file.txt",
|
||||
"",
|
||||
"/leading",
|
||||
"a//b",
|
||||
"x/../y",
|
||||
&"k".repeat(1025),
|
||||
&"s".repeat(256),
|
||||
"baddir.",
|
||||
"a:b",
|
||||
"NUL.txt",
|
||||
] {
|
||||
assert_eq!(
|
||||
format!("{:?}", check_new_object_args("test-bucket", object, false)),
|
||||
format!("{:?}", check_put_object_args("test-bucket", object)),
|
||||
"{object:?}"
|
||||
);
|
||||
}
|
||||
assert!(matches!(
|
||||
check_new_object_args("Bad_Bucket", "file.txt", false),
|
||||
Err(StorageError::BucketNameInvalid(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_object_name_windows_reserved_characters() {
|
||||
// Keys containing Win32-reserved characters are rejected on Windows
|
||||
// with a descriptive reason (issue #3299); valid elsewhere.
|
||||
for object in ["path/*sUt*mykey", "a:b", "what?", "pipe|name", "quote\"d", "lt<gt>"] {
|
||||
let result = check_object_name_for_length_and_slash("test-bucket", object);
|
||||
if cfg!(target_os = "windows") {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn test_check_new_object_args_keeps_full_key_set_off_windows() {
|
||||
for object in ["a\\b", "a:b", "baddir.", "NUL", "CONIN$", "tab\tname"] {
|
||||
assert!(check_new_object_args("test-bucket", object, false).is_ok(), "{object:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `a\b` and `a/b` resolve to the same file on a Windows disk, so a new
|
||||
/// `a\b` would overwrite (or be overwritten by) `a/b`.
|
||||
#[test]
|
||||
#[cfg(target_os = "windows")]
|
||||
fn windows_new_object_key_rejects_backslash_alias() {
|
||||
assert!(check_new_object_args("test-bucket", "a/b", false).is_ok());
|
||||
for object in ["a\\b", "dir\\", "dir/sub\\file"] {
|
||||
assert!(
|
||||
matches!(
|
||||
&check_new_object_args("test-bucket", object, false),
|
||||
Err(StorageError::InvalidArgument(_, _, reason)) if reason == WINDOWS_BACKSLASH_REASON
|
||||
),
|
||||
"{object:?}"
|
||||
);
|
||||
}
|
||||
// Reads, deletes and in-flight multipart uploads of existing keys keep working.
|
||||
assert!(check_object_args("test-bucket", "a\\b").is_ok());
|
||||
assert!(check_del_obj_args("test-bucket", "a\\b").is_ok());
|
||||
assert!(check_new_object_args("test-bucket", "a\\b", true).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(target_os = "windows")]
|
||||
fn windows_new_object_key_rejects_unaddressable_names() {
|
||||
for (object, expected) in [
|
||||
("baddir.", WINDOWS_RESERVED_SEGMENT_REASON),
|
||||
("dir/LPT9.log", WINDOWS_RESERVED_SEGMENT_REASON),
|
||||
("COM\u{b9}", WINDOWS_RESERVED_SEGMENT_REASON),
|
||||
("CONOUT$", WINDOWS_RESERVED_SEGMENT_REASON),
|
||||
("a:b", WINDOWS_RESERVED_CHARACTERS_REASON),
|
||||
("tab\tname", WINDOWS_RESERVED_CHARACTERS_REASON),
|
||||
("esc\u{1b}", WINDOWS_RESERVED_CHARACTERS_REASON),
|
||||
] {
|
||||
for result in [
|
||||
check_object_name_for_length_and_slash("test-bucket", object),
|
||||
check_put_object_args("test-bucket", object),
|
||||
check_new_object_args("test-bucket", object, false),
|
||||
check_object_args("test-bucket", object),
|
||||
] {
|
||||
assert!(
|
||||
matches!(&result, Err(StorageError::InvalidArgument(_, _, reason)) if reason == WINDOWS_RESERVED_CHARACTERS_REASON),
|
||||
"object name must be rejected on Windows with a descriptive reason: {object:?}, got {result:?}"
|
||||
matches!(&result, Err(StorageError::InvalidArgument(_, _, reason)) if reason == expected),
|
||||
"{object:?} must be rejected with {expected:?}, got {result:?}"
|
||||
);
|
||||
} else {
|
||||
assert!(result.is_ok(), "object name must remain valid on non-Windows: {object:?}");
|
||||
}
|
||||
}
|
||||
// NUL keeps the platform-independent answer.
|
||||
assert!(matches!(
|
||||
check_put_object_args("test-bucket", "a\0b"),
|
||||
Err(StorageError::ObjectNameInvalid(_, _))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -24,10 +24,11 @@ use crate::cluster::rpc::{gen_tonic_replay_scope_headers, gen_tonic_signature_he
|
||||
#[cfg(test)]
|
||||
use crate::cluster::rpc::{tonic_boot_epoch_challenge, tonic_boot_epoch_response_headers};
|
||||
use crate::disk::error::{DiskError, Error as DiskErrorType, RpcStatusError};
|
||||
#[cfg(test)]
|
||||
use crate::runtime::sources as runtime_sources;
|
||||
use http::{Request as HttpRequest, Response as HttpResponse, Uri};
|
||||
use rustfs_protos::{
|
||||
ChannelClass, create_new_channel, get_channel_for_class,
|
||||
ChannelClass, get_channel_for_class,
|
||||
proto_gen::node_service::{
|
||||
heal_control_service_client::HealControlServiceClient, node_service_client::NodeServiceClient,
|
||||
scanner_control_service_client::ScannerControlServiceClient,
|
||||
@@ -53,7 +54,7 @@ use super::context_propagation::{inject_request_id_into_metadata, inject_trace_c
|
||||
/// 3. Subsequent calls will attempt fresh connections
|
||||
/// 4. If node is still down, connection will fail fast (3s timeout)
|
||||
pub async fn node_service_time_out_client(
|
||||
addr: &String,
|
||||
addr: &str,
|
||||
interceptor: TonicInterceptor,
|
||||
) -> Result<NodeServiceClient<InterceptedService<AuthenticatedChannel, TonicInterceptor>>, Box<dyn Error>> {
|
||||
// Default to the latency-sensitive control channel; bulk `bytes` RPCs opt in via the
|
||||
@@ -66,12 +67,9 @@ pub(crate) async fn scanner_control_time_out_client(
|
||||
interceptor: TonicInterceptor,
|
||||
) -> crate::error::Result<ScannerControlServiceClient<InterceptedService<AuthenticatedChannel, TonicInterceptor>>> {
|
||||
let interceptor = interceptor.with_rpc_audience(addr)?;
|
||||
let channel = match runtime_sources::cached_node_channel(addr).await {
|
||||
Some(channel) => channel,
|
||||
None => create_new_channel(addr)
|
||||
.await
|
||||
.map_err(|err| crate::error::Error::other(err.to_string()))?,
|
||||
};
|
||||
let channel = get_channel_for_class(addr, ChannelClass::Control)
|
||||
.await
|
||||
.map_err(|err| crate::error::Error::other(err.to_string()))?;
|
||||
let channel = ReplayScopeChannel::new(channel, interceptor.replay_scope_audience());
|
||||
let limit = rustfs_protos::scoped_dirty_usage::SCOPED_DIRTY_USAGE_MAX_REQUEST_BYTES as usize;
|
||||
Ok(ScannerControlServiceClient::with_interceptor(channel, interceptor)
|
||||
@@ -84,10 +82,7 @@ pub async fn heal_control_time_out_client(
|
||||
interceptor: TonicInterceptor,
|
||||
) -> Result<HealControlServiceClient<InterceptedService<AuthenticatedChannel, TonicInterceptor>>, Box<dyn Error>> {
|
||||
let interceptor = interceptor.with_rpc_audience(addr)?;
|
||||
let channel = match runtime_sources::cached_node_channel(addr).await {
|
||||
Some(channel) => channel,
|
||||
None => create_new_channel(addr).await?,
|
||||
};
|
||||
let channel = get_channel_for_class(addr, ChannelClass::Control).await?;
|
||||
let max_message_size = rustfs_protos::HEAL_CONTROL_RPC_MAX_MESSAGE_SIZE;
|
||||
let channel = ReplayScopeChannel::new(channel, interceptor.replay_scope_audience());
|
||||
Ok(HealControlServiceClient::with_interceptor(channel, interceptor)
|
||||
@@ -100,10 +95,7 @@ pub async fn tier_mutation_control_time_out_client(
|
||||
interceptor: TonicInterceptor,
|
||||
) -> Result<TierMutationControlServiceClient<InterceptedService<AuthenticatedChannel, TonicInterceptor>>, Box<dyn Error>> {
|
||||
let interceptor = interceptor.with_rpc_audience(addr)?;
|
||||
let channel = match runtime_sources::cached_node_channel(addr).await {
|
||||
Some(channel) => channel,
|
||||
None => create_new_channel(addr).await?,
|
||||
};
|
||||
let channel = get_channel_for_class(addr, ChannelClass::Control).await?;
|
||||
let max_message_size = rustfs_protos::TIER_MUTATION_RPC_MAX_MESSAGE_SIZE;
|
||||
let channel = ReplayScopeChannel::new(channel, interceptor.replay_scope_audience());
|
||||
Ok(TierMutationControlServiceClient::with_interceptor(channel, interceptor)
|
||||
@@ -118,22 +110,12 @@ pub async fn tier_mutation_control_time_out_client(
|
||||
/// from lock/health RPCs; everything else uses [`ChannelClass::Control`]. When isolation is
|
||||
/// disabled the two classes resolve to the same cached channel, i.e. legacy behavior.
|
||||
pub async fn node_service_time_out_client_for_class(
|
||||
addr: &String,
|
||||
addr: &str,
|
||||
interceptor: TonicInterceptor,
|
||||
class: ChannelClass,
|
||||
) -> Result<NodeServiceClient<InterceptedService<AuthenticatedChannel, TonicInterceptor>>, Box<dyn Error>> {
|
||||
let interceptor = interceptor.with_rpc_audience(addr)?;
|
||||
let channel = match class {
|
||||
ChannelClass::Control => match runtime_sources::cached_node_channel(addr).await {
|
||||
Some(channel) => {
|
||||
debug!("Using cached gRPC channel for: {}", addr);
|
||||
channel
|
||||
}
|
||||
// No cached connection, create new one.
|
||||
None => create_new_channel(addr).await?,
|
||||
},
|
||||
ChannelClass::Bulk => get_channel_for_class(addr, ChannelClass::Bulk).await?,
|
||||
};
|
||||
let channel = get_channel_for_class(addr, class).await?;
|
||||
|
||||
let max_message_size = rustfs_protos::internode_rpc_max_message_size();
|
||||
let channel = ReplayScopeChannel::new(channel, interceptor.replay_scope_audience());
|
||||
@@ -142,8 +124,24 @@ pub async fn node_service_time_out_client_for_class(
|
||||
.max_encoding_message_size(max_message_size))
|
||||
}
|
||||
|
||||
pub(crate) async fn node_service_delete_recovery_client(
|
||||
addr: &str,
|
||||
interceptor: TonicInterceptor,
|
||||
timeout: std::time::Duration,
|
||||
) -> Result<NodeServiceClient<InterceptedService<AuthenticatedChannel, TonicInterceptor>>, DiskError> {
|
||||
let interceptor = interceptor.with_rpc_audience(addr).map_err(DiskError::from)?;
|
||||
let channel = rustfs_protos::create_channel_with_timeout(addr, timeout)
|
||||
.await
|
||||
.map_err(|error| DiskError::RemoteClientUnavailable(error.to_string()))?;
|
||||
let channel = ReplayScopeChannel::new(channel, interceptor.replay_scope_audience());
|
||||
let max_message_size = rustfs_protos::internode_rpc_max_message_size();
|
||||
Ok(NodeServiceClient::with_interceptor(channel, interceptor)
|
||||
.max_decoding_message_size(max_message_size)
|
||||
.max_encoding_message_size(max_message_size))
|
||||
}
|
||||
|
||||
pub async fn node_service_time_out_client_no_auth(
|
||||
addr: &String,
|
||||
addr: &str,
|
||||
) -> Result<NodeServiceClient<InterceptedService<AuthenticatedChannel, TonicInterceptor>>, Box<dyn Error>> {
|
||||
node_service_time_out_client(addr, TonicInterceptor::NoOp(NoOpInterceptor)).await
|
||||
}
|
||||
@@ -355,6 +353,15 @@ fn apply_peer_replay_response(
|
||||
}
|
||||
}
|
||||
|
||||
fn rename_data_grpc_stage(path: &str) -> bool {
|
||||
matches!(
|
||||
path,
|
||||
"/node_service.NodeService/RenameData"
|
||||
| "/node_service.NodeService/RenameDataAtIncarnation"
|
||||
| "/node_service.NodeService/RenameDataWithQuotaFence"
|
||||
)
|
||||
}
|
||||
|
||||
impl<S, ReqBody, ResBody> Service<HttpRequest<ReqBody>> for ReplayScopeChannel<S>
|
||||
where
|
||||
S: Service<HttpRequest<ReqBody>, Response = HttpResponse<ResBody>>,
|
||||
@@ -372,6 +379,12 @@ where
|
||||
}
|
||||
|
||||
fn call(&mut self, mut request: HttpRequest<ReqBody>) -> Self::Future {
|
||||
let observe_rename_data = rustfs_io_metrics::put_stage_metrics_enabled() && rename_data_grpc_stage(request.uri().path());
|
||||
let request_scope_started = if observe_rename_data {
|
||||
rustfs_io_metrics::put_stage_timer()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let authenticated = self.audience.as_ref().is_some_and(|_| {
|
||||
request
|
||||
.headers()
|
||||
@@ -406,11 +419,30 @@ where
|
||||
}
|
||||
}
|
||||
}
|
||||
rustfs_io_metrics::record_put_object_stage_duration_from(
|
||||
rustfs_io_metrics::PUT_STAGE_SET_DISK_RENAME_REMOTE_CLIENT_REQUEST_SCOPE,
|
||||
request_scope_started,
|
||||
);
|
||||
|
||||
let audience = self.audience.clone();
|
||||
let future = self.inner.call(request);
|
||||
Box::pin(async move {
|
||||
let response = future.await?;
|
||||
let response = if observe_rename_data {
|
||||
rustfs_io_metrics::observe_put_stage_future(
|
||||
future,
|
||||
rustfs_io_metrics::PUT_STAGE_SET_DISK_RENAME_REMOTE_CLIENT_TRANSPORT_CALL,
|
||||
rustfs_io_metrics::PUT_STAGE_SET_DISK_RENAME_REMOTE_CLIENT_TRANSPORT_CALL_POLL_PENDING_COUNT,
|
||||
rustfs_io_metrics::PUT_STAGE_SET_DISK_RENAME_REMOTE_CLIENT_TRANSPORT_CALL_FIRST_PENDING_TO_READY,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
future.await?
|
||||
};
|
||||
let replay_response_started = if observe_rename_data {
|
||||
rustfs_io_metrics::put_stage_timer()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let (Some(audience), Some(challenge)) = (audience, challenge) {
|
||||
let response_state = verify_tonic_peer_replay_capabilities_response(&audience, challenge, response.headers());
|
||||
if let Err(error) = &response_state
|
||||
@@ -428,6 +460,10 @@ where
|
||||
}
|
||||
apply_peer_replay_response(audience, sent_state, response_state);
|
||||
}
|
||||
rustfs_io_metrics::record_put_object_stage_duration_from(
|
||||
rustfs_io_metrics::PUT_STAGE_SET_DISK_RENAME_REMOTE_CLIENT_REPLAY_RESPONSE,
|
||||
replay_response_started,
|
||||
);
|
||||
Ok(response)
|
||||
})
|
||||
}
|
||||
@@ -546,6 +582,126 @@ mod tests {
|
||||
use tracing_opentelemetry::OpenTelemetrySpanExt;
|
||||
use tracing_subscriber::{Registry, layer::SubscriberExt};
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_recovery_channel_uses_its_scoped_deadline() {
|
||||
use bytes::Bytes;
|
||||
use http_body_util::StreamBody;
|
||||
use hyper::{body::Frame, service::service_fn};
|
||||
use hyper_util::rt::{TokioExecutor, TokioIo};
|
||||
use rustfs_protos::proto_gen::node_service::{RecoverDeleteTransactionRequest, RecoverDeleteTransactionResponse};
|
||||
use tonic_prost::prost::Message;
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind recovery peer");
|
||||
let addr = format!("http://{}", listener.local_addr().expect("peer address"));
|
||||
let server = tokio::spawn(async move {
|
||||
loop {
|
||||
let (stream, _) = listener.accept().await.expect("accept recovery connection");
|
||||
tokio::spawn(async move {
|
||||
let service = service_fn(|_request: HttpRequest<hyper::body::Incoming>| async {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
let payload = RecoverDeleteTransactionResponse {
|
||||
success: true,
|
||||
protocol_version: 1,
|
||||
result: Bytes::new(),
|
||||
error: None,
|
||||
}
|
||||
.encode_to_vec();
|
||||
let mut data = vec![0];
|
||||
data.extend_from_slice(&u32::try_from(payload.len()).expect("small response").to_be_bytes());
|
||||
data.extend_from_slice(&payload);
|
||||
let mut trailers = http::HeaderMap::new();
|
||||
trailers.insert("grpc-status", "0".parse().expect("status"));
|
||||
let frames: Vec<Result<Frame<Bytes>, std::convert::Infallible>> =
|
||||
vec![Ok(Frame::data(data.into())), Ok(Frame::trailers(trailers))];
|
||||
let mut response = HttpResponse::new(StreamBody::new(futures::stream::iter(frames)));
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("content-type", "application/grpc".parse().expect("content type"));
|
||||
Ok::<_, std::convert::Infallible>(response)
|
||||
});
|
||||
let _ = hyper::server::conn::http2::Builder::new(TokioExecutor::new())
|
||||
.serve_connection(TokioIo::new(stream), service)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
});
|
||||
let request = || RecoverDeleteTransactionRequest {
|
||||
disk: "disk".into(),
|
||||
volume: "bucket".into(),
|
||||
protocol_version: 1,
|
||||
operation: Bytes::new(),
|
||||
};
|
||||
// Seed an existing short-lived control channel to reproduce the inherited deadline.
|
||||
let control = tonic::transport::Endpoint::from_shared(addr.clone())
|
||||
.expect("test endpoint")
|
||||
.timeout(std::time::Duration::from_millis(50))
|
||||
.connect()
|
||||
.await
|
||||
.expect("control connect");
|
||||
rustfs_common::cache_connection(addr.clone(), control).await;
|
||||
let mut foreground = node_service_time_out_client(&addr, TonicInterceptor::NoOp(NoOpInterceptor))
|
||||
.await
|
||||
.expect("control client");
|
||||
let error = foreground
|
||||
.recover_delete_transaction(request())
|
||||
.await
|
||||
.expect_err("short control deadline");
|
||||
assert!(matches!(error.code(), tonic::Code::Cancelled | tonic::Code::DeadlineExceeded));
|
||||
// The 2-second test override bypasses the production range parser, which rejects 2.
|
||||
let mut recovery = node_service_delete_recovery_client(
|
||||
&addr,
|
||||
TonicInterceptor::NoOp(NoOpInterceptor),
|
||||
std::time::Duration::from_secs(2),
|
||||
)
|
||||
.await
|
||||
.expect("scoped recovery client");
|
||||
assert!(
|
||||
recovery
|
||||
.recover_delete_transaction(request())
|
||||
.await
|
||||
.expect("scoped deadline must survive control deadline")
|
||||
.into_inner()
|
||||
.success
|
||||
);
|
||||
server.abort();
|
||||
rustfs_protos::evict_failed_connection(&addr).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rpc_client_factories_do_not_reuse_https_channels_without_tls_generation() {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("reserve local address");
|
||||
let addr = format!("https://{}", listener.local_addr().expect("local address"));
|
||||
drop(listener);
|
||||
for factory in 0..4 {
|
||||
let placeholder = tonic::transport::Endpoint::from_static("http://127.0.0.1:1").connect_lazy();
|
||||
runtime_sources::cache_test_node_channel(addr.clone(), placeholder).await;
|
||||
let interceptor = TonicInterceptor::NoOp(NoOpInterceptor);
|
||||
let _result = match factory {
|
||||
0 => scanner_control_time_out_client(&addr, interceptor)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.to_string()),
|
||||
1 => heal_control_time_out_client(&addr, interceptor)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.to_string()),
|
||||
2 => tier_mutation_control_time_out_client(&addr, interceptor)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.to_string()),
|
||||
_ => node_service_time_out_client(&addr, interceptor)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.to_string()),
|
||||
};
|
||||
assert!(
|
||||
!runtime_sources::test_node_channel_is_cached(&addr).await,
|
||||
"factory {factory} must evict an unbound HTTPS cache entry before attempting a fresh dial"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct EpochProofService {
|
||||
audience: String,
|
||||
|
||||
@@ -289,13 +289,9 @@ async fn client(
|
||||
>,
|
||||
NetworkPeerProbeError,
|
||||
> {
|
||||
node_service_time_out_client_for_class(
|
||||
&address.to_owned(),
|
||||
TonicInterceptor::Signature(gen_tonic_signature_interceptor()),
|
||||
class,
|
||||
)
|
||||
.await
|
||||
.map_err(|_| NetworkPeerProbeError::Unreachable)
|
||||
node_service_time_out_client_for_class(address, TonicInterceptor::Signature(gen_tonic_signature_interceptor()), class)
|
||||
.await
|
||||
.map_err(|_| NetworkPeerProbeError::Unreachable)
|
||||
}
|
||||
|
||||
fn ping_request(payload: &[u8]) -> PingRequest {
|
||||
@@ -329,6 +325,24 @@ fn map_status(status: tonic::Status) -> NetworkPeerProbeError {
|
||||
match status.code() {
|
||||
Code::Unavailable | Code::Unknown => NetworkPeerProbeError::Unreachable,
|
||||
Code::DeadlineExceeded => NetworkPeerProbeError::TimedOut,
|
||||
Code::Cancelled | Code::Internal => {
|
||||
let mut source = std::error::Error::source(&status);
|
||||
while let Some(error) = source {
|
||||
if error
|
||||
.downcast_ref::<h2::Error>()
|
||||
.is_some_and(|error| matches!(error.reason(), Some(h2::Reason::CANCEL | h2::Reason::NO_ERROR)))
|
||||
{
|
||||
return NetworkPeerProbeError::Unreachable;
|
||||
}
|
||||
source = if let Some(error) = error.downcast_ref::<std::io::Error>() {
|
||||
// io::Error::source skips its immediate custom error.
|
||||
error.get_ref().map(|inner| inner as &(dyn std::error::Error + 'static))
|
||||
} else {
|
||||
error.source()
|
||||
};
|
||||
}
|
||||
NetworkPeerProbeError::ProtocolFailure
|
||||
}
|
||||
_ => NetworkPeerProbeError::ProtocolFailure,
|
||||
}
|
||||
}
|
||||
@@ -339,6 +353,35 @@ mod tests {
|
||||
use crate::layout::endpoint::Endpoint;
|
||||
use crate::layout::endpoints::{Endpoints, PoolEndpoints};
|
||||
|
||||
#[test]
|
||||
fn peer_transport_resets_are_unreachable_without_reclassifying_application_errors() {
|
||||
for reason in [h2::Reason::CANCEL, h2::Reason::NO_ERROR] {
|
||||
let status = tonic::Status::from_error(Box::new(h2::Error::from(reason)));
|
||||
assert!(matches!(status.code(), Code::Cancelled | Code::Internal));
|
||||
let code = status.code();
|
||||
assert_eq!(map_status(status), NetworkPeerProbeError::Unreachable);
|
||||
let mut wrapped = tonic::Status::new(code, "wrapped transport reset");
|
||||
wrapped.set_source(Arc::new(std::io::Error::other(h2::Error::from(reason))));
|
||||
assert_eq!(map_status(wrapped), NetworkPeerProbeError::Unreachable);
|
||||
}
|
||||
for reason in [h2::Reason::PROTOCOL_ERROR, h2::Reason::COMPRESSION_ERROR] {
|
||||
let status = tonic::Status::from_error(Box::new(h2::Error::from(reason)));
|
||||
assert_eq!(map_status(status), NetworkPeerProbeError::ProtocolFailure);
|
||||
}
|
||||
for status in [
|
||||
tonic::Status::cancelled("peer cancelled the application request"),
|
||||
tonic::Status::internal("peer rejected the probe"),
|
||||
tonic::Status::unauthenticated("authentication rejected"),
|
||||
tonic::Status::permission_denied("authorization rejected"),
|
||||
] {
|
||||
assert_eq!(map_status(status), NetworkPeerProbeError::ProtocolFailure);
|
||||
}
|
||||
assert_eq!(
|
||||
map_status(tonic::Status::deadline_exceeded("probe deadline elapsed")),
|
||||
NetworkPeerProbeError::TimedOut
|
||||
);
|
||||
}
|
||||
|
||||
fn pacing(duration: Duration) -> Arc<DiagnosticPacing> {
|
||||
NetworkPeerProbeClient::from_endpoint_pools(&topology())
|
||||
.with_diagnostic_pacing(Instant::now(), duration)
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
|
||||
use crate::cluster::rpc::client::{
|
||||
AuthenticatedChannel, TonicInterceptor, gen_tonic_signature_interceptor, is_network_like_disk_error,
|
||||
node_service_time_out_client, node_service_time_out_client_for_class, node_service_time_out_client_no_auth,
|
||||
node_service_delete_recovery_client, node_service_time_out_client, node_service_time_out_client_for_class,
|
||||
node_service_time_out_client_no_auth,
|
||||
};
|
||||
use crate::cluster::rpc::internode_data_transport::{
|
||||
InternodeDataTransport, NsScannerCapabilityRequest, NsScannerStreamRequest, ReadStreamRequest, WalkDirStreamRequest,
|
||||
@@ -22,10 +23,12 @@ use crate::cluster::rpc::internode_data_transport::{
|
||||
};
|
||||
use crate::disk::error::{Error, Result};
|
||||
use crate::disk::{
|
||||
BatchReadVersionReq, BatchReadVersionResp, CheckPartsResp, ConditionalFileUpdate, DeleteOptions, DiskAPI, DiskInfo,
|
||||
DiskInfoOptions, DiskLocation, DiskOption, FileInfoVersions, FileReader, FileWriter, PartTransactionAction, ReadMultipleReq,
|
||||
ReadMultipleResp, ReadOptions, RenameDataResp, SnapshotLeaseToken, UpdateMetadataOpts, VolumeInfo, WalkDirOptions,
|
||||
batch_read_version_one_by_one,
|
||||
BatchReadVersionReq, BatchReadVersionResp, CheckPartsResp, ConditionalFileUpdate, DELETE_RECOVERY_PROTOCOL_VERSION,
|
||||
DeleteOptions, DeleteRecoveryAction, DeleteRecoveryCursor, DeleteRecoveryDigest, DeleteRecoveryInspection,
|
||||
DeleteRecoveryPage, DeleteRecoveryRpcOperation, DeleteRecoveryRpcReply, DiskAPI, DiskInfo, DiskInfoOptions, DiskLocation,
|
||||
DiskOption, FileInfoVersions, FileReader, FileWriter, GuardedDeleteVersionsPayload, GuardedFileInfoVersions,
|
||||
PartTransactionAction, ReadMultipleReq, ReadMultipleResp, ReadOptions, RenameDataResp, SnapshotLeaseToken,
|
||||
UpdateMetadataOpts, VolumeInfo, WalkDirOptions, batch_read_version_one_by_one,
|
||||
disk_store::{
|
||||
DEFAULT_RUSTFS_DRIVE_ACTIVE_MONITORING, ENV_RUSTFS_DRIVE_ACTIVE_MONITORING, SKIP_IF_SUCCESS_BEFORE,
|
||||
get_drive_active_check_interval, get_drive_active_check_timeout, get_drive_disk_info_timeout, get_drive_list_dir_timeout,
|
||||
@@ -57,12 +60,12 @@ use rustfs_protos::proto_gen::node_service::RenamePartRequest;
|
||||
use rustfs_protos::proto_gen::node_service::{
|
||||
BatchReadVersionRequest, BatchReadVersionResponse, CheckPartsRequest, CompareAndUpdateFileOutcome,
|
||||
CompareAndUpdateFileRequest, DeletePathsRequest, DeleteRequest, DeleteVersionRequest, DeleteVersionsRequest,
|
||||
DeleteVersionsResponse, DeleteVolumeRequest, DiskInfoRequest, ListDirRequest, ListVolumesRequest, MakeVolumeRequest,
|
||||
MakeVolumesRequest, PreparePartTransactionRequest, ReadAllRequest, ReadMetadataRequest, ReadMultipleRequest,
|
||||
ReadMultipleResponse, ReadPartsRequest, ReadVersionRequest, ReadXlRequest, RenameDataRequest, RenameFileRequest,
|
||||
SettlePartTransactionRequest, SnapshotLeaseReleaseRequest, SnapshotLeaseRenewRequest, SnapshotLeaseRequest,
|
||||
SnapshotLeaseResponse, StatVolumeRequest, UpdateMetadataRequest, VerifyFileRequest, WriteAllRequest, WriteMetadataRequest,
|
||||
node_service_client::NodeServiceClient,
|
||||
DeleteVersionsResponse, DeleteVolumeRequest, DiskInfoRequest, GuardedDeleteVersionsRequest, ListDirRequest,
|
||||
ListVolumesRequest, MakeVolumeRequest, MakeVolumesRequest, PreparePartTransactionRequest, ReadAllRequest,
|
||||
ReadMetadataRequest, ReadMultipleRequest, ReadMultipleResponse, ReadPartsRequest, ReadVersionRequest, ReadXlRequest,
|
||||
RecoverDeleteTransactionRequest, RenameDataRequest, RenameFileRequest, SettlePartTransactionRequest,
|
||||
SnapshotLeaseReleaseRequest, SnapshotLeaseRenewRequest, SnapshotLeaseRequest, SnapshotLeaseResponse, StatVolumeRequest,
|
||||
UpdateMetadataRequest, VerifyFileRequest, WriteAllRequest, WriteMetadataRequest, node_service_client::NodeServiceClient,
|
||||
};
|
||||
use serde::{Serialize, de::DeserializeOwned};
|
||||
use std::{
|
||||
@@ -142,6 +145,45 @@ fn decode_delete_versions_errors(response: DeleteVersionsResponse, expected_len:
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn decode_delete_recovery_batch_errors(codes: Vec<Option<u32>>, expected_len: usize) -> Result<Vec<Option<DiskError>>> {
|
||||
if codes.len() != expected_len {
|
||||
return Err(Error::other("malformed recoverable batch delete response length"));
|
||||
}
|
||||
codes
|
||||
.into_iter()
|
||||
.map(|code| match code {
|
||||
None => Ok(None),
|
||||
Some(code) => DiskError::from_u32(code)
|
||||
.map(Some)
|
||||
.ok_or_else(|| Error::other("invalid recoverable batch delete error code")),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn validate_delete_recovery_inspection(
|
||||
volume: &str,
|
||||
object: &str,
|
||||
transaction: Uuid,
|
||||
inspection: &DeleteRecoveryInspection,
|
||||
) -> Result<()> {
|
||||
if inspection.protocol_version != DELETE_RECOVERY_PROTOCOL_VERSION
|
||||
|| inspection.id.object != object
|
||||
|| inspection.id.transaction != transaction
|
||||
|| transaction.is_nil()
|
||||
{
|
||||
return Err(Error::other("delete recovery inspection does not match request"));
|
||||
}
|
||||
if let Some(journal) = &inspection.journal
|
||||
&& (journal.protocol_version != DELETE_RECOVERY_PROTOCOL_VERSION
|
||||
|| journal.volume != volume
|
||||
|| journal.object != object
|
||||
|| journal.transaction != transaction)
|
||||
{
|
||||
return Err(Error::other("delete recovery journal does not match request"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn snapshot_lease_token_from_response(response: SnapshotLeaseResponse) -> Result<SnapshotLeaseToken> {
|
||||
if !response.success {
|
||||
return Err(response.error.unwrap_or_default().into());
|
||||
@@ -176,6 +218,24 @@ fn attach_mutation_body_digest<T>(
|
||||
crate::cluster::rpc::set_tonic_rolling_canonical_body_digest(request, &canonical_body).map_err(Error::other)
|
||||
}
|
||||
|
||||
fn attach_quota_mutation_body_digest<T>(
|
||||
request: &mut Request<T>,
|
||||
canonical_body: std::result::Result<Vec<u8>, std::num::TryFromIntError>,
|
||||
) -> Result<()> {
|
||||
let topology = crate::services::notification_sys::configured_fleet_topology_fingerprint()
|
||||
.ok_or_else(|| Error::other("quota mutation topology is not initialized or conflicts with startup"))?;
|
||||
let body = canonical_body.map_err(|_| Error::other("quota request length cannot be represented"))?;
|
||||
let body = rustfs_protos::canonical_quota_mutation_request_body(topology, &body)
|
||||
.map_err(|_| Error::other("quota topology envelope length cannot be represented"))?;
|
||||
request.metadata_mut().insert(
|
||||
rustfs_protos::QUOTA_MUTATION_TOPOLOGY_HEADER,
|
||||
topology
|
||||
.parse()
|
||||
.map_err(|_| Error::other("quota topology cannot be encoded"))?,
|
||||
);
|
||||
crate::cluster::rpc::set_tonic_canonical_body_digest(request, &body).map_err(Error::other)
|
||||
}
|
||||
|
||||
fn decode_volume_infos(volume_infos: Vec<String>) -> Result<Vec<VolumeInfo>> {
|
||||
volume_infos
|
||||
.into_iter()
|
||||
@@ -858,6 +918,132 @@ fn spawn_control_channel_prewarm(addr: String) {
|
||||
}
|
||||
|
||||
impl RemoteDisk {
|
||||
async fn guarded_delete_versions_rpc(
|
||||
&self,
|
||||
volume: &str,
|
||||
payload: Vec<u8>,
|
||||
probe_only: bool,
|
||||
) -> Result<DeleteVersionsResponse> {
|
||||
if payload.len() > rustfs_protos::DELETE_RECOVERY_MAX_PAYLOAD_BYTES {
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
self.execute_with_timeout(
|
||||
|| async {
|
||||
let mut client = self.get_client().await?;
|
||||
let mut request = Request::new(GuardedDeleteVersionsRequest {
|
||||
disk: self.endpoint.to_string(),
|
||||
volume: volume.to_owned(),
|
||||
payload: payload.into(),
|
||||
probe_only,
|
||||
});
|
||||
let body = rustfs_protos::canonical_guarded_delete_versions_request_body(request.get_ref())
|
||||
.map_err(|_| DiskError::FileCorrupt)?;
|
||||
crate::cluster::rpc::set_tonic_canonical_body_digest(&mut request, &body).map_err(Error::other)?;
|
||||
// The additive method is mandatory: an old peer cannot safely delete
|
||||
// a guarded request through any legacy batch method.
|
||||
let response = client
|
||||
.guarded_delete_versions(request)
|
||||
.await
|
||||
.map_err(|status| {
|
||||
if status.code() == Code::Unimplemented {
|
||||
DiskError::GuardedDeleteUnsupported
|
||||
} else {
|
||||
status.into()
|
||||
}
|
||||
})?
|
||||
.into_inner();
|
||||
if !response.success {
|
||||
return Err(response.error.ok_or(DiskError::FileCorrupt)?.into());
|
||||
}
|
||||
if response.error.is_some() {
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
Ok(response)
|
||||
},
|
||||
get_max_timeout_duration(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn is_plain_orphan_data_dir(&self, volume: &str, path: &str) -> Result<bool> {
|
||||
match self
|
||||
.recover_delete_transaction(volume, DeleteRecoveryRpcOperation::InspectOrphanDataDir { path: path.to_owned() })
|
||||
.await?
|
||||
{
|
||||
DeleteRecoveryRpcReply::OrphanDataDir { plain } => Ok(plain),
|
||||
_ => Err(Error::other("unexpected orphan data directory inspection reply")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn recover_delete_transaction(
|
||||
&self,
|
||||
volume: &str,
|
||||
operation: DeleteRecoveryRpcOperation,
|
||||
) -> Result<DeleteRecoveryRpcReply> {
|
||||
let payload = rmp_serde::to_vec_named(&operation)?;
|
||||
if payload.len() > operation.max_wire_payload_bytes() {
|
||||
return Err(Error::other("delete recovery operation exceeds payload limit"));
|
||||
}
|
||||
let settle_timeout = if matches!(&operation, DeleteRecoveryRpcOperation::Settle { .. }) {
|
||||
Some(crate::disk::get_delete_recovery_timeout()?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.execute_with_timeout(
|
||||
|| async {
|
||||
let mut client = if let Some(timeout) = settle_timeout {
|
||||
if let Some(error) = self.offline_bypass_error() {
|
||||
return Err(error);
|
||||
}
|
||||
node_service_delete_recovery_client(
|
||||
&self.addr,
|
||||
TonicInterceptor::Signature(gen_tonic_signature_interceptor()),
|
||||
timeout,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
self.get_client().await?
|
||||
};
|
||||
let mut request = Request::new(RecoverDeleteTransactionRequest {
|
||||
disk: self.endpoint.to_string(),
|
||||
volume: volume.to_owned(),
|
||||
protocol_version: DELETE_RECOVERY_PROTOCOL_VERSION,
|
||||
operation: payload.into(),
|
||||
});
|
||||
let canonical_body = rustfs_protos::canonical_recover_delete_transaction_request_body(request.get_ref())
|
||||
.map_err(|_| Error::other("recover_delete_transaction request length cannot be represented"))?;
|
||||
crate::cluster::rpc::set_tonic_canonical_body_digest(&mut request, &canonical_body).map_err(Error::other)?;
|
||||
// UNIMPLEMENTED is deliberately propagated: older peers cannot settle transactions.
|
||||
let response = client
|
||||
.recover_delete_transaction(request)
|
||||
.await
|
||||
.map_err(|status| {
|
||||
if status.code() == Code::Unimplemented {
|
||||
DiskError::UnsupportedDisk
|
||||
} else {
|
||||
status.into()
|
||||
}
|
||||
})?
|
||||
.into_inner();
|
||||
if response.protocol_version != DELETE_RECOVERY_PROTOCOL_VERSION {
|
||||
return Err(DiskError::UnsupportedDisk);
|
||||
}
|
||||
if !response.success {
|
||||
return Err(response
|
||||
.error
|
||||
.ok_or_else(|| Error::other("missing delete recovery error"))?
|
||||
.into());
|
||||
}
|
||||
if response.error.is_some() {
|
||||
return Err(Error::other("malformed delete recovery success"));
|
||||
}
|
||||
rustfs_protos::decode_delete_recovery_payload(&response.result).map_err(Into::into)
|
||||
},
|
||||
settle_timeout.unwrap_or_else(get_max_timeout_duration),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn rename_file_with_durability(
|
||||
&self,
|
||||
src_volume: &str,
|
||||
@@ -1920,6 +2106,12 @@ fn encode_msgpack<T: Serialize>(value: &T) -> Result<Vec<u8>> {
|
||||
encode_msgpack_with_capacity(value, MSGPACK_ENCODE_CAPACITY_HINT)
|
||||
}
|
||||
|
||||
// Optional fields are omitted by serde; named maps keep rollback flags and
|
||||
// transaction UUIDs independent of field order across peer versions.
|
||||
fn encode_delete_options_msgpack(value: &DeleteOptions) -> Result<Vec<u8>> {
|
||||
Ok(rmp_serde::to_vec_named(value)?)
|
||||
}
|
||||
|
||||
fn encode_file_info_msgpack(value: &FileInfo) -> Result<Vec<u8>> {
|
||||
encode_msgpack_with_capacity(value, FILE_INFO_MSGPACK_ENCODE_CAPACITY_HINT)
|
||||
}
|
||||
@@ -2376,6 +2568,7 @@ impl RemoteDisk {
|
||||
self.execute_with_timeout_for_op(
|
||||
"rename_data",
|
||||
|| async {
|
||||
let prepare_started = rustfs_io_metrics::put_stage_timer();
|
||||
let file_info = compat_json(fi)?;
|
||||
let file_info_bin = encode_file_info_msgpack(fi)?;
|
||||
let mut client = self.get_client().await?;
|
||||
@@ -2403,33 +2596,85 @@ impl RemoteDisk {
|
||||
rustfs_protos::canonical_rename_data_request_body(request.get_ref())
|
||||
};
|
||||
let incarnation_bound = !request.get_ref().bucket_incarnation_id.is_empty();
|
||||
if scanner_publication_lease_token.is_some() || incarnation_bound {
|
||||
let quota_fenced = rustfs_utils::http::metadata_compat::contains_key_str(
|
||||
&fi.metadata,
|
||||
crate::disk::QUOTA_MUTATION_FENCE_METADATA_SUFFIX,
|
||||
);
|
||||
if quota_fenced {
|
||||
attach_quota_mutation_body_digest(&mut request, canonical_body)?;
|
||||
} else if scanner_publication_lease_token.is_some() || incarnation_bound {
|
||||
let canonical_body =
|
||||
canonical_body.map_err(|_| Error::other("rename_data request length cannot be represented"))?;
|
||||
crate::cluster::rpc::set_tonic_canonical_body_digest(&mut request, &canonical_body).map_err(Error::other)?;
|
||||
} else {
|
||||
attach_mutation_body_digest(&mut request, canonical_body, "rename_data")?;
|
||||
}
|
||||
rustfs_io_metrics::record_put_object_stage_duration_from(
|
||||
rustfs_io_metrics::PUT_STAGE_SET_DISK_RENAME_REMOTE_CLIENT_PREPARE,
|
||||
prepare_started,
|
||||
);
|
||||
|
||||
let response = if artifact_incarnation.is_some() {
|
||||
client.artifact_rename_data(request).await?
|
||||
} else if incarnation_bound {
|
||||
// Older peers return Unimplemented before mutation; never downgrade.
|
||||
client.rename_data_at_incarnation(request).await?
|
||||
} else {
|
||||
client.rename_data(request).await?
|
||||
}
|
||||
.into_inner();
|
||||
let rpc_started = rustfs_io_metrics::put_stage_timer();
|
||||
let response = rustfs_io_metrics::observe_put_stage_future(
|
||||
async {
|
||||
if artifact_incarnation.is_some() {
|
||||
client.artifact_rename_data(request).await
|
||||
} else if quota_fenced {
|
||||
// The method itself proves quota-token enforcement; an old peer must fail closed.
|
||||
client.rename_data_with_quota_fence(request).await
|
||||
} else if incarnation_bound {
|
||||
// Older peers return Unimplemented before mutation; never downgrade.
|
||||
client.rename_data_at_incarnation(request).await
|
||||
} else {
|
||||
client.rename_data(request).await
|
||||
}
|
||||
},
|
||||
rustfs_io_metrics::PUT_STAGE_SET_DISK_RENAME_REMOTE_CLIENT_RPC_AWAIT,
|
||||
rustfs_io_metrics::PUT_STAGE_SET_DISK_RENAME_REMOTE_CLIENT_RPC_AWAIT_POLL_PENDING_COUNT,
|
||||
rustfs_io_metrics::PUT_STAGE_SET_DISK_RENAME_REMOTE_CLIENT_RPC_AWAIT_FIRST_PENDING_TO_READY,
|
||||
)
|
||||
.await?;
|
||||
let response_process_started = rustfs_io_metrics::put_stage_timer();
|
||||
let response_headers_started = rustfs_io_metrics::put_stage_timer();
|
||||
let _ = response.metadata().len();
|
||||
rustfs_io_metrics::record_put_object_stage_duration_from(
|
||||
rustfs_io_metrics::PUT_STAGE_SET_DISK_RENAME_REMOTE_CLIENT_RESPONSE_HEADERS,
|
||||
response_headers_started,
|
||||
);
|
||||
let into_inner_started = rustfs_io_metrics::put_stage_timer();
|
||||
let response = response.into_inner();
|
||||
rustfs_io_metrics::record_put_object_stage_duration_from(
|
||||
rustfs_io_metrics::PUT_STAGE_SET_DISK_RENAME_REMOTE_CLIENT_INTO_INNER,
|
||||
into_inner_started,
|
||||
);
|
||||
rustfs_io_metrics::record_put_object_stage_duration_from(
|
||||
rustfs_io_metrics::PUT_STAGE_SET_DISK_RENAME_REMOTE_CLIENT_RPC,
|
||||
rpc_started,
|
||||
);
|
||||
|
||||
let success_check_started = rustfs_io_metrics::put_stage_timer();
|
||||
if !response.success {
|
||||
return Err(response.error.unwrap_or_default().into());
|
||||
}
|
||||
rustfs_io_metrics::record_put_object_stage_duration_from(
|
||||
rustfs_io_metrics::PUT_STAGE_SET_DISK_RENAME_REMOTE_CLIENT_SUCCESS_CHECK,
|
||||
success_check_started,
|
||||
);
|
||||
|
||||
let decode_started = rustfs_io_metrics::put_stage_timer();
|
||||
let rename_data_resp = decode_msgpack_or_json::<RenameDataResp>(
|
||||
&response.rename_data_resp_bin,
|
||||
&response.rename_data_resp,
|
||||
"RenameDataResp",
|
||||
)?;
|
||||
rustfs_io_metrics::record_put_object_stage_duration_from(
|
||||
rustfs_io_metrics::PUT_STAGE_SET_DISK_RENAME_REMOTE_CLIENT_DECODE,
|
||||
decode_started,
|
||||
);
|
||||
rustfs_io_metrics::record_put_object_stage_duration_from(
|
||||
rustfs_io_metrics::PUT_STAGE_SET_DISK_RENAME_REMOTE_CLIENT_RESPONSE_PROCESS,
|
||||
response_process_started,
|
||||
);
|
||||
|
||||
Ok(rename_data_resp)
|
||||
},
|
||||
@@ -2510,6 +2755,39 @@ impl RemoteDisk {
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl DiskAPI for RemoteDisk {
|
||||
async fn supports_guarded_delete_versions(&self) -> Result<()> {
|
||||
let response = self.guarded_delete_versions_rpc("", Vec::new(), true).await?;
|
||||
if !response.errors.is_empty() || !response.item_errors.is_empty() {
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
async fn delete_versions_guarded_recoverable(
|
||||
&self,
|
||||
volume: &str,
|
||||
versions: Vec<GuardedFileInfoVersions>,
|
||||
opts: DeleteOptions,
|
||||
) -> Vec<Option<DiskError>> {
|
||||
let count = versions.len();
|
||||
let payload = GuardedDeleteVersionsPayload { versions, opts };
|
||||
if let Err(error) = payload.validate() {
|
||||
return vec![Some(error); count];
|
||||
}
|
||||
let encoded = match rmp_serde::to_vec_named(&payload) {
|
||||
Ok(encoded) => encoded,
|
||||
Err(error) => return vec![Some(error.into()); count],
|
||||
};
|
||||
match self.guarded_delete_versions_rpc(volume, encoded, false).await {
|
||||
Ok(response) => {
|
||||
// New guarded peers must return typed errors, never lossy strings.
|
||||
if response.item_errors.len() != count {
|
||||
return vec![Some(DiskError::FileCorrupt); count];
|
||||
}
|
||||
decode_delete_versions_errors(response, count)
|
||||
}
|
||||
Err(error) => vec![Some(error); count],
|
||||
}
|
||||
}
|
||||
#[tracing::instrument(level = "trace", skip_all)]
|
||||
fn to_string(&self) -> String {
|
||||
self.endpoint.to_string()
|
||||
@@ -2788,7 +3066,7 @@ impl DiskAPI for RemoteDisk {
|
||||
// `_bin` support for DeleteVersion is new (grpc-optimization P2); always dual-write
|
||||
// JSON + msgpack until its fallback counter has read zero across a release window.
|
||||
let file_info_bin = encode_file_info_msgpack(&fi)?;
|
||||
let opts_bin = encode_msgpack(&opts)?;
|
||||
let opts_bin = encode_delete_options_msgpack(&opts)?;
|
||||
let conditional_marker = opts.expected_delete_marker.is_some();
|
||||
let file_info = serde_json::to_string(&fi)?;
|
||||
let opts = serde_json::to_string(&opts)?;
|
||||
@@ -2860,7 +3138,7 @@ impl DiskAPI for RemoteDisk {
|
||||
|
||||
// `_bin` support for DeleteVersions is new (grpc-optimization P2); always dual-write JSON +
|
||||
// msgpack until its fallback counter has read zero across a release window.
|
||||
let opts_bin = match encode_msgpack(&opts) {
|
||||
let opts_bin = match encode_delete_options_msgpack(&opts) {
|
||||
Ok(opts_bin) => opts_bin,
|
||||
Err(err) => {
|
||||
let mut errors = Vec::with_capacity(versions.len());
|
||||
@@ -3017,8 +3295,15 @@ impl DiskAPI for RemoteDisk {
|
||||
.map_err(|_| Error::other("snapshot lease TTL cannot be represented"))?,
|
||||
});
|
||||
let canonical_body = rustfs_protos::canonical_snapshot_lease_request_body(request.get_ref());
|
||||
attach_mutation_body_digest(&mut request, canonical_body, "acquire_snapshot_lease")?;
|
||||
let response = client.acquire_snapshot_lease(request).await?.into_inner();
|
||||
let quota_fence = volume == crate::disk::RUSTFS_META_BUCKET && crate::disk::is_quota_mutation_fence_path(path);
|
||||
let response = if quota_fence {
|
||||
attach_quota_mutation_body_digest(&mut request, canonical_body)?;
|
||||
client.acquire_quota_mutation_fence(request).await?
|
||||
} else {
|
||||
attach_mutation_body_digest(&mut request, canonical_body, "acquire_snapshot_lease")?;
|
||||
client.acquire_snapshot_lease(request).await?
|
||||
}
|
||||
.into_inner();
|
||||
snapshot_lease_token_from_response(response)
|
||||
},
|
||||
get_max_timeout_duration(),
|
||||
@@ -3059,8 +3344,15 @@ impl DiskAPI for RemoteDisk {
|
||||
token: token.as_bytes().to_vec().into(),
|
||||
});
|
||||
let canonical_body = rustfs_protos::canonical_snapshot_lease_release_request_body(request.get_ref());
|
||||
attach_mutation_body_digest(&mut request, canonical_body, "release_snapshot_lease")?;
|
||||
let response = client.release_snapshot_lease(request).await?.into_inner();
|
||||
let quota_fence = volume == crate::disk::RUSTFS_META_BUCKET && crate::disk::is_quota_mutation_fence_path(path);
|
||||
let response = if quota_fence {
|
||||
attach_quota_mutation_body_digest(&mut request, canonical_body)?;
|
||||
client.release_quota_mutation_fence(request).await?
|
||||
} else {
|
||||
attach_mutation_body_digest(&mut request, canonical_body, "release_snapshot_lease")?;
|
||||
client.release_snapshot_lease(request).await?
|
||||
}
|
||||
.into_inner();
|
||||
if !response.success {
|
||||
return Err(response.error.unwrap_or_default().into());
|
||||
}
|
||||
@@ -3270,7 +3562,7 @@ impl DiskAPI for RemoteDisk {
|
||||
opts_bin: opts_bin.into(),
|
||||
});
|
||||
|
||||
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_read_version_request();
|
||||
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_read_version_request(opts);
|
||||
if let Some(request_payload_bytes) = request_payload_bytes {
|
||||
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_read_version_sent_bytes(request_payload_bytes);
|
||||
}
|
||||
@@ -3348,6 +3640,7 @@ impl DiskAPI for RemoteDisk {
|
||||
record_batch_read_version_stage(INTERNODE_STAGE_BATCH_READ_VERSION_REQUEST_ENCODE, encode_started);
|
||||
let request_payload_bytes = batch_read_version_attribution_enabled
|
||||
.then(|| batch_read_version_request_payload_len(&req, &batch_read_version_req, &batch_read_version_req_bin));
|
||||
let request_opts = req.opts;
|
||||
let batch_result = self
|
||||
.execute_with_timeout_for_op(
|
||||
"batch_read_version",
|
||||
@@ -3361,7 +3654,7 @@ impl DiskAPI for RemoteDisk {
|
||||
batch_read_version_req_bin: batch_read_version_req_bin.into(),
|
||||
});
|
||||
|
||||
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_batch_read_version_request();
|
||||
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_batch_read_version_request(&request_opts);
|
||||
if let Some(request_payload_bytes) = request_payload_bytes {
|
||||
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_batch_read_version_sent_bytes(
|
||||
request_payload_bytes.saturating_add(disk_len),
|
||||
@@ -3854,6 +4147,153 @@ impl DiskAPI for RemoteDisk {
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "trace", skip_all)]
|
||||
async fn delete_version_recoverable(
|
||||
&self,
|
||||
volume: &str,
|
||||
object: &str,
|
||||
file_info: FileInfo,
|
||||
force_del_marker: bool,
|
||||
opts: DeleteOptions,
|
||||
) -> Result<()> {
|
||||
match self
|
||||
.recover_delete_transaction(
|
||||
volume,
|
||||
DeleteRecoveryRpcOperation::ApplyVersion {
|
||||
object: object.to_owned(),
|
||||
file_info: Box::new(file_info),
|
||||
force_del_marker,
|
||||
opts,
|
||||
},
|
||||
)
|
||||
.await?
|
||||
{
|
||||
DeleteRecoveryRpcReply::AppliedVersion => Ok(()),
|
||||
_ => Err(Error::other("unexpected recoverable delete reply")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_versions_recoverable(
|
||||
&self,
|
||||
volume: &str,
|
||||
versions: Vec<FileInfoVersions>,
|
||||
opts: DeleteOptions,
|
||||
) -> Vec<Option<DiskError>> {
|
||||
let expected_len = versions.len();
|
||||
let result = match self
|
||||
.recover_delete_transaction(volume, DeleteRecoveryRpcOperation::ApplyVersions { versions, opts })
|
||||
.await
|
||||
{
|
||||
Ok(DeleteRecoveryRpcReply::AppliedVersions(codes)) => decode_delete_recovery_batch_errors(codes, expected_len),
|
||||
Ok(_) => Err(Error::other("unexpected recoverable batch delete reply")),
|
||||
Err(error) => Err(error),
|
||||
};
|
||||
match result {
|
||||
Ok(errors) => errors,
|
||||
Err(error) => vec![Some(error); expected_len],
|
||||
}
|
||||
}
|
||||
|
||||
async fn has_delete_transaction_evidence(&self, volume: &str, object: &str) -> Result<bool> {
|
||||
match self
|
||||
.recover_delete_transaction(
|
||||
volume,
|
||||
DeleteRecoveryRpcOperation::Exists {
|
||||
object: object.to_owned(),
|
||||
},
|
||||
)
|
||||
.await?
|
||||
{
|
||||
DeleteRecoveryRpcReply::Evidence { present } => Ok(present),
|
||||
_ => Err(Error::other("unexpected delete recovery existence reply")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn discover_delete_transactions(
|
||||
&self,
|
||||
volume: &str,
|
||||
object: Option<&str>,
|
||||
cursor: Option<DeleteRecoveryCursor>,
|
||||
limit: u32,
|
||||
) -> Result<DeleteRecoveryPage> {
|
||||
match self
|
||||
.recover_delete_transaction(
|
||||
volume,
|
||||
DeleteRecoveryRpcOperation::Discover {
|
||||
object: object.map(str::to_owned),
|
||||
cursor,
|
||||
limit,
|
||||
},
|
||||
)
|
||||
.await?
|
||||
{
|
||||
DeleteRecoveryRpcReply::Discovered(page) => {
|
||||
if object.is_some_and(|object| page.transactions.iter().any(|id| id.object != object)) {
|
||||
return Err(Error::other("delete recovery discovery escaped requested object"));
|
||||
}
|
||||
if page.transactions.len() > usize::try_from(limit).map_err(|_| Error::other("invalid discovery limit"))?
|
||||
|| page
|
||||
.transactions
|
||||
.iter()
|
||||
.any(|id| id.transaction.is_nil() || id.object.is_empty())
|
||||
{
|
||||
return Err(Error::other("malformed delete discovery page"));
|
||||
}
|
||||
Ok(page)
|
||||
}
|
||||
_ => Err(Error::other("unexpected delete discovery reply")),
|
||||
}
|
||||
}
|
||||
async fn inspect_delete_transaction(
|
||||
&self,
|
||||
volume: &str,
|
||||
object: &str,
|
||||
transaction: Uuid,
|
||||
) -> Result<DeleteRecoveryInspection> {
|
||||
match self
|
||||
.recover_delete_transaction(
|
||||
volume,
|
||||
DeleteRecoveryRpcOperation::Inspect {
|
||||
object: object.to_owned(),
|
||||
transaction,
|
||||
},
|
||||
)
|
||||
.await?
|
||||
{
|
||||
DeleteRecoveryRpcReply::Inspected(inspection) => {
|
||||
validate_delete_recovery_inspection(volume, object, transaction, &inspection)?;
|
||||
Ok(inspection)
|
||||
}
|
||||
_ => Err(Error::other("unexpected delete inspection reply")),
|
||||
}
|
||||
}
|
||||
async fn settle_delete_transaction(
|
||||
&self,
|
||||
volume: &str,
|
||||
object: &str,
|
||||
transaction: Uuid,
|
||||
expected_fingerprint: DeleteRecoveryDigest,
|
||||
action: DeleteRecoveryAction,
|
||||
) -> Result<DeleteRecoveryInspection> {
|
||||
match self
|
||||
.recover_delete_transaction(
|
||||
volume,
|
||||
DeleteRecoveryRpcOperation::Settle {
|
||||
object: object.to_owned(),
|
||||
transaction,
|
||||
expected_fingerprint,
|
||||
action,
|
||||
},
|
||||
)
|
||||
.await?
|
||||
{
|
||||
DeleteRecoveryRpcReply::Settled(inspection) => {
|
||||
validate_delete_recovery_inspection(volume, object, transaction, &inspection)?;
|
||||
Ok(inspection)
|
||||
}
|
||||
_ => Err(Error::other("unexpected delete settlement reply")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn settle_part_transaction(&self, volume: &str, path: &str, action: PartTransactionAction) -> Result<()> {
|
||||
self.execute_with_timeout(
|
||||
|| async {
|
||||
@@ -4344,6 +4784,101 @@ mod tests {
|
||||
peer.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn quota_fence_old_peer_rejects_without_legacy_fallback() {
|
||||
runtime_sources::ensure_test_rpc_secret();
|
||||
crate::services::notification_sys::install_cross_pool_fence_fleet_proof_for_test();
|
||||
let _without_proof = crate::services::notification_sys::without_live_cross_pool_fence_fleet_proof_for_test();
|
||||
assert!(crate::services::notification_sys::acquire_cross_pool_fence_fleet_proof().is_none());
|
||||
assert!(crate::services::notification_sys::configured_fleet_topology_fingerprint().is_some());
|
||||
let peer = TestGrpcPeer::spawn(Bytes::new(), Bytes::new())
|
||||
.await
|
||||
.expect("old peer must bind");
|
||||
let remote = remote_disk_for_addr(&peer.addr).await;
|
||||
let fence_path = crate::disk::quota_mutation_fence_path("bucket", "object");
|
||||
assert!(
|
||||
remote
|
||||
.acquire_snapshot_lease(crate::disk::RUSTFS_META_BUCKET, &fence_path)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
remote
|
||||
.release_snapshot_lease(crate::disk::RUSTFS_META_BUCKET, &fence_path, SnapshotLeaseToken::revoke_all())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
let mut file_info = sample_rename_data_file_info();
|
||||
rustfs_utils::http::metadata_compat::insert_str(
|
||||
&mut file_info.metadata,
|
||||
crate::disk::QUOTA_MUTATION_FENCE_METADATA_SUFFIX,
|
||||
Uuid::new_v4().to_string(),
|
||||
);
|
||||
assert!(
|
||||
remote
|
||||
.rename_data("tmp", "source", file_info, "bucket", "object")
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
let methods = peer
|
||||
.peer
|
||||
.unsupported_methods
|
||||
.lock()
|
||||
.expect("unsupported method list lock")
|
||||
.clone();
|
||||
assert_eq!(
|
||||
methods,
|
||||
vec![
|
||||
"/node_service.NodeService/AcquireQuotaMutationFence",
|
||||
"/node_service.NodeService/ReleaseQuotaMutationFence",
|
||||
"/node_service.NodeService/RenameDataWithQuotaFence",
|
||||
],
|
||||
"quota operations must never fall back to snapshot or unfenced rename methods"
|
||||
);
|
||||
peer.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn guarded_delete_old_peer_rejects_without_legacy_fallback() {
|
||||
runtime_sources::ensure_test_rpc_secret();
|
||||
let peer = TestGrpcPeer::spawn(Bytes::new(), Bytes::new())
|
||||
.await
|
||||
.expect("old peer must bind");
|
||||
let remote = remote_disk_for_addr(&peer.addr).await;
|
||||
assert_eq!(remote.supports_guarded_delete_versions().await, Err(DiskError::GuardedDeleteUnsupported));
|
||||
let group = GuardedFileInfoVersions {
|
||||
group: FileInfoVersions {
|
||||
name: "object".into(),
|
||||
versions: vec![FileInfo::default()],
|
||||
..Default::default()
|
||||
},
|
||||
expected_current: crate::disk::CurrentDeleteGeneration::Absent,
|
||||
};
|
||||
let errors = remote
|
||||
.delete_versions_guarded_recoverable(
|
||||
"bucket",
|
||||
vec![group],
|
||||
DeleteOptions {
|
||||
old_data_dir: Some(Uuid::new_v4()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert_eq!(errors, vec![Some(DiskError::GuardedDeleteUnsupported)]);
|
||||
let methods = peer
|
||||
.peer
|
||||
.unsupported_methods
|
||||
.lock()
|
||||
.expect("unsupported method list lock")
|
||||
.clone();
|
||||
assert_eq!(
|
||||
methods,
|
||||
vec!["/node_service.NodeService/GuardedDeleteVersions"; 2],
|
||||
"neither capability failure nor guarded application may call a legacy deletion method"
|
||||
);
|
||||
peer.stop().await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artifact_repair_seal_response_requires_proof_full_request_and_physical_identity() {
|
||||
use rustfs_protos::proto_gen::node_service::{
|
||||
@@ -4431,6 +4966,45 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recoverable_batch_delete_rejects_invalid_error_codes_and_length() {
|
||||
let errors = decode_delete_recovery_batch_errors(vec![None, Some(DiskError::FileNotFound.to_u32())], 2)
|
||||
.expect("valid per-item errors");
|
||||
assert!(errors[0].is_none());
|
||||
assert_eq!(errors[1], Some(DiskError::FileNotFound));
|
||||
assert!(decode_delete_recovery_batch_errors(vec![None], 2).is_err());
|
||||
assert!(decode_delete_recovery_batch_errors(vec![Some(0)], 1).is_err());
|
||||
assert!(decode_delete_recovery_batch_errors(vec![Some(u32::MAX)], 1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_recovery_inspection_rejects_wrong_transaction_object_and_protocol() {
|
||||
let transaction = Uuid::new_v4();
|
||||
let inspection = DeleteRecoveryInspection {
|
||||
protocol_version: DELETE_RECOVERY_PROTOCOL_VERSION,
|
||||
id: crate::disk::DeleteTransactionId {
|
||||
object: "object".into(),
|
||||
transaction,
|
||||
},
|
||||
state: crate::disk::DeleteRecoveryState::Absent,
|
||||
journal: None,
|
||||
current_digest: None,
|
||||
current_versions: Vec::new(),
|
||||
backup_digest: None,
|
||||
rollback_data_complete: false,
|
||||
commit_data_safe: false,
|
||||
explicit_delete_safe: false,
|
||||
fingerprint: [0; 32],
|
||||
};
|
||||
validate_delete_recovery_inspection("bucket", "object", transaction, &inspection).expect("matching inspection");
|
||||
assert!(validate_delete_recovery_inspection("bucket", "other", transaction, &inspection).is_err());
|
||||
assert!(validate_delete_recovery_inspection("bucket", "object", Uuid::new_v4(), &inspection).is_err());
|
||||
assert!(validate_delete_recovery_inspection("bucket", "object", Uuid::nil(), &inspection).is_err());
|
||||
let mut incompatible = inspection;
|
||||
incompatible.protocol_version += 1;
|
||||
assert!(validate_delete_recovery_inspection("bucket", "object", transaction, &incompatible).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_and_update_wire_outcome_fails_closed() {
|
||||
assert_eq!(
|
||||
@@ -4464,32 +5038,19 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retired_marker_options_preserve_legacy_positional_wire_shape() {
|
||||
fn delete_options_named_wire_preserves_rollback_and_legacy_marker_fields() {
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize, PartialEq)]
|
||||
struct LegacyDeleteOptions {
|
||||
recursive: bool,
|
||||
immediate: bool,
|
||||
undo_write: bool,
|
||||
#[serde(default)]
|
||||
undo_delete: bool,
|
||||
old_data_dir: Option<Uuid>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
expected_delete_marker: Option<rustfs_filemeta::MetaDeleteMarker>,
|
||||
}
|
||||
let legacy = LegacyDeleteOptions {
|
||||
recursive: false,
|
||||
immediate: false,
|
||||
undo_write: false,
|
||||
undo_delete: false,
|
||||
old_data_dir: None,
|
||||
};
|
||||
let original = encode_msgpack(&legacy).unwrap();
|
||||
let current = encode_msgpack(&DeleteOptions::default()).unwrap();
|
||||
assert_eq!(current, original, "ordinary deletes must retain the older peer's positional payload");
|
||||
assert_eq!(rmp_serde::from_slice::<LegacyDeleteOptions>(¤t).unwrap(), legacy);
|
||||
assert!(
|
||||
rmp_serde::from_slice::<DeleteOptions>(&original)
|
||||
.unwrap()
|
||||
.expected_delete_marker
|
||||
.is_none()
|
||||
);
|
||||
let transaction = Uuid::new_v4();
|
||||
let mut marker = FileInfo {
|
||||
deleted: true,
|
||||
version_id: Some(Uuid::new_v4()),
|
||||
@@ -4498,12 +5059,69 @@ mod tests {
|
||||
};
|
||||
marker.set_delete_marker_incarnation(Uuid::new_v4());
|
||||
let marker = rustfs_filemeta::MetaDeleteMarker::from(marker);
|
||||
for expected_delete_marker in [None, Some(marker)] {
|
||||
for (undo_write, undo_delete) in [(true, false), (false, true)] {
|
||||
let legacy = LegacyDeleteOptions {
|
||||
recursive: false,
|
||||
immediate: false,
|
||||
undo_write,
|
||||
undo_delete,
|
||||
old_data_dir: Some(transaction),
|
||||
expected_delete_marker: expected_delete_marker.clone(),
|
||||
};
|
||||
// Older senders use positional arrays. Their unchanged field prefix and
|
||||
// missing optional fields must also decode on the named-map sender's peer.
|
||||
for (encoding, payload) in [
|
||||
("positional", rmp_serde::to_vec(&legacy).expect("encode legacy positional options")),
|
||||
("named", rmp_serde::to_vec_named(&legacy).expect("encode legacy named options")),
|
||||
] {
|
||||
let decoded: DeleteOptions = rmp_serde::from_slice(&payload).expect("decode legacy options on current peer");
|
||||
assert_eq!(decoded.undo_write, undo_write, "{encoding} undo-write flag");
|
||||
assert_eq!(decoded.undo_delete, undo_delete, "{encoding} undo-delete flag");
|
||||
assert_eq!(decoded.old_data_dir, Some(transaction), "{encoding} transaction UUID");
|
||||
assert_eq!(decoded.expected_delete_marker, expected_delete_marker, "{encoding} marker precondition");
|
||||
assert!(decoded.delete_recovery_bucket_incarnation.is_none(), "{encoding} omitted incarnation");
|
||||
assert!(!decoded.recoverable_delete, "{encoding} omitted recovery flag");
|
||||
}
|
||||
for incarnation in [None, Some(Uuid::new_v4())] {
|
||||
let current = DeleteOptions {
|
||||
undo_write,
|
||||
undo_delete,
|
||||
old_data_dir: Some(transaction),
|
||||
expected_delete_marker: expected_delete_marker.clone(),
|
||||
delete_recovery_bucket_incarnation: incarnation,
|
||||
recoverable_delete: true,
|
||||
..Default::default()
|
||||
};
|
||||
let payload = encode_delete_options_msgpack(¤t).expect("encode current named options");
|
||||
let decoded: DeleteOptions = rmp_serde::from_slice(&payload).expect("decode current options on current peer");
|
||||
assert_eq!(decoded.undo_write, undo_write);
|
||||
assert_eq!(decoded.undo_delete, undo_delete);
|
||||
assert_eq!(decoded.old_data_dir, Some(transaction));
|
||||
assert_eq!(decoded.expected_delete_marker, expected_delete_marker);
|
||||
assert_eq!(decoded.delete_recovery_bucket_incarnation, incarnation);
|
||||
assert!(decoded.recoverable_delete);
|
||||
let decoded: LegacyDeleteOptions =
|
||||
rmp_serde::from_slice(&payload).expect("decode current options on legacy peer");
|
||||
assert_eq!(decoded, legacy, "unknown recovery fields cannot shift rollback or marker values");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_nonempty_delete_options_do_not_fall_back_to_json() {
|
||||
let options = DeleteOptions {
|
||||
expected_delete_marker: Some(marker.clone()),
|
||||
undo_write: true,
|
||||
old_data_dir: Some(Uuid::new_v4()),
|
||||
..Default::default()
|
||||
};
|
||||
let decoded: DeleteOptions = rmp_serde::from_slice(&encode_msgpack(&options).unwrap()).unwrap();
|
||||
assert_eq!(decoded.expected_delete_marker, Some(marker));
|
||||
let json = serde_json::to_string(&options).expect("valid compatibility JSON");
|
||||
assert!(decode_msgpack_or_json::<DeleteOptions>(&[0xc1], &json, "DeleteOptions").is_err());
|
||||
let decoded: DeleteOptions =
|
||||
decode_msgpack_or_json(&[], &json, "DeleteOptions").expect("absent MessagePack permits JSON compatibility");
|
||||
assert!(decoded.undo_write);
|
||||
assert_eq!(decoded.old_data_dir, options.old_data_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4737,6 +5355,7 @@ mod tests {
|
||||
read_all_data: Bytes,
|
||||
next_format_read_gate: Arc<StdMutex<Option<Arc<TestFormatReadGate>>>>,
|
||||
rpc_paths: Arc<StdMutex<Vec<String>>>,
|
||||
unsupported_methods: Arc<StdMutex<Vec<String>>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
@@ -4756,6 +5375,7 @@ mod tests {
|
||||
read_all_data,
|
||||
next_format_read_gate: Arc::default(),
|
||||
rpc_paths: Arc::default(),
|
||||
unsupported_methods: Arc::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4907,13 +5527,20 @@ mod tests {
|
||||
Ok(grpc.unary(method, request).await)
|
||||
})
|
||||
}
|
||||
_ => Box::pin(async move {
|
||||
let mut response = http::Response::new(tonic::body::Body::default());
|
||||
let headers = response.headers_mut();
|
||||
headers.insert(tonic::Status::GRPC_STATUS, (tonic::Code::Unimplemented as i32).into());
|
||||
headers.insert(http::header::CONTENT_TYPE, tonic::metadata::GRPC_CONTENT_TYPE);
|
||||
Ok(response)
|
||||
}),
|
||||
_ => {
|
||||
self.peer
|
||||
.unsupported_methods
|
||||
.lock()
|
||||
.expect("unsupported method list lock")
|
||||
.push(request.uri().path().to_owned());
|
||||
Box::pin(async move {
|
||||
let mut response = http::Response::new(tonic::body::Body::default());
|
||||
let headers = response.headers_mut();
|
||||
headers.insert(tonic::Status::GRPC_STATUS, (tonic::Code::Unimplemented as i32).into());
|
||||
headers.insert(http::header::CONTENT_TYPE, tonic::metadata::GRPC_CONTENT_TYPE);
|
||||
Ok(response)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5193,6 +5820,8 @@ mod tests {
|
||||
cleanup_data_dir: Some(Uuid::new_v4()),
|
||||
sign: Some(vec![0x14, 0x35]),
|
||||
old_current_size: Some(crate::disk::OldCurrentSize::Present(64 * 1024)),
|
||||
old_current_source_checked: false,
|
||||
old_current_source: None,
|
||||
};
|
||||
let json = serde_json::to_string(&response).expect("legacy rename_data JSON response should encode");
|
||||
let decode_before = rustfs_io_metrics::internode_metrics::global_internode_metrics().msgpack_json_decode_total_for_test();
|
||||
@@ -5595,6 +6224,8 @@ mod tests {
|
||||
cleanup_data_dir: Some(Uuid::new_v4()),
|
||||
sign: Some(vec![1_u8; 32]),
|
||||
old_current_size: Some(crate::disk::OldCurrentSize::Present(4096)),
|
||||
old_current_source_checked: false,
|
||||
old_current_source: None,
|
||||
};
|
||||
let json = serde_json::to_vec(&response).expect("rename data response json should encode");
|
||||
let named_msgpack = rmp_serde::encode::to_vec_named(&response).expect("rename data response named msgpack should encode");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -642,17 +642,20 @@ where
|
||||
>,
|
||||
{
|
||||
let h = HeaderMap::new();
|
||||
let mut rd = api
|
||||
.get_object_reader(RUSTFS_META_BUCKET, file, None, h, opts)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
if err == Error::FileNotFound || matches!(err, Error::ObjectNotFound(_, _)) {
|
||||
Error::ConfigNotFound
|
||||
} else {
|
||||
warn!("read_config_with_metadata: err: {:?}, file: {}", err, file);
|
||||
err
|
||||
}
|
||||
})?;
|
||||
let read = api.get_object_reader(RUSTFS_META_BUCKET, file, None, h, opts);
|
||||
#[cfg(feature = "diagnostic-read-version-caller")]
|
||||
let read = crate::cluster::rpc::runtime_sources::with_read_version_entry(
|
||||
rustfs_io_metrics::internode_metrics::ReadVersionEntrySource::ConfigRead,
|
||||
read,
|
||||
);
|
||||
let mut rd = read.await.map_err(|err| {
|
||||
if err == Error::FileNotFound || matches!(err, Error::ObjectNotFound(_, _)) {
|
||||
Error::ConfigNotFound
|
||||
} else {
|
||||
warn!("read_config_with_metadata: err: {:?}, file: {}", err, file);
|
||||
err
|
||||
}
|
||||
})?;
|
||||
|
||||
let data = if let Some(max_bytes) = max_bytes {
|
||||
let object_size = usize::try_from(rd.object_info.size).map_err(|_| Error::CorruptedFormat)?;
|
||||
@@ -875,7 +878,18 @@ where
|
||||
>,
|
||||
{
|
||||
let mut put_data = PutObjReader::from_vec(data);
|
||||
match api.put_object(RUSTFS_META_BUCKET, file, &mut put_data, opts).await {
|
||||
let write = api.put_object(RUSTFS_META_BUCKET, file, &mut put_data, opts);
|
||||
#[cfg(feature = "diagnostic-read-version-caller")]
|
||||
let write = crate::cluster::rpc::runtime_sources::with_read_version_entry(
|
||||
rustfs_io_metrics::internode_metrics::ReadVersionEntrySource::ConfigWrite,
|
||||
async {
|
||||
crate::cluster::rpc::runtime_sources::observe_read_version_context_boundary(
|
||||
rustfs_io_metrics::internode_metrics::ReadVersionContextBoundary::ConfigWrite,
|
||||
);
|
||||
write.await
|
||||
},
|
||||
);
|
||||
match write.await {
|
||||
Ok(object_info) => Ok(object_info),
|
||||
Err(err) => {
|
||||
if log_error {
|
||||
@@ -2908,7 +2922,7 @@ mod tests {
|
||||
use rustfs_config::{
|
||||
DEFAULT_DELIMITER, ENABLE_KEY, EnableState, HEAL_BITROT_CYCLE, HEAL_SUB_SYS, MYSQL_DSN_STRING,
|
||||
MYSQL_MAX_OPEN_CONNECTIONS, MYSQL_QUEUE_DIR, MYSQL_TABLE, SCANNER_CYCLE, SCANNER_IDLE_MODE, SCANNER_KEYS, SCANNER_SPEED,
|
||||
SCANNER_SUB_SYS,
|
||||
SCANNER_SUB_SYS, SCANNER_WALKERS_PER_DISK,
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
@@ -2938,6 +2952,11 @@ mod tests {
|
||||
#[derive(Debug, Default)]
|
||||
struct MetadataWriteStore {
|
||||
error: Option<Error>,
|
||||
#[cfg(feature = "diagnostic-read-version-caller")]
|
||||
observations:
|
||||
std::sync::Mutex<Vec<(Option<rustfs_io_metrics::internode_metrics::ReadVersionEntrySource>, Option<&'static str>)>>,
|
||||
#[cfg(feature = "diagnostic-read-version-caller")]
|
||||
pending: bool,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
@@ -2968,7 +2987,20 @@ mod tests {
|
||||
_data: &mut Self::PutObjectReader,
|
||||
_opts: &Self::ObjectOptions,
|
||||
) -> core::result::Result<Self::ObjectInfo, Self::Error> {
|
||||
Err(self.error.clone().expect("test store error should be configured"))
|
||||
#[cfg(feature = "diagnostic-read-version-caller")]
|
||||
{
|
||||
self.observations.lock().expect("observations lock").push((
|
||||
crate::cluster::rpc::runtime_sources::current_read_version_entry_source(),
|
||||
rustfs_io_metrics::s3_http_metrics::current_s3_http_operation_name(),
|
||||
));
|
||||
if self.pending {
|
||||
std::future::pending::<()>().await;
|
||||
}
|
||||
}
|
||||
match self.error.clone() {
|
||||
Some(error) => Err(error),
|
||||
None => Ok(ObjectInfo::default()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2976,6 +3008,10 @@ mod tests {
|
||||
async fn save_config_preserves_retryable_system_volume_errors() {
|
||||
let store = Arc::new(MetadataWriteStore {
|
||||
error: Some(Error::BucketNotFound(RUSTFS_META_BUCKET.to_string())),
|
||||
#[cfg(feature = "diagnostic-read-version-caller")]
|
||||
observations: Default::default(),
|
||||
#[cfg(feature = "diagnostic-read-version-caller")]
|
||||
pending: false,
|
||||
});
|
||||
let error =
|
||||
save_config_with_opts_inner(store, "buckets/example/.metadata.bin", Vec::new(), &ObjectOptions::default(), false)
|
||||
@@ -2987,6 +3023,59 @@ mod tests {
|
||||
Error::InsufficientWriteQuorum(RUSTFS_META_BUCKET.to_string(), "buckets/example/.metadata.bin".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "diagnostic-read-version-caller")]
|
||||
#[tokio::test]
|
||||
async fn diagnostic_config_write_retains_origin_and_api_for_results_and_cancellation() {
|
||||
use crate::cluster::rpc::runtime_sources::{current_read_version_entry_source, with_read_version_entry};
|
||||
use rustfs_io_metrics::internode_metrics::ReadVersionEntrySource;
|
||||
use rustfs_io_metrics::s3_http_metrics::{S3HttpRequestGuard, current_s3_http_operation_context};
|
||||
|
||||
let mut guard = S3HttpRequestGuard::new("PUT");
|
||||
let put = guard.in_scope(|| {
|
||||
rustfs_io_metrics::record_s3_op(rustfs_s3_ops::S3Operation::PutObject);
|
||||
current_s3_http_operation_context().expect("captured PutObject")
|
||||
});
|
||||
for earlier in [None, Some(ReadVersionEntrySource::TierConfigReload)] {
|
||||
for api in [None, Some(put)] {
|
||||
for case in ["success", "error", "cancelled"] {
|
||||
let store = Arc::new(MetadataWriteStore {
|
||||
error: (case == "error").then_some(Error::VolumeNotFound),
|
||||
pending: case == "cancelled",
|
||||
..Default::default()
|
||||
});
|
||||
let opts = ObjectOptions::default();
|
||||
let write = async {
|
||||
let result = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(1),
|
||||
save_config_with_opts_inner(store.clone(), "buckets/diagnostic/state", Vec::new(), &opts, false),
|
||||
)
|
||||
.await;
|
||||
match case {
|
||||
"success" => assert!(result.expect("write completes").is_ok()),
|
||||
"error" => {
|
||||
assert!(matches!(result.expect("error completes"), Err(Error::InsufficientWriteQuorum(_, _))))
|
||||
}
|
||||
_ => assert!(result.is_err(), "pending write is cancelled"),
|
||||
}
|
||||
};
|
||||
rustfs_io_metrics::s3_http_metrics::with_isolated_s3_http_operation_context(api, async {
|
||||
match earlier {
|
||||
Some(source) => with_read_version_entry(source, write).await,
|
||||
None => write.await,
|
||||
}
|
||||
})
|
||||
.await;
|
||||
assert_eq!(
|
||||
*store.observations.lock().expect("observations lock"),
|
||||
vec![(Some(earlier.unwrap_or(ReadVersionEntrySource::ConfigWrite)), api.map(|_| "s3:PutObject"))]
|
||||
);
|
||||
assert_eq!(current_read_version_entry_source(), None, "write scope restores: {case}");
|
||||
assert_eq!(current_s3_http_operation_context(), None, "API scope restores: {case}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
use rustfs_lock::client::LockClient;
|
||||
use rustfs_lock::client::local::LocalClient;
|
||||
use rustfs_lock::{LockError, LockInfo, LockResponse, LockStats};
|
||||
@@ -4181,6 +4270,8 @@ mod tests {
|
||||
let value = match *key {
|
||||
SCANNER_SPEED => "fast",
|
||||
SCANNER_IDLE_MODE => "false",
|
||||
// "1" is this key's default, which the diff encoding omits.
|
||||
SCANNER_WALKERS_PER_DISK => "2",
|
||||
_ => "1",
|
||||
};
|
||||
scanner_kvs.insert((*key).to_string(), value.to_string());
|
||||
@@ -4202,6 +4293,8 @@ mod tests {
|
||||
let expected = match *key {
|
||||
SCANNER_SPEED => "fast",
|
||||
SCANNER_IDLE_MODE => "false",
|
||||
// "1" is this key's default, which the diff encoding omits.
|
||||
SCANNER_WALKERS_PER_DISK => "2",
|
||||
_ => "1",
|
||||
};
|
||||
assert_eq!(decoded_scanner.get(key), expected, "scanner key {key} should survive roundtrip");
|
||||
|
||||
@@ -17,11 +17,11 @@ use rustfs_config::{
|
||||
DEFAULT_SCANNER_BITROT_CYCLE_SECS, DEFAULT_SCANNER_CACHE_SAVE_TIMEOUT_SECS, DEFAULT_SCANNER_CYCLE_MAX_DIRECTORIES,
|
||||
DEFAULT_SCANNER_CYCLE_MAX_DURATION_SECS, DEFAULT_SCANNER_CYCLE_MAX_OBJECTS, DEFAULT_SCANNER_IDLE_MODE,
|
||||
DEFAULT_SCANNER_MAX_CONCURRENT_DISK_SCANS, DEFAULT_SCANNER_MAX_CONCURRENT_SET_SCANS, DEFAULT_SCANNER_SPEED,
|
||||
DEFAULT_SCANNER_YIELD_EVERY_N_OBJECTS, SCANNER_ALERT_EXCESS_FOLDERS, SCANNER_ALERT_EXCESS_VERSION_SIZE,
|
||||
SCANNER_ALERT_EXCESS_VERSIONS, SCANNER_BITROT_CYCLE, SCANNER_CACHE_SAVE_TIMEOUT, SCANNER_CYCLE,
|
||||
SCANNER_CYCLE_MAX_DIRECTORIES, SCANNER_CYCLE_MAX_DURATION, SCANNER_CYCLE_MAX_OBJECTS, SCANNER_DELAY, SCANNER_IDLE_MODE,
|
||||
SCANNER_MAX_CONCURRENT_DISK_SCANS, SCANNER_MAX_CONCURRENT_SET_SCANS, SCANNER_MAX_WAIT, SCANNER_SPEED, SCANNER_START_DELAY,
|
||||
SCANNER_YIELD_EVERY_N_OBJECTS,
|
||||
DEFAULT_SCANNER_WALKERS_PER_DISK, DEFAULT_SCANNER_YIELD_EVERY_N_OBJECTS, SCANNER_ALERT_EXCESS_FOLDERS,
|
||||
SCANNER_ALERT_EXCESS_VERSION_SIZE, SCANNER_ALERT_EXCESS_VERSIONS, SCANNER_BITROT_CYCLE, SCANNER_CACHE_SAVE_TIMEOUT,
|
||||
SCANNER_CYCLE, SCANNER_CYCLE_MAX_DIRECTORIES, SCANNER_CYCLE_MAX_DURATION, SCANNER_CYCLE_MAX_OBJECTS, SCANNER_DELAY,
|
||||
SCANNER_IDLE_MODE, SCANNER_MAX_CONCURRENT_DISK_SCANS, SCANNER_MAX_CONCURRENT_SET_SCANS, SCANNER_MAX_WAIT, SCANNER_SPEED,
|
||||
SCANNER_START_DELAY, SCANNER_WALKERS_PER_DISK, SCANNER_YIELD_EVERY_N_OBJECTS,
|
||||
};
|
||||
use std::sync::LazyLock;
|
||||
|
||||
@@ -92,6 +92,11 @@ pub static DEFAULT_KVS: LazyLock<KVS> = LazyLock::new(|| {
|
||||
value: DEFAULT_SCANNER_MAX_CONCURRENT_DISK_SCANS.to_string(),
|
||||
hidden_if_empty: false,
|
||||
},
|
||||
KV {
|
||||
key: SCANNER_WALKERS_PER_DISK.to_owned(),
|
||||
value: DEFAULT_SCANNER_WALKERS_PER_DISK.to_string(),
|
||||
hidden_if_empty: false,
|
||||
},
|
||||
KV {
|
||||
key: SCANNER_YIELD_EVERY_N_OBJECTS.to_owned(),
|
||||
value: DEFAULT_SCANNER_YIELD_EVERY_N_OBJECTS.to_string(),
|
||||
|
||||
@@ -3691,6 +3691,55 @@ fn activation_pool_meta_lock_error(err: rustfs_lock::LockError) -> Error {
|
||||
}
|
||||
}
|
||||
|
||||
fn rebalance_worker_read_lock_error(err: rustfs_lock::LockError, object: &str) -> Error {
|
||||
match err {
|
||||
rustfs_lock::LockError::QuorumNotReached { required, achieved } => Error::NamespaceLockQuorumUnavailable {
|
||||
mode: "read",
|
||||
bucket: RUSTFS_META_BUCKET.to_string(),
|
||||
object: object.to_string(),
|
||||
required,
|
||||
achieved,
|
||||
},
|
||||
other => Error::Lock(other),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-only admission to an already durable rebalance run. Keeping this
|
||||
/// separate from the activation writer prevents admission from authorizing a
|
||||
/// pool or rebalance metadata write under shared namespace locks.
|
||||
pub(crate) struct PoolRebalanceWorkerFence {
|
||||
pool_meta_guard: rustfs_lock::NamespaceLockGuard,
|
||||
rebalance_meta_guard: rustfs_lock::NamespaceLockGuard,
|
||||
fleet_proof: Option<crate::services::notification_sys::CrossPoolFenceFleetProofToken>,
|
||||
}
|
||||
|
||||
impl PoolRebalanceWorkerFence {
|
||||
pub(crate) fn set_fleet_proof(
|
||||
&mut self,
|
||||
fleet_proof: Option<crate::services::notification_sys::CrossPoolFenceFleetProofToken>,
|
||||
) {
|
||||
self.fleet_proof = fleet_proof;
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_held(&self) -> Result<()> {
|
||||
if self.pool_meta_guard.is_lock_lost()
|
||||
|| self.pool_meta_guard.is_released()
|
||||
|| self.rebalance_meta_guard.is_lock_lost()
|
||||
|| self.rebalance_meta_guard.is_released()
|
||||
{
|
||||
return Err(Error::other("worker admission lock lost before rebalance activation"));
|
||||
}
|
||||
if self
|
||||
.fleet_proof
|
||||
.as_ref()
|
||||
.is_some_and(|proof| !crate::services::notification_sys::cross_pool_fence_fleet_proof_matches(proof))
|
||||
{
|
||||
return Err(Error::other(POOL_ACTIVATION_FLEET_PROOF_EXPIRED));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct PoolRebalanceActivationFence {
|
||||
pool_meta_guard: Arc<rustfs_lock::NamespaceLockGuard>,
|
||||
rebalance_meta_guard: Arc<rustfs_lock::NamespaceLockGuard>,
|
||||
@@ -3762,6 +3811,35 @@ tokio::task_local! {
|
||||
pub(crate) static REBALANCE_ACTIVATION_LOCK_ATTEMPT: Arc<tokio::sync::Notify>;
|
||||
}
|
||||
|
||||
pub(crate) async fn acquire_pool_rebalance_worker_locks<S>(pool: Arc<S>) -> Result<PoolRebalanceWorkerFence>
|
||||
where
|
||||
S: crate::storage_api_contracts::namespace::NamespaceLocking<
|
||||
Error = Error,
|
||||
NamespaceLock = rustfs_lock::NamespaceLockWrapper,
|
||||
>,
|
||||
{
|
||||
// Match activation and entry admission: rebalance.bin -> pool.bin. Joining
|
||||
// the same durable run must coexist with entries retaining their run fence.
|
||||
let rebalance_meta_lock = pool.new_ns_lock(RUSTFS_META_BUCKET, REBAL_META_NAME).await?;
|
||||
#[cfg(test)]
|
||||
let _ = REBALANCE_ACTIVATION_LOCK_ATTEMPT.try_with(|attempted| attempted.notify_one());
|
||||
let rebalance_meta_guard = rebalance_meta_lock
|
||||
.get_read_lock(get_lock_acquire_timeout())
|
||||
.await
|
||||
.map_err(|err| rebalance_worker_read_lock_error(err, REBAL_META_NAME))?;
|
||||
let pool_meta_lock = pool.new_ns_lock(RUSTFS_META_BUCKET, POOL_META_NAME).await?;
|
||||
let pool_meta_guard = pool_meta_lock
|
||||
.get_read_lock(get_lock_acquire_timeout())
|
||||
.await
|
||||
.map_err(|err| rebalance_worker_read_lock_error(err, POOL_META_NAME))?;
|
||||
|
||||
Ok(PoolRebalanceWorkerFence {
|
||||
pool_meta_guard,
|
||||
rebalance_meta_guard,
|
||||
fleet_proof: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn acquire_pool_rebalance_activation_locks<S>(
|
||||
pool: Arc<S>,
|
||||
fleet_proof: Option<crate::services::notification_sys::CrossPoolFenceFleetProofToken>,
|
||||
@@ -6685,12 +6763,12 @@ fn pool_meta_cas_options(object: &str, token: &PoolMetaCasToken, fence: &PoolMet
|
||||
fence.ensure_held()?;
|
||||
let mut opts = ObjectOptions {
|
||||
max_parity: true,
|
||||
write_completion: crate::object_api::WriteCompletion::TailDrained,
|
||||
no_lock: true,
|
||||
http_preconditions: Some(pool_meta_cas_preconditions(token, object)?),
|
||||
..Default::default()
|
||||
};
|
||||
fence.add_to_options(&mut opts, object);
|
||||
opts.write_completion = crate::object_api::WriteCompletion::TailDrainedWithOwnerRepair;
|
||||
Ok(opts)
|
||||
}
|
||||
|
||||
@@ -6718,6 +6796,72 @@ async fn execute_pool_meta_object_cas<S>(
|
||||
fence: &PoolMetaPersistenceFence<'_>,
|
||||
phase: &'static str,
|
||||
) -> PoolMetaCasWriteOutcome
|
||||
where
|
||||
S: EcstoreObjectIO,
|
||||
{
|
||||
// Bound the CAS state retained by callers, including ordinary metadata
|
||||
// transactions that never enter identity repair. Large inline retry states
|
||||
// otherwise propagate through startup and cross-crate storage futures.
|
||||
Box::pin(async move {
|
||||
if object != POOL_META_IDENTITY_NAME {
|
||||
return execute_pool_meta_object_cas_once(pool, object, data, opts, fence, phase).await;
|
||||
}
|
||||
// Keep the exact identity owned by this transaction. A quorum read alone
|
||||
// cannot discharge repair responsibility for a missing disk replica.
|
||||
let identity = data;
|
||||
let mut outcome = execute_pool_meta_object_cas_once(pool.clone(), object, identity.clone(), opts, fence, phase).await;
|
||||
for delay_secs in [1, 2, 4] {
|
||||
if !outcome.source.as_deref().is_some_and(|err| {
|
||||
matches!(err, Error::Io(inner) if inner.get_ref().is_some_and(|source| source.is::<crate::set_disk::SystemMetadataRepairRequired>()))
|
||||
}) {
|
||||
break;
|
||||
}
|
||||
// The caller's transaction arm remains live across reconnect waits,
|
||||
// verification and repair. Cancellation must retain its sticky gate.
|
||||
tokio::time::sleep(std::time::Duration::from_secs(delay_secs)).await;
|
||||
let repair_options = async {
|
||||
fence.ensure_held()?;
|
||||
let (current, info) = read_config_no_lock_preserve_empty_with_metadata(pool.clone(), object).await?;
|
||||
if current != identity {
|
||||
return Err(Error::PreconditionFailed);
|
||||
}
|
||||
let token = info
|
||||
.etag
|
||||
.filter(|etag| !etag.trim().is_empty())
|
||||
.map(PoolMetaCasToken::Existing)
|
||||
.unwrap_or(PoolMetaCasToken::Unsafe);
|
||||
pool_meta_cas_options(object, &token, fence)
|
||||
}
|
||||
.await;
|
||||
outcome = match repair_options {
|
||||
Ok(opts) => execute_pool_meta_object_cas_once(pool.clone(), object, identity.clone(), opts, fence, phase).await,
|
||||
Err(err) => PoolMetaCasWriteOutcome::not_dispatched(err),
|
||||
};
|
||||
// A rejected repair cannot turn the preceding committed write into a
|
||||
// harmless CAS conflict that the outer loop could accept on readback.
|
||||
outcome.may_have_mutated = true;
|
||||
outcome.result = outcome.result.map_err(|err| {
|
||||
let original = outcome.source.get_or_insert_with(|| Arc::new(err));
|
||||
Error::other(pool_metadata_error(
|
||||
crate::error::PoolMetadataFailure::TransactionUnknown,
|
||||
phase,
|
||||
Some(Arc::clone(original)),
|
||||
))
|
||||
});
|
||||
}
|
||||
outcome
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn execute_pool_meta_object_cas_once<S>(
|
||||
pool: Arc<S>,
|
||||
object: &str,
|
||||
data: Vec<u8>,
|
||||
opts: ObjectOptions,
|
||||
fence: &PoolMetaPersistenceFence<'_>,
|
||||
phase: &'static str,
|
||||
) -> PoolMetaCasWriteOutcome
|
||||
where
|
||||
S: EcstoreObjectIO,
|
||||
{
|
||||
@@ -6735,7 +6879,11 @@ where
|
||||
"payload_sha256": rustfs_utils::crypto::hex(Sha256::digest(&data)),
|
||||
"if_match": opts.http_preconditions.as_ref().and_then(|p| p.if_match.as_deref()),
|
||||
"if_none_match": opts.http_preconditions.as_ref().and_then(|p| p.if_none_match.as_deref()),
|
||||
"tail_drained": opts.write_completion == crate::object_api::WriteCompletion::TailDrained,
|
||||
"tail_drained": matches!(
|
||||
opts.write_completion,
|
||||
crate::object_api::WriteCompletion::TailDrained
|
||||
| crate::object_api::WriteCompletion::TailDrainedWithOwnerRepair
|
||||
),
|
||||
"no_lock": opts.no_lock,
|
||||
})
|
||||
});
|
||||
@@ -12430,6 +12578,18 @@ impl ECStore {
|
||||
Ok(pool_meta)
|
||||
}
|
||||
|
||||
pub(crate) async fn load_runtime_pool_meta_under_worker_fence(
|
||||
&self,
|
||||
write_state: &mut PoolMetaWriteState,
|
||||
worker_fence: &PoolRebalanceWorkerFence,
|
||||
operation: &str,
|
||||
) -> Result<PoolMeta> {
|
||||
worker_fence.ensure_held()?;
|
||||
let pool_meta = self.load_runtime_pool_meta_observing(write_state, operation).await?;
|
||||
worker_fence.ensure_held()?;
|
||||
Ok(pool_meta)
|
||||
}
|
||||
|
||||
pub(crate) async fn load_runtime_pool_meta_under_activation_fence(
|
||||
&self,
|
||||
write_state: &mut PoolMetaWriteState,
|
||||
@@ -24216,6 +24376,335 @@ mod pools_tests {
|
||||
identity: StdMutex<Option<(Vec<u8>, String)>>,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_meta_cas_future_remains_stack_bounded() {
|
||||
let pool = Arc::new(PartialPoolMetaWriteStorage::default());
|
||||
let fence = PoolMetaPersistenceFence::OfflineRecovery;
|
||||
let future = super::execute_pool_meta_object_cas(
|
||||
pool.clone(),
|
||||
POOL_META_IDENTITY_NAME,
|
||||
b"owned-identity".to_vec(),
|
||||
ObjectOptions::default(),
|
||||
&fence,
|
||||
"identity_cas",
|
||||
);
|
||||
let original = super::execute_pool_meta_object_cas_once(
|
||||
pool,
|
||||
POOL_META_IDENTITY_NAME,
|
||||
b"owned-identity".to_vec(),
|
||||
ObjectOptions::default(),
|
||||
&fence,
|
||||
"identity_cas",
|
||||
);
|
||||
let future_size = std::mem::size_of_val(&future);
|
||||
let original_size = std::mem::size_of_val(&original);
|
||||
assert!(
|
||||
future_size <= 4 * 1024,
|
||||
"pool metadata CAS future must remain stack-bounded; measured {future_size} bytes versus {original_size} without repair"
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum IdentityRepairFault {
|
||||
Healthy,
|
||||
Once,
|
||||
Always,
|
||||
Changed,
|
||||
Unreadable,
|
||||
TypedReadFailure,
|
||||
MissingEtag,
|
||||
EmptyEtag,
|
||||
Reject,
|
||||
Unknown,
|
||||
BlockBackoff,
|
||||
BlockRead,
|
||||
BlockWrite,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct IdentityRepairStorage {
|
||||
inner: PartialPoolMetaWriteStorage,
|
||||
fault: IdentityRepairFault,
|
||||
writes: AtomicUsize,
|
||||
reads: AtomicUsize,
|
||||
blocked: tokio::sync::Notify,
|
||||
}
|
||||
|
||||
impl IdentityRepairStorage {
|
||||
fn new(fault: IdentityRepairFault) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
inner: PartialPoolMetaWriteStorage::default(),
|
||||
fault,
|
||||
writes: AtomicUsize::new(0),
|
||||
reads: AtomicUsize::new(0),
|
||||
blocked: tokio::sync::Notify::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ObjectIO for IdentityRepairStorage {
|
||||
type Error = Error;
|
||||
type RangeSpec = HTTPRangeSpec;
|
||||
type HeaderMap = http::HeaderMap;
|
||||
type ObjectOptions = ObjectOptions;
|
||||
type ObjectInfo = ObjectInfo;
|
||||
type GetObjectReader = crate::object_api::GetObjectReader;
|
||||
type PutObjectReader = crate::object_api::PutObjReader;
|
||||
|
||||
async fn get_object_reader(
|
||||
&self,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
range: Option<HTTPRangeSpec>,
|
||||
headers: http::HeaderMap,
|
||||
opts: &ObjectOptions,
|
||||
) -> std::result::Result<Self::GetObjectReader, Error> {
|
||||
self.reads.fetch_add(1, Ordering::SeqCst);
|
||||
if self.fault == IdentityRepairFault::Unreadable {
|
||||
return Err(Error::ErasureReadQuorum);
|
||||
}
|
||||
if self.fault == IdentityRepairFault::TypedReadFailure {
|
||||
return Err(Error::other(super::pool_metadata_error(
|
||||
crate::error::PoolMetadataFailure::ReadUnavailable,
|
||||
"identity_repair_read",
|
||||
Some(Arc::new(Error::ErasureReadQuorum)),
|
||||
)));
|
||||
}
|
||||
if self.fault == IdentityRepairFault::BlockRead {
|
||||
self.blocked.notify_one();
|
||||
std::future::pending::<()>().await;
|
||||
}
|
||||
let mut reader = self.inner.get_object_reader(bucket, object, range, headers, opts).await?;
|
||||
match self.fault {
|
||||
IdentityRepairFault::Changed => {
|
||||
reader.stream = Box::new(Cursor::new(b"other-identity".to_vec()));
|
||||
reader.object_info.size = 14;
|
||||
}
|
||||
IdentityRepairFault::MissingEtag => reader.object_info.etag = None,
|
||||
IdentityRepairFault::EmptyEtag => reader.object_info.etag = Some(" ".to_owned()),
|
||||
_ => {}
|
||||
}
|
||||
Ok(reader)
|
||||
}
|
||||
|
||||
async fn put_object(
|
||||
&self,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
data: &mut Self::PutObjectReader,
|
||||
opts: &ObjectOptions,
|
||||
) -> std::result::Result<ObjectInfo, Error> {
|
||||
let attempt = self.writes.fetch_add(1, Ordering::SeqCst);
|
||||
assert_eq!(opts.write_completion, crate::object_api::WriteCompletion::TailDrainedWithOwnerRepair);
|
||||
assert!(opts.max_parity && opts.no_lock);
|
||||
if attempt > 0 {
|
||||
assert_eq!(
|
||||
opts.http_preconditions
|
||||
.as_ref()
|
||||
.and_then(HTTPPreconditions::if_none_match_value),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
opts.http_preconditions.as_ref().and_then(HTTPPreconditions::if_match_value),
|
||||
self.inner
|
||||
.identity
|
||||
.lock()
|
||||
.expect("identity lock")
|
||||
.as_ref()
|
||||
.map(|(_, etag)| etag.as_str()),
|
||||
"repair must use the observed post-commit CAS token",
|
||||
);
|
||||
if self.fault == IdentityRepairFault::Reject {
|
||||
return Err(Error::PreconditionFailed);
|
||||
}
|
||||
if self.fault == IdentityRepairFault::BlockWrite {
|
||||
self.blocked.notify_one();
|
||||
std::future::pending::<()>().await;
|
||||
}
|
||||
}
|
||||
let info = self.inner.put_object(bucket, object, data, opts).await?;
|
||||
if self.fault == IdentityRepairFault::Unknown {
|
||||
return Err(Error::Timeout);
|
||||
}
|
||||
if self.fault != IdentityRepairFault::Healthy && (attempt == 0 || self.fault == IdentityRepairFault::Always) {
|
||||
if self.fault == IdentityRepairFault::BlockBackoff {
|
||||
self.blocked.notify_one();
|
||||
}
|
||||
return Err(Error::other(crate::set_disk::SystemMetadataRepairRequired));
|
||||
}
|
||||
Ok(info)
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn pool_identity_owner_repair_preserves_cas_and_fail_closed_boundaries() {
|
||||
for (fault, object, expected_writes, expected_reads, succeeds) in [
|
||||
(IdentityRepairFault::Healthy, POOL_META_IDENTITY_NAME, 1, 0, true),
|
||||
(IdentityRepairFault::Once, POOL_META_IDENTITY_NAME, 2, 1, true),
|
||||
(IdentityRepairFault::Always, POOL_META_IDENTITY_NAME, 4, 3, false),
|
||||
(IdentityRepairFault::Changed, POOL_META_IDENTITY_NAME, 1, 1, false),
|
||||
(IdentityRepairFault::Unreadable, POOL_META_IDENTITY_NAME, 1, 1, false),
|
||||
(IdentityRepairFault::TypedReadFailure, POOL_META_IDENTITY_NAME, 1, 1, false),
|
||||
(IdentityRepairFault::MissingEtag, POOL_META_IDENTITY_NAME, 1, 1, false),
|
||||
(IdentityRepairFault::EmptyEtag, POOL_META_IDENTITY_NAME, 1, 1, false),
|
||||
(IdentityRepairFault::Reject, POOL_META_IDENTITY_NAME, 2, 1, false),
|
||||
(IdentityRepairFault::Unknown, POOL_META_IDENTITY_NAME, 1, 0, false),
|
||||
(IdentityRepairFault::Once, POOL_META_NAME, 1, 0, false),
|
||||
] {
|
||||
let pool = IdentityRepairStorage::new(fault);
|
||||
let state = super::PoolMetaWriteState::default();
|
||||
let mut transaction = state.arm_transaction();
|
||||
let started = tokio::time::Instant::now();
|
||||
let result = save_pool_meta_object_cas(
|
||||
pool.clone(),
|
||||
object,
|
||||
b"owned-identity".to_vec(),
|
||||
&PoolMetaCasToken::Missing,
|
||||
&PoolMetaPersistenceFence::OfflineRecovery,
|
||||
"identity_cas",
|
||||
&mut transaction,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(result.is_ok(), succeeds, "{fault:?}: {result:?}");
|
||||
let original_source = result
|
||||
.as_ref()
|
||||
.err()
|
||||
.and_then(|err| err.pool_metadata_failure())
|
||||
.and_then(|failure| failure.source.clone());
|
||||
if fault == IdentityRepairFault::TypedReadFailure {
|
||||
assert_eq!(
|
||||
original_source
|
||||
.as_ref()
|
||||
.expect("read failure source")
|
||||
.pool_metadata_failure()
|
||||
.expect("retain the original read context")
|
||||
.kind,
|
||||
crate::error::PoolMetadataFailure::ReadUnavailable
|
||||
);
|
||||
}
|
||||
if succeeds {
|
||||
transaction.disarm();
|
||||
} else {
|
||||
assert_eq!(
|
||||
result
|
||||
.expect_err("failed repair")
|
||||
.pool_metadata_failure()
|
||||
.expect("typed uncertainty")
|
||||
.kind,
|
||||
crate::error::PoolMetadataFailure::TransactionUnknown,
|
||||
"{fault:?}"
|
||||
);
|
||||
}
|
||||
drop(transaction);
|
||||
let status = state.ensure_write_safe("after repair");
|
||||
assert_eq!(status.is_ok(), succeeds, "{fault:?}");
|
||||
if let Err(blocked) = status {
|
||||
let source = blocked
|
||||
.pool_metadata_failure()
|
||||
.expect("blocked transaction context")
|
||||
.source
|
||||
.as_ref()
|
||||
.expect("blocked transaction source");
|
||||
assert!(
|
||||
Arc::ptr_eq(source, original_source.as_ref().expect("caller failure source")),
|
||||
"caller and latch must retain the same original cause: {fault:?}"
|
||||
);
|
||||
}
|
||||
assert_eq!(pool.writes.load(Ordering::SeqCst), expected_writes, "{fault:?}");
|
||||
assert_eq!(pool.reads.load(Ordering::SeqCst), expected_reads, "{fault:?}");
|
||||
if fault == IdentityRepairFault::Always {
|
||||
assert_eq!(started.elapsed(), StdDuration::from_secs(7), "repair backoff must be bounded");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn pool_identity_owner_repair_cancellation_keeps_the_transaction_armed() {
|
||||
for fault in [
|
||||
IdentityRepairFault::BlockBackoff,
|
||||
IdentityRepairFault::BlockRead,
|
||||
IdentityRepairFault::BlockWrite,
|
||||
] {
|
||||
let pool = IdentityRepairStorage::new(fault);
|
||||
let state = Arc::new(tokio::sync::Mutex::new(super::PoolMetaWriteState::default()));
|
||||
let task = tokio::spawn({
|
||||
let pool = pool.clone();
|
||||
let state = state.clone();
|
||||
async move {
|
||||
let state = state.lock().await;
|
||||
let mut transaction = state.arm_transaction();
|
||||
save_pool_meta_object_cas(
|
||||
pool,
|
||||
POOL_META_IDENTITY_NAME,
|
||||
b"owned-identity".to_vec(),
|
||||
&PoolMetaCasToken::Missing,
|
||||
&PoolMetaPersistenceFence::OfflineRecovery,
|
||||
"identity_cas",
|
||||
&mut transaction,
|
||||
)
|
||||
.await
|
||||
}
|
||||
});
|
||||
pool.blocked.notified().await;
|
||||
task.abort();
|
||||
assert!(task.await.expect_err("cancelled repair").is_cancelled());
|
||||
let state = state.lock().await;
|
||||
let error = state
|
||||
.ensure_write_safe("cancelled repair")
|
||||
.expect_err("partial identity remains blocked");
|
||||
assert_eq!(error.pool_metadata_failure().expect("typed uncertainty").phase, "identity_cas");
|
||||
assert_eq!(state.active_transactions.load(Ordering::SeqCst), 0);
|
||||
assert!(pool.inner.identity.lock().expect("identity lock").is_some());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pool_identity_owner_repair_stops_after_fence_loss() {
|
||||
let client = Arc::new(LocalClient::with_manager(Arc::new(GlobalLockManager::new())));
|
||||
let lock = NamespaceLock::with_clients_and_quorum("identity-repair-fence".to_string(), vec![client], 1);
|
||||
let request = LockRequest::new(
|
||||
ObjectKey::new(super::RUSTFS_META_BUCKET, POOL_META_NAME),
|
||||
LockType::Exclusive,
|
||||
"identity-repair-owner",
|
||||
)
|
||||
.with_acquire_timeout(StdDuration::from_secs(1))
|
||||
.with_ttl(StdDuration::from_millis(50))
|
||||
.with_refresh_interval(StdDuration::from_millis(50));
|
||||
let owner = Arc::new(
|
||||
lock.acquire_guard(&request)
|
||||
.await
|
||||
.expect("acquire fence")
|
||||
.expect("available fence"),
|
||||
);
|
||||
let fence = PoolMetaPersistenceFence::Owned(owner.clone());
|
||||
let pool = IdentityRepairStorage::new(IdentityRepairFault::Once);
|
||||
let state = super::PoolMetaWriteState::default();
|
||||
let mut transaction = state.arm_transaction();
|
||||
let error = save_pool_meta_object_cas(
|
||||
pool.clone(),
|
||||
POOL_META_IDENTITY_NAME,
|
||||
b"owned-identity".to_vec(),
|
||||
&PoolMetaCasToken::Missing,
|
||||
&fence,
|
||||
"identity_cas",
|
||||
&mut transaction,
|
||||
)
|
||||
.await
|
||||
.expect_err("expired owner must not repair");
|
||||
assert!(owner.is_lock_lost());
|
||||
assert_eq!(
|
||||
error.pool_metadata_failure().expect("typed uncertainty").kind,
|
||||
crate::error::PoolMetadataFailure::TransactionUnknown
|
||||
);
|
||||
assert_eq!(pool.writes.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(pool.reads.load(Ordering::SeqCst), 0);
|
||||
drop(transaction);
|
||||
state
|
||||
.ensure_write_safe("lost repair fence")
|
||||
.expect_err("lost fence retains the write gate");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decommission_target_gate_retry_is_bounded() {
|
||||
assert!(!decommission_capacity_target_gate_retry_exhausted(0));
|
||||
@@ -24692,7 +25181,7 @@ mod pools_tests {
|
||||
_ => panic!("phase fixture must receive valid pool metadata"),
|
||||
};
|
||||
assert!(opts.max_parity && opts.no_lock);
|
||||
assert_eq!(opts.write_completion, crate::object_api::WriteCompletion::TailDrained);
|
||||
assert_eq!(opts.write_completion, crate::object_api::WriteCompletion::TailDrainedWithOwnerRepair);
|
||||
self.trace.calls.lock().expect("trace lock").push(WriteCall {
|
||||
pool: self.pool,
|
||||
phase,
|
||||
|
||||
@@ -893,6 +893,8 @@ fn is_data_movement_rewritten_metadata(object_info: &ObjectInfo, key: &str, norm
|
||||
SUFFIX_ACTUAL_SIZE,
|
||||
SUFFIX_CRC,
|
||||
SUFFIX_PART_CHECKSUMS,
|
||||
// Inline placement is local to each physical copy, like its data directory.
|
||||
rustfs_utils::http::SUFFIX_INLINE_DATA,
|
||||
crate::object_api::ENCRYPTED_PART_LAYOUT_QUORUM_SUFFIX,
|
||||
]
|
||||
.iter()
|
||||
@@ -3532,6 +3534,23 @@ mod tests {
|
||||
assert!(overwrite_resume_for_target(&source, source.clone()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_data_movement_identity_accepts_inline_layout_change_only() {
|
||||
let source = overwrite_equivalence_source();
|
||||
let mut target = source.clone();
|
||||
rustfs_utils::http::insert_str(
|
||||
Arc::make_mut(&mut target.user_defined),
|
||||
rustfs_utils::http::SUFFIX_INLINE_DATA,
|
||||
"true".to_string(),
|
||||
);
|
||||
assert!(is_equivalent_data_movement_object(&source, &target));
|
||||
assert!(is_equivalent_data_movement_object(&target, &source));
|
||||
|
||||
Arc::make_mut(&mut target.user_defined).insert("x-amz-meta-inline-data".to_string(), "user value".to_string());
|
||||
assert!(!is_equivalent_data_movement_object(&source, &target));
|
||||
assert!(!is_equivalent_data_movement_object(&target, &source));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_data_movement_overwrite_resume_accepts_destination_marker() {
|
||||
let source = overwrite_equivalence_source();
|
||||
|
||||
@@ -110,19 +110,17 @@ async fn read_replica(set: Arc<SetDisks>) -> Result<ScannerPauseBacklogRetiremen
|
||||
set_index: set.set_index,
|
||||
data: None,
|
||||
};
|
||||
let reader = match set
|
||||
.get_object_reader(
|
||||
RUSTFS_META_BUCKET,
|
||||
SCANNER_PAUSE_BACKLOG_PATH,
|
||||
None,
|
||||
HeaderMap::new(),
|
||||
&ObjectOptions {
|
||||
no_lock: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
let opts = ObjectOptions {
|
||||
no_lock: true,
|
||||
..Default::default()
|
||||
};
|
||||
let read = set.get_object_reader(RUSTFS_META_BUCKET, SCANNER_PAUSE_BACKLOG_PATH, None, HeaderMap::new(), &opts);
|
||||
#[cfg(feature = "diagnostic-read-version-caller")]
|
||||
let read = crate::cluster::rpc::runtime_sources::with_read_version_entry(
|
||||
rustfs_io_metrics::internode_metrics::ReadVersionEntrySource::ScannerBacklogRead,
|
||||
read,
|
||||
);
|
||||
let reader = match read.await {
|
||||
Ok(reader) => reader,
|
||||
Err(err) if is_err_object_not_found(&err) || is_err_version_not_found(&err) => {
|
||||
return Ok(ScannerPauseBacklogRetirementRead { replica, etag: None });
|
||||
|
||||
@@ -1325,6 +1325,18 @@ fn select_admin_data_usage_snapshot(
|
||||
&& authoritative.is_complete_bucket_usage_snapshot()
|
||||
&& observed_data_usage_is_newer(&observed, &authoritative) =>
|
||||
{
|
||||
// A bucket can span several sets. Without a current or retained
|
||||
// generation for every live set, its observed sum is only a subset,
|
||||
// not a replacement for the last complete bucket total. Keep the
|
||||
// baseline's timestamp and provenance until coverage is available.
|
||||
if authoritative_format
|
||||
&& observed
|
||||
.usage_snapshot_set_states
|
||||
.iter()
|
||||
.any(|state| !state.tombstone && state.scanner_cycle.is_none())
|
||||
{
|
||||
return (authoritative, authoritative_format);
|
||||
}
|
||||
(merge_partial_observation_for_admin(authoritative, observed), true)
|
||||
}
|
||||
Some(observed) if observed_data_usage_is_newer(&observed, &authoritative) => (observed, true),
|
||||
@@ -2466,7 +2478,13 @@ async fn apply_bucket_usage_memory_overlay_if_authoritative(data_usage_info: &mu
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if !cached.stale_snapshot_pending && persisted_update.is_some_and(|persisted| cached.usage_updated_at <= persisted) {
|
||||
// An observed scan can finish after mutations it never enumerated. Its
|
||||
// completion time cannot supersede unreconciled single-process counts.
|
||||
let observed_dirty_usage = data_usage_info.usage_snapshot_converged == Some(false) && cached.dirty;
|
||||
if !cached.stale_snapshot_pending
|
||||
&& !observed_dirty_usage
|
||||
&& persisted_update.is_some_and(|persisted| cached.usage_updated_at <= persisted)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -2529,19 +2547,17 @@ where
|
||||
use crate::object_api::ObjectOptions;
|
||||
use http::HeaderMap;
|
||||
|
||||
match store
|
||||
.get_object_reader(
|
||||
RUSTFS_META_BUCKET,
|
||||
key,
|
||||
None,
|
||||
HeaderMap::new(),
|
||||
&ObjectOptions {
|
||||
no_lock: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
let opts = ObjectOptions {
|
||||
no_lock: true,
|
||||
..Default::default()
|
||||
};
|
||||
let read = store.get_object_reader(RUSTFS_META_BUCKET, key, None, HeaderMap::new(), &opts);
|
||||
#[cfg(feature = "diagnostic-read-version-caller")]
|
||||
let read = crate::cluster::rpc::runtime_sources::with_read_version_entry(
|
||||
rustfs_io_metrics::internode_metrics::ReadVersionEntrySource::DataUsageCacheRead,
|
||||
read,
|
||||
);
|
||||
match read.await {
|
||||
// A cache object that fails to decode is treated as absent rather than
|
||||
// as an error: a corrupt cache should not stall the caller, and the
|
||||
// next scanner pass rewrites it.
|
||||
@@ -3064,6 +3080,8 @@ mod tests {
|
||||
transient_failures: Mutex<usize>,
|
||||
reads: Mutex<Vec<String>>,
|
||||
terminal_error: Mutex<Option<Error>>,
|
||||
#[cfg(feature = "diagnostic-read-version-caller")]
|
||||
record_context: bool,
|
||||
}
|
||||
|
||||
impl UsageCacheReadStore {
|
||||
@@ -3072,6 +3090,8 @@ mod tests {
|
||||
transient_failures: Mutex::new(n),
|
||||
reads: Mutex::new(Vec::new()),
|
||||
terminal_error: Mutex::new(None),
|
||||
#[cfg(feature = "diagnostic-read-version-caller")]
|
||||
record_context: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3080,6 +3100,8 @@ mod tests {
|
||||
transient_failures: Mutex::new(0),
|
||||
reads: Mutex::new(Vec::new()),
|
||||
terminal_error: Mutex::new(Some(error)),
|
||||
#[cfg(feature = "diagnostic-read-version-caller")]
|
||||
record_context: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3106,6 +3128,12 @@ mod tests {
|
||||
_h: Self::HeaderMap,
|
||||
_opts: &Self::ObjectOptions,
|
||||
) -> Result<Self::GetObjectReader, Self::Error> {
|
||||
#[cfg(feature = "diagnostic-read-version-caller")]
|
||||
if self.record_context {
|
||||
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_read_version_request(
|
||||
&crate::disk::ReadOptions::default(),
|
||||
);
|
||||
}
|
||||
self.reads.lock().await.push(object.to_string());
|
||||
if let Some(error) = self.terminal_error.lock().await.clone() {
|
||||
return Err(error);
|
||||
@@ -3205,6 +3233,89 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "diagnostic-read-version-caller")]
|
||||
#[test]
|
||||
fn diagnostic_usage_cache_read_preserves_api_and_earlier_entry() {
|
||||
use crate::cluster::rpc::runtime_sources::with_read_version_entry;
|
||||
use metrics_util::debugging::{DebugValue, DebuggingRecorder};
|
||||
use rustfs_io_metrics::internode_metrics::ReadVersionEntrySource;
|
||||
use rustfs_io_metrics::s3_http_metrics::{
|
||||
S3HttpRequestGuard, current_s3_http_operation_context, with_s3_http_operation_context,
|
||||
};
|
||||
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("diagnostic usage cache runtime");
|
||||
let recorder = DebuggingRecorder::new();
|
||||
let snapshots = recorder.snapshotter();
|
||||
rustfs_io_metrics::set_get_stage_metrics_enabled(true);
|
||||
let mut request = S3HttpRequestGuard::new("GET");
|
||||
let get = request.in_scope(|| {
|
||||
rustfs_io_metrics::record_s3_op(rustfs_s3_ops::S3Operation::GetObject);
|
||||
current_s3_http_operation_context().expect("dispatched GET operation")
|
||||
});
|
||||
request.response(200);
|
||||
metrics::with_local_recorder(&recorder, || {
|
||||
runtime.block_on(async {
|
||||
let ambient = crate::cluster::rpc::runtime_sources::ReadVersionDiagnosticContext::capture();
|
||||
for (operation, source) in [
|
||||
(None, None),
|
||||
(Some(get), None),
|
||||
(None, Some(ReadVersionEntrySource::ServerInfoPools)),
|
||||
(Some(get), Some(ReadVersionEntrySource::StoreObjectReader)),
|
||||
] {
|
||||
let store = UsageCacheReadStore {
|
||||
record_context: true,
|
||||
..Default::default()
|
||||
};
|
||||
with_s3_http_operation_context(operation, async {
|
||||
let load = load_data_usage_cache(&store, "usage-cache");
|
||||
let cache = match source {
|
||||
Some(source) => with_read_version_entry(source, load).await,
|
||||
None => load.await,
|
||||
}
|
||||
.expect("missing diagnostic cache is harmless");
|
||||
assert!(cache.cache.is_empty());
|
||||
assert_eq!(store.read_keys().await.len(), 2, "both prefixed and legacy cache reads are observed");
|
||||
})
|
||||
.await;
|
||||
assert_eq!(
|
||||
crate::cluster::rpc::runtime_sources::ReadVersionDiagnosticContext::capture(),
|
||||
ambient,
|
||||
"cache reads must restore the caller's complete diagnostic scope"
|
||||
);
|
||||
}
|
||||
assert_eq!(current_s3_http_operation_context(), None);
|
||||
});
|
||||
});
|
||||
let rows = snapshots.snapshot().into_vec();
|
||||
for (source, operation, state) in [
|
||||
("data_usage_cache_read", "unattributed", "absent"),
|
||||
("data_usage_cache_read", "s3:GetObject", "preserved"),
|
||||
("server_info_pools", "unattributed", "absent"),
|
||||
("store_object_reader", "s3:GetObject", "preserved"),
|
||||
] {
|
||||
assert!(
|
||||
rows.iter().any(|(key, _, _, value)| {
|
||||
let labels = key
|
||||
.key()
|
||||
.labels()
|
||||
.map(|label| (label.key(), label.value()))
|
||||
.collect::<std::collections::HashMap<_, _>>();
|
||||
key.key().name() == "rustfs_storage_diagnostic_context_transitions_total"
|
||||
&& labels.get("entry_source") == Some(&source)
|
||||
&& labels.get("boundary") == Some(&"remote_rpc")
|
||||
&& labels.get("parent_api_operation") == Some(&operation)
|
||||
&& labels.get("child_api_operation") == Some(&operation)
|
||||
&& labels.get("api_context") == Some(&state)
|
||||
&& matches!(value, DebugValue::Counter(2))
|
||||
}),
|
||||
"cache reads must retain API and the earliest observed entry: {source}/{operation}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn data_usage_removal_surfaces_missing_system_volume_as_read_quorum() {
|
||||
for cause in [Error::BucketNotFound(RUSTFS_META_BUCKET.to_string()), Error::VolumeNotFound] {
|
||||
@@ -3718,6 +3829,169 @@ mod tests {
|
||||
assert!(selected.unknown_tier_stats.is_none());
|
||||
}
|
||||
|
||||
fn usage_snapshots_with_missing_observed_set() -> (DataUsageInfo, DataUsageInfo) {
|
||||
let baseline_time = SystemTime::UNIX_EPOCH + Duration::from_secs(10);
|
||||
let mut authoritative = data_usage_info_for_test("large-bucket", 510_000, 580_000_000_000, baseline_time);
|
||||
authoritative.scanner_epoch = Some(4);
|
||||
authoritative.scanner_cycle = Some(10);
|
||||
authoritative.usage_snapshot_converged = Some(true);
|
||||
|
||||
let mut observed = data_usage_info_for_test("large-bucket", 3_000, 3_000_000_000, baseline_time + Duration::from_secs(1));
|
||||
observed.scanner_epoch = Some(4);
|
||||
observed.scanner_cycle = Some(11);
|
||||
observed.usage_snapshot_complete = false;
|
||||
observed.usage_snapshot_partial = true;
|
||||
observed.usage_snapshot_converged = Some(false);
|
||||
observed.usage_snapshot_authoritative_baseline = Some(authoritative.snapshot_identity());
|
||||
observed.usage_snapshot_set_states = vec![
|
||||
rustfs_data_usage::DataUsageSnapshotSetState {
|
||||
pool_index: 0,
|
||||
set_index: 0,
|
||||
scanner_epoch: Some(4),
|
||||
scanner_cycle: Some(11),
|
||||
scan_plan_digest: Some([1; 32]),
|
||||
complete: true,
|
||||
..Default::default()
|
||||
},
|
||||
rustfs_data_usage::DataUsageSnapshotSetState {
|
||||
pool_index: 1,
|
||||
set_index: 0,
|
||||
scan_plan_digest: Some([1; 32]),
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
assert!(
|
||||
observed.is_valid_partial_snapshot(),
|
||||
"a missing set is valid diagnostic progress, not a bucket total"
|
||||
);
|
||||
(authoritative, observed)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_admin_observation_does_not_replace_totals_with_a_missing_set() {
|
||||
let (authoritative, observed) = usage_snapshots_with_missing_observed_set();
|
||||
let identity = authoritative.snapshot_identity();
|
||||
|
||||
let (selected, _) = select_admin_data_usage_snapshot(authoritative, true, Some(observed));
|
||||
|
||||
assert_eq!(selected.buckets_usage["large-bucket"].objects_count, 510_000);
|
||||
assert_eq!(selected.buckets_usage["large-bucket"].size, 580_000_000_000);
|
||||
assert_eq!(selected.objects_total_count, 510_000);
|
||||
assert_eq!(selected.objects_total_size, 580_000_000_000);
|
||||
assert_eq!(
|
||||
selected.snapshot_identity(),
|
||||
identity,
|
||||
"retained counts must not gain a newer timestamp or generation"
|
||||
);
|
||||
assert!(selected.is_complete_bucket_usage_snapshot());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn partial_admin_observation_does_not_promote_a_legacy_baseline() {
|
||||
let (legacy, observed) = usage_snapshots_with_missing_observed_set();
|
||||
let (selected, current_format) = select_admin_data_usage_snapshot(legacy, false, Some(observed));
|
||||
let (normalized, _) = normalize_loaded_data_usage(selected, current_format).await;
|
||||
|
||||
assert!(normalized.is_valid_partial_snapshot());
|
||||
assert!(!normalized.is_complete_bucket_usage_snapshot());
|
||||
assert_eq!(normalized.usage_snapshot_converged, Some(false));
|
||||
assert_eq!(normalized.objects_total_count, 3_000);
|
||||
assert_eq!(normalized.objects_total_size, 3_000_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_admin_observation_accepts_decreases_when_every_set_is_covered() {
|
||||
let (authoritative, mut observed) = usage_snapshots_with_missing_observed_set();
|
||||
// A compatible retained generation is useful even when it is not a
|
||||
// completed scan of the current cycle. A tombstone explicitly removes
|
||||
// a set; neither case is the same as an unavailable live set.
|
||||
for tombstone in [false, true] {
|
||||
observed.usage_snapshot_set_states[1].tombstone = tombstone;
|
||||
observed.usage_snapshot_set_states[1].scanner_epoch = (!tombstone).then_some(4);
|
||||
observed.usage_snapshot_set_states[1].scanner_cycle = (!tombstone).then_some(10);
|
||||
let (selected, _) = select_admin_data_usage_snapshot(authoritative.clone(), true, Some(observed.clone()));
|
||||
|
||||
assert_eq!(selected.objects_total_count, 3_000);
|
||||
assert_eq!(selected.objects_total_size, 3_000_000_000);
|
||||
assert!(selected.is_valid_partial_snapshot());
|
||||
assert!(!selected.is_complete_bucket_usage_snapshot());
|
||||
assert_eq!(selected.usage_snapshot_converged, Some(false));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn partial_admin_observation_preserves_persisted_totals_across_cache_reload() {
|
||||
use crate::bucket::metadata_sys::test_support::isolated_store_over_temp_disks;
|
||||
use crate::config::com::save_config;
|
||||
|
||||
let (_dirs, store) = isolated_store_over_temp_disks().await;
|
||||
let (authoritative, observed) = usage_snapshots_with_missing_observed_set();
|
||||
let primary_bytes = serde_json::to_vec(&authoritative).expect("encode complete usage");
|
||||
let observed_bytes = serde_json::to_vec(&observed).expect("encode partial observation");
|
||||
save_config(store.clone(), DATA_USAGE_OBJ_NAME_PATH.as_str(), primary_bytes.clone())
|
||||
.await
|
||||
.expect("persist complete usage");
|
||||
save_config(store.clone(), DATA_USAGE_OBSERVED_OBJ_NAME_PATH.as_str(), observed_bytes.clone())
|
||||
.await
|
||||
.expect("persist observation with a missing set");
|
||||
clear_usage_memory_cache_for_test().await;
|
||||
invalidate_data_usage_snapshot_cache().await;
|
||||
|
||||
for reload in [false, false, true] {
|
||||
if reload {
|
||||
// Recreate process-local usage state, not an OS process restart.
|
||||
clear_usage_memory_cache_for_test().await;
|
||||
invalidate_data_usage_snapshot_cache().await;
|
||||
}
|
||||
let mut displayed = load_admin_data_usage_from_backend_cached(store.clone())
|
||||
.await
|
||||
.expect("load admin usage through the real snapshot cache");
|
||||
apply_bucket_usage_memory_overlay(&mut displayed).await;
|
||||
assert_eq!(displayed.objects_total_count, 510_000);
|
||||
assert_eq!(displayed.objects_total_size, 580_000_000_000);
|
||||
assert_eq!(displayed.bucket_sizes["large-bucket"], 580_000_000_000);
|
||||
assert_eq!(displayed.snapshot_identity(), authoritative.snapshot_identity());
|
||||
}
|
||||
let quota = load_data_usage_from_backend_cached(store.clone())
|
||||
.await
|
||||
.expect("load quota authority");
|
||||
assert_eq!(quota.objects_total_size, 580_000_000_000);
|
||||
assert_eq!(
|
||||
read_config(store.clone(), DATA_USAGE_OBJ_NAME_PATH.as_str())
|
||||
.await
|
||||
.expect("read primary"),
|
||||
primary_bytes
|
||||
);
|
||||
assert_eq!(
|
||||
read_config(store.clone(), DATA_USAGE_OBSERVED_OBJ_NAME_PATH.as_str())
|
||||
.await
|
||||
.expect("read diagnostic observation"),
|
||||
observed_bytes,
|
||||
"admin reads must not rewrite either persisted snapshot"
|
||||
);
|
||||
|
||||
let mut reconciled = data_usage_info_for_test("large-bucket", 2, 42, SystemTime::UNIX_EPOCH + Duration::from_secs(12));
|
||||
reconciled.scanner_epoch = Some(4);
|
||||
reconciled.scanner_cycle = Some(12);
|
||||
reconciled.usage_snapshot_converged = Some(true);
|
||||
save_config(
|
||||
store.clone(),
|
||||
DATA_USAGE_OBJ_NAME_PATH.as_str(),
|
||||
serde_json::to_vec(&reconciled).expect("encode confirmed deletion"),
|
||||
)
|
||||
.await
|
||||
.expect("persist a later complete scan after deletion");
|
||||
invalidate_data_usage_snapshot_cache().await;
|
||||
let displayed = load_admin_data_usage_from_backend_cached(store)
|
||||
.await
|
||||
.expect("load confirmed decrease");
|
||||
assert_eq!((displayed.objects_total_count, displayed.objects_total_size), (2, 42));
|
||||
assert_eq!(displayed.snapshot_identity(), reconciled.snapshot_identity());
|
||||
clear_usage_memory_cache_for_test().await;
|
||||
invalidate_data_usage_snapshot_cache().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authoritative_save_cleanup_removes_observed_snapshot_best_effort() {
|
||||
let store = UsageCasStore::default();
|
||||
@@ -6522,6 +6796,117 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn observed_admin_snapshot_preserves_unreconciled_mutations() {
|
||||
let bucket = "observed-mutations";
|
||||
for partial in [false, true] {
|
||||
for delete in [false, true] {
|
||||
for timestamp_offset in [0, 10] {
|
||||
clear_usage_memory_cache_for_test().await;
|
||||
let mut baseline = data_usage_info_for_test(bucket, 2, 20, SystemTime::now() - Duration::from_secs(10));
|
||||
baseline.scanner_epoch = Some(4);
|
||||
baseline.scanner_cycle = Some(10);
|
||||
baseline.usage_snapshot_converged = Some(true);
|
||||
replace_bucket_usage_memory_from_info(&baseline).await;
|
||||
let (expected_count, expected_size) = if delete {
|
||||
record_bucket_object_delete_memory(bucket, 10, true).await;
|
||||
(1, 10)
|
||||
} else {
|
||||
record_bucket_object_write_memory(bucket, None, 10).await;
|
||||
(3, 30)
|
||||
};
|
||||
let mutation_time = memory_cache().read().await[bucket].usage_updated_at;
|
||||
let mut observed = baseline.clone();
|
||||
observed.last_update = Some(mutation_time + Duration::from_secs(timestamp_offset));
|
||||
observed.scanner_cycle = Some(11);
|
||||
observed.usage_snapshot_complete = !partial;
|
||||
observed.usage_snapshot_partial = partial;
|
||||
observed.usage_snapshot_converged = Some(false);
|
||||
observed.usage_snapshot_authoritative_baseline = Some(baseline.snapshot_identity());
|
||||
if partial {
|
||||
observed.usage_snapshot_set_states = vec![rustfs_data_usage::DataUsageSnapshotSetState {
|
||||
pool_index: 0,
|
||||
set_index: 0,
|
||||
scanner_cycle: Some(11),
|
||||
scanner_epoch: Some(4),
|
||||
scan_plan_digest: Some([1; 32]),
|
||||
complete: false,
|
||||
tombstone: false,
|
||||
segment_invalidation_proof: None,
|
||||
}];
|
||||
assert!(observed.is_valid_partial_snapshot());
|
||||
}
|
||||
|
||||
// Observational saves invalidate the admin cache without reconciling
|
||||
// memory. Selecting that newer view must not erase local mutations.
|
||||
let (selected, current_format) = select_admin_data_usage_snapshot(baseline, true, Some(observed));
|
||||
assert_eq!(selected.scanner_cycle, Some(11));
|
||||
let (selected, _) = normalize_loaded_data_usage(selected, current_format).await;
|
||||
let mut distributed = selected.clone();
|
||||
apply_bucket_usage_memory_overlay_if_authoritative(&mut distributed, false).await;
|
||||
assert_eq!((distributed.objects_total_count, distributed.objects_total_size), (2, 20));
|
||||
|
||||
let mut response = selected;
|
||||
apply_bucket_usage_memory_overlay_if_authoritative(&mut response, true).await;
|
||||
assert_eq!(
|
||||
(response.objects_total_count, response.objects_total_size),
|
||||
(expected_count, expected_size),
|
||||
"partial={partial}, delete={delete}, timestamp_offset={timestamp_offset}"
|
||||
);
|
||||
assert_eq!(response.versions_total_count, expected_count);
|
||||
assert_eq!(response.bucket_sizes[bucket], expected_size);
|
||||
assert_eq!(response.buckets_usage[bucket].objects_count, expected_count);
|
||||
assert_eq!(response.usage_snapshot_converged, Some(false));
|
||||
assert_eq!(response.usage_snapshot_partial, partial);
|
||||
let cache = memory_cache().read().await;
|
||||
assert!(cache[bucket].dirty, "an admin read cannot acknowledge mutations");
|
||||
assert!(!cache[bucket].stale_snapshot_pending);
|
||||
assert_eq!(cache[bucket].pending_scanner_position, None);
|
||||
drop(cache);
|
||||
|
||||
// A later converged scan can legitimately correct the count downward;
|
||||
// the display fix must not retain dirty counts or a historical maximum.
|
||||
let mut converged = data_usage_info_for_test(bucket, 0, 0, mutation_time + Duration::from_secs(20));
|
||||
converged.scanner_epoch = Some(4);
|
||||
converged.scanner_cycle = Some(12);
|
||||
converged.usage_snapshot_converged = Some(true);
|
||||
replace_bucket_usage_memory_from_info(&converged).await;
|
||||
apply_bucket_usage_memory_overlay_if_authoritative(&mut converged, true).await;
|
||||
assert_eq!((converged.objects_total_count, converged.objects_total_size), (0, 0));
|
||||
assert!(!memory_cache().read().await[bucket].dirty);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn observed_admin_snapshot_does_not_overlay_clean_or_unknown_usage() {
|
||||
let bucket = "observed-baseline";
|
||||
for unknown in [false, true] {
|
||||
clear_usage_memory_cache_for_test().await;
|
||||
let baseline = data_usage_info_for_test(bucket, 2, 20, SystemTime::now() - Duration::from_secs(10));
|
||||
replace_bucket_usage_memory_from_info(&baseline).await;
|
||||
if unknown {
|
||||
memory_cache().write().await.insert(
|
||||
bucket.to_string(),
|
||||
cached_bucket_usage_from_backend(BucketUsageInfo::default(), SystemTime::UNIX_EPOCH, false),
|
||||
);
|
||||
record_bucket_object_write_memory(bucket, None, 10).await;
|
||||
}
|
||||
let mutation_time = memory_cache().read().await[bucket].usage_updated_at;
|
||||
let mut observed = data_usage_info_for_test(bucket, 100, 1_000, mutation_time + Duration::from_secs(10));
|
||||
observed.usage_snapshot_converged = Some(false);
|
||||
observed.usage_snapshot_authoritative_baseline = Some(baseline.snapshot_identity());
|
||||
let (mut response, _) = select_admin_data_usage_snapshot(baseline, true, Some(observed));
|
||||
apply_bucket_usage_memory_overlay_if_authoritative(&mut response, true).await;
|
||||
assert_eq!((response.objects_total_count, response.objects_total_size), (100, 1_000));
|
||||
assert_eq!(response.usage_snapshot_complete, !unknown);
|
||||
assert_eq!(response.usage_snapshot_converged, Some(false));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn scanner_sync_preserves_dirty_memory_update_with_later_partial_snapshot() {
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
use crate::cluster::rpc::{
|
||||
ScannerBucketListing, TonicInterceptor, gen_tonic_signature_interceptor, node_service_time_out_client,
|
||||
};
|
||||
use crate::data_usage::{DATA_USAGE_CACHE_NAME, DATA_USAGE_ROOT, load_data_usage_from_backend_cached};
|
||||
use crate::data_usage::{
|
||||
DATA_USAGE_CACHE_NAME, DATA_USAGE_ROOT, load_admin_data_usage_from_backend_cached as load_server_info_usage_snapshot,
|
||||
};
|
||||
use crate::error::{Error, Result};
|
||||
use crate::{
|
||||
disk::endpoint::{Endpoint, EndpointType},
|
||||
@@ -37,12 +39,16 @@ use rustfs_protos::{
|
||||
};
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
time::Duration,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use time::OffsetDateTime;
|
||||
use tokio::time::timeout;
|
||||
use tonic::Request;
|
||||
use tracing::warn;
|
||||
use tracing::{error, trace, warn};
|
||||
|
||||
const EVENT_SERVER_PING: &str = "server_ping";
|
||||
const EVENT_SERVER_INFO: &str = "server_info";
|
||||
const LOG_COMPONENT_ECSTORE: &str = "ecstore";
|
||||
const LOG_SUBSYSTEM_DIAGNOSTICS: &str = "diagnostics";
|
||||
|
||||
use shadow_rs::shadow;
|
||||
|
||||
@@ -157,11 +163,22 @@ async fn is_server_resolvable(endpoint: &Endpoint) -> Result<()> {
|
||||
|
||||
let response: PingResponse = client.ping(request).await?.into_inner();
|
||||
|
||||
let ping_response_body = flatbuffers::root::<PingBody>(&response.body);
|
||||
if let Err(e) = ping_response_body {
|
||||
eprintln!("{e}");
|
||||
if flatbuffers::root::<PingBody>(&response.body).is_err() {
|
||||
error!(
|
||||
event = EVENT_SERVER_PING,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_DIAGNOSTICS,
|
||||
result = "invalid_response",
|
||||
"Server ping response decoding failed"
|
||||
);
|
||||
} else {
|
||||
println!("ping_resp:body(flatbuffer): {ping_response_body:?}");
|
||||
trace!(
|
||||
event = EVENT_SERVER_PING,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_DIAGNOSTICS,
|
||||
result = "success",
|
||||
"Server ping completed"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -259,16 +276,10 @@ fn collect_runtime_server_stats() -> (MemStats, u64, u64) {
|
||||
}
|
||||
|
||||
pub async fn get_server_info(get_pools: bool) -> InfoMessage {
|
||||
let nowt: OffsetDateTime = OffsetDateTime::now_utc();
|
||||
|
||||
warn!("get_server_info start {:?}", nowt);
|
||||
let started = tracing::enabled!(tracing::Level::TRACE).then(Instant::now);
|
||||
|
||||
let local = get_local_server_property().await;
|
||||
|
||||
let after1 = OffsetDateTime::now_utc();
|
||||
|
||||
warn!("get_local_server_property end {:?}", after1 - nowt);
|
||||
|
||||
let mut servers = {
|
||||
if let Some(sys) = runtime_sources::notification_sys() {
|
||||
sys.server_info().await
|
||||
@@ -277,9 +288,6 @@ pub async fn get_server_info(get_pools: bool) -> InfoMessage {
|
||||
}
|
||||
};
|
||||
|
||||
let after2 = OffsetDateTime::now_utc();
|
||||
|
||||
warn!("server_info end {:?}", after2 - after1);
|
||||
servers.push(local);
|
||||
|
||||
let mut buckets = rustfs_madmin::Buckets::default();
|
||||
@@ -294,7 +302,7 @@ pub async fn get_server_info(get_pools: bool) -> InfoMessage {
|
||||
if let Some(store) = runtime_sources::object_store_handle() {
|
||||
mode = ITEM_ONLINE;
|
||||
apply_data_usage_result(
|
||||
load_data_usage_from_backend_cached(store.clone()).await,
|
||||
load_server_info_usage_snapshot(store.clone()).await,
|
||||
&mut buckets,
|
||||
&mut objects,
|
||||
&mut versions,
|
||||
@@ -314,15 +322,8 @@ pub async fn get_server_info(get_pools: bool) -> InfoMessage {
|
||||
);
|
||||
}
|
||||
|
||||
let after3 = OffsetDateTime::now_utc();
|
||||
|
||||
warn!("load_data_usage_from_backend end {:?}", after3 - after2);
|
||||
|
||||
let backend_info = StorageAdminApi::backend_info(store.as_ref()).await;
|
||||
|
||||
let after4 = OffsetDateTime::now_utc();
|
||||
|
||||
warn!("backend_info end {:?}", after4 - after3);
|
||||
if let Some(endpoints) = runtime_sources::endpoint_pools() {
|
||||
let (added, report) = reconcile_servers_with_endpoint_topology(&mut servers, &endpoints);
|
||||
if added > 0 || !report.is_complete() {
|
||||
@@ -344,9 +345,6 @@ pub async fn get_server_info(get_pools: bool) -> InfoMessage {
|
||||
}
|
||||
let (online_disks, offline_disks, unknown_disks) = get_online_offline_disks_stats(&all_disks);
|
||||
|
||||
let after5 = OffsetDateTime::now_utc();
|
||||
|
||||
warn!("get_online_offline_disks_stats end {:?}", after5 - after4);
|
||||
backend = rustfs_madmin::ErasureBackend {
|
||||
backend_type: rustfs_madmin::BackendType::ErasureType,
|
||||
online_disks: online_disks.sum(),
|
||||
@@ -359,14 +357,24 @@ pub async fn get_server_info(get_pools: bool) -> InfoMessage {
|
||||
};
|
||||
if get_pools {
|
||||
pools = get_pools_info(&all_disks).await.unwrap_or_default();
|
||||
let after6 = OffsetDateTime::now_utc();
|
||||
|
||||
warn!("get_pools_info end {:?}", after6 - after5);
|
||||
}
|
||||
}
|
||||
|
||||
let services = rustfs_madmin::Services::default();
|
||||
|
||||
if let Some(started) = started {
|
||||
trace!(
|
||||
event = EVENT_SERVER_INFO,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_DIAGNOSTICS,
|
||||
state = "completed",
|
||||
mode,
|
||||
include_pools = get_pools,
|
||||
duration_ms = started.elapsed().as_secs_f64() * 1000.0,
|
||||
"Server info collected"
|
||||
);
|
||||
}
|
||||
|
||||
InfoMessage {
|
||||
mode: Some(mode.to_string()),
|
||||
domain: None,
|
||||
@@ -678,12 +686,16 @@ async fn get_pools_info(all_disks: &[Disk]) -> Result<HashMap<i32, HashMap<i32,
|
||||
|
||||
if erasure_set.id == 0 {
|
||||
erasure_set.id = d.set_index;
|
||||
match load_data_usage_cache(
|
||||
let load = load_data_usage_cache(
|
||||
store.pools[d.pool_index as usize].disk_set[d.set_index as usize].as_ref(),
|
||||
DATA_USAGE_CACHE_NAME,
|
||||
)
|
||||
.await
|
||||
{
|
||||
);
|
||||
#[cfg(feature = "diagnostic-read-version-caller")]
|
||||
let load = crate::cluster::rpc::runtime_sources::with_read_version_entry(
|
||||
rustfs_io_metrics::internode_metrics::ReadVersionEntrySource::ServerInfoPools,
|
||||
load,
|
||||
);
|
||||
match load.await {
|
||||
Ok(cache) => apply_erasure_set_usage(&cache, erasure_set),
|
||||
Err(_) => erasure_set.usage_error = Some(DATA_USAGE_UNAVAILABLE_ERROR.to_string()),
|
||||
}
|
||||
@@ -967,6 +979,93 @@ mod tests {
|
||||
assert_eq!(usage.size, 6);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn server_info_reads_complete_observation_without_promoting_authority() {
|
||||
use crate::bucket::metadata_sys::test_support::isolated_store_over_temp_disks;
|
||||
use crate::config::com::{read_config, save_config};
|
||||
use crate::data_usage::{
|
||||
DATA_USAGE_OBJ_NAME_PATH, DATA_USAGE_OBSERVED_OBJ_NAME_PATH, invalidate_data_usage_snapshot_cache,
|
||||
load_data_usage_from_backend_cached,
|
||||
};
|
||||
use rustfs_data_usage::{BucketUsageInfo, DataUsageInfo};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
let (_dirs, store) = isolated_store_over_temp_disks().await;
|
||||
let primary = DataUsageInfo {
|
||||
last_update: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1)),
|
||||
scanner_epoch: Some(1),
|
||||
usage_snapshot_bootstrap_pending: true,
|
||||
usage_snapshot_converged: Some(false),
|
||||
..Default::default()
|
||||
};
|
||||
let observed = DataUsageInfo {
|
||||
last_update: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(2)),
|
||||
scanner_epoch: Some(1),
|
||||
scanner_cycle: Some(1),
|
||||
buckets_count: 1,
|
||||
objects_total_count: 3,
|
||||
versions_total_count: 4,
|
||||
delete_markers_total_count: 1,
|
||||
objects_total_size: 6,
|
||||
buckets_usage: [(
|
||||
"bucket".to_string(),
|
||||
BucketUsageInfo {
|
||||
objects_count: 3,
|
||||
versions_count: 4,
|
||||
delete_markers_count: 1,
|
||||
size: 6,
|
||||
..Default::default()
|
||||
},
|
||||
)]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
usage_snapshot_complete: true,
|
||||
usage_snapshot_converged: Some(false),
|
||||
usage_snapshot_authoritative_baseline: Some(primary.snapshot_identity()),
|
||||
..Default::default()
|
||||
};
|
||||
let primary_bytes = serde_json::to_vec(&primary).expect("bootstrap should encode");
|
||||
save_config(store.clone(), DATA_USAGE_OBJ_NAME_PATH.as_str(), primary_bytes.clone())
|
||||
.await
|
||||
.expect("bootstrap should persist");
|
||||
save_config(
|
||||
store.clone(),
|
||||
DATA_USAGE_OBSERVED_OBJ_NAME_PATH.as_str(),
|
||||
serde_json::to_vec(&observed).expect("observation should encode"),
|
||||
)
|
||||
.await
|
||||
.expect("observation should persist");
|
||||
invalidate_data_usage_snapshot_cache().await;
|
||||
|
||||
let displayed = super::load_server_info_usage_snapshot(store.clone())
|
||||
.await
|
||||
.expect("server info should load the complete observation");
|
||||
assert_eq!(displayed.usage_snapshot_converged, Some(false));
|
||||
let mut buckets = rustfs_madmin::Buckets::default();
|
||||
let mut objects = rustfs_madmin::Objects::default();
|
||||
let mut versions = rustfs_madmin::Versions::default();
|
||||
let mut delete_markers = rustfs_madmin::DeleteMarkers::default();
|
||||
let mut usage = rustfs_madmin::Usage::default();
|
||||
apply_data_usage_result(Ok(displayed), &mut buckets, &mut objects, &mut versions, &mut delete_markers, &mut usage);
|
||||
assert!(objects.error.is_none());
|
||||
assert_eq!(
|
||||
(buckets.count, objects.count, versions.count, delete_markers.count, usage.size),
|
||||
(1, 3, 4, 1, 6)
|
||||
);
|
||||
let quota = load_data_usage_from_backend_cached(store.clone())
|
||||
.await
|
||||
.expect("quota should retain its bootstrap state");
|
||||
assert!(!quota.is_complete_bucket_usage_snapshot());
|
||||
assert_eq!(
|
||||
read_config(store, DATA_USAGE_OBJ_NAME_PATH.as_str())
|
||||
.await
|
||||
.expect("primary should remain readable"),
|
||||
primary_bytes
|
||||
);
|
||||
invalidate_data_usage_snapshot_cache().await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incomplete_data_usage_is_unavailable_in_server_info() {
|
||||
let mut buckets = rustfs_madmin::Buckets::default();
|
||||
|
||||
@@ -182,6 +182,7 @@ pub(crate) const GET_METADATA_CACHE_REASON_DELETE_MARKER: &str = "delete_marker"
|
||||
pub(crate) const GET_METADATA_CACHE_REASON_DIST_ERASURE: &str = "dist_erasure";
|
||||
pub(crate) const GET_METADATA_CACHE_REASON_INCL_FREE_VERSIONS: &str = "incl_free_versions";
|
||||
pub(crate) const GET_METADATA_CACHE_REASON_INSUFFICIENT_CACHED_QUORUM: &str = "insufficient_cached_quorum";
|
||||
pub(crate) const GET_METADATA_CACHE_REASON_BODY_LAYOUT_REQUIRED: &str = "body_layout_required";
|
||||
pub(crate) const GET_METADATA_CACHE_REASON_META_BUCKET: &str = "meta_bucket";
|
||||
pub(crate) const GET_METADATA_CACHE_REASON_NO_LOCK: &str = "no_lock";
|
||||
pub(crate) const GET_METADATA_CACHE_REASON_NOT_FOUND_OR_EXPIRED: &str = "not_found_or_expired";
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -167,6 +167,12 @@ pub enum DiskError {
|
||||
#[error("method not allowed")]
|
||||
MethodNotAllowed,
|
||||
|
||||
#[error("conditional delete object generation changed")]
|
||||
ConditionalDeleteConflict,
|
||||
|
||||
#[error("guarded batch deletion is not supported by this disk")]
|
||||
GuardedDeleteUnsupported,
|
||||
|
||||
#[error("erasure write quorum")]
|
||||
ErasureWriteQuorum,
|
||||
|
||||
@@ -762,6 +768,8 @@ impl Clone for DiskError {
|
||||
DiskError::PartMissingOrCorrupt => DiskError::PartMissingOrCorrupt,
|
||||
DiskError::NoHealRequired => DiskError::NoHealRequired,
|
||||
DiskError::MethodNotAllowed => DiskError::MethodNotAllowed,
|
||||
DiskError::ConditionalDeleteConflict => DiskError::ConditionalDeleteConflict,
|
||||
DiskError::GuardedDeleteUnsupported => DiskError::GuardedDeleteUnsupported,
|
||||
DiskError::ErasureWriteQuorum => DiskError::ErasureWriteQuorum,
|
||||
DiskError::ErasureReadQuorum => DiskError::ErasureReadQuorum,
|
||||
DiskError::ShortWrite => DiskError::ShortWrite,
|
||||
@@ -819,6 +827,8 @@ impl DiskError {
|
||||
DiskError::Timeout => 0x29,
|
||||
DiskError::InvalidPath => 0x2A,
|
||||
DiskError::RemoteClientUnavailable(_) => 0x2B,
|
||||
DiskError::ConditionalDeleteConflict => 0x2C,
|
||||
DiskError::GuardedDeleteUnsupported => 0x2D,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -867,6 +877,8 @@ impl DiskError {
|
||||
0x29 => Some(DiskError::Timeout),
|
||||
0x2A => Some(DiskError::InvalidPath),
|
||||
0x2B => Some(DiskError::RemoteClientUnavailable(String::new())),
|
||||
0x2C => Some(DiskError::ConditionalDeleteConflict),
|
||||
0x2D => Some(DiskError::GuardedDeleteUnsupported),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -1645,6 +1657,21 @@ mod tests {
|
||||
assert!(rendered.contains("transport error: connection refused"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guarded_delete_errors_preserve_wire_and_io_types() {
|
||||
for (error, code) in [
|
||||
(DiskError::ConditionalDeleteConflict, 0x2C),
|
||||
(DiskError::GuardedDeleteUnsupported, 0x2D),
|
||||
] {
|
||||
assert_eq!(error.to_u32(), code);
|
||||
assert_eq!(DiskError::from_u32(code), Some(error.clone()));
|
||||
let wire: rustfs_protos::proto_gen::node_service::Error = error.clone().into();
|
||||
assert_eq!(DiskError::from(wire), error);
|
||||
let io_error: std::io::Error = error.clone().into();
|
||||
assert_eq!(DiskError::from(io_error), error);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_client_unavailable_wire_roundtrip_keeps_variant_and_detail() {
|
||||
let original = DiskError::RemoteClientUnavailable("dial tcp: connection refused".to_string());
|
||||
|
||||
@@ -131,6 +131,15 @@ pub fn build_write_quorum_failure_summary(
|
||||
|
||||
fn dominant_error_label(errors: &[Option<Error>], ignored_errs: &[Error], dominant_error: Option<&Error>) -> &'static str {
|
||||
let Some(dominant_error) = dominant_error else {
|
||||
// The reducer also returns None when every failure is ignored for
|
||||
// quorum voting. That case contains no successful nil entry.
|
||||
if !errors.is_empty() && errors.iter().all(Option::is_some) {
|
||||
return if errors.iter().all(|error| error.as_ref() == Some(&Error::DiskNotFound)) {
|
||||
"disk_not_found"
|
||||
} else {
|
||||
"all_ignored"
|
||||
};
|
||||
}
|
||||
return "nil_dominated";
|
||||
};
|
||||
|
||||
@@ -387,9 +396,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_write_quorum_failure_summary_all_ignored_is_nil_dominated() {
|
||||
// All disks offline (all DiskNotFound). The dominant error must be None and
|
||||
// the label must be "nil_dominated" rather than falling back to "other_error".
|
||||
fn test_build_write_quorum_failure_summary_all_missing_is_disk_not_found() {
|
||||
// Ignoring unavailable disks for quorum reduction must not imply a
|
||||
// successful nil entry in the diagnostic label.
|
||||
let errors = vec![
|
||||
Some(Error::DiskNotFound),
|
||||
Some(Error::DiskNotFound),
|
||||
@@ -397,7 +406,21 @@ mod tests {
|
||||
];
|
||||
let summary = build_write_quorum_failure_summary(&errors, OBJECT_OP_IGNORED_ERRS, 2);
|
||||
assert_eq!(summary.dominant_error, None);
|
||||
assert_eq!(summary.dominant_error_label, "nil_dominated");
|
||||
assert_eq!(summary.achieved, 0);
|
||||
assert_eq!(summary.dominant_error_label, "disk_not_found");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_write_quorum_failure_summary_all_ignored_has_no_success() {
|
||||
let errors = [Some(Error::FaultyDisk), Some(Error::DiskNotFound)];
|
||||
let summary = build_write_quorum_failure_summary(&errors, OBJECT_OP_IGNORED_ERRS, 2);
|
||||
assert_eq!(summary.dominant_error, None);
|
||||
assert_eq!(summary.achieved, 0);
|
||||
assert_eq!(summary.dominant_error_label, "all_ignored");
|
||||
assert_eq!(
|
||||
reduce_write_quorum_errs(&errors, OBJECT_OP_IGNORED_ERRS, 2),
|
||||
Some(Error::ErasureWriteQuorum)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -215,12 +215,6 @@ pub fn rename_std(from: impl AsRef<Path>, to: impl AsRef<Path>) -> io::Result<()
|
||||
std::fs::rename(from, to)
|
||||
}
|
||||
|
||||
#[cfg(any(not(windows), test))]
|
||||
#[tracing::instrument(level = "debug", skip_all)]
|
||||
pub async fn read_file(path: impl AsRef<Path>) -> io::Result<Vec<u8>> {
|
||||
fs::read(path.as_ref()).await
|
||||
}
|
||||
|
||||
// Bucket existence cache - reduces statx syscalls for repeated bucket checks
|
||||
|
||||
/// Cache for bucket directory existence checks.
|
||||
@@ -584,28 +578,6 @@ mod tests {
|
||||
assert_eq!(content, "test content");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_read_file() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let file_path = temp_dir.path().join("test_read.txt");
|
||||
|
||||
let test_content = b"This is test content for reading";
|
||||
tokio::fs::write(&file_path, test_content).await.unwrap();
|
||||
|
||||
// Read file
|
||||
let read_content = read_file(&file_path).await.unwrap();
|
||||
assert_eq!(read_content, test_content);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_read_file_nonexistent() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let file_path = temp_dir.path().join("nonexistent.txt");
|
||||
|
||||
// Should fail for non-existent file
|
||||
assert!(read_file(&file_path).await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_same_file() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
// 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.
|
||||
|
||||
//! Bucket-scoped observations distinguish exact metadata reads from walks.
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
pub(crate) struct Counts {
|
||||
pub walks: usize,
|
||||
pub reads: Vec<String>,
|
||||
}
|
||||
|
||||
fn observers() -> &'static Mutex<HashMap<String, Arc<Mutex<Counts>>>> {
|
||||
static OBSERVERS: OnceLock<Mutex<HashMap<String, Arc<Mutex<Counts>>>>> = OnceLock::new();
|
||||
OBSERVERS.get_or_init(Mutex::default)
|
||||
}
|
||||
|
||||
pub(crate) struct Observer {
|
||||
bucket: String,
|
||||
counts: Arc<Mutex<Counts>>,
|
||||
}
|
||||
|
||||
impl Observer {
|
||||
pub fn new(bucket: &str) -> Self {
|
||||
let counts = Arc::new(Mutex::new(Counts::default()));
|
||||
assert!(
|
||||
observers()
|
||||
.lock()
|
||||
.expect("I/O observers lock")
|
||||
.insert(bucket.to_owned(), counts.clone())
|
||||
.is_none(),
|
||||
"each test must use its own bucket"
|
||||
);
|
||||
Self {
|
||||
bucket: bucket.to_owned(),
|
||||
counts,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> Counts {
|
||||
self.counts.lock().expect("I/O counts lock").clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Observer {
|
||||
fn drop(&mut self) {
|
||||
observers().lock().expect("I/O observers lock").remove(&self.bucket);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn observe(bucket: &str, object: Option<&str>) {
|
||||
let observer = observers().lock().expect("I/O observers lock").get(bucket).cloned();
|
||||
if let Some(observer) = observer {
|
||||
let mut counts = observer.lock().expect("I/O counts lock");
|
||||
match object {
|
||||
Some(object) => counts.reads.push(object.to_owned()),
|
||||
None => counts.walks += 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
+4476
-635
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user